diff --git a/.claude/skills/approval-module/SKILL.md b/.claude/skills/approval-module/SKILL.md index 8ced79991c..5b20570cc7 100644 --- a/.claude/skills/approval-module/SKILL.md +++ b/.claude/skills/approval-module/SKILL.md @@ -5,8 +5,8 @@ description: >- 覆盖统一审批网关、多场景引擎、多节点流转、outbox 业务执行、站内信通知、异常处理。 迭代审批功能或修复审批相关 Bug 前先读本 skill,可直接定位架构与代码锚点,无需全仓搜索。 TRIGGER when: 用户要改动/修复"审批""审批中心""approval"相关功能(菜单权限申请、频道订阅审批、 - 知识空间加入审批、审批流程/节点配置、异常处理、outbox/Celery 执行),或排查审批通过后业务未生效、 - 审批人看不到任务、站内信未发等问题。 + 知识空间加入审批、应用发布审批、审批流程/节点配置、异常处理、outbox/Celery 执行),或排查审批通过后业务未生效、 + 审批人看不到任务、站内信未发、撤回被 18118 拒等问题。 --- # 审批模块(审批中心 F025) @@ -18,7 +18,8 @@ description: >- - 主流程分支逻辑(`ApprovalGate.request_or_pass` 的 pass/flow/exception 分流、`decide_task` / `_advance_after_node_approved` 的节点流转)→ 更新 [§2 架构与主流程](#2-架构与主流程) - 新增/删除/重命名服务文件或关键方法 → 更新 [§3 代码锚点](#3-代码锚点) -- 新增/删除预置场景或改动其触发入口、Handler → 更新 [§4 预置场景](#4-预置场景) +- 新增/删除预置场景或改动其触发入口、Handler、**seed 内容**(`approval_seed_service.DEFAULT_APPROVAL_SCENARIO_SEEDS`)→ 更新 [§4 预置场景](#4-预置场景) +- **改动 `approver_resolver` 的任一来源解析语义** → 更新 [§10 配置要点](#10-配置要点),并当作**跨场景行为变更**处理:所有配了该来源的既有节点当场受影响,须跑全场景回归 + release note 声明 - 数据库表/状态枚举变化 → 更新 [§5 数据库表](#5-数据库表) - API 路由增删改 → 更新 [§7 API 列表](#7-api-列表) - 站内信触发时机/接收人变化 → 更新 [§8 站内信通知矩阵](#8-站内信通知矩阵) @@ -50,6 +51,7 @@ ApprovalGate.request_or_pass() ← 统一网关,所有场景从这里 │ ┌────┴───────────────────────────┐ │ pass 分支 (route_type=pass) │ → instance(APPROVED) + outbox → Celery → on_approved() → EXECUTED + │ └ 场景 mandatory_approval=True │ → 当作「无分支命中」,落 EXCEPTION(见下) │ flow 分支 (route_type=flow) │ → instance(PENDING) + 首节点 task(PENDING) → 等待审批人 │ 无分支命中 │ → instance(EXCEPTION, route_missing) + 通知管理员 │ 审批人解析为空 │ → instance(EXCEPTION, approver_empty) + 通知管理员 @@ -63,8 +65,21 @@ ApprovalCenterService.decide_task() └── 无后续节点(最后节点) → instance(APPROVED) + outbox → Celery → EXECUTED + 通知申请人 拒绝 → instance(REJECTED) + 通知申请人 撤回 → instance(WITHDRAWN) + 通知有 task 的审批人 + └── ⚠️ 前置守卫:instance 非 PENDING 一律拒 18118(见下) + 业务对象被删 → cancel_instance_by_business() → instance(CANCELLED) + 通知有 task 的审批人(排除操作人) ``` +**必审场景不接受免审分支(2026-09-18 加,PRD-1 INV-34)**:`ApprovalScenarioPreset` 新增 `mandatory_approval` 字段,`app_publish_request` 置 `True`,其余三个预置场景保持 `False`。**声明写在 preset 上而不是在引擎里硬编码场景码**——审批中心是通用模块,不该认识某个业务场景的名字。两道闸: + +1. **配置面拒绝**:`ApprovalScenarioAdminService._assert_route_type_allowed()` 在 `create_route` **与** `update_route` 两处校验,必审场景配 `route_type=pass` 答 **18119**。两处都要,否则「建不了 pass 但能把 flow 改成 pass」。注册表不认识的 scenario_code(管理员手工新建的场景)不受限——它们没做过这个承诺。 +2. **网关兜底**:`ApprovalGate._forbids_pass()`,匹配到的 pass 路由若属必审场景,**当作没匹配到**(落 `route_missing` 异常 + 通知管理员),不是抛错也不是放行。这一道不是冗余:路由行还能从手工 UPDATE、从改动前的备份恢复进来,而对必审场景来说「静默自动通过并上线」是最坏的结果,落异常队列让管理员去修才是对的。 + +⚠️ 这条**只收紧 `app_publish_request` 一个场景**。菜单权限 / 频道订阅 / 知识空间加入三个场景的 pass 分支照常可配可用——它们一直允许免审,跟着收紧等于给存量租户一个无声的行为变更。回归护栏在 `test/approval/test_mandatory_approval_scenario.py`(6 例,含「其它场景仍可配 pass」与「其它场景的 pass 仍正常放行」两条反向断言)。 + +**`withdraw_instance` 的终态守卫(2026-08-19 加,F055 T051 / AC-22)**:`withdraw_instance` 过去**只校验 `applicant_user_id`、不校验实例状态**,于是已 APPROVED / REJECTED / CANCELLED 的单子被直接打 API 也能「撤回」,`on_withdrawn` 照样触发——落到应用发布场景就是**已上线版本的 `app_version.terminal_state` 被反复改写成 `withdrawn`**。现在守卫是 `if instance.status != PENDING: raise ApprovalInstanceNotPendingError()`(**18118**,approval 段),位置在 `applicant_user_id` 校验**之后**、任何写入**之前**: +- 排在 applicant 之后是刻意的——反过来的话,陌生人打一个终态单子会拿到「已结束」而不是「无权限」,等于把「哪些单子还开着」探测出去。 +- ⚠️ **这是审批模块公共 API 的行为收紧,对菜单权限申请 / 频道订阅 / 知识空间加入三个既有场景同样生效**。`executing` / `executed` / `execute_failed` / `exception` 四个审批后状态同样被拒(「已结束不能撤回」)。回归护栏在 `test/app_publish/test_withdraw_guard.py`(15 例,含频道订阅与知识空间加入两个既有场景)。 + **多节点 / 会签**:`_advance_after_node_approved()` 实现顺序流转。 - OR 节点(`node_mode=or`):任一人通过即把同节点其余 PENDING task 置 SKIPPED 并 advance。 - AND 节点(`node_mode=and`):同节点全部通过才 advance。 @@ -72,6 +87,8 @@ ApprovalCenterService.decide_task() **异常实例也留痕**:`_create_exception_result()` 在创建异常后会补写 `action='approval.request.submit'` 审计日志(与正常 PENDING/PASS 分支一致)。 +**执行失败单重复申请(2026-11-03)**:`execute_failed` 仍参与同租户/场景/业务键/申请人的去重,避免旧执行可重试时又创建第二份授权。`ApprovalGate.request_or_pass()` 命中此状态返回业务错误 **18120**,不再返回 pending,也不新建实例、任务或 outbox。申请人应由管理员通过既有异常入口处理;取消旧异常后可重新申请。其它 pending/exception 去重不变。知识空间详情与广场通过只读 `approval/application/request_state.py::get_failed_request_keys()`(固定申请人租户,按100键分批,取最新在途记录)剔除这种假待审显示;无有效 visible 时使用既有 not_subscribed,当前有效授权仍显示已加入。读取不会重试、取消或修改历史审批/成员/授权。 + --- ## 3. 代码锚点 @@ -83,13 +100,15 @@ ApprovalCenterService.decide_task() | 文件 | 职责 | 关键方法 | |------|------|---------| | `approval/domain/services/approval_gate.py` | 统一入口:路由匹配、实例创建、pass/pending/exception 分流 | `request_or_pass()`、`_create_exception_result()`、`_notify_admins_of_exception()` | -| `approval/domain/services/approval_center_service.py` | 用户端:任务列表/详情、同意/拒绝、撤回、菜单申请、多节点流转 | `decide_task()`、`_advance_after_node_approved()`、`_dispatch_outbox()`、`_send_approval_notify()` | +| `approval/domain/services/approval_center_service.py` | 用户端:任务列表/详情、同意/拒绝、撤回、**业务侧取消**、菜单申请、多节点流转 | `decide_task()`、`_advance_after_node_approved()`、`_dispatch_outbox()`、`_send_approval_notify()`、`withdraw_instance()`(**带 18118 终态守卫**)、**`cancel_instance_by_business()`**(公共 API,见 §4.5) | | `approval/domain/services/approval_exception_service.py` | 管理端异常处理:重试/指定审批人/跳过节点/取消/标记完成 | `assign_approvers()`、`_resolve_exception_node()` | | `approval/domain/services/approval_outbox_service.py` | outbox 执行与重试;成功后置 instance=EXECUTED | `execute_outbox()`、`retry_outbox()` | | `approval/domain/services/approval_scenario_admin_service.py` | 管理端:场景/分支/流程/节点配置、异常列表 | — | -| `approval/domain/services/approver_resolver.py` | 解析审批人来源 `direct_user` / `department_admin` / `tenant_admin` | `resolve_approvers_from_sources()` | +| `approval/domain/services/approver_resolver.py` | 解析审批人来源 `direct_user` / `department_admin` / `tenant_admin` | `resolve_approvers_from_sources()`、**`resolve_tenant_admin_user_ids()`** | +| `approval/domain/services/approval_seed_service.py` | **预置场景 seed(每租户)**:`DEFAULT_APPROVAL_SCENARIO_SEEDS` 三条 × 五行(scenario / route / flow / flow_version / node) | `seed_approval_scenarios_for_tenant()`、`seed_approval_scenarios_in_session()` | | `approval/domain/services/approval_registry.py` | 场景预置目录 + handler 注册表 | `with_default_presets()`、`register_handler()`、`get_handler()` | -| `approval/domain/services/approval_runtime_handler_factory.py` | 为 outbox 执行 / 多节点 advance 重新构造运行时 handler | `build_runtime_handler(scenario_code)` | +| `approval/domain/services/approval_runtime_handler_factory.py` | 为 outbox 执行 / 多节点 advance 重新构造运行时 handler;审批详情通过原阅读资格后追加场景当前事实 | `build_runtime_handler(scenario_code)`、`read_current_scenario_detail()` | +| `approval/application/execution_source.py` | 业务侧只读审批投影 | `get_business_approval()` / `get_business_approvals()`(按 100 条分批,版本记录复用) | | `approval/domain/services/approval_notification_service.py` | 站内信统一封装 | `notify_user()` / `notify_users()` / `notify_admins()` | | `approval/domain/services/user_menu_access_service.py` | 菜单授权增删查,含父级菜单依赖自动补全 | `grant_menu_access()`、`revoke_menu_access()`、`ensure_application_allowed()` | | `approval/domain/services/approval_service.py` + `message_handler.py` | **旧系统(已废弃)**:部门知识空间文件上传审批(`approval_request` 表),与审批中心独立,仅兼容存量、勿新增功能 | `ApprovalService.decide_request()` | @@ -99,19 +118,31 @@ ApprovalCenterService.decide_task() | `approval/api/endpoints/approval_admin.py` | Platform 管理 API(`/api/v1/approval/admin/...`) | — | | `approval/api/endpoints/approval.py` | 旧系统 legacy API(`/api/v1/approval/requests/...`),**已废弃** | — | -### 三个场景 Handler +### 应用审读窄端口(F114) + +`approval/application/app_review.py::pending_app_reviews` 返回冻结的 tenant/app/version/instance/task 值对象;仓储 `app_review_repository.py` 只查询指定真实自然人、指定 app、实例和任务均 pending 的记录。该个人任务查询不继承页面管理视角,源码读取随后在验证过的任务租户内执行并恢复调用前上下文。服务账号不能借其归属人的审批任务,临时资格只覆盖待审版本和当前正式比较基线;普通长期源码读取改用 app edit,不变成长期授权。 + +### 应用发布当前可见范围(F115) + +任务详情、个人实例详情、管理实例详情在原租户与审批阅读门禁通过后,调用 `read_current_scenario_detail()`。仅 `app_publish_request` 使用 Handler 的只读 `read_current_detail()`,其它场景不触发动态查询。新增同级 `current_visibility`;`detail_snapshot`、`payload_snapshot` 仍是冻结值,不回写。应用发布域核对实例绑定的 app/tenant,再经 F048 当前有效 `use` 来源和实时主体资格生成摘要;读取不完整时返回 `unavailable`,不误报“仅负责人可见”。此窄例外不授予长期 manage_permission、审批处理资格或其它资源能力。 + +### 四个场景 Handler | 文件 | 类 | |------|----| | `approval/domain/services/menu_access_handler.py` | `MenuAccessApprovalHandler` | | `approval/domain/services/channel_subscribe_scenario_handler.py` | `ChannelSubscribeScenarioHandler` | | `approval/domain/services/knowledge_space_subscribe_scenario_handler.py` | `KnowledgeSpaceSubscribeScenarioHandler` | +| **`app_publish/domain/services/app_publish_scenario_handler.py`**(⚠️ 不在 `approval/` 下,归 F055 应用发布域) | `AppPublishScenarioHandler` | + +> ⚠️ Handler 是**鸭子类型**、没有基类可继承。完整协议九个方法:`validate` / `build_title` / `build_detail` / `build_business_link` / `resolve_approvers` / `on_approved` / `on_rejected` / `on_withdrawn` / `on_cancelled`。少一个的表现是审批时引擎深处抛 `AttributeError`——新场景务必照抄这九个。 +> ⚠️ **`resolve_approvers` 必须显式转调 `resolve_approvers_from_sources`**:网关调它之后不再做别的,`department_admin` / `tenant_admin` / `direct_user` 这些通用来源**只因为 handler 主动调了那个函数才被解析到**。返回 `[]` 的症状与「管理员没配审批人」完全一样。 ### 前端 | 文件 | 职责 | |------|------| -| `src/frontend/client/src/components/messageApproval/MessageApprovalDialog.tsx` | 「消息与审批」统一弹窗外壳(导航栏:我的审批 / 我的申请 / 通知 + 徽标) | +| `src/frontend/client/src/pages/settings/SettingsPage.tsx` | 「设置」页:承载我的审批 / 我的申请 / 通知三个区的外壳(取代旧的审批中心弹窗) | | `src/frontend/client/src/components/approval/ApprovalPane.tsx` | 我的审批 / 我的申请 列表 + 详情 + 同意/拒绝/撤回/撤销授权 | | `src/frontend/client/src/components/approval/ApprovalDetailPanels.tsx` | 任务详情 / 实例详情面板(基础信息、业务内容、进度时间轴) | | `src/frontend/client/src/components/approval/approvalPresentation.tsx` | 审批公共展示层(状态徽章、时间轴节点、信息网格、格式化) | @@ -128,9 +159,13 @@ ApprovalCenterService.decide_task() ## 4. 预置场景 -三个场景由 `ApprovalRegistry.with_default_presets()` 注册(仅是"目录/下拉来源",**不等于已启用**)。每个场景的业务入口在创建 `ApprovalGateRequest` 时**都需要传 `applicant_department_id`**(供 `department_admin` 审批人来源使用,查 `UserDepartmentDao.aget_user_primary_department()`)。 +四个场景由 `ApprovalRegistry.with_default_presets()` 注册(仅是"目录/下拉来源",**不等于已启用**)。每个场景的业务入口在创建 `ApprovalGateRequest` 时**都需要传 `applicant_department_id`**(供 `department_admin` 审批人来源使用,查 `UserDepartmentDao.aget_user_primary_department()`)。 -**首次部署自动落库**:4.2 频道订阅审批、4.3 知识空间加入审批由 `common/init_data.py::_init_default_approval_scenarios()`(在 `init_default_data` 内)为默认租户幂等 seed——各建「默认分支(catch-all, route_type=flow) → 默认流程 → 单节点(node_mode=or 或签)」,审批人来源即资源 owner+manager(频道 `channel_owner`/`channel_manager`,知识空间 `knowledge_space_owner`/`knowledge_space_manager`),场景 `enabled=True`。按 `tenant_id+scenario_code` 判存在即跳过,绝不覆盖人工改动。菜单权限申请(4.1)**不**自动 seed。新租户不自动 seed,需管理后台手工配置。 +**首次部署自动落库**(2026-08-19 重写,随 F055):真身已从 `common/init_data.py::_init_default_approval_scenarios()` 迁到 **`approval/domain/services/approval_seed_service.py`**,`init_data` 那个函数现在只是转调的薄壳。三条 seed(`DEFAULT_APPROVAL_SCENARIO_SEEDS`)= 4.2 频道订阅审批 · 4.3 知识空间加入审批 · **4.4 应用发布**,各建五行「场景 → 默认分支(catch-all `match_config={}`, route_type=flow) → 默认流程 → 流程版本 → 单节点(node_mode=or 或签)」,场景 `enabled=True`。审批人来源:频道 `channel_owner`/`channel_manager`,知识空间 `knowledge_space_owner`/`knowledge_space_manager`,**应用发布 `department_admin` + `tenant_admin`**。菜单权限申请(4.1)**仍不**自动 seed(它是「管理员主动打开」的能力,在 seed 里偷偷启用等于把产品决策夹带进一次 seed 改动)。 + +⚠️ **两条已变的事实,别再照旧文办事**: +1. **租户是参数不是常量**。旧实现在六处硬编码默认租户,结果**开机之后新建的租户一个场景都没有**;现在两条建租户路径(`tenant_service.py:163`、`tenant_mount_service.py:245`)都调 `seed_approval_scenarios_for_tenant(tenant_id)`。"新租户需管理后台手工配置"这句话已经不成立。 +2. **幂等键仍是 `(tenant_id, scenario_code)`,且这就是 AC-19 的全部**:场景行存在 → **整条 seed 跳过**(含流程、节点、分支),管理员改过的审批人 / 加过的节点不会被下次平台升级重置。 ### 4.1 菜单权限申请 (`menu_access_request`) - **入口**:Client `/workspace/menu-unavailable?plugin=xxx` → `POST /api/v1/approval/menu-access/apply` @@ -154,6 +189,29 @@ ApprovalCenterService.decide_task() - PENDING 时调 `_send_space_approval_notification()` 通知审批人 - **不变量:先过网关、再落 membership。** `subscribe_space` 对 APPROVAL 空间必须先 `await gate.request_or_pass()`,按 gate 结果(pass→ACTIVE / pending·exception→PENDING)才通过 `_persist_space_member()` 写 `space_channel_member`。**严禁在调网关前预写 PENDING membership**——否则场景未配置/未启用时网关 `raise ApprovalScenarioDisabledError`,但 PENDING 行已落库,下次点"关注"会被 `subscribe_space` 顶部"已 PENDING 直接返回 pending"的早退分支短路,掩盖错误(首次报错、二次假成功)。无场景时每次点击都应一致报错。 +### 4.4 应用发布审批 (`app_publish_request`) — F055,2026-08-19 新增 +- **入口**:`app_publish/domain/services/publish_approval_service.py`(`bisheng deploy` 管线的审批节点;**每次发布必审,无免审分支**,故 preset 的 `condition_fields=[]`) +- **Handler**:`AppPublishScenarioHandler`(`app_publish/domain/services/app_publish_scenario_handler.py`,**不在 `approval/` 下**) +- **审批人来源**:`department_admin`(owner 的**主部门**管理员)**∪** `tenant_admin`,同一 OR 节点或签;preset 另放行 `direct_user`,否则管理员改配后再也放不回一个具名审批人(管理页的来源下拉就是从 `approver_source_types` 建的) +- **`business_key` = `deployment_id`**:一次发布尝试 = 一个审批单 +- **申请人是 owner 自然人,不是提交用的服务账号**——审批单的 `applicant_user_id` 取 `resource_owner_user_id` +- **不得自审**:「提交人不能批自己的发布」这条过滤在 **handler 出口**做,不在共享 resolver 里做——频道订阅与知识空间加入要的恰恰相反,一个 resolver 两套策略,策略跟着场景走 +- **首节点站内信由 F055 自己发**(网关只建 task + 写审计、不发消息,见 §8 注) +- ⚠️ **终态回调的成功语义**:outbox 只按「有没有抛异常」判成败。「待上线(资源不足 / 拉起失败)」是**产品终态**(有应用态、有通知、有发布面呈现)→ **必须正常返回**;只有系统坏了才抛。判据是「这个申请会不会自己变好——会,就 return;不会,就 raise」 + +**2026-10-24 F055 环境变量摘要**:`publish_approval_service._capabilities_payload(..., app_id=...)` 使用 F055 `secret_reference_status` 追加 `{kind:"secret",name,revoked:false,reason:"ok"|"missing_value"}`。值、掩码与加密内容不进入审批快照。既有 `AppPublishScenarioHandler.build_detail` 传递该能力数组;客户端审批卡按“提交时已配置/未配置”呈现,读侧不会取得写权限。后端生产者的定向测试为 `test_app_secret_declaration::test_approval_summary_contains_only_names_and_missing_status`;客户端组件执行受本地 canvas 原生依赖缺失限制,未宣称运行通过。 + +### 4.5 业务侧取消:`cancel_instance_by_business()` — 公共 API,2026-08-19 新增 +`ApprovalCenterService.cancel_instance_by_business(*, instance_id=None, scenario_code=None, business_resource_type=None, business_resource_id=None, tenant_id=None, reason=None, operator_user_id=0, operator_user_name=None)` + +**用途**:审批单指向的业务对象**已经不存在了**(首例:owner 删除了托管应用,F055 AC-35)→ 把在途单置 `CANCELLED`、PENDING task 置 `CANCELLED`、写 `action_log('cancelled')` + 审计 `approval.request.cancel`、通知**有 task 的审批人**(排除操作人本人)、最后调 `handler.on_cancelled()`。 + +四点容易踩: +- **两种寻址**:按 `instance_id`,或按业务对象(`scenario_code` + `business_resource_type` + `business_resource_id` + `tenant_id`)——删除钩子只知道自己的 id、不知道审批的存在,所以第二种是给它用的。 +- **找不到在途单返回 `None`,不是报错**:「删一个从来没提交过发布的应用」是常态。 +- **它不是 `cancel_exception_api`**:那个从**异常记录**起手(碰不到健康的 pending 单)、且通知**申请人**。这里申请人就是刚按下删除的人,他知道;需要被告知的是审批人——否则待办里留着一个指向已消失应用的任务。 +- **`on_cancelled` 抛异常被吞**(记 log):审批侧此时已经一致,钩子失败不该让实例停在半取消状态。 + --- ## 5. 数据库表 @@ -161,7 +219,7 @@ ApprovalCenterService.decide_task() | 表名 | 说明 | 关键状态字段 | |------|------|------------| | `approval_scenario` | 租户下启用的审批场景 | `enabled` | -| `approval_route_rule` | 场景下条件分支(按 `sort_order` 匹配) | `route_type: pass/flow`、`enabled` | +| `approval_route_rule` | 场景下条件分支(按 `sort_order` 匹配) | `route_type: pass/flow`(必审场景只接受 `flow`,见 §2)、`enabled` | | `approval_flow_definition` | 审批流程定义头 | — | | `approval_flow_version` | 流程版本快照 | `is_active` | | `approval_node_definition` | 流程版本内顺序节点 | `node_order`、`node_mode: or/and`、`approver_config` | @@ -175,6 +233,8 @@ ApprovalCenterService.decide_task() > 模型定义见 `approval/domain/models/approval_instance.py`、`approval_scenario.py`、`user_menu_access.py`。 > `approval_instance.latest_approver_user_id` 字段已定义但**当前从未赋值**(已知限制,需要时在 `decide_task` 里补)。 +> **2026-08-19 补**:`approval_instance.status` 的取值集合没变,但 `cancelled` 现在**多了一条到达路径**——除既有的「异常被管理员取消」外,业务对象消失时由 `cancel_instance_by_business()` 直接置(§4.5)。同时 `pending → withdrawn` 现在**只能从 `pending` 出发**(18118 守卫,§2),此前任何状态都能被改写成 `withdrawn`。 +> **无新表、无新枚举值**:应用发布场景完全复用既有九张表,`app_publish_request` 只是 `approval_scenario.scenario_code` 的一个新取值。 --- @@ -211,7 +271,7 @@ GET /approval/my-tasks/{task_id} # 任务详情 POST /approval/tasks/{task_id}/decision # 同意/拒绝 GET /approval/my-requests # 我的申请(申请人视角) GET /approval/instances/{instance_id} # 实例详情(tasks + flow_nodes + action_logs) -POST /approval/instances/{instance_id}/withdraw # 撤回 +POST /approval/instances/{instance_id}/withdraw # 撤回(⚠️ 仅 PENDING;非 PENDING 答 18118,见 §2) GET /approval/menu-access/pending-check # 菜单申请前置校验 POST /approval/menu-access/apply # 菜单权限申请 POST /approval/menu-access/{instance_id}/revoke-grant # 撤销菜单授权(审批人) @@ -241,6 +301,10 @@ POST /approval/admin/exceptions/{exception_id}/retry # 重试/指定审 POST /approval/admin/exceptions/{exception_id}/cancel # 取消审批(必须填原因) ``` +> **2026-08-19:路由本身零增删**(应用发布场景没有自己的审批端点,它复用上面这一套)。唯一的对外行为变化是 `withdraw` 多了 **18118**「该审批申请已结束,无法撤回」——错误码归 **approval 段 181xx**(`common/errcode/approval.py`),三语文案在 `src/frontend/packages/locales/src/api_errors/`。⚠️ **181 是审批引擎的段位,不是某个场景 owner 的段位**:在这里加码会同时收紧菜单权限 / 频道订阅 / 知识空间加入 / 应用发布四个场景,必须有全场景回归(`docs/constitution.md` C5 已登记该口径)。 + +> **2026-09-18**:管理端分支配置多了 **18119**「该审批场景不允许配置免审分支」——只对 `mandatory_approval=True` 的场景(当前仅应用发布)生效,其余三场景的 pass 分支不受影响(§2)。前端 `ApprovalPage` 对这类场景直接不渲染「无需审批」选项,`list_presets` 响应里的 `mandatory_approval` 是它的判据。 + ### 旧系统 legacy(`/approval/requests`、`/approval/department-knowledge-space`)— ⚠️ 已废弃 部门知识空间文件上传审批,独立于审批中心,见 `approval.py`。**已废弃**,仅兼容存量数据,不要在此新增/扩展接口。 @@ -248,6 +312,9 @@ POST /approval/admin/exceptions/{exception_id}/cancel # 取消审批( ## 8. 站内信通知矩阵 +审批深链:发布首节点通知以 `instance_id` 定位,由每位接收人解析自己的 task,不能向所有人共用首个 task_id。撤回及业务取消通知发送给原审批人,进入「我的审批」已处理项;同栏内更换目标必须重新读取详情,筛选切换不能覆盖通知指定的目标。目标不可用时不选取另一条无关记录。 + + | 触发时机 | 接收人 | 实现位置 | |----------|--------|---------| | 创建审批任务(菜单申请) | 审批人 | `ApprovalCenterService._send_menu_access_approval_messages()` | @@ -259,6 +326,10 @@ POST /approval/admin/exceptions/{exception_id}/cancel # 取消审批( | 申请撤回 | 有 task 的审批人 | `ApprovalCenterService.withdraw_instance()` | | 异常产生(route_missing/approver_empty) | 管理员(AdminRole) | `ApprovalGate._notify_admins_of_exception()` / `ApprovalNotificationService.notify_admins()` | | 异常取消 | 申请人 | `ApprovalExceptionService.cancel_exception_api()` | +| **应用发布审批创建(PENDING)** | 审批人 | `app_publish/domain/services/publish_notification_service.py::notify_approvers_of_new_task()`(`approval_task_pending`) | +| **业务对象消失致取消**(首例:应用被 owner 删除) | 有 task 的审批人,**排除操作人本人** | `ApprovalCenterService.cancel_instance_by_business()`(`approval_instance_cancelled`,2026-08-19 新增 action_code) | +| **应用上线终检遇容量不足 → 待上线** | owner + 超管 + 租户管理员(**无条件 union**) | `publish_notification_service.notify_pending_online()`(`app_publish_pending_capacity`,**非审批类**) | +| **应用拉起 / 探活失败 → 待上线** | 同上 | `publish_notification_service.notify_pending_online()`(`app_publish_deploy_failed`,**非审批类**) | > **待办类通知不进通知列表**:`approval_task_pending` / `request_menu_access` / `request_channel` / > `request_knowledge_space`(`MessageService.APPROVAL_TODO_ACTION_CODES`)只属于【我的审批-待我处理】。 @@ -266,6 +337,12 @@ POST /approval/admin/exceptions/{exception_id}/cancel # 取消审批( > > 注:申请人侧"通过"通知是在**最后节点 finalize** 时发的(即审批通过即通知),不等 outbox 业务真正执行完。若要"业务执行成功"的精确通知,需在 `execute_outbox` 成功回调里补。 +> ⚠️ **网关不发首节点站内信**(`approval_gate.py` 只建 task + 写审计)——**每个场景都在自己那侧补发**,四个场景无一例外。新加场景漏了这一步的症状是「审批人永远不知道有活」,而一切正常、无任何报错。 + +> ⚠️ **非审批类通知的 `message_type` 必须中性**(`MessageTypeEnum.NOTIFY`,走 `ApprovalNotificationService` → `send_generic_notify` 天然满足)。client 的 `isApprovalMessageType`(`NotificationsDialog.tsx:152-156`)只看 `message_type` 是不是 `request` / `approve`,**不看 action_code 白名单**——类型用错会长出一个点了就报错的跳转按钮。两条待上线通知走 `getNotificationText` 的兜底 key `com_notifications_action_{action_code}`,因此前端零改动。 + +> ⚠️ **待上线两类的收件人是无条件 union(`ApprovalNotificationService._get_admin_recipient_ids`),这与审批人解析的条件回退是两码事,别混用**:多通知一个超管无害;多解析出一个审批人 = 多一个能拍板的人。 + --- ## 9. 审批进度时间轴 @@ -303,6 +380,21 @@ action_logs[action!=submitted] ← 撤回/取消等其他日志 ``` `user_names` 由前端保存时写入,用于节点卡片直接显示用户名,避免二次查库。 +### ⚠️ `tenant_admin` 来源的语义已变(2026-08-19,F055 T025 / AC-21)——**对三个既有场景与所有人工配置立即生效** + +| | 旧("务实近似") | 新 | +|---|---|---| +| 解析什么 | `UserRoleDao.aget_roles_user([AdminRole])` = **全站平台超管**,**完全忽略 `tenant_id`** | `TenantAdminService.list_tenant_admins(tenant_id)` = **该租户真正的租户管理员** | +| 多租户下的后果 | 每个租户的审批全压到超管一人;真正被配成租户管理员的人**一条待办都收不到** | 各租户各自的管理员各管各的 | +| Root / 单租户 | 同上(恰好"能用",掩盖了缺陷) | `list_tenant_admins` 对 Root **按构造返回 `[]`**(INV-T3:Root 的权威是全局超管权限,不是租户管理员授予),故**仅 Root 回退平台超管** | + +三条不能改的性质: +1. **回退是条件式的,不是 union**。非 Root 租户没有管理员就是解析为空 → `approver_empty` 异常(管理员看得见、能处理),**往那里补超管正是这次要消灭的缺陷**。 +2. **它不是 `ApprovalNotificationService._get_admin_recipient_ids`**。那个是超管 ∪ 租户管理员的**无条件 union**,且必须保持无条件——它挑的是**通知收件人**(多一个读者无害);审批人解析是反面(多一个解析出的人 = 多一个能拍板的人)。两边任一方向的合并都会破坏另一边的契约。 +3. **`list_tenant_admins` fail-closed**(权限后端不可达时返回 `[]`):结果是一个管理员看得见的 `approver_empty` 异常,严格优于静默放宽「谁可以审批」。 + +> 🔁 **回归口径**:这条改的是**共享 resolver**,所以频道订阅 / 知识空间加入 / 菜单权限申请三个既有场景里凡是配了 `tenant_admin` 来源的节点,**审批人集合当场就变了**。改动这里必须跑全场景回归,并在 release note 里显著声明。 + --- ## 11. 调试指南 @@ -330,6 +422,12 @@ SELECT id, exception_type, status, detail FROM approval_exception WHERE instance ``` 若异常类型是 `approver_empty`:检查 `approval_instance.applicant_department_id` 是否为 NULL,以及节点 `approver_config.sources` 里 `department_admin` 是否依赖部门。 +### "撤回接口返回 18118" +不是 bug:该实例已不是 `PENDING`(已通过 / 已驳回 / 已取消 / 已进入执行态)。查 `SELECT status FROM approval_instance WHERE id=;`。前端应引导用户刷新列表——单子已经有终态了。**这是 2026-08-19 起的新行为,对所有场景生效**(§2)。 + +### "审批人从超管变成了别人 / 超管突然收不到某租户的待办了" +是 `tenant_admin` 来源语义变更的预期结果(§10)。查 `SELECT approver_user_id FROM approval_task WHERE instance_id=;` 与该租户的租户管理员名单。**单租户(Root)部署下仍应解析到平台超管**——若解析为空,先看 `TenantAdminService.list_tenant_admins` 是不是因权限后端不可达而 fail-closed 返回了 `[]`(那会先落 Root 回退,不该为空)。 + ### "频道/知识空间审批通过但成员列表看不到" 检查对应 `sync_direct_channel_user_permissions` / `sync_direct_space_user_permissions` 是否在该激活路径被调用(写 ReBAC/OpenFGA 关系)。若 `instance=executed` 但 `space_channel_member.status` 仍为 `PENDING`,说明 `on_approved` 没真正激活成员(见 §6 的"业务回调不得静默失败"原则)。 @@ -341,3 +439,28 @@ SELECT id, exception_type, status, detail FROM approval_exception WHERE instance ```bash cd src/backend && uv run pytest test/approval/ ``` + +**跨模块的审批测试有两处**(改审批公共 API 时两处都得跑): +```bash +cd src/backend && uv run pytest test/approval test/app_publish +``` +- `test/app_publish/test_withdraw_guard.py` —— 18118 终态守卫(含频道订阅 / 知识空间加入两个既有场景的回归) +- `test/app_publish/test_publish_notification.py` —— 六类事件触达与「消息不承载操作」的发送契约 + +> ⚠️ 2026-08-19 实测:`test/approval` 有 13 例失败,**全部是本批之前就红的既有漂移**(本地 MySQL 未起 4–5 例、fake 与被测 API 漂移 6 例、断言漂移 2 例),不是新引入的。改审批模块前先跑一次拿基线,别把既有红当成自己弄坏的。 + + +## 2026-10-29 管理视角与异常进度修复 + +平台右上角复用 F019 管理视角:非超管管理查询严格限定所属租户,超管只能选择一个有效的具体租户,未选择或失效时回到经验证的所属租户;不改变 JWT 的人员归属和 SDK/RPC 身份。`approval/admin` 加入共享管理查询范围。管理列表按当前视角读取,创建/更新仍携带具体租户;个人审批接口不使用管理视角。F114 管理 middleware 不再向管理列表提供全平台语义;个人任务不受管理视角影响。 + +异常列表进度改用 `GET /api/v1/approval/admin/instances/{instance_id}`:先 `_ensure_admin`,再 `ApprovalCenterService.get_instance_detail(management=True)` 复核选中租户和超管/该租户管理员资格;不要求管理员必须是申请人或审批任务持有人。原个人实例详情仍检查申请/任务归属;新增只读入口不授予同意/拒绝权限。API 锚点:`approval_admin.get_admin_instance_detail`。配对测试:`test/approval/test_admin_instance_progress.py` 与 `test/admin/test_management_view_filtering.py`;未改审批人来源解析和节点流转。 + + +### F114 应用发布候选与业务撤回(2026-10-30,本地实现) + +- `approval/application/app_candidates.py` 与所属仓储核对当前有效自然人及真实管理员全集;应用 handler 消费冻结的归属人和 self_review_user_id,双重排除,仅真实单管理员例外允许自审。 +- `approval_runtime_handler_factory.py::eligible_scenario_candidates` / `validate_scenario_candidates` 仅对应用发布调用候选校验;引擎通过任务、异常指定流程/人和后续节点复用,其他场景沿原候选来源,不改 approver_resolver 通用语义。 +- `approval/application/app_publish.py::withdraw_app_publish` → `ApprovalCenterService.withdraw_app_publish` → `app_publish_withdrawal_repository.py` 在一个事务内锁定目标实例、取消任务、写 withdrawn 动作。目标由发布模块基于冻结 tenant/app/deployment/version/instance 生成;发布模块先重验当前 publish,实际操作者计审计。通用 `withdraw_instance` 仍要求申请人。 +- 平台 Vibe 管理页使用 `POST /api/v1/apps/{app_id}/withdraw`,body 含 expected_instance_id 和可选 reason;不拿申请人身份代发通用撤回。完整确认和终态保护不变。 +- 双用途预览由 F055 public `preview_access` 当前资格端口消费 pending task;临时资格不会写入长期 F048 Grant。真实部署 E2E 尚未执行,以 F114 verification 为准。 diff --git a/.claude/skills/code-review/SKILL.md b/.claude/skills/code-review/SKILL.md deleted file mode 100644 index f8c5668569..0000000000 --- a/.claude/skills/code-review/SKILL.md +++ /dev/null @@ -1,154 +0,0 @@ -# Skill: code-review - -## 描述 - -L2 特性级多维度代码审查。Feature 全部任务完成后执行。 - -## 触发 - -``` -/code-review --base 2.5.0-PM -``` - ---- - -## 审查流程 - -1. 执行 `git diff 2.5.0-PM...HEAD --stat` 获取变更文件列表 -2. 执行 `git diff 2.5.0-PM...HEAD` 获取完整 diff -3. 对照 Feature 的 `spec.md`、`design.md` 和 `tasks.md` -4. 按 7 维度逐一审查 -5. 输出审查报告 - ---- - -## 7 维度审查框架 - -### 维度 1:边界条件 - -| 检查项 | 说明 | -|--------|------| -| null/None 处理 | 外部输入是否校验 None/空字符串 | -| 空集合 | 列表/字典为空时是否正确处理(不抛异常) | -| 数值边界 | 分页 page/size 合法性、ID 为 0/-1 | -| 字符串长度 | 数据库字段长度限制是否在 API 层校验 | -| 超时处理 | 外部调用(LLM/MCP/HTTP)是否设置超时 | -| 分页溢出 | 请求超出总页数时返回空列表而非错误 | - -### 维度 2:权限与认证 - -| 检查项 | 说明 | -|--------|------| -| 认证注入 | 需要认证的端点是否使用 `UserPayload = Depends(UserPayload.get_login_user)` | -| 五级权限链路 | 是否遵循:super_admin → tenant 归属 → tenant admin → ReBAC → RBAC 菜单 | -| PermissionService | 权限检查是否走 `PermissionService.check()` 而非直接查旧表 | -| 资源授权 | 创建资源时是否调用 `PermissionService.authorize()` 写入 owner 元组 | -| tenant_id 隔离 | 跨租户访问是否被阻止(SQLAlchemy event 自动注入) | -| WebSocket 认证 | WS 端点是否使用 `UserPayload.get_login_user_from_ws` | - -### 维度 3:并发安全 - -| 检查项 | 说明 | -|--------|------| -| OpenFGA 双写 | MySQL + OpenFGA 写入是否有失败补偿(failed_tuples 表) | -| 数据库事务 | 多表写入是否在同一事务内 | -| Celery 幂等 | 异步任务是否支持重试不产生副作用 | -| 竞态条件 | 并发创建同名资源是否有唯一约束或乐观锁 | -| 会话状态 | Redis 缓存读写是否考虑过期和并发更新 | - -### 维度 4:信息泄漏 - -| 检查项 | 说明 | -|--------|------| -| 硬编码敏感信息 | 代码中无明文密码/密钥/token | -| 错误信息 | 异常响应不暴露堆栈/SQL/内部路径 | -| 日志脱敏 | logger 输出中敏感字段已脱敏 | -| 前端暴露 | 前端代码不包含后端 IP/密钥/内部 API 路径 | -| tenant_id 泄漏 | API 响应不向前端返回其他租户的 tenant_id | - -### 维度 5:测试覆盖 - -| 检查项 | 说明 | -|--------|------| -| Service 测试 | 核心 Service 方法有单元测试(mock DAO) | -| API 测试 | 新端点有集成测试(happy path + 主要 error path) | -| AC 覆盖 | spec 中每条 AC 都有对应测试或手动验证 | -| 错误路径 | 权限拒绝、参数校验失败等错误路径有测试 | -| 测试质量 | mock 合理,不 mock 掉核心逻辑 | - -> **务实适配**:当前测试基础薄弱,降低阈值但要求核心 Service 方法必须有测试。 -> 前端暂用手动验证替代(tasks.md 中有「手动验证」描述即可)。 - -### 维度 6:代码风格 - -| 检查项 | 说明 | -|--------|------| -| DDD 分层 | 新代码在正确的层级(domain/services vs api/endpoints) | -| 命名一致 | DAO/Service/错误码命名遵循项目约定 | -| 代码重复 | 无复制粘贴式重复逻辑(应提取到 Service 或工具函数) | -| 未使用代码 | 无 dead code、注释掉的代码块、空函数 | -| 格式化 | Python 代码通过 ruff check(hook 自动处理) | - -### 维度 7:文档同步(design.md 现状快照) - -> **目的**:确保 feature 合入后,新 agent 接手时读 design.md 能 5 分钟建立准确认知 —— 没有过期描述、没有缺失的反直觉坑、没有未声明的对外契约。 - -| 检查项 | 说明 | 严重度 | -|--------|------|--------| -| design.md 存在 | feature 目录下有 `design.md`(按 `features/_templates/design.md` 起的稿) | HIGH(缺失直接 NEEDS_FIX) | -| 决策同步 | diff 中新增/替换的关键技术决策(数据格式、配对/匹配策略、同步 vs 异步、二进制依赖等)在 §3 方案对比有记录,且给出"何时该重新考虑" | HIGH | -| 数据流/契约同步 | diff 触及 API 路径 / 请求响应字段 / 内部 Service 入参出参 / 数据库新表新字段 / 关键文件职责变化 → §4.1 数据流、§4.2 字段约定、§4.3 模块职责对应章节已更新 | HIGH | -| 已知坑同步 | 修了一个"代码里看不出的"反直觉 bug(典型:上游字段格式不符合直觉、运行时值与文档不符、必须的兜底逻辑)→ §5 已知坑 表新增一行(带"如果不知道会怎样"+"在哪处理") | HIGH | -| 契约/依赖同步 | 新增对外 endpoint / 新依赖 chat/knowledge/permission 等模块的隐式契约 / 新增系统二进制依赖 → §6 Outgoing / Incoming 已补 | HIGH | -| 修订历史 | §修订历史 末尾追加了本次 feature 完成的条目(日期 + 改动 + 触发原因) | MEDIUM | -| 与 spec 不冲突 | design.md 对当前实现的描述未与 spec.md AC 矛盾(spec 是不变目标,design 是当前实现,二者口径必须对齐) | HIGH | - -**判定要点**: -- 若 design.md 与代码现状偏离 → 必须修复(视为 HIGH),不能合入 -- 仅本地小修小补(不改变对外行为、不引入新坑、不动决策)→ 本维度全 PASS 即可 -- design.md 不存在但已在 SDD 流程要求之内 → HIGH,要求按模板补全后再审 - ---- - -## 判定规则 - -| 结果 | 条件 | 动作 | -|------|------|------| -| **PASS** | 无 HIGH 或 MEDIUM | 可合并 | -| **PASS_WITH_WARNINGS** | 仅 MEDIUM 级 | 可合并,记录待改进 | -| **NEEDS_FIX** | 有 HIGH 级 | 修复后重审(最多 2 轮) | - ---- - -## 输出格式 - -```markdown -# Code Review Report - -**Feature**: -**Review scope**: <描述> -**Base branch**: 2.5.0-PM -**Changed files**: <数量> - -## Summary - -| Dimension | High | Medium | Low | Status | -|-----------|------|--------|-----|--------| -| Boundary Conditions | 0 | 0 | 0 | PASS | -| Permission & Auth | 0 | 0 | 0 | PASS | -| Concurrency Safety | 0 | 0 | 0 | PASS | -| Information Leakage | 0 | 0 | 0 | PASS | -| Test Coverage | 0 | 0 | 0 | PASS | -| Code Style | 0 | 0 | 0 | PASS | -| Docs Sync (design.md) | 0 | 0 | 0 | PASS | - -## Findings(如有) - -### HIGH -- [Permission] `xxx_endpoint.py:42` — 缺少 PermissionService.check() 调用 - -### MEDIUM -- [Style] `xxx_service.py:18` — DAO 方法未使用 @classmethod - -## Overall: PASS / PASS_WITH_WARNINGS / NEEDS_FIX -``` diff --git a/.claude/skills/e2e-test/SKILL.md b/.claude/skills/e2e-test/SKILL.md index 683f456169..2378e69252 100644 --- a/.claude/skills/e2e-test/SKILL.md +++ b/.claude/skills/e2e-test/SKILL.md @@ -165,7 +165,7 @@ cd src/backend ```markdown # E2E 验证清单: {feature_name} -**测试环境**: http://192.168.106.114:4001 (Platform) / :4001/workspace (Client) +**测试环境**: http://192.168.106.114:4101 (Platform) / :4101/workspace (Client) **前置条件**: <描述测试前需要的数据/账号> ## Platform 前端 diff --git a/.claude/skills/sdd-code-review/SKILL.md b/.claude/skills/sdd-code-review/SKILL.md new file mode 100644 index 0000000000..40619a979c --- /dev/null +++ b/.claude/skills/sdd-code-review/SKILL.md @@ -0,0 +1,170 @@ +--- +name: sdd-code-review +description: >- + SDD 第 8 步:Feature 全部任务完成后、合入前的特性级多维度代码审查,对照 spec/design/tasks 按 7 个维度出 PASS / NEEDS_FIX 报告。 + 用法:/sdd-code-review --base <目标分支>(通常是发版线,如 feat/3.0.0)。 + TRIGGER when: 用户运行 /sdd-code-review,或在 SDD feature 合入前要求特性级代码审查。 + 与 Claude Code 自带的 /code-review(云端多 agent 找缺陷)不是同一个工具。 +--- + +# Skill: sdd-code-review + +## 描述 + +L2 特性级多维度代码审查。Feature 全部任务完成后执行(SDD 第 8 步)。 + +本 skill 是仓库内的唯一原件:`.cursor/skills` 与 `.agents/skills` 都是指向 `.claude/skills` 的软链接,改这里即可。 + +## 触发 + +``` +/sdd-code-review --base feat/3.0.0 +``` + +`--base` 取 Feature 要合入的目标分支,下文用 `` 指代。 + +--- + +## 审查流程 + +1. 执行 `git diff ...HEAD --stat` 获取变更文件列表 +2. 执行 `git diff ...HEAD` 获取完整 diff +3. 对照 Feature 的 `spec.md`、`design.md` 和 `tasks.md` +4. 按 7 维度逐一审查 +5. 输出审查报告 + +--- + +## 7 维度审查框架 + +### 维度 1:边界条件 + +| 检查项 | 说明 | +|--------|------| +| null/None 处理 | 外部输入是否校验 None/空字符串 | +| 空集合 | 列表/字典为空时是否正确处理(不抛异常) | +| 数值边界 | 分页 page/size 合法性、ID 为 0/-1 | +| 字符串长度 | 数据库字段长度限制是否在 API 层校验 | +| 超时处理 | 外部调用(LLM/MCP/HTTP)是否设置超时 | +| 分页溢出 | 请求超出总页数时返回空列表而非错误 | + +### 维度 2:权限与认证 + +> 以 `docs/constitution.md` C4 为准;本表只列审查时要看的点。 + +| 检查项 | 说明 | +|--------|------| +| 认证注入 | 需要认证的端点是否使用 `UserPayload = Depends(UserPayload.get_login_user)` | +| 资源动作鉴权 | 资源操作是否走 `permission.application` 的 `require_business_action` / `check_business_action`(F048 统一运行时),而不是直接查 `role_access`、OpenFGA 或旧的 relation/`permission_id` 模板(RULE-8) | +| 判定顺序 | 不自行重写 super_admin → 租户不匹配拒绝 → 租户管理员 → Catalog/动作闸 → OpenFGA 的短路顺序;RBAC 菜单权限不能当资源动作的兜底放行 | +| 资源生命周期 | 资源创建、移动、复制、删除与授权变更是否走权限模块的投影台账,而不是业务代码自己写 tuple(RULE-9) | +| tenant_id 隔离 | 跨租户访问被阻止;批量 `update()` / `delete()` 和原生 SQL 是否手写了 tenant 条件(自动过滤只拦 SELECT) | +| 普通用户验证 | 权限相关的测试或手动验证是否用了非管理员账号(super_admin 会短路) | +| WebSocket 认证 | WS 端点是否使用 `UserPayload.get_login_user_from_ws` | + +### 维度 3:并发安全 + +| 检查项 | 说明 | +|--------|------| +| OpenFGA 双写 | MySQL + OpenFGA 写入是否有失败补偿(failed_tuples 表) | +| 数据库事务 | 多表写入是否在同一事务内 | +| Celery 幂等 | 异步任务是否支持重试不产生副作用 | +| 竞态条件 | 并发创建同名资源是否有唯一约束或乐观锁 | +| 会话状态 | Redis 缓存读写是否考虑过期和并发更新 | + +### 维度 4:信息泄漏 + +| 检查项 | 说明 | +|--------|------| +| 硬编码敏感信息 | 代码中无明文密码/密钥/token | +| 错误信息 | 异常响应不暴露堆栈/SQL/内部路径 | +| 日志脱敏 | logger 输出中敏感字段已脱敏 | +| 前端暴露 | 前端代码不包含后端 IP/密钥/内部 API 路径 | +| tenant_id 泄漏 | API 响应不向前端返回其他租户的 tenant_id | + +### 维度 5:测试覆盖 + +| 检查项 | 说明 | +|--------|------| +| Service 测试 | 核心 Service 方法有单元测试(mock DAO) | +| API 测试 | 新端点有集成测试(happy path + 主要 error path) | +| AC 覆盖 | spec 中每条 AC 都有对应测试或手动验证 | +| 错误路径 | 权限拒绝、参数校验失败等错误路径有测试 | +| 测试质量 | mock 合理,不 mock 掉核心逻辑 | + +> **务实适配**:核心 Service 方法必须有测试。 +> 前端:可抽成纯函数的逻辑(数据转换、校验、兼容升级)要有 vitest 单测;交互与视觉走 `/e2e-test` 的手动验证清单,tasks.md 只写「手动验证」不算覆盖。 + +### 维度 6:代码风格 + +| 检查项 | 说明 | +|--------|------| +| DDD 分层 | 新代码在正确的层级(domain/services vs api/endpoints) | +| 命名一致 | DAO/Service/错误码命名遵循项目约定 | +| 代码重复 | 无复制粘贴式重复逻辑(应提取到 Service 或工具函数) | +| 未使用代码 | 无 dead code、注释掉的代码块、空函数 | +| 格式化 | Python 代码通过 ruff check(hook 自动处理) | + +### 维度 7:文档同步(design.md 现状快照) + +> **目的**:确保 feature 合入后,新 agent 接手时读 design.md 能 5 分钟建立准确认知 —— 没有过期描述、没有缺失的反直觉坑、没有未声明的对外契约。 + +| 检查项 | 说明 | 严重度 | +|--------|------|--------| +| design.md 存在 | feature 目录下有 `design.md`(按 `features/_templates/design.md` 起的稿) | HIGH(缺失直接 NEEDS_FIX) | +| 决策同步 | diff 中新增/替换的关键技术决策(数据格式、配对/匹配策略、同步 vs 异步、二进制依赖等)在 §3 方案对比有记录,且给出"何时该重新考虑" | HIGH | +| 数据流/契约同步 | diff 触及 API 路径 / 请求响应字段 / 内部 Service 入参出参 / 数据库新表新字段 / 关键文件职责变化 → §4.1 数据流、§4.2 字段约定、§4.3 模块职责对应章节已更新 | HIGH | +| 已知坑同步 | 修了一个"代码里看不出的"反直觉 bug(典型:上游字段格式不符合直觉、运行时值与文档不符、必须的兜底逻辑)→ §5 已知坑 表新增一行(带"如果不知道会怎样"+"在哪处理") | HIGH | +| 契约/依赖同步 | 新增对外 endpoint / 新依赖 chat/knowledge/permission 等模块的隐式契约 / 新增系统二进制依赖 → §6 Outgoing / Incoming 已补 | HIGH | +| 修订历史 | §修订历史 末尾追加了本次 feature 完成的条目(日期 + 改动 + 触发原因) | MEDIUM | +| 与 spec 不冲突 | design.md 对当前实现的描述未与 spec.md AC 矛盾(spec 是不变目标,design 是当前实现,二者口径必须对齐) | HIGH | + +**判定要点**: +- 若 design.md 与代码现状偏离 → 必须修复(视为 HIGH),不能合入 +- 仅本地小修小补(不改变对外行为、不引入新坑、不动决策)→ 本维度全 PASS 即可 +- design.md 不存在但已在 SDD 流程要求之内 → HIGH,要求按模板补全后再审 + +--- + +## 判定规则 + +| 结果 | 条件 | 动作 | +|------|------|------| +| **PASS** | 无 HIGH 或 MEDIUM | 可合并 | +| **PASS_WITH_WARNINGS** | 仅 MEDIUM 级 | 可合并,记录待改进 | +| **NEEDS_FIX** | 有 HIGH 级 | 修复后重审(最多 2 轮) | + +--- + +## 输出格式 + +```markdown +# Code Review Report + +**Feature**: +**Review scope**: <描述> +**Base branch**: +**Changed files**: <数量> + +## Summary + +| Dimension | High | Medium | Low | Status | +|-----------|------|--------|-----|--------| +| Boundary Conditions | 0 | 0 | 0 | PASS | +| Permission & Auth | 0 | 0 | 0 | PASS | +| Concurrency Safety | 0 | 0 | 0 | PASS | +| Information Leakage | 0 | 0 | 0 | PASS | +| Test Coverage | 0 | 0 | 0 | PASS | +| Code Style | 0 | 0 | 0 | PASS | +| Docs Sync (design.md) | 0 | 0 | 0 | PASS | + +## Findings(如有) + +### HIGH +- [Permission] `xxx_endpoint.py:42` — 资源删除前未调用 require_business_action(action="delete") + +### MEDIUM +- [Style] `xxx_service.py:18` — DAO 方法未使用 @classmethod + +## Overall: PASS / PASS_WITH_WARNINGS / NEEDS_FIX +``` diff --git a/.cursor/README.md b/.cursor/README.md index 30af259bfc..3ed36e49c7 100644 --- a/.cursor/README.md +++ b/.cursor/README.md @@ -8,7 +8,7 @@ |-------------|--------|------| | `.claude/settings.json` | `.cursor/hooks.json` | Agent 钩子配置 | | `.claude/hooks/` | `.cursor/hooks/` | 钩子脚本 | -| `.claude/skills/` | `.cursor/skills/` | 项目级 Agent Skills | +| `.claude/skills/` | `.cursor/skills/`(软链接) | 项目级 Agent Skills,唯一原件在 `.claude/skills/` | ## Hooks @@ -26,13 +26,13 @@ ## Skills -从 `.claude/skills/` 同步,命令用法不变: +`.cursor/skills` 是指向 `../.claude/skills` 的软链接(`.agents/skills` 同理),三处读到的是同一份文件,命令用法不变: | Skill | 触发 | |-------|------| | `sdd-review` | `/sdd-review ` | | `task-review` | `/task-review ` | -| `code-review` | `/code-review --base ` | +| `sdd-code-review` | `/sdd-code-review --base ` | | `e2e-test` | `/e2e-test [feature_dir]` | | `approval-module` | 审批相关改动前自动加载 | | `i18n-localizer` | 模块国际化 | @@ -40,9 +40,8 @@ ## 维护 -更新 Skill 或 Hook 时,建议同时维护 `.claude/` 与 `.cursor/` 两份,或从 `.claude/` 复制后做格式适配。 +Skill 只改 `.claude/skills/` 下的原件,不要在 `.cursor/skills` 或 `.agents/skills` 里另建副本;新增 Skill 放进 `.claude/skills/` 后三处自动可见。 -```bash -cp -R .claude/skills/* .cursor/skills/ -# 然后检查 hooks 脚本是否需要同步 -``` +Hooks 仍是两份(事件名与输出格式不同),改 `.claude/hooks/` 时检查 `.cursor/hooks/` 是否需要同步。 + +Windows 检出需要 `git config core.symlinks true`(并具备创建符号链接的权限),否则软链接会变成一个只含路径文本的普通文件。 diff --git a/.cursor/skills b/.cursor/skills new file mode 120000 index 0000000000..454b8427cd --- /dev/null +++ b/.cursor/skills @@ -0,0 +1 @@ +../.claude/skills \ No newline at end of file diff --git a/.cursor/skills/approval-module/SKILL.md b/.cursor/skills/approval-module/SKILL.md deleted file mode 100644 index 00fc280ab9..0000000000 --- a/.cursor/skills/approval-module/SKILL.md +++ /dev/null @@ -1,330 +0,0 @@ ---- -name: approval-module -description: >- - BiSheng 审批模块(审批中心 F025)的架构与代码参考。 - 覆盖统一审批网关、多场景引擎、多节点流转、outbox 业务执行、站内信通知、异常处理。 - 迭代审批功能或修复审批相关 Bug 前先读本 skill,可直接定位架构与代码锚点,无需全仓搜索。 - TRIGGER when: 用户要改动/修复"审批""审批中心""approval"相关功能(菜单权限申请、频道订阅审批、 - 知识空间加入审批、审批流程/节点配置、异常处理、outbox/Celery 执行),或排查审批通过后业务未生效、 - 审批人看不到任务、站内信未发等问题。 ---- - -# 审批模块(审批中心 F025) - -## ⚠️ 维护契约(修改代码后必读) - -**本 skill 是审批模块的唯一权威参考,必须与代码永远一致。** -当你改动以下任意一项时,**同一个改动里必须同步更新本文件对应章节**,否则视为改动未完成: - -- 主流程分支逻辑(`ApprovalGate.request_or_pass` 的 pass/flow/exception 分流、`decide_task` / `_advance_after_node_approved` 的节点流转)→ 更新 [§2 架构与主流程](#2-架构与主流程) -- 新增/删除/重命名服务文件或关键方法 → 更新 [§3 代码锚点](#3-代码锚点) -- 新增/删除预置场景或改动其触发入口、Handler → 更新 [§4 预置场景](#4-预置场景) -- 数据库表/状态枚举变化 → 更新 [§5 数据库表](#5-数据库表) -- API 路由增删改 → 更新 [§7 API 列表](#7-api-列表) -- 站内信触发时机/接收人变化 → 更新 [§8 站内信通知矩阵](#8-站内信通知矩阵) -- Celery 队列/路由变化 → 更新 [§6 outbox 与 Celery](#6-outbox-与-celery) - -> 自检:改完代码后问自己"本 skill 里有没有哪句话现在变成假的了?"——有就改它。 - ---- - -## 1. 概述 - -审批中心是一套**通用多场景审批引擎**,所有场景共用同一套网关 / 路由 / 流程 / 节点 / 实例 / 任务 / outbox 机制。 - -**核心原则:审批"通过"与"执行业务"解耦为两步**——通过后只写 `approval_outbox(PENDING)`,由 Celery 异步执行业务 `on_approved()`,成功后实例才置 `EXECUTED`。 - -> ⚠️ **已废弃**:另有一套独立的旧系统——部门知识空间文件上传审批(`approval_request` 表),由 `approval_service.py` + `message_handler.py` 承载,路由在 `/approval/requests/*` 与 `/approval/department-knowledge-space/*`。该功能**已废弃**,仅为兼容存量保留,**不要在其上新增功能**;新需求一律走审批中心引擎。改审批中心时也不要误改它。 - ---- - -## 2. 架构与主流程 - -``` -申请人触发业务入口 - │ - ▼ -ApprovalGate.request_or_pass() ← 统一网关,所有场景从这里进入 - │ - 路由匹配 (approval_route_rule 表,按 sort_order 自上而下) - │ - ┌────┴───────────────────────────┐ - │ pass 分支 (route_type=pass) │ → instance(APPROVED) + outbox → Celery → on_approved() → EXECUTED - │ flow 分支 (route_type=flow) │ → instance(PENDING) + 首节点 task(PENDING) → 等待审批人 - │ 无分支命中 │ → instance(EXCEPTION, route_missing) + 通知管理员 - │ 审批人解析为空 │ → instance(EXCEPTION, approver_empty) + 通知管理员 - └────────────────────────────────┘ - │ (flow 分支被审批人处理) - ▼ -ApprovalCenterService.decide_task() - │ - 通过 → _advance_after_node_approved() - ├── 有后续节点(node_order 更大) → 解析下一节点审批人 + 建 tasks + 通知审批人;解析为空 → EXCEPTION(approver_empty) - └── 无后续节点(最后节点) → instance(APPROVED) + outbox → Celery → EXECUTED + 通知申请人 - 拒绝 → instance(REJECTED) + 通知申请人 - 撤回 → instance(WITHDRAWN) + 通知有 task 的审批人 -``` - -**多节点 / 会签**:`_advance_after_node_approved()` 实现顺序流转。 -- OR 节点(`node_mode=or`):任一人通过即把同节点其余 PENDING task 置 SKIPPED 并 advance。 -- AND 节点(`node_mode=and`):同节点全部通过才 advance。 -- finalize 时若 `handler_key` 未注册,记录 error 后仍照常 APPROVED + 建 outbox(避免卡死)。 - -**异常实例也留痕**:`_create_exception_result()` 在创建异常后会补写 `action='approval.request.submit'` 审计日志(与正常 PENDING/PASS 分支一致)。 - ---- - -## 3. 代码锚点 - -> 路径相对 `src/backend/bisheng/`。这些是定位问题的第一入口。 - -### 后端服务 - -| 文件 | 职责 | 关键方法 | -|------|------|---------| -| `approval/domain/services/approval_gate.py` | 统一入口:路由匹配、实例创建、pass/pending/exception 分流 | `request_or_pass()`、`_create_exception_result()`、`_notify_admins_of_exception()` | -| `approval/domain/services/approval_center_service.py` | 用户端:任务列表/详情、同意/拒绝、撤回、菜单申请、多节点流转 | `decide_task()`、`_advance_after_node_approved()`、`_dispatch_outbox()`、`_send_approval_notify()` | -| `approval/domain/services/approval_exception_service.py` | 管理端异常处理:重试/指定审批人/跳过节点/取消/标记完成 | `assign_approvers()`、`_resolve_exception_node()` | -| `approval/domain/services/approval_outbox_service.py` | outbox 执行与重试;成功后置 instance=EXECUTED | `execute_outbox()`、`retry_outbox()` | -| `approval/domain/services/approval_scenario_admin_service.py` | 管理端:场景/分支/流程/节点配置、异常列表 | — | -| `approval/domain/services/approver_resolver.py` | 解析审批人来源 `direct_user` / `department_admin` / `tenant_admin` | `resolve_approvers_from_sources()` | -| `approval/domain/services/approval_registry.py` | 场景预置目录 + handler 注册表 | `with_default_presets()`、`register_handler()`、`get_handler()` | -| `approval/domain/services/approval_runtime_handler_factory.py` | 为 outbox 执行 / 多节点 advance 重新构造运行时 handler | `build_runtime_handler(scenario_code)` | -| `approval/domain/services/approval_notification_service.py` | 站内信统一封装 | `notify_user()` / `notify_users()` / `notify_admins()` | -| `approval/domain/services/user_menu_access_service.py` | 菜单授权增删查,含父级菜单依赖自动补全 | `grant_menu_access()`、`revoke_menu_access()`、`ensure_application_allowed()` | -| `approval/domain/services/approval_service.py` + `message_handler.py` | **旧系统(已废弃)**:部门知识空间文件上传审批(`approval_request` 表),与审批中心独立,仅兼容存量、勿新增功能 | `ApprovalService.decide_request()` | -| `worker/approval/tasks.py` | Celery 任务(走默认 `celery` 队列) | `execute_approval_outbox`、`retry_approval_outbox` | -| `worker/config.py` | Celery 路由配置(审批任务**不**配路由,fall through 到默认队列) | `task_routes` | -| `approval/api/endpoints/approval_user.py` | Client 端 API(`/api/v1/approval/...`) | — | -| `approval/api/endpoints/approval_admin.py` | Platform 管理 API(`/api/v1/approval/admin/...`) | — | -| `approval/api/endpoints/approval.py` | 旧系统 legacy API(`/api/v1/approval/requests/...`),**已废弃** | — | - -### 三个场景 Handler - -| 文件 | 类 | -|------|----| -| `approval/domain/services/menu_access_handler.py` | `MenuAccessApprovalHandler` | -| `approval/domain/services/channel_subscribe_scenario_handler.py` | `ChannelSubscribeScenarioHandler` | -| `approval/domain/services/knowledge_space_subscribe_scenario_handler.py` | `KnowledgeSpaceSubscribeScenarioHandler` | - -### 前端 - -| 文件 | 职责 | -|------|------| -| `src/frontend/client/src/components/approval/ApprovalCenterDialog.tsx` | 审批中心弹窗(我的审批 + 我的申请 + 时间线) | -| `src/frontend/client/src/api/approval.ts` | 审批 API 封装,含 `ApprovalApiError`(非 200 自动抛出) | -| `src/frontend/client/src/pages/MenuUnavailablePage.tsx` | 无权限占位页 + 申请入口 | -| `src/frontend/client/src/layouts/MenuApprovalPluginGate.tsx` | 菜单审批路由守卫 | -| `src/frontend/platform/src/pages/ApprovalPage/index.tsx` | 管理后台审批页(场景/分支/流程/节点/异常) | -| `src/frontend/platform/src/controllers/API/approval.ts` | Platform 审批 API 封装 | - ---- - -## 4. 预置场景 - -三个场景由 `ApprovalRegistry.with_default_presets()` 注册(仅是"目录/下拉来源",**不等于已启用**)。每个场景的业务入口在创建 `ApprovalGateRequest` 时**都需要传 `applicant_department_id`**(供 `department_admin` 审批人来源使用,查 `UserDepartmentDao.aget_user_primary_department()`)。 - -**首次部署自动落库**:4.2 频道订阅审批、4.3 知识空间加入审批由 `common/init_data.py::_init_default_approval_scenarios()`(在 `init_default_data` 内)为默认租户幂等 seed——各建「默认分支(catch-all, route_type=flow) → 默认流程 → 单节点(node_mode=or 或签)」,审批人来源即资源 owner+manager(频道 `channel_owner`/`channel_manager`,知识空间 `knowledge_space_owner`/`knowledge_space_manager`),场景 `enabled=True`。按 `tenant_id+scenario_code` 判存在即跳过,绝不覆盖人工改动。菜单权限申请(4.1)**不**自动 seed。新租户不自动 seed,需管理后台手工配置。 - -### 4.1 菜单权限申请 (`menu_access_request`) -- **入口**:Client `/workspace/menu-unavailable?plugin=xxx` → `POST /api/v1/approval/menu-access/apply` -- **Handler**:`MenuAccessApprovalHandler` -- `on_approved` 调 `UserMenuAccessService.grant_menu_access()`,自动补父级依赖(如 `knowledge_space` → 同时授权 `workstation`);`on_revoke` 调 `revoke_menu_access()` -- 申请前校验 `ensure_application_allowed()`(`menu_approval_mode=false` 或已有权限时拒绝) - -### 4.2 频道订阅审批 (`channel_subscribe_request`) -- **入口**:`channel/domain/services/channel_service.py::subscribe_channel()`(`REVIEW` 可见性频道) -- **Handler**:`ChannelSubscribeScenarioHandler` -- 通过 / pass 路径调 `ChannelService.sync_direct_channel_user_permissions()` 写 ReBAC(OpenFGA) 关系(否则成员不出现在 ReBAC 成员列表) -- `on_approved` 先把申请人的 **PENDING** membership 翻成 ACTIVE 再写 ReBAC(查 membership 注意频道默认只返回 ACTIVE,激活需带非 ACTIVE 状态) -- PENDING 时调 `_send_channel_approval_notification()` 通知审批人 - -### 4.3 知识空间加入审批 (`knowledge_space_subscribe_request`) -- **入口**:`knowledge/domain/services/knowledge_space_service.py::subscribe_space()`(`auth_type=APPROVAL`) -- **Handler**:`KnowledgeSpaceSubscribeScenarioHandler` -- 通过 / ACTIVE 路径调 `sync_direct_space_user_permissions()` 写 ReBAC 关系 -- PENDING 时调 `_send_space_approval_notification()` 通知审批人 -- **不变量:先过网关、再落 membership。** `subscribe_space` 对 APPROVAL 空间必须先 `await gate.request_or_pass()`,按 gate 结果(pass→ACTIVE / pending·exception→PENDING)才通过 `_persist_space_member()` 写 `space_channel_member`。**严禁在调网关前预写 PENDING membership**——否则场景未配置/未启用时网关 `raise ApprovalScenarioDisabledError`,但 PENDING 行已落库,下次点"关注"会被 `subscribe_space` 顶部"已 PENDING 直接返回 pending"的早退分支短路,掩盖错误(首次报错、二次假成功)。无场景时每次点击都应一致报错。 - ---- - -## 5. 数据库表 - -| 表名 | 说明 | 关键状态字段 | -|------|------|------------| -| `approval_scenario` | 租户下启用的审批场景 | `enabled` | -| `approval_route_rule` | 场景下条件分支(按 `sort_order` 匹配) | `route_type: pass/flow`、`enabled` | -| `approval_flow_definition` | 审批流程定义头 | — | -| `approval_flow_version` | 流程版本快照 | `is_active` | -| `approval_node_definition` | 流程版本内顺序节点 | `node_order`、`node_mode: or/and`、`approver_config` | -| `approval_instance` | 一次审批申请 | `pending/approved/rejected/withdrawn/executed/execute_failed/exception/cancelled` | -| `approval_task` | 分配给审批人的节点待办 | `pending/approved/rejected/skipped/cancelled` | -| `approval_exception` | 异常记录 | `open/resolved`,`exception_type: route_missing/approver_empty/execute_failed` | -| `approval_outbox` | 业务执行队列 | `pending/success/failed` | -| `approval_action_log` | 时间线日志 | — | -| `user_menu_access` | 用户级菜单授权(菜单审批专用) | `active/revoked` | -| `approval_request` | **旧系统(已废弃)**:部门知识空间文件上传审批,仅兼容存量 | — | - -> 模型定义见 `approval/domain/models/approval_instance.py`、`approval_scenario.py`、`user_menu_access.py`。 -> `approval_instance.latest_approver_user_id` 字段已定义但**当前从未赋值**(已知限制,需要时在 `decide_task` 里补)。 - ---- - -## 6. outbox 与 Celery - -业务执行走 outbox:通过后写 `approval_outbox(PENDING)` → Celery `execute_approval_outbox` 执行 `handler.on_approved()` → 成功 outbox=SUCCESS、instance=EXECUTED;失败 outbox=FAILED、instance=EXECUTE_FAILED 并建 `execute_failed` 异常。 - -> **原则:业务回调(`on_approved` 等)不得静默失败。** 该执行成功/失败由「是否抛异常」判定:抛异常 → outbox=FAILED + `execute_failed` 异常暴露问题;正常返回 → 一律视为成功并置 instance=EXECUTED。因此前置条件缺失(如找不到要激活的 membership/资源)**必须 raise**,绝不能 `return {'status':'xxx'}` 之类把失败伪装成成功——否则会出现 instance=executed 但业务实际没生效的「假成功」,且无任何告警。 - -**dispatch 入口(两处,功能相同名字不同):** -- `approval_center_service.py::_dispatch_outbox(outbox_id)` — `decide_task` 最后节点通过 / skip_node -- `approval_gate.py` PASS 分支 — 调 `execute_approval_outbox.delay(outbox_id)` - -**Celery 队列:走默认 `celery` 队列。** `worker/config.py` **不**为 `bisheng.worker.approval.*` 配路由,任务自然 fall through 到默认队列。`workflow_celery` 专供工作流 DAG 执行,审批任务不占用。 - -> ⚠️ 部署时必须有 worker 消费默认 `celery` 队列(`run_celery.py` 的 `all` / `file` 模式都含),否则审批通过后业务不执行。站内信发送是同步写库,不依赖 Celery。 - -启动消费默认队列的 worker: -```bash -uv run celery -A bisheng.worker.main worker -l info -c 100 -P threads -n default@%h -``` - ---- - -## 7. API 列表 - -> 全局前缀 `/api/v1`。以代码为准(`approval_user.py` / `approval_admin.py` / `approval.py`)。 - -### 用户端(`/approval`) -``` -GET /approval/my-tasks # 我的待办(审批人视角) -GET /approval/my-tasks/{task_id} # 任务详情 -POST /approval/tasks/{task_id}/decision # 同意/拒绝 -GET /approval/my-requests # 我的申请(申请人视角) -GET /approval/instances/{instance_id} # 实例详情(tasks + flow_nodes + action_logs) -POST /approval/instances/{instance_id}/withdraw # 撤回 -GET /approval/menu-access/pending-check # 菜单申请前置校验 -POST /approval/menu-access/apply # 菜单权限申请 -POST /approval/menu-access/{instance_id}/revoke-grant # 撤销菜单授权(审批人) -``` - -### 管理端(`/approval/admin`) -``` -GET /approval/admin/scenario-presets # 预置场景目录(下拉来源) -GET /approval/admin/scenarios # 场景列表 -POST /approval/admin/scenarios # 新增场景 -PUT /approval/admin/scenarios/{scenario_id} # 更新场景 -DELETE /approval/admin/scenarios/{scenario_id} # 删除场景 -GET /approval/admin/scenarios/{scenario_id}/routes # 分支列表 -POST /approval/admin/scenarios/{scenario_id}/routes # 新增分支 -PUT /approval/admin/routes/{route_rule_id} # 更新分支 -DELETE /approval/admin/routes/{route_rule_id} # 删除分支 -PATCH /approval/admin/scenarios/{scenario_id}/routes/reorder # 分支排序 -GET /approval/admin/scenarios/{scenario_id}/flows # 流程列表 -POST /approval/admin/scenarios/{scenario_id}/flows # 新增流程 -PUT /approval/admin/flows/{flow_definition_id} # 更新流程 -DELETE /approval/admin/flows/{flow_definition_id} # 删除流程 -GET /approval/admin/flows/{flow_definition_id}/nodes # 节点配置 -PUT /approval/admin/flows/{flow_definition_id}/nodes # 提交节点(全量提交触发新版本) -GET /approval/admin/flows/{flow_definition_id}/versions/{flow_version_id} # 版本预览 -GET /approval/admin/exceptions # 异常列表 -POST /approval/admin/exceptions/{exception_id}/retry # 重试/指定审批人/跳过节点/标记完成 -POST /approval/admin/exceptions/{exception_id}/cancel # 取消审批(必须填原因) -``` - -### 旧系统 legacy(`/approval/requests`、`/approval/department-knowledge-space`)— ⚠️ 已废弃 -部门知识空间文件上传审批,独立于审批中心,见 `approval.py`。**已废弃**,仅兼容存量数据,不要在此新增/扩展接口。 - ---- - -## 8. 站内信通知矩阵 - -| 触发时机 | 接收人 | 实现位置 | -|----------|--------|---------| -| 创建审批任务(菜单申请) | 审批人 | `ApprovalCenterService._send_menu_access_approval_messages()` | -| 频道审批创建(PENDING) | 审批人 | `ChannelService._send_channel_approval_notification()` | -| 知识空间审批创建(PENDING) | 审批人 | `KnowledgeSpaceService._send_space_approval_notification()` | -| 中间节点通过、生成下一节点任务 | 下一节点审批人 | `_advance_after_node_approved()` → `_send_approval_notify('approval_task_pending')` | -| 审批通过(最后节点 finalize) | 申请人 | `_advance_after_node_approved()` → `_send_approval_notify('approval_instance_approved')` | -| 审批拒绝 | 申请人 | `decide_task()` reject 分支 | -| 申请撤回 | 有 task 的审批人 | `ApprovalCenterService.withdraw_instance()` | -| 异常产生(route_missing/approver_empty) | 管理员(AdminRole) | `ApprovalGate._notify_admins_of_exception()` / `ApprovalNotificationService.notify_admins()` | -| 异常取消 | 申请人 | `ApprovalExceptionService.cancel_exception_api()` | - -> 注:申请人侧"通过"通知是在**最后节点 finalize** 时发的(即审批通过即通知),不等 outbox 业务真正执行完。若要"业务执行成功"的精确通知,需在 `execute_outbox` 成功回调里补。 - ---- - -## 9. 审批进度时间轴 - -`get_instance_detail` 返回三组数据,前端合并展示: -``` -action_logs[action=submitted] ← 提交申请 -flow_nodes (按 node_order 排序) ← 完整流程骨架(来自 approval_node_definition,含未到达节点) - ├── 已有 task → 实际状态 - └── 无 task → 灰色"未到达" -action_logs[action!=submitted] ← 撤回/取消等其他日志 -``` -`flow_nodes` 解决了"tasks 只有已创建节点"的问题,能展示完整流程定义。 - ---- - -## 10. 配置要点 - -条件分支 `match_config` 格式: -```json -{} // 无条件,始终命中(catch-all) -{"field": "applicant_role", "value": "dept_admin"} // 申请人是部门管理员 -{"field": "menu_key", "value": "knowledge_space"} // 申请特定菜单 -{"field": "space_type", "value": "department"} // 知识空间类型 -``` -`applicant_role` 枚举:`admin`(系统管理员) / `tenant_admin`(租户管理员) / `dept_admin`(部门管理员) / `regular_user`(普通用户, catch-all) / `role_{id}`(特定角色)。 - -节点 `approver_config.sources` 格式: -```json -[ - {"type": "direct_user", "user_ids": [701], "user_names": ["00017"]}, - {"type": "department_admin"}, - {"type": "tenant_admin"} -] -``` -`user_names` 由前端保存时写入,用于节点卡片直接显示用户名,避免二次查库。 - ---- - -## 11. 调试指南 - -### "审批通过但业务没下发" -```sql -SELECT id, status, applicant_user_id FROM approval_instance WHERE id=; -SELECT id, status, error_summary FROM approval_outbox WHERE instance_id=; -``` -- outbox 不存在 → `_dispatch_outbox` 没调 -- outbox 存在且 `pending` → 没有 worker 消费默认 `celery` 队列 -- outbox 存在且 `failed` → 看 `error_summary`,并查 `approval_exception` 的 `execute_failed` - -手动补偿: -```python -# set_current_tenant_id(tenant_id) -# handler = await build_runtime_handler(outbox.handler_key) -# await handler.on_approved(instance_id, outbox.payload_snapshot) -``` - -### "审批人看不到任务" -```sql -SELECT id, approver_user_id, status FROM approval_task WHERE instance_id=; -SELECT id, exception_type, status, detail FROM approval_exception WHERE instance_id=; -``` -若异常类型是 `approver_empty`:检查 `approval_instance.applicant_department_id` 是否为 NULL,以及节点 `approver_config.sources` 里 `department_admin` 是否依赖部门。 - -### "频道/知识空间审批通过但成员列表看不到" -检查对应 `sync_direct_channel_user_permissions` / `sync_direct_space_user_permissions` 是否在该激活路径被调用(写 ReBAC/OpenFGA 关系)。若 `instance=executed` 但 `space_channel_member.status` 仍为 `PENDING`,说明 `on_approved` 没真正激活成员(见 §6 的"业务回调不得静默失败"原则)。 - ---- - -## 12. 测试 - -审批相关测试在 `src/backend/test/approval/`(`asyncio_mode=auto`)。新测试放到该目录,不放 `test/` 根。 -```bash -cd src/backend && uv run pytest test/approval/ -``` diff --git a/.cursor/skills/code-review/SKILL.md b/.cursor/skills/code-review/SKILL.md deleted file mode 100644 index 325f7dd142..0000000000 --- a/.cursor/skills/code-review/SKILL.md +++ /dev/null @@ -1,162 +0,0 @@ ---- -name: code-review -description: >- - L2 特性级多维度代码审查。Feature 全部任务完成后执行。 - 用法:/code-review --base - TRIGGER when: 用户运行 /code-review,或在 merge 前要求特性级代码审查。 ---- - -# Skill: code-review - -## 描述 - -L2 特性级多维度代码审查。Feature 全部任务完成后执行。 - -## 触发 - -``` -/code-review --base 2.5.0-PM -``` - ---- - -## 审查流程 - -1. 执行 `git diff 2.5.0-PM...HEAD --stat` 获取变更文件列表 -2. 执行 `git diff 2.5.0-PM...HEAD` 获取完整 diff -3. 对照 Feature 的 `spec.md`、`design.md` 和 `tasks.md` -4. 按 7 维度逐一审查 -5. 输出审查报告 - ---- - -## 7 维度审查框架 - -### 维度 1:边界条件 - -| 检查项 | 说明 | -|--------|------| -| null/None 处理 | 外部输入是否校验 None/空字符串 | -| 空集合 | 列表/字典为空时是否正确处理(不抛异常) | -| 数值边界 | 分页 page/size 合法性、ID 为 0/-1 | -| 字符串长度 | 数据库字段长度限制是否在 API 层校验 | -| 超时处理 | 外部调用(LLM/MCP/HTTP)是否设置超时 | -| 分页溢出 | 请求超出总页数时返回空列表而非错误 | - -### 维度 2:权限与认证 - -| 检查项 | 说明 | -|--------|------| -| 认证注入 | 需要认证的端点是否使用 `UserPayload = Depends(UserPayload.get_login_user)` | -| 五级权限链路 | 是否遵循:super_admin → tenant 归属 → tenant admin → ReBAC → RBAC 菜单 | -| PermissionService | 权限检查是否走 `PermissionService.check()` 而非直接查旧表 | -| 资源授权 | 创建资源时是否调用 `PermissionService.authorize()` 写入 owner 元组 | -| tenant_id 隔离 | 跨租户访问是否被阻止(SQLAlchemy event 自动注入) | -| WebSocket 认证 | WS 端点是否使用 `UserPayload.get_login_user_from_ws` | - -### 维度 3:并发安全 - -| 检查项 | 说明 | -|--------|------| -| OpenFGA 双写 | MySQL + OpenFGA 写入是否有失败补偿(failed_tuples 表) | -| 数据库事务 | 多表写入是否在同一事务内 | -| Celery 幂等 | 异步任务是否支持重试不产生副作用 | -| 竞态条件 | 并发创建同名资源是否有唯一约束或乐观锁 | -| 会话状态 | Redis 缓存读写是否考虑过期和并发更新 | - -### 维度 4:信息泄漏 - -| 检查项 | 说明 | -|--------|------| -| 硬编码敏感信息 | 代码中无明文密码/密钥/token | -| 错误信息 | 异常响应不暴露堆栈/SQL/内部路径 | -| 日志脱敏 | logger 输出中敏感字段已脱敏 | -| 前端暴露 | 前端代码不包含后端 IP/密钥/内部 API 路径 | -| tenant_id 泄漏 | API 响应不向前端返回其他租户的 tenant_id | - -### 维度 5:测试覆盖 - -| 检查项 | 说明 | -|--------|------| -| Service 测试 | 核心 Service 方法有单元测试(mock DAO) | -| API 测试 | 新端点有集成测试(happy path + 主要 error path) | -| AC 覆盖 | spec 中每条 AC 都有对应测试或手动验证 | -| 错误路径 | 权限拒绝、参数校验失败等错误路径有测试 | -| 测试质量 | mock 合理,不 mock 掉核心逻辑 | - -> **务实适配**:当前测试基础薄弱,降低阈值但要求核心 Service 方法必须有测试。 -> 前端暂用手动验证替代(tasks.md 中有「手动验证」描述即可)。 - -### 维度 6:代码风格 - -| 检查项 | 说明 | -|--------|------| -| DDD 分层 | 新代码在正确的层级(domain/services vs api/endpoints) | -| 命名一致 | DAO/Service/错误码命名遵循项目约定 | -| 代码重复 | 无复制粘贴式重复逻辑(应提取到 Service 或工具函数) | -| 未使用代码 | 无 dead code、注释掉的代码块、空函数 | -| 格式化 | Python 代码通过 ruff check(hook 自动处理) | - -### 维度 7:文档同步(design.md 现状快照) - -> **目的**:确保 feature 合入后,新 agent 接手时读 design.md 能 5 分钟建立准确认知 —— 没有过期描述、没有缺失的反直觉坑、没有未声明的对外契约。 - -| 检查项 | 说明 | 严重度 | -|--------|------|--------| -| design.md 存在 | feature 目录下有 `design.md`(按 `features/_templates/design.md` 起的稿) | HIGH(缺失直接 NEEDS_FIX) | -| 决策同步 | diff 中新增/替换的关键技术决策(数据格式、配对/匹配策略、同步 vs 异步、二进制依赖等)在 §3 方案对比有记录,且给出"何时该重新考虑" | HIGH | -| 数据流/契约同步 | diff 触及 API 路径 / 请求响应字段 / 内部 Service 入参出参 / 数据库新表新字段 / 关键文件职责变化 → §4.1 数据流、§4.2 字段约定、§4.3 模块职责对应章节已更新 | HIGH | -| 已知坑同步 | 修了一个"代码里看不出的"反直觉 bug(典型:上游字段格式不符合直觉、运行时值与文档不符、必须的兜底逻辑)→ §5 已知坑 表新增一行(带"如果不知道会怎样"+"在哪处理") | HIGH | -| 契约/依赖同步 | 新增对外 endpoint / 新依赖 chat/knowledge/permission 等模块的隐式契约 / 新增系统二进制依赖 → §6 Outgoing / Incoming 已补 | HIGH | -| 修订历史 | §修订历史 末尾追加了本次 feature 完成的条目(日期 + 改动 + 触发原因) | MEDIUM | -| 与 spec 不冲突 | design.md 对当前实现的描述未与 spec.md AC 矛盾(spec 是不变目标,design 是当前实现,二者口径必须对齐) | HIGH | - -**判定要点**: -- 若 design.md 与代码现状偏离 → 必须修复(视为 HIGH),不能合入 -- 仅本地小修小补(不改变对外行为、不引入新坑、不动决策)→ 本维度全 PASS 即可 -- design.md 不存在但已在 SDD 流程要求之内 → HIGH,要求按模板补全后再审 - ---- - -## 判定规则 - -| 结果 | 条件 | 动作 | -|------|------|------| -| **PASS** | 无 HIGH 或 MEDIUM | 可合并 | -| **PASS_WITH_WARNINGS** | 仅 MEDIUM 级 | 可合并,记录待改进 | -| **NEEDS_FIX** | 有 HIGH 级 | 修复后重审(最多 2 轮) | - ---- - -## 输出格式 - -```markdown -# Code Review Report - -**Feature**: -**Review scope**: <描述> -**Base branch**: 2.5.0-PM -**Changed files**: <数量> - -## Summary - -| Dimension | High | Medium | Low | Status | -|-----------|------|--------|-----|--------| -| Boundary Conditions | 0 | 0 | 0 | PASS | -| Permission & Auth | 0 | 0 | 0 | PASS | -| Concurrency Safety | 0 | 0 | 0 | PASS | -| Information Leakage | 0 | 0 | 0 | PASS | -| Test Coverage | 0 | 0 | 0 | PASS | -| Code Style | 0 | 0 | 0 | PASS | -| Docs Sync (design.md) | 0 | 0 | 0 | PASS | - -## Findings(如有) - -### HIGH -- [Permission] `xxx_endpoint.py:42` — 缺少 PermissionService.check() 调用 - -### MEDIUM -- [Style] `xxx_service.py:18` — DAO 方法未使用 @classmethod - -## Overall: PASS / PASS_WITH_WARNINGS / NEEDS_FIX -``` diff --git a/.cursor/skills/e2e-test/SKILL.md b/.cursor/skills/e2e-test/SKILL.md deleted file mode 100644 index 683f456169..0000000000 --- a/.cursor/skills/e2e-test/SKILL.md +++ /dev/null @@ -1,251 +0,0 @@ ---- -name: e2e-test -description: >- - 为 BiSheng 生成和运行 E2E 测试。两种模式: - (1) SDD 模式 — 基于 feature spec.md 的 AC 生成覆盖; - (2) 自由模式 — 对指定页面/功能写测试。 - 采用双层策略:API 端到端测试(pytest + httpx)+ 页面手动验证清单。 - 自动处理认证、多租户隔离、权限检查、Radix UI 交互等常见问题。 - 用法:/e2e-test [feature_dir] 或 /e2e-test <描述> - 当用户说"写 E2E 测试"、"端到端测试"、"E2E coverage", - 或使用 /e2e-test 命令时触发。 ---- - -# E2E Test Skill - -## 概述 - -生成并运行 BiSheng 的 E2E 测试,覆盖 API 链路和 UI 交互流程。自动处理 JWT 认证、多租户数据隔离、OpenFGA 权限检查验证、UnifiedResponseModel 响应断言等 BiSheng 特有问题。 - -## 调用方式 - -``` -/e2e-test # SDD 模式:基于 spec.md AC 生成 -/e2e-test <描述> # 自由模式:对指定页面/功能写测试 -``` - -示例: -``` -/e2e-test features/v2.5.0/004-rebac-core -/e2e-test 为租户管理页面写创建流程测试 -``` - ---- - -## 六步流程 - -### Step 1:模式识别 - -解析用户参数: - -- **SDD 模式**:参数是 `features/` 开头的路径 → 读取该目录下的 `spec.md` -- **自由模式**:参数是自由文本描述 → 直接进入 Step 3 - -### Step 2:AC 分析(仅 SDD 模式) - -读取 `/spec.md`,从 AC 表格中分类: - -**API 行为类**(自动化 pytest 测试): -- CRUD 操作及响应格式 -- 权限检查(允许/拒绝) -- 分页、过滤、排序 -- 错误码返回(MMMEE) -- 跨租户访问拒绝 - -**UI 交互类**(手动验证清单): -- 表单填写、按钮点击 -- 列表展示、搜索过滤 -- 弹窗/抽屉交互 -- 路由跳转 -- 权限控制(按钮隐藏/禁用) - -**排除**: -- 纯样式/布局调整 -- 纯内部状态逻辑 - -输出分类后的 AC 列表,作为测试用例依据。 - -### Step 3:基础设施检查 - -检查共享 helpers 是否存在: - -``` -src/backend/test/e2e/ -├── conftest.py # pytest fixtures(认证、client、cleanup) -├── helpers/ -│ ├── __init__.py -│ ├── auth.py # JWT 认证 + 用户创建 -│ ├── api.py # API 常量 + 通用 CRUD helpers -│ └── cleanup.py # 数据隔离 + 安全 cleanup -└── test_e2e_xxx.py # 各 Feature 的测试文件 -``` - -如果不存在,按照 `references/test-template.md` 创建基础设施。 -如果需要新增共享函数,先加到对应的 helpers 文件中。 - -### Step 4:生成测试 - -基于 `references/test-template.md` 生成测试文件。 - -**文件命名**:`src/backend/test/e2e/test_e2e_{feature_name}.py` - -**强制生成规则(12 条)**: - -1. **数据隔离(红线)**:测试数据统一 `e2e-{feature}-` 前缀(≥5 字符)。**禁止无条件删除所有资源**——cleanup 必须按前缀过滤,只删本套件创建的数据。E2E 运行前后,非测试数据必须保持不变 -2. **双重 cleanup**:setup fixture 清理上次残留 + teardown 清理本次数据 -3. **测试租户隔离**:使用专用 `test_tenant_id`,不影响正式租户数据。创建测试数据前先确保测试租户存在 -4. **认证流程**:通过 helpers 获取 JWT token,注入到请求 headers。测试管理员和普通用户两种角色 -5. **响应格式断言**:所有 API 响应必须断言 `UnifiedResponseModel` 格式(`status_code`, `status_message`, `data`) -6. **权限测试配对**:每个"允许"操作配对一个"拒绝"测试(不同角色/不同租户) -7. **共享 helpers**:导入 `test/e2e/helpers/` 的函数,**禁止在测试文件内重新定义**通用工具函数 -8. **AC 追溯**:每个测试方法的 docstring 标注 `AC-NN: <描述>` -9. **API 验证**:数据变更操作后,通过 GET 请求断言最终状态(不仅依赖创建响应) -10. **错误码精确断言**:业务错误断言具体的 MMMEE 错误码,不仅检查非 200 -11. **串行执行**:使用 pytest-ordering 或 class 内方法顺序保证 setup → tests → cleanup -12. **幂等性**:测试可重复运行,不依赖特定的数据库状态(除测试自己创建的数据) - -### Step 5:运行与修复 - -运行生成的测试: - -```bash -cd src/backend -.venv/bin/pytest test/e2e/test_e2e_{feature_name}.py -v -``` - -如果失败,按照 `references/common-pitfalls.md` 的诊断表定位问题。 - -**最多 3 轮修复**。如果 3 轮后仍有失败,输出剩余问题让用户决定。 - -**调试技巧**: -```bash -# 单个测试 -.venv/bin/pytest test/e2e/test_e2e_{feature}.py::TestE2E{Feature}::test_ac01 -v -s - -# 显示完整请求/响应 -.venv/bin/pytest test/e2e/test_e2e_{feature}.py -v -s --log-cli-level=DEBUG - -# 只运行失败的 -.venv/bin/pytest test/e2e/test_e2e_{feature}.py --lf -v -``` - -### Step 6:覆盖报告 - -输出 AC 覆盖表: - -```markdown -# E2E 覆盖报告: - -## API 测试结果 - -| AC-ID | 描述 | 状态 | 测试方法 | -|-------|------|------|---------| -| AC-01 | 创建租户成功 | ✅ 通过 | test_ac01_create_tenant | -| AC-02 | 重复租户名拒绝 | ✅ 通过 | test_ac02_duplicate_name | -| AC-05 | 表单提交创建 | ⏭️ 跳过(UI 交互,见手动清单) | — | - -通过: N/M | 跳过: K(UI 交互类)| 失败: J - -## 手动验证清单 - -生成位置: `features/v2.5.0/{NNN}-{name}/e2e-checklist.md` -覆盖 AC: AC-05, AC-06, ... - -## 整体状态: PASS / PARTIAL / FAIL -``` - ---- - -## 手动验证清单格式 - -当 AC 涉及 UI 交互时,生成结构化验证清单。 - -**文件位置**:`features/v2.5.0/{NNN}-{name}/e2e-checklist.md` - -```markdown -# E2E 验证清单: {feature_name} - -**测试环境**: http://192.168.106.114:4001 (Platform) / :4001/workspace (Client) -**前置条件**: <描述测试前需要的数据/账号> - -## Platform 前端 - -### AC-05: <描述> -- [ ] 步骤 1: 以管理员登录 Platform (admin/admin123) -- [ ] 步骤 2: 导航到 <页面路径> -- [ ] 步骤 3: 点击 <按钮/元素> -- [ ] 步骤 4: 填写表单: <字段=值> -- [ ] 预期: <具体可观察结果,如 toast 提示、列表刷新> -- [ ] 验证: 刷新页面后数据仍存在 - -### AC-06: <错误场景描述> -- [ ] 步骤: <触发错误的操作> -- [ ] 预期: <错误提示内容> - -## Client 前端(如适用) - -### AC-07: <描述> -- [ ] ... - -## 回归检查 -- [ ] 相关页面(<列出>)正常加载,无 console 错误 -- [ ] 既有功能(<列出>)不受影响 -- [ ] 不同角色(管理员/普通用户)看到的内容符合权限设定 -``` - ---- - -## 参考文件 - -生成测试前**必须阅读**以下参考文件: - -| 文件 | 用途 | 何时阅读 | -|------|------|---------| -| `references/test-template.md` | pytest E2E 测试骨架模板 | 生成新测试文件时 | -| `references/common-pitfalls.md` | BiSheng E2E 常见陷阱诊断表 | 测试失败时 | - ---- - -## 已有共享 Helpers 清单 - -> 首次运行时由 Step 3 自动创建。以下是目标结构。 - -### `test/e2e/helpers/auth.py` - -| 函数 | 签名 | 用途 | -|------|------|------| -| `get_admin_token` | `(client) -> str` | 获取管理员 JWT token | -| `get_user_token` | `(client, username, password) -> str` | 获取指定用户 JWT token | -| `create_test_user` | `(client, admin_token, username, role_id) -> dict` | 创建测试用户 | -| `auth_headers` | `(token) -> dict` | 构建认证请求头 | - -### `test/e2e/helpers/api.py` - -| 导出 | 用途 | -|------|------| -| `API_BASE` | 后端 API 基础 URL 常量 (`http://localhost:7860/api/v1`) | -| `assert_resp_200(resp)` | 断言 UnifiedResponseModel 成功响应 | -| `assert_resp_error(resp, code)` | 断言 UnifiedResponseModel 错误码 | -| `create_resource(client, path, data, token)` | 通用 POST 创建 | -| `list_resources(client, path, token, params)` | 通用 GET 列表 | -| `delete_resource(client, path, resource_id, token)` | 通用 DELETE | - -### `test/e2e/helpers/cleanup.py` - -| 函数 | 用途 | -|------|------| -| `cleanup_by_prefix(client, path, prefix, token)` | 安全删除指定前缀的资源。**前缀必须 ≥5 字符**,否则抛错防止误删 | -| `ensure_test_tenant(client, admin_token, tenant_code)` | 确保测试租户存在(不存在则创建) | - ---- - -## 新增 Helper 的规则 - -当测试需要新的共享函数时: - -1. **认证相关** → 加到 `helpers/auth.py` -2. **API 请求/断言** → 加到 `helpers/api.py` -3. **数据管理/fixtures** → 加到 `helpers/cleanup.py` -4. **特定 feature 的 helper** → 留在测试文件内,不提取 - -提取标准:**2 个以上测试文件使用** → 提取到 helpers。 diff --git a/.cursor/skills/e2e-test/references/common-pitfalls.md b/.cursor/skills/e2e-test/references/common-pitfalls.md deleted file mode 100644 index 509f65d0c8..0000000000 --- a/.cursor/skills/e2e-test/references/common-pitfalls.md +++ /dev/null @@ -1,216 +0,0 @@ -# BiSheng E2E 测试常见陷阱与诊断修复 - -## 陷阱 1:业务错误 HTTP 200 - -**症状**:`assert resp.status_code == 400` 失败,实际收到 200。 - -**原因**:BiSheng 的 `UnifiedResponseModel` 将业务错误包装在 HTTP 200 响应体中,通过 `status_code` 字段区分。 - -**修复**: -```python -# ❌ BiSheng 业务错误也返回 HTTP 200 -assert resp.status_code == 400 - -# ✅ 检查响应体中的 status_code -body = resp.json() -assert body["status_code"] == 10901 # 具体 MMMEE 错误码 -assert body["status_message"] != "SUCCESS" -``` - ---- - -## 陷阱 2:认证 Token 获取失败 - -**症状**:登录 API 返回错误,或后续请求 401。 - -**原因**:BiSheng 登录密码需要 RSA 加密。前端从 `/api/v1/user/public_key` 获取公钥后加密。 - -**修复**: -```python -# ✅ 先获取公钥,再加密密码 -from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import padding - -resp = await client.get("/user/public_key") -public_key_pem = resp.json()["data"]["public_key"] - -# 加密密码 -public_key = serialization.load_pem_public_key(public_key_pem.encode()) -encrypted = public_key.encrypt(password.encode(), padding.PKCS1v15()) -encrypted_password = base64.b64encode(encrypted).decode() - -# 登录 -resp = await client.post("/user/login", json={ - "user_name": username, - "password": encrypted_password, -}) -``` - -**建议**:将此逻辑封装在 `helpers/auth.py` 中,测试文件直接调用 `get_admin_token()`。 - ---- - -## 陷阱 3:tenant_id 自动注入导致测试数据不可见 - -**症状**:创建了数据但 GET 列表查不到。 - -**原因**:SQLAlchemy event 自动注入 `tenant_id` 过滤,测试用户的 tenant_id 与数据不匹配。 - -**修复**: -```python -# ✅ 确保测试用户属于正确的租户 -# 1. 创建测试租户 -# 2. 将测试用户加入该租户 -# 3. 用该用户的 token 创建和查询数据 - -# ❌ 不要试图绕过 tenant_id(那是安全底线) -``` - ---- - -## 陷阱 4:OpenFGA 权限未同步 - -**症状**:创建资源后,同一用户立即查询却被权限拒绝。 - -**原因**:资源创建时应同步写入 OpenFGA owner 元组,如果 `PermissionService.authorize()` 调用失败或遗漏,用户虽然创建了资源但没有 owner 权限。 - -**修复**: -```python -# ✅ 创建后验证权限元组已写入 -resp = await client.post("/resource", json={...}, headers=admin_headers) -data = assert_resp_200(resp) - -# 紧接着用同一用户查询,应该能看到 -get_resp = await client.get(f"/resource/{data['id']}", headers=admin_headers) -assert_resp_200(get_resp) # 如果失败,说明 OpenFGA 元组没写入 -``` - ---- - -## 陷阱 5:cleanup 顺序错误 - -**症状**:`DELETE /resource/{id}` 返回错误,因为有关联数据未先删除。 - -**原因**:BiSheng 资源间有关联关系(如知识库→文件、助手→工具/技能/知识库),删除有顺序要求。 - -**修复**: -```python -# ✅ 正确的 cleanup 顺序(依赖关系逆序) -async def cleanup_feature_data(client, token, prefix): - headers = auth_headers(token) - - # 1. 先删除依赖方(如关联表、子资源) - # 2. 再删除主资源 - # 3. 最后清理 OpenFGA 元组(如有直接操作的话) - - # 示例:删除知识库 - # 先删知识库文件 → 再删知识库空间 - -# ❌ 不要假设可以直接删除主资源 -``` - ---- - -## 陷阱 6:Celery 异步任务未完成就断言 - -**症状**:创建知识库文件后立即查询,状态还是 `WAITING` 而非 `SUCCESS`。 - -**原因**:文件处理通过 Celery `knowledge_celery` 队列异步执行,创建 API 返回后任务可能还在处理。 - -**修复**: -```python -# ✅ 轮询等待异步任务完成 -import asyncio - -async def wait_for_status(client, path, token, expected_status, timeout=30): - headers = auth_headers(token) - for _ in range(timeout): - resp = await client.get(path, headers=headers) - data = resp.json()["data"] - if data["status"] == expected_status: - return data - await asyncio.sleep(1) - raise TimeoutError(f"Status not reached: {expected_status}") - -# 使用 -data = await wait_for_status( - client, f"/knowledge_file/{file_id}", - admin_token, expected_status=2 # SUCCESS -) -``` - ---- - -## 陷阱 7:分页参数不一致 - -**症状**:列表查询返回的数据数量不对。 - -**原因**:BiSheng 不同 API 的分页参数名称可能不同(`page`/`page_num`、`limit`/`page_size`/`size`)。 - -**修复**: -```python -# ✅ 先查看 API 文档确认参数名 -# 常见模式: -resp = await client.get("/resource", params={ - "page": 1, # 或 page_num - "limit": 10, # 或 page_size 或 size -}, headers=headers) - -# ✅ 响应分页格式(PageData) -data = resp.json()["data"] -items = data["data"] # 列表数据 -total = data["total"] # 总数 -``` - ---- - -## 陷阱 8:WebSocket 测试 - -**症状**:WebSocket 连接失败或消息收不到。 - -**原因**:BiSheng 的 WebSocket 使用特殊的认证方式(`UserPayload.get_login_user_from_ws`),token 通过 query 参数传递。 - -**修复**: -```python -# ✅ WebSocket 认证 -import websockets - -async with websockets.connect( - f"ws://localhost:7860/api/v1/chat/{flow_id}?t={token}" -) as ws: - # 发送消息 - await ws.send(json.dumps({"message": "hello"})) - # 接收响应 - response = await ws.recv() -``` - ---- - -## 陷阱 9:RSA 公钥缓存 - -**症状**:多个测试用不同用户登录,部分登录失败。 - -**原因**:公钥可能在短时间内变化,或 RSA 加密使用了错误的 padding。 - -**修复**: -```python -# ✅ 每次登录前重新获取公钥(不缓存) -# helpers/auth.py 中的 get_token() 函数应每次都获取新公钥 -``` - ---- - -## 快速诊断表 - -| 错误关键词 | 可能原因 | 首先检查 | -|-----------|---------|---------| -| HTTP 200 但 status_code 非 200 | 业务错误 | 检查 MMMEE 错误码含义 | -| 401 Unauthorized | Token 过期或格式错误 | 重新获取 token,检查 Cookie/Header | -| 查不到刚创建的数据 | tenant_id 不匹配 | 确认用户与资源同租户 | -| 权限拒绝(刚创建的资源) | OpenFGA 元组未写入 | 检查 PermissionService.authorize() | -| DELETE 失败 400/409 | 有关联数据 | 按依赖逆序删除 | -| 异步操作状态不对 | Celery 任务未完成 | 轮询等待 + 增加 timeout | -| 分页数据数量不对 | 参数名不一致 | 查 API 文档确认 page/limit 参数名 | -| 登录失败 | RSA 加密问题 | 检查公钥获取和加密 padding | -| `Connection refused` | 后端未启动 | 确认 localhost:7860 可访问 | -| `Redis connection error` | Redis 未启动 | 确认 Redis 服务运行中 | diff --git a/.cursor/skills/e2e-test/references/test-template.md b/.cursor/skills/e2e-test/references/test-template.md deleted file mode 100644 index fd29cdfbe3..0000000000 --- a/.cursor/skills/e2e-test/references/test-template.md +++ /dev/null @@ -1,226 +0,0 @@ -# E2E 测试文件骨架模板 - -## 完整 pytest 模板 - -```python -""" -E2E tests for - -Prerequisites: -- Backend running on localhost:7860 -- MySQL/Redis/Milvus/ES/OpenFGA services running - -Covers: -- AC-01: -- AC-02: -""" - -import pytest -import httpx - -from test.e2e.helpers.auth import get_admin_token, get_user_token, auth_headers, create_test_user -from test.e2e.helpers.api import API_BASE, assert_resp_200, assert_resp_error -from test.e2e.helpers.cleanup import cleanup_by_prefix, ensure_test_tenant - -# Data prefix for test isolation (must be >= 5 chars) -PREFIX = "e2e--" - -# Test tenant for multi-tenant isolation -TEST_TENANT = "e2e--tenant" - - -class TestE2E: - """E2E: """ - - # ──────── Fixtures ──────── - - @pytest.fixture(autouse=True, scope="class") - async def setup_and_teardown(self): - """双重 cleanup: setup 清理上次残留 + teardown 清理本次""" - async with httpx.AsyncClient(base_url=API_BASE, timeout=30.0) as client: - # Setup: 获取 admin token - admin_token = await get_admin_token(client) - headers = auth_headers(admin_token) - - # Setup: 确保测试租户存在 - await ensure_test_tenant(client, admin_token, TEST_TENANT) - - # Setup: 清理上次残留的测试数据 - await cleanup_by_prefix(client, "/resource", PREFIX, admin_token) - - yield # 运行测试 - - # Teardown: 清理本次创建的测试数据 - await cleanup_by_prefix(client, "/resource", PREFIX, admin_token) - - @pytest.fixture - async def client(self): - """提供 httpx AsyncClient""" - async with httpx.AsyncClient(base_url=API_BASE, timeout=30.0) as client: - yield client - - @pytest.fixture - async def admin_token(self, client): - """获取管理员 token""" - return await get_admin_token(client) - - @pytest.fixture - async def user_token(self, client, admin_token): - """创建并返回普通用户 token""" - user = await create_test_user( - client, admin_token, - username=f"{PREFIX}user", - role_id=2 # DefaultRole - ) - return await get_user_token(client, user["user_name"], "test_password") - - # ──────── Happy Path Tests ──────── - - async def test_ac01_create_success(self, client, admin_token): - """AC-01: <操作描述> → <预期结果>""" - headers = auth_headers(admin_token) - - # 创建资源 - resp = await client.post( - "/resource", - json={"name": f"{PREFIX}test-entity"}, - headers=headers, - ) - - # 断言 UnifiedResponseModel 成功格式 - data = assert_resp_200(resp) - assert data["name"] == f"{PREFIX}test-entity" - assert "id" in data - - # 通过 GET 验证最终状态(不仅依赖创建响应) - get_resp = await client.get(f"/resource/{data['id']}", headers=headers) - get_data = assert_resp_200(get_resp) - assert get_data["name"] == f"{PREFIX}test-entity" - - async def test_ac02_list_with_pagination(self, client, admin_token): - """AC-02: 分页查询资源列表""" - headers = auth_headers(admin_token) - - resp = await client.get( - "/resource", - params={"page": 1, "limit": 10}, - headers=headers, - ) - - data = assert_resp_200(resp) - assert "data" in data # PageData format - assert "total" in data - - # ──────── Error Path Tests ──────── - - async def test_ac03_duplicate_name_rejected(self, client, admin_token): - """AC-03: 重复名称 → 返回 MMMEE 错误码""" - headers = auth_headers(admin_token) - - # 创建第一个 - await client.post( - "/resource", - json={"name": f"{PREFIX}duplicate"}, - headers=headers, - ) - - # 创建同名第二个 - resp = await client.post( - "/resource", - json={"name": f"{PREFIX}duplicate"}, - headers=headers, - ) - - # 断言具体错误码(不仅检查非 200) - assert_resp_error(resp, expected_code=10901) # MMMEE - - # ──────── Permission Tests ──────── - - async def test_ac04_unauthorized_access_denied(self, client, user_token, admin_token): - """AC-04: 普通用户无权访问管理接口 → 权限拒绝""" - headers = auth_headers(user_token) - - resp = await client.get("/admin-only-resource", headers=headers) - assert_resp_error(resp, expected_code=10601) # user permission denied - - async def test_ac05_cross_tenant_blocked(self, client, admin_token): - """AC-05: 跨租户访问 → tenant_id 不匹配拒绝""" - # 创建资源属于 tenant A - headers_a = auth_headers(admin_token) # tenant A - resp = await client.post( - "/resource", - json={"name": f"{PREFIX}tenant-a-only"}, - headers=headers_a, - ) - resource_id = assert_resp_200(resp)["id"] - - # 用 tenant B 的 token 尝试访问 - # (需要创建 tenant B 的用户和 token) - # headers_b = auth_headers(tenant_b_token) - # resp = await client.get(f"/resource/{resource_id}", headers=headers_b) - # assert resp.status_code == 200 - # body = resp.json() - # assert body["status_code"] != 200 # 应该被拒绝 -``` - -## 关键结构规则 - -1. **class-based 组织** — 每个 Feature 一个 TestClass,fixture 管理生命周期 -2. **setup_and_teardown 是 class-scoped** — 确保整个类运行前清理 + 运行后清理 -3. **每个测试方法 docstring 标注 AC-NN** — 追溯到 spec.md 的 AC 表格 -4. **PREFIX 常量** — 所有测试数据以 `e2e-{feature}-` 开头 -5. **API 验证** — 数据变更后,通过 GET 断言最终状态 -6. **共享 helpers** — 认证/断言/清理使用 `test/e2e/helpers/`,不在文件内重定义 -7. **权限配对** — 每个 "允许" 操作配对一个 "拒绝" 测试 - -## 响应断言模式 - -```python -# ✅ 正确:断言 UnifiedResponseModel 完整格式 -def assert_resp_200(resp): - assert resp.status_code == 200 - body = resp.json() - assert body["status_code"] == 200 - assert body["status_message"] == "SUCCESS" - return body["data"] - -# ✅ 正确:断言具体 MMMEE 错误码 -def assert_resp_error(resp, expected_code): - body = resp.json() - assert body["status_code"] == expected_code - -# ❌ 错误:只检查 HTTP 状态码 -assert resp.status_code == 400 # BiSheng 业务错误也返回 HTTP 200 -``` - -## 认证模式 - -```python -# ✅ JWT Cookie 认证(BiSheng 主要认证方式) -headers = {"Cookie": f"access_token_cookie={token}"} - -# ✅ 或 Header 认证 -headers = {"Authorization": f"Bearer {token}"} - -# 获取 token -resp = await client.post("/user/login", json={ - "user_name": "admin", - "password": "" -}) -token = resp.json()["data"]["access_token"] -``` - -## 多租户测试模式 - -```python -# ✅ 测试租户隔离 -TEST_TENANT_CODE = "e2e-feature-tenant" - -# setup: 确保测试租户存在 -await ensure_test_tenant(client, admin_token, TEST_TENANT_CODE) - -# 创建属于测试租户的数据 -# (tenant_id 由 SQLAlchemy event 自动注入,不需手动设置) - -# 验证:不同租户的用户看不到此数据 -``` diff --git a/.cursor/skills/i18n-localizer/SKILL.md b/.cursor/skills/i18n-localizer/SKILL.md deleted file mode 100644 index 5b60788441..0000000000 --- a/.cursor/skills/i18n-localizer/SKILL.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -name: i18n-localizer -description: Internationalize a module by extracting hardcoded Chinese strings, generating translation keys, and updating all three locale files (en, zh-Hans, ja). ---- - -# i18n Localizer - -This skill extracts hardcoded Chinese strings from a React module and replaces them with `useLocalize()` calls, keeping all three locale files in sync. - -## Instructions -1. **Read the Workflow**: Read the content of `resources/INSTRUCTIONS.md` for the complete step-by-step process. -2. **Read the Conventions**: Read `resources/CONVENTIONS.md` for key naming rules and usage patterns. -4. **Execute**: Follow the workflow to localize the target module. diff --git a/.cursor/skills/i18n-localizer/resources/CONVENTIONS.md b/.cursor/skills/i18n-localizer/resources/CONVENTIONS.md deleted file mode 100644 index 443cc76f3e..0000000000 --- a/.cursor/skills/i18n-localizer/resources/CONVENTIONS.md +++ /dev/null @@ -1,133 +0,0 @@ -# i18n Conventions for This Project - -## Technology Stack - -- **Library**: `i18next` (v24+) + `react-i18next` (v15+) + `i18next-browser-languagedetector` (v8+) -- **Supported Languages**: `en` (English), `zh-Hans` (Simplified Chinese), `ja` (Japanese) - -## File Locations - -| File | Purpose | -|------|---------| -| `src/locales/i18n.ts` | i18next initialization and configuration | -| `src/locales/en/translation.json` | English translations | -| `src/locales/zh-Hans/translation.json` | Simplified Chinese translations | -| `src/locales/ja/translation.json` | Japanese translations | -| `src/hooks/useLocalize.ts` | Custom hook wrapping `useTranslation` with Recoil lang state | - -## Key Naming Convention - -### Domain Namespaces - -Keys are organized by domain namespace. Each domain is a top-level object in the JSON: - -| Namespace | Scope | -|-----------|-------| -| `com_ui` | General UI elements (buttons, labels, status text) | -| `com_nav` | Navigation, sidebar, top bar, menus | -| `com_auth` | Authentication (login, register, password) | -| `com_endpoint` | LLM endpoint configuration | -| `com_sop` | SOP / task execution features | -| `com_knowledge` | Knowledge base management | -| `com_tools` | Tool panel and tool-related features | -| `com_agent` | Agent-related features | -| `com_app` | App center / agent marketplace | -| `com_invite` | Invitation features | -| `com_linsight` | Linsight-specific features | -| `com_label` | Label / tagging features | -| `com_search` | Search-related features | -| `com_file` | File management | -| `com_message` | Chat message related | -| `com_segment` | Mode segment features | - -### Key Naming Rules - -1. Use **snake_case** (all lowercase, underscores between words). -2. Keep keys **descriptive but concise** (2-5 words). -3. For similar operations, use consistent suffixes: `_success`, `_error`, `_failed`, `_confirm`, `_placeholder`, `_title`, `_desc`. -4. Do NOT include the translated text in the key name. - -## JSON File Format - -> [!IMPORTANT] -> **Legacy keys** (flat format like `"com_ui_cancel": "Cancel"`) MUST be left as-is. Do NOT refactor them. -> **New keys** MUST use the nested namespace format described below. - -### New Key Format (Nested) - -New keys use nested objects grouped by domain namespace: - -```json -{ - "com_ui_cancel": "Cancel", - "com_ui_delete": "Delete", - - "com_knowledge": { - "space_create_success": "Knowledge space created", - "space_deleted": "Space has been dissolved", - "folder_max_depth": "Folder depth limit reached (10 levels)", - "drop_to_upload": "Drop files here to upload" - } -} -``` - -- Old flat keys like `"com_ui_cancel"` stay untouched at root level. -- New keys go inside their namespace object (e.g. `com_knowledge.space_create_success`). -- Within each namespace object, keys are sorted alphabetically. -- Namespace objects are placed after all legacy flat keys, also sorted alphabetically. - -### Interpolation - -- Use `{{0}}`, `{{1}}` for positional args; `{{name}}` for named args. -- Use `$t(keyName)` to reference other keys inline. - -## Usage in Components - -### Import Pattern - -```tsx -// Preferred: via the barrel export -import { useLocalize } from "~/hooks"; - -// Alternative: direct import -import useLocalize from "~/hooks/useLocalize"; -``` - -### Component Usage - -```tsx -function MyComponent() { - const localize = useLocalize(); - - return ( -
- {/* New nested key — use dot notation */} -

{localize("com_knowledge.title")}

- - {/* Legacy flat key — unchanged */} - - - {/* With interpolation */} -

{localize("com_knowledge.files_count", { 0: fileCount })}

-
- ); -} -``` - -### Toast Messages - -```tsx -showToast({ - message: localize("com_knowledge.space_create_success"), - severity: NotificationSeverity.SUCCESS -}); -``` - -## Interpolation Examples - -| Pattern | Locale Value | Code | -|---------|-------------|------| -| Positional | `"已选择 {{0}} 个文件(共 {{1}} 个文件)"` | `localize("key", { 0: selected, 1: total })` | -| Named | `"File: {{name}} exceeds {{size}}MB"` | `localize("key", { name, size })` | -| Nested ref | `"$t(linsight)正在规划..."` | Automatically resolved by i18next | -| Plural (count) | `"剩余任务次数: {{count}}次"` | `localize("key", { count: remaining })` | diff --git a/.cursor/skills/i18n-localizer/resources/CONVENTIONS_PLATFORM.md b/.cursor/skills/i18n-localizer/resources/CONVENTIONS_PLATFORM.md deleted file mode 100644 index 00444d63c5..0000000000 --- a/.cursor/skills/i18n-localizer/resources/CONVENTIONS_PLATFORM.md +++ /dev/null @@ -1,124 +0,0 @@ -# i18n Conventions for Platform Frontend (src/frontend/platform/) - -## Technology Stack - -- **Library**: `i18next` (v23+) + `react-i18next` (v15+) + `i18next-http-backend` (v2+) -- **Supported Languages**: `en-US` (English), `zh-Hans` (Simplified Chinese), `ja` (Japanese) - -## File Locations - -| File | Purpose | -|------|---------| -| `src/i18n.js` | i18next initialization (HTTP backend loader) | -| `public/locales/en-US/{ns}.json` | English translations | -| `public/locales/zh-Hans/{ns}.json` | Simplified Chinese translations | -| `public/locales/ja/{ns}.json` | Japanese translations | - -## Namespace Files - -Platform uses **multiple namespace files** per language (loaded via HTTP backend at runtime): - -| Namespace | File | Scope | -|-----------|------|-------| -| `bs` | `bs.json` | General UI, common labels, system messages | -| `flow` | `flow.json` | Flow/workflow builder, nodes, edges | -| `model` | `model.json` | LLM model management, fine-tuning | -| `tool` | `tool.json` | Tool/plugin management | -| `dashboard` | `dashboard.json` | Dashboard, charts, analytics | -| `knowledge` | `knowledge.json` | Knowledge base management | - -> When adding keys, choose the namespace that best matches the module the string belongs to. Default to `bs` for cross-cutting or ambiguous strings. - -## Key Naming Convention - -### Key Naming Rules - -1. Use **dot-separated paths** for hierarchy: `knowledge.spaceCreateSuccess`. -2. Use **camelCase** for leaf keys. -3. Keep keys **descriptive but concise** (2-5 words). -4. For similar operations, use consistent suffixes: `Success`, `Error`, `Failed`, `Confirm`, `Placeholder`, `Title`, `Desc`. - -### Example Keys - -```json -// public/locales/zh-Hans/bs.json -{ - "deleteConfirm": "确定要删除吗?", - "saveSuccess": "保存成功", - "cancel": "取消" -} - -// public/locales/zh-Hans/knowledge.json -{ - "spaceCreateSuccess": "知识空间创建成功", - "dropToUpload": "松手即可上传文件至此处", - "folderMaxDepth": "文件夹层级已达上限(10层)" -} -``` - -## JSON File Format - -- Each namespace is a **flat key-value** JSON object (no nesting). -- Keys are sorted alphabetically. -- Use `{{0}}`, `{{1}}` for positional interpolation, `{{name}}` for named interpolation. -- Do NOT duplicate existing keys — search before adding. - -## Usage in Components - -### Import Pattern - -```tsx -import { useTranslation } from "react-i18next" -``` - -### Component Usage - -```tsx -function MyComponent() { - const { t } = useTranslation() - - return ( -
- {/* Default namespace (bs) */} - - - {/* Specific namespace */} -

{t('knowledge:spaceCreateSuccess')}

- - {/* With interpolation */} -

{t('knowledge:filesCount', { 0: fileCount })}

-
- ) -} -``` - -### Toast Messages - -```tsx -import { toast } from "@/components/bs-ui/toast/use-toast" - -toast({ - title: t('prompt'), - variant: 'success', - description: t('knowledge:spaceCreateSuccess') -}) -``` - -### Specifying Namespace via useTranslation - -```tsx -// Load a specific namespace -const { t } = useTranslation('knowledge') -// Now t('spaceCreateSuccess') resolves from knowledge.json - -// Load multiple namespaces -const { t } = useTranslation(['bs', 'knowledge']) -``` - -## Interpolation Examples - -| Pattern | Locale Value | Code | -|---------|-------------|------| -| Positional | `"已选择 {{0}} 个文件(共 {{1}} 个文件)"` | `t('key', { 0: selected, 1: total })` | -| Named | `"文件: {{name}} 超过 {{size}}MB"` | `t('key', { name, size })` | -| Count | `"剩余任务次数:{{count}}次"` | `t('key', { count: remaining })` | diff --git a/.cursor/skills/i18n-localizer/resources/INSTRUCTIONS.md b/.cursor/skills/i18n-localizer/resources/INSTRUCTIONS.md deleted file mode 100644 index a3ee304724..0000000000 --- a/.cursor/skills/i18n-localizer/resources/INSTRUCTIONS.md +++ /dev/null @@ -1,78 +0,0 @@ -# i18n Localization Workflow - -## Step 1 — Scan the Module - -1. Read all `.tsx` and `.ts` files in the target module directory. -2. Identify every hardcoded user-facing string (Chinese text, toast messages, placeholders, button labels, titles, tooltips, error messages, etc.). -3. Ignore: code comments, CSS class names, variable names, enum values, strings already wrapped in `t()` / `localize()` / `i18n.t()`, and dev-only content (`console.log`). - -## Step 2 — Generate Translation Keys - -For each extracted string, determine which domain namespace it belongs to (e.g. `com_knowledge`, `com_ui`, `com_sop`), then generate a concise snake_case key name. - -Example: `"知识空间创建成功"` → namespace `com_knowledge`, key `space_create_success` → used as `com_knowledge.space_create_success` - -Refer to `CONVENTIONS.md` and `SAMPLE_KEYS.json` for naming details. - -## Step 3 — Update Locale Files - -> **CRITICAL**: Legacy flat keys (like `"com_ui_cancel"`) MUST be left untouched. Only ADD new keys using the nested namespace format. - -Add new keys to **all three** translation files using nested structure: - -```json -{ - "com_ui_cancel": "Cancel", - - "com_knowledge": { - "space_create_success": "Knowledge space created", - "drop_to_upload": "Drop files here to upload" - } -} -``` - -| File | Value | -|------|-------| -| `src/locales/zh-Hans/translation.json` | Original Chinese string | -| `src/locales/en/translation.json` | Professional English translation | -| `src/locales/ja/translation.json` | Professional Japanese translation | - -Rules: -- Do NOT modify or restructure existing flat keys. -- New keys go inside their namespace object, sorted alphabetically. -- If the namespace object already exists, append to it. If not, create it. -- Namespace objects are placed after all legacy flat keys, sorted alphabetically. -- Use `{{0}}` for positional interpolation, `{{name}}` for named interpolation. -- Do NOT duplicate existing keys — search before adding. - -## Step 4 — Update Component Code - -1. Import (if not present): `import { useLocalize } from "~/hooks";` -2. Initialize (if not present): `const localize = useLocalize();` -3. Replace hardcoded strings using **dot notation** for new nested keys: - ```tsx - // Before - showToast({ message: "知识空间创建成功" }); - // After - showToast({ message: localize("com_knowledge.space_create_success") }); - - // Before (with dynamic values) - message: `已开始处理 ${files.length} 个文件` - // After - message: localize("com_knowledge.files_processing_started", { 0: files.length }) - - // Before (JSX) -

松手即可上传文件至此处

- // After -

{localize("com_knowledge.drop_to_upload")}

- ``` - -## Step 5 — Verify - -1. No hardcoded Chinese remains in modified files (excluding code comments). -2. Every new key exists in all three locale JSON files. -3. No existing flat keys were modified or restructured. - -## Output - -After completing, provide a summary: number of strings extracted, list of new keys, and files modified. diff --git a/.cursor/skills/react-component-refactor/SKILL.md b/.cursor/skills/react-component-refactor/SKILL.md deleted file mode 100644 index 06b10a2405..0000000000 --- a/.cursor/skills/react-component-refactor/SKILL.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -name: react-component-refactor -description: Refactor large React components by extracting hooks, splitting sub-components, and organizing directory structure following established patterns. ---- - -# React Component Refactor - -This skill provides a systematic approach for refactoring complex React components. Use it when a module has overgrown files, tangled state, or unclear separation of concerns. - -## Instructions -1. **Read the Guidelines**: Read `resources/GUIDELINES.md` for the complete refactoring checklist and rules. -2. **Read the Examples**: Read `resources/EXAMPLES.md` for concrete before/after patterns from real refactoring work. -3. **Execute**: Follow the guidelines to refactor the target module. diff --git a/.cursor/skills/react-component-refactor/resources/EXAMPLES.md b/.cursor/skills/react-component-refactor/resources/EXAMPLES.md deleted file mode 100644 index aa9f2551a8..0000000000 --- a/.cursor/skills/react-component-refactor/resources/EXAMPLES.md +++ /dev/null @@ -1,185 +0,0 @@ -# React Component Refactoring — Real Examples - -These examples are drawn from the `Subscription` module refactoring and demonstrate each pattern in context. - ---- - -## Example 1: Extract Sub-Component - -### Before (in `CreateChannelDrawer.tsx`, ~120 lines inline) -```tsx -// Inline sub-component buried inside the main component -function CreateChannelDrawer({ open, onOpenChange, ... }) { - // ... 18 useState calls ... - - // Inline sub-component — hard to find, test, or reuse - function SubChannelBlock({ data, onNameChange, ... }) { - // 120 lines of JSX + local state - } - - return ( /* uses SubChannelBlock inline */ ); -} -``` - -### After -``` -CreateChannel/ -├── CreateChannelDrawer.tsx # imports SubChannelBlock -└── SubChannelBlock.tsx # standalone, with exported Props interface -``` - -```tsx -// SubChannelBlock.tsx -export interface SubChannelData { id: string; name: string; ... } - -interface SubChannelBlockProps { - data: SubChannelData; - onNameChange: (name: string) => void; - onRemove: () => void; - // ... -} - -export function SubChannelBlock({ data, onNameChange, ... }: SubChannelBlockProps) { - // self-contained component -} -``` - ---- - -## Example 2: Extract Form State Hook - -### Before (`CreateChannelDrawer.tsx` — 18 useState + handlers) -```tsx -function CreateChannelDrawer(...) { - const [channelName, setChannelName] = useState(""); - const [channelDesc, setChannelDesc] = useState(""); - const [visibility, setVisibility] = useState("private"); - const [sources, setSources] = useState([]); - // ... 14 more useState calls ... - - const resetForm = () => { /* reset all 18 states */ }; - const handleAddSubChannel = () => { /* manipulate subChannels state */ }; - // ... more handlers ... - - return ( /* 400+ lines of JSX using all these states */ ); -} -``` - -### After -``` -hooks/ -└── useCreateChannelForm.ts # all 18 states + handlers -CreateChannel/ -└── CreateChannelDrawer.tsx # clean UI component -``` - -```tsx -// hooks/useCreateChannelForm.ts -export function useCreateChannelForm() { - const [channelName, setChannelName] = useState(""); - // ... all states ... - const resetForm = () => { /* ... */ }; - const handleAddSubChannel = () => { /* ... */ }; - - return { channelName, setChannelName, ..., resetForm, handleAddSubChannel }; -} - -// CreateChannelDrawer.tsx — now a presentational component -function CreateChannelDrawer(...) { - const form = useCreateChannelForm(); - return ( - form.setChannelName(e.target.value)} /> - // ... form.visibility, form.handleAddSubChannel, etc. - ); -} -``` - ---- - -## Example 3: Extract Data Manager Hook - -### Before (`AddSourceDropdown.tsx` — 497 lines with data loading + UI) -```tsx -function AddSourceDropdown({ sources, onSourcesChange, expanded, ... }) { - const [wechatSources, setWechatSources] = useState([]); - const [websiteSources, setWebsiteSources] = useState([]); - const [searchKeyword, setSearchKeyword] = useState(""); - - // Data loading effect - useEffect(() => { - if (!expanded) return; - const load = async () => { /* API call + state mapping */ }; - load(currentType); - }, [expanded, activeTab]); - - // WeChat auto-detection effect - useEffect(() => { /* 50 lines of async logic */ }, [expanded, viewMode]); - - // Filtering logic - const filteredSources = useMemo(() => { /* ... */ }, [...]); - - return ( /* 200+ lines of UI */ ); -} -``` - -### After -``` -hooks/ -└── useSourceManager.ts # API calls, filtering, toggle logic -CreateChannel/ -└── AddSourceDropdown.tsx # pure UI (328 lines, down from 497) -``` - -```tsx -// AddSourceDropdown.tsx — clean separation -function AddSourceDropdown({ sources, onSourcesChange, expanded, ... }) { - const mgr = useSourceManager(sources, onSourcesChange, expanded, onExpandChange); - - return ( - mgr.setSearchKeyword(e.target.value)} /> - // ... mgr.filteredSources, mgr.toggleSource, mgr.handleConfirm, etc. - ); -} -``` - ---- - -## Example 4: Extract Validation to Utility - -### Before (inline in submit handler — 45 lines of validation) -```tsx -onClick={async () => { - if (form.sources.length < 1) { showToast({ message: "..." }); return; } - if (!form.channelName.trim()) { showToast({ message: "..." }); return; } - if (form.contentFilter) { - const err = validateFilterGroups(form.filterGroups); - if (err) { showToast({ message: err }); return; } - } - if (form.createSubChannel) { - for (const sub of form.subChannels) { /* more checks */ } - } - // ... then build data and submit -}} -``` - -### After -```tsx -// channelUtils.ts — pure validation function -export function validateCreateChannelForm( - data: CreateChannelFormData, - localize: (key: string) => string -): string | null { - if (data.sources.length < 1) return localize("need_one_source") || "至少需添加 1 个信息源"; - if (!data.channelName.trim()) return localize("cannot_empty_channel_name"); - // ... all checks ... - return null; -} - -// CreateChannelDrawer.tsx — clean submit handler -onClick={async () => { - const data = { /* assemble form data */ }; - const error = validateCreateChannelForm(data, localize); - if (error) { showToast({ message: error, severity: "warning" }); return; } - // submit -}} -``` diff --git a/.cursor/skills/react-component-refactor/resources/GUIDELINES.md b/.cursor/skills/react-component-refactor/resources/GUIDELINES.md deleted file mode 100644 index 06724df659..0000000000 --- a/.cursor/skills/react-component-refactor/resources/GUIDELINES.md +++ /dev/null @@ -1,159 +0,0 @@ -# React Component Refactoring Guidelines - -This document defines the standard refactoring methodology for this project. Follow these rules when adding new features or refactoring existing modules to keep code maintainable and consistent. - ---- - -## 1. Directory Structure Rules - -### When to create a sub-directory -- When a feature area has **3+ closely related component files**, group them into a named sub-directory. -- The directory name should describe the **feature**, not the component (e.g., `CreateChannel/`, not `CreateChannelDrawerFiles/`). - -### Standard layout - -``` -src/pages/ModuleName/ -├── index.tsx # Page entry, layout & routing -├── moduleUtils.ts # Pure utility functions (validation, data transform, payload builders) -├── hooks/ # Custom hooks (one hook per file) -│ ├── useFeatureForm.ts # Form state & handlers -│ └── useDataManager.ts # Data fetching, filtering, CRUD -├── FeatureA/ # Feature sub-directory -│ ├── MainComponent.tsx # Top-level feature component -│ ├── SubComponentA.tsx # Extracted sub-component -│ └── SubComponentB.tsx # Another extracted sub-component -└── FeatureB/ - └── ... -``` - -### Import path conventions -- Components within the same feature directory use relative imports: `./SubComponent` -- Hooks are imported from `../hooks/useXxx` -- Utils are imported from `../moduleUtils` - ---- - -## 2. Component Splitting Rules - -### When to extract a sub-component -- An inline function component is **>120 lines**. -- A block of JSX is **self-contained** (has its own props/state concept). -- A component is **reused** or could be tested independently. - -### How to extract -1. Create a new file in the same feature directory. -2. Define a clear `Props` interface and export it. -3. Move the component body; keep UI unchanged. -4. Import and use in the parent — the parent JSX should only change the component reference. - -### Naming conventions -- Sub-component file name = component name (PascalCase): `SubChannelBlock.tsx` -- Always `export function ComponentName` (named exports, no default). -- Co-export related types/interfaces that are tightly coupled. - ---- - -## 3. Hook Extraction Rules - -### When to extract a hook -- A component has **≥8 `useState` calls**. -- There is a block of **`useEffect` + state** that handles data loading or side effects. -- Multiple event handlers share the same state and form a logical unit. - -### Naming conventions -- File: `hooks/useFeatureName.ts` (camelCase with `use` prefix) -- Hook function: `useFeatureName` -- Return a flat object: `{ stateA, setStateA, handlerB, ... }` -- The consuming component accesses via `const form = useFeatureName(...)` and references `form.stateA` - -### What belongs in a hook -| Belongs in Hook | Stays in Component | -|---|---| -| `useState` declarations | JSX rendering | -| Derived/computed values (`useMemo`) | Layout-specific handlers (e.g., scroll position) | -| Data loading `useEffect`s | Event handlers that only call `showToast` | -| CRUD handlers (add/remove/update) | Direct UI event wiring | -| Form reset logic | | - -### What does NOT belong in a hook -- UI library calls (`showToast`, `localize`) — pass as params if needed -- API layer definitions — keep in `~/api/` -- Component-specific render helpers - ---- - -## 4. Utility / Validation Extraction Rules - -### When to extract to `moduleUtils.ts` -- **Validation functions** that check form data and return error messages. -- **Payload builders** that transform form data into API payloads. -- **Data transformers** that convert between API types and UI types. -- **Pure functions** that don't depend on React state or hooks. - -### Function signature pattern -```typescript -// Validation: returns error message or null -export function validateFormData( - data: FormDataType, - localize: (key: string) => string -): string | null; - -// Payload builder: transforms form → API payload -export function buildPayload(data: FormDataType): ApiPayloadType; -``` - -### Rules -- Keep functions pure — no side effects. -- Accept `localize` as a parameter for i18n error messages. -- The component is responsible for displaying errors (toast/UI). - ---- - -## 5. Refactoring Checklist - -When refactoring a module, follow this order: - -1. **[ ] Analyze** — Count lines, identify state density, find inline sub-components. -2. **[ ] Restructure directories** — Group files by feature if threshold met. -3. **[ ] Extract sub-components** — Move inline components to separate files. -4. **[ ] Extract hooks** — Pull state management into `hooks/useXxx.ts`. -5. **[ ] Extract utilities** — Move validation and data transforms to `moduleUtils.ts`. -6. **[ ] Clean imports** — Remove unused imports, verify all paths resolve. -7. **[ ] Verify** — Run `yarn start` to ensure compilation succeeds. - -### DO NOT change during refactoring -- **UI/JSX structure** — no visual changes. -- **CSS classes** — keep exact same styling. -- **API layer** — do not restructure API files unless explicitly requested. -- **i18n hardcoded strings** — handle separately with the `i18n-localizer` skill. - ---- - -## 6. File Size Guidelines - -| File Type | Target Lines | Action if exceeded | -|---|---|---| -| Page component (`index.tsx`) | < 600 | Extract sub-sections | -| Feature component | < 600 | Extract hooks & sub-components | -| Custom hook | < 200 | Split by concern | -| Utility file | < 300 | Split by domain | -| Sub-component | < 150 | Already well-scoped | - ---- - -## 7. Data Flow Conventions - -``` -API Layer (~/api/) - ↕ raw types -Hooks (hooks/useXxx.ts) - ↕ processed state + handlers -Component (Feature/Main.tsx) - ↕ props -Sub-components (Feature/Sub.tsx) -``` - -- **Unidirectional**: Parent → Child via props; Child → Parent via callback props. -- **No prop drilling beyond 3 levels** — if deeper, use a hook or context. -- **Hooks own the state**, components own the rendering. diff --git a/.cursor/skills/sdd-review/SKILL.md b/.cursor/skills/sdd-review/SKILL.md deleted file mode 100644 index 7c55026d6e..0000000000 --- a/.cursor/skills/sdd-review/SKILL.md +++ /dev/null @@ -1,116 +0,0 @@ ---- -name: sdd-review -description: 对 BiSheng 项目的 SDD 文档执行审查。 - - spec:写完 spec.md 后调用,同时检查 PRD gap 和架构合规性,生成报告供用户参考 - - design:写完 design.md 后调用,检查"接手测试"四要素(现状/决策/坑/契约),生成报告供用户参考 - - tasks:写完 tasks.md 后自动调用,检查 AC 追溯、任务拆解质量和技术债预防 - 用法:/sdd-review ,doc_type 为 spec / design / tasks。 - TRIGGER when: 用户完成了 SDD 的 spec.md / design.md / tasks.md 编写,或者用户使用 /sdd-review 命令,或者 Claude 完成了这些文件的编写后需要审查。 ---- - -# SDD Review Skill - -## 调用方式 - -``` -/sdd-review -``` - -例: -``` -/sdd-review features/v2.5.0/004-rebac-core spec -/sdd-review features/v2.5.0/001-multi-tenant tasks -``` - -## 审查流程 - -### 第一步:解析参数 - -从用户输入或调用上下文中提取: -- `feature_dir`:特性目录路径(如 `features/v2.5.0/004-rebac-core`) -- `doc_type`:文档类型,必须是 `spec` / `design` / `tasks` - -若参数缺失或无效,向用户报告错误后停止。 - ---- - -### spec 模式(辅助审查,不自动推进) - -spec.md 合并了需求规范和技术设计,因此 spec 审查同时覆盖需求覆盖和架构合规检查。 - -**第二步(spec):执行合并审查** - -读取文件: -- `/spec.md`(已写的规格文档) -- spec.md 中"关联 PRD"字段指向的文件(若未标注,读取 `docs/PRD/` 下与特性名最相关的文件) -- `features/v2.5.0/release-contract.md`(不变量约束,确认 spec 未越界) -- `docs/architecture/02-backend-modules.md`(后端模块架构) -- `docs/architecture/10-permission-rbac.md`(权限体系) - -按 `references/spec-checklist.md` 中的检查清单执行 14 项检查。 - -**第三步(spec):展示报告,等待用户确认** - -向用户展示分析结果: -- 无 gap / 无问题:告知"审查通过,可继续确认" -- 有 gap / 有问题:展示每个问题(MISSING / FORMAT / CONFLICT / ISSUE),供用户决定是否修改 - -**等待用户确认**(唯一手动暂停点)。用户确认后,将 `/tasks.md` 状态表中 spec.md 行更新为 `✅ 已评审`。 - ---- - -### design 模式(辅助审查,不自动推进) - -design.md 是"现状快照 + 关键决策"文档,决定新 agent 接手时能否在不读代码情况下快速建立认知。审查重点是**接手测试**四要素:现状、决策、坑、契约。 - -**第二步(design):执行审查** - -读取文件: -- `/design.md`(已写的设计文档) -- `/spec.md`(校验 design 没偏离需求) -- `/tasks.md`(若存在;校验偏差记录已回写) -- `features/_templates/design.md`(模板结构基线) -- `features/v{X.Y.Z}/release-contract.md`(关键约束、不变量) - -按 `references/design-checklist.md` 中的检查清单执行 24 项检查。 - -**第三步(design):展示报告,等待用户确认** - -向用户展示分析结果: -- 无问题:告知"审查通过,可继续确认" -- 有问题:展示每个问题(ISSUE 含 SEVERITY 和 LOC),供用户决定是否修改 - -**等待用户确认**(唯一手动暂停点)。用户确认后,将 `/tasks.md` 状态表中 design.md 行更新为 `✅ 已评审`(若状态表尚无该行,提示用户补一行 `| design.md | 🔲 草稿 | ... |`)。 - ---- - -### tasks 模式(自动审查) - -**第二步(tasks):执行审查** - -读取文件: -- `/tasks.md` -- `/spec.md`(验收标准 + 技术方案) -- `features/v2.5.0/release-contract.md`(领域归属 + 不变量) - -按 `references/tasks-checklist.md` 中的检查清单执行 21 项检查。 - -**第三步(tasks):处理审查结果** - -**输出格式**: -- 有问题:`ISSUE: <描述> | SEVERITY: high/medium/low | TASK: ` -- 无问题:`LGTM` - -**处理逻辑**: -- `LGTM` → 更新 `/tasks.md` 状态表,将 `tasks.md` 行改为 `✅ 已拆解` -- 有 `high`/`medium` ISSUE → 修复后重新审查(最多 2 轮) -- `low` ISSUE → 记录但跳过 -- 2 轮后仍有 `high`/`medium` → 停止,向用户报告剩余问题 - -## 错误处理 - -- feature_dir 不存在 → 报告路径错误,停止 -- doc_type 不是 spec / design / tasks → 报告参数错误,停止 -- spec.md 不存在 → 报告"找不到 spec.md,请先完成 spec",停止 -- design.md 不存在(design 模式)→ 报告"找不到 design.md,请先按 features/_templates/design.md 完成 design",停止 -- tasks.md 不存在(tasks 模式)→ 报告"找不到 tasks.md,请先完成 tasks",停止 diff --git a/.cursor/skills/sdd-review/references/design-checklist.md b/.cursor/skills/sdd-review/references/design-checklist.md deleted file mode 100644 index d20d32e6e4..0000000000 --- a/.cursor/skills/sdd-review/references/design-checklist.md +++ /dev/null @@ -1,76 +0,0 @@ -你是 BiSheng 项目的设计文档评审员。请审查 design.md 是否能让一个**完全不熟悉本 feature 的新 agent**在不读代码、不翻 commit log 的情况下,理解"今天系统长什么样、为什么这么做、改的时候要注意什么"。 - -请自行读取以下文件: -- {feature_dir}/design.md(待审查的设计文档) -- {feature_dir}/spec.md(What 和 AC,校验 design 没有偏离需求) -- {feature_dir}/tasks.md(若已存在;校验流水账与 design 现状一致) -- features/_templates/design.md(模板结构基线) -- features/v{X.Y.Z}/release-contract.md(确认关键约束、不变量、依赖契约对齐) -- docs/constitution.md(架构铁律 C1–C7,用于 Constitution Check) - -**核心评判标准 — 接手测试**: -> 想象一个新 agent 一个月后接手这个 feature 做扩展。他**只能读 design.md**, -> 不能问人、不能猜代码。他能否: -> (a) 5 分钟说出系统现状;(b) 知道哪些决策是"被否决的备选"防止重复尝试; -> (c) 知道有哪些"运行时反直觉"的坑;(d) 知道改了我会破坏谁、我依赖谁。 -> 若任何一项不能,对应章节就是不合格的。 - ---- - -**§1 目标与非目标**: -1. 目标是否 1-3 句话说清,避免长篇大论? -2. 非目标是否明确(防止后人误扩范围)? - -**§2 关键约束 + Constitution Check**: -3. 是否只写**本功能特有**的约束(性能 / 容量 / 部署形态 / 上游数据格式依赖)? -4. 全局架构铁律(双 DB / 多租户 / 权限 / 分层 / 错误码)是否**只引用 `docs/constitution.md` C1–C7、未重抄**(而非凭空写或抄一遍)? - - **Constitution Check(门禁)**:design 的方案是否违反 constitution 任一条 C1–C7?违反 → SEVERITY high(BLOCKER)。 - -**§3 方案对比与选定(最高价值章节)**: -5. 是否至少记录 1-3 条**关键设计决策**?(feature 完全无决策极少见) -6. 每条决策是否包含:备选方案(至少 1 个被否决的)/ 选定 / 原因? -7. 每条决策是否给出"何时该重新考虑"的触发条件(否则就是死锚点)? -8. "原因"是否引用了具体证据(性能数字、上游约束、踩过的坑),而非"感觉更好"? - -**§4 系统现状(接手必读)**: -9. 数据流是否能让人 30 秒理解"输入 → 输出"主线? -10. 关键数据结构 / 字段约定是否写明**对外可见**的契约(字段名、JSON 格式、类型)? -11. 关键模块职责表是否同时写了"做什么 + 不做什么"? -12. 是否与 spec.md §5-§7 的实际实现一致(没有过时描述)? - -**§5 已知坑 / 反直觉事实(最防失传章节)**: -13. 是否至少列出 1 条**反直觉**事实(代码里看不出、commit message 散落)? - - 若 feature 真的没有任何反直觉点,要在本节明确写"无",而不是留空 -14. 每条坑是否带"如果不知道会怎样"(让后人知道严重性)? -15. 每条坑是否给出"在哪处理"(具体文件 / 函数名)? - -**§6 对外契约与依赖**: -16. Outgoing(我提供给别人的)是否包含 HTTP API + 内部 Python 接口 + 数据格式? -17. Incoming(我依赖别人的)是否包含上游数据契约 + 系统二进制 + 第三方服务? -18. 每条依赖是否标注"风险点"(什么变化会破坏我)? - -**§7 测试与可观测**: -19. 是否说清楚整体策略(单元 / 集成 / e2e 各覆盖哪一层),而不是重复 tasks.md 的清单? -20. 是否给了"手动验证一遍"的可操作命令 / URL / 账号? - -**§8 后续改进**: -21. 是否标出已知短板和"不打算做的理由",防止后人重复提议? - -**整体一致性**: -22. 修订历史是否在 feature 完成时已记初版? -23. design.md 是否与 spec.md 不矛盾?(spec 是不变目标,design 是当前实现,二者口径必须对齐) -24. design.md 是否反映了 tasks.md §实际偏差记录 中的所有"改了系统认知"的偏差? - ---- - -返回格式(必须严格遵守): - -有 gap / 问题时,每个问题单独一行: -`ISSUE: <章节> <描述> | SEVERITY: high/medium/low | LOC: ` - -无问题时:`LGTM` - -**SEVERITY 判断**: -- **high**:缺失"接手测试"的关键章节(§3/§4/§5/§6),或与 spec 矛盾 -- **medium**:章节填了但深度不够(决策缺"原因"或"何时重新考虑"、坑缺"如果不知道会怎样") -- **low**:格式 / 表述 / 修订历史问题 diff --git a/.cursor/skills/sdd-review/references/spec-checklist.md b/.cursor/skills/sdd-review/references/spec-checklist.md deleted file mode 100644 index 1169e6d34d..0000000000 --- a/.cursor/skills/sdd-review/references/spec-checklist.md +++ /dev/null @@ -1,36 +0,0 @@ -你是 BiSheng 项目的需求评审员。 - -**spec.md 是纯 What** —— 用户故事 + 验收标准 + 边界 + out-of-scope。**所有 How(架构决策 / API 契约 / 数据模型 / 分层 / 响应格式 / 文件清单 / 性能指标)都在 design.md,不在 spec。** 请严格按此模型审。 - -请自行读取以下文件: -- {feature_dir}/spec.md(已写的规格文档) -- {prd_path}(从 spec.md「关联 PRD」字段获取;未标注则读 docs/PRD/ 下与特性名最相关的文件) -- features/v{X.Y.Z}/release-contract.md(不变量约束 + 领域归属,确认 spec 未越界) -- docs/constitution.md(架构铁律 C1–C7) - -**需求覆盖**: -1. PRD 的功能点 / 用户场景,spec 是否都有对应 AC? -2. PRD 的边界条件、错误场景,spec 是否覆盖(§3 边界情况)? -3. out-of-scope(本次明确不做)是否写清,防 scope 膨胀? - -**AC 质量**: -4. AC-ID 是否唯一、格式 `AC-NN`,可被 tasks 的「覆盖 AC: AC-NN」追溯? -5. 是否有 AC 不可测试("友好提示""响应快"这类没法验收的模糊词)? -6. **P0 / 复杂 feature 的 AC 是否用 EARS 句型**(`WHEN/IF/WHILE/WHERE … THE SYSTEM SHALL …`),能直接转成测试?小功能用表格式可放行。 -7. 错误码是否**仅作「可观测的对外行为」引用**(如「返错误码 12061」),而非在 spec 维护错误码详表(那是 design §6 / 代码的事)? - -**边界与合规**: -8. 是否越界进入 release-contract 表 1 中归属其他 Feature 的领域? -9. 是否与 release-contract 的 INV 不变量、或 constitution C1–C7 冲突? -10. **spec 是否误写了 How**(架构决策 / API 契约 / 数据模型 / 分层 / 响应格式 / 文件清单 / 性能指标)?这些应在 design.md —— spec 里出现即为 gap(违反纯 What 模型)。 - -返回格式(必须严格遵守): - -有 gap / 问题时,每个问题单独一行: -- MISSING: | SEVERITY: high/medium/low | PRD_REF: -- ISSUE: | SEVERITY: high/medium/low | AC: -- CONFLICT: <与 INV / constitution 冲突> | SEVERITY: high | REF: - -无 gap 且无问题时,只返回一行:LGTM - -注意:本报告供参考,是否修改由用户决定。不要建议怎么改,只列出观察到的 gap 和问题。 diff --git a/.cursor/skills/sdd-review/references/tasks-checklist.md b/.cursor/skills/sdd-review/references/tasks-checklist.md deleted file mode 100644 index 9bd6315703..0000000000 --- a/.cursor/skills/sdd-review/references/tasks-checklist.md +++ /dev/null @@ -1,55 +0,0 @@ -你是 BiSheng 项目的任务计划评审员。请审查 {feature_dir}/tasks.md。 - -请自行读取以下文件: -- {feature_dir}/spec.md(验收标准 + 技术方案) -- features/v2.5.0/release-contract.md(领域归属 + 不变量) - -任务规范要求: -- Test-First:后端测试任务必须先于其配对的实现任务 -- 每个测试任务必须有"覆盖 AC: AC-NN, AC-NN"标注 -- 基础设施任务(ORM 模型、错误码、配置)无测试配对,排在最前面 -- 每个任务应在一次 AI 会话内可完成(目标约 30 分钟,最多 1-2 个文件) -- 依赖关系:依赖的任务 ID 必须存在且顺序合理 -- 每个任务必须自包含:内联文件路径、逻辑、测试上下文(实现阶段不需要回读 spec.md) -- 任务分 6 类:基础设施 / 后端 Domain / 后端 API / 前端 Platform / 前端 Client / Worker -- 前端任务必须区分 Platform(src/frontend/platform/)和 Client(src/frontend/client/) -- Worker 任务须说明 tenant_id 传递方式(Celery headers → ContextVar) -- 「测试降级」标注仅在测试成本极高时允许,必须说明理由 - -审查清单(4 组 17 条 + BiSheng 特有 4 条): - -**A. 形式合规** -1. **AC 追溯完整性** — spec.md 中每条 AC 是否都有至少一个测试任务覆盖(带"覆盖 AC:"标注)? -2. **AC 标注完整性** — 是否存在缺少"覆盖 AC:"标注的测试任务? -3. **Test-First 顺序** — 后端测试任务是否先于其配对的实现任务? -4. **依赖关系正确性** — 被依赖的任务 ID 是否存在、顺序是否合理? -5. **原子化** — 每个任务范围是否 ≤ 2 个文件,能在一次会话内完成? -6. **自包含** — 每个任务是否内联了文件路径、逻辑描述、测试上下文? - -**B. 任务拆解质量** -7. **粒度合理性** — 单个任务不超过 3 个文件、不跨前后端? -8. **顺序高效性** — 不存在任务 A 的输出被后续任务覆盖/重写的返工情况? -9. **无重复工作** — 不存在多个任务对同一文件同一部分做非增量的重复修改? -10. **spec 覆盖完整性** — spec.md 中定义的每个 API 端点、ORM 模型、Service 方法、前端组件都有对应实现任务? -11. **任务间接口清晰** — 任务描述中明确前驱任务的产出(DAO 方法签名、Service 接口、API 端点路径)? -12. **无过度工程** — 不存在 spec.md 中未提及但 tasks.md 中新增的实现内容? - -**C. AC 标注规范** -13. **AC 标注格式** — 必须逐条列举 `AC-01, AC-02`,禁止 `AC-01~AC-05` 范围写法? -14. **测试任务纯净性** — 标注了"覆盖 AC"的测试任务不得混入实现逻辑? - -**D. 技术债预防** -15. **无延迟 TODO** — 任务描述中不得有 TODO/FIXME/HACK 将本 Feature 范围内问题推迟? -16. **数据库回滚** — 数据库模型变更任务需包含回滚方案或说明不可逆原因? -17. **跨 Feature 副作用** — 修改其他 Feature 领域对象的写入行为需检查 release-contract.md;修改共享文件需说明影响范围? - -**E. BiSheng 特有** -18. **前端分区** — 前端任务是否区分 Platform / Client 两个分区,不混在一起? -19. **Worker tenant_id** — Worker/Celery 任务是否说明 tenant_id 传递方式(headers → ContextVar)? -20. **基础设施优先** — 基础设施任务(ORM/错误码/conftest)是否排在所有业务任务之前? -21. **测试降级理由** — 标注「测试降级」的任务是否说明了充分理由(如需要 Milvus/ES mock)? - -返回格式(必须严格遵守): -有问题时,每个问题单独一行: -- ISSUE: <描述> | SEVERITY: high/medium/low | TASK: -无问题时,只返回一行:LGTM diff --git a/.cursor/skills/task-review/SKILL.md b/.cursor/skills/task-review/SKILL.md deleted file mode 100644 index 305ffdf043..0000000000 --- a/.cursor/skills/task-review/SKILL.md +++ /dev/null @@ -1,108 +0,0 @@ ---- -name: task-review -description: L1 任务级代码审查。在每个任务完成后执行轻量级约定合规检查, - 确保架构红线和编码约定在任务级别被守住,不让违规累积到特性级审查(L2)才发现。 - 用法:/task-review - TRIGGER when: 用户完成了一个 SDD 任务(实现或测试),或者用户使用 /task-review 命令。 ---- - -# Task Review Skill(L1 任务级审查) - -## 调用方式 - -``` -/task-review -``` - -例: -``` -/task-review features/v2.5.0/004-rebac-core T003 -/task-review features/v2.5.0/007-resource-permission-ui T007 -``` - -## 审查流程 - -### Step 1: 解析参数 + 收集变更范围 - -1. 验证参数: - - `feature_dir` 必须存在且包含 `tasks.md` - - `task_id` 必须匹配 tasks.md 中的某个任务(格式:`T001`、`T003` 等) - - 若参数缺失或无效,报告错误后停止 - -2. 从 `/tasks.md` 中读取指定任务的元数据: - - 任务类型(测试 / 实现 / 基础设施 / Worker) - - 目标文件列表 - - 前置依赖 - - 配对任务(测试↔实现) - - 覆盖 AC 标注(测试任务) - -3. 读取任务声明的所有目标文件内容(直接读取文件,不依赖 git diff) - -### Step 2: 判断任务类型,选择检查子集 - -根据任务类型确定适用的检查项(参见 `references/task-checklist.md`): - -| 任务类型 | 适用检查项 | 额外检查 | -|---------|-----------|---------| -| **测试任务** | #2 命名 + #5 前端约定 | AC 标注格式(`覆盖 AC: AC-NN`) | -| **实现任务** | 完整 #1~#7 | 配对测试任务已完成(tasks.md 中已打勾);design.md 同步检查 | -| **基础设施任务** | #1 架构分层 + #4 数据库约定 + #6 信息泄漏 + #7 设计同步 | 无 | -| **Worker 任务** | #1 架构 + #4 数据库 + #6 信息泄漏 + #7 设计同步 | tenant_id 通过 Celery headers 传递 | - -任务类型判断规则: -- 文件路径包含 `test/` 或 `__tests__/` → 测试任务 -- 文件路径包含 `domain/models/` 或 `common/errcode/` 或任务描述含"ORM""迁移""错误码""配置" → 基础设施任务 -- 文件路径包含 `worker/` 或任务描述含"Celery""异步任务" → Worker 任务 -- 其他 → 实现任务 -- 若任务同时包含测试和实现文件,按实现任务处理 - -### Step 3: 按检查清单执行检查 - -逐项执行 `references/task-checklist.md` 中适用的检查项。 - -### Step 4: 元数据交叉验证 - -- **文件范围**:任务声明的目标文件是否实际存在,是否存在范围蔓延(修改了任务未声明的文件) -- **配对测试**:若为实现任务,检查 tasks.md 中配对的测试任务是否已打勾 ✅ -- **前置依赖**:检查任务声明的依赖项是否已完成(tasks.md 中已打勾) - -### Step 5: 输出报告 - -按以下格式输出: - -```markdown -## Task Review: - -**任务**: <任务标题> -**类型**: 测试 / 实现 / 基础设施 / Worker -**文件**: <文件列表> - -| # | 检查项 | 结果 | 说明 | -|---|--------|------|------| -| 1 | 架构分层 | PASS / FAIL / N/A | <若 FAIL,具体描述> | -| 2 | 命名规范 | PASS / FAIL / N/A | | -| 3 | 序列化约定 | PASS / FAIL / N/A | | -| 4 | 数据库约定 | PASS / FAIL / N/A | | -| 5 | 前端约定 | PASS / FAIL / N/A | | -| 6 | 信息泄漏 | PASS / FAIL / N/A | | -| 7 | 设计同步 | PASS / FAIL / N/A | <若 FAIL,列出 design.md 哪节应更新> | - -**元数据验证**: 文件范围 PASS/FAIL | 配对测试 PASS/FAIL/N/A | 依赖 PASS/FAIL - -**结果**: PASS / PASS_WITH_NOTES / NEEDS_FIX -``` - -### Step 6: 处理结果 - -| 结果 | 条件 | 动作 | -|------|------|------| -| **PASS** | 全部通过 | 告知用户可以打勾 | -| **PASS_WITH_NOTES** | 仅 MEDIUM 级提醒,无 HIGH | 告知用户可以打勾,列出提醒供参考 | -| **NEEDS_FIX** | 任何 HIGH 违规 | 列出需要修复的具体问题,修复后可再次调用 `/task-review` 重审(最多 1 轮重审) | - -## 错误处理 - -- `feature_dir` 不存在 → 报告路径错误,停止 -- `tasks.md` 不存在 → 报告"找不到 tasks.md",停止 -- `task_id` 不匹配 → 报告"未找到任务 ",停止 -- 目标文件不存在 → 标记为 WARNING(文件可能尚未创建),继续检查其他文件 diff --git a/.cursor/skills/task-review/references/task-checklist.md b/.cursor/skills/task-review/references/task-checklist.md deleted file mode 100644 index d4192f8c7c..0000000000 --- a/.cursor/skills/task-review/references/task-checklist.md +++ /dev/null @@ -1,42 +0,0 @@ -# L1 任务审查检查清单 - -本清单定义了 `/task-review` 在每个任务完成后执行的精简检查项。 -L1 聚焦约定合规和架构红线,不检查边界条件、权限、并发、测试覆盖(留给 L2)。 - -## 检查项 - -| # | 检查项 | 适用文件 | 严重度 | 检查方法 | -|---|--------|---------|--------|---------| -| 1 | 架构分层 | 后端 `*.py` | HIGH | Endpoint 不直接实例化 DAO 做复杂业务(应通过 Service);Service 不导入 FastAPI 对象(Request/Response/Depends/APIRouter);`domain/models/` 不得 `from bisheng.*.domain.services`;`common/` 和 `core/` 不得导入领域模块;新代码不放 `api/services/`(旧服务层),应放 `{module}/domain/services/`;Worker 只导入 Domain Services 不导入 Endpoint | -| 2 | 命名规范 | 全部 | MEDIUM | DAO 方法:同步 `get_xxx`/`create_xxx`/`update_xxx`/`delete_xxx`,异步 `aget_xxx`/`acreate_xxx`/`aupdate_xxx`/`adelete_xxx`;DAO 为 `@classmethod`;Service 类名 `{Module}{Function}Service`;错误码类名 `{Module}{Error}Error`,Code 遵循 MMMEE;前端页面 PascalCase,store 文件 camelCase+Store,API 函数 camelCase;i18n key 小写+点分隔 | -| 3 | 序列化约定 | 后端 `*.py` | HIGH | ORM 继承 `SQLModelSerializable`;API 响应用 `UnifiedResponseModel`(`resp_200`/`resp_500`/`ErrorClass.return_resp`);分页用 `PageData[T]`(新代码);枚举序列化为 `.value`;SSE 用 `to_sse_event()`;WS 关闭用 `websocket_close_message()` | -| 4 | 数据库约定 | 后端 models/migration `*.py` | HIGH | 新表必须含 `tenant_id`(`index=True`);必须含 `create_time`/`update_time`;禁止手动 `WHERE tenant_id=`(SQLAlchemy event 自动注入);禁止 Service 层直接写 SQL(用 DAO classmethod);新模块 DAO 放 `{module}/domain/models/` 而非 `database/models/`;使用 `get_sync_db_session()`/`get_async_db_session()` | -| 5 | 前端约定 | `*.tsx`/`*.ts` | MEDIUM | Platform: 全局状态用 Zustand store(`src/store/`),API 通过 `controllers/API/` 封装,用户可见文字走 `t('key')` i18n,新路由在 `src/routes/` 注册。Client: API 通过 `src/api/` 封装,store 用 Zustand(`src/store/`),路由基础路径 `/workspace` | -| 6 | 信息泄漏 | 全部 | HIGH | 无硬编码密码/密钥/token(`password = "xxx"` 等);错误响应不暴露堆栈/SQL(用 BaseErrorCode);日志中敏感字段脱敏;API 不返回 tenant_id 到前端;前端不硬编码后端 IP。排除:测试 fixtures、config.yaml.example | -| 7 | 设计同步 | 全部 | MEDIUM | **触发条件**:本任务修改了 service / endpoint / store / API 契约 / 数据模型 / 渲染器 / 任何 design.md §4.3 "关键模块职责" 表里列出的文件。**检查**:(a) design.md 修订历史是否反映了本任务的改动(或本任务后会一并补);(b) 改动是否触及 §3 决策(替换/新增)、§4 数据流或字段约定、§5 已知坑(新增反直觉事实)、§6 对外契约或依赖 —— 若是,必须**同步更新对应章节**;(c) 仅本地小修小补(重构、改文案、调样式、bugfix 不涉及对外行为)→ 标 PASS 即可。**判定**:触发但 design.md 未同步 → FAIL(MEDIUM);触发且已同步 / 未触发 → PASS | - -## 差异化处理规则 - -### 测试任务 -- 仅检查:#2 命名规范 + #5 前端约定中的 i18n + AC 标注格式(`覆盖 AC: AC-NN`) -- 跳过:#1 架构分层、#3 序列化、#4 数据库、#7 设计同步 - -### 实现任务 -- 完整执行 #1~#7 -- 额外验证:配对的测试任务是否已完成(tasks.md 中已打勾) - -### 基础设施任务(ORM 模型、错误码、配置) -- 检查:#1 架构分层、#4 数据库约定、#6 信息泄漏、#7 设计同步(新表/新错误码很可能影响 design.md §4.2 字段约定 + §6 契约) -- 跳过:#5 前端约定 - -### Worker 任务 -- 检查:#1 架构分层、#4 数据库约定、#6 信息泄漏、#7 设计同步 -- 额外检查:tenant_id 是否通过 Celery headers 传递并在 Worker 侧恢复 ContextVar - -## 判定规则 - -| 结果 | 条件 | 动作 | -|------|------|------| -| **PASS** | 全部通过 | 打勾,继续下一任务 | -| **PASS_WITH_NOTES** | 仅 MEDIUM 级信息性提醒 | 打勾 + 记录偏差,继续 | -| **NEEDS_FIX** | 任何 HIGH 违规 | 修复 → 重审(最多 1 轮) | diff --git a/.drone.yml b/.drone.yml index 7fef4b74f8..4951dad98c 100644 --- a/.drone.yml +++ b/.drone.yml @@ -107,6 +107,40 @@ steps: # 定义流水线执行步骤,这些步骤将顺序执行 - docker build -t $docker_repo:$version . - docker push $docker_repo:$version + - name: build_docker_sandbox + pull: if-not-exists + image: docker:24.0.6 + privileged: true + volumes: + - name: apt-cache + path: /var/cache/apt/archives + - name: socket + path: /var/run/docker.sock + environment: + http_proxy: + from_secret: PROXY + https_proxy: + from_secret: PROXY + no_proxy: 192.168.106.8,localhost,127.0.0.1,mirrors.tuna.tsinghua.edu.cn,pypi.tuna.tsinghua.edu.cn + version: release + docker_registry: http://192.168.106.8:6082 + docker_repo: 192.168.106.8:6082/dataelement/bisheng-sandbox + docker_user: + from_secret: NEXUS_USER + docker_password: + from_secret: NEXUS_PASSWORD + commands: + # Context is the repo root (Dockerfile COPYs src/backend lock + src/sandbox-runner). + # uv/apt indexes are baked into the Dockerfile; proxy is build-arg only (not in the image). + # tuna is on no_proxy so apt/pip do not get NODATA/NOSPLIT through the corporate proxy. + - test -f docker/bisheng-sandbox/Dockerfile + - docker login -u $docker_user -p $docker_password $docker_registry + - export DOCKER_BUILDKIT=1 + - docker pull $docker_repo:$version || true + - docker pull $docker_repo:release || true + - cache=""; docker image inspect $docker_repo:$version >/dev/null 2>&1 && cache="$cache --cache-from $docker_repo:$version" || true; docker image inspect $docker_repo:release >/dev/null 2>&1 && cache="$cache --cache-from $docker_repo:release" || true; docker build $cache --build-arg http_proxy=$http_proxy --build-arg https_proxy=$https_proxy --build-arg HTTP_PROXY=$http_proxy --build-arg HTTPS_PROXY=$https_proxy --build-arg no_proxy=$no_proxy --build-arg NO_PROXY=$no_proxy --build-arg BUILDKIT_INLINE_CACHE=1 -f docker/bisheng-sandbox/Dockerfile -t $docker_repo:$version . + - docker push $docker_repo:$version + - name: ssh deploy image: appleboy/drone-ssh pull: if-not-exists @@ -284,6 +318,40 @@ steps: # 定义流水线执行步骤,这些步骤将顺序执行 - docker build -t $docker_repo:$version . - docker push $docker_repo:$version + - name: build_docker_sandbox + pull: if-not-exists + image: docker:24.0.6 + privileged: true + volumes: + - name: apt-cache + path: /var/cache/apt/archives + - name: socket + path: /var/run/docker.sock + environment: + http_proxy: + from_secret: PROXY + https_proxy: + from_secret: PROXY + no_proxy: 192.168.106.8,localhost,127.0.0.1,mirrors.tuna.tsinghua.edu.cn,pypi.tuna.tsinghua.edu.cn + version: ${DRONE_BRANCH} + docker_registry: http://192.168.106.8:6082 + docker_repo: 192.168.106.8:6082/dataelement/bisheng-sandbox + docker_user: + from_secret: NEXUS_USER + docker_password: + from_secret: NEXUS_PASSWORD + commands: + - echo "old tag is $version" + - version=$(echo $version | sed 's/\\//_/g') + - echo "build image tag is $version" + - test -f docker/bisheng-sandbox/Dockerfile + - docker login -u $docker_user -p $docker_password $docker_registry + - export DOCKER_BUILDKIT=1 + - docker pull $docker_repo:$version || true + - docker pull $docker_repo:release || true + - cache=""; docker image inspect $docker_repo:$version >/dev/null 2>&1 && cache="$cache --cache-from $docker_repo:$version" || true; docker image inspect $docker_repo:release >/dev/null 2>&1 && cache="$cache --cache-from $docker_repo:release" || true; docker build $cache --build-arg http_proxy=$http_proxy --build-arg https_proxy=$https_proxy --build-arg HTTP_PROXY=$http_proxy --build-arg HTTPS_PROXY=$https_proxy --build-arg no_proxy=$no_proxy --build-arg NO_PROXY=$no_proxy --build-arg BUILDKIT_INLINE_CACHE=1 -f docker/bisheng-sandbox/Dockerfile -t $docker_repo:$version . + - docker push $docker_repo:$version + # - name: ssh deploy backend # image: appleboy/drone-ssh # pull: if-not-exists diff --git a/.github/workflows/app-runtime-quality.yml b/.github/workflows/app-runtime-quality.yml new file mode 100644 index 0000000000..55f062a49c --- /dev/null +++ b/.github/workflows/app-runtime-quality.yml @@ -0,0 +1,108 @@ +name: App Runtime Quality + +# Gate for the app-factory runtime layer (F054): src/runtime-manager/, +# src/app-proxy/ and the docker-compose wiring that connects them. +# +# Why it exists: both processes are configured entirely through environment +# variables and both are *fail-closed but still healthy* — a typo'd variable +# name is indistinguishable from an unset one, the process boots, the container +# reports healthy, and every request renders a fallback page. That failure mode +# is invisible to any runtime probe, so it has to be caught statically. +# +# It got caught the hard way once: the compose file shipped +# APP_PROXY_BACKEND_BASE_URL / APP_PROXY_HMAC_SECRET, which nothing reads, while +# the systemd unit next to it had already been fixed. Nothing was red. + +on: + pull_request: + paths: + - "src/runtime-manager/**" + - "src/app-proxy/**" + - "docker/docker-compose.yml" + - "docker/.env.example" + - "docker/install.sh" + - "scripts/deploy.sh" + - "scripts/tests/compose/test-install.sh" + - "docker/lib/**" + - "docker/gateway/**" + - "docker/nginx/**" + - "docker/bisheng/config/config.yaml" + - "scripts/tests/compose/verify-app-runtime-compose.sh" + - "scripts/tests/compose/verify_app_runtime_compose.py" + - ".github/workflows/app-runtime-quality.yml" + - ".github/workflows/runtime-images.yml" + - "scripts/build_runtime_images.sh" + - "scripts/sync_runtime_images_cr.sh" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + installer-contract: + name: Compose installer + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Installer command-boundary tests + run: bash scripts/tests/compose/test-install.sh + + - name: Validate generated Compose configurations + run: | + bash docker/install.sh --prepare-only + printf '%s\n' 'ci-configuration-check-only' > "$RUNNER_TEMP/gateway-license.txt" + bash docker/install.sh --prepare-only --gateway \ + --license-file "$RUNNER_TEMP/gateway-license.txt" --public-url https://example.invalid + + compose-contract: + name: Compose ↔ config.py contract + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + # ubuntu-latest ships docker compose v2; the script needs it only to + # resolve the compose document, never to start anything. + - name: Prepare Compose configuration + run: cp docker/.env.example docker/.env + + - name: Verify the app-runtime compose wiring + env: + MINIO_ROOT_USER: ci-minio-user + MINIO_ROOT_PASSWORD: ci-minio-password + run: bash scripts/tests/compose/verify-app-runtime-compose.sh + + unit: + name: Lint & Test (${{ matrix.package }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + package: [runtime-manager, app-proxy] + defaults: + run: + working-directory: src/${{ matrix.package }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + # --frozen, not a plain sync: uv.lock is committed and both Dockerfiles + # build from `uv export --frozen`, so CI must resolve the same way. + - name: Sync (frozen) + run: uv sync --frozen --extra dev + + - name: Ruff + run: uv run ruff check . + + # `docker`-marked cases need a real daemon and are skipped by default + # (RTM_RUN_DOCKER_TESTS=1 turns them on during the 114 verification). + - name: Tests + run: uv run pytest -q diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d6abab903..4a26a15427 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -210,12 +210,55 @@ jobs: docker tag ${{ env.DOCKERHUB_REPO }}bisheng-frontend:${{ steps.get_version.outputs.VERSION }}-arm64 cr.dataelem.com/${{ env.DOCKERHUB_REPO }}bisheng-frontend:${{ steps.get_version.outputs.VERSION }}-arm64 docker push cr.dataelem.com/${{ env.DOCKERHUB_REPO }}bisheng-frontend:${{ steps.get_version.outputs.VERSION }}-arm64 + build_sandbox: + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-22.04-arm + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Build sandbox and push architecture image + env: + VERSION: ${{ github.ref_name }} + ARCH: ${{ matrix.arch }} + run: | + docker buildx build --file docker/bisheng-sandbox/Dockerfile --platform "linux/$ARCH" --provenance=false \ + --build-arg APT_MIRROR=deb.debian.org --build-arg APT_SECURITY_MIRROR=security.debian.org \ + --build-arg PYPI_INDEX_URL=https://pypi.org/simple \ + --label "org.opencontainers.image.revision=$GITHUB_SHA" \ + --label "org.opencontainers.image.version=$VERSION" \ + --tag "dataelement/bisheng-sandbox:$VERSION-$ARCH" --push . + - uses: docker/login-action@v3 + with: + registry: cr.dataelem.com + username: ${{ secrets.CR_DOCKERHUB_USERNAME }} + password: ${{ secrets.CR_DOCKERHUB_TOKEN }} + - name: Sync sandbox architecture image to CR + env: + VERSION: ${{ github.ref_name }} + ARCH: ${{ matrix.arch }} + run: | + docker pull "dataelement/bisheng-sandbox:$VERSION-$ARCH" + docker tag "dataelement/bisheng-sandbox:$VERSION-$ARCH" "cr.dataelem.com/dataelement/bisheng-sandbox:$VERSION-$ARCH" + docker push "cr.dataelem.com/dataelement/bisheng-sandbox:$VERSION-$ARCH" + notify_feishu: needs: - build_bisheng_backend - build_backend_arm - build_bisheng_frontend - build_frontend_arm + - build_sandbox runs-on: ubuntu-latest steps: - name: Get version @@ -239,4 +282,4 @@ jobs: url: ${{ secrets.FEISHU_WEBHOOK }} method: 'POST' data: '{"msg_type":"post","content":{"post":{"zh_cn":{"title": "${{ steps.get_version.outputs.VERSION }}-amd64镜像预发布成功", "content": [[{"tag":"text","text":"发布功能:"},{"tag":"text","text":"${{ env.message }}"}]]}}}}' - \ No newline at end of file + diff --git a/.github/workflows/cli-quality.yml b/.github/workflows/cli-quality.yml new file mode 100644 index 0000000000..3054f91320 --- /dev/null +++ b/.github/workflows/cli-quality.yml @@ -0,0 +1,128 @@ +name: CLI Quality + +# Quality gate for src/bisheng-cli/ (the standalone `bisheng` developer CLI). +# +# Why this exists as its own workflow: the repo's CI currently runs no tests at +# all for runtime-manager or app-proxy. Anything not wired into a gate here ends +# up in the same place — a suite nobody runs and nobody trusts. A separate file +# (rather than a job inside ci.yml) also keeps the merge-conflict surface at +# zero while several features are landing in parallel. + +on: + pull_request: + paths: + - "src/bisheng-cli/**" + - "scripts/pack_cli_wheel.sh" + - ".github/workflows/cli-quality.yml" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + locked: + name: Lint & Test (locked resolution) + runs-on: ubuntu-latest + defaults: + run: + working-directory: src/bisheng-cli + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + # --frozen, not a plain sync: uv.lock is committed, and CI must use it + # rather than re-resolving. Same rule as src/backend/Dockerfile. + - name: Sync (frozen) + run: uv sync --frozen --extra dev + + - name: Ruff + run: | + uv run ruff check . + uv run ruff format --check . + + # `network`-marked cases need a real platform and are skipped by default; + # they only run during the 114 manual verification. + - name: Tests + run: uv run pytest -m "not network" + + wheel: + name: Wheel install smoke + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup uv + uses: astral-sh/setup-uv@v5 + + # The only leg that exercises the shape a user actually receives: a wheel + # installed into an empty environment, where uv.lock plays no part and the + # dependency ceilings in the wheel metadata are the only constraint. + - name: Build wheel and run the packaging smoke test + run: bash scripts/pack_cli_wheel.sh + + - name: Version drift guard + # Catches "changed the CLI, forgot to re-run pack_cli_wheel.sh": the + # platform would keep serving the previous wheel with no signal at all. + run: | + set -euo pipefail + PKG_VERSION="$(sed -n 's/^__version__ = "\(.*\)"$/\1/p' src/bisheng-cli/bisheng_cli/__init__.py)" + MANIFEST_VERSION="$(python3 -c "import json;print(json.load(open('src/backend/bisheng/dev_toolkit/artifacts/manifest.json'))['cli']['version'])")" + if [ "${PKG_VERSION}" != "${MANIFEST_VERSION}" ]; then + echo "::error::bisheng_cli.__version__=${PKG_VERSION} but artifacts/manifest.json says ${MANIFEST_VERSION}" + exit 1 + fi + # The artifacts must be committable. `.gitignore` carries build/, lib/, + # wheels/ and sdist/ without a leading slash, so a future rename of the + # directory would make `git add` fail silently. + WHEEL="$(ls src/backend/bisheng/dev_toolkit/artifacts/*.whl | head -n 1)" + if git check-ignore -q "${WHEEL}"; then + echo "::error::${WHEEL} is matched by a .gitignore rule and would never reach the backend image" + exit 1 + fi + + - name: Artifacts are committed, not just built + # The wheel travels inside the backend package because the image's build + # context is only ./src/backend/. If rebuilding it produces a diff, the + # committed copy is stale. + run: | + set -euo pipefail + if ! git diff --quiet -- src/backend/bisheng/dev_toolkit/artifacts/manifest.json; then + echo "::error::artifacts/manifest.json is out of date — run bash scripts/pack_cli_wheel.sh and commit the result" + git --no-pager diff -- src/backend/bisheng/dev_toolkit/artifacts/manifest.json + exit 1 + fi + + highest: + name: Upper-bound sentinel (advisory) + runs-on: ubuntu-latest + # Advisory on purpose: this leg answers "is it time to raise a ceiling?", + # which is a decision, not a defect. When it goes red the fix is to raise + # the bound after checking the new version, or to pin — never to delete the + # bound, which is how app-proxy got a green suite and a dead production + # process. + continue-on-error: true + defaults: + run: + working-directory: src/bisheng-cli + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup uv + uses: astral-sh/setup-uv@v5 + + - name: Resolve to the newest allowed dependencies + run: uv sync --upgrade --extra dev + + - name: Import smoke only + run: | + uv run python -c "import bisheng_cli.main" + uv run bisheng --version + uv run bisheng deploy --help > /dev/null diff --git a/.github/workflows/frontend-quality.yml b/.github/workflows/frontend-quality.yml index b56f85620a..8aa2aae695 100644 --- a/.github/workflows/frontend-quality.yml +++ b/.github/workflows/frontend-quality.yml @@ -42,6 +42,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Shared configuration tests and types + run: pnpm test:config && pnpm typecheck:config + - name: Shared locale artifacts in sync (packages/locales) run: pnpm --filter @bisheng/locales check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 67c55cdf80..87ce183426 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,6 +49,18 @@ jobs: docker manifest create ${{ env.DOCKERHUB_REPO }}bisheng-frontend:latest ${{ env.DOCKERHUB_REPO }}bisheng-frontend:${{ steps.get_version.outputs.VERSION }}-arm64 ${{ env.DOCKERHUB_REPO }}bisheng-frontend:${{ steps.get_version.outputs.VERSION }}-amd64 docker manifest push ${{ env.DOCKERHUB_REPO }}bisheng-frontend:latest + + docker manifest create ${{ env.DOCKERHUB_REPO }}bisheng-sandbox:${{ steps.get_version.outputs.VERSION }} ${{ env.DOCKERHUB_REPO }}bisheng-sandbox:${{ steps.get_version.outputs.VERSION }}-arm64 ${{ env.DOCKERHUB_REPO }}bisheng-sandbox:${{ steps.get_version.outputs.VERSION }}-amd64 + docker manifest push ${{ env.DOCKERHUB_REPO }}bisheng-sandbox:${{ steps.get_version.outputs.VERSION }} + + docker manifest create ${{ env.DOCKERHUB_REPO }}bisheng-sandbox:latest ${{ env.DOCKERHUB_REPO }}bisheng-sandbox:${{ steps.get_version.outputs.VERSION }}-arm64 ${{ env.DOCKERHUB_REPO }}bisheng-sandbox:${{ steps.get_version.outputs.VERSION }}-amd64 + docker manifest push ${{ env.DOCKERHUB_REPO }}bisheng-sandbox:latest + + for service in runtime-manager app-proxy runtime-helper runtime-assembler; do + image="${{ env.DOCKERHUB_REPO }}bisheng-$service" + docker manifest create "$image:${{ steps.get_version.outputs.VERSION }}" "$image:${{ steps.get_version.outputs.VERSION }}-amd64" "$image:${{ steps.get_version.outputs.VERSION }}-arm64" + docker manifest push "$image:${{ steps.get_version.outputs.VERSION }}" + done sync_dataelem_repos: runs-on: ubuntu-latest @@ -98,6 +110,24 @@ jobs: docker pull ${{ env.DOCKERHUB_REPO }}bisheng-frontend:${{ steps.get_version.outputs.VERSION }}-arm64 docker tag ${{ env.DOCKERHUB_REPO }}bisheng-frontend:${{ steps.get_version.outputs.VERSION }}-arm64 cr.dataelem.com/${{ env.DOCKERHUB_REPO }}bisheng-frontend:${{ steps.get_version.outputs.VERSION }}-arm64 docker push cr.dataelem.com/${{ env.DOCKERHUB_REPO }}bisheng-frontend:${{ steps.get_version.outputs.VERSION }}-arm64 + # CR uses a single-platform default tag; ARM64 retains its suffix. + docker pull ${{ env.DOCKERHUB_REPO }}bisheng-sandbox:${{ steps.get_version.outputs.VERSION }}-amd64 + docker tag ${{ env.DOCKERHUB_REPO }}bisheng-sandbox:${{ steps.get_version.outputs.VERSION }}-amd64 cr.dataelem.com/${{ env.DOCKERHUB_REPO }}bisheng-sandbox:${{ steps.get_version.outputs.VERSION }} + docker tag ${{ env.DOCKERHUB_REPO }}bisheng-sandbox:${{ steps.get_version.outputs.VERSION }}-amd64 cr.dataelem.com/${{ env.DOCKERHUB_REPO }}bisheng-sandbox:latest + docker push cr.dataelem.com/${{ env.DOCKERHUB_REPO }}bisheng-sandbox:${{ steps.get_version.outputs.VERSION }} + docker push cr.dataelem.com/${{ env.DOCKERHUB_REPO }}bisheng-sandbox:latest + docker pull ${{ env.DOCKERHUB_REPO }}bisheng-sandbox:${{ steps.get_version.outputs.VERSION }}-arm64 + docker tag ${{ env.DOCKERHUB_REPO }}bisheng-sandbox:${{ steps.get_version.outputs.VERSION }}-arm64 cr.dataelem.com/${{ env.DOCKERHUB_REPO }}bisheng-sandbox:${{ steps.get_version.outputs.VERSION }}-arm64 + docker push cr.dataelem.com/${{ env.DOCKERHUB_REPO }}bisheng-sandbox:${{ steps.get_version.outputs.VERSION }}-arm64 + for service in runtime-manager app-proxy runtime-helper runtime-assembler; do + image="${{ env.DOCKERHUB_REPO }}bisheng-$service" + docker pull "$image:${{ steps.get_version.outputs.VERSION }}-amd64" + docker tag "$image:${{ steps.get_version.outputs.VERSION }}-amd64" "cr.dataelem.com/$image:${{ steps.get_version.outputs.VERSION }}" + docker push "cr.dataelem.com/$image:${{ steps.get_version.outputs.VERSION }}" + docker pull "$image:${{ steps.get_version.outputs.VERSION }}-arm64" + docker tag "$image:${{ steps.get_version.outputs.VERSION }}-arm64" "cr.dataelem.com/$image:${{ steps.get_version.outputs.VERSION }}-arm64" + docker push "cr.dataelem.com/$image:${{ steps.get_version.outputs.VERSION }}-arm64" + done echo "--- sync over ---" test_pull_images: @@ -138,6 +168,16 @@ jobs: docker pull ${{ env.DOCKERHUB_REPO }}bisheng-frontend:${{ steps.get_version.outputs.VERSION }} docker pull cr.dataelem.com/${{ env.DOCKERHUB_REPO }}bisheng-frontend:${{ steps.get_version.outputs.VERSION }} + docker pull ${{ env.DOCKERHUB_REPO }}bisheng-sandbox:${{ steps.get_version.outputs.VERSION }} + docker pull cr.dataelem.com/${{ env.DOCKERHUB_REPO }}bisheng-sandbox:${{ steps.get_version.outputs.VERSION }} + docker pull cr.dataelem.com/${{ env.DOCKERHUB_REPO }}bisheng-sandbox:${{ steps.get_version.outputs.VERSION }}-arm64 + + for service in runtime-manager app-proxy runtime-helper runtime-assembler; do + docker pull "${{ env.DOCKERHUB_REPO }}bisheng-$service:${{ steps.get_version.outputs.VERSION }}" + docker pull "cr.dataelem.com/${{ env.DOCKERHUB_REPO }}bisheng-$service:${{ steps.get_version.outputs.VERSION }}" + docker pull "cr.dataelem.com/${{ env.DOCKERHUB_REPO }}bisheng-$service:${{ steps.get_version.outputs.VERSION }}-arm64" + done + notify_feishu: needs: diff --git a/.github/workflows/runtime-images.yml b/.github/workflows/runtime-images.yml new file mode 100644 index 0000000000..113f0d0f5d --- /dev/null +++ b/.github/workflows/runtime-images.yml @@ -0,0 +1,109 @@ +name: Runtime delivery images +on: + push: + tags: ['v*'] + workflow_dispatch: + inputs: + version: + description: Immutable release identifier (a new identifier for changed content) + required: true + type: string +permissions: + contents: read +concurrency: + group: runtime-images-${{ inputs.version || github.ref_name }} + cancel-in-progress: false +jobs: + native: + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-22.04 + - arch: arm64 + runner: ubuntu-22.04-arm + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Login to CR + uses: docker/login-action@v3 + with: + registry: cr.dataelem.com + username: ${{ secrets.CR_DOCKERHUB_USERNAME }} + password: ${{ secrets.CR_DOCKERHUB_TOKEN }} + - name: Build, native isolated smoke, push and inventory + env: + RUNTIME_RELEASE: ${{ inputs.version || github.ref_name }} + TARGET_ARCH: ${{ matrix.arch }} + RUNTIME_PUBLISH: '1' + RUNTIME_REPLACE_RELEASE: ${{ github.event_name == 'push' && github.event.forced && github.ref_name || '' }} + run: bash scripts/build_runtime_images.sh + - name: Sync verified native images to CR + env: + RUNTIME_RELEASE: ${{ inputs.version || github.ref_name }} + TARGET_ARCH: ${{ matrix.arch }} + RUNTIME_REPLACE_RELEASE: ${{ github.event_name == 'push' && github.event.forced && github.ref_name || '' }} + run: bash scripts/sync_runtime_images_cr.sh + - uses: actions/upload-artifact@v4 + with: + name: runtime-images-${{ matrix.arch }} + path: | + dist/runtime-images-*.json + dist/cr/runtime-images-*.json + catalog: + # Publish catalogs only after both native image builds and registry pushes succeed. + needs: native + strategy: + fail-fast: false + matrix: + include: + - registry: dockerhub + inventory_dir: dist + artifact: runtime-profiles + - registry: cr + inventory_dir: dist/cr + artifact: runtime-profiles-cr + runs-on: ubuntu-22.04 + env: + CATALOG_DIR: ${{ matrix.inventory_dir }} + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + pattern: runtime-images-* + merge-multiple: true + path: dist + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Login to CR + if: matrix.registry == 'cr' + uses: docker/login-action@v3 + with: + registry: cr.dataelem.com + username: ${{ secrets.CR_DOCKERHUB_USERNAME }} + password: ${{ secrets.CR_DOCKERHUB_TOKEN }} + - name: Verify matching native inventories before generating catalogs + run: | + python src/runtime-manager/runtime_manager/image_catalog.py merge \ + --inventory "$CATALOG_DIR/runtime-images-amd64.json" --inventory "$CATALOG_DIR/runtime-images-arm64.json" \ + --output "$CATALOG_DIR/runtime-release.json" + # Tag builds produce architecture images and evidence only. Formal service + # tags are promoted by release.yml after internal validation and Release publication. + - name: Generate both-platform application catalog + run: python src/runtime-manager/tools/runtime_materials.py catalog --inventory "$CATALOG_DIR/runtime-images-amd64.json" --inventory "$CATALOG_DIR/runtime-images-arm64.json" --output "$CATALOG_DIR/runtime-profiles.json" --revision ${{ github.run_number }} + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: | + ${{ matrix.inventory_dir }}/runtime-profiles.json + ${{ matrix.inventory_dir }}/runtime-release.json diff --git a/.github/workflows/sdk-quality.yml b/.github/workflows/sdk-quality.yml new file mode 100644 index 0000000000..190a38fc39 --- /dev/null +++ b/.github/workflows/sdk-quality.yml @@ -0,0 +1,162 @@ +name: SDK Quality + +# Quality gate for src/bisheng-sdk/ (the `bisheng-sdk` application runtime SDK). +# +# Same three legs as cli-quality.yml, and for the same reason: the repo's CI runs +# no tests for the standalone packages, so anything not wired into a gate here +# ends up a suite nobody runs. The wheel leg matters more for the SDK than for +# the CLI — a hosted application installs the *wheel* inside a build container +# with no public internet, so the dependency ceilings in the wheel metadata are +# the only constraint that applies there, and uv.lock plays no part at all. + +on: + pull_request: + paths: + - "src/bisheng-sdk/**" + - "scripts/pack_sdk_wheel.sh" + - ".github/workflows/sdk-quality.yml" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + locked: + name: Lint & Test (locked resolution) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + # The package and the distribution machinery land in separate changes, so + # a run triggered by the script alone must say "nothing to test" rather + # than fail on a directory that does not exist yet. + - name: Is the package present? + id: guard + run: | + if [ -f src/bisheng-sdk/pyproject.toml ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "::notice::src/bisheng-sdk/ is not on this branch — skipping the SDK legs" + fi + + # --frozen, not a plain sync: uv.lock is committed and CI must use it. + - name: Sync (frozen) + if: steps.guard.outputs.present == 'true' + working-directory: src/bisheng-sdk + run: uv sync --frozen --extra dev + + - name: Ruff + if: steps.guard.outputs.present == 'true' + working-directory: src/bisheng-sdk + run: | + uv run ruff check . + uv run ruff format --check . + + # `network`-marked cases need a real platform and are skipped by default; + # they only run during the 114 manual verification. + - name: Tests + if: steps.guard.outputs.present == 'true' + working-directory: src/bisheng-sdk + run: uv run pytest -m "not network" + + wheel: + name: Wheel install smoke + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup uv + uses: astral-sh/setup-uv@v5 + + - name: Is the package present? + id: guard + run: | + if [ -f src/bisheng-sdk/pyproject.toml ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "::notice::src/bisheng-sdk/ is not on this branch — skipping the wheel leg" + fi + + - name: Build wheel and run the packaging smoke test + if: steps.guard.outputs.present == 'true' + run: bash scripts/pack_sdk_wheel.sh + + - name: Version drift guard + # Catches "changed the SDK, forgot to re-run pack_sdk_wheel.sh": the + # platform would keep distributing the previous wheel, and applications + # built after the change would silently pin the old one. + if: steps.guard.outputs.present == 'true' + run: | + set -euo pipefail + PKG_VERSION="$(sed -n 's/^__version__ = "\(.*\)"$/\1/p' src/bisheng-sdk/bisheng_sdk/__init__.py)" + MANIFEST_VERSION="$(python3 -c "import json;print(json.load(open('src/backend/bisheng/dev_toolkit/artifacts/manifest.json'))['sdk']['version'])")" + if [ "${PKG_VERSION}" != "${MANIFEST_VERSION}" ]; then + echo "::error::bisheng_sdk.__version__=${PKG_VERSION} but artifacts/manifest.json says ${MANIFEST_VERSION}" + exit 1 + fi + # The wheel must be committable: `.gitignore` carries build/, lib/, + # wheels/ and sdist/ without a leading slash, so a future rename of the + # staging directory would make `git add` fail silently. + WHEEL="$(ls src/backend/bisheng/dev_toolkit/artifacts/bisheng_sdk-*.whl | head -n 1)" + if git check-ignore -q "${WHEEL}"; then + echo "::error::${WHEEL} is matched by a .gitignore rule and would never reach the backend image" + exit 1 + fi + + - name: Artifacts are committed, not just built + # The wheel travels inside the backend package because the image's build + # context is only ./src/backend/. A diff here means the committed copy is + # stale — and the `cli` section must be untouched by an SDK rebuild. + if: steps.guard.outputs.present == 'true' + run: | + set -euo pipefail + if ! git diff --quiet -- src/backend/bisheng/dev_toolkit/artifacts/manifest.json; then + echo "::error::artifacts/manifest.json is out of date — run bash scripts/pack_sdk_wheel.sh and commit the result" + git --no-pager diff -- src/backend/bisheng/dev_toolkit/artifacts/manifest.json + exit 1 + fi + + highest: + name: Upper-bound sentinel (advisory) + runs-on: ubuntu-latest + # Advisory on purpose: this leg answers "is it time to raise a ceiling?", + # which is a decision, not a defect. When it goes red the fix is to raise the + # bound after checking the new version, or to pin — never to delete the + # bound, which is how app-proxy got a green suite and a dead production + # process. + continue-on-error: true + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup uv + uses: astral-sh/setup-uv@v5 + + - name: Is the package present? + id: guard + run: | + if [ -f src/bisheng-sdk/pyproject.toml ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + fi + + - name: Resolve to the newest allowed dependencies + if: steps.guard.outputs.present == 'true' + working-directory: src/bisheng-sdk + run: uv sync --upgrade --extra dev + + - name: Tests against the newest httpx in range + if: steps.guard.outputs.present == 'true' + working-directory: src/bisheng-sdk + run: uv run pytest -m "not network" diff --git a/.gitignore b/.gitignore index 4b21a42466..cea87c53f0 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,8 @@ autogen_coding/ .claude/docs/ # Claude Code runtime artifacts .claude/scheduled_tasks.lock +# Codex local tooling config (per-developer, not for sharing) +.codex/ @@ -149,6 +151,8 @@ downloads/ eggs/ .eggs/ lib/ +!docker/lib/ +!docker/lib/** lib64/ sdist/ var/ @@ -274,6 +278,7 @@ sftp-config.json sftp-config.json # Docker local files +docker/.generated/ docker/data/* docker/mysql/data/* docker/office/bisheng/*.gz @@ -319,3 +324,7 @@ src/backend/migration_reports # packages/ui/docs (entry: root AGENTS.md §4), so a second unmaintained copy # only invites the two to disagree. .impeccable.md +# Local tooling +.cursor/ +config-106120.yaml +values-106120.yaml diff --git a/AGENTS.md b/AGENTS.md index edfed1e207..7d3d2faca2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ | Path | Project | Stack | |------|---------|-------| | `src/backend/` | FastAPI + Celery Workers + Linsight Worker | Python 3.11+, uv, SQLModel, LangGraph | -| `src/frontend/platform/` | Admin / builder UI | Vite 5 + **Zustand** + react-query v3 + bs-ui | +| `src/frontend/platform/` | Admin / builder UI | Vite 6 + **Zustand** + @tanstack/react-query v4 + bs-ui | | `src/frontend/client/` | End-user chat UI (`/workspace` base path) | Vite 6 + **Recoil** + react-query v4 (@tanstack) + shadcn/ui | **Runtime topology** (full picture → `docs/architecture/01-architecture-overview.md`): @@ -37,36 +37,14 @@ Middleware (MySQL / Redis / Milvus / ES / MinIO / OpenFGA): integration tests ru ## 4. Frontend Rules (P0) -Two React apps that **must not be mixed**. Per-app rules auto-load from each sub-project's `AGENTS.md`: -- `src/frontend/platform/AGENTS.md` — Admin/builder UI (Zustand, react-query v3, bs-ui, `@/`) -- `src/frontend/client/AGENTS.md` — End-user chat UI (Recoil, react-query v4, shadcn, `~/`) - -**Design specs & component library — mandatory entry for ANY UI work (new pages AND edits to existing ones):** -- **Read the design spec docs first.** They are the SSOT for "when to use what" (typography, color, radius/shadow, multi-device, per-component usage rules). They live at `src/frontend/packages/ui/docs/` (git-tracked; entry `index.md`, specs in `基础-*` / `组件-*` files; browsable as a site via `pnpm dev:ui` at `src/frontend/`). The specs are actively maintained and keep evolving — read the current version each time; never code from memory of an old read. -- **Component selection**: when a UI needs a component, use a **landed** one from the component overview (`src/frontend/packages/ui/docs/components/index.mdx` — i.e. pages marked `status: done`, shipped in `@bisheng/ui`). If the component you need is not finalized or not built yet, do NOT invent a new pattern — find the closest existing implementation in the current client code and follow it. -- **Ownership**: design spec content and component visual styles belong to the designer. Non-designers must not edit spec decisions, change component styles, or add new components on their own. Frontend engineers MAY improve existing component internals (refactor, perf, a11y), but any style/visual change requires designer sign-off. - -**Hard rules (both apps — single source of truth here; per-app files add only app-specific detail):** -- TypeScript only (`.ts` / `.tsx`); functional components only; no class components. -- Single file ≤ 600 lines. Extract sub-components or hooks when exceeded. -- `interface` for Props; `type` for internal types. `handleXxx` internal handlers / `onXxx` props. PascalCase components, camelCase utilities/hooks. -- Named exports for components (`export function`); no default exports. Minimize `any` — if unavoidable, `// eslint-disable-next-line` + a one-line reason. -- **Never** `import axios` directly — use the wrapped request module. (store must not call HTTP = constitution **C7**) -- **Never** introduce new UI or state-management libraries. -- All code comments in English. -- 403 handled automatically by response interceptors — never add 403 branches in business code. -- **A Radix `` must never receive an empty-string `value`** — it throws and takes the whole page down with it. Any selector rendering a backend list has to filter out rows whose id is `null` or `''` before mapping them to items. Do not rely on the backend to sanitize: older backend versions do send such rows, and the crash surfaces to the user as a blank page rather than an empty dropdown. -- **UI copy is written in plain language and does not borrow internal object names.** Where the spec says `PermissionModel`, the interface needs a separate business word: check what comparable products call it, then confirm the term isn't already taken by another concept on the platform. Don't coin abbreviations — read the sentence aloud, and if it sounds wrong it is wrong. Buttons take short verbs, fields take noun phrases, and explanatory sentences belong in a hint rather than crammed into a control's label. When copy refuses to come out clear, what's usually missing is a mechanism the page never explains — fix that first instead of renaming. -- **No frosted glass by default** — no `backdrop-filter` / `backdrop-blur-*`, including arbitrary values (`backdrop-blur-[4px]`), variant prefixes (`hover:backdrop-blur-sm`) and the arbitrary-property form `[backdrop-filter:blur(…)]`. Every such element gets its own compositing layer and re-snapshots + re-blurs its backdrop each frame; without GPU acceleration — the norm on 信创 machines — that runs on the CPU. A customer on 信保安全浏览器 (Chromium 108) had scrolling *and mouse movement* stall in daily chat; forcing `backdrop-filter: none` fixed it outright. Cost is **per element, not per radius**: a 4px blur on a 24px button costs the same order as a full-screen one, and the per-message action buttons multiplied it by conversation length. Use a translucent background instead (`bg-white/80`, `bg-black/40`) — past ~70% opacity the blur was invisible anyway. **Only exception**, and it must be justified: a full-screen overlay of which at most one exists at a time, verified on a 信创 browser. Never on anything that scales with content (list rows, message bubbles, cards, notification items). Rationale + alternatives: `packages/ui/docs/基础-阴影与圆角规范.mdx` §3. -- **i18n**: no hardcoded Chinese in source (lint-enforced; legacy frozen). New keys ship all three languages (zh-Hans/en/ja) in the same PR. Error-code copy lives ONLY in `src/frontend/packages/locales` (`api_errors` domain — platform addresses `api_errors:`, client `api_errors.`); its generated artifacts (`platform/public/locales/*/api_errors.json`, `client/src/locales/*/api_errors.gen.json`) are never edited by hand (CI-checked). CI also runs `pnpm check-i18n` — key parity across languages + backend error-code coverage; legacy drift is frozen in `scripts/i18n-baseline.json` (shrink-only, `--update-baseline` after healing). Legacy hardcoded Chinese is paid down by whoever touches the file: when editing a file with frozen violations, extract its Chinese strings to i18n (`/i18n-localizer`) in the same change. See `packages/locales/README.md`. -- **Quality gate (CI-enforced, `frontend-quality.yml`)**: `pnpm lint` + `pnpm typecheck` (run from `src/frontend/`) must pass. Legacy violations are frozen — ESLint in each app's `eslint-suppressions.json`, TS strict via `// @ts-strict-ignore` file headers — and may only shrink: never hand-edit the suppressions file, never add `@ts-strict-ignore` to a new file. After fixing violations in a file, run `pnpm lint:prune` (per app) and delete its `@ts-strict-ignore` header if it now passes strict. +Two React apps that **must not be mixed**. Rules shared by both apps (design-spec entry, component selection, hard rules, i18n, quality gate) → `src/frontend/AGENTS.md`; per-app detail → `src/frontend/platform/AGENTS.md` · `src/frontend/client/AGENTS.md`. All of them auto-load when you edit files under `src/frontend/`. --- ## 5. Architecture Guard (Auto-enforced) `scripts/arch-guard.sh` runs after every Write/Edit via a PostToolUse hook (through `.claude/hooks/arch-guard-hook.sh`, which feeds violations back to the agent as `additionalContext` for self-correction). -The 8 RULEs are the machine-enforcement arm of constitution **C1 / C4 / C6 / C7** — the clause↔RULE anchor table lives in [`docs/constitution.md`](docs/constitution.md). **VIOLATION must be fixed immediately.** +The 10 RULEs (RULE-1 … RULE-10) are the machine-enforcement arm of constitution **C1 / C4 / C6 / C7** — the clause↔RULE anchor table lives in [`docs/constitution.md`](docs/constitution.md). **VIOLATION must be fixed immediately.** --- @@ -84,7 +62,7 @@ The 8 RULEs are the machine-enforcement arm of constitution **C1 / C4 / C6 / C7* 5. branch feat//{NNN}-{name} (create early; docs + code on the branch) 6. implement wave-by-wave → /task-review → check off 7. /e2e-test (mandatory) -8. /code-review --base
(+ CI auto-review) +8. /sdd-code-review --base
(+ CI auto-review) 9. merge ``` @@ -93,32 +71,16 @@ Artifacts: `features/v{X.Y.Z}/{NNN}-{name}/{spec,design,tasks}.md`. Templates: ` Tests: new backend tests under `test//` (e.g., `test/approval/`), not `test/` root. `asyncio_mode=auto`. -- **Renumbering a section means running a cross-reference sweep** — collect every `§X.Y` reference and every heading, and diff both ways. Prose problems are caught by reading the document once; broken references are not. -- **Write AC ids out in full** (`AC-P4、AC-P5`, never `AC-P4 / P5`): a contracted form is not greppable and breaks the traceability chain. Anything meant to change later belongs in an AC, not a comment — comments get lost, ACs get checked at delivery. -- **Whether a feature actually shipped cannot be read off the `features/` directory**, nor off its entry in the release contract: complete spec and design documents with zero lines of implementation have happened. Grep the implementation side for real wiring (registration, call sites, i18n keys) before claiming delivery. -- **The release-line branch is the single source of truth.** Anything not specific to a derived branch lands on the current release line first, and derived branches merge down from it. Doing it the other way round — first on the derived branch, then cherry-picked back — leaves the same change on two lines under different hashes, so history carries it twice and "did this reach the release line?" can only be answered by comparing content. Across version lines, cherry-pick only, and only from a commit that is already on a release line. When back-porting, `git cherry` is not a reliable duplicate check: a fix that was picked into a hotfix branch and then merged forward is an *ancestor* of the merge base, so the comparison range is empty and it looks absent. Check per file with `git diff -- ` plus `git log -- `, and record any deliberate divergence from the source branch — otherwise the next person "corrects" it back. +- **Release line first**: changes not specific to a derived branch land on the release line, then merge down; across version lines, cherry-pick only from a commit already on a release line. Details (back-port duplicate checks, AC id format, renumbering sweep, verifying delivery) → `docs/SDD-Guide.md` §9. --- ## 7. Common Pitfalls -Backend runtime pitfalls (tenant-filter SELECT-only gap, ruff hook import trap, Celery Beat × multi-tenant, DB config Redis TTL and db index, single-host compose hiding multi-node bugs, `create_time` tie-breaking, duplicate table definitions, non-admin permission verification, baseline-diff regression judgement) → `src/backend/AGENTS.md` §Known Pitfalls; config/packaging contracts (new YAML top-level keys, `uv export`, committed CLI wheel, dependency licensing) → the same file's §Config & Packaging Contracts. Linsight agent-framework contracts → `src/backend/bisheng/linsight/AGENTS.md`; skill-pack authoring → `.../linsight/builtin_skills/AGENTS.md`. Client pitfalls (MinIO presigned-URL host, sandboxed `srcDoc` storage, markdown pipeline, local dev login, `pnpm install` breaking jest) → `src/frontend/client/AGENTS.md` §Known Pitfalls. MinIO `sharepoint` image-proxy pitfall → `src/frontend/platform/AGENTS.md` §Known Pitfalls. Commercial edition (`BISHENG_PRO` env, gateway proxy, SSO) → `docs/architecture/11-gateway.md`. +Subsystem pitfalls live in the nearest AGENTS.md (backend / client / platform §Known Pitfalls, linsight). Factual pitfalls table + method pitfalls in full → `docs/agent-pitfalls.md`. One-line index: liveness = trace callers + frontend entry · root cause = check magnitude, measure where it reproduces · scope follows root cause; dead code is deleted · surface model misbehaviour, don't fall back. -| Pitfall | Reality | -|---------|---------| -| `/api/v1/env` version field | Hardcoded `2.4.0` in source — unreliable. Use route probing instead. | -| Passwords in config.yaml | Fernet-encrypted. Never write plaintext passwords into the YAML. | -| First registered user | Becomes `super_admin` automatically. In multi-tenant mode, create the tenant first. | -| `docker compose restart bisheng-backend` | `bisheng-backend` is a `container_name`; the compose *service* names are `backend` / `backend_worker`. The wrong one fails with `no such service` and restarts nothing — and if the container happened to restart on its own, `Up N seconds` makes it look like the command worked. | -| "I clicked it and nothing happened" | Read `process_time` for that request in the backend access log first. "Slow" (a synchronous blocking action) and "broken" (a callback that never refreshed) have completely different fixes, and a second click usually returns a state-conflict error — if the frontend only toasts it without rolling back or re-syncing, the UI stays stuck on the old state forever. | - -**Method pitfalls** (they cost more than the factual ones above): - -- **To decide whether code is still live, trace its callers and its frontend entry point** — its presence in the tree proves nothing. This repo carries a lot of paths whose UI was removed while the backend implementation stayed. Grep the backend, then the frontend API layer and component usages, and then check whether guard conditions and field defaults leave the branch permanently untaken. Reading statically and treating leftovers as current produces badly wrong conclusions. -- **Let the root cause determine the scope; don't fold every gap you pass into one change.** For each candidate ask "once the root cause is fixed, does this still hold?" — the ones that don't go on an explicit "not doing" list with the reason. Before editing a prompt or adding a fallback, read what the existing template / DB config / YAML **actually contains**; absence from the code is not absence from the system. And the right treatment for dead code is deletion, not reconnection. -- **Surface model misbehaviour; do not paper over it with a fallback.** A model that claims a deliverable it never produced is a diagnosable defect — manufacturing the file on its behalf destroys the evidence and makes the defect unmeasurable. Preference order: fix the prompt, then detect and log or flag, then let the UI show the anomaly honestly (greyed out and marked "not generated", not a normal-looking link that does nothing). Keep a fallback only where it is already an established product contract. -- **When several agents share one working tree, repository-wide git writes are forbidden**: `git stash` (including pop/apply), `git checkout .`, `git reset --hard`, `git clean`, and `git restore` without a path. They act on the whole tree rather than "your" files and silently swallow another session's in-progress work. Read committed content with `git show HEAD:`, inspect your own edits with `git diff HEAD -- `, and use `git worktree add` when you need a clean checkout; a path-scoped `git checkout -- ` is safe. Put this in the task brief when dispatching concurrent sub-agents. -- **`git checkout --theirs ` takes the other side's *whole file*, not just the conflicted hunks.** On files where both sides added entries independently (locale JSON, error-code copy) it silently drops everything unique to your side, and the result still parses and still looks like a one-line diff. Use `git checkout --merge -- ` to restore the conflict markers and resolve hunk by hunk, then validate by parsing the file. +- **Shared working tree: no repo-wide git writes** — `git stash` (incl. pop/apply), `git checkout .`, `git reset --hard`, `git clean`, path-less `git restore`. Use `git show HEAD:`, `git diff HEAD -- `, `git worktree add`; a path-scoped `git checkout -- ` is safe. Put this in every concurrent sub-agent brief. +- **`git checkout --theirs ` takes the whole file**, silently dropping your side's unique entries (locale JSON, error-code copy). Use `git checkout --merge -- `, resolve hunk by hunk, then parse the file to validate. --- @@ -126,7 +88,8 @@ Backend runtime pitfalls (tenant-filter SELECT-only gap, ruff hook import trap, - **Docs index** → `docs/README.md` (navigation hub); onboarding & testing → `docs/architecture/09-development-guide.md` - **Architecture docs** → `docs/architecture/` (overview, permission, gateway, multi-tenant, data-models, …) -- **Skills**: `/sdd-review`, `/task-review`, `/code-review`, `/e2e-test`, `/i18n-localizer`, `/react-component-refactor` +- **Skills**: `/sdd-review`, `/task-review`, `/sdd-code-review`, `/e2e-test`, `/i18n-localizer`, `/react-component-refactor`. Single source in `.claude/skills/`; `.cursor/skills` and `.agents/skills` are symlinks to it — never add a copy there. `/sdd-code-review` is the project checklist review; Claude Code's built-in `/code-review` is a different tool. -**Instruction files (AGENTS.md map).** Root = this file, loaded every session. Auto-loaded on top when editing the matching directory: `src/backend/`, `src/frontend/platform/`, `src/frontend/client/`, `src/frontend/packages/ui/` (shared component library + design-token SSOT), plus deep-dir specials `src/backend/bisheng/core/database/alembic/` (migrations), `src/backend/scripts/` (one-off scripts), `src/backend/bisheng/linsight/` (agent-framework contracts) and `src/backend/bisheng/linsight/builtin_skills/` (skill-pack authoring). Every `CLAUDE.md` is a symlink to its sibling `AGENTS.md` — edit `AGENTS.md` only. Put a new rule in the deepest file covering its scope (cross-app / cross-module → this file; app- or dir-specific → the nearest file); never duplicate a rule across levels — it *will* drift. +**Instruction files (AGENTS.md map).** Root = this file, loaded every session. Auto-loaded on top when editing the matching directory: `src/backend/`, `src/frontend/` (rules shared by both apps), `src/frontend/platform/`, `src/frontend/client/`, `src/frontend/packages/ui/` (shared component library + design-token SSOT), plus deep-dir specials `src/backend/bisheng/core/database/alembic/` (migrations), `src/backend/scripts/` (one-off scripts), `src/backend/bisheng/linsight/` (agent-framework contracts) and `src/backend/bisheng/linsight/builtin_skills/` (skill-pack authoring). Every `CLAUDE.md` is a symlink to its sibling `AGENTS.md` — edit `AGENTS.md` only. Put a new rule in the deepest file covering its scope (cross-module → this file; cross-frontend-app → `src/frontend/AGENTS.md`; app- or dir-specific → the nearest file); never duplicate a rule across levels — it *will* drift. +**Only write verified behaviour into an AGENTS.md entry, and say how it was verified** (a measurement, a repro script, a test). If something is plausible but unproven, keep it in memory marked as a hypothesis (why → `docs/agent-pitfalls.md`). diff --git a/Claude outputs/bisheng-3.0-beta1-permission-dictionary/permission-dictionary.html b/Claude outputs/bisheng-3.0-beta1-permission-dictionary/permission-dictionary.html new file mode 100644 index 0000000000..efcf4501c3 --- /dev/null +++ b/Claude outputs/bisheng-3.0-beta1-permission-dictionary/permission-dictionary.html @@ -0,0 +1,1041 @@ +毕昇权限配置数据字典 + + + + + +
+
数据字典3.0.0-beta1直连数据库只读查询
+

毕昇权限配置数据字典

+

说明毕昇平台的权限配置保存在哪些表、每个字段和取值的含义,并附上可直接执行的查询,用来回答"谁把什么权限配给了谁"。

+
+
适用数据库
MySQL · 达梦 DM8
+
核对日期
2026-09-14
+
范围
权限配置,不含最终生效结果
+
+
+ +
+ + +
+
+

读库前先看

+
    +
  • 这是配置库里记录的是"配给了谁"。某人最终能看到、能操作什么,还要叠加部门成员(含下级部门)、用户组成员、文件夹继承、知识空间加入方式和管理员身份,单靠读表得不出这个结果。
  • +
  • 只取生效行授权表只取 state = 'ACTIVE' 的行。INACTIVE 是已撤销的历史记录。
  • +
  • 只读,不写直接改表不会同步到权限引擎,界面上不生效,还会让两边数据不一致。请使用只读账号。
  • +
  • 按版本核对表结构随版本变化。本字典按 3.0.0-beta1 核对,平台升级后请重新核对。
  • +
+
+ +
+

配置存在哪里

+
+ + + + + + + + + + + +
想知道去读
某个资源授给了谁、给的什么权限permission_grant + permission_grant_assignee
每个权限模型包含哪些操作permission_model · permission_model_action · permission_action
文件夹、文件是继承上级还是自定义resource_permission_mode
知识空间是公开、需审批还是私有knowledge.auth_type
人属于哪个部门、哪个用户组user_department · usergroup
人有哪些角色、角色能进哪些菜单userrole · role · roleaccess · user_menu_access
谁是管理员userrole · department_admin_grant · usergroup,另有两类只在 OpenFGA 库的 tuple 表
+
+
+ +
+

资源授权

+

知识空间、知识库、文件、工作流、助手、工具、频道、看板的授权都存在这两张表。permission_grant 每行是"某资源上的某个权限模型",被授予的人、部门、用户组挂在 permission_grant_assignee 下面。

+
+
+flowchart LR
+  G["permission_grant<br/>资源 × 权限模型"] -->|"grant_id"| A["permission_grant_assignee<br/>授给谁"]
+  G -->|"resource_type + resource_id"| R["业务表<br/>knowledge / knowledgefile / flow …"]
+  G -->|"model_key"| M["permission_model<br/>当前目录版本"]
+  M --> PA["permission_model_action<br/>→ permission_action"]
+  A -->|"subject_type + subject_id"| S["user / department<br/>group / service_account"]
+
+
+ +

permission_grant资源上的一个权限模型

+

同一资源上每个权限模型只有一行(唯一键:tenant_id, resource_type, resource_id, model_key)。

+
+ + + + + + + + + + + + + +
字段含义
idBIGINT主键,被 permission_grant_assignee.grant_id 引用
tenant_idBIGINT租户 ID。单租户部署恒为 1
resource_typeVARCHAR(64)资源类型,取值见下方「资源类型」
resource_idVARCHAR(64)资源 ID,按资源类型对应到业务表。数字 ID 也以字符串保存
model_keyVARCHAR(64)权限模型编码,对应 permission_model.model_key
stateVARCHAR(64)状态,取值见下方「状态」
projection_stateVARCHAR(64)同步到权限引擎的进度:CURRENT 已同步,PENDING / PROJECTING 同步中,FAILED_CLOSED 同步失败
versionBIGINT并发控制用的版本号
create_time · update_timeDATETIME创建、最后修改时间
+
+ +

permission_grant_assignee这个权限模型授给了谁

+
+ + + + + + + + + + + + + + + + + +
字段含义
idBIGINT主键
tenant_idBIGINT租户 ID
grant_idBIGINT所属授权,外键指向 permission_grant.id
subject_typeVARCHAR(64)授权对象类型,取值见下方「授权对象」
subject_idVARCHAR(64)授权对象 ID,按对象类型对应到用户、部门、用户组或服务账号表
include_childrenBOOLEAN1 = 连同下级部门一起授权。只有部门会用到
userset_relationVARCHAR(64)部门:member 仅本部门,subtree_member 含下级部门;用户组:member 全体成员,admin 仅组管理员;用户和服务账号为空
source_typeVARCHAR(64)这条授权是怎么来的,取值见下方「授权来源」
protectedBOOLEAN1 = 受保护授权(创建人的所有者身份),界面上不能移除
stateVARCHAR(64)状态,与 permission_grant.state 取值相同。两张表都要取 ACTIVE
projected_subjectVARCHAR(256)写入权限引擎的对象串,例如 department:4#subtree_member
source_ref · source_locator · source_fingerprintVARCHAR / CHAR(64)来源定位和去重键,系统内部使用,读数时可忽略
version · create_time · update_timeBIGINT / DATETIME版本号、创建与修改时间
+
+ +

资源类型resource_type → 业务表

+
+ + + + + + + + + + + + + + +
取值界面名称对应表.字段名称字段补充
knowledge_space知识空间knowledge.idnametype = 3
knowledge_library知识库knowledge.idnametype = 0 文档库,type = 1 问答库
folder文件夹knowledgefile.idfile_namefile_type = 0
knowledge_file文件knowledgefile.idfile_name知识库和知识空间的文件都在这张表
workflow工作流flow.idnameflow_type = 10
assistant助手assistant.idnameis_delete = 1 为已删除
tool工具t_gpts_tools_type.idname按工具集授权;is_delete = 1 为已删除
channel频道channel.idname
dashboard看板dashboard.idtitle
linsight_skill灵思技能linsight_skill.idname只记录创建人
+
+ +

授权对象subject_type → 组织表

+
+ + + + + + + + +
取值含义对应表.字段名称字段与说明
user用户user.user_iduser_name;delete = 1 为已停用
department部门department.idname;path 为 /1/2/3/ 形式的层级路径
user_group用户组group.idgroup_name
service_account服务账号service_account.idname;开放接口的调用方
+
+ +

授权来源source_type

+
+ + + + + + + + + + + + + +
取值含义
DIRECT在「权限管理」中直接授给某个用户或服务账号
DEPARTMENT授给部门
USER_GROUP授给用户组
CREATOR资源创建人自动获得「所有者」,protected = 1
CREATOR_GRANT服务账号创建资源时,自身获得「管理者」
SPACE_MEMBERSHIP知识空间成员身份:空间管理员对应「管理者」,普通成员对应「查看者」
CHANNEL_MEMBERSHIP频道成员身份
SNAPSHOT_FROM_PARENT文件夹或文件从「继承」改为「自定义」时,从上级复制下来的授权
OTHER其他来源
+
+ +

状态state,两张表通用

+
+ + + + + + + + +
取值含义
ACTIVE生效
INACTIVE已撤销,保留作历史
PENDING正在写入
FAILED_CLOSED处理失败,系统按未授权对待
+
+
+ +
+

权限模型

+

权限模型是一组操作的集合,例如「编辑者」包含上传、重命名、编辑等。管理员调整模型时会生成新的目录版本,模型表按版本存多份,读取时必须只取当前版本,否则同一模型会重复出现。

+ +

permission_catalog_release目录版本

+
+ + + + + + + +
字段含义
idBIGINT主键,被模型表和操作表的 catalog_release_id 引用
versionBIGINT版本序号
statusVARCHAR(64)CURRENT 为当前生效版本(只有一行);DRAFT 草稿,RETIRED 已退役,COMMITTED / PROJECTING 发布中,FAILED_CLOSED 发布失败
+
+ +

permission_model权限模型

+
+ + + + + + + + + + +
字段含义
catalog_release_idBIGINT所属目录版本
model_keyVARCHAR(64)模型编码。内置 viewer editor manager owner;管理员自建的是随机 ID;平台升级迁移生成的形如 legacy-custom-…
nameVARCHAR(255)界面显示名,如「查看者」
kindVARCHAR(64)STANDARD 内置,CUSTOM 自定义
derived_levelINT权限等级 1–4:查看者 1、编辑者 2、管理者 3、所有者 4;自定义模型按所含操作推算
activeBOOLEAN是否启用
+
+ +

permission_model_action · permission_action模型包含的操作

+

permission_model_action 用 model_id、action_id 连接两张表。permission_action.name 存的是英文编码,中文名见下表。

+
+ + + + + + + + + + + + + + + + + + + + + + + + +
操作编码中文名适用资源查看者viewer编辑者editor管理者manager所有者owner
use使用知识库、工作流、助手、工具●●●●
download下载文件夹、文件●●●●
edit编辑知识空间、知识库、工作流、助手、工具、频道、看板—●●●
rename重命名文件夹、文件—●●●
create_folder新建文件夹知识空间、文件夹—●●●
upload_file上传文件知识空间、文件夹—●●●
move移动文件夹、文件—●●●
share分享知识空间、文件、工作流、助手——●●
publish发布工作流、助手——●●
unpublish取消发布工作流、助手——●●
manage_permission权限管理全部资源——●●
delete删除全部资源———●
+
+

上表是四个内置模型的默认配置。自定义模型以及贵方实际配置,以查询 Q2 的结果为准。

+
+ +
+

继承与自定义

+

resource_permission_mode资源的权限模式

+
+ + + + + + + + +
字段含义
resource_type · resource_idVARCHAR(64)资源,含义同授权表
modeVARCHAR(64)INHERIT 继承:除自身授权外,上级的授权也对它生效;CUSTOM 自定义:只按自身授权
parent_type · parent_idVARCHAR(64)上级资源(知识空间、知识库或文件夹)。顶层资源没有上级,恒为 CUSTOM
projection_state · version · update_time—同步进度、版本号、修改时间
+
+

从继承改为自定义时,系统把当时上级的授权复制一份到自身,这些授权的来源记为 SNAPSHOT_FROM_PARENT。

+
+ +
+

知识空间加入方式

+

知识空间(knowledge.type = 3)的 auth_type 决定其他人能否申请加入。库里存的是大写值。

+
+ + + + + + + +
取值含义
PUBLIC公开,申请后直接加入
APPROVAL需要空间管理员审批
PRIVATE私有,不接受申请
+
+

加入后的成员会以 SPACE_MEMBERSHIP 来源出现在资源授权表里,所以统计空间成员直接读授权表即可。

+
+ +
+

组织归属

+

授权给部门或用户组时,授权表只记录部门、用户组本身。要知道具体涉及哪些人,需要结合下面几张表。

+ +

user用户

+
+ + + + + + + + +
字段含义
user_idINT主键,授权表中 subject_type = 'user' 的 subject_id
user_nameVARCHAR(128)用户名
external_idVARCHAR外部系统账号(组织同步、单点登录)
deleteINT1 = 已停用
+
+ +

department部门

+
+ + + + + + + + + +
字段含义
idINT主键,授权表中部门的 subject_id 就是它
dept_id · external_idVARCHAR部门编码、外部系统部门 ID。不要拿它们去关联授权表
nameVARCHAR(128)部门名称
parent_id · pathINT / VARCHAR上级部门、层级路径(如 /1/3/12/)。查下级部门可用 path LIKE '/1/3/%'
status · is_deletedVARCHAR / INTstatus = 'active' 为正常
+
+ +

user_department用户所属部门

+
+ + + + + + + + +
字段含义
user_idINT用户
department_idINT部门,对应 department.id
is_primaryINT1 = 主部门。一个人可以属于多个部门
sourceVARCHAR来源(手工维护或组织同步)
+
+ +

group · usergroup用户组及其成员

+
+ + + + + + + + +
字段含义
group.idINT用户组主键,授权表中用户组的 subject_id
group.group_nameVARCHAR用户组名称
usergroup.user_id · group_idINT成员关系
usergroup.is_group_adminBOOLEAN1 = 用户组管理员
+
+
+ +
+

角色与菜单

+

3.0 起,角色只决定能进入哪些菜单,不再决定能访问哪些资源;资源权限全部以上面的资源授权表为准。

+ +

role · userrole角色及用户的角色

+
+ + + + + + + + + +
字段含义
role.idINT角色主键。1 为系统管理员
role.role_nameVARCHAR角色名称
role.role_typeVARCHAR(16)global 所有租户可见,tenant 仅本租户
role.department_idINT角色所属部门(部门自建的角色)
userrole.user_id · role_idINT用户拥有的角色
+
+ +

roleaccess角色的菜单权限

+
+ + + + + + + +
字段含义
role_idINT角色
typeINT99 = 菜单权限。1–12 是旧版资源授权类型,3.0 起不再使用
third_idVARCHARtype = 99 时为菜单编码,见下表
+
+ +
+ + + + + + + + + + + + + + +
菜单编码界面名称菜单编码界面名称
workstation工作台admin管理后台
home首页(工作台)linsight_task_mode灵思任务模式
apps应用中心build应用构建
create_app新建应用knowledge知识管理
create_knowledge新建知识库knowledge_space知识空间
model模型管理tool工具管理
mcpMCP 服务channel频道管理
evaluation模型评测dataset数据集管理
mark_task标注任务board数据看板
subscription订阅管理frontend backend create_dashboard已废弃,可忽略
+
+ +

user_menu_access通过审批单独获得的菜单

+
+ + + + + + + + + +
字段含义
user_idINT用户
menu_key · menu_nameVARCHAR菜单编码与名称
grant_sourceVARCHAR(64)approval_instance 通过审批获得
statusVARCHAR(32)active 生效,revoked 已收回
revoked_reason · revoked_by_user_id · revoked_at—收回原因、操作人、时间
+
+
+ +
+

管理员身份

+

管理员不受资源授权限制,授权表里没有他们的记录也能操作。其中两类只记录在 OpenFGA(平台的权限引擎)自己的库里。

+
+ + + + + + + + +
身份记录在说明
超级管理员userrole.role_id = 1,以及 OpenFGA 库中 system:global 的 super_admin 关系两处任一即为超级管理员,建议合并去重
子租户管理员仅 OpenFGA 库:tenant:<租户 ID> 的 admin 关系只在多租户部署中存在
部门管理员department_admin_grantgrant_source:manual 手工设置,sso 单点登录同步
用户组管理员usergroup.is_group_admin = 1
+
+ +

tupleOpenFGA 库中的关系表

+
+ + + + + + + + +
字段含义
object_type · object_idVARCHAR对象,如 system / global、tenant / 3
relationVARCHAR(50)关系,如 super_admin、admin
_userVARCHAR(256)主体,形如 user:1,冒号后是 user.user_id
store · inserted_atCHAR(26) / TIMESTAMP存储 ID(一套部署通常只有一个)、写入时间
+
+

位置:Docker Compose 默认部署时,是业务库所在 MySQL 实例上的 openfga 库;达梦部署时,是 OpenFGA 服务配置 OPENFGA_DATASTORE_URI 指定的模式。只读这张表,不要修改。

+
+ +
+

常用查询

+
+ 数据库 +
+ + +
+ 关键字 user、group、mode、protected 用反引号 +
+
+ +
+
Q1资源授权明细:哪个资源、什么权限、授给了谁
+
SELECT
+  g.tenant_id,
+  g.resource_type,
+  g.resource_id,
+  COALESCE(k.name, f.file_name, w.name, s.name, t.name, c.name, b.title, sk.name) AS resource_name,
+  CASE WHEN k.id IS NULL AND f.id IS NULL AND w.id IS NULL AND s.id IS NULL
+            AND t.id IS NULL AND c.id IS NULL AND b.id IS NULL AND sk.id IS NULL
+       THEN 0 ELSE 1 END AS resource_exists,
+  g.model_key,
+  pm.name AS model_name,
+  a.subject_type,
+  a.subject_id,
+  CASE a.subject_type
+    WHEN 'user' THEN u.user_name
+    WHEN 'department' THEN d.name
+    WHEN 'user_group' THEN ug.group_name
+    WHEN 'service_account' THEN sa.name
+  END AS subject_name,
+  a.include_children,
+  a.source_type,
+  a.`protected`,
+  a.create_time,
+  a.update_time
+FROM permission_grant g
+JOIN permission_grant_assignee a
+  ON a.grant_id = g.id AND a.state = 'ACTIVE'
+LEFT JOIN permission_catalog_release r
+  ON r.status = 'CURRENT'
+LEFT JOIN permission_model pm
+  ON pm.catalog_release_id = r.id AND pm.model_key = g.model_key
+LEFT JOIN knowledge k
+  ON g.resource_type IN ('knowledge_space', 'knowledge_library') AND CONCAT('', k.id) = g.resource_id
+LEFT JOIN knowledgefile f
+  ON g.resource_type IN ('folder', 'knowledge_file') AND CONCAT('', f.id) = g.resource_id
+LEFT JOIN flow w
+  ON g.resource_type = 'workflow' AND w.id = g.resource_id
+LEFT JOIN assistant s
+  ON g.resource_type = 'assistant' AND s.id = g.resource_id AND s.is_delete = 0
+LEFT JOIN t_gpts_tools_type t
+  ON g.resource_type = 'tool' AND CONCAT('', t.id) = g.resource_id AND t.is_delete = 0
+LEFT JOIN channel c
+  ON g.resource_type = 'channel' AND c.id = g.resource_id
+LEFT JOIN dashboard b
+  ON g.resource_type = 'dashboard' AND CONCAT('', b.id) = g.resource_id
+LEFT JOIN linsight_skill sk
+  ON g.resource_type = 'linsight_skill' AND CONCAT('', sk.id) = g.resource_id
+LEFT JOIN `user` u
+  ON a.subject_type = 'user' AND CONCAT('', u.user_id) = a.subject_id
+LEFT JOIN department d
+  ON a.subject_type = 'department' AND CONCAT('', d.id) = a.subject_id
+LEFT JOIN `group` ug
+  ON a.subject_type = 'user_group' AND CONCAT('', ug.id) = a.subject_id
+LEFT JOIN service_account sa
+  ON a.subject_type = 'service_account' AND CONCAT('', sa.id) = a.subject_id
+WHERE g.state = 'ACTIVE'
+ORDER BY g.tenant_id, g.resource_type, g.resource_id, pm.derived_level, a.subject_type, a.subject_id;
+
+ +
+
Q2当前生效的权限模型及其操作
+
SELECT pm.model_key, pm.name AS model_name, pm.kind, pm.derived_level, pm.active,
+       pa.code AS action_code
+FROM permission_catalog_release r
+JOIN permission_model pm ON pm.catalog_release_id = r.id
+LEFT JOIN permission_model_action pma ON pma.model_id = pm.id
+LEFT JOIN permission_action pa ON pa.id = pma.action_id
+WHERE r.status = 'CURRENT'
+ORDER BY pm.derived_level, pm.model_key, pa.sort_order;
+
+ +
+
Q3文件夹、文件的继承 / 自定义模式
+
SELECT m.tenant_id, m.resource_type, m.resource_id, m.`mode`, m.parent_type, m.parent_id, m.update_time
+FROM resource_permission_mode m
+ORDER BY m.tenant_id, m.resource_type, m.resource_id;
+
+ +
+
Q4知识空间加入方式
+
SELECT k.tenant_id, k.id AS space_id, k.name, k.auth_type
+FROM knowledge k
+WHERE k.type = 3
+ORDER BY k.tenant_id, k.id;
+
+ +
+
Q5用户所属部门、用户组两条语句
+
SELECT ud.user_id, u.user_name, ud.department_id, d.name AS department_name, d.path, ud.is_primary
+FROM user_department ud
+JOIN `user` u ON u.user_id = ud.user_id
+JOIN department d ON d.id = ud.department_id
+ORDER BY ud.user_id, ud.is_primary DESC;
+
+SELECT x.user_id, u.user_name, x.group_id, g.group_name, x.is_group_admin
+FROM usergroup x
+JOIN `user` u ON u.user_id = x.user_id
+JOIN `group` g ON g.id = x.group_id
+ORDER BY x.group_id, x.user_id;
+
+ +
+
Q6用户角色、角色菜单、审批获得的菜单三条语句
+
SELECT ur.user_id, u.user_name, ur.role_id, r.role_name, r.department_id AS role_department_id
+FROM userrole ur
+JOIN `user` u ON u.user_id = ur.user_id
+JOIN role r ON r.id = ur.role_id
+ORDER BY ur.user_id, ur.role_id;
+
+SELECT ra.role_id, r.role_name, ra.third_id AS menu_key
+FROM roleaccess ra
+JOIN role r ON r.id = ra.role_id
+WHERE ra.type = 99
+ORDER BY ra.role_id, ra.third_id;
+
+SELECT uma.user_id, u.user_name, uma.menu_key, uma.menu_name, uma.grant_source, uma.create_time
+FROM user_menu_access uma
+JOIN `user` u ON u.user_id = uma.user_id
+WHERE uma.status = 'active'
+ORDER BY uma.user_id, uma.menu_key;
+
+ +
+
Q7部门管理员、超级管理员三条语句,最后一条读 OpenFGA 库
+
SELECT dag.user_id, u.user_name, dag.department_id, d.name AS department_name, dag.grant_source
+FROM department_admin_grant dag
+JOIN `user` u ON u.user_id = dag.user_id
+JOIN department d ON d.id = dag.department_id
+ORDER BY dag.department_id, dag.user_id;
+
+SELECT ur.user_id, u.user_name
+FROM userrole ur
+JOIN `user` u ON u.user_id = ur.user_id
+WHERE ur.role_id = 1;
+
+SELECT object_type, object_id, relation, _user AS subject, inserted_at
+FROM openfga.tuple
+WHERE (object_type = 'system' AND relation = 'super_admin')
+   OR (object_type = 'tenant' AND relation = 'admin')
+ORDER BY object_type, object_id;
+
+
+
+ +
+

读库注意事项

+
    +
  • 资源删除后授权可能残留Q1 输出的 resource_exists = 0 表示业务表里已找不到该资源,统计时请过滤掉。
  • +
  • ID 以字符串关联resource_id、subject_id 是字符串,业务表的数字主键要先转成字符串再比较。示例用 CONCAT('', id),MySQL 和达梦都适用。
  • +
  • 多租户部署各表都有 tenant_id,跨表关联时请同时带上租户条件。单租户部署恒为 1。
  • +
  • 数据量接入组织同步的部署,user、user_department、userrole 可能有十万行以上,建议加条件查询,不要整表导出。
  • +
  • 只读账号MySQL 示例如下。业务库名以部署配置为准,Docker Compose 默认是 bisheng。 +
    创建只读账号(MySQL)
    CREATE USER 'bisheng_readonly'@'%' IDENTIFIED BY '请替换为强密码';
    +GRANT SELECT ON bisheng.* TO 'bisheng_readonly'@'%';
    +GRANT SELECT ON openfga.tuple TO 'bisheng_readonly'@'%';
    +
  • +
+
+ +
+ 核对方式:字段与取值逐项对照 3.0.0-beta1 源码;全部查询已在达梦数据库上实际运行通过,MySQL 语法已单独验证。 +
+
+
+ + diff --git a/Claude outputs/bisheng-3.0-beta1-permission-dictionary/permission_config_queries.dm8.sql b/Claude outputs/bisheng-3.0-beta1-permission-dictionary/permission_config_queries.dm8.sql new file mode 100644 index 0000000000..e099bf8478 --- /dev/null +++ b/Claude outputs/bisheng-3.0-beta1-permission-dictionary/permission_config_queries.dm8.sql @@ -0,0 +1,137 @@ +-- BiSheng 3.0.0-beta1 permission configuration queries (DM8 dialect). +-- Identifiers user, group, mode and protected are double-quoted. + +-- Q1 Resource grants: who was granted which permission model on which resource +SELECT + g.tenant_id, + g.resource_type, + g.resource_id, + COALESCE(k.name, f.file_name, w.name, s.name, t.name, c.name, b.title, sk.name) AS resource_name, + CASE WHEN k.id IS NULL AND f.id IS NULL AND w.id IS NULL AND s.id IS NULL + AND t.id IS NULL AND c.id IS NULL AND b.id IS NULL AND sk.id IS NULL + THEN 0 ELSE 1 END AS resource_exists, + g.model_key, + pm.name AS model_name, + a.subject_type, + a.subject_id, + CASE a.subject_type + WHEN 'user' THEN u.user_name + WHEN 'department' THEN d.name + WHEN 'user_group' THEN ug.group_name + WHEN 'service_account' THEN sa.name + END AS subject_name, + a.include_children, + a.source_type, + a."protected", + a.create_time, + a.update_time +FROM permission_grant g +JOIN permission_grant_assignee a + ON a.grant_id = g.id AND a.state = 'ACTIVE' +LEFT JOIN permission_catalog_release r + ON r.status = 'CURRENT' +LEFT JOIN permission_model pm + ON pm.catalog_release_id = r.id AND pm.model_key = g.model_key +LEFT JOIN knowledge k + ON g.resource_type IN ('knowledge_space', 'knowledge_library') AND CONCAT('', k.id) = g.resource_id +LEFT JOIN knowledgefile f + ON g.resource_type IN ('folder', 'knowledge_file') AND CONCAT('', f.id) = g.resource_id +LEFT JOIN flow w + ON g.resource_type = 'workflow' AND w.id = g.resource_id +LEFT JOIN assistant s + ON g.resource_type = 'assistant' AND s.id = g.resource_id AND s.is_delete = 0 +LEFT JOIN t_gpts_tools_type t + ON g.resource_type = 'tool' AND CONCAT('', t.id) = g.resource_id AND t.is_delete = 0 +LEFT JOIN channel c + ON g.resource_type = 'channel' AND c.id = g.resource_id +LEFT JOIN dashboard b + ON g.resource_type = 'dashboard' AND CONCAT('', b.id) = g.resource_id +LEFT JOIN linsight_skill sk + ON g.resource_type = 'linsight_skill' AND CONCAT('', sk.id) = g.resource_id +LEFT JOIN "user" u + ON a.subject_type = 'user' AND CONCAT('', u.user_id) = a.subject_id +LEFT JOIN department d + ON a.subject_type = 'department' AND CONCAT('', d.id) = a.subject_id +LEFT JOIN "group" ug + ON a.subject_type = 'user_group' AND CONCAT('', ug.id) = a.subject_id +LEFT JOIN service_account sa + ON a.subject_type = 'service_account' AND CONCAT('', sa.id) = a.subject_id +WHERE g.state = 'ACTIVE' +ORDER BY g.tenant_id, g.resource_type, g.resource_id, pm.derived_level, a.subject_type, a.subject_id; + +-- Q2 Permission models in effect and the actions each contains +SELECT pm.model_key, pm.name AS model_name, pm.kind, pm.derived_level, pm.active, + pa.code AS action_code, pa.name AS action_name +FROM permission_catalog_release r +JOIN permission_model pm ON pm.catalog_release_id = r.id +LEFT JOIN permission_model_action pma ON pma.model_id = pm.id +LEFT JOIN permission_action pa ON pa.id = pma.action_id +WHERE r.status = 'CURRENT' +ORDER BY pm.derived_level, pm.model_key, pa.sort_order; + +-- Q3 Inherit / custom mode of folders and files (and top-level resources) +SELECT m.tenant_id, m.resource_type, m.resource_id, m."mode", m.parent_type, m.parent_id, m.update_time +FROM resource_permission_mode m +ORDER BY m.tenant_id, m.resource_type, m.resource_id; + +-- Q4 Knowledge space join policy +SELECT k.tenant_id, k.id AS space_id, k.name, k.auth_type +FROM knowledge k +WHERE k.type = 3 +ORDER BY k.tenant_id, k.id; + +-- Q5a User - department membership +SELECT ud.user_id, u.user_name, ud.department_id, d.name AS department_name, d.path, ud.is_primary +FROM user_department ud +JOIN "user" u ON u.user_id = ud.user_id +JOIN department d ON d.id = ud.department_id +ORDER BY ud.user_id, ud.is_primary DESC; + +-- Q5b User - user group membership +SELECT x.user_id, u.user_name, x.group_id, g.group_name, x.is_group_admin +FROM usergroup x +JOIN "user" u ON u.user_id = x.user_id +JOIN "group" g ON g.id = x.group_id +ORDER BY x.group_id, x.user_id; + +-- Q6a User - role +SELECT ur.user_id, u.user_name, ur.role_id, r.role_name, r.department_id AS role_department_id +FROM userrole ur +JOIN "user" u ON u.user_id = ur.user_id +JOIN role r ON r.id = ur.role_id +ORDER BY ur.user_id, ur.role_id; + +-- Q6b Role - menu permissions (type 99 = web menu) +SELECT ra.role_id, r.role_name, ra.third_id AS menu_key +FROM roleaccess ra +JOIN role r ON r.id = ra.role_id +WHERE ra.type = 99 +ORDER BY ra.role_id, ra.third_id; + +-- Q6c Menu permissions granted to individual users through approval +SELECT uma.user_id, u.user_name, uma.menu_key, uma.menu_name, uma.grant_source, uma.create_time +FROM user_menu_access uma +JOIN "user" u ON u.user_id = uma.user_id +WHERE uma.status = 'active' +ORDER BY uma.user_id, uma.menu_key; + +-- Q7a Department admins +SELECT dag.user_id, u.user_name, dag.department_id, d.name AS department_name, dag.grant_source +FROM department_admin_grant dag +JOIN "user" u ON u.user_id = dag.user_id +JOIN department d ON d.id = dag.department_id +ORDER BY dag.department_id, dag.user_id; + +-- Q7b Super admins recorded in the business DB (legacy role 1) +SELECT ur.user_id, u.user_name +FROM userrole ur +JOIN "user" u ON u.user_id = ur.user_id +WHERE ur.role_id = 1; + +-- Q7c Super admins / child-tenant admins recorded only in the OpenFGA datastore. +-- On DM8 deployments the datastore is the schema configured in OPENFGA_DATASTORE_URI, not the business schema. +SELECT object_type, object_id, relation, _user AS subject, inserted_at +FROM .tuple +WHERE (object_type = 'system' AND relation = 'super_admin') + OR (object_type = 'tenant' AND relation = 'admin') +ORDER BY object_type, object_id; diff --git a/Claude outputs/bisheng-3.0-beta1-permission-dictionary/permission_config_queries.mysql.sql b/Claude outputs/bisheng-3.0-beta1-permission-dictionary/permission_config_queries.mysql.sql new file mode 100644 index 0000000000..b5cce7f0c8 --- /dev/null +++ b/Claude outputs/bisheng-3.0-beta1-permission-dictionary/permission_config_queries.mysql.sql @@ -0,0 +1,136 @@ +-- BiSheng 3.0.0-beta1 permission configuration queries (MySQL dialect). +-- DM8: replace backticks with double quotes (also needed for the mode and protected columns). + +-- Q1 Resource grants: who was granted which permission model on which resource +SELECT + g.tenant_id, + g.resource_type, + g.resource_id, + COALESCE(k.name, f.file_name, w.name, s.name, t.name, c.name, b.title, sk.name) AS resource_name, + CASE WHEN k.id IS NULL AND f.id IS NULL AND w.id IS NULL AND s.id IS NULL + AND t.id IS NULL AND c.id IS NULL AND b.id IS NULL AND sk.id IS NULL + THEN 0 ELSE 1 END AS resource_exists, + g.model_key, + pm.name AS model_name, + a.subject_type, + a.subject_id, + CASE a.subject_type + WHEN 'user' THEN u.user_name + WHEN 'department' THEN d.name + WHEN 'user_group' THEN ug.group_name + WHEN 'service_account' THEN sa.name + END AS subject_name, + a.include_children, + a.source_type, + a.`protected`, + a.create_time, + a.update_time +FROM permission_grant g +JOIN permission_grant_assignee a + ON a.grant_id = g.id AND a.state = 'ACTIVE' +LEFT JOIN permission_catalog_release r + ON r.status = 'CURRENT' +LEFT JOIN permission_model pm + ON pm.catalog_release_id = r.id AND pm.model_key = g.model_key +LEFT JOIN knowledge k + ON g.resource_type IN ('knowledge_space', 'knowledge_library') AND CONCAT('', k.id) = g.resource_id +LEFT JOIN knowledgefile f + ON g.resource_type IN ('folder', 'knowledge_file') AND CONCAT('', f.id) = g.resource_id +LEFT JOIN flow w + ON g.resource_type = 'workflow' AND w.id = g.resource_id +LEFT JOIN assistant s + ON g.resource_type = 'assistant' AND s.id = g.resource_id AND s.is_delete = 0 +LEFT JOIN t_gpts_tools_type t + ON g.resource_type = 'tool' AND CONCAT('', t.id) = g.resource_id AND t.is_delete = 0 +LEFT JOIN channel c + ON g.resource_type = 'channel' AND c.id = g.resource_id +LEFT JOIN dashboard b + ON g.resource_type = 'dashboard' AND CONCAT('', b.id) = g.resource_id +LEFT JOIN linsight_skill sk + ON g.resource_type = 'linsight_skill' AND CONCAT('', sk.id) = g.resource_id +LEFT JOIN `user` u + ON a.subject_type = 'user' AND CONCAT('', u.user_id) = a.subject_id +LEFT JOIN department d + ON a.subject_type = 'department' AND CONCAT('', d.id) = a.subject_id +LEFT JOIN `group` ug + ON a.subject_type = 'user_group' AND CONCAT('', ug.id) = a.subject_id +LEFT JOIN service_account sa + ON a.subject_type = 'service_account' AND CONCAT('', sa.id) = a.subject_id +WHERE g.state = 'ACTIVE' +ORDER BY g.tenant_id, g.resource_type, g.resource_id, pm.derived_level, a.subject_type, a.subject_id; + +-- Q2 Permission models in effect and the actions each contains +SELECT pm.model_key, pm.name AS model_name, pm.kind, pm.derived_level, pm.active, + pa.code AS action_code, pa.name AS action_name +FROM permission_catalog_release r +JOIN permission_model pm ON pm.catalog_release_id = r.id +LEFT JOIN permission_model_action pma ON pma.model_id = pm.id +LEFT JOIN permission_action pa ON pa.id = pma.action_id +WHERE r.status = 'CURRENT' +ORDER BY pm.derived_level, pm.model_key, pa.sort_order; + +-- Q3 Inherit / custom mode of folders and files (and top-level resources) +SELECT m.tenant_id, m.resource_type, m.resource_id, m.`mode`, m.parent_type, m.parent_id, m.update_time +FROM resource_permission_mode m +ORDER BY m.tenant_id, m.resource_type, m.resource_id; + +-- Q4 Knowledge space join policy +SELECT k.tenant_id, k.id AS space_id, k.name, k.auth_type +FROM knowledge k +WHERE k.type = 3 +ORDER BY k.tenant_id, k.id; + +-- Q5a User - department membership +SELECT ud.user_id, u.user_name, ud.department_id, d.name AS department_name, d.path, ud.is_primary +FROM user_department ud +JOIN `user` u ON u.user_id = ud.user_id +JOIN department d ON d.id = ud.department_id +ORDER BY ud.user_id, ud.is_primary DESC; + +-- Q5b User - user group membership +SELECT x.user_id, u.user_name, x.group_id, g.group_name, x.is_group_admin +FROM usergroup x +JOIN `user` u ON u.user_id = x.user_id +JOIN `group` g ON g.id = x.group_id +ORDER BY x.group_id, x.user_id; + +-- Q6a User - role +SELECT ur.user_id, u.user_name, ur.role_id, r.role_name, r.department_id AS role_department_id +FROM userrole ur +JOIN `user` u ON u.user_id = ur.user_id +JOIN role r ON r.id = ur.role_id +ORDER BY ur.user_id, ur.role_id; + +-- Q6b Role - menu permissions (type 99 = web menu) +SELECT ra.role_id, r.role_name, ra.third_id AS menu_key +FROM roleaccess ra +JOIN role r ON r.id = ra.role_id +WHERE ra.type = 99 +ORDER BY ra.role_id, ra.third_id; + +-- Q6c Menu permissions granted to individual users through approval +SELECT uma.user_id, u.user_name, uma.menu_key, uma.menu_name, uma.grant_source, uma.create_time +FROM user_menu_access uma +JOIN `user` u ON u.user_id = uma.user_id +WHERE uma.status = 'active' +ORDER BY uma.user_id, uma.menu_key; + +-- Q7a Department admins +SELECT dag.user_id, u.user_name, dag.department_id, d.name AS department_name, dag.grant_source +FROM department_admin_grant dag +JOIN `user` u ON u.user_id = dag.user_id +JOIN department d ON d.id = dag.department_id +ORDER BY dag.department_id, dag.user_id; + +-- Q7b Super admins recorded in the business DB (legacy role 1) +SELECT ur.user_id, u.user_name +FROM userrole ur +JOIN `user` u ON u.user_id = ur.user_id +WHERE ur.role_id = 1; + +-- Q7c Super admins / child-tenant admins recorded only in the OpenFGA datastore (database `openfga`) +SELECT object_type, object_id, relation, _user AS subject, inserted_at +FROM openfga.tuple +WHERE (object_type = 'system' AND relation = 'super_admin') + OR (object_type = 'tenant' AND relation = 'admin') +ORDER BY object_type, object_id; diff --git a/Claude outputs/slide-01.png b/Claude outputs/slide-01.png new file mode 100644 index 0000000000..46e7db2e5e Binary files /dev/null and b/Claude outputs/slide-01.png differ diff --git a/SECURITY.md b/SECURITY.md index 3b3fa06639..6a34107607 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,39 +1,53 @@ # Security Policy -## Reporting Security Issues +## Reporting a Vulnerability -We take the security of our project seriously. If you believe you have found a security vulnerability, please report it to us privately. **Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.** +Please report security vulnerabilities to us privately, and **not** through public +GitHub issues, discussions or pull requests. -> **Important Note**: Any code within the `classic/` folder is considered legacy, unsupported, and out of scope for security reports. We will not address security vulnerabilities in this deprecated code. +Use GitHub's private reporting: +[**Report a vulnerability**](https://github.com/dataelement/bisheng/security/advisories/new) -Instead, please report them via: -- [GitHub Security Advisory](https://github.com/dataelement/bisheng/security/advisories/new) - +This opens a draft advisory visible only to you and the maintainers. We can discuss +the details, share a patch, and publish the advisory together once a fix is out. -### Reporting Process -1. **Submit Report**: Use one of the above channels to submit your report -2. **Response Time**: Our team will acknowledge receipt of your report within 14 business days. -3. **Collaboration**: We will collaborate with you to understand and validate the issue -4. **Resolution**: We will work on a fix and coordinate the release process +### What to include +- Reproducible steps, and a proof of concept if you have one +- The version or commit hash you tested +- Any mitigation or workaround you already know of -### Disclosure Policy -- Please provide detailed reports with reproducible steps -- Include the version/commit hash where you discovered the vulnerability -- Allow us a 90-day security fix window before any public disclosure -- Share any potential mitigations or workarounds if known +### What to expect + +| | | +|---|---| +| We acknowledge your report | within **3 business days** | +| We tell you whether we can reproduce it, and how we rate it | within **10 business days** | +| We keep you updated while a fix is being prepared | at least every 2 weeks | +| We credit you in the advisory and the release notes | unless you ask us not to | + +If you have not heard from us within those windows, the report has fallen through +a crack rather than been dismissed — please say so in the draft advisory, or open a +public issue asking us to look at it. + +### Disclosure + +We ask for a 90-day window before public disclosure, and we would rather publish +sooner: once a fix has shipped we publish the advisory and request a CVE. + +If a report goes unanswered past the windows above, we do not expect you to keep +waiting. That has happened before and it was our failure, not yours. ## Supported Versions -Only the following versions are eligible for security updates: + +Security fixes land on the current release line. Older lines receive fixes only for +vulnerabilities rated critical, and only while they are still in support. | Version | Supported | |---------|-----------| -| Latest release on master branch | ✅ | -| Development commits (pre-master) | ✅ | -| Classic folder (deprecated) | ❌ | -| All other versions | ❌ | - - +| 3.0.x (current release line) | ✅ | +| 2.6.x | Critical fixes only | +| Earlier releases | ❌ | --- -Last updated: November 2024 +Last updated: September 2026 diff --git a/architecture-critique.md b/architecture-critique.md new file mode 100644 index 0000000000..017bf72a78 --- /dev/null +++ b/architecture-critique.md @@ -0,0 +1,32 @@ +PROCEED — 最新设计已闭合异步提交、锁序与跨事务凭据生产的三项发现;未发现需要继续修改的架构缺陷。此结论仅供设计评审,不构成实施授权。 + +| 维度 | 评分 | 锚定理由 | +|---|---|---| +| 需求与质量属性忠实度 | 🟢 | 设计 L17、L97–105、L248 及后续接口合同落实保存零执行、显式确认、冻结取值与实际运行差异。 | +| 边界与公共接口 | 🟢 | L207、L222、L235 分别通过 F055 快照、凭据 Owner 事务入口和 F054 运行快照提供能力,未复制主数据或泄漏 ORM。 | +| 模式克制 | 🟢 | D17–D19 保留单表配置与既有操作账本;maintenance 转换记录仅承接已有事实的幂等消费,没有新增配置状态机。 | +| 数据、状态及生产消费完整性 | 🟢 | L221–222 明确全部环境生产者使用同一 session;L228 指名 ready→commit 消费者及重复事实后的提交恢复。 | +| YAGNI 与变化容忍度 | 🟢 | 保留有界配置、现有部署执行器与内部协议,不预建配置历史库、自动调度、轮换产品或热加载机制。 | +| 故障与规模现实性 | 🟢 | L208–219 区分受理、claim/fact、commit 的实际锁关系;L226–231 覆盖自身维护转换、回放、期限与失败后禁止自动重建。 | +| 决策记录与可读性 | 🟢 | 修订明确指出现有实现的独立事务和锁序差异,并记录具体适配边界,没有将目标行为误写为现有事实。 | + +### blocker + +无。 + +### should-fix + +无。 + +### nit + +无。 + +本轮复核确认: + +- **原 B1 已解决:**[设计 L228](/Users/zhangguoqing/works/bisheng/.worktrees/vibe-app-integration/features/v3.0.0/055-app-publish-pipeline/design.md:228) 明确冻结 `auto_commit=true`,复用事实事务结束后的提交入口;持久 ready 重放仍重试原提交,符合现有 manager 等待 serving 的执行协议。 +- **原 B2 已解决:**[设计 L208–219](/Users/zhangguoqing/works/bisheng/.worktrees/vibe-app-integration/features/v3.0.0/055-app-publish-pipeline/design.md:208) 禁止受理持有 App/node 后锁已有 operation,并将 fact 与 commit 分成两个事务,消除先前指出的锁等待环。 +- **原 S1 已解决:**[设计 L222](/Users/zhangguoqing/works/bisheng/.worktrees/vibe-app-integration/features/v3.0.0/055-app-publish-pipeline/design.md:222) 将主体、凭据、委托项、binding 和签发审计全部交给同 session 的凭据 Owner 入口,明确禁止独立提交及读取未提交行的另一 session。 +- **自身 maintenance 围栏已闭合:**[设计 L226](/Users/zhangguoqing/works/bisheng/.worktrees/vibe-app-integration/features/v3.0.0/055-app-publish-pipeline/design.md:226) 只接受本操作造成的精确转换;旧事实重放不再改变后续 serving 路由。 + +这是对尚未实现设计的可实施性判断。复核仅阅读文档与源码,未编辑文件、运行测试或执行远程操作。 diff --git a/deploy/kubernetes/app-runtime/Chart.yaml b/deploy/kubernetes/app-runtime/Chart.yaml new file mode 100644 index 0000000000..7edd320648 --- /dev/null +++ b/deploy/kubernetes/app-runtime/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: bisheng-app-runtime +description: Open source namespace-scoped BiSheng application runtime +version: 0.1.0 +appVersion: "3.0.0" +type: application diff --git a/deploy/kubernetes/app-runtime/README.md b/deploy/kubernetes/app-runtime/README.md new file mode 100644 index 0000000000..204ad4287f --- /dev/null +++ b/deploy/kubernetes/app-runtime/README.md @@ -0,0 +1,205 @@ +# Kubernetes 开源应用托管层 + +本 Chart 只安装 runtime-manager、app-proxy、egress-proxy 和独立 admission 进程。毕昇 backend、Celery、MySQL/DM8、Redis、MinIO、OpenFGA 继续按平台原有方式部署。所有模块开源,无商业额度或许可证执行点。 + +目标验收版本为 Kubernetes 1.28.15,支持 Linux amd64/arm64 独立执行域。Chart 渲染和单元测试不是集群实测;每个环境都必须执行下述真实探测及业务 E2E。单节点 local PV 不提供跨节点恢复或高可用承诺。 + +## 1. 身份、权限和网络 + +| 进程/身份 | 允许访问 | 不授予 | +| --- | --- | --- | +| 安装者(外部 kubeconfig) | 安装专用 namespace、PSA、网络、配额、准入证书、RBAC、PV;运行预检、更新描述文件与探测报告 | 不向任何业务 Pod 传递此身份;不由 manager 安装或扩大权限 | +| runtime-manager ServiceAccount | 仅 execution namespace:Pod/Service/PVC/Secret 的 get/list/watch/create/patch/delete;pods/log、events、endpoints、EndpointSlice 的 get/list/watch | nodes、PV、StorageClass、Namespace、RBAC、ConfigMap、Deployment、pods/status、exec、attach、portforward;不访问 control namespace 中的 Secret/工作负载 | +| app-proxy | backend 的授权及只读路由 API;执行 Pod 的 `http` 端口 | Kubernetes token、manager/control HMAC、数据库或 MinIO 账号 | +| egress-proxy | 独立 backend 策略身份;受控 DNS、平台及允许目标 | Kubernetes token、编排 API 权限 | +| admission | 只读 backend admission plan API;只读 canary plan/public settings;TLS 服务证书 | Kubernetes token、应用凭据、owner 私钥、数据库或 MinIO 账号 | +| 执行 Pod/helper | 其冻结模板与当前任务的最小材料/应用身份;业务流量只连接 egress ClusterIP | SA token、宿主敏感目录、跨应用卷、平台存储账号 | + +manager 的 token 由显式 projected ServiceAccountToken 投射,3600 秒轮换,每次 API 请求重新读取;所有 Pod 禁用自动挂载。其权限仍是可创建工作负载的强权限,必须保留独立进程、namespace RBAC 和无 token 的 admission 服务。 + +执行 namespace 默认拒绝全部入站和出站。仅 app-proxy→`http`、manager→`http`/`data-control`;出站只到系统 namespace 的 egress-proxy:3128,不直接允许 DNS、backend、MinIO、集群 API 或其他应用。`services.egressClusterIP` 必须是已规划且空闲的 Service IP,应用无需 DNS 即可连接。域名解析、私网地址/重绑定和授权时效继续由代理检查。 + +系统 namespace 也默认拒绝,values 明确列出 API-server、DNS(包括实际 NodeLocal DNS 地址)、backend、入口和控制端点来源 CIDR/端口。backend CIDR 应按真实目的地址最小化,不能宽泛覆盖应用 Pod 网段。标准 NetworkPolicy 无法约束可信节点和 cluster-admin;存在不可信 hostNetwork 工作负载时须先配置等价 CNI 主机策略并验证,不能宣称默认策略已经覆盖。 + +集群启用审计时按 `system:serviceaccount::` 查询 manager;未启用时验收记录注明“未启用”,本工具不生成伪造审计日志。 + +## 2. 安装前置和配置 + +先复制 `values.yaml` 到部署目录,配置实际地址和精确镜像引用。默认空镜像/CA/CIDR 会被 schema 拒绝;空探测报告永远不会让节点 ready。 + +- `platform.origin`:材料及内部控制 API 的 HTTPS origin,禁止内嵌账号、路径和查询参数。`platform.caConfigMap` 中的 `ca.crt` 为公开 CA 链。 +- `platform.backendBase`:app-proxy 使用的平台内部地址。当前 app-proxy 未提供专用私有 CA 参数,使用受保护的内部 HTTP 或系统信任的 HTTPS;不要关闭 TLS 校验。 +- `services`:独立系统 namespace、现存身份/seed/TLS Secret 名、控制及入口的共享暴露。外部平台访问不了 ClusterIP 时,使用两个受保护的共享 NodePort(默认不开启),由 nginx/backend 指向它们并验证真实源地址与 ACL。每应用不开放节点端口。 +- `execution`:独立业务 namespace、逻辑 node_id、cluster_id、架构、预算、临时磁盘上限、公开 data key、冻结 CRI 检查 profile。`sandbox` 为 default/recommended/hardened;强档 RuntimeClass 映射由 `runtimeClasses` 指定。 +- `images`:manager、entry、egress、admission、helper、assembler 的 `repo@sha256:…`。egress/admission 复用同版本 manager 镜像,覆盖进程命令。helper/assembler 独立镜像。控制进程需要拉取凭据时配置 `services.pullSecret`;执行 Pod 使用独立 namespace 的 `execution.pullSecret`。 +- `storage`:经实际验证的 ext4/xfs 块文件系统;预置 PV 或指定 StorageClass,生产和 scratch 独立。默认 `sqliteCompatible: false`,核实存储前不能填 true。 +- `offline`:离线默认开启;显式配置包源、镜像端点、可选 source-image 目标仓库及 registry Secret。无公共源兜底。节点 CRI 的镜像拉取由节点配置负责,不受 Pod 的 NetworkPolicy 约束。source-image 仓库/认证/CDN 出站按完整 origin 显式登记 `imageRegistryEndpoints`;私有仓库若用 5000/5443 等端口,须核对 egress Pod 的实际目的地址和端口并配置 `network.egressPorts` 或同等受限策略。仓库通过宿主端口、Service 或其他 DNAT 转发时,CNI 可能按转换后的目的端口执行策略;仅放行外显 5443 不一定允许转发到容器 5000。应在专用环境做同 selector 的 TCP 允许/拒绝配对,不扩大整个集群或应用 Pod 的出站范围。`insecureRegistry` 仅为指定目标 origin 启用 HTTP,不降低其他 base/认证端点的 TLS;跨仓库组装需为完整 OCI tar 预留临时磁盘配额。 + +### 平台 HTTPS 根路径与外层代理 + +`platform.origin` 必须同时提供既有 `/api/v1/internal/app-runtime/v2/…` 控制接口和独立的 `/internal/app-runtime/v2/material-access/…` 材料接口。后者由 backend 在根路径挂载,不带 `/api/v1` 或 `/workspace`;仅转发 `/api`、`/apps` 会使 helper 下载源码、依赖或上传产物时得到 404。Chart 不安装平台 nginx,部署者须核对该 origin 经过的**每一层**反向代理、Ingress 或网关。 + +产品 nginx 的 `docker/nginx/conf.d/default.conf` 已提供下列精确路由。自建入口须保留相同语义:原方法、原路径、原查询参数和请求体传到 backend;不能 rewrite、添加 workspace 别名、重定向到浏览器登录,也不能把全部 `/internal/` 对外转发。`proxy_pass` 不带 URI 部分,保留原始签名目标;材料 Ed25519 签名、Pod UID、委托、票据和分块摘要仍由 backend 校验,不能在代理处跳过鉴权。 + +```nginx +location ^~ /internal/app-runtime/v2/material-access/ { + proxy_pass http://backend_server; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_request_buffering off; + proxy_buffering off; + client_max_body_size 16m; + client_body_timeout 60s; + proxy_connect_timeout 60s; + proxy_send_timeout 65s; + proxy_read_timeout 65s; +} +``` + +`backend_server` 复用本部署既有 backend upstream;Host/转发来源头按平台入口现有规则传递,不改写 `X-Material-*` 身份头。16 MiB 是**单次材料分块**的 nginx 上限,不是整个源码或依赖包大小;backend 继续独立执行 64 KiB 控制请求、16 MiB 分块及材料总大小限制。关闭请求/响应缓冲,避免把流式材料落入代理临时文件;65 秒代理读写超时给 backend 的 60 秒材料请求期限保留返回错误的余量。 + +入口配置变更先执行 `nginx -t`,成功后按现有发布方式 reload,并用新连接复核原 `/api` 路由。向 HTTPS origin 的材料 exchange 路径发送无身份请求应得到 backend 的 401,而非 404、SPA HTML 或登录跳转;这只能证明根路径可达,不能证明有效签名材料传输成功。安装验收还须以真实委托覆盖 exchange、带原始 query 的 Range 下载、分块上传和 finalize,并保留签名篡改拒绝的配对证据。 + +### 必需的既有 Secret 与公钥 + +Secret 在 control namespace 中预先创建,私密值通过部署系统/安全文件供应;不要放进 values、Git、报告或命令行参数。 + +| Secret | 键 | 消费者 | +| --- | --- | --- | +| `services.credentialsSecret` | manager-key-id、manager-secret | manager→backend,仅 manager 角色 | +| 同上 | control-key-id、control-secret | backend→manager 的独立入站身份 | +| 同上 | entry-key-id、entry-secret、authz-secret | app-proxy 只读路由及既有授权协议 | +| 同上 | egress-key-id、egress-secret | egress→backend,仅 egress 角色 | +| 同上 | admission-key-id、admission-secret | admission→backend,仅只读 admission 角色 | +| `services.materialIssuerSecret` | seed:恰好 32 个原始字节 | manager 的独立持久 Ed25519 根种子,按授权 fsGroup 只读 0440 投射 | +| `services.admissionTlsSecret` | tls.crt、tls.key | admission HTTPS,SAN 包含 `runtime-admission..svc` | + +backend 另行登记各角色、node_id、HMAC 与 controller 绑定。backend 的 owner 数据签名私钥仅保留在 backend;Chart 只接受 `execution.dataPublicKeys` 公钥字典,写入 execution 的指定 ConfigMap。公钥轮转须保留仍有效 key_id,并复核 helper 实际可见值;不能把 manager seed 或 owner 私钥作为公钥填入。 + +### 命名空间引导与安装 + +Helm release 存在独立 installer namespace,避免 manager 所在 namespace 拥有 Helm release Secret。control/execution namespace 被 Chart 标为 `keep`。若为预置 Secret 先创建这些 namespace,需要显式写入本 release 的 Helm ownership(先确认没有其他安装拥有它们): + +```sh +kubectl create namespace runtime-installer +kubectl create namespace bisheng-runtime-system +kubectl label namespace bisheng-runtime-system app.kubernetes.io/managed-by=Helm +kubectl annotate namespace bisheng-runtime-system meta.helm.sh/release-name=bisheng-runtime meta.helm.sh/release-namespace=runtime-installer +``` + +execution 如需预置 pull/registry Secret,也按同样方式创建并登记 ownership。然后由安全文件创建既有 Secret、公开 CA ConfigMap,配置持久证书;升级时不随机重生成 CA/种子。 + +```sh +helm lint deploy/kubernetes/app-runtime -f /secure/deploy/runtime-values.yaml +helm template bisheng-runtime deploy/kubernetes/app-runtime -n runtime-installer -f /secure/deploy/runtime-values.yaml > /secure/deploy/runtime-rendered.yaml +helm upgrade --install bisheng-runtime deploy/kubernetes/app-runtime -n runtime-installer -f /secure/deploy/runtime-values.yaml --wait --timeout 5m +``` + +不要用 `helm --wait` 成功代替运行域 ready;服务可启动但尚未通过探测时,backend 必须拒绝业务执行。manager 按只读描述读取数据,无 `kubectl`、Docker CLI 或 docker.sock 依赖。 + +## 3. 本地卷、SQLite 和容量 + +可用无 StorageClass 的预置 local PV。`storage.volumes` 分 production/scratch/preflight 池,设置真实容量、唯一目录、节点、Retain 和 nodeAffinity;生产与临时卷禁止共用路径。manager 只创建/消费授权 PVC,不创建宿主目录、格式化磁盘或修改 PV。 + +**目录存在不等于有界持久卷。** local PV 的 capacity 字段不会把普通目录限制为指定大小。建议节点管理员预置独立块设备/逻辑卷,或专用有界 loopback 文件系统;所有格式化操作须先证明是本次新建且专用的设备/文件,禁止把示例命令用于已有盘。设置 UID/GID 10001 可写、0700/适当目录权限;只为应用开放 business 子目录,备份和平台日志目录不可见。 + +节点管理员需维护持久 mount unit、实际文件系统 UUID/挂载点和容量标记,启动时确认挂载成功才允许该节点承载应用。loop 未挂载时底层空目录必须 fail closed,不能让初始化器把它当作新数据库。升级/reboot 验收必须模拟挂载失败并验证拒绝启动;单次预检不能替代此运行期保护。 + +真实 storage canary 在独立 preflight PVC 上测试:跨进程 flock、真实 SQLite 建表与 integrity_check、文件/目录 fsync、实际挂载文件系统及容量。工具只接受 ext4/xfs,容量约为该 PVC 申请大小(0.8–1.1 倍,容许文件系统开销),拒绝把 1Gi PVC 目录映射到整块数百 Gi 宿主盘。CSI 若以共享底层容量呈现,必须另有经验证的配额证据,不能跳过该检查。NFS/SMB 不作为 SQLite WAL 的替代。 + +local PV 节点失效时数据不能自动跟随新节点。Retain 的 scratch/preflight PV 在 PVC 释放后可能进入 Released;安装者仅在所有相关 Pod 同 UID 停止并清理确认后回收专用临时池,不能按名字清生产卷或无条件清 claimRef。卷池耗尽时准备失败;保留生产数据。 + +## 4. 离线物料分开核对 + +1. **镜像可用**:供应同版本 `dist/runtime-release.json`、单架构 `runtime-images-.json`、runtime-profiles、Chart、镜像 archive、licenses 和摘要。必须核对目标架构及每个 digest。`docker load` 成功不是 CRI 按该引用启动成功;使用实际目标 CRI 灌入并执行精确引用 canary。无法保留 digest 时供应可选内网只读分发端点。 +2. **依赖可用**:预制 runtime/preparer 镜像、SDK wheel、完整且带 hash 的 wheelhouse/npm 包缓存、系统动态库、前端编译工具。首次发布或新增依赖前提前在匹配 OS/架构/ABI 的环境准备、校验并经 backend 上传。已有缓存不保证新增依赖可解析;离线模式不得向公网临时补包。 +3. **运行期接口可用**:平台 HTTPS/CA/HMAC、材料下载上传、授权/路由/策略,以及应用需要的模型、检索、附件 API;应用运行不需要外网并不代表可离线调用外部模型。 + +当前默认 bundle 路径不构建新 OCI 镜像:backend 分发版本化源码/依赖,helper 在 Pod 的独立卷中物化;runtime 挂载只读代码/环境运行。完整 image 路径直接启动冻结镜像;兼容 source-image 路径额外需要可推送内网仓库和 assembler,缺少只阻断这一路径。发行清单中的 config image_id、单平台 manifest digest、multiarch index 和 tag 含义不同,不互相替代。 + +自定义 bundle 运行镜像必须实现对应的启动器合同:Python 包含冻结版本的解释器;Node 20 保留 `/usr/local/bin/docker-entrypoint.sh` 的标准参数处理;static 保留 `/opt/bisheng/entrypoint.sh`,按 `PORT` 在可写 `/tmp` 生成 nginx 配置。代码和依赖挂载只读、应用以 profile 冻结的非特权 UID/GID 运行。任意自定义镜像若不满足这些合同,应走完整 image 模式,由它自己的 ENTRYPOINT/CMD 决定启动方式。 + +## 5. 安装验证工具 + +使用同版本 Python 3.11+ runtime-manager 依赖、kubectl 和既有 installer kubeconfig。默认只读;启用 `--live` 才创建本次 `preflight-*` canary。工具不改变 kube-system、节点代理、Docker daemon 或现有业务应用;安装者需可 impersonate manager ServiceAccount,以真测 namespace RBAC,而非用管理员身份假装 manager。 + +```sh +python scripts/verify_k8s_runtime.py --context --control-namespace bisheng-runtime-system --execution-namespace bisheng-runtime-apps --probe-plan /secure/deploy/runtime-probes.json +python scripts/verify_k8s_runtime.py --context --control-namespace bisheng-runtime-system --execution-namespace bisheng-runtime-apps --probe-plan /secure/deploy/runtime-probes.json --live --write-report +``` + +probe plan 为安装者本地文件,不放任何 secret: + +```json +{ + "installation_id": "bisheng-runtime", + "webhook_name": "bisheng-runtime-runtime", + "node_name": "dedicated-worker", + "node_id": "bisheng-k8s-amd64", + "platform_origin": "https://platform.internal", + "platform_ca_file": "/secure/deploy/platform-ca.crt", + "storage_size_mb": 1024, + "verify_registry": false, + "release_manifest": "/secure/deploy/runtime-release.json", + "dependency_files": [{"path": "/secure/deploy/wheelhouse.tar", "sha256": "<64 lowercase hex>"}], + "network_targets": { + "network-direct-denied": [ + {"name": "backend", "host": "", "port": 7860, "expected": "denied"}, + {"name": "minio", "host": "", "port": 9000, "expected": "denied"}, + {"name": "kubernetes-api", "host": "", "port": 6443, "expected": "denied"}, + {"name": "other-app", "host": "@preflight", "port": 8080, "expected": "denied"} + ], + "network-egress-allowed": [{"name": "egress", "host": "", "port": 3128, "expected": "allowed"}], + "network-dns-denied": [{"name": "cluster-dns", "host": "", "port": 53, "expected": "denied", "protocol": "udp-dns"}] + } +} +``` + +`other-app` 可用 `@preflight` 指向工具自身已启动的独立 idle canary,避免首次安装还没有业务应用的循环前置;负例仍从另一个执行 Pod 发起并做监听正对照。工具运行位置须能连到计划中的正对照目标(通常是专用集群管理机/节点)。 + +HMAC 由安全环境变量 `RTM_VERIFY_PLATFORM_KEY_ID`、`RTM_VERIFY_PLATFORM_SECRET` 注入,限本安装 node_id 的 manager 角色。它验证控制侧 HTTPS、签名允许/未签名拒绝及节点范围;**不能代替后续应用 Pod/SDK/材料委托链路 E2E**。 + +`execution.canaryProfiles` 必须包含本执行域每个实际启用的 runtime/preparer 镜像的 digest 和固定版本命令,例如 Python `["python","--version"]`、Node `["node","--version"]`、static `["nginx","-v"]`。helper 也实际启动;观察原生 Node 架构、Pod spec 固定引用、CRI imageID。只是镜像清单或 Docker 本地缓存存在不会通过这项检查。 + +工具逐项执行 15 个必需检查:namespace 预算、restricted PSA、允许及拒绝准入、可信 status 来源、删除后原子 binding 拒绝、跨 namespace RBAC、exec 拒绝、入口白名单、直连拒绝、proxy 端口允许、UDP DNS 拒绝、SQLite 卷、实际 CRI、材料 TLS、平台签名 API。危险 PodSpec 负例使用 API-server `dryRun=All`;正例用真实 Pod。网络负例在实际拒绝前后分别验证目标确实监听,connection refused、无路由、未知结果不算隔离成功。入口正反使用系统 namespace 的无凭据 helper Pod,分别采用 entry/manager/untrusted 标签,从实际网络身份发起。 + +`verify_registry: true` 时,先在系统 namespace 创建无凭据的 helper canary,使用与 egress-proxy 相同的策略标签,检查冻结 helper/profile 基础镜像仓库、目标仓库及显式 `imageRegistryEndpoints` 的 TCP 通路。origin 沿用运行时的严格解析;域名由 installer 解析全部 TCP 地址,按 IP/端口去重,解析失败或超过 32 个目标时拒绝,不静默跳过端点。该 Pod 保持 `NotReady`(未填充的 readiness gate),并检查对应 Service 未启用 `publishNotReadyAddresses`,防止探测 Pod 进入业务代理端点;它仍可正常达到 `Succeeded`、读取结果并按原 UID 停止清理。 + +此项只证明**同 selector 策略下、installer 本轮解析所得地址的 TCP 可达性**;不证明 TLS、registry 身份、代理认证、egress Pod 的域名解析或完整 assembler 路径。DNS 分区、地址轮换、CDN 重定向端点仍需按实际环境核对,新增 origin 必须显式登记。仅执行 Pod→proxy:3128 端口连通也不能替代本项。 + +TCP 前置通过后,原外部 installer 使用同版本离线供应的 `crane`,在独立随机 preflight 子仓库做真实 copy/digest/delete。该步骤证明 installer 侧 TLS、registry 权限及读写,不把它记成 assembler Pod 的端到端推送;凭据仍使用安装者已有 DOCKER_CONFIG,不读取、输出或复制给探测 Pod。真实 source-image 发布 E2E 继续验证受限 assembler 的基础镜像配置读取、TLS、代理认证和推送。任一前置失败只阻断 source-image 路径;已满足基线的 bundle/image 路径不依赖该可选检查。 + +强沙箱实际 canary 失败会记录 `sandbox-recommended` 或 `sandbox-hardened` 失败;只有全部默认必需基线成功才允许显式回退默认档。 + +canary 通过受保护的 ConfigMap 声明固定模板,plan 只有 1 小时以内寿命。无业务 Secret、源码或生产卷。installer 观察实际 UID 后更新授权,删除携带 UID/resourceVersion/宽限期;只有 fresh 同 UID 的终态或受保护的未调度删除证据才移除 finalizer。404、NodeLost、超时、同名替换一律保留,不能按名字强删。 + +清理失败会发布失败报告并返回非零。崩溃后可显式运行 `--cleanup-expired`,只处理当前安装已过期且 UID 已确认的 preflight 资源;不接管活跃探测,不操作业务应用。缺 UID、对象丢失或替换时停止,需管理员核对后处理。 + +### 报告刷新与结果边界 + +报告绑定 descriptor 摘要,只有真实执行 driver 的结果才能成功;每项包含观察时间、资源 UID 和安全原因。更改 helper、profile、namespace、预算、存储、策略或沙箱设置使旧报告失效。CM 整体 resourceVersion replace 防止覆盖并发配置;manager 只读消费,不能自行签发 ready。 + +有效期 24 小时,由**外部运维身份**每 6 小时重复真实验证并原子更新: + +```sh +python scripts/verify_k8s_runtime.py --context --control-namespace bisheng-runtime-system --execution-namespace bisheng-runtime-apps --probe-plan /secure/deploy/runtime-probes.json --live --write-report --repeat-seconds 21600 +``` + +将该进程纳入客户现有运维监督,失败退出时告警并修复;不新增具有安装者权限的集群常驻 Pod。工具不会在用户机器擅自安装定时任务。报告失效/探测失败后必需基线 fail closed;不存在跳过隔离的开关。刷新只创建预检资源、更新报告,不重装 Chart或删除业务资源。 + +## 6. 升级、卸载与验收 + +升级会通过Helm lookup核对当前release的canary ConfigMap,保留计划原始内容及UID/RV;不能用values重置活跃计划。初装canaryPlans必须为空。升级预演使用`--is-upgrade --dry-run=server`,离线渲染无法证明旧对象归属时会拒绝。无installation label的旧安装还要核对同release descriptor范围。 + +`--cleanup-expired`仅接管自然过期且UID确认的计划。未调度Pod的停止证据按资源重新实测binding拒绝;已绑定Pod需可信终态,未知/替换/缺失均保留。若旧Chart曾清空计划,先保存证据并由管理员核对精确UID,不按名称批量清理。 + + +升级先保存脱敏值、descriptor/profile/策略版本和证据,确认镜像/依赖/平台接口独立可用。保持 admission CA、material seed、owner 公钥和策略保护连续;不能卸掉 webhook 后领养旧 Pod。升级服务 Deployment 不创建应用 Deployment/Job ownerReference,不连带重建或删除独立应用 Pod/PVC。manager 采用 Recreate 避免旧新控制进程争用;多副本只允许后端租约保证单持有者,不以租约超时认定旧写入者已死。 + +代理升级会断开被终止进程的 TCP/WebSocket;按既有排空/重连协议验收,不承诺原连接永不重建。每次变更后重做真实探测,核对保持的同 UID 应用、SQLite 数据、附件和版本路由。 + +卸载前用平台停用入口并安全停止执行,确认所有 writer 的停止事实;保留源码、依赖、快照、附件和应用 PVC。`helm uninstall` 保留 Chart 的 namespace、公钥 ConfigMap和 local PV;没有 Helm ownerReference 的应用 Pod/PVC也不会随控制层删除。不能以卸载成功代替 writer 停止。只有 owner 显式删除应用的既有流程在停写确认和安全擦除成功后允许清理应用数据;Retain PV 后续由安装者按证据处理。 + +真实交付还必须补业务 E2E:发布审批/预览、Python/Node/static bundle与完整镜像/source-image、HTTP/WebSocket、应用身份/OBO/SDK、材料/附件、SQLite 数据权限、停用恢复;以及初始化失败、取消、重启、响应丢失、未知写入者、迁移失败、数据库快照恢复、升级和卸载保留。记录静态、模拟、真实执行、未验证和环境限制;189 单节点 amd64 不代表跨节点或 arm64 已通过。 + +现场配置须明确 `execution.storageControllerUsernames`(例如启用逐控制器ServiceAccount的集群),其身份同时绑定admission与能力报告。平台若经宿主机端口DNAT,`network.backendPorts`需包含网络策略实际观察到的容器端口。请用真实探测验证,不能仅以宿主curl成功判定Pod可达。 diff --git a/deploy/kubernetes/app-runtime/templates/admission.yaml b/deploy/kubernetes/app-runtime/templates/admission.yaml new file mode 100644 index 0000000000..67d6a4e82e --- /dev/null +++ b/deploy/kubernetes/app-runtime/templates/admission.yaml @@ -0,0 +1,33 @@ +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + name: {{ printf "%s-runtime" .Values.execution.installationId }} +webhooks: +{{- range $name := list "resources" "status" }} + - name: {{ printf "%s.%s.runtime.bisheng.io" $name $.Values.execution.installationId }} + admissionReviewVersions: [v1] + sideEffects: None + failurePolicy: Fail + matchPolicy: Equivalent + timeoutSeconds: {{ $.Values.admission.timeoutSeconds }} + namespaceSelector: + matchLabels: {runtime.bisheng.io/installation: {{ $.Values.execution.installationId }}} + clientConfig: + caBundle: {{ $.Values.admission.caBundle | quote }} + service: + namespace: {{ $.Values.services.namespace }} + name: runtime-admission + path: /validate + port: 443 + rules: + - apiGroups: [""] + apiVersions: [v1] + scope: Namespaced +{{- if eq $name "status" }} + operations: [UPDATE] + resources: [pods/status] +{{- else }} + operations: [CREATE, UPDATE, DELETE] + resources: [pods, services, secrets, persistentvolumeclaims] +{{- end }} +{{- end }} diff --git a/deploy/kubernetes/app-runtime/templates/network.yaml b/deploy/kubernetes/app-runtime/templates/network.yaml new file mode 100644 index 0000000000..e6358356cc --- /dev/null +++ b/deploy/kubernetes/app-runtime/templates/network.yaml @@ -0,0 +1,163 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: {name: runtime-default-deny, namespace: {{ .Values.execution.namespace }}} +spec: + podSelector: {} + policyTypes: [Ingress, Egress] +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: {name: runtime-entry, namespace: {{ .Values.execution.namespace }}} +spec: + podSelector: {matchLabels: {bisheng.managed: v2}} + policyTypes: [Ingress] + ingress: + - from: + - namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: {{ .Values.services.namespace }}}} + podSelector: {matchLabels: {app: app-proxy}} + ports: [{protocol: TCP, port: http}] + - from: + - namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: {{ .Values.services.namespace }}}} + podSelector: {matchLabels: {app: runtime-manager}} + ports: [{protocol: TCP, port: http}, {protocol: TCP, port: data-control}] +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: {name: runtime-egress-only, namespace: {{ .Values.execution.namespace }}} +spec: + podSelector: {matchLabels: {bisheng.managed: v2}} + policyTypes: [Egress] + egress: + - to: + - namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: {{ .Values.services.namespace }}}} + podSelector: {matchLabels: {app: egress-proxy}} + ports: [{protocol: TCP, port: 3128}] +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: {name: runtime-system-default-deny, namespace: {{ .Values.services.namespace }}} +spec: + podSelector: {} + policyTypes: [Ingress, Egress] +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: {name: runtime-system-ingress, namespace: {{ .Values.services.namespace }}} +spec: + podSelector: {matchLabels: {app: runtime-manager}} + policyTypes: [Ingress] + ingress: + - from: +{{- range .Values.network.controlAllowedCidrs }} + - ipBlock: {cidr: {{ . | quote }}} +{{- end }} + ports: [{protocol: TCP, port: 8091}] +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: {name: entry-system-ingress, namespace: {{ .Values.services.namespace }}} +spec: + podSelector: {matchLabels: {app: app-proxy}} + policyTypes: [Ingress] + ingress: + - from: +{{- range .Values.network.entryAllowedCidrs }} + - ipBlock: {cidr: {{ . | quote }}} +{{- end }} + ports: [{protocol: TCP, port: 8080}] +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: {name: admission-system-ingress, namespace: {{ .Values.services.namespace }}} +spec: + podSelector: {matchLabels: {app: runtime-admission}} + policyTypes: [Ingress] + ingress: + - from: +{{- range .Values.network.kubernetesApiCidrs }} + - ipBlock: {cidr: {{ . | quote }}} +{{- end }} + ports: [{protocol: TCP, port: 8443}] +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: {name: proxy-system-ingress, namespace: {{ .Values.services.namespace }}} +spec: + podSelector: {matchLabels: {app: egress-proxy}} + policyTypes: [Ingress] + ingress: + - from: + - namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: {{ .Values.execution.namespace }}}} + podSelector: {matchLabels: {bisheng.managed: v2}} + ports: [{protocol: TCP, port: 3128}] +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: {name: runtime-system-dns, namespace: {{ .Values.services.namespace }}} +spec: + podSelector: {} + policyTypes: [Egress] + egress: + - to: +{{- range .Values.network.dnsCidrs }} + - ipBlock: {cidr: {{ . | quote }}} +{{- end }} + ports: [{protocol: UDP, port: 53}, {protocol: TCP, port: 53}] +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: {name: runtime-system-platform, namespace: {{ .Values.services.namespace }}} +spec: + podSelector: {} + policyTypes: [Egress] + egress: + - to: +{{- range .Values.network.backendCidrs }} + - ipBlock: {cidr: {{ . | quote }}} +{{- end }} + ports: +{{- range .Values.network.backendPorts }} + - {protocol: TCP, port: {{ . }}} +{{- end }} +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: {name: runtime-manager-cluster, namespace: {{ .Values.services.namespace }}} +spec: + podSelector: {matchLabels: {app: runtime-manager}} + policyTypes: [Egress] + egress: + - to: +{{- range .Values.network.kubernetesApiCidrs }} + - ipBlock: {cidr: {{ . | quote }}} +{{- end }} + ports: [{protocol: TCP, port: 443}, {protocol: TCP, port: 6443}] + - to: + - namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: {{ .Values.execution.namespace }}}} + podSelector: {matchLabels: {bisheng.managed: v2}} + ports: [{protocol: TCP, port: http}, {protocol: TCP, port: data-control}] +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: {name: entry-upstream, namespace: {{ .Values.services.namespace }}} +spec: + podSelector: {matchLabels: {app: app-proxy}} + policyTypes: [Egress] + egress: + - to: + - namespaceSelector: {matchLabels: {kubernetes.io/metadata.name: {{ .Values.execution.namespace }}}} + podSelector: {matchLabels: {bisheng.managed: v2}} + ports: [{protocol: TCP, port: http}] +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: {name: proxy-outbound, namespace: {{ .Values.services.namespace }}} +spec: + podSelector: {matchLabels: {app: egress-proxy}} + policyTypes: [Egress] + egress: + - to: [{ipBlock: {cidr: 0.0.0.0/0}}, {ipBlock: {cidr: "::/0"}}] + ports: +{{- range .Values.network.egressPorts }} + - {protocol: TCP, port: {{ . }}} +{{- end }} diff --git a/deploy/kubernetes/app-runtime/templates/rbac.yaml b/deploy/kubernetes/app-runtime/templates/rbac.yaml new file mode 100644 index 0000000000..8d9c00ebd8 --- /dev/null +++ b/deploy/kubernetes/app-runtime/templates/rbac.yaml @@ -0,0 +1,65 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ .Values.services.managerAccount }} + namespace: {{ .Values.services.namespace }} +automountServiceAccountToken: false +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: runtime-manager + namespace: {{ .Values.execution.namespace }} +rules: + - apiGroups: [""] + resources: [pods, services, persistentvolumeclaims, secrets] + verbs: [get, list, watch, create, patch, delete] + - apiGroups: [""] + resources: [pods/log, events, endpoints] + verbs: [get, list, watch] + - apiGroups: [discovery.k8s.io] + resources: [endpointslices] + verbs: [get, list, watch] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: runtime-manager + namespace: {{ .Values.execution.namespace }} +subjects: + - kind: ServiceAccount + name: {{ .Values.services.managerAccount }} + namespace: {{ .Values.services.namespace }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: runtime-manager +--- +apiVersion: v1 +kind: ResourceQuota +metadata: + name: runtime-budget + namespace: {{ .Values.execution.namespace }} +spec: + hard: + requests.memory: {{ printf "%dMi" (int .Values.execution.memoryBudgetMi) | quote }} + limits.memory: {{ printf "%dMi" (int .Values.execution.memoryBudgetMi) | quote }} + requests.cpu: {{ printf "%dm" (int .Values.execution.cpuBudgetMillicores) | quote }} + limits.cpu: {{ printf "%dm" (int .Values.execution.cpuBudgetMillicores) | quote }} + pods: {{ .Values.execution.maxPods | quote }} + services.nodeports: "0" + services.loadbalancers: "0" +--- +apiVersion: v1 +kind: LimitRange +metadata: + name: runtime-container-limits + namespace: {{ .Values.execution.namespace }} +spec: + limits: + - type: Container + min: {cpu: 1m, memory: 16Mi, ephemeral-storage: 1Mi} + max: + cpu: {{ printf "%dm" (int .Values.execution.cpuBudgetMillicores) | quote }} + memory: {{ printf "%dMi" (int .Values.execution.memoryBudgetMi) | quote }} + ephemeral-storage: {{ printf "%dMi" (int .Values.execution.temporaryStorageMi) | quote }} diff --git a/deploy/kubernetes/app-runtime/templates/services.yaml b/deploy/kubernetes/app-runtime/templates/services.yaml new file mode 100644 index 0000000000..7c43aa71fa --- /dev/null +++ b/deploy/kubernetes/app-runtime/templates/services.yaml @@ -0,0 +1,159 @@ +{{- $profileImages := list }} +{{- range .Values.execution.canaryProfiles }} +{{- $profileImages = append $profileImages .runtime_image }} +{{- end }} +{{- $canary := lookup "v1" "ConfigMap" .Values.services.namespace "runtime-canary-plans" }} +{{- $canaryData := dict "schema_version" 1 "plans" (dict) "profile_images" $profileImages | toJson }} +{{- if not (empty .Values.execution.canaryPlans) }} +{{- fail "canary plans are installer-owned; values may not seed or replace plans" }} +{{- end }} +{{- if .Release.IsUpgrade }} +{{- if not $canary }}{{ fail "existing canary plans are required for upgrade" }}{{ end }} +{{- $owned := dict "runtime-canary-plans" $canary }} +{{- $installation := get (default (dict) $canary.metadata.labels) "runtime.bisheng.io/installation" }} +{{- if and $installation (ne $installation .Values.execution.installationId) }} +{{- fail "canary installation identity mismatch" }} +{{- end }} +{{- if not $installation }} +{{- $descriptor := lookup "v1" "ConfigMap" .Values.services.namespace "runtime-deployment-descriptor" }} +{{- if not $descriptor }}{{ fail "legacy canary descriptor is required for upgrade" }}{{ end }} +{{- $_ := set $owned "runtime-deployment-descriptor" $descriptor }} +{{- $domain := fromJson (get (default (dict) $descriptor.data) "descriptor.json") }} +{{- if or (ne $domain.installation_id .Values.execution.installationId) (ne $domain.cluster_id .Values.execution.clusterId) (ne $domain.namespace .Values.execution.namespace) }} +{{- fail "legacy canary descriptor scope mismatch" }} +{{- end }} +{{- end }} +{{- range $name, $object := $owned }} +{{- $meta := default (dict) $object.metadata }} +{{- $labels := default (dict) $meta.labels }} +{{- $annotations := default (dict) $meta.annotations }} +{{- if or (ne $meta.name $name) (ne $meta.namespace $.Values.services.namespace) (ne (get $labels "app.kubernetes.io/managed-by") "Helm") (ne (get $annotations "meta.helm.sh/release-name") $.Release.Name) (ne (get $annotations "meta.helm.sh/release-namespace") $.Release.Namespace) }} +{{- fail "existing canary object is not owned by this release" }} +{{- end }} +{{- if or (not (kindIs "string" $meta.uid)) (not (regexMatch "^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$" $meta.uid)) (not (kindIs "string" $meta.resourceVersion)) (empty $meta.resourceVersion) }} +{{- fail "existing canary object identity is invalid" }} +{{- end }} +{{- end }} +{{- if or (not (kindIs "map" $canary.data)) (ne (len $canary.data) 1) (not (kindIs "string" (get $canary.data "plans.json"))) }} +{{- fail "existing canary data is invalid" }} +{{- end }} +{{- $canaryData = get $canary.data "plans.json" }} +{{- $document := fromJson $canaryData }} +{{- if or (gt (len $canaryData) 1048576) (ne (len $document) 3) (not (kindIs "float64" $document.schema_version)) (ne $document.schema_version 1.0) (not (kindIs "map" $document.plans)) (not (kindIs "slice" $document.profile_images)) }} +{{- fail "existing canary document is invalid" }} +{{- end }} +{{- range $image := $document.profile_images }} +{{- if or (not (kindIs "string" $image)) (not (regexMatch "^[A-Za-z0-9][A-Za-z0-9./:_-]*@sha256:[a-f0-9]{64}$" $image)) }} +{{- fail "existing canary profile image is invalid" }} +{{- end }} +{{- end }} +{{- range $ref, $plan := $document.plans }} +{{- if or (not (regexMatch "^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$" $ref)) (not (kindIs "map" $plan)) }} +{{- fail "existing canary plan identity is invalid" }} +{{- end }} +{{- if or (ne $plan.resource_ref $ref) (ne $plan.namespace $.Values.execution.namespace) (not (kindIs "string" $plan.name)) (not (hasPrefix "preflight-" $plan.name)) (not (has $plan.kind (list "Pod" "PersistentVolumeClaim"))) (not (has $plan.stop_state (list "held" "confirmed"))) (not (kindIs "float64" $plan.expires_at)) (le $plan.expires_at 0.0) }} +{{- fail "existing canary plan scope is invalid" }} +{{- end }} +{{- range $flag := list "allow_create" "allow_update" "allow_delete" "cleanup_only" }} +{{- if not (kindIs "bool" (get $plan $flag)) }}{{ fail "existing canary plan authority is invalid" }}{{ end }} +{{- end }} +{{- if or (and $plan.allow_create $plan.uid) (and (or $plan.uid $plan.allow_update $plan.allow_delete) (not (regexMatch "^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$" (default "" $plan.uid)))) }} +{{- fail "existing canary plan UID is invalid" }} +{{- end }} +{{- $template := default (dict) $plan.template }} +{{- $meta := default (dict) $template.metadata }} +{{- if or (ne $template.kind $plan.kind) (ne $meta.name $plan.name) (ne $meta.namespace $plan.namespace) (ne (get (default (dict) $meta.labels) "runtime.bisheng.io/resource") $ref) }} +{{- fail "existing canary plan template scope is invalid" }} +{{- end }} +{{- end }} +{{- else if $canary }} +{{- fail "existing canary plans require an upgrade of their owning release" }} +{{- end }} +{{- if eq .Values.services.namespace .Values.execution.namespace }} +{{- fail "control and execution namespaces must differ" }} +{{- end }} +apiVersion: v1 +kind: Namespace +metadata: + name: {{ .Values.services.namespace }} + annotations: + helm.sh/resource-policy: keep + labels: + pod-security.kubernetes.io/enforce: restricted + pod-security.kubernetes.io/enforce-version: v1.28 +--- +apiVersion: v1 +kind: Namespace +metadata: + name: {{ .Values.execution.namespace }} + annotations: + helm.sh/resource-policy: keep + labels: + runtime.bisheng.io/installation: {{ .Values.execution.installationId }} + pod-security.kubernetes.io/enforce: restricted + pod-security.kubernetes.io/enforce-version: v1.28 +--- +apiVersion: v1 +kind: Service +metadata: + name: runtime-manager + namespace: {{ .Values.services.namespace }} +spec: + type: {{ .Values.services.managerType }} + selector: {app: runtime-manager} + ports: + - {name: control, port: 8091, targetPort: 8091{{ if eq .Values.services.managerType "NodePort" }}, nodePort: {{ .Values.services.managerNodePort }}{{ end }}} +--- +apiVersion: v1 +kind: Service +metadata: + name: app-proxy + namespace: {{ .Values.services.namespace }} +spec: + type: {{ .Values.services.entryType }} + selector: {app: app-proxy} + ports: + - {name: http, port: 8080, targetPort: 8080{{ if eq .Values.services.entryType "NodePort" }}, nodePort: {{ .Values.services.entryNodePort }}{{ end }}} +--- +apiVersion: v1 +kind: Service +metadata: + name: egress-proxy + namespace: {{ .Values.services.namespace }} +spec: + type: ClusterIP + clusterIP: {{ .Values.services.egressClusterIP | quote }} + selector: {app: egress-proxy} + ports: [{name: proxy, port: 3128, targetPort: 3128}] +--- +apiVersion: v1 +kind: Service +metadata: + name: runtime-admission + namespace: {{ .Values.services.namespace }} +spec: + selector: {app: runtime-admission} + ports: [{name: https, port: 443, targetPort: 8443}] +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Values.execution.dataKeyConfigMap }} + namespace: {{ .Values.execution.namespace }} + annotations: {helm.sh/resource-policy: keep} +data: + public-keys.json: {{ .Values.execution.dataPublicKeys | toJson | quote }} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: runtime-canary-plans + namespace: {{ .Values.services.namespace }} + labels: + runtime.bisheng.io/installation: {{ .Values.execution.installationId | quote }} +{{- if .Release.IsUpgrade }} + uid: {{ $canary.metadata.uid | quote }} + resourceVersion: {{ $canary.metadata.resourceVersion | quote }} +{{- end }} +data: + plans.json: {{ $canaryData | quote }} diff --git a/deploy/kubernetes/app-runtime/templates/storage.yaml b/deploy/kubernetes/app-runtime/templates/storage.yaml new file mode 100644 index 0000000000..2a48b304b8 --- /dev/null +++ b/deploy/kubernetes/app-runtime/templates/storage.yaml @@ -0,0 +1,36 @@ +{{- $names := dict }} +{{- $paths := dict }} +{{- range .Values.storage.volumes }} +{{- if or (hasKey $names .name) (hasKey $paths .path) }} +{{- fail "local PV names and paths must be unique" }} +{{- end }} +{{- $_ := set $names .name true }} +{{- $_ := set $paths .path true }} +{{- if or (contains ".." .path) (not (hasPrefix (printf "%s/" (trimSuffix "/" $.Values.storage.localRoot)) .path)) }} +{{- fail "local PV paths must be descendants of storage.localRoot" }} +{{- end }} +--- +apiVersion: v1 +kind: PersistentVolume +metadata: + name: {{ .name }} + annotations: {helm.sh/resource-policy: keep} + labels: + runtime.bisheng.io/installation: {{ $.Values.execution.installationId }} + runtime.bisheng.io/namespace: {{ $.Values.execution.namespace }} + runtime.bisheng.io/volume-role: {{ .role }} +spec: + capacity: {storage: {{ printf "%dMi" (int .sizeMi) | quote }}} + volumeMode: Filesystem + accessModes: [ReadWriteOnce] + persistentVolumeReclaimPolicy: Retain + storageClassName: {{ $.Values.storage.className | quote }} + local: {path: {{ .path | quote }}} + nodeAffinity: + required: + nodeSelectorTerms: + - matchExpressions: + - key: kubernetes.io/hostname + operator: In + values: [{{ .node | quote }}] +{{- end }} diff --git a/deploy/kubernetes/app-runtime/templates/workloads.yaml b/deploy/kubernetes/app-runtime/templates/workloads.yaml new file mode 100644 index 0000000000..7fad174cbb --- /dev/null +++ b/deploy/kubernetes/app-runtime/templates/workloads.yaml @@ -0,0 +1,422 @@ +{{- $storage := dict "class" .Values.storage.className "size_mb" .Values.storage.sizeMi "scratch_size_mb" .Values.storage.scratchSizeMi "filesystem" .Values.storage.filesystem "sqlite_compatible" .Values.storage.sqliteCompatible "node_local" .Values.storage.nodeLocal "static_pool" (not (empty .Values.storage.volumes)) }} +{{- $capabilities := dict "pod_cidrs" .Values.network.podCidrs "service_cidrs" .Values.network.serviceCidrs }} +{{- $descriptor := dict "schema_version" 1 "cluster_id" .Values.execution.clusterId "installation_id" .Values.execution.installationId "namespace" .Values.execution.namespace "platform" (printf "linux/%s" .Values.execution.architecture) "memory_budget_mb" .Values.execution.memoryBudgetMi "cpu_budget_millicores" .Values.execution.cpuBudgetMillicores "temporary_storage_mb" .Values.execution.temporaryStorageMi "status_policy_revision" .Values.execution.statusPolicyRevision "cni_username" .Values.execution.cniUsername "storage_controller_usernames" .Values.execution.storageControllerUsernames "helper_image" .Values.images.helper "assembler_image" .Values.images.assembler "storage" $storage "capabilities" $capabilities "pod_cidrs" .Values.network.podCidrs "service_cidrs" .Values.network.serviceCidrs "runtime_class" .Values.execution.runtimeClass "pull_secret" .Values.execution.pullSecret "data_public_keys_configmap" .Values.execution.dataKeyConfigMap "package_endpoints" .Values.offline.packageEndpoints "image_repository" .Values.offline.imageRepository "image_registry_endpoints" .Values.offline.imageRegistryEndpoints "registry_secret" .Values.offline.registrySecret "insecure_registry" .Values.offline.insecureRegistry "offline" .Values.offline.enabled "sandbox" (dict "requested" .Values.execution.sandbox "runtime_classes" .Values.execution.runtimeClasses) "canary_profiles" .Values.execution.canaryProfiles "probe_report" .Values.execution.probeReport }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: runtime-deployment-descriptor + namespace: {{ .Values.services.namespace }} +data: + descriptor.json: {{ $descriptor | toJson | quote }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: runtime-manager + namespace: {{ .Values.services.namespace }} +spec: + replicas: {{ .Values.services.managerReplicas }} + strategy: {type: Recreate} + selector: {matchLabels: {app: runtime-manager}} + template: + metadata: + labels: {app: runtime-manager} + spec: + automountServiceAccountToken: false + nodeSelector: + kubernetes.io/os: linux + kubernetes.io/arch: {{ .Values.execution.architecture }} +{{- if .Values.services.pullSecret }} + imagePullSecrets: [{name: {{ .Values.services.pullSecret }}}] +{{- end }} + serviceAccountName: {{ .Values.services.managerAccount }} + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: {type: RuntimeDefault} + terminationGracePeriodSeconds: 30 + containers: + - name: runtime-manager + image: {{ .Values.images.manager | quote }} + imagePullPolicy: IfNotPresent + command: [python, -m, uvicorn, "runtime_manager.main:app", --host, 0.0.0.0, --port, "8091", --no-access-log] + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: {drop: [ALL]} + ports: [{name: service, containerPort: 8091}] + resources: + requests: {cpu: 100m, memory: 128Mi, ephemeral-storage: 64Mi} + limits: {cpu: "1", memory: 512Mi, ephemeral-storage: 512Mi} + env: + - name: RTM_HOST + value: 0.0.0.0 + - name: RTM_PORT + value: "8091" + - name: RTM_EXECUTOR + value: kubernetes + - name: RTM_DATA_ROOT + value: /tmp/runtime + - name: RTM_PLATFORM_KEY_ID + valueFrom: + secretKeyRef: {name: {{ .Values.services.credentialsSecret }}, key: manager-key-id} + - name: RTM_PLATFORM_SECRET + valueFrom: + secretKeyRef: {name: {{ .Values.services.credentialsSecret }}, key: manager-secret} + - name: RTM_INBOUND_KEY_ID + valueFrom: + secretKeyRef: {name: {{ .Values.services.credentialsSecret }}, key: control-key-id} + - name: RTM_INBOUND_SECRET + valueFrom: + secretKeyRef: {name: {{ .Values.services.credentialsSecret }}, key: control-secret} + - name: RTM_MATERIAL_ISSUER_KEY_FILE + value: /run/material-issuer/seed + - name: RTM_K8S_SANDBOX + value: {{ .Values.execution.sandbox | quote }} + - name: RTM_K8S_NAMESPACE + value: {{ .Values.execution.namespace | quote }} + - name: RTM_K8S_CLUSTER_ID + value: {{ .Values.execution.clusterId | quote }} + - name: RTM_K8S_DEPLOYMENT_DESCRIPTOR + value: /run/deployment/descriptor.json + - name: RTM_K8S_API_SERVER + value: {{ .Values.network.apiServer | quote }} + - name: RTM_K8S_TOKEN_FILE + value: /run/kubernetes/token + - name: RTM_K8S_CA_FILE + value: /run/kubernetes/ca.crt + - name: RTM_EGRESS_PROXY + value: {{ printf "%s:3128" .Values.services.egressClusterIP | quote }} + - name: RTM_BUILD_INDEX_URL + value: {{ .Values.offline.packageIndex | quote }} + - name: RTM_BUILD_EXTRA_INDEX_URL + value: {{ .Values.offline.extraPackageIndex | quote }} + - name: RTM_BUILD_NPM_REGISTRY + value: {{ .Values.offline.npmRegistry | quote }} + - name: RTM_PROTOCOL_VERSION + value: "2" + - name: RTM_NODE_ID + value: {{ .Values.execution.nodeId | quote }} + - name: RTM_PLATFORM_BASE_URL + value: {{ .Values.platform.origin | quote }} + - name: RTM_PLATFORM_CA_FILE + value: /run/platform-ca/ca.crt + readinessProbe: + httpGet: {path: /healthz, port: service} + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 6 + livenessProbe: + httpGet: {path: /healthz, port: service} + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 6 + volumeMounts: + - {name: tmp, mountPath: /tmp} + - {name: platform-ca, mountPath: /run/platform-ca, readOnly: true} + - {name: kubernetes, mountPath: /run/kubernetes, readOnly: true} + - {name: descriptor, mountPath: /run/deployment, readOnly: true} + - {name: material-issuer, mountPath: /run/material-issuer, readOnly: true} + volumes: + - name: tmp + emptyDir: {sizeLimit: 512Mi} + - name: platform-ca + configMap: + name: {{ .Values.platform.caConfigMap }} + defaultMode: 0444 + items: [{key: ca.crt, path: ca.crt}] + - name: kubernetes + projected: + defaultMode: 0440 + sources: + - serviceAccountToken: {path: token, expirationSeconds: 3600} + - configMap: + name: kube-root-ca.crt + items: [{key: ca.crt, path: ca.crt}] + - name: descriptor + configMap: + name: runtime-deployment-descriptor + defaultMode: 0444 + - name: material-issuer + secret: + secretName: {{ .Values.services.materialIssuerSecret }} + defaultMode: 0440 + items: [{key: seed, path: seed}] +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: app-proxy + namespace: {{ .Values.services.namespace }} +spec: + replicas: {{ .Values.services.entryReplicas }} + strategy: {type: Recreate} + selector: {matchLabels: {app: app-proxy}} + template: + metadata: + labels: {app: app-proxy} + spec: + automountServiceAccountToken: false + nodeSelector: + kubernetes.io/os: linux + kubernetes.io/arch: {{ .Values.execution.architecture }} +{{- if .Values.services.pullSecret }} + imagePullSecrets: [{name: {{ .Values.services.pullSecret }}}] +{{- end }} + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: {type: RuntimeDefault} + terminationGracePeriodSeconds: 30 + containers: + - name: app-proxy + image: {{ .Values.images.entry | quote }} + imagePullPolicy: IfNotPresent + command: [python, -m, uvicorn, "app_proxy.main:app", --host, 0.0.0.0, --port, "8080", --no-access-log] + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: {drop: [ALL]} + ports: [{name: service, containerPort: 8080}] + resources: + requests: {cpu: 100m, memory: 128Mi, ephemeral-storage: 64Mi} + limits: {cpu: "1", memory: 512Mi, ephemeral-storage: 512Mi} + env: + - name: APP_PROXY_HOST + value: 0.0.0.0 + - name: APP_PROXY_PORT + value: "8080" + - name: APP_PROXY_PROTOCOL_VERSION + value: "2" + - name: APP_PROXY_BACKEND_BASE + value: {{ .Values.platform.backendBase | quote }} + - name: APP_PROXY_MANAGER_BASE + value: {{ printf "http://runtime-manager.%s.svc:8091" .Values.services.namespace | quote }} + - name: APP_PROXY_BACKEND_SECRET + valueFrom: + secretKeyRef: {name: {{ .Values.services.credentialsSecret }}, key: authz-secret} + - name: APP_PROXY_MANAGER_SECRET + value: "" + - name: APP_PROXY_ROUTE_KEY_ID + valueFrom: + secretKeyRef: {name: {{ .Values.services.credentialsSecret }}, key: entry-key-id} + - name: APP_PROXY_ROUTE_SECRET + valueFrom: + secretKeyRef: {name: {{ .Values.services.credentialsSecret }}, key: entry-secret} + readinessProbe: + httpGet: {path: /healthz, port: service} + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 6 + livenessProbe: + httpGet: {path: /healthz, port: service} + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 6 + volumeMounts: + - {name: tmp, mountPath: /tmp} + volumes: + - name: tmp + emptyDir: {sizeLimit: 512Mi} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: egress-proxy + namespace: {{ .Values.services.namespace }} +spec: + replicas: {{ .Values.services.egressReplicas }} + strategy: {type: Recreate} + selector: {matchLabels: {app: egress-proxy}} + template: + metadata: + labels: {app: egress-proxy} + spec: + automountServiceAccountToken: false + nodeSelector: + kubernetes.io/os: linux + kubernetes.io/arch: {{ .Values.execution.architecture }} +{{- if .Values.services.pullSecret }} + imagePullSecrets: [{name: {{ .Values.services.pullSecret }}}] +{{- end }} + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: {type: RuntimeDefault} + terminationGracePeriodSeconds: 30 + containers: + - name: egress-proxy + image: {{ .Values.images.egress | quote }} + imagePullPolicy: IfNotPresent + command: [python, -m, runtime_manager.egress_proxy] + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: {drop: [ALL]} + ports: [{name: service, containerPort: 3128}] + resources: + requests: {cpu: 100m, memory: 128Mi, ephemeral-storage: 64Mi} + limits: {cpu: "1", memory: 512Mi, ephemeral-storage: 512Mi} + env: + - name: RTM_EXECUTOR + value: docker + - name: RTM_DATA_ROOT + value: /tmp/runtime + - name: RTM_EGRESS_LISTEN + value: 0.0.0.0:3128 + - name: RTM_EGRESS_PROXY + value: {{ printf "%s:3128" .Values.services.egressClusterIP | quote }} + - name: RTM_PLATFORM_KEY_ID + valueFrom: + secretKeyRef: {name: {{ .Values.services.credentialsSecret }}, key: egress-key-id} + - name: RTM_PLATFORM_SECRET + valueFrom: + secretKeyRef: {name: {{ .Values.services.credentialsSecret }}, key: egress-secret} + - name: RTM_PROTOCOL_VERSION + value: "2" + - name: RTM_NODE_ID + value: {{ .Values.execution.nodeId | quote }} + - name: RTM_PLATFORM_BASE_URL + value: {{ .Values.platform.origin | quote }} + - name: RTM_PLATFORM_CA_FILE + value: /run/platform-ca/ca.crt + readinessProbe: + tcpSocket: {port: service} + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 6 + livenessProbe: + tcpSocket: {port: service} + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 6 + volumeMounts: + - {name: tmp, mountPath: /tmp} + - {name: platform-ca, mountPath: /run/platform-ca, readOnly: true} + volumes: + - name: tmp + emptyDir: {sizeLimit: 512Mi} + - name: platform-ca + configMap: + name: {{ .Values.platform.caConfigMap }} + defaultMode: 0444 + items: [{key: ca.crt, path: ca.crt}] +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: runtime-admission + namespace: {{ .Values.services.namespace }} +spec: + replicas: {{ .Values.services.admissionReplicas }} + strategy: {type: Recreate} + selector: {matchLabels: {app: runtime-admission}} + template: + metadata: + labels: {app: runtime-admission} + spec: + automountServiceAccountToken: false + nodeSelector: + kubernetes.io/os: linux + kubernetes.io/arch: {{ .Values.execution.architecture }} +{{- if .Values.services.pullSecret }} + imagePullSecrets: [{name: {{ .Values.services.pullSecret }}}] +{{- end }} + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + fsGroupChangePolicy: OnRootMismatch + seccompProfile: {type: RuntimeDefault} + terminationGracePeriodSeconds: 30 + containers: + - name: runtime-admission + image: {{ .Values.images.admission | quote }} + imagePullPolicy: IfNotPresent + command: [python, -m, runtime_manager.k8s_admission] + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: {drop: [ALL]} + ports: [{name: service, containerPort: 8443}] + resources: + requests: {cpu: 100m, memory: 128Mi, ephemeral-storage: 64Mi} + limits: {cpu: "1", memory: 512Mi, ephemeral-storage: 512Mi} + env: + - name: RTM_ADMISSION_PLATFORM_URL + value: {{ .Values.platform.origin | quote }} + - name: RTM_ADMISSION_KEY_ID + valueFrom: + secretKeyRef: {name: {{ .Values.services.credentialsSecret }}, key: admission-key-id} + - name: RTM_ADMISSION_SECRET_FILE + value: /run/admission-identity/secret + - name: RTM_ADMISSION_PLATFORM_CA_FILE + value: /run/platform-ca/ca.crt + - name: RTM_K8S_NAMESPACE + value: {{ .Values.execution.namespace | quote }} + - name: RTM_ADMISSION_MANAGER_USERNAME + value: {{ printf "system:serviceaccount:%s:%s" .Values.services.namespace .Values.services.managerAccount | quote }} + - name: RTM_ADMISSION_STATUS_POLICY_REVISION + value: {{ .Values.execution.statusPolicyRevision | quote }} + - name: RTM_ADMISSION_CNI_USERNAME + value: {{ .Values.execution.cniUsername | quote }} + - name: RTM_STORAGE_CONTROLLER_USERNAMES + value: {{ .Values.execution.storageControllerUsernames | toJson | quote }} + - name: RTM_DATA_PUBLIC_KEYS_CONFIGMAP + value: {{ .Values.execution.dataKeyConfigMap | quote }} + - name: RTM_ADMISSION_TLS_CERT_FILE + value: /run/admission-tls/tls.crt + - name: RTM_ADMISSION_TLS_KEY_FILE + value: /run/admission-tls/tls.key + - name: RTM_ADMISSION_HELPER_IMAGE + value: {{ .Values.images.helper | quote }} + - name: RTM_ADMISSION_CANARY_PLANS_FILE + value: /run/preflight/plans.json + readinessProbe: + httpGet: {path: /health, port: service, scheme: HTTPS} + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 6 + livenessProbe: + httpGet: {path: /health, port: service, scheme: HTTPS} + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 6 + volumeMounts: + - {name: tmp, mountPath: /tmp} + - {name: platform-ca, mountPath: /run/platform-ca, readOnly: true} + - {name: admission-identity, mountPath: /run/admission-identity, readOnly: true} + - {name: admission-tls, mountPath: /run/admission-tls, readOnly: true} + - {name: canary-plans, mountPath: /run/preflight, readOnly: true} + volumes: + - name: tmp + emptyDir: {sizeLimit: 512Mi} + - name: platform-ca + configMap: + name: {{ .Values.platform.caConfigMap }} + defaultMode: 0444 + items: [{key: ca.crt, path: ca.crt}] + - name: admission-identity + secret: + secretName: {{ .Values.services.credentialsSecret }} + defaultMode: 0440 + items: [{key: admission-secret, path: secret}] + - name: admission-tls + secret: + secretName: {{ .Values.services.admissionTlsSecret }} + defaultMode: 0440 + items: [{key: tls.crt, path: tls.crt}, {key: tls.key, path: tls.key}] + - name: canary-plans + configMap: + name: runtime-canary-plans + defaultMode: 0444 diff --git a/deploy/kubernetes/app-runtime/values.schema.json b/deploy/kubernetes/app-runtime/values.schema.json new file mode 100644 index 0000000000..2fb2e55d9e --- /dev/null +++ b/deploy/kubernetes/app-runtime/values.schema.json @@ -0,0 +1,665 @@ +{ + "type": "object", + "additionalProperties": false, + "required": [ + "platform", + "services", + "execution", + "images", + "network", + "storage", + "admission", + "offline" + ], + "properties": { + "platform": { + "type": "object", + "additionalProperties": false, + "required": [ + "origin", + "backendBase", + "caConfigMap" + ], + "properties": { + "origin": { + "type": "string", + "pattern": "^https://[^/]+/?$" + }, + "backendBase": { + "type": "string", + "pattern": "^https?://[^/]+/?$" + }, + "caConfigMap": { + "type": "string", + "minLength": 1, + "maxLength": 63, + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$" + } + } + }, + "services": { + "type": "object", + "additionalProperties": false, + "required": [ + "namespace", + "credentialsSecret", + "materialIssuerSecret", + "admissionTlsSecret", + "managerAccount", + "managerReplicas", + "entryReplicas", + "egressReplicas", + "admissionReplicas", + "managerType", + "entryType", + "managerNodePort", + "entryNodePort", + "egressClusterIP", + "pullSecret" + ], + "properties": { + "namespace": { + "type": "string", + "minLength": 1, + "maxLength": 63, + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", + "allOf": [ + { + "not": { + "enum": [ + "default", + "kubekey-system" + ] + } + }, + { + "not": { + "pattern": "^kube-" + } + } + ] + }, + "credentialsSecret": { + "type": "string", + "minLength": 1, + "maxLength": 63, + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$" + }, + "materialIssuerSecret": { + "type": "string", + "minLength": 1, + "maxLength": 63, + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$" + }, + "admissionTlsSecret": { + "type": "string", + "minLength": 1, + "maxLength": 63, + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$" + }, + "managerAccount": { + "type": "string", + "minLength": 1, + "maxLength": 63, + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$" + }, + "managerReplicas": { + "type": "integer", + "minimum": 1, + "maximum": 3 + }, + "entryReplicas": { + "type": "integer", + "minimum": 1, + "maximum": 3 + }, + "egressReplicas": { + "type": "integer", + "minimum": 1, + "maximum": 3 + }, + "admissionReplicas": { + "type": "integer", + "minimum": 1, + "maximum": 3 + }, + "managerType": { + "enum": [ + "ClusterIP", + "NodePort" + ] + }, + "entryType": { + "enum": [ + "ClusterIP", + "NodePort" + ] + }, + "managerNodePort": { + "type": "integer", + "minimum": 30000, + "maximum": 32767 + }, + "entryNodePort": { + "type": "integer", + "minimum": 30000, + "maximum": 32767 + }, + "egressClusterIP": { + "type": "string", + "minLength": 1, + "pattern": "^[0-9a-fA-F.:]+$" + }, + "pullSecret": { + "type": "string", + "maxLength": 63, + "pattern": "^$|^[a-z0-9]([a-z0-9-]*[a-z0-9])?$" + } + } + }, + "execution": { + "type": "object", + "additionalProperties": false, + "required": [ + "namespace", + "clusterId", + "nodeId", + "installationId", + "architecture", + "memoryBudgetMi", + "cpuBudgetMillicores", + "maxPods", + "temporaryStorageMi", + "runtimeClass", + "statusPolicyRevision", + "cniUsername", + "storageControllerUsernames", + "pullSecret", + "dataKeyConfigMap", + "dataPublicKeys", + "probeReport", + "canaryPlans", + "canaryProfiles", + "sandbox", + "runtimeClasses" + ], + "properties": { + "namespace": { + "type": "string", + "minLength": 1, + "maxLength": 63, + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", + "allOf": [ + { + "not": { + "enum": [ + "default", + "kubekey-system" + ] + } + }, + { + "not": { + "pattern": "^kube-" + } + } + ] + }, + "clusterId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$" + }, + "nodeId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$" + }, + "installationId": { + "type": "string", + "minLength": 1, + "maxLength": 63, + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$" + }, + "architecture": { + "enum": [ + "amd64", + "arm64" + ] + }, + "memoryBudgetMi": { + "type": "integer", + "minimum": 1 + }, + "cpuBudgetMillicores": { + "type": "integer", + "minimum": 1 + }, + "maxPods": { + "type": "integer", + "minimum": 1 + }, + "temporaryStorageMi": { + "type": "integer", + "minimum": 1 + }, + "runtimeClass": { + "type": "string" + }, + "statusPolicyRevision": { + "type": "string", + "minLength": 1, + "maxLength": 63, + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$" + }, + "cniUsername": { + "type": "string" + }, + "storageControllerUsernames": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^system:(kube-controller-manager|kube-scheduler|serviceaccount:[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?:[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)$" + } + }, + "pullSecret": { + "type": "string" + }, + "dataKeyConfigMap": { + "type": "string", + "minLength": 1, + "maxLength": 63, + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$" + }, + "dataPublicKeys": { + "type": "object", + "minProperties": 1, + "maxProperties": 8, + "additionalProperties": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "probeReport": { + "type": "object" + }, + "canaryPlans": { + "type": "object" + }, + "canaryProfiles": { + "type": "array", + "maxItems": 32, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "runtime_image", + "command" + ], + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]{0,62}$" + }, + "runtime_image": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9./:_-]*@sha256:[a-f0-9]{64}$" + }, + "command": { + "enum": [ + [ + "python", + "--version" + ], + [ + "node", + "--version" + ], + [ + "nginx", + "-v" + ] + ] + } + } + } + }, + "sandbox": { + "enum": [ + "default", + "recommended", + "hardened" + ] + }, + "runtimeClasses": { + "type": "object", + "additionalProperties": false, + "required": [ + "recommended", + "hardened" + ], + "properties": { + "recommended": { + "type": "string", + "maxLength": 63, + "pattern": "^$|^[a-z0-9]([a-z0-9-]*[a-z0-9])?$" + }, + "hardened": { + "type": "string", + "maxLength": 63, + "pattern": "^$|^[a-z0-9]([a-z0-9-]*[a-z0-9])?$" + } + } + } + } + }, + "images": { + "type": "object", + "additionalProperties": false, + "required": [ + "manager", + "entry", + "egress", + "admission", + "helper", + "assembler" + ], + "properties": { + "manager": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9./:_-]*@sha256:[a-f0-9]{64}$" + }, + "entry": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9./:_-]*@sha256:[a-f0-9]{64}$" + }, + "egress": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9./:_-]*@sha256:[a-f0-9]{64}$" + }, + "admission": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9./:_-]*@sha256:[a-f0-9]{64}$" + }, + "helper": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9./:_-]*@sha256:[a-f0-9]{64}$" + }, + "assembler": { + "anyOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9./:_-]*@sha256:[a-f0-9]{64}$" + }, + { + "const": "" + } + ] + } + } + }, + "network": { + "type": "object", + "additionalProperties": false, + "required": [ + "podCidrs", + "serviceCidrs", + "controlAllowedCidrs", + "entryAllowedCidrs", + "kubernetesApiCidrs", + "dnsCidrs", + "backendCidrs", + "backendPorts", + "egressPorts", + "apiServer" + ], + "properties": { + "podCidrs": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "items": { + "type": "string", + "pattern": "^[0-9a-fA-F.:]+/[0-9]{1,3}$" + } + }, + "serviceCidrs": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "items": { + "type": "string", + "pattern": "^[0-9a-fA-F.:]+/[0-9]{1,3}$" + } + }, + "controlAllowedCidrs": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "items": { + "type": "string", + "pattern": "^[0-9a-fA-F.:]+/[0-9]{1,3}$" + } + }, + "entryAllowedCidrs": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "items": { + "type": "string", + "pattern": "^[0-9a-fA-F.:]+/[0-9]{1,3}$" + } + }, + "kubernetesApiCidrs": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "items": { + "type": "string", + "pattern": "^[0-9a-fA-F.:]+/[0-9]{1,3}$" + } + }, + "dnsCidrs": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "items": { + "type": "string", + "pattern": "^[0-9a-fA-F.:]+/[0-9]{1,3}$" + } + }, + "backendCidrs": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "items": { + "type": "string", + "pattern": "^[0-9a-fA-F.:]+/[0-9]{1,3}$" + } + }, + "backendPorts": { + "type": "array", + "minItems": 1, + "items": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + }, + "egressPorts": { + "type": "array", + "minItems": 1, + "items": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + }, + "apiServer": { + "type": "string", + "pattern": "^https://[^/]+/?$" + } + } + }, + "storage": { + "type": "object", + "additionalProperties": false, + "required": [ + "className", + "sizeMi", + "scratchSizeMi", + "filesystem", + "sqliteCompatible", + "nodeLocal", + "localRoot", + "volumes" + ], + "properties": { + "className": { + "type": "string" + }, + "sizeMi": { + "type": "integer", + "minimum": 1 + }, + "scratchSizeMi": { + "type": "integer", + "minimum": 1 + }, + "filesystem": { + "enum": [ + "ext4", + "xfs" + ] + }, + "sqliteCompatible": { + "type": "boolean" + }, + "nodeLocal": { + "type": "boolean" + }, + "localRoot": { + "type": "string", + "minLength": 2, + "pattern": "^/[^.].*" + }, + "volumes": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "role", + "path", + "node", + "sizeMi" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 63, + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$" + }, + "role": { + "enum": [ + "production", + "scratch", + "preflight" + ] + }, + "path": { + "type": "string", + "pattern": "^/" + }, + "node": { + "type": "string", + "minLength": 1, + "maxLength": 63, + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$" + }, + "sizeMi": { + "type": "integer", + "minimum": 1 + } + } + } + } + } + }, + "admission": { + "type": "object", + "additionalProperties": false, + "required": [ + "caBundle", + "timeoutSeconds" + ], + "properties": { + "caBundle": { + "type": "string", + "minLength": 1, + "pattern": "^[A-Za-z0-9+/=\\r\\n]+$" + }, + "timeoutSeconds": { + "type": "integer", + "minimum": 1, + "maximum": 10 + } + } + }, + "offline": { + "type": "object", + "additionalProperties": false, + "required": [ + "enabled", + "packageIndex", + "extraPackageIndex", + "npmRegistry", + "packageEndpoints", + "imageRepository", + "imageRegistryEndpoints", + "registrySecret", + "insecureRegistry" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "packageIndex": { + "type": "string" + }, + "extraPackageIndex": { + "type": "string" + }, + "npmRegistry": { + "type": "string" + }, + "packageEndpoints": { + "type": "array", + "items": { + "type": "string" + } + }, + "imageRepository": { + "type": "string" + }, + "imageRegistryEndpoints": { + "type": "array", + "items": { + "type": "string" + } + }, + "registrySecret": { + "type": "string" + }, + "insecureRegistry": { + "type": "boolean" + } + } + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} diff --git a/deploy/kubernetes/app-runtime/values.yaml b/deploy/kubernetes/app-runtime/values.yaml new file mode 100644 index 0000000000..2a721344dd --- /dev/null +++ b/deploy/kubernetes/app-runtime/values.yaml @@ -0,0 +1,90 @@ +# Supply immutable image references and existing credentials before installing. +# The chart installs the runtime only; backend and platform storage are external. +platform: + origin: "" + backendBase: "" + caConfigMap: runtime-platform-ca +services: + namespace: bisheng-runtime-system + credentialsSecret: runtime-service-identities + materialIssuerSecret: runtime-material-issuer + admissionTlsSecret: runtime-admission-tls + managerAccount: runtime-manager + pullSecret: "" + managerReplicas: 1 + entryReplicas: 1 + egressReplicas: 1 + admissionReplicas: 2 + managerType: ClusterIP + managerNodePort: 30091 + entryType: ClusterIP + entryNodePort: 30080 + egressClusterIP: "" +execution: + namespace: bisheng-runtime-apps + clusterId: bisheng-k8s + nodeId: bisheng-k8s-amd64 + installationId: bisheng-runtime + architecture: amd64 + memoryBudgetMi: 4096 + cpuBudgetMillicores: 4000 + maxPods: 64 + temporaryStorageMi: 4096 + runtimeClass: "" + sandbox: default + runtimeClasses: {recommended: "", hardened: ""} + statusPolicyRevision: v1 + cniUsername: "" + # Explicit controller usernames for PVC binding/protection; never natural users or groups. + storageControllerUsernames: [system:kube-controller-manager, system:kube-scheduler] + pullSecret: "" + dataKeyConfigMap: runtime-data-public-keys + dataPublicKeys: {} + # Written only by the privileged verification tool after actual cluster probes. + probeReport: {} + # Optional immutable canary approvals for the installation verification tool. + canaryPlans: {} + # Frozen native CRI checks; only python/node/nginx version commands are accepted. + canaryProfiles: [] +images: + manager: "" + entry: "" + egress: "" + admission: "" + helper: "" + assembler: "" +network: + podCidrs: [] + serviceCidrs: [] + controlAllowedCidrs: [] + entryAllowedCidrs: [] + kubernetesApiCidrs: [] + dnsCidrs: [] + backendCidrs: [] + backendPorts: [443, 7860] + egressPorts: [80, 443] + apiServer: https://kubernetes.default.svc +storage: + className: "" + sizeMi: 1024 + scratchSizeMi: 4096 + filesystem: ext4 + sqliteCompatible: false + nodeLocal: true + # Directories must already exist on mounted, bounded filesystems. + localRoot: /var/lib/bisheng-runtime + volumes: [] +admission: + # Base64 public CA certificate matching the persistent TLS secret. + caBundle: "" + timeoutSeconds: 5 +offline: + enabled: true + packageIndex: "" + extraPackageIndex: "" + npmRegistry: "" + packageEndpoints: [] + imageRepository: "" + imageRegistryEndpoints: [] + registrySecret: "" + insecureRegistry: false diff --git a/deploy/runtime-manager/README.md b/deploy/runtime-manager/README.md new file mode 100644 index 0000000000..79cd20e245 --- /dev/null +++ b/deploy/runtime-manager/README.md @@ -0,0 +1,47 @@ +# Docker 宿主访问隔离(F054) + +这是宿主上的 root oneshot/timer,独立于非 root 的 runtime-manager 容器。需要宿主 Python 3.11 与已安装的 `runtime_manager` 包(含 docker SDK、httpx)、可访问的 Engine API、明确的 iptables 或 nftables 后端;不需要 Docker CLI。**不得将此单元放进 manager 容器运行。** + +## 安装顺序 + +1. 预加载固定 digest 的 Python 运行镜像;canary 只用其标准库,不下载依赖。创建 IPv4 `--internal` bridge 应用网络和受控出口。使用独立构建网络时也列入 `network_names`,每个网络都必须能到达同一受控出口容器。未受保护的默认 bridge 构建会被拒绝。 +2. 写 root-only `/etc/bisheng/runtime-host-isolation.json`(下面示例使用文档地址,需替换): + +```json +{ + "node_id": "docker-node-1", + "network_names": ["bisheng-apps", "bisheng-build"], + "proxy_container": "bisheng-egress-proxy", + "ingress_containers": ["bisheng-runtime-manager", "bisheng-app-proxy"], + "canary_image": "dataelement/bisheng-app-python@sha256:<已交付的真实镜像摘要>", + "host_targets": [["192.0.2.10", 22]], + "firewall_backend": "iptables", + "platform_base_url": "http://127.0.0.1" +} +``` + +`ingress_containers` 仅填写受信控制服务的 Docker 容器名,不填写托管应用。工具检查容器运行中、没有托管应用标签,并把其实际网络 IP 加入签名 descriptor。只在 FORWARD 链允许这些精确源 IP 到同一应用子网的 ORIGINAL TCP;不增加 INPUT 放行。canary 还通过这些控制容器实测 HTTP 入口。控制容器重建后地址不变可恢复;地址或名称变化须重新生成绑定,旧绑定立即失效。manager 和宿主工具必须使用同一版本,否则 descriptor 对账拒绝执行。 + +`host_targets` 由管理员指定,必须确实监听,且同端口经应用 bridge gateway 可达;例如监听 0.0.0.0 的专用探测服务。canary 对该服务执行 host 允许、应用拒绝的配对检查。服务不监听不能充当隔离证据。镜像必须包含 `python`,使用 rootfs 只读、非 root、无挂载的临时实例;此探测不接触应用 SQLite/PVC。 + +3. 在宿主执行 `python -m runtime_manager.host_isolation --settings /etc/bisheng/runtime-host-isolation.json --describe`。该命令只读,输出实际 Engine ID、network ID、配置摘要及 descriptor。将完整输出按 node_id 存入 backend 的 `app_runtime.runtime_host_isolation_nodes` 或 `BISHENG_RUNTIME_HOST_ISOLATION_NODES` JSON 映射。网络/出口容器/IP/探测镜像或固定目标改变后重新生成并更新映射。后端旧报告随配置摘要变化立即失效。 +4. backend 的 `runtime_service_keys` 增加单独 `roles=["host-isolation"]`、绑定该 node_id 的签名身份。manager 的身份保持 `roles=["manager"]`;两者不能共用 key。写 `/etc/bisheng/runtime-host-isolation.env`,只配置 `HOST_ISOLATION_KEY_ID`、`HOST_ISOLATION_SECRET`、`RTM_DOCKER_HOST`(如 socket proxy HTTP 地址;也可用宿主 socket)。密钥不写进 Git。Compose 已登记 `host-isolation-node-1`;其 secret 对应 `BISHENG_RUNTIME_HOST_ISOLATION_SECRET`,节点描述对应 `BISHENG_RUNTIME_HOST_ISOLATION_NODES`。宿主 `platform_base_url` 使用宿主可达的 nginx/backend 地址,不能使用仅在容器 DNS 内有效的 `backend` 主机名。 +5. 配置 manager 的 `RTM_NODE_ID`、`RTM_PLATFORM_BASE_URL`、`RTM_PLATFORM_KEY_ID`、`RTM_PLATFORM_SECRET`,使其可使用已有 v2 内部签名客户端读取宿主证明。legacy 形态也需要这条证明读取配置;不改变 legacy 业务 API。注意:manager 与出站代理以宿主 systemd 进程运行的部署,只补这条配置不够——隔离规则会拦掉应用到宿主网关的出站和附件访问,只能整体升级到 v2(2026-10-27 在 114 验证,见 `docs/architecture/14-app-factory-deployment.md`「形态二」)。 +6. 执行 `bash deploy/runtime-manager/install-host-isolation.sh /opt/bisheng/runtime-manager/.venv/bin/python`。脚本验证包和配置后安装并运行单元;首次失败不会启用 timer。使用 `systemctl status` 和 `journalctl -u bisheng-host-isolation` 定位错误;不得只按进程存活认定隔离成立。 + +## 行为与恢复 + +- iptables 使用自有 INPUT/FORWARD 链,parent jump 放在前部;应用源只允许精确 egress IP:3128 的 ORIGINAL TCP 和 ESTABLISHED REPLY;受信控制入口另按上述精确源 IP / 目标子网开放 FORWARD TCP,其他应用主动连接丢弃,不放开 backend 附件服务端口。 +- nftables 使用自有 `inet bisheng_egress` 的 input/forward hooks。每次先检查完整事务、应用、读取结构,并做真实 allow/deny canary。不同工具版本的 read-back 不匹配会拒绝证明,不降级为只看链存在。 +- timer 每 30 秒重新应用自有规则、修复漂移并探测;backend 报告有效期 60 秒,使用接收时刻、递增 revision 和一次性签名 nonce。不由 manager 心跳续期。 +- 恢复失败时只停止实际 Engine 上、指定网络内的本模块托管实例;v2 还要求 node 标签匹配。停止后再次读取同一容器 ID 确认。未知停止状态继续按失败处理;不删除容器、卷、SQLite 或其他网络资源。 +- 旧 canary 意外残留、宿主同时重启、nft 不同版本规范化和隔离漂移的实际恢复延迟仍需专用 Docker 环境验收;本轮只做规则/报告的定向本地验证,不宣称宿主实测通过。 +- 报告失效拒绝新的 prepare/probe/preview/deploy/restore/迁移、执行领取及 ready/route 提交;停止/销毁/清理不因报告过期受阻。K8s 保持 F059 的 CNI 能力证明,不读取 Docker 宿主报告。 + +## 卸载与升级 + +升级先安装兼容的 host 工具和配置,再启动新 manager;缺少证明时新版 Docker 启动会明确失败。应用需要的代码包/依赖/离线镜像按原运行方案保留。 + +卸载先 `systemctl disable --now bisheng-host-isolation.timer`,确认 oneshot 已结束,再显式执行 `python -m runtime_manager.host_isolation --settings /etc/bisheng/runtime-host-isolation.json --remove`;该操作先通过签名报告作废 backend 准入,再确认本模块实例已停止,最后只移除自有 jumps/chains/table;无法作废报告时保留规则并退出失败。最后删除这两个 systemd 单元并 daemon-reload。保留配置与证据供排障;不清应用数据、不清其他服务的防火墙规则。 + +每个 Docker Engine 只安装一个本单元,使用一个 node_id,并在 network_names 汇总该节点的应用与构建网络。独立 installer 不能共享自有链名。 diff --git a/deploy/runtime-manager/bisheng-host-isolation.service b/deploy/runtime-manager/bisheng-host-isolation.service new file mode 100644 index 0000000000..25545b9d34 --- /dev/null +++ b/deploy/runtime-manager/bisheng-host-isolation.service @@ -0,0 +1,16 @@ +[Unit] +Description=BiSheng Docker host isolation verification and repair +After=docker.service network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +User=root +EnvironmentFile=/etc/bisheng/runtime-host-isolation.env +ExecStart=@PYTHON@ -m runtime_manager.host_isolation --settings /etc/bisheng/runtime-host-isolation.json +TimeoutStartSec=180 +UMask=0077 +PrivateTmp=true +ProtectHome=true +ProtectSystem=strict +ReadWritePaths=/run/lock diff --git a/deploy/runtime-manager/bisheng-host-isolation.timer b/deploy/runtime-manager/bisheng-host-isolation.timer new file mode 100644 index 0000000000..a74a7ba909 --- /dev/null +++ b/deploy/runtime-manager/bisheng-host-isolation.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Refresh BiSheng host isolation evidence every 30 seconds + +[Timer] +OnBootSec=10 +OnUnitActiveSec=30 +AccuracySec=1 +Unit=bisheng-host-isolation.service + +[Install] +WantedBy=timers.target diff --git a/deploy/runtime-manager/install-host-isolation.sh b/deploy/runtime-manager/install-host-isolation.sh new file mode 100644 index 0000000000..ebf945960d --- /dev/null +++ b/deploy/runtime-manager/install-host-isolation.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail +if [[ ${EUID} -ne 0 ]]; then + echo 'Run on the Docker host as root.' >&2 + exit 1 +fi +python_path=${1:?Usage: install-host-isolation.sh /absolute/path/to/python} +if [[ ! ${python_path} =~ ^/[A-Za-z0-9_./-]+$ || ! -x ${python_path} ]]; then + echo 'A valid absolute Python executable is required.' >&2 + exit 1 +fi +"${python_path}" -c 'import runtime_manager.host_isolation' +for required_file in /etc/bisheng/runtime-host-isolation.json /etc/bisheng/runtime-host-isolation.env; do + test -f "${required_file}" + chown root:root "${required_file}" + chmod 600 "${required_file}" +done +unit_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +sed "s|@PYTHON@|${python_path}|g" "${unit_dir}/bisheng-host-isolation.service" > /etc/systemd/system/bisheng-host-isolation.service +install -m 0644 "${unit_dir}/bisheng-host-isolation.timer" /etc/systemd/system/bisheng-host-isolation.timer +systemctl daemon-reload +systemctl start bisheng-host-isolation.service +systemctl enable --now bisheng-host-isolation.timer diff --git a/docker/.env.example b/docker/.env.example new file mode 100644 index 0000000000..12de4bfa5d --- /dev/null +++ b/docker/.env.example @@ -0,0 +1,126 @@ +# Deployment variables for docker-compose.yml and the optional Compose files. +# Run `bash install.sh` to generate `.env` and deploy. The installer preserves +# existing settings. Defaults keep the legacy single-host credentials. +# Compose reads only `.env`; it does not load this template itself. +# Image addresses are defined here; Compose requires them from `.env`. +# Container-internal addresses and derived environment values are wired in +# docker-compose.yml. + +# Public account for the default registry; install.sh logs in before pulling. +CR_REGISTRY_USERNAME=docker +CR_REGISTRY_PASSWORD=dataelem + +# Images: use complete references, including registry/repository and tag or digest. +# Backend and worker share one image; OpenFGA and its migration share another. +# CR release tags below are AMD64; on ARM64 append -arm64 to the five BISHENG service image tags. +# Tag builds publish -amd64/-arm64 first; the formal tags are promoted after Release approval. +BISHENG_BACKEND_IMAGE=cr.dataelem.com/dataelement/bisheng-backend:v3.0.0 +BISHENG_FRONTEND_IMAGE=cr.dataelem.com/dataelement/bisheng-frontend:v3.0.0 +MYSQL_IMAGE=cr.dataelem.com/mysql:8.0 +OPENFGA_IMAGE=cr.dataelem.com/dataelement/openfga:v1.15.2-dm +REDIS_IMAGE=cr.dataelem.com/redis:7.0.4 +ELASTICSEARCH_IMAGE=cr.dataelem.com/bitnami/elasticsearch:8.12.0 +ETCD_IMAGE=cr.dataelem.com/quay.io/coreos/etcd:v3.5.5 +MINIO_IMAGE=cr.dataelem.com/minio/minio:RELEASE.2023-03-20T20-16-18Z +MILVUS_IMAGE=cr.dataelem.com/milvusdb/milvus:v2.5.10 +# Full image reference replaces the former SANDBOX_IMAGE_TAG setting. +BISHENG_SANDBOX_IMAGE=cr.dataelem.com/dataelement/bisheng-sandbox:v3.0.0 +# Optional app-runtime profile images. +BISHENG_RUNTIME_MANAGER_IMAGE=cr.dataelem.com/dataelement/bisheng-runtime-manager:v3.0.0 +BISHENG_APP_PROXY_IMAGE=cr.dataelem.com/dataelement/bisheng-app-proxy:v3.0.0 +# Socket proxy uses separate architecture tags; the unsuffixed tag is AMD64. +DOCKER_SOCKET_PROXY_IMAGE=cr.dataelem.com/tecnativa/docker-socket-proxy:0.3.0 +# ARM alternative: set DOCKER_SOCKET_PROXY_IMAGE to cr.dataelem.com/tecnativa/docker-socket-proxy:0.3.0-arm64. +# Optional standalone Compose services. +ONLYOFFICE_IMAGE=cr.dataelem.com/onlyoffice/documentserver:7.1.1 +# Gateway v1.8.0 publishes separate architecture tags. +BISHENG_GATEWAY_IMAGE=cr.dataelem.com/dataelement/gateway:v1.8.0 +# ARM alternative: set BISHENG_GATEWAY_IMAGE to cr.dataelem.com/dataelement/gateway:v1.8.0-arm64. + +# The dedicated platform network is created automatically by Compose. +COMPOSE_PROJECT_NAME=bisheng +BISHENG_NETWORK_NAME=bisheng-network + +# install.sh adds/removes gateway here; Compose uses this to select services. +COMPOSE_PROFILES= +BISHENG_PRO=false +BISHENG_PUBLIC_URL= +# User-supplied readable license file; no file means no Gateway. +BISHENG_GATEWAY_LICENSE_FILE= +# Populated from the file by install.sh, never entered directly. +BISHENG_GATEWAY_LICENSE= +BISHENG_GATEWAY_HMAC_SECRET= +BISHENG_GATEWAY_DB_PASSWORD= +# install.sh selects a generated directory when Gateway is enabled. +BISHENG_NGINX_CONF_DIR=./nginx/conf.d + +# Required: object storage +# Object storage credentials. Used by MinIO itself, by the bisheng backend and +# workers, and by Milvus, which stores its segments in the same bucket. All of +# them must agree, so they are defined once here. +# Legacy defaults; retain your existing values when upgrading. +MINIO_ROOT_USER=minioadmin +MINIO_ROOT_PASSWORD=minioadmin + +# Base services +# Where the compose stack keeps its data directories. Defaults to the compose +# file's own directory. +DOCKER_VOLUME_DIRECTORY=. +# Maximum OpenFGA authorization graph resolution depth. +OPENFGA_RESOLVE_NODE_LIMIT=25 + +# Code sandbox +# Shared by backend, worker and both code runners. Replace the development +# token for deployment, e.g. with the output of openssl rand -hex 32. +SANDBOX_TOKEN=bisheng-sandbox-local +# Local code execution (code nodes, code interpreter type `local`) runs under a +# throwaway uid with resource limits. Startup tightens /app/bisheng and +# /app/data (host data/bisheng) to 0700. Set to `off` to keep the old +# same-user execution. +LOCAL_ISOLATION=uid + +# CPUs shared by all sessions on each code runner (code-runner-1/2). Must be +# <= the host CPU count or Docker refuses to start the runners. Default 2. +SANDBOX_CPUS=2 + +# Optional app runtime: enable with --profile app-runtime and the matching +# backend configuration. See docs/architecture/14-app-factory-deployment.md +# and src/runtime-manager/OPERATIONS.md for the deployment prerequisites. +BISHENG_APP_DATA_ROOT=/opt/bisheng/app-data +BISHENG_RUNTIME_PROTOCOL_VERSION=2 +BISHENG_RUNTIME_NODE_ID=docker-node-1 +BISHENG_RUNTIME_OFFLINE=true +BISHENG_RUNTIME_IMAGE_PULL_POLICY=never +BISHENG_RUNTIME_TASK_TIMEOUT_SECONDS=1800 +# Optional audit source DateTime zone; blank uses the backend/worker TZ (then UTC). +BISHENG_RUNTIME_AUDIT_SOURCE_TIMEZONE= + +# App runtime authentication: generate a distinct secret for each variable, +# e.g. openssl rand -hex 32. Required when enabling the app-runtime profile. +# Keep real secrets out of Git and retain a secure backup. +BISHENG_RUNTIME_MANAGER_SECRET= +BISHENG_RUNTIME_CONTROL_SECRET= +BISHENG_RUNTIME_ENTRY_SECRET= +BISHENG_RUNTIME_EGRESS_SECRET= +BISHENG_APP_PROXY_HMAC_SECRET= +BISHENG_APP_OBO_SECRET= +# Fernet key, generated with cryptography. Shared by backend and worker only. +# Generate in the backend Python environment: +# python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())' +BISHENG_RUNTIME_PAYLOAD_KEY= + +# App runtime host isolation: independent Docker-host reporter identity. +# Generate the node descriptor with --describe and install host units before +# enabling app runtime; see deploy/runtime-manager/README.md. +BISHENG_RUNTIME_HOST_ISOLATION_SECRET= +BISHENG_RUNTIME_HOST_ISOLATION_NODES= + +# App runtime capacity and build package indexes +BISHENG_RTM_RESERVE_MB=2048 +BISHENG_RTM_OVERCOMMIT_RATIO=0.8 +BISHENG_RTM_BUILD_RESERVE_MB=2048 +# Optional approved Python/npm mirrors. Blank uses the runtime image defaults. +BISHENG_RTM_BUILD_INDEX_URL= +BISHENG_RTM_BUILD_NPM_REGISTRY= +# Runtime/preparer base images are selected by immutable profiles in backend, +# not by RTM_IMAGE_PREFIX. See src/runtime-manager/OPERATIONS.md. diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000000..3c34cf0055 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,55 @@ +# BiSheng 全新部署 + +需要已启动的 Docker 和 Docker Compose。推荐 Compose V2;也支持具备 profiles 和完成依赖能力的 `docker-compose`。 + +从代码根目录执行: + +```bash +bash docker/install.sh +``` + +脚本首次将 `.env.example` 复制为 `.env`,校验配置,使用默认公共账号登录 `cr.dataelem.com`,拉取镜像并执行 `up -d`。默认不启用 Gateway,也不会询问是否启用。已有 `.env` 会保留,不自动覆盖或合并。部署需要 Bash 和常用系统命令,无需宿主机 Python。 + +账号在 `.env` 的 `CR_REGISTRY_USERNAME` / `CR_REGISTRY_PASSWORD` 中配置;默认使用提供的公共账号,密码通过 `--password-stdin` 传入 Docker。`--prepare-only` 只生成、校验配置,不登录、不拉取、不启动服务。 + +## Gateway(可选) + +准备可读、非空的 license 文件后执行: + +```bash +bash docker/install.sh --gateway \ + --license-file /secure/path/license.txt \ + --public-url http://服务器IP:3001 +``` + +Gateway 与基础服务位于同一个 Compose 文件,通过 `gateway` profile 启用。脚本配置 license、共享密钥、数据库初始化及 Nginx 的 API/WebSocket 转发。没有 license 文件路径则跳过 Gateway;提供了不存在、不可读或空文件则停止。license 的有效性和期限由 Gateway 校验。 + +后续重复执行会保留 `.env` 中的 Gateway 配置。使用 `--no-gateway` 可恢复直接转发;该选项不会删除数据,也不停止已经运行的 Gateway。 + +## 镜像与部署配置 + +所有镜像地址集中在 `.env.example`,默认使用 `cr.dataelem.com`。需要修改镜像时,先执行 `bash docker/install.sh --prepare-only`,编辑生成的 `.env` 中完整的 `*_IMAGE` 地址,再执行安装脚本。下载源码不会重新构建镜像。 + +ARM 主机需要将每个启用服务的镜像替换为兼容 ARM 的 tag 或多架构镜像。Gateway ARM tag 为 `v1.8.0-arm64`。Socket proxy 的 AMD64 tag 为 `0.3.0`,ARM64 部署需设置 `DOCKER_SOCKET_PROXY_IMAGE=cr.dataelem.com/tecnativa/docker-socket-proxy:0.3.0-arm64`;沙箱必须使用可用的对应架构镜像。 + +MinIO 默认账号密码为 `minioadmin/minioadmin`,MySQL 沿用 `1234`。数据目录默认位于 `docker/` 下,由 `DOCKER_VOLUME_DIRECTORY` 控制。平台使用专用网络 `bisheng-network`(可通过 `BISHENG_NETWORK_NAME` 修改),沙箱使用 `bisheng-sandbox`。 + +端口和资源需要满足 Compose 配置;Elasticsearch 的宿主机配置、外部模型及文档服务按实际环境准备。首次启动后仍需注册用户、配置模型。 + +## 查看启动状态 + +```bash +cd docker +docker compose ps +docker compose logs -f backend backend_worker +# 启用 Gateway 时检查授权结果: +docker compose logs gateway +``` + +`up -d` 返回表示已执行后台启动,服务是否就绪需查看状态和日志。默认入口为 `http://服务器IP:3001`。 + +## 其他部署方式 + +- VibeApp 运行环境是独立的 `app-runtime` profile,需要额外配置,见[应用工场部署说明](../docs/architecture/14-app-factory-deployment.md)。 +- Kubernetes 部署见 [Helm 说明](helm/README.md)。 +- Office 可选服务使用 `docker-compose-office.yml`。 diff --git a/docker/bisheng-ft/config.yaml b/docker/bisheng-ft/config.yaml deleted file mode 100644 index 954f08f919..0000000000 --- a/docker/bisheng-ft/config.yaml +++ /dev/null @@ -1,3 +0,0 @@ -# Celery 的broker配置。存储ft指令执行结果。 -# 密码加密规则和backend一致, 暂不支持集群redis -redis_url: "redis://bisheng-redis:6379/5" diff --git a/docker/bisheng-sandbox/Dockerfile b/docker/bisheng-sandbox/Dockerfile new file mode 100644 index 0000000000..6d553ef8dc --- /dev/null +++ b/docker/bisheng-sandbox/Dockerfile @@ -0,0 +1,113 @@ +# Isolation-environment image: backend venv + office binaries + runner HTTP. +# Build from the repository root (same Dockerfile for amd64 / arm64): +# docker build -f docker/bisheng-sandbox/Dockerfile -t dataelement/bisheng-sandbox:v3.0.0-beta1 . +# +# Derived from src/backend/base.Dockerfile: full uv.lock venv, LibreOffice, pandoc, +# fonts, ffmpeg. Does NOT copy platform code, config.yaml, entrypoint.sh, or +# Chromium. Build-time compilers / wget are purged before the final layer. +# +# CI: pass HTTP(S)_PROXY build-args (GitHub / Debian). Do not bake proxy into ENV — +# the running sandbox has no outbound (AC-12). + +FROM python:3.11-slim + +ARG TARGETARCH +ARG APT_MIRROR=mirrors.tuna.tsinghua.edu.cn +ARG APT_SECURITY_MIRROR=mirrors.tuna.tsinghua.edu.cn +ARG PYPI_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple +ARG http_proxy +ARG https_proxy +ARG HTTP_PROXY +ARG HTTPS_PROXY +ARG no_proxy +ARG NO_PROXY + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + NLTK_DATA=/usr/local/share/nltk_data \ + PATH="/app/.venv/bin:/usr/local/bin:/usr/bin:/bin" \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_INDEX_URL=${PYPI_INDEX_URL} \ + UV_DEFAULT_INDEX=${PYPI_INDEX_URL} + +WORKDIR /app + +# Official debian images wipe apt caches after each RUN; drop that so the +# BuildKit .deb cache survives across CI builds. Do NOT cache /var/lib/apt — +# a failed proxy response there sticks as NODATA/NOSPLIT on the next build. +# +# deb.debian.org through the CI proxy returns invalid Release signatures; use +# tuna by default; public CI supplies the official Debian/PyPI sources. +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + rm -f /etc/apt/apt.conf.d/docker-clean && \ + if [ -f /etc/apt/sources.list.d/debian.sources ]; then \ + sed -i "s|deb.debian.org|${APT_MIRROR}|g; s|security.debian.org|${APT_SECURITY_MIRROR}|g" \ + /etc/apt/sources.list.d/debian.sources; \ + fi && \ + if [ -f /etc/apt/sources.list ]; then \ + sed -i "s|deb.debian.org|${APT_MIRROR}|g; s|security.debian.org|${APT_SECURITY_MIRROR}|g" \ + /etc/apt/sources.list; \ + fi && \ + apt-get -o APT::Update::Error-Mode=any update && \ + apt-get install -y --no-install-recommends \ + gcc g++ build-essential wget curl ca-certificates \ + libreoffice-writer libreoffice-impress libreoffice-calc \ + libreoffice-java-common default-jre-headless \ + fonts-wqy-zenhei fontconfig \ + tini \ + ffmpeg \ + libglib2.0-0 libsm6 libxrender1 libxext6 libgl1 \ + && fc-cache -f + +# pandoc 3.6.4 → /usr/bin/pandoc. TARGETARCH matches Docker (amd64 / arm64). +RUN arch="${TARGETARCH:-amd64}" && \ + wget -q "https://github.com/jgm/pandoc/releases/download/3.6.4/pandoc-3.6.4-linux-${arch}.tar.gz" \ + -O /tmp/pandoc.tar.gz && \ + tar -xzf /tmp/pandoc.tar.gz -C /tmp && \ + cp /tmp/pandoc-3.6.4/bin/pandoc /usr/bin/pandoc && \ + rm -rf /tmp/pandoc.tar.gz /tmp/pandoc-3.6.4 + +# uv from PyPI (tuna). Do not download the official installer from GitHub. +RUN pip install --no-cache-dir uv + +# Full third-party venv, same lock as backend. --no-install-project keeps +# FastAPI / Celery / Linsight sources out of the image (AC-15 / AC-28). +COPY src/backend/pyproject.toml src/backend/uv.lock ./ +RUN --mount=type=cache,target=/root/.cache/uv \ + mkdir -p /usr/local/share/nltk_data && \ + uv sync --frozen --no-dev --no-install-project && \ + python -c "import os, nltk; d=os.environ['NLTK_DATA']; nltk.download('punkt', download_dir=d); nltk.download('punkt_tab', download_dir=d); nltk.download('averaged_perceptron_tagger', download_dir=d); nltk.download('averaged_perceptron_tagger_eng', download_dir=d)" && \ + apt-get purge -y --auto-remove gcc g++ build-essential wget curl && \ + chmod -R a+rX /app /usr/local/share/nltk_data + +# Playwright Python package arrives via uv.lock; do NOT install Chromium. + +COPY --chmod=0555 src/sandbox-runner /opt/sandbox-runner +ENV PYTHONPATH="/opt/sandbox-runner" + +# Session uids (10000-10999) need passwd / group entries, or getpass.getuser() +# and pwd.getpwuid() raise KeyError inside user code. Written at build time: +# the runtime root filesystem is read-only. +RUN for uid in $(seq 10000 10999); do \ + echo "sandbox${uid}:x:${uid}:${uid}::/nonexistent:/usr/sbin/nologin" >> /etc/passwd; \ + echo "sandbox${uid}:x:${uid}:" >> /etc/group; \ + done + +# matplotlib's default sans-serif list has no CJK glyphs, so Chinese titles +# render as boxes. The runner passes MATPLOTLIBRC through to user code. +RUN printf '%s\n' \ + 'font.family: sans-serif' \ + 'font.sans-serif: WenQuanYi Zen Hei, DejaVu Sans' \ + 'axes.unicode_minus: False' \ + > /etc/matplotlibrc && chmod 0644 /etc/matplotlibrc +ENV MATPLOTLIBRC=/etc/matplotlibrc + +WORKDIR /opt/sandbox-runner +USER 65534 + +# tini is PID 1 and reaps orphans that user code leaves behind (soffice's gpg +# helpers, fork loops); python as PID 1 never reaps them and the pids limit +# fills up. Compose must not set init: true on top (that would make tini PID 2). +# Compose must not publish 8080 to the host. +ENTRYPOINT ["tini", "--"] +CMD ["python", "serve.py"] diff --git a/docker/bisheng-uns/config.yaml b/docker/bisheng-uns/config.yaml deleted file mode 100644 index eb92df028d..0000000000 --- a/docker/bisheng-uns/config.yaml +++ /dev/null @@ -1,57 +0,0 @@ -# 可根据loguru的文档配置不同 handlers -logger_conf: - # 默认输出到控制台的日志级别, 大于等于此级别都会输出 - level: DEBUG - # 默认输出格式 - format: '[{time:YYYY-MM-DD HH:mm:ss.SSSSSS}] [{level.name} process-{process.id}-{thread.id} {name}:{line}] - trace={extra[trace_id]} {message}' - # 参考loguru.add()中的参数可以配置多个handler - handlers: - # 文件路径,支持插入一些系统环境变量,若环境变量不存在则置空。例如 HOSTNAME: 主机名。后端会处理环境变量的替换 - - sink: "/app/logs/bisheng_uns.log" - # 日志级别 - level: INFO - # 和原生不一样,后端会将配置使用eval()执行转为函数用来过滤特定日志级别。推荐lambda - # filter: "lambda record: record['level'].name == 'INFO'" - # 日志格式化函数,extra内支持trace_id - format: "[{time:YYYY-MM-DD HH:mm:ss.SSSSSS}]|{level}|BISHENG|{extra[trace_id]}|{process.id}|{thread.id}|{message}" - # 每天的几点进行切割 - rotation: "00:00" - retention: "3 Days" - - sink: "/app/logs/err-v0-BISHENG-UNS-{HOSTNAME}.log" - level: ERROR - filter: "lambda record: record['level'].name == 'ERROR'" - format: "[{time:YYYY-MM-DD HH:mm:ss.SSSSSS}]|{level}|BISHENG|{extra[trace_id]}||{process.id}|{thread.id}|||#EX_ERR:POS={name},line {line},ERR=500,EMSG={message}" - rotation: "00:00" - retention: "3 Days" - -# pdf解析需要用到的模型配置, 配置了rt_server环境变量的话会替换为对应的地址 -pdf_model_params: - layout_ep: "http://192.168.106.12:9001/v2.1/models/elem_layout_v1/infer" - cell_model_ep: "http://192.168.106.12:9001/v2.1/models/elem_table_cell_detect_v1/infer" - rowcol_model_ep: "http://192.168.106.12:9001/v2.1/models/elem_table_rowcol_detect_v1/infer" - table_model_ep: "http://192.168.106.12:9001/v2.1/models/elem_table_detect_v1/infer" - ocr_model_ep: "http://192.168.106.12:9001/v2.1/models/elem_ocr_collection_v3/infer" - -# 是否全部走ocr识别, false的话则由代码逻辑判断是否需要走ocr识别 -is_all_ocr: false -# ocr识别需要的配置项 -ocr_conf: - params: - sort_filter_boxes: true, - enable_huarong_box_adjust: true, - rotateupright: false, - support_long_image_segment: true, - split_long_sentence_blank: true - scene_mapping: - print: - det: general_text_det_mrcnn_v2.0 - recog: transformer-blank-v0.2-faster - hand: - det: general_text_det_mrcnn_v2.0 - recog: transformer-hand-v1.16-faster - print_recog: - recog: transformer-blank-v0.2-faster - hand_recog: - recog: transformer-hand-v1.16-faster - det: - det: general_text_det_mrcnn_v2.0 diff --git a/docker/bisheng/config/config.yaml b/docker/bisheng/config/config.yaml index 5686b4b33f..ffbc8c14c5 100644 --- a/docker/bisheng/config/config.yaml +++ b/docker/bisheng/config/config.yaml @@ -26,6 +26,14 @@ database_pool: # 轮换:改这里的值或删掉配置表中的记录后重启,全员需重新登录。 # jwt_secret: "replace-with-a-long-random-string" +# Desktop deployment: set the public origin once for login, Chat and Work tools. +# Use the address entered in the client, including its public port when present. +# Example: https://work.example.com or http://192.0.2.15:18081 +# Enable this section with a Desktop-compatible backend image. +# dsh: +# enabled: true +# platform_public_url: https://work.example.com + # 缓存配置 redis://[[username]:[password]]@localhost:6379/0 # 如果设置了密码,需要参考MySQL密码的加密逻辑对密码进行加密。eg: redis://root:gAAAAABlp4b4c59FeVGF_OQRVf6NOUIGdxq8246EBD-b0hdK_jVKRs1x4PoAn0A6C5S6IiFKmWn0Nm5eBUWu-7jxcqw6TiVjQA==@redis:6379/0 # 普通模式: @@ -36,9 +44,15 @@ openfga: enabled: true api_url: "http://openfga:8080" -# 网关 HMAC 推送验签(与 bisheng-gateway 的 bisheng.gateway-hmac-secret 一致) +# 网关 HMAC 推送验签,仅商业版 Java 网关的 SSO 登录/组织同步链路使用。 +# 留空即关闭:/internal/sso/login-sync、/internal/sso/gateway-wecom-org-sync、 +# /departments/sync、/internal/departments/relink* 会拒绝所有请求(fail-closed)。 +# 开源单体部署不走这条链路,保持留空即可。 +# 部署商业版网关时必须填一个足够长的随机串,并与网关的 +# bisheng.gateway-hmac-secret 配成同一个值——这把密钥等同于「可以替任何人 +# 签发登录态」的权限,绝不能沿用示例值或在多个环境间复用。 sso_sync: - gateway_hmac_secret: "bisheng-local-hmac-20260422" + gateway_hmac_secret: !env ${BISHENG_GATEWAY_HMAC_SECRET} signature_header: "X-Signature" # 集群模式或者哨兵模式(只能选其一): @@ -156,15 +170,147 @@ logger_conf: retention: "3 Days" enqueue: ture -# Open API (personal tokens / skill packs). Uncomment only on images that already -# know this section (3.0.0-beta1 and later): older images refuse unknown keys at boot. + +# ══════════════════════════════════════════════════════════════════════════ +# 应用工场(v3.0 新增) +# +# ⚠️ 升级顺序不可颠倒:**先发代码 → 再加下面的键 → 再全量重启**。 +# 后端加载 config.yaml 时对**不认识的顶层键直接抛 KeyError 拒绝启动** +# (common/services/config_service.py 的 load_settings_from_yaml)。 +# 也就是说:把 `app_runtime:` / `open_platform:` 加到还没升级到 v3.0 的 +# 镜像上,后端会起不来,症状是容器反复重启而不是"功能没生效"。 +# 正因如此,**下面两块默认整体注释掉**——本文件要能被任何镜像版本安全加载。 +# 升级到 v3.0 镜像之后,再把要用的那一块解开注释(去掉行首的 "# ")并重启。 +# +# 两个开关互相独立、任意组合都能启动,**默认都是 false = 整层不装**: +# - open_platform 开放能力层(本地开发工具包 / CLI 的三个 scope、连接信息面板) +# - app_runtime 工场运行时层(runtime-manager + app-proxy + /apps/{slug} 入口) +# 完整运维说明见 docs/architecture/14-app-factory-deployment.md +# ══════════════════════════════════════════════════════════════════════════ + +# ── 开放能力层开关(F049)──────────────────────────────────────────────── +# false:本地开发工具包的三个 scope(model:invoke / identity:read / app:manage) +# 既不在密钥签发表单里出现,签发/编辑时携带也会被拒(错误码 26023); +# 注意:这是签发期策略,已签发的密钥不因该开关改回 false 而在调用期被拒; +# 服务账号模块本身始终可用,不受此开关影响。 +# 用 CLI(bisheng login / bisheng deploy)部署托管应用时**必须为 true**—— +# 它签发的密钥需要 app:manage 位。 +# open_platform: +# enabled: false + +# ── 开放 API 调用链参数(可选,不配则用下面的默认值)─────────────────── # open_api: -# # Browser-facing address of this platform, baked into downloaded skill packs. -# # Set it when the backend sits behind the commercial gateway, a path prefix, -# # or any proxy that does not forward X-Forwarded-Proto / X-Forwarded-Host. +# service_account_idle_days: 90 # 多少天无调用后在列表里标记为"闲置" +# credential_cache_ttl_seconds: 3 # 凭据校验的 Redis 正缓存秒数;硬上限 5,超过按 5 处理 +# management_ui_enabled: true # 管理后台是否显示「服务账号」/「个人令牌」两个 tab;默认 true = 显示 +# # 只管界面可见性、接口不受影响;改 false 后界面上无法新建服务账号 / 签发 CLI 部署用的密钥 +# pat_enabled: true # 个人访问令牌:部署级开关,默认开;管理后台另有租户级开关(默认关),两层都开才生效 +# pat_admin_ttl_days: 7 # 管理员持有的个人访问令牌有效期上限(天),取租户默认有效期与本值的较小者 +# # 平台对浏览器的地址,会写进下载的技能包与安装提示;留空则按反代的 X-Forwarded-Proto / X-Forwarded-Host 推导。 +# # 后端在商业网关之后、带路径前缀、或反代不转发这两个头时需要显式配置。 # public_base_url: https://kb.example.com +# ── 工场运行时层(F054)───────────────────────────────────────────────── +# 这一层可以整层不装:enabled 保持 false 时,平台其余部分零变化, +# /apps/{slug} 会落到"本环境未启用应用工场"引导页,CLI 部署被 16207 明确拒绝。 +# compose 形态下这两个服务挂在 `app-runtime` profile 后面,不带 +# `--profile app-runtime` 启动就根本不创建(scripts/deploy.sh 已自动处理)。 +# app_runtime: + # 本机(本部署)是否安装了工场运行时层 +# enabled: false + + # ── 与 runtime-manager 的通道 ───────────────────────────────────────── + # systemd 形态:runtime-manager 是宿主机上的独立单元,用回环地址; + # compose 形态:改成 http://runtime-manager:8091(service 名)。 +# manager_base_url: "http://127.0.0.1:8091" + + # ── 三个共享密钥:**必须替换**,且三个各不相同 ───────────────────── + # 生成:openssl rand -hex 32 + # 空值 = fail-closed(拒绝一切调用),不是"免鉴权"。 + # 不要把明文写进本文件;用 !env 从环境变量注入(见下方注释形态)。 + # ⚠️ `!env ${VAR}` 在环境变量**不存在时会直接报错拒启**,所以要么整行注释掉、 + # 要么保证部署脚本一定注入了该变量。 + # + # manager_hmac_secret: !env ${BISHENG_RTM_HMAC_SECRET} # backend → runtime-manager 验签;须与 RTM_HMAC_SECRET 一致 + # proxy_hmac_secret: !env ${BISHENG_APP_PROXY_HMAC_SECRET} # app-proxy → backend 内部授权端点验签;须与 APP_PROXY_BACKEND_SECRET 一致 + # obo_secret: !env ${BISHENG_APP_OBO_SECRET} # 注入应用的临时身份令牌签名密钥,**必须不同于 jwt_secret** +# manager_hmac_secret: "" +# proxy_hmac_secret: "" +# obo_secret: "" + + # 注入应用的临时身份令牌有效期(秒),最小 60 +# obo_ttl_seconds: 900 + + # ── 入口地址 ────────────────────────────────────────────────────────── + # 用户实际访问平台的对外基地址(含协议与端口,不带结尾斜杠), + # 例如 https://bisheng.example.com 或 http://192.168.1.10:3001。 + # 应用地址 = {entry_base_url}/apps/{slug}。留空时后端只能给出相对地址 + # /apps/{slug}——浏览器里能用,但二维码、CLI 回显、外发链接都会是残缺的, + # 所以**强烈建议配置**。 +# entry_base_url: "" + + # 单条被反代的 WebSocket 连接的授权寿命上限(秒),最小 60(能力后置,先占位) +# ws_max_lifetime_seconds: 28800 + + # 托管应用访问记录的合并窗口(秒):同一用户在窗口内反复进入同一应用只记一条 + # (Redis 键 app_access:{app_id}:{user_id} 的 TTL)。默认 30 分钟,对齐会话粒度。 +# access_log_merge_window_seconds: 1800 + + # ── 存储、容量准入与构建源:不在这里配 ────────────────────────────────── + # 这几项只有 runtime-manager 读得到,它一律走自己的环境变量,不看本文件: + # data_root → RTM_DATA_ROOT 每应用数据目录所在的宿主机目录 + # reserve_mb → RTM_RESERVE_MB 闸①:先从 MemAvailable 扣掉的余量 + # overcommit_ratio → RTM_OVERCOMMIT_RATIO 闸②:承诺规格之和的天花板比例 + # build_reserve_mb → RTM_BUILD_RESERVE_MB 构建同样过闸,按此预留 + # build_index_url → RTM_BUILD_INDEX_URL 构建期注入容器的包索引 PIP_INDEX_URL(python3.11 模板) + # RTM_BUILD_NPM_REGISTRY 同上,node20 模板的 npm 源(static 模板不拉包) + # compose 形态在 docker-compose.yml 的 runtime-manager 服务上设;systemd 形态写进 + # 它的 EnvironmentFile。写在本文件里不会报错,但也**不会生效**(后端启动时会就此告警)。 + + # ── 应用包上限(同时是 CLI 上传前自检的取值来源,GET /api/v2/apps/deploy-limits)── +# max_package_mb: 50 # 上传包体积上限(MB) +# max_unpacked_mb: 200 # 解包后总体积上限(MB),防解压炸弹 +# max_package_entries: 20000 # 包内条目数上限,防海量小文件 + + # ── 出厂资源档位(可选)─────────────────────────────────────────────── + # 不配 = 用内置三档:轻量 0.5C/1G · 标准 2C/4G · 性能 4C/8G。 + # ⚠️ 档位只在**首次启动播种**时写库,之后改本项无效(改档位请走超管界面)。 + # 小内存机器必须在第一次启动前就配好。配置会**整体替换**内置三档,不是合并。 + # default_tiers: + # light: + # name: "轻量" + # cpu_millicores: 500 # 注意单位是毫核:500 = 0.5 核 + # memory_mb: 512 + # description: "内部工具、表单、看板类应用" + # sort_order: 0 + # standard: + # name: "标准" + # cpu_millicores: 1000 + # memory_mb: 1024 + # sort_order: 1 + + # 审核人临时预览实例的存活天数,最小 1(能力后置,先占位) +# preview_ttl_days: 7 + +# Isolation-environment (sandbox) access. Keep this whole block commented in the +# shipped file: older images refuse unknown top-level keys at boot. +# Override any field with BS_SANDBOX_CONF__, e.g. BS_SANDBOX_CONF__TOKEN. +# endpoints is the only replica list. There is no hostname scan. +# sandbox_conf: +# token: "..." +# endpoints: ["http://code-runner-1:8080", "http://code-runner-2:8080"] + # Linsight (task mode) runtime knobs. Commented out on purpose: an older image # rejects unknown top-level keys, so enable per deployment or via 系统配置. # linsight: # citation_handles_enabled: true # F069 short citation handles [Sn]; false = F047 verbatim-id contract + +# MCP transport security. DNS rebinding protection is disabled by default so +# multi-hop proxies that rewrite Host work without per-environment configuration. +# Enable it only together with exact transport Host/Origin allowlists. +# open_mcp: +# enable_dns_rebinding_protection: false +# transport_allowed_hosts: +# - mcp.example.com +# transport_allowed_origins: +# - https://mcp.example.com diff --git a/docker/bisheng/entrypoint.sh b/docker/bisheng/entrypoint.sh index 6b33b70019..fc2b7214e9 100644 --- a/docker/bisheng/entrypoint.sh +++ b/docker/bisheng/entrypoint.sh @@ -54,6 +54,9 @@ if [ "$start_mode" = "api" ]; then fi echo "Starting API server..." uvicorn bisheng.main:app --host 0.0.0.0 --port 7860 --no-access-log --workers 2 +elif [ "$start_mode" = "upgrade-work-knowledge" ]; then + shift + exec python scripts/register_work_knowledge_tool.py "$@" elif [ "$start_mode" = "knowledge" ]; then echo "Starting Knowledge Celery worker..." start_knowledge @@ -88,6 +91,6 @@ elif [ "$start_mode" = "worker" ]; then echo "All workers started successfully." else - echo "Invalid start mode. Use api、worker、knowledge、knowledge_ocr、workflow、beat、default、linsight." + echo "Invalid start mode. Use api、worker、knowledge、knowledge_ocr、workflow、beat、default、linsight、upgrade-work-knowledge." exit 1 fi diff --git a/docker/deploy.sh b/docker/deploy.sh deleted file mode 100644 index 5a29fcbda4..0000000000 --- a/docker/deploy.sh +++ /dev/null @@ -1,232 +0,0 @@ -#!/usr/bin/env bash -# ============================================================= -# BiSheng 运维管理脚本 -# 用法: ./bisheng.sh <命令> [参数] -# ============================================================= - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -COMPOSE_FILE="${SCRIPT_DIR}/docker-compose.yml" -COMPOSE_CMD="docker compose -f ${COMPOSE_FILE}" - -# 容器名常量(与 docker-compose.yml 对应) -BACKEND_CONTAINER="bisheng-backend" -WORKER_CONTAINER="bisheng-backend-worker" - -# 所有可管理的 compose service 名称 -ALL_SERVICES=(backend backend_worker frontend mysql redis elasticsearch minio milvus etcd) - -# ─── 颜色输出 ──────────────────────────────────────────────── -RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' -CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m' - -info() { echo -e "${GREEN}[INFO]${RESET} $*"; } -warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; } -error() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } -header() { echo -e "${CYAN}${BOLD}$*${RESET}"; } - -# ─── 帮助 ──────────────────────────────────────────────────── -usage() { - header "═══════════════════════════════════════════════" - header " BiSheng 运维管理脚本" - header "═══════════════════════════════════════════════" - echo "" - echo -e "${BOLD}查看日志:${RESET}" - echo " $0 logs backend 实时跟踪 backend 日志(默认最近 200 行)" - echo " $0 logs worker 实时跟踪 backend_worker 日志(默认最近 200 行)" - echo " $0 logs backend -n 0 实时跟踪 backend 所有历史日志" - echo " $0 logs worker -n 500 查看 worker 最近 500 行日志" - echo "" - echo -e "${BOLD}镜像版本管理:${RESET}" - echo " $0 version 查看当前配置的镜像版本" - echo " $0 version v3.0.0 修改 backend、worker、frontend 的版本为 v3.0.0" - echo "" - echo -e "${BOLD}进入容器 Shell:${RESET}" - echo " $0 exec backend 进入 backend 容器" - echo " $0 exec worker 进入 backend_worker 容器" - echo "" - echo -e "${BOLD}更新镜像并重启:${RESET}" - echo " $0 update 拉取最新镜像并重启 backend + worker" - echo " $0 update backend 只更新并重启 backend" - echo " $0 update worker 只更新并重启 worker" - echo "" - echo -e "${BOLD}重启容器:${RESET}" - echo " $0 restart 重启 backend + worker" - echo " $0 restart backend 重启 backend" - echo " $0 restart worker 重启 worker" - echo " $0 restart frontend 重启 frontend" - echo " $0 restart 重启任意多个 service" - echo "" - echo -e "${BOLD}可用 service 名称:${RESET}" - echo " ${ALL_SERVICES[*]}" - echo "" -} - -# ─── service 别名解析 ───────────────────────────────────────── -resolve_service() { - case "$1" in - backend) echo "backend" ;; - worker|backend_worker) echo "backend_worker" ;; - frontend) echo "frontend" ;; - mysql) echo "mysql" ;; - redis) echo "redis" ;; - es|elasticsearch) echo "elasticsearch" ;; - minio) echo "minio" ;; - milvus) echo "milvus" ;; - etcd) echo "etcd" ;; - *) echo "$1" ;; # 原样传入,让 docker compose 自行报错 - esac -} - -# ─── 查看日志 ───────────────────────────────────────────────── -cmd_logs() { - local target="${1:-}" - shift || true - - local lines=200 - - # 解析可选 -n <行数> - while [[ $# -gt 0 ]]; do - case "$1" in - -n) - lines="${2:-200}" - shift 2 - ;; - *) - shift - ;; - esac - done - - local service - case "$target" in - backend) service="backend" ;; - worker|backend_worker) service="backend_worker" ;; - *) - error "未知目标 '${target}',请使用 backend 或 worker" - exit 1 - ;; - esac - - if [ "$lines" -eq 0 ]; then - info "实时跟踪 ${service} 所有历史日志(Ctrl+C 退出)..." - ${COMPOSE_CMD} logs -f "${service}" - else - info "实时跟踪 ${service} 最近 ${lines} 行日志(Ctrl+C 退出)..." - ${COMPOSE_CMD} logs -f --tail="${lines}" "${service}" - fi -} - -# ─── 修改版本号 ─────────────────────────────────────────────── -cmd_version() { - local new_version="${1:-}" - - if [[ -z "$new_version" ]]; then - info "当前 docker-compose.yml 配置的版本:" - grep -E "image:.*dataelement/bisheng-(backend|frontend):" "$COMPOSE_FILE" | awk '{$1=$1};1' - return 0 - fi - - info "正在将 backend, backend_worker, frontend 版本修改为: ${new_version}" - - # 兼容 macOS 和 Linux 的 sed -i 用法 - if sed --version 2>/dev/null | grep -q GNU; then - sed -i -E "s|(image: dataelement/bisheng-backend):.*|\1:${new_version}|g" "$COMPOSE_FILE" - sed -i -E "s|(image: dataelement/bisheng-frontend):.*|\1:${new_version}|g" "$COMPOSE_FILE" - else - # macOS/BSD sed - sed -i '' -E "s|(image: dataelement/bisheng-backend):.*|\1:${new_version}|g" "$COMPOSE_FILE" - sed -i '' -E "s|(image: dataelement/bisheng-frontend):.*|\1:${new_version}|g" "$COMPOSE_FILE" - fi - - info "✅ 版本修改完成:" - grep -E "image:.*dataelement/bisheng-(backend|frontend):" "$COMPOSE_FILE" | awk '{$1=$1};1' - warn "注意:只是修改了配置文件,若要生效请执行 '$0 update'" -} - -# ─── 进入容器 ───────────────────────────────────────────────── -cmd_exec() { - local target="${1:-}" - local container - - case "$target" in - backend) container="${BACKEND_CONTAINER}" ;; - worker|backend_worker) container="${WORKER_CONTAINER}" ;; - *) - error "未知目标 '${target}',请使用 backend 或 worker" - exit 1 - ;; - esac - - info "进入容器 ${container} ..." - docker exec -it "${container}" /bin/bash 2>/dev/null \ - || docker exec -it "${container}" /bin/sh -} - -# ─── 更新镜像并重启 ─────────────────────────────────────────── -cmd_update() { - local targets=() - - if [[ $# -eq 0 ]]; then - targets=("backend" "backend_worker" "frontend") - else - for t in "$@"; do - targets+=("$(resolve_service "$t")") - done - fi - - info "拉取最新镜像:${targets[*]}" - ${COMPOSE_CMD} pull "${targets[@]}" - - info "重启服务(不重建依赖):${targets[*]}" - ${COMPOSE_CMD} up -d --no-deps "${targets[@]}" - - info "✅ 更新完成" - ${COMPOSE_CMD} ps "${targets[@]}" -} - -# ─── 重启容器 ───────────────────────────────────────────────── -cmd_restart() { - local targets=() - - if [[ $# -eq 0 ]]; then - targets=("backend" "backend_worker" "frontend") - else - for t in "$@"; do - targets+=("$(resolve_service "$t")") - done - fi - - info "重启服务:${targets[*]}" - ${COMPOSE_CMD} restart "${targets[@]}" - - info "✅ 重启完成" - ${COMPOSE_CMD} ps "${targets[@]}" -} - -# ─── 入口 ──────────────────────────────────────────────────── -main() { - if [[ $# -eq 0 ]]; then - usage - exit 0 - fi - - local cmd="$1"; shift - - case "$cmd" in - logs) cmd_logs "$@" ;; - version) cmd_version "$@" ;; - exec) cmd_exec "$@" ;; - update) cmd_update "$@" ;; - restart) cmd_restart "$@" ;; - help|-h|--help) usage ;; - *) - error "未知命令: ${cmd}" - usage - exit 1 - ;; - esac -} - -main "$@" diff --git a/docker/docker-compose-ft.yml b/docker/docker-compose-ft.yml deleted file mode 100644 index 4fa2af5f51..0000000000 --- a/docker/docker-compose-ft.yml +++ /dev/null @@ -1,30 +0,0 @@ -services: - ft_server: - container_name: bisheng-ft-server - image: dataelement/bisheng-ft:v0.5.0 - shm_size: "4g" - ports: - - "8000:8000" - environment: - TZ: Asia/Shanghai - volumes: - - ${DOCKER_VOLUME_DIRECTORY:-.}/bisheng-ft/config.yaml:/opt/bisheng-ft/sft_server/config.yaml # 服务启动所需的配置文件地址,默认不用改 - - ${DOCKER_VOLUME_DIRECTORY:-.}/data/llm:/opt/bisheng-ft/models/model_repository # 这个是存放基座模型的目录,挂载到本机目录 - - ${DOCKER_VOLUME_DIRECTORY:-.}/data/finetune_output:/opt/bisheng-ft/finetune_output # 这个是存放微调过程的中间日志和微调训练后模型的目录,挂载到本机目录,不能与存放基座模型的目录相同 - security_opt: - - seccomp:unconfined - command: bash start-sft-server.sh # 启动服务 - restart: on-failure - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8000/health"] - start_period: 30s - interval: 90s - timeout: 30s - retries: 3 - deploy: - resources: - reservations: - devices: - - driver: nvidia - count: all - capabilities: [gpu] diff --git a/docker/docker-compose-office.yml b/docker/docker-compose-office.yml index dac1e80e01..8c86dd12f6 100644 --- a/docker/docker-compose-office.yml +++ b/docker/docker-compose-office.yml @@ -1,7 +1,7 @@ services: office: container_name: bisheng-office - image: onlyoffice/documentserver:7.1.1 + image: ${ONLYOFFICE_IMAGE:?set ONLYOFFICE_IMAGE in docker/.env (see .env.example)} ports: - "8701:80" environment: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 4e1c8382bb..739ef2adc6 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,7 +1,69 @@ +x-runtime-platform-env: &runtime-platform-env + BISHENG_RUNTIME_PROTOCOL_VERSION: ${BISHENG_RUNTIME_PROTOCOL_VERSION:-2} + BISHENG_RUNTIME_NODE_ID: ${BISHENG_RUNTIME_NODE_ID:-docker-node-1} + BISHENG_RUNTIME_PLATFORM_BASE_URL: 'http://backend:7860' + BISHENG_RUNTIME_AUDIT_SOURCE_TIMEZONE: '${BISHENG_RUNTIME_AUDIT_SOURCE_TIMEZONE:-}' + BISHENG_RUNTIME_OFFLINE: ${BISHENG_RUNTIME_OFFLINE:-true} + BISHENG_RUNTIME_TASK_TIMEOUT_SECONDS: ${BISHENG_RUNTIME_TASK_TIMEOUT_SECONDS:-1800} + BISHENG_RUNTIME_PAYLOAD_KEY: ${BISHENG_RUNTIME_PAYLOAD_KEY:-} + BISHENG_APP_PROXY_HMAC_SECRET: ${BISHENG_APP_PROXY_HMAC_SECRET:-} + BISHENG_APP_OBO_SECRET: ${BISHENG_APP_OBO_SECRET:-} + BISHENG_RUNTIME_CONTROL_KEY_ID: platform-control + BISHENG_RUNTIME_CONTROL_SECRET: ${BISHENG_RUNTIME_CONTROL_SECRET:-} + # Generate this node descriptor on the Docker host; see deploy/runtime-manager/README.md. + BISHENG_RUNTIME_HOST_ISOLATION_NODES: '${BISHENG_RUNTIME_HOST_ISOLATION_NODES:-}' + BISHENG_RUNTIME_SERVICE_KEYS: '{"manager-node-1":{"secret":"${BISHENG_RUNTIME_MANAGER_SECRET:-}","node_id":"${BISHENG_RUNTIME_NODE_ID:-docker-node-1}","roles":["manager"]},"egress-node-1":{"secret":"${BISHENG_RUNTIME_EGRESS_SECRET:-}","node_id":"${BISHENG_RUNTIME_NODE_ID:-docker-node-1}","roles":["egress"]},"host-isolation-node-1":{"secret":"${BISHENG_RUNTIME_HOST_ISOLATION_SECRET:-}","node_id":"${BISHENG_RUNTIME_NODE_ID:-docker-node-1}","roles":["host-isolation"]},"entry-reader":{"secret":"${BISHENG_RUNTIME_ENTRY_SECRET:-}","roles":["entry"]}}' + services: + gateway-db-init: + profiles: [gateway] + image: ${MYSQL_IMAGE:?set MYSQL_IMAGE in docker/.env (see .env.example)} + environment: + MYSQL_PWD: "1234" + GATEWAY_DATABASE_PASSWORD: ${BISHENG_GATEWAY_DB_PASSWORD:-} + volumes: + - ./gateway/init.sql:/schema.sql:ro + - ./gateway/init.sh:/init.sh:ro + entrypoint: ["bash", "/init.sh"] + depends_on: + mysql: + condition: service_healthy + + gateway: + profiles: [gateway] + image: ${BISHENG_GATEWAY_IMAGE:?run bash docker/install.sh to create docker/.env} + container_name: bisheng-gateway + environment: + TZ: Asia/Shanghai + LANG: C.UTF-8 + LC_ALL: C.UTF-8 + # install.sh validates these values only when the gateway profile is enabled. + BISHENG_LICENSE: ${BISHENG_GATEWAY_LICENSE:-} + BISHENG_GATEWAY_HMAC_SECRET: ${BISHENG_GATEWAY_HMAC_SECRET:-} + BISHENG_PUBLIC_URL: ${BISHENG_PUBLIC_URL:-} + GATEWAY_DATABASE_PASSWORD: ${BISHENG_GATEWAY_DB_PASSWORD:-} + SPRING_CONFIG_LOCATION: file:/application.yml + volumes: + - ${DOCKER_VOLUME_DIRECTORY:-.}/gateway/config/application.yml:/application.yml:ro + # A real user-provided file is required even with direct Compose commands. + - type: bind + source: ${BISHENG_GATEWAY_LICENSE_FILE:-./gateway/license.required} + target: /run/secrets/bisheng-license + read_only: true + bind: + create_host_path: false + depends_on: + gateway-db-init: + condition: service_completed_successfully + redis: + condition: service_healthy + backend: + condition: service_healthy + restart: on-failure + mysql: container_name: bisheng-mysql - image: mysql:8.0 + image: ${MYSQL_IMAGE:?set MYSQL_IMAGE in docker/.env (see .env.example)} ports: - "3306:3306" @@ -22,7 +84,7 @@ services: openfga-migrate: container_name: bisheng-openfga-migrate - image: cr.dataelem.com/dataelement/openfga:v1.15.2-dm + image: ${OPENFGA_IMAGE:?set OPENFGA_IMAGE in docker/.env (see .env.example)} command: migrate environment: OPENFGA_DATASTORE_ENGINE: mysql @@ -33,7 +95,7 @@ services: openfga: container_name: bisheng-openfga - image: cr.dataelem.com/dataelement/openfga:v1.15.2-dm + image: ${OPENFGA_IMAGE:?set OPENFGA_IMAGE in docker/.env (see .env.example)} command: run environment: OPENFGA_DATASTORE_METRICS_ENABLED: "true" @@ -66,7 +128,7 @@ services: redis: container_name: bisheng-redis - image: redis:7.0.4 + image: ${REDIS_IMAGE:?set REDIS_IMAGE in docker/.env (see .env.example)} ports: - "6379:6379" environment: @@ -84,12 +146,14 @@ services: backend: container_name: bisheng-backend - image: dataelement/bisheng-backend:v3.0.0-beta6 + image: ${BISHENG_BACKEND_IMAGE:?set BISHENG_BACKEND_IMAGE in docker/.env (see .env.example)} ports: - "7860:7860" environment: + <<: *runtime-platform-env TZ: Asia/Shanghai - BS_SSO_SYNC__GATEWAY_HMAC_SECRET: "bisheng-local-hmac-20260422" + BISHENG_PRO: ${BISHENG_PRO:-false} + BISHENG_GATEWAY_HMAC_SECRET: ${BISHENG_GATEWAY_HMAC_SECRET:-} BS_MILVUS_CONNECTION_ARGS: '{"host":"milvus","port":"19530","user":"","password":"","secure":false}' BS_MILVUS_IS_PARTITION: 'true' BS_MILVUS_PARTITION_SUFFIX: '1' @@ -99,15 +163,24 @@ services: BS_MINIO_CERT_CHECK: 'false' BS_MINIO_ENDPOINT: 'minio:9000' BS_MINIO_SHAREPOINT: 'minio:9000' - BS_MINIO_ACCESS_KEY: 'minioadmin' - BS_MINIO_SECRET_KEY: 'minioadmin' + BS_MINIO_ACCESS_KEY: '${MINIO_ROOT_USER:?set MINIO_ROOT_USER and MINIO_ROOT_PASSWORD in docker/.env (see .env.example)}' + BS_MINIO_SECRET_KEY: '${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_USER and MINIO_ROOT_PASSWORD in docker/.env (see .env.example)}' + # Shared with code-runner-*; editor "run once" executes in this process. + BS_SANDBOX_CONF__TOKEN: ${SANDBOX_TOKEN:-bisheng-sandbox-local} + # One URL per replica. Adding code-runner-3 means appending its URL here. + BS_SANDBOX_CONF__ENDPOINTS: '["http://code-runner-1:8080","http://code-runner-2:8080"]' + # Run local code execution under a throwaway uid; set LOCAL_ISOLATION=off in .env to disable. + BS_SANDBOX_CONF__LOCAL_ISOLATION: ${LOCAL_ISOLATION:-uid} volumes: - - ${DOCKER_VOLUME_DIRECTORY:-.}/bisheng/config/config.yaml:/app/bisheng/config.yaml - - ${DOCKER_VOLUME_DIRECTORY:-.}/bisheng/entrypoint.sh:/app/entrypoint.sh + - ${DOCKER_VOLUME_DIRECTORY:-.}/bisheng/config/config.yaml:/app/bisheng/config.yaml:ro + - ${DOCKER_VOLUME_DIRECTORY:-.}/bisheng/entrypoint.sh:/app/entrypoint.sh:ro - ${DOCKER_VOLUME_DIRECTORY:-.}/data/bisheng:/app/data security_opt: - seccomp:unconfined command: sh entrypoint.sh api # 启动api服务 + networks: + - default + - sandbox_net restart: on-failure healthcheck: test: ["CMD", "curl", "-f", "http://localhost:7860/health"] @@ -116,6 +189,9 @@ services: timeout: 30s retries: 3 depends_on: + # Application context initialization connects to object storage. + minio: + condition: service_healthy mysql: condition: service_healthy redis: @@ -126,10 +202,12 @@ services: backend_worker: container_name: bisheng-backend-worker - image: dataelement/bisheng-backend:v3.0.0-beta6 + image: ${BISHENG_BACKEND_IMAGE:?set BISHENG_BACKEND_IMAGE in docker/.env (see .env.example)} environment: + <<: *runtime-platform-env TZ: Asia/Shanghai - BS_SSO_SYNC__GATEWAY_HMAC_SECRET: "bisheng-local-hmac-20260422" + BISHENG_PRO: ${BISHENG_PRO:-false} + BISHENG_GATEWAY_HMAC_SECRET: ${BISHENG_GATEWAY_HMAC_SECRET:-} BS_MILVUS_CONNECTION_ARGS: '{"host":"milvus","port":"19530","user":"","password":"","secure":false}' BS_MILVUS_IS_PARTITION: 'true' BS_MILVUS_PARTITION_SUFFIX: '1' @@ -139,41 +217,55 @@ services: BS_MINIO_CERT_CHECK: 'false' BS_MINIO_ENDPOINT: 'minio:9000' BS_MINIO_SHAREPOINT: 'minio:9000' - BS_MINIO_ACCESS_KEY: 'minioadmin' - BS_MINIO_SECRET_KEY: 'minioadmin' + BS_MINIO_ACCESS_KEY: '${MINIO_ROOT_USER:?set MINIO_ROOT_USER and MINIO_ROOT_PASSWORD in docker/.env (see .env.example)}' + BS_MINIO_SECRET_KEY: '${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_USER and MINIO_ROOT_PASSWORD in docker/.env (see .env.example)}' + # Shared with code-runner-*; override SANDBOX_TOKEN in .env for non-dev. + BS_SANDBOX_CONF__TOKEN: ${SANDBOX_TOKEN:-bisheng-sandbox-local} + # One URL per replica. Adding code-runner-3 means appending its URL here. + BS_SANDBOX_CONF__ENDPOINTS: '["http://code-runner-1:8080","http://code-runner-2:8080"]' + # Run local code execution under a throwaway uid; set LOCAL_ISOLATION=off in .env to disable. + BS_SANDBOX_CONF__LOCAL_ISOLATION: ${LOCAL_ISOLATION:-uid} volumes: - - ${DOCKER_VOLUME_DIRECTORY:-.}/bisheng/config/config.yaml:/app/bisheng/config.yaml - - ${DOCKER_VOLUME_DIRECTORY:-.}/bisheng/entrypoint.sh:/app/entrypoint.sh + - ${DOCKER_VOLUME_DIRECTORY:-.}/bisheng/config/config.yaml:/app/bisheng/config.yaml:ro + - ${DOCKER_VOLUME_DIRECTORY:-.}/bisheng/entrypoint.sh:/app/entrypoint.sh:ro - ${DOCKER_VOLUME_DIRECTORY:-.}/data/bisheng:/app/data security_opt: - seccomp:unconfined command: sh entrypoint.sh worker # 启动celery的异步worker服务,用来处理一些耗时的任务 restart: on-failure + networks: + - default + - sandbox_net depends_on: + # Application context initialization connects to object storage. + minio: + condition: service_healthy mysql: condition: service_healthy redis: condition: service_healthy openfga: condition: service_started + # Runners are not on this chain: a missing replica is 28001 at lease time, + # not a blocked mysql/redis healthcheck. frontend: container_name: bisheng-frontend - image: dataelement/bisheng-frontend:v3.0.0-beta6 + image: ${BISHENG_FRONTEND_IMAGE:?set BISHENG_FRONTEND_IMAGE in docker/.env (see .env.example)} ports: - "3001:3001" environment: TZ: Asia/Shanghai volumes: - ${DOCKER_VOLUME_DIRECTORY:-.}/nginx/nginx.conf:/etc/nginx/nginx.conf - - ${DOCKER_VOLUME_DIRECTORY:-.}/nginx/conf.d:/etc/nginx/conf.d + - ${BISHENG_NGINX_CONF_DIR:-./nginx/conf.d}:/etc/nginx/conf.d restart: on-failure depends_on: - backend elasticsearch: container_name: bisheng-es - image: docker.io/bitnamilegacy/elasticsearch:8.12.0 + image: ${ELASTICSEARCH_IMAGE:?set ELASTICSEARCH_IMAGE in docker/.env (see .env.example)} user: root ports: - "9200:9200" @@ -186,7 +278,7 @@ services: etcd: container_name: bisheng-milvus-etcd - image: quay.io/coreos/etcd:v3.5.5 + image: ${ETCD_IMAGE:?set ETCD_IMAGE in docker/.env (see .env.example)} environment: ETCD_AUTO_COMPACTION_MODE: revision ETCD_AUTO_COMPACTION_RETENTION: "1000" @@ -205,13 +297,21 @@ services: minio: container_name: bisheng-milvus-minio - image: cr.dataelem.com/minio/minio:RELEASE.2023-03-20T20-16-18Z + image: ${MINIO_IMAGE:?set MINIO_IMAGE in docker/.env (see .env.example)} environment: - MINIO_ACCESS_KEY: minioadmin - MINIO_SECRET_KEY: minioadmin + MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:?set MINIO_ROOT_USER and MINIO_ROOT_PASSWORD in docker/.env (see .env.example)} + MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_USER and MINIO_ROOT_PASSWORD in docker/.env (see .env.example)} + # Object storage holds every uploaded document, knowledge-base file and + # report template. + # 9000 - data API. Published because the document server runs as its own + # compose project and cannot resolve `minio`, so BS_MINIO_SHAREPOINT + # has to name the host for report templates to load. Restrict it to + # the networks that need it and put it behind TLS. + # 9001 - admin console, deliberately NOT published: it is a full web UI + # over every stored file and nothing in the stack needs it. Reach + # it with `ssh -L 9001:localhost:9001 ` when required. ports: - "9100:9000" - - "9101:9001" volumes: - /etc/localtime:/etc/localtime:ro - ${DOCKER_VOLUME_DIRECTORY:-.}/data/milvus-minio:/minio_data @@ -225,13 +325,17 @@ services: milvus: container_name: bisheng-milvus-standalone - image: milvusdb/milvus:v2.5.10 + image: ${MILVUS_IMAGE:?set MILVUS_IMAGE in docker/.env (see .env.example)} command: ["milvus", "run", "standalone"] security_opt: - seccomp:unconfined environment: ETCD_ENDPOINTS: etcd:2379 MINIO_ADDRESS: minio:9000 + # Without these Milvus falls back to its built-in minioadmin default and + # cannot open its own segments. + MINIO_ACCESS_KEY_ID: ${MINIO_ROOT_USER:?set MINIO_ROOT_USER and MINIO_ROOT_PASSWORD in docker/.env (see .env.example)} + MINIO_SECRET_ACCESS_KEY: ${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_USER and MINIO_ROOT_PASSWORD in docker/.env (see .env.example)} volumes: - /etc/localtime:/etc/localtime:ro - ${DOCKER_VOLUME_DIRECTORY:-.}/data/milvus:/var/lib/milvus @@ -248,3 +352,266 @@ services: depends_on: - etcd - minio + + # ── 应用工场运行时层(F054 T074 / T077 / T078)────────────────────── + # 四个 service 都挂在 `app-runtime` profile 后面:**「整层不装」是产品明确 + # 支持的形态**(GOV-10 / AC-59)。不带 --profile app-runtime 启动时它们根本 + # 不创建,平台其余部分零变化——这正是 nginx 的 `location /apps/` 必须用 + # 变量式 upstream 的原因(静态主机名解析不了会让 nginx 整个起不来)。 + # + # 四者都**不 publish 端口**:外部流量一律经 frontend 的 nginx 进来; + # docker-socket-proxy 与 egress-proxy 更是连内网都只对本层可见。 + runtime-manager: + container_name: bisheng-runtime-manager + profiles: [app-runtime] + image: ${BISHENG_RUNTIME_MANAGER_IMAGE:?set BISHENG_RUNTIME_MANAGER_IMAGE in docker/.env (see .env.example)} + environment: + TZ: Asia/Shanghai + RTM_HOST: '0.0.0.0' + RTM_PORT: '8091' + RTM_PROTOCOL_VERSION: '2' + RTM_RECONCILE_INTERVAL_SECONDS: '5' + RTM_NODE_ID: ${BISHENG_RUNTIME_NODE_ID:-docker-node-1} + RTM_PLATFORM_KEY_ID: manager-node-1 + RTM_PLATFORM_SECRET: ${BISHENG_RUNTIME_MANAGER_SECRET:-} + RTM_INBOUND_KEY_ID: platform-control + RTM_INBOUND_SECRET: ${BISHENG_RUNTIME_CONTROL_SECRET:-} + RTM_PLATFORM_BASE_URL: 'http://backend:7860' + RTM_DATA_ROOT: /app-data + RTM_HOST_DATA_ROOT: ${BISHENG_APP_DATA_ROOT:-/opt/bisheng/app-data} + RTM_DOCKER_HOST: 'tcp://docker-socket-proxy:2375' + RTM_NETWORK: bisheng-apps + RTM_DATA_HELPER_IMAGE: ${BISHENG_RUNTIME_MANAGER_IMAGE:?set BISHENG_RUNTIME_MANAGER_IMAGE in docker/.env (see .env.example)} + RTM_EGRESS_PROXY: 'egress-proxy:3128' + RTM_BUILD_NETWORK: bisheng-build + RTM_RESERVE_MB: ${BISHENG_RTM_RESERVE_MB:-2048} + RTM_OVERCOMMIT_RATIO: ${BISHENG_RTM_OVERCOMMIT_RATIO:-0.8} + RTM_BUILD_RESERVE_MB: ${BISHENG_RTM_BUILD_RESERVE_MB:-2048} + RTM_BUILD_INDEX_URL: ${BISHENG_RTM_BUILD_INDEX_URL:-} + RTM_BUILD_NPM_REGISTRY: ${BISHENG_RTM_BUILD_NPM_REGISTRY:-} + RTM_BUILD_EXTRA_INDEX_URL: 'http://backend:7860/api/v1/dev-toolkit/simple/' + RTM_BUILD_EXTRA_TRUSTED_HOST: backend + RTM_IMAGE_PULL_POLICY: ${BISHENG_RUNTIME_IMAGE_PULL_POLICY:-never} + RTM_TASK_TIMEOUT_SECONDS: ${BISHENG_RUNTIME_TASK_TIMEOUT_SECONDS:-1800} + RTM_CLEANUP_SECONDS: '900' + volumes: + - /etc/localtime:/etc/localtime:ro + # ⚠️ docker socket **不再直挂**(D2-B,T078):编排走 + # docker-socket-proxy 的端点白名单。全平台仍然只有那一个容器持有 socket, + # 而它是一个没有业务代码、只做 HTTP 转发的镜像。 + # 源路径写成与 RTM_HOST_DATA_ROOT **逐字相同的表达式**,这样两者不可能漂移。 + # 不能沿用 ${DOCKER_VOLUME_DIRECTORY:-.}:它默认是相对路径 `.`,compose 自己 + # 会相对本文件解析,但作为环境变量原样传进去后 dockerd 只接受绝对路径。 + - ${BISHENG_APP_DATA_ROOT:-/opt/bisheng/app-data}:/app-data + networks: + # default:backend reaches the authenticated manager API. + # bisheng-apps:启动探活是 manager 进程**直接 HTTP 访问应用容器在该网络上的 + # bridge IP**(probe.py:95),不在这张网上就永远探不通。 + - default + - runtime-control + - bisheng-apps + depends_on: + - docker-socket-proxy + restart: on-failure + + # D2-B:把「全有全无的 root 等价权限」收成端点级。放行的端点由 + # runtime_manager.docker_backend.SOCKET_PROXY_PERMISSIONS 唯一定义, + # tests/test_socket_proxy.py 逐字比对本段——改一边不改另一边测试就红。 + # **绝不 publish 端口**:谁够得到 2375 谁就是这台机器的 root。 + docker-socket-proxy: + container_name: bisheng-docker-socket-proxy + profiles: [app-runtime] + image: ${DOCKER_SOCKET_PROXY_IMAGE:?set DOCKER_SOCKET_PROXY_IMAGE in docker/.env (see .env.example)} + environment: + ALLOW_RESTARTS: "0" + ALLOW_START: "1" + ALLOW_STOP: "1" + AUTH: "0" + BUILD: "1" + COMMIT: "0" + CONFIGS: "0" + CONTAINERS: "1" + DISTRIBUTION: "0" + EVENTS: "0" + EXEC: "0" + GRPC: "0" + IMAGES: "1" + INFO: "1" + NETWORKS: "1" + NODES: "0" + PING: "1" + PLUGINS: "0" + POST: "1" + SECRETS: "0" + SERVICES: "0" + SESSION: "0" + SWARM: "0" + SYSTEM: "0" + TASKS: "0" + VERSION: "1" + VOLUMES: "0" + volumes: + # :ro 只挡文件系统写,不挡 API 写——真正的收窄是上面那张表。 + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - runtime-control + restart: on-failure + + # D12 L2:托管应用出站的唯一出口。它自己就是 runtime-manager 那个镜像,跑 + # separate entry point; v2 reads bounded policies from backend. + # 它**同时**接在 bisheng-apps(--internal,应用只够得到它)与 default + #(有默认路由,能真的出网)上——「内网 + 唯一出口」这句话的字面落点。 + egress-proxy: + container_name: bisheng-egress-proxy + profiles: [app-runtime] + image: ${BISHENG_RUNTIME_MANAGER_IMAGE:?set BISHENG_RUNTIME_MANAGER_IMAGE in docker/.env (see .env.example)} + command: ["python", "-m", "runtime_manager.egress_proxy"] + user: '10001:10001' + read_only: true + environment: + TZ: Asia/Shanghai + RTM_PROTOCOL_VERSION: '2' + RTM_NODE_ID: ${BISHENG_RUNTIME_NODE_ID:-docker-node-1} + RTM_PLATFORM_KEY_ID: egress-node-1 + RTM_PLATFORM_SECRET: ${BISHENG_RUNTIME_EGRESS_SECRET:-} + RTM_PLATFORM_BASE_URL: 'http://backend:7860' + RTM_EGRESS_PROXY: 'egress-proxy:3128' + RTM_EGRESS_LISTEN: '0.0.0.0:3128' + networks: [default, bisheng-apps, bisheng-build] + restart: on-failure + + app-proxy: + container_name: bisheng-app-proxy + profiles: [app-runtime] + image: ${BISHENG_APP_PROXY_IMAGE:?set BISHENG_APP_PROXY_IMAGE in docker/.env (see .env.example)} + environment: + TZ: Asia/Shanghai + APP_PROXY_HOST: '0.0.0.0' + APP_PROXY_PORT: '8090' + APP_PROXY_PROTOCOL_VERSION: '2' + APP_PROXY_WS_REAUTHORIZE_INTERVAL_SECONDS: '2' + APP_PROXY_BACKEND_BASE: 'http://backend:7860' + APP_PROXY_BACKEND_SECRET: ${BISHENG_APP_PROXY_HMAC_SECRET:-} + APP_PROXY_ROUTE_KEY_ID: entry-reader + APP_PROXY_ROUTE_SECRET: ${BISHENG_RUNTIME_ENTRY_SECRET:-} + networks: + # default:nginx 用 `app-proxy` 这个服务名做变量式 upstream 解析 + #(docker/nginx/conf.d/default.conf:65-67),backend 也在这张网上。 + # bisheng-apps:它拿到的 upstream 是应用容器的 bridge IP(design D5.1), + # 跨 bridge 网络会被 docker 的隔离规则丢包,必须同网。 + - default + - bisheng-apps + depends_on: [backend] + restart: on-failure + + # Named replicas (not deploy.replicas). Platform endpoints list each of these. + # No ports: — isolation is network unreachability, not hiding a host port. + code-runner-1: &code_runner + image: ${BISHENG_SANDBOX_IMAGE:?set BISHENG_SANDBOX_IMAGE in docker/.env (see .env.example)} + container_name: bisheng-code-runner-1 + # Isolation mode (sessions > 1): the runner is root so it can chown/setuid per session. + # User code still drops via SETUID. DAC_OVERRIDE is required while the runner + # chowns the session dir at lease create (copy-in into 0700/uid 10000). + # KILL lets the runner signal a session uid's process group on timeout / delete. + # PID 1 is tini from the image (reaps orphans); do not add init: true. + user: "0" + read_only: true + cap_drop: + - ALL + cap_add: + - SETUID + - SETGID + - CHOWN + - DAC_OVERRIDE + - KILL + security_opt: + - no-new-privileges:true + mem_limit: 2g + # Shared by every session on this runner; RLIMIT_CPU caps each process on top. + # Must not exceed the host CPU count: the daemon refuses to start the + # container otherwise (set SANDBOX_CPUS=1 on a 1-vCPU host). + cpus: ${SANDBOX_CPUS:-2} + pids_limit: 256 + tmpfs: + # 0755 root:root — session uids cannot plant files in /tmp (not Docker's 1777). + - /tmp:mode=755 + # Session work dirs and the copy-in spool live on disk, not on /tmp: tmpfs + # pages are charged to mem_limit and are not freed by killing processes, so + # two near-cap copy-ins (512 MiB spool + 512 MiB extracted each) would let + # the OOM killer take the runner and every session on it. + # Each replica needs its own directory (code-runner-2 overrides this). + volumes: + - ${DOCKER_VOLUME_DIRECTORY:-.}/data/sandbox/code-runner-1:/sessions + # Leases live in runner memory only, so dirs left by a previous run are + # orphans. Clear them before the runner starts (tini stays PID 1). + command: + - sh + - -c + - "find /sessions -mindepth 1 -delete || echo 'sandbox: stale session cleanup failed' >&2; exec python serve.py" + environment: + TZ: Asia/Shanghai + SANDBOX_TOKEN: ${SANDBOX_TOKEN:-bisheng-sandbox-local} + SANDBOX_SESSIONS_ROOT: /sessions + SANDBOX_MAX_SESSIONS: "50" + # Total bytes copied into one session (512 MiB); files past it are skipped and reported. + SANDBOX_MAX_COPY_IN_TOTAL_BYTES: "536870912" + SANDBOX_ENABLE_UID_ISOLATION: "true" + SANDBOX_LEASE_TTL_S: "900" + networks: + - sandbox_net + restart: on-failure + + code-runner-2: + <<: *code_runner + container_name: bisheng-code-runner-2 + volumes: + - ${DOCKER_VOLUME_DIRECTORY:-.}/data/sandbox/code-runner-2:/sessions + +# ── 托管应用专用网络 ──────────────────────────────────────────────────── +# runtime-manager 只**读**这张网(docker_backend.py:161-166「网络由部署方创建」), +# 它绝不自己建——一个会偷偷建网的编排器会把"新机器上第一次发布必挂"这件事藏起来。 +# 那么 compose 形态下的"部署方"就是这个文件。 +# +# `name:` 必须显式写死:compose 默认会加项目名前缀(docker_bisheng-apps),而 +# runtime-manager 是用裸 Docker API 建应用容器的,NetworkMode 取的是 +# RTM_NETWORK / DEFAULT_NETWORK = `bisheng-apps` 这个真名,对不上就 404 network +# not found,每次 deploy 都失败。 +# +# 两个 service 都显式列了 `networks:`,这是有意的:一旦给 service 写了 networks, +# 它就**不再自动接 default**,而 nginx→app-proxy、backend→runtime-manager 都靠 +# default 上的服务名解析,所以两张网都得列上。 +# +# `internal: true` = D12 的 L1(AC-16)。接在这张网上的容器**没有默认路由**, +# 所以托管应用连不上外网;唯一出口是同时接了 default 的 egress-proxy。 +# ⚠️ 这个属性只在建网时生效:升级到本版本的环境里 `bisheng-apps` 已经存在且 +# 不是 internal,compose 不会去改它,`runtime/status` 的 +# `application_network_internal` 会判红并给出重建命令。删网前要先停掉全部 +# 托管应用(网络在用时删不掉)。 +networks: + default: + name: ${BISHENG_NETWORK_NAME:-bisheng-network} + driver: bridge + runtime-control: + name: bisheng-runtime-control + driver: bridge + internal: true + bisheng-apps: + name: bisheng-apps + driver: bridge + internal: true + # 构建期专用(RTM_BUILD_NETWORK)。同样 --internal:`docker build` 拿到的 + # HTTP_PROXY build arg 因此不是「建议」——忽略它的 Dockerfile 根本没有路由。 + # 只有 egress-proxy 在这张网上,构建容器连 runtime-manager 都够不到。 + bisheng-build: + name: bisheng-build + driver: bridge + internal: true + # User code shares the runner network namespace. Not internal, so the + # sandbox can reach the public internet. Runners still do not join the + # middleware network and do not publish host ports. Docker applies + # `internal` only when the network is created; an existing internal + # sandbox_net stays closed until it is recreated. + sandbox_net: + name: bisheng-sandbox + driver: bridge + internal: false diff --git a/docker/gateway/config/application.yml b/docker/gateway/config/application.yml new file mode 100644 index 0000000000..ae56ad7834 --- /dev/null +++ b/docker/gateway/config/application.yml @@ -0,0 +1,70 @@ +# Contract: bisheng-gateway v1.8.0 (2995df6f); Docker-only addresses. +server: + port: 8080 +spring: + main: + allow-bean-definition-overriding: true + application: + name: gateway + codec: + max-in-memory-size: 200MB + cloud: + gateway: + httpclient: + connect-timeout: 30000 + response-timeout: 3600s + websocket: + max-frame-payload-length: 1048576 + routes: + - id: bisheng-http + uri: http://backend:7860 + filters: + - name: CacheRequestBody + args: + bodyClass: java.lang.String + predicates: + - Path=/api/v1/**,/api/v2/**,/api/v3/** + - id: bisheng-ws + uri: ws://backend:7860 + filters: + - name: CacheRequestBody + args: + bodyClass: java.lang.String + predicates: + - Path=/api/v1/chat/**,/api/v2/chat/**,/api/v1/workflow/chat/**,/api/v3/assistant/chat/**,/api/v3/workflow/chat/** + global-filter: + websocket-routing: + enabled: false + web: + resources: + add-mappings: false + data: + redis: + host: redis + port: 6379 + database: 3 + datasource: + username: bisheng_gateway + password: ${GATEWAY_DATABASE_PASSWORD} + url: jdbc:mysql://mysql:3306/bisheng_gateway?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8 +logging: + level: + root: info +bisheng: + license: ${BISHENG_LICENSE} + gateway-hmac-secret: ${BISHENG_GATEWAY_HMAC_SECRET} + signature-header: X-Signature + home-url: ${BISHENG_PUBLIC_URL} + web-home-url: ${BISHENG_PUBLIC_URL} + bisheng-api-url: http://backend:7860 + enable-sync: false + time: 1 + filter-url: + - api/v1/assistant/chat/ + - api/v1/process/ + - api/v2/assistant/chat + websocket: + anonymous-path-prefixes: + - /api/v2 + - /api/v3/assistant/chat + - /api/v3/workflow/chat diff --git a/docker/gateway/init.sh b/docker/gateway/init.sh new file mode 100644 index 0000000000..d82bd504e7 --- /dev/null +++ b/docker/gateway/init.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Runs inside the MySQL image; no host MySQL client is needed. +set -euo pipefail +: "${GATEWAY_DATABASE_PASSWORD:?Set BISHENG_GATEWAY_DB_PASSWORD before enabling Gateway}" +mysql -h mysql -u root < /schema.sql +password=${GATEWAY_DATABASE_PASSWORD//\\/\\\\} +password=${password//\'/\'\'} +{ + printf "CREATE USER IF NOT EXISTS 'bisheng_gateway'@'%%' IDENTIFIED BY '%s';\n" "$password" + printf "GRANT ALL PRIVILEGES ON bisheng_gateway.* TO 'bisheng_gateway'@'%%';\n" +} | mysql -h mysql -u root diff --git a/docker/gateway/init.sql b/docker/gateway/init.sql new file mode 100644 index 0000000000..5907efb3ae --- /dev/null +++ b/docker/gateway/init.sql @@ -0,0 +1,61 @@ +-- MySQL deployment schema from bisheng-gateway v1.8.0 (2995df6f), covering +-- init.sql, update_v_1.0.sql and update_v_2.5.sql. Existing tables are preserved. +CREATE DATABASE IF NOT EXISTS bisheng_gateway + CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; +USE bisheng_gateway; + +CREATE TABLE IF NOT EXISTS gt_group_resource ( + id INT AUTO_INCREMENT PRIMARY KEY, + group_id INT NOT NULL, + resource_id VARCHAR(256) NOT NULL, + resource_limit INT NOT NULL DEFAULT 0, + resource_type TINYINT NOT NULL +); +CREATE TABLE IF NOT EXISTS gt_sensitive_words ( + id INT AUTO_INCREMENT PRIMARY KEY, + resource_id VARCHAR(256) NOT NULL, + resource_type TINYINT, + auto_words TEXT, + words TEXT, + words_types VARCHAR(32), + is_check TINYINT NOT NULL DEFAULT 0, + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + logic_delete TINYINT NOT NULL DEFAULT 0, + auto_reply VARCHAR(128) +); +CREATE TABLE IF NOT EXISTS gt_user_group ( + id INT AUTO_INCREMENT PRIMARY KEY, + group_name VARCHAR(256) NOT NULL, + admin_user VARCHAR(512), + admin_user_id VARCHAR(512), + group_limit INT NOT NULL DEFAULT 0, + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + logic_delete TINYINT NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS gt_block_record ( + id INT AUTO_INCREMENT PRIMARY KEY, + chat_id VARCHAR(64), + user_input TEXT, + system_out TEXT, + block_words TEXT, + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + resource_id VARCHAR(64) +); +CREATE TABLE IF NOT EXISTS gt_department ( + id INT PRIMARY KEY, + dept_name VARCHAR(256) NOT NULL, + dept_limit INT NOT NULL DEFAULT 0, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS gt_department_resource ( + id INT AUTO_INCREMENT PRIMARY KEY, + department_id INT NOT NULL, + resource_id VARCHAR(256) NOT NULL, + resource_limit INT NOT NULL DEFAULT 0, + resource_type TINYINT NOT NULL, + INDEX idx_dept_resource (department_id, resource_id) +); diff --git a/docker/helm/README.md b/docker/helm/README.md new file mode 100644 index 0000000000..96cc19a839 --- /dev/null +++ b/docker/helm/README.md @@ -0,0 +1,64 @@ +# BiSheng Helm charts + +Two independent charts. The platform never talks to the Kubernetes API; the +worker calls the replica URLs in `sandbox.endpoints`. + +| Chart | What it installs | +|-------|------------------| +| `bisheng-sandbox` | StatefulSet + **headless** Service `code-runner` | +| `bisheng` | frontend, backend API, Celery worker | + +MySQL / Redis / MinIO / Elasticsearch / Milvus / OpenFGA are **not** in these +charts. Point `bisheng` values (and `files/config.yaml` hosts) at Services that +already exist. + +## Install + +```bash +# 1. Sandbox — keep the Service name code-runner (default fullnameOverride) +helm upgrade --install sandbox docker/helm/bisheng-sandbox \ + --namespace bisheng-sandbox --create-namespace \ + --set token.value="$SANDBOX_TOKEN" + +# 2. Platform +helm upgrade --install bisheng docker/helm/bisheng \ + --namespace bisheng --create-namespace \ + --set sandbox.token="$SANDBOX_TOKEN" \ + --set sandbox.endpoints='{http://code-runner-0.code-runner.bisheng-sandbox.svc.cluster.local:8080,http://code-runner-1.code-runner.bisheng-sandbox.svc.cluster.local:8080}' +``` + +`SANDBOX_TOKEN` must be the same on both releases. + +NetworkPolicy on the sandbox chart only allows TCP/8080 from pods in namespace +`bisheng` labeled `app.kubernetes.io/component=worker`, `backend` or `linsight` +(automatic `kubernetes.io/metadata.name` label). Change `networkPolicy.allowFrom` +if you use other names. Egress is allowed by default +(`networkPolicy.denyEgress: false`). Set it to `true` to block all outbound +traffic from the runner pods. + +## Why not one ClusterIP for runners + +`keep_session` sticks to a **hostname** (`http://code-runner-0.code-runner…`). +A ClusterIP / kube-proxy VIP would send the next `exec` to another Pod and +lose the working directory. + +## Scale runners + +```bash +helm upgrade sandbox docker/helm/bisheng-sandbox --reuse-values --set replicaCount=4 +``` + +Then add each new Pod URL to the platform chart `sandbox.endpoints` and upgrade that release. Nothing scans DNS for new ordinals. + +## Check + +```bash +kubectl -n bisheng-sandbox get sts,svc,po +kubectl -n bisheng exec deploy/bisheng-worker -- \ + getent hosts code-runner-0.code-runner.bisheng-sandbox.svc.cluster.local +``` + +配置项与排障见 +[沙箱部署说明](../../features/v3.0.0-beta1/068-code-execution-sandbox/沙箱部署说明.md), +页面用法见 +[沙箱使用说明](../../features/v3.0.0-beta1/068-code-execution-sandbox/沙箱使用说明.md)。 diff --git a/docker/helm/bisheng-sandbox/.helmignore b/docker/helm/bisheng-sandbox/.helmignore new file mode 100644 index 0000000000..478d0a4fb3 --- /dev/null +++ b/docker/helm/bisheng-sandbox/.helmignore @@ -0,0 +1,2 @@ +.DS_Store +*.tgz diff --git a/docker/helm/bisheng-sandbox/Chart.yaml b/docker/helm/bisheng-sandbox/Chart.yaml new file mode 100644 index 0000000000..ddc40c83b3 --- /dev/null +++ b/docker/helm/bisheng-sandbox/Chart.yaml @@ -0,0 +1,10 @@ +apiVersion: v2 +name: bisheng-sandbox +description: Isolation-environment runners for BiSheng (StatefulSet + headless Service). +type: application +version: 0.1.0 +appVersion: v3.0.0 +keywords: + - bisheng + - sandbox + - code-runner diff --git a/docker/helm/bisheng-sandbox/templates/NOTES.txt b/docker/helm/bisheng-sandbox/templates/NOTES.txt new file mode 100644 index 0000000000..8c100ed9c5 --- /dev/null +++ b/docker/helm/bisheng-sandbox/templates/NOTES.txt @@ -0,0 +1,10 @@ +bisheng-sandbox {{ .Chart.Version }} + +Headless Service: {{ include "bisheng-sandbox.fullname" . }} +Replicas: {{ .Values.replicaCount }} +List each Pod URL in the platform chart sandbox.endpoints (do not use the Service name as a VIP): + + BS_SANDBOX_CONF__TOKEN= + +Example Pod DNS: + {{ include "bisheng-sandbox.fullname" . }}-0.{{ include "bisheng-sandbox.fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local:{{ .Values.service.port }} diff --git a/docker/helm/bisheng-sandbox/templates/_helpers.tpl b/docker/helm/bisheng-sandbox/templates/_helpers.tpl new file mode 100644 index 0000000000..24b1550d34 --- /dev/null +++ b/docker/helm/bisheng-sandbox/templates/_helpers.tpl @@ -0,0 +1,38 @@ +{{- define "bisheng-sandbox.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "bisheng-sandbox.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{- define "bisheng-sandbox.labels" -}} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | quote }} +app.kubernetes.io/name: {{ include "bisheng-sandbox.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/component: code-runner +{{- end }} + +{{- define "bisheng-sandbox.selectorLabels" -}} +app.kubernetes.io/name: {{ include "bisheng-sandbox.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/component: code-runner +{{- end }} + +{{- define "bisheng-sandbox.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "bisheng-sandbox.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/docker/helm/bisheng-sandbox/templates/networkpolicy.yaml b/docker/helm/bisheng-sandbox/templates/networkpolicy.yaml new file mode 100644 index 0000000000..24be6f7767 --- /dev/null +++ b/docker/helm/bisheng-sandbox/templates/networkpolicy.yaml @@ -0,0 +1,26 @@ +{{- if .Values.networkPolicy.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "bisheng-sandbox.fullname" . }} + labels: + {{- include "bisheng-sandbox.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + {{- include "bisheng-sandbox.selectorLabels" . | nindent 6 }} + policyTypes: + - Ingress + {{- if .Values.networkPolicy.denyEgress }} + - Egress + {{- end }} + ingress: + - from: + {{- toYaml .Values.networkPolicy.allowFrom | nindent 8 }} + ports: + - protocol: TCP + port: {{ .Values.service.port }} + {{- if .Values.networkPolicy.denyEgress }} + egress: [] + {{- end }} +{{- end }} diff --git a/docker/helm/bisheng-sandbox/templates/secret.yaml b/docker/helm/bisheng-sandbox/templates/secret.yaml new file mode 100644 index 0000000000..2793feeea7 --- /dev/null +++ b/docker/helm/bisheng-sandbox/templates/secret.yaml @@ -0,0 +1,11 @@ +{{- if not .Values.token.existingSecret }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "bisheng-sandbox.fullname" . }} + labels: + {{- include "bisheng-sandbox.labels" . | nindent 4 }} +type: Opaque +stringData: + token: {{ .Values.token.value | quote }} +{{- end }} diff --git a/docker/helm/bisheng-sandbox/templates/service.yaml b/docker/helm/bisheng-sandbox/templates/service.yaml new file mode 100644 index 0000000000..d8436790b1 --- /dev/null +++ b/docker/helm/bisheng-sandbox/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "bisheng-sandbox.fullname" . }} + labels: + {{- include "bisheng-sandbox.labels" . | nindent 4 }} +spec: + clusterIP: None + publishNotReadyAddresses: true + selector: + {{- include "bisheng-sandbox.selectorLabels" . | nindent 4 }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http diff --git a/docker/helm/bisheng-sandbox/templates/statefulset.yaml b/docker/helm/bisheng-sandbox/templates/statefulset.yaml new file mode 100644 index 0000000000..05245ad247 --- /dev/null +++ b/docker/helm/bisheng-sandbox/templates/statefulset.yaml @@ -0,0 +1,70 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "bisheng-sandbox.fullname" . }} + labels: + {{- include "bisheng-sandbox.labels" . | nindent 4 }} +spec: + serviceName: {{ include "bisheng-sandbox.fullname" . }} + replicas: {{ .Values.replicaCount }} + podManagementPolicy: Parallel + selector: + matchLabels: + {{- include "bisheng-sandbox.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "bisheng-sandbox.labels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "bisheng-sandbox.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: code-runner + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + env: + - name: SANDBOX_PORT + value: {{ .Values.service.port | quote }} + - name: SANDBOX_TOKEN + valueFrom: + secretKeyRef: + name: {{ default (include "bisheng-sandbox.fullname" .) .Values.token.existingSecret }} + key: {{ .Values.token.existingSecretKey }} + {{- range $key, $value := .Values.env }} + - name: {{ $key }} + value: {{ $value | quote }} + {{- end }} + volumeMounts: + - name: tmp + mountPath: /tmp + livenessProbe: + {{- toYaml .Values.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.readinessProbe | nindent 12 }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumes: + - name: tmp + emptyDir: {} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/docker/helm/bisheng-sandbox/values.yaml b/docker/helm/bisheng-sandbox/values.yaml new file mode 100644 index 0000000000..708e9dbf7e --- /dev/null +++ b/docker/helm/bisheng-sandbox/values.yaml @@ -0,0 +1,107 @@ +# Default fullname is "code-runner" so Pod DNS is +# code-runner-{n}.code-runner..svc.cluster.local +# (StatefulSet ordinal from 0). Do not use a ClusterIP Service as the only +# entry — that breaks keep_session sticky binds. +fullnameOverride: code-runner +nameOverride: code-runner + +image: + repository: dataelement/bisheng-sandbox + tag: v3.0.0 + pullPolicy: IfNotPresent + +imagePullSecrets: [] + +replicaCount: 2 + +service: + port: 8080 + +# Shared with the BiSheng worker (BS_SANDBOX_CONF__TOKEN). +token: + value: bisheng-sandbox-local + existingSecret: "" + existingSecretKey: token + +env: + SANDBOX_MAX_SESSIONS: "50" + # Total bytes copied into one session (512 MiB); files past it are skipped and reported. + SANDBOX_MAX_COPY_IN_TOTAL_BYTES: "536870912" + SANDBOX_ENABLE_UID_ISOLATION: "true" + SANDBOX_LEASE_TTL_S: "900" + SANDBOX_LOG_LEVEL: INFO + TZ: Asia/Shanghai + +resources: + requests: + cpu: 200m + memory: 512Mi + limits: + # Shared by every session on the pod (compose: SANDBOX_CPUS). Keep it set: + # without a CPU limit one runaway session starves the node. + cpu: "2" + memory: 2Gi + +# Isolation hardening — keep in lockstep with docker/docker-compose.yml. +securityContext: + runAsUser: 0 + runAsNonRoot: false + readOnlyRootFilesystem: true + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + # KILL: signal a session uid's process group on timeout / session delete. + add: ["SETUID", "SETGID", "CHOWN", "DAC_OVERRIDE", "KILL"] + +podSecurityContext: {} + +# Who may call POST /v1/sessions. Uses the automatic namespace name label. +networkPolicy: + enabled: true + # User code shares the runner netns. Egress is allowed so the sandbox + # can reach the public internet. Ingress stays limited to platform pods. + denyEgress: false + allowFrom: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: bisheng + podSelector: + matchLabels: + app.kubernetes.io/component: worker + # Editor "run once" executes in the API process, not the Celery worker. + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: bisheng + podSelector: + matchLabels: + app.kubernetes.io/component: backend + # Task mode (Linsight) runs in its own Deployment; the worker image's + # entrypoint does not start it. + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: bisheng + podSelector: + matchLabels: + app.kubernetes.io/component: linsight + +livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 20 + +readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 3 + periodSeconds: 10 + +nodeSelector: {} +tolerations: [] +affinity: {} + +serviceAccount: + create: false + name: "" diff --git a/docker/helm/bisheng/.helmignore b/docker/helm/bisheng/.helmignore new file mode 100644 index 0000000000..478d0a4fb3 --- /dev/null +++ b/docker/helm/bisheng/.helmignore @@ -0,0 +1,2 @@ +.DS_Store +*.tgz diff --git a/docker/helm/bisheng/Chart.yaml b/docker/helm/bisheng/Chart.yaml new file mode 100644 index 0000000000..4feecfcbf4 --- /dev/null +++ b/docker/helm/bisheng/Chart.yaml @@ -0,0 +1,9 @@ +apiVersion: v2 +name: bisheng +description: BiSheng platform — frontend, API, and Celery worker. +type: application +version: 0.1.0 +appVersion: v3.0.0 +keywords: + - bisheng + - llm diff --git a/docker/helm/bisheng/files/config.yaml b/docker/helm/bisheng/files/config.yaml new file mode 100644 index 0000000000..75d2b6ff49 --- /dev/null +++ b/docker/helm/bisheng/files/config.yaml @@ -0,0 +1,173 @@ +# 数据库配置, 当前加密串的密码是1234, +# 密码加密参考 https://dataelem.feishu.cn/wiki/BSCcwKd4Yiot3IkOEC8cxGW7nPc#Gxitd1xEeof1TzxdhINcGS6JnXd +database_url: + "mysql+pymysql://root:gAAAAABlp4b4c59FeVGF_OQRVf6NOUIGdxq8246EBD-b0hdK_jVKRs1x4PoAn0A6C5S6IiFKmWn0Nm5eBUWu-7jxcqw6TiVjQA==@mysql:3306/bisheng?charset=utf8mb4" + # "dm+dmPython://BISHENG:@192.168.107.9:5236/?schema=BISHENG" +# 数据库连接池配置(可选,省略则用下方默认值)。 +# 注意:连接池是“每进程每引擎”一套,总连接数 ≈ 进程数 ×(pool_size + max_overflow)。 +# 调优时务必让该总数小于数据库服务端的最大会话数(如达梦 MAX_SESSIONS)。 +database_pool: + pool_size: 50 # 每进程常驻连接数 + max_overflow: 20 # 高峰允许超出 pool_size 的临时连接数 + pool_timeout: 30 # 等待空闲连接的超时秒数 + pool_recycle: 3600 # 连接最大存活秒数,超过则回收重建 + pool_pre_ping: true # 取连接前先 ping 检测有效性 + # MySQL TLS 默认关闭。使用私有 CA 时配置 ca_file;双向 TLS 再同时配置 cert_file 与 key_file。 + ssl: + enabled: false + # ca_file: /etc/bisheng/certs/mysql-ca.pem + # cert_file: /etc/bisheng/certs/mysql-client-cert.pem + # key_file: /etc/bisheng/certs/mysql-client-key.pem + # verify_hostname: true + +# 登录态 JWT 签名密钥(可选)。 +# 不配置时,首次启动会自动生成一个随机密钥写入系统配置表(config.jwt_secret),所有 api / worker 进程共用。 +# 配置后以这里为准;历史版本代码里自带的默认值已作废,即使写在这里也会被忽略并改用自动生成的密钥。 +# 轮换:改这里的值或删掉配置表中的记录后重启,全员需重新登录。 +# jwt_secret: "replace-with-a-long-random-string" + +# 缓存配置 redis://[[username]:[password]]@localhost:6379/0 +# 如果设置了密码,需要参考MySQL密码的加密逻辑对密码进行加密。eg: redis://root:gAAAAABlp4b4c59FeVGF_OQRVf6NOUIGdxq8246EBD-b0hdK_jVKRs1x4PoAn0A6C5S6IiFKmWn0Nm5eBUWu-7jxcqw6TiVjQA==@redis:6379/0 +# 普通模式: +redis_url: "redis://redis:6379/1" + +# 与 docker-compose 中 openfga 服务对应(容器内网络) +openfga: + enabled: true + api_url: "http://openfga:8080" + +# 网关 HMAC 推送验签(与 bisheng-gateway 的 bisheng.gateway-hmac-secret 一致) +sso_sync: + gateway_hmac_secret: "bisheng-local-hmac-20260422" + signature_header: "X-Signature" + +# 集群模式或者哨兵模式(只能选其一): +# redis_url: +# mode: "cluster" +# startup_nodes: +# - {"host": "192.168.106.115", "port": 6002} +# password: encrypt(gAAAAABlp4b4c59FeVGF_OQRVf6NOUIGdxq8246EBD-b0hdK_jVKRs1x4PoAn0A6C5S6IiFKmWn0Nm5eBUWu-7jxcqw6TiVjQA==) +# #sentinel +# mode: "sentinel" +# sentinel_hosts: [("redis", 6379)] +# sentinel_master: "mymaster" +# sentinel_password: encrypt(gAAAAABlp4b4c59FeVGF_OQRVf6NOUIGdxq8246EBD-b0hdK_jVKRs1x4PoAn0A6C5S6IiFKmWn0Nm5eBUWu-7jxcqw6TiVjQA==) +# db: 1 + +# Celery broker Redis 配置 +# 单点模式(兼容现有写法): +celery_redis_url: "redis://redis:6379/2" +# 哨兵模式: +# celery_redis_url: +# mode: "sentinel" +# sentinel_hosts: +# - {"host": "redis-sentinel-1", "port": 26379} +# - {"host": "redis-sentinel-2", "port": 26379} +# - {"host": "redis-sentinel-3", "port": 26379} +# sentinel_master: "mymaster" +# sentinel_password: encrypt(gAAAAABlp5vQN7g85IfoeK8nLCb9cfVqpy9ZK9kN7b0qbAXZ4NOZT_Ef7stKJBY6PjL0dngQnCvQMdsavuGu-EE2o6Zlvv6l-Frye08DBXRR4WmL7y7EfK4=) +# password: encrypt(gAAAAABlp5vQN7g85IfoeK8nLCb9cfVqpy9ZK9kN7b0qbAXZ4NOZT_Ef7stKJBY6PjL0dngQnCvQMdsavuGu-EE2o6Zlvv6l-Frye08DBXRR4WmL7y7EfK4=) +# db: 10 + +# 知识库文件解析调度策略 +knowledge_file_worker: + # 是否开启单独的ocr解析队列,防止ocr文件解析阻塞普通的文件解析 + ocr_queue_enabled: false + # ocr解析队列celery对应的queue名称,只有在ocr_queue_enabled=true时生效。必须启动worker并指定-Q ocr_celery 才能处理ocr相关的任务,否则ocr相关的任务会一直积压在队列里无法被处理 + ocr_queue: "ocr_celery" + + # 是否开启公平调度 + fair_scheduler_enabled: false + # 公平调度的配置项,只有在fair_scheduler_enabled=true时生效 + fair_scheduler: + # 调度任务的分布式锁的超时时间,不能大于调度任务的执行间隔时间,否则可能出现锁还没过期但调度任务已经开始执行了,导致多个调度任务同时执行,无法达到公平调度的效果。建议设置为调度任务执行间隔时间的0.8倍以下 + dispatch_lock_ttl_seconds: 24 + # 每个队列的全局并发上限 = 文件解析的最大并发数,是唯一的硬上限。应与监听该队列的所有worker进程的 -c(--concurrency) 之和保持一致。配置过大会导致任务在broker中堆积,配置过小则worker空转。 + queue_concurrency: + knowledge_celery: 20 + ocr_celery: 5 + # 用户公平权重(不是在飞上限)。调度时优先把空位分配给"当前在飞数/权重"最小的用户,权重越大稳态在飞份额越高。默认1表示所有用户等额竞争。 + per_user_pick_size: 1 + # 单独给某些用户配置更大的权重,key是用户id,value是权重。如 {"1": 3} 表示用户1维持约3倍于普通用户的在飞份额。没有单独配置的用户按照per_user_pick_size。 + user_overrides: {"1": 3} + # 已经进入队列但还没有被worker执行的文件在队列里的最长时间,超过这个时间后会被认为是异常文件,调度任务会重新调度这个文件。建议设置为worker执行一个文件的最长时间的1.5倍以上 + inflight_ttl_seconds: 7200 + + +celery_task: + # 对celery熟悉的用户可以自定义配置任务的路由,启动不同类型的worker处理不同类型的异步任务。注意工作流的执行必须在workflow_celery开头的进程里!!! + task_routers: + bisheng.worker.knowledge.*: # 知识库文件处理相关任务 + queue: knowledge_celery + + +# 知识库的milvus和es配置 支持使用 !env ${PATH} 填写环境变量的值, 若环境变量不存在则会报错 +vector_stores: + milvus: + connection_args: !env ${BS_MILVUS_CONNECTION_ARGS} + is_partition: !env ${BS_MILVUS_IS_PARTITION} + partition_suffix: !env ${BS_MILVUS_PARTITION_SUFFIX} + elasticsearch: + url: !env ${BS_ELASTICSEARCH_URL} + ssl_verify: !env ${BS_ELASTICSEARCH_SSL_VERIFY} + + +# 对象存储, 目前只支持minio +object_storage: + type: minio + minio: + schema: !env ${BS_MINIO_SCHEMA} + cert_check: !env ${BS_MINIO_CERT_CHECK} + endpoint: !env ${BS_MINIO_ENDPOINT} + sharepoint: !env ${BS_MINIO_SHAREPOINT} + access_key: !env ${BS_MINIO_ACCESS_KEY} + secret_key: !env ${BS_MINIO_SECRET_KEY} + public_bucket: 'bisheng' # 公共bucket,存储平台上一些需要持久化的文件。会设置为可公开访问 + tmp_bucket: 'tmp-dir' # 临时bucket,会对传到此bucket内的文件设置有效期 + +environment: + env: dev + uns_support: ['png','jpg','jpeg','bmp','doc', 'docx', 'ppt', 'pptx', 'xls', 'xlsx', 'txt', 'md', 'html', 'pdf', 'csv', 'tiff'] + +# 可根据loguru的文档配置不同 handlers +logger_conf: + # 默认输出到sys.stdout的日志级别, 大于等于此级别都会输出 + level: DEBUG + # 默认输出格式 + format: '[{time:YYYY-MM-DD HH:mm:ss.SSSSSS}] [{level.name} process-{process.id}-{thread.id} {name}:{line}] - trace={extra[trace_id]} {message}' + # 参考loguru.add()中的参数可以配置多个handler + handlers: + # 文件路径,支持插入一些系统环境变量,若环境变量不存在则置空。例如 HOSTNAME: 主机名。后端会处理环境变量的替换 + - sink: "/app/data/bisheng.log" + # 日志级别 + level: INFO + # 日志格式化函数,extra内支持trace_id + format: '[{time:YYYY-MM-DD HH:mm:ss.SSSSSS}] [{level.name} process-{process.id}-{thread.id} {name}:{line}] - trace={extra[trace_id]} {message}' + # 每天的几点进行切割 + rotation: "00:00" + retention: "3 Days" + enqueue: ture + - sink: "/app/data/statistic.log" + level: INFO + # 和原生不一样,后端会将配置使用eval()执行转为函数用来过滤特定日志级别。推荐lambda + filter: "lambda record: record['level'].name == 'INFO' and record['message'].startswith('k=s')" + format: "[{time:YYYY-MM-DD HH:mm:ss.SSSSSS}]|{level}|BISHENG|{extra[trace_id]}||{process.id}|{thread.id}|||#EX_ERR:POS={name},line {line},ERR=500,EMSG={message}" + rotation: "00:00" + retention: "3 Days" + enqueue: ture + +# Open API (personal tokens / skill packs). Uncomment only on images that already +# know this section (3.0.0-beta1 and later): older images refuse unknown keys at boot. +# open_api: +# # Browser-facing address of this platform, baked into downloaded skill packs. +# # Set it when the backend sits behind the commercial gateway, a path prefix, +# # or any proxy that does not forward X-Forwarded-Proto / X-Forwarded-Host. +# public_base_url: https://kb.example.com + +# Isolation-environment (sandbox) access. Keep this whole block commented in the +# shipped file: older images refuse unknown top-level keys at boot. +# Override any field with BS_SANDBOX_CONF__, e.g. BS_SANDBOX_CONF__TOKEN. +# endpoints is the only replica list. There is no hostname scan. +# sandbox_conf: +# token: "..." +# endpoints: ["http://code-runner-1:8080", "http://code-runner-2:8080"] diff --git a/docker/helm/bisheng/templates/NOTES.txt b/docker/helm/bisheng/templates/NOTES.txt new file mode 100644 index 0000000000..99680152f5 --- /dev/null +++ b/docker/helm/bisheng/templates/NOTES.txt @@ -0,0 +1,19 @@ +bisheng {{ .Chart.Version }} + +Frontend Service: {{ include "bisheng.frontendService" . }}:{{ .Values.frontend.service.port }} +Backend Service: {{ include "bisheng.backendService" . }}:{{ .Values.backend.service.port }} + +{{- if .Values.sandbox.enabled }} + +Sandbox endpoints (one URL per replica, not the Service VIP): +{{- range .Values.sandbox.endpoints }} + {{ . }} +{{- end }} + +Install the sibling chart first, for example: + + helm upgrade --install sandbox docker/helm/bisheng-sandbox \ + --namespace bisheng-sandbox --create-namespace + +Keep SANDBOX_TOKEN / BS_SANDBOX_CONF__TOKEN identical on both releases. +{{- end }} diff --git a/docker/helm/bisheng/templates/_helpers.tpl b/docker/helm/bisheng/templates/_helpers.tpl new file mode 100644 index 0000000000..1d4d52b464 --- /dev/null +++ b/docker/helm/bisheng/templates/_helpers.tpl @@ -0,0 +1,91 @@ +{{- define "bisheng.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "bisheng.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{- define "bisheng.labels" -}} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | quote }} +app.kubernetes.io/name: {{ include "bisheng.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{- define "bisheng.selectorLabels" -}} +app.kubernetes.io/name: {{ include "bisheng.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{- define "bisheng.backendService" -}} +{{- printf "%s-backend" (include "bisheng.fullname" .) }} +{{- end }} + +{{- define "bisheng.frontendService" -}} +{{- printf "%s-frontend" (include "bisheng.fullname" .) }} +{{- end }} + +{{- define "bisheng.commonEnv" -}} +- name: TZ + value: {{ .Values.timezone | quote }} +- name: BS_SSO_SYNC__GATEWAY_HMAC_SECRET + valueFrom: + secretKeyRef: + name: {{ include "bisheng.fullname" . }} + key: gatewayHmacSecret +- name: BS_MILVUS_CONNECTION_ARGS + value: {{ .Values.middleware.milvus.connectionArgs | quote }} +- name: BS_MILVUS_IS_PARTITION + value: {{ .Values.middleware.milvus.isPartition | quote }} +- name: BS_MILVUS_PARTITION_SUFFIX + value: {{ .Values.middleware.milvus.partitionSuffix | quote }} +- name: BS_ELASTICSEARCH_URL + value: {{ .Values.middleware.elasticsearch.url | quote }} +- name: BS_ELASTICSEARCH_SSL_VERIFY + value: {{ .Values.middleware.elasticsearch.sslVerify | quote }} +- name: BS_MINIO_SCHEMA + value: {{ .Values.middleware.minio.schema | quote }} +- name: BS_MINIO_CERT_CHECK + value: {{ .Values.middleware.minio.certCheck | quote }} +- name: BS_MINIO_ENDPOINT + value: {{ .Values.middleware.minio.endpoint | quote }} +- name: BS_MINIO_SHAREPOINT + value: {{ .Values.middleware.minio.sharepoint | quote }} +- name: BS_MINIO_ACCESS_KEY + valueFrom: + secretKeyRef: + name: {{ include "bisheng.fullname" . }} + key: minioAccessKey +- name: BS_MINIO_SECRET_KEY + valueFrom: + secretKeyRef: + name: {{ include "bisheng.fullname" . }} + key: minioSecretKey +{{- end }} + +{{- define "bisheng.sandboxEnv" -}} +{{- if .Values.sandbox.enabled }} +- name: BS_SANDBOX_CONF__TOKEN + valueFrom: + secretKeyRef: + {{- if .Values.sandbox.existingSecret }} + name: {{ .Values.sandbox.existingSecret }} + key: {{ .Values.sandbox.existingSecretKey }} + {{- else }} + name: {{ include "bisheng.fullname" . }} + key: sandboxToken + {{- end }} +- name: BS_SANDBOX_CONF__ENDPOINTS + value: {{ .Values.sandbox.endpoints | toJson | quote }} +{{- end }} +{{- end }} diff --git a/docker/helm/bisheng/templates/backend-deployment.yaml b/docker/helm/bisheng/templates/backend-deployment.yaml new file mode 100644 index 0000000000..76d8ee1c5a --- /dev/null +++ b/docker/helm/bisheng/templates/backend-deployment.yaml @@ -0,0 +1,82 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "bisheng.fullname" . }}-backend + labels: + {{- include "bisheng.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +spec: + replicas: {{ .Values.backend.replicaCount }} + selector: + matchLabels: + {{- include "bisheng.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: backend + template: + metadata: + labels: + {{- include "bisheng.labels" . | nindent 8 }} + app.kubernetes.io/component: backend + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: backend + image: "{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag }}" + imagePullPolicy: {{ .Values.backend.image.pullPolicy }} + command: ["sh", "entrypoint.sh", "api"] + ports: + - name: http + containerPort: {{ .Values.backend.service.port }} + env: + {{- include "bisheng.commonEnv" . | nindent 12 }} + {{- include "bisheng.sandboxEnv" . | nindent 12 }} + {{- with .Values.backend.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + - name: config + mountPath: /app/bisheng/config.yaml + subPath: config.yaml + - name: data + mountPath: /app/data + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 40 + periodSeconds: 30 + timeoutSeconds: 10 + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 20 + periodSeconds: 15 + timeoutSeconds: 10 + resources: + {{- toYaml .Values.backend.resources | nindent 12 }} + volumes: + - name: config + configMap: + name: {{ default (printf "%s-config" (include "bisheng.fullname" .)) .Values.config.existingConfigMap }} + - name: data + {{- if .Values.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ include "bisheng.fullname" . }}-backend-data + {{- else }} + emptyDir: {} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/docker/helm/bisheng/templates/configmap-config.yaml b/docker/helm/bisheng/templates/configmap-config.yaml new file mode 100644 index 0000000000..e16c4c2b86 --- /dev/null +++ b/docker/helm/bisheng/templates/configmap-config.yaml @@ -0,0 +1,17 @@ +{{- if not .Values.config.existingConfigMap }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "bisheng.fullname" . }}-config + labels: + {{- include "bisheng.labels" . | nindent 4 }} +data: + config.yaml: | +{{- if .Values.config.yaml }} +{{ .Values.config.yaml | nindent 4 }} +{{- else if .Values.config.file }} +{{ .Files.Get .Values.config.file | nindent 4 }} +{{- else }} +{{ .Files.Get "files/config.yaml" | nindent 4 }} +{{- end }} +{{- end }} diff --git a/docker/helm/bisheng/templates/configmap-nginx.yaml b/docker/helm/bisheng/templates/configmap-nginx.yaml new file mode 100644 index 0000000000..b16216c225 --- /dev/null +++ b/docker/helm/bisheng/templates/configmap-nginx.yaml @@ -0,0 +1,109 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "bisheng.fullname" . }}-nginx + labels: + {{- include "bisheng.labels" . | nindent 4 }} +data: + default.conf: | + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + map $http_x_forwarded_proto $bs_forwarded_proto { + default $http_x_forwarded_proto; + "" $scheme; + } + map $http_x_forwarded_host $bs_forwarded_host { + default $http_x_forwarded_host; + "" $http_host; + } + server { + gzip on; + gzip_comp_level 2; + gzip_min_length 1000; + gzip_types text/xml text/css; + gzip_http_version 1.1; + gzip_vary on; + listen {{ .Values.frontend.service.port }}; + + location / { + root /usr/share/nginx/html/platform; + index index.html index.htm; + try_files $uri $uri/ /index.html =404; + add_header X-Frame-Options SAMEORIGIN; + } + + location /workspace/ { + alias /usr/share/nginx/html/client/; + index index.html index.htm; + try_files $uri $uri/ /workspace/index.html; + } + + location ~ ^(/workspace)?/api(/|$) { + rewrite ^/workspace(/.*)$ $1 break; + proxy_pass http://{{ include "bisheng.backendService" . }}:{{ .Values.backend.service.port }}; + proxy_read_timeout 300s; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $bs_forwarded_proto; + proxy_set_header X-Forwarded-Host $bs_forwarded_host; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + client_max_body_size 1024m; + add_header X-Frame-Options SAMEORIGIN; + } + + location ~ ^/(workspace/bisheng|bisheng|tmp-dir)/ { + rewrite ^/workspace(/.*)$ $1 break; + # SigV4 signs Host; must match object_storage.minio.sharepoint. + proxy_set_header Host {{ .Values.middleware.minio.sharepoint }}; + proxy_pass http://{{ .Values.middleware.minio.endpoint }}; + } + } + {{- if .Values.frontend.service.clientPort }} + server { + gzip on; + gzip_comp_level 2; + gzip_min_length 1000; + gzip_types text/xml text/css; + gzip_http_version 1.1; + gzip_vary on; + listen {{ .Values.frontend.service.clientPort }}; + + location = / { + return 302 /workspace/; + } + + location /workspace/ { + alias /usr/share/nginx/html/client/; + index index.html index.htm; + try_files $uri $uri/ /workspace/index.html; + } + + location ~ ^(/workspace)?/api(/|$) { + rewrite ^/workspace(/.*)$ $1 break; + proxy_pass http://{{ include "bisheng.backendService" . }}:{{ .Values.backend.service.port }}; + proxy_read_timeout 300s; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $bs_forwarded_proto; + proxy_set_header X-Forwarded-Host $bs_forwarded_host; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + client_max_body_size 1024m; + add_header X-Frame-Options SAMEORIGIN; + } + + location ~ ^/(workspace/bisheng|bisheng|tmp-dir)/ { + rewrite ^/workspace(/.*)$ $1 break; + # SigV4 signs Host; must match object_storage.minio.sharepoint. + proxy_set_header Host {{ .Values.middleware.minio.sharepoint }}; + proxy_pass http://{{ .Values.middleware.minio.endpoint }}; + } + } + {{- end }} diff --git a/docker/helm/bisheng/templates/frontend-deployment.yaml b/docker/helm/bisheng/templates/frontend-deployment.yaml new file mode 100644 index 0000000000..4de0d3fd34 --- /dev/null +++ b/docker/helm/bisheng/templates/frontend-deployment.yaml @@ -0,0 +1,80 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "bisheng.fullname" . }}-frontend + labels: + {{- include "bisheng.labels" . | nindent 4 }} + app.kubernetes.io/component: frontend +spec: + replicas: {{ .Values.frontend.replicaCount }} + # hostPort on the client listener cannot coexist with a second replica. + strategy: + type: Recreate + selector: + matchLabels: + {{- include "bisheng.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: frontend + template: + metadata: + labels: + {{- include "bisheng.labels" . | nindent 8 }} + app.kubernetes.io/component: frontend + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: frontend + image: "{{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag }}" + imagePullPolicy: {{ .Values.frontend.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.frontend.service.port }} + {{- if .Values.frontend.service.clientPort }} + - name: client + containerPort: {{ .Values.frontend.service.clientPort }} + {{- if .Values.frontend.service.clientHostPort }} + hostPort: {{ .Values.frontend.service.clientHostPort }} + {{- end }} + {{- end }} + env: + - name: TZ + value: {{ .Values.timezone | quote }} + {{- with .Values.frontend.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + - name: nginx + mountPath: /etc/nginx/conf.d/default.conf + subPath: default.conf + livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 10 + periodSeconds: 20 + readinessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + {{- toYaml .Values.frontend.resources | nindent 12 }} + volumes: + - name: nginx + configMap: + name: {{ include "bisheng.fullname" . }}-nginx + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/docker/helm/bisheng/templates/ingress.yaml b/docker/helm/bisheng/templates/ingress.yaml new file mode 100644 index 0000000000..d6e31f34e1 --- /dev/null +++ b/docker/helm/bisheng/templates/ingress.yaml @@ -0,0 +1,35 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "bisheng.fullname" . }} + labels: + {{- include "bisheng.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- toYaml .Values.ingress.tls | nindent 4 }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "bisheng.frontendService" $ }} + port: + number: {{ $.Values.frontend.service.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/docker/helm/bisheng/templates/linsight-deployment.yaml b/docker/helm/bisheng/templates/linsight-deployment.yaml new file mode 100644 index 0000000000..7d8f08fb0c --- /dev/null +++ b/docker/helm/bisheng/templates/linsight-deployment.yaml @@ -0,0 +1,65 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "bisheng.fullname" . }}-linsight + labels: + {{- include "bisheng.labels" . | nindent 4 }} + app.kubernetes.io/component: linsight +spec: + replicas: {{ .Values.linsight.replicaCount }} + selector: + matchLabels: + {{- include "bisheng.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: linsight + template: + metadata: + labels: + {{- include "bisheng.labels" . | nindent 8 }} + app.kubernetes.io/component: linsight + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: linsight + image: "{{ .Values.linsight.image.repository }}:{{ .Values.linsight.image.tag }}" + imagePullPolicy: {{ .Values.linsight.image.pullPolicy }} + command: ["sh", "entrypoint.sh", "linsight"] + env: + {{- include "bisheng.commonEnv" . | nindent 12 }} + {{- include "bisheng.sandboxEnv" . | nindent 12 }} + {{- with .Values.linsight.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + - name: config + mountPath: /app/bisheng/config.yaml + subPath: config.yaml + - name: data + mountPath: /app/data + resources: + {{- toYaml .Values.linsight.resources | nindent 12 }} + volumes: + - name: config + configMap: + name: {{ default (printf "%s-config" (include "bisheng.fullname" .)) .Values.config.existingConfigMap }} + - name: data + {{- if .Values.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ include "bisheng.fullname" . }}-linsight-data + {{- else }} + emptyDir: {} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/docker/helm/bisheng/templates/pvc.yaml b/docker/helm/bisheng/templates/pvc.yaml new file mode 100644 index 0000000000..5425d8cd1b --- /dev/null +++ b/docker/helm/bisheng/templates/pvc.yaml @@ -0,0 +1,35 @@ +{{- if .Values.persistence.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "bisheng.fullname" . }}-backend-data + labels: + {{- include "bisheng.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: {{ .Values.persistence.size }} + {{- with .Values.persistence.storageClass }} + storageClassName: {{ . }} + {{- end }} +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "bisheng.fullname" . }}-worker-data + labels: + {{- include "bisheng.labels" . | nindent 4 }} + app.kubernetes.io/component: worker +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: {{ .Values.persistence.size }} + {{- with .Values.persistence.storageClass }} + storageClassName: {{ . }} + {{- end }} +{{- end }} diff --git a/docker/helm/bisheng/templates/secret.yaml b/docker/helm/bisheng/templates/secret.yaml new file mode 100644 index 0000000000..5f66703a1d --- /dev/null +++ b/docker/helm/bisheng/templates/secret.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "bisheng.fullname" . }} + labels: + {{- include "bisheng.labels" . | nindent 4 }} +type: Opaque +stringData: + gatewayHmacSecret: {{ .Values.secrets.gatewayHmacSecret | quote }} + minioAccessKey: {{ .Values.middleware.minio.accessKey | quote }} + minioSecretKey: {{ .Values.middleware.minio.secretKey | quote }} + {{- if and .Values.sandbox.enabled (not .Values.sandbox.existingSecret) }} + sandboxToken: {{ .Values.sandbox.token | quote }} + {{- end }} diff --git a/docker/helm/bisheng/templates/services.yaml b/docker/helm/bisheng/templates/services.yaml new file mode 100644 index 0000000000..52226c5b88 --- /dev/null +++ b/docker/helm/bisheng/templates/services.yaml @@ -0,0 +1,43 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "bisheng.backendService" . }} + labels: + {{- include "bisheng.labels" . | nindent 4 }} + app.kubernetes.io/component: backend +spec: + selector: + {{- include "bisheng.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: backend + ports: + - name: http + port: {{ .Values.backend.service.port }} + targetPort: http +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "bisheng.frontendService" . }} + labels: + {{- include "bisheng.labels" . | nindent 4 }} + app.kubernetes.io/component: frontend +spec: + type: {{ .Values.frontend.service.type }} + selector: + {{- include "bisheng.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: frontend + ports: + - name: http + port: {{ .Values.frontend.service.port }} + targetPort: http + {{- if and (eq .Values.frontend.service.type "NodePort") .Values.frontend.service.nodePort }} + nodePort: {{ .Values.frontend.service.nodePort }} + {{- end }} + {{- if .Values.frontend.service.clientPort }} + - name: client + port: {{ .Values.frontend.service.clientPort }} + targetPort: client + {{- if and (eq .Values.frontend.service.type "NodePort") .Values.frontend.service.clientNodePort }} + nodePort: {{ .Values.frontend.service.clientNodePort }} + {{- end }} + {{- end }} diff --git a/docker/helm/bisheng/templates/worker-deployment.yaml b/docker/helm/bisheng/templates/worker-deployment.yaml new file mode 100644 index 0000000000..35334cd34b --- /dev/null +++ b/docker/helm/bisheng/templates/worker-deployment.yaml @@ -0,0 +1,65 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "bisheng.fullname" . }}-worker + labels: + {{- include "bisheng.labels" . | nindent 4 }} + app.kubernetes.io/component: worker +spec: + replicas: {{ .Values.worker.replicaCount }} + selector: + matchLabels: + {{- include "bisheng.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: worker + template: + metadata: + labels: + {{- include "bisheng.labels" . | nindent 8 }} + app.kubernetes.io/component: worker + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: worker + image: "{{ .Values.worker.image.repository }}:{{ .Values.worker.image.tag }}" + imagePullPolicy: {{ .Values.worker.image.pullPolicy }} + command: ["sh", "entrypoint.sh", "worker"] + env: + {{- include "bisheng.commonEnv" . | nindent 12 }} + {{- include "bisheng.sandboxEnv" . | nindent 12 }} + {{- with .Values.worker.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + - name: config + mountPath: /app/bisheng/config.yaml + subPath: config.yaml + - name: data + mountPath: /app/data + resources: + {{- toYaml .Values.worker.resources | nindent 12 }} + volumes: + - name: config + configMap: + name: {{ default (printf "%s-config" (include "bisheng.fullname" .)) .Values.config.existingConfigMap }} + - name: data + {{- if .Values.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ include "bisheng.fullname" . }}-worker-data + {{- else }} + emptyDir: {} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/docker/helm/bisheng/values.yaml b/docker/helm/bisheng/values.yaml new file mode 100644 index 0000000000..151ff8524a --- /dev/null +++ b/docker/helm/bisheng/values.yaml @@ -0,0 +1,130 @@ +# Application chart only. MySQL / Redis / MinIO / ES / Milvus / OpenFGA are +# expected to already exist (compose, operator, or another chart). Point the +# hosts below at those Services. + +timezone: Asia/Shanghai + +imagePullSecrets: [] + +backend: + replicaCount: 1 + image: + repository: dataelement/bisheng-backend + tag: v3.0.0 + pullPolicy: IfNotPresent + service: + port: 7860 + resources: + requests: + cpu: 500m + memory: 1Gi + limits: + memory: 4Gi + extraEnv: [] + +worker: + replicaCount: 1 + image: + repository: dataelement/bisheng-backend + tag: v3.0.0 + pullPolicy: IfNotPresent + resources: + requests: + cpu: 500m + memory: 1Gi + limits: + memory: 4Gi + extraEnv: [] + +# Task-mode / Linsight worker. The image `entrypoint.sh worker` path does not +# keep this process as PID 1; run it as its own Deployment. +linsight: + replicaCount: 1 + image: + repository: dataelement/bisheng-backend + tag: v3.0.0 + pullPolicy: IfNotPresent + resources: + requests: + cpu: 250m + memory: 1Gi + limits: + memory: 4Gi + extraEnv: [] + +frontend: + replicaCount: 1 + image: + repository: dataelement/bisheng-frontend + tag: v3.0.0 + pullPolicy: IfNotPresent + service: + type: ClusterIP + port: 3001 + # Client SPA (base /workspace). 0 disables the extra listen/Service port. + clientPort: 4001 + clientNodePort: null + # Bind the node host to clientPort (single-node / no Ingress). Empty = off. + clientHostPort: null + resources: + requests: + cpu: 100m + memory: 128Mi + extraEnv: [] + +# Isolation-environment access. Token must match the bisheng-sandbox chart. +# List each replica URL. Do not point this at the ClusterIP Service. +sandbox: + enabled: true + token: bisheng-sandbox-local + existingSecret: "" + existingSecretKey: token + endpoints: + - http://code-runner-0.code-runner.bisheng-sandbox.svc.cluster.local:8080 + - http://code-runner-1.code-runner.bisheng-sandbox.svc.cluster.local:8080 + +middleware: + milvus: + connectionArgs: '{"host":"milvus","port":"19530","user":"","password":"","secure":false}' + isPartition: "true" + partitionSuffix: "1" + elasticsearch: + url: http://elasticsearch:9200 + sslVerify: "{}" + minio: + schema: "false" + certCheck: "false" + endpoint: minio:9000 + sharepoint: minio:9000 + accessKey: minioadmin + secretKey: minioadmin + +secrets: + gatewayHmacSecret: bisheng-local-hmac-20260422 + +# Mounted at /app/bisheng/config.yaml. Empty = chart files/config.yaml +# (same as docker/bisheng/config/config.yaml). Hosts mysql / redis / openfga +# must resolve in this namespace (or edit the YAML). +config: + existingConfigMap: "" + yaml: "" + +ingress: + enabled: false + className: nginx + annotations: {} + hosts: + - host: bisheng.example.com + paths: + - path: / + pathType: Prefix + tls: [] + +persistence: + enabled: false + size: 20Gi + storageClass: "" + +nodeSelector: {} +tolerations: [] +affinity: {} diff --git a/docker/install.sh b/docker/install.sh new file mode 100644 index 0000000000..e9530e7681 --- /dev/null +++ b/docker/install.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Copy the template, optionally configure Gateway, then let Compose deploy. +set -euo pipefail +umask 077 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="${SCRIPT_DIR}/.env" +source "${SCRIPT_DIR}/lib/install-env.sh" +die() { printf '部署停止:%s\n' "$*" >&2; exit 1; } + +GATEWAY_CHOICE=""; LICENSE_FILE=""; PUBLIC_URL=""; PREPARE_ONLY=false +while [[ $# -gt 0 ]]; do + case "$1" in + --gateway) GATEWAY_CHOICE=true; shift ;; + --no-gateway) GATEWAY_CHOICE=false; shift ;; + --prepare-only) PREPARE_ONLY=true; shift ;; + --license-file|--public-url) + [[ $# -ge 2 && -n "$2" ]] || die "$1 需要参数" + if [[ "$1" = --license-file ]]; then LICENSE_FILE="$2"; else PUBLIC_URL="$2"; fi + shift 2 ;; + -h|--help) + printf '%s\n' '用法:bash docker/install.sh [--gateway | --no-gateway] [--prepare-only]' \ + '首次无参数部署不启用 Gateway;已有 .env 按原配置继续。' \ + 'Gateway 必须提供 license 文件路径:--license-file <文件> --public-url <平台地址>' + exit 0 ;; + *) die "未知参数:$1" ;; + esac +done +if docker compose version >/dev/null 2>&1; then COMPOSE=(docker compose); +elif command -v docker-compose >/dev/null 2>&1; then COMPOSE=(docker-compose); +else die "请安装 Docker Compose(推荐 V2,也支持 docker-compose 命令入口)"; fi +# An existing .env is never replaced or merged automatically. +[[ -f "$ENV_FILE" ]] || cp "${SCRIPT_DIR}/.env.example" "$ENV_FILE" +chmod 600 "$ENV_FILE" +compose() { "${COMPOSE[@]}" --env-file "$ENV_FILE" -f "${SCRIPT_DIR}/docker-compose.yml" "$@"; } +profiles="$(env_get "$ENV_FILE" COMPOSE_PROFILES)" +if [[ -n "$LICENSE_FILE" ]]; then + [[ "$GATEWAY_CHOICE" != false ]] || die "--license-file 不能与 --no-gateway 同时使用" + GATEWAY_CHOICE=true +fi +if [[ -z "$GATEWAY_CHOICE" ]]; then + case ",$profiles," in *,gateway,*) GATEWAY_CHOICE=true ;; esac +fi +if [[ "$GATEWAY_CHOICE" = true ]]; then + LICENSE_FILE="${LICENSE_FILE:-$(env_get "$ENV_FILE" BISHENG_GATEWAY_LICENSE_FILE)}" + if [[ -z "$LICENSE_FILE" && -t 0 ]]; then + read -r -p 'Gateway license 文件路径(留空则不启用):' LICENSE_FILE + fi + if [[ -z "$LICENSE_FILE" ]]; then + printf '未提供 license 文件路径,跳过 Gateway。\n' + GATEWAY_CHOICE=false + PUBLIC_URL="" + else + [[ -f "$LICENSE_FILE" && -r "$LICENSE_FILE" ]] || die "license 文件不存在或不可读:$LICENSE_FILE" + fi +fi +if [[ "$GATEWAY_CHOICE" = true ]]; then + LICENSE_FILE="$(cd "$(dirname "$LICENSE_FILE")" && pwd)/$(basename "$LICENSE_FILE")" + license="$(cat "$LICENSE_FILE")" + [[ "$license" =~ [^[:space:]] ]] || die "license 文件不能为空" + env_set "$ENV_FILE" BISHENG_GATEWAY_LICENSE_FILE "$LICENSE_FILE" + PUBLIC_URL="${PUBLIC_URL:-$(env_get "$ENV_FILE" BISHENG_PUBLIC_URL)}" + if [[ -z "$PUBLIC_URL" && -t 0 ]]; then read -r -p '平台对外地址(如 http://服务器IP:3001):' PUBLIC_URL; fi + [[ "$PUBLIC_URL" =~ ^https?://[^/?#@[:space:]]+/?$ ]] || die "请填写不带路径的平台 http(s) 地址" + for key in BISHENG_GATEWAY_HMAC_SECRET BISHENG_GATEWAY_DB_PASSWORD; do + if [[ -z "$(env_get "$ENV_FILE" "$key")" ]]; then + env_set "$ENV_FILE" "$key" "$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')" + fi + done + volume="$(env_get "$ENV_FILE" DOCKER_VOLUME_DIRECTORY)"; volume="${volume:-.}" + case "$volume" in /*) ;; *) volume="${SCRIPT_DIR}/${volume}" ;; esac + mkdir -p "${SCRIPT_DIR}/.generated/nginx" + target="$(mktemp -d "${SCRIPT_DIR}/.generated/nginx/gateway.XXXXXX")" + cp -R "${volume}/nginx/conf.d/." "$target/" + awk ' + /^[[:space:]]*location[[:space:]]/ { api = index($0, "^(/workspace)?/api") > 0 } + api { sub(/proxy_pass http:\/\/backend_server;/, "proxy_pass http://gateway:8080;") } + { print } + ' "${volume}/nginx/conf.d/default.conf" > "${target}/default.conf" + sed 's|proxy_pass http://backend:7860;|proxy_pass http://gateway:8080;|g' \ + "${volume}/nginx/conf.d/websocket.conf" > "${target}/websocket.conf" + env_set "$ENV_FILE" BISHENG_GATEWAY_LICENSE "$license" + env_set "$ENV_FILE" BISHENG_PUBLIC_URL "${PUBLIC_URL%/}" + env_set "$ENV_FILE" BISHENG_NGINX_CONF_DIR "$target" +elif [[ -n "$LICENSE_FILE" || -n "$PUBLIC_URL" ]]; then die "Gateway 参数需要同时使用 --gateway"; +fi +if [[ -n "$GATEWAY_CHOICE" ]]; then + env_set "$ENV_FILE" COMPOSE_PROFILES "$(set_gateway_profile "$profiles" "$GATEWAY_CHOICE")" + env_set "$ENV_FILE" BISHENG_PRO "$GATEWAY_CHOICE" + if [[ "$GATEWAY_CHOICE" = false ]]; then + volume="$(env_get "$ENV_FILE" DOCKER_VOLUME_DIRECTORY)"; volume="${volume:-.}" + env_set "$ENV_FILE" BISHENG_NGINX_CONF_DIR "${volume%/}/nginx/conf.d" + fi +fi +compose config --quiet +if [[ "$PREPARE_ONLY" = true ]]; then printf '配置已生成并通过 Compose 校验。\n'; exit 0; fi +# Authenticate with the public defaults; existing deployments may override them. +registry_user="$(env_get "$ENV_FILE" CR_REGISTRY_USERNAME)" +registry_password="$(env_get "$ENV_FILE" CR_REGISTRY_PASSWORD)" +printf '%s\n' "${registry_password:-dataelem}" | \ + docker login cr.dataelem.com --username "${registry_user:-docker}" --password-stdin \ + || die "CR 镜像源登录失败,请检查网络或 .env 中的 CR_REGISTRY_USERNAME / CR_REGISTRY_PASSWORD" +unset registry_password +# Pull first so missing images stop deployment before existing services change. +compose pull || die "镜像拉取失败;请检查网络、镜像权限或 .env 中的 *_IMAGE 地址。不会自动构建。" +case ",$(env_get "$ENV_FILE" COMPOSE_PROFILES)," in + *,gateway,*) compose up -d gateway ;; +esac +compose up -d +printf '已执行启动;请使用 Compose ps / logs 确认服务就绪,Gateway 授权结果查看 gateway 日志。\n' diff --git a/docker/lib/env.awk b/docker/lib/env.awk new file mode 100644 index 0000000000..09c70bb369 --- /dev/null +++ b/docker/lib/env.awk @@ -0,0 +1,97 @@ +# Single-line dotenv reader/writer. Values are data, never shell source. +function trim(value) { + sub(/^[[:space:]]+/, "", value) + sub(/[[:space:]]+$/, "", value) + return value +} +function fail(key) { + print "Cannot read single-line dotenv value: " key > "/dev/stderr" + invalid = 1 + exit 1 +} +function decode(raw, key, quote, result, i, char, nextchar, tail, closed) { + raw = trim(raw) + quote = substr(raw, 1, 1) + if (quote != "\047" && quote != "\042") { + sub(/[[:space:]]+#.*/, "", raw) + if (substr(raw, 1, 1) == "#") raw = "" + return raw + } + result = "" + for (i = 2; i <= length(raw); i++) { + char = substr(raw, i, 1) + nextchar = substr(raw, i + 1, 1) + if (quote == "\042" && char == "$" && nextchar == "$") { + result = result "$" + i++ + } else if (char == "\\" && nextchar == quote) { + result = result quote + i++ + } else if (quote == "\042" && char == "\\") { + if (nextchar == "n" || nextchar == "r") fail(key) + if (nextchar == "t") result = result "\t" + else if (nextchar == "\\") result = result "\\" + else result = result char nextchar + i++ + } else if (char == quote) { + tail = trim(substr(raw, i + 1)) + if (tail != "" && substr(tail, 1, 1) != "#") fail(key) + closed = 1 + break + } else result = result char + } + if (!closed) fail(key) + return result +} +function encode(value, result, i, char, double_quoted) { + # Compose single quotes cannot represent an odd trailing backslash. + double_quoted = index(value, "\\") > 0 + if (double_quoted) { + result = "\042" + for (i = 1; i <= length(value); i++) { + char = substr(value, i, 1) + if (char == "\\" || char == "\042") result = result "\\" + if (char == "$") result = result "$" + result = result char + } + return result "\042" + } + result = "\047" + for (i = 1; i <= length(value); i++) { + char = substr(value, i, 1) + if (char == "\047") result = result "\\" + result = result char + } + return result "\047" +} +{ + line = $0 + sub(/\r$/, "", line) + original[NR] = line + candidate = line + sub(/^[[:space:]]*/, "", candidate) + sub(/^export[[:space:]]+/, "", candidate) + if (candidate !~ /^[A-Za-z_][A-Za-z0-9_]*[[:space:]]*=/) next + key = candidate + sub(/[[:space:]]*=.*/, "", key) + if (seen[key]++) fail(key) + order[++count] = key + names[NR] = key + raw = candidate + sub(/^[^=]*=/, "", raw) + values[key] = decode(raw, key) +} +END { + if (invalid) exit 1 + if (MODE == "get") { + printf "%s", values[KEY] + } else if (MODE == "set") { + value = ENVIRON["BISHENG_ENV_VALUE"] + if (value ~ /[\r\n]/) fail(KEY) + for (i = 1; i <= NR; i++) { + if (names[i] == KEY) print KEY "=" encode(value) + else print original[i] + } + if (!(KEY in values)) print KEY "=" encode(value) + } +} diff --git a/docker/lib/install-env.sh b/docker/lib/install-env.sh new file mode 100644 index 0000000000..c3d8c30c8a --- /dev/null +++ b/docker/lib/install-env.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Shared deployment helpers. Only this checked-in file is sourced, never .env. + +env_get() { awk -v MODE=get -v KEY="$2" -f "${SCRIPT_DIR}/lib/env.awk" "$1"; } + +env_set() { + local file="$1" key="$2" value="$3" temporary + case "$value" in + *$'\n'*|*$'\r'*) die "配置项不能包含换行:${key}" ;; + esac + temporary="$(mktemp "${file}.XXXXXX")" + if BISHENG_ENV_VALUE="$value" awk -v MODE=set -v KEY="$key" \ + -f "${SCRIPT_DIR}/lib/env.awk" "$file" > "$temporary"; then + chmod 600 "$temporary" + mv "$temporary" "$file" + else + rm -f "$temporary" + die "无法更新配置项:${key}" + fi +} + +set_gateway_profile() { + local profiles="$1" enabled="$2" + awk -v profiles="$profiles" -v enabled="$enabled" 'BEGIN { + count = split(profiles, items, /[[:space:],]+/) + for (i = 1; i <= count; i++) { + item = items[i] + if (item != "" && item != "gateway" && !seen[item]++) result = result (result ? "," : "") item + } + if (enabled == "true") result = result (result ? "," : "") "gateway" + printf "%s", result + }' +} diff --git a/docker/nginx/conf.d/default.conf b/docker/nginx/conf.d/default.conf index b87aea3d23..3fac60649b 100644 --- a/docker/nginx/conf.d/default.conf +++ b/docker/nginx/conf.d/default.conf @@ -62,11 +62,107 @@ server { try_files $uri $uri/ /workspace/index.html; } + + # ── 托管应用入口(F054 T073 / design D5)──────────────────────────── + # 前缀匹配,比 `/` 长故优先于 platform 的 SPA fallback;与正则 + # `~ ^(/workspace)?/api(/|$)` 不冲突(正则只吃 /api)。 + # + # ⚠️ upstream 必须走**变量 + resolver 延迟解析**,不能写成静态 + # `proxy_pass http://app-proxy:8090`。nginx 在 **config load 时**解析静态 + # proxy_pass 里的主机名;而 app-proxy 挂在 `app-runtime` profile 后面, + # 「整层不装」是产品明确支持的形态(GOV-10)。一旦它没起,静态写法会让 + # **整个 nginx 起不来** —— 症状是升级后前端全站白屏,而不是「托管应用打不开」。 + # 变量式 proxy_pass 把解析推迟到请求期,解析失败只影响这一个 location。 + # + # 变量式 proxy_pass **不会**自动带上原始 URI,必须显式拼 $request_uri + # (它已含 query string)。app-proxy 自己剥 /apps/{slug} 前缀,所以这里 + # 原样透传,不做 rewrite。 + location /apps/ { + resolver 127.0.0.11 valid=10s ipv6=off; + set $bisheng_app_proxy http://app-proxy:8090; + proxy_pass $bisheng_app_proxy$request_uri; + + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + # 托管应用可能用 WebSocket(复用文件顶部的 map)。 + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # 应用可能长轮询或流式输出;缓冲会把流式响应攒成一坨。 + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_buffering off; + client_max_body_size 1024m; + + # 只兜 nginx 自己产生的错误(上游连不上)。**刻意不开 + # proxy_intercept_errors** —— 开了会把应用自己返回的 502/503 也换成 + # 平台的兜底页,那是在替应用说谎。 + error_page 502 503 504 = @apps_unavailable; + } + + # 「工场运行时层未部署 / 暂不可用」的引导页,由 backend 渲染(T035)。 + location @apps_unavailable { + # ⚠️ 命名 location 里的 proxy_pass **不能带 URI 部分** + # (nginx: "proxy_pass cannot have URI part in ... named location"), + # 所以目标路径只能靠 rewrite 设定。写成 + # `proxy_pass http://backend_server/api/v1/apps/_unavailable` 会让整个 + # nginx 起不来——本文件已实测过一次。 + rewrite ^ /api/v1/apps/_unavailable break; + proxy_pass http://backend_server; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + # Workload material signatures cover the original root path, query and body. + # Keep this exact prefix independent of the browser API/workspace rewrites. + # Backend still validates the delegation, Pod identity, signature and ticket. + location ^~ /internal/app-runtime/v2/material-access/ { + proxy_pass http://backend_server; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $bs_forwarded_proto; + proxy_set_header X-Forwarded-Host $bs_forwarded_host; + proxy_set_header Connection ""; + proxy_request_buffering off; + proxy_buffering off; + client_max_body_size 16m; + client_body_timeout 60s; + proxy_connect_timeout 60s; + proxy_send_timeout 65s; + proxy_read_timeout 65s; + } + + # F086 raw offline material imports have a separate streaming body limit. + # Keep this regex before the generic API regex; backend still enforces ownership, + # compressed/unpacked size, profile compatibility and content hashes. + location ~ ^(/workspace)?/api/v1/(runtime/materials/public|apps/[^/]+/runtime/materials)$ { + rewrite ^/workspace/api(.*)$ /api$1 break; + proxy_pass http://backend_server; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $bs_forwarded_proto; + proxy_set_header X-Forwarded-Host $bs_forwarded_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_request_buffering off; + proxy_buffering off; + proxy_read_timeout 1800s; + proxy_send_timeout 1800s; + client_max_body_size 2048m; + } + location ~ ^(/workspace)?/api(/|$) { rewrite ^/workspace(/.*)$ $1 break; proxy_pass http://backend_server; proxy_read_timeout 300s; - proxy_set_header Host $host; + # Preserve the caller's public authority, including a mapped port. + # MCP and Desktop login share dsh.platform_public_url on the backend. + proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $bs_forwarded_proto; diff --git a/docker/nginx/conf.d/websocket.conf b/docker/nginx/conf.d/websocket.conf index 60ec256c83..ea655769cb 100644 --- a/docker/nginx/conf.d/websocket.conf +++ b/docker/nginx/conf.d/websocket.conf @@ -30,7 +30,8 @@ server { location /api { proxy_pass http://backend:7860; proxy_read_timeout 300s; - proxy_set_header Host $host; + # Preserve the same public authority as the main API ingress. + proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $bs_forwarded_proto; diff --git a/docs/DESKTOP_SETTINGS_DEPLOYMENT.md b/docs/DESKTOP_SETTINGS_DEPLOYMENT.md new file mode 100644 index 0000000000..523268c3a0 --- /dev/null +++ b/docs/DESKTOP_SETTINGS_DEPLOYMENT.md @@ -0,0 +1,50 @@ +# 桌面设置内容模式部署验收 + +首次部署于 2026-10-19,2026-10-20 修复后重新部署并完成原生联合验收(Asia/Shanghai)。目标:`192.168.106.119:3006`。当前状态以本节为准,下方保留首次部署记录。 + +## 2026-10-20 修复重新部署 + +活动发布 `bisheng-work-120-de5abeeb3-skill-picker-20261020` 的实际设置脚本 `SettingsPage.Dq-0h4Je.js` 缺少桌面内容模式及就绪回报,导致桌面正确导航后仍停留在加载状态。经用户授权,以该活动发布的实际源码为基线,合入现有 10 个设置相关源码/测试文件,保留较新的资源菜单、输入框、技能选择等代码,发布为 `desktop-settings-20261020-r2`。 + +- 切换前持有发布锁,核对 2968 个源码文件;交付 468 个源码及构建文件,10 个设置源码/测试文件均与本地工作区一致。 +- 仅重建前端容器;管理端构建、运行时配置和其他服务容器身份保持。旧发布保留,部署脚本含失败自动回退和回退指纹检查。 +- 新设置脚本为 `SettingsPage.B7ZFOg4Z.js`,实际 HTTP 响应与构建字节一致,含 `reportSettings`、`data-desktop-settings-content` 及内容模式路由。 +- 新客户端首页 SHA-256:`2f74a417128864139226eb5277258a04f02a11b789a8b873aa30dac6313c33d8`。服务器清单、脚本及结果:`/data/bs119/deployments/f054-unified-agent-3006/incoming/desktop-settings-20261020-r2/`。 +- 隔离线上源码构建的工作区类型检查、10 个文件 ESLint、8 个套件 28 项测试、i18n 检查及生产构建通过。全量 lint 仍有未修改的 platform 95 个错误、client 22 个错误,未增加豁免。 +- 冷启动 macOS 开发桌面客户端后,账号与安全、用量与存储、我的审批、我的申请、通知实际显示内容,原生端收到路径、分区及内容模式全部匹配的就绪回报。关闭和网页内 Escape 均恢复聊天页。未执行审批写操作、密码修改或真实退出。 + +桌面底部现为账号信息入口,菜单顺序为设置、审批、连接手机、退出登录;审批是独立面板,设置导航不再包含审批。网页继续复用同一内容模式白名单,无需新增审批 API。后续发布须将这 10 个文件的内容保留在构建输入中,并复核实际脚本和原生就绪回报,避免再次漏带。 + +## 代码与发布 + +- 延续工作区已有的内容模式实现,未重写设置功能;本次补充浏览器隔离、六个分区、内容挂载后回报、用量正常/失败、个人 AI 接入开关和 Escape 清理的回归测试。 +- 以线上 `bisheng-work-120-15f319c06-20261019` 为构建基线,叠加 10 个设置相关源码/测试文件。保留线上较新的输入框、资源菜单和三语语言包,未用本地较旧实现覆盖它们。 +- 首次发布:`desktop-settings-20261019-r1`,核验 471 个源码及构建文件。仅重建前端容器;管理端构建、运行时配置和其他服务容器身份保持。 +- 2026-10-20 检查时,活动发布已由其他工作推进至 `work-resources-20261020-r1`,客户端首页指纹仍与本次发布一致。 +- 客户端首页 SHA-256:`5d703274674b4380170306fb8b8609952b1e5b48ad24141837683a9aaf8f28ca`。 +- 发布清单、部署脚本、自动回退及验证结果保存在服务器 `/data/bs119/deployments/f054-unified-agent-3006/incoming/desktop-settings-20261019-r1/`;原发布保留。 + +## 自动验证 + +- 工作区 `pnpm typecheck` 通过;线上基线加设置修改的隔离构建再次通过 client strict typecheck。 +- 8 个相关 Jest 套件、28 项测试通过;工作区和隔离构建均执行通过。 +- 改动的 10 个 client 文件 ESLint 通过;架构守卫、`git diff --check`、工作区 `pnpm check-i18n` 通过。 +- 隔离 client 生产构建成功;部署后客户端、管理端、公开健康接口及六个设置地址均返回 200,实际服务首页与构建指纹一致。 +- 全量 lint 未通过:未修改的 platform 存量问题为 95 个错误,client 存量问题为 22 个错误;未扩大本次修改范围或增加豁免。 +- Jest 首次启动被已安装但未编译的可选 canvas 原生依赖阻塞;按 client AGENTS.md 的既有处理方式修复本地依赖解析后重跑。未修改测试框架配置。 + +## 原生桌面实测 + +沿用当前企业登录态和原生 WebContentsView,读取真实页面及画面,未执行审批决定、撤回、账号变更或退出。 + +| 页面/行为 | 结果 | +| --- | --- | +| 账号与安全 | 原生外框内显示现有账号、头像及安全设置组件;网页不重复展示设置导航或退出入口 | +| 用量与存储 | 显示真实个人存储配额、模型用量热力图及 Token 汇总;就绪后实际显示内容 | +| 我的审批 | `/workspace/settings/my-tasks?dshSettings=1` 加载待处理/已处理、搜索及审批内容;实际 API 返回 200 | +| 我的申请 | 原生标签切换至 `my-requests`,显示真实申请列表、详情和审批进度 | +| 通知 | 原生标签切换至 `notifications`,显示现有通知、所有/未读及搜索入口 | +| 关闭设置 | 点击原生关闭按钮后,设置外框消失,恢复进入设置前的 `/workspace/c/new` 及原聊天输入框 | +| 外观 | 深色原生外框内的账号、申请和通知内容跟随现有主题桥接显示;浅色用量内容亦已实测 | + +普通浏览器保持原设置页的隔离条件、语言桥接和个人 AI 接入开关由自动回归覆盖;个人 AI 接入开关关闭时不向宿主发布可用入口。Windows、正式安装包及审批写操作不在本次验收范围。 diff --git "a/docs/PRD/2.6 \347\201\265\346\200\235 deepagents \350\277\201\347\247\273 PRD/\347\201\265\346\200\235\344\273\243\347\240\201\346\211\247\350\241\214\346\262\231\347\256\261\351\200\211\345\236\213\350\260\203\347\240\224\357\274\210\347\273\255\357\274\211\357\274\232\346\226\260\345\205\264\346\226\271\346\241\210\344\270\216\350\275\273\351\207\217\351\232\224\347\246\273\350\267\257\347\272\277.md" "b/docs/PRD/2.6 \347\201\265\346\200\235 deepagents \350\277\201\347\247\273 PRD/\347\201\265\346\200\235\344\273\243\347\240\201\346\211\247\350\241\214\346\262\231\347\256\261\351\200\211\345\236\213\350\260\203\347\240\224\357\274\210\347\273\255\357\274\211\357\274\232\346\226\260\345\205\264\346\226\271\346\241\210\344\270\216\350\275\273\351\207\217\351\232\224\347\246\273\350\267\257\347\272\277.md" new file mode 100644 index 0000000000..dd795f1d3a --- /dev/null +++ "b/docs/PRD/2.6 \347\201\265\346\200\235 deepagents \350\277\201\347\247\273 PRD/\347\201\265\346\200\235\344\273\243\347\240\201\346\211\247\350\241\214\346\262\231\347\256\261\351\200\211\345\236\213\350\260\203\347\240\224\357\274\210\347\273\255\357\274\211\357\274\232\346\226\260\345\205\264\346\226\271\346\241\210\344\270\216\350\275\273\351\207\217\351\232\224\347\246\273\350\267\257\347\272\277.md" @@ -0,0 +1,470 @@ +# 灵思代码执行沙箱选型调研(续):新兴方案与轻量隔离路线 + +> **调研日期**:2026-09-15 +> **前篇**:[灵思代码执行沙箱选型调研(私有化场景)](./灵思代码执行沙箱选型调研(私有化场景).md)(2026-08-11)。前篇结论:E2B、Daytona、LangSmith Sandbox 出局;推荐基线为「独立容器 + compose 原生加固」;硬隔离需求叠加 gVisor。 +> **本篇范围**:agent-infra/sandbox、CubeSandbox、microsandbox、Clone、sandlock、OpenHands runtime、gVisor、V8 Isolate / Wasm、nsjail / bubblewrap、自研路线 +> **关注维度**:① 本地私有化部署与信创适配 ② 资源消耗与速度(能否提升任务模式运行速度)③ 开源许可证的商用友好性 +> **方法**: +> - deep-research 工作流:5 个检索方向,抓取 24 个来源,抽出 120 条论断,其中 25 条做了三票对抗核验,21 条成立、4 条否决。 +> - 另派 4 个 agent 定向补核:OpenHands、V8/Wasm、nsjail/bwrap/自研、许可证与 gVisor 落地。 +> - 两路结论冲突的地方,人工复核一手 commit diff。 +> - 所有结论都以一手来源为准(README、LICENSE、源码、release、issue/PR、官方文档)。厂商自测数据会单独标注。 + +**一句话结论**: +- **默认档不变。** 本轮 10 个方向里,没有一个能替代「加固容器」。 +- **升级档仍是 gVisor systrap。** 它是唯一能在「VMware 虚机、无嵌套虚拟化」下提供更强隔离的方案,但 2026-08 起最低内核从 4.14.77 提到 5.6,而且代码层面真的依赖 `openat2`。4.19 客户只能钉旧版本。 +- **新兴 microVM 进不了默认交付形态。** CubeSandbox、microsandbox、Clone 的毫秒级启动全都建立在 KVM 上。 +- **sandlock 不可用。** 内核低于 5.13 时它直接拒绝启动。 +- **任务模式提速不该押在隔离技术上。** 实测瓶颈是 LLM 轮次和镜像工具链缺失。沙箱层真正能拿到的收益来自「会话级常驻容器 + 常驻解释器 + 预热池 + 工具链完整的镜像」,这几项跟用哪种隔离技术无关,在加固容器上就能做。 + +--- + +## 1. 结论先行 + +| 分档 | 方案 | 适用客户 | 一句话理由 | +|---|---|---|---| +| **默认档** | **加固容器**(自研 `ContainerExecutor` 或 llm-sandbox),配合会话级常驻、预热池和收紧的 seccomp profile | 全部,包括 4.19 内核和龙芯 | 唯一不挑内核、架构、虚拟化平台的方案;前篇 POC 已验证 office 链路在最严加固下全绿 | +| **硬隔离升级档** | **gVisor(runsc,systrap 平台)**,通过 compose `runtime: runsc` 或 K8s RuntimeClass 开关 | x86_64 / arm64,且内核 ≥ 5.6(例如 5.10) | 唯一不需要 `/dev/kvm` 的用户态内核隔离;arm64 有性能折损,上线前必须 POC | +| **观察档**(只看不接) | CubeSandbox(KVM 路线)、microsandbox | 物理机或 KVM 系私有云、能开 `/dev/kvm` 的少数客户 | 许可证都友好,但一个还在 0.x 且性能宣称自相矛盾,一个自认 beta | +| **借鉴设计,不集成** | OpenHands runtime、agent-infra/sandbox | — | 前者的会话生命周期管理值得抄;后者的「一体化运行环境」镜像组织方式值得参考。两者隔离都弱于我们的基线 | +| **不推荐** | Clone、sandlock、CubeSandbox PVM 路线、V8 Isolate / Wasm、nsjail/bwrap 替代容器、gVisor KVM/ptrace 平台、Judge0/isolate | — | 分别卡在:KVM + 早期项目、内核 5.13+、要换宿主内核、跑不了原生二进制、要放宽容器、嵌套虚拟化性能差或已停止支持、GPL | + +**龙芯(LoongArch64)客户**:本轮所有隔离增强方案(gVisor、sandlock、各 microVM、workerd、Deno)都**没有官方支持**,只能用加固容器。另外,麒麟/UOS 龙芯版属于「旧世界」ABI,Docker 默认 seccomp 在上面是否真正生效还没有一手证据,必须实机验证(见 §8)。 + +**前篇三处安全缺陷的现状**(2026-09-15 复核 `3.0-vibe` 分支):**三处都还没修**,选型前应先处理。 +- Fernet 密钥仍硬编码在开源代码里:`src/backend/bisheng/core/config/settings.py:22`。 +- 执行器现在**传了** `env=`,但 `_child_env()` 的实现是 `os.environ.copy()`(`bisheng_langchain/gpts/tools/code_interpreter/local_executor.py:180`),子进程仍然全量继承 compose 注入的 `BS_MINIO_ACCESS_KEY` / `BS_MINIO_SECRET_KEY`。**缺陷的实质没有变**。 +- `docker/docker-compose.yml` 里 backend(L111)和 backend_worker(L151)仍然是 `seccomp:unconfined`。 + +--- + +## 2. 判据:BiSheng 交付形态的硬约束 + +| 约束 | 现状 | 对选型的影响 | +|---|---|---| +| 虚拟化 | 以虚机为主,很多是 VMware。Broadcom KB 313547 明确不支持在虚机内跑嵌套 hypervisor | 需要 `/dev/kvm` 的方案不进默认档 | +| 编排 | compose 与 K8s 都要支持,但不能强制客户上 K8s | 只支持 K8s(或 K8s 还是 preview)的方案扣分 | +| CPU | x86_64(含海光/兆芯)、arm64(鲲鹏/飞腾)、LoongArch64(龙芯) | 不支持龙芯的方案不能当唯一方案 | +| 内核 | 麒麟 V10 / UOS 20 常见 4.19、5.10 | Landlock(5.13+)、新版 gVisor(5.6+)受限 | +| glibc | 麒麟 Server V10 SP3 为 2.28(一手:官方源 RPM `glibc-2.28-88.p06.01.ky10`),UOS 20 为 2.28(二手) | 要求 glibc 2.35+ 的预编译二进制(workerd)直接不可用 | +| 网络 | 离线内网 | 安装阶段要联网的方案必须能打离线包 | +| 负载 | Python(带 C 扩展)、LibreOffice、pandoc、PyMuPDF,可能还有 node / playwright;产物要写回宿主 | 不能执行原生二进制的方案直接出局 | + +--- + +## 3. 对比总表 + +图例:✅ 支持 / ⚠️ 有条件 / ❌ 不支持 / ❓ 未核实 + +| 方案 | 隔离边界 | 需要 KVM | VMware 无嵌套虚拟化 | 内核门槛 | x86_64 / arm64 / 龙芯 | 启动与加速手段(数据来源) | 许可证 | 成熟度(2026-09) | 判定 | +|---|---|:--:|:--:|---|---|---|---|---|---| +| **加固容器(基线)** | runc 容器,与宿主共享内核 | 否 | ✅ | 无特殊要求 | ✅ / ✅ / ⚠️ seccomp 在旧世界 ABI 上待实测 | 秒级容器启动;可做会话常驻 + 预热池 | 自有代码;llm-sandbox 为 MIT | llm-sandbox 维护集中在一人(巴士系数 1) | **默认档** | +| **gVisor systrap** | 用户态内核(Sentry) | 否 | ✅(依据平台原理推导,无 VMware 专门实测) | 文档 5.6+(自 `release-20260824.0` 起);4.19 需钉 `release-20260817.0` | ✅ / ⚠️ 无 syscall patch 加速 / ❌ | checkpoint/restore(Modal 实测 restore 快约 2.5 倍);Docker 只能恢复到原容器 | Apache-2.0 | 19.3k★,约每周发一版,**无 LTS** | **硬隔离升级档** | +| agent-infra/sandbox | 普通 Docker 容器(官方示例 `seccomp=unconfined`) | 否 | ✅ | 无 | ❓ | 常驻一体化容器(浏览器/Shell/Jupyter/VSCode/MCP) | Apache-2.0 | 5.9k★,2025-08 创建,活跃 | 参考运行环境组织,**不作隔离方案** | +| CubeSandbox | KVM microVM(RustVMM / cloud-hypervisor / kata 改造) | **是**(PVM 路线不需要,但要换宿主内核) | ❌(PVM 需换 OpenCloudOS 6.x 内核,VMware 上有 panic 报告) | 宿主 glibc ≥ 2.31、XFS reflink;PVM 需定制 6.x 内核 | ✅ / ⚠️ 仅裸金属原生 KVM / ❌ | 资源池 + 快照克隆。README 宣称 <60ms、<5MB;**官方 benchmark 博客**:50 并发平均 276ms、P95 508ms,每个沙箱 21.5–25.7MB | Apache-2.0 主体 + 第三方(BSD / 部分 GPL-2.0 eBPF 源码) | 12.3k★,2026-04 创建,v0.7.1,K8s 为 preview | 观察档(仅限 KVM 客户) | +| microsandbox | libkrun microVM | **是** | ❌ | — | ✅ / ✅(需 KVM)/ ❌ | ❓ 本轮未核实性能数据 | Apache-2.0 | 8.3k★,**自认 beta**,0.6.x 几天发一版 | 观察档(仅限 KVM 客户) | +| Clone | KVM VMM | **是** | ❌ | 推荐 guest 内核 6.5+ | ✅ / ❌ / ❌ | 从预热模板 fork:最小 Alpine <20ms,4GB Ubuntu 约 160ms(**裸金属自测**) | MIT | 421★,2026-03 创建,**0 个 release** | 不推荐 | +| sandlock | Landlock + seccomp,进程级,与宿主共享内核 | 否 | ⚠️ 取决于内核版本 | **5.13 硬门槛**(低于即拒绝启动),完整功能需 6.12 | ✅ / ✅ / ❌(`arch.rs` 无 loongarch,编译不过) | 启动约 5–7ms、COW fork(**自报,各份材料数字不一致**) | Apache-2.0 | 405★,v0.8.7 | 不推荐 | +| OpenHands runtime | 每会话一个 Docker 容器;编排端挂 docker.sock | 否 | ✅ | 无 | ✅ / ✅ / ❌(无 loong64 镜像) | 会话懒启动、超上限暂停最旧沙箱、三级镜像 tag 缓存 | 核心 MIT;enterprise / Cloud 为 **PolyForm Free Trial** | V0 runtime 已于 2026-04 删除,V1 架构重组中 | 借鉴设计,不集成 | +| V8 Isolate / Wasm | V8 isolate / Wasm VM | 否 | ✅ | workerd 预编译包要求 **glibc 2.35+** | ✅ / ✅ / ❌ | Cloudflare 快照:import 从 10s 降到 1s(厂商自报) | workerd ❓;Deno MIT;isolated-vm ISC | isolated-vm 2026-08 出过 Critical 漏洞;langchain-sandbox 已归档 | 不推荐(形态不匹配) | +| nsjail | namespace + cgroup + seccomp(Kafel) | 否 | ✅ | 核心功能无高门槛(按源码推断 4.19 可跑,未实机验证) | ✅ / ✅ / ⚠️ Kafel 需打补丁 | 进程级 | Apache-2.0 | 4.1k★,2026-03 发布 3.5 / 3.6 | 不推荐作默认;作第二层代价高 | +| bubblewrap | user namespace + bind mount + seccomp | 否 | ✅ | 需要能创建 userns | ✅ / ✅ / ✅(麒麟 V10 SP3 源有 0.3.3,版本老) | 进程级 | **LGPL-2.0-or-later** | Codex CLI 的默认 Linux 沙箱 | 同上 | + +--- + +## 4. 逐项结论 + +### 4.1 agent-infra/sandbox(AIO Sandbox):运行环境集成方案,不是隔离技术 + +- **它是什么**:README 写明把 Browser、Shell、File、MCP、VSCode Server、Jupyter 打包进**同一个 Docker 容器**,共享文件系统。浅克隆全仓库 grep `gvisor/runsc/kata/firecracker/microvm/runtimeClass`,**零命中**。K8s 示例是普通 Deployment,没有 `runtimeClassName`。「cloud-native lightweight sandbox」只是宣传用语。 +- **隔离比我们的基线弱**:README 的 4 个 `docker run` 示例、Compose 示例和仓库自带的 `docker-compose.yaml` **全部带 `seccomp=unconfined`**,理由是给 Chromium 自己的 sandbox 用。`security.md` 把它定位为 trusted local development。如果要接入,必须自己写 seccomp profile 并补上 cap_drop 等加固。 +- **部署**:普通 Docker 即可,不依赖 KVM 或 privileged。这一点从部署方式推出,文档没有明说。多架构镜像情况本轮未核实。 +- **许可证与成熟度**:Apache-2.0,5,907★,2025-08 创建,最近 push 2026-09-14,release 1.11.0。维护方背景的推断在核验中被否决,**不要写成定论**。 +- **对 BiSheng 的价值**:「一个会话一个一体化运行环境」的镜像组织方式可以参考,比如浏览器、Jupyter 内核、文件服务共存。**不能**拿它替代加固基线。 +- 来源:https://github.com/agent-infra/sandbox + +### 4.2 CubeSandbox(腾讯云):所有部署路径都离不开 KVM + +- **架构**:KVM microVM,基于 RustVMM,改造自 kata-containers 和 cloud-hypervisor,宣称兼容 E2B SDK。roadmap 仍写着「Close remaining gaps」,**兼容并不完整**。 +- **部署依赖**: + - 标准部署要 `/dev/kvm` 可读写,也就是物理机或开了嵌套虚拟化的虚机。 + - 必须 root 加 Docker,控制面还要 MySQL、Redis、MinIO、CoreDNS 等组件。 + - 宿主要求 glibc ≥ 2.31 和 XFS reflink。 + - K8s 部署(Helm chart)标为 **preview**。 +- **PVM 路线**(README 标为「推荐」,不需要 `/dev/kvm`): + - 要装定制宿主内核 `kernel-*opencloudos9.cubesandbox.pvm.host*` 并重启,再 `modprobe kvm_pvm`。PVM 是没进主线的 out-of-tree 补丁,**麒麟/UOS 自带的 4.19 / 5.10 内核里不可能有**。 + - v0.5.0 release notes 原文:「PVM (nested KVM) is x86_64-only; ARM64 requires bare-metal with native KVM」。 + - 文档里没有任何 VMware 验证。Issue #1722(2026-09-11,open,维护者未回复)报告在 VMware Workstation 上装定制内核后 OOM kernel panic;Issue #161 报告 AWS C8i 装 PVM 内核后起不来。 +- **离线**:部署文档前置条件写着 Internet access。one-click 离线包可以手动下载,`deploy/one-click/env.example` 支持把镜像指向私有仓库或 `@sha256`,但**没有完整的官方离线安装指南**。 +- **架构支持**:物理机文档写 x86_64 / aarch64,但 `online-install.sh` 只认 x86_64 包,ARM64 要手动装。LoongArch 未被提及,release 产物只有 amd64 / arm64。 +- **性能宣称自相矛盾**,这是本轮最需要警惕的一点: + - README 原文是「under 60ms with less than 5MB of memory overhead」。实现方式是「Resource pooling and snapshot cloning skip all cold-start overhead」,即**资源池预置 + 快照克隆,不是真正的冷启动**。脚注注明裸金属测试:单并发 60ms;50 并发平均 67ms、P95 90ms、P99 137ms;内存数据仅在沙箱规格 ≤ 32GB 时成立。 + - 官方自己的 benchmark 博客(`docs/blog/posts/2026-06-01-cubesandbox-perf-benchmark.md`):BMI5 裸金属 96 核 / 375GiB,沙箱规格 2vCPU / 2GiB。串行平均 47.8ms;**50 并发平均 276.1ms、P95 508ms**;**每个沙箱内存约 21.5–25.7MB**;报告里找不到 P99 137ms。 + - ⇒ **README 的并发与内存数字无法被自家报告复现**,引用时只能用博客数据,并注明是裸金属。 +- **许可证**:根 LICENSE 主体 Apache-2.0,并列出第三方组件:kata / cloud-hypervisor 为 Apache-2.0(腾讯修改);cilium/bpf 原为 BSD-2 / GPL-2.0 双许可,声明选用 BSD-2。`hypervisor/` 另带 BSD-3-Clause。代码搜索 GPL-2.0 命中 24 个文件(主要是 CubeNet eBPF 源码),**未逐一核对 SPDX 头**;guest 内核镜像未列入 LICENSE。商用分发前需要法务做一次 SPDX 审计。 +- **成熟度**:12,269★,2026-04-10 创建,v0.7.1(2026-09-11),未到 1.0。 +- **判定**:**观察档**,仅面向能提供原生 KVM 的物理机客户,而且要等 K8s 转正、性能数据口径统一后再评估。**PVM 路线不推荐**:要替换客户宿主内核,这在信创项目里基本不可能过审。 +- 来源:https://github.com/TencentCloud/CubeSandbox 、https://cubesandbox.com/guide/pvm-deploy + +### 4.3 microsandbox:KVM 硬依赖,beta + +- README 的 Requirements 明写「Linux: KVM enabled」,因此没有嵌套虚拟化的 VMware 虚机跑不了。 +- 最新 v0.6.18(2026-09-09)的 Linux 产物只有 x86_64 和 aarch64;仓库 issue 与代码里搜 `loongarch` 结果为 0。 +- Apache-2.0;README 原文「still beta software. Expect breaking changes」。8,252★,0.6.x 补丁版近一个月每 1–6 天发一版。 +- 存疑:「是否存在非 KVM 兜底」的核验论断被 0-3 否决,推测是 issue 回复细节有误,但「Linux 上要 KVM」由 README 独立支撑。 +- 前篇已指出商业化风险:Super Rad Company 的云服务在闭测,形态与转闭源前的 Daytona 相似;guest kernel 作为 release asset 分发,上游停发即无法离线部署。 +- **判定**:观察档,仅限 KVM 客户。 +- 来源:https://github.com/superradcompany/microsandbox + +### 4.4 Clone(unixshells/clone):fork 很快,但太早期 + +- README 写明「KVM-based」,要求「Linux host with KVM (/dev/kvm)」「Kernel 6.5+ recommended」(严格说是 e2e 测试用的 guest 内核)。 +- 从 x86 PCI MMIO hole、bzImage 加载、`--target x86_64-unknown-linux-musl` 等细节看,**事实上只做了 x86_64**。 +- 速度数据来自 README 自测,测试机是 OVH 独服(Xeon E-2386G、Ubuntu 24.04、kernel 6.8): + - 冷启动约 2.2s,最好成绩 2,217ms,5 次平均 2,338ms; + - 从预热模板 fork 最小 Alpine(128–512MB、无网络)<20ms; + - fork 带网络的 4GB Ubuntu 约 160ms。 + - fork 数据没给运行次数和分布,**最小模板的数字不能代表带 LibreOffice / Python 的重模板**。 +- 许可证 MIT(Copyright 2026 Unix Shells Limited Company)。2026-03-16 创建,421★,默认分支 25 个 commit,**0 个 release、无 tag**。README 把 MSI-X、SR-IOV、TDX/SEV、qcow2 per-fork overlay 列为 Needs work。 +- **判定**:不推荐。KVM、仅 x86_64、项目太早期,三条各自都足以否决。 +- 来源:https://github.com/unixshells/clone + +### 4.5 sandlock:信创内核上完全不可用 + +- **机制**:Landlock(文件系统 + 网络 + IPC)+ seccomp-bpf + seccomp user notification。「No root, no cgroups, no containers」,对比表 Kernel 一栏写 Shared。论文 arXiv 2605.26298 把内核漏洞列在威胁模型之外,并说对抗性多租户场景仍应使用 microVM。 +- **内核门槛**:完整功能要 6.12+(Landlock ABI v6)。分项最低要求:seccomp notify 5.6、Landlock FS 5.13、TCP 规则 6.7、IPC scoping 6.12。 +- **没有降级路径**(源码 HEAD f58072c9e,2026-09-12):默认 Strict 模式缺保护就拒绝启动。`Protection` 枚举只覆盖 ABI v2–v6,基础 Landlock FS(ABI v1)不能关闭;`landlock.rs` 里 `abi_version()` 失败直接返回 `LandlockUnavailable`,`context.rs` 随即失败。⇒ **5.13 以下内核上 `allow_degraded` 也救不回来**。 +- Landlock 本身还要求 `CONFIG_SECURITY_LANDLOCK=y` 并在 `lsm=` 中启用(内核文档原文「first introduced in Linux 5.13」)。没有搜到麒麟、UOS 或 openEuler 4.19 回移 Landlock 的证据。 +- **架构**:`crates/sandlock-core/src/arch.rs` 只定义了 x86_64 / aarch64 / riscv64,没有兜底分支,**龙芯上编译不过**。另提供 `sandlock-oci` shim,可接 containerd / CRI-O / K8s。 +- **性能**(自报,不可信):README 性能表写 `/bin/echo` 启动 7ms,顶部表格写 5ms;COW fork 1000 个 README 写约 530ms,官方博客写 718ms;论文里的 Redis 数字也和 README 不同。论文测试机是 Ryzen 5 5500U 笔记本 + Linux 6.18。 +- 许可证 Apache-2.0,405★,v0.8.7(2026-09-04)。 +- **判定**:不推荐。4.19 / 5.10 内核直接无法启动,而且共享内核也算不上硬隔离。 +- 来源:https://github.com/multikernel/sandlock 、https://docs.kernel.org/userspace-api/landlock.html + +### 4.6 OpenHands runtime:值得借鉴设计模式,不适合直接集成 + +**先纠正一个前提**:网上大多数资料描述的 V0 runtime **已不存在**。PR #14117(2026-04-24)删除了 `openhands/runtime`;2026-07-27 主仓清空,改为 TS 前端「Agent Canvas」(#15397);Python 服务端拆到 `OpenHands/sandbox-server` 和 `OpenHands/enterprise`。下文 V0 行号取删除前 commit `137bede1`。 + +- **V0 形态**: + - 用 `docker.from_env()` 起普通容器,不涉及 KVM。容器按会话常驻,容器名为前缀加 sid。backend 通过 `POST /execute_action` 下发 action;容器里有 bash 会话、Jupyter 和 VSCode 插件。 + - `impl/` 下有 docker / local / remote / **kubernetes** / cli 五种 runtime。 + - **Remote runtime 依赖闭源 Runtime API**:文档写明仅用于评测、需填表申请,源码不公开,Helm chart 在 OpenHands-Cloud 仓(PolyForm Free Trial),**不能按开源方式自托管**。 + - 三级镜像 tag(source / lock / versioned)做构建缓存,支持自定义 `base_container_image`。 +- **会话生命周期的演进**: + - PR #6403(2025-01-23 合并)把闲置容器从 remove 改为 stop,`keep_runtime_alive` 默认改为 False;V0 后来又加了 `pause_closed_runtimes=True`。 + - V1 sandbox-server 的 `max_num_sandboxes` 默认 **5**,超出时暂停最旧的沙箱(`docker_sandbox_service.py#L600-L603@f19f9e0d`)。常被引用的「3」是 `max_concurrent_conversations`,不是沙箱数。 + - Issue #9156 提出的 none / pause / keep_alive 复用策略没有实现,2026-05-21 以 not_planned 关闭。「每会话启动 30–60s」是提案作者的估计,不是实测。 +- **「只把执行放进容器」的尝试失败了**:PR software-agent-sdk#4883 由 neubig 本人开,09-06 标 ready,09-07 自己转回 Draft。 + - 设计:可信的外层 agent-server 保留 agent loop 和凭据,只有 terminal / file_editor / grep / glob / apply_patch 通过 RPC 在每会话一个的容器里执行。 + - docker run 参数:cap-drop ALL、no-new-privileges、tmpfs /workspace、tmpfs /tmp 512m、pids-limit 512,端口只绑 127.0.0.1;**没有 `--network none`、内存 / CPU 限额和 `--read-only`**(`execution_runtime/workspace.py#L297-L331@7096d432`)。 + - 该模式直接拒绝 skills、memory、plugins、hooks、MCP、自定义工具(`#L215-L232`、`#L280-L295`)。维护者的结论是更倾向「每会话一个完整 Agent Server 容器」。 + - **对 BiSheng 的启示**:任务模式要在沙箱里读 SKILL.md、跑 skill 脚本,「只把单个工具 RPC 转发进沙箱」会撞上同样的能力缺口。**沙箱里要能看到完整工作区与技能包**,这印证了前篇「技能包挂载时序是一等公民」的判断。 +- **V1 主线的加固**: + - SDK `DockerWorkspace` 默认几乎不加固,只有 `--rm` 和 `--ulimit nofile`。 + - sysbox-runc / gVisor 通过 RuntimeClass 选择的加固,**只在商业版 K8s remote 路径里有**。 + - 编排端 `docker-compose.yml` 挂了 `/var/run/docker.sock`,沙箱容器本身不挂。 +- **许可证**:software-agent-sdk 与主仓为 MIT;`OpenHands/enterprise`、OpenHands-Cloud 为 **PolyForm Free Trial 1.0.0(禁止分发)**,典型 open-core。MIT 部分保留版权声明即可,**不能拷贝 enterprise / Cloud / runtime-api 的任何代码或 chart**。 +- **架构**:`agent-server`、`runtime`、`runtime-api` 镜像在 GHCR 上只有 amd64 / arm64。 +- **直接集成的阻碍**: + - V1 agent-server 自带完整 agent loop,与 deepagents 重叠; + - SDK 要求 Python ≥ 3.12,BiSheng 是 3.11,而且依赖 litellm、fastmcp、browser-use 等重型包; + - 编排端需要 docker.sock; + - 加固路径在商业许可部分; + - 无 loong64 镜像。 +- **值得抄的设计**: + - 会话级懒启动容器; + - 超上限暂停最旧的沙箱; + - 容器打 server label,启动时清扫孤儿容器; + - 三级镜像 tag 缓存; + - 端口只绑 loopback,每个 workspace 一个 capability token,token 写入 0600 权限的 env 文件; + - 用 RuntimeClass 做可切换的加固开关。 +- 来源:https://github.com/OpenHands/OpenHands/blob/137bede1/openhands/runtime/ 、https://github.com/OpenHands/software-agent-sdk/pull/4883 、https://github.com/OpenHands/sandbox-server 、https://github.com/All-Hands-AI/OpenHands/pull/6403 、https://github.com/OpenHands/OpenHands/issues/9156 + +### 4.7 gVisor:仍是唯一的硬隔离升级档,但门槛比前篇写的高 + +**平台选择** +- systrap 自 2023 年中取代 ptrace 成为默认平台。它用 seccomp `SECCOMP_RET_TRAP` 让内核发 SIGSYS,再交给 Sentry 处理。官方博客原文:「Systrap runs on most Linux machines out of the box without virtualization」。platforms 文档写明,虚机内和没有虚拟化支持的机器上 systrap 是更好的选择。 +- KVM 平台:「good performance when using bare-metal virtualization, but has a noticeable impact with nested virtualization」,performance 文档写的是「runs poorly with nested virtualization」。在没有 `/dev/kvm` 的客户虚机里根本起不来。 +- ptrace 平台:「no longer supported and is expected to eventually be removed」,**不能当备选**。 +- 能跑在 VMware 虚机里是从「不依赖 `/dev/kvm`」推出来的,**官方没有 VMware 专门实测**。 + +**内核门槛(订正前篇)** +- 官方安装页现在写「gVisor supports x86_64 and ARM64, and requires Linux 5.6+」。 +- 门槛来自 commit [a7402ea5de](https://github.com/google/gvisor/commit/a7402ea5de253311ea085d054fc8540eb26af4be)(2026-08-18,标题「Guarantee that `getParentFD()` doesn't follow symlinks」)。**人工复核 diff 确认这不是纯文档改动**:`runsc/fsgofer/lisafs.go` 新增 `unix.Openat2(..., RESOLVE_NO_SYMLINKS)`,`runsc/fsgofer/filter/config.go` 在 gofer 的 seccomp 白名单里加入 `SYS_OPENAT2`,diff 里没有 ENOSYS 回退分支。`openat2` 是 5.6 才有的系统调用,因此**新版本在 4.19 内核上走到这条 gofer 路径会失败**。directfs 默认开启时有多少操作会走到这里,本轮未核实。 +- 第一个包含该 commit 的版本是 `release-20260824.0`;最后一个文档仍写 4.14.77+ 的是 `release-20260817.0`。 +- ⇒ **4.19 客户若要用 gVisor,只能钉 `release-20260817.0` 或更早,而且放弃后续安全修复**。考虑到 gVisor 没有 LTS、官方推荐 latest,这在长期维护上是明显负担。 +- 社区文章称 `release-20250331.0` 在银河麒麟 V10、4.19.90 x86_64 上配置成功,但**没有容器实际运行输出**,只能当弱证据。 + +**架构** +- 官方只支持 x86_64 和 ARM64,`pkg/sentry/arch` 下只有 amd64 / arm64。 +- **arm64 缺少 systrap 的主要加速手段**:x86_64 上会把 syscall 指令改写成跳到 trampoline;arm64 的 `usertrap_arm64.go` 里 `PatchSyscall()` 直接 `return false`,到 2026-08-27 仍未补上。「arm64 更慢」是由此推断,没有 arm64 benchmark。 +- arm64 已知问题:#10487(open)Graviton2 上 CPU 密集负载变慢,维护者认为和 SSBS 位有关;#10900(已修)systrap 在 5.4 老内核的 c7gd 上 syscall 返回值出错。 +- **鲲鹏 920 / 飞腾没有找到任何人跑过 gVisor 的记录**。 +- LoongArch64:社区移植 PR #13332(ptrace 平台,目标银河麒麟 V11)因没签 CLA 被关,未合入。 + +**LibreOffice / Chromium 兼容性** +- **Dangerzone 是最接近我们场景的一手实现**: + - 外层 Podman / Docker 容器:`--cap-drop all --cap-add SYS_CHROOT`、`no-new-privileges`、`--userns nomap`、`--network=none`,外加一份放行 ptrace 的自定义 seccomp(`dangerzone/isolation_provider/container.py` 的 `get_runtime_security_args`)。 + - 内层:`runsc --rootless=true --network=none --directfs=false run`,OCI 配置里 capability 全空、rootfs 只读(`dangerzone-image/src/helpers/entrypoint.py`)。 + - LibreOffice 和 runsc 装在同一个 Debian 镜像里。 + - ⚠️ **Dangerzone 两个仓库都是 AGPL-3.0**,只能借鉴做法,**不能拷贝代码或镜像脚本**。 +- Chromium / playwright:官方 compatibility 页面没有条目。gVisor 官方博客的 MAGI 多 agent 示例在 gVisor 里跑 Chrome,配置 `"noSandbox": true`;issue #7416 显示有人在生产环境用 gVisor 跑 Chromium。「为什么必须 `--no-sandbox`」只找到二手解释。 + +**性能** +- 官方 performance 页**已过时**:测的是 ptrace 平台、n1-standard-4 虚机,文档自己声明不能代表 systrap,**不宜拿来判断**。其中通过 Docker 启动空容器 runsc 1,144ms、runc 1,193ms,说明启动时间主要耗在 Docker 本身。 +- directfs(现为默认):stat 快 2 倍以上,真实负载快约 12%,Ruby 加载快约 17%。rootfs 默认 `--overlay2=root:self`,写入落在文件支撑的 tmpfs 上,不占内存。 +- **systrap 下 runsc 冷启动与 IO 开销没有一手数值**,必须 POC 实测。soffice 转换和 Python 大量 import 这两类负载 IO 和 syscall 都密集,是最可能变慢的地方。 + +**接入与离线** +- Docker:`sudo runsc install` 写 `/etc/docker/daemon.json`,compose 里写 `runtime: runsc`。 +- K8s:containerd 配 `runtime_type = "io.containerd.runsc.v1"`,RuntimeClass 写 `handler: runsc`。 +- 离线:发布包含 `runsc`、`containerd-shim-runsc-v1` 和 `gvisor-bin/`。`gvisor-bin/` 是 2026-07 起新增的 sidecar 目录,**必须和 runsc 放在一起**;缺文件时 `runsc install` 会尝试联网下载,离线包必须带全。 +- 权限:安装需要 root。rootless Docker 至今不可用(issue #12575 未关);`--rootless` 基本只适用于 `runsc do`,而且不支持 checkpoint/restore。 + +**checkpoint/restore 能否用于提速** +- 能力:`runsc restore --background` 可以先恢复内核状态让程序跑起来,内存在后台加载;应用可通过 `/proc/gvisor/checkpoint` 在自选时机触发快照。但 **Docker 只能恢复到原来那个容器,不能恢复成新容器**。 +- 生产用例: + - Modal 基于 runsc,restore 比正常启动约快 2.5 倍,`import torch` 从约 5s 降到 p50 1.05s; + - GKE Pod snapshots 必须开 GKE Sandbox,恢复要求相同机型系列和 CPU 架构。 +- 对 BiSheng:可行,但需要自研编排层,而且和 compose 形态不合,**近期不建议投入**。 + +**许可证与维护**:Apache-2.0,19,319★,按日期发版(`release-YYYYMMDD.N`,约每周一版),**无 LTS**。 + +- 来源:https://gvisor.dev/docs/user_guide/install/ 、https://gvisor.dev/docs/architecture_guide/platforms/ 、https://gvisor.dev/blog/2023/04/28/systrap-release/ 、https://gvisor.dev/docs/architecture_guide/performance/ 、https://gvisor.dev/blog/2024/09/23/safe-ride-into-the-dangerzone/ 、https://github.com/freedomofpress/dangerzone 、https://modal.com/blog/mem-snapshots 、https://docs.cloud.google.com/kubernetes-engine/docs/concepts/pod-snapshots + +### 4.8 V8 Isolate / WebAssembly:形态不匹配,不推荐 + +**三项硬需求都不满足** +- **原生二进制**:Wasm 和 V8 里都无法 fork/exec。CPython 官方文档原文「The `subprocess` is importable but does not work」,Pyodide 的 multiprocessing、threading、sockets 能 import 但不能用,所以 soffice、pandoc、playwright 执行不了。 +- **带 C 扩展的 Python 包**: + - Pyodide 314.0.7 自带 pandas、lxml、numpy、Pillow。 + - openpyxl、python-docx、python-pptx 不在自带列表里,但它们是纯 Python wheel,理论上可用 micropip 装(**推断,未实测**)。 + - **PyMuPDF 官方文档写明 Pyodide 构建「currently experimental」「micropip.install() does not work」**;PyPI 上的 `pyemscripten_2025_0` wheel 与 Pyodide 314 的 `2026_0` ABI 能否兼容不确定。 +- **产物写回宿主**:langchain-sandbox README 原文「You will not be able to access the files written by the sandbox」。其他方案要写回就得挂宿主目录,隔离随之打开。 + +**自认不是安全边界** +- workerd README:「WARNING: `workerd` is not a hardened sandbox」,运行可能恶意的代码时「must run it inside an appropriate secure sandbox, such as a virtual machine」;Cloudflare 托管服务额外的纵深防御不在开源版里。 +- Deno 官方文档:权限在 JS 层实施;`--allow-run` 派生的子进程「runs … outside the sandbox」;`--allow-ffi` 可绕过权限检查。不可信代码建议再叠 chroot / cgroups / seccomp 或 VM / gVisor / Firecracker。 +- isolated-vm:2026-08-07 发布 **Critical** 漏洞 GHSA-864f-rcv7-6rh4(ExternalCopy type confusion,沙箱内代码可破坏宿主内存、劫持控制流),影响 ≤ 7.0.0,修复于 7.0.1 / 6.2.0。根因在 C++ 胶水层,V8 isolate 边界本身没被攻破。README 自称「maintenance mode」,许可证 ISC。 + +**信创不兼容** +- workerd 最新版 v1.20260914.1 的 Linux 预编译包只有 x86-64 / arm64,**要求 glibc 2.35+**;麒麟 V10 SP3、UOS 20 都是 2.28,直接跑不起来。 +- workerd、Deno(v2.9.6)、Wasmtime(v48)都**没有 LoongArch 官方构建**。 + +**LangChain 自己已放弃这条路线**:langchain-ai/langchain-sandbox(Pyodide + Deno)于 **2026-01-14 归档**,README 写明不建议用于生产,改推 sandbox API 或 LLM 厂商 API;每次运行启动有「few seconds of latency」。 + +**WASI 路线** +- CPython `wasm32-unknown-wasip1` 是 Tier 2 平台,但 PEP 816 只支持 WASI 0.1,线程要等 WASI 0.3.1,subprocess 同样不可用;没有标准化的 WASI 版 C 扩展分发渠道。 +- Wasmtime 能用 `--dir` 按目录授权读写宿主文件,这点比 Pyodide 顺手。 +- Wasmer 靠非标准的 WASIX 支持 numpy、pandas,但没有 LibreOffice 和 PyMuPDF。 + +**局部适用的子场景**:只跑纯 Python 或 pandas 计算片段、不需要子进程和网络时,Pyodide 或 Wasmtime 可以当低成本前置执行器。但它**替代不了主沙箱**,而多维护一套执行后端的成本明显高于收益,**不建议做**。 + +- 来源:https://github.com/cloudflare/workerd 、https://docs.deno.com/runtime/fundamentals/security/ 、https://github.com/pyodide/pyodide/blob/main/docs/usage/wasm-constraints.md 、https://pymupdf.readthedocs.io/en/latest/pyodide.html 、https://github.com/langchain-ai/langchain-sandbox 、https://github.com/laverdet/isolated-vm/security/advisories/GHSA-864f-rcv7-6rh4 、https://blog.cloudflare.com/python-workers-advancements/ 、https://peps.python.org/pep-0816/ 、https://update.cs2c.com.cn/NS/V10/V10SP3/os/adv/lic/base/x86_64/Packages/ + +### 4.9 nsjail / bubblewrap:适合当「容器内第二层」,但代价是放宽容器 + +**基本事实** +- **nsjail**:只用 namespaces、cgroup(v1 / v2)、rlimits 和 Kafel seccomp-bpf,不依赖 KVM。Apache-2.0,README 末尾注明「This is not an official Google product」。3.5(2026-03-05)、3.6(2026-03-18),最近 push 2026-08-27。 + - README 没有统一的最低内核版本,只有按功能的门槛:cgroup ns 4.6、`SECCOMP_FILTER_FLAG_LOG` 4.14、time ns 5.3、core scheduling 5.14;新 mount API 要 6.3,低于 6.3 自动走 legacy 路径。**按源码推断 4.19 能跑核心功能,未实机验证**。 +- **bubblewrap**:`bubblewrap.c` 头部标 `SPDX: LGPL-2.0-or-later`。按独立二进制 exec 调用时,调用方代码不被传染;**但随私有化包分发 bwrap 本身时要附许可证并提供源码**,改过 bwrap 还要公开改动。0.12.0 已移除 setuid 模式。 +- **OpenAI Codex CLI 生产实践**(`codex-rs/linux-sandbox/README.md`): + - 「Bubblewrap is the default filesystem sandbox」,Landlock 降为「explicit legacy fallback」; + - 需要能创建 user namespace,不能创建时启动阶段就告警; + - 系统里没有 bwrap 时回退到随包自带的二进制; + - 受限容器里可用 `--no-proc`。 + - ⇒ 生产级 agent 沙箱**可以不依赖 Landlock 和它的 5.13 门槛**。 + +**关键冲突:和加固容器叠加时,必须先放宽容器** +- Docker 默认 seccomp profile(一手 `moby/profiles/seccomp/default.json`)中,`unshare`、`setns`、`mount` 以及带 namespace 标志(掩码 `0x7E020000`)的 `clone`,**只有容器拥有 CAP_SYS_ADMIN 时才放行**;`clone3` 直接返回 ENOSYS。 +- 需要纠正一个常见误解:`cap_drop: ALL` 本身并不禁止创建非特权 userns。真正挡住的是三样东西:**默认 seccomp、docker-default AppArmor、runc 对 /proc 的屏蔽**。 +- bwrap 在没有特权的 Docker 里会报「No permissions to create new namespace」,维护者表态这不是 bwrap 的 bug(containers/bubblewrap#505)。 +- nsjail 的 README Docker 示例直接用 `--privileged`,不开特权要么 root 运行加 `--disable_clone_newuser`,要么宿主开启非特权 userns。注意 `kernel.unprivileged_userns_clone` 是 Debian/Ubuntu 补丁参数,主线和 RHEL 系用 `user.max_user_namespaces`。 +- Coder 文档里「至少需要 CAP_NET_ADMIN」说的是它自家 Agent Firewall 模式(要配 veth + iptables),不是 nsjail 的通用要求;CAP_SYS_ADMIN 只建议 testing 用。 +- ⇒ 在容器里套 nsjail / bwrap,要么给 CAP_SYS_ADMIN,要么自定义 seccomp 加 AppArmor 放开 namespace 相关调用。**这等于先削弱外层**;而放开 userns 会把一大块内核代码暴露给非特权进程,历史上很多提权 CVE 都要先创建 userns。 + +**信创可得性** +- 麒麟 Server V10 SP3 软件源里 x86_64 / aarch64 / loongarch64 三个架构**都有 `bubblewrap-0.3.3`**(版本很老,不支持 `--argv0`);loongarch64 还有 `libseccomp-2.5.0`(上游 2.6.0 才支持 LoongArch,应是厂商补丁版)。 +- **nsjail 在龙芯上不能直接编译**:Kafel 的 `src/common.h` 在未知架构上直接 `#error "Unsupported architecture"`,也没有 loongarch 的 syscall 表。 +- 麒麟桌面版源返回 403、UOS 源返回 401;两者的 `max_user_namespaces` 默认值查不到一手来源,**只能到目标机上 `sysctl` 实测**。 + +**LoongArch 上的 seccomp** +- 主线内核 v5.19 合入 LoongArch 时已有 `HAVE_ARCH_SECCOMP_FILTER`;libseccomp 2.6.0(2025-01-23)加入 LoongArch;moby/profiles 于 2025-10-15(commit 8a3110624)加入 `SCMP_ARCH_LOONGARCH64`,moby/moby PR #45302 仍 open。 +- **风险**:Gentoo wiki 指出麒麟 / UOS 的龙芯版属于「旧世界」ABI,与上游「新世界」不兼容。4.19 厂商内核上 seccomp 和 Docker 默认 profile 是否真的生效,**必须实机验证**。 + +**判定** +- ① **不作为容器替代**:替代容器意味着宿主机跑 root 守护进程,或 K8s 用 privileged Pod,破坏交付形态。 +- ② **不建议作为容器内第二层**:如果坚持一次会话一个容器,更划算的做法是**直接收紧容器自身的 seccomp profile**(在默认 profile 基础上再禁掉 ptrace、bpf、io_uring、keyctl 等),不必套娃。套娃只在「一个容器复用给多个任务,或和 Agent 凭据放在同一容器」时才有收益,而这两种设计本身就该避免。 +- ③ **对 4.19 内核加龙芯客户,基本多不出有效隔离层**:namespace 和 seccomp 与容器共用同一内核,内核被提权时两层一起失守;老内核上开 userns 反而扩大攻击面。 + +- 来源:https://github.com/google/nsjail 、https://github.com/containers/bubblewrap 、https://github.com/openai/codex/blob/main/codex-rs/linux-sandbox/README.md 、https://github.com/moby/profiles/blob/main/seccomp/default.json 、https://github.com/containers/bubblewrap/issues/505 、https://update.cs2c.com.cn/NS/V10/V10SP3/os/adv/lic/base/loongarch64/Packages/ 、https://github.com/google/kafel/blob/master/src/common.h 、https://github.com/seccomp/libseccomp/blob/main/CHANGELOG 、https://github.com/moby/moby/pull/45302 + +### 4.10 自研路线:就是默认档的实现方式,风险在工程细节 + +「自研」在本篇里指「在加固容器之上自建 code-runner」,也就是前篇路径 A(约 20–30 人天)。本轮补充了现成实现的核验和风险清单。 + +- **llm-sandbox(vndee/llm-sandbox)**(源码核实 HEAD `eedb05b`,2026-09-14):MIT,后端支持 Docker / Kubernetes / Podman。能力齐全,但**默认值、池、常驻会话都和我们的加固要求冲突**,不能开箱即用: + - **维护**:1,120★;最新 release 0.3.44(2026-08-03);vndee 一人 553 次提交,排第二的是 dependabot(29 次),巴士系数 1 不变。 + - **前篇未决两项已从源码确认**(未实测): + - `runtime_configs` 通过 `container_config.update(...)` 原样传给 docker-py(`docker.py:383-384`),`runtime="runsc"`、`cap_drop`、`network_mode`、`read_only`、`mem_limit`、`pids_limit` 都能透传; + - `copy_to_runtime` 只检查容器存在(`core/mixins.py:173-176`),**session 打开后可随时调用**,技能包挂载时序可控。 + - **默认不安全**: + - Docker 创建容器写死 `"user": "root"`,不加任何限制;K8s 默认 Pod 为 `runAsUser: 0`(`kubernetes.py:447-456`)。 + - 「严格模式」PR #169 自 2026-05 起一直 open 未合入;K8s 的 SA token 自动挂载与出网问题(#170)也 open。 + - **`security` 模块不是隔离手段**:它是正则代码扫描策略(`SecurityPolicy`)加包名校验。另有人于 2026-08-08 称在 `SecurityPolicy` 扫描路径发现漏洞、等待私下披露(#210)。 + - **与非 root + cap_drop ALL 可能不兼容**:非 root 时,复制文件后它以 root `chown -R` 且不检查返回值(`docker.py:211-214`)。去掉所有 capability 后 root 无 CAP_CHOWN,推断会静默失败,导致非 root 用户写不了复制进去的文件,**需实测**。 + - **`InteractiveSandboxSession`(常驻 IPython)**: + - `open()` 无条件执行 `pip install ipython`(`interactive.py:292-301`),断网环境必须把 ipython 预装进镜像(推断已安装时 pip 不联网)。 + - 执行采用文件投递:`put_archive` 写入命令 JSON,每 0.1s 一次 `docker exec test -f` 轮询结果,再取回、删除(`interactive.py:381-414`)。每次调用都有若干次 docker exec 开销。 + - 默认单次超时 300s。 + - **容器池**: + - 默认 `max_container_uses=None`、`max_container_lifetime=3600`,归还时只判断是否过期,**不清理工作区**(`pool/base.py:278-309`)。⇒ 默认跨任务复用会有残留,必须设 `max_container_uses=1`。 + - 常驻会话不支持池化(`interactive.py` 里没有任何池相关逻辑,`PooledSandboxSession` 只提供 run / execute_command / copy)。⇒ **预热池和常驻解释器不能同时开箱即用**。 + - **结论**:前篇估算的「用 llm-sandbox 可省 5–8 人天」偏乐观,加固参数、池策略、常驻会话与池的组合、断网依赖都要自己补。**倾向自研 `ContainerExecutor`**,并借用其 MIT 源码中的文件复制与 IPython runner 实现,不把它作为运行时依赖。 +- **GPL 陷阱**:Judge0 是 **GPL-3.0**;它的隔离层 ioi/isolate 是 **GPL-2.0-or-later**,依赖 cgroup,默认 root 运行;piston 代码是 MIT,但镜像打包了 isolate 的 fork。私有化交付属于「分发」,会触发 GPL 义务。 +- **主要工程风险**: + 1. **池化复用残留**:复用容器时 /tmp、后台进程、环境变量清理不干净会跨任务泄漏。⇒ 池里只放**没用过**的容器,用完即销毁,绝不回池。 + 2. **资源控制**:要同时适配 cgroup v1 / v2(4.19 以 v1 为主);要防 fork bomb、防 tmpfs 写满;PID 1 要回收僵尸进程;超时后要清理并回收结果。前篇 POC 已测出 4 路并发时 soffice 的内存下限是 384Mi,OOM 时报错完全指不到内存。 + 3. **两套编排**:compose 路线要挂 docker.sock,这等同宿主 root,**控制面必须和执行面隔离**,不能让执行容器摸到 docker.sock(前篇已点名 RAGFlow 的 `privileged + docker.sock` 反例);K8s 路线冷启动秒级,还要配 RBAC。 + 4. **多节点**:输入输出文件必须走对象存储,不能依赖本地盘(宪法 C8)。 + 5. **多架构镜像**:LibreOffice、Chromium / Playwright 在 loongarch64 上能否拿到可用构建,本轮**未核实**,需单独立项确认。 + 6. **离线**:`network: none` 与模型现场 pip install 冲突,需要预置离线 wheel,并在工具描述里如实告知模型。 + 7. **安全维护**:自定义 seccomp profile 要按架构维护,龙芯旧世界的 syscall 号尤其要注意。 + +--- + +## 5. 速度:隔离技术能帮任务模式提速多少 + +**先看瓶颈在哪**。这是 2026-08-09 在 180 POC 环境做的一次内部复盘(会话「帮我生成一个思源电气的 ppt」,总时长 56 分 29 秒,净机器时间 45.6 分钟): + +| 耗时来源 | 量级 | 与沙箱隔离技术的关系 | +|---|---|---| +| 容器没装 node,模型把 Node 工具链手工移植成 Python,重试 7 版 | **18 分 13 秒**(最大单因) | 无关,属于**镜像工具链是否完整** | +| LLM 首 token 延迟(TTFT)累计 | 650 秒(平均 6.08s) | 无关 | +| 上下文 168k 时触发的 summarization | 213 秒(单次) | 无关 | +| 11 次 429 限流重试 | — | 无关 | + +**再看执行器每次调用的开销**: +- 当前 local 执行器每次调用都 `subprocess.Popen([sys.executable, file])` 新起一个进程(`local_executor.py:196-210`),没有常驻解释器。 +- 本机实测(i9-9880H、backend `.venv`、磁盘缓存已热):空 Python 启动约 0.02–0.04s;**`import pandas, openpyxl, docx, pptx, fitz, matplotlib.pyplot` 每次约 1.4–1.7s**。首次运行 31s 是冷缓存和 pyc 编译,不计入。 +- 每次文档转换也要冷启动 soffice,这部分本轮未测。 + +**结论:隔离技术的选择对任务模式整体速度影响很小;提速要靠执行环境的「形态设计」。** + +| 提速手段 | 预计收益量级 | 是否依赖隔离技术 | 建议 | +|---|---|---|---| +| **镜像工具链一次补全**(node / npm、defusedxml、poppler-utils、zip / unzip,统一字体) | **分钟级**:消除模型绕路 | 否 | **P0**,前篇已列,与选型无关 | +| **常驻解释器**:会话内复用 Jupyter kernel 或 `InteractiveSandboxSession`,避免每次重新 import | 每次调用省约 1–2s,一个任务几十次调用累计可达**分钟级** | 否 | **P1**,默认档一起做。注意状态残留:模型上一轮的变量会留在内核里,工具描述要讲清楚 | +| **会话级常驻容器 + 懒启动**(OpenHands 模式) | 每次调用省去容器启动(秒级) | 否 | P1,和常驻解释器一起落地 | +| **预热池**(提前创建未用过的容器) | 每会话首次调用省去容器启动(秒级) | 否 | P2,并发高的客户再开 | +| **soffice 常驻 listener**(例如 unoserver 一类方案) | 每次转换省去 soffice 冷启动(秒级,**未实测**) | 否 | P2,先 POC 量化;要注意常驻进程崩溃恢复和多会话串扰 | +| 快照 / fork 克隆(Clone、sandlock、CubeSandbox、gVisor C/R) | 在已有预热池基础上再省**毫秒到秒级** | **是** | **不建议投入**:前三者进不了交付形态;gVisor C/R 需要自研编排,而且 Docker 只能恢复到原容器 | + +**隔离层本身的速度代价**: +- runc 容器的 syscall 开销接近原生。 +- gVisor systrap 在 syscall 密集、文件 IO 密集的负载上会变慢,soffice 转换和 Python 大量 import 正是这两类负载;arm64 还没有 syscall patch 加速。**官方没有 systrap 的开销数值**,所以升级档 POC 必须和 runc 对照测:大量 import 的 Python、soffice 转 docx / xlsx / pptx、pandoc。 +- **不能拿厂商的毫秒级启动数字推断任务模式会变快**:那些数字都是裸金属、最小模板、池化快照下测的,而任务模式单次任务是分钟到小时级。 + +--- + +## 6. 分档推荐与落地建议 + +### 6.1 P0:与选型无关,先止血(前篇已列,本轮复核仍未完成) + +- **Fernet 密钥外置**:`settings.py:22`。 +- **`_child_env()` 改为白名单**:只透传 PATH、LANG、HOME、MPLCONFIGDIR 等必要变量,**不要用 `os.environ.copy()`**。验证方法:执行器子进程的 `/proc//environ` 不含 `BS_MINIO_*`。 +- **去掉 `docker-compose.yml` backend / backend_worker 的 `seccomp:unconfined`**,并给执行负载加 cgroup 限额。 +- **镜像工具链补全**:node / npm、defusedxml、poppler-utils、zip / unzip。 + +### 6.2 默认档:加固容器执行后端 + +在前篇「中期」方案(`ContainerExecutor`,20–30 人天;或 llm-sandbox 省 5–8 人天)基础上,本轮新增以下要求: + +1. **生命周期按会话,不按调用**:一个灵思会话一个执行容器,首次调用时懒启动,会话结束或空闲超时后销毁。容器打上 label,服务启动时清扫孤儿容器。设置并发上限,超出时暂停最旧的容器(参考 OpenHands V1)。 +2. **常驻解释器**:容器内保持一个 Python 内核(Jupyter kernel 或 llm-sandbox `InteractiveSandboxSession`),避免每次重新 import。 +3. **预热池只放新容器**,用过的容器绝不回池(防止残留泄漏)。 +4. **收紧 seccomp,不套娃**:在 Docker 默认 profile 基础上额外禁掉 ptrace、bpf、io_uring、keyctl 等,**不引入 nsjail / bwrap 做第二层**。 +5. **控制面与执行面隔离**:执行容器不挂 docker.sock,不接入中间件网络;compose 路线由独立的受控组件持有 docker.sock。 +6. **隔离运行时做成配置项**:compose 的 `runtime` 字段、K8s 的 `runtimeClassName` 从一开始就留成可配置,为升级档留开关。 +7. **沙箱内看得到完整工作区与技能包**:不要走「只把单个工具 RPC 转发进沙箱」的路线(OpenHands #4883 的教训)。 + +### 6.3 硬隔离升级档:gVisor systrap + +- **适用范围**:x86_64 或 arm64,且内核 ≥ 5.6(例如 UOS / 麒麟 5.10)。4.19 客户原则上**不进**这一档;确有需要时钉 `release-20260817.0`,并书面说明放弃后续安全修复。 +- **接入方式**:compose 用 `runtime: runsc`(宿主 root 执行 `runsc install`),K8s 用 RuntimeClass `handler: runsc`。离线包必须同时带 `runsc`、`containerd-shim-runsc-v1`、`gvisor-bin/`。 +- **资源限额**仍靠宿主 cgroup 兜底(前篇已引用官方「resource limits are not enforced within the sandbox」)。 +- **Chromium / playwright** 预期需要 `noSandbox`。 +- **版本管理**:gVisor 无 LTS,需要自己建立「钉版本 + 定期升级回归」的节奏。 +- **前置条件**:§8 的 POC 1–2 通过。估算 +3–5 人天(沿用前篇),arm64 实测另计。 + +### 6.4 观察档:KVM 客户 + +CubeSandbox 和 microsandbox **不接入**,每季度复查一次。复查条件: +- CubeSandbox:K8s 转正、有完整离线安装指南、README 性能数字与 benchmark 口径统一; +- microsandbox:发布 1.0、商业化路线明朗。 + +### 6.5 不推荐(附一句理由,方便评审时引用) + +| 方案 | 理由 | +|---|---| +| Clone | KVM + 仅 x86_64 + 0 个 release | +| sandlock | 5.13 以下内核拒绝启动,无降级路径;龙芯上编译不过 | +| CubeSandbox PVM 路线 | 需要替换客户宿主内核为 OpenCloudOS 定制 6.x;仅 x86_64;VMware 上有 panic 报告 | +| V8 Isolate / Wasm | 跑不了原生二进制;workerd 要 glibc 2.35+;LangChain 已归档同类项目 | +| OpenHands 直接集成 | V0 runtime 已删;V1 agent loop 与 deepagents 重叠;要求 Python 3.12;加固能力在 PolyForm 商业许可部分 | +| agent-infra/sandbox 作为隔离方案 | 隔离边界是普通容器,官方示例关闭 seccomp,定位 trusted local development | +| nsjail / bwrap 替代容器或作第二层 | 需要放宽外层容器的 seccomp / AppArmor;对 4.19 加龙芯无增量 | +| gVisor KVM / ptrace 平台 | KVM 在嵌套虚拟化下性能差、无 `/dev/kvm` 起不来;ptrace 已停止支持 | +| Judge0 / isolate | GPL-3.0 / GPL-2.0+,私有化分发触发义务 | + +--- + +## 7. 对前篇的订正 + +| 前篇位置 | 原表述 | 订正 | +|---|---|---| +| §6「中期可选加固档:叠加 gVisor」 | 「麒麟 V10 的 4.19.90 内核名义满足 4.14.77+ 门槛」 | 自 `release-20260824.0` 起文档门槛改为 **5.6+**,而且**代码层面真实依赖 `openat2`**(commit a7402ea5de,gofer 路径,无回退分支)。4.19 只能钉 `release-20260817.0` 或更早 | +| §6 同节 | 以 Dangerzone 为「几乎 1:1 复刻」的旁证 | 旁证依然成立,但 **Dangerzone 是 AGPL-3.0**,只能借鉴做法,不能拷贝其代码或镜像脚本 | +| §2 信创维度 / §3 表 gVisor 信创列 | 「gVisor 官方支持 x86_64 / ARM64」 | 补充:**arm64 上 systrap 没有 syscall patch 加速**,Graviton2 有未关的 CPU 变慢 issue;鲲鹏、飞腾没有任何人跑过 gVisor 的记录 | +| §6 短期「`subprocess.Popen` 传 `env=` 白名单」 | 缺陷描述为「未传 `env=`」 | 代码现在传了 `env=`,但实现是 `os.environ.copy()`,**缺陷实质未修** | +| §3 许可证坑「LGPL-2.0+ — bubblewrap」 | 只点名 | 补充:exec 调用不传染调用方;**随包分发 bwrap 本身时要附许可证并提供源码** | +| §7 #5「llm-sandbox 能否透传 `runtime='runsc'`」、#6「session 启动后能否 `copy_to_runtime`」 | 未验证 | **源码确认两项都可以**(未实测)。另发现:默认 root 运行、容器池默认跨任务复用且不清理、常驻会话无条件 pip install 且不支持池化,详见 §4.10 | +| §6「路径 B(llm-sandbox)可省约 5~8 人天」 | 省工作量 | **偏乐观**:加固默认值、池策略、断网依赖都要自己补,倾向自研并借用其 MIT 源码 | + +--- + +## 8. 待验证(POC 清单)与未决问题 + +| # | 待验证 | 阻塞什么 | 方法 | +|---|---|---|---| +| 1 | gVisor systrap 在 **x86_64 和鲲鹏 arm64、UOS / 麒麟 5.10、VMware 虚机**上跑 soffice(writer / calc / impress)、pandoc、PyMuPDF、大量 import 的 Python、node、playwright(noSandbox) | 升级档能否上线 | 与 runc 对照测成功率和耗时;内存限额沿用前篇 1g | +| 2 | 4.19 内核:`release-20260817.0` 能否在麒麟 V10 4.19.90 上实跑;新版本在 4.19 上是否在 gofer `openat2` 路径报错(directfs 默认开启时是否绕开) | 4.19 客户能否进升级档 | 实机运行,并构造走 gofer 的文件操作 | +| 3 | 龙芯(旧世界 ABI)上 Docker 默认 seccomp 是否生效 | 龙芯客户默认档的真实隔离强度 | 容器内 `grep Seccomp /proc/self/status`,再试一个默认 profile 应拦截的调用 | +| 4 | 客户环境一次性摸底 | 分档落到具体客户 | 采集脚本:`systemd-detect-virt`、`ls -l /dev/kvm`、`uname -rm`、`ldd --version`、`sysctl user.max_user_namespaces`、cgroup 版本 | +| 5 | 常驻解释器提速 | 默认档 P1 的收益论证 | 同一任务脚本分别跑 N 次「每次新进程」与「常驻 kernel」,对比总耗时 | +| 6 | soffice 常驻 listener 的收益与稳定性 | 是否做 P2 | 连续 50 次转换,对比冷启动方式;故意杀进程看恢复 | +| 7 | llm-sandbox 在 `user` 非 root + `cap_drop: ALL` + `read_only` 下:复制进去的文件能否被写;预装 ipython 且 `network: none` 时常驻会话能否启动;`runtime="runsc"` 透传是否生效 | 自研还是借用 llm-sandbox | 实测(`copy_to_runtime` 时序与透传已由源码确认) | +| 8 | LibreOffice、Chromium 在 loongarch64 上的可用构建 | 龙芯客户能否用任务模式 | 查麒麟 / UOS 龙芯源,实机安装 | + +**其他未决**: +- CubeSandbox 的 GPL-2.0 eBPF 源码与 guest 内核镜像的许可证,需要做 SPDX 审计(仅在观察档转正时才需要)。 +- 等保 2.0 对「平台内置代码执行器」的判定口径(前篇遗留)。 + +--- + +## 附录 A:数据可信度说明 + +- **核验中被否决、不得引用的论断**: + - 「CubeSandbox 冷启动 <60ms、内存 <5MB,50 并发平均 67ms、P99 137ms」作为定论:0-3 否决。官方 benchmark 博客的数字与 README 不一致,见 §4.2。 + - 「microsandbox issue #1483 无维护者回复、无软件兜底」:0-3 否决。 + - 「agent-infra/sandbox 由字节 / 火山引擎维护、版本号跳跃」:1-2 否决。 + - 「按 BiSheng VM 形态 gVisor 可以跑」:1-2 否决。漏掉了 5.6+ 内核和架构前提,准确说法见 §4.7。 +- **两路核验冲突时以一手 diff 为准**:关于 gVisor commit a7402ea5de,一路 agent 认为「只改文档」,另一路认为「技术硬限制」。人工复核 diff 确认代码新增了 `unix.Openat2` 调用和 `SYS_OPENAT2` seccomp 放行,**后者成立**。 +- **所有性能数据都是厂商自测**(裸金属或很新的内核),没有第三方复现,不能直接推到「VMware 虚机 + 4.19 / 5.10 内核」上。 +- **时效性**:gVisor(周更)、CubeSandbox(0.x)、microsandbox(几天一版)、Clone、sandlock 都在快速变动,许可证和支持矩阵随时可能变。本文所有结论的核实日期均为 **2026-09-15**。 + +## 附录 B:内部证据 + +- 执行器实现:`src/backend/bisheng_langchain/gpts/tools/code_interpreter/local_executor.py`(L164-184 `_child_env`,L196-210 `Popen`) +- 安全缺陷复核:`src/backend/bisheng/core/config/settings.py:22`,`docker/docker-compose.yml` L111、L151 +- 性能复盘:180 POC 环境会话 `aa352cb420f649d2be391cb09d9ad69d`(2026-08-09) diff --git "a/docs/PRD/2.6 \347\201\265\346\200\235 deepagents \350\277\201\347\247\273 PRD/\347\201\265\346\200\235\344\273\273\345\212\241\346\250\241\345\274\217 NL2SQL \346\225\260\346\215\256\345\210\206\346\236\220\345\255\220\344\273\243\347\220\206\346\226\271\346\241\210.md" "b/docs/PRD/2.6 \347\201\265\346\200\235 deepagents \350\277\201\347\247\273 PRD/\347\201\265\346\200\235\344\273\273\345\212\241\346\250\241\345\274\217 NL2SQL \346\225\260\346\215\256\345\210\206\346\236\220\345\255\220\344\273\243\347\220\206\346\226\271\346\241\210.md" new file mode 100644 index 0000000000..c21603c5fe --- /dev/null +++ "b/docs/PRD/2.6 \347\201\265\346\200\235 deepagents \350\277\201\347\247\273 PRD/\347\201\265\346\200\235\344\273\273\345\212\241\346\250\241\345\274\217 NL2SQL \346\225\260\346\215\256\345\210\206\346\236\220\345\255\220\344\273\243\347\220\206\346\226\271\346\241\210.md" @@ -0,0 +1,139 @@ +# 灵思任务模式 NL2SQL 数据分析子代理方案 + +> **定位**:v2.6.0 · 灵思任务模式在"问数/NL2SQL"场景的效果增强(轻量方案) +> **归属**:F035-linsight-task-mode 域内增强,不新增领域对象(复用 `LinsightSkill` 等既有对象) +> **落地入口**:`src/backend/bisheng/linsight/domain/services/agent_factory.py` + skill 正文约定 +> **关联**:[灵思 Linsight 迁移 deepagents 框架 PRD](./灵思%20Linsight%20迁移%20deepagents%20框架%20PRD.md) · [灵思任务模式 #1 子代理重引入技术方案](./灵思任务模式%20%231%20子代理重引入技术方案.md) · [灵思任务模式 Skill 能力恢复方案](./灵思任务模式%20Skill%20能力恢复方案.md) + +--- + +## 一、结论先行(核心主张) + +在现有灵思任务模式上**新增一个只读的 `data-analyst` 子代理**: + +- **DB 访问不建基建**,连接信息留在 **skill 正文**里(Tier B,如"早报生成"skill 直写 MySQL 地址/账号/密码); +- 通过**把会话 skill 接线进子代理**,让子代理读到连接信息与领域知识; +- 子代理在**沙箱(code interpreter)**里跑 SQL、产出数据与图表,**主图负责组装最终交付物**; +- 歧义走 `NEED_CLARIFICATION → 主图 ask_user → 重新委派`(**纯 prompt,零业务代码**)。 + +**为什么轻**:零新基建、零 DB schema、零前端、零新工具。改动集中在**一个文件** `agent_factory.py`(子代理 + skill 接线 + 两处 prompt)+ skill 正文约定。三处关键机制已核实到可执行级。 + +--- + +## 二、背景与动机(Why) + +- **需求**:把任务模式在"问数/NL2SQL"场景做强——用户用自然语言问业务库,产出带数据与图表的报告。 +- **已定路线(Tier B)**:短期**不建独立数据源模块**;连接信息留在 skill 里(**已验证沙箱可直连业务库**)。重基建(数据源表 / Fernet 加密 / OpenFGA 权限 / CRUD / 管理页、统一 workflow 节点内联连接)**整体推后为债**。 +- **现有 harness 家底(均现成可复用)**:deepagents 装配(`agent_factory.py:758`)、researcher 子代理构造式防御、resilience + tool-loop 中间件、沙箱 code interpreter、WorkspaceBackend、`ask_user`→interrupt→park→resume 全链、StreamEventMapper。 +- **三条硬约束**(贯穿全案,逐条落地): + 1. 子代理进不了 `ask_user` → 用 `NEED_CLARIFICATION:` 返回形态回主图触发澄清。 + 2. 子代理是"容器"、skill 是"内容" → **父 agent 的会话 skill 必须接线进子代理**(连接信息就在 skill 里,不接线子代理无从连库)。 + 3. 委派 prompt 必须**自包含**(子代理 messages 被 deepagents 替换成只剩 task description)。 + +--- + +## 三、方案设计(MECE 六块) + +### 3.1 容器 · `data-analyst` 子代理 + +- 新增第二个子代理(`name="data-analyst"`,区别于 researcher 的 `general-purpose`)。其 `description` 会被 deepagents 的 `SubAgentMiddleware` **自动冒泡进主图 system prompt**(`subagents.py:683-685`),模型据此决定派活,**无需手写进主模板**。 +- 工具 = `_subagent_tools(tools)`(已核实:含 `bisheng_code_interpreter` 沙箱、且不在 `_SUBAGENT_TOOL_DENY` 黑名单)。**只读纪律**靠 prompt + skill 里的只读账号。 +- 中间件栈 = resilience + tool-loop-breaker + **SkillsMiddleware(新接线,见 3.2)** + LanguageTail(永远置尾)。 + +### 3.2 内容 · skill 接线进子代理(约束 2 · 命门) + +- ❌ **不能**用 deepagents 原生子代理 `skills=` 键:它硬绑传给 `create_deep_agent` 的共享 `WorkspaceBackend`(`graph.py:630`),而 `WorkspaceBackend.ls` 返回 `is_dir=False`(`workspace_backend.py:311-337`)→ SkillsMiddleware 目录枚举(`skills.py:597-610`)**发现 0 个 skill**。这与主图当初绕开原生 `skills=` 是同一根因。 +- ✅ **正确做法**:往子代理 middleware **append 一个与主图同构的 `SkillsMiddleware`**: + - `backend = FilesystemBackend(root_dir=file_dir, virtual_mode=True)`(dir-aware,能 `ls /skills/`) + - `sources = [("/skills/", "Skills")]` + - 位置:**LanguageTail 之前**(保证语言指令仍在最尾) + - `file_dir` / `skills_present` 在子代理装配作用域(`agent_factory.py:742-751`)内已可见,**无需改函数签名**。 +- **披露方式**(progressive disclosure):模型 system prompt 里只看到 skill 的 `name + description + 路径`;连接串写在 SKILL.md **正文**,子代理**按需 `read_file /skills//SKILL.md`** 才拉进上下文(枚举靠 FilesystemBackend、读正文靠子代理自身 FilesystemMiddleware 的共享 WorkspaceBackend 按路径读,各司其职)。 +- **通用工作流**(schema-exploration / query-writing 那套 know-how)→ **写进子代理 prompt**(不新建 skill——BiSheng 无"常驻 skill"机制,做成 skill 反而更重);**场景 skill**(早报,含连接 + 领域知识)仍走会话选中 + 本接线。 + +### 3.3 澄清环 · NEED_CLARIFICATION(约束 1 · 纯 prompt 零代码) + +- 子代理返回文本 → 冒泡成 ToolMessage 进主图上下文(`subagents.py:519-531`)→ 主图模型可见 → 识别 `NEED_CLARIFICATION:` 前缀是**模型语义任务**,无需改引擎(stream_event_mapper / `_handle_event` 均不需动)。 +- **必须动 prompt**:改写主模板里"整会话最多澄清一轮 / 澄清必须在 task 之前 / 绝不第二次 ask_user"这几条硬约束(`agent_factory.py:104 / 110 / 111 / 153`),否则模型拒绝二次澄清。 +- **约定返回契约**:子代理无法自解的歧义 → 返回**仅** `NEED_CLARIFICATION: [问题列表]`;主图识别后调 `ask_user`,拿到答案**折进新的自包含 description 重新委派**,不得把 NEED_CLARIFICATION 当最终答案。 +- **防无限环**(对齐历史红线):设**有界澄清预算**(每次数据委派最多触发 ≤2 轮澄清,超出则带显式假设继续),澄清由用户驱动、非机器自环。 +- **全链现成无缺口**:`ask_user`→`interrupt`(`agent_factory.py:567`)→`__interrupt__` 探测(`stream_event_mapper.py:248`)→`NeedUserInput`→ park(`WAITING_FOR_USER_INPUT` + `USER_INPUT` push)→ `POST /workbench/user-input`(`linsight.py:405` put_head resume)→ `async_resume` 喂 `Command(resume=...)`(`task_exec.py:328`),同 thread_id + Redis checkpointer。 + +### 3.4 委派契约 + 职责边界(约束 3 + 交付物不变量) + +- **自包含委派**:主图派数据任务时,description 必须自带 `精确问题 + 用哪个 skill/库 + 口径 + 允许范围`(子代理只看得到这一条 `HumanMessage`,`subagents.py:539`)。落在委派段 `agent_factory.py:117` / `__KB_DELEGATE_LINE__` 附近,比照现有 KB 委派的 `knowledge_id` 自包含约束。 +- **职责边界**(保持 `:125` 不变量"最终交付物必须主智能体亲自完成,不得委派"):**data-analyst 只出"结构化数据 + 图表路径 + 关键发现 + 执行的 SQL",主智能体亲自拼装最终交付物**。既保隔离收益,又不违反"交付物不委派"。 + +### 3.5 产物治理(别堆一堆中间产物) + +- **中间产物**(原始 dump、探索性查询、草图)一律写 **`scratch/`**(不被 `get_final_result_file` 收割)。 +- **只有要进交付物的最终图表**写 **`output/`**(收割区),且约定**节制**——不把每张中间图丢进 output/。 +- 子代理返回里**显式给出 output/ 产物路径**——规避已知瑕疵:主图 `ls` 只读 MinIO(`workspace_backend.py:311`)、可能列不出沙箱新写文件;但 `read_file`(缓存)与最终结果面板(扫 `file_dir`)均可见。 + +### 3.6 安全底线(唯一不可省) + +- **skill 里的连接必须用只读账号**——哪怕模型生成 DML 或凭据泄漏,爆炸半径锁死在只读。这是短期唯一安全护栏,写进 skill 编写规范。 +- 凭据明文在 SKILL.md(可被 `read_file`)= 已知短期取舍,记为债,随数据源模块 productize 时收口。 + +--- + +## 四、改动清单(What to change) + +**唯一核心文件**:`src/backend/bisheng/linsight/domain/services/agent_factory.py` + +| 改动 | 位置 | 说明 | +|---|---|---| +| 新增 `_DATA_ANALYST_PROMPT_TEMPLATE_ZH` | 常量区 | 子代理系统 prompt:数据分析工作流(探 schema→写 SELECT→双检→执行→报错自纠)+ 只读纪律 + "读所选 skill 的 SKILL.md 拿连接/领域知识" + 沙箱跑 SQL + 产物治理(scratch/output) + 自包含 + NEED_CLARIFICATION 返回契约 + 返回蒸馏结论/SQL/产物路径 | +| 新增 `_build_data_analyst_subagent(tools)` | 仿 `:589-619` | `name="data-analyst"` + description + prompt + `tools=_subagent_tools(tools)` | +| `subagents=[researcher, data_analyst]` | `:763` | 加入子代理列表 | +| 子代理 middleware 接 SkillsMiddleware | `:742-751` 同款 | §3.2 的正确写法,guard=`skills_present and file_dir`,置于 LanguageTail 前 | +| 主模板放宽澄清约束 + 加委派/识别指令 | `:104/110/111/117/153` + `__KB_DELEGATE_LINE__` | §3.3/§3.4:允许 NEED_CLARIFICATION 二次澄清(有界)、数据任务委派 data-analyst、自包含约束、职责边界 | + +**skill 内容(约定,非代码改动)**:场景 skill(早报式)SKILL.md 正文写**只读账号**连接 + 领域知识。 + +**明确不动**:无新工具、无 DB schema/迁移、无前端、无数据源模块、researcher 子代理不变、workflow 节点不动。 + +**依赖**:data-analyst 走沙箱查库,需用户在配置里**启用 `bisheng_code_interpreter`**。 + +--- + +## 五、验收标准(AC 口径) + +| ID | 场景 | 预期 | +|---|---|---| +| AC-01 | 会话选中一个含只读 MySQL 连接的场景 skill 并提数据问题 | 主图委派 `data-analyst`,子代理 `read_file` 该 SKILL.md 拿到连接,沙箱跑 SELECT,返回"结论 + SQL + output/ 图表路径",主图亲自拼装交付物 | +| AC-02 | 子代理遇到无法自解的歧义(时间口径 / 多表歧义) | 子代理返回 `NEED_CLARIFICATION: […]`;主图调 `ask_user` 弹澄清卡,用户答复后主图折进新 description 重新委派并跑通 | +| AC-03 | 连续歧义 | 澄清轮数受有界预算约束(≤2/委派),不进入无限澄清环 | +| AC-04 | 子代理产出 | 中间产物落 `scratch/`(不进交付物),仅最终图表落 `output/`;子代理返回显式给出 output/ 路径 | +| AC-05 | skills_present 为真 | data-analyst 子代理装配含 SkillsMiddleware,可枚举并按需读 `/skills//SKILL.md` | +| AC-06 | 只读约束 | skill 连接使用只读账号;即便模型生成 DML 亦无法写库(DB 侧拒绝) | +| AC-07 | 职责边界 | 最终交付物由主智能体亲自拼装(不违反 F035 "交付物不委派"不变量) | + +--- + +## 六、验证方案(端到端) + +1. **环境**:本地起前后端 + 连 test 环境中间件(不改 test 部署);备一个**只读账号**可达的测试 MySQL + 一个早报式 skill(连接写进 SKILL.md)。 +2. **主链路**(AC-01/04/07):任务模式提数据问题 → 观察委派 / `read_file` SKILL.md / 沙箱 SQL / 中间产物落 `scratch/`、最终图落 `output/` / 返回"结论+SQL+路径" / 主图拼装交付物。 +3. **澄清环**(AC-02/03):给歧义问题 → 子代理 `NEED_CLARIFICATION` → 主图 `ask_user` → 用户答 → 重新委派跑通;验证有界预算。 +4. **单测**(`test/linsight/`):① 子代理构造含 SkillsMiddleware(skills_present 为真时);② 主 prompt 含 NEED_CLARIFICATION 契约且已放宽"最多一轮"约束;③ 委派 description 自包含约束存在。 +5. 可选 `/e2e-test`。 + +--- + +## 七、不做 / 推后债 + +- 独立数据源模块(表 + Fernet 加密 + 连接工厂 + OpenFGA 权限 + CRUD + 管理页) +- 统一 workflow 节点的内联连接、数据源级权限、中央凭据管理 / 轮转 +- schema 语义层 / 数据字典、多方言(DM8 等)扩展、结构化结果协议标准化、结果脱敏 + +--- + +## 八、开放问题 / 风险 + +| # | 风险 | 缓解 | +|---|---|---| +| 1 | 模型遵循度:NEED_CLARIFICATION 契约 + "放宽但不无限环" 属 prompt 遵循,随模型波动 | prompt 用强结构 + few-shot,有界预算兜底;实测校准 | +| 2 | 凭据明文在 SKILL.md(可 `read_file`) | 只读账号是唯一护栏,接受为短期债,随数据源模块收口 | +| 3 | 沙箱连通性:新业务库需确认沙箱网络 / 驱动可达 | 当前库已验证;每接新库前置确认 | +| 4 | 澄清预算上界具体值 | 建议每次数据委派 ≤2 轮,落 prompt 时定稿 | diff --git "a/docs/PRD/2.6 \347\201\265\346\200\235 deepagents \350\277\201\347\247\273 PRD/\347\201\265\346\200\235\344\273\273\345\212\241\346\250\241\345\274\217\345\274\225\347\224\250\346\272\257\346\272\220 PRD.md" "b/docs/PRD/2.6 \347\201\265\346\200\235 deepagents \350\277\201\347\247\273 PRD/\347\201\265\346\200\235\344\273\273\345\212\241\346\250\241\345\274\217\345\274\225\347\224\250\346\272\257\346\272\220 PRD.md" new file mode 100644 index 0000000000..71fb4c3ada --- /dev/null +++ "b/docs/PRD/2.6 \347\201\265\346\200\235 deepagents \350\277\201\347\247\273 PRD/\347\201\265\346\200\235\344\273\273\345\212\241\346\250\241\345\274\217\345\274\225\347\224\250\346\272\257\346\272\220 PRD.md" @@ -0,0 +1,198 @@ +# 灵思任务模式引用溯源 PRD + +| 项 | 值 | +|---|---| +| 版本 | v2.6.0 | +| Feature | F040-linsight-citation-traceability | +| 优先级 | P2 | +| 承接 | 《灵思 Linsight 迁移 deepagents 框架 PRD》FR-2.13、§4.3.3「引用溯源」 | +| 日期 | 2026-09-03 | +| 交互 demo | 见本文 §4 所附链接 | + +## 0. 结论 + +任务模式的报告接入平台既有的溯源机制:报告正文出现可点击角标,点击回看知识库原文件(定位到页)或原网页。 + +日常对话模式已有完整溯源链路,任务模式的报告预览已复用同一渲染器。本期前端不改动,后端改动集中在检索工具与任务完成时的持久化。 + +分两期:一期应用内预览角标;二期下载的 Word/PDF 内带可见编号引用与参考资料。 + +## 1. 目标与范围 + +| 本期做 | 说明 | +|---|---| +| 报告预览内联角标 | 覆盖知识库检索与网页检索两类来源 | +| 角标悬浮预览卡 | 标题、摘要、来源名 | +| 点击回看 | 知识库角标预览原文件并定位;网页角标打开原网页 | +| 权限 | 角标遵守知识库文件的查看权限 | +| 持久化 | 任务完成时保存报告实际引用的来源 | + +| 本期不做 | 去向 | 原因 | +|---|---|---| +| 下载的 Word/PDF 内的引用 | 二期 | 需独立的导出处理;下载件内知识库文件只能显示名称与页码 | +| 用户上传附件的溯源 | 候选 1.5 | 附件经按需翻阅,不走检索工具,需另一套机制 | +| 「参考资料」汇总列表面板 | 后续 | 先只做行内角标 | +| 漏标记的补救重跑 | 不做 | 优雅降级 | +| 检索本身的文件级可见性 | F029 / F035 | 本期只在角标呈现面复用其过滤 | +| 日常对话模式 | 不改 | 本期只接入任务模式 | + +## 2. 用户场景 + +| 角色 | 目标 | 价值 | +|---|---|---| +| 业务用户 | 在报告里看到引用旁的角标,一键回看原文件对应页或原网页 | 核实结论出处,不必人工翻找 | +| 管理员 / 合规 | 无权查看某文件的用户,看不到该文件来源信息,也无法借角标预览它 | 溯源不成为越权入口 | + +## 3. 需求规则 + +### 3.1 来源捕获 + +| # | 规则 | 依据 | +|---|---|---| +| R1 | 知识库检索命中的每个片段登记一条来源,含文档名、知识库名、页码、页内定位 | 角标要能回看到原文件对应位置 | +| R2 | 网页检索的每条结果登记一条来源,含 URL、标题、摘要、站点 | 网页角标要能打开原网页并展示预览 | +| R3 | 模型只对实际使用的来源打标记,标记紧跟使用处;不使用则无标记 | 角标位置即出处 | +| R4 | 子任务检索到的来源标记能传递到主流程撰写的报告里 | 检索常由子任务完成,报告由主流程撰写 | + +### 3.2 报告呈现 + +| # | 规则 | 依据 | +|---|---|---| +| R5 | 报告预览时标记渲染为顺序编号上标角标 [1][2]… | 与日常模式一致 | +| R6 | 知识库角标蓝色,网页角标紫色 | 与日常模式一致,一眼分辨来源类型 | +| R7 | 悬浮角标显示标题、摘要、来源名;知识库附页码,网页附日期 | 不打开文件即可判断出处 | +| R8 | 点击知识库角标打开原文件预览;PDF 高亮并定位到被引片段 | 核实出处需看上下文 | +| R9 | 点击网页角标在新标签打开原网页 | — | +| R10 | 同一句多来源时相邻显示多个角标 | — | + +### 3.3 权限 + +| # | 规则 | 依据 | +|---|---|---| +| R11 | 用户对某知识库文件无查看权限时,角标解析不返回该文件任何来源信息 | INV-7 | +| R12 | 无权限的角标显示为灰色「无权限查看」,不可点击 | 不报错、不泄露 | + +### 3.4 持久化与健壮性 + +| # | 规则 | 依据 | +|---|---|---| +| R13 | 任务完成时只保存报告实际引用到的来源,绑定该任务轮结果 | 老报告预览时仍可解析 | +| R14 | 重复完成(续轮、恢复)不产生重复来源记录 | — | +| R15 | 来源登记的任何异常不影响检索与任务,回退为无溯源的结果 | 溯源是增强,不能拖垮任务 | +| R16 | 模型漏打部分标记时对应来源无角标,其余正常,不出现坏角标 | 优雅降级 | +| R17 | 不改任务执行流的事件协议与执行记录结构 | 非侵入 | + +## 4. 交互说明 + +交互 demo:见本 PRD 随附的可点击页面。demo 展示对话内嵌任务模式:左侧对话列(提问、任务卡、结果),右侧工作区面板预览报告。 + +| 状态 | 触发 | 表现 | +|---|---|---| +| 角标 | 报告预览 | 顺序编号上标胶囊;知识库蓝、网页紫 | +| 悬浮卡 | 鼠标悬浮角标 | 标题、摘要、来源图标 + 来源名 + 页码或日期 | +| 回看知识库 | 点击蓝色角标 | 工作区切到原文件预览;PDF 黄色高亮并滚动到被引片段 | +| 打开网页 | 点击紫色角标 | 新标签打开原网页 | +| 无权限 | 悬浮灰色角标 | 「无权限查看」;点击无反应 | +| 多来源 | 同一句引多个来源 | 相邻多个角标 | + +一期不在结果区增加「参考资料」汇总面板。 + +## 5. 验收标准 + +| ID | 验收标准 | +|---|---| +| AC-01 | WHERE 任务开启了知识库检索, WHEN 知识库检索返回结果, THE SYSTEM SHALL 为每个命中片段登记一条带稳定来源标识的来源,含文档名、知识库名、页码、页内定位,并使模型读到该结果时能获得对应来源标识 | +| AC-02 | WHERE 任务开启了网页检索, WHEN 网页检索返回结果, THE SYSTEM SHALL 为每条网页结果登记一条带稳定来源标识的来源,含 URL、标题、摘要、站点 | +| AC-03 | WHEN 模型撰写报告并使用了某条已登记来源的内容, THE SYSTEM SHALL 使报告正文在对应句或段位置内联携带该来源的溯源标记;未使用任何已登记来源时不产生标记 | +| AC-04 | WHEN 检索发生在子任务而报告由主流程撰写, THE SYSTEM SHALL 使来源标记可越过子任务边界传递到最终报告正文 | +| AC-05 | WHEN 任务完成, THE SYSTEM SHALL 解析报告中实际引用到的来源,仅将被引用的来源持久化并绑定到该任务轮的结果消息 | +| AC-06 | WHEN 同一份报告被重复完成处理, THE SYSTEM SHALL 保持幂等,不产生重复的来源记录 | +| AC-07 | WHERE 查看者对某知识库文件无查看权限, WHEN 解析该报告角标, THE SYSTEM SHALL NOT 在角标解析接口响应的任何结构化字段中返回该文件的来源信息 | +| AC-08 | WHEN 用户在应用内预览任务报告, THE SYSTEM SHALL 把正文溯源标记渲染为顺序编号的上标角标;同一句多来源时相邻显示多个角标 | +| AC-09 | WHEN 用户悬浮某角标, THE SYSTEM SHALL 展示该来源的标题、摘要、来源名;知识库来源附页码,网页来源附日期 | +| AC-10 | WHEN 用户点击知识库角标, THE SYSTEM SHALL 打开该原文件预览;WHERE 文件为 PDF, THE SYSTEM SHALL 高亮并滚动定位到被引片段 | +| AC-11 | WHEN 用户点击网页角标, THE SYSTEM SHALL 在新标签打开原网页 URL | +| AC-12 | WHERE 查看者对某知识库角标对应文件无权限, THE SYSTEM SHALL 将该角标呈现为「无权限查看」态且不可点击,不报错 | +| AC-13 | IF 来源登记过程发生任何异常, THEN THE SYSTEM SHALL 回退到不带溯源的检索结果并继续任务 | +| AC-14 | IF 模型在报告中漏发部分标记, THEN THE SYSTEM SHALL 对应来源不出现角标,其余角标正常,不产生坏角标或解析报错 | +| AC-15 | THE SYSTEM SHALL 保持既有任务执行流的事件协议与执行记录结构不变 | + +需求 ↔ 验收对照 + +| 规则 | AC | +|---|---| +| R1 | AC-01 | +| R2 | AC-02 | +| R3 | AC-03 | +| R4 | AC-04 | +| R5、R6、R10 | AC-08 | +| R7 | AC-09 | +| R8 | AC-10 | +| R9 | AC-11 | +| R11 | AC-07 | +| R12 | AC-12 | +| R13 | AC-05 | +| R14 | AC-06 | +| R15 | AC-13 | +| R16 | AC-14 | +| R17 | AC-15 | + +## 6. 分期 + +| 期 | 内容 | 前端改动 | 状态 | +|---|---|---|---| +| 一期 | 预览角标(知识库 + 网页)、悬浮卡、点击回看、权限、持久化 | 无 | 本期 | +| 二期 | 下载的 Word/PDF 内可见 [1] 编号 + 文末「参考资料」(知识库 = 文档名 + 页码;网页 = 标题 + URL) | 无 | 延后 | +| 候选 1.5 | 用户上传附件的溯源 | 待定 | 延后 | +| 不做 | 「参考资料」汇总面板;漏标记补救重跑 | — | — | + +二期说明:下载件内知识库文件只显示名称与页码,不可点击预览;网页来源为可点击 URL。 + +## 7. 决策记录 + +| # | 决策 | 选定 | 被否方案 | 理由 | 何时重估 | +|---|---|---|---|---|---| +| D1 | 整体路线 | 复用平台既有溯源机制 | 自研任务模式专属机制;用 Skill 或提示词让模型自己写参考资料 | 检索工具今天不把来源信息给模型,让模型写参考资料只能编造文件名;Skill 按会话勾选,溯源会时有时无;复用后前端零改动、权限过滤现成 | 溯源机制大改,或报告不再以 Markdown 预览 | +| D2 | 来源信息的捕获点 | 在任务模式检索工具边界保留并登记 | 换用日常模式的检索工具 | 两者白名单语义与错误契约不同,整体替换比在现有工具补信息改动更大 | 两条检索路径合并时 | +| D3 | 来源的收集方式 | 写运行时缓存 | 内存收集器跨子任务传递 | 子任务独立执行,内存对象要穿透子任务;缓存天然全局 | 出现缓存不可用的部署形态 | +| D4 | 引用来源的保存位置 | 既有消息引用记录,绑定任务轮结果 | 存进任务结果字段 | 既有记录自带权限过滤、定位、链接刷新;另存要重造且绕开权限红线 | 报告需脱离对话独立分享时 | +| D5 | 漏标记的处理 | 优雅降级 | 补救重跑 | 少角标不影响报告可用;重跑成本高且不保证补全;Deep Research 类产品同样接受降级 | 漏标率明显影响使用 | +| D6 | 分期 | 预览先行,下载随后;先不做汇总面板 | 一次做全 | 预览面零前端改动、价值最高;下载件需独立导出处理 | — | + +## 附录 A 实现约束与取证 + +一期改动点(后端) + +| 位置 | 现状 | 改动 | +|---|---|---| +| `src/backend/bisheng/tool/domain/langchain/linsight_knowledge.py` `base_search` | 只返回片段文本,丢弃来源元数据(第 92 行) | 保留元数据、补知识库标识、登记来源并写缓存;异常回退为原结果 | +| `src/backend/bisheng/linsight/domain/services/agent_factory.py` | 系统与子代理提示词无引用规则 | 追加引用规则(复用 `core/prompts/yaml/citation.yaml`);包装网页检索工具登记来源 | +| `src/backend/bisheng/linsight/domain/task_exec.py` 三个完成处理器 | `persist_task_turn_message` 返回值被丢弃 | 捕获返回,解析报告引用并保存来源 | + +复用不改:`bisheng/citation/` 的 `annotate_*`、`collect_*`、`cache_citation_registry_items`、`extract_citation_ids_from_text`、`save_message_citations`、`/api/v1/citations/resolve` 及其权限过滤。 + +一期前端:无改动。报告预览 `PreviewBody.tsx` 已走 `Chat/Messages/Content/Markdown`,具备角标渲染、悬浮卡、点击预览、PDF 高亮、无权限灰化。 + +约束 + +| 约束 | 内容 | +|---|---| +| INV-7 | 角标解析响应不得含无权文件的来源字段;复用 `CitationResolveService` 的 view_file 过滤 | +| 事件协议 | `MessageEventType` 10 个枚举冻结,不新增 | +| 达梦写放大 | 不写 `LinsightExecuteTask.history`(历史事故 -7120) | +| 工具契约 | 检索工具不得抛异常(异常会终止整个任务);来源登记包在窄异常处理内 | +| 数据库 | 无迁移,复用 `message_citation` | + +二期落点:`linsight_export.py` 两个导出工具在转换前把标记烘焙为可见编号与参考资料段;磁盘 Markdown 保持原标记供预览。 + +SDD 文档:`features/v2.6.0/040-linsight-citation-traceability/{spec,design}.md`;版本契约 `features/v2.6.0/release-contract.md` 已登记 F040。 + +## 附录 B 对标 + +ChatGPT、Gemini 的 Deep Research 溯源模式:检索时给每个来源分配稳定标识,模型写作时按标识内联引用,展示层解析为可点击来源。平台日常模式已是同一模式,本 PRD 只把任务模式接入。 + +## 变更记录 + +| 日期 | 内容 | +|---|---| +| 2026-09-03 | 初版 | diff --git "a/docs/PRD/2.6 \347\201\265\346\200\235 deepagents \350\277\201\347\247\273 PRD/\347\201\265\346\200\235\344\273\273\345\212\241\346\250\241\345\274\217\346\226\207\344\273\266\345\244\204\347\220\206\351\223\276\350\267\257\347\263\273\347\273\237\345\210\206\346\236\220.md" "b/docs/PRD/2.6 \347\201\265\346\200\235 deepagents \350\277\201\347\247\273 PRD/\347\201\265\346\200\235\344\273\273\345\212\241\346\250\241\345\274\217\346\226\207\344\273\266\345\244\204\347\220\206\351\223\276\350\267\257\347\263\273\347\273\237\345\210\206\346\236\220.md" new file mode 100644 index 0000000000..c96162abaa --- /dev/null +++ "b/docs/PRD/2.6 \347\201\265\346\200\235 deepagents \350\277\201\347\247\273 PRD/\347\201\265\346\200\235\344\273\273\345\212\241\346\250\241\345\274\217\346\226\207\344\273\266\345\244\204\347\220\206\351\223\276\350\267\257\347\263\273\347\273\237\345\210\206\346\236\220.md" @@ -0,0 +1,571 @@ +# 灵思任务模式文件处理链路系统分析 + +> 取证基线:`e96ce0017`(`fix(linsight): guard binary uploads, keep originals for the code interpreter`),工作树 clean。 +> 本文只描述现状与已查实的缺口,**不含任何代码改动**。所有结论内联 `文件:行号`,可直接跳转复核。 + +--- + +## 摘要:一句话回答四个问题 + +**灵思任务模式没有"一个文件系统",而是三套存储 + 一个统一门面。** + +门面是 deepagents 的 `BackendProtocol`——灵思用自定义的 `WorkspaceBackend` 顶替了框架默认的内存实现,把 agent 的 `read_file`/`write_file`/`ls` 接到了 MinIO 的 `workspace/{svid}/` 前缀。但**代码执行器不走这个门面**:它直接在 `WorkspaceBackend` 的本地写穿缓存目录里起子进程,产物只落本地盘。而用户最终看到的产出文件走的是第三条路:任务收尾时扫描那个本地目录,另行上传到 `linsight/final_result/{svid}/`。 + +三条写入路径唯一的交汇点是本地目录 `file_dir`。**抓住这一点,本文其余所有现象都可以推导出来**——包括"agent 画完图用 `ls` 却找不到"、"产出文件偶尔凭空消失"、"跨轮看不到上轮图表"这几个反复出现的排障问题。 + +对四个原始问题的直接回答: + +| 问题 | 答案 | +|---|---| +| workspace 和 deepagents 虚拟文件系统是一回事吗? | **是同一套接口的两种实现**。接口是 deepagents 的,实现是灵思自己写的,物理载体是 MinIO 对象存储——不是内存态,也不是本地 POSIX 目录 | +| 用户上传的文件存在哪、怎么处理? | 经知识库 ETL **解析成 markdown**,再以"文本视图 + 原件"**双轨**拷进 `workspace/{svid}/uploads/`。模型初见只有零正文的指针块 | +| 代码执行器跑在哪?和 workspace 什么关系? | 跑在 **linsight worker 容器内的子进程**里(无隔离),cwd 就是 workspace 的本地缓存目录——**共享本地目录,但不共享 MinIO** | +| 执行器生成的文件在哪? | 落 `file_dir/output/`(本地盘),靠任务收尾的**本地目录扫描**进入交付列表,**全程不经过 MinIO workspace 前缀** | + +### 全景图 + +```mermaid +flowchart TB + subgraph W["写入方(三条路径)"] + U["① 平台搬运
用户上传件"] + A["② Agent 工具
write_file / edit_file / export_docx"] + C["③ 代码执行器
bisheng_code_interpreter"] + end + + subgraph S["存储"] + MW["MinIO workspace/{svid}/
uploads · output · scratch · skills
(真值源)"] + LD["本地 file_dir
CACHE_DIR/linsight/{svid:8}/
(写穿缓存 + 执行器 cwd)"] + MF["MinIO linsight/final_result/{svid}/
(交付快照)"] + end + + U -->|直写 MinIO| MW + A -->|写穿:先本地后 MinIO| LD + A --> MW + C -->|"只写本地"| LD + MW -.->|read 懒加载回填| LD + + LD -->|"任务收尾 os.walk 扫描
select_deliverables"| MF + MF --> FE["前端产物列表
output_result.final_files"] + + MW -->|"跨轮 copy-forward
output/ + uploads/"| MW2["下一轮 workspace/{新svid}/"] + + style C fill:#ffe6e6 + style LD fill:#fff4e6 + style MW fill:#e6f3ff + style MF fill:#e6ffe6 +``` + +图中最关键的是那条**红色的单向箭头**:代码执行器只写本地,不写 MinIO。第四章和第六章几乎所有内容都源于此。 + +--- + +## 第一章 · 门面层:workspace 与 deepagents 虚拟文件系统的关系 + +> **本章结论**:是同一套接口的两种实现。接口(工具签名、返回结构)来自 deepagents,实现(物理落盘)是灵思自己的 `WorkspaceBackend`,载体是 MinIO。 + +### 1.1 deepagents 提供的是抽象,不是实现 + +deepagents(锁定版本 0.6.8,`src/backend/uv.lock:1357`)把文件系统抽象成 `BackendProtocol` 抽象基类(`deepagents/backends/protocol.py:329`),方法面为 `ls / read / write / edit / glob / grep / upload_files / download_files` 及其 `a*` 异步版本。框架内置四种实现: + +| 实现 | 载体 | +|---|---| +| `StateBackend` | LangGraph state(内存 / checkpoint)——**框架默认值** | +| `FilesystemBackend` | 本地磁盘 | +| `CompositeBackend` | 路由到多个 backend | +| `store / sandbox / local_shell / langsmith` | 其它载体 | + +`create_deep_agent` 在不传 backend 时用内存实现:`backend = backend if backend is not None else StateBackend()`(`deepagents/graph.py:590`)。 + +另有一个 `SandboxBackendProtocol`(`protocol.py:800`),在 `BackendProtocol` 之上多了 `execute/aexecute`——**这才是框架定义的"能执行命令的沙箱"**。记住这个区分,1.4 节要用。 + +### 1.2 灵思注入的是自己的 `WorkspaceBackend` + +```python +# bisheng/linsight/domain/services/workspace_backend.py:197 +class WorkspaceBackend(FilesystemBackend): +``` + +注入链路: + +``` +task_exec.py:691 backend = WorkspaceBackend(svid=session_model.id, minio=minio, file_dir=self.file_dir) + ↓ +agent_factory.py:778-784 create_deep_agent(..., backend=backend, ...) +``` + +所以**任务模式的 agent 从第一天起就没用过内存文件系统**——它调用的 `write_file` 工具是 deepagents 注册的(`deepagents/middleware/filesystem.py:763-771`,共 7 个:`ls`/`read_file`/`write_file`/`edit_file`/`glob`/`grep`/`execute`),但每一次调用最终落到的是 `WorkspaceBackend` 的方法体。 + +### 1.3 双层语义:MinIO 是真值,本地是写穿缓存 + +模块 docstring 本身就是契约(`workspace_backend.py:1-33`): + +| 语义 | 实现 | 位置 | +|---|---|---| +| **对象 key 规则** | `f"{WORKSPACE_PREFIX}/{self.svid}/{rel_path}"`,即 `workspace/{svid}/...` | `:222-224` | +| **本地缓存路径** | `Path(self.file_dir) / rel_path` | `:226-227` | +| **write 写穿** | 先写本地 cache,再同步 `_minio_put_sync` | `:289-295` | +| **read 懒加载** | cache 命中优先,miss 则回源 MinIO 并回填 | `:298-308`、`_materialize :267-272` | +| **ls 权威在 MinIO** | `minio_client_sync.list_objects(bucket, prefix, recursive=True)` | `:311-337` | +| **edit** | cache 改写 + 写穿 MinIO;非唯一匹配报错 | `:340-367` | + +写穿的意义:清空本地缓存是无损的,被挂起(park)的任务恢复时可以从 MinIO 重新 materialize。这也是任务结束能直接 `shutil.rmtree(self.file_dir)`(`task_exec.py:156`)而不丢数据的前提。 + +### 1.4 由此推出的三条反直觉行为 + +**(a) workspace 里"没有目录"。** `list_objects` 用了 `recursive=True`,MinIO 因此不返回目录前缀条目,`is_dir` 恒为 `False`(`workspace_backend.py:319-333`)。 + +这不是无害的细节——它直接改变了 Skill 的装配方式。`skill_provisioning.py:4-9` 的 docstring 明写:deepagents 原生的 `skills=` 参数指向这个 backend 会发现**零个** skill,因为它做 progressive disclosure 时依赖 `is_dir=True` 的目录条目。解法是给 `SkillsMiddleware` **另开一个真正的 `FilesystemBackend` 指向本地盘**: + +```python +# agent_factory.py:715-717 +SkillsMiddleware( + backend=FilesystemBackend(root_dir=file_dir, virtual_mode=True), + sources=[(f"/{WORKSPACE_SKILLS_DIR}/", "Skills")], +) +``` + +同理,`ls` **永远是全量递归**,`path` 参数只当作 key 前缀过滤用,不是 POSIX 的"列一层"(`:311-319`)。 + +**(b) `grep` 不是正则,是子串包含。** + +```python +# workspace_backend.py:408 +if pattern in line: +``` + +而 deepagents 原生 `FilesystemBackend.grep` 走 ripgrep / 正则。模型按工具描述传一个正则表达式过来,会**静默失配**——不报错,只是搜不到。 + +**(c) workspace 不是沙箱。** 它继承的是 `FilesystemBackend` 而非 `SandboxBackendProtocol`,通篇没有 `execute` 方法。deepagents 仍然会注册 `execute` 工具(`middleware/filesystem.py:770`),灵思也没有把它排除掉,所以模型**看得见**这个工具;但运行时 `supports_execution(backend)`(`middleware/filesystem.py:526`)为 False,调用只会拿回一条 error ToolMessage。 + +`WorkspaceBackend` 提供的"隔离"实际只有两样: + +| 手段 | 位置 | +|---|---| +| 拒绝 `..` 路径穿越(抛 `ValueError`) | `normalize_workspace_path :118-133` | +| key 前缀命名空间 `workspace/{svid}/`,无法跨会话访问 | `_object_key :222` | + +**没有**进程隔离、资源限制、网络隔离。真正的"执行环境"是另一回事——见第三章。 + +--- + +## 第二章 · 输入层:用户上传文件的四跳 + +> **本章结论**:上传件先被知识库 ETL 解析成 markdown,再以"文本视图 + 原件"**双轨**拷进 `workspace/{svid}/uploads/`。模型初见只有零正文的指针块,正文靠自己 `read_file` 取。 + +### 2.1 四跳全览 + +| 跳 | 落点 | 代码位置 | +|---|---|---| +| **① 上传落本地临时盘** | `CACHE_DIR/linsight/.`
⚠️ 这是 **API 进程的本地盘,linsight worker 进程读不到** | `workbench_impl.py:1095`(`upload_file`)
`core/cache/utils.py:211-231` | +| **② 后台解析 → markdown 进临时桶** | `tmp-dir` 桶,key = `.md`(无前缀)
元信息进 Redis,TTL 24h | `workbench_impl.py:1129`(`parse_file`)
`:1218` `put_object_tmp`
`:1262` Redis key `linsight_file:{file_id}` | +| **③ 提交问题时转正式桶 + 写进 workspace** | 正式桶 `linsight/{svid}/{file_id}.md`
**并且** `workspace/{svid}/uploads/<原名>.md` | `_formal_markdown_object :572`
`_write_attachment_to_workspace :656-720` | +| **④ 元信息落库** | `LinsightSessionVersion.files`(JSON 列,**没有独立表**) | `models/linsight_session_version.py:78-80` | + +第 ③ 跳有个已经踩过的坑值得记:svid 是**提前生成**的(`workbench_impl.py:198` `svid = uuid.uuid4().hex`),因为附件必须写进将来那个 session version 的 workspace 前缀。早期按 `chat_id` 写,导致 agent `read_file` 时 MinIO 报 NoSuchKey、整个任务失败。 + +### 2.2 解析:复用知识库那套 ETL,按扩展名分派 + +统一走 `TempFilePipeline`——知识库 RAG 的临时管线,**不入 Milvus / ES**(`workbench_impl.py:1179-1195`)。分派依据是**扩展名,不是 MIME**(`knowledge/rag/base_file_pipeline.py:26-58`): + +``` +xlsx/csv/xls → _init_excel_loader +doc/docx → _init_word_loader +ppt/pptx → _init_ppt_loader +pdf → _init_pdf_loader +ofd → _init_ofd_loader +png/jpg/... → _init_image_loader +mp3/mp4/... → _init_audio_loader / _init_video_loader +``` + +PDF 再四选一(`_build_pdf_loader`,`base_file_pipeline.py:207`):配了 ETL4LM → `Etl4lmLoader`;配了 Mineru → `MineruLoader`;配了 PaddleOCR → `PaddleOcrLoader`;都没有 → `LocalPdfLoader`。 + +**图片走的是 OCR 转文字,不是多模态 image block**:`_init_image_loader`(`:257-260`)复用 PDF loader,且当落到 `LocalPdfLoader`(即没配任何 OCR 服务)时直接 `raise KnowledgeFileNotSupportedError()`。这与前端只在 `enable_etl4lm` 时才把图片加进上传白名单是一致的(`client/src/common/index.ts:11-12`)。 + +### 2.3 双轨:文本视图 + 原件 + +只有 markdown 是不够的——把 Excel 转成 markdown 去做数值计算是南辕北辙。所以对一批格式额外把**原件**也写进 `uploads/`,与 `.md` 同目录并存(`_write_raw_original_to_workspace`,`workbench_impl.py:723-762`): + +```python +_RAW_KEEP_EXTS = frozenset({"xlsx","xls","csv","docx","doc","pptx","ppt","pdf","ofd"}) # :87 +_RAW_KEEP_MAX_BYTES = 50 * 1024 * 1024 # :91 +_IMAGE_EXTS = frozenset({"png","jpg","jpeg","gif","webp","bmp"}) # :77 +``` + +目的是让代码执行器能用 pandas / python-docx / fitz 精确读取。**图片被刻意排除在双轨之外**(`:83-86` 注释):图片原件只提升到正式桶供前端预览,不进 workspace——因为 `read_file` 回来会变成 image block,非视觉模型会拒收。 + +### 2.4 两层二进制守卫 + +双轨带来一个新问题:workspace 里现在有 `.pdf`/`.docx` 这类二进制原件了,而 deepagents 的 `read_file` 会按扩展名把它们包装成多模态 block(`deepagents/backends/utils.py:25-55`:`.pdf/.ppt/.pptx → "file"`,图片 → `"image"`)。但 `WorkspaceBackend.aread` 永远返回 `decode("utf-8", errors="replace")` 的文本(`:477-480`),于是产出的是"把乱码当 base64"的坏块——这正是历史上 **"PDF 触发 400 Invalid value: file"** 的成因。 + +两层拦截(`binary_content_guard.py`): + +| 中间件 | 拦截点 | 行为 | +|---|---|---| +| `BinaryReadGuardMiddleware`(`:122`) | `awrap_tool_call`,拦 `read_file` 的返回 | 非文本 block 或 mojibake(U+FFFD 占比 >5% 或含 `\x00`)→ 换成可执行的文字提示 | +| `ModelContentGuardMiddleware`(`:168`) | `awrap_model_call`,拦出站消息 | `_BLOCKED_BLOCK_TYPES = {"file","audio","video","input_audio"}`(`:57`)一律换文本占位 | + +`image` 块**故意放行**——主流 provider 接受它。 + +装配位置:`agent_factory.py:691-692`(主图)、`:765-766`(子代理)。 + +### 2.5 模型初见的是指针,不是正文 + +`prepare_file_list`(`workbench_impl.py:871-953`)生成一个**零正文**的块,拼进第一条 user message: + +``` + +- path: /uploads/委托书.md + name: 委托书.pdf + lines: 1234 + images: 0 + raw: /uploads/委托书.pdf + +``` + +话术按是否绑了代码执行器分岔(`:936-951`):绑了才会说"raw 指向原件可用代码读取",没绑则改说"无法读取原件,请基于文本视图作答"。这个 lockstep 由 `task_exec.py:688` 的 `_has_code_interpreter` 统一驱动,是为了避免"提示模型去调一个根本不存在的工具"这类失败模式。 + +### 2.6 跨轮延续:服务端 copy-forward + +新一轮任务会生成**新的 svid**,也就意味着一个全空的 `workspace/{新svid}/` 前缀——上一轮的 `output/report.md` 直接读不到。解法是开局做一次服务端拷贝: + +```python +# workspace_backend.py:136-140 +async def seed_workspace_from_previous(minio, src_svid, dst_svid, + zones=(OUTPUT_DIR, UPLOADS_DIR)) -> int: +``` + +关键性质: + +- **只拷 `output/` + `uploads/` + `manifest.json`**;`scratch/` 刻意排除(`:149-150`) +- **服务端 copy**(`minio.copy_object_sync`,`:181-184`),字节不经过应用 +- **幂等**:目标前缀已有任意对象就直接返回 0(`:166-167`) +- **best-effort**:单对象失败只 warning,不阻塞本轮(`:185-191`) +- **只从紧邻的上一个 version 拷**——因为每轮的 workspace 都是累积的,拷一轮即承接整条会话(`:146-149`) + +调用点唯一:`task_exec.py:467`,在建 agent 之前。注意 `POST /workbench/continue`(追问)**复用同一个 svid 和同一个 LangGraph thread**,因此不触发也不需要 copy-forward。 + +--- + +## 第三章 · 执行层:代码执行器跑在哪 + +> **本章结论**:跑在 linsight worker 容器内的一个子进程里,cwd 就是 `WorkspaceBackend` 的本地写穿缓存目录——与 workspace **共享本地目录,但不共享 MinIO**。没有任何沙箱级隔离。 + +### 3.1 工具是条件启用的 + +工具类 `CodeInterpreterTool`,工具名 `bisheng_code_interpreter`,只有一个参数 `python_code: str`(`bisheng_langchain/gpts/tools/code_interpreter/tool.py:47-73`)。 + +**按用户在输入框的勾选启用**,不是无条件绑定: + +```python +# workbench_impl.py:1266-1277 (_init_bisheng_code_tool) +bisheng_code_tool = await GptsToolsDao.aget_tool_by_tool_key(tool_key="bisheng_code_interpreter") +if not bisheng_code_tool or bisheng_code_tool.id not in selected_tool_ids: + return tools # 本轮没勾选 → 不绑定 +selected_tool_ids.remove(bisheng_code_tool.id) # 消费掉,避免通用通道二次构建 +``` + +新装实例会把它放进默认预勾选清单(`workstation/domain/services/workstation_service.py:105-110`),所以多数用户感知上"它一直都在"。 + +### 3.2 两种实现,生产上只有一种活着 + +| 实现 | 文件 | 状态 | +|---|---|---| +| `LocalExecutor` | `local_executor.py:52` | **默认,生产链路** | +| `E2bCodeExecutor` | `e2b_executor.py:26` | 远程 E2B 沙箱,需显式配 `type != "local"` | + +选择逻辑在 `bisheng_langchain/gpts/load_tools.py:104-112`,`executor_type` 默认 `"local"`。 + +**E2B 的 workspace 集成在生产上是死代码**:`self.workspace_backend = kwargs.get("workspace_backend")`(`e2b_executor.py:52`)这个 kwarg 全仓无人注入(唯一赋值点是测试 `test/linsight/test_e2b_copy_in_out.py:121`),而 `_materialize_working_set()` 恒返回 `{}`(`:140-149`,标着 `TODO(Wave 2)`)。结果是切到 E2B 后,沙箱既拿不到 workspace 文件,产物也不回写 workspace。 + +### 3.3 真正的执行动作 + +```python +# local_executor.py:155-179 +cmd = [sys.executable if lang.startswith("python") else cls._cmd(lang), filename] +... +future = executor.submit(subprocess.run, cmd, cwd=work_dir, capture_output=True, text=True) +``` + +即 **linsight worker 进程 fork 出的子进程,用 worker 自己的 Python 解释器**。工作目录: + +```python +# local_executor.py:340-341 +if self.local_sync_path and os.path.exists(self.local_sync_path): + exit_code, logs, file_list = self.run_with_dir(code, dir_path=self.local_sync_path, lang=lang) +``` + +而 `local_sync_path` 被显式绑成任务的 `file_dir`: + +```python +# workbench_impl.py:1284, 1287 +code_config["config"]["local"]["local_sync_path"] = file_dir +code_config["config"]["e2b"]["local_sync_path"] = file_dir +``` + +`file_dir` 同时也是 `WorkspaceBackend` 的写穿缓存目录(`task_exec.py:691` 传的是同一个变量): + +```python +# task_exec.py:562 +file_dir = os.path.join(CACHE_DIR, "linsight", session_model.id[:8]) +``` + +**这就是全文最关键的那个交汇点**:执行器的 cwd ≡ workspace 的本地缓存。 + +### 3.4 没有隔离 + +| 维度 | 现状 | +|---|---| +| 进程/容器 | 同一个 linsight worker 容器,同用户,同 Python 解释器 | +| 文件系统 | 可访问容器内任意路径(工作目录只是**软约定**) | +| 网络 | 与 worker 同网络 | +| 凭据 | MinIO 凭据本来就作为 `minio` dict 传给执行器(`tool/domain/services/executor.py:157-160`) | +| 资源限制 | **未找到**任何 ulimit / cgroup / seccomp / 内存 CPU 限制 / 降权用户 | + +`base_executor.py:12-14` 的注释自己也称其为 "the shared LocalExecutor"。 + +### 3.5 环境细节 + +| 项 | 值 | 位置 | +|---|---|---| +| 超时 | `DEFAULT_TIMEOUT = 600`(秒)——但**并不真正终止子进程**,见第六章 #3 | `local_executor.py:24, 206` | +| 可用库 | pandas、numpy、matplotlib、openpyxl / XlsxWriter、python-docx、Pillow、reportlab、PyMuPDF(`fitz`) | `LOCAL_DESCRIPTION :43-49` | +| 明确禁止 | pdfminer / pdfplumber / PyPDF2(未安装)、`pip install`(共享离线环境) | 同上 | +| 中文字体 | 每次运行强制注入 `matplotlib.rc("font", family="WenQuanYi Zen Hei")`,并 `os.remove` 掉 matplotlib 全局字体缓存 | `:77-94` | +| 代码文件清理 | `finally: os.remove(filepath)`(`tmp_code_.py`) | `:222-228` | +| 任务目录清理 | 每轮任务结束 `shutil.rmtree(self.file_dir, ignore_errors=True)` | `task_exec.py:156` | +| 沙箱关闭 | fresh / resume / continue 三条路径都会找到工具并 `close()`;LocalExecutor 的 `close()` 是空实现 | `task_exec.py:312/408/501`、`base_executor.py:129-130` | + +### 3.6 执行前的预取 + +worker 在建 agent 之前会把上传件**双轨预取**到 `file_dir` 根目录(`task_exec.py:566-607`): + +- `_download_file`(`:611`)拉 `markdown_file_path` → `file_dir/.md` +- `_download_raw_original`(`:636`)拉 `original_file_path` → `file_dir/` + +原因写在注释里(`:588-593`):代码执行器的文件清单是用 `os.walk(file_dir)` 生成的,**原件不落到这里,pandas / python-docx / fitz 就看不见它**。 + +紧接着拍一张 baseline 快照(`:607` `snapshot_file_paths`),这是后面判定"哪些文件是本轮产出"的依据。 + +--- + +## 第四章 · 产出层:执行器生成的文件去了哪 + +> **本章结论**:落在本地 `file_dir/output/`,靠任务收尾的**本地目录扫描**进入交付列表。全程不经过 MinIO workspace 前缀。 + +### 4.1 完整链路 + +```mermaid +sequenceDiagram + participant M as 模型 + participant E as LocalExecutor + participant FD as 本地 file_dir + participant TMP as MinIO tmp-dir 桶 + participant H as 任务收尾 (task_exec) + participant FR as MinIO final_result + participant UI as 前端 + + M->>E: python_code + E->>FD: ① pre_snapshot(前置快照) + E->>FD: ② subprocess.run(cwd=file_dir) 写文件 + E->>FD: ③ post_snapshot → diff 出 created / modified + E->>FD: ④ 根目录新文件搬进 output/ + E->>TMP: ⑤ 上传 touched 文件,取 7 天预签名 URL + E-->>M: {exitcode, log, file_list:[URL...]}
(只进模型上下文,非用户产物) + + Note over H: 任务完成 + H->>FD: ⑥ read_file_directory:os.walk 全量扫描 + H->>H: ⑦ select_deliverables:排除非交付区 → 优先 output/ → 类型排序 + H->>FR: ⑧ 上传 linsight/final_result/{svid}/{file_id}{ext} + H->>UI: ⑨ output_result.final_files +``` + +逐步取证: + +| 步 | 说明 | 位置 | +|---|---|---| +| ①③ | `_snapshot_files` 递归 `os.walk`,跳过隐藏目录与 `__pycache__`;前后 diff 才能回答"这次运行写了什么"——工作目录里还混着预取的上传件和之前每一步的产物 | `local_executor.py:230-253` | +| ④ | `_relocate_root_files`:**只搬本次运行新建的**根级文件进 `output/`,预取的上传源在前置快照里因而原地不动;同名覆盖是有意为之(重跑要刷新而非堆积 `report (1).xlsx`) | `:255-287` | +| ⑤ | `upload_minio` → **`tmp-dir` 桶 + 7 天预签名 URL** | `base_executor.py:88-127` | +| ⑥ | `read_file_directory(self.file_dir)`——**本地 `os.walk`,不是 MinIO** | `utils.py:173-200`;三条完成路径调用点 `task_exec.py:1424 / 1482 / 1510` | +| ⑦ | `select_deliverables` | `utils.py:203-256` | +| ⑧ | `linsight/final_result/{svid}/{file_id}{ext}`(纯 ASCII key,规避华为 OBS 预签名 403) | `utils.py:288` | +| ⑨ | `output_result.final_files` → 前端 `file_list` | `client/src/hooks/useLinsightManager.tsx:130` | + +⑤ 那批 URL **只进模型上下文,不是用户看到的产物列表**——这是一个常见误解。用户产物完全由 ⑥⑦⑧ 决定。 + +### 4.2 交付物筛选规则 + +`select_deliverables`(`utils.py:203-256`)是两条互斥的有序判据,**不做任何文本匹配**: + +1. **`output/` 区**——所有写入方都被指向的交付契约 +2. **本轮新建的文件**(对比 baseline)——仅当 `output/` 为空时启用;没有 baseline 则跳过而不猜 + +两条判据都排除 `NON_DELIVERABLE_ZONES = {scratch, uploads, skills}`(`:105`)。排除 `skills/` 尤其重要:skill bundle 是在 baseline 快照**之后**才拷进 workspace 的,不排除的话,一次没产出 output/ 文件的运行会把上百个 `SKILL.md` 当成"本轮产物"交付出去。 + +结果按**文件类型排名优先、时间次之**排序(`_DELIVERABLE_TYPE_RANK`,`:133-164`): + +``` +文档 (.md/.docx/.pdf/.html…) = 0 +表格 (.xlsx/.csv…) = 1 +演示 (.pptx…) = 2 +其它 (.zip/.ipynb…) = 3 +图片 (.png/.jpg…) = 4 +``` + +`files[0]` 就是前端"已为您整理好 X"的标题文件。按类型而非时间排序是有意的:一次先写 `报告.docx` 再渲染三张图表的运行,最新文件是 PNG——恰恰是最不该当标题的东西。 + +**兜底**:若一个 output/ 产物都没有,把最终 answer 写成 `output/报告.md` 再走同样的上传流程(`build_fallback_report_file`,`utils.py:323-367`)。弱模型有时会在 `write_todos` 上打转、始终不调 `write_file`,这条兜底保证任务至少有交付物。 + +### 4.3 绝对路径逃逸 + +模型写 `/output/report.pdf`(前导斜杠)时,文件落到**容器文件系统根目录**,在被扫描的 `file_dir` 之外——于是静默消失,用户只看到兜底的 `报告.md`。 + +因为 LocalExecutor 是共享的,从容器根抢救文件会造成任务间串档,所以选择"**不救 + 追加纠正提示**": + +```python +# base_executor.py:18 +_ABSOLUTE_DELIVERABLE_RE = re.compile(r"""['"]/(?:output|scratch)(?:/|['"])""") +``` + +匹配上就把 `ABSOLUTE_PATH_NOTICE`(`:20-26`)追加到工具结果末尾,非阻断,让模型下一步自纠(触发点 `local_executor.py:350-357`)。姊妹机制是 `relocation_advisory`(`:74-86`),配合 4.1 步骤④告知模型新路径——因为搬完之后旧路径就不再 resolve 了。 + +--- + +## 第五章 · 五个 key 空间对照表 + +把全文压成一张表。**同一个文件在这条链路上最多会存在于三个地方**。 + +| # | 存储 | 位置 | 写入方 | 谁能看到 | 生命周期 | +|---|---|---|---|---|---| +| 1 | **MinIO workspace** | `workspace/{svid}/`
`├ uploads/`(上传件双轨)
`├ output/`(交付区)
`├ scratch/`(中间态)
`└ skills/`(技能包) | 平台上传搬运、agent `write_file`/`edit_file`/`export_docx`、skill 拷贝 | agent 的 `ls`/`read_file`/`glob`/`grep` | 长期;跨轮 copy-forward | +| 2 | **本地 file_dir** | `CACHE_DIR/linsight/{svid[:8]}/` | 上述写穿 + 预取 + **代码执行器(独占)** | 代码执行器(cwd);agent 只能 `read_file` 命中,`ls` 看不到 | **任务结束即删** | +| 3 | **MinIO 交付区** | `linsight/final_result/{svid}/{file_id}{ext}`
`linsight/session_files/{svid}/{file_id}{ext}` | 任务收尾扫描后上传 | 前端产物列表 / 下载 | 永久快照 | +| 4 | **MinIO 正式桶(上传件)** | `linsight/{svid}/{file_id}.md`
`linsight/{svid}/{file_id}_original.{ext}` | 提交问题时 tmp→正式 | 前端上传件预览;worker 预取的来源 | 永久 | +| 5 | **MinIO 临时桶** | `tmp-dir` 桶:`.md`、执行器产物 `..{ext}` | 上传解析、执行器 `upload_minio` | 解析中转;执行器返回给模型的 URL(7 天) | 临时 | + +各区的创建者归属(第 1 项展开): + +| 目录 | 谁创建 | 用途 | +|---|---|---| +| `uploads/` | **平台**,提交时直写 MinIO(不经 backend) | 用户附件的 md 视图 + 原件 | +| `output/` | **模型**(`write_file`/`export_docx`/`export_pdf`)、**代码执行器**、**兜底逻辑** | 唯一交付区 | +| `scratch/` | **模型**(写 key 即建) | 中间产物;不交付、不跨轮拷贝 | +| `skills/` | **平台**,任务启动时拷贝(`materialize_session_skills`,`skill_provisioning.py:47`),拷贝动作本身即白名单闸门 | 本轮授权的 skill bundle | +| `charts/` | **无人**——它只是系统提示词里的一个示例路径 `output/charts/x.png`(`agent_factory.py:126`),模型写时才顺带产生 | — | +| `manifest.json` | **无写入实现**(见第六章 #9) | — | +| `large_tool_results/`、`conversation_history/` | **deepagents 框架**(同一个 backend) | 超长工具结果 / 历史卸载(`deepagents/middleware/filesystem.py:752-753`) | + +--- + +## 第六章 · 已查实的缺口与风险 + +本章每条均有代码位置,**本轮不做修改**,供后续单独排期。按影响面排序。 + +### #1 代码执行器产物对 `ls` 不可见(结构性) + +- **现象**:agent 用代码执行器画完图,再调 `ls` 看不到那张图;下一轮任务也读不到上一轮用代码生成的图表 +- **位置**:`local_executor.py:319-329`(只写本地 + 传 tmp 桶)vs `workspace_backend.py:311-337`(`ls` 权威在 MinIO) +- **机理**:`write_file` 写的文件是本地 + MinIO 双写,`ls` 看得到;代码执行器写的文件**只有本地**,于是 `read_file` 能命中缓存读到,`ls`/`glob`/`grep` 却一律看不到;跨轮 `seed_workspace_from_previous` 也带不走 +- **影响**:交付不受影响(走本地扫描),但模型的世界观是撕裂的,容易在"我明明写了"和"列不出来"之间空转 +- **修复取向**:在执行器收割阶段把 `touched` 文件写穿一次 MinIO workspace(可复用 `WorkspaceBackend.write`) + +### #2 执行失败时日志被吞 + +```python +# local_executor.py:345-346 +if exit_code != 0: + return {"exitcode": exit_code, "log": logs_all} # logs_all 尚未累加本块 logs +``` + +- **触发**:单代码块(`extract_code` 对纯代码返回 1 个块,是常态)+ 执行失败 +- **影响**:模型拿到 `{"exitcode":1,"log":""}`,看不到 traceback,只能盲改重试 +- **修复取向**:返回前累加当前块的 `logs` + +### #3 超时不真正生效 + +```python +# local_executor.py:168-179 +with ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(subprocess.run, cmd, cwd=work_dir, ...) + try: + result = future.result(timeout=timeout) + except TimeoutError: + return 1, TIMEOUT_MSG, "" +``` + +- **机理**:超时后确实立刻返回,但退出 `with` 会触发 `shutdown(wait=True)`,阻塞到 `subprocess.run` 自己结束;子进程**从不被 kill**。Windows 分支干脆完全没有超时(`:159-166`) +- **影响**:一个死循环脚本会挂住 worker 的并发槽 +- **修复取向**:改用 `subprocess.Popen` + `communicate(timeout=)` + `kill()` + +### #4 E2B → workspace 回写是死代码 + +- **位置**:`e2b_executor.py:52`(kwarg 无人注入)、`:140-149`(`_materialize_working_set` 恒 `return {}`)、`:194-196`(回写分支永不执行) +- **影响**:一旦切到 E2B,沙箱既拿不到 workspace 文件(只能靠 `init_sandbox` 时预打包的 `file_list`),产物也不回写 +- **修复取向**:与 #1 一并处理——注入 `workspace_backend` 并完成 Wave 2 的 TODO + +### #5 `large_tool_results/` 与 `conversation_history/` 未列入非交付区 + +- **位置**:`utils.py:105` 的 `NON_DELIVERABLE_ZONES` vs `deepagents/middleware/filesystem.py:752-753` +- **触发**:`output/` 为空、走 baseline-diff 兜底判据时 +- **影响**:框架卸载的超长工具结果 / 对话历史文件会被当成"本轮产物"交付给用户 +- **修复取向**:把这两个前缀加进 `NON_DELIVERABLE_ZONES` + +### #6 成功时日志翻倍 + +```python +# local_executor.py:297 +logs += "\n" + logs +``` +自我拼接,每次成功执行的日志都翻一倍进模型上下文。纯浪费。 + +### #7 上传对象名双点 + +`local_executor.py:323-324`:`file_ext` 来自 `os.path.splitext(rel)[-1]`(已含 `.`),再拼 `f"{uuid}.{file_ext}"` → `abc..png`。影响观感与下载名。 + +### #8 `file_dir` 只取 svid 前 8 位 + +`task_exec.py:562` `session_model.id[:8]`。8 位十六进制在同机多任务下存在理论碰撞面,而该目录同时是 workspace 缓存与执行器 cwd——碰撞意味着两个任务的文件互串。未见去碰撞处理。 + +### #9 文档与实现漂移(三处) + +| 漂移 | 位置 | +|---|---| +| `manifest.json` 只有常量定义(`:95`)与跨轮拷贝时的 key 拼接(`:175`),**无任何写入方** | `workspace_backend.py:32` docstring 称其为大文件指针清单 | +| `uploads//index.md` 与实现的 `uploads/.md` 不符(`prepare_file_list` 的 fallback `workbench_impl.py:905` 仍按旧格式拼) | `workspace_backend.py:29` | +| `charts/` 只出现在系统提示词示例中,全仓无其它引用 | `agent_factory.py:126` | + +### #10 `image_count` 恒为 0 + +`workbench_impl.py:717` 只做 `entry.get("image_count", 0)`,全仓无写入点,指针块因此恒输出 `images: 0`。根因在上游:`TempFilePipeline`(`temp_file_pipeline.py:16`)**没有覆写** `_get_image_object_dir`,继承基类的 `return None`(`base_file_pipeline.py:145-152`,经 `:142` 传给 loader),于是 loader 不上传抽出的图片——而知识库正式入库路径是覆写了的(`knowledge_file_pipeline.py:103`、`preview_file_pipeline.py:59`)。指针块信息因此失真。 + +### #11 本地预取路径与 workspace 相对路径不一致 + +`_download_file` 落到 `file_dir/.md`(`task_exec.py:614-617`),而 `WorkspaceBackend._cache_path` 期望 `file_dir/uploads/.md`(`workspace_backend.py:226`)。结果:agent 第一次 `read_file('/uploads/x.md')` 必然 cache miss 回源 MinIO。功能正确(回源能拿到),只是白付一次往返;预取实际只对代码执行器的 `os.walk` 有效。注释(`task_exec.py:567-571`)看起来是有意为之。 + +### #12 matplotlib 字体缓存被全局删除 + +`local_executor.py:83-86`:每次运行都 `os.remove` 掉 matplotlib 的全局 `fontlist*` 缓存。这是跨任务的进程级副作用,并发场景下会互相拖慢首次绘图。 + +### #13 `grep` 语义与工具描述不符 + +`workspace_backend.py:408` 用子串包含实现,而 deepagents 的工具描述按正则宣传。模型传正则过来会静默失配。 + +--- + +## 附录 · 取证方法 + +本文结论全部来自静态代码取证,基线 `e96ce0017`,工作树 clean。核心文件: + +| 文件 | 覆盖内容 | +|---|---| +| `bisheng/linsight/domain/services/workspace_backend.py` | 第一章全部、2.6 跨轮延续 | +| `bisheng/linsight/domain/services/workbench_impl.py` | 第二章上传链路、3.1 工具启用、3.3 目录绑定 | +| `bisheng/linsight/domain/task_exec.py` | 生命周期、预取、baseline、三条收尾路径 | +| `bisheng/linsight/domain/utils.py` | 第四章收割与交付物筛选 | +| `bisheng_langchain/gpts/tools/code_interpreter/{base_executor,local_executor,e2b_executor}.py` | 第三、四章执行器 | +| `bisheng/linsight/domain/services/{agent_factory,skill_provisioning,binary_content_guard}.py` | 中间件装配、skill 供给、二进制守卫 | +| `.venv/.../deepagents/{graph.py,backends/protocol.py,middleware/filesystem.py}` | 框架侧抽象与默认值 | + +**复核建议**:第六章 13 条缺口每条都能在当前工作树按给出的行号直接定位。若行号有漂移,用文中引用的符号名(如 `_relocate_root_files`、`select_deliverables`、`seed_workspace_from_previous`)grep 即可重新锚定。 + +**相关文档**:同目录《灵思任务模式运行链路与 deepagents 上下文工程对比》(执行流与上下文)、《灵思任务模式后端执行与前端渲染映射技术报告》(前端渲染)、《灵思任务模式 Skill 能力恢复方案》(skills/ 区的来龙去脉)。 diff --git "a/docs/PRD/3.0-beta2/3.0 DSH Desktop \344\274\201\344\270\232\346\216\245\345\205\245\346\226\271\346\241\210.md" "b/docs/PRD/3.0-beta2/3.0 DSH Desktop \344\274\201\344\270\232\346\216\245\345\205\245\346\226\271\346\241\210.md" new file mode 100644 index 0000000000..304384ec78 --- /dev/null +++ "b/docs/PRD/3.0-beta2/3.0 DSH Desktop \344\274\201\344\270\232\346\216\245\345\205\245\346\226\271\346\241\210.md" @@ -0,0 +1,504 @@ +# DSH Desktop 企业接入方案 · 统一登录与企业模型服务 + +| 项 | 值 | +|---|---| +| 文档版本 | **v0.9**(草案) | +| 日期 | 2026-09-03(v0.1 起于 2026-09-02) | +| 读者 | 产品经理。讲清实现思路与框架,不含代码细节 | +| 需求范围 | ① dsh desktop 复用 BiSheng 登录体系;② BiSheng 有选择地把托管模型转接给 dsh desktop(按人 / 部门授权、员工不持厂商密钥),并提供用量统计、审计、RPM / TPM / 预算额度 | +| 关联文档 | 《3.0 开放 API 鉴权与身份传递 PRD》v2.4(凭据底座、个人访问令牌 §4.10、治理闸门);《3.0 应用工场 PRD-1》v2.0(DEV-02 模型协议面、GOV-04 审计、§5.2 顺延清单);`features/v3.0.0/051-model-protocol-gateway/spec.md`;`features/v3.0.0/release-contract.md`;`docs/architecture/11-gateway.md`(商业网关) | +| 代码核实日期 | 2026-09-02(分支 `3.0-vibe`;网关取自本地私有仓 `bisheng-gateway`) | +| 外部取证 | dsh-desktop 仓库(README、`src/main`);DeepSeek Harness 用户指南「模型配置」与本地克隆的 `packages/{boot,llm,credentials,settings}` | +| 交互演示 | [演示站](https://claude.ai/code/artifact/8ab14900-e95b-42dd-8a5c-3f2750e046b0):九个界面场景可点可切状态,另有浏览器与桌面端联动的「完整剧本」;各节标注对应场景 | + +> **阅读指引** +> §一是全文结论,只读这一节即可做立项决策。§二说明需求来源,§三是一张总图,§四逐块展开,§五是管理员视角,§六分期与工作量,§七决策记录与连带修订,§八回答「做成付费功能要改什么、放不放网关」。 +> 本文档自足,不依赖任何前置讨论背景。 + +--- + +# 一、结论 + +## 1.1 一句话 + +**把 dsh desktop 当作《鉴权 PRD》「场景四 · 员工自己选的工具」的又一个消费者接入:登录复用平台登录、凭据用个人访问令牌、模型走平台的 OpenAI 兼容模型面、开放范围与额度在同一份授权面配置、用量与审计落在既有的逐条调用记录上。** + +不引入任何新概念。新增的是四处扩展:个人访问令牌放开「模型调用」权限位并支持一人多把;模型协议面接受自然人主体;模型升格为可授权资源;额度与用量视图(原顺延到 v3.1 的两项提前)。 + +## 1.2 三层机制 + +| 层 | 回答的问题 | 做法 | 依据 | +|---|---|---|---| +| **登录** | 谁在用 | 桌面端把员工带到平台登录页(含企业 SSO),登录后平台签发一把**个人访问令牌**自动带回桌面端;员工不填任何密钥 | 《鉴权 PRD》§4.10 个人访问令牌:主体是自然人、权限动态继承本人、随人事级联失效 | +| **模型** | 能用什么 | 员工手里只有这把个人令牌;厂商密钥只在平台模型管理页;桌面端经平台的 OpenAI 兼容模型面调用,只看得到**被授权给本人**的模型 | PRD-1 DEV-02 模型协议面;F051 spec | +| **治理** | 能用多少、用了多少 | **授权**答「能不能」(按人 / 部门 / 用户组开放模型),**额度**答「能用多少」(RPM / TPM / 月预算),**逐条记录**答「谁、什么时候、用哪个模型、多少 token」;三者在管理面同处配置与查看 | 《鉴权 PRD》§4.10.7 三道闸门;PRD-1 GOV-04 | + +## 1.3 复用、规划中、新增 + +| 件 | 状态 | 本需求要做的 | +|---|---|---| +| 平台自签发凭据底座(哈希存储、明文只显示一次、5 秒撤销、租户隔离) | **已实现** | 直接复用 | +| 个人访问令牌 `bs-pat-` | PRD 已定,**未实现** | 本需求是它的第二个消费者:放开 `model:invoke` 权限位;一人多把、按接入端签发 | +| OpenAI 兼容模型协议面(F051) | spec 已定,**未实现** | 凭据主体扩展到自然人;可用范围 = 租户已启用 ∩ 授权给本人 | +| 模型的按人 / 部门授权 | **新增** | 模型升格为可授权资源,复用既有权限弹窗 | +| 额度:RPM / TPM / 月预算 | **新增**(PRD-1 §5.2 顺延项提前) | 平台没有限流基础设施,从零建 | +| 逐条模型调用记录 | F051 已定 | 加「持有人」「接入端」维度 | +| 用量聚合视图 | **二期**(2026-09-03 裁定本期不做) | 管理面按人 / 部门 / 模型 / 时间的汇总与导出移二期;本期只有逐条记录(F051 既有)与员工本月用量自查(额度计数顺带得到) | +| 桌面端登录(两个入口)与「账号与企业」 | **新增**(dsh 企业扩展包 + 桌面壳三处小改) | 入口 A 桌面端发起;入口 B 固定链接(管理员分发,或工作台头像菜单「DSH Desktop」)自动拉起桌面端(§4.1);整套功能由「DSH Desktop 接入」开关控制、默认关 | +| 授权密钥与席位 | **新增** | 桌面端接入全功能开源;不配密钥免费 10 个席位,密钥抬高席位上限或不限(§8.5) | + +## 1.4 分期 + +- **一期(跑通闭环)**:桌面端登录(两个入口)+ 个人访问令牌 + 模型协议面 + 模型开放范围 + 逐条记录 + 月预算硬闸 + 授权密钥与席位上限。员工登录一次即可在 dsh 里用企业模型,管理员能开放模型、设月预算、查到每一次调用。 +- **二期**:RPM / TPM、用量看板、金额预算、部门共享池。 +- 工作量粗估与锚点见 §六。 + +--- + +# 二、需求 + +## 2.1 谁在用 + +| 角色 | 要什么 | +|---|---| +| 员工 | 在 dsh desktop 里用企业身份登录一次,之后直接用企业模型;不找模型地址、不管密钥 | +| 租户管理员 | 决定哪些模型开放给谁、每人能用多少;看得到谁用了多少;能随时收回 | +| 安全与合规 | 员工离职即失效;每次调用可追溯到人;厂商密钥不出平台 | + +## 2.2 与《鉴权 PRD》场景四的关系 + +场景四的判据是「**谁来配这把密钥**」:前三类场景由管理员配,场景四由员工自配。dsh desktop 正是员工自己选的工具,属场景四。三处相同、三处不同: + +| 维度 | 场景四(技能包接通用 Agent) | dsh desktop | +|---|---|---| +| 密钥由谁配 | 员工本人 | 员工本人(但由桌面端自动完成) | +| 权限来源 | 持有人本人的既有权限 | 相同 | +| 离职 / 停用 | 令牌级联失效 | 相同 | +| **拿密钥的方式** | 到平台复制一串明文粘给 Agent | **登录即拿到**,员工不接触密钥明文 | +| **开放的能力** | 知识检索(数据流出组织) | **模型调用**(提示词流入平台,数据不流出) | +| **治理关注点** | 泄密 | **成本与滥用**,因此需要额度 | + +后两处差异决定了本方案比场景四多出两块:登录流程与额度控制。 + +## 2.3 需求归纳 + +1. 员工在桌面端**登录一次即用**,登录方式与平台一致(账号密码或企业 SSO) +2. 员工**不持有任何厂商密钥**,也不需要知道模型服务地址 +3. 员工在 dsh 里**只看到被授权给自己的模型**,多一个是越权、少一个是功能不可信 +4. 超出额度时**得到明确提示**,而不是莫名报错 +5. 管理员能看到**谁在用、用了多少**,并能按人 / 部门 / 模型收回或调额 +6. 员工离职、被停用后,桌面端**立即失效**,不依赖任何人记得去吊销;换租户不打断,令牌随人走 + +## 2.4 dsh 侧的接入形态(取证事实) + +dsh 的「设置 → 模型」支持**添加自定义 provider**:填 Provider ID、Base URL、API 协议、凭据、模型 ID 列表即可,凭据存本地凭据文件、**环境变量优先**。这意味着平台只要提供一个 OpenAI 兼容地址 + 一把令牌 + 一份模型清单,dsh 不需要任何改造就能用;桌面端要做的只是把这三样自动填进去。 + +自定义 provider 的协议只有三种:`openai-completions`、`openai-responses`、`anthropic-messages`,F051 的对话补全面对应第一种;对经代理转发的 DeepSeek 推理内容,dsh 有 `compat.thinkingFormat` 兼容项可声明。dsh 还提供程序化的扩展点(profile 扩展包、动态注册 provider、写凭据接口、设置页插槽),§4.6 的桌面侧实现即建立在这些扩展点上。 + +--- + +# 三、总体框架 + +``` + dsh desktop(Electron,捆绑 dsh 运行时) BiSheng(私有化部署) + ┌──────────────────────────────────────────┐ ┌──────────────────────────────────────────────┐ + │ 设置 → 账号与企业 │ │ 登录页(账号密码 / 企业 SSO,既有) │ + │ [登录企业账号] ──── 系统浏览器打开 ───────┼─────────▶│ 桌面端登录确认页 → 签发个人访问令牌 bs-pat- │ + │ ◀─── 登录结果回传本机 ───────────────────┼──────────┤ (接入端 = dsh-desktop@设备名) │ + │ 已登录 · 姓名 · 有效期至 · 本月用量 │ │ │ + │ │ │ OpenAI 兼容模型协议面(F051) │ + │ dsh 运行时 │ Bearer │ ① 令牌校验(5 秒撤销) │ + │ 自定义 provider = 平台地址 + 令牌 + 模型清单 ┼─ bs-pat- ▶│ ② 模型授权判定:租户已启用 ∩ 授权给本人 │ + │ (登录后自动写入,每次启动刷新清单) │ │ ③ 额度闸:RPM / TPM / 月预算 │ + │ │◀─ 流式 ──┤ ④ 转发上游模型(厂商密钥只在这里) │ + └──────────────────────────────────────────┘ │ ⑤ 逐条调用记录 → 用量视图 / 审计 │ + │ │ + 员工手里只有一把个人令牌 │ 管理面:模型「开放范围 + 额度」· 令牌台账 · │ + 厂商密钥永不出平台 │ 个人令牌能力开关 · 审计与用量 │ + └──────────────────────────────────────────────┘ +``` + +--- + +# 四、方案分述 + +## 4.1 统一登录:桌面端登录,浏览器完成、自动返回 + +> 交互演示:[S1 登录页与确认页](https://claude.ai/code/artifact/8ab14900-e95b-42dd-8a5c-3f2750e046b0#s1) · [S2 工作台头像菜单弹窗](https://claude.ai/code/artifact/8ab14900-e95b-42dd-8a5c-3f2750e046b0#s2:open) · [S8 深链确认](https://claude.ai/code/artifact/8ab14900-e95b-42dd-8a5c-3f2750e046b0#s8) · [完整剧本](https://claude.ai/code/artifact/8ab14900-e95b-42dd-8a5c-3f2750e046b0#script) + +### 两个入口,一套签发 + +| 入口 | 谁发起 | 员工怎么开始 | 登录结果怎么回到桌面端 | +|---|---|---|---| +| A · 桌面端发起 | 员工 | 在桌面端「设置 → 账号与企业」填平台地址、点「登录企业账号」 | 平台页面回传到桌面端在本机监听的地址 | +| B · 固定链接 | 管理员或员工 | 打开管理员分发的**固定链接**(如 `https://<平台地址>/desktop-login`),或在工作台头像菜单点「DSH Desktop」→「打开 DSH Desktop」;在网页里登录 | 平台页面用自定义协议 `dsh-desktop://…` 拉起本地桌面端 | + +两个入口共用同一张「桌面端登录」页、同一次确认、同一套令牌签发,只差登录结果回到桌面端的方式。入口 B 的链接**人人相同、不过期、不含任何身份或凭据**,可写进入职手册或做成二维码。**不设「授权码」这一产品概念**:浏览器与桌面端之间的交接凭证是实现细节,一次性、短时、不向员工展示,也不提供复制粘贴。 + +### 入口 A 流程 + +| # | 谁 | 做什么 | 看到什么 | +|---|---|---|---| +| 1 | 员工 | 在「设置 → 账号与企业」填一次平台地址,点「登录企业账号」 | 系统浏览器被打开 | +| 2 | 平台 | 打开「桌面端登录」页。已登录平台的直接进第 3 步;未登录的走平台既有登录(账号密码 / 企业 SSO),登录后回到本页 | 平台登录页 | +| 3 | 员工 | 确认页:「允许 DSH Desktop 使用你的身份调用企业模型」,显示设备名、用途、有效期至;点「允许」。席位已满时此处改为提示「本实例桌面端席位已用完,请联系管理员」,不签发 | 确认页 | +| 4 | 平台 | 签发一把个人访问令牌(接入端 = `dsh-desktop@设备名`),把登录结果回传到桌面端在本机监听的地址 | 浏览器页面提示「可以回到桌面端了」 | +| 5 | 桌面端 | 收到后自动写入 dsh 的自定义 provider:平台模型面地址 + 令牌 + 可用模型清单 | 「账号与企业」显示已登录、姓名与租户、有效期至、本月用量 | +| 6 | 员工 | 在 dsh 里选企业模型开始用 | 模型下拉只列被授权给自己的模型 | + +**兜底**:桌面端本机监听地址不可用(端口被占、安全软件拦截)时,页面提示「回到桌面端点『重试』」,桌面端换端口重新发起;不提供授权码复制粘贴。 + +**退出登录**:桌面端调平台接口吊销这把令牌并清本地配置。换一台设备登录 = 新签一把,互不影响。 + +### 入口 B 流程 + +| # | 谁 | 做什么 | 看到什么 | +|---|---|---|---| +| 1 | 管理员 / 员工 | 管理员把固定链接发给员工(企微 / 邮件 / 入职手册 / 二维码);或员工在工作台头像菜单点「DSH Desktop」→「打开 DSH Desktop」 | — | +| 2 | 员工 | 打开链接;未登录的走平台既有登录(账号密码 / 企业 SSO) | 平台登录页 | +| 3 | 员工 | 同入口 A 的确认页:「允许 DSH Desktop 使用你的身份调用企业模型」,点「允许」(含席位检查) | 确认页 | +| 4 | 平台 | 页面自动尝试打开 `dsh-desktop://login?server=…`(带一次性交接凭证),同时常驻「打开 DSH Desktop」按钮 | 浏览器询问「要打开 DSH Desktop 吗」 | +| 5 | 桌面端 | 被系统拉起(未运行则启动),弹确认「连接到 <平台地址>,身份 <姓名>?」,确认后向平台换取令牌,其余同入口 A 第 5 步 | 桌面端前置,「账号与企业」显示已登录 | +| 6 | 员工 | 在 dsh 里选企业模型开始用 | 模型下拉只列被授权给自己的模型 | + +**兜底**:未安装桌面端时页面给「下载 DSH Desktop」;浏览器不允许自动跳转或拦截了协议时,点常驻的「打开 DSH Desktop」按钮。不提供授权码复制粘贴。 + +### 深链安全规则 + +| 规则 | 说明 | +|---|---| +| 链接里只有一次性交接凭证 | 交接凭证 5 分钟内用一次即作废,绑定生成它的平台地址,不向员工展示;令牌本身永不出现在任何 URL | +| 桌面端兑换前必须确认 | 自定义协议可被任意网页触发,桌面端收到深链先展示平台地址与姓名,员工确认后才向该地址兑换 | +| 只对确认过的平台地址发起 | 防止恶意页面把桌面端引到别的服务器 | +| 浏览器行为如实告知 | 浏览器会先询问「要打开 DSH Desktop 吗」;部分浏览器不允许无点击的自动跳转,因此按钮常驻 | + +### 令牌规则 + +| 项 | 规则 | 依据 | +|---|---|---| +| 主体 | 登录的员工本人 | 《鉴权 PRD》§4.10.2 | +| 权限位 | 本期只有 `model:invoke`(模型调用与模型清单) | 开放判据 = 「出现只需要它的真实消费者」(《鉴权 PRD》§4.10.3),dsh desktop 即是 | +| 可访问范围 | 权限位 ∩ 持有人本人的模型授权(§4.3),每次调用现判、不做快照 | 与 §4.10.3 两层判定同构 | +| 接入端标签 | `dsh-desktop@设备名`,签发时由桌面端上报 | 个人中心与台账据此区分技能包令牌与桌面端令牌 | +| 有效期 | 租户配置的默认时长(默认 1 年),员工不可选;到期桌面端回到未登录态并提示重新登录 | §4.10.5 | +| 级联失效 | 持有人被停用 / 删除时立即失效,桌面端下次调用即被拒并提示;**换租户不失效**——令牌随人迁移到新租户(不重签,审计记一条「令牌随持有人迁移」),可用模型按当前租户与当前授权现判 | §4.10.5;「换租户」一项为本方案改判(D-21) | +| 管理员短路 | 管理员登录桌面端照常享受短路,但不跨租户 | §4.10.4(D17) | +| 一人多把 | 每个接入端一把;个人中心「授权管理」列表可逐个吊销 | §七 D-1 | +| 席位 | 签发前检查实例席位:持有效桌面端令牌的自然人数 ≤ 上限(免费 10,密钥可抬高或不限);已占席者换设备不占新席;超限不签发并提示 | §8.5 | + +### 与「OAuth 授权服务器」的边界 + +《鉴权 PRD》§六明确不做完整 OAuth 2.0 授权服务器,D18 取消了面向第三方平台的授权服务器。本方案不与之冲突:这是**第一方桌面端**的登录流程——客户端固定(只有 dsh desktop)、只允许回传到本机监听地址、无刷新令牌、无第三方注册、无权限协商。§六措辞需补一句限定(见 §七连带修订)。 + +### 工作台入口与治理 + +- **工作台头像菜单「DSH Desktop」**:与「审批中心」「消息提醒」同形态——菜单项拉起弹窗,位置在「消息提醒」之后。弹窗四件事:「打开 DSH Desktop」(即入口 B,员工已登录,直接进确认与拉起)、「下载 DSH Desktop」(下载地址由管理员在系统配置里设置)、「**授权管理**」(只列本人 DSH Desktop 的登录授权:接入端、创建时间、最后使用、有效期至、状态,逐个吊销)、本月用量与剩余额度。技能包等其它工具的个人令牌不在此处,仍走《鉴权 PRD》§4.10.6 的个人中心。 +- **管理员台账**(闸门二):本租户全部个人令牌,新增「接入端」与「占席」列;可按人批量强制吊销。 +- **「DSH Desktop 接入」开关**(闸门一):一个开关同时管**展示**与**放行**。开 = 工作台菜单入口、桌面端登录页、模型「开放范围」入口、系统配置席位与授权区、台账接入端列全部出现,桌面端令牌可签发可调用;关 = 以上全部不出现,桌面端登录页显示「本平台未启用 DSH Desktop 接入」,已签发令牌**停用不撤销**,重开即恢复。**默认关,管理员一键开**。层级沿用《鉴权 PRD》两层:部署级(运维配置)+ 租户级(租户管理员界面即时生效);租户级原「允许个人令牌用于模型调用」勾选升格改名为本开关,技能包的「允许用于知识检索」勾选保留。单租户只显示租户级一个,部署级未开时置灰说明原因。依据:升级不应静默打开新能力;两种用途数据流向相反,各自独立开关。 + +## 4.2 企业模型服务:员工只持令牌,模型走平台 + +- **承载面**:F051 模型协议面(OpenAI 兼容子集:对话补全含流式 + 模型列表)。dsh 侧以「自定义 provider」接入,桌面端登录后自动写入、每次启动刷新模型清单,员工零配置。 +- **「员工不持厂商密钥」如何成立**:厂商密钥只存在于平台模型管理页;桌面端只有个人令牌;令牌泄漏的爆炸半径 = 本人被授权模型的使用权 + 本人的额度,5 秒内可吊销。 +- **相对 F051 spec 的两处扩展**: + +| 项 | F051 spec 现定 | 本需求扩展 | +|---|---|---| +| 凭据主体 | 服务账号密钥、托管应用运行期凭据 | 加**自然人**(个人访问令牌),仍是「自身身份模式」,请求流程不变 | +| 可用模型范围 | 服务账号 = 租户已启用(租户级) | 自然人 = 租户已启用 **∩ 授权给本人**(§4.3) | +| 调用记录归属 | actor = 密钥、subject = 服务账号自身 | actor = 令牌、**subject = 持有人**(闸门三:区分本人在平台用与桌面端拉取) | + +其余规则原样沿用:模型名 = 模型管理页原名、跨服务商同名拒绝裸名并给限定名、模型下线 60 秒内对新调用生效、裸协议透传不建会话不留正文、错误对客户端可读。 + +- **风险与前置动作**:dsh 带 DeepSeek 协议扩展(推理内容、工具调用的流式形态)。模型面是裸透传、理论上不受影响,但**必须在一期早期用 dsh 真机跑一遍 PoC**,确认工具调用与流式在平台面上完整可用。只讲 Anthropic 协议的路径本版不承诺(F051 决议-1)。 + +## 4.3 模型开放范围:模型升格为可授权资源 + +> 交互演示:[S3 模型「开放范围 + 额度」弹窗](https://claude.ai/code/artifact/8ab14900-e95b-42dd-8a5c-3f2750e046b0#s3:open) + +| 项 | 规则 | 依据 | +|---|---|---| +| 资源类型 | 「模型」成为与知识库、工作流同级的可授权资源;模型管理页每个模型加「**开放范围**」入口 | 复用既有权限弹窗(用户 / 部门 / 用户组),零新建授权界面 | +| 关系 | 一种:「可调用」 | 模型没有编辑 / 删除的分档需求,一档即可 | +| 默认 | **无人可用**。管理员显式开放后员工才在桌面端看到 | 「有选择地开放」的字面含义;升级零影响(平台内消费不变,见下) | +| 作用范围 | **只管对外模型协议面**(dsh desktop 等外部客户端)。平台内工作台 / 助手 / 工作流照旧租户内全开 | §七 D-3:不动存量行为,升级无需补授权 | +| 判定 | 桌面端可用模型 = 租户已启用 ∩ 授权给本人(部门 / 用户组成员动态展开,每次调用现判) | 与《鉴权 PRD》§4.4.1 两层判定同构 | +| 管理员 | 租户管理员短路照常(本租户全部已启用模型),不跨租户 | D17 | +| 失败 | 授权引擎不可用 → 拒绝,绝不放行更宽集合 | 铁律 3 | +| 模型列表 | 模型列表端点按上述范围过滤,桌面端下拉与之一致 | F051 AC-10「三处同源」 | + +**现状说明**:`llm_server` / `llm_model` 今天已是权限引擎里的类型,但属旧式类型,没有接入 F048 的授权弹窗与可见性投影;今天唯一的授权关系是 Root 租户把服务商共享给子租户。把模型迁入 F048 体系,是本块的主要工程点。 + +## 4.4 额度控制:授权答「能不能」,额度答「能用多少」 + +> 交互演示:[S3 额度列与取最大](https://claude.ai/code/artifact/8ab14900-e95b-42dd-8a5c-3f2750e046b0#s3:open) · [S9 员工超额与失效提示](https://claude.ai/code/artifact/8ab14900-e95b-42dd-8a5c-3f2750e046b0#s9) + +### 先回答产品经理的问题 + +**额度不由 RBAC 管,也不由 ReBAC 管;它跟着授权走,但存在授权旁边、由另一套机制执行。** + +| | 授权(ReBAC) | 额度 | +|---|---|---| +| 回答 | 这个人能不能用这个模型 | 能用多少 | +| 存哪 | 权限引擎(只回答是 / 否,不存数字) | 授权账本旁的**额度策略表**,一条授权可附带一条额度 | +| 在哪配 | 模型「开放范围」弹窗 | **同一个弹窗**,每行授权后面多一列「额度」(可留空 = 用上级默认) | +| 谁执行 | 模型面入口第 ② 步 | 模型面入口第 ③ 步,Redis 计数 | + +结论:**从交互上放在一起(管理员配一次),从机制上分开(引擎只管是否,计数器管多少)。** 不需要给角色配额度:角色是权限载体不是组织维度,且模型额度需要按模型设,落在授权行上最自然。 + +### 策略维度 + +| 维度 | 取值 | 说明 | +|---|---|---| +| 主体 | 租户默认 / 部门·用户组 / 用户 | **按人与按部门可同时配,所有命中的明确额度行取最大值**(与授权「并集」语义一致);一条都没有才用租户默认。代价:无法把某人压到低于其部门 / 用户组的额度,需要个别收紧时二期加「个人上限」标记(D-13) | +| 模型 | 全部模型 / 指定模型 | 指定模型优先于全部 | +| 指标 | RPM(每分钟请求数)/ TPM(每分钟 token 数)/ 月 token 预算 | 三项各自可空 | +| 部门行的语义 | **组内每人各自一份** | §七 D-5;部门共享池二期 | +| 计数口径 | 只计经模型协议面的调用 | 与 §4.3 作用范围一致;平台内使用不计入个人桌面额度 | + +### 执行语义 + +| 指标 | 超限行为 | 员工看到 | 依据 | +|---|---|---|---| +| RPM / TPM | 拒绝本次,返回 OpenAI 429 语义与 `Retry-After`;滑动窗口。TPM 按已消耗 token 判定,允许最后一次越界 | dsh 原生显示限流并按提示重试 | 讲 OpenAI 协议的客户端本就处理 429 | +| 月预算 | **走完当前回合再拦**:在途响应完成,之后的新调用被拒;自然月 1 日重置 | 「本月企业模型额度已用完,如需继续请联系管理员」 | PRD-1 §5.2「预算触顶走完当前回合再拦截 → 转管理员」 | +| 触顶通知 | 站内信通知持有人与租户管理员;审计记一条「额度触顶」;台账与用量视图高亮 | — | 闸门二、三 | + +### 与既有机制的关系 + +- 服务商级「每日调用上限」(模型管理页既有)继续作为**租户总量兜底**,不与个人额度混用。 +- 平台已有「每月模型 token」配额键但从未接线执行,可作为**租户级月预算**的落点,避免第二套账。 +- 预算按 **token** 计。金额预算需要先建模型单价表,列二期。 + +**成本红线**:平台今天没有任何 HTTP 层限流基础设施,最接近的可复用原语在 Redis 故障时放行而非拒绝,不能直接用于鉴权场景。额度这块的估算**不可按「接个现成库」计**。 + +## 4.5 用量统计与审计 + +> 交互演示:[S5 个人令牌台账](https://claude.ai/code/artifact/8ab14900-e95b-42dd-8a5c-3f2750e046b0#s5) · [S6 模型用量(二期)](https://claude.ai/code/artifact/8ab14900-e95b-42dd-8a5c-3f2750e046b0#s6) + +| 层 | 内容 | 说明 | +|---|---|---| +| 逐条记录 | 每次调用一条:持有人、令牌、接入端、模型(含服务商)、时间、输入 / 输出 token、结果 | 复用 F051 ModelCallRecord,新增「持有人」「接入端」两维;被拒调用计审计事件、不入用量;不含消息正文与令牌明文 | +| 账本口径 | 计入平台既有 token 用量账本,同一口径 | F051 AC-23,不另立一套 | +| 聚合视图(管理面) | **本期不做**(2026-09-03 裁定):按人 / 部门 / 模型 / 时间的汇总与导出移二期;本期管理员经审计页看逐条记录 | 二期补做时须在用量表加维度,不从遥测反查 | +| 员工自查 | 「账号与企业」页显示本月已用 / 剩余额度 / 重置日期 | 让员工在被拦之前看得到;数据来自月预算计数,本期即有 | +| 审计事件 | 令牌签发(含桌面端登录)、吊销、被拒调用、额度触顶、开放范围变更、额度变更 | 双归属:actor = 令牌,subject = 持有人 | + +## 4.6 桌面端要做的:一个 dsh 企业扩展包 + 桌面壳三处小改 + +> 交互演示:[S7 设置 → 账号与企业](https://claude.ai/code/artifact/8ab14900-e95b-42dd-8a5c-3f2750e046b0#s7:open) · [S8 深链确认](https://claude.ai/code/artifact/8ab14900-e95b-42dd-8a5c-3f2750e046b0#s8) · [S9 超额与失效提示](https://claude.ai/code/artifact/8ab14900-e95b-42dd-8a5c-3f2750e046b0#s9) + +**形态**:dsh-desktop 是纯壳——没有自己的界面层,窗口里装的就是 dsh 自带的 Web 界面,定制靠给 dsh 打补丁;而 dsh 上游提供了一等扩展点:profile 扩展包(启动时用 `--profile` 装载)、动态注册 provider、写凭据接口、设置页插槽。因此桌面侧**不做 Electron 原生页面,做一个 dsh 企业扩展包**,随桌面安装包内置;桌面壳只做三处小改。 + +| 件 | 内容 | 落点 | +|---|---|---| +| 「账号与企业」卡片 | 设置页里的一张卡片:平台地址、登录 / 退出、登录状态(姓名、租户、有效期至)、本月用量与剩余额度、失效提示;与工作台「DSH Desktop」弹窗展示同一份信息 | 企业扩展包(设置页插槽) | +| 登录客户端 | 打开系统浏览器;本机回环端口监听登录结果回传(与桌面壳「只信任 127.0.0.1」的安全策略一致);换端口重试;换取令牌 | 企业扩展包(运行在 dsh 运行时内) | +| provider 自动注册 | 动态注册一个 provider:协议 `openai-completions`、Base URL = 平台模型面地址、模型清单 = 平台按本人过滤后的列表;每次启动刷新清单;退出时注销 | 企业扩展包 | +| 令牌保管 | 经 dsh 凭据接口写入本地凭据文件(同 OS 用户可读)。**绝不放进环境变量**:dsh 里环境变量会遮蔽凭据文件,且会让后续写入被拒 | 企业扩展包 | +| 失效处理 | 令牌失效 / 到期 / 超额时,把平台返回的可读错误呈现给员工,卡片给出「重新登录」入口 | 企业扩展包 | +| 桌面壳改动(三处小改) | ① 启动参数加 `--profile enterprise`;② 安装配置注册自定义协议 `dsh-desktop://`,收到深链后转交扩展包的本机监听(入口 B,复用既有单实例处理);③ 企业扩展包随安装包内置,不再从平台下载 | dsh-desktop | +| 安装包分发 | 平台不托管安装包;管理员在系统配置填「DSH Desktop 下载地址」(内网文件服务器或官网),工作台弹窗与登录页的「下载」按钮指向它 | 平台配置项 | + +扩展包须按 dsh 版本发布:dsh 仍处开发者预览、明示会有破坏性变更,扩展包与桌面壳捆绑的 dsh 版本一起升级(§7.3)。 + +--- + +# 五、治理与管理面:管理员在哪配什么 + +> 交互演示:[S4 系统配置 → 授权与席位、接入开关](https://claude.ai/code/artifact/8ab14900-e95b-42dd-8a5c-3f2750e046b0#s4) · [S3 开放范围](https://claude.ai/code/artifact/8ab14900-e95b-42dd-8a5c-3f2750e046b0#s3:open) · [S5 台账](https://claude.ai/code/artifact/8ab14900-e95b-42dd-8a5c-3f2750e046b0#s5) · [S6 用量(二期)](https://claude.ai/code/artifact/8ab14900-e95b-42dd-8a5c-3f2750e046b0#s6) + +| 面 | 位置 | 配什么 / 看什么 | +|---|---|---| +| 模型开放范围 + 额度 | 管理后台 → 模型管理 → 某模型「开放范围」 | 授权给用户 / 部门 / 用户组;每行可带 RPM / TPM / 月预算;租户默认额度 | +| 「DSH Desktop 接入」开关 | 租户设置(多租户另有部署级) | 一个开关管展示与放行,**默认关**;关闭 = 停用不撤销;技能包的「允许用于知识检索」勾选保留;令牌默认有效期;**DSH Desktop 下载地址**(工作台弹窗与登录页的「下载」按钮指向它) | +| 令牌台账 | 管理后台 → 个人令牌 | 本租户全部令牌:持有人、接入端、掩码、最后使用、有效期、是否占席;单个或按人强制吊销 | +| 授权与席位 | 管理后台 → 系统配置 → 授权 | 席位已用 / 上限(免费 10 · 密钥 N · 不限)、到期日、粘贴密钥;超限时的「联系我们获取授权密钥」提示 | +| 审计 | 管理后台 → 审计 | 逐条模型调用记录(可按密钥 / 时间筛选);聚合视图与导出二期;租户管理员限本租户,平台超管跨租户 | +| 部署级开关 | 运维配置 | 开放能力层与个人令牌部署级开关,未开时上述入口不出现 | + +--- + +# 六、分期与工作量 + +> 粗估,须研发按 spec 复核。锚点取自《鉴权 PRD》§1.4 的既有估算;限流与额度无内部锚点,按「从零建」计。 + +| 块 | 一期内容 | 粗估(人天) | 锚点 | +|---|---|---|---| +| 个人访问令牌底座 | 《鉴权 PRD》§4.10 全部:自助签发、级联失效、台账、两层开关、审计归因;放开 `model:invoke`;一人多把 | 12~15(+1~2) | PRD §1.4 原估 | +| 桌面端登录(平台侧) | 桌面端登录页与确认页(两个入口共用)、回传与深链拉起页、令牌签发与换取、工作台「DSH Desktop」弹窗(含「授权管理」)、接入开关 | 5~7 | 参照 PAT 自助签发面;弹窗形态复用审批中心;不做授权码粘贴省 1 人天 | +| 模型协议面(F051) | spec 全量 + 自然人主体扩展 + 记录维度 | 10~15 | 量级参照 F050 身份传递(12~14) | +| 模型开放范围 | 模型类型迁入 F048 授权体系、模型管理页入口、模型面判定接线 | 5~8 | 参照托管应用类型加入 F048 | +| 月预算硬闸 | 额度策略表、授权弹窗额度列、Redis 计数、触顶处置与通知 | 6~9 | 限流基建从零 | +| dsh 企业扩展包 + 桌面壳 | 账号与企业卡片、登录回调、provider 自动注册、凭据写入、失效与超额提示、本人用量;桌面壳 `--profile`、协议注册与深链转交、扩展包内置 | 7~12 | dsh 扩展点现成(§4.6) | +| 授权密钥与席位 | 密钥格式与验签、席位计数与签发前检查、系统配置「授权」区、被拒提示、台账占席标记、到期横幅 | 3~4 | 复用 `license_str` 落点、租户 `user_count` 计数范式、LicenseBanner 形态 | +| **一期合计** | | **约 49~72** | | +| 二期 | RPM / TPM(5~8);用量看板(5~8);金额预算含单价表(3~5);部门共享池(3~5) | 16~26 | | + +两条前提:一期依赖开放能力层与全端点鉴权(F049)已交付;模型协议面若在 design 阶段选用现成代理承载协议面,其自带的按密钥预算与限流可作实现参照,但**授权与额度的真相必须在平台侧**(记账回流一处,防两层漂移)。 + +--- + +# 七、决策记录与连带修订 + +## 7.1 决策一览 + +| # | 决策 | 结论 | 否决项 | 依据 | +|---|---|---|---|---| +| D-1 | 桌面端凭据主体 | **个人访问令牌,一人多把、按接入端签发** | 一人一把(桌面端程序化登录拿不到已签令牌的明文,只能重签,重签即作废旧的,会顶掉该员工技能包里的配置);服务账号密钥(管理员签发、权限不等于本人、不随人事失效) | 《鉴权 PRD》§4.10.6 已预留「出现真实诉求再升级为列表,数据模型不用改」;2026-09-02 拍板 | +| D-2 | 登录形态 | **系统浏览器登录,结果回传本机(入口 A)或深链拉起(入口 B);不设「授权码」产品概念、不做复制粘贴兜底** | 设备码(本机场景多一次抄码,无必要);完整 OAuth 授权服务器(§六非目标;本方案客户端固定、无刷新令牌、无第三方注册);员工手动粘贴令牌或授权码(2026-09-03 裁定不做,兜底改为重试与下载) | 对标 `gh auth login` 的浏览器模式 | +| D-3 | 模型授权作用范围 | **只管对外模型协议面** | 全平台统一按人授权(要改所有模型消费方与系统默认模型逻辑,存量升级必须先补授权否则全员看不到模型) | 2026-09-02 拍板 | +| D-4 | 授权与额度的关系 | **同一交互配置;额度不进权限引擎,存授权旁的策略表,模型面入口计数执行** | 把额度做成权限引擎里的关系(引擎不存数字);额度另开一页独立配置(管理员配两遍、两处漂移);额度挂角色(角色不是组织维度,且额度要按模型设) | §4.4 | +| D-5 | 部门 / 用户组行的额度语义 | **组内每人各自一份** | 部门共享池(要解释「谁用完了」、还得按人再拆一层防独占);两者都要(首版配置面过重) | 2026-09-02 拍板;共享池二期 | +| D-6 | 预算单位 | **token**;金额二期 | 金额(平台无模型单价表) | §4.4 | +| D-7 | 协议面 | **仅 OpenAI 兼容**,沿用 F051 | Anthropic 面 | F051 决议-1;dsh 自定义 provider 支持 OpenAI 协议 | +| D-8 | 个人令牌租户级开关粒度 | **按用途分两个:「知识检索」勾选 + 「DSH Desktop 接入」开关**(后者兼作展示开关,D-20) | 单一总开关 | 数据流向相反、决策人可能不同 | +| D-13 | 额度多行命中时的取法 | **按人与按部门可同时配,所有明确额度行取最大;无明确行才用租户默认** | 最具体者优先(能对个人收紧,但规则难解释、与授权并集语义不一致) | 2026-09-03 拍板;代价与二期「个人上限」见 §4.4 | +| D-14 | 反向入口(管理员发链接拉起桌面端) | **做**:固定链接 + 网页登录 + 自定义协议深链拉起,与入口 A 共用签发与兑换 | 只做桌面端发起(管理员无法批量引导);网页内嵌桌面端功能(桌面端才持有 dsh 运行时) | 2026-09-03 拍板;安全规则见 §4.1 | +| D-20 | 工作台入口与展示开关 | **头像菜单加「DSH Desktop」弹窗**(打开 / 下载 / 授权管理 / 本月用量);**「DSH Desktop 接入」一个开关管展示与放行,默认关,关闭 = 停用不撤销**,与 D-8 的「模型调用」勾选合并 | 只靠管理员发链接(员工找不到入口);展示开关与放行勾选分开(两个开关表达同一件事会漂移);默认开(升级静默打开新能力) | 2026-09-03 拍板 | +| D-21 | 持有人换租户时令牌的处置 | **不失效,随人迁移到新租户**(不重签、审计记一条);停用 / 删除仍立即失效 | 换租户即失效(《鉴权 PRD》§4.10.5 原口径;员工换部门被迫重新登录) | 2026-09-03 拍板;密钥仍只属一个租户的不变量不破 | +| D-22 | 模型用量聚合视图 | **本期不做**,移二期;本期靠逐条记录(F051)与员工本月用量自查 | 一期做管理面汇总与导出 | 2026-09-03 裁定 | + +## 7.2 连带须修订的既有文档(本次只列不改) + +| 文档 | 位置 | 改什么 | +|---|---|---| +| 《鉴权 PRD》 | §4.10.3 权限位白名单 | `knowledge:read` 之外放开 `model:invoke`(判据即该节自己写的「有没有只需要它的真实消费者」) | +| 《鉴权 PRD》 | §4.10.6 一人一把 | 改为按接入端签发、「授权管理」列表;AC-P3 / AC-P10 随之改写 | +| 《鉴权 PRD》 | §4.10.7 闸门一 | 租户级开关按用途分两个;「模型调用」一项改名「DSH Desktop 接入」并兼作展示开关 | +| 《鉴权 PRD》 | §4.10.5 级联失效 | ~~去掉「换租户」:令牌随持有人迁移到新租户、不失效;AC-P8 / AC-P9 改写;技能包令牌建议同口径~~ **已改**(2026-09-03,鉴权 PRD v2.6 · D19;新增 AC-P9a 与迁移三项工程要求,技能包令牌同口径) | +| 《鉴权 PRD》 | §六 非目标「不实现完整 OAuth 2.0 授权服务器」 | 加限定:第一方桌面端登录流程不在此列 | +| 《鉴权 PRD》 | §1.4 / §3.5 | 场景四补 dsh desktop 为第二个消费者 | +| F051 spec | AC-04 / 09 / 20 / 21 / 22 | 凭据主体加自然人;可用范围加「∩ 授权给本人」;记录 subject = 持有人 | +| F051 spec | 排除项「token 上限、per-key 限流」 | 随本需求回收,改为一期月预算、二期 RPM / TPM | +| PRD-1 | §5.2 顺延清单 | 「按 key 聚合账单」与「精细成本控制」两项提前,登记本方案为来源 | +| release-contract | INV-31 | 「三扩展位一律以服务账号自身身份执行」改为「以密钥主体自身身份执行」(自然人持 `model:invoke` 仍是自身身份模式) | +| 对客文档 | 接入说明 | 新增 dsh desktop 接入章节 | + +## 7.3 开放问题 + +| # | 问题 | 说明 | +|---|---|---| +| 1 | 桌面端令牌是否用比租户默认更短的有效期 | 桌面端可自动重新登录,短有效期成本低;但会增加登录次数,待定 | +| 2 | 金额预算 | 需要模型单价表(按服务商 / 模型 / 输入输出分别计价),二期评估 | +| 3 | 平台内是否也按人授权模型 | 本版不做(D-3);若客户提出,扩展同一份授权即可 | +| 4 | dsh 协议兼容 PoC | 一期早期用 dsh 真机验证工具调用与流式在平台模型面上的完整性 | +| 5 | 商业版网关前置形态下的可达性 | Java 网关只代理 `/api` 前缀,桌面端登录页与模型面的路径须在网关侧放行,需与网关团队确认 | +| 6 | 企业扩展包与 dsh 版本的绑定 | dsh 处于开发者预览、破坏性变更频繁;扩展包须按 dsh 版本发布并随桌面壳升级,升级节奏与兼容承诺待定(令牌注入方式已定为写凭据文件,§4.6) | + +--- + +# 八、付费化与落点:全功能开源、免费 10 席位,授权密钥抬高上限 + +## 8.1 结论 + +1. **桌面端企业接入的全部功能进开源版,免费 10 个席位。** 登录(两个入口)、模型转接、模型开放范围、额度、用量看板、令牌台账全部开源可用;不配置密钥时最多 10 名员工可接入桌面端。凭据底座、个人访问令牌、模型协议面、模型授权、逐条记录本就在 v3.0 发版契约里。 +2. **付费买的是席位。** 超过 10 人联系 dataelement 获取授权密钥,密钥只做一件事:抬高席位上限(数字或不限)。付费代码与开源同仓、不单独部署、网关零改动。 +3. **不放进 bisheng-gateway。** 这条链路需要的东西网关一样都没有:模型路由真相、租户上下文、权限引擎、令牌校验、token 计量;它又不在开源部署里、按客户分叉、自身鉴权与密钥管理薄弱。放进去等于在 Java 里复制一份平台核心,正是《产品方案》§3.4 已否决的「两层漂移」。 +4. 许可靠合同与密钥约束,与平台既有付费能力同一现实,不做技术防破解。 + +## 8.2 免费档与付费的边界 + +| 件 | 免费档(不配密钥) | 付费(配密钥) | +|---|---|---| +| 桌面端登录(两个入口)、「账号与企业」卡片 | 可用 | 可用 | +| 模型转接、模型开放范围授权 | 可用 | 可用 | +| 额度 RPM / TPM / 月预算、员工自查(用量看板二期) | 可用 | 可用 | +| 令牌台账、审计 | 可用 | 可用 | +| **席位数**(持有效桌面端令牌的自然人) | **10** | **密钥写的上限,或不限** | +| 到期 | — | 回落免费档,存量不硬断 | + +依据:功能不分档、只数人头,规则最简单;小团队零门槛试用;与 D-11「不靠削开源功能」一致。凭据底座、个人访问令牌、模型协议面、模型授权、逐条记录本就是开源交付项(release-contract、PRD-1 GOV-10)。 + +## 8.3 改动清单 + +| 组件 | 改什么 | 落在哪 | +|---|---|---| +| Python 后端 | 授权密钥的配置、验签与席位上限读取;席位计数(持有效桌面端令牌的自然人,实例级);签发前席位检查与被拒响应;桌面端登录端点(两个入口);额度策略表与执行闸;「授权管理」列表(用量聚合端点二期) | 开源仓 | +| platform 前端 + client 工作台 | 系统配置「授权」区(席位已用 / 上限、到期日、粘贴密钥、联系我们);令牌台账「占席」标记;额度列、接入端列(用量页二期);**client 头像菜单「DSH Desktop」弹窗**(打开 / 下载 / 授权管理 / 本月用量);「DSH Desktop 接入」开关 | 开源仓 | +| dsh 企业扩展包 | 「账号与企业」卡片;本机回环回调与深链接收、换取令牌;provider 自动注册与凭据写入;本人用量与失效提示;席位已满时展示平台返回的提示 | 随 dsh-desktop 安装包内置 | +| dsh-desktop 壳 | 启动参数加 `--profile enterprise`;注册自定义协议并转交深链;扩展包内置 | 开源仓(dsh-desktop) | +| bisheng-gateway | 新路径全部放在 `/api/v1/**`、`/api/v2/**` 下则零改动;否则各加一条 route 与 nginx location | 私有仓 | +| 交付与许可 | 合同 + 交付授权密钥(含席位上限与到期日);技术上不做防破解 | — | + +## 8.4 为什么不放网关 + +**不适合,五条:** + +| # | 网关现状 | 后果 | +|---|---|---| +| 1 | 模型转发所需的一切都在 Python:对 19 家服务商的参数翻译、厂商凭据(平台库)、模型名解析、租户上下文、权限引擎判定;网关零 LLM 代码、无权限引擎客户端、无租户概念(根租户 ID 硬编码) | 放网关 = 在 Java 里重写一份平台核心,即《产品方案》§3.4 已否决的「两层漂移」;记账也会变成两套 | +| 2 | 网关不认 `Authorization: Bearer`,代理的 HTTP 请求不做任何鉴权,含 `api/v2/` 的 WebSocket 一律跳过身份 | 令牌校验仍要落平台,网关只能是一跳透传 | +| 3 | 网关限流是进程内令牌桶,不跨实例、不落 Redis,按部门 / 资源、无用户与 token 维度 | RPM 勉强,TPM 与月预算做不了——它们需要响应里的 token 数,只有模型面知道 | +| 4 | License 只有 trial / pro 与到期日,无席位或模块字段;且只在启动与每小时校验、不拦单个请求 | 当不了席位门控,要用得先改 License 模型 | +| 5 | 网关不在开源部署里、独立部署、按客户分叉;自身管理端点无鉴权、密钥硬编码在仓内 | 放核心链路进去 = 只有装网关的客户才有模型面(与「开放能力层可独立部署」冲突),且把凭据签发与模型代理放在防护最弱的一层 | + +**适合的三件窄事:** + +| 事 | 说明 | +|---|---| +| SSO 前门 | 既有能力零改动:桌面端登录页在浏览器里打开,网关的 SSO 回调照常写 cookie,登录页即可识别身份 | +| 路由放行 | 新路径放 `/api/v1/**` 或 `/api/v2/**` 下即自动代理(模型面 base URL 建议落在此范围);否则加 route 与 nginx location | +| 与授权密钥的关系 | 平台自验授权密钥,不依赖网关;装了网关的客户两套授权并存、互不影响 | + +## 8.5 门控机制:免费 10 席位 + 授权密钥 + +| 项 | 规则 | 依据 / 先例 | +|---|---|---| +| 席位定义 | 持有**至少一把有效桌面端令牌**(接入端为 dsh-desktop、未过期未吊销)的自然人;一人多设备只占一席;技能包个人令牌不占席 | 数人不数设备,员工换电脑不吃席位 | +| 计数范围 | 按部署实例计,多租户合并计数,不按租户分配 | 授权密钥是实例级;2026-09-03 拍板 | +| 免费档 | 未配置密钥 = 10 席,功能完整;前提是管理员打开「DSH Desktop 接入」开关(默认关) | 2026-09-03 拍板 | +| 密钥是什么 | dataelement 签发的签名串:客户名、席位上限(数字或不限)、到期日、**实例指纹(必绑)** | 公钥内置开源代码、私钥留在 dataelement——比网关 License 把公私钥都打进 jar 更稳;网关有 `finger.sh` 生成指纹但从不校验,本方案校验 | +| 在哪配 | 管理后台「系统配置 → 授权」:显示**本实例指纹**(管理员复制发给 dataelement 签发)、席位已用 / 上限、到期日,粘贴密钥保存即验签,指纹不匹配即拒收;自动化部署可改用配置文件 / 环境变量 | 落点 = 平台配置里从未使用的 `license_str` 字段 | +| 检查时机 | 桌面端登录签发令牌前(入口 A / B 同一处);已占席的人重新登录、换设备不占新席 | 只在签发处判定一次 | +| 超限表现 | 第 11 人(或超出密钥上限)登录:确认页显示「本实例桌面端席位已用完(10 / 10),请联系管理员」,不签发令牌;管理员在授权区看到用量与「联系我们获取授权密钥」 | 员工与管理员各看各的提示 | +| 释放席位 | 管理员吊销该人全部桌面端令牌,或其令牌全部到期 / 级联失效 → 席位释放;**不按不活跃自动释放** | 与令牌生命周期同源,不另建状态;2026-09-03 拍板 | +| 到期 / 降档 | 不再签发超出上限的新席位;已登录设备用到令牌到期或被吊销为止;提前 30 天顶部横幅提醒超管;台账高亮超出上限的席位 | 2026-09-03 拍板:回落免费档、存量不硬断;横幅复用 LicenseBanner 形态 | +| 与网关 | 平台自验,不依赖网关;装了网关的客户两套授权并存、互不影响 | 网关 License 无席位字段(§8.4) | + +三条规则:① 密钥只管席位数,不管功能。② 密钥绑定实例指纹,迁移主机须重新签发。③ 授权密钥与商业版整体(`BISHENG_PRO`)、看板 PRO(`BISHENG_DASHBOARD_PRO`)相互独立,各自售卖、各自配置,互不并入。 + +**如实写代价**:付费代码公开可见,校验可被改掉,保护靠合同与密钥——与看板 PRO、网关 License 同一现实。 + +## 8.6 新增决策与开放问题 + +| # | 决策 | 结论 | 否决项 | 依据 | +|---|---|---|---|---| +| D-9 | 售卖粒度 | **独立可售,按席位上限授权** | 随商业版整体(不能单卖);按用量计费(需计量对账,范围大一个量级) | 2026-09-02 定独立可售,2026-09-03 定席位 | +| D-10 | 落点 | **平台后端 + 内置 dsh 扩展包**,网关零改动 | 放进 bisheng-gateway | §8.4 | +| D-11 | 开源版功能范围 | **全功能开源,免费 10 席位** | 功能分档(登录与模型免费、额度与看板付费);未配密钥入口不出现 | 2026-09-03 改判:规则最简单、小团队零门槛 | +| D-12 | 门控形态 | **平台自验的授权密钥,只控席位上限** | 环境变量开关(要改部署配置、无到期与客户信息);单独部署的付费组件(安装越来越复杂);用网关 License 权益字段(网关无该字段、后端不读网关 License) | 2026-09-03 拍板;§8.5 | +| D-15 | 到期 / 降档处理 | **回落免费档,存量不硬断** | 到期即停用超出部分(员工在不知情下被断) | 2026-09-03 拍板 | +| D-16 | 多租户席位分配 | **实例级合并计数,不按租户分配** | 按租户分配上限(多一层配置,当前无诉求) | 2026-09-03 拍板 | +| D-17 | 席位自动释放 | **不自动释放**,只靠管理员吊销或令牌到期 / 级联失效 | 按不活跃天数自动释放(员工休假回来发现被挤掉) | 2026-09-03 拍板 | +| D-18 | 实例指纹 | **密钥绑定实例指纹**,授权区展示指纹供签发,迁移主机重新签发 | 不绑定(一把密钥可多处复用) | 2026-09-03 拍板 | +| D-19 | 看板 PRO 关系 | **不并入**,看板 PRO 保持独立开关、独立售卖 | 并入同一把密钥统一配置 | 2026-09-03 拍板 | + +本节无待决开放问题。 + +--- + +# 附录 A · 现状核实(模块级) + +| 项 | 现状 | 位置 | +|---|---|---| +| 平台登录 | 会话 JWT 只走 cookie,24 小时,无刷新;商业版 SSO 由 Java 网关经 `login-sync` 回调 | `user/`、`sso_sync/` | +| OAuth / 设备码 | 平台无授权服务器、无设备码流;CLI `bisheng login` 是粘贴密钥 | `src/bisheng-cli/` | +| 凭据底座 | `api_credential` 已实现(哈希、权限位、5 秒撤销);服务账号密钥 `bs-sak-` 在用;个人访问令牌 `bs-pat-` 零代码 | `open_api/` | +| `model:invoke` 权限位 | 已登记,未绑定任何端点 | `open_api/domain/scopes.py` | +| 模型协议面 | F051 只有 spec,无 design / tasks / 代码;`/api/v2` 下无模型路由 | `features/v3.0.0/051-*` | +| 模型的权限引擎类型 | `llm_server` / `llm_model` 是旧式类型,未进 F048 体系;唯一关系是 Root 租户共享给子租户 | `permission/domain/models/authorization_model_f048.py` | +| 授权弹窗 | 支持用户 / 部门(成员、子树)/ 用户组 | `platform/.../permission/PermissionDialog.tsx` | +| 计量 | `llm_token_log` / `llm_call_log` 只在工作流 LLM 节点写入;「每月模型 token」配额键存在但无执行点;服务商级每日调用上限是唯一的闸 | `llm/`、`role/domain/services/quota_service.py` | +| 限流 | 无任何 HTTP 层限流依赖;最近原语在 Redis 故障时放行 | — | +| 用量视图 | 无 | — | +| 开放 API 审计 | 只写日志,不进审计表 | `open_api/api/dependencies.py` | +| dsh 模型接入 | 自定义 provider = Provider ID + Base URL + API 协议 + 凭据 + 模型 ID;凭据文件明文、环境变量优先 | dsh 用户指南「模型配置」 | +| dsh-desktop | 纯壳:无自有界面层,窗口内是 dsh 自带 Web 界面;定制靠 patch-package;无协议处理器、无本机监听;只信任 127.0.0.1;启动参数可加 `--profile`;无账号 / 企业 / 许可概念 | 本地仓 `dsh-desktop/src/main` | +| dsh 扩展点 | profile 扩展包(`--profile` 装载)、动态注册 provider(declared)、写凭据接口、设置页插槽;协议枚举 `openai-completions` / `openai-responses` / `anthropic-messages`;环境变量遮蔽凭据文件且令写入被拒 | 本地仓 `deepseek-harness/packages/{boot,llm,credentials,settings}` | +| bisheng-gateway | 只代理 `/api/v1/**`、`/api/v2/**` 与三个 chat WebSocket 路径;代理的 HTTP 不鉴权、不认 Bearer;限流为进程内令牌桶、无 token 维度;License 仅 trial / pro 与到期日、无模块权益、只在启动与每小时校验;零 LLM 代码;无租户概念;不在开源 compose、按客户分叉 | 私有仓 `bisheng-gateway`;`docs/architecture/11-gateway.md` | +| 付费门控先例 | 商业版整体 = 环境变量 `BISHENG_PRO` → `/api/v1/env` → 前端隐藏(后端仅一处消费);单售模块 = `BISHENG_DASHBOARD_PRO` → 服务层 `is_commercial()` 分档;部署层开关(开放能力层 / 工场 / 多租户)关闭即路由不注册;仓内无商业代码放置规则、无插件机制 | `common/services/config_service.py`、`telemetry_search/`、`api/router.py` | +| 授权密钥落点 | 平台配置字段 `license_str` 已声明、从未读取;前端 LicenseBanner 现读网关 `/api/license/status`(开源下静默),仅超管可见;管理后台「系统配置」tab 可承载「授权」区 | `core/config/settings.py`、`platform/.../LicenseBanner.tsx`、`SystemPage/components/Config.tsx` | +| dsh-desktop 深链 | 无自定义协议注册、无 open-url 处理;已有单实例锁与 second-instance 处理(现只聚焦窗口),深链在此扩展 | 本地仓 `dsh-desktop/src/main/index.ts` | +| 席位计数先例 | 租户配额键 `user_count` 已有按租户数人并比上限的逻辑(租户管理员自设,与实例级席位是两回事),可作席位计数范式 | `role/domain/services/quota_service.py` | +| 工作台头像菜单 | client 头像菜单项均为「菜单项拉起弹窗」(审批中心、消息提醒),「DSH Desktop」按同一形态;client 读平台开关走 `/api/v1/env`(`app_runtime_enabled` 先例) | `client/src/layouts/UserPopMenu.tsx`、`hooks/useAppRuntimeEnabled.ts` | diff --git "a/docs/PRD/3.0-beta2/3.0 \345\267\245\344\275\234\346\265\201\345\257\271\345\244\226\345\217\221\345\270\203 MCP \346\234\215\345\212\241\345\260\201\350\243\205 \346\226\271\346\241\210.md" "b/docs/PRD/3.0-beta2/3.0 \345\267\245\344\275\234\346\265\201\345\257\271\345\244\226\345\217\221\345\270\203 MCP \346\234\215\345\212\241\345\260\201\350\243\205 \346\226\271\346\241\210.md" new file mode 100644 index 0000000000..39af04949f --- /dev/null +++ "b/docs/PRD/3.0-beta2/3.0 \345\267\245\344\275\234\346\265\201\345\257\271\345\244\226\345\217\221\345\270\203 MCP \346\234\215\345\212\241\345\260\201\350\243\205 \346\226\271\346\241\210.md" @@ -0,0 +1,169 @@ +# 3.0 工作流对外发布 MCP 服务封装 方案 + +> 状态:调研完成,方案待对齐(见 §9 待拍板问题) +> 日期:2026-07-29 +> 输入:MCP 2026-07-28 规范联网调研 + 全仓代码取证 +> 关联:对外发布页「API 访问」(`/api/v2/workflow/invoke` / `stop`) + +--- + +## 1. 背景与问题 + +工作流「对外发布」目前提供三种接入方式:API 访问(`POST /api/v2/workflow/invoke` + `stop`)、免登录链接、需登录链接。API 访问的形态是:服务端生成 `session_id`,调用方轮流拿事件(输出事件直接渲染;输入事件渲染表单收集用户输入后带 `session_id` 续跑)。 + +问题:**这套 API 能否封装为 MCP 服务**,让 Claude Code / 各类 Agent 客户端把已发布的工作流当作 MCP tool 直接调用?恰好 MCP 规范于 2026-07-28 发布了新版本,本文基于新规范做可行性评估并给出方案。 + +## 2. 结论摘要 + +**能封装,且 2026-07-28 新规范让契合度显著提升**——新规范三大变化逐条对上毕昇现有 API 的形态: + +| MCP 2026-07-28 新特性 | 毕昇现有对应物 | 契合度 | +|---|---|---| +| 无状态核心 + **server-minted handle 作为普通工具参数回传**(SEP-2567) | `session_id` 服务端生成、调用方原样传回续跑 | ★★★ 完全同构 | +| **MRTR**(SEP-2322):tools/call 返回 `resultType:"input_required"` + ElicitRequest 表单,客户端带 `inputResponses`+`requestState` 重试原请求 | `input` / `output_with_input_msg` / `output_with_choose_msg` 三类 HITL 事件 + `session_id`+`message_id`+`{node_id:{key:value}}` 续跑 | ★★★ 语义同构 | +| **Tasks 官方扩展**(`io.modelcontextprotocol/tasks`):`tasks/get` 轮询 + `tasks/update` 送输入 + cancel | `stream=false` 的"跑到断点"语义 + Redis 事件队列 + `/stop` | ★★☆ 可映射 | + +四个非丝滑点(§6):表单**文件上传**在 elicitation 中无对应类型;HITL 状态**钉死在 Celery worker 进程内存**;v2 API **无鉴权**不能直接透出;**SDK v2 仍 beta、客户端生态未跟上新规范**。 + +**总体策略**:按 2026-07-28 的理念(句柄 + 可重入 + input_required)设计工具契约,先跑在稳定 spec / `mcp` 1.x streamable-http 上;session_id-as-argument 在任何协议版本下都成立,真 MRTR 等客户端生态跟上后作为纯增量升级。 + +## 3. MCP 2026-07-28 规范要点 + +发布于 2026-07-28(正式版 + Tier 1 SDK 同步更新)。与本题相关的变更: + +1. **彻底无状态**:删除 `initialize` 握手和 `Mcp-Session-Id` 头;每个请求在 `_meta` 里自带协议版本/客户端能力;新增 `server/discover`。跨调用状态的官方姿势 = **服务端铸造的句柄作为普通工具参数传回**(SEP-2567)。任意实例可处理任意请求,天然适配负载均衡。 +2. **MRTR 取代服务端主动请求**(breaking change):`elicitation/create` / `sampling` 不再由服务端反向发起,而是 `tools/call` 直接返回 `InputRequiredResult{resultType:"input_required", inputRequests:{key→ElicitRequest…}, requestState}`;客户端收集输入后**带 `inputResponses` + 原样回显 `requestState` 重试原请求**(新 request id,完全独立的请求)。规范对 `requestState` 的安全要求:影响业务逻辑时必须 HMAC/AEAD 防篡改 + TTL + 绑定 principal 防重放;服务端收到缺失输入时 SHOULD 再次返回 `InputRequiredResult` 而非报错。ElicitRequest form 模式的 `requestedSchema` 仅支持原始类型(string/number/boolean/enum)。 +3. **Tasks 从实验核心转为官方扩展**(`io.modelcontextprotocol/tasks`):`tasks/get` 轮询(废除阻塞式 `tasks/result`)、新增 `tasks/update` 做 client→server 输入、允许服务端未经请求就返回 task 句柄。状态机含 `input_required`,可与 MRTR 组合处理"长任务中途要输入"。 +4. 其它相关:所有 result 必带 `resultType`(老服务端缺省视为 `"complete"`);`tools/list` 必带 `ttlMs`/`cacheScope` 且鼓励确定性排序(利好 prompt cache);**删除 SSE 断流重放**(`Last-Event-ID` 移除,断流 = 重发新请求);Sampling/Roots/Logging 进入 12 个月弃用期;auth 收紧(RFC 9207 iss 校验、DCR 弃用转 Client ID Metadata Documents)。 +5. **SDK 现状**:Python SDK v2(实现 2026-07-28)仍是 beta,官方明确不建议生产使用;1.x 稳定线实现 2025-11-25 及以前。毕昇后端已锁 `mcp` 1.27.1(纯 client 用途,无 fastmcp 依赖,但 `mcp` 包自带 `mcp.server.fastmcp` 与 streamable-http server)。 + +来源:[官方 changelog](https://modelcontextprotocol.io/specification/2026-07-28/changelog) · [发布博客](https://blog.modelcontextprotocol.io/posts/2026-07-28/) · [MRTR 规范页](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr) · [SDK beta 公告](https://blog.modelcontextprotocol.io/posts/sdk-betas-2026-07-28/) · [Python SDK v2 beta 解读(pydantic.dev)](https://pydantic.dev/articles/mcp-python-sdk-v2-beta) · [The Register](https://www.theregister.com/devops/2026/07/23/model-context-protocol-prepares-to-break-with-its-stateful-past/5276722) + +## 4. 现状盘点(代码取证) + +| 事实 | 位置 | +|---|---| +| v2 路由实际在 `open_endpoints/` 模块,`/api/v2` 前缀在聚合处拼接 | `open_endpoints/api/endpoints/workflow.py:28`,`api/router.py:94` | +| invoke 入参:`workflow_id` 必填、`stream` 默认 True、`input`(alias)、`message_id`、`session_id`、`override`(散参 Body,无 pydantic model) | `open_endpoints/api/endpoints/workflow.py:31-40` | +| **`stream=false` = 跑到断点**(结束或进入 `WorkflowStatus.INPUT`)返回 `{session_id, events:[…]}`,增量 `stream_msg` 已过滤 | 同上 `:107-124`、`:90` | +| 对外事件 9 种;HITL 交互 4 形态:`input`(form_input 多字段表单 / dialog_input 对话+文件)、`output_with_input_msg`(行内单输入)、`output_with_choose_msg`(行内单选) | `api/v1/schema/workflow.py:7-24`,转换器 `api/services/workflow.py:864-976` | +| 表单项结构 `WorkflowInputItem{key,type,required,multiple,options,file_type…}` | `api/v1/schema/workflow.py:35-43` | +| 续跑:status==INPUT 时带 `session_id`+`message_id`+`input={node_id:{key:value}}` 再次 invoke,key 集合严格校验 | `endpoints/workflow.py:72-81`,`worker/workflow/redis_callback.py:309-363` | +| 执行模型:Celery + 一致性哈希绑 `chat_id` 到固定 worker 队列;**INPUT 态 Workflow 对象只在该 worker 进程内存**(`_global_workflow`),重启即丢 | `worker/utils/stateful_worker.py:107-122`,`worker/workflow/tasks.py:22,41-46,128-130` | +| 事件经 Redis list 中转,TTL = workflow timeout(默认 720min)+60s | `redis_callback.py:61-66`,`core/config/settings.py:151-155` | +| **v2 无鉴权**:统一 `get_default_operator()` 身份,无 key/签名;且 invoke **不校验 `FlowStatus.ONLINE`** | `endpoints/workflow.py:41,44-48`,`open_endpoints/domain/utils.py:26-50` | +| stop:置 Redis stop 标记 + `stop_workflow.delay()`;**delay 走通用队列而非绑定队列,可能落错 worker**(只置 FAILED,图内节点仍在跑) | `redis_callback.py:403-406`,`tasks.py:175-196` | +| MCP 现状:仓库只有 client 侧(`mcp_manage/`,约 250 行,官方 `mcp` SDK),**无任何 server 侧代码** | `mcp_manage/clients/*`,`pyproject.toml:50` | +| 工具元数据:`Flow.name/description/status(ONLINE=2)/flow_type(WORKFLOW=10)`;**输入参数无静态 schema API**,藏在 `Flow.data` 的 INPUT 节点里 | `database/models/flow.py:53-63`,`workflow/nodes/input/input.py:80-101` | +| 全事件权威 JSON 示例(写 adapter 的对照材料) | `src/frontend/platform/src/components/bs-comp/apiComponent/ApiAccessFlow.tsx:296-1016` | + +## 5. 映射分析 + +| workflow API 概念 | MCP 概念(2026-07-28) | 说明 | +|---|---|---| +| `session_id`(服务端生成、调用方回传) | server-minted handle as tool argument | 一字不改就是规范推荐模式 | +| `stream=false` 跑到断点返回 `{session_id, events}` | `tools/call` 一次调用返回 | 天然契约,invoke 内核零改动 | +| `input` 事件 + form_input schema | MRTR `InputRequiredResult` + `ElicitRequest(mode:"form", requestedSchema)` | text/select(options→enum)/required 均可映射 | +| `output_with_choose_msg` | ElicitRequest enum 单选 | 1:1 | +| `session_id`+`message_id`+`{node_id:{key:value}}` 续跑 | 重试原 tools/call + `inputResponses` + `requestState`(编码 session_id/message_id/node_id + HMAC) | 语义同构;`requestState` 防篡改要求正好补上现有 API 裸奔的洞 | +| `/stop` | Tasks 扩展 `tasks/cancel`;MVP 期一个 `workflow_stop` 工具 | | +| 720min 长任务 | Tasks 扩展(轮询 `tasks/get`) | 进阶项,MVP 先限定"断点式"用法 | +| `Flow.name/description` | tool `name/description` | 现成 | +| `guide_word/guide_question/stream_msg` 增量 | 无对应,折叠进文本结果或丢弃 | `stream=false` 路径本来就过滤增量 | + +## 6. 非丝滑点与对策(按严重度) + +1. **文件上传无映射**。`dialog_input` 的文件位 / form_input 的 file 类型,elicitation form 只支持原始类型。 + 对策:MVP 在 tools/list 层过滤含文件输入的 workflow(或调用时返回明确错误);进阶提供"传 URL、服务端代下载"约定。 +2. **HITL 状态的后端现实**。协议无状态了,但 INPUT 态 Workflow 对象在 worker 进程内存、靠一致性哈希回同进程;worker 重启即丢会话。MCP 化不引入新问题(HTTP API 同病),但 LLM 客户端会自动重试,**丢会话必须返回可理解的错误**("会话已失效,请重新发起"),否则模型陷入重试循环。 + 对策:`workflow_continue` 对 session 不存在/超时的情况返回结构化 `{status:"failed", reason:"session_expired", recoverable_by:"re-invoke"}` 级别的语义化错误。 +3. **鉴权必须新加**。直接透出 v2 无鉴权模式 = 匿名可枚举并调用全部 workflow。 + 对策:MCP endpoint 强制 API key(形态见 §9 D2),且只暴露 `FlowStatus.ONLINE` 的 workflow(顺手补上 invoke 不校验 ONLINE 的缺口,至少在 MCP 侧)。 +4. **生态时机**。真 MRTR 需要客户端也说 2026-07-28(发布次日,Python SDK v2 仍 beta)。 + 对策:MVP 用"**模型中介式 HITL**"——工具返回结构化 `{status:"input_required", …, input_form}`,靠 LLM 理解后向用户收集输入再调续跑工具;协议级 MRTR 作为纯增量升级(契约不变,只是把 input_required 从 structuredContent 提升为 resultType)。 +5. 小项: + - stop 任务落错队列的既有 bug(`redis_callback.py:406` 走通用队列)在 cancel 语义下会放大,宜顺手修(改 `apply_async(queue=绑定队列)`)。 + - 新规范删除 SSE 断流重放 → 事件经 Redis list lpop 消费是破坏性读取,断流重试的幂等性要设计(re-issue 时从 status + 未消费事件恢复,不能假设事件还在)。 + +## 7. 方案设计 + +### 7.1 架构位置 + +新模块 `src/backend/bisheng/mcp_server/`(纯增量;无新依赖,`mcp` 1.27.1 自带 streamable-http server),挂载到 FastAPI(如 `/api/v2/mcp`)。**不经 HTTP 自调用**,直接复用服务层:`execute_workflow.apply_async` + `RedisCallback` + `convert_chat_response_to_workflow_event`——与 `open_endpoints/api/endpoints/workflow.py:42-124` 的同源逻辑抽成共享 service,两个入口(REST / MCP)共用。 + +### 7.2 工具粒度:两阶段 + +**阶段一(MVP):4 个通用工具** + +| 工具 | 入参 | 返回 | +|---|---|---| +| `workflow_list` | — | ONLINE workflow 列表(id/名称/描述/输入形态摘要/是否含文件输入) | +| `workflow_invoke` | `workflow_id`, `inputs?` | 见 7.3 契约 | +| `workflow_continue` | `session_id`, `message_id`, `inputs` | 同上 | +| `workflow_stop` | `session_id` | `{status}` | + +实现最薄,任何已发布 workflow 即插即用,无需管理 tool 注册的动态性。 + +**阶段二(进阶):每个已发布 workflow = 一个 MCP tool** +`inputSchema` 由新写的 `Flow.data` INPUT 节点静态解析函数生成(复用 `workflow/nodes/input/input.py:80-101` + `api/services/workflow.py:906-933` 的逻辑),配 `listChanged` 通知 + `ttlMs` 缓存 + 确定性排序。LLM 的工具选择体验更好(名称/描述/参数直接可见,不用先 list 再 invoke)。 + +### 7.3 HITL 契约(MVP,模型中介式) + +`workflow_invoke` / `workflow_continue` 返回 structuredContent: + +```json +{ + "status": "completed | input_required | failed", + "session_id": "…", + "message_id": 123, + "outputs": [{"node_name": "…", "text": "…", "files": []}], + "input_form": { + "node_id": "input_xxx", + "form_type": "form_input | dialog_input | inline_input | inline_choose", + "fields": [{"key": "…", "label": "…", "type": "text|select|number", "required": true, "options": []}] + }, + "error": {"reason": "session_expired | …", "recoverable_by": "re-invoke | retry | none"} +} +``` + +tool description 写明协议:"收到 `input_required` 时,向用户收集 `fields` 各项后调用 `workflow_continue`"。升级 MRTR 时该结构原样搬进 `InputRequiredResult`(`input_form` → ElicitRequest,`session_id/message_id/node_id` → HMAC 保护的 `requestState`)。 + +### 7.4 鉴权 + +MCP endpoint 强制 API key(HTTP header),发布页生成/吊销;执行身份沿用 default_operator 语义(与现有 API 访问一致),但增加:key 校验、只暴露 ONLINE、审计日志记 key 主体。规范正向 OAuth/CIMD 收紧,为演进保留 header 抽象。具体形态见 §9 D2。 + +### 7.5 产品形态 + +对外发布页在「API 访问 / 免登录链接 / 需登录链接」旁新增「**MCP 接入**」标签页:展示 MCP endpoint URL、API key 管理、客户端配置示例(Claude Code `mcp.json` / 通用 `mcpServers` JSON——格式与 `mcp_manage/manager.py` 消费的一致)、HITL 行为说明。 + +### 7.6 协议演进路线 + +| 时机 | 动作 | +|---|---| +| 现在(MVP) | 稳定 spec(`mcp` 1.x streamable-http),模型中介式 HITL | +| Python SDK v2 GA + 主流客户端支持 2026-07-28 | 升级 SDK,`input_required` 提升为协议级 MRTR,`requestState` 加 HMAC+TTL+principal 绑定 | +| 长任务需求出现 | 接 Tasks 扩展(`tasks/get` 轮询 + cancel),与 HITL 的 `input_required` 状态组合 | + +## 8. 风险清单 + +| 风险 | 等级 | 缓解 | +|---|---|---| +| worker 重启丢 INPUT 态会话 → LLM 重试循环 | 中 | 语义化错误 + description 指引 re-invoke(§6.2) | +| 文件输入类 workflow 不可用 | 中 | list 层过滤 + 明确报错(§6.1) | +| API key 泄漏 = 可调全部已发布 workflow | 中 | key 按租户/按 workflow 收窄(D2),审计日志 | +| stop 落错队列,cancel 不彻底 | 低 | 顺手修队列路由(§6.5) | +| 断流重试幂等性(Redis lpop 破坏性读取) | 低 | status + 事件恢复设计(§6.5) | +| SDK v2 breaking 变化 | 低 | MVP 不依赖 v2,契约层隔离 | + +## 9. 待拍板问题 + +- **D1 工具粒度节奏**:MVP 直接做阶段二(per-workflow tool)还是先 4 个通用工具?(建议:先通用,静态 schema 解析函数另立小任务并行) +- **D2 鉴权形态**:平台级单 key / 按 workflow 发 key / 按租户发 key?key 存哪(复用 `gpts_tools.api_key` 模式 vs 新表)? +- **D3 文件输入策略**:MVP 直接排除,还是首发就带"URL 代下载"约定? +- **D4 v2 既有行为顺手修**:invoke 不校验 ONLINE、stop 队列错配——只在 MCP 侧收口,还是连 REST 侧一起修(后者是行为变更,需评估存量调用方)? +- **D5 落版本**:进 3.0.x 哪个迭代;是否与「应用工场」的对外能力规划合并考虑。 + +## 10. 验证方式 + +- 文档阶段:本方案 review(逐项过 §6 非丝滑点取舍 + §9 拍板)。 +- 实现阶段:MCP Inspector / Claude Code 作为客户端连 server 实测——`tools/list` 可见已发布 workflow → invoke 一个含 INPUT 节点的翻译流 → 模型收集表单输入 → continue 跑完 → stop 中断验证;worker 重启后续跑旧 session 应得到可理解错误而非 500;无 key / 错 key / 未上线 workflow 三类拒绝路径。 diff --git "a/docs/PRD/3.0-beta2/3.0 \345\274\200\346\224\276 API \351\211\264\346\235\203\344\270\216\350\272\253\344\273\275\344\274\240\351\200\222 PRD.md" "b/docs/PRD/3.0-beta2/3.0 \345\274\200\346\224\276 API \351\211\264\346\235\203\344\270\216\350\272\253\344\273\275\344\274\240\351\200\222 PRD.md" index 8dee487b4c..eb91946056 100644 --- "a/docs/PRD/3.0-beta2/3.0 \345\274\200\346\224\276 API \351\211\264\346\235\203\344\270\216\350\272\253\344\273\275\344\274\240\351\200\222 PRD.md" +++ "b/docs/PRD/3.0-beta2/3.0 \345\274\200\346\224\276 API \351\211\264\346\235\203\344\270\216\350\272\253\344\273\275\344\274\240\351\200\222 PRD.md" @@ -20,6 +20,7 @@ | v2.8 → v2.9 主要变更 | 2026-09-13,PAT × 技能包两项新决策 + 员工侧界面定稿(七个决策点经逐项采访确认)。① **新增 D21:PAT 数据范围可收窄**——租户管理员可把个人令牌的可检索范围收窄为「仅持有人本人创建的知识库」,默认不收窄。**只做租户级单层**(曾考虑部署级 + 租户级两层取严,否决:能力开关默认关已承担「升级不静默开出口」的职责,数据范围属租户自治,单租户部署下租户级即等效全环境);**收窄优先于管理员短路**(数据出口管控不是权限判定,D17「令牌可见范围 = 平台可见范围」的承诺限于默认档,AC-P17 加限定);判定口径与界面「我创建的」同源、按库 / 空间归属粒度**不下探文件创建者**(本人库内他人添加的内容仍可检索——残余风险已确认接受,界面不另行提示);调用期判定不写凭据行、对存量令牌立即生效、可逆(同闸门一约定 3)。落点 §4.10.3 / §4.10.4 / §4.10.7 闸门一之二;附录 C 新增 `26044`;新增 AC-P23–AC-P28;**租户策略变更补审计要求**(此前策略 PUT 无审计)。② **新增 D22:员工侧界面形态定稿**——功能更名「**AI 助手接入**」(两端统一,替换「个人 API 令牌」/「个人访问令牌」的同物不同名);入口 = 知识库页常驻主入口 + 设置页一级分区 + 深链(现状单入口埋于账号信息页,桌面最短 3 击);**风险提示分层**:普通员工只在密钥展示时警示(含「拿到密钥的人不需要登录即可查到相同内容」与数据范围四态说明),管理员默认档保留签发前二次确认,收窄档降级为普通流程;**既有警示文案「此令牌不会继承管理员特权」写于 D17 改判前、与现行规则直接冲突,必须随本次改写**(AC-P29)。落点 §4.10.6;新增 AC-P29–AC-P31。③ 技能包必备件 `references/api.md` 行补数据范围行为与 `26044` 处置指引(§4.10.8) | | v2.9 → v2.11 主要变更 | 2026-09-30,**点亮任务模式**,需求正文由子文档《3.0 任务模式开放 API PRD》 v1.3 承接(该文 §7.2 连带修订)。① §1.4 引用块改为指向子文档;② §4.6.3 交付边界表与能力表:任务模式 × 异步、查询、取回、下载、终止已开放,日常模式 × 异步仍返回 `26015`;③ §4.6.3 第六小节登记四项前置与两项必考项的处理,**前置 ④ 由子文档 DT-5 改判为「只排队、不设上限」**;④ AC-50 改写;⑤ 附录 C `26017` 含义收窄为「指定了枚举外的运行模式」。(v2.10 是 `3.0-vibe` 上的 D23 修订,未进入发版线,本版跳过该号) | | 2026-09-14 订正 | **身份请求头与实现对齐**,不升版本号(无决策改判,对外契约的名称与报错行为向已实现的代码订正):① `X-Bisheng-On-Behalf-Of` / `X-Bisheng-End-User` 全文改为 `X-On-Behalf-Of` / `X-End-User`;② §4.3 新增:带 `Bisheng` 前缀等非标准写法的身份头返回 400(`26019`),**不做别名、不静默忽略**;§4.9 与 AC-26 明确裸 `user_id` 同码报错;③ §4.3.3 补 `X-On-Behalf-Of` 取值只收用户 ID(正整数),**不再接受用户名**,§4.4.4 同步;④ §4.3.4 补 `X-End-User` 格式约束(`26018`);⑤ 附录 C 补 `26018` / `26019`,新增 AC-51 | +| v2.9 → v2.10 主要变更 | 2026-09-15,**员工侧说明适用的 AI 助手**(**D23**)。动机:私有化部署多数不对公网开放,技能包脚本在 AI 助手所在的电脑上运行,网页版 AI 助手连不上;原入口「让第三方 AI 助手检索知识空间」暗示任何助手都行。① 叫法定为「**本地 AI 助手**」(知识库页入口副文案、设置页描述);② 弹窗第 1 步卡片内只写正例「发给装在你电脑上的 AI 助手,比如 WorkBuddy 桌面版。」,**不写「网页版用不了」反例**;③ 连通性提示落在**技能包脚本**的连接失败分支(2026-09-16 修订:曾加在安装提示词第四行,上线后观感不好,已撤回,提示词保持三行),同时删除密钥卡片「从未使用」下方的验证说明;④ §3.5 订正 WorkBuddy 归类(桌面端走技能包属 A 类,附录 D 新增 S22);⑤ 附录 F 新增两条红线(「任何 AI 助手都能接」与「本地 AI 助手 = 数据不出本地」都不能说);⑥ 新增 AC-P32、AC-P33(§4.10.6) | > **阅读指引** > §1 是全文结论,只读这一节即可做立项决策。§2–§3 说明问题与需求来源,§4 是需求正文(研发与产品的执行依据),§5–§7 是验收、边界与待决项。 @@ -325,7 +326,7 @@ BiSheng 对外暴露两组 HTTP 接口: | | **A 类 · 有执行能力的通用 Agent**(本期目标) | **B 类 · 平台内配插件/连接器** | |---|---|---| -| 代表 | openclaw、Claude Code、Cursor、客户自研 Agent | Coze / 扣子、豆包工作台、WorkBuddy、钉钉 | +| 代表 | openclaw、Claude Code、Cursor、**WorkBuddy 桌面版**、客户自研 Agent | Coze / 扣子、豆包工作台、WorkBuddy(插件 / 连接器机制)、钉钉 | | 能力 | 有本地文件系统与进程执行能力,能自己发 HTTP 请求 | 只能在对方平台的管理界面里配一个插件 / 连接器 | | 接入形态 | **技能包 + HTTP API + 一把个人密钥**——对方不必为我们开任何口子 | **同样支持技能包**(zip);但若走各家的插件 / 连接器机制,则受其鉴权形态约束:WorkBuddy 只接 MCP [S18];Coze 的插件静态密钥是**插件级全局一把** [S20];钉钉连接器凭据是连接器级、管理员托管 [S19] | | 运行位置 | **用户自己可控的环境(本机 / 自有容器)→ 本来就在内网** | 公有云 SaaS | @@ -336,6 +337,8 @@ BiSheng 对外暴露两组 HTTP 接口: > **技能包这一形态是通用的,各平台都能导入**——因此**交付物只有一份,不为任何平台另做**(D18 已定)。上表拆两类,是为了说清**风险在哪不一样**,不是范围边界。 > +> **WorkBuddy 归类订正(2026-09-15)**:WorkBuddy 有桌面端,在员工电脑上运行、能读写本地文件、可安装技能 [S22]——走技能包时属 A 类,连通性不成问题;原先只列入 B 类,针对的是它的插件 / 连接器机制(只接 MCP [S18])。员工侧界面点名的「WorkBuddy 桌面版」即指前者(§4.10.6 / D23)。 +> > **B 类仍有两点值得知道,但都不改变我们做什么**:① **连通性**——公有云侧要访问客户内网的 BiSheng,得客户自己打通;② **逐用户凭据存在哪里**——在 openclaw 这类 Agent 上密钥落在用户本地配置、天然逐人隔离,而托管平台若把配置按机器人 / 应用存,就会退回"一把共用密钥",个人令牌的逐人语义随之失效。**后者是那个平台的形态问题,不是我们的交付缺口**;但它决定了对外能怎么说(附录 F 措辞红线)。 --- @@ -1145,7 +1148,7 @@ R4 定义 2「权限单独授予」落在这里。 | §4.10.3 | 权限动态继承持有人;本期只开 `knowledge:read`;租户可收窄数据范围至「仅本人创建」(D21) | AC-P4、AC-P5、AC-P14、AC-P23、AC-P24、AC-P25 | | §4.10.4 | 管理员短路照常生效,但令牌不跨租户;数据范围收窄优先于短路 | AC-P16、AC-P17、AC-P17a、AC-P26 | | §4.10.5 | 有效期由租户默认值给定(默认 1 年)、员工不可选;随持有人停用、删除级联失效;**换租户不失效、随人迁移**(D19) | AC-P2、AC-P2a、AC-P8、AC-P9、AC-P9a | -| §4.10.6 | 自助签发、只见自己、明文只显示一次、产出安装提示词;界面按「AI 助手接入」形态交付(D22) | AC-P3、AC-P10、AC-P18、AC-P29、AC-P30、AC-P31 | +| §4.10.6 | 自助签发、只见自己、明文只显示一次、产出安装提示词;界面按「AI 助手接入」形态交付(D22);写明适用的是本地 AI 助手、安装提示词带失败自述(D23) | AC-P3、AC-P10、AC-P18、AC-P29、AC-P30、AC-P31、AC-P32、AC-P33 | | §4.10.7 | 治理三闸门:能力开关与数据范围(D21)、管理员台账与强制吊销、审计双归属 | AC-P1、AC-P1a、AC-P1b、AC-P11、AC-P12、AC-P13、AC-P27、AC-P28 | | §4.10.8 | 交付一个 `zip` 技能包 + HTTP API + 个人密钥;检索结果集合相等;对客文档模式改名 | AC-P6、AC-P7、AC-P19、AC-P20、AC-P21、AC-P22 | @@ -1267,7 +1270,7 @@ R4 定义 2「权限单独授予」落在这里。 | # | 功能 | 要点 | |---|---|---| | 1 | **获取** | 一键获取,**不填任何参数**(无名称、无有效期);确认后**一次性展示明文**,此后只见掩码 | -| 2 | **状态展示** | 掩码、**有效期至**、最后使用时间;**状态**只在已过期 / 已吊销时出现,有效是默认态不单列;从未使用时附一句如何验证连接 | +| 2 | **状态展示** | 掩码、**有效期至**、最后使用时间;**状态**只在已过期 / 已吊销时出现,有效是默认态不单列。**从未使用时只显示「从未使用」,不另附验证说明**(2026-09-16:原要求「附一句如何验证连接」已实现又删除——「最后使用 从未使用」已经把状态说清,那段话不让人做出不同的动作) | | 3 | **删除 / 重新获取** | 删除即作废;重新获取 = 作废旧的并签发新的,**泄漏应急与到期续期用的是同一个动作** | | 4 | **安装提示词** | 一段可一键复制的自然语言指令,**给 Agent 的,不是给人的** | @@ -1286,7 +1289,10 @@ R4 定义 2「权限单独授予」落在这里。 | 关停态 | 租户级关停时:知识库页入口隐藏,设置分区**保留**并说明「已发放的密钥暂时无法使用,重新开启后自动恢复」(关闭 = 停用不是撤销,§4.10.7 约定 3 的界面表达);部署级关停时两处均不出现 | | 风险提示(分层) | **普通员工:只在密钥展示时警示**,不加签发前确认(签发可逆,风险在保管不在签发动作)。警示要点:拿到密钥的人**不需要登录**即可查到相同内容;勿发到群聊 / 代码仓库 / 共享文档;怀疑泄露即删除或重新获取,旧密钥立即失效。**管理员(默认数据范围):签发前勾选确认 + 展示时加重警示**(即 §4.10.4 三件中的第 1 件);数据范围已收窄时降级为普通流程 | | 数据范围说明 | 密钥展示与状态页均展示「这把密钥能查到什么」:**静态文案,按「是否管理员 × 是否收窄」四态联动**,不做动态清单 | +| 适用的 AI 助手(2026-09-15,D23) | 界面叫「**本地 AI 助手**」,指装在员工电脑上的 AI 助手:知识库页入口副文案「让本地 AI 助手检索知识空间」,设置页描述「让 WorkBuddy 桌面版等本地 AI 助手检索你在平台可见的知识」。弹窗第 1 步卡片内、复制按钮上方写一句正例「发给装在你电脑上的 AI 助手,比如 WorkBuddy 桌面版。」——**只给正例,不写「网页版用不了」反例**;贴错地方时的原因交给安装提示词第四行,由对方 Agent 自述(见第 4 项)。文案静态,不按部署是否对公网开放切换 | +> 为什么要写适用范围:技能包脚本**在 AI 助手所在的那台电脑上运行**,从那台电脑访问平台。私有化部署多数不对公网开放,在厂商服务器上运行的 AI 助手(典型是各家网页版)连不上;此前入口写「第三方 AI 助手」,暗示随便哪个助手都行。「本地」只说助手装在哪,**不代表数据留在本地**——本地 AI 助手照样把检索结果发给它背后的云端模型(附录 F)。 +> > 命名判据:个人令牌对外的唯一形态是技能包,没有直接的 API 产品面——入口按用途命名,不按凭证对象命名。 > > 既有警示文案「此令牌不会继承管理员特权」写于 D17 改判前,与现行规则直接冲突,**必须随本次一并改写**(AC-P29)——否则管理员在界面上读到的承诺与事实相反。 @@ -1303,6 +1309,8 @@ R4 定义 2「权限单独授予」落在这里。 **「本实例地址」必须由页面按当前部署动态生成**,不能是静态文案——BiSheng 是私有化部署,每个客户的地址都不一样。 +**提示词保持三行**(2026-09-16 修订,D23):曾加过第四行「打不开地址就直说连不上」,上线后实测观感不好——那是写给 Agent 的运行规则,不该混进给人看的复制框。**连通性提示改落在技能包脚本**:请求连接失败(拿不到任何 HTTP 状态码)时,脚本自己输出「这台机器打不开平台地址,本技能只能在能打开它的电脑上运行,请告知用户、不要重试或另找办法」。 + 页面同时保留一份**给人看的**最小调用示例(端点、请求头、一个最小请求体),供自研 Agent 或 curl 验证。 > 员工拿到一串密钥而不知道下一步做什么,等于功能没交付。AC-P21 验收的是「装包、配密钥、提问」这条端到端链路,而不只是 API 可调。 @@ -1557,6 +1565,8 @@ R4 定义 2「权限单独授予」落在这里。 - **AC-P29** 对客界面(员工侧与管理端)不再出现「个人 API 令牌」「个人访问令牌」字样与权限位内部码(如 `knowledge:read` 原码),功能统一名为「AI 助手接入」;管理员签发警示文案与 D17 一致——**「此令牌不会继承管理员特权」及同义表述不得再出现**。 - **AC-P30** 知识库页入口与设置页分区两处均可达同一签发弹窗;租户级关停后知识库页入口隐藏、设置分区保留关停说明(含「重新开启后自动恢复」);部署级关停两处均不出现。 - **AC-P31** 密钥展示弹窗包含:一次性展示警示、泄露风险警示、「这把密钥能查到什么」范围说明;范围说明按「是否管理员 × 是否收窄」四态正确联动,收窄态明示「不含部门知识空间和别人共享给你的知识」。 +- **AC-P32** 知识库页「连接 AI 助手」入口副文案与设置页「AI 助手接入」描述写明「本地 AI 助手」,界面不再出现「第三方 AI 助手」;弹窗第 1 步卡片内、复制按钮上方可见「发给装在你电脑上的 AI 助手,比如 WorkBuddy 桌面版。」;三语齐全(§4.10.6 界面形态 / D23)。 +- **AC-P33** **安装提示词保持三行**,不含连通性提示;连通性提示由技能包脚本承担——请求连接失败(无 HTTP 状态码)时,`scripts/search.py` 输出的信息指明「这台机器打不开平台地址、本技能只能在能打开它的电脑上运行」,并要求告知用户、不要重试(§4.10.6 第 4 项 / D23)。 > **AC-P5 与 AC-P7 是这个功能"可不可信"的两条线:多一条是越权,少一条是功能不可用。** > 二者都必须双向验证——正例验集合相等,反例验不出现。 @@ -1613,6 +1623,7 @@ R4 定义 2「权限单独授予」落在这里。 | **D20** | 服务账号的权限来源 | **已定:单独授予,不继承任何自然人** | 2026-09-09 拍板。研发提出「创建服务账号时指定一个自然人,后续资源权限检查以该人为准」,否决。**判据是这把密钥服务的是一条集成,还是一个人**——服务集成则边界按这条集成收敛(服务账号单独授予),服务个人则走个人访问令牌(§4.10)。继承会把授权方向倒过来:为了让集成跑起来,必须把集成所需的权限永久加到一个真人身上。配置负担这一真实痛点改由「按某自然人当前权限初始化授权」的**静态对齐**承接,而非动态继承。展开见 §7.7 | | **D21** | PAT 数据范围收窄 | **已定:租户级单层,默认全部可见,可收窄为「仅本人创建」** | 2026-09-13 拍板。动机:企业客户介意组织知识经员工的第三方 Agent 流向公网模型。**收窄优先于管理员短路**——这是数据出口管控不是权限判定,豁免管理员等于豁免可见面最大的那把钥匙;D17「令牌可见范围 = 平台可见范围」的承诺限于默认档。**只做租户级**:曾考虑部署级 + 租户级两层取严,否决——能力开关默认关已承担「升级不静默开出口」的职责,数据范围属租户自治,单租户部署下租户级即等效全环境。判定口径与界面「我创建的」同源,按库 / 空间归属粒度**不下探文件创建者**(本人库内他人添加的内容仍可检索——残余风险经确认接受,界面不另行提示;更严档位待真实诉求再立项)。**与 D3 不冲突**:这是租户级、全体一致的策略,不是密钥级白名单。调用期判定不写凭据行、对存量立即生效、可逆(同闸门一约定 3)。展开见 §4.10.3 / §4.10.7 闸门一之二 | | **D22** | 员工侧界面形态 | **已定:更名「AI 助手接入」+ 双入口 + 风险提示分层** | 2026-09-13 拍板(七个决策点逐项采访确认)。命名判据:个人令牌对外唯一形态是技能包、无直接 API 产品面,入口按用途命名;顺带收敛两端同物不同名(「个人 API 令牌」/「个人访问令牌」)。入口 = 知识库页常驻主入口(数据就近,1 击)+ 设置页一级分区(生命周期驻地,承载关停态解释)+ 深链;曾考虑全局侧边栏常驻直达,否决——与「废除头像弹出菜单」的既有方向相逆,低频配置动作不占全局栏位。风险提示分层:普通员工只在密钥展示时警示(签发可逆,风险在保管不在签发动作),管理员默认档保留签发前二次确认(§4.10.4 三件之一),收窄档降级;范围说明为静态四态文案、不做动态清单(可见清单与可检索范围存在口径差,展示反而误导)。展开见 §4.10.6 | +| **D23** | 员工侧如何说明适用的 AI 助手 | **已定:叫「本地 AI 助手」+ 弹窗只给正例 + 安装提示词加失败自述** | 2026-09-15 拍板。动机:私有化部署多数不对公网开放,技能包脚本在 AI 助手所在的电脑上运行,网页版 AI 助手连不上;原入口「第三方 AI 助手」暗示任何助手都行。**叫法**:用户选「本地 AI 助手」(曾推荐「装在电脑上的 AI 助手」,以免被读成数据不出本地——否决,该顾虑改由附录 F 红线兜住)。**弹窗**:第 1 步卡片内、复制按钮上方只写正例「发给装在你电脑上的 AI 助手,比如 WorkBuddy 桌面版。」,**不写「网页版用不了」反例**。**连通性提示**(2026-09-16 修订):**不写进安装提示词**——第四行上线后观感不好,且把「给人看的复制框」与「给 Agent 的运行规则」混在了一起;改由技能包脚本在连接失败时输出。**顺带删除**:密钥卡片「从未使用」下方那段验证说明(「最后使用 从未使用」已说清状态,那段话不让人做出不同的动作)。**不做**:管理端不加适用范围说明(管理员的决策依据是数据出口风险,这句不改变它,写了反而可能被读成数据留在公司内);不按部署是否对公网开放动态切换文案。WorkBuddy 归类随之订正(§3.5)。展开见 §4.10.6 | ## 7.2 D2 · 为什么不采用"权限取交集" @@ -2315,6 +2326,8 @@ Coze 官方文档自述 [S5]: | S20 | Coze / 扣子 · 插件节点授权模式 | https://docs.coze.cn/guides/plugin_node | | **S21** | **腾讯 ima · Skills 安装页**(真实案例来源,用户提供截图) | https://ima.qq.com/agent-interface | | **S21** | **腾讯 ima · 技能包本体**(2026-08-27 实地下载解包,见 A.2-bis 拆包记录) | https://app-dl.ima.qq.com/skills/ima-skills-1.1.9.zip | +| **S22** | **腾讯云 · WorkBuddy 下载、安装与使用指南**(桌面端在本机运行、可读写本地文件;2026-09-15 取证) | https://www.tencentcloud.com/techpedia/144100?lang=zh | +| **S22** | **WorkBuddy 官方文档 · 技能市场**(桌面端对话内安装技能;2026-09-15 取证) | https://www.workbuddy.cn/docs/workbuddy/From-Beginner-to-Expert-Guide/Function-Description/Skills-Market | > **本轮取证边界(务必遵守)**:以下三类主张在本轮**零条通过对抗性核查**,本 PRD 因此**没有**引用它们,后续也不得以"业界都这么做"补写——① MCP 授权规范(OAuth 2.1 Resource Server / Protected Resource Metadata / RFC 8707)② 查询期 ACL 与 security trimming 的厂商做法(Azure AI Search / Graph connectors / Glean / Elastic / Kendra)③ OWASP、令牌泛滥、DLP 与出站合规。**另有两条被明确推翻**:「GitHub 离职即自动级联吊销 PAT」「GitLab 删除或封禁用户即自动吊销其 PAT」——均以 0-3 被否,**不可引用**(正确口径见 §4.10.4)。 > @@ -2635,3 +2648,5 @@ platform 侧服务账号管理面已完整(`SystemPage/components/ServiceAccou | 每个员工用自己的密钥、按本人权限检索 | 在**托管平台**上也承诺"逐人身份"——那类平台若把技能配置按机器人 / 应用存,密钥就是共用的,逐人语义不成立(§4.10.8 结论三) | | 技能包格式是通行的 `zip`,各家 Agent 通用 | 承诺"已在某某平台跑通"——除非该平台已实测(本轮只拆过 ima 的包,未在任何平台端到端跑过) | | ima 把知识库做成技能包供 Agent 安装,是一个可对标的先例 | 「业界都用技能包」——**本轮只有 ima 一个样本**(A.8-bis 第 4 条) | +| 本地 AI 助手(装在员工电脑上的,如 WorkBuddy 桌面版)可以接入 | 「任何 AI 助手都能接」「网页版 AI 助手也能用」——私有化部署多数不对公网开放,在厂商服务器上运行的 AI 助手连不上(D23) | +| 「本地 AI 助手」说的是 AI 助手装在员工电脑上 | 「数据不出本地」「知识留在公司内」——本地 AI 助手照样把检索结果发给它背后的云端模型,这正是 D21 要应对的风险 | diff --git "a/docs/PRD/3.0-beta2/3.0 \347\237\245\350\257\206\351\227\256\347\255\224\345\244\232\346\250\241\346\200\201\350\257\273\345\233\276 PRD.md" "b/docs/PRD/3.0-beta2/3.0 \347\237\245\350\257\206\351\227\256\347\255\224\345\244\232\346\250\241\346\200\201\350\257\273\345\233\276 PRD.md" new file mode 100644 index 0000000000..3732232f36 --- /dev/null +++ "b/docs/PRD/3.0-beta2/3.0 \347\237\245\350\257\206\351\227\256\347\255\224\345\244\232\346\250\241\346\200\201\350\257\273\345\233\276 PRD.md" @@ -0,0 +1,209 @@ +# 知识问答多模态读图 PRD + +| 版本 | Feature | 目标版本 | 日期 | +|---|---|---|---| +| v3.0(待评审) | F060-qa-image-reading | v3.0.0 | 2026-09-03 | + +--- + +## 一、要做什么 + +三个问答场景——首页日常对话、知识空间问答、频道订阅问答——里,模型读不到文档和文章里的图。它看到的只是一个 markdown 图片标签,于是要么说看不到,要么照着周边文字编一段。 + +**做法**:给每张图编一个本轮临时号,标记追加在原图片引用旁边;模型需要看时按编号调用「查看图片」工具,服务端取图、按档缩放后送给模型。 + +图片在文档解析时已被抽取并托管,**不改入库链路,存量知识库零迁移**。 + +--- + +## 二、原理 + +模型没有手脚——不能上网、不能读盘、不能发请求。它每次收到的就是一个消息列表,每条消息的内容是一串「块」,块要么是文字要么是图片,**图片块里装的就是图的字节本身**。所以模型能看见一张图,唯一条件是这次请求的列表里有一个装着它的块。 + +因此「工具返回图片」是说法上的错觉。走一遍: + +``` +① 检索命中一段文字,里面有 ![](…/chart.png) +② 服务端编号 img#1,正文改成 …![](…/chart.png)⟦img#1⟧…,并记下 img#1 → 图片路径 +③ 第一次请求模型:[系统提示, 问题, 检索结果] + ← 模型此刻还是瞎的,只是从标记知道"这儿有张图" +④ 模型说:调用「查看图片」(img#1, 标准档) +⑤ 服务端取字节 → 缩到长边 512 → 编码 +⑥ 第二次请求模型:[系统提示, 问题, 检索结果, "我要看 img#1", 图片块] + └──── 第一次的内容原样重发 ────┘ └ 新塞的 ┘ +⑦ 模型看见了,答出实际走势 +``` + +模型全程被动:它只做了第 ④ 步「说一句话」,取图与送达都是服务端干的。**第 ⑥ 步不是"把图送给模型",而是整个对话重发一遍、外加一张图**——模型没有记忆,每次调用都从头全量读。 + +后面的规则都从这里推出,三条最关键的: + +| 设计 | 因为 | +|---|---| +| 图必须放进**用户消息** | 图片块按框架规矩会挂在工具消息上,但 OpenAI 兼容面的工具角色只收字符串,部分端点直接拒收 | +| 必须**分档缩放** | 全量重发意味着图的成本每轮都付;而成本只跟像素数量有关、跟信息量无关。要多细只有模型自己知道,所以档位交给它选 | +| 同轮只留**最近一批**图 | 模型看第 6 张时前 5 张会跟着重发,成本随次数平方增长 | + +--- + +## 三、范围 + +**做**:图片编号与标记 · 「查看图片」工具 · 三场景统一为同一套循环 · 知识空间检索工具化 · 视觉门禁与降级 · 读图预算 · 外链抓取护栏 · 修复工具结果把图片数据写进数据库的既有缺陷。 + +**不做**: + +| 不做 | 原因 | +|---|---| +| 入库期给图片生成文字描述 | 那解决的是"图里的内容能否被**检索到**",本期解决"检索命中了但**看不见**"。下一版本 | +| 把原件整页渲染给模型看 | 补"图落在相邻分段"的缺口,下一版本 | +| md / txt / xls / csv 里的图 | 文档解析本就不抽取,起点不存在 | +| 工作流 RAG 节点、助手 | 没有读图工具,看到标记反而诱发编造 | + +--- + +## 四、规则 + +### 4.1 编号与寻址 + +| # | 规则 | +|---|---| +| R1 | 每张图获得本轮唯一编号,标记**追加**在原图片引用之后,原引用一字不改(回答渲染规则要求模型原样保留图片 markdown,替换会让回答里的图不显示) | +| R2 | 编号只收录本轮已通过权限过滤的内容中的图片 | +| R3 | 工具**只接受编号,不接受地址**(见 §五 T2) | +| R4 | 编号仅本轮有效;回答落库前清除残留标记 | + +### 4.2 「查看图片」工具 + +| # | 规则 | +|---|---| +| R5 | 入参为一批编号 + 一个清晰度档位,返回模型可直接查看的图片 | +| R6 | 三档:粗看 / 标准(默认)/ 高清。不提供原图档;原图更小时不放大 | +| R7 | 预算三重上限:单次张数、单轮累计张数、单轮累计体量。触顶返回文字说明,不中断回答 | +| R8 | 单张失败降级为文字:告知原因、要求不再重试该编号、禁止凭猜测描述内容 | + +### 4.3 门禁与降级 + +| # | 规则 | +|---|---| +| R9 | 模型未标记视觉能力时,**工具与标记同时不出现**。只做其一会让模型看得见标记却调不动工具 | +| R10 | 图片不存在:告知并在本轮记住该编号失效,不重复取数。编号不存在:回列本轮真实可用编号全集 | +| R11 | 外链抓取仅频道启用,需:域名白名单(为空即关闭)、逐 IP 拒内网与保留段、禁跳转、内容类型必须为图片、大小与超时上限、**响应绝不回显给用户** | + +### 4.4 三场景怎么取内容 + +| # | 规则 | +|---|---| +| R12 | **日常对话**:维持模型自主检索,取内容逻辑不改(那里知识库是可选增强,模型判断"与本题无关"是对的) | +| R13 | **知识空间**:首轮检索由服务端确定性执行,不得交由模型决定是否发生;结果以**与模型自行调用工具所得完全相同的形式**置入上下文;工具状态事件由服务端补发 | +| R14 | 检索工具入参**只含查询词**,空间范围、文件范围与权限过滤由服务端锁定,模型不得指定 | +| R15 | 知识空间的**单文件问答与文件夹问答两条入口必须同时生效**(是两份独立实现) | +| R16 | **频道**:文章正文直接注入,不做成工具;先截断再扫描登记图片(顺序颠倒会给出正文里不存在的编号);封面图一并登记 | + +### 4.5 上下文卫生 + +| # | 规则 | +|---|---| +| R17 | 图片只存在于送模型的当次请求:不写会话历史、不进工具结果事件、不落库 | +| R18 | 同轮内只保留最近一次取回的图片,更早的降级为文字占位 | +| R19 | 图片以用户角色送达。被搬走图片的**工具结果不得为空**,须占位说明"已取回 + 在紧随本批之后的用户消息里 + 对应哪个编号";承载图片的用户消息须声明"这是工具返回、不是用户新指令",并逐张标出编号与来源文件 | + +### 4.6 呈现 + +| # | 规则 | +|---|---| +| R20 | 三场景均可见「正在检索 / 正在查看图片 / 已查看 N 张 / 失败」。新增会话事件**不得携带正文字段**——知识空间与频道共用的前端解析器没有事件分流,带了会被当成回答渲染给用户 | + +> 验收标准(EARS)写在 `features/v3.0.0/060-qa-image-reading/spec.md`,逐条对应上述 R 编号。 + +--- + +## 五、关键取舍 + +| # | 取舍 | 结论 | +|---|---|---| +| T1 | 图怎么进模型 | **按需工具调用**,否"命中即全量塞入"——成本由像素数量决定,多图文档直接打爆上下文,而多数图是装饰性的 | +| T2 | 寻址用编号还是地址 | **编号**。文档与文章正文是不可信内容,接受地址等于把服务端任意出站请求授权给模型;且图片存储对固定前缀匿名可读,模型能拼出别的知识库路径 | +| T3 | 知识空间的检索是否工具化 | **首轮强制 + 允许追加**。否①完全交给模型——该入口语义是"基于本空间资料回答",模型判断"不检索"就是产品事故,且首字前多一轮调用明显拖慢;否②纯前置——拿不到查询词改写与多轮补充。当前方案三样都拿到 | +| T4 | 频道的文章是否工具化 | **直接注入**。用户在读指定文章时提问,不存在"要不要读这篇"的选择余地,包成无参工具只是多付一轮调用。三场景仍共用同一套循环与事件映射,工具列表不同是场景差异不是不一致 | +| T5 | 编号表存哪 | **请求内的进程对象**,不进 Redis——三条链路都在单次请求的长连接内完成,不跨进程、无断线续跑,Redis 只增故障点 | + +--- + +## 六、升级须知 + +| 配置项 | 变化 | +|---|---| +| 知识空间 · **用户提示词** | **移除**。检索结果改由工具消息承载,占位符失去内容来源,存量租户自定义值不再生效 | +| 知识空间 · 系统提示词 | 保留,默认值更新(补检索工具与图片标记说明)。存量自定义值不迁移——措辞不精确但不影响功能 | +| 频道 · 系统/用户提示词 | **不变**。文章仍直接注入,占位符继续有效 | + +两个 tab 的配置项因此不对称,是取内容方式不同的结果,不是遗漏。 + +**分期**:一期后端全量(功能即完整可用,旧前端对新事件天然无害,表现为回答前多几秒停顿);二期前端补工具卡片与三语文案。 + +--- + +## 七、实现须知 + +**改动点** + +| 位置 | 做什么 | +|---|---| +| `knowledge_utils.py:131` `format_retrieved_chunk` | 标记注入唯一出口。加 `image_registry=None`,**默认 None 则另外四个消费方**(`workflow/nodes/agent/agent.py:116`、`workflow/nodes/rag/rag.py:110`、`api/services/assistant_agent.py:126`、`chat_service.py:296`)**一行不改、行为逐字节不变** | +| `chat_service.py:496` `_parse_tool_results` | **必改**:`_json_safe`(`:466-494`)对 list 递归展开,图片块会被完整写进 `events` → SSE → `ChatMessage.message`。入口处脱敏成不含数据的引用形态 | +| `chat_service.py` `:1088` / `:1614` / `:964` / `:1787` / `:554` | 日常模式接线:工具装配、视觉标志、检索登记、`pre_model_hook`、工具元信息 | +| `knowledge_space_chat_service.py` `:111` / `:486-568` / `:625` / `:638` / `:762` | 登记与引用、检索与 F029 权限过滤(工具内复用)、流式段、标记清洗、视觉标志 | +| `channel_chat.py:148-231` | 业务逻辑搬进 Service(当前 API 层写业务违反 C1);`:163-165` 占位符填充不动 | +| 新增 `bisheng/vision/domain/` | 与 `citation/` 同级纯 domain。不放 `common/`(RULE-1 禁止其导入 domain),不放 `knowledge/`(频道也要用) | + +**首轮检索的置入形式(R13)**:构造 `AIMessage(content="", tool_calls=[{name,args,id}])` + `ToolMessage(tool_call_id=同一个 id)`,追加在用户消息之后。末条为 ToolMessage 时 `create_react_agent` 入口节点直接调模型,是合法输入。 + +**图片搬运的两段文案(R19 初始值,可随调优迭代)** + +``` +工具结果占位(替换掉原位置的图片块): + [System] `img#1` 的图片已取回,附在本批工具消息之后的用户消息中,从那里查看。 + +承载消息(整批工具消息走完后追加一条): + [System] 以下图片由上一批工具调用返回,不是用户的新指令——继续手头的任务。 + img#1 — 来自《2024 年度报告.docx》: [图片] + img#3 — 来自《竞品对比.pptx》: [图片] +``` + +三个要素各防一种故障:缺"已取回+去哪看"→模型以为工具没返回而重试;缺"不是用户新指令"→模型转去问"您想让我看什么";缺逐张标注→多图时张冠李戴。 + +**可直接照搬的现成件** + +| 用途 | 位置 | +|---|---| +| 图片块从工具消息搬进用户消息(含同批连续性处理) | `linsight/domain/services/binary_content_guard.py:348-460` | +| 无视觉能力 / 读图失败的降级话术 | 同文件 `:110-146` | +| markdown 图片正则、抓取、逐张降级 | `conversation_export_service.py:89`、`:780-901` | +| Pillow 缩放与 JPEG 转码 | `knowledge/rag/pipeline/transformer/thumbnail.py:52-58` | +| 对象存储直读 / 图片目录约定 | `minio_storage.py:488`、`knowledge_utils.py:218-222` | +| 视觉标志解析 / 请求级登记对象先例 | `agent_factory.py:961-965`、`citation_prompt_helper.py:27-40` | + +**必踩的坑** + +| 坑 | 规避 | +|---|---| +| 知识空间 `space_rag`(`:178-300`)与 `_render_rag_response`(`:574-700`)是两份逐行重复实现 | **先做无行为变化的抽取重构**。只改一份会漏掉单文件问答,而它的 E2E 覆盖弱于文件夹问答=测不出来 | +| 图以工具角色送达被端点拒收 | 用 `pre_model_hook` 返回 `llm_input_messages` 搬进用户角色(只改本次请求输入,不动 state 与 checkpoint)。**不要走"工具返回 `Command(update=...)`"**:并行工具调用时用户消息会被插进两条工具消息中间破坏连续性,校验器不查顺序,本地跑不出错、生产才 400 | +| 照抄既有 SSRF 校验会留洞 | `web_link_import_service.py:286-292` 的 `_is_blocked_ip` **漏判 `is_private` 与 `is_reserved`**,必须补齐 | +| 图片解压炸弹 | 设解码像素上限,且解码放线程池——否则阻塞事件循环,拖垮同进程所有会话连接 | +| 模型编造编号(必然发生) | 未命中时回列真实编号全集;同一编号连续两次未命中则追加"不要再猜测编号" | +| 匿名可读的图片前缀是既有越权面,本功能会放大 | 工具只收编号,编号只来自已过权限过滤的检索结果;工具内二次校验图片所属知识库在本次授权集合内。技术债:该策略值得单独排期收敛为签名地址 | + +**不改**:文档解析全链路 · 分段 metadata 白名单 · 引用溯源体系 · 工作流与助手的提示词与上下文 · 日常模式取内容 · 频道文章注入 · 既有会话事件类型与字段。 + +**SDD 指针**:`features/v3.0.0/060-qa-image-reading/{spec,design,tasks}.md`;需在 `features/v3.0.0/release-contract.md` 登记 F060。 + +--- + +## 变更记录 + +| 日期 | 版本 | 内容 | +|---|---|---| +| 2026-09-03 | v3.0 | 按评审意见大幅精简:463 → 约 170 行;EARS 验收标准移出至 SDD spec.md,正文只留规则 | +| 2026-09-03 | v2.x | 新增原理章节;检索工具化改判为「首轮强制 + 可追加」;补图片搬运的文案要素 | +| 2026-09-03 | v1.0 | 初稿 | diff --git "a/docs/PRD/3.0-release/3.0 \345\272\224\347\224\250\345\267\245\345\234\272 PRD-1 \344\270\223\344\270\232\345\274\200\345\217\221\350\200\205\351\200\232\351\201\223\344\270\216\345\272\224\347\224\250\350\277\220\350\241\214\346\227\266.md" "b/docs/PRD/3.0-release/3.0 \345\272\224\347\224\250\345\267\245\345\234\272 PRD-1 \344\270\223\344\270\232\345\274\200\345\217\221\350\200\205\351\200\232\351\201\223\344\270\216\345\272\224\347\224\250\350\277\220\350\241\214\346\227\266.md" new file mode 100644 index 0000000000..719a23b27d --- /dev/null +++ "b/docs/PRD/3.0-release/3.0 \345\272\224\347\224\250\345\267\245\345\234\272 PRD-1 \344\270\223\344\270\232\345\274\200\345\217\221\350\200\205\351\200\232\351\201\223\344\270\216\345\272\224\347\224\250\350\277\220\350\241\214\346\227\266.md" @@ -0,0 +1,1273 @@ +# BiSheng 3.0 应用工场 PRD-1:专业开发者通道与应用运行时 + +| 项 | 值 | +|---|---| +| 文档版本 | v3.1 | +| 日期 | 2026-10-11 | +| 状态 | 功能已在 `3.0-vibe` 实现,待需求评审与验收 | +| 读者 | 接手开发、测试、设计 | +| 技术方案 | 《3.0 应用工场 技术架构方案》(同目录)。本文只写需求,不写实现 | +| 凭据与鉴权 | 《3.0 开放 API 鉴权与身份传递 PRD》(`docs/PRD/3.0-beta2/`,下称「鉴权 PRD」)。服务账号、密钥底座、权限位清单以该文为准,本文只写应用工场在其上的增量 | +| 姊妹分册 | 《3.0 应用工场 PRD-2 造应用工作台》,已无限期搁置 | +| 交付进度 | `features/v3.0.0/README.md`「当前状态」一节,本文附录 B 是 2026-10-09 的快照 | + +**阅读顺序。** §1 用一页讲清本册做什么、为什么做、不做什么,其中 §1.4 是 21 项功能的完整清单。§2 写用户与旅程。§3 是需求正文,开发与测试按它执行:§3.0 是全册共用的三项约定,§3.1–§3.3 按 DEV、RT、GOV 三个域展开。§4–§6 是非功能需求、范围外事项与开放问题。 + +**条目写法。** 每个条目以一句话结论开头,然后依次写「需求描述、界面与交互、规则与边界、验收要点」四段。条目编号(DEV-xx、RT-xx、GOV-xx)在两册之间全局唯一,`features/v3.0.0/` 下的 spec 按编号和验收要点序号追溯,因此本文改写时保留了全部编号和序号。 + +--- + +## 1. 综述 + +### 1.1 一句话定位 + +本册让专业开发者把本地开发好的应用部署到 BiSheng 平台上运行,并开放给企业内的同事使用。开发者在本地用自己的 IDE 或 coding agent 写代码,需要时通过 MCP、OpenAI 兼容模型接口、SDK 接入平台的知识库、模型和用户身份。开发完成后,开发者执行 `bisheng deploy` 把应用提交到平台。平台对每次提交做预检和密钥扫描,再经审批后托管运行。应用上线后,owner 在平台上设置可见范围,范围内的同事就能从应用广场打开使用。审批、权限和审计由平台统一执行,应用代码不需要处理这些事情。 + +### 1.2 为什么做 + +企业里有越来越多的员工会用 coding agent 开发小应用,例如表单、台账、看板和内部工具。这些应用开发完之后通常没有地方运行。开发者要么自己找服务器部署,要么放弃。自己部署的应用要单独处理登录、权限、数据库和模型密钥,IT 部门看不到它们,也管不住它们。这类绕开 IT 的自建应用(shadow AI)会带来数据外泄和审计缺失的风险。 + +BiSheng 已经具备企业身份、组织架构、权限体系、知识库、模型管理、审批和审计能力。本册把这些能力开放给本地开发的应用使用,并提供一个托管运行环境。这样,开发者省去了重复建设登录和权限的工作,IT 部门也得到了一条有治理的上线通道。 + +本册和通用容器平台(例如 k8s)解决的问题不同。容器平台负责让进程运行起来。本册在运行之上增加了五件事:应用复用平台登录、应用自动获得访问者身份、应用按声明接入知识库和模型、每次上线经过审批、应用在广场中可被同事找到。 + +### 1.3 五条设计前提 + +下面五条前提贯穿全册。读正文前先掌握它们,正文中的大部分规则都可以从这五条推出。 + +| # | 前提 | 含义 | +|---|---|---| +| 1 | 平台能力按需集成 | MCP 工具、技能包、SDK 和 CLI 四种接入方式都是可选的。不使用任何平台能力的存量应用也可以直接部署。四种方式全部使用标准协议,任何本地 coding agent 不需要改造即可接入 | +| 2 | 同一条发布管线 | CLI 部署的应用和将来在平台内造的应用走同一条发布管线,审批、权限和审计规则相同 | +| 3 | 凭据以鉴权 PRD 为准 | 服务账号、密钥底座和权限位清单定义在鉴权 PRD。本册只增加三项:权限位清单中的「本地开发工具包」三个权限位、服务账号详情页的接入信息区、若干落地口径 | +| 4 | 产品行为与运行时形态无关 | 发布管线、资源档位、审批、权限、日志和崩溃自愈的行为在任何运行时形态下都相同。形态之间的差异由运行时组件内部处理 | +| 5 | 默认可用,不需要先配置 | 发布审批有平台预置流程,资源档位有默认档,模型和知识库沿用平台已有配置。租户管理员不做任何配置,第一次发布也能走通 | + +### 1.4 本册功能全集(21 项) + +本册交付 21 项功能,分属三个域。下表是本册功能的唯一完整清单,每项的需求和验收见 §3 的同名条目。 + +| 编号 | 功能 | 交付内容 | 主要角色 | +|---|---|---|---| +| **DEV:专业开发者通道(6 项,§3.1)** |||| +| DEV-01 | 服务账号密钥(本地开发用途) | 租户管理员为开发者建服务账号并签发密钥;权限位清单新增「本地开发工具包」三个权限位;服务账号详情页新增接入信息区 | 租户管理员、开发者 | +| DEV-02 | MCP 工具面与模型协议面 | 六类工具经 MCP 对外;模型经 OpenAI 兼容协议调用;两者都以服务账号自身身份执行,按权限位过滤 | 开发者的 coding agent | +| DEV-03 | 开发技能包(两包) | 「部署纳管」和「平台能力接线」两个技能包,随平台版本发布,`bisheng login` 时自动拉取 | 开发者的 coding agent | +| DEV-04 | CLI 命令集 | `login`、`skills sync`、`dev`、`deploy`、`logs` 五个命令;`deploy` 是 CLI 的唯一发布入口 | 开发者 | +| DEV-05 | 本地运行与身份注入 | `bisheng dev` 在本地注入与线上结构相同的身份,应用不写登录页 | 开发者 | +| DEV-07 | bisheng-sdk | auth、retrieve、storage 三个模块;模型调用和应用数据库不进 SDK,改用行业标准库 | 开发者 | +| **RT:应用运行时与发布(7 项,§3.2)** |||| +| RT-01 | 统一入口与平台登录复用 | 内网入口 `/apps/{应用标识}` 和四类兜底页;复用平台登录,应用不写登录代码 | 业务用户 | +| RT-02 | 应用广场 | 托管应用与工作流、助手进入同一个广场,按可见范围过滤 | 业务用户 | +| RT-03 | 发布审批流 | 提交、冻结快照、密钥扫描、审批、上线终检的完整管线;审批人可以审读代码,审批人和 owner 都可以拉起临时预览实例 | owner、审批人 | +| RT-04 | 能力声明定义 | 「能力声明」的唯一定义;迭代发布同样必须审批 | 全体 | +| RT-05 | 版本记录与结构演进 | 每次提交生成一条只增不改的版本记录;加列自动迁移,改列和删列须显式确认并在迁移前留快照 | owner | +| RT-07 | 资产持久与显式删除 | 代码和数据保存在平台存储中;下线、重新上线和显式删除三个动作及其前置状态 | owner、租户管理员 | +| RT-08 | 稳定性承诺 | 单个应用崩溃只影响它自己,5 分钟内自动恢复;切换和恢复期间不出现报错页 | 全体 | +| **GOV:治理与管理面(8 项,§3.3)** |||| +| GOV-01 | 权限资源类型与授权 | 托管应用注册为平台权限体系的一种资源类型;授权复用构建页卡片上的「管理权限」弹窗 | owner、租户管理员 | +| GOV-02 | 发布审批场景 | 平台预置审批流程,部署即生效;租户管理员可以改配审批人 | 租户管理员 | +| GOV-03 | 资源档位 | 三个预置档位供发布时选择,不提供自定义规格;容量不足时应用进入待上线状态 | 平台超管、owner | +| GOV-04 | 全链路审计 | 应用创建、能力接入、发布运维、密钥事件全部留痕,管理面可查询和导出 | 租户管理员、平台超管 | +| GOV-05 | 能力总线 | 模型和知识库在管理面配置一次,应用通过能力声明使用;owner 为本应用录入第三方密钥,平台运行时注入 | 租户管理员 | +| GOV-07 | 应用工场权限控制 | 界面入口沿用既有「创建应用」权限点,CLI 入口由密钥上的 `app:manage` 权限位控制,两者互不校验 | 租户管理员 | +| GOV-08 | 开放 API 鉴权改造与密钥承诺 | v2 开放 API 改为密钥鉴权,不设兼容窗口;密钥的四条产品承诺 | 租户管理员 | +| GOV-10 | 可选部署与运行时形态 | 工场运行时层可以整层不装,不装时平台其余功能的行为不变;装则选单机或 k8s 集群形态,两种形态的产品行为相同 | 平台超管 | + +编号中缺少 DEV-06、RT-06、RT-09、GOV-06 和 GOV-09。这是有意为之:其中有的已取消,有的顺延到 v3.1,有的属于 PRD-2。每一项的去向见 §5。 + +### 1.5 本版明确不做的事 + +| 域 | 本版明确不做 | +|---|---| +| DEV | 本地端专有插件;第三方 MCP 网关;本地身份模拟参数(`bisheng dev --as`) | +| RT | 公网域名、公开可见、SEO、水印;平台侧回滚;应用标签设置;迭代审批单的变更摘要 | +| GOV | 自定义资源规格与自动扩缩容;运行时形态之间的迁移;租户级实例数配额;聚合用量账单 | + +每一项的理由和将来补做时的注意事项见 §5。 + +--- + +## 2. 用户与旅程 + +### 2.1 用户与角色 + +| 用户 | 需求 | 主要使用面 | +|---|---|---| +| 业务技术员(主用户,会用 coding agent) | 把本地开发的应用部署到平台给同事用 | 专业开发者通道(§3.1) | +| IT 与平台管理员 | 看得到、管得住员工自建的应用,出问题时能处置 | 治理与管理面(§3.3) | +| 普通业务用户 | 在应用广场找到并使用同事开发的应用 | 统一入口与应用广场(§3.2) | + +下表是全文角色名称的唯一出处。正文提到角色时一律使用表中名称,不泛称「管理员」。 + +| 角色 | 定义 | 典型动作 | +|---|---|---| +| 平台超管 | 平台级运维管理员 | 维护资源档位、跨租户查询审计 | +| 租户管理员 | 租户内的治理角色 | 签发和吊销服务账号密钥、下线和重新上线应用、配置审批人、查询本租户审计 | +| 审批人 | 处理发布审批单的人,来源由 GOV-02 预置流程决定 | 审读代码、预览试用、通过或驳回 | +| owner | 应用的所有者。CLI 首次部署的应用,owner 是所用密钥对应服务账号的资源归属人 | 部署、审批期预览待上线版本、撤回审批、设置可见范围、下线、删除 | +| 普通业务用户 | 应用的使用者 | 在可见范围内发现并使用应用 | + +单租户部署下,平台超管和租户管理员可以是同一个人。 + +面向终端用户的兜底页文案是唯一例外,这些页面使用中性称呼「平台管理员」。原因是兜底页由应用入口组件 app-proxy 渲染。app-proxy 是独立进程,读不到平台的多租户开关,无法按部署形态选择「租户管理员」或「平台超管」。标准安装默认单租户,而单租户下不存在租户管理员,页面上写「请联系租户管理员」会指向一个不存在的人。 + +### 2.2 主旅程:本地开发、集成平台能力、部署上线 + +1. 开发者在本地用 IDE 或 coding agent 开发应用。平台不参与这一步。 +2. 开发者需要平台能力时,请租户管理员建服务账号并签发密钥(DEV-01)。开发者的 coding agent 通过 MCP 调用检索、身份和应用数据工具,通过 OpenAI 兼容协议调用平台模型(DEV-02),按需引入 SDK(DEV-07)。技能包在 `bisheng login` 时已经自动拉取(DEV-03)。如果应用不需要平台能力,开发者跳过这一步。 +3. 开发者执行 `bisheng dev`,在本地运行应用。CLI 注入与线上结构相同的身份,应用不需要登录页(DEV-05)。 +4. 开发者执行 `bisheng deploy`,应用进入发布管线:预检、密钥扫描、审批、上线(DEV-04、RT-03)。开发者用 `bisheng logs` 查看日志排障。 +5. 应用首次上线后,owner 在平台上打开应用详情页,在「发布」tab 设置可见范围(GOV-01)。如果 owner 不设置,应用虽然已上线,但只有 owner 自己能看到。这是首次上线后必须做的一步,也是整个旅程中第一次离开命令行。 +6. 之后的迭代仍然是本地改代码、再次 `deploy`。代码修改只能在本地进行,平台对 CLI 部署的应用代码只读。 + +下表列出没有命令行对应操作、必须在平台界面上完成的操作。入口都在 platform 端「构建 → 应用」中该应用的详情页(导航见 §3.0.1)。 + +| 操作 | 何时需要 | 出处 | +|---|---|---| +| 设置可见范围 | 首次上线后必须做 | GOV-01 | +| 待上线状态下手动上线 | 审批通过但运行环境容量不足时 | GOV-03 | +| 撤回在途审批单 | 提交后想撤回时 | RT-03 | +| 下线、重新上线 | 需要临时停止服务时 | RT-07 | +| 显式删除 | 应用退役时(须先下线) | RT-07 | +| 获取入口链接和二维码 | 分发给同事时 | RT-01 | + +除设置可见范围外,其余操作都是按需触发。审批状态有两个查询途径:应用详情页和 MCP 应用状态工具(DEV-02),因此 coding agent 在本地就能跟踪审批进度。 + +### 2.3 使用旅程:普通业务用户 + +普通业务用户通过应用广场、入口链接或二维码打开应用。已登录平台的用户不需要再次登录。每个用户在应用里只看到自己有权限的数据(RT-01、RT-02)。 + +--- + +## 3. 功能需求 + +### 3.0 全册通用约定 + +下面三项约定被多个条目引用,在这里集中定义。各条目只引用、不重复定义。 + +| 约定 | 回答的问题 | 引用者 | +|---|---|---| +| §3.0.1 承载面与导航 | 界面操作在哪里完成 | 全文凡提到「应用详情页 · X」处 | +| §3.0.2 应用状态机 | 应用和审批单处于什么状态、如何流转 | RT-03、RT-05、RT-07、GOV-01、GOV-03 | +| §3.0.3 事件触达 | 一方操作后,另一方如何得知 | 全文凡提到「通知」「提示」处 | + +#### 3.0.1 承载面与导航 + +托管应用与工作流、助手共用 platform 端「构建」区的同一个应用列表,不另设入口。完整导航路径如下,全文提到「应用详情页 · X」时都指这条路径的终点: + +``` +platform 端 + └─ 左侧主菜单「构建」 + └─ 应用列表(既有卡片流页) + · 顶部:搜索 + 应用类型筛选 + 状态筛选 + 标签筛选 + · 应用类型筛选新增第三项「托管应用」,与「工作流」「助手」并列 + · 卡片:图标、名称、描述、创建人、类型角标、版本下拉、上下线开关 + └─ 点击托管应用卡片 + └─ 应用详情页:发布 · 数据 · 运行日志 · 版本(四个 tab) +``` + +应用列表复用构建页的既有能力:搜索、筛选、标签、上下线开关和版本下拉。owner 看到的是自己的应用。租户管理员通过平台既有的权限模型,天然能看到本租户的全部应用,因此本册不另建治理列表。 + +应用详情页有四个 tab,内容如下。PRD-2 曾计划在这个页面左侧加入对话区,这部分已随 PRD-2 搁置,本册的详情页没有对话区。 + +| tab | 内容 | 编号 | +|---|---|---| +| 发布 | 提交发布与档位选择、审批状态与驳回理由、撤回、能力声明清单、应用密钥、可见范围、下线与重新上线、待上线时的手动上线、入口链接与二维码、底部危险操作区(删除)。各区块的定义见 RT-03 | WB-14 | +| 数据 | 查看、编辑和导出应用自有数据库中的数据;编辑生产数据前须二次确认,编辑动作计审计 | WB-06 | +| 运行日志 | 只读的应用运行日志,权限与内容范围和 CLI `logs` 一致(DEV-04) | WB-13 | +| 版本 | 版本列表、终态标注、相邻版本的文件差异(RT-05) | WB-15 | + +四个 tab 的编号沿用 PRD-2 的 WB 前缀,因为它们在 PRD-2 中定义过完整形态。本册交付的形态以上表为准。 + +卡片上只保留列表级的轻操作,即上下线开关和版本下拉。提交发布、设置可见范围和删除这类需要上下文的操作都在详情页完成。 + +托管应用放在构建区而不是 client 端,原因有三点。第一,工作流、助手和托管应用都是用户造出来的东西,放在一处便于查找。第二,构建页的列表、筛选、上下线和权限能力可以直接复用,owner 和租户管理员看的是同一个页面。第三,平台分工因此更清楚:造和管在 platform 端,用在 client 端(应用广场和 `/apps/{slug}` 在 client 端)。 + +#### 3.0.2 应用状态机 + +应用的生命周期用两条互相独立的状态线描述。应用状态表示应用当前能否对外提供服务,审批单状态表示一次发布请求的处理进度。版本记录的终态见 RT-05。构建页卡片和筛选器上的「发布状态」使用同一组状态值。 + +应用状态: + +| 状态 | 进入条件 | 可做的操作 | 去向 | +|---|---|---|---| +| 草稿 | 首次部署后尚未上线;首次发布被驳回或撤回 | owner 提交发布、再次部署 | 首次审批通过并上线成功 → 已上线;删除 → 已删除 | +| 已上线 | 审批通过且上线成功 | 可见范围内用户使用;owner 提交迭代、调整可见范围;owner 或租户管理员下线。本状态不能直接删除,须先下线(RT-07) | 下线 → 已下线 | +| 待上线(资源不足) | 审批通过,但上线终检发现运行环境容量不足(GOV-03) | owner 在资源释放后手动上线,不需要重新审批;owner 删除 | 上线成功 → 已上线;删除 → 已删除 | +| 已下线 | owner 或租户管理员下线 | owner 或租户管理员重新上线(同样检查运行环境容量);owner 提交发布(审批通过后只记为待运行版本,不自动上线);owner 删除 | 重新上线 → 已上线;删除 → 已删除 | +| 已删除 | owner 执行显式删除(RT-07) | 无,只能在审计中查到 | 终态 | + +审批单状态有五个:待审、通过、驳回、已撤回、已取消。应用被删除时,在途审批单自动变为已取消(RT-07)。迭代审批被驳回时,应用保持已上线状态,当前版本继续运行,只有待上线版本被标记为驳回(RT-03)。 + +#### 3.0.3 事件触达 + +一方操作后需要另一方知道的事件,全文统一使用两种触达渠道。 + +第一种是站内消息。它复用平台既有的消息通知能力,接收方在工作台的「消息提醒」入口(铃铛图标)查看。审批类事件同时进入审批中心的待办。管理后台没有消息面,租户管理员和审批人的消息都在工作台接收。站内消息只做通知,不承载操作,处理动作回到各自的界面完成。 + +第二种是界面自查。状态和结果常驻在固定界面上,例如应用详情页的发布 tab、构建页的应用列表和管理后台的密钥管理页,用户随时可以查看。 + +| 事件 | 接收方 | 站内消息 | 自查位置 | +|---|---|---|---| +| 审批单生成(首发或迭代) | 审批人 | 有(审批中心既有触达) | 审批中心待办 | +| 审批通过或驳回 | owner | 有 | 发布 tab 的审批状态区;MCP 应用状态工具 | +| owner 撤回在途审批单 | 已收到待办的审批人 | 有 | 审批中心 | +| 应用删除导致在途审批单取消 | 审批人 | 有 | 审批中心 | +| 上线终检容量不足,应用进入待上线 | owner、租户管理员 | 有 | 发布 tab(owner);构建页状态筛选(租户管理员) | +| 资源释放后可以手动上线 | owner | 无 | 发布 tab 的「手动上线」按钮 | +| 租户管理员下线或重新上线应用 | owner | 有 | 发布 tab 的状态;审计记录 | +| 声明的能力被收回(模型下线、知识库收回) | owner | 无 | 发布 tab 的失效提示 | +| 服务账号密钥签发完成 | 资源归属人 | 无 | 开发者侧没有自查位置。租户管理员通过企业既有渠道转交密钥和接入信息(DEV-01) | +| 密钥被吊销或服务账号被禁用 | 资源归属人 | 无 | 下一次调用被拒绝,CLI 提示重新 login(DEV-04) | +| 应用上架或用户新获可见权限 | 可见范围内的用户 | 无(本版不做上新推送) | 应用广场;owner 自行转发入口链接 | + +本约定只覆盖事件发生时的一次性触达。催办、超时提醒和升级机制本版不做(§5.2)。 + +### 3.1 专业开发者通道(DEV) + +本域让已有本地项目的开发者按需集成平台能力,并把项目部署到平台。六个条目按开发者的使用顺序排列: + +| 条目 | 解决的问题 | +|---|---| +| DEV-01 | 开发者如何拿到凭据,凭据能做什么 | +| DEV-02 | coding agent 如何调用平台能力 | +| DEV-03 | coding agent 如何学会使用平台能力 | +| DEV-04 | 开发者如何完成部署和排障 | +| DEV-05 | 应用如何在本地运行并获得身份 | +| DEV-07 | 应用代码如何调用平台能力 | + +本域的直接操作者通常是开发者的 coding agent,而不是开发者本人。开发者在本地对话中提出部署要求,coding agent 读取技能包,调用 CLI 和 MCP,完成部署和排障。DEV-04 验收 7 检查的就是这一点。 + +本域用「本地开发三面」统称 MCP 工具面、模型协议面和 CLI 三个对外接口。三面使用同一把服务账号密钥鉴权。 + +--- + +**DEV-01 服务账号密钥(本地开发用途)** + +> 开发者使用的密钥就是鉴权 PRD 中的服务账号密钥,不是一种新的密钥类型。签发表单、密钥底座和权限位清单都沿用鉴权 PRD,应用工场只在权限位清单中增加了三个权限位。 + +**需求描述。** 租户管理员为开发者建立服务账号,并把该开发者指定为服务账号的资源归属人,然后签发密钥。服务账号是不能登录的平台主体。开发者用这把密钥接入本地开发三面。 + +密钥的能力由两层条件共同决定:密钥上勾选的权限位,以及租户管理员显式授予该服务账号的资源。密钥不继承任何自然人的权限。 + +本地开发只接受服务账号密钥。鉴权 PRD 场景四另有个人访问令牌,它固定只持有 `knowledge:read` 一个权限位,不能用于部署、模型调用和身份查询。 + +密钥明文只在签发时展示一次,由租户管理员转交开发者,此后任何界面只显示掩码(NFR-2)。 + +**界面与交互。** 本条没有独立界面。签发、列表和吊销都使用鉴权 PRD 中服务账号模块的界面,下表只写应用工场的增量。 + +| 界面 | 应用工场增量 | +|---|---| +| 服务账号新建表单 | 在资源归属人选择处增加风险提示:该账号首次部署的应用以此人为 owner;删除应用和撤回审批单只有 owner 能做,平台超管和租户管理员都不能代做;归属人在创建时固定,以后修改不影响已有应用。因此归属人选错后,本版只能由管理侧下线应用 | +| 密钥签发表单 | 权限位清单新增「本地开发工具包」一组三个权限位,每个附用途说明,并提示「部署应用需要勾选 `app:manage`」,默认全不勾。`identity:read` 显示醒目的风险标注:勾选后本租户组织架构全部可读,不能限制到部分部门。`delegate` 与这三个权限位互斥,同时勾选时无法保存,提示「配了委托的密钥不能用于本地开发,请另发一把」 | +| 密钥列表、明文展示弹窗、吊销 | 沿用鉴权 PRD。列表的权限位列包含新增三位;吊销确认文案增加一句「MCP、模型协议和 CLI 将同时拒绝这把密钥」;开发者没有自助吊销入口 | +| 接入信息区 | 位于服务账号详情页「API 密钥」标签页顶部。展示 MCP Server 地址、OpenAI 兼容 base URL、CLI 安装包下载链接和 `bisheng login` 指引,并提供「一键复制接入信息」按钮。未部署开放能力层时不显示 | + +**规则与边界。** + +① 权限位与本地开发三面的对应关系见下表。`login` 和 `skills sync` 只校验密钥有效,不校验权限位。唯一的例外是 `delegate`:持有它的密钥在 `login` 时就被拒绝,理由见 DEV-02 规则 ④。 + +| 权限位 | 来源 | MCP 工具 | 模型协议面 | CLI | +|---|---|---|---|---| +| `knowledge:read` | 鉴权 PRD 既有,复用 | 知识库检索、知识库清单 | — | — | +| `model:invoke` | 应用工场新增 | 模型清单 | OpenAI 兼容调用 | — | +| `identity:read` | 应用工场新增 | 用户身份与组织查询 | — | — | +| `app:manage` | 应用工场新增 | 应用数据查询、应用状态与日志 | — | `deploy`、`logs` | +| `delegate` | 鉴权 PRD 既有 | 全部拒绝 | 全部拒绝 | `login` 即拒绝 | + +新增的三个权限位与鉴权 PRD 既有的权限位在同一张清单、同一个签发表单里,默认全不勾。它们不构成第二个权限维度。同一把勾了 `knowledge:read` 的密钥,通过开放 API 检索和通过 MCP 检索,得到的可见集合相同。`model:invoke` 与鉴权 PRD 的 `chat:invoke` 互不包含:前者是直接调用模型,后者是调用平台的会话能力。 + +② 凭据规则。密钥的生成、哈希存储、校验、展示和吊销都遵循 GOV-08 的统一密钥底座,密钥前缀是 `bs-sak-`。租户管理员可以在签发后修改权限位,修改立即生效,不需要重新签发,修改动作计审计。密钥可以设置过期时间,默认长期有效,到期自动失效并计审计。本版不做调用频率限流(§5.2)。模型 token 用量只计量、不设上限,每次调用按密钥记录。 + +③ 权限模型。服务账号的资源授权只有一个入口:租户管理员在服务账号详情页的「资源授权」标签页中授予。服务账号不出现在各资源的授权选人框中,这样普通资源所有者不能单方面扩大一把密钥的数据范围。 + +`identity:read` 是高风险权限位。勾选后,密钥能读取本租户的全部组织架构,包括姓名、部门和层级。平台没有把部分部门授予服务账号的机制,所以这个范围不能收窄。风险控制只能靠「给不给这个权限位」。租户隔离仍然有效,跨租户的数据读不到。 + +资源归属人只决定归属,不出借权限。在应用工场里它有两个作用。第一,CLI 首次部署的应用,owner 就是密钥对应服务账号的资源归属人。第二,应用数据、状态和日志工具以及 CLI `logs` 只对该归属人作为 owner 的应用生效。租户管理员修改资源归属人后,修改只影响以后创建的应用,已部署应用的 owner 不变。 + +④ 安全约束。CLI 把凭据保存在本地用户目录,文件只有属主可读,不写入工程仓库。技能包要求 coding agent 通过环境变量引用密钥,不在代码里硬编码。发布前的密钥扫描规则包含 `bs-sak-` 前缀,硬编码进应用代码的密钥在部署时被拦截(RT-03)。发现密钥泄漏时,资源归属人联系租户管理员吊销;租户管理员可以一键吊销某个服务账号名下的全部密钥,吊销在 5 秒内生效。服务账号被禁用或删除时,名下全部密钥自动失效。资源归属人离职时,平台不自动处理,须由租户管理员回收其服务账号(自动回收见 §5.2)。 + +⑤ 接入信息的传递。「一键复制」生成一段格式化文本,包含 MCP Server 地址、OpenAI 兼容 base URL、CLI 下载链接和 `bisheng login` 指引。租户管理员把这段文本通过企业微信、邮件等既有渠道发给开发者。文本可以随时复制,内容实时生成。这段文本不包含密钥明文,也不包含任何能换取密钥明文的凭据。接入信息不保密,可以随时重发;密钥明文保密,只展示一次,由租户管理员单独转交。本版不做分享链接、二维码或站内信推送接入信息。 + +⑥ 可选部署。未部署开放能力层时,新增的三个权限位和接入信息区都不显示。服务账号模块本身随鉴权 PRD 交付,始终存在(GOV-10)。 + +**验收要点。** + +1. 每次调用都能在审计中追溯到密钥及其服务账号和资源归属人,模型调用包含模型名和 token 数(GOV-04)。 +2. 密钥吊销后,下一次调用在 MCP、模型协议面和 CLI 上都被拒绝。 +3. 密钥明文签发时展示一次,之后任何界面只显示掩码。 +4. CLI 凭据保存在本地用户目录,只有属主可读,不写入工程仓库。 +5. 签发表单默认不勾任何权限位。不带权限位的密钥只能执行 `login` 和 `skills sync`,其余三面能力和开放 API 都被拒绝。租户管理员修改权限位后立即生效,不需要重新签发。超出权限位的调用在三面都被拒绝(按规则 ① 的表逐个权限位验证)。 +6. 密钥能访问的数据 = 权限位允许的范围 ∩ 服务账号被授予的资源。未授予的资源不可访问,检索和清单查询都不返回。同一把持有 `knowledge:read` 的密钥,经开放 API 检索和经 MCP 检索得到相同的可见集合。 +7. 服务账号被禁用后,名下密钥从下一次调用起在三面都被拒绝,禁用事件计审计。 +8. 把密钥明文写入应用代码后执行部署,发布前密钥扫描拦截部署(DEV-04、RT-03)。 +9. 「一键复制」的文本包含 MCP 地址、模型 base URL、CLI 下载链接和 login 指引,不包含密钥明文或可换取明文的凭据。开发者凭这段文本加上单独转交的密钥,能完成 `bisheng login` 并成功调用一次 MCP 工具。 +10. `delegate` 与「本地开发工具包」三个权限位不能同时勾选,签发和编辑表单都拒绝保存并提示。已存在的此类历史密钥在三面调用时被明确拒绝(DEV-02 规则 ④)。 + +--- + +**DEV-02 MCP 工具面与模型协议面** + +> 平台能力通过两个接口对外:工具走 MCP,模型走 OpenAI 兼容协议。两者都用服务账号密钥鉴权,都以服务账号自身的身份执行,都按权限位过滤。 + +**需求描述。** 平台运行一个 MCP Server,用服务账号密钥鉴权,按权限位过滤,对外提供下表六类工具。工具的权限边界与平台界面上的权限体系一致。模型调用不走 MCP,因为模型调用需要原生协议和流式输出,所以平台另外提供一个 OpenAI 兼容的模型接口。 + +| 工具 | 用途 | +|---|---| +| 知识库检索 | 在服务账号被授予的知识库中检索 | +| 知识库清单 | 列出服务账号被授予的知识库及其标识,供填写能力声明(RT-04) | +| 模型清单 | 查询可用模型;模型调用本身不走 MCP | +| 用户身份与组织查询 | 开发期查询用户身份和组织架构 | +| 应用数据查询 | 开发和排障时读写应用自有数据库 | +| 应用状态与日志 | 部署后查询审批状态和运行日志,使 coding agent 能完成「读日志、修改、再部署」的循环 | + +**界面与交互。** 本条没有平台界面。开发者需要的地址和接入指引集中在 DEV-01 的接入信息区。 + +模型协议面的约定如下: + +| 要素 | 约定 | +|---|---| +| 协议 | OpenAI 兼容协议,支持流式输出。本版不提供 Anthropic 兼容协议 | +| 鉴权 | 服务账号密钥(DEV-01) | +| 可用模型 | 租户管理员在模型管理页启用的模型(GOV-05) | +| 计量 | 每次调用按密钥记录 token 数(GOV-04) | +| 适用对象 | 支持 OpenAI 协议的 coding agent,例如 Qwen Code、Codex CLI、Kimi Code。只支持 Anthropic 协议的工具(例如 Claude Code)不能调用平台模型,但仍可以通过 MCP、技能包、SDK 和 CLI 接入平台的其他能力 | + +**规则与边界。** + +① 知识库检索的范围和过滤强度。检索和清单查询的结果不超出服务账号被显式授予的知识库(NFR-1)。过滤必须做到文件级:MCP 检索与平台工作台检索使用同一条过滤路径,即索引层预过滤加结果层批量校验。旧的开放 API 检索路径只在知识库级校验一次,不做文件级过滤,MCP 检索不得复用它。权限评估失败时,MCP 返回错误,不返回未过滤或部分过滤的结果。无法确认调用者身份时,MCP 拒绝调用。 + +② 身份与组织查询的边界。持有 `identity:read` 的密钥可以读取本租户的全部组织架构。平台不支持把部分部门授予服务账号,服务账号本身也不属于任何部门,所以这个范围无法收窄。风险控制靠「给不给这个权限位」,签发表单必须醒目提示(DEV-01)。跨租户的部门和用户一律读不到。如果以后要收窄,只能从工具能力上限制,例如只允许按 user_id 单个查询、不提供组织树列举,这属于独立议题。 + +③ 应用类工具只能操作自己的应用。应用数据、应用状态与日志两类工具,以及 CLI `logs`,只对密钥资源归属人作为 owner 的应用生效,查询他人的应用一律被拒绝。开发者日常不会碰到这个限制,因为首次部署的应用 owner 就是资源归属人。这条规则限制的是密钥泄漏后的影响范围:密钥是保存在开发者电脑上的文件,如果没有这条限制,任何一把密钥泄漏都会让全租户应用的数据和日志被读走。租户管理员要查他人应用的日志,走应用详情页,不走密钥通道。应用状态工具返回审批终态和驳回理由全文。 + +④ 本地开发三面只以服务账号自身身份执行,持 `delegate` 的密钥被拒绝。鉴权 PRD 定义了两种身份模式:自身身份(模式 S)和代表他人(模式 D,需要 `delegate` 权限位)。本地开发三面只支持模式 S。持有 `delegate` 的密钥在通道入口被拒绝,CLI 在 `login` 时就拒绝,提示「这把密钥配置为委托专用,本地开发请另发一把」。 + +做出这个决定的依据是:代表他人执行只在有第三方自然人在场时才有意义,而本地开发的两端是开发者本人和他的 coding agent,没有被代表的人。逐个工具看,模式 D 在这里没有用处,还会造成两类损害。模型清单和组织查询的范围本来就是租户级配置,换成被代表用户后结果不变。知识库清单的用途是填写应用级的能力声明,如果按被代表用户返回,开发者会把某一个人的可见范围写进声明,运行期再按访问用户过滤一次,结果是两次收窄,而且不报错。应用数据、状态和日志工具在模式 D 下会把规则 ③ 的「只能操作一个人的应用」扩大成「委托范围内所有人的应用」。 + +拒绝必须在入口按权限位直接执行,不得实现成「没带委托头就按模式 S 执行」。鉴权 PRD 规定持 `delegate` 的密钥漏传身份头时返回错误、不降级,静默按模式 S 执行正是该文否决的失败方式;而返回一个参数错误码,coding agent 又无法理解原因。 + +托管应用运行期同样不使用模式 D:访问用户的身份由 app-proxy 根据平台登录态注入(RT-01)。因此应用工场全册没有模式 D 场景。 + +**验收要点。** + +1. 六类工具都能经 MCP 调用。密钥不持有对应权限位时调用被拒绝(按 DEV-01 规则 ① 的表逐个权限位验证)。 +2. 经 MCP 检索到的内容不超出服务账号被显式授予的知识库。 +3. 用 A 的密钥查询 B 的应用(数据、状态、日志)被拒绝;查询 A 自己部署的应用正常。 +4. 支持 OpenAI 协议的工具能调用平台模型并获得流式返回,调用按密钥记录。只支持 Anthropic 协议的工具连接被拒绝,并得到明确提示。 +5. 不持 `identity:read` 的密钥调用身份与组织查询被拒绝。持有时能读取本租户组织架构,读不到其他租户的部门和用户。 +6. 经 MCP 查询他人应用的日志一律被拒绝,即使密钥属于租户管理员名下的服务账号。 +7. 应用状态工具能查到审批驳回状态和驳回理由全文。 +8. 经 MCP 检索的结果与服务账号被授予范围的预期集合相等,不多也不少。测试样本须包含未授予的知识库,以及同一知识库内部分文件无权限的情况。 +9. 使权限引擎不可用后,MCP 检索返回明确错误,不返回未过滤或部分过滤的结果(NFR-1)。 +10. 持 `delegate` 的密钥在 MCP、模型协议面和 CLI `login` 三处都被拒绝,提示指向「委托专用,本地开发另发一把」。验证时须确认它没有按服务账号身份正常执行,也没有只返回一个参数错误码。 + +--- + +**DEV-03 开发技能包(两包)** + +> MCP 负责调用,技能包负责告诉 coding agent 怎么用。平台能力的使用说明以开放技能格式随平台版本发布,coding agent 读取后才知道如何写应用包声明、如何调用 SDK。 + +**需求描述。** 技能包采用开放技能格式,每个包包含 SKILL.md、可运行样例和连通自检脚本,开发者用 `bisheng skills sync` 从自己的平台拉取。首发两个包: + +| 技能包 | 使用时机 | 内容 | +|---|---|---| +| 部署纳管 | 准备把应用部署到平台时 | `bisheng-app.yaml` 写法;能力声明(包括密钥引用);安全红线(不写鉴权、不写连接串、不写密钥);托管运行契约(DEV-04);部署流程与预检排障 | +| 平台能力接线 | 写代码调用平台能力时 | SDK 三个模块(DEV-07):auth、retrieve、storage。另外两种不经 SDK 的接法:模型用官方 OpenAI 客户端加平台注入的 base_url 和密钥;应用数据库用标准库连接平台注入的连接参数,并遵守结构演进规则(RT-05) | + +「平台能力接线」包中的 auth 一节必须醒目标注。原因是读取身份是唯一一种写错后应用照常运行、但身份是错的场景,平台不会报任何错误。 + +技能包分为两个,判断依据是「coding agent 不预先知道会不会失败,以及能否从平台报错中自己学会」。部署纳管的知识必须预先知道,因为预检只报告缺什么,不教怎么写。SDK 和两种标准库接法是同一件事,所以合为一包。两个包对应两种互相独立的使用方式:只部署不集成,以及只集成暂不部署。合成一个包会让每种使用方式都读到无关内容。技能包和平台内引擎读同一份内容,包越少,需要保持一致的内容越少。 + +**界面与交互。** 本条没有平台界面。技能包被拉到本地用户目录,由 coding agent 直接读取。 + +**规则与边界。** 技能包从开发者自己的平台拉取,版本与平台一致,不依赖公网。重复执行 `skills sync` 时,平台当前版本覆盖本地内容。`bisheng login` 成功后自动执行一次 sync,开发者不需要单独执行。技能包的触发描述不包含产品名,使用「把本地项目部署到企业的应用平台」这样的通用表述,因为企业部署 BiSheng 时常用自有品牌名。目标平台由 login 凭据决定,与名称无关。 + +**验收要点。** + +1. 执行 `bisheng skills sync` 后,本地有两个包,每个包含可运行样例和自检脚本。自检脚本在配置正确的环境下通过,在缺少配置时给出可读的失败原因。 +2. 两个包的内容与平台内引擎使用的平台能力说明是同一份(抽查关键规则一致)。 +3. 非 Claude Code 的 coding agent 可以通过 AGENTS.md 引用同一个技能目录。 +4. 平台升级后再次 sync,本地技能包更新为平台当前版本的内容。 +5. 用不含产品名的自然语言(例如「把这个应用部署到公司的应用平台」)向 coding agent 提出部署要求,能触发「部署纳管」包。 +6. 未读技能包时,coding agent 倾向于自建登录页;读过「平台能力接线」包后,coding agent 改为读取平台注入的身份,不写登录页(NFR-1)。 + +--- + +**DEV-04 CLI 命令集** + +> 五个命令覆盖接入、本地运行、发布和排障。`deploy` 是 CLI 唯一的发布入口,它与平台内发布走同一条管线。 + +**需求描述。** `bisheng` CLI 是开发者与平台交互的命令行工具。CLI 安装包由平台自己分发,下载链接在 DEV-01 的接入信息区,纯内网环境也能安装。 + +**界面与交互。** 命令行就是本条的交互面。 + +| 环节 | 命令 | 行为 | +|---|---|---| +| 接入 | `login` | 用服务账号密钥登录平台,凭据保存在本地用户目录;登录成功后自动执行一次 `skills sync` | +| 接入 | `skills sync` | 从已登录的平台拉取技能包(DEV-03),用于平台升级后的更新 | +| 本地运行 | `dev` | 在本地运行应用,注入身份并提供本地应用数据库(DEV-05) | +| 发布 | `deploy` | 把当前项目作为一个版本提交发布,管线见下文 | +| 排障 | `logs` | 查看自己应用的运行日志;查询他人应用被拒绝 | + +运行日志有三个入口,权限规则和内容范围完全相同,区别只在操作者: + +| 入口 | 适用场景 | +|---|---| +| MCP「应用状态与日志」工具(DEV-02) | coding agent 使用,是「部署、读日志、修改、再部署」循环的默认路径 | +| CLI `bisheng logs` | 开发者本人在终端使用,或者未配置 MCP 时使用 | +| 应用详情页 · 运行日志 | 需要图形界面时使用;租户管理员查看他人应用只能走这个入口 | + +**发布管线。** `deploy` 依次执行四步: + +① 托管预检。平台构建依赖、校验应用包声明并启动探活,判断标准是下文的托管运行契约。预检失败时,`deploy` 直接失败并说明缺失项和改造方法,不生成审批单。如果预检发现改列或删列,开发者必须显式确认(交互确认,或加 `--confirm-schema-change` 参数),否则 `deploy` 被拒绝。迁移前自动留快照(RT-05)。 + +② 发布前密钥扫描。规则集与平台内发布相同,产物中有明文密钥或连接串时部署被拦截。CLI 输出命中的规则和文件位置,但不输出密钥值本身(NFR-2)。开发者在本地修复后重新部署。 + +③ 审批。首发和迭代的每次部署都生成审批单,审批人由 GOV-02 的预置流程确定。同一应用同时最多有一张在途审批单,在途期间再次部署被拒绝并提示。审批人和 owner 都可以用待上线版本快照拉起临时预览实例(RT-03)。审批被驳回后,owner 在发布 tab 或通过 MCP 应用状态工具查看驳回理由,修改后重新部署,生成新的审批单。 + +④ 上线托管。审批通过后,应用在平台托管环境中运行并进入广场(RT-01、RT-02)。 + +**托管运行契约。** 预检按下表判断应用能否托管。这张表也随「部署纳管」技能包发布,开发者在本地开发时就能对照。 + +| 平台提供 | 应用不得自带或依赖 | +|---|---| +| 语言运行时 | 数据库、消息队列、缓存等中间件 | +| 应用数据库 | 白名单以外的外网地址 | +| 附件与文件存储 | — | +| 能力总线的各项能力,包括应用密钥的注入 | — | +| 依赖包经平台内网镜像安装 | — | + +符合契约的应用保证可以托管。不符合契约的依赖在预检时被拒绝,并给出改造方法,例如把自带数据库改为使用平台应用数据库(做法见「平台能力接线」技能包)。平台不承诺自动改造。 + +**规则与边界。** 首次部署时,密钥对应服务账号的资源归属人成为应用的唯一 owner。资源归属人是服务账号的必填字段,所以不存在无 owner 的部署。之后只有资源归属人相同的密钥才能再次部署这个应用,换一把归属人不同的密钥部署会被拒绝,提示中说明应用的归属。owner 转移见 §5.2。 + +发布的输入以 `bisheng-app.yaml` 为准。能力声明和资源档位都写在这个文件里,未声明档位时取默认档(GOV-03)。应用名称、描述和图标也取自这个文件,随部署更新(RT-02)。 + +可见范围不在应用包里,`deploy` 不处理它。首次上线后,owner 必须到应用详情页设置可见范围,否则只有 owner 能看到应用(GOV-01)。撤回、下线和删除等其他界面操作按需进行(§2.2)。 + +首次部署的准入条件是密钥上的 `app:manage` 权限位。CLI 不检查资源归属人有没有「创建应用」权限,因为那个权限点只控制界面上的新建入口,两个入口各自独立(GOV-07)。CLI 只以服务账号自身身份执行,持 `delegate` 的密钥在 `login` 时就被拒绝,所以部署的 owner 始终等于资源归属人,不会因任何请求头而改变。 + +**验收要点。** + +1. 五个命令按上表行为可用。密钥被吊销后,CLI 命令报鉴权失败并提示重新 login。 +2. 每次 `bisheng deploy`(首发和迭代)都进入审批流(RT-03、RT-04)。发布前扫描与平台内发布使用同一规则集,包括常见凭据正则、数据库连接串和平台密钥前缀(GOV-08),规则集内的样本 100% 被拦截。 +3. CLI 部署的应用在广场、权限和审计中的表现与平台内应用一致。`bisheng-app.yaml` 声明的资源档位生效,未声明时为默认档。 +4. 依赖平台不提供的中间件(例如自带数据库)的项目在预检时被拒绝,报错说明缺失项和改造方法,且不生成审批单。 +5. 同一应用有在途审批单时,再次部署被拒绝并有明确提示。 +6. 通过预检和审批的应用在托管环境中正常启动运行。 +7. 端到端:开发者在 coding agent 对话中用自然语言(不含产品名)提出部署要求,coding agent 凭技能包、CLI 和 MCP 完成声明补全、部署、预检失败修复和审批状态跟踪。这一段全程不离开本地对话。 +8. 承接上条:首次上线后,owner 在应用详情页设置可见范围,被授权的同事能从应用广场打开应用。验证「上线」和「可用」是两个步骤,缺少第二步时同事看不到应用。 +9. 用 A 的密钥部署 B 的已有应用被拒绝,提示说明应用的归属。 +10. 包含改列或删列的迭代部署,未经显式确认时被预检拒绝;确认后进入管线,并在迁移前自动留快照。 +11. `bisheng login` 成功后,本地已有技能包,不需要手动执行 `skills sync`。平台升级后重新执行 `skills sync` 能更新到当前版本。 +12. 同一应用的运行日志,经 MCP 工具、CLI `logs` 和应用详情页三个入口获取,内容范围和权限判定一致。 + +--- + +**DEV-05 本地运行与平台身份注入** + +> 应用只读取平台注入的身份,不写登录页。`bisheng dev` 在本地注入与线上结构相同的身份,所以同一份代码在本地和线上都能运行。 + +**需求描述。** `bisheng dev` 在本地运行应用,并以与线上相同的方式注入身份,应用不需要为本地和线上写两套代码。 + +| 项 | 线上托管运行 | 本地 `bisheng dev` | +|---|---|---| +| 身份来源 | app-proxy 验证平台会话后注入当前访问用户 | CLI 内置的本地代理注入密钥对应的服务账号 | +| 应用读取方式 | 读注入的身份头,或调用 `SDK.auth` | 完全相同 | +| 平台能力按谁的权限放行 | 当前访问用户 | 服务账号被显式授予的范围(NFR-1) | + +身份的唯一可信来源是平台注入,应用不得自建登录页或鉴权逻辑(NFR-1)。 + +**界面与交互。** 本条没有平台界面。`bisheng dev` 的终端输出必须写明当前注入的身份来源和账号,避免开发者误以为自己在以其他人的身份运行。 + +**规则与边界。** + +① 不提供本地身份模拟参数。CLI 不提供 `--as ` 一类的参数。这类参数让开发者随意填一个 user_id 就能让 `SDK.auth` 返回那个人,等于一个绕过平台准入的后门。鉴权 PRD 中「裸 user_id 参数」的安全缺口正是这类问题。平台支持代表他人执行,但那条路是开放 API 的模式 D,它需要 `delegate` 权限位和委托范围,经过五道准入检查并留下双归属审计,而且不作用于本地开发三面(DEV-02 规则 ④)。`--as` 和模式 D 的区别在于这次操作是否经过平台授权。 + +② 本地无法验证不同用户的行为差异。本地注入的是服务账号,不是自然人,本地也没有真实的访问者,所以「张三看到什么、李四看到什么」这类差异在本地无法复现。验证方法是发布后用真实账号访问应用。 + +③ 权限边界与线上规则一致。本地运行时,应用经 SDK 或 MCP 调用平台能力,按服务账号被显式授予的权限放行,过滤强度和 fail-closed 行为与线上相同。`bisheng dev` 在本地启动一个 sqlite 数据库,并注入与线上同名的连接环境变量,使应用用标准库写的数据访问代码在本地和线上一致(DEV-07)。本地能检索到的知识库不超出服务账号被授予的范围,可能比线上真实用户看到的少。这个差异只会导致「本地看到的少」,不会导致「本地能运行、线上越权」。 + +**验收要点。** + +1. `bisheng dev` 运行时,应用读到的身份是密钥对应的服务账号,注入结构与线上一致,应用代码不需要修改。 +2. CLI 没有任何指定其他用户身份的参数。伪造注入头对应用无效,身份的唯一来源是平台注入(NFR-1)。 +3. 本地运行时,应用调用知识库检索等平台能力,结果按服务账号被授予的权限过滤。权限引擎不可用时返回明确错误,不返回未过滤的结果(NFR-1)。 +4. 应用代码中没有登录页和自建鉴权逻辑。 +5. 同一份代码不加修改执行 `bisheng deploy` 上线后,应用在托管环境读到的是当前访问用户的身份(RT-01)。 + +--- + +**DEV-07 bisheng-sdk** + +> SDK 只封装平台特有、且写错会造成安全问题的三件事:auth、retrieve、storage。模型调用和应用数据库不进 SDK,因为它们已有行业标准库。 + +**需求描述。** SDK 首发 Python 版本,提供三个模块。每个模块都要回答「为什么需要 SDK,而不是直接调用」。 + +| 模块 | 用途 | 为什么需要 SDK | 权限要点 | +|---|---|---|---| +| auth | 读取平台注入的身份 | 读身份是唯一一个写错后应用照常运行、但身份是错的场景,平台不会报错。SDK 用一行调用把正确做法变成默认做法 | 身份唯一来源是平台注入(NFR-1) | +| retrieve | 知识库检索 | 检索必须以当前访问用户的身份进行,而不是以应用自身的身份进行,选错即越权。SDK 把这个选择固定下来 | 托管运行时:结果 = 能力声明中的知识库 ∩ 当前访问用户的可见范围,文件级过滤(GOV-05);本地运行时按服务账号被授予的权限(DEV-05) | +| storage | 附件文件存取 | 平台必须居中:按应用隔离存取范围,不计入租户存储配额,不向应用暴露底层对象存储 | 托管契约禁止应用自带文件存储(DEV-04) | + +下表记录不进 SDK 的两项及其理由,防止实施时按习惯加回来。 + +| 项 | 不进 SDK 的理由 | 应用怎么用 | +|---|---|---| +| 模型调用 | 模型接口只提供 OpenAI 兼容协议,这本身就是行业标准,官方 `openai` 库可以直接使用。自研封装只省去 base_url 和密钥两行赋值,却让开发者学一套非标准 API,失去官方库的流式、重试和类型支持,并让平台承担这些功能的维护 | 用官方 OpenAI 客户端,base_url 和密钥取平台注入的环境变量 | +| 应用数据库 | 支持封装的理由都能被「注入连接 + 标准库」替代:环境变量引用不是字面量,不会触发密钥扫描;建表和改表由平台根据应用包声明执行,应用只读写已有的表;SQLAlchemy 本身就屏蔽了 sqlite 和 postgres 的差异;租户隔离由注入的连接承担;结构演进预检在平台侧执行 | 用 SQLAlchemy 等标准库连接平台注入的连接环境变量;本地由 `bisheng dev` 提供同名变量(DEV-05) | + +**界面与交互。** 本条没有平台界面。开发者接触的是 SDK 包和随包发布的开发者指南。 + +**规则与边界。** 平台内模板生成的应用(PRD-2)和本地应用使用同一套 SDK 调用方式。SDK 随版本发布开发者指南,内容包括安装、三个模块的用法、模型和数据库的标准库接法,以及与技能包和 CLI 的配合。以后要新增 SDK 模块,必须先证明它同时满足「平台特有」和「写错会造成安全问题」两个条件。 + +**验收要点。** + +1. 三个模块可用:auth 读到平台注入的身份,retrieve 的结果按调用者权限过滤,storage 能存取附件。 +2. 不经 SDK 的两条路径可用:用官方 OpenAI 客户端加平台注入的 base_url 和密钥能调用平台模型;用标准数据库库加平台注入的连接环境变量能读写应用数据库。 +3. 平台内模板生成的应用与本地引入 SDK 的应用使用同一调用方式。本项依赖 PRD-2 的模板,随 PRD-2 搁置,本版不验收。 +4. 开发者指南随版本发布,按指南能走通 login、dev、deploy 的完整流程。 + +--- + +### 3.2 应用运行时与发布(RT) + +本域覆盖应用部署之后的全部行为:如何被访问、如何被找到、如何上线、如何留痕、如何退役、出问题时怎么办。CLI 部署的应用和将来在平台内造的应用,从提交发布开始走同一套流程,本域的条目对两者一样适用。 + +| 问题 | 条目 | 本域的答案 | +|---|---|---| +| 如何被访问 | RT-01 | 复用平台登录,应用不写登录代码 | +| 如何被找到 | RT-02 | 与工作流、助手进入同一个广场 | +| 如何上线 | RT-03、RT-04 | 每次发布都审批,审批对象是能力声明和冻结的快照 | +| 如何留痕与演进 | RT-05 | 版本记录只增不改,表结构变更随发布生效 | +| 如何退役 | RT-07 | 资产保存在平台,只有 owner 能显式删除 | +| 出问题时怎么办 | RT-08 | 应用崩溃只影响自身,自动恢复 | + +--- + +**RT-01 统一入口与平台登录复用** + +> 应用不自建登录。用户用平台已有的登录方式登录,已登录用户点开即用,应用不需要写登录代码。 + +**需求描述。** 每个已发布的应用有一个内网入口 `/apps/{应用标识}`。应用复用平台的登录方式,包括账号密码和 SSO 扫码,与平台登录配置一致。应用自动获得当前访问者的身份和组织信息。 + +**界面与交互。** 访问时按访问者状态分支处理: + +| 访问者状态 | 系统行为 | +|---|---| +| 已登录平台,且在可见范围内 | 直接进入应用,注入访问者的身份和组织信息 | +| 未登录(包括手机扫码) | 跳转到平台登录页(包括已配置的 SSO 方式),登录后回到原应用地址 | +| 已登录平台,但不在可见范围内 | 显示「无权限」页 | + +四类兜底页: + +| 兜底页 | 触发场景 | 页面内容 | +|---|---|---| +| 无权限页 | 已登录但不在可见范围内 | 应用名、owner、无权限说明、引导联系 owner 或平台管理员开通、「返回广场」按钮。本版没有在线申请入口 | +| 未部署引导页 | 本环境未部署工场运行时层(GOV-10) | 「本环境未启用应用工场」说明、引导联系平台管理员 | +| 已下线页 | 应用处于已下线状态(RT-07) | 应用名、已下线提示、引导联系 owner 或平台管理员、「返回广场」按钮 | +| 应用不存在页 | 访问已删除应用的旧链接或二维码,或访问从未上线(草稿、待上线)的应用 | 「应用不存在或未上线」说明、「返回广场」按钮 | + +**规则与边界。** 访问者的身份和组织信息由平台在入口注入,客户端传入或伪造的身份一律无效(NFR-1)。应用不自建登录,也不自己解析凭据。能否进入应用由可见范围决定,入口访问和广场展示使用同一份可见范围(GOV-01),变更从用户的下一次请求起生效。应用标识在首次发布时确定,迭代发布、下线和重新上线都不改变入口地址和二维码,已经分发出去的链接长期有效。 + +**验收要点。** + +1. 已登录平台的用户点开应用链接直接进入,不需要再次登录。 +2. 应用能读到当前访问者的身份和组织信息,例如在应用中显示访问者姓名和部门。 +3. 未登录的浏览器扫码访问时,跳转到平台登录页(包括已配置的 SSO 方式),登录后直接进入原应用。 +4. 不在可见范围内的用户访问时看到「无权限」页,页面包含 owner 和开通指引。 +5. 访问已删除应用的旧链接或二维码时看到「应用不存在或未上线」页,不出现报错页。 + +--- + +**RT-02 应用广场** + +> 已发布的托管应用与工作流、助手并列出现在同一个广场,使用同一套标签体系。用户只看到自己可见范围内的应用。 + +**需求描述。** 已发布的应用进入应用广场,与工作流、助手并列展示,接入广场已有的标签分类。用户只看到自己可见范围内的应用,可以从广场直接打开使用。 + +**界面与交互。** 广场卡片与工作流、助手的卡片结构相同,展示图标、应用名、一句话描述和 owner,点击卡片打开应用。 + +**规则与边界。** 托管应用使用广场已有的标签体系,不新建标签体系。本版不提供给托管应用打标签的入口(§5.2),未打标签的应用在广场默认分类中正常显示。应用的名称、描述和图标取自 `bisheng-app.yaml`,随部署更新,而部署本身经过审批(DEV-04)。名称、描述和图标不属于能力声明,修改它们不触发审批,但计审计(GOV-04)。广场按可见范围过滤,无权限用户的广场中不出现该应用。 + +**验收要点。** + +1. 应用发布并审批通过后,出现在有权限用户的应用广场中。 +2. 未打标签的应用在广场默认分类中正常显示,可以被搜索和浏览到。 +3. 无权限用户的广场中不出现该应用。 +4. 用户能从广场直接打开应用。 +5. 广场卡片展示图标、应用名、描述和 owner。owner 修改这些信息后广场随之更新,不生成审批单,修改计审计。 + +--- + +**RT-03 发布审批流** + +> 任何发布都必须审批,平台不提供免审路径。审批人审的是提交时冻结的待上线版本快照,可以只读审读代码,也可以拉起临时实例预览试用。owner 在审批期间也可以拉起临时实例,查看自己提交的版本在平台上的实际效果。 + +**需求描述。** 应用发布接入平台审批中心,审批人在审批中心处理发布审批单(审批场景与审批人见 GOV-02)。首次发布和迭代发布都必须审批,平台不提供任何免审配置。 + +**流程。** + +① owner 提交发布。CLI 路径由 `bisheng deploy` 提交(DEV-04)。 + +② 平台冻结生成待上线版本快照。快照是审批人审读的对象。 + +③ 平台执行发布前密钥扫描,规则集见 DEV-04。产物中有明文密钥或连接串时,发布被拦截,CLI 输出命中规则和文件位置,不输出密钥值本身(NFR-2)。 + +④ 平台生成审批单,进入审批中心。审批人由 GOV-02 预置流程确定,收到站内消息和待办(§3.0.3)。提交时必须携带 owner 的主部门,因为部门管理员这一审批人来源依赖它,缺失时审批单会全部落到租户管理员。 + +⑤ 审批通过后,平台做上线终检,检查运行环境容量(GOV-03)。终检通过时,应用上线并进入广场。终检未通过时,应用进入「待上线(资源不足)」状态(§3.0.2),资源释放后由 owner 手动上线,不需要重新审批。 + +⑥ 审批驳回时,首次发布的应用回到草稿状态;迭代发布的应用保持已上线,当前版本继续运行,只有待上线版本被标记为驳回。owner 在发布 tab 的审批状态区看到驳回状态和理由全文,也可以通过 MCP 应用状态工具查询(DEV-02)。owner 修改后重新提交,生成新的审批单。 + +**界面与交互:owner 侧。** 位置是应用详情页的「发布」tab(导航见 §3.0.1)。CLI 部署的应用与其他应用使用同一页面。 + +| 区块 | 内容 | +|---|---| +| 提交发布 | 「提交发布」按钮和资源档位选择(GOV-03)。CLI 路径由 `bisheng deploy` 提交,不必来此页 | +| 审批状态区 | 当前审批单状态(待审、通过、驳回、已撤回)、驳回理由全文、「撤回」按钮(待审时可用)、待上线版本的预览(待审时可用,状态与操作同下文「预览试用」) | +| 能力声明清单 | 应用声明使用的全部平台能力(定义见 RT-04) | +| 应用密钥 | 本应用使用的第三方密钥:引用名、掩码、状态(已录入或缺少值)、最近修改时间;owner 可以录入、修改和删除(GOV-05) | +| 版本列表 | 全部版本记录和终态,只读(RT-05) | +| 可见范围 | 当前可见范围状态和「设置可见范围」按钮,按钮打开平台通用授权弹窗(GOV-01)。首次上线后必须设置 | +| 运营操作区 | 下线、重新上线(RT-07);待上线状态时显示「手动上线」(GOV-03) | +| 入口链接与二维码 | 用于分发给同事(RT-01) | +| 危险操作区(底部) | 显式删除,仅 owner 可见(RT-07) | + +**界面与交互:审批人侧。** 位置是平台审批中心 → 应用发布场景 → 审批单详情。 + +| 区块 | 内容 | +|---|---| +| 基本信息 | 应用名、owner、来源(CLI 部署或平台内造)、类型(首发或迭代)、提交时间 | +| 能力声明摘要 | 待上线版本的能力声明摘要(RT-04) | +| 可见范围 | 提交时的快照,标注「仅供参考,可见范围变更立即生效,不经审批」(GOV-01) | +| 资源档位 | 所选档位(GOV-03) | +| 操作区 | 通过、驳回(必须填写理由)、「查看待上线版本」入口 | + +审读视图左侧是文件树,右侧是只读代码。审读视图的 tab 包括能力声明完整清单、版本历史,以及迭代时相对上一版本的文件差异。「预览试用」按钮位于顶部。 + +**预览试用。** CLI 部署的应用没有草稿沙箱,所以审批人和 owner 用待上线版本快照拉起审批期临时预览实例。这个实例复用部署预检的启动探活能力,连接应用的草稿数据库。owner 需要预览,是因为本地运行(DEV-05)覆盖不到托管环境的全部差异,例如构建产物、只读文件系统和出站白名单。 + +| 项 | 约定 | +|---|---| +| 拉起 | 审批人在审读视图、owner 在发布 tab 的审批状态区按需拉起,平台不预先启动。只有审批单处于待审时可以拉起 | +| 进入 | 每个实例只有拉起它的人能进入。owner 进不了审批人拉起的实例,审批人也进不了 owner 拉起的实例 | +| 回收 | 审批单进入终态(通过、驳回、撤回、取消)时回收;长期未处理时超时回收,默认 7 天,可在部署配置中修改;回收后可以再次拉起 | +| 资源 | 不占用应用运行实例的名额 | + +临时预览实例有四个界面状态: + +| 状态 | 界面 | 去向 | +|---|---|---| +| 未拉起 | 「拉起预览」按钮 | 拉起 → 拉起中 | +| 拉起中 | 进度;失败时显示原因 | 成功 → 运行中;失败 → 未拉起,可重试 | +| 运行中 | 「打开预览」和「手动回收」 | 审批终态、超时或手动回收 → 已回收 | +| 已回收 | 「重新拉起」按钮 | 拉起 → 拉起中 | + +**规则与边界。** 审批人审读的对象是提交时冻结的待上线版本快照。审批期间 owner 可以继续在本地改代码,改动不影响已提交的快照,下一次 `deploy` 才生成新版本。同一应用同时最多一张在途审批单,再次提交前须先撤回。 + +审批人预览时,应用注入的身份是审批人本人,平台能力按 owner 的权限放行,这是一个审批例外,计审计(NFR-1)。owner 预览时,应用注入的身份是 owner 本人,平台能力按 owner 本人的权限放行,不属于审批例外,拉起和回收同样计审计。owner 只能预览已提交审批的版本;提交之前要验证效果,用本地运行(DEV-05)。审批人和 owner 都可以在草稿数据库中写入试用数据。CLI 部署的应用首次上线时,生产数据库从空库初始化,审批期的试用数据不带入生产。 + +本版不做审批单的催办和超时提醒(§5.2)。紧急情况下的处理方式是:下线立即止血(RT-07,不需要审批),再部署旧版本走正常审批恢复(RT-05)。 + +**验收要点。** + +1. 首次发布的应用在审批通过前,除 owner 和审批人经审读视图和预览试用外,任何其他用户都不能访问。 +2. 审批人在审批单中能看到能力声明摘要、可见范围和资源档位,并能打开待上线版本的只读审读视图(代码、能力声明完整清单、版本历史;迭代时包括相对上一版本的文件差异)。 +3. 审批通过后进入上线终检。容量充足时,应用上线并出现在可见范围内用户的广场中(RT-02)。容量不足时,应用进入「待上线(资源不足)」状态,不对外服务,owner 和租户管理员收到通知(§3.0.3);资源释放后 owner 手动上线成功,不需要重新审批(GOV-03)。 +4. 驳回后,owner 在发布 tab 看到驳回状态和理由,修改后能再次提交。 +5. 提交的产物包含明文密钥或连接串时,`deploy` 失败并返回命中项和修改指引(与 DEV-04 验收 2 一致)。 +6. 审批期间 owner 能撤回审批单,撤回后修改再提交,生成新的审批单。 +7. 审批人能在审读视图中用待上线版本快照拉起临时预览实例;审批单进入终态后实例被回收。 +8. 审批人预览时,应用注入的身份是审批人本人,平台能力按 owner 权限放行,预览行为计审计。 +9. 临时预览实例超时回收后能再次拉起,超时时长取部署配置。 +10. 迭代审批被驳回时,应用保持已上线,当前版本继续服务,只有待上线版本被丢弃。 +11. 审批单待审期间,owner 能在发布 tab 拉起待上线版本的临时预览实例。应用注入的身份是 owner 本人,平台能力按 owner 本人的权限放行;拉起和回收计审计。owner 不能进入审批人拉起的实例,审批人也不能进入 owner 拉起的实例。 + +--- + +**RT-04 能力声明定义** + +> 本条是「能力声明」的唯一定义,并规定迭代发布同样必须审批。 + +**需求描述。** 能力声明是应用声明使用的平台能力清单。本版包括三项:知识库、模型和密钥引用。密钥引用是应用使用的第三方密钥的名字,密钥值由 owner 在平台上录入(GOV-05)。能力声明写在 `bisheng-app.yaml` 中,发布 tab 和审批单展示的内容都来自它。 + +**界面与交互。** 本条没有独立界面。能力声明在三处展示,用词统一如下:发布 tab 展示「能力声明清单」,审批单展示「能力声明摘要」,审读视图展示「能力声明完整清单」。 + +**规则与边界。** 迭代发布与首次发布同样必须审批,即使能力声明没有变化(RT-03)。可见范围的变更不经审批,立即生效,计审计(GOV-01、GOV-04)。 + +新增或删除密钥引用属于能力声明变更,须经审批。修改已录入密钥的值不属于能力声明变更,不经审批(GOV-05)。 + +本版不做迭代审批单的变更摘要(§5.2),审批人通过审读视图的文件差异比对。将来补做时,摘要应覆盖能力声明、可见范围、表结构和资源档位四类变更。 + +**验收要点。** + +1. 能力声明和可见范围都没有改变的迭代发布同样生成审批单,审批通过前新版本不上线。 +2. 新接入的知识库或模型、新增的密钥引用,都出现在审批单的能力声明摘要中。 + +--- + +**RT-05 版本记录与结构演进** + +> 每次提交发布生成一条版本记录并标注终态,部署历史是线性的、可审计的。本版不做平台侧回滚,CLI 用户的回退方法是在本地检出旧版本后重新部署。 + +**需求描述。** 平台在每次提交发布时生成一条版本记录,并在审批结束后标注终态(已上线、被驳回、已撤回)。版本记录同时用于审计追溯、审批快照和资产持久(RT-07),所以即使不做回滚也必须保留。 + +每条版本记录包含版本号、类型(首发或迭代)、提交时间、终态、代码快照、能力声明、平台注入配置和资源档位。后四项属于同一个快照,将来做回滚时一起回退,避免出现「代码回退了、配置还是新的」。 + +**表结构演进规则:** + +| 变更类型 | 何时确认 | 发布时的行为 | +|---|---|---| +| 加列 | 不需要确认 | 自动迁移 | +| 改列、删列 | `deploy` 时显式确认(DEV-04) | 发布时不再确认;在发布摘要中展示;迁移前自动保存生产数据快照 | + +**界面与交互。** 版本列表位于应用详情页 · 版本,展示全部版本记录的版本号、类型、提交时间、终态和能力声明摘要,并提供相邻版本的文件差异。列表只读,不提供回滚入口。 + +**规则与边界。** 版本记录只增不改,被驳回和已撤回的版本同样留在列表中。本版不做平台侧回滚(§5.2),原因是本册的用户在本地有完整的 git 历史,检出旧版本后重新 `deploy` 就是等价的回退方法,而且同样经过审批。新版本出问题时,第一手段是下线(RT-07),下线立即生效,不需要审批。 + +**验收要点。** + +1. 每次提交发布都生成一条版本记录,包含版本号、类型、提交时间和终态。被驳回和已撤回的版本同样显示在列表中并标注终态。 +2. 版本记录不会被覆盖或删除,同一应用的历史版本可以完整追溯。 +3. 包含改列或删列的迭代部署,在发布摘要中展示结构变更,并在迁移前自动保存生产数据快照。 +4. 版本列表只读,没有回滚入口。 + +--- + +**RT-07 资产持久与显式删除** + +> 应用的运行实例可以随时回收,代码和应用数据始终保存在平台存储中。删除只能由 owner 通过显式删除流程完成,除此之外没有任何删除资产的途径。 + +**需求描述。** 应用的运行实例不保存唯一数据。代码和应用数据保存在平台存储中,每次发布的版本快照(RT-05)提供代码备份。资产只能通过「显式删除应用」流程删除。 + +**界面与交互。** + +| 操作 | 发起人 | 入口 | 确认方式 | 生效表现 | +|---|---|---|---|---| +| 下线 | owner、租户管理员 | owner 在发布 tab 的运营操作区;租户管理员在构建页的卡片上 | 二次确认,说明入口和广场将显示已下线 | 入口和广场显示已下线页(RT-01),资产不受影响 | +| 重新上线 | owner、租户管理员 | 同下线 | 无 | 恢复访问;同样检查运行环境容量(GOV-03) | +| 显式删除 | 仅 owner | 卡片 ⚙️ 菜单的「删除」(与工作流、助手一致),或发布 tab 底部的危险操作区 | 二次确认弹窗,不要求输入应用名,弹窗中说明删除范围;已上线状态下入口置灰,须先下线 | 资产删除,应用进入已删除状态;旧链接和二维码显示「应用不存在或未上线」页 | + +三个操作都计审计。 + +**规则与边界。** 删除的确认强度与工作流相同:保留二次确认,但不要求输入应用名。要求输入应用名的强确认成本高,用户也会用复制粘贴应付,防护效果不随之提高。本册改用前置状态来加强保护:已上线状态下删除入口置灰,提示「请先下线」,owner 必须先下线再删除。草稿、待上线(资源不足)和已下线三种状态可以直接删除,它们的共同点是没有对外服务的运行实例。下线本身有可察觉的后果,同事访问会立刻看到已下线页,所以误操作会被及时发现,并且留有恢复的机会。 + +确认弹窗必须说明删除范围。托管应用的删除会一并删除代码和应用生产数据(例如用户填报的业务记录),影响比删除一条工作流大,文案不能只写「确定删除吗」。 + +租户管理员不能删除应用,管理侧的处置手段是下线。owner 离职后的应用归属见 §5.2。 + +应用下线、空闲回收、实例崩溃和工场运行时层整体不部署时,平台只回收运行实例,不触碰代码存档和版本记录。存档随平台数据一同备份。删除应用时,在途审批单自动取消(§3.0.2),审批人收到站内消息(§3.0.3)。已下线状态下可以提交发布,审批通过后只记为待运行版本,不自动上线;重新上线时同样检查运行环境容量。 + +**验收要点。** + +1. 应用下线或实例回收后重新上线,代码和生产数据完整。 +2. 除显式删除流程外,没有任何操作会删除应用资产。 +3. 已上线状态下删除入口置灰并提示「请先下线」。下线后可以删除,弹出二次确认(不要求输入应用名),文案说明删除范围包括代码和生产数据。草稿和待上线状态可以直接删除。删除动作可在审计中查到。 +4. 下线期间访问入口和广场显示已下线提示,重新上线后恢复。 +5. 租户管理员发起删除被拒绝。owner 删除后,在途审批单自动取消,审批人收到站内消息。 +6. 已下线状态下提交发布,审批通过后应用不自动上线;重新上线后新版本生效。 + +--- + +**RT-08 稳定性承诺** + +> 单个应用崩溃只影响它自己,并自动恢复。版本切换和恢复期间,访问者看到过渡提示,不看到报错页。 + +**需求描述。** 应用与平台之间、应用与应用之间运行隔离。单个应用崩溃不影响平台其他功能和其他应用,并在 5 分钟内自动恢复。 + +**界面与交互。** 两个过渡状态都不显示报错页: + +| 过渡状态 | 触发场景 | 入口表现 | 结束 | +|---|---|---|---| +| 发布中 | 新版本上线的切换期间 | 显示「发布中」提示 | 切换完成后恢复正常访问 | +| 应用恢复中 | 应用崩溃后的自动恢复期间 | 显示「应用恢复中」提示 | 恢复后恢复正常访问 | + +**规则与边界。** 本承诺与运行时形态无关(§1.3 前提 4),所有形态的验收标准和时限相同。5 分钟的恢复时限同时登记为 NFR-6。 + +**验收要点。** + +1. 人为使某个应用崩溃后,平台主要功能和其他应用的访问正常。 +2. 崩溃的应用在无人工干预的情况下 5 分钟内恢复可用(NFR-6)。 +3. 版本切换期间访问应用不出现报错页,切换完成后恢复正常访问。 +4. 崩溃恢复期间访问应用入口看到「应用恢复中」提示,不看到报错页。 + +--- + +### 3.3 治理与管理面(GOV) + +本域回答管理员的两个问题:员工自建的应用能不能看到,能不能管住。八个条目按管理对象分为五组: + +| 管理对象 | 条目 | 本域的答案 | +|---|---|---| +| 谁能造、谁能用 | GOV-07(能造)、GOV-01(能用) | 都沿用平台已有的权限能力,本册不新建 | +| 每次上线怎么审 | GOV-02 | 平台预置审批流程,部署即生效 | +| 给多少资源、能用哪些能力 | GOV-03、GOV-05 | 档位三选一;能力配置一次,应用声明使用 | +| 留什么痕、密钥怎么管 | GOV-04、GOV-08 | 全链路留痕;密钥四条产品承诺 | +| 装不装 | GOV-10 | 工场运行时层可以整层不装 | + +本域的设计取向是默认可用、按需收紧(§1.3 前提 5)。租户管理员不做任何配置,第一次发布也能走通;需要收紧时再逐项配置。本域不建独立的管理后台应用列表,因为租户管理员在构建页天然能看到本租户全部应用,授权、下线和审计分别由 GOV-01、RT-07 和 GOV-04 承担。 + +**角色与操作权限矩阵。** 下表是全文角色权限的唯一出处,细则以「出处」列的条目为准。✓ 表示有权执行,— 表示无权。 + +| 操作 | 平台超管 | 租户管理员 | 审批人 | owner | 出处 | +|---|---|---|---|---|---| +| 签发、吊销服务账号密钥(勾 `app:manage` 即放行 CLI 部署) | — | ✓ | — | — | DEV-01、GOV-07 | +| 下线、重新上线应用 | — | ✓ | — | ✓ | RT-07 | +| 删除应用 | — | — | — | ✓ | RT-07 | +| 撤回在途审批单 | — | — | — | ✓ | RT-03 | +| 处理审批单(审读、通过、驳回) | — | ✓(预置流程中的审批人来源之一) | ✓ | — | RT-03、GOV-02 | +| 审批期预览待上线版本 | — | ✓(仅作为审批人时) | ✓ | ✓ | RT-03 | +| 配置发布审批人 | — | ✓ | — | — | GOV-02 | +| 维护资源档位 | ✓ | — | — | — | GOV-03 | +| 查询审计记录 | ✓(跨租户) | ✓(本租户) | — | — | GOV-04 | +| 配置能力总线(模型、知识库) | — | ✓ | — | — | GOV-05 | +| 配置「创建应用」权限(只控制界面入口) | — | ✓ | — | — | GOV-07 | +| 调整应用可见范围 | — | ✓(本租户任一应用,平台既有行为) | — | ✓ | GOV-01 | + +平台超管可以通过平台已有的租户切换能力代行租户管理员的全部操作,审计中记录的操作人是平台超管本人。切换后,管理后台、构建页和审计查询都以目标租户为上下文。 + +删除应用和撤回审批单两个操作只有 owner 能做,平台超管和租户管理员都不能代做。管理侧的处置手段是下线(RT-07)。owner 离职等归属问题见 §5.2。 + +--- + +**GOV-01 权限资源类型与授权** + +> 应用的可见范围完全复用构建页卡片 ⚙️ 菜单中的「管理权限」弹窗,与工作流、助手使用同一个入口、同一个弹窗、同一套语义,本册不新建授权界面。可见范围变更立即生效,不经审批,计审计。 + +**需求描述。** 托管应用注册为平台权限体系的一种资源类型,可见范围可以按用户、部门和用户组授予。 + +**界面与交互。** 有两个入口,打开的是同一个弹窗: + +| 入口 | 位置 | 使用者 | 作用 | +|---|---|---|---| +| 卡片 ⚙️ →「管理权限」(主入口) | 构建页应用列表,与工作流、助手一致 | owner;租户管理员(本租户任一应用) | 日常调整 | +| 发布 tab 的可见范围区 | 应用详情页 · 发布 | owner | 流程引导:展示当前可见范围;首次上线后仍为「仅 owner 可见」时醒目提示,并提供设置按钮 | + +发布 tab 也放一个入口,是因为首次上线后设置可见范围是必须的一步。如果只有卡片入口,owner 发布完要返回列表找到卡片再点齿轮,操作流程会中断。发布 tab 的入口只是同一个弹窗的第二个触发点,加上一处状态展示,不是第二套授权界面。 + +卡片 ⚙️ 菜单现有「管理权限、创建模板、复制、删除」四项。对托管应用,「管理权限」和「删除」直接复用,「创建模板」和「复制」不显示,因为它们涉及代码和应用数据的复制,语义尚未定义。删除项受 RT-07 的前置状态约束,并且只对 owner 显示。 + +**规则与边界。** 可见范围变更从被授权用户的下一次请求起生效,作用于入口访问和广场展示(RT-01、RT-02),不经审批,计审计(GOV-04)。审批单中展示的可见范围是提交时的快照,标注「仅供参考」,因为它在审批期间可能被修改。可见范围不属于版本快照(RT-05)。应用首次上线时默认只有 owner 可见。 + +租户管理员可以直接查看和调整本租户任一应用的可见范围。这是平台既有权限模型的结果,工作流和助手也是如此,本册保持一致,变更计审计并记录变更人。 + +**验收要点。** + +1. 两个入口(卡片 ⚙️ →「管理权限」和发布 tab 的可见范围区)打开的是同一个弹窗,可以按用户、部门和用户组授予。 +2. 授权后,被授权用户的下一次请求就能在广场中看到并打开应用;撤销授权同样立即生效(RT-01、RT-02)。 +3. 首次上线后未设置可见范围时,发布 tab 醒目提示「仅 owner 可见」并提供设置入口。 +4. 租户管理员能查看和调整本租户任一应用的可见范围,变更计审计并记录变更人(GOV-04)。 +5. 可见范围变更不触发审批,也不进入版本快照(RT-03、RT-05)。 +6. 托管应用卡片的 ⚙️ 菜单只显示「管理权限」和「删除」两项,不显示「创建模板」和「复制」;已上线状态下删除项不可用并提示须先下线(RT-07)。 + +--- + +**GOV-02 发布审批场景(平台预置流程)** + +> 审批开箱即用,不需要先配置。平台预置一套流程,审批人是 owner 所属部门的部门管理员和租户管理员,或签,任何一人处理即出结果。 + +**需求描述。** 应用发布作为审批中心的一个新场景接入。平台预置一套默认审批流程,部署后立即生效,租户管理员不需要先配置。租户管理员可以在此基础上修改审批人和审批链。 + +**预置流程规格:** + +| 项 | 取值 | 说明 | +|---|---|---| +| 场景 | 「应用发布」 | 随部署启用 | +| 分支 | 一条无条件分支 | 所有发布都审批,没有直通分支 | +| 节点 | 一个节点,或签 | 任何一个审批人处理即出结果 | +| 审批人来源 | ① owner 主部门的部门管理员 ② 租户管理员,两者并列;单租户部署下 ② 改为平台超管 | 复用审批中心已有的两种审批人来源 | + +**界面与交互。** 管理后台 → 审批中心 → 场景配置中新增「应用发布」场景,展示预置流程的当前配置,可以修改审批人和审批链(复用审批中心已有的配置交互)。界面不出现「未配置审批人」的提示,因为预置流程保证始终有审批人来源。 + +**规则与边界。** 每次发布(首发和迭代)都必须审批,没有免审路径,也没有免审配置项。 + +审批人是部门管理员和租户管理员的并集,或签,谁先处理谁决定结果。部门管理员负责本部门人员的日常治理,租户管理员是更高一级的权限,也在部门管理员缺位时承接审批,所以不需要额外的兜底规则。部门取 owner 的主部门,不向上级部门查找。owner 没有部门,或主部门没有部门管理员时,这一来源为空,审批由租户管理员承接。两个来源同时为空时,审批单进入审批中心的异常状态,并通知管理员处理,发布不被放行,也不会无提示地卡住。 + +提交人本人在审批人候选中时(例如 owner 自己就是部门管理员),系统跳过提交人,由其他候选人审批。如果全租户只有一名管理员且没有其他候选人,允许自审,审计中加自审标注。 + +单租户部署下,租户管理员来源改为平台超管。单租户下平台超管实际承担租户管理员的职责,这样「部署即生效」在单租户形态下同样成立。如果不改,这一来源始终为空,owner 又没有部门管理员时,第一次发布就会卡住。 + +预置流程覆盖每个租户,包括新建的租户:租户创建时同步写入预置流程。预置按「租户 + 场景」判断是否已存在,已存在时跳过,所以租户管理员改过的配置在平台升级后不会被重置。 + +**验收要点。** + +1. 全新部署后不做任何审批配置,直接发布应用就能生成审批单,审批单落到 owner 主部门的部门管理员和租户管理员。 +2. 部门管理员或租户管理员任一人处理后即出结果,另一方的待办同步关闭。 +3. owner 没有部门或部门没有部门管理员时,审批单落到租户管理员,发布不卡住。 +4. 租户管理员能修改审批人和审批链,修改后平台升级不会重置。 +5. 新创建的租户中首次发布应用,能正常生成审批单。 +6. 单租户部署下不做任何审批配置即可发布,审批单落到平台超管。 +7. 提交人在审批人候选中时,节点跳过提交人本人;只有一名管理员的租户中的自审单在审计中带自审标注。 +8. 两个审批人来源都为空时,审批单进入异常状态并通知管理员,发布不被放行。 + +--- + +**GOV-03 资源档位** + +> 应用的资源规格只能从平台预置的档位中选择,不提供自定义。档位是运行环境给容器设定资源上限的唯一依据。 + +**需求描述。** 平台预置资源档位,应用发布时从中选择,不提供自定义资源规格。 + +本版有三个档位。下表是初始默认值,由部署配置提供,平台超管可以修改。 + +| 档位 | 默认规格 | 适用说明 | +|---|---|---| +| 轻量 | 0.5 核 CPU / 1 GB 内存 | 表单、台账类轻量应用 | +| 标准 | 2 核 CPU / 4 GB 内存 | 多数日常业务应用 | +| 性能 | 4 核 CPU / 8 GB 内存 | 数据处理量大、访问人数多的应用 | + +轻量档原定 1 核 / 2 GB,实现时下调为 0.5 核 / 1 GB。原因是 1 核让 CPU 配额先于机器本身成为上线瓶颈:8 核机器按 0.8 的超售系数只能同时运行 6 个轻量应用,而实测五个在线应用合计只用了 0.05% CPU。0.5 核下带重依赖的应用冷启动约 2.4 秒,健康检查窗口仍有 4 倍余量(F055 spec 决议-10)。 + +**界面与交互。** + +| 界面 | 位置 | 内容 | +|---|---|---| +| 资源档位管理 | 管理后台 → 系统管理 → 「资源档位」tab,仅平台超管可见;未部署工场运行时层时不显示 | 档位列表:名称、CPU、内存、适用说明、启用状态、使用中的应用数。可以在行内编辑规格和说明。停用档位时提示影响:存量应用不受影响,发布时不再可选 | +| 发布时选择档位 | 发布 tab;CLI 路径在 `bisheng-app.yaml` 中声明 | 从启用中的档位选择,每档展示规格和适用说明 | + +**规则与边界。** 只能从预置档位中选择,不提供自定义规格。每个应用只有一个实例,不提供实例数和并发调节(§5.1)。默认档是轻量档:发布 tab 默认选中它,CLI 未声明档位时也取它。停用档位只阻止新的选择,存量应用继续按原档位运行和迭代。档位规格的修改从应用下一次发布或重新上线起生效。 + +档位与运行时形态无关:单机形态由运行时组件设容器资源上限,k8s 形态映射为 Pod 的 requests 和 limits。 + +上线可能因运行环境容量不足而失败(单机形态是机器资源耗尽,k8s 形态是集群无可调度资源)。上线终检发现容量不足时,审批单保持通过,应用进入「待上线(资源不足)」状态(§3.0.2),并通知 owner 和租户管理员(§3.0.3)。资源释放后,owner 在发布 tab 手动上线,不需要重新审批。 + +应用代码存档和附件本版都不计入平台存储配额(§5.2)。模型 token 用量只计量、不设上限。本版不做租户级实例数配额(§5.2)。 + +**验收要点。** + +1. 资源档位由平台预置,平台超管可以修改规格和启用状态。发布时只能从预置档位中选择,没有自定义规格、实例数或并发调节的入口。 +2. 未声明档位的 CLI 应用使用轻量档;发布 tab 默认选中轻量档。 +3. 档位停用后,存量应用照常运行和迭代发布,新发布不能再选该档位。 +4. 所选档位在运行环境中实际生效,容器或 Pod 的资源上限与档位规格一致。 +5. 上线终检容量不足时,应用进入待上线状态并提示原因;资源释放后 owner 手动上线成功,不需要重新审批。 + +--- + +**GOV-04 全链路审计** + +> 应用的创建、能力接入、运行期调用、发布运维和密钥事件全部留痕,管理面可以查询和导出。租户管理员查本租户,平台超管可以跨租户查。 + +**需求描述。** 应用工场的全部关键事件都记录审计,审计记录可以在管理面查询。 + +审计事件清单如下。每类记录至少包含操作人、时间、对象、动作和结果,「特有内容」列只列该类事件额外记录的内容。 + +| 事件类别 | 来源条目 | 特有内容 | +|---|---|---| +| 应用创建(CLI 首次部署) | DEV-04 | 应用名 | +| 能力声明变更 | GOV-05 | 应用、能力声明变更内容 | +| 应用运行期的能力调用(检索、模型) | GOV-05、GOV-08 | 双归属:调用方是哪个应用,以哪个访问用户的名义调用 | +| 应用访问 | RT-01 | 访问用户、应用、访问时间 | +| 生产数据编辑 | 应用详情页 · 数据 | 应用、数据对象、编辑动作 | +| 发布、下线、重新上线、删除 | RT-03、RT-07 | 应用、版本、结果 | +| 模型调用 | DEV-01、GOV-08 | 归属(服务账号密钥调用归服务账号,应用运行期调用归应用)、模型、时间、token 数。逐条记录,本版不做聚合账单(§5.2) | +| 应用密钥的录入、修改、删除 | GOV-05 | 应用、引用名、动作;不记录密钥值 | +| 密钥签发、权限位修改、吊销、自动失效 | DEV-01、GOV-08 | 密钥掩码、动作、失效原因;应用运行期凭据的自动签发和回收同样记录 | +| 可见范围变更 | GOV-01 | 变更人(owner 或租户管理员)、应用、变更内容 | +| 应用名称、描述、图标修改 | RT-02 | 对象、修改前后内容 | + +**界面与交互。** 审计查询在管理后台的审计页进行,分两部分。 + +| 位置 | 承载的事件 | 构成 | +|---|---|---| +| 已有审计 tab(扩展事件类型) | 操作类事件:应用创建、能力声明变更、生产数据编辑、发布运维、密钥事件、可见范围变更、名称描述图标修改 | 筛选:时间范围、操作人、事件类型、对象应用;平台超管另有租户筛选。列表列:时间、操作人、对象、动作、结果,平台超管视角另有租户列。支持导出 | +| 新增「应用运行记录」tab | 运行期高频记录:模型调用、应用运行期的能力调用、应用访问 | 三类记录各自一个子视图。筛选:时间范围、应用、服务账号密钥(模型调用)、访问用户;平台超管另有租户筛选。列表列按类别不同:模型调用列出归属、模型、token 数;能力调用列出应用、能力、以谁的名义调用、结果;访问记录列出访问用户、应用、时间。支持导出。未部署工场运行时层时不显示 | + +运行期记录单独成 tab,原因是它们的数量比操作类事件高几个数量级,混在同一个列表中会淹没操作类事件,筛选维度也不同。 + +**规则与边界。** 审计记录中不出现密钥明文等敏感值(NFR-2)。租户管理员可以查询本租户的全部事件,跨租户查询仅限平台超管。开发者没有自助查询入口,需要时由租户管理员导出后转交。owner 在应用侧自查审计和访问记录本版不提供(§5.2)。 + +**验收要点。** + +1. 上表每类事件发生后,在审计页能查到记录(操作人、时间、对象、动作)。 +2. 审计记录中不出现密钥明文等敏感值。 +3. 模型调用可以按密钥或按应用追溯到具体调用记录,包括模型和 token 数。 +4. 租户管理员查不到其他租户的审计事件;平台超管可以跨租户查询。 + +> 当前实现状态:三类运行期记录已经写入,「应用运行记录」tab 尚未实现,验收 1 和验收 3 在它实现前无法通过。 + +--- + +**GOV-05 能力总线** + +> 模型和知识库在管理面配置一次,租户内的应用通过能力声明使用。应用调用第三方系统用的密钥由 owner 录入,平台在运行时注入。业务人员和 AI 都不接触底层的账号和端点,应用代码里也不出现密钥值。 + +**需求描述。** 能力总线是应用使用平台能力的统一通道。底层账号和端点只在管理面配置一次,应用通过能力声明获得使用权,发布审批通过即授权(RT-03、RT-04)。本版能力总线包括模型、知识库和应用密钥三项。模型和知识库的配置全部复用已有界面,不新建配置页;应用密钥由 owner 在应用详情页录入。 + +**界面与交互。** + +| 能力 | 谁在哪里配置 | 应用如何获得使用权 | +|---|---|---| +| 模型 | 租户管理员在已有的模型管理页配置服务商凭据并启用模型。租户内的助手、工作流和托管应用共享这份配置 | 应用在能力声明中写模型名(与模型管理页的名称相同),审批通过后,平台在运行时注入调用通道 | +| 知识库 | 没有新增配置。建库和授权走已有的知识库管理和权限体系 | 应用在能力声明中写要使用的知识库,审批通过即授权。运行时检索范围见下文 | +| 应用密钥 | owner 在应用详情页 · 发布的「应用密钥」区录入,只用于本应用 | 应用在能力声明中写引用名,审批通过后,平台在应用启动时把值注入为环境变量 | + +**规则与边界。** 能力配置只在管理面进行,应用侧没有任何底层账号或端点的配置入口。 + +已上线应用声明的能力被收回时(模型下线、知识库删除或权限收回),应用调用该能力得到明确错误,不回退到旧值,应用的其余功能保持可用。owner 在发布 tab 看到失效提示。 + +**应用密钥。** 应用调用平台以外的系统(例如企业微信 webhook、内部 ERP 接口)时需要密钥,这类密钥由 owner 在发布 tab 的「应用密钥」区录入。应用在 `bisheng-app.yaml` 的能力声明中只写引用名,平台在应用启动时把值注入为同名环境变量。密钥值加密存储,录入后任何界面只显示引用名和掩码,平台不回显明文,也不把它写入日志(NFR-2)。只有 owner 能录入、修改和删除密钥;租户管理员能看到引用名和状态,看不到值。 + +新增或删除引用名属于能力声明变更,随发布审批生效(RT-04)。修改已录入的值不经审批,只保存配置并计审计(GOV-04),不自动重启。当前实例使用的值与最新保存值不一致时,发布 tab 显示有未生效变更及引用名,不展示值;owner 可在获知可能短暂中断后确认重建当前运行版本,使确认的配置生效,也可稍后处理。 + +能力声明引用了一个还没有录入值的密钥时,`deploy` 输出警告,发布 tab 和审批单都标注「缺少值」,但发布不被阻断。这样设计是因为应用首次部署后才在平台上存在,owner 只能在首次部署之后录入值。正式启动时,缺少值的环境变量为空,应用调用第三方系统会失败。owner 补录、替换或删除值只更新保存配置,不改变当前实例中的值;下一次正式启动或 owner 确认重建时使用所选配置,缺值注入为空。删除仍被在线版本引用的值时,须说明当前实例可能继续使用旧值直到重建,不把删除保存配置说成已撤销第三方凭据。 + +**知识库检索范围。** 应用运行时的检索结果经过两层过滤。第一层是声明层:应用没有声明的知识库对应用完全不可访问,即使当前访问用户本人有权限。第二层是用户层:按当前访问用户的身份逐个资源鉴权,在声明范围内,检索结果与该用户在平台上自行检索的结果完全相同。 + +声明层是对一份显式白名单的收窄,不是对两个主体的可见集合求交集。鉴权 PRD 否决的是「权限取交集」,两者不是一回事。 + +应用运行时的「当前访问用户」由 app-proxy 根据平台登录态注入,这与鉴权 PRD 模式 D 的委托头是两套机制。实现时不得把应用内的用户身份传递挂在模式 D 委托头上:模式 D 禁止以超级管理员或租户管理员为委托目标,复用它会让管理员无法使用任何托管应用。 + +过滤强度是文件级,与平台工作台检索使用同一条过滤路径(与 DEV-02 规则 ① 相同)。检索遵循 fail-closed(NFR-1):无法取得当前访问用户的身份时,检索被拒绝,不回退为按应用声明全量返回;权限评估失败时返回明确错误,不返回未过滤或部分过滤的结果。 + +**验收要点。** + +1. 租户管理员在管理面完成一次能力配置后,租户内的应用不需要再配置就能声明使用。 +2. 应用侧没有任何底层账号或端点的配置入口。 +3. 生产实例实际可用的能力 = 其声明且经审批的能力,未声明的能力不能调用。审批期预览按「已声明的能力 + owner 本人的权限」放行(RT-03)。 +4. 收回某个已上线应用声明的能力后,调用该能力返回明确错误,应用其余功能可用,owner 看到失效提示。 +5. 用户本人有权限、但应用没有声明的知识库,经该应用检索不到。 +6. 应用声明了、但当前访问用户无权限的知识库内容(包括库内无权限的文件),不出现在该用户的检索结果中。声明范围内的检索结果与该用户在平台上自行检索的结果相同。 +7. 使权限引擎不可用后,应用内检索得到明确错误,不返回未过滤或部分过滤的结果(NFR-1)。 +8. owner 在发布 tab 录入密钥后,应用运行时能从同名环境变量读到值;任何界面、日志和审计记录中都不出现密钥明文。 +9. 修改值不生成审批单、不自动重启;页面提示当前运行配置与最新保存配置的差异,owner 确认后才重建当前运行版本并采用确认配置。取消不影响实例,重建失败不丢失已保存值;未引用项或恢复成原值不误报需重建。新增引用名的发布仍生成审批单,摘要显示引用名。 +10. 能力声明引用了未录入值的密钥时,`deploy` 输出警告,审批单标注「缺少值」,发布不被阻断。 + +--- + +**GOV-07 应用工场权限控制** + +> 本册在租户内不新增任何配置项。界面入口沿用已有的「创建应用」权限点,CLI 入口由密钥上的 `app:manage` 权限位控制,两者互不校验。 + +**需求描述。** 谁能创建托管应用,由两个入口各自的准入条件决定。本册不新增一级菜单,也不新增权限点。 + +| 入口 | 准入条件 | 由谁控制 | +|---|---|---| +| 界面(构建 → 应用 → 新建) | 已有的「创建应用」权限点(「构建」菜单的子项) | 租户管理员配置角色 | +| CLI(`bisheng deploy` 首次部署) | 服务账号密钥上的 `app:manage` 权限位(DEV-01) | 租户管理员签发密钥时勾选 | + +**界面与交互。** 托管应用涉及三个使用面,都位于已有页面中,可见性沿用已有的菜单权限。升级前后,角色配置面上的项数不变。 + +| 使用面 | 位置 | 可见性控制 | +|---|---|---| +| 构建面(创建和管理自己的应用) | 构建页应用列表 | 已有的「构建」菜单权限;新建入口另受「创建应用」控制 | +| 治理面(管理全租户的应用) | 同一个页面,租户管理员天然能看到本租户全部应用 | 「构建」菜单权限加租户管理员身份 | +| 使用面(使用同事的应用) | 应用广场和 `/apps/{slug}` | 已有的应用广场可见性加应用可见范围(GOV-01) | + +**规则与边界。** + +① 界面入口。「创建应用」只控制新建入口,不影响已有应用的运行、访问、迭代发布和管理。它是「构建」的子权限,关闭「构建」时它一并失效。有「构建」但没有「创建应用」时,用户可以管理已有应用,但不能新建。这套组合早已对工作流和助手生效,托管应用只是被它管理的第三种类型。默认值沿用已有设置:新建角色默认开启「构建」、不开启「创建应用」,所以升级后没有任何角色凭空获得新建能力。复用这个权限点的代价是无法单独对托管应用灰度开放,本册判断这个代价可以接受(§5.2)。 + +② CLI 入口。界面的权限点和菜单都不参与 CLI 判定。CLI 的请求主体是服务账号,服务账号没有角色。如果改为检查资源归属人的角色权限,就违反了鉴权 PRD「资源归属人只决定归属、不出借权限」的规定,还会导致「资源归属人一被停用,其名下密钥立即失效」的连带效应。CLI 入口并不因此失控:密钥只能由租户管理员签发,开发者没有自助入口;部署出的应用 owner 始终是资源归属人,责任和审计落在具体的人身上。 + +③ 灰度开放。灰度开放是运营动作,两个入口分别处理:界面入口按角色勾选「创建应用」,CLI 入口按人签发带 `app:manage` 的密钥。两边都不另设开关。 + +**验收要点。** + +1. 没有「创建应用」权限的用户在构建页看不到新建入口,其已有应用的运行、访问、迭代发布和管理入口不受影响。 +2. 密钥未勾 `app:manage` 时,CLI 首次部署被拒绝并有明确提示;勾选后可以部署,与资源归属人有没有「创建应用」权限无关。 +3. 资源归属人的角色权限被修改或账号被停用后,其服务账号名下已有密钥的部署行为不变。 +4. 本册在角色权限体系中没有新增:升级前后角色配置面的菜单项和权限点数量相同;三个使用面分别位于已有的构建页和应用广场中。 + +--- + +**GOV-08 开放 API 鉴权改造与密钥承诺** + +> 本条管两件用户能感知的事:v2 开放 API 从「内网可信」改为密钥鉴权,以及密钥的四条产品承诺。细化需求和验收以鉴权 PRD 为准。 + +**需求描述。** 已有的 v2 开放 API 从「内网可信」模型改为密钥鉴权,签发和吊销在管理面进行(表单和流程见 DEV-01)。v2 开放 API 改造的细化需求和验收以鉴权 PRD 为准。 + +应用运行期使用的凭据不作为产品概念,也不设管理入口,owner 和使用者感知不到它。它的行为分别由 GOV-05(能力生效与收回)、RT-07(下线)、GOV-04(审计)和本条验收 5 约束。 + +**密钥底座与四条产品承诺。** 全平台各类密钥使用同一个底座,实现细节以鉴权 PRD 为准。 + +| 承诺 | 内容 | +|---|---| +| 不暴露明文 | 明文只在签发时展示一次,此后一切界面只显示掩码(前缀加末四位)。服务端只存 SHA-256 单向哈希,明文不可还原 | +| 可吊销且立即生效 | 吊销是软删除,记录保留供审计。吊销时主动清除缓存,生效时间上限 5 秒。支持一键吊销某个服务账号名下的全部密钥 | +| 可被扫描拦截 | 服务账号密钥的前缀是 `bs-sak-`,该前缀内置在发布前密钥扫描规则中(RT-03、DEV-04),也可以注册到代码托管平台的密钥扫描规则 | +| 全部事件留痕 | 签发、吊销和自动失效都计审计(GOV-04) | + +**流程:v2 开放 API 鉴权改造。** + +① 发布即带鉴权,不设兼容开关,也不设兼容窗口。任何「默认关闭」的过渡开关都意味着升级后匿名越权通道仍然开着,鉴权 PRD 已否决这种做法。升级后所有 `/api/v2` 调用必须携带密钥,没有密钥的调用一律返回 401。裸 `user_id` 参数随本版一并移除。 + +② 迁移动作必须在升级前完成。发布说明在醒目位置给出三步指引:为每个存量调用方创建服务账号并按最小权限授权;签发服务账号密钥;修改调用方代码,增加 `Authorization` 头(原来的裸 `user_id` 参数改为 `X-Bisheng-On-Behalf-Of` 头)。签发人是租户管理员,迁移责任人是各调用方的 owner。 + +③ 平台不做任何自动迁移,不改动任何存量用户和资源。原 `default_operator` 配置项随本版移除,它指向的用户保持普通用户身份,名下资源不变。从升级到管理员配好密钥之间存在接入空窗,须在升级沟通中说明。 + +**界面与交互。** 管理后台的 API 密钥管理页是平台密钥和服务账号管理的唯一入口,也就是鉴权 PRD 服务账号模块的承载页。服务账号模块包括列表和详情页(概览、API 密钥、资源授权),构成和验收以鉴权 PRD 为准。本册在其上只增加两项:签发表单中的「本地开发工具包」三个权限位,以及详情页的接入信息区(DEV-01)。应用运行期凭据没有管理界面。 + +**规则与边界。** 应用运行期能力的生效和收回,在产品上一律以「能力」表述,不以「凭据」表述。发布审批通过后,新的能力声明生效,旧声明立即失效(GOV-05、RT-04);应用下线时冻结,删除时回收(RT-07)。底层凭据的重签和吊销随之自动发生,不单独暴露操作入口。 + +应急处置统一为下线(RT-07)。怀疑凭据泄漏或应用滥用平台能力时,一律下线该应用。本版不提供「应用仍可访问、但能力调用失败」的中间状态,因为这种状态对使用者来说就是应用坏了。 + +服务账号密钥是否同时能用于本地开发三面和开放 API,由同一张权限位清单上勾了哪些权限位决定,与凭据类型无关。 + +**验收要点。** + +1. 服务账号密钥能在管理面签发和吊销;明文只在签发时展示一次,此后所有界面只显示掩码。 +2. 吊销后 5 秒内调用被拒绝;一键吊销某个服务账号时,其名下全部密钥同时失效。 +3. 密钥的签发、吊销和自动失效事件都计审计(GOV-04)。 +4. 存量环境升级后,不带密钥的 v2 调用一律返回 401,没有任何回退路径(细化验收以鉴权 PRD 为准)。 +5. owner 的自然人账号被禁用后,其应用照常运行,可见范围内的用户正常使用。服务账号密钥同样不随自然人账号禁用而失效,须由租户管理员处理其服务账号。 +6. 迭代发布上线后,应用运行期能使用的能力就是新能力声明所列的能力,旧声明中已删除的能力立即不可用(GOV-05、RT-04)。 +7. 管理面没有应用运行期凭据的查看或吊销入口,应用的应急处置只有下线。 +8. 存量 v2 调用方迁移时签发的是服务账号密钥(绑定不可登录的服务账号)。 + +--- + +**GOV-10 可选部署与运行时形态** + +> 不需要应用工场的客户可以整层不装,平台其余功能的行为不变。装的客户按自己的基础设施选择单机形态或 k8s 集群形态,两种形态的产品行为完全相同。 + +**需求描述。** 应用工场按层可选部署。不装的环境没有新增的常驻负担,已有功能的行为不变。安装工场运行时层时,部署人员在部署时选择一种运行时形态:单机形态(compose 或 systemd,默认)或 k8s 集群形态。 + +**界面与交互。** 本条没有配置界面。部署形态在部署时决定,产品上唯一的可见差异是未部署时相关入口和页面不显示。 + +| 层 | 用户可感知的能力 | 能否独立部署 | +|---|---|---| +| 平台核心 | 平台已有的全部功能 | 必装 | +| 开放能力层 | 服务账号密钥的本地开发用途、OpenAI 兼容模型接口、MCP 工具面 | 可以独立于工场运行时层部署 | +| 工场运行时层 | 应用托管运行、应用广场中的托管应用、发布管线 | 可以整层不装;装则选单机或 k8s 形态 | + +**规则与边界。** 未部署工场运行时层时,构建页不出现「托管应用」类型,应用广场不出现托管应用,访问应用路径显示未部署引导页(RT-01)而不是报错,平台其余功能的行为不变。 + +未部署开放能力层时,服务账号和密钥模块本身仍然存在,因为它随鉴权 PRD 交付,是 v2 开放 API 的唯一接入方式。不显示的只有应用工场的增量:签发表单的「本地开发工具包」三个权限位、详情页的接入信息区,以及 OpenAI 兼容模型接口和 MCP 工具面。 + +单机形态和 k8s 形态的产品面必须一致(§1.3 前提 4):发布管线、资源档位、审批、权限、日志和崩溃自愈在两种形态下行为相同,凡本册验收项在两种形态下表现必须相同。形态差异只出现在运行时组件内部,例如资源档位在单机形态下设为容器资源上限,在 k8s 形态下映射为 Pod 的 requests 和 limits(GOV-03)。运行时形态在部署时一次性选定,本版不支持形态之间的迁移。 + +> 当前实现状态:单机形态已实现;k8s 形态只有需求规格(F059 spec),设计与实现尚未开始。 + +**验收要点。** + +1. 未部署工场运行时层的环境中,平台已有功能的回归测试全部通过,行为没有变化。 +2. 未部署工场运行时层时,访问应用路径显示未部署引导页,不出现报错。 +3. 只部署开放能力层时,服务账号密钥的本地开发用途、OpenAI 兼容模型接口和 MCP 工具面可以正常使用。 +4. 未部署开放能力层的环境中,服务账号模块照常可用;签发表单不出现「本地开发工具包」三个权限位,详情页不出现接入信息区;OpenAI 兼容模型接口和 MCP 工具面不可访问。 +5. 本册全部验收项在单机和 k8s 两种形态下表现一致。 + +--- + +## 4. 非功能需求 + +本节只列产品可感知的跨域不变量,具体行为落在所引用的条目中。 + +**NFR-1 权限与数据边界。** + +1. 应用(包括其中的 AI)能访问的知识库范围不超出当前调用主体自身的权限。线上的调用主体是当前访问用户,包括文件级权限(RT-01);本地运行和 MCP 的调用主体是服务账号,范围是被显式授予的资源(DEV-02、DEV-05)。托管运行时,范围进一步收窄为「能力声明中的知识库 ∩ 当前访问用户的可见范围」(GOV-05)。 +2. 未上线的应用只有 owner 能访问,owner 在审批期间经预览试用访问(RT-03)。唯一的例外是审批期间审批人经审读视图和预览试用访问,其中平台能力按 owner 权限放行,属于审批例外,计审计(RT-03)。此外没有任何其他途径。 +3. 应用获得的身份信息唯一可信来源是平台注入,客户端伪造的身份一律无效(RT-01、DEV-05)。 +4. 权限评估遵循 fail-closed:无法确认调用者或访问用户的身份时拒绝,不回退为放行;权限评估失败时报错,不返回未过滤或部分过滤的结果(GOV-05、DEV-02)。 + +**NFR-2 密钥不暴露。** 密钥值不写入日志、不回显、不进入代码,一切界面只显示引用名和掩码。owner 录入的应用密钥加密存储,录入后不回显明文。平台签发的密钥在服务端只存单向哈希,明文不可还原,只在签发时展示一次(GOV-08)。发布产物不得包含明文密钥或连接串,发布前扫描拦截,扫描规则内置平台密钥前缀(RT-03、DEV-04)。 + +**NFR-3 资产持久。** 运行实例可以随时回收,代码和应用数据始终保存在平台存储中,删除只能通过显式删除流程(RT-07)。 + +**NFR-4 可选部署。** 不部署工场运行时层的环境,平台已有功能没有新增的常驻负担,行为不变(GOV-10)。 + +**NFR-5 审计可追溯。** 应用创建、能力接入、发布、删除、生产数据编辑和密钥事件全部留痕(GOV-04)。 + +**NFR-6 稳定性。** 应用与平台、应用与应用之间运行隔离,单个应用崩溃只影响自身,并在 5 分钟内自动恢复(RT-08)。 + +--- + +## 5. 范围外与顺延 + +### 5.1 本版明确不做 + +| 项 | 说明 | +|---|---| +| 本地身份模拟参数(`bisheng dev --as`) | 它是绕过平台准入的后门(DEV-05)。验证不同用户行为差异的方法是发布后用真实账号访问 | +| 公网域名、公开可见、SEO、水印 | 应用只有内网入口,由权限体系控制访问,没有「公开」开关 | +| 自定义资源规格、实例数与并发调节、自动扩缩容、GPU、应用间编排、自定义运行环境构建 | 每个应用一个实例,从预置档位中选择规格(GOV-03) | +| token 用量限制 | 私有化环境使用自有模型和算力,本版只计量不限制 | +| 运行时形态之间的迁移、同一环境同时运行两种形态 | 运行时形态在部署时一次性选定;多机调度只在 k8s 形态内由 k8s 承担 | +| 本地端专有插件 | 四种接入方式全部使用标准协议,任何 coding agent 不需要改造即可接入 | +| Anthropic 兼容模型接口 | 模型接口只提供 OpenAI 兼容协议,平台不实现协议的长尾语义(DEV-02) | + +### 5.2 顺延到 v3.1 + +本版的目标是先走通最小闭环:拿到密钥、本地开发、部署、审批、上线、设置可见范围、同事使用。不在这条链路上的功能一律顺延。下表记录每一项顺延的理由和将来补做时的注意事项,避免重新论证。 + +| 顺延项 | 顺延理由 | 补做时的注意事项 | +|---|---|---| +| 租户级实例数配额(原属 GOV-03) | 实例数配额防不住它要防的问题:保护机器的是运行环境的准入控制;实例数只数个数不看档位,三档资源差 8 倍;单租户部署完全用不上 | 必须以资源量(CPU、内存)为单位,不能以实例数为单位 | +| 平台侧回滚(原属 RT-05) | 本册用户在本地有完整 git 历史,检出旧版本后重新部署就是等价方法,并且同样经过审批 | 版本快照的四要素(代码、能力声明、注入配置、资源档位)本版已按同一快照存储,补做时一起回退即可。应急止血始终是下线 | +| 迭代审批单的变更摘要(原属 RT-04) | 首次发布闭环用不到;审批人可以用审读视图的文件差异比对 | 覆盖四类变更:能力声明、可见范围、表结构、资源档位 | +| 应用标签设置(原属 RT-02) | 未打标签的应用在广场默认分类中正常显示,不影响闭环 | 复用广场已有标签体系 | +| 按密钥、按应用的聚合用量账单(原属 GOV-04) | 平台已有的用量表没有应用和密钥维度,出账单需要新建能力;本版用逐条调用审计代替 | 给用量表增加 `app_id` 和 `api_key_id` 列,并贯通调用链上下文,不要从遥测数据反查 | +| 应用代码存档计入存储配额(原属 GOV-03) | 平台存储配额的统计范围只覆盖知识库文件,加入代码存档需要新建统计;代码存档体量远小于知识库文件 | 统计改为多来源,注意各表的租户列名不同 | +| 改码权归属与交接(原 DEV-06) | 交接的对象是 PRD-2 的应用管家,PRD-2 搁置后无处交接。本版 CLI 部署的应用只能在本地改代码,平台只读 | 随 PRD-2 一起做;届时审批预览增加「复用 owner 草稿沙箱」的方式 | + +下列功能本版明确取消,以后也不做。 + +| 取消项 | 取消理由 | +|---|---| +| 管理后台应用列表(原 GOV-09) | 它要做的四件事都不是新需求:租户管理员在构建页天然能看到本租户全部应用;授权由 GOV-01 承担;下线由 RT-07 承担;审计由 GOV-04 承担 | +| 草稿对租户管理员不可见(原属 GOV-09) | 这是对已有行为的收窄。租户管理员本来就能看到他人未发布的工作流和助手,只对托管应用加限制会制造类型间的不一致 | +| 应用运行期凭据作为产品概念(原 GOV-08 中的应用 token、会话 key) | owner 和使用者都感知不到它,属于技术设计范畴 | +| 应用运行期凭据的强制吊销入口(原属 GOV-08) | 「应用可访问、但能力调用失败」对使用者就是「应用坏了」。应急处置统一为下线 | +| 为托管应用单独设立「创建应用」权限点 | 复用已有权限点的代价是无法只开放工作流、不开放托管应用。为这一种组合新增权限点,收益抵不上角色配置面上永久多出的一项。实际的灰度手段是部署开关(按环境)和按人签发密钥(按人) | + +其他顺延项: + +| 顺延项 | 说明 | +|---|---| +| 精细成本控制 | token 按人记账与成本展示、会话预算、预算触顶后的处置流程 | +| 存量代码执行点迁入沙箱 | 工作流 CodeNode 和助手本地执行器 | +| 应用退役与归属转移 | owner 离职后的接管;长期无人维护的应用自动归档下线 | +| 资源归属人离职联动 | 归属人账号禁用或删除时,提示或自动回收其服务账号;本版由租户管理员手动回收 | +| 敏感字段分类分级 | 受控导出与脱敏(等保 2.0、数据安全法) | +| 能力总线调用已有工作流和助手 | 把工作流和助手作为应用可声明的能力 | +| 第三方 MCP 网关;多机调度 | — | +| 审批单超时提醒、催办与升级 | 本版的应急方法是下线(RT-07) | +| owner 自查应用的审计和访问记录 | 本版审计只能在管理面查询(GOV-04) | +| 服务账号密钥调用频率限流 | 本版只计量、不限流 | +| iframe 嵌入安全升级(原 RT-09) | postMessage 协议规范化和嵌入域名白名单(社区 #1705)。这是平台已有能力的独立安全修复,与应用工场无功能依赖,可以随任意版本交付 | + +--- + +## 6. 开放问题 + +本版的开放问题已于 2026-10-09 全部拍板,结论如下。 + +| 问题 | 结论 | +|---|---| +| k8s 运行时形态是否本版交付 | 本版交付,不顺延(GOV-10)。F059 须补设计与实现 | +| 应用自有密钥是否本版交付 | 本版交付最小版:owner 在发布 tab 掩码录入,应用声明引用名,平台注入环境变量(RT-04、GOV-05) | +| GOV-04 三类运行期记录的查询入口 | 审计页新增「应用运行记录」tab(GOV-04) | +| 轻量档规格 | 采用实现值 0.5 核 / 1 GB(GOV-03) | +| 资源档位的数量 | 维持三档。以后需要调整时,由平台超管在「资源档位」tab 直接修改规格 | +| 能力域命名 | 定名为「应用工场」,应用类型名为「托管应用」 | +| 「应用」作为统称是否降级 | 本版不改,构建页筛选器仍写「全部应用类型」。将来可以把筛选标签改为「全部类型」、第三类直接叫「应用」,记为后续优化 | +| SDK 静默失败点评测的达标线 | 已准备的 6 个样本全部通过(6/6);未达标时修改技能包后重跑,不改判据(DEV-03 验收 6) | + +--- + +## 附录 A 修订记录 + +需求正文不带日期,「本版做什么」以 §3 为准,「为什么不做」以 §5 为准。v2.1 之前的逐版决策记录见 git 历史中本文件的 v2.1 版本。 + +| 版本 | 日期 | 主要变更 | +|---|---|---| +| v1.6–v1.9 | 2026-08-06 至 08-17 | 取消个人 API key,统一为服务账号密钥;与鉴权 PRD v2.0 对齐;运行时增加 k8s 形态;审批改为平台预置流程;模型接口只做 OpenAI 兼容;技能包收敛为两包;SDK 收敛为三个模块;界面承载面改到构建页;MVP 收敛顺延五项;本地开发三面只支持模式 S | +| v2.0–v2.1 | 2026-08-17 至 08-19 | 全文按金字塔结构重构,需求实质不变;兜底页文案改用「平台管理员」 | +| v3.0 补充 | 2026-10-23 | 用户明确修订 GOV-05:保存环境变量不自动重启;页面显示当前实例与最新配置的差异,owner 确认后才重建。对应 F055 规格修订已确认,简化设计待确认,尚未实现。 | +| v3.0 | 2026-10-09 | 开放问题全部拍板(§6):k8s 形态本版交付(GOV-10);应用自有密钥本版交付最小版(RT-04、GOV-05,GOV-05 新增验收 8–10);GOV-04 新增「应用运行记录」tab;轻量档 0.5 核 / 1 GB;维持三档;「应用工场」定名。交接版。① 按 2026-09-18 验收结果同步实现:轻量档改为 0.5 核 / 1 GB(GOV-03);GOV-04 标注查询入口缺口。② PRD-2 搁置后,应用详情页四个 tab 的定义收进本文 §3.0.1,删除对 PRD-2 对话外壳、管家回流和草稿沙箱的依赖。③ 补充 §1.2「为什么做」,内容取自原《产品方案》。④ 实现锚点表移入《技术架构方案》,正文不再出现代码符号。⑤ 补充个人访问令牌不能用于本地开发的说明(DEV-01)。⑥ 按受控写作规则重写全文:一句一事、写明主体、统一术语(「开发者 key」统一为「服务账号密钥」),删除修辞表述。条目编号和验收要点序号全部保留 | +| v3.1 | 2026-10-11 | 允许 owner 在审批期间预览待上线版本(RT-03 新增验收 11,改写验收 1;§3.3 角色矩阵拆出「审批期预览待上线版本」一行;NFR-1.2 同步)。owner 预览注入 owner 本人身份、按本人权限放行,不属于审批例外 | + +## 附录 B 交付状态(2026-10-09 快照) + +以下状态来自 `features/v3.0.0/README.md`,最新状态以该文件为准。 + +| 项 | 状态 | +|---|---| +| 代码 | 21 项功能代码于 2026-09-16 全部合入 `3.0-vibe`。2026-09-18 验收:17 项完整交付,3 项需求与实现不一致,1 项实现缺口。按 2026-10-09 拍板,待开发的有三项:GOV-10 的 k8s 形态(F059)、GOV-04 的「应用运行记录」tab、GOV-05 的应用密钥最小版 | +| 对应 Feature | DEV-01、GOV-08 → 鉴权 PRD 底座(beta2 F053);DEV-02 → F051、F052;DEV-03、DEV-04、DEV-05 → F053;DEV-07 → F057;RT-01、RT-08、GOV-01、GOV-10 → F054;RT-03、RT-04、RT-05、RT-07、GOV-02、GOV-03、GOV-05 → F055;RT-02、GOV-04、GOV-07 → F056 | +| 测试环境 | 114 运行 `3.0-vibe`,主流程(CLI 部署、审批、上线、授权访问、查看日志、下线、删除)已走通。驳回、撤回、删除导致审批取消、审批期预览、托管应用检索五条路径尚未在 114 上实际运行 | +| 未完成验收 | F051 T025、T027、T028;F052 T104、T302(需 CI 中间件);F053 T033;F054 T074(compose 形态真机启动)、T096(E2E);F055 T049;F056 T021;F057 T032、T040、T042 | +| 已知安全缺口 | 出站白名单开启后,systemd 形态下托管应用容器仍能经网关地址访问宿主机上监听 0.0.0.0 的服务,114 上实测可达 MySQL 3306、backend 7860、MinIO 9000、SSH 22。需要补宿主机 INPUT 规则,合入发版线前修复。另一项合入前修复:`app_runtime.obo_secret` 缺失或与平台登录密钥相同时,托管应用入口改为拒绝进入。详见《技术架构方案》§7 | +| 合入发版线 | 未合入。合入时须在 116 和 105 上停服执行 FGA 授权模型发布脚本 | diff --git "a/docs/PRD/3.0-release/3.0 \345\272\224\347\224\250\345\267\245\345\234\272 PRD-2 \351\200\240\345\272\224\347\224\250\345\267\245\344\275\234\345\217\260.md" "b/docs/PRD/3.0-release/3.0 \345\272\224\347\224\250\345\267\245\345\234\272 PRD-2 \351\200\240\345\272\224\347\224\250\345\267\245\344\275\234\345\217\260.md" new file mode 100644 index 0000000000..4830df7df6 --- /dev/null +++ "b/docs/PRD/3.0-release/3.0 \345\272\224\347\224\250\345\267\245\345\234\272 PRD-2 \351\200\240\345\272\224\347\224\250\345\267\245\344\275\234\345\217\260.md" @@ -0,0 +1,812 @@ +# BiSheng 3.0 应用工场 PRD-2:造应用工作台 + +> 版本:v1.6 | 日期:2026-10-09 | **状态:无限期搁置** | 读者:开发·测试·设计 | 技术方案:《3.0 应用工场 技术架构方案》(同目录)第 7 节 +> +> **搁置说明(2026-10-09)**:本册排期无限期搁置,研发资源集中在 PRD-1。本册保留为需求存档,正文停在 v1.5(2026-08-16)的内容,**不再随 PRD-1 同步**,重新启动前须整体复核。已知与 PRD-1 v3.0 的差异有四处,以 PRD-1 为准:① 应用详情页四个 tab(发布 WB-14、数据 WB-06、运行日志 WB-13、版本 WB-15)的本版形态已收进 PRD-1 §3.0.1;② 本册多处引用的 PRD-1 GOV-09 已取消,「我的应用列表」就是构建页按归属过滤的视图;③ 应用自有密钥的最小版已收进 PRD-1(GOV-05:owner 在发布 tab 掩码录入),本册 WB-05 密钥子页重启时在其上扩展;④ 原上游文档《3.0 应用工场 产品方案》已删除,其产品部分并入 PRD-1 §1,技术部分并入《技术架构方案》;原《预置模板首发清单调研》的结论收为本册附录 A。 +> +> 分册说明:应用工场 PRD 按交付优先级拆为两册,条目编号两册全局唯一、互引按域前缀定位:本册承载造应用工作台(WB)、应用管家(GJ)及工作台专属的 RT-06、GOV-06;应用运行时与发布(RT)、治理与管理面(GOV)、专业开发者通道(DEV)见先行交付的《3.0 应用工场 PRD-1 专业开发者通道与应用运行时》,本册直接按条目编号引用。WB-13 运行日志、WB-14 发布面(审批状态与撤回、可见范围设置、运营动作、改码权归属)、WB-06 数据面、WB-15 版本与差异查看四条的最小版本随 PRD-1 同批先行交付;本册交付其完整版,差量以本册条目为准(如 WB-14 的完整承载清单)。全文角色词以 PRD-1 §2.1 角色定义表与 §3.3 角色×动作矩阵为单一出处,不泛称「管理员」。 +> +> **2026-08-16 界面承载面变更(v1.5)**:应用工场**不新增一级菜单**,本册的「我的应用列表」与「造应用工作台」全部落在 **platform 端「构建 → 应用」**——列表即既有构建页卡片流(应用类型筛选新增第三项「托管应用」,与工作流 / 助手并列),工作台即点开卡片后的**应用详情页**在本册补齐管家对话与三个 tab 后的完整形态。原「平台主导航『应用工场』入口页」的提法**已废止**;承载面定义以 PRD-1 §2.2「应用的承载面」为单一出处,本册只引用不重复定义。 +> +> **跨册交付期说明(v1.5 补)**:PRD-1 在 2026-08-16 MVP 收敛中顺延的两项**随本册一并交付**,故本册各条目对它们的引用照常有效——① **DEV-06 改码权归属与交接**(管家未上线时「交接给管家」无处可去,见 §3.1(d));② **RT-05 平台侧回滚**(PRD-1 主用户本地有完整 git、可 checkout 后重新 deploy,而工作台内造的应用**没有本地 git**,回滚的增量价值正在本册,PRD-1 §5.2 已注明「届时随 PRD-2 一并实现」)。另有一项**不随本册回归、本册不得再引用**:**租户管理员代 owner 调整可见范围**——已收敛为「误授权的应急手段是下线」(PRD-1 GOV-01 / GOV-09 / RT-07)。 + +--- + +## 1. 综述 + +**一句话定位:让不会编程的业务人员在 BiSheng 平台内对话式从 0 到 1 造应用——在 platform 端「构建 → 应用」里(与工作流 / 助手同处,PRD-1 §2.2)「对话 + 实时预览 + 一键发布」,接入企业的知识/模型/身份,发布进应用广场、同事即点即用;常驻应用管家负责造与改,发布后迭代 = 继续对话。应用运行时、发布审批管线与治理面是两册共享的地基,见《PRD-1 专业开发者通道与应用运行时》。** + +三大能力(对应功能需求域的归属): + +| 能力 | 一句话 | 承载域 | +|------|--------|--------| +| 造 | 浏览器内「对话 + 实时预览 + 一键发布」的造应用工作台,从空白 / 模板(推荐)/ 导入本地项目三路径起步 | §3.1 工作台(WB) | +| 活 | 管家会话随应用终身存续:发布后迭代 = 继续对话,审批/权限/配额/审计全程自动生效 | §3.2 管家(GJ) | +| 接与跑 | 应用托管运行、发布审批与治理的地基 | PRD-1(本册 §3.3 承载工作台专属的 RT-06 / GOV-06) | + +本册交付边界一览: + +| 维度 | 本版做 | 本版明确不做(详见 §5) | +|------|--------|------------------------| +| 造 | 从空白 / 模板(首发 6 类,推荐)/ 导入本地项目三路径新建;选项式澄清;修错回流;代码版本管理(每轮自动版本/回滚/差异查看) | 点选局改;代码在线编辑 | +| 数据与资源 | 「资源」tab(数据/知识库/模型/密钥四子页,数据为默认子页):数据网格查改/导入 Excel·CSV/附件字段/导出回溯;知识库可浏览可接入、模型只读展示;密钥 masked(掩码不回显)管理 | SQL 查询面;对象存储独立管理面;数据自助恢复 | +| 代码 | 只读玻璃盒 + 只读运行日志子页 + 导出工程 | 交互式终端;云端通用 IDE | +| 发布 | 发布 tab 承载能力声明完整白话清单 / 审批状态与撤回 / 版本与回滚 / 资源档位选择 / 入口链接 + 二维码 / 运营动作(下线/重新上线)/ 显式删除 / 改码权归属 / 元信息修改(审批管线与治理规则见 PRD-1) | 公网域名/公开可见性(PRD-1 §5.1) | +| 管家 | 造/改会话终身存续 | 上线应用持续看护(v3.1 重新立项,见 §5.2) | + +--- + +## 2. 用户与旅程 + +### 2.1 用户 + +| 用户 | 要什么 | 主要触达面 | +|------|--------|-----------| +| 业务人员(主用户,非程序员) | 把脑子里的表单/看板/小工具变成同事能用的应用,不学编程、不等排期 | 造应用工作台(§3.1) | +| 普通业务用户 | 在应用广场找到并直接使用同事造的工具 | 统一入口与应用广场(PRD-1 §3.2) | +| 租户管理员 / 平台超管 | 造应用引擎与凭据可控可管、业务人员零感知 | 管理面(GOV-06) | + +注:业务人员创建应用后即成为该应用 owner,对应 PRD-1 §2.1「开发者(owner)」;全文角色词以 PRD-1 §2.1 角色定义表为单一出处。 + +**本册角色×动作矩阵(域级约定)** + +本矩阵与 PRD-1 §3.3 角色×动作矩阵合读:两册共享动作(下线/删除/回滚/撤回审批单/可见范围调整等)以 PRD-1 §3.3 为准,本表仅收口本册条目新增的动作;审批人的审读与草稿沙箱预览动作见 PRD-1 RT-03,本表不重复。各动作细则以「出处」列条目为准。 + +| 动作 | 平台超管 | 租户管理员 | 审批人 | 开发者(owner) | 出处 | +|------|---------|-----------|--------|----------------|------| +| 新建应用 | — | — | — | ✓(受既有「创建应用」`create_app` 权限控制,GOV-07) | WB-01 / GOV-07 | +| 提交发布 / 更新上线 / 自测数据去留决策 | — | — | — | ✓(仅 owner) | WB-14 / GJ-02 / RT-06 | +| 密钥录入 / 覆盖 / 吊销 | — | —(应急处置 = 下线,RT-07) | — | ✓(仅 owner) | WB-05 | +| 生产数据行编辑 / 删除 | — | —(不提供生产数据入口;应急处置 = 下线,RT-07) | — | ✓(仅 owner) | WB-06 | +| 数据导入 | — | — | — | ✓ | WB-07 | +| 结构变更确认 | — | — | — | ✓ | WB-09 | +| 知识库接入 / 解除接入 | — | — | — | ✓ | WB-10 | +| 草稿日志查看 | — | — | — | ✓(仅 owner) | WB-13 | +| 生产日志查看 | — | ✓(经构建 → 应用页「全租户」视角,GOV-09) | — | ✓ | WB-13 | +| 代码版本回滚 | — | — | — | ✓(仅 owner;免审批,区别于发布回滚 RT-05) | WB-15 | +| 凭据档案创建 / 分配 / 吊销 | ✓(平台默认档案) | ✓(本租户档案) | — | — | GOV-06 | + +### 2.2 主旅程:业务人员 造 → 发布 → 迭代 + +1. **新建**:在 platform 端「构建 → 应用」点新建、选「托管应用」类型,从空白或模板起步(模板推荐;导入本地项目走 PRD-1 §2.2 主旅程),随即进入该应用的工作台并一句话描述需求(WB-01)。 +2. **澄清**:管家以选项式提问一轮问完,用户点选即可(WB-02)。 +3. **生成**:管家列出任务清单并着手执行(管家内核行为方式,见 §3.2);需要知识库时,管家以候选选项卡供点选接入(WB-10);右侧预览逐步成形(WB-04)。 +4. **备数与自测**:对话里传 Excel 或在数据面导入存量台账(WB-07);在预览中完整试用自测(WB-04)。 +5. **发布**:管家选项式询问预览自测数据去留(默认清空,RT-06);选择资源档位(默认推荐,WB-14);首次发布提交审批(RT-03)。 +6. **使用**:审批通过后,owner 在发布面设置可见范围(GOV-01,首发默认仅 owner),同事方可经应用广场 / 入口链接 / 扫二维码免二次登录使用,各自只看到权限内数据(RT-01 / RT-02)。 +7. **迭代**:回到同一个对话说「导出加个 Excel 格式」,管家在草稿里改好、预览确认、点更新上线提交迭代审批,通过后生效(GJ-02 / RT-04)。 + +--- + +## 3. 功能需求 + +### 3.1 造应用工作台(WB) + +**本域交付:业务人员造应用的唯一主界面——左栏与管家的持久对话 + 任务清单,右栏预览/资源/代码/发布四个核心 tab(「资源」tab 含数据/知识库/模型/密钥四个子页,默认停在数据);草稿全程仅 owner 可见。** + +以下(a)~(d)为域级约定,供本域各条目统一引用,不新增条目编号(手法同 PRD-1 §3.2 应用状态机域级约定): + +**(a)工作台框架与布局** + +> **「工作台」不是一个新页面**:它是 PRD-1 §2.2 已交付的**应用详情页**(platform 构建 → 应用 → 点卡片进入)在本册补齐后的完整形态——**左侧接入管家持久对话**,右侧 tab 由 PRD-1 阶段的四项(发布 / 数据 / 运行日志 / 版本)重组增补为下表的**预览 / 资源 / 代码 / 发布**(数据并入资源 tab 子页、运行日志并入代码 tab、版本移至顶栏「版本 vN」入口)。**入口与既有内容不变**,用户感知为同一个页面长出了对话区与新 tab。 + +| 区域 | 构成 | 承载条目 | +|------|------|---------| +| 顶栏 | 返回「构建 → 应用」列表入口(定义见 c)+ 应用名(可编辑;元信息语义同 RT-02——修改不触发审批、计审计,与发布面元信息区(WB-14)为同一动作的两个入口)+ 应用态徽标(枚举引用 PRD-1 §3.2 应用状态机;存在在途审批单时以附加标识呈现,审批单态同引该处四值枚举)+ 代码版本入口「版本 vN」(打开代码版本面板,WB-15) | RT-02 / WB-15 | +| 左栏(管家持久对话) | 上 = 任务清单区(可折叠,条目带待办/进行中/完成态;任务清单为对话体验呈现、不作为独立需求条目约束,§3.2);中 = 对话流(结构化卡片类型见 b);底 = 输入框(文本 + 附件上传按钮,支持 Excel/CSV/图片截图,承接 WB-07 导入路径① 与 WB-03 截图反馈) | §3.2 / WB-02 / WB-03 / WB-07 | +| 右栏 | 四核心 tab:预览(默认,WB-04)/ 资源(数据默认/知识库/模型/密钥四子页,WB-05~11)/ 代码(WB-12/13)/ 发布(WB-14) | WB-04 ~ WB-14 | + +各条目「界面与交互」小节只写本条增量,不重复本表布局。 + +**(b)对话流结构化卡片类型清单** + +| 卡片类型 | 出现时机 | 可点动作 | 回链 | +|---------|---------|---------|------| +| 澄清卡 | 首轮需求存在待澄清要点时 | 逐题点选候选(含「其他(自由输入)」兜底);整卡「都按推荐,直接开始」 | WB-02 | +| 修错回流与修复进度卡 | 报错/截图/构建失败回流后 | 查看业务语言原因说明与修复进度(进度同步落任务清单区) | WB-03 | +| 知识库候选卡 / 接线记录卡 | 管家判断需要知识库时 / 任一路径接入或解除接入后 | 候选卡:多选候选库或「先不接」;接线记录卡:留痕、无操作 | WB-10 | +| 结构变更确认卡 | 改/删列变更需确认时 | 确认执行;执行后卡片含变更内容与快照留存标识 | WB-09 | +| 回滚事件卡 | 代码版本回滚后 | 留痕;回滚跨结构变更存档点时含结构不一致提示与处置选项 | WB-15 | +| 自测数据去留选项卡 | 提交发布/更新上线且草稿库含自测数据时 | 点选「清空[默认]/ 保留」 | RT-06 | +| 导入失败行报告卡 | 对话路径导入 Excel/CSV 存在失败行时 | 查看结果摘要与失败行明细、下载失败行文件 | WB-07 | +| 保留冲突报告卡 | 迭代发布选「保留」且存在冲突行时 | 查看成功并入/冲突跳过行数与冲突行明细 | RT-06 | +| 「更新上线」操作卡 / 审批事件卡 | 预览确认后 / 审批单提交与出终态时 | 操作卡:点「更新上线」提交(与发布面主操作区按钮为同一动作的等价入口);事件卡:留痕,驳回卡含理由全文 | GJ-02 | +| 引擎降级 / 错误系统提示卡 | 造应用引擎凭据异常时 | 仅提示、无操作(文案不暴露引擎与凭据细节) | GOV-06 | + +**(c)「我的应用列表」定义**(承接 WB-01 验收 3、WB-16 验收 2;**它不是一个独立列表**,而是 PRD-1 §2.2 定义的构建 → 应用页按归属过滤出的 owner 视角,与租户管理员的「全租户」视角同页切换(PRD-1 GOV-09)——本表只写 owner 视角的构成,页面本体定义在 PRD-1) + +| 要素 | 定义 | +|------|------| +| 语义位置 | platform 端「构建 → 应用」页的「我的」视角(既有卡片流页,PRD-1 §2.2);无既有「创建应用」(`create_app`)权限时隐藏新建入口、保留既有应用入口(PRD-1 GOV-07——该权限点是平台既有的构建菜单子项,非本次新增) | +| 内容范围 | 本人为 owner 的全部应用(含草稿与已下线),他人不可见 | +| 与应用广场的关系 | 应用广场(RT-02)仅呈现已发布且在访问者可见范围内的应用;本列表是 owner 视角的全量造应用入口,两者互不替代 | +| 卡片字段 | 应用名、图标、一句话描述、应用态徽标(枚举引用 PRD-1 §3.2)+ 在途审批标识、最近编辑时间、来源标识(平台内造 / CLI 导入) | +| 卡片动作 | 点卡片进入应用详情页 = 工作台(恒有,见 a);打开应用(仅已上线态展示);上下线开关与版本下拉沿用既有卡片能力(PRD-1 §2.2) | +| 空态 | 引导文案 + 新建入口 + 模板推荐(WB-01;新建入口受既有 `create_app` 权限控制) | +| 排序与搜索 | 默认按最近编辑时间倒序;支持按应用名搜索 | + +**(d)导入应用的工作台呈现(按改码权归属,承接 DEV-06)** + +> **交付期说明**:DEV-06 改码权归属与交接**在 PRD-1 本版不做**(2026-08-16 MVP 收敛——管家未上线时「交接给管家」无处可去,整条是空的),**随本册一并交付**。因此本(d)与 DEV-06 同批生效;在本册上线前,导入应用的改码权恒在本地(PRD-1 §5.2)。 + +CLI 导入的应用进入工作台后,各界面行为按改码权归属(DEV-06)呈现差异;交接与收回在发布面改码权归属区完成(WB-14): + +| 界面 | 改码权在本地 | 改码权在管家 | +|------|-------------|-------------| +| 左栏对话 | 可答疑、不改码;用户提出改码需求时,管家说明改码权在本地并指引「在发布面交接给管家」(DEV-06) | 与平台内造应用一致 | +| 预览 tab | 无草稿沙箱:呈引导态「该应用改码权在本地,暂无草稿预览」+「打开线上应用」链接(仅已上线态展示) | 正常草稿预览 | +| 资源 tab:数据 / 模型 / 密钥子页 | 照常可用(DEV-06) | 照常可用 | +| 资源 tab:知识库子页「接入本应用」 | 禁用 + 提示「该应用能力声明由应用包(bisheng-app.yaml)管理,请在本地声明后 deploy,或先交接改码权」 | 照常可用 | +| 代码 tab / 运行日志 | 只读照常(展示最新已发布版本,DEV-06) | 照常可用 | +| 发布面 | 照常(改码权归属区呈「交接给管家」,WB-14) | 照常(改码权归属区呈「收回到本地」) | +| 代码版本(WB-15) | 交接前版本列表 = 每次 deploy 快照(即发布版本,RT-05);代码版本时间线自交接后首轮对话起生成 | 每轮自动版本 | + +#### 入口与新建 + +**WB-01 新建三路径:空白 / 模板 / 导入本地项目** +- 需求描述:新建应用提供空白、模板(推荐起步)、导入本地项目三条路径;模板首发 6 类,每个模板 = 「一张表 + 一条流 + 一个看板」的微应用。 +- 三路径: + +| 路径 | 说明 | 起步方式 | +|------|------|---------| +| ① 从空白 | 一句话描述需求直接生成 | 新建界面顶部输入框提交,即创建应用并进入工作台首轮对话(后续澄清见 WB-02) | +| ② 从模板(推荐起步) | 首发 6 类模板(清单见下),含预接线的数据表与页面 | 模板卡片区点选,确认后按模板初始化并进入工作台 | +| ③ 导入本地项目 | 已在本地(vscode / cursor 等)开发的存量项目导入平台纳管发布;新建入口仅提供该路径的指引,导入本身经开发者通道的统一发布管线完成(bisheng deploy,PRD-1 §3.1) | 「导入本地项目」入口卡打开指引 | + +- 模板清单(每个 = 「一张表 + 一条流 + 一个看板」的微应用,含预接线的数据表与页面): + 1. 信息收集上报 + 2. 业务台账 + 3. 检查整改闭环 + 4. 数据看板+AI 解读 + 5. 知识问答页 + 6. 轻审批·督办跟踪 +- 界面与交互: + +| 界面/入口 | 位置 | 构成 | +|-----------|------|------| +| 新建界面 | 构建 → 应用页的新建入口,类型选「托管应用」(§3.1 域引言 c;入口受既有 `create_app` 权限控制,GOV-07) | 顶部一句话需求输入框(空白路径)+ 模板卡片区(6 张卡)+「导入本地项目」入口卡 | +| 模板卡片 | 新建界面模板卡片区 | 模板名、适用场景一句话、构成标注(一张表+一条流+一个看板)、缩略示意;点选确认后按模板初始化并进入工作台 | +| 导入指引 | 「导入本地项目」入口卡打开的指引页/弹窗 | 三步指引:CLI 安装 → bisheng login(开发者 API key——服务账号密钥,由租户管理员签发,DEV-01)→ bisheng deploy,附开发者文档链接;页面本身不承载导入动作 | + +- 规则与边界: + - 空白路径允许从空白直接生成,不强制先选模板。 + - 模板初始化失败时回流左栏对话,由管家说明原因并可重试(回流形态承接 WB-03)。 + - 经开发者通道导入的应用出现在 owner 的「我的应用列表」(§3.1 域引言 c),其工作台呈现见 §3.1 域引言 d。 +- 验收要点: + 1. 从空白路径可用:一句话描述需求即可直接生成应用(后续澄清见 WB-02)。 + 2. 新建流程中 6 类模板全部可选,选中后基于模板生成初始应用(含预接线的数据表与页面)。 + 3. 新建入口存在「导入本地项目」路径指引;经开发者通道导入的应用出现在 owner 的「我的应用列表」(§3.1 域引言 c;导入与发布管线见 PRD-1 §3.1)。 + 4. 空白路径一句话提交后进入工作台并开始首轮对话。 + 5. 模板卡片区 6 张卡均可见适用场景一句话与构成标注;模板初始化失败时回流对话并可重试。 + +#### 左栏对话 + +**WB-02 一句话起步与选项式澄清** +- 需求描述:用户一句话描述需求后,管家先「梳理需求 → 确认要点 → 匹配平台资源(知识库;选择交互见 WB-10)」再动手;澄清以选项式为主,用户点选即可,尽量不要求打字描述技术细节。 +- 界面与交互(澄清卡,出现在左栏对话流,§3.1 域引言 b): + +| 要素 | 说明 | +|------|------| +| 题目构成 | 每题 = 问题文案 + 候选选项(标注单选/多选)+「其他(自由输入)」兜底项 | +| 整卡快捷动作 | 「都按推荐,直接开始」:跳过逐题作答,按管家推荐项开始生成 | +| 已答呈现与改选 | 已答题呈现所选项;生成开始前可改选,开始后改需求走对话 | + +- 流程: + 1. 用户一句话描述需求。 + 2. 管家梳理需求、判断要点是否齐备;「要点齐备 →」不澄清,直接给出任务清单并开始执行。 + 3. 管家给出一轮选项式澄清(≤5 题),用户点选作答;候选不合适时经「其他(自由输入)」兜底。 + 4. 管家给出需求确认摘要,随后给出任务清单并开始执行(任务清单为对话体验呈现,§3.2)。 +- 规则与边界: + - 澄清问题数不超过 5 个。 + - 一轮问完,不出现多轮连环追问。 + - 选项式优先:问题连同候选答案一起给出,尽量不要求打字描述技术细节。 +- 验收要点: + 1. 首轮需求存在待澄清要点时,管家先给出澄清问题(≤5 个)而非直接生成;要点齐备时直接进入任务清单。 + 2. 澄清问题以「问题 + 候选选项」形式呈现,可全程点选完成;候选不合适时可经「其他(自由输入)」兜底作答。 + 3. 验收用例集内澄清均在一轮内完成并进入任务清单,不出现第二轮追问卡。 + +**WB-03 报错一键回流与修错** +- 需求描述:运行报错、用户截图反馈、以及构建/依赖安装失败,均可一键回流给管家;管家用业务人员能懂的话解释原因并自动修复,改到好为止。 +- 界面与交互(场景 × 入口): + +| 场景 | 回流入口 | 构成 | +|------|---------|------| +| ① 预览内运行报错 | 预览区内联回流条(WB-04) | 业务语言错误摘要一句 +「帮我修」按钮 | +| ② 构建/依赖安装失败 | 预览 tab 整面失败态(替代空白预览,WB-04 状态表) | 失败说明 +「帮我修」按钮 | +| ③ 用户截图反馈 | 左栏输入框图片上传(§3.1 域引言 a) | 上传截图(可附文字说明),管家识别后进入修复流程 | + +- 规则与边界: + - 管家的原因说明与修复说明用业务语言,不出现堆栈/错误码原文。 + - 修复进度落左栏任务清单区(§3.1 域引言 a)。 + - 修复完成后回流条消失、预览自动刷新。 +- 验收要点: + 1. 预览出错与构建失败场景均出现「帮我修」一键回流入口,点击后管家自动开始修复。 + 2. 回流后管家说明报错原因与修复内容,说明中不出现堆栈/错误码原文;修复完成后预览恢复可用。 + 3. 构建失败场景下预览 tab 呈整面失败态且「帮我修」可用。 + +#### 预览面 + +**WB-04 预览 tab(桌面/移动两态)** +- 需求描述:右栏默认停在预览 tab,应用未发布也可在预览中完整试用;提供桌面/移动两态切换(上报/检查类应用常被移动端打开)。 +- 界面与交互: + +| 要素 | 位置 | 说明 | +|------|------|------| +| 桌面/移动切换控件 | 预览 tab 顶部 | 两态切换,移动态以移动端视口呈现 | +| 手动刷新按钮 | 预览 tab 顶部工具区 | 手动重载预览 | +| 全屏切换 | 预览 tab 顶部工具区 | 预览区全屏展开/退出 | +| 预览视口 | tab 主体 | 应用实时预览,可完成完整业务操作 | +| 内联回流条 | 预览区内、报错处内联出现 | 业务语言错误摘要 +「帮我修」(承接 WB-03) | + +- 状态: + +| 状态 | 呈现 | 去向 | +|------|------|------| +| 空态 | 尚未生成内容:引导语「开始对话后应用会在这里逐步成形」 | 生成开始 → 构建中 | +| 构建中 | 生成中提示 | 完成 → 正常(自动刷新);失败 → 失败态 | +| 正常 | 应用可完整试用;运行报错时于报错处出现内联回流条 | 报错回流修复不改变整体状态 | +| 失败态 | 整面失败态:失败说明 +「帮我修」(WB-03) | 修复完成 → 正常 | + +- 规则与边界: + - 预览试用产生的数据一律落草稿库,在预览内应用自带数据页可见。 + - 已发布应用迭代期间同理:迭代期草稿数据仍在预览内应用数据页查看——此时工作台数据面呈生产库,草稿库属管家工作区内部事务(WB-06)。 + - 应用无自带数据页时,迭代期草稿数据本版不提供额外查看入口,发布时仍经 RT-06 去留提问处置。 + - 预览出错在预览内联回流(WB-03),不做独立终端。 +- 验收要点: + 1. 未发布的草稿应用可在预览中完成完整业务操作(提交表单、查看列表等)。 + 2. 桌面/移动两态切换生效,移动态以移动端视口呈现。 + 3. 预览内应用报错时出现内联「报错 → 帮我修」回流条。 + 4. 空态与构建中态按状态表正确呈现,构建完成后自动刷新为正常态。 + 5. 迭代期间预览试用数据落草稿库并在预览内应用数据页可见,工作台数据面仍呈生产库(WB-06)。 + +#### 资源面(「资源」tab:数据 / 知识库 / 模型 / 密钥) + +**WB-05 「资源」tab 结构** +- 需求描述:右栏四个核心 tab 为预览 / 资源 / 代码 / 发布;「资源」tab 是使用视角的四子页结构,打开默认停在「数据」。密钥子页管理本应用自有的第三方凭证(如 webhook token),全程只见引用名与掩码。 +- 界面与交互(子页结构): + +| 子页 | 定位 | 构成与关键字段 | 回链条目 | +|------|------|---------------|---------| +| 数据(默认子页) | 应用自有数据的一等面 | 网格查改、导入、附件、导出与表结构演进 | WB-06 ~ WB-09 | +| 知识库 | owner 可见的知识库,可浏览、可接入 | 库卡片列表 + 搜索 | WB-10 | +| 模型 | 租户管理员经能力总线配置的可用模型,只读展示 | 模型列表 + 搜索 | WB-11 / GOV-05 | +| 密钥 | 本应用要用的第三方凭证(如 webhook token) | 密钥列表 + masked 录入,见下 | 本条 / NFR-2 | + +- 界面与交互(密钥子页): + +| 界面/入口 | 位置 | 构成 | +|-----------|------|------| +| 密钥列表 | 资源 tab · 密钥子页 | 列:引用名、掩码、用途备注、状态(启用/已吊销)、创建/更新时间、最近使用时间、操作 | +| 新增弹窗 | 密钥子页「录入密钥」按钮 | 引用名(唯一性校验,代码内即以此名引用)、值(masked 输入,提交即写平台加密存储、不回显)、备注 | +| 行动作 | 密钥列表行上 | 覆盖更新(重输新值、不显旧值);吊销(二次确认,明示「引用该密钥的功能将报明确错误态直至重新录入」) | + + - 空态:「本应用暂无密钥」+ 录入引导 + 「请勿在对话中粘贴密钥」提示。 + - 失效呈现:已吊销行状态标红;发布面/资源面的失效提示与能力收回(GOV-05)同语义。 +- 流程(密钥生命周期): + 1. owner 在密钥子页以 masked 控件录入 → 值直写平台加密存储。 + 2. 代码以引用名引用该密钥(代码中不出现值)。 + 3. 覆盖更新 → 新值即时生效,旧值即刻失效且不可见。 + 4. 吊销 → 运行中应用对该引用的调用得到明确错误态(不回退旧值)→ 重新录入后恢复。 +- 状态(密钥): + +| 状态 | 含义 | 应用侧表现 | +|------|------|-----------| +| 未录入 | 代码或能力声明引用了尚未在密钥子页录入的引用名 | 引用该密钥的功能报明确错误态,应用整体可用 | +| 生效 | 已录入且未吊销 | 功能正常调用 | +| 已吊销 | 值已吊销、引用名保留 | 引用该密钥的功能报明确错误态(不回退旧值),重新录入后恢复 | + +- 规则与边界: + - 密钥值不经对话、不写日志、不回显;任何界面(含对话历史、日志、代码玻璃盒)只见引用名与掩码(NFR-2)。 + - 代码中仅出现引用名,不出现值。 + - 不提供「在对话里粘贴密钥」的路径;管家在对话中收到疑似密钥明文时,引导用户改用密钥子页。 + - 密钥录入/覆盖/吊销仅 owner 操作;租户管理员的应急手段 = 下线(RT-07)。上述事件计审计(GOV-04)。 + - 资源 tab 是使用视角:平台共享能力的账号/端点配置只在管理面(GOV-05),密钥子页管理的仅是本应用自有凭证。 + - 知识库/模型子页带搜索,只放「需要用户挑选的实例」;应用能做什么,由 6 类模板承担示例职责(WB-01)。 + - 后续版本可以扩展子页。 +- 验收要点: + 1. 右栏四核心 tab 为预览/资源/代码/发布;「资源」tab 含数据/知识库/模型/密钥四个子页且默认停在数据。 + 2. 资源 tab 内不存在任何账号/端点配置项。 + 3. 密钥录入后任何界面(含对话历史、日志、代码玻璃盒)均不出现明文值,仅见引用名与掩码、无「查看明文」入口;应用代码中仅存在引用名。 + 4. 吊销密钥后,应用引用该密钥的功能报明确错误但应用整体可用;重新录入后恢复。 + 5. 覆盖更新后新值生效、旧值即刻失效且不可见,事件计审计(GOV-04)。 + 6. 管家在对话中收到疑似密钥明文时给出改用密钥子页的引导,且不留存明文。 + +**WB-06 数据子页:网格查改与导出回溯** +- 需求描述:「数据」子页为资源 tab 默认子页(WB-05)、应用数据的一等面:应用存的数据在此可查、可改、可导出、可回溯——业务人员看数据就是看真数据。数据面无草稿/生产切换器,随应用状态自动跟随。 +- 界面与交互: + +| 界面/入口 | 位置 | 构成 | +|-----------|------|------| +| 数据面布局 | 资源 tab · 数据子页 | 左侧表清单(多表切换)+ 右侧可编辑网格(查、改、增行、删行) | +| 工具栏 | 网格上方 | 搜索、按列筛选、增行、删行、导入入口(WB-07)、导出 | +| 库标识徽标 | 网格顶部 | 非交互徽标「草稿数据 / 生产数据」,只标识、不可切换 | +| 二次确认弹窗 | 编辑/删除生产库中的行时 | 明示影响行数 + 「该操作计入审计」提示 | + + - 空态:应用尚无数据表时,引导「到对话里建表或导入」(WB-07)。 + - 导出:点导出时选择范围(当前筛选结果 / 全表,默认当前筛选结果)与格式(xlsx / csv)。 +- 状态: + +| 应用状态 | 数据面呈现 | 预览自测数据去向 | +|---------|-----------|----------------| +| 未发布 | 草稿库 | 落草稿库(草稿库承接导入与预览自测数据);提交发布时经去留提问处置(RT-06) | +| 已发布 | 生产库 | 预览试用数据仍落草稿库、不入生产库(WB-04) | +| 已发布、迭代期间 | 仍呈生产库 | 迭代草稿数据在预览内应用数据页查看(WB-04),草稿库属管家工作区内部事务;更新上线时经去留提问处置(RT-06) | + +- 规则与边界: + - 数据面不存在草稿/生产手动切换控件,库随应用状态自动跟随。 + - 编辑与删除生产库中的行均需二次确认,且计入审计(回链 GOV-04)。 + - 数据子页仅 owner 可达(含生产库视图);不向租户管理员提供生产数据查看/编辑入口,其处置手段 = 下线(RT-07)。 + - 导出格式为 xlsx / csv;导出范围 = 当前筛选结果或全表。 + - 附件列导出为文件名(回链 WB-08)。 +- 验收要点: + 1. 数据面以可编辑网格呈现应用数据表,支持查、改、增行、删行、导出。 + 2. 数据面不存在草稿/生产手动切换控件;发布前后打开数据面分别呈现草稿库/生产库数据。 + 3. 编辑或删除生产行时弹出二次确认;确认后该操作在审计记录中可查(操作人/时间/变更内容)。 + 4. 多表应用可经左侧表清单切换查看各表。 + 5. 库标识徽标随应用状态正确呈现「草稿数据/生产数据」,且不可点击切换。 + +**WB-07 数据子页:导入双路径** +- 需求描述:存量数据导入提供两条路径且都必须可用;导入前做列匹配与类型校验预检,失败行处置以失败行报告驱动闭环。 +- 双路径: + +| 路径 | 入口 | 适用场景 | +|------|------|---------| +| ① 对话上传 | 左栏输入框上传 Excel/CSV,管家建表灌数 | 尚无数据表,建新表并灌入 | +| ② 数据面显式导入 | 数据面工具栏「导入 Excel/CSV」入口 | 向已有表导入数据 | + +- 流程: + 1. 选择 Excel/CSV 文件(对话路径:左栏输入框上传;数据面路径:工具栏导入入口并选定目标表)。文件无法解析 → 导入中止并提示原因。 + 2. 列匹配与类型校验预检:数据面路径呈映射界面供确认与调整;对话路径由管家自动完成列建模并在对话流给出确认摘要。 + 3. 逐行校验落库。部分行失败 → 失败行跳过、成功行落库、出具失败行报告(可修正后重导);全部行失败 → 导入中止 + 原因摘要提示。 +- 界面与交互: + +| 界面/入口 | 位置 | 构成 | +|-----------|------|------| +| 导入映射界面 | 数据面路径:点工具栏导入入口后 | 源列→目标列映射表、类型校验结果、可调整映射 | +| 失败行报告 | 两路径同一报告结构:数据面路径为导入结果弹窗/结果区;对话路径为管家在对话流呈现的同格式报告卡(摘要 + 下载入口) | 结果摘要(成功行数 / 失败行数 / 失败原因归类)+ 失败行明细(行号、原始值、失败原因列)+ 「下载失败行文件」(保留原列结构 + 追加失败原因列)+ 「修正后重新导入失败行文件」指引 | + +- 规则与边界: + - 重导语义 = 报告驱动的失败行闭环:下载失败行文件、修正后仅重导该文件;不承诺全文件级去重。 +- 验收要点: + 1. 在左栏对话上传 Excel/CSV 后,管家能创建对应数据表并完成数据灌入。 + 2. 数据面存在显式导入入口,可将 Excel/CSV 导入指定数据表。 + 3. 两条路径导入的数据均在数据网格中可见、可编辑。 + 4. 列不匹配/类型不符时给出失败行报告,成功行正常落库。 + 5. 全部行失败时导入中止并提示原因。 + 6. 失败行文件可下载;修正后重导成功,且不产生成功行重复入库。 + +**WB-08 数据子页:附件字段与行内预览下载** +- 需求描述:附件字段为本版基线能力:上报/整改类应用支持拍照/上传附件,数据面附件列支持行内预览与下载。 +- 界面与交互: + - 附件单元格形态:图片 = 缩略图、点开大图预览;文档 = 文件名 + 下载图标;多附件时带数量角标。 + - 上传交互:超限或类型不符即时提示,文案含上限值与允许类型;移动端上传失败保留重试按钮。 +- 规则与边界: + - 单文件大小上限与允许类型为部署配置项(默认单文件 ≤ 20MB、文档/图片常用类型白名单)。 + - 超限或类型不符的上传即时拒绝并提示;移动端上传失败可重试。 + - 网格导出时附件列导出为文件名(WB-06)。 + - 独立的对象存储管理面不在本版(§5)。 +- 验收要点: + 1. 含附件字段的模板应用中,终端用户可上传附件(含移动端拍照上传)。 + 2. 数据面中附件列可行内预览(图片类)与下载(通用类)。 + 3. 超过单文件上限或类型不在白名单的上传被拒绝并有明确提示。 + 4. 移动端上传失败后可重试成功。 + +**WB-09 数据子页:表结构演进与确认闸口** +- 需求描述:表结构演进由管家以业务语言引导;本条是工作台路径结构变更确认的唯一闸口。 +- 变更类型: + +| 变更类型 | 是否需确认 | 变更前快照 | +|---------|-----------|-----------| +| 加列 | 无需确认,安全直接做 | 不留 | +| 改/删列 | 需用户确认 | 变更前自动留数据快照 | + +- 规则与边界: + - 本条是工作台路径结构变更确认的唯一闸口;CLI 路径的确认闸口见 DEV-04 / RT-05 结构演进表。 + - 草稿期已确认的结构变更,发布时不再二次确认(见 RT-05)。 + - 快照为系统级保障,本版不提供用户自助查看与恢复入口(数据自助恢复与结构变更管理面顺延 §5.2);需要恢复时由管家给出「联系租户管理员」指引。 + - 确认执行后,管家在对话流留结构变更确认卡(含变更内容与快照留存标识)。 + - 结构变更确认记录以草稿库当前实际结构为准;代码版本回滚不自动撤销已执行的库结构变更(WB-15 联动)。 + - 提交发布时以「草稿库实际结构 vs 生产库结构」差集生成变更摘要(RT-04)。 +- 验收要点: + 1. 请求删列/改列时管家要求确认;确认后执行,且对话流中出现含快照留存标识的结构变更确认卡;加列不要求确认。 + 2. 草稿期已确认的结构变更在迭代发布时不再触发二次确认(RT-05 验收 4)。 + +**WB-10 知识库子页:浏览与接入** +- 需求描述:知识库子页列出 owner 可见的库供浏览与接入本应用;三条接入路径殊途同归、均在对话流留痕。工作台内不新建知识库。 +- 界面与交互: + +| 界面/入口 | 位置 | 构成 | +|-----------|------|------| +| 库卡片列表 | 资源 tab · 知识库子页(带搜索,WB-05) | 字段:知识库名称(即平台知识库模块中的名称,无独立别名)、一句话描述、文档量、更新时间、「已接入」角标 | +| 卡片按钮 | 库卡片上 | 「接入本应用」;已接入态替换为「解除接入」(二次确认,明示应用相关问答功能将不再引用该库) | +| 库详情 | 点卡片打开右侧抽屉 | 简介、文档量、更新时间、内容样例(若干篇文档标题或片段) | +| 空态 | owner 无任何可见库时 | 指引去向:到知识库模块建库、或联系知识库管理者/租户管理员授权(知识库管理者为平台知识库模块既有角色;走既有授权,本册不新增授权面) | + +- 接入路径(三条路径,殊途同归): + +| 路径 | 触发 | 交互形态 | 结果 | +|------|------|---------|------| +| ① 页内点按钮 | 知识库子页卡片「接入本应用」(WB-05) | 点按钮即接入 | 接入动作与结果在对话流留痕 | +| ② 管家推荐 | 澄清或造/改过程中管家判断需要知识库 | 选项式候选卡:库名 + 一句话描述 + 「先不接」,支持多选 | 点选完成接入,对话流留痕 | +| ③ 用户点名 | 对话中说「接入 XX 知识库」 | 名称近似的多个库以选项式挑 | 接入完成,对话流留痕 | + +- 规则与边界: + - 权限边界:子页、候选与点名的范围恒 ⊆ owner 自身知识库权限——无权限的库不出现、点名应答「未找到」。 + - 管家推荐是尽力而为的便利入口、不作硬保证(候选带试检索命中预览为 v3.1 增强,§5.2)。 + - 找不到时:owner 可见范围内无匹配时,管家明确告知并指引去向(到知识库模块建库、或联系知识库管理者/租户管理员授权),不静默跳过。 + - 解除接入:对话中说「不要再用 XX 库」为页内「解除接入」按钮的等价路径;动作与结果在对话流留痕,并计入能力声明变更(RT-04)。 + - 工作台内不新建知识库。 +- 验收要点: + 1. 知识库子页列出且仅列出 owner 有权限的库;「接入本应用」完成接入。 + 2. 需求涉及知识问答/口径类内容时,管家给出候选知识库选项(库名、一句话描述、「先不接」),点选完成接入;点名「接入 XX 知识库」可完成接入。 + 3. 无匹配时管家明确告知并给出去向指引,不静默跳过。 + 4. owner 无权限的库不出现在子页与推荐候选中,点名时应答为「未找到」。 + 5. 任一路径接入后,对话流中留有接线记录。 + 6. 解除接入后应用不再引用该库;对话流留痕;迭代审批单变更摘要含该项(RT-04)。 + +**WB-11 模型子页:可用模型展示** +- 需求描述:只读展示租户管理员经能力总线配置的可用模型(GOV-05),组织方式沿用平台模型管理现状——按模型服务提供方分组,模型名用原名(不做别名转换)。应用所用模型为平台默认配置,业务人员无需选择;更换模型的交互本期不做(§5.2)。造应用引擎的模型与凭据与此无关,对业务人员零感知(GOV-06)。 +- 验收要点: + 1. 模型子页列出且仅列出租户管理员配置的可用模型,均为只读展示、无更换入口。 + 2. 未做任何模型相关操作时,应用的 AI 功能使用平台默认模型即可完整工作。 + +#### 代码玻璃盒 + +**WB-12 只读代码玻璃盒与导出工程** +- 需求描述:代码 tab 以只读玻璃盒呈现应用完整工程——看得到应用「到底是什么」,但改码一律走对话,不提供在线编辑。 +- 界面与交互: + +| 界面/入口 | 位置 | 构成 | +|-----------|------|------| +| 代码 tab | 右栏四核心 tab 之一;「高级折叠」定位 = tab 常驻但视觉弱化,不占默认注意力、点击即达 | 左侧文件树 + 右侧只读代码视图 + 工具栏「导出工程」按钮 + 「运行日志」子页入口(WB-13) | +| 首次进入说明条 | 代码 tab 顶部,首次进入时展示、可关闭 | 「改代码请回到左栏对话向管家提需求」说明文案 | +| 空态 | 应用生成前进入代码 tab | 「应用生成后可在此查看代码」引导文案 | + +- 规则与边界: + - 全部内容只读:无任何编辑入口;不提供在线编辑(云端通用 IDE 不做,§5.1;代码在线编辑顺延 §5.2)。 + - 不提供交互式终端;不提供 SQL 查询能力(数据出口走数据面导出,WB-06)。 + - 「导出工程」导出完整工程包到本地,包含全部代码与工程文件;代码与导出物中密钥只出现引用名、不含值(NFR-2,回链 WB-05)。 + - CLI 导入且改码权在本地的应用,代码 tab 展示最新已发布版本快照(DEV-06;各面呈现差异见 §3.1 域引言)。 +- 验收要点: + 1. 代码 tab 可浏览完整文件树与文件内容,且全部只读、无编辑入口。 + 2. 可导出完整工程包到本地。 + 3. 工作台内不存在任何终端输入或 SQL 查询入口。 + 4. 导出工程包中不含任何密钥值,仅出现密钥引用名。 + +**WB-13 只读运行日志子页** +- 需求描述:代码玻璃盒内含只读「运行日志」子页,承担终端/控制台的排障查看职能——排障看得到日志,但不提供任何命令执行能力。 +- 界面与交互: + +| 界面/入口 | 位置 | 构成 | +|-----------|------|------| +| 运行日志子页 | 代码 tab 内「运行日志」子页 | 顶部草稿/生产日志分段切换 + 筛选器(时间范围、关键字)+ 日志列表 | +| 日志条目 | 日志列表 | 时间戳 + 级别 + 内容;只读、可复制 | +| 空态 | 所选日志段无内容时 | 「暂无日志」+ 原因说明(如应用尚未运行、尚未发布) | + + - 与数据面「无草稿/生产切换器」(WB-06)的设计差异说明:日志两态并存、排障需分别查看,故此处提供分段切换;数据面单库随应用状态自动跟随,不可切换。 +- 可见性: + +| 日志态 | 可见角色 | 备注 | +|--------|---------|------| +| 草稿态 | 仅 owner | — | +| 生产态 | owner + 租户管理员 | owner 入口 = 本子页;租户管理员入口 = 构建 → 应用页「全租户」视角(GOV-09);对指定用户单独授权顺延 v3.1(§5.2) | + +- 规则与边界: + - 日志内容范围(PRD-1 DEV-02 MCP 日志工具与 DEV-04 `bisheng logs` 以本条为口径出处):应用运行输出与错误信息(含错误栈);不含平台侧敏感日志,不出现密钥值(NFR-2)。 + - 日志页不提供任何命令执行能力。 + - 非 owner 技术同事参与排障走导出工程(WB-12)或由 owner 协同。 +- 验收要点: + 1. 运行日志子页可分别查看草稿态与生产态日志,均只读。 + 2. 草稿日志仅 owner 可见;owner 与租户管理员之外的用户看不到生产日志。 + 3. 日志页不提供任何命令执行能力。 + 4. 时间范围与关键字筛选可用。 + 5. 日志内容中不出现密钥明文。 + +#### 发布面 + +**WB-14 发布 tab** +- 需求描述:发布 tab 是应用发布、运营与归属动作在 owner 侧的唯一承载面:提交发布与审批跟踪、能力声明、发布版本与回滚、入口分发、资源档位、可见范围、元信息、下线与删除均在此完成(审批管线与治理规则见 PRD-1)。 +- 界面与交互(区块表,操作动作逐行标注操作者): + +| 区块 | 内容与关键字段 | 交互与回链 | +|------|---------------|-----------| +| ① 主操作区 | 「提交发布 / 更新上线」按钮;待上线(配额不足)态时呈「手动上线」按钮 | 仅 owner;存在在途审批单时一切提交类动作(提交发布 / 更新上线 / 提交回滚)置灰并提示「需先撤回在途审批单」(RT-03);「更新上线」与对话侧操作卡为同一动作的两个等价入口(GJ-02) | +| ② 审批状态区 | 审批单状态徽标(待审 / 通过 / 驳回 / 已撤回,引用 PRD-1 §3.2 审批单态枚举)+ 单类型标识(首发 / 迭代 / 回滚 → vN,RT-05)+ 提交时间 + 驳回理由全文 | 「撤回」按钮仅 owner,二次确认弹窗明示「撤回后可修改再提交生成新审批单」(RT-03);审批期间草稿有新改动时显式提示「草稿已领先于待上线版本,本次审批仅覆盖提交时快照」 | +| ③ 能力声明区 | 能力声明完整白话清单(权威定义见 RT-04;与审批单「白话摘要」、审读视图「完整清单」同源) | 只读展示,随草稿能力声明变化更新 | +| ④ 版本列表区 | 发布版本列表,列定义引用 RT-05:版本号 / 类型(首发 / 迭代 / 回滚 → vN)/ 提交时间 / 终态标注 | 曾上线版本行上「回滚到此版本」(仅 owner,确认弹窗与回滚语义见 RT-05);与 WB-15 代码版本(造的过程记录,入口在顶栏)为两个概念,对照表见 WB-15 | +| ⑤ 入口链接 + 二维码区 | 入口链接 + 二维码(移动端扫码直达应用) | 仅已上线态展示(RT-01) | +| ⑥ 资源档位区 | 首次发布提交时从平台预置资源模板中必选,默认推荐轻量档,各档带白话适用说明(GOV-03) | owner 选择;迭代发布可调整、未调整时沿用原档位,调整计入审批单变更摘要(RT-04) | +| ⑦ 可见范围区 | 当前可见范围摘要 + 「设置」按钮 | owner 点击打开平台通用授权弹窗(走弹窗、不占 tab,GOV-01);首发审批通过前显示「仅 owner(默认)」且不可设置,审批通过后开放(GOV-01 / RT-03 口径);**租户管理员不提供代调整**——已收敛为「误授权的应急手段是下线」(RT-07;PRD-1 GOV-01 / GOV-09) | +| ⑧ 元信息区 | 应用名称 / 描述 / 图标编辑 | 仅 owner;修改不触发审批、即时生效、计审计(RT-02) | +| ⑨ 运营动作区 | 下线 / 重新上线按钮(按应用当前态二选一展示) | owner 操作,二次确认(RT-07);租户管理员同款动作入口在构建 → 应用页「全租户」视角(GOV-09) | +| ⑩ 改码权归属区 | 当前归属状态(本地开发者 / 平台管家)+「交接给管家 / 收回到本地」按钮 | 仅 CLI 导入应用显示;仅 owner;构成与确认弹窗引用 DEV-06 | +| ⑪ 底部危险操作区 | 显式删除入口 | 仅 owner;二次确认弹窗要求输入应用名 + 明示删除范围(代码/对话/应用数据全部删除、不可恢复)与计审计(RT-07) | + +- 状态(应用态 × 发布 tab 呈现差异;应用态枚举引用 PRD-1 §3.2 域级应用状态机): + +| 场景 | 发布 tab 呈现 | +|------|--------------| +| 草稿(从未发布) | 空态:主操作区与资源档位区可用,其余区块隐藏或呈空态文案 | +| 存在在途审批单(待审) | 审批状态区呈待审 + 「撤回」按钮;提交类按钮置灰并提示 | +| 首发被驳回(应用回草稿态) | 审批状态区呈驳回 + 理由全文,可修改后重新提交;入口链接/二维码与运营动作区隐藏 | +| 已上线 | 全区块展示(改码权归属区仍仅 CLI 导入应用显示) | +| 待上线(配额不足) | 状态提示 + 主操作区呈「手动上线」按钮(owner;配额释放后点击上线、无需重新审批,GOV-03 / RT-03) | +| 已下线 | 运营动作区呈「重新上线」 | + +- 规则与边界: + - 首发与迭代每次发布均需审批、无免审路径(RT-03 / RT-04)。 + - 同一应用同时至多一个在途审批单(RT-03)。 + - 提交发布动线次序 = RT-06 自测数据去留确认 → 资源档位确认 → 生成审批单;可见范围于审批通过后由 owner 设置(与 §2.2 主旅程一致,GOV-01)。 + - 「更新上线」在发布面与对话流两处等价(GJ-02),行为一致。 +- 验收要点: + 1. 发布面完整列出应用的能力声明,用业务可读语言呈现。 + 2. 发布后展示入口链接与二维码,手机扫码可直达应用。 + 3. 可见范围设置以弹窗形式完成,不新增 tab。 + 4. 首次发布时可从预置资源模板中选择档位且有默认推荐;迭代发布未调整时沿用原档位。 + 5. 首发 / 迭代 / 回滚三类审批单在发布面均可见当前审批状态(待审 / 通过 / 驳回 / 已撤回)。 + 6. 待审中 owner 可撤回审批单;撤回后状态呈已撤回,可修改后重新提交生成新审批单。 + 7. 驳回理由全文在发布面可见。 + 8. 存在在途审批单时,提交发布 / 更新上线 / 提交回滚按钮均置灰且有明确提示。 + 9. 下线、重新上线、显式删除(输入应用名确认)均可在发布面完成且计审计。 + 10. CLI 导入应用的发布面显示改码权归属区,平台内造应用不显示。 + 11. 元信息修改即时生效、不产生审批单、计审计。 + 12. 待上线(配额不足)态发布面显示「手动上线」按钮,配额释放后点击上线成功、无需重新审批。 + +#### 版本与草稿边界 + +**WB-15 代码版本管理(每轮自动版本、回滚与差异查看)** +- 需求描述:每轮改动自动生成一个代码版本(无需手动保存),版本列表随应用保留,支持回到任一版本与查看版本差异。代码版本是「造的过程记录」,与发布版本(RT-05,上线记录)是两个概念,入口分立、互不混排。 +- 界面与交互: + +| 界面/入口 | 位置 | 构成 | +|-----------|------|------| +| 代码版本面板 | 工作台顶栏「版本 vN」入口(§7 Demo「存档 v3」按钮即此示意) | 版本列表:版本号、生成时间、该轮改动一句话摘要、当前版本标识;行动作 = 查看差异、回到此版本 | +| 差异查看 | 版本行「查看差异」 | 相对上一版本的文件变更清单(新增/修改/删除)+ 行级差异,均为只读视图 | +| 回滚确认弹窗 | 版本行「回到此版本」 | 明示「对话历史保留、草稿数据不随版本回滚」;确认后代码与管家工作上下文恢复到该版本,对话流插入回滚事件卡 | +| 空态 | 应用刚创建、仅有初始版本时 | 列表仅含初始版本一条;无上一版本,不提供差异查看 | + +- 两概念对照(代码版本 vs 发布版本): + +| 维度 | 代码版本(本条) | 发布版本(RT-05) | +|------|-----------------|------------------| +| 生成时机 | 每轮对话改动自动生成 | 提交发布时生成 | +| 回滚是否审批 | 免审批、即时生效 | 生成回滚审批单(RT-05) | +| 是否影响生产 | 仅作用于草稿工作区 | 作用于生产 | +| 数据与对话是否回退 | 均不回退 | 生产数据不回退(回滚处置见 RT-05 处置表) | +| 入口位置 | 顶栏代码版本面板 | 发布面版本列表区(WB-14 ④) | + +- 规则与边界: + - 每轮改动自动生成版本,无需手动保存。 + - 回滚仅 owner 可发起,仅作用于草稿工作区,不构成发布、不触发审批。 + - 对话历史只追加、永远完整保留(GJ-01);草稿数据不随版本回滚。 + - 已执行的库结构变更不随代码回滚自动撤销(WB-09);回滚目标版本与草稿库当前表结构不一致(回滚跨越已执行的结构变更)时,管家在回滚事件卡中明确提示结构不一致及其影响,并给出处置选项:由管家生成反向结构修正,或放弃本次回滚(恢复到回滚前版本)。 + - CLI 导入应用:改码权交接前版本列表 = 每次 deploy 的版本快照(即发布版本,RT-05);代码版本时间线自交接给管家后的首轮对话起生成(DEV-06,§3.1 域引言)。 +- 验收要点: + 1. 每轮改动后版本列表自动新增一条版本记录,无需用户手动保存。 + 2. 可查看任一版本的文件变更清单与行级差异,均为只读。 + 3. 回滚到任一版本后,代码与管家工作上下文恢复到该版本状态;对话历史与草稿数据不受影响,对话流中出现回滚事件卡。 + 4. 代码版本面板(顶栏)与发布面版本列表(WB-14)分立呈现,代码版本回滚不产生审批单。 + 5. 回滚跨越已执行结构变更时,回滚事件卡含结构不一致提示与处置选项。 + +**WB-16 草稿 owner-only** +- 需求描述:草稿全程仅 owner 可见可试,想给同事用就发布。 +- 规则与边界: + - owner-only 的范围 = 草稿工作区:对话、预览、数据均只对 owner 开放。 + - 唯一例外:审批期间,审批人可经审读视图(RT-03)查看待上线版本快照、并经草稿沙箱预览试用(审批例外、计审计,NFR-1.2)。 + - 草稿对话无例外:始终仅 owner 可见,审批人亦不可见。 + - 不提供授权试用、共建草稿能力(本版明确不做,§5.1)。 +- 验收要点: + 1. 任何非 owner 用户(包括租户管理员与平台超管在内)访问草稿的对话/预览/数据均被拒绝;审批人仅经审读视图查看与预览(RT-03),无工作台草稿入口。 + 2. 草稿应用不出现在任何他人的「我的应用列表」(§3.1 域引言)与应用广场(RT-02)中,也不出现在构建 → 应用页的「全租户」视角(GOV-09 仅列已发布应用)。 + 3. 工作台内不存在「邀请协作」「授权试用」类入口。 + +### 3.2 应用管家(GJ) + +**本域交付:每个应用绑定一个常驻管家,造它、改它是同一个 agent、同一段会话。管家基于成熟 coding agent 内核,天然具备「讨论只回答、改动先列任务清单再执行」的行为方式——任务清单作为对话体验呈现,不作为独立产品需求条目约束。左栏界面构成与对话流卡片清单见 §3.1 域引言。** + +**GJ-01 管家会话随应用终身存续** +- 需求描述:管家的对话历史与工作上下文和应用绑定存储,不随发布、运行实例回收或时间流逝而丢失;应用创建数月后回来提一句需求,管家仍带着全部上下文(数据模型/口径/历史决策)接得住,上下文零移交。 +- 规则与边界: + - 重进工作台时对话历史完整可滚动回溯、任务清单一并恢复(验收 1 的界面落点;左栏构成见 §3.1 域引言)。 + - 导入应用冷启动约定:CLI 导入应用交接改码权(DEV-06)时,以最新已发布版本快照初始化草稿沙箱与管家工作上下文;管家首轮回复包含对应用结构(页面/数据表/能力声明)的摘要确认;交接前的 deploy 历史经发布面的发布版本列表(WB-14)可查、不进入对话流。 +- 验收要点: + 1. 关闭浏览器/长时间离开后重新打开应用工作台,完整对话历史与任务清单可见。 + 2. 运行实例被回收后再次进入,管家能在回复中正确引用会话内既有事实(应用数据表与字段名、历史决策记录),并给出不与历史决策冲突的修改方案。 + 3. 会话历史的生命周期与应用一致,仅随应用显式删除而删除(回链 RT-07)。 + 4. CLI 导入应用交接改码权后的首轮对话中,管家给出对应用结构(页面/数据表/能力声明)的摘要确认。 + +**GJ-02 发布后迭代 = 继续对话** +- 需求描述:发布后的修改不需要「打开工程」:用户在同一对话里提需求,管家只在草稿中修改,用户预览确认后点「更新上线」提交迭代发布审批(RT-04),审批通过后生效。管家永不直接改动生产实例。 +- 流程: + 1. 用户在原对话中提出修改需求,管家仅在草稿中修改;生产应用不发生任何变化。 + 2. 用户在预览 tab 确认修改效果(WB-04)。 + 3. 点「更新上线」,双入口等价:管家在预览确认后于对话流给出「更新上线」操作卡;发布面主操作区提供同款按钮(WB-14),两处为同一动作。 + 4. 发布流程编排:自测数据去留提问(草稿库有自测数据时,RT-06)→ 资源档位确认(默认沿用原档位,WB-14)→ 生成迭代审批单(RT-04),对话流插入「已提交审批」事件卡。 + 5. 审批终态回流对话:通过 → 对话流追加「审批通过、新版本已生效」事件卡;「驳回 →」对话流追加驳回事件卡(含理由全文),owner 修改后重新提交生成新审批单;审批终态同步经平台站内信通知 owner。 +- 规则与边界: + - 提交前 owner 可在发布面查看本次四类变更摘要(能力声明 / 可见范围 / 表结构 / 资源档位,与 RT-04 审批单同源)。 + - 管家永不直接改动生产实例;迭代期间同事访问线上应用不受影响。 +- 验收要点: + 1. 已发布应用的修改请求全程在原对话中完成,生产应用在迭代审批通过前不发生任何变化。 + 2. 迭代过程中同事访问线上应用不受影响。 + 3. 提交时生成的版本记录在迭代审批通过后标注为已上线并生效(回链 RT-05)。 + 4. 「已提交审批」、「审批通过」与驳回三类事件卡在对话流中可见,驳回事件卡含理由全文。 + +### 3.3 工作台专属的运行时与治理条目(RT-06 / GOV-06) + +其余 RT / GOV 条目见 PRD-1 §3.2 / §3.3;以下两条与工作台流程强绑定,随本册交付与评审: + +**RT-06 预览自测数据发布去留** +- 需求描述:任何发布(首发与迭代)时,若草稿库存在 owner 预览自测产生的数据,管家以选项式提问由用户点选去留,默认选项为「清空」;用户显式选择保留时这些数据随发布进入生产库。 +- 界面与交互: + +| 界面/入口 | 位置 | 构成 | +|-----------|------|------| +| 自测数据去留选项卡 | 工作台左栏对话流——owner 点「提交发布 / 更新上线」后由管家发出(对话流卡片类型清单见 §3.1 域引言) | 提示文案 + 自测数据行数摘要 + 选项「清空[默认]/ 保留」;点选后发布流程继续,未点选前发布流程停在该步 | +| 保留冲突报告卡 | 工作台左栏对话流——迭代发布选「保留」且存在冲突行时出现 | 复用 WB-07 失败行报告形态:成功并入行数 + 冲突跳过行数 + 冲突行明细 | + +- 流程: + 1. owner 点「提交发布 / 更新上线」(发布面主操作区,或对话流「更新上线」操作卡,WB-14 / GJ-02)。 + 2. 判定草稿库是否存在预览自测产生的数据;「无自测数据 →」不出现提问,直接进入步骤 5。 + 3. 管家在左栏对话流发出去留选项卡(默认「清空」);「未点选 →」发布流程停在该步、不生成审批单。 + 4. 点「清空」→ 自测数据不随发布进入生产库;点「保留」→ 自测数据随发布进入生产库;迭代发布时保留行以追加方式并入生产库,「冲突行(主键/唯一约束)→」跳过并出保留冲突报告卡。 + 5. 发布动线继续:资源档位确认(首发默认推荐轻量档、迭代默认沿用原档位,WB-14 / GOV-03)→ 生成审批单(RT-03/04);可见范围于审批通过后设置(WB-14 / §2.2)。 +- 规则与边界: + - 提问与「清空」仅针对 owner 预览自测产生的数据;经 WB-07 双路径导入的存量数据一律随发布进入生产库、不在提问范围。 + - 自测/导入数据的判定依据 = 导入按批次留痕,仅用于发布去留判定,不构成 §5.2 顺延的行级归属能力。 + - 「保留」范围冻结为提交发布时刻的草稿库快照;审批期间审批人新增的试用数据一律不带入生产(RT-03)。 + - 迭代草稿库初始内容约定:迭代开始时草稿库不含生产数据副本,仅承接迭代期的自测与导入数据。 + - 首发与迭代提问行为一致;草稿库无自测数据时不出现该提问。 + - 本条为工作台发布路径专属;CLI deploy 路径无此提问,首发生产库默认空库初始化(回链 RT-03)。 +- 验收要点: + 1. 草稿库含预览自测数据的应用发布时(首发与迭代一致),管家给出「清空 / 保留」选项式提问,默认为清空。 + 2. 选清空后生产库无自测数据行;选保留后这些行在生产库可见。 + 3. 草稿库无自测数据时不出现该提问。 + 4. 含导入数据的应用选「清空」后,导入行完整进入生产库、仅自测行被清空。 + 5. 审批期间审批人新增的试用数据不出现在生产库。 + 6. 未点选去留前发布流程停在该步、不生成审批单。 + +**GOV-06 引擎与凭据档案配置(管理面,业务人员零感知)** +- 需求描述:造应用引擎的选择、模型接入路径与凭据档案全部是管理面决策;业务人员在工作台永远只是「开始造应用」,对引擎及其模型/凭据零感知,最多收到一条友好降级提示。应用自身 AI 功能所用模型为平台默认配置(WB-11),与引擎配置无关。 +- 角色分工: + +| 角色 | 职责 | +|------|------| +| 平台超管 | 维护平台默认档案(平台托管模型类) | +| 租户管理员 | 创建/分配本租户档案(客户订阅账号授权 / 客户自有模型 API key),分配粒度 = 全租户 / 用户组 / 指定用户;设并发上限;吊销;预配降级策略(订阅额度用尽自动降级到平台托管模型) | + +- 档案类型: + +| 档案类型 | 凭据形态 | 适用场景 | +|---------|---------|---------| +| 平台托管模型 | 平台内置引擎模型服务凭据,随部署提供、平台超管维护 | 开箱即用的默认路径;亦为降级策略的目标档案 | +| 客户订阅账号授权 | 企业既有引擎服务订阅账号的授权凭据 | 复用企业已订阅的引擎服务额度 | +| 客户自有模型 API key | 企业自备模型服务的 API key | 以企业自有模型承载造应用引擎 | + +- 界面与交互(位置:管理后台-造应用引擎配置页,与能力总线 GOV-05 分置;逐区标注操作角色): + +| 界面/入口 | 位置 | 构成 | +|-----------|------|------| +| 凭据档案列表 | 造应用引擎配置页主区(租户管理员见本租户档案;平台超管见平台默认档案) | 列:档案名、类型、分配范围摘要、并发上限、当前并发数、状态(启用/已吊销)、创建时间;同一用户命中多档案时按解析链标注生效档案;空态 =「尚未配置凭据档案」+ 新建引导 | +| 创建/编辑弹窗 | 列表「新建档案」按钮 / 行上编辑 | 字段:档案名、类型(三类档案单选)、凭据录入(masked 控件,NFR-2 同口径)、分配范围选择器(全租户 / 用户组 / 指定用户)、并发上限、降级策略开关(订阅额度用尽 → 平台托管档案) | +| 吊销确认弹窗 | 档案行上「吊销」按钮 | 二次确认,明示对进行中会话的影响:自下一次请求起失效 | + +- 引擎凭据异常 × 会话侧表现: + +| 异常场景 | 会话侧表现 | 会话与历史 | +|---------|-----------|-----------| +| 订阅额度用尽、已预配降级策略 | 自动降级到平台托管模型档案;工作台左栏对话流出现一条友好降级系统提示卡(文案不暴露引擎与凭据细节) | 会话继续、不中断 | +| 订阅额度用尽且未配降级 / 凭据被吊销 / 无可用档案 | 对话流出现明确错误卡(「造应用服务暂不可用,请联系租户管理员」类文案) | 会话与对话历史完整保留,凭据恢复后原地续聊 | +| 同一档案并发会话数达上限 | 新消息被拒绝并提示「当前使用人数较多,请稍后再试」,不排队 | 会话与历史保留,稍后重试 | + +- 规则与边界: + - 生效档案解析链 = 指定用户 > 用户组 > 租户默认 > 平台默认;未配置任何租户档案时按解析链落到平台默认档案,不影响使用。 + - 降级为租户管理员预配策略:未预配时订阅额度用尽按上表错误卡处理,不自动降级。 + - 档案吊销自下一次请求起失效。 + - 同一档案并发会话数不超过其设定的并发上限;超限拒绝、不排队。 + - 业务人员零感知:引擎及其模型/凭据的一切配置项不出现在工作台,业务人员仅可能感知上表的降级提示或错误提示。 + - 应用自身 AI 功能所用模型为平台默认配置(WB-11 只读展示),与引擎配置无关。 + - 档案创建/分配/吊销与自动降级触发事件均计审计(GOV-04)。 +- 验收要点: + 1. 引擎与凭据的全部配置项只出现在管理面,工作台无造应用引擎相关的选择入口(WB-11 的应用模型子页为只读展示,不属引擎配置)。 + 2. 凭据档案只对其分配范围内的用户会话生效;吊销后自下一次请求起失效。 + 3. 订阅额度用尽时(配置了降级策略)用户会话不中断,仅收到降级提示。 + 4. 同一档案并发会话数不超过其设定的并发上限。 + 5. 凭据档案的创建/分配/吊销在管理面可完成且计审计。 + 6. 同时命中指定用户与用户组档案时,按解析链取指定用户档案。 + 7. 无可用档案时对话侧出现明确错误卡、会话历史保留,凭据恢复后可原地续聊。 + 8. 并发超限时新消息得到明确提示而非静默失败。 + +--- + +## 4. 非功能需求 + +非功能不变量全量收录于 PRD-1 §4,两册共用。与本册条目直接回链的:NFR-1.2 草稿 owner-only(WB-16)、NFR-2 密钥零暴露(工作台密钥交互见 WB-05 密钥子页)、NFR-3 资产持久(对话历史 GJ-01)。 + +--- + +## 5. 范围外与顺延 + +### 5.1 本版明确不做 + +| 项 | 说明 | +|----|------| +| 交互式终端 / SQL 查询面 | 排障走只读运行日志(WB-13),数据出口走导出(WB-06) | +| 模拟用户视角切换 / 样本数据播种 | 不做视角切换入口与样本数据播种;想体验其他用户视角,用真实账号登录访问已发布应用即可 | +| 复制同事应用为自己的草稿 | 不做;新建走空白 / 模板 / 导入本地项目三路径(WB-01) | +| 草稿授权试用 / 共建 | 草稿 owner-only(WB-16),想给同事用就发布 | +| 组织级规范资产注入 | 不在本版范围(组织统一 UI/代码规范等资产自动注入生成过程) | +| 云端通用 IDE | 改码走对话,玻璃盒只读(WB-12) | +| 已定顺延 v3.1 的项 | 只在 §5.2 维护(点选局改、代码在线编辑、数据自助恢复等),本表不重复 | + +### 5.2 v3.1 顺延清单 + +- 管家持续看护上线应用(定时/事件触发巡检、增量比对节流、合并简报推送,及其依赖的平台级触发/调度子系统)——本版不做,v3.1 重新立项。 +- 点选局改;代码在线编辑(玻璃盒本版只读,WB-12);模板库深化。 +- 知识库推荐候选的试检索命中预览(候选卡上展示「为什么推荐它」的检索证据,WB-10 增强)。 +- 应用模型的选择/更换交互(本期应用模型用平台默认配置,WB-11 只读展示)。 +- 生产日志对指定用户单独授权(本版生产日志仅 owner 与租户管理员可见,WB-13)。 +- 应用数据行级归属(系统 owner 字段 / 来源字段)与「仅本人 / 全部」按表数据模式。 +- 应用数据备份与自助恢复(用户显式发起、可审计,不动摇「生产数据默认不回滚」)。 +- 应用所有者使用情况视图(调用量/活跃用户/趋势,仅 owner 可见)——与 PRD-1 §5.2「owner 应用侧审计与访问记录自查视图」同域,v3.1 立项时合并评估,避免两处分别维护。 +- 对象存储独立管理面;独立「设置」面;结构变更管理面。 + +--- + +## 6. 开放问题 + +| # | 问题 | 说明 | +|---|------|------| +| 1 | 命名 | 「应用工场」「造应用」均为暂定名,非最终命名,评审时定夺 | +| 2 | 交互稿评审 | 以四核心 tab(预览/资源/代码玻璃盒/发布)为基线细化:左对话 + 任务清单布局、选项式澄清、资源 tab 四子页(数据默认/知识库/模型/密钥)、资源选择选项(选项式提问)、tab 切换与预览全屏(全屏已收编为正式需求,见 WB-04)、预览两态、发布版本回滚在发布面(WB-14/RT-05)与代码版本回滚在顶栏版本面板(WB-15)两处分立、可见范围走弹窗、密钥走资源 tab 密钥子页(masked 录入,WB-05) | +| 3 | 内部 dogfood | 首批用工场造 5-10 个内部工具,优先从首发 6 类模板场景选题 | + +--- + +## 7. 评审演示对照 + +交互 Demo 为单页工作台演示(含四 tab / 弹窗 / 图例),评审时按下表逐项对照。 + +> ⚠️ **Demo 按 2026-08-16 之前的承载面绘制**(独立入口页形态),与本册 v1.5 已改定的「构建 → 应用 → 应用详情页」不一致——**工作台内部布局与 tab 结构仍可对照,页面外壳与进入路径以本册 §3.1(a)与 PRD-1 §2.2 为准**,Demo 后续更新。 + +| PRD 条目 | Demo 演示位置 | 检查点 | +|----------|--------------|--------| +| WB-01 新建三路径 | 图例说明(决策 1,非演示项) | 图例标注空白/模板(首发 6 类)/导入本地项目三路径 | +| WB-02 选项式澄清 + 任务清单(§3.2 引言,体验呈现) | 左栏对话首轮 + 任务清单区 | 问题带候选选项、一轮问完;任务清单与执行进度可见 | +| WB-03 修错回流 | 预览内联回流条 | 「帮我修」一键回流 | +| WB-04 预览两态 | 预览 tab 顶部切换 | 桌面/移动切换 | +| WB-05/10/11 资源 tab(结构 + 知识库/模型子页) | 不在 Demo 范围 | 待 Demo 后续更新时补充演示 | +| WB-06/07/08 数据面 | 资源 tab · 数据子页(Demo 当前为独立数据 tab,以本 PRD 为准,Demo 后续更新) | 网格查改、导入入口、附件列、草稿/生产自动跟随标识、生产行编辑二次确认 | +| WB-12/13 代码玻璃盒 | 代码 tab | 只读文件树、运行日志子页、导出按钮、无终端/SQL | +| WB-14 发布面 | 发布 tab | 能力声明完整白话清单、审批状态、版本列表、链接+二维码;运营动作区/危险操作区/改码权归属区(Demo 未含,以本 PRD 为准,Demo 后续更新) | +| WB-15 代码版本管理 | 顶栏「存档 v3」按钮(示意) | 每轮自动生成代码版本,顶栏按钮即代码版本面板入口;回滚与差异查看不在 Demo 范围 | +| WB-16 草稿 owner-only | 图例说明 | 非演示项,图例标注 | +| GOV-01 可见范围授权 | 发布面「可见范围」弹窗 | 权限弹窗形态、不占 tab;**主入口实为构建页卡片 ⚙️ →「管理权限」**(与工作流 / 助手同一交互),发布面按钮拉起的是同一个弹窗(PRD-1 GOV-01) | +| WB-05 密钥子页 | 资源 tab · 密钥子页(Demo 当前为发布面弹窗,以本 PRD 为准,Demo 后续更新) | masked 录入、只见引用名与掩码 | +| RT-03/04 发布审批 | 发布面审批状态 | 首发与迭代审批状态流转(Demo 当前文案为「迭代免审」,以本 PRD 为准,Demo 后续更新) | +| RT-06 自测数据去留 | 发布 tab 自测数据去留确认区(Demo 当前为发布 tab 确认区,以本 PRD 对话流选项式提问为准,Demo 后续更新) | 选项式定夺、默认清空 | +| GJ-01/02、WB-09、RT 其余、GOV 管理面、DEV 通道 | 不在 Demo 范围 | 图例标注或另行评审(Demo 只演示工作台本身;列表页、治理面与 DEV 通道另行评审) | + +--- + +## 附录 A 预置模板首发清单(WB-01) + +本附录收录原《3.0 附录:预置模板首发清单调研》的结论,证据原文见 git 历史中的该文件。 + +**取舍依据。** 业务人员自建应用的主要形态是「收集数据、流转、记账、看数据」:表单收集上报、轻审批、业务台账和自动看板。央国企客户与通用企业的区别在于「上级要查、必须留痕」,所以检查整改、台账和层层填报类应用的案例最多,CRM 和获客类应用在央国企基层几乎不出现。首发 6 类模板取通用企业和央国企两类证据的交集(模板 1、2、3、6),再加上 BiSheng 有差异化能力的两类(模板 4、5)。每个模板是「一张表、一条流程、一个看板」的微应用,不做行业大套件。 + +| # | 模板 | 场景示例 | 用到的平台能力 | +|---|---|---|---| +| 1 | 信息收集上报 | 日报、走访记录、报名登记、问卷、基层报数汇总 | 数据库、身份自动带入、数据后台 | +| 2 | 业务台账 | 物资、设备、合同、合规台账,导入导出与查询 | 数据库、Excel 导入导出、数据后台 | +| 3 | 检查整改闭环 | 巡检和安全检查记录、隐患登记、整改跟踪、汇总看板 | 数据库、身份、状态流转、看板、消息通知 | +| 4 | 数据看板与 AI 解读 | 上传 Excel 或选择数据表,生成图表看板和解读结论 | 数据库、模型 | +| 5 | 知识问答页 | 部门 FAQ、制度问答,对接平台知识库 | 知识库检索、模型、身份 | +| 6 | 轻审批与督办跟踪 | 用章、外出等申请登记,事项督办台账 | 数据库、身份、状态流转、消息通知 | + +模板 6 只承载业务自建的轻流转和督办台账,正式的组织级审批仍走平台审批中心。 + +**第二批候选(按客户需求触发)**:项目与任务跟踪、CRM-lite、班组报工、在线考试与培训考核、党建学习台账。 + +**明确不做**:值班排班(约束求解问题,不是表单形态)、电力两票、IoT 与视频监测、MES/ERP/WMS 级系统、正式公文流转、财务共享与监管报送、依赖扫码/GPS/离线的移动采集型巡检。 + diff --git "a/docs/PRD/3.0-release/3.0 \345\272\224\347\224\250\345\267\245\345\234\272 \344\272\247\345\223\201\346\226\271\346\241\210.md" "b/docs/PRD/3.0-release/3.0 \345\272\224\347\224\250\345\267\245\345\234\272 \344\272\247\345\223\201\346\226\271\346\241\210.md" deleted file mode 100644 index 61918f0f79..0000000000 --- "a/docs/PRD/3.0-release/3.0 \345\272\224\347\224\250\345\267\245\345\234\272 \344\272\247\345\223\201\346\226\271\346\241\210.md" +++ /dev/null @@ -1,399 +0,0 @@ -# BiSheng 3.0 产品方案:应用工场 - -> 版本:v3.0(2026-07-24) 状态:待评审;已纳入资深架构师技术架构 review(见同目录 review 文档);评审通过后进入 release-contract + SDD 拆解 -> 命名说明:能力域名暂定「应用工场」、工作台入口动词暂用「造应用」,均非最终命名(见 §5.4 开放问题) -> 调研支撑:8 轮竞品/引擎/UX/平台盘点调研(关键事实均经一手来源核实),索引见附录 - ---- - -## 0. 摘要 - -**BiSheng 3.0 让不会编程的业务人员在平台里对话式造应用,并让每一个应用天生长在企业治理之内。** 三句话说完: - -1. **造**:浏览器内「对话 + 实时预览 + 一键发布」的造应用工作台;引擎复用开源 coding agent(可替换适配层,不自研不绑定)。 -2. **接与跑**:应用跑在平台沙箱运行时;经统一的企业能力总线接入企业的知识库、模型与身份(能力总线的技术构成见 §3.4)——生成的应用天生认识组织架构和权限。 -3. **活与管**:每个应用绑定一个常驻**应用管家**——造它、改它(发布后迭代 = 继续对话)、守它(定时解读数据、主动预警);发布审批、权限、配额、审计默认在场。(「守」态本版先定稿规格 + 预留数据表,运行时是否本版落地待定夺,见 §3.2/§5.4) - ---- - -## 1. 背景与痛点 - -### 1.1 背景:应用生产方式正在换代 - -- **对话取代画布**:字节把扣子的拖拽画布整体撤下,换成对话式生成(扣子编程);百度秒哒 8 个月生成 50 万应用、81% 用户非程序员;美团 NoCode 非技术岗使用者是技术岗的 3 倍——「说需求出应用」已被市场验证,且主力用户是业务人员而非开发者。 -- **企业侧同样成立**:招商银行低代码体系年新增应用 2831 个、59.4% 出自业务人员——但银行对 AI 编程明确「审慎」(技术债与治理顾虑)。 -- **引擎已是公共品**:Qwen Code / Codex CLI(Apache-2.0)、Kimi Code CLI(MIT)等开源 coding agent 成熟且可自由内嵌,平台不必自研引擎。 - -**结论:应用的稀缺环节不再是「会不会造」,而是「有没有一个带企业上下文、有治理的地方去造和跑」。** - -### 1.2 痛点:业务人员的三个诉求 + IT 的一个焦虑 - -来自内部用户/客户的真实原声: - -1. **「我能不能直接在 bisheng 上 vibe coding,把企业的用户体系、知识库、数据都接进应用里?」**——想造,但缺一个带企业上下文的地方(主诉求)。 -2. **「我用 Claude Code 在本地开发了应用,希望部署到 bisheng 让大家访问;应用不稳定,但平台要正常。」**——造完了,没地方跑、没人能访问。 -3. **「应用搭好了,需要一个 agent 持续为我解读、分析、预警。」**——跑起来了,还想让它自己会看数据。 - -IT 侧的焦虑是 shadow AI:员工绕开 IT 用公共 AI 工具造应用 → 数据外泄、无人运维、审计缺失。IBM《Cost of a Data Breach 2025》:20% 数据泄露事件涉及 shadow AI(均损 $4.63M);Lovable 生成的 170+ 应用数据库对匿名请求裸奔(CVE-2025-48757)。**「与其堵,不如给一条有治理的路」——这是 IT 部门愿意引入本能力的根本理由。** - -### 1.3 市场空档 - -| 玩家 | 现状 | 空档 | -|------|------|------| -| 扣子编程 | 对话式造应用 + 一键托管,形态最完整 | **只在云端 SaaS**,私有化无供给 | -| Coze Studio 开源版 | 无 vibe coding / 托管能力,2026.02 更新放缓 | 开源侧留白 | -| Dify | 纯配置式工作流范式(DSL) | 无代码应用生成/托管信号 | -| Lovable / bolt / v0 / Replit | UX 机制成熟(本文大量借鉴) | 公有云 SaaS + 安全事故频发 | -| Databricks Apps | 验证「平台托管内部应用」的真实需求(正式发布前半年即 2500+ 组织 / 2 万应用) | 绑定自家数据栈,不做国内私有化 | - -**「私有化环境下的对话式造应用 + 托管 + 治理」目前无人占位;扣子在教育市场,窗口在收窄。** - ---- - -## 2. 产品定位与设计理念 - -### 2.1 定位 - -``` -BiSheng ≤2.6:平台即 IDE —— 应用用画布在平台内搭,表达力受画布上限约束 -BiSheng 3.0 :平台即应用工场 + 运行时 —— 业务人员对话式造应用, - 应用跑在平台沙箱里、接着企业的知识/模型/身份、由常驻管家照看, - 治理默认在场 -``` - -差异化判断:**引擎是公共品,竞争在「壳」**。对话生成应用的能力,各家很快都会有;但应用要真正在企业里活下来,需要的是身份与权限、知识库与数据、审批与审计——这些恰好是 BiSheng 已经建成的部分,而 Lovable/扣子们要么没有、要么短期给不出私有化供给。生成的应用在 BiSheng **天生认识企业组织架构、天生长在治理之内**,这是本方案真正的护城河。 - -### 2.2 设计理念(三条) - -**① Vibe with the agent, not the code —— 应用管家。** 业务人员相处的对象不是代码,而是一个长期存在的 agent;代码只是它的产物。每个应用绑定一个管家,贯穿三种状态: - -- **造**:从一句话需求开始,澄清、生成、预览、发布; -- **改**:发布后迭代 = 继续对话(「把导出加个 Excel 格式」),管家在草稿沙箱改好、预览确认、点更新上线——业务人员永远不需要「打开工程」; -- **守**:设定巡检节奏后,管家按时间或事件唤醒,解读应用数据的增量,值得说才说,把 30 秒能读完的简报推到企微/飞书/站内信。 - -造它的、改它的、守它的是同一个 agent,**会话随应用终身存续**、上下文零移交——这是对「session 用完即弃」类产品的结构性差异。 - -**② 引擎复用,不自研、不绑定。** coding agent 是快速演进的公共品,自研必然落后;绑定单一引擎则被别人的断代绑架。平台通过可替换的引擎适配层(§4.2)驱动开源 coding agent,默认引擎可按租户配置、可随生态演进更换。 - -**③ 平台管安全,AI 只管功能。** 「让 LLM 生成正确的鉴权代码」被证明不可靠(Lovable 170+ 应用数据库裸奔),扫描器也只能查有无、查不出对错。BiSheng 不把安全押在「AI 写对代码」上,而是由平台运行环境强制保障:生成应用默认仅企业内可见、所有流量过平台身份网关、数据访问走平台注入的租户隔离连接、沙箱出站白名单、发布走审批。AI 生成的代码只负责业务功能——写得再差,也越不过平台划定的安全边界:代码里没有裸连接串,也没有自己发明的登录。 - -### 2.3 目标用户与核心旅程 - -| 用户 | 要什么 | -|------|--------| -| **业务人员**(主用户,非程序员) | 把脑子里的表单/看板/小工具变成同事能用的应用,不学编程、不等排期 | -| 平台管理员 / IT | 业务自建应用看得见、管得住、出事能兜底 | -| 业务技术员(会用 Claude Code 的少数派) | 本地开发的应用部署到平台给大家用 | -| 普通业务用户 | 在应用广场找到并直接使用同事造的工具 | - -一段旅程:销售运营专员说「做一个销售周报看板,数据用上周台账」。管家澄清两个问题后开始工作,她看着右侧预览成形,中途说「大客户单独列一栏」——管家查了知识库里的口径改好了。点发布、主管审批,同事用企业账号直接打开,各自只看到自己权限内的数据。一个月后她说「每周一早上把异常客户推到企微群」——同一个管家接下了这活。**她全程没看过一行代码,但代码一直都在那里,IT 全程可见可审。** - ---- - -## 3. 产品功能结构 - -``` -应用工场 -├─ 造应用工作台 对话 + 实时预览 + 发布(业务人员主界面) -├─ 应用管家 造 / 改 / 守 三态一体的常驻 agent -├─ 应用运行时 沙箱托管、统一入口、版本回滚(草稿与生产同一套设施) -├─ 企业能力总线 API key · 双协议模型代理 · MCP Server · SDK -├─ 治理面 发布审批 · 权限 · 配额 · 审计 · 日志 -└─ 专业开发者通道 CLI 部署旁路(本地开发的应用走同一管线) -``` - -### 3.1 造应用工作台 - -核心机制(取六家对标产品的收敛范式,结合 BiSheng 独有优势): - -| 机制 | 规格 | -|------|------| -| 一句话起步 + 澄清前置 | 写码前先「梳理需求 → 确认要点 → 匹配平台资源(知识库/数据表)」,避免零碎指令越改越乱;澄清交互**以选项式为主**——管家把问题连同候选答案一起给出,业务人员点选即可,尽量不要求打字描述技术细节,且一轮问完、数量有界(复用灵思任务模式澄清环与选项式提问的成熟经验) | -| 三分区工作台 | 对话 + 实时预览为主界面;代码是可选玻璃盒——可见、可改、但不必看(纯黑盒出问题没有逃生通道) | -| 想 / 做分离 | 规划态(只讨论不改码)与执行态显式区分 | -| 每轮自动存档(含数据) | 每次改动自动快照 = 代码 + 对话上下文 + 应用数据库;**草稿/代码回滚无损**(生成新版本卡、历史可重放),**生产数据默认不回滚**、schema 演进需迁移策略(回滚语义见 §3.3) | -| 修错免费 + 报错一键回流 | 「帮我修」不额外计量;报错/截图一键贴回对话(SaaS 按 token 计费的 bug-loop 是全行业最大口碑杀手,私有化部署天然免疫) | -| 基础组件平台自带 | 数据库/对象存储/登录/权限这些基础组件由平台提供、开箱即用,**不让 AI 从头写**:应用数据库 = **平台托管、按 app 独立**(小档 SQLite-per-app / 中档 Postgres schema,与平台 MySQL·DM8 解耦——生成代码不认 DM8 方言,故不复用平台库;单文件 SQLite 顺带简化每轮存档,租户隔离连接注入);登录与权限 = 平台身份桥接,生成应用天生认识组织架构(如何递到管家手里见 §4.4) | -| 自动数据后台 | 业务应用大多是「表单 + 列表 + 看板」——生成应用自动附带数据管理页(查/改/导出/回溯) | -| 草稿 / 生产分离 | 管家只碰草稿沙箱;「发布」才产出独立生产实例;线上回滚与草稿存档是两套、都要有 | -| 预置模板起步 + 组织模板库 | 不从空白生成:**首发 6 类模板**——信息收集上报 / 业务台账 / 检查整改闭环 / 数据看板+AI 解读 / 知识问答页 / 轻审批·督办跟踪(经通用模板市场实证 + 央国企场景两路调研定夺,详见附录《预置模板首发清单调研》);每个模板 = 「一张表+一条流+一个看板」的微应用,不做行业大套件;同事造好的应用可一键复刻到自己空间再改,可见范围走权限体系——好应用在企业内自发扩散 | -| 逃生梯度 | 报错回流 → 代码玻璃盒 → 导出工程;后续版本加点选局改(Design Mode 式) | - -### 3.2 应用管家 - -- **造 / 改**:见 §3.1;**管家的会话随应用终身存续**——工作区快照 + 对话历史与应用绑定存储,不随发布、容器回收或时间流逝而丢失;应用创建三个月后回来提一句需求,管家仍带着全部上下文接得住。 -- **守**(持续看护上线应用)——本版先把「守」态的规格定稿并预留数据表;**是否在本版就实现,待定夺**:建议等开发进入后半段、按剩余人力决定,不足则顺延 v3.1(见 §5.4)。**底层依赖一个新建的平台触发/调度子系统**(cron/webhook 触发器、常驻生命周期、预算闸、机队观测)——灵思 worker 是一次性会话模型、丢弃终态队列项、无 cron/webhook、默认 compose 都不起,非直接复用;管家是该子系统首个消费者,工作流定时/知识库定时同步同样可复用(见 §4.6)。规格如下: - - 触发:定时(cron)+ 事件(webhook)——由平台触发/调度子系统提供(新建); - - 省钱:数据探测零 LLM(与上次游标比对,无增量不唤醒模型);使命进 system prompt,每次唤醒只注入增量元数据(token 成本不随运行次数膨胀); - - 降噪:两级过滤(有没有新 → 值不值得打扰);每次唤醒只发一份合并简报(一句话结论 + 分条信号 + 建议动作);无信号绝不发「本轮无事」;宁少报不多报; - - 推送:经**新建的通知服务**发出——站内信(现有)+ 企微/钉钉/飞书群机器人 + 通用 webhook(借 workflow 群机器人 webhook 逻辑;org_sync 仅入站目录同步、无外发能力,平台今日无通用外发通知服务);**收件人创建时锁定,agent 不得动态选择**; - - 成本:创建时出示花费预估(巡检频率 × 模型)确认后生效;预算上限超限自动暂停; - - 观测:看护总览面板(状态灯 / 上次运行 / 累计花费 / 简报历史);简报卡片可嵌入(iframe)到第三方系统页面。 - -### 3.3 应用运行时与发布 - -- **应用包规范 `bisheng-app.yaml`**(发布时由管家自动生成): - -```yaml -name: sales-weekly # 入口路径 /apps/sales-weekly -runtime: python3.11 # python3.11 | node20 | static -entry: streamlit run app.py --server.port $PORT -capabilities: # 声明式能力申请,审批即授权 - knowledge: [kb_123] - models: [qwen3-max] - secrets: [webhook_token] # 应用级密钥引用;值存平台 secret store(Fernet),注入同 capabilities -appdb: sqlite # sqlite(小档,单文件)| postgres(中档,独立 schema) -resources: small # small | medium 固定档位,杜绝配额博弈 -``` - -- **统一入口**:`/apps/{slug}`,同事免二次登录直接访问;应用广场与工作流/助手并列呈现。 -- **稳定性承诺**:应用容器与平台进程零共享,单应用崩溃只自愈该容器,平台与其他应用不受影响。 -- **版本与回滚**:每次发布生成版本记录,一键回滚——**代码/配置回滚无损;生产数据默认不回滚**(有状态生产应用回滚会丢回滚点后的数据),republish 涉 schema 演进需策略(仅增列 / 迁移前快照 / 人工确认)。 -- 本版不做:k8s 多机、公网域名、应用间编排、GPU、自动扩缩容、Dockerfile 自定义构建。 - -### 3.4 企业能力总线 - -- **API key 体系**:个人 key(本地开发者)/ 应用 token(发布时按 capabilities 签发)/ 会话 key(工作台沙箱随会话吊销);scope + 配额 + 可吊销;替换现有 v2 开放 API 的「内网可信」模型。 -- **双协议模型代理**:对外暴露 OpenAI 兼容 + Anthropic 兼容两个协议面,任何讲两种协议之一的引擎(含客户自装的 Claude Code)都能直连平台托管模型。做 Anthropic 面的依据:DeepSeek/智谱/Kimi/Qwen/MiniMax 五家均官方提供 Anthropic 兼容端点,该协议已成国产模型事实第二标准。**实现上采用现成开源代理(LiteLLM / one-api 类)承载协议面,不自研 Anthropic `/v1/messages` 长尾**(content blocks / tool_use / SSE / thinking / prompt caching 语义);**BishengLLM 退居 provider 路由的唯一真相**(现成代理经其**自定义 provider/回调扩展点**调用 BishengLLM 解析租户模型配置与端点——BishengLLM 只提供解析、不自建 HTTP 面;LiteLLM 可作库内嵌 backend、one-api 类可独立进程,二者皆经此扩展点回连,不重实现、防两层漂移)。三个必钉约束:① license 核查(LiteLLM 企业功能单独授权、商业私有化须确认;one-api/new-api 为 MIT);② 记账回流一处(外部代理 per-key 用量喂回 `llm_token_log`/配额——现记账是 `on_llm_end` 按叶子租户、须补 per-key 归属,避免两套账单);③ 与 §4.3 auth_profile 三路径协同。平台今日无此模型网关代理(现有 `/api/v2/assistant/chat/completions` 是按 UUID 分发到 Assistant、非裸模型透传)。 -- **BiSheng MCP Server**(streamable HTTP,key 鉴权,scope 过滤):知识库检索、模型清单、用户身份/组织查询、应用数据库 schema/查询(管家迭代应用时用)、应用状态自查;数据语义层(本体)查询作为后续扩展方向。平台自己的灵思/助手/工作流同样可消费——内外同源。 -- **bisheng-sdk**(Python 首发):auth / retrieve / chat / storage / appdb 五件套薄封装,模板预接线。 - -### 3.5 治理面 - -- 应用/工作台会话/管家均为权限体系(OpenFGA)资源类型,授权复用现有通用授权交互; -- 发布走审批中心(F025 新场景):capabilities 变更、可见范围扩大触发重审; -- 全链路审计(谁造了什么、接了哪些知识库、谁在用)、按 key 的用量账单、租户级容器与 token 配额; -- **引擎与模型接入统一由管理员配置**:用哪个引擎、走平台模型代理还是客户订阅、凭据档案的创建与分配,全部是管理面决策(§4.3),业务人员零感知; -- 灰度手段:应用工场按租户/用户组开关。 - -### 3.6 专业开发者通道(旁路) - -`bisheng` CLI(init / deploy / logs)+ 应用模板 + `.mcp.json` 预配置:本地用 Claude Code/codex 开发的应用,经同一发布管线、同一审批与治理上平台——本地造的和平台造的,在治理面前无差别。客户自装 Claude Code 时经双协议代理连平台托管模型、经 MCP 接企业上下文。 - ---- - -## 4. 技术架构方案 - -### 4.1 总体架构 - -``` -业务人员浏览器(对话 + 预览 + 玻璃盒) - │ WS/SSE(归一化事件流) -BiSheng backend(编译期/运行期零 docker 依赖,arch-guard 加 import 禁令强制) - ├─ vibe_builder 模块:会话编排 / 引擎适配层 / 快照恢复 / 发布编排 - ├─ open_platform 模块:API key(三类)/ OBO token / 配额 - ├─ 模型代理(现成代理 LiteLLM/one-api 承载协议面 + BishengLLM provider 路由) - ├─ mcp_server 模块:平台能力 MCP 化 - └─ app_runtime 模块:manifest 生成 / 向 runtime-manager 下发期望态 intent(声明期望、不直连 docker) -runtime-manager(独立最小权限编排器,唯一持 docker 访问) - ├─ intent 驱动 reconcile:构建 / 容器生命周期 / 日志探活 / 崩溃自愈 - ├─ 准入控制 · idle 回收 · 并发上限 · cgroup 硬隔离(容量/密度治理) - └─ 沙箱容器(草稿态与生产态同一套设施) - ├─ 草稿:引擎 CLI + 工作区 + dev server(预览经 /apps/preview/{session}/) - └─ 生产:应用容器(/apps/{slug}/) -app-proxy(版本无关鉴权代理,唯一暴露 /apps):验会话 + 签 OBO + 注入头 + 反代容器,支持 WebSocket - └─ nginx(开源)与 Java 网关(商业)都只把 /apps 转发到它——安全逻辑一处实现、不随部署形态分叉 -``` - -工作区与会话持久化:**每轮修改自动存档 = 工作区文件 + 对话上下文 + 应用数据库**,统一落 MinIO 并与应用绑定——这既是工作台「回滚」的承载,也是「管家会话随应用终身存续」的承载:容器随时可回收,回来即恢复。**承载方式据实**:灵思 workspace 是 per-file write-through 对象缓存(非时间点快照),无法直吃 builder 工作区的 git + node_modules(万级小文件、git 语义),故须**新增 tar/bulk-snapshot 层**(应用库选单文件 SQLite 后,数据部分的存档简化为「tar 一个文件」);「无损」限定草稿/代码,生产数据回滚语义见 §3.3。前端归属:工作台放 client(业务人员所在 SPA,初版交互稿:https://claude.ai/design/p/8e7bca96-b23b-4901-9468-80ad5bbad5e9?file=%E5%BA%94%E7%94%A8%E5%B7%A5%E5%9C%BA+Demo.dc.html&via=share ),管理面放 platform。 - -### 4.2 引擎适配层与选型 - -**RuntimeAdapter 抽象**(Python),按「CLI 进程 + JSONL/JSON-RPC 事件流」建模: - -- 统一 `run(RunConfig) → {sessionId, text, structured, usage}` 契约;公共必备只有 run(),undo/compact/steer 按引擎能力可选——差异不抹平、在上层显式分叉; -- 流式事件归一化为五类(text_delta / thought / tool_use / tool_input_delta / tool_result)上抛,前端渲染与引擎解耦,换引擎不动 UI; -- 引擎可用性探测 + 依赖注入;引擎选择同样是管理员配置的结果(用户组覆盖 > 租户默认 > 平台默认),业务人员不感知引擎; -- 模型与凭据:适配层不关心引擎用什么模型、以谁的身份跑——统一由「凭据档案」承载(见 §4.3); -- 指令单一来源:CLAUDE.md/AGENTS.md 软链或编译,避免多引擎指令漂移。 - -**引擎矩阵(2026-07 调研 + 对抗验证,一手来源核实)**: - -| 引擎 | License | 定位 | 依据 | -|------|---------|------|------| -| **Qwen Code** | Apache-2.0 | **默认引擎首选候选** | 官方四协议(OpenAI/Anthropic/Gemini/Qwen)+ vLLM;`qwen -p` 无头 + SDK;独立演进约一年、血统成熟;阿里出品,私有化/国产化叙事最顺 | -| **Codex CLI** | Apache-2.0 | **第二适配器 / 协议标杆** | 嵌入协议面最强(`exec --json` JSONL + app-server JSON-RPC 生产级);prompt 为 GPT 系调优,国产模型实效需 PoC | -| **Kimi Code CLI** | MIT | **第三适配器 / 观察位** | 六 provider 原生覆盖双协议面;对国产 reasoning 模型经网关转发有独有的 `reasoning_key` 一等容错;K2.7-code 开源权重可组全国产组合;硬伤 = 仓库仅 2 个月、0.x 日抛发版、刚经 Python→TS 断代——先做兼容性 spike,锁版本观察至发版收敛;客户已有 Kimi 订阅/自托管 K2.7 时提前落地 | -| opencode | MIT | 暂不入列 | server-first 架构本最合适(189k stars),但正处 2.0 破坏性重写期(V1 API 将断代),openai-compatible 流式层 bug 活跃(含中文流式编码错误 closed-not-planned);观察至 2.0 稳定 | -| Claude Code / Agent SDK | 专有(All rights reserved) | **永不内嵌,BYO 兼容位** | 无分发授权(npm license 一手验证)+ 对中资实体销售限制;客户自装后经平台双协议代理 + MCP 接入 | - -**Wave-0 PoC(先测后定)**:薄版 adapter × {Qwen Code, Codex CLI}(Kimi Code 可选 spike)× {GLM-5.2, DeepSeek-V4, Qwen3.7-Max}(经平台代理)× 3 个代表性造应用任务(取自首发模板:信息收集上报 / 数据看板+AI 解读 / 知识问答页)。评测口径不止「单发成功率」——**真正的失败模式是「demo 能跑、多轮飘」(tool-calling 可靠性经翻译层退化,灵思已踩过),单发任务测不出,故须含多轮鲁棒性口径**(连续修改/纠错下的稳定性);按成功率/多轮鲁棒性/轮次/token 成本设**量化 go/no-go gate** 定默认引擎。**同时把「模板生成器」纳入 PoC 作对照基线**——「业务应用大多表单+列表+看板」意味着模板/DSL 可能不止是 fallback、甚至该是主干、coding agent 作逃生梯度;引擎 vs 模板的主次由 PoC 数据决定、不预设。**引擎在国产模型上的实效是全案最大技术未知数;若结论是「无任何开源 coding agent 能在国产模型上稳定多轮驱动造应用」,模板生成器即成主干退路——「不自研引擎」原则本身没有退路(Claude Code 永不内嵌)。** - -### 4.3 引擎凭据与模型接入(coding agent 用什么模型、以谁的身份跑) - -引擎跑起来需要两样东西:**模型访问**(调哪个大模型)和**身份凭据**(以谁的名义调)。平台把两者统一抽象为**凭据档案**——引擎适配层 RunConfig 上的一个 `auth_profile` 字段,与引擎种类正交,换路径不换适配层。 - -**立场先说清:走哪条路径、用哪份凭据,是管理员的部署配置决策,不是业务人员的选择。** 下述 A/B/C 三条路径全部在管理面配置生效,业务人员在工作台里永远只是「开始造应用」,对引擎、模型、凭据零感知。 - -``` -凭据档案 = { 类型: 平台模型代理 | 订阅登录态(Claude Code / Codex / Kimi)| 厂商 API key, - 内容: 加密存储(复用平台 Fernet 加密),如 ~/.claude 目录、auth.json、key, - 分配: 由管理员创建并分配(全租户 / 用户组 / 指定用户), - 并发上限: 同一档案同时最多 N 个会话 } -``` - -**路径 A:平台模型代理(默认,规模化主路径)。** 引擎经能力总线的双协议模型代理(§3.4)使用平台托管的国产模型,接线方式按引擎所讲协议分两种: - -- Anthropic 协议引擎:会话容器内设 `ANTHROPIC_BASE_URL/AUTH_TOKEN` 指向平台代理(token = 会话 key),并把 opus/sonnet/haiku 三档默认模型映射到租户配置的强/中/快三档国产模型(duoduo `compatible_endpoint` 的成品做法); -- OpenAI 协议引擎:同理设 base_url + 会话 key;对 Kimi Code 这类不读 shell 环境变量凭据的引擎,改为 per-session 生成其 config.toml 注入容器。 - -治理全在平台手里:会话 key 随会话签发/吊销、scope 最小化、配额限流、token 记账到人。这是唯一不依赖任何外部账号可得性的路径,适用于所有客户。 - -**路径 B:客户订阅(BYO 凭据:Claude Code / Codex / Kimi 席位)。** 订阅的登录态本质是一份磁盘凭据(`~/.claude` 目录 / `$CODEX_HOME/auth.json`);duoduo 已验证「凭据完全委托给 CLI 自己的登录态」的机制可行——其 `claude_code_local` 短路模式:**不注入任何 `ANTHROPIC_*` 环境变量,且启动时反向清除环境里残留的 API key 防止劫持订阅路径**(此细节照抄)。平台化为三个环节: - -1. **绑定(一次性,管理面操作)**:管理员在后台发起——服务账号型订阅由管理员直接完成官方登录流(订阅无头场景有 setup-token 长期令牌方式;Codex 走设备码登录);个人席位型由管理员向席位持有人发送授权邀请,对方完成一次授权即收档。登录态落盘 → 加密入档,管理面可随时吊销; -2. **注入(每次会话)**:沙箱容器启动时把档案挂载为容器内 `$HOME/.claude`(或设 CLAUDE_CONFIG_DIR)/ `$CODEX_HOME`,走短路模式; -3. **治理(平台必须补的三件)**:**并发闸**——订阅限额按账号计(滚动窗口 + 周上限),档案级并发上限(建议 1-2),限额报错友好化(「订阅额度用尽,xx 点重置」);**归属**——计费虽在订阅侧,平台仍从引擎 usage 事件流记每人每会话的 token 台账;**隔离**——档案只注入其分配范围内用户的会话容器,分配与使用全程审计。 - -两点注意:①**全员共用一个订阅账号不可作为产品承诺**——违反消费者条款(账号共享)、限额不够分(两三个并发会话即顶满)、用量无法归属到人;只可小规模试点,合规路径是每人/每组一个席位;②**Claude Code 二进制不得打进平台镜像**(无分发授权)——客户自装到宿主机或自备镜像层,平台以挂载方式调用;Codex CLI(Apache-2.0)可直接内置沙箱镜像。 - -定位:BYO 增值路径——已有订阅的客户白赚顶级引擎质量;规模化主路径仍是路径 A。 - -**路径 C:客户自有厂商 API key。** 企业已采购的模型 API(DeepSeek/GLM 等)建议配置为平台代理的上游、仍经代理转发——保留记账/限流/审计;直连厂商 endpoint 作为例外场景(档案类型 = API key,注入方式同路径 A)。 - -**路径解析与降级(均为管理员配置的结果)**:解析链 = 用户组级配置 > 租户级默认 > 系统默认(平台代理),全部在管理面设定,业务人员无从选择也无需选择;「订阅额度用尽自动降级切平台代理」同样是管理员预配的策略开关——对业务人员只表现为「一直可用」,最多收到一条降级提示。同一套适配层,只换 `auth_profile` 的取值。 - -**引擎 × 路径兼容矩阵**: - -| 引擎 | 平台模型代理 | 客户订阅 | -|------|-------------|---------| -| Qwen Code | ✓(双协议均可) | —(无订阅形态) | -| Codex CLI | ✓(OpenAI 面) | ✓(ChatGPT 席位) | -| Kimi Code CLI | ✓(双协议,生成 config.toml) | ✓(Kimi For Coding) | -| Claude Code(客户自装) | ✓(Anthropic 面) | ✓(Claude 席位) | - -**统一治理底线**(无论哪条路径):凭据只进属主会话容器、沙箱出站白名单只放行对应 endpoint、用量台账到人、全程审计。 - -### 4.4 基础组件注入机制(不让 AI 写的部分,怎么递到管家手里) - -数据库/登录/权限/存储这些基础组件「平台自带、AI 不写」,靠四条通道让管家**知道有、直接用、不越界**: - -1. **模板预接线**:预置模板里 SDK 已引入,读当前用户、查数据表、检索知识库的样例调用就在代码里——管家照现成范式扩展,而不是自由发挥; -2. **声明式资源清单**:应用要用什么(数据表/知识库/模型)写进 manifest 的 capabilities,平台在容器启动时注入凭据与连接——代码里永远不出现连接串和密钥; -3. **MCP 工具面**:建表/改表结构/读写数据/查身份/检索知识库都是平台 MCP 工具,管家调用工具完成,而非生成底层实现代码; -4. **引擎指令约束 + 纠偏**:引擎系统指令明确禁写鉴权/数据库连接/加密逻辑,管家若违规生成则视为错误、引导改写为平台组件调用——「引导」这一层若失效,还有 §4.5 的运行环境兜底(代码里没有凭据可泄、网络上没有旁门可走)。 - -### 4.5 沙箱与安全模型 - -| 隔离档位 | 实现 | 适用 | -|----------|------|------| -| 基础档(默认) | Docker 加固:read-only rootfs、no-new-privileges、独立网络、资源限额、崩溃自愈 | 所有环境 | -| 推荐档 | + gVisor(runsc 换入 Docker runtime,**无需 KVM/k8s**) | x86_64/ARM64,标准信创虚机可用 | -| 加固档 | Kata/Firecracker microVM | 客户有裸金属/KVM 时可选 | - -选型依据:Firecracker/Kata 依赖 KVM,客户虚机普遍无嵌套虚拟化(信创云尤甚),不能作默认;内部应用的现实威胁是**数据外渗与越权**而非内核逃逸——**默认出站白名单**(仅放行平台 API 与显式声明域名)比换 runtime 更重要;LoongArch 等 gVisor 不支持的架构回退基础档。 - -**编排特权(全案最重安全约束)**:容器由独立最小权限编排器 **runtime-manager** 生管——它是唯一持 docker 访问的组件,backend 编译期/运行期零 docker 依赖(arch-guard import 禁令强制;取证:现网 backend 无 docker 依赖,`docker.sock` 仅出现在 CI);backend 只向它下发**期望态 intent**(声明式,manager 自 reconcile),绝不把 `/var/run/docker.sock` 挂进 backend 或起 DinD——否则控制面即握 root-on-host、与「安全来自笼子」(§2.2③)自相矛盾,私有化银行/信创客户安全团队必一票否决。runtime-manager 独立受限 service account、凭据与 backend 隔离;容量/密度治理(准入控制、idle 回收、并发上限、cgroup 硬隔离)挂在它上面,兜住「平台正常即使某应用崩」。 - -**出站白名单 × 依赖安装(构建/运行两段分离)**:生成应用要 pip/npm install(社区 #1568),运行期 egress 锁死后包怎么装?**构建期与运行期 egress 分离**——构建期只放行**平台自托管的 PyPI/npm 镜像**(白名单里的受控条目、非洞:版本可 pin、可缓存、信创可离线预灌),运行期不放行任何包源。 - -**鉴权注入(app-proxy 层,版本无关)**:`auth_request` 现网零使用、纯新建,且 Java 网关只认 `/api/**`、有自己的 Sa-Token/SSO + 自定义 WS filter、完全不认 `/apps`(据 `docs/architecture/11-gateway.md`)——故验会话 + 签 OBO + 注入头 + 反代容器统一收在 **app-proxy** 一个组件里,nginx(开源)与 Java 网关(商业)都只把 /apps 转发到它,安全逻辑一处实现、不随版本分叉: - -``` -浏览器 → nginx / Java 网关 → app-proxy /apps/{slug}/*(或 /apps/preview/{session}/*) - ├─ 验平台会话 + OpenFGA viewer 检查 - ├─ 签发短时 OBO token(scope = 应用 capabilities ∩ 用户自身权限) - ├─ 注入头:X-BiSheng-User-* / X-BiSheng-Access-Token - └─ 反代容器(支持 WebSocket) -``` - -两个躲不掉的不变量:① **header 防伪**——app-proxy 剥离客户端伪造的 `X-BiSheng-*` 头,容器仅经 app-proxy 可达(不暴露直连端口);② **WS 长连接的 OBO 过期/吊销策略**——握手时鉴权一次不够,长连接期间 OBO 过期或权限被撤须有中断策略。app-proxy 与 runtime-manager 是邻居,可同组件可拆。 - -- 应用启动注入应用 token(M2M,后台逻辑用);请求级注入 OBO 用户 token(以当前用户权限调知识库——知识库权限对应用内每个用户自然生效,「AI 可见性 ⊆ 用户 UI 可见性」不变量外推到应用场景)。 -- 应用侧零登录代码:读头即得身份,SDK 一行拿到。 - -### 4.6 平台既有资产复用 - -本表据代码取证据实标注复用度(✅真复用 / ⚠️部分或借经验 / ❌净新建)——避免把「以为能复用、实为新建」的隐性工作量埋进波次图: - -| 既有资产 | 用于 | 复用度(代码取证) | -|----------|------|------| -| OpenFGA ReBAC + 通用授权弹窗 | 应用授权、模板库可见范围 | ✅ 加类型近一行(`_standard_resource_type`),PermissionService 字符串透传无 switch;**但** `permission_schema.py:VALID_RESOURCE_TYPES` 已与模型漂移、须手工同步 | -| 审批中心(F025 场景化引擎) | 发布审批、看护任务花费审批 | ⚠️ 新场景须改两处硬编码(`with_default_presets()` 3 预设 + `build_runtime_handler()` 的 `if scenario_code==` 硬链,在 Celery outbox 路径);有先例不算重 | -| v2 知识库检索 API | MCP `knowledge_retrieve` 包装 | ✅ 但**须身份绑定**:`resolve_operator` 出真实 user_id + seed 租户 ContextVar 才守得住 INV-7 per-user 可见性 | -| BishengLLM 统一封装 + token 记账 | 模型代理 provider 路由、会话/管家账单 | ⚠️ **库可复用、HTTP 协议面净新建**:BishengLLM 是进程内库类、无 `/v1/messages`;记账 `on_llm_end` 按叶子租户、须改按 key 归属(协议面决定采用现成代理,见 §3.4) | -| 灵思澄清环 / 任务模式经验 | 造应用的需求澄清前置、想做分离 | ✅ prompt/UX 经验直接复用 | -| MinIO + 灵思 workspace | 工作区持久化与恢复 | ⚠️ **借 MinIO、非快照**:workspace_backend 是 per-file write-through 对象缓存、不吃 git+node_modules;须新增 tar/bulk-snapshot 层(见 §4.1) | -| 灵思 deep-agent + 中间件 + 模型容错 | 管家「守」态的**单次执行** | ✅ 单次执行可复用 | -| 灵思 worker / 队列 / 状态机 | 管家「守」态的**调度与生命周期** | ❌ **一次性会话模型**:worker 丢弃终态队列项、无 cron/webhook、默认 compose 都不起——常驻实体的触发/调度/生命周期是净新建平台能力(见 §4.1、§5 F108) | -| org_sync 企微/钉钉/飞书 | 管家推送 | ❌ **零外发**:org_sync 纯入站目录同步;平台今日无通用外发通知(仅站内信 + 一个客户专属转发器);须新建通知服务、借 workflow 群机器人 webhook 逻辑 | -| share_link + iframe.js | 简报卡片与应用嵌入 | ⚠️ **postMessage + 域名白名单净新建**:现 iframe 嵌 chatbot 非简报卡;订阅/文章简报 iframe 虽有 postMessage 但 `targetOrigin '*'` 无 origin 校验,可借鉴但须补白名单 | -| E2B executor copy-in/out 先例 | 沙箱文件交换 | ⚠️ 现 code interpreter **默认走 LocalExecutor(worker 原生子进程、无沙箱)**、E2B 远程执行器非默认;本地 docker 沙箱对两者都是净新建 | - -**净新建核心基建(不在复用表内、却决定成败,须显式排期与安全评审)**:① **runtime-manager**(独立最小权限编排器,唯一持 docker,全案最重最险,安全评审前置);② **app-proxy**(版本无关鉴权代理,取代 auth_request、消解网关冲突);③ **模型代理协议面**(Anthropic/OpenAI 兼容,采现成代理承载);④ **workspace tar/snapshot 层**;⑤ **平台触发/调度子系统**(管家守态、工作流定时、知识库定时同步共用);⑥ **应用级 secret store**(Fernet,见 §4.7)。 - -### 4.7 合规与风险 - -合规预判:新模块 DDD 分层(C1);新表 MySQL/DM8 双方言(C2,**但生成应用自己的库不受 C2 约束、走 SQLite/Postgres**);全部带 tenant_id、容器/镜像/存储前缀租户隔离(C3);应用/会话/管家均为 OpenFGA 资源类型、预览与生产统一过 **app-proxy** 无旁门(C4);错误码遵 **C5**(非 C6;C6 是禁硬编码密钥)——段位须避开已占用段(130=chat、160=dataset),建议 161 起 4 段、最终以 release-contract 定;应用级 secret store 复用 **C6** Fernet;双前端纪律不变(C7)。 - -| 风险 | 对策 | -|------|------| -| **引擎在国产模型上的实效未知(最大技术风险)** | Wave-0 PoC 先行;适配层保证引擎可换;模板 + 澄清前置降低对模型上限的依赖 | -| 引擎 API 漂移/断代 | 引擎依赖收口在 RuntimeAdapter 单模块;pin 版本 | -| 造应用会话 token 成本 | 会话 key 配额 + 单轮/单会话预算上限 + 模板起步减少从零生成;成本对用户可见 | -| 跑任意代码的安全暴露 | 沙箱分档 + 出站白名单 + capabilities 最小授权 + 发布审批 + 审计 | -| 生成应用质量差/改不动 | 存档回滚(草稿/代码无损)+ 修错免费 + 玻璃盒逃生 + 模板起步(不从空白开始) | -| 客户环境差异 | 工场/沙箱为可选组件(compose profile),缺席时平台其余功能完整;gVisor 不可用自动回退 | -| 订阅路径依赖外部账号可得性与限额 | 档案级并发闸 + 限额友好提示 + 一键降级切平台代理续跑(§4.3) | -| **控制面特权(编排 root-on-host)** | 独立最小权限 runtime-manager 唯一持 docker,backend 零 docker 依赖(arch-guard 强制)、intent-based RPC、凭据隔离(§4.5) | -| **容量/密度(单机 N 用户各开沙箱 + 生产容器)** | 准入控制 + idle 回收 + 并发上限 + cgroup 硬隔离,挂 runtime-manager;档位固定 small/medium | -| **应用级 secret 无落点** | 新建应用 secret store(Fernet),声明式注入(同 capabilities + 发布审批) | -| **Anthropic 兼容面耦合引擎实效风险** | 翻译层劣化 tool-calling 会加重模型实效风险;采现成代理省自研长尾、协议面稳定性外包成熟实现(§3.4) | - ---- - -## 5. 路线图 - -### 5.1 本版(v3.0)Feature 拆解 - -| Feature | 内容 | 依赖 | -|---------|------|------| -| F100 engine-poc(Wave-0) | 薄版 adapter × 引擎 × 国产三模型 × 3 任务对测 + 模板生成器对照基线,量化 go/no-go gate 定默认引擎 | F102 最小版 | -| F101 open-platform-keys | API key(个人/应用/会话)/ OBO token / 配额 + v2 鉴权改造 | — | -| F102 model-proxy-dual | 双协议模型代理:采用现成代理(LiteLLM/one-api)承载协议面 + BishengLLM provider 路由 + per-key 记账回流 | F101 | -| F103 runtime-manager + sandbox-core | 独立最小权限编排器 runtime-manager(唯一持 docker、intent RPC、容量/idle/并发/cgroup)+ 容器沙箱底座:构建/生命周期/预览与生产路由/日志(backend 零 docker 依赖,安全评审前置) | F101 | -| F107 app-proxy | 版本无关鉴权代理:验会话/签 OBO/注入头/反代容器/WS;/apps 路由统一(nginx 与 Java 网关只转发);header 防伪 + WS 生命周期两不变量 | F101 | -| F105 bisheng-mcp-server | MCP server + 工具集(含应用数据库) | F101, F102 | -| F110 builder-engine | RuntimeAdapter 完整版 + 默认引擎适配器 + 凭据档案 + 会话快照/恢复(tar/bulk-snapshot 层 + SQLite-per-app) | F100, F103 | -| F111 builder-workbench | 造应用工作台 UI:对话+预览+玻璃盒、选项式澄清、模板起步、存档/恢复(含数据)、修错回流、数据后台 | F110 | -| F112 publish-pipeline | workspace → manifest → 应用包 → 托管;草稿/生产分离;回滚(代码无损 / 生产数据默认不回滚) | F110, F103, F107 | -| F104 app-governance | OpenFGA 资源类型 / 发布审批 / 应用广场 / 审计 | F112 | -| F106 cli-sdk-templates(旁路) | CLI + SDK + 首发 6 类预置模板(清单见附录调研)+ 开发者指南 | F103, F105 | -| F108 scheduling-subsystem + guardian-spec | **平台触发/调度子系统**(cron/webhook 触发器 · 常驻生命周期 · 预算闸 · 机队观测)+ 管家守态作首个消费者:PRD/design 定稿 + 表结构预留;简报卡嵌入复用 F109;实现是否本版待定夺(见 §5.4)——defer 的是可复用平台能力、非隐性债 | F102, F105 | -| F109 embed-upgrade | postMessage 协议 + 域名白名单(修社区 #1705) | — | - -**波次**(波次 = 分批开发的先后顺序,前一批为后一批打地基,共五批):Wave-0 = **F102 最小协议面 stub**(F100 前置:三国产模型经代理可达)+ F100(引擎 PoC + 模板生成器对照基线,2-3 周,与 Wave-1 部分并行)→ Wave-1 = F101/F102(完整)/F103/F107(地基;**runtime-manager 特权面安全评审前置本波、过审才动工**)→ Wave-2 = F110/F105 → Wave-3 = F111/F112 → Wave-4 = F104 + F108 规格定稿;F106/F109 若后段人力不足则裁剪顺延。**工作量校准**:关键路径含 §4.6「净新建核心基建」五块,均按新建估时、不按「复用」。 - -**本版明确不做**:k8s 多机、Dockerfile 自定义构建、点选局改(Design Mode)、云端通用 IDE、第三方 MCP 网关;管家「守」态是否在本版实现另行定夺(见 §5.4)。 - -### 5.2 下一版(v3.1) - -**应用管家「守」态正式可用**——让管家持续看护上线应用:看护总览(一屏看住所有看护任务的状态灯 / 上次运行 / 累计花费 / 历史简报)、花费先估后用(开启前出示预估、可走审批、超预算自动停)、巡检频率自动调节(有情况勤看、平稳时少跑);点选局改(对着预览点选即改);模板库与复刻深化;第三方 MCP server 接入网关化;存量代码执行点(workflow CodeNode / gpts local executor)迁入沙箱设施;k8s 多机调度。 - -### 5.3 远期 - -数据语义层(本体)接入与 text2SQL grounding、应用间编排、移动端产物(小程序/APK)、应用市场生态。 - -### 5.4 开放问题 - -1. **命名**:能力域名暂定「应用工场」、入口动词暂用「造应用」,均非最终命名;最终命名待评审(要求:业务人员一听即懂、易记)。 -2. Wave-0 PoC 立项(最紧急):任务集设计、评测口径、三模型接入。 -3. release-contract(v3.0.0)初始化:领域对象归属(App/AppVersion/ApiKey/BuilderSession/Guardian…)、错误码段位(引 C5,避开 130=chat/160=dataset,建议 161 起)、不变量草案(「预览与生产必须过 app-proxy」「OBO scope ⊆ 用户实际权限」「引擎依赖收口 RuntimeAdapter」「凭据档案只注入其分配范围内的会话」「backend 零 docker 依赖,容器编排唯经 runtime-manager」「app-proxy 剥离客户端伪造 X-BiSheng-* 头」)。 -4. 造应用工作台交互稿评审:细化三分区布局、选项式澄清、玻璃盒展开方式(初版交互稿见 §4.1)。 -5. `/apps`(含 preview + WebSocket)路由与鉴权在 **app-proxy 层统一**(nginx 与 Java 网关都只转发到 app-proxy,安全逻辑不分叉)——据 `docs/architecture/11-gateway.md`,Java 网关只认 `/api/**`、有自己的 Sa-Token/SSO + 自定义 WS filter、不认 `/apps`,故这是**设计任务**而非「待对齐」;待细化 header 防伪与 WS 长连 OBO 过期/吊销两个不变量的落地。 -6. 内部 dogfood:首批用工场造 5-10 个内部工具(优先从首发 6 类模板场景选题,兼作 PoC 素材)。 -7. 管家「守」态是否纳入本版实现:本版基线 = 只定稿规格 + 预留数据表(F108);建议开发进入后半段时按剩余人力决定是否实现,不足则顺延 v3.1——**待定夺**。 - ---- - -## 附录:调研索引 - -- **预置模板首发清单调研**(两路 subagent:通用模板市场实证 + 央国企场景特写):见同目录《3.0 附录:预置模板首发清单调研.md》——钉钉模板中心使用量硬数字、央国企「留痕驱动」差集判断、首发/二批/不做三级清单。 -- **引擎(2026-07,一手核实)**:Qwen Code(github.com/QwenLM/qwen-code,Apache-2.0);Codex CLI(github.com/openai/codex,exec --json / app-server);Kimi Code CLI(github.com/MoonshotAI/kimi-code,MIT;providers 六类型 + reasoning_key 容错:kimi.com/code/docs);opencode(anomalyco/opencode,MIT;2.0 breaking beta:v2.opencode.ai);Claude Code license(anthropics/claude-code LICENSE.md = All rights reserved + anthropic.com/legal/commercial-terms)。 -- **国产 Anthropic 兼容端点(官方文档一手核实)**:DeepSeek(api-docs.deepseek.com/guides/anthropic_api)、智谱(docs.bigmodel.cn,GLM-5.2 映射)、Kimi(platform.kimi.ai)、阿里 Model Studio、MiniMax。 -- **造应用 UX 机制**:扣子编程(code.coze.cn/docs.coze.cn:三分区/需求前置/开箱数据库/部署回退)、秒哒(黑盒形态与其教训)、Lovable(Versioning 2.0 / Try-to-Fix / CVE-2025-48757)、v0(Design Mode)、Replit(checkpoint 含数据库 / SaaStr 事故 postmortem)。 -- **运行时与鉴权范本**:Databricks Apps(OBO 双轨鉴权 / 固定资源档位 / GA 数据);沙箱选型(gVisor vs Firecracker,Anthropic/Modal 实践)。 -- **常驻 agent 原语**:duoduo(openduo/duoduo)——触发五元、机械层/大脑分离、降噪纪律、成本收据、机队视图;本地逆向仓 reconstruction/ 为 RuntimeAdapter 设计参考(统一 run 契约 / 事件归一化 / compatible_endpoint 接线),只借设计不搬代码。 -- **市场与治理**:IBM Cost of a Data Breach 2025;Gartner shadow AI 预测;秒哒/美团 NoCode/招行公开数据;BiSheng 社区 issues(#2189 沙箱 / #1705 嵌入 / #1763 Skills / #1568 pip)。 -- **平台能力盘点**(仓内实查):v2 API 鉴权现状、MCP client/server 现状、模型代理缺口、触发与推送缺口、灵思 runtime 复用面、部署形态。 -- **技术架构 review(资深架构师,代码取证)**:见同目录《3.0 技术架构 Review(资深架构师意见).md》——含 §4.6 复用度取证、净新建三块(runtime-manager / app-proxy / 模型代理协议面)识别、错误码 C5 段位、egress 构建·运行分离、应用 secret store、容量治理。取证锚点:`docker.sock` 仅 CI、`auth_request` 仅方案文档、`linsight/worker.py` 无 cron producer、constitution C5(130=chat / 160=dataset)。 diff --git "a/docs/PRD/3.0-release/3.0 \345\272\224\347\224\250\345\267\245\345\234\272 \346\212\200\346\234\257\346\236\266\346\236\204\346\226\271\346\241\210.md" "b/docs/PRD/3.0-release/3.0 \345\272\224\347\224\250\345\267\245\345\234\272 \346\212\200\346\234\257\346\236\266\346\236\204\346\226\271\346\241\210.md" new file mode 100644 index 0000000000..3f23ed7f77 --- /dev/null +++ "b/docs/PRD/3.0-release/3.0 \345\272\224\347\224\250\345\267\245\345\234\272 \346\212\200\346\234\257\346\236\266\346\236\204\346\226\271\346\241\210.md" @@ -0,0 +1,661 @@ +# BiSheng 3.0 应用工场 技术架构方案 + +> **版本**:v1.0 **日期**:2026-10-09 +> **读者**:接手应用工场开发与运维的后端、前端、运维工程师。 +> **与其他文档的关系**:本文是应用工场三篇文档之一。《3.0 应用工场 PRD-1 专业开发者通道与应用运行时》定义「做什么」,本文描述「已经怎么建成」;《3.0 应用工场 PRD-2 造应用工作台》已无限期搁置,本文 §10 只保留它的技术结论摘要。本文吸收并取代原《3.0 应用工场 产品方案》§3.4 / §4 / §5.1、《3.0 附录:纳管应用技术路线与架构选型调研》和《3.0 技术架构 Review(资深架构师意见)》,旧章节去向见附录 B。 +> **事实基准**:`3.0-vibe` 分支 `9a19db231`(2026-10-08)。事实冲突时按「代码 > `features/v3.0.0//tasks.md` 偏差记录 > design.md > 旧方案文档」取舍。注意:F053 / F054 / F055 的 design.md 正文仍保留设计期「本轮后置」「尚未开工」等表述,实现后没有整体改写,读它们时以 tasks.md 末尾的偏差记录为准。 +> **范围**:本文只写已实现的架构与已定的技术约束;设计期推演与版本演变留在 git 历史,附录 A 只保留结论与一句理由。 + +--- + +## 0. 术语 + +下表中的术语全文只用这一个说法。 + +| 术语 | 定义 | +|---|---| +| 托管应用 | 开发者在本地开发、经 `bisheng deploy` 导入平台、由平台构建并以容器运行的 Web 应用。代码中的资源类型名是 `app`,构建页 UNION 中 `flow_type=35`。 | +| 平台核心 | BiSheng 既有的全部功能(backend、两个 SPA、Celery、六类存储)。 | +| 开放能力层 | 对外开放平台能力的一层:凭据底座、`/api/v2` 鉴权管线、模型协议面、MCP 面、开发者工具分发。它全部在 backend 进程内,零 docker 依赖。 | +| 工场运行时层 | 运行托管应用的一层:runtime-manager、app-proxy、egress-proxy、docker-socket-proxy 和托管应用容器。 | +| runtime-manager | 编排器。独立包、独立进程,接收 backend 的编排意图并调用 docker API 执行。 | +| app-proxy | 托管应用的入口反向代理。负责 `/apps/{slug}` 的入口判定、身份头剥离与注入、HTTP 与 WebSocket 转发。 | +| 编排意图 | backend 发给 runtime-manager 的声明式请求(build / deploy / stop / destroy / probe / db-migrate / preview),用 HMAC 签名。 | +| 服务账号密钥 | 前缀 `bs-sak-` 的凭据,主体是不可登录的服务账号。开发者用它登录 CLI。 | +| 个人访问令牌 | 前缀 `bs-pat-` 的凭据,主体是自然人。 | +| 应用运行期凭据 | 前缀 `bs-app-` 的凭据,主体类型 `hosted_app`。发布管线自动签发,以环境变量 `BISHENG_APP_TOKEN` 注入容器,没有管理入口。 | +| OBO 令牌 | app-proxy 在每次请求中注入的 `X-BiSheng-Access-Token`,表示「当前访问用户」。backend 用 `app_runtime.obo_secret` 签发,默认寿命 900 秒。 | +| 能力声明 | `bisheng-app.yaml` 中的 `capabilities` 段:应用要调用的模型和知识库。审批通过即授权。 | +| 能力总线 | 把能力声明变成运行期注入与运行期判权的服务(`CapabilityBusService`)。本版只覆盖模型和知识库两项。 | +| 应用态 | `app.state` 的五个取值:`draft` / `online` / `pending_capacity` / `stopped` / `deleted`。 | + +--- + +## 1. 总览 + +### 1.1 结论 + +应用工场在平台核心之外新增两层,两层都可以整层不装。开放能力层是 backend 进程内的模块,它用平台自签发凭据替换了 v2 开放 API 原来的「内网可信」模型,并对外提供 OpenAI 兼容模型面和 MCP 面。工场运行时层是三个新进程加一组容器:backend 只发编排意图,runtime-manager 执行构建与容器生命周期,app-proxy 承担入口与身份注入,egress-proxy 承担出站白名单。backend 编译期与运行期都不依赖 docker,这条约束由 `scripts/arch-guard.sh` RULE-10 强制。托管应用获得访问者身份的唯一途径是 app-proxy 注入的请求头,应用调用平台能力时同时出示「哪个应用」(应用运行期凭据)和「为谁」(OBO 令牌)两把凭据。 + +### 1.2 总体架构图 + +``` + 开发者本地 业务用户浏览器 + coding agent ──MCP / OpenAI 协议──┐ │ /apps/{slug} + bisheng CLI ───/api/v2/apps/*─────┤ │ + ▼ ▼ + ┌──────────── nginx(开源)/ Java 网关(商业)──────────────┐ + │ /api/** /apps/* │ + ▼ ▼ +┌──────── 平台核心 + 开放能力层(backend :7860)────────┐ ┌──── 工场运行时层 ─────────────────────┐ +│ open_api 凭据底座 · v2 鉴权管线 · 模型协议面 │ │ app-proxy :8090 │ +│ open_api/mcp MCP 面 /api/v2/mcp │◄─┤ 入口判定(HMAC 调 backend) │ +│ dev_toolkit CLI / SDK / 技能包分发 │ │ 剥离 x-bisheng-* · 注入 10 个身份头 │ +│ app_runtime 应用域 · 状态机 · 入口判定 · OBO 签发 │ │ 反代 HTTP / WebSocket │ +│ app_publish 发布管线 · 能力总线 · 运行期凭据 │ └──────────────┬────────────────────────┘ +│ approval(F025) 审批场景 app_publish_request │ │ 查路由 +│ Celery default 发布管线异步段 · 审批 outbox │──HMAC 意图 RPC─►┌▼─────────────────────────┐ +└───┬─────────┬─────────┬──────────┬────────────────────┘ │ runtime-manager :8091 │ + MySQL|DM8 Redis OpenFGA MinIO 桶 bisheng-apps ◄──代码快照 / 附件──│ 构建 · 拉起 · 探活 · 自愈│ + │ 容量准入 · 数据面 · 附件 │ + └──┬──────────────┬───────┘ + docker API│ policy.json│ + docker-socket-proxy egress-proxy :3128 + │ ▲ 唯一出口 + dockerd ──► 托管应用容器(网络 bisheng-apps,--internal) + 只读 rootfs · /data/app.db(per-app SQLite) +``` + +托管应用容器只有两条出路:入方向只经 app-proxy 到达,出方向只经 egress-proxy 离开。容器回调平台(模型面、检索、附件)时同样走 egress-proxy,平台地址在运行期白名单内。 + +### 1.3 三层与部署开关 + +| 层 | 组件 | 部署开关 | 开关关闭时的行为 | +|---|---|---|---| +| 平台核心 | 既有全部功能 | — | 不变 | +| 开放能力层 | `open_api`、`open_api/mcp`、`dev_toolkit` | `open_platform.enabled`(三个扩展权限位能否签发、模型面与 MCP 面是否挂载、分发端点是否注册);`open_api.management_ui_enabled` / `pat_enabled`(管理界面,beta2 起默认开) | 模型面与 MCP 面不挂载,路径返回与不存在路径逐字节相同的 404;`/api/v2` 其余端点照常 key 鉴权 | +| 工场运行时层 | runtime-manager、app-proxy、egress-proxy、docker-socket-proxy | `app_runtime.enabled`(backend)+ compose profile `app-runtime` 或 systemd 单元是否安装 | 两个 SPA 读 `/api/v1/env.app_runtime_enabled` 隐藏全部入口;deploy / preview 端点被 `require_app_runtime_enabled` 拒绝;新表随 alembic 建出、空置无害 | + +两层的依赖方向是单向的:工场运行时层依赖开放能力层的凭据底座,平台核心不感知任一层。平台核心与两层共享的只有两个注册式扩展点:OpenFGA 资源类型 `app` 和审批场景 `app_publish_request`。 + +### 1.4 Feature 与代码地图 + +| Feature | 负责的组件 | 主要代码位置 | PRD-1 条目 | 状态(2026-10-08) | +|---|---|---|---|---| +| beta2 F053(取代 vibe F049) | 凭据底座、服务账号、v2 鉴权管线 | `src/backend/bisheng/open_api/` | DEV-01、GOV-07②、GOV-08 | 已交付于发版线 | +| F051 | 模型协议面 | `open_api/api/endpoints/model_gateway.py`、`open_api/domain/services/model_gateway_service.py`、`llm/.../model_catalog.py` | DEV-02(模型)、GOV-04(模型调用记录) | 27/30,余项需 114 | +| F052 | MCP 面、统一检索门面 | `open_api/mcp/`、`knowledge/domain/services/retrieval_facade_service.py` | DEV-02(工具) | 33/35,余项需 CI 中间件 | +| F053 | CLI、技能包、分发端点 | `src/bisheng-cli/`、`src/backend/bisheng/dev_toolkit/` | DEV-01、DEV-03、DEV-04、DEV-05 | 52/53 | +| F057 | Python SDK | `src/bisheng-sdk/` | DEV-07 | 42/45 | +| F054 | 应用域、runtime-manager、app-proxy、出站白名单、per-app 数据库、附件存储 | `src/backend/bisheng/app_runtime/`、`database/models/app*.py`、`src/runtime-manager/`、`src/app-proxy/` | RT-01、RT-07、RT-08、GOV-01(类型注册)、GOV-03(限额落地)、GOV-10 | 102/104 | +| F055 | 发布管线、审批接入、档位、能力总线、运行期凭据 | `src/backend/bisheng/app_publish/`、`worker/app_publish/` | DEV-04(管线)、RT-03、RT-04、RT-05、GOV-02、GOV-03、GOV-05、GOV-08(运行期凭据) | 71/72 | +| F056 | 广场接入、授权交互、审计事件 | `database/models/flow.py`(UNION)、`app_runtime/domain/services/visibility_audit.py`、platform `pages/BuildPage/hostedApp/` | RT-02、GOV-01(交互)、GOV-04、GOV-07 | 32/33,GOV-04 有真缺口 | +| F059 | k8s 执行后端 | — | GOV-10(k8s 形态) | 仅 spec | +| F050 / F058 | 身份模式、日常模式会话开放 | — | 伴生 PRD,不在 PRD-1 | 本目录仅 spec,见 §2.1 | + +应用工场尚未合入发版线:`feat/3.0.0-beta3` 不含 `app_runtime` / `app_publish` / `runtime-manager` / `app-proxy`。 + +### 1.5 主链路 + +下面是 PRD-1 主旅程在系统中的执行顺序,各步的设计见后续章节。 + +1. 租户管理员在 platform「服务账号」页建服务账号(必填资源归属人),签发勾选 `app:manage` 的服务账号密钥(§2.1)。 +2. 开发者执行 `bisheng login <平台地址> --api-key …`。CLI 调 `GET /api/v2/auth/whoami` 校验密钥,写 `~/.bisheng/credentials.json`,随后自动同步技能包(§3.1、§3.2)。 +3. 开发者执行 `bisheng deploy`。CLI 打包上传到 `POST /api/v2/apps/deploy`;backend 同步完成解包、manifest 校验、建草稿或归属判定、快照入 MinIO,然后把异步段交给 Celery default worker(§5.1)。 +4. 异步段依次执行密钥扫描、结构预检、构建(runtime-manager 渲染 Dockerfile 模板并 `docker build`)、启动探活;全部通过后写版本记录并提交审批单(§5.2–§5.5)。 +5. 审批人在审批中心通过。审批 outbox 在 Celery default worker 上调用 `on_approved`,执行上线终检:容量准入、签发应用运行期凭据、合并能力注入变量、下发 deploy 意图、切换路由(§5.6、§5.7)。 +6. owner 在卡片 ⚙️「管理权限」或发布面可见范围区授权给同事(§6.2)。 +7. 同事从应用广场点卡片,浏览器整页跳到 `/apps/{slug}`。app-proxy 向 backend 做入口判定,剥离伪造头,注入身份头与 OBO 令牌,反代到容器(§4.6)。 +8. 应用代码用 SDK 读身份、调检索;用标准 `openai` 客户端调模型;用标准库读写 `/data/app.db`(§3.3、§5.7)。 + +--- + +## 2. 开放能力层 + +### 2.1 鉴权底座(取 beta2 F053 实现) + +开放能力层的鉴权底座整体使用发版线 beta2 F053 的实现,vibe 分支自己的 F049 实现于 2026-09-10 归档。决策理由是两套实现在同名表上结构不同、并存会让权限主体判定出现两个真相;合并方案与冲突解法见 `features/v3.0.0/beta2-openapi-base-migration.md` M1–M17。 + +**凭据与主体。** 平台自签发凭据共用一张表 `api_credential`,服务端只存 `sha256(明文)`,明文只在签发时展示一次(INV-28)。主体类型由 CHECK 约束限定为三种:`service_account`(`bs-sak-`)、`natural_person`(`bs-pat-`)、`hosted_app`(`bs-app-`)。服务账号是独立的 `service_account` 表,不在 `user` 表上打标;它不可登录、不进选人场景、授权只走主体侧(INV-29)。`hosted_app` 主体由 F055 加入,见 §5.7。 + +**单一鉴权管线。** `/api/v2` 的路由级依赖是 `verify_open_api_access`(`open_api/api/dependencies.py`)。每个端点必须带 `@open_api_scope(...)` 标记,漏标记的端点结构性拒绝。管线按顺序做:校验 bearer、个人访问令牌的额外策略检查、端点标记检查、`hosted_app` 只能进标了 `hosted_app=True` 的路由(否则 26052)、`delegate` 与三个扩展位互斥(调用期 26051)、权限位检查(26003)、身份模式解析。主体对象是 `open_api/domain/context.py` 的 `OpenApiPrincipal`。 + +**权限位。** PRD-1 使用三个扩展位:`model:invoke`(模型面)、`identity:read`(MCP 身份组织工具)、`app:manage`(CLI deploy / logs、MCP 应用数据与日志工具)。三位都随 `open_platform.enabled` 可签发,个人访问令牌永远拿不到这三位。三位与 `delegate` 位在签发期硬阻断(26050)、在调用期拒绝(26051),因为本地开发三面一律以服务账号自身身份执行,不承载委托(INV-31)。 + +**身份模式。** 无委托头时是模式 S,以凭据主体身份执行。持 `delegate` 位的服务账号可经 `X-On-Behalf-Of` 头进入模式 D,以目标自然人身份执行,目标须在委托范围表 `credential_delegate_scope` 内且不能是特权管理员。应用工场全程只用模式 S。头名用 beta2 的 `X-On-Behalf-Of` / `X-End-User`,不是伴生 PRD 写的 `X-Bisheng-*`,这是按 beta2 现状接受的偏差。F050 spec 所列的「两种身份模式、受限委托」在代码中已由 beta2 交付大半,`features/v3.0.0/README.md` 中 F050 的「未做」描述需要按代码重核。 + +**错误与审计。** v2 返回真实 HTTP 状态,信封仍是 `{status_code, status_message, data}`;模型面前缀单独渲染为 OpenAI 错误体。每次 v2 调用由 `open_api/api/middleware.py` 写一条 `open_api.call` 审计,带身份模式与授权主体。错误码段 260 归 beta2 F053,以 `common/errcode/open_api.py` 为准。 + +### 2.2 模型协议面(F051) + +模型协议面只做 OpenAI 兼容,挂在 `/api/v2/model/v1`,提供 `POST /chat/completions`(含 SSE 流式与工具调用)和 `GET /models`。其余路径由显式 catch-all 返回 26501,Anthropic `messages` 的两种路径拼法返回专用码 26502;embeddings 与 Responses 不提供。设计期曾计划同时提供 Anthropic 兼容面并用 LiteLLM / one-api 类现成代理承载协议面,实现改为在 backend 内自研一层薄翻译(OpenAI 请求 ⇄ `BishengLLM`),原因是现成代理需要同步第二份服务商凭据、不知道租户可见范围与服务商日上限,同步延迟即静默越权,且信创部署要多一个组件(F051 design D1)。 + +**模型解析。** 模型名以平台模型管理中的 `LLMModel.model_name` 为准,跨服务商同名时用限定名 `{服务商名}/{模型名}`。可用范围由 `llm` 域的 `model_catalog.py` 计算(进程内 60 秒缓存,失败即拒),`ModelRangePolicy` 按主体类型分派:服务账号取租户已启用模型,托管应用取能力声明(经 F055 注册的端口读取)。 + +**记录与计量。** 每次调用写一行独立表 `model_call_record`(主体、资源归属人、`app_id`、访问用户、模型、token、首 token 时延、`trace_id`),由批量异步写入器落库。被范围策略拒绝的调用不写这张表,由 `open_api.call` 审计承接。token 口径与 MODEL_INVOKE 遥测同源,计费归资源归属人,不写 `llm_token_log`。本面不新增任何限流闸,只沿用服务商的 `limit_flag / limit`,超限返回 26517 / 429;聚合账单顺延 v3.1。 + +**接入地址。** `GET /api/v1/dev-toolkit/versions` 的 `model` 段给出 base URL、协议与鉴权方式。错误码段 265(`common/errcode/model_face.py`),每个码同时带 OpenAI 的 `type` / `code`。细节见 `features/v3.0.0/051-model-protocol-gateway/design.md` D1–D14。 + +### 2.3 MCP 面与统一检索门面(F052) + +MCP 面使用 FastMCP 的 streamable-http 传输,`stateless_http=True`、`json_response=True`,以精确 `Route` 挂在 `/api/v2/mcp`,只在 `open_platform.enabled` 为真时装配。鉴权由自建 ASGI 闸 `McpAccessGate`(`open_api/mcp/gate.py`)完成,它复用 v2 的 `validate_bearer`,接受服务账号密钥与个人访问令牌,一律模式 S,持 `delegate` 的凭据在入口被拒。`list_tools` 按权限位过滤,没有任何权限位的凭据能完成握手但只得到空清单。 + +工具注册表是 `open_api/mcp/registry.py` 的 `TOOL_REGISTRY`,共六类: + +| 类别 | 工具 | 权限位 | +|---|---|---| +| 知识库检索 | `bisheng_knowledge_search` | `knowledge:read` | +| 知识库清单 | `bisheng_knowledge_list` | `knowledge:read` | +| 模型清单 | `bisheng_model_list` | `model:invoke` | +| 身份组织 | `bisheng_identity_get_user`、`bisheng_org_tree`、`bisheng_dept_members` | `identity:read` | +| 应用数据 | `bisheng_app_db_tables`、`_schema`、`_rows`、`_row_update` | `app:manage`(仅本人名下应用) | +| 应用状态与日志 | `bisheng_app_status`、`bisheng_app_logs` | `app:manage`(运行时层未部署时返回 16207) | + +模型调用不走 MCP,本地 agent 直连模型协议面;MCP 只提供模型清单。每次 `tools/call` 写一条 `open_api.mcp.tool_call` 审计。 + +**统一检索门面**是 `knowledge/domain/services/retrieval_facade_service.py`,它有四个调用方:MCP 检索工具、v2 `POST /filelib/retrieve`、托管运行期的能力总线、SDK `retrieve`。门面按文件级做权限过滤,权限引擎不可用或结果不可判定时整体报错、绝不返回部分过滤的结果(INV-30、INV-36)。错误码段 263(`common/errcode/mcp_face.py`):26300–26319 归 MCP 传输与工具面,26320–26339 归检索门面。细节见 `features/v3.0.0/052-mcp-server-face/design.md` D1–D12。 + +--- + +## 3. 开发者工具 + +### 3.1 CLI(F053) + +CLI 是独立 Python 包 `src/bisheng-cli/`,发行名 `bisheng-cli`、import 名 `bisheng_cli`、命令名 `bisheng`,运行依赖只有 `httpx` 与 `PyYAML`,与 backend 代码零耦合。命令集如下: + +| 命令 | 作用 | 平台接口 | +|---|---|---| +| `login BASE_URL --api-key / --api-key-stdin` | 校验服务账号密钥并落盘;成功后自动 `skills sync`;只拿到 `delegate` 位的密钥在写盘前被拒 | `GET /api/v2/auth/whoami` | +| `deploy [PATH]` | 打包上传,按服务端 `stage` 输出进度;`--wait` 轮询到终态,每种终态一个退出码 | `POST /api/v2/apps/deploy`、`GET /api/v2/apps/deployments/{id}` | +| `logs` | 读运行日志,`--follow` 每 3 秒短轮询 | `GET /api/v2/apps/{id}/logs` | +| `skills sync` | 拉取技能包并接入本机 AI 编程工具 | `GET /api/v1/dev-toolkit/skills/{pack}` | +| `dev` | 本地起迷你代理,注入与线上同名的身份头和环境变量 | 无 | +| `platforms list / use` | 在多个已登录平台间切换 | 无 | + +凭据文件 `~/.bisheng/credentials.json` 以 `O_CREAT|O_EXCL` 加 0600 一步创建,目录 0700,结构支持多平台 profile。应用标识写在项目根 `.bisheng/app.json`,平台一接收上传就写入,避免重跑时再建一个同名草稿。打包产物是可复现的 `tar.gz`(mtime 归零、路径 posix 化),忽略规则依次为 git、内置 gitignore 子集、`.bishengignore`,`.venv` / `node_modules` / `.bisheng/` 硬排除;上传是一次性 multipart POST,上限取自 `GET /api/v2/apps/deploy-limits`。CLI 不提供 `init`、`logout`、`whoami`、`--as` 和设备码登录。 + +**安装件分发。** 平台自身分发 CLI 与 SDK 的 wheel,端点在 `dev_toolkit/api/endpoints/distribution.py`,前缀 `/api/v1/dev-toolkit/`,匿名可达,`open_platform.enabled` 关闭时不注册。`scripts/pack_cli_wheel.sh` 与 `pack_sdk_wheel.sh` 构建 wheel 并拷入 `src/backend/bisheng/dev_toolkit/artifacts/`,同时更新带 sha256 的 `manifest.json`。wheel 必须提交进 git,因为后端镜像的 build context 只有 `src/backend/`;修改 CLI 或 SDK 后不重跑脚本并提交,平台会继续分发旧版本。 + +### 3.2 技能包(F053) + +技能包源目录是 `src/backend/bisheng/dev_toolkit/skills/`,共两包:`deploy-hosting`(托管运行契约、manifest 写法、安全红线、部署工作流、自检清单)和 `platform-wiring`(访问者身份、检索、附件存储、应用数据库、模型接线、`bisheng dev`、SDK 来源)。每包带可运行样例与 `selfcheck.py`。技能包使用 Agent Skills 开放格式,不做任何本地端专有插件。 + +平台按请求把技能目录实时打成字节确定的 gzip tar,版本放在响应头 `X-Bisheng-Pack-Version`。CLI 把它落到 `~/.bisheng/skills/<平台标识>//`,然后在检测到的 `~/.claude/skills/`、`~/.codex/skills/` 下建软链(Windows 改为复制);没有技能目录约定的工具,由 `deploy` 在项目 `AGENTS.md` 写一行路径指针。这一步是 2026-08-18 缺陷修订的结果:只同步到 `~/.bisheng/skills/` 时没有任何编程工具会扫描该目录,「同步成功」并不代表 AI 读得到。技能内容同时是 SDK 开发者指南的唯一来源(F057 design D12)。 + +### 3.3 SDK(F057) + +SDK 是 `src/bisheng-sdk/`,只有 Python 版,版本 0.1.1 独立于平台,唯一依赖 `httpx`。公开面恰好是四个模块,由 `test_public_surface.py` 守住: + +- `auth`:`current_user()` 从请求头读出 `Identity`,用 ContextVar 绑定到请求上下文,提供 ASGI / WSGI 中间件与 `bind()`;没有注入身份即抛 `PlatformIdentityMissingError`,不返回 None。 +- `retrieve`:`search()` / `asearch()` 调 `POST /api/v2/filelib/retrieve`,同时送 `Authorization: Bearer `(哪个应用)与 `X-BiSheng-Access-Token`(为谁)。 +- `storage`:附件六函数 put / get / open / stat / list / delete 及其异步版本;托管期走 runtime-manager 附件接口,本地期走 `BISHENG_APP_STORAGE_DIR` 目录。 +- `errors`:19 个异常类,服务端码经 `_codes.py` 映射,未登记的码原样呈现。 + +模型与应用数据库刻意不进 SDK(PRD-1 DEV-07):应用用官方 `openai` 客户端读平台注入的 `OPENAI_BASE_URL` / `OPENAI_API_KEY`,用标准库或任意 ORM 读 `BISHENG_APP_DB_URL`。SDK 也不封装 MCP。托管构建期 runtime-manager 经平台的 PEP 503 索引 `/api/v1/dev-toolkit/simple/` 安装 SDK。 + +### 3.4 `bisheng dev` 本地身份注入 + +`bisheng dev` 在本地起一个迷你代理(`bisheng_cli/devproxy.py`),向本地应用注入与 app-proxy 同名的十个身份头和同名环境变量,身份是开发者的服务账号(`subject_kind=service_account`,部门三头缺省)。本地数据库是 `<项目>/.bisheng/dev/app.db`,附件目录是 `<项目>/.bisheng/attachments/`。本地期的 `X-BiSheng-Access-Token` 是 CLI 用 HMAC 签的 `bsdev.` 句柄,平台无法验签,所以本地无法端到端验证 `retrieve`,技能包把这一点标为「不要依赖」。平台不提供身份模拟开关,平台能力调用恒以服务账号真实身份计权限(INV-32)。 + +--- + +## 4. 应用域与运行时(F054) + +### 4.1 领域模型与状态机 + +托管应用的表定义在 `src/backend/bisheng/database/models/`,放在这里而不放进 `app_runtime/domain/` 的原因是构建页列表的 UNION 要在 `flow.py` 中引用它,放进 domain 会触发 arch-guard RULE-2。 + +| 表 | 作用 | 要点 | +|---|---|---| +| `app` | 应用本体 | id 为 uuid 字符串;`slug` 全局唯一(跨租户);`state` 显式列;`current_version_id` 指向正在运行的版本 | +| `app_version` | 版本记录 | 只增不改,唯一会变的列是 `terminal_state`;冻结 `code_object_key` / `manifest` / `capabilities` / `injections` / `tier_id` / `runtime` / `image_ref`;无 `tenant_id`,隔离由所属 `app` 行派生 | +| `app_instance` | 运行实例 | `phase`、`health`、`restart_count`、`last_probe_at`;`exec_ref` 只用于审计,不参与路由 | +| `app_access_log` | 访问记录 | 一次进入一条,同一用户同一应用 1800 秒内合并(Redis `app_access:{app_id}:{user_id}`) | +| `resource_tier` | 资源档位 | 平台级、无 `tenant_id`,F055 拥有,见 §5.6 | +| `app_deployment` | 一次发布尝试的进度 | F055 拥有,CLI 轮询它 | +| `hosted_app_subject` | 应用到整数主体 id 的映射 | F055 拥有,见 §5.7 | + +状态机定义在 `app_runtime/domain/constants.py` 的 `AppState` 与 `ALLOWED_TRANSITIONS`: + +``` +draft ──► online | pending_capacity | deleted +online ──► online(迭代发布切版本)| stopped +pending_capacity ──► online | deleted +stopped ──► online | pending_capacity | deleted +deleted ──► (终态,行保留供审计) +``` + +状态机没有 `online → deleted` 这条边:删除运行中的应用必须先下线,保证容器与宿主卷总是由状态动作回收,不会因为行消失而成为孤儿(INV-35)。`app_runtime/domain/services/app_state_service.py` 是 `app.state` 的唯一写入方,用带前态断言的单行 UPDATE 落库,冲突返回 16102;新建草稿走 `app_provision_service.create_draft`。只有 `online` 与 `stopped` 两个状态能从入口访问到(`stopped` 显示「已下线」页)。 + +### 4.2 backend 与编排层的契约 + +backend 侧的 `app_runtime` 模块只做三件事:维护领域模型与状态机、向 runtime-manager 发编排意图(`orchestrator_client.py`)、为 app-proxy 提供入口判定端点(`api/endpoints/internal_app_proxy.py`)。进程间有三处认证,各用一把独立的密钥: + +| 调用方向 | 认证方式 | backend 配置键 | 对端环境变量 | +|---|---|---|---| +| backend → runtime-manager | HMAC-SHA256,签名串 `METHOD\nPATH\nbody`,放在 `X-Signature` | `app_runtime.manager_hmac_secret` | `RTM_HMAC_SECRET` | +| app-proxy → runtime-manager(查路由) | 同上,同一把密钥 | — | `APP_PROXY_MANAGER_SECRET` | +| app-proxy → backend(入口判定) | HMAC | `app_runtime.proxy_hmac_secret` | `APP_PROXY_BACKEND_SECRET` | +| backend 签发 OBO 令牌 | HMAC 签名令牌 | `app_runtime.obo_secret`(必须不同于 `jwt_secret`) | — | + +任何一把密钥为空时,对应调用方 fail-closed:runtime-manager 返回 401,backend 把它折叠为 16121「编排器不可用」,排查时容易误判为 dockerd 故障。容量准入阈值、数据目录、构建包源只在 runtime-manager 的 `RTM_*` 环境变量里生效,backend `config.yaml` 中的同名旧键已删除,写了只会打一条告警(`core/config/app_runtime.py`)。 + +### 4.3 runtime-manager + +runtime-manager 是独立包 `src/runtime-manager/runtime_manager/`,FastAPI 单 worker,默认监听 `127.0.0.1:8091`,只有 `/healthz` 免签。它的接口按用途分四组: + +| 组 | 端点 | 文件 | +|---|---|---| +| 编排意图 | `POST /v1/admission`、`/v1/intents/{build,deploy,stop,destroy,probe,db-migrate}`、`/v1/intents/preview[/stop]`、`GET /v1/builds/{id}` | `api/intents.py` | +| 路由 | `GET /v1/apps/{id}/route`(返回容器 bridge IP:端口、版本、generation)、`GET /v1/previews/{session}/route` | `api/routes.py` | +| 只读 | `/v1/apps/{id}/status`、`/v1/apps/{id}/logs`、`/v1/runtime/status`(含部署自检 preflight) | `api/readonly.py` | +| 数据面与附件 | `/v1/apps/{id}/db/*`(HMAC)、`/v1/apps/{id}/storage/*`(应用自己的 bearer) | `api/appdb.py`、`api/storage.py` | + +**编排访问面的边界。** backend 不持有任何编排访问面,这一点在所有形态下都成立。runtime-manager 访问 dockerd 有两种配置:D2-A 直连 `/var/run/docker.sock`(进程需要 docker 组),D2-B 经 `tecnativa/docker-socket-proxy` 的端点白名单访问(`RTM_DOCKER_HOST=tcp://…:2375`,EXEC 等写能力关闭)。compose 形态固定使用 D2-B,此时 socket 由 docker-socket-proxy 容器持有,它没有业务代码;systemd 形态由 `RTM_DOCKER_HOST` 是否设置决定。端点白名单由 `docker_backend.socket_proxy_env()` 唯一给出,compose 文件与测试逐字比对。设计期提法「runtime-manager 是唯一持有 docker socket 的进程」应理解为「编排访问面只属于工场运行时层的编排侧,backend 与 app-proxy、egress-proxy 都不持有」。 + +**自愈。** 进程退出类故障交给 docker 的 `RestartPolicy: unless-stopped`(内置指数退避),runtime-manager 不自研重启循环。`reconciler.py` 每 15 秒一轮,只处理 docker 自身不处理的「unhealthy 但进程未退出」实例:重建容器并把 generation 加 1,使 app-proxy 的路由缓存失效。期望态存放在本机状态文件,同时双写到容器 label 作灾备;从 label 恢复时,「容器存在但未运行」判为期望停止,避免状态文件丢失后把已下线的应用全部复活。已运行的容器不依赖 runtime-manager 进程存活。 + +**容量准入**(`admission.py`)。准入要同时通过两道闸:闸① 宿主 `MemAvailable − RTM_RESERVE_MB` 不小于本次所需;闸② 已承诺规格之和不超过总量 × `RTM_OVERCOMMIT_RATIO`(默认 0.8)。构建与拉起共用这套判定,构建按 `RTM_BUILD_RESERVE_MB` 预留。准入失败时应用进入 `pending_capacity`,不自动重试,由 owner 显式「手动上线」。 + +### 4.4 构建路线 + +构建使用平台持有的 Dockerfile 模板矩阵,开发者与 AI 都不写 Dockerfile(`runtime_manager/builder.py` + `templates/{python3.11,node20,static}/*.j2`)。manifest 的 `runtime` 字段选择模板目录,基础镜像分别是 `python:3.11-slim`、`node:20-slim`、`nginx:1.27-alpine`,镜像内以 UID 10001 的非 root 用户运行。支持的 runtime 列表由实际存在的模板目录决定,所以只预置了 python 基础镜像的离线环境只会宣告 `python3.11`。构建阶段依次是 `build_admission → fetch_source → render_dockerfile → docker_build`,在 runtime-manager 所在主机上执行。 + +构建期与运行期的出站策略是分开的。构建容器跑在专用 `--internal` 网络 `bisheng-build` 上,唯一出口是 egress-proxy,只放行部署配置的包源(`RTM_BUILD_INDEX_URL`、`RTM_BUILD_NPM_REGISTRY`)与平台自身分发端点;能力声明中的域名不作用于构建期,防止借 manifest 把依赖绕过审批带进来。镜像 tag 为 `bisheng-app/{slug}:{version_no}-{id[:8]}`,每个应用保留 2 个版本。Railpack / Nixpacks 与 CNB buildpacks 均被否决,理由见附录 A。 + +### 4.5 容器规格与沙箱 + +runtime-manager 在 `lifecycle.py::build_container_payload` 中一次性写定容器规格,之后不原地修改: + +- 资源限额:`NanoCpus` / `Memory` / `MemorySwap` 取自档位,创建时固化;超管调整档位规格后,只在下次发布或重新上线时生效。 +- 文件系统:`ReadonlyRootfs`;`/tmp` 为 64 MB 的 `noexec,nosuid` tmpfs;唯一可写持久路径是 bind 到宿主 `{data_root}/apps/{app_id}/db/` 的 `/data`。 +- 权限:`no-new-privileges`,非 root 用户运行。 +- 网络:`NetworkMode=bisheng-apps`,不发布任何端口,入方向只能经 bridge IP 到达。 +- 日志:json-file,10 MB × 3 份轮转,这也是日志的保留窗口。 + +沙箱只实现了「Docker 加固」一档。gVisor 与 Kata / Firecracker 在 F054 design §8 列为不做,代码零引用;容器规格中也没有 `CapDrop` 与 `PidsLimit`,列为技术债(§9)。 + +### 4.6 app-proxy + +app-proxy 是独立包 `src/app-proxy/app_proxy/`,默认监听 `127.0.0.1:8090`,不 import bisheng 包,不持有任何 docker 权限。nginx 用变量式 upstream 把 `location /apps/` 转发给它(`docker/nginx/conf.d/default.conf`);商业版 Java 网关只认 `/api/**`,同样只需把 `/apps/` 转发到 app-proxy,入口安全逻辑只在 app-proxy 一处实现。 + +**入口判定。** 每个请求,app-proxy 带 HMAC 调 backend `POST /api/v1/internal/app-proxy/authorize`。backend(`app_runtime/domain/services/entry_authz_service.py`)依次判断:运行时层开关、平台登录态(token_version、账号禁用、租户禁用)、应用存在且曾上线、`check_business_action(app, use)`、应用态。判定结果与路由各缓存 3 秒,键为(cookie 哈希,slug)。权限引擎不可用时拒绝;backend 返回 app-proxy 不认识的判定值时按拒绝处理。backend 在放行时 fire-and-forget 写 `app_access_log`,app-proxy 本身不连库。 + +**身份头剥离与注入**(`headers.py`)。剥离按归一化前缀匹配:头名先 `lower()` 并把 `_` 换成 `-`,凡以 `x-bisheng-` 开头的一律丢弃。这条规则直接来自 oauth2-proxy CVE-2025-64484:只按连字符写法精确剥离,会让 `X_BiSheng_User_Id` 这类下划线变体经 WSGI 框架的归一化后绕过。app-proxy 同时丢弃客户端的 `x-forwarded-*` / `forwarded` 与 hop-by-hop 头,并从 Cookie 中摘掉平台会话 cookie `access_token_cookie`,防止把长效平台凭据交给容器。之后注入十个头: + +`X-BiSheng-User-Id`、`X-BiSheng-User-Name`、`X-BiSheng-Tenant-Id`、`X-BiSheng-Dept-Id`、`X-BiSheng-Dept-Name`、`X-BiSheng-Dept-Path`、`X-BiSheng-Subject-Kind`、`X-BiSheng-App-Id`、`X-BiSheng-Access-Token`(OBO 令牌)、`X-BiSheng-Request-Id`。 + +含中文的三个头做 UTF-8 percent-encoding。app-proxy 重写 `X-Forwarded-Prefix: /apps/{slug}` 等转发头,转发时剥掉 `/apps/{slug}` 前缀,容器经环境变量 `BISHENG_APP_BASE_PATH` 得知前缀。应用响应里以 `/` 开头的 `Location`,app-proxy 补回前缀(`routing.restore_entry_prefix`),平台不改写 HTML 与 JS。`bisheng dev` 的本地代理按同样的规则把应用挂在 `/apps/{slug}` 下,前缀之外的请求返回 404 和原因说明,漏写前缀的根绝对路径在本地就会暴露。SDK 的 `_headers.py` 与 CLI 迷你代理使用逐字相同的十个头名。 + +**路径与兜底页。** GET / HEAD 请求裸 `/apps/{slug}` 时,app-proxy 在鉴权放行之后返回 308 到 `/apps/{slug}/`;缺这一步时应用的相对资源路径会解析错位,表现为白屏。未登录、无权限、已下线、不存在或未上线四类情况由 app-proxy 自渲染兜底页;导航请求一律返回 200 加页面,只有非导航的 JSON 请求返回 401 / 403 / 404 / 503,原因是扫码打开时浏览器错误页和「平台故障」无法区分。未登录时兜底页用内联 JS 跳平台登录页并回跳。 + +**WebSocket。** WebSocket 鉴权有三条不变量,均已实现(`websocket.py`)。第一,连接的授权有效期在握手时定死为 min(OBO 剩余寿命, `ws_max_lifetime_seconds`) 加随机抖动,到期以 4001 关闭。第二,权限撤销、应用下线时 backend 调 `/internal/connections/close` 主动断连,另有周期性重新鉴权兜底。第三,所有关闭码的语义都是「请重连」,应用侧把重握手当作常态路径。 + +**审批期预览。** `/apps/preview/{session}` 是审批人和 owner 预览实例的入口,由 F055 管理(§5.5)。只有拉起该实例的人能进入。 + +### 4.7 per-app 数据库 + +每个托管应用一个 SQLite 文件,宿主路径 `{data_root}/apps/{app_id}/db/app.db`(`data_root` 默认 `/opt/bisheng/app-data`),容器内为 `/data/app.db`,平台注入 `BISHENG_APP_DB_URL=sqlite:////data/app.db` 与 `BISHENG_APP_DB_PATH`。隔离靠文件与卷边界,没有账号密码类凭据。应用库与平台 MySQL / DM8 解耦,不受双方言约束 C2,生成代码也不需要认识 DM8 方言。设计期曾规划「SQLite 小档 + Postgres schema-per-app 中档」,本版只实现 SQLite,Postgres 中档顺延。 + +SQLite 带来两条硬约束:WAL 模式单写者,且不支持网络文件系统。所以 SQLite 应用必须「单实例 + 本机卷」,多实例或多机之前必须先升档到 Postgres。backend 从不直接打开应用库:数据面读写经 runtime-manager 的 `appdb.py`,接口只接受类型化参数(表名、主键、列值),所有标识符先对照 `sqlite_master` 校验再引用,接口上不存在携带 SQL 的参数,所以 DDL 无法从数据面发出。建表与改表只走发布管线的 `db-migrate` 意图,输入是声明式计划(§5.4)。数据面目前只有单行 PATCH,F052 需要的 `insert_row` / `delete_row` 未交付。 + +### 4.8 附件存储、代码快照与日志 + +托管应用相关对象全部放在独立 MinIO 桶 `bisheng-apps`,不放平台的 `bisheng` 桶,原因是 nginx 的 `location /bisheng/` 会把任意 key 转发给 MinIO,放进公共桶就只剩桶策略一道防线。该桶不挂 nginx location、不设匿名策略,由 runtime-manager 首次使用时创建。 + +| 对象 | 键 | 写入方 | +|---|---|---| +| 代码快照 | `apps/{app_id}/versions/{version_id}/code.tar.gz` | backend `package_service` | +| 应用附件 | `apps/{app_id}/attachments/…`(越界 key 直接拒绝) | runtime-manager `storage.py`,应用经 SDK 调用 | +| 数据库快照 | `apps/{app_id}/db-snapshots/…`(先 `.backup` 再 tar) | runtime-manager | + +应用经注入的 `BISHENG_APP_STORAGE_ENDPOINT` / `_TOKEN` / `_MAX_FILE_MB` 访问附件;未配置 MinIO 时句柄照常注入,但所有存储调用返回 503,`/v1/runtime/status` 的 preflight 会指出。附件不计入租户存储配额。运行日志由 runtime-manager 从 docker 读取,保留窗口即日志轮转窗口;CLI 与 MCP 读日志只允许 owner,platform 详情页按入口分口径。 + +### 4.9 出站白名单 + +出站白名单由三层组成,每一层存在的理由是上一层在某些主机上可能缺席(`runtime_manager/egress.py` 文件头): + +| 层 | 实现 | 作用 | +|---|---|---| +| L1 网络拓扑 | `bisheng-apps` 与 `bisheng-build` 均以 `--internal` 创建,容器无默认路由 | 断开一切直接出网;零成本、配错时可被 preflight 发现 | +| L2 egress-proxy | runtime-manager 包的另一个入口点 `runtime_manager.egress_proxy`,监听 3128,同时接在内网与可出网网络上 | 唯一能看到主机名的一层:CONNECT 盲隧道,按 `Proxy-Authorization` 区分应用,每个应用只放行平台 API、附件端点和它自己声明的 `egress.domains`;直连 IP 拒绝;默认只放 80 / 443;解析后的地址再查一次,防止被当跳板进入内网 | +| L3 宿主防火墙 | `firewall_rules()` 生成 iptables `DOCKER-USER` 或 nftables `bisheng_egress` 表:允许到代理、丢弃全部 UDP、其余丢弃 | 兜住「网络未以 --internal 创建」的情况;也是唯一能拦 UDP(QUIC / HTTP3 无法按主机名过滤)的一层 | + +egress-proxy 与 runtime-manager 的全部契约是策略文件 `{data_root}/egress/policy.json`,代理按 mtime 热加载并在读取失败时保留上一份,所以编排器重启不会带走所有应用的出站。runtime-manager 自动探测防火墙后端:Docker 29 默认的 nftables 后端没有 `DOCKER-USER` 链,iptables 规则会「下发成功」却不生效,探测到 nftables 时 preflight 判红。`RTM_EGRESS_PROXY` 留空表示整层未部署,preflight 明说「出站不受限」,不存在「开关已开但代理不在、出站全断」的组合。设计期曾称「双层」,实现按上表拆为三层。systemd 形态下有一个已知缺口,见 §7.4。 + +--- + +## 5. 发布管线(F055) + +### 5.1 管线形态 + +发布管线是「同步接收端点 + Celery 异步阶段机」,`app_deployment` 表记录一次发布尝试的进度,CLI 轮询 `GET /api/v2/apps/deployments/{id}`。`/api/v2/apps/*` 端点标记 `open_api_scope("app:manage")`,走 §2.1 的单一鉴权管线;应用 owner 取凭据的资源归属人,归属人为空即拒,迭代发布只允许 owner。 + +同步段在 `publish_pipeline_service.accept()` 中秒级完成:解包(包体、解包后大小、文件数三道闸,默认 50 MB / 200 MB / 20000)→ manifest 校验 → 首发建草稿或迭代做归属判定 → 在途与待上线两道闸 → 能力引用校验(16224)→ 代码包写入 MinIO。异步段是 `worker/app_publish/tasks.py::run_publish_pipeline`,跑在 Celery **default 队列**上,与审批 outbox 共用 default worker,不另建队列;所以改动 `app_publish` 后必须重启 celery default worker。异步阶段顺序由 `PIPELINE_STAGES` 决定: + +``` +secret_scan → precheck_schema → precheck_build → precheck_probe → 写版本记录 → 提交审批 +``` + +密钥扫描排在构建之前,与 PRD DEV-04 的字面顺序相反,这是有意的:含密钥的包不应该进入构建容器(spec 决议-13)。任一阶段失败即停,失败原因统一为五元组 `{stage, code, message, details, hints}`(`schemas/failure.py`),CLI 原样展示服务端的 `stage`。预检或扫描失败的提交不进版本列表。 + +### 5.2 应用包声明 `bisheng-app.yaml` + +manifest 的权威定义是 `app_publish/domain/schemas/app_manifest.py`,pydantic `extra='forbid'`,未知字段直接拒绝。CLI 与 runtime-manager 只消费它,不各自定义。 + +| 字段 | 含义 | +|---|---| +| `manifest_version` | 当前为 1,前向兼容闸 | +| `name` / `description` / `icon` / `slug` | 元信息;`slug` 决定入口 `/apps/{slug}`;元信息在通过预检时更新 | +| `runtime` | `python3.11` / `node20` / `static`,不支持返回 16222 | +| `port` | 应用监听端口 | +| `tier` | 资源档位,不写即 `light` | +| `capabilities.models[].name` / `capabilities.knowledge_bases[].{id\|name}` | 能力声明(RT-04) | +| `database.tables[].columns[]` | 应用数据表结构,由平台建表 | +| `egress.domains[]` | 运行期出站域名白名单 | + +设计期 manifest 中的 `entry`、`appdb: sqlite|postgres`、`secrets` 三个字段没有实现:入口命令由模板约定,应用库只有 SQLite,密钥引用原划归 PRD-2,当前预检遇到能力声明中的密钥引用会拒绝;2026-10-09 改判为本版交付最小版(PRD-1 GOV-05),该字段待实现(§9)。 + +### 5.3 密钥扫描 + +扫描器是 `secret_scanner.py` 中的常量规则表 `SECRET_SCAN_RULES`,不引入 gitleaks / detect-secrets,原因是 PRD 要求「与平台内发布同一规则集」,这要求规则集由本仓库持有。规则包括 `bs_sak`、`bs_pat`(引用凭据底座的前缀常量)、`aws_akid`、`openai_sk`、`private_key_pem`、`db_conn_string`(用户名与密码同时出现才命中)和带占位符白名单的 `generic_high_entropy`。命中结果只含 `{rule_id, file, line}`,不带值,也不带掩码值。二进制文件与超过 1 MiB 的文件跳过,并在结果中列为 `skipped`,不静默放过。扫描器不提供行内豁免注释。 + +### 5.4 版本记录、冻结快照与结构演进 + +每次通过预检的发布生成一条 `app_version`,代码包、manifest、能力声明、注入配置(不含明文凭据)、档位标识、runtime、镜像引用作为同一个快照冻结(RT-05)。写入顺序是「先过审批 Gate,再 INSERT」(F055 design D6)。档位只存标识,规格按运行时的当前值解析。平台侧版本回滚本版不做(PRD-1 §1.5),WB-15 只提供版本差异的只读查看。 + +结构演进由 `schema_evolution_service.py` 比较新旧 `database.tables`:只加列时自动迁移;改列或删列必须由开发者显式确认(`bisheng deploy --confirm-schema-change`),并在迁移前留数据库快照。迁移以声明式计划经 `db-migrate` 意图交给 runtime-manager 执行(§4.7)。生产数据不随代码回退。 + +### 5.5 审批接入 + +发布审批复用审批中心(F025),新增场景 `app_publish_request`,接入点四处:预置定义 `approval/domain/services/approval_registry.py`、种子数据 `approval_seed_service.py`(启动时与新建租户时各执行一次)、运行期 handler 工厂分支 `approval_runtime_handler_factory.py`、业务 handler `app_publish/domain/services/app_publish_scenario_handler.py`。预置流程是「owner 主部门的部门管理员 ∪ 租户管理员,或签」,Root 租户回退平台超管;申请人是应用 owner 本人,不是服务账号。审批通过后,outbox 在 Celery default worker 上执行 `on_approved`。审批模块的整体结构见 `approval-module` 技能。 + +**发布必审**(INV-34)。场景预置带 `mandatory_approval=True`。`ApprovalScenarioAdminService._assert_route_type_allowed` 在新建与修改路由时拒绝免审分支,返回 18119;`ApprovalGate._forbids_pass` 是第二道防线,即使库里已有免审路由,匹配到也按「未匹配」处理并记 `route_missing` 异常,不放行。这一缺陷在 2026-09-18 验收时发现并修复。 + +**审批期预览。** 审批人和 owner 都可以拉起待上线版本的临时预览实例(拉起权限沿用 `ReviewAccess`;owner 预览注入本人身份、按本人权限放行;`preview_instance_service.py`,`app_preview_session` 表),入口为 `/apps/preview/{session}`。预览实例的 `/data` 用 tmpfs、不注入附件凭据,寿命由 `app_runtime.preview_ttl_days`(默认 7 天)与审批终态共同决定。预览中平台能力按 owner 权限放行并计审计,这是 INV-36 唯一的例外。审读视图提供文件树、代码与版本差异(`snapshot_browse_service.py`、`version_diff_service.py`)。 + +### 5.6 上线终检与资源档位 + +上线终检在 `publish_online_service.py` 中执行,规则是「应用会自己好转或点一下就好的,标状态返回;真正出错的,抛异常」。容量准入失败时应用进入 `pending_capacity`(待上线·资源不足);拉起或探活失败时应用同样进入待上线态,版本终态标「待上线」;应用已删除或已下线时只登记版本、不拉起;编排器不可达或状态机拒绝时抛异常,outbox 把审批实例记为 `execute_failed`。F055 从不直接写 `app.state`,一律调用 F054 的 `AppStateService`。手动上线不再走审批。 + +资源档位(GOV-03)预置三档,常量是 `app_runtime/domain/constants.py::DEFAULT_TIERS`,F055 从它 seed `resource_tier` 表,表存在后以表为准: + +| 档位 | 规格 | 适用 | +|---|---|---| +| `light` 轻量(默认) | 0.5 vCPU / 1 GiB | 内部工具、表单、看板类,并发个位数 | +| `standard` 标准 | 2 vCPU / 4 GiB | 带数据处理或文件解析,日常并发几十 | +| `performance` 性能 | 4 vCPU / 8 GiB | 计算密集或常驻大内存 | + +轻量档在 PRD-1 中写为 1C/2G,F055 spec 决议-10 于 2026-09-09 按实测下调为 0.5C/1G:五个在线应用合计只用 0.05% CPU 与 98 MiB,每应用占一整核会让 8 核机器最多同时运行 6 个应用,瓶颈在 CPU 配额闸而不在机器本身。档位只能停用不能删除,平台超管在系统管理「资源档位」tab 调整规格(审计 `app.tier_update`)。未写过档位的部署可以在首次启动前用 `app_runtime.default_tiers` 覆盖出厂规格。 + +### 5.7 能力总线与应用运行期凭据 + +**能力总线**(GOV-05,`capability_bus_service.py`)把能力声明变成注入与判权。当前实现覆盖模型与知识库;第三项应用密钥(owner 掩码录入、平台注入同名环境变量,PRD-1 GOV-05)于 2026-10-09 定为本版交付,尚未实现,注入通道已为引用名预留位置。模型侧,平台注入 `OPENAI_BASE_URL` 与 `BISHENG_MODEL_BASE_URL`(均为 `{平台}/api/v2/model/v1`)以及 `OPENAI_API_KEY`(等于 `BISHENG_APP_TOKEN`),应用用标准 `openai` 客户端调用 F051,可调范围是当前生效的声明,拒绝沿用 265 段错误码。知识库侧,白名单在运行期从 `app.current_version_id` 指向的版本读取,不缓存、不由应用上报,可及范围 = 白名单 ∩ 当前访问用户可见范围,由 §2.3 的检索门面按文件级计算;拿不到访问用户身份即返回 26320,不回退为 owner 权限或声明全量(INV-36)。能力被管理员收回后,「已失效」状态在查询时实时计算,不落库,收回与未声明分别返回 16273 / 16274。注入变量经 `app_runtime/domain/services/runtime_env_ports.py` 端口合并,因为 arch-guard RULE-5 禁止 `app_runtime` 反向 import `app_publish`;`BISHENG_` 前缀是平台保留名,平台值覆盖同名的应用值。 + +**应用运行期凭据**(GOV-08,`app_credential_service.py`)是主体类型 `hosted_app` 的 `bs-app-` 凭据。因为 `api_credential.subject_id` 是整数而 `app.id` 是字符串,每个应用在 `hosted_app_subject` 表领一个整数代理 id,该行永不删除,保证代理 id 不被复用。签发时机是审批通过、拉起新容器之前:先撤销旧凭据再签发新凭据,明文只经环境变量 `BISHENG_APP_TOKEN` 注入一次,不落库。下线时停用主体,重新上线后同一把凭据恢复可用;删除应用时由 F054 `lifecycle_hooks` 的订阅者撤销。撤销 5 秒内生效。`hosted_app` 主体只能进入标了 `hosted_app=True` 的路由,目前只有 `POST /filelib/retrieve` 与模型面三条路由。签发与撤销写 `open_api.api_key.issue/revoke` 审计,操作者为系统。 + +应用调用平台能力时出示两把凭据:`Authorization: Bearer ` 证明「哪个应用」,`X-BiSheng-Access-Token`(OBO 令牌)证明「为谁」。应用运行期凭据本身的 `effective_user_id` 有意为空,访问用户只能来自 OBO 令牌。 + +**能力调用记录。** 托管应用的检索调用写 `app_capability_call_record`(应用、能力、目标、结果、chunk 数、时延、凭据 id,双归属为「应用 × 访问用户」),模型调用写 F051 的 `model_call_record`,两者都不进 `audit_log`。这两张表与 `app_access_log` 目前只写不读,见 §6.3。 + +--- + +## 6. 治理(F056 及 F054 类型注册) + +### 6.1 权限资源类型 + +托管应用以资源类型 `app` 注册进 F048 权限体系,走 `MIGRATED_RESOURCE_TYPES` 全适配器路线(`core/openfga/authorization_model_f048.py`,适配器 `app_runtime/domain/services/f048_app_permission.py`),注册归 F054,F056 只消费。`app` 按扁平资源类型建模,与 channel、dashboard 同形,适用动作是 `manage_permission`、`edit`、`delete`、`use`、`publish`、`unpublish`;入口判定用的是 `use`。 + +FGA 模型在本分支命名为 `MODEL_VERSION = "f048-v5-app"`,规则是「所基于的发版线版本号 + `-app`」,避免占用发版线编号。114 上登记名仍是 `f048-v6`,checksum 相同,无需重新发布。应用工场正式合入发版线时,116 / 105 需要停服执行 `src/backend/scripts/publish_authorization_model_change.py` 发布含 `app` 的模型。存量环境的 Catalog 范围表变更靠 F054 新写的升级脚本补 scope 行,该脚本只覆盖 `app` 一种类型。 + +### 6.2 应用广场与可见范围 + +应用广场复用工作流 / 助手的既有列表,在 `database/models/flow.py` 的 UNION 中加第三支(投影 10 列,含 `app_state`)。广场请求对托管应用豁免 `status` 条件,改为服务端写死只取 `{online, stopped}`,所以草稿在行集层面就不存在;动作口径从 `visible` 改为 `use`;搜索只按名称。卡片点击用 `window.location.assign('/apps/{slug}')` 整页跳转。 + +可见范围有两个入口:构建页卡片 ⚙️「管理权限」与发布面的 `VisibilityScopeSection`,两者拉起同一个 `PermissionDialog`,零新建授权界面。「仅 owner 可见」的判据是非 protected 授权行数为 0。可见范围变更不经审批、即时生效,由挂在 F048 `mutate_grants` 上的按类型回调写 `app.visibility_change` 审计。界面通道的「创建应用」沿用既有 `create_app` 权限点,CLI 通道由密钥 `app:manage` 位把关,两条通道互不校验(GOV-07)。 + +### 6.3 审计 + +应用工场写四类记录,可查询性不同: + +| 记录 | 内容 | 写入方 | 查询入口 | +|---|---|---|---| +| `audit_log` 中的 `app.*` / `app.release.*` 事件族 | 发布、上线、下线、删除、元信息、可见范围、数据编辑与导出、档位调整 | F054 / F055 / F056 | 有:审计页,后端白名单、`log.ts`、三语文案四处同步 | +| `model_call_record` | 逐条模型调用 | F051 | **无**:DAO 有 `alist` / `aiter_export`,业务代码零调用 | +| `app_capability_call_record` | 托管应用检索调用 | F055 | **无**:只有 `ainsert` | +| `app_access_log` | 谁在什么时间进入哪个应用 | F054 | **无**:`alist_page` 零调用方 | + +后三类只写不读,PRD-1 GOV-04 验收要点 1、3 目前不可达。2026-10-09 定为方案 A:审计页新增「应用运行记录」tab,三类记录各一个子视图(PRD-1 GOV-04),待实现(`features/v3.0.0/056-app-square-governance/tasks.md` 末节)。实现时运行期凭据审计行的 `metadata` 需顺带补 `app_id`。`audit_log` 新增 action 时必须四处同步(`AppAuditAction` 枚举、`audit_log.py` 白名单、platform `log.ts`、三语文案),否则事件写入但界面不可见,lockstep 测试遍历的是 `AppAuditAction`。 + +### 6.4 事件触达 + +发布相关事件通过站内信触达,全部是纯陈述消息,不带操作按钮(`app_publish/domain/services/publish_notification_service.py`)。审批通过、驳回、撤回的消息由审批引擎发出;F055 只补发三类:审批单创建后通知首节点审批人、容量不足进入待上线、拉起失败进入待上线,后两类发给 owner 与管理员的并集。应用删除导致审批取消时由 `cancel_instance_by_business` 通知。上线成功不额外发站内信。应用态变化的其余通知在 `app_runtime/domain/services/state_change_notify.py`。 + +--- + +## 7. 安全模型 + +### 7.1 身份信任根 + +托管应用获得访问者身份的唯一可信来源是 app-proxy 注入的请求头(INV-32)。这条不变量依赖四个条件同时成立:容器不发布端口、只能经 app-proxy 到达;app-proxy 按归一化前缀剥离一切 `x-bisheng-*` 客户端头;app-proxy 摘掉平台会话 cookie,不把平台凭据交给容器;OBO 令牌用独立的 `obo_secret` 签发,与 `jwt_secret` 不同,防止 OBO 令牌被当作平台会话重放。应用侧不得自建登录页,SDK 在缺身份时抛异常而不是返回匿名。本地 `bisheng dev` 期身份是开发者服务账号,不提供模拟开关。 + +进程间调用用三把独立的 HMAC 密钥(§4.2)。HMAC 只防篡改、不防暴露,所以 runtime-manager 与 app-proxy 默认只监听回环地址;systemd 形态下若要让容器访问附件接口,runtime-manager 需要改监听 `bisheng-apps` 网桥网关地址,该地址只在宿主本地可达。 + +### 7.2 编排特权隔离 + +backend 编译期与运行期零编排依赖:`scripts/arch-guard.sh` RULE-10 禁止 `src/backend/bisheng/**` import docker / kubernetes 客户端、出现 `/var/run/docker.sock` 字面量、读取 `DOCKER_HOST`。编排一律经 runtime-manager 的意图 RPC 下发期望态。app-proxy 与 egress-proxy 都不持有 docker 权限。compose 形态下 runtime-manager 也不直接持有 socket,只能经 docker-socket-proxy 调用白名单端点(§4.3)。特权逐级递减的链路是 backend → runtime-manager → docker-socket-proxy → dockerd。 + +### 7.3 沙箱分档 + +| 档位 | 实现 | 状态 | 适用环境 | +|---|---|---|---| +| 基础档 | Docker 加固:只读 rootfs、`no-new-privileges`、非 root、`--internal` 网络、资源限额、restart policy | **已实现,唯一一档** | 全部环境,含 LoongArch | +| 推荐档 | gVisor systrap 平台(无需 KVM) | 未实现 | x86_64 / ARM64 虚机 | +| 加固档 | gVisor-KVM / Kata / Firecracker | 未实现 | **仅裸金属** | + +档位规则来自选型调研:gVisor 官方只推荐在裸金属上用 KVM 平台,虚机内即使有嵌套 KVM 也应用 systrap;Kata 全系 VMM 硬依赖 KVM;gVisor 与 Kata 都不支持 LoongArch,龙芯机型只有基础档。gVisor 的 netstack 没有按域名过滤出站的能力,换 runtime 不解决出站问题,所以出站白名单的优先级高于沙箱档位。 + +### 7.4 出站白名单与已知缺口 + +出站白名单的三层实现见 §4.9。它有一个已知安全缺口:开启白名单后,**systemd 形态**下托管应用容器仍可经 `bisheng-apps` 网关 IP 直连宿主上监听 `0.0.0.0` 的服务(114 上实测可达 MySQL 3306、backend 7860、MinIO 9000、SSH 22)。原因是 `--internal` 只断默认路由,访问网关地址的包走宿主 INPUT 链,而 L3 规则以 `-s <应用网段>` 挂在 `DOCKER-USER`(FORWARD 路径)上,碰不到这些包。修复方向是在宿主 INPUT 链补规则:对应用网段只放行 egress-proxy 与附件端口。2026-10-09 定为合入发版线前修复,已登记到 F054 tasks.md。compose 形态下 `docker/docker-compose.yml` 把 MySQL 3306、backend 7860、MinIO 9100 等端口发布到宿主,容器经网关 IP 访问这些发布端口的包是否会被 L3 规则拦截,取决于 docker 的端口转发实现,目前没有验证,合入前需要一并核对。 + +### 7.5 密钥零暴露 + +平台自签发凭据服务端只存单向哈希,明文只在签发时展示一次(INV-28)。CLI 凭据文件 0600 原子创建。发布前密钥扫描门阻断含裸密钥或连接串的包,命中结果不含值(§5.3)。应用运行期凭据只经环境变量注入一次,不进 `app_version.injections`、不落库。HMAC 密钥与 OBO 密钥经环境或 `config.yaml` 注入,`config.yaml` 中的密码按平台惯例 Fernet 加密。应用级密钥录入(secret store)划归 PRD-2,本版不提供。 + +### 7.6 fail-closed 矩阵 + +下表列出各判定点在依赖不可用时的行为。前五行是 fail-closed;后两行是已知的 fail-open,均有记录在案的理由,但需要在合入前复核。 + +| 判定点 | 依赖不可用或结果不可判定时 | +|---|---| +| `/api/v2` 端点漏标 `open_api_scope` | 拒绝 | +| 检索门面权限评估(MCP / v2 / 能力总线 / SDK) | 整体报错,不返回部分结果(INV-30) | +| 托管运行期检索缺访问用户身份 | 26320 拒绝,不回退为 owner 或声明全量(INV-36) | +| app-proxy 入口判定:权限引擎不可用、未知判定值、HMAC 密钥为空 | 拒绝 / 503 | +| runtime-manager HMAC 密钥为空 | 401,backend 报 16121 | +| 入口判定中「租户已禁用」检查遇 Redis 不可达 | **放行**,与 `CustomMiddleware` 行为一致;只有权限引擎是 fail-closed。2026-10-09 确认维持:改成拒绝会让一次 Redis 抖动锁死所有租户的所有托管应用,而它防的只是刚停用的租户在故障期间短暂可进入 | +| `obo_secret` 缺失或等于 `jwt_secret` | **不签发 OBO 令牌但照常放行入口**;此时应用的能力调用因缺身份被拒。2026-10-09 定为合入发版线前改为**拒绝进入**(入口显示配置不完整页,启动日志报错),已登记到 F054 tasks.md | + +--- + +## 8. 部署形态 + +本节说明各形态的架构差异。装机步骤、`app_runtime` 与 `open_platform` 配置项、升级顺序和排障对照表见 `docs/architecture/14-app-factory-deployment.md`。 + +### 8.1 compose 形态 + +compose 形态在 `docker/docker-compose.yml` 中以 profile `app-runtime` 声明四个服务:runtime-manager、docker-socket-proxy、egress-proxy、app-proxy,都不发布端口。不带这个 profile 启动即等于工场运行时层整层不装。两张网络 `bisheng-apps` 与 `bisheng-build` 以 `internal: true` 和显式 `name:` 声明,显式命名是因为 runtime-manager 用裸 Docker API 按真名引用网络,compose 默认的项目名前缀会让它找不到网络。runtime-manager 需要同时设置 `RTM_DATA_ROOT`(容器内路径)和 `RTM_HOST_DATA_ROOT`(宿主路径),因为 bind 路径由宿主 dockerd 解释。静态守卫是 `docker/verify-app-runtime-compose.sh`。compose 形态从未在真机启动过(F054 T074),此前发现的三个缺陷都被 systemd 形态恰好正确的配置掩盖,所以 114 跑通不能证明 compose 可用。 + +### 8.2 systemd 形态(114) + +114 运行 systemd 形态。三个单元的模板在 `features/v3.0.0/054-app-domain-runtime/deploy/`:`bisheng-runtime-manager.service`(`Requires=docker.service`)、`bisheng-app-proxy.service`、`bisheng-egress-proxy.service`,均 `PartOf=bisheng.target`,环境变量分别读 `/etc/bisheng/{runtime-manager,app-proxy}.env`。真身在独立运维仓 `bisheng-ops/systemd/`,`/opt/bisheng-ops/deploy.sh` 部署时一并重启。前置资源是 `docker network create --internal bisheng-apps` 与 `bisheng-build`;已存在的网络不会被 `create` 改为 internal,必须先停全部托管应用、删网、重建,preflight 的 `application_network_internal` 一行用来发现这种情况。egress-proxy 必须监听 `bisheng-apps` 网桥网关地址而不是 `0.0.0.0`,且与 runtime-manager 的 `RTM_EGRESS_PROXY` 逐字相同。nginx 在 `/etc/nginx/conf.d/` 的入口 conf 中加 `location /apps/`。 + +### 8.3 k8s 形态(本版交付,实施中,尚未交付) + +k8s 执行后端是 F059。2026-10-09 已定为本版交付、不顺延(PRD-1 GOV-10);2026-10-21 修订 spec(55 条有效 AC)已评审且用户确认,当前 [F059 design](../../../features/v3.0.0/059-k8s-runtime-backend/design.md) 已经用户确认,tasks 的80项拆解与评审已完成;当前39项任务完成本地验证(含控制、材料Service/HTTP/client、隔离helper与节点协调),执行器、平台链路及集群部署尚未完成。设计复用 F086 默认应用包、完整镜像与显式兼容构建合同,选择平台直接管理固定生产 Pod/Service、独立生产 PVC、范围化材料初始化授权、命名空间最小权限与独立准入、代理出站和真实能力探测。SQLite 更新须确认旧 UID 全部写入者停止,不能只凭单副本或 RWO 接管。现有代码耦合还包括 operations、bundle 准备/启动和数据 helper,不能把 docker_backend.py 的 Protocol 注释当作“只换一个文件”的实现结论。INV-33 的产品面一致和部署期二选一不变;控制器/网络/存储的具体方案以已确认 design 和后续验收为准,不能按此段宣称已部署验收。 + +### 8.4 可选部署(GOV-10) + +不部署工场运行时层的环境,平台既有功能零新增常驻进程、行为零变化(NFR-4)。开关组合见 §1.3。`/api/v1/env` 同时下发四个互相独立的开关:`open_platform_enabled`、`app_runtime_enabled`、`personal_token_enabled`、`open_api_management_enabled`。`config.yaml` 新增顶级键会让老镜像拒绝启动,部署新版本时需要确认镜像与配置同步升级。 + +### 8.5 信创与离线约束 + +| 环境 | 构建 | 沙箱 | 出站 L3 | 备注 | +|---|---|---|---|---| +| x86_64 / ARM64 虚机(无嵌套虚拟化) | Dockerfile 模板 + 私有包源 | 基础档 | iptables `DOCKER-USER` | gVisor systrap 在鲲鹏 / 飞腾 + 麒麟 / 统信内核上无实测 | +| x86_64 / ARM64 裸金属 | 同上 | 基础档(KVM 类档位未实现) | 同上 | — | +| LoongArch | 同上 | 仅基础档 | 同上 | gVisor 与 Kata 均不支持 | +| 完全离线 | 基础镜像随安装包预置;`RTM_BUILD_INDEX_URL` / `RTM_BUILD_NPM_REGISTRY` 指向内网源 | 同上 | 同上 | 只预置部分基础镜像时,runtime 列表自动收窄 | +| Docker 29+(nftables 后端) | — | — | 无 `DOCKER-USER` 链,需 nftables 表或锁定 iptables 后端 | preflight 判红 | + +设计期规划的 devpi(PyPI)与 verdaccio(npm)自托管镜像没有随产品交付,实现只提供包源地址配置项,由部署方自备内网源。 + +--- + +## 9. 已知缺口与技术债 + +| 类别 | 事项 | 位置 | +|---|---|---| +| 安全缺口(合入前修) | systemd 形态容器可经网关 IP 直连宿主 `0.0.0.0` 服务,需补宿主 INPUT 规则并核对 compose 形态(§7.4) | 054 tasks.md | +| 安全缺口(合入前修) | `obo_secret` 缺失或等于 `jwt_secret` 时入口照常放行,改为拒绝进入(§7.6) | 054 tasks.md、`entry_authz_service.py:587` | +| 安全复核 | 容器规格无 `CapDrop` / `PidsLimit` | `lifecycle.py` | +| 待开发 | GOV-04「应用运行记录」tab(§6.3,方案 A) | 056 tasks.md 末节 | +| 待开发 | GOV-05 应用密钥最小版:发布 tab 掩码录入、加密存储、注入同名环境变量、预检改为缺值警告(§5.2、§5.7) | 055 tasks.md | +| 待开发 | GOV-10 k8s 形态(§8.3) | F059 | +| 功能缺口 | 数据面缺 `insert_row` / `delete_row`,MCP 应用数据工具只能单行改 | 054 tasks.md 跨 Feature 回写表 | +| 功能缺口 | 本地 `bisheng dev` 无法端到端验证 `retrieve`(`bsdev.` 句柄平台不受理);v2 `retrieve` 的 `knowledge_base_ids` 仍要求非空 | 052 / 057 tasks.md | +| 功能缺口 | CLI `--wait` 上线后拿不到 `entry_url`,`logs` 响应缺 `app_state` | 053 tasks.md | +| 已知不一致 | 能力声明写裸模型名、事后出现跨服务商同名时,模型面按集合成员放行限定名,与发布面解析不同源 | 051 tasks.md 偏差第 20 条 | +| 未实现 | gVisor 档、Postgres 中档、多实例与多机、idle 回收、长期日志留存与日志脱敏、代码存档计入存储配额、平台侧回滚 | 054 design §8 | +| 部署形态 | compose 形态从未真机启动(T074) | 054 tasks.md | +| 验收未跑 | 114 上 37 个发布审批单全部 `executed`:驳回、撤回、删除致取消、审批期预览、托管应用检索从未真跑;F052 集合相等与真 MCP 客户端需 CI 中间件 | README「未结事项」 | +| 合入发版线 | 应用工场合入 `feat/3.0.0-beta3`;116 / 105 停服发布含 `app` 的 FGA 模型 | `beta2-openapi-base-migration.md` §6 | +| 多租户 | `resolve_service_account` 在租户上下文装好前执行且无 bypass,多租户部署下可能 503;服务账号停用码被并入 26002 | `beta2-openapi-base-migration.md` §7 | +| 文档债 | F053 / F054 / F055 design.md 正文未按实现改写 | 各 design.md | +| 遥测 | 托管应用落入 `ApplicationTypeEnum.UNKNOWN` 桶 | 054 tasks.md | + +--- + +## 10. PRD-2 相关的技术设计(搁置) + +PRD-2(平台内对话式造应用工作台与应用管家)已无限期搁置,下列设计均**未实现**,只保留结论供将来立项时参考。 + +**引擎适配层。** 设计为 Python `RuntimeAdapter` 抽象,按「CLI 进程 + JSONL / JSON-RPC 事件流」建模,统一 `run(RunConfig)` 契约,流式事件归一化为 text_delta / thought / tool_use / tool_input_delta / tool_result 五类。引擎候选按 2026-07 调研排序为 Qwen Code(Apache-2.0,默认候选)、Codex CLI(Apache-2.0,第二适配器)、Kimi Code CLI(MIT,观察位);Claude Code 无分发授权,永不内嵌,只作客户自装的兼容位。默认引擎须由 Wave-0 PoC 按量化 go/no-go 门槛决定,评测口径必须包含多轮鲁棒性,并以模板生成器作对照基线;若开源引擎在国产模型上无法稳定多轮驱动,模板生成器即成主干。 + +**引擎凭据。** 设计为凭据档案 `auth_profile`,与引擎种类正交,三条路径均由管理员配置:A 平台模型代理(默认,会话 key 随会话签发吊销)、B 客户订阅登录态(加密入档、挂载注入、档案级并发闸)、C 客户自有厂商 key(建议仍作为平台代理上游)。会话 key 与本版应用运行期凭据是不同用途,届时仍应复用 §2.1 的凭据底座。路径 A 若要支持 Anthropic 协议引擎,需要重新评估 §2.2「只做 OpenAI 兼容」的决定。 + +**基础组件注入。** 设计为四条通道:模板预接线、声明式资源清单(manifest 能力声明与数据表结构)、MCP 工具面、引擎指令约束加发布前扫描。其中声明式清单、MCP 工具面与发布前扫描已在 PRD-1 中实现,PRD-2 可直接复用同一规则集与同一 manifest。 + +**草稿沙箱与工作区。** 设计为草稿态与生产态共用 runtime-manager 设施,预览经 `/apps/preview/{session}/`;工作区(git + node_modules)需要新增 tar / bulk-snapshot 层,因为灵思 workspace 是逐文件 write-through 对象缓存,不是时间点快照。PRD-1 已实现的审批期预览实例(§5.5)可作为草稿预览的起点。应用级 secret store(Fernet)、管家持续看护及其依赖的平台触发 / 调度子系统、通知外发服务、postMessage 嵌入白名单均随 PRD-2 或 v3.1 立项。 + +--- + +## 附录 A:设计期决策记录 + +状态取值:**已落实** = 实现与结论一致;**被推翻** = 实现改用了其他做法;**未实现** = 结论仍有效但本版没做;**搁置** = 随 PRD-2 搁置。 + +| 议题 | 结论与理由 | 状态 | 出处 | +|---|---|---|---| +| 编排特权 | 独立最小权限编排器 runtime-manager,backend 零 docker 依赖、意图式 RPC;backend 直挂 docker.sock 等于控制面握有宿主 root | 已落实(§4.3、§7.2) | Review T1-1 | +| socket 面收窄 | runtime-manager 与 dockerd 之间加 docker-socket-proxy 端点白名单 | 已落实(compose 固定,systemd 可选) | 调研 §2.6 | +| 编排器厚度 | 自研「薄」层:崩溃重启下沉 restart policy,只补 unhealthy 重建与切流量;不用 Nomad / swarm / quadlet | 已落实 | 调研 §2.6 | +| 入口鉴权 | 自研独立 app-proxy,取代 nginx `auth_request`;nginx 与 Java 网关都只转发 `/apps/` | 已落实(§4.6) | Review T2-1 | +| header 剥离 | 按下划线 / 连字符 / 大小写归一化的前缀匹配,避免复刻 CVE-2025-64484 | 已落实 | 调研 §2.3 | +| WebSocket 生命周期 | 握手定死有效期 + 吊销事件主动断连 + 重连常态化 | 已落实 | 调研 §2.9 | +| 双 token 模式 | 应用凭据 + 用户 OBO 令牌 + 身份头,同构 Databricks Apps | 已落实(§5.7) | 调研 §2.1 | +| 能力声明收权 | 有效权限 = 声明 ∩ 用户自身权限,只收窄不放大 | 已落实(INV-36) | 调研 §2.2 | +| 构建路线 | 平台持有 Dockerfile 模板 + 基础镜像矩阵;开发者不写 Dockerfile | 已落实(§4.4) | 调研 §2.5 | +| Railpack / Nixpacks | 否决:Nixpacks 进入维护模式;Railpack 构建期联网拉 Mise、frontend 镜像在 ghcr.io、无 air-gap 支持 | 已否决 | 调研 §2.5 | +| CNB buildpacks | 设计期列为 x86_64 / arm64 可选增强 | 被推翻:实现不采用,只保留模板矩阵 | 调研 §2.5、F054 D3 | +| 依赖源 | 构建期只放行自托管包源,运行期零包源 | 已落实(包源由部署方提供,devpi / verdaccio 未随产品交付) | Review T2-4、调研 §1 | +| 沙箱默认档 | Docker 加固 | 已落实(唯一一档) | 调研 §2.4 | +| gVisor 推荐档 | 虚机用 systrap,无 KVM 依赖 | 未实现 | 调研 §2.4 | +| KVM 类加固档 | 收紧为仅裸金属 | 未实现 | 调研 §2.4 | +| LoongArch | 只能基础档 | 已落实(基础档天然适用) | 调研 §2.4 | +| 出站白名单 | 设计期为「`--internal` + egress-proxy 双层,DOCKER-USER 兜底」;封 UDP;不用 DNS-only 过滤 | 已落实,实现拆为三层;systemd 形态有 INPUT 链缺口 | 调研 §2.8 | +| 防火墙后端 | 探测 iptables / nftables,Docker 29 nftables 无 DOCKER-USER | 已落实(探测并判红) | 调研 §2.8 | +| 应用数据库 | SQLite 小档 + Postgres schema 中档,与平台 MySQL / DM8 解耦 | 部分落实:只有 SQLite | Review T2-2、调研 §2.7 | +| 生产数据回滚 | 代码 / 配置可回退,生产数据默认不回滚;改删列须确认并留快照 | 已落实;平台侧回滚本版不做 | Review T2-2 | +| 模型协议面 | 设计期为 OpenAI + Anthropic 双协议,用 LiteLLM / one-api 承载 | 被推翻:只做 OpenAI 兼容,backend 内自研薄翻译层;现成代理需同步第二份服务商凭据且不知租户可见范围 | 产品方案 §3.4、Review T2-3、F051 D1 | +| 模型记账 | 设计期为外部代理 per-key 用量回流 `llm_token_log` | 被推翻:独立表 `model_call_record`,token 口径与 MODEL_INVOKE 遥测同源,不写 `llm_token_log` | Review T2-3、F051 D9 / D10 | +| API key 三类 | 设计期为个人 key / 应用 token / 会话 key | 被推翻:服务账号密钥 + 个人访问令牌 + 应用运行期凭据,共用一个底座;会话 key 随 PRD-2 | 产品方案 §3.4 | +| 开放 API 底座 | vibe F049 自研 | 被推翻:改用发版线 beta2 F053,避免主体判定双真相 | `beta2-openapi-base-migration.md` | +| `hosted_app` 主体 | 迁移方案 M10 曾定本轮不做 | 被推翻:F055 已实现 `bs-app-` 与 `hosted_app_subject` | F055 T055 | +| CLI 命令 | login / dev / deploy / logs / skills sync,不设 init | 已落实,另加 `platforms` | 产品方案 §3.6 | +| SDK 范围 | 设计期五件套 auth / retrieve / chat / storage / appdb | 被推翻:三件套 auth / retrieve / storage;模型与数据库用行业标准库 | 产品方案 §3.4、PRD-1 DEV-07 | +| 技能包 | 设计期首发四包 | 被推翻:两包 deploy-hosting / platform-wiring | 产品方案 §3.6、PRD-1 DEV-03 | +| 密钥扫描 | presence check,不判鉴权对错;自持规则集 | 已落实(§5.3),扫描前移到构建之前 | 产品方案 §4.4 | +| 依赖 CVE 扫描 / SBOM | 视人力 v3.1 补齐 | 未实现 | 产品方案 §4.5 | +| 应用级 secret store | Fernet,声明式注入 | 搁置(随 PRD-2) | Review T3-5 | +| 容量与密度 | 准入控制、idle 回收、并发上限、cgroup 硬隔离挂 runtime-manager | 部分落实:准入双闸与 cgroup 限额已实现,idle 回收未实现 | Review T3-4 | +| 资源档位 | 三档预置,不提供自定义 | 已落实;轻量档 1C/2G 下调为 0.5C/1G | F055 决议-10 | +| 错误码段 | 设计期建议 130–133 或 161 起四段 | 已落实:161 F054 / 162 F055 / 163 F056 / 164 F059 预留;开放能力层 260 / 263 / 265 | Review T3-1、release-contract | +| 发布审批 | 首发与迭代每次必审,无免审路径 | 已落实,18119 阻断免审配置 | 产品方案 §3.5、INV-34 | +| 迭代审批变更摘要 | 审批单呈现四类变更摘要 | 未实现(PRD-1 §1.5 明确不做),以版本差异视图替代 | 产品方案 §3.5 | +| k8s 多机 | v3.1 只换 runtime-manager 执行后端 | 2026-10-09 改判本版交付,未实现(F059 仅 spec) | 产品方案 §3.3 | +| 引擎 PoC 与模板对照 | 量化 go/no-go + 多轮鲁棒性 + 模板生成器对照 | 搁置 | Review T1-2 | +| 管家常驻态 | 重标为平台触发 / 调度子系统,管家作首个消费者 | 搁置 | Review T1-3 | +| 工作区快照层 | 新增 tar / bulk-snapshot 层 | 搁置 | Review T2-5 | +| org_sync 推送 | 零外发能力,须新建通知服务 | 搁置 | Review T3-2 | +| postMessage 嵌入 | 新建 postMessage + 域名白名单 | 搁置(RT-09 顺延) | Review T3-3 | + +## 附录 B:旧文档章节映射 + +`features/` 下多份 spec 仍引用旧文档章节,按下表查找对应内容。 + +| 旧文档章节 | 本文位置 | +|---|---| +| 《产品方案》§3.3 应用运行时与发布(manifest、统一入口、版本与回滚) | §4.1、§4.6、§5.2、§5.4 | +| 《产品方案》§3.4 企业能力总线(API key、双协议代理、MCP、SDK、技能包) | §2.1、§2.2、§2.3、§3、§5.7;决议见附录 A | +| 《产品方案》§3.5 治理面 | §5.5、§6 | +| 《产品方案》§3.6 专业开发者通道 | §3 | +| 《产品方案》§4.1 总体架构 | §1.2、§1.3 | +| 《产品方案》§4.2 引擎适配层与选型 | §10 | +| 《产品方案》§4.3 引擎凭据与模型接入 | §10 | +| 《产品方案》§4.4 基础组件注入机制 | §5.3、§5.7、§10 | +| 《产品方案》§4.5 沙箱与安全模型 | §4.3、§4.6、§4.9、§7 | +| 《产品方案》§4.6 平台既有资产复用 | §6.1、§5.5、§2.3;未实现项见附录 A | +| 《产品方案》§4.7 合规与风险 | §7、§9、附录 A、附录 C | +| 《产品方案》§4.8 体量与部署形态 | §1.3、§8 | +| 《产品方案》§5.1 本版 Feature 拆解(F100–F113) | §1.4;F1xx 与 F049–F059 的映射见 `features/v3.0.0/release-contract.md` 表 3 | +| 《调研》§0 / §1 总判定与选型速查 | 附录 A | +| 《调研》§2.1 双 token / §2.2 能力声明收权 | §5.7、§7.1 | +| 《调研》§2.3 header 剥离 | §4.6、§7.1 | +| 《调研》§2.4 沙箱分档 | §4.5、§7.3 | +| 《调研》§2.5 构建路线 | §4.4 | +| 《调研》§2.6 单机编排 | §4.3 | +| 《调研》§2.7 per-app 数据库 | §4.7 | +| 《调研》§2.8 出站白名单 | §4.9、§7.4 | +| 《调研》§2.9 WebSocket 生命周期 | §4.6 | +| 《调研》§3 信创 / air-gapped 矩阵 | §8.5 | +| 《调研》§4 三条红线兼容性 | §1.3、§7.2 | +| 《调研》§5 回写建议 / §6 残留问题 | 附录 A、§8.5、§9 | +| 《Review》§2 复用表对账 | 附录 A | +| 《Review》T1-1 特权悖论 | §4.3、§7.2 | +| 《Review》T1-2 引擎实效 / T1-3 管家常驻态 | §10 | +| 《Review》T2-1 auth_request 与网关冲突 | §4.6 | +| 《Review》T2-2 应用数据库与回滚语义 | §4.7、§5.4 | +| 《Review》T2-3 双协议模型代理 | §2.2 | +| 《Review》T2-4 出站白名单与依赖安装 | §4.4、§4.9 | +| 《Review》T2-5 workspace 快照层 | §10 | +| 《Review》T3-1 错误码段位 | 附录 C | +| 《Review》T3-2 org_sync 推送 / T3-3 postMessage | §10 | +| 《Review》T3-4 容量密度 | §4.3、§5.6 | +| 《Review》T3-5 应用 secrets | §7.5、§10 | + +## 附录 C:错误码段 + +| 段 | 模块 | 文件 | +|---|---|---| +| 260 | 开放 API 鉴权(beta2 F053) | `common/errcode/open_api.py` | +| 263 | MCP 面与统一检索门面(F052) | `common/errcode/mcp_face.py` | +| 264 | public_endpoints(发版线,非应用工场) | — | +| 265 | 模型协议面(F051),每码带 OpenAI `type` / `code` | `common/errcode/model_face.py` | +| 161 | 应用域与运行时(F054):16100 领域 · 16120 编排 · 16140 入口 · 16160 数据面 · 16180 部署开关 | `common/errcode/app_factory.py` | +| 162 | 发布管线(F055) | `common/errcode/app_publish.py` | +| 163 | 应用广场与治理(F056) | — | +| 164 | k8s 执行后端(F059,预留) | — | +| 181 | 审批中心(F025),其中 18119 为「审批场景不允许免审分支」 | `common/errcode/approval.py` | + +新增错误码时须同时更新 `docs/constitution.md` C5 与 `src/frontend/packages/locales` 的三语 `api_errors` 文案。 diff --git "a/docs/PRD/3.0-release/3.0 \346\212\200\346\234\257\346\236\266\346\236\204 Review\357\274\210\350\265\204\346\267\261\346\236\266\346\236\204\345\270\210\346\204\217\350\247\201\357\274\211.md" "b/docs/PRD/3.0-release/3.0 \346\212\200\346\234\257\346\236\266\346\236\204 Review\357\274\210\350\265\204\346\267\261\346\236\266\346\236\204\345\270\210\346\204\217\350\247\201\357\274\211.md" deleted file mode 100644 index 9b928d44a5..0000000000 --- "a/docs/PRD/3.0-release/3.0 \346\212\200\346\234\257\346\236\266\346\236\204 Review\357\274\210\350\265\204\346\267\261\346\236\266\346\236\204\345\270\210\346\204\217\350\247\201\357\274\211.md" +++ /dev/null @@ -1,122 +0,0 @@ -# BiSheng 3.0《AI 应用工场》技术架构 Review(资深架构师意见) - -> 评审对象:`docs/PRD/3.0-release/3.0 AI 应用工场 产品方案.md`(v3.0,2026-07-24) -> 视角:资深 toB 开源大模型应用平台架构师 -> 方法:方案通读 + 3 组代码取证(对 §4.4「既有资产复用」逐条核实)+ 对照 `docs/constitution.md` -> 日期:2026-07-24 状态:评审意见(未回写方案;如需采纳见文末「附录 A」映射) -> 说明:文中「建议方向(已对齐)」= review 过程中与产品负责人逐项讨论后达成的推荐方向,非既成结论。 - ---- - -## 1. 总体判断 - -方案**方向可行、战略框定正确**(引擎是公共品、竞争在「壳」与治理底座;设计理念三条——应用管家 / 引擎复用 / 安全来自笼子——也立得住)。 - -技术架构的核心病灶只有一个:**§4.4「既有资产复用」表约一半失真——最重、最险的几块基建被当成「复用」轻描淡写,导致关键路径比波次图显示的更长、更险。** 本 review 的价值在于把「以为能复用、实为新建」的隐性工作量显式化,避免立项后在地基上返工。 - ---- - -## 2. 核心病灶:复用表对账(代码取证) - -| §4.4 既有资产 | 方案定性 | 取证结论 | 证据锚点 | -|---|---|---|---| -| OpenFGA + 授权弹窗 | 复用 | ✅ 基本属实 | `core/openfga/authorization_model.py`(`_standard_resource_type` 一行加类型);`PermissionService` object_type 字符串透传无 switch;但还要改 `permission_schema.py:VALID_RESOURCE_TYPES`(已与模型漂移,证明非自动同步)| -| 审批中心 F025 | 新场景 | ⚠️ 部分——有中心分发器要改 | `approval/` 有 handler 协议 + registry,但 `with_default_presets()` 硬编码 3 预设、`approval_runtime_handler_factory.build_runtime_handler()` 是 `if scenario_code==` 硬链(Celery outbox 关键路径),加场景须改这两处(有先例,不算重)| -| v2 知识库检索 API | 包成 MCP tool | ✅ 属实但**身份绑定** | `/api/v2/filelib/retrieve`→`aretrieve_chunks`;须 `resolve_operator` 出真实 user_id 并 seed 租户 ContextVar 才能守住 INV-7 per-user 可见性 | -| BishengLLM + 记账 | 双协议代理 | ⚠️ 库可复用,HTTP 面全新 | `BishengLLM` 是**进程内库类非 HTTP 端点**;无 `/v1/messages`;记账是 `on_llm_end` callback 按**叶子租户**记(`token_tracker.py`+`llm_token_log`),代理要改成**按 API key 归属** | -| MinIO + 灵思 workspace 快照 | 工作区持久化 | ⚠️ **误导**——不是快照 | `workspace_backend.py` 是 per-file write-through 对象缓存,非时间点快照;git + node_modules(万级小文件、无 git 语义)不可直接复用,需新增 tar/snapshot 层 | -| 灵思澄清环/任务模式 | 需求澄清前置 | ✅ prompt/UX 经验可复用 | — | -| 灵思 worker/队列/状态机/中间件 | 管家值守运行时 | ⚠️ 部分——**一次性会话模型** | worker 是独立进程 + Redis list 队列,但**所有触发都是交互式 HTTP、会话一次性/终态**(worker 主动丢弃终态队列项),**无 cron/webhook 触发器**,默认 compose 都不起这个 worker;常驻 agent 是新原语 | -| org_sync 企微/钉钉/飞书 | 管家群机器人推送 | ❌ **假**——零外发能力 | `org_sync` 只有 `authenticate/fetch_departments/fetch_members`,**纯入站目录同步**;群机器人只存在于 workflow 工具(`t_gpts_tools.json`);平台通知今天只有站内信 | -| share_link + iframe.js | 简报卡片/嵌入 | ⚠️ 部分——postMessage 是新建 | `share_link`/`iframe.source.js` 存在,但**无 postMessage、无域名白名单**;iframe 现在嵌的是 chatbot 不是简报卡 | -| E2B executor copy-in/out | 沙箱文件交换 | ⚠️ 现沙箱是**外部 E2B** | 现 code interpreter 走远程 E2B(`e2b_executor.py`);本地 docker 沙箱是新建 | -| **(§4.1)app_runtime / docker SDK** | 新模块(轻描淡写) | ❌ **全新、最重、最险** | 无 `docker` 依赖、无 `app_runtime`、**运行期无 docker.sock 挂载**(仅 CI `.drone.yml` 用);backend 生管兄弟容器 = 全新特权面 | -| **(§4.3)nginx auth_request** | 「核心安全模型」 | ❌ **100% 新建、未验证** | `auth_request` 全仓只出现在方案文档里;现网 nginx 全是裸 `proxy_pass`,鉴权在 backend JWT | - -**规律**:真能复用的(OpenFGA 类型、审批场景、知识检索、澄清 UX)都是**较轻**的项;最重的新基建要么被贴成「复用」(workspace 快照、值守 worker),要么净新建却被低估(docker 沙箱、auth_request、模型代理 HTTP 面、org_sync 推送、postMessage 嵌入)。 - ---- - -## 3. 发现(按严重度分档) - -### Tier 1 — 会动摇根基,须在立项前拍板 - -**T1-1 容器编排的「特权悖论」(全案最重、与安全叙事自相矛盾)** -- app_runtime 让 backend 用 docker SDK 生管每 app/session 的兄弟容器 → 需把 `/var/run/docker.sock` 挂进 backend 或 DinD = **root-on-host**。控制面因此握有系统里最大的特权,直接违背 §2.2③「安全来自笼子」。 -- 它在方案里被当成「构建(docker SDK)/ 生命周期 reconcile」一笔带过,实为**最重、最新、最险**的一块,不在复用表内却决定成败。 -- 私有化 toB 客户安全团队一定会审这条;「平台 backend 对宿主机有 root」对银行/信创客户基本是一票否决。 -- **→ 建议方向(已对齐):独立最小权限编排器 `runtime-manager`**。硬约束:① runtime-manager 是唯一持有 docker 访问的组件,backend 编译期/运行期零 docker 依赖(arch-guard 加 import 禁令);② RPC 契约 intent-based(声明期望态、manager 自 reconcile),非远程调 docker 命令;③ manager 独立受限 service account,凭据与 backend 隔离。 - -**T1-2 引擎在国产模型上的实效 = 存在性风险,但无 Plan B** -- 方案自己点名这是「全案最大技术未知数」,Wave-0 PoC 前置正确。 -- 但若 PoC 结论是「没有任何开源 coding agent 能在国产模型上稳定驱动多轮造应用」,「不自研引擎」原则**没有退路**(Claude Code 又永不内嵌)——整个产品命题落空。 -- 真正的失败模式不是「崩」而是「demo 能跑、多轮飘」(tool-calling 可靠性经翻译层退化,灵思已踩过);3 个单发任务测不出。 -- 「业务应用大多表单+列表+看板」(§3.1)意味着模板/DSL 可能不止是 fallback、甚至该是主干、coding agent 作逃生梯度。 -- **→ 建议方向(已对齐):F100 加量化 go/no-go gate + 多轮鲁棒性口径 + 模板生成器入 PoC 作对照基线**;引擎 vs 模板的主次关系由 PoC 数据决定,不预设。 - -**T1-3 管家值守态是全新常驻 agent 原语,不是灵思 worker 的复用** -- 灵思是一次性/终态会话模型(worker 丢弃终态队列项、单 thread=session),**无 cron/webhook 触发、无常驻生命周期**,默认 compose 都不起该 worker。 -- 拆两个轴:**单次执行**(✅ 可复用灵思 deep-agent + 中间件栈 + workspace + 模型容错)vs **常驻实体的调度与生命周期**(❌ 全新:cron/webhook producer、armed/paused/预算超限 生命周期、零-LLM 游标水位、机队观测)。后者本质是**平台级触发/调度子系统**(v2.6 完全没有;频道信息源不更新事故的根因就是缺 Beat+default worker),管家只是首个消费者,且该原语可被工作流定时/知识库定时同步复用。 -- **→ 建议方向(已对齐):F108 从「管家值守态」重标为「平台触发/调度子系统 + 管家作首个消费者」**,实现仍 defer——defer 掉的是可复用平台能力,而非「以为能复用实为新建」的隐性债。 - -### Tier 2 — 实质设计缺口,须在 design 阶段解决 - -**T2-1 auth_request/OBO 100% 新建 + 与商业版 Java 网关真冲突(开放问题 #5 是冲突不是待对齐)** -- 网关只路由 `/api/**`、有自己的 Sa-Token/SSO 鉴权 + 自定义 WS filter(`SelfWebsocketRoutingFilter`),**完全不认 `/apps`**。/apps(含 preview + WebSocket)走网关还是绕过、双重鉴权如何协调、WS 如何过——全未解。 -- 商业版会话是网关的 Sa-Token cookie 而非 backend JWT——「auth_request→backend 验会话」在商业版悬空。 -- **→ 建议方向(已对齐):独立 app-proxy 层(版本无关)**——验会话+签 OBO+注入头+反代容器收在一个组件,nginx(开源)与 Java 网关(商业)都只把 /apps 转发到它,安全逻辑一处实现不分叉、网关冲突消解。两个不变量(不论选型都躲不掉):① header 防伪(剥离客户端伪造 `X-BiSheng-*` + 容器仅经 proxy 可达);② WS 长连接的 OBO 过期/吊销策略(握手一次不够)。app-proxy 与 runtime-manager 是邻居,可同组件可拆。 - -**T2-2「应用数据库」引擎未定 + 与 C2 双方言 / 回滚语义冲突** -- §3.1「应用数据库 = 平台托管」指生成应用**自己的库**,方案没交代由什么引擎承载。纯 DM8 信创环境下,生成的应用代码不认 DM8 方言(C2 是平台表的法,管不到生成代码)→ 要么被迫再引入 SQLite/MySQL(削弱信创单库叙事),要么应用跑不起来。 -- 「每轮存档含应用数据库」+「生产回滚」对**有状态生产应用**意味着:回滚会丢回滚点后的生产数据、schema 演进无迁移脚本。「恢复无损」对生产态是 overclaim。 -- **→ 建议方向(已对齐):应用库 = SQLite-per-app(小档)+ Postgres schema(中档),与平台 MySQL/DM8 解耦**。一箭三雕:绕开生成代码不认 DM8 方言、单文件 SQLite 顺带解 workspace 快照难题(tar 一个文件=一次存档)、天然按 app 隔离、映射 small|medium 两档。回滚语义收窄:**代码/配置回滚=无损;生产数据默认不回滚;republish schema 演进需策略(仅增列/迁移前快照/人工确认)**——「恢复无损」限定草稿+代码。 - -**T2-3 双协议模型代理不是「暴露 BishengLLM」** -- Anthropic `/v1/messages` 兼容面是有长尾的兼容工程(content blocks / tool_use / streaming SSE / thinking / prompt caching 语义),要让 Claude Code/Codex/Qwen Code 都真跑通并非补个端点。且记账要从 callback-per-tenant 改成 per-key 归属 + 限流。它在**关键路径**上(F102 卡 F103/F110),且**耦合 T1-2**——翻译层劣化 tool-calling 会加重模型实效风险。 -- **→ 建议方向(已对齐):采用现成代理(LiteLLM/one-api)做协议面**,BishengLLM 退居 provider 路由;省 Anthropic 长尾自研。三个必钉约束:① license 核查(LiteLLM 企业功能单独授权,商业私有化要确认;one-api/new-api MIT);② BishengLLM 仍是 provider 路由唯一真相(现成代理调 BishengLLM 解析端点,不重实现租户模型配置,防漂移);③ 记账回流一处(外部代理 per-key 用量喂回 `llm_token_log`/`QuotaService`,避免两套账单)。与方案 §4.3 已定的 auth_profile 三路径协同。 - -**T2-4 出站白名单 vs 依赖安装的矛盾** -- egress 白名单是对的威胁模型,但生成应用要 pip/npm install(社区 #1568)。白名单锁死后依赖怎么装?方案引了 #1568 但没设计这条。 -- **→ 建议方向(已对齐):构建期/运行期 egress 分离 + 平台自托管 PyPI/npm 镜像进构建白名单**。镜像是白名单里的受控条目而非洞;附带版本可 pin/可缓存/信创可离线预灌。运行期不放行任何包源。 - -**T2-5 workspace 快照层缺失**(承 §2 复用表)——git + node_modules 规模需要新的 tar/bulk-snapshot 层,直接决定「每轮自动存档 / 恢复无损 / 容器可回收回来能续」这几条机制能否成立。(T2-2 选 SQLite-per-app 后,应用数据部分的快照自动简化为「tar 一个文件」。) - -### Tier 3 — 澄清/补齐,不阻塞(处置已定) - -- **T3-1 错误码段位撞车**:§4.5「错误码建议 130-133 段(C6)」——① 错误码是 **C5** 不是 C6;② **130 段已被 `chat` 占用**(C5 明列)。→ **引用改 C5,段位挪到 160-163(160+ 未占)**。 -- **T3-2 org_sync 推送 = 假**:org_sync 零外发(纯入站目录同步),群机器人只存在于 workflow 工具(`t_gpts_tools.json` 钉钉/企微 webhook)。→ **管家推送改「新建通知服务,借 workflow 群机器人 webhook 发送逻辑」,并入 T1-3 触发/调度子系统的推送出口,一起 defer**。 -- **T3-3 postMessage + 域名白名单是新建**,iframe 现嵌 chatbot 非简报卡。→ **§4.4 改「新建 postMessage + 域名白名单;简报卡是新用途」,F109 定性改「重构」**。 -- **T3-4 容量/密度模型缺失**:单机 docker 下 N 用户各开 builder 沙箱 + 生产容器,资源竞争 / 冷启动 / idle 回收无模型。→ **补风险表 + 把准入控制/idle 回收/并发上限挂到 runtime-manager;「平台正常即使应用崩」补 cgroup 硬隔离兜底**。 -- **T3-5 应用 secrets 管理缺失**:capabilities 只声明 knowledge/models,外部集成 secret 无落点。→ **补应用级 secret store(复用 C6 Fernet),注入同 capabilities(声明式 + 发布审批)**。 - ---- - -## 4. 值得肯定(避免只挑刺) - -引擎=公共品/护城河=治理 的战略框定;「安全来自笼子」不信任生成代码的降维;OBO 双轨(借 Databricks Apps);egress 白名单 > 换 runtime 的威胁模型排序;gVisor 免 KVM 应对信创无嵌套虚拟化;Wave-0 把最大未知前置;值守态 zero-LLM 游标 diff 降本;把值守态降级为「spec 冻结」的容量务实。 - ---- - -## 附录 A:若采纳,对产品方案的回写映射(§级) - -> 供决定回写方案时参考;本 review 未直接改动方案文档。 - -- **§4.1 总体架构** — 新增 `runtime-manager`(独立最小权限编排器,唯一持 docker 访问);`app_runtime` 改为「向 runtime-manager 下发 intent」不直连 docker。新增 `app-proxy`(版本无关鉴权代理)层,取代 nginx auth_request 叙事。模型代理改为「采用现成代理(LiteLLM/one-api)+ BishengLLM provider 路由」。 -- **§4.2 引擎(F100)** — 加量化 go/no-go gate + 多轮鲁棒性口径 + 模板生成器对照基线;补 fallback(模板/DSL,主次由 PoC 定)。 -- **§4.3 沙箱与安全** — 编排改 runtime-manager(不挂 docker.sock 进 backend);app-proxy 鉴权取代 auth_request,补 header 防伪 + WS 生命周期两个不变量;egress 分构建/运行两段 + 自托管镜像;补容量/准入/idle 回收(挂 runtime-manager)+ cgroup 硬隔离兜底。 -- **§3.1 / §3.3 应用库与回滚** — 应用库明确 SQLite(小)+ Postgres(中),与平台 MySQL/DM8 解耦;「恢复无损」收窄到草稿/代码,补生产数据/schema 演进语义;补应用级 secret store。 -- **§4.4 复用表据实重标** — workspace 快照 / 灵思 worker 值守 / org_sync 推送 / iframe 从「复用」改为「借经验 / 部分 / 新建」;`runtime-manager`、`app-proxy`、模型代理协议面 显式列为**新建核心基建**。 -- **§4.5 合规与风险** — 错误码 `130-133(C6)`→ 改引 **C5** + 段位挪 **160-163**;风险表补:容量/密度、应用 secrets、Anthropic 兼容面耦合 T1-2、编排特权(已解为 runtime-manager)。 -- **§3.2 守态 + F108** — 重标为「平台触发/调度子系统 + 管家作首个消费者」;org_sync 推送改「新建通知服务」。 -- **§5 波次重排** — runtime-manager 安全评审前置 Wave-1;F100 加 gate;被低估的净新建(app-proxy、模型代理面、workspace 快照层、触发子系统)并回关键路径与工作量估计。 -- **§5.4 开放问题 #5** — 从「待对齐」升级为「/apps 路由与鉴权在 app-proxy 层统一(网关只转发)」设计任务。 - ---- - -## 附录 B:结论复核锚点 - -本 review 结论均有代码锚点(见 §2「证据锚点」列),可按下列命令自查: -- 特权悖论:`grep -r "docker.sock\|import docker" src/backend`(仅 `.drone.yml` 命中) -- auth_request novelty:`grep -rn auth_request docker/nginx`(零命中,仅方案文档有) -- 值守触发缺口:`bisheng/linsight/worker.py` 无 cron/beat/webhook producer -- 错误码:`docs/constitution.md` C5(130=chat) diff --git "a/docs/PRD/3.0-release/3.0 \351\231\204\345\275\225\357\274\232\351\242\204\347\275\256\346\250\241\346\235\277\351\246\226\345\217\221\346\270\205\345\215\225\350\260\203\347\240\224.md" "b/docs/PRD/3.0-release/3.0 \351\231\204\345\275\225\357\274\232\351\242\204\347\275\256\346\250\241\346\235\277\351\246\226\345\217\221\346\270\205\345\215\225\350\260\203\347\240\224.md" deleted file mode 100644 index 361d0466c6..0000000000 --- "a/docs/PRD/3.0-release/3.0 \351\231\204\345\275\225\357\274\232\351\242\204\347\275\256\346\250\241\346\235\277\351\246\226\345\217\221\346\270\205\345\215\225\350\260\203\347\240\224.md" +++ /dev/null @@ -1,72 +0,0 @@ -# 3.0 附录:预置模板首发清单调研 - -> 2026-07-24 服务于《3.0 AI 应用工场 产品方案》§3.1 -> 两路调研:①通用低代码/AI 造应用平台模板市场实证 ②央国企自建应用场景特写;关键数字均有一手来源(§3 索引) - ---- - -## 1. 结论 - -### 1.1 首发 6 类模板 - -每个模板 = 「一张表 + 一条流 + 一个看板」的**微应用**,附自动数据后台,身份/权限由平台桥接零配置。**不做行业大套件**(证据:唯一有公开使用量的钉钉模板中心显示,日报 49 万+人、走访记录 14 万+人、外出审批 19 万+人这类单点微应用的真实使用量碾压 CRM/进销存等套件一个量级)。 - -| # | 模板 | 场景示例 | 证据强度 | 用到的基础组件 | -|---|------|---------|---------|---------------| -| 1 | **信息收集上报** | 日报/走访记录/报名登记/问卷/基层报数汇总 | 通用最强(钉钉日报 49 万+人)+ 央国企强(「层层报数」刚需,国网安徽报表提前 5h) | 数据库 · 身份自动带入(人/部门)· 数据后台 | -| 2 | **业务台账** | 物资/设备/合同/合规台账,导入导出与查询 | 央国企强(中海油原话「线下台账线上化」是低代码第一需求)+ 通用中 | 数据库 · Excel 导入导出 · 数据后台 | -| 3 | **检查整改闭环** | 巡检/安全检查记录 → 隐患登记 → 整改跟踪 → 汇总看板 | **央国企案例密度最高**(晋城供电/山西二建/金风科技/特科院,整改率 60%→95% 案例)+ 通用 5 平台类目重合 | 数据库 · 身份 · 状态流转 · 看板 · 消息通知 | -| 4 | **数据看板 + AI 解读** | 上传 Excel/选数据表 → 图表看板 + 管家解读结论 | 通用强(仪表盘为各平台核心件;美团 NoCode 明星场景「一个早上做完数据分析工具」);**AI 差异化主打** | 数据库 · 模型 ·(衔接管家值守态) | -| 5 | **知识问答页** | 部门 FAQ/制度问答页,对接平台知识库 | AI 平台侧中强(传统低代码做不了);**BiSheng 知识库资产的最短变现路径** | 知识库检索 · 模型 · 身份 | -| 6 | **轻审批 · 督办跟踪** | 用章/外出等申请登记,事项督办催办台账 | 通用最强(外出审批 19 万+/补卡 9 万+)+ 央国企中(督办案例)| 数据库 · 身份 · 状态流转 · 消息通知 | - -边界说明:模板 6 承载**业务自建的轻流转与督办台账**;正式的组织级审批仍走平台审批中心,模板不与其重叠。 - -**PoC 任务集对齐**:Wave-0 引擎对测的 3 个任务 = 模板 1(信息收集上报)/ 模板 4(数据看板+AI 解读)/ 模板 5(知识问答页)——与方案 §4.2 一致。 - -### 1.2 二批候选(v3.1,按客户触发) - -| 模板 | 缓入理由 | -|------|---------| -| 项目/任务跟踪 | 通用证据强,但央国企基层偏弱,且飞书系产品竞争激烈 | -| CRM-lite(客户跟进) | 通用市场热门第一,**但央国企基层自建清单中几乎不出现**——BiSheng 客户群以央国企/金融为主,留给通用企业客户触发 | -| 班组报工 / 生产小工单 | 制造类客户触发(烟草班组报工、扫码报工案例) | -| 在线考试 / 培训考核 | 央国企有实证(常州公交安规考试)但需题库组件 | -| 党建学习台账 | 央国企特色场景,但证据多来自厂商方案页且内容敏感——与首个客户共创后再模板化 | - -### 1.3 明确不做(形态不适配或有专业壁垒) - -- **值班智能排班**:约束求解问题非表单形态,且所有热门榜均无自建实证(值班表登记可归入台账模板); -- **电力两票**(操作票/工作票):强安全规程 + 专业厂商领域; -- **IoT/智慧工地/视频监测**:重硬件集成、GIS、视频流; -- **MES/ERP/WMS 级全流程系统**:长期迭代 + 系统集成产物,非对话生成可承载; -- **正式公文流转**:电子签章、密级管理,属 OA 域; -- **财务共享 / 业财一体化、对外监管报送**:重系统对接; -- **移动重采集型巡检**(扫码/GPS/离线):首发的检查整改模板为 PC/H5 记录形态,产品文案须明示此边界、管理预期。 - ---- - -## 2. 关键证据与判断 - -**通用侧(模板市场实证)**: -- 业务人员自建应用的基本盘 = 「**收数—流转—记账—看数**」闭环:表单收集上报 + 轻审批 + 业务台账 + 自动看板。 -- 「厂商想卖的」(CRM/进销存/MES 行业套件)≠「用户真在用的」(日报/走访/补卡类微应用,唯一有公开使用量的钉钉数据显示后者量级碾压)。 -- AI 造应用平台(秒哒/美团 NoCode/扣子)在此之上新增一层「轻页面/小工具/问卷」,且知识问答成为传统低代码没有的新形态。 - -**央国企侧(场景特写)**: -- 与通用企业的差集内核 = 「**上级要查、必须留痕**」:记录-检查-整改类应用(巡检、隐患排查整改闭环、台账、层层填报、督办)案例密度远高于经营类; -- CRM/获客/增长类在央国企基层自建清单中几乎不出现; -- 招行(半年 2831 应用、59.4% 业务人员开发)验证强监管机构的全民开发可行性,但其应用构成未公开。 - -**取舍逻辑**:首发 6 类 = 通用与央国企证据的交集(1/2/3/6)+ BiSheng 的 AI 差异化独有项(4/5);二批 = 单侧证据强或需特定客户触发;不做 = 形态不适配(求解/集成/移动重采集)或有专业壁垒。 - ---- - -## 3. 证据来源索引 - -- **钉钉模板中心使用量**(唯一硬数字源):appcenter.dingtalk.com/template——日报 49 万+、客户经理日报 40 万+、外出审批 19 万+、走访 14 万+、补卡 9 万+。 -- **简道云**:模板中心热门六件套(CRM/进销存/项目/人事/小工单/WMS)jiandaoyun.com/index/solution_center;央国企案例库 hc.jiandaoyun.com/blog/10849(晋城供电/陕投/中交/山西二建/常州公交/国电新疆等实名案例)。 -- **轻流/氚云/微搭/飞书**:qingflow.com(251 模板)、center.h3yun.com、腾讯微搭「表单最常见=信息收集」、feishu.cn/template/group/base(任务管理「100 万人在用」)。 -- **AI 平台**:秒哒应用广场类型(cloud.baidu.com/doc/MIAODA)、美团 NoCode 1680 应用与内部实证(oschina.net/news/354604、pingwest.com/a/305369)、扣子编程产物三类(coze.cn/open/docs)。 -- **央国企案例**:招行(finance.sina.com.cn 2024-12-21、53ai.com 陈曦演讲)、中海油-炎黄盈动(actionsoft.com.cn/case/cnooc)、i国网(chinapower.com.cn)、国网安徽(treasurychina.com/post/10571)、中国电信 1000+ 应用(cww.net.cn/article?id=597419)、奥哲航司半年 700+ 应用、蓝凌/泛微/致远央国企方案页(党建/督办/会议)、信通院低代码白皮书。 -- 未核实项:宜搭模板中心直采(SPA 未抓取)、招行 2831 应用构成明细、信通院场景占比精确百分数、秒哒点赞排行。 diff --git "a/docs/PRD/3.0-release/F068 \344\273\243\347\240\201\346\211\247\350\241\214\346\262\231\347\256\261\346\265\213\350\257\225\346\212\245\345\221\212.md" "b/docs/PRD/3.0-release/F068 \344\273\243\347\240\201\346\211\247\350\241\214\346\262\231\347\256\261\346\265\213\350\257\225\346\212\245\345\221\212.md" new file mode 100644 index 0000000000..201b22ff12 --- /dev/null +++ "b/docs/PRD/3.0-release/F068 \344\273\243\347\240\201\346\211\247\350\241\214\346\262\231\347\256\261\346\265\213\350\257\225\346\212\245\345\221\212.md" @@ -0,0 +1,285 @@ +# F068 代码执行沙箱测试报告 + +- **测试依据**:《F068 代码执行沙箱测试用例》(同目录) +- **测试时间**:2026-10-29 至 10-30(台北时间) +- **测试环境**:116 上的 `bisheng-test-*` 容器组(Docker Compose) +- **镜像**: + - runner:`bisheng-sandbox:release`,2026-10-28 14:32 构建,测试期间没有变化; + - backend:`bisheng-backend:release`。测试期间 CI 自动重新部署了 3 次,最后一次是 10-29 22:28 构建。 +- **执行方式**:除特别说明外都是「沙箱运行」。任务模式用的模型是 908(minimax2.5)。 + +--- + +## 1. 结论 + +**F068 现在不能按「沙箱运行」默认开启上线。** 正常的单任务链路基本都通过了。但并发、超时、长任务、大工作区这几种情况会让 runner 丢会话或卡死,部分情况下 runner 主进程会被杀掉,用户看到的是看不懂的 28001 或 28006,任务里的代码调用全部失败。 + +问题集中在 runner 和执行器的会话管理这一层: + +- **D1** runner 同一时间只执行一段代码; +- **D2** 超时后杀不掉用户进程; +- **D3** 工作区太大时 runner 主进程被内存超限杀掉; +- **D4** 会话一旦丢失,执行器不会重新申请; +- **D5** 僵尸进程不回收; +- **D6** 进程被杀后,返回结果看不出原因。 + +这 6 个缺陷的根因都已经定位到代码行,建议作为一组一起修,修完后重跑第 5 节(R1–R5)的用例。 + +**修复情况(2026-10-30 补记)**:D1–D7、D9、D10、D11–D14、D16 已在 feat/3.0.0 修复,修复内容和验收结果见第 7 节。D8 和 D15 不在这次修复范围内。 + +正常链路这边,任务模式的核心链路(TC-10 到 TC-22)、技能包、子代理并行、日常会话、代码节点、本地隔离、runner 不可达时的报错都通过了。 + +--- + +## 2. 缺陷清单 + +严重度的含义: + +- **P0**:默认配置下用户就会遇到,并且会导致任务失败、数据丢失,或者波及同一 runner 上的其他用户; +- **P1**:功能错误或安全口径不达标; +- **P2**:体验问题或一致性问题。 + +「根因」一列只写在代码或日志里确认过的内容。 + +| 编号 | 严重度 | 缺陷 | 关联用例 | 根因 | +|---|---|---|---|---| +| D1 | P0 | runner 同一时间只执行一段代码,执行期间连新建会话和健康检查都处理不了 | TC-31、TC-54 | 代码已定位 | +| D2 | P0 | 超时后杀不掉用户进程:模型收到 28006,进程一直在跑 | TC-30 | 代码已定位 | +| D3 | P0 | 工作区约 1.5GB 时,runner 主进程被内存超限杀掉,同一 runner 上的会话全部丢失 | TC-36 | 代码已定位 | +| D4 | P0 | 会话丢失后执行器不重新申请租约,之后每次调用都报 28006;空闲回收会删掉正在执行的会话 | TC-32、TC-33、TC-34、TC-31 | 代码已定位 | +| D5 | P0 | runner 不回收僵尸进程,正常使用也会逐步耗尽进程数配额 | TC-54、TC-98 | 已复现 | +| D6 | P1 | 内存超限或 CPU 超时被杀后,模型只收到 `exitcode=-9` 和空输出 | TC-35、TC-37 | 代码已定位 | +| D7 | P1 | 用户点「停止」后,沙箱里的代码继续运行,直到自然结束 | TC-39 | 代码已定位 | +| D8 | P1 | 沙箱里能连到 `backend:7860` 和另一个 runner | TC-94 | 已复现 | +| D9 | P1 | 同一 runner 上两个存活会话会分到同一个 uid | TC-96 | 代码已定位 | +| D10 | P1 | runner 主进程以 root 运行,没有 CPU 上限;用户进程保留了附加组 0 | TC-99、TC-95 | 已复现 | +| D11 | P2 | matplotlib 默认字体不含中文,图表中文显示成方框 | TC-52 | 已复现 | +| D12 | P2 | 本地方式不预先创建 `output/`、`scratch/`,和沙箱方式不一致 | TC-40、TC-82 | 代码已定位 | +| D13 | P2 | runner 把 HOME 设为工作目录,LibreOffice、fontconfig 的缓存被列进产物清单 | TC-15、TC-38 | 已复现 | +| D14 | P2 | 沙箱里调用 `getpass.getuser()` 报 KeyError | TC-01 | 已复现 | +| D15 | P2 | 日常会话的流在 300 秒被 nginx 切断,而代码执行的超时默认是 600 秒 | TC-30 | 配置已核对 | +| D16 | P2 | 代码节点没有 return 时,报的是普通 500,不是 28008 | TC-70 | 已复现 | + +### D1 runner 同一时间只执行一段代码(P0) + +- **现象**:同一个 runner 上的两个会话同时执行 `sleep 10`,总耗时 20.1 秒:第二个会话等第一个结束后才开始。在这期间发出的 `/health` 请求也等了 18.1 秒才返回。这个结果来自最小复现脚本,配置是默认的 `max_sessions=50`。 +- **根因**:两个原因叠加: + - `exec_code` 是 `async def`,却直接调用同步的 `handle_exec`,后者会阻塞在 `subprocess.communicate` 上(`src/sandbox-runner/app.py:163`)。`put_files` 也同样同步调用了 `handle_put_files`(`app.py:121`)。 + - uvicorn 只开了 1 个 worker(`serve.py:46`)。 + + 所以只要有一段代码在执行,这个 runner 的事件循环就被占住,任何请求都处理不了。 +- **影响**:`SANDBOX_MAX_SESSIONS=50` 实际上只能做到「同一时间 1 个执行」。并发时,其他请求等满 30 秒超时后,执行器报 28001「执行环境不可达」,但 runner 其实是正常的。TC-31 中,runner-2 有空闲槽位,却因为正在执行代码而接不了新会话。TC-54 的并发转换失败,以及 TC-36 拷贝 1.5GB 很慢,可能也和这个有关。 +- **建议**:把 `handle_exec` 和 `handle_put_files` 放到线程池里执行(`run_in_threadpool`),或者改成异步子进程。修完后用最小复现脚本复测,两个会话的总耗时应接近 10 秒。 + +### D2 超时后杀不掉用户进程(P0,R1 成立) + +- **现象**:日常会话里执行 `sleep(100000)`,超时设为 60 秒。到 60 秒时,模型收到的是 `SandboxProtocolError`(28006,「执行环境返回无效」),而不是「执行超时」。超时 412 秒之后,runner-2 里这个进程(uid 10000)还在运行,而它所在的会话目录已经被删掉了。 +- **根因**:超时后,runner 调用 `os.killpg(..., SIGKILL)` 去杀用户进程组,这一步报 `PermissionError: Operation not permitted`(`src/sandbox-runner/execute.py:82`)。runner 容器只有 `SETUID SETGID CHOWN DAC_OVERRIDE` 这几项能力,没有 `CAP_KILL`,所以杀不掉其他 uid 的进程。这个异常让 exec 接口返回 HTTP 500,执行器就把它报成了 28006。 +- **影响**: + - 每次超时都会留下一个进程,占着内存和进程数配额; + - 模型看不出这次失败是超时; + - 留下的进程,它的 uid 会被后来的会话复用(D9); + - 任务模式的超时写死为 3600 秒,走的是同一段 runner 代码。我没有实测这 1 小时,但可以确定结果一样。 +- **建议**: + - 给 runner 加上 `CAP_KILL`,或者以会话 uid 的身份发送信号; + - `_kill_group` 失败时不能抛异常,应该退而尝试 `proc.kill()`,并返回 exitcode 124; + - 执行器收到 124 时,要给出「执行超时」的提示。 + +### D3 大工作区让 runner 主进程被内存超限杀掉(P0,R4 成立) + +- **现象**:任务里上传 32 个 48MB 的文件,共 1.5GB。copy-in 期间,runner-1 的内存涨到 1.27GiB 以上,最后 runner 主进程(UID 0,RSS 2GB)被 OOM killer 杀掉,容器在 22:51:44 重启。这个任务收到 `Isolation environment unreachable`,同一 runner 上的其他会话也全部丢失。 +- **根因**: + - copy-in 只有单文件上限(50MB),没有总量上限,所以 48MB 的文件全部会被拷进去; + - 两端都把整个请求体读进内存:执行器把所有文件读成 dict 再打成完整的 tar.gz(`container_executor.py:168-189`),runner 用 `await request.body()` 一次性读完后再解包(`app.py:115`); + - runner 容器的内存上限是 2GB。 +- **建议**: + - 增加 copy-in 总量上限,超过时跳过并提示,和单文件超限的处理方式一样; + - 两端都改成流式传输和解包; + - 让 runner 主进程比用户进程更晚被内存超限选中(`oom_score_adj`)。 + +### D4 会话丢失后不会恢复(P0,R3、R4 成立) + +- **现象**: + - **TC-34**:执行中重启 runner。之后执行器拿着旧的 session_id 去 copy-in,连续 3 次收到 `session missing`,每次调用都报 28006,直到任务结束。 + - **TC-32**:租约 TTL 设为 60 秒。两次代码调用之间要做联网检索,这期间租约过期。之后每次代码调用都报 28006,模型大约每 3 秒重试一次,一共失败 57 次,最后是我手动中止的。 + - **TC-33**:会话 A 的 `sleep(120)` 正常结束,`exitcode=0`。27 毫秒后,runner 以 `reason=idle` 回收了这个会话。接下来的 copy-out 找不到会话,`before.txt` 和 `after.txt` 都丢了,模型收到 28006。 + - **TC-31**:一个已经建好的会话排在前一段代码后面等了 2 分钟(D1),还没开始执行就被当成空闲回收了。 +- **根因**: + - 执行器在会话丢失时直接报 28006,不会重新申请租约; + - runner 只在每个请求开始时刷新 `last_active`(`src/sandbox-runner/leases.py:113`),`reap_expired` 也不看会话是否正在执行(`leases.py:61`)。 +- **影响**:默认 TTL 是 900 秒。生产环境中,单次执行超过 15 分钟,或两次代码调用间隔超过 15 分钟,都会遇到这个问题;任何一次 runner 重启(包括 D3)都会让正在跑的任务失去执行能力。 +- **建议**: + - 执行器收到 404 时清掉旧租约,重新申请,并把工作区重新拷进去; + - runner 在 exec 结束时也刷新 `last_active`; + - 回收时跳过持有执行锁的会话。 + +### D5 僵尸进程不回收(P0) + +- **现象**: + - runner-2 上做一次普通的 soffice 文档转 PDF,结束后留下 5 个僵尸进程(gpgconf、gpg、gpgsm); + - fork 测试(TC-98)在 runner-1 上留下 244 个僵尸 python 进程,只能重启容器恢复。 + + 每个 runner 的进程数上限是 256,大约做 50 次转换后,这个 runner 就再也创建不了进程,但 `/health` 仍然返回 ok。 +- **根因**:runner 的 PID 1 是 `python serve.py`,它不回收孤儿进程。 +- **建议**:在 compose 里给 runner 加 `init: true`,helm 也做对应修改;或者在镜像里用 tini 作为 PID 1。 + +### D6 进程被杀后看不出原因(P1) + +- **现象**: + - **TC-35**:内存耗尽的循环被 cgroup OOM 杀掉; + - **TC-37**:死循环在 601.2 秒被 CPU 时间上限杀掉。 + + 两次模型收到的都是 `exitcode=-9`,stdout 和 stderr 为空。runner 日志里也只有 `exitcode=-9`,没有 `exec oom`。 +- **根因**:runner 直接把 `proc.returncode` 当作 exitcode(`src/sandbox-runner/execute.py:149`)。Python 里被信号杀掉的进程,返回码是负的信号值 `-9`,不是 shell 风格的 `137`。所以 runner 的 `if exitcode == 137` 分支(`execute.py:154`)和执行器里对 137 的判断,一次都执行不到。 +- **好的一面**:内存超限只杀掉了那一个用户进程,runner 没有重启,同一 runner 上的其他会话和下一次调用都正常。 +- **建议**: + - 把负的返回码换算成 `128 + 信号值`; + - 在执行器里为 137(被杀)和 CPU 超限各补一句给模型看的说明。 + +### D7 停止任务后代码继续运行(P1) + +- **现象**:21:52:11 点击停止,worker 在 21:52:42 把任务标为 terminated。但沙箱里的 `sleep 300` 一直跑到 21:57:01 才结束,会话直到这时才释放。用例要求 30 秒内释放。 +- **根因**:`ContainerExecutor.execute_code` 用同步 HTTP 请求等待 exec 返回。取消任务中断不了这个请求,执行器也没有向 runner 发送终止。 +- **建议**:任务取消时,由执行器调用 runner 的删除会话接口,强制结束会话并杀掉进程。这一步依赖 D2 的修复。 + +### D8 网络隔离不完整(P1) + +- **现象**:沙箱里连 MySQL、Redis、MinIO、ES、OpenFGA、Milvus 都失败,但 `backend:7860` 和 `code-runner-2:8080` 能连通,显示 OPEN。 +- **影响**:用户代码可以直接调用后端 API,也可以访问其他 runner 的接口。其他 runner 的接口需要 token 才能用。 +- **建议**:把 runner 放进独立网络,只允许 backend 和 worker 单向访问 runner。 + +### D9 uid 撞号(P1,R6 成立) + +- **现象**:会话 B 还在运行时,新建的会话 C 也分到了 uid 10001。C 用明确路径读 B 的目录被拒绝,返回 PermissionError,因为执行前目录属主是 root、权限 0700;在 `/proc` 里也看不到 B 的进程。所以这次没有复现跨会话读取。 +- **根因**:`uid = 10000 + (len(self._leases) % 1000)`(`src/sandbox-runner/leases.py:81`),会话释放后再新建,就会和还活着的会话撞号。 +- **风险**:如果 D2 留下的进程和新会话 uid 相同,它就能读写新会话已经属于这个 uid 的工作目录。 +- **建议**:维护一个正在使用的 uid 集合,分配时跳过集合里的 uid。 + +### D10 runner 运行身份和资源上限(P1,R7 成立) + +- runner 主进程以 uid 0 运行,容器 `NanoCpus=0`、`CpuQuota=0`,没有 CPU 上限。这和 AC-14,以及对外说的「非 root」不一致。 +- 用户进程的 `os.getgroups()` 返回 `[0]`,也就是保留了附加组 root。 + +### D11 到 D16(P2) + +- **D11 中文字体**:镜像里装了文泉驿(`wqy-zenhei`),但 matplotlib 默认的 `font.sans-serif` 是 DejaVu Sans,标题中的中文触发 `Glyph … missing` 告警,显示成方框。建议在镜像里放一份 matplotlibrc,把文泉驿加进默认字体。 +- **D12 本地方式不建目录**:沙箱方式的 runner 在每次执行前创建 `output/` 和 `scratch/`(`execute.py:115`),本地方式不建。TC-11、TC-13、TC-19、TC-40 在本地方式下,第一次写交付物都报「目录不存在」,模型要多调用一次才能成功。工具说明里虽然写了要先 `os.makedirs`,但两种方式的表现应该一致。 +- **D13 缓存进产物清单**:runner 把 HOME 和 XDG 目录都设为工作目录,soffice、fontconfig、dconf 的缓存(`fontconfig/*.cache-9`、`dconf/user` 等)出现在工具结果的文件清单里,也进了 `all_from_session_files`:一次任务有 38 个 LibreOffice 文件、9 个 fontconfig 文件、1 个 dconf 文件。结果区的交付物不受影响,前端也不读这个字段。问题是模型会看到无关文件,而且这些文件每次调用都要来回拷贝。建议把 HOME 指向 `scratch/` 或会话外的临时目录。 +- **D14 getpass**:沙箱的 `/etc/passwd` 里没有 uid 10000 及以上的条目,`getpass.getuser()` 和 `pwd.getpwuid()` 都会报 KeyError。本地隔离方式有 `sandbox` 用户,没有这个问题。 +- **D15 流式超时不一致**:test 环境 nginx 对日常会话接口设的是 `proxy_read_timeout 300s`,而代码执行的超时是 600 秒:本地方式写死在 `local_executor.py:34`,沙箱方式的默认值也是 600。执行超过 300 秒时,日常会话的流先被切断,用户只看到连接中断。 +- **D16 代码节点没有 return**:报的是普通 500「main function output must be dict」,信息能看懂,但不是用例写的 28008。缺少 `main` 的情况在运行时报错;保存草稿是否报错,这次没测。 + +--- + +## 3. 用例结果 + +| 用例 | 结果 | 说明 | +|---|---|---| +| TC-01 沙箱运行基本可用 | 通过 | cwd 在 `/tmp/sessions/` 下,uid 10000,python 是 `/app/.venv/bin/python`,有 `output/`、`scratch/`。`getpass` 报错见 D14 | +| TC-02 任务结束释放会话 | 通过 | 日志依次出现 created、exec done、`deleted reason=client` | +| TC-03 切换执行方式不重启 | 通过 | 切到本地方式后,uid 30000,runner 没有新会话;切回沙箱方式后,cwd 回到 `/tmp/sessions/` | +| TC-10 读取上传的原件 | 通过 | 汇总结果正确:华北 1800、西南 1400、华东 1300、华南 1000 | +| TC-11、TC-12、TC-13 | 通过 | 根目录文件被移到 `output/` 并给出提示;产物清单里没有输入文件 | +| TC-14 跨轮继承产物 | 通过 | `report2.xlsx` 的 c 列是 4、6 | +| TC-15 技能包可执行 | 通过 | docx、pptx、xlsx 三个技能都调用了 `skills/*/scripts/`;xlsx 的 604 个公式重算 0 错误。docx 周报按「非公文」版式生成,需要产品确认 | +| TC-16 ask_user 等待后恢复 | 通过 | 提问期间会话被释放;恢复后新建了会话,读到了上传的文件 | +| TC-17 读取 large_tool_results | 未测成 | 搜索结果没有超过转存阈值,没有生成 `large_tool_results` | +| TC-18 单文件超过 50MB 被跳过 | 通过 | 提示原文正确 | +| TC-19、TC-20、TC-21 | 通过 | 中文文件名正常;绝对路径给出纠正提示;越界访问被拒绝 | +| TC-22 首次调用耗时 | 已记录 | 第一次约 9 秒,拷入 105MB;第二次约 0.1 秒,增量 45 字节 | +| TC-30 执行超时 | 失败 | 见 D2、D15。任务模式要等 3600 秒,没有实测 | +| TC-31 容量满 | 部分符合 | 第 5 个任务和日常会话都收到 28002,任务没有整体失败;但 4 个槽位实际只同时用上了 2 个(D1),前 4 个任务里也有失败的(D4) | +| TC-32、TC-33、TC-34 | 失败 | 见 D4 | +| TC-35 内存耗尽 | 部分通过 | 只有单个进程被杀,没有波及其他会话;返回结果看不出原因,见 D6 | +| TC-36 大工作区 | 失败 | 见 D3 | +| TC-37 CPU 时间上限 | 失败 | 限制生效了,但返回结果看不出原因,见 D6 | +| TC-38 子代理并行 | 通过 | 3 个文件都生成了,各自的工具结果只列出自己的文件 | +| TC-39 用户中止任务 | 失败 | 见 D7 | +| TC-40 执行失败时的半成品 | 沙箱方式通过 | `half.txt` 没有进产物。本地方式的表现不一致,见 D12 | +| TC-50 无外网、TC-51 不能 pip、TC-55 无 Chromium | 通过 | 报错原因清楚 | +| TC-52 中文字体 | 失败 | 见 D11 | +| TC-54 LibreOffice 并发转换 | 失败 | 返回码 `[0, 0, 1, 1]`,只生成了 2 个 PDF | +| TC-56 时区、TC-57 承诺可用的库 | 通过 | `TZ=Asia/Shanghai` | +| TC-60 日常会话 | 通过 | 经过了 runner | +| TC-62 助手、TC-64 工作流 Agent 节点 | 未验证 | 测试脚本没有解析到工具结果 | +| TC-66 到 TC-69、TC-71 到 TC-73 代码节点 | 通过 | 28005、28007 和语法错误的报错清楚;伪造的哨兵值没有被误判;单节点运行从 backend 进程连到了 runner | +| TC-70 没有返回结果 | 部分通过 | 见 D16 | +| TC-80 本地隔离生效 | 通过 | uid 30000,环境变量为空,`config.yaml`、`/app/data`、`/app/bisheng`、`/proc/1/environ` 都报 PermissionError | +| TC-81 权限收紧后平台功能正常 | 部分 | 任务模式的上传、下载、预览都正常;知识库上传解析和检索没测 | +| TC-82 本地隔离下的任务模式 | 基本通过 | 结果区文件名正确;首次写入失败,见 D12 | +| TC-83 worker 崩溃后清理 | 通过 | 重启后运行目录被删除,没有残留用户进程 | +| TC-84 关闭本地隔离 | 通过 | uid 变为 0,能读到 `BS_MINIO_SECRET_KEY` 等密钥,日志有 NOT isolated 告警 | +| TC-90、TC-91 | 未测 | 需要老版本环境或全新安装,116 上没有 | +| TC-92 runner 不可用 | 通过 | 日常会话和任务模式都报 `SandboxUnreachableError`,没有退回本机执行,任务也没有整体失败 | +| TC-93 删除 `sandbox_conf` 段 | 通过(有前提) | 能正常启动和执行。能连上 runner,是因为管理页面在库里存了地址和 token(`sandbox_conf_service`);全新安装如果两边都没配,能启动但连不上 runner | +| TC-94 网络隔离 | 失败 | 见 D8 | +| TC-95 凭据隔离 | 通过 | 环境变量只有白名单项,找不到 `config.yaml`。附加组问题见 D10 | +| TC-96 跨会话隔离 | 撞号已复现 | 跨会话读取没有复现,见 D9 | +| TC-97 租约令牌越权 | 通过 | 错误的租约和缺少租约都返回 403,没有 token 或 token 错误都返回 401 | +| TC-98 资源上限 | 通过 | fork 到第 244 个进程时被拦住,但留下了僵尸进程,见 D5 | +| TC-99 runner 运行身份 | 不达标 | 见 D10 | + +--- + +## 4. 和 F068 无关但在测试中看到的问题 + +- minimax2.5 的 `` 内容漏进了任务模式的最终回答(TC-14),正文被截成了两段。 +- 只勾选 3 个技能,每次调用仍然把全部技能拷进沙箱,约 1000 个文件、5MB,并且全部列在 `all_from_session_files` 里。如果「勾选」本来只用来控制提示词,这一条可以不算缺陷,需要确认设计意图。 +- 10-29 下午出现过一次:admin 还剩 18 次邀请码,提交任务却报 11030「次数已用完」。之后没有再复现,原因没查到。 + +--- + +## 5. 测试期间对环境做过的改动 + +| 改动 | 状态 | +|---|---| +| 系统配置 `linsight_invitation_code` 从 `true` 改为 `false` | **尚未恢复** | +| 代码执行方式从「本地隔离运行」改为「沙箱运行」 | **尚未恢复** | +| 新建了邀请码 `Tibr5MiEg0`(批次 `f068-sandbox-test`,未绑定) | **尚未删除** | +| runner 临时设为 `MAX_SESSIONS=2`、`LEASE_TTL_S=60` | 已恢复,compose 与备份 `docker-compose.yml.bak-f068-phase2-20261030-103829` 一致 | +| backend 和 worker 临时设为 `LOCAL_ISOLATION=off`、`DEFAULT_TIMEOUT_S=60` | 已恢复 | +| 删除 `config.yaml` 里的 `sandbox_conf` 段 | 已恢复,备份是 `config.yaml.bak-f068-tc93-20261030-112714` | +| 多次重启或停止 runner,重启过 worker | runner 和 backend 都已正常运行;超时留下的进程已随 runner-2 重启清除 | + +--- + +## 6. 对用例文档的改正 + +这次按实测,在用例文档里改了以下几处过时的前提: + +1. **R2、R4、TC-31**:runner 默认 `SANDBOX_MAX_SESSIONS=50`,不是每个 runner 2 个会话。TC-31 改为先临时把上限调成 2 再测。 +2. **TC-03、第 8 节**:本地隔离默认开启。代码默认值是 `uid`,test 环境的 `config.yaml` 也是 `uid`,跑第 8 节前不需要再改 compose。 +3. **第 8 节的权限说明**:`/app/bisheng` 不是在 worker 启动时收紧,而是每个进程第一次执行本地代码时才收紧。新启动的容器在第一次本地执行前是 755。`/app/data` 是挂载卷,改过的权限会一直保留。 +4. **TC-80 的预期**:输出里的 cwd 会被改写成工作区路径,实际运行目录要在执行期间到 `/tmp/bisheng-local-run/` 下查看。 + +--- + +## 7. 修复与验收(2026-10-30) + +修复提交在 feat/3.0.0 上,覆盖 D1–D7、D9、D10、D11–D14、D16。验收在 116 上进行:验收人用 `bisheng-sandbox:release` 叠加修复后的 runner 代码,起一组独立的 `f068v-*` runner 和客户端容器,逐项重跑上文各缺陷的复现场景,没有使用 `bisheng-test-*`。14 项全部通过,验收结束后 `f068v-*` 容器、网络和镜像都已删除。本地沙箱单测为 354 passed、16 skipped。 + +| 缺陷 | 修法 | 验收结果 | +|---|---|---| +| D1 | exec、拷入、拷出、建删会话放到工作线程执行,三类操作分开限流 | 两个 `sleep 10` 总耗时 10.1s;执行期间 `/health` 3ms 返回 | +| D2 | 先 `killpg`,失败再 `kill`,不抛异常;超时返回 124;镜像加 `CAP_KILL` | 返回 124/timeout,进程已清掉;`setsid` 逃逸也能清掉;执行器报 28003 | +| D3 | 拷入流式解包,单会话总量上限 512MiB(`SANDBOX_MAX_COPY_IN_TOTAL_BYTES`),请求体超限返回 413;会话目录改放磁盘 `/sessions` | 1.5GB 工作区跑通,超出上限的 22 个文件被跳过,模型收到提示;runner 峰值内存 486MiB,没有重启 | +| D4 | 执行中的会话不被空闲回收;会话不存在时 runner 统一返回 404,执行器重新申请会话并重试一次 | TTL=60 时 70s 的执行产物完整;服务端删会话、重启 runner 后都能自动恢复 | +| D5 | 镜像用 tini 作为 PID 1 | 10 次 soffice 转换和 100 个孤儿进程之后,僵尸数为 0 | +| D6 | 负退出码换算为 128+N;新增 `killed_reason`(memory、cpu、timeout、signal);执行器给模型固定提示;OOM 后保留会话 | 内存超限返回 137/memory,同 runner 另一会话不受影响;CPU 超限返回 137/cpu | +| D7 | 删除会话时杀掉正在执行的进程;日常对话的停止接到执行器的 `close()` | 停止后 0.13s 内进程被杀、会话被删 | +| D9 | 分配最小的空闲 uid;会话结束时按 uid 杀掉残留进程,确认没有残留后才释放 uid | 三个会话的 uid 不冲突 | +| D10 | 子进程清空附加组;CPU 上限 `${SANDBOX_CPUS:-2}` | `getgroups()=[]`,CPU 上限为 2 | +| D11 | 镜像写入 `/etc/matplotlibrc`,默认文泉驿字体 | 中文标题和负号没有缺字警告 | +| D12 | 本地方式预建 `output/` 和 `scratch/` | 单测覆盖 | +| D13 | HOME 和 XDG 目录移到工作目录外 | 产物只剩 `output/` 下的文件 | +| D14 | 镜像写入 uid 10000–10999 的 passwd 和 group 条目 | `getpass.getuser()` 返回 `sandbox10000` | +| D16 | 代码节点没有返回值时报 28008 | 单测覆盖 | + +验收和修复之后仍有以下几点需要知道: + +1. TC-35 的原始代码先撞上进程的 2GB `RLIMIT_AS`,结果是 `MemoryError`(exitcode 1),不会走到 137。要验证 137/memory,需要另一个会话先占住内存,再让本会话逐页写入大块内存。 +2. 用户进程被 OOM 杀掉后,`docker inspect` 会把 runner 容器标为 `OOMKilled=true`,但 runner 本身没有重启。监控不能把这个标志当作 runner 崩溃。 +3. 请求体不带 Content-Length 且超过上限时,客户端看到的是连接重置,而不是 413。执行器会先按总量上限截断,不会发出这样的请求。 +4. `TMPDIR` 仍在工作目录里,用户代码留下的临时文件可能进入产物;拷出和 stdout 仍然整块放在内存里。 +5. 验收镜像里的 tini 是从宿主机的 docker-init 拷入的,因为 116 没有外网。Dockerfile 里的 `apt install tini` 要以第一次 CI 构建为准。 +6. Compose 新增 CPU 上限,默认值 2。宿主机少于 2 核时必须把 `SANDBOX_CPUS` 设成 1,否则 Docker 拒绝启动 runner。 diff --git "a/docs/PRD/3.0-release/F068 \344\273\243\347\240\201\346\211\247\350\241\214\346\262\231\347\256\261\346\265\213\350\257\225\347\224\250\344\276\213.md" "b/docs/PRD/3.0-release/F068 \344\273\243\347\240\201\346\211\247\350\241\214\346\262\231\347\256\261\346\265\213\350\257\225\347\224\250\344\276\213.md" new file mode 100644 index 0000000000..cc4d6fbedf --- /dev/null +++ "b/docs/PRD/3.0-release/F068 \344\273\243\347\240\201\346\211\247\350\241\214\346\262\231\347\256\261\346\265\213\350\257\225\347\224\250\344\276\213.md" @@ -0,0 +1,821 @@ +# F068 代码执行沙箱测试用例 + +- **对应功能**:F068 代码执行沙箱统一底座。设计文档在 `features/v3.0.0-beta1/068-code-execution-sandbox/` +- **代码基线**:`origin/feat/3.0.0`(2026-10-17)。这个功能已经合进 `release` +- **测试环境**:test 环境(116 上的 `bisheng-test-*` 容器组,Docker Compose)。Helm 部署时,把 `docker exec` 换成 `kubectl exec`,容器名按实际改 +- **用例来源**:按代码编写。第 1 节「高风险点」是读代码得出的判断,标「推断」的条目可信度更低 +- **执行记录**:2026-10-29 至 10-30 在 test 环境执行过一轮,结果和缺陷见同目录的《F068 代码执行沙箱测试报告》。2026-10-30 按实测改正了几处过时的前提:runner 会话上限(第 1 节 R2、R4,TC-31)、本地隔离默认开启(TC-03、第 8 节)、权限收紧的时机(第 8 节) + +--- + +## 1. 高风险点 + +下表是读代码发现的风险。用例里的 R1–R8 指这张表。第 5 节的用例专门验证 R1–R5,建议冒烟通过后先跑。 + +| 编号 | 风险 | 依据 | 实测成立时的影响 | +|---|---|---|---| +| R1 | 沙箱错误可能直接中断任务模式(推断) | 28001/28002/28003/28006 是抛出的异常。LangGraph `ToolNode` 默认只吞参数校验错误,任务模式没有配置 `handle_tool_errors`。日常会话有 `_handle_agent_tool_error` 兜底 | 本地模式超时时,模型拿到超时说明后任务继续;沙箱运行超时则整个任务失败 | +| R2 | 并发容量少于任务模式并发数 | runner 默认 `SANDBOX_MAX_SESSIONS=50`(代码默认值,仓库 compose 和 helm values 也是 50),test 环境 2 个 runner 共 100 个槽位。任务模式整轮占用一个会话,包括模型思考时间。领不到槽位时不排队,直接报 28002 | 默认配置下槽位很难占满,要复现得临时调小上限(见 TC-31)。实测发现真正的瓶颈是 runner 同一时间只执行一段代码,见测试报告 | +| R3 | 空闲 15 分钟回收打断长任务 | `SANDBOX_LEASE_TTL_S=900`,活跃时间只在执行开始时刷新。执行器拿到 404 后不会重新领租约。任务模式单次超时设为 3600 秒 | 两次代码调用相隔超过 15 分钟时报 28006;单次执行超过 15 分钟时,工作目录可能在执行中被删 | +| R4 | 资源限制叠加导致误判 | `mem_limit: 2g` 由同一 runner 上的全部会话(最多 50 个)和 runner 主进程共享;`/tmp` tmpfs 不限大小,占用同一份内存;子进程 `RLIMIT_AS=2GB`、`RLIMIT_CPU=600s` | 工作区大时 OOM;runner 被杀后所有会话丢失;原生报错不指向资源限制,容易被误判为功能缺陷 | +| R5 | 子代理并行共用同一个执行器(推断) | 子代理的工具和主代理是同一批实例。copy-out 只按最近一次 exec 前的快照做差 | 并行时产物被算到别的子代理结果里,或丢失 | +| R6 | 同一 runner 上两个会话可能拿到相同 uid | `uid = 10000 + 当前会话数`,会话释放后再新建会撞号 | 同一容器内两个会话互相可读,违反跨会话隔离 | +| R7 | 与安全评审口径不一致 | runner 容器没有 CPU 上限;runner 主进程以 root 运行,带 `SETUID`/`DAC_OVERRIDE` 能力。2026-10-17 在 test 环境核实:`NanoCpus=0`、`User=0` | AC-14 的 CPU 上限没有落地;对外说「非 root」需要先对齐口径 | +| R8 | 时区可能不对(推断) | 沙箱 Dockerfile 里没有看到安装 `tzdata` | `datetime.now()` 是 UTC,报告日期和时间慢 8 小时 | + +--- + +## 2. 通用准备 + +### 2.1 环境与执行方式 + +1. 登录 116,设置容器名变量。下文所有 `docker` 命令都在 116 上执行: + + ```bash + ssh root@192.168.106.116 + BACKEND=bisheng-test-backend + WORKER=bisheng-test-backend-worker # 任务模式 worker 也在这个容器里 + RUNNER1=bisheng-test-code-runner-1 + RUNNER2=bisheng-test-code-runner-2 + ``` +2. 确认 runner 镜像和后端镜像是要测的版本: + + ```bash + docker inspect -f '{{.Config.Image}} {{.Created}}' $RUNNER1 $WORKER + ``` + + test 环境用 `release` 标签的镜像。先确认镜像在 F068 最新提交之后构建过。 +3. 进入平台管理端,打开「工具 → 内置工具 → 代码执行器」。「代码执行方式」有三个选项: + - **本地隔离运行**(`type=local`) + - **沙箱运行**(`type=container`) + - **E2B沙箱运行**(`type=e2b`) + + 用例里写「沙箱运行」,就先选这一项并保存。 +4. 检查 runner 连通性。两条命令都应返回 `{"status":"ok"}` 或类似的健康响应: + + ```bash + for c in $BACKEND $WORKER; do + docker exec $c curl -sS -m 5 http://code-runner-1:8080/health; echo + done + ``` +5. 测试期间开两个终端跟日志: + + ```bash + # 终端 1:两个 runner + docker logs -f --since 1m $RUNNER1 2>&1 | grep -E "session|exec|oom|capacity" & + docker logs -f --since 1m $RUNNER2 2>&1 | grep -E "session|exec|oom|capacity" + # 终端 2:worker(任务模式、工作流) + docker logs -f --since 1m $WORKER 2>&1 | grep -iE "sandbox|code interpreter|2800[0-9]" + ``` + +### 2.2 让模型原样执行代码 + +很多用例要求模型执行一段指定的代码。在对话框里先写下面这段固定前缀,再接代码块。下文用 **【原样执行】** 指代这段前缀: + +> 请调用代码执行器,原样运行下面的 Python 代码,不要改写、不要拆分,也不要先做别的事;运行后把完整输出原样贴给我。 + +模型有时仍会改写代码。每条用例执行后,展开工具调用详情,确认实际执行的就是给定代码。如果被改写,就重新发一次。 + +### 2.3 结果记录 + +每条用例记录以下内容:用例编号、执行方式、通过或失败、会话链接、工具调用详情截图,以及 runner 日志里相关的 `session_id`。 + +--- + +## 3. 冒烟(每次部署后先跑) + +### TC-01 沙箱运行基本可用 + +- **前置**:执行方式为「沙箱运行」。 +- **步骤**: + 1. 在任务模式中发送【原样执行】加下面的代码: + + ```python + import os, sys, getpass + print("cwd:", os.getcwd()) + print("uid:", os.getuid(), "user:", getpass.getuser()) + print("python:", sys.executable) + print("ls:", sorted(os.listdir("."))) + ``` +- **预期**: + - `cwd` 以 `/tmp/sessions/` 开头。 + - `uid` 不是 0。 + - `python` 是 `/app/.venv/bin/python` 一类的路径。 + - `ls` 里有 `output`、`scratch`。 + - runner 日志依次出现 `session created`、`exec start`、`exec done exitcode=0`。 + +### TC-02 任务结束释放会话 + +- **步骤**: + 1. 等 TC-01 的任务结束。 + 2. 查看 runner 日志。 +- **预期**:出现 `session deleted ... reason=client`,`slots` 回到 `0/2`。 + +### TC-03 执行方式切换不需要重启 + +- **步骤**: + 1. 把执行方式改成「本地隔离运行」,保存。不要重启任何服务。 + 2. 新开一个任务,再跑 TC-01 的代码。 + 3. 改回「沙箱运行」,新开一个任务,再跑一次。 +- **预期**: + - 第 2 步 runner 日志没有新会话。`cwd` 显示为任务工作区 `/root/.cache/bisheng/linsight/...`。本地隔离默认开启,test 环境也是开启的:uid 在 30000–39999 之间,代码实际在 `/tmp/bisheng-local-run/run-*` 里运行,输出里的路径会被改写回工作区路径,见第 8 节。如果 uid 是 0,说明本地隔离被关掉了。 + - 第 3 步的 `cwd` 回到 `/tmp/sessions/`。 +- **关联**:AC-22。 + +--- + +## 4. 任务模式核心链路 + +### TC-10 读取用户上传的原件 + +- **前置**:准备 `sales.xlsx`,包含两列「地区」「金额」,10 行数据。 +- **步骤**: + 1. 在任务模式里上传 `sales.xlsx`。 + 2. 发送:「用代码读取我上传的 Excel,按地区汇总金额,把结果打印出来。」 +- **预期**: + - 模型在代码里用 `uploads/sales.xlsx` 读文件,不报 `FileNotFoundError`。 + - 汇总数值和手工计算一致。 + +### TC-11 产物出现在结果区 + +- **步骤**:发送【原样执行】加下面的代码: + + ```python + import pandas as pd + pd.DataFrame({"a": [1, 2], "b": [3, 4]}).to_excel("output/report.xlsx", index=False) + print("done") + ``` +- **预期**: + - 结果区出现 `report.xlsx`,可以下载,打开后内容正确。 + - 在同一轮里接着说「用 read_file 读一下 /output/report.xlsx 的信息」,模型能读到。 + +### TC-12 根目录文件被移到 `output/` + +- **步骤**:发送【原样执行】加下面的代码: + + ```python + open("root_file.txt", "w").write("hello") + print("written") + ``` +- **预期**: + - 工具结果里提示文件已移到 `output/root_file.txt`。 + - 结果区出现 `root_file.txt`。 + +### TC-13 输入文件不进入产物清单 + +- **前置**:沿用 TC-10 的会话,`uploads/sales.xlsx` 已存在。 +- **步骤**:发送【原样执行】加下面的代码: + + ```python + import pandas as pd + df = pd.read_excel("uploads/sales.xlsx") + df.to_csv("output/sales.csv", index=False) + print(len(df)) + ``` +- **预期**:产物清单里只有 `sales.csv`,没有 `sales.xlsx`。 + +### TC-14 跨轮继承上一轮的产物 + +- **步骤**: + 1. 在 TC-11 的任务完成后,在同一会话里继续追问:「在刚才的 report.xlsx 里加一列 c = a + b,另存为 report2.xlsx。」 +- **预期**: + - 模型能读到 `output/report.xlsx`。 + - 新产物 `report2.xlsx` 里的 c 列是 4、6。 + - runner 日志里,这一轮是一个新的 `session created`。 + +### TC-15 技能包可执行 + +- **前置**:在任务模式里勾选 bisheng-docx、bisheng-pptx、bisheng-xlsx 技能。 +- **步骤**:分三次新任务发送: + 1. 「写一份 300 字的项目周报,输出 Word。」 + 2. 「做一份 3 页介绍公司的 PPT。」 + 3. 「生成一张包含公式的月度预算表,输出 Excel。」 +- **预期**: + - 每个任务都调用了 `skills/<技能名>/scripts/` 下的脚本,产物能打开。 + - 版式符合技能定义:docx 是公文版式,xlsx 里的公式能算出值。 + +### TC-16 `ask_user` 等待后恢复 + +- **步骤**: + 1. 发一个模糊的需求,例如「帮我做个分析报告」,让模型触发澄清提问。 + 2. 澄清提问出现后,等 2 分钟再回答。 + 3. 回答后继续执行,过程中要用到代码。 +- **预期**: + - 第 1 步结束时,runner 日志有 `session deleted`。 + - 恢复后出现新的 `session created`,代码能读到之前上传的文件。 + +### TC-17 读取 `large_tool_results` + +- **步骤**: + 1. 发送:「联网搜索『2025 年中国新能源汽车销量』,把搜索结果全部保留。」等工具结果提示「Tool result too large … /large_tool_results/…」。 + 2. 接着发送:「用代码读取那个 large_tool_results 文件,统计它有多少个字符。」 +- **预期**:代码 `open('large_tool_results/')` 成功,输出字符数。 + +### TC-18 单个文件超过 50MB 被跳过 + +- **前置**:准备一个 60MB 的文件。可以用 `dd if=/dev/urandom of=big.bin bs=1M count=60` 生成,再改名为 `big.csv`。 +- **步骤**: + 1. 上传 `big.csv`,同时上传一个小文件 `small.txt`。 + 2. 发送【原样执行】加下面的代码: + + ```python + import os + print(sorted(os.listdir("uploads"))) + ``` +- **预期**: + - 输出里有 `small.txt`,没有 `big.csv`。 + - 工具结果里出现 `[SYSTEM NOTICE] Copy-in skipped an oversized file: uploads/big.csv`。 + - 如果上传接口本身就拒收 60MB 文件,记录上传上限,然后把这条用例改成「上限附近的文件」。 + +### TC-19 中文路径 + +- **步骤**:上传 `季度销售数据.xlsx`,然后发送【原样执行】加下面的代码: + + ```python + import pandas as pd + df = pd.read_excel("uploads/季度销售数据.xlsx") + df.to_excel("output/汇总结果.xlsx", index=False) + print("ok") + ``` +- **预期**:成功执行;结果区出现 `汇总结果.xlsx`,文件名不乱码。 + +### TC-20 绝对路径纠正提示 + +- **步骤**:发送【原样执行】加下面的代码: + + ```python + import os + os.makedirs("/output", exist_ok=True) + open("/output/abs.txt", "w").write("x") + ``` +- **预期**:执行失败或产物不出现,工具结果里有纠正提示,内容是「改用相对路径 `output/abs.txt`」。 + +### TC-21 越界访问被拒绝 + +- **步骤**:分两次发送【原样执行】加下面的代码: + + ```python + print(open("/root/.cache/bisheng/x").read()) + ``` + + ```python + import os + for r, d, f in os.walk("/"): + print(r) + ``` +- **预期**:两次都不执行,直接返回「只能访问工作区」一类的拒绝说明,`exitcode=1`。 + +### TC-22 首次调用耗时 + +- **前置**:上传 5 个 20MB 左右的文件,同时勾选 3 个技能。 +- **步骤**: + 1. 发送【原样执行】加 `print(1)`,记录这次工具调用的耗时。 + 2. 在同一轮里再发一次同样的代码,记录耗时。 +- **预期**: + - 第 2 次明显快于第 1 次,因为只增量拷贝。 + - 把两次的耗时写进测试报告,供产品判断能否接受。 + +--- + +## 5. 失败与边界(R1–R5,优先测) + +### TC-30 执行超时(R1) + +- **前置**:执行方式为「沙箱运行」。任务模式的超时在代码里写死为 3600 秒,不能通过配置缩短,所以这条用例要等 1 小时。可以先在日常会话里做对照:日常会话用 `sandbox_conf.default_timeout_s`(默认 600 秒)。在 backend 上临时设置 `BS_SANDBOX_CONF__DEFAULT_TIMEOUT_S=60` 并重建,可以把日常会话的超时缩到 60 秒。 +- **步骤**:发送【原样执行】加下面的代码: + + ```python + import time + time.sleep(100000) + ``` +- **预期**:模型收到「执行超时、产物不存在」的结果后继续工作,任务不失败。 +- **判定**:如果整个任务直接进入失败状态,或页面报错、停止,说明 R1 成立,记缺陷。 +- **对照**:切到「本地隔离运行」重做一次,对比两种方式的表现。 + +### TC-31 容量满(R2) + +- **前置**:runner 默认每个 50 个会话,下面 5 个并发填不满。先临时把 runner 的 `SANDBOX_MAX_SESSIONS` 改成 `2` 并重建两个 runner,得到 2 × 2 = 4 个槽位: + + ```bash + cd /opt/server/bisheng-test && cp -p docker-compose.yml docker-compose.yml.bak-tc31-$(date +%Y%m%d-%H%M%S) + # 把 runner 的 SANDBOX_MAX_SESSIONS: '50' 改成 '2'(两个 runner 共用同一个 environment 锚点) + docker compose up -d --no-deps code-runner-1 code-runner-2 + docker logs --since 1m $RUNNER1 2>&1 | grep ready # 应显示 max_sessions=2 + ``` + + 测完用备份恢复 compose 并再次重建 runner。CI 每次自动部署都按这份 compose 重建容器,所以不要把改动留在环境里。 +- **步骤**: + 1. 用 5 个浏览器窗口(可以是同一账号)各开一个任务模式会话。 + 2. 每个会话都发送【原样执行】加下面的代码,尽量在 10 秒内全部发出: + + ```python + import time + time.sleep(120) + print("done") + ``` + 3. 第 5 个任务发出后,再到工作台日常会话里发送【原样执行】加 `print(1)`。 +- **预期**:前 4 个任务正常执行。第 5 个任务和日常会话要么排队等待,要么给出看得懂的「执行资源已满」提示。 +- **判定**: + - 第 5 个任务整体失败,说明 R1 和 R2 同时成立。 + - 日常会话显示「工具执行失败: …28002…」:记录原文,评估提示能否看懂。 + - 在 worker 日志里搜 `28002` 或 `at capacity` 作为佐证。 + +### TC-32 两次代码调用相隔超过 15 分钟(R3) + +- **步骤**: + 1. 在任务模式发送:「先用代码打印 1;然后等我。」 + 2. 在同一轮里制造超过 15 分钟的间隔。最可控的方式是临时把 runner 的 `SANDBOX_LEASE_TTL_S` 改成 `60`,重建 runner。然后发一个先执行代码、再做 2 分钟以上联网检索、最后再执行代码的需求,例如:「先用代码打印当前时间;再联网搜 10 篇关于大模型 Agent 的文章并逐篇总结;最后用代码把总结字数统计出来。」 +- **预期**:最后一次代码调用成功。如果租约已过期,执行器应自动重新领租约。 +- **判定**:最后一次调用报 28006(`Isolation environment response is invalid`)或任务失败,说明 R3 成立。 + +### TC-33 单次执行超过空闲回收时间(R3) + +- **前置**:沿用 `SANDBOX_LEASE_TTL_S=60`。 +- **步骤**: + 1. 会话 A 发送【原样执行】加下面的代码: + + ```python + import time + open("output/before.txt", "w").write("1") + time.sleep(120) + open("output/after.txt", "w").write("2") + print("finished") + ``` + 2. A 执行 70 秒后,在会话 B 发送【原样执行】加 `print(1)`。B 会触发 runner 的回收检查。 +- **预期**:A 正常完成,产物里有 `before.txt` 和 `after.txt`。 +- **判定**:如果 runner 日志在 A 结束前出现 A 的 `session deleted reason=idle`,或 A 报 28006,说明 R3 成立。 + +### TC-34 执行中 runner 重启(R4) + +- **步骤**: + 1. 任务模式发送【原样执行】加 `import time; time.sleep(60)`。 + 2. 执行 10 秒后运行 `docker restart $RUNNER1`;如果会话落在 runner-2,就重启 runner-2,看日志确认。 + 3. 等这次调用返回后,在同一轮里再让模型执行 `print(1)`。 +- **预期**:第一次调用返回可归因的错误。第二次调用能重新领到租约并成功。 +- **判定**:第二次及以后的调用都报 28006,或任务失败。 + +### TC-35 OOM(R4) + +- **步骤**:发送【原样执行】加下面的代码: + + ```python + data = [] + while True: + data.append(bytearray(100 * 1024 * 1024)) + ``` +- **预期**:返回内容能看出是内存不足,例如 `MemoryError` 或 exitcode 137 时说明是内存超限。下一次调用能正常执行。 +- **附加**:执行期间,在同一 runner 的另一个会话里跑 `time.sleep(30); print("alive")`,确认那个会话不被连带杀掉。 +- **说明**:上面的代码会先撞上单进程 2GB 的 `RLIMIT_AS`,一般得到 `MemoryError`。如果要验证容器内存超限(exitcode 137、`killed_reason=memory`)的路径,先在另一个会话里占住约 600MB,再在本会话里循环执行 `data.append(b"x" * 50 * 1024 * 1024)`。 + +### TC-36 工作区很大时的内存占用(R4) + +- **前置**:上传多个大文件,总量约 1.5GB。如果单文件上限不允许,就分多轮上传。 +- **步骤**:发送【原样执行】加 `print(1)`。 +- **预期**:执行成功。 +- **判定**:runner 容器被 OOM 杀掉或重启(`docker inspect -f '{{.State.OOMKilled}} {{.RestartCount}}' $RUNNER1`)。 + +### TC-37 CPU 时间 600 秒上限(R4) + +- **步骤**:发送【原样执行】加下面的代码: + + ```python + i = 0 + while True: + i += 1 + ``` +- **预期**:大约 600 秒后进程被杀,返回结果能看出是 CPU 时间超限,任务不失败。 +- **记录**:返回给模型的原文和 exitcode。如果只看到 `Killed` 或空日志,说明报错不可归因,记缺陷。 + +### TC-38 子代理并行执行代码(R5) + +- **步骤**:发送:「同时启动 3 个子代理并行工作:第 1 个用代码生成 output/a.xlsx,第 2 个生成 output/b.docx,第 3 个生成 output/c.png(一张折线图)。」 +- **预期**:结果区三个文件都在,每个子代理的工具结果只列出自己的文件。 +- **判定**:文件缺失,或 A 的工具结果里列出了 B 的文件。 + +### TC-39 用户中止任务 + +- **步骤**: + 1. 任务模式发送【原样执行】加 `import time; time.sleep(300)`。 + 2. 执行 10 秒后点击「停止」。 +- **预期**:任务停止。30 秒内 runner 日志出现 `session deleted`,`slots` 回落。 + +### TC-40 执行失败时的半成品 + +- **步骤**:发送【原样执行】加下面的代码: + + ```python + open("output/half.txt", "w").write("partial") + raise RuntimeError("boom") + ``` +- **预期**:返回 `RuntimeError: boom`,`half.txt` 不出现在产物里。切到「本地隔离运行」重做一次,两种方式表现一致。 + +--- + +## 6. 执行环境能力 + +### TC-50 无外网 + +- **步骤**:发送【原样执行】加下面的代码: + + ```python + import requests + print(requests.get("https://www.baidu.com", timeout=5).status_code) + ``` +- **预期**:返回连接失败。模型能说明「执行环境不能联网」,不会反复重试同一段代码。 +- **附加**:发送「用代码下载 https://picsum.photos/200 这张图片插入 PPT」,观察模型是否改用其它方式,或者如实告知用户。 + +### TC-51 不能 `pip install` + +- **步骤**:发送「用代码安装 seaborn 然后画一张热力图。」 +- **预期**:安装失败后,模型改用 matplotlib 完成,或者如实说明。 + +### TC-52 中文字体 + +- **步骤**:发送【原样执行】加下面的代码: + + ```python + import matplotlib.pyplot as plt + plt.plot([1, 2, 3], [3, 1, 2]) + plt.title("季度销售趋势") + plt.xlabel("月份") + plt.savefig("output/chart.png") + print("ok") + ``` +- **预期**:图片里的中文正常显示,不是方框。 + +### TC-53 公文版式转 PDF 的字体 + +- **步骤**:勾选 bisheng-docx 技能,发送:「写一份关于开展安全检查的通知,公文格式,输出 Word 和 PDF。」 +- **预期**:两个文件都生成。 +- **记录**:PDF 中标题和正文实际使用的字体(用 PDF 阅读器的「文档属性 → 字体」查看)。对比 Word 里设定的仿宋、小标宋,记录字体回退的效果,交给产品判断。 + +### TC-54 LibreOffice 并发转换 + +- **步骤**:发送【原样执行】加下面的代码: + + ```python + import subprocess, docx, concurrent.futures as cf + for i in range(4): + d = docx.Document(); d.add_paragraph(f"文档 {i}"); d.save(f"scratch/d{i}.docx") + def conv(i): + return subprocess.run(["soffice", "--headless", "--convert-to", "pdf", + "--outdir", "output", f"scratch/d{i}.docx"], + capture_output=True, text=True, timeout=180).returncode + with cf.ThreadPoolExecutor(4) as ex: + print(list(ex.map(conv, range(4)))) + ``` +- **预期**:输出 `[0, 0, 0, 0]`,产物里有 4 个 PDF。 + +### TC-55 无 Chromium + +- **步骤**:发送【原样执行】加下面的代码: + + ```python + from playwright.sync_api import sync_playwright + with sync_playwright() as p: + p.chromium.launch() + ``` +- **预期**:报浏览器不存在。再让一个需要浏览器的技能(如 HTML 转图片)去执行,确认它能探测到缺失并降级,任务不失败。 + +### TC-56 时区(R8) + +- **步骤**:发送【原样执行】加下面的代码: + + ```python + import datetime, time, os + print(datetime.datetime.now(), time.tzname, os.environ.get("TZ")) + ``` +- **预期**:时间是 UTC+8,和当前台北时间一致。 +- **判定**:比当前时间慢 8 小时,说明 R8 成立。 + +### TC-57 承诺可用的库 + +- **步骤**:发送【原样执行】加下面的代码: + + ```python + import pandas, numpy, matplotlib, openpyxl, xlsxwriter, docx, pptx, PIL, reportlab, fitz + print("all ok") + ``` +- **预期**:输出 `all ok`。 +- **关联**:AC-04。 + +--- + +## 7. 其它路径回归 + +### TC-60 日常会话基本执行 + +- **步骤**:在工作台日常会话里打开代码执行器工具,发送【原样执行】加 TC-11 的代码。 +- **预期**:返回结果里有 `report.xlsx` 的下载链接,可以下载。runner 日志里这次会话执行完立即 `session deleted`。 + +### TC-61 日常会话不把上传原件送进执行环境 + +- **步骤**:在日常会话里上传 `sales.xlsx`,再发送【原样执行】加 `import os; print(os.listdir("."))`。 +- **预期**:列表里没有 `sales.xlsx`,行为和改造前一致。 + +### TC-62 助手 + +- **前置**:新建一个助手,挂上代码执行器工具,发布。 +- **步骤**:在助手对话里重复 TC-60。 +- **预期**:同 TC-60。 + +### TC-63 知识库图片做成 PPT + +- **前置**:建一个知识库,上传一份含 3 张以上图片的 docx,等解析完成。 +- **步骤**:在日常会话里选择这个知识库,打开代码执行器,发送:「找到知识库里关于 XX 的所有图片,合并到一个 PPT 里,每页一张。」 +- **预期**: + - PPT 生成,图片数量和检索到的图片一致。 + - 如果有图片取不到,回复里如实说明。 + - 「本地隔离运行」「沙箱运行」两种方式都要测。 +- **反例**:在同一会话里发送【原样执行】加 `print(open("/bisheng/knowledge/images/1/1/x.png","rb").read()[:10])`,即模型自己编的路径。预期读不到,不会触发下载。 + +### TC-64 工作流 Agent 节点 + +- **前置**:建一个工作流:开始 → Agent 节点(挂代码执行器)→ 结束。 +- **步骤**:运行工作流,输入「用代码生成一个 10 行的 Excel」。 +- **预期**:节点输出里有文件链接,可以下载。 + +### TC-65 工作流工具节点 + +- **前置**:建一个工作流:开始 → 工具节点(代码执行器,代码参数用固定代码 `print(1+1)`)→ 结束。 +- **步骤**:运行。 +- **预期**:输出 `2`。 + +### TC-66 代码节点:存量节点回归 + +- **前置**:从旧版本导出一个含代码节点的工作流并导入,或新建一个代码节点: + + ```python + def main(arg1: str, arg2: str) -> dict: + return {"result": arg1 + arg2} + ``` +- **步骤**:分别在「本地隔离运行」「沙箱运行」两种方式下运行,`arg1="a"`、`arg2="b"`。 +- **预期**:两种方式都输出 `result = "ab"`。 + +### TC-67 代码节点:导入后端已有的包 + +- **代码**: + + ```python + def main() -> dict: + import pandas, requests, jieba + return {"ok": "yes"} + ``` +- **预期**:成功。如果 `jieba` 不在依赖里,改用 `uv.lock` 里任意一个包。 + +### TC-68 代码节点:返回值不可序列化(28005) + +- **代码**: + + ```python + def main() -> dict: + return {"r": object()} + ``` +- **预期**:节点报错,原因指向「输出不可序列化」,不会静默产出空值。 + +### TC-69 代码节点:返回值过大(28007) + +- **代码**: + + ```python + def main() -> dict: + return {"r": "x" * (5 * 1024 * 1024)} + ``` +- **预期**:报错信息里写明结果字节数超过 4MB 上限。 + +### TC-70 代码节点:没有返回结果 / 缺少 main(28008) + +- **代码 1**:`def main() -> dict:` 函数体只有 `print(1)`,没有 return。预期报「main 没有返回结果」。 +- **代码 2**:整段代码里没有 `main` 函数。预期在调试或上线时就报错,保存草稿时不报错。 + +### TC-71 代码节点:上游数据伪造哨兵 + +- **代码**: + + ```python + def main(arg1: str) -> dict: + print(arg1) + return {"r": "ok"} + ``` +- **步骤**:让上游节点传入 `arg1 = "__BISHENG_CODE_NODE_UNSERIALIZABLE__"`。 +- **预期**:节点正常输出 `r = "ok"`,不误报「不可序列化」。 + +### TC-72 代码节点:语法错误的报错时机 + +- **代码**:`def main( -> dict:` +- **预期**:在节点初始化(运行或调试开始)时报语法错误,报错时机和信息与改造前一致。 +- **关联**:AC-20。 + +### TC-73 编辑器「运行一次」 + +- **步骤**:在工作流编辑器里,对 TC-66 的代码节点点击单节点「运行一次」。 +- **预期**:成功。这条路径在 backend 进程里执行,所以 backend 也必须能连到 runner。 + +--- + +## 8. 本地隔离运行(10-14 新增) + +10-14 起,「本地隔离运行」让代码以临时普通 uid(30000–39999)在私有目录 `/tmp/bisheng-local-run/run-*` 里执行。 + +**开关**:配置项是 `sandbox_conf.local_isolation`,可以用环境变量 `BS_SANDBOX_CONF__LOCAL_ISOLATION` 覆盖。代码默认值是 `uid`,也就是默认开启;只有显式写 `off` 或空字符串才关闭,写错的值一律按开启处理。仓库里的 compose 也通过 `.env` 的 `LOCAL_ISOLATION` 默认传 `uid`。2026-10-30 核实:test 环境的 `config.yaml` 写的是 `local_isolation: uid`,compose 没有设置这个变量,所以本地隔离是开启的,跑本节用例前不需要改配置。用下面的命令确认: + +```bash +docker exec $WORKER grep -A6 '^sandbox_conf:' /app/bisheng/config.yaml | grep local_isolation +docker exec $WORKER env | grep LOCAL_ISOLATION # 没有输出表示没有用环境变量覆盖 +``` + +**权限收紧的时机**:收紧不在 worker 启动时做,而是每个进程第一次执行本地代码时,在预检里把 `/app/bisheng`、`/app/data` 改为 0700,再用探针确认临时 uid 读不到它们。因此: + +- 新启动的容器里,`/app/bisheng` 是 755,要等这个容器第一次本地执行后才变成 0700。backend 和 worker 两个容器各自独立收紧。2026-10-30 实测:worker 首次本地执行前 `/app/bisheng` 是 755,执行后是 700。 +- `/app/data` 是挂载卷,权限改在宿主机的目录上,容器重建后仍然是 0700。 +- `config.yaml` 本身应为 `600 root`。如果宿主机上的文件对组或其他用户可读,worker 日志会出现 `is group/other readable` 告警。 + +所以检查权限时,要先在同一个容器里执行一次本地代码,再看 `stat -c %a /app/bisheng`。 + +### TC-80 本地隔离生效 + +- **前置**:执行方式为「本地隔离运行」。 +- **步骤**:发送【原样执行】加下面的代码: + + ```python + import os + print(os.getuid(), os.getcwd()) + print({k: v for k, v in os.environ.items() if any(s in k.upper() for s in ("MINIO", "MYSQL", "REDIS", "SECRET", "TOKEN", "PASSWORD"))}) + for p in ["/app/bisheng/config.yaml", "/app/data", "/root/.cache/bisheng"]: + try: + print(p, os.listdir(p) if os.path.isdir(p) else open(p).read(50)) + except Exception as e: + print(p, "->", type(e).__name__) + ``` +- **预期**: + - uid 在 30000–39999 之间。 + - cwd 显示为任务工作区路径 `/root/.cache/bisheng/linsight/...`。代码实际在 `/tmp/bisheng-local-run/run-*` 里运行,执行器会把输出里的私有目录路径改写回工作区路径。要确认实际运行目录,在执行中运行 `docker exec $WORKER ls -la /tmp/bisheng-local-run/`,能看到属主为临时 uid 的 `run-*` 目录。 + - 环境变量字典为空。 + - 三个路径都报 `PermissionError`。 +- **提示**:模型经常改写或截断这段代码。如果工具调用详情里的代码和上面不一致,可以在 worker 容器里直接调用 `LocalExecutor.execute_code(code=..., work_dir=...)` 跑同一段代码,结果等价。 + +### TC-81 权限收紧后平台功能正常 + +- **步骤**:在本地隔离开启的情况下,先执行一次本地代码,让 backend 和 worker 都完成权限收紧,然后依次做以下操作:上传知识库文件并等待解析完成、在日常会话里检索、在任务模式里上传文件、下载产物、预览附件。 +- **预期**:每项都正常。如果某项失败,记录报错,排查是否和 `/app/data` 改为 0700 有关。 + +### TC-82 本地隔离下任务模式的完整链路 + +- **步骤**:在「本地隔离运行」下重跑 TC-10 到 TC-16。 +- **预期**:结果和「沙箱运行」一致。 + +### TC-83 worker 崩溃后清理残留 + +- **步骤**: + 1. 本地隔离模式下,发送【原样执行】加 `import time; time.sleep(300)`。 + 2. 执行中运行 `docker restart $WORKER`。 + 3. 重启完成后运行 `docker exec $WORKER ls -la /tmp/bisheng-local-run/`。 +- **预期**:残留的运行目录已被启动清理删掉,没有遗留的用户进程(`docker exec $WORKER ps -eo uid,cmd | awk '$1>=10000'` 输出为空)。 + +### TC-84 关闭本地隔离 + +- **步骤**:在 compose 里给 backend 和 backend_worker 加上 `BS_SANDBOX_CONF__LOCAL_ISOLATION: 'off'`,重建这两个容器,再跑 TC-80。测完删掉这一行并重建。 +- **预期**:恢复成旧行为:uid 为 0,能读到环境变量。worker 日志出现 `local code execution is NOT isolated` 告警。这条只用来确认开关有效,不代表推荐这样配置。 + +--- + +## 9. 部署、配置与安全 + +### TC-90 存量升级不改变执行方式 + +- **步骤**: + 1. 用 3.0.0-beta3 或更早的环境,把执行方式设为本机运行。 + 2. 升级到当前版本。 + 3. 打开代码执行器配置页。 +- **预期**:仍是「本地隔离运行」,没有被改成「沙箱运行」。 +- **关联**:AC-23。 + +### TC-91 新装默认沙箱运行 + +- **步骤**:全新安装后打开代码执行器配置页。 +- **预期**:默认是「沙箱运行」。 +- **关联**:AC-24。 + +### TC-92 runner 不可用时不静默回落 + +- **步骤**: + 1. 运行 `docker stop $RUNNER1 $RUNNER2`。 + 2. 在日常会话里发送【原样执行】加 `print(1)`。 + 3. 在任务模式里发送同样的代码。 +- **预期**:两处都报「执行环境不可达」(28001),不会悄悄回落到本机执行。 +- **判定**:任务模式整个失败时归入 R1。 +- **收尾**:运行 `docker start $RUNNER1 $RUNNER2`。 +- **关联**:AC-05。 + +### TC-93 配置缺失时能启动 + +- **步骤**:删掉 `config.yaml` 里的 `sandbox_conf` 段(如果有),重启 backend 和 worker。 +- **预期**:正常启动,按默认值工作。 +- **关联**:AC-25。 + +### TC-94 网络隔离 + +- **步骤**:在沙箱运行下发送【原样执行】加下面的代码: + + ```python + import socket + for h, p in [("mysql", 3306), ("redis", 6379), ("minio", 9000), ("elasticsearch", 9200), + ("openfga", 8080), ("backend", 7860), ("milvus", 19530)]: + try: + socket.create_connection((h, p), timeout=3); print(h, "OPEN") + except Exception as e: + print(h, "->", type(e).__name__) + ``` +- **预期**:全部失败,主机名解析失败或连接被拒,没有任何一行是 `OPEN`。 +- **关联**:AC-12。 + +### TC-95 凭据与配置隔离 + +- **步骤**:在沙箱运行下发送【原样执行】加下面的代码: + + ```python + import os, glob + print(dict(os.environ)) + print(glob.glob("/app/**/config.yaml", recursive=True), glob.glob("/app/**/entrypoint.sh", recursive=True)) + ``` +- **预期**: + - 环境变量里只有 PATH、LANG、HOME 等白名单项,没有 token、lease token 和中间件口令。 + - 找不到 `config.yaml`、`entrypoint.sh`。 +- **关联**:AC-15、AC-28。 + +### TC-96 同一 runner 上的跨会话隔离(R6) + +- **前置**:临时只保留 runner-1(`docker stop $RUNNER2`),保证会话都落在同一个 runner 上。 +- **步骤**: + 1. 会话 A 在任务模式发送【原样执行】加下面的代码(让 A 一直占着会话): + + ```python + import os, time + open("output/secret_A.txt", "w").write("A-secret") + print(os.getuid(), os.getcwd()); time.sleep(600) + ``` + 2. 会话 B 发送【原样执行】加下面的代码,记录 uid: + + ```python + import os; print(os.getuid()) + ``` + + 等 B 的任务结束、会话被释放。 + 3. 会话 C 发送【原样执行】加下面的代码: + + ```python + import os, glob + print(os.getuid()) + for d in glob.glob("/tmp/sessions/*"): + try: print(d, os.listdir(d)) + except Exception as e: print(d, "->", type(e).__name__) + ``` +- **预期**:C 读不到 A 的目录,结果是 `PermissionError`。 +- **判定**:如果 C 的 uid 和 A 相同,或 C 能列出 A 目录里的 `secret_A.txt`,说明 R6 成立,记安全缺陷。 +- **收尾**:运行 `docker start $RUNNER2`。 + +### TC-97 租约令牌越权 + +- **步骤**:在 worker 容器里直接调用 runner 接口: + + ```bash + docker exec $WORKER sh -c ' + T=${BS_SANDBOX_CONF__TOKEN} + A=$(curl -s -X POST -H "Authorization: Bearer $T" http://code-runner-1:8080/v1/sessions) + echo "A=$A" + SID=$(echo "$A" | python -c "import sys,json;print(json.load(sys.stdin)[\"session_id\"])") + curl -s -o /dev/null -w "wrong-lease:%{http_code}\n" -X POST \ + -H "Authorization: Bearer $T" -H "X-Lease-Token: deadbeef" -H "Content-Type: application/json" \ + -d "{\"code\":\"print(1)\"}" http://code-runner-1:8080/v1/sessions/$SID/exec + curl -s -o /dev/null -w "no-token:%{http_code}\n" -X POST http://code-runner-1:8080/v1/sessions + ' + ``` +- **预期**:`wrong-lease:403`,`no-token:401` 或 403。测试创建的会话会在空闲超时后被回收,也可以手动 DELETE。 + +### TC-98 资源上限 + +- **步骤**:在沙箱运行下分别执行: + 1. fork 炸弹:`import os` 后执行 `while True: os.fork()`。 + 2. 跑满 CPU:`while True: pass`,执行期间在宿主机用 `docker stats $RUNNER1` 观察 CPU 占用。 +- **预期**: + - fork 被 `pids_limit` 或 `RLIMIT_NPROC` 拦住,宿主机不受影响。 + - CPU 占用能看出有上限。 +- **判定**:如果 CPU 能吃满宿主机所有核,说明 R7(AC-14 未落地)成立。 + +### TC-99 runner 自身的运行身份(R7) + +- **步骤**:运行 `docker exec $RUNNER1 ps -eo uid,user,cmd`。 +- **记录**:runner 主进程的 uid(预计是 0),交给安全评审对齐「非 root」的口径。 + +--- + +## 10. 执行顺序建议 + +1. **冒烟**:TC-01 到 TC-03。不通过就停止,先排查部署问题。 +2. **高风险**:TC-30 到 TC-34。结论尽早给开发。 +3. **任务模式核心**:TC-10 到 TC-22。 +4. **执行环境**:TC-50 到 TC-57。 +5. **其它路径**:TC-60 到 TC-73。 +6. **本地隔离**:TC-80 到 TC-84。 +7. **部署与安全**:TC-90 到 TC-99。 +8. **其余边界**:TC-35 到 TC-40。 diff --git a/docs/PRD/3.0-release/qwen-embedding-openai-client.md b/docs/PRD/3.0-release/qwen-embedding-openai-client.md new file mode 100644 index 0000000000..3d93309b0a --- /dev/null +++ b/docs/PRD/3.0-release/qwen-embedding-openai-client.md @@ -0,0 +1,206 @@ +# Qwen Embedding 客户端切换说明 + +状态:后端实现及离线自动化测试已完成,真实网关联调与知识库召回验收待执行。 + +适用分支:`feat/3.0.0`。实施基线:`111268ba77b3fac37cbcc8fb809181e65d9560c5`,核对日期:2026-10-21。 + +## 目标与修改范围 + +将模型管理中 Qwen 文本向量模型的客户端由 `DashScopeEmbeddings` 切换为 `langchain_openai.OpenAIEmbeddings`,使用阿里云的 OpenAI 兼容接口,并让界面填写的 Base URL 实际控制请求地址。Qwen LLM 已有的地址与凭证映射方式是本次实现参考。 + +继续使用 `BishengEmbedding` 对外提供的向量接口,保留模型名称、模型 ID、上线状态检查、调用限制和向量归一化逻辑。服务商类型仍为 `qwen`,用户无需把服务商改成 OpenAI。 + +本次涉及现有后端适配和针对性测试;界面已经有地址输入和保存能力,无需新增字段,也不涉及数据库结构迁移。Qwen LLM、rerank、ASR、TTS 及其他服务商的协议与默认值不在修改范围内。 + +## 修改前的链路与根因 + +### 界面配置已经保存 + +模型管理界面的 Qwen 配置包含以下字段: + +| 界面字段 | 保存字段 | 当前默认值 | +| --- | --- | --- | +| Base URL | `LLMServer.config.openai_api_base` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | +| API Key | `LLMServer.config.openai_api_key` | 由用户填写 | + +实际调用链为: + +```text +modelProviderFields.ts 定义 Qwen 字段 +→ CustomForm.getData 返回配置 +→ ModelConfig.handleSave 将配置放入 config +→ finetune.ts 调用 POST 或 PUT /api/v1/llm +→ LLMService 保存 LLMServer.config +→ BishengBase.get_server_info_config 读取配置 +→ embedding._get_qwen_params 组装客户端参数 +``` + +新增和修改服务商时,服务层已经对地址、凭证等字符串清理首尾空白。问题出在最后一步:embedding 的 Qwen 参数处理只将 `openai_api_key` 映射为 `dashscope_api_key`,没有读取服务商的 `openai_api_base` 或 `base_url`;客户端映射也仍指向 `DashScopeEmbeddings`。 + +因此,界面虽然能保存地址,该地址却没有通过这条参数链传入 embedding 客户端。该结论来自代码及参数函数的隔离执行,不能据此推断某个已部署环境的实际出站地址。 + +### LLM 已有可参考实现 + +`llm.py` 的 `_get_qwen_params` 先调用本模块的 `_get_openai_params`,完成凭证与地址映射,再在未配置地址时补充 Qwen 默认地址。客户端为 `ChatQwen`,其父类为 `ChatOpenAICompatible`。 + +embedding 应参考这一组装方式,复用 **embedding 模块自己的** `_get_openai_params`。LLM 中的 `stream_usage`、联网搜索和流式输出参数属于聊天能力,不应带入向量请求。 + +## 阿里云接口依据 + +依据用户提供的[阿里云向量化文档](https://help.aliyun.com/zh/model-studio/embedding),文本向量支持 OpenAI 兼容调用。配套[同步接口 API 文档](https://help.aliyun.com/zh/model-studio/text-embedding-synchronous-api)说明,SDK 接收 Base URL,请求发送至其下的 `/embeddings`;例如北京地域当前示例为 `https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/compatible-mode/v1`。部署时应填写实际业务空间和地域对应的地址。 + +界面填写的是 API 根地址,不是带 `/embeddings` 的完整接口地址,也不是 DashScope 原生 `/api/v1/services/embeddings/...` 地址。自建网关同样需要提供 OpenAI 兼容的文本向量接口。 + +## 后端实现 + +### 客户端映射 + +在 `src/backend/bisheng/llm/domain/llm/embedding.py` 中: + +1. 将 `_node_type[LLMServerType.QWEN.value]["client"]` 改为已有的 `OpenAIEmbeddings`。 +2. 移除该文件不再使用的 `DashScopeEmbeddings` 导入。 +3. 改写 `_get_qwen_params`,复用本文件的 OpenAI 参数处理,并补齐 Qwen 默认值。 + +保留 LangChain 的 `Embeddings` 接口,底层由 OpenAI Python SDK 发起请求;无需另写直接使用 `openai.OpenAI` 的适配器。仓库已依赖这些组件,不需要为了本次切换升级依赖或删除全局 DashScope 依赖。 + +### 地址与凭证优先级 + +| 参数 | 处理规则 | +| --- | --- | +| `model` | 使用已保存的 `LLMModel.model_name`,保持现有优先级 | +| `base_url` | 服务商 `openai_api_base` → 服务商 `base_url` → Qwen 默认地址 | +| `api_key` | 服务商 `openai_api_key` → 服务商 `api_key` → 现有 OpenAI 适配的 `"empty"` 占位值 | +| `openai_proxy` | 复用现有 `usable_proxy_url` 的过滤与传递逻辑 | +| `check_embedding_ctx_length` | 保持现有 OpenAI embedding 适配的 `False`,发送原始文本 | +| `chunk_size` | 按模型名称选择默认值和上限,见下文批量规则;未知型号默认 `10` | +| `encoding_format` | 在 `model_kwargs` 中默认设为 `"float"` | + +Qwen 默认地址继续与现有 LLM 保持一致:`https://dashscope.aliyuncs.com/compatible-mode/v1`。这是项目的兼容默认值,不代表所有地域和业务空间都应使用该地址;界面显式配置始终优先。 + +组装时需要处理缺失值、空字符串和 `None`,并去掉地址末尾的 `/`。当前 embedding 通用 OpenAI helper 对部分空值直接执行 `.rstrip()`,Qwen 路径应先在配置副本中准备有效地址,再复用该 helper,避免扩大到其他服务商的行为修改。 + +服务商凭证、地址和模型名称优先于模型高级参数 `user_kwargs` 中的同名配置。其他高级参数继续透传,支持现有的字典与 JSON 字符串形式;例如 `dimensions`、超时和重试参数遵守客户端及服务端约束。Qwen 专用默认值使用补缺方式,不覆盖用户有效的显式设置;其中 `chunk_size` 还须通过下文的批量上限校验。组装过程中不得修改持有的服务商或模型配置对象,包括嵌套的 `user_kwargs` 与 `model_kwargs`。 + +不再生成或发送 `dashscope_api_key`。缺少真实 API Key 时,`"empty"` 只是沿用的客户端占位规则,不能视为阿里云认证可用。 + +### 批量与返回格式 + +旧 `DashScopeEmbeddings` 会自行拆分请求。锁定版本 `langchain-community==0.4.2` 中,v1、v2 每批 25 条,v3、v4 每批 10 条;直接换用 `OpenAIEmbeddings` 会失去该默认策略。新适配应按模型区分批大小,既避免超过接口上限,也保留各型号的批量效率。 + +依据阿里云当前[同步接口文档](https://help.aliyun.com/zh/model-studio/text-embedding-synchronous-api),建立 Qwen 文本向量模型的批量映射: + +| 模型名称 | 默认每批条数 | 已知单次请求上限 | +| --- | --- | --- | +| `text-embedding-v1` | 25 | 25 | +| `text-embedding-v2` | 25 | 25 | +| `text-embedding-v3` | 10 | 10 | +| `text-embedding-v4` | 10 | 10 | +| `qwen3.7-text-embedding` | 20 | 20 | +| `qwen3.7-text-embedding-flash` | 20 | 20 | +| 未知型号或网关自定义别名 | 10 | 未知,不能把默认值当作已确认上限 | + +处理规则如下: + +1. 以已保存的实际 `model_name` 精确匹配。只有明确列入映射的型号才使用对应上限,不通过宽泛前缀猜测新版本的能力。 +2. 未配置 `user_kwargs.chunk_size` 时,使用表中的默认批大小。 +3. 显式配置必须是正整数,布尔值、字符串、`None`、零和负数均在客户端初始化前拒绝,并明确指出参数无效。已知型号允许调小;超过型号上限时报错,提示模型名称与允许上限,不静默截断或发送超限请求。 +4. 未知型号默认每批 10 条。用户可按实际服务能力显式设置正整数,但这不是平台确认该型号支持此批大小;若网关不接受请求,应显式报告失败,不自动缩批重试。 +5. 自定义 Base URL 不改变已知型号的上限。如果网关限制更小,可显式调小批次;网关别名按照未知型号规则处理。 +6. 将最终批大小传入 `OpenAIEmbeddings.chunk_size`,由客户端按输入顺序拆批。这里限制的是每次请求的文本条数,不是文件切片长度;单条文本的 Token 上限仍由实际模型约束,不按字符数截断文本。 + +批量验收至少覆盖以下边界,均检查返回数量及输入顺序: + +| 模型 | 输入条数 | 未覆盖默认值时的请求批次 | +| --- | --- | --- | +| v1、v2 | 26 | `25 + 1` | +| v3、v4 | 21 | `10 + 10 + 1` | +| qwen3.7 标准与 flash | 21 | `20 + 1` | +| 未知型号 | 11 | `10 + 1` | + +另须检查恰好达到上限时只有一次请求,以及显式调小批次后的拆分结果。 + +显式使用 `encoding_format="float"` 可以避免依赖不同网关的 base64 返回行为;阿里云[接口文档](https://help.aliyun.com/zh/model-studio/text-embedding-synchronous-api)说明新旧网关的实际返回格式存在差异。向量仍交给 `BishengEmbedding` 执行现有归一化处理。 + +## 兼容影响 + +### 查询语义需要回归 + +旧客户端的 `embed_documents` 显式传递 `text_type="document"`,`embed_query` 显式传递 `text_type="query"`。阿里云[向量化文档](https://help.aliyun.com/zh/model-studio/embedding)将 `text_type` 列为 DashScope SDK 和 API 专有能力。 + +改用 OpenAI 兼容接口后,不应宣称查询向量与旧调用逐值一致,也不应把 `text_type` 塞入 `extra_body` 后就假定它生效。需要使用既有知识库的固定问题集比较召回结果,确认新查询向量与已有文档向量的匹配效果。 + +不自动重建知识库。模型名称、实际模型版本、输出维度和召回结果验证一致后,再判断能否继续使用存量向量;若需要重建或调整模型,另行确定迁移方案。仅维度相同不足以证明向量空间兼容。 + +### 高级参数与模型类型 + +`dimensions` 使用 OpenAI 兼容接口的复数名称,不把 DashScope 的 `dimension` 静默等同处理。已有高级参数若使用原生协议字段,需要核对后调整,不能承诺任意旧参数都可直接迁移。 + +本次仅覆盖文本稠密向量。阿里云[向量化文档](https://help.aliyun.com/zh/model-studio/embedding)中,多模态向量属于另一条接口能力,不纳入此次客户端切换的支持声明。 + +## 验收要求 + +以下为实施后的检查项,尚未执行产品验收。 + +| 编号 | 场景 | 预期 | +| --- | --- | --- | +| AC-QE-01 | Qwen embedding 创建客户端 | 实际使用 `OpenAIEmbeddings`,不走 `DashScopeEmbeddings` | +| AC-QE-02 | 界面填写自定义 Base URL 并保存 | 查询和文档向量请求均发送至该地址下的 `/embeddings` | +| AC-QE-03 | 两个地址字段同时存在 | `openai_api_base` 优先;末尾斜杠不导致路径重复 | +| AC-QE-04 | 地址缺失、空字符串或 `None` | 使用项目 Qwen 默认地址,不因 `.rstrip()` 抛错 | +| AC-QE-05 | 凭证与高级参数冲突 | 服务商凭证、地址和保存的模型名称生效,不生成 `dashscope_api_key` | +| AC-QE-06 | 高级参数分别使用字典和 JSON 字符串 | 有效参数保留,输入配置及嵌套对象不被修改 | +| AC-QE-07 | 各型号按默认批次执行边界输入 | v1、v2 为 25;v3、v4 为 10;qwen3.7 标准与 flash 为 20;拆批结果符合上表,返回数量及顺序正确 | +| AC-QE-08 | 检查实际 HTTP 请求体 | `input` 为原始文本,默认 `encoding_format` 为 `float`,无聊天专用参数 | +| AC-QE-09 | 同步接口及模型探测所走的异步接口 | 返回合法向量;既有归一化与失败状态处理保持有效 | +| AC-QE-10 | 自定义地址不可达、凭证无效或模型无效 | 显式报告失败,不改走默认地址或其他客户端 | +| AC-QE-11 | 其他 embedding 服务商及 Qwen LLM | 客户端映射与已有参数行为不变 | +| AC-QE-12 | 存量知识库与新增文件 | 完成上传向量化、召回对比和回答引用检查,记录模型与向量维度 | +| AC-QE-13 | 已知型号显式配置批大小 | 正整数且不超过上限时按配置拆批;超过上限或类型、取值无效时,在发起请求前报错 | +| AC-QE-14 | 未知型号或自定义别名 | 未配置时每批 10 条;有效显式正整数按配置执行,不猜测型号上限,不因失败自动缩批重试 | +| AC-QE-15 | 自定义 Base URL 配合已知型号 | 继续执行型号批量上限;允许按网关要求显式调小,同步与异步调用拆批规则一致 | + +新增后端测试位于 `src/backend/test/llm/test_qwen_embedding_provider.py`,覆盖参数组装、客户端选择与基于 mock HTTP transport 的请求行为。离线测试成功只能证明客户端行为,真实网关地址、阿里云模型权限和检索效果还需联调。 + +联调时在界面保存自定义地址,通过网关访问记录核对 `/embeddings` 请求;再执行单条查询、超过一批的文档向量化及知识库检索。模型验证接口 `POST /api/v1/llm/verify` 即使探测失败也可能返回 HTTP 200,必须检查业务返回中的 `status`、`remark` 和实际调用结果。 + +## 实施文件与代码索引 + +| 文件 | 用途 | +| --- | --- | +| [embedding.py](../../../src/backend/bisheng/llm/domain/llm/embedding.py) | 核心修改点;客户端映射、Qwen 参数、统一向量封装 | +| [llm.py](../../../src/backend/bisheng/llm/domain/llm/llm.py) | 参考 `_get_openai_params` 与 `_get_qwen_params` 的地址和凭证处理 | +| [chat_qwen.py](../../../src/backend/bisheng/core/ai/llm/chat_qwen.py) | Qwen LLM 的 OpenAI 兼容客户端继承关系 | +| [modelProviderFields.ts](../../../src/frontend/platform/src/pages/ModelPage/manage/modelProviderFields.ts) | Qwen 界面字段及默认地址 | +| [ModelConfig.tsx](../../../src/frontend/platform/src/pages/ModelPage/manage/ModelConfig.tsx) | 实际界面保存入口 | +| [finetune.ts](../../../src/frontend/platform/src/controllers/API/finetune.ts) | 界面实际使用的服务商新增和更新请求 | +| [llm 服务](../../../src/backend/bisheng/llm/domain/services/llm.py) | 配置保存、空白清理和模型探测 | +| [base.py](../../../src/backend/bisheng/llm/domain/llm/base.py) | 服务商与模型配置读取 | +| [uv.lock](../../../src/backend/uv.lock) | 已有依赖:`langchain-openai==1.2.2`、`openai==2.41.0`、`langchain-community==0.4.2` | + +## 验证结果 + +2026-10-21 完成以下离线验证: + +- 新增 Qwen embedding 测试 140 项,覆盖已知型号、未知别名、参数冲突与配置对象不变性,以及真实 LangChain 和 OpenAI SDK 通过 mock HTTP transport 发出的请求。 +- 每个型号分别验证恰好一批、超过一批一条、超过两批一条,并覆盖同步封装、异步封装和底层 SDK 异步接口。请求地址、认证、原始文本、浮点格式、输出数量和顺序均验证通过。 +- 认证错误、无效请求、模型不存在、限流、服务端错误和连接失败均向上报告;未发生地址回退或自动缩批。 +- 与 Qwen LLM、其他聊天客户端、推理内容兼容、模型探测和共享模型读取测试合并执行,结果为 **192 passed,1 skipped**。跳过项为需要真实 `QWEN_API_KEY` 的 Qwen LLM 在线调用。 +- Ruff、格式检查、架构守卫及差异空白检查通过。 + +复现测试选择,在 `src/backend` 下执行: + +```bash +python -m pytest \ + test/llm/test_qwen_embedding_provider.py \ + test/llm/test_qwen_provider.py \ + test/llm/test_provider_chatmodels.py \ + test/llm/test_reasoning_content_compat.py \ + test/llm/test_model_status_verify.py \ + test/llm/test_llm_share_fallback.py -q --disable-warnings +``` + +本地验证复用了主检出的 `.venv`,通过 `PYTHONPATH` 指向本工作目录源码,并以 `config` 环境变量指定已有配置文件,没有复制或修改运行配置。HTTP 请求被 mock transport 接管,测试未调用付费模型接口。 + +相关的 `test_qwen_provider.py` 原先通过 AST 提取函数,遗漏了 `usable_proxy_url` 依赖;干净实施基线复现相同的 3 项失败。该测试现已改为直接导入实际参数函数,3 项均恢复通过,Qwen LLM 业务代码未修改。 + +AC-QE-02 的请求地址传递部分已自动验证,界面保存与真实网关访问记录仍需联调;AC-QE-12 的存量知识库、上传和检索效果尚未验收。尚未部署。 diff --git a/docs/README.md b/docs/README.md index 0eafbf9c90..dd1dd82ebd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,10 +9,13 @@ | [`constitution.md`](constitution.md) | 架构铁律 C1–C7(双 DB / 多租户 / 权限 / 分层 / 错误码 / 安全)——不可违反 | | [`SDD-Guide.md`](SDD-Guide.md) | 开发流程总纲:流程分级、★ 暂停点、偏差处理、测试分层、harness 现状 | | `../AGENTS.md` | 全局 agent 规则入口(各子项目规则按目录自动加载) | +| [`security-response.md`](security-response.md) | 漏洞报告怎么接、怎么定级、怎么修、怎么发公告;对外承诺见根目录 `SECURITY.md` | ## 架构(子系统深度) -[`architecture/`](architecture/) — 13 篇:总览 / 后端模块 / 工作流引擎 / RAG / Linsight / 双前端 / 数据模型 / 部署 / 开发指南 / 权限 ReBAC / 商业网关 / 多租户 / 游标分页 + 数据库表结构。子系统架构的唯一正文。 +[`architecture/`](architecture/) — 14 篇:总览 / 后端模块 / 工作流引擎 / RAG / Linsight / 双前端 / 数据模型 / 部署 / 开发指南 / 权限 ReBAC / 商业网关 / 多租户 / 游标分页 / 应用工场部署 + 数据库表结构。子系统架构的唯一正文。 + +装机相关的两篇:平台本体见 [`architecture/08-deployment.md`](architecture/08-deployment.md);**应用工场**(托管应用运行时层,v3.0 新增、默认不装)见 [`architecture/14-app-factory-deployment.md`](architecture/14-app-factory-deployment.md)——含 `app_runtime` / `open_platform` 配置项详解、两种部署形态、⚠️ 升级顺序与排障对照表。 ## 功能文档(SDD 产出) @@ -22,8 +25,9 @@ | 目录 | 内容 | |------|------| -| [`api/`](api/) | 接口文档(filelib 纯检索、知识空间/知识库接口) | +| [`api/`](api/) | 接口文档(filelib 纯检索、知识空间/知识库接口、[MCP 服务](../src/backend/docs/api/open-mcp.md)、[模型协议面](api/model-gateway.md)) | | [`PRD/`](PRD/) | 现行迭代的产品 PRD 与技术方案(按 `{版本} {主题} PRD/` 组织;3.0 起按版本分子目录,如 [`3.0-beta2/`](PRD/3.0-beta2/)、[`3.0-release/`](PRD/3.0-release/)) | +| [`customer-guides/`](customer-guides/) | **对外口径**使用说明(如[多租户产品使用说明](customer-guides/多租户产品使用说明.md))——面向客户/实施,不含实现细节;PRD 与产品方案放 [`PRD/`](PRD/) | | [`observability/`](observability/) | BS_METRIC 指标日志契约(监控团队解析依据) | | [`私有化部署/`](私有化部署/) | 部署文档 | | [`blog/`](blog/) | 设计科普(如"企业权限体系的前世今生") | diff --git a/docs/SDD-Guide.md b/docs/SDD-Guide.md index 0cf75b0a54..366f1f0c9c 100644 --- a/docs/SDD-Guide.md +++ b/docs/SDD-Guide.md @@ -24,7 +24,7 @@ Not every change runs the full pipeline. Match track to scope: | Track | When | Steps | |---|---|---| -| **Hotfix / trivial** | bug fix, copy/text change, dep bump, ≲ 1 file of logic, **no contract change** | branch → fix → arch-guard + smoke pass → `/code-review` → merge. No spec/design/tasks. | +| **Hotfix / trivial** | bug fix, copy/text change, dep bump, ≲ 1 file of logic, **no contract change** | branch → fix → arch-guard + smoke pass → `/sdd-code-review` → merge. No spec/design/tasks. | | **Small feature** | single module, no cross-feature contract, low uncertainty | lightweight `spec.md` (AC list) → implement → `/e2e-test` → review. Decisions/gotchas: record in the PR, or a short `design.md` if non-trivial. | | **Full SDD** | new capability, cross-module, new/changed contract, or touches a constitution clause | full pipeline (§2) | @@ -46,7 +46,7 @@ Not every change runs the full pipeline. Match track to scope: 6. implement → /task-review → check off — harness auto-catches violations & runs smoke (§6); deviations handled per §4 7. /e2e-test (mandatory; frontend = Playwright 🚧) -8. /code-review --base
(+ CI auto-review 🚧) +8. /sdd-code-review --base
(+ CI auto-review 🚧) 9. merge ``` @@ -141,3 +141,12 @@ Biggest drift risk: **hotfixes that skip SDD** — code changes, nobody updates - CI must bring up the full middleware stack (+ DM8) before the CI/Stop-hook items can land. > Each 🚧 item should land with a minimal eval (does it actually reduce human intervention?) before being treated as ✅ — see §0 lesson. + +## 9. Writing & branching rules + +Moved out of root `AGENTS.md §6`; root keeps a one-line summary of the release-line rule. + +- **Renumbering a section means running a cross-reference sweep** — collect every `§X.Y` reference and every heading, and diff both ways. Prose problems are caught by reading the document once; broken references are not. +- **Write AC ids out in full** (`AC-P4、AC-P5`, never `AC-P4 / P5`): a contracted form is not greppable and breaks the traceability chain. Anything meant to change later belongs in an AC, not a comment — comments get lost, ACs get checked at delivery. +- **Whether a feature actually shipped cannot be read off the `features/` directory**, nor off its entry in the release contract: complete spec and design documents with zero lines of implementation have happened. Grep the implementation side for real wiring (registration, call sites, i18n keys) before claiming delivery. +- **The release-line branch is the single source of truth.** Anything not specific to a derived branch lands on the current release line first, and derived branches merge down from it. Doing it the other way round — first on the derived branch, then cherry-picked back — leaves the same change on two lines under different hashes, so history carries it twice and "did this reach the release line?" can only be answered by comparing content. Across version lines, cherry-pick only, and only from a commit that is already on a release line. When back-porting, `git cherry` is not a reliable duplicate check: a fix that was picked into a hotfix branch and then merged forward is an *ancestor* of the merge base, so the comparison range is empty and it looks absent. Check per file with `git diff -- ` plus `git log -- `, and record any deliberate divergence from the source branch — otherwise the next person "corrects" it back. diff --git a/docs/STATUS.md b/docs/STATUS.md index eb9d4f6444..6b94bc5b80 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,5 +1,462 @@ +## 2026-11-03 — Desktop 弹窗显式模态语义 + +- 真实 test9 客户端连接已更新的 13001,上传技能弹窗仍只覆盖右侧。页面通知区域使用 `aria-live`,Radix 保留根节点可访问状态;弹窗缺少 `aria-modal`,Desktop 监听因此漏报模态状态。 +- Dialog / OriginalDialog 将 Root 的 `modal` 设置通过 Context 传递给弹窗内容,AlertDialog 明确声明 `aria-modal=true`。既有 Desktop 协议据此触发整窗展示。 +- 旧代码新增回归 5 项失败;修复后相关组件 20 项通过。使用 test9 包内原版 preload 的 macOS 原生验证 36 组通过,涵盖宽度、缩放、主题、三类弹窗、嵌套确认、关闭及侧栏像素覆盖。全量前端 lint/typecheck、Client 构建、i18n 通过。 +- 基线 `e5941f38e40a32590d2c7a9b618cd6f370de12e2`,分支 `codex/dialog-modal-semantics-20261103`。修复合入、Client 部署及 macOS/Windows 真实账号复测待执行,见[验证记录](verification/dialog-modal-semantics-20261103.md)。 + +## 2026-11-03 — 发现广场知识空间详情布局 + +- PR #2591 同步目标分支 `e7319073fbd69ac21b5e720ca760724e8f346ef6`,合并双方在本文件新增的状态记录。两份布局组件及原生验证脚本与原 PR 提交逐字节一致。同步后 3 文件 / 15 项组件回归、前端全量 lint/typecheck、Client 生产构建、i18n 及差异检查通过;Client 部署、Windows 安装版和真实账号验收待执行。 +- 统一返回图标、标题和文件列表的水平基准,使用单层 16px 区域间距;加入状态按钮置于标题右侧,长标题及统计数量按空间换行。 +- 旧版真实渲染复现 40px 水平错位和 40px 顶部空隙。相关 15 项组件回归、全量 lint/typecheck、Client 构建和 i18n 已通过,macOS Electron 实际宿主 54 组布局/导航与浏览器模式 3 档布局/关闭验证通过。 +- 基线 `1e495e15a46bcb749a86642e292ff2c35897d847`。Client 部署、Windows 安装版与真实账号验收待执行,见[验证记录](verification/knowledge-preview-layout-20261103.md)。 + +## 2026-11-03 — 旧会话知识空间权限状态 + +- 修复旧会话恢复空间选择后缺少权限核验的问题。失效空间退出有效标签与计数,输入区显示原因及重新选择引导,发送和重新生成遵循核验结果。 +- 原实现 3 项缺陷回归失败;修复后 6 文件 / 76 项通过。全量前端 lint、类型检查、Client 构建与 i18n 校验通过。 +- 基线 `feat/bisheng-work-120-main@d4ada988a44e82016a3dd427fc6cb6e14ff5e92a`;Client 部署、原账号和 Windows 复测待执行。 +- 交付记录:`docs/verification/chat-space-access-20261103.md`;关联 TC-120-KSP-038。 + +## 2026-11-03 — 应用侧栏移除旧模块导航 + +- 按用户标注,应用侧栏直接使用仅含 Logo 与关闭按钮的共享头部,移除首页、订阅、知识空间、应用四个入口及对应占位行。应用卡片、新建对话、会话历史与返回逻辑继续使用现有实现。 +- 原因是应用侧栏复用的 `MobileSidebarHeaderTabs` 同时包含头部和全局导航,宽度进入 `touch-mobile` 档位时展示四个重复入口。现将头部独立为 `MobileSidebarHeader`,频道及知识空间继续按原组合使用导航。移除零引用的 `SideNavModuleTabs.tsx`。 +- 同步按仓库规则将所编辑文件的两处中文兜底文案迁入中英日资源,并通过 lint:prune 收缩对应存量豁免。 +- 基线 `feat/bisheng-work-120-main@1e495e15a46bcb749a86642e292ff2c35897d847`;分支 `codex/app-sidebar-remove-module-nav`。 +- 前端全量 lint、typecheck、Client 生产构建、i18n、变更文件架构守卫及差异检查通过;既有导航、返回来源与分享回归 3 文件 / 42 项通过。日志位于外层工作区 `outputs/app-sidebar-remove-module-nav-20261103/`。 +- Client 部署及 Windows 安装版原截图场景验收待执行。部署后核对窄窗口和缩放下四个入口已移除,关闭按钮、应用卡片、新建及历史操作可用。 + +## 2026-11-02 — 只读分享页空状态 + +- 空分享页显示“该分享暂无对话记录”,按共享消息判断空状态;普通应用会话继续提供有效的新建入口。空状态根据页面新建能力显示操作,中英日文案同步。 +- 原版新增分享场景 8 项失败;修复后 5 文件 / 61 项回归通过,覆盖助手/工作流、个人历史、开场白、普通会话新建跳转与已有分享历史只读展示。 +- 前端全量 ESLint、类型检查、Client 生产构建、i18n、变更文件架构守卫与差异检查通过。基线 `ac625c821396dcc628a8c71845e7a808f7b95b9b`。 +- Client 部署与测试环境 macOS Chrome 真实账号验收待执行,见[验证记录](verification/shared-chat-empty-20261102.md)。 + +## 2026-11-02 — 应用会话分享以用户输入为准 + +- 顶栏分享资格统一按当前路由会话的应用分享权限与 `question` 用户消息判断;欢迎语、自动输出、待填表单保持新会话状态。宽屏、紧凑布局共用结果,侧栏分享应用沿用现有规则。 +- 原代码新增回归 9 项失败;修复后新增 13 项及相关回归共 4 文件 / 46 项通过。前端全量 ESLint、类型检查、Client 生产构建、i18n、变更文件架构守卫和差异检查通过。 +- 基线 `23a4b97e5d86e38d8a2f53a5de3fe17ffb8671c4`。Client 部署、浏览器及 macOS/Windows 安装版真实账号验收待执行,见[验证记录](verification/app-chat-share-20261102.md)。 + +## 2026-11-02 — 发现广场知识空间预览返回 + +- 测试原图对应 `/square` 的知识空间预览抽屉。原组件内状态保持列表 URL,Desktop 相同 URL 导航提前返回;抽屉左侧中部收起入口及固定宽度影响可发现性与窄窗口可见性。 +- 预览现由 `previewSpace` URL 参数驱动,顶部提供本地化“返回”,关闭时保留来源分类;再次进入发现广场由路由直接关闭预览。宽度随视口收缩,上限 1000px。 +- 原版新增回归 5 项失败;修复后 7 文件 / 38 项通过。前端全量 lint/typecheck、Client 生产构建、i18n、架构和差异检查通过;macOS 实际 Electron 宿主、preload、预览组件的宽度/缩放/主题 12 组通过。 +- 基线 `725ee5bf38d4ff2d067763fb305817a730b238a4`。Client 部署及 Windows 安装版真实账号验收待执行,见[验证记录](verification/knowledge-square-navigation-20261102.md)。 + +## 2026-11-02 — 积分分配列表精简交付复核 + +- 当前基线:`upstream/feat/bisheng-work-120-main@954f8ba9f7b979d3f2193caa173dbe28356700dd`;分支 `codex/credit-allocation-labels-pr-20261102`。原实现 `ecfccecea` 已完整恢复,业务组件与三语资源的内容与原提交一致;最新服务分支的其他修复完整保留。 +- 用户、部门、角色列表统一为对象、每人每月积分额度、操作三列;额度表头提供信息图标与说明,编辑区域保留“启用此积分分配”,新增分配默认启用,停用状态显示在额度旁。积分校验继续采用每月 100 万上限和最多一位小数。 +- 本轮确定性验证:CreditRulesView、dshPolicyView、dshCreditAmount 共 3 组 / 43 项通过;Platform 与 Client 全量 ESLint、620 / 1515 个严格类型文件检查通过;Platform 生产构建、i18n、共享语言资源同步、变更文件架构守卫和差异检查通过。 +- 交付范围为现有实现的 PR 补交;按用户要求保持 `192.168.106.109:13001` 当前部署。真实账号及浏览器视觉验收继续待执行。 + +## 2026-10-29 — 积分分配列表精简(本地验证通过) + +- 产品确认:移除列表中的配置状态列,积分表头统一为“每人每月积分额度”,添加积分说明入口。 +- 实现:用户、部门、角色三个页签采用三列表格;额度表头使用现有 Tooltip 与信息图标,支持悬停和键盘聚焦。编辑区域保留启停操作,停用配置在额度旁显示灰色标签;新增配置默认启用。中英日文案同步,顶部说明明确每人额度、多条已启用配置取最高值和 Chat / Work 共用。 +- 基线:目标分支 `upstream/feat/bisheng-work-120-main@3950752273b75a120a0f8e25ecd5d903c94f8106`;工作目录 `.worktrees/bisheng-credit-allocation-labels`,分支 `codex/credit-allocation-labels`。已同步目标分支,保留最新每月 100 万积分上限与 1 位小数规则。本轮范围为 BISHENG 后台前端。 +- 验证:现有 CreditRulesView 9 项回归、platform 全量 ESLint、619 个严格类型文件、三语 i18n 检查、变更文件架构检查、差异空白检查通过。使用本地已有依赖,Tooltip 源码与工作区版本一致。workspace 级 pnpm 命令受运行器依赖自动安装及外部依赖目录保护限制,转为直接执行 platform 对应检查。 +- 验收边界:代码与上述本地自动化已完成;部署、真实账号和浏览器视觉验收待执行。 + +## 2026-11-02 — 知识广场撤权后的加入状态与重新申请 + +- TC-120-DSQ-014:旧 ACTIVE 成员记录被当作已加入,重新申请也被该记录短路。广场/详情现以有效 visible 判定加入状态,撤权后按既有审核流程重新申请。 +- 抽屉明确收到权限拒绝时同步广场与加入按钮;申请写入使用普通成员角色。人数继续按原 ACTIVE 成员记录统计,统计口径和历史记录清理独立处理。 +- 原版后端 7 项、抽屉 1 项新增用例失败;修复后后端 18 项及前端相关 8 组 / 43 项通过。完整服务、真实权限/审批链路、部署和 Windows 实机回归待执行。 +- 基线 `780723543`,同步目标 `037d6037a` 后组合回归 9 组 / 52 项通过;[验证记录](verification/knowledge-square-revoked-access-20261102.md)。 +- PR #2582 同步目标 `26eb25bcc`:合并双方新增状态记录,知识广场修复文件与原 PR 一致,应用缩放文件与目标分支一致。组合回归 10 组 / 75 项、Client ESLint、1515 个严格文件、语言资源与差异检查通过。 + +## 2026-11-02 — 应用会话缩放后内容区宽度分配 + +- PR #2581 同步目标分支 `037d6037a1cf6a906caf51f3657b7c688ac42bd7`,完整保留本修复与整窗蒙层双方状态记录;业务文件自动合并,并逐文件核对与各自提交内容一致。组合回归 5 组 / 52 项、前端全量 lint/typecheck、Client 生产构建通过;部署与 Windows 原机验收继续单列。 +- 额外根因:桌面嵌入视口变窄时仍为应用侧栏保留 240px,对话区域的标题下拉覆盖返回按钮,进一步缩放后按钮进入裁剪范围。16px/24px 字体对照的失败组合一致。 +- 767px 以下桌面内容区复用已有历史 region,菜单与标题访问应用历史,对话获得完整宽度;放宽后恢复侧栏偏好,跨过宽标题断点时关闭历史。字体、按钮视觉和来源返回解析沿用原实现。 +- 旧实现新增回归 4 项失败;修正后 3 组 / 38 项通过。macOS Electron 192 组设备缩放、页面缩放、字体与宽度组合全部通过,旧版对应 28 组返回命中/重叠失败;连续窗口变宽/恢复及原生鼠标、Esc、返回操作通过。 +- 全量前端 lint/typecheck、Client 生产构建、i18n、架构与差异检查通过。基线 `780723543c408013d0011a2d8053fdc6bb712420`,见 [验证记录](verification/app-chat-scaled-layout-20261102.md)。部署、Windows 系统 DPI/真实字体与安装版真实账号验收待执行。 + +## 2026-11-02 — 桌面弹窗整窗统一蒙层 + +- 桌面内的 Dialog、OriginalDialog、AlertDialog 统一采用黑色 40% 蒙层,配合现有 Desktop 窗口级模态协议,让左侧会话栏与右侧内容一起变暗,弹窗保持清晰。实现直接调整 Client 共用展示函数,采用现有 Modal 规范的蒙层取值。 +- 展示回归在原逻辑上 4 项失败;修复后弹窗及知识空间 14 项、技能解析与持久化 17 项通过。前端全量 ESLint、Platform/Client/file-viewers 类型检查、Client 生产构建、语言资源与差异检查通过。 +- 实际 Client 组件及生产 CSS 与 Desktop V1.2.0 原生视图、preload/IPC 联合验证 36 组通过,覆盖三类弹窗、深浅主题、两档宽度、三档缩放、嵌套确认、关闭恢复及侧栏坐标蒙层像素。验证使用隔离服务夹具,Windows 安装交互、真实账号技能上传及 Client 部署后验收待执行。 +- 基线 `3120e714f`;详细记录与复现入口见 [验证记录](verification/window-dialog-overlay-20261102.md)。 + +## 2026-11-02 — 应用已删除会话旧链接读取 + +- 根因:会话软删除后,历史与详情读取仍接受该会话行。服务层现将删除状态纳入读取判断,已删除历史返回空消息,详情按缺失会话处理。 +- 原版对照 4 项失败、4 项通过;修复后 8 项定向回归通过,覆盖同账号删除前后旧链接、分享入口、详情及正常分页。 +- Ruff、架构与差异检查通过;测试使用源码路由/服务方法和 DAO 夹具,完整服务依赖、真实数据库、部署与真实账号复测待验证。 +- 远端基线 `611fb0f1a`;[验证记录](verification/app-deleted-session-history-20261102.md)。 + +## 2026-11-02 — 应用历史 PDF 附件下载地址修复 + +- 根因:应用附件将存储 URL 主机替换成 Client `BASE_URL`,生成 `/workspace/bisheng/...`,请求进入前端路由并把 HTML 保存为 PDF。`ChatFile` 现在复用现有对象存储地址解析器,按桶代理根路径下载,保留签名、编码对象名与原文件名。 +- 基线 `3120e714fa4ed492884cade11a128341d03eb105`,分支 `codex/app-attachment-download`;复用共享解析逻辑,卡片展示、权限与下载生命周期沿用原实现。 +- 旧实现初始新增回归 13 项失败;修正后应用、历史附件及预览 URL 共 11 组 / 88 项通过。本地 HTTP 夹具验证旧代码收到 `text/html`,修正后请求正确对象路径并收到 PDF 响应。 +- 前端 workspace 全量 lint/typecheck、Client 生产构建、架构守卫和差异检查通过。详见 [验证记录](verification/app-attachment-download-20261102.md)。网页部署、真实存储与账号、Windows/macOS 安装版下载和 PDF 打开验收待执行。 + +## 2026-11-02 — 应用流式回答手动停止标识 + +- 手动停止将本轮输出转为停止状态,截断回答下方显示“已停止”;中断思考与工具卡片同步收尾,下一轮恢复正常输出。 +- 助手历史消费已有 `break_answer` 标记;工作流即时停止保留手动重跑,正常完成沿用原有展示。 +- 原版对照新增用例 5 项失败,修复后应用目录 8 组 / 48 项通过;本地静态检查及生产构建状态见[验证记录](verification/app-stream-stop-20261102.md)。基线 `9a985f421`。 +- 网页部署、真实账号与 Windows 安装版实机验收待执行。 + +## 2026-10-31 — 知识空间返回保留列表标签 + +- 列表标签改为路由状态,进入空间、文件夹及返回列表时保留选择;知识页面使用实时路由上下文。 +- 原版导航对照 6 项失败,页面缓存重入对照 1 项失败;修复后新增 11 项、相关 6 组共 37 项通过。 +- 前端全量 ESLint/类型检查、Client 生产构建及语言资源检查通过。源码基线 `4cc8f565f`,部署和真实账号 Windows 安装版验收待执行。 +- [验证记录](verification/knowledge-return-tab-20261031.md)。 +- PR #2574 同步目标 `2ebff30cb`,整合知识空间设置字号与应用返回按钮的交付记录。组合回归 8 组 / 59 项、前端全量 ESLint 与类型检查、语言资源及差异检查通过;知识空间实现与原修复一致。 + +## 2026-10-31 — 桌面知识空间设置字号 + +- 修正桌面内嵌网页误用移动字阶:启动时同步宿主身份,字号及输入控件防缩放规则按该身份选择适用范围。 +- 窄桌面内容区正文恢复原有 14px/22px,浏览器/H5 沿用窄屏 16px/24px。 +- 35 项相关自动回归、48 组 macOS Electron 原版/修正版排版对照与保存动作检查通过;前端静态检查和生产构建通过。 +- 基线 `6d5651ad4`;详见 `docs/verification/knowledge-settings-type-20261031.md`。镜像部署、Windows 安装版系统 DPI 与真实账号验收待执行。 + +## 2026-10-31 — 应用会话恢复返回按钮 + +- 紧凑标题栏根据应用返回动作显示按钮,标题加载后继续显示;菜单保持桌面应用侧栏与移动系统菜单的各自入口。 +- 返回保留发现广场应用分类、应用中心、移动探索三种来源路径。 +- 33 项相关回归、24 组 macOS Electron 隔离渲染/点击检查、前端静态检查及 Client 构建通过。验证数据采用夹具;部署与真实账号安装端验收待执行。 +- 基线 `4cc8f565f`;详细记录见 `docs/verification/app-chat-back-20261031.md`。 + +## 2026-10-30 — 订阅首页比例调整 + +- 页面左右留白自适应为 16–32px,栏目最小宽度由 260px 调整至 320px,并按实际容器宽度收缩;960px 内容窗口呈现两列。 +- 保留原有横线、列间竖线、编号、字体、字号与频道内部间距。业务源码修改集中于订阅首页两处布局参数。 +- 本地比例预览已确认;macOS Electron 调整版 36 组边界和交互检查通过,字体及分隔线样式与原版一致。 +- 提交基线 `feat/bisheng-work-120-main@250fdce4c`。Client 部署、真实账号及 Windows 安装版 DPI 验收待执行。[验证记录与同尺寸截图](status/subscription-layout-20261030.md)。 + +## 2026-10-30 — 应用会话下拉列表与侧栏状态修复 + +- PR #2567 冲突处理:同步目标 `05d329021`,合并状态文档双方新增记录。组合回归 7 组 / 38 项、Client 全量 ESLint、1504 个严格类型文件、生产构建、语言资源及差异检查通过。 + +- 应用会话历史列表声明为内容区 region,桌面嵌入窗口保持内容区宽度。旧实现把列表声明为窗口级 modal,窗口扩宽跨过 1023px 断点后卸载列表、恢复宽度、再次挂载,形成循环闪烁。 +- 紧凑桌面布局的顶部菜单交由应用侧栏处理;打开历史列表时菜单仍可关闭列表并切换侧栏。移动布局沿用系统菜单。右上角加号创建会话时保留侧栏展开状态;列表内创建与切换会话沿用应用会话逻辑。 +- 26 项相关回归、前端全量 ESLint/typecheck、Client 生产构建、语言资源、架构和差异检查通过。旧实现运行新增用例为 9 失败 / 3 通过,窗口宽度复现 1200/900 交替;修复后新增 12 项通过。 +- macOS Electron 实际下拉组件与原生 EnterpriseChatView/preload 隔离验证覆盖 1100/1200/1280 三种窗口宽度,每组 20 次采样保持内容区边界。数据与导航服务使用夹具。Windows 安装版、真实账号和部署后验收待执行。 +- 基线与交付证据:[应用会话顶部交互](verification/app-chat-header-20261030.md)。 + +## 2026-10-30 — 桌面内弹窗保留页面原色 + +- 用户确认桌面内弹窗采用透明背景;技能上传、旧版弹窗和二次确认三套 Client 共用壳通过既有 `isDesktopEmbedded()` 识别桌面,统一设置透明背景。Radix 继续负责焦点、滚动隔离和关闭交互,普通浏览器沿用现有展示。 +- 基线 `feat/bisheng-work-120-main@1954f60c4acba5de5cca5e9385b76fc6b21effc0`。新增真实组件回归覆盖两种主题、SPA 跳转、浏览器范围、Tab 焦点与 Esc 关闭;恢复原蒙版行为时 3 项透明背景断言失败,修复后通过。 +- 弹窗相关扩展回归 7 组中 6 组通过,共 47 项通过、1 项失败;失败为现有 `PermissionDraftPickerDialog.test.tsx` 的 `value="department"` 源码字符串断言,对应组件与测试均保持基线内容。 +- Client ESLint、1503 个严格类型文件、生产构建通过;Platform、UI 和 file-viewers ESLint、file-viewers 类型检查通过。Platform 严格类型检查在现有 DSH 内容安全页面报告 6 项缺失 `@bisheng/ui` / `@tanstack/react-query` 的依赖解析错误,全仓类型门禁待验证。 +- Desktop 配套变更将知识空间颜色交回网页组件,保留窗口范围与关闭恢复。测试环境部署、真实账号技能上传、Windows 安装客户端验收待执行。 + +## 2026-10-30 — 知识空间问答跳转新会话崩溃修复 + +- 知识空间提问跳转新聊天时,模型状态的同步订阅刷新会在准备完成状态提交前重新执行交接逻辑,反复设置模型并触发 `Maximum update depth exceeded`。真实 ChatView、输入框和模型状态组合夹具已复现。 +- 交接现在先同步登记当前问题的准备阶段,再更新知识空间和模型;准备完成后通过 React 状态推进,待会话、模型、知识空间就绪时发送。 +- 新增沿用原模型和切换模型的同步订阅回归,覆盖 StrictMode 与连续知识空间提问。两项在旧实现失败;修复后相关 5 组 62 项回归、客户端与平台 ESLint/严格类型检查、Client 生产构建、语言资源及架构检查通过。 +- 初始复现基线 `8d93affff`,提交前已同步目标 `feat/bisheng-work-120-main@78815c66e`。本次完成源码与本地自动化验证;测试环境部署、真实账号及 macOS/Windows 安装客户端验收待执行。测试环境构建号与反馈截图一致,截图该次异常的精确指纹待现场日志对照。 + +- PR #2564 同步目标 `ffb2ed186`,整合双方状态记录。同步后 7 组 76 项相关回归、Client 全量 ESLint、1501 个严格类型文件、语言资源及差异检查通过;知识问答实现与已验证修复一致。 + +## 2026-10-30 — 内置 Skill 名称与说明随界面语言切换 + +- 根因:技能区域使用独立列表接口,内置来源在接口中合并为平台来源;PR #2524 的工具词典覆盖了工具区域。 +- 实现:接口提供来源派生的 `builtin_key`,共享技能 Hook 在渲染时解析 Word/PPT/Excel 的中英日名称与说明,列表、详情与搜索共用展示结果;企业自定义与个人技能使用配置文案。 +- 验证:36 项前端和 39 项后端回归通过;4 项新增页面回归在基线代码上失败。全量前端 lint/typecheck、Client 生产构建、语言资源、Ruff、架构及差异检查通过。 +- 基线、审查与验收清单:[交付证据](verification/builtin-skill-localization-20261030.md)。后端与 Client 网页部署、真实账号及 macOS/Windows 安装端验收待执行。 +- PR #2563 同步目标分支 `78815c66e`,保留双方状态记录;同步后 18 项技能前端回归、三语言一致性与文案完整性检查通过。 + +## 2026-10-30 — 授权对象搜索空态文案 + +- 部门和用户组分别使用对应空态文案,已授权列表按当前对象类型选择提示,中英日同步。基线 `feat/bisheng-work-120-main@06ffc6fba`。 +- 相关 14 项回归、前端完整 lint/typecheck、Client 构建和 i18n 检查通过;权限模块 57 项通过、2 项已确认的基线失败。部署与 Windows/真实账号验收待执行。见[专项记录](status/permission-search-empty-20261030.md)。 +- PR #2561 同步目标分支 `8d93affff`,合并双方状态记录;同步后 14 项相关回归、三语言一致性与差异检查通过。 + +## 2026-10-29 — 订阅新增网站抓取失败原因与人工处理说明 + +- 无效网址在提交前显示格式说明;抓取失败原因直接显示在展开的队列中,保留服务错误分类,并支持移除后重新提交。 +- 人工处理入口展示已配置的联系说明;空配置、请求失败与历史占位邮箱统一引导联系管理员,覆盖中英日三语。 +- 35 项相关回归、前端静态检查、共享包检查、三语检查及 Client 生产构建通过;针对性回归在原代码捕获 9 项失败。 +- 源码修复完成;测试环境部署、真实网址与账号、浏览器和安装包验收待执行。[修复与验证记录](status/subscription-url-errors-20261029.md)。 + +## 2026-10-29 — 每月积分业务上限与输入精度 + +- 确认规则:每人每月积分为 0~1,000,000,支持整数或最多 1 位小数,默认值 500。前端输入、保存按钮与后端写入接口按同一范围校验,中英日提示同步更新。 +- 基线:实时获取 `dataelement/bisheng:feat/bisheng-work-120-main@62ad8ecccda06dbfdf8d77b845de1a7fe0af3319`;工作目录 `.worktrees/bisheng-monthly-credit-business-limit`,分支 `codex/monthly-credit-business-limit`。本轮范围为平台管理端与服务端。 +- 根因与实现:原上限源自底层 64 位整数存储极限。`CreditRuleInput` 承担管理端业务校验,`CreditRuleValue` 承担历史额度和迁移的精确值校验。实际消耗、余额、倍率和底层账本保留原有精度;已有额度原值保留,编辑保存使用新规则。 +- 自动化:前端输入与保存流程 35 项通过;后端 schema、SQLite 仓储/历史迁移、隔离真实 Redis 账本联合回归 51 项通过。覆盖 0、0.1、999999.9、1000000、1000000.0、超限与多位小数、取消编辑、迁移精确值与幂等、倍率计费、重复结算与 Redis 数据恢复。 +- 静态检查:前端完整 lint/typecheck、中英日 i18n 检查、变更 Python Ruff、架构检查和 diff 检查通过。前端复用本地已有依赖,后端 Python 3.11.15;Redis 8.8.0 临时实例启用 AOF/noeviction,测试后关闭并清理。 +- 验收状态:代码与上述自动化通过;测试环境部署、真实账号页面验收及 MySQL/DM8 迁移验收待执行。需求与验收条件见 [积分输入规则](../features/local/monthly-credit-input/spec.md)。 + +## 2026-10-28 — 工作流下线后的旧卡片与运行状态 + +- 旧连接每次提交实时校验工作流发布状态;下线后返回明确提示并停止等待,禁用历史交互卡片,拦截重复提交。 +- 正常完成的会话正确结束运行状态;草稿预览和自动重跑沿用既有约定。 +- 后端与前端定向自动回归、静态检查及 Client 生产构建已验证;环境部署、真实账号与原生桌面复测待执行。[修复记录](status/workflow-offline-20261028.md)。 + +## 2026-10-28 — 知识空间退出后旧资料页面访问修复 + +- 资料预览现在校验当前文件可见权限与空间归属,退出后保存的页面链接按实时授权处理;独立文件授权由统一权限运行时判断。 +- 新增回归在原实现复现 7 项失败;修复后 42 项相关回归通过,同步最新目标后 54 项通过;Ruff、格式、架构守卫和差异检查通过。测试使用外部服务夹具。 +- 源码修复完成;测试环境部署和真实账号验收待执行。已签发对象存储直链按原有效期生效。[修复与复测记录](status/knowledge-exit-access-20261028.md)。 + +## 2026-10-28 — 订阅频道导航与当前地址同步 + +- 根因:整个频道路由树共用 KeepAlive 缓存,跨模块返回时保留旧路由上下文与详情状态。频道首页、详情、创建、设置及分享页改为消费实时路由,聊天继续保留缓存与草稿。 +- 31 项相关回归通过;新增真实 Subscription 回归在原代码失败、修复后通过。客户端与平台 ESLint、严格类型检查、共享包检查、i18n 检查及客户端生产构建通过。 +- 13001 Chrome 已复现原问题;本地 Chrome 真实组件配合测试数据验证首页恢复、详情切换、历史导航及聊天草稿保留。13001 部署与 macOS 客户端修复后验收待执行。[修复与复测记录](status/channel-route-navigation-20261028.md)。 + +## 2026-10-28 — 知识空间资料预览与下载地址修复 + +- 根因:对象存储地址被加上 `/workspace` 页面前缀,13001 返回 HTML;修复后使用文件根路径,统一 PDF、PPTX 转换预览及列表/预览页下载。 +- 69 项相关回归、客户端 ESLint、1484 个严格类型文件与生产构建通过;macOS Chrome 本地组件成功渲染实际 file/4 的 2 页 PDF。PPTX 使用转换结果夹具验证。原工具栏测试的 ESM 加载问题在参考分支同样复现。 +- 当前为源码交付;13001 部署、Windows Chrome 与同事原始 PPTX 待验收。[修复与复测记录](status/knowledge-preview-resource-routing-20261028.md)。 +- TXT 专项复核:macOS 从搜索结果打开实际 file/6,确认同一地址前缀问题;#2535 已合并的共享修复覆盖正文加载。新增 5 项正文回归、相关 33 项检查及客户端静态检查通过,实际 TXT 本地组件显示正常。13001 部署后完整验收与 Safari 验收待执行。[TXT 证据](status/knowledge-txt-preview-20261028.md)。 + +## 2026-10-28 — 普通 Chat 知识空间按会话隔离 + +- 修复账号级知识选择导致 C1、C2 相互覆盖:选择按账号与会话保存,输入框、发送和重新生成共用当前会话范围,首次发送的正式 ID 迁移保留选择。 +- 初始定向回归 31 项通过;同步最新目标 `894d4d7de` 后 6 个文件 / 48 项回归通过,客户端 ESLint、类型检查和生产构建通过。扩展输入框回归的 4 项工具/技能显示失败在原始基线上同样复现。 +- 本次交付为源码修复;部署、Windows 安装包、真实账号检索与引用待验收。记录与清单:[会话知识空间隔离](status/chat-knowledge-session-isolation-20261028.md)。 + +## 2026-10-27 — Work 1.2.0 五条 PR 同步验证 + +- 本批功能分别在 PR #2519、#2520、#2521、#2523、#2524 交付;各分支同步目标 `f834aa2b1` 与公共质量修复 `bb949d634`(PR #2529)。本节记录分支验证,功能进入目标分支的状态以对应 PR 合并结果为准。 +- 合并后五个分支的平台和客户端全量 ESLint 通过;公共修复的全量类型、双前端构建与 64 项定向回归通过。GitHub 针对各 PR 最新提交执行 Frontend Quality 检查。 +- 定向回归:#2519 图片接收与输入框 44 项;#2520 Logo 上传 6 项;#2521 输入框图标 13 项;#2523 权限角色和授权列表 22 项;#2524 工具目录前端 18 项及后端完整目录组 23 项,均通过。 +- 部门来源断言校验现有完整行中的来源与子部门文案;目录测试同时保留翻译标识、稳定排序、权限及账户开关回归。后端使用 Python 3.11 完整依赖环境和项目测试夹具,外部服务由夹具隔离。 +- 五个分支采用相同的本批状态记录,保留每项功能原始证据,支持逐条合并时复用相同文档内容。真实账号与部署验收继续单列。 + +## 2026-10-27 — Work 工具列表、详情与搜索复用共享翻译 + +- 基线:`dataelement/bisheng:feat/bisheng-work-120-main@d2be34458dbfa19c40324f6916dfa75202743cf9`;分支 `codex/work120-tool-localization`,目录 `.worktrees/bisheng-work120-tool-localization`。 +- 实现:现有工具中英日词典迁入 `packages/locales/src/tool`,生成平台与客户端资源;目录为内置工具提供稳定 `tool_key`。列表、详情、无障碍名称和搜索使用统一显示数据,搜索同时覆盖原始文案和工具标识。自定义 API/MCP/技能保留配置文案,旧接口与未知工具使用原始信息。知识库检索复用已有分类翻译,当前 39 个内置工具均有词典条目。 +- 自动验证:前端 23 项回归、隔离目录服务 5 项测试、1,471 个严格类型文件、变更文件 ESLint、Ruff、词典生成与 i18n 检查、生产构建通过。后端测试加载实际目录服务,提供方、可用性与技能依赖使用夹具;jsdom 将当前环境中缺失原生组件的可选 canvas 视为未安装。 +- 全量 client lint 的 23 项存量错误位于 chatApi.ts、ComposerEditor.test.tsx、CitationReferencesDrawer.tsx,已核对三文件及 lint 配置与目标分支一致;完整后端环境和真实账号网页/原生桌面验收待执行。发布需同步后端目录接口与 client 网页。 +- 本轮交付源码 PR;证据位于主工作区 `outputs/bisheng-work120-tool-localization-20261027/`。 + +## 2026-10-27 — Knowledge-space permission role localization + +- Baseline: `dataelement/bisheng:feat/bisheng-work-120-main@d2be34458dbfa19c40324f6916dfa75202743cf9`, verified remotely before implementation and PR delivery. Branch: `codex/work-knowledge-permission-locale`. +- Root cause: permission components displayed the API model name directly, while the standard role names arrive in Chinese. The protected creator row therefore displayed Chinese in English mode. +- Implementation: resolve `owner`, `manager`, `editor` and `viewer` through the existing Chinese/English/Japanese locale entries at render time. Draft rows, role menus, member lists, authorization selectors/badges and role-name search share the resolver. Custom role names and submitted role keys retain their original values. +- Verification: 47 permission tests passed; 3 existing failures were reproduced with baseline production sources (department badge text assertion, source-comment Chinese scan, retired tab-selector assertion). The focused role/authorization suite passed 21 cases. Strict client type checking (1,469 files), changed-file ESLint, i18n parity, architecture guard, diff checks and production client build passed. Full client ESLint reports the same 23 errors against both baseline and modified sources in unrelated chat files. +- Test fixtures use the existing English role dictionary; the list fixture now provides its required confirmation hook. Added tests cover all four standard roles in three languages, preserved custom names, protected creator rows, live English/Chinese switching, absent role options and localized role-name search. +- Delivery: web-client change for the desktop embedded knowledge-space page. Target deployment and native visual acceptance remain pending. Local evidence: `outputs/knowledge-permission-locale-20261027/`. + +## 2026-10-27 — 聊天输入框资源图标统一 + +- 需求:输入框内技能、工具、知识空间统一显示菜单同款图标与资源名称;每项保留整体选中、删除和撤销。 +- 实现:ComposerReference 明确携带 skill/tool/space 类型,复用 Outlined.Skill、Outlined.Hammer、Outlined.FolderClose;标签与文字共享输入区,长名称省略并保留完整 title,颜色沿用品牌主题。资源名称以文本节点写入,ID、工具 children 与发送关联继续独立保存。 +- 基线:已核对远端 `dataelement/bisheng:feat/bisheng-work-120-main@d4d60f77e41d58753a76969c6f40465a36054fa6`;工作目录 `.worktrees/bisheng-composer-resource-icons`,分支 `codex/composer-resource-icons`。 +- 验证:13 项 ComposerEditor 回归通过,1,467 个严格类型文件检查、修改文件 lint、生产构建、三语言一致性、架构检查与 diff 检查通过。全量 client lint 首次检查保留 23 项存量问题,本次已将所修改测试内的 2 处中文输入数据改为引用现有语言资源,其余 21 项位于未改动文件。 +- 隔离 macOS Electron 窗口使用真实 ComposerEditor,确认三类 SVG、完整名称、contenteditable=false、整体删除及撤销恢复;浅深色截图已检查。该证据为实际组件验证,真实账号页面及发布环境验收待执行。 +- 配套 Work 实现位于 `codex/v120-composer-resource-icons`,支持 skill/tool/folder 图标与草稿持久化。 + +## 2026-10-27 — 工作台双 Logo 上传修复与 3006 验收 + +- 基线:`dataelement/bisheng:feat/bisheng-work-120-main@d4d60f77e41d58753a76969c6f40465a36054fa6`;独立分支 `codex/workbench-logo-upload`。 +- 根因修复:Logo 上传直接使用现有上传 API 和统一错误处理,成功后更新地址;接口拒绝或图片解码失败时保留当前 Logo 并显示原因。文件选择后重置输入值,同文件可再次选择;上传中禁用重复选择和删除。保留 PNG/JPG/JPEG 合同及现有 Work/Chat 共享助手图标配置。 +- 自动验证:6 项组件回归通过,覆盖两处上传入口、错误保留、同文件重试、压缩失败、请求中状态和绝对地址;同组用例在旧代码上有 3 项失败。612 个严格类型文件、变更文件 lint、架构检查、生产构建和 diff 检查通过。全量 platform lint 报告 95 项问题,涉及的 8 个文件均与基线一致。 +- 部署:基于 3006 当前 `tools-skills-auth-20261026-root-membership` 源码合入两个上传组件,发布为 `workbench-logo-upload-20261027-100846`。669 个构建文件及 7,436 个保留文件逐一核验;仅重建 3006 前端,其余 3006 容器保持原实例。原发布目录保留用于回退。 +- 真实验收:修复前两个 PNG 上传请求已可成功,原始服务端失败未复现;本次定位并修复前端失败处理与重试缺陷。修复后在管理员「构建 → 工作台」分别上传原有两张 Logo,看到“配置保存成功”,刷新后两处新图片地址保留且图片正常显示。验证保持原有图案。 +- 证据:主工作区 `outputs/workbench-logo-upload-20261027/` 的 `deployment.json`、`browser-verification.json`、`saved.png`、`verified.png` 与构建日志。真实验证覆盖后台上传、保存及刷新;本次桌面 Work/Chat 共享逻辑保持源码原状,桌面展示联动验收单列。 + +## 2026-10-26 — 聊天输入框图片粘贴修复 + +- 基线:远端 `dataelement/bisheng:feat/bisheng-work-120-main@d4d60f77e41d58753a76969c6f40465a36054fa6`;独立修复分支 `codex/chat-image-paste`。 +- 根因:上传格式列表只依据 `enable_etl4lm` 选择图片格式,后台已配置的模型视觉能力在附件接收阶段没有参与判断。当前输入框将图片接收与处理能力分开:PNG/JPG/JPEG/WebP/GIF/BMP 可经粘贴、拖拽、选择文件进入附件区,任务模式切回普通聊天时保留图片。 +- 普通聊天发送时使用所选模型的 `visual` 与服务端图片解析能力判断。视觉模型直接走已有识图链路;具备解析能力时沿用文件解析链路;条件不足时提示用户切换模型或配置解析服务,保留图片和文字。全局附件权限、格式范围、大小限制及普通聊天配文字发送规则继续生效。 +- 自动验证:44 项定向回归通过,包括真实 ComposerEditor / 粘贴 Hook / InputFiles 组件组合(上传接口使用测试响应);相同新增行为用例在旧代码下有 4 项失败。1,467 个严格类型文件检查、修改文件 lint、三语言一致性、架构检查、生产构建及 diff 检查通过。 +- 全量 client lint 仍有基线 23 项问题,位于未改动的 `api/chatApi.ts`、`ComposerEditor.test.tsx` 和 `CitationReferencesDrawer.tsx`;本轮保持修复范围。 +- 状态:代码及定向自动验证完成。运行环境部署、真实模型识图及 Windows/macOS 安装包端到端验收待执行;本轮仅修改平台聊天前端,桌面原生程序保持现状。 + +## 2026-10-27 — 恢复 Work 1.2.0 前端质量检查 + +- 基线:`dataelement/bisheng:feat/bisheng-work-120-main@f834aa2b1`;工作目录 `.worktrees/bisheng-work120-frontend-quality`。 +- 实现:引用请求与展示组件以明确的错误结构处理失效、权限和传输失败;复用请求层已有的 `silent` 类型;敏感词表单保存始终读取当前值,加载提示补齐中英日翻译;主体搜索保持 Unicode 空白边界规则;测试使用国际化样例文案。 +- 全量前端 ESLint、严格类型检查、语言资源一致性、平台与客户端生产构建通过。64 项定向回归覆盖引用接口失败与成功、Unicode 空白、主体搜索、输入法和授权提示。 +- ESLint 历史豁免由工具自动清理,仅减少计数;质量规则保持原合同。依赖按锁文件安装;客户端 jsdom 测试将本机缺失原生二进制的可选 canvas 作为未安装处理,相关用例覆盖 DOM 与请求逻辑。 +- 交付范围为代码及自动化验证;真实账号与发布环境验收待执行。 + +## 2026-10-27 — 工具开关保持列表位置 + +- 工具目录按类型和固定资源标识排序,账户启停只更新可用状态;关闭、重新开启、刷新和重新进入目录时保持同一顺序。 +- 根因:目录原来将 `ready` 放在排序首位,账户关闭工具后进入列表末尾。修复由目录服务直接使用稳定字段排序,客户端继续消费服务端顺序。 +- 基线:`dataelement/bisheng:feat/bisheng-work-120-main@0d6080902`,工作分支 `codex/work120-stable-tool-order`。 +- 验证:新增 API / 内置 / MCP 三类工具启停与重新加载回归,原实现 3 项失败,修复后 3 项通过;使用实际目录服务及隔离的数据依赖运行相关目录、权限入口、只读试用和可用状态共 10 项通过;架构守卫、F821 静态检查与差异检查通过。 +- 本轮交付服务端代码。运行环境更新与用户界面验收待执行;证据位于主工作区 `outputs/tool-order-20261027/`。 + +## 2026-10-27 — 任务模式恢复 local 执行器(定向自动化通过) + +- 基线:`dataelement/bisheng:feat/bisheng-work-120-main@e59b249a3a2fc5672f14aa012f04d867a28f7049`;工作目录 `.worktrees/bisheng-work120-main-integration`。本轮修复后端任务执行链路。 +- 实现:按 `local`、`container`、`e2b` 分别绑定当前任务工作目录和持久化 workspace 路径;缺省类型归一为 `local`,E2B 文件预上传由 E2B 分支处理。自动装配、资源使用权限、账户工具开关与调用审计沿用现有入口。 +- 连续性:local 生成和修改的文件回写当前轮对象存储;追问通过已有工作区继承及文件恢复逻辑装入新目录,并按文件指纹识别本轮修改后的交付物。 +- 验证:324 项测试及 9 个子测试通过,覆盖三类执行器初始化、代码执行超时、工作区边界、文件回传、追问恢复、交付物筛选、工具权限与账户可用性、开放 API 任务模式提交/配置/查询/端点。新增 12 项 local 回归在基线代码上全部失败,在修复后全部通过。两轮连续性测试启动真实 Python 子进程生成 CSV,清除首轮本地目录后通过对象存储继承并修改原文件,验证最终文件内容;对象存储和权限使用测试替身。 +- 测试环境:macOS、Python 3.11.15,独立环境按 `uv.lock` 执行 `uv sync --frozen --extra test`,额外安装 `pypandoc-binary==1.15` 供既有模块导入;测试配置使用空 YAML。原有凭据边界测试在基线和修复版均因真实 Settings 赋值校验失败,本轮改为注入独立配置替身后通过。 +- 静态检查:变更 Python 文件格式、测试与错误码 Ruff、架构检查、差异空白检查和中英日错误文案生成一致性通过。`workbench_impl.py` 全文件 Ruff 既有问题由 58 项降为 57 项,新增诊断为 0。 +- 验收边界:本轮完成代码与定向自动化验证。真实部署、普通账号与开放 API 全链路验收待执行。 +- 执行边界:`local` 使用后端 worker 的本机子进程;部署方负责该运行环境的隔离。当前代码继续使用现有的工具授权、账户可用性检查、路径检查和调用审计。 + +## 2026-10-27 — Desktop 公开地址统一与 Nginx 转发 + +- 源码基线:实时核对 `upstream/feat/bisheng-work-120-main@d4d60f77e41d58753a76969c6f40465a36054fa6`;独立工作区 `.worktrees/bisheng-work-public-origin`,分支 `codex/work-public-origin`。现场安装包为 BISHENG `1.2.0-test.2`、Harness `0.2.1-rc.2`;本轮修改范围为服务端、部署模板和回归测试。 +- 部署配置统一使用 `dsh.platform_public_url`。配置加载时按 HTTP URL 规则规范化域名大小写、默认端口、国际化域名与 IPv6;登录授权和 MCP Host/Origin 校验共用规范地址。两个 Nginx 模板以 `$http_host` 保留公开端口;外部代理使用同一通用转发规则。 +- 新增地址回归在修改前为 14 失败 / 9 通过,修复后 23 项通过。真实 Nginx 1.28.0 加载生产模板中的 map/请求头转发指令,覆盖两个入口的单层 HTTP、双层 HTTPS,MCP 初始化、工具列表以及错误 Host、错误 Origin、伪造转发头拒绝,4 项通过;原有 Work 能力回归 15 项通过,合计 42 项。完整生产路由、数据库登录、真实模型聊天和实际工具业务调用属于独立部署验收。 +- 13001 现场已取得认证后 MCP 421;公开登录预请求返回的地址确认为 `http://192.168.106.109:13001`。现有源码已兼容带端口和去端口的 Host,故原 `$host` 丢端口只能证明转发缺陷。具体拒绝层、实际转发 Host 和运行的工具服务版本仍待服务器核验。109 SSH 密钥认证失败,本次通过独立修复分支交付,远端环境和安装包保持原版本。 +- 提交前再次验证 42 项回归全部通过,Ruff、差异空白检查及变更文件架构检查通过。发布模板以注释形式提供 Desktop 配置示例,兼容旧镜像配置解析。测试同事按 [13001 配置核对与验收清单](../features/v3.0.0/110-dsh-local-access/public-origin-13001-validation.md) 记录逐跳 Host、各 API 实例版本、部署与回退信息,完成真实登录、Chat 和工具调用验收。 + +## 2026-10-23 — 工具统一开关与 Work 手动选择 + +- 工具与技能页统一为“全部 / 技能 / 工具”,API、MCP 与平台工具合并展示;工具行及详情使用统一工具名称,列表每行提供账户启停开关。全部页复用技能中心的详情与开关。 +- 账户启停覆盖所有工具类型,与原有租户和资源使用权限共同生效。Chat、云端任务和桌面 Work 在发现、选择及实际调用时执行检查;已发布工作流和助手保持各自配置。桌面知识空间专用检索入口也复用此工具权限与启停边界。 +- 本地 43 项账户隔离、目录、调用拒绝和知识检索边界测试通过;客户端 12 项分类及选择菜单测试、1467 个严格类型文件检查、变更 lint 与生产构建通过。知识空间端点文件有 4 项既有 lint 问题,保留原状。 +- 3006 发布 `tool-selection-20261023-1805`,沿用 `67db6edb6` 主线和任务消息广播修复;发布前活动任务为 0,API、后台工作进程及前端健康检查通过,原发布目录保留用于回退。在实际后端依赖下,ToolExecutor 的 3 项同步/异步执行与初始化拒绝回归通过。 +- 真实普通账号网页验收:全部/技能/工具三分类、统一工具图标、每行开关和类型标记移除均确认;关闭当前时间工具、刷新后状态保持、Chat 菜单对应入口禁用,随后恢复原启用值。证据 `outputs/tool-selection-20261023/tools.png`(主工作区)。本轮覆盖配置、菜单与执行边界;外部提供方业务端到端验收保持单列。 +- 桌面手动选择使用原生 /tools 与加号菜单,将账户绑定的工具引用加入输入框,发送前重查可用性;具体安装与验收状态见桌面仓库 STATUS。 + +## 2026-10-23 — 网页与桌面任务消息同步修复 + +- 用户现场会话 `8ed7e55860c34b258910fbf4aa67288d` / 版本 `a7e5e1779f0d48039976c3fcce970efd`,数据库确认 15:59:55 completed、2 个最终文件、Redis 待发送消息 0;桌面截图停在 3/5。 +- 根因:每条 WebSocket 直接 BLPOP 同一队列,多个页面和断开的旧连接竞争消费事件。修改为订阅先建立、队列事件原子广播给全部在线连接,再确认移除;工作进程沿用原队列写入,前端事件合同保持。 +- 同时监听客户端断开并取消订阅;空闲连接按 15 秒间隔读取数据库终态,通过原有完成 / 失败 / 终止事件收尾。保留排队事件顺序,避免终态核对提前截断尾部进度。 +- 隔离真实 Redis 与生命周期 14 项回归通过;服务器完整依赖下 19 项通过,含真实双 WebSocket、不同 Redis 客户端、事件顺序、旧工作进程队列格式、断连资源释放、任务隔离、丢失完成通知及传输异常。新增和变更代码 Ruff、逐文件架构检查通过。 +- 功能提交 `359df6ca7df56253e36882baac3c8906d300500f` 已推送平台分支 `feat/bisheng-work-120-main`;3006 发布 `task-stream-sync-20261023-171818`,完整应用导入、三个运行源码文件哈希及 HTTP 健康检查通过。 +- 发布前活动任务为 0,仅更新 API;任务工作进程、前端、Gateway 与 3007 的容器及启动时间保持。桌面 Chat 复用服务端修复,现有桌面包可继续使用。双 WebSocket 自动验收通过,安装后原生界面的人工验收待执行。 +- 部署与验证证据:主工作区 `outputs/task-stream-sync-20261023/`。 + +## 2026-10-23 — 同事提测环境核对 + +- 用户要求汇总近期桌面 / 网页 / 服务后端功能影响范围,确认 3006 最新运行代码和桌面安装包。Work 保持自动调用平台工具。 +- 本次功能源码基线 `feat/bisheng-work-120-main@ef4e5c0cb6b51391ef7f9b79d4793436f905d907`;API、backend_worker 与 backend_linsight 三进程各核对 1,717 个源码文件,全部一致,包含 47 个近期变更文件。 +- 客户端从该基线重新生产构建并发布至 `test-handoff-20261023-150004`。客户端与管理端容器入口文件哈希分别为 `99ed764aa00d6d2103e99acc1e9ea08b3e3184eb3e9b34c5681d05eb6a5862dd`、`35bccc46680defc98fb572abc092e64dbaa8044d51453dc6409dd0c64a41c16e`,均与发布文件一致;倍率显示管理端更新保持。 +- 后端健康、技能页、模型页与桌面接入配置接口均 HTTP 200;后台三进程、Gateway 与 3007 容器 ID 及启动时间保持。此次为静态前端更新,无数据库变更。 +- 前轮功能与权限证据保持独立;普通用户真实知识库数据、多账号交叉和 Office 产物验收交由同事补测。桌面最新包验证和 Word 提测说明在主工作区 `outputs/test-handoff-20261023/`。 + +## 2026-10-23 — 简化模型积分倍率显示 + +- 移除 ModelMultiplierInput 输入框旁的乘号装饰,保留倍率输入、校验、保存、重试和状态反馈。 +- 5 项倍率保存/冲突/校验回归、变更 lint 与管理前端生产构建通过。 +- 3006 管理前端发布 multiplier-symbol-20261023-143032,仅重建前端,保留最新 chat-row-a7cdfa16e-20261023-r5 客户端与所有后台、3007 实例。浏览器连接当前不可用,未进行本轮渲染截图验收;文件哈希、HTTP 路由和服务保持检查通过。 +- 证据 outputs/multiplier-symbol-20261023。 + +## 2026-10-23 — 技能列表与 Token 数量展示细节 + +- 移除技能列表个人行的更新时间;网页工具与技能入口复用原技能中心 Newspaper 图标,桌面 capabilities 入口映射至已有 skills 图标。内置工具启停范围保持现状。 +- 桌面中文 Token 日历提示、累计和输入/输出统计统一按原始值选单位:小于 1 亿使用万,达到 1 亿使用亿,两种单位均保留一位小数。消息数继续显示整数;未知用量保留不可用状态。英文使用本地化紧凑计数与一位小数。 +- 新增 6 个日期提示和统计一致性的边界回归(0、万、小于亿、等于亿、大于亿);完整桌面 2057 项通过、3 项条件跳过,类型与 JS 语法检查通过。前端 1467 个严格类型文件、变更 lint、技能列表回归与生产构建通过。 +- 3006 客户端发布 skill-detail-20261023-131210;管理前端及后端服务保持原实例,保留 new-model-visibility-20261023 修复,并同步同事远端 d985514cd。网页已确认技能行时间移除及菜单图标。macOS arm64 安装包、开发签名与包内 Office 验证通过;包内账号与导航插件和源码逐字节一致。新进程真实提示为 115.2万,累计为 940.2万,截图已保存。 +- 证据 outputs/skill-detail-20261023。 + +## 2026-10-23 — 能力空状态、导航位置与用量提示 + +- 用户确认空状态仅显示“目前还没有配置相关能力”,去掉查看全部按钮。网页和桌面共用的工具与技能入口移至发现广场前。 +- 桌面账号与用量日历改用 Harness Tooltip 原语,鼠标悬停和键盘聚焦显示日期、当前指标与完整数值;使用 body portal 保持在设置滚动容器外可见。中英文的 Token/消息切换和身份切换回归通过。 +- 内置工具类别补齐知识库检索的中英日翻译,并在构建工具和流程侧栏使用真实元数据作为缺失翻译的默认值。 +- 前端 client 1467、platform 550 个严格类型文件及变更 lint 通过,两套生产构建通过。桌面类型检查、35 项设置视图回归、全量 2051 项测试通过(3 项条件跳过);macOS arm64 打包、开发签名和包内 Office 检查通过。 +- 3006 前端发布 capability-polish-20261023-121446。组合构建保留同事已部署的积分作用域编辑 5 个前端文件;构建时的源码快照记录在 outputs/capability-polish-20261023/preserved-platform-wip.json,已随同事提交 17bc89dad 合入共享分支。后端、Gateway 和 3007 容器保持原实例。网页验收确认空状态、菜单位置和知识库工具名称;新桌面进程确认菜单顺序和日期 Token 气泡实际显示 2026-10-22 / 1,152,049。消息数同日数据为 81,切换与提示合同自动回归通过。 +- 交付证据 outputs/capability-polish-20261023,安装包与截图在该目录。Windows 验收未执行。 + +## 2026-10-23 — 统一技能管理与桌面导航 + +- 用户授权修复桌面更多入口、合并技能中心、个人和企业技能逐项启停、上传按钮置于技能分类栏右侧,并合入同事最新提交重新打包。 +- 根因:平台上报 capabilities 后,桌面固定导航白名单拒绝整个目录。扩展明确路由白名单及对应主题范围,保持原导航权限和路径校验。 +- 技能分类复用原设计列表与个人/企业分组;个人技能由浏览器预览迁入按账号隔离的服务器存储,复用对象存储与现有 Skill 行,上传保留二进制资源;旧本地包通过稳定 ID 幂等迁移。技能关闭按账号保存,输入、Chat provisioning、模型/工具运行中间件、Work 目录与版本包读取共同约束;管理员总停用继续优先。 +- 已通过 37 项后端目录/开关/所有权测试,25 项列表/菜单回归、11 项输入框回归及 22 项包验证/持久化回归;前端 1467 个严格类型文件、变更 lint 和生产构建通过。合入同事平台 8862f452a、桌面 514959d 后,桌面全量 2051 项通过、3 项条件跳过,类型检查和 macOS arm64 打包通过;开发签名校验及包内 Office 生成/转换检查通过。 +- 3006 已部署真实技能存储与启停逻辑,保留当前 credit-usage-tabs-20261023-r2 管理页更新。真实依赖下验证了企业关闭后的目录/版本包拒绝、个人 ZIP 二进制完整性与重试幂等、跨账号隔离,以及关闭后模型/工具运行中间件和 Chat provisioning 拒绝;临时验收技能已清理。 +- 最新包使用隔离验证配置连接 3006:更多菜单显示工具与技能、知识空间、订阅、应用、发现广场;工具与技能入口正常打开,技能分类显示个人/企业组和各项开关,右侧上传按钮可打开上传对话框。实际模型生成文档的用户验收另行进行。 +- 证据 outputs/skills-unification-20261023;本地旧 Skill service/provisioning 大套件因隔离环境缺 pandas 尚未加载,使用服务器完整依赖继续验证运行合同。 + +## 2026-10-22 — 3007 能力整合至 3006 / 共享分支 + +- 用户授权直接合入平台 feat/bisheng-work-120-main 与桌面 V1.2.0,并更新 3006。平台基线 a15826aeb,桌面基线 56dcf4b,Harness 0.2.0-rc.2。 +- 按功能迁移已验收的工具与技能中心精简列表、后台技能归位、输入框二级菜单/行内引用/字体/光标修复,以及账号联网搜索设置和服务端执行约束。保留最新模型、积分、知识空间、任务模式和桌面主题改动。 +- 桌面企业工具通过宿主 MCP 注册与审批;技能复用主线已有 provider 和 ZIP 包,每次加载重新校验依赖并补充原生工具名称。保持单一技能目录,支持完整二进制附件。 +- 自动验证:32 项后端、24 项网页输入/目录回归,1465 个严格类型文件、定向 lint、前后台构建通过;桌面 npm ci 补丁重放、类型检查、构建通过,完整回归 241 文件 / 2047 项通过、2 项条件跳过(含真实 Python MCP 跨运行时检查)。 +- 3006 已发布并完成真实账号检查:时间工具执行、原生技能 ZIP/依赖、账号关闭后输入目录/MCP 目录/直接调用拦截、真实数据库绑定与缓存同步/异步执行拒绝均通过,搜索网络调用数 0,原设置已恢复。3007 与 3006 网关容器保持;现有数据库、配置与模型/积分源码保留。 +- 网页浏览器确认任务模式置顶、技能/工具二级菜单、无前置图标、连续工具行内引用、精简列表与搜索详情。3006 展示自身已有的 39 项工具和 6 项技能;3007 演示资源配置保留在独立环境。 +- 合入范围与最终共享分支 SHA 记录在主工作区 outputs/capabilities-merge-20261022。 + +## 2026-10-22 — 内置知识库检索工具 + +- 工具目录登记 `bisheng_knowledge_search`(知识库检索),通用 ToolExecutor 绑定真实执行用户和当前租户,沿用工具使用授权和调用审计。模型输入限定 query / space_ids。 +- 桌面检索 API 与平台工具共用检索及结果格式,复用文档知识库、知识空间、文件和主版本过滤。支持同步/异步调用;租户上下文变化或执行用户失效时返回错误。 +- 自动化:59 项定向测试、8 个子测试通过,覆盖身份参数伪造、普通用户绑定、权限拒绝、租户变化、文件可见性、来源及登记幂等。新增代码 Ruff 和架构检查通过;原知识空间 endpoint 的 4 项 Ruff 既有问题与基线一致。 +- 3006 发布到 `knowledge-tool-20261022-221700`,后端健康,发布前运行中任务为 0。存量登记完成(tool_id=40,category_id=18),统一权限投影为 CURRENT;现有前端和其他工具权限保持原值。 +- 3006 真实冒烟:通过 ToolExecutor 初始化并执行新工具,管理员检索空间 1 返回 8 个片段,来源字段完整;现有环境未找到可访问空间的普通用户,普通用户真实数据验收待补。登记脚本连接释放调整后,本地 9 项登记回归再次通过。 +- 本次完成平台通用工具入口。云端 Work / 统一 MCP 的发现及调用端接线由对应集成工作继续完成;普通用户真实数据检索验收与管理员运行验证分开记录。 +- 运维登记与权限检查步骤见 [脚本说明](../src/backend/scripts/README.md)。 + +## 2026-10-22 — 企业技能运行时接口 + +- 新增登录态 `/api/v1/linsight/skill/runtime/catalog` 和 `/runtime/{id}/bundle?version=`,供桌面 Work 使用与 Chat 相同的租户已启用技能。管理接口继续使用管理员权限。 +- 目录返回稳定记录 ID 和内容版本;完整 ZIP 保留脚本、模板及二进制资产。下载前后检查租户、启用状态与版本;响应附带 SHA-256 和 no-store。 +- 自动化:运行时/API/服务测试 53 项通过;运行时及真实租户隔离测试 12 项通过;Ruff 通过。覆盖普通用户、未登录、跨租户、禁用撤销、版本失效及完整包下载。 +- 3006 已部署到 `enterprise-skills-20261022-184518`,复用当前发布的前端及环境配置,后端健康检查通过。新版桌面实际读取目录并显示平台 Office 技能。 +- 权限语义沿用 Chat 的租户已启用技能规则;Work 本地工具执行继续受桌面权限策略约束。真实 Office 文件生成验收由用户执行。 + +## 2026-10-22 — 用户可选模型统一配置 + +- 交付方式按用户确认调整为直接合入 `feat/bisheng-work-120-main`;合并前同步远端 `022aee8cb`,保留 Chat 字体渲染更新,状态文档保留双方记录。 + +- 布局微调:可见范围标题与按钮统一左对齐,右侧三列使用固定宽度,支持图片和积分倍率整体右移,显示名称区域获得更多空间。定向 ESLint 与管理端构建通过,已部署 3006 `user-models-6dea932e8-20261022`,实际页面截图确认标题与按钮左侧对齐、支持图片及积分倍率右移。 + +- 基线 `feat/bisheng-work-120-main@b3fbbd166`。统一工作台与桌面端配置,显示实际模型、服务提供方、显示名称、视觉、积分倍率及“配置可见范围”按钮;其他系统配置保持现有流程。 +- 管理员专用读取入口保留完整原始元数据,保存名称保留隐藏模型、描述和既有默认值。Chat 与桌面目录使用服务提供方及显示名称,调用和授权继续使用原模型 ID。 +- 自动化:后台隔离容器测试 58 项通过、2 项环境用例跳过;管理端相关 28 项通过,严格类型检查 550 文件、定向 ESLint、生产构建及架构检查通过。全局 i18n 检查的两组错误与基线一致(既有日文权限文案及 28001–28009 错误码)。本次新增文案提供中英日三语。 +- 3006 已发布 `user-models-2a309aeff-20261022`,后台健康。实际管理员页面验证合并表格、名称保存与重新加载、用户/部门/角色可见范围弹窗、其他模型配置。实际 BISHENG Dev 刷新后,模型选择器显示“阿里云百炼 / Qwen 名称联动验收”;随后恢复原名称 `qwen`,后台和桌面均确认恢复。权限和倍率沿用原值。设计及验收范围见 [配置说明](product/user-selectable-models-20261022.md)。 + +## 2026-10-22 — Chat 与 Work 字体渲染一致 + +- Chat 界面采用 Harness Work 的系统字体平滑设置(antialiased / grayscale),欢迎标题沿用共同的 26px / 500 / 32px 字阶。字体族和字号相同时,字形边缘也使用一致的渲染方式。 +- 对照已安装 Harness 0.2.0-rc.2 的基础样式完成源码核对;客户端生产构建通过,源码 `022aee8cb` 已推送共同分支。3006 发布到 `chat-font-022aee8cb-20261022`,保留管理端 `user-models-6dea932e8` 版本,前端 HTTP 200。现有桌面包重新加载网页即可生效,视觉效果由用户手动验收。 + +## 2026-10-22 — 文件预览与深色主题 + +- 文件画布与应用界面采用独立配色边界。Word、Excel、PDF、HTML 和引用文档保留文件内容配色;预览背景、缩放工具栏、加载与错误状态、文本/Markdown、音视频及产物侧栏使用主题色。 +- 文档画布通过 `data-document-canvas` 与桌面宿主约定;宿主为弹出菜单和对话框提供完整深色文字、背景和控件色。 +- 客户端生产构建通过。桌面类型检查及相关回归 28 项通过;按用户要求由用户手动验收视觉效果。源码 `b3fbbd166` 已推送 `feat/bisheng-work-120-main`,3006 前端发布到 `dark-preview-b3fbbd166-20261022`,前端容器已启动。 + +## 2026-10-22 — Chat 输入提示及上下文弹窗交互 + +- 输入提示随编辑器真实空状态显示,聚焦空输入框保留提示;中文输入法组合输入即时隐藏提示,清空后恢复,文字与光标使用相同的内边距。 +- 加号菜单按打开方式恢复焦点:鼠标操作返回编辑器,键盘操作返回触发按钮。知识空间列表采用固定高度,加载与结果变化保持弹窗尺寸。 +- 此前输入提示回归 9 项及构建通过;本轮合入集成分支 `94f6380fc` 后,`646980179` 的 Chat 构建通过并推送 `dataelement/bisheng:feat/bisheng-work-120-main`。3006 前端发布目录为 `knowledge-picker-646980179-20261022`,前端容器已重新启动;按用户要求由用户手动验收界面。 + # DSH 企业后台交付状态 +- 2026-10-22:共同开发与后续交付统一使用 `feat/bisheng-work-120-main`;合入任务模式修复 `51baf285f`,保留消息操作样式提交 `cb4b18018`。合并无代码冲突,后端与已发布到 3006 的修复版本一致,前端与共同分支原有版本一致。 + +- 2026-10-22:统一 Chat 消息操作为复制、点赞、点踩、导出,采用 DSH Work 0.2.0-rc.2 的原始图标及 28px 按钮、15px 图标、8px 间距;桌面内嵌 Chat 与网页共用此实现,Work 保留新建分支。参考桌面分支 V1.2.0 b510182。复制回归 2 项、前端类型检查、改动文件 ESLint 与客户端构建通过;前端全量 ESLint 在平台既有文件报 95 项错误。本次源码待部署后的视觉验收。 + +- 2026-10-22:Chat 输入提示改为独立定位,空输入框聚焦仍显示,开始输入(含中文组词)立即隐藏,清空后恢复;提示文字沿用输入框内边距,与光标起点对齐,中文组词期间保持原生输入位置。ComposerEditor 9 项回归和客户端构建通过;3006 预览已验证聚焦显示、输入隐藏、清空恢复及内边距一致,API 返回 200。预览保留任务模式与积分结算更新,等待用户在桌面端验收后推送共享分支。 + +- 2026-10-22:修复主线迁移后 Desktop Chat 网页会话交换 500:恢复经过校验的 `web_session_cookie_name` 配置,默认使用 `access_token_cookie`;会话测试改用实际 `DshSettings`,覆盖默认配置和自定义入口 Cookie。27 项会话及配置回归与 Ruff 通过;独立容量工厂测试因本地缺少 docstring_parser 待复验。3006 部署复验执行中。 + +## 2026-10-22:任务模式执行链路补齐(3006 已发布,业务效果待用户验收) + +- 基线:`codex/work120-main-integration@480fc2972`,同步 3006 已发布的 Cookie 配置修复 `a01d35a3c`;候选代码在独立工作树开发。 +- 已实现:有使用权限时自动装配代码执行器;工具与分类配置统一解析;恢复容器执行器传输;文件落入任务持久化空间并通过稳定路径引用;恢复执行带入原文件、产物及中间文件,后续任务继承原文件和产物;检测交替重复的工具调用。 +- 安全边界:权限入口沿用统一工具授权与审计。执行器地址与凭据由后端配置控制,代码执行通过隔离 runner;本机直接执行配置在任务启动时报告配置错误。 +- 自动化:96 项定向测试通过,覆盖隐式授权、配置解析、文件回传、隔离传输、任务文件恢复与重复调用。 +- 真实环境:3006 隔离执行器探测已通过,后端执行与积分修复均已发布,14 个部署文件与修复清单一致;页面及健康接口返回 200。Excel、Word、PPT 和 Excel 续写任务均完成并返回产物,Excel/Word 下载成功。按用户最新要求,文件打开、内容和版式效果由用户验收。共享结算和 Work 审计边界回归 33 项通过、2 项外部 Redis 用例未运行,完整审计集受测试 ORM mock 初始化问题影响,未计为通过。详细记录见 [任务验收](TASKMODE_EXECUTION_ACCEPTANCE.md)。 + + +## 2026-10-22:Work 120 main 积分与 Chat 集成候选 + +- 基线:`upstream/feat/bisheng-work-120-main@7c701e86bcdfbafd53a99997f3146097a1497c41`;集成分支:`codex/work120-main-integration`。 +- 迁入旧 Work 120 的 Chat 交互与桌面桥接,以及 3007 的统一积分、模型倍率和可见范围;沿用新基线的原生登录与席位移除实现。桌面配套分支为 `dataelement/bisheng-work:codex/v120-unified-credits`,基于 V1.2.0。 +- 自动化:Chat 定向测试 101 项、管理端相关测试 213 项、原生认证及迁移测试 11 项、模型审计测试 24 项通过;双前端生产构建与前端工作区类型检查通过。部分测试按运行环境跳过。 +- 后台全量 DSH 回归仍有失败及环境错误,涉及旧版按模型额度断言、数据库/Redis 等依赖和测试隔离,当前状态为集成候选。真实数据库迁移、登录、Chat/Work 扣分与权限联调待验收。 +- 当前交付为源码分支。3006/3007 部署、共享目标分支合并及桌面安装包发布待后续执行。 +- 迁移范围和数据升级路径见 [集成说明](WORK120_MAIN_INTEGRATION.md)。 + + - 2026-09-22:跟进 #2433 的席位反馈验收。确认“重新分配”后,失败原因以“用户名 + 原因”的单个 toast 展示,约 4 秒自动消失;每次操作的同一失败只提示一次,新操作可再次提示。表格保持原列宽。已撤销席位的管理提示提供明确的重新分配步骤。批量容量错误配套 [Gateway #6](https://github.com/dataelement/bisheng-gateway/pull/6):新增和待恢复成员统一计入所需席位,容量不足统一返回 seat_limit_reached。 - 自动化:39 文件 / 333 项 DSH 前端回归、平台全量 lint、467 文件严格类型检查、生产构建、i18n 校验和架构守卫通过。 - 浏览器:实际 React 组件配合模拟失败回执,确认后显示 toast,随后自动消失;7 列宽度在显示前、显示中和消失后完全一致。 @@ -15,3 +472,126 @@ - 2026-09-17:`codex/dsh-enterprise-beta2` 已归并最新看板、部门额度、导航、个人年度用量和隐藏审计入口,进入提测候选。 - 实现、来源、确定性检查和真实环境验收边界见 [提测交付记录](../features/v3.0.0-beta2/062-dsh-desktop-model-access/enterprise-beta2-delivery.md)。 - 当前组合分支基于最新已核验的 beta2-pre 基线;真实数据库与登录态端到端验收由提测执行。 + + +## 2026-10-22 Chat knowledge dialog presentation +- KnowledgeSpaceDialog publishes its open/close lifecycle to the desktop presentation bridge. The dialog portal uses the full desktop window; the underlying chat layout follows the host-provided chat region dimensions. +- The knowledge overlay has an explicit presentation class so the host can apply Work's sampled mask color and alpha. Browser-only dialogs retain the shared Modal behavior. +- Client production build passed. 3006 frontend release: `chat-window-dialog-20261022-1905`, preserving the current enterprise skills, backend and admin deployment. Visual acceptance is assigned to the user. + + +## 2026-10-22 Select skills before task mode +- Synced `feat/bisheng-work-120-main@fd153d685` before the change. +- Chat exposes the enabled skill picker to users with task-mode access while composing ordinary chat. Selecting a skill retains the selection and activates task mode; further skill selection keeps task mode active. Switching back to ordinary chat clears task skills and keeps the picker available. +- Ten focused composer tests and the client production build passed. Deployed client build to 3006 release `chat-skill-auto-mode-20261022`, preserving the current backend and admin files. Desktop Chat loads this client; visual acceptance remains with the user. + +## 2026-10-23:客户端管理拆分积分设置与用量 + +- 基于共同分支 `feat/bisheng-work-120-main@448fbad77`,将原“积分与使用数据”拆成“积分设置”和“用量”两个独立页签。默认显示积分设置,已有 `?tab=usage` 链接直接进入用量;按用户、部门及时间筛选归属用量页。 +- 积分配置、分配优先级及保存接口沿用现有实现,本次调整页面组织和中英日页签文案。 +- 自动化:页面导航、设置与积分保存共 12 项测试通过;平台 550 个严格类型文件、变更文件 lint、生产构建通过。全量 lint 存在 8 个基线文件的 95 项既有错误;全量 i18n 校验与 `448fbad77` 基线输出一致(日文 12 个缺失键与 9 个后端错误码翻译缺失)。 +- 部署:3006 管理前端发布为 `credit-usage-tabs-20261023-r2`,基于并行更新后的 `skills-unified-20261023-111837` 保留其后端和 Chat 客户端。仅重建前端容器,旧发布目录保留用于回滚。管理端入口 SHA-256:`eddd88a6389b10263602affba649ede2cc26b36ff1f2dd5752df5c5c77465259`。 +- 浏览器验收:3006 当前管理员登录态下,两页签均加载真实数据;用量筛选与图表独立显示;返回积分页时筛选栏收起。实际积分配置调整由用户继续体验。 + +## 2026-10-23:按用户、部门、角色分配积分并行内编辑 + +- 积分设置采用“用户 / 部门 / 角色”分类切换,各列表仅展示对应对象及配置额度。新增分配在当前列表创建编辑行,已有记录原行编辑,支持保存、取消、启停和失败保留草稿;对象名称直接展示。 +- 统一积分计算按当前用户命中的有效用户、部门(含祖先部门)、角色配置取最高值。个人较低或零额度继续参与取最大值;停用配置退出计算。Chat 与 Work 沿用同一计算入口、原有鉴权、租户过滤、版本冲突与审计记录。 +- 自动化:14 项前端定向测试、36 项后端规则与仓储测试通过;5 项需要专用 Redis 的账本用例条件跳过。550 个严格类型文件、变更 ESLint、Ruff 和管理端生产构建通过。全量 i18n 输出保持上一基线的 12 个日文键与 9 个错误码翻译缺口。 +- 3006 发布:`credit-scope-editing-20261023`,保留 `credit-usage-tabs-20261023-r2` 内已合入的技能运行逻辑及 Chat 客户端,仅更新积分计算文件和管理端构建。发布前确认执行中会话为零;历史已完成会话中的两条遗留任务记录保持原样。旧发布目录用于回滚。 +- 线上验收:后端健康通过,实际容器中的最高额度计算验证通过;浏览器用户列表 11 条原配置完整显示,原行编辑、取消、部门/角色分类新增对象选择均通过。业务积分数值保持原值。真实新增/修改保存的操作由用户继续体验,保存合同与冲突恢复已自动验证。 +- 合并前同步共同分支 `aea7ce81e`,保留同事技能统一管理改动。管理入口构建 SHA-256:`15abecaf313fbfd2985ed1501a27ce12b7a6fc549e189168b3359b2faf686b7a`。 + +## 2026-10-23:新增对话模型默认对所有成员可见 + +- 企业客户端启用时,新增服务商携带的对话模型、已有服务商追加的对话模型,在创建事务中写入所属租户的所有成员可见配置(初始积分倍率 1)。模型及授权同时提交;授权失败时模型创建一起回滚。已有模型的显式授权、版本和积分倍率继续保留。Embedding 等模型沿用原有入口;模型上线状态、模型能力与调用额度继续由原运行链路校验。 +- 自动化:53 项默认授权、普通成员有效权限、既有指定用户授权、跨租户隔离、失败回滚及模型 DAO 回归通过;变更 Ruff、格式检查、diff 检查通过。本机轻量 Python 环境缺少完整 provider 依赖,测试启动器跳过 `llm.domain` 聚合入口,实际 DAO、异步 SQLite、策略仓储及事务照常执行;完整 provider 导入链由 3006 正常启动验证。 +- 3006 发布:`new-model-visibility-20261023`,基于现场 `capability-polish-20261023-121446`,仅覆盖三个后端文件,保留现有前端及其他会话更新;发布前执行中会话为零,旧发布保留用于回滚。服务健康检查通过,已安装源码哈希匹配。 +- 现场验证:真实数据库事务中验证默认所有成员可见、已有显式限制保留及重复初始化幂等,测试数据全部回滚。业务模型及授权保持原值。管理员从界面新建真实模型及普通用户真实调用留待产品验收。 + +## 2026-10-26 — 工具与技能授权 + +- 基线:dataelement/bisheng feat/bisheng-work-120-main@d4d60f77e41d58753a76969c6f40465a36054fa6,提交前再次核验远端 SHA 一致。 +- 实现:顶部“工具与技能”中英日同步;内置工具、企业技能复用授权弹窗与 F048 资源授权服务。用户确认保留全员可用,管理员可关闭全员并授权指定人员、部门或用户组。 +- 全员使用采用所属租户成员的普通 Grant;模型升级至 f048-v6,迁移将旧系统全员标记转换为可撤销 Grant。重跑迁移及初始化保留已撤销结果。个人技能持续校验所属账户。 +- 使用边界:工具列表和每次调用、技能目录/直接下载/装载、开放 API 配置/提交按当前使用授权过滤;个人启停在企业授权范围内生效。 +- 自动验证:123 项权限与技能回归通过;隔离提供方实现后,24 项工具业务测试及 9 个子场景通过。前端 4 项回归、611 个严格类型文件、变更 lint、生产构建、i18n 与架构检查通过。 +- 扩大回归:技能业务组基线 69 通过/20 失败,本分支 72 通过/18 失败(补齐 runtime 测试身份与 DAO fixture,增加撤销下载回归);余下为原有 selectable/provisioning fixture 与业务入口不匹配。授权主体路由组基线和本分支均 9 通过/1 失败,原因是本地 FastAPI 路由结构变化。完整提供方导入、真实 OpenFGA/数据库迁移、普通账号网页及任务端到端验收待上线环境执行。 +- 上线步骤与回退:[upgrade.md](../features/local/tools-skills-authorization/upgrade.md)。已提交 [PR #2518](https://github.com/dataelement/bisheng/pull/2518),部署与合并单独执行。 + + +### 2026-10-26 — 3006 授权部署与根租户成员兼容 + +- 已发布 f048-v6 / Catalog 3,迁移 18 项内置工具、7 项企业技能;重复迁移检查全部 preserved。API、worker、Linsight 各 20 个发布文件哈希一致;后台健康、页面与 browser-config 返回 200。 +- 管理员页面已确认“工具与技能”、内置工具授权盾牌、技能授权按钮,以及默认开启的全员可用和用户/部门/用户组入口。实际权限配置保持原值。 +- 真实成员核验发现根租户沿用动态归属而没有持久 member 元组。组织关系适配器现从 active UserTenant 快照补充请求级根租户成员事实;迁出、停用、历史记录均移除该事实。子租户沿用持久投影。 +- 根租户归属变更、停用、历史状态、请求上下文及原授权模型共 53 项定向回归通过,变更 Ruff / 格式 / diff 检查通过。最终发布 `tools-skills-auth-20261026-root-membership` 已完成,后台三个进程各 23 个源文件哈希一致。真实 OpenFGA 抽样 3 位 active 成员、25 项资源共 75 次 can_use 全部允许;无成员身份的 25 次检查全部拒绝,旧 user:* 工具公开标记归零。API 两个实例均就绪并使用 Catalog 3。 + +- 3006 最终健康与 browser-config 返回 200,管理员授权弹窗重新读取成功。业务权限开关和指定人员保存、普通账号完整任务调用由用户继续验收。首次发布后检查触发自动回退导致服务中断,已恢复并收敛部署检查范围;最终版本、备份和验收日志已留存。 + +## 2026-10-28 — 知识空间提问模型选择与布局 + +- 基线:`dataelement/bisheng:feat/bisheng-work-120-main@2a726f5c97be74670d99718cdc475cb1f7331b62`;分支 `codex/knowledge-ask-model`。 +- 根因:模型触发器基础样式受主聊天页面选择器限制,知识空间按钮失去 flex 对齐;提问栏缺少模型选择回调,跨页交接仅携带问题和知识空间。 +- 实现:共享组件统一提供按钮基础布局,知识输入框限制模型区域宽度并保留全名提示;草稿保存选择,发送携带稳定模型 ID,新对话完成普通聊天模式、配置、模型和知识空间准备后发送。模型失效时恢复问题草稿并提示重新选择。 +- 自动验证:5 组 / 48 项相关回归通过,覆盖真实发送 Hook 的请求参数、模型记录、缓存任务页回到新对话、配置延迟、重复激活、失效模型和文章附件交接。将新增用例运行于原始实现,复现 5 项失败。客户端 1,480 个严格类型文件、全量 ESLint、生产构建、三语言一致性、架构守卫和差异检查通过。 +- 组件验证:macOS Electron 43.0.0 使用实际 KnowledgeAskBar / KnowledgeAiInput / AiModelSelect,800/500/320 宽度与 100%/125%/150% 缩放共 9 组布局检查通过;选择长名称模型后发送,交接获得 model=2、space=28。配置、导航和后端服务采用隔离夹具。 +- 交付边界:本次修改平台网页客户端;发布需部署配套 client。Windows 安装包、真实账号和模型服务端执行验收待测试环境复测。 + +- 同步目标分支 `474783598` 的连续提问修复:发送链路同时校验路由和实际会话,发送获接受后完成交接;保留连续提问、生成中切换知识空间、拒绝后重试和重复激活回归。组合代码继续通过相关检查。 + +## 2026-10-28 — 发现广场应用对话返回修复 + +- 应用对话读取当前路由和入口来源,发现广场进入后返回应用分类。33 项组合回归、前端质量检查和构建通过;macOS Electron 两条组件导航路径通过。 +- 部署及真实账号 macOS 客户端验收待执行。[修复与验证记录](status/app-explore-chat-return-20261028.md)。 + +## 2026-10-28 — 知识空间分享介绍与加入入口 + +- 根因:介绍访问绑定广场发布状态,与加入接口按开放/审批设置处理的规则存在差异。 +- 已在 `codex/knowledge-share-preview` 修正介绍访问条件;广场筛选与内容权限沿用各自原有规则。 +- 修复前复现 2 项失败,修复后 58 项相关回归通过;Ruff、格式、架构与差异检查通过。 +- 本次按用户要求直接交付 `feat/bisheng-work-120-main`;测试环境部署与 macOS 真实账号验收待执行。 +- 记录:[分享介绍修复](status/knowledge-share-preview-20261028.md)。 + +## 2026-10-28 — 应用对话简介字段统一 + +- 助手详情读取层统一将已保存的 `desc` 映射为会话使用的 `description`,应用对话显示实际简介。 +- 28 项相关回归、Client ESLint、1,488 个文件的严格类型检查、生产构建、i18n 与改动文件架构检查通过。测试环境部署及 macOS 原生客户端验收待执行。[修复记录](status/app-description-20261028.md)。 + +## 2026-10-28 — 知识空间跳转新聊天自动提交 + +- 聊天缓存保持路由更新,发送准备完成使用独立状态驱动提交;覆盖相同模型与知识空间的连续提问。 +- 同步目标 `e468e2f2b` 后,7 组 / 71 项回归、客户端静态检查与构建通过,macOS 浏览器组件夹具连续 3 次自动提交成功;13001 部署与原生客户端验收待执行。 +- [修复与验证记录](status/knowledge-ask-submit-20261028.md)。 + +## 2026-10-28 — 发现广场应用卡片暗色主题 + +- 应用卡片及其操作区统一使用主题表面颜色,暗色沿用知识空间配色。同步目标 `9a077fdce` 后,Client 静态检查与构建、18 项权限与导航组合回归通过;卡片保持已通过 12 组组件渲染验证的代码。 +- 已创建 [PR #2544](https://github.com/dataelement/bisheng/pull/2544),发布位置为 Client 网页资源。环境发布、13001 真实账号及 Windows 验收待执行。[修复记录](status/app-square-dark-theme-20261028.md)。 + +## 2026-10-28 — 订阅审批、关注列表与访问状态一致性 + +- 归属:毕昇平台共用的频道服务和网页客户端;桌面端呈现内嵌页面。初始基线 `1a8ac45e1`,构建验证基线 `c8ae351ee`,合入前保留 `8821e490d` 的应用描述更新,独立分支 `codex/channel-state-consistency`。 +- 实现:详情、广场和关注列表统一使用有效访问权限;授权投影成功后激活成员;自动审批统一交给持久化审批任务。前端采用服务端返回的订阅结果,并在页面恢复、窗口切回和可见状态每 30 秒刷新;广场刷新保留已加载页面,访问撤回收起内容。 +- 验证:后端 55 项通过;相同用例在初始基线产生 12 项失败、43 项通过。前端 15 项组件与 Hook 回归通过。client/platform 全量 ESLint、严格类型、共享包检查、i18n、Ruff、架构与差异检查、客户端生产构建通过。 +- 交付:用户授权直接合入 `dataelement/bisheng:feat/bisheng-work-120-main`;13100 部署、真实审批记录与权限数据核验、原生 macOS 验收待执行。 +- [修复与验证记录](status/channel-state-consistency-20261028.md)。 + + +## 2026-10-28 — 应用中心置顶状态保存 + +- 助手与工作流统一使用应用目录解析,完成上线状态和使用权限校验后保存当前用户置顶记录;Client 按业务状态确认成功,失败显示操作提示。 +- 22 项后端、22 项前端回归、静态与类型检查、i18n 和 Client 生产构建通过。测试环境发布、真实账号与 macOS 原生复测待执行。[修复记录](status/app-pin-state-20261028.md)。 + +## 2026-10-28 — Work 知识检索旧库升级 + +- 测试环境内置目录缺少 `bisheng_knowledge_search`;Work 检索前的工具门禁会拒绝缺项。升级命令现在一并完成工具登记和该分类的权限校准,并以 CURRENT 状态报告完成。 +- 源码与 Docker 入口统一支持 `upgrade-work-knowledge`;命令默认预览,Apply 绑定权限 Store 和审计操作人,重复执行保留管理员配置及个人开关。 +- 84 项相关回归及 17 个参数子项通过,包含 SQLite 登记、实际权限校准器的受控验证、双空间资料来源和缺项拒绝;静态与差异检查通过。 +- 后端代码与升级命令交付;环境数据升级及 test3 / dsh-sara5 的 K1/K2 真实验收待执行。[修复与部署说明](status/work-knowledge-upgrade-20261028.md)。 + +## 2026-10-28 — Work 知识检索发布流程 + +- `docker/deploy.sh update` 在 backend 就绪后执行知识工具登记与权限校准,成功后更新其余选中服务;失败向发布调用方返回错误并停止后续步骤。 +- 新增目标镜像预览入口,发布显式携带 Store 确认值和操作人;沿用现有数据维护与权限入口。操作说明见 `docker/README.md`。 +- 原脚本同组测试 19 失败、4 通过;修复后部署测试 23 项及组合回归 107 项、17 个参数子项通过。Docker 与外部权限服务使用替身;13001 发布和真实账号验收待执行。详见 `docs/status/work-knowledge-release-gate-20261028.md`。 diff --git a/docs/TASKMODE_EXECUTION_ACCEPTANCE.md b/docs/TASKMODE_EXECUTION_ACCEPTANCE.md new file mode 100644 index 0000000000..ff83cf241c --- /dev/null +++ b/docs/TASKMODE_EXECUTION_ACCEPTANCE.md @@ -0,0 +1,38 @@ +# 任务模式执行链路修复与验收 + +## 范围 + +最终共同开发分支为 `dataelement/bisheng:feat/bisheng-work-120-main`。本轮修复从 `codex/work120-main-integration@480fc2972` 开始,并于 2026-10-22 按用户要求合入共同分支,保留该分支的 `cb4b18018` 前端修改。本轮同步 3006 已发布的 `a01d35a3c` Cookie 配置修复。平台 Chat 的任务模式在服务端隔离容器执行;桌面 Work 保持本地执行边界。日常聊天的技能使用规则沿用现有实现。 + +## 根因及修改 + +1. 空工具选择导致任务工具装配提前返回,代码执行器依赖前端显式选择;改为在任务初始化、恢复时按当前用户的 `tool/use` 权限装配基础执行能力,显式选择去重。权限拒绝时保留其他可用工具,权限系统异常按原规则中止。 +2. 3006 已配置 `type=container` 并运行隔离 runner,新基线缺少对应后端传输实现;恢复后端容器执行适配,地址与凭据仅由服务配置控制,任务元数据只传工作目录、存储前缀及执行选项。 +3. 工具配置写在分类层,而旧装配只读工具层;统一按工具配置优先、分类配置兜底解析,使用独立 ORM 对象保存本次工作区绑定,数据库配置保持原值。 +4. 执行产物只有临时下载地址;任务文件同步到 `workspace//` 后返回稳定路径,保存失败报告工具错误,恢复执行带回原件、产物、中间文件和大工具结果;后续新一轮任务继承原件与产物。 +5. 重复调用检测只识别相邻一致调用;增加周期 2–4 的重复后缀检测,保留用户输入、交互工具和正常内容变化的边界。 + +6. 统一积分迁移后,Chat 结算调用了依赖 Work 审计执行对象的私有方法,出现 `SimpleNamespace has no attribute audit`。将共享账本结算提取为明确的数据接口;Work 保留自己的审计回调,Chat 使用统一计量和结算接口。 + +## 验收记录 + +- 初始定向测试:96 项通过。 +- 扩展测试:181 项通过;独立测试容器缺少 init 进程时,既有本地执行器超时用例将已结束的子进程误判为存活。以 `--init` 运行该组及真实 ORM 对象回归,21 项通过。 +- 真实候选后端探测:通过正式权限运行时绑定执行器,沙箱用户为 UID 10000,无外网,环境变量无服务凭据;Office 库及 LibreOffice 可用;文件回传至任务存储并按稳定路径读取。 +- 积分/模型调用:30 项通过,2 项外部 Redis 用例未运行;增加真实结算接口的成功、失败和未知用量回归。 +- 3006 第一版已健康发布,保留前端和其他容器。真实任务 `ea6ee078fddd4094af8c904a9f5e31cf`、`a3180f669d854e85a455cd1ca0b450f1` 暴露上述审计接口错误,保留失败记录;积分修复已发布。 + +## 边界 + +真实探测使用用户明确批准的 3006 后端服务凭据。凭据留在可信后端,代码执行使用独立 runner。此次发布只覆盖后端源码,保留其他会话正在调整的前端与桌面版本。数据库结构保持现有迁移版本。 + +## 发布后检查(2026-10-22) + +- 3006 当前发布目录随后由并行前端工作更新为 `chat-placeholder-aligned-preview-20261022`;逐一核验本轮 14 个后端文件 SHA-256 均与发布清单相同。后端 healthy,3006 页面及后端 health 均返回 HTTP 200。最近 15 分钟任务 worker 实际 ERROR/CRITICAL 日志为空。 +- Excel `810f967a04e54e8885255591d7fe25c2`、Word `9ca42f91378c44dcaba67c36a6925a32`、PPT `5ded8da318784cf992d19f869f402352` 均为 completed,并返回对应 Office 产物;前两类通过用户鉴权下载接口成功取得文件。 +- Excel 同会话续写 `7127e43d8bd04574a5be21f855b5718a` 为 completed,返回更新的 Excel。此次仅确认任务状态、工具执行和产物链路,内容正确性由用户验收。 +- 上述请求通过临时测试用户登录态访问 3006 入口 API,传入 `tools: []`;代码执行器由后端按权限自动绑定。桌面登录 UI 与安装包验收沿用独立交付流程。 +- 扩展模型审计集因独立测试启动器的 UserGroup ORM mock 无法初始化而受阻,未计为通过;共享结算、原模型服务及新增 Work 审计边界定向回归 33 项通过,2 项外部 Redis 用例未运行。 +- 用户要求自行验证 Word、Excel、PPT 文件效果,本轮停止额外 Office 生成及视觉验收。文件可打开、内容、公式结果及排版仍属用户验收范围。 + +运行证据保存在工作区 `outputs/taskmode-completion-20261022/`,服务器证据在 `/tmp/taskmode-completion-20261022/`。3006 使用现有 Office runner 镜像和后端私有配置;其他环境部署时需配置同一执行协议、Office 依赖、网络隔离与工具授权。 diff --git a/docs/WORK120_MAIN_INTEGRATION.md b/docs/WORK120_MAIN_INTEGRATION.md new file mode 100644 index 0000000000..325f9bbbcf --- /dev/null +++ b/docs/WORK120_MAIN_INTEGRATION.md @@ -0,0 +1,21 @@ +# Work 120 main 集成说明 + +## 来源与配套 + +后台基于 `feat/bisheng-work-120-main@7c701e86bcdfbafd53a99997f3146097a1497c41`,迁入旧 `feat/bisheng-work-120` 中的 Chat 交互与桌面桥接,以及 `codex/unified-model-access@c555c807bfb3aeb61adc4edffcb56f23adb0a849` 中的积分和权限功能。 + +桌面配套分支:`dataelement/bisheng-work:codex/v120-unified-credits`,基于 `V1.2.0@a1ce7bf83c1d3f98dda0802f197e906669de167f`,保留该版本知识空间及引用协议,并迁入本地积分、V1.1 原生认证和模型恢复改动。 + +## 最终行为 + +管理员统一配置模型可见范围、模型倍率,以及部门、角色、成员的月度积分。Chat 和 Work 使用统一目录和个人积分账本。桌面余额在账号设置显示一位小数,企业模型使用紧凑列表。后台保留新基线的原生授权、登录、退出与审计能力。 + +## 数据升级 + +Alembic 链为 `f065_dsh_install_policy_default_off` → `work110_dsh_local_sessions` → `f062_unified_model_access` → `f062_credit_multiplier`。新权限和积分表接在原生会话迁移之后;席位移除沿用基线实现。升级部署前需在目标环境数据库副本上验证历史额度导入,并检查迁移前后个人额度、模型可见范围和使用账本。 + +## 验证状态 + +Chat、管理端模型权限及积分、原生认证/迁移、审计定向测试及双前端生产构建与前端工作区类型检查已通过。后台全量 DSH 测试仍包含旧额度断言和环境相关失败,需要继续整理和复验。当前源码可用于集成联调;真实环境登录、Chat/Work 扣分、权限变更、余额刷新及存量数据库升级待验收。 + +本次保留现有 3006/3007 环境状态。安装包与部署需要从配套新分支生成后再执行验收。 diff --git a/docs/WORK_120_MAIN_MIGRATION.md b/docs/WORK_120_MAIN_MIGRATION.md new file mode 100644 index 0000000000..ffe96e039a --- /dev/null +++ b/docs/WORK_120_MAIN_MIGRATION.md @@ -0,0 +1,35 @@ +# Kinyoo work-120 migration + +Source: `feat/bisheng-work-120` at `de60988d7`. +Target baseline: `origin/feat/bisheng-work-120-main` at `7e573b3a6`. + +## Scope + +The user approved all 27 non-merge commits authored by Kinyoo that are not ancestors of the target baseline. 26 were cherry-picked with source hashes in their commit messages. Source `994fde7ff` was skipped: the target already has the identical API example builder content under `apiExampleSnippets.ts`, rather than `apiRequestExampleBuilders.ts`. + +No backend changes or unrelated release-line commits were merged. Nothing was pushed. + +## Conflict decisions + +- Permission roster search: retain the Outlined search icon and semantic color. +- Message attachments: retain target React hooks and add the attachment icon import. +- Markdown: retain target source-preview callback alongside compact image rendering. +- Layout: retain target model-resolution/default-skill logic and organization-KB pagination while applying the new layout/tool-selection composition. +- Article dock: preserve target per-surface model isolation and automatic selection repair when extracting DockInput. +- Locales: three-way merge source deltas; all pre-existing target translation leaf keys are retained in en, ja and zh-Hans. +- ESLint suppressions: retain target suppressions during layout conflict; do not manually add exceptions. + +## Approved desktop dependencies + +The user explicitly approved the smallest required desktop bridge dependencies, not the other author's complete chat visual redesign. Source snapshots at `e85f8ef21` supply desktopEmbedding, useDesktopAppearance, useDesktopConversation, useDesktopNavigation and their tests, plus DesktopShellBridge and its test. Their original introduction is traceable to `562ee4e6a` and `93d4df622`. + +The integration follow-up mounts DesktopShellBridge in MainLayout, suppresses duplicate web sidebar chrome in embedded desktop mode, and connects conversation landing-state reporting. Existing web chat model behavior is retained. + +## Verification + +- `git diff --check`: passed before integration commit. +- Target locale leaf-key preservation: passed for all three languages. +- Backend diff against target baseline: empty. +- Full frontend lint/typecheck could not complete: the desktop pnpm runner terminated them after approximately 300 seconds without progress. Direct Node checks also remained without output and were stopped; no passing quality-gate claim is made. +- Focused desktop Jest: desktopEmbedding test passed; multiple jsdom suites failed to initialize because the pre-existing canvas native binary (`canvas.node`) is absent. The remaining stalled run was stopped. These are incomplete checks, not accepted regressions. +- Local alias/relative import scan of migrated application files found no missing modules (two matches in rspress demo strings are not actual imports). diff --git a/docs/agent-pitfalls.md b/docs/agent-pitfalls.md new file mode 100644 index 0000000000..fd0a29373e --- /dev/null +++ b/docs/agent-pitfalls.md @@ -0,0 +1,32 @@ +# Agent Pitfalls — Factual and Method + +Moved out of root `AGENTS.md §7` to keep the root file short. Root keeps a one-line index and the two destructive-git rules. + +## Where subsystem pitfalls live + +Backend runtime pitfalls (tenant-filter SELECT-only gap, ruff hook import trap, Celery Beat × multi-tenant, DB config Redis TTL and db index, single-host compose hiding multi-node bugs, `create_time` tie-breaking, duplicate table definitions, non-admin permission verification, baseline-diff regression judgement) → `src/backend/AGENTS.md` §Known Pitfalls; config/packaging contracts (new YAML top-level keys, `uv export`, committed CLI wheel, dependency licensing) → the same file's §Config & Packaging Contracts. Linsight agent-framework contracts → `src/backend/bisheng/linsight/AGENTS.md`; skill-pack authoring → `.../linsight/builtin_skills/AGENTS.md`. Client pitfalls (MinIO presigned-URL host, sandboxed `srcDoc` storage, markdown pipeline, local dev login, `pnpm install` breaking jest) → `src/frontend/client/AGENTS.md` §Known Pitfalls. MinIO `sharepoint` image-proxy pitfall → `src/frontend/platform/AGENTS.md` §Known Pitfalls. Commercial edition (`BISHENG_PRO` env, gateway proxy, SSO) → `docs/architecture/11-gateway.md`. + +## Factual pitfalls + +| Pitfall | Reality | +|---------|---------| +| `/api/v1/env` version field | Hardcoded `2.4.0` in source — unreliable. Use route probing instead. | +| Passwords in config.yaml | Fernet-encrypted. Never write plaintext passwords into the YAML. | +| First registered user | Becomes `super_admin` automatically. In multi-tenant mode, create the tenant first. | +| `docker compose restart bisheng-backend` | `bisheng-backend` is a `container_name`; the compose *service* names are `backend` / `backend_worker`. The wrong one fails with `no such service` and restarts nothing — and if the container happened to restart on its own, `Up N seconds` makes it look like the command worked. | +| "I clicked it and nothing happened" | Read `process_time` for that request in the backend access log first. "Slow" (a synchronous blocking action) and "broken" (a callback that never refreshed) have completely different fixes, and a second click usually returns a state-conflict error — if the frontend only toasts it without rolling back or re-syncing, the UI stays stuck on the old state forever. | + +## Method pitfalls + +These cost more than the factual ones above. + +- **To decide whether code is still live, trace its callers and its frontend entry point** — its presence in the tree proves nothing. This repo carries a lot of paths whose UI was removed while the backend implementation stayed. Grep the backend, then the frontend API layer and component usages, and then check whether guard conditions and field defaults leave the branch permanently untaken. Reading statically and treating leftovers as current produces badly wrong conclusions. +- **Before naming a root cause, check the magnitude and measure where the bug reproduces.** Write down the number your explanation predicts and do the byte arithmetic; if it doesn't add up, it isn't the cause. A memory problem has three shapes and each leaves a different trace: live objects (tracemalloc grows), allocator retention (RSS plateaus near a peak, `malloc_trim` gives some back), a native leak (linear growth per operation that neither gc nor trim recovers) — a linear climb rules out the second outright. Take the decisive measurements in the environment that actually shows the bug; when two environments differ in several variables (branch, database, a fix), split them before attributing, and until then call it a correlation. A suspected mechanism that a 50-line script can test gets the script first. The COFCO task-mode OOM was blamed on missing summarization for two rounds; the real cause — dmPython ≤ 2.5.38 leaking one reference per `fetchall` value — only surfaced through a minimal repro and per-layer A/B. +- **Let the root cause determine the scope; don't fold every gap you pass into one change.** For each candidate ask "once the root cause is fixed, does this still hold?" — the ones that don't go on an explicit "not doing" list with the reason. Before editing a prompt or adding a fallback, read what the existing template / DB config / YAML **actually contains**; absence from the code is not absence from the system. And the right treatment for dead code is deletion, not reconnection. +- **Surface model misbehaviour; do not paper over it with a fallback.** A model that claims a deliverable it never produced is a diagnosable defect — manufacturing the file on its behalf destroys the evidence and makes the defect unmeasurable. Preference order: fix the prompt, then detect and log or flag, then let the UI show the anomaly honestly (greyed out and marked "not generated", not a normal-looking link that does nothing). Keep a fallback only where it is already an established product contract. +- **When several agents share one working tree, repository-wide git writes are forbidden**: `git stash` (including pop/apply), `git checkout .`, `git reset --hard`, `git clean`, and `git restore` without a path. They act on the whole tree rather than "your" files and silently swallow another session's in-progress work. Read committed content with `git show HEAD:`, inspect your own edits with `git diff HEAD -- `, and use `git worktree add` when you need a clean checkout; a path-scoped `git checkout -- ` is safe. Put this in the task brief when dispatching concurrent sub-agents. +- **`git checkout --theirs ` takes the other side's *whole file*, not just the conflicted hunks.** On files where both sides added entries independently (locale JSON, error-code copy) it silently drops everything unique to your side, and the result still parses and still looks like a one-line diff. Use `git checkout --merge -- ` to restore the conflict markers and resolve hunk by hunk, then validate by parsing the file. + +## Why AGENTS.md entries must be verified + +A hypothesis written as a rule is loaded into every later session as fact: the 2026-08 guess that history summarization "does nothing for process memory" graduated from memory into `linsight/AGENTS.md` unmeasured and steered a 2026-10 OOM investigation for three rounds. diff --git a/docs/api/filelib-retrieve.md b/docs/api/filelib-retrieve.md index fbfe337ea2..d418eebdc6 100644 --- a/docs/api/filelib-retrieve.md +++ b/docs/api/filelib-retrieve.md @@ -77,7 +77,7 @@ Content-Type: application/json | `filters.knowledge_base_filters[].tags` | string[] | ✅ | — | 标签名(**不是** tag id)。标签作用域是单个 KB(business_type=knowledge_space, business_id=该 KB 的 id)。 | | `filters.knowledge_base_filters[].tag_match_mode` | enum | ❌ | `"ANY"` | 枚举:`"ANY"` 或 `"ALL"`。**目前只支持 ANY**,传 `"ALL"` 返回 400。 | | `top_k` | int | ❌ | 10 | 最终返回的 chunk 数量上限,跨所有 KB 合并后再截断。范围 `[1, 200]`。 | -| `max_content` | int | ❌ | 15000 | 单个 KB 内合并文本的字符上限(影响检索阶段返回的最大 chunk 数),传递给底层 retriever。范围 `>=1`。 | +| `max_content` | int | ❌ | 15000 | 单个 KB 内合并文本的字符上限(影响检索阶段返回的最大 chunk 数),传递给底层 retriever。范围 `1 ~ 60000`;超过 60000 直接按参数校验失败拒绝(本端点上 v2 的校验错误统一回 HTTP 400,见 §5.1),**不做静默夹取**——响应体里没有承载「已被截断」的字段,悄悄给少了调用方无从察觉。 | ### 3.4 字段语义补充 @@ -163,8 +163,11 @@ Content-Type: application/json | `knowledge_base_ids` 为空 | `"knowledge_base_ids must not be empty"` | | 过滤器引用了不在 `knowledge_base_ids` 中的 KB | `"filter references kb_id 99 not present in knowledge_base_ids"` | | `tag_match_mode` 传了 `"ALL"` | `"tag_match_mode=ALL is not yet supported"` | +| `max_content` 超过 60000 | 字段校验失败,`status_message` 为 pydantic 错误列表 | | 字段类型校验失败(FastAPI 自动校验) | `"validation error"` 嵌套结构 | +> `/api/v2` 上的参数校验失败由 v2 异常处理器统一回 **HTTP 400**(不是 FastAPI 默认的 422),响应体形如 `{"status_code": 400, "status_message": [...]}`。 + ```json { "detail": "tag_match_mode=ALL is not yet supported" @@ -175,18 +178,22 @@ Content-Type: application/json BiSheng 沿用了「HTTP 200 + body 内 status_code」的统一响应模型用于业务错误: -| `status_code` | 含义 | 何时触发 | -|---|---|---| -| 404 | 知识库不存在 | `knowledge_base_ids` 中某个 ID 在数据库中查不到 | -| 403 | 默认操作员对该 KB 无访问权限 | 多租户 / ReBAC 权限检查不通过 | -| 500 | 服务器内部错误 | 向量库 / ES 不可用、embedding 服务异常等 | +| `status_code` | HTTP | 含义 | 何时触发 | +|---|---|---|---| +| 26321 | 404 | 知识库不可及 | `knowledge_base_ids` 里有一个或多个不可及。**不存在 / 未授予 / 类型不支持(QA 库、个人库)/ 被个人令牌的 data scope 窄化掉** 四种情形给同一个响应——可区分就等于给了一条探测知识库存在性的通道。`data.unreachable_ids` 只列出是哪些 id,不说原因。整个请求失败,不会静默剔除后返回缩水结果 | +| 26322 | 409 | 声明的知识库能力已被收回 | 仅托管应用运行期出现:能力声明里的库已被删除 | +| 26323 | 400 | 检索范围过大 | 未显式指定目标、而可及范围超过 200;请在 `knowledge_base_ids` 里点名 | +| 19002 / 19201 | 503 | 权限引擎不可用 | 评估失败即报错、零结果;**绝不返回未过滤或部分过滤的结果集** | +| 500 | 500 | 服务器内部错误 | 向量库 / ES 不可用、embedding 服务异常等 | + +> **2026-09 变更(F052)**:此前「知识库不存在」答 404 `NotFoundError`、「无权限」答 403、「类型不支持」答 10962,三者可区分;现已统一收敛为 26321。个人令牌被 data scope 窄化时此前答 26044,现同样落到 26321(26044 仍出现在清单类端点上)。 ```json { - "status_code": 404, - "status_message": "Knowledge base 99 not found", + "status_code": 26321, + "status_message": "Knowledge base is unreachable", "data": { - "exception": "..." + "unreachable_ids": [99] } } ``` @@ -284,7 +291,7 @@ def bisheng_retrieve(query: str, knowledge_base_ids: list[int], top_k: int = 10) 每个 KB 独立执行以下流程,最终结果合并: -1. 权限校验:default operator 是否对该 KB 有 view 权限。 +1. 可及性判定(统一检索门面,F052):按**执行身份**(密钥主体本身;委托模式下是被代表用户,不是 default operator)一次批量判定——知识空间看 `visible`、文档知识库看 `use`。任一目标不可及即整请求 26321。随后的检索对知识空间还会做**文件级**双层过滤(索引层预过滤 + 结果层过滤),过滤发生在返回之前。 2. 解析过滤:tag 名(如有)→ tag id → file id 列表。无对应文件时该 KB 返回空。 3. 构建过滤器:file id 列表 + 主版本过滤(排除已废弃的文档版本)→ Milvus expr + ES filter。 4. 双路召回:Milvus 向量检索 + Elasticsearch 全文检索,各取 top 100。 diff --git a/docs/api/mcp-server.md b/docs/api/mcp-server.md new file mode 100644 index 0000000000..ee2167949f --- /dev/null +++ b/docs/api/mcp-server.md @@ -0,0 +1,8 @@ +# MCP Server 工具面(已合并) + +2026-10-10 起,平台只有一个 MCP 服务:`POST /api/v2/mcp`,由 F067 统一远程 MCP 服务(`bisheng/open_mcp/`)提供。 +F052 原有的身份/组织工具与应用工具已并入该服务,工具名不变。 + +- 对外契约见 [`src/backend/docs/api/open-mcp.md`](../../src/backend/docs/api/open-mcp.md),其中「应用工场工具」一节列出了从 F052 并入的 9 个工具。 +- F052 的 `bisheng_knowledge_search` 由 F067 的 `bisheng_knowledge_retrieve` 取代,两者走同一个统一检索门面。 +- F052 的 `bisheng_model_list` 已移除,查询模型名请调用 `GET /api/v2/model/v1/models`。 diff --git a/docs/api/model-gateway.md b/docs/api/model-gateway.md new file mode 100644 index 0000000000..0efadde64b --- /dev/null +++ b/docs/api/model-gateway.md @@ -0,0 +1,163 @@ +# 模型协议面(OpenAI 兼容) + +`POST /api/v2/model/v1/chat/completions` · `GET /api/v2/model/v1/models` + +平台把自己已配好的模型以 **OpenAI 兼容协议**对外开一条直连面:拿一个 base URL 和一把服务账号密钥,官方 `openai` 客户端、各家 coding agent、以及托管在平台上的应用都能零改造调用,不必再各自申请厂商 key、也不用把厂商 key 复制到第二个地方。 + +本文是这条接口的对外契约。实现侧的决策与坑见 [`features/v3.0.0/051-model-protocol-gateway/design.md`](../../features/v3.0.0/051-model-protocol-gateway/design.md)。 + +--- + +## 1. 接入 + +| 项 | 值 | +|---|---| +| base URL | `{平台地址}/api/v2/model/v1`,**末尾的 `/v1` 是 base 的一部分**,官方客户端只会在其后拼 `/chat/completions` 与 `/models` | +| 鉴权 | `Authorization: Bearer bs-sak-…`(服务账号密钥);托管应用用平台注入的应用运行期凭据 | +| 权限位 | `model:invoke`,**仅模式 S**(不承载委托) | +| 地址从哪来 | 持密钥调用的一方读 `GET /api/v2/auth/whoami` 的 `model_base_url` 字段(`bisheng dev`、技能包走这条);服务账号详情页的接入信息区读 `GET /api/v1/dev-toolkit/versions` 的 `model.base_url`,值同上 | + +**地址只有一个生产者。** 上面两个字段都由后端的 `model_gateway_base_url()` 算出,没有第二份拼法:页面、CLI、技能包都不许自己用 `location.origin` 或配置里的主机名拼——平台前端在开发期跑在另一个端口,拼出来的地址在客户机器上是死链。 + +环境变量名(`bisheng dev` 与托管运行期**同名注入**,应用代码本地线上零差异): + +| 变量 | 值 | +|---|---| +| `OPENAI_BASE_URL` | base URL。官方 `openai` 客户端直读 | +| `OPENAI_API_KEY` | 凭据明文 | +| `BISHENG_MODEL_BASE_URL` | 同 base URL 的平台保留名,供不读 OpenAI 惯例变量的引擎使用 | + +**未部署开放能力层的环境上这条路径不存在**(404,与任何不存在的路径无差别),接入信息区也不展示地址。翻 `open_platform.enabled` 需要重启后端。 + +--- + +## 2. 模型名怎么写 + +**页面上「模型名称」填的是什么,代码里 `model` 就写什么。** 不是显示名,也不是内部 id。 + +解析规则固定,按顺序: + +1. **精确匹配模型名**。命中一个即调用。 +2. 命中多个(不同服务商配了同名模型)→ **拒绝,不替你挑**(`26514`),错误体的 `candidates` 列出这个裸名命中的全部限定名。 +3. 裸名没命中且请求里带 `/` → 按 **`{服务商名}/{模型名}`** 再解析一次。限定名在任何时候都可用,写死它就不会因为别人新配了一个同名模型而突然歧义。 +4. 仍未命中 → 只分两种:模型**已下线** `26512`,其余一律 `26511`——不存在、不属于本租户、不是对话类、服务商已被删除(删服务商会连它的模型行一起删,与「从未存在过」无从分辨),**「没有」与「不归你」不区分**,否则这条接口就成了模型清单的探测器。 + `26513`「服务商已删除」不来自这一步:名称解析走一层模型目录缓存(`open_api.model_catalog_ttl_seconds`,默认 30 秒、上限 60 秒),窗口内名字还解析得出、真去取模型时行已经没了,那一次才答 `26513`。模型被管理员下线同样最多滞后这一个窗口。 + +`GET /models` 只列**当前可调用**的对话类模型:`id` 在名称唯一时是裸名、歧义时只给限定名;`owned_by` 是服务商名;另有两个扩展键 `bisheng_model_type`、`bisheng_qualified_name`(恒为限定名,想写稳定名的取它)。 + +--- + +## 3. 承诺的请求参数 + +`POST /chat/completions`,请求体是 OpenAI 的子集: + +| 字段 | 说明 | +|---|---| +| `model` · `messages` | 必填 | +| `stream` · `stream_options` | `stream_options={"include_usage": true}` 时末块带 `usage` | +| `temperature` · `top_p` · `max_tokens` · `max_completion_tokens` · `stop` | 原样转给服务商 | +| `presence_penalty` · `frequency_penalty` · `seed` · `response_format` · `user` | 同上 | +| `tools` · `tool_choice` · `parallel_tool_calls` | 工具调用往返支持,流式增量可被官方 SDK 累加 | +| `n` | **只接受 1**;`n>1` 直接拒(`26503`)。底层一次只产一个候选,静默少给比报错更难查 | +| 其它未列出的键 | **原样转发,不做保证**——平台不改写、不注入任何系统提示词 | + +响应就是 OpenAI 的 `chat.completion` / `chat.completion.chunk`。思考类模型的推理内容放在 `reasoning_content`——流式增量里为空即不出现,非流式响应体按 OpenAI 结构完整给出、为空时是 `null`。官方客户端忽略这个它不认识的键,两种形态都不影响它解析。 + +**本版只提供上面两条端点。** 其余 OpenAI 协议族路径(`/embeddings`、`/completions`、`/responses`、`/images/*`、`/audio/*` …)一律 404 + 可读原因(`26501`),不是框架的空 404。Anthropic 的 `/v1/messages` 单列一码(`26502`)并说明本面只提供 OpenAI 兼容协议——把 `ANTHROPIC_BASE_URL` 指到这里的人,得到的是一句能看懂的话而不是协议层乱码。 + +--- + +## 4. 错误体 + +本面**不用平台信封**,用 OpenAI 错误体 + 真实 HTTP 状态,官方客户端才能 `except` 到正确的异常类: + +```json +{"error": {"message": "…", "type": "invalid_request_error", "code": "model_not_found", "param": null, "bisheng_code": 26511}} +``` + +`bisheng_code` 是 OpenAI 体之外的扩展键,官方客户端忽略它,平台侧脚本据它精确分类。流式途中出错:先发一条 `data: {"error":{…}}`,再 `data: [DONE]`。 + +| `bisheng_code` | 含义 | HTTP | `type` / `code` | +|---|---|---|---| +| 26001 / 26002 / 26027 / 26043 | 没带凭据 / 凭据无效 / 服务账号已停用 / 个人令牌持有人已失效 | 401 | `authentication_error` · `invalid_api_key` | +| 26003 | 缺 `model:invoke` 位 | 403 | `permission_error` · `insufficient_scope` | +| 26051 | 委托专用密钥(本面不承载委托,另发一把) | 403 | `permission_error` · `delegate_only_credential` | +| 26004 / 26005 / 26010 / 26018 / 26019 | 带了身份传递类请求头或 `user_id` 入参(`/api/v2` 共用底座判的) | 400 / 403 | `invalid_request_error` · `identity_header_not_accepted` | +| 26505 | 本面自己再拒一次 `X-End-User`(共用底座会默默采纳它) | 403 | `permission_error` · `identity_header_not_accepted` | +| 26501 / 26502 | 端点不支持 / Anthropic 协议 | 404 | `invalid_request_error` · `endpoint_not_supported` / `anthropic_protocol_not_supported` | +| 26503 | 请求体不合法(`messages` 为空、`n>1` …) | 400 | `invalid_request_error` · `invalid_request` | +| 26504 | 服务账号密钥附带了访问者凭据,或托管应用的访问者凭据验不过 | 403 | `permission_error` · `access_token_not_accepted` | +| 26511 / 26512 / 26513 | 模型不存在 / 已下线 / 服务商已删除 | 404 | `invalid_request_error` · `model_not_found` / `model_offline` / `model_revoked` | +| 26514 | 裸名歧义,`error.candidates` 给限定名 | 400 | `invalid_request_error` · `model_name_ambiguous` | +| 26515 | 托管应用调了未声明的模型能力 | 403 | `permission_error` · `capability_undeclared` | +| 26516 | 模型目录 / 能力声明不可判定 → fail-closed | 503 | `server_error` · `model_catalog_unavailable` | +| 26517 | 服务商日调用上限 | 429 | `rate_limit_error` · `provider_daily_limit_exceeded` | +| 26531 / 26532 / 26533 | 上游失败 / 上游拒绝(上下文超长、参数不支持、内容拦截) / 上游限流 | 502 / 上游自己的 4xx / 429 | `server_error` · `upstream_error` / `invalid_request_error` · `upstream_rejected` / `rate_limit_error` · `upstream_rate_limited` | +| 26534 | 流式中途中断(只出现在 SSE 错误事件里) | — | `server_error` · `stream_interrupted` | + +**不可判定一律 fail-closed**(`26516` / `503`):目录读不出来时报错,不退回「按空清单放行」或「按上次结果放行」。 + +--- + +## 5. 身份与边界 + +- **一律以密钥主体自身的身份执行**:服务账号不继承它的资源归属人,可调用范围 = 该主体在本租户可见的、已上线的对话类模型。 +- **不承载委托**:持 `delegate` 位的密钥在入口即被拒(`26051`),不静默降级。 +- **不接受任何身份传递请求头**(`X-On-Behalf-Of` / `X-End-User` 及其变体)与 `user_id` 入参:收下再忽略,会让调用方误以为调用跑在别人的身份下。 +- **托管应用**走同一条面,Bearer 换成平台注入的 `BISHENG_APP_TOKEN`,可调用范围额外 ∩ **该应用当前生效能力声明**里的模型——声明外的模型答 `26515`,应用不能自报范围。 +- **不在这条面上做配额与账单**:本版只逐条记录调用(模型、token 数、时延、结果、归属人),不做聚合、不加上限;服务商自身的日调用上限照旧生效(`26517`)。 + +--- + +## 6. 引擎与客户端配置 + +官方 Python 客户端: + +```python +from openai import OpenAI + +client = OpenAI(base_url="https://<平台地址>/api/v2/model/v1", api_key="bs-sak-…") +print([m.id for m in client.models.list().data]) + +resp = client.chat.completions.create( + model="qwen2.5-72b", # 页面上的「模型名称」;歧义时写 "服务商名/模型名" + messages=[{"role": "user", "content": "你好"}], + stream=True, + stream_options={"include_usage": True}, +) +for chunk in resp: + print(chunk.choices[0].delta.content or "", end="") +``` + +`curl`: + +```bash +curl -N https://<平台地址>/api/v2/model/v1/chat/completions \ + -H "Authorization: Bearer bs-sak-…" -H "Content-Type: application/json" \ + -d '{"model":"qwen2.5-72b","messages":[{"role":"user","content":"你好"}],"stream":true}' +``` + +读 OpenAI 惯例环境变量的引擎(Qwen Code、Codex CLI 的 chat-completions provider、Kimi Code、各类 IDE 插件): + +```bash +export OPENAI_BASE_URL=https://<平台地址>/api/v2/model/v1 +export OPENAI_API_KEY=bs-sak-… +``` + +**Claude Code 走不通**,这是设计如此:把 `ANTHROPIC_BASE_URL` 指到这里会得到 `26502`,说明本面只提供 OpenAI 兼容协议。需要在 Claude Code 里用平台模型的,改用它的 OpenAI 兼容 provider 配置。 + +托管应用里不必自己配地址与密钥——两者由平台按同名环境变量注入,`openai` 客户端零参数构造即可: + +```python +from openai import OpenAI + +client = OpenAI() # OPENAI_BASE_URL / OPENAI_API_KEY 由平台注入 +``` + +--- + +## 7. 调用记录 + +每次调用落一行(`model_call_record`):凭据(掩码)、主体与资源归属人、托管应用 id、请求的模型名与解析结果、是否流式、三个 token 数(未知写 NULL,**不写 0**)、结果与错误码、时延与首字时延、`request_id`。请求体正文、密钥、服务商配置**都不进记录,也不进日志**。 + +凭据层就被拒的调用(401 / 403 / `26051`)不落本表,落既有的 `open_api.call` 审计行——那不是一次模型调用。 diff --git "a/docs/api/\345\274\200\346\224\276 API \346\216\245\345\217\243\346\226\271\346\241\210.md" "b/docs/api/\345\274\200\346\224\276 API \346\216\245\345\217\243\346\226\271\346\241\210.md" new file mode 100644 index 0000000000..c2a84f20b2 --- /dev/null +++ "b/docs/api/\345\274\200\346\224\276 API \346\216\245\345\217\243\346\226\271\346\241\210.md" @@ -0,0 +1,1713 @@ +# BiSheng 开放 API 接口方案 + +--- + +## 1. 概述 + +### 1.1 能力总览 + +| 能力 | 说明 | 章节 | +|---|---|---| +| **知识库** | 知识资源的创建与管理、文档上传与解析、语义检索、元数据管理、问答对管理、引用溯源 | [第 3 章](#3-知识库接口) | +| **工作流** | 触发已发布的工作流执行,支持流式输出与多轮人机交互 | [第 4 章](#4-工作流接口) | +| **助手** | 以 OpenAI 兼容格式调用智能助手对话 | [第 5 章](#5-助手接口) | +| **会话记录** | 查询会话历史、生成会话标题、写入用户反馈、回填外部消息 | [第 6 章](#6-会话记录接口) | + +### 1.2 接入流程 + +``` +① 申请密钥 ② 选择身份模式 ③ 调用接口 +管理员创建服务账号 → 按你的用户体系选择 → 携带密钥与身份头 +签发密钥并勾选权限位 两种模式之一 发起 HTTP 请求 +并授予所需资源 +``` + +### 1.3 典型集成场景 + +按两个问题即可确定应选的模式:**这次调用背后有终端用户吗?如果有,他们在 BiSheng 上有账号吗?** + +| 场景 | 你的应用长什么样 | 应选身份模式 | +|---|---|---| +| **系统集成** | 后台任务、数据管道,没有终端用户的概念。例如每天把 ERP 的新合同同步进知识库,或订单状态变更时触发审批工作流 | 服务账号模式 | +| **企业内部应用** | 自建门户或业务系统,使用者就是 BiSheng 平台上的用户。例如员工在自建系统里搜索知识库,结果需与其在 BiSheng 中看到的一致 | 代表平台用户模式 | +| **对外应用** | 面向自己客户或公众的应用,使用者与 BiSheng 无账号关系。例如对外客服机器人 | 服务账号模式
+ 外部用户标识头 | + +系统集成与对外应用走的是**同一条技术路径**(权限基准都是服务账号),差别只在两处:对外应用需要按终端用户分区会话,以及服务账号的授权尺度截然不同——请见 §2.3.1。 + +--- + +## 2. 接入准备 + +### 2.1 基础信息 + +| 项 | 值 | +|---|---| +| 接口根地址 | `https://<你的部署域名>/api/v2` | +| 请求/响应格式 | JSON(文件上传接口为 `multipart/form-data`) | +| 字符编码 | UTF-8 | +| 时间格式 | `YYYY-MM-DD HH:mm:ss`(除特别注明为 Unix 时间戳的字段) | + +本文档中所有接口路径均为相对于接口根地址的路径。例如 `POST /filelib/retrieve` 的完整地址是 `https://<你的部署域名>/api/v2/filelib/retrieve`。 + +### 2.2 认证 + +所有接口均需在请求头携带 API 密钥,**无密钥一律返回 401**: + +``` +Authorization: Bearer +``` + +#### 密钥与服务账号 + +密钥统一以 `bs-sak-` 为前缀,由管理员签发给一个**服务账号**。 + +服务账号是专用于系统集成的主体:它不能登录平台,权限由管理员单独授予,不继承任何自然人。这意味着—— + +- 集成的权限边界可以按最小必要收敛,不会因为借用了某位员工的账号而过宽; +- 不会因为某位员工离职或账号变动导致集成中断; +- 审计记录能明确追溯到具体是哪个系统发起的调用。 + +一个集成建议对应一个服务账号,便于独立授权、独立停用与独立审计。 + +#### 权限位 + +创建密钥时按需勾选,**默认一个都不勾**。调用未被授予权限位的接口返回 `26003`,错误信息会指明缺少哪一位。 + +| 权限位 | 授予的能力 | +|---|---| +| `knowledge:read` | 知识资源列表、检索、文件列表、元数据读取、问答对查询、引用溯源查询 | +| `knowledge:write` | 知识资源增删改、文件上传删除、元数据写入、问答对增删改 | +| `workflow:invoke` | 触发与停止工作流 | +| `workflow:read` | 查询工作流信息 | +| `assistant:invoke` | 助手对话 | +| `assistant:read` | 助手信息与列表查询 | +| `session:read` | 会话历史查询、生成会话标题 | +| `session:write` | 会话反馈写入(点赞、标记已解决、评论) | +| `session:sync` | 向已有会话回填消息 | +| `delegate` | 代表平台用户执行(见 §2.3.2) | + +> **为什么 `session:sync` 单独成位**:回填接口可以向会话写入任意消息,等同于改写对话记录;而反馈类操作只改元数据。风险量级不同的动作不共用权限位,因此想收集用户点赞的集成不会连带获得改写历史的能力。 + +#### 两层授权:权限位 × 资源授权 + +一次调用能不能成功,由**两层"与"关系**共同决定。两层由管理员在**不同的界面**配置,排障时请分清: + +| 层 | 判定什么 | 在哪配置 | 不满足时 | +|---|---|---|---| +| **① 密钥权限位** | 这把密钥能做哪**一类**动作(读知识库 / 调工作流 / 委托……) | 密钥的创建或编辑表单 | `26003`,并指明缺哪一位 | +| **② 主体资源授权** | 执行身份对**这一个具体资源**有什么权限 | 服务账号详情页的「资源授权」 | 各业务模块自身的资源权限错误 | + +两层缺一不可:持有 `knowledge:read` 但服务账号未被授予知识库 A,读 A 失败;被授予了 A 但密钥没勾 `knowledge:read`,同样失败。 + +**两种失败返回不同的错误码**,据此即可判断该找管理员补权限位,还是该申请资源授权。 + +> 使用**代表平台用户模式**时,第二层的判定基准整个换成被代表用户,服务账号自身的资源授权此刻不参与判定。详见 §2.3.2。 + +#### 安全须知 + +- 密钥明文仅在创建时展示一次,请妥善保存;遗失后需重新创建。 +- **仅在服务端使用**。嵌入前端页面或客户端应用的密钥可被提取盗用。 +- 密钥可随时撤销,撤销后 5 秒内失效。 +- 可为密钥设置有效期;不设即长期有效。 +- 修改密钥的权限位**无需轮换密钥**,改完即时生效。 + +### 2.3 身份模式 + +调用时需明确"这次操作以谁的身份执行"。身份模式**只有两种**,通过是否携带 `X-Bisheng-On-Behalf-Of` 请求头选择: + +| 模式 | 请求头 | 数据可见范围 | 资源归属 | +|---|---|---|---| +| **服务账号**(默认) | 不传 | 服务账号被授权的范围 | 服务账号 | +| **代表平台用户** | `X-Bisheng-On-Behalf-Of` | **该用户在 BiSheng 中的权限范围** | 被代表用户 | + +此外还有一个可选的 `X-Bisheng-End-User` 头(§2.3.3),用于按你自己的终端用户分区会话。**它不是第三种身份模式**——传与不传,数据可见范围完全相同。 + +两个身份头**互斥**,同时传入返回 `26010`。 + +#### 2.3.1 服务账号模式 + +不传 `X-Bisheng-On-Behalf-Of`。所有操作以密钥主体身份执行,数据范围即该服务账号被授权的范围。 + +适用于两类情况: + +- **系统集成**——后台任务、数据管道,本来就没有"终端用户"的概念 +- **对外应用**——你的使用者与 BiSheng 无账号关系,不应受平台用户权限体系约束。此时请配合 §2.3.3 的 `X-Bisheng-End-User` 头做会话分区 + +两类情况的接口行为完全一致,差别在于**如何设定服务账号的授权范围**: + +> ⚠️ 对外应用请按"哪些内容可以对外部访客开放"从严授权。**这个范围就是该应用的数据出口边界**——服务账号被授予什么,外部访客就可能检索到什么。同样一次配宽,在内部集成上是越权,在对外应用上是公网数据泄漏。 + +#### 2.3.2 代表平台用户模式 + +``` +X-Bisheng-On-Behalf-Of: 10086 +``` + +取值为平台用户 ID 或用户名,**每次调用指定一个用户**。适用于你的应用使用者就是 BiSheng 平台用户的情况——检索结果、资源列表会与该用户在 BiSheng 中看到的一致;创建的资源归属该用户,他在 BiSheng 中可以直接看到并管理。 + +**权限基准整体替换为被代表用户**,不多也不少:不会因为走了 API 而少给他有权访问的内容,也不会让他看到无权访问的内容。 + +> **这是授权委托,不是身份认证。** 它声称的不是"该用户本人正在操作"——任何平台都无法凭一个请求头证明这一点。它声称的是"平台管理员已授权这个集成方,可以按该用户的权限边界执行,并把结果归属给他"。安全性来自密钥的保密性、委托能力的显式授予与审计双归属,从不来自这个头本身。 + +使用前提(任一不满足将拒绝执行): + +| 前提 | 不满足时 | +|---|---| +| 密钥已被授予 `delegate` 权限位 | `26004` | +| 目标用户须在**可代表范围**内——管理员用「指定用户」或「部门(含子树)」圈定这把密钥允许代表哪些人;该范围为必填项,未配置即无法代表任何人 | `26004` | +| 目标用户已存在于平台、账号可用、且与密钥属于同一租户 | `26005` | +| 目标用户不得是超级管理员或租户管理员 | `26007` | +| 该接口支持代表模式(见各接口的"支持的身份模式") | `26006` | + +> 最后两条是平台的安全底线:**不允许代表管理员身份执行**,避免通过代表机制取得无边界权限。请为集成场景使用普通用户身份。 + +**关于委托后的权限边界**:委托是权限基准的**替换**,不是收窄。调用方最多获得可代表范围内某个真实用户的真实权限——可能宽于服务账号自身,但永远够不到管理员那两类无条件放行的身份。请据此设定可代表范围。 + +#### 具备委托能力的密钥必须每次声明被代表用户 + +密钥一旦被授予委托能力,它的**每一次**调用都必须携带 `X-Bisheng-On-Behalf-Of`,漏传返回 `26016`(HTTP 400)。没有"可代表也可不代表"的混用密钥——一把密钥的身份模式在签发时就已确定。 + +> **漏传身份头不会降级执行**——不会返回全量数据,不会返回某个"安全的"公开子集,也不会静默改以服务账号身份执行。这是刻意的设计:静默降级会让你以为拿到的是该用户的数据,而实际上给多了是越权、给少了是功能缺失,且从响应上完全无从察觉。报错能让问题在联调阶段就暴露。 + +如果同一个集成既要以服务账号身份跑后台任务、又要代表用户执行,请让管理员签发**两把**密钥:一把不带委托能力(服务账号模式),一把带委托能力(代表模式)。 + +#### 2.3.3 外部用户标识(可选) + +``` +X-Bisheng-End-User: crm-user-88f3a2 +``` + +取值为你自己的用户标识,格式与含义完全由你定义。 + +> **`X-Bisheng-End-User` 仅用于会话隔离与审计留痕,不参与任何权限判定。** 平台不认识、不校验、不解析该标识。传与不传,数据可见范围完全相同,**均等于密钥主体(服务账号)的权限边界**。 + +因此,对外应用能访问的数据边界,请通过**给服务账号授予恰当的资源范围**来界定,而不是依赖这个头。 + +对于工作流、助手这类有会话语义的接口,**建议始终传入该头**。会话本身按接口返回的 `session_id` / `chat_id` 隔离,不传该头不会导致终端用户之间读到彼此的对话;但所有会话会统一归属服务账号,你将无法按终端用户维度做问题追溯。 + +与 `X-Bisheng-On-Behalf-Of` 同时传入将返回 `26010`——代表平台用户时,会话已归属该用户,无需再做外部分区。 + +### 2.4 通用响应结构 + +多数接口返回统一包装: + +```json +{ + "status_code": 200, + "status_message": "SUCCESS", + "data": { } +} +``` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `status_code` | integer | 业务状态码,成功固定为 `200`。 | +| `status_message` | string | 业务状态说明,成功通常为 `SUCCESS`。 | +| `data` | object/array/null | 业务数据,随接口而异;无返回数据的接口为 `null`。 | + +**以下接口不使用该包装**,接入时请按各自章节的说明单独处理: + +| 接口 | 响应形态 | +|---|---| +| `POST /workflow/invoke`(`stream=true`) | SSE 事件流,见 §4.1.1 | +| `POST /assistant/chat/completions` | OpenAI 原生结构(流式与非流式都不包装),见 §5.1 | +| `POST /chat/liked`、`POST /chat/solved` | `{"status_code": 200, "status_message": "success"}`,**无 `data` 字段** | + +### 2.5 错误码 + +#### 认证与授权 + +| 业务码 | HTTP | 含义 | 处理建议 | +|---|---|---|---| +| `26001` | 401 | 缺少 API 密钥或格式非法 | 检查 `Authorization` 头格式 | +| `26002` | 401 | 密钥无效、已撤销或已过期 | 更换有效密钥 | +| `26003` | 403 | 密钥缺少本接口所需的权限位 | 在密钥管理中补充权限位;错误信息会指明缺哪一位 | +| `26004` | 403 | 密钥未被授予代表能力,或目标用户不在可代表范围内 | 联系管理员开启 `delegate` 或调整可代表范围 | +| `26005` | 403 | 代表的目标用户不存在、已禁用或跨租户 | 核对用户 ID/用户名 | +| `26006` | 403 | 该接口不支持代表模式 | 改用服务账号模式 | +| `26007` | 403 | 不允许代表超级管理员或租户管理员 | 改用普通用户身份 | +| `26010` | 400 | 身份头冲突(两个身份头同时传入) | 只保留一个身份头 | +| `26016` | 400 | 密钥具备委托能力,但未传 `X-Bisheng-On-Behalf-Of` | 补上身份头;该密钥不允许以服务账号身份调用,需要服务账号模式请另签一把不带委托能力的密钥 | + +除上述之外,**资源权限不足**会返回各业务模块自身的错误码(而非 `26003`)——这是刻意区分的,见 §2.2「两层授权」。 + +#### 通用 HTTP 状态码 + +| 状态码 | 含义 | +|---|---| +| `200` | 请求成功(业务是否成功仍需看 `status_code`) | +| `201` | 资源创建成功 | +| `400` | 请求参数校验失败,响应体 `detail` 中包含具体字段与原因 | +| `401` | 未认证 | +| `403` | 无权限 | +| `404` | 目标资源不存在 | +| `500` | 服务端异常 | + +> **权限评估失败时接口会返回 5xx,绝不返回未过滤或部分过滤的结果集。** 收到 5xx 请重试,不要把它当作"没有匹配数据"处理。 + +--- + +## 3. 知识库接口 + +知识资源是知识库能力的统一载体,包含四种类型: + +| `type` | 类型 | 说明 | +|---|---|---| +| `0` | 文档知识库 | 上传文档,自动解析切分并建立检索索引 | +| `1` | QA 知识库 | 以问答对形式组织内容 | +| `2` | 个人知识库 | 归属个人的文档知识库 | +| `3` | 知识空间 | 支持文件夹层级组织的知识库 | + +多数接口对四种类型通用,调用方只需传资源 ID,无需区分具体类型。 + +### 3.1 知识资源层级 + +#### 3.1.1 获取知识资源列表 + +**接口地址** `GET /filelib/` + +**接口说明** 按类型、名称和分页查询当前调用身份可见的知识资源。 + +**所需权限位** `knowledge:read` + +**支持的身份模式** 服务账号 / 代表平台用户 + +**入参** + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `type` | integer | 否 | `0` | 资源类型:`0` 文档知识库,`1` QA 知识库,`2` 个人知识库,`3` 知识空间。 | +| `name` | string | 否 | 无 | 按名称搜索,知识库名称与知识空间名称都匹配此字段。 | +| `sort_by` | string | 否 | `update_time` | 排序字段:`update_time` / `create_time` / `name`。 | +| `page_size` | integer | 否 | `10` | 每页数量。 | +| `cursor` | string | 否 | 无 | 游标分页 token,取上一页响应的 `next_cursor`;首页不传。 | + +**出参** + +| 字段 | 类型 | 说明 | +|---|---|---| +| `data.data` | array | 当前页的知识资源列表。 | +| `data.page_size` | integer | 每页数量。 | +| `data.has_more` | boolean | 是否还有下一页。 | +| `data.next_cursor` | string | 下一页游标;无更多数据时为 `null`。 | +| `id` | integer | 知识资源 ID。 | +| `name` | string | 知识资源名称。 | +| `type` | integer | 资源类型。 | +| `description` | string | 资源描述。 | +| `model` | string | Embedding 模型 ID。 | +| `state` | integer | 资源状态。 | +| `auth_type` | string | 访问方式:`public` 公开,`private` 私有,`approval` 需审批。 | +| `is_released` | boolean | 是否发布到知识广场。 | +| `user_id` | integer | 创建人 ID。 | +| `user_name` | string | 创建人名称。 | +| `create_time` | string | 创建时间。 | +| `update_time` | string | 更新时间。 | +| `permission_ids` | array | 当前调用身份对该资源拥有的权限点。 | + +#### 3.1.2 创建知识资源 + +**接口地址** `POST /filelib/` + +**接口说明** 创建一个知识资源,通过 `type` 区分创建哪一类。 + +**所需权限位** `knowledge:write` + +**支持的身份模式** 服务账号 / 代表平台用户 + +> 使用代表平台用户模式时,创建出的知识资源归属被代表用户,该用户在 BiSheng 中可直接看到并管理。 + +**入参** + +| 字段 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `name` | string | 是 | 无 | 知识资源名称。 | +| `type` | integer | 否 | `0` | 资源类型。当前支持 `0` / `1` / `3`。 | +| `description` | string | 否 | 无 | 知识资源描述。 | +| `model` | string | 是 | 无 | Embedding 模型 ID,用于后续文档向量化检索。 | +| `auth_type` | string | 否 | `public` | 访问方式:`public` 公开,`private` 私有,`approval` 需审批。 | +| `is_released` | boolean | 否 | `false` | 是否发布到知识广场。主要用于知识空间。 | + +**出参** `data` 为创建成功的知识资源对象,字段同 3.1.1 列表项。 + +#### 3.1.3 更新知识资源 + +**接口地址** `PUT /filelib/` + +**接口说明** 更新知识资源的名称与描述。 + +**所需权限位** `knowledge:write` + +**支持的身份模式** 服务账号 / 代表平台用户 + +**入参** + +| 字段 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `knowledge_id` | integer | 是 | 无 | 要更新的知识资源 ID。 | +| `name` | string | 否 | 不传则不修改 | 新的知识资源名称。 | +| `description` | string | 否 | 不传则将描述置空 | 新的知识资源描述。 | + +**出参** `data` 为更新后的知识资源对象,字段同 3.1.1 列表项。 + +#### 3.1.4 删除知识资源 + +**接口地址** `DELETE /filelib/{knowledge_id}` + +**接口说明** 删除指定知识资源及其全部内容。 + +**所需权限位** `knowledge:write` + +**支持的身份模式** 服务账号 / 代表平台用户 + +**入参** + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `knowledge_id` | integer | 是 | 知识资源 ID(路径参数)。 | + +**出参** `data` 为 `null`,`status_message` 返回 `knowledge deleted successfully`。 + +#### 3.1.5 清空知识资源内容 + +**接口地址** `DELETE /filelib/clear/{knowledge_id}` + +**接口说明** 清空知识资源下的文件内容与检索索引,保留资源本身。资源 ID、名称、描述、配置均不变,仅内容被清空。 + +**所需权限位** `knowledge:write` + +**支持的身份模式** 服务账号 / 代表平台用户 + +**入参** + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `knowledge_id` | integer | 是 | 知识资源 ID(路径参数)。 | + +**出参** `data` 为 `null`,`status_message` 返回 `knowledge clear successfully`。 + +#### 3.1.6 检索知识资源分段 ★ + +**接口地址** `POST /filelib/retrieve` + +**接口说明** 跨一个或多个知识资源执行语义检索,返回最相关的文本分段。不做大模型生成,适合自带模型的应用集成。 + +**所需权限位** `knowledge:read` + +**支持的身份模式** 服务账号 / 代表平台用户 + +> 这是知识库能力中最常用的接口。若你的应用使用者是平台用户,请使用代表平台用户模式,检索范围会自动收敛到该用户有权访问的知识资源。 + +**⚠️ 权限判定的粒度是知识资源级,不做文件级过滤** + +本接口对 `knowledge_base_ids` 中的每个知识资源做一次访问权限校验:无权访问的资源不参与检索。但**一旦某个资源可访问,其中全部文件的分段都可能被检索到**——本接口不在文件粒度上做二次过滤。 + +若你需要更细的数据边界,请通过**拆分知识资源**来实现(把不同可见范围的文档放进不同的知识资源,再分别授权),不要依赖本接口做文件级收敛。 + +**请求示例** + +```json +{ + "query": "报销标准是多少", + "knowledge_base_ids": [1, 2], + "filters": { + "knowledge_base_filters": [ + { + "knowledge_base_id": 1, + "tags": ["财务制度", "2026"], + "tag_match_mode": "ANY" + } + ] + }, + "top_k": 10, + "max_content": 15000 +} +``` + +**入参** + +| 字段 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `query` | string | 是 | 无 | 检索问题或关键词,最小长度 1。 | +| `knowledge_base_ids` | integer[] | 是 | 无 | 要检索的知识资源 ID 列表,至少 1 个。 | +| `filters` | object | 否 | `null` | 检索过滤器。 | +| `filters.knowledge_base_filters` | array | 否 | `[]` | 按知识资源分别配置标签过滤,每项对应一个资源。 | +| `filters.knowledge_base_filters[].knowledge_base_id` | integer | 是 | 无 | 必须出现在 `knowledge_base_ids` 中,否则返回 400。 | +| `filters.knowledge_base_filters[].tags` | string[] | 是 | 无 | 标签名称(不是标签 ID)。标签的作用域是单个知识资源。 | +| `filters.knowledge_base_filters[].tag_match_mode` | string | 否 | `ANY` | `ANY` 命中任一标签即纳入。 | +| `top_k` | integer | 否 | `10` | 返回分段数量上限,跨资源合并后截断。取值范围 `[1, 200]`。 | +| `max_content` | integer | 否 | `15000` | 单个知识资源内合并文本的字符上限。取值范围 `>= 1`。 | + +**出参** + +| 字段 | 类型 | 说明 | +|---|---|---| +| `data.chunks` | array | 检索命中的分段列表。 | +| `data.total` | integer | 本次返回的分段数量。 | +| `content` | string | 分段文本内容。 | +| `knowledge_id` | integer | 分段所属的知识资源 ID。 | +| `document_id` | integer | 分段所属的文件 ID。 | +| `document_name` | string | 分段所属的文件名称。 | +| `chunk_index` | integer | 分段在文件中的序号。 | +| `document_update_time` | string | 源文件最近更新时间。 | + +**检索范围与过滤的关系** + +- `knowledge_base_ids` 决定**哪些资源参与检索**。 +- `filters.knowledge_base_filters` 决定**参与的资源各自如何筛选**。 +- 未在 `knowledge_base_filters` 中出现的资源按整库检索,不施加标签过滤。 +- 出现在 `knowledge_base_filters` 中但标签无匹配文件的资源返回 0 条分段,不影响其他资源。 +- `max_content` 是单资源内的字符上限,多资源调用时各自独立生效,最终再按 `top_k` 全局截断。 +- 无匹配结果时返回 200 + 空数组,**不是错误**。 + +### 3.2 文档层级 + +#### 3.2.1 上传文件到知识资源 + +**接口地址** `POST /filelib/file/{knowledge_id}` + +**接口说明** 向知识资源上传文件,并触发解析、切分与入库。上传后文件进入异步处理队列,可通过文件列表接口的 `status` 字段查询处理进度。 + +**所需权限位** `knowledge:write` + +**支持的身份模式** 服务账号 / 代表平台用户 + +**请求格式** `multipart/form-data` + +**入参** + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `knowledge_id` | integer | 是 | 无 | 知识资源 ID(路径参数)。 | +| `file` | file | 否 | 无 | 要上传的文件。与 `file_url` 二选一。 | +| `file_url` | string | 否 | 无 | 文件的可下载地址,由服务端拉取。与 `file` 二选一,适合文件已在你自己的对象存储上的场景。 | +| `parent_id` | integer | 否 | 无 | 目标文件夹 ID。仅知识空间使用;不传表示上传到根目录。普通知识库无需传。 | +| `split_mode` | string | 否 | `auto` | 分段模式:`auto` 自动,`custom` 自定义分隔符,`hierarchical` 层级分段。 | +| `separator` | string[] | 否 | 无 | 自定义分隔符列表,`split_mode=custom` 时生效。 | +| `separator_rule` | string[] | 否 | 无 | 分隔符切分位置,与 `separator` 一一对应:`before` 在分隔符前切,`after` 在分隔符后切。 | +| `chunk_size` | integer | 否 | 无 | 目标分段长度(字符数)。 | +| `chunk_overlap` | integer | 否 | 无 | 相邻分段的重叠长度(字符数)。 | +| `hierarchy_level` | integer | 否 | `3` | 层级分段的层数,`split_mode=hierarchical` 时生效。 | +| `append_title` | boolean | 否 | `false` | 是否在每个分段前拼接所属标题。 | +| `max_chunk_size` | integer | 否 | `1000` | 分段长度上限(字符数)。 | +| `retain_images` | integer | 否 | `1` | 是否保留文档中的图片:`1` 保留,`0` 丢弃。 | +| `force_ocr` | integer | 否 | `0` | 是否强制走 OCR:`1` 强制,`0` 自动判断。 | +| `enable_formula` | integer | 否 | `1` | 是否识别公式:`1` 识别,`0` 不识别。 | +| `filter_page_header_footer` | integer | 否 | `0` | 是否过滤页眉页脚:`1` 过滤,`0` 保留。 | +| `excel_rule` | string | 否 | 无 | Excel 专用解析规则,JSON 字符串。 | +| `callback_url` | string | 否 | 无 | 解析完成后的回调地址。 | + +**出参** + +| 字段 | 类型 | 说明 | +|---|---|---| +| `data` | object | 上传后创建的文件记录。 | +| `id` | integer | 文件 ID。 | +| `knowledge_id` | integer | 文件所属知识资源 ID。 | +| `file_name` | string | 文件名称。 | +| `file_type` | integer | 文件类型:`0` 文件夹,`1` 文件。 | +| `file_source` | string | 文件来源,上传接口为 `upload`。 | +| `file_size` | integer | 文件大小,单位字节。 | +| `status` | integer | 处理状态,见附录 9.2。 | +| `remark` | string | 处理备注或失败原因。 | +| `object_name` | string | 文件在对象存储中的地址。 | +| `create_time` | string | 创建时间。 | +| `update_time` | string | 更新时间。 | + +#### 3.2.2 上传文件并附带元数据 + +**接口地址** `POST /filelib/chunks` + +**接口说明** 上传文件的同时指定分段策略与文件元数据。分段参数与 3.2.1 完全相同,两者的差异只有三处:本接口的 `knowledge_id` 走表单而非路径、`file` 与 `metadata` 均为必填、不支持 `file_url`。 + +**所需权限位** `knowledge:write` + +**支持的身份模式** 服务账号 / 代表平台用户 + +**请求格式** `multipart/form-data` + +**入参** + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `file` | file | 是 | 无 | 要上传的文件。 | +| `knowledge_id` | integer | 是 | 无 | 目标知识资源 ID。 | +| `metadata` | string | 是 | 无 | 文件元数据,JSON 字符串。 | +| 其余分段与解析参数 | — | 否 | — | 同 3.2.1(`split_mode`、`separator`、`chunk_size`、`max_chunk_size`、`retain_images`、`force_ocr` 等)。 | + +**出参** 同 3.2.1。 + +#### 3.2.3 上传文本内容 + +**接口地址** `POST /filelib/chunks_string` + +**接口说明** 直接提交文本内容入库,无需先落成文件。适合从其他系统同步结构化文本。 + +**所需权限位** `knowledge:write` + +**支持的身份模式** 服务账号 / 代表平台用户 + +**入参** + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `knowledge_id` | integer | 是 | 目标知识资源 ID。 | +| `documents` | array | 是 | 文本内容列表,各项按顺序拼接为一个文件入库。 | +| `documents[].page_content` | string | 是 | 文本内容。 | +| `documents[].metadata` | object | 是 | 文本元数据,其中 `source` 字段作为生成的文件名。 | + +**出参** 同 3.2.1。 + +#### 3.2.4 获取文件列表 + +**接口地址** `GET /filelib/file/list` + +**接口说明** 按知识资源 ID 获取文件列表。 + +**所需权限位** `knowledge:read` + +**支持的身份模式** 服务账号 / 代表平台用户 + +**入参** + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `knowledge_id` | integer | 是 | 无 | 知识资源 ID。 | +| `parent_id` | integer | 否 | 无 | 目标文件夹 ID。仅知识空间使用;不传表示列出根目录。普通知识库忽略此参数。 | +| `keyword` | string | 否 | 无 | 文件名或标签关键词。 | +| `status` | integer[] | 否 | 无 | 按处理状态过滤,见附录 9.2。 | +| `page_size` | integer | 否 | `10` | 每页数量。 | +| `cursor` | string | 否 | 无 | 游标分页 token,取上一页响应的 `next_cursor`;首页不传。 | + +**出参** + +| 字段 | 类型 | 说明 | +|---|---|---| +| `data.data` | array | 当前页文件列表。 | +| `data.page_size` | integer | 每页数量。 | +| `data.has_more` | boolean | 是否还有下一页。 | +| `data.next_cursor` | string | 下一页游标;无更多数据时为 `null`。 | +| `data.writeable` | boolean | 当前身份对该资源是否有写入权限。 | +| `id` | integer | 文件 ID。 | +| `knowledge_id` | integer | 文件所属知识资源 ID。 | +| `file_name` | string | 文件名称。 | +| `file_type` | integer | 文件类型:`0` 文件夹,`1` 文件。 | +| `file_source` | string | 文件来源。 | +| `file_size` | integer | 文件大小,单位字节。 | +| `status` | integer | 处理状态,见附录 9.2。 | +| `remark` | string | 处理备注或失败原因。 | +| `title` | string | 文件摘要或标题。 | +| `tags` | array | 文件标签列表。 | +| `create_time` | string | 创建时间。 | +| `update_time` | string | 更新时间。 | + +#### 3.2.5 删除文件 + +**接口地址** `DELETE /filelib/file/{file_id}` + +**接口说明** 删除指定文件及其检索索引。 + +**所需权限位** `knowledge:write` + +**支持的身份模式** 服务账号 / 代表平台用户 + +**入参** + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `file_id` | integer | 是 | 要删除的文件 ID(路径参数)。 | + +**出参** `data` 为 `null`。 + +#### 3.2.6 批量删除文件 + +**接口地址** `POST /filelib/delete_file` + +**接口说明** 批量删除文件及其检索索引。 + +**所需权限位** `knowledge:write` + +**支持的身份模式** 服务账号 / 代表平台用户 + +**入参** 请求体直接为文件 ID 数组: + +```json +[101, 102, 103] +``` + +**出参** `data` 为 `null`。 + +### 3.3 元数据层级 + +元数据分两层:**元数据字段**是知识库级别的字段定义(相当于表结构),**文件用户元数据**是具体文件上的字段取值(相当于表数据)。需先定义字段,再为文件赋值。 + +#### 3.3.1 获取元数据字段 + +**接口地址** `GET /knowledge/get_metadata_fields/{knowledge_id}` + +**所需权限位** `knowledge:read` + +**支持的身份模式** 服务账号 / 代表平台用户 + +**入参** + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `knowledge_id` | integer | 是 | 知识库 ID(路径参数)。 | + +**出参** + +| 字段 | 类型 | 说明 | +|---|---|---| +| `data.knowledge_id` | integer | 知识库 ID。 | +| `data.metadata_fields` | array | 元数据字段列表。 | +| `field_name` | string | 字段名。 | +| `field_type` | string | 字段类型:`string` 文本,`number` 数值,`time` 时间。 | +| `updated_at` | integer | 字段更新时间,Unix 时间戳。 | + +#### 3.3.2 添加元数据字段 + +**接口地址** `POST /knowledge/add_metadata_fields` + +**所需权限位** `knowledge:write` + +**支持的身份模式** 服务账号 / 代表平台用户 + +**入参** + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `knowledge_id` | integer | 是 | 知识库 ID。 | +| `metadata_fields` | array | 是 | 要添加的元数据字段列表。 | +| `metadata_fields[].field_name` | string | 是 | 字段名,只能使用小写字母、数字、下划线,且必须以小写字母开头。 | +| `metadata_fields[].field_type` | string | 是 | 字段类型:`string` / `number` / `time`。 | + +**出参** `data` 为 boolean,表示是否添加成功。 + +#### 3.3.3 修改元数据字段 + +**接口地址** `PUT /knowledge/modify_metadata_fields` + +**接口说明** 修改元数据字段名称。 + +**所需权限位** `knowledge:write` + +**支持的身份模式** 服务账号 / 代表平台用户 + +**入参** + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `knowledge_id` | integer | 是 | 知识库 ID。 | +| `metadata_fields` | array | 是 | 要修改的字段列表。 | +| `metadata_fields[].old_field_name` | string | 是 | 原字段名。 | +| `metadata_fields[].new_field_name` | string | 是 | 新字段名,只能使用小写字母、数字、下划线,且必须以小写字母开头。 | + +**出参** `data` 为 boolean。 + +#### 3.3.4 删除元数据字段 + +**接口地址** `DELETE /knowledge/delete_metadata_fields` + +**所需权限位** `knowledge:write` + +**支持的身份模式** 服务账号 / 代表平台用户 + +**入参** + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `knowledge_id` | integer | 是 | 知识库 ID。 | +| `field_names` | string[] | 是 | 要删除的字段名列表。 | + +**出参** `data` 为 boolean。 + +#### 3.3.5 批量查询文件用户元数据 + +**接口地址** `POST /knowledge/file/list_user_metadata` + +**所需权限位** `knowledge:read` + +**支持的身份模式** 服务账号 / 代表平台用户 + +**入参** + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `knowledge_id` | integer | 是 | 知识库 ID。 | +| `knowledge_file_ids` | integer[] | 是 | 文件 ID 列表。 | + +**出参** + +| 字段 | 类型 | 说明 | +|---|---|---| +| `data` | object | 文件用户元数据,key 为文件 ID。 | +| `field_value` | string/number | 字段值。 | +| `field_type` | string | 字段类型。 | +| `updated_at` | integer | 更新时间,Unix 时间戳。 | + +#### 3.3.6 添加文件用户元数据 + +**接口地址** `POST /knowledge/file/add_user_metadata` + +**所需权限位** `knowledge:write` + +**支持的身份模式** 服务账号 / 代表平台用户 + +**入参** + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `knowledge_id` | integer | 是 | 无 | 知识库 ID。 | +| `add_metadata_list` | array | 是 | 无 | 文件元数据添加列表。 | +| `add_metadata_list[].knowledge_file_id` | integer | 是 | 无 | 文件 ID。 | +| `add_metadata_list[].user_metadata_list` | array | 是 | 无 | 要添加的元数据列表。 | +| `user_metadata_list[].field_name` | string | 是 | 无 | 元数据字段名,须已在 3.3.2 中定义。 | +| `user_metadata_list[].field_value` | string/number | 否 | `null` | 元数据字段值。 | + +**出参** `data` 为 boolean。 + +#### 3.3.7 修改文件用户元数据 + +**接口地址** `PUT /knowledge/file/modify_user_metadata` + +**所需权限位** `knowledge:write` + +**支持的身份模式** 服务账号 / 代表平台用户 + +**入参** + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `knowledge_id` | integer | 是 | 无 | 知识库 ID。 | +| `modify_metadata_list` | array | 是 | 无 | 文件元数据修改列表。 | +| `modify_metadata_list[].knowledge_file_id` | integer | 是 | 无 | 文件 ID。 | +| `modify_metadata_list[].user_metadata_list` | array | 是 | 无 | 要修改的元数据列表。 | +| `user_metadata_list[].field_name` | string | 是 | 无 | 元数据字段名。 | +| `user_metadata_list[].field_value` | string/number | 否 | `null` | 新的元数据字段值。 | + +**出参** `data` 为 boolean。 + +#### 3.3.8 删除文件用户元数据 + +**接口地址** `DELETE /knowledge/file/delete_user_metadata` + +**所需权限位** `knowledge:write` + +**支持的身份模式** 服务账号 / 代表平台用户 + +**入参** + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `knowledge_id` | integer | 是 | 知识库 ID。 | +| `delete_user_metadatas` | array | 是 | 文件元数据删除列表。 | +| `delete_user_metadatas[].knowledge_file_id` | integer | 是 | 文件 ID。 | +| `delete_user_metadatas[].field_names` | string[] | 是 | 要删除的字段名列表。 | + +**出参** `data` 为 boolean。 + +### 3.4 问答对层级 + +面向 QA 知识库(`type=1`)的问答对管理。一个问答对包含一组等价问法与一组答案。 + +#### 3.4.1 新增问答对 + +**接口地址** `POST /filelib/add_qa` + +**接口说明** 批量向 QA 知识库新增问答对,新增后自动建立检索索引。 + +**所需权限位** `knowledge:write` + +**支持的身份模式** 服务账号 / 代表平台用户 + +**入参** + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `knowledge_id` | integer | 是 | QA 知识库 ID。 | +| `data` | array | 是 | 要新增的问答对列表。 | +| `data[].question` | string | 是 | 问题。 | +| `data[].answer` | string[] | 是 | 答案列表,可给出多条答案。 | +| `data[].extra` | object | 否 | 附加信息,原样存储。 | + +**出参** `data` 为新增成功的问答对对象数组,字段见附录 9.3。 + +#### 3.4.2 追加相似问法 + +**接口地址** `POST /filelib/add_relative_qa` + +**接口说明** 为已有问答对追加等价问法,扩大该问答对的召回覆盖面。答案不变。 + +**所需权限位** `knowledge:write` + +**支持的身份模式** 服务账号 / 代表平台用户 + +**入参** + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `knowledge_id` | integer | 是 | QA 知识库 ID。 | +| `data` | object | 是 | 追加参数。 | +| `data.id` | string | 是 | 目标问答对 ID。 | +| `data.relative_questions` | string[] | 是 | 要追加的相似问法列表。 | + +**出参** `data` 为更新后的问答对对象,字段见附录 9.3。问答对不存在时返回 404。 + +#### 3.4.3 修改问答对 + +**接口地址** `POST /filelib/update_qa` + +**接口说明** 修改问答对的问题或答案。修改问题会重建该问答对的检索索引。 + +**所需权限位** `knowledge:write` + +**支持的身份模式** 服务账号 / 代表平台用户 + +**入参** + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `id` | integer | 是 | 问答对 ID。 | +| `question` | string | 否 | 新的问题内容。 | +| `original_question` | string | 否 | 要被替换的原问题。传入时只替换该条问法,其余问法保留;不传时用 `question` 覆盖全部问法。 | +| `answer` | string[] | 否 | 新的答案列表。不传则答案不变。 | + +**出参** `data` 为 `null`。 + +#### 3.4.4 删除问答对 + +**接口地址** `DELETE /filelib/qa/{qa_id}` + +**所需权限位** `knowledge:write` + +**支持的身份模式** 服务账号 / 代表平台用户 + +**入参** + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `qa_id` | integer | 是 | 问答对 ID(路径参数)。 | +| `question` | string | 否 | 传入时只删除该条问法并保留问答对;不传时删除整个问答对。 | + +**出参** `data` 为 `null`。 + +#### 3.4.5 获取问答对详情 + +**接口地址** `GET /filelib/detail_qa` + +**所需权限位** `knowledge:read` + +**支持的身份模式** 服务账号 / 代表平台用户 + +**入参** + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `id` | integer | 是 | 问答对 ID。 | + +**出参** `data` 为问答对对象,字段见附录 9.3。 + +#### 3.4.6 按时间范围查询问答对 + +**接口地址** `POST /filelib/query_qa` + +**接口说明** 查询指定时间范围内、通过页面录入或审核沉淀产生的问答对。通过接口创建的问答对不在返回范围内。 + +**所需权限位** `knowledge:read` + +**支持的身份模式** 服务账号 / 代表平台用户 + +**入参** + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `timeRange` | string[] | 是 | 长度为 2 的数组,`[开始时间, 结束时间]`,格式 `YYYY-MM-DD HH:mm:ss`。 | + +**出参** `data` 为问答对对象数组,字段见附录 9.3。 + +### 3.5 引用溯源 + +#### 3.5.1 获取引用详情 + +**接口地址** `GET /citation/{citation_id}` + +**接口说明** 根据引用 ID 查询该引用指向的源文件信息,用于在你自己的界面上呈现"这段答案出自哪份文档的哪个位置"。引用 ID 出现在助手与工作流回复的引用标记中。 + +**所需权限位** `knowledge:read` + +**支持的身份模式** 服务账号 / 代表平台用户 + +**入参** + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `citation_id` | string | 是 | 引用 ID(路径参数)。 | + +**出参** + +| 字段 | 类型 | 说明 | +|---|---|---| +| `data.file_id` | integer | 源文件 ID。 | +| `data.file_name` | string | 源文件名称。 | +| `data.file_type` | string | 源文件类型。 | +| `data.knowledge_name` | string | 源文件所属知识资源名称。 | +| `data.download_url` | string | 源文件下载地址。 | +| `data.preview_url` | string | 源文件预览地址。 | +| `data.bbox` | string | 引用内容在原文中的位置信息,JSON 字符串。 | + +以上字段均可能为 `null`。 + +> **引用不存在、类型不支持、源文件已删除、或当前身份无权访问该文件,一律返回 404。** 这是刻意设计——不区分这几种情况,避免通过响应差异推断出某个引用或文件是否存在。 + +--- + +## 4. 工作流接口 + +### 4.1 调用工作流 + +**接口地址** `POST /workflow/invoke` + +**接口说明** 触发一条工作流执行。支持流式与同步两种返回方式,并支持工作流中途要求用户输入时的多轮续跑。 + +**所需权限位** `workflow:invoke` + +**支持的身份模式** 服务账号 / 代表平台用户 + +> **只有已上线的工作流可以通过本接口调用。** 草稿态工作流会被拒绝执行——请先在平台上完成上线,再接入调用。 + +**入参** + +| 字段 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `workflow_id` | string | 是 | 无 | 工作流 ID(UUID)。 | +| `stream` | boolean | 否 | `true` | `true` 以 SSE 流式返回;`false` 等待执行结束后一次性返回全部事件。 | +| `session_id` | string | 否 | 无 | 会话 ID。首次调用不传,由服务端生成并在响应中返回;续跑时必须原样回传。 | +| `input` | object | 否 | 无 | 用户输入内容。仅在工作流处于等待输入状态时需要。 | +| `message_id` | integer | 否 | 无 | 消息 ID。提交用户输入时必填,取自 `input` 事件返回的 `message_id`。 | +| `override` | object | 否 | 无 | 节点参数覆盖,用于在本次执行中临时调整工作流节点配置。 | + +#### 4.1.1 流式返回(`stream=true`) + +响应为 `text/event-stream`,每条事件形如: + +``` +data: {"session_id":"<会话ID>","data":{"event":"stream_msg","status":"start", ...}} +``` + +**事件类型** + +| `event` | 含义 | +|---|---| +| `guide_word` | 开场引导语 | +| `guide_question` | 引导问题 | +| `output_msg` | 完整输出内容 | +| `stream_msg` | 流式输出片段,通过 `status` 区分 `start` / `end` | +| `output_with_input_msg` | 输出内容并要求用户输入 | +| `output_with_choose_msg` | 输出内容并要求用户选择 | +| `input` | 工作流等待用户输入 | +| `close` | 本次执行结束 | +| `error` | 执行出错 | + +**事件字段** + +| 字段 | 类型 | 说明 | +|---|---|---| +| `session_id` | string | 会话 ID,续跑时需回传。 | +| `data.event` | string | 事件类型。 | +| `data.status` | string | 事件状态,流式输出用 `start` / `end` 区分。 | +| `data.message_id` | string | 消息 ID,提交用户输入时需要。 | +| `data.node_id` | string | 产生该事件的节点 ID。 | +| `data.node_name` | string | 节点名称。 | +| `data.node_execution_id` | string | 节点本次执行的唯一 ID。 | +| `data.output_schema.message` | any | 输出的消息内容。 | +| `data.output_schema.reasoning_content` | string | 推理过程内容。 | +| `data.output_schema.files` | array | 输出的文件列表。 | +| `data.input_schema.input_type` | string | 输入形态:`dialog` 对话框输入,`form` 表单输入。 | +| `data.input_schema.value` | array | 需要用户填写的输入项定义。 | + +**输入项定义**(`input_schema.value[]`) + +| 字段 | 类型 | 说明 | +|---|---|---| +| `key` | string | 输入项标识,提交时作为 `input` 的 key。 | +| `type` | string | 输入类型:`dialog` 文本、`select` 选择、`file` 文件。 | +| `label` | string | 输入项显示名称。 | +| `value` | any | 默认值。 | +| `required` | boolean | 是否必填。 | +| `multiple` | boolean | 是否多选。 | +| `options` | array | 选项列表,`type=select` 时提供。 | +| `file_type` | string | 允许上传的文件类型,`type=file` 时提供。 | + +#### 4.1.2 同步返回(`stream=false`) + +服务端等待执行结束后一次性返回: + +```json +{ + "status_code": 200, + "status_message": "SUCCESS", + "data": { + "session_id": "a1b2c3d4_async_task_id", + "events": [ ] + } +} +``` + +`events` 为本次执行产生的全部事件数组,事件结构同 4.1.1,但不含 `stream_msg` 类型的中间流式片段。 + +#### 4.1.3 多轮输入交互 + +工作流中途需要用户输入时,流程如下: + +1. 首次调用不传 `session_id`,从响应中取得 `session_id`。 +2. 收到 `input`(或 `output_with_input_msg` / `output_with_choose_msg`)事件时,记录事件中的 `message_id` 与 `input_schema`。 +3. 携带同一个 `session_id`、上一步的 `message_id`,以及按 `input_schema` 组装的 `input` 对象再次调用本接口,工作流从中断处继续执行。 +4. 重复步骤 2–3 直至收到 `close` 事件。 + +`input` 对象的 key 取自 `input_schema.value[].key`: + +```json +{ + "workflow_id": "……", + "session_id": "a1b2c3d4_async_task_id", + "message_id": 12345, + "input": { + "user_choice": "同意", + "remark": "已核对无误" + } +} +``` + +### 4.2 停止工作流 + +**接口地址** `POST /workflow/stop` + +**接口说明** 中止指定会话正在执行的工作流。 + +**所需权限位** `workflow:invoke` + +**支持的身份模式** 服务账号 / 代表平台用户 + +> **只能停止属于当前调用身份的会话。** 试图停止其他身份创建的会话将返回 403。 + +**入参** + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `workflow_id` | string | 是 | 工作流 ID(UUID)。 | +| `session_id` | string | 是 | 要停止的会话 ID。 | + +**出参** `data` 为 `null`。 + +### 4.3 获取工作流信息 + +**接口地址** `GET /flows/{flow_id}` + +**接口说明** 查询一条工作流的配置信息,可用于在调用前确认工作流的输入项定义。 + +**所需权限位** `workflow:read` + +**支持的身份模式** 服务账号 / 代表平台用户 + +**入参** + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `flow_id` | string | 是 | 工作流 ID(路径参数,UUID)。 | + +**出参** `data` 为工作流对象,含 ID、名称、描述、状态、节点配置等信息。 + +工作流不存在、或该 ID 指向的不是工作流时返回 404;当前身份无权查看该工作流时返回无权限错误。 + +--- + +## 5. 助手接口 + +### 5.1 助手对话 + +**接口地址** `POST /assistant/chat/completions` + +**接口说明** 以 OpenAI Chat Completions 兼容格式调用智能助手。已有 OpenAI SDK 的应用可直接替换 base URL 与 model 接入。 + +**所需权限位** `assistant:invoke` + +**支持的身份模式** 服务账号 / 代表平台用户 + +> **本接口的响应不使用 §2.4 的统一包装**,流式与非流式均返回 OpenAI 原生结构,以保证 SDK 兼容性。 + +**入参** + +| 字段 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `model` | string | 是 | 无 | **填助手 ID**。 | +| `messages` | array | 是 | 无 | 对话消息列表,支持 `user` 与 `assistant` 两种角色。系统提示词取助手自身配置,无需传入。 | +| `messages[].role` | string | 是 | 无 | `user` 或 `assistant`。 | +| `messages[].content` | string | 是 | 无 | 消息内容。 | +| `stream` | boolean | 否 | `false` | 是否流式返回。 | +| `temperature` | float | 否 | `0.0` | 模型温度。传 `0` 或不传表示沿用助手自身配置。 | +| `n` | integer | 否 | `1` | 返回结果数量,当前仅支持 `1`。 | +| `tools` | array | 否 | `[]` | **本接口忽略此字段**。助手可用的工具取助手自身配置,不能在调用时指定。 | + +**出参(非流式)** + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | string | 本次回复 ID。 | +| `object` | string | 固定为 `chat.completion`。 | +| `created` | integer | 创建时间,Unix 时间戳。 | +| `model` | string | 助手 ID。 | +| `choices` | array | 回复列表。 | +| `choices[].index` | integer | 选项序号。 | +| `choices[].message` | object | 回复消息,含 `role` 与 `content`。 | +| `choices[].finish_reason` | string | 结束原因,固定为 `stop`。 | + +**出参(流式,`stream=true`)** + +响应为 `text/event-stream`,每条形如 `data: {...}`,`object` 为 `chat.completion.chunk`,增量内容位于 `choices[].delta.content`;思考过程位于 `choices[].delta.reasoning_content`。流以 `data: [DONE]` 结束。 + +> 若助手所选模型不支持流式输出,接口仍会以 SSE 形式返回,但内容会在一条 chunk 中一次性给出。 + +### 5.2 获取助手详情 + +**接口地址** `GET /assistant/info/{assistant_id}` + +**所需权限位** `assistant:read` + +**支持的身份模式** 服务账号 / 代表平台用户 + +**入参** + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `assistant_id` | string | 是 | 助手 ID(路径参数,UUID)。 | + +**出参** `data` 为助手对象,含 ID、名称、描述、开场白、引导问题,以及该助手关联的工具列表、工作流列表与知识库列表。 + +### 5.3 获取助手列表 + +**接口地址** `GET /assistant/list` + +**接口说明** 查询当前调用身份可见的助手列表。 + +**所需权限位** `assistant:read` + +**支持的身份模式** 服务账号 / 代表平台用户 + +**入参** + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `name` | string | 否 | 无 | 按名称模糊搜索,同时匹配描述。 | +| `tag_id` | integer | 否 | 无 | 按标签过滤。 | +| `status` | integer | 否 | 无 | 按上线状态过滤。 | +| `page` | integer | 否 | `1` | 页码,从 1 开始。 | +| `limit` | integer | 否 | `10` | 每页数量。 | + +**出参** + +| 字段 | 类型 | 说明 | +|---|---|---| +| `data.data` | array | 当前页助手列表。 | +| `data.total` | integer | 符合条件的助手总数。 | +| `id` | string | 助手 ID。 | +| `name` | string | 助手名称。 | +| `desc` | string | 助手描述。 | +| `logo` | string | 助手图标地址。 | +| `status` | integer | 上线状态。 | +| `user_id` | integer | 创建人 ID。 | +| `user_name` | string | 创建人名称。 | +| `tags` | array | 标签列表。 | +| `create_time` | string | 创建时间。 | +| `update_time` | string | 更新时间。 | + +> 返回范围由**当前调用身份**决定:服务账号模式下是该服务账号被授权的助手,代表平台用户模式下是被代表用户可见的助手。 + +--- + +## 6. 会话记录接口 + +本章接口用于读写**会话与消息记录**——包括通过工作流、助手等任何路径产生的会话。 + +它们与"发起一次对话"是两类不同的动作,因此使用独立的 `session:*` 权限位:读取历史用 `session:read`,写入用户反馈用 `session:write`,回填消息用 `session:sync`。 + +### 6.1 获取会话历史 + +**接口地址** `GET /chat/history` + +**接口说明** 按会话 ID 分页获取消息记录,用于在你自己的界面上还原对话。 + +**所需权限位** `session:read` + +**支持的身份模式** 服务账号 / 代表平台用户 + +**入参** + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|---|---|---|---|---| +| `chat_id` | string | 是 | 无 | 会话 ID。 | +| `flow_id` | string | 是 | 无 | 该会话所属的工作流或助手 ID。与 `chat_id` 不匹配时返回空数组。 | +| `id` | string | 否 | 无 | 游标:传入某条消息 ID,返回比它更早的消息。首页不传。 | +| `page_size` | integer | 否 | `20` | 每页数量。 | + +**出参** `data` 为消息数组,按时间排列。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | integer | 消息 ID。 | +| `chat_id` | string | 所属会话 ID。 | +| `flow_id` | string | 所属工作流或助手 ID。 | +| `is_bot` | boolean | 是否为机器回复。 | +| `message` | string | 消息内容。 | +| `type` | string | 消息类型。 | +| `category` | string | 消息分类。 | +| `sender` | string | 发送方名称。 | +| `receiver` | object | 接收方信息。 | +| `files` | string | 消息附带的文件列表,JSON 字符串。 | +| `intermediate_steps` | string | 中间执行过程。 | +| `extra` | string | 附加信息,JSON 字符串。 | +| `liked` | integer | 点赞状态:`0` 未评价,`1` 赞,`2` 踩。 | +| `solved` | integer | 会话解决状态:`0` 未评价,`1` 已解决,`2` 未解决。 | +| `copied` | integer | 是否被复制过。 | +| `remark` | string | 用户评论内容。 | +| `source` | integer | 是否支持溯源。 | +| `mark_status` | integer | 标注状态。 | +| `mark_user` | integer | 标注人 ID。 | +| `mark_user_name` | string | 标注人名称。 | +| `sensitive_status` | integer | 内容安全审查状态。 | +| `user_id` | integer | 消息归属用户 ID。 | +| `user_name` | string | 消息归属用户名称。 | +| `flow_name` | string | 工作流或助手名称。 | +| `name` | string | 会话名称。 | +| `create_time` | string | 创建时间。 | +| `update_time` | string | 更新时间。 | + +> `chat_id` 与 `flow_id` 任一为空、或两者不匹配时,返回 200 + 空数组,不报错。 + +### 6.2 生成会话标题 + +**接口地址** `POST /chat/gen_title` + +**接口说明** 获取会话的展示标题。 + +**所需权限位** `session:read` + +**支持的身份模式** 服务账号 / 代表平台用户 + +**入参** + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `conversationId` | string | 是 | 会话 ID。 | + +**出参** + +| 字段 | 类型 | 说明 | +|---|---|---| +| `data.title` | string | 会话标题。 | + +> ⚠️ **本接口存在约 5 秒的服务端等待**(用于让标题生成完成),请把客户端超时设为 5 秒以上。 +> 会话不存在时返回 `"New Chat"` 而非报错。 + +### 6.3 消息点赞与点踩 + +**接口地址** `POST /chat/liked` + +**接口说明** 为一条机器回复记录用户的赞或踩。 + +**所需权限位** `session:write` + +**支持的身份模式** 服务账号 / 代表平台用户 + +**入参** + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `message_id` | integer | 是 | 消息 ID。 | +| `liked` | integer | 是 | `0` 未评价,`1` 赞,`2` 踩。 | + +**出参** **不使用统一包装**,返回: + +```json +{"status_code": 200, "status_message": "success"} +``` + +### 6.4 标记会话解决状态 + +**接口地址** `POST /chat/solved` + +**接口说明** 标记整个会话是否已解决用户的问题,用于会话质量统计。 + +**所需权限位** `session:write` + +**支持的身份模式** 服务账号 / 代表平台用户 + +**入参** + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `chat_id` | string | 是 | 会话 ID。 | +| `solved` | integer | 是 | `0` 未评价,`1` 已解决,`2` 未解决。 | + +**出参** **不使用统一包装**,返回: + +```json +{"status_code": 200, "status_message": "success"} +``` + +### 6.5 提交消息评论 + +**接口地址** `POST /chat/comment` + +**接口说明** 为一条消息提交文字反馈,通常与点踩配合使用,用于收集"为什么不满意"。 + +**所需权限位** `session:write` + +**支持的身份模式** 服务账号 / 代表平台用户 + +**入参** + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `message_id` | integer | 是 | 消息 ID。 | +| `comment` | string | 否 | 评论内容。**超过 4096 字符的部分会被截断。** | + +**出参** `data` 为 `null`。 + +> 本接口只写入评论。请求体中的 `liked` 字段会被忽略——点赞与点踩请使用 §6.3。 + +### 6.6 回填会话消息 + +**接口地址** `POST /chat/sync/messages` + +**接口说明** 把你自己系统中产生的对话消息写入指定会话,使其出现在 BiSheng 的会话记录中。适用于人工客服接管、外部渠道消息归档等场景。 + +**所需权限位** `session:sync` + +**支持的身份模式** 服务账号 / 代表平台用户 + +> ⚠️ **这是本文档中风险最高的写入接口**,因此单独占用 `session:sync` 权限位、不并入 `session:write`:它可以向会话写入任意内容的消息,等同于改写对话记录;而点赞、评论这类反馈只改元数据。**请只给确有归档需求的集成授予此权限位。** + +**入参** + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `flow_id` | string | 是 | 会话所属的工作流或助手 ID(UUID)。 | +| `chat_id` | string | 是 | 目标会话 ID。 | +| `message_list` | array | 是 | 要写入的消息列表。 | +| `message_list[].is_send` | boolean | 是 | 消息方向标识。 | +| `message_list[].message` | string | 是 | 消息内容。 | +| `message_list[].create_time` | string | 是 | 消息产生时间,格式 `YYYY-MM-DD HH:mm:ss`。 | +| `message_list[].extra` | object | 是 | 附加信息,原样存储。 | + +**出参** `data` 为 `null`。 + +--- + +## 7. 调用示例 + +### 7.1 服务账号模式:检索知识库 + +系统集成场景,以服务账号自身权限检索。 + +```bash +curl -X POST 'https://<你的部署域名>/api/v2/filelib/retrieve' \ + -H 'Authorization: Bearer bs-sak-xxxxxxxxxxxx' \ + -H 'Content-Type: application/json' \ + -d '{ + "query": "差旅费报销标准", + "knowledge_base_ids": [1, 2], + "top_k": 5 + }' +``` + +### 7.2 代表平台用户模式:按员工权限检索 + +企业内部应用场景,检索范围收敛到该员工有权访问的知识资源。 + +```bash +curl -X POST 'https://<你的部署域名>/api/v2/filelib/retrieve' \ + -H 'Authorization: Bearer bs-sak-xxxxxxxxxxxx' \ + -H 'X-Bisheng-On-Behalf-Of: 10086' \ + -H 'Content-Type: application/json' \ + -d '{ + "query": "差旅费报销标准", + "knowledge_base_ids": [1, 2], + "top_k": 5 + }' +``` + +### 7.3 服务账号模式 + 外部用户标识:调用工作流 + +对外应用场景。身份仍是服务账号(权限范围不因这个头而改变),额外传入 `X-Bisheng-End-User` 以隔离各终端用户的会话。 + +```bash +curl -X POST 'https://<你的部署域名>/api/v2/workflow/invoke' \ + -H 'Authorization: Bearer bs-sak-xxxxxxxxxxxx' \ + -H 'X-Bisheng-End-User: crm-user-88f3a2' \ + -H 'Content-Type: application/json' \ + -d '{ + "workflow_id": "3fa85f6457174562b3fc2c963f66afa6", + "stream": false, + "input": {"question": "帮我查一下订单状态"} + }' +``` + +### 7.4 Python 集成示例 + +```python +import httpx + +BASE_URL = "https://<你的部署域名>/api/v2" +API_KEY = "bs-sak-xxxxxxxxxxxx" + + +def build_headers(on_behalf_of: int | None = None, + end_user: str | None = None) -> dict: + """构造请求头。on_behalf_of 与 end_user 互斥,最多传一个。""" + headers = {"Authorization": f"Bearer {API_KEY}"} + if on_behalf_of is not None: + headers["X-Bisheng-On-Behalf-Of"] = str(on_behalf_of) + elif end_user is not None: + headers["X-Bisheng-End-User"] = end_user + return headers + + +def retrieve(query: str, kb_ids: list[int], user_id: int | None = None) -> list[dict]: + """检索知识库。传入 user_id 时按该平台用户的权限过滤。""" + resp = httpx.post( + f"{BASE_URL}/filelib/retrieve", + headers=build_headers(on_behalf_of=user_id), + json={"query": query, "knowledge_base_ids": kb_ids, "top_k": 5}, + timeout=30, + ) + resp.raise_for_status() + return resp.json()["data"]["chunks"] + + +def invoke_workflow(workflow_id: str, user_input: dict, end_user: str) -> dict: + """同步调用工作流,按外部用户标识隔离会话。""" + resp = httpx.post( + f"{BASE_URL}/workflow/invoke", + headers=build_headers(end_user=end_user), + json={"workflow_id": workflow_id, "stream": False, "input": user_input}, + timeout=120, + ) + resp.raise_for_status() + return resp.json()["data"] + + +def fetch_history(chat_id: str, flow_id: str, page_size: int = 20) -> list[dict]: + """读取会话历史。需要 session:read 权限位。""" + resp = httpx.get( + f"{BASE_URL}/chat/history", + headers=build_headers(), + params={"chat_id": chat_id, "flow_id": flow_id, "page_size": page_size}, + timeout=30, + ) + resp.raise_for_status() + return resp.json()["data"] +``` + +### 7.5 流式调用工作流 + +```python +import json +import httpx + +with httpx.stream( + "POST", + f"{BASE_URL}/workflow/invoke", + headers=build_headers(end_user="crm-user-88f3a2"), + json={"workflow_id": workflow_id, "stream": True, "input": {"question": "..."}}, + timeout=120, +) as resp: + session_id = None + for line in resp.iter_lines(): + if not line.startswith("data: "): + continue + event = json.loads(line[6:]) + session_id = event["session_id"] + data = event["data"] + if data["event"] == "stream_msg": + print(data["output_schema"]["message"], end="", flush=True) + elif data["event"] == "input": + # 工作流等待输入:记录 message_id 与 input_schema,续跑时回传 + break + elif data["event"] == "close": + break +``` + +### 7.6 错误处理 + +业务错误以 HTTP 状态码 + 响应体中的 `status_code` 双重表达,两者都要检查: + +```python +def call_api(path: str, **kwargs) -> dict: + resp = httpx.post(f"{BASE_URL}{path}", **kwargs) + + if resp.status_code == 401: + raise RuntimeError("密钥无效或已撤销,请更换密钥") + if resp.status_code == 403: + body = resp.json() + code = body.get("status_code") + if code == 26003: + raise RuntimeError(f"密钥缺少所需权限位:{body.get('status_message')}") + if code in (26004, 26005, 26006, 26007): + raise RuntimeError(f"委托被拒绝:{body.get('status_message')}") + raise RuntimeError(f"资源权限不足:{body.get('status_message')}") + if resp.status_code >= 500: + # 权限评估失败等情况会返回 5xx,且绝不返回部分结果,可安全重试 + raise RuntimeError("服务端异常,请重试") + + resp.raise_for_status() + return resp.json()["data"] +``` + +--- + +## 8. 升级说明 + +### 8.1 这是一次破坏性变更 + +本版本发布即启用鉴权,**不提供兼容开关**:升级后所有 `/api/v2` 调用必须携带密钥,无密钥一律返回 401。 + +之所以不设过渡开关:本次改造关闭的是一条无需任何凭据即可调用、且可指定任意用户身份执行的通道。任何"默认关闭"的兼容开关都意味着升级后通道依然敞着,只有主动去翻开关的客户才真正受到保护。 + +**请在升级前完成调用方改造。** + +### 8.2 升级前必须完成的三步 + +| 步骤 | 动作 | +|---|---| +| 1 | 在平台创建服务账号,按最小必要授予其所需的知识资源、工作流、助手 | +| 2 | 为该服务账号签发密钥,按需勾选权限位;若集成需要代表平台用户执行,另配可代表范围 | +| 3 | 改造调用方代码:加 `Authorization: Bearer` 请求头;原先在请求参数里传 `user_id` 的调用改为 `X-Bisheng-On-Behalf-Of` 请求头 | + +### 8.3 变更清单 + +| 变更 | 影响 | 应对 | +|---|---|---| +| **全部端点要求密钥** | 无密钥调用返回 401 | 所有请求补 `Authorization` 头 | +| **请求参数中的裸 `user_id` 移除** | 原先在请求体或查询参数里传 `user_id` 指定执行身份的调用会返回参数错误 | 改用 `X-Bisheng-On-Behalf-Of` 请求头,并确保密钥已被授予 `delegate` 权限位与可代表范围 | +| **工作流上线状态校验** | 原先可调用草稿态工作流,现在会被拒绝 | 先在平台上线目标工作流 | +| **停止工作流校验会话归属** | 原先可停止任意会话,现在只能停自己的 | 无需改造,除非你的集成依赖了这个行为 | +| **原「默认操作员」不再作为兜底身份** | 原先所有调用共用一个数据库配置的固定用户,其权限即全部调用的权限边界 | 该用户会被转为服务账号并降权,名下已有资源的归属与可见性不变 | + +> 若你此前按旧文档的建议把默认操作员配置成了超级管理员,**升级后该身份会被降权**。请按 §8.2 重新规划服务账号的最小必要授权,不要试图恢复超管配置。 + +--- + +## 9. 附录 + +### 9.1 限制说明 + +| 项 | 限制 | +|---|---| +| 检索单次返回分段数(`top_k`) | 1 – 200,默认 10 | +| 检索单资源内容长度(`max_content`) | ≥ 1,默认 15000 字符 | +| 检索资源数量(`knowledge_base_ids`) | 至少 1 个 | +| 分段长度上限(`max_chunk_size`) | 默认 1000 字符 | +| 助手返回结果数(`n`) | 仅支持 1 | +| 会话历史每页数量(`page_size`) | 默认 20 | +| 消息评论长度(`comment`) | 4096 字符,超出截断 | +| 生成会话标题的服务端等待 | 约 5 秒,客户端超时须大于此值 | + +### 9.2 文件处理状态 + +| `status` | 含义 | +|---|---| +| `1` | 处理中 | +| `2` | 处理成功 | +| `3` | 处理失败 | +| `4` | 重建中 | +| `5` | 排队中 | +| `6` | 处理超时 | +| `7` | 内容安全违规 | + +### 9.3 问答对对象字段 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | integer | 问答对 ID。 | +| `knowledge_id` | integer | 所属 QA 知识库 ID。 | +| `questions` | string[] | 问法列表,包含原始问题与追加的相似问法。 | +| `answers` | string[] | 答案列表。 | +| `user_id` | integer | 创建人 ID。 | +| `source` | integer | 来源:`1` 页面录入,`2` 审核沉淀,`3` 接口创建,`4` 批量导入。 | +| `status` | integer | 状态:`1` 生效,`0` 已关闭,`2` 处理中,`3` 入库失败。 | +| `extra_meta` | string | 附加信息,创建时原样存储。 | +| `remark` | string | 备注。 | +| `create_time` | string | 创建时间。 | +| `update_time` | string | 更新时间。 | + +### 9.4 知识资源类型 + +| `type` | 类型 | +|---|---| +| `0` | 文档知识库 | +| `1` | QA 知识库 | +| `2` | 个人知识库 | +| `3` | 知识空间 | + +### 9.5 接口索引 + +「模式」列:**S** 服务账号模式,**D** 代表平台用户模式。 + +#### 知识库 + +| 接口 | 方法与路径 | 权限位 | 模式 | +|---|---|---|---| +| 获取知识资源列表 | `GET /filelib/` | `knowledge:read` | S / D | +| 创建知识资源 | `POST /filelib/` | `knowledge:write` | S / D | +| 更新知识资源 | `PUT /filelib/` | `knowledge:write` | S / D | +| 删除知识资源 | `DELETE /filelib/{knowledge_id}` | `knowledge:write` | S / D | +| 清空知识资源内容 | `DELETE /filelib/clear/{knowledge_id}` | `knowledge:write` | S / D | +| 检索知识资源分段 | `POST /filelib/retrieve` | `knowledge:read` | S / D | +| 上传文件到知识资源 | `POST /filelib/file/{knowledge_id}` | `knowledge:write` | S / D | +| 上传文件并附带元数据 | `POST /filelib/chunks` | `knowledge:write` | S / D | +| 上传文本内容 | `POST /filelib/chunks_string` | `knowledge:write` | S / D | +| 获取文件列表 | `GET /filelib/file/list` | `knowledge:read` | S / D | +| 删除文件 | `DELETE /filelib/file/{file_id}` | `knowledge:write` | S / D | +| 批量删除文件 | `POST /filelib/delete_file` | `knowledge:write` | S / D | +| 获取元数据字段 | `GET /knowledge/get_metadata_fields/{knowledge_id}` | `knowledge:read` | S / D | +| 添加元数据字段 | `POST /knowledge/add_metadata_fields` | `knowledge:write` | S / D | +| 修改元数据字段 | `PUT /knowledge/modify_metadata_fields` | `knowledge:write` | S / D | +| 删除元数据字段 | `DELETE /knowledge/delete_metadata_fields` | `knowledge:write` | S / D | +| 批量查询文件用户元数据 | `POST /knowledge/file/list_user_metadata` | `knowledge:read` | S / D | +| 添加文件用户元数据 | `POST /knowledge/file/add_user_metadata` | `knowledge:write` | S / D | +| 修改文件用户元数据 | `PUT /knowledge/file/modify_user_metadata` | `knowledge:write` | S / D | +| 删除文件用户元数据 | `DELETE /knowledge/file/delete_user_metadata` | `knowledge:write` | S / D | +| 新增问答对 | `POST /filelib/add_qa` | `knowledge:write` | S / D | +| 追加相似问法 | `POST /filelib/add_relative_qa` | `knowledge:write` | S / D | +| 修改问答对 | `POST /filelib/update_qa` | `knowledge:write` | S / D | +| 删除问答对 | `DELETE /filelib/qa/{qa_id}` | `knowledge:write` | S / D | +| 获取问答对详情 | `GET /filelib/detail_qa` | `knowledge:read` | S / D | +| 按时间范围查询问答对 | `POST /filelib/query_qa` | `knowledge:read` | S / D | +| 获取引用详情 | `GET /citation/{citation_id}` | `knowledge:read` | S / D | + +#### 工作流 + +| 接口 | 方法与路径 | 权限位 | 模式 | +|---|---|---|---| +| 调用工作流 | `POST /workflow/invoke` | `workflow:invoke` | S / D | +| 停止工作流 | `POST /workflow/stop` | `workflow:invoke` | S / D | +| 获取工作流信息 | `GET /flows/{flow_id}` | `workflow:read` | S / D | + +#### 助手 + +| 接口 | 方法与路径 | 权限位 | 模式 | +|---|---|---|---| +| 助手对话 | `POST /assistant/chat/completions` | `assistant:invoke` | S / D | +| 获取助手详情 | `GET /assistant/info/{assistant_id}` | `assistant:read` | S / D | +| 获取助手列表 | `GET /assistant/list` | `assistant:read` | S / D | + +#### 会话记录 + +| 接口 | 方法与路径 | 权限位 | 模式 | +|---|---|---|---| +| 获取会话历史 | `GET /chat/history` | `session:read` | S / D | +| 生成会话标题 | `POST /chat/gen_title` | `session:read` | S / D | +| 消息点赞与点踩 | `POST /chat/liked` | `session:write` | S / D | +| 标记会话解决状态 | `POST /chat/solved` | `session:write` | S / D | +| 提交消息评论 | `POST /chat/comment` | `session:write` | S / D | +| 回填会话消息 | `POST /chat/sync/messages` | `session:sync` | S / D | diff --git a/docs/architecture/05-linsight-agent.md b/docs/architecture/05-linsight-agent.md index 7d879e225c..11a888481b 100644 --- a/docs/architecture/05-linsight-agent.md +++ b/docs/architecture/05-linsight-agent.md @@ -173,7 +173,7 @@ Agent 的推理和执行逻辑实现在 `src/backend/bisheng_langchain/linsight/ - **任务树构建**(`rebuild_tasks`):将 LLM 生成的任务 JSON 实例化为 `Task`(Function Calling 模式)或 `ReactTask`(ReAct 模式),处理父子关系和执行顺序。 - **工具 Schema 增强**:所有工具的参数 Schema 中自动注入 `call_reason` 必填字段,要求 LLM 在调用工具时说明原因。同时添加内置的 `call_user_input` 伪工具,用于触发用户输入事件。 - **执行调度**(`ainvoke_task`):串行执行根任务,通过内部异步队列(`aqueue`)收集事件并向上层传递。 -- **历史管理**:当工具调用历史超过 `tool_buffer` 配置的 token 数时,自动调用 LLM 进行摘要压缩。 +- **历史管理**:(2.0 引擎)当工具调用历史超过 `tool_buffer` 配置的 token 数时,自动调用 LLM 进行摘要压缩。迁移到 deepagents 后这套机制已退役,`tool_buffer` 也已删除;现在由模型上下文长度决定压缩时机,见 [F087 design](../../features/v3.0.0/087-context-window-budget/design.md)。 ### 执行模式 @@ -203,6 +203,8 @@ linsight:task:owner:{session_version_id} # 任务归属 所有状态数据的 Redis 过期时间为 3600 秒(1 小时)。核心操作均带有重试机制(3 次重试,间隔 1 秒)。 +任务事件仍由工作进程写入上述 Redis List。每条 WebSocket 先订阅会话版本对应的 Redis 广播通道,再通过单键原子操作把队首事件发布给所有在线订阅者并确认移除。网页与桌面、不同 API 进程各自接收完整事件;客户端断开时立即释放该连接的订阅。连接空闲时按 15 秒间隔核对数据库终态,已完成、失败或终止的任务通过原有终态事件收尾,沿用现有前端消息合同。 + ### 消息事件类型(MessageEventType) | 事件类型 | 含义 | @@ -317,6 +319,40 @@ MCP(Model Context Protocol)集成模块位于 `src/backend/bisheng/mcp_manag } ``` +### 远程示例:Parallel Search + +[Parallel Search MCP](https://docs.parallel.ai/integrations/mcp/search-mcp) 提供 `web_search`(搜索公开网页)和 `web_fetch`(提取网页内容),匿名连接不需要 Parallel 账号或 API Key,免费使用有速率限制。 + +在工具页面选择「MCP工具」→「添加MCP服务器」,填写服务名称,并将下面的 JSON 粘贴到 MCP 服务配置中。`type` 必须为 `streamable`;省略后会按 SSE 连接,无法使用此端点。每次添加一个服务器: + +```json +{ + "mcpServers": { + "parallel-search": { + "type": "streamable", + "name": "Parallel Search", + "description": "搜索公开网页并提取网页内容", + "url": "https://search.parallel.ai/mcp" + } + } +} +``` + +配置框失去焦点或点击刷新时,BiSheng 就会连接 Parallel 获取工具列表,不必等到保存。确认「可用工具」中出现 `web_search` 和 `web_fetch` 后,可点击工具旁的测试按钮。在 `web_search` 的测试窗口,按参数分别填写下面的值,`search_queries` 使用 JSON 数组: + +```json +{ + "objective": "查找 Parallel Search MCP 的官方使用文档", + "search_queries": ["Parallel Search MCP documentation"] +} +``` + +保存后,在需要联网检索的 Agent 或工作流中选择这些工具。Agent 获得工具后可能自行调用;搜索词、请求的 URL,以及传入的目标、上下文和元数据会发送给 Parallel。请只传入允许发送给该服务的内容。此配置不会替换现有搜索工具或更改权限,也不需要设置认证请求头。 + +企业部署前,请确认服务端可以访问 `https://search.parallel.ai`,且符合组织的外网访问和数据出境策略;未经授权,不要传入企业敏感数据。匿名免费额度适合探索和轻量使用,生产环境如需更高额度,可按官方文档配置 API Key 认证。 + +停止使用时,先从相关 Agent 或工作流中移除这些工具;不再需要该连接时,可由有权限的用户在 MCP 服务器编辑页删除它。 + ### 传输层实现 所有客户端继承自 `BaseMcpClient` 抽象基类,统一实现 `get_transport()` 方法返回读写流,由基类的 `initialize()` 方法建立 `ClientSession`。 @@ -369,16 +405,12 @@ LangChain StructuredTool | 参数 | 默认值 | 说明 | |------|--------|------| | `debug` | `false` | 调试模式,开启后记录 LLM 输入输出 | -| `tool_buffer` | `100000` | 工具执行历史的最大 token 数,超过后自动摘要 | | `max_steps` | `200` | 单个任务最大执行步骤数,防止死循环 | | `retry_num` | `3` | 模型调用失败重试次数 | | `retry_sleep` | `5` | 重试间隔(秒) | -| `max_file_num` | `5` | SOP 生成时 prompt 中放入的用户文件数量 | -| `max_knowledge_num` | `20` | SOP 生成时 prompt 中放入的知识库数量 | | `default_temperature` | `0` | 默认模型温度 | -| `retry_temperature` | `1` | ReAct 模式 JSON 解析失败后重试时的模型温度 | -| `file_content_length` | `5000` | 拆分子任务时读取文件内容的字符数上限 | -| `max_file_content_num` | `3` | 拆分子任务时读取的中间过程文件数量 | + +> 压缩时机不再由配置项决定:任务模式按所用模型的上下文长度计算(模型高级配置 `context_window`、Ollama 上下文长度字段或服务商接口),见 [F087 design](../../features/v3.0.0/087-context-window-budget/design.md)。`tool_buffer`、`max_file_num`、`max_knowledge_num`、`retry_temperature`、`file_content_length`、`max_file_content_num` 已于 F087 删除,旧配置里留着也不影响启动。 ### McpConf diff --git a/docs/architecture/06-frontend-architecture.md b/docs/architecture/06-frontend-architecture.md index 8522f8b11f..0ae7bf682f 100644 --- a/docs/architecture/06-frontend-architecture.md +++ b/docs/architecture/06-frontend-architecture.md @@ -10,7 +10,7 @@ BiSheng 前端由两个独立的 React 应用构成:**Platform**(管理端 | 包名 | bisheng | bishengchat | | 版本 | 2.4.0_beta1 | 2.4.0 | | React | 18.3.1 | 18.2.0 | -| Vite | 5.3.1 (SWC 编译) | 6.3.6 | +| Vite | 6.4.x(Babel React 插件) | 6.4.x(Babel React 插件) | | 开发端口 | 3001 | 4001 | | 基础路径 | `/`(可配置) | `/workspace` | | 构建输出 | `build/` | `build/` | diff --git a/docs/architecture/08-deployment.md b/docs/architecture/08-deployment.md index 822a32177d..4cbf6c99c4 100644 --- a/docs/architecture/08-deployment.md +++ b/docs/architecture/08-deployment.md @@ -14,7 +14,7 @@ BiSheng 采用 Docker Compose 编排全部基础设施和应用服务。生产 | `bisheng-backend-worker` | `dataelement/bisheng-backend:v2.4.0` | (无) | Celery 全部 Worker + Beat,通过 `entrypoint.sh worker` 启动 | | `bisheng-frontend` | `dataelement/bisheng-frontend:v2.4.0` | 3001:3001 | Nginx 托管前端静态资源 | | `bisheng-milvus-etcd` | `quay.io/coreos/etcd:v3.5.5` | (无) | Milvus 元数据存储(ETCD) | -| `bisheng-milvus-minio` | `minio/minio:RELEASE.2023-03-20T20-16-18Z` | 9100:9000, 9101:9001 | Milvus 数据存储(MinIO),同时作为业务对象存储 | +| `bisheng-milvus-minio` | `minio/minio:RELEASE.2023-03-20T20-16-18Z` | 9100:9000 | Milvus 数据存储(MinIO),同时作为业务对象存储。管理台端口 9001 不对外映射,见 [对象存储凭证与端口](#对象存储凭证与端口) | | `bisheng-milvus-standalone` | `milvusdb/milvus:v2.5.10` | 19530:19530, 9091:9091 | 向量数据库,依赖 ETCD 和 MinIO | | `bisheng-es` | `bitnamilegacy/elasticsearch:8.12.0` | 9200:9200, 9300:9300 | 全文检索引擎 | @@ -43,7 +43,6 @@ bisheng-milvus-standalone | 文件 | 容器名 | 镜像 | 端口 | 用途 | |------|--------|------|------|------| -| `docker-compose-ft.yml` | `bisheng-ft-server` | `dataelement/bisheng-ft:v0.5.0` | 8000 | 模型微调服务,需要 GPU(nvidia driver) | | `docker-compose-uns.yml` | `bisheng-unstructured` | `dataelement/bisheng-unstructured:v0.0.3.14` | 10001 | 非结构化文档解析服务 | | `docker-compose-office.yml` | `bisheng-office` | `onlyoffice/documentserver:7.1.1` | 8701:80 | OnlyOffice 文档预览与编辑 | @@ -78,6 +77,39 @@ bisheng-milvus-standalone └─────────────────┘ ``` +### 对象存储凭证与端口 + +MinIO 保存全部上传文档、知识库文件、报告模板与对话附件,Milvus 的向量段也存在同一个 bucket 里。 + +**凭证来自 `docker/.env`,compose 不提供默认值**(从 `docker/.env.example` 复制): + +```bash +cd docker && cp .env.example .env +echo "MINIO_ROOT_USER=$(openssl rand -hex 8)" >> .env +echo "MINIO_ROOT_PASSWORD=$(openssl rand -hex 16)" >> .env +``` + +未设置时 `docker compose up` 直接拒绝启动并提示变量名。这一份凭证被 MinIO 服务、后端、异步 worker 和 Milvus 四处引用,compose 已统一注入,无需手工同步。**Milvus 尤其要注意**:不给它凭证时它会回落到内置的厂商默认值,服务照常启动、健康检查照常通过,只有读取向量集合时才报错。轮换凭证后务必做一次知识库检索验证。 + +**端口**: + +| 端口 | 说明 | +|---|---| +| 9100 → 9000 | 数据接口,**保留映射**。文档服务(OnlyOffice)是独立的 compose 项目,访问不到容器网络里的 `minio`,报告模板必须通过宿主机地址下载,因此 `BS_MINIO_SHAREPOINT` 需指向宿主机。请限制可访问来源并配置 TLS | +| 9101 → 9001 | 管理台,**不映射**。它是覆盖全部文件的网页界面,技术栈中无组件依赖它。排查问题用 SSH 隧道:`ssh -L 9001:localhost:9001 `,再访问 `http://localhost:9001` | + +**从 3.0.0-beta3 之前的版本升级** + +旧版 compose 写死 `minioadmin/minioadmin` 并映射了 9100、9101 两个端口。上面的凭证要求**只作用于我们随包发的 compose 模板**,代码不依赖特定凭证,所以**已有部署保持原样也能正常运行**——升级镜像不会覆盖你自己那份 compose。但那样口子也原样保留。 + +分两步收,不要一起做: + +**第一步(必做,零风险)**:在现有 compose 里删掉 `- "9101:9001"`,执行 `docker compose up -d minio` 重建该容器。不碰凭证、不碰数据、不影响任何功能——技术栈中没有组件访问管理台。需要管理台时改用 SSH 隧道。 + +**第二步(可选,需停机窗口)**:轮换凭证。凭证必须在 MinIO 服务、后端、worker、**Milvus** 四处同时更新。Milvus 最易遗漏:旧 compose 没给它传凭证,它用的是内置默认值;只改前三处会导致它连不上,而**容器照常启动、健康检查照常通过**,只有检索知识库时才报错。换完务必做一次知识库检索验证,不要只看容器状态。 + +**不要整份替换 compose**:现有文件通常已改过镜像 tag、端口、`BS_MINIO_SHAREPOINT` 等,整份覆盖会冲掉这些改动。按上面两步挑行修改即可。 + ### 代码节点开关 工作流「代码节点」默认关闭,由系统配置控制: @@ -200,26 +232,18 @@ npm start -- --host 0.0.0.0 # 端口 3001,API 代理到 localhost:7860 ## 远程开发工作流 -项目支持本地编辑、远程运行的开发模式。代码在本地 Mac 编辑,通过 rsync 同步到远程服务器执行。 - -### 同步脚本 - -项目根目录的 `bisheng-sync.sh` 提供三种同步模式: - -| 命令 | 说明 | -|------|------| -| `./bisheng-sync.sh up` | 本地代码推送到远程服务器 | -| `./bisheng-sync.sh down` | 远程代码拉取到本地 | -| `./bisheng-sync.sh watch` | 监听本地文件变化,自动推送到远程 | +个人开发机用「git 检出 + systemd」部署:目标机上的 `/opt/bisheng` 是真实的 git 检出,部署就是把它收敛到分支头再重启服务,`git rev-parse HEAD` 就是这台机器的版本。 -### 日常流程 +旧的 rsync 同步脚本 `bisheng-sync.sh`(个人本地文件,已被 `.gitignore` 排除)**不要再用来部署**。rsync 只覆盖有改动的文件,目标机上更早落后的文件和已删除的文件都不会被处理,「目标机是否等于某个提交」无法回答。 -1. 终端 A 常驻 `./bisheng-sync.sh watch`,监听文件变化自动同步 -2. 本地 IDE(Claude Code / Cursor 等)编辑 `/Users/lilu/Projects/bisheng` 下的代码 -3. 改动自动推送到远程服务器,远程进程加载新代码 -4. 浏览器通过 `http://192.168.106.114:8860` 访问(Nginx 反向代理) +### 114(192.168.106.114) -注意:如果未开启 watch 模式,修改代码后需手动执行 `./bisheng-sync.sh up` 推送到远程。 +| 项 | 内容 | +|----|------| +| 部署 | `bash /opt/bisheng-ops/deploy.sh`(默认分支 `feat/3.0.0-vibe`)。脚本和运维说明在独立的运维仓 `/opt/bisheng-ops/`,不进产品仓,先读它的 README | +| 后端 | 6 个 systemd unit(`bisheng-api`、4 个 `bisheng-celery-*`、`bisheng-linsight-worker`)+ `bisheng.target` | +| 入口 | `http://192.168.106.114:4101/`(管理端,vite 开发服务)、`/workspace/`(用户端静态包)、`/apps//`(托管应用) | +| 托管应用运行时层 | 2026-10-27 起以容器运行(协议 v2 + 宿主隔离),`deploy.sh` 不更新它;换镜像的步骤见运维仓 README。为什么不能再用 systemd 形态见 [14-app-factory-deployment.md](14-app-factory-deployment.md) 「形态二」 | ## Celery Worker 启动模式 @@ -249,18 +273,79 @@ npm start -- --host 0.0.0.0 # 端口 3001,API 代理到 localhost:7860 ## 运维管理脚本 -`docker/deploy.sh` 提供常用运维操作的快捷命令: +`scripts/deploy.sh` 提供常用运维操作的快捷命令: | 命令 | 用法 | 说明 | |------|------|------| -| `logs` | `./deploy.sh logs backend [-n 200]` | 实时跟踪容器日志 | -| `version` | `./deploy.sh version [v3.0.0]` | 查看或修改镜像版本号 | -| `exec` | `./deploy.sh exec backend` | 进入容器 Shell | -| `update` | `./deploy.sh update [backend]` | 拉取最新镜像并重启 | -| `restart` | `./deploy.sh restart [backend worker]` | 重启指定服务 | +| `logs` | `bash scripts/deploy.sh logs backend [-n 200]` | 实时跟踪容器日志 | +| `version` | `bash scripts/deploy.sh version [v3.0.0]` | 查看或修改镜像版本号 | +| `exec` | `bash scripts/deploy.sh exec backend` | 进入容器 Shell | +| `update` | `bash scripts/deploy.sh update [backend]` | 拉取最新镜像并重启 | +| `restart` | `bash scripts/deploy.sh restart [backend worker]` | 重启指定服务 | 支持的 service 别名:`backend`、`worker`(backend_worker)、`frontend`、`mysql`、`redis`、`es`(elasticsearch)、`minio`、`milvus`、`etcd`。 +### Existing-environment updates + +Compose image references live in `.env`; use the template when adding new +settings manually. `BISHENG_SANDBOX_IMAGE` replaces `SANDBOX_IMAGE_TAG`. + +Use `bash scripts/deploy.sh` from the matching release checkout to update an existing Compose +deployment. Keep the release's Compose file, mounted entrypoint and image versions +together. Run one deployment job in a maintenance window; control ingress and +queue consumers according to the environment's release procedure. + +### Work knowledge retrieval + +The backend image must include the Work knowledge registration script from PR +#2546. Set the intended image version with `bash scripts/deploy.sh version `, +then preview the target image against the service's existing configuration: + +```bash +bash scripts/deploy.sh upgrade-preview +``` + +This pulls the configured backend image and runs a read-only catalog/permission +preview in a disposable container with the backend service's mounts, environment +and network. Existing infrastructure must be running. The preview requires a +compatible database schema and CURRENT F048 permission runtime; complete their +version-specific prerequisites when the preview reports an error. + +Use the preview's Store ID and the actual audit operator's user ID to update: + +```bash +bash scripts/deploy.sh update --confirm-store-id --operator-id +# To update only the backend: +bash scripts/deploy.sh update backend --confirm-store-id --operator-id +``` + +For an update containing `backend`, the release script: + +1. Validates the required confirmation arguments before Docker operations. +2. Pulls the selected images, starts the backend and waits up to 180 seconds for + readiness. The API entrypoint owns the schema migration. +3. Executes the knowledge tool's registration and permission reconciliation using + the running backend's configuration. Apply exits successfully only after the + tool exists and its permission projection is CURRENT. +4. Updates the remaining selected services, waits for readiness, and reports + completion after the service status command succeeds. + +Any failed stage returns a nonzero exit status and stops subsequent stages. If +knowledge maintenance fails, the backend has already been updated; maintain the +release window, resolve the reported error and repeat the same update command. +Registration and permission reconciliation support repeated execution. The +deployment script leaves schema/data recovery to the release operator. + +Worker-only and frontend-only updates retain their selected service scope. +`restart` restarts the existing deployment. Direct `docker compose up` and external +deployment pipelines must explicitly run the same maintenance operation and +require its successful exit before declaring their release complete; see +[`src/backend/scripts/README.md`](../../src/backend/scripts/README.md). + +After deployment, verify ordinary-user tool discovery and single-space plus K1/K2 +multi-space retrieval with the expected document citations. Deployment checks and +real-account acceptance are recorded separately. + ## 升级 checklist > 跨版本升级时,除 `alembic upgrade head`(DDL)外,部分版本还引入了**一次性数据迁移 / backfill 脚本**。这类数据操作按约定不放进 Alembic(见 `src/backend/CLAUDE.md`「Migration vs. script」),需在升级后**手动按序执行**。脚本均**默认 dry-run、可重复执行(幂等)**,建议先空跑看摘要再加 `--apply`。脚本详细说明见 `src/backend/scripts/README.md`。 @@ -340,8 +425,22 @@ ownership)。横向扩容时按下表核对。 [C8](../constitution.md#c8-no-shared-state-on-the-local-filesystem)。单机 compose 下 `backend` 与 `backend_worker` 共享同一个 `/app/data`,会让「跨进程传文件」看起来能用——这是巧合,不是保证。 +## 备份 + +权威存储只有三处,备份也只看这三处:**MySQL / DM8**(全部业务元数据)、**MinIO**(知识库文件、 +灵思技能 bundle、托管应用代码包等对象)、**Redis**(缓存与队列,可丢,重启后自愈)。节点本地磁盘 +(`/app/data`、进程缓存目录)不在备份范围内——它们是缓存,见上文「多节点部署」。 + +装了应用工场运行时层的环境**多一处**:托管应用的 SQLite 库放在 runtime-manager 所在机器的本机磁盘 +(`{data_root}/apps/{app_id}/db/`),既不在 MySQL 也不在 MinIO 里,平台本体的备份**覆盖不到它**。 +它的快照方法(WAL 库不能裸 tar)、对象存储里哪些前缀属于托管应用、以及恢复顺序,见 +[`14-app-factory-deployment.md`](14-app-factory-deployment.md) 的「备份与恢复」一节。 + ## 相关文档 - 系统架构总览 -- `docs/architecture/01-architecture-overview.md` - 配置系统详解 -- Settings 类定义在 `src/backend/bisheng/core/config/settings.py` - 开发指南 -- `docs/architecture/09-development-guide.md` +- **应用工场部署与配置** -- `docs/architecture/14-app-factory-deployment.md`(v3.0 新增的托管应用 + 运行时层:runtime-manager / app-proxy 两个服务、`/apps/` 的 nginx location、`app_runtime` 与 + `open_platform` 配置项、⚠️ 加键与发版的先后顺序。**这一层默认不安装**,不装时本文其余内容不受影响) diff --git a/docs/architecture/10-permission-rbac.md b/docs/architecture/10-permission-rbac.md index 3cf3d24001..5f93ea203b 100644 --- a/docs/architecture/10-permission-rbac.md +++ b/docs/architecture/10-permission-rbac.md @@ -1,7 +1,7 @@ # 用户、菜单与资源权限架构 > 现状版本:v3.0.0-beta1 / F048 -> 最后更新:2026-08-13 +> 最后更新:2026-10-28 > 规范来源:`features/v3.0.0-beta1/048-rebac-permission-model-grants/` BiSheng 的权限体系分为三个互不替代的层面: @@ -505,6 +505,29 @@ draft 绑定 base release、impact checksum、过期时间和 idempotency key。 roster 使用 opaque cursor,page size 1~100。mutation 支持 ADD / MOVE / REMOVE,必须携带 resource、Catalog 和 assignee 乐观版本。 +#### 8.2.1 部门授权候选范围 + +`/resources/{type}/{id}/grant-subjects/departments/children`、`search` 和 +`{dept_id}/path-tree` 检查资源的 `manage_permission`,不要求调用者拥有 +部门管理身份。候选范围由已验证资源的租户确定;部门知识空间再与绑定 +部门子树取交集。创建知识空间、频道时的部门授权候选复用同一范围逻辑。 + +租户根以 `tenant.root_dept_id` 为准。挂载子租户不会改写部门原来的 +`parent_id`、`tenant_id`,因此不能用 `parent_id IS NULL` 或部门存储租户 +等于资源租户来识别其根节点。`GrantDepartmentRepository` 验证根指针、 +挂载双向关系及活动状态后,按根部门的存储租户和物化路径限定查询; +首层返回该范围根,展开只接受范围内的父部门,搜索和定位截断根之上的 +祖先。`has_children` 同样在此范围内计算,祖先和子节点存在性查询按 +100 个 ID 分批读取。 + +保存部门授权时也复用该租户范围校验,再进入原有 F048 Grant mutation +和投影流水。失效根指针、归档根、异常路径或不一致挂载关系返回空候选, +对应非法部门授权被拒绝,不回退为无边界查询。 + +回归入口:`test/tenant/test_grant_department_scope.py`。其 SQLite SQL、 +ASGI 接口及 mutation 调用链测试覆盖挂载根、独立根、部门空间限制、 +普通用户权限入口和跨租户拒绝;真实 OpenFGA、MySQL/DM8 验证仍按 §14。 + ### 8.3 决策 `POST /check` 接收 `resource_type`、`resource_id`、具体 action。endpoint @@ -580,6 +603,62 @@ model A/B 或第二次迁移。迁移失败时保持应用迁移门禁和 F048 r --- +## 10.1 存量环境新增业务资源类型(F054 `app`) + +「给 F048 加一种业务资源类型」在**全新安装**上不需要任何操作——模型由代码常量在首启时构建。 +但在**已经跑过 §10 迁移的存量环境**上,只改代码会让全站权限直接不可用,原因有两条且互相独立: + +1. **授权模型 checksum 变了**。每个进程把代码构建出的模型与 SQL 控制面 pin 的那个比对, + 不一致就不发 ready 心跳、所有权限判定一律 503 —— 不只是新类型,是**全部**。 +2. **运行期没有任何代码路径写 `permission_action_resource_scope`**。该表只在首次迁移与 + Catalog 草稿发布两处写入,而 `CatalogChangeType` 的 7 个取值**没有一个能改 + `resource_types`**。不补这批行,`is_action_effective(<新类型>, …)` 的 SQL join 永远为假, + 任何 `check_business_action` 都抛 `InvalidCatalogActionError`。 + +因此存量生效是一个**四步**过程,入口只有一个脚本: + +```text +src/backend/scripts/upgrade_f048_authorization_model.py (包装器 .sh,默认 dry-run) +``` + +1. **(控制面 HTTP,事务外)** 在同一 Store 内按 canonical checksum 查重后发布新模型 M2; + 已存在同 checksum 的模型则直接沿用(这也是重跑幂等的根据)。 +2. `authorization_model_release` 插入新 ACTIVE 行(`model_version=f048-v2`、 + `predecessor_model_id=M1`、重算 `required_relations_checksum`),M1 行置 RETIRED。 +3. CURRENT `permission_catalog_release.required_authorization_model_release_id` 指向新行。 +4. 对目标 action 补 `permission_action_resource_scope(action_id, <新类型>)` 并重算 release checksum。 + +**步骤 2–4 在同一个 SQL 事务**,中途异常整体回滚;步骤 1 是一次 HTTP 写、进不了 SQL 事务。 +这条边界不是洁癖:只要「指针已改、scope 行没补」这个中间态落了地,所有进程都会加载一份 +「新类型下没有任何 action 生效」的 Catalog,而且这个状态在任何日志里都读起来像"升级成功"。 + +```bash +cd src/backend/ +export config=config.yaml # 必须与线上服务同一个值 + +bash scripts/upgrade_f048_authorization_model.sh # plan:只读,什么都不写 +bash scripts/upgrade_f048_authorization_model.sh apply +bash scripts/upgrade_f048_authorization_model.sh verify +``` + +**三件必须写死的事**: + +- **跑完必须重启全部进程**(API / celery×3 / beat / linsight worker)。心跳 15s 复核、TTL 45s, + 没重启的旧进程不是"还能用",而是会自行 fail-closed。 +- **先发代码、后往 `config.yaml` 加新键**。`load_settings_from_yaml` 遇未知顶层键直接 `KeyError` + 拒绝启动,顺序反了的现象是后端起不来,而不是功能不可用。 +- **存量环境先 `plan` 再 `apply`**。 + +**回滚语义**(别被"事务"两字误导):`rollback` 子命令把 ACTIVE 指针指回 M1 行、撤掉新类型的 +scope 行,然后同样要重启全部进程。**步骤 1 写进 Store 的 M2 回滚不掉**——OpenFGA 的授权模型 +不可变、不可删,M2 会变成一个没人 pin 的孤儿。这无害,恰恰是因为步骤 1 按 checksum 查重: +下一次 `apply` 会认领这个孤儿,而不是再发一个。 + +**不要用 `force_write_model`**:它只写 OpenFGA 不写 SQL、不查重(每次重启多一个重复 model)、 +且生产环境禁用——三条各自都足以让它当不了升级手段。 + +--- + ## 11. 一致性、性能与可观测 ### 11.1 一致性 diff --git a/docs/architecture/11-gateway.md b/docs/architecture/11-gateway.md index a346367417..be27a9f690 100644 --- a/docs/architecture/11-gateway.md +++ b/docs/architecture/11-gateway.md @@ -499,12 +499,15 @@ npm start # 远程服务器路径 # /opt/bisheng-gateway/ on 192.168.106.114 -# 同步脚本(项目根目录 gateway-sync.sh) +# 同步脚本(bisheng-gateway 仓根目录的 gateway-sync.sh,不在 bisheng 仓) ./gateway-sync.sh up # 本地 → 远程 ./gateway-sync.sh down # 远程 → 本地 ./gateway-sync.sh watch # 监听变化,自动推送 ``` +> 2026-10-27 核实:114 上只保留了 `/opt/bisheng-gateway/` 目录,Gateway 没有在运行(8180 无监听), +> bisheng 后端以 `BISHENG_PRO=false` 启动。要在 114 联调商业版,先启动 Gateway,再把后端各 unit 的 `BISHENG_PRO` 改回 `true`。 + ### 11.3 数据库 Gateway 的 4 张表(`gt_*`)部署在独立数据库 `bisheng_gateway` 中(也可与 bisheng 主库同实例不同 schema)。表结构通过 MyBatis-Plus 的 `@TableName` 注解映射,无自动迁移机制,需手动建表。 diff --git a/docs/architecture/12-multi-tenant.md b/docs/architecture/12-multi-tenant.md index 60688a7a32..cd1ac2d1a5 100644 --- a/docs/architecture/12-multi-tenant.md +++ b/docs/architecture/12-multi-tenant.md @@ -1,6 +1,20 @@ # 多租户架构 -BiSheng v2.5.0 引入了基于**逻辑隔离**的多租户架构,在同一数据库实例内通过 `tenant_id` 列实现数据隔离,覆盖 44 张业务表和 5 种存储引擎(MySQL、Redis、Milvus、Elasticsearch、MinIO)。系统支持两种运行模式:**单租户模式**(`enabled=false`,向后兼容 v2.4.x)和**多租户模式**(`enabled=true`,强制租户上下文)。租户上下文通过 Python `ContextVar` 在请求作用域内传播,SQLAlchemy 事件钩子自动完成 SELECT 过滤和 INSERT 填充,开发者编写普通 ORM 代码即可获得租户隔离。 +> **核实基准**:本文依据 `feat/3.0.0-beta1` 分支代码逐条核实(2026-08-05)。 +> v2.5.0 建立扁平多租户基线,v2.5.1 重写为两层租户树并废弃用户手工切换,v2.6.0-beta2 撤销业务资源跨租户共享。本文描述**当前实现**,不是设计意图——凡实现与 v2.5 PRD 不符处均以代码为准并显式标注。 + +BiSheng 多租户采用**逻辑隔离**:所有租户共享同一数据库实例,通过 `tenant_id` 列区分数据。租户上下文经 `ContextVar` 在请求作用域传播,SQLAlchemy 事件钩子自动完成 SELECT 过滤与 INSERT 填充。 + +**隔离强度的真实边界**(务必先读这一条): + +| 存储 | 隔离机制 | 强度 | +|------|----------|------| +| MySQL | ORM 事件自动注入 `WHERE tenant_id IN (...)` | ✅ 强隔离,唯一真正生效的一层 | +| Milvus / ES | 无租户前缀;collection/index 名建库时随机生成,靠"拿不到 MySQL 里的名字就访问不到"间接隔离 | ⚠️ 间接 | +| MinIO | 无租户前缀(写侧不加前缀) | ⚠️ 靠 MySQL 侧的文件记录间接约束 | +| Redis | 无租户前缀,key 全局共用 | ❌ 不隔离 | + +`core/storage/tenant_storage.py` 中的 4 个前缀函数(MinIO / Milvus / ES / Redis)**在生产代码中零调用方**,详见 §8。任何对外材料都不得声称"五种存储各自带租户前缀隔离"。 --- @@ -8,631 +22,537 @@ BiSheng v2.5.0 引入了基于**逻辑隔离**的多租户架构,在同一数 ### 设计原则 -- **逻辑隔离**:所有租户共享同一数据库实例,通过 `tenant_id` 列区分数据。选择逻辑隔离而非物理隔离(独立库/独立实例)的原因:降低运维复杂度、支持跨租户聚合查询、简化部署 -- **向后兼容**:默认租户(id=1)在所有外部存储中不添加前缀,与 v2.4.x 的数据路径完全一致 -- **透明接入**:业务代码无需手动添加 `WHERE tenant_id=X`,SQLAlchemy 事件钩子自动注入 +- **逻辑隔离**:共享数据库实例,`tenant_id` 列区分。不支持独立库/独立实例的物理隔离(v2.5.0 F001 明确推迟到 v3.x,至今未做) +- **向后兼容**:默认租户(id=1,即 Root)数据路径与 v2.4.x 完全一致 +- **透明接入**:业务代码写普通 ORM,事件钩子自动注入过滤 -### 租户隔离全链路 +### 租户隔离链路 ```mermaid flowchart TB - subgraph 客户端 - Browser["浏览器"] - end + Browser["浏览器"] subgraph 中间件层 - HTTP["CustomMiddleware
JWT Cookie → tenant_id"] + HTTP["CustomMiddleware
JWT → tenant_id + visible_tenant_ids"] + SCOPE["AdminScopeMiddleware
超管管理视角覆盖"] WS["WebSocketMiddleware
ASGI Cookie → tenant_id"] end - subgraph 上下文 - CV["ContextVar
current_tenant_id"] + subgraph 上下文 ContextVar + CV["current_tenant_id
visible_tenant_ids
_admin_scope_tenant_id
_strict_tenant_filter"] end subgraph 数据层 - SQLFilter["SQLAlchemy Events
tenant_filter.py
SELECT → WHERE tenant_id=X
INSERT → 自动填充 tenant_id"] - StoragePrefix["Storage Prefix
tenant_storage.py"] + SQLFilter["tenant_filter.py
SELECT → WHERE tenant_id IN (...)
INSERT → 自动填充"] end - subgraph 存储引擎 - MySQL["MySQL
WHERE tenant_id=X"] - MinIO["MinIO
tenant_{code}/"] - Milvus["Milvus
t{id}_collection"] - ES["ES
t{id}_index"] - Redis["Redis
t:{id}:key"] - end + MySQL["MySQL ✅ 强隔离"] + Other["MinIO / Milvus / ES / Redis
⚠️ 无前缀,间接隔离"] - subgraph Celery异步任务 - Publish["before_task_publish
headers.tenant_id=X"] - PreRun["task_prerun
set_current_tenant_id(X)"] - PostRun["task_postrun
reset to None"] + subgraph Celery + Publish["before_task_publish
headers.tenant_id"] + PreRun["task_prerun → set_current_tenant_id"] end - Browser --> HTTP - Browser --> WS - HTTP --> CV - WS --> CV - CV --> SQLFilter - CV --> StoragePrefix - CV --> Publish - SQLFilter --> MySQL - StoragePrefix --> MinIO - StoragePrefix --> Milvus - StoragePrefix --> ES - StoragePrefix --> Redis - Publish --> PreRun --> PostRun + Browser --> HTTP --> SCOPE --> CV + Browser --> WS --> CV + CV --> SQLFilter --> MySQL + CV -.前缀函数未接线.-> Other + CV --> Publish --> PreRun ``` --- ## 2. 配置与运行模式 -### 配置模型 - ```python -# src/backend/bisheng/core/config/multi_tenant.py +# core/config/multi_tenant.py class MultiTenantConf(BaseModel): - enabled: bool = Field(default=False) # 是否启用多租户 - default_tenant_code: str = Field(default='default') # 默认租户编码 + enabled: bool = False + default_tenant_code: str = 'default' + admin_scope_ttl_seconds: int = 14400 # F019 管理视角 Redis TTL,滑动刷新 ``` -### config.yaml - ```yaml +# config.yaml multi_tenant: - enabled: false + enabled: true default_tenant_code: "default" ``` -### 两种运行模式 +| 维度 | `enabled=false` | `enabled=true` | +|------|----------------|---------------| +| 无上下文时 | 回退 `DEFAULT_TENANT_ID=1`,不报错 | 抛 `NoTenantContextError`(20004) | +| 登录流程 | 跳过整块租户逻辑 | 走 TenantResolver 派生叶租户 | +| 工作台配置 | 回落全局 `config` 表 | 写 `tenant_workstation_config` | +| Milvus 跨租户扩展 | `aexpand_with_root_shared` 原样返回 | (该方法本身无生产调用方,见 §8) | + +**两处缺 enabled 守卫**(`enabled=false` 时仍会执行,属已知瑕疵): -| 维度 | 单租户模式 (`enabled=false`) | 多租户模式 (`enabled=true`) | -|------|---------------------------|--------------------------| -| 默认行为 | 所有查询自动使用 `DEFAULT_TENANT_ID=1` | 必须在请求上下文中设置 `tenant_id` | -| 无上下文时 | 回退到默认租户,不报错 | 抛出 `NoTenantContextError`(20004) | -| 登录流程 | 直接分配 `tenant_id=1` | 查询用户关联租户,可能需要选择 | -| 存储前缀 | 永远为空(原路径) | 默认租户为空,新租户加前缀 | -| 适用场景 | v2.4.x 升级后的默认模式 | 企业集团多组织部署 | +- `worker/tenant_reconcile/tasks.py` —— 6 小时归属对账任务无开关,单租户部署下空跑(无害) +- `tenant/api/endpoints/tenant_mount.py` —— 挂载/卸载 API 无开关,单租户模式下超管仍能挂子租户,但后续隔离逻辑不生效。**运维约束:必须先开 `enabled` 再挂载** --- -## 3. 数据模型 +## 3. 租户树模型(v2.5.1 F011) -### ER 关系 +### 两层锁 -```mermaid -erDiagram - Tenant ||--o{ UserTenant : "包含用户" - User ||--o{ UserTenant : "属于租户" - Tenant ||--|| Department : "root_dept_id 关联根部门" - - Tenant { - int id PK "自增主键" - string tenant_code UK "租户编码(唯一)" - string tenant_name "租户名称" - string logo "Logo URL" - int root_dept_id FK "根部门 ID" - string status "active / disabled / archived" - string contact_name "联系人" - string contact_phone "联系电话" - string contact_email "联系邮箱" - json quota_config "资源配额配置" - json storage_config "存储配置覆盖" - int create_user "创建人 ID" - datetime create_time "创建时间" - datetime update_time "更新时间" - } - - UserTenant { - int id PK "自增主键" - int user_id FK "用户 ID" - int tenant_id FK "租户 ID" - int is_default "是否默认租户" - string status "active / disabled" - datetime last_access_time "最后访问时间" - datetime join_time "加入时间" - } +``` +Root (id=1, parent_tenant_id=NULL) +├── Child A (parent_tenant_id=1) +└── Child B (parent_tenant_id=1) ``` -### 租户感知的业务表(44 张) +**INV-T1:硬锁两层。** 子租户由「把部门树上的某个部门节点标记为租户根」产生(mount),挂载时检查祖先链上是否已有挂载点,有则抛 `TenantTreeNestingForbiddenError`:`tenant_mount_service.py:193`。根部门不可挂载(`:186-188`)。 -以下表均包含 `tenant_id` 列(`INT NOT NULL DEFAULT 1`),由 Alembic 迁移 `v2_5_0_f001_multi_tenant.py` 统一添加: +双向链接:`department.is_tenant_root=1` + `department.mounted_tenant_id` ↔ `tenant.root_dept_id`,在同一事务内写入(`tenant_mount_service.py:207-231`)。 -| 模块 | 表名 | -|------|------| -| **核心应用** | `flow`, `flowversion`, `assistant`, `assistantlink`, `template` | -| **标签/分组** | `tag`, `taglink`, `group`, `groupresource`, `usergroup` | -| **角色/权限** | `role`, `roleaccess`, `userrole` | -| **会话/消息** | `chatmessage`, `message_session`, `t_report`, `t_variable_value` | -| **知识库** | `knowledge`, `knowledgefile`, `qaknowledge` | -| **工具** | `t_gpts_tools`, `t_gpts_tools_type` | -| **渠道** | `channel`, `channel_info_source`, `channel_article_read` | -| **分享** | `share_link` | -| **消息收件箱** | `inbox_message`, `inbox_message_read` | -| **微调/部署** | `finetune`, `presettrain`, `modeldeploy`, `server`, `sftmodel` | -| **Linsight** | `linsight_sop`, `linsight_sop_record`, `linsight_session_version`, `linsight_execute_task` | -| **LLM** | `llm_server`, `llm_model` | -| **评测/标注** | `evaluation`, `dataset`, `marktask`, `markrecord`, `markappuser` | -| **审计/邀请** | `auditlog`, `invitecode` | - -**不包含 `tenant_id` 的表**:`user`(用户全局)、`user_tenant`(关联表,tenant_id 为 FK)、`tenant`(租户主表)、`config`(系统配置)、`recallchunk`(检索中间表)、`failed_tuple`(补偿表) +### 状态机 ---- +| 状态 | 触发 | 语义 | +|------|------|------| +| `active` | mount 时创建 | 正常 | +| `disabled` | `PUT /tenants/{id}/status` | 写 Redis 黑名单 `disabled_tenant:{id}`(永久 key)+ 批量 bump 全体成员 `token_version` 踢下线(`tenant_service.py:266-292`)| +| `archived` | **仅由 unmount 产生** | **终态不可恢复**。状态机守卫拦截 archived→其它(`tenant_service.py:253-256`),防止前端"启用"按钮复活没有挂载点的租户 | +| `orphaned` | 挂载点部门被删 | 写审计 + 站内信通知超管(`department_deletion_handler.py:56,77-86`)。TenantResolver 遇到会继续向上找(`tenant_resolver.py:109-118`)| -## 4. 租户上下文传播 +Root 受保护:不可停用/归档/删除,22008(`tenant_crud.py:100-127`)。配额统计只算 `active` 子租户(`quota_service.py:710-717`)。 -### ContextVar 机制 +### unmount 流程 -租户隔离基于 Python 的 `contextvars` 模块,天然支持线程安全和异步安全。 +`tenant_mount_service.unmount_child`(`:484-608`),仅超管: -```python -# src/backend/bisheng/core/context/tenant.py +1. 该 Child 名下所有租户表数据 `tenant_id` 批量改回 1(`_migrate_child_resources_to_root`,预置工具行排除) +2. 同事务内:tenant 置 `archived` + `tenant_code` 改名为 `{code}#archived#{ts}` 释放唯一约束;部门 `is_tenant_root=0, mounted_tenant_id=NULL` +3. 清 FGA 中该 tenant 作为 object 或 user 的全部 tuple;资源级 `shared_with → tenant:{child}` **故意保留**(member userset 已死链,`:373-379`) +4. 重算子树用户归属(必须在清 flag 之后) +5. 写审计 -DEFAULT_TENANT_ID: int = 1 +v2.5.1 已把 unmount 收窄为唯一路径,旧的 archive/manual 三策略被删除;"冻结不迁移资源"的诉求归 disable(`:487-491`)。 -# 请求作用域内的租户 ID -current_tenant_id: ContextVar[Optional[int]] = ContextVar('current_tenant_id', default=None) +--- -# 是否绕过租户过滤(系统管理员跨租户查询) -_bypass_tenant_filter: ContextVar[bool] = ContextVar('_bypass_tenant_filter', default=False) -``` +## 4. 数据模型 -### API 函数 +### Tenant / UserTenant -| 函数 | 用途 | -|------|------| -| `get_current_tenant_id()` | 获取当前上下文的租户 ID,未设置返回 `None` | -| `set_current_tenant_id(tid)` | 设置当前上下文的租户 ID | -| `bypass_tenant_filter()` | 上下文管理器,临时禁用租户过滤 | -| `is_tenant_filter_bypassed()` | 检查当前是否已绕过过滤 | +关键字段(`database/models/tenant.py`): -### 上下文设置时机 +| 字段 | 说明 | +|------|------| +| `parent_tenant_id` | NULL=Root;否则指向 Root id(MVP 锁两层)| +| `share_default_to_children` | 见 §12——**字段名与实际语义已严重不符** | +| `root_dept_id` | 挂载点部门 | +| `status` | active/disabled/archived/orphaned | +| `quota_config` | JSON,租户级配额 | +| `storage_config` | ❌ **死字段**,全仓无运行时读取方 | -| 场景 | 设置方 | 文件 | -|------|--------|------| -| HTTP 请求 | `CustomMiddleware` 从 JWT Cookie 解码 | `utils/http_middleware.py` | -| WebSocket | `WebSocketLoggingMiddleware` 从 ASGI Cookie 解码 | `utils/http_middleware.py` | -| Celery 任务 | `task_prerun` 信号从 headers 恢复 | `worker/tenant_context.py` | -| 系统初始化 | 显式调用 `set_current_tenant_id()` 或 `bypass_tenant_filter()` | `common/init_data.py` | +`UserTenant` 唯一约束在 v2.5.1 从 `(user_id, tenant_id)` 改为 **`(user_id, is_active)`**(`tenant.py:170`):`is_active=1` 每人至多一行(当前归属),`is_active=NULL` 为历史流水(MySQL 唯一索引允许多 NULL)。 ---- +`is_default` 已退化为迁移期字段:运行时写死 0(`tenant_service.py:112`),唯一读取方 `get_user_default_tenant` 无调用方(死代码)。 -## 5. 自动租户过滤(SQLAlchemy 事件钩子) +### 租户感知表:79 张声明 / 78 张参与过滤 -核心文件:`src/backend/bisheng/core/database/tenant_filter.py` +> 旧版本文档写的"44 张"已过时。统计需**沿基类链解析**——大量模型在 `XxxBase` 上声明 `tenant_id`、由 `Xxx(XxxBase, table=True)` 建表,只 grep 模型文件会严重漏数。 -### 注册时机 +`user_tenant` 被显式排除(其 tenant_id 是外键非隔离列):`tenant_filter.py:32`。 -`DatabaseManager._register_tenant_filter()` 在数据库连接管理器初始化时调用 `register_tenant_filter_events()`,注册全局 Session 事件。该函数幂等,多次调用只注册一次。 +覆盖域:审批中心(11) / 权限 ReBAC(6) / 组织角色(8) / 应用与资源 / 会话消息(5) / LLM(5) / Linsight(5) / 微调(5) / 标注(3) / 运营审计等。 -### 自动发现机制 +**不带 tenant_id 的 24 张**,三类: -`_discover_tenant_aware_tables()` 扫描 SQLModel 的 metadata,自动发现所有包含 `tenant_id` 列的表。新增 ORM 模型只要声明了 `tenant_id` 字段即可自动参与过滤,无需额外注册。 +- **真全局**:`user`(用户是全局对象,租户归属靠 `user_tenant`+部门树外挂表达)、`user_link`、`config`、`tenant` 自身、权限目录/DSL 全套(8 张,平台级)、`dashboard_default` +- **靠父表间接隔离**:`knowledge_document(_version)`、`dashboard_component/dataset`、`message_citation`、`recallchunk` 等 +- **值得注意的两张 ACL 表**:`department_admin_grant`、`space_channel_member`(配额统计时 JOIN 父表兜 tenant,`quota_service.py:75-96`) -排除列表 `_EXCLUDED_TABLES = {'user_tenant'}`:`user_tenant` 的 `tenant_id` 是外键关联字段,非隔离字段。 +--- -### SELECT 拦截流程 +## 5. 租户上下文 -```mermaid -flowchart TD - A["ORM SELECT 执行"] --> B{"bypass_tenant_filter?"} - B -->|Yes| C["跳过,原样执行"] - B -->|No| D{"is_select?"} - D -->|No| C - D -->|Yes| E["提取 statement 中的租户感知表"] - E --> F{"找到租户感知表?"} - F -->|No| C - F -->|Yes| G["_resolve_tenant_id()"] - G --> H{"ContextVar 有值?"} - H -->|Yes| I["使用 ContextVar 值"] - H -->|No| J{"multi_tenant.enabled?"} - J -->|No| K["使用 DEFAULT_TENANT_ID = 1"] - J -->|Yes| L["抛出 NoTenantContextError"] - I --> M["注入 WHERE tenant_id = X"] - K --> M +```python +# core/context/tenant.py +current_tenant_id : ContextVar[int | None] # v2.5.0 基线 +_bypass_tenant_filter : ContextVar[bool] +visible_tenant_ids : ContextVar[frozenset | None] # v2.5.1 F012:IN-list +_strict_tenant_filter : ContextVar[bool] # 强制退回严格等值 +_admin_scope_tenant_id : ContextVar[int | None] # F019 超管管理视角 +_is_management_api : ContextVar[bool] ``` -`do_orm_execute` 事件通过两种方式提取查询中的表: -1. `column_descriptions` — 适用于 `select(Model)` 模式 -2. `froms` 回退 — 适用于 joins 和 subquery +`get_current_tenant_id()` **优先返回 admin-scope 覆盖值**(`tenant.py:79-95`)。 + +### visible_tenant_ids 的不对称性 -### INSERT 自动填充 +`_compute_visible_tenant_ids`(`utils/http_middleware.py:207-227`): -`before_flush` 事件在 `session.new` 中遍历待插入对象: -- 若对象所属表在 `_tenant_aware_tables` 中,且 `tenant_id` 为 `None` 或 `0`,自动填充为当前上下文的租户 ID -- 多租户模式下若无上下文,跳过填充(由后续 SELECT 时的 `_resolve_tenant_id()` 捕获异常) +| 主体 | 可见集合 | 含义 | +|------|----------|------| +| 全局超管(无 admin-scope) | `None` | 不注入任何过滤 | +| Root 用户 | `{1}` | **闭** —— 总部看不到子租户数据 | +| Child 用户 | `{自己, 1}` | **开** —— 子租户可见 Root(用于读共享资源)| +| `tenant_id=0` | `frozenset()` | 注入 `WHERE false()`,中间件另行 403 | -### 已知限制 +方向性是刻意设计:Root 想看 Child 数据必须显式切 admin-scope,且仅在白名单内生效。 -`text()` 构造的原生 SQL **不触发** ORM 事件。使用原生 SQL 时必须手动添加 `WHERE tenant_id = X`。 +### strict_tenant_filter 的三类必用场景 + +strict 时 `_resolve_visible_tenant_ids()` 返回 None,回落严格等值(`tenant_filter.py:349`)。不用会出错的地方: + +1. **配额计数**(INV-T6):Child 的 visible 含 1,不 strict 会把 Root 资源算进 Child 用量 → 一上来就"超配额"。`quota_service.py:697-708` +2. **"我拥有多少资源"判定**:改部门时统计旧租户资源数,IN-list 会误报 → 卡住所有人调岗。`user_tenant_sync_service.py:247` +3. **引用投影收敛**:工作台继承 Root 配置后,把引用到的知识库/应用收敛到本租户实有行,否则 UI 显示了点进去 403。`workstation_service.py:438,492,504` --- -## 6. HTTP / WebSocket 中间件 +## 6. 自动租户过滤 -核心文件:`src/backend/bisheng/utils/http_middleware.py` +`core/database/tenant_filter.py`,由 `DatabaseManager._register_tenant_filter()` 幂等注册。 -### 请求处理流程 +**自动发现**:`_discover_tenant_aware_tables()`(`:127-141`)扫描 `SQLModel.metadata`,凡有 `tenant_id` 列的表自动入列——新模型声明字段即参与,无需注册。 -```mermaid -sequenceDiagram - participant B as 浏览器 - participant M as CustomMiddleware - participant JWT as JWT 解码 - participant Redis as Redis - participant CV as ContextVar - participant API as 业务路由 - - B->>M: HTTP 请求 + Cookie(access_token_cookie) - M->>JWT: decode_jwt_token(token) - JWT-->>M: {user_id, user_name, tenant_id} - M->>CV: set_current_tenant_id(tenant_id) - - alt 豁免路径 - M->>API: 直接放行 - else tenant_id == 0(待选择) - M-->>B: 403 Missing tenant context - else 正常路径 - M->>Redis: GET disabled_tenant:{tenant_id} - alt 租户已禁用 - M-->>B: 403 Tenant is disabled - else 租户正常 - M->>API: 放行 - end - end -``` +**SELECT 注入**(`do_orm_execute`,`:157-197`):bypass → 非 SELECT → 无租户表 均跳过;否则按 visible 集合注入 `== X` / `IN (...)` / `false()`。 -### 豁免路径 +**INSERT 填充**(`before_flush`,`:198-232`):`session.new` 中 tenant_id 为 None/0 的自动填当前租户。 -以下路径不进行租户状态检查(登录前或系统级接口): +### 两个必须知道的限制 -``` -/api/v1/user/login -/api/v1/user/register -/api/v1/user/sso # legacy compatibility only; new third-party login uses /api/v1/internal/sso/login-sync -/api/v1/user/ldap -/api/v1/user/public_key -/api/v1/user/switch-tenant -/api/v1/user/tenants -/api/v1/env -/health -/docs -/openapi.json -/redoc -``` +**① 只拦 SELECT。** `:163-164` 对非 SELECT 直接 return——**批量 `update()` / `delete()` 与 `text()` 原生 SQL 一律无注入**,必须手写 tenant 条件。已导致两次跨租户泄漏(LLM 模块、F035)。 + +需要手挂条件时用 `build_tenant_filter_clause(tenant_col)`(`:235-282`),逻辑与监听器一致。 -### WebSocket 中间件 +当前全仓原生 SQL 触及租户表的情况:3 处手写了条件(`tenant_mount_service.py:336,450-454`、`knowledge_rag.py:136`),**3 处没带**——`message.py:284`(靠 flow_id 间接约束)、`mark_record.py:94`(靠 task_id)、`role_service.py:879`(**只按 id 取、无租户条件**,三者中风险最高)。另 `quota_service.py:65-105` 的 `_RESOURCE_COUNT_TEMPLATES` 是一整套裸 SQL,手工把 `{col}` 填成 tenant_id,功能正确但属技术债(作者自陈于 `:698-701`)。 -`WebSocketLoggingMiddleware` 从 ASGI scope 的 headers 中解析 Cookie,调用相同的 `_set_tenant_context()` 函数设置租户上下文。 +**② 强制 import 列表有缺口。** `_TENANT_AWARE_MODEL_MODULES`(`:41-104`)显式 import 55 个模块以保证模型进入 metadata,但带 `tenant_id` 的模型分布在 60 个模块中。**未列入的 5 个**:`approval.domain.models.approval_instance`、`approval_scenario`、`user_menu_access`、`knowledge_space_tag_library`、`sensitive_word_policy`。这些表能否被过滤取决于路由链是否恰好 import 到——`:36-40` 的注释指明这正是 v2.5 泄漏的成因。**排查手法**:启动日志中的 `Tenant filter events registered for N tables`,N 应为 78。 --- -## 7. 存储隔离策略 +## 7. 中间件 -核心文件:`src/backend/bisheng/core/storage/tenant_storage.py` +`utils/http_middleware.py`: -### 前缀规则 +1. 解 JWT → `set_current_tenant_id` +2. `_apply_token_version_and_visible`(`:229-247`):校验 `token_version`,不匹配返 **401+19103** 强制重登;Redis 5min 缓存,基础设施故障时 **fail-open**(`:122-141`) +3. 计算并设置 `visible_tenant_ids` +4. `tenant_id==0` → 403+20004;Redis 黑名单命中 → 403+20001(`:334-352`) -默认租户(id=1)不添加前缀,保持与 v2.4.x 完全兼容。新租户使用各存储引擎对应的前缀: +`AdminScopeMiddleware`(`common/middleware/admin_scope.py`)注册在更内层(`main.py:273`),确保 JWT 已解析。它**独立复核调用者是否超管**,非超管 fail-closed 不读 Redis key(`:106-116`),防伪造。 -| 存储引擎 | 默认租户 (id=1) | 新租户 (id=2, code="acme") | 函数 | -|----------|:---------------:|:-------------------------:|------| -| MinIO | `""` (原路径) | `tenant_acme/` | `get_minio_prefix(tenant_id, tenant_code)` | -| Milvus | `""` (原集合名) | `t2_` | `get_milvus_collection_prefix(tenant_id)` | -| Elasticsearch | `""` (原索引名) | `t2_` | `get_es_index_prefix(tenant_id)` | -| Redis | `""` (原 key) | `t:2:` | `get_redis_key_prefix(tenant_id)` | +豁免路径:login / register / sso / ldap / public_key / user/tenants / env / health / docs / openapi.json / redoc。 -### 使用方式 +WebSocket 走 `WebSocketLoggingMiddleware`,从 ASGI headers 解 Cookie 后调用同一套 `_set_tenant_context()`。 -这些函数定义的是前缀约定。实际的存储调用点在各业务模块中拼接前缀后再访问存储引擎: +--- -- **MinIO**:文件上传路径拼接 `{prefix}{original_path}` -- **Milvus**:知识库创建时 collection 名为 `{prefix}{collection_name}` -- **ES**:索引创建时名为 `{prefix}{index_name}` -- **Redis**:缓存 key 拼接 `{prefix}{original_key}` +## 8. 存储隔离现状 ⚠️ ---- +**本节是与旧文档差异最大的部分。** -## 8. Celery 任务租户上下文传递 +`core/storage/tenant_storage.py` 定义了 4 个前缀函数: -核心文件:`src/backend/bisheng/worker/tenant_context.py` +| 函数 | 定义 | 生产调用方 | +|------|------|-----------| +| `get_minio_prefix` | `:14` | **无**(仅 `test/tenant/test_tenant_storage.py`)| +| `get_milvus_collection_prefix` | `:25` | **无** | +| `get_es_index_prefix` | `:36` | **无** | +| `get_redis_key_prefix` | `:47` | **无** | -### 信号流程 +文件自身 docstring 已说明原因(`:7-8`):*"These functions define the prefix convention only. Actual storage call sites are modified in F008-resource-rebac-adaptation, **not here**"* —— **F008 的接线从未落地**。全仓 grep `tenant_{` 只命中该文件自身与 MinIO 的正则。 -```mermaid -sequenceDiagram - participant API as FastAPI 进程 - participant Broker as Redis Broker - participant Worker as Celery Worker - - Note over API: ContextVar tenant_id = 2 - API->>Broker: publish task
headers = {tenant_id: 2} - Note over API: before_task_publish 信号 - - Broker->>Worker: deliver task - Note over Worker: task_prerun 信号 - Worker->>Worker: set_current_tenant_id(2) - Worker->>Worker: 执行任务
(ORM 自动过滤 tenant_id=2) - Note over Worker: task_postrun 信号 - Worker->>Worker: current_tenant_id.set(None) -``` +### 各存储的真实隔离方式 -### 三个 Celery 信号 +- **Milvus / ES**:无前缀。collection/index 名建库时随机生成(`knowledge_service.py:732-733`,`index_name = generate_knowledge_index_name()`,collection 同名)。隔离是**间接的**——拿不到 MySQL 里的 knowledge 行就拿不到名字。同一实例内物理不分租户。 +- **Redis**:无前缀,key 全局共用。 +- **MinIO**:写侧无前缀。读侧有一段前缀剥离兜底(`minio_storage.py:39,180-200`,接线于 `:508,553,592`),但由于无任何写方产生 `tenant_*/` key,`_translate_to_root_prefix` 恒返回 None → **半死代码**(函数被调用,分支永不进入)。 -| 信号 | 时机 | 作用 | -|------|------|------| -| `before_task_publish` | API 进程发布任务时 | 将当前 `tenant_id` 写入任务 `headers` | -| `task_prerun` | Worker 执行任务前 | 从 `headers` 恢复 `ContextVar`,无值时回退到 `DEFAULT_TENANT_ID` | -| `task_postrun` | Worker 执行任务后 | 重置 `ContextVar` 为 `None`,防止线程池复用时泄露 | +### 其它未接线的跨租户存储能力 + +`KnowledgeRag.aexpand_with_root_shared`(`knowledge_rag.py:69`,Milvus 检索"Child + Root 共享"并集)**无生产调用方**,仅测试引用。 -信号注册方式:`worker/main.py` 中 `import bisheng.worker.tenant_context` 触发模块级信号绑定。 +> **结论**:对外描述隔离能力时,只能说"数据层通过租户标识强制过滤",不能说"各存储引擎独立分区"。若客户合规要求物理隔离,须独立部署实例。 --- -## 9. 登录与租户选择流程 +## 9. Celery 租户上下文传递 -### 完整流程 +`worker/tenant_context.py`,三个信号: -```mermaid -flowchart TD - A["POST /api/v1/user/login"] --> B["验证用户名密码"] - B --> C{"multi_tenant.enabled?"} - - C -->|No| D["tenant_id = DEFAULT_TENANT_ID"] - D --> E["签发 JWT,设置 Cookie"] - - C -->|Yes| F["查询 UserTenant 列表"] - F --> G{"关联几个活跃租户?"} - - G -->|0 个| H["返回 NoTenantsAvailableError"] - G -->|1 个| I["tenant_id = 该租户 ID"] - I --> E - - G -->|多个| J["tenant_id = 0(待选择)"] - J --> K["返回 requires_tenant_selection=true
+ tenants 列表"] - K --> L["前端跳转 TenantSelect 页面"] - L --> M["用户选择租户"] - M --> N["POST /api/v1/user/switch-tenant"] - N --> O["验证 UserTenant 存在且 active"] - O --> P["验证 Tenant 状态为 active"] - P --> Q["签发新 JWT(tenant_id=目标ID)"] - Q --> R["Set-Cookie + 跳转应用首页"] -``` +| 信号 | 作用 | +|------|------| +| `before_task_publish` | 把当前 tenant_id 写入任务 headers | +| `task_prerun` | 从 headers 恢复 ContextVar,无值回退 `DEFAULT_TENANT_ID` | +| `task_postrun` | 重置为 None,防线程池复用泄露 | + +注册靠 `worker/main.py` 中 `import bisheng.worker.tenant_context` 触发模块级绑定。 + +**Beat × 多租户**:Beat 调度只触发一次,任务体需自行遍历租户——每轮 `set_current_tenant_id()`,且跨租户枚举查询本身要包在 `bypass_tenant_filter()` 里。 + +--- + +## 10. 用户归属派生(v2.5.1 F012,已取代"租户选择") + +> **旧文档的"登录时选择租户 + Header 切换器"整套已废弃。** -### JWT Payload 结构 +### TenantResolver -```json -{ - "user_id": 1, - "user_name": "admin", - "tenant_id": 2 -} +`tenant/domain/services/tenant_resolver.py`: + +``` +用户主部门 → 沿物化路径向上找最近 is_tenant_root=1 的部门 + → 取其 mounted_tenant_id + → 挂载点租户非 active 则继续向上(:100-118) + → 无主部门 / 无挂载点 / 全非 active → 回落 Root(1) + → 路径成环 → TenantCycleDetectedError(:90-97) ``` -`tenant_id=0` 是一个临时状态,表示用户已认证但尚未选择租户。中间件对此状态的非豁免路径返回 403。 +**部门树是真相,`user_tenant` 是派生投影**,由 `UserTenantSyncService.sync_user` 对齐(`user_tenant_sync_service.py:69-145`),三个触发点:登录、改主部门、6 小时 Celery 对账。 + +登录期租户闸门(`user/domain/services/user.py`):主部门指向 disabled 租户 → 拒登(`:483-505`);无 active `user_tenant` → 尝试挂默认租户,仍失败则 `NoTenantsAvailableError`(`:508-522`);leaf 非 active 或命中黑名单 → `TenantDisabledError`(`:566-592`)。**JWT 里的 tenant_id 会被 resolver 结果覆盖**(`:552-554`)。 + +### 调岗安全阀 + +若用户在旧租户下持有资源(knowledge/flow/assistant/channel/t_gpts_tools,`user_tenant_sync_service.py:57-63`),且 `settings.user_tenant_sync.enforce_transfer_before_relocate=True`,改部门被 `TenantRelocateBlockedError` 拦下(`:98-112`),须先转移资源。默认关闭。 + +归属变更会 bump `token_version`(`:117`)强制重登,并连带清除 admin-scope(`:126-129`)。 + +### 已废弃 + +- `POST /api/v1/user/switch-tenant` → **410 Gone**,且故意不挂 auth 依赖(避免客户端对 401/403 重试):`tenant/api/endpoints/user_tenant.py:29-47` +- `TenantService.aswitch_tenant` 服务层仍在但无调用方(死代码) +- `requires_tenant_selection` 逻辑仍在但**紧接着被 resolver 覆盖**,实际永不生效 +- 前端 `TenantSelect.tsx` 已改为墓碑页,全仓无任何跳转来源 +- `GET /api/v1/user/tenants` 仍存活,仅作展示 --- -## 10. 租户管理 API +## 11. 管理 API + +### 租户 CRUD(超管) -### 端点清单 +| 端点 | 状态 | +|------|------| +| `POST /api/v1/tenants/` | **410 Gone** —— 改由部门挂载产生 | +| `GET /api/v1/tenants/` | 列表(分页+搜索)| +| `GET/PUT /api/v1/tenants/{id}` | 详情 / 更新(仅名称等)| +| `DELETE /api/v1/tenants/{id}` | 物理删除。前置:子树内 0 用户(`acount_users_by_tenant_subtree`)。连带删 UserTenant 行、该租户部门、Redis 黑名单 key、FGA tuple(`tenant_service.py:318-358`)| +| `PUT /api/v1/tenants/{id}/status` | active/disabled | +| `GET/PUT /api/v1/tenants/{id}/quota` | 配额 | +| `GET /api/v1/tenants/{id}/users` | 存活。数据源已从 `user_tenant` 换为**部门子树派生**(F024,避免"幽灵成员")| +| `POST /api/v1/tenants/{id}/users` | **410 Gone** | +| `DELETE /api/v1/tenants/{id}/users/{user_id}` | **410 Gone** | -**管理员端点**(需要系统管理员权限): +410 响应体指路改主部门接口,标 `deprecated_since: v2.5.1, removed_in: v2.6.0`(`tenant_users.py:25-36`)。 -| 端点 | 方法 | 说明 | -|------|------|------| -| `/api/v1/tenants/` | POST | 创建租户(含根部门 + UserTenant + OpenFGA 元组) | -| `/api/v1/tenants/` | GET | 租户列表(分页 + 关键词搜索 + 状态筛选) | -| `/api/v1/tenants/{id}` | GET | 租户详情(含管理员用户列表) | -| `/api/v1/tenants/{id}` | PUT | 更新租户信息(名称 / logo / 联系人) | -| `/api/v1/tenants/{id}` | DELETE | 删除租户(须无活跃用户) | -| `/api/v1/tenants/{id}/status` | PUT | 状态管理(active / disabled / archived) | -| `/api/v1/tenants/{id}/quota` | GET/PUT | 配额查看 / 设置 | -| `/api/v1/tenants/{id}/users` | GET/POST/DELETE | 租户用户管理 | - -**用户端点**(已登录用户): - -| 端点 | 方法 | 说明 | -|------|------|------| -| `/api/v1/user/tenants` | GET | 获取我的可用租户列表 | -| `/api/v1/user/switch-tenant` | POST | 切换租户(签发新 JWT) | +### 挂载 / 卸载 -### 保护规则 +`POST /api/v1/departments/{dept_id}/mount-tenant`、`DELETE` 同路径,仅超管(`_require_super` → 22010)。 -- 默认租户(id=1)不可删除,不可修改 `tenant_code` -- 删除租户前须确认无活跃用户(`TenantHasUsersError`) -- 禁用租户时向 Redis 写入黑名单 key(`disabled_tenant:{id}`),中间件实时生效 -- 不能移除租户最后一个管理员(`TenantAdminRequiredError`) +`POST /tenants/{id}/resources/migrate-from-root` 后端存活但**前端无调用**,仅 API 可达。 -### 创建租户的原子流程 +### admin-scope(F019) + +- `POST /api/v1/admin/tenant-scope`,body `{tenant_id: int|null}`,null 清除 +- `GET` 读当前 scope + 剩余 TTL +- Redis `admin_scope:{user_id}`,TTL 14400s 滑动续期 + +**白名单前缀(仅这些路径行为会变)**,`admin_scope.py:62-70`: ``` -1. 创建 Tenant 记录(检查 tenant_code 唯一性) -2. 创建根部门(DepartmentService.acreate_root_department) -3. 回写 Tenant.root_dept_id -4. 为管理员用户创建 UserTenant 记录 -5. 写入 OpenFGA 权限元组(admin + member) +/api/v1/llm, /api/v1/workstation, /api/v1/linsight, +/api/v1/tool, /api/v1/knowledge, /api/v1/chat/online, /api/v1/admin ``` +非白名单走快速路径完全不受影响(`:88-93`)。注释记录 `/api/v1/roles` 已移除、`/api/v1/audit_log` 是从未匹配过的死字符串(真实前缀 `/api/v1/audit`)。 + +**前端入口只有 2 处**:`ModelPage/manage/ScopeBar.tsx`(模型管理)与 `BuildPage/bench/DialogueWork.tsx`(工作台配置)。白名单里的 knowledge / tool / linsight 等**没有 UI 入口**。`components/AdminScopeSelector.tsx` 是零 import 的死代码。 + +> v3.0 修复:`DialogueWork.tsx` 的 ScopeBar 曾缺 `multiTenantEnabled` 守卫(ModelPage 侧有),单租户部署下超管在工作台配置页仍会看到「管理视图」下拉。现两处调用点已一致地写成 `{appConfig.multiTenantEnabled && }`。 + --- -## 11. 数据库迁移与初始化 +## 12. 跨租户共享现状 ⚠️ + +**仅 `llm_server` 一种类型。** -### Alembic 迁移 +```python +# tenant/domain/services/resource_share_service.py:60-62 +SUPPORTED_SHAREABLE_TYPES: set[str] = {'llm_server'} +``` + +`knowledge_space / workflow / assistant / channel / tool` 五类业务资源在 **v2.6.0-beta2 移除**(QA 发现子租户用户在"我加入的空间"里看到从未加入的 Root 知识空间),退到 `LEGACY_SHAREABLE_TYPES`(`:66-75`)仅供撤销/清理存量 tuple。历史 tuple 由 alembic `f041_revoke_business_resource_share` 撤回。 -迁移文件:`src/backend/bisheng/core/database/alembic/versions/v2_5_0_f001_multi_tenant.py` +**死代码提醒**:`tenant_mount_service.py:437-455` 扫描 5 类 `is_shared=1` 资源的 UNION SQL **已无任何调用方**。`components/bs-ui/sharedBadge/` 前端组件零 import。 -**upgrade 流程**: +### llm_server 共享是真接线的 -1. 创建 `tenant` 表 -2. 创建 `user_tenant` 表(含 `uk_user_tenant` 唯一约束) -3. 为 44 张业务表添加 `tenant_id` 列(`INT NOT NULL DEFAULT 1`)+ 索引 -4. 种子数据:插入默认租户 (id=1, code='default', name='Default Tenant') -5. 回填:为所有现有用户创建 `user_tenant` 记录(关联默认租户) +- 创建时按 `share_to_children` 标志 fan out:`llm_server.py:196-207` +- 开关:`aupdate_server_share`(`:213-231`),仅超管、仅 Root 归属的 server,否则 `LLMModelSharedReadonlyError` +- 删除时撤销:`:466-470` +- 前端唯一入口:`ModelPage/manage/ModelConfig.tsx:688-703`,可见条件 `canShareToChildren()`(`permissions.ts:59-66`)四条全满足 +- **开关是布尔的**:开=共享给全部活跃 Child,关=全部收回,**不能指定共享给某几个 Child** +- 新 Child 挂载时 fan out Root 全部 llm_server(`tenant_mount_service.py:305-340`) -**downgrade**:逆序删除 `tenant_id` 列和表。注意 tenant_id > 1 的数据上下文会丢失。 +### share_default_to_children 的真实语义 -### 应用初始化 +字段名严重误导。它**已不再控制**业务资源分发或 llm_server 共享——后者由每请求的 `share_to_children` 标志独占控制,代码中有明确注释推翻(`llm_server.py:196-199`)。 -`src/backend/bisheng/common/init_data.py` 中的相关函数: +**当前唯一实际效果**:子租户是否继承 Root 的**系统模型默认选型**(`tenant_system_model_config.py:171,196` + `llm/domain/share_fallback.py:51,58`)。写文档或改代码时按此语义理解,不要按字面。 -| 函数 | Feature | 作用 | -|------|---------|------| -| `_init_default_tenant()` | F001 | 确保默认租户存在,回填 `user_tenant` | -| `_init_default_root_department()` | F002 | 为默认租户创建根部门 | -| `_migrate_rbac_to_rebac_if_needed()` | F006 | 一次性 RBAC → ReBAC 迁移(Redis SETNX 锁保证幂等) | +### 另外两条"像共享实则不是"的路径 -这些函数在应用启动时(lifespan)调用,使用 `bypass_tenant_filter()` 绕过租户过滤。 +- **系统模型配置回落**:Child 无行时读时回落 Root,不是 FGA 共享 +- **内置工具是复制不是共享**:`acopy_root_builtin_tools_to_tenant` 给子租户建自己 tenant_id 的副本行 --- -## 12. 前端集成 +## 13. 权限与租户交叉 + +### 三层管理员 + +| 角色 | 判定 | 边界 | +|------|------|------| +| 全局超管 | FGA `super_admin system:global`,回退 RBAC `AdminRole=1` | visible=None 不注入过滤;独占挂载/卸载、admin-scope、授撤 Child Admin、租户 CRUD、翻转共享开关 | +| Root 管理员 | tenant_id=1 的管理员 | **Root 没有 `tenant#admin` 概念**,权限一律走 `system:global#super_admin`(`tenant_admin_service.py:9-13`)| +| Child 管理员 | FGA `tenant:{id}#admin` | 本租户内:建模型、配系统模型/工作台、管理子树用户与权限。不能碰 Root、不能改共享开关、不能挂载 | -### 租户选择页 +依赖 `get_tenant_admin_user`(`common/dependencies/user_deps.py:78-102`):超管 **或** 当前 tid≠1 且 `has_tenant_admin(tid)`。 -`src/frontend/platform/src/pages/LoginPage/TenantSelect.tsx` +### 资源授权的租户约束 -- 在登录后若用户有多个可用租户,前端跳转到租户选择页 -- 使用 `sessionStorage` 缓存待选租户列表(来自登录 API 响应) -- 用户点击租户后调用 `switchTenantApi(tenantId)` 获取新 JWT -- 成功后跳转到应用首页 +**授权侧是硬校验**:`TenantPermissionSubjectDirectory.canonical_source`(`tenant/domain/services/f048_permission_subject.py:53-91`)对三种主体全部要求 tenant 匹配资源自身的 tenant,不匹配抛 `PermissionInvalidResourceError`(`:82-83`)。 -### 租户管理页 +→ **总部资源无法授权给子租户用户**,跨租户授权在 F048 grant API 被硬拦。 -`src/frontend/platform/src/pages/TenantPage/` +**选人侧是弱的**:`GET /api/v1/user/list` —— `user` 表无 tenant_id 列,ORM 自动过滤对它完全不起作用;且 `login_user.is_admin()` 为真时整段收窄逻辑被短路(`user/api/user.py:439`),超管能搜到全平台用户。 -- 系统管理员可见的租户管理面板 -- 租户列表(表格 + 搜索 + 状态筛选) -- 子组件:CreateTenantDialog(创建/编辑)、TenantUserDialog(用户管理)、TenantQuotaDialog(配额设置) -- 删除租户需输入 `tenant_code` 二次确认 +→ 真实表现:**超管在授权弹窗能选到子租户用户,但提交必被拒**。这是 UX 缺口而非越权漏洞,对外表述统一为"资源只能授权给本租户成员"。 -### API 层 +### 同租户内不默认互通 -`src/frontend/platform/src/controllers/API/tenant.ts` 封装了所有租户相关 API 调用。 +2026-04-21 Round 2 收窄:资源的 manager/editor 移除所有 `tenant#` 来源,viewer 只保留 `tenant#shared_to#member`。资源授权回归 4 源(owner + user + department#member + user_group#member)。 + +**后果**:同一租户内的成员默认看不到彼此的资源,必须显式授权。IN-list 只决定"元数据可见性",不等于可读内容。 --- -## 13. 错误码 +## 14. 配额层级(v2.5.1 F016) -模块编码:`200`(5 位编码 `200XX`) +租户级配额存 `Tenant.quota_config` JSON;角色级配额存 `role.quota_config`。合法 key 集合见 `quota_service.py:44,30-41`;`-1` = 无限制。 -| 错误码 | 类名 | 说明 | -|--------|------|------| -| 20000 | `TenantNotFoundError` | 租户不存在 | -| 20001 | `TenantDisabledError` | 租户已禁用 | -| 20002 | `UserNotInTenantError` | 用户不属于该租户 | -| 20003 | `TenantCodeDuplicateError` | 租户编码重复 | -| 20004 | `NoTenantContextError` | 多租户模式下缺少租户上下文 | -| 20005 | `TenantHasUsersError` | 删除租户时仍有活跃用户 | -| 20006 | `TenantAdminRequiredError` | 不能移除租户最后一个管理员 | -| 20007 | `TenantSwitchForbiddenError` | 用户不属于目标租户 | -| 20008 | `TenantCreationFailedError` | 租户创建失败 | -| 20009 | `NoTenantsAvailableError` | 用户无可用租户 | +### 双层取严 + Root 硬顶(INV-T9) ---- +`QuotaService` 创建资源前构造租户链 `chain = [leaf]` 或 `[leaf, Root]`(两层锁的直接体现),逐层比对,任一层 `remaining == 0` 即拒绝: -## 14. 开发者注意事项 +| 层 | 用量算法 | 超限 reason | +|----|----------|-------------| +| Child(leaf) | `_count_usage_strict` —— 必须 strict,否则 IN-list 会把 Root 资源算进 Child 用量 | `tenant_limit` | +| Root | `_aggregate_root_usage` —— **自身 + Σ 所有 active 子租户** | `root_hardcap`,文案含"集团总量已耗尽" | -### 原生 SQL 的租户过滤 +即**Root 配额是全集团总量天花板**:Root 满时所有 Child 一并被阻断,此时给单个 Child 加配额无效。disabled/archived/orphaned 子租户视为退出资源池,不计入累加(`quota_service.py:710-717`)。 -`text()` 构造的原生 SQL 绕过 ORM 事件,不会自动注入 `WHERE tenant_id=X`。使用原生 SQL 时必须手动添加租户过滤: +实现:`role/domain/services/quota_service.py:200-268`。接线点包括知识空间上传(`workstation/api/endpoints/knowledge.py:73`)、频道创建(`channel_service.py:1426`)等。 -```python -# 错误 — 无租户过滤 -session.execute(text("SELECT * FROM flow WHERE status = 'active'")) +### 存储 key 别名 -# 正确 — 手动添加 tenant_id -tid = get_current_tenant_id() -session.execute(text("SELECT * FROM flow WHERE status = 'active' AND tenant_id = :tid"), {"tid": tid}) -``` +租户管理 UI 只写 `storage_gb`,而角色级装饰器用 `knowledge_space_file`,二者在读取租户级上限时别名到同一个 `storage_gb` key(`quota_service.py:243-245`)。改动任一侧都要同步。 -### bypass_tenant_filter 使用场景 +### 前端只暴露一项 -`bypass_tenant_filter()` 是一个上下文管理器,临时禁用 SELECT 过滤和 INSERT 自动填充。仅在以下场景使用: +`TenantQuotaDialog` 只渲染 `storage_gb`(0.1~99999,一位小数)。后端支持的 `user_count` / `model_tokens_monthly` 以及 9 项角色级配额**没有租户级配置入口**——写进 `quota_config` 也只能靠 API。 -- 系统初始化(`init_data.py`) -- 系统管理员跨租户管理查询(`TenantDao` 内部) -- 登录/注册流程(用户尚无租户上下文) -- 数据迁移脚本 +--- -```python -from bisheng.core.context.tenant import bypass_tenant_filter +## 15. 迁移与初始化 -with bypass_tenant_filter(): - # 此上下文内的查询不带 WHERE tenant_id=X - all_tenants = TenantDao.get_all() -# 退出后自动恢复过滤 -``` +| 迁移 | 内容 | +|------|------| +| `v2_5_0_f001_multi_tenant.py` | 建 `tenant`/`user_tenant`,业务表加 `tenant_id`,插入 Root(id=1, code='default') | +| `alembic_helpers/f011.py` | Root 改树形(`parent_tenant_id=NULL`);`is_default=1 且 active` 回填 `is_active=1`;多活跃行去重(保留 `last_access_time` 最新);换唯一索引 | +| `v2_5_1_f012_user_token_version.py` | `user` 加 `token_version` | +| `f041_revoke_business_resource_share` | 撤回 5 类业务资源的存量共享 tuple | -### 新增 ORM 模型 +应用启动 lifespan 中 `common/init_data.py`:`_init_default_tenant()`、`_init_default_root_department()`,均包在 `bypass_tenant_filter()` 内。 -只要在模型中声明 `tenant_id` 列,`_discover_tenant_aware_tables()` 会在应用启动时自动发现,无需额外注册: +单→多租户流程:跑迁移 → 开 `enabled=true` → 重启 → `POST /departments/{id}/mount-tenant` 建首个 Child。 -```python -class MyNewModel(SQLModel, table=True): - id: int = Field(primary_key=True) - tenant_id: int = Field(default=0, index=True) # 声明即参与自动过滤 - name: str -``` +--- -### Celery 任务 +## 16. 前端集成 -任务函数内的 ORM 操作自动享受租户隔离(通过 `task_prerun` 信号恢复上下文)。但若通过其他方式直接操作存储(如 Redis `SET/GET`、MinIO 上传),需手动拼接前缀。 +| 界面 | 位置 | 说明 | +|------|------|------| +| 租户管理菜单 | `MainLayout.tsx:245-251` | 条件 `isSuperAdmin && multiTenantEnabled` | +| 租户列表页 | `pages/TenantPage/index.tsx` | **无新建按钮**;操作=编辑/配额/停用启用/删除;archived 行只剩删除 | +| 挂载入口 | `SystemPage/components/Departments.tsx:228` → `DepartmentSettings.tsx:564-608` | 「标记为子租户」/「取消挂载」,条件 `multiTenantEnabled && is_global_super` | +| 挂载弹窗 | `MountTenantDialog.tsx` | 租户名称 + 初始管理员(限该部门子树);无 tenant_code 输入 | +| 配额弹窗 | `TenantQuotaDialog.tsx:56-61` | **界面只暴露 `storage_gb` 一项**(0.1~99999)。后端支持的其它 key 读进来但不渲染 | +| 成员弹窗 | `TenantUserDialog.tsx` | 仅展示 + 设/取消管理员;Root 租户隐藏操作列 | +| 管理视角 | `ModelPage/manage/ScopeBar.tsx:127-173` | 下拉形态;`isGlobalSuperUser` 为假直接返回 null | +| 共享开关 | `ModelPage/manage/ModelConfig.tsx:688-703` | 「共享给子租户」,默认 true | +| 只读标识 | `ModelPage/manage/index.tsx:59-67` | 「{{tenantName}} 共享 · 只读」+ 按钮禁用 | -### 测试 +**client 端(`/workspace`)完全无多租户 UI**,仅在跨租户/配额错误码文案中出现"租户"字样。注意其中 `10972`/`18041` 文案写着"请切换到…所属租户后重试",但界面上并无切换入口——措辞待修。 -测试代码中需显式设置租户上下文: +**前端死代码**(勿参考):`AdminScopeSelector.tsx`、`bs-ui/sharedBadge/`、`pages/DepartmentPage/`(`/department` 已重定向到 `/sys`)、`DepartmentTree.tsx`(线上用 `LazyDepartmentTree`)。 -```python -from bisheng.core.context.tenant import set_current_tenant_id, bypass_tenant_filter +**i18n 缺口**:24 个代码在用的 `tenant.*` key 三个 locale 文件全缺,靠 `defaultValue` 中文兜底 —— **英/日界面会显示中文**,集中在部门挂载流程(`tenant.markAsTenant`/`mountTitle`/`mountHint`/`mountedTag` 等)、取消挂载、配置继承 banner、下线提示页四组。另有 16 个孤儿文案(成员增删、联系人、租户切换),是已下线功能的残留,勿据以反推功能。 -def test_something(): - set_current_tenant_id(1) - # ... 测试代码 +--- -def test_cross_tenant(): - with bypass_tenant_filter(): - # ... 跨租户查询 -``` +## 17. 错误码 -### 默认租户保护 +模块 `200`(20000-20009):`TenantNotFoundError`(20000) / `TenantDisabledError`(20001) / `UserNotInTenantError`(20002) / `TenantCodeDuplicateError`(20003) / `NoTenantContextError`(20004) / `TenantHasUsersError`(20005) / `TenantAdminRequiredError`(20006) / `TenantSwitchForbiddenError`(20007) / `TenantCreationFailedError`(20008) / `NoTenantsAvailableError`(20009)。 -id=1 的默认租户具有特殊保护:不可删除、不可修改状态、存储前缀为空。对默认租户的操作应格外谨慎。 +租户树相关另用 `220` 段(22001 嵌套禁止 / 22008 Root 受保护 / 22010 需超管),LLM 隔离用 `198` 段(19801 共享只读 / 19803 跨 tenant 写 / 19804 端点白名单)。 + +> 前端 zh-Hans 仍保留 `19501`「仅根租户可将资源共享给子租户」文案,该码随业务资源共享一并删除,属残留。 --- -## 15. 关键源码索引 - -所有路径相对于 `src/backend/bisheng/`,除非另有标注。 - -| 功能 | 文件路径 | 关键内容 | -|------|----------|----------| -| 租户上下文 ContextVar | `core/context/tenant.py` | `current_tenant_id`, `bypass_tenant_filter()` | -| SQLAlchemy 自动过滤 | `core/database/tenant_filter.py` | `do_orm_execute`, `before_flush` 事件钩子 | -| 过滤事件注册 | `core/database/manager.py:58-65` | `DatabaseManager._register_tenant_filter()` | -| 多租户配置 | `core/config/multi_tenant.py` | `MultiTenantConf` | -| 存储隔离前缀 | `core/storage/tenant_storage.py` | 4 种前缀函数 | -| HTTP 中间件 | `utils/http_middleware.py` | `CustomMiddleware`, `WebSocketLoggingMiddleware` | -| Celery 信号 | `worker/tenant_context.py` | 3 个 Celery signals | -| Worker 信号注册 | `worker/main.py` | `import bisheng.worker.tenant_context` | -| Tenant ORM 模型 | `database/models/tenant.py` | `Tenant`, `UserTenant`, DAO 类 | -| 租户管理服务 | `tenant/domain/services/tenant_service.py` | `TenantService` 业务逻辑 | -| 租户 CRUD API | `tenant/api/endpoints/tenant_crud.py` | 管理员 CRUD 端点 | -| 用户租户 API | `tenant/api/endpoints/user_tenant.py` | `switch-tenant`, `tenants` | -| 租户 DTO | `tenant/domain/schemas/tenant_schema.py` | 请求/响应 Pydantic 模型 | -| 错误码 | `common/errcode/tenant.py` | 20000-20009 | -| Alembic 迁移 | `core/database/alembic/versions/v2_5_0_f001_multi_tenant.py` | 表创建 + 44 表加列 | -| 初始化数据 | `common/init_data.py` | `_init_default_tenant()`, `_init_default_root_department()` | -| 前端租户选择 | `src/frontend/platform/src/pages/LoginPage/TenantSelect.tsx` | 租户选择页面 | -| 前端租户 API | `src/frontend/platform/src/controllers/API/tenant.ts` | API 调用封装 | -| 前端租户管理 | `src/frontend/platform/src/pages/TenantPage/` | 管理页面 | +## 18. 开发者注意事项 + +- **原生 SQL / 批量 UPDATE·DELETE 无自动过滤**,手写条件或用 `build_tenant_filter_clause()`。见 §6① +- **新增带 tenant_id 的模型时**,务必把模块加进 `_TENANT_AWARE_MODEL_MODULES`(`tenant_filter.py:41-104`),否则过滤是否生效取决于 import 时序。见 §6② +- **配额/归属统计必须包 `strict_tenant_filter()`**。见 §5 +- **`bypass_tenant_filter()` 仅用于**:系统初始化、超管跨租户管理查询、登录注册(尚无上下文)、迁移脚本 +- **Celery Beat 遍历租户**时逐个 `set_current_tenant_id()`,枚举查询本身包 `bypass_tenant_filter()` +- **测试**中显式 `set_current_tenant_id(1)` +- **改动对外表述前**先读 §8 与 §12——这两节是历史文档最容易出错的地方 + +--- + +## 19. 源码索引 + +路径相对 `src/backend/bisheng/`。 + +| 功能 | 文件 | +|------|------| +| 租户上下文 | `core/context/tenant.py` | +| 自动过滤 | `core/database/tenant_filter.py` | +| 配置 | `core/config/multi_tenant.py` | +| 存储前缀(未接线) | `core/storage/tenant_storage.py` | +| HTTP 中间件 | `utils/http_middleware.py` | +| admin-scope 中间件 | `common/middleware/admin_scope.py` | +| Celery 信号 | `worker/tenant_context.py` | +| ORM 模型 | `database/models/tenant.py` | +| 归属派生 | `tenant/domain/services/tenant_resolver.py` | +| 归属同步 | `tenant/domain/services/user_tenant_sync_service.py` | +| 挂载/卸载 | `tenant/domain/services/tenant_mount_service.py` | +| 租户服务 | `tenant/domain/services/tenant_service.py` | +| 管理员授撤 | `tenant/domain/services/tenant_admin_service.py` | +| 跨租户共享 | `tenant/domain/services/resource_share_service.py` | +| 授权主体租户校验 | `tenant/domain/services/f048_permission_subject.py` | +| 配额 | `role/domain/services/quota_service.py` | +| admin-scope 服务 | `admin/domain/services/tenant_scope.py` | +| 前端租户管理 | `src/frontend/platform/src/pages/TenantPage/` | +| 前端挂载 | `src/frontend/platform/src/pages/DepartmentPage/components/MountTenantDialog.tsx` | --- ## 相关文档 -- [系统架构总览](./01-architecture-overview.md) — 运行时组件和数据流全景 -- [数据模型与存储层](./07-data-models.md) — ORM 模型完整清单 -- [部署架构与配置](./08-deployment.md) — 配置系统和环境变量 -- [用户与权限体系](./10-permission-rbac.md) — RBAC/ReBAC 权限模型 -- [商业版 API 网关](./11-gateway.md) — Gateway 与多租户的集成方向 +- [系统架构总览](./01-architecture-overview.md) +- [用户与权限体系](./10-permission-rbac.md) — ReBAC/OpenFGA +- [数据模型与存储层](./07-data-models.md) +- [商业版 API 网关](./11-gateway.md) +- **对外口径的使用说明** → [`docs/customer-guides/多租户产品使用说明.md`](../customer-guides/多租户产品使用说明.md)(客户交付版,不含本文的实现细节与缺口记录) diff --git a/docs/architecture/14-app-factory-deployment.md b/docs/architecture/14-app-factory-deployment.md new file mode 100644 index 0000000000..70bc9f5d7f --- /dev/null +++ b/docs/architecture/14-app-factory-deployment.md @@ -0,0 +1,687 @@ +# 应用工场部署与配置 + +> F086 protocol v2 安装说明见 [runtime-manager/OPERATIONS.md](../../src/runtime-manager/OPERATIONS.md)。下文原协议1、本地策略和 manager 直连存储的配置属于升级输入,不能直接作为 v2 安装配置;最终验收状态以 [F086 validation](../../features/v3.0.0/086-vibe-runtime-delivery/validation.json) 为准。 + +> 面向运维 / 实施。**这一层默认不安装**——不装它,平台其余部分与 v2.x 完全一致。 +> 本文覆盖"从零把它装起来"的全过程:装什么、配什么、怎么验、装错了什么症状。 +> +> 平台整体部署(MySQL / Redis / Milvus / ES / MinIO / 后端 / 前端)见 +> [`08-deployment.md`](08-deployment.md),本文只讲**增量**。 + +## 这一层是什么 + +「应用工场」让开发者把本地写好的应用(Web 页面、表单、看板、小工具、后端服务) +用命令行一条 `bisheng deploy` 发布到平台上,成为与工作流、助手并列的第三种应用类型 +——**托管应用**。发布后它跑在平台管的容器里,用户从 `/apps/{slug}` 访问, +登录态与可见范围由平台统一管,应用自己不做登录。 + +它由**两个互相独立的开关**控制,两个都默认关闭,任意组合都能正常启动: + +| 开关 | config.yaml 键 | 关掉的后果 | +|------|---------------|-----------| +| **开放能力层** | `open_platform.enabled` | 密钥签发表单里没有 `model:invoke` / `identity:read` / `app:manage` 三个能力位,带这些位的请求被拒(错误码 26023)。命令行部署要用 `app:manage`,所以**用 CLI 就必须开** | +| **工场运行时层** | `app_runtime.enabled` | `/apps/{slug}` 渲染"本环境未启用应用工场"引导页;CLI 部署被 16207 明确拒绝(不是超时);平台其余功能零变化 | + +两者不是父子关系:只开 `open_platform` 可以让开发者拿到密钥调开放 API, +但发布不了托管应用;只开 `app_runtime` 时已有的托管应用能跑、能访问, +但没人能用 CLI 发新的。**要跑通完整闭环,两个都要开。** + +另有两个只管**管理面 / 功能面**的键,与上面两个开关正交,**默认开**(beta2 自 `75455c572` 起): + +| 键 | 关掉的后果 | +|----|-----------| +| `open_api.management_ui_enabled` | 管理后台「系统管理」里没有「服务账号 / 个人令牌」两个 tab。**关着时没有任何界面能新建服务账号、签发密钥**,CLI 就拿不到第一把 key——除非本部署不对外开放 API,否则不要关 | +| `open_api.pat_enabled` | 员工个人访问令牌整个功能关闭:工作台「设置 → 账号」不出现申请入口,已签发的令牌调用即被拒(部署级开着时,租户级开关仍默认关,要租户管理员另开) | + +## 组件与端口 + +运行时层在平台既有服务之外新增**两个进程**: + +| 服务 | 端口 | 职责 | 需要 docker 权限 | +|------|------|------|-----------------| +| **runtime-manager** | 8091 | 编排器。构建镜像、拉起 / 停止 / 重建实例、容量准入、15 秒一轮的自愈对账、提供实例路由与日志 | **是**(唯一被允许访问容器编排面的组件) | +| **app-proxy** | 8090 | 统一入口。解析 `/apps/{slug}` → 向后端问一次访问判定 → 剥掉伪造的身份头 → 注入真实访问者身份 → 反向代理到实例 | 否(刻意不给) | + +两个端口**都不对外暴露**:外部流量一律先进 nginx(3001),由 nginx 的 +`location /apps/` 转给 app-proxy。三方之间的调用全部经 HMAC 签名, +且**后端自身不持有任何容器编排能力**——它只向 runtime-manager 下发"期望它变成什么样" +的意图,这是本层最重要的安全边界。 + +``` +浏览器 ──► nginx :3001 + ├─ / → platform 前端 + ├─ /workspace/ → client 前端 + ├─ /api/… → backend :7860 + └─ /apps/{slug}/… → app-proxy :8090 + │ ① 问判定 ──► backend :7860 (内部授权端点,HMAC) + │ ② 问地址 ──► runtime-manager :8091 (HMAC) + └──► 应用容器(bisheng-apps 网络内,不映射宿主端口) + +backend :7860 ──(下发编排意图,HMAC)──► runtime-manager :8091 ──► dockerd +``` + +托管应用容器接在一个名为 `bisheng-apps` 的 bridge 网络上,**不映射任何宿主端口** +——它们只能被 app-proxy 访问到,这是「不绕过权限直连应用」的物理保证。 + +## 形态一:docker compose 单机 + +### 1. 准备数据目录 + +```bash +mkdir -p /opt/bisheng/app-data && chmod 755 /opt/bisheng/app-data +``` + +数据目录必须是**本机磁盘**。托管应用的内置数据库用 SQLite(WAL 模式), +放在 NFS / CIFS 等网络存储上会损坏。想换位置就设 `BISHENG_APP_DATA_ROOT` +(compose 用它同时决定卷的宿主侧路径和 `RTM_HOST_DATA_ROOT`,两者不会漂移)。 + +`bisheng-apps` 网络**由部署方创建,runtime-manager 不会自己建**——一个会偷偷建网的 +编排器会把"新机器上第一次发布必挂"这件事藏起来。compose 形态下这个"部署方"就是 +`docker-compose.yml`(文件末尾已声明该网络并显式写死 `name: bisheng-apps`), +**不需要手工建**;只有不用 compose 的形态才要自己跑一次: + +```bash +docker network create bisheng-apps # 仅 systemd / 非 compose 形态需要 +``` + +### 2. 生成三个密钥 + +```bash +export BISHENG_RTM_HMAC_SECRET=$(openssl rand -hex 32) +export BISHENG_APP_PROXY_HMAC_SECRET=$(openssl rand -hex 32) +export BISHENG_APP_OBO_SECRET=$(openssl rand -hex 32) +``` + +写进部署机的环境文件(compose 的 `.env` 或 systemd 的 `EnvironmentFile`), +**三个必须互不相同**,且 `BISHENG_APP_OBO_SECRET` 必须不同于平台的 `jwt_secret` +(相同时后端会拒绝签发身份令牌并打印告警——共用会让注入应用的临时令牌 +能被当平台会话 cookie 用)。 + +### 3. 改 config.yaml + +见下文「配置项详解」。**注意加键与发版的先后顺序**——见下文 +「⚠️ 升级顺序(不可颠倒)」,顺序反了后端直接起不来。 + +### 4. 启动 + +两个服务挂在 compose 的 `app-runtime` profile 后面,不带 profile 就根本不创建: + +```bash +cd docker/ + +# 平台本体(不含工场运行时层)——一如既往 +docker compose up -d + +# 追加工场运行时层:**每一条**相关的 compose 命令都要带 --profile +docker compose --profile app-runtime up -d runtime-manager app-proxy +``` + +漏掉 `--profile` 的症状是 `no such service`,不是静默不启动。 + +创建之后的日常运维用 `deploy.sh`,它会**自动补 `--profile`**,不用自己记: + +```bash +./deploy.sh logs runtime-manager +./deploy.sh restart runtime-manager app-proxy +./deploy.sh exec app-proxy +``` + +(`deploy.sh` 只有 `logs` / `version` / `exec` / `update` / `restart` 五个命令, +**没有 `start`**——首次创建容器还是得用上面的 `docker compose up -d`。) + +⚠️ 这两个服务在 compose 里是 **`build:` 而不是 `image:`**——它们从 +`src/runtime-manager/` 与 `src/app-proxy/` 现场构建。所以部署机上必须有这两份源码, +且第一次 `up` 会花几分钟构建;纯拉镜像的离线环境需要先在有网机器上 +`docker compose --profile app-runtime build` 再导出镜像。 + +### 5. 两个进程的环境变量 + +它们**不读 config.yaml**(独立包,不 import 平台代码),配置全部来自环境变量。 +与 `config.yaml` 之间只有一个值要对齐:HMAC 密钥。其余各项**只有环境变量一处来源**, +`config.yaml` 里没有对应项,写了也不生效(后端启动时会就此告警)。对不上的症状见文末「排障对照表」。 + +**runtime-manager**: + +| 环境变量 | 必填 | 对应的 config.yaml 项 | 说明 | +|---------|:---:|----------------------|------| +| `RTM_HOST` / `RTM_PORT` | | | 容器内监听 `0.0.0.0:8091`;宿主 systemd 形态用 `127.0.0.1:8091` | +| `RTM_HMAC_SECRET` | ✅ | `app_runtime.manager_hmac_secret` | 不一致 = 所有编排请求被回 401 | +| `RTM_DATA_ROOT` | ✅ | — | **本进程看到的**数据目录路径 | +| `RTM_HOST_DATA_ROOT` | compose 必填 | — | **宿主 dockerd 看到的**同一个目录。容器化跑时两者不同(容器内 `/app-data`,宿主 `/opt/bisheng/app-data`),不设会让应用数据落到一个没人看的地方且**不报错** | +| `RTM_NETWORK` | | — | 默认 `bisheng-apps` | +| `RTM_RESERVE_MB` / `RTM_OVERCOMMIT_RATIO` / `RTM_BUILD_RESERVE_MB` | | — | 容量准入,见下。**改这里,不是改 config.yaml** | +| `RTM_BUILD_INDEX_URL` | | — | 内网 pip 源(`python3.11` 模板)。不设 = 走镜像内默认源(公网 PyPI) | +| `RTM_BUILD_EXTRA_INDEX_URL` | 用 SDK 时必填 | — | 平台自身的 pip 简单索引 `http://<平台宿主地址>:7860/api/v1/dev-toolkit/simple/`,应用 `requirements.txt` 里的 `bisheng-sdk` 从这里装(第三方依赖仍走上面那个主源)。⚠️ **值必须是构建容器可达的地址**:bridge 网络里 `localhost` 指向容器自己,写成 `localhost` 构建必失败,日志读起来像"平台的 pip 源挂了" | +| `RTM_BUILD_EXTRA_TRUSTED_HOST` | 同上(http 时) | — | 上一行地址的主机部分;明文 http 索引不加它 pip 直接拒绝 | +| ↑ 配这两项时**同时配 `RTM_BUILD_INDEX_URL`** | | | pip 在主源与附加源里**都找**同一个包名、取版本高的那个。主源若是公网 PyPI,外部同名包只要版本更高就会顶掉平台这份。指到内网镜像即可关掉这条路 | +| `RTM_BUILD_NPM_REGISTRY` | | — | 内网 npm 源(`node20` 模板)。不设 = 走镜像内默认源(公网 registry);`static` 模板不拉任何包 | +| `RTM_DOCKER_HOST` | | — | 留空 = 本机 `/var/run/docker.sock` | +| `RTM_MINIO_ENDPOINT` / `RTM_MINIO_ACCESS_KEY` / `RTM_MINIO_SECRET_KEY` / `RTM_MINIO_SECURE` | | 可与 `minio.*` 同值 | 托管应用**附件存储**(AC-45)。不设 = 句柄仍注入,应用的存储调用答 503,`runtime-status` 的 `attachment_storage` 会指出来 | +| `RTM_STORAGE_BUCKET` | | — | 缺省 `bisheng-apps`,**独立于平台的 `bisheng` 桶**(nginx `location /bisheng/` 会把任意 key 转给 MinIO,附件不能进那个桶)。首次使用自动建桶、不设匿名策略、不挂 nginx location | +| `RTM_STORAGE_MAX_FILE_MB` | | — | 单文件上限,缺省 20;同值注入应用为 `BISHENG_APP_STORAGE_MAX_FILE_MB` | +| `RTM_APP_FACING_BASE_URL` | systemd 必填 | — | **应用容器**访问 runtime-manager 的地址(注入为 `BISHENG_APP_STORAGE_ENDPOINT` 前缀)。compose 下 `http://runtime-manager:8091`;systemd 下写 `bisheng-apps` 网桥网关,见形态二 | + +**app-proxy**: + +| 环境变量 | 必填 | 对应的 config.yaml 项 | +|---------|:---:|----------------------| +| `APP_PROXY_HOST` / `APP_PROXY_PORT` | ✅ | — | +| `APP_PROXY_BACKEND_BASE` | ✅ | 平台 API 基地址(compose 下 `http://backend:7860`) | +| `APP_PROXY_MANAGER_BASE` | ✅ | `app_runtime.manager_base_url` | +| `APP_PROXY_BACKEND_SECRET` | ✅ | `app_runtime.proxy_hmac_secret` | +| `APP_PROXY_MANAGER_SECRET` | ✅ | `app_runtime.manager_hmac_secret` | +| `APP_PROXY_ENTRY_BASE_URL` | | `app_runtime.entry_base_url` | +| `APP_PROXY_WS_PROXY_ENABLED` | | —(默认 `true`;设 `false` 时允许的 WebSocket 升级以 4501 关闭而不反代) | +| `APP_PROXY_WS_MAX_LIFETIME_SECONDS` | | `app_runtime.ws_max_lifetime_seconds`(**仅兜底**:入口判定里带的后端值优先) | +| `APP_PROXY_WS_LIFETIME_JITTER_SECONDS` / `APP_PROXY_WS_REAUTHORIZE_INTERVAL_SECONDS` | | —(默认 30 / 30:连接寿命抖动上限;长连接周期性重问判定的间隔) | + +⚠️ **前六项的默认值全是单机回环便利值**(`127.0.0.1` + 空密钥)。在容器里 +`127.0.0.1` 指的是容器自己,空密钥是 fail-closed——进程照样启动、健康检查照样通过、 +每一个请求都渲染兜底页。所以它们**必须显式设置**,不能靠默认值。 + +**两个服务都必须同时接在 `default` 与 `bisheng-apps` 两张网上**(compose 文件里已配好): + +- `default` —— nginx 靠服务名 `app-proxy` 解析上游,backend 靠服务名找 runtime-manager; +- `bisheng-apps` —— app-proxy 拿到的上游是应用容器在该网络上的 bridge IP,跨 bridge + 网络会被 docker 的隔离规则丢包;runtime-manager 的启动探活也是直接 HTTP 访问同一个 IP。 + +⚠️ compose 里只要给某个 service 写了 `networks:`,它就**不再自动接 default**, +所以两张网都得列上——漏一张的症状分别是"nginx 解析不到 app-proxy"和"应用永远探活不过"。 + +宿主 systemd 形态下两个进程都在宿主机上,宿主本身可达 bridge 网段,无需额外处理。 + +## 形态二:systemd(信创 / 无 compose 环境) + +> ⚠️ **当前代码不能用这个形态运行。本节只适用于协议 1、且还没有 F054 宿主隔离的旧版本。** +> 当前 runtime-manager 每次查应用路由前都校验宿主隔离证明(`docker_backend.py` 的 `assert_host_isolation`), +> 没有关闭开关。宿主隔离工具要求出站代理是接在应用网络上的容器(配置项 `proxy_container`), +> 并在应用网桥的 INPUT 链上只放行登记过的控制容器、其余全部 DROP。manager 和出站代理以宿主进程运行时, +> 应用访问网关地址上的出站代理(3128)和附件存储(8091)都会被拦。 +> +> 2026-10-27 在 114 验证:升级到含 F054 的版本后,所有托管应用显示「应用恢复中」, +> manager 查路由返回 500(`service_identity_not_configured`)。114 已把这一层改为容器形态(协议 v2), +> 存量应用按 [OPERATIONS.md](../../src/runtime-manager/OPERATIONS.md) 的 v2 升级流程迁移。 +> 新环境用形态一或 OPERATIONS.md 的 v2 安装;既有 systemd 部署升级前必须先规划 v2 迁移。 + +两个进程作为宿主机上的独立单元运行,其余与 compose 形态一致: + +| 单元 | 监听 | 依赖 | 运行身份 | +|------|------|------|---------| +| `bisheng-runtime-manager.service` | `127.0.0.1:8091` | `After=docker.service` | root 或 docker 组(需访问 docker socket) | +| `bisheng-app-proxy.service` | `127.0.0.1:8090` | `After=bisheng-api.service` | 普通用户即可,**不要**给 docker 权限 | + +与 compose 形态的差异只有三点: + +1. `RTM_HOST_DATA_ROOT` **不用设**——进程与 dockerd 在同一个文件系统视图里, + 两个路径本来就是同一个。 +2. `manager_base_url` / `APP_PROXY_BACKEND_BASE` 用回环地址而不是 service 名。 +3. nginx 的 `location /apps/` 里 `proxy_pass` 指向 `127.0.0.1:8090`, + `resolver` 那行不需要(见下)。 + +密钥经 `EnvironmentFile` 注入、不落配置文件: + +``` +/etc/bisheng/runtime-manager.env # RTM_HMAC_SECRET / RTM_DATA_ROOT / RTM_NETWORK … +/etc/bisheng/app-proxy.env # APP_PROXY_BACKEND_BASE / …_MANAGER_BASE / 两个 SECRET +``` + +单元文件模板在 `features/v3.0.0/054-app-domain-runtime/deploy/` +(`bisheng-runtime-manager.service` / `bisheng-app-proxy.service`),真身由各环境的 +部署仓维护。装好后把两者加进平台 `bisheng.target` 的 `Wants=` 与部署脚本的服务清单。 + +两个单元都只监听 `127.0.0.1`:前面永远有 nginx,HMAC 是防篡改不是防暴露, +把编排端口挂到 `0.0.0.0` 等于把编排能力挂到网上。 + +**附件存储句柄的例外**(AC-45):托管应用要从容器里回调 runtime-manager 存取附件, +而 `127.0.0.1` 在容器里是容器自己。systemd 形态把 runtime-manager 绑到 +`bisheng-apps` 网桥的**网关地址**(宿主本地、外网不可达,仍不是 `0.0.0.0`): + +```bash +GW=$(docker network inspect bisheng-apps -f '{{(index .IPAM.Config 0).Gateway}}') # 形如 172.18.0.1 +# /etc/bisheng/runtime-manager.env +RTM_HOST=$GW +RTM_APP_FACING_BASE_URL=http://$GW:8091 +# 同步改 backend config.yaml 的 app_runtime.manager_base_url 与 app-proxy 的 APP_PROXY_MANAGER_BASE +``` + +⚠️ **`RTM_HOST` 只有在单元的 `ExecStart` 从它取值时才生效。** 老版本的 +`bisheng-runtime-manager.service` 把 `--host 127.0.0.1` 写死在 `ExecStart` 里, +EnvironmentFile 里设了也不生效——而**只设 `RTM_APP_FACING_BASE_URL`、没换绑定地址 +是比忘记设更常见的半配置**:知道有这个变量的人会先设它。2026-09-16 在 114 上实测: +`attachment_storage` 答 `ok=true`、写着「apps dial http://172.22.0.1:8091」, +而容器里 `curl` 那个地址直接连接被拒。现在两头都修了——单元改成 +`--host ${RTM_HOST}`,自检也会在「广播的地址 ≠ 实际绑定」时报 `ok=false`。 +升级既有部署时请一并替换单元文件并 `systemctl daemon-reload`。 + +不这样配的症状:`runtime-status` 的 `attachment_storage` 为 `ok=false`, +应用里的 SDK `storage` 调用连接被拒。 + +## nginx:`location /apps/` + +`docker/nginx/conf.d/default.conf` 已内置。自建 nginx 时照抄,注意三处: + +```nginx +location /apps/ { + # ① compose 形态必须用「变量 + resolver 延迟解析」 + resolver 127.0.0.11 valid=10s ipv6=off; + set $bisheng_app_proxy http://app-proxy:8090; + proxy_pass $bisheng_app_proxy$request_uri; + + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # ② 应用可能流式输出,缓冲会把流攒成一坨 + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_buffering off; + client_max_body_size 1024m; + + # ③ 只兜 nginx 自己产生的错误(上游连不上) + error_page 502 503 504 = @apps_unavailable; +} + +location @apps_unavailable { + rewrite ^ /api/v1/apps/_unavailable break; + proxy_pass http://backend_server; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +} +``` + +- **① 为什么不能写成 `proxy_pass http://app-proxy:8090;`**:nginx 在**加载配置时** + 解析静态 `proxy_pass` 里的主机名。整层不装时 app-proxy 容器不存在、主机名解析不了, + **整个 nginx 起不来**——症状是升级后前端全站白屏,而不是"托管应用打不开"。 + 变量式写法把解析推迟到请求期,解析失败只影响这一个 location。 + 变量式 `proxy_pass` 不会自动带上原始 URI,所以要显式拼 `$request_uri`。 + systemd 形态指向 `127.0.0.1:8090`(IP 无需解析),可以写静态形式。 +- **③ 刻意不开 `proxy_intercept_errors`**:开了会把应用自己返回的 502/503 也换成平台 + 兜底页,那是在替应用说谎。 + +宿主 systemd 形态下,环境里有几份 nginx 配置就要改几份(对外端口那份也要)。 + +## 配置项详解 + +模板见 `docker/bisheng/config/config.yaml` 文件末尾的「应用工场」段。 +**所有键都是进程级配置,改完必须重启后端**(不是数据库里的热配置,界面上改不了)。 +**没有 `BS_*` 环境变量覆盖机制**——`app_runtime` / `open_platform` 只能写在 +config.yaml 里;要避免明文密钥,用 `!env ${VAR}` 语法从环境变量取值。 + +> ⚠️ `!env ${VAR}` 在**环境变量不存在时会直接报错拒启**。要么保证部署脚本一定注入, +> 要么把整行注释掉。 + +### `open_platform` / `open_api` + +| 键 | 默认值 | 含义 | +|----|-------|------| +| `open_platform.enabled` | `false` | 见上文开关表 | +| `open_api.management_ui_enabled` | `true` | 管理后台是否显示「服务账号 / 个人令牌」tab。只管界面显隐,不影响已签发密钥的校验;关掉后界面上无法新建服务账号、签发 CLI 部署用的密钥 | +| `open_api.pat_enabled` | `true` | 员工个人访问令牌功能开关(部署级;租户级另有一层开关、默认关,两者都开才生效) | +| `open_api.pat_admin_ttl_days` | `7` | 管理员持有的个人令牌有效期上限(天),比租户默认值短时取短者 | +| `open_api.public_base_url` | 空 | 平台对浏览器的地址,写进下载的技能包与安装提示;留空按反代的 `X-Forwarded-Proto` / `X-Forwarded-Host` 推导。后端在商业网关之后、带路径前缀或反代不转发这两个头时要显式配;**不是绝对 http(s) URL 时启动即失败** | +| `open_api.service_account_idle_days` | `90` | 多少天无调用后在密钥列表里标记"闲置" | +| `open_api.credential_cache_ttl_seconds` | `3` | 凭据校验的正缓存秒数。**硬上限 5**,配更大不会报错,按 5 生效 | + +### `app_runtime` + +| 键 | 默认值 | 怎么取值 | +|----|-------|---------| +| `enabled` | `false` | 本机是否安装了运行时层 | +| `manager_base_url` | `http://127.0.0.1:8091` | systemd 形态保持默认;compose 形态改 `http://runtime-manager:8091` | +| `manager_hmac_secret` | `""` | `openssl rand -hex 32`,与 `RTM_HMAC_SECRET` 一致 | +| `proxy_hmac_secret` | `""` | `openssl rand -hex 32`,与 `APP_PROXY_BACKEND_SECRET` 一致 | +| `obo_secret` | `""` | `openssl rand -hex 32`,**必须不等于 `jwt_secret`** | +| `obo_ttl_seconds` | `900` | 注入应用的身份令牌寿命(秒),最小 60 | +| `entry_base_url` | `""` | 用户实际访问平台的对外基地址,如 `https://bisheng.example.com`(不带结尾斜杠)。**强烈建议配**:留空时应用地址只能给出相对路径 `/apps/{slug}`,浏览器里能用,但二维码、CLI 回显、外发链接都是残缺的 | +| `ws_max_lifetime_seconds` | `28800` | 单条被反代 WebSocket 的授权寿命上限(秒)。能力后置,当前占位 | +| `access_log_merge_window_seconds` | `1800` | 托管应用访问记录的合并窗口(秒):同一用户在窗口内反复进入同一应用只记一条(Redis 键 `app_access:{app_id}:{user_id}` 的 TTL)。30 分钟对齐会话粒度,刷新页面不会多出一条;Redis 不可达时照常记录、只是不去重 | +| `max_package_mb` | `50` | 上传包体积上限(MB) | +| `max_unpacked_mb` | `200` | 解包后总体积上限(MB),防解压炸弹 | +| `max_package_entries` | `20000` | 包内条目数上限,防海量小文件 | +| `default_tiers` | 不配 | 出厂资源档位覆盖,见下 | +| `preview_ttl_days` | `7` | 审核人临时预览实例存活天数。能力后置,当前占位 | + +三个密钥的对应关系一览(**配错的症状全是"看起来在跑但什么都进不去"**,务必核对): + +| config.yaml | 对端环境变量 | 配错的症状 | +|-------------|-------------|-----------| +| `app_runtime.manager_hmac_secret` | runtime-manager 的 `RTM_HMAC_SECRET`
app-proxy 的 `APP_PROXY_MANAGER_SECRET` | 所有编排动作返回 16121「应用运行时不可用」,容易被误判成 dockerd 挂了 | +| `app_runtime.proxy_hmac_secret` | app-proxy 的 `APP_PROXY_BACKEND_SECRET` | 每次访问 `/apps/{slug}` 都是兜底页;后端日志里是 401 | +| `app_runtime.obo_secret` | —(只在后端签名,app-proxy 不持有) | 留空(或与 `jwt_secret` 相同)→ 后端不签身份令牌、只按进程记一条 error 日志,**访问照常放行**,所以症状不是「进不去」而是「进得去但平台不知道是谁进来的」:托管应用的模型调用全部记成 `subject_kind=app_self`(审计的用户维度整列为空),知识检索因取不到访问用户而被拒。**已有消费方后必须配**(`openssl rand -hex 32`,且不等于 `jwt_secret`);核对方式是日志里没有 `obo_secret is not configured` | + +空密钥是 **fail-closed**(拒绝一切调用),不是"免鉴权"。 + +### 资源档位 + +不配 `default_tiers` 时用内置三档:**轻量 0.5C/1G · 标准 2C/4G · 性能 4C/8G**。 + +⚠️ **档位只在首次启动播种时写库**,之后改 config.yaml 无效(播种按 code 幂等)。 +内存吃紧的机器**必须在第一次启动前**就配好,否则只能事后到超管界面逐个改。 +配置会**整体替换**内置三档而不是合并——只写两档就只有两档。 + +```yaml +app_runtime: + default_tiers: + light: + name: "轻量" + cpu_millicores: 500 # 单位是毫核:500 = 0.5 核 + memory_mb: 512 + description: "内部工具、表单、看板类应用" + sort_order: 0 + standard: + name: "标准" + cpu_millicores: 1000 + memory_mb: 1024 + sort_order: 1 +``` + +### 容量准入:按**实际可用内存**配,不是按总内存 + +启动实例和构建镜像都要过两道闸,任一不过就返回 16125「运行环境容量不足」, +应用停在「待上线(资源不足)」状态。 + +> **这几个数只在 runtime-manager 的环境变量里生效**:`RTM_RESERVE_MB` / +> `RTM_OVERCOMMIT_RATIO` / `RTM_BUILD_RESERVE_MB`。compose 形态改 +> `docker-compose.yml` 的 runtime-manager 服务(或对应的 `BISHENG_RTM_*`), +> systemd 形态写进它的 `EnvironmentFile`,**改完要重启该进程**(配置只在启动时读一次)。 +> 判定发生在这个进程里,后端的 `config.yaml` 既看不到也改不动它。 + +- **闸①(此刻真有多少)**:`MemAvailable - reserve_mb ≥ 本次所需`。 + `MemAvailable` 是 `/proc/meminfo` 里内核自己的估算,也就是 `free -m` 的 + **available 列**(不是 free 列,后者会把页缓存算成"已用"而误拒健康的机器)。 + `reserve_mb` 是留给平台本体(uvicorn、三个 celery worker、灵思 worker、 + 商业版的 JVM)抖动的余量,**不是保险系数**。 +- **闸②(已经许诺出去多少)**:`已运行实例的规格之和 + 本次 ≤ 总内存 × overcommit_ratio`, + CPU 同理按 `nproc × overcommit_ratio`。挡的是"十个刚起来的应用各用 40MB 都过了闸①、 + 等它们热起来机器就没了"。 + +**构建也走同一道门**(按 `build_reserve_mb` 计),因为 `pip install` 一个没有 wheel 的 +包正是那种"什么都还没在跑就把机器压垮"的内存尖峰。 + +怎么定这几个数: + +1. 在目标机器上跑 `free -m`,看 **available** 那一列,记为 A。 +2. `reserve_mb` 取 A 的 20%~30%,且不低于 2048。 +3. 用 `A - RTM_RESERVE_MB` 除以最常用档位的内存,得到大致能跑几个应用。 + ⚠️ **也算一遍 CPU**:`nproc × RTM_OVERCOMMIT_RATIO ÷ 档位核数`。两个数取小的那个才是 + 真上限,而先撞的往往是 CPU——8 核按 0.8 只有 6.4 核可承诺,轻量档若占满一整核, + 第 7 个应用必被拒,此时机器的内存和负载都还很空。 +4. **轻量档 0.5C/1G 是安全的**;**性能档 4C/8G 会实打实挤压平台本体**—— + 在只有 16GB 且同机跑着 MySQL / ES / Milvus 的机器上,一个性能档实例就足以 + 让知识库解析开始 OOM。这类机器建议把档位整体调小,而不是靠调低 + `RTM_RESERVE_MB` 硬塞。⚠️ `default_tiers` **只在首次启动播种时生效**:表一旦落库, + 改它和改代码常量都不再有任何影响,只能直接更新 `resource_tier` 行,新规格自各应用 + 下一次发布或重新上线起生效。 + +## ⚠️ 升级顺序(不可颠倒) + +**先发代码 → 再往 config.yaml 加键 → 再全量重启。** + +后端加载 config.yaml 时,对**不认识的顶层键直接抛 `KeyError` 拒绝启动** +(`bisheng/common/services/config_service.py` 的 `load_settings_from_yaml`)。 +所以把 `app_runtime:` / `open_platform:` 加到还没升级到 v3.0 的镜像上, +后端**起不来**——症状是容器反复重启,日志里一行 `Key app_runtime not found in settings`, +看起来像"升级把平台搞坏了",实际只是顺序反了。 + +标准升级步骤: + +```bash +# 1. 先把代码 / 镜像升到 v3.0(此时 config.yaml 还是老的,键都认识,能正常起) +cd docker/ && ./deploy.sh update + +# 2. 确认后端已经是新版本再改配置 +docker compose logs backend | tail -20 # 起来了、健康检查过了 + +# 3. 加键(app_runtime / open_platform;open_api 各键已默认开,按需覆盖) +vi bisheng/config/config.yaml + +# 4. 全量重启:API、三个 celery worker、beat、灵思 worker 一个都不能漏 +./deploy.sh restart backend backend_worker + +# 5. 起运行时层(compose 会自己建 bisheng-apps 网络) +docker compose --profile app-runtime up -d runtime-manager app-proxy +``` + +第 4 步**必须是全量重启**:`app_runtime` 是进程级配置,只重启 API 会让 celery +侧仍然认为这一层没装,表现为界面上能操作但后台动作静默不生效。 + +**回退**:把 `app_runtime.enabled` 改回 `false`、重启后端、 +`docker compose --profile app-runtime stop runtime-manager app-proxy` 即可 +(**`stop` 也要带 `--profile`**,否则 compose 会说 `no such service`)。 +键本身可以留着(新代码认识它),只有降级回 v2.x 镜像时才必须把两块整体删掉或注释掉。 + +### ⚠️ 已跑过 vibe 版 open_api 的库不能原地升级 + +2026-09-10 之前用 `3.0-vibe` 分支部署过、库里已有 `service_account` / `api_credential` 两张表的环境 +(典型是 114),**不能直接 `alembic upgrade head`**:两个分支的这两张表同名不同构,新迁移的建表 +守卫看到表已存在就跳过,后端启动即崩。必须停服 → 备份 → 删掉这两张旧表 → 再升级 → 重建服务账号与密钥, +步骤见 `features/v3.0.0/beta2-openapi-base-migration.md` §4。托管应用的 `app*` 表不受影响。 +这个过程还要发布当前代码生成的 OpenFGA 模型(以发布脚本 dry-run 输出的 checksum 为准):模型 checksum 变了而不发布,进程发现与 SQL 里的 ACTIVE release 对不上,所有权限判定一律返回 503。版本名只是可读标签:本分支不占用发版线的模型编号,命名为「所基于的发版线版本号 + `-app`」(当前 `f048-v5-app`);判定只看 checksum,改名不需要重新发布。 + +## 整层不装 + +「不装」是产品明确支持的形态,不需要任何额外操作: + +- config.yaml 里两个 `enabled` 保持 `false`(或整块不写); +- compose 启动时不带 `--profile app-runtime`,两个容器根本不创建; +- nginx 的 `location /apps/` 可以留着——变量式 `proxy_pass` 在上游不存在时 + 只让这一个 location 出错,回落到后端渲染的引导页,不影响 nginx 其余部分。 + +此时的用户可见行为:访问 `/apps/任意` 得到"本环境未启用应用工场"引导页; +CLI 部署被 16207 拒绝并说明原因(不是超时、不是 500)。 + +## 上线自检 + +按顺序核对,每一步都能独立定位问题: + +```bash +# 1. 两个开关是否透出来(匿名可读) +curl -s http://:3001/api/v1/env | python3 -m json.tool | grep -E 'app_runtime|open_platform|open_api_management|personal_token' +# 期望:app_runtime_enabled: true, open_platform_enabled: true, +# open_api_management_enabled: true(否则管理后台没有「服务账号」tab) + +# 2. 运行时层自检(需登录态,超管账号) +curl -s -b "access_token_cookie=" http://:3001/api/v1/apps/runtime-status +``` + +`runtime-status` 返回四类信息,逐条看: + +- `backend_available` — 编排后端(dockerd)是否可达; +- `supported_runtimes` — 本部署实际装了哪些运行时模板。完整安装是 `["node20", "python3.11", "static"]` + (v3.0 首发镜像只有 `python3.11`);清单里的 `runtime` 必须取其中之一,见下文「运行时模板与基础镜像」; +- `capacity` — 当前容量快照:`total_mb` / `mem_available_mb` / `committed_mb` / `cpu` / + `committed_cpu` / `reserve_mb` / `overcommit_ratio` / `instances`。`readable=false` 表示 + 这台机器的 `/proc/meminfo` 读不到——那本身就是要修的事,不会被折叠成 500; +- `preflight[]` — 部署自检,每项直接给出**修复动作**而不是症状: + +| 检查项 | 不过的含义 | +|--------|-----------| +| `orchestration_backend` | 连不上 dockerd。检查 socket 挂载 / 运行身份 | +| `application_network` | `bisheng-apps` 网络不存在 → `docker network create bisheng-apps` | +| `data_root_writable` | 数据目录不可写 → 检查 `data_root` / `RTM_DATA_ROOT` 与目录权限 | +| `host_data_root_mapping` | compose 形态 `RTM_HOST_DATA_ROOT` 不是绝对路径 | +| `runtime_templates` | 一个运行时模板都没装(镜像不完整) | +| `base_images` | 基础镜像不在本地,`detail` 里逐个列出缺哪些。离线环境要**先手动 pull**(三个模板各一个,见下节),否则第一次构建会挂很久然后失败 | +| `attachment_storage` | 附件存储没配(`RTM_MINIO_*`)、桶配成了公共桶 `bisheng`、应用容器够不到本进程(systemd 形态没给 `RTM_APP_FACING_BASE_URL`),或**给了 `RTM_APP_FACING_BASE_URL` 却仍绑在回环上**(广播的地址没人应答)。detail 里写明改哪个变量 | + +再走一遍端到端:用**非管理员账号**访问一个已上线应用的 `/apps/{slug}` +(管理员会短路权限判定,用管理员验等于没验)。 + +### 运行时模板与基础镜像 + +清单 `runtime` 的每个取值对应 runtime-manager 里一个平台维护的 Dockerfile 模板 +(`src/runtime-manager/runtime_manager/templates//`);开发者不写 Dockerfile, +非 root、只读根文件系统、exec 形式入口这套安全基线三个模板一致: + +| `runtime` | 基础镜像 | 构建时做什么 | 启动命令 | +|-----------|---------|-------------|---------| +| `python3.11` | `python:3.11-slim` | `requirements.txt` 非空才 `pip install`(走 `RTM_BUILD_INDEX_URL`,`bisheng-sdk` 走 `RTM_BUILD_EXTRA_INDEX_URL` 指向的平台简单索引) | `BISHENG_APP_START` → `Procfile` `web:` → `main.py` → `app.py` | +| `node20` | `node:20-slim` | `package.json` 有依赖才装:有 `package-lock.json` / `npm-shrinkwrap.json` 用 `npm ci`,否则 `npm install`(走 `RTM_BUILD_NPM_REGISTRY`);有 `scripts.build` 先 `npm run build` 再裁掉 devDependencies | `BISHENG_APP_START` → `Procfile` `web:` → `package.json` `scripts.start` → `main` → `server.js` / `index.js` / `app.js` / `main.js` | +| `static` | `nginx:1.27-alpine` | 不装任何包。找 `index.html`:包根目录 → `dist/` → `build/` → `public/`,取第一个命中的目录 | 无应用进程;nginx 以非 root 跑,配置、pid、临时文件全在 `/tmp` | + +这些决定在**渲染 Dockerfile 时**就从源码树里读定(`source_facts`),渲染出的 Dockerfile 写明做了什么; +源码树不满足(`static` 没有 `index.html`、`package.json` 不是合法 JSON)会在 `render_dockerfile` 阶段 +以一句话失败,不会到 `docker build` 才报 `COPY failed`。没有依赖的应用**完全不碰包管理器**,离线机器只要有基础镜像就能构建。 + +离线 / 信创环境预拉(与 `base_images` 检查项一一对应): + +```bash +docker pull python:3.11-slim node:20-slim nginx:1.27-alpine +``` + +只想支持部分运行时,删掉对应模板目录即可:`supported_runtimes` 按目录动态给出,预检会拒绝清单里用了未装模板的应用并列出可用取值。 + +**装完新模板后的一次性验证**(单元测试只覆盖渲染结果,镜像能不能起来只有真 dockerd 能回答)。在部署机上, +分别拿一个最小 `node20` 项目(`server.js` 监听 `PORT`,无 `package.json`)和一个最小 `static` 项目(一个 `index.html`) +走一遍 `bisheng deploy`,然后: + +```bash +# 1. 三个镜像都在本地、预检全绿 +curl -s -b "access_token_cookie=" http://:3001/api/v1/apps/runtime-status | python3 -m json.tool | grep -A2 base_images + +# 2. 构建成功、容器以 uid 10001 跑、根文件系统只读 +docker ps --filter name=bisheng-app- --format '{{.Names}} {{.Image}} {{.Status}}' +docker inspect <容器名> --format '{{.Config.User}} {{.HostConfig.ReadonlyRootfs}} {{.State.Health.Status}}' # 期望:bisheng true healthy + +# 3. 入口能开、静态包的子路径与前端路由都回落到 index.html +curl -s -o /dev/null -w '%{http_code}\n' http://:3001/apps// +curl -s -o /dev/null -w '%{http_code}\n' http://:3001/apps//some/route # static:200 +curl -s -o /dev/null -w '%{http_code}\n' http://:3001/apps//missing.js # static:404,不是 200 + +# 4. node20 的 SIGTERM 到得了应用(停止应在 10s 优雅窗口内结束,而不是被 KILL) +time docker stop <容器名> +``` + +## 排障对照表 + +| 症状 | 最可能的原因 | +|------|------------| +| 后端反复重启,日志 `Key app_runtime not found in settings` | 顺序反了:先加的键、后发的代码。见「⚠️ 升级顺序(不可颠倒)」 | +| 升级后**前端全站白屏**(不只是应用打不开) | nginx 里 `location /apps/` 写成了静态 `proxy_pass http://app-proxy:8090`,而 app-proxy 没起 → nginx 整个加载失败 | +| 访问 `/apps/{slug}` 得到"本环境未启用应用工场" | `app_runtime.enabled` 仍是 `false`,或后端没重启 | +| 访问 `/apps/{slug}` 一直是"应用暂时不可用"兜底页 | ①`proxy_hmac_secret` 与 `APP_PROXY_BACKEND_SECRET` 不一致(后端日志 401);②`APP_PROXY_BACKEND_BASE` 指向了 `127.0.0.1`(在容器里就是它自己) | +| 裸访问 `/apps/{slug}`(不带结尾斜杠)白屏 | 缺 308 跳转,相对路径资源解析到了别的应用。确认 app-proxy 是当前版本 | +| 所有上线 / 下线动作返回 16121「应用运行时不可用」 | `manager_hmac_secret` 与 `RTM_HMAC_SECRET` 不一致(**不是** dockerd 挂了,先查这个) | +| 上线卡在「待上线(资源不足)」 | 容量准入没过。看 `runtime-status` 的 `capacity`:先比 `committed_cpu` 与 `cpu × overcommit_ratio`(CPU 常常先于内存撞顶),再看内存。调 runtime-manager 的 `RTM_RESERVE_MB` / `RTM_OVERCOMMIT_RATIO`(**不是 config.yaml**,改完重启该进程),或改用更小的档位 | +| 构建一直失败在拉包 | 内网无外网出口 → `RTM_BUILD_INDEX_URL` 指向私有 pip 源(`python3.11`)/ `RTM_BUILD_NPM_REGISTRY` 指向私有 npm 源(`node20`)。没有依赖的应用不会拉包,先确认它是不是真的需要 | +| 构建失败只在 `bisheng-sdk` 这一行 | ①`RTM_BUILD_EXTRA_INDEX_URL` 没配;②配成了 `localhost`(构建容器里那是它自己);③平台没发布 SDK 安装件——`curl <平台>/api/v1/dev-toolkit/versions` 看 `data.sdk` 是不是 null,是就补 `bash scripts/pack_sdk_wheel.sh` 并提交 | +| `static` 应用预检失败在 `render_dockerfile`,提示找不到 `index.html` | 构建产物在 `dist/` / `build/` 下,而 CLI 打包默认不带这两个目录 → 项目里加 `.bishengignore` 写一行 `!dist/`,或把 `index.html` 放到包根目录 | +| `node20` 应用起来了但一直不健康 | 应用没监听 `PORT` / 绑了 `127.0.0.1`(与 python 一样),或 `scripts.start` 里的命令依赖 `npm start` 才有的行为——启动命令是直接执行的,不经过 npm | +| 应用能跑,但重启后数据没了 | compose 形态漏配 `RTM_HOST_DATA_ROOT`,数据落在了容器内路径对应的宿主目录之外 | +| CLI `bisheng deploy` 报 16207 | 该环境没装运行时层(或 `enabled` 是 false) | +| CLI 拿不到 `app:manage` 能力位 | `open_platform.enabled` 是 `false` | +| 管理后台「系统管理」里没有「服务账号」tab | `open_api.management_ui_enabled` 被显式配成了 `false`,或后端没重启 | +| 升级后后端起不来,迁移日志提示 `service_account` / `api_credential` 表已存在或列不匹配 | 这台机器跑过 vibe 版 open_api,不能原地升级——见上文「已跑过 vibe 版 open_api 的库不能原地升级」 | +| 点上线 / 下线"没反应"约 10 秒 | 停容器有固定的 10 秒优雅停机窗口,属正常;不要在这期间重复点 | + +日志位置: + +```bash +./deploy.sh logs runtime-manager # 等价于 docker compose --profile app-runtime logs -f +./deploy.sh logs app-proxy +journalctl -u bisheng-runtime-manager -f # systemd 形态 +tail -f /tmp/bisheng-app-proxy.log # systemd 形态(单元模板里的落盘位置) +``` + +托管应用自身的日志经平台界面(应用详情 → 日志)与 CLI 查看,保留窗口 = +容器日志轮转窗口(每应用 30MB),产品口径是"最近的运行日志",不承诺永久留存。 + +### 按事件名捞日志 + +三个进程各有一组**结构化事件**,事件名就是 grep 的锚点(字段以 `键=值` 跟在事件名后面, +没有值的字段打 `-`)。排障时先按事件名收窄,再看字段,不要按中文提示语去搜——提示语会改,事件名不会。 + +| 事件 | 进程 | 字段 | 什么时候看它 | +|------|------|------|------------| +| `app_proxy.request` | app-proxy | `request_id` `slug` `user_id` `decision` `reason` `cache_hit` `upstream_status` `latency_ms` | 「某个人打不开某个应用」。`decision` 是判定结果,`upstream_status` 为 `-` 表示请求**根本没到应用**(被拒或走了兜底页),有值才是应用自己的回答。已登录却被拒的行(`decision=forbidden` / `stopped`)同样带 `user_id`;`login` 时还没有访问者可言,`not_found` 按 AC-29 什么都不透出。**一次请求一行**,裸路径的 308 补斜杠也算一行(`reason=trailing_slash_redirect`),所以同一个人打开一个应用通常会看到两行 | +| `app_proxy.header_strip` | app-proxy | `request_id` `slug` `stripped` | **WARNING**。客户端自己带了平台身份头,已被剥离。偶发 = 集成配错;频次异常 = 有人在试探伪造身份(AC-32 在生产上唯一可观测的信号) | +| `app_proxy.fallback` | app-proxy | `request_id` `slug` `kind` `reason` | 兜底页分布。`kind=recovering` 变多 = 运行时层在挣扎;`kind=deploying` 变多 = 发布窗口变长 | +| `rtm.intent` | runtime-manager | `kind` `app_id` `result` `latency_ms` | 「点了上线没反应」。`result` 非 `ok` 时是 WARNING,值就是后端映射 161xx 用的那个机器码 | +| `rtm.reconcile` | runtime-manager | `desired` `actual` `actions` | 每轮对齐做了什么。同一个应用每轮都在 `recreated` / `started` = 崩溃循环被 daemon 的退避掩盖了。`realigned` = 实例的出站代理变量与当前出站白名单配置不一致、已蓝绿替换(开关出站白名单或改代理地址后,每轮一个应用,属预期)。`actual=-1` = **这轮没读到编排后端**(不是"宿主上没有实例"),同时会带 `backend_error`;无动作的轮次降到 DEBUG,默认级别看不到 | +| `rtm.rebuild` | runtime-manager | `app_id` `container` `generation` `reason` | **WARNING**。存活但不健康触发的重建。**频次异常 = 应用本身有问题**,不是平台在抖 | +| `rtm.admission_reject` | runtime-manager | `purpose` `reason` `required_mb` `required_cpu` `snapshot` | 容量拒绝。`snapshot` 与 `runtime-status` 的 `capacity`、与用户看到的「待上线(资源不足)」成因**是同一份数据** | +| `app.state_transition` | backend | `app_id` `from_state` `to_state` `reason` `actor` `result` | 应用态每一次变更尝试。`result=lost_race`(并发抢跑)与 `result=refused`(非法跃迁)**不进审计表**,只有这里看得到——「状态卡住不动」先查这两个 | + +```bash +journalctl -u bisheng-app-proxy | grep app_proxy.header_strip # 伪造头 +journalctl -u bisheng-runtime-manager | grep rtm.rebuild # 谁在反复重建 +./deploy.sh logs backend | grep app.state_transition | grep lost_race +``` + +## 备份与恢复 + +托管应用的存档分两处:**代码在对象存储里、数据在 runtime-manager 那台机器的本机磁盘上**。 +平台本体的备份(MySQL / MinIO,见 [`08-deployment.md`](08-deployment.md)「备份」)覆盖前者, +**覆盖不到后者**——这是装了运行时层之后备份清单里唯一多出来的东西。 + +| 内容 | 位置 | 要不要备份 | +|------|------|-----------| +| 应用数据(SQLite) | `{data_root}/apps/{app_id}/db/app.db`(默认 `data_root` 为 `/opt/bisheng/app-data`;compose 形态是宿主侧 `BISHENG_APP_DATA_ROOT`) | **要**,且不能裸拷,见下 | +| 代码快照 | MinIO 独立 bucket **`bisheng-apps`**,键 `apps/{app_id}/versions/{version_id}/code.tar.gz`(只增不改,每个已发布版本一份) | 随 MinIO 备份,**整桶备**——同桶下的附件前缀(`apps/{app_id}/attachments/`)一并覆盖 | +| 应用元数据(应用、版本、实例、审批单) | 平台 MySQL / DM8 的 `app*` 表 | 随平台库备份 | +| 期望态文件 | `{data_root}/state/desired-state.json` | 可丢:进程启动时会从容器标签重建 | +| 应用附件 | 同一个 `bisheng-apps` 桶,前缀 `apps/{app_id}/attachments/` | 随整桶备份;**owner 显式删除应用时随之清空** | +| 构建上下文 / 镜像 | `{data_root}/builds/`、dockerd 里的 `bisheng-app/*` 镜像 | 可丢:重新上线会从代码快照重建 | + +`bisheng-apps` 桶**不挂 nginx location、不设匿名策略**,与公共 `bisheng` 桶隔离;备份时用 MinIO 自己的 +工具(`mc mirror` / 站点复制)而不是走 HTTP 入口。 + +### SQLite 快照:绝不裸 tar 一个 WAL 库 + +托管应用的库开着 WAL 模式,`app.db` 旁边跟着 `app.db-wal` / `app.db-shm`。**直接 `tar` / `cp` 这三个文件 +得到的是不一致副本**:备份看起来成功,恢复时要么丢最近的写、要么直接 corrupt,而且只在应用正在写的时候复现。 +必须先用 SQLite 的在线备份 API 生成一致快照,再归档快照文件——应用**不用停**,备份过程不阻塞它的读写: + +```bash +DATA_ROOT=/opt/bisheng/app-data # RTM_DATA_ROOT(systemd)/ BISHENG_APP_DATA_ROOT(compose) +STAMP=$(date +%Y%m%dT%H%M%S) +mkdir -p /var/backups/bisheng-apps/$STAMP + +for db in "$DATA_ROOT"/apps/*/db/app.db; do + app_id=$(basename "$(dirname "$(dirname "$db")")") + # 有 sqlite3 命令行就用它;没有就用 python3 标准库(平台机器上一定有) + sqlite3 "$db" ".backup '/var/backups/bisheng-apps/$STAMP/$app_id.db'" \ + || python3 -c "import sqlite3,sys; s=sqlite3.connect(sys.argv[1]); d=sqlite3.connect(sys.argv[2]); s.backup(d); d.close(); s.close()" \ + "$db" "/var/backups/bisheng-apps/$STAMP/$app_id.db" +done +tar -C /var/backups/bisheng-apps -czf "/var/backups/bisheng-apps/$STAMP.tar.gz" "$STAMP" +``` + +得到的 `{app_id}.db` 是自包含的单文件(不带 `-wal` / `-shm`),可以放心归档、异地存放。 +`VACUUM INTO '/path/out.db'` 与 `.backup` 等价,只是产物更紧凑。 +平台**当前没有**自动做这件事的作业——把上面的循环接进平台本体的备份计划(与 MySQL dump 同一周期即可)。 + +### 恢复 + +按这个顺序,每一步都可以独立核对: + +1. **先恢复平台库与 MinIO**(`app*` 表 + `bisheng-apps` 桶)。应用的存在、版本号、审批状态都在库里, + 代码在桶里;两者对不上时,上线会失败在「取不到代码快照」。 +2. **停掉要恢复数据的应用**:在应用详情页「下线」,或让它保持未上线状态。容器还在跑时替换库文件, + 跑着的连接会写回旧页,恢复等于没做。 +3. **放回库文件**:把快照复制成 `{data_root}/apps/{app_id}/db/app.db`,**删掉残留的 `app.db-wal` / `app.db-shm`** + (它们属于旧库,留着会把旧的写重放到新库上),然后 `chown -R 10001:10001 {data_root}/apps/{app_id}/db` + ——这是容器内应用用户的固定 uid/gid。runtime-manager 上线时只 chown `db/` 目录本身、不递归, + 手工放回的 `app.db` 若仍属 root,应用能读不能写,症状是上线成功、一写就报 `readonly database`。 +4. **重新上线**:应用详情页「上线」。runtime-manager 会从代码快照重建镜像(本地镜像丢了也无妨)、 + 挂上 `db/`、探活通过后切流量。 +5. **核对**:用非管理员账号打开 `/apps/{slug}`,看数据是否是快照时刻的;`GET /api/v1/apps/runtime-status` + 的 `preflight` 应全绿。 + +`desired-state.json` 与 `builds/` 不用恢复。但要知道对账的边界:runtime-manager 重启后只会从**还在的容器标签** +里认回实例;整机重装、容器全没了的情况下它一无所知,不会自己把库里标着「已上线」的应用拉起来—— +每个应用都要按步骤 4 在平台里重新上线一次。 + +## 相关文档 + +- 平台整体部署与升级 -- [`08-deployment.md`](08-deployment.md) +- 系统架构总览 -- [`01-architecture-overview.md`](01-architecture-overview.md) +- 权限体系(谁能看到 / 进入一个托管应用)-- [`10-permission-rbac.md`](10-permission-rbac.md) +- 商业版网关(它只认 `/api`,`/apps` 必须在它前面的 nginx 层直达)-- [`11-gateway.md`](11-gateway.md) +- 配置模板 -- `docker/bisheng/config/config.yaml` +- 配置模型定义 -- `src/backend/bisheng/core/config/app_runtime.py` · `open_platform.py` diff --git a/docs/architecture/knowledge-space-delete-consistency.md b/docs/architecture/knowledge-space-delete-consistency.md new file mode 100644 index 0000000000..1f602fcc15 --- /dev/null +++ b/docs/architecture/knowledge-space-delete-consistency.md @@ -0,0 +1,64 @@ +# 知识空间删除一致性:3.0.0 分支适配 + +本修复适配自 COFCO 提交 `a03a31bf1e2d43d90aaabaa4be09d38f616bb923`。 +目标基线为 `feat/3.0.0` 的 `2f216a464b46d418839ba06fbeb35055b94cfd68`。 +保留主版本现有接口、版本链规则、频道绑定清理和聊天记录归档行为。 + +## 分支差异与范围 + +COFCO 已有 F046 的删除清单、任务表、执行步骤及恢复扫描器,可以直接复用。 +3.0.0 没有这些组件,因此本次提供知识领域自己的最小删除任务,不引入 F046 或审批状态机。 +对应持久化表为 `knowledge_space_delete_job`,仅服务正式文件、文件夹及批量删除。 + +3.0.0 的共享文件可见性服务在结果层始终查询正式文件记录,管理员也经过这一步; +业务记录提交删除后,该路径不再返回文件。因此不复制 COFCO 的 F046 footprint 过滤机制。 +本修复不承诺所有历史检索入口均经过共享可见性服务,也不修改任务模式检索工具、 +通用对象签名接口、网页 HTML 快照字段或已签发链接的有效期。 + +## 数据库事务边界 + +1. 校验明确的当前租户,锁定空间、根资源、子树和相关版本链。 +2. 按现有规则展开版本:删除主版本时删除整个文档链,删除历史版本时只删除对应版本。 +3. 对全部展开资源重新检查 `delete`,包括子树之外的历史版本文件;冻结权限快照。 +4. 同一事务写入清理任务,同时删除文件、文件夹、版本行和相应文档锚点。 +5. 事务提交后才尝试投递 Celery,API 的成功表示正式业务记录已删除。 + +在步骤 4 或提交失败时,业务记录和任务一起回滚,OpenFGA、MinIO、ES 和 Milvus +尚未发生写入。提交已成功但回执丢失时,持久化任务仍可由扫描器发现;不恢复业务行。 +文件夹之间及批量条目之间延续原有逐项执行语义,不提供整个批量请求的总事务。 + +## 多进程协作与恢复 + +- API 进程只向数据库提交删除事实及冻结清单,然后发送任务 ID。 +- Celery 消息显式携带 `tenant_id` 和权限任务标记;Worker 恢复该租户后读取任务。 +- Worker 通过行锁领取 15 分钟租约;领取时生成独立 token,重复消息不重复领取活动任务。 +- 每个清理阶段及 FGA/MinIO 循环续租。步骤完成和错误记录均校验 token,过期持有者 + 不能覆盖另一 Worker 的进度。进程崩溃后,租约到期即可重新领取。 +- Beat 每 60 秒扫描所有租户的到期任务,仅跨租户读取路由用的任务 ID 和租户 ID; + 实际清理仍在单租户上下文中执行。消息丢失、Broker 不可用、进程退出均不丢失清理意图。 + +清理顺序为 `FGA → MinIO → ES → Milvus`。每个阶段完成后提交 checkpoint。 +FGA 使用删除前的业务权限快照及原始幂等操作账本;重试已完成资源不会创建第二次删除。 +MinIO 删除后检查对象不存在,ES 删除后 refresh 并检查剩余分片数,Milvus 删除后 flush +并检查剩余数。只有四个阶段都成功才将任务标记为 `SUCCEEDED`。 + +阶段失败时保留已完成 checkpoint、记录错误,60 秒后可重试。下次执行跳过已确认阶段。 +外部写入成功而 checkpoint 提交失败时,阶段可能重放;依靠幂等删除和 FGA 原始账本恢复, +不要求消息恰好投递一次。租约无法撤回已经发出的外部请求,安全性也依赖该重放性质。 + +## 部署与验证 + +新表通过仓库已有的模型发现和建表流程创建;没有变更已有表,也没有数据迁移脚本。 +部署时按既有流程执行数据库升级,随后更新 API、知识队列 Worker 和 Beat。 +Worker 必须消费 `knowledge_celery`,Beat 必须运行,否则正式删除成功后外部清理会等待。 +任务及完成清单保留在数据库中,排查可查看 `state`、`attempts`、`completed_steps`、 +`last_error`、`lease_until` 和 `next_attempt_at`。 + +验证覆盖数据库回滚、提交回执丢失、投递失败、版本链权限拒绝、租户隔离、重复领取、 +过期 token、部分清理后跨实例恢复、清理残留拒绝完成,以及 F048 删除账本重放。 +MySQL/DM8 的真实行锁竞争及外部中间件一致性仍需在集成环境验证。 +本次代码修复不自动修复此前已经损坏的生产文件夹权限。 + +本地专项验证 34 项通过;权限回归 934 项通过、15 项既有失败、10 项跳过, +知识模块回归 853 项通过、22 项既有失败、23 项跳过。两组既有失败集合均与修改前一致。 +权限回归排除了修改前同样无法收集的 MCP 依赖兼容测试模块。 diff --git a/docs/architecture/proposals/2026-10-12-mineru-v4-integration.md b/docs/architecture/proposals/2026-10-12-mineru-v4-integration.md new file mode 100644 index 0000000000..c4859459bd --- /dev/null +++ b/docs/architecture/proposals/2026-10-12-mineru-v4-integration.md @@ -0,0 +1,156 @@ +# MinerU 4.0 独立提供方接入技术方案 + +- 日期:2026-10-12 +- 状态:历史技术方案草案;当前实施设计以 `features/v3.0.0/076-mineru-v4/design.md` 为准。本文保留为讨论背景,其中缓存、队列和任务恢复边界已按后续用户决定更新到 SDD 文档 +- 工作分支:`feat/mineru_4.0`,基线 `origin/feat/3.0.0` 的 `eb86a1017267753de76c5c661bb45d0e3178874a` +- 范围:毕昇的文档解析接入;MinerU 服务的部署和模型选型由部署环境负责 + +## 1. 结论与边界 + +新增 `mineru_v4` 提供方、`MineruV4Loader` 和独立配置块。保留现有 `mineru` 提供方及 `MineruLoader`,不通过 URL 探测或失败后自动切换协议。两个 Loader 都从 `BaseFilePipeline._build_pdf_loader()` 进入现有的 Load → Transform → Ingest 管线。 + +本方案覆盖 PDF、图片及当前先转 PDF 的 OFD;不把 Office、HTML、表格文件改走 MinerU 4.0,尽管 MinerU 4.0 本身支持更多格式。知识库文件解析、文件预览和临时解析共用同一选择逻辑。历史文件不批量重解析、不迁移已有 `parse_type` 或存储产物。解析失败时保留真实失败,不自动退回旧 MinerU 或本地 PDF Loader。 + +这是跨后端配置、解析协议和前端预览契约的功能,实施阶段按 `docs/SDD-Guide.md` 的完整 SDD 流程补齐并确认需求、设计与任务;本文先固定技术方向和待验证的上游产物契约。 + +## 2. 现状与升级原因 + +### 2.1 毕昇现状 + +| 环节 | 当前行为 | 代码位置 | +|---|---|---| +| 提供方选择 | `KnowledgeConf.loader_provider` 从 DB 中的 `initdb_config.knowledges` 读取;`_build_pdf_loader()` 选择 PDF Loader,图片和 OFD 复用此入口 | `core/config/settings.py`、`common/services/config_service.py`、`knowledge/rag/base_file_pipeline.py` | +| 旧 MinerU 请求 | 对配置中的完整 URL 执行一次 multipart `POST`,传 `return_content_list`、`return_middle_json`、`return_images` 等旧参数 | `knowledge/rag/pipeline/loader/mineru.py:MineruLoader.load` | +| 旧 MinerU 结果 | 从 `results[*].content_list`、`middle_json.pdf_info` 和 base64 `images` 生成 `Document`、页码、bbox 与图片引用 | `knowledge/rag/pipeline/loader/mineru.py` | +| 图片与版面 | Loader 暂存图片并先写最终引用,`ImageUploadTransformer` 上传;`ExtraFileTransformer` 写 bbox;`metadata.indexes` 必须对应替换图片引用后的正文 | `knowledge/rag/pipeline/loader/base.py`、`knowledge/rag/pipeline/transformer/` | +| 解析标识 | 持久文件记 `parse_type=mineru`;预览接口返回 Loader 类名 | `knowledge/domain/models/knowledge_file.py`、`knowledge/domain/services/knowledge_service.py` | + +旧版测试 `test/knowledge/rag/test_mineru.py` 直接连一台固定服务和本机绝对路径,不能作为 4.0 协议或可重复回归的证明。 + +### 2.2 上游 4.0 契约 + +MinerU 4.0 自部署解析 API 以 `/v1` 为路径前缀,服务根地址不含 `/v1`。一次请求需要创建上传、上传字节、完成上传、创建解析任务、轮询终态,并按 `output_files` 的文件 ID 下载产物;旧 `/file_parse` 不再提供。任务状态包括 `queued`、`running`、`completed`、`partial`、`failed` 和 `canceled`。V1 ZIP 包含结构化结果及图片素材;单独的 Middle JSON 不保证携带图片字节。新版 Middle JSON 以序列化字段 `schema=docvortex.middle`、`schema_version=2.0` 标识,页面使用 `pages[].blocks[]`,不能按旧 `pdf_info` 读取。 + +以上依据:[4.0 迁移指南](https://mineru.atomgit.com/zh/reference/migration_4.html)、[V1 HTTP API 与经过测试的脚本](https://mineru.atomgit.com/zh/usage/http_api.html)、[输出格式与结果协议](https://mineru.atomgit.com/zh/reference/output_files.html)。实现前须以目标服务的 `/v1/health`、`/v1/tiers` 和实际 ZIP 样本核对,因为部署可用档位和输出格式以服务声明为准。 + +## 3. 关键决策 + +### D1:以独立提供方隔离协议 + +- **选定**:`loader_provider=mineru_v4`,新增 `MineruV4Conf` 与 `MineruV4Loader`;旧 `mineru` 配置、类和行为不改。 +- **原因**:旧配置的 `url` 是完整 `/file_parse` 地址,4.0 的 `api_url` 是服务根地址;请求、状态、产物和 JSON 结构也各不相同。独立提供方使回滚只需切回原配置,且便于核对实际解析版本。 +- **不采用**:在旧 `mineru` 中增加 `api_version` 并把两种协议放进同一个 `load()`;不按健康检查或 HTTP 错误自动判断版本。 +- **重新考虑条件**:若未来多个 MinerU 版本共享了经官方承诺的同一解析 API,再提取公共客户端接口,不预先抽象两种不同协议。 + +### D2:继续调用独立 MinerU 服务,使用毕昇已有 HTTP 依赖 + +- **选定**:在毕昇内实现薄 V1 HTTP 客户端,由 `MineruV4Loader` 负责调用;使用项目已有的 `httpx`,不在毕昇 API/Celery 环境安装 MinerU 推理包。 +- **原因**:现有接入就是服务间调用。4.0 Python SDK 的 `MinerUApiParser` 可以完成上传、轮询与下载,但引入 `mineru` 包会把解析运行时依赖和硬件组合带进毕昇后端;独立服务的镜像与模型版本应由 MinerU 部署维护。[Python SDK 文档](https://mineru.atomgit.com/zh/usage/sdk_api.html) +- **不采用**:直接在毕昇 Worker 内运行 4.0 模型、调用 CLI,或把 VLM 的 OpenAI 兼容地址当作文档解析 API。 +- **重新考虑条件**:若官方提供轻量、独立的 V1 客户端包并满足毕昇依赖与许可要求,可改用官方客户端以减少协议维护。 + +### D3:只接受可完整消费的 ZIP 结果 + +- **选定**:新提供方要求服务支持 `zip`,创建任务时请求 `output_formats=["zip"]`;从 ZIP 读取当前 Middle JSON、Markdown 与实际引用的 `images/` 素材。能力缺失、产物缺失或协议身份不符均报解析失败。 +- **原因**:BiSheng 需要正文、页码、bbox 与图片字节同时成立。仅下载 Markdown 无法可靠建立块到 bbox 的映射;仅下载 Middle JSON 无法保证图片素材可用。[输出协议](https://mineru.atomgit.com/zh/reference/output_files.html) +- **不采用**:把 4.0 JSON 当成旧 `pdf_info`、从文件名猜图片路径、在缺素材时生成正常样式的失效图片链接。 +- **重新考虑条件**:若目标服务提供同等完整且有明确版本约束的非 ZIP 产物,再评估精简下载。 + +## 4. 配置与选择 + +新增的配置位于现有 DB 配置的 `knowledges` 节点,不向启动 `config.yaml` 增加顶层键: + +```yaml +knowledges: + loader_provider: "mineru_v4" # 切换时显式修改;默认仍为 etl4lm + mineru_v4: + api_url: "http://mineru-v4.example:8000" # /v1 之前的根地址 + api_key_env: "MINERU_V4_API_KEY" # 只存环境变量名;匿名内网服务可留空 + tier: "standard" # 上线时按目标硬件显式选择 + ocr_mode: "auto" + connect_timeout_seconds: 10 + request_timeout_seconds: 60 + transfer_timeout_seconds: 300 + job_timeout_seconds: 1800 + poll_interval_seconds: 3 +``` + +`api_url`、`tier`、各超时和轮询间隔应有类型与范围校验。`api_key_env` 只表示密钥所在环境变量名,API 进程和知识 Celery Worker 都需要能读取该变量;若配置了变量名但运行时未设置,立即报配置错误。不要把明文 Key 写入仓库、DB 系统配置或日志。`MineruV4Conf` 字段默认值应使旧 DB 配置无需新增节点也能被读取,但选择 `mineru_v4` 后,空 `api_url` 或不可用档位必须明确失败,不能悄悄退到 `LocalPdfLoader`。 + +`initdb_config.yaml` 中加入默认禁用的 `mineru_v4` 示例块。既有安装由 `ConfigService.merge_missing_config()` 向 `knowledges` 补入缺失的子块,不覆盖旧值;实际切换需更新 DB 配置中的 `loader_provider`。读取路径有约 100 秒 Redis 缓存,部署验证必须确认最终生效值。`KnowledgeConf.image_parser_enabled` 增加 `mineru_v4` 判断;`_build_pdf_loader()` 增加一条显式分支。旧 `mineru` 节点和默认提供方不变。 + +用户可见的配置说明和错误提示统一标识为 **MinerU V4**,例如 `MinerU V4 parse job timed out`。`/v1` 仅表示 HTTP 接口版本,不用于用户提示中的产品版本名称。 + +## 5. V1 请求生命周期 + +```text +本地输入文件 + → GET /v1/health、GET /v1/tiers:核对 zip、来源和配置的 tier + → POST /v1/uploads:文件名、长度、MIME、SHA-256、purpose=parse + → pending 时按返回的 method / URL / headers 上传字节,再 complete + completed 时使用服务返回的 file_id,跳过字节上传 + → POST /v1/parse/jobs:file_id、tier、ocr_mode、output_formats=[zip] + → GET /v1/parse/jobs/{job_id}:有界轮询至终态 + → GET /v1/files/{output_file_id}/content:下载 ZIP + → 校验、转换为 BiSheng Document / bbox_list / local_image_dir +``` + +每个文件只提交一个 parse job。PDF 默认全页,不传默认前 10 页的文档库 CLI 语义;文件规则 `force_ocr=1` 时将单次任务的 `ocr_mode` 设为 `ocr`,否则使用提供方配置的 `ocr_mode`。图片和 OFD→PDF 沿用 `_build_pdf_loader()` 的现有路由,OFD 预览仍由 `OfdLoader` 保留转换后的 PDF。 + +状态与重试规则: + +1. 校验每一步的 HTTP 状态和 JSON 结构,`upload_id`、`file_id`、`job_id`、终态文件及 ZIP 产物 ID 必须非空;HTTP 200 本身不是成功。 +2. 单文件任务只有 `completed` 且对应文件 `completed`、ZIP 完整下载并通过校验才成功。`partial`、`failed`、`canceled`、缺失产物均失败,记录上游结构化错误码;不把 `partial` 的正文写成成功文件。 +3. 分开连接、普通请求、传输与整个任务等待上限。读超时或轮询预算耗尽时记录 job ID 和阶段;不假定远端任务已取消。`POST /v1/parse/jobs` 响应不明时不自动重发,以免产生重复任务。 +4. 服务返回的上传 URL 可能是同源路径,也可能是跨源预签名地址。保留 `upload_headers`;只对同源上传发送 MinerU Bearer Key,跨源上传及下载重定向不转发 Key。日志不打印 Key 或预签名 URL。[HTTP API 安全规则](https://mineru.atomgit.com/zh/usage/http_api.html) +5. 自部署 V1 服务的上传/文件/任务索引在进程内,服务重启后旧 ID 不保证可恢复。毕昇不把远端 job ID 当作持久状态;失败后由现有文件重解析操作发起新任务,不承诺断点续跑。[部署边界](https://mineru.atomgit.com/zh/usage/http_api.html) + +## 6. 结果到毕昇的映射 + +`MineruV4Loader` 只接受识别出的当前 Middle JSON 协议身份,按源 `pages[].page_idx` 与块顺序转换。不能复用旧 Loader 的 `merge_pdf_info()` 或 `merge_conten_list()`。具体文本、标题、公式、表格和图片块的转换函数须用目标 4.0 ZIP 样本固化;文档中的简化 JSON 示例不足以证明所有块形态。 + +| 上游数据 | BiSheng 产物 | 约束 | +|---|---|---| +| 页面及语义块 | 单个 `Document.page_content`,`metadata.pages/types/bboxes/indexes` | 保持上游块顺序与原始零基 `page_idx`;`indexes` 以最终正文计算 | +| 有效四坐标 bbox | `loader.bbox_list` 的 `TextBbox` | 缺失或不合法 bbox 不伪造;正文仍可保留,记录无定位块数 | +| 表格、公式与标题 | 可读 Markdown 文本 | 以真实 ZIP 样本核对渲染,不能把复杂块静默丢弃 | +| ZIP 中实际引用的图片 | `loader.local_image_dir` 中的平铺文件及正文最终链接 | 对 ZIP 路径做安全校验、防重复覆盖;先写最终链接再计算 `indexes` | + +`ImageUploadTransformer` 只扫描平铺目录,因此 ZIP 的 `images/...` 路径要安全地映射到文档内唯一文件名,并同步替换正文引用。`retain_images=0` 时正文不能留下未上传的本地路径;`retain_images=1` 且引用的素材缺失时解析失败。页眉页脚过滤在计算 `indexes` 前对规范化块进行;bbox 为空的块不得进入当前 `ExtraFileTransformer` 的四坐标键生成。全文为空则保留现有 `KnowledgeFileEmptyError` 语义。 + +这些适配均在 Loader 中完成,已有摘要、分块、图片上传、bbox 上传与向量写入管线不另建分支。临时解析的本地图片只在当前任务的临时目录使用,不作为跨 API/Worker 节点的持久共享状态;遵循 `docs/constitution.md` C8。 + +## 7. 兼容与前端消费 + +- **旧部署**:`loader_provider=mineru` 时仍用旧 URL、旧参数及旧解析逻辑;升级毕昇镜像不会自动切换到 V1。MinerU 官方对部分 AMD/国产加速卡仍要求 `<4` 的旧平台适配,保留旧提供方有实际部署价值。[旧平台适配](https://mineru.atomgit.com/zh/usage/compatibility.html) +- **历史文件**:原 `parse_type=mineru` 和毕昇已保存的正文、图片、bbox 不做迁移。显式重解析时才按当时的当前提供方生成新结果;切回旧提供方也不改写历史文件。 +- **新文件**:`ParseType` 新增 `MINERU_V4="mineru_v4"`,明确保存来源;文件分享和预览逻辑需将该值视为外部解析提供方,核对 preview URL、bbox 和图片链接。 +- **聊天引用预览**:平台 `src/frontend/platform/src/controllers/API/index.ts` 与客户端 `src/frontend/client/src/api/apps.ts` 当前仅将 `etl4lm/un_etl4lm` 映射到新版预览 URL,`mineru` 本身也未覆盖。新增 `mineru_v4` 时应在同一处把 `mineru` 和 `mineru_v4` 一并纳入“有外部解析预览”的判断,避免解析成功但引用列表的 `fileUrl` 为空;对应文件查看组件也要使用一致的判断。这里只改消费条件,不改变视觉样式;正式 UI 修改前遵循 `src/frontend/packages/ui/docs/` 的设计规范。 + +## 8. 交付步骤与验证 + +1. **协议样本**:在目标 4.0 服务和硬件上记录版本、`/v1/health`、`/v1/tiers`;取得原生 PDF、扫描 PDF、图片及带表格/公式/多图的 ZIP 样本,确认 `zip`、bbox、素材和目标 tier。样本脱敏后成为测试夹具。 +2. **后端实现**:新增独立配置、V1 客户端及 Loader;在唯一 PDF 选择入口接线;扩展 `ParseType` 与图片能力判断;保持旧 Loader 的输入输出测试不变。 +3. **消费端修复**:核对知识文件分享、预览接口、平台与客户端聊天引用的 `parse_type` 分支,使旧版和 4.0 文件都能取得可用预览地址。 +4. **可重复测试**:模拟上传命中/未命中、同源/跨源凭据、成功/四种终态、结构错误、任务超时、下载失败、恶意 ZIP 路径与缺失图片;对 PDF/OFD/图片校验正文、页码、bbox、最终图片引用、`metadata.indexes` 和分块结果。将原依赖固定内网服务的旧测试改为可重复的协议测试,真实服务测试单独运行。 +5. **环境验收**:先在独立 MinerU 4.0 环境以显式 `mineru_v4` 配置解析上述样本,核对知识库文件终态、预览、问答引用与 MinIO 图片;再扩大到目标硬件上的并发、长任务、重启场景。回滚时显式切回原 `loader_provider`,不回滚历史文件数据。 + +观测日志至少包含 provider、阶段、耗时、job ID、终态、上游错误码、ZIP 校验结果和产出块/图片数,不包含文档正文、Key 或带签名的 URL。验收同时核对任务是否真正到终态,不能只看接口返回或文件入队。 + +## 9. 待确认的实现输入 + +| 项目 | 为什么需要 | 建议默认处理 | +|---|---|---| +| 目标 MinerU 4.0 具体版本、服务镜像和硬件 | 决定 tier、模型准备和产物实际形态 | 先在隔离服务抓真实样本,不依据旧模型目录推断 | +| 自部署 V1 API 的鉴权和网络拓扑 | 决定 Key 注入、上传 URL 与重定向策略 | 用环境变量注入 Key;校验同源边界 | +| 质量档位与任务最长等待时间 | 直接影响解析质量、Worker 占用和预览等待 | 部署时显式配置 `tier`,按样本耗时确定 job 超时 | +| 是否需要对历史文件统一重解析 | 会引入额外任务量与结果差异 | 本方案不做;如需要单独定义迁移计划 | + +## 参考 + +- [MinerU 4.0 主页](https://mineru.atomgit.com/zh/index.html) +- [从 3.x 迁移到 4.0](https://mineru.atomgit.com/zh/reference/migration_4.html) +- [V1 HTTP API 完整示例](https://mineru.atomgit.com/zh/usage/http_api.html) +- [Python SDK](https://mineru.atomgit.com/zh/usage/sdk_api.html) +- [输出格式与结果协议](https://mineru.atomgit.com/zh/reference/output_files.html) +- [旧平台适配](https://mineru.atomgit.com/zh/usage/compatibility.html) diff --git a/docs/architecture/proposals/2026-10-21-workflow-input-file-parsing.md b/docs/architecture/proposals/2026-10-21-workflow-input-file-parsing.md new file mode 100644 index 0000000000..cb13dd7ad3 --- /dev/null +++ b/docs/architecture/proposals/2026-10-21-workflow-input-file-parsing.md @@ -0,0 +1,221 @@ +# 工作流输入文件图片留存与解析切分修复方案 + +- 日期:2026-10-21 +- 修订日期:2026-10-22 +- 状态:方案已确认,已完成代码修复与本地相关测试,尚未提交;真实环境联调待执行 +- 分支:`feat/3.0.0` +- 排查基线:`8510a9645382c7ac5f886503e26ae68cb8a0d935`,已 fetch 并快进到 `origin/feat/3.0.0` +- 范围:工作流输入节点的文件解析、解析图片留存及访问、是否进入检索切分的判断 + +## 1 方案结论 + +两个问题来自同一个调用边界:输入节点把“只提取正文”和“生成检索切片”统一交给 `TempFilePipeline`,但这条管线既没有图片上传步骤,又始终应用知识库切分规则。 + +实现已在输入节点进入解析管线之前明确处理目的,并为图片增加独立的存储上下文: + +1. **仅提取正文**:解析文件、上传解析图片、按既有正文长度上限输出;不执行知识库切分、分段长度校验和向量写入。 +2. **解析并存入临时知识库**:解析文件、上传解析图片、执行现有切分并写入 Milvus 和 ES;正文输出变量继续兼容现有契约。 +3. **保留原始文件**:继续跳过解析,不创建解析图片或检索切片。 + +正式会话的原始附件和解析图片统一保存在主桶,随会话留存,由会话附件清单负责定位和删除。这项存储策略已于 2026-10-22 确认。访问链接的有效期与对象留存时间分开处理:链接过期后可以重新签发,对象不会因链接过期或临时桶到期而删除。 + +调试和未关联历史消息的解析产物进入 `tmp_bucket`。临时知识库不是正式知识库,正文提取和临时入库均不创建 `knowledge` 或 `knowledge_file` 记录。 + +## 2 排查基线与根因 + +以下位置均基于排查基线;部署环境的实际桶策略与报错样本尚未核对。 + +| 环节 | 当前行为 | 代码位置 | +|---|---|---| +| 输入策略 | 已能区分 `extract_text`、`ingest_to_temp_kb` 和 `keep_raw`,兼容字符串、数组及旧版类型映射 | `workflow/nodes/input/input.py` 的 `_modes_for_file()` | +| 创建解析管线 | 固定构造 `FileProcessBase(knowledge_id=0)`,调用 `TempFilePipeline.run(PipelineConfig())`,没有传入是否需要检索切片 | 同文件 `get_upload_file_path_content()`,327 行起 | +| 判断是否入库 | 拿到解析结果以后才判断是否需要初始化向量客户端、写入 Milvus 和 ES | 同文件 `parse_upload_file()`,437 至 467 行 | +| 临时管线 | 普通文件添加 Splitter 或按页 DirectChunk;Excel 在 Loader 内切片;均没有图片上传 Transformer | `knowledge/rag/temp_file_pipeline.py` | +| 图片引用 | 未指定 `image_object_dir` 时,Loader 把本地路径写进 Markdown | `knowledge/rag/pipeline/loader/base.py` 的 `build_image_url()` | +| 图片实际寿命 | `BaseFilePipeline.run()` 退出 `TemporaryDirectory` 后,解析图片随目录删除 | `knowledge/rag/base_file_pipeline.py`,120 行起 | +| 知识库图片 | 有独立对象目录,并配置 `ImageUploadTransformer`,在临时目录销毁前上传字节 | `knowledge/rag/knowledge_file_pipeline.py` | + +因此,工作流返回的 Markdown 可能包含 `![](/tmp/.../figure.png)`。文件解析本身成功,也不代表图片已经上传。前端无法访问 Worker 的本地文件;管线结束后该文件也已不存在。 + +第二个问题发生在是否入库的判断之前。`file_content_size` 默认 15,000 字符,是输入节点输出正文的截断上限;它与检索分段的 10,000 字符上限不是同一个限制,不能互相替代。 + +### 最新分支已经具备的修复 + +普通 `SplitterTransformer` 已接入 `hard_split_limit`,对默认分隔符无法切开的超长片段进行强制切分。对应提交为 `b358664c61`,不能再把所有长文本报错都归因于缺少这项修复。 + +但 PPT 默认按页进入 `DirectChunkTransformer`,单页超过 10,000 字符仍抛出 `KnowledgeFileChunkMaxError`,业务码 `10912`。Excel 还有 Loader 内部的字符预算和 `10916` 校验。因此,仅删掉普通 Splitter 或扩大它的上限都不能完整解决“仅提取正文不应受检索分段约束”。 + +### 本地验证 + +- 现有相关测试:`test_splitter_hard_limit.py`、`test_input_parse_mode.py`、`test_input_node_media.py`,合计 **38 passed**。 +- 用模拟 Loader 运行真实 `TempFilePipeline`:返回的图片引用仍为本地路径,管线退出后该文件不存在。 +- 用模拟 Loader 返回 10,001 字符的 PPT 单页:真实默认临时管线抛出 `10912`。 +- 同步 `NormalPipeline.run(stop_at=LOAD)` 当前返回的 `documents` 为 `None`。直接改为这个停止阶段既取不到正文,也会跳过图片上传;Excel 的 Loader 内切片还会照常发生。 + +这些验证确认代码路径,不等同于真实 OCR、MinIO 和浏览器联调。用户现场报错具体属于哪种文件路径,需要实施验收时用原样本确认。 + +## 3 临时桶与历史图片的处理 + +### 当前与历史行为 + +需要区分桶名和对象目录名: + +- 原始上传件默认进入 `tmp_bucket`。工作流问题消息落库时,已有 `promote_chat_attachments_sync()` 将可识别附件复制到主桶,记录 `files[].object_name`。 +- 当前代码为整个 `tmp_bucket` 设置 **7 天到期**,且初始化时会校正旧规则。部分 F043、F062 文档和注释仍写 3 天;方案以 `minio_storage.py` 的 `_TMP_OBJECT_EXPIRE_DAYS = 7` 为准,实际部署需核验。 +- `KnowledgeUtils.get_knowledge_file_image_dir()` 在没有知识库 ID 时返回 `tmp/images/`,但图片上传 Transformer 使用的是**主桶**。`tmp/images` 目录不等于 `tmp_bucket`,也不会自动获得后者的过期清理。 +- 检查图片上传集中化之前的父提交,临时管线也没有指定图片对象目录,Loader 的上传辅助方法在此情况下直接返回本地路径。因此,所检查的这条历史实现也不能证明解析图片曾经存入 `tmp_bucket`。 + +### 方案比较 + +| 选项 | 结果与代价 | 建议 | +|---|---|---| +| 所有解析图片进入 `tmp_bucket` | 改动较小、自动回收;7 天后历史答案中的图片失效,重新签名也无法恢复已删除字节 | 不采用 | +| 所有图片进入主桶 `tmp/images`,只补上传 | 可以沿用部分图片 URL,但没有归属清单和删除链,会产生长期孤儿对象;该目录已有匿名读策略 | 不采用 | +| 正式会话原件与解析图片统一主桶留存,调试产物使用临时桶 | 历史可回看;补齐派生图片清单、链接解析和会话删除 | **已确认并实现** | + +“不进入临时知识库”只决定是否生成检索数据,不决定正式会话中的对象是否留存。正式会话主桶留存同时覆盖仅提取正文和入临时知识库两种模式,不再把正式会话解析图片的 7 天失效作为候选实现。 + +现有原始附件已经按会话保存在主桶,没有代码内置的 7 天自动删除策略;本次为解析图片补齐同样的留存规则。这里的留存不是永不删除:删除会话时清理相关对象。历史上已经失效的临时链接或被回收的对象,不因这项决定自动恢复。 + +## 4 图片留存的实现建议 + +### 存储上下文与图片上传 + +输入节点为每个文件传入处理目的、源文件引用、执行标识,以及服务端确认的会话和主体信息。图片对象键使用会话、工作流、运行、节点、源文件身份与本次尝试 UUID 组成的文件键,不使用默认 `document_id=0` 作为图片身份。重试和并发尝试均使用独立对象键,已发布的历史图片不被覆盖或退回 `pending`;同一上下文禁止再次上传。检索用的每文件 UUID 保持原有契约。 + +外部 HTTP URL 的身份保留域名、编码路径和业务查询参数,不能仅使用路径。只有地址属于本系统 MinIO 共享域名及已知桶,或使用已知桶的相对地址时,才移除签名参数并统一绝对/相对表示;业务参数仍参与匹配。源附件匹配与对象身份计算复用同一规则。 + +复用 `ImageUploadTransformer` 上传图片字节,增加显式目标桶参数;默认行为保持知识库主桶上传。仅隔离了本次尝试对象键的工作流上下文显式启用失败回滚,知识库、预览及其他默认上下文不删除已发布对象。Loader 的图片引用构造也必须接受同一存储上下文,不能只改上传的桶,而让正文仍指向默认主桶。 + +保持现有解析契约:**先确定图片在正文中的最终稳定引用,再计算 `metadata.indexes`,随后在清理本地目录前完成上传**。不能等切分后再替换成长度不同的图片 URL,否则会再次破坏 bbox 与正文偏移对齐。OFD、信创格式的委托 Loader 要传递同一上下文。 + +### 正式会话图片 + +复用主桶现有 `chat/...` 附件边界,派生图片键为 `chat///workflow//images/`。租户取持久会话所属租户,而非共享工作流的配置所属租户。原件沿用现有临时桶到主桶的复制流程;已留存的原件不重复复制,解析图片在本地暂存目录销毁前上传到主桶。 + +在问题消息对应附件下增加可选 `derived_assets` 清单,保存图片 ID、对象键、桶、来源文件与状态;同时在独立 `workflow_file_manifest` 表按本次尝试保存原件和图片清单。清单归属会话而非某条问题消息,单独删除源问题后,历史图片仍能解析,删除会话仍能回收原件及图片。这是内部派生资源,不增加用户上传的附件卡片,也不挤占现有 `dialog_image_files` 变量;后者表达用户直接上传的图片,不是文档内抽取图片。 + +正文使用稳定 HTTP 图片地址 `/api/v1/chat/{chat_id}/files/{asset_id}/content`。这个新增的薄端点复用现有附件链接解析 Service;每次请求先校验会话归属,再从持久清单查找 `ready` 状态图片,返回 307 跳转到本次签发的 MinIO 地址,并设置 `Cache-Control: no-store`。既有 `/url` 端点也支持相同派生资源。签名有效期沿用现有附件访问策略,链接续签不改变对象随会话留存的生命周期。 + +这里选择服务端稳定地址代替前端自定义图片引用解析器:现有 client 和 platform Markdown 的标准图片请求即可携带登录 Cookie 访问,无需新增前端解析分支或更改组件样式。正式会话的预签名 URL 不写死在历史正文中。端点从服务端清单选对象,同时校验图片键与会话用户、租户及图片 ID 一致,不接收调用方指定的任意对象路径。沿用既有会话附件读取权限,不新增匿名读取授权。 + +### 临时运行与清理 + +没有保存会话的调试运行,图片进入 `tmp_bucket`,复用 7 天生命周期,按执行主体和运行标识隔离;运行期链接解析只使用该次运行登记的清单。旧的独立工作流与免登录路径沿用其现有身份边界,不把发布者身份当成访客拥有者。 + +正式图片先在独立表和源附件中登记 `pending` 清单,再上传主桶,成功后更新为 `ready`;两份清单写入使用会话与消息行锁,并在同一事务提交。源问题在上传中途被删除时,使用独立清单完成登记。上传或完成登记失败时只清理本次尝试已经上传的图片,不返回成功结果;若与会话删除并发,完成登记会拒绝已删除会话,并触发上传清理。删除失败保留对象键和日志,可再次调用会话删除重试物理清理。临时运行的失败字节由清理及临时桶生命周期兜底。 + +首次删除和重试删除均先校验自然人会话归属、用户及叶子租户,拒绝其他用户、其他租户及 API 主体会话后才执行审计、软删除和物理清理。软删除 UPDATE 显式绑定会话、用户、租户及主体类型,不能依赖只拦截 SELECT 的自动租户过滤。 + +会话删除时,扩展现有 `_remove_attachments()`,同时处理 `derived_assets`,合并读取消息附件与独立清单并按对象键去重。两类记录均按主键分页,覆盖超出 1,000 条消息及 500 条清单的历史,不以空字符串作为字符串主键的首屏边界,避免 DM8 空串语义影响查询。MinIO 删除失败保留清单以供重试,不宣称已完成物理回收。 + +本期不新增知识库记录或独立资源管理系统。为修复消息硬删除后清单丢失,新增独立 `workflow_file_manifest` 表,数据读写集中在 `WorkflowFileRepository`,使用双库通用 `LargeText` 而非数据库特有 JSON 操作。模型位于标准 `domain/models` 目录,加入租户模型注册;部署时按现有流程执行 `alembic upgrade head`,自动发现并创建该缺失整表,不需手工建表或数据迁移。既有已丢失的清单不自动回填。 + +## 5 解析与检索切分分离 + +### 在调用之前决定处理目的 + +`parse_upload_file()` 在调用解析方法之前计算是否包含 `ingest_to_temp_kb`,将结果显式传入临时管线。新增的 `split_for_retrieval` 参数默认保持当前行为,仅由工作流输入节点按策略覆盖。 + +| 输入模式 | Loader 与图片上传 | 检索切分与分段长度校验 | Milvus 和 ES | +|---|---|---|---| +| `keep_raw` | 跳过 | 跳过 | 跳过 | +| 仅 `extract_text` | 执行 | 跳过 | 跳过 | +| 包含 `ingest_to_temp_kb` | 执行 | 保持现有行为 | 执行 | + +已有字符串、数组、类型映射和缺失值的历史兼容规则保持不变;缺失配置的旧表单仍按原来的入库默认处理。组合模式只解析一次,再用该次产物完成切分、正文输出与入库。 + +### 各类文件的处理 + +- **PDF、Word、Markdown、HTML、图片 OCR、音视频转写**:仅提取模式返回 Loader 解析内容并持久化图片,不接入 Splitter、HierarchicalSplitter、DirectChunk 等检索 Transformer。 +- **PPT**:可以保留 Loader 的自然页顺序,但仅提取模式不经过 DirectChunk 的 10,000 字符校验。入库模式继续执行已有页切片约束。 +- **Excel、CSV、ET**:必须同步处理 Loader 内切片。仅提取模式按工作表输出内容,关闭检索用途的行分片、重复表头、字符预算和分段超长校验;保留数据、表头、工作表顺序与图片。ET 转换后的 Excel 委托也要收到该参数。入库模式继续使用原有预算与长行处理。 + +输入节点继续执行用户配置的正文长度上限,不提高默认 15,000 字符,不以“去掉分段上限”为名允许无限正文进入模型。保留现有文件大小、媒体数量、解析超时和格式错误处理;解析完成前仍可能因这些真实限制失败。 + +## 6 改动范围与兼容 + +| 区域 | 预期修改 | +|---|---| +| `workflow/nodes/input/input.py` | 提前决定是否检索切分,传入文件身份与图片归属,保持输出变量契约 | +| `knowledge/rag/temp_file_pipeline.py` | 增加显式处理目的与可选图片上传,保留其他调用方默认行为 | +| `knowledge/rag/base_file_pipeline.py` 与 Loader 基类 | 传递图片引用和存储上下文、表格提取参数,默认行为兼容知识库 | +| 图片上传 Transformer、Excel 与委托 Loader | 目标桶与正文引用一致;仅提取模式避开 Loader 内的检索分片 | +| 工作流回调、会话附件 Service 与持久消息 | 登记派生图片、解析当前链接、删除及失败清理;按层调用 Repository | +| `chat_session/api/endpoints/workflow_files.py` 与现有前端 Markdown | 新增带会话鉴权的稳定图片 HTTP 地址;前端沿用现有图片请求,无代码或样式改动 | +| 相关测试与文档 | 增加两个问题的回归覆盖,修正本次涉及的过期时间说明 | + +`TempFilePipeline` 还被日常聊天和灵思调用,禁止直接把它全局改成不切分或自动永久保存图片。新参数保持兼容默认,工作流显式选择。知识库及预览管线保持现有图片目录、切分、bbox 和入库行为。 + +本期不批量重解析历史消息、不重建历史向量。历史图片若只存在于已删除的本地目录,不能通过修改 URL 恢复;需要原件仍在并重新运行解析,不能制造正常样式的失效链接。现存 `tmp/images` 对象不自动迁移或删除。 + +## 7 验收标准 + +| 编号 | 验收要求 | +|---|---| +| AC-01 | 含图片的 PDF、DOCX、PPTX、XLSX 解析后,正文中保留下来的每个图片引用都有对应可读对象;管线临时目录删除后仍可展示 | +| AC-02 | 仅提取模式处理超 10,000 字符 PPT 单页、长正文、超宽表格或长单元格时,不触发检索分段类 `10912`、`10916`;正文仍受输入节点长度上限控制 | +| AC-03 | 仅提取模式不初始化 embedding、Milvus 或 ES,不调用检索 Splitter;Excel 也不走检索用途的行分片 | +| AC-04 | 入临时知识库模式继续生成切片并写入两种检索存储,bbox、page、chunk_index 与图片引用对齐;不全局放宽知识库分段限制 | +| AC-05 | 正式会话的原始附件和解析图片均在主桶随会话留存,不因临时桶过期而失效;签名过期后,有权读取会话者可获取新签名;对象寿命和签名寿命分别验证 | +| AC-06 | 调试图片按临时策略过期,不要求永久历史回看;跨进程、跨主机读取不依赖 Worker 本地目录 | +| AC-07 | 同名文件、多个文件、并发运行和不同租户不会串图;不同外部域名或业务参数不得合并;无权读取或删除会话者不能取得对象链接或触发物理删除 | +| AC-08 | 删除会话能够收集并删除其原始附件和派生图片,源问题消息被单独删除后仍成立;失败重试不删除已发布图片,上传失败、执行重试、Worker 中断及删除失败均不会被当作完整成功 | +| AC-09 | 旧版输入配置、原始文件模式、日常聊天、灵思、正式知识库与文件预览的默认行为通过回归验证 | +| AC-10 | 用现场原样本核对真实 OCR 产物、MinIO 对象、浏览器图片响应及历史回看;模拟 Loader 测试不代替这项联调 | + +## 8 已确认的实施范围 + +1. **已确认的正式会话存储策略**:2026-10-22 用户确认“统一都复制到主桶里保留”。原始附件和解析图片统一主桶留存,随会话清理;访问链接仍有有效期,过期后按权限重新签发。此前讨论的“正式会话解析图片只在临时桶有效期内可访问”不再采用。 +2. **解析与调试策略**:仅提取正文完全跳过检索切分,同时保留现有正文长度上限;进入临时知识库时继续切分。未保存会话的调试图片按临时桶生命周期回收。 +3. **历史范围**:本次只保证修复后的新解析;历史坏图不自动批量补偿,原件仍在时可以重新运行恢复。 + +用户已确认整体方案,并要求直接在 `feat/3.0.0` 修复、运行相关测试、暂不提交。实现和测试均保留在该工作区,其他会话的未提交改动未纳入本次修改。 + +## 9 验证与真实环境检查清单 + +### 已执行的本地验证 + +- 修复后回归覆盖工作流输入策略、长 PPT、长 Excel 单元格、表格表头、图片上传、真实本地 PDF、DOCX、PPTX、XLSX Loader、OFD、MinerU V4、原件复制与附件解析、日常聊天及灵思共享临时管线调用方;最终执行结果见本文最后的复审记录。 +- 图片用内存对象存储替身验证上传字节和清理顺序;清单落库及超过 1,000 条消息的遍历使用 SQLite 验证,不连接业务数据库。 +- HTTP 集成测试通过真实 FastAPI 路由验证每次签名重取、307 跳转、拒绝访问无 Location,以及 `no-store`。新增真实 Service 和 SQLite 链路,验证其他用户不能读取图片或首次删除会话,拥有者删除后对象已清理且图片请求被拒绝。登录依赖和存储使用受控替身。 +- 新增实时只读 HTTP E2E 测试 `test/e2e/test_e2e_workflow_parsed_images.py`。本机没有运行中的后端,两项均按服务不可达跳过,不作为真实环境通过证据。 +- 对本次实现及测试执行 Ruff、格式和架构检查。端点独立置于本模块文件,避免改动原聊天端点中既有的跨模块 API 依赖。 + +### 部署环境待执行 + +- [ ] AC-01、AC-10:使用现场含图片原样本,分别选“仅提取正文”和“解析并存入临时知识库”;确认 OCR 产物、主桶图片字节和浏览器最终图片响应,不能只看解析请求成功。 +- [ ] AC-02、AC-03:上传超长 PPT 单页、长正文和长表格,确认仅提取模式不触发 `10912`、`10916`,输出仍受正文上限约束,且没有 embedding、Milvus 或 ES 写入。 +- [ ] AC-04:入库模式召回含图切片,检查 bbox、页码、chunk_index 与图片一致。 +- [ ] AC-05:使旧签名过期后重新打开历史消息,稳定地址重新签发;单独清理临时上传副本,主桶原件与解析图片仍能打开。 +- [ ] AC-06:将 API 与 Workflow Worker 分离部署,临时目录删除后仍可访问图片;无会话调试对象进入临时桶并按实际桶策略回收。 +- [ ] AC-07:用会话所属普通用户、其他用户、其他租户及未登录浏览器分别验证图片读取;未授权响应不得携带签名地址。 +- [ ] AC-08:删除源问题消息后确认历史图片仍可访问,再删除会话核对原件及图片;模拟上传中删除、存储删除失败与重复删除,核对清单与实际对象最终状态。 +- [ ] AC-09:确认旧输入配置、原始文件模式、知识库预览、日常聊天和灵思默认流程可用;执行 MySQL 与 DM8 的锁及分页兼容验证。 + +## 10 代码自查 + +已按仓库 `sdd-code-review` 的七个维度检查本次改动:边界条件、会话权限与租户归属、并发与失败清理、对象路径及签名泄漏、测试覆盖、代码风格、文档同步。本地自动化与静态检查通过;上述真实环境检查项保留未完成标记。未执行提交、推送或部署。 + +### 2026-10-22 修复四项 Review 发现后的复审 + +范围为 `feat/3.0.0` 上本次未提交的解析、附件及清单改动,不包含其他会话的权限模块修改。 + +| 原发现 | 修复与验证 | +|---|---| +| 上传失败回滚删除已发布图片 | 工作流每次尝试独立对象键,回滚需显式启用;默认知识库/预览上下文不删除已发布对象。覆盖默认与显式上下文失败、第二张上传失败、完成登记失败及旧图继续读取 | +| 首次删除会话未校验归属 | 首次与重试删除共用用户、叶子租户及自然人主体检查;数据库 UPDATE 显式绑定相同边界。覆盖其他用户、其他租户、service_account、public_v3、拥有者及失败后重试 | +| 删除源问题后清单丢失 | 独立表保留原件及本次尝试的图片清单;覆盖上传前登记后删除问题、上传完成后删除问题、历史图访问和会话最终删除,以及超过 1,000 条清单的分页 | +| 不同来源被路径归一化合并 | 外部 URL 保留域名、编码路径、凭据大小写及业务查询参数;仅本系统 MinIO 签名做归一化。覆盖不同域名、业务参数、附件匹配及绝对/相对签名链接 | + +| 审查维度 | 本地复审结果 | 证据与边界 | +|---|---|---| +| 边界条件 | PASS | 长正文、长表格、单上下文重复使用、旧附件与清单分页回归 | +| 权限与认证 | PASS | 普通用户正反例、跨租户及 API 主体拒绝;HTTP 调用真实 Service 和 SQLite,登录依赖受控 | +| 并发与失败恢复 | PASS | 逐次上传隔离、消息删除时序、软删除后拒绝完成、清理失败可重试;真实 MySQL/DM8 锁行为待联调 | +| 信息泄漏 | PASS | 未授权图片响应无 Location;新增图片使用默认策略未授予匿名读的 `chat/...` 前缀,存量桶策略待联调;沿用有期限签名策略 | +| 测试覆盖 | PASS | 24 个相关测试文件合并运行:**231 passed、2 skipped**;两项实时 HTTP 测试因本机无运行后端跳过 | +| 代码风格与分层 | PASS | 21 个实现/测试文件 Ruff 与格式检查通过,15 个实现文件架构检查通过 | +| 文档同步 | PASS | 已同步独立清单表、上传尝试隔离、来源匹配、删除权限及部署建表要求 | + +本地复审未发现新增阻塞项。真实 OCR、MinIO、浏览器和 MySQL/DM8 并发验收仍按上方清单保留;本地替身和 SQLite 结果不代表部署环境验收完成。未提交、推送或部署。 diff --git a/docs/architecture/proposals/2026-10-22-dsh-gateway-content-safety.md b/docs/architecture/proposals/2026-10-22-dsh-gateway-content-safety.md new file mode 100644 index 0000000000..57c2f82d52 --- /dev/null +++ b/docs/architecture/proposals/2026-10-22-dsh-gateway-content-safety.md @@ -0,0 +1,443 @@ +# DSH 模型转发敏感词控制与审计回传方案 + +- 日期:2026-10-22 +- 状态:待评审;本轮仅新增方案文档 +- BiSheng 分支:feat/bisheng-work-dsh-guardrails +- BiSheng 基线:2e6b5ae0fb1dcd2e32d480545e1d1ea57e77cd24 +- Gateway 核查基线:feat/remove-dsh-seat,f6160db4ec40bfb815bdcde22f41fd9804008d0a +- Desktop 核查基线:codex/dsh-without-gateway,025fa9198218e989ebec51ff25b48622a1926291 + +本期由 Gateway 按租户执行 DSH 输入与输出敏感词检查,并可靠地将检查结果回传到 BiSheng 审计。backend 的模型鉴权、执行、额度和计量链路继续独立运行,审计模块新增外部内容安全事件接收、投影和查询能力。 + +内容安全事件单独保存事实明细;审计列表的命中标记及筛选直接读取现有审计主表上的摘要字段。默认列表不关联全量事件表,不临时聚合事件历史。会话和树形目录同步维护命中汇总。 + +## 1 目标与职责边界 + +| 系统 | 本期职责 | +|---|---| +| Gateway | 可信租户定位、profile 缓存、租户策略、词库及匹配器、协议检查、阻断、投递 Outbox、补报与监控 | +| backend 模型链路 | 保持既有 Token 和会话鉴权、模型权限、额度准入、供应商调用、取消及计量行为 | +| backend 审计模块 | 接收外部检查事件、校验绑定、幂等落库、生成或补齐审计条目、维护筛选摘要和会话汇总 | +| 平台前端 | 租户策略管理、审计命中标记、筛选、详情和汇总展示 | +| Desktop | 正确识别内容阻断、停止当前轮次和工具交付、显示提示 | +| 部署 | 模型流量统一经过 Gateway,backend 模型端口不对客户端直接暴露 | + +backend 不新增 Gateway URL、敏感词检查 RPC、词库或阻断判断。审计接收端允许可信外部检查器上报事实;采用通用的内容安全事件契约,不把 Gateway 的内部实现写入模型执行流程。 + +本期按租户统一配置,覆盖该租户通过 DSH 使用的所有模型。暂不引入按人、部门、模型分别覆盖的策略优先级。图片识别、OCR、加密 reasoning、同音词、语义审核不在本期范围。原有 Python 工作台、频道和文件策略独立运行。 + +## 2 当前实现与可复用能力 + +DSH 的 Chat Completions 和 Responses 共用 DshModelService.complete。Chat 通过 BishengLLM.ainvoke/astream 执行;原生 Responses 使用所配置 SDK Client 的 responses 接口,因此只包装 LangChain 的调用不能覆盖两条路径。 + +Gateway 的 HTTP 敏感词过滤目前仅覆盖助手对话和旧 process 路由。HTTP 响应检查逐个 SSE 事件匹配,缺少跨事件词语匹配。已有 testWords 接口返回命中话术,并沿用旧过滤器的原文日志及 BlockRecord 写入,不作为新的执行接口直接调用。 + +本期复用 Gateway 的内置词库加载、词表解析和 ACPlus。DSH 使用单独的协议适配与策略读取服务,避免把新行为混进旧应用过滤器。旧应用路由和响应语义保持兼容。 + +现有 backend 审计具有三个需要纳入设计的条件: + +1. 审计主行和正文异步写入,外部回传可能先到。 +2. 请求列表以 started_at 和 request_id 进行游标分页,主表本身是元数据表。 +3. 会话和树形目录已有独立投影,命中状态变化必须触发投影,不能只在详情页添加标记。 + +## 3 请求链路与身份定位 + + Desktop + → Gateway DSH 路由 + → 身份缓存命中,或调用 backend profile + → 读取租户策略快照 + → 输入检查 + → backend 原有模型鉴权、执行和计量 + → Gateway 输出检查 + → Desktop + + Gateway 检查结果 + → Gateway 事务 Outbox + → backend 内容安全事件接收 + → 事件明细与审计摘要 + → 审计列表、详情、会话与树形目录 + +仅拦截下列模型入口: + +| 方法 | 路径 | +|---|---| +| POST | /api/v1/dsh/chat/completions | +| POST | /api/v1/dsh/responses | + +同一入口下所有 request_kind 均检查,不根据客户端审计字段跳过。登录、刷新、profile、模型目录、用量等接口继续透传。部署存在其他模型入口时,必须先列出实际可达路由,再决定接入或封闭,不能根据前缀猜测覆盖范围。 + +Gateway 使用原始 Bearer Token 直接访问 backend 的 GET /api/v1/dsh/profile。目标地址来自部署配置,不接受请求传入的目标 URL,不经过 Gateway 自身路由。user.id 与 tenant.id 以成功响应为准,不使用客户端自报的租户选择策略。 + +profile 用于 DSH 运行时身份定位。它不证明调用人具有租户管理权限,不能用于策略管理授权。实际模型请求仍原样携带 Token,由 backend 重新验证身份、会话及模型权限。 + +## 4 profile 开销与缓存 + +### 4.1 当前调用成本 + +当前正常成功路径按源码静态计数,主体包含约 8 次 SELECT: + +| 路径 | 次数 | +|---|---:| +| DSH 管理设置读取 | 1 | +| access.authenticate 中的用户、租户归属、租户读取 | 3 | +| 设备会话查询 | 1 | +| profile 中再次读取用户、归属和租户 | 3 | + +此计数不包含冷启动和配置基础设施额外访问。缓存未命中会增加一次内部 HTTP 和上述读取;没有实测毫秒延迟。profile 每次查询的去重优化不纳入本期 backend 改动。 + +### 4.2 缓存结构 + +查找顺序为本机 L1、Redis L2、backend profile。复用 Gateway 已有的 Guava 依赖实现有限容量缓存,不因本功能引入新的缓存库。 + +| 项目 | 建议初值 | +|---|---| +| 键 | backend 实例命名空间 + SHA-256 完整原始 Token | +| 值 | tenant_id、user_id、验证时间、绝对失效时间;必要的会话标识可空 | +| L1 正缓存 | 最多 2 秒,容量有限 | +| Redis 正缓存 | 最多 30 秒,可仅向下抖动失效时间 | +| Token 到期上界 | min(30 秒,exp 减当前时间减 5 秒) | +| 凭证错误负缓存 | 本机最多 1 秒,仅限明确凭证或会话失效,容量有限 | +| profile 总预算 | 建议 1 秒,等待合并和 HTTP 共用预算,不自动重试 | + +命中不续期,L1 不延长 L2 的绝对失效时间。只有 profile 成功后才写正缓存。读取同一已验证 Token 的 exp 仅用于限制缓存寿命,身份归属仍取 profile 返回值。Token 解码异常时不缓存该成功结果。 + +缓存不保存原始 Token,不以用户 ID 或租户 ID 单独作为键。新的 Token 需重新验证。现有通用 WebClient 会在 DEBUG 下输出请求头,新 ProfileClient 不沿用该头部日志,不记录 Authorization 或 Cookie。 + +### 4.3 并发、故障与失效 + +同一实例对同一 Token 的并发未命中合并成一次查询;多个 Gateway 副本通过 Redis 短锁协调并读取共享结果。共享查询不因一个等待请求取消而被取消。锁有有限租约,等待有截止时间;超时不触发无上限重复查询。 + +profile 设置连接池、独立并发上限和有限等待队列。建议初始每实例最多 32 个在途 profile 查询,结合实际负载调整。容量耗尽返回明确不可用,不延长旧缓存有效期。 + +Redis 不可用时,允许使用仍在有效期内的本机结果;未命中则在并发预算内直接查询 profile。profile 超时、5xx、响应格式异常时拒绝本次模型请求,不写成身份无效或策略关闭。 + +backend 返回明确凭证、会话、用户或租户失效时删除该 Token 缓存,并原样返回错误;模型权限不足和额度不足不当作身份缓存失效。模型请求不因清缓存而自动重试。 + +身份缓存最多保留 30 秒旧定位信息,不承诺缓存期内会话持续有效。实际模型准入仍由 backend 检查最新会话和归属。输入被提前阻断时,可能优先显示内容提示;若要求所有阻断前都实时报告注销,需要额外实时验证,此项不作为默认行为。 + +### 4.4 成本估算与验收 + +估算公式为 profile QPS 约等于模型请求 QPS 乘身份缓存未命中率。计量单位是模型 HTTP 请求,不能按 SSE 片段计算。 + +例如,同一 Token 在缓存有效的 30 秒内连续请求 20 次,首次调用 profile,其余请求使用缓存。不同 Token 的首次访问仍要查询,低频访问的命中率可能较低。 + +上线前分别测量冷缓存、热缓存、Token 刷新、多副本和 Redis 故障下的 profile QPS、缓存命中率、p50/p95/p99、连接池等待及 backend 查询量。建议超时值不代表已达到的响应时间。 + +## 5 租户策略与词库 + +Gateway 新增 gt_dsh_sensitive_policy,以 tenant_id 唯一定位策略,保存 enabled、input_enabled、output_enabled、word_types、custom_words、auto_reply、version、内置词库摘要、操作者及更新时间。使用版本号防止并发编辑覆盖。 + +策略按租户隔离,不把租户 ID 塞入旧应用 resource_id 来复用主键。无策略记录表示默认关闭,查询失败表示不可用。启用时验证词表来源、实际有效词数、词数和字节预算、单词长度及提示话术;空词表不能成为已启用但无检查的状态。 + +| 缓存 | 规则 | +|---|---| +| 租户当前策略 | Redis 最多 5 秒,本机最多 1 秒 | +| 策略保存 | DB 事务提交后更新共享缓存,广播失效 | +| 广播丢失 | 由绝对有效期保证新请求最多 5 秒后刷新 | +| 匹配器 | 按 tenant_id、策略版本、词库摘要编译缓存,限制总内存 | +| 在途请求 | 固定使用进入时的策略快照 | + +旧查询不能覆盖新版本;缓存有效期从读取开始计算并在写入前检查,防止慢查询延长旧版本寿命。保存接口说明“新请求最多 5 秒生效”。普通策略更新不打断在途流。 + +内置词库随 Gateway 发布,发布时核对副本词库摘要。相同策略版本不能在不同实例上静默使用不同内置文件。匹配器加载和编译不阻塞 Reactor 事件循环。 + +部署级围栏能力开关默认关闭。关闭时保持原有透传。启用后需要取得可信租户才能判断租户策略;无法取得身份或策略时返回不可用。Gateway 商业能力不可用时,已启用的围栏不能自动降为透传。 + +## 6 输入和输出检查 + +### 6.1 检查范围 + +结构化提取完整上下文中的消息文本、可读 reasoning、工具描述、工具参数和工具结果。JSON 先解码,工具参数进一步还原 JSON 中的字符串内容,不扫描整个原始请求 JSON。 + +同一消息的连续文本块保持连续;不同消息、不同工具调用分别处理,避免误拼接。图片 base64、加密 reasoning、鉴权材料、协议标识和审计元数据不进入匹配缓冲。 + +首版沿用 Gateway 字面子串匹配和大小写语义,不附加同音、拼音或语义判断。遇到无法安全解析的协议正文,返回明确错误,不跳过检查透传。 + +### 6.2 放行时机 + +| 类型 | 行为 | +|---|---| +| 输入命中 | 不转发 backend,立即阻断并记录安全事件 | +| 非流式输出 | 完整聚合、解析和检查后交付 | +| 流式文本 | 本地批次检查,保留最大词长 L 减 1 的尾部,结束时检查剩余部分 | +| 工具调用 | 完整组装参数并解析检查后交付,检查前不暴露可执行参数 | +| backend 原有错误 | 保持鉴权、额度等错误,不伪装成敏感词命中 | + +检查以 Unicode 码点统一计长,正确处理 UTF-8 跨字节分片及 SSE 多行。仓库内置词表样本的最大词长为 16 个码点,实际尾部长度按生效词库计算。 + +Chat 与 Responses 都覆盖正文、可读推理及工具参数。Responses 的 delta、done、最终 response 不能分别走不同检查口径,完整快照出现新正文时需先检查。原始事件保持顺序及 sequence_number,不因延迟交付重新排序。 + +验收目标是命中词在约定可读文本范围内不被下发。已放行的安全前缀保留。若要求命中后整条回复均不可见,需要整轮缓冲,属于另一种产品口径。 + +### 6.3 限制与终止 + +DSH 路由使用独立有限缓冲,不能直接继承 Gateway 当前 200 MB 的全局缓存预算。对齐现有 DSH 的 32 MiB 请求上限,以及原生 Responses 的 8 MiB 单事件、32 MiB 累计响应上限;额外限制总在途缓冲、解析复杂度、词表编译内存和检查任务并发。 + +检查器异常、协议解析失败、缓存超限均停止交付并报告错误。禁止打印原文后透传。文本检查不复制图片 base64 到匹配缓冲,压测同时测量编码字节预算与 Java 堆内存放大。 + +输出命中后建议停止客户端交付,由 Gateway 管理有界后端流读取,争取让 backend 获得 usage 并完成结算。建议初始剩余读取时间最多 30 秒,另设字节和并发预算。此期间仍可能生成 Token 并计费。超时、断连或预算耗尽时关闭上游,backend 沿用现有取消和 USAGE_UNKNOWN 行为。 + +| 场景 | 客户端结果 | +|---|---| +| 输入或非流式输出命中 | HTTP 403,content_blocked | +| Chat 流式命中 | content_filter 终止,不再发送正常成功尾帧 | +| Responses 流式命中 | 明确的内容阻断错误事件,停止正常完成事件 | +| 检查基础设施失败 | 响应开始前 HTTP 503;开始后发送错误事件并终止 | + +Gateway 对模型入口使用现有 DSH 错误包结构。错误回传保持最小元数据,不包含匹配原文。 + +## 7 审计回传契约与可靠性 + +### 7.1 事件 + +backend 新增通用接收端 POST /api/v1/internal/dsh/content-safety-events。Gateway 使用服务间凭据及 HMAC-SHA256 签名,复用现有签名计算机制;接收认证不依赖用户 Token 当前仍有效。 + +仅对新增事件端点建立服务鉴权入口,签名通过后按事件绑定建立租户上下文,不豁免整个 DSH 路由前缀。浏览器 JWT 不能直接写入检查结果。 + +采用内容安全专用凭据、请求体大小限制和签名时间窗口。签名覆盖方法、路径、完整原始请求体,事件编号和发送时间位于签名保护范围内。重试保留原事件编号和发生时间,每次发送更新签名时间。接收端保留幂等结果,重复投递返回已提交确认。 + +| 字段 | 含义 | +|---|---| +| source_id、event_id、schema_version | 来源、幂等标识和契约版本 | +| gateway_request_id | Gateway 在模型请求开始时生成的编号 | +| backend_request_id | 本次模型响应 X-Request-ID;未转发时为空 | +| tenant_id、user_id、model_id | 检查发生时的归属 | +| session_id | 可空;缺失时不能编造 | +| request_started_at、occurred_at | 请求发生时间和事件发生时间 | +| protocol、event_kind、stage、decision | 协议、DECISION/FINAL/DRAIN 事件种类、适用阶段与决定 | +| stage_results、event_version | 输入输出各阶段结果与来源内单调事件版本 | +| policy_version、builtin_digest | 采用的策略和词库快照 | +| hit_count、rule_refs | 有限命中摘要,不保存原文 | +| conversation、request_kind | 按现有约束校验的可选会话提示 | +| forwarded、drain_status | 是否向 backend 转发及后续读取情况 | + +每次请求上报检查终态,正常请求通常一条最终汇总;命中时立即生成事件,后续读取结束需要更新状态时另发事件。事件量随请求数增长,不随 Token 或 SSE 片段数增长。上报 input/output 两阶段状态,禁止只上报“输出通过”就把未检查的输入显示为通过。 + +阶段结果区分 PASSED、BLOCKED、ERROR、DISABLED、NOT_APPLICABLE 和 UNREPORTED。例如,输入阻断后输出为 NOT_APPLICABLE;backend 返回鉴权或额度错误时,没有模型输出可检查,不能编造输出通过。 + +没有事件时汇总为 UNREPORTED;全部阶段明确关闭才为 DISABLED;任一阶段命中优先汇总为 BLOCKED;其余可检查阶段失败为 ERROR;全部适用阶段完成且通过才为 PASSED。DISABLED 需要收到明确事实,部署未接入 Gateway 或开关关闭且未上报时仍是 UNREPORTED。后到 PASSED、ERROR 或旧版本事件均不能清除命中。 + +### 7.2 Gateway Outbox + +Gateway 在同一事务内提交检查决定和待投递事件;也可以直接以不可变 Outbox 事件作为本地事实,避免再保存重复正文日志。后台通过有限租约领取任务、指数退避投递,支持重启恢复、失败告警及补报。 + +backend 在事件与审计摘要事务提交后才确认成功。不能仅放入现有内存审计队列就返回成功。ACK 丢失后 Gateway 重试,由 backend 唯一约束去重。 + +Outbox 写入失败仍阻断内容,产生明确告警,不能把缺口描述为已可靠回传。待投递数量、最老事件年龄、失败数及最后成功时间可观测。 + +### 7.3 关联与竞态 + +输出事件使用模型响应中的 backend_request_id,不使用 profile 请求的 X-Request-ID。接收端按事件的 tenant_id、user_id、model_id 校验与已有审计行的绑定。历史事件不因用户现在已退出或调换租户而丢弃。 + +输出事件可能先于模型审计到达。接收端先保存事件,并创建仅有外部安全元数据的审计条目;模型审计后到时补齐同一条目,不产生第二条记录。双方创建竞争通过唯一约束和短事务重读解决。 + +输入命中时没有 backend 模型请求,使用 gateway_request_id 创建外部安全审计条目,明确显示“输入命中,未调用模型”。不写 dsh_model_call,不产生额度或 Token 用量事实。 + +转发后未取得 backend 请求编号的事件保留为未关联,不猜测匹配某条模型记录。重试投递不能重新创建已超过保留期且已清理的审计条目。 + +## 8 数据模型与大数据量筛选 + +### 8.1 事实表与摘要分离 + +backend 新增 dsh_content_safety_event,保存不可变事件事实;投递处理状态可单独维护。事件表承担幂等、详情、排障、回放与修复,不承担默认列表的临时聚合。 + +现有 dsh_model_audit_request 新增查询摘要: + +| 字段 | 用途 | +|---|---| +| has_sensitive_hit | 是否已经收到命中事实,单调 false 到 true | +| input_sensitive_hit、output_sensitive_hit | 输入与输出命中方向 | +| content_safety_state | UNREPORTED、PASSED、BLOCKED、ERROR、DISABLED;处理中可按明确事件扩展 | +| content_safety_updated_at | 最近事实更新时间 | +| content_safety_policy_version | 可显示的策略版本;多版本以事件详情为准 | +| safety_projection_version | 摘要并发控制和重建水位 | +| record_origin | MODEL 或 EXTERNAL_SAFETY | +| model_entered | 可空;外部输入阻断为 false,仅 backend 事实可确认 true | +| model_started_at | 可空;保存 backend 原生审计提供的实际模型执行起点 | + +has_sensitive_hit=false 仅表示没有已知命中事实,必须与 content_safety_state 一起展示,不能翻译为“安全”。历史记录初值 UNREPORTED,不回填为 PASSED。 + +### 8.2 外部安全条目的状态兼容 + +当前审计 DTO 要求所有条目具备模型审计模式,主表也仅接受 METADATA/CONTENT;不能直接插入安全条目并把状态凑成 RUNNING。 + +本期需要明确扩展审计契约: + +- EXTERNAL_SAFETY 的 effective_mode、model_result、response_result、usage_settlement 允许不适用;不伪造模型运行、ASGI 交付和结算结果。 +- 此类条目的原文状态是 NOT_CAPTURED,audit_status 增加 NOT_APPLICABLE,界面显示“不适用”;输入阻断另显示 model_entered=false。其计数不归入原模型审计的 NORMAL 或 ABNORMAL。 +- 后到 backend 原生审计补齐真实字段,来源升级为 MODEL,恢复既有状态派生规则,并保留全部安全摘要。当前 upsert_request 对身份字段要求完全相等,需要增加受控合并:允许补齐未知字段,拒绝覆盖已知且冲突的租户、用户、模型及会话绑定。 +- UNKNOWN 与“不适用”分别表达;仅外部事件到达不证明模型已执行。 +- 停滞扫描、完整性校验、readiness、会话索引和正文访问均识别条目类型。未知模型名称不阻塞全租户索引发布。 +- 外部安全条目不生成正文查看链接;真实原文仍遵循既有权限和保留规则。 + +主表 started_at 首次建立后固定:原生模型条目沿用既有 started_at,外部条目使用经验证的 request_started_at;后到的原生审计填写 model_started_at,不移动排序键。旧 MODEL 行可将 model_started_at 回填为 started_at。事件仍保留自己的 occurred_at,界面区分请求开始和模型执行开始,不把回调到达时间当作发生时间。 + +### 8.3 默认列表直接使用主表索引 + +默认列表读取主表摘要,不 JOIN 全量事件表,不对事件表逐条查询,不从事件 JSON 搜索命中词。仅详情按 tenant_id 和 request_id 游标读取对应事件。 + +建议必备索引: + +| 表 | 索引顺序 | 查询 | +|---|---|---| +| 审计主表 | tenant_id、has_sensitive_hit、started_at、request_id | 租户内命中记录和时间分页 | +| 事件表 | source_id、event_id,唯一 | 幂等接收 | +| 事件表 | tenant_id、request_id、occurred_at、event_id | 单请求事件详情 | +| 事件表 | tenant_id、received_at、event_id | 保留期处理和排障 | + +按实际高频组合,经执行计划验证再增加主表的 tenant_id、user_id、has_sensitive_hit、started_at、request_id,或 tenant_id、model_id、has_sensitive_hit、started_at、request_id。不为所有筛选组合堆索引,也不单独给布尔值建索引。 + +命中列表的逻辑条件如下,由 SQLAlchemy 生成 MySQL/DM8 对应分页语法: + + SELECT request_id, started_at, has_sensitive_hit, content_safety_state, ... + FROM dsh_model_audit_request + WHERE tenant_id = :tenant + AND has_sensitive_hit = :hit + AND started_at >= :start_at AND started_at < :end_at + AND (started_at > :cursor_time + OR (started_at = :cursor_time AND request_id > :cursor_id)) + ORDER BY started_at, request_id + +每页取 page_size 加 1,避免 OFFSET 深分页。游标绑定租户和全部过滤条件,新增命中条件也计入指纹。活动查询中后到的事件可能让旧记录新满足条件,界面提供刷新,不宣称跨页构成不可变快照。 + +默认时间窗口建议最近 7 天,单次在线查询最多 31 天。更长时间分段查询或后续专项导出。不在每次翻页执行全历史 COUNT DISTINCT 或对事件做 GROUP BY。现有 usage 补数的查询成本单独作为基线,本功能不能额外引入每行一次事件查询。 + +### 8.4 投影与汇总一致性 + +事件接收事务内完成去重、行锁或 CAS、摘要更新及持久 dirty 标记;ACK 表示这部分已经提交。安全字段由安全投影器唯一维护,原模型审计 writer 补齐其他字段时不得把命中标记重置。 + +相同 source_id 和 event_id 对应不同载荷摘要时返回冲突,不能按重复成功掩盖冲突。事件重复不增加命中次数,乱序不清除 BLOCKED。输入和输出的命中标记累积,检查结果按请求阶段及事件版本归并。冲突身份事件拒绝关联并保留诊断,不覆盖原行。 + +会话和树形目录新增 sensitive_hit_count、has_sensitive_hit 及 own/branch 命中汇总,独立于原模型审计异常数。即使原 audit_status 没变,命中变化仍要触发 group 和 tree summary 更新。 + +计数按“该请求是否已计入命中”计算差量,持久保存每请求已投影的安全命中状态,不能按收到的命中事件数累加。审计条目数包含外部安全条目;模型调用次数和用量继续取 dsh_model_call,不由新增条目推算。worker 有有限批次、租约、重试和可恢复的进度。汇总构建复用现有代际发布机制,合并短时间内重复变更,不为每个 SSE 片段或事件重建整棵树。 + +只按是否命中筛选会话或树节点时,直接读取汇总标记并使用包含租户、父节点或代际、命中标记、排序键的索引。组合模型、用户、时间等请求条件时,沿用有界候选查询,超过扫描预算必须标记 scan_limited,不能把未扫描部分展示成没有命中。 + +### 8.5 数据增长与保留 + +独立事件表确实有存储和写入成本。若假设每天 100 万个请求、平均 2 条事件、每条编码后 1 KiB,则原始事件约 1.91 GiB/天、57.2 GiB/30天,尚未计入行、索引、Outbox、副本及备份。这是容量示例,不是当前环境实测。 + +主表摘要每请求一行,不随事件条数增加;普通命中列表的查询不随事件总量做线性聚合。主表规模、租户倾斜、筛选选择率和索引写放大仍需压测。 + +建议在线事件明细先按 30 天评估,最终与现有审计保留期一起确认。摘要保留到对应主审计删除,不因明细清理变回 UNREPORTED。需要完整重建的窗口必须保留事件或可回放归档,不能一边删除事实一边承诺任意重建。 + +清理使用按租户和时间游标的有限批次,先验证关联保留条件;不执行单次大事务删除。分区、额外搜索服务或归档存储作为达到容量门槛后的专项评估,首期不承诺跨 MySQL/DM8 通用的分区 DDL。 + +## 9 平台前端与 Desktop + +平台 DSH 管理增加租户敏感词配置,沿用现有词表交互和已落地组件。管理接口由 Gateway 持有,建议使用 /api/sensitive/dsh/settings 和 /api/sensitive/dsh/records 等明确路径。 + +Gateway 使用浏览器登录凭据查询已有 backend 用户和租户接口,验证租户管理权限。普通租户管理员仅能管理本租户;超管选定的目标租户也须验证存在与可管理范围。配置写操作实时校验权限,不使用 DSH profile 身份缓存授权。 + +backend 审计 API 返回安全摘要,增加是否命中、方向和检查结果筛选。列表显示“输入命中”“输出命中”;详情显示策略版本、时间、有限命中摘要和投递来源。会话、轮次及树形目录显示命中汇总和汇总更新时间。 + +界面允许同时显示“模型执行成功”和“输出命中,已拦截”。输入安全条目显示“未调用模型”,不显示零 Token 来冒充已完成计量。未知结果显示“尚未收到检查结果”,不当作已通过。 + +Desktop 的 Chat 已识别 content_filter;Responses 当前会把错误事件归类为上游错误,需要保留内容阻断和检查不可用的明确错误码。阻断后停止当前轮次,不自动重试,不执行尚未检查通过的工具参数。后台读取可能仍在结算,客户端用量展示以 backend 后续结果为准。 + +界面沿用当前设计规范和组件,新增文案补齐 zh-Hans/en/ja,不自行改变组件视觉样式。 + +## 10 各仓库改动范围 + +| 范围 | 计划改动 | 边界 | +|---|---|---| +| Gateway 路由及过滤器 | DSH 精确路由、有限 body/SSE 缓冲、输入输出适配、阻断与有界读取 | 不重新引入席位和旧 DSH 登录认证 | +| Gateway 身份 | 独立 ProfileClient、L1/L2 缓存、请求合并、并发限制、失效监控 | 不持有 backend Token 签名密钥 | +| Gateway 策略与投递 | DSH 租户策略、匹配器快照、Outbox、签名发送和重试 | 不调用旧 filterNormal 的原文持久化副作用 | +| backend 事件接口 | 独立接收认证、契约校验、幂等事务、补报状态 | 不主动连接 Gateway | +| backend 数据与审计 writer | 新事件事实表、主表摘要、外部安全条目与后到模型审计合并 | 不修改模型调用和 quota 结算语义 | +| backend 查询和投影 | 主表索引、DTO、过滤、会话和树汇总、保留期与修复 | 不把正文或事件 JSON 放进主列表热查询 | +| 平台前端 | 租户配置、命中筛选、标签、事件详情、汇总与三语文案 | 配置走 Gateway,审计查询走 backend | +| Desktop | Chat/Responses 错误展示和终止语义 | 不新增客户端词库判定 | +| 部署和迁移 | 网关入口、专用回传凭据、SQLModel/迁移适配、索引回填、监控 | 独立核对 MySQL 和 DM8 | + +backend 重点涉及 dsh/api/endpoints、domain/models/model_audit_request、domain/schemas/model_audit、domain/repositories/model_audit_write 与 model_audit_queries、domain/services/model_audit_writer、会话和树投影以及安全事件专用模块。runtime.py、model.complete 的执行与计量逻辑不增加 Gateway 检查依赖。 + +外部安全条目的状态扩展是审计合同变更,需要在后续 Spec/Design 评审中明确,不能仅增加几个前端字段后视为完成。 + +## 11 发布与迁移 + +1. 先发布 backend 接收、事件表、摘要字段及兼容读取能力;旧记录按有限批次回填 UNREPORTED 和无已知命中。新增列与索引的锁表和耗时需在目标库预演。 +2. 批次构建会话和树命中汇总,维护 readiness。完成前界面显示索引准备中,不返回貌似完整的空结果。 +3. 发布平台与 Desktop 兼容展示,再发布 Gateway 检查和投递能力。保持部署开关关闭验证透传。 +4. 检查 Gateway 与 backend 时钟、签名配置、profile 内网连通、Outbox 补报、词库摘要与模型流量入口。 +5. 按租户启用,观察正常流量、命中率、profile 开销、回传滞后和查询性能,再扩大范围。 + +接收端支持事件先到和重试;Gateway 不依赖 backend 已升级的模型接口。关闭租户策略影响新请求,在途请求按固定快照结束。停用检查后,已经产生的 Outbox 继续补报;回滚不删除事实表、摘要或待投递事件。 + +backend 接收与查询增量迁移接在当前 work-110 实际迁移 head 后,不复制其他版本线的 revision。跨仓库分别提交、测试和核对发布顺序。本轮只交付评审文档。 + +## 12 验收与性能验证 + +| 编号 | 必须验证的结果 | +|---|---| +| AC-S01 | 两个模型入口的流式与非流式均覆盖;未命中透传保持协议、Token、错误和用量兼容 | +| AC-S02 | profile 键按完整 Token 和 backend 区分;30秒上界、Token到期、并发合并、多副本、撤销、负缓存和Redis故障符合约定 | +| AC-S03 | 普通用户不能管理策略;管理员不能越租户读写策略、事件或审计;回传不依赖用户Token当前有效 | +| AC-S04 | 策略保存对新请求最多5秒生效,在途快照固定;词库为空或加载失败不会默许放行 | +| AC-S05 | 跨SSE、UTF-8字节、连续文本块、转义JSON和工具参数命中均在交付前阻断;Responses最终快照不能绕过 | +| AC-S06 | 输入命中后backend模型调用次数为0,有独立安全审计条目,没有额度和Token调用事实 | +| AC-S07 | 输出命中关联准确,模型审计先到、后到、关闭、缺失及Gateway未拿到编号分别显示真实状态 | +| AC-S08 | 重试、ACK丢失、乱序、并发、重启和多实例消费不重复记账、不清除命中、不丢已确认事件 | +| AC-S09 | 原模型writer后写不重置安全字段;外部条目不导致readiness永久失败、正文入口失效或停滞任务误报 | +| AC-S10 | 列表直接筛选主表摘要;会话及树命中计数按请求去重且独立于模型异常;索引未完成有明确提示 | +| AC-S11 | 内容审计关闭仍能保存最小安全事实;新字段不把历史UNREPORTED显示为检查通过 | +| AC-S12 | 有界读取在30秒及字节/并发预算内停止,保留实际计量或未知状态,不伪造零消耗 | +| AC-S13 | MySQL/DM8升级、索引、回填、幂等、保留期、权限及既有Gateway应用回归通过 | + +性能数据集至少覆盖百万与千万请求主行,事件量按平均每请求1至3条生成;分别覆盖低命中率、高命中率、单租户集中、跨租户、晚到事件和长会话。逐类验证“仅命中”“命中加用户”“命中加模型”“命中加时间”“会话和树汇总”。 + +MySQL 与 DM8 分别查看执行计划、扫描行数、排序、p95/p99、索引大小、写入与锁等待,特别验证深游标页和回填期间。与相同数据和选择条件下的当前审计查询比较,不能用空库或 SQLite 证明生产性能。 + +正常列表不触发事件表全扫、全历史聚合或逐行事件查询是结构验收条件;目标延迟与容量门槛在真实中间件压测后确定。新增安全功能不能用“有索引”替代实测结论。 + +## 13 需要评审确认的取舍 + +| 项目 | 本方案建议 | +|---|---| +| 身份缓存 | 最多30秒,只定位租户,模型准入仍由backend验证 | +| 策略生效 | 新请求最多5秒,在途请求固定快照 | +| 流式展示 | 保留安全前缀,命中词不下发;工具参数整调用检查 | +| 输出命中计量 | 有界读取后端流,最多30秒,接受继续产生Token的可能 | +| 审计存储 | 独立事件事实表加现有主表摘要,列表不聚合事件历史 | +| 外部安全条目 | 输入阻断和原审计缺失也可展示,明确不适用或未观测状态 | +| 在线时间范围 | 默认7天、单次最多31天;更久分段 | +| 明细保留 | 先按30天评估,与原审计保留和归档能力一起确认 | + +当前为方案评审。通过后按项目 SDD 流程形成 Spec、Design 和任务拆分,再进入实现与真实环境验收。 + +## 附录 源码核查入口 + +以下路径相对对应仓库根目录。当前结论来自所列基线源码;缓存参数、索引和容量数据集属于待实现和待压测设计。 + +| 仓库 | 路径 | 核查内容 | +|---|---|---| +| BiSheng | src/backend/bisheng/dsh/api/endpoints/models.py | 两个模型入口、profile和SSE资源关闭 | +| BiSheng | src/backend/bisheng/dsh/domain/services/model.py | 准入、供应商执行、结算与SSE出口 | +| BiSheng | src/backend/bisheng/dsh/infrastructure/chat_adapter.py | Chat调用、工具片段、推理和usage | +| BiSheng | src/backend/bisheng/dsh/infrastructure/responses_adapter.py | 原生SDK、事件、终态及大小限制 | +| BiSheng | src/backend/bisheng/dsh/access_runtime.py | profile依赖与DSH设置读取 | +| BiSheng | src/backend/bisheng/dsh/domain/services/access.py | Token、身份和会话验证 | +| BiSheng | src/backend/bisheng/dsh/domain/services/identity.py | profile重复身份读取 | +| BiSheng | src/backend/bisheng/dsh/domain/repositories/identities.py | 用户、归属和租户的三次读取 | +| BiSheng | src/backend/bisheng/dsh/api/responses.py | X-Request-ID和ASGI审计观察边界 | +| BiSheng | src/backend/bisheng/dsh/domain/models/model_audit_request.py | 主表、证据和现有索引 | +| BiSheng | src/backend/bisheng/dsh/domain/schemas/model_audit.py | 当前状态类型和元数据DTO约束 | +| BiSheng | src/backend/bisheng/dsh/domain/repositories/model_audit_queries.py | 主表过滤、游标和页面补数 | +| BiSheng | src/backend/bisheng/dsh/domain/repositories/model_audit_write.py | 严格身份绑定和异步主行创建 | +| BiSheng | src/backend/bisheng/dsh/domain/services/model_audit_writer.py | 元数据、正文写入及字段更新 | +| BiSheng | src/backend/bisheng/dsh/domain/repositories/model_audit_conversations.py | 会话计数、dirty标记及有界候选查询 | +| BiSheng | src/backend/bisheng/dsh/domain/repositories/model_audit_tree_build.py | 会话树代际构建与汇总发布 | +| BiSheng | src/backend/bisheng/sso_sync/domain/services/hmac_auth.py | 可提取复用的签名计算模式 | +| BiSheng | src/backend/bisheng/user/api/user.py | 浏览器用户信息和管理标识 | +| BiSheng | src/backend/bisheng/tenant/api/endpoints/tenant_crud.py | 已有租户读取和管理鉴权 | +| Gateway | src/main/java/com/dataelem/gateway/filter/SensitiveWordsFilter.java | 旧路由、缓存、匹配及日志副作用 | +| Gateway | src/main/java/com/dataelem/gateway/filter/CustomResponseFilter.java | HTTP响应过滤及逐SSE事件检查 | +| Gateway | src/main/java/com/dataelem/gateway/entity/SensitiveWords.java | 旧应用策略没有tenant_id | +| Gateway | src/main/java/com/dataelem/gateway/utils/ac/ACPlus.java | 可复用匹配引擎 | +| Gateway | src/main/java/com/dataelem/gateway/config/SpringCustomConfig.java | WebClient、Redis与请求头日志 | +| Gateway | src/main/java/com/dataelem/gateway/client/BishengSsoPushClient.java | 已有HMAC发送模式 | +| Gateway | src/main/java/com/dataelem/gateway/filter/LicenseExpiredGlobalFilter.java | 商业端点和透传端点的现有边界 | +| Desktop | packages/dsh-desktop-enterprise/openai.js | content_filter及错误码处理 | +| Desktop | packages/dsh-desktop-enterprise/responses.js | 错误事件归类、事件顺序验证 | diff --git "a/docs/blog/\345\275\223\344\270\200\344\270\252\347\237\245\350\257\206\345\272\223\345\220\214\346\227\266\351\235\242\345\257\271\344\270\200\345\215\203\347\247\215\346\235\203\351\231\220.md" "b/docs/blog/\345\275\223\344\270\200\344\270\252\347\237\245\350\257\206\345\272\223\345\220\214\346\227\266\351\235\242\345\257\271\344\270\200\345\215\203\347\247\215\346\235\203\351\231\220.md" new file mode 100644 index 0000000000..0f3a887355 --- /dev/null +++ "b/docs/blog/\345\275\223\344\270\200\344\270\252\347\237\245\350\257\206\345\272\223\345\220\214\346\227\266\351\235\242\345\257\271\344\270\200\345\215\203\347\247\215\346\235\203\351\231\220.md" @@ -0,0 +1,404 @@ +# 当一个知识库同时面对一千种权限:企业级 RAG 的检索层设计 + +> 本文所有代码路径均来自 BISHENG 开源仓库(github.com/dataelement/bisheng),可逐行查证。 + +--- + +## 1. 安全评审上的一道必答题 + +一套 AI 应用平台要进大型企业内网,绕不开信息安全部门的评审。几乎每一家都会问到同一个问题,措辞略有不同,本质完全一致: + +> "同一个知识库,销售和法务问同一个问题,AI 会不会给出同样的答案?" + +这看起来是产品问题,实际上是架构题。答不好,就出局。 + +值得注意的是,问题的落点通常并不在"权限配置错了"。绝大多数平台的权限模型本身是对的——那份《薪酬管理办法》在文件列表页对销售就是不可见的,点进去会 403。真正的风险在另一个地方: + +**"检索"这一步,默认不经过权限。** + +同一份数据,在列表页跑的是一套可见性口径(走权限引擎),在问答页跑的是另一套(走向量相似度)。两套口径长期不对齐,就会出现那个最难解释的场景:用户在文件列表里根本看不到这份文件,却在 AI 的回答里读到了它的原文段落,还附带一个点不开的引用角标。 + +于是全文要回答的问题就变成了: + +**向量检索的本质是"给我一个向量,还你最近的 k 个",它凭什么知道调用者是谁?** + +--- + +## 2. 第一性原理:向量检索为什么天生不认身份 + +### 2.1 接口契约里没有"谁在问" + +ANN(近似最近邻)检索的接口契约只有一条: + +``` +search(query_vector, k) -> top-k nearest neighbors +``` + +入参里有向量,有 k,有距离度量,有搜索精度参数。**没有主体。** 这不是某个向量数据库的疏漏,而是这类数据结构的定义本身——它建模的是"空间中的邻近关系",不是"访问控制关系"。所以任何"权限感知的向量检索",都必然是在这条契约之外额外拼装出来的;区别只在于拼在哪一层,代价由谁付。 + +### 2.2 过滤发生在图遍历的哪一层,决定了它是几乎免费的还是毁灭性的 + +BISHENG 的 Milvus Collection 默认建 HNSW 索引(`src/backend/bisheng/knowledge/rag/milvus_factory.py:10`): + +```python +# src/backend/bisheng/knowledge/rag/milvus_factory.py +_default_index_params = {"index_type": "HNSW", "metric_type": "L2", "params": {"M": 8, "efConstruction": 64}} +``` + +HNSW 是一张分层邻近图,检索是图上的贪心游走:从上层稀疏图找到大致方向,逐层下沉,在底层用一个大小为 `ef` 的候选队列做局部扩展,最后取前 k 个。理解这一点,就能理解过滤位置的差别: + +| 过滤位置 | 机制 | 代价 | +|---|---|---| +| **遍历后过滤**(post-filter) | 图照常走完,取回 k 个,再按业务条件砍 | 图遍历本身不受影响,但**最终剩几条完全不可控** | +| **遍历中过滤**(filtered search,Milvus `expr` 下推) | 过滤条件参与图遍历,被排除的节点不进候选队列 | 结果数量可控,但**过滤条件的选择性直接影响图的连通性与搜索代价** | + +第二种并不总是更好。当过滤条件极度稀疏,图的有效连通性被破坏,贪心游走需要探索远多于 `ef` 的节点才能凑够 k 个,代价可能反而高于全量遍历。Milvus 内部对此有自适应策略,但**"下推一定更快"是一个错误的直觉**。 + +### 2.3 关键洞察:权限是一个"每个用户都不一样的动态谓词" + +这是本文的地基。 + +按部门过滤、按时间过滤、按文件类型过滤,这些都是普通业务字段:**取值空间有限、可枚举、可分区、可建标量索引**,代价是一次性的。 + +权限不是。权限的取值空间是 **用户数 × 文件数** 的组合——1000 个用户 × 10 万份文件 = 一亿个布尔值,而且它随组织架构变化持续漂移:入职、转岗、离职、部门重组、临时授权到期。**它无法预先物化,也无法枚举成有限的分区键。** + +**这就是权限过滤和一切普通业务过滤的本质区别:普通过滤的谓词属于数据,权限过滤的谓词属于调用者。** 数据侧的东西可以提前算好放进索引;调用者侧的东西只能在请求到达时才知道。 + +--- + +## 3. 三种朴素解法,以及它们各自是怎么崩的 + +### 3.1 方案 A:一人一 Collection / 一人一 Partition + +**思路**:既然向量库支持物理隔离,那就给每个用户一个自己的容器,检索时只查自己的。安全性上无懈可击。 + +**崩塌点**: + +1. **数量随用户数线性膨胀。** 任何向量库对 Collection / Partition 数量都有上限,元数据管理、加载内存、连接句柄都随之膨胀。 +2. **共享文档要复制 N 份向量。** 一份 500 页的制度文件被 200 人可见,就要存 200 份 Embedding,存储、写入、更新成本一起乘以 200。 +3. **权限一变就要重建索引。** 员工转岗,他的容器要重新灌一遍;权限变更从一次数据库写变成一次批量向量写入。 + +**结论**:这个方案在**租户级隔离**上是完全正确的——租户数量有限、租户边界稳定、跨租户共享是例外而非常态。Milvus 官方那篇讲多租户策略的文章(Collection / Partition / Partition Key 三档选型)值得完整读一遍,它是这个层面的标准答案。但**它不适合用户级权限**:用户级权限的三个特征(数量大、边界变化频繁、共享是常态)恰好把这个方案的三个前提全部推翻。 + +> **物理隔离解决的是"不同的水池",权限解决的是"同一个水池里谁能捞哪条鱼"。用前者去做后者,是量纲错了。** + +### 3.2 方案 B:先召回,后过滤(post-filter only) + +**思路**:正常检索 top-k,拿到结果后逐条查权限,没权限的丢掉。改动最小,安全性也没问题。这是绝大多数团队面对第 1 章那道必答题时,最先想到的答案。 + +**崩塌点**,一句话说透: + +> **你要的是 top-10,你召回了 top-100,过滤完剩 3 条——而且你事先不知道会剩几条。** + +展开两点: + +1. **权限越小的人,检索体验越差。** 一个只能看 20 份文件的实习生,在一个 10 万份文件的知识空间里检索,top-100 里可能一条都不属于他。而部门总监几乎全量可见,过滤基本不掉东西。**系统对权限最小的人最不友好,这恰恰和企业直觉相反**——企业以为受限的只是"看到的范围",实际受损的是"检索质量本身"。 +2. **安全达标了,但可用性塌了。** 用户不会看到"你没有权限",他看到的是"没有找到相关内容"。这是最糟糕的一种失败:**它是静默的,无法归因,用户会认为是 AI 不行。** + +**结论**:**召回率不能是运气。** 只做结果层过滤,等价于把召回率交给"用户权限占全库比例"这个你不控制的变量。 + +### 3.3 方案 C:把完整 ACL 拼成 expr 全量下推 + +**思路**:既然 Milvus 支持 `expr` 下推,那就在检索前向权限引擎要出该用户的全部可见文件 id,拼成 `document_id in [...]` 一次性下推。召回率问题解决了。 + +**崩塌点**: + +1. **字符串本身就是灾难。** 一个知识空间十万份文件,某个管理员可见九万九——`document_id in [1, 2, 3, ... ]` 这个表达式本身就是几百 KB 的字符串,要序列化、要传输、要在 QueryNode 上解析。 +2. **列举本身是 ListObjects 级别的开销。** 每次检索前都要问权限引擎"这个人到底能看哪些",在 ReBAC 模型下这是一次关系图展开,不是一次索引查询。 +3. **权限引擎一抖动,检索直接不可用。** 你把一个在线检索请求的可用性,绑死在了权限引擎的 P99 上。 + +**结论**:全量下推把召回率救回来了,代价是把性能和可用性一起搭进去。 + +### 3.4 权限感知检索的不可能三角 + +``` + 安全 + (零越权泄漏) + /\ + / \ + / \ + / \ + / 三选二 \ + / \ + / \ + /______________\ + 召回率 性能 + (该找到的都能找到) (延迟与资源可控) + + 方案 A → 牺牲性能与成本,换安全 + 召回率 + 方案 B → 牺牲召回率,换安全 + 性能 + 方案 C → 牺牲性能与可用性,换安全 + 召回率 +``` + +三条边不可能同时最优。但把三个方案摆在一起看,会发现它们的失败方式其实是同一个:**都在用一种固定的策略,应对所有形状的请求。** + +一个用户可见 5 份文件,和一个用户可见 9 万份文件,是两个完全不同的问题,不该由同一段代码用同一种方式处理。所以: + +> **正确答案不是"选一种",而是"按情况选"。** + +--- + +## 4. 双层过滤 + 四态策略矩阵 + +### 4.1 双层结构 + +BISHENG 知识空间 AI 问答的检索链路是这样的: + +```mermaid +flowchart TD + Q["用户提问
(携带登录身份)"] --> G{"空间级门禁
knowledge_space / visible"} + G -- 拒绝 --> X["直接返回空"] + G -- 通过 --> P["索引层预过滤
build_index_prefilter
→ empty / in / notin / none"] + P -- "strategy = empty" --> X + P -- "expr + es_filter" --> R["有界扩量重试循环
k = 100 × multiplier"] + + subgraph RC["双路并发召回"] + M["Milvus 稠密召回
HNSW + expr 下推"] + E["ES 稀疏召回
BM25 + filter 下推"] + end + + R --> M + R --> E + M --> F["RRF 融合
KnowledgeRetrieverTool._rrf_rerank"] + E --> F + F --> POST["结果层裁决
post_filter_visible_files
逐 chunk 复核 visible"] + POST -- "非空" --> OUT["交给 LLM 生成答案 + 引用"] + POST -- "空且还有重试额度" --> R + POST -- "空且额度耗尽" --> X +``` + +整个设计哲学可以压缩成一句话: + +> **索引层是性能优化,结果层是安全裁决。索引层可以偷懒,结果层永远不能。** + +这句话正是破解不可能三角的方式:**把"安全"这条边交给结果层守死,让它成为一个不参与权衡的常量;剩下"召回率"和"性能"两条边,就可以在索引层根据当次请求的实际形状自由权衡。** 三角形还在,但你只需要在二维平面上做选择题了。 + +要强调的是,结果层不是"双保险"式的冗余:索引层四种策略里有三种并不完备——`none` 完全不过滤,`in` / `notin` 的列表也只是请求开始时算出的快照,而权限可能在这几百毫秒里刚好被改掉。**结果层是唯一一个在"数据已取回、马上要交给模型"这个时刻做判断的地方,因此它必须无条件执行。** + +### 4.2 四态策略矩阵 + +索引层的决策全部收在 `KnowledgeFileVisibilityService.build_index_prefilter`(`src/backend/bisheng/knowledge/domain/services/knowledge_file_visibility_service.py:120`)。它返回一个 `IndexFilter`,`strategy` 字段只有四种取值。阈值来自 `KnowledgeQAFilterConf.index_filter_threshold`(`src/backend/bisheng/core/config/settings.py:335`,默认 **5000**)。 + +设 `k` = 该用户在本空间的可见主版本文件数,`n` = 本空间主版本文件总数,`T` = 阈值: + +| strategy | 触发条件 | 下推到 Milvus 的 expr | 下推到 ES 的 filter | 设计意图 | +|---|---|---|---|---| +| **`empty`** | `k == 0` | —(调用方直接跳过检索) | — | 可见集为空,任何检索都是纯浪费。**提前熔断。** | +| **`in`** | `k <= T` | `document_id in [id1, id2, ...]` | `[{"terms": {"metadata.document_id": [...]}}]` | 可见集小,白名单下推最精确,召回全部有效。 | +| **`notin`** | `k > T` 且补集非空且 `n - k <= T` | `document_id not in [excluded...]` | `[{"bool": {"must_not": {"terms": {"metadata.document_id": [...]}}}}]` | 几乎全可见时,**列黑名单比列白名单短得多**。 | +| **`none`** | `k > T` 且 `n - k > T`(且无业务范围) | 不下推任何权限条件 | 不下推 | 两边都太大,拼 expr 的代价超过收益。**索引层放弃,全部交给结果层。** | + +`in` / `notin` 的选择本质上是一个**表达式长度最小化**问题:可见集和不可见集,哪边短就列哪边。这是一个很朴素的优化,但它把方案 C"十万个 id 拼成字符串"的最坏情况直接砍掉了——最坏情况被 `none` 兜住,而 `none` 之所以敢存在,是因为结果层一定会复核。 + +还有一个细节:`IndexFilter.is_empty` 是一个显式的属性(`:48-50`),调用方必须自己判断并跳过检索。**把"什么都不该返回"表达成一个策略状态,而不是一个空的 expr,避免了"空列表被当成无过滤"这类经典空值陷阱。** + +### 4.3 权限范围 vs 业务范围:"有没有兜底"决定了"能不能偷懒" + +这是整个设计里最不教科书、但最能体现真实工程判断的一处。 + +用户在知识空间问答时,可以额外勾选文件夹或标签,把检索限定在某个子集内。这个子集(代码里叫 `candidate_file_ids`)和权限可见集,在索引层看起来是同一类东西——都是一个文件 id 集合,都要下推成 `document_id in [...]`。 + +**但它们的处理规则完全不同。** 代码里那段注释把理由说得很清楚(`knowledge_file_visibility_service.py:129-133`): + +```python +# candidate_file_ids carries the explicit folder / tag business scope. +# Unlike the permission set, it has NO result-layer backstop +# (post_filter_visible_files only enforces ``visible``), so whenever it +# is provided it MUST be pushed down into the index — it can never be +# dropped via the both-sides-too-large 'none' shortcut. +``` + +结果层的 `post_filter_visible_files` 只复核 `visible` 这一个动作,它**不知道**用户勾了哪个文件夹。所以: + +- **权限范围**有结果层兜底 → 索引层可以走 `none` 偷懒,最坏结果只是多召回一些后面会被丢掉的 chunk,是性能问题。 +- **业务范围**没有结果层兜底 → 索引层一旦放弃,用户勾选的文件夹边界就彻底失效了,是正确性问题。 + +因此代码里有两处专门为业务范围开的口子(`:170-194`):`notin` 的触发条件额外接受 `has_business_scope and len(complement) <= k`(哪边短用哪边,而不是直接放弃);即使两边都超过阈值,只要有业务范围,也宁可拼一个很长的 `in` 列表: + +```python +# Large explicit scope with a large complement: enforce via IN. The +# folder / tag boundary is correctness, not an optimisation, so a +# big IN list is preferable to leaking outside it. +``` + +提炼成一句方法论: + +> **"有没有兜底"决定了"能不能偷懒"。** 一个优化能否被跳过,取决于它下游是否还有人为同一个约束负责——而不是取决于它看起来多贵。 + +### 4.4 expr 的组合:一次检索至少三重 AND + +真实系统里一次检索的 `expr`,最少由三部分 AND 组成: + +| 过滤维度 | 来源 | Milvus 形态 | +|---|---|---| +| 权限过滤 | `build_index_prefilter` | `document_id in [...]` / `not in [...]` / 无 | +| 业务范围过滤 | 用户勾选的文件夹 / 标签,交进 `candidate_file_ids` | 与权限集求交后一并体现在上一行 | +| 版本过滤 | `build_primary_only_filter()` | `document_id not in [非主版本 ids]` | + +版本过滤这一条很容易被忽略:知识空间支持文件多版本,检索时**必须只命中主版本**,否则同一份制度的三个历史版本会同时进入上下文,让模型自己去纠结哪个是现行有效的。 + +它的实现在 `src/backend/bisheng/knowledge/rag/version_filter.py`——**一个不碰数据库的纯函数**: + +```python +# src/backend/bisheng/knowledge/rag/version_filter.py:11 +def build_primary_only_filter( + excluded_file_ids: Iterable[int], + *, + base_milvus_expr: Optional[str] = None, + base_es_filter: Optional[List[dict]] = None, +) -> Tuple[Optional[str], List[dict]]: +``` + +三个设计约束一眼可见:接受一个 `base_*` 让它可以**叠加**在已有过滤之上;ES 侧 `es_out = list(base_es_filter)` **绝不原地修改调用方的列表**;id 排序后再拼字面量,让输出**确定性可测**。 + +> **过滤器必须可组合、不互相污染。** 一个过滤器如果会吞掉或改写另一个过滤器的条件,那么"多加一道防线"就会变成"少一道防线"。 + +--- + +## 5. 召回率怎么保住:有界扩量重试,以及一个 HNSW 的坑 + +### 5.1 有界扩量重试 + +索引层策略选得再好,`none` 分支下结果层仍然会丢掉一部分 chunk。要让最终剩下的量足够,唯一的办法是首次就多召回一些。 + +`_retrieve_and_filter`(`src/backend/bisheng/knowledge/domain/services/knowledge_space_chat_service.py:484`)的循环是这样的: + +```python +# src/backend/bisheng/knowledge/domain/services/knowledge_space_chat_service.py:509 +multipliers = ( + conf.retrieval_initial_multiplier, # 默认 3 + conf.retrieval_expansion_multiplier, # 默认 6 +) +... +for attempt_idx, multiplier in enumerate(multipliers, start=1): + base_k = 100 # current retrieval default; multiplier scales it + milvus_kwargs = {"k": base_k * multiplier, "param": {"ef": 110}} + ... + if survivors: + break # AD-03: stop on first non-empty attempt +``` + +三个数值全部有明确取舍: + +| 参数 | 默认值 | 取舍 | +|---|---|---| +| `retrieval_initial_multiplier` | `3` | 首次就召回 `100 × 3 = 300` 条,为结果层留出丢弃余量。 | +| `retrieval_expansion_multiplier` | `6` | 首次不够时**只重试一次**,`100 × 6 = 600` 条,然后无论结果如何都停。 | +| 首次非空即停 | — | 只要第一轮有幸存者就直接返回,不为"更多"付第二次代价。 | + +配置层还有一条 `model_validator` 硬约束(`settings.py:372`):`retrieval_expansion_multiplier` 必须 `>=` `retrieval_initial_multiplier`,否则启动即失败——**扩量重试不允许配成缩量**。 + +为什么重试次数必须有硬上限?因为**扩量重试是一个会被权限最小的用户触发得最频繁的路径**。一个可见集极小的用户,每一次提问都会走满两轮、每轮扫描数百个候选;如果允许无限扩量直到凑够 top-k,他的每一次提问都会退化成一次全库扫描。 + +> **宁可返回空,也不能让单个请求拖垮集群。** 有界重试的本质,是拒绝把"某个用户运气不好"升级成"所有用户都变慢"。 + +### 5.2 一个 HNSW 的坑:`ef` 必须覆盖 `k` + +上面那段代码里藏着一个必然会炸的组合: + +- `k` 随 `multiplier` 动态放大:`100 × 3 = 300`,`100 × 6 = 600`; +- `ef` 在调用处**是写死的 110**。 + +而 HNSW 的搜索约束是 `ef >= k`——候选队列不可能比要返回的结果还小。于是扩量重试一开,Milvus QueryNode 就会直接报错:`ef(110) should be larger than k(300)`。 + +修复的位置很讲究。它不在调用方(那意味着每个新调用方都要记住这条不等式),而在**最靠近引擎的那一层**——`src/backend/bisheng/core/vectorstore/milvus.py:104` 的 `_ensure_ef_covers_k()`: + +```python +# src/backend/bisheng/core/vectorstore/milvus.py:104 +@staticmethod +def _ensure_ef_covers_k(param: dict | None, k: int) -> dict | None: + if not isinstance(param, dict): + return param + nested = param.get("params") + if isinstance(nested, dict) and isinstance(nested.get("ef"), int): + if nested["ef"] < k: + return {**param, "params": {**nested, "ef": k}} + elif isinstance(param.get("ef"), int) and param["ef"] < k: + return {**param, "ef": k} + return param +``` + +它被挂在 `_collection_search` / `_acollection_search` 两个出口上(`:139`、`:157`),同步异步都覆盖。三个细节:兼容 `ef` 在顶层和嵌套在 `params` 里两种写法;**返回修正后的副本,绝不改调用方传进来的 dict**;不做任何日志噪音,因为这不是异常,是常态。 + +> **凡是"两个参数必须满足某个不等式"的地方,都应该在最靠近引擎的那一层强制成立,而不是靠上层自觉。** 靠自觉的约束,会在第 N 个调用方那里失效,而 N 通常等于 2。 + +--- + +## 6. 双路召回的一致性陷阱 + +BISHENG 的知识库是**双写**的:同一批 chunk,稠密向量写进 Milvus(负责语义相似),稀疏索引写进 Elasticsearch(负责关键词精确命中)。检索时两路并发召回,再用 RRF 融合(`src/backend/bisheng/tool/domain/langchain/knowledge.py:73`,`RRFRerank` 常数 `c=60`,默认等权)。 + +双路召回对效果是显著的加成——纯向量检索对专有名词、编号、缩写不敏感,BM25 正好补上。但它对权限设计引入了一个非常容易踩的陷阱: + +> **权限过滤如果只加在 Milvus 一侧,ES 那一路会把不该出现的 chunk 原样拉回来,RRF 融合之后照样泄漏。** + +而且这种泄漏在测试里极难发现:只要目标 chunk 语义相似度够高,Milvus 一路就能命中,你根本看不出 ES 那一路漏了什么。它只在"关键词强、语义弱"的查询上暴露——**恰好是用户查具体制度编号、具体人名时的形态。** + +所以 `IndexFilter` 从定义上就是**成对**的:`milvus_expr` 和 `es_filter` 两个字段一起产生、一起消费,任何一个策略分支都必须同时给出两者。两侧的语义等价关系是: + +| 语义 | Milvus expr | ES filter 子句 | +|---|---|---| +| 白名单 | `document_id in [1, 2, 3]` | `{"terms": {"metadata.document_id": [1, 2, 3]}}` | +| 黑名单 | `document_id not in [7, 8]` | `{"bool": {"must_not": {"terms": {"metadata.document_id": [7, 8]}}}}` | +| 排除非主版本 | `document_id not in [<非主版本 ids>]` | `{"bool": {"must_not": {"terms": {"metadata.document_id": [...]}}}}` | +| 不过滤 | 不设置 `expr` | 不设置 `filter` | + +注意字段名的不对称:Milvus 侧是标量字段 `document_id`(由 `MilvusFactory` 从元数据 schema 动态建出),ES 侧是 `metadata.document_id`。**两侧写法不同、语义必须相同——这正是这类 bug 的温床,也正是把两者绑在同一个 dataclass 里返回的理由。** + +> **多路召回架构下,权限过滤的最弱一路决定整个系统的安全水位。加一条召回链路,就是加一个必须同步维护的过滤面。** + +融合之后还有两步收尾,都在 `_rrf_rerank` 里(`knowledge.py:83-99`):按 `max_content` 累加 `page_content` 长度做截断;以及当结果**恰好全部来自同一个文件**时,按 `(document_name, chunk_index)` 重排。**单文档场景下,叙述顺序比相关性排序更有助于模型理解上下文。** + +--- + +## 7. 结语 + +回到第 1 章那道必答题。 + +销售和法务问同一个问题,AI 会不会给出同样的答案?答案应该是:**不会,而且这个"不会"不是靠提示词约束、不是靠模型自觉、不是靠事后审计发现,而是靠检索层根本就没把那些 chunk 取出来。** + +也正因为索引层和结果层调用的是同一个权限门面、同一个 `visible` 动作,而知识空间的文件列表页走的也是这同一个门面,三件事被同一个原语锁死:**列表页可见性 ≡ 索引层预过滤 ≡ 结果层裁决**。推论就是那条对企业客户最有说服力的硬约束—— + +> **"我在列表里看不到的文件,绝不会在问答里被引用。"** + +(这句话的适用范围要说清楚:它在**知识空间 AI 问答**这条规范路径上成立;平台内其它检索入口走各自的链路,过滤强度并不完全相同。把一条局部不变式泛化成全平台承诺,是安全评审里最容易翻车的一种表述。) + +**企业级 RAG 和 Demo 级 RAG 的差距,不在模型,不在切分策略,不在 Rerank 用了哪一家,而在"这份数据到底该给谁看"这件事上能不能做到零妥协。** Demo 只需要让一个人满意;企业级系统需要同时让一千个权限各不相同的人满意,并且在任何一个人的权限第二天变化时,仍然满意。 + +方法论只有一句: + +> **把安全交给一个绝不妥协的裁决层,把性能交给一个可以按情况偷懒的优化层。** + +安全不参与权衡,所以它必须简单、唯一、无分支——一个原语,所有人调用它。性能全部参与权衡,所以它可以分四种策略、可以在两边都太大时干脆放弃——因为放弃了也不会出事。**架构的功夫不在于让每一层都完美,而在于想清楚哪一层必须完美、哪一层可以将就,然后让"将就"的那层永远兜在"完美"的那层后面。** + +而这一切之所以能落成一个工程问题、而不是一个研究课题,前提是底层能力已经就位:Milvus 的 `expr` 下推让权限条件有机会参与图遍历;混合检索让"过滤面成对维护"成为可管理的问题;动态 schema 让 `document_id` 这类业务标量能被当作一等公民建索引和过滤。**没有这些,权限就只能编织在检索之外,也就只能退化成第 3 章的方案 B。** + +BISHENG 完全开源:**github.com/dataelement/bisheng**。本文提到的所有文件路径、类名、方法名、默认值均可在仓库中查证。如果你的团队在权限过滤上采用了不同的方案——尤其是在 `none` 这类"索引层放弃"的边界上做了不同的取舍——欢迎来 Issue 里聊聊,我们很想知道别人是怎么画那条线的。 + +--- + + diff --git a/docs/constitution.md b/docs/constitution.md index b2bc530474..4f7bf73b41 100644 --- a/docs/constitution.md +++ b/docs/constitution.md @@ -6,13 +6,14 @@ > - `scripts/arch-guard.sh` is the **machine-enforcement arm** of this document: each RULE maps to a clause below (see the anchor table). > - Violations are reported as **BLOCKER** during `/sdd-review design`. > - **Change governance**: editing this file requires PR review (a law change affects every feature). If a RULE is involved, sync the "→ Cx" note in `arch-guard.sh`. -> - Last revised: 2026-08-14 (C8: no shared state on the local filesystem). +> - Last revised: 2026-09-16 (C5: registry re-derived to 41 modules — **263 mcp_face (F052)** added, the MCP server face plus the unified retrieval facade; previous revision 2026-09-15 took it to 40 with 261 public_endpoints and 270 commercial_license, 26044 F066 data scope in the personal-token sub-band and the 26050 / 26051 `delegate` ⊗ local-dev-toolkit codes). ## Anchor Table (clause ↔ arch-guard RULE) | Clause | Law | arch-guard RULE | Severity | |---|---|---|---| | **C1** | DDD layered call chain | RULE-1 / 2 / 3 / 4 / 5 | VIOLATION (RULE-3 is WARNING during migration) | +| **C1** | Backend holds no orchestration privilege (F054 K1): no docker / kubernetes SDK import, no `/var/run/docker.sock`, no `DOCKER_HOST` anywhere under `src/backend/bisheng/**`. Container lifecycle is driven by desired-state intents sent to the standalone `src/runtime-manager/` process, which is the sole holder of that access surface. | RULE-10 | VIOLATION | | **C2** | Dual-DB compatibility (MySQL + DM8) | — (review + CI) | — | | **C3** | Multi-tenancy auto-injection | — (review) | — | | **C4** | Permission unified entry point | RULE-8 / 9 | VIOLATION | @@ -33,6 +34,7 @@ Call chain — **never skip layers**: `Router → Endpoint → Service → Repos - `database/models/` must not import `domain/` (RULE-2). - `domain/models/` must not import `domain/services/` (RULE-4). - The API layer must not cross-import between modules (RULE-5). +- `src/backend/bisheng/**` must not import a container/orchestration SDK (`docker`, `aiodocker`, `kubernetes`, `kubernetes_asyncio`), reference `/var/run/docker.sock`, or read `DOCKER_HOST` (RULE-10). The backend describes *desired state* over HTTP to the standalone `src/runtime-manager/` package; only that process holds the orchestration access surface. Rationale: a backend that can talk to dockerd is root-equivalent on the host, which defeats the isolation the whole hosted-app design rests on. ## C2. Dual-DB Compatibility (MySQL + DM8) ⚠️ @@ -109,10 +111,10 @@ await require_business_action( - 5-digit `MMMEE` (3-digit module + 2-digit error), defined in `common/errcode/`. -**Module registry** (35 in use as of 2026-09-14). The authoritative source is always the `Code: int = NNNNN` literals themselves; this table mirrors them and *will* drift. **Before claiming a new module number, re-derive the list:** +**Module registry** (41 in use as of 2026-10-10). The authoritative source is always the `Code: int = NNNNN` / `Code = NNNNN` literals themselves; this table mirrors them and *will* drift. **Before claiming a new module number, re-derive the list:** ```bash -grep -rhoE "Code:\s*int\s*=\s*[0-9]{5}" src/backend/bisheng/common/errcode/*.py \ +grep -rhoE "Code(:\s*int)?\s*=\s*[0-9]{5}" src/backend/bisheng/common/errcode/*.py \ | grep -oE "[0-9]{5}" | cut -c1-3 | sort -un ``` @@ -120,20 +122,30 @@ grep -rhoE "Code:\s*int\s*=\s*[0-9]{5}" src/backend/bisheng/common/errcode/*.py |---|---| | 10x | 100 server · 101 finetune · 102 model_deploy · 103 component · 104 assistant · 105 flow · 106 user · 107 tag · 108 llm · 109 knowledge | | 11x | 110 linsight · 111 linsight (second block) | -| 12x–18x | 120 workstation · 140 message · 150 tool · 160 dataset · 170 telemetry · 180 knowledge_space · 181 approval | -| 19x (tenant / permission) | 190 channel **and** permission ⚠️ · 191 tenant_resolver · 192 tenant_fga · 193 sso_sync · 194 tenant_quota · 195 tenant_sharing · 196 resource_owner_transfer · 197 admin_scope · 198 llm_tenant | +| 12x–18x | 120 workstation · 130 chat · 140 message · 150 tool · 160 dataset · **161 app_factory (F054) · 162 app_factory (F055)** · 170 telemetry · 180 knowledge_space · 181 approval | +| 19x (tenant / permission) | 190 channel + permission denied (19000) · 191 tenant_resolver · 192 tenant_fga · 193 sso_sync · 194 tenant_quota · 195 tenant_sharing · 196 resource_owner_transfer · 197 admin_scope · 198 llm_tenant | | 20x–25x (org) | 200 tenant · 210 department · 220 org_sync **and** tenant_tree ⚠️ · 230 user_group · 240 role · 250 permission | -| 26x–27x | 260 open_api · 270 commercial_license | +| 26x–27x (open / public API · license) | 260 open_api · 261 dsh · 262 dsh_market · **263 mcp_face (F052)** · 264 public_endpoints · **265 model_face (F051)** · 270 commercial_license | | 261 | DSH Desktop access (F062, registered 2026-09-09); 260 remains reserved for F053 | | 262 | Enterprise plugin marketplace | | 264 | Anonymous public v3 channel / guest share links (`public_endpoints`, moved from 261 on 2026-10-08 after colliding with DSH) | - -- ⚠️ **190 and 220 are each shared by two modules** — pre-existing collisions, not a precedent. Never reuse an occupied number. -- **130 was registered as `chat` but is not used by any error code.** Do not treat it as free without checking; do not cite it as an example. -- **260 is assigned** to Open API authentication, identity and the task-mode Open API (`/api/v2`; 26060–26068 are task mode, F073). Do not reuse it. +| 265 | Model protocol face (`model_face`, F051; moved from 262 on 2026-10-08 after colliding with the plugin marketplace) | +| 28x | 280 sandbox | + +- ⚠️ **220 is shared by two modules** — a pre-existing collision, not a precedent. Permission errors formerly at 19001–19007 moved to 25020–25026; 19000 remains permission denied and 19001–19007 belong only to channel errors. Never reuse an occupied number. +- **130 = chat** (`common/errcode/chat.py`, 13004–13010). Earlier revisions of this table called it "registered but unused" because its codes are declared `Code = NNNNN` without the `: int` annotation and the old derive command skipped that style. It is occupied — do not treat it as free; do not cite it as an example. +- **260 is assigned** to Open API authentication and identity (`/api/v2`; F053, `common/errcode/open_api.py`). Do not reuse it. Sub-bands as of 2026-09-15: 26001–26019 open face (`/api/v2` credential / scope / delegation / identity headers) · 26020–26031 service-account management face (`/api/v1/service-accounts/**`) · 26040–26044 personal tokens (`/api/v1/personal-tokens/**`; 26044 = F066 tenant data-scope narrowing) · 26050 onward the `delegate` ⊗ local development toolkit scope policy, one code per gate (26050 `OpenApiDelegateExclusiveScopeError`: the combination refused at issue / edit time, service-account keys and personal tokens alike, HTTP 400 · 26051 `OpenApiDelegateLocalDevRefusedError`: a key that already carries it refused at the `/api/v2` channel entrance, HTTP 403 — INV-31 运行期兜底) · 26060–26068 the task-mode Open API (F073: sync transport, conversation id, unavailable skills, delegated user without task mode, stopping a finished task, content safety, unavailable model / tool, queue unavailable) · 26070 onward service-account management, continued (26020–26031 is full; 26028 stays retired): 26070 `ServiceAccountNameTakenError`, the name is held by a live or deleted account of the tenant, HTTP 409; 26032–26039 and 26045–26049 stay reserved. Holes inside those ranges are not free by default (26013 / 26014 were retired and are never reused) — read the file before claiming a number. Every 260xx carries a real `http_status`, which `open_api/api/exception_handlers.py` returns on `/api/v2` paths (200 elsewhere); copy for each code must land in `packages/locales/src/api_errors/*.json` (all three languages) in the same change. Codes in this file are declared `Code = NNNNN` without the `: int` annotation — the derive command above matches both styles for that reason. +- **181 = approval** (F025 审批中心, `common/errcode/approval.py`): 18100–18120 in use (18120: an existing approved execution failed; explicit recovery is required before reapplying). Note that 181 is the band for the approval **engine**, which every scenario shares — `withdraw` / `decide` guards live here (e.g. **18118** `ApprovalInstanceNotPendingError`, F055 T051), *not* in a scenario owner's band such as 162. A code added here tightens behaviour for menu access, channel subscription, knowledge-space join and app publish at once, so it needs regression coverage in every live scenario, and its copy must land in `packages/locales/src/api_errors/*.json` (all three languages) in the same change. +- **161–164 = app_factory** (v3.0.0 应用工场). One band, four owners — split so each feature can claim codes without touching another's file: **161 = F054** (hosted-app domain + runtime, `common/errcode/app_factory.py`) · **162 = F055** (publish pipeline, `common/errcode/app_publish.py`) · **163 = F056** (app square / governance) · **164 = F059** (k8s runtime backend). 161 sub-ranges: `16100-16119` domain/state machine · `16120-16139` runtime/orchestration · `16140-16159` entry & identity injection · `16160-16179` data plane/logs · `16180-16199` deployment switch/ops. The same assignment is mirrored in `features/v3.0.0/release-contract.md` ("已分配模块编码"), which is where F055 / F056 / F059 look it up — update both together. +- **263 is assigned** to the MCP tool face and the unified retrieval facade (F052, `common/errcode/mcp_face.py`). Sub-bands: **26300–26319** MCP tool face — since 2026-10-10 the only MCP server is F067's `open_mcp/`; F052's own server was folded into it, 26305 / 26306 are raised by `open_mcp/tools/`, and 26301–26304 (F052's transport gate) are retired and never reused · **26320–26339** the retrieval facade, shared by its callers (the MCP retrieve tool, `POST /api/v2/filelib/retrieve`, the hosted-app runtime, the SDK's `retrieve`) · **26340+** reserved. It deliberately does **not** extend 260: that band's 26032–26039 / 26045–26049 holes are pinned by `test/open_api/test_error_codes.py`. Every 263xx carries a real `http_status` returned via `open_api/domain/http_status.py` on `/api/v2` paths (200 elsewhere), and every subclass spells its code `Code: int = 263xx` — the un-annotated form used in `open_api.py` is invisible to `pnpm check-i18n`'s backend-code regex, so a missing translation would pass CI. +- **264 is assigned** to the anonymous public channel (`/api/v3`, `common/errcode/public_endpoints.py`, 26401–26404; was 261 until 2026-10-08). Its codes carry a real transport status for the v3 handler. Do not reuse it. +- **265 is assigned** (was 262 until 2026-10-08) to the OpenAI-compatible model protocol face (`/api/v2/model/v1`; F051, `common/errcode/model_face.py`). Do not reuse it. Sub-bands: `26500–26509` protocol face / endpoint / request shape (26501 endpoint not offered · 26502 Anthropic path · 26503 malformed request · 26504 access token refused · 26505 delegation-style identity header refused) · `26510–26529` callable range and name resolution (26511 not found · 26512 offline · 26513 revoked · 26514 ambiguous bare name · 26515 capability undeclared · 26516 catalog undecidable, fail-closed 503 · 26517 provider daily limit, 429) · `26530–26549` upstream and streaming (26531 upstream failure · 26532 upstream rejection, transport status taken from the provider · 26533 upstream rate limit · 26534 stream interrupted, only inside an SSE event) · `26550–26559` records and ledger, reserved. `ModelFaceError` subclasses `OpenApiAuthError` purely to reuse the v2 handler's `http_status` branch — they are not authentication errors. Each code also carries `openai_type` / `openai_code`, the OpenAI-shaped rendering the face returns under its own path prefix; copy for each code must land in `packages/locales/src/api_errors/*.json` (all three languages) in the same change, and codes here are declared `Code: int = NNNNN` so `check-i18n` actually sees them. - **270 is assigned** to commercial license status aggregation and reporting (`commercial_license`). Do not reuse it. Do not treat Gateway business code 11001 as a BISHENG module number. +- **280 is assigned** to isolation-environment (sandbox) execution (`sandbox`). Do not reuse it. - When you claim a number, add it here in the same change. +- **F113 allocations (2026-10-28)**: 26071 owner/account tenant mismatch (403), 26072 owner current tenant unresolved (403), 26073 service-account management scope changed (409), in `common/errcode/open_api.py`. These continue the occupied 26070 management band; they do not allocate a new module. + ## C6. No Hardcoded Secrets (RULE-7) No `password` / `secret_key` / `api_key` / `access_token` literals in code. Use config + Fernet encryption (passwords in `config.yaml` are Fernet-encrypted; never write plaintext). @@ -141,7 +153,7 @@ No `password` / `secret_key` / `api_key` / `access_token` literals in code. Use ## C7. Frontend Store Must Not Call HTTP Directly (RULE-6) A frontend store must not call HTTP directly — go through `controllers/API/` (platform) or `api/` (client). -All other frontend conventions (state library, UI library, path aliases, i18n, Toast, etc.) live in `.claude/rules/platform-frontend.md` and `.claude/rules/client-frontend.md` (see also `AGENTS.md §4`). +All other frontend conventions (state library, UI library, path aliases, i18n, Toast, etc.) live in `.claude/rules/platform-frontend.md` and `.claude/rules/client-frontend.md` (see also `src/frontend/AGENTS.md`). ## C8. No Shared State on the Local Filesystem ⚠️ diff --git "a/docs/customer-guides/\345\244\232\347\247\237\346\210\267\344\272\247\345\223\201\344\275\277\347\224\250\350\257\264\346\230\216.md" "b/docs/customer-guides/\345\244\232\347\247\237\346\210\267\344\272\247\345\223\201\344\275\277\347\224\250\350\257\264\346\230\216.md" new file mode 100644 index 0000000000..fa839e9eb2 --- /dev/null +++ "b/docs/customer-guides/\345\244\232\347\247\237\346\210\267\344\272\247\345\223\201\344\275\277\347\224\250\350\257\264\346\230\216.md" @@ -0,0 +1,366 @@ +# BiSheng 多租户产品使用说明 + +> 适用版本:v2.5.1 及以上(本文依据 v3.0.0-beta1 核实) +> 读者:实施工程师、系统管理员、售前顾问 + +--- + +## 1. 适用场景与基本概念 + +多租户用于**一套 BiSheng 服务同时承载多个相对独立的组织单元**的场景,典型如集团总部与下属分子公司:各分支机构的知识库、应用、会话数据彼此不可见,配额独立核算,但共用同一套部署,由总部统一运维。 + +### 1.1 两层组织结构 + +平台的租户结构固定为**两层**: + +``` +总部租户(Root) +├── 分支机构 A(子租户) +├── 分支机构 B(子租户) +└── 分支机构 C(子租户) +``` + +- **总部租户**:系统初始化时自动创建,全局唯一,不可删除、不可停用。开启多租户前的所有存量数据都归属于它。 +- **子租户**:由管理员从部门树中"标记"产生,数量不限。 + +> **不支持三层及以上结构。** 已成为子租户的部门,其下级部门不能再次标记为子租户。如果客户的组织是"集团—分公司—子公司"三级,只有其中一级能作为租户边界,其余层级用部门树表达(部门树本身层级不限,但部门不构成数据隔离边界)。 + +### 1.2 租户与组织架构的关系 + +**这是理解多租户的关键:租户不是独立于组织架构的概念,而是部门树上的一个标记。** + +创建子租户的动作,本质是把部门树中的某个部门节点标记为"子租户根"。此后该部门及其所有下级部门构成这个子租户的范围: + +- 用户属于哪个租户,由他的**主部门**决定——主部门落在哪个子租户的范围内,他就属于哪个租户;不在任何子租户范围内的用户,属于总部租户。 +- 因此**没有"添加租户成员"这一操作**,成员管理统一在组织架构中完成。调整用户的主部门,其租户归属自动跟随变更。 + +### 1.3 一人一租户 + +每个用户在同一时间只属于一个租户,登录后即进入该租户,**使用过程中不需要也无法切换租户**。这一设计使普通用户对多租户完全无感知——他看到的就是自己机构的数据。 + +--- + +## 2. 开启多租户 + +多租户是**部署级开关**,需要在服务端配置并重启,不能在界面上开关,也不能对不同用户分别启用。 + +### 2.1 操作步骤 + +**第一步:修改配置** + +编辑后端 `config.yaml`: + +```yaml +multi_tenant: + enabled: true + default_tenant_code: "default" +``` + +**第二步:执行数据库迁移** + +```bash +cd src/backend +uv run alembic upgrade head +``` + +**第三步:重启后端服务** + +包括 API 服务与所有 Celery Worker。 + +### 2.2 开启后的状态 + +- 所有存量数据(知识库、应用、会话、模型配置等)**全部归属总部租户**,无需迁移,用户无感知。 +- 管理员菜单中出现「租户管理」入口(仅超级管理员可见)。 +- 左侧菜单「系统」→ 组织与成员中,部门设置页出现「标记为子租户」按钮。 + +> **务必先开启多租户再创建子租户。** 关闭状态下即使通过接口创建了子租户,租户隔离逻辑也不会生效,数据不会被隔离。 + +### 2.3 关闭多租户 + +将 `enabled` 改回 `false` 并重启即可回到单租户模式,所有功能按单一组织运行。但**已经拆分到子租户的数据不会自动合并回总部**,关闭前请确认没有子租户在使用,否则这部分数据将无法从界面访问。 + +--- + +## 3. 创建分支机构(子租户) + +### 3.1 前置准备 + +创建子租户前必须先在组织架构中建好对应部门,并把该机构的人员加入这个部门(包括未来要担任管理员的人)。**子租户的初始管理员只能从该部门及其下级部门的成员中选择**,选不到人说明部门里还没有人。 + +### 3.2 操作步骤 + +1. 以超级管理员登录,点击左侧菜单 **「系统」** +2. 切换到 **组织与成员** 标签页 +3. 在左侧部门树中选中目标部门(不能是根部门) +4. 在右侧切换到 **设置** 标签页,滚动到底部 +5. 点击 **「标记为子租户」** +6. 在弹窗中填写: + - **租户名称**(必填):默认预填部门名称,可修改,最长 128 字符 + - **初始管理员**(必填,可多选):只能搜索到该部门子树内的成员 +7. 点击 **「确认挂载」** + +创建成功后,该部门在部门树中会显示蓝色的「子租户」标签,部门图标变为高亮色。 + +### 3.3 注意事项 + +- **该操作不可撤销**(只能通过"取消挂载"解除,解除过程见 §8.2),弹窗中会有橙色提示。 +- 租户编码由系统自动生成,无需填写,且**创建后不可修改**。 +- 若部分管理员授权失败,租户仍会创建成功,界面会提示哪些用户未授权成功,可在成员管理中补授。 +- 一个部门被标记为子租户后,它的**下级部门不能再被标记**(两层结构限制)。 + +--- + +## 4. 成员与管理员管理 + +### 4.1 成员归属 + +如 §1.2 所述,成员归属由主部门自动决定,**租户管理界面中没有"添加成员""移除成员"按钮**。要调整某人的租户归属,请到左侧菜单「系统」→ 组织与成员 中修改他的主部门。 + +### 4.2 调岗的影响 + +当用户的主部门变更到另一个租户范围内时: + +- 该用户的租户归属立即变更; +- **该用户会被强制下线,需要重新登录**(原有登录凭证立即失效); +- 重新登录后,他将看到新机构的数据,**原机构中由他创建的知识库、应用等资源不会跟随转移**,仍留在原租户。 + +> 如果调岗人员名下有重要资源,建议**先完成资源交接再调整部门**。平台可配置为在此情况下阻断部门变更并提示先行交接,需要时请联系实施人员开启。 + +### 4.3 设置租户管理员 + +1. 进入 **租户管理**(左侧菜单,仅超级管理员可见) +2. 在租户列表中,**点击该租户的「用户数」数字**,打开成员对话框 +3. 在成员列表中找到目标用户,点击 **「设为管理员」**;取消则点击 **「取消管理员」**(需二次确认) + +管理员用户名后会显示「管理员」标签。 + +> 总部租户不在此处设置管理员——总部的管理权限由超级管理员承担,该租户的成员对话框中不显示管理员操作按钮。 + +### 4.4 租户管理员的权限范围 + +子租户管理员可以在本机构范围内: + +- 管理本机构的用户组、角色与资源权限 +- 注册与管理本机构自有的模型服务 +- 配置本机构的系统默认模型、工作台设置 +- 查看本机构的配额使用情况 + +子租户管理员**不能**:创建或删除租户、标记或解除子租户、跨机构查看数据、修改总部共享给本机构的模型、修改总部的任何配置。 + +--- + +## 5. 配额管理 + +### 5.1 设置配额 + +1. 进入 **租户管理** +2. 在目标租户行点击 **「配额设置」** +3. 设置 **「知识库存储用量 (GB)」** +4. 保存 + +- 取值范围 0.1 ~ 99999,最多一位小数 +- **留空表示不限制** +- 标签右侧会显示该租户当前已用量 + +租户列表的「存储用量」列会以进度条形式展示"已用/配额",未设置配额的显示为 `-`。 + +### 5.2 总部配额是集团总量上限 + +总部租户的配额有特殊含义:**总部的用量统计包含自身用量与所有子租户用量的总和**。 + +这意味着如果总部配额设为 100GB,即使某个子租户自己的配额还有剩余,只要全集团累计已达 100GB,该子租户也会被阻止上传。适合需要控制整体采购容量的场景。 + +### 5.3 超出配额的表现 + +存储达到上限后,知识库文件上传会被拒绝。提示文案区分两种情况,便于快速定位是哪一层超限: + +- **本机构配额用尽** —— 提示当前租户配额已耗尽,需要为该机构扩容 +- **集团总量用尽** —— 提示"集团总量已耗尽",说明是总部配额封顶,此时给单个子租户加配额没有用,必须调整总部配额 + +删除文件释放空间后即可恢复上传,无需管理员干预。 + +--- + +## 6. 模型配置与共享 + +### 6.1 总部模型共享给分支机构 + +超级管理员在总部创建模型服务时,配置抽屉中有 **「共享给子租户」** 开关(默认开启): + +- **开启**:所有子租户都能使用这个模型服务 +- **关闭**:仅总部可用 + +该开关**仅在超级管理员配置总部的模型服务时出现**,子租户管理员看不到它。共享是面向全部子租户的统一开关,不能指定共享给其中某几个机构。 + +### 6.2 分支机构看到的共享模型 + +子租户成员在模型管理页看到总部共享的模型时: + +- 模型名称旁显示 **「XX 共享 · 只读」** 标签 +- 「模型配置」按钮与可用性开关**均为禁用状态** + +即分支机构可以**使用**这些模型,但不能修改、停用或删除。 + +### 6.3 分支机构自建模型 + +子租户管理员可以在本机构注册自己的模型服务,与总部共享的模型并存使用。这适用于分支机构有独立的模型采购、独立计费或数据合规要求的场景。 + +> 部署时可配置模型端点白名单,限制子租户管理员只能注册指定域名下的模型服务。需要时请联系实施人员。 + +### 6.4 系统默认模型的继承 + +知识库、助手、评测、工作流、工作台这五类场景的默认模型选择支持按租户配置: + +- 子租户**未配置**时,自动继承总部的设置,界面显示「继承自 Root」标签 +- 子租户**一旦自行配置**,即与总部脱钩,此后总部的调整不再影响该机构 + +--- + +## 7. 超级管理员的管理视角切换 + +超级管理员可以临时切换到某个分支机构的视角,代为查看和配置其模型相关设置。 + +### 7.1 使用方式 + +在 **模型管理** 页或 **工作台配置** 页,顶部有 **「管理视图:XXX」** 下拉按钮: + +1. 点击下拉,选择目标租户(列表含总部与所有启用中的子租户) +2. 选中后当前页面数据自动刷新为该租户的配置 +3. 切回总部选择列表第一项 + +切换视角**不会登出、不会跳转页面、不改变自己的租户归属**,只影响当前页面看到的配置数据。切换后按钮上会持续显示当前所处的视角。 + +### 7.2 作用范围 + +管理视角**仅在模型管理与工作台配置这两处生效**。知识库、应用、会话等业务数据不在此范围内——超级管理员无法通过视角切换查看分支机构的业务数据。 + +视角在一段时间无操作后自动失效,回到默认的总部视角。 + +--- + +## 8. 租户状态管理 + +租户列表中每行展示当前状态:**正常 / 已停用 / 已归档**。 + +### 8.1 停用与启用 + +在租户管理页点击 **「停用」**(需二次确认): + +- 该租户**全体成员立即被强制下线**,且无法再登录 +- 数据完整保留,不做任何删除或迁移 +- 后台任务与定时同步不再为该租户执行 + +点击 **「启用」** 即可恢复,成员重新登录后一切如常。 + +> 总部租户受系统保护,不显示停用按钮。 + +### 8.2 解除子租户(取消挂载) + +如果要把某个分支机构合并回总部: + +1. 进入左侧菜单 **「系统」→ 组织与成员** +2. 选中该子租户对应的部门 +3. 在右侧 **设置** 标签页底部点击 **「取消挂载」**,二次确认 + +执行后: + +- 该机构名下的**所有资源自动迁回总部**,不会丢失 +- 部门恢复为普通部门,其成员归属自动回到总部 +- 该租户转为 **已归档** 状态 + +> **已归档是终态,不可恢复。** 归档后的租户在列表中只保留记录供审计追溯,不能再被启用,也不能再编辑或设置配额。如果只是想临时冻结一个机构,请使用"停用"而不是"取消挂载"。 + +### 8.3 删除租户 + +只有**已停用或已归档**的租户才显示「删除」按钮,且需要: + +1. 确保该租户下已无成员(只要还有人的主部门在该租户范围内,删除就会被拒绝) +2. 在确认框中**手动输入该租户的租户编码**,才能激活删除按钮 + +删除是物理删除,且会**连带删除该租户名下的部门**,请谨慎操作。日常的机构下线建议使用「停用」或「取消挂载」,删除仅用于清理确认不再需要的空租户。 + +--- + +## 9. 数据隔离范围说明 + +### 9.1 隔离了什么 + +开启多租户后,以下数据均按租户隔离,跨租户不可见: + +| 类别 | 具体内容 | +|------|----------| +| 知识与文档 | 知识库、知识空间、文档文件、问答知识库 | +| 应用 | 工作流、助手、看板、工具 | +| 会话数据 | 对话记录、消息、会话历史 | +| 渠道 | 频道、信息源、文章 | +| 组织与权限 | 部门、用户组、角色、资源授权关系 | +| 模型 | 模型服务、模型、系统默认模型配置、调用与用量记录 | +| 运营数据 | 配额、审计日志、组织同步配置 | +| 其他 | 灵思会话与技能、评测与标注、微调任务 | + +用户账号本身是全局的(同一账号不会因为多租户而重复创建),但其可见的业务数据严格限定在所属租户内。 + +### 9.2 可见性方向 + +- **分支机构之间完全隔离**,互不可见,也不能互相共享资源。 +- **总部默认看不到分支机构的业务数据**。超级管理员如需查看,只能通过 §7 的管理视角,且范围仅限模型与工作台配置。 +- **分支机构可以看到总部共享的模型服务**(只读),这是唯一的跨租户共享内容。业务资源(知识库、应用等)不支持跨租户共享。 + +### 9.3 同一租户内部并非默认互通 + +**这一点客户容易误解,请在方案沟通中明确:** 同属一个租户的成员,默认**看不到彼此创建的资源**。租户隔离解决的是"机构之间不互通",机构内部的资源可见性由权限体系单独控制——资源创建者需要显式授权给指定的用户、部门或用户组,对方才能访问。 + +这是有意的安全设计,避免同机构内所有人的知识库互相可见。 + +### 9.4 授权范围限制 + +资源只能授权给**本租户内**的用户、部门或用户组。不能把总部的知识库直接授权给分支机构的某位员工——跨租户的访问只能通过 §6 的模型共享机制,业务资源没有对应能力。 + +### 9.5 隔离级别说明(合规相关) + +BiSheng 多租户采用**逻辑隔离**:所有租户共用同一套数据库与存储服务,通过数据层的租户标识实现隔离,平台的数据访问层统一强制附加租户过滤条件。知识库的向量索引与全文索引按知识库独立创建,租户之间无法互相检索。 + +**这不是物理隔离。** 平台不提供"每个租户使用独立数据库实例/独立存储实例"的部署形态。如果客户的合规要求明确规定数据必须物理分离(独立库、独立存储、独立服务器),则需要为该客户单独部署一套 BiSheng 实例,而不是使用多租户功能。这一点建议在方案阶段主动确认,避免交付后返工。 + +--- + +## 10. 常见问题 + +**Q:能不能做三层结构,比如集团—分公司—子公司?** +不能。租户结构固定两层。可以让分公司作为子租户,子公司用部门表达;但子公司之间的数据不构成隔离边界,同一分公司下的所有部门共享同一份数据可见范围(具体到某份资源仍受权限控制)。 + +**Q:一个用户能同时属于两个机构吗?** +不能。每人同一时间只属于一个租户,由主部门决定。可以给用户设置兼职部门,但兼职部门不改变租户归属。 + +**Q:用户能自己切换机构吗?** +不能,也不需要。登录后自动进入所属机构,界面上没有切换入口。 + +**Q:总部管理员能看到分支机构的知识库吗?** +不能。管理视角只覆盖模型与工作台配置,不包括业务数据。如果确有需要,只能由分支机构的成员主动把资源授权出来,或者由超级管理员在数据库层面处理(不推荐)。 + +**Q:分支机构 A 能把知识库共享给分支机构 B 吗?** +不能。跨租户共享目前只支持模型服务,且方向固定为总部→分支。 + +**Q:开启多租户后,原来的数据会不会丢?** +不会。存量数据全部归入总部租户,路径与访问方式不变,用户无感知。 + +**Q:分支机构的配额和总部是什么关系?** +分支机构受"自己的配额"和"总部配额"双重约束,任一超限都会被阻止。总部配额相当于全集团的总量上限。 + +**Q:删了子租户对应的部门会怎样?** +该租户会进入异常状态并通知超级管理员处理,其成员的租户归属会回落到上级最近的租户。请避免直接删除已标记为子租户的部门,应使用"取消挂载"。 + +**Q:界面上哪里能看到我当前在哪个租户?** +普通用户界面不展示租户信息(对用户而言就是"我的系统")。管理员可在租户管理页查看全部租户,以及在模型管理页的管理视图按钮上看到当前视角。 + +--- + +## 附:术语对照 + +| 文档用语 | 界面用语 | 说明 | +|----------|----------|------| +| 总部租户 | Root / 默认租户 | 系统自动创建的顶层租户 | +| 子租户 / 分支机构 | 子租户 | 由部门标记产生 | +| 标记为子租户 | 标记为子租户 / 挂载 | 创建子租户的操作 | +| 取消挂载 | 取消挂载 | 解除子租户,资源迁回总部 | +| 管理视角 | 管理视图 | 超级管理员临时切换的配置视角 | diff --git a/docs/merge-reports/main-into-feat-3.0.0-20261023.md b/docs/merge-reports/main-into-feat-3.0.0-20261023.md new file mode 100644 index 0000000000..2a6612969b --- /dev/null +++ b/docs/merge-reports/main-into-feat-3.0.0-20261023.md @@ -0,0 +1,100 @@ +# main 合入 feat/3.0.0:改动与影响范围 + +日期:2026-10-23。此报告基于本次实际合并差异、代码调用路径和本地验证,不把历史 feature 文档作为已交付证明。 + +## 合并基线与交付状态 + +| 项目 | 内容 | +|---|---| +| 目标分支原提交 | `feat/3.0.0` / `38395b19804effe7a8474809f54f5f7396e5b940` | +| 来源分支 | `origin/main` / `83aa354034c14c913800f821074bad8f0c29e1e0` | +| 来源版本 | `3.0.0-beta6`;目标原版本为 `3.0.0-beta4` | +| 来源新增历史 | 35 个目标分支尚未包含的提交(含合并提交) | +| 合并方式 | 保留双方历史的 merge commit | +| 远端复核 | 合并落地前再次查询,main 和目标远端均未变化 | +| 交付边界 | 本地合入 feat/3.0.0;本次未推送、未部署、未执行运维回填 | +| 工作区保护 | 原有脚本 README 修改及两个未跟踪文件保留,不纳入合并提交 | + +## 差异统计 + +相对合并前 `38395b198` 共 390 个文件(包含本报告)。 + +| 范围 | 文件数 | +|---|---:| +| 发布脚本及 compose | 2 | +| docs | 3 | +| features | 62 | +| src/backend | 240 | +| src/frontend/client | 4 | +| src/frontend/packages | 3 | +| src/frontend/platform | 76 | + +精确清单可执行 `git diff --name-status 38395b198 HEAD` 查看;原有未提交文件不在该范围内。 + +## 主要改动与业务影响 + +| 范围 | 实际改动 | 影响对象与关注点 | +|---|---|---| +| DSH 身份与准入 | Python 本地持有授权请求、会话和凭证;增加桌面本地认证及旧路径兼容;移除 DSH 对 Gateway 商业 License、人数和席位准入的运行时依赖 | 桌面登录、刷新、退出与模型访问。原 Gateway 会话需要重新登录;平台用户状态、租户及模型授权仍生效。不能据此认定整个平台商业版 Gateway 都已移除 | +| 部门及个人额度 | 采用个人额度优先、未配置时继承部门的路径;前端删除席位重新授权及商业 License/席位管理入口 | DSH 管理员授权、额度继承、用量及操作恢复;原用量、UNKNOWN 和审计证据继续保留 | +| 模型协议 | 增加企业模型协议、推理参数及原生 Responses 支持,复用 provider Token 参数兼容处理 | DSH Chat/Responses、流式输出、工具调用及模型 Token 计量;实际供应商和部署模型需另行验收 | +| 模型审计 | 增加设置、请求元数据/正文、会话与树结构、标题等模型和查询;增加后台写入、状态收敛与树准备任务;管理端提供审计模式、筛选、分组、原文与失败状态 | DSH 请求链、Celery/Beat、SQL、Redis、对象存储及审计管理员。正文模式涉及内容保存、密钥可用性、容量和读取权限,应按实际配置验收 | +| 保存工具调用 | 按名称解析现有保存工具,校验嵌套输入;引入受限 OpenAPI 执行及 JSON 解析边界,不再要求另行发布才暴露保存工具 | DSH 桌面调用及共享工具执行入口。重点回归权限、重名、schema、超时、响应体大小和错误状态 | +| 插件与工作台市场 | 增加工作台上传、目录和安装策略;插件支持 zip/tgz/tar.gz;移除签名租约相关流程 | 企业管理员导入及桌面安装链。新租户第三方插件/工作台安装默认关闭,已有租户选择不因默认值迁移被重置 | +| 前端公共请求 | 导出共享响应/错误处理和认证头,供审计流式请求复用 | 保留 feat/3.0.0 的 F088 ApiError 完整拒绝语义、全局登录/权限处理及共享 notification,避免 main 的旧错误吞噬行为覆盖发布线 | +| 非 DSH 公共后端 | 租户过滤器注册新增模型;公共 LLM 构造时剔除能力声明 reasoning_efforts;工具工厂可注入;HTTP 异常保留结构化 code;用户资料不再投递旧远端同步意图 | 公共请求、用户资料、租户隔离、模型初始化与工具执行均有间接影响,不能将本次范围理解为只改 DSH 页面 | +| 信息源查询 | 保留来源分支 return_information 参数修复,整合目标分支已有快照边界测试 | 最终信息源客户端相对合并前目标分支无净代码变更;测试同时覆盖 return_information 和 max_create_time,未退回旧同步快照行为 | +| 发布与依赖 | 前后端版本、compose 镜像和 update.sh 更新至 beta6;声明 jsonschema==4.25.1 | 后续使用默认 compose/update 脚本将选择 beta6 镜像。本次没有构建、拉取或部署镜像;保留目标分支 React Query 4.44.0 和现有依赖架构 | + +主要实现入口:`src/backend/bisheng/dsh/`、`src/backend/bisheng/dsh_market/`、`src/backend/bisheng/tool/domain/services/`、`src/frontend/platform/src/pages/SystemPage/dsh/`、`src/frontend/platform/src/pages/BuildPage/dsh/`、`src/frontend/platform/src/controllers/request{,Stream}.ts`。 + +## 冲突与合并补充修复 + +本次 Git 报告 14 个冲突文件。采用逐项整合,未整文件用另一侧覆盖语言资源或业务逻辑: + +- `release-contract.md`:保留发布线已有 F076/F077/F078/F085/F086/F087/F088 内容,补入 main 的 DSH 本地接入约束。 +- 信息源测试:同时保留两种 return_information 的参数化验证和目标分支 max_create_time/快照断言。 +- 请求模块:保留 F088 错误对象、错误提示去重、登录和权限行为;补入 main 的流式复用出口及 Bearer 认证头。 +- 部门额度、DSH 管理页:应用 main 去席位化及模型审计入口,保留目标分支共享组件。 +- 插件市场:保留共享 UI,接入工作台页签、安装策略和 npm 包格式。 +- 删除已由 main 移除的席位/License 组件及对应测试,不重新连接被移除的产品入口。 +- ESLint 抑制文件保留双方自动合并内容、冲突处保留发布线较小计数,并运行 lint:prune;最终相对合并前无净变化。 +- 自动合并之外,修复 main 新增页面指向已删除旧 Button/Input/Toast/Badge 等组件入口的引用,统一使用已落地的共享组件;相关测试匹配共享组件的 aria-disabled 与真实指针交互。 + +## 数据库与升级影响 + +传入 main 的两条迁移: + +1. `f065_dsh_install_policy_default_off`:只更改安装策略字段的数据库默认值,已有租户的值保持原样。 +2. `work110_dsh_local_sessions`:既有 dsh_model_call 的 seat_id/grant_version 允许为空,并调整版本检查约束;不清除历史调用记录。 + +目标分支原有两个迁移 head,合并 main 后成为三个。新增 `v3_0_0_merge_main_work110_release300.py`,将 `asr_rt_model_type`、`merge_skill_center_heads`、`work110_dsh_local_sessions` 汇成 `merge_main_work110_release300` 单一 head。该合并迁移 upgrade/downgrade 均无操作,未改写已发布迁移的父子关系,未执行数据修复。 + +新会话及审计整表由项目现有模型发现/create_all 启动路径创建。历史审计名称、展示状态和树结构有独立运维脚本;部署后如需完整历史检索,应逐租户预览后按运维流程执行:`backfill_dsh_audit_model_names.py`、`backfill_dsh_audit_presentation.py`、`prepare_dsh_audit_tree.py`。本次仅合并文件,没有运行这些脚本。 + +## 验证结果与证据边界 + +| 检查 | 结果 | +|---|---| +| 前端 DSH、市场、统一请求及流式请求 | 36 个文件、302 个用例通过 | +| 后端 DSH 本地用例 | 654 passed、110 skipped、63 deselected;显式排除外部数据库/Redis用例及已定位的批量测试状态问题 | +| 市场及保存工具专项 | 191 passed、1 deselected;排除本地 MCP 依赖不匹配导致的启动用例 | +| 模型协议、本地认证及工具 HTTP 专项 | 124 passed、1 skipped | +| 信息源、迁移图和本地会话专项 | 29 passed、1 skipped;包括单 head 和既有 main 迁移文件逐字节不变检查 | +| 本次前端整合文件 ESLint | 23 个文件通过 | +| 三语及错误码 | pnpm check-i18n、pnpm --filter @bisheng/locales check 均通过 | +| 架构守卫 | 无 VIOLATION;6 个传入测试/fixture 文件触发 RULE-7 模式警告 | +| 全量 pnpm lint | 未通过:85 个错误;在合并前 38395b198 的主工作区运行得到相同 85 个错误 | +| 全量 pnpm typecheck | 未通过:缺少本地 @hello-pangea/dnd 安装、clientVoiceDraft.test.tsx 参数个数错误;以相同依赖运行合并前提交,复现相同 2 项错误 | + +全量后端首次运行并非全绿:955 passed、117 skipped、54 个隔离外部服务配置错误,以及 3 个失败。其一是合并后多 head,已修复并通过专项;其二是本地 MCP 库不接受 max_request_body_size;其三是 native_access 的租户隔离用例在批量进程内失败,该用例在原始 main 批量测试亦复现、独立及协议专项通过。按模块拆分后取得上表结果,未把批量失败记为通过。另一次宽范围排除运行出现跨测试租户 ContextVar 污染,不作为业务行为验收证据。 + +未运行生产部署、MySQL/DM8 升级回放、真实 Redis/MinIO/OpenFGA 多租户 E2E、真实桌面客户端和供应商模型联调;本地测试不能替代这些环境的验收。合并完成不代表已达到发版验收条件。 + +## 建议后续验收重点 + +1. 部署前修复既有 lint/typecheck 基线;在 MySQL、DM8 验证升级至唯一 head、旧调用证据及额度保持。 +2. 真实桌面验证旧会话重新登录、本地刷新/退出、账号禁用、租户切换及非管理员模型授权。 +3. 验证审计 OFF/METADATA/CONTENT、失败/超时/取消、原文权限、密钥缺失、后台队列和历史准备状态。 +4. 验证企业 Chat/Responses 推理参数、Token 结算、嵌套保存工具输入与第三方安装默认关闭。 +5. 对公共 LLM 初始化、用户资料更新和信息源快照同步做部署后回归。 diff --git a/docs/product/user-selectable-models-20261022.md b/docs/product/user-selectable-models-20261022.md new file mode 100644 index 0000000000..ffab258405 --- /dev/null +++ b/docs/product/user-selectable-models-20261022.md @@ -0,0 +1,31 @@ +# 用户可选模型配置 + +用户于 2026-10-22 确认交互预览并授权实施、部署 3006、推送代码。 + +## 已确认规则 + +- 首标签为“用户可选模型”,合并原桌面端目录。其余四类系统配置保留。 +- 列为实际模型、服务提供方、显示名称、视觉、积分倍率、可见范围。末列只显示“配置可见范围”按钮。 +- Chat 与灵思默认模型沿用已保存值;页面移除两个默认选择列。描述信息保留在存储中。 +- 向量、语音和标题模型保留在“其他模型配置”。 +- 名称使用原 WorkbenchModelConfig.models[].displayName 作为唯一配置源,空白值使用实际模型名。Chat 与桌面目录统一为“服务提供方 / 显示名称”,调用 ID 保持不变。 +- 企业可用模型继续按已有授权目录生成。编辑显示名称保持权限、倍率、积分余额不变。 + +## 实现与验收 + +管理员通过专用 settings 读取入口编辑原始元数据,避免把个人授权过滤后的模型目录写回系统配置。该入口使用现有管理员与租户范围校验。用户读取入口继续执行授权投影。 + +验收覆盖名称更新与回退、同名跨服务商、目录刷新后的名称变化及稳定 ID、隐藏描述和默认项保存、可见范围按钮与既有授权交互。真实环境检查目标为 3006;其他部署保持。 + +基线:后台 b3fbbd166(feat/bisheng-work-120-main),桌面 e4a27e0(V1.2.0),已安装 Harness 0.2.0-rc.2。独立工作目录均为 codex/user-selectable-models。 + + +## 3006 发布结果 + +发布代码:`1d5270442`,表头三语文案修正:`2a309aeff`。最终目录:`/data/bs119/deployments/f054-unified-agent-3006/releases/user-models-2a309aeff-20261022`。前两次健康检查因启动瞬间连接重置、服务器旧版 curl 参数兼容性触发回滚;最终兼容轮询检查通过。 + +登录态实测:后台将 qwen-max 的显示名称从 qwen 改为测试名称,保存后桌面“账号与安全”和 Work 模型选择器均显示新名称与阿里云百炼。验证后恢复 qwen,并分别确认后台重新读取和桌面刷新后的原值。实际模型 ID、权限和倍率保持原配置。可见范围弹窗包含用户、部门、角色;本轮只检查已有权限,没有修改授权。 + +后台 58 项通过、2 项环境用例跳过;管理端相关 28 项通过,类型检查、定向 ESLint、构建、架构检查通过。桌面相关 65 项通过,合入最新 f97e158 后重跑的名称及服务回归 53 项通过,类型检查通过。 + +代码审查:[BISHENG #2515](https://github.com/dataelement/bisheng/pull/2515)、[桌面 #72](https://github.com/dataelement/bisheng-work/pull/72)。后续按用户确认直接合入后台 `feat/bisheng-work-120-main` 与桌面 `V1.2.0`;以上 PR 保留为改动历史入口。 diff --git a/docs/reviews/3.0-bugfix-integration-2026-10-30.md b/docs/reviews/3.0-bugfix-integration-2026-10-30.md new file mode 100644 index 0000000000..21b63b8b23 --- /dev/null +++ b/docs/reviews/3.0-bugfix-integration-2026-10-30.md @@ -0,0 +1,18 @@ +# 3.0 缺陷修复合并验证 + +本批提交包含:登录页图标位置、服务账户全平台只读列表及所属租户展示、托管应用列表自动加载当前版本、租户管理员数据集路由、超管跨租户环境摘要读取、知识空间问答共享模型同步更新死循环。 + +细节与现场证据见同目录 `tenant-admin-dataset-404-2026-10-30.md`、`hosted-app-global-view-secrets-2026-10-30.md`、`knowledge-ask-react-update-loop-2026-10-30.md`;服务账户边界同步 F113 design,环境摘要边界同步 F055 design。 + +提交前组合验证:后端 101、管理端 48、客户端 66,共 215 项相关测试通过;全前端 `pnpm lint`、`pnpm typecheck`、`pnpm check-i18n` 通过;改动 Python Ruff、架构守卫与 diff 空白检查通过。组合运行发现服务账户测试直接以 Root 上下文核验子租户数据,已将其核验步骤绑定到该子租户;删除聊天输入框一个没有消费者的局部变量,使既有 lint 问题消除。 + +额外运行 `AiChatInput.test.tsx`:默认 jsdom 缺少 TextEncoder,使用临时 Node util 编码 polyfill 后 13 通过、4 失败。将 HEAD 原始输入框源文件通过临时 Jest 映射运行,同样 13 通过、相同 4 项失败: + +- new chat offers setup, established chat hides setup while preserving context +- returning to ordinary chat clears selected task skills +- send acceptance false controls the entire skill draft reset +- account availability removes a selected skill while the picker stays closed + +这四项依赖旧的 `@Reference`、`/research` 展示,与当前内联引用呈现不符;本批未改其行为或弱化断言,保留为旧测试基线。临时原始源文件已删除,临时测试配置和结果仅留在 `/private/tmp`,未纳入提交。没有运行全仓测试,不宣称全套测试全绿。 + +按用户指令,先将本批提交汇入 `feat/3.0.0`,再合并到 `release` 并推送。提交推送与合并不等同于现场部署;各现场修复后浏览器验收仍需升级对应服务。 diff --git a/docs/reviews/bisheng-gateway-runtime-manager-implementation-plan.html b/docs/reviews/bisheng-gateway-runtime-manager-implementation-plan.html new file mode 100644 index 0000000000..0cb770543c --- /dev/null +++ b/docs/reviews/bisheng-gateway-runtime-manager-implementation-plan.html @@ -0,0 +1,802 @@ + +bisheng-gateway 商业托管模块同仓库实施方案
BISHENG · 技术评审 历史参考 · 已替代
faed249 / 736ef59 · 2026-10-19Markdown 源文

bisheng-gateway 商业托管模块同仓库实施方案

+

日期:2026-10-19 | 状态:实施方案草案 | 范围:仓库组织、运行进程、功能迁移、授权与交付

+
+

历史参考,已被替代(2026-10-19): Vibe 已确定全部按开源实现,代码继续留在 BiSheng 仓库。本文中的迁入 Gateway、Java 商业 runtime、授权和固定上线额度建议不再作为实施要求;当前有效方案见 Vibe 应用托管全开源技术方案。下文保留原评审记录。

+
+

在现有 bisheng-gateway Git 仓库内增加完整托管编排实现,将当前单 Maven 应用重组为多模块工程,Gateway 与托管服务分别构建、启动和升级。这是既有仓库内的工程组织调整,不涉及新建或合并 Git 仓库。先完成现有 Docker 功能及已确认的存储解耦,再扩展应用包和 K8s。

+

已经确认的边界: 附件接口归 BiSheng backend;manager 保留本地 SQLite 备份、迁移和恢复,通过 backend 下载源码、上传快照,不直接访问 MinIO;免费同时在线 10 个应用,停用释放名额。前文按代理与 manager 分进程展开,本轮进一步比较三者合并部署;分进程是可选运行形态,不是技术硬性要求,见 §2.4。Java 重实现、元数据库部署方式和分阶段切换是本方案建议,进入开发前需完成对应设计。

+

本方案基于本地 Gateway feat/dsh-access@faed249a96ea43a4c65b90765dd4054bf6e55722,工作区检查时无改动;runtime 功能清单以 BiSheng feat/3.0.0-vibe@736ef593f3fc6e1634008c842d2604304d6f70a6 为基线。Gateway 基线是当前本地检出,不代表远端最新提交。本文交付方案,不调整这两个项目的业务代码、POM、配置或运行进程。

+

阅读导航

+ +

+

1 当前仓库与改造前置

+

1.1 当前目录

+
bisheng-gateway/
+├── pom.xml                         # 单个 gateway 可执行工程
+├── Dockerfile                      # COPY gateway.jar,启动一个 JVM
+├── src/main/java/com/dataelem/gateway/
+│   ├── GatewayApplication.java
+│   ├── controller/ filter/ config/  # 网关、SSO、到期控制等
+│   ├── service/ mapper/ sdk/
+│   └── dsh/                        # DSH 控制器、授权、席位及持久化
+├── src/main/resources/
+├── src/test/java/
+├── docker/
+│   ├── docker-compose.yml          # 当前示例仅声明 gateway
+│   ├── gateway/config/application.yml
+│   └── db/                         # MySQL 和 DM SQL
+├── scripts/                        # 打包、上传、重启和本地启动脚本
+└── docs/                           # Gateway 与 DSH 说明
+
+

根 POM 的 artifact 为 gateway,采用 Java 17、Spring Boot 3.2.6、Spring Cloud 2023.0.1,包含 Spring Cloud Gateway、Reactor 集成及 MyBatis、MySQL/DM 驱动。根 POM 尚无 <modules> 或聚合工程配置。G01

+

/Users/zhangguoqing/works/bisheng-gateway 已经是 Git 仓库。Git 管版本,Maven 模块管构建,进程管运行边界;增加 Maven 子模块不会创建子 Git 仓库,模块数量也不决定进程数量。

+

1.2 必须一起处理的现有耦合

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
当前实现改造影响实施要求
单个根 POM 管理全部依赖新 runtime 若直接加到根 src,会进入 Gateway 同一个 classpath 和启动范围将两个应用分开,不以 profile 切换同一份完整应用来模拟独立服务
GatewayApplication 扫描 com.dataelem.gateway,并注册 Mapper新服务误用扫描范围会带起 SSO、DSH、旧数据库配置及定时任务runtime 使用 com.dataelem.runtime 独立启动包;共享库不自动扫描 Gateway 组件 G02
deploy.sh 在根目录执行 Maven,寻找 target/gateway-*.jar移动源码后旧脚本找不到产物,或误发其他模块 JAR修改 Shell、PowerShell、本地启动和 Docker 打包入口,保留 Gateway 产物名称和外部启动约定 G03
旧授权到期 WebFilter 会处理未单独分类的网关自有 API新 hosting 路由可能被旧 SSO 授权误拦,影响免费 10 应用增加精确路由归属和独立 hosting 授权规则,覆盖缺少/过期旧授权的测试 G04
DSH 验签与内置 10 席位在 dsh 包中DSH 的 audience、格式、人数及回收规则不是应用托管授权抽密码学基础接口,保留 DSH 协议适配;另建 hosting 授权模型 G05 G06
DSH 已有 MySQL/DM 事务及幂等操作记录可借鉴短事务、并发控制方式,但表和语义不能直接复用托管使用自己的运行任务、应用占用和所有权记录,独立验证两种数据库 G07
+

当前检出未发现 .github 工作流目录;不能把现有脚本中的 -DskipTests 当作发布验证。实施时需要明确实际使用的 CI 系统、制品仓库及镜像发布入口,并将下文的构建与验收要求落到该系统。

+

+

2 目标目录与进程

+

2.1 推荐目录

+
bisheng-gateway/
+├── pom.xml                         # 聚合父 POM,packaging=pom
+├── apps/
+│   ├── gateway/
+│   │   ├── pom.xml                 # artifactId=gateway
+│   │   ├── Dockerfile
+│   │   └── src/                    # 现有 Gateway 源码与测试迁入
+│   └── runtime-manager/
+│       ├── pom.xml
+│       ├── Dockerfile
+│       └── src/
+│           ├── main/java/com/dataelem/runtime/
+│           │   ├── RuntimeManagerApplication.java
+│           │   ├── api/ application/ domain/
+│           │   ├── infrastructure/
+│           │   │   ├── docker/ platform/ persistence/ sqlite/
+│           │   │   └── egress/
+│           │   └── config/
+│           ├── main/resources/templates/
+│           └── test/
+├── libs/
+│   ├── license-core/               # 通用验签、受信公钥、时钟等;普通 JAR
+│   └── runtime-contracts/          # Java DTO、错误码与协议版本;普通 JAR
+├── contracts/
+│   ├── runtime/                    # 语言无关 schema、请求/响应和签名样例
+│   ├── platform-artifacts/         # backend 文件传输协议
+│   └── egress-policy/              # 与开源代理对齐的策略样例和兼容测试
+├── deploy/
+│   ├── compose/                    # 内部网络、服务和配置示例
+│   ├── config/                     # Gateway/runtime/egress 分别配置
+│   ├── db/mysql/                   # 商业 runtime 元数据 SQL
+│   ├── db/dm/
+│   └── offline/                    # 镜像、配置、校验清单和安装次序
+├── scripts/                        # 按服务选择构建和发布
+└── docs/                           # 架构、协议、升级、故障恢复和版本矩阵
+
+

第一轮只设四个 Maven 模块:apps/gateway、apps/runtime-manager、libs/license-core、libs/runtime-contracts。Docker/K8s 适配先通过 runtime 内部接口与包隔离;实际出现独立复用或发布需求后,再拆 Maven 模块。egress-proxy 继续留在 BiSheng 开源仓库,独立管理依赖和镜像,不进入商业仓库的源码或 Maven 构建。

+

该目录不要求修改仓库名称;同一个 Git 提交可对应多个独立制品,运行时依赖由服务协议表达。

+

2.2 进程及代码归属

+

下表是独立组件部署形态下的建议,代码归属与运行形态分别判断。合并部署时,组件仍保持逻辑模块,具体取舍见 §2.4;不能把表中的“独立进程”当成架构必须条件。

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
组件建议代码归属运行方式和权限
Gatewaybisheng-gateway/apps/gateway独立 JVM,继续负责入口、SSO/DSH;增加 hosting 内部 API 路由及授权展示,不持编排凭据和应用数据卷
runtime-managerbisheng-gateway/apps/runtime-manager独立 JVM/容器,管理托管任务、生命周期、额度、路由和本地 SQLite;仅内部开放 API,建议保留 8091 服务端口
egress-proxyBiSheng 开源仓库;目标拆为独立模块和镜像独立进程,负责策略读取和 HTTP/CONNECT 转发;商业 runtime 生成、更新及撤销策略,不将代理转发实现商业化
app-proxyBiSheng 开源仓库独立进程,继续调用 backend 授权、runtime 路由;接口兼容即可,不因仓库合并而要求改写
docker-socket-proxy引用第三方镜像,不复制其源码进业务模块独立容器,持 Docker socket;runtime 经其 Engine API 编排
backend / Celery / SDKBiSheng 仓库平台业务、权限、附件、产物/快照存储和应用侧 SDK;与商业仓库通过版本化协议协作
+

因此,Docker 部署仍有 Gateway、runtime、app-proxy、egress-proxy 和 socket proxy 等独立服务。商业仓库收拢托管编排与商业授权实现,部署清单引用开源代理制品,不把代理源码迁入商业仓库,也不把这些进程塞进 Gateway 的 JVM。

+

分进程交付时,egress 使用独立精简镜像,源码保持开源。 当前代理代码位于 BiSheng src/runtime-manager/runtime_manager/egress_proxy.py,并使用原 Python runtime-manager 镜像的另一入口。建议在 BiSheng 仓库内拆出 src/egress-proxy/ 目标模块;独立镜像只包含代理、策略读取和必要依赖,不包含 intent API、builder、lifecycle、reconciler 或可启动的完整 manager。该拆分解决交付边界,不改变代理的开源归属;既有公开的 manager 源码也不会因镜像精简而消失。B01 B02

+

策略协议及代理侧行为在开源仓库维护,商业仓库只保存兼容样例与测试,并实现运行时策略生成、注册和撤销。商业化价值及额度控制位于编排执行,不依赖将通用代理闭源。

+

2.3 实现语言与过渡选择

+

推荐以 Java 17 独立 runtime 为目标,与当前 Gateway 工程体系一致;Spring Boot 版本升级作为另一个受控变更,避免同时引入目录重组、框架大升级和行为重写。目标 JDK/依赖版本须在开发启动时重新核定支持与安全要求。

+

若短期仅做代码归仓,也可将 Python manager 放入 services/runtime-manager-python,保持独立构建;但这只完成仓库迁移,不会自动满足 Java 交付或代码保护目标。该路径可作为受控迁移环境,不建议成为另一套长期、无限制的完整执行入口。

+

+

2.4 代理与 manager 合并进程的可行性

+

app-proxy、egress-proxy 和 runtime-manager 可以合并为同一进程。 三者分别是应用入口反向代理、出站正向代理和编排控制模块,不存在要求它们必须有不同 PID 的协议限制。同一 JVM 可以启动多个监听器和执行器;Reactor Netty 提供配置监听地址和端口、绑定服务器的能力,具体三个服务的集成属于新增设计。Reactor Netty 官方说明

+
Gateway JVM                          # 现有网关,继续独立
+
+托管服务 JVM                         # 三合一候选形态
+├── app-proxy 模块       :8090        # HTTP / WebSocket 入口
+├── egress-proxy 模块    :3128        # HTTP / CONNECT 出站代理
+└── runtime-manager 模块 :8091        # 内部管理 API、构建/迁移任务与对账
+
+应用容器 / Pod                        # 应用代码仍在各自隔离执行体内
+
+

端口为示意,可以复用当前服务端口;同一进程监听多个端口不代表全部端口都对外发布。管理接口仍需单独绑定/网络策略、服务认证和调用角色限制,不能因为代理端口面向应用开放,就让应用获得 intent API。manager 经 socket proxy 调 API 仍拥有有效编排权限;同进程模块共享运行身份、内存和凭据,线程池或端口分开不能形成进程级权限隔离。

+
+ + + + + + + + + + + + + + + + + + + + + + +
运行形态收益代价与适用条件
三者合成一个托管进程服务数量最少;路由/策略可在进程内协作;部署配置较少共享内存、有效权限和重启周期;构建/迁移负载及 OOM 可影响代理;升级需处理 HTTP/WebSocket 与 CONNECT 连接中断
两个代理合为一个进程,manager 独立从三个进程减为两个;开源代理与商业编排仍分别运行;manager 重启不直接关闭代理连接两个代理共享故障和连接资源;需整合现有两个 Python 组件及各自配置,验证资源竞争和隔离
三个进程独立权限、资源和升级边界清楚,可分别扩缩容及排障服务和协议配置较多;路由及策略同步需单独管理
+

当前 app-proxy 和 egress-proxy 都是 Python,规划中的新 manager 是 Java。保留现有代理实现时,放到同一镜像、容器或 Pod 里仍然是多个进程。若要求单 JVM,必须将 HTTP/WebSocket 入口、身份头处理、撤权断连、CONNECT 隧道、DNS/IP 校验和流控等代理功能移植到 JVM 实现;这部分工作量不能省略,也不能认为普通 Gateway 路由已完整覆盖这些行为。B01 B02 B09

+

采用三合一时,逻辑模块继续解耦:代理使用独立事件循环、连接池和有界缓冲,构建/SQLite 操作使用有界后台执行器;为并发连接、文件传输和构建分别设限。进程重启会同时影响三个模块,需要明确排空、恢复与可用性目标。开源代理源码的归属与发布条件需在合并构建方案中保持清楚,不能因同一进程就将代理功能整体认定为闭源商业逻辑。

+

结合原需求的建议: 若保留 F054 AC-22“运行时服务重启时已运行应用继续对外服务”和 AC-60“运行时与 app-proxy 可独立启停”的承诺,应让 manager 与代理流量进程分开;两个代理是否合并仍可单独评估。三合一在协议和实现上可行,但属于运行边界调整,不能视为完全保持原需求的等价替换;需要先确认可接受的连接中断,或设计并验证额外高可用机制。P0 应同时比较改造量、权限、运行开销和原规格的兼容性,后续制品/进程组合随该决策调整。B10

+

2.5 原实现拆分的依据

+

原设计将编排控制、应用入口转发和出站转发分开,主要依据是控制面重启与权限变化不应直接影响应用流量。此处说明设计意图及已有实现边界,不把规格中的承诺等同于已经完整实测通过。

+
+ + + + + + + + + + + + + + + + + + + + + + +
依据原实现中的落点对合并方案的约束
运行稳定性F054 AC-22 要求 runtime 服务重启时应用继续对外服务,AC-60 要求 runtime 与 app-proxy 可独立启停;egress 代码明确只依赖持久策略文件,不依赖 manager 一直在线三合一重启会关闭同进程的入口/出口连接,需要变更运行承诺或另做高可用设计 B10 B12
编排权限和网络攻击面manager 负责容器操作,意图 API 在内部;egress 面向应用网络,只提供代理协议,Compose 刻意不给它 manager HMAC secret 和 Docker host 配置socket proxy 收窄的是 Engine API,不会消除 manager 的有效编排能力;分端口不能替代进程权限边界 B02 B12
专用入口与出口职责app-proxy 处理平台授权、可信头/OBO 注入、HTTP/WS 和拒绝页面;egress 处理正向代理、目标域名与解析地址校验;manager 维护路由与生命周期职责可以合并实现,但各自鉴权和协议边界仍要完整保留;原 D5 还要求开源/商业版共用入口行为 B11 B12
+

实际实现也有需要验证的边界:app-proxy 的路由仍查询 manager,只缓存约 3 秒;缓存失效后 manager 不可达会进入恢复处理。因此,分进程确实使既有转发连接不因 manager 退出而直接被关闭,egress 也可继续按已保存策略工作,但不能据此宣称当前版本已保证任意时长 manager 故障下所有新请求都不受影响。原路由依赖应在迁移验收中单独覆盖。B13

+

+

3 Maven 构建与依赖隔离

+

3.1 模块依赖

+
apps/gateway
+  ├── libs/license-core
+  └── libs/runtime-contracts
+
+apps/runtime-manager
+  ├── libs/license-core
+  └── libs/runtime-contracts
+
+BiSheng/src/egress-proxy              # 开源仓库内的目标独立模块
+  └── 版本化策略协议,与商业仓库的兼容样例互验
+
+

Gateway 不依赖 runtime 应用 JAR;runtime 不依赖 Gateway 应用 JAR。license-core 不引入 Gateway controller、DSH 席位服务、数据源、Spring Web starter 或整套 LicenseLoader。runtime-contracts 只表达协议,不包含部署执行器、数据库访问或授权决策。

+

Gateway 保持 WebFlux;runtime 建议采用独立 Spring MVC 应用,将阻塞的 Engine、文件、SQLite 操作放入有界任务池。不能把两个 Web starter 都放入公共依赖后期待自动选对框架:Spring Boot 3.2.6 在两者同时存在时默认选择 MVC。Spring Boot 官方说明

+

3.2 构建规则

+

父 POM 使用 dependencyManagement 和 pluginManagement 管版本及插件配置;两个应用分别声明 Boot 打包插件,公共库产生普通 JAR。先保持 Gateway 的 artifactId 和外部镜像名称约定,内部产物路径改由脚本显式选择。

+
# 以下是目标结构的示例命令,当前单模块仓库尚不能这样执行。
+mvn -pl apps/gateway -am verify
+mvn -pl apps/runtime-manager -am verify
+mvn verify
+
+

Maven reactor 根据模块依赖排序,支持构建指定模块及其依赖;选定模块的命令应在父工程运行。Maven 官方说明

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
文件或入口改造方式检查点
根 pom.xml改为父工程;将现有业务依赖移到 gateway 子 POMruntime 不被传递引入 Sa-Token Reactor、SSO 或 DSH 业务配置
根 src/用保留 Git 可追踪关系的移动迁入 apps/gateway/src/Java 包名、资源路径、Mapper XML 与测试资源仍可用
根 Dockerfile调整为明确构建 Gateway 的兼容入口,或以模块 Dockerfile 替代并同步发布脚本不再依赖人工将任意 JAR 放成根 gateway.jar
deploy.sh、deploy.ps1指定 gateway 模块、准确产物路径;新增 runtime 发布入口不使用递归通配符从多个模块中猜选 JAR
start-local-stack.ps1指定 gateway 模块启动,runtime 使用独立启动命令两个进程可分别调试,端口与配置互不覆盖
镜像与离线发布商业仓库构建两个 Java 服务,BiSheng 构建开源 egress 镜像;清单记录各自 Git SHA、镜像 digest 和协议版本更新 runtime 不要求同步替换 Gateway 或代理,兼容范围内可分别回退
+

共享库变化需验证所有消费者。源码统一提交,制品是否一起升级由兼容矩阵决定;离线交付通过版本清单锁定一组经过验证的服务镜像。

+

+

4 完整 runtime 的功能迁移

+

4.1 Python 到 Java 的功能对应

+

“完整实现”以已落地的 Docker manager 行为为范围,同时落实确认的存储边界;附件和 MinIO 直连从职责中迁出。不能只把容器 create/start 包成 Java 接口即宣告迁移完成。

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
原模块Java 目标职责必须保留或调整的语义
api/intents.py、api/schemas.py、auth.pyAPI、DTO、服务认证、操作幂等与任务提交参数默认值、错误码/HTTP 状态、超时和签名样例跨语言对齐;区分读写调用者
builder.py、source_facts.py、templates/源码检查、环境解析、模板渲染、构建任务和日志Python/Node/static 模板、入口识别、锁文件/依赖层、构建网络及受保护参数;Jinja 模板改写后需比较结果
docker_backend.pyContainerBackend 接口及 Docker 实现通过 socket proxy 调 Engine API;构建流、inspect、日志、网络、删除和错误分类;不要求宿主 Docker CLI
admission.py资源容量准入CPU/内存保留和并发准备资源;与商业应用数分开判断、分别报错
lifecycle.py部署、更新、停止、销毁和实例退休固定安全容器参数、挂卷、环境保留字段、出站主体、探活后换路由及旧实例清理
desired_state.py、reconciler.py持久期望、实例接管、故障恢复与对账实例标签、generation、未知实例隔离、重启恢复;自动创建路径也执行授权及占用检查
probe.py、routing.py、只读 API临时探活、正式就绪、路由、状态、自检和日志临时实例不使用正式卷;app-proxy HTTP/WS 查询契约;坏 Engine 不导致读接口完全无法启动
preview.py审批预览生命周期临时库、独立策略、到期清理、无生产附件凭据;预览资源限制独立于正式名额
appdb.py、api/appdb.pySQLite 数据查询/导出、本地备份、迁移及恢复标识符白名单、事务/幂等迁移、一致性 Backup API、所有权和卷路径;业务权限仍由 backend 判断
egress.py出站策略生成、注册、撤销及安装前置检查与独立 egress 服务保持策略文件兼容;必要防火墙规则由部署流程受控安装,不给业务应用额外宿主权限
storage.py、api/storage.py从 manager 删除;由 backend 承接附件句柄、路径隔离、大小限制、错误信封及令牌校验迁移;manager 不再接收附件数据流
config.py、观测和入口独立配置、任务日志、指标、健康和停机行为RTM 配置迁移表、敏感信息脱敏、任务恢复;没有 MinIO 密钥/bucket 配置
+

基线模块及测试清单见 B01—B07。现有 Python 测试用作行为规格和案例来源;Java 必须重新建立 API 契约、领域及真实执行后端测试,不能只统计原测试数量。

+

4.2 关键实现边界

+

任务与资源。 构建、部署、迁移采用持久操作记录和有界执行池,明确排队、并发、取消、超时和进程重启后的处理。HTTP 接收线程不承担整个构建过程;数据库短事务中不执行拉镜像、上传文件或等就绪等远程动作。

+

模板与系统操作。 对源码安全解包、模板生成、环境变量覆盖规则和命令参数逐项迁移;不执行用户提供的任意宿主命令。Docker 适配器负责 Engine 差异,领域层只使用固定的部署模型。镜像、网络和数据卷清理只匹配受管标签及当前所有者。

+

SQLite 与国产化。 Java 不消除 SQLite 的本地库依赖。Xerial JDBC 使用 native 库并公布平台列表,选定版本须核对实际 OS、CPU、libc 和动态库加载条件;不能由 JAR 通用性推断龙芯、MIPS 等未验证环境可用。将打开 WAL 库、Backup API、DDL 事务、文件权限和恢复列为最早的原型检查。SQLite JDBC 官方说明

+

备份使用一致性备份 API,不以复制活跃 app.db 替代。若目标架构缺少可用驱动,应先交付并验证对应 native 依赖,或另行评审受限本地执行器方案;不得通过不一致文件拷贝绕过该前置。SQLite 官方备份说明

+

K8s。 第一阶段只定义执行后端接口并交付 Docker。现有 BiSheng K8s 内容属于规格;真正增加 K8s 还要处理构建器、仓库、RBAC、PVC、节点数据访问和网络策略,不能将 Docker 客户端替换视作完整 K8s 实现。B08

+

+

5 平台协议与存储解耦

+

5.1 三类调用各有边界

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
链路目标接口与身份归属
backend/worker → Gateway → runtime托管操作入口、服务认证、请求关联;runtime 校验实际操作权限、许可和占用Gateway 是门面,runtime 是执行与运行事实来源
app-proxy → runtime路由和必要只读状态;使用单独只读身份不能拿与 backend 相同的全能力密钥去调用 intent API
runtime → backend源码下载、快照上传/查询/恢复下载;服务身份和限定任务授权backend 返回文件流,操作对象由平台校验,不把 bucket/key 下发
应用 SDK → backend每应用附件句柄、能力凭据和访问者上下文附件由 backend 处理;应用得不到 runtime 管理凭据或快照任务授权
+

Gateway 新 hosting API 建议使用明确的内部路径前缀,并与现有到期过滤器分开归属。Gateway 校验上游服务身份后,按下游方法、路径和请求体摘要重新签署对 runtime 的请求;路径改写后不能原样转发按旧路径生成的 HMAC。最终路径和字段在协议设计中冻结。

+

5.2 保持兼容的范围

+

先对齐现有 /v1/intents/build、build 查询、deploy/stop/destroy/probe/preview、db-migrate、路由、日志和数据库操作的字段及返回行为。新版产物引用、幂等任务和调用方权限通过显式版本协商或兼容适配加入;不能悄悄将同步返回改成 202 而不改调用者。

+

contracts/ 保存 Python 与 Java 都可消费的固定请求、响应、UTF-8 原始请求体、签名及错误样例;覆盖路径编码、空 body、中文、浮点资源参数、缺省字段和签名失败。SDK 当前识别 manager 风格的错误信封,backend 附件迁移需提供兼容适配或发布 SDK 更新。B04 B05 B07

+

新存储协议至少覆盖:产物内容下载;快照任务创建、上传、状态查询和恢复下载;附件对象与元信息操作。具体路径沿用存储评审中的草案继续设计,不把草案名称认定为现有接口。

+

5.3 发布和快照链路

+
时序图 1 第 1 部分Backend 到 Store:1 保存源码并建立产物引用;Backend 到 Gateway:2 提交构建任务和下载授权;Gateway 到 Runtime:3 认证转发;记录幂等操作;Runtime 到 Backend:4 通过平台接口请求源码内容;Backend 到 Runtime:5 流式返回;下载校验摘要;Runtime 到 Engine:6 构建并执行临时探活;Backend 到 Gateway:7 审批后提交迁移和上线任务Backend / CeleryGatewayJava runtimeDocker EngineMinIO1 保存源码并建立产物引用2 提交构建任务和下载授权3 认证转发;记录幂等操作4 通过平台接口请求源码内容5 流式返回;下载校验摘要6 构建并执行临时探活7 审批后提交迁移和上线任务
图 1 · 第 1/3 部分 · 按步骤编号连续阅读;窄屏可横向滚动
时序图 1 第 2 部分Gateway 到 Runtime:8 转交冻结版本及任务授权;Runtime 到 Runtime:9 预检资源及许可;预占或复用应用名额;Runtime 到 Runtime:10 隔离写入;本地生成 SQLite 一致快照;Runtime 到 Backend:11 上传快照并等待持久确认;Backend 到 Store:12 保存快照对象;Backend 到 Backend:13 持久记录平台快照元数据;Backend 到 Runtime:14 返回已保存状态与 snapshot_idBackend / CeleryGatewayJava runtimeDocker EngineMinIO8 转交冻结版本及任务授权9 预检资源及许可;预占或复用应用名额10 隔离写入;本地生成 SQLite 一致快照11 上传快照并等待持久确认12 保存快照对象13 持久记录平台快照元数据14 返回已保存状态与 snapshot_id
图 1 · 第 2/3 部分 · 按步骤编号连续阅读;窄屏可横向滚动
时序图 1 第 3 部分Runtime 到 Runtime:15 本地迁移;上线前重验许可及占用;Runtime 到 Engine:16 启动探活、切换路由、退休旧实例;Runtime 到 Gateway:17 返回执行结果和任务状态;Gateway 到 Backend:18 返回平台更新与对账所需结果Backend / CeleryGatewayJava runtimeDocker EngineMinIO15 本地迁移;上线前重验许可及占用16 启动探活、切换路由、退休旧实例17 返回执行结果和任务状态18 返回平台更新与对账所需结果
图 1 · 第 3/3 部分 · 按步骤编号连续阅读;窄屏可横向滚动

图中的持久化元数据由 backend 写平台数据库,MinIO 保存对象内容。发布任务需要先完成资源及许可预检,再进入停写和迁移;最终执行前仍须重新校验。首次上线名额预占的准确时点、迁移失败补偿与升级复用策略按下一节实现,不能让迁移和运行结果被当成单一事务。

+

源码和快照 API 直接传输文件内容,不返回或重定向到 MinIO 预签名 URL;manager、准备任务和初始化执行体的网络权限中不包含对象存储。平台凭据绑定应用、版本/任务、操作、有效期、摘要及幂等标识,并限制下载目标,避免将任意 URL 变成下载入口。

+

快照的对象和元数据均确认保存后才执行破坏性 DDL;失败或响应丢失时查询任务、幂等重试,不能继续迁移。backend 与 runtime 采用任务协调和流式传输,避免请求线程互相等待形成资源耗尽。附件已从 runtime 移除,应用 SDK 直接访问 backend。

+

+

6 授权与运行状态

+

6.1 可以复用和必须新增的能力

+

license-core 提供受信公钥、签名校验、时间处理、严格字段解析等基础能力;保留 DSH 自己的适配器、schema、audience 和席位模型。新增 HostingLicenseService,使用独立的托管能力、部署范围和在线应用上限,runtime 启动后本地验签。

+

建议 hosting 扩展授权使用独立文件或独立版本化 envelope。现有 DSH 签名绑定外层旧许可证字段,不能未经兼容设计就在原文中添加字段或扩展其严格 schema。托管授权只交付受信公钥;厂商签名私钥不进入客户仓库或镜像。G05

+

免费 10 个在线应用由 runtime 内置规则提供,不要求持有 DSH 席位或有效 SSO 授权。Gateway 展示授权及用量,并可提前拒绝超额请求;实际上线、恢复、自动对账和实例接管仍必须在 runtime 验证。许可失效后的超额存量行为沿用原评审的待定事项,不能直接采用 DSH 自动撤销席位的规则。

+

6.2 推荐运行元数据方案

+

建议为商业 runtime 增加独立的持久元数据域,存储任务、期望状态、在线名额及执行所有者。可以复用现有 MySQL/DM 服务,使用独立 schema 或明确隔离的表与最小权限账号;runtime 只访问自己的运行元数据,不查询 BiSheng 应用、用户、审批或权限业务表。

+

这会增加 runtime 的元数据库配置和连通性前置,应作为本轮架构决策确认。相比将全局额度只记在本地 JSON 或进程缓存中,该方案更适合任务恢复、并发准入和后续多执行节点。首版建议一个部署范围只有一个活动编排写入者,先完成正确性,再开放多副本主动写入。

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
建议记录关键约束用途
runtime_quota_scope部署范围唯一;短事务锁定范围记录后判断和预占同一授权范围共享在线上限,避免每节点各送 10 个
runtime_app(deployment_scope, app_id) 唯一;保存当前/候选引用及占用状态升级复用已有应用名额,停止与销毁分别处理
runtime_operation幂等 ID 唯一并关联 payload hash、阶段、结果和错误响应丢失、重试、进程中断后可查询和恢复
runtime_owner执行节点、所有权代次及存活记录限定对账操作范围;后续多节点接管需明确旧所有者失效规则
+

这些名称为设计建议,DDL、长度、索引和锁语义需要同时验证 MySQL/DM。可借鉴现有 DSH 短 SQL 事务及幂等实现,但不复用 dsh_* 数据,也不在锁内执行 Engine/文件调用。G07

+

本地期望状态文件可用于迁移读取和节点执行缓存,不能与数据库同时成为两份可独立修改的权威。新库导入、标签重建及本地文件读写优先级应在切换设计中明确。元数据库不可达时,不启动新应用或放宽额度;对已在线实例的转发可按已验证路由继续服务,恢复后先对账,不能因一次故障自动全量停服或无限创建。

+

6.3 名额和操作时序

+

首次上线在进入实际变更前原子预占名额;同一 app 升级复用原占用,临时候选不另算在线应用。构建和审批不占正式名额,预览另设资源/数量/TTL。破坏性迁移前预占成功并不等于应用上线,记录必须保留阶段差异。

+

名额仅在确认候选或正式实例与路由已按动作收敛后释放;网络超时、进程失联、任务 TTL 过期本身不代表应用已经离线。恢复和自愈必须查验既有占用,不得绕过商业许可。多节点实现要同时解决全局账本和执行所有权;数据库租约并不自动阻止旧进程继续调用 Docker API,未完成 fencing 验证前不承诺多活动编排。

+

+

7 分阶段实施

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
阶段主要工作完成条件和后续依赖
P0 技术原型与契约固定代码基线;列齐接口/错误/模板/状态;验证 Java Engine 调用、SQLite 备份/恢复及元数据库锁;比较三合一、两代理合并与三进程形态明确代理保留或移植范围、权限与连接可用性;Docker/SQLite 原型通过,决定进程组合、Java 路线及存储模型后进入 P1
P1 仓库工程重组父 POM、现有 Gateway 子模块、普通共享库、独立配置、脚本和 Dockerfile;先保持 Gateway 行为Gateway 单独打包启动;SSO、DSH、路由、WebSocket、授权到期及既有 SQL 行为回归;本阶段不替换生产 manager
P2 平台接口与 runtime 骨架BiSheng backend 附件/产物/快照接口及 SDK 兼容,在 BiSheng 拆出独立开源 egress 镜像;商业仓库实现 runtime API、认证、任务/元数据、自检和平台传输客户端移除 MinIO 配置后可走 backend 下载/上传;读写调用者隔离;平台与商业仓库按契约互验
P3 Docker 全功能迁移构建、准入、生命周期、路由、预览、数据面、备份/迁移、对账/自愈、出站策略和日志使用隔离应用完成 Python/Node/static、SQLite、更新失败和重启接管;无遗漏的旧 manager 调用
P4 商业授权闭环独立 hosting 许可、免费 10 个、原子占用、升级复用、失败补偿、停止释放、恢复和对账并发第 10/11 个应用、超时重试、重启、失联和旧授权到期情形均通过;不能仅验证手动 deploy 路径
P5 迁移演练与交付旧状态/标签盘点与导入、附件句柄切换、只读影子比较、停写切换、回退演练;离线包和运维手册同一应用只有一个编排所有者;原卷和数据可恢复;版本清单可离线安装;交付说明覆盖兼容范围
P6 独立后续工作应用包路线、用户完整镜像准入、K8s、多活动或多节点编排分别完成需求和设计评审;不计入首轮 Docker 完整迁移已完成的依据
+

P0 契约工作完成后,P1 与 backend 接口开发可并行推进;P3 的文件路径依赖 P2,P4 可以随 P3 实现但必须在 P5 切换前完成。商业运行包不能以“授权以后再补”为由先开放一套完整无限制执行服务。

+

跨仓库交付应至少有三组关联变更:Gateway 工程重组;BiSheng 平台存储接口与 SDK;商业 runtime 及部署交付。每组列明协议版本和依赖,合并到各仓库约定的开发主线后再集成,避免将 feature 文档完成视为功能已交付。

+

+

8 切换与回退

+

8.1 切换前准备

+

盘点受管容器、app/version、真实挂载目录、当前/退休/预览实例、路由、出站主体和本地期望状态。备份元数据、状态文件及实际 SQLite 数据;业务能力凭据和附件令牌按敏感数据处理,不能写入迁移报告或普通日志。

+

先在隔离 Engine/应用环境验证新 runtime。对生产数据做影子比较时,只读查询实例和状态,不运行旧、新两套 reconcile 写入循环,不在同一应用数据目录上做双写或迁移。

+

8.2 受控切换流程

+
时序图 2 第 1 部分Ops 到 Backend:1 冻结新发布及迁移,保留正常应用访问;Ops 到 Old:2 排空任务并停止编排及 reconcile 写入;Ops 到 New:3 导入已备份状态、名额和执行映射;New 到 Engine:4 校验实例标签、数据卷和运行事实;Ops 到 Backend:5 切换 runtime 操作与路由查询地址;Backend 到 New:6 运行兼容接口检查及受控应用验证;New 到 Engine:7 取得唯一所有权后开启对账写入运维切换任务Backend /Gateway旧 Python manager新 Java runtimeDocker Engine1 冻结新发布及迁移,保留正常应用访问2 排空任务并停止编排及 reconcile 写入3 导入已备份状态、名额和执行映射4 校验实例标签、数据卷和运行事实5 切换 runtime 操作与路由查询地址6 运行兼容接口检查及受控应用验证7 取得唯一所有权后开启对账写入
图 2 · 第 1/2 部分 · 按步骤编号连续阅读;窄屏可横向滚动
时序图 2 第 2 部分Ops 到 Backend:8 恢复发布并观察任务与额度状态运维切换任务Backend /Gateway旧 Python manager新 Java runtimeDocker Engine8 恢复发布并观察任务与额度状态
图 2 · 第 2/2 部分 · 按步骤编号连续阅读;窄屏可横向滚动

切换时保留内部服务名可减少配置变化,但必须验证协议、HMAC 及状态已经就绪;不能把 Java 镜像换到旧服务名就直接启动自愈。既有实例在接管时建立合法占用,未知或超额实例进入明确处理流程,不能默认新库为空就再赠送 10 个名额。

+

附件旧地址和令牌需要单独迁移。可通过受控重新部署切换到 backend 句柄,或在限定期限内提供旧地址兼容转发,并由 backend 校验经授权迁移的应用凭据。过渡路径不应继续持有 MinIO 配置,也不能保留完整旧 manager 作为长期附件服务。

+

8.3 回退约束

+

保留可回退的服务镜像、配置、数据库变更说明和状态导出格式;不能假定新 runtime 写入的状态旧 Python 可直接读取。回退需要先停止新写入者,再由验证过的转换/导入流程交回所有权。

+

代码版本回退不自动回退 SQLite schema 和已写入的业务数据。破坏性迁移已经完成时,按批准的数据库恢复点和停写窗口操作;快照无法保存或验证时应在迁移前拒绝任务。候选启动失败也要检查应用能力凭据是否已轮换,不能把旧容器还在等同于旧版本完整可用。

+

退回已公开的旧完整 manager 会降低商业控制能力,应作为有期限、可审计的故障处置,并在产品交付中明确支持边界;正式免费托管包仍由同一商业执行实现提供 10 个名额。

+

+

9 验收和交付

+

9.1 必要验证

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
领域必须验证的场景通过条件
工程隔离仅构建/启动 Gateway,或仅构建/启动 runtime各自产物和配置正确,公共库不是可执行应用;Gateway 无编排库和数据卷
既有功能回归SSO、DSH 登录/席位、授权到期、HTTP 和 WebSocket 路由行为符合原契约;引入 hosting 不改变其他授权范围
协议对齐原始签名、编码、DTO 缺省、错误信封、build/preview/db APIPython 调用方与 Java 响应一致;任何不兼容都有明确版本和适配
Docker 生命周期构建、探活、发布、更新、停止、删除、重启收养和孤儿清理只操作受管资源;失败和重试可恢复,路由与实例可对账
存储隔离禁止 runtime/准备任务直连 MinIO;文件和附件操作正常没有存储 SDK/长期密钥/bucket 依赖或 302 旁路;附件请求不进入 runtime
SQLite并发写入下的一致备份、迁移独占、快照上传故障、导出、指定恢复快照确认后才做破坏性变更;不跨应用;backend 不挂应用数据库目录
额度并发争最后一名额、重复请求、升级双实例、停用、失联、重启/恢复同一应用唯一占用;全局不超过许可;超时不错误释放,自动路径同样受控
授权独立无 DSH/SSO 扩展、旧授权过期、托管签名无效或过期免费 10 个逻辑独立;新增及存量规则按托管策略执行
代理隔离伪造身份头、app-proxy 尝试写 intent、egress 不合法目标、预览访问正式数据最小身份有效;旧全量 manager 不在 egress 镜像中
合并形态构建/快照高负载时代理 HTTP/WS/CONNECT;管理端口访问控制;进程重启与连接排空对所选进程组合明确资源上限和中断行为;不把线程池或多监听端口当成进程级隔离
国产化与离线支持矩阵内的 JVM、SQLite native 库、镜像、pip/npm、SDK 分发在目标架构和无外网条件真实验证,缺依赖能定位,不用开发机成功替代
状态切换与回退旧实例接管、非空账本、异常挂载、回退、双写防护单一编排所有者、数据不丢失或错挂、未知事实先隔离后处理
+

上述是实施验收集合,本次没有运行新服务、客户 Docker/K8s、离线安装或授权压力测试。现有 Gateway 测试也未在此次目录分析中重跑。

+

9.2 交付物

+

商业仓库交付多模块源码及普通共享库、独立 Gateway/runtime 镜像、API 与错误码契约、MySQL/DM 元数据迁移脚本、状态导入/导出和校验工具、Compose 配置及离线镜像清单、支持架构列表、兼容矩阵和升级/回退手册。配套 egress/app-proxy 镜像由 BiSheng 开源仓库构建,完整交付包按版本和 digest 引用,不改变其源码归属。

+

离线包需要分别准备管理服务镜像、应用运行基础镜像、应用依赖来源、平台 SDK 及存储/元数据库配置。Java manager 不依赖 Python 解释器运行自身,不等于被托管的 Python 应用不需要解释器或依赖。K8s、应用包和自带镜像属于单独交付范围。

+

+

10 开发前的决策

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
事项本方案建议对实施的影响
目标语言和归仓路线独立 Java runtime 为目标,先做 P0;Python 仅作受控参考/过渡决定驱动、模板、数据操作和功能对齐的投入
仓库布局商业仓库仅新增 runtime 应用及共享库;egress 独立模块留在 BiSheng目录重组和脚本先行,避免把 runtime 依赖引入网关
进程组合保持原 AC-22/AC-60 时 manager 与代理分开;两代理合并可评估,三合一需另行确认可用性或高可用方案影响代理移植、镜像组合、权限、线程/连接资源及重启可用性;不由 Git 或 Maven 模块数量决定
运行元数据独立 runtime 元数据域,可复用已有 MySQL/DM 服务;首版单活动写入者新增连接、DDL 与备份前置;后续多节点不靠 JSON 计数
代理范围app-proxy、egress-proxy 均留在 BiSheng 开源仓库;egress 拆独立精简镜像商业 runtime 管理策略并对齐协议,代理不承担商业授权或应用额度控制
首版范围当前 Docker 完整功能+确认的存储解耦+许可额度闭环避免将 K8s、应用包和框架大升级一起成为首发依赖
授权到期与预览独立 hosting 策略,存量超额/到期行为和预览口径按原评审定案需要产品规则后才能冻结对应恢复、升级和回收实现
+

按上述建议推进时,下一阶段先形成两仓库的接口契约、元数据、进程组合和切换设计,再开始工程重组与实现。变更是在现有 Git 仓库内组织工程;服务启动权限、发布上线入口和商业额度控制仍由所选运行边界约束。

+

+

11 代码和官方资料

+

11.1 Gateway 固定基线

+

以下 Gateway 链接固定到本次本地检出的提交;私有仓库需要读取权限。

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
编号代码入口说明
G01pom.xml单模块、Java/Spring 版本与依赖
G02GatewayApplication.java启动与 Mapper 扫描范围
G03scripts/deploy.shMaven 根目录构建及产物位置
G04LicenseExpiredGlobalFilter.java到期规则及独立能力路由归属
G05DshEntitlementService.java严格签名 schema、audience 及旧授权绑定
G06DshLicenseState.javaDSH 内置 10 席位与有效授权状态
G07DshSeatRepository.javaMySQL/DM 短事务及容量控制
G08Dockerfile、docker/docker-compose.yml当前单进程镜像与服务示例
+

11.2 BiSheng 固定基线

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
编号代码入口说明
B01src/runtime-manager/runtime_manager/原 manager 完整职责和 API
B02docker/docker-compose.ymlmanager、egress、app-proxy、socket proxy 的部署关系
B03runtime_manager/appdb.pySQLite 迁移、一致性快照及原 MinIO 保存路径
B04runtime_manager/api/storage.py当前应用附件接口与令牌校验
B05bisheng_sdk/_storage_remote.pySDK 句柄和错误信封兼容要求
B06runtime_manager/egress.py出站策略持久化及宿主网络前置
B07runtime_manager/auth.py当前内部 HMAC 契约
B08features/v3.0.0/059-k8s-runtime-backend/spec.mdK8s 规格范围
B09src/app-proxy/app_proxy/main.py现有 Python 应用入口、HTTP/WS 与连接控制
B10054-app-domain-runtime/spec.mdAC-22 重启可用性及 AC-60 独立启停要求
B11054-app-domain-runtime/design.mdD5 专用入口服务与开源/商业版通用行为
B12runtime_manager/egress_proxy.py独立出口、无编排接口及持久策略文件依赖
B13app_proxy/clients.py路由查询和短缓存的当前实现边界
+

存储职责和原实现的完整评审见同目录 Vibe 应用托管评审。本方案继承其中已确认的业务边界;源包/应用镜像/应用包路线仍按原评审区分。

+

11.3 官方技术资料

+ +
此 HTML 由同目录 Markdown 生成,正文以 Markdown 为维护依据。流程图已内嵌为 SVG,无在线渲染依赖。
源文 SHA-256:c9c87e983023b858fe35dce701855d9e7ae340ceee84d49f3270db7c9fc19182
diff --git a/docs/reviews/bisheng-gateway-runtime-manager-implementation-plan.md b/docs/reviews/bisheng-gateway-runtime-manager-implementation-plan.md new file mode 100644 index 0000000000..f29956e320 --- /dev/null +++ b/docs/reviews/bisheng-gateway-runtime-manager-implementation-plan.md @@ -0,0 +1,533 @@ +# bisheng-gateway 商业托管模块同仓库实施方案 + +日期:2026-10-19 | 状态:实施方案草案 | 范围:仓库组织、运行进程、功能迁移、授权与交付 + +> **历史参考,已被替代(2026-10-19):** Vibe 已确定全部按开源实现,代码继续留在 BiSheng 仓库。本文中的迁入 Gateway、Java 商业 runtime、授权和固定上线额度建议不再作为实施要求;当前有效方案见 [Vibe 应用托管全开源技术方案](vibe-app-open-source-technical-design.html)。下文保留原评审记录。 + +在现有 `bisheng-gateway` Git 仓库内增加完整托管编排实现,将当前单 Maven 应用重组为多模块工程,Gateway 与托管服务分别构建、启动和升级。这是既有仓库内的工程组织调整,不涉及新建或合并 Git 仓库。先完成现有 Docker 功能及已确认的存储解耦,再扩展应用包和 K8s。 + +**已经确认的边界:** 附件接口归 BiSheng backend;manager 保留本地 SQLite 备份、迁移和恢复,通过 backend 下载源码、上传快照,不直接访问 MinIO;免费同时在线 10 个应用,停用释放名额。前文按代理与 manager 分进程展开,本轮进一步比较三者合并部署;分进程是可选运行形态,不是技术硬性要求,见 [§2.4](#process-options)。Java 重实现、元数据库部署方式和分阶段切换是本方案建议,进入开发前需完成对应设计。 + +本方案基于本地 Gateway `feat/dsh-access@faed249a96ea43a4c65b90765dd4054bf6e55722`,工作区检查时无改动;runtime 功能清单以 BiSheng `feat/3.0.0-vibe@736ef593f3fc6e1634008c842d2604304d6f70a6` 为基线。Gateway 基线是当前本地检出,不代表远端最新提交。本文交付方案,不调整这两个项目的业务代码、POM、配置或运行进程。 + +## 阅读导航 + +- [1 当前仓库与改造前置](#section-1) +- [2 目标目录与进程](#section-2) +- [3 Maven 构建与依赖隔离](#section-3) +- [4 完整 runtime 的功能迁移](#section-4) +- [5 平台协议与存储解耦](#section-5) +- [6 授权与运行状态](#section-6) +- [7 分阶段实施](#section-7) +- [8 切换与回退](#section-8) +- [9 验收和交付](#section-9) +- [10 开发前的决策](#section-10) +- [11 代码和官方资料](#section-11) + + + +## 1 当前仓库与改造前置 + +### 1.1 当前目录 + +```text +bisheng-gateway/ +├── pom.xml # 单个 gateway 可执行工程 +├── Dockerfile # COPY gateway.jar,启动一个 JVM +├── src/main/java/com/dataelem/gateway/ +│ ├── GatewayApplication.java +│ ├── controller/ filter/ config/ # 网关、SSO、到期控制等 +│ ├── service/ mapper/ sdk/ +│ └── dsh/ # DSH 控制器、授权、席位及持久化 +├── src/main/resources/ +├── src/test/java/ +├── docker/ +│ ├── docker-compose.yml # 当前示例仅声明 gateway +│ ├── gateway/config/application.yml +│ └── db/ # MySQL 和 DM SQL +├── scripts/ # 打包、上传、重启和本地启动脚本 +└── docs/ # Gateway 与 DSH 说明 +``` + +根 POM 的 artifact 为 `gateway`,采用 Java 17、Spring Boot 3.2.6、Spring Cloud 2023.0.1,包含 Spring Cloud Gateway、Reactor 集成及 MyBatis、MySQL/DM 驱动。根 POM 尚无 `` 或聚合工程配置。[G01] + +`/Users/zhangguoqing/works/bisheng-gateway` 已经是 Git 仓库。Git 管版本,Maven 模块管构建,进程管运行边界;增加 Maven 子模块不会创建子 Git 仓库,模块数量也不决定进程数量。 + +### 1.2 必须一起处理的现有耦合 + +| 当前实现 | 改造影响 | 实施要求 | +| --- | --- | --- | +| 单个根 POM 管理全部依赖 | 新 runtime 若直接加到根 `src`,会进入 Gateway 同一个 classpath 和启动范围 | 将两个应用分开,不以 profile 切换同一份完整应用来模拟独立服务 | +| `GatewayApplication` 扫描 `com.dataelem.gateway`,并注册 Mapper | 新服务误用扫描范围会带起 SSO、DSH、旧数据库配置及定时任务 | runtime 使用 `com.dataelem.runtime` 独立启动包;共享库不自动扫描 Gateway 组件 [G02] | +| `deploy.sh` 在根目录执行 Maven,寻找 `target/gateway-*.jar` | 移动源码后旧脚本找不到产物,或误发其他模块 JAR | 修改 Shell、PowerShell、本地启动和 Docker 打包入口,保留 Gateway 产物名称和外部启动约定 [G03] | +| 旧授权到期 WebFilter 会处理未单独分类的网关自有 API | 新 hosting 路由可能被旧 SSO 授权误拦,影响免费 10 应用 | 增加精确路由归属和独立 hosting 授权规则,覆盖缺少/过期旧授权的测试 [G04] | +| DSH 验签与内置 10 席位在 `dsh` 包中 | DSH 的 audience、格式、人数及回收规则不是应用托管授权 | 抽密码学基础接口,保留 DSH 协议适配;另建 hosting 授权模型 [G05] [G06] | +| DSH 已有 MySQL/DM 事务及幂等操作记录 | 可借鉴短事务、并发控制方式,但表和语义不能直接复用 | 托管使用自己的运行任务、应用占用和所有权记录,独立验证两种数据库 [G07] | + +当前检出未发现 `.github` 工作流目录;不能把现有脚本中的 `-DskipTests` 当作发布验证。实施时需要明确实际使用的 CI 系统、制品仓库及镜像发布入口,并将下文的构建与验收要求落到该系统。 + + + +## 2 目标目录与进程 + +### 2.1 推荐目录 + +```text +bisheng-gateway/ +├── pom.xml # 聚合父 POM,packaging=pom +├── apps/ +│ ├── gateway/ +│ │ ├── pom.xml # artifactId=gateway +│ │ ├── Dockerfile +│ │ └── src/ # 现有 Gateway 源码与测试迁入 +│ └── runtime-manager/ +│ ├── pom.xml +│ ├── Dockerfile +│ └── src/ +│ ├── main/java/com/dataelem/runtime/ +│ │ ├── RuntimeManagerApplication.java +│ │ ├── api/ application/ domain/ +│ │ ├── infrastructure/ +│ │ │ ├── docker/ platform/ persistence/ sqlite/ +│ │ │ └── egress/ +│ │ └── config/ +│ ├── main/resources/templates/ +│ └── test/ +├── libs/ +│ ├── license-core/ # 通用验签、受信公钥、时钟等;普通 JAR +│ └── runtime-contracts/ # Java DTO、错误码与协议版本;普通 JAR +├── contracts/ +│ ├── runtime/ # 语言无关 schema、请求/响应和签名样例 +│ ├── platform-artifacts/ # backend 文件传输协议 +│ └── egress-policy/ # 与开源代理对齐的策略样例和兼容测试 +├── deploy/ +│ ├── compose/ # 内部网络、服务和配置示例 +│ ├── config/ # Gateway/runtime/egress 分别配置 +│ ├── db/mysql/ # 商业 runtime 元数据 SQL +│ ├── db/dm/ +│ └── offline/ # 镜像、配置、校验清单和安装次序 +├── scripts/ # 按服务选择构建和发布 +└── docs/ # 架构、协议、升级、故障恢复和版本矩阵 +``` + +第一轮只设四个 Maven 模块:`apps/gateway`、`apps/runtime-manager`、`libs/license-core`、`libs/runtime-contracts`。Docker/K8s 适配先通过 runtime 内部接口与包隔离;实际出现独立复用或发布需求后,再拆 Maven 模块。egress-proxy 继续留在 BiSheng 开源仓库,独立管理依赖和镜像,不进入商业仓库的源码或 Maven 构建。 + +该目录不要求修改仓库名称;同一个 Git 提交可对应多个独立制品,运行时依赖由服务协议表达。 + +### 2.2 进程及代码归属 + +下表是独立组件部署形态下的建议,代码归属与运行形态分别判断。合并部署时,组件仍保持逻辑模块,具体取舍见 §2.4;不能把表中的“独立进程”当成架构必须条件。 + +| 组件 | 建议代码归属 | 运行方式和权限 | +| --- | --- | --- | +| Gateway | `bisheng-gateway/apps/gateway` | 独立 JVM,继续负责入口、SSO/DSH;增加 hosting 内部 API 路由及授权展示,不持编排凭据和应用数据卷 | +| runtime-manager | `bisheng-gateway/apps/runtime-manager` | 独立 JVM/容器,管理托管任务、生命周期、额度、路由和本地 SQLite;仅内部开放 API,建议保留 8091 服务端口 | +| egress-proxy | BiSheng 开源仓库;目标拆为独立模块和镜像 | 独立进程,负责策略读取和 HTTP/CONNECT 转发;商业 runtime 生成、更新及撤销策略,不将代理转发实现商业化 | +| app-proxy | BiSheng 开源仓库 | 独立进程,继续调用 backend 授权、runtime 路由;接口兼容即可,不因仓库合并而要求改写 | +| docker-socket-proxy | 引用第三方镜像,不复制其源码进业务模块 | 独立容器,持 Docker socket;runtime 经其 Engine API 编排 | +| backend / Celery / SDK | BiSheng 仓库 | 平台业务、权限、附件、产物/快照存储和应用侧 SDK;与商业仓库通过版本化协议协作 | + +因此,Docker 部署仍有 Gateway、runtime、app-proxy、egress-proxy 和 socket proxy 等独立服务。商业仓库收拢托管编排与商业授权实现,部署清单引用开源代理制品,不把代理源码迁入商业仓库,也不把这些进程塞进 Gateway 的 JVM。 + +**分进程交付时,egress 使用独立精简镜像,源码保持开源。** 当前代理代码位于 BiSheng `src/runtime-manager/runtime_manager/egress_proxy.py`,并使用原 Python runtime-manager 镜像的另一入口。建议在 BiSheng 仓库内拆出 `src/egress-proxy/` 目标模块;独立镜像只包含代理、策略读取和必要依赖,不包含 intent API、builder、lifecycle、reconciler 或可启动的完整 manager。该拆分解决交付边界,不改变代理的开源归属;既有公开的 manager 源码也不会因镜像精简而消失。[B01] [B02] + +策略协议及代理侧行为在开源仓库维护,商业仓库只保存兼容样例与测试,并实现运行时策略生成、注册和撤销。商业化价值及额度控制位于编排执行,不依赖将通用代理闭源。 + +### 2.3 实现语言与过渡选择 + +推荐以 Java 17 独立 runtime 为目标,与当前 Gateway 工程体系一致;Spring Boot 版本升级作为另一个受控变更,避免同时引入目录重组、框架大升级和行为重写。目标 JDK/依赖版本须在开发启动时重新核定支持与安全要求。 + +若短期仅做代码归仓,也可将 Python manager 放入 `services/runtime-manager-python`,保持独立构建;但这只完成仓库迁移,不会自动满足 Java 交付或代码保护目标。该路径可作为受控迁移环境,不建议成为另一套长期、无限制的完整执行入口。 + + + +### 2.4 代理与 manager 合并进程的可行性 + +**app-proxy、egress-proxy 和 runtime-manager 可以合并为同一进程。** 三者分别是应用入口反向代理、出站正向代理和编排控制模块,不存在要求它们必须有不同 PID 的协议限制。同一 JVM 可以启动多个监听器和执行器;Reactor Netty 提供配置监听地址和端口、绑定服务器的能力,具体三个服务的集成属于新增设计。[Reactor Netty 官方说明](https://projectreactor.io/docs/netty/release/reference/http-server.html) + +```text +Gateway JVM # 现有网关,继续独立 + +托管服务 JVM # 三合一候选形态 +├── app-proxy 模块 :8090 # HTTP / WebSocket 入口 +├── egress-proxy 模块 :3128 # HTTP / CONNECT 出站代理 +└── runtime-manager 模块 :8091 # 内部管理 API、构建/迁移任务与对账 + +应用容器 / Pod # 应用代码仍在各自隔离执行体内 +``` + +端口为示意,可以复用当前服务端口;同一进程监听多个端口不代表全部端口都对外发布。管理接口仍需单独绑定/网络策略、服务认证和调用角色限制,不能因为代理端口面向应用开放,就让应用获得 intent API。manager 经 socket proxy 调 API 仍拥有有效编排权限;同进程模块共享运行身份、内存和凭据,线程池或端口分开不能形成进程级权限隔离。 + +| 运行形态 | 收益 | 代价与适用条件 | +| --- | --- | --- | +| 三者合成一个托管进程 | 服务数量最少;路由/策略可在进程内协作;部署配置较少 | 共享内存、有效权限和重启周期;构建/迁移负载及 OOM 可影响代理;升级需处理 HTTP/WebSocket 与 CONNECT 连接中断 | +| 两个代理合为一个进程,manager 独立 | 从三个进程减为两个;开源代理与商业编排仍分别运行;manager 重启不直接关闭代理连接 | 两个代理共享故障和连接资源;需整合现有两个 Python 组件及各自配置,验证资源竞争和隔离 | +| 三个进程独立 | 权限、资源和升级边界清楚,可分别扩缩容及排障 | 服务和协议配置较多;路由及策略同步需单独管理 | + +当前 app-proxy 和 egress-proxy 都是 Python,规划中的新 manager 是 Java。保留现有代理实现时,放到同一镜像、容器或 Pod 里仍然是多个进程。若要求单 JVM,必须将 HTTP/WebSocket 入口、身份头处理、撤权断连、CONNECT 隧道、DNS/IP 校验和流控等代理功能移植到 JVM 实现;这部分工作量不能省略,也不能认为普通 Gateway 路由已完整覆盖这些行为。[B01] [B02] [B09] + +采用三合一时,逻辑模块继续解耦:代理使用独立事件循环、连接池和有界缓冲,构建/SQLite 操作使用有界后台执行器;为并发连接、文件传输和构建分别设限。进程重启会同时影响三个模块,需要明确排空、恢复与可用性目标。开源代理源码的归属与发布条件需在合并构建方案中保持清楚,不能因同一进程就将代理功能整体认定为闭源商业逻辑。 + +**结合原需求的建议:** 若保留 F054 AC-22“运行时服务重启时已运行应用继续对外服务”和 AC-60“运行时与 app-proxy 可独立启停”的承诺,应让 manager 与代理流量进程分开;两个代理是否合并仍可单独评估。三合一在协议和实现上可行,但属于运行边界调整,不能视为完全保持原需求的等价替换;需要先确认可接受的连接中断,或设计并验证额外高可用机制。P0 应同时比较改造量、权限、运行开销和原规格的兼容性,后续制品/进程组合随该决策调整。[B10] + +### 2.5 原实现拆分的依据 + +原设计将编排控制、应用入口转发和出站转发分开,主要依据是控制面重启与权限变化不应直接影响应用流量。此处说明设计意图及已有实现边界,不把规格中的承诺等同于已经完整实测通过。 + +| 依据 | 原实现中的落点 | 对合并方案的约束 | +| --- | --- | --- | +| 运行稳定性 | F054 AC-22 要求 runtime 服务重启时应用继续对外服务,AC-60 要求 runtime 与 app-proxy 可独立启停;egress 代码明确只依赖持久策略文件,不依赖 manager 一直在线 | 三合一重启会关闭同进程的入口/出口连接,需要变更运行承诺或另做高可用设计 [B10] [B12] | +| 编排权限和网络攻击面 | manager 负责容器操作,意图 API 在内部;egress 面向应用网络,只提供代理协议,Compose 刻意不给它 manager HMAC secret 和 Docker host 配置 | socket proxy 收窄的是 Engine API,不会消除 manager 的有效编排能力;分端口不能替代进程权限边界 [B02] [B12] | +| 专用入口与出口职责 | app-proxy 处理平台授权、可信头/OBO 注入、HTTP/WS 和拒绝页面;egress 处理正向代理、目标域名与解析地址校验;manager 维护路由与生命周期 | 职责可以合并实现,但各自鉴权和协议边界仍要完整保留;原 D5 还要求开源/商业版共用入口行为 [B11] [B12] | + +实际实现也有需要验证的边界:app-proxy 的路由仍查询 manager,只缓存约 3 秒;缓存失效后 manager 不可达会进入恢复处理。因此,分进程确实使既有转发连接不因 manager 退出而直接被关闭,egress 也可继续按已保存策略工作,但不能据此宣称当前版本已保证任意时长 manager 故障下所有新请求都不受影响。原路由依赖应在迁移验收中单独覆盖。[B13] + + + +## 3 Maven 构建与依赖隔离 + +### 3.1 模块依赖 + +```text +apps/gateway + ├── libs/license-core + └── libs/runtime-contracts + +apps/runtime-manager + ├── libs/license-core + └── libs/runtime-contracts + +BiSheng/src/egress-proxy # 开源仓库内的目标独立模块 + └── 版本化策略协议,与商业仓库的兼容样例互验 +``` + +Gateway 不依赖 runtime 应用 JAR;runtime 不依赖 Gateway 应用 JAR。`license-core` 不引入 Gateway controller、DSH 席位服务、数据源、Spring Web starter 或整套 `LicenseLoader`。`runtime-contracts` 只表达协议,不包含部署执行器、数据库访问或授权决策。 + +Gateway 保持 WebFlux;runtime 建议采用独立 Spring MVC 应用,将阻塞的 Engine、文件、SQLite 操作放入有界任务池。不能把两个 Web starter 都放入公共依赖后期待自动选对框架:Spring Boot 3.2.6 在两者同时存在时默认选择 MVC。[Spring Boot 官方说明](https://docs.spring.io/spring-boot/docs/3.2.6/reference/html/web.html#web.reactive) + +### 3.2 构建规则 + +父 POM 使用 `dependencyManagement` 和 `pluginManagement` 管版本及插件配置;两个应用分别声明 Boot 打包插件,公共库产生普通 JAR。先保持 Gateway 的 artifactId 和外部镜像名称约定,内部产物路径改由脚本显式选择。 + +```shell +# 以下是目标结构的示例命令,当前单模块仓库尚不能这样执行。 +mvn -pl apps/gateway -am verify +mvn -pl apps/runtime-manager -am verify +mvn verify +``` + +Maven reactor 根据模块依赖排序,支持构建指定模块及其依赖;选定模块的命令应在父工程运行。[Maven 官方说明](https://maven.apache.org/guides/mini/guide-multiple-modules.html) + +| 文件或入口 | 改造方式 | 检查点 | +| --- | --- | --- | +| 根 `pom.xml` | 改为父工程;将现有业务依赖移到 gateway 子 POM | runtime 不被传递引入 Sa-Token Reactor、SSO 或 DSH 业务配置 | +| 根 `src/` | 用保留 Git 可追踪关系的移动迁入 `apps/gateway/src/` | Java 包名、资源路径、Mapper XML 与测试资源仍可用 | +| 根 `Dockerfile` | 调整为明确构建 Gateway 的兼容入口,或以模块 Dockerfile 替代并同步发布脚本 | 不再依赖人工将任意 JAR 放成根 `gateway.jar` | +| `deploy.sh`、`deploy.ps1` | 指定 gateway 模块、准确产物路径;新增 runtime 发布入口 | 不使用递归通配符从多个模块中猜选 JAR | +| `start-local-stack.ps1` | 指定 gateway 模块启动,runtime 使用独立启动命令 | 两个进程可分别调试,端口与配置互不覆盖 | +| 镜像与离线发布 | 商业仓库构建两个 Java 服务,BiSheng 构建开源 egress 镜像;清单记录各自 Git SHA、镜像 digest 和协议版本 | 更新 runtime 不要求同步替换 Gateway 或代理,兼容范围内可分别回退 | + +共享库变化需验证所有消费者。源码统一提交,制品是否一起升级由兼容矩阵决定;离线交付通过版本清单锁定一组经过验证的服务镜像。 + + + +## 4 完整 runtime 的功能迁移 + +### 4.1 Python 到 Java 的功能对应 + +“完整实现”以已落地的 Docker manager 行为为范围,同时落实确认的存储边界;附件和 MinIO 直连从职责中迁出。不能只把容器 create/start 包成 Java 接口即宣告迁移完成。 + +| 原模块 | Java 目标职责 | 必须保留或调整的语义 | +| --- | --- | --- | +| `api/intents.py`、`api/schemas.py`、`auth.py` | API、DTO、服务认证、操作幂等与任务提交 | 参数默认值、错误码/HTTP 状态、超时和签名样例跨语言对齐;区分读写调用者 | +| `builder.py`、`source_facts.py`、`templates/` | 源码检查、环境解析、模板渲染、构建任务和日志 | Python/Node/static 模板、入口识别、锁文件/依赖层、构建网络及受保护参数;Jinja 模板改写后需比较结果 | +| `docker_backend.py` | `ContainerBackend` 接口及 Docker 实现 | 通过 socket proxy 调 Engine API;构建流、inspect、日志、网络、删除和错误分类;不要求宿主 Docker CLI | +| `admission.py` | 资源容量准入 | CPU/内存保留和并发准备资源;与商业应用数分开判断、分别报错 | +| `lifecycle.py` | 部署、更新、停止、销毁和实例退休 | 固定安全容器参数、挂卷、环境保留字段、出站主体、探活后换路由及旧实例清理 | +| `desired_state.py`、`reconciler.py` | 持久期望、实例接管、故障恢复与对账 | 实例标签、generation、未知实例隔离、重启恢复;自动创建路径也执行授权及占用检查 | +| `probe.py`、`routing.py`、只读 API | 临时探活、正式就绪、路由、状态、自检和日志 | 临时实例不使用正式卷;app-proxy HTTP/WS 查询契约;坏 Engine 不导致读接口完全无法启动 | +| `preview.py` | 审批预览生命周期 | 临时库、独立策略、到期清理、无生产附件凭据;预览资源限制独立于正式名额 | +| `appdb.py`、`api/appdb.py` | SQLite 数据查询/导出、本地备份、迁移及恢复 | 标识符白名单、事务/幂等迁移、一致性 Backup API、所有权和卷路径;业务权限仍由 backend 判断 | +| `egress.py` | 出站策略生成、注册、撤销及安装前置检查 | 与独立 egress 服务保持策略文件兼容;必要防火墙规则由部署流程受控安装,不给业务应用额外宿主权限 | +| `storage.py`、`api/storage.py` | 从 manager 删除;由 backend 承接 | 附件句柄、路径隔离、大小限制、错误信封及令牌校验迁移;manager 不再接收附件数据流 | +| `config.py`、观测和入口 | 独立配置、任务日志、指标、健康和停机行为 | RTM 配置迁移表、敏感信息脱敏、任务恢复;没有 MinIO 密钥/bucket 配置 | + +基线模块及测试清单见 [B01]—[B07]。现有 Python 测试用作行为规格和案例来源;Java 必须重新建立 API 契约、领域及真实执行后端测试,不能只统计原测试数量。 + +### 4.2 关键实现边界 + +**任务与资源。** 构建、部署、迁移采用持久操作记录和有界执行池,明确排队、并发、取消、超时和进程重启后的处理。HTTP 接收线程不承担整个构建过程;数据库短事务中不执行拉镜像、上传文件或等就绪等远程动作。 + +**模板与系统操作。** 对源码安全解包、模板生成、环境变量覆盖规则和命令参数逐项迁移;不执行用户提供的任意宿主命令。Docker 适配器负责 Engine 差异,领域层只使用固定的部署模型。镜像、网络和数据卷清理只匹配受管标签及当前所有者。 + +**SQLite 与国产化。** Java 不消除 SQLite 的本地库依赖。Xerial JDBC 使用 native 库并公布平台列表,选定版本须核对实际 OS、CPU、libc 和动态库加载条件;不能由 JAR 通用性推断龙芯、MIPS 等未验证环境可用。将打开 WAL 库、Backup API、DDL 事务、文件权限和恢复列为最早的原型检查。[SQLite JDBC 官方说明](https://github.com/xerial/sqlite-jdbc) + +备份使用一致性备份 API,不以复制活跃 `app.db` 替代。若目标架构缺少可用驱动,应先交付并验证对应 native 依赖,或另行评审受限本地执行器方案;不得通过不一致文件拷贝绕过该前置。[SQLite 官方备份说明](https://www.sqlite.org/backup.html) + +**K8s。** 第一阶段只定义执行后端接口并交付 Docker。现有 BiSheng K8s 内容属于规格;真正增加 K8s 还要处理构建器、仓库、RBAC、PVC、节点数据访问和网络策略,不能将 Docker 客户端替换视作完整 K8s 实现。[B08] + + + +## 5 平台协议与存储解耦 + +### 5.1 三类调用各有边界 + +| 链路 | 目标接口与身份 | 归属 | +| --- | --- | --- | +| backend/worker → Gateway → runtime | 托管操作入口、服务认证、请求关联;runtime 校验实际操作权限、许可和占用 | Gateway 是门面,runtime 是执行与运行事实来源 | +| app-proxy → runtime | 路由和必要只读状态;使用单独只读身份 | 不能拿与 backend 相同的全能力密钥去调用 intent API | +| runtime → backend | 源码下载、快照上传/查询/恢复下载;服务身份和限定任务授权 | backend 返回文件流,操作对象由平台校验,不把 bucket/key 下发 | +| 应用 SDK → backend | 每应用附件句柄、能力凭据和访问者上下文 | 附件由 backend 处理;应用得不到 runtime 管理凭据或快照任务授权 | + +Gateway 新 hosting API 建议使用明确的内部路径前缀,并与现有到期过滤器分开归属。Gateway 校验上游服务身份后,按下游方法、路径和请求体摘要重新签署对 runtime 的请求;路径改写后不能原样转发按旧路径生成的 HMAC。最终路径和字段在协议设计中冻结。 + +### 5.2 保持兼容的范围 + +先对齐现有 `/v1/intents/build`、build 查询、deploy/stop/destroy/probe/preview、db-migrate、路由、日志和数据库操作的字段及返回行为。新版产物引用、幂等任务和调用方权限通过显式版本协商或兼容适配加入;不能悄悄将同步返回改成 202 而不改调用者。 + +`contracts/` 保存 Python 与 Java 都可消费的固定请求、响应、UTF-8 原始请求体、签名及错误样例;覆盖路径编码、空 body、中文、浮点资源参数、缺省字段和签名失败。SDK 当前识别 manager 风格的错误信封,backend 附件迁移需提供兼容适配或发布 SDK 更新。[B04] [B05] [B07] + +新存储协议至少覆盖:产物内容下载;快照任务创建、上传、状态查询和恢复下载;附件对象与元信息操作。具体路径沿用存储评审中的草案继续设计,不把草案名称认定为现有接口。 + +### 5.3 发布和快照链路 + +```mermaid +sequenceDiagram + participant Backend as Backend / Celery + participant Gateway as Gateway + participant Runtime as Java runtime + participant Engine as Docker Engine + participant Store as MinIO + Backend->>Store: 1 保存源码并建立产物引用 + Backend->>Gateway: 2 提交构建任务和下载授权 + Gateway->>Runtime: 3 认证转发;记录幂等操作 + Runtime->>Backend: 4 通过平台接口请求源码内容 + Backend-->>Runtime: 5 流式返回;下载校验摘要 + Runtime->>Engine: 6 构建并执行临时探活 + Backend->>Gateway: 7 审批后提交迁移和上线任务 + Gateway->>Runtime: 8 转交冻结版本及任务授权 + Runtime->>Runtime: 9 预检资源及许可;预占或复用应用名额 + Runtime->>Runtime: 10 隔离写入;本地生成 SQLite 一致快照 + Runtime->>Backend: 11 上传快照并等待持久确认 + Backend->>Store: 12 保存快照对象 + Backend->>Backend: 13 持久记录平台快照元数据 + Backend-->>Runtime: 14 返回已保存状态与 snapshot_id + Runtime->>Runtime: 15 本地迁移;上线前重验许可及占用 + Runtime->>Engine: 16 启动探活、切换路由、退休旧实例 + Runtime-->>Gateway: 17 返回执行结果和任务状态 + Gateway-->>Backend: 18 返回平台更新与对账所需结果 +``` + +图中的持久化元数据由 backend 写平台数据库,MinIO 保存对象内容。发布任务需要先完成资源及许可预检,再进入停写和迁移;最终执行前仍须重新校验。首次上线名额预占的准确时点、迁移失败补偿与升级复用策略按下一节实现,不能让迁移和运行结果被当成单一事务。 + +源码和快照 API 直接传输文件内容,不返回或重定向到 MinIO 预签名 URL;manager、准备任务和初始化执行体的网络权限中不包含对象存储。平台凭据绑定应用、版本/任务、操作、有效期、摘要及幂等标识,并限制下载目标,避免将任意 URL 变成下载入口。 + +快照的对象和元数据均确认保存后才执行破坏性 DDL;失败或响应丢失时查询任务、幂等重试,不能继续迁移。backend 与 runtime 采用任务协调和流式传输,避免请求线程互相等待形成资源耗尽。附件已从 runtime 移除,应用 SDK 直接访问 backend。 + + + +## 6 授权与运行状态 + +### 6.1 可以复用和必须新增的能力 + +`license-core` 提供受信公钥、签名校验、时间处理、严格字段解析等基础能力;保留 DSH 自己的适配器、schema、audience 和席位模型。新增 `HostingLicenseService`,使用独立的托管能力、部署范围和在线应用上限,runtime 启动后本地验签。 + +建议 hosting 扩展授权使用独立文件或独立版本化 envelope。现有 DSH 签名绑定外层旧许可证字段,不能未经兼容设计就在原文中添加字段或扩展其严格 schema。托管授权只交付受信公钥;厂商签名私钥不进入客户仓库或镜像。[G05] + +免费 10 个在线应用由 runtime 内置规则提供,不要求持有 DSH 席位或有效 SSO 授权。Gateway 展示授权及用量,并可提前拒绝超额请求;实际上线、恢复、自动对账和实例接管仍必须在 runtime 验证。许可失效后的超额存量行为沿用原评审的待定事项,不能直接采用 DSH 自动撤销席位的规则。 + +### 6.2 推荐运行元数据方案 + +建议为商业 runtime 增加独立的持久元数据域,存储任务、期望状态、在线名额及执行所有者。可以复用现有 MySQL/DM 服务,使用独立 schema 或明确隔离的表与最小权限账号;runtime 只访问自己的运行元数据,不查询 BiSheng 应用、用户、审批或权限业务表。 + +这会增加 runtime 的元数据库配置和连通性前置,应作为本轮架构决策确认。相比将全局额度只记在本地 JSON 或进程缓存中,该方案更适合任务恢复、并发准入和后续多执行节点。首版建议一个部署范围只有一个活动编排写入者,先完成正确性,再开放多副本主动写入。 + +| 建议记录 | 关键约束 | 用途 | +| --- | --- | --- | +| `runtime_quota_scope` | 部署范围唯一;短事务锁定范围记录后判断和预占 | 同一授权范围共享在线上限,避免每节点各送 10 个 | +| `runtime_app` | `(deployment_scope, app_id)` 唯一;保存当前/候选引用及占用状态 | 升级复用已有应用名额,停止与销毁分别处理 | +| `runtime_operation` | 幂等 ID 唯一并关联 payload hash、阶段、结果和错误 | 响应丢失、重试、进程中断后可查询和恢复 | +| `runtime_owner` | 执行节点、所有权代次及存活记录 | 限定对账操作范围;后续多节点接管需明确旧所有者失效规则 | + +这些名称为设计建议,DDL、长度、索引和锁语义需要同时验证 MySQL/DM。可借鉴现有 DSH 短 SQL 事务及幂等实现,但不复用 `dsh_*` 数据,也不在锁内执行 Engine/文件调用。[G07] + +本地期望状态文件可用于迁移读取和节点执行缓存,不能与数据库同时成为两份可独立修改的权威。新库导入、标签重建及本地文件读写优先级应在切换设计中明确。元数据库不可达时,不启动新应用或放宽额度;对已在线实例的转发可按已验证路由继续服务,恢复后先对账,不能因一次故障自动全量停服或无限创建。 + +### 6.3 名额和操作时序 + +首次上线在进入实际变更前原子预占名额;同一 app 升级复用原占用,临时候选不另算在线应用。构建和审批不占正式名额,预览另设资源/数量/TTL。破坏性迁移前预占成功并不等于应用上线,记录必须保留阶段差异。 + +名额仅在确认候选或正式实例与路由已按动作收敛后释放;网络超时、进程失联、任务 TTL 过期本身不代表应用已经离线。恢复和自愈必须查验既有占用,不得绕过商业许可。多节点实现要同时解决全局账本和执行所有权;数据库租约并不自动阻止旧进程继续调用 Docker API,未完成 fencing 验证前不承诺多活动编排。 + + + +## 7 分阶段实施 + +| 阶段 | 主要工作 | 完成条件和后续依赖 | +| --- | --- | --- | +| P0 技术原型与契约 | 固定代码基线;列齐接口/错误/模板/状态;验证 Java Engine 调用、SQLite 备份/恢复及元数据库锁;比较三合一、两代理合并与三进程形态 | 明确代理保留或移植范围、权限与连接可用性;Docker/SQLite 原型通过,决定进程组合、Java 路线及存储模型后进入 P1 | +| P1 仓库工程重组 | 父 POM、现有 Gateway 子模块、普通共享库、独立配置、脚本和 Dockerfile;先保持 Gateway 行为 | Gateway 单独打包启动;SSO、DSH、路由、WebSocket、授权到期及既有 SQL 行为回归;本阶段不替换生产 manager | +| P2 平台接口与 runtime 骨架 | BiSheng backend 附件/产物/快照接口及 SDK 兼容,在 BiSheng 拆出独立开源 egress 镜像;商业仓库实现 runtime API、认证、任务/元数据、自检和平台传输客户端 | 移除 MinIO 配置后可走 backend 下载/上传;读写调用者隔离;平台与商业仓库按契约互验 | +| P3 Docker 全功能迁移 | 构建、准入、生命周期、路由、预览、数据面、备份/迁移、对账/自愈、出站策略和日志 | 使用隔离应用完成 Python/Node/static、SQLite、更新失败和重启接管;无遗漏的旧 manager 调用 | +| P4 商业授权闭环 | 独立 hosting 许可、免费 10 个、原子占用、升级复用、失败补偿、停止释放、恢复和对账 | 并发第 10/11 个应用、超时重试、重启、失联和旧授权到期情形均通过;不能仅验证手动 deploy 路径 | +| P5 迁移演练与交付 | 旧状态/标签盘点与导入、附件句柄切换、只读影子比较、停写切换、回退演练;离线包和运维手册 | 同一应用只有一个编排所有者;原卷和数据可恢复;版本清单可离线安装;交付说明覆盖兼容范围 | +| P6 独立后续工作 | 应用包路线、用户完整镜像准入、K8s、多活动或多节点编排 | 分别完成需求和设计评审;不计入首轮 Docker 完整迁移已完成的依据 | + +P0 契约工作完成后,P1 与 backend 接口开发可并行推进;P3 的文件路径依赖 P2,P4 可以随 P3 实现但必须在 P5 切换前完成。商业运行包不能以“授权以后再补”为由先开放一套完整无限制执行服务。 + +跨仓库交付应至少有三组关联变更:Gateway 工程重组;BiSheng 平台存储接口与 SDK;商业 runtime 及部署交付。每组列明协议版本和依赖,合并到各仓库约定的开发主线后再集成,避免将 feature 文档完成视为功能已交付。 + + + +## 8 切换与回退 + +### 8.1 切换前准备 + +盘点受管容器、app/version、真实挂载目录、当前/退休/预览实例、路由、出站主体和本地期望状态。备份元数据、状态文件及实际 SQLite 数据;业务能力凭据和附件令牌按敏感数据处理,不能写入迁移报告或普通日志。 + +先在隔离 Engine/应用环境验证新 runtime。对生产数据做影子比较时,只读查询实例和状态,不运行旧、新两套 reconcile 写入循环,不在同一应用数据目录上做双写或迁移。 + +### 8.2 受控切换流程 + +```mermaid +sequenceDiagram + participant Ops as 运维切换任务 + participant Backend as Backend / Gateway + participant Old as 旧 Python manager + participant New as 新 Java runtime + participant Engine as Docker Engine + Ops->>Backend: 1 冻结新发布及迁移,保留正常应用访问 + Ops->>Old: 2 排空任务并停止编排及 reconcile 写入 + Ops->>New: 3 导入已备份状态、名额和执行映射 + New->>Engine: 4 校验实例标签、数据卷和运行事实 + Ops->>Backend: 5 切换 runtime 操作与路由查询地址 + Backend->>New: 6 运行兼容接口检查及受控应用验证 + New->>Engine: 7 取得唯一所有权后开启对账写入 + Ops->>Backend: 8 恢复发布并观察任务与额度状态 +``` + +切换时保留内部服务名可减少配置变化,但必须验证协议、HMAC 及状态已经就绪;不能把 Java 镜像换到旧服务名就直接启动自愈。既有实例在接管时建立合法占用,未知或超额实例进入明确处理流程,不能默认新库为空就再赠送 10 个名额。 + +附件旧地址和令牌需要单独迁移。可通过受控重新部署切换到 backend 句柄,或在限定期限内提供旧地址兼容转发,并由 backend 校验经授权迁移的应用凭据。过渡路径不应继续持有 MinIO 配置,也不能保留完整旧 manager 作为长期附件服务。 + +### 8.3 回退约束 + +保留可回退的服务镜像、配置、数据库变更说明和状态导出格式;不能假定新 runtime 写入的状态旧 Python 可直接读取。回退需要先停止新写入者,再由验证过的转换/导入流程交回所有权。 + +代码版本回退不自动回退 SQLite schema 和已写入的业务数据。破坏性迁移已经完成时,按批准的数据库恢复点和停写窗口操作;快照无法保存或验证时应在迁移前拒绝任务。候选启动失败也要检查应用能力凭据是否已轮换,不能把旧容器还在等同于旧版本完整可用。 + +退回已公开的旧完整 manager 会降低商业控制能力,应作为有期限、可审计的故障处置,并在产品交付中明确支持边界;正式免费托管包仍由同一商业执行实现提供 10 个名额。 + + + +## 9 验收和交付 + +### 9.1 必要验证 + +| 领域 | 必须验证的场景 | 通过条件 | +| --- | --- | --- | +| 工程隔离 | 仅构建/启动 Gateway,或仅构建/启动 runtime | 各自产物和配置正确,公共库不是可执行应用;Gateway 无编排库和数据卷 | +| 既有功能回归 | SSO、DSH 登录/席位、授权到期、HTTP 和 WebSocket 路由 | 行为符合原契约;引入 hosting 不改变其他授权范围 | +| 协议对齐 | 原始签名、编码、DTO 缺省、错误信封、build/preview/db API | Python 调用方与 Java 响应一致;任何不兼容都有明确版本和适配 | +| Docker 生命周期 | 构建、探活、发布、更新、停止、删除、重启收养和孤儿清理 | 只操作受管资源;失败和重试可恢复,路由与实例可对账 | +| 存储隔离 | 禁止 runtime/准备任务直连 MinIO;文件和附件操作正常 | 没有存储 SDK/长期密钥/bucket 依赖或 302 旁路;附件请求不进入 runtime | +| SQLite | 并发写入下的一致备份、迁移独占、快照上传故障、导出、指定恢复 | 快照确认后才做破坏性变更;不跨应用;backend 不挂应用数据库目录 | +| 额度 | 并发争最后一名额、重复请求、升级双实例、停用、失联、重启/恢复 | 同一应用唯一占用;全局不超过许可;超时不错误释放,自动路径同样受控 | +| 授权独立 | 无 DSH/SSO 扩展、旧授权过期、托管签名无效或过期 | 免费 10 个逻辑独立;新增及存量规则按托管策略执行 | +| 代理隔离 | 伪造身份头、app-proxy 尝试写 intent、egress 不合法目标、预览访问正式数据 | 最小身份有效;旧全量 manager 不在 egress 镜像中 | +| 合并形态 | 构建/快照高负载时代理 HTTP/WS/CONNECT;管理端口访问控制;进程重启与连接排空 | 对所选进程组合明确资源上限和中断行为;不把线程池或多监听端口当成进程级隔离 | +| 国产化与离线 | 支持矩阵内的 JVM、SQLite native 库、镜像、pip/npm、SDK 分发 | 在目标架构和无外网条件真实验证,缺依赖能定位,不用开发机成功替代 | +| 状态切换与回退 | 旧实例接管、非空账本、异常挂载、回退、双写防护 | 单一编排所有者、数据不丢失或错挂、未知事实先隔离后处理 | + +上述是实施验收集合,本次没有运行新服务、客户 Docker/K8s、离线安装或授权压力测试。现有 Gateway 测试也未在此次目录分析中重跑。 + +### 9.2 交付物 + +商业仓库交付多模块源码及普通共享库、独立 Gateway/runtime 镜像、API 与错误码契约、MySQL/DM 元数据迁移脚本、状态导入/导出和校验工具、Compose 配置及离线镜像清单、支持架构列表、兼容矩阵和升级/回退手册。配套 egress/app-proxy 镜像由 BiSheng 开源仓库构建,完整交付包按版本和 digest 引用,不改变其源码归属。 + +离线包需要分别准备管理服务镜像、应用运行基础镜像、应用依赖来源、平台 SDK 及存储/元数据库配置。Java manager 不依赖 Python 解释器运行自身,不等于被托管的 Python 应用不需要解释器或依赖。K8s、应用包和自带镜像属于单独交付范围。 + + + +## 10 开发前的决策 + +| 事项 | 本方案建议 | 对实施的影响 | +| --- | --- | --- | +| 目标语言和归仓路线 | 独立 Java runtime 为目标,先做 P0;Python 仅作受控参考/过渡 | 决定驱动、模板、数据操作和功能对齐的投入 | +| 仓库布局 | 商业仓库仅新增 runtime 应用及共享库;egress 独立模块留在 BiSheng | 目录重组和脚本先行,避免把 runtime 依赖引入网关 | +| 进程组合 | 保持原 AC-22/AC-60 时 manager 与代理分开;两代理合并可评估,三合一需另行确认可用性或高可用方案 | 影响代理移植、镜像组合、权限、线程/连接资源及重启可用性;不由 Git 或 Maven 模块数量决定 | +| 运行元数据 | 独立 runtime 元数据域,可复用已有 MySQL/DM 服务;首版单活动写入者 | 新增连接、DDL 与备份前置;后续多节点不靠 JSON 计数 | +| 代理范围 | app-proxy、egress-proxy 均留在 BiSheng 开源仓库;egress 拆独立精简镜像 | 商业 runtime 管理策略并对齐协议,代理不承担商业授权或应用额度控制 | +| 首版范围 | 当前 Docker 完整功能+确认的存储解耦+许可额度闭环 | 避免将 K8s、应用包和框架大升级一起成为首发依赖 | +| 授权到期与预览 | 独立 hosting 策略,存量超额/到期行为和预览口径按原评审定案 | 需要产品规则后才能冻结对应恢复、升级和回收实现 | + +按上述建议推进时,下一阶段先形成两仓库的接口契约、元数据、进程组合和切换设计,再开始工程重组与实现。变更是在现有 Git 仓库内组织工程;服务启动权限、发布上线入口和商业额度控制仍由所选运行边界约束。 + + + +## 11 代码和官方资料 + +### 11.1 Gateway 固定基线 + +以下 Gateway 链接固定到本次本地检出的提交;私有仓库需要读取权限。 + +| 编号 | 代码入口 | 说明 | +| --- | --- | --- | +| [G01] | `pom.xml` | 单模块、Java/Spring 版本与依赖 | +| [G02] | `GatewayApplication.java` | 启动与 Mapper 扫描范围 | +| [G03] | `scripts/deploy.sh` | Maven 根目录构建及产物位置 | +| [G04] | `LicenseExpiredGlobalFilter.java` | 到期规则及独立能力路由归属 | +| [G05] | `DshEntitlementService.java` | 严格签名 schema、audience 及旧授权绑定 | +| [G06] | `DshLicenseState.java` | DSH 内置 10 席位与有效授权状态 | +| [G07] | `DshSeatRepository.java` | MySQL/DM 短事务及容量控制 | +| [G08] | `Dockerfile`、`docker/docker-compose.yml` | 当前单进程镜像与服务示例 | + +### 11.2 BiSheng 固定基线 + +| 编号 | 代码入口 | 说明 | +| --- | --- | --- | +| [B01] | `src/runtime-manager/runtime_manager/` | 原 manager 完整职责和 API | +| [B02] | `docker/docker-compose.yml` | manager、egress、app-proxy、socket proxy 的部署关系 | +| [B03] | `runtime_manager/appdb.py` | SQLite 迁移、一致性快照及原 MinIO 保存路径 | +| [B04] | `runtime_manager/api/storage.py` | 当前应用附件接口与令牌校验 | +| [B05] | `bisheng_sdk/_storage_remote.py` | SDK 句柄和错误信封兼容要求 | +| [B06] | `runtime_manager/egress.py` | 出站策略持久化及宿主网络前置 | +| [B07] | `runtime_manager/auth.py` | 当前内部 HMAC 契约 | +| [B08] | `features/v3.0.0/059-k8s-runtime-backend/spec.md` | K8s 规格范围 | +| [B09] | `src/app-proxy/app_proxy/main.py` | 现有 Python 应用入口、HTTP/WS 与连接控制 | +| [B10] | `054-app-domain-runtime/spec.md` | AC-22 重启可用性及 AC-60 独立启停要求 | +| [B11] | `054-app-domain-runtime/design.md` | D5 专用入口服务与开源/商业版通用行为 | +| [B12] | `runtime_manager/egress_proxy.py` | 独立出口、无编排接口及持久策略文件依赖 | +| [B13] | `app_proxy/clients.py` | 路由查询和短缓存的当前实现边界 | + +存储职责和原实现的完整评审见同目录 [Vibe 应用托管评审](vibe-app-hosting-commercialization-review.md)。本方案继承其中已确认的业务边界;源包/应用镜像/应用包路线仍按原评审区分。 + +### 11.3 官方技术资料 + +- [Maven 多模块与 reactor](https://maven.apache.org/guides/mini/guide-multiple-modules.html):聚合、依赖顺序和按模块构建。 +- [Spring Boot 3.2.6 Web](https://docs.spring.io/spring-boot/docs/3.2.6/reference/html/web.html#web.reactive):MVC/WebFlux 自动配置关系。 +- [Xerial SQLite JDBC](https://github.com/xerial/sqlite-jdbc):native 依赖和支持平台;实施时按选定版本再次核对。 +- [SQLite Backup API](https://www.sqlite.org/backup.html):一致性备份与文件操作边界。 +- [Reactor Netty HTTP Server](https://projectreactor.io/docs/netty/release/reference/http-server.html):监听器地址、端口及服务器生命周期;三模块合并属于本方案的工程推导。 + +[G01]: https://github.com/dataelement/bisheng-gateway/blob/faed249a96ea43a4c65b90765dd4054bf6e55722/pom.xml#L1 +[G02]: https://github.com/dataelement/bisheng-gateway/blob/faed249a96ea43a4c65b90765dd4054bf6e55722/src/main/java/com/dataelem/gateway/GatewayApplication.java#L10 +[G03]: https://github.com/dataelement/bisheng-gateway/blob/faed249a96ea43a4c65b90765dd4054bf6e55722/scripts/deploy.sh#L15 +[G04]: https://github.com/dataelement/bisheng-gateway/blob/faed249a96ea43a4c65b90765dd4054bf6e55722/src/main/java/com/dataelem/gateway/filter/LicenseExpiredGlobalFilter.java#L39 +[G05]: https://github.com/dataelement/bisheng-gateway/blob/faed249a96ea43a4c65b90765dd4054bf6e55722/src/main/java/com/dataelem/gateway/dsh/service/DshEntitlementService.java#L25 +[G06]: https://github.com/dataelement/bisheng-gateway/blob/faed249a96ea43a4c65b90765dd4054bf6e55722/src/main/java/com/dataelem/gateway/dsh/service/DshLicenseState.java#L13 +[G07]: https://github.com/dataelement/bisheng-gateway/blob/faed249a96ea43a4c65b90765dd4054bf6e55722/src/main/java/com/dataelem/gateway/dsh/repository/DshSeatRepository.java#L18 +[G08]: https://github.com/dataelement/bisheng-gateway/blob/faed249a96ea43a4c65b90765dd4054bf6e55722/docker/docker-compose.yml#L1 +[B01]: https://github.com/dataelement/bisheng/tree/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager +[B02]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/docker/docker-compose.yml#L277 +[B03]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/appdb.py#L717 +[B04]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/api/storage.py#L1 +[B05]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/bisheng-sdk/bisheng_sdk/_storage_remote.py#L1 +[B06]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/egress.py#L444 +[B07]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/auth.py#L42 +[B08]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/features/v3.0.0/059-k8s-runtime-backend/spec.md#L108 +[B09]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/app-proxy/app_proxy/main.py#L1 +[B10]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/features/v3.0.0/054-app-domain-runtime/spec.md#L88-L97 +[B11]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/features/v3.0.0/054-app-domain-runtime/design.md#L121 +[B12]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/egress_proxy.py#L1-L42 +[B13]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/app-proxy/app_proxy/clients.py#L281-L307 diff --git a/docs/reviews/build-tenant-scope-fix-2026-10-29.md b/docs/reviews/build-tenant-scope-fix-2026-10-29.md new file mode 100644 index 0000000000..b0fff6cc0c --- /dev/null +++ b/docs/reviews/build-tenant-scope-fix-2026-10-29.md @@ -0,0 +1,68 @@ +# 构建列表租户范围及权限错误码冲突修复 + +## 范围与基线 + +- 分支:`codex/build-tenant-scope-fix`。 +- 从已 fetch 的 `origin/feat/3.0.0@9ef489dc3ab9f6c83ef9f59589d13b575000fd83` 创建独立工作树。 +- 按已确认修复方案走 SDD-Guide §1 缺陷修复轻量流程;恢复现有租户管理权限边界,不新增业务能力、表或数据迁移。 +- 不修改 116/120 部署,不迁移存量应用、服务账号、授权或其他资源,不运行全量测试。 + +## 根因与处理 + +构建页使用 `/api/v1/workflow/list?managed=true&action=use`。`managed=true` 实际将筛选动作设为 `edit`,租户管理员走列表快捷分支,不逐资源做权限过滤。默认数据库读取范围为本租户及根租户,导致根租户资源也被标记为可管理;卡片随后请求 `my-permissions` 时,业务校验按当前租户拒绝根租户托管应用。 + +`WorkFlowService.get_all_flows_envelope` 现仅在“管理列表、租户管理员、非平台超管”条件下,将完整游标扫描包在现有 `strict_tenant_filter()` 中。三类应用的 UNION 子查询沿用既有过滤器,在 SQL 分页前排除根租户和其他子租户资源;本租户其他创建人的应用仍可列出。超管、普通用户及非管理列表保留现有路径。上下文退出或异常时自动恢复,不改变广场共享读取的过滤机制。 + +本修复没有给跨租户资源增加授权。旧应用仍属于原租户,因此创建人迁租户后在新租户管理列表看不到它们,符合本次确认边界。 + +## 错误码 + +权限旧码与信息源模块重复使用同一数字,前端共享文案无法区分。现仅迁移冲突的七个权限码,保留类名和已有 Python 别名,保留 `19000` 权限拒绝和信息源原码: + +| 权限错误 | 原码 | 新码 | +|---|---|---| +| 权限检查失败 | 19001 | 25020 | +| 权限服务不可用 | 19002 | 25021 | +| 资源不存在或不可访问 | 19003 | 25022 | +| 权限投影写入失败 | 19004 | 25023 | +| 权限关系无效 | 19005 | 25024 | +| 权限模型名称重复 | 19006 | 25025 | +| 禁止移除最后一位所有者 | 19007 | 25026 | + +三语文案修改在 `packages/locales/src/api_errors`,由仓库生成器同步 platform/client 产物;权限无效资源不再显示信息源的“解析失败”文案。`25022` 保持缺失资源与跨租户资源相同的外部错误,避免泄露存在性。权限服务不可用的 HTTP 503 语义不变。 + +部署时应同时更新 backend/worker 与前端文案。自行按旧 `19001–19007` 识别权限错误的外部调用方需要同步更新;本次不为旧码增加歧义兼容。历史 E2E 中相应断言已更新,未执行这些远端用例。两份旧 E2E 文件因 Ruff 规范发生格式整理及少量类型/未使用变量修正,测试函数与断言数量未变。 + +## 定向验证 + +使用 Python 3.11 现有虚拟环境,测试配置为不含连接凭据的临时空 YAML;测试不连接真实中间件。 + +| 检查 | 结果 | +|---|---| +| `test_build_managed_tenant_scope.py` | 4 passed;使用真实 `FlowDao`、三类应用 UNION、游标编解码和 SQLite 执行查询;覆盖他人创建的同租户资源、根租户/其他子租户排除、分页、超管范围、非管理列表及异常后上下文恢复 | +| `test_workflow_list_visible_first.py` | 9 passed;保留普通用户权限过滤及管理员路径 | +| `test_permission_error_code_collision.py` | 9 passed;冲突检查、旧信息源码保留、Python 别名、三语与两客户端生成产物、错误响应 | +| `test_f048_decision_api.py` | 5 passed;允许/拒绝、动作错误、权限服务不可用、缺失/跨租户错误。修复原测试缺少身份隔离的 fixture,仍使用真实 decision/registry 和响应代码 | +| `test_filelib_retrieve_facade.py::test_permission_unavailable_is_503_with_25021_not_26030` | 1 passed;权限服务错误仍映射为 503 | +| Ruff format/check | 所有本次改动 Python 文件通过 | +| locales 生成产物检查、check-i18n | 通过,无新增三语漂移 | +| 架构守卫 | 无新增提示;`filelib.py` 既有 RULE-5 和历史 E2E 既有 RULE-7 提示与基线完全一致 | +| `git diff --check` | 通过 | + +去重共 28 个用例通过。初次发现的测试环境问题为配置文件缺失、旧权限 HTTP 测试依赖未隔离的运行时身份、SQLite 时间字符串与 ISO 游标比较差异;调整仅限测试配置/fixture,随后只重跑相关失败用例并补一个 503 定向检查。未以这些本地结果冒充真实部署验收。 + +## SDD 代码审查 + +使用 `sdd-code-review` 七维清单自审,对比基线及工作区完整差异;轻量缺陷流程没有新 feature spec/design/tasks,现有 F048 design 及错误码登记已同步。 + +| 维度 | 结果 | 核查要点 | +|---|---|---| +| 边界条件 | PASS | 过滤发生在分页前,空页/下一页沿用既有协议;异常恢复上下文 | +| 权限与认证 | PASS | 租户管理员管理范围收窄;无跨租户补授权;普通用户及超管路径不扩权 | +| 并发安全 | PASS | 使用请求级 ContextVar 上下文,无新增共享状态或写事务 | +| 信息泄漏 | PASS | 跨租户与缺失资源保持相同响应;无新增敏感信息 | +| 测试覆盖 | PASS | 真实 SQL 查询与权限 HTTP/错误响应定向用例通过 | +| 代码风格 | PASS | Ruff、差异检查通过,未新增架构违规 | +| 文档同步 | PASS | F048 当前设计、已知坑和错误码归属已更新 | + +结论:本地修复及必要定向验证完成;尚未合并、推送或部署。 diff --git a/docs/reviews/database-connection-special-characters-design-review-2026-10-26.md b/docs/reviews/database-connection-special-characters-design-review-2026-10-26.md new file mode 100644 index 0000000000..fee6731c62 --- /dev/null +++ b/docs/reviews/database-connection-special-characters-design-review-2026-10-26.md @@ -0,0 +1,33 @@ +# 数据库密码含特殊字符时的连接问题 + +目前先不改代码,整理给相关伙伴确认。 + +## 背景 + +客户的数据库密码可能包含 `@` 等特殊字符。毕昇应该正确处理,不能要求客户换成简单密码来规避。 + +## 问题与原因 + +已用模拟密码复现:密码为 `p@ss` 时,直接拼接连接串会把部分密码当成主机地址,导致解析错误。涉及两处: + +- [工作流 SQL 节点](../../src/backend/bisheng/workflow/nodes/agent/agent.py):直接拼接账号、密码和地址,没有处理特殊字符。 +- [主库配置](../../src/backend/bisheng/core/config/settings.py):密码解密后直接写回连接串,同样可能解析错误。 + +这是参数解析问题,尚未连接真实数据库验证。并不是所有字段含 `@` 都有问题,例如本次账号含 `@` 的样本解析正常。 + +## 建议怎么改 + +统一用 SQLAlchemy 的结构化连接对象,账号、密码、地址分别处理,替代手工拼接和正则替换。 + +- 工作流分字段填写的密码原样传入,不要求用户手动转义。 +- 主库密码按现有规则解密,再作为密码字段设置;保留现有配置格式和加密方式。 +- 完整连接串按标准 URL 解析,避免重复编码或解码;不能全局替换 `@`,也不能自动猜测有歧义的连接串。 +- 同步转异步只修改驱动字段,避免整串替换误改密码;日志隐藏密码。 + +## 需要确认 + +请数据库、部署和工作流伙伴确认:现有配置是否有特殊写法,以及各 SQL 节点支持的地址格式。建议先覆盖主库和工作流 SQL,Redis 另行评估。 + +验证重点:特殊字符密码、现有加密配置、同步/异步连接、MySQL 和达梦兼容,以及工作流其他数据库的实际连接。 + +参考:[SQLAlchemy 官方说明](https://docs.sqlalchemy.org/en/20/core/engines.html#escaping-special-characters-such-as-signs-in-passwords)。 diff --git a/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/arch-final.txt b/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/arch-final.txt new file mode 100644 index 0000000000..68e46a1b15 --- /dev/null +++ b/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/arch-final.txt @@ -0,0 +1 @@ +⚠️ [arch-guard] RULE-7 WARNING: settings.py — 疑似硬编码敏感信息 diff --git a/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/backend-final.txt b/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/backend-final.txt new file mode 100644 index 0000000000..c724605efa --- /dev/null +++ b/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/backend-final.txt @@ -0,0 +1,8 @@ +............................... [100%] +=============================== warnings summary =============================== +../../../../src/backend/.venv/lib/python3.11/site-packages/jieba/_compat.py:18 + /Users/zhangguoqing/works/bisheng/src/backend/.venv/lib/python3.11/site-packages/jieba/_compat.py:18: UserWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html. The pkg_resources package is slated for removal as early as 2025-11-30. Refrain from using this package or pin to Setuptools<81. + import pkg_resources + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +31 passed, 1 warning in 1.87s diff --git a/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/backend.txt b/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/backend.txt new file mode 100644 index 0000000000..8028352619 --- /dev/null +++ b/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/backend.txt @@ -0,0 +1,8 @@ +.............................. [100%] +=============================== warnings summary =============================== +../../../../src/backend/.venv/lib/python3.11/site-packages/jieba/_compat.py:18 + /Users/zhangguoqing/works/bisheng/src/backend/.venv/lib/python3.11/site-packages/jieba/_compat.py:18: UserWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html. The pkg_resources package is slated for removal as early as 2025-11-30. Refrain from using this package or pin to Setuptools<81. + import pkg_resources + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +30 passed, 1 warning in 1.27s diff --git a/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/download-size.txt b/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/download-size.txt new file mode 100644 index 0000000000..86cc12e008 --- /dev/null +++ b/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/download-size.txt @@ -0,0 +1,8 @@ +. [100%] +=============================== warnings summary =============================== +../../../../src/backend/.venv/lib/python3.11/site-packages/jieba/_compat.py:18 + /Users/zhangguoqing/works/bisheng/src/backend/.venv/lib/python3.11/site-packages/jieba/_compat.py:18: UserWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html. The pkg_resources package is slated for removal as early as 2025-11-30. Refrain from using this package or pin to Setuptools<81. + import pkg_resources + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +1 passed, 1 warning in 0.26s diff --git a/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/e2e-collection.txt b/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/e2e-collection.txt new file mode 100644 index 0000000000..6a8a1b91f2 --- /dev/null +++ b/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/e2e-collection.txt @@ -0,0 +1,12 @@ +test/e2e/test_e2e_vibe_governance_closeout.py::test_ac46_53_three_real_sources_and_paired_denial[model] +test/e2e/test_e2e_vibe_governance_closeout.py::test_ac46_53_three_real_sources_and_paired_denial[capability] +test/e2e/test_e2e_vibe_governance_closeout.py::test_ac46_53_three_real_sources_and_paired_denial[access] +test/e2e/test_e2e_vibe_governance_closeout.py::test_ac51_56_complete_authenticated_export + +=============================== warnings summary =============================== +../../../../src/backend/.venv/lib/python3.11/site-packages/jieba/_compat.py:18 + /Users/zhangguoqing/works/bisheng/src/backend/.venv/lib/python3.11/site-packages/jieba/_compat.py:18: UserWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html. The pkg_resources package is slated for removal as early as 2025-11-30. Refrain from using this package or pin to Setuptools<81. + import pkg_resources + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +4 tests collected in 0.03s diff --git a/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/eslint-last.txt b/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/eslint-last.txt new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/host-and-lease.txt b/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/host-and-lease.txt new file mode 100644 index 0000000000..10480f209a --- /dev/null +++ b/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/host-and-lease.txt @@ -0,0 +1,8 @@ +..... [100%] +=============================== warnings summary =============================== +../../../../src/backend/.venv/lib/python3.11/site-packages/jieba/_compat.py:18 + /Users/zhangguoqing/works/bisheng/src/backend/.venv/lib/python3.11/site-packages/jieba/_compat.py:18: UserWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html. The pkg_resources package is slated for removal as early as 2025-11-30. Refrain from using this package or pin to Setuptools<81. + import pkg_resources + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +5 passed, 1 warning in 0.48s diff --git a/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/manager-verified.txt b/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/manager-verified.txt new file mode 100644 index 0000000000..3539aa73ff --- /dev/null +++ b/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/manager-verified.txt @@ -0,0 +1,2 @@ +......... [100%] +9 passed in 0.18s diff --git a/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/obo-final.txt b/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/obo-final.txt new file mode 100644 index 0000000000..a18189d862 --- /dev/null +++ b/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/obo-final.txt @@ -0,0 +1,8 @@ +................................................ [100%] +=============================== warnings summary =============================== +../../../../src/backend/.venv/lib/python3.11/site-packages/jieba/_compat.py:18 + /Users/zhangguoqing/works/bisheng/src/backend/.venv/lib/python3.11/site-packages/jieba/_compat.py:18: UserWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html. The pkg_resources package is slated for removal as early as 2025-11-30. Refrain from using this package or pin to Setuptools<81. + import pkg_resources + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +48 passed, 1 warning in 2.74s diff --git a/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/proxy-cache.txt b/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/proxy-cache.txt new file mode 100644 index 0000000000..d586731f14 --- /dev/null +++ b/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/proxy-cache.txt @@ -0,0 +1,2 @@ +..... [100%] +5 passed in 0.04s diff --git a/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/proxy-http-final.txt b/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/proxy-http-final.txt new file mode 100644 index 0000000000..c4bfb9029e --- /dev/null +++ b/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/proxy-http-final.txt @@ -0,0 +1,2 @@ +.... [100%] +4 passed in 0.13s diff --git a/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/static-receipt.json b/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/static-receipt.json new file mode 100644 index 0000000000..c5cdaa80e0 --- /dev/null +++ b/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/static-receipt.json @@ -0,0 +1,8 @@ +{ + "changed_python_files": 66, + "ruff": "pass", + "architecture": "zero_violations_existing_settings_warning", + "installer_bash_syntax": "pass", + "compose_yaml_and_role_bindings": "pass", + "runtime_ui_locale_parity": "pass" +} diff --git a/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/typecheck-last.txt b/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/typecheck-last.txt new file mode 100644 index 0000000000..144c2a03ea --- /dev/null +++ b/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/typecheck-last.txt @@ -0,0 +1,6 @@ +- Looking for strict files... +🎯 Found 881 strict files +- Compiling with strict mode... +src/pages/BuildPage/bench/OrgKbConfig.tsx(17,55): error TS2307: Cannot find module '@hello-pangea/dnd' or its corresponding type declarations. +src/test/clientVoiceDraft.test.tsx(47,49): error TS2554: Expected 0 arguments, but got 1. +💥 Found 2 errors diff --git a/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/ui-final.txt b/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/ui-final.txt new file mode 100644 index 0000000000..80ac5afbd4 --- /dev/null +++ b/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/ui-final.txt @@ -0,0 +1,23 @@ + + RUN v3.2.7 /Users/zhangguoqing/works/bisheng/.worktrees/vibe-app-integration/src/frontend/platform + + ✓ src/pages/LogPage/index.test.tsx (5 tests) 86ms +stderr | src/test/runtimeAudit.test.tsx > renders real source rows and distinguishes failed loading from empty records +Error: unavailable + at /Users/zhangguoqing/works/bisheng/.worktrees/vibe-app-integration/src/frontend/platform/src/test/runtimeAudit.test.tsx:36:38 + at file:///Users/zhangguoqing/.codex/worktrees/vibe-k8s-runtime/bisheng/src/frontend/node_modules/.pnpm/@vitest+runner@3.2.7/node_modules/@vitest/runner/dist/chunk-hooks.js:155:11 + at file:///Users/zhangguoqing/.codex/worktrees/vibe-k8s-runtime/bisheng/src/frontend/node_modules/.pnpm/@vitest+runner@3.2.7/node_modules/@vitest/runner/dist/chunk-hooks.js:752:26 + at file:///Users/zhangguoqing/.codex/worktrees/vibe-k8s-runtime/bisheng/src/frontend/node_modules/.pnpm/@vitest+runner@3.2.7/node_modules/@vitest/runner/dist/chunk-hooks.js:1897:20 + at new Promise () + at runWithTimeout (file:///Users/zhangguoqing/.codex/worktrees/vibe-k8s-runtime/bisheng/src/frontend/node_modules/.pnpm/@vitest+runner@3.2.7/node_modules/@vitest/runner/dist/chunk-hooks.js:1863:10) + at runTest (file:///Users/zhangguoqing/.codex/worktrees/vibe-k8s-runtime/bisheng/src/frontend/node_modules/.pnpm/@vitest+runner@3.2.7/node_modules/@vitest/runner/dist/chunk-hooks.js:1574:12) + at runSuite (file:///Users/zhangguoqing/.codex/worktrees/vibe-k8s-runtime/bisheng/src/frontend/node_modules/.pnpm/@vitest+runner@3.2.7/node_modules/@vitest/runner/dist/chunk-hooks.js:1729:8) + at runFiles (file:///Users/zhangguoqing/.codex/worktrees/vibe-k8s-runtime/bisheng/src/frontend/node_modules/.pnpm/@vitest+runner@3.2.7/node_modules/@vitest/runner/dist/chunk-hooks.js:1787:3) + at startTests (file:///Users/zhangguoqing/.codex/worktrees/vibe-k8s-runtime/bisheng/src/frontend/node_modules/.pnpm/@vitest+runner@3.2.7/node_modules/@vitest/runner/dist/chunk-hooks.js:1820:3) + + ✓ src/test/runtimeAudit.test.tsx (4 tests) 314ms + + Test Files 2 passed (2) + Tests 9 passed (9) + Start at 11:19:21 + Duration 6.79s (transform 1.48s, setup 360ms, collect 10.83s, tests 400ms, environment 880ms, prepare 141ms) diff --git a/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/ui-verified.txt b/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/ui-verified.txt new file mode 100644 index 0000000000..7a341bb121 --- /dev/null +++ b/docs/reviews/evidence/vibe-governance-closeout-2026-10-24/ui-verified.txt @@ -0,0 +1,22 @@ + + RUN v3.2.7 /Users/zhangguoqing/works/bisheng/.worktrees/vibe-app-integration/src/frontend/platform + +stderr | src/test/runtimeAudit.test.tsx > renders real source rows and distinguishes failed loading from empty records +Error: unavailable + at /Users/zhangguoqing/works/bisheng/.worktrees/vibe-app-integration/src/frontend/platform/src/test/runtimeAudit.test.tsx:36:38 + at file:///Users/zhangguoqing/.codex/worktrees/vibe-k8s-runtime/bisheng/src/frontend/node_modules/.pnpm/@vitest+runner@3.2.7/node_modules/@vitest/runner/dist/chunk-hooks.js:155:11 + at file:///Users/zhangguoqing/.codex/worktrees/vibe-k8s-runtime/bisheng/src/frontend/node_modules/.pnpm/@vitest+runner@3.2.7/node_modules/@vitest/runner/dist/chunk-hooks.js:752:26 + at file:///Users/zhangguoqing/.codex/worktrees/vibe-k8s-runtime/bisheng/src/frontend/node_modules/.pnpm/@vitest+runner@3.2.7/node_modules/@vitest/runner/dist/chunk-hooks.js:1897:20 + at new Promise () + at runWithTimeout (file:///Users/zhangguoqing/.codex/worktrees/vibe-k8s-runtime/bisheng/src/frontend/node_modules/.pnpm/@vitest+runner@3.2.7/node_modules/@vitest/runner/dist/chunk-hooks.js:1863:10) + at runTest (file:///Users/zhangguoqing/.codex/worktrees/vibe-k8s-runtime/bisheng/src/frontend/node_modules/.pnpm/@vitest+runner@3.2.7/node_modules/@vitest/runner/dist/chunk-hooks.js:1574:12) + at runSuite (file:///Users/zhangguoqing/.codex/worktrees/vibe-k8s-runtime/bisheng/src/frontend/node_modules/.pnpm/@vitest+runner@3.2.7/node_modules/@vitest/runner/dist/chunk-hooks.js:1729:8) + at runFiles (file:///Users/zhangguoqing/.codex/worktrees/vibe-k8s-runtime/bisheng/src/frontend/node_modules/.pnpm/@vitest+runner@3.2.7/node_modules/@vitest/runner/dist/chunk-hooks.js:1787:3) + at startTests (file:///Users/zhangguoqing/.codex/worktrees/vibe-k8s-runtime/bisheng/src/frontend/node_modules/.pnpm/@vitest+runner@3.2.7/node_modules/@vitest/runner/dist/chunk-hooks.js:1820:3) + + ✓ src/test/runtimeAudit.test.tsx (4 tests) 412ms + + Test Files 1 passed (1) + Tests 4 passed (4) + Start at 11:32:55 + Duration 5.20s (transform 1.19s, setup 182ms, collect 3.74s, tests 412ms, environment 432ms, prepare 73ms) diff --git a/docs/reviews/evidence/vibe-release-20261025/docker116-deployment.json b/docs/reviews/evidence/vibe-release-20261025/docker116-deployment.json new file mode 100644 index 0000000000..4464740600 --- /dev/null +++ b/docs/reviews/evidence/vibe-release-20261025/docker116-deployment.json @@ -0,0 +1,78 @@ +{ + "observed_at": 1792922380.5025725, + "containers": [ + { + "name": "/bisheng-test-backend", + "image_id": "sha256:4995f590ce618f0004abc39dde912297dc0c32d8a47b94210c64b82bbea06348", + "reference": "192.168.106.8:6082/dataelement/bisheng-backend:release", + "running": true, + "restart_count": 0, + "health": "healthy" + }, + { + "name": "/bisheng-test-backend-worker", + "image_id": "sha256:4995f590ce618f0004abc39dde912297dc0c32d8a47b94210c64b82bbea06348", + "reference": "192.168.106.8:6082/dataelement/bisheng-backend:release", + "running": true, + "restart_count": 0, + "health": null + }, + { + "name": "/bisheng-test-backend-worker-ocr", + "image_id": "sha256:4995f590ce618f0004abc39dde912297dc0c32d8a47b94210c64b82bbea06348", + "reference": "192.168.106.8:6082/dataelement/bisheng-backend:release", + "running": true, + "restart_count": 0, + "health": null + }, + { + "name": "/bisheng-test-frontend", + "image_id": "sha256:e52b28a9b21a183c0830737aa9f183c085f9c881231afe1815ffd315fb11e44e", + "reference": "192.168.106.8:6082/dataelement/bisheng-frontend:release", + "running": true, + "restart_count": 0, + "health": null + }, + { + "name": "/bisheng-runtime-manager", + "image_id": "sha256:8bf311a232c826b68ce87ab0e712131da8ee2a47c4563ba92b4ddb7d7bd7b9da", + "reference": "192.168.106.189:5443/vibe-release/bisheng-runtime-manager@sha256:e7a38d0239ef9a8ed77f1e1d06cc7b9e2012185f976f4d51c49ec696015156fc", + "running": true, + "restart_count": 0, + "health": null + }, + { + "name": "/bisheng-app-proxy", + "image_id": "sha256:806585703bb8f7970daa28b062551093c581cc1a295463450a1c42c3dc6a7d37", + "reference": "192.168.106.189:5443/vibe-release/bisheng-app-proxy@sha256:6d68b9b61fb32674cd1dab40485f2e1a29b6ae09d039666731dcdbf426a1a964", + "running": true, + "restart_count": 0, + "health": null + }, + { + "name": "/bisheng-egress-proxy", + "image_id": "sha256:8bf311a232c826b68ce87ab0e712131da8ee2a47c4563ba92b4ddb7d7bd7b9da", + "reference": "192.168.106.189:5443/vibe-release/bisheng-runtime-manager@sha256:e7a38d0239ef9a8ed77f1e1d06cc7b9e2012185f976f4d51c49ec696015156fc", + "running": true, + "restart_count": 0, + "health": null + }, + { + "name": "/bisheng-docker-socket-proxy", + "image_id": "sha256:1484f9fee8a7f423374977ca5e39457c762d859182511192270bf1e542670608", + "reference": "192.168.106.189:5443/vibe-release/bisheng-docker-socket-proxy@sha256:2f92c6e85a1199b3403c99d7439695898a162c69689b11130450ffadb352f0a0", + "running": true, + "restart_count": 0, + "health": null + } + ], + "health": { + "backend": 200, + "frontend": 200 + }, + "host_isolation": [ + "Result=success", + "ExecMainStatus=0" + ], + "timer": "enabled" +} \ No newline at end of file diff --git a/docs/reviews/evidence/vibe-release-20261025/f086-draft-inventory.json b/docs/reviews/evidence/vibe-release-20261025/f086-draft-inventory.json new file mode 100644 index 0000000000..0ea8724269 --- /dev/null +++ b/docs/reviews/evidence/vibe-release-20261025/f086-draft-inventory.json @@ -0,0 +1,422 @@ +[ + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/README.md", + "sha256": "6014af13ba36e6663873b0b19dee8924fdc3224ed7f49ffda366a3aca9c30a72", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/app-proxy-index.json", + "sha256": "0c5cbff5b4ae0e7e5ab6be8dd468549013e305e4317ef15d73b958cb2279506a", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/cleanup-verification.json", + "sha256": "da0422acf68e383dea178995fed3d71854275c8ce624ef9ea5733c9078aeea31", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/release.json", + "sha256": "a57b4b5e9589919a382ebb3d12c740e36d0ae58d7b4da7efe45d21e77bbe94ea", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/runtime-manager-index.json", + "sha256": "1e42c3a775593063838b2ba8c2e1daceeacf7849e1ebaaf21f0af4ecaa155547", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/service-verification.json", + "sha256": "1ec63f3ae5da40941792da6f01f2209077dfb158b434a1656d2a61d39f96d466", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/sha256.json", + "sha256": "065cb7a51ba1316f74857ae3eb2f01fdbe80a2c3f8a3b12dcfb3657c72186f7d", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/verify_services.py", + "sha256": "01722a2e1dfc0eed3981c8b15a268bed0474d7c7a6c93d89e777cb9382ecebd8", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/.gitignore", + "sha256": "a4e6b5adcc8e252c6e820a9a56a20a6249b18222a616090634ca5ab06f483a5a", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/README.md", + "sha256": "74fa8ee74fe07988b4090dd14800747191444adb8a1f8e61d981e1bba1c1a525", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/cancel-timeout-results.json", + "sha256": "9cf30cb4b11aad5c685cfd15ef4bbfdc3123f3334044000115d4641b5cee0867", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/compile-timeout-result.json", + "sha256": "b1f38374532a2f7007a0cf9926bb3a8c4394131c170ebcaa7041b515947ac2cc", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/connect-policy-revoke.json", + "sha256": "e6c21701d3daeed33c4fb36568be8cc841cb6a6e7ac1992e27da1e714e6e62ac", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/deletion-followup.json", + "sha256": "a3c4ced6a96c0a8cddf308e7326dd84cc5ea4be918b29d97796a0cb9ea2cf9cf", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/docker-cleanup.json", + "sha256": "4ac5d21a57bf5240186cfea43323bf1d6a05727986010cc6d9c28da8e0c9d147", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/install-cancel-result.json", + "sha256": "12f984b1295c18973f1ec68f50332411cd34d8ef6442ce443b28151c1eee67af", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/legacy-container-before.txt", + "sha256": "1a55bff1e4fc0821a3cddb2adc17ac3418a6c093dc0775ea4640cc2c2c11cbdf", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/legacy-manager-image.json", + "sha256": "bc418fba3a5936e0c9a10bdf51fd4e75e4b353a229ac0b80b02b543327238897", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/approval-retry.log", + "sha256": "46172b32a17c3d002e8359f78ceed447ba0ceedf6a322d10787f549d73c5bde8", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/bootstrap-before.log", + "sha256": "e5836e984b153c35bed972ea87ea851b4d24bf056ce97d1afa8fde5c6a802360", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/bootstrap-fixed.log", + "sha256": "9de2d09895516621dd074d8d6555dca6a0c7707d86bdd70ab1125559d0bf2e1f", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/compile-timeout.log", + "sha256": "186a02e18ed0daad559fc290e5f845d51ca290b50904b40dd862ac52f6a6d1f5", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/connect-confirm.log", + "sha256": "ccbafa3afc98c77243eae78a5326492aafa5bfb2cb7faf2187db099f7c21eb07", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/deletion-followup.log", + "sha256": "8630a61d399cbb7b367790e6e21149a8e17fe7845dc19d7103a3c9bdbc9638b9", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/install-cancel.log", + "sha256": "c6e190447a7dd995201a3f20beeb286cd7d9e6871abf0a5074b816b3c49ff7c9", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/legacy-pending-confirm.log", + "sha256": "a6a9c6c5546d142a091dcc68afda20ecc7180a762334db29ed49cc04b6c32bea", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/legacy-publication.log", + "sha256": "7730d2d07ab724ae3e82c6589dbeb62c4adcc76247ba836ca6117cffb1daa565", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/live-boundaries.log", + "sha256": "7086834d382ffe07d561d3c902f8cc2255e7fc6e76cde3a36ff266f20ffd966d", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/manager-connect.log", + "sha256": "7be6cbf12ff61752a9f96e9fa120d8c48159ba549086f563a6a13e93d6fc921e", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/manager-existing-connections.log", + "sha256": "23d4ec1b73e890ebe13af1b0feb8e35c46ac1e23180d6fd57031b60ece94e924", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/migration-startup-failure.log", + "sha256": "2c6191c112630159b98defdce94b3ad7bb51975cfe42b93de815c7b12a62ff37", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/mt-online.log", + "sha256": "a93b1f042b039ed458ed07ec59ae47914924907b92ac24cfe3eee7962f6d4ba3", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/multi-tenant-identities.log", + "sha256": "4ab4ff8ebdcabce9209c99a203b02293c5d012958c677e24b1cab537e6c5960e", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/restart-probe.log", + "sha256": "26339b428eb60ab07b86c35facbf1897269e390276e1b8290cd025d713476f83", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/snapshot-fault-confirm.log", + "sha256": "c4d456d6a5ec0eeea025f9b53c0acbb7a93b599acff4238ffed3239641ec1267", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/timeout-confirm.log", + "sha256": "b3cfb6fad1983a2985e7fb6009d85bb42b9b6c99ac6d0b257b1d2dec4f433f60", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/upgrade-apply-repeat.log", + "sha256": "08f86945fc7b4d381fe9c0d93940ae667b283548995e9db6d2c2c0bec23ed403", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/upgrade-apply.log", + "sha256": "08f86945fc7b4d381fe9c0d93940ae667b283548995e9db6d2c2c0bec23ed403", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/upgrade-dry-run.log", + "sha256": "efa0eb31e66bcad82999a7045d4c1bfa032b481e882781fd2620b073354901d5", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/upgrade-inventory.log", + "sha256": "3d8e323bc3609387abffeb793811724d41f2a0cad7f18f334ca1606a2d9ccff2", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/upgrade-rebind.log", + "sha256": "25687ac0cdb519d0568c370941abc0599723e18f4c75a61937cb411c97ecbf36", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/upgrade-retained-attachment.log", + "sha256": "5a0c240ed5b24ac14917b5a829784fc7d14c0125493d804e69097a3122e55360", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/upgrade-rollback-check.log", + "sha256": "1bb69304f58b9ab3d8e8eed3c92c328ece68a88f373337287866252363fb6e50", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/ws-backend-failclosed.log", + "sha256": "edf3ea75d0ca90e6c55bbbe902e6d64c198b7a4e14a436f04b26419ec434aad8", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/ws-permission-revoke.log", + "sha256": "c292b1272ae3bd0c6c0f2740ca25614bf718f3f990cf52e64162c19530342649", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/manager-existing-connections.json", + "sha256": "f74016c6fc491249f0e022ab080061c80b51032232fd337047766c315a276a5d", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/migration-startup-failure.json", + "sha256": "e1f78ee22b08ca516ee54c38df8ecc82b10d656547bd752e53a461781be94e4f", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/mt-baseline.json", + "sha256": "2a6279219ca1b752ec1d4dad84cba1ba1af2f6f72e32e5e0a75ef3606ed6e5f9", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/normalization.json", + "sha256": "d2f376a2029fed4f39894d7691836e4ae02bfd4d8ebf12b5de9c623f467f93bd", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/protected-backup-facts.json", + "sha256": "8eb9ff8609e6566c5b065298b9e9c52e5a9f447d3f660d22abc79201aa4a348b", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/quality-and-startup-observation.json", + "sha256": "78976d111bc9715aff6f59ef0c5023cc6b180be27ca497e70ec4180aec60a216", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/restart-probe-after-ids.txt", + "sha256": "3d90977135589a05781aad7479d30bc646cb490eb20ee1e78791a700a3d6ad77", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/restart-probe-before-ids.txt", + "sha256": "3d90977135589a05781aad7479d30bc646cb490eb20ee1e78791a700a3d6ad77", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/restart-probe-result.json", + "sha256": "4787c45c00aac946db6781e32cec28114d1dda35f3ea502083dd53b136814288", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/scratch-cleanup-facts.json", + "sha256": "e935c80c7770ba9befa96faf7626cd28c94804ba9a793fa163f0456d4e99a7cb", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/sha256-export.json", + "sha256": "91580ce535d1c00bf287e48a518063cc36b62e26816e4941ec50e0af28e7893d", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/smoke-inventory.json", + "sha256": "9e0203c5f9b74579404beb283e7cd5c5f6cc23add0ecc1e5b822f362c6e23973", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/snapshot-confirmation-fault.json", + "sha256": "b7aa868b8b342d41891807b0b68d16a4a050f2c9a0228fd6837ccc4e4c94912e", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/upgrade-apply.json", + "sha256": "cf27f6f21c0c84c17db1134552a8b84e528d26a76076badc3f92f3ce8b5e924b", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/upgrade-dry-run.json", + "sha256": "6a1a89f1c1f9386ba6cda23886f28055f705358c33ebcdbf25572f965ad67f0a", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/upgrade-first-apply.json", + "sha256": "bb7654a451b76301d099ba8dfaf66a1daeb21f15c66a137bc63117941169b659", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/upgrade-inventory.json", + "sha256": "0cea380418a9f2c082e96f9714fe47572caee36462757a7f9fbe7ce139bc6237", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/upgrade-rebind-result.json", + "sha256": "904d2fcfee647aa312f1d993e4b7a41fe0481b4bb199e0a0aa6d7e6c40bee0a5", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/ws-backend-failclosed.json", + "sha256": "b073253b3550e3e21d2f6a8ca21da3f100a876e0b8df134f9541b3154ce05361", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/ws-permission-revoke.json", + "sha256": "4648bd269a0fe0d099f80518197a5fa5c27522573f23c311701719c031e55767", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/model-review-tests.txt", + "sha256": "e6df50c913491f78c8e9aa6725aa6afdad6be6c1ae90d8cf16571a7393440b6e", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/model-review.json", + "sha256": "b85baa43b230aa792e4beadbb099021e2988fe6838032f5c974b181fc1577535", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/model-review.md", + "sha256": "c15e6048f3552814af4ab8d5e8851d35edb3fb54549b0fd1363c55e73ef0999f", + "same_as_integrated": true + }, + { + "path": "src/backend/test/tenant/test_tenant_execution_scope.py", + "sha256": "582700a8ff1a426c53291fc16928ff0516e6d164aa63ea9d393198b0233032ea", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/code-review.md", + "sha256": "204063a2ee251ec3af4bad41d2c2caf6298c6616305bdb1cd5f41cdfe761a81a", + "same_as_integrated": false + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/design.md", + "sha256": "854ab6a042a6b258bfb822a25b93562445a86a711c2471d60a848a12f2ffaa71", + "same_as_integrated": false + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-checklist.md", + "sha256": "ecf6d5c246fec5e2607cff8bdab90fb7071f42548c85846993843a9029b604ca", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/implementation-files.sha256.json", + "sha256": "6bf757300db21cce238bef95515392a23b8ceb4d12a3717fa350dd61557c1fa9", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/live-validation.md", + "sha256": "3d25e7b03506829dbafbf8a40eacb677e8e6d9911b5793c16250fdea23cfa1af", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/spec.md", + "sha256": "52b6be5885b1f010ce04a4adf4ae90ed4381ee39e623eb62fd38cedc23921645", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/task-reviews.md", + "sha256": "b1691ae2263d254980f086776a99c99708076ee6455594a7023e593ac9a416ca", + "same_as_integrated": false + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/tasks-index.json", + "sha256": "3a019e3c68b49c38821bbb5861bf920cd992af4e922e0e7e7ccc2db845955fab", + "same_as_integrated": true + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/tasks.md", + "sha256": "cfeb8d132b0251ed972f80989af0219e2ada8b16ec0e41e68fd5a1640213e61d", + "same_as_integrated": false + }, + { + "path": "features/v3.0.0/086-vibe-runtime-delivery/validation.json", + "sha256": "1319567ab824b021e8d59fba0f7bc63b6ba68616c9d4aaed430eb106e43fbc50", + "same_as_integrated": false + }, + { + "path": "src/backend/bisheng/api/services/f048_preset_dashboard_bootstrap.py", + "sha256": "80d0118a2065f2e1e2e69d50a48a512f810d6c43ac40428ec0454e805734ad30", + "same_as_integrated": true + }, + { + "path": "src/backend/bisheng/core/context/tenant.py", + "sha256": "6cd240901bab1b9fc8983044ea4e3fe1eb6d31a908304efba6417ef1b5ddf62a", + "same_as_integrated": true + }, + { + "path": "src/backend/bisheng/permission/application/system_resource_reconcile.py", + "sha256": "48a3ec8b62cca4d97a99eeab896505fbe2a21f7d167b2e4e36f43ccd85c742cf", + "same_as_integrated": false + }, + { + "path": "src/backend/test/permission/test_f048_preset_dashboard_bootstrap.py", + "sha256": "053da8023afe2981354d2555851b1cdaf307c79cf2415adef66c24cb4efe3a41", + "same_as_integrated": true + }, + { + "path": "src/backend/test/permission/test_f048_system_resource_reconcile.py", + "sha256": "fb2ef0cf3b43b570db6809fd274c81c25a25c62cf80b24f8a8081ddff7c26aad", + "same_as_integrated": false + } +] diff --git a/docs/reviews/evidence/vibe-release-20261025/host-parent-order.txt b/docs/reviews/evidence/vibe-release-20261025/host-parent-order.txt new file mode 100644 index 0000000000..cfcde3fe33 --- /dev/null +++ b/docs/reviews/evidence/vibe-release-20261025/host-parent-order.txt @@ -0,0 +1,2 @@ +........... [100%] +11 passed in 0.02s diff --git a/docs/reviews/evidence/vibe-release-20261025/k8s189-deployment.json b/docs/reviews/evidence/vibe-release-20261025/k8s189-deployment.json new file mode 100644 index 0000000000..b47aa8315a --- /dev/null +++ b/docs/reviews/evidence/vibe-release-20261025/k8s189-deployment.json @@ -0,0 +1,40 @@ +{ + "observed_date": "2026-10-26", + "source_revision": "d9e6c4d4fe43588763b889cd4c55fcb24f397e54", + "backend_base_source": "f5db95ecab6934c590fa32486cb15a5ff3dc2737", + "backend_hotfix_commit": "f8fbe894812f6cfc348c27d54430d3e5776156e2", + "backend_image": "192.168.106.189:5443/vibe-release/bisheng-backend@sha256:fb69ce59a1f5925c322b85064a07801bc04eca63cf131aae630711eb2a083b6d", + "entry_image": "192.168.106.189:5443/vibe-release/bisheng-app-proxy@sha256:4a037405875987d483842aa45d92923d6f0a3c3d64e9e926a5a62f56d54b2e39", + "entry_base": "https://192.168.106.189:30143", + "application_id": "c63121a2f58441df953762501fdc62d3", + "deployment_id": "4f599876e25e4bcdaee8e1a213f183b0", + "version_id": "24fcb79a0b394e6d9634e952375f8091", + "pod_name": "app-e38bf26369d3bffed06c1ec2c0691914e52d1b27", + "pod_uid": "49151023-0085-4c31-ae97-c4fbd5f1cc1e", + "pod_phase": "Running", + "containers_ready": [ + true, + true + ], + "restarts": [ + 0, + 0 + ], + "tier": "light", + "app_cpu_limit": "500m", + "app_memory_limit": "1Gi", + "helper_cpu_limit": "25m", + "helper_memory_limit": "128Mi", + "production_pv": "vibe-release-production-3", + "http_status": 200, + "scope": "one static bundle application, normal authenticated HTTP; not a full regression", + "docker116_business_acceptance": "deferred by user until resources available", + "ci": { + "number": 5635, + "source_revision": "d9e6c4d4fe43588763b889cd4c55fcb24f397e54", + "status": "running", + "backend": "success", + "frontend": "running", + "automatic_deploy": "pending" + } +} diff --git a/docs/reviews/evidence/vibe-release-20261025/k8s189-descriptor.json b/docs/reviews/evidence/vibe-release-20261025/k8s189-descriptor.json new file mode 100644 index 0000000000..90ada9ac50 --- /dev/null +++ b/docs/reviews/evidence/vibe-release-20261025/k8s189-descriptor.json @@ -0,0 +1,294 @@ +{ + "assembler_image": "192.168.106.189:5443/vibe-release/bisheng-runtime-assembler@sha256:93d072a998db38d594d32a04e5c6b32abebb67f0aa8e8b1e7cad196e7289b97c", + "canary_profiles": [ + { + "command": [ + "python", + "--version" + ], + "name": "app-python", + "runtime_image": "192.168.106.189:5443/vibe-release/bisheng-app-python@sha256:585663bd94cb176c0e72a8203941f5b976c561c4271738d634493d4661bd9b67" + }, + { + "command": [ + "python", + "--version" + ], + "name": "app-python-preparer", + "runtime_image": "192.168.106.189:5443/vibe-release/bisheng-app-python-preparer@sha256:d1be6968edb6209b811ec1bb58b6556580502b7519a6403d5590f96bb5e33802" + }, + { + "command": [ + "node", + "--version" + ], + "name": "app-node", + "runtime_image": "192.168.106.189:5443/vibe-release/bisheng-app-node@sha256:4e4670b5f669c793ece6f99309ec80c8fd7500405006e8a772554dd4d1be2c98" + }, + { + "command": [ + "node", + "--version" + ], + "name": "app-node-preparer", + "runtime_image": "192.168.106.189:5443/vibe-release/bisheng-app-node-preparer@sha256:ab620d24bc1efcccb340ebe6034f6b1e23bea7f828e2a83656f5957ce3992247" + }, + { + "command": [ + "nginx", + "-v" + ], + "name": "app-static", + "runtime_image": "192.168.106.189:5443/vibe-release/bisheng-app-static@sha256:e9d9b7235d02160e71ab6579b4bdec00f7ce74c56c588c4376e8a74926087453" + } + ], + "capabilities": { + "pod_cidrs": [ + "10.233.64.0/24" + ], + "service_cidrs": [ + "10.233.0.0/18" + ] + }, + "cluster_id": "bisheng-vibe-189", + "cni_username": "system:serviceaccount:kube-system:calico-cni-plugin", + "cpu_budget_millicores": 4000, + "data_public_keys_configmap": "runtime-data-public-keys", + "helper_image": "192.168.106.189:5443/vibe-release/bisheng-runtime-helper@sha256:468b8e774c1e55a8360a0e0db6c48b001b38168f923ec3d8bbdf2c0f79782e52", + "image_registry_endpoints": [ + "https://192.168.106.189:5443" + ], + "image_repository": "192.168.106.189:5443/vibe-release/apps", + "insecure_registry": false, + "installation_id": "bisheng-vibe-release", + "memory_budget_mb": 4096, + "namespace": "bisheng-vibe-runtime-apps", + "offline": true, + "package_endpoints": [], + "platform": "linux/amd64", + "pod_cidrs": [ + "10.233.64.0/24" + ], + "probe_report": { + "artifacts": { + "image-cri": [ + { + "image_id": "docker-pullable://192.168.106.189:5443/vibe-release/bisheng-runtime-helper@sha256:468b8e774c1e55a8360a0e0db6c48b001b38168f923ec3d8bbdf2c0f79782e52", + "image_ref": "192.168.106.189:5443/vibe-release/bisheng-runtime-helper@sha256:468b8e774c1e55a8360a0e0db6c48b001b38168f923ec3d8bbdf2c0f79782e52", + "platform": "linux/amd64", + "resource_version": "3521248", + "uid": "61de8902-7c77-4442-9a82-d2b76209e467" + }, + { + "image_id": "docker-pullable://192.168.106.189:5443/vibe-release/bisheng-app-python@sha256:585663bd94cb176c0e72a8203941f5b976c561c4271738d634493d4661bd9b67", + "image_ref": "192.168.106.189:5443/vibe-release/bisheng-app-python@sha256:585663bd94cb176c0e72a8203941f5b976c561c4271738d634493d4661bd9b67", + "platform": "linux/amd64", + "resource_version": "3521328", + "uid": "58ed4489-5ab0-456d-8ea4-a2b7d5653531" + }, + { + "image_id": "docker-pullable://192.168.106.189:5443/vibe-release/bisheng-app-python-preparer@sha256:d1be6968edb6209b811ec1bb58b6556580502b7519a6403d5590f96bb5e33802", + "image_ref": "192.168.106.189:5443/vibe-release/bisheng-app-python-preparer@sha256:d1be6968edb6209b811ec1bb58b6556580502b7519a6403d5590f96bb5e33802", + "platform": "linux/amd64", + "resource_version": "3521386", + "uid": "8fada07d-cf09-4935-8318-86785b21fb28" + }, + { + "image_id": "docker-pullable://192.168.106.189:5443/vibe-release/bisheng-app-node@sha256:4e4670b5f669c793ece6f99309ec80c8fd7500405006e8a772554dd4d1be2c98", + "image_ref": "192.168.106.189:5443/vibe-release/bisheng-app-node@sha256:4e4670b5f669c793ece6f99309ec80c8fd7500405006e8a772554dd4d1be2c98", + "platform": "linux/amd64", + "resource_version": "3521447", + "uid": "8636344f-3ea9-4357-bdcd-be7bbc2f85cd" + }, + { + "image_id": "docker-pullable://192.168.106.189:5443/vibe-release/bisheng-app-node-preparer@sha256:ab620d24bc1efcccb340ebe6034f6b1e23bea7f828e2a83656f5957ce3992247", + "image_ref": "192.168.106.189:5443/vibe-release/bisheng-app-node-preparer@sha256:ab620d24bc1efcccb340ebe6034f6b1e23bea7f828e2a83656f5957ce3992247", + "platform": "linux/amd64", + "resource_version": "3521507", + "uid": "e26a57de-8581-40e5-a72c-76bdd1262fe0" + }, + { + "image_id": "docker-pullable://192.168.106.189:5443/vibe-release/bisheng-app-static@sha256:e9d9b7235d02160e71ab6579b4bdec00f7ce74c56c588c4376e8a74926087453", + "image_ref": "192.168.106.189:5443/vibe-release/bisheng-app-static@sha256:e9d9b7235d02160e71ab6579b4bdec00f7ce74c56c588c4376e8a74926087453", + "platform": "linux/amd64", + "resource_version": "3521569", + "uid": "efd66ae8-3926-4e37-93cf-523f6f771fce" + } + ] + }, + "checks": [ + { + "evidence": { + "uid": "61de8902-7c77-4442-9a82-d2b76209e467" + }, + "name": "admission-create", + "observed_at": 1792938423.6732242, + "ok": true, + "reason": "verified" + }, + { + "evidence": { + "uid": "6454758b-be1d-4eee-984f-7e586efc3126" + }, + "name": "binding-delete-fence", + "observed_at": 1792938423.6732242, + "ok": true, + "reason": "verified" + }, + { + "evidence": { + "digest": "sha256:468b8e774c1e55a8360a0e0db6c48b001b38168f923ec3d8bbdf2c0f79782e52", + "uid": "61de8902-7c77-4442-9a82-d2b76209e467" + }, + "name": "image-cri", + "observed_at": 1792938423.6732242, + "ok": true, + "reason": "verified" + }, + { + "evidence": { + "code": "control_side_tls", + "uid": "f870c3eb-6c1e-4e6b-92bf-2f2b0fa4efc6" + }, + "name": "material-tls", + "observed_at": 1792938423.6732242, + "ok": true, + "reason": "verified" + }, + { + "evidence": { + "uid": "f870c3eb-6c1e-4e6b-92bf-2f2b0fa4efc6" + }, + "name": "namespace-budget", + "observed_at": 1792938423.6732242, + "ok": true, + "reason": "verified" + }, + { + "evidence": { + "uid": "e668ad01-b24b-4efa-bf70-5825d7bd61fe" + }, + "name": "network-direct-denied", + "observed_at": 1792938423.6732242, + "ok": true, + "reason": "verified" + }, + { + "evidence": { + "uid": "3b96e611-bfbe-4364-99d1-e8640b5d00ce" + }, + "name": "network-dns-denied", + "observed_at": 1792938423.6732242, + "ok": true, + "reason": "verified" + }, + { + "evidence": { + "uid": "7422ef13-5afa-4ab8-8bc6-032f3d79d9ec" + }, + "name": "network-egress-allowed", + "observed_at": 1792938423.6732242, + "ok": true, + "reason": "verified" + }, + { + "evidence": { + "uid": "61de8902-7c77-4442-9a82-d2b76209e467" + }, + "name": "network-ingress", + "observed_at": 1792938423.6732242, + "ok": true, + "reason": "verified" + }, + { + "evidence": { + "uid": "c1f9ea5a-6fc3-47da-a8c2-1a3d5b7a96b0" + }, + "name": "no-exec", + "observed_at": 1792938423.6732242, + "ok": true, + "reason": "verified" + }, + { + "evidence": { + "code": "control_side_authenticated_api", + "uid": "f870c3eb-6c1e-4e6b-92bf-2f2b0fa4efc6" + }, + "name": "platform-capabilities", + "observed_at": 1792938423.6732242, + "ok": true, + "reason": "verified" + }, + { + "evidence": { + "uid": "c1f9ea5a-6fc3-47da-a8c2-1a3d5b7a96b0" + }, + "name": "rbac-namespace", + "observed_at": 1792938423.6732242, + "ok": true, + "reason": "verified" + }, + { + "evidence": { + "uid": "61de8902-7c77-4442-9a82-d2b76209e467" + }, + "name": "restricted-psa", + "observed_at": 1792938423.6732242, + "ok": true, + "reason": "verified" + }, + { + "evidence": { + "policy_revision": "v1", + "uid": "61de8902-7c77-4442-9a82-d2b76209e467" + }, + "name": "status-provenance", + "observed_at": 1792938423.6732242, + "ok": true, + "reason": "verified" + }, + { + "evidence": { + "uid": "e571afa2-f754-448b-bc73-5e9e5f87bc2d" + }, + "name": "storage-sqlite", + "observed_at": 1792938423.6732242, + "ok": true, + "reason": "verified" + } + ], + "descriptor_digest": "8b4d243db44fd4baa2b1b78fe1d9e28e9fda2331940da9c5a3484a812e0e78fd", + "observed_at": 1792938423.6732242, + "source": "cluster-probe" + }, + "pull_secret": "", + "registry_secret": "", + "runtime_class": "", + "sandbox": { + "requested": "default", + "runtime_classes": { + "hardened": "", + "recommended": "" + } + }, + "schema_version": 1, + "service_cidrs": [ + "10.233.0.0/18" + ], + "status_policy_revision": "v1", + "storage": { + "class": "bisheng-vibe-local", + "filesystem": "ext4", + "node_local": true, + "scratch_size_mb": 4096, + "size_mb": 1024, + "sqlite_compatible": true, + "static_pool": true + }, + "storage_controller_usernames": [ + "system:kube-scheduler", + "system:serviceaccount:kube-system:persistent-volume-binder", + "system:serviceaccount:kube-system:pvc-protection-controller" + ], + "temporary_storage_mb": 4096 +} \ No newline at end of file diff --git a/docs/reviews/evidence/vibe-release-20261025/k8s189-install-report.json b/docs/reviews/evidence/vibe-release-20261025/k8s189-install-report.json new file mode 100644 index 0000000000..43f9ef622a --- /dev/null +++ b/docs/reviews/evidence/vibe-release-20261025/k8s189-install-report.json @@ -0,0 +1,192 @@ +{ + "artifacts": { + "image-cri": [ + { + "image_id": "docker-pullable://192.168.106.189:5443/vibe-release/bisheng-runtime-helper@sha256:468b8e774c1e55a8360a0e0db6c48b001b38168f923ec3d8bbdf2c0f79782e52", + "image_ref": "192.168.106.189:5443/vibe-release/bisheng-runtime-helper@sha256:468b8e774c1e55a8360a0e0db6c48b001b38168f923ec3d8bbdf2c0f79782e52", + "platform": "linux/amd64", + "resource_version": "3521248", + "uid": "61de8902-7c77-4442-9a82-d2b76209e467" + }, + { + "image_id": "docker-pullable://192.168.106.189:5443/vibe-release/bisheng-app-python@sha256:585663bd94cb176c0e72a8203941f5b976c561c4271738d634493d4661bd9b67", + "image_ref": "192.168.106.189:5443/vibe-release/bisheng-app-python@sha256:585663bd94cb176c0e72a8203941f5b976c561c4271738d634493d4661bd9b67", + "platform": "linux/amd64", + "resource_version": "3521328", + "uid": "58ed4489-5ab0-456d-8ea4-a2b7d5653531" + }, + { + "image_id": "docker-pullable://192.168.106.189:5443/vibe-release/bisheng-app-python-preparer@sha256:d1be6968edb6209b811ec1bb58b6556580502b7519a6403d5590f96bb5e33802", + "image_ref": "192.168.106.189:5443/vibe-release/bisheng-app-python-preparer@sha256:d1be6968edb6209b811ec1bb58b6556580502b7519a6403d5590f96bb5e33802", + "platform": "linux/amd64", + "resource_version": "3521386", + "uid": "8fada07d-cf09-4935-8318-86785b21fb28" + }, + { + "image_id": "docker-pullable://192.168.106.189:5443/vibe-release/bisheng-app-node@sha256:4e4670b5f669c793ece6f99309ec80c8fd7500405006e8a772554dd4d1be2c98", + "image_ref": "192.168.106.189:5443/vibe-release/bisheng-app-node@sha256:4e4670b5f669c793ece6f99309ec80c8fd7500405006e8a772554dd4d1be2c98", + "platform": "linux/amd64", + "resource_version": "3521447", + "uid": "8636344f-3ea9-4357-bdcd-be7bbc2f85cd" + }, + { + "image_id": "docker-pullable://192.168.106.189:5443/vibe-release/bisheng-app-node-preparer@sha256:ab620d24bc1efcccb340ebe6034f6b1e23bea7f828e2a83656f5957ce3992247", + "image_ref": "192.168.106.189:5443/vibe-release/bisheng-app-node-preparer@sha256:ab620d24bc1efcccb340ebe6034f6b1e23bea7f828e2a83656f5957ce3992247", + "platform": "linux/amd64", + "resource_version": "3521507", + "uid": "e26a57de-8581-40e5-a72c-76bdd1262fe0" + }, + { + "image_id": "docker-pullable://192.168.106.189:5443/vibe-release/bisheng-app-static@sha256:e9d9b7235d02160e71ab6579b4bdec00f7ce74c56c588c4376e8a74926087453", + "image_ref": "192.168.106.189:5443/vibe-release/bisheng-app-static@sha256:e9d9b7235d02160e71ab6579b4bdec00f7ce74c56c588c4376e8a74926087453", + "platform": "linux/amd64", + "resource_version": "3521569", + "uid": "efd66ae8-3926-4e37-93cf-523f6f771fce" + } + ] + }, + "checks": [ + { + "evidence": { + "uid": "61de8902-7c77-4442-9a82-d2b76209e467" + }, + "name": "admission-create", + "observed_at": 1792938423.6732242, + "ok": true, + "reason": "verified" + }, + { + "evidence": { + "uid": "6454758b-be1d-4eee-984f-7e586efc3126" + }, + "name": "binding-delete-fence", + "observed_at": 1792938423.6732242, + "ok": true, + "reason": "verified" + }, + { + "evidence": { + "digest": "sha256:468b8e774c1e55a8360a0e0db6c48b001b38168f923ec3d8bbdf2c0f79782e52", + "uid": "61de8902-7c77-4442-9a82-d2b76209e467" + }, + "name": "image-cri", + "observed_at": 1792938423.6732242, + "ok": true, + "reason": "verified" + }, + { + "evidence": { + "code": "control_side_tls", + "uid": "f870c3eb-6c1e-4e6b-92bf-2f2b0fa4efc6" + }, + "name": "material-tls", + "observed_at": 1792938423.6732242, + "ok": true, + "reason": "verified" + }, + { + "evidence": { + "uid": "f870c3eb-6c1e-4e6b-92bf-2f2b0fa4efc6" + }, + "name": "namespace-budget", + "observed_at": 1792938423.6732242, + "ok": true, + "reason": "verified" + }, + { + "evidence": { + "uid": "e668ad01-b24b-4efa-bf70-5825d7bd61fe" + }, + "name": "network-direct-denied", + "observed_at": 1792938423.6732242, + "ok": true, + "reason": "verified" + }, + { + "evidence": { + "uid": "3b96e611-bfbe-4364-99d1-e8640b5d00ce" + }, + "name": "network-dns-denied", + "observed_at": 1792938423.6732242, + "ok": true, + "reason": "verified" + }, + { + "evidence": { + "uid": "7422ef13-5afa-4ab8-8bc6-032f3d79d9ec" + }, + "name": "network-egress-allowed", + "observed_at": 1792938423.6732242, + "ok": true, + "reason": "verified" + }, + { + "evidence": { + "uid": "61de8902-7c77-4442-9a82-d2b76209e467" + }, + "name": "network-ingress", + "observed_at": 1792938423.6732242, + "ok": true, + "reason": "verified" + }, + { + "evidence": { + "uid": "c1f9ea5a-6fc3-47da-a8c2-1a3d5b7a96b0" + }, + "name": "no-exec", + "observed_at": 1792938423.6732242, + "ok": true, + "reason": "verified" + }, + { + "evidence": { + "code": "control_side_authenticated_api", + "uid": "f870c3eb-6c1e-4e6b-92bf-2f2b0fa4efc6" + }, + "name": "platform-capabilities", + "observed_at": 1792938423.6732242, + "ok": true, + "reason": "verified" + }, + { + "evidence": { + "uid": "c1f9ea5a-6fc3-47da-a8c2-1a3d5b7a96b0" + }, + "name": "rbac-namespace", + "observed_at": 1792938423.6732242, + "ok": true, + "reason": "verified" + }, + { + "evidence": { + "uid": "61de8902-7c77-4442-9a82-d2b76209e467" + }, + "name": "restricted-psa", + "observed_at": 1792938423.6732242, + "ok": true, + "reason": "verified" + }, + { + "evidence": { + "policy_revision": "v1", + "uid": "61de8902-7c77-4442-9a82-d2b76209e467" + }, + "name": "status-provenance", + "observed_at": 1792938423.6732242, + "ok": true, + "reason": "verified" + }, + { + "evidence": { + "uid": "e571afa2-f754-448b-bc73-5e9e5f87bc2d" + }, + "name": "storage-sqlite", + "observed_at": 1792938423.6732242, + "ok": true, + "reason": "verified" + } + ], + "descriptor_digest": "8b4d243db44fd4baa2b1b78fe1d9e28e9fda2331940da9c5a3484a812e0e78fd", + "observed_at": 1792938423.6732242, + "source": "cluster-probe" +} \ No newline at end of file diff --git a/docs/reviews/evidence/vibe-release-20261025/k8s189-mainflow.json b/docs/reviews/evidence/vibe-release-20261025/k8s189-mainflow.json new file mode 100644 index 0000000000..54b9d717f1 --- /dev/null +++ b/docs/reviews/evidence/vibe-release-20261025/k8s189-mainflow.json @@ -0,0 +1,385 @@ +{ + "slug": "e2e-vibe-k8s-20261025", + "events": [ + { + "event": "runtime-catalog-configured", + "at": 1792938134.5826523, + "platform": "linux/amd64", + "official": false + }, + { + "event": "runtime-observed", + "at": 1792938134.6935134, + "executor": "kubernetes", + "ready": false, + "available_mb": 4096 + }, + { + "event": "business-flow-not-started", + "at": 1792938134.6938388, + "reason": "configured-only" + }, + { + "event": "runtime-catalog-configured", + "at": 1792938879.09368, + "platform": "linux/amd64", + "official": false + }, + { + "event": "runtime-observed", + "at": 1792938879.2507253, + "executor": "kubernetes", + "ready": false, + "available_mb": 4096 + }, + { + "event": "mainflow-error", + "at": 1792938879.253054, + "error_type": "AssertionError", + "reason": "runtime installation has not passed readiness", + "locations": [ + { + "function": "run", + "line": 118 + }, + { + "function": "run_until_complete", + "line": 654 + }, + { + "function": "main", + "line": 39 + } + ] + }, + { + "event": "runtime-catalog-configured", + "at": 1792938973.704902, + "platform": "linux/amd64", + "official": false + }, + { + "event": "runtime-observed", + "at": 1792938973.8237011, + "executor": "kubernetes", + "ready": true, + "available_mb": 4096 + }, + { + "event": "deployment-accepted", + "at": 1792938974.6527858, + "app_id": "c63121a2f58441df953762501fdc62d3", + "deployment_id": "3685d71782c546f69a10e0adf3670495", + "version_id": "b8dba3399b3d467e8006e66edc36a408" + }, + { + "event": "deployment-progress", + "at": 1792938974.7755468, + "status": "running", + "state": "draft" + }, + { + "event": "deployment-progress", + "at": 1792938997.3296766, + "status": "failed", + "state": "draft" + }, + { + "event": "deployment-failed", + "at": 1792938997.3301513, + "code": 16227, + "stage": "precheck_build" + }, + { + "event": "temporary-deploy-credential-revoked", + "at": 1792938997.4207375 + }, + { + "event": "mainflow-error", + "at": 1792938997.423008, + "error_type": "RuntimeError", + "reason": "deployment failed", + "locations": [ + { + "function": "run", + "line": 118 + }, + { + "function": "run_until_complete", + "line": 654 + }, + { + "function": "main", + "line": 59 + } + ] + }, + { + "event": "runtime-catalog-configured", + "at": 1792942693.9684994, + "platform": "linux/amd64", + "official": false + }, + { + "event": "runtime-observed", + "at": 1792942694.088782, + "executor": "kubernetes", + "ready": true, + "available_mb": 4096 + }, + { + "event": "retry-failed-deployment", + "at": 1792942694.3505647, + "app_id": "c63121a2f58441df953762501fdc62d3", + "previous_deployment_id": "3685d71782c546f69a10e0adf3670495" + }, + { + "event": "deployment-accepted", + "at": 1792942694.5136333, + "app_id": "c63121a2f58441df953762501fdc62d3", + "deployment_id": "3a41496861c340c1b455071452090e76", + "version_id": "64420d08e6e44ca2a38d7131f95afd3e" + }, + { + "event": "deployment-progress", + "at": 1792942694.631636, + "status": "running", + "state": "draft" + }, + { + "event": "deployment-progress", + "at": 1792942719.3805442, + "status": "failed", + "state": "draft" + }, + { + "event": "deployment-failed", + "at": 1792942719.3810406, + "code": 16227, + "stage": "precheck_build" + }, + { + "event": "temporary-deploy-credential-revoked", + "at": 1792942719.470533 + }, + { + "event": "mainflow-error", + "at": 1792942719.472946, + "error_type": "RuntimeError", + "reason": "deployment failed", + "locations": [ + { + "function": "run", + "line": 118 + }, + { + "function": "run_until_complete", + "line": 654 + }, + { + "function": "main", + "line": 63 + } + ] + }, + { + "event": "runtime-catalog-configured", + "at": 1792942951.6576405, + "platform": "linux/amd64", + "official": false + }, + { + "event": "runtime-observed", + "at": 1792942951.78467, + "executor": "kubernetes", + "ready": true, + "available_mb": 4096 + }, + { + "event": "retry-failed-deployment", + "at": 1792942952.02366, + "app_id": "c63121a2f58441df953762501fdc62d3", + "previous_deployment_id": "3a41496861c340c1b455071452090e76" + }, + { + "event": "deployment-accepted", + "at": 1792942952.1247234, + "app_id": "c63121a2f58441df953762501fdc62d3", + "deployment_id": "4f599876e25e4bcdaee8e1a213f183b0", + "version_id": "24fcb79a0b394e6d9634e952375f8091" + }, + { + "event": "deployment-progress", + "at": 1792942952.248616, + "status": "running", + "state": "draft" + }, + { + "event": "deployment-progress", + "at": 1792943100.6226368, + "status": "waiting_approval", + "state": "draft" + }, + { + "event": "approval-confirmed", + "at": 1792943100.8656135, + "instance_id": 1 + }, + { + "event": "temporary-deploy-credential-revoked", + "at": 1792943553.444028 + }, + { + "event": "mainflow-error", + "at": 1792943553.4489405, + "error_type": "TimeoutError", + "reason": "single deployment did not reach online", + "locations": [ + { + "function": "run", + "line": 118 + }, + { + "function": "run_until_complete", + "line": 654 + }, + { + "function": "main", + "line": 74 + } + ] + }, + { + "event": "runtime-catalog-configured", + "at": 1792943717.5486763, + "platform": "linux/amd64", + "official": false + }, + { + "event": "runtime-observed", + "at": 1792943717.663652, + "executor": "kubernetes", + "ready": true, + "available_mb": 4096 + }, + { + "event": "approval-execution-retry-requested", + "at": 1792943718.0166128, + "instance_id": 1, + "exception_id": 1 + }, + { + "event": "approval-execution-retry-returned", + "at": 1792943809.3155162, + "instance_id": 1 + }, + { + "event": "deployment-progress", + "at": 1792943809.4644253, + "status": "succeeded", + "state": "online" + }, + { + "event": "temporary-deploy-credential-revoked", + "at": 1792943812.5661318 + }, + { + "event": "mainflow-error", + "at": 1792943812.5688274, + "error_type": "AssertionError", + "reason": "('application-entry', 503)", + "locations": [ + { + "function": "run", + "line": 118 + }, + { + "function": "run_until_complete", + "line": 654 + }, + { + "function": "main", + "line": 78 + } + ] + }, + { + "event": "runtime-catalog-configured", + "at": 1792943907.4887767, + "platform": "linux/amd64", + "official": false + }, + { + "event": "runtime-observed", + "at": 1792943907.622679, + "executor": "kubernetes", + "ready": true, + "available_mb": 2944 + }, + { + "event": "deployment-progress", + "at": 1792943907.9696195, + "status": "succeeded", + "state": "online" + }, + { + "event": "temporary-deploy-credential-revoked", + "at": 1792944061.5865147 + }, + { + "event": "mainflow-error", + "at": 1792944061.590177, + "error_type": "AssertionError", + "reason": "('application-entry', 503)", + "locations": [ + { + "function": "run", + "line": 118 + }, + { + "function": "run_until_complete", + "line": 654 + }, + { + "function": "main", + "line": 82 + } + ] + }, + { + "event": "runtime-catalog-configured", + "at": 1792944198.9897182, + "platform": "linux/amd64", + "official": false + }, + { + "event": "runtime-observed", + "at": 1792944199.1507697, + "executor": "kubernetes", + "ready": true, + "available_mb": 2944 + }, + { + "event": "deployment-progress", + "at": 1792944199.4989026, + "status": "succeeded", + "state": "online" + }, + { + "event": "mainflow-passed", + "at": 1792944199.632141, + "app_id": "c63121a2f58441df953762501fdc62d3", + "version_id": "24fcb79a0b394e6d9634e952375f8091", + "entry": "http://192.168.106.189:30101/apps/e2e-vibe-k8s-20261025/", + "http_status": 200, + "tier": "light" + }, + { + "event": "temporary-deploy-credential-revoked", + "at": 1792944199.6852221 + } + ], + "app_id": "c63121a2f58441df953762501fdc62d3", + "deployment_id": "4f599876e25e4bcdaee8e1a213f183b0", + "version_id": "24fcb79a0b394e6d9634e952375f8091" +} \ No newline at end of file diff --git a/docs/reviews/evidence/vibe-release-20261025/k8s189-worker-control-rule-applied.json b/docs/reviews/evidence/vibe-release-20261025/k8s189-worker-control-rule-applied.json new file mode 100644 index 0000000000..4a0df2f081 --- /dev/null +++ b/docs/reviews/evidence/vibe-release-20261025/k8s189-worker-control-rule-applied.json @@ -0,0 +1,49 @@ +{ + "apiVersion": "networking.k8s.io/v1", + "kind": "NetworkPolicy", + "metadata": { + "name": "vibe-platform-control-ingress", + "namespace": "bisheng-vibe-runtime-system" + }, + "spec": { + "podSelector": { + "matchLabels": { + "app": "runtime-manager" + } + }, + "policyTypes": [ + "Ingress" + ], + "ingress": [ + { + "from": [ + { + "namespaceSelector": { + "matchLabels": { + "kubernetes.io/metadata.name": "bisheng-vibe-platform" + } + }, + "podSelector": { + "matchExpressions": [ + { + "key": "app.kubernetes.io/component", + "operator": "In", + "values": [ + "backend", + "worker" + ] + } + ] + } + } + ], + "ports": [ + { + "protocol": "TCP", + "port": 8091 + } + ] + } + ] + } +} \ No newline at end of file diff --git a/docs/reviews/evidence/vibe-release-20261025/migration.txt b/docs/reviews/evidence/vibe-release-20261025/migration.txt new file mode 100644 index 0000000000..e356017876 --- /dev/null +++ b/docs/reviews/evidence/vibe-release-20261025/migration.txt @@ -0,0 +1,23 @@ +. [100%] +=============================== warnings summary =============================== +bisheng/core/ai/__init__.py:2 + /Users/zhangguoqing/works/bisheng/.worktrees/vibe-app-integration/src/backend/bisheng/core/ai/__init__.py:2: DeprecationWarning: `langchain-community` is being sunset and is no longer actively maintained. See https://github.com/langchain-ai/langchain-community/issues/674 for details and migration guidance toward standalone integration packages. + from langchain_community.chat_models import ChatTongyi, ChatZhipuAI, MoonshotChat + +:241 +:241 + :241: DeprecationWarning: builtin type SwigPyPacked has no __module__ attribute + +:241 +:241 + :241: DeprecationWarning: builtin type SwigPyObject has no __module__ attribute + +:241 + :241: DeprecationWarning: builtin type swigvarlink has no __module__ attribute + +../../../../src/backend/.venv/lib/python3.11/site-packages/jieba/_compat.py:18 + /Users/zhangguoqing/works/bisheng/src/backend/.venv/lib/python3.11/site-packages/jieba/_compat.py:18: UserWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html. The pkg_resources package is slated for removal as early as 2025-11-30. Refrain from using this package or pin to Setuptools<81. + import pkg_resources + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +1 passed, 7 warnings in 0.20s diff --git a/docs/reviews/evidence/vibe-release-20261025/purge.txt b/docs/reviews/evidence/vibe-release-20261025/purge.txt new file mode 100644 index 0000000000..0e5439c906 --- /dev/null +++ b/docs/reviews/evidence/vibe-release-20261025/purge.txt @@ -0,0 +1,2 @@ +.............................. [100%] +30 passed in 1.11s diff --git a/docs/reviews/evidence/vibe-release-20261025/worktree-inventory.json b/docs/reviews/evidence/vibe-release-20261025/worktree-inventory.json new file mode 100644 index 0000000000..152313bdca --- /dev/null +++ b/docs/reviews/evidence/vibe-release-20261025/worktree-inventory.json @@ -0,0 +1,107 @@ +[ + { + "path": "/private/tmp/f059-cli-capacity", + "branch": "codex/f059-cli-capacity", + "head": "01b58350e97d71e87c784d4ebc58fd039a94b80c", + "contained": true, + "dirty": false + }, + { + "path": "/private/tmp/f059-destroy-fact-tests", + "branch": "codex/f059-destroy-fact-tests", + "head": "9f50990b6b477556bf35de433c83d022ba9641b6", + "contained": true, + "dirty": false + }, + { + "path": "/private/tmp/f059-destroy-maintenance-phase", + "branch": "codex/f059-destroy-maintenance-phase", + "head": "dd549c009fbdd4cdac5d9d97ee7d2a9c67fa75f4", + "contained": true, + "dirty": false + }, + { + "path": "/private/tmp/f059-installer-dnat", + "branch": "codex/059-installer-dnat", + "head": "dedc4831bb462f832877940901201061b1e027cf", + "contained": true, + "dirty": false + }, + { + "path": "/private/tmp/f059-prepare-cancel-tests", + "branch": "codex/f059-prepare-cancel-tests", + "head": "a277ad2bdaaa071d662e3099e8c4ce5ee3bdd61b", + "contained": true, + "dirty": false + }, + { + "path": "/private/tmp/f059-protection-backend-tests", + "branch": "codex/f059-protection-backend-tests", + "head": "cc1afdd3deccb2554687728b4adf3272df478589", + "contained": true, + "dirty": false + }, + { + "path": "/private/tmp/f059-protection-runtime", + "branch": "codex/f059-protection-runtime", + "head": "0338d2f65ff2b16af047648cbf2e90657bc9b8cb", + "contained": true, + "dirty": false + }, + { + "path": "/private/tmp/f059-restore-resume-tests", + "branch": "codex/f059-restore-resume-tests", + "head": "7968f3453da5ea5ed4f5f9e8325d3f4898891021", + "contained": true, + "dirty": false + }, + { + "path": "/Users/zhangguoqing/.codex/worktrees/f059-e2e/bisheng", + "branch": "codex/f059-e2e", + "head": "21250d32900c4c4ba893248aa5b8cc1d8d65182b", + "contained": true, + "dirty": false + }, + { + "path": "/Users/zhangguoqing/.codex/worktrees/f059-image-release/bisheng", + "branch": "codex/f059-image-release", + "head": "f84415f2540b1b762dcfba2b7e8e521ca170f3bd", + "contained": true, + "dirty": false + }, + { + "path": "/Users/zhangguoqing/.codex/worktrees/f059-installation/bisheng", + "branch": "codex/f059-installation", + "head": "992905292418308b298d48a24d20cc9e19032c15", + "contained": true, + "dirty": false + }, + { + "path": "/Users/zhangguoqing/.codex/worktrees/vibe-k8s-runtime/bisheng", + "branch": "codex/059-k8s-runtime", + "head": "cda8dccf26298af91a1df277dcd1a49787b6b444", + "contained": true, + "dirty": false + }, + { + "path": "/Users/zhangguoqing/.codex/worktrees/vibe-runtime-delivery/bisheng", + "branch": "codex/086-vibe-runtime-delivery", + "head": "667546bb77eeb366e66a13d544d122ef24811e35", + "contained": true, + "dirty": true + }, + { + "path": "/Users/zhangguoqing/.codex/worktrees/vibe-tenant-prerequisites/bisheng", + "branch": "codex/f086-tenant-bootstrap", + "head": "9d156f261cc7769fcf75f41caeb104a425308017", + "contained": true, + "dirty": false + }, + { + "path": "/Users/zhangguoqing/works/bisheng/.worktrees/vibe-app-integration", + "branch": "feat/3.0.0-vibe", + "head": "804e4a1965cb4134a09633e1d36b85131ca96d6c", + "contained": true, + "dirty": false + } +] diff --git a/docs/reviews/hosted-app-global-view-secrets-2026-10-30.md b/docs/reviews/hosted-app-global-view-secrets-2026-10-30.md new file mode 100644 index 0000000000..7d132e46a9 --- /dev/null +++ b/docs/reviews/hosted-app-global-view-secrets-2026-10-30.md @@ -0,0 +1,29 @@ +# 全平台视图管理子租户托管应用时误报负责人权限 + +## 现场证据 + +2026-10-30,通过用户已登录 Chrome 在截图地址 `192.168.106.120:3002/build/apps/18bce12e6ae34a07abd798c2c13b3daf` 核验。用户切换为系统管理员后,页面明确显示 admin / 整个平台;应用“档位测试应用-标准”的基本信息、运行实例、审批状态及两条版本记录均正常加载,环境变量区显示负责人权限错误。 + +Network 实际请求 `GET /api/v1/apps/18bce12e6ae34a07abd798c2c13b3daf/secrets`:HTTP 200,业务码 16254,消息 `Only the application owner may perform this action`。错误由发布页自动读取环境摘要触发。未执行应用下线、删除、写配置或其他现场变更。 + +## 根因与修复 + +环境摘要读复用了负责人写事务的 strict tenant 过滤;全平台视图下,超管当前租户仍是其归属租户,子租户应用在业务鉴权前就被过滤掉,Repository 统一报负责人限制。指定目标租户的视图本来可读取,同一应用会因视图不同得到矛盾结果。 + +增加管理摘要专用只读事务:先使用现有管理视图过滤定位应用,再临时绑定应用租户,严格读取配置、版本和运行快照;成功或异常后恢复原请求上下文。Service 在读配置前继续检查请求租户与 owner / 超管 / 租户管理员 / 现有 edit 资格。非 owner 仍只取得冻结版本声明的名称及配置状态,不能取得私有未声明名称、值、掩码或逐项更新时间。 + +读侧应用不存在或不在当前视图内使用 16101,管理资格不足使用 16106。保存、删除与运行重建保持原有业务资格;负责人专属修改仍返回 16254。没有新增错误码、接口、依赖或数据库变更。 + +## 验证 + +- 原环境配置 Service / Repository / API 基线 12 项通过。 +- 新回归在修复前 5 失败、1 通过:全平台读取误拒、拒绝原因错误均被实际捕获,指定目标租户读取正常。 +- 修复后 9 个相关测试文件共 76 项通过,包括新增 11 项真实 ORM / API 用例:全平台与指定租户读取、其他租户视图拒绝、非超管跨租户拒绝、edit allow/deny、摘要脱敏、超管写拒绝、当前运行环境比较及异常后作用域恢复。 +- Ruff format/check、改动文件架构守卫、diff 空白检查通过。测试输出仅有既有第三方依赖弃用警告。 +- F055 design D15、已知坑和修订历史同步更新;未运行全仓或专用 DM8 验证。 + +## L2 审查 + +按 sdd-code-review 七维度审查:边界条件、权限认证、并发安全、信息泄漏、测试覆盖、代码风格、文档同步均无本次改动发现项。应用定位继续依赖受信管理视图自动过滤;未引入全局 bypass;解密和业务数据读取位于资格检查之后;相关查询恢复为应用所属单租户。原写事务与 CAS / 审计规则未改,读取不提交写入。 + +代码修复已完成,尚未部署到现场。升级后需以系统管理员在整个平台视图重开应用,确认环境摘要成功且无负责人错误提示;再配对验证普通用户拒绝路径。本文不宣称现场已修复。 diff --git a/docs/reviews/knowledge-and-ui-issues-2026-11-03.md b/docs/reviews/knowledge-and-ui-issues-2026-11-03.md new file mode 100644 index 0000000000..24da012399 --- /dev/null +++ b/docs/reviews/knowledge-and-ui-issues-2026-11-03.md @@ -0,0 +1,53 @@ +# 3.0 知识空间与 UI 缺陷处理记录 + +本轮按已确认的“单标签选择 → 跳转日常对话 → 固定检索范围 → 可取消 → 会话重进保留”范围实现。尚未部署。后台与前台代码需配套发布,旧后端不会识别新增标签参数。 + +## 工单对照 + +| 工单 | 处理结果 | +| --- | --- | +| IKSK0E | 恢复真实空间标签查询;区分加载、失败、空列表;打开候选时刷新,可重试。标签随空间跳转,输入框独立展示并可删除/撤销恢复。发送和重新生成携带固定筛选,后端校验归属并在双索引召回前过滤,和主版本/权限范围取交集。 | +| IKSLFS / IKSLDV | 补回任务、日常模式默认模型选择器,沿用已有配置字段与 client 默认模型解析。 | +| IKSKN5 | 约束编辑成员弹窗 grid 与多选内容的最小宽度,防止内容撑出弹窗。实际浏览器长内容布局待复测。 | +| IKSKA7 | 仅对 BS@root 且名称仍是内置默认名的组织作展示翻译,保留自定义组织名。 | +| IKSKBY / IKSKB1 | 加入空间的“退出”不再因管理员同时具有删除权限而进入真实删除分支;创建、部门空间的删除仍保留。 | +| IKSKBB | 对 citations 元数据精确匹配的裸引用标记恢复角标,未知引用不猜来源,代码/链接保持原文。现场原始响应未取到,不宣称线上截图已复现。 | +| IKSKT9 | 前端公共发送入口、重新生成补能力兜底;后端无 OCR 时明确失败,不再吞掉不支持文件异常。复用已有解析服务超时,错误 SSE 后正常结束,不切换模型。生产现场所有“持续执行”原因未逐一复现。 | +| IKSKT8 | 仅将默认上传类审查提示改为文章阅读提示,保留自定义提示和原审查策略。 | +| IKSKXX | 116 只读确认是现有免审路由配置,未改代码/配置。详见下节。 | +| IKSJF2 / IKSJEF | 已由此前提交 234c154b3a 修复错误提示遗漏与上传租户标记问题。 | +| IKIE8D / ICV72W / IK18YG | 用户明确排除,本轮不处理。 | + +## 标签范围与兼容 + +- 新参数 `use_knowledge_base.knowledge_space_tag_filters` 为可选的 `{space_id: tag_id}` 映射,未传时保留原逻辑。 +- tag 必须属于已选知识空间,服务端按实际标签及文件关系验证,不信任前端名称。用户固定标签不会被模型工具参数覆盖。 +- 标签空集合、已删除或解析异常不会退回全空间检索。 +- 日常模式支持标签;任务模式带标签时明确阻止发送并保留输入,提示取消标签或切回日常模式。 +- 会话选择沿用工作台既有按账号、会话保存的本地机制,支持同浏览器刷新与重进;没有新增跨设备同步协议。 + +## 116 只读证据 + +频道“公众号频道订阅”配置为 REVIEW;最近两次订阅实例 491、492 均命中路由 32(AAA,pass,空匹配条件兜底)。没有生成审批任务,outbox 185、186 均执行成功,审计包含 `approval.route.pass` 与随后约 1 秒的 `approval.handler.success`。当前“普通用户需要审批”仅匹配角色 110,其他非管理员用户会落到 AAA 免审。全程只读事务并回滚,没有调整线上路由。 + +## 局部验证 + +- Client:9 个相关测试文件共 95 项通过;补齐引用编辑器 DOM 删除/恢复及图片重新生成兜底两项后,对涉及的 3 文件复跑 47 项通过。本轮唯一测试项合计 97 项。 +- Backend:标签归属、固定筛选、双索引/主版本/权限、图片失败结束与审查/订阅状态等 8 个相关文件,81 项通过。 +- Platform:默认模型保存及根组织展示 3 文件,7 项通过。 +- Shared chat:引用标记与 Markdown 渲染 2 文件,14 项通过。 +- 定向 ESLint、Ruff、相关新前端文件语义检查、三语 JSON 及差异空白检查通过。只读独立审查未发现 P0–P2。 +- 没有跑全量测试;没有用单元测试冒充真实 ES/Milvus/浏览器端到端验证。现有测试仍有 React act/timer 与依赖弃用告警。 + +## 发布后手工测试 + +1. 知识空间中已有文件标签:输入 `#`,看到真实候选;网络慢时显示加载,失败可重试。 +2. 选择标签后提问:新对话同时出现空间、标签;实际召回仅包含该标签且有权限的主版本文件。 +3. 删除标签继续追问:恢复当前已选空间的正常检索;撤销删除恢复标签,空间不丢失。 +4. 刷新、重新进入、切换两个会话:标签不串会话;取消对应空间后不留下孤立标签。 +5. 删除服务端标签或撤销文件权限后再问:明确失败或无结果,不扩大检索范围。 +6. 带标签切任务模式:保留输入并提示;取消标签或返回日常后可继续。 +7. 后台默认模型保存后刷新回显;没有手选模型的新用户使用对应模式的默认值。 +8. 管理员在“我加入的”执行退出:确认弹窗和动作均为退出;拥有删除权限时也不能误删空间。 +9. 非视觉模型且无解析服务:保留图片和文字,阻止发送;后台配置变更导致前端缓存陈旧时,后端也明确失败并结束。 +10. 多用户组/角色与长名称的编辑成员弹窗:右侧内容、保存按钮可见,不横向撑出弹窗。 diff --git a/docs/reviews/knowledge-ask-react-update-loop-2026-10-30.md b/docs/reviews/knowledge-ask-react-update-loop-2026-10-30.md new file mode 100644 index 0000000000..a2c8c01364 --- /dev/null +++ b/docs/reviews/knowledge-ask-react-update-loop-2026-10-30.md @@ -0,0 +1,24 @@ +# 知识空间问答跳转新对话后页面崩溃 + +## 现场与根因 + +2026-10-30,Chrome 当前页面为 `192.168.106.120:3002/workspace/c/new`,从知识空间提问后出现前端错误页。现场版本 `v3.0.0 (20261030-1041)`,错误码 `7YDW-QC6G`;下载诊断日志的追踪 ID 为 `36a14358f2de4110`,同一次调查初始截图追踪 ID 为 `269f8b4e2fc04938`。 + +诊断日志为 React error #185。对应公开业务脚本 `workspace/assets/main.wUYH7M14.js:2:767801`,定位到模型选择回调写入现有 Recoil chatModel;堆栈中的 replaceState 是共享状态库更新,并非浏览器跳转。真实 RecoilRoot + 原 chatModel + 原模型解析与会话知识选择组合测试复现 `Maximum update depth exceeded`,在模型写入处失败,修复前原有 14 项手工 useState 夹具测试仍通过。 + +问题接收逻辑先更新知识选择、模型,再把 React 局部状态标记为准备完成。共享模型库会同步通知订阅者,导致准备状态尚未提交就重入同一个 effect,反复选择模型而崩溃。 + +## 修复 + +在改变知识选择和模型之前,用 ref 标记该问题正在准备,防止同步重入重复执行。原 preparedAsk 状态仍用于等待选择更新后的渲染,确保选择相同时也能继续提交;不回退为仅 ref 的旧实现。每个新问题重置准备标记,沿用现有提交防重、模型不可用退回编辑和附件等待流程。 + +没有新增 Recoil atom、生产导入、跨页状态或 API。新增测试直接使用已有共享模型对象和真实 Recoil 实现。两个知识问答输入测试原先按旧 button/title 定位模型选择器,与已落地 combobox 不符;测试选择器改为当前可访问角色和完整展示文本,保留选模、携带模型 ID、清理空白和新建对话行为断言。未改选择器产品实现。 + +## 验证与审查 + +- 6 个相关文件 66 项测试通过:问题接收、真实共享状态、连续提问、StrictMode 重放、缓存路由、实际聊天请求、会话知识选择、模型解析与知识空间输入。 +- 同模型 / 不同模型均使用真实共享状态验证;每次问题只准备、提交一次,模型与知识空间正确携带。 +- 客户端类型检查通过(1690 个 strict 文件);改动文件 ESLint、架构守卫、diff 空白检查通过。未运行全仓测试;本轮未重跑包含已知其他文件 lint 问题的全前端门禁。 +- Hotfix L2 七维度自审无本次发现项:空问题/失效模型/附件及重放保留原路径;无权限、租户、后端和持久协议变化;同步重入在状态写之前阻止;连续新问题可继续处理;现场堆栈与回归覆盖同一断点。 + +代码修复已完成,尚未部署到 120 或 116,未宣称现场页面已恢复。部署客户端前端后,需从同一知识空间连续提出两个问题,确认不再出现错误页、所选模型与知识来源正确且无重复提交。 diff --git a/docs/reviews/tenant-admin-dataset-404-2026-10-30.md b/docs/reviews/tenant-admin-dataset-404-2026-10-30.md new file mode 100644 index 0000000000..c54deb9f93 --- /dev/null +++ b/docs/reviews/tenant-admin-dataset-404-2026-10-30.md @@ -0,0 +1,31 @@ +# 3.0 租户管理员数据集入口 404 + +2026-10-30;分支 `codex/fix-3.0-tenant-admin-dataset`,基于 `feat/3.0.0`。按 hotfix 流程,业务逻辑仅增加一个已有菜单权限。 + +## 原因与修复 + +管理端侧栏按租户管理员身份展示数据集入口,但路由权限补充清单遗漏 `dataset`。角色菜单未单独授予数据集时,动态路由删除 `/dataset`,访问落入 404。数据集列表 API 已要求租户管理员身份,前端路由与现有业务资格不一致。 + +在租户管理员路由清单补入 `dataset`,保留普通用户、部门管理员的角色菜单检查。 + +## 验证 + +- 基线原有路由测试 17 项通过;新增真实路由注册回归后,租户管理员用例因缺少 `dataset` 失败,普通用户及部门管理员的授权/拒绝用例通过。 +- 修复后,路由权限、路由过滤及身份侧栏测试共 27 项通过。 +- 全前端类型检查通过;管理端全量 lint、改动文件定向 lint、架构守卫及 diff 空白检查通过。 +- 全前端 lint 未通过:聊天端 `src/frontend/client/src/components/Chat/AiChatInput.tsx:375` 已有变量 `hasAttachedFiles` 未使用。该文件未修改,HEAD 中同样只有声明。 + +## L2 七维度审查 + +边界条件、权限认证、并发安全、信息泄漏、测试覆盖、代码风格、文档同步均无本次改动发现项。权限检查覆盖非管理员授权/拒绝;不改 API、数据隔离或资源动作鉴权;无新状态和依赖。此为既有功能纠错,无新 feature spec/design。 + +## 现场状态 + +用户授权系统权限后,通过 Chrome 实际登录会话完成核验: + +- `192.168.106.116:3001/dashboard` 显示 gzx01,侧栏展示数据集;点击后实际跳转 `/404`,页面显示“页面不存在”。 +- Network 中 `/api/v1/user/info` 返回 200:user_id=849,is_child_admin=true,is_department_admin=false,is_global_super=false,leaf_tenant_id=60,leaf_tenant_name=“测试应用审批”。web_menu 为 admin/home/linsight_task_mode/subscription/apps/workstation/knowledge_space/knowledge/build,缺少 dataset;菜单审批模式为 false。 +- 116 公开前端入口引用 `index-CSW4-cjX.js`,业务脚本为 `TenantSelect-v09g3rto.js`(SHA-256:`1b2c56c4bd3687ebd0434237d6bbb8102ded0f6fcc6a650cd5943fbb2b351de9`)。只读下载核对:`/dataset` 路由要求 dataset,但租户管理员补充清单为 board/build/create_app/knowledge/model/evaluation/mark_task/log/sys/workstation,确实遗漏 dataset。 +- 回归测试已使用现场用户的完整菜单及管理员标记。 + +现场根因已确认。尚未部署到 116,SSH 密钥认证仍未通过;需更新 116 管理端前端后再做修复后浏览器验收。未改账号角色或租户数据。 diff --git a/docs/reviews/tenant-bootstrap-prerequisite-review.md b/docs/reviews/tenant-bootstrap-prerequisite-review.md new file mode 100644 index 0000000000..f4fc17c56d --- /dev/null +++ b/docs/reviews/tenant-bootstrap-prerequisite-review.md @@ -0,0 +1,38 @@ +# 公共租户启动修复评审 + +## 范围与来源 + +- 基于发布线 `feat/3.0.0@8510a9645382c7ac5f886503e26ae68cb8a0d935`;只读 fetch 后 origin 同 SHA。 +- F086 真实多租户验收发现:后台启动/投影只有业务 actor.tenant_id,未绑定 ORM ContextVar;没有 HTTP 请求上下文时无法完成系统资源与首管理员预置看板投影。 +- 复用 F086 工作树中已经验证的同一修复,不搬入 Vibe 代码、迁移或运行时实现。六个实现/测试文件复制前已与发布线基线逐文件比对,无基线差异。 +- 属于现有启动行为的 hotfix,不新增业务入口、权限动作、数据库结构或授权分支。 + +## 设计与行为 + +`tenant_execution_scope(verified_tenant_id)` 只绑定经调用者验证的租户,不授予权限。范围内设置 current tenant、单租户 visible set,清除管理覆盖和 bypass;退出时按逆序恢复原值。可嵌套、支持异步任务各自隔离,异常同样恢复。 + +系统资源 reconcile 对每个已经过检查的资源租户进入该范围,再走原 authorize_system_owned;首管理员看板进入原 DEFAULT_TENANT_ID 范围,再走原 authorize_created。原先 actor、目标、动作、幂等键和权限主链路不变。 + +不能从 actor 中带有 tenant_id 推断 SQLAlchemy 已绑定同一租户;也不能用全局 bypass 解决后台写入,这是此次修复的必要原因。 + +## 七维审查 + +同一代理自审,无独立代理或外部评审。 + +| 维度 | 结果 | 证据/限制 | +| --- | --- | --- | +| 边界 | PASS | 非正整数、bool、None、字符串租户均拒绝;无 HTTP 新入参 | +| 权限/租户 | PASS | 仅使用已验证资源租户;清除继承的 admin/visible/bypass;保留原权限入口 | +| 并发 | PASS | ContextVar tokens 保证嵌套、异常与 async task 隔离;不引入共享可变全局状态 | +| 信息泄漏 | PASS | 无凭据、HTTP响应或日志变化 | +| 测试 | PASS | 三个定向测试文件 17 passed;覆盖原无上下文启动回归和状态恢复 | +| 风格/分层 | PASS | 核心上下文工具不依赖领域;已有两个消费者原层级不迁移;Ruff和逐文件arch-guard通过 | +| 文档 | PASS | 本文记录根因、上下文合同与限制;未改变既有业务设计 | + +## 验证边界与合入顺序 + +本工作树执行的是定向本地单元回归;F086 既有109环境证据属于此前验收,未在这里重新部署发布线。该补丁不能被描述为完整 F086 或 F059 已交付。 + +公共修复需要先进入 `feat/3.0.0`,再由 Vibe 衍生分支继承。原 F086 工作树及主工作区均保留;本分支未直接推送发布线。BIGINT 扩容修复属于尚未进入该发布线的 Vibe 模型,不包含在本补丁中。 + +**结果:PASS(仅本公共 hotfix 范围)。** diff --git a/docs/reviews/vibe-116-management-fixes-2026-10-29.md b/docs/reviews/vibe-116-management-fixes-2026-10-29.md new file mode 100644 index 0000000000..249a9b601f --- /dev/null +++ b/docs/reviews/vibe-116-management-fixes-2026-10-29.md @@ -0,0 +1,72 @@ +# 116 管理视角及 Vibe 应用缺陷修复 + +## 交付范围 + +2026-10-29,直接在最新 `feat/3.0.0@4721204ce1e82129bf502ebb6c6406f2e5535754` 修复。按用户本次明确的缺陷和视角语义实施,属于现有功能纠正;未增加数据库表、迁移资源、扩大自然人归属或 SDK 凭据权限。仅本功能定向验证。 + +本记录覆盖代码和窄范围现场验证;未更新 116 release backend/worker/前端镜像,也未更新 120 前端;提交推送不等于已部署。116 当前服务为 release 镜像,manager 为此前的专用镜像。 + +## 十项问题及处理 + +| # | 定位 | 修复 / 边界 | +| --- | --- | --- | +| 1 | 正式 route=serving、instance=running,但运行操作 stage=completed;原运行配置关联仅接受 serving | 接受匹配的 completed 正式操作,继续核对状态、路由/实例、version、generation、owner_epoch 及密文;失败状态拒绝。配置未丢失,此只读提示不阻止正常发布 | +| 2 | 管理列表继承 leaf+root 可读范围,管理员列表短路后列出无法管理的 root 知识库 | 共用管理视角;非超管管理范围收紧为本租户。超管右上角全平台/明确租户选择;自动 ORM 和 UNION clause 同步 | +| 3 | managed 列表按 F048 edit 可见,而应用详情和发布状态只允许原创建人/管理员 | 平台详情、实例、发布状态采用同租户 edit;环境变量对授权管理用户只读引用名/状态。私有数据、日志、开发凭据、变量写/重建维持原业务资格 | +| 4 | 最新分支已删除无作用按钮,现场旧前端仍显示 | 保留已实现删除及 CLI 提交说明,不重复添加按钮或另造发布入口;需更新前端后生效 | +| 5 | 宿主和 backend 都为 UTC+8;记录 API 输出 UTC,页面原样显示 | 带时区记录按浏览器本地时间格式化,查询和导出保持 UTC;不改服务器时间或历史值 | +| 6 | approval/admin 未接入共享视角,管理 endpoint 用登录人的归属 tenant_id | 加入共享视角;读取管理范围而非把人员迁到目标租户。全平台列表明确支持可选 tenant,写配置保留具体租户 | +| 7 | 异常列表进度复用个人实例详情,要求申请人或任务持有人 | 新增管理只读详情 endpoint,验证超管/同租户管理员及选中范围;不授予同意、拒绝等任务处理权 | +| 8 | 跨租户列表范围与业务删除限制是两个问题;现有合同只允许创建人删除,有效创建人的应用不允许管理员删除 | 先修复租户视角。已有批量 owner 查询增加 owner_active 投射,前端同时检查 Catalog 和业务资格,隐藏无权删除入口;创建人停用/移除后的管理员清理路径保留。未扩大超管删除权,相关可选政策问题已向用户询问,未经另行确认不放开 | +| 9 | asetNx 每次 SETNX 后都 EXPIRE,即使获取失败也续期,持续使用时节流锁永不释放 | 异步适配器改原子 SET NX EX,失败不续期。保留每个凭据最多每分钟写库一次;此时间不是逐请求实时事件 | +| 10 | auto 分支仍显示人工长度/重叠参数 | 自动切分不显示参数面板;自定义/层级切分保持原控件和取值;删除触及文件的一条旧中文 fallback,复用现有三语 key | + +## 统一视角实现边界 + +- `HeaderTenantScope` 复用现有 Select、F019 Redis scope API。明确区分 null 全平台与 id=1 的 Root 租户,加载全部租户选项并过滤非法 ID。 +- 切换清理 query cache 并重载页面,覆盖 useEffect 管理的页面及旧表单;模型、工作台和服务账号页的重复选择器移除。 +- scope 中间件采用真实路由前缀和路径边界;包括知识库、模型、应用管理、构建的元数据操作、审批管理、审计、权限/角色/用户组等管理请求。聊天执行、工作流 run_once/WS、v2 RPC、个人审批不受管理视角影响。 +- 全平台是 SELECT 级受信范围,不等于全局 bypass;INSERT 仍绑定明确租户,业务鉴权和已有显式过滤保留。后台 `tenant_execution_scope` 清除全平台及管理标记,防止扩大 worker 读范围。 +- 服务账号仍要求明确租户和原 X-Service-Account-Tenant 范围确认,不能在全平台直接创建/修改。 +- 本次对实际报告问题入口及公用过滤器做配对验证;不以通用过滤器测试宣称每个历史模块的定制查询和写权限均已端到端验收。 + +## 现场验证 + +1. 116 宿主 `2026-10-29T20:55:30+0800`;backend local/utc 差八小时,符合配置,没有修改系统时钟。 +2. 目标应用 `84cbd14b90424a879ef39dc2c8674a73` 在线,tenant=60;route/instance/op 的 version、generation=7、owner_epoch=1、exec_ref 匹配,sealed_env 存在,但 op.stage=completed。一次性只读 Python 进程对比旧、新关联:`old_match=false / patched_match=true`。没有解密或输出环境值,没有写入运行事实。 +3. 116 Redis 创建两个 UUID 临时键,2 秒期限;第一次获取后间隔 1.15 秒获取失败,再隔 1.15 秒重试:旧适配器 false,修复适配器 true。两个键在 finally 删除,未操作现有凭据或节流键。 + +## 定向验证 + +- 公用管理视角/真实 SQLite SELECT 与 UNION clause、异常管理详情/API 权限配对、真实 RedisClient 适配器隔离测试:34 项通过;后续补充全平台配置写拒绝配对用例。 +- 配置比较、凭据时间更新及旧 scope 中间件相关文件:16 项通过。 +- 原变量保存/隔离、详情 edit allow/deny、CLI/MCP/跨租户拒绝、后台视角恢复:15 项通过。 +- 删除资格投射、非 owner 拒绝、停用 owner 管理员清理:7 项通过。 +- 前端:Header 视角、审计时间、自动切分、共用时间格式化 11 项;工作台和模型迁移 2 项;删除资格 4 项;服务账号范围快照 9 项通过。 +- Ruff、改动文件架构守卫、定向 ESLint、JSON 解析和 diff 空白检查执行结果以本轮交付输出为准。ESLint prune 仅减少 RuleFile 一条已移除的中文 fallback 抑制。 +- 平台类型门未通过:本机缓存包缺少部分 rehype/remark/dnd 依赖及共享组件图标类型;发现旧 ScopeBar 另一调用者后已修复并完成对应测试,不把环境依赖问题记为通过。 +- 额外触及的旧审批测试有 5 项失败;在未修改的 e30dd8fe0 基线重现相同 5 项:引用已删除 create_node、过时查询/撤回夹具缺少当前依赖。没有通过新增测试别名或重新连接废弃 API 掩盖它们。 + +未运行全量测试,未做升级后的浏览器验收或 Kubernetes 验证。DM8 无新 DDL 或专有 SQL,本轮仅静态兼容检查。 + +## L2 定向审查 + +按 sdd-code-review 七维度自审:范围只在已验证的浏览器管理请求,非超管忽略 stale super scope;严格后台作用域覆盖并恢复全平台标记;未把 F048 edit 当私有变量写或开发凭据资格;管理进度只读入口先 admin 检查再限定目标租户;last-used 保留限流,只有 NX 成功设置期限;无密钥明文、日志正文或新数据迁移。 + +限制:完整类型门和旧测试夹具尚未通过;正式服务仍未升级。第 8 项若要求放开有效创建人的管理员删除,需用户另行确认业务边界。 + +共享角色目录例外:角色列表的显式 tenant 参数同步管理视角;全局预置角色仍属于可读公共目录,不能因为收紧租户过滤而让合法角色详情返回 403。RoleDao 的管理读取将公共 global 类型与当前视角内自定义角色并集,不放开其他租户的 custom role。定向 SQLite 查询覆盖指定租户与全平台两种情况。 + +架构守卫收尾:角色模型原有反向导入 user.domain 的纯 ORM 映射;将 UserRole/UserRoleBase 移至 database/models,原 domain 模块保留同一对象的导入兼容,DAO 不搬迁,不重复定义表、不改变列或约束。共享对象及表名/非空约束有定向检查。 + + +## 2026-10-30 第 8 项授权后的补充修复 + +用户明确确认允许平台超管删除有效创建人的应用,保留二次确认和审计;取代上文第 8 项暂不放开的边界。 + +- 后端仅增加全局超管删除资格;保留租户管理员清理失效 owner 的原例外。目标仍经管理视角过滤加载,在线态仍拒绝删除,不自动停用。 +- 卡片和详情危险操作区均向超管开放;仍复用 useHostedAppActions 的确认流程,取消不调用删除 API,文案明示代码、对话历史及应用生产数据删除范围。 +- 沿用资产清理、权限删除投影及审批取消 hooks;审计保留应用租户、实际操作人及其租户、目标、版本,并明确区分平台超管删除原因。 +- 定向验证覆盖有效 owner 的草稿/待上线/已下线应用超管删除及审计、在线拒绝、租户管理员拒绝、原失效 owner 清理、普通用户/迁租用户拒绝,以及卡片和详情入口、确认与取消。不对 116 存量应用执行删除,不升级现场镜像。 + +本次补充验证结果:后端删除选集 26 通过、6 跳过,另 2 项迁租/凭据拒绝验证通过;前端卡片、详情入口及共用确认流程共 14 通过。Ruff、定向 ESLint、架构守卫和 diff 检查通过。删除选集额外选到旧的 stop/resume 验证 `test_no_path_deletes_assets_except_explicit_delete`,当前夹具报缺少 app_deployment 表;旧 e30dd8fe0 基线相同用例也失败,但表现为缺少发布版本记录(不是相同异常文本)。该恢复夹具问题不计为本次通过,未扩展修改、不重跑全量。 diff --git a/docs/reviews/vibe-app-hosting-commercialization-review.html b/docs/reviews/vibe-app-hosting-commercialization-review.html new file mode 100644 index 0000000000..c34dd3f66c --- /dev/null +++ b/docs/reviews/vibe-app-hosting-commercialization-review.html @@ -0,0 +1,2014 @@ + +Vibe 应用托管、发布与商业化技术评审
BISHENG · 技术评审 历史参考 · 已替代
736ef59 · 2026-10-16Markdown 源文

Vibe 应用托管、发布与商业化技术评审

+

文档日期:2026-10-15 | 更新日期:2026-10-16 | 状态:技术评审稿 | 读者:架构、研发、交付与商业产品负责人

+
+

历史参考,已被替代(2026-10-19): Vibe 已确定全部按开源实现。本文中的商业授权、“免费同时在线 10 个应用”和闭源 runtime 建议不再作为实施要求;当前有效方案见 Vibe 应用托管全开源技术方案。下文保留当时的代码核查和讨论记录。

+
+

评审目的: 说明现有应用托管链路如何工作、现场需要哪些资源,以及哪些能力适合作为商业模块;同时评估“预制运行镜像+应用包”能否降低离线交付成本。

+
+

结论口径: 现有源码发布采用“平台模板构建应用镜像”的 Docker 路线;K8s 仅核实到 F059 规格,不能视为已交付。应用包路线和商业 runtime 均为评审建议。会话已确认“源码包+完整镜像”双交付意向,以及“免费同时在线 10 个应用、停用释放名额”的商业口径。2026-10-16 进一步确认存储边界:附件接口迁到 backend;manager 保留本地 SQLite 备份、迁移和恢复,通过 backend 接口下载源码、上传快照,不直接访问 MinIO。这些目标能力尚不能按现有代码认定为已实现。

+
+

阅读导航

+ +

+

1. 代码基线、结论与决策状态

+

1.1 本次核查范围

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
对象核查基线使用方式
BiSheng 托管实现feat/3.0.0-vibe,完整提交 736ef593f3fc6e1634008c842d2604304d6f70a6已用只读远端查询确认分支提交;文中代码链接固定到该提交
当前工作目录/Users/zhangguoqing/works/bisheng,工作分支为 feat/3.0.0-beta3通过 git show 读取 vibe 提交,未把 beta3 本地改动当成托管实现
Gateway 授权参考本地 feat/dsh-access,提交 faed249a96ea43a4c65b90765dd4054bf6e55722仅说明所检查源码中的许可证与 DSH 席位机制,不代表远端最新 Gateway
市场与技术资料Posit Connect、Modal、Cloud Foundry、Fission、Coolify,以及 Kubernetes、SQLite 官方文档用于解释技术路线与边界,不作为毕昇已经实现这些能力的证据
+

本次完成的是静态源码、部署配置及外部官方资料核查。没有实际运行客户 Docker/K8s 集群、构建离线应用、测试许可证或执行数据恢复。仓库中的部分注释保留早期设计表述,判断以当前调用、参数和部署配置为准。

+

1.2 哪些事实已经成立

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
状态事项对评审的意义
已实现CLI 上传源码 tar.gz;后端保存 MinIO 快照;Celery 调 manager 构建与探活;审批后发布当前并非“用户给一个镜像就能上线”的接口 C01 C06 C08
已实现manager、app-proxy、egress-proxy、docker-socket-proxy 是四个 Compose 服务逻辑职责、进程和镜像三个概念需区分;egress-proxy 与 manager 使用同一代码镜像的不同入口 C29
已实现manager 经 socket proxy 访问 Docker Engine API;应用不发布宿主机端口manager 镜像不需要 Docker CLI;容器内相同监听端口可以共存 C10 C16 C29
已实现SQLite 位于按应用隔离的宿主机挂载目录;附件和迁移快照可进入 MinIO运行数据独立于源码包和应用镜像 C17 C21 C22
规格约定K8s 构建执行体、镜像仓库、RBAC、网络策略、PVC 与离线交付本次未在 runtime 实现中发现对应 K8s 后端;不能直接宣称支持 C34
会话已确认同时在线应用数计费;免费 10 个,停用释放;期望支持源码包和完整镜像是目标业务要求,不是当前授权代码事实
会话已确认,待实现附件接口归 backend;manager 保留本地 SQLite 操作,通过 backend 下载源码、上传快照,不直接访问 MinIO存储实现和凭据归平台;目标调用链见 §3.6,接口契约仍需设计
待评审建议默认源码路线改用运行镜像+应用包;独立闭源 runtime 承担商业编排涉及发布契约、部署执行及授权设计,需评审后再进入实施
+

1.3 本文的重点建议

+

应用形态有限、离线交付普遍时,优先验证“预制运行镜像+不可变代码包+可复用依赖环境”,保留用户提供完整应用镜像的模式。复杂系统依赖和高度定制应用仍适合完整镜像。商业价值应落在实际拥有环境准备、部署状态、恢复、路由和配额控制的闭源编排服务,Gateway 提供统一 API 入口。以上均为建议,不替代本次评审决议。

+

已确认的存储拆分独立于发布产物路线。 无论继续构建应用镜像,还是后续采用应用包,backend 都负责附件服务、产物传输和快照存储;manager 只通过平台协议获取源码和提交快照,不持有 MinIO SDK、长期访问密钥、bucket 或对象路径配置。本地 SQLite 一致性备份、结构迁移和恢复仍由 manager 执行;backend 不为此挂载应用磁盘。本文保留基线现状,并将这一目标方案单独标为“已确认,待实现”。

+

+

2. 应用内容、存储与持久化

+

2.1 当前应用压缩包具体包含什么

+

应用是可执行的 Web 项目。当前 CLI 把文件制成 package.tar.gz,以 multipart 字段 package 上传到 /api/v2/apps/deploy。包内应包含清单、源码、依赖声明和可解析的启动入口。Python 示例:C01 C02 C03

+
leave-helper/
+├── bisheng-app.yaml       # 应用元数据、runtime、port、资源档位与能力声明
+├── requirements.txt       # Python 依赖,可声明 bisheng-sdk
+├── Procfile               # 可选;显式的 web 启动命令
+├── main.py                # 应用代码
+├── templates/             # 页面模板
+└── static/                # 静态资源
+
+
manifest_version: 1
+name: 请假知识助手
+slug: leave-helper
+runtime: python3.11
+port: 8080
+tier: light
+capabilities:
+  models: []
+  knowledge_bases: []
+database:
+  tables: []
+egress:
+  domains: []
+
+
# Procfile 内容示例;uvicorn 必须在 requirements.txt 中声明
+web: uvicorn main:app --host 0.0.0.0 --port $PORT
+
+

当前清单支持 manifest_version/name/description/icon/slug/runtime/port/tier/capabilities/database/egress,未知顶层字段被拒绝;没有用户可直接填写的 image、command、任意 Dockerfile 或通用环境变量字段。后端接收端支持 python3.11/node20/static,异步构建时还会向 manager 复核其模板集合。C03 C07

+

Node 项目依赖 package.json 及实际提供的锁文件;模板安装依赖,存在 build script 时执行 npm run build。静态项目提交可直接提供服务的文件,构建时从包根或约定的 dist/build/public 中识别包含 index.html 的目录。C09 C45

+

CLI 默认硬排除 .git、node_modules、虚拟环境和缓存目录;数据库、附件、dist/build 默认软排除,可通过 .bishengignore 取回需要交付的文件。因此静态项目必须确认构建产物确实进入包;生产 SQLite 数据不应作为源码包交付。打包会规范顺序和元数据,生成可核对的 SHA-256。C02 C47

+

2.2 包大小与安全解包

+

后端默认限制压缩包 50 MiB、解压后 200 MiB、最多 20,000 个条目;实际值来自部署配置,CLI 通过 /api/v2/apps/deploy-limits 获取,不能把默认值当成不可修改的产品限制。后端按流接收和解包,拒绝路径逃逸、链接、设备节点、FIFO 等危险条目。规范包以清单位于根目录为准;后端也兼容仅一层顶级目录的包。C04 C05

+

2.3 六种不同的交付物和运行对象

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
对象例子当前产生方能否直接供浏览器使用
源码归档code.tar.gzCLI 打包、后端存储不能;它是构建输入与审批查看依据
基础运行镜像python:3.11-slim安装交付准备不能;只有语言运行环境,不含具体应用代码
manager 服务镜像包含 runtime_manager 的 Python 镜像平台交付运行编排服务,和应用基础镜像不是同一交付物
完整应用镜像bisheng-app/leave-helper:1-<version前8位>manager 通过 Engine 构建要先创建运行容器,再经入口代理访问
应用容器某个 app/version/generation 的实例Docker Engine仅内部网络可达,无宿主端口映射
业务运行数据/data/app.db、应用附件应用与平台数据服务经应用或受控接口访问,独立于代码版本
+

当前基础镜像映射为 python:3.11-slim、node:20-slim、nginx:1.27-alpine。manager 自身也使用 Python 基础镜像,但不会把自己的编排程序作为用户应用的运行镜像。当前应用镜像标签由 app slug、版本号和版本 ID 派生。C09 C16 C29

+

2.4 存储位置、访问方与生命周期

+

当前基线映射: 下表描述 736ef59 的实际实现;其中 manager 直连对象存储的路径将按后续目标表替换。

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
数据当前位置或路径主要读写进程生命周期与说明
应用、版本、发布尝试、实例、审批元数据平台 MySQL/DM8 对应业务表backend、Celery保存所有权、权限关联、状态、清单、镜像引用和发布结果;manager 不直接查询这些表
源码快照MinIO 私有桶 bisheng-apps:apps/{app_id}/versions/{version_id}/code.tar.gz后端写入;worker 读取;manager 使用预签名 URL 下载按版本存放;不是浏览器静态站点目录 C04
构建工作目录{RTM_DATA_ROOT}/builds/{build_id}manager;Engine 消费构建上下文构建结束清理,不是业务数据目录 C09 C17
应用镜像manager 连接的 Docker daemon 镜像库Docker Engine;manager 发起构建、检查和清理当前单机路径未把应用镜像推入 MinIO 或镜像仓库 C10
编排期望状态{RTM_DATA_ROOT}/state/desired-state.jsonmanagerJSON 文件,原子替换;包含实例、环境和退休容器信息,不能当普通公开配置分发 C18
出站策略{RTM_DATA_ROOT}/egress/ 下策略文件manager 写;egress-proxy 读通过文件共享,不通过后端业务表实时查询 C27 C28
SQLite 运行库宿主 {RTM_HOST_DATA_ROOT}/apps/{app_id}/db/ → 容器 /data应用;manager 的数据库接口与迁移服务数据库为 /data/app.db;同时需要目录内 WAL/SHM 等伴随文件可写 C16 C17
数据库迁移快照bisheng-apps:apps/{app_id}/db-snapshots/{ts}.tarmanager使用 SQLite backup API 生成一致副本,再打 tar 存储;不是直接复制正在写入的 db 文件 C21
应用附件bisheng-apps:apps/{app_id}/attachments/{relative_key}manager 的附件服务;应用经 SDK 调用应用只持有该 app 的句柄令牌,不拿 MinIO 主凭据 C22 C33
审批预览和预检数据临时容器 /data tmpfs临时应用进程空的临时数据库,不挂正式库;实例结束即丢弃 C20 C44
+

bisheng-apps 是所有应用共享、按 app/version/附件前缀隔离的私有桶,不是每个应用一个桶。单独建桶是因为现有平台公共桶存在匿名策略和 nginx 公开转发路径;代码与私有附件不能沿用该暴露面。建桶和访问授权不应依赖“对象路径难猜”。C04 C22

+

已确认目标映射,待实现: 继续复用平台 MinIO 和 bisheng-apps 私有桶;bucket 与对象前缀成为 backend 内部实现,不再下发给 manager。当前对象路径可以继续使用;是否调整快照命名和元数据索引在接口设计中确定。

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
数据目标读写链路职责与生命周期
源码及后续代码/依赖产物manager 或受控准备任务 → backend 产物接口 → MinIObackend 校验任务及产物归属、流式返回内容;manager 下载校验摘要后使用,不接收对象存储 URL
应用附件应用 SDK → backend 附件接口 → MinIObackend 管理应用身份、路径隔离、大小限制、访问审计和数据清理;manager 只注入平台提供的附件句柄
SQLite 迁移快照manager 本地 Backup API → backend 快照上传接口 → MinIOmanager 生成一致副本;backend 保存文件和快照元数据,持久化确认后返回 snapshot_id;上传未确认则不做破坏性迁移
SQLite 运行库与恢复应用或 manager → 本地数据卷;恢复文件由 backend 接口提供/data/app.db 及其本地持久化关系保留;backend 不直接打开应用磁盘上的 SQLite 文件
+

停止应用仍保留源码、附件、SQLite 和迁移快照。目标方案的显式删除由 backend 清理或按保留策略处置源码、附件和快照,manager 仅销毁执行体并按明确指令清理本地卷;两侧分别记录结果并重试,存储清理不能成为未受控的跨系统隐式事务。

+

2.5 停用、升级、删除与备份

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
操作代码快照与版本记录SQLite附件实例
停用应用保留保留保留正式实例停止/回收;后续恢复复用资产
发布新版本新增冻结快照及版本复用同一应用数据目录,可能执行迁移复用应用附件空间新实例健康后切路由,旧实例宽限退休
拒绝或撤回发布未上线的发布结果被记录预检/预览临时库不影响正式库不向预览注入正式附件句柄回收相关临时预览
显式删除应用进入应用删除与发布清理流程manager 收到 purge_volume=true 才删除应用目录manager 清理附件;后端删除钩子负责其他发布资产销毁实例并清理路由/出站主体
迁移前快照不替代代码快照破坏性迁移前尝试创建一致副本;快照失败则拒绝迁移无直接变化快照本身不是实例
+

删除涉及 backend、发布删除钩子及 manager 的多个动作,不能推断为覆盖数据库、对象存储、磁盘的单一原子事务。失败源码快照另有孤儿清理;镜像保留/清理也有独立规则。恢复程序、保留期限与现场备份策略仍需明确。C04 C16 C21

+

上表描述当前行为。按已确认目标,附件和远端快照的保留/删除改由 backend 执行,本地库的备份、迁移、恢复及显式卷清理由 manager 执行;停用释放商业名额不删除任何上述业务数据。

+

2.6 SQLite 在 Docker 与 K8s 中的约束

+

当前 Docker 形式通过宿主机 bind mount 持久化,不把数据库写入镜像可写层。默认宿主数据根为 /opt/bisheng/app-data;manager 容器内一般看到 /app-data。RTM_DATA_ROOT 是 manager 自身读写视角,RTM_HOST_DATA_ROOT 是 Engine 解释 bind mount 的宿主视角,两者必须指向同一份实际数据。C17 C29

+

K8s 规格要求一个应用一个独占数据卷,应用 Pod 重建后复用 PVC。StorageClass 只提供节点本地盘时应标注节点绑定;节点损坏后不能承诺自动无损跨节点恢复。采用可重新挂载的 CSI 块存储、在单节点格式化挂载为文件系统,与把 SQLite 放到 NFS/SMB 共享目录是两回事。SQLite WAL 官方明确不支持网络文件系统上的跨主机协同。C34 R07

+

建议在 CSI 支持时使用 ReadWriteOncePod;普通 ReadWriteOnce 仅限制节点,同节点仍可能有多个 Pod 同时挂载。SQLite 应用采用受控单活动实例发布,结构迁移时停止或隔离正式写入。审批预览使用临时库或明确生成的副本,不能直接挂正式 PVC。当前 Docker 升级存在新旧容器重叠,不能把建议中的单活动 Pod 当成当前既有保证。C16 R08

+

代码回滚不会自动逆转数据库 DDL 或恢复已写入的业务行。发布记录需要分别描述代码版本、数据库 schema 版本及快照;恢复生产数据是独立动作,应明确停写、恢复点、数据丢失窗口和操作者。

+

当前版本记录和镜像保留为诊断、重新上线提供依据,但本次没有据此认定存在完整的任意历史版本一键回滚产品流程。

+

+

3. 发布上线与访问的实际时序

+

§3.1—§3.5 保留当前基线的真实调用,§3.6 描述已确认但尚未实现的目标链路;两者不能混读为同一版本的接口。

+

3.1 上传、异步预检与构建

+

图中 Docker Engine 一列包含经 docker-socket-proxy 转发的 API 调用;proxy 本身不执行业务编排。

+
时序图 1 第 1 部分CLI 到 API:1 上传 package、app_id、结构变更确认;API 到 API:2 鉴权、限额解包、清单/归属/能力/结构校验;API 到 Store:3 写入版本代码快照;API 到 Worker:4 发布部署任务入队;API 到 CLI:5 返回 deployment_id 和轮询入口;Worker 到 Store:6 读取快照;扫描密钥、计算结构变化;Worker 到 Manager:7 检查运行时;提交 build intentCLIBackend APICelery workerruntime-managerDocker EngineMinIO1 上传 package、app_id、结构变更确认2 鉴权、限额解包、清单/归属/能力/结构校验3 写入版本代码快照4 发布部署任务入队5 返回 deployment_id和轮询入口6 读取快照;扫描密钥、计算结构变化7 检查运行时;提交build intent
图 1 · 第 1/2 部分 · 按步骤编号连续阅读;窄屏可横向滚动
时序图 1 第 2 部分Manager 到 Store:8 使用预签名 URL 下载源码;Manager 到 Engine:9 模板渲染后构建应用镜像;Worker 到 Manager:10 轮询 build_id 得到 image_ref;Worker 到 Manager:11 提交临时镜像探活;Manager 到 Engine:12 启动、探活并回收临时实例;Manager 到 Worker:13 返回预检结果CLIBackend APICelery workerruntime-managerDocker EngineMinIO8 使用预签名 URL 下载源码9 模板渲染后构建应用镜像10 轮询 build_id 得到image_ref11 提交临时镜像探活12 启动、探活并回收临时实例13 返回预检结果
图 1 · 第 2/2 部分 · 按步骤编号连续阅读;窄屏可横向滚动
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
步骤实际执行方与动作参数或数据失败时的结果
1CLI 检查文件、执行排除规则、生成 tar.gz 和摘要,先查部署大小限制package、可选 app_id、confirm_schema_change本地打包错误不上传;上传前检查不能替代服务端校验
2/api/v2 统一密钥鉴权链校验凭据、scope 和模式,再检查 runtime 开关发布接口 scope 为 app:manage,模式限定 S;资源归属按凭据关联的自然人判断身份、scope、模式、运行层、归属或清单错误在同步请求返回
3后端按流临时落盘、安全解包;解析清单、资源档位、能力引用和结构变更确认版本 ID 在接收时预分配;冻结 manifest、tier 和快照键校验失败不进入慢构建;跨系统失败需靠清理/补偿处理
4后端写 MinIO 快照及 app_deployment,审计后发送 Celery 默认队列任务deployment_id 随任务传递;租户上下文随任务链传递入队/worker 故障影响后续进度,CLI 上传成功不代表已构建或上线
5API 返回接收结果,CLI 持续查询发布状态GET /api/v2/apps/deployments/{deployment_id}审批可能长时间等待,不能用一个长 HTTP 请求覆盖全过程
6worker 重新取源码,按当前顺序执行扫描、结构预检、构建、启动预检secret_scan → precheck_schema → precheck_build → precheck_probe某阶段失败记录阶段、原因、日志摘要;未进入审批
7worker 查询 manager 支持 runtime,生成源码短期预签名 URL 并提交构建app_id/version_id/runtime/port/code_url/code_object_key/slug/version_no不可达、runtime 不支持、构建容量不足分别返回可定位原因
8manager 后台线程检查构建资源、下载解包、识别源码特征、渲染平台模板基础镜像由 runtime 映射;依赖源来自 RTM 配置下载失败与模板失败属于不同构建阶段
9manager 经 Engine API 构建,生成版本标签,返回构建日志状态Python 安装 requirements;Node 安装并按需 build;静态文件装入 nginx 镜像依赖解析、编译、网络阻断或 Engine 错误使 build 失败
10worker 轮询得到 image_ref,再提交独立的镜像探活端口、路径、基路径环境;探活默认路径为 /临时实例使用 tmpfs,无正式卷;失败时保存日志摘要并清理容器
+

依据:C01 C02 C04 C06 C07 C08 C09 C41 C44。

+

3.2 审批、版本入库与上线

+
+

实际顺序修正: 构建和探活通过后,版本服务先提交审批准入,再插入版本记录;若版本插入失败,会尝试取消刚建立的审批。业务上可合称“记录版本并进入审批”,但实现不是“版本先入库、再发审批”的简单顺序。C37

+
+
时序图 2 第 1 部分Worker 到 Approval:1 审批准入;随后写入版本,失败补偿取消;Approval 到 Worker:2 审批通过的业务回调;Worker 到 Worker:3 暂存待上线版本;回收审批预览;Worker 到 Manager:4 迁移声明表;必要时先做数据库快照;Worker 到 Manager:5 查询运行资源准入;Worker 到 Worker:6 签发应用凭据并组装能力环境;Worker 到 Manager:7 deploy intent:image_ref、tier、port、env 等审批引擎 / OutboxCelery workerruntime-managerDocker Engine正式应用实例1 审批准入;随后写入版本,失败补偿取消2 审批通过的业务回调3 暂存待上线版本;回收审批预览4 迁移声明表;必要时先做数据库快照5 查询运行资源准入6 签发应用凭据并组装能力环境7 deploy intent:image_ref、tier、port、env 等
图 2 · 第 1/2 部分 · 按步骤编号连续阅读;窄屏可横向滚动
时序图 2 第 2 部分Manager 到 Engine:8 再次准入、挂卷、限额、创建并启动新容器;Engine 到 App:9 执行镜像 entrypoint;Manager 到 App:10 等待 HTTP 就绪;Manager 到 Manager:11 写期望状态,路由改指新实例;Manager 到 Worker:12 返回正式实例运行结果;Worker 到 Worker:13 更新应用/版本/实例和审计记录;Manager 到 Engine:14 宽限期结束后回收旧实例审批引擎 / OutboxCelery workerruntime-managerDocker Engine正式应用实例8 再次准入、挂卷、限额、创建并启动新容器9 执行镜像 entrypoint10 等待 HTTP 就绪11 写期望状态,路由改指新实例12 返回正式实例运行结果13 更新应用/版本/实例和审计记录14 宽限期结束后回收旧实例
图 2 · 第 2/2 部分 · 按步骤编号连续阅读;窄屏可横向滚动

审批及业务状态逻辑主要在平台后端执行,图中“审批引擎 / Outbox”是逻辑角色,不表示额外交付了一个独立审批容器。上线与预检可在 Celery worker 内执行同一套后端领域服务。C14 C15

+

审批通过首先把版本标记为待上线并回收审批预览。如果应用已经被主动停用,回调只暂存版本,不擅自恢复;以后恢复应用时采用待上线版本并补做迁移。其余上线路径先调用表结构迁移,再做资源准入和启动。资源 tier 来自平台表,传到 manager 后表示为 vCPU 与 MiB。C14 C15 C21

+

manager 再次进行容量校验,准备 /data、文件所有权、附件令牌和出站策略;随后调用容器 create/start。容器由镜像内 entrypoint 选择业务命令,manager 的 deploy 请求没有单独的 command 字段。应用通过就绪检查后,manager 写入新实例期望状态,代理后续路由查询得到新地址;默认 30 秒宽限后退休旧实例。后端收到成功结果后才更新平台应用和版本状态。C08 C12 C13 C16 C17

+

需要评审的事务边界: 数据库迁移早于资源准入和实例启动;迁移已成功但后续容量或启动失败时,数据库可能已经变化。manager 路由切换与后端数据库状态更新也不是同一事务。当前代码没有据此自动承诺数据库回滚、发布全局原子性或零停机,需要设计兼容性迁移与对账。C14 C15 C16

+

应用能力凭据也存在切换边界:后端在调用 deploy 前重签应用令牌,凭据服务先撤销旧令牌再签发新令牌。如果候选容器随后启动失败,旧容器保留并不意味着它持有的旧令牌仍能调用平台能力。此处需要验证失败补偿或凭据双阶段切换,不能把“旧实例未删除”写成“旧版本完全不受影响”。C36 C48

+

3.3 不同场景的结果

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
场景当前行为或边界
首次发布建立应用草稿;预检全部通过、审批准入及版本入库后等待审批;审批成功再尝试上线
在线应用更新使用新镜像启动候选实例,健康后换路由;新实例启动失败时保留旧实例和在线状态,但不能据此断言数据库或能力凭据从未变化
首次上线资源不足保留待上线版本,进入容量等待结果;人工重试不创建第二轮审批
已在线应用更新资源不足旧应用保持在线;本次新版本未上线,不能把整个应用描述为停服
预检失败发布尝试失败、保留可诊断阶段与日志;不进入业务审批
审批拒绝/撤回/终止新版本不生效,回收对应临时预览;在线旧版本按已有状态继续
审批通过时应用已停用仅暂存待运行版本,恢复时才启动,不自动占回用户已释放的运行资源
审批预览独立临时实例、/data tmpfs、独立出站主体、无正式附件句柄;当前不是自动复制生产数据库的预览
停用/恢复停用保留代码、SQLite 和附件;恢复重新检查资源,采用 pending/current 版本
runtime 进程重启读取本地期望状态,结合管理标签接管受管实例;持续对账,避免误清理同机其他容器
+

“商业额度释放”是目标方案,应在确认执行状态与入口失效后完成,不能直接等同于现有后端 state=stopped 写入。当前 stop 先写应用状态,再调用 manager,编排 RPC 失败仍可能需要补偿/对账。C14 C15 C19 C20 C43

+

3.4 浏览器访问与身份注入

+
时序图 3 第 1 部分Browser 到 Nginx:1 请求 /apps/{slug}/...;Nginx 到 Proxy:2 原 URI 与 WebSocket 升级信息;Proxy 到 Backend:3 authorize:会话、slug、请求信息;Backend 到 Proxy:4 用户/应用授权结果与 OBO 身份材料;Proxy 到 Manager:5 查询 app_id 的当前运行地址;Manager 到 Proxy:6 内部地址与监听端口;Proxy 到 App:7 清理伪造身份头后注入可信身份并反代浏览器nginxapp-proxyBackendruntime-manager应用实例1 请求 /apps/{slug}/...2 原 URI 与 WebSocket升级信息3 authorize:会话、slug、请求信息4 用户/应用授权结果与OBO 身份材料5 查询 app_id 的当前运行地址6 内部地址与监听端口7 清理伪造身份头后注入可信身份并反代
图 3 · 第 1/2 部分 · 按步骤编号连续阅读;窄屏可横向滚动
时序图 3 第 2 部分App 到 Proxy:8 HTTP 内容或 WebSocket 数据;Proxy 到 Browser:9 经 nginx 返回浏览器nginxapp-proxyBackendruntime-manager应用实例8 HTTP 内容或 WebSocket 数据9 经 nginx 返回
图 3 · 第 2/2 部分 · 按步骤编号连续阅读;窄屏可横向滚动

后端判定运行层是否启用、会话、用户/租户状态、应用状态以及 OpenFGA app:use 权限;它不把容器地址作为授权结果返回。app-proxy 独立向 manager 取得路由,分别缓存授权与路由(默认短缓存约 3 秒),并处理入口前缀、可信身份头及 HTTP/WebSocket 转发。WebSocket 不是只握手一次就永久放行,还存在重校验与最大存活时间约束。C24 C25 C26 C30

+

所有应用可监听容器内 8080,实际连接目标是各容器内部地址与端口。当前 PortBindings={} 且 PublishAllPorts=false,不需要每个应用对外分配一个宿主端口;对外仍复用 /apps/{slug}。K8s 对应目标是经受控 Service/Pod 网络转发,具体实现尚未落地。C16 C34

+

3.5 应用访问毕昇能力

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
能力应用侧调用与身份平台侧处理方
当前访问者bisheng_sdk.auth.current_user() 从当前请求上下文读取平台注入头app-proxy 注入;后端先完成身份和入口权限判定
知识检索SDK 调 POST /api/v2/filelib/retrieve;应用运行凭据+X-BiSheng-Access-Token后端根据应用声明和访问者权限共同判定;无访问者不能退化成应用所有者
模型OpenAI 兼容客户端读取 OPENAI_BASE_URL/OPENAI_API_KEY;请求按平台能力接线携带访问者信息后端开放模型面及能力策略;模型调用不由 SDK 包装
附件SDK 读取 BISHENG_APP_STORAGE_ENDPOINT/TOKEN,调用 manager 的每应用存储 APImanager 校验应用令牌和路径,将请求映射到 MinIO 私有前缀
SQLite标准 sqlite 库读取 BISHENG_APP_DB_PATH/URL应用直接操作挂载的数据文件;不是每次通过后端 HTTP 执行 SQL
出站 HTTP/HTTPS使用注入的 HTTP/HTTPS 代理及出站令牌egress-proxy 根据 manager 写入的策略校验目标并转发
+

SDK 安装在声明该依赖的应用镜像/环境里;manager 不 import bisheng-sdk 执行业务。SDK wheel 从后端 /api/v1/dev-toolkit/simple/ 分发。manager 只负责构建时接通索引、运行时注入句柄;Java 重写 manager 不意味着必须把应用 Python SDK 也改写为 Java。C31 C32 C33 C36 C42

+

+

3.6 已确认目标:通过 backend 传输源码、附件和快照

+

状态:2026-10-16 会话确认,待实现;下图是目标时序,不是 736ef59 的交付事实。 上传、扫描、审批和入口鉴权仍承接前述业务流程;与存储有关的执行路径改为下图。图中的“发布执行服务”可运行在 backend/Celery,backend API 接收 manager 的文件请求。

+
时序图 4 第 1 部分Publish 到 Manager:1 提交产物引用及限定任务的下载凭据;Manager 到 Backend:2 请求源码内容;backend 校验任务归属;Backend 到 Store:3 读取私有源码对象;Backend 到 Manager:4 流式返回源码;manager 校验摘要后构建探活;Publish 到 Manager:5 审批后提交迁移任务及快照上传授权;Manager 到 Manager:6 隔离正式写入;本地 Backup API 生成快照;Manager 到 Backend:7 按快照任务流式上传文件及摘要发布执行服务Backend APIruntime-managerMinIO应用实例 / SDK1 提交产物引用及限定任务的下载凭据2 请求源码内容;backend校验任务归属3 读取私有源码对象4 流式返回源码;manager校验摘要后构建探活5 审批后提交迁移任务及快照上传授权6 隔离正式写入;本地 Backup API 生成快照7 按快照任务流式上传文件及摘要
图 4 · 第 1/3 部分 · 按步骤编号连续阅读;窄屏可横向滚动
时序图 4 第 2 部分Backend 到 Store:8 持久化快照并记录元数据;Backend 到 Manager:9 返回已保存状态及 snapshot_id;Manager 到 Manager:10 确认快照保存成功后执行本地迁移;Publish 到 Manager:11 提交部署;注入 backend 附件句柄;Manager 到 App:12 启动、探活并切换路由;App 到 Backend:13 SDK 通过应用凭据调用附件接口;Backend 到 Store:14 按应用私有前缀读写附件发布执行服务Backend APIruntime-managerMinIO应用实例 / SDK8 持久化快照并记录元数据9 返回已保存状态及snapshot_id10 确认快照保存成功后执行本地迁移11 提交部署;注入 backend 附件句柄12 启动、探活并切换路由13 SDK 通过应用凭据调用附件接口14 按应用私有前缀读写附件
图 4 · 第 2/3 部分 · 按步骤编号连续阅读;窄屏可横向滚动
时序图 4 第 3 部分Backend 到 App:15 返回附件内容或元信息发布执行服务Backend APIruntime-managerMinIO应用实例 / SDK15 返回附件内容或元信息
图 4 · 第 3/3 部分 · 按步骤编号连续阅读;窄屏可横向滚动
    +
  1. 源码下载: 平台保存不可变源码并建立产物引用;manager 只接收 artifact_id、预期摘要、大小和受控 backend 下载入口。backend 返回文件内容,不能以 302 或响应字段把调用者引向 MinIO 预签名地址。源码、后续依赖产物和初始化下载均遵守这一边界。
  2. +
  3. 附件: SDK 直接调用 backend;backend 从可信应用凭据确定应用和租户,校验操作与相对路径后访问私有对象。manager 不再接收附件流量,也不在本地期望状态中承担附件令牌的权威校验。预览不获得正式附件权限,采用隔离临时空间或保持现有本地临时模式。
  4. +
  5. 本地备份与迁移: manager 取得该应用的迁移独占权,停止或隔离所有正式写入者,使用 SQLite Backup API 生成一致快照。由 manager 执行本地文件操作,backend 不挂应用数据卷;不能把正在写入的 app.db 直接打包代替一致性备份。
  6. +
  7. 持久确认: manager 通过 backend 上传快照,backend 校验任务、大小及内容摘要,将对象和对应元数据保存成功后才返回“已保存”。破坏性 DDL 必须等到该确认;上传失败或结果不明时先查询任务状态、幂等重试,不推断为成功,也不继续迁移。首次发布且尚无数据库时无需制造空快照,但仍须验证上传通道前置。
  8. +
  9. 部署与恢复: 快照、迁移与实例启动各自记录结果;快照成功不代表迁移或上线成功。恢复由 backend 完成业务授权并提供指定快照文件,manager 停止写入、下载校验、恢复本地库并验证后再启动。代码回滚不自动触发数据库恢复。
  10. +
+

文件上传/下载应采用流式传输与有界缓存;临时快照只在持久化已确认或任务明确取消后清理,异常任务按策略回收。backend 与 manager 通过持久任务状态协调,避免 backend 同步请求占满执行资源后,又等待 manager 回调同一服务上传快照。快照失败不执行破坏性迁移,但隔离写入后的恢复服务和失败补偿仍需明确定义。

+

该方案将存储实现和长期凭据归入平台,商业 runtime 依赖版本化平台 API;接口可达性、超时、认证与版本兼容仍是部署前置,并非取消所有平台依赖。

+

+

4. 进程职责、接口、参数与环境依赖

+

4.1 进程与资源访问矩阵

+

当前基线: 本表保留已核查代码的职责;目标方案的资源边界见其后表格。

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
进程或组件负责的业务直接使用的数据/第三方接口主要边界
CLI本地打包、上传、跟踪发布和日志文件系统、平台 Open API不直接创建客户容器;本地可用 git 优化忽略规则,但不是 manager 的运行依赖
Backend API鉴权、所有权、清单接收、状态动作、入口授权、数据操作的业务授权SQL、Redis、OpenFGA、MinIO、manager HTTP API不挂 Docker socket,不直接调用容器编排 SDK
Celery worker扫描、预检、版本/审批与 outbox 业务执行与后端共享配置及业务数据;MinIO;manager API长耗时阶段不放在上传请求内等待
runtime-manager构建、资源准入、执行生命周期、对账、路由、预览、日志、附件、SQLite 数据面Docker API 经 socket proxy;本地文件/SQLite;MinIO;源码预签名 URL;应用 HTTP 探活不直接读平台 MySQL/DM8 或 OpenFGA;也不读取 backend 的 config.yaml
app-proxy浏览器应用入口、可信头注入、HTTP/WS 反代后端 authorize API;manager route API;应用 HTTP/WS;本地短缓存不构建镜像、不持编排权限、不直接操作 MinIO/SQLite
egress-proxy应用与构建网络的 HTTP/CONNECT 目标控制共享策略文件、DNS、允许的目标网络独立入口进程;不提供 manager intent API,不分发业务权限
docker-socket-proxy对 Docker API 端点做转发限制宿主 /var/run/docker.sock当前 Compose 唯一直接持 socket 的组件;不是配额服务,也不理解 app_id
应用容器执行业务代码、提供页面/API、使用 SQLite 和平台能力自身 /data;平台 API;附件句柄;受控外部接口不持 MinIO 主凭据、Docker socket 或集群 API 凭据
BiSheng SDK应用侧身份上下文、检索及附件 API 封装应用请求头、环境变量、后端检索 API、manager 附件 API是应用内库,不是额外常驻服务
+

依据:C23 C24 C27 C28 C29 C31 C38。

+

已确认目标,待实现:

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
进程或组件目标职责直接访问的资源配置与权限边界
Backend API / Celery产物接收与分发、附件服务、快照登记/保存/清理、应用/租户鉴权和审计;发起发布及迁移任务平台数据库、Redis、OpenFGA、MinIO;manager 受控 API持有平台存储配置和应用附件凭据的校验规则;不挂应用磁盘或 Docker socket
runtime-manager构建/依赖准备编排、准入、实例生命周期、路由、自愈;本地 SQLite 备份、迁移和恢复Engine API 或后续 K8s API;本地文件/SQLite;backend 产物/快照接口;应用探活不使用 MinIO SDK、长期密钥、bucket 或对象 key;通过任务授权获取/提交文件
应用 / SDK业务代码、SQLite 访问及平台能力调用自身 /data;backend 附件/能力接口;受控出站路径附件不经 manager;应用拿应用范围句柄,不拿快照上传授权或存储主凭据
构建、准备及初始化执行体在限定任务内准备或获取固定产物backend 产物接口、批准的包源/工具链、临时卷下载或上传凭据绑定当前任务和产物,不共享 manager 的全局管理凭据,不直连 MinIO
+

app-proxy 的入口授权/路由查询、egress-proxy 的出站控制与 socket proxy 的 Engine 转发分别按既有职责接入新边界。它们不接管应用附件或远端快照存储。

+

4.2 manager 的功能与实际前置条件

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
功能当前实现要做的事必要条件
镜像构建检查资源、下载代码、渲染模板、传递 pip/npm/代理参数、调用 Engine build、跟踪日志与结果、清理上下文支持 runtime 的模板、基础镜像、Engine build API、临时磁盘、构建期依赖源
资源准入检查宿主可用内存减保留量;检查存活实例承诺的 CPU/内存总量可读 /proc/meminfo、CPU 信息、本地期望状态;这是资源容量,不是许可证额度
上线/更新生成实例名、注入环境、准备数据卷、建立出站主体、create/start、探活、更新路由、旧实例退休Engine 生命周期 API;应用内部网络;数据目录读写及所有权可用
停用/删除停止正式/退休实例、更新期望状态;显式清理数据及附件可达 Engine、可写状态目录、删除时可达 MinIO
探活启动临时镜像或检查正式应用;HTTP 就绪、日志读取及回收manager 能访问应用 bridge 地址;应用监听 0.0.0.0 和声明端口
对账/自愈重启时从状态与管理标签恢复;修复受管实例、出站漂移、退休和过期预览Engine inspect/list;持久状态目录;仅操作平台管理标签范围
路由/状态/日志从真实实例与期望记录返回当前地址、健康、版本及日志manager HTTP API 可达;平台/代理 HMAC 一致;日志受长度和保留限制
审批预览独立临时实例、空临时数据库、超时回收、禁止正式附件访问镜像已构建;临时资源容量;预览会话/授权由后端提供
附件服务每应用令牌鉴权、路径校验、大小限制、对象存取和隔离MinIO RTM 配置、私有桶;应用可达 manager 的 app-facing 地址
SQLite 管理表/结构/行查询、更新/导出、声明式迁移、破坏性迁移前 backup API 快照可达数据库文件;文件权限/锁可用;快照 MinIO 可用;业务权限由后端先校验
+

依据:C09 C16 C19 C20 C21 C22 C39 C46。

+

目标职责调整: 上表“附件服务”从 manager 移除;“镜像构建”的源码获取改为 backend 下载接口;“SQLite 管理”保留本地执行,但快照保存改为向 backend 上传并等待持久确认。停用/删除只清理执行体、状态和明确要求删除的本地卷,远端附件/产物/快照由 backend 处理。manager 自检相应检查平台文件接口、任务认证和本地备份条件,不再检查 MinIO 账号或 bucket。其余构建、探活和恢复能力仍受 Engine、磁盘、包源及网络条件约束。

+

4.3 关键接口与参数来源

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
接口调用方 → 被调用方关键参数参数归属与校验
POST /api/v2/apps/deployCLI → backendmultipart package/app_id/confirm_schema_change用户提交;后端按凭据、租户、资源所有权和清单检查
GET /api/v2/apps/deployments/{id}CLI → backenddeployment_id平台查询发布过程,返回阶段与原因,不等于直接查询 Docker
POST /v1/intents/buildworker → managerapp_id/version_id/runtime/code_url/code_object_key/slug/version_no/port/build_argsID 与源码 URL 由后端生成;模板和受保护代理参数由 manager 控制
GET /v1/builds/{id}worker → managerbuild_idmanager 返回 status/stage/message/tail/image_ref;构建记录是进程内状态
POST /v1/intents/probeworker → managerimage_ref 或 app_id、port/health/env/timeout临时镜像探活和正式应用探活是不同输入形态
POST /v1/admission后端服务 → managerpurpose=run/build、可选 tierCPU 为 vCPU、mem 为 MiB;输出准入判定和宿主快照
POST /v1/intents/db-migrate上线领域服务 → managerapp_id/plan/snapshot后端按清单差异形成迁移;manager 校验操作并执行/快照
POST /v1/intents/deploy上线领域服务 → managerimage_ref/tier/port/env/health/platform_api_base/base_path/egress_domains 等后端提供冻结版本意图;manager 控制容器安全参数和保留环境
POST /v1/intents/stop、/destroy后端 → managerapp_id;destroy 增加 purge_volume应用动作先经后端权限;删除数据必须显式指定
POST /v1/intents/preview、/preview/stop预览服务 → managersession_id/app_id/version_id/image_ref/tier/expires_at 等后端校验审批参与资格;manager 执行临时实例生命周期
POST /api/v1/internal/app-proxy/authorizeapp-proxy → backendslug、会话/访问上下文业务鉴权;另有 authorize-preview,不复用一个含糊开关
GET /v1/apps/{app_id}/routeapp-proxy → managerapp_idmanager 返回路由事实;不代替后端 app:use 权限
/v1/apps/{app_id}/storage/...应用 SDK → manager应用令牌、相对路径与内容每 app Bearer 句柄,和后端 HMAC 管理接口是两套凭据
/v1/apps/{app_id}/db/...后端数据服务 → manager表、行键、分页/变更manager 接口用内部 HMAC;实际用户操作授权在后端
+

以上均为当前已检查接口,不是完整 OpenAPI 文档。manager /healthz 是明确的免 HMAC 存活检查;不能由 healthz 成功推断 Docker、MinIO 或部署前置全部可用。C08 C23 C24 C35 C38 C41 C46

+

下面两个 JSON 是当前基线中后端向 manager 发送的脱敏意图示例。code_url 必须能从 manager 下载;它不是应用运行期间使用的地址。JSON 字段以代码契约为准,示例值不代表现场默认值;其中 MinIO 预签名 URL 和 code_object_key 不属于目标存储协议。

+
{
+  "app_id": "app-example",
+  "version_id": "a1b2c3d4e5f647809abcdef0123456789",
+  "runtime": "python3.11",
+  "slug": "leave-helper",
+  "version_no": 1,
+  "port": 8080,
+  "code_object_key": "apps/app-example/versions/a1b2c3d4e5f647809abcdef0123456789/code.tar.gz",
+  "code_url": "https://minio.example.internal/bisheng-apps/...<presigned>",
+  "build_args": {}
+}
+
+
{
+  "app_id": "app-example",
+  "slug": "leave-helper",
+  "version_id": "a1b2c3d4e5f647809abcdef0123456789",
+  "version_no": 1,
+  "image_ref": "bisheng-app/leave-helper:1-a1b2c3d4",
+  "tier": {"cpu": 0.5, "mem": 1024},
+  "port": 8080,
+  "env": {"BISHENG_APP_TOKEN": "<redacted>"},
+  "health": {"path": "/", "interval": 10, "timeout": 3, "retries": 3},
+  "platform_api_base": "https://bisheng.example.internal",
+  "base_path": "/apps/leave-helper",
+  "egress_domains": []
+}
+
+

目标接口草案: 下表路径和字段仅用于表达职责与协议设计范围,尚未实现或冻结;内部传输 API 不直接复用面向自然人会话的登录态。

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
拟议接口调用方 → 被调用方参数产生、校验与消费
GET /api/v1/internal/app-runtime/artifacts/{artifact_id}/contentmanager / 准备任务 → backendbackend 建立产物、预期摘要和大小,签发下载任务授权;backend 验证 app/版本/任务范围;消费方流式下载并校验,不接收 bucket/key 或 MinIO URL
PUT /api/v1/internal/app-runtime/snapshots/{snapshot_id}/contentmanager → backendbackend 分配快照任务及幂等标识;manager 提交一致快照内容、大小及摘要;backend 校验归属和上传范围,持久保存后返回已确认结果
GET /api/v1/internal/app-runtime/snapshots/{snapshot_id}manager / 发布任务 → backend通过同一快照 ID 查询 pending/stored/failed 等建议状态及摘要;用于响应丢失后的对账,不凭 HTTP 超时判断对象不存在
GET /api/v1/internal/app-runtime/snapshots/{snapshot_id}/contentmanager → backendbackend 校验已获授权的恢复任务和目标应用;manager 下载、校验摘要后执行受控本地恢复
/api/v2/apps/{app_id}/storage/...应用 SDK → backendbackend 签发并校验应用范围附件句柄,根据可信应用身份解析租户和相对路径;不能让调用者任意指定 bucket、完整对象 key 或其他应用
+

目标迁移任务可以携带如下产物和上传描述;字段是设计示例,不能当作现有 manager schema 发送。任务授权应通过认证头传递,不置于 URL 查询串,不下发应用容器,也不作为长期期望状态或日志内容保存。

+
{
+  "deployment_id": "dep-example",
+  "app_id": "app-example",
+  "source": {
+    "artifact_id": "artifact-example",
+    "download_url": "https://bisheng.example.internal/api/v1/internal/app-runtime/artifacts/artifact-example/content",
+    "sha256": "<expected-sha256>",
+    "size_bytes": 1048576
+  },
+  "snapshot_upload": {
+    "snapshot_id": "snapshot-example",
+    "upload_url": "https://bisheng.example.internal/api/v1/internal/app-runtime/snapshots/snapshot-example/content",
+    "idempotency_key": "dep-example:before-migrate"
+  }
+}
+
+

认证应同时考虑服务身份与任务授权:manager 调平台文件接口使用受控服务身份,下载/上传权限绑定 app、版本、任务、操作、有效期和幂等键;准备/初始化执行体仅获得最小任务授权。签发方和 backend 校验方属于平台,manager 只消费授权。当前 HMAC 的字段与防重放限制见 §6.5,不能默认原接口已经具备这里的任务约束。

+

SDK 的 BISHENG_APP_STORAGE_ENDPOINT/TOKEN 使用方式应保持兼容,但服务端归属迁到 backend;当前 SDK 解析 manager 错误信封、附件令牌又在 manager 期望状态中校验,因此迁移需提供兼容 API 适配或同步升级 SDK,不能只替换 URL。应用令牌轮换、停用/删除后的拒绝策略和预览隔离由 backend 统一定义并验证。C22 C33

+

4.4 配置归属与应用注入变量

+

当前基线配置:

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
配置组读取进程关键配置和要求
后端运行层backend、Celeryapp_runtime.enabled;manager_base_url/manager_hmac_secret;proxy_hmac_secret;独立 obo_secret;入口地址、包大小、预览 TTL
后端开放能力backend、Celeryopen_platform.enabled 与 Open API scope 可用性;open_api.public_base_url、app_runtime.entry_base_url 等对外地址应可被应用实际访问
manager 服务managerRTM_HOST/PORT/HMAC_SECRET;RTM_DOCKER_HOST;RTM_DATA_ROOT/RTM_HOST_DATA_ROOT;容量、探活及对账参数
manager 构建manager、构建容器RTM_BUILD_INDEX_URL、可信主机、RTM_BUILD_NPM_REGISTRY、RTM_BUILD_EXTRA_INDEX_URL;基础镜像和构建代理/网络
manager 对象存储managerRTM_MINIO_ENDPOINT/ACCESS_KEY/SECRET_KEY、RTM_STORAGE_BUCKET;应用可达的 RTM_APP_FACING_BASE_URL
app-proxyapp-proxyAPP_PROXY_BACKEND_BASE/SECRET、APP_PROXY_MANAGER_BASE/SECRET;它们分别对应后端入口授权和 manager 路由
egressmanager 写策略;egress-proxy 读策略RTM_EGRESS_PROXY、RTM_PLATFORM_BASE_URL、构建/运行允许目标、internal 网络和需要的宿主防火墙前置
+

容量、构建源及 manager 数据根不从后端 YAML 自动继承。后端 AppRuntimeConf 对历史遗留同名配置会告警并指出对应 RTM 环境变量。manager 获取源码只需要短期 URL,但附件读写和 SQLite 快照上传由它直接访问 MinIO,因此还要向 manager 进程注入 RTM_MINIO_ENDPOINT/ACCESS_KEY/SECRET_KEY 和 RTM_STORAGE_BUCKET;这些配置不会从后端配置自动读取。C05 C07 C17 C21 C22 C29

+

这里的“单独配置”指进程需要收到连接参数,不要求另建 MinIO 实例,也不要求另建 manager 专用桶。当前 Compose 指向平台已有的 minio:9000,使用共享私有桶 bisheng-apps;源码、附件和 SQLite 快照按对象前缀区分。它们共用这一私有桶,区别于平台已有的 bisheng 公共用途桶。生产环境可为 manager 分配权限受限的独立访问凭据,但“独立凭据”不等于“独立实例或独立桶”,也不是当前代码强制的条件。C04 C17 C21 C22 C29

+

已确认目标配置: 下列变更应随接口迁移落地,不应仅删除环境变量而保留当前直连代码。

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
配置或凭据目标归属manager 需要接收的内容
MinIO endpoint、TLS、访问密钥、bucket、对象路径与保留策略backend / 平台存储模块无;删除 manager 的 RTM_MINIO_*、RTM_STORAGE_BUCKET 配置和 MinIO SDK 依赖
平台产物/快照传输入口backend 提供,部署配置指定 manager 可达地址平台内部服务基址、TLS 信任、请求超时和受控服务身份;具体新增配置名待设计
下载与快照上传任务授权backend 签发和校验限定任务的短期授权、产物/快照引用、摘要和幂等标识;与应用附件凭据分离
应用附件 endpoint/token/max filebackend 附件服务与应用凭据模块随受控 deploy intent 传入的应用句柄,manager 注入 BISHENG_APP_STORAGE_*;backend 为令牌校验权威
本地 SQLite 与构建/编排参数manager数据根、宿主卷路径、备份临时目录、迁移独占控制,以及已有 Engine/容量/探活/构建源配置
+

RTM_APP_FACING_BASE_URL 当前用于应用访问 manager 附件接口;附件迁出后需删除这一用途并检查是否仍有其他调用方,再决定移除该变量。内部服务调用的 HMAC/服务身份、应用主体凭据、文件传输任务授权和访问者 OBO 各有作用域,不应复用一把高权限密钥。

+

下表描述当前变量及来源;目标差异在附件行单独标注。

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
应用可见变量/请求头来源用途与边界
PORT/BISHENG_APP_PORT清单 port 经 manager 注入应用需监听正确端口与 0.0.0.0
BISHENG_APP_ID/SLUG/VERSION/VERSION_ID/BASE_PATH后端版本意图+manager识别应用与访问路径;保留变量由平台覆盖
BISHENG_APP_DB_PATH/URLmanager固定指向 /data/app.db 与 SQLite URL
BISHENG_PLATFORM_API_BASE部署意图经 manager 注入SDK 平台 API 地址;不能填写构建容器不可达的 localhost
BISHENG_APP_STORAGE_ENDPOINT/TOKEN/MAX_FILE_MB当前 manager 生成;目标由 backend 提供、manager 注入当前指向 manager,目标指向 backend 本 app 附件 API;不暴露 MinIO 主凭据;当前令牌校验不能直接沿用为平台权威
BISHENG_APP_TOKEN后端能力凭据服务应用主体凭据;更新时可能重签,不应等同于访客登录态
OPENAI_BASE_URL/OPENAI_API_KEY后端能力注入声明模型能力时设置;地址缺失可能导致容器能启动但模型调用失败
HTTP_PROXY/HTTPS_PROXY 等manager出站路径与主体;依赖客户端应实际使用代理
X-BiSheng-*、X-BiSheng-Access-Tokenapp-proxy 按请求注入访问者身份、app_id、请求关联与 OBO;应用不能据自身凭据猜测访客身份
+

当前 Python entrypoint 支持参数、BISHENG_APP_START、Procfile web:、main.py/app.py 的解析优先级,但托管发布没有让开发者设置 BISHENG_APP_START 的通路;实际应提交 Procfile 或约定入口。Node 还支持 package.json 的 start/main 和默认 JS 文件。静态 runtime 直接运行平台 nginx。入口约定本身也是托管契约,不能只描述“传一个 command”。C12 C13 C31

+

4.5 Docker 和 K8s 的前置条件比较

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
条件当前 Docker 实现K8s 镜像构建路线:规格/待实现应用包路线:评审建议
执行引擎可达的 Docker Engine;socket proxy 放行 build/container/image/network 所需 API可用 K8s API、合适容器运行时;不能假设节点有 dockerd同样需要 K8s API,但源码发布可免 OCI 镜像构建
Docker CLImanager 不需要;它使用 Python Docker SDK 调 Engine HTTP APImanager 和节点不必为应用发布安装 docker build 命令不要求;准备依赖/静态资源可由普通受限 Job 完成
容器镜像构建Engine 构建,依赖宿主 daemonK8s 本身不提供源码转镜像;需额外交付构建器、资源/超时/并发治理一般应用准备代码和依赖包;OS/运行时变化仍要制作运行镜像
镜像分发构建和启动使用同一 daemon 的镜像库;无需单机应用仓库所有工作节点可拉应用镜像;客户仓库或附带最小 Registry少量固定运行/初始化镜像可预装节点或从内网仓库获取;每次应用包发布不必入镜像仓库
依赖与编译Python/npm 源、原生库/编译工具必须满足模板构建构建 Pod 可达的包源、基础镜像、凭据与网络策略依赖准备仍需源或离线包,产物应匹配运行时/ABI/架构
权限manager 控制 Engine API、数据目录;proxy 持 socket专用 namespace、ServiceAccount、RBAC;不把集群凭据给应用同左;准备 Job 也不自动挂业务无关集群凭据
网络默认网、bisheng-apps 应用网、bisheng-build 构建网、出口代理及实际生效的底层限制CNI/NetworkPolicy 可用并验证;构建/应用目标分开控制初始化下载访问 backend 产物接口,任务凭据不扩散给业务代码;manager/准备任务不直连 MinIO
SQLite宿主按 app bind mount;manager 需文件读写及所有权操作专用 PVC、合适 StorageClass、单实例及迁移隔离数据 PVC 与临时代码/依赖卷分开;Pod 重建重新取固定版本包
运行层安装Compose app-runtime profile 和后端开关配套Helm/manifests、构建器、仓库接入、网络/存储前置探测Helm/manifests 增加包初始化及依赖产物管理,仍需现场验证
+

Compose 服务 build: 能在开发机构建,不代表离线现场可直接执行。交付时应预制管理层服务镜像、基础镜像和工具镜像,避免现场构建管理服务再遇到 pip 下载问题。C29 C34

+

存储拆分适用于 Docker 和后续 K8s 两种执行后端:manager 及其受控准备/初始化任务访问 backend 文件接口,由 backend 访问 MinIO;应用访问 backend 附件接口。网络策略、代理允许目标和部署自检应据此更新。K8s 中 manager 仍需能在受控执行环境访问对应 SQLite 数据卷,具体挂载执行方式待 F059 设计,不能假设远端 PVC 可直接从 manager 进程本机打开。

+

4.6 权限不能只按进程用户或 socket 挂载判断

+

当前 socket proxy 做端点级白名单,manager 不直挂 socket;不过允许的容器 create/start/build 等 API 仍具有很强的编排能力。端点白名单不等于按 app_id 限权,也不自动验证 bind mount、特权参数和租户边界。manager 必须固定安全容器参数,且编排网络与凭据不能开放给普通应用。Docker 官方也强调 daemon API 对宿主机的权限影响。C16 C29 R10

+

因此,“改成 Java”“不安装 Docker CLI”“通过 HTTP API”各自都不能证明权限很小;进程独立、API 权限控制和商业代码保护需要分别论证。K8s 形态可通过 namespace/RBAC 限制资源范围,但还需要准入策略约束可创建 Pod 的安全字段,不能只给 create pods 就认定已完成隔离。

+

4.7 离线交付清单与验收层次

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
层次离线交付必须准备的内容现场验证
管理服务能启动manager、代理及所需平台服务的目标架构镜像;配置、密钥注入、网络和目录管理服务存活、内部鉴权、Engine/K8s 可达
应用运行镜像可用模板所用基础镜像或受控运行镜像,架构与版本固定真正执行构建/拉取的节点或引擎能访问;不是仅在管理员电脑上存在
应用能准备依赖内网 PyPI/npm、完整离线 wheel/npm 产物、SDK 平台索引、原生工具链新依赖版本、锁文件中的外链、install/build 脚本均不访问公网
代码/产物能分发平台私有产物存储及 backend 文件接口;镜像路线需要节点可拉的 Registry当前源码用预签名 URL;目标应验证 manager/准备任务只从 backend 下载且无对象存储重定向;另验构建推送与节点拉取
快照能保存与恢复manager 本地备份目录、backend 快照接口、平台对象存储和任务认证有界流式传输、摘要、持久确认、超时对账;backend 故障时拒绝破坏性迁移;恢复不要求 backend 挂应用磁盘
应用能持久运行正式数据卷、权限、容量、日志和退出/重建策略SQLite 写入、Pod/容器重建后数据、节点故障条件下恢复能力
业务能力可调用平台可达地址、模型部署、检索权限、附件句柄、出站目标页面可访问不等于模型、知识检索和附件可用,应分别验证
+

客户没有仓库时,镜像路线的现有 K8s 规格要求随工场附带最小仓库。仓库自身需要先由离线安装流程启动,再灌入其他镜像,不能形成“仓库镜像也只能从尚未启动的仓库拉取”的闭环。仓库需要持久卷、认证与节点可达地址。若所有节点预装固定运行镜像,应用包路线可不因每次发版额外建设应用镜像仓库,但新增节点和镜像升级仍要纳入交付。C34 R12

+

manager 的 /v1/runtime/status 会检查编排后端、应用网络、数据目录、模板、基础镜像、附件/出站相关前置。它不能证明任意应用依赖闭包已完整离线化,也不能代替现场故障恢复测试。C40

+

目标离线包应包含 backend 新增文件/附件接口和兼容 SDK,manager 包不再包含 MinIO 访问配置。平台侧仍要准备 MinIO/私有桶和存储权限;manager 自检转为平台 API、认证、传输与本地备份前置,不能把“不直连 MinIO”理解成客户环境不需要对象存储。

+

+

5. 发布路线与同类产品评审

+

5.1 市面上并非只有“每次发布生成应用镜像”

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
产品官方描述的交付方式可借鉴内容与适用边界
Posit ConnectK8s 使用预制执行环境镜像,挂载代码、已安装依赖等;Python 环境按依赖列表与 Python 版本复用与 Streamlit/Dash/FastAPI 托管接近;其共享存储方案不能直接当作 SQLite 数据卷设计 R01 R02
Modal环境镜像安装系统/语言依赖;add_local_dir(copy=False) 可在容器启动时加载代码,代码修改不必重建环境镜像借鉴环境和代码分离,不据此推断 Modal 可按毕昇方式离线私有化 R03
Cloud Foundrystaging 生成 droplet 归档,上传 Blobstore;运行容器取包;构建缓存单独保存成熟的“运行包”路线,但整套平台较重,适合借鉴产物模型 R04
Fissionenvironment 镜像+source/deployment archive;无包 Registry 时仍可走存储服务归档路径借鉴 K8s 上代码包与环境分离;函数 specialize 契约不直接等于任意常驻 Web 服务 R05
Coolify源码构建镜像;用户提供镜像时跳过构建,再管理容器和入口当前毕昇路线有成熟参照;通用应用可重复部署和环境封装较直接 R06
+

以上比较的是可验证的交付机制。是否开源、能否离线、协议限制和商业许可需要按具体产品版本另评估,不能因为技术路线相似就直接引入整套产品。

+

5.2 三条路线的收益和代价

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
维度A 当前:源码→应用镜像B 建议:运行镜像+应用包+依赖环境C 目标:用户提供完整镜像
发布产物每个版本的完整应用镜像固定运行镜像+不可变代码/前端产物包+依赖产物已构建的应用镜像摘要
代码小改发起构建;依赖层缓存命中时只更新后续层更新代码包,复用兼容依赖环境由用户 CI 重新产出镜像,毕昇跳过构建
依赖变化镜像构建重新准备相关依赖层准备新的依赖包/环境,仍需校验和编译由镜像提供方负责
离线条件基础镜像、构建器/Engine、包源;K8s 还需镜像分发运行镜像、backend 产物接口、平台存储、依赖包或包源;一般源码发版不需推 OCI 镜像镜像必须进入客户可达仓库或预装节点
对新增 OS 软件的适应可通过平台模板扩展;当前未开放任意 Dockerfile超出固定运行环境时需新环境镜像或转 C容器内系统库和程序由用户完整封装
冷启动与缓存镜像分层拉取和节点缓存成熟需要下载/展开代码与依赖包,可增加产物缓存;不能假定始终更快取决于镜像体积与节点缓存
复现和回滚固定镜像摘要;数据库仍独立必须同时固定运行镜像、依赖、代码摘要和启动契约固定镜像摘要;平台仍应验证入口与运行限制
平台复杂度镜像构建/推送/清理、仓库、构建缓存依赖准备、ABI 兼容、包校验/分发/清理与缓存少做源码构建,但要做镜像准入和凭据管理
隔离边界容器/Pod+资源和网络限制同样可以一应用一容器/Pod;无需共享业务进程容器/Pod;用户镜像也必须遵守平台限制
+

当前每次源码发布都会进入 build 阶段,没有应用级“比较依赖后跳过整次构建”的实现。Docker 模板先复制依赖清单、安装依赖,再复制源码;缓存可用时复用依赖层。pip --no-cache-dir 关闭的是 pip 下载缓存,不等于关闭 Docker 层缓存。一次性 K8s 构建 Job 若不显式保留/导入外部构建缓存,不能自动获得上一 Job 的层缓存。C06 C09 C10 C11 R11

+

5.3 应用包路线的建议链路

+

发布产物路线 B 仍待评审;下图已纳入本次确认的存储边界,准备 Job 和应用初始化均通过 backend 传输产物,不直接访问 MinIO。

+
时序图 5 第 1 部分Backend 到 Store:1 保存不可变代码或前端产物包;Backend 到 Runtime:2 提交环境、产物引用及任务授权;Runtime 到 Runtime:3 查找兼容依赖产物;命中则复用;Runtime 到 Prepare:4 未命中则通过 backend 取源码并准备依赖;Prepare 到 Backend:5 上传依赖产物、摘要和兼容元数据;Backend 到 Store:6 保存并登记固定依赖产物;Runtime 到 Pod:7 创建固定运行镜像的 Pod 及独立数据 PVCBackend 发布与产物API商业 runtime依赖准备 JobMinIO应用 Pod1 保存不可变代码或前端产物包2 提交环境、产物引用及任务授权3 查找兼容依赖产物;命中则复用4 未命中则通过 backend取源码并准备依赖5 上传依赖产物、摘要和兼容元数据6 保存并登记固定依赖产物7 创建固定运行镜像的 Pod 及独立数据 PVC
图 5 · 第 1/2 部分 · 按步骤编号连续阅读;窄屏可横向滚动
时序图 5 第 2 部分Pod 到 Backend:8 初始化阶段请求代码和依赖内容;Backend 到 Store:9 读取已确认产物;Backend 到 Pod:10 流式返回文件;初始化校验摘要;Pod 到 Pod:11 只读挂载代码/依赖,读写挂载 /data;Pod 到 Runtime:12 就绪通过后切换路由;Runtime 到 Backend:13 返回发布结果与产物组合Backend 发布与产物API商业 runtime依赖准备 JobMinIO应用 Pod8 初始化阶段请求代码和依赖内容9 读取已确认产物10 流式返回文件;初始化校验摘要11 只读挂载代码/依赖,读写挂载 /data12 就绪通过后切换路由13 返回发布结果与产物组合
图 5 · 第 2/2 部分 · 按步骤编号连续阅读;窄屏可横向滚动

建议的版本单元是 运行镜像 digest+代码包 SHA-256+依赖产物 SHA-256+启动/健康契约。不要在多个在线实例共用的目录中覆盖代码,也不要在每次正常启动时联网执行任意 pip install/npm install。依赖准备应在上线前完成,失败不切流;运行初始化只取经过确认的产物。K8s 的 init container+共享卷可以承接下载和准备步骤,但具体隔离与缓存属于新增实现。R09

+

依赖缓存键至少涵盖目标 OS/CPU 架构、解释器/ABI、运行镜像摘要、锁定的依赖及影响安装的配置。不能把开发机虚拟环境原样搬到任意国产化机器,也不能只依据 requirements.txt 文件名判断兼容。Python 原生扩展、Node 原生模块和系统动态库都需要在匹配环境中准备。

+

TypeScript/前端构建仍然需要执行,输出可保存为应用包;纯静态内容可用固定 nginx 运行镜像承载。常用库可预装,但不建议把所有可能用到的包塞入一个巨大公共环境;应明确预装包版本和可覆盖范围,并保留按应用依赖隔离的能力。

+

5.4 对现有模块的改动影响

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
模块若选择 B,需要评审的变化
CLI/清单/版本模型增加可区分的源码包与完整镜像交付描述;源码模式记录环境和代码/依赖产物引用;image_ref 单字段不足以表达所有产物
发布管线把通用“准备运行产物”与“构建 OCI 镜像”区分;保留扫描、依赖准备日志、就绪验证、审批和业务授权
backend 产物/附件服务按已确认边界提供产物下载、快照上传和附件 API;路线 B 再扩展依赖产物登记/分发;MinIO 配置、对象布局及远端清理由平台管理
runtime增加依赖产物匹配、准备任务、校验、通过 backend 初始化下载、清理与路由恢复;保留本地 SQLite 备份/迁移并经 backend 上传快照;Docker/K8s 适配分别实现挂载
数据与预览代码/依赖临时卷独立于 SQLite 正式卷;backend 提供隔离附件空间或继续使用临时本地模式,预览不复用生产库/附件权限
app-proxy / SDK延续入口前缀和身份头;附件 endpoint 迁到 backend,令牌权威和错误信封需提供兼容适配或随 SDK 升级
F059现有规格将镜像构建/推送放在源码发布主路径;改路线应先调整需求与设计,不能只替换一个底层客户端就宣称完成
+

推荐先验证 Python Web 应用与静态应用的纵向流程,再评价 Node/原生依赖。是否采用 B、是否同时交付 A 作为备选,以及自定义运行镜像的登记权限,在本次评审后定案。

+

附件/产物/快照经 backend 的边界已确认,不以是否采用路线 B 为前提;即使保留路线 A,也应按 §3.6 完成同样的存储解耦。

+

+

6. 商业化边界与保护方案

+

6.1 商业化具体落在哪一层

+

已确认的计费口径是“同一授权部署范围内同时在线应用数,免费 10 个,停用释放名额”。授权部署范围如何绑定实例、集群或客户,需补充产品定义;不能默认“每个 manager 节点都免费 10 个”。资源 CPU/内存准入与商业应用数量额度应分别计算、分别报错。

+

商业化重点是托管编排业务:运行环境匹配、依赖/镜像准备、上线部署、持久化衔接、探活、切换、恢复、路由与生命周期治理。只在 Gateway 的发布 API 上加一个计数器,而完整可执行 manager 仍开源并可直接调用,不能保护这部分业务价值。

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
层建议归属与边界评审说明
开源平台 backend/worker应用元数据、发布材料接收、审批、用户权限、平台能力、SDK 分发;附件 API、源码/产物分发和快照存储存储配置、对象布局、应用/租户隔离、保留清理归平台;通过受控接口请求商业托管能力
商业 Gateway托管部署对外/内部 API 门面、许可证管理、鉴权和路由是调用入口;不能单独作为所有执行路径的额度强制点
独立闭源 runtime托管状态与实际上线控制、配额强制、环境/产物编排、执行后端适配、恢复对账;本地 SQLite 备份和迁移作为重点推荐;通过平台 API 下载源码、上传快照,不含附件服务或 MinIO 配置;可复用 Gateway 工程与授权基础,进程独立
通用代理app-proxy、egress-proxy、Docker socket proxy 可继续独立使用不必为了额度把通用转发都重写为 Java;路由/授权必须接入商业执行事实,避免另留完整开放编排路径
应用 SDK保持应用侧库和接口契约SDK 开源本身不等于开放商业编排逻辑;改 manager 语言不要求同步改 SDK 语言
+

已确认的商业边界: 附件和远端产物/快照存储归 backend,商业 runtime 保留实际编排、本地 SQLite 一致性备份、迁移及恢复能力。后端数据管理页/MCP 仍经业务授权后调用 manager 的本地数据库操作;不为迁移附件接口而把 SQLite 文件读写搬进 backend。替换现有 Python manager 时仍需承接这些本地数据接口和生命周期行为,不能仅迁移 deploy API 就删除原服务。

+

商业保护依然落在实际上线、恢复和对账的执行路径。backend 的附件与快照 API 不需要承担免费在线额度校验,也不应提供第二套可绕过商业 runtime 的完整编排入口。存储协议解耦减少商业模块对平台对象存储实现的依赖,但不消除其对平台身份、任务授权和产物接口的依赖。

+

“免费 10 个应用”与“编排源码公开”是不同的交付决定。可以免费分发同一套闭源 runtime 二进制,在无扩展授权时提供 10 个在线名额。若社区部署原本不包含 Gateway,还要确定免费托管包是否同时附带 Gateway 门面;不能通过另外保留一套无限制开源 manager 来实现免费版,否则商业执行边界重新出现绕行路径。

+

6.2 四种方案的比较

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
方案改造范围代码与额度保护国产化/交付影响建议定位
仅 Gateway 入口限额增加许可检查和计数;原 manager 不变修改/直调开源 manager 可绕过;HMAC 密钥也不等同于商业许可证改造小可做短期流程控制,不作为核心商业保护
闭源 Python 镜像沿用 manager,内置许可/额度;可选混淆/编译不公开源码增加门槛;镜像仍可提取,普通 pyc/混淆难度有限;编译不提供绝对保护原生编译产物需按架构、ABI 和运行环境验证;多架构打包成本上升可作为快速交付或迁移阶段方案
Java 控制层+精简执行代理闭源层拥有状态机、许可、配额、准备/部署流程;代理只执行受限操作必须移除代理的完整自主 deploy/reconcile 路径;否则关键流程仍能绕开可分阶段迁移;多进程协议和故障补偿更复杂适合降低一次重写风险
Java 重实现完整 runtime重写托管编排、本地 SQLite 备份/迁移及数据操作、平台传输客户端和执行后端适配;附件/MinIO 服务留在 backend商业控制点清楚;JAR 可反编译,需配合签名授权及工程加固依赖目标架构可用 JVM、基础镜像和本地文件语义;并非“Java 天然兼容所有信创机器”长期重点候选,实施成本最高
+

分进程主要解决权限、资源占用、故障和升级边界;代码闭源与许可证强制是另一个维度。把编排放在 Gateway JVM 内并非技术上不可能,但会把构建任务、数据目录、SQLite 和编排权限带进网关进程。本会话已倾向独立进程,建议保持这一部署边界,语言选择仍列为评审事项。

+

6.3 离线授权与最终执行控制

+

建议机制: 由厂商签发包含能力、额度、有效期和部署绑定信息的非对称签名授权;厂商私钥不随客户交付。商业 runtime 通过内置受信公钥本地验证,免费基线由商业实现提供,不依赖每次发布联网回连厂商。字段格式、绑定规则、时钟回拨和证书轮换在正式设计中定义,本文不把示例名称冒充现有接口。

+

Gateway 可先做同样校验以给用户及时反馈;最终执行上线、恢复或收养实例的闭源 runtime 必须基于受验证的授权和共享额度账本决策。若使用短期执行许可,它应绑定 app、操作、部署、幂等键和有效期并防重放;只给现有 HMAC 加一个布尔 licensed=true 不构成授权保护。

+
时序图 6 第 1 部分Backend 到 Gateway:1 请求已批准版本上线;Gateway 到 Runtime:2 认证后的执行请求与幂等标识;Runtime 到 Runtime:3 本地验签并检查授权能力与部署绑定;Runtime 到 Ledger:4 原子取得已有名额或预占新名额;Ledger 到 Runtime:5 返回占用结果或额度不足;Runtime 到 Engine:6 准备、启动、探活和切换路由;Runtime 到 Ledger:7 成功转为在线;失败补偿预占平台发布服务商业 Gateway闭源 runtime共享额度账本Docker / K8s1 请求已批准版本上线2 认证后的执行请求与幂等标识3 本地验签并检查授权能力与部署绑定4 原子取得已有名额或预占新名额5 返回占用结果或额度不足6 准备、启动、探活和切换路由7 成功转为在线;失败补偿预占
图 6 · 第 1/2 部分 · 按步骤编号连续阅读;窄屏可横向滚动
时序图 6 第 2 部分Runtime 到 Gateway:8 返回可重试、可对账的结果;Gateway 到 Backend:9 更新平台发布状态平台发布服务商业 Gateway闭源 runtime共享额度账本Docker / K8s8 返回可重试、可对账的结果9 更新平台发布状态
图 6 · 第 2/2 部分 · 按步骤编号连续阅读;窄屏可横向滚动

图示是目标方案,不是现有 API 时序。是否把额度账本放在 Gateway 数据库还是商业 runtime 的持久状态中,需要结合多副本与灾备设计确定;必须只有一个权威口径,不能各进程独立累计。

+

6.4 “免费 10 个在线应用”的一致性要求

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
情况建议规则需保护的边界
首次上线原子校验 在线占用+未完成预占 < 有效上限,按 app_id 唯一预占防并发请求各自看到第 10 个空位,实际启动到 11 个
同一请求重试相同业务幂等标识复用同一操作与名额网络超时不代表执行失败,不能重复扣名额或重复创建
已在线应用升级复用该 app 的已有名额;候选容器不额外算一个应用计费对象是应用,不是发布版本、容器数量或退休窗口
构建/准备/审批建议不占“在线应用”正式名额;单独限制临时资源和并发防止借免费预览无限运行;预览口径仍需产品确认
启动失败确认路由和候选执行体已回收,再释放首次上线预占不能只因后端超时就释放一个可能已经在线的 app
停用入口失效且执行状态收敛后释放;数据卷保留后端状态、路由和容器/Pod 三者须对账
删除释放名额与删除业务数据分别记录,均可幂等重试数据删除失败不能靠重复执行造成错误计费
进程重启重建账本与执行事实的对应关系;异常占用需有恢复规则不能因本地缓存/JSON 清空而重新获得 10 个免费名额
自动恢复/收养实例恢复已占名额的同一应用;未知实例需验证归属与合法占用不能只在手动 API 限额、自动对账却无限创建
多 manager/多副本共享部署范围、持久账本和并发控制进程内锁、每宿主容器数都不能代表客户总额度
SQLite 应用名额与数据 PVC 生命周期独立停用释放额度不等于删除数据库,恢复不能新建空库替代旧库
+

当前 manager 的 AdmissionService 只检查 CPU/内存,DesiredStateStore 是本地 JSON+进程内锁;它们不是商业额度的全局事务实现。必须新增商业账本或等效的受控单写权威,并明确故障补偿和对账。C18 C39

+

6.5 各种保护手段能解决什么

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
手段能解决的问题不能承诺的事情
当前内部 HMAC共享密钥下的调用方认证、方法/路径/请求体完整性不是厂商许可证;当前签名串没有时间戳和 nonce,不自带操作级防重放 C35
网络隔离、最小权限减少应用直接访问编排 API、数据和宿主的机会不能阻止有宿主管理员权限的人重配网络、替换服务
许可证非对称签名防止未持厂商私钥者任意修改授权额度,支持离线验签不能防止攻击者替换为一个完全不验签的实现
闭源交付、Java 编译、混淆增加理解、修改和移除校验的成本,保护持续维护的商业实现不等于不可提取、不可反编译或绝对防破解
授权校验与执行状态绑定阻止正常调用路径绕过额度,覆盖重试、恢复和多副本前提是没有另一套可自由执行的完整开源控制面
审计与支持边界记录授权、上线/停用、异常实例、覆盖和补偿事实审计本身不代替执行校验,也不能保证管理员不能篡改本地环境
+

如果已有完整 manager 源码对外发布,后续闭源不能消除既有副本。客户控制宿主机时也可以自行搭建其他编排实现。实际目标应是保护官方商业实现和后续能力、提高绕过成本,并让标准交付路径中的额度可信,而不是承诺“拥有 root 的客户绝对无法绕过”。

+

6.6 Gateway 已有机制与需要新增的能力

+

所检查 Gateway 提交中存在 dsh_entitlement 的 RS256 签名校验;DSH 的 DshLicenseState 有内置 10 席位,并在签名无效/过期时计算其有效授权。它控制 DSH 席位,不是托管应用上线数量;不能直接复用该 audience、能力字段或过期回收策略来控制应用。G01 G02

+

可参考受信公钥、签名授权解析、有效授权状态和审计表达方式。托管模块仍需独立的能力标识、授权绑定、在线应用账本和执行校验;在本次检查的 Gateway 源码中未发现完整 app-hosting runtime 或相应在线应用额度实现。现有许可证状态接口也不能单独证明托管授权存在。G03

+

+

7. 评审事项、验证场景与实施影响

+

7.1 应在定案前回答的事项

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
编号待决事项本文建议决策影响
D01源码发布默认采用应用镜像还是应用包优先验证应用包,完整镜像作为另一交付方式决定构建器、应用仓库与依赖产物管理投入
D02商业 runtime 选择 Java 全量重写、渐进迁移还是闭源 Python独立进程;优先按关键状态/执行边界评估 Java 路线决定迁移范围和多架构维护成本,不仅是编译器选择
D03谁可以登记自定义基础/运行镜像建议管理员登记受控环境,完整应用镜像另行准入关系到运行契约、系统库、UID、只读文件系统和供应链
D04授权范围如何绑定建议一个客户授权部署范围共享额度,显式支持多执行节点防止“每个节点各有免费 10 个”
D05许可证过期或额度下降时,如何处理已在线超额应用倾向阻止新增并显式提示,避免未经说明自动停生产应用;最终须产品确认恢复、升级、停用后重启与存量 grandfather 规则需统一
D06预览是否计费、可运行多久建议不计正式在线数,另设数量/TTL/资源限制防止预览通道被当成免费正式运行环境
D07SQLite 升级的停写与恢复策略单活动实例、迁移前快照、兼容变更优先;独立审批数据恢复影响停机窗口、代码回滚可用性和数据损失风险
D08离线依赖管理允许到什么范围明确支持包目录、架构与版本,提供缺包诊断与补充包流程“任意第三方依赖完全离线可用”不能靠预装常用库保证
D09已确认存储边界下的接口版本、任务授权与切换策略backend 接管附件/远端存储,manager 保留本地 SQLite;定稿流式传输、持久确认和 SDK 兼容契约职责归属已确认;待评审的是协议细节、旧令牌/运行实例切换和迁移验收
D10免费托管组件是否要求同时安装 Gateway同一闭源 runtime 提供免费基线;明确门面和许可证组件的最小交付组合影响社区安装体验、额外进程数量和商业控制边界
+

这些是技术评审仍需裁决的事项,不是文档作者自行确定的产品承诺。会话已确认的免费额度、双交付意向,以及“附件归 backend、manager 本地 SQLite+平台文件 API、不直连 MinIO”的职责边界不在此重新询问;D09 仅保留协议和切换细节。

+

7.2 验证场景与通过标准

+

下表是实施后应执行的验收集合,本次只形成检查项,没有执行结果。

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
类别场景通过标准
包与租户隔离超限、路径逃逸、特殊 tar 条目、跨应用/租户发布在正确阶段拒绝;没有跨目录/资产写入;错误可定位
存储网络边界禁止 manager、准备/初始化执行体直连 MinIO,正常发布和附件读写源码/依赖经 backend 返回文件内容;没有 MinIO URL/302 跳转,无存储 SDK/长期密钥/bucket 配置;附件不经 manager
附件接口迁移新旧 SDK、错误信封、令牌轮换/吊销、跨应用/租户请求、预览上传应用使用方式兼容;backend 作为鉴权权威;预览不读写正式附件,manager 退出附件数据路径
快照上传确认上传中断、摘要错误、对象已保存但响应丢失、元数据写入失败、重复请求同一任务幂等对账;只有对象和元数据均已确认时才迁移;失败/未知状态不得执行破坏性 DDL
SQLite 本地执行备份期间写入隔离、迁移独占、指定快照恢复、backend 不挂应用盘使用一致性备份;受控停写/恢复;业务权限在平台、本地操作在 manager;恢复点和数据损失窗口明确
正常首次发布上传→扫描→准备→探活→审批→上线→浏览器访问版本、审批、运行引用和路由一致;仅授权用户可访问
启动入口Python Procfile、默认入口、Node start/main、静态包监听正确地址/端口;基路径、资源 URL 和 WebSocket 可用
失败补偿审批创建成功但版本写入失败;启动成功但响应丢失可取消或对账;没有无法追踪的审批、容器或重复名额
更新与迁移代码更新、破坏性 DDL、迁移或凭据重签后启动失败旧版本兼容性、平台能力凭据及数据恢复符合明确策略,不声称旧容器保留即完全无影响
SQLite 持久化停用恢复、容器/Pod 重建、节点故障、磁盘满正式数据保留;本地卷故障边界清楚;预览不触碰生产库
完全离线阻断公网,验证所有镜像/依赖/SDK/产物与运行接口不靠公网 fallback;缺失时报告具体阶段和资源
K8s 权限和仓库无 Docker CLI、无节点 socket、无客户 Registry所选方案下可运行;镜像路线附带仓库能完成自举;每工作节点可拉
应用包路线仅代码变化、依赖变化、运行镜像变化、架构变化复用准确;错误 ABI 不进入正式运行;缓存丢失可从持久产物恢复
授权并发免费第 10/11 个、并行上线、请求重试、滚动候选上限按唯一 app_id 保证;升级不重复占用;临时资源另计
授权故障恢复manager/Gateway 重启、多副本、账本不可用、过期授权不因缓存重置增额;已有/新增操作行为符合产品决议
通道绕过直调执行 API、自动重建、预览、未知实例收养同一受控授权边界覆盖全部正式上线路径
SDK 与能力身份、模型、检索、附件、令牌更新和过期应用与访问者身份正确;无 MinIO/编排主凭据泄漏
+

7.3 评审后的实施次序建议

+

先落定已确认存储边界的接口和迁移设计:backend 提供附件及产物/快照 API,兼容 SDK 句柄和错误信封;manager 保留本地 SQLite 操作,改走平台下载/上传协议,更新认证、自检、网络策略及部署配置,并验证在拒绝其直连 MinIO 时仍能完整发布。旧在线实例持有的附件地址和令牌需有切换方案;过渡适配的期限与退出条件应明确,最终状态不得保留 manager 附件服务或 MinIO 旁路。

+

再根据发布产物路线和商业语言方案的评审结果更新清单/版本契约与 F059 规格;用一个 Python+SQLite 应用完成离线准备、发布、更新、停用恢复和故障对账的纵向验证。其后验证静态/Node、用户完整镜像、多架构以及多节点。商业额度应随实际执行状态设计,不能等编排重写结束后再外挂一个 Gateway 计数器。

+

当前仓库中存在的构建、代理、存储、凭据和迁移能力都有既有调用方;迁移应以接口兼容和逐项替换为单位。本文只交付评审材料,没有修改发布接口、容器行为、授权或数据库。

+

+

附录 A. 代码证据索引

+

以下链接固定到核查提交。Gateway 为私有仓库,读取需要相应访问权限。证据采用“可执行调用及配置优先”的口径;早期注释中的“本轮暂不支持”等表述不单独作为交付状态依据。

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
编号固定提交中的代码入口核查内容
C01src/bisheng-cli/bisheng_cli/commands/deploy.py:54CLI 上传与轮询
C02src/bisheng-cli/bisheng_cli/packaging.py:110可重复打包与 SHA-256
C03src/backend/bisheng/app_publish/domain/schemas/app_manifest.py:147应用清单字段
C04src/backend/bisheng/app_publish/domain/services/package_service.py:61私有桶、解包与代码快照
C05src/backend/bisheng/core/config/app_runtime.py:45后端配置及包限制
C06src/backend/bisheng/app_publish/domain/services/publish_pipeline_service.py:92异步发布阶段与接收入口
C07src/backend/bisheng/app_publish/domain/services/precheck_service.py:156构建、预签名下载与临时探活
C08src/runtime-manager/runtime_manager/api/schemas.py:37编排 API 参数模型
C09src/runtime-manager/runtime_manager/builder.py:76模板、基础镜像和构建流程
C10src/runtime-manager/runtime_manager/docker_backend.py:124Docker API 协议与真实实现
C11src/runtime-manager/runtime_manager/templates/python3.11/Dockerfile.j2:42依赖层与源码层
C12src/runtime-manager/runtime_manager/templates/python3.11/entrypoint.sh.j2:27Python 启动入口
C13src/runtime-manager/runtime_manager/templates/node20/entrypoint.sh.j2:44Node 启动入口
C14src/backend/bisheng/app_publish/domain/services/publish_online_service.py:110审批通过、停用分支与迁移顺序
C15src/backend/bisheng/app_runtime/domain/services/app_state_service.py:283应用状态与上线意图
C16src/runtime-manager/runtime_manager/lifecycle.py:209容器参数、部署、路由切换与删除
C17src/runtime-manager/runtime_manager/config.py:57RTM 配置、路径和超时默认值
C18src/runtime-manager/runtime_manager/desired_state.py:229本地期望状态持久化
C19src/runtime-manager/runtime_manager/reconciler.py:192重启接管、修复及孤儿清理
C20src/runtime-manager/runtime_manager/preview.py:18审批预览的数据库隔离
C21src/runtime-manager/runtime_manager/appdb.py:509SQLite 数据面、迁移和一致性快照
C22src/runtime-manager/runtime_manager/storage.py:147MinIO 附件句柄和应用隔离
C23src/runtime-manager/runtime_manager/main.py:79manager 入口与路由注册
C24src/app-proxy/app_proxy/clients.py:177入口鉴权和路由的双 RPC
C25src/backend/bisheng/app_runtime/domain/services/entry_authz_service.py:88入口权限及 OBO
C26src/app-proxy/app_proxy/headers.py:29身份头清理与注入
C27src/runtime-manager/runtime_manager/egress.py:444出站策略持久化和运行配置
C28src/runtime-manager/runtime_manager/egress_proxy.py:171独立出站转发进程
C29docker/docker-compose.yml:277四进程部署、网络、挂载和配置
C30docker/nginx/conf.d/default.conf:80浏览器入口反向代理
C31src/bisheng-sdk/README.md:1SDK 的运行位置与三类功能
C32src/bisheng-sdk/bisheng_sdk/retrieve.py:40检索 API 与双重身份
C33src/bisheng-sdk/bisheng_sdk/_storage_remote.py:3SDK 附件服务地址
C34features/v3.0.0/059-k8s-runtime-backend/spec.md:108K8s 构建、仓库和 PVC 规格
C35src/runtime-manager/runtime_manager/auth.py:42内部 HMAC 的当前实现
C36src/backend/bisheng/app_publish/domain/services/capability_bus_service.py:384应用凭据和模型地址注入
C37src/backend/bisheng/app_publish/domain/services/version_service.py:138冻结版本与镜像引用
C38src/backend/bisheng/app_runtime/domain/services/orchestrator_client.py:134后端编排 RPC 客户端
C39src/runtime-manager/runtime_manager/admission.py:137资源容量准入
C40src/runtime-manager/runtime_manager/api/readonly.py:245运行环境自检
C41src/backend/bisheng/app_publish/api/endpoints/deploy.py:128发布 HTTP 接口及鉴权依赖
C42src/backend/bisheng/dev_toolkit/api/endpoints/distribution.py:271SDK 的平台内分发
C43src/backend/bisheng/app_publish/domain/services/preview_instance_service.py:440预览参数与回收
C44src/runtime-manager/runtime_manager/probe.py:126临时镜像探活与回收
C45src/runtime-manager/runtime_manager/templates/node20/Dockerfile.j2:46Node 依赖和前端编译
C46src/runtime-manager/runtime_manager/api/appdb.py:27SQLite 数据管理接口
C47src/bisheng-cli/bisheng_cli/ignore.py:42CLI 默认排除规则
C48src/backend/bisheng/app_publish/domain/services/app_credential_service.py:134应用凭据重签与撤销顺序
G01src/main/java/com/dataelem/gateway/dsh/service/DshEntitlementService.java:39DSH 签名校验
G02src/main/java/com/dataelem/gateway/dsh/service/DshLicenseState.java:13DSH 免费席位基线
G03src/main/java/com/dataelem/gateway/config/LicenseStatusHolder.java:19Gateway 许可证状态
+

+

附录 B. 官方技术资料

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
编号资料本文使用范围
R01Posit Connect:K8s off-host 架构执行镜像与代码/依赖挂载分离
R02Posit Connect:Python 包与环境管理相同依赖列表与 Python 版本复用环境
R03Modal:Image 接口默认 copy=False 的代码加载与镜像层区分
R04Cloud Foundry:应用 stagingdroplet、Blobstore 与 buildpack 缓存
R05Fission:Packages and buildsruntime 环境与部署归档包,存储服务路径
R06Coolify:Applications源码构建镜像与现有镜像交付
R07SQLite:Write-Ahead LoggingWAL 的同主机共享内存及网络文件系统限制
R08Kubernetes:ReadWriteOncePod单节点 RWO 与单 Pod RWOP 的差异
R09Kubernetes:Init Container 共享卷应用包初始化的实现原语
R10Docker:Engine security编排 API 与宿主权限边界
R11Docker:外部构建缓存一次性构建器跨任务复用需要显式缓存
R12CNCF Distribution:Registry内网仓库的镜像存储、TLS/认证及隔离网络分发
+

外部产品资料用于技术路线对照,本文未进行这些产品的安装和性能测试。浏览器打开官方链接需要网络;本 HTML 的正文、表格和流程图均可离线阅读。

+
此 HTML 由同目录 Markdown 生成,正文以 Markdown 为维护依据。流程图已内嵌为 SVG,无在线渲染依赖。
源文 SHA-256:528d3e7c1f1898711a3cdb34bc14b9ca4786306a8e98074d9d387b09f6326066
diff --git a/docs/reviews/vibe-app-hosting-commercialization-review.md b/docs/reviews/vibe-app-hosting-commercialization-review.md new file mode 100644 index 0000000000..7beb58ddcf --- /dev/null +++ b/docs/reviews/vibe-app-hosting-commercialization-review.md @@ -0,0 +1,957 @@ +# Vibe 应用托管、发布与商业化技术评审 + +文档日期:2026-10-15 | 更新日期:2026-10-16 | 状态:技术评审稿 | 读者:架构、研发、交付与商业产品负责人 + +> **历史参考,已被替代(2026-10-19):** Vibe 已确定全部按开源实现。本文中的商业授权、“免费同时在线 10 个应用”和闭源 runtime 建议不再作为实施要求;当前有效方案见 [Vibe 应用托管全开源技术方案](vibe-app-open-source-technical-design.html)。下文保留当时的代码核查和讨论记录。 + +**评审目的:** 说明现有应用托管链路如何工作、现场需要哪些资源,以及哪些能力适合作为商业模块;同时评估“预制运行镜像+应用包”能否降低离线交付成本。 + +> **结论口径:** 现有源码发布采用“平台模板构建应用镜像”的 Docker 路线;K8s 仅核实到 F059 规格,不能视为已交付。应用包路线和商业 runtime 均为评审建议。会话已确认“源码包+完整镜像”双交付意向,以及“免费同时在线 10 个应用、停用释放名额”的商业口径。2026-10-16 进一步确认存储边界:附件接口迁到 backend;manager 保留本地 SQLite 备份、迁移和恢复,通过 backend 接口下载源码、上传快照,不直接访问 MinIO。这些目标能力尚不能按现有代码认定为已实现。 + +## 阅读导航 + +- [1. 代码基线、结论与决策状态](#section-1) +- [2. 应用内容、存储与持久化](#section-2) +- [3. 发布上线与访问的实际时序](#section-3) +- [4. 进程职责、接口、参数与环境依赖](#section-4) +- [5. 发布路线与同类产品评审](#section-5) +- [6. 商业化边界与保护方案](#section-6) +- [7. 评审事项、验证场景与实施影响](#section-7) +- [附录 A. 代码证据索引](#appendix-a) +- [附录 B. 官方技术资料](#appendix-b) + + + +## 1. 代码基线、结论与决策状态 + +### 1.1 本次核查范围 + +| 对象 | 核查基线 | 使用方式 | +| --- | --- | --- | +| BiSheng 托管实现 | `feat/3.0.0-vibe`,完整提交 `736ef593f3fc6e1634008c842d2604304d6f70a6` | 已用只读远端查询确认分支提交;文中代码链接固定到该提交 | +| 当前工作目录 | `/Users/zhangguoqing/works/bisheng`,工作分支为 `feat/3.0.0-beta3` | 通过 `git show` 读取 vibe 提交,未把 beta3 本地改动当成托管实现 | +| Gateway 授权参考 | 本地 `feat/dsh-access`,提交 `faed249a96ea43a4c65b90765dd4054bf6e55722` | 仅说明所检查源码中的许可证与 DSH 席位机制,不代表远端最新 Gateway | +| 市场与技术资料 | Posit Connect、Modal、Cloud Foundry、Fission、Coolify,以及 Kubernetes、SQLite 官方文档 | 用于解释技术路线与边界,不作为毕昇已经实现这些能力的证据 | + +本次完成的是静态源码、部署配置及外部官方资料核查。没有实际运行客户 Docker/K8s 集群、构建离线应用、测试许可证或执行数据恢复。仓库中的部分注释保留早期设计表述,判断以当前调用、参数和部署配置为准。 + +### 1.2 哪些事实已经成立 + +| 状态 | 事项 | 对评审的意义 | +| --- | --- | --- | +| 已实现 | CLI 上传源码 `tar.gz`;后端保存 MinIO 快照;Celery 调 manager 构建与探活;审批后发布 | 当前并非“用户给一个镜像就能上线”的接口 [C01] [C06] [C08] | +| 已实现 | manager、app-proxy、egress-proxy、docker-socket-proxy 是四个 Compose 服务 | 逻辑职责、进程和镜像三个概念需区分;egress-proxy 与 manager 使用同一代码镜像的不同入口 [C29] | +| 已实现 | manager 经 socket proxy 访问 Docker Engine API;应用不发布宿主机端口 | manager 镜像不需要 Docker CLI;容器内相同监听端口可以共存 [C10] [C16] [C29] | +| 已实现 | SQLite 位于按应用隔离的宿主机挂载目录;附件和迁移快照可进入 MinIO | 运行数据独立于源码包和应用镜像 [C17] [C21] [C22] | +| 规格约定 | K8s 构建执行体、镜像仓库、RBAC、网络策略、PVC 与离线交付 | 本次未在 runtime 实现中发现对应 K8s 后端;不能直接宣称支持 [C34] | +| 会话已确认 | 同时在线应用数计费;免费 10 个,停用释放;期望支持源码包和完整镜像 | 是目标业务要求,不是当前授权代码事实 | +| 会话已确认,待实现 | 附件接口归 backend;manager 保留本地 SQLite 操作,通过 backend 下载源码、上传快照,不直接访问 MinIO | 存储实现和凭据归平台;目标调用链见 [§3.6](#target-storage),接口契约仍需设计 | +| 待评审建议 | 默认源码路线改用运行镜像+应用包;独立闭源 runtime 承担商业编排 | 涉及发布契约、部署执行及授权设计,需评审后再进入实施 | + +### 1.3 本文的重点建议 + +应用形态有限、离线交付普遍时,优先验证“预制运行镜像+不可变代码包+可复用依赖环境”,保留用户提供完整应用镜像的模式。复杂系统依赖和高度定制应用仍适合完整镜像。商业价值应落在实际拥有环境准备、部署状态、恢复、路由和配额控制的闭源编排服务,Gateway 提供统一 API 入口。以上均为建议,不替代本次评审决议。 + +**已确认的存储拆分独立于发布产物路线。** 无论继续构建应用镜像,还是后续采用应用包,backend 都负责附件服务、产物传输和快照存储;manager 只通过平台协议获取源码和提交快照,不持有 MinIO SDK、长期访问密钥、bucket 或对象路径配置。本地 SQLite 一致性备份、结构迁移和恢复仍由 manager 执行;backend 不为此挂载应用磁盘。本文保留基线现状,并将这一目标方案单独标为“已确认,待实现”。 + + + +## 2. 应用内容、存储与持久化 + +### 2.1 当前应用压缩包具体包含什么 + +应用是可执行的 Web 项目。当前 CLI 把文件制成 `package.tar.gz`,以 multipart 字段 `package` 上传到 `/api/v2/apps/deploy`。包内应包含清单、源码、依赖声明和可解析的启动入口。Python 示例:[C01] [C02] [C03] + +```text +leave-helper/ +├── bisheng-app.yaml # 应用元数据、runtime、port、资源档位与能力声明 +├── requirements.txt # Python 依赖,可声明 bisheng-sdk +├── Procfile # 可选;显式的 web 启动命令 +├── main.py # 应用代码 +├── templates/ # 页面模板 +└── static/ # 静态资源 +``` + +```yaml +manifest_version: 1 +name: 请假知识助手 +slug: leave-helper +runtime: python3.11 +port: 8080 +tier: light +capabilities: + models: [] + knowledge_bases: [] +database: + tables: [] +egress: + domains: [] +``` + +```text +# Procfile 内容示例;uvicorn 必须在 requirements.txt 中声明 +web: uvicorn main:app --host 0.0.0.0 --port $PORT +``` + +当前清单支持 `manifest_version/name/description/icon/slug/runtime/port/tier/capabilities/database/egress`,未知顶层字段被拒绝;没有用户可直接填写的 `image`、`command`、任意 Dockerfile 或通用环境变量字段。后端接收端支持 `python3.11/node20/static`,异步构建时还会向 manager 复核其模板集合。[C03] [C07] + +Node 项目依赖 `package.json` 及实际提供的锁文件;模板安装依赖,存在 build script 时执行 `npm run build`。静态项目提交可直接提供服务的文件,构建时从包根或约定的 `dist/build/public` 中识别包含 `index.html` 的目录。[C09] [C45] + +CLI 默认硬排除 `.git`、`node_modules`、虚拟环境和缓存目录;数据库、附件、`dist/build` 默认软排除,可通过 `.bishengignore` 取回需要交付的文件。因此静态项目必须确认构建产物确实进入包;生产 SQLite 数据不应作为源码包交付。打包会规范顺序和元数据,生成可核对的 SHA-256。[C02] [C47] + +### 2.2 包大小与安全解包 + +后端默认限制压缩包 50 MiB、解压后 200 MiB、最多 20,000 个条目;实际值来自部署配置,CLI 通过 `/api/v2/apps/deploy-limits` 获取,不能把默认值当成不可修改的产品限制。后端按流接收和解包,拒绝路径逃逸、链接、设备节点、FIFO 等危险条目。规范包以清单位于根目录为准;后端也兼容仅一层顶级目录的包。[C04] [C05] + +### 2.3 六种不同的交付物和运行对象 + +| 对象 | 例子 | 当前产生方 | 能否直接供浏览器使用 | +| --- | --- | --- | --- | +| 源码归档 | `code.tar.gz` | CLI 打包、后端存储 | 不能;它是构建输入与审批查看依据 | +| 基础运行镜像 | `python:3.11-slim` | 安装交付准备 | 不能;只有语言运行环境,不含具体应用代码 | +| manager 服务镜像 | 包含 `runtime_manager` 的 Python 镜像 | 平台交付 | 运行编排服务,和应用基础镜像不是同一交付物 | +| 完整应用镜像 | `bisheng-app/leave-helper:1-` | manager 通过 Engine 构建 | 要先创建运行容器,再经入口代理访问 | +| 应用容器 | 某个 app/version/generation 的实例 | Docker Engine | 仅内部网络可达,无宿主端口映射 | +| 业务运行数据 | `/data/app.db`、应用附件 | 应用与平台数据服务 | 经应用或受控接口访问,独立于代码版本 | + +当前基础镜像映射为 `python:3.11-slim`、`node:20-slim`、`nginx:1.27-alpine`。manager 自身也使用 Python 基础镜像,但不会把自己的编排程序作为用户应用的运行镜像。当前应用镜像标签由 app slug、版本号和版本 ID 派生。[C09] [C16] [C29] + +### 2.4 存储位置、访问方与生命周期 + +**当前基线映射:** 下表描述 `736ef59` 的实际实现;其中 manager 直连对象存储的路径将按后续目标表替换。 + +| 数据 | 当前位置或路径 | 主要读写进程 | 生命周期与说明 | +| --- | --- | --- | --- | +| 应用、版本、发布尝试、实例、审批元数据 | 平台 MySQL/DM8 对应业务表 | backend、Celery | 保存所有权、权限关联、状态、清单、镜像引用和发布结果;manager 不直接查询这些表 | +| 源码快照 | MinIO 私有桶 `bisheng-apps`:`apps/{app_id}/versions/{version_id}/code.tar.gz` | 后端写入;worker 读取;manager 使用预签名 URL 下载 | 按版本存放;不是浏览器静态站点目录 [C04] | +| 构建工作目录 | `{RTM_DATA_ROOT}/builds/{build_id}` | manager;Engine 消费构建上下文 | 构建结束清理,不是业务数据目录 [C09] [C17] | +| 应用镜像 | manager 连接的 Docker daemon 镜像库 | Docker Engine;manager 发起构建、检查和清理 | 当前单机路径未把应用镜像推入 MinIO 或镜像仓库 [C10] | +| 编排期望状态 | `{RTM_DATA_ROOT}/state/desired-state.json` | manager | JSON 文件,原子替换;包含实例、环境和退休容器信息,不能当普通公开配置分发 [C18] | +| 出站策略 | `{RTM_DATA_ROOT}/egress/` 下策略文件 | manager 写;egress-proxy 读 | 通过文件共享,不通过后端业务表实时查询 [C27] [C28] | +| SQLite 运行库 | 宿主 `{RTM_HOST_DATA_ROOT}/apps/{app_id}/db/` → 容器 `/data` | 应用;manager 的数据库接口与迁移服务 | 数据库为 `/data/app.db`;同时需要目录内 WAL/SHM 等伴随文件可写 [C16] [C17] | +| 数据库迁移快照 | `bisheng-apps`:`apps/{app_id}/db-snapshots/{ts}.tar` | manager | 使用 SQLite backup API 生成一致副本,再打 tar 存储;不是直接复制正在写入的 db 文件 [C21] | +| 应用附件 | `bisheng-apps`:`apps/{app_id}/attachments/{relative_key}` | manager 的附件服务;应用经 SDK 调用 | 应用只持有该 app 的句柄令牌,不拿 MinIO 主凭据 [C22] [C33] | +| 审批预览和预检数据 | 临时容器 `/data` tmpfs | 临时应用进程 | 空的临时数据库,不挂正式库;实例结束即丢弃 [C20] [C44] | + +`bisheng-apps` 是所有应用共享、按 app/version/附件前缀隔离的私有桶,不是每个应用一个桶。单独建桶是因为现有平台公共桶存在匿名策略和 nginx 公开转发路径;代码与私有附件不能沿用该暴露面。建桶和访问授权不应依赖“对象路径难猜”。[C04] [C22] + +**已确认目标映射,待实现:** 继续复用平台 MinIO 和 `bisheng-apps` 私有桶;bucket 与对象前缀成为 backend 内部实现,不再下发给 manager。当前对象路径可以继续使用;是否调整快照命名和元数据索引在接口设计中确定。 + +| 数据 | 目标读写链路 | 职责与生命周期 | +| --- | --- | --- | +| 源码及后续代码/依赖产物 | manager 或受控准备任务 → backend 产物接口 → MinIO | backend 校验任务及产物归属、流式返回内容;manager 下载校验摘要后使用,不接收对象存储 URL | +| 应用附件 | 应用 SDK → backend 附件接口 → MinIO | backend 管理应用身份、路径隔离、大小限制、访问审计和数据清理;manager 只注入平台提供的附件句柄 | +| SQLite 迁移快照 | manager 本地 Backup API → backend 快照上传接口 → MinIO | manager 生成一致副本;backend 保存文件和快照元数据,持久化确认后返回 `snapshot_id`;上传未确认则不做破坏性迁移 | +| SQLite 运行库与恢复 | 应用或 manager → 本地数据卷;恢复文件由 backend 接口提供 | `/data/app.db` 及其本地持久化关系保留;backend 不直接打开应用磁盘上的 SQLite 文件 | + +停止应用仍保留源码、附件、SQLite 和迁移快照。目标方案的显式删除由 backend 清理或按保留策略处置源码、附件和快照,manager 仅销毁执行体并按明确指令清理本地卷;两侧分别记录结果并重试,存储清理不能成为未受控的跨系统隐式事务。 + +### 2.5 停用、升级、删除与备份 + +| 操作 | 代码快照与版本记录 | SQLite | 附件 | 实例 | +| --- | --- | --- | --- | --- | +| 停用应用 | 保留 | 保留 | 保留 | 正式实例停止/回收;后续恢复复用资产 | +| 发布新版本 | 新增冻结快照及版本 | 复用同一应用数据目录,可能执行迁移 | 复用应用附件空间 | 新实例健康后切路由,旧实例宽限退休 | +| 拒绝或撤回发布 | 未上线的发布结果被记录 | 预检/预览临时库不影响正式库 | 不向预览注入正式附件句柄 | 回收相关临时预览 | +| 显式删除应用 | 进入应用删除与发布清理流程 | manager 收到 `purge_volume=true` 才删除应用目录 | manager 清理附件;后端删除钩子负责其他发布资产 | 销毁实例并清理路由/出站主体 | +| 迁移前快照 | 不替代代码快照 | 破坏性迁移前尝试创建一致副本;快照失败则拒绝迁移 | 无直接变化 | 快照本身不是实例 | + +删除涉及 backend、发布删除钩子及 manager 的多个动作,不能推断为覆盖数据库、对象存储、磁盘的单一原子事务。失败源码快照另有孤儿清理;镜像保留/清理也有独立规则。恢复程序、保留期限与现场备份策略仍需明确。[C04] [C16] [C21] + +上表描述当前行为。按已确认目标,附件和远端快照的保留/删除改由 backend 执行,本地库的备份、迁移、恢复及显式卷清理由 manager 执行;停用释放商业名额不删除任何上述业务数据。 + +### 2.6 SQLite 在 Docker 与 K8s 中的约束 + +当前 Docker 形式通过宿主机 bind mount 持久化,不把数据库写入镜像可写层。默认宿主数据根为 `/opt/bisheng/app-data`;manager 容器内一般看到 `/app-data`。`RTM_DATA_ROOT` 是 manager 自身读写视角,`RTM_HOST_DATA_ROOT` 是 Engine 解释 bind mount 的宿主视角,两者必须指向同一份实际数据。[C17] [C29] + +**K8s 规格**要求一个应用一个独占数据卷,应用 Pod 重建后复用 PVC。StorageClass 只提供节点本地盘时应标注节点绑定;节点损坏后不能承诺自动无损跨节点恢复。采用可重新挂载的 CSI 块存储、在单节点格式化挂载为文件系统,与把 SQLite 放到 NFS/SMB 共享目录是两回事。SQLite WAL 官方明确不支持网络文件系统上的跨主机协同。[C34] [R07] + +**建议**在 CSI 支持时使用 `ReadWriteOncePod`;普通 `ReadWriteOnce` 仅限制节点,同节点仍可能有多个 Pod 同时挂载。SQLite 应用采用受控单活动实例发布,结构迁移时停止或隔离正式写入。审批预览使用临时库或明确生成的副本,不能直接挂正式 PVC。当前 Docker 升级存在新旧容器重叠,不能把建议中的单活动 Pod 当成当前既有保证。[C16] [R08] + +代码回滚不会自动逆转数据库 DDL 或恢复已写入的业务行。发布记录需要分别描述代码版本、数据库 schema 版本及快照;恢复生产数据是独立动作,应明确停写、恢复点、数据丢失窗口和操作者。 + +当前版本记录和镜像保留为诊断、重新上线提供依据,但本次没有据此认定存在完整的任意历史版本一键回滚产品流程。 + + + +## 3. 发布上线与访问的实际时序 + +§3.1—§3.5 保留当前基线的真实调用,§3.6 描述已确认但尚未实现的目标链路;两者不能混读为同一版本的接口。 + +### 3.1 上传、异步预检与构建 + +图中 Docker Engine 一列包含经 docker-socket-proxy 转发的 API 调用;proxy 本身不执行业务编排。 + +```mermaid +sequenceDiagram + participant CLI as CLI + participant API as Backend API + participant Worker as Celery worker + participant Manager as runtime-manager + participant Engine as Docker Engine + participant Store as MinIO + CLI->>API: 1 上传 package、app_id、结构变更确认 + API->>API: 2 鉴权、限额解包、清单/归属/能力/结构校验 + API->>Store: 3 写入版本代码快照 + API->>Worker: 4 发布部署任务入队 + API-->>CLI: 5 返回 deployment_id 和轮询入口 + Worker->>Store: 6 读取快照;扫描密钥、计算结构变化 + Worker->>Manager: 7 检查运行时;提交 build intent + Manager->>Store: 8 使用预签名 URL 下载源码 + Manager->>Engine: 9 模板渲染后构建应用镜像 + Worker->>Manager: 10 轮询 build_id 得到 image_ref + Worker->>Manager: 11 提交临时镜像探活 + Manager->>Engine: 12 启动、探活并回收临时实例 + Manager-->>Worker: 13 返回预检结果 +``` + +| 步骤 | 实际执行方与动作 | 参数或数据 | 失败时的结果 | +| --- | --- | --- | --- | +| 1 | CLI 检查文件、执行排除规则、生成 tar.gz 和摘要,先查部署大小限制 | `package`、可选 `app_id`、`confirm_schema_change` | 本地打包错误不上传;上传前检查不能替代服务端校验 | +| 2 | `/api/v2` 统一密钥鉴权链校验凭据、scope 和模式,再检查 runtime 开关 | 发布接口 scope 为 `app:manage`,模式限定 `S`;资源归属按凭据关联的自然人判断 | 身份、scope、模式、运行层、归属或清单错误在同步请求返回 | +| 3 | 后端按流临时落盘、安全解包;解析清单、资源档位、能力引用和结构变更确认 | 版本 ID 在接收时预分配;冻结 manifest、tier 和快照键 | 校验失败不进入慢构建;跨系统失败需靠清理/补偿处理 | +| 4 | 后端写 MinIO 快照及 `app_deployment`,审计后发送 Celery 默认队列任务 | `deployment_id` 随任务传递;租户上下文随任务链传递 | 入队/worker 故障影响后续进度,CLI 上传成功不代表已构建或上线 | +| 5 | API 返回接收结果,CLI 持续查询发布状态 | `GET /api/v2/apps/deployments/{deployment_id}` | 审批可能长时间等待,不能用一个长 HTTP 请求覆盖全过程 | +| 6 | worker 重新取源码,按当前顺序执行扫描、结构预检、构建、启动预检 | `secret_scan → precheck_schema → precheck_build → precheck_probe` | 某阶段失败记录阶段、原因、日志摘要;未进入审批 | +| 7 | worker 查询 manager 支持 runtime,生成源码短期预签名 URL 并提交构建 | `app_id/version_id/runtime/port/code_url/code_object_key/slug/version_no` | 不可达、runtime 不支持、构建容量不足分别返回可定位原因 | +| 8 | manager 后台线程检查构建资源、下载解包、识别源码特征、渲染平台模板 | 基础镜像由 runtime 映射;依赖源来自 RTM 配置 | 下载失败与模板失败属于不同构建阶段 | +| 9 | manager 经 Engine API 构建,生成版本标签,返回构建日志状态 | Python 安装 requirements;Node 安装并按需 build;静态文件装入 nginx 镜像 | 依赖解析、编译、网络阻断或 Engine 错误使 build 失败 | +| 10 | worker 轮询得到 `image_ref`,再提交独立的镜像探活 | 端口、路径、基路径环境;探活默认路径为 `/` | 临时实例使用 tmpfs,无正式卷;失败时保存日志摘要并清理容器 | + +依据:[C01] [C02] [C04] [C06] [C07] [C08] [C09] [C41] [C44]。 + +### 3.2 审批、版本入库与上线 + +> **实际顺序修正:** 构建和探活通过后,版本服务先提交审批准入,再插入版本记录;若版本插入失败,会尝试取消刚建立的审批。业务上可合称“记录版本并进入审批”,但实现不是“版本先入库、再发审批”的简单顺序。[C37] + +```mermaid +sequenceDiagram + participant Approval as 审批引擎 / Outbox + participant Worker as Celery worker + participant Manager as runtime-manager + participant Engine as Docker Engine + participant App as 正式应用实例 + Worker->>Approval: 1 审批准入;随后写入版本,失败补偿取消 + Approval-->>Worker: 2 审批通过的业务回调 + Worker->>Worker: 3 暂存待上线版本;回收审批预览 + Worker->>Manager: 4 迁移声明表;必要时先做数据库快照 + Worker->>Manager: 5 查询运行资源准入 + Worker->>Worker: 6 签发应用凭据并组装能力环境 + Worker->>Manager: 7 deploy intent:image_ref、tier、port、env 等 + Manager->>Engine: 8 再次准入、挂卷、限额、创建并启动新容器 + Engine->>App: 9 执行镜像 entrypoint + Manager->>App: 10 等待 HTTP 就绪 + Manager->>Manager: 11 写期望状态,路由改指新实例 + Manager-->>Worker: 12 返回正式实例运行结果 + Worker->>Worker: 13 更新应用/版本/实例和审计记录 + Manager->>Engine: 14 宽限期结束后回收旧实例 +``` + +审批及业务状态逻辑主要在平台后端执行,图中“审批引擎 / Outbox”是逻辑角色,不表示额外交付了一个独立审批容器。上线与预检可在 Celery worker 内执行同一套后端领域服务。[C14] [C15] + +审批通过首先把版本标记为待上线并回收审批预览。如果应用已经被主动停用,回调只暂存版本,不擅自恢复;以后恢复应用时采用待上线版本并补做迁移。其余上线路径先调用表结构迁移,再做资源准入和启动。资源 tier 来自平台表,传到 manager 后表示为 vCPU 与 MiB。[C14] [C15] [C21] + +manager 再次进行容量校验,准备 `/data`、文件所有权、附件令牌和出站策略;随后调用容器 create/start。容器由镜像内 entrypoint 选择业务命令,manager 的 deploy 请求没有单独的 `command` 字段。应用通过就绪检查后,manager 写入新实例期望状态,代理后续路由查询得到新地址;默认 30 秒宽限后退休旧实例。后端收到成功结果后才更新平台应用和版本状态。[C08] [C12] [C13] [C16] [C17] + +**需要评审的事务边界:** 数据库迁移早于资源准入和实例启动;迁移已成功但后续容量或启动失败时,数据库可能已经变化。manager 路由切换与后端数据库状态更新也不是同一事务。当前代码没有据此自动承诺数据库回滚、发布全局原子性或零停机,需要设计兼容性迁移与对账。[C14] [C15] [C16] + +应用能力凭据也存在切换边界:后端在调用 deploy 前重签应用令牌,凭据服务先撤销旧令牌再签发新令牌。如果候选容器随后启动失败,旧容器保留并不意味着它持有的旧令牌仍能调用平台能力。此处需要验证失败补偿或凭据双阶段切换,不能把“旧实例未删除”写成“旧版本完全不受影响”。[C36] [C48] + +### 3.3 不同场景的结果 + +| 场景 | 当前行为或边界 | +| --- | --- | +| 首次发布 | 建立应用草稿;预检全部通过、审批准入及版本入库后等待审批;审批成功再尝试上线 | +| 在线应用更新 | 使用新镜像启动候选实例,健康后换路由;新实例启动失败时保留旧实例和在线状态,但不能据此断言数据库或能力凭据从未变化 | +| 首次上线资源不足 | 保留待上线版本,进入容量等待结果;人工重试不创建第二轮审批 | +| 已在线应用更新资源不足 | 旧应用保持在线;本次新版本未上线,不能把整个应用描述为停服 | +| 预检失败 | 发布尝试失败、保留可诊断阶段与日志;不进入业务审批 | +| 审批拒绝/撤回/终止 | 新版本不生效,回收对应临时预览;在线旧版本按已有状态继续 | +| 审批通过时应用已停用 | 仅暂存待运行版本,恢复时才启动,不自动占回用户已释放的运行资源 | +| 审批预览 | 独立临时实例、`/data` tmpfs、独立出站主体、无正式附件句柄;当前不是自动复制生产数据库的预览 | +| 停用/恢复 | 停用保留代码、SQLite 和附件;恢复重新检查资源,采用 pending/current 版本 | +| runtime 进程重启 | 读取本地期望状态,结合管理标签接管受管实例;持续对账,避免误清理同机其他容器 | + +“商业额度释放”是目标方案,应在确认执行状态与入口失效后完成,不能直接等同于现有后端 `state=stopped` 写入。当前 stop 先写应用状态,再调用 manager,编排 RPC 失败仍可能需要补偿/对账。[C14] [C15] [C19] [C20] [C43] + +### 3.4 浏览器访问与身份注入 + +```mermaid +sequenceDiagram + participant Browser as 浏览器 + participant Nginx as nginx + participant Proxy as app-proxy + participant Backend as Backend + participant Manager as runtime-manager + participant App as 应用实例 + Browser->>Nginx: 1 请求 /apps/{slug}/... + Nginx->>Proxy: 2 原 URI 与 WebSocket 升级信息 + Proxy->>Backend: 3 authorize:会话、slug、请求信息 + Backend-->>Proxy: 4 用户/应用授权结果与 OBO 身份材料 + Proxy->>Manager: 5 查询 app_id 的当前运行地址 + Manager-->>Proxy: 6 内部地址与监听端口 + Proxy->>App: 7 清理伪造身份头后注入可信身份并反代 + App-->>Proxy: 8 HTTP 内容或 WebSocket 数据 + Proxy-->>Browser: 9 经 nginx 返回 +``` + +后端判定运行层是否启用、会话、用户/租户状态、应用状态以及 OpenFGA `app:use` 权限;它不把容器地址作为授权结果返回。app-proxy 独立向 manager 取得路由,分别缓存授权与路由(默认短缓存约 3 秒),并处理入口前缀、可信身份头及 HTTP/WebSocket 转发。WebSocket 不是只握手一次就永久放行,还存在重校验与最大存活时间约束。[C24] [C25] [C26] [C30] + +所有应用可监听容器内 `8080`,实际连接目标是各容器内部地址与端口。当前 `PortBindings={}` 且 `PublishAllPorts=false`,不需要每个应用对外分配一个宿主端口;对外仍复用 `/apps/{slug}`。K8s 对应目标是经受控 Service/Pod 网络转发,具体实现尚未落地。[C16] [C34] + +### 3.5 应用访问毕昇能力 + +| 能力 | 应用侧调用与身份 | 平台侧处理方 | +| --- | --- | --- | +| 当前访问者 | `bisheng_sdk.auth.current_user()` 从当前请求上下文读取平台注入头 | app-proxy 注入;后端先完成身份和入口权限判定 | +| 知识检索 | SDK 调 `POST /api/v2/filelib/retrieve`;应用运行凭据+`X-BiSheng-Access-Token` | 后端根据应用声明和访问者权限共同判定;无访问者不能退化成应用所有者 | +| 模型 | OpenAI 兼容客户端读取 `OPENAI_BASE_URL/OPENAI_API_KEY`;请求按平台能力接线携带访问者信息 | 后端开放模型面及能力策略;模型调用不由 SDK 包装 | +| 附件 | SDK 读取 `BISHENG_APP_STORAGE_ENDPOINT/TOKEN`,调用 manager 的每应用存储 API | manager 校验应用令牌和路径,将请求映射到 MinIO 私有前缀 | +| SQLite | 标准 sqlite 库读取 `BISHENG_APP_DB_PATH/URL` | 应用直接操作挂载的数据文件;不是每次通过后端 HTTP 执行 SQL | +| 出站 HTTP/HTTPS | 使用注入的 HTTP/HTTPS 代理及出站令牌 | egress-proxy 根据 manager 写入的策略校验目标并转发 | + +SDK 安装在声明该依赖的应用镜像/环境里;manager 不 import `bisheng-sdk` 执行业务。SDK wheel 从后端 `/api/v1/dev-toolkit/simple/` 分发。manager 只负责构建时接通索引、运行时注入句柄;Java 重写 manager 不意味着必须把应用 Python SDK 也改写为 Java。[C31] [C32] [C33] [C36] [C42] + + + +### 3.6 已确认目标:通过 backend 传输源码、附件和快照 + +**状态:2026-10-16 会话确认,待实现;下图是目标时序,不是 `736ef59` 的交付事实。** 上传、扫描、审批和入口鉴权仍承接前述业务流程;与存储有关的执行路径改为下图。图中的“发布执行服务”可运行在 backend/Celery,backend API 接收 manager 的文件请求。 + +```mermaid +sequenceDiagram + participant Publish as 发布执行服务 + participant Backend as Backend API + participant Manager as runtime-manager + participant Store as MinIO + participant App as 应用实例 / SDK + Publish->>Manager: 1 提交产物引用及限定任务的下载凭据 + Manager->>Backend: 2 请求源码内容;backend 校验任务归属 + Backend->>Store: 3 读取私有源码对象 + Backend-->>Manager: 4 流式返回源码;manager 校验摘要后构建探活 + Publish->>Manager: 5 审批后提交迁移任务及快照上传授权 + Manager->>Manager: 6 隔离正式写入;本地 Backup API 生成快照 + Manager->>Backend: 7 按快照任务流式上传文件及摘要 + Backend->>Store: 8 持久化快照并记录元数据 + Backend-->>Manager: 9 返回已保存状态及 snapshot_id + Manager->>Manager: 10 确认快照保存成功后执行本地迁移 + Publish->>Manager: 11 提交部署;注入 backend 附件句柄 + Manager->>App: 12 启动、探活并切换路由 + App->>Backend: 13 SDK 通过应用凭据调用附件接口 + Backend->>Store: 14 按应用私有前缀读写附件 + Backend-->>App: 15 返回附件内容或元信息 +``` + +1. **源码下载:** 平台保存不可变源码并建立产物引用;manager 只接收 `artifact_id`、预期摘要、大小和受控 backend 下载入口。backend 返回文件内容,不能以 302 或响应字段把调用者引向 MinIO 预签名地址。源码、后续依赖产物和初始化下载均遵守这一边界。 +2. **附件:** SDK 直接调用 backend;backend 从可信应用凭据确定应用和租户,校验操作与相对路径后访问私有对象。manager 不再接收附件流量,也不在本地期望状态中承担附件令牌的权威校验。预览不获得正式附件权限,采用隔离临时空间或保持现有本地临时模式。 +3. **本地备份与迁移:** manager 取得该应用的迁移独占权,停止或隔离所有正式写入者,使用 SQLite Backup API 生成一致快照。由 manager 执行本地文件操作,backend 不挂应用数据卷;不能把正在写入的 `app.db` 直接打包代替一致性备份。 +4. **持久确认:** manager 通过 backend 上传快照,backend 校验任务、大小及内容摘要,将对象和对应元数据保存成功后才返回“已保存”。破坏性 DDL 必须等到该确认;上传失败或结果不明时先查询任务状态、幂等重试,不推断为成功,也不继续迁移。首次发布且尚无数据库时无需制造空快照,但仍须验证上传通道前置。 +5. **部署与恢复:** 快照、迁移与实例启动各自记录结果;快照成功不代表迁移或上线成功。恢复由 backend 完成业务授权并提供指定快照文件,manager 停止写入、下载校验、恢复本地库并验证后再启动。代码回滚不自动触发数据库恢复。 + +文件上传/下载应采用流式传输与有界缓存;临时快照只在持久化已确认或任务明确取消后清理,异常任务按策略回收。backend 与 manager 通过持久任务状态协调,避免 backend 同步请求占满执行资源后,又等待 manager 回调同一服务上传快照。快照失败不执行破坏性迁移,但隔离写入后的恢复服务和失败补偿仍需明确定义。 + +该方案将存储实现和长期凭据归入平台,商业 runtime 依赖版本化平台 API;接口可达性、超时、认证与版本兼容仍是部署前置,并非取消所有平台依赖。 + + + +## 4. 进程职责、接口、参数与环境依赖 + +### 4.1 进程与资源访问矩阵 + +**当前基线:** 本表保留已核查代码的职责;目标方案的资源边界见其后表格。 + +| 进程或组件 | 负责的业务 | 直接使用的数据/第三方接口 | 主要边界 | +| --- | --- | --- | --- | +| CLI | 本地打包、上传、跟踪发布和日志 | 文件系统、平台 Open API | 不直接创建客户容器;本地可用 git 优化忽略规则,但不是 manager 的运行依赖 | +| Backend API | 鉴权、所有权、清单接收、状态动作、入口授权、数据操作的业务授权 | SQL、Redis、OpenFGA、MinIO、manager HTTP API | 不挂 Docker socket,不直接调用容器编排 SDK | +| Celery worker | 扫描、预检、版本/审批与 outbox 业务执行 | 与后端共享配置及业务数据;MinIO;manager API | 长耗时阶段不放在上传请求内等待 | +| runtime-manager | 构建、资源准入、执行生命周期、对账、路由、预览、日志、附件、SQLite 数据面 | Docker API 经 socket proxy;本地文件/SQLite;MinIO;源码预签名 URL;应用 HTTP 探活 | 不直接读平台 MySQL/DM8 或 OpenFGA;也不读取 backend 的 config.yaml | +| app-proxy | 浏览器应用入口、可信头注入、HTTP/WS 反代 | 后端 authorize API;manager route API;应用 HTTP/WS;本地短缓存 | 不构建镜像、不持编排权限、不直接操作 MinIO/SQLite | +| egress-proxy | 应用与构建网络的 HTTP/CONNECT 目标控制 | 共享策略文件、DNS、允许的目标网络 | 独立入口进程;不提供 manager intent API,不分发业务权限 | +| docker-socket-proxy | 对 Docker API 端点做转发限制 | 宿主 `/var/run/docker.sock` | 当前 Compose 唯一直接持 socket 的组件;不是配额服务,也不理解 app_id | +| 应用容器 | 执行业务代码、提供页面/API、使用 SQLite 和平台能力 | 自身 `/data`;平台 API;附件句柄;受控外部接口 | 不持 MinIO 主凭据、Docker socket 或集群 API 凭据 | +| BiSheng SDK | 应用侧身份上下文、检索及附件 API 封装 | 应用请求头、环境变量、后端检索 API、manager 附件 API | 是应用内库,不是额外常驻服务 | + +依据:[C23] [C24] [C27] [C28] [C29] [C31] [C38]。 + +**已确认目标,待实现:** + +| 进程或组件 | 目标职责 | 直接访问的资源 | 配置与权限边界 | +| --- | --- | --- | --- | +| Backend API / Celery | 产物接收与分发、附件服务、快照登记/保存/清理、应用/租户鉴权和审计;发起发布及迁移任务 | 平台数据库、Redis、OpenFGA、MinIO;manager 受控 API | 持有平台存储配置和应用附件凭据的校验规则;不挂应用磁盘或 Docker socket | +| runtime-manager | 构建/依赖准备编排、准入、实例生命周期、路由、自愈;本地 SQLite 备份、迁移和恢复 | Engine API 或后续 K8s API;本地文件/SQLite;backend 产物/快照接口;应用探活 | 不使用 MinIO SDK、长期密钥、bucket 或对象 key;通过任务授权获取/提交文件 | +| 应用 / SDK | 业务代码、SQLite 访问及平台能力调用 | 自身 `/data`;backend 附件/能力接口;受控出站路径 | 附件不经 manager;应用拿应用范围句柄,不拿快照上传授权或存储主凭据 | +| 构建、准备及初始化执行体 | 在限定任务内准备或获取固定产物 | backend 产物接口、批准的包源/工具链、临时卷 | 下载或上传凭据绑定当前任务和产物,不共享 manager 的全局管理凭据,不直连 MinIO | + +app-proxy 的入口授权/路由查询、egress-proxy 的出站控制与 socket proxy 的 Engine 转发分别按既有职责接入新边界。它们不接管应用附件或远端快照存储。 + +### 4.2 manager 的功能与实际前置条件 + +| 功能 | 当前实现要做的事 | 必要条件 | +| --- | --- | --- | +| 镜像构建 | 检查资源、下载代码、渲染模板、传递 pip/npm/代理参数、调用 Engine build、跟踪日志与结果、清理上下文 | 支持 runtime 的模板、基础镜像、Engine build API、临时磁盘、构建期依赖源 | +| 资源准入 | 检查宿主可用内存减保留量;检查存活实例承诺的 CPU/内存总量 | 可读 `/proc/meminfo`、CPU 信息、本地期望状态;这是资源容量,不是许可证额度 | +| 上线/更新 | 生成实例名、注入环境、准备数据卷、建立出站主体、create/start、探活、更新路由、旧实例退休 | Engine 生命周期 API;应用内部网络;数据目录读写及所有权可用 | +| 停用/删除 | 停止正式/退休实例、更新期望状态;显式清理数据及附件 | 可达 Engine、可写状态目录、删除时可达 MinIO | +| 探活 | 启动临时镜像或检查正式应用;HTTP 就绪、日志读取及回收 | manager 能访问应用 bridge 地址;应用监听 `0.0.0.0` 和声明端口 | +| 对账/自愈 | 重启时从状态与管理标签恢复;修复受管实例、出站漂移、退休和过期预览 | Engine inspect/list;持久状态目录;仅操作平台管理标签范围 | +| 路由/状态/日志 | 从真实实例与期望记录返回当前地址、健康、版本及日志 | manager HTTP API 可达;平台/代理 HMAC 一致;日志受长度和保留限制 | +| 审批预览 | 独立临时实例、空临时数据库、超时回收、禁止正式附件访问 | 镜像已构建;临时资源容量;预览会话/授权由后端提供 | +| 附件服务 | 每应用令牌鉴权、路径校验、大小限制、对象存取和隔离 | MinIO RTM 配置、私有桶;应用可达 manager 的 app-facing 地址 | +| SQLite 管理 | 表/结构/行查询、更新/导出、声明式迁移、破坏性迁移前 backup API 快照 | 可达数据库文件;文件权限/锁可用;快照 MinIO 可用;业务权限由后端先校验 | + +依据:[C09] [C16] [C19] [C20] [C21] [C22] [C39] [C46]。 + +**目标职责调整:** 上表“附件服务”从 manager 移除;“镜像构建”的源码获取改为 backend 下载接口;“SQLite 管理”保留本地执行,但快照保存改为向 backend 上传并等待持久确认。停用/删除只清理执行体、状态和明确要求删除的本地卷,远端附件/产物/快照由 backend 处理。manager 自检相应检查平台文件接口、任务认证和本地备份条件,不再检查 MinIO 账号或 bucket。其余构建、探活和恢复能力仍受 Engine、磁盘、包源及网络条件约束。 + +### 4.3 关键接口与参数来源 + +| 接口 | 调用方 → 被调用方 | 关键参数 | 参数归属与校验 | +| --- | --- | --- | --- | +| `POST /api/v2/apps/deploy` | CLI → backend | multipart `package/app_id/confirm_schema_change` | 用户提交;后端按凭据、租户、资源所有权和清单检查 | +| `GET /api/v2/apps/deployments/{id}` | CLI → backend | `deployment_id` | 平台查询发布过程,返回阶段与原因,不等于直接查询 Docker | +| `POST /v1/intents/build` | worker → manager | `app_id/version_id/runtime/code_url/code_object_key/slug/version_no/port/build_args` | ID 与源码 URL 由后端生成;模板和受保护代理参数由 manager 控制 | +| `GET /v1/builds/{id}` | worker → manager | `build_id` | manager 返回 `status/stage/message/tail/image_ref`;构建记录是进程内状态 | +| `POST /v1/intents/probe` | worker → manager | `image_ref` 或 `app_id`、`port/health/env/timeout` | 临时镜像探活和正式应用探活是不同输入形态 | +| `POST /v1/admission` | 后端服务 → manager | `purpose=run/build`、可选 tier | CPU 为 vCPU、mem 为 MiB;输出准入判定和宿主快照 | +| `POST /v1/intents/db-migrate` | 上线领域服务 → manager | `app_id/plan/snapshot` | 后端按清单差异形成迁移;manager 校验操作并执行/快照 | +| `POST /v1/intents/deploy` | 上线领域服务 → manager | `image_ref/tier/port/env/health/platform_api_base/base_path/egress_domains` 等 | 后端提供冻结版本意图;manager 控制容器安全参数和保留环境 | +| `POST /v1/intents/stop`、`/destroy` | 后端 → manager | `app_id`;destroy 增加 `purge_volume` | 应用动作先经后端权限;删除数据必须显式指定 | +| `POST /v1/intents/preview`、`/preview/stop` | 预览服务 → manager | `session_id/app_id/version_id/image_ref/tier/expires_at` 等 | 后端校验审批参与资格;manager 执行临时实例生命周期 | +| `POST /api/v1/internal/app-proxy/authorize` | app-proxy → backend | slug、会话/访问上下文 | 业务鉴权;另有 authorize-preview,不复用一个含糊开关 | +| `GET /v1/apps/{app_id}/route` | app-proxy → manager | `app_id` | manager 返回路由事实;不代替后端 `app:use` 权限 | +| `/v1/apps/{app_id}/storage/...` | 应用 SDK → manager | 应用令牌、相对路径与内容 | 每 app Bearer 句柄,和后端 HMAC 管理接口是两套凭据 | +| `/v1/apps/{app_id}/db/...` | 后端数据服务 → manager | 表、行键、分页/变更 | manager 接口用内部 HMAC;实际用户操作授权在后端 | + +以上均为当前已检查接口,不是完整 OpenAPI 文档。manager `/healthz` 是明确的免 HMAC 存活检查;不能由 healthz 成功推断 Docker、MinIO 或部署前置全部可用。[C08] [C23] [C24] [C35] [C38] [C41] [C46] + +下面两个 JSON 是**当前基线**中后端向 manager 发送的脱敏意图示例。`code_url` 必须能从 manager 下载;它不是应用运行期间使用的地址。JSON 字段以代码契约为准,示例值不代表现场默认值;其中 MinIO 预签名 URL 和 `code_object_key` 不属于目标存储协议。 + +```json +{ + "app_id": "app-example", + "version_id": "a1b2c3d4e5f647809abcdef0123456789", + "runtime": "python3.11", + "slug": "leave-helper", + "version_no": 1, + "port": 8080, + "code_object_key": "apps/app-example/versions/a1b2c3d4e5f647809abcdef0123456789/code.tar.gz", + "code_url": "https://minio.example.internal/bisheng-apps/...", + "build_args": {} +} +``` + +```json +{ + "app_id": "app-example", + "slug": "leave-helper", + "version_id": "a1b2c3d4e5f647809abcdef0123456789", + "version_no": 1, + "image_ref": "bisheng-app/leave-helper:1-a1b2c3d4", + "tier": {"cpu": 0.5, "mem": 1024}, + "port": 8080, + "env": {"BISHENG_APP_TOKEN": ""}, + "health": {"path": "/", "interval": 10, "timeout": 3, "retries": 3}, + "platform_api_base": "https://bisheng.example.internal", + "base_path": "/apps/leave-helper", + "egress_domains": [] +} +``` + +**目标接口草案:** 下表路径和字段仅用于表达职责与协议设计范围,尚未实现或冻结;内部传输 API 不直接复用面向自然人会话的登录态。 + +| 拟议接口 | 调用方 → 被调用方 | 参数产生、校验与消费 | +| --- | --- | --- | +| `GET /api/v1/internal/app-runtime/artifacts/{artifact_id}/content` | manager / 准备任务 → backend | backend 建立产物、预期摘要和大小,签发下载任务授权;backend 验证 app/版本/任务范围;消费方流式下载并校验,不接收 bucket/key 或 MinIO URL | +| `PUT /api/v1/internal/app-runtime/snapshots/{snapshot_id}/content` | manager → backend | backend 分配快照任务及幂等标识;manager 提交一致快照内容、大小及摘要;backend 校验归属和上传范围,持久保存后返回已确认结果 | +| `GET /api/v1/internal/app-runtime/snapshots/{snapshot_id}` | manager / 发布任务 → backend | 通过同一快照 ID 查询 `pending/stored/failed` 等建议状态及摘要;用于响应丢失后的对账,不凭 HTTP 超时判断对象不存在 | +| `GET /api/v1/internal/app-runtime/snapshots/{snapshot_id}/content` | manager → backend | backend 校验已获授权的恢复任务和目标应用;manager 下载、校验摘要后执行受控本地恢复 | +| `/api/v2/apps/{app_id}/storage/...` | 应用 SDK → backend | backend 签发并校验应用范围附件句柄,根据可信应用身份解析租户和相对路径;不能让调用者任意指定 bucket、完整对象 key 或其他应用 | + +目标迁移任务可以携带如下产物和上传描述;字段是设计示例,不能当作现有 manager schema 发送。任务授权应通过认证头传递,不置于 URL 查询串,不下发应用容器,也不作为长期期望状态或日志内容保存。 + +```json +{ + "deployment_id": "dep-example", + "app_id": "app-example", + "source": { + "artifact_id": "artifact-example", + "download_url": "https://bisheng.example.internal/api/v1/internal/app-runtime/artifacts/artifact-example/content", + "sha256": "", + "size_bytes": 1048576 + }, + "snapshot_upload": { + "snapshot_id": "snapshot-example", + "upload_url": "https://bisheng.example.internal/api/v1/internal/app-runtime/snapshots/snapshot-example/content", + "idempotency_key": "dep-example:before-migrate" + } +} +``` + +认证应同时考虑服务身份与任务授权:manager 调平台文件接口使用受控服务身份,下载/上传权限绑定 app、版本、任务、操作、有效期和幂等键;准备/初始化执行体仅获得最小任务授权。签发方和 backend 校验方属于平台,manager 只消费授权。当前 HMAC 的字段与防重放限制见 §6.5,不能默认原接口已经具备这里的任务约束。 + +SDK 的 `BISHENG_APP_STORAGE_ENDPOINT/TOKEN` 使用方式应保持兼容,但服务端归属迁到 backend;当前 SDK 解析 manager 错误信封、附件令牌又在 manager 期望状态中校验,因此迁移需提供兼容 API 适配或同步升级 SDK,不能只替换 URL。应用令牌轮换、停用/删除后的拒绝策略和预览隔离由 backend 统一定义并验证。[C22] [C33] + +### 4.4 配置归属与应用注入变量 + +**当前基线配置:** + +| 配置组 | 读取进程 | 关键配置和要求 | +| --- | --- | --- | +| 后端运行层 | backend、Celery | `app_runtime.enabled`;`manager_base_url/manager_hmac_secret`;`proxy_hmac_secret`;独立 `obo_secret`;入口地址、包大小、预览 TTL | +| 后端开放能力 | backend、Celery | `open_platform.enabled` 与 Open API scope 可用性;`open_api.public_base_url`、`app_runtime.entry_base_url` 等对外地址应可被应用实际访问 | +| manager 服务 | manager | `RTM_HOST/PORT/HMAC_SECRET`;`RTM_DOCKER_HOST`;`RTM_DATA_ROOT/RTM_HOST_DATA_ROOT`;容量、探活及对账参数 | +| manager 构建 | manager、构建容器 | `RTM_BUILD_INDEX_URL`、可信主机、`RTM_BUILD_NPM_REGISTRY`、`RTM_BUILD_EXTRA_INDEX_URL`;基础镜像和构建代理/网络 | +| manager 对象存储 | manager | `RTM_MINIO_ENDPOINT/ACCESS_KEY/SECRET_KEY`、`RTM_STORAGE_BUCKET`;应用可达的 `RTM_APP_FACING_BASE_URL` | +| app-proxy | app-proxy | `APP_PROXY_BACKEND_BASE/SECRET`、`APP_PROXY_MANAGER_BASE/SECRET`;它们分别对应后端入口授权和 manager 路由 | +| egress | manager 写策略;egress-proxy 读策略 | `RTM_EGRESS_PROXY`、`RTM_PLATFORM_BASE_URL`、构建/运行允许目标、internal 网络和需要的宿主防火墙前置 | + +容量、构建源及 manager 数据根不从后端 YAML 自动继承。后端 AppRuntimeConf 对历史遗留同名配置会告警并指出对应 RTM 环境变量。manager 获取源码只需要短期 URL,但附件读写和 SQLite 快照上传由它直接访问 MinIO,因此还要向 manager 进程注入 `RTM_MINIO_ENDPOINT/ACCESS_KEY/SECRET_KEY` 和 `RTM_STORAGE_BUCKET`;这些配置不会从后端配置自动读取。[C05] [C07] [C17] [C21] [C22] [C29] + +这里的“单独配置”指进程需要收到连接参数,不要求另建 MinIO 实例,也不要求另建 manager 专用桶。当前 Compose 指向平台已有的 `minio:9000`,使用共享私有桶 `bisheng-apps`;源码、附件和 SQLite 快照按对象前缀区分。它们共用这一私有桶,区别于平台已有的 `bisheng` 公共用途桶。生产环境可为 manager 分配权限受限的独立访问凭据,但“独立凭据”不等于“独立实例或独立桶”,也不是当前代码强制的条件。[C04] [C17] [C21] [C22] [C29] + +**已确认目标配置:** 下列变更应随接口迁移落地,不应仅删除环境变量而保留当前直连代码。 + +| 配置或凭据 | 目标归属 | manager 需要接收的内容 | +| --- | --- | --- | +| MinIO endpoint、TLS、访问密钥、bucket、对象路径与保留策略 | backend / 平台存储模块 | 无;删除 manager 的 `RTM_MINIO_*`、`RTM_STORAGE_BUCKET` 配置和 MinIO SDK 依赖 | +| 平台产物/快照传输入口 | backend 提供,部署配置指定 manager 可达地址 | 平台内部服务基址、TLS 信任、请求超时和受控服务身份;具体新增配置名待设计 | +| 下载与快照上传任务授权 | backend 签发和校验 | 限定任务的短期授权、产物/快照引用、摘要和幂等标识;与应用附件凭据分离 | +| 应用附件 endpoint/token/max file | backend 附件服务与应用凭据模块 | 随受控 deploy intent 传入的应用句柄,manager 注入 `BISHENG_APP_STORAGE_*`;backend 为令牌校验权威 | +| 本地 SQLite 与构建/编排参数 | manager | 数据根、宿主卷路径、备份临时目录、迁移独占控制,以及已有 Engine/容量/探活/构建源配置 | + +`RTM_APP_FACING_BASE_URL` 当前用于应用访问 manager 附件接口;附件迁出后需删除这一用途并检查是否仍有其他调用方,再决定移除该变量。内部服务调用的 HMAC/服务身份、应用主体凭据、文件传输任务授权和访问者 OBO 各有作用域,不应复用一把高权限密钥。 + +下表描述当前变量及来源;目标差异在附件行单独标注。 + +| 应用可见变量/请求头 | 来源 | 用途与边界 | +| --- | --- | --- | +| `PORT/BISHENG_APP_PORT` | 清单 port 经 manager 注入 | 应用需监听正确端口与 `0.0.0.0` | +| `BISHENG_APP_ID/SLUG/VERSION/VERSION_ID/BASE_PATH` | 后端版本意图+manager | 识别应用与访问路径;保留变量由平台覆盖 | +| `BISHENG_APP_DB_PATH/URL` | manager | 固定指向 `/data/app.db` 与 SQLite URL | +| `BISHENG_PLATFORM_API_BASE` | 部署意图经 manager 注入 | SDK 平台 API 地址;不能填写构建容器不可达的 localhost | +| `BISHENG_APP_STORAGE_ENDPOINT/TOKEN/MAX_FILE_MB` | 当前 manager 生成;目标由 backend 提供、manager 注入 | 当前指向 manager,目标指向 backend 本 app 附件 API;不暴露 MinIO 主凭据;当前令牌校验不能直接沿用为平台权威 | +| `BISHENG_APP_TOKEN` | 后端能力凭据服务 | 应用主体凭据;更新时可能重签,不应等同于访客登录态 | +| `OPENAI_BASE_URL/OPENAI_API_KEY` | 后端能力注入 | 声明模型能力时设置;地址缺失可能导致容器能启动但模型调用失败 | +| `HTTP_PROXY/HTTPS_PROXY` 等 | manager | 出站路径与主体;依赖客户端应实际使用代理 | +| `X-BiSheng-*`、`X-BiSheng-Access-Token` | app-proxy 按请求注入 | 访问者身份、app_id、请求关联与 OBO;应用不能据自身凭据猜测访客身份 | + +当前 Python entrypoint 支持参数、`BISHENG_APP_START`、`Procfile web:`、`main.py/app.py` 的解析优先级,但托管发布没有让开发者设置 `BISHENG_APP_START` 的通路;实际应提交 Procfile 或约定入口。Node 还支持 `package.json` 的 start/main 和默认 JS 文件。静态 runtime 直接运行平台 nginx。入口约定本身也是托管契约,不能只描述“传一个 command”。[C12] [C13] [C31] + +### 4.5 Docker 和 K8s 的前置条件比较 + +| 条件 | 当前 Docker 实现 | K8s 镜像构建路线:规格/待实现 | 应用包路线:评审建议 | +| --- | --- | --- | --- | +| 执行引擎 | 可达的 Docker Engine;socket proxy 放行 build/container/image/network 所需 API | 可用 K8s API、合适容器运行时;不能假设节点有 dockerd | 同样需要 K8s API,但源码发布可免 OCI 镜像构建 | +| Docker CLI | manager 不需要;它使用 Python Docker SDK 调 Engine HTTP API | manager 和节点不必为应用发布安装 docker build 命令 | 不要求;准备依赖/静态资源可由普通受限 Job 完成 | +| 容器镜像构建 | Engine 构建,依赖宿主 daemon | K8s 本身不提供源码转镜像;需额外交付构建器、资源/超时/并发治理 | 一般应用准备代码和依赖包;OS/运行时变化仍要制作运行镜像 | +| 镜像分发 | 构建和启动使用同一 daemon 的镜像库;无需单机应用仓库 | 所有工作节点可拉应用镜像;客户仓库或附带最小 Registry | 少量固定运行/初始化镜像可预装节点或从内网仓库获取;每次应用包发布不必入镜像仓库 | +| 依赖与编译 | Python/npm 源、原生库/编译工具必须满足模板构建 | 构建 Pod 可达的包源、基础镜像、凭据与网络策略 | 依赖准备仍需源或离线包,产物应匹配运行时/ABI/架构 | +| 权限 | manager 控制 Engine API、数据目录;proxy 持 socket | 专用 namespace、ServiceAccount、RBAC;不把集群凭据给应用 | 同左;准备 Job 也不自动挂业务无关集群凭据 | +| 网络 | 默认网、`bisheng-apps` 应用网、`bisheng-build` 构建网、出口代理及实际生效的底层限制 | CNI/NetworkPolicy 可用并验证;构建/应用目标分开控制 | 初始化下载访问 backend 产物接口,任务凭据不扩散给业务代码;manager/准备任务不直连 MinIO | +| SQLite | 宿主按 app bind mount;manager 需文件读写及所有权操作 | 专用 PVC、合适 StorageClass、单实例及迁移隔离 | 数据 PVC 与临时代码/依赖卷分开;Pod 重建重新取固定版本包 | +| 运行层安装 | Compose `app-runtime` profile 和后端开关配套 | Helm/manifests、构建器、仓库接入、网络/存储前置探测 | Helm/manifests 增加包初始化及依赖产物管理,仍需现场验证 | + +Compose 服务 `build:` 能在开发机构建,不代表离线现场可直接执行。交付时应预制管理层服务镜像、基础镜像和工具镜像,避免现场构建管理服务再遇到 pip 下载问题。[C29] [C34] + +存储拆分适用于 Docker 和后续 K8s 两种执行后端:manager 及其受控准备/初始化任务访问 backend 文件接口,由 backend 访问 MinIO;应用访问 backend 附件接口。网络策略、代理允许目标和部署自检应据此更新。K8s 中 manager 仍需能在受控执行环境访问对应 SQLite 数据卷,具体挂载执行方式待 F059 设计,不能假设远端 PVC 可直接从 manager 进程本机打开。 + +### 4.6 权限不能只按进程用户或 socket 挂载判断 + +当前 socket proxy 做端点级白名单,manager 不直挂 socket;不过允许的容器 create/start/build 等 API 仍具有很强的编排能力。端点白名单不等于按 app_id 限权,也不自动验证 bind mount、特权参数和租户边界。manager 必须固定安全容器参数,且编排网络与凭据不能开放给普通应用。Docker 官方也强调 daemon API 对宿主机的权限影响。[C16] [C29] [R10] + +因此,“改成 Java”“不安装 Docker CLI”“通过 HTTP API”各自都不能证明权限很小;进程独立、API 权限控制和商业代码保护需要分别论证。K8s 形态可通过 namespace/RBAC 限制资源范围,但还需要准入策略约束可创建 Pod 的安全字段,不能只给 create pods 就认定已完成隔离。 + +### 4.7 离线交付清单与验收层次 + +| 层次 | 离线交付必须准备的内容 | 现场验证 | +| --- | --- | --- | +| 管理服务能启动 | manager、代理及所需平台服务的目标架构镜像;配置、密钥注入、网络和目录 | 管理服务存活、内部鉴权、Engine/K8s 可达 | +| 应用运行镜像可用 | 模板所用基础镜像或受控运行镜像,架构与版本固定 | 真正执行构建/拉取的节点或引擎能访问;不是仅在管理员电脑上存在 | +| 应用能准备依赖 | 内网 PyPI/npm、完整离线 wheel/npm 产物、SDK 平台索引、原生工具链 | 新依赖版本、锁文件中的外链、install/build 脚本均不访问公网 | +| 代码/产物能分发 | 平台私有产物存储及 backend 文件接口;镜像路线需要节点可拉的 Registry | 当前源码用预签名 URL;目标应验证 manager/准备任务只从 backend 下载且无对象存储重定向;另验构建推送与节点拉取 | +| 快照能保存与恢复 | manager 本地备份目录、backend 快照接口、平台对象存储和任务认证 | 有界流式传输、摘要、持久确认、超时对账;backend 故障时拒绝破坏性迁移;恢复不要求 backend 挂应用磁盘 | +| 应用能持久运行 | 正式数据卷、权限、容量、日志和退出/重建策略 | SQLite 写入、Pod/容器重建后数据、节点故障条件下恢复能力 | +| 业务能力可调用 | 平台可达地址、模型部署、检索权限、附件句柄、出站目标 | 页面可访问不等于模型、知识检索和附件可用,应分别验证 | + +客户没有仓库时,镜像路线的现有 K8s 规格要求随工场附带最小仓库。仓库自身需要先由离线安装流程启动,再灌入其他镜像,不能形成“仓库镜像也只能从尚未启动的仓库拉取”的闭环。仓库需要持久卷、认证与节点可达地址。若所有节点预装固定运行镜像,应用包路线可不因每次发版额外建设应用镜像仓库,但新增节点和镜像升级仍要纳入交付。[C34] [R12] + +manager 的 `/v1/runtime/status` 会检查编排后端、应用网络、数据目录、模板、基础镜像、附件/出站相关前置。它不能证明任意应用依赖闭包已完整离线化,也不能代替现场故障恢复测试。[C40] + +目标离线包应包含 backend 新增文件/附件接口和兼容 SDK,manager 包不再包含 MinIO 访问配置。平台侧仍要准备 MinIO/私有桶和存储权限;manager 自检转为平台 API、认证、传输与本地备份前置,不能把“不直连 MinIO”理解成客户环境不需要对象存储。 + + + +## 5. 发布路线与同类产品评审 + +### 5.1 市面上并非只有“每次发布生成应用镜像” + +| 产品 | 官方描述的交付方式 | 可借鉴内容与适用边界 | +| --- | --- | --- | +| Posit Connect | K8s 使用预制执行环境镜像,挂载代码、已安装依赖等;Python 环境按依赖列表与 Python 版本复用 | 与 Streamlit/Dash/FastAPI 托管接近;其共享存储方案不能直接当作 SQLite 数据卷设计 [R01] [R02] | +| Modal | 环境镜像安装系统/语言依赖;`add_local_dir(copy=False)` 可在容器启动时加载代码,代码修改不必重建环境镜像 | 借鉴环境和代码分离,不据此推断 Modal 可按毕昇方式离线私有化 [R03] | +| Cloud Foundry | staging 生成 droplet 归档,上传 Blobstore;运行容器取包;构建缓存单独保存 | 成熟的“运行包”路线,但整套平台较重,适合借鉴产物模型 [R04] | +| Fission | environment 镜像+source/deployment archive;无包 Registry 时仍可走存储服务归档路径 | 借鉴 K8s 上代码包与环境分离;函数 specialize 契约不直接等于任意常驻 Web 服务 [R05] | +| Coolify | 源码构建镜像;用户提供镜像时跳过构建,再管理容器和入口 | 当前毕昇路线有成熟参照;通用应用可重复部署和环境封装较直接 [R06] | + +以上比较的是可验证的交付机制。是否开源、能否离线、协议限制和商业许可需要按具体产品版本另评估,不能因为技术路线相似就直接引入整套产品。 + +### 5.2 三条路线的收益和代价 + +| 维度 | A 当前:源码→应用镜像 | B 建议:运行镜像+应用包+依赖环境 | C 目标:用户提供完整镜像 | +| --- | --- | --- | --- | +| 发布产物 | 每个版本的完整应用镜像 | 固定运行镜像+不可变代码/前端产物包+依赖产物 | 已构建的应用镜像摘要 | +| 代码小改 | 发起构建;依赖层缓存命中时只更新后续层 | 更新代码包,复用兼容依赖环境 | 由用户 CI 重新产出镜像,毕昇跳过构建 | +| 依赖变化 | 镜像构建重新准备相关依赖层 | 准备新的依赖包/环境,仍需校验和编译 | 由镜像提供方负责 | +| 离线条件 | 基础镜像、构建器/Engine、包源;K8s 还需镜像分发 | 运行镜像、backend 产物接口、平台存储、依赖包或包源;一般源码发版不需推 OCI 镜像 | 镜像必须进入客户可达仓库或预装节点 | +| 对新增 OS 软件的适应 | 可通过平台模板扩展;当前未开放任意 Dockerfile | 超出固定运行环境时需新环境镜像或转 C | 容器内系统库和程序由用户完整封装 | +| 冷启动与缓存 | 镜像分层拉取和节点缓存成熟 | 需要下载/展开代码与依赖包,可增加产物缓存;不能假定始终更快 | 取决于镜像体积与节点缓存 | +| 复现和回滚 | 固定镜像摘要;数据库仍独立 | 必须同时固定运行镜像、依赖、代码摘要和启动契约 | 固定镜像摘要;平台仍应验证入口与运行限制 | +| 平台复杂度 | 镜像构建/推送/清理、仓库、构建缓存 | 依赖准备、ABI 兼容、包校验/分发/清理与缓存 | 少做源码构建,但要做镜像准入和凭据管理 | +| 隔离边界 | 容器/Pod+资源和网络限制 | 同样可以一应用一容器/Pod;无需共享业务进程 | 容器/Pod;用户镜像也必须遵守平台限制 | + +当前每次源码发布都会进入 build 阶段,没有应用级“比较依赖后跳过整次构建”的实现。Docker 模板先复制依赖清单、安装依赖,再复制源码;缓存可用时复用依赖层。`pip --no-cache-dir` 关闭的是 pip 下载缓存,不等于关闭 Docker 层缓存。一次性 K8s 构建 Job 若不显式保留/导入外部构建缓存,不能自动获得上一 Job 的层缓存。[C06] [C09] [C10] [C11] [R11] + +### 5.3 应用包路线的建议链路 + +发布产物路线 B 仍待评审;下图已纳入本次确认的存储边界,准备 Job 和应用初始化均通过 backend 传输产物,不直接访问 MinIO。 + +```mermaid +sequenceDiagram + participant Backend as Backend 发布与产物 API + participant Runtime as 商业 runtime + participant Prepare as 依赖准备 Job + participant Store as MinIO + participant Pod as 应用 Pod + Backend->>Store: 1 保存不可变代码或前端产物包 + Backend->>Runtime: 2 提交环境、产物引用及任务授权 + Runtime->>Runtime: 3 查找兼容依赖产物;命中则复用 + Runtime->>Prepare: 4 未命中则通过 backend 取源码并准备依赖 + Prepare->>Backend: 5 上传依赖产物、摘要和兼容元数据 + Backend->>Store: 6 保存并登记固定依赖产物 + Runtime->>Pod: 7 创建固定运行镜像的 Pod 及独立数据 PVC + Pod->>Backend: 8 初始化阶段请求代码和依赖内容 + Backend->>Store: 9 读取已确认产物 + Backend-->>Pod: 10 流式返回文件;初始化校验摘要 + Pod->>Pod: 11 只读挂载代码/依赖,读写挂载 /data + Pod-->>Runtime: 12 就绪通过后切换路由 + Runtime-->>Backend: 13 返回发布结果与产物组合 +``` + +**建议的版本单元**是 `运行镜像 digest+代码包 SHA-256+依赖产物 SHA-256+启动/健康契约`。不要在多个在线实例共用的目录中覆盖代码,也不要在每次正常启动时联网执行任意 `pip install/npm install`。依赖准备应在上线前完成,失败不切流;运行初始化只取经过确认的产物。K8s 的 init container+共享卷可以承接下载和准备步骤,但具体隔离与缓存属于新增实现。[R09] + +依赖缓存键至少涵盖目标 OS/CPU 架构、解释器/ABI、运行镜像摘要、锁定的依赖及影响安装的配置。不能把开发机虚拟环境原样搬到任意国产化机器,也不能只依据 `requirements.txt` 文件名判断兼容。Python 原生扩展、Node 原生模块和系统动态库都需要在匹配环境中准备。 + +TypeScript/前端构建仍然需要执行,输出可保存为应用包;纯静态内容可用固定 nginx 运行镜像承载。常用库可预装,但不建议把所有可能用到的包塞入一个巨大公共环境;应明确预装包版本和可覆盖范围,并保留按应用依赖隔离的能力。 + +### 5.4 对现有模块的改动影响 + +| 模块 | 若选择 B,需要评审的变化 | +| --- | --- | +| CLI/清单/版本模型 | 增加可区分的源码包与完整镜像交付描述;源码模式记录环境和代码/依赖产物引用;`image_ref` 单字段不足以表达所有产物 | +| 发布管线 | 把通用“准备运行产物”与“构建 OCI 镜像”区分;保留扫描、依赖准备日志、就绪验证、审批和业务授权 | +| backend 产物/附件服务 | 按已确认边界提供产物下载、快照上传和附件 API;路线 B 再扩展依赖产物登记/分发;MinIO 配置、对象布局及远端清理由平台管理 | +| runtime | 增加依赖产物匹配、准备任务、校验、通过 backend 初始化下载、清理与路由恢复;保留本地 SQLite 备份/迁移并经 backend 上传快照;Docker/K8s 适配分别实现挂载 | +| 数据与预览 | 代码/依赖临时卷独立于 SQLite 正式卷;backend 提供隔离附件空间或继续使用临时本地模式,预览不复用生产库/附件权限 | +| app-proxy / SDK | 延续入口前缀和身份头;附件 endpoint 迁到 backend,令牌权威和错误信封需提供兼容适配或随 SDK 升级 | +| F059 | 现有规格将镜像构建/推送放在源码发布主路径;改路线应先调整需求与设计,不能只替换一个底层客户端就宣称完成 | + +推荐先验证 Python Web 应用与静态应用的纵向流程,再评价 Node/原生依赖。是否采用 B、是否同时交付 A 作为备选,以及自定义运行镜像的登记权限,在本次评审后定案。 + +附件/产物/快照经 backend 的边界已确认,不以是否采用路线 B 为前提;即使保留路线 A,也应按 §3.6 完成同样的存储解耦。 + + + +## 6. 商业化边界与保护方案 + +### 6.1 商业化具体落在哪一层 + +已确认的计费口径是“**同一授权部署范围内同时在线应用数,免费 10 个,停用释放名额**”。授权部署范围如何绑定实例、集群或客户,需补充产品定义;不能默认“每个 manager 节点都免费 10 个”。资源 CPU/内存准入与商业应用数量额度应分别计算、分别报错。 + +商业化重点是托管编排业务:运行环境匹配、依赖/镜像准备、上线部署、持久化衔接、探活、切换、恢复、路由与生命周期治理。只在 Gateway 的发布 API 上加一个计数器,而完整可执行 manager 仍开源并可直接调用,不能保护这部分业务价值。 + +| 层 | 建议归属与边界 | 评审说明 | +| --- | --- | --- | +| 开源平台 backend/worker | 应用元数据、发布材料接收、审批、用户权限、平台能力、SDK 分发;附件 API、源码/产物分发和快照存储 | 存储配置、对象布局、应用/租户隔离、保留清理归平台;通过受控接口请求商业托管能力 | +| 商业 Gateway | 托管部署对外/内部 API 门面、许可证管理、鉴权和路由 | 是调用入口;不能单独作为所有执行路径的额度强制点 | +| 独立闭源 runtime | 托管状态与实际上线控制、配额强制、环境/产物编排、执行后端适配、恢复对账;本地 SQLite 备份和迁移 | 作为重点推荐;通过平台 API 下载源码、上传快照,不含附件服务或 MinIO 配置;可复用 Gateway 工程与授权基础,进程独立 | +| 通用代理 | app-proxy、egress-proxy、Docker socket proxy 可继续独立使用 | 不必为了额度把通用转发都重写为 Java;路由/授权必须接入商业执行事实,避免另留完整开放编排路径 | +| 应用 SDK | 保持应用侧库和接口契约 | SDK 开源本身不等于开放商业编排逻辑;改 manager 语言不要求同步改 SDK 语言 | + +**已确认的商业边界:** 附件和远端产物/快照存储归 backend,商业 runtime 保留实际编排、本地 SQLite 一致性备份、迁移及恢复能力。后端数据管理页/MCP 仍经业务授权后调用 manager 的本地数据库操作;不为迁移附件接口而把 SQLite 文件读写搬进 backend。替换现有 Python manager 时仍需承接这些本地数据接口和生命周期行为,不能仅迁移 deploy API 就删除原服务。 + +商业保护依然落在实际上线、恢复和对账的执行路径。backend 的附件与快照 API 不需要承担免费在线额度校验,也不应提供第二套可绕过商业 runtime 的完整编排入口。存储协议解耦减少商业模块对平台对象存储实现的依赖,但不消除其对平台身份、任务授权和产物接口的依赖。 + +“免费 10 个应用”与“编排源码公开”是不同的交付决定。可以免费分发同一套闭源 runtime 二进制,在无扩展授权时提供 10 个在线名额。若社区部署原本不包含 Gateway,还要确定免费托管包是否同时附带 Gateway 门面;不能通过另外保留一套无限制开源 manager 来实现免费版,否则商业执行边界重新出现绕行路径。 + +### 6.2 四种方案的比较 + +| 方案 | 改造范围 | 代码与额度保护 | 国产化/交付影响 | 建议定位 | +| --- | --- | --- | --- | --- | +| 仅 Gateway 入口限额 | 增加许可检查和计数;原 manager 不变 | 修改/直调开源 manager 可绕过;HMAC 密钥也不等同于商业许可证 | 改造小 | 可做短期流程控制,不作为核心商业保护 | +| 闭源 Python 镜像 | 沿用 manager,内置许可/额度;可选混淆/编译 | 不公开源码增加门槛;镜像仍可提取,普通 pyc/混淆难度有限;编译不提供绝对保护 | 原生编译产物需按架构、ABI 和运行环境验证;多架构打包成本上升 | 可作为快速交付或迁移阶段方案 | +| Java 控制层+精简执行代理 | 闭源层拥有状态机、许可、配额、准备/部署流程;代理只执行受限操作 | 必须移除代理的完整自主 deploy/reconcile 路径;否则关键流程仍能绕开 | 可分阶段迁移;多进程协议和故障补偿更复杂 | 适合降低一次重写风险 | +| Java 重实现完整 runtime | 重写托管编排、本地 SQLite 备份/迁移及数据操作、平台传输客户端和执行后端适配;附件/MinIO 服务留在 backend | 商业控制点清楚;JAR 可反编译,需配合签名授权及工程加固 | 依赖目标架构可用 JVM、基础镜像和本地文件语义;并非“Java 天然兼容所有信创机器” | 长期重点候选,实施成本最高 | + +分进程主要解决权限、资源占用、故障和升级边界;代码闭源与许可证强制是另一个维度。把编排放在 Gateway JVM 内并非技术上不可能,但会把构建任务、数据目录、SQLite 和编排权限带进网关进程。本会话已倾向独立进程,建议保持这一部署边界,语言选择仍列为评审事项。 + +### 6.3 离线授权与最终执行控制 + +**建议机制:** 由厂商签发包含能力、额度、有效期和部署绑定信息的非对称签名授权;厂商私钥不随客户交付。商业 runtime 通过内置受信公钥本地验证,免费基线由商业实现提供,不依赖每次发布联网回连厂商。字段格式、绑定规则、时钟回拨和证书轮换在正式设计中定义,本文不把示例名称冒充现有接口。 + +Gateway 可先做同样校验以给用户及时反馈;最终执行上线、恢复或收养实例的闭源 runtime 必须基于受验证的授权和共享额度账本决策。若使用短期执行许可,它应绑定 app、操作、部署、幂等键和有效期并防重放;只给现有 HMAC 加一个布尔 `licensed=true` 不构成授权保护。 + +```mermaid +sequenceDiagram + participant Backend as 平台发布服务 + participant Gateway as 商业 Gateway + participant Runtime as 闭源 runtime + participant Ledger as 共享额度账本 + participant Engine as Docker / K8s + Backend->>Gateway: 1 请求已批准版本上线 + Gateway->>Runtime: 2 认证后的执行请求与幂等标识 + Runtime->>Runtime: 3 本地验签并检查授权能力与部署绑定 + Runtime->>Ledger: 4 原子取得已有名额或预占新名额 + Ledger-->>Runtime: 5 返回占用结果或额度不足 + Runtime->>Engine: 6 准备、启动、探活和切换路由 + Runtime->>Ledger: 7 成功转为在线;失败补偿预占 + Runtime-->>Gateway: 8 返回可重试、可对账的结果 + Gateway-->>Backend: 9 更新平台发布状态 +``` + +图示是目标方案,不是现有 API 时序。是否把额度账本放在 Gateway 数据库还是商业 runtime 的持久状态中,需要结合多副本与灾备设计确定;必须只有一个权威口径,不能各进程独立累计。 + +### 6.4 “免费 10 个在线应用”的一致性要求 + +| 情况 | 建议规则 | 需保护的边界 | +| --- | --- | --- | +| 首次上线 | 原子校验 `在线占用+未完成预占 < 有效上限`,按 app_id 唯一预占 | 防并发请求各自看到第 10 个空位,实际启动到 11 个 | +| 同一请求重试 | 相同业务幂等标识复用同一操作与名额 | 网络超时不代表执行失败,不能重复扣名额或重复创建 | +| 已在线应用升级 | 复用该 app 的已有名额;候选容器不额外算一个应用 | 计费对象是应用,不是发布版本、容器数量或退休窗口 | +| 构建/准备/审批 | 建议不占“在线应用”正式名额;单独限制临时资源和并发 | 防止借免费预览无限运行;预览口径仍需产品确认 | +| 启动失败 | 确认路由和候选执行体已回收,再释放首次上线预占 | 不能只因后端超时就释放一个可能已经在线的 app | +| 停用 | 入口失效且执行状态收敛后释放;数据卷保留 | 后端状态、路由和容器/Pod 三者须对账 | +| 删除 | 释放名额与删除业务数据分别记录,均可幂等重试 | 数据删除失败不能靠重复执行造成错误计费 | +| 进程重启 | 重建账本与执行事实的对应关系;异常占用需有恢复规则 | 不能因本地缓存/JSON 清空而重新获得 10 个免费名额 | +| 自动恢复/收养实例 | 恢复已占名额的同一应用;未知实例需验证归属与合法占用 | 不能只在手动 API 限额、自动对账却无限创建 | +| 多 manager/多副本 | 共享部署范围、持久账本和并发控制 | 进程内锁、每宿主容器数都不能代表客户总额度 | +| SQLite 应用 | 名额与数据 PVC 生命周期独立 | 停用释放额度不等于删除数据库,恢复不能新建空库替代旧库 | + +当前 manager 的 `AdmissionService` 只检查 CPU/内存,`DesiredStateStore` 是本地 JSON+进程内锁;它们不是商业额度的全局事务实现。必须新增商业账本或等效的受控单写权威,并明确故障补偿和对账。[C18] [C39] + +### 6.5 各种保护手段能解决什么 + +| 手段 | 能解决的问题 | 不能承诺的事情 | +| --- | --- | --- | +| 当前内部 HMAC | 共享密钥下的调用方认证、方法/路径/请求体完整性 | 不是厂商许可证;当前签名串没有时间戳和 nonce,不自带操作级防重放 [C35] | +| 网络隔离、最小权限 | 减少应用直接访问编排 API、数据和宿主的机会 | 不能阻止有宿主管理员权限的人重配网络、替换服务 | +| 许可证非对称签名 | 防止未持厂商私钥者任意修改授权额度,支持离线验签 | 不能防止攻击者替换为一个完全不验签的实现 | +| 闭源交付、Java 编译、混淆 | 增加理解、修改和移除校验的成本,保护持续维护的商业实现 | 不等于不可提取、不可反编译或绝对防破解 | +| 授权校验与执行状态绑定 | 阻止正常调用路径绕过额度,覆盖重试、恢复和多副本 | 前提是没有另一套可自由执行的完整开源控制面 | +| 审计与支持边界 | 记录授权、上线/停用、异常实例、覆盖和补偿事实 | 审计本身不代替执行校验,也不能保证管理员不能篡改本地环境 | + +如果已有完整 manager 源码对外发布,后续闭源不能消除既有副本。客户控制宿主机时也可以自行搭建其他编排实现。实际目标应是保护官方商业实现和后续能力、提高绕过成本,并让标准交付路径中的额度可信,而不是承诺“拥有 root 的客户绝对无法绕过”。 + +### 6.6 Gateway 已有机制与需要新增的能力 + +所检查 Gateway 提交中存在 `dsh_entitlement` 的 RS256 签名校验;DSH 的 `DshLicenseState` 有内置 10 席位,并在签名无效/过期时计算其有效授权。它控制 DSH 席位,**不是托管应用上线数量**;不能直接复用该 audience、能力字段或过期回收策略来控制应用。[G01] [G02] + +可参考受信公钥、签名授权解析、有效授权状态和审计表达方式。托管模块仍需独立的能力标识、授权绑定、在线应用账本和执行校验;在本次检查的 Gateway 源码中未发现完整 app-hosting runtime 或相应在线应用额度实现。现有许可证状态接口也不能单独证明托管授权存在。[G03] + + + +## 7. 评审事项、验证场景与实施影响 + +### 7.1 应在定案前回答的事项 + +| 编号 | 待决事项 | 本文建议 | 决策影响 | +| --- | --- | --- | --- | +| D01 | 源码发布默认采用应用镜像还是应用包 | 优先验证应用包,完整镜像作为另一交付方式 | 决定构建器、应用仓库与依赖产物管理投入 | +| D02 | 商业 runtime 选择 Java 全量重写、渐进迁移还是闭源 Python | 独立进程;优先按关键状态/执行边界评估 Java 路线 | 决定迁移范围和多架构维护成本,不仅是编译器选择 | +| D03 | 谁可以登记自定义基础/运行镜像 | 建议管理员登记受控环境,完整应用镜像另行准入 | 关系到运行契约、系统库、UID、只读文件系统和供应链 | +| D04 | 授权范围如何绑定 | 建议一个客户授权部署范围共享额度,显式支持多执行节点 | 防止“每个节点各有免费 10 个” | +| D05 | 许可证过期或额度下降时,如何处理已在线超额应用 | 倾向阻止新增并显式提示,避免未经说明自动停生产应用;最终须产品确认 | 恢复、升级、停用后重启与存量 grandfather 规则需统一 | +| D06 | 预览是否计费、可运行多久 | 建议不计正式在线数,另设数量/TTL/资源限制 | 防止预览通道被当成免费正式运行环境 | +| D07 | SQLite 升级的停写与恢复策略 | 单活动实例、迁移前快照、兼容变更优先;独立审批数据恢复 | 影响停机窗口、代码回滚可用性和数据损失风险 | +| D08 | 离线依赖管理允许到什么范围 | 明确支持包目录、架构与版本,提供缺包诊断与补充包流程 | “任意第三方依赖完全离线可用”不能靠预装常用库保证 | +| D09 | 已确认存储边界下的接口版本、任务授权与切换策略 | backend 接管附件/远端存储,manager 保留本地 SQLite;定稿流式传输、持久确认和 SDK 兼容契约 | 职责归属已确认;待评审的是协议细节、旧令牌/运行实例切换和迁移验收 | +| D10 | 免费托管组件是否要求同时安装 Gateway | 同一闭源 runtime 提供免费基线;明确门面和许可证组件的最小交付组合 | 影响社区安装体验、额外进程数量和商业控制边界 | + +这些是技术评审仍需裁决的事项,不是文档作者自行确定的产品承诺。会话已确认的免费额度、双交付意向,以及“附件归 backend、manager 本地 SQLite+平台文件 API、不直连 MinIO”的职责边界不在此重新询问;D09 仅保留协议和切换细节。 + +### 7.2 验证场景与通过标准 + +下表是实施后应执行的验收集合,**本次只形成检查项,没有执行结果**。 + +| 类别 | 场景 | 通过标准 | +| --- | --- | --- | +| 包与租户隔离 | 超限、路径逃逸、特殊 tar 条目、跨应用/租户发布 | 在正确阶段拒绝;没有跨目录/资产写入;错误可定位 | +| 存储网络边界 | 禁止 manager、准备/初始化执行体直连 MinIO,正常发布和附件读写 | 源码/依赖经 backend 返回文件内容;没有 MinIO URL/302 跳转,无存储 SDK/长期密钥/bucket 配置;附件不经 manager | +| 附件接口迁移 | 新旧 SDK、错误信封、令牌轮换/吊销、跨应用/租户请求、预览上传 | 应用使用方式兼容;backend 作为鉴权权威;预览不读写正式附件,manager 退出附件数据路径 | +| 快照上传确认 | 上传中断、摘要错误、对象已保存但响应丢失、元数据写入失败、重复请求 | 同一任务幂等对账;只有对象和元数据均已确认时才迁移;失败/未知状态不得执行破坏性 DDL | +| SQLite 本地执行 | 备份期间写入隔离、迁移独占、指定快照恢复、backend 不挂应用盘 | 使用一致性备份;受控停写/恢复;业务权限在平台、本地操作在 manager;恢复点和数据损失窗口明确 | +| 正常首次发布 | 上传→扫描→准备→探活→审批→上线→浏览器访问 | 版本、审批、运行引用和路由一致;仅授权用户可访问 | +| 启动入口 | Python Procfile、默认入口、Node start/main、静态包 | 监听正确地址/端口;基路径、资源 URL 和 WebSocket 可用 | +| 失败补偿 | 审批创建成功但版本写入失败;启动成功但响应丢失 | 可取消或对账;没有无法追踪的审批、容器或重复名额 | +| 更新与迁移 | 代码更新、破坏性 DDL、迁移或凭据重签后启动失败 | 旧版本兼容性、平台能力凭据及数据恢复符合明确策略,不声称旧容器保留即完全无影响 | +| SQLite 持久化 | 停用恢复、容器/Pod 重建、节点故障、磁盘满 | 正式数据保留;本地卷故障边界清楚;预览不触碰生产库 | +| 完全离线 | 阻断公网,验证所有镜像/依赖/SDK/产物与运行接口 | 不靠公网 fallback;缺失时报告具体阶段和资源 | +| K8s 权限和仓库 | 无 Docker CLI、无节点 socket、无客户 Registry | 所选方案下可运行;镜像路线附带仓库能完成自举;每工作节点可拉 | +| 应用包路线 | 仅代码变化、依赖变化、运行镜像变化、架构变化 | 复用准确;错误 ABI 不进入正式运行;缓存丢失可从持久产物恢复 | +| 授权并发 | 免费第 10/11 个、并行上线、请求重试、滚动候选 | 上限按唯一 app_id 保证;升级不重复占用;临时资源另计 | +| 授权故障恢复 | manager/Gateway 重启、多副本、账本不可用、过期授权 | 不因缓存重置增额;已有/新增操作行为符合产品决议 | +| 通道绕过 | 直调执行 API、自动重建、预览、未知实例收养 | 同一受控授权边界覆盖全部正式上线路径 | +| SDK 与能力 | 身份、模型、检索、附件、令牌更新和过期 | 应用与访问者身份正确;无 MinIO/编排主凭据泄漏 | + +### 7.3 评审后的实施次序建议 + +先落定已确认存储边界的接口和迁移设计:backend 提供附件及产物/快照 API,兼容 SDK 句柄和错误信封;manager 保留本地 SQLite 操作,改走平台下载/上传协议,更新认证、自检、网络策略及部署配置,并验证在拒绝其直连 MinIO 时仍能完整发布。旧在线实例持有的附件地址和令牌需有切换方案;过渡适配的期限与退出条件应明确,最终状态不得保留 manager 附件服务或 MinIO 旁路。 + +再根据发布产物路线和商业语言方案的评审结果更新清单/版本契约与 F059 规格;用一个 Python+SQLite 应用完成离线准备、发布、更新、停用恢复和故障对账的纵向验证。其后验证静态/Node、用户完整镜像、多架构以及多节点。商业额度应随实际执行状态设计,不能等编排重写结束后再外挂一个 Gateway 计数器。 + +当前仓库中存在的构建、代理、存储、凭据和迁移能力都有既有调用方;迁移应以接口兼容和逐项替换为单位。本文只交付评审材料,没有修改发布接口、容器行为、授权或数据库。 + + + +## 附录 A. 代码证据索引 + +以下链接固定到核查提交。Gateway 为私有仓库,读取需要相应访问权限。证据采用“可执行调用及配置优先”的口径;早期注释中的“本轮暂不支持”等表述不单独作为交付状态依据。 + +| 编号 | 固定提交中的代码入口 | 核查内容 | +| --- | --- | --- | +| [C01] | `src/bisheng-cli/bisheng_cli/commands/deploy.py:54` | CLI 上传与轮询 | +| [C02] | `src/bisheng-cli/bisheng_cli/packaging.py:110` | 可重复打包与 SHA-256 | +| [C03] | `src/backend/bisheng/app_publish/domain/schemas/app_manifest.py:147` | 应用清单字段 | +| [C04] | `src/backend/bisheng/app_publish/domain/services/package_service.py:61` | 私有桶、解包与代码快照 | +| [C05] | `src/backend/bisheng/core/config/app_runtime.py:45` | 后端配置及包限制 | +| [C06] | `src/backend/bisheng/app_publish/domain/services/publish_pipeline_service.py:92` | 异步发布阶段与接收入口 | +| [C07] | `src/backend/bisheng/app_publish/domain/services/precheck_service.py:156` | 构建、预签名下载与临时探活 | +| [C08] | `src/runtime-manager/runtime_manager/api/schemas.py:37` | 编排 API 参数模型 | +| [C09] | `src/runtime-manager/runtime_manager/builder.py:76` | 模板、基础镜像和构建流程 | +| [C10] | `src/runtime-manager/runtime_manager/docker_backend.py:124` | Docker API 协议与真实实现 | +| [C11] | `src/runtime-manager/runtime_manager/templates/python3.11/Dockerfile.j2:42` | 依赖层与源码层 | +| [C12] | `src/runtime-manager/runtime_manager/templates/python3.11/entrypoint.sh.j2:27` | Python 启动入口 | +| [C13] | `src/runtime-manager/runtime_manager/templates/node20/entrypoint.sh.j2:44` | Node 启动入口 | +| [C14] | `src/backend/bisheng/app_publish/domain/services/publish_online_service.py:110` | 审批通过、停用分支与迁移顺序 | +| [C15] | `src/backend/bisheng/app_runtime/domain/services/app_state_service.py:283` | 应用状态与上线意图 | +| [C16] | `src/runtime-manager/runtime_manager/lifecycle.py:209` | 容器参数、部署、路由切换与删除 | +| [C17] | `src/runtime-manager/runtime_manager/config.py:57` | RTM 配置、路径和超时默认值 | +| [C18] | `src/runtime-manager/runtime_manager/desired_state.py:229` | 本地期望状态持久化 | +| [C19] | `src/runtime-manager/runtime_manager/reconciler.py:192` | 重启接管、修复及孤儿清理 | +| [C20] | `src/runtime-manager/runtime_manager/preview.py:18` | 审批预览的数据库隔离 | +| [C21] | `src/runtime-manager/runtime_manager/appdb.py:509` | SQLite 数据面、迁移和一致性快照 | +| [C22] | `src/runtime-manager/runtime_manager/storage.py:147` | MinIO 附件句柄和应用隔离 | +| [C23] | `src/runtime-manager/runtime_manager/main.py:79` | manager 入口与路由注册 | +| [C24] | `src/app-proxy/app_proxy/clients.py:177` | 入口鉴权和路由的双 RPC | +| [C25] | `src/backend/bisheng/app_runtime/domain/services/entry_authz_service.py:88` | 入口权限及 OBO | +| [C26] | `src/app-proxy/app_proxy/headers.py:29` | 身份头清理与注入 | +| [C27] | `src/runtime-manager/runtime_manager/egress.py:444` | 出站策略持久化和运行配置 | +| [C28] | `src/runtime-manager/runtime_manager/egress_proxy.py:171` | 独立出站转发进程 | +| [C29] | `docker/docker-compose.yml:277` | 四进程部署、网络、挂载和配置 | +| [C30] | `docker/nginx/conf.d/default.conf:80` | 浏览器入口反向代理 | +| [C31] | `src/bisheng-sdk/README.md:1` | SDK 的运行位置与三类功能 | +| [C32] | `src/bisheng-sdk/bisheng_sdk/retrieve.py:40` | 检索 API 与双重身份 | +| [C33] | `src/bisheng-sdk/bisheng_sdk/_storage_remote.py:3` | SDK 附件服务地址 | +| [C34] | `features/v3.0.0/059-k8s-runtime-backend/spec.md:108` | K8s 构建、仓库和 PVC 规格 | +| [C35] | `src/runtime-manager/runtime_manager/auth.py:42` | 内部 HMAC 的当前实现 | +| [C36] | `src/backend/bisheng/app_publish/domain/services/capability_bus_service.py:384` | 应用凭据和模型地址注入 | +| [C37] | `src/backend/bisheng/app_publish/domain/services/version_service.py:138` | 冻结版本与镜像引用 | +| [C38] | `src/backend/bisheng/app_runtime/domain/services/orchestrator_client.py:134` | 后端编排 RPC 客户端 | +| [C39] | `src/runtime-manager/runtime_manager/admission.py:137` | 资源容量准入 | +| [C40] | `src/runtime-manager/runtime_manager/api/readonly.py:245` | 运行环境自检 | +| [C41] | `src/backend/bisheng/app_publish/api/endpoints/deploy.py:128` | 发布 HTTP 接口及鉴权依赖 | +| [C42] | `src/backend/bisheng/dev_toolkit/api/endpoints/distribution.py:271` | SDK 的平台内分发 | +| [C43] | `src/backend/bisheng/app_publish/domain/services/preview_instance_service.py:440` | 预览参数与回收 | +| [C44] | `src/runtime-manager/runtime_manager/probe.py:126` | 临时镜像探活与回收 | +| [C45] | `src/runtime-manager/runtime_manager/templates/node20/Dockerfile.j2:46` | Node 依赖和前端编译 | +| [C46] | `src/runtime-manager/runtime_manager/api/appdb.py:27` | SQLite 数据管理接口 | +| [C47] | `src/bisheng-cli/bisheng_cli/ignore.py:42` | CLI 默认排除规则 | +| [C48] | `src/backend/bisheng/app_publish/domain/services/app_credential_service.py:134` | 应用凭据重签与撤销顺序 | +| [G01] | `src/main/java/com/dataelem/gateway/dsh/service/DshEntitlementService.java:39` | DSH 签名校验 | +| [G02] | `src/main/java/com/dataelem/gateway/dsh/service/DshLicenseState.java:13` | DSH 免费席位基线 | +| [G03] | `src/main/java/com/dataelem/gateway/config/LicenseStatusHolder.java:19` | Gateway 许可证状态 | + + + +## 附录 B. 官方技术资料 + +| 编号 | 资料 | 本文使用范围 | +| --- | --- | --- | +| R01 | [Posit Connect:K8s off-host 架构][R01] | 执行镜像与代码/依赖挂载分离 | +| R02 | [Posit Connect:Python 包与环境管理][R02] | 相同依赖列表与 Python 版本复用环境 | +| R03 | [Modal:Image 接口][R03] | 默认 copy=False 的代码加载与镜像层区分 | +| R04 | [Cloud Foundry:应用 staging][R04] | droplet、Blobstore 与 buildpack 缓存 | +| R05 | [Fission:Packages and builds][R05] | runtime 环境与部署归档包,存储服务路径 | +| R06 | [Coolify:Applications][R06] | 源码构建镜像与现有镜像交付 | +| R07 | [SQLite:Write-Ahead Logging][R07] | WAL 的同主机共享内存及网络文件系统限制 | +| R08 | [Kubernetes:ReadWriteOncePod][R08] | 单节点 RWO 与单 Pod RWOP 的差异 | +| R09 | [Kubernetes:Init Container 共享卷][R09] | 应用包初始化的实现原语 | +| R10 | [Docker:Engine security][R10] | 编排 API 与宿主权限边界 | +| R11 | [Docker:外部构建缓存][R11] | 一次性构建器跨任务复用需要显式缓存 | +| R12 | [CNCF Distribution:Registry][R12] | 内网仓库的镜像存储、TLS/认证及隔离网络分发 | + +外部产品资料用于技术路线对照,本文未进行这些产品的安装和性能测试。浏览器打开官方链接需要网络;本 HTML 的正文、表格和流程图均可离线阅读。 + +[C01]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/bisheng-cli/bisheng_cli/commands/deploy.py#L54 +[C02]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/bisheng-cli/bisheng_cli/packaging.py#L110 +[C03]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/backend/bisheng/app_publish/domain/schemas/app_manifest.py#L147 +[C04]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/backend/bisheng/app_publish/domain/services/package_service.py#L61 +[C05]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/backend/bisheng/core/config/app_runtime.py#L45 +[C06]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/backend/bisheng/app_publish/domain/services/publish_pipeline_service.py#L92 +[C07]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/backend/bisheng/app_publish/domain/services/precheck_service.py#L156 +[C08]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/api/schemas.py#L37 +[C09]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/builder.py#L76 +[C10]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/docker_backend.py#L124 +[C11]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/templates/python3.11/Dockerfile.j2#L42 +[C12]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/templates/python3.11/entrypoint.sh.j2#L27 +[C13]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/templates/node20/entrypoint.sh.j2#L44 +[C14]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/backend/bisheng/app_publish/domain/services/publish_online_service.py#L110 +[C15]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/backend/bisheng/app_runtime/domain/services/app_state_service.py#L283 +[C16]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/lifecycle.py#L209 +[C17]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/config.py#L57 +[C18]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/desired_state.py#L229 +[C19]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/reconciler.py#L192 +[C20]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/preview.py#L18 +[C21]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/appdb.py#L509 +[C22]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/storage.py#L147 +[C23]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/main.py#L79 +[C24]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/app-proxy/app_proxy/clients.py#L177 +[C25]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/backend/bisheng/app_runtime/domain/services/entry_authz_service.py#L88 +[C26]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/app-proxy/app_proxy/headers.py#L29 +[C27]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/egress.py#L444 +[C28]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/egress_proxy.py#L171 +[C29]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/docker/docker-compose.yml#L277 +[C30]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/docker/nginx/conf.d/default.conf#L80 +[C31]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/bisheng-sdk/README.md#L1 +[C32]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/bisheng-sdk/bisheng_sdk/retrieve.py#L40 +[C33]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/bisheng-sdk/bisheng_sdk/_storage_remote.py#L3 +[C34]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/features/v3.0.0/059-k8s-runtime-backend/spec.md#L108 +[C35]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/auth.py#L42 +[C36]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/backend/bisheng/app_publish/domain/services/capability_bus_service.py#L384 +[C37]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/backend/bisheng/app_publish/domain/services/version_service.py#L138 +[C38]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/backend/bisheng/app_runtime/domain/services/orchestrator_client.py#L134 +[C39]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/admission.py#L137 +[C40]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/api/readonly.py#L245 +[C41]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/backend/bisheng/app_publish/api/endpoints/deploy.py#L128 +[C42]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/backend/bisheng/dev_toolkit/api/endpoints/distribution.py#L271 +[C43]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/backend/bisheng/app_publish/domain/services/preview_instance_service.py#L440 +[C44]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/probe.py#L126 +[C45]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/templates/node20/Dockerfile.j2#L46 +[C46]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/api/appdb.py#L27 +[C47]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/bisheng-cli/bisheng_cli/ignore.py#L42 +[C48]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/backend/bisheng/app_publish/domain/services/app_credential_service.py#L134 +[G01]: https://github.com/dataelement/bisheng-gateway/blob/faed249a96ea43a4c65b90765dd4054bf6e55722/src/main/java/com/dataelem/gateway/dsh/service/DshEntitlementService.java#L39 +[G02]: https://github.com/dataelement/bisheng-gateway/blob/faed249a96ea43a4c65b90765dd4054bf6e55722/src/main/java/com/dataelem/gateway/dsh/service/DshLicenseState.java#L13 +[G03]: https://github.com/dataelement/bisheng-gateway/blob/faed249a96ea43a4c65b90765dd4054bf6e55722/src/main/java/com/dataelem/gateway/config/LicenseStatusHolder.java#L19 +[R01]: https://docs.posit.co/connect/admin/appendix/off-host/arch-overview/ +[R02]: https://docs.posit.co/connect/admin/python/package-management/index.html +[R03]: https://modal.com/docs/sdk/py/latest/Image#add_local_dir +[R04]: https://docs.cloudfoundry.org/concepts/how-applications-are-staged.html +[R05]: https://fission.io/docs/concepts/packages-and-builds/ +[R06]: https://coolify.io/docs/applications/ +[R07]: https://www.sqlite.org/wal.html +[R08]: https://kubernetes.io/docs/tasks/administer-cluster/change-pv-access-mode-readwriteoncepod/ +[R09]: https://kubernetes.io/docs/tasks/configure-pod-container/configure-pod-initialization/ +[R10]: https://docs.docker.com/engine/security/ +[R11]: https://docs.docker.com/build/cache/backends/ +[R12]: https://distribution.github.io/distribution/about/ diff --git a/docs/reviews/vibe-app-open-source-technical-design.html b/docs/reviews/vibe-app-open-source-technical-design.html new file mode 100644 index 0000000000..8a30a7bf8a --- /dev/null +++ b/docs/reviews/vibe-app-open-source-technical-design.html @@ -0,0 +1,1578 @@ + +Vibe 应用托管全开源技术方案
BISHENG · 技术评审 评审稿
736ef59 · 2026-10-20Markdown 源文

Vibe 应用托管全开源技术方案

+

日期:2026-10-19 | 更新:2026-10-20 | 状态:待技术评审 | 范围:应用交付、发布、运行、存储、权限、离线部署与实施阶段

+

Vibe 应用托管作为毕昇开源功能,在 bisheng 仓库内继续开发和交付。复用现有 Python runtime-manager、app-proxy、egress-proxy、CLI 和 SDK,保留独立进程的权限及故障边界。应用通过平台统一发布和访问,编排服务通过 backend 获取产物、上传快照,平台统一管理附件和对象存储。

+

本轮决定:全部开源,不设置商业授权和固定上线数量额度。 原“免费同时在线 10 个应用”、闭源 runtime、Java 重写以保护代码、迁入 bisheng-gateway 等方案退出实施范围。上线仍受 CPU、内存、磁盘、临时任务并发等资源约束;平台现有用户、租户和应用权限继续生效。既有商业网关可按部署环境继续转发平台请求,但不是 Vibe 功能的安装或授权前置。

+

推荐发布路线: 以“预制运行镜像+版本化应用包+可复用依赖产物”为源码应用的目标默认路线,同时支持用户提交完整应用镜像;现有源码构建应用镜像的路线作为迁移基线及兼容方式保留。应用包、完整镜像和 K8s 的具体契约均为待开发能力,不按已有代码认定为已交付。

+

服务镜像交付已确认: runtime-manager 仿照 backend,由项目 CI/CD 构建并推送 Docker Hub,正式 Compose 引用已发布镜像;客户安装时拉取或离线导入镜像。应用基础运行镜像另行维护,不能用 runtime-manager 服务镜像运行用户应用。实现细节见 §3.4—§3.8,发布交付见 §9.4。

+

2026-10-20 补充:纯离线依赖来源、补包闭环及失败行为见 §9.5 离线依赖准备与补包;现有基础镜像位置和目标运行环境配置、替换步骤见 §9.6 基础运行镜像配置。运行环境清单导入与离线依赖导入均为待开发能力。

+

本方案替代此前商业化评审和 Gateway 归仓实施方案中的产品与架构选择。2026-10-16 确认的存储边界继续有效:附件 API 迁到 backend;manager 保留本地 SQLite 备份、迁移和恢复,经 backend 下载源码、上传快照,不直接访问 MinIO。

+

阅读导航

+ +

+

1 评审基线与方案决策

+

1.1 代码基线

+

2026-10-19 只读核对远端 feat/3.0.0-vibe,提交仍为 736ef593f3fc6e1634008c842d2604304d6f70a6。源码事实均以该固定提交为准;当前工作目录检出的是另一版本分支,本文通过 git show 核查,不把工作目录中的其他改动作为 Vibe 实现。

+

本轮完成静态核查和技术方案,尚未执行应用部署、离线安装、K8s 适配或故障恢复实测。下文按“当前基线”“目标方案”“后续扩展”标注能力状态。

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
事项当前基线本方案目标
代码与语言Python manager、两个 Python 代理位于 BiSheng 仓库全部继续开源,沿用 Python;不增加 Gateway 代码归仓或 Maven 重组任务
源码发布tar.gz → 平台模板构建应用镜像 → 探活 → 审批 → 上线应用包+运行镜像作为目标默认;镜像构建兼容;用户完整镜像另有准入契约
运行后端Docker Engine API 经 socket proxy;K8s 仅有规格Docker 先完成可交付闭环;K8s 使用同一产物和生命周期协议另行适配
对象存储manager 预签名下载源码,直连 MinIO 处理附件/SQLite 快照manager 只经 backend 文件 API;SDK 附件直达 backend
控制状态manager 本地 JSON、容器标签;出站策略使用共享本地文件平台 SQL 持久化控制状态,Redis 做投影/缓存,本地只作可重建缓存
路由app-proxy 查询 manager,短缓存约 3 秒manager 上报真实就绪事实;backend 提供独立路由投影读接口,去掉请求对 manager 存活的依赖
上线数量已查代码中的 Admission 检查 CPU/内存,不是商业授权无商业上线名额;管理员按运行资源及任务并发设置容量策略
+

依据:C01 C06 C07 C18 C27 C29 C34 C39 A05。

+

1.2 推荐决策

+
    +
  1. 保留三个服务进程。 manager 负责有编排能力的控制面,app-proxy 和 egress-proxy 负责流量转发;socket proxy 为额外的 Docker 基础组件。
  2. +
  3. 平台统一存储和业务授权。 backend 管理附件、产物、快照、应用/租户权限及审计;manager 不读 backend 配置文件或平台业务数据库,不持 MinIO 主凭据。
  4. +
  5. 共享控制状态落平台。 遵守 constitution C8;跨进程读取的路由、出站策略、任务和期望状态不能依赖某台机器上的 JSON 文件。A03
  6. +
  7. 运行环境与应用版本分离。 版本固定运行镜像 digest、代码包摘要、依赖产物摘要、启动/健康契约;代码小改可以复用依赖,不要求每次生成 OCI 应用镜像。
  8. +
  9. SQLite 保持单活动写入。 升级前停写、必要时备份并等待平台持久确认;不承诺 SQLite 结构变更零停机。
  10. +
  11. 按可验证阶段交付。 先补齐 Docker 当前链路和存储/状态边界,再上线应用包及完整镜像方式;K8s 不与首轮 Docker 修整混为同一个完成标记。
  12. +
+

1.3 功能范围

+

开源交付保留应用清单与版本、部署预检、审批和临时预览、上线/停用/恢复、统一访问入口、SDK 能力、附件、SQLite 数据管理、运行日志及环境自检。本文主要调整运行实现和可靠性;既有业务权限继续由 backend 判定。C15 C25 C40 C43 C46

+

SQLite 数据页与 MCP 数据工具仍经 backend 完成 owner 收窄和审计,再调用 manager 的表清单、结构、分页行读取、单行修改及导出接口;不接受任意 SQL,也不让 backend 挂卷或直开数据库。目标 bundle/image 模式都要说明是否支持该数据契约,不将任意用户镜像中的数据库自动识别为平台可管理数据。C46

+

托管层继续允许按部署开关选装。未安装时隐藏相关入口,既有平台功能应正常启动;这属于部署形态,不是许可证判定。app_runtime.enabled 与开放能力层开关保持独立,CLI/SDK 所需开放接口由安装自检明确核对。C05 A02

+

+

2 开源仓库与运行架构

+

2.1 目录组织

+
bisheng/                             # 现有 Git 仓库
+├── src/backend/bisheng/
+│   ├── app_publish/                 # 接收、预检、审批、版本、上线协调
+│   └── app_runtime/                 # 状态、权限、运行事实、数据及文件接口
+├── src/runtime-manager/
+│   ├── runtime_manager/             # Python 编排、执行适配、SQLite 本地操作
+│   ├── Dockerfile                   # manager 服务镜像
+│   └── Dockerfile.egress            # 目标:代理专用精简构建入口
+├── src/app-proxy/                   # 应用 HTTP/WS 入口,独立服务镜像
+├── src/app-runtime-images/          # 目标:Python/Node/static 运行环境和准备镜像
+├── src/bisheng-cli/                 # 打包、部署、状态和日志
+├── src/bisheng-sdk/                 # 应用内身份、检索及附件句柄
+├── docker/                         # Compose、nginx、网络及安装材料
+│   └── runtime-profiles.yaml        # 目标:管理员导入 backend 的运行环境清单
+└── features/v3.0.0/                 # F054/F055/F059 等规格与设计
+
+

上述业务目录已存在;src/app-runtime-images/、docker/runtime-profiles.yaml、Dockerfile.egress 及平台状态/产物接口为目标增量。首轮不为开源交付重排整个仓库,也不强制新建共享 Python 工程;代理依赖可按需抽取最小配置与策略读取模块,避免拖入全部编排依赖。

+

2.2 进程职责

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
服务或组件负责内容资源访问边界
backend / Celery元数据、用户权限、审批、版本、附件、产物传输、快照保存;运行期望与事实投影平台 SQL、Redis、MinIO、统一权限模块;调用 manager,不调用 Docker/K8s SDK,不挂应用数据卷
runtime-manager准备产物、容量判断、容器/Pod 生命周期、探活、对账、自愈、预览、日志、SQLite 数据管理与备份/迁移/恢复Engine API 或 K8s API、受控数据卷、backend 内部接口;不直连 MinIO 或平台 SQL
app-proxy会话授权结果消费、可信头注入、HTTP/WS 转发、预览和拒绝页面backend 授权和路由读接口、应用网络;无 Engine 管理身份
egress-proxyHTTP/CONNECT、应用出站身份、目标域名和解析地址校验、连接限制backend 策略只读接口、允许的出口;不提供编排接口,不持 manager 管理密钥
docker-socket-proxy限制并转发 Docker Engine API持 Docker socket;只在专用控制网络可达,不开放宿主端口
应用容器执行业务、页面/API、SQLite、SDK/模型能力本 app 数据卷、平台应用 API、受控出站;不接触控制面密钥和其他应用卷
CLI / SDKCLI 调平台部署 API;SDK 运行在应用内部仅获得对应用户/应用/访问者权限,无宿主或集群管理能力
+

app-proxy、egress-proxy 不因开源而必须合并,也不因开源就失去进程隔离价值。manager 升级和构建任务不应直接带走应用入口/出口连接;原 F054 AC-22、AC-60 已定义相应的重启及独立启停目标。A01 A02

+

egress 可以继续与 manager 共用源码工程,但使用独立入口和镜像,且只读自己的策略缓存,不挂整个应用数据根目录。独立镜像用于缩小依赖和运行权限;本方案不以隐藏源码作为安全手段。

+

2.3 网络与权限

+

Docker 目标至少区分入口/平台网络、应用受限网络、构建网络和 Engine 控制网络。manager 与 socket proxy 使用控制网络,backend 和两个代理不因共享默认网络而自动获得 Engine API 可达性。应用之间的可达性也需要网络规则限制,不能仅凭“没有 publish 宿主端口”宣称绝对不可直连。C16 C29

+

对外复用平台 nginx 的 /apps/{slug}/,不为每个应用开放宿主机端口。所有应用可以监听容器内部相同端口,通过各自内部地址区分。管理 API 只供已认证平台服务使用;egress 监听端口只用于正向代理协议,不能混入 intent API。

+

+

3 应用交付与运行契约

+

3.1 当前源码包

+
leave-helper/
+├── bisheng-app.yaml
+├── requirements.txt                # 可声明 bisheng-sdk
+├── Procfile                        # 可选,指定 web 启动命令
+├── main.py
+├── templates/
+└── static/
+
+

CLI 当前生成 tar.gz,以 multipart package 上传 /api/v2/apps/deploy;支持 python3.11、node20、static。源码包默认上限 50 MiB、展开 200 MiB、20,000 条目,实际值由平台配置及 deploy-limits 接口决定;拒绝路径逃逸、链接和特殊设备条目。当前 manifest 没有完整镜像或通用 command 字段。C01 C02 C03 C04 C05

+

SDK 是应用依赖,不是另一台服务;应用框架、第三方包、前端资源由项目及构建产物提供。Procfile、Python/Node 约定入口和平台 entrypoint 共同确定启动方式,不要求用户以 manager 服务镜像作为应用镜像。C09 C12 C13 C31

+

3.2 三种发布路线

+
+ + + + + + + + + + + + + + + + + + + + + + +
路线发布和启动方式定位
运行镜像+应用包预制运行镜像;平台准备不可变代码/前端产物及兼容依赖包,启动时只物化已确认产物目标默认源码路线;免去每次代码发布生成 OCI 镜像,但仍管理依赖和编译
用户完整应用镜像提交镜像 digest、容器内启动/健康/端口契约;跳过平台源码构建目标支持方式;适合复杂系统库和用户已有 CI,仍须遵守资源、UID、网络及卷限制
当前源码构建应用镜像平台 Dockerfile 模板安装依赖、复制代码并生成应用镜像当前已实现;迁移基线和兼容方式,复杂环境仍可使用
+

运行镜像中可以预装常用库,但版本必须明确。新增 OS 软件或不兼容解释器时需新的运行镜像/环境档位,或改用完整镜像;不能通过预装“常用库”保证任意应用离线运行。

+

应用包路线下,依赖变更会触发依赖准备;纯代码变更可复用已验证依赖。缓存键至少覆盖 OS/CPU、解释器与 ABI、运行镜像 digest、锁定依赖和安装配置。Python 原生扩展、Node native 模块必须在匹配环境中准备,不能直接复用开发机虚拟环境。

+

TypeScript/Vite 等前端编译仍需执行并保存输出,静态站点可使用固定 nginx 运行镜像。准备任务不得等到每次应用正常启动时再访问公网安装依赖。当前实现只有 Docker 分层缓存,没有应用级“比较依赖后跳过整次构建”的完整功能。C09 C11 C45

+

3.3 目标 manifest 与版本模型

+

建议保持 v1 兼容,新增显式的 v2 执行描述。下例仅为协议设计草案,字段不能直接用于当前 v1 接口。

+
manifest_version: 2
+name: 请假知识助手
+slug: leave-helper
+runtime: python3.11
+port: 8080
+tier: light
+execution:
+  type: bundle
+  runtime_profile: python311-standard-v1
+  command: [python, -m, uvicorn, main:app, --host, 0.0.0.0, --port, "8080"]
+data_mode: sqlite
+health:
+  path: /healthz
+
+

runtime_profile 指向管理员登记的运行环境及镜像 digest;用户自定义运行镜像通过同一登记/验证流程接入。完整镜像模式使用 execution.type=image 和镜像 digest,默认采用镜像 entrypoint,也可按契约覆盖容器内 argv。所有命令只在受限应用容器内执行,不转成任意宿主 shell。

+

版本记录需要冻结:源码/前端产物摘要、依赖产物格式/摘要与完整锁定结果、运行镜像 digest 或完整镜像 digest、启动 argv、端口、健康契约、资源档位、能力声明、数据库 schema 及出站声明。image_ref 一个字段不足以表达应用包版本。完整镜像不上传 MinIO;平台保存其引用和运行契约。

+

源码包继续采用严格安全解包。依赖产物是平台在受控环境产出的专门归档,需要校验路径和链接目标;不能直接接受任意用户虚拟环境 tar 包,也不能无条件沿用源码包拒绝所有链接的规则,导致 Node 等依赖布局被破坏。

+

3.4 应用包路线的实现分层

+

每个应用版本由“运行镜像+代码产物+依赖产物+启动契约”组成。manager 调用 Engine API 创建准备容器和业务容器,不把应用代码安装进自己的 Python 环境,不为每次代码修改提交一个新镜像。

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
层次内容与发布者更新频率与存储
manager 服务镜像平台编排 Python 代码及其服务依赖;项目 CI 发布随平台版本发布到 Docker Hub,Compose 引用;不包含用户应用
应用运行镜像Python/Node/nginx、固定系统库、非特权用户和平台 launcher;项目 CI 发布或管理员登记环境升级时发布,冻结实际 CPU 架构对应的镜像 digest;执行节点提前加载/拉取
准备镜像与运行环境匹配的解释器、包工具、必要编译器/头文件平台发布并固定 digest;只用于隔离准备任务,不将工具链带入普通应用容器
代码产物安全解包后的源码、必要的前端编译输出、冻结清单每版本不可变,经 backend 存 MinIO、下载至执行节点
依赖产物Python wheelhouse 与安装锁文件,或匹配环境的 Node 依赖归档及元数据兼容键命中时复用,经 backend 持久化;本机安装环境是可重建缓存
+

应用运行镜像可以携带常用 wheel 文件,帮助离线依赖准备;Python 默认独立 venv 不继承基础解释器的全局 site-packages。不能一边隔离依赖,一边假定基础镜像中 pip install 的全局包会自动出现在每个应用环境中。建议把常用包做成版本明确的离线 wheel 集合,由依赖准备过程选用;经验证的固定环境档位也可以直接命中同一依赖组合。R05

+

目标执行顺序:

+
时序图 1 第 1 部分Worker 到 Manager:1 提交运行档位、锁文件和固定源码产物;Manager 到 API:2 查询兼容依赖产物或下载源码和锁文件;Manager 到 Prep:3 未命中时用批准包源或已导入离线包准备依赖;Prep 到 Manager:4 返回 wheelhouse 或 Node 依赖及校验结果;Manager 到 API:5 上传新产物,冻结摘要和环境兼容元数据;Manager 到 Prep:6 本节点环境未命中时,用依赖产物离线安装及校验;Manager 到 Manager:7 物化代码、只读环境,固定版本引用发布 workerruntime-managerBackend 产物 API隔离准备容器正式应用容器1 提交运行档位、锁文件和固定源码产物2 查询兼容依赖产物或下载源码和锁文件3 未命中时用批准包源或已导入离线包准备依赖4 返回 wheelhouse 或 Node 依赖及校验结果5 上传新产物,冻结摘要和环境兼容元数据6 本节点环境未命中时,用依赖产物离线安装及校验7 物化代码、只读环境,固定版本引用
图 1 · 第 1/2 部分 · 按步骤编号连续阅读;窄屏可横向滚动
时序图 1 第 2 部分Manager 到 App:8 指定运行镜像、挂载、环境变量和容器内 argv;App 到 Manager:9 launcher 执行业务进程,通过健康检查发布 workerruntime-managerBackend 产物 API隔离准备容器正式应用容器8 指定运行镜像、挂载、环境变量和容器内 argv9 launcher 执行业务进程,通过健康检查
图 1 · 第 2/2 部分 · 按步骤编号连续阅读;窄屏可横向滚动

这是单次准备与启动的内部细化图,预检、审批和正式上线的外层顺序仍按第 6 节执行。缓存命中可跳过对应准备步骤;跨节点第一次运行需要下载产物并建立节点缓存,不依赖另一个 manager 的本地目录。

+

3.5 Python 依赖的安装与复用

+

第一层:可持久复用的 wheel 产物。 推荐应用提交锁定依赖及完整传递依赖;只提交 requirements.txt 时,平台首次准备需生成并冻结解析结果,后续复用必须引用该固定结果。显式刷新依赖才重新解析,不能让同一个已批准版本随包源变化得到另一套库。

+

隔离准备容器在匹配目标 OS/CPU、Python ABI 和系统库的环境中下载或构建 wheels。纯 Python wheel 也记录目标兼容性;原生扩展要在匹配工具链中构建,并在目标运行镜像中验证导入和动态库。wheelhouse 包含整个运行依赖闭包、bisheng-sdk、安装清单和各文件摘要,缺少任意依赖不得退回公网补装。R06

+

以下是联网准备机或可访问内网包源的准备容器中的示意命令;工具版本、包源、锁文件和网络权限由平台准备计划指定,不在 manager 服务容器执行。纯离线现场必须先具备相应包文件和构建依赖,不能通过该命令凭空取得新包,来源与导入过程见 §9.5:

+
python -m pip wheel --wheel-dir /out/wheelhouse -r /input/requirements.resolved.lock
+
+

源码构建出的 wheel 与原始源码归档的摘要可能不同。平台应据最终 wheel 内容生成 requirements.runtime.lock,固定全部包版本及 wheel 哈希,并记录构建来源;不能把源码包的哈希直接当成新 wheel 的哈希。SDK 按固定版本从平台指定分发源取得并核对摘要,不依赖公共源中恰好出现同名包。

+

第二层:执行节点的已安装环境缓存。 manager 为 env_key 分配临时目录,挂载进一次性准备容器的 /opt/venv;在与正式应用相同的运行镜像中创建环境并离线安装。这里不需要编译器:所有需要的 wheel 已在第一层准备完毕。

+
python -m venv /opt/venv
+/opt/venv/bin/python -m pip install --no-index --find-links=/deps/wheelhouse --only-binary=:all: --require-hashes -r /deps/requirements.runtime.lock
+/opt/venv/bin/python -m pip check
+
+

--no-index 配合本地 wheelhouse 完成安装,运行锁文件禁止残留 VCS、外网 URL 和开发机绝对路径;需要本地项目依赖时,先做成 wheel 并计入内容摘要。准备容器同时验证关键 import,最终用真实应用预检验证启动;pip check 通过不等于整个应用就绪。R07

+

验证成功后以原子方式发布节点缓存完成标记。正式容器把该目录只读挂到相同的 /opt/venv,设置 PATH 并执行其 Python;不再运行 pip。Python 官方指出 venv 通常不可移动,本方案因此持久保存 wheel 及锁文件,节点丢缓存时就地重建 venv,不把开发机或其他节点的 venv 打包搬运。R05

+

env_key 至少包含租户缓存范围、平台解析结果/依赖哈希、目标架构、运行镜像实际 digest、Python ABI、准备镜像/工具版本和安装策略。同租户多个应用依赖完全一致时可只读共用;不同依赖或镜像得到不同目录。默认不跨租户共享私有依赖产物和命中信息。

+

并发准备同一个 key 时只能有一个有效发布结果,失败临时目录不标完成;运行中的缓存不允许原地 pip install 或升级。PYTHONDONTWRITEBYTECODE=1 避免把字节码写回共享环境,需要运行缓存的库使用 app 自己的 /tmp 或指定数据子目录。

+

3.6 Node 与静态应用的依赖处理

+

Node 使用固定 Node/npm 版本,基于 package.json、package-lock.json 和影响安装的配置执行 npm ci。该命令按锁文件安装且不改锁文件;锁文件不匹配时拒绝,而不是现场自动修正。R08

+

平台准备并验证对应架构的依赖归档。运行依赖只读挂到 /workspace/app/node_modules,代码挂到 /workspace/app,保留正常 Node/ESM 相对解析路径;不能仅依赖 NODE_PATH 将所有依赖放在任意外部目录。代码物化时由平台预留依赖挂载点,不能使用用户传入的宿主路径或不可信链接。

+

需要 TypeScript/Vite 构建时,在私有可写准备工作区安装构建依赖、执行构建并保存输出;不能写入正在被其他应用使用的依赖缓存。运行版本只保留实际所需依赖和输出。若安装脚本、workspace、本地依赖或原生模块构建读取应用源码,相关源码摘要也必须计入 key;此时“锁文件没变”并不足以跳过准备。

+

静态应用将已生成的 HTML/CSS/JS 产物挂到固定 nginx 运行镜像的站点目录,按约定端口提供服务。无需 Node 运行时的静态站点不启动 Node;纯静态资源没有依赖时也不生成空的依赖环境。特殊系统库或不能满足只读契约的应用使用登记过的自定义运行环境或完整镜像模式。

+

3.7 代码挂载和启动契约

+

Docker 首轮采用宿主内容目录+只读 bind mount。manager 通过 backend 流式下载代码包到临时目录,验证 app/version 归属、大小和摘要,再安全解包并原子发布;被运行容器使用的版本目录不做原地覆盖。文件大小写、可执行位、依赖链接及 UID/GID 一并校验。

+

以当前默认数据根为例,目标布局如下;cache/ 下全部可从平台产物重建,SQLite 目录按独立持久化策略管理:

+
宿主机 /opt/bisheng/app-data/             manager 容器内 /app-data/
+├── cache/releases/{app_id}/{version_id}/app/    # 代码、前端输出
+├── cache/envs/{tenant_scope}/{env_key}/venv/    # 本节点安装好的 Python 环境
+├── cache/node-deps/{tenant_scope}/{env_key}/node_modules/
+└── apps/{app_id}/data/                         # 目标 SQLite 持久目录
+
+

manager 看见的路径与交给宿主 Engine 的路径必须按配置根映射;本例 RTM_DATA_ROOT=/app-data,RTM_HOST_DATA_ROOT=/opt/bisheng/app-data。不能把仅存在 manager 镜像可写层中的目录当作应用 bind 源。Docker bind 的源路径由 daemon 所在主机解释,因此首轮要求 manager 对应同机 Engine;远程 Engine 须先提供该执行节点自己的产物物化机制。C17 R09

+

Python 应用实例的关键 Engine 参数示例如下。它是目标 DTO 示意,并非完整安全配置;实际创建仍须包含资源、应用网络、日志、只读、卷及可信标签约束:

+
{
+  "Image": "dataelement/bisheng-app-python@sha256:<runtime-arch-digest>",
+  "WorkingDir": "/workspace/app",
+  "Entrypoint": ["/usr/local/bin/bisheng-launch"],
+  "Cmd": ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"],
+  "User": "10001:10001",
+  "Env": [
+    "PATH=/opt/venv/bin:/usr/local/bin:/usr/bin:/bin",
+    "VIRTUAL_ENV=/opt/venv",
+    "PYTHONDONTWRITEBYTECODE=1",
+    "PORT=8080"
+  ],
+  "HostConfig": {
+    "ReadonlyRootfs": true,
+    "Binds": [
+      "/opt/bisheng/app-data/cache/releases/app-example/version-example/app:/workspace/app:ro",
+      "/opt/bisheng/app-data/cache/envs/tenant-example/env-example/venv:/opt/venv:ro",
+      "/opt/bisheng/app-data/apps/app-example/data:/data:rw"
+    ],
+    "Tmpfs": {"/tmp": "rw,nosuid,nodev,size=67108864"}
+  }
+}
+
+

bisheng-launch 是目标运行镜像内的平台启动入口:校验必需目录和版本材料、设置有限环境后用 exec 启动 Cmd 对应业务进程,使其接收停止信号。它不联网安装依赖、不运行宿主命令、不启动 manager。manifest 显式 argv 优先;兼容的 Procfile 和语言默认入口在发布准备阶段解析成冻结启动契约,再由运行镜像消费。

+

应用在容器内监听 0.0.0.0:8080;不同容器可重复使用该端口,不配置每应用宿主 PortBindings,app-proxy 使用内部地址访问。/data 的所有权由受控维护步骤准备,普通应用不能获得整棵缓存或其他应用数据。重启复用同版本目录;新版本使用新目录和新实例,数据兼容与切换按第 6、8 节处理。

+

3.8 何时重装、复用与清理

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
事件依赖和代码处理
仅普通业务代码变化创建新代码版本;完整兼容 key 不变则复用依赖产物和本节点环境;仍执行必要前端编译和应用探活
依赖锁定结果/安装相关源码变化新依赖 key;准备新产物及新环境,旧环境保持不可变
同一版本应用重启有效本地缓存直接挂载,不执行 pip/npm 安装,不重新解析依赖
切到新节点或删除缓存从 backend 下载冻结产物;Python 用 wheelhouse 离线重建环境,Node 按验证契约物化依赖;再启动
切换基础镜像、架构或 ABI按新兼容 key 重新准备与验证,不能凭 requirements.txt 相同跨架构复用
更新 runtime-manager 服务镜像只升级编排服务;已有应用版本的运行镜像、依赖和代码引用不随之变化
清理与回退按当前实例、审批、运行操作及回退窗口引用回收;节点缓存可重建,平台不可变产物保留策略单独管理
+

这一方案省去的是每次发布的 OCI 应用镜像构建/推送。首次安装、新依赖和新节点仍有依赖准备成本;命中的是经验证的完整环境组合,不能把 package manager 的下载缓存命中等同于应用运行环境已经可用。

+

+

4 存储与共享状态

+

4.1 数据归属

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
数据权威存储及目标访问方本地用途
应用、租户归属、权限关联、版本和审批平台 MySQL/DM8;backend/Celery 经领域服务和仓储读写manager 不复制平台用户/权限模型
发布/运行操作、期望版本、节点归属、阶段和幂等结果平台 SQL;manager 通过内部接口接收任务和上报结果本地任务缓存可重建,不是另一份可独立修改的权威
执行事实、就绪版本和路由投影manager 产生真实事实,backend 经归属/generation 校验持久化;Redis 加速读取app-proxy 内存缓存可在有效期内使用,不共享宿主 JSON
出站策略及策略代次平台 SQL 保存确认后的声明与派生策略,Redis 投影;egress 经只读 API 获取代理缓存版本化快照,重启可重新获取;不以共享文件为唯一来源
运行环境档位及默认映射管理员从清单导入,backend 校验并存平台 SQL;执行计划冻结具体修订与镜像YAML 是配置导入材料,manager 不以某台机器的文件作为档位权威
源码和代码/依赖产物MinIO 私有 bisheng-apps;backend 存取和分发manager/准备任务按内容摘要下载到受控缓存,挂载给应用只读
附件同一私有桶;应用 SDK → backend → MinIO应用不拿 MinIO 账号;manager 不经过附件数据流
SQLite 快照同一私有桶及平台快照元数据;manager 本地生成后上传 backend临时一致副本、tar 和导出文件;持久确认后清理
应用 SQLite应用专属持久卷,单活动应用使用;manager 在受控环境执行备份/迁移Docker 本机 bind 或 K8s PVC,是显式的应用数据持久化,不是平台跨服务控制状态
+

constitution C8 要求跨进程共享状态的权威放在 SQL、Redis 或 MinIO。A03 当前 manager 的 desired-state.json 和 egress 策略文件是单机实现,应迁移为节点可重建缓存;本方案不继续把共享本地文件扩展为多节点控制协议。C18 C27

+

应用 SQLite 延续原规格的单应用持久卷约束,不承接平台路由、权限、任务或跨节点共识。它的数据恢复与节点绑定需在 F054/F059 设计中明确,不能将该局部存储方式推广到平台业务控制状态。

+

4.2 对象路径与生命周期

+

可继续使用现有私有桶和对象布局;无需新建 MinIO 实例或每应用一个桶。目标接口对 manager 隐藏 bucket/key,以下路径只由 backend 内部管理。C04 C21 C22

+
bisheng-apps/
+└── apps/{app_id}/
+    ├── versions/{version_id}/code.tar.gz
+    ├── attachments/{relative_key}
+    ├── db-snapshots/{snapshot_id}.tar
+    └── artifacts/{artifact_id}/...          # 目标扩展
+
+

当前 SQLite 快照使用时间戳文件名;目标建议由平台分配快照 ID 并关联任务,避免并发或重试依赖秒级命名。旧路径可按元数据兼容读取;内容摘要、大小和产物归属应独立记录。

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
动作代码/镜像与产物SQLite 和附件运行状态
停用保留版本与可恢复产物保留平台禁止入口,停止执行并撤销出站主体;失败可重试
更新新增不可变版本,复用兼容依赖缓存沿用本 app 数据,必要时迁移;附件命名空间保持新版本就绪后发布路由事实,旧版本按发布策略回收
审批预览使用冻结版本,临时执行临时空库/隔离附件,不挂生产数据独立 session、策略、TTL 和并发限制
删除backend 清理或按策略保留远端资产;共享依赖按引用回收manager 仅按明确指令清本地卷;backend 清附件和快照先停用并作删除标记,再幂等清理;不是跨系统单一原子事务
代码回滚选定仍可用的历史产物组合不自动恢复数据库先检查数据库兼容性;数据恢复是独立受控动作
+

缓存、临时任务和孤儿对象定期清理;被在用、回退窗口或审批引用的产物不得回收。大包采用流式传输和有界临时空间;源码、依赖产物、附件、快照分别设置大小和保留策略,不能把源码 50 MiB 默认值套到所有产物。

+

+

5 平台与 runtime 接口

+

5.1 三组内部协议

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
协议调用方主要内容校验方与副作用
运行意图与操作查询backend/Celery → manager准备、部署、停止、销毁、探活、预览、SQLite 操作;operation ID、app/version、generationmanager 验服务身份、任务归属、重复请求和节点所有权;操作 Engine/数据卷并上报事实
产物与快照manager/准备任务 → backend内容下载、摘要校验、快照上传/状态查询/恢复下载backend 校验任务授权和范围,访问 MinIO;不返回 MinIO URL 或 302 跳转
控制状态与只读投影manager → backend 上报;两个代理 → backend 读取任务、期望、心跳、实例、路由、出站策略及撤销代次backend 用 SQL CAS/幂等记录和 Redis 投影;代理只读,不获得编排身份
+

现有 /v1/intents/*、构建查询、route、db、logs 接口可以作为兼容入口,但目标字段通过显式协议版本演进。当前 build 使用 code_url/code_object_key,后续改为平台产物描述;当前 deploy 只含 image_ref,后续需要执行产物组合。不得直接改变返回形态而不升级 CLI/worker/代理调用者。C08 C38 C41

+

建议的内部接口能力如下,路径和 DTO 在设计评审后冻结,当前代码尚未提供这些目标接口:

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
接口能力关键参数的产生与消费
artifacts/{id}/content 下载backend 建立 artifact ID、大小、摘要与任务授权;manager 或准备任务消费并校验
snapshots/{id}/content 上传/恢复下载backend 分配 snapshot ID 和目的;manager 提交一致副本或在恢复任务中下载;backend 校验摘要并持久确认
运行操作创建/查询及期望读取backend 创建操作和冻结计划;manager 取得所分配节点/应用任务;重试使用相同 operation ID 和 payload hash
执行事实与路由发布manager 上报真实就绪版本、可达地址、generation、owner epoch;backend 校验并形成路由投影
代理路由和策略快照读取backend 提供节点/应用范围内的已确认结果及代次;代理持专用只读身份,按版本刷新
应用附件 APISDK 使用 app 范围 endpoint/token;backend 判断应用、租户及相对路径,返回内容和元信息
+

5.2 产物和运行意图示例

+

以下 JSON 为目标协议示例,不可原样发送到当前 manager schema;摘要和镜像值均为占位符。

+
{
+  "protocol_version": 2,
+  "operation_id": "op-example",
+  "app_id": "app-example",
+  "version_id": "version-example",
+  "node_id": "runtime-node-a",
+  "generation": 7,
+  "execution": {
+    "type": "bundle",
+    "runtime_image": "bisheng/app-python@sha256:<digest>",
+    "code": {"artifact_id": "code-example", "sha256": "<sha256>"},
+    "dependencies": {"artifact_id": "deps-example", "format": "python-wheelhouse-v1", "sha256": "<sha256>"},
+    "command": ["python", "main.py"],
+    "port": 8080
+  },
+  "resources": {"cpu": 0.5, "memory_mb": 1024},
+  "data_mode": "sqlite"
+}
+
+

backend 提供应用附件句柄、平台能力地址和短期任务授权,manager 负责受保护环境变量覆盖及注入。服务管理密钥和文件传输任务授权不进入应用环境、普通日志或公开版本记录。

+

5.3 认证与传输

+

内部 HMAC/服务认证用于确认服务身份和请求完整性,继续保留,与商业许可证无关。新协议增加时间窗口、nonce 或等效防重放机制,以及 app/任务/动作范围;文件传输按可信内容摘要签署并在接收时验证,不为了签名将大文件全部读入内存。原 HMAC 只覆盖方法、路径和 raw body,不能按已有代码宣称具备这些新增约束。C35

+

应用运行凭据代表应用;OBO 凭据代表本次访问者;附件句柄仅对应应用附件空间;构建/快照任务授权仅对应某个操作。四者不可互换,代理读权限也不能调用 runtime intent。

+

SDK 附件 API 保持应用使用方式,但需处理地址、令牌校验权威、响应 envelope 和错误语义的兼容。当前 SDK 按 manager 的错误信封解析,不能仅改 endpoint 就宣布迁移完成。C22 C33

+

+

6 发布上线流程

+

6.1 上传与预检

+
时序图 2 第 1 部分CLI 到 API:1 上传源码包或完整镜像描述;API 到 API:2 鉴权、归属、大小、清单和安全校验;API 到 Store:3 保存不可变源码或清单产物,元数据存平台 SQL;API 到 Worker:4 记录 deployment 并提交预检任务;API 到 CLI:5 返回 deployment_id 与查询入口;Worker 到 Worker:6 扫描密钥、能力与数据库变更;Worker 到 Manager:7 提交有幂等标识的产物准备任务CLIBackend APICelery workerruntime-manager准备任务 / EngineMinIO1 上传源码包或完整镜像描述2 鉴权、归属、大小、清单和安全校验3 保存不可变源码或清单产物,元数据存平台 SQL4 记录 deployment 并提交预检任务5 返回 deployment_id与查询入口6 扫描密钥、能力与数据库变更7 提交有幂等标识的产物准备任务
图 2 · 第 1/3 部分 · 按步骤编号连续阅读;窄屏可横向滚动
时序图 2 第 2 部分Manager 到 API:8 请求固定源码或依赖产物内容;API 到 Manager:9 流式返回文件;校验摘要;Manager 到 Engine:10 复用依赖或准备编译产物;Manager 到 API:11 上传准备产物并登记兼容元数据;API 到 Store:12 保存代码、依赖与静态产物;Manager 到 Engine:13 用冻结产物组合启动临时实例探活;Manager 到 Worker:14 返回产物组合、结果和日志CLIBackend APICelery workerruntime-manager准备任务 / EngineMinIO8 请求固定源码或依赖产物内容9 流式返回文件;校验摘要10 复用依赖或准备编译产物11 上传准备产物并登记兼容元数据12 保存代码、依赖与静态产物13 用冻结产物组合启动临时实例探活14 返回产物组合、结果和日志
图 2 · 第 2/3 部分 · 按步骤编号连续阅读;窄屏可横向滚动
时序图 2 第 3 部分Worker 到 Worker:15 审批准入;写版本,失败补偿取消审批CLIBackend APICelery workerruntime-manager准备任务 / EngineMinIO15 审批准入;写版本,失败补偿取消审批
图 2 · 第 3/3 部分 · 按步骤编号连续阅读;窄屏可横向滚动

这是目标流程:应用包模式准备代码/依赖,镜像兼容模式在第 10 步构建应用镜像,完整镜像模式检查镜像契约并跳过源码依赖准备。三者均执行相应扫描/准入及启动预检,不能用“用户提供镜像”跳过运行约束。

+

当前实现已具有源码接收、扫描、模板构建和临时探活;使用短期 MinIO 预签名 URL 下载源码,附件/快照直连也尚未迁移。版本写入的真实顺序是先提交审批准入,再插入版本,失败时尝试取消审批;本方案继续保持这一已查明语义。C06 C07 C37 C44

+

6.2 审批后上线与迁移

+
时序图 3 第 1 部分Publish 到 Backend:1 接收审批结果,冻结本次上线计划;Publish 到 Manager:2 检查资源并取得应用独占操作权;Manager 到 Backend:3 确认任务、期望代次及节点归属;Manager 到 App:4 SQLite 场景停写并隔离旧执行体;Manager 到 Manager:5 Backup API 生成迁移前一致快照;Manager 到 Backend:6 上传快照并等待持久确认;Backend 到 Manager:7 对象和元数据保存成功,返回 snapshot_id发布执行服务Backend APIruntime-managerDocker / K8s应用实例1 接收审批结果,冻结本次上线计划2 检查资源并取得应用独占操作权3 确认任务、期望代次及节点归属4 SQLite 场景停写并隔离旧执行体5 Backup API 生成迁移前一致快照6 上传快照并等待持久确认7 对象和元数据保存成功,返回 snapshot_id
图 3 · 第 1/3 部分 · 按步骤编号连续阅读;窄屏可横向滚动
时序图 3 第 2 部分Manager 到 Manager:8 执行事务化、可重试的本地迁移;Publish 到 Manager:9 提交冻结产物及应用能力配置;Manager 到 Engine:10 再验容量、挂卷、启动正式实例;Manager 到 App:11 执行就绪检查;Manager 到 Backend:12 上报就绪事实和待发布路由代次;Backend 到 Backend:13 CAS 保存执行事实并更新路由投影;Backend 到 Manager:14 确认可供代理读取的路由版本发布执行服务Backend APIruntime-managerDocker / K8s应用实例8 执行事务化、可重试的本地迁移9 提交冻结产物及应用能力配置10 再验容量、挂卷、启动正式实例11 执行就绪检查12 上报就绪事实和待发布路由代次13 CAS 保存执行事实并更新路由投影14 确认可供代理读取的路由版本
图 3 · 第 2/3 部分 · 按步骤编号连续阅读;窄屏可横向滚动
时序图 3 第 3 部分Manager 到 Engine:15 按策略回收旧执行体和临时资源;Manager 到 Publish:16 返回操作结果,平台完成状态和审计发布执行服务Backend APIruntime-managerDocker / K8s应用实例15 按策略回收旧执行体和临时资源16 返回操作结果,平台完成状态和审计
图 3 · 第 3/3 部分 · 按步骤编号连续阅读;窄屏可横向滚动

迁移快照和产物的 MinIO 存取均由 backend 执行,图中第 7 步的确认包含对象内容及平台 SQL 元数据。首次发布尚无数据库、或无需快照的兼容性迁移,可以按明确策略跳过第 5—7 步,不生成无意义空快照。

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
阶段执行方及关键输入状态/存储副作用失败处理
接收CLI → backend;package/清单、可选 app ID、结构变更确认源码快照、deployment、审计同步拒绝越权/危险归档;大文件临时空间受限
预检worker 调 manager;固定版本/产物及资源分阶段结果、依赖产物、临时探活日志失败不进入审批,清理临时执行体;保留可诊断日志
审批与预览平台审批服务、预览参与资格、冻结版本审批和版本记录、临时 preview session拒绝/撤回回收预览;生产数据不被预览修改
上线准入backend 协调,manager 检查资源、任务和独占权上线 operation、节点归属/代次资源不足保留待上线版本,旧在线版本按其状态继续;不重复建审批
SQLite 迁移manager、本地卷、冻结迁移计划及快照任务快照文件/元数据、DDL 结果快照失败/状态不明则不做破坏性 DDL;停写后的恢复动作明确记录
正式启动manager、Engine、运行镜像/代码/依赖、env、卷、资源候选执行体和探活结果失败不发布候选路由,按是否已经迁移判断旧版本可否恢复
路由发布manager 上报事实;backend 验 generation 和所有权SQL 事实、Redis 投影、路由代次投影未确认不退休旧版本;通过 outbox/重试修复,不回报虚假上线
停用/删除backend 权限与入口状态;manager 执行停止/销毁路由和策略撤销、本地卷显式清理、远端资产由 backend 处理幂等重试和对账;数据清理不靠重复动作猜测是否完成
+

6.3 更新方式和异常边界

+

无持久写入或已确认数据兼容的应用可采用候选探活后切路由;声明 SQLite 或存在不能并发运行的数据写入者时,采用单活动发布:隔离旧写入、备份/迁移、启动新实例。后者允许短维护窗口,不能同时承诺任意 schema 变更零停机。C16 C21

+

当前实现先迁移,再执行部分资源准入/启动;并存在 deploy 前先撤销旧应用能力凭据再重签的路径。新方案将资源预检前移,并把凭据切换设计成候选可用、正式切换后再失效旧凭据的受控阶段。数据库迁移和容器启动仍不是同一事务,启动失败不能自动推断旧版本数据及凭据未变化。C14 C15 C36 C48

+

同一个 app 的发布、迁移、恢复、停用按持久操作和 generation 串行协调;重复 operation ID 复用结果,ID 相同但参数摘要不同则拒绝。后台对账同样读取有效期望与执行所有权,不按本地过期缓存擅自重建已停用应用。

+

+

7 应用访问与平台能力

+

7.1 目标访问链路

+
时序图 4 第 1 部分Browser 到 Proxy:1 请求 /apps/slug,携带平台会话;Proxy 到 Backend:2 请求入口授权及当前访问者材料;Backend 到 Proxy:3 应用可见性、用户状态和短期 OBO;Proxy 到 Backend:4 独立查询已确认路由投影;Backend 到 Proxy:5 返回就绪实例与 generation;Proxy 到 App:6 清理伪造头后注入身份并转发 HTTP / WS;App 到 Backend:7 用应用凭据及访问者 OBO 调模型或检索浏览器 / nginxapp-proxyBackend API应用 / SDKegress-proxy允许的外部服务1 请求 /apps/slug,携带平台会话2 请求入口授权及当前访问者材料3 应用可见性、用户状态和短期 OBO4 独立查询已确认路由投影5 返回就绪实例与generation6 清理伪造头后注入身份并转发 HTTP / WS7 用应用凭据及访问者 OBO调模型或检索
图 4 · 第 1/2 部分 · 按步骤编号连续阅读;窄屏可横向滚动
时序图 4 第 2 部分App 到 Backend:8 用 app 范围附件句柄读写附件;App 到 Egress:9 访问批准的外部地址,携带代理身份;Egress 到 Egress:10 校验策略版本、域名及解析地址;Egress 到 Remote:11 建立受控 HTTP 或 CONNECT 连接;App 到 Proxy:12 返回应用内容或持续 WebSocket 数据;Proxy 到 Browser:13 返回浏览器浏览器 / nginxapp-proxyBackend API应用 / SDKegress-proxy允许的外部服务8 用 app 范围附件句柄读写附件9 访问批准的外部地址,携带代理身份10 校验策略版本、域名及解析地址11 建立受控 HTTP 或CONNECT 连接12 返回应用内容或持续 WebSocket 数据13 返回浏览器
图 4 · 第 2/2 部分 · 按步骤编号连续阅读;窄屏可横向滚动

这是目标链路,路由读接口改到 backend 投影;当前仍由 app-proxy 查询 manager。后端的授权结果和路由读取继续保持不同语义、不同缓存,不把“有路由”当作“有访问权限”。C24 C25 A05

+

7.2 路由与策略的可用性

+

manager 是真实就绪和执行地址的生产者,backend 只接收已认证、归属正确且代次有效的事实,形成持久只读投影。平台不通过 exec_ref 猜容器地址,也不复制 Docker 操作逻辑。代理读取该投影,因此 manager 短暂重启时不会因 3 秒路由缓存到期就必须连 manager 才能接收新请求。

+

出站策略采用相同原则:批准的声明和派生策略存平台,egress 经专用只读 API 获取版本化快照。旧 JSON/共享文件仅作为迁移输入或缓存;代理重启可从平台恢复,不要求与 manager 在同一机器共享目录。该变化修正原 D5.1 和文件策略的单机依赖,需要更新相应设计与契约。C18 C27 A03

+

缓存必须有应用、版本、generation、有效期及撤销语义,刷新采用轮询/版本查询,推送仅作加速。停止/删除/撤权由 backend 使授权失效并更新投影,必要时通知代理关闭既有连接。权限或策略状态无法确认时拒绝新授权;不得为了 manager 故障容错而无限保留已撤销权限。平台自身不可用与 manager 重启是不同故障,不承诺权限服务长期不可用时仍绕过校验。

+

7.3 身份与出站控制

+

app-proxy 继续处理可信头规范化、前缀剥离和重定向修复,转发 HTTP/WebSocket,并定期重校验长连接。backend 通过统一权限运行时验证用户/租户状态和 app:use。检索和需要访问者身份的模型能力按“应用声明范围与访问者权限共同约束”判断,缺少访问者不能退化为应用 owner 权限。C25 C26 C31 C32 C36

+

egress 检查代理令牌、允许域名/端口和 DNS 解析地址,防止将出口变成访问内部数据库的跳板。CONNECT 只在建连前校验目标,不解密 TLS。代理配置不能独自阻止不遵循 HTTP_PROXY 的应用,仍需实际生效的网络和 TCP/UDP 兜底规则;构建期允许包源,运行期不自动获得包源权限。C27 C28

+

+

8 SQLite 持久化与恢复

+

8.1 数据卷与预览

+

Docker 沿用按 app 隔离的宿主数据目录,挂载到 /data,数据库为 /data/app.db。RTM_DATA_ROOT 是 manager 视角,RTM_HOST_DATA_ROOT 是 Engine 解释 bind mount 的宿主视角,必须对应同一数据。代理不挂整棵应用数据根,backend 不直接打开 SQLite。C16 C17 C29

+

K8s 使用每应用独占 PVC 和单活动执行策略。在 CSI/集群支持时优先 ReadWriteOncePod;普通 ReadWriteOnce 只限制单节点,仍可能被同节点多个 Pod 使用。SQLite WAL 需要本机共享内存,不可让跨主机进程把 NFS/SMB 上的数据库当作共享运行库。R02 R03

+

审批预览默认空临时库和临时附件空间,结束后清理。若需要生产数据副本,必须另行授权生成隔离副本,不能直接挂正式 PVC 或注入生产附件凭据。当前 preview 使用 tmpfs,目标延续隔离原则。C20 C43

+

8.2 备份与迁移

+

manager 在操作独占和写入隔离下执行 SQLite Backup API,得到一致副本,再通过 backend 上传。快照元数据至少包含 app、来源版本、schema、operation、大小、摘要、创建时间和状态;backend 确认文件和元数据都已持久化后,才能执行破坏性 DDL。C21 R01

+

上传超时可能发生在文件已经保存之后,因此以同一 snapshot/operation ID 查询和重试;不能重复产生不可关联的快照,也不能将“本地备份完成”当作“平台备份完成”。事务化 DDL 只保证数据库内部事务,不覆盖上传、启动和路由切换。

+

应用包和历史镜像回退只恢复代码/环境。数据库恢复需要停写、明确恢复点和数据损失窗口,下载并校验对应快照、验证完整性、恢复文件所有权,再探活并恢复路由。启动失败后不能自动恢复旧快照而覆盖可能已产生的新业务数据。

+

8.3 节点故障边界

+

Docker 本机盘不能承诺节点损坏后自动无损迁移;应通过平台快照和现场备份策略恢复。K8s 跨节点恢复取决于 CSI/PVC 的实际能力。manager 在 K8s 下不能假定远端 PVC 在自己本机可读,应编排挂载同一 PVC 的受控维护执行体,保证业务写入者已经停止,再进行备份/迁移/恢复。

+

这种执行体是编排服务的受控操作手段,不是新的常驻业务服务;不能以任意远程 SQL 或任意宿主路径作为接口输入。各平台支持矩阵需覆盖 Python/SQLite 动态库、文件锁、文件所有权及目标 CPU 架构。

+

+

9 Docker 与 K8s 交付

+

本节实施摘要:

+
    +
  1. 无外网且无可用内网包源时,提前交付离线依赖包。 首次发布使用已导入的基础依赖集合;新增依赖缺失时,在可取得依赖的环境制作补充包,经 backend 导入和验证后再发布。缺包在预检阶段明确报错,不回退公网。
  2. +
  3. 离线包包含完整依赖闭包。 固定锁文件、传递依赖、SDK、目标平台信息及文件摘要;不能仅拷贝顶层库,也不能把开发机 venv 当作通用交付物。manager 经 backend 获取 wheelhouse,在执行节点离线建立并缓存环境。
  4. +
  5. 应用基础镜像通过运行环境清单配置。 目标新增 docker/runtime-profiles.yaml,由管理员导入 backend 后统一持久化;应用使用 execution.runtime_profile 选择版本化档位。当前代码仍是 BASE_IMAGES 常量,该配置入口需要实施。
  6. +
  7. manager 镜像与应用基础镜像分别更新。 Compose 的 runtime-manager.image 只更新编排服务;替换应用运行环境需要新档位、依赖兼容验证及正常发布,不能使已冻结版本随默认配置变化。
  8. +
+

操作流程与配置示例分别见 离线依赖准备与补包、基础运行镜像配置。

+

9.1 前置条件

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
条件Docker 首轮交付K8s 后续适配
执行接口Docker Engine HTTP API,经 socket proxy;manager 不需要 Docker CLIK8s API、专用 namespace、ServiceAccount/RBAC;不要求节点安装 dockerd
应用包manager 从 backend 下载并校验,物化到受控内容寻址缓存,以只读方式供应用使用init/准备执行体通过 backend 下载到受控卷,校验后供主容器只读使用
依赖准备匹配运行环境的隔离准备任务;限制网络、CPU、内存和临时磁盘同类受限 Job,记录结果/日志/产物;不是默认每次生成 OCI 镜像
镜像构建兼容已有 Engine build 和平台模板若启用该路线,另配构建器、推送凭据、镜像分发与缓存;集群本身不提供源码构建能力
运行镜像预制且固定版本/digest,目标 Engine 可加载或拉取所有可调度节点可以取得对应架构的运行/初始化镜像
SQLite单应用本机 bind,明确路径与权限独占 PVC、合适 StorageClass、单活动策略及维护执行体
路由与策略backend 持久投影/读接口,代理缓存同一接口可用;应用地址由执行适配器产生,可使用稳定 Service
资源与身份编排能力仅给 manager;代理只读身份最小 RBAC 和 Pod 安全准入;应用不挂集群控制凭据
+

当前 Docker manager 和模板已经存在;K8s 行为属于目标适配。应用包路线只减少应用镜像构建步骤,不会取消集群 API、镜像分发、卷和网络前置。C09 C10 C34

+

9.2 无外网和无内网镜像仓库

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
层次离线材料必须验证
平台/管理服务backend、worker、manager、代理和基础组件的目标架构镜像、配置与数据迁移完整安装和内部认证,不仅是容器存活
应用运行环境固定运行镜像、初始化/维护镜像、常用环境档位在实际执行引擎/节点存在,摘要与架构一致
依赖与编译完整 wheel/npm 产物或内网包源、系统库、工具链、SDK 平台索引锁文件外链、安装脚本和前端编译都不访问公网
产物与数据服务平台私有桶、backend 文件接口、共享 SQL/Redis、数据卷和容量manager 被禁止直连 MinIO 时仍能准备源码、保存和恢复快照
应用运行能力模型、检索、附件和明确允许的业务接口页面可打开不等于平台能力可调用;外部 SaaS 不会因离线镜像自动可用
+

Docker 单机没有仓库时,可随安装包导入固定服务/运行镜像到同一个 Engine;应用包放平台对象存储,后续代码发布不必新增应用 OCI 镜像。完整镜像模式需要用户将对应镜像导入目标 Engine 或提供可访问仓库。

+

K8s 推荐客户内网仓库或附带最小 Registry。若不建设仓库,可以在可控的全部目标节点预装固定镜像,并配置与现场策略相符的 IfNotPresent/Never;新增/替换节点必须先完成镜像准备再承载应用。Kubernetes 官方明确说明预拉取方式依赖可控节点和一致的镜像准备,Never 缺图时直接启动失败;还须按目标集群版本核对本地镜像的凭据验证策略。R04

+

无仓库方式不适合不受控的自动节点替换。离线包应有镜像 digest/架构清单、产物摘要、导入顺序、补包流程及缺依赖诊断;Registry 自身若随包交付,要能在其他镜像尚未导入前启动。

+

9.3 配置归属

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
配置读取方与归属
backend app_runtimebackend/worker 读取部署开关、manager 地址、内部认证、包限制及发布配置;不把 manager 容量参数复制进此处
manager RTM_*manager 自己读取 Engine/集群连接、数据根、资源预留、包源、超时和目标平台内部客户端配置;不读取 backend 的 config.yaml
app-proxy / egress 配置各进程读取监听地址、平台只读接口及自己的服务身份、缓存/连接限制;不继承 manager 管理凭据
MinIO / 平台 SQL只由平台存储及领域服务使用;目标 manager 不再配置 MinIO 地址、bucket、访问密钥
应用注入变量backend 产生业务能力和访问句柄,manager 按冻结版本注入;运行镜像/入口消费,用户不能覆盖平台保留项
运行环境目录(目标新增)管理员用 docker/runtime-profiles.yaml 导入 backend;SQL 保存不可变档位与默认映射。worker 从平台目录冻结执行计划,manager 不直接读取该 YAML,见 §9.6
+

当前容量、数据根和包源已经由 manager 的环境变量读取,不能仅修改 backend 配置就预期生效。C05 C17 新增内部客户端和策略 API 配置属于本次目标增量,应提供各进程独立配置样例和缺项诊断。

+

9.4 runtime-manager 的 CI/CD 与 Compose 交付

+

确定采用:与 backend 相同的预构建镜像交付方式。 已检查的 backend 流程在 v* tag 推送时构建并推送 amd64/arm64 镜像到 Docker Hub,release 流程聚合版本 manifest。runtime-manager 应加入对应构建和发布阶段;当前源码基线尚未接入该发布作业,Compose 的 manager 服务仍使用本地 build:。A06 A07 C29

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
环节目标实施
镜像构建入口复用 src/runtime-manager/Dockerfile 及该目录构建上下文,依赖锁文件安装服务自身依赖;新功能开发后同步调整依赖并修正旧 socket/root 注释
CI 作业在现有 ci.yml 增加 manager 的 amd64/arm64 构建、必要测试与镜像启动检查;使用仓库 Docker Hub secrets,不在 Compose 或镜像层放推送凭据
镜像命名建议 dataelement/bisheng-runtime-manager:<platform-version>-amd64/-arm64;版本名示意,尚未声明这些仓库/tag 已存在
正式发布release.yml 聚合同一源码提交且构建成功的架构镜像为版本 tag;核验各 digest/提交标签存在后再发布,不能只因 release 事件触发就认定 tag CI 已完成
Compose正式文件用 image: 引用已发布版本,不带 manager build:;本地源码开发可在开发 override 中启用构建
其他镜像app-proxy、egress-proxy 同样纳入各自预构建交付;应用运行/准备镜像有独立环境版本和兼容清单,不跟随每次应用代码发布重建
离线与架构安装材料包含所需架构的服务、运行及准备镜像和 digest 清单;国产化平台逐项验证,amd64/arm64 覆盖不等于支持所有 CPU/系统库组合
+

现有 manager Dockerfile 是服务构建入口,不是应用运行基础;其中关于“直接挂 socket、因此必须 root”的旧注释与当前 Compose 的 socket proxy 方式不一致,发布前应按实际访问链路修正。服务镜像是否以非 root 运行还需核对本地数据目录、chown 与维护操作,不能只改 USER 就宣布权限适配完成。A08 C29

+

正式 Compose 的关键配置片段如下。它表达目标交付方式,不是完整可直接替换的 Compose;backend 新内部 API 身份、容量、网络、就绪和出站规则需与全文件一起配置:

+
services:
+  runtime-manager:
+    image: ${BISHENG_IMAGE_NAMESPACE:-dataelement}/bisheng-runtime-manager:${BISHENG_VERSION:?set BISHENG_VERSION}
+    profiles: [app-runtime]
+    environment:
+      RTM_HOST: "0.0.0.0"
+      RTM_PORT: "8091"
+      RTM_HMAC_SECRET: ${BISHENG_RTM_HMAC_SECRET:?set BISHENG_RTM_HMAC_SECRET}
+      RTM_DOCKER_HOST: tcp://docker-socket-proxy:2375
+      RTM_PLATFORM_BASE_URL: http://backend:7860
+      RTM_EGRESS_PROXY: egress-proxy:3128
+      RTM_DATA_ROOT: /app-data
+      RTM_HOST_DATA_ROOT: ${BISHENG_APP_DATA_ROOT:-/opt/bisheng/app-data}
+    volumes:
+      - type: bind
+        source: ${BISHENG_APP_DATA_ROOT:-/opt/bisheng/app-data}
+        target: /app-data
+        bind:
+          create_host_path: false
+    networks: [default, bisheng-apps, runtime-control]
+    depends_on: [docker-socket-proxy]
+    restart: on-failure
+
+

安装器先创建绝对数据根并设置权限;socket proxy 只加入受控的 runtime-control 网络,backend/代理不加入该控制网。此片段不挂 Docker socket、不配置 MinIO、不开放 manager 宿主端口;生产 image 固定发布版本,交付清单另记录 digest,不能默认依赖 latest。R10

+

在线安装时 Compose 拉取 manager 镜像;离线安装时事先导入与引用一致的镜像并采用现场拉取策略。manager 和应用运行镜像的 Engine API 获取权限分别配置;CI 中用于构建镜像的 Docker/Buildx 工具不成为 manager 运行容器必须安装 Docker CLI 的理由。

+

平台升级 manager 镜像后,仍按旧应用版本记录使用原运行镜像、代码和依赖;运行环境档位升级应显式产生新的应用执行组合并预检。这样平台服务升级与应用环境升级有各自的回退边界。

+

+

9.5 断外网时,wheelhouse 从哪里获取

+

纯离线环境必须提前获得依赖文件。 wheelhouse 是已下载或已构建 wheel 的集合,不能自己产生一个现场不存在的包。首次发布不一定需要临时搬包:若随安装包提供的依赖集合已经覆盖应用,可以直接准备;新增依赖不在已导入集合或内网源中时,必须先补包,再继续发布。

+
+ + + + + + + + + + + + + + + + + + + + + + +
现场条件首次发布及新增依赖的来源未满足时的行为
无公网,有完整内网 PyPI/制品源在受限准备任务中从批准的内网源获取锁定依赖,产出 wheelhouse内网源未同步目标版本、只会转发公网请求时,不能认为依赖可用;报告缺包
无公网、无内网包源从平台离线安装包的基础依赖集合,或预先导入的应用专用离线包选取缺少目标包/版本/架构时,预检失败,不生成审批、不尝试公网回退
已有同兼容键的依赖产物经 backend 下载冻结 wheelhouse;节点环境命中则直接复用产物不完整或摘要/运行环境不匹配则拒绝,不能借用另一 ABI 的缓存
+

建议随平台离线交付一份有明确版本清单的基础依赖集合,覆盖 SDK 和官方样例的依赖。它是有限集合,不能承诺包含所有 Python 库及所有版本;业务新增包通过增量离线包交付。预制运行镜像、wheel 集合和 SDK 版本分别登记,镜像中携带常用 wheels 也要进入同样的完整性及兼容检查。

+

离线包制作在可取得依赖的环境完成。 推荐在与客户目标运行环境匹配的准备镜像/CPU 上,使用固定锁文件下载或构建完整传递依赖,必要时从项目交付物取得指定 SDK wheel,然后在目标运行镜像中断网执行安装和探活验证。已有兼容 wheel 时可用下列命令收集;只能在联网准备机或可达完整包源处运行:R11

+
python -m pip download --only-binary=:all: --dest /out/wheelhouse -r /input/requirements.runtime.lock
+
+

需要源码构建的包按 §3.5 制作目标 wheel,再生成最终 wheel 哈希锁文件;在 x86/macOS 开发机直接下载出来的包不能默认适配客户 ARM/其他国产 CPU。若使用 pip 的跨平台下载参数,须完整指定目标解释器/ABI/平台并另做目标验证;跨平台下载选项不等于交叉编译能力。R11

+

应用专用离线包建议包含以下内容。目录是目标交付格式草案,非现有 CLI 自动生成能力:

+
app-offline-deps.tar.gz
+├── manifest.json                   # 格式、目标环境/平台、来源、锁文件和文件摘要
+├── requirements.runtime.lock       # 全部运行包的固定版本及最终 wheel 哈希
+├── wheelhouse/
+│   └── *.whl                       # 包含传递依赖和对应 SDK
+├── checksums.sha256
+└── validation.json                 # 制作环境、目标镜像与断网安装验证记录
+
+

系统动态库、Python 解释器和工具链不由 wheelhouse 自动解决。应用需要新增 OS 库时,应交付相容的新运行/准备镜像及其离线镜像材料,或完整应用镜像;仅搬入 .whl 可能仍不能导入或运行。离线现场仍要从平台存储和数据卷读取产物,只是不访问公网。

+

导入和增量补包闭环:

+
    +
  1. 管理员或获授权交付人员提交离线包到 backend 产物导入接口;接口/工具为本方案新增项,不要求 manager 访问 MinIO。
  2. +
  3. backend 校验身份、归属、大小、安全归档、清单及摘要,把内容存私有对象存储并保存 SQL 元数据,先标为待验证;文件哈希仅证明一致性,信任来源仍由导入权限及供应方验证决定。
  4. +
  5. backend 调 manager 启动受限准备任务,以目标镜像、明确禁公网网络和任务范围的文件访问取得离线材料,完成安装、关键 import 和应用预检;失败不把产物标为可复用。
  6. +
  7. 验证通过后登记依赖产物及兼容键,发布计划引用该 artifact ID;执行节点从 backend 下载后按需要重建本地 venv。
  8. +
  9. 新增依赖时输出可复核的缺失清单,包含可确定的包/版本约束、目标架构/ABI、镜像和阶段。外部补齐完整依赖闭包或可合并的增量包,重新验证后复用/重试发布;不能仅上传一个顶层库就跳过其传递依赖检查。
  10. +
+

无内网包源的首轮交付优先采用“平台基础离线集合+按应用补包”。应用持续增加大量依赖时再建设内网 PyPI/制品源,减少人工转运;源里的内容仍需预先同步。Node 的 npm 包、安装脚本下载件和前端构建工具也遵循同一离线闭包原则。

+

+

9.6 预置应用基础运行镜像的配置与替换

+

当前代码:基础镜像写在 src/runtime-manager/runtime_manager/builder.py 的 BASE_IMAGES,还没有可直接修改的部署配置项。 python3.11 对应 python:3.11-slim,node20 对应 node:20-slim,static 对应 nginx:1.27-alpine,模板使用 FROM {{ base_image }}。现有构建调用没有从配置接收基础镜像的入口;修改这些默认值需要改代码并重建 manager 服务镜像。A09

+

RTM_IMAGE_PREFIX 是构建后应用镜像的命名前缀,RTM_BUILD_INDEX_URL/RTM_BUILD_NPM_REGISTRY 是包源,Compose 的 manager image: 是编排服务镜像;都不能用来替换应用基础运行镜像。C17 A09

+

目标方案:新增 docker/runtime-profiles.yaml 运行环境清单,由管理员导入 backend 的运行环境目录。 导入后的不可变档位、默认映射和修订记录存 SQL,worker/API 都从平台目录读取;YAML 是安装/变更的输入,不是各服务共享读取的本地状态。manager 只接收已冻结的档位、实际镜像摘要和执行契约,不读取 backend 配置或该 YAML。

+

以下配置仅为新增格式草案,当前代码不能直接读取;镜像和摘要均为占位示意:

+
schema_version: 1
+profiles:
+  - id: python311-standard-v2
+    runtime: python3.11
+    runtime_image: "registry.intra/bisheng/app-python:py311-v2@sha256:<runtime-manifest-digest>"
+    prepare_image: "registry.intra/bisheng/app-python-prepare:py311-v2@sha256:<prepare-manifest-digest>"
+    platforms: [linux/amd64, linux/arm64]
+    launcher: /usr/local/bin/bisheng-launch
+    default_port: 8080
+defaults:
+  python3.11: python311-standard-v2
+
+

镜像内容需要符合 bundle 运行契约:解释器、launcher、非特权用户、挂载目录、系统库及准备环境匹配。不能仅把任意 python 官方镜像地址填进档位就预期它具备平台 launcher。镜像内容在目标新增的 src/app-runtime-images/ Dockerfile 中维护并经 CI 发布,清单登记已发布/已导入的结果。

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
想修改的对象目标修改入口生效范围
应用的基础运行/准备镜像新建运行档位,修改 runtime-profiles.yaml 对应引用,导入 backend新发布/显式升级的应用版本,且必须重验依赖兼容性
新应用默认使用哪个档位清单 defaults 映射,经 backend 校验后持久化后续未显式选择档位的发布;版本一旦冻结,不随默认值变化
某个应用选择哪个环境应用 bisheng-app.yaml 的 execution.runtime_profile该应用新版本;示例 python311-standard-v2
runtime-manager 自身升级正式 Compose 的 runtime-manager.image编排服务,不替换已上线应用的基础镜像
应用完全自带镜像execution.type=image 及批准的完整镜像引用走完整镜像契约,不再给该镜像叠加 bundle 基础运行镜像
+

替换步骤是:发布或导入新镜像 → 新建档位清单并导入平台 → 执行环境自检与依赖验证 → 修改默认档位或应用的档位选择 → 正常发布预检、审批及上线。旧档位 ID 的镜像内容不可原地改写;旧版本和回退窗口仍引用原组合。镜像地址转到内网仓库时,也必须验证内容与登记摘要一致。

+

多架构镜像的清单摘要在执行计划中进一步解析为目标平台的实际镜像摘要,参与依赖兼容键。无仓库离线导入须验证目标 Engine 能使用内容一致的执行引用,不能只检查同名 tag 存在。拉取凭据保留在节点/集群的受控配置,不写入应用包、清单公开内容或用户应用环境。

+

该运行环境目录、导入接口、默认选择、v2 manifest 与 manager 执行协议需要一起实现;本轮只补齐评审设计,不声称已有对应配置文件或管理命令。

+

+

10 权限隔离与运行可靠性

+

10.1 编排权限

+

全部开源不降低运行权限要求。manager 经 Engine API 仍能创建/删除容器及控制挂载;socket proxy 的端点白名单不能自动限制请求中的特权、hostPath 或 bind 参数。manager 必须生成固定安全参数并限制受管标签范围;普通应用和代理不能访问控制网络或持有同一把全能力管理密钥。C16 C29

+

镜像和容器使用只读根文件系统、明确可写目录、非特权用户和资源限制;用户完整镜像也必须遵循。镜像来源和自定义运行环境的登记属于平台管理权限,不是商业能力开关。

+

10.2 状态一致性与节点所有权

+

建议扩展平台现有 app/version/deployment/instance 元数据,补充运行操作、节点归属、owner epoch、generation、心跳及路由/策略投影。新增表和索引须支持 MySQL/DM8,使用仓库双方言类型,业务权限走统一应用层接口,不让 runtime 直接写平台表。

+

一个 app 同时只有一个有效执行所有者。首轮 Docker 按明确节点分配,禁止多 manager 自动争抢同一应用;跨节点迁移和多活动控制面另行验证旧 owner 失效规则。租约超时本身不能保证旧进程已经停止操作 Engine,失联时先隔离和对账,不盲目在另一节点启动第二个 SQLite 写入者。

+

平台操作持久化后再投递,worker 重放使用相同幂等 ID;manager 重启从平台期望和真实执行体标签恢复,本地缓存可删除重建。原 JSON 状态和策略文件需要受控导入,不能在迁移后与平台状态同时作为权威。

+

10.3 故障处理

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
场景目标处理
manager 重启/升级已运行容器和两个代理继续运行;路由/策略从平台投影读取,恢复后先对账再变更
backend/权限服务不可达新业务授权和任务不放行;代理对已持有的短期权限按契约处理,到期/撤销不能无限容错
Redis 故障通过可控 SQL 读取/投影重建或明确不可用处理,不以本地跨服务文件替代权威
元数据已保存但响应丢失用 operation/generation 查询确定结果,不能仅按调用超时重复创建或清理
快照上传失败保留可诊断状态,禁止破坏性迁移;按维护策略恢复旧服务或等待重试
路由投影失败不宣布候选对外上线,不过早退休旧实例;投影修复与事实对账可重试
单应用退出/不健康资源范围内进行有界重启和探活,持续失败进入可见故障状态,避免无限高速重建
磁盘不足准入检查及缓存回收;不能删除在用数据卷或未完成确认的唯一备份
+

日志包含 operation、app/version、阶段、节点与错误原因,过滤已知注入的敏感值;构建日志和运行日志有大小/保留限制。健康分为进程存活、依赖就绪和应用业务就绪,不以 /healthz 成功代替全部前置验证。C40

+

+

11 实施阶段与验收

+

11.1 实施顺序

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
阶段变更内容完成标准
P0 契约与样例更新 F054/F055/F059 设计;冻结交付描述、运行档位清单/导入、离线依赖格式、状态与文件接口;建立 Python/Node/static 样例明确已实现与增量;Docker 全流程基线及故障点可重现;不以商业方案为开发前提
P1 平台存储和控制状态backend 附件、源码/产物/快照 API;任务/期望/执行事实持久化、路由与策略投影;manager/代理协议迁移无 manager MinIO 配置;禁止共享本地文件作为控制权威;多 API/worker 节点可读同一状态
P2 生命周期和数据可靠性独占任务、快照确认、SQLite 单活动发布、凭据分阶段切换、重启对账、代理最小权限和镜像manager 重启不直接关闭代理连接,新请求无需 manager 路由 RPC;失败不越权、不误迁移或误删数据
P3 应用包与完整镜像运行档位目录与默认选择、运行/准备镜像、离线包导入验证、Python wheelhouse/venv、Node 依赖、只读挂载和 launcher;完整镜像准入普通代码更新复用兼容依赖;缺包明确拒绝且补包后可重试;新节点可离线重建,v1 镜像路线兼容
P4 开源交付与升级manager/代理/运行镜像 CI 发布,Compose image 引用、一键安装/自检、离线清单、SDK/CLI 兼容、旧状态导入和运维文档无需现场构建 manager 服务镜像;完成真实安装、发布、升级、停用恢复和故障演练
P5 K8s 适配Python K8s 执行后端、Job/init、Service、RBAC、PVC、网络/镜像前置、维护执行体不改平台业务语义,分别验证 bundle 和完整镜像模式;将集群验证结果与 Docker 区分
+

P1 和 P2 是应用包默认发布前的基础。P3 中依赖管理与完整镜像可分别开发,但必须共用运行/数据契约;P5 作为独立交付阶段。是否先开放当前镜像路线的稳定版本、再开放应用包默认路线,由本次评审确认,不能把后续能力提前计入交付。

+

P4 中的 manager 镜像构建与发布作业可以提前接入,首个对客户交付的版本即应提供预构建镜像;阶段编号不表示先要求客户自行构建,再补正式镜像。

+

11.2 代码改动映射

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
模块主要增量
backend app_publish产物准备抽象、版本组合、兼容 manifest、预检/审批与上线计划、凭据切换、阶段结果及补偿
backend app_runtime文件/附件、离线产物导入、运行环境目录与默认映射、任务和事实、节点/所有权、路由/策略读接口、数据授权和审计
平台模型/迁移产物/快照引用和摘要、操作/generation/节点、只读投影元数据;MySQL/DM8 双方言
manager平台文件/状态客户端、两级依赖准备/缓存、挂载路径映射、执行适配、幂等生命周期、SQLite 维护和事实上报;删除 MinIO 与附件服务依赖
src/app-runtime-imagesPython/Node/static 运行镜像、匹配准备镜像、launcher、固定 UID/目录与兼容清单;该目录为目标新增
app-proxy路由源迁至平台投影、读身份和缓存/撤销处理;保留授权、头规范化、HTTP/WS 行为
egress-proxy独立精简镜像、平台策略读取、版本缓存、连接/目标限制;不依赖同机共享目录或 manager 存活
CLI / SDKv2 交付描述和状态跟踪;保持 SDK 存储语义,适配 backend envelope/身份和预览隔离
.github/workflows/ci.yml / release.ymlmanager 及代理多架构构建、Docker Hub 推送、版本聚合、提交/digest 核验;应用运行镜像的环境版本发布
Compose / 后续 K8s 材料正式 image 引用、开发 build override、控制网络、只读/专用卷、凭据、运行档位清单、离线基础依赖/补包和健康检查
+

11.3 验收集合

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
类别必须覆盖的场景通过标准
开源安装不安装商业 Gateway、不提供许可证可完整发布和使用;没有固定 10 个上线名额检查,实际资源足够时第 11 个应用不因授权被拒绝
包与隔离超限、路径逃逸、跨应用/租户文件、伪造任务和越权镜像档位在正确阶段拒绝,无跨路径写入/读取;新字段有明确版本
正常发布上传、扫描、准备、探活、审批、上线、浏览器/WS平台版本、执行事实和路由一致;临时资源回收
依赖与镜像纯代码修改、锁文件/安装脚本变化、原生依赖、基础镜像/架构变化、自定义环境、完整镜像环境 key 包含全部输入;变更正确失效;不会修改其他应用使用中的环境
挂载与启动manager 容器路径和宿主路径不同、重启、缓存清空、新节点、Node ESM 和只读目录代码、venv/node_modules 和数据各挂正确路径;有缓存不安装;缓存丢失从平台产物离线重建
服务镜像交付CI 两架构构建、release 聚合、正式 Compose 与离线镜像包固定版本/提交/digest 一致,正式配置没有 manager build;平台服务升级不悄然更换应用环境
断外网目标架构镜像、依赖、SDK、源码及快照接口新装和新增依赖均按离线闭包验证,不依赖开发机已有缓存
离线补包首次发布依赖命中/缺失、传递依赖遗漏、错误架构、内网源未同步、增量包导入缺包不入审批、不回退公网;导入且目标验证后可重试,manager 无 MinIO 连接
运行镜像配置新档位导入、改默认、旧 ID 修改、应用选择、新镜像缺 launcher 或不兼容仅授权变更生效;冻结版本不漂移;错误环境不放行,换镜像使依赖 key 正确失效
状态与多进程删除本地缓存、不同 API/worker 主机、重复任务、旧 generation从平台恢复一致状态,不依赖共享本地盘,不发生多次创建或过期覆盖
manager 故障超过原路由短缓存时间的 manager 重启/不可达已有代理连接不被 manager 退出直接关闭,新请求按平台权限及有效路由继续处理;独立测平台故障
SQLite停写、Backup API、上传响应丢失、DDL 失败、指定恢复、预览保存确认前不破坏性迁移;无两个活动写入者;生产数据不被预览或回退误覆盖
凭据候选失败、版本切换、撤权/停用、伪造头和 OBO 缺失老版本能力按切换契约处理,不能因旧容器存在就忽略旧令牌已被撤销的问题
网络与权限绕代理、UDP/IP 直连、私网解析、代理调用 intent、控制网络扫描安全规则实际生效;代理和应用无 Engine 管理能力
K8s 独立验证PVC 单写、维护任务、节点替换、预装镜像/仓库、RBAC在支持矩阵内真实验证;Docker 结果不代替集群交付
+

上表是实施后的验收要求,本次没有执行结果。文档阶段仅检查源码依据、方案一致性及离线 HTML 布局;正式开发按仓库 SDD 流程评审 spec/design/tasks,再实施和验证。

+

+

12 待评审事项与代码证据

+

12.1 需要定案的技术选择

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
编号事项本方案建议
D01交付阶段先补齐 Docker 存储/状态和数据可靠性,随后将应用包作为默认,镜像兼容及完整镜像并存
D02进程组合保留 manager、入口和出口三个进程;部署通过 Compose 简化,不以合并 PID 为目标
D03控制状态演进平台 SQL+Redis 投影,经 API 读写;明确修改原 manager 本地路由/策略权威的设计
D04应用 v2 描述与环境配置明确 bundle/image、运行档位清单导入、默认选择、自定义镜像准入、argv、data_mode 及版本组合;兼容 v1
D05SQLite 升级单活动维护窗口;备份持久确认;恢复需明确恢复点与停写策略,不承诺任意 DDL 零停机
D06预览与清理默认临时空库及隔离附件,TTL/并发由资源策略控制;生产副本另行授权
D07K8s 首发范围在独立阶段适配 bundle 和完整镜像;构建 OCI 只作为显式启用的兼容路线
D08离线支持矩阵与补包明确 OS/CPU、运行镜像、SQLite/原生库和依赖闭包;提供基础离线集合+补包导入,无仓库模式限定可控节点
+

全部开源、取消商业上线额度、留在 BiSheng 仓库、backend 存储边界,以及 manager 经 CI/CD 发布镜像并由正式 Compose 引用,是确定方向。技术评审只对上表及具体依赖/启动契约等实现细节作决策。

+

12.2 固定提交代码证据

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
编号固定提交中的位置核查内容
C01src/bisheng-cli/bisheng_cli/commands/deploy.py:54CLI 上传与轮询
C02src/bisheng-cli/bisheng_cli/packaging.py:110可重复打包与 SHA-256
C03src/backend/bisheng/app_publish/domain/schemas/app_manifest.py:147应用清单字段
C04src/backend/bisheng/app_publish/domain/services/package_service.py:61私有桶、解包与代码快照
C05src/backend/bisheng/core/config/app_runtime.py:45后端配置及包限制
C06src/backend/bisheng/app_publish/domain/services/publish_pipeline_service.py:92异步发布阶段与接收入口
C07src/backend/bisheng/app_publish/domain/services/precheck_service.py:156构建、预签名下载与临时探活
C08src/runtime-manager/runtime_manager/api/schemas.py:37编排 API 参数模型
C09src/runtime-manager/runtime_manager/builder.py:76模板、基础镜像和构建流程
C10src/runtime-manager/runtime_manager/docker_backend.py:124Docker API 协议与真实实现
C11src/runtime-manager/runtime_manager/templates/python3.11/Dockerfile.j2:42依赖层与源码层
C12src/runtime-manager/runtime_manager/templates/python3.11/entrypoint.sh.j2:27Python 启动入口
C13src/runtime-manager/runtime_manager/templates/node20/entrypoint.sh.j2:44Node 启动入口
C14src/backend/bisheng/app_publish/domain/services/publish_online_service.py:110审批通过、停用分支与迁移顺序
C15src/backend/bisheng/app_runtime/domain/services/app_state_service.py:283应用状态与上线意图
C16src/runtime-manager/runtime_manager/lifecycle.py:209容器参数、部署、路由切换与删除
C17src/runtime-manager/runtime_manager/config.py:57RTM 配置、路径和超时默认值
C18src/runtime-manager/runtime_manager/desired_state.py:229本地期望状态持久化
C20src/runtime-manager/runtime_manager/preview.py:18审批预览的数据库隔离
C21src/runtime-manager/runtime_manager/appdb.py:509SQLite 数据面、迁移和一致性快照
C22src/runtime-manager/runtime_manager/storage.py:147MinIO 附件句柄和应用隔离
C24src/app-proxy/app_proxy/clients.py:177入口鉴权和路由的双 RPC
C25src/backend/bisheng/app_runtime/domain/services/entry_authz_service.py:88入口权限及 OBO
C26src/app-proxy/app_proxy/headers.py:29身份头清理与注入
C27src/runtime-manager/runtime_manager/egress.py:444出站策略持久化和运行配置
C28src/runtime-manager/runtime_manager/egress_proxy.py:171独立出站转发进程
C29docker/docker-compose.yml:277四进程部署、网络、挂载和配置
C31src/bisheng-sdk/README.md:1SDK 的运行位置与三类功能
C32src/bisheng-sdk/bisheng_sdk/retrieve.py:40检索 API 与双重身份
C33src/bisheng-sdk/bisheng_sdk/_storage_remote.py:3SDK 附件服务地址
C34features/v3.0.0/059-k8s-runtime-backend/spec.md:108K8s 构建、仓库和 PVC 规格
C35src/runtime-manager/runtime_manager/auth.py:42内部 HMAC 的当前实现
C36src/backend/bisheng/app_publish/domain/services/capability_bus_service.py:384应用凭据和模型地址注入
C37src/backend/bisheng/app_publish/domain/services/version_service.py:138冻结版本与镜像引用
C38src/backend/bisheng/app_runtime/domain/services/orchestrator_client.py:134后端编排 RPC 客户端
C39src/runtime-manager/runtime_manager/admission.py:137资源容量准入
C40src/runtime-manager/runtime_manager/api/readonly.py:245运行环境自检
C41src/backend/bisheng/app_publish/api/endpoints/deploy.py:128发布 HTTP 接口及鉴权依赖
C43src/backend/bisheng/app_publish/domain/services/preview_instance_service.py:440预览参数与回收
C44src/runtime-manager/runtime_manager/probe.py:126临时镜像探活与回收
C45src/runtime-manager/runtime_manager/templates/node20/Dockerfile.j2:46Node 依赖和前端编译
C46src/runtime-manager/runtime_manager/api/appdb.py:27SQLite 数据管理接口
C48src/backend/bisheng/app_publish/domain/services/app_credential_service.py:134应用凭据重签与撤销顺序
A01features/v3.0.0/054-app-domain-runtime/spec.md:L88-L97F054 AC-14、AC-22:编排权限与运行服务重启目标
A02features/v3.0.0/054-app-domain-runtime/spec.md:L148-L153F054 AC-59、AC-60、AC-61:选装与独立启停
A03docs/constitution.md:L156-L180C8:多节点默认与共享控制状态持久化
A05src/app-proxy/app_proxy/clients.py:L281-L307当前路由缓存与 manager RPC 依赖
A06.github/workflows/ci.yml:L3-L54tag 构建触发、Docker Hub 认证、backend 构建入口;arm64 见同文件 L108
A07.github/workflows/release.yml:L3-L45release 触发和 backend 多架构版本聚合
A08src/runtime-manager/Dockerfile:L1-L25manager 自身依赖、服务启动入口及待修正的 socket/root 注释
A09src/runtime-manager/runtime_manager/builder.py:L76-L80现有基础镜像常量;渲染读取见 L129-L148,实际调用见 L404
+

12.3 官方资料

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
编号资料使用范围
R01SQLite Backup API在线一致备份和错误处理;本方案另加迁移前停写与持久确认
R02SQLite WAL单写入者与跨主机/网络文件系统限制
R03Kubernetes Persistent VolumesRWO/RWOP、CSI 和实际挂载约束
R04Kubernetes Images镜像拉取策略、预装节点与版本相关的凭据验证
R05Python 3.11 venv默认环境隔离、可执行脚本路径及不可通用搬运的限制
R06pip wheel从锁定输入准备可安装 wheel 产物
R07pip install本地 wheelhouse、无索引安装、二进制与哈希约束
R08npm ci锁文件一致性、冻结安装和安装参数约束
R09Docker bind mountsdaemon 主机路径语义与只读挂载
R10Docker Compose services已发布镜像引用及服务配置;本文片段仍是待实现目标
R11pip download离线收集包、目标平台参数及只能下载已有目标二进制的边界
+

历史参考:原商业化评审、原 Gateway 归仓方案。它们的商业化及 Gateway 迁移建议已经被本方案替代,历史代码核查结论可按固定提交追溯。

+
此 HTML 由同目录 Markdown 生成,正文以 Markdown 为维护依据。流程图已内嵌为 SVG,无在线渲染依赖。
源文 SHA-256:2fb280efb649aae0c6430b3cc418078e559cef8734e3e30fa402a1575a447c52
diff --git a/docs/reviews/vibe-app-open-source-technical-design.md b/docs/reviews/vibe-app-open-source-technical-design.md new file mode 100644 index 0000000000..47d3718bee --- /dev/null +++ b/docs/reviews/vibe-app-open-source-technical-design.md @@ -0,0 +1,1014 @@ +# Vibe 应用托管全开源技术方案 + +日期:2026-10-19 | 更新:2026-10-20 | 状态:待技术评审 | 范围:应用交付、发布、运行、存储、权限、离线部署与实施阶段 + +Vibe 应用托管作为毕昇开源功能,在 `bisheng` 仓库内继续开发和交付。复用现有 Python runtime-manager、app-proxy、egress-proxy、CLI 和 SDK,保留独立进程的权限及故障边界。应用通过平台统一发布和访问,编排服务通过 backend 获取产物、上传快照,平台统一管理附件和对象存储。 + +**本轮决定:全部开源,不设置商业授权和固定上线数量额度。** 原“免费同时在线 10 个应用”、闭源 runtime、Java 重写以保护代码、迁入 `bisheng-gateway` 等方案退出实施范围。上线仍受 CPU、内存、磁盘、临时任务并发等资源约束;平台现有用户、租户和应用权限继续生效。既有商业网关可按部署环境继续转发平台请求,但不是 Vibe 功能的安装或授权前置。 + +**推荐发布路线:** 以“预制运行镜像+版本化应用包+可复用依赖产物”为源码应用的目标默认路线,同时支持用户提交完整应用镜像;现有源码构建应用镜像的路线作为迁移基线及兼容方式保留。应用包、完整镜像和 K8s 的具体契约均为待开发能力,不按已有代码认定为已交付。 + +**服务镜像交付已确认:** runtime-manager 仿照 backend,由项目 CI/CD 构建并推送 Docker Hub,正式 Compose 引用已发布镜像;客户安装时拉取或离线导入镜像。应用基础运行镜像另行维护,不能用 runtime-manager 服务镜像运行用户应用。实现细节见 §3.4—§3.8,发布交付见 §9.4。 + +2026-10-20 补充:纯离线依赖来源、补包闭环及失败行为见 [§9.5 离线依赖准备与补包](#offline-dependencies);现有基础镜像位置和目标运行环境配置、替换步骤见 [§9.6 基础运行镜像配置](#runtime-image-config)。运行环境清单导入与离线依赖导入均为待开发能力。 + +本方案替代此前商业化评审和 Gateway 归仓实施方案中的产品与架构选择。2026-10-16 确认的存储边界继续有效:附件 API 迁到 backend;manager 保留本地 SQLite 备份、迁移和恢复,经 backend 下载源码、上传快照,不直接访问 MinIO。 + +## 阅读导航 + +- [1 评审基线与方案决策](#section-1) +- [2 开源仓库与运行架构](#section-2) +- [3 应用交付与运行契约](#section-3) +- [4 存储与共享状态](#section-4) +- [5 平台与 runtime 接口](#section-5) +- [6 发布上线流程](#section-6) +- [7 应用访问与平台能力](#section-7) +- [8 SQLite 持久化与恢复](#section-8) +- [9 Docker 与 K8s 交付](#section-9) +- [10 权限隔离与运行可靠性](#section-10) +- [11 实施阶段与验收](#section-11) +- [12 待评审事项与代码证据](#section-12) + + + +## 1 评审基线与方案决策 + +### 1.1 代码基线 + +2026-10-19 只读核对远端 `feat/3.0.0-vibe`,提交仍为 `736ef593f3fc6e1634008c842d2604304d6f70a6`。源码事实均以该固定提交为准;当前工作目录检出的是另一版本分支,本文通过 `git show` 核查,不把工作目录中的其他改动作为 Vibe 实现。 + +本轮完成静态核查和技术方案,尚未执行应用部署、离线安装、K8s 适配或故障恢复实测。下文按“当前基线”“目标方案”“后续扩展”标注能力状态。 + +| 事项 | 当前基线 | 本方案目标 | +| --- | --- | --- | +| 代码与语言 | Python manager、两个 Python 代理位于 BiSheng 仓库 | 全部继续开源,沿用 Python;不增加 Gateway 代码归仓或 Maven 重组任务 | +| 源码发布 | tar.gz → 平台模板构建应用镜像 → 探活 → 审批 → 上线 | 应用包+运行镜像作为目标默认;镜像构建兼容;用户完整镜像另有准入契约 | +| 运行后端 | Docker Engine API 经 socket proxy;K8s 仅有规格 | Docker 先完成可交付闭环;K8s 使用同一产物和生命周期协议另行适配 | +| 对象存储 | manager 预签名下载源码,直连 MinIO 处理附件/SQLite 快照 | manager 只经 backend 文件 API;SDK 附件直达 backend | +| 控制状态 | manager 本地 JSON、容器标签;出站策略使用共享本地文件 | 平台 SQL 持久化控制状态,Redis 做投影/缓存,本地只作可重建缓存 | +| 路由 | app-proxy 查询 manager,短缓存约 3 秒 | manager 上报真实就绪事实;backend 提供独立路由投影读接口,去掉请求对 manager 存活的依赖 | +| 上线数量 | 已查代码中的 Admission 检查 CPU/内存,不是商业授权 | 无商业上线名额;管理员按运行资源及任务并发设置容量策略 | + +依据:[C01] [C06] [C07] [C18] [C27] [C29] [C34] [C39] [A05]。 + +### 1.2 推荐决策 + +1. **保留三个服务进程。** manager 负责有编排能力的控制面,app-proxy 和 egress-proxy 负责流量转发;socket proxy 为额外的 Docker 基础组件。 +2. **平台统一存储和业务授权。** backend 管理附件、产物、快照、应用/租户权限及审计;manager 不读 backend 配置文件或平台业务数据库,不持 MinIO 主凭据。 +3. **共享控制状态落平台。** 遵守 constitution C8;跨进程读取的路由、出站策略、任务和期望状态不能依赖某台机器上的 JSON 文件。[A03] +4. **运行环境与应用版本分离。** 版本固定运行镜像 digest、代码包摘要、依赖产物摘要、启动/健康契约;代码小改可以复用依赖,不要求每次生成 OCI 应用镜像。 +5. **SQLite 保持单活动写入。** 升级前停写、必要时备份并等待平台持久确认;不承诺 SQLite 结构变更零停机。 +6. **按可验证阶段交付。** 先补齐 Docker 当前链路和存储/状态边界,再上线应用包及完整镜像方式;K8s 不与首轮 Docker 修整混为同一个完成标记。 + +### 1.3 功能范围 + +开源交付保留应用清单与版本、部署预检、审批和临时预览、上线/停用/恢复、统一访问入口、SDK 能力、附件、SQLite 数据管理、运行日志及环境自检。本文主要调整运行实现和可靠性;既有业务权限继续由 backend 判定。[C15] [C25] [C40] [C43] [C46] + +SQLite 数据页与 MCP 数据工具仍经 backend 完成 owner 收窄和审计,再调用 manager 的表清单、结构、分页行读取、单行修改及导出接口;不接受任意 SQL,也不让 backend 挂卷或直开数据库。目标 bundle/image 模式都要说明是否支持该数据契约,不将任意用户镜像中的数据库自动识别为平台可管理数据。[C46] + +托管层继续允许按部署开关选装。未安装时隐藏相关入口,既有平台功能应正常启动;这属于部署形态,不是许可证判定。`app_runtime.enabled` 与开放能力层开关保持独立,CLI/SDK 所需开放接口由安装自检明确核对。[C05] [A02] + + + +## 2 开源仓库与运行架构 + +### 2.1 目录组织 + +```text +bisheng/ # 现有 Git 仓库 +├── src/backend/bisheng/ +│ ├── app_publish/ # 接收、预检、审批、版本、上线协调 +│ └── app_runtime/ # 状态、权限、运行事实、数据及文件接口 +├── src/runtime-manager/ +│ ├── runtime_manager/ # Python 编排、执行适配、SQLite 本地操作 +│ ├── Dockerfile # manager 服务镜像 +│ └── Dockerfile.egress # 目标:代理专用精简构建入口 +├── src/app-proxy/ # 应用 HTTP/WS 入口,独立服务镜像 +├── src/app-runtime-images/ # 目标:Python/Node/static 运行环境和准备镜像 +├── src/bisheng-cli/ # 打包、部署、状态和日志 +├── src/bisheng-sdk/ # 应用内身份、检索及附件句柄 +├── docker/ # Compose、nginx、网络及安装材料 +│ └── runtime-profiles.yaml # 目标:管理员导入 backend 的运行环境清单 +└── features/v3.0.0/ # F054/F055/F059 等规格与设计 +``` + +上述业务目录已存在;`src/app-runtime-images/`、`docker/runtime-profiles.yaml`、`Dockerfile.egress` 及平台状态/产物接口为目标增量。首轮不为开源交付重排整个仓库,也不强制新建共享 Python 工程;代理依赖可按需抽取最小配置与策略读取模块,避免拖入全部编排依赖。 + +### 2.2 进程职责 + +| 服务或组件 | 负责内容 | 资源访问边界 | +| --- | --- | --- | +| backend / Celery | 元数据、用户权限、审批、版本、附件、产物传输、快照保存;运行期望与事实投影 | 平台 SQL、Redis、MinIO、统一权限模块;调用 manager,不调用 Docker/K8s SDK,不挂应用数据卷 | +| runtime-manager | 准备产物、容量判断、容器/Pod 生命周期、探活、对账、自愈、预览、日志、SQLite 数据管理与备份/迁移/恢复 | Engine API 或 K8s API、受控数据卷、backend 内部接口;不直连 MinIO 或平台 SQL | +| app-proxy | 会话授权结果消费、可信头注入、HTTP/WS 转发、预览和拒绝页面 | backend 授权和路由读接口、应用网络;无 Engine 管理身份 | +| egress-proxy | HTTP/CONNECT、应用出站身份、目标域名和解析地址校验、连接限制 | backend 策略只读接口、允许的出口;不提供编排接口,不持 manager 管理密钥 | +| docker-socket-proxy | 限制并转发 Docker Engine API | 持 Docker socket;只在专用控制网络可达,不开放宿主端口 | +| 应用容器 | 执行业务、页面/API、SQLite、SDK/模型能力 | 本 app 数据卷、平台应用 API、受控出站;不接触控制面密钥和其他应用卷 | +| CLI / SDK | CLI 调平台部署 API;SDK 运行在应用内部 | 仅获得对应用户/应用/访问者权限,无宿主或集群管理能力 | + +app-proxy、egress-proxy 不因开源而必须合并,也不因开源就失去进程隔离价值。manager 升级和构建任务不应直接带走应用入口/出口连接;原 F054 AC-22、AC-60 已定义相应的重启及独立启停目标。[A01] [A02] + +egress 可以继续与 manager 共用源码工程,但使用独立入口和镜像,且只读自己的策略缓存,不挂整个应用数据根目录。独立镜像用于缩小依赖和运行权限;本方案不以隐藏源码作为安全手段。 + +### 2.3 网络与权限 + +Docker 目标至少区分入口/平台网络、应用受限网络、构建网络和 Engine 控制网络。manager 与 socket proxy 使用控制网络,backend 和两个代理不因共享默认网络而自动获得 Engine API 可达性。应用之间的可达性也需要网络规则限制,不能仅凭“没有 publish 宿主端口”宣称绝对不可直连。[C16] [C29] + +对外复用平台 nginx 的 `/apps/{slug}/`,不为每个应用开放宿主机端口。所有应用可以监听容器内部相同端口,通过各自内部地址区分。管理 API 只供已认证平台服务使用;egress 监听端口只用于正向代理协议,不能混入 intent API。 + + + +## 3 应用交付与运行契约 + +### 3.1 当前源码包 + +```text +leave-helper/ +├── bisheng-app.yaml +├── requirements.txt # 可声明 bisheng-sdk +├── Procfile # 可选,指定 web 启动命令 +├── main.py +├── templates/ +└── static/ +``` + +CLI 当前生成 tar.gz,以 multipart `package` 上传 `/api/v2/apps/deploy`;支持 `python3.11`、`node20`、`static`。源码包默认上限 50 MiB、展开 200 MiB、20,000 条目,实际值由平台配置及 deploy-limits 接口决定;拒绝路径逃逸、链接和特殊设备条目。当前 manifest 没有完整镜像或通用 command 字段。[C01] [C02] [C03] [C04] [C05] + +SDK 是应用依赖,不是另一台服务;应用框架、第三方包、前端资源由项目及构建产物提供。`Procfile`、Python/Node 约定入口和平台 entrypoint 共同确定启动方式,不要求用户以 manager 服务镜像作为应用镜像。[C09] [C12] [C13] [C31] + +### 3.2 三种发布路线 + +| 路线 | 发布和启动方式 | 定位 | +| --- | --- | --- | +| 运行镜像+应用包 | 预制运行镜像;平台准备不可变代码/前端产物及兼容依赖包,启动时只物化已确认产物 | 目标默认源码路线;免去每次代码发布生成 OCI 镜像,但仍管理依赖和编译 | +| 用户完整应用镜像 | 提交镜像 digest、容器内启动/健康/端口契约;跳过平台源码构建 | 目标支持方式;适合复杂系统库和用户已有 CI,仍须遵守资源、UID、网络及卷限制 | +| 当前源码构建应用镜像 | 平台 Dockerfile 模板安装依赖、复制代码并生成应用镜像 | 当前已实现;迁移基线和兼容方式,复杂环境仍可使用 | + +运行镜像中可以预装常用库,但版本必须明确。新增 OS 软件或不兼容解释器时需新的运行镜像/环境档位,或改用完整镜像;不能通过预装“常用库”保证任意应用离线运行。 + +应用包路线下,依赖变更会触发依赖准备;纯代码变更可复用已验证依赖。缓存键至少覆盖 OS/CPU、解释器与 ABI、运行镜像 digest、锁定依赖和安装配置。Python 原生扩展、Node native 模块必须在匹配环境中准备,不能直接复用开发机虚拟环境。 + +TypeScript/Vite 等前端编译仍需执行并保存输出,静态站点可使用固定 nginx 运行镜像。准备任务不得等到每次应用正常启动时再访问公网安装依赖。当前实现只有 Docker 分层缓存,没有应用级“比较依赖后跳过整次构建”的完整功能。[C09] [C11] [C45] + +### 3.3 目标 manifest 与版本模型 + +建议保持 v1 兼容,新增显式的 v2 执行描述。下例仅为协议设计草案,字段不能直接用于当前 v1 接口。 + +```yaml +manifest_version: 2 +name: 请假知识助手 +slug: leave-helper +runtime: python3.11 +port: 8080 +tier: light +execution: + type: bundle + runtime_profile: python311-standard-v1 + command: [python, -m, uvicorn, main:app, --host, 0.0.0.0, --port, "8080"] +data_mode: sqlite +health: + path: /healthz +``` + +`runtime_profile` 指向管理员登记的运行环境及镜像 digest;用户自定义运行镜像通过同一登记/验证流程接入。完整镜像模式使用 `execution.type=image` 和镜像 digest,默认采用镜像 entrypoint,也可按契约覆盖容器内 argv。所有命令只在受限应用容器内执行,不转成任意宿主 shell。 + +版本记录需要冻结:源码/前端产物摘要、依赖产物格式/摘要与完整锁定结果、运行镜像 digest 或完整镜像 digest、启动 argv、端口、健康契约、资源档位、能力声明、数据库 schema 及出站声明。`image_ref` 一个字段不足以表达应用包版本。完整镜像不上传 MinIO;平台保存其引用和运行契约。 + +源码包继续采用严格安全解包。依赖产物是平台在受控环境产出的专门归档,需要校验路径和链接目标;不能直接接受任意用户虚拟环境 tar 包,也不能无条件沿用源码包拒绝所有链接的规则,导致 Node 等依赖布局被破坏。 + +### 3.4 应用包路线的实现分层 + +每个应用版本由“运行镜像+代码产物+依赖产物+启动契约”组成。manager 调用 Engine API 创建准备容器和业务容器,不把应用代码安装进自己的 Python 环境,不为每次代码修改提交一个新镜像。 + +| 层次 | 内容与发布者 | 更新频率与存储 | +| --- | --- | --- | +| manager 服务镜像 | 平台编排 Python 代码及其服务依赖;项目 CI 发布 | 随平台版本发布到 Docker Hub,Compose 引用;不包含用户应用 | +| 应用运行镜像 | Python/Node/nginx、固定系统库、非特权用户和平台 launcher;项目 CI 发布或管理员登记 | 环境升级时发布,冻结实际 CPU 架构对应的镜像 digest;执行节点提前加载/拉取 | +| 准备镜像 | 与运行环境匹配的解释器、包工具、必要编译器/头文件 | 平台发布并固定 digest;只用于隔离准备任务,不将工具链带入普通应用容器 | +| 代码产物 | 安全解包后的源码、必要的前端编译输出、冻结清单 | 每版本不可变,经 backend 存 MinIO、下载至执行节点 | +| 依赖产物 | Python wheelhouse 与安装锁文件,或匹配环境的 Node 依赖归档及元数据 | 兼容键命中时复用,经 backend 持久化;本机安装环境是可重建缓存 | + +应用运行镜像可以携带常用 wheel 文件,帮助离线依赖准备;Python 默认独立 venv 不继承基础解释器的全局 site-packages。不能一边隔离依赖,一边假定基础镜像中 `pip install` 的全局包会自动出现在每个应用环境中。建议把常用包做成版本明确的离线 wheel 集合,由依赖准备过程选用;经验证的固定环境档位也可以直接命中同一依赖组合。[R05] + +目标执行顺序: + +```mermaid +sequenceDiagram + participant Worker as 发布 worker + participant Manager as runtime-manager + participant API as Backend 产物 API + participant Prep as 隔离准备容器 + participant App as 正式应用容器 + Worker->>Manager: 1 提交运行档位、锁文件和固定源码产物 + Manager->>API: 2 查询兼容依赖产物或下载源码和锁文件 + Manager->>Prep: 3 未命中时用批准包源或已导入离线包准备依赖 + Prep-->>Manager: 4 返回 wheelhouse 或 Node 依赖及校验结果 + Manager->>API: 5 上传新产物,冻结摘要和环境兼容元数据 + Manager->>Prep: 6 本节点环境未命中时,用依赖产物离线安装及校验 + Manager->>Manager: 7 物化代码、只读环境,固定版本引用 + Manager->>App: 8 指定运行镜像、挂载、环境变量和容器内 argv + App-->>Manager: 9 launcher 执行业务进程,通过健康检查 +``` + +这是单次准备与启动的内部细化图,预检、审批和正式上线的外层顺序仍按第 6 节执行。缓存命中可跳过对应准备步骤;跨节点第一次运行需要下载产物并建立节点缓存,不依赖另一个 manager 的本地目录。 + +### 3.5 Python 依赖的安装与复用 + +**第一层:可持久复用的 wheel 产物。** 推荐应用提交锁定依赖及完整传递依赖;只提交 `requirements.txt` 时,平台首次准备需生成并冻结解析结果,后续复用必须引用该固定结果。显式刷新依赖才重新解析,不能让同一个已批准版本随包源变化得到另一套库。 + +隔离准备容器在匹配目标 OS/CPU、Python ABI 和系统库的环境中下载或构建 wheels。纯 Python wheel 也记录目标兼容性;原生扩展要在匹配工具链中构建,并在目标运行镜像中验证导入和动态库。wheelhouse 包含整个运行依赖闭包、`bisheng-sdk`、安装清单和各文件摘要,缺少任意依赖不得退回公网补装。[R06] + +以下是联网准备机或可访问内网包源的准备容器中的示意命令;工具版本、包源、锁文件和网络权限由平台准备计划指定,不在 manager 服务容器执行。纯离线现场必须先具备相应包文件和构建依赖,不能通过该命令凭空取得新包,来源与导入过程见 §9.5: + +```sh +python -m pip wheel --wheel-dir /out/wheelhouse -r /input/requirements.resolved.lock +``` + +源码构建出的 wheel 与原始源码归档的摘要可能不同。平台应据最终 wheel 内容生成 `requirements.runtime.lock`,固定全部包版本及 wheel 哈希,并记录构建来源;不能把源码包的哈希直接当成新 wheel 的哈希。SDK 按固定版本从平台指定分发源取得并核对摘要,不依赖公共源中恰好出现同名包。 + +**第二层:执行节点的已安装环境缓存。** manager 为 `env_key` 分配临时目录,挂载进一次性准备容器的 `/opt/venv`;在与正式应用相同的运行镜像中创建环境并离线安装。这里不需要编译器:所有需要的 wheel 已在第一层准备完毕。 + +```sh +python -m venv /opt/venv +/opt/venv/bin/python -m pip install --no-index --find-links=/deps/wheelhouse --only-binary=:all: --require-hashes -r /deps/requirements.runtime.lock +/opt/venv/bin/python -m pip check +``` + +`--no-index` 配合本地 wheelhouse 完成安装,运行锁文件禁止残留 VCS、外网 URL 和开发机绝对路径;需要本地项目依赖时,先做成 wheel 并计入内容摘要。准备容器同时验证关键 import,最终用真实应用预检验证启动;`pip check` 通过不等于整个应用就绪。[R07] + +验证成功后以原子方式发布节点缓存完成标记。正式容器把该目录只读挂到相同的 `/opt/venv`,设置 `PATH` 并执行其 Python;不再运行 pip。Python 官方指出 venv 通常不可移动,本方案因此持久保存 wheel 及锁文件,节点丢缓存时就地重建 venv,不把开发机或其他节点的 venv 打包搬运。[R05] + +`env_key` 至少包含租户缓存范围、平台解析结果/依赖哈希、目标架构、运行镜像实际 digest、Python ABI、准备镜像/工具版本和安装策略。同租户多个应用依赖完全一致时可只读共用;不同依赖或镜像得到不同目录。默认不跨租户共享私有依赖产物和命中信息。 + +并发准备同一个 key 时只能有一个有效发布结果,失败临时目录不标完成;运行中的缓存不允许原地 `pip install` 或升级。`PYTHONDONTWRITEBYTECODE=1` 避免把字节码写回共享环境,需要运行缓存的库使用 app 自己的 `/tmp` 或指定数据子目录。 + +### 3.6 Node 与静态应用的依赖处理 + +Node 使用固定 Node/npm 版本,基于 `package.json`、`package-lock.json` 和影响安装的配置执行 `npm ci`。该命令按锁文件安装且不改锁文件;锁文件不匹配时拒绝,而不是现场自动修正。[R08] + +平台准备并验证对应架构的依赖归档。运行依赖只读挂到 `/workspace/app/node_modules`,代码挂到 `/workspace/app`,保留正常 Node/ESM 相对解析路径;不能仅依赖 `NODE_PATH` 将所有依赖放在任意外部目录。代码物化时由平台预留依赖挂载点,不能使用用户传入的宿主路径或不可信链接。 + +需要 TypeScript/Vite 构建时,在私有可写准备工作区安装构建依赖、执行构建并保存输出;不能写入正在被其他应用使用的依赖缓存。运行版本只保留实际所需依赖和输出。若安装脚本、workspace、本地依赖或原生模块构建读取应用源码,相关源码摘要也必须计入 key;此时“锁文件没变”并不足以跳过准备。 + +静态应用将已生成的 HTML/CSS/JS 产物挂到固定 nginx 运行镜像的站点目录,按约定端口提供服务。无需 Node 运行时的静态站点不启动 Node;纯静态资源没有依赖时也不生成空的依赖环境。特殊系统库或不能满足只读契约的应用使用登记过的自定义运行环境或完整镜像模式。 + +### 3.7 代码挂载和启动契约 + +Docker 首轮采用宿主内容目录+只读 bind mount。manager 通过 backend 流式下载代码包到临时目录,验证 app/version 归属、大小和摘要,再安全解包并原子发布;被运行容器使用的版本目录不做原地覆盖。文件大小写、可执行位、依赖链接及 UID/GID 一并校验。 + +以当前默认数据根为例,目标布局如下;`cache/` 下全部可从平台产物重建,SQLite 目录按独立持久化策略管理: + +```text +宿主机 /opt/bisheng/app-data/ manager 容器内 /app-data/ +├── cache/releases/{app_id}/{version_id}/app/ # 代码、前端输出 +├── cache/envs/{tenant_scope}/{env_key}/venv/ # 本节点安装好的 Python 环境 +├── cache/node-deps/{tenant_scope}/{env_key}/node_modules/ +└── apps/{app_id}/data/ # 目标 SQLite 持久目录 +``` + +manager 看见的路径与交给宿主 Engine 的路径必须按配置根映射;本例 `RTM_DATA_ROOT=/app-data`,`RTM_HOST_DATA_ROOT=/opt/bisheng/app-data`。不能把仅存在 manager 镜像可写层中的目录当作应用 bind 源。Docker bind 的源路径由 daemon 所在主机解释,因此首轮要求 manager 对应同机 Engine;远程 Engine 须先提供该执行节点自己的产物物化机制。[C17] [R09] + +Python 应用实例的关键 Engine 参数示例如下。它是目标 DTO 示意,并非完整安全配置;实际创建仍须包含资源、应用网络、日志、只读、卷及可信标签约束: + +```json +{ + "Image": "dataelement/bisheng-app-python@sha256:", + "WorkingDir": "/workspace/app", + "Entrypoint": ["/usr/local/bin/bisheng-launch"], + "Cmd": ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"], + "User": "10001:10001", + "Env": [ + "PATH=/opt/venv/bin:/usr/local/bin:/usr/bin:/bin", + "VIRTUAL_ENV=/opt/venv", + "PYTHONDONTWRITEBYTECODE=1", + "PORT=8080" + ], + "HostConfig": { + "ReadonlyRootfs": true, + "Binds": [ + "/opt/bisheng/app-data/cache/releases/app-example/version-example/app:/workspace/app:ro", + "/opt/bisheng/app-data/cache/envs/tenant-example/env-example/venv:/opt/venv:ro", + "/opt/bisheng/app-data/apps/app-example/data:/data:rw" + ], + "Tmpfs": {"/tmp": "rw,nosuid,nodev,size=67108864"} + } +} +``` + +`bisheng-launch` 是目标运行镜像内的平台启动入口:校验必需目录和版本材料、设置有限环境后用 `exec` 启动 `Cmd` 对应业务进程,使其接收停止信号。它不联网安装依赖、不运行宿主命令、不启动 manager。manifest 显式 argv 优先;兼容的 `Procfile` 和语言默认入口在发布准备阶段解析成冻结启动契约,再由运行镜像消费。 + +应用在容器内监听 `0.0.0.0:8080`;不同容器可重复使用该端口,不配置每应用宿主 `PortBindings`,app-proxy 使用内部地址访问。`/data` 的所有权由受控维护步骤准备,普通应用不能获得整棵缓存或其他应用数据。重启复用同版本目录;新版本使用新目录和新实例,数据兼容与切换按第 6、8 节处理。 + +### 3.8 何时重装、复用与清理 + +| 事件 | 依赖和代码处理 | +| --- | --- | +| 仅普通业务代码变化 | 创建新代码版本;完整兼容 key 不变则复用依赖产物和本节点环境;仍执行必要前端编译和应用探活 | +| 依赖锁定结果/安装相关源码变化 | 新依赖 key;准备新产物及新环境,旧环境保持不可变 | +| 同一版本应用重启 | 有效本地缓存直接挂载,不执行 pip/npm 安装,不重新解析依赖 | +| 切到新节点或删除缓存 | 从 backend 下载冻结产物;Python 用 wheelhouse 离线重建环境,Node 按验证契约物化依赖;再启动 | +| 切换基础镜像、架构或 ABI | 按新兼容 key 重新准备与验证,不能凭 `requirements.txt` 相同跨架构复用 | +| 更新 runtime-manager 服务镜像 | 只升级编排服务;已有应用版本的运行镜像、依赖和代码引用不随之变化 | +| 清理与回退 | 按当前实例、审批、运行操作及回退窗口引用回收;节点缓存可重建,平台不可变产物保留策略单独管理 | + +这一方案省去的是每次发布的 OCI 应用镜像构建/推送。首次安装、新依赖和新节点仍有依赖准备成本;命中的是经验证的完整环境组合,不能把 package manager 的下载缓存命中等同于应用运行环境已经可用。 + + + +## 4 存储与共享状态 + +### 4.1 数据归属 + +| 数据 | 权威存储及目标访问方 | 本地用途 | +| --- | --- | --- | +| 应用、租户归属、权限关联、版本和审批 | 平台 MySQL/DM8;backend/Celery 经领域服务和仓储读写 | manager 不复制平台用户/权限模型 | +| 发布/运行操作、期望版本、节点归属、阶段和幂等结果 | 平台 SQL;manager 通过内部接口接收任务和上报结果 | 本地任务缓存可重建,不是另一份可独立修改的权威 | +| 执行事实、就绪版本和路由投影 | manager 产生真实事实,backend 经归属/generation 校验持久化;Redis 加速读取 | app-proxy 内存缓存可在有效期内使用,不共享宿主 JSON | +| 出站策略及策略代次 | 平台 SQL 保存确认后的声明与派生策略,Redis 投影;egress 经只读 API 获取 | 代理缓存版本化快照,重启可重新获取;不以共享文件为唯一来源 | +| 运行环境档位及默认映射 | 管理员从清单导入,backend 校验并存平台 SQL;执行计划冻结具体修订与镜像 | YAML 是配置导入材料,manager 不以某台机器的文件作为档位权威 | +| 源码和代码/依赖产物 | MinIO 私有 `bisheng-apps`;backend 存取和分发 | manager/准备任务按内容摘要下载到受控缓存,挂载给应用只读 | +| 附件 | 同一私有桶;应用 SDK → backend → MinIO | 应用不拿 MinIO 账号;manager 不经过附件数据流 | +| SQLite 快照 | 同一私有桶及平台快照元数据;manager 本地生成后上传 backend | 临时一致副本、tar 和导出文件;持久确认后清理 | +| 应用 SQLite | 应用专属持久卷,单活动应用使用;manager 在受控环境执行备份/迁移 | Docker 本机 bind 或 K8s PVC,是显式的应用数据持久化,不是平台跨服务控制状态 | + +constitution C8 要求跨进程共享状态的权威放在 SQL、Redis 或 MinIO。[A03] 当前 manager 的 `desired-state.json` 和 egress 策略文件是单机实现,应迁移为节点可重建缓存;本方案不继续把共享本地文件扩展为多节点控制协议。[C18] [C27] + +应用 SQLite 延续原规格的单应用持久卷约束,不承接平台路由、权限、任务或跨节点共识。它的数据恢复与节点绑定需在 F054/F059 设计中明确,不能将该局部存储方式推广到平台业务控制状态。 + +### 4.2 对象路径与生命周期 + +可继续使用现有私有桶和对象布局;无需新建 MinIO 实例或每应用一个桶。目标接口对 manager 隐藏 bucket/key,以下路径只由 backend 内部管理。[C04] [C21] [C22] + +```text +bisheng-apps/ +└── apps/{app_id}/ + ├── versions/{version_id}/code.tar.gz + ├── attachments/{relative_key} + ├── db-snapshots/{snapshot_id}.tar + └── artifacts/{artifact_id}/... # 目标扩展 +``` + +当前 SQLite 快照使用时间戳文件名;目标建议由平台分配快照 ID 并关联任务,避免并发或重试依赖秒级命名。旧路径可按元数据兼容读取;内容摘要、大小和产物归属应独立记录。 + +| 动作 | 代码/镜像与产物 | SQLite 和附件 | 运行状态 | +| --- | --- | --- | --- | +| 停用 | 保留版本与可恢复产物 | 保留 | 平台禁止入口,停止执行并撤销出站主体;失败可重试 | +| 更新 | 新增不可变版本,复用兼容依赖缓存 | 沿用本 app 数据,必要时迁移;附件命名空间保持 | 新版本就绪后发布路由事实,旧版本按发布策略回收 | +| 审批预览 | 使用冻结版本,临时执行 | 临时空库/隔离附件,不挂生产数据 | 独立 session、策略、TTL 和并发限制 | +| 删除 | backend 清理或按策略保留远端资产;共享依赖按引用回收 | manager 仅按明确指令清本地卷;backend 清附件和快照 | 先停用并作删除标记,再幂等清理;不是跨系统单一原子事务 | +| 代码回滚 | 选定仍可用的历史产物组合 | 不自动恢复数据库 | 先检查数据库兼容性;数据恢复是独立受控动作 | + +缓存、临时任务和孤儿对象定期清理;被在用、回退窗口或审批引用的产物不得回收。大包采用流式传输和有界临时空间;源码、依赖产物、附件、快照分别设置大小和保留策略,不能把源码 50 MiB 默认值套到所有产物。 + + + +## 5 平台与 runtime 接口 + +### 5.1 三组内部协议 + +| 协议 | 调用方 | 主要内容 | 校验方与副作用 | +| --- | --- | --- | --- | +| 运行意图与操作查询 | backend/Celery → manager | 准备、部署、停止、销毁、探活、预览、SQLite 操作;operation ID、app/version、generation | manager 验服务身份、任务归属、重复请求和节点所有权;操作 Engine/数据卷并上报事实 | +| 产物与快照 | manager/准备任务 → backend | 内容下载、摘要校验、快照上传/状态查询/恢复下载 | backend 校验任务授权和范围,访问 MinIO;不返回 MinIO URL 或 302 跳转 | +| 控制状态与只读投影 | manager → backend 上报;两个代理 → backend 读取 | 任务、期望、心跳、实例、路由、出站策略及撤销代次 | backend 用 SQL CAS/幂等记录和 Redis 投影;代理只读,不获得编排身份 | + +现有 `/v1/intents/*`、构建查询、route、db、logs 接口可以作为兼容入口,但目标字段通过显式协议版本演进。当前 build 使用 `code_url/code_object_key`,后续改为平台产物描述;当前 deploy 只含 `image_ref`,后续需要执行产物组合。不得直接改变返回形态而不升级 CLI/worker/代理调用者。[C08] [C38] [C41] + +建议的内部接口能力如下,路径和 DTO 在设计评审后冻结,当前代码尚未提供这些目标接口: + +| 接口能力 | 关键参数的产生与消费 | +| --- | --- | +| `artifacts/{id}/content` 下载 | backend 建立 artifact ID、大小、摘要与任务授权;manager 或准备任务消费并校验 | +| `snapshots/{id}/content` 上传/恢复下载 | backend 分配 snapshot ID 和目的;manager 提交一致副本或在恢复任务中下载;backend 校验摘要并持久确认 | +| 运行操作创建/查询及期望读取 | backend 创建操作和冻结计划;manager 取得所分配节点/应用任务;重试使用相同 operation ID 和 payload hash | +| 执行事实与路由发布 | manager 上报真实就绪版本、可达地址、generation、owner epoch;backend 校验并形成路由投影 | +| 代理路由和策略快照读取 | backend 提供节点/应用范围内的已确认结果及代次;代理持专用只读身份,按版本刷新 | +| 应用附件 API | SDK 使用 app 范围 endpoint/token;backend 判断应用、租户及相对路径,返回内容和元信息 | + +### 5.2 产物和运行意图示例 + +以下 JSON 为目标协议示例,不可原样发送到当前 manager schema;摘要和镜像值均为占位符。 + +```json +{ + "protocol_version": 2, + "operation_id": "op-example", + "app_id": "app-example", + "version_id": "version-example", + "node_id": "runtime-node-a", + "generation": 7, + "execution": { + "type": "bundle", + "runtime_image": "bisheng/app-python@sha256:", + "code": {"artifact_id": "code-example", "sha256": ""}, + "dependencies": {"artifact_id": "deps-example", "format": "python-wheelhouse-v1", "sha256": ""}, + "command": ["python", "main.py"], + "port": 8080 + }, + "resources": {"cpu": 0.5, "memory_mb": 1024}, + "data_mode": "sqlite" +} +``` + +backend 提供应用附件句柄、平台能力地址和短期任务授权,manager 负责受保护环境变量覆盖及注入。服务管理密钥和文件传输任务授权不进入应用环境、普通日志或公开版本记录。 + +### 5.3 认证与传输 + +内部 HMAC/服务认证用于确认服务身份和请求完整性,继续保留,与商业许可证无关。新协议增加时间窗口、nonce 或等效防重放机制,以及 app/任务/动作范围;文件传输按可信内容摘要签署并在接收时验证,不为了签名将大文件全部读入内存。原 HMAC 只覆盖方法、路径和 raw body,不能按已有代码宣称具备这些新增约束。[C35] + +应用运行凭据代表应用;OBO 凭据代表本次访问者;附件句柄仅对应应用附件空间;构建/快照任务授权仅对应某个操作。四者不可互换,代理读权限也不能调用 runtime intent。 + +SDK 附件 API 保持应用使用方式,但需处理地址、令牌校验权威、响应 envelope 和错误语义的兼容。当前 SDK 按 manager 的错误信封解析,不能仅改 endpoint 就宣布迁移完成。[C22] [C33] + + + +## 6 发布上线流程 + +### 6.1 上传与预检 + +```mermaid +sequenceDiagram + participant CLI as CLI + participant API as Backend API + participant Worker as Celery worker + participant Manager as runtime-manager + participant Engine as 准备任务 / Engine + participant Store as MinIO + CLI->>API: 1 上传源码包或完整镜像描述 + API->>API: 2 鉴权、归属、大小、清单和安全校验 + API->>Store: 3 保存不可变源码或清单产物,元数据存平台 SQL + API->>Worker: 4 记录 deployment 并提交预检任务 + API-->>CLI: 5 返回 deployment_id 与查询入口 + Worker->>Worker: 6 扫描密钥、能力与数据库变更 + Worker->>Manager: 7 提交有幂等标识的产物准备任务 + Manager->>API: 8 请求固定源码或依赖产物内容 + API-->>Manager: 9 流式返回文件;校验摘要 + Manager->>Engine: 10 复用依赖或准备编译产物 + Manager->>API: 11 上传准备产物并登记兼容元数据 + API->>Store: 12 保存代码、依赖与静态产物 + Manager->>Engine: 13 用冻结产物组合启动临时实例探活 + Manager-->>Worker: 14 返回产物组合、结果和日志 + Worker->>Worker: 15 审批准入;写版本,失败补偿取消审批 +``` + +这是目标流程:应用包模式准备代码/依赖,镜像兼容模式在第 10 步构建应用镜像,完整镜像模式检查镜像契约并跳过源码依赖准备。三者均执行相应扫描/准入及启动预检,不能用“用户提供镜像”跳过运行约束。 + +当前实现已具有源码接收、扫描、模板构建和临时探活;使用短期 MinIO 预签名 URL 下载源码,附件/快照直连也尚未迁移。版本写入的真实顺序是先提交审批准入,再插入版本,失败时尝试取消审批;本方案继续保持这一已查明语义。[C06] [C07] [C37] [C44] + +### 6.2 审批后上线与迁移 + +```mermaid +sequenceDiagram + participant Publish as 发布执行服务 + participant Backend as Backend API + participant Manager as runtime-manager + participant Engine as Docker / K8s + participant App as 应用实例 + Publish->>Backend: 1 接收审批结果,冻结本次上线计划 + Publish->>Manager: 2 检查资源并取得应用独占操作权 + Manager->>Backend: 3 确认任务、期望代次及节点归属 + Manager->>App: 4 SQLite 场景停写并隔离旧执行体 + Manager->>Manager: 5 Backup API 生成迁移前一致快照 + Manager->>Backend: 6 上传快照并等待持久确认 + Backend-->>Manager: 7 对象和元数据保存成功,返回 snapshot_id + Manager->>Manager: 8 执行事务化、可重试的本地迁移 + Publish->>Manager: 9 提交冻结产物及应用能力配置 + Manager->>Engine: 10 再验容量、挂卷、启动正式实例 + Manager->>App: 11 执行就绪检查 + Manager->>Backend: 12 上报就绪事实和待发布路由代次 + Backend->>Backend: 13 CAS 保存执行事实并更新路由投影 + Backend-->>Manager: 14 确认可供代理读取的路由版本 + Manager->>Engine: 15 按策略回收旧执行体和临时资源 + Manager-->>Publish: 16 返回操作结果,平台完成状态和审计 +``` + +迁移快照和产物的 MinIO 存取均由 backend 执行,图中第 7 步的确认包含对象内容及平台 SQL 元数据。首次发布尚无数据库、或无需快照的兼容性迁移,可以按明确策略跳过第 5—7 步,不生成无意义空快照。 + +| 阶段 | 执行方及关键输入 | 状态/存储副作用 | 失败处理 | +| --- | --- | --- | --- | +| 接收 | CLI → backend;package/清单、可选 app ID、结构变更确认 | 源码快照、deployment、审计 | 同步拒绝越权/危险归档;大文件临时空间受限 | +| 预检 | worker 调 manager;固定版本/产物及资源 | 分阶段结果、依赖产物、临时探活日志 | 失败不进入审批,清理临时执行体;保留可诊断日志 | +| 审批与预览 | 平台审批服务、预览参与资格、冻结版本 | 审批和版本记录、临时 preview session | 拒绝/撤回回收预览;生产数据不被预览修改 | +| 上线准入 | backend 协调,manager 检查资源、任务和独占权 | 上线 operation、节点归属/代次 | 资源不足保留待上线版本,旧在线版本按其状态继续;不重复建审批 | +| SQLite 迁移 | manager、本地卷、冻结迁移计划及快照任务 | 快照文件/元数据、DDL 结果 | 快照失败/状态不明则不做破坏性 DDL;停写后的恢复动作明确记录 | +| 正式启动 | manager、Engine、运行镜像/代码/依赖、env、卷、资源 | 候选执行体和探活结果 | 失败不发布候选路由,按是否已经迁移判断旧版本可否恢复 | +| 路由发布 | manager 上报事实;backend 验 generation 和所有权 | SQL 事实、Redis 投影、路由代次 | 投影未确认不退休旧版本;通过 outbox/重试修复,不回报虚假上线 | +| 停用/删除 | backend 权限与入口状态;manager 执行停止/销毁 | 路由和策略撤销、本地卷显式清理、远端资产由 backend 处理 | 幂等重试和对账;数据清理不靠重复动作猜测是否完成 | + +### 6.3 更新方式和异常边界 + +无持久写入或已确认数据兼容的应用可采用候选探活后切路由;声明 SQLite 或存在不能并发运行的数据写入者时,采用单活动发布:隔离旧写入、备份/迁移、启动新实例。后者允许短维护窗口,不能同时承诺任意 schema 变更零停机。[C16] [C21] + +当前实现先迁移,再执行部分资源准入/启动;并存在 deploy 前先撤销旧应用能力凭据再重签的路径。新方案将资源预检前移,并把凭据切换设计成候选可用、正式切换后再失效旧凭据的受控阶段。数据库迁移和容器启动仍不是同一事务,启动失败不能自动推断旧版本数据及凭据未变化。[C14] [C15] [C36] [C48] + +同一个 app 的发布、迁移、恢复、停用按持久操作和 generation 串行协调;重复 operation ID 复用结果,ID 相同但参数摘要不同则拒绝。后台对账同样读取有效期望与执行所有权,不按本地过期缓存擅自重建已停用应用。 + + + +## 7 应用访问与平台能力 + +### 7.1 目标访问链路 + +```mermaid +sequenceDiagram + participant Browser as 浏览器 / nginx + participant Proxy as app-proxy + participant Backend as Backend API + participant App as 应用 / SDK + participant Egress as egress-proxy + participant Remote as 允许的外部服务 + Browser->>Proxy: 1 请求 /apps/slug,携带平台会话 + Proxy->>Backend: 2 请求入口授权及当前访问者材料 + Backend-->>Proxy: 3 应用可见性、用户状态和短期 OBO + Proxy->>Backend: 4 独立查询已确认路由投影 + Backend-->>Proxy: 5 返回就绪实例与 generation + Proxy->>App: 6 清理伪造头后注入身份并转发 HTTP / WS + App->>Backend: 7 用应用凭据及访问者 OBO 调模型或检索 + App->>Backend: 8 用 app 范围附件句柄读写附件 + App->>Egress: 9 访问批准的外部地址,携带代理身份 + Egress->>Egress: 10 校验策略版本、域名及解析地址 + Egress->>Remote: 11 建立受控 HTTP 或 CONNECT 连接 + App-->>Proxy: 12 返回应用内容或持续 WebSocket 数据 + Proxy-->>Browser: 13 返回浏览器 +``` + +这是目标链路,路由读接口改到 backend 投影;当前仍由 app-proxy 查询 manager。后端的授权结果和路由读取继续保持不同语义、不同缓存,不把“有路由”当作“有访问权限”。[C24] [C25] [A05] + +### 7.2 路由与策略的可用性 + +manager 是真实就绪和执行地址的生产者,backend 只接收已认证、归属正确且代次有效的事实,形成持久只读投影。平台不通过 `exec_ref` 猜容器地址,也不复制 Docker 操作逻辑。代理读取该投影,因此 manager 短暂重启时不会因 3 秒路由缓存到期就必须连 manager 才能接收新请求。 + +出站策略采用相同原则:批准的声明和派生策略存平台,egress 经专用只读 API 获取版本化快照。旧 JSON/共享文件仅作为迁移输入或缓存;代理重启可从平台恢复,不要求与 manager 在同一机器共享目录。该变化修正原 D5.1 和文件策略的单机依赖,需要更新相应设计与契约。[C18] [C27] [A03] + +缓存必须有应用、版本、generation、有效期及撤销语义,刷新采用轮询/版本查询,推送仅作加速。停止/删除/撤权由 backend 使授权失效并更新投影,必要时通知代理关闭既有连接。权限或策略状态无法确认时拒绝新授权;不得为了 manager 故障容错而无限保留已撤销权限。平台自身不可用与 manager 重启是不同故障,不承诺权限服务长期不可用时仍绕过校验。 + +### 7.3 身份与出站控制 + +app-proxy 继续处理可信头规范化、前缀剥离和重定向修复,转发 HTTP/WebSocket,并定期重校验长连接。backend 通过统一权限运行时验证用户/租户状态和 `app:use`。检索和需要访问者身份的模型能力按“应用声明范围与访问者权限共同约束”判断,缺少访问者不能退化为应用 owner 权限。[C25] [C26] [C31] [C32] [C36] + +egress 检查代理令牌、允许域名/端口和 DNS 解析地址,防止将出口变成访问内部数据库的跳板。CONNECT 只在建连前校验目标,不解密 TLS。代理配置不能独自阻止不遵循 HTTP_PROXY 的应用,仍需实际生效的网络和 TCP/UDP 兜底规则;构建期允许包源,运行期不自动获得包源权限。[C27] [C28] + + + +## 8 SQLite 持久化与恢复 + +### 8.1 数据卷与预览 + +Docker 沿用按 app 隔离的宿主数据目录,挂载到 `/data`,数据库为 `/data/app.db`。`RTM_DATA_ROOT` 是 manager 视角,`RTM_HOST_DATA_ROOT` 是 Engine 解释 bind mount 的宿主视角,必须对应同一数据。代理不挂整棵应用数据根,backend 不直接打开 SQLite。[C16] [C17] [C29] + +K8s 使用每应用独占 PVC 和单活动执行策略。在 CSI/集群支持时优先 `ReadWriteOncePod`;普通 `ReadWriteOnce` 只限制单节点,仍可能被同节点多个 Pod 使用。SQLite WAL 需要本机共享内存,不可让跨主机进程把 NFS/SMB 上的数据库当作共享运行库。[R02] [R03] + +审批预览默认空临时库和临时附件空间,结束后清理。若需要生产数据副本,必须另行授权生成隔离副本,不能直接挂正式 PVC 或注入生产附件凭据。当前 preview 使用 tmpfs,目标延续隔离原则。[C20] [C43] + +### 8.2 备份与迁移 + +manager 在操作独占和写入隔离下执行 SQLite Backup API,得到一致副本,再通过 backend 上传。快照元数据至少包含 app、来源版本、schema、operation、大小、摘要、创建时间和状态;backend 确认文件和元数据都已持久化后,才能执行破坏性 DDL。[C21] [R01] + +上传超时可能发生在文件已经保存之后,因此以同一 snapshot/operation ID 查询和重试;不能重复产生不可关联的快照,也不能将“本地备份完成”当作“平台备份完成”。事务化 DDL 只保证数据库内部事务,不覆盖上传、启动和路由切换。 + +应用包和历史镜像回退只恢复代码/环境。数据库恢复需要停写、明确恢复点和数据损失窗口,下载并校验对应快照、验证完整性、恢复文件所有权,再探活并恢复路由。启动失败后不能自动恢复旧快照而覆盖可能已产生的新业务数据。 + +### 8.3 节点故障边界 + +Docker 本机盘不能承诺节点损坏后自动无损迁移;应通过平台快照和现场备份策略恢复。K8s 跨节点恢复取决于 CSI/PVC 的实际能力。manager 在 K8s 下不能假定远端 PVC 在自己本机可读,应编排挂载同一 PVC 的受控维护执行体,保证业务写入者已经停止,再进行备份/迁移/恢复。 + +这种执行体是编排服务的受控操作手段,不是新的常驻业务服务;不能以任意远程 SQL 或任意宿主路径作为接口输入。各平台支持矩阵需覆盖 Python/SQLite 动态库、文件锁、文件所有权及目标 CPU 架构。 + + + +## 9 Docker 与 K8s 交付 + +**本节实施摘要:** + +1. **无外网且无可用内网包源时,提前交付离线依赖包。** 首次发布使用已导入的基础依赖集合;新增依赖缺失时,在可取得依赖的环境制作补充包,经 backend 导入和验证后再发布。缺包在预检阶段明确报错,不回退公网。 +2. **离线包包含完整依赖闭包。** 固定锁文件、传递依赖、SDK、目标平台信息及文件摘要;不能仅拷贝顶层库,也不能把开发机 venv 当作通用交付物。manager 经 backend 获取 wheelhouse,在执行节点离线建立并缓存环境。 +3. **应用基础镜像通过运行环境清单配置。** 目标新增 `docker/runtime-profiles.yaml`,由管理员导入 backend 后统一持久化;应用使用 `execution.runtime_profile` 选择版本化档位。当前代码仍是 `BASE_IMAGES` 常量,该配置入口需要实施。 +4. **manager 镜像与应用基础镜像分别更新。** Compose 的 `runtime-manager.image` 只更新编排服务;替换应用运行环境需要新档位、依赖兼容验证及正常发布,不能使已冻结版本随默认配置变化。 + +操作流程与配置示例分别见 [离线依赖准备与补包](#offline-dependencies)、[基础运行镜像配置](#runtime-image-config)。 + +### 9.1 前置条件 + +| 条件 | Docker 首轮交付 | K8s 后续适配 | +| --- | --- | --- | +| 执行接口 | Docker Engine HTTP API,经 socket proxy;manager 不需要 Docker CLI | K8s API、专用 namespace、ServiceAccount/RBAC;不要求节点安装 dockerd | +| 应用包 | manager 从 backend 下载并校验,物化到受控内容寻址缓存,以只读方式供应用使用 | init/准备执行体通过 backend 下载到受控卷,校验后供主容器只读使用 | +| 依赖准备 | 匹配运行环境的隔离准备任务;限制网络、CPU、内存和临时磁盘 | 同类受限 Job,记录结果/日志/产物;不是默认每次生成 OCI 镜像 | +| 镜像构建兼容 | 已有 Engine build 和平台模板 | 若启用该路线,另配构建器、推送凭据、镜像分发与缓存;集群本身不提供源码构建能力 | +| 运行镜像 | 预制且固定版本/digest,目标 Engine 可加载或拉取 | 所有可调度节点可以取得对应架构的运行/初始化镜像 | +| SQLite | 单应用本机 bind,明确路径与权限 | 独占 PVC、合适 StorageClass、单活动策略及维护执行体 | +| 路由与策略 | backend 持久投影/读接口,代理缓存 | 同一接口可用;应用地址由执行适配器产生,可使用稳定 Service | +| 资源与身份 | 编排能力仅给 manager;代理只读身份 | 最小 RBAC 和 Pod 安全准入;应用不挂集群控制凭据 | + +当前 Docker manager 和模板已经存在;K8s 行为属于目标适配。应用包路线只减少应用镜像构建步骤,不会取消集群 API、镜像分发、卷和网络前置。[C09] [C10] [C34] + +### 9.2 无外网和无内网镜像仓库 + +| 层次 | 离线材料 | 必须验证 | +| --- | --- | --- | +| 平台/管理服务 | backend、worker、manager、代理和基础组件的目标架构镜像、配置与数据迁移 | 完整安装和内部认证,不仅是容器存活 | +| 应用运行环境 | 固定运行镜像、初始化/维护镜像、常用环境档位 | 在实际执行引擎/节点存在,摘要与架构一致 | +| 依赖与编译 | 完整 wheel/npm 产物或内网包源、系统库、工具链、SDK 平台索引 | 锁文件外链、安装脚本和前端编译都不访问公网 | +| 产物与数据服务 | 平台私有桶、backend 文件接口、共享 SQL/Redis、数据卷和容量 | manager 被禁止直连 MinIO 时仍能准备源码、保存和恢复快照 | +| 应用运行能力 | 模型、检索、附件和明确允许的业务接口 | 页面可打开不等于平台能力可调用;外部 SaaS 不会因离线镜像自动可用 | + +Docker 单机没有仓库时,可随安装包导入固定服务/运行镜像到同一个 Engine;应用包放平台对象存储,后续代码发布不必新增应用 OCI 镜像。完整镜像模式需要用户将对应镜像导入目标 Engine 或提供可访问仓库。 + +K8s 推荐客户内网仓库或附带最小 Registry。若不建设仓库,可以在可控的全部目标节点预装固定镜像,并配置与现场策略相符的 `IfNotPresent/Never`;新增/替换节点必须先完成镜像准备再承载应用。Kubernetes 官方明确说明预拉取方式依赖可控节点和一致的镜像准备,`Never` 缺图时直接启动失败;还须按目标集群版本核对本地镜像的凭据验证策略。[R04] + +无仓库方式不适合不受控的自动节点替换。离线包应有镜像 digest/架构清单、产物摘要、导入顺序、补包流程及缺依赖诊断;Registry 自身若随包交付,要能在其他镜像尚未导入前启动。 + +### 9.3 配置归属 + +| 配置 | 读取方与归属 | +| --- | --- | +| backend `app_runtime` | backend/worker 读取部署开关、manager 地址、内部认证、包限制及发布配置;不把 manager 容量参数复制进此处 | +| manager `RTM_*` | manager 自己读取 Engine/集群连接、数据根、资源预留、包源、超时和目标平台内部客户端配置;不读取 backend 的 `config.yaml` | +| app-proxy / egress 配置 | 各进程读取监听地址、平台只读接口及自己的服务身份、缓存/连接限制;不继承 manager 管理凭据 | +| MinIO / 平台 SQL | 只由平台存储及领域服务使用;目标 manager 不再配置 MinIO 地址、bucket、访问密钥 | +| 应用注入变量 | backend 产生业务能力和访问句柄,manager 按冻结版本注入;运行镜像/入口消费,用户不能覆盖平台保留项 | +| 运行环境目录(目标新增) | 管理员用 `docker/runtime-profiles.yaml` 导入 backend;SQL 保存不可变档位与默认映射。worker 从平台目录冻结执行计划,manager 不直接读取该 YAML,见 §9.6 | + +当前容量、数据根和包源已经由 manager 的环境变量读取,不能仅修改 backend 配置就预期生效。[C05] [C17] 新增内部客户端和策略 API 配置属于本次目标增量,应提供各进程独立配置样例和缺项诊断。 + +### 9.4 runtime-manager 的 CI/CD 与 Compose 交付 + +**确定采用:与 backend 相同的预构建镜像交付方式。** 已检查的 backend 流程在 `v*` tag 推送时构建并推送 amd64/arm64 镜像到 Docker Hub,release 流程聚合版本 manifest。runtime-manager 应加入对应构建和发布阶段;当前源码基线尚未接入该发布作业,Compose 的 manager 服务仍使用本地 `build:`。[A06] [A07] [C29] + +| 环节 | 目标实施 | +| --- | --- | +| 镜像构建入口 | 复用 `src/runtime-manager/Dockerfile` 及该目录构建上下文,依赖锁文件安装服务自身依赖;新功能开发后同步调整依赖并修正旧 socket/root 注释 | +| CI 作业 | 在现有 `ci.yml` 增加 manager 的 amd64/arm64 构建、必要测试与镜像启动检查;使用仓库 Docker Hub secrets,不在 Compose 或镜像层放推送凭据 | +| 镜像命名 | 建议 `dataelement/bisheng-runtime-manager:-amd64/-arm64`;版本名示意,尚未声明这些仓库/tag 已存在 | +| 正式发布 | `release.yml` 聚合同一源码提交且构建成功的架构镜像为版本 tag;核验各 digest/提交标签存在后再发布,不能只因 release 事件触发就认定 tag CI 已完成 | +| Compose | 正式文件用 `image:` 引用已发布版本,不带 manager `build:`;本地源码开发可在开发 override 中启用构建 | +| 其他镜像 | app-proxy、egress-proxy 同样纳入各自预构建交付;应用运行/准备镜像有独立环境版本和兼容清单,不跟随每次应用代码发布重建 | +| 离线与架构 | 安装材料包含所需架构的服务、运行及准备镜像和 digest 清单;国产化平台逐项验证,amd64/arm64 覆盖不等于支持所有 CPU/系统库组合 | + +现有 manager Dockerfile 是服务构建入口,不是应用运行基础;其中关于“直接挂 socket、因此必须 root”的旧注释与当前 Compose 的 socket proxy 方式不一致,发布前应按实际访问链路修正。服务镜像是否以非 root 运行还需核对本地数据目录、chown 与维护操作,不能只改 `USER` 就宣布权限适配完成。[A08] [C29] + +正式 Compose 的关键配置片段如下。它表达目标交付方式,不是完整可直接替换的 Compose;backend 新内部 API 身份、容量、网络、就绪和出站规则需与全文件一起配置: + +```yaml +services: + runtime-manager: + image: ${BISHENG_IMAGE_NAMESPACE:-dataelement}/bisheng-runtime-manager:${BISHENG_VERSION:?set BISHENG_VERSION} + profiles: [app-runtime] + environment: + RTM_HOST: "0.0.0.0" + RTM_PORT: "8091" + RTM_HMAC_SECRET: ${BISHENG_RTM_HMAC_SECRET:?set BISHENG_RTM_HMAC_SECRET} + RTM_DOCKER_HOST: tcp://docker-socket-proxy:2375 + RTM_PLATFORM_BASE_URL: http://backend:7860 + RTM_EGRESS_PROXY: egress-proxy:3128 + RTM_DATA_ROOT: /app-data + RTM_HOST_DATA_ROOT: ${BISHENG_APP_DATA_ROOT:-/opt/bisheng/app-data} + volumes: + - type: bind + source: ${BISHENG_APP_DATA_ROOT:-/opt/bisheng/app-data} + target: /app-data + bind: + create_host_path: false + networks: [default, bisheng-apps, runtime-control] + depends_on: [docker-socket-proxy] + restart: on-failure +``` + +安装器先创建绝对数据根并设置权限;socket proxy 只加入受控的 `runtime-control` 网络,backend/代理不加入该控制网。此片段不挂 Docker socket、不配置 MinIO、不开放 manager 宿主端口;生产 image 固定发布版本,交付清单另记录 digest,不能默认依赖 `latest`。[R10] + +在线安装时 Compose 拉取 manager 镜像;离线安装时事先导入与引用一致的镜像并采用现场拉取策略。manager 和应用运行镜像的 Engine API 获取权限分别配置;CI 中用于构建镜像的 Docker/Buildx 工具不成为 manager 运行容器必须安装 Docker CLI 的理由。 + +平台升级 manager 镜像后,仍按旧应用版本记录使用原运行镜像、代码和依赖;运行环境档位升级应显式产生新的应用执行组合并预检。这样平台服务升级与应用环境升级有各自的回退边界。 + + + +### 9.5 断外网时,wheelhouse 从哪里获取 + +**纯离线环境必须提前获得依赖文件。** wheelhouse 是已下载或已构建 wheel 的集合,不能自己产生一个现场不存在的包。首次发布不一定需要临时搬包:若随安装包提供的依赖集合已经覆盖应用,可以直接准备;新增依赖不在已导入集合或内网源中时,必须先补包,再继续发布。 + +| 现场条件 | 首次发布及新增依赖的来源 | 未满足时的行为 | +| --- | --- | --- | +| 无公网,有完整内网 PyPI/制品源 | 在受限准备任务中从批准的内网源获取锁定依赖,产出 wheelhouse | 内网源未同步目标版本、只会转发公网请求时,不能认为依赖可用;报告缺包 | +| 无公网、无内网包源 | 从平台离线安装包的基础依赖集合,或预先导入的应用专用离线包选取 | 缺少目标包/版本/架构时,预检失败,不生成审批、不尝试公网回退 | +| 已有同兼容键的依赖产物 | 经 backend 下载冻结 wheelhouse;节点环境命中则直接复用 | 产物不完整或摘要/运行环境不匹配则拒绝,不能借用另一 ABI 的缓存 | + +建议随平台离线交付一份**有明确版本清单的基础依赖集合**,覆盖 SDK 和官方样例的依赖。它是有限集合,不能承诺包含所有 Python 库及所有版本;业务新增包通过增量离线包交付。预制运行镜像、wheel 集合和 SDK 版本分别登记,镜像中携带常用 wheels 也要进入同样的完整性及兼容检查。 + +**离线包制作在可取得依赖的环境完成。** 推荐在与客户目标运行环境匹配的准备镜像/CPU 上,使用固定锁文件下载或构建完整传递依赖,必要时从项目交付物取得指定 SDK wheel,然后在目标运行镜像中断网执行安装和探活验证。已有兼容 wheel 时可用下列命令收集;只能在联网准备机或可达完整包源处运行:[R11] + +```sh +python -m pip download --only-binary=:all: --dest /out/wheelhouse -r /input/requirements.runtime.lock +``` + +需要源码构建的包按 §3.5 制作目标 wheel,再生成最终 wheel 哈希锁文件;在 x86/macOS 开发机直接下载出来的包不能默认适配客户 ARM/其他国产 CPU。若使用 pip 的跨平台下载参数,须完整指定目标解释器/ABI/平台并另做目标验证;跨平台下载选项不等于交叉编译能力。[R11] + +应用专用离线包建议包含以下内容。目录是目标交付格式草案,非现有 CLI 自动生成能力: + +```text +app-offline-deps.tar.gz +├── manifest.json # 格式、目标环境/平台、来源、锁文件和文件摘要 +├── requirements.runtime.lock # 全部运行包的固定版本及最终 wheel 哈希 +├── wheelhouse/ +│ └── *.whl # 包含传递依赖和对应 SDK +├── checksums.sha256 +└── validation.json # 制作环境、目标镜像与断网安装验证记录 +``` + +系统动态库、Python 解释器和工具链不由 wheelhouse 自动解决。应用需要新增 OS 库时,应交付相容的新运行/准备镜像及其离线镜像材料,或完整应用镜像;仅搬入 `.whl` 可能仍不能导入或运行。离线现场仍要从平台存储和数据卷读取产物,只是不访问公网。 + +**导入和增量补包闭环:** + +1. 管理员或获授权交付人员提交离线包到 backend 产物导入接口;接口/工具为本方案新增项,不要求 manager 访问 MinIO。 +2. backend 校验身份、归属、大小、安全归档、清单及摘要,把内容存私有对象存储并保存 SQL 元数据,先标为待验证;文件哈希仅证明一致性,信任来源仍由导入权限及供应方验证决定。 +3. backend 调 manager 启动受限准备任务,以目标镜像、明确禁公网网络和任务范围的文件访问取得离线材料,完成安装、关键 import 和应用预检;失败不把产物标为可复用。 +4. 验证通过后登记依赖产物及兼容键,发布计划引用该 artifact ID;执行节点从 backend 下载后按需要重建本地 venv。 +5. 新增依赖时输出可复核的缺失清单,包含可确定的包/版本约束、目标架构/ABI、镜像和阶段。外部补齐完整依赖闭包或可合并的增量包,重新验证后复用/重试发布;不能仅上传一个顶层库就跳过其传递依赖检查。 + +无内网包源的首轮交付优先采用“平台基础离线集合+按应用补包”。应用持续增加大量依赖时再建设内网 PyPI/制品源,减少人工转运;源里的内容仍需预先同步。Node 的 npm 包、安装脚本下载件和前端构建工具也遵循同一离线闭包原则。 + + + +### 9.6 预置应用基础运行镜像的配置与替换 + +**当前代码:基础镜像写在 `src/runtime-manager/runtime_manager/builder.py` 的 `BASE_IMAGES`,还没有可直接修改的部署配置项。** `python3.11` 对应 `python:3.11-slim`,`node20` 对应 `node:20-slim`,`static` 对应 `nginx:1.27-alpine`,模板使用 `FROM {{ base_image }}`。现有构建调用没有从配置接收基础镜像的入口;修改这些默认值需要改代码并重建 manager 服务镜像。[A09] + +`RTM_IMAGE_PREFIX` 是构建后应用镜像的命名前缀,`RTM_BUILD_INDEX_URL`/`RTM_BUILD_NPM_REGISTRY` 是包源,Compose 的 manager `image:` 是编排服务镜像;都不能用来替换应用基础运行镜像。[C17] [A09] + +**目标方案:新增 `docker/runtime-profiles.yaml` 运行环境清单,由管理员导入 backend 的运行环境目录。** 导入后的不可变档位、默认映射和修订记录存 SQL,worker/API 都从平台目录读取;YAML 是安装/变更的输入,不是各服务共享读取的本地状态。manager 只接收已冻结的档位、实际镜像摘要和执行契约,不读取 backend 配置或该 YAML。 + +以下配置仅为新增格式草案,当前代码不能直接读取;镜像和摘要均为占位示意: + +```yaml +schema_version: 1 +profiles: + - id: python311-standard-v2 + runtime: python3.11 + runtime_image: "registry.intra/bisheng/app-python:py311-v2@sha256:" + prepare_image: "registry.intra/bisheng/app-python-prepare:py311-v2@sha256:" + platforms: [linux/amd64, linux/arm64] + launcher: /usr/local/bin/bisheng-launch + default_port: 8080 +defaults: + python3.11: python311-standard-v2 +``` + +镜像内容需要符合 bundle 运行契约:解释器、launcher、非特权用户、挂载目录、系统库及准备环境匹配。不能仅把任意 `python` 官方镜像地址填进档位就预期它具备平台 launcher。镜像内容在目标新增的 `src/app-runtime-images/` Dockerfile 中维护并经 CI 发布,清单登记已发布/已导入的结果。 + +| 想修改的对象 | 目标修改入口 | 生效范围 | +| --- | --- | --- | +| 应用的基础运行/准备镜像 | 新建运行档位,修改 `runtime-profiles.yaml` 对应引用,导入 backend | 新发布/显式升级的应用版本,且必须重验依赖兼容性 | +| 新应用默认使用哪个档位 | 清单 `defaults` 映射,经 backend 校验后持久化 | 后续未显式选择档位的发布;版本一旦冻结,不随默认值变化 | +| 某个应用选择哪个环境 | 应用 `bisheng-app.yaml` 的 `execution.runtime_profile` | 该应用新版本;示例 `python311-standard-v2` | +| runtime-manager 自身升级 | 正式 Compose 的 `runtime-manager.image` | 编排服务,不替换已上线应用的基础镜像 | +| 应用完全自带镜像 | `execution.type=image` 及批准的完整镜像引用 | 走完整镜像契约,不再给该镜像叠加 bundle 基础运行镜像 | + +替换步骤是:发布或导入新镜像 → 新建档位清单并导入平台 → 执行环境自检与依赖验证 → 修改默认档位或应用的档位选择 → 正常发布预检、审批及上线。旧档位 ID 的镜像内容不可原地改写;旧版本和回退窗口仍引用原组合。镜像地址转到内网仓库时,也必须验证内容与登记摘要一致。 + +多架构镜像的清单摘要在执行计划中进一步解析为目标平台的实际镜像摘要,参与依赖兼容键。无仓库离线导入须验证目标 Engine 能使用内容一致的执行引用,不能只检查同名 tag 存在。拉取凭据保留在节点/集群的受控配置,不写入应用包、清单公开内容或用户应用环境。 + +该运行环境目录、导入接口、默认选择、v2 manifest 与 manager 执行协议需要一起实现;本轮只补齐评审设计,不声称已有对应配置文件或管理命令。 + + + +## 10 权限隔离与运行可靠性 + +### 10.1 编排权限 + +全部开源不降低运行权限要求。manager 经 Engine API 仍能创建/删除容器及控制挂载;socket proxy 的端点白名单不能自动限制请求中的特权、hostPath 或 bind 参数。manager 必须生成固定安全参数并限制受管标签范围;普通应用和代理不能访问控制网络或持有同一把全能力管理密钥。[C16] [C29] + +镜像和容器使用只读根文件系统、明确可写目录、非特权用户和资源限制;用户完整镜像也必须遵循。镜像来源和自定义运行环境的登记属于平台管理权限,不是商业能力开关。 + +### 10.2 状态一致性与节点所有权 + +建议扩展平台现有 app/version/deployment/instance 元数据,补充运行操作、节点归属、owner epoch、generation、心跳及路由/策略投影。新增表和索引须支持 MySQL/DM8,使用仓库双方言类型,业务权限走统一应用层接口,不让 runtime 直接写平台表。 + +一个 app 同时只有一个有效执行所有者。首轮 Docker 按明确节点分配,禁止多 manager 自动争抢同一应用;跨节点迁移和多活动控制面另行验证旧 owner 失效规则。租约超时本身不能保证旧进程已经停止操作 Engine,失联时先隔离和对账,不盲目在另一节点启动第二个 SQLite 写入者。 + +平台操作持久化后再投递,worker 重放使用相同幂等 ID;manager 重启从平台期望和真实执行体标签恢复,本地缓存可删除重建。原 JSON 状态和策略文件需要受控导入,不能在迁移后与平台状态同时作为权威。 + +### 10.3 故障处理 + +| 场景 | 目标处理 | +| --- | --- | +| manager 重启/升级 | 已运行容器和两个代理继续运行;路由/策略从平台投影读取,恢复后先对账再变更 | +| backend/权限服务不可达 | 新业务授权和任务不放行;代理对已持有的短期权限按契约处理,到期/撤销不能无限容错 | +| Redis 故障 | 通过可控 SQL 读取/投影重建或明确不可用处理,不以本地跨服务文件替代权威 | +| 元数据已保存但响应丢失 | 用 operation/generation 查询确定结果,不能仅按调用超时重复创建或清理 | +| 快照上传失败 | 保留可诊断状态,禁止破坏性迁移;按维护策略恢复旧服务或等待重试 | +| 路由投影失败 | 不宣布候选对外上线,不过早退休旧实例;投影修复与事实对账可重试 | +| 单应用退出/不健康 | 资源范围内进行有界重启和探活,持续失败进入可见故障状态,避免无限高速重建 | +| 磁盘不足 | 准入检查及缓存回收;不能删除在用数据卷或未完成确认的唯一备份 | + +日志包含 operation、app/version、阶段、节点与错误原因,过滤已知注入的敏感值;构建日志和运行日志有大小/保留限制。健康分为进程存活、依赖就绪和应用业务就绪,不以 `/healthz` 成功代替全部前置验证。[C40] + + + +## 11 实施阶段与验收 + +### 11.1 实施顺序 + +| 阶段 | 变更内容 | 完成标准 | +| --- | --- | --- | +| P0 契约与样例 | 更新 F054/F055/F059 设计;冻结交付描述、运行档位清单/导入、离线依赖格式、状态与文件接口;建立 Python/Node/static 样例 | 明确已实现与增量;Docker 全流程基线及故障点可重现;不以商业方案为开发前提 | +| P1 平台存储和控制状态 | backend 附件、源码/产物/快照 API;任务/期望/执行事实持久化、路由与策略投影;manager/代理协议迁移 | 无 manager MinIO 配置;禁止共享本地文件作为控制权威;多 API/worker 节点可读同一状态 | +| P2 生命周期和数据可靠性 | 独占任务、快照确认、SQLite 单活动发布、凭据分阶段切换、重启对账、代理最小权限和镜像 | manager 重启不直接关闭代理连接,新请求无需 manager 路由 RPC;失败不越权、不误迁移或误删数据 | +| P3 应用包与完整镜像 | 运行档位目录与默认选择、运行/准备镜像、离线包导入验证、Python wheelhouse/venv、Node 依赖、只读挂载和 launcher;完整镜像准入 | 普通代码更新复用兼容依赖;缺包明确拒绝且补包后可重试;新节点可离线重建,v1 镜像路线兼容 | +| P4 开源交付与升级 | manager/代理/运行镜像 CI 发布,Compose image 引用、一键安装/自检、离线清单、SDK/CLI 兼容、旧状态导入和运维文档 | 无需现场构建 manager 服务镜像;完成真实安装、发布、升级、停用恢复和故障演练 | +| P5 K8s 适配 | Python K8s 执行后端、Job/init、Service、RBAC、PVC、网络/镜像前置、维护执行体 | 不改平台业务语义,分别验证 bundle 和完整镜像模式;将集群验证结果与 Docker 区分 | + +P1 和 P2 是应用包默认发布前的基础。P3 中依赖管理与完整镜像可分别开发,但必须共用运行/数据契约;P5 作为独立交付阶段。是否先开放当前镜像路线的稳定版本、再开放应用包默认路线,由本次评审确认,不能把后续能力提前计入交付。 + +P4 中的 manager 镜像构建与发布作业可以提前接入,首个对客户交付的版本即应提供预构建镜像;阶段编号不表示先要求客户自行构建,再补正式镜像。 + +### 11.2 代码改动映射 + +| 模块 | 主要增量 | +| --- | --- | +| backend `app_publish` | 产物准备抽象、版本组合、兼容 manifest、预检/审批与上线计划、凭据切换、阶段结果及补偿 | +| backend `app_runtime` | 文件/附件、离线产物导入、运行环境目录与默认映射、任务和事实、节点/所有权、路由/策略读接口、数据授权和审计 | +| 平台模型/迁移 | 产物/快照引用和摘要、操作/generation/节点、只读投影元数据;MySQL/DM8 双方言 | +| manager | 平台文件/状态客户端、两级依赖准备/缓存、挂载路径映射、执行适配、幂等生命周期、SQLite 维护和事实上报;删除 MinIO 与附件服务依赖 | +| `src/app-runtime-images` | Python/Node/static 运行镜像、匹配准备镜像、launcher、固定 UID/目录与兼容清单;该目录为目标新增 | +| app-proxy | 路由源迁至平台投影、读身份和缓存/撤销处理;保留授权、头规范化、HTTP/WS 行为 | +| egress-proxy | 独立精简镜像、平台策略读取、版本缓存、连接/目标限制;不依赖同机共享目录或 manager 存活 | +| CLI / SDK | v2 交付描述和状态跟踪;保持 SDK 存储语义,适配 backend envelope/身份和预览隔离 | +| `.github/workflows/ci.yml` / `release.yml` | manager 及代理多架构构建、Docker Hub 推送、版本聚合、提交/digest 核验;应用运行镜像的环境版本发布 | +| Compose / 后续 K8s 材料 | 正式 image 引用、开发 build override、控制网络、只读/专用卷、凭据、运行档位清单、离线基础依赖/补包和健康检查 | + +### 11.3 验收集合 + +| 类别 | 必须覆盖的场景 | 通过标准 | +| --- | --- | --- | +| 开源安装 | 不安装商业 Gateway、不提供许可证 | 可完整发布和使用;没有固定 10 个上线名额检查,实际资源足够时第 11 个应用不因授权被拒绝 | +| 包与隔离 | 超限、路径逃逸、跨应用/租户文件、伪造任务和越权镜像档位 | 在正确阶段拒绝,无跨路径写入/读取;新字段有明确版本 | +| 正常发布 | 上传、扫描、准备、探活、审批、上线、浏览器/WS | 平台版本、执行事实和路由一致;临时资源回收 | +| 依赖与镜像 | 纯代码修改、锁文件/安装脚本变化、原生依赖、基础镜像/架构变化、自定义环境、完整镜像 | 环境 key 包含全部输入;变更正确失效;不会修改其他应用使用中的环境 | +| 挂载与启动 | manager 容器路径和宿主路径不同、重启、缓存清空、新节点、Node ESM 和只读目录 | 代码、venv/node_modules 和数据各挂正确路径;有缓存不安装;缓存丢失从平台产物离线重建 | +| 服务镜像交付 | CI 两架构构建、release 聚合、正式 Compose 与离线镜像包 | 固定版本/提交/digest 一致,正式配置没有 manager build;平台服务升级不悄然更换应用环境 | +| 断外网 | 目标架构镜像、依赖、SDK、源码及快照接口 | 新装和新增依赖均按离线闭包验证,不依赖开发机已有缓存 | +| 离线补包 | 首次发布依赖命中/缺失、传递依赖遗漏、错误架构、内网源未同步、增量包导入 | 缺包不入审批、不回退公网;导入且目标验证后可重试,manager 无 MinIO 连接 | +| 运行镜像配置 | 新档位导入、改默认、旧 ID 修改、应用选择、新镜像缺 launcher 或不兼容 | 仅授权变更生效;冻结版本不漂移;错误环境不放行,换镜像使依赖 key 正确失效 | +| 状态与多进程 | 删除本地缓存、不同 API/worker 主机、重复任务、旧 generation | 从平台恢复一致状态,不依赖共享本地盘,不发生多次创建或过期覆盖 | +| manager 故障 | 超过原路由短缓存时间的 manager 重启/不可达 | 已有代理连接不被 manager 退出直接关闭,新请求按平台权限及有效路由继续处理;独立测平台故障 | +| SQLite | 停写、Backup API、上传响应丢失、DDL 失败、指定恢复、预览 | 保存确认前不破坏性迁移;无两个活动写入者;生产数据不被预览或回退误覆盖 | +| 凭据 | 候选失败、版本切换、撤权/停用、伪造头和 OBO 缺失 | 老版本能力按切换契约处理,不能因旧容器存在就忽略旧令牌已被撤销的问题 | +| 网络与权限 | 绕代理、UDP/IP 直连、私网解析、代理调用 intent、控制网络扫描 | 安全规则实际生效;代理和应用无 Engine 管理能力 | +| K8s 独立验证 | PVC 单写、维护任务、节点替换、预装镜像/仓库、RBAC | 在支持矩阵内真实验证;Docker 结果不代替集群交付 | + +上表是实施后的验收要求,本次没有执行结果。文档阶段仅检查源码依据、方案一致性及离线 HTML 布局;正式开发按仓库 SDD 流程评审 spec/design/tasks,再实施和验证。 + + + +## 12 待评审事项与代码证据 + +### 12.1 需要定案的技术选择 + +| 编号 | 事项 | 本方案建议 | +| --- | --- | --- | +| D01 | 交付阶段 | 先补齐 Docker 存储/状态和数据可靠性,随后将应用包作为默认,镜像兼容及完整镜像并存 | +| D02 | 进程组合 | 保留 manager、入口和出口三个进程;部署通过 Compose 简化,不以合并 PID 为目标 | +| D03 | 控制状态演进 | 平台 SQL+Redis 投影,经 API 读写;明确修改原 manager 本地路由/策略权威的设计 | +| D04 | 应用 v2 描述与环境配置 | 明确 bundle/image、运行档位清单导入、默认选择、自定义镜像准入、argv、data_mode 及版本组合;兼容 v1 | +| D05 | SQLite 升级 | 单活动维护窗口;备份持久确认;恢复需明确恢复点与停写策略,不承诺任意 DDL 零停机 | +| D06 | 预览与清理 | 默认临时空库及隔离附件,TTL/并发由资源策略控制;生产副本另行授权 | +| D07 | K8s 首发范围 | 在独立阶段适配 bundle 和完整镜像;构建 OCI 只作为显式启用的兼容路线 | +| D08 | 离线支持矩阵与补包 | 明确 OS/CPU、运行镜像、SQLite/原生库和依赖闭包;提供基础离线集合+补包导入,无仓库模式限定可控节点 | + +全部开源、取消商业上线额度、留在 BiSheng 仓库、backend 存储边界,以及 manager 经 CI/CD 发布镜像并由正式 Compose 引用,是确定方向。技术评审只对上表及具体依赖/启动契约等实现细节作决策。 + +### 12.2 固定提交代码证据 + +| 编号 | 固定提交中的位置 | 核查内容 | +| --- | --- | --- | +| [C01] | `src/bisheng-cli/bisheng_cli/commands/deploy.py:54` | CLI 上传与轮询 | +| [C02] | `src/bisheng-cli/bisheng_cli/packaging.py:110` | 可重复打包与 SHA-256 | +| [C03] | `src/backend/bisheng/app_publish/domain/schemas/app_manifest.py:147` | 应用清单字段 | +| [C04] | `src/backend/bisheng/app_publish/domain/services/package_service.py:61` | 私有桶、解包与代码快照 | +| [C05] | `src/backend/bisheng/core/config/app_runtime.py:45` | 后端配置及包限制 | +| [C06] | `src/backend/bisheng/app_publish/domain/services/publish_pipeline_service.py:92` | 异步发布阶段与接收入口 | +| [C07] | `src/backend/bisheng/app_publish/domain/services/precheck_service.py:156` | 构建、预签名下载与临时探活 | +| [C08] | `src/runtime-manager/runtime_manager/api/schemas.py:37` | 编排 API 参数模型 | +| [C09] | `src/runtime-manager/runtime_manager/builder.py:76` | 模板、基础镜像和构建流程 | +| [C10] | `src/runtime-manager/runtime_manager/docker_backend.py:124` | Docker API 协议与真实实现 | +| [C11] | `src/runtime-manager/runtime_manager/templates/python3.11/Dockerfile.j2:42` | 依赖层与源码层 | +| [C12] | `src/runtime-manager/runtime_manager/templates/python3.11/entrypoint.sh.j2:27` | Python 启动入口 | +| [C13] | `src/runtime-manager/runtime_manager/templates/node20/entrypoint.sh.j2:44` | Node 启动入口 | +| [C14] | `src/backend/bisheng/app_publish/domain/services/publish_online_service.py:110` | 审批通过、停用分支与迁移顺序 | +| [C15] | `src/backend/bisheng/app_runtime/domain/services/app_state_service.py:283` | 应用状态与上线意图 | +| [C16] | `src/runtime-manager/runtime_manager/lifecycle.py:209` | 容器参数、部署、路由切换与删除 | +| [C17] | `src/runtime-manager/runtime_manager/config.py:57` | RTM 配置、路径和超时默认值 | +| [C18] | `src/runtime-manager/runtime_manager/desired_state.py:229` | 本地期望状态持久化 | +| [C20] | `src/runtime-manager/runtime_manager/preview.py:18` | 审批预览的数据库隔离 | +| [C21] | `src/runtime-manager/runtime_manager/appdb.py:509` | SQLite 数据面、迁移和一致性快照 | +| [C22] | `src/runtime-manager/runtime_manager/storage.py:147` | MinIO 附件句柄和应用隔离 | +| [C24] | `src/app-proxy/app_proxy/clients.py:177` | 入口鉴权和路由的双 RPC | +| [C25] | `src/backend/bisheng/app_runtime/domain/services/entry_authz_service.py:88` | 入口权限及 OBO | +| [C26] | `src/app-proxy/app_proxy/headers.py:29` | 身份头清理与注入 | +| [C27] | `src/runtime-manager/runtime_manager/egress.py:444` | 出站策略持久化和运行配置 | +| [C28] | `src/runtime-manager/runtime_manager/egress_proxy.py:171` | 独立出站转发进程 | +| [C29] | `docker/docker-compose.yml:277` | 四进程部署、网络、挂载和配置 | +| [C31] | `src/bisheng-sdk/README.md:1` | SDK 的运行位置与三类功能 | +| [C32] | `src/bisheng-sdk/bisheng_sdk/retrieve.py:40` | 检索 API 与双重身份 | +| [C33] | `src/bisheng-sdk/bisheng_sdk/_storage_remote.py:3` | SDK 附件服务地址 | +| [C34] | `features/v3.0.0/059-k8s-runtime-backend/spec.md:108` | K8s 构建、仓库和 PVC 规格 | +| [C35] | `src/runtime-manager/runtime_manager/auth.py:42` | 内部 HMAC 的当前实现 | +| [C36] | `src/backend/bisheng/app_publish/domain/services/capability_bus_service.py:384` | 应用凭据和模型地址注入 | +| [C37] | `src/backend/bisheng/app_publish/domain/services/version_service.py:138` | 冻结版本与镜像引用 | +| [C38] | `src/backend/bisheng/app_runtime/domain/services/orchestrator_client.py:134` | 后端编排 RPC 客户端 | +| [C39] | `src/runtime-manager/runtime_manager/admission.py:137` | 资源容量准入 | +| [C40] | `src/runtime-manager/runtime_manager/api/readonly.py:245` | 运行环境自检 | +| [C41] | `src/backend/bisheng/app_publish/api/endpoints/deploy.py:128` | 发布 HTTP 接口及鉴权依赖 | +| [C43] | `src/backend/bisheng/app_publish/domain/services/preview_instance_service.py:440` | 预览参数与回收 | +| [C44] | `src/runtime-manager/runtime_manager/probe.py:126` | 临时镜像探活与回收 | +| [C45] | `src/runtime-manager/runtime_manager/templates/node20/Dockerfile.j2:46` | Node 依赖和前端编译 | +| [C46] | `src/runtime-manager/runtime_manager/api/appdb.py:27` | SQLite 数据管理接口 | +| [C48] | `src/backend/bisheng/app_publish/domain/services/app_credential_service.py:134` | 应用凭据重签与撤销顺序 | +| [A01] | `features/v3.0.0/054-app-domain-runtime/spec.md:L88-L97` | F054 AC-14、AC-22:编排权限与运行服务重启目标 | +| [A02] | `features/v3.0.0/054-app-domain-runtime/spec.md:L148-L153` | F054 AC-59、AC-60、AC-61:选装与独立启停 | +| [A03] | `docs/constitution.md:L156-L180` | C8:多节点默认与共享控制状态持久化 | +| [A05] | `src/app-proxy/app_proxy/clients.py:L281-L307` | 当前路由缓存与 manager RPC 依赖 | +| [A06] | `.github/workflows/ci.yml:L3-L54` | tag 构建触发、Docker Hub 认证、backend 构建入口;arm64 见同文件 L108 | +| [A07] | `.github/workflows/release.yml:L3-L45` | release 触发和 backend 多架构版本聚合 | +| [A08] | `src/runtime-manager/Dockerfile:L1-L25` | manager 自身依赖、服务启动入口及待修正的 socket/root 注释 | +| [A09] | `src/runtime-manager/runtime_manager/builder.py:L76-L80` | 现有基础镜像常量;渲染读取见 L129-L148,实际调用见 L404 | + +### 12.3 官方资料 + +| 编号 | 资料 | 使用范围 | +| --- | --- | --- | +| [R01] | SQLite Backup API | 在线一致备份和错误处理;本方案另加迁移前停写与持久确认 | +| [R02] | SQLite WAL | 单写入者与跨主机/网络文件系统限制 | +| [R03] | Kubernetes Persistent Volumes | RWO/RWOP、CSI 和实际挂载约束 | +| [R04] | Kubernetes Images | 镜像拉取策略、预装节点与版本相关的凭据验证 | +| [R05] | Python 3.11 venv | 默认环境隔离、可执行脚本路径及不可通用搬运的限制 | +| [R06] | pip wheel | 从锁定输入准备可安装 wheel 产物 | +| [R07] | pip install | 本地 wheelhouse、无索引安装、二进制与哈希约束 | +| [R08] | npm ci | 锁文件一致性、冻结安装和安装参数约束 | +| [R09] | Docker bind mounts | daemon 主机路径语义与只读挂载 | +| [R10] | Docker Compose services | 已发布镜像引用及服务配置;本文片段仍是待实现目标 | +| [R11] | pip download | 离线收集包、目标平台参数及只能下载已有目标二进制的边界 | + +历史参考:[原商业化评审](vibe-app-hosting-commercialization-review.md)、[原 Gateway 归仓方案](bisheng-gateway-runtime-manager-implementation-plan.md)。它们的商业化及 Gateway 迁移建议已经被本方案替代,历史代码核查结论可按固定提交追溯。 + +[C01]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/bisheng-cli/bisheng_cli/commands/deploy.py#L54 +[C02]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/bisheng-cli/bisheng_cli/packaging.py#L110 +[C03]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/backend/bisheng/app_publish/domain/schemas/app_manifest.py#L147 +[C04]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/backend/bisheng/app_publish/domain/services/package_service.py#L61 +[C05]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/backend/bisheng/core/config/app_runtime.py#L45 +[C06]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/backend/bisheng/app_publish/domain/services/publish_pipeline_service.py#L92 +[C07]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/backend/bisheng/app_publish/domain/services/precheck_service.py#L156 +[C08]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/api/schemas.py#L37 +[C09]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/builder.py#L76 +[C10]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/docker_backend.py#L124 +[C11]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/templates/python3.11/Dockerfile.j2#L42 +[C12]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/templates/python3.11/entrypoint.sh.j2#L27 +[C13]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/templates/node20/entrypoint.sh.j2#L44 +[C14]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/backend/bisheng/app_publish/domain/services/publish_online_service.py#L110 +[C15]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/backend/bisheng/app_runtime/domain/services/app_state_service.py#L283 +[C16]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/lifecycle.py#L209 +[C17]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/config.py#L57 +[C18]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/desired_state.py#L229 +[C20]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/preview.py#L18 +[C21]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/appdb.py#L509 +[C22]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/storage.py#L147 +[C24]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/app-proxy/app_proxy/clients.py#L177 +[C25]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/backend/bisheng/app_runtime/domain/services/entry_authz_service.py#L88 +[C26]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/app-proxy/app_proxy/headers.py#L29 +[C27]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/egress.py#L444 +[C28]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/egress_proxy.py#L171 +[C29]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/docker/docker-compose.yml#L277 +[C31]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/bisheng-sdk/README.md#L1 +[C32]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/bisheng-sdk/bisheng_sdk/retrieve.py#L40 +[C33]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/bisheng-sdk/bisheng_sdk/_storage_remote.py#L3 +[C34]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/features/v3.0.0/059-k8s-runtime-backend/spec.md#L108 +[C35]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/auth.py#L42 +[C36]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/backend/bisheng/app_publish/domain/services/capability_bus_service.py#L384 +[C37]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/backend/bisheng/app_publish/domain/services/version_service.py#L138 +[C38]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/backend/bisheng/app_runtime/domain/services/orchestrator_client.py#L134 +[C39]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/admission.py#L137 +[C40]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/api/readonly.py#L245 +[C41]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/backend/bisheng/app_publish/api/endpoints/deploy.py#L128 +[C43]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/backend/bisheng/app_publish/domain/services/preview_instance_service.py#L440 +[C44]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/probe.py#L126 +[C45]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/templates/node20/Dockerfile.j2#L46 +[C46]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/api/appdb.py#L27 +[C48]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/backend/bisheng/app_publish/domain/services/app_credential_service.py#L134 +[A01]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/features/v3.0.0/054-app-domain-runtime/spec.md#L88-L97 +[A02]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/features/v3.0.0/054-app-domain-runtime/spec.md#L148-L153 +[A03]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/docs/constitution.md#L156-L180 +[A05]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/app-proxy/app_proxy/clients.py#L281-L307 +[A06]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/.github/workflows/ci.yml#L3-L54 +[A07]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/.github/workflows/release.yml#L3-L45 +[A08]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/Dockerfile#L1-L25 +[A09]: https://github.com/dataelement/bisheng/blob/736ef593f3fc6e1634008c842d2604304d6f70a6/src/runtime-manager/runtime_manager/builder.py#L76-L80 +[R01]: https://sqlite.org/backup.html +[R02]: https://sqlite.org/wal.html +[R03]: https://kubernetes.io/docs/concepts/storage/persistent-volumes/ +[R04]: https://kubernetes.io/docs/concepts/containers/images/ +[R05]: https://docs.python.org/3.11/library/venv.html +[R06]: https://pip.pypa.io/en/stable/cli/pip_wheel/ +[R07]: https://pip.pypa.io/en/stable/cli/pip_install/ +[R08]: https://docs.npmjs.com/cli/v11/commands/npm-ci/ +[R09]: https://docs.docker.com/engine/storage/bind-mounts/ +[R10]: https://docs.docker.com/reference/compose-file/services/ +[R11]: https://pip.pypa.io/en/stable/cli/pip_download/ diff --git a/docs/reviews/vibe-app-secrets-manual-apply-architecture-final-2026-10-23.md b/docs/reviews/vibe-app-secrets-manual-apply-architecture-final-2026-10-23.md new file mode 100644 index 0000000000..017bf72a78 --- /dev/null +++ b/docs/reviews/vibe-app-secrets-manual-apply-architecture-final-2026-10-23.md @@ -0,0 +1,32 @@ +PROCEED — 最新设计已闭合异步提交、锁序与跨事务凭据生产的三项发现;未发现需要继续修改的架构缺陷。此结论仅供设计评审,不构成实施授权。 + +| 维度 | 评分 | 锚定理由 | +|---|---|---| +| 需求与质量属性忠实度 | 🟢 | 设计 L17、L97–105、L248 及后续接口合同落实保存零执行、显式确认、冻结取值与实际运行差异。 | +| 边界与公共接口 | 🟢 | L207、L222、L235 分别通过 F055 快照、凭据 Owner 事务入口和 F054 运行快照提供能力,未复制主数据或泄漏 ORM。 | +| 模式克制 | 🟢 | D17–D19 保留单表配置与既有操作账本;maintenance 转换记录仅承接已有事实的幂等消费,没有新增配置状态机。 | +| 数据、状态及生产消费完整性 | 🟢 | L221–222 明确全部环境生产者使用同一 session;L228 指名 ready→commit 消费者及重复事实后的提交恢复。 | +| YAGNI 与变化容忍度 | 🟢 | 保留有界配置、现有部署执行器与内部协议,不预建配置历史库、自动调度、轮换产品或热加载机制。 | +| 故障与规模现实性 | 🟢 | L208–219 区分受理、claim/fact、commit 的实际锁关系;L226–231 覆盖自身维护转换、回放、期限与失败后禁止自动重建。 | +| 决策记录与可读性 | 🟢 | 修订明确指出现有实现的独立事务和锁序差异,并记录具体适配边界,没有将目标行为误写为现有事实。 | + +### blocker + +无。 + +### should-fix + +无。 + +### nit + +无。 + +本轮复核确认: + +- **原 B1 已解决:**[设计 L228](/Users/zhangguoqing/works/bisheng/.worktrees/vibe-app-integration/features/v3.0.0/055-app-publish-pipeline/design.md:228) 明确冻结 `auto_commit=true`,复用事实事务结束后的提交入口;持久 ready 重放仍重试原提交,符合现有 manager 等待 serving 的执行协议。 +- **原 B2 已解决:**[设计 L208–219](/Users/zhangguoqing/works/bisheng/.worktrees/vibe-app-integration/features/v3.0.0/055-app-publish-pipeline/design.md:208) 禁止受理持有 App/node 后锁已有 operation,并将 fact 与 commit 分成两个事务,消除先前指出的锁等待环。 +- **原 S1 已解决:**[设计 L222](/Users/zhangguoqing/works/bisheng/.worktrees/vibe-app-integration/features/v3.0.0/055-app-publish-pipeline/design.md:222) 将主体、凭据、委托项、binding 和签发审计全部交给同 session 的凭据 Owner 入口,明确禁止独立提交及读取未提交行的另一 session。 +- **自身 maintenance 围栏已闭合:**[设计 L226](/Users/zhangguoqing/works/bisheng/.worktrees/vibe-app-integration/features/v3.0.0/055-app-publish-pipeline/design.md:226) 只接受本操作造成的精确转换;旧事实重放不再改变后续 serving 路由。 + +这是对尚未实现设计的可实施性判断。复核仅阅读文档与源码,未编辑文件、运行测试或执行远程操作。 diff --git a/docs/reviews/vibe-app-secrets-manual-apply-architecture-round1-2026-10-23.md b/docs/reviews/vibe-app-secrets-manual-apply-architecture-round1-2026-10-23.md new file mode 100644 index 0000000000..0ef7692cd5 --- /dev/null +++ b/docs/reviews/vibe-app-secrets-manual-apply-architecture-round1-2026-10-23.md @@ -0,0 +1,42 @@ +REVISE — 单表配置与手动生效的简化方向成立,但异步 ready 提交和实际锁序尚未闭合;另有一处跨事务凭据写入需要明确处理。本报告仅供评审,不构成实施授权。 + +| 维度 | 评分 | 锚定理由 | +|---|---|---| +| 需求与质量属性忠实度 | 🟢 | 设计 L5、L17、L97–105、L248 明确无意外停机、秘密隔离、保存与运行差异,并覆盖手动确认、A→B→A 和在途保存。 | +| 边界与公共接口 | 🟢 | 设计 L171–176、L207、L219 将配置主数据、生命周期执行和运行快照分属原 Owner,采用显式 session 与内部 DTO。 | +| 模式克制 | 🟢 | 设计 L89–105 以有界单表配置和既有 RuntimeOperation 取代三表、独立 applied 状态机与配置执行引擎,结构与当前需求相称。 | +| 数据、状态及生产消费完整性 | 🔴 | 设计 L197、L211–214 描述异步受理和 ready 后提交,但没有接通当前唯一后台 ready→commit 条件;环境装配也包含独立提交的凭据生产者。 | +| YAGNI 与变化容忍度 | 🟢 | 设计 L93、L105、L309 明确不预建值历史、外部 Vault、轮换工具或热加载;现有执行上下文缺失可退化为 unknown。 | +| 故障与规模现实性 | 🔴 | 设计 L208 的 node→operation 锁序与当前 claim/fact 的 operation→node 相反,会使重建受理与在途事实消费形成死锁。 | +| 决策记录与可读性 | 🟡 | D17–D20 的选型、否决方案及重议条件清楚;L208 对“既有锁序”的事实描述错误,L209 未说明凭据独立事务的处理。 | + +### blocker + +**B1 — 异步受理后缺少明确的 ready 提交触发器** +维度:数据与状态、生产消费完整性。 + +- **锚点:**[设计 L197](/Users/zhangguoqing/works/bisheng/.worktrees/vibe-app-integration/features/v3.0.0/055-app-publish-pipeline/design.md:197) 返回 `{operation_id,status}`,序列图 L129–135 表示受理后由后台完成;L211–214 只规定执行与提交行为。当前 [RuntimeOperationService.fact L543](/Users/zhangguoqing/works/bisheng/.worktrees/vibe-app-integration/src/backend/bisheng/app_runtime/domain/services/runtime_operation_service.py:543) 仅在 `payload.auto_commit` 为真时自动调用提交。普通部署依赖 [Coordinator L379](/Users/zhangguoqing/works/bisheng/.worktrees/vibe-app-integration/src/backend/bisheng/app_runtime/domain/services/runtime_delivery_coordinator.py:379) 的 `wait → commit_runtime_delivery`。 +- **失败序列及影响:**新接口创建并返回操作,manager 停止旧实例、启动候选并上报 ready;没有等待中的普通 deploy 调用者,也没有明确冻结 `auto_commit`。manager 随后在 [operations.py L323](/Users/zhangguoqing/works/bisheng/.worktrees/vibe-app-integration/src/runtime-manager/runtime_manager/operations.py:323) 等待 serving,直至超时,造成已确认重建无法上线。 +- **最小方向:**明确在受理事务中冻结 `auto_commit=true`,由既有 fact 消费者调用 purpose 专属提交分支;同时规定 ready 重放在首次提交回滚后仍会再次提交原操作。无需增加执行器或新表。 + +**B2 — 新受理锁序与当前事实消费锁序相反** +维度:故障与并发。 + +- **锚点:**[设计 L208](/Users/zhangguoqing/works/bisheng/.worktrees/vibe-app-integration/features/v3.0.0/055-app-publish-pipeline/design.md:208) 要求 node→assignment/operation→App/route→配置。实际 [Repository.claim L392–402](/Users/zhangguoqing/works/bisheng/.worktrees/vibe-app-integration/src/backend/bisheng/app_runtime/domain/repositories/runtime_delivery_repository.py:392) 先锁 operation 再锁 node;[fact guard L374–377](/Users/zhangguoqing/works/bisheng/.worktrees/vibe-app-integration/src/backend/bisheng/app_runtime/domain/services/runtime_operation_service.py:374) 同样如此。 +- **失败序列及影响:**重建受理先持有 node,随后检查并锁定已有 operation;同时 manager 的事实处理已持有该 operation,正在等待 node。双方互等。即使重建本应因已有在途操作而拒绝,也可能先触发死锁;若数据库选择事实事务为牺牲者,还会干扰正在执行的部署。 +- **最小方向:**列清这次受理与 create/admit/claim/fact/commit 共用的锁集合,选用兼容的顺序;不得把 node→operation 写成现有一致约定。可以避免持 node 后回锁已有 operation,或调整本次会接触的生命周期入口,不需要扩成通用锁框架。 + +### should-fix + +**S1 — 外层运行 UoW 不能回滚现有凭据签发的独立事务** +维度:数据一致性、生产消费完整性。 + +- **锚点:**[设计 L208–209](/Users/zhangguoqing/works/bisheng/.worktrees/vibe-app-integration/features/v3.0.0/055-app-publish-pipeline/design.md:208) 宣称通过现有 `inject_environment` 同事务冻结,失败整次回滚。该方法 [L314–316](/Users/zhangguoqing/works/bisheng/.worktrees/vibe-app-integration/src/backend/bisheng/app_runtime/domain/services/runtime_delivery_coordinator.py:314) 调用 `AppRuntimeCredentialService.stage`;后者 [L197–213](/Users/zhangguoqing/works/bisheng/.worktrees/vibe-app-integration/src/backend/bisheng/app_publish/domain/services/app_credential_service.py:197) 先签发 ApiCredential,再经运行 Repository 写 binding。凭据 [Repository L31–44](/Users/zhangguoqing/works/bisheng/.worktrees/vibe-app-integration/src/backend/bisheng/open_api/domain/repositories/credential_repository.py:31) 使用自己的 session 和事务。 +- **失败序列及影响:**候选凭据及签发审计先独立提交,随后 seal、运行记录 flush 或外层事务提交失败;RuntimeOperation、binding 和预占回滚,凭据行却保留。重新受理又生成一条凭据,留下无法由原操作账本完整追踪的候选凭据与签发记录。当前 route/binding 校验会拒绝其正式调用,但这仍不符合整次回滚的设计承诺。 +- **最小方向:**明确由凭据 Owner 提供本次受理所需的 session-aware staging,或定义以原 operation 为键的幂等补偿。不能原样调用现有独立事务方法并宣称外层 UoW 已覆盖。此项仅涉及受理路径,不要求重做历史凭据审计治理。 + +### nit + +无。 + +最新的自身 maintenance 精确转换与幂等消费修订已解决 origin epoch 自冲突,不再列为问题。审查仅阅读文档与源码,未编辑文件、运行测试或执行远程操作。 diff --git a/docs/reviews/vibe-app-secrets-manual-apply-design-review-2026-10-23.html b/docs/reviews/vibe-app-secrets-manual-apply-design-review-2026-10-23.html new file mode 100644 index 0000000000..41a1414ff8 --- /dev/null +++ b/docs/reviews/vibe-app-secrets-manual-apply-design-review-2026-10-23.html @@ -0,0 +1,249 @@ +应用密钥 · 单表与手动生效设计

应用密钥 · 单表与手动生效设计

F055 LGTM / PROCEED · 设计待确认 · 尚未编码

后续状态:用户已确认F055简化设计,进入实施。 以下为设计评审记录,不代表实现/E2E已完成。

应用密钥 · 单表与用户确认重建设计评审

+

F055 设计复审:LGTM;独立架构复审:PROCEED。修订规格已获用户确认,设计仍待单独确认。 本结论仅覆盖 F055 简化设计及必要接口衔接;F054 的宿主隔离恢复 B1、F056 的 binding 回收审计 S1 仍未关闭,三份治理设计整体不能标成全部通过。

+

1. 本次确认与当前方案

+

用户已明确回复:“确认修订规格,更新简化设计”。92 条 AC 的规格正文没有再改;本轮只记录确认元数据并更新正式设计。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
环节简化后的行为
持久配置只新增一张 AppSecretConfig 表,每应用一行,整组值加密;逐项修改时间放在加密映射中
保存/删除写配置、revision、既有审计;不调用 manager、不创建部署操作、不重启
页面差异比较当前正式路由对应 operation 的受限冻结环境与最新配置;只返回差异名/状态
用户确认显示可能短暂中断;取消不发请求;版本/config revision/route epoch 过期则拒绝
实例重建复用 RuntimeOperation 与部署执行器;原版本/镜像/profile/档位/PVC不变,不审批、不构建、不迁移
失败与重放沿原操作账本、期限、writer 停止证明和幂等;不自动再发重建,已保存配置不丢
+

不再新增 AppSecretState、AppSecretMutation、配置独立 applied 回执、自动配置扫描器或自动失败重试。部署加密 key 仍固定保存/备份,轮换仅运维说明。原审批声明、owner 权限、预检/预览空值、敏感输出保护保持。

+

2. 与真实底座的衔接修订

+

独立评审初轮找到三项衔接问题,已修订并由同一独立 agent 复核;自身 maintenance 的 epoch 变化也已补齐。这里的“关闭”表示设计合同已明确,不是源代码已修复或部署验收通过。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
问题最终设计合同结果
maintenance 由本操作自己推进 route_epoch,可能被当外部变化拒绝既有事实到路由的精确幂等转换记在原 operation;只允许本操作的 before/after,重复旧事实不再改新路由已关闭于设计
异步 accepted 后没有 HTTP 等待者提交 ready冻结 auto_commit=true;现有 fact 事务结束后调用提交,同事实重报仍重试原提交已关闭于设计
受理锁序与 claim/fact/commit 不兼容局部锁表明确 App→node/assignment→route→config;不回锁已有 operation,只操作本事务私有新行;fact/commit 两段事务已关闭于设计
凭据签发/主体/审计在独立 session 中先提交凭据 Owner 提供显式同 session 的 stage_in_transaction,覆盖全部 producer 和 DTO 构建;失败候选全链回滚已关闭于设计
+

单表是因为运行状态已有持久账本;上述接线仍是必要的少量跨模块修改,不能宣称把旧 deploy() 原样调用即可兑现手动同版本重建。

+

3. SDD design 检查(F055)

+
LGTM
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#检查结果设计依据
1目标简明PASS§1
2非目标明确PASS§1 / §8
3本功能约束PASS§2
4Constitution Check 引用与门禁PASS§2;release-contract 新资产登记
5关键决策PASS§3(D 编号保留)
6备选、选定与原因PASS§3;F054 D6/D12、F055 D17–D20、F056 D12–D15
7重新考虑触发条件PASS各决策末句 / §8
8具体证据PASS§4.1 / 下方源码证据表
9主数据流PASS§4.1 图与文字
10字段与 JSON 契约PASS§4.4①②:单表、API、comparison 与受限 DTO
11模块做什么 / 不做什么PASS§4.3
12现状与拟实现区分PASS页首 / §4.1
13反直觉事实PASS§5
14坑的后果PASS§5 表
15处理文件或函数PASS§4.3 / §5
16Outgoing HTTP/Python/数据PASS§4.4③④ / §6.1:snapshot、stage、ready提交、route比较的生产消费方
17Incoming 服务/配置/二进制PASS§6.2;现有凭据独立事务与显式session适配
18依赖破坏风险PASS§4.4③局部锁图、自动恢复限制、执行上下文留存;§6.2
19测试层次与范围PASS§7;只定向、不全量
20可操作手验入口/命令/身份PASS§7;真实环境尚未执行
21后续边界与不做理由PASS§8
22修订历史PASS末节,保留旧基线引用
23规格一致 / AC 追溯PASS92条AC;手动生效新增/修订追溯见统一稿55条映射
24tasks 偏差认知回写PASStasks状态及追加偏差同步;未展开或勾选本轮任务
+

4. 独立复审与未关闭事项

+ +

5. 仍需在编码阶段执行的定向验证

+

保存/删除/取消均零执行副作用;未引用与 A→B→A 不误报;快照缺失/路由变化显示未知;原确认重放和过期新请求分开;双 SQL session 交错;候选签发后 seal/flush/commit 失败回滚;maintenance重复与外部route改变;ready提交丢响应/重报;失败不自动创建下一次操作;原镜像/代码/档位/SQLite/PVC保留。Docker/K8s分别验证变化路径,不跑全量;DM8仅静态。

+

本轮未改产品代码、未运行产品测试或部署、未公开推送/打tag。Markdown/HTML同源、目录/链接/AC编号/节号与hash仅静态检查;视觉和打印未实测。

+

6. 正式文档与 SDD 阶段

+ +

依据 AGENTS.md §6、SDD-Guide §3 和 sdd-review,设计需要用户单独确认后才能拆任务与编码。本次规格确认已经登记,不会再次请求同一规格确认。

+
\ No newline at end of file diff --git a/docs/reviews/vibe-app-secrets-manual-apply-design-review-2026-10-23.json b/docs/reviews/vibe-app-secrets-manual-apply-design-review-2026-10-23.json new file mode 100644 index 0000000000..eebf04dfc8 --- /dev/null +++ b/docs/reviews/vibe-app-secrets-manual-apply-design-review-2026-10-23.json @@ -0,0 +1,161 @@ +{ + "user_spec_confirmation": "确认修订规格,更新简化设计", + "approved_spec_sha256": "33f74ab3b2c3eaac67e64099df4b056ff5e9cf5ec78e2e461f8f34266413804d", + "current_spec_sha256": "b58594c6c912d39928482813df0f0de03ea84a6e100d3890019c91ec7f9524b4", + "design_sha256": "92e1ca11a391d971ddc63d36e4cb11c9ba6d070648b9c8e15ecefe3d179dd2ac", + "spec_confirmed": true, + "design_confirmed": true, + "implementation_started": true, + "date": "2026-10-23", + "result": "LGTM", + "critic_result": "PROCEED", + "scope": "F055 simplified design and required public integration contracts", + "unresolved_high": 0, + "unresolved_medium": 0, + "unresolved_low": 0, + "remaining_other_feature_findings": [ + "F054-B1", + "F056-S1" + ], + "single_config_table": true, + "automatic_restart_on_save": false, + "product_tests_executed": false, + "full_suite_executed": false, + "remote_mutations": false, + "incremental_tasks_created": true, + "designs": { + "F054": { + "path": "features/v3.0.0/054-app-domain-runtime/design.md", + "sha256": "c04c3fdecefde9d78d81c1423290091739b3e5845e4d8ef8a784aba513530d75" + }, + "F055": { + "path": "features/v3.0.0/055-app-publish-pipeline/design.md", + "sha256": "92e1ca11a391d971ddc63d36e4cb11c9ba6d070648b9c8e15ecefe3d179dd2ac" + }, + "F056": { + "path": "features/v3.0.0/056-app-square-governance/design.md", + "sha256": "01cd39b77745e5fcb0a2e1ef2b981ab0cedb990a2aab16118adce412ca4bb4f3" + } + }, + "sdd_checklist": [ + { + "item": 1, + "result": "PASS" + }, + { + "item": 2, + "result": "PASS" + }, + { + "item": 3, + "result": "PASS" + }, + { + "item": 4, + "result": "PASS" + }, + { + "item": 5, + "result": "PASS" + }, + { + "item": 6, + "result": "PASS" + }, + { + "item": 7, + "result": "PASS" + }, + { + "item": 8, + "result": "PASS" + }, + { + "item": 9, + "result": "PASS" + }, + { + "item": 10, + "result": "PASS" + }, + { + "item": 11, + "result": "PASS" + }, + { + "item": 12, + "result": "PASS" + }, + { + "item": 13, + "result": "PASS" + }, + { + "item": 14, + "result": "PASS" + }, + { + "item": 15, + "result": "PASS" + }, + { + "item": 16, + "result": "PASS" + }, + { + "item": 17, + "result": "PASS" + }, + { + "item": 18, + "result": "PASS" + }, + { + "item": 19, + "result": "PASS" + }, + { + "item": 20, + "result": "PASS" + }, + { + "item": 21, + "result": "PASS" + }, + { + "item": 22, + "result": "PASS" + }, + { + "item": 23, + "result": "PASS" + }, + { + "item": 24, + "result": "PASS" + } + ], + "critic_report": "vibe-app-secrets-manual-apply-architecture-final-2026-10-23.md", + "closed_in_design": [ + "own-maintenance-transition", + "ready-to-commit-consumer", + "scoped-lock-order", + "transaction-aware-credential-producers" + ], + "checks": { + "spec_ac_total": 92, + "mapped_incremental_ac": 55, + "markdown_html_same_source": true, + "product_source_unchanged": true, + "visual_print": "not verified", + "local_links": "PASS", + "html_toc": "PASS", + "offline_assets": "PASS", + "sections_decisions": "PASS", + "spec_ac_ids": "PASS: 1-92", + "incremental_traceability_count": 55, + "canonical_design_hashes": "PASS" + }, + "design_confirmation": "确认 F055 简化设计", + "approved_design_sha256": "2ef7247c7fcc71d1e2e44b34a9df8dfe865135adc66c249166a29202e29402d6" +} diff --git a/docs/reviews/vibe-app-secrets-manual-apply-design-review-2026-10-23.md b/docs/reviews/vibe-app-secrets-manual-apply-design-review-2026-10-23.md new file mode 100644 index 0000000000..689b6b21f7 --- /dev/null +++ b/docs/reviews/vibe-app-secrets-manual-apply-design-review-2026-10-23.md @@ -0,0 +1,86 @@ +# 应用密钥 · 单表与用户确认重建设计评审 + +> **后续状态:用户已确认 F055 简化设计,任务清单已评审,现进入实施。** 下文保留设计评审时的结论与测试计划;没有据此宣称代码或E2E已完成。 + +**F055 设计复审:LGTM;独立架构复审:PROCEED。修订规格已获用户确认,设计仍待单独确认。** 本结论仅覆盖 F055 简化设计及必要接口衔接;F054 的宿主隔离恢复 B1、F056 的 binding 回收审计 S1 仍未关闭,三份治理设计整体不能标成全部通过。 + +## 1. 本次确认与当前方案 + +用户已明确回复:“确认修订规格,更新简化设计”。92 条 AC 的规格正文没有再改;本轮只记录确认元数据并更新正式设计。 + +| 环节 | 简化后的行为 | +|---|---| +| 持久配置 | 只新增一张 AppSecretConfig 表,每应用一行,整组值加密;逐项修改时间放在加密映射中 | +| 保存/删除 | 写配置、revision、既有审计;不调用 manager、不创建部署操作、不重启 | +| 页面差异 | 比较当前正式路由对应 operation 的受限冻结环境与最新配置;只返回差异名/状态 | +| 用户确认 | 显示可能短暂中断;取消不发请求;版本/config revision/route epoch 过期则拒绝 | +| 实例重建 | 复用 RuntimeOperation 与部署执行器;原版本/镜像/profile/档位/PVC不变,不审批、不构建、不迁移 | +| 失败与重放 | 沿原操作账本、期限、writer 停止证明和幂等;不自动再发重建,已保存配置不丢 | + +不再新增 AppSecretState、AppSecretMutation、配置独立 applied 回执、自动配置扫描器或自动失败重试。部署加密 key 仍固定保存/备份,轮换仅运维说明。原审批声明、owner 权限、预检/预览空值、敏感输出保护保持。 + +## 2. 与真实底座的衔接修订 + +独立评审初轮找到三项衔接问题,已修订并由同一独立 agent 复核;自身 maintenance 的 epoch 变化也已补齐。这里的“关闭”表示设计合同已明确,不是源代码已修复或部署验收通过。 + +| 问题 | 最终设计合同 | 结果 | +|---|---|---| +| maintenance 由本操作自己推进 route_epoch,可能被当外部变化拒绝 | 既有事实到路由的精确幂等转换记在原 operation;只允许本操作的 before/after,重复旧事实不再改新路由 | 已关闭于设计 | +| 异步 accepted 后没有 HTTP 等待者提交 ready | 冻结 auto_commit=true;现有 fact 事务结束后调用提交,同事实重报仍重试原提交 | 已关闭于设计 | +| 受理锁序与 claim/fact/commit 不兼容 | 局部锁表明确 App→node/assignment→route→config;不回锁已有 operation,只操作本事务私有新行;fact/commit 两段事务 | 已关闭于设计 | +| 凭据签发/主体/审计在独立 session 中先提交 | 凭据 Owner 提供显式同 session 的 stage_in_transaction,覆盖全部 producer 和 DTO 构建;失败候选全链回滚 | 已关闭于设计 | + +单表是因为运行状态已有持久账本;上述接线仍是必要的少量跨模块修改,不能宣称把旧 deploy() 原样调用即可兑现手动同版本重建。 + +## 3. SDD design 检查(F055) + +``` +LGTM +``` + +| # | 检查 | 结果 | 设计依据 | +|---|---|---|---| +| 1 | 目标简明 | PASS | §1 | +| 2 | 非目标明确 | PASS | §1 / §8 | +| 3 | 本功能约束 | PASS | §2 | +| 4 | Constitution Check 引用与门禁 | PASS | §2;release-contract 新资产登记 | +| 5 | 关键决策 | PASS | §3(D 编号保留) | +| 6 | 备选、选定与原因 | PASS | §3;F054 D6/D12、F055 D17–D20、F056 D12–D15 | +| 7 | 重新考虑触发条件 | PASS | 各决策末句 / §8 | +| 8 | 具体证据 | PASS | §4.1 / 下方源码证据表 | +| 9 | 主数据流 | PASS | §4.1 图与文字 | +| 10 | 字段与 JSON 契约 | PASS | §4.4①②:单表、API、comparison 与受限 DTO | +| 11 | 模块做什么 / 不做什么 | PASS | §4.3 | +| 12 | 现状与拟实现区分 | PASS | 页首 / §4.1 | +| 13 | 反直觉事实 | PASS | §5 | +| 14 | 坑的后果 | PASS | §5 表 | +| 15 | 处理文件或函数 | PASS | §4.3 / §5 | +| 16 | Outgoing HTTP/Python/数据 | PASS | §4.4③④ / §6.1:snapshot、stage、ready提交、route比较的生产消费方 | +| 17 | Incoming 服务/配置/二进制 | PASS | §6.2;现有凭据独立事务与显式session适配 | +| 18 | 依赖破坏风险 | PASS | §4.4③局部锁图、自动恢复限制、执行上下文留存;§6.2 | +| 19 | 测试层次与范围 | PASS | §7;只定向、不全量 | +| 20 | 可操作手验入口/命令/身份 | PASS | §7;真实环境尚未执行 | +| 21 | 后续边界与不做理由 | PASS | §8 | +| 22 | 修订历史 | PASS | 末节,保留旧基线引用 | +| 23 | 规格一致 / AC 追溯 | PASS | 92条AC;手动生效新增/修订追溯见统一稿55条映射 | +| 24 | tasks 偏差认知回写 | PASS | tasks状态及追加偏差同步;未展开或勾选本轮任务 | + +## 4. 独立复审与未关闭事项 + +- [最终独立评审原文](vibe-app-secrets-manual-apply-architecture-final-2026-10-23.md):PROCEED;七维均绿,无未决 blocker/should-fix/nit。它是设计建议,不是用户确认或实施授权。 +- [初轮独立评审原文](vibe-app-secrets-manual-apply-architecture-round1-2026-10-23.md):保留历史 REVISE 与对应源码路径,不覆盖成“从未有问题”。 +- [此前三份设计第三轮结果](vibe-governance-closeout-design-round3-review-2026-10-23.md):其中 F054 B1 与 F056 S1 尚未关闭,本轮凭据受理原子性不等于回收审计已补齐。 + +## 5. 仍需在编码阶段执行的定向验证 + +保存/删除/取消均零执行副作用;未引用与 A→B→A 不误报;快照缺失/路由变化显示未知;原确认重放和过期新请求分开;双 SQL session 交错;候选签发后 seal/flush/commit 失败回滚;maintenance重复与外部route改变;ready提交丢响应/重报;失败不自动创建下一次操作;原镜像/代码/档位/SQLite/PVC保留。Docker/K8s分别验证变化路径,不跑全量;DM8仅静态。 + +本轮未改产品代码、未运行产品测试或部署、未公开推送/打tag。Markdown/HTML同源、目录/链接/AC编号/节号与hash仅静态检查;视觉和打印未实测。 + +## 6. 正式文档与 SDD 阶段 + +- [F055 正式简化设计](../../features/v3.0.0/055-app-publish-pipeline/design.md) +- [已确认的修订规格](../../features/v3.0.0/055-app-publish-pipeline/spec.md) +- [三份设计全文及当前追溯](vibe-governance-closeout-design-review-2026-10-23.html) + +依据 AGENTS.md §6、SDD-Guide §3 和 sdd-review,设计需要用户单独确认后才能拆任务与编码。本次规格确认已经登记,不会再次请求同一规格确认。 diff --git a/docs/reviews/vibe-app-secrets-simplification-2026-10-23.html b/docs/reviews/vibe-app-secrets-simplification-2026-10-23.html new file mode 100644 index 0000000000..60c6b0ded1 --- /dev/null +++ b/docs/reviews/vibe-app-secrets-simplification-2026-10-23.html @@ -0,0 +1,168 @@ +应用密钥简化方案

应用密钥 · 手动生效简化方案

单表建议 · 保存不重启 · 用户确认后复用现有部署 · 修订规格待确认

修订规格已确认;当前入口为 正式简化设计评审。以下为历史提案。

应用密钥简化方案:保存配置,用户确认后重建

+

状态:修订规格待确认;本文为后续设计方向,不是已确认设计或实现。 用户最新边界优先:保存环境变量不能自动重启;页面只提示当前实例与最新保存配置不一致,由用户决定是否重建。

+

1. 为什么原先设计了三张表

+

原方案把“保存”与“自动使其生效”绑定:AppSecretValue 保存值、AppSecretState 跟踪自动生效状态、AppSecretMutation 保存独立请求及重试意图。运行控制底座已经有 RuntimeOperation、冻结环境、实例和路由投影,再为本功能维护一套自动生效状态不符合当前最小需求。

+

按本次边界,建议只增加一个应用配置资产,复用现有生命周期和操作账本;不增加独立自动重建队列、配置状态表、请求表、配置成功回执或新的跨领域提交协议。

+

2. 单表保存,复用已有数据

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
数据建议保存位置用途
最新已保存的键值一张 AppSecretConfig 表,每应用一行app_id 主键、tenant_id、revision、加密的完整键值映射、updated_by/at;名称大小写精确;完整值不回显
本次启动实际选中的变量现有 RuntimeOperation 的受限 sealed_env按批准版本声明取子集,冻结后用于 Docker/K8s 启动和故障恢复;新增非秘密 config_revision 注记即可
当前使用哪次执行现有 RuntimeRoute / AppInstance 与对应 operation识别当前正式实例实际使用的配置;不把最新保存版本号直接当已生效版本号
重建进度、失败、重试现有 RuntimeOperation复用现有 generation、deadline、租约、停止确认、幂等键和日志
保存与确认重建的留痕现有操作审计记录应用、引用名、操作者、动作和结果,不记录值
+

选择加密完整映射的一行,可以去掉逐名称索引及大小写 collation 问题;每应用数量和大小继续有界。使用既有持久部署加密 key,密文绑定租户/应用及用途,错误 key 明确失败;换 key 仍只作运维说明。最终字段、迁移及锁顺序在修订后的 design 定稿,本文不作为数据库变更授权。

+

3. 保存路径

+
    +
  1. owner 在页面编辑名称和值;backend 校验租户、业务归属、名称、保留变量和大小限制。
  2. +
  3. 同一个短事务按配置 revision 防止旧页面覆盖,更新加密配置和审计。首次创建用 app_id 唯一约束处理并发。
  4. +
  5. 保存路径不调用 manager、不创建部署操作、不停止或启动实例。 不论新增、替换还是删除均如此;停用应用保持停用。
  6. +
  7. 不需要独立保存请求账本。保存响应丢失时先重新读取安全元数据核对,不自动重复写入、不误报成功;旧 revision 的重复提交按冲突处理。
  8. +
  9. 明确提示“已保存,有未生效变更”;运行中已经取得的旧值不会因删除平台保存值而消失。若第三方 key 泄漏,第三方系统撤销凭据是另外的操作,不能承诺平台删值会立即撤销它。
  10. +
+

4. 如何判断是否有未生效变更

+

backend 根据当前正式路由绑定的 operation,在受限内存中解密该次执行的环境,再与最新保存配置在当前批准版本声明的引用名上逐项比较。对缺值统一按空值语义处理。

+
    +
  • 只返回 has_unapplied_changes、有差异的引用名和安全状态,不返回新旧明文或可枚举值摘要。
  • +
  • 全应用 revision 不同不一定需要重建:改了未被引用的项不提示当前实例需要重建。
  • +
  • A → B → A 恢复原值后,与运行值相等就清除该项差异,不永久标脏。
  • +
  • 没有运行实例时显示“下次启动使用最新配置”;无法获取可信运行配置时显示“无法确认”,不误报已一致。
  • +
  • 对应受限执行上下文的留存与现有日志保护使用同一生命周期。若已清理,状态显示未知;不为查询新建一份明文或密钥摘要历史库。
  • +
+

因此不需要单独的 AppSecretState/applied_revision;运行配置取现有已切流的执行事实,而不是在两个模块分别写“已生效”。

+

5. 用户确认后的重建路径

+

页面显示差异后提供明确动作,例如“重建使配置生效”,说明当前版本和可能短暂中断。用户取消不发执行请求;不能把点击保存视为重建确认。

+
    +
  1. 确认请求携带预期当前运行版本和配置 revision。backend 重新核验 owner、实际运行状态及现有生命周期互斥;过期确认拒绝,要求重新核对。
  2. +
  3. 原子受理本次配置快照并创建既有类型的 RuntimeOperation,复用既有幂等键;同一请求恢复原操作,不叠加另一个重建。快照受理后再保存的值不改变本次环境,也不被旧结果覆盖。
  4. +
  5. 复用部署执行器的明确“重建当前实例”分支:绕过同版本直接返回;使用当前已批准的 version/delivery/profile/镜像/档位,不启动 pending_version,不重新构建镜像、不重新审批、不生成代码版本。
  6. +
  7. 停止旧 writer 并确认,再创建 Docker 容器或 K8s Pod,保留 SQLite 数据卷;不迁移、不备份/恢复数据库。正式值通过 Docker Env 或 K8s Secret + secretKeyRef 注入;预检、预览仍只取名称与空值。
  8. +
  9. 探活成功后沿原部署提交路径切换实例/路由;不追加 AppSecretState 写入或 ConfigurationApplyPort。页面根据新路由对应的冻结环境重新计算差异,不把操作成功等同于所有后续保存值已生效。
  10. +
  11. 失败沿既有操作记录显示;已保存配置保留,运行状态如实呈现。停止不明确时不启动第二个 writer;用户再次确认后才重试,不后台自动循环重建。manager 在途恢复仍是原操作续办,不是另发一次用户动作。
  12. +
+

正常新版本发布和原重新上线动作仍可按原规则启动,此时选择当次最新配置;本次只取消“保存配置自动触发重建”。停止应用不会因保存、差异提示或本功能动作自行上线。

+

6. 保留与删除的范围

+ + + + + + + + + + + + + + + + + + + + + + + + + +
保留移除
owner 权限、加密保存、保留变量拒绝、声明审批、预检/预览空值保存后自动启动刷新操作
现有部署/停止证明、SQLite 单写、容量准入、探活、路由提交AppSecretState、AppSecretMutation 两张专用表及自动刷新状态机
现有 sealed_env、RuntimeOperation 幂等及故障续办配置专用自动扫描器、自动失败重试、跨领域 applied/receipt 提交协议
页面差异提示、用户显式确认、保存及重建审计、日志保护密钥轮换功能、热加载、SDK 明文读取、历史值版本库
+

7. 规格变更及评审

+

F055 spec.md 修订 AC-70、AC-73、AC-74、AC-76、AC-77、AC-86,新增 AC-90、AC-91、AC-92,共 92 条;不重编号。PRD GOV-05 的自动重启文字已按用户明确要求同步,历史决议与之前批准的 hash 保留,不能把旧确认套用于新稿。

+

按 sdd-review spec 的范围执行本次增量核对,结果 LGTM(仅修订规格)。检查表如下,不是简化设计的批准或产品验收:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#检查结果
1PRD 覆盖:仅保存、差异提示、确认重建PASS
2边界:删除、缺值、未引用、A→B→A、停用、并发、过期确认、执行失败PASS
3非目标:轮换、热加载、跨应用共享等保持原边界PASS
4AC 唯一且编号可追溯PASS:1–92,无重排
5AC 可测试PASS:保存零执行副作用;取消不执行;确认后原版本重建
6新增/修订 AC 使用 EARSPASS
7不在 spec 维护错误码详表PASS
8不改变领域 OwnerPASS:F055 配置,F054 生命周期
9Constitution / release-contractPASS:复用原权限、租户、数据与编排边界;没有铁律例外
10What / How 分离PASS:表、API、算法只在本文建议,不写进 spec AC
+

下一步依据 AGENTS.md §6、SDD-Guide §3:用户确认修订规格后,再正式覆盖 F055 design、同步三份设计评审并提交设计确认;目前不拆任务、不编码。此前 B1(宿主隔离失效恢复)及 S1(实际 binding 回收审计)仍未关闭,不因本次简化而标为完成。

+

8. 定向验证计划与本轮检查

+

后续仅验证变化:保存/删除不触发操作;取消不重建;相同确认不重复创建;过期确认拒绝;未引用和恢复原值不误报;运行快照缺失显示未知;重建使用原镜像/版本/档位且 SQLite 数据保留;在途保存后仍显示真实差异。Docker/K8s 各走对应最小链路,不重跑全量。

+

本轮只改 PRD、修订规格和评审/状态文档,未修改产品源代码、未运行产品测试、未部署、未公开推送。Markdown/HTML 同源生成,验证本地链接、目录和离线资源;未做浏览器视觉或打印实测。

+
\ No newline at end of file diff --git a/docs/reviews/vibe-app-secrets-simplification-2026-10-23.json b/docs/reviews/vibe-app-secrets-simplification-2026-10-23.json new file mode 100644 index 0000000000..e2b04a6ebe --- /dev/null +++ b/docs/reviews/vibe-app-secrets-simplification-2026-10-23.json @@ -0,0 +1,93 @@ +{ + "date": "2026-10-23", + "source_commit": "7b90271017b74f8c43075dd28e40d895462d6783", + "result": "LGTM_SPEC_INCREMENT", + "spec_confirmed": false, + "design_confirmed": false, + "formal_design_revised": false, + "proposal_tables": 1, + "removed_tables": [ + "AppSecretState", + "AppSecretMutation" + ], + "save_restarts_instance": false, + "rebuild_requires_owner_confirmation": true, + "modified_ac": [ + "AC-70", + "AC-73", + "AC-74", + "AC-76", + "AC-77", + "AC-86" + ], + "new_ac": [ + "AC-90", + "AC-91", + "AC-92" + ], + "total_feature_ac": 92, + "pending_previous_findings": [ + "B1", + "S1" + ], + "product_tests_executed": false, + "full_suite_executed": false, + "remote_mutations": false, + "implementation_started": false, + "spec_checklist": [ + { + "item": 1, + "result": "PASS" + }, + { + "item": 2, + "result": "PASS" + }, + { + "item": 3, + "result": "PASS" + }, + { + "item": 4, + "result": "PASS" + }, + { + "item": 5, + "result": "PASS" + }, + { + "item": 6, + "result": "PASS" + }, + { + "item": 7, + "result": "PASS" + }, + { + "item": 8, + "result": "PASS" + }, + { + "item": 9, + "result": "PASS" + }, + { + "item": 10, + "result": "PASS" + } + ], + "sha256": { + "features/v3.0.0/055-app-publish-pipeline/spec.md": "33f74ab3b2c3eaac67e64099df4b056ff5e9cf5ec78e2e461f8f34266413804d", + "features/v3.0.0/055-app-publish-pipeline/design.md": "a7594fc8d9979a4c418c77972a308d62c003209db880e806c26a6afba55d6322", + "docs/PRD/3.0-release/3.0 应用工场 PRD-1 专业开发者通道与应用运行时.md": "32cce2ed08ac0bb2b6ef9a959c985d50392674ed7e5c4d05a538acdc6d8fec1e" + }, + "checks": { + "local_links": "PASS", + "html_toc": "PASS", + "offline_assets": "PASS", + "ac_unique_sequential": "PASS: 1-92", + "markdown_html_same_source": true, + "product_source_unchanged": true, + "visual_print": "not verified" + } +} diff --git a/docs/reviews/vibe-app-secrets-simplification-2026-10-23.md b/docs/reviews/vibe-app-secrets-simplification-2026-10-23.md new file mode 100644 index 0000000000..bfb97990f6 --- /dev/null +++ b/docs/reviews/vibe-app-secrets-simplification-2026-10-23.md @@ -0,0 +1,92 @@ +# 应用密钥简化方案:保存配置,用户确认后重建 + +> **后续状态:用户已确认修订规格,正式简化设计已更新并完成复审。** [当前设计评审](vibe-app-secrets-manual-apply-design-review-2026-10-23.md)。以下保留当时的提案记录;设计仍待用户单独确认。 + +**状态:修订规格待确认;本文为后续设计方向,不是已确认设计或实现。** 用户最新边界优先:保存环境变量不能自动重启;页面只提示当前实例与最新保存配置不一致,由用户决定是否重建。 + +## 1. 为什么原先设计了三张表 + +原方案把“保存”与“自动使其生效”绑定:`AppSecretValue` 保存值、`AppSecretState` 跟踪自动生效状态、`AppSecretMutation` 保存独立请求及重试意图。运行控制底座已经有 RuntimeOperation、冻结环境、实例和路由投影,再为本功能维护一套自动生效状态不符合当前最小需求。 + +按本次边界,建议只增加一个应用配置资产,复用现有生命周期和操作账本;不增加独立自动重建队列、配置状态表、请求表、配置成功回执或新的跨领域提交协议。 + +## 2. 单表保存,复用已有数据 + +| 数据 | 建议保存位置 | 用途 | +|---|---|---| +| 最新已保存的键值 | 一张 `AppSecretConfig` 表,每应用一行 | app_id 主键、tenant_id、revision、加密的完整键值映射、updated_by/at;名称大小写精确;完整值不回显 | +| 本次启动实际选中的变量 | 现有 RuntimeOperation 的受限 sealed_env | 按批准版本声明取子集,冻结后用于 Docker/K8s 启动和故障恢复;新增非秘密 config_revision 注记即可 | +| 当前使用哪次执行 | 现有 RuntimeRoute / AppInstance 与对应 operation | 识别当前正式实例实际使用的配置;不把最新保存版本号直接当已生效版本号 | +| 重建进度、失败、重试 | 现有 RuntimeOperation | 复用现有 generation、deadline、租约、停止确认、幂等键和日志 | +| 保存与确认重建的留痕 | 现有操作审计 | 记录应用、引用名、操作者、动作和结果,不记录值 | + +选择加密完整映射的一行,可以去掉逐名称索引及大小写 collation 问题;每应用数量和大小继续有界。使用既有持久部署加密 key,密文绑定租户/应用及用途,错误 key 明确失败;换 key 仍只作运维说明。最终字段、迁移及锁顺序在修订后的 design 定稿,本文不作为数据库变更授权。 + +## 3. 保存路径 + +1. owner 在页面编辑名称和值;backend 校验租户、业务归属、名称、保留变量和大小限制。 +2. 同一个短事务按配置 revision 防止旧页面覆盖,更新加密配置和审计。首次创建用 app_id 唯一约束处理并发。 +3. **保存路径不调用 manager、不创建部署操作、不停止或启动实例。** 不论新增、替换还是删除均如此;停用应用保持停用。 +4. 不需要独立保存请求账本。保存响应丢失时先重新读取安全元数据核对,不自动重复写入、不误报成功;旧 revision 的重复提交按冲突处理。 +5. 明确提示“已保存,有未生效变更”;运行中已经取得的旧值不会因删除平台保存值而消失。若第三方 key 泄漏,第三方系统撤销凭据是另外的操作,不能承诺平台删值会立即撤销它。 + +## 4. 如何判断是否有未生效变更 + +backend 根据当前正式路由绑定的 operation,在受限内存中解密该次执行的环境,再与最新保存配置在**当前批准版本声明的引用名**上逐项比较。对缺值统一按空值语义处理。 + +- 只返回 `has_unapplied_changes`、有差异的引用名和安全状态,不返回新旧明文或可枚举值摘要。 +- 全应用 revision 不同不一定需要重建:改了未被引用的项不提示当前实例需要重建。 +- A → B → A 恢复原值后,与运行值相等就清除该项差异,不永久标脏。 +- 没有运行实例时显示“下次启动使用最新配置”;无法获取可信运行配置时显示“无法确认”,不误报已一致。 +- 对应受限执行上下文的留存与现有日志保护使用同一生命周期。若已清理,状态显示未知;不为查询新建一份明文或密钥摘要历史库。 + +因此不需要单独的 AppSecretState/applied_revision;运行配置取现有已切流的执行事实,而不是在两个模块分别写“已生效”。 + +## 5. 用户确认后的重建路径 + +页面显示差异后提供明确动作,例如“重建使配置生效”,说明当前版本和可能短暂中断。用户取消不发执行请求;不能把点击保存视为重建确认。 + +1. 确认请求携带预期当前运行版本和配置 revision。backend 重新核验 owner、实际运行状态及现有生命周期互斥;过期确认拒绝,要求重新核对。 +2. 原子受理本次配置快照并创建**既有类型的 RuntimeOperation**,复用既有幂等键;同一请求恢复原操作,不叠加另一个重建。快照受理后再保存的值不改变本次环境,也不被旧结果覆盖。 +3. 复用部署执行器的明确“重建当前实例”分支:绕过同版本直接返回;使用当前已批准的 version/delivery/profile/镜像/档位,不启动 pending_version,不重新构建镜像、不重新审批、不生成代码版本。 +4. 停止旧 writer 并确认,再创建 Docker 容器或 K8s Pod,保留 SQLite 数据卷;不迁移、不备份/恢复数据库。正式值通过 Docker Env 或 K8s Secret + secretKeyRef 注入;预检、预览仍只取名称与空值。 +5. 探活成功后沿原部署提交路径切换实例/路由;不追加 AppSecretState 写入或 ConfigurationApplyPort。页面根据新路由对应的冻结环境重新计算差异,不把操作成功等同于所有后续保存值已生效。 +6. 失败沿既有操作记录显示;已保存配置保留,运行状态如实呈现。停止不明确时不启动第二个 writer;用户再次确认后才重试,不后台自动循环重建。manager 在途恢复仍是原操作续办,不是另发一次用户动作。 + +正常新版本发布和原重新上线动作仍可按原规则启动,此时选择当次最新配置;本次只取消“保存配置自动触发重建”。停止应用不会因保存、差异提示或本功能动作自行上线。 + +## 6. 保留与删除的范围 + +| 保留 | 移除 | +|---|---| +| owner 权限、加密保存、保留变量拒绝、声明审批、预检/预览空值 | 保存后自动启动刷新操作 | +| 现有部署/停止证明、SQLite 单写、容量准入、探活、路由提交 | AppSecretState、AppSecretMutation 两张专用表及自动刷新状态机 | +| 现有 sealed_env、RuntimeOperation 幂等及故障续办 | 配置专用自动扫描器、自动失败重试、跨领域 applied/receipt 提交协议 | +| 页面差异提示、用户显式确认、保存及重建审计、日志保护 | 密钥轮换功能、热加载、SDK 明文读取、历史值版本库 | + +## 7. 规格变更及评审 + +[F055 spec.md](../../features/v3.0.0/055-app-publish-pipeline/spec.md) 修订 AC-70、AC-73、AC-74、AC-76、AC-77、AC-86,新增 AC-90、AC-91、AC-92,共 92 条;不重编号。PRD GOV-05 的自动重启文字已按用户明确要求同步,历史决议与之前批准的 hash 保留,不能把旧确认套用于新稿。 + +按 sdd-review spec 的范围执行本次增量核对,结果 **LGTM(仅修订规格)**。检查表如下,不是简化设计的批准或产品验收: + +| # | 检查 | 结果 | +|---|---|---| +| 1 | PRD 覆盖:仅保存、差异提示、确认重建 | PASS | +| 2 | 边界:删除、缺值、未引用、A→B→A、停用、并发、过期确认、执行失败 | PASS | +| 3 | 非目标:轮换、热加载、跨应用共享等保持原边界 | PASS | +| 4 | AC 唯一且编号可追溯 | PASS:1–92,无重排 | +| 5 | AC 可测试 | PASS:保存零执行副作用;取消不执行;确认后原版本重建 | +| 6 | 新增/修订 AC 使用 EARS | PASS | +| 7 | 不在 spec 维护错误码详表 | PASS | +| 8 | 不改变领域 Owner | PASS:F055 配置,F054 生命周期 | +| 9 | Constitution / release-contract | PASS:复用原权限、租户、数据与编排边界;没有铁律例外 | +| 10 | What / How 分离 | PASS:表、API、算法只在本文建议,不写进 spec AC | + +下一步依据 AGENTS.md §6、SDD-Guide §3:用户确认修订规格后,再正式覆盖 F055 design、同步三份设计评审并提交设计确认;目前不拆任务、不编码。此前 B1(宿主隔离失效恢复)及 S1(实际 binding 回收审计)仍未关闭,不因本次简化而标为完成。 + +## 8. 定向验证计划与本轮检查 + +后续仅验证变化:保存/删除不触发操作;取消不重建;相同确认不重复创建;过期确认拒绝;未引用和恢复原值不误报;运行快照缺失显示未知;重建使用原镜像/版本/档位且 SQLite 数据保留;在途保存后仍显示真实差异。Docker/K8s 各走对应最小链路,不重跑全量。 + +本轮只改 PRD、修订规格和评审/状态文档,未修改产品源代码、未运行产品测试、未部署、未公开推送。Markdown/HTML 同源生成,验证本地链接、目录和离线资源;未做浏览器视觉或打印实测。 diff --git a/docs/reviews/vibe-credential-defaults-hotfix-2026-10-25.md b/docs/reviews/vibe-credential-defaults-hotfix-2026-10-25.md new file mode 100644 index 0000000000..76c12478e2 --- /dev/null +++ b/docs/reviews/vibe-credential-defaults-hotfix-2026-10-25.md @@ -0,0 +1,17 @@ +# 运行凭据默认字段热修审查 + +范围:189 单应用正式上线的审批 outbox 触发 MySQL MissingGreenlet;仅修复 CredentialRepository.create_in_transaction 的服务器默认字段读取。 + +复现:关闭 SQLite INSERT RETURNING,原实现于 KeyItem.from_row 读取 create_time 失败;配对结果 1 passed / 1 failed。显式 await session.refresh(row) 后 2 passed,仍验证调用方 rollback 可移除候选凭据。此模拟验证不替代真实 MySQL/K8s 发布。 + +| 七维度 | 结论 | +|---|---| +| 边界条件 | 有/无 INSERT RETURNING 两条路径;不制造默认时间 | +| 权限认证 | 不改 tenant、subject、scope、认证与审计字段 | +| 并发事务 | refresh 使用当前 session;不增加 commit;rollback 验证通过 | +| 信息泄漏 | 不新增日志、响应字段或凭据持久化 | +| 定向测试 | 两例复现/回归;未跑全量测试;DM8 仅静态审查 ORM 兼容性 | +| 风格分层 | Repository 内 ORM refresh;Ruff、架构守卫和 diff check | +| 文档同步 | F056 design 已知坑与修订说明同步 | + +结论:PASS。复用已批准合同,按 SDD Guide §1 hotfix 流程;真实部署重试结果另记部署记录。 diff --git a/docs/reviews/vibe-data-latency-fix-2026-10-29.md b/docs/reviews/vibe-data-latency-fix-2026-10-29.md new file mode 100644 index 0000000000..055fe7ba8b --- /dev/null +++ b/docs/reviews/vibe-data-latency-fix-2026-10-29.md @@ -0,0 +1,50 @@ +# Vibe 数据页延迟及繁忙误报修复 + +## 范围与交付状态 + +- 日期:2026-10-29。 +- 基线:`origin/feat/3.0.0@3eb8f153be8b07b745c7e47ada951e4f40493284`。 +- 开发分支:`codex/vibe-data-browse-latency`;独立工作树,未覆盖原 checkout。 +- 按现有缺陷修复处理:不新增 API、数据库字段或产品行为;复用既有 16167 三语提示。 +- 本记录为提交前完成的代码及定向验证结果;本次交付为 feat/3.0.0 代码修复,未切换 116 正式 manager/helper/前端镜像,116 临时镜像仅供有界只读测量。 + +## 根因与现场证据 + +116 示例应用数据页先取表清单,然后并发取 schema 与 rows。每个数据请求都启动独立的短命 helper,manager 同一应用锁为非阻塞。第二个并发请求因此返回 16167,原消息却称“under maintenance”。 + +现场顺序 API 查询:schema 11.693 秒、rows 11.478 秒,均 HTTP 200 / business 200。并发查询:一个 11.511 秒成功,另一个 0.148 秒返回 business 16167。应用本身 online、发布成功,并非正在维护。 + +helper 读取 `chat_history` 一行的分段计时(不输出对话、用户字段或凭据): + +| 方案 | CPU 限额 | 总时长 | helper 运行时长 | 说明 | +| --- | --- | --- | --- | --- | +| 原镜像 | 0.1 核 | 12.248 秒 | 11.429 秒 | FastAPI 累计导入约 6.400 秒,appdb 累计约 8.896 秒 | +| 原镜像,仅测量时覆盖 CPU | 1 核 | 1.581 秒 | 1.061 秒 | 用于区分 CPU 启动成本;未修改部署配置 | +| 修复临时镜像 | 0.1 核 | 4.293 秒 | 3.706 秒 | 无 FastAPI 导入;appdb 累计约 2.103 秒 | +| 修复临时镜像,再次测量 | 0.1 核 | 4.490 秒 | 3.816 秒 | 剩余主要为标准库导入与启动成本 | + +这些是同一宿主的窄范围样本,带 Python importtime 诊断;不是稳定延迟承诺,也不是浏览器整页耗时。原镜像 Engine create/start/remove 约 0.038/0.451/0.007 秒,主要耗时在容器内启动。 + +## 修复 + +1. `errors.py` 直接继承 FastAPI 原本兼容的 Starlette HTTPException,保持 status/detail 信封;observability 捕获同一公共基类。 +2. `ExecutionError` 移到轻量独立模块;container_runner 继续导出同一类以兼容现有调用,appdb 直接引用新模块,不再引入编排模块。 +3. 页面按现有本地 effect 流程串行读取;schema 成功后才取 rows。同一应用、同一表的 schema 在翻页/排序时复用;切表及显式刷新清除或更新缓存。过期响应丢弃,失败不阻塞后续重试,不缓存业务行。 +4. 锁竞争保留 `data_busy`,消息准确描述繁忙;页面用既有 16167 多语言文案,不再直接显示误导性的维护英文。 + +保留:单应用 DB 挂载、禁网、非 root、只读根、cap-drop、no-new-privileges、128 MiB、100m CPU、最多两个 helper、30 秒期限、owner/租户鉴权与维护互斥。未将 SQLite 读取搬到 manager 主进程。 + +## 必要定向验证 + +- 运行时相关文件:原 85 项通过;新增锁竞争测试后,新增文件 4 项通过(相关独立用例累计 86 项)。覆盖 appdb、迁移、helper 隔离与错误清理、观测异常、轻量导入、HTTP 错误信封及 ExecutionError 兼容。 +- 前端 `hostedAppDataTab.test.tsx`:16 项通过;覆盖顺序读取、schema 复用/刷新、快速切表旧响应丢弃、失败后重试、繁忙提示、原编辑/导出行为。 +- Ruff check/format、改动文件架构守卫、git diff 空白检查:通过。 +- 改动前端文件 ESLint:通过。 +- 平台 `tsc-strict`:未通过。本机缓存依赖不完整,未改动的 shared chat/file-viewers 缺少 react 等 workspace 链接,shared UI 的 Eye/EyeClose 图标类型也报错;日志在临时目录。不能将此门记为通过;未修改无关代码或扩大测试范围。 +- 未运行全量回归;未执行 K8s 性能测试;未做前端部署后的浏览器验收。 + +## L2 定向自审 + +按 sdd-code-review 七维度检查:边界、认证、并发、信息泄漏和代码风格无新增发现;设计同步到 F086 §4.9/已知坑。异常 HTTP 基类变更有信封与 observability 测试;旧执行异常 import 保持类身份。helper 启动依赖在新进程中断言,避免 pytest 已导入模块掩盖问题。 + +状态:**修复及定向测试通过;完整类型门受本机依赖阻塞,正式服务部署及浏览器验证待完成**。不据此宣称线上问题已解决。当前短命容器仍有数秒成本,持续进程/提高 CPU 等进一步路线需要单独评估,不混入本次修复。 diff --git a/docs/reviews/vibe-development-integration-2026-10-23.md b/docs/reviews/vibe-development-integration-2026-10-23.md new file mode 100644 index 0000000000..f8bd3b3ab2 --- /dev/null +++ b/docs/reviews/vibe-development-integration-2026-10-23.md @@ -0,0 +1,74 @@ +# Vibe 开发整合与合入审查(2026-10-23) + +## 结论与范围 + +**开发合入:PASS_WITH_WARNINGS;完整 Feature 交付验收仍未完成。** 用户明确要求先收尾开发、合并到 `feat/3.0.0-vibe` 并推送远端,只做必要定向验证。本次不改变已确认规格、进程边界或授权/存储合同;无新增 Tag、公开镜像发行或远端环境变更。 + +- 目标起点:`feat/3.0.0-vibe@4aa9ce69b2830b81162fe631b9c801057c40ee8b`(已 fetch 核对远端)。 +- F059 实现与最新实证:`cda8dccf26298af91a1df277dcd1a49787b6b444`;其中运行修复源为 `0f2db1f9a29213ca94d4596e64b00ad8fcbac528`。 +- F086 源分支:`667546bb77eeb366e66a13d544d122ef24811e35`,本地历史合并提交 `575984069`;BIGINT/迁移在 F059 已经由 `ea3b1f6afb68da2b52ba4fd1fcf0bb5e060510ba` 继承。 +- F059 已继承的发布线历史随分支合入;不是把目标以来全部 1640 个文件差异都称为本轮新增 Vibe 代码。未合并另一个任务正在修改的发布线最新工作区。 +- 收回 F086 尚未提交的限定 Feature 文档/证据共 78 个文件、579065 字节;源工作树保留。五个公共后端草稿及一个 tenant 测试与 F059 完全相同,不复制覆盖;原主工作区其他任务不动。 + +## 已完成开发范围 + +| 模块 | 本次整合内容 | +| --- | --- | +| Docker / 应用交付 | 预制 runtime/preparer、版本化应用包、依赖材料缓存、离线补包、完整镜像与兼容 source-image | +| K8s 执行 | 受限 API 客户端、固定生产 Pod、PVC、材料授权/初始化、控制器租约、资源 UID/停止事实、生命周期/对账 | +| 数据与代理 | backend 源码/附件/快照存储,manager SQLite 维护;独立 app-proxy/egress-proxy 读取平台投影,manager 不直接访问 MinIO | +| 安装与发行实现 | Chart、准入服务、真实能力探测/报告失效、helper/assembler Dockerfile 与构建脚本、CI/目录/离线物料工具 | +| CLI / SDK | 现有业务接口接线、离线材料工具、容量码映射、提交的 wheel/manifest 与源码匹配 | +| 最终开发收尾 | 保留 K8s 后续模型/租户约束;刷新设计当前快照与已知坑;同版本镜像 CI 入口使用同一并发组 T274 | + +F059 当前 **268/272**;F086 **92/94**。剩余勾选项是分项回归、完整验收、发行或交付汇总门。开发合入审查完成不把这些合并任务自动改为通过。 + +## 七维代码审查 + +三条只读分工审查 runtime、backend 和安装/发行/CLI/SDK;root 集成结论如下。完整报告保存于 F059 `integration-review/`。 + +| 维度 | 未关闭 HIGH | 结论 | +| --- | --- | --- | +| 边界条件 | 0 | 操作期限、准备/数据/材料边界及未知结果保护未放宽 | +| 权限与认证 | 0 | Owner/tenant/permission、HMAC/材料身份及 K8s 准入范围保持 | +| 并发安全 | 0 | 租约/UID/单写与持久事实核对;CI 同 release 跨入口竞争已修复 | +| 信息泄漏 | 0 | 未复制平台凭据到 manager;限定新增证据完成敏感值检查 | +| 测试覆盖 | 0(开发代码范围) | 复用既有 L1/真实证据;本次只做下列定向验证,完整验收缺口另列 | +| 风格与分层 | 0 | Ruff/架构门、进程和平台存储边界保持 | +| 文档同步 | 0 | D1 旧当前快照已修,runtime/backend 两线仅定向复审关闭 | + +发现并处理:D1 设计仍写 R29/265-of-269 和 owner 数据未验,现更新为 R32/R11/M2 与明确未验范围;CI 并发按 ref 分组导致同版本跨入口可竞争,现与实际 release 表达式同源。后者只约束该仓库此工作流;仓库外 CLI/其他工作流仍需发布权限或仓库不可变策略。 + +## 必要定向验证 + +| 检查 | 结果与边界 | +| --- | --- | +| 两个 Git 冲突 | 按 hunk 保留 F059 `String`、17 表/tenant=None 与 UID 约束测试;历史合并后的产品/测试内容与 F059 源一致 | +| CI 同版本发布锁 | 现有单条契约先失败,修复后 **1 passed**;没有触发镜像构建/推送 | +| 冲突涉及的模型合同 | **17 passed**,真实 SQLite + 静态 MySQL/DM 方言;未连接 DM8。新工作树缺私有配置的初次收集失败,改用现有本地测试配置后成功 | +| 代码静态 | 修改测试 Ruff format/check、arch-guard 与 git diff --check 通过 | +| 静态整合完整性 | 任务正文/索引、限定 JSON、冲突标记、迁移唯一 head、变更 Markdown 本地链接及恢复文件来源核对;不代替部署 | + +未运行全量 runtime/backend/frontend 回归;未重新部署 109/189。旧测试记录保留其原 source/date,不能转标为当前提交的实测。 + +## 子分支与恢复记录 + +- `codex/f059-installation@992905292`、`codex/f059-e2e@21250d329`、`codex/f059-image-release@f84415f25`、installer DNAT 分支已被 F059 继承。 +- CLI capacity 独占旧 hash 虽非祖先,当前五个相关文件逐字节相同,源有 `0ff595428` 接线与测试/已重建 wheel。 +- protection-runtime `0338d2f65` 对应源 `2992a28a8`,六个文件相同;destroy-maintenance `dd549c009` 对应 `1532e115e`,operations 文件相同。后续 purge 等待和 controller 准入继续保留。 +- destroy-fact、prepare-cancel、protection-backend、restore-resume 四个测试分支的独占文件与 F059 逐字节相同,源历史有对应测试/修订。未仅凭 ancestry 或 git cherry 判定缺失,也未重复覆盖旧实现。 +- 公共租户修复先在发布线 `9d156f261c` 落地,F059 正常继承;BIGINT 两原迁移父链保留,经显式 merge revision 汇合。 + +## 保留的验收与发行边界 + +1. F086 T055/T056:完整离线平台新装、目录导入及剩余故障组合与全交付汇总未结束。 +2. F059 T067/T070/T071/T072:后续 Docker/平台范围、剩余故障和独立域卸载保留数据、当前版本双架构正式发行及最终逐 AC 交付汇总仍 pending;L2 开发审查子项完成。 +3. ARM 按用户要求暂缓;DM8 仅静态;189 单节点 amd64 不能证明跨节点、NodeLost 或所有存储类型。 +4. 189 R32 在途 prepare 的原操作/deadline/install UID/CID 续办实证保持;不把后续 probe 的 SQL 结果扩大成物理清理或批准上线。旧失败/unknown writer 及容量围栏不清除。 +5. `v3.0.0-test-vibe@755d4456e5` 的历史双原生七镜像 CI 与 109 启动证据保留;不代表包含后续 F059 修复的九镜像版本已正式发行。R32 私有 manager-only 镜像也不是最终整合提交全组件发行。 +6. 历史 frontend/backend 既有基线失败不因本次合并自动消除;未将相关其他业务工作区混入。 + +## 关联资料 + +- [F059 当前设计](../../features/v3.0.0/059-k8s-runtime-backend/design.md)、[任务](../../features/v3.0.0/059-k8s-runtime-backend/tasks.md)、[审查](../../features/v3.0.0/059-k8s-runtime-backend/code-review.md) +- [F086 设计](../../features/v3.0.0/086-vibe-runtime-delivery/design.md)、[审查](../../features/v3.0.0/086-vibe-runtime-delivery/code-review.md)、[历史完整交付审查](../../features/v3.0.0/086-vibe-runtime-delivery/code-review-2026-10-21.md) diff --git a/docs/reviews/vibe-docker-ingress-hotfix-2026-10-26.md b/docs/reviews/vibe-docker-ingress-hotfix-2026-10-26.md new file mode 100644 index 0000000000..b0d1163c42 --- /dev/null +++ b/docs/reviews/vibe-docker-ingress-hotfix-2026-10-26.md @@ -0,0 +1,39 @@ +# Docker 控制入口热修评审 + +实测根因:116 host→同一无凭据HTTP canary=200,manager/entry→canary=ConnectTimeout,主流程在precheck_probe失败。修复仅恢复受信控制入口,按 SDD Guide §1 hotfix 处理,不改变已确认发布/隔离合同。 + +| 维度 | 结果 | +|---|---| +| 边界 | 容器名称、运行状态、标签、实际网络、源地址和所属子网校验;未知/停止/应用主体拒绝 | +| 权限 | 只增加精确受信源到应用子网的FORWARD TCP;无INPUT放行、无应用源任意出口 | +| 并发 | 既有签名descriptor与60秒独立报告围栏;地址变更fail closed,同地址受信服务合法重建可恢复 | +| 信息泄漏 | 不新增凭据、响应或日志内容;canary不携带应用数据 | +| 定向测试 | 宿主隔离模块26 passed;iptables/nft规则范围、反向拒绝、动态发现和既有围栏;未跑全量 | +| 风格 | Ruff及定向架构守卫、diff check | +| 文档 | F054设计和Docker宿主安装说明同步;需更新宿主工具与manager,并重生成节点绑定 | + +结论 PASS;真实116应用重试及allow/deny实证另行记录,不以单测代替。 + +## 116 真实主流程结果(2026-10-26) + +- 修复源码:`df8757ba046f2997df4ec8c9d6e98e69bd44e141`;116 manager 与宿主工具使用同源实现。 +- 实际 manager 镜像:`192.168.106.189:5443/vibe-release/bisheng-runtime-manager@sha256:d94d1ca4191ae981a75bd2c2ba7860340c0b5842288efa84b69a58a255ef6681`。以原固定发行镜像为基础,仅覆盖宿主隔离模块;私有测试发行,不能视为正式 Docker Hub CI 发行结果。 +- host、manager、app-proxy 访问同一无凭据临时 HTTP canary 均返回 200;canary 已安全删除。宿主隔离周期服务 `Result=success / ExecMainStatus=0`,应用访问宿主发布地址及桥接网关的拒绝校验保留。 +- 116 root 每小时的镜像清理会删除私有仓库镜像:仅排除 `192.168.106.189:5443/vibe-release/` 路径,保留其余规则。原配置已在目标主机 root-only 目录备份。 +- 116 原 nginx 绑定配置缺少 `/apps/`;补齐至同网络 app-proxy 的反代,`nginx -t` 通过并平滑 reload。既有平台、Gateway API 和工作区映射保留。 +- 测试应用 `e2e-vibe-docker-20261025`,ID `c0662e86c4414ff7b4265aff6ffe79cb`;部署 `ea3f7d60bdeb42b39e6e1ad54d7f5e01`,版本 `dbb691b5d9cf4d528933fb3d19decc86`。 +- 通过实际 API 登录、上传静态应用包、准备、临时探活、审批实例 407、正式部署及路由访问。已授权专用测试超管通过正式审批 API 处理既有管理员待办;无直接修改审批状态。最终发布 `succeeded`、应用 `online`;入口返回 HTTP 200 且包含此次应用的正文标记。 +- 档位 `light`:256 MiB、250 millicores;验证资源档位实际用于此次部署,未声称完成在线档位切换或升级/停止回归。 +- 访问地址:[116 测试应用](http://192.168.106.116:3001/apps/e2e-vibe-docker-20261025/)。应用保留在线供查看,仍需正常平台身份授权;登录会话仅在进程内使用,临时发布密钥已回收。 + +### 临时容量配置及证据边界 + +经用户明确同意,`RTM_OVERCOMMIT_RATIO` 从 `0.8` 临时升至 `0.95`,保留 `RTM_RESERVE_MB=2048`;最终查询剩余可调度约 3470 MiB。这是预算调整,不代表增加了物理内存。原 Compose 的 0.8 配置保留,临时 override 位于目标主机 `/opt/bisheng-vibe-release/f5db95eca/budget-temporary-116.yml`。 + +恢复原预算时,在 `/opt/server/bisheng-test` 执行以下命令(不加载临时 override): + +```sh +docker compose -f docker-compose.yml --profile app-runtime up -d --no-deps runtime-manager +``` + +本次验证仅覆盖上述一个 Docker 静态应用包上线主流程和修复所需定向检查;没有运行全量测试,没有补做 arm64、升级、停止或 K8s 回归。189 此前通过的主流程结果不作为本次 Docker 修复的替代证据。服务器脱敏事件证据保留在 `/opt/server/bisheng-test/bisheng/data/vibe-release-20261025/e2e-vibe-docker-20261025.json`;该目录的其他文件可能含测试账号信息,不纳入代码仓库。 diff --git a/docs/reviews/vibe-entry-current-origin-hotfix-2026-10-27.md b/docs/reviews/vibe-entry-current-origin-hotfix-2026-10-27.md new file mode 100644 index 0000000000..d151737382 --- /dev/null +++ b/docs/reviews/vibe-entry-current-origin-hotfix-2026-10-27.md @@ -0,0 +1,29 @@ +# 应用入口跟随浏览器地址:热修审查 + +日期:2026-10-27。基线:`origin/feat/3.0.0`(本地核对提交 `c667d1bc09`)。适用 F054 AC-25、历史 T053 / T065 / T091 的发布页地址展示;用户要求采用教程页面的当前浏览器地址口径。后端返回字段、CLI、实际应用权限与运行路由不变。 + +## 根因与修改 + +原后端由 `app_runtime.entry_base_url` 加 `/apps/{slug}` 产生 `entry_url`,发布页原样展示/复制/打开/生成二维码。固定入口不同于用户实际访问的域名、协议或端口时,界面带回固定入口。历史注释把开发 Vite 未代理 `/apps` 当作不能使用当前 origin 的原因。 + +现四处共享 `window.location.origin + __APP_ENV__.BASE_URL + /apps/{encoded-slug}/`。保留空入口显隐规则;应用下线时链接仍展示,继续由入口授权/状态页判断可访问性。Vite 为 `/apps/` 增加 HTTP/WebSocket 代理,开发时由 `VITE_APP_PROXY_TARGET` 指定已部署入口;只提供 FastAPI 的 API 目标不能充当应用入口。配置说明及旧注释已同步。 + +## SDD 热修审查 + +| 维度 | 结论 | 依据 | +|---|---|---| +| 边界 | PASS | 空入口保持原规则;相对后端地址不影响页面展示;保留部署前缀和尾斜杠,slug编码 | +| 权限与认证 | PASS | 不新增接口或改授权;应用访问仍走既有nginx/app-proxy/backend身份判定 | +| 并发 | PASS | 地址为当前渲染的只读值,不引入缓存或共享状态 | +| 信息泄漏 | PASS | 四处不再展示配置中的固定主机地址;无新凭据、存储或日志 | +| 定向测试 | PASS | PublishTab 4例;实际Vite配置加载1例;未跑全量 | +| 风格 | PASS | 修改的TS/TSX通过ESLint;后端仅docstring修改,Ruff通过;架构守卫及diff check通过 | +| 文档 | PASS | F054设计现状、任务追加记录、开发代理README同步;后端固定地址合同不变 | + +## 验证与交付边界 + +- `PublishTab.test.tsx`:4 passed,覆盖文字/复制/打开/二维码一致、下线态、部署前缀及相对入口、空入口。 +- `config/vite.test.ts -t 'loads both real Vite configs'`:1 passed,实际加载Vite配置并验证独立应用入口及HTTP/WebSocket代理选项;其余10例未执行。 +- 本地依赖复用现有virtual store;首次离线安装缺包及重复React环境问题已排除后,以上定向用例通过。没有修改锁文件或依赖清单。 +- 未更新116前端镜像,未执行实际浏览器/手机扫码验收;截图的实例HTTP500不是本次修复对象。 +- Overall:PASS(代码自审);不等同于CI和部署验收通过。 diff --git a/docs/reviews/vibe-governance-closeout-architecture-critique-round1-2026-10-23.md b/docs/reviews/vibe-governance-closeout-architecture-critique-round1-2026-10-23.md new file mode 100644 index 0000000000..fac7cecba3 --- /dev/null +++ b/docs/reviews/vibe-governance-closeout-architecture-critique-round1-2026-10-23.md @@ -0,0 +1,41 @@ +**REVISE — 三份设计总体沿用现有边界,但导出能力被未经确认地收窄;密钥脱敏、加密密钥轮换和 OBO 错误展示还缺少可执行的合同。** 审查基于固定提交 `7b90271017b74f8c43075dd28e40d895462d6783`,仅做静态只读核验,未运行测试或部署。 + +| 维度 | 评级 | 一句话理由 | +|---|---|---| +| 1. 需求与质量属性保真 | 🔴 红 | 安全隔离、可靠恢复、审计完整性贯穿设计,但 F056 新增硬性导出限制、F054 改写配置错误提示,偏离已确认 AC。 | +| 2. 边界质量 | 🟢 绿 | F054 执行、F055 配置资产、F056 治理读侧的归属清楚,跨域结果回调明确要求应用协议。 | +| 3. 模式克制 | 🟢 绿 | 复用持久操作、现有部署执行器与三张事实表,并给出拒绝另建状态机、统一大表及外部 Vault 的原因。 | +| 4. 数据与状态建模 | 🟡 黄 | revision、幂等、冻结声明和应用状态区分充分,但日志敏感项传递与轮换后的幂等验证缺少完整生产消费合同。 | +| 5. 变化容忍与 YAGNI | 🟢 绿 | 未来 KMS、热加载、跨类型分析均只留决策条件,没有预建框架。 | +| 6. 故障与规模真实性 | 🟡 黄 | 对未知 writer、响应丢失、后台恢复和失权处理较认真;轮换后历史请求重放及导出预算仍有断点。 | +| 7. 决策记录与可读性 | 🟡 黄 | 大部分决策附有原因和替代方案,但资源预算被写成既定产品限制,轮换维护说明未覆盖全部持久状态。 | + +### Blocker + +**B1|维度 1:F056 把“导出全部筛选结果”改成了最多 10,000 行。** + +- **锚点:** `056-app-square-governance/design.md:18`、`:77–79`,超过 10,000 行便拒绝导出,且查询区间最多 90 天;同目录 `spec.md:176` 的 **AC-56** 明确要求导出当前筛选条件下全部受授权记录,原 **AC-32** 及决议 8 也如此规定。 +- **影响:** 即使管理员有权查看,合法筛选结果达到 10,001 行也无法兑现已确认交付能力。分批执行的内部资源预算与用户可导出的总量不是同一约束;UI 明示不能消除需求偏差。 +- **建议方向:** 让内部批次有界,同时由持久任务完成全部选定记录;若确实要限制总量或历史区间,先提出明确规格变更供用户确认,再据此设计。 + +### Should-fix + +**S1|维度 4、6:应用密钥允许任意合法名称和短值,现有日志脱敏却依赖名称启发式及长度门槛。** + +- **锚点:** F055 `design.md:19` 允许任意匹配名称及短值,`:222` 只笼统承诺对已知值原文脱敏。实际 `src/runtime-manager/runtime_manager/container_runner.py:35–45` 仅替换名称含 `TOKEN/SECRET/KEY/...` 且长度至少 6 的值;正式日志入口 `api/platform_observe.py:75–85`、K8s 故障诊断 `executors/kubernetes.py:954–983` 均消费该函数,legacy 日志还有 `api/readonly.py:128–136` 的另一套过滤规则。 +- **影响:** 合法声明如 `PIN=1234`、`CERTIFICATE=...` 不满足旧规则。新 env 注入合同已经改变,设计尚未明确敏感项由谁标记、怎样传到上述日志和故障输出消费者,无法据此保证 **AC-68**。 +- **建议方向:** 明确由可信批准声明生成 operation 绑定的敏感项集合,规定所有日志、启动失败和 diagnostic 消费者采用相同集合,取消对这些业务密钥的名称猜测与最短长度门槛;覆盖短值、普通名称、换行及重叠值。 + +**S2|维度 4、6、7:部署加密密钥轮换没有覆盖历史 mutation 的幂等 HMAC。** + +- **锚点:** F055 `design.md:94` 指定请求幂等摘要由 `runtime_payload_key` 派生 HMAC,轮换维护只要求重加密密文及 resumable payload;`:188` 持久保存历史 `AppSecretMutation.request_hmac`,`:205` 又要求历史 request_id 相同参数重放返回原结果。 +- **影响:** 轮换后,同一历史请求会得到不同 HMAC。旧值若已被替换或删除,数据库只剩旧 HMAC,无法用新密钥重新生成;于是维护轮换会破坏设计承诺的历史幂等语义。 +- **建议方向:** 为幂等验证明确独立稳定密钥,或持久记录验证 key version 并保留可验证旧记录的密钥材料及清理规则。把 mutation 验证纳入维护轮换步骤与负向验证,无需引入完整 KMS。 + +**S3|维度 1:F054 的用户提示没有兑现已确认的配置不完整状态。** + +- **锚点:** F054 `design.md:55` 明确选择对配置原因显示通用“应用暂不可用”;`spec.md:176` 的 **AC-70** 要求原本有权访问者得到“本环境配置不完整,请联系平台管理员”状态。 +- **影响:** 配置缺失会被用户理解为应用临时故障,无法区分等待恢复与联系管理员处理;backend 的内部 reason 并不能替代对外承诺。 +- **建议方向:** 保留既有判定顺序,仅在已经确认访问资格后,让代理消费 `identity_config_incomplete` 并显示已确认的配置提示;不暴露配置值或内部地址。 + +**结论:先修正上述合同并复审,再进入实现;本报告不构成实施授权。** diff --git a/docs/reviews/vibe-governance-closeout-architecture-critique-round2-2026-10-23.md b/docs/reviews/vibe-governance-closeout-architecture-critique-round2-2026-10-23.md new file mode 100644 index 0000000000..d695a097c6 --- /dev/null +++ b/docs/reviews/vibe-governance-closeout-architecture-critique-round2-2026-10-23.md @@ -0,0 +1,62 @@ +**PROCEED — 最终修订未发现 blocker、should-fix 或 nit,可以提交用户进行设计确认。** 本报告为独立架构建议,不构成实现、部署或操作授权。 + +评审对象为 `/Users/zhangguoqing/works/bisheng/.worktrees/vibe-app-integration` 当前设计,源码基线为 `7b90271017b74f8c43075dd28e40d895462d6783`。本次复核重新读取了最终小修及其相关合同;`src/` 相对 HEAD 无未提交差异。未修改文件、运行测试、部署或委派任务。 + +## 七维评估 + +| 维度 | 评分 | 判断 | +|---|---|---| +| 1. 需求与质量属性 | 🟢 | F054 保持宿主隔离和 OBO 拒绝边界;F055 覆盖 owner 权限、秘密保护与保存后生效;F056 支持当前受权条件下完整导出。 | +| 2. 边界与公共接口 | 🟢 | 密钥资产仍归 F055,F054 经应用协议消费;配置生效显式共享同一 Session,F056 经 source-owner Repository 读取,领域写入权清楚。 | +| 3. 模式与实际压力 | 🟢 | guard 对应生命周期并发,刷新复用既有执行围栏,私有分块对应大结果集和多进程交付;没有新增缺乏需求依据的平台层。 | +| 4. 数据、状态与生产消费完整性 | 🟢 | request_id、revision、applied 元组及 guard 释放条件相互一致;敏感项、日志、事实、审计关联均指明生产与消费路径。 | +| 5. YAGNI 与变化容忍 | 🟢 | 未扩展部署 key 轮换、Vault、密钥历史恢复、热加载或批量重启;已有执行协议得到复用,未来扩展点仅作说明。 | +| 6. 故障、规模、重试与幂等 | 🟢 | 历史重放先于 CAS、失败清理后受限重试、未知 writer 不释放;导出固定读取视图、完整 manifest 发布、撤权与崩溃恢复合同明确。 | +| 7. 决策、理由与备选可读性 | 🟢 | 先前 request_id 唯一范围歧义已消除;F056 图示已与条件快照、固定读取视图和分块产物合同一致。 | + +## 最终小修核对 + +1. **幂等范围与 CAS 顺序一致。** + [F055 design.md:190](/Users/zhangguoqing/works/bisheng/.worktrees/vibe-app-integration/features/v3.0.0/055-app-publish-pipeline/design.md:190) 的 `(app_id,request_id)` 唯一约束与 [同文档:207](/Users/zhangguoqing/works/bisheng/.worktrees/vibe-app-integration/features/v3.0.0/055-app-publish-pipeline/design.md:207) 的应用内唯一规则一致;action、ref、value、expected_revision 和确认参数进入 HMAC,历史同参请求先返回原结果,不会因当前 revision 已变化而误报 CAS 冲突或重复写入。此前 nit 已关闭。 + +2. **配置修订与执行重试保持不同语义。** + [F055 design.md:211](/Users/zhangguoqing/works/bisheng/.worktrees/vibe-app-integration/features/v3.0.0/055-app-publish-pipeline/design.md:211) 明确每个新成功写入/删除均递增 revision,包括下线应用及未引用项;历史重放不递增。它与 `:108`“同一失败配置 revision 的新 attempt”一致:重试生效不会凭空产生新的配置内容。 + +3. **失败后的 guard 可以安全退出。** + [F055 design.md:219](/Users/zhangguoqing/works/bisheng/.worktrees/vibe-app-integration/features/v3.0.0/055-app-publish-pipeline/design.md:219) 已区分成功完成与失败、取消、超时:后者只有全部相关 writer 停止且清理确认后,才按 operation/revision CAS 释放;保留失败状态、不推进 applied_revision。它与 `:108`、`:212`、`:221` 的重试和未知执行约束相容,没有通过终态自动放开单写保护。 + +4. **未知时间与完整导出语义一致。** + [F056 design.md:75](/Users/zhangguoqing/works/bisheng/.worktrees/vibe-app-integration/features/v3.0.0/056-app-square-governance/design.md:75) 明确 all 保留未知时间行,recent7d/custom 不伪造其区间归属;`:79–83` 的源读取合同仍覆盖完整匹配结果。更新后的 [数据流图:115](/Users/zhangguoqing/works/bisheng/.worktrees/vibe-app-integration/features/v3.0.0/056-app-square-governance/design.md:115) 正确表达冻结条件和 watermark、worker 读取 source Repository、输出不可变 CSV 分块及 manifest,没有重新引入全量 ID 选择集。 + +5. **部署 key 轮换边界保持收敛。** + [F055 design.md:94](/Users/zhangguoqing/works/bisheng/.worktrees/vibe-app-integration/features/v3.0.0/055-app-publish-pipeline/design.md:94) 与 `:96` 仍将部署 key 定义为固定配置,并记录备份、误改拒绝和未来迁移说明;本轮没有要求实现轮换功能。owner 更新第三方业务密钥及刷新实例仍在范围内。 + +## Findings + +- **blocker:无。** +- **should-fix:无。** +- **nit:无。** + +## 最终评审对象 SHA-256 + +以下 `spec/design/tasks` 路径均位于 `features/v3.0.0/` 对应 Feature 目录;F056 哈希包含最后的 Mermaid 同步修正。 + +```text +F054 spec 1da2a0126379d1e469278ab1212b2ed710d9bbc9abdafa0beda15dddab299be4 +F054 design e52973922fe19a9ce0abb957bb41efcdd39bf958675fbaf1b818885d02d83629 +F054 tasks bce914e03c5ff352ab533d53812d8890ed150ca10fd4cd8e88a2d04b27d9576f + +F055 spec cf8591704198dbd5cd7131c4bd4dfcf964b60aef7e8d2a0c3fc0c2911f35c70c +F055 design d45bfeab9ee5464bfb5b8d7ad97f701971a70bd8fe6f43f698a41110b0c663ae +F055 tasks 5c326fdd0251dcd1d08f950e3fd1ba86beb3c34ff91a0886f4b901367f02281b + +F056 spec ddceb74c25e8a8f0a4ee858c997b32204f9150804d8a73f76ce27b9045d150a1 +F056 design 01cd39b77745e5fcb0a2e1ef2b981ab0cedb990a2aab16118adce412ca4bb4f3 +F056 tasks 4561a27f588f4db431d71ec5d5338970d34b3709a70fb36c6b6a6828a85021cc + +release-contract a423c2dafcbd75a161633cc2dd1e11b45d9015d38ef1d23762f918cb72a3283c +AGENTS.md 6c1c072ad7c818491901ec3e35664b0b2fd7eacd6a0eefd51b32ffc9ac358553 +constitution.md 4ea062832a2bd3b56d07ba2634ab8c7132a945315f32cc04f840e4c8dedb1c78 +``` + +**结论:三份最终设计已具备提交用户确认的条件,运行安全与一致性承诺仍需后续实现及定向验收证明。** diff --git a/docs/reviews/vibe-governance-closeout-architecture-critique-round3-2026-10-23.md b/docs/reviews/vibe-governance-closeout-architecture-critique-round3-2026-10-23.md new file mode 100644 index 0000000000..0c97859e66 --- /dev/null +++ b/docs/reviews/vibe-governance-closeout-architecture-critique-round3-2026-10-23.md @@ -0,0 +1,43 @@ +**Verdict: REVISE — 存在 1 项阻塞问题和 1 项应修正问题;这是设计建议,不代表实施授权。** + +| 维度 | 评价 | 依据 | +|---|---|---| +| Requirements and quality attributes | 🟡 Yellow | 新增密钥、OBO 和审计需求大体覆盖,但既有实例的隔离持续性及凭据回收留痕尚未闭环。 | +| Boundary quality | 🟡 Yellow | Owner 边界清楚;凭据回收的实际写方未进入新增审计生产合同。 | +| Pattern restraint | 🟢 Green | 复用现有 operation、执行器及专表,没有引入通用工作流或统一审计大表。 | +| Data/state modeling | 🟢 Green | revision、guard、执行事实、同一事务 receipt,以及导出 attempt/epoch 的关系已明确。 | +| YAGNI/change tolerance | 🟢 Green | 明确排除部署 key 轮换、热加载及外部 Vault,范围适当。 | +| Failure/scale realism | 🔴 Red | 宿主隔离失效后的动作仅限制新操作,无法收敛仍在运行的应用。 | +| Decision capture | 🟢 Green | 主要取舍、兼容边界和验证限制均有记录。 | + +## Blocker + +### B1 — 宿主隔离规则丢失后,既有执行体没有恢复或隔离路径 + +- **维度:** Failure/scale realism;requirements and quality attributes。 +- **设计锚点:** `054-app-domain-runtime/design.md:155` 将持续任务定义为“只验证”,宿主/dockerd 重启后只要求先验证再放行新部署;`:161` 的证明消费点限于新操作 create/claim/提交路由,既有应用继续保留。`:151` 的失败恢复针对安装更新过程,未覆盖运行期间规则丢失。 +- **需求锚点:** `054-app-domain-runtime/spec.md:172–175`,AC-66 要求阻断应用主动直连宿主服务,AC-68 要求重启后隔离保持有效。 +- **可复现失败序列:** 应用已正常运行且证明通过 → 受控故障注入移除本模块 INPUT 跳转,模拟宿主防火墙重载丢失规则 → 应用进程继续运行 → 下一次 verifier 报 failed → backend 拒绝新部署,但现存应用仍可持续连接原本禁止的宿主监听端口。证明过期或失败本身不会关闭这条网络路径。 +- **为什么重要:** 新部署准入只能避免增加不安全实例,不能恢复现有实例已经失去的隔离。这不涉及防御恶意宿主 root,而是设计自身持续验证所检测出的故障没有执行消费者。 +- **最小修正方向:** 增补宿主规则的运行期收敛合同:由宿主 unit 恢复自有规则并读回、配对验证;恢复失败时,明确隔离或停止受影响执行体的责任方及失败状态,同时保留原 writer 停止证明。不要泛称每次 dockerd 重启都会删除规则;应验证具体支持环境的启动和规则恢复顺序。 + +## Should-fix + +### S1 — 补 `CredentialService._audit` 的应用关联,仍覆盖不到实际运行期凭据回收 + +- **维度:** Boundary quality;requirements and quality attributes。 +- **设计锚点:** `056-app-square-governance/design.md:95–99`、`:142` 指定由凭据 Owner 在签发/撤销时补可信应用关联,具体落点为 `CredentialService._audit`。 +- **已核实代码锚点:** + - `src/backend/bisheng/app_publish/domain/services/app_credential_service.py:186–213`:新 operation 经 `stage()` 签发候选凭据。 + - `src/backend/bisheng/app_runtime/domain/services/runtime_projection_service.py:50–55`:正式切换直接将旧 `RuntimeCredentialBinding` 标记为 `revoked`。 + - 同文件 `:64–68`:失败/停止候选的终结路径也直接撤销 binding。 + - `src/backend/bisheng/open_api/domain/services/credential_service.py:305–333`:上述 binding 撤销不经过 `_audit`。 +- **可复现失败序列:** 正式 v1 运行 → 发布 v2,或执行新增同版本配置刷新 → stage 签发新凭据 → 切路由时旧 binding 被撤销、旧凭据失去运行资格 → 按应用查询操作审计。即使 `_audit` 已补 `target_app_id`,仍只有新凭据签发事件,没有这次旧凭据回收事件。 +- **为什么重要:** F056 AC-59 和 F055 AC-81 承诺按应用追溯运行期凭据签发/回收;真实回收写方没有对应事件生产合同,查询侧无法补出缺失事实。 +- **最小修正方向:** 将 projection 的 binding 撤销纳入凭据 Owner 的公开应用协议,在真实状态变化处持久记录带可信 app 关联的回收事件,并按 credential/operation/action 去重;同时覆盖切换撤旧和失败候选清理。无需新增并列凭据管理服务。 + +## Nit + +无。 + +本次仅核对设计、规格、发布契约和当前源码;未修改文件、未执行测试或远程操作。 diff --git a/docs/reviews/vibe-governance-closeout-design-rereview-2026-10-23.html b/docs/reviews/vibe-governance-closeout-design-rereview-2026-10-23.html new file mode 100644 index 0000000000..2cc08d14d3 --- /dev/null +++ b/docs/reviews/vibe-governance-closeout-design-rereview-2026-10-23.html @@ -0,0 +1,70 @@ +Vibe 设计复评 · NEEDS_FIX
历史复评快照:原问题已授权修订。查看当前修订后复审

Vibe 治理收尾 · 设计复评

+

结论:NEEDS_FIX / REVISE。2 项 high、3 项 medium;尚不适合进入任务拆解与编码。

+

日期:2026-10-23。固定设计/源码提交:7b90271017b74f8c43075dd28e40d895462d6783。本轮为用户要求的“先评审设计”,不是设计确认或修订授权。仅只读静态核验;未运行测试、未操作远端、未改 spec/design/产品代码。

+

1. 复评方式与范围

+

按 sdd-review design 的 24 项清单复核 F054/F055/F056,并使用 architecture-critic 要求的一名独立、无编写历史的 agent 审查七个架构维度。独立报告原文保存在 architecture-critique.md。主 agent 复核其四项发现,再补一项运行状态完成协议问题;下表的严重度按 SDD 清单映射,不等同于独立报告 blocker/should-fix 的词面数量。

+

本轮结论取代上次自审 LGTM 作为当前评审状态;旧报告保留历史文本并加失效提示。已确认规格与未获确认设计保持原文,等用户决定修订。整体开源、独立进程、平台存储解耦和 formal-only 真实值边界没有被否定。

+

2. 发现项

+

R1 · high · 10,000 行/90 天硬限制违背已确认的全部导出

+

ISSUE: 10,000 行/90 天硬限制违背已确认的全部导出 | SEVERITY: high | LOC: F056 design.md:18(固定提交行号)

+

F056 AC-56 及原 AC-32 要求导出当前筛选的全部受授权记录。设计 §2、D13 把 10,001 行及超过 90 天的合法筛选直接拒绝,属于需求收窄,不能仅称作实现预算。应将内部批次做成有界,持久任务完成全部冻结选择;若要用户总量限制,须先提规格变更。

+

关联规格:F056 AC-32、F056 AC-56。

+

R2 · high · OBO 配置错误提示与已确认规格不一致

+

ISSUE: OBO 配置错误提示与已确认规格不一致 | SEVERITY: high | LOC: F054 design.md:55(固定提交行号)

+

F054 AC-70 已明确对原本有权者显示“本环境配置不完整,请联系平台管理员”;设计 D7 却要求通用“应用暂不可用”。应在原登录/租户/可见性判定之后,把 identity_config_incomplete 映射为确认过的配置提示,不暴露配置值。

+

关联规格:F054 AC-70、F054 AC-71。

+

R3 · medium · 密钥敏感项缺少从注入到全部日志/诊断路径的明确传递合同

+

ISSUE: 密钥敏感项缺少从注入到全部日志/诊断路径的明确传递合同 | SEVERITY: medium | LOC: F055 design.md:222(固定提交行号)

+

现有 container_runner.redact 仅替换名称含 TOKEN/SECRET/KEY 等且值长至少 6 的环境值。PIN=1234 或普通名称下的长值均可绕过旧规则。设计只写“已知值原文脱敏”,未明确批准声明如何生成 operation 绑定敏感项以及 Docker 正式日志、legacy 日志、K8s 日志/启动失败、diagnostic_tail 落库前各消费者如何取得并使用。应统一敏感清单,先脱敏再截断/分行,并覆盖短值、换行/跨块、重叠值;不宣称防住应用主动编码外传。

+

关联规格:F055 AC-68、F055 AC-79。

+

R4 · medium · 部署密钥轮换后历史 request_id 的 HMAC 无法按现有合同验证

+

ISSUE: 部署密钥轮换后历史 request_id 的 HMAC 无法按现有合同验证 | SEVERITY: medium | LOC: F055 design.md:94(固定提交行号)

+

幂等摘要由可轮换 runtime_payload_key 派生,但维护方案只重加密业务密文与运行 payload。历史 mutation 仅存 HMAC,旧值已改/删时无法重新生成;同参历史重试会成为错误冲突或失去幂等保障。应选择独立稳定验证 key 或持久 key version + 旧 key 验证/保留策略,明确轮换/清理步骤。

+

关联规格:F055 AC-77。

+

R5 · medium · 路由生效与 applied_revision 的完成/恢复协议仍含未定选择

+

ISSUE: 路由生效与 applied_revision 的完成/恢复协议仍含未定选择 | SEVERITY: medium | LOC: F055 design.md:213(固定提交行号)

+

设计写“尽量同事务”,没有选定唯一完成点或给出跨事务重放步骤。现有 AppStateService.commit_runtime_delivery 在 op.stage=serving 时立即返回(658–659 行),并由 RuntimeProjectionService.activate 提交路由;若路由已提交而 F055 结果回调失败,沿用旧幂等早退可能不再补写 applied_revision/释放配置 guard,导致实际运行新值却长期显示 pending/unknown。应明确同一 UoW 的公开结果适配协议,或以 version/revision/operation/exec_ref 冻结事实幂等补齐的恢复路径;覆盖该崩溃点及 completed/serving 重试。这里是设计遗漏,不是已复现的产品缺陷。

+

关联规格:F055 AC-76、F055 AC-77、F055 AC-86。

+

3. 证据与定向复验要求

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
证据对应结论修订后需验证什么
F056 spec AC-56;design §2 / D13R1大于 10,000 行仍完整导出且扫描有限,不靠缩小筛选规避规格
F054 spec AC-70;design D7R2已有权者得到配置提示;匿名/不存在/跨租户/无权限仍按原顺序拒绝
container_runner.py:35;api/platform_observe.py:75;executors/kubernetes.py:954;api/readonly.py:128R3PIN 短值、普通名称、Unicode/换行、截断/跨块不泄漏到返回和持久诊断
F055 D17 / AppSecretMutation / request_id 合同R4轮换前历史请求在轮换后同参可识别、异参拒绝、无重复写入
app_state_service.py:648 / runtime_projection_service.py:activateR5路由已提交但配置结果未提交时崩溃,重启只补结果,不重复启实例/DDL;旧 revision 不能确认新值
+

4. SDD 清单结论

+

24 项结构/追溯仍已覆盖;复评发现第 10 项(字段/完成协议)、第 17–18 项(日志敏感项与密钥轮换依赖)、第 23 项(spec 一致)不能维持 PASS,因此总体 NEEDS_FIX。追溯表“有一行映射”不等于设计兑现了该 AC。Constitution 的分层/租户/存储边界暂未发现新的实锤违反;这不是产品测试通过结论。

+

5. 后续边界

+

应先修订并复审以上合同,再由用户单独确认设计。保留已批准的规格,不把 R1/R2 的偏差直接改进规格当作已获授权。只需相应定向验证,不要求重新跑全部 Docker/K8s 回归、ARM、DM8 真测或镜像发行。

+

本轮保留原设计,不自动修订、不拆任务或编码。架构批评为评审建议;实施阶段仍遵守仓库 SDD 确认点。

+
\ No newline at end of file diff --git a/docs/reviews/vibe-governance-closeout-design-rereview-2026-10-23.json b/docs/reviews/vibe-governance-closeout-design-rereview-2026-10-23.json new file mode 100644 index 0000000000..415236248c --- /dev/null +++ b/docs/reviews/vibe-governance-closeout-design-rereview-2026-10-23.json @@ -0,0 +1,98 @@ +{ + "date": "2026-10-23", + "reviewed_commit": "7b90271017b74f8c43075dd28e40d895462d6783", + "result": "NEEDS_FIX", + "independent_verdict": "REVISE", + "high": 2, + "medium": 3, + "design_confirmed": false, + "product_tests_executed": false, + "remote_mutations": false, + "canonical_designs_edited": false, + "findings": [ + { + "id": "R1", + "severity": "high", + "feature": "F056", + "line": 18, + "title": "10,000 行/90 天硬限制违背已确认的全部导出", + "detail": "F056 AC-56 及原 AC-32 要求导出当前筛选的全部受授权记录。设计 §2、D13 把 10,001 行及超过 90 天的合法筛选直接拒绝,属于需求收窄,不能仅称作实现预算。应将内部批次做成有界,持久任务完成全部冻结选择;若要用户总量限制,须先提规格变更。", + "ac": [ + "F056 AC-32", + "F056 AC-56" + ], + "origin": [ + "critic B1", + "primary verification" + ] + }, + { + "id": "R2", + "severity": "high", + "feature": "F054", + "line": 55, + "title": "OBO 配置错误提示与已确认规格不一致", + "detail": "F054 AC-70 已明确对原本有权者显示“本环境配置不完整,请联系平台管理员”;设计 D7 却要求通用“应用暂不可用”。应在原登录/租户/可见性判定之后,把 identity_config_incomplete 映射为确认过的配置提示,不暴露配置值。", + "ac": [ + "F054 AC-70", + "F054 AC-71" + ], + "origin": [ + "critic S3", + "primary verification" + ] + }, + { + "id": "R3", + "severity": "medium", + "feature": "F055", + "line": 222, + "title": "密钥敏感项缺少从注入到全部日志/诊断路径的明确传递合同", + "detail": "现有 container_runner.redact 仅替换名称含 TOKEN/SECRET/KEY 等且值长至少 6 的环境值。PIN=1234 或普通名称下的长值均可绕过旧规则。设计只写“已知值原文脱敏”,未明确批准声明如何生成 operation 绑定敏感项以及 Docker 正式日志、legacy 日志、K8s 日志/启动失败、diagnostic_tail 落库前各消费者如何取得并使用。应统一敏感清单,先脱敏再截断/分行,并覆盖短值、换行/跨块、重叠值;不宣称防住应用主动编码外传。", + "ac": [ + "F055 AC-68", + "F055 AC-79" + ], + "origin": [ + "critic S1", + "primary verification" + ] + }, + { + "id": "R4", + "severity": "medium", + "feature": "F055", + "line": 94, + "title": "部署密钥轮换后历史 request_id 的 HMAC 无法按现有合同验证", + "detail": "幂等摘要由可轮换 runtime_payload_key 派生,但维护方案只重加密业务密文与运行 payload。历史 mutation 仅存 HMAC,旧值已改/删时无法重新生成;同参历史重试会成为错误冲突或失去幂等保障。应选择独立稳定验证 key 或持久 key version + 旧 key 验证/保留策略,明确轮换/清理步骤。", + "ac": [ + "F055 AC-77" + ], + "origin": [ + "critic S2", + "primary verification" + ] + }, + { + "id": "R5", + "severity": "medium", + "feature": "F055", + "line": 213, + "title": "路由生效与 applied_revision 的完成/恢复协议仍含未定选择", + "detail": "设计写“尽量同事务”,没有选定唯一完成点或给出跨事务重放步骤。现有 AppStateService.commit_runtime_delivery 在 op.stage=serving 时立即返回(658–659 行),并由 RuntimeProjectionService.activate 提交路由;若路由已提交而 F055 结果回调失败,沿用旧幂等早退可能不再补写 applied_revision/释放配置 guard,导致实际运行新值却长期显示 pending/unknown。应明确同一 UoW 的公开结果适配协议,或以 version/revision/operation/exec_ref 冻结事实幂等补齐的恢复路径;覆盖该崩溃点及 completed/serving 重试。这里是设计遗漏,不是已复现的产品缺陷。", + "ac": [ + "F055 AC-76", + "F055 AC-77", + "F055 AC-86" + ], + "origin": [ + "primary review" + ] + } + ], + "design_sha256": { + "F054": "71c7dd43cb35832e547f4893d092d52fb60bbf69e37c9fe58ab24bc5cce54cb3", + "F055": "d66add116486204bb2bfd653f8fa87d59c42b1472aa36835003e298af7bc5ef0", + "F056": "375bf364e0a585f66eac7015e0d8ef33a9b48fefbc8d0ce0a437cabf903f0644" + } +} diff --git a/docs/reviews/vibe-governance-closeout-design-rereview-2026-10-23.md b/docs/reviews/vibe-governance-closeout-design-rereview-2026-10-23.md new file mode 100644 index 0000000000..cebed06cdc --- /dev/null +++ b/docs/reviews/vibe-governance-closeout-design-rereview-2026-10-23.md @@ -0,0 +1,75 @@ +> **历史复评快照**:本页的 NEEDS_FIX 针对 7b9027101;原问题已由用户授权修订并另行复审,当前结果见[修订后复审](vibe-governance-closeout-design-revision2-review-2026-10-23.md)。 + +# Vibe 治理收尾 · 设计复评 + +**结论:NEEDS_FIX / REVISE。2 项 high、3 项 medium;尚不适合进入任务拆解与编码。** + +日期:2026-10-23。固定设计/源码提交:`7b90271017b74f8c43075dd28e40d895462d6783`。本轮为用户要求的“先评审设计”,不是设计确认或修订授权。仅只读静态核验;未运行测试、未操作远端、未改 spec/design/产品代码。 + +## 1. 复评方式与范围 + +按 sdd-review design 的 24 项清单复核 F054/F055/F056,并使用 architecture-critic 要求的一名独立、无编写历史的 agent 审查七个架构维度。独立报告原文保存在 [architecture-critique.md](vibe-governance-closeout-architecture-critique-round1-2026-10-23.md)。主 agent 复核其四项发现,再补一项运行状态完成协议问题;下表的严重度按 SDD 清单映射,不等同于独立报告 blocker/should-fix 的词面数量。 + +本轮结论**取代上次自审 LGTM 作为当前评审状态**;旧报告保留历史文本并加失效提示。已确认规格与未获确认设计保持原文,等用户决定修订。整体开源、独立进程、平台存储解耦和 formal-only 真实值边界没有被否定。 + +## 2. 发现项 + +### R1 · high · 10,000 行/90 天硬限制违背已确认的全部导出 + +ISSUE: 10,000 行/90 天硬限制违背已确认的全部导出 | SEVERITY: high | LOC: [F056 design.md:18](../../features/v3.0.0/056-app-square-governance/design.md)(固定提交行号) + +F056 AC-56 及原 AC-32 要求导出当前筛选的全部受授权记录。设计 §2、D13 把 10,001 行及超过 90 天的合法筛选直接拒绝,属于需求收窄,不能仅称作实现预算。应将内部批次做成有界,持久任务完成全部冻结选择;若要用户总量限制,须先提规格变更。 + +关联规格:F056 AC-32、F056 AC-56。 + +### R2 · high · OBO 配置错误提示与已确认规格不一致 + +ISSUE: OBO 配置错误提示与已确认规格不一致 | SEVERITY: high | LOC: [F054 design.md:55](../../features/v3.0.0/054-app-domain-runtime/design.md)(固定提交行号) + +F054 AC-70 已明确对原本有权者显示“本环境配置不完整,请联系平台管理员”;设计 D7 却要求通用“应用暂不可用”。应在原登录/租户/可见性判定之后,把 identity_config_incomplete 映射为确认过的配置提示,不暴露配置值。 + +关联规格:F054 AC-70、F054 AC-71。 + +### R3 · medium · 密钥敏感项缺少从注入到全部日志/诊断路径的明确传递合同 + +ISSUE: 密钥敏感项缺少从注入到全部日志/诊断路径的明确传递合同 | SEVERITY: medium | LOC: [F055 design.md:222](../../features/v3.0.0/055-app-publish-pipeline/design.md)(固定提交行号) + +现有 container_runner.redact 仅替换名称含 TOKEN/SECRET/KEY 等且值长至少 6 的环境值。PIN=1234 或普通名称下的长值均可绕过旧规则。设计只写“已知值原文脱敏”,未明确批准声明如何生成 operation 绑定敏感项以及 Docker 正式日志、legacy 日志、K8s 日志/启动失败、diagnostic_tail 落库前各消费者如何取得并使用。应统一敏感清单,先脱敏再截断/分行,并覆盖短值、换行/跨块、重叠值;不宣称防住应用主动编码外传。 + +关联规格:F055 AC-68、F055 AC-79。 + +### R4 · medium · 部署密钥轮换后历史 request_id 的 HMAC 无法按现有合同验证 + +ISSUE: 部署密钥轮换后历史 request_id 的 HMAC 无法按现有合同验证 | SEVERITY: medium | LOC: [F055 design.md:94](../../features/v3.0.0/055-app-publish-pipeline/design.md)(固定提交行号) + +幂等摘要由可轮换 runtime_payload_key 派生,但维护方案只重加密业务密文与运行 payload。历史 mutation 仅存 HMAC,旧值已改/删时无法重新生成;同参历史重试会成为错误冲突或失去幂等保障。应选择独立稳定验证 key 或持久 key version + 旧 key 验证/保留策略,明确轮换/清理步骤。 + +关联规格:F055 AC-77。 + +### R5 · medium · 路由生效与 applied_revision 的完成/恢复协议仍含未定选择 + +ISSUE: 路由生效与 applied_revision 的完成/恢复协议仍含未定选择 | SEVERITY: medium | LOC: [F055 design.md:213](../../features/v3.0.0/055-app-publish-pipeline/design.md)(固定提交行号) + +设计写“尽量同事务”,没有选定唯一完成点或给出跨事务重放步骤。现有 AppStateService.commit_runtime_delivery 在 op.stage=serving 时立即返回(658–659 行),并由 RuntimeProjectionService.activate 提交路由;若路由已提交而 F055 结果回调失败,沿用旧幂等早退可能不再补写 applied_revision/释放配置 guard,导致实际运行新值却长期显示 pending/unknown。应明确同一 UoW 的公开结果适配协议,或以 version/revision/operation/exec_ref 冻结事实幂等补齐的恢复路径;覆盖该崩溃点及 completed/serving 重试。这里是设计遗漏,不是已复现的产品缺陷。 + +关联规格:F055 AC-76、F055 AC-77、F055 AC-86。 + +## 3. 证据与定向复验要求 + +| 证据 | 对应结论 | 修订后需验证什么 | +|---|---|---| +| F056 spec AC-56;design §2 / D13 | R1 | 大于 10,000 行仍完整导出且扫描有限,不靠缩小筛选规避规格 | +| F054 spec AC-70;design D7 | R2 | 已有权者得到配置提示;匿名/不存在/跨租户/无权限仍按原顺序拒绝 | +| container_runner.py:35;api/platform_observe.py:75;executors/kubernetes.py:954;api/readonly.py:128 | R3 | PIN 短值、普通名称、Unicode/换行、截断/跨块不泄漏到返回和持久诊断 | +| F055 D17 / AppSecretMutation / request_id 合同 | R4 | 轮换前历史请求在轮换后同参可识别、异参拒绝、无重复写入 | +| app_state_service.py:648 / runtime_projection_service.py:activate | R5 | 路由已提交但配置结果未提交时崩溃,重启只补结果,不重复启实例/DDL;旧 revision 不能确认新值 | + +## 4. SDD 清单结论 + +24 项结构/追溯仍已覆盖;复评发现第 10 项(字段/完成协议)、第 17–18 项(日志敏感项与密钥轮换依赖)、第 23 项(spec 一致)不能维持 PASS,因此总体 NEEDS_FIX。追溯表“有一行映射”不等于设计兑现了该 AC。Constitution 的分层/租户/存储边界暂未发现新的实锤违反;这不是产品测试通过结论。 + +## 5. 后续边界 + +应先修订并复审以上合同,再由用户单独确认设计。保留已批准的规格,不把 R1/R2 的偏差直接改进规格当作已获授权。只需相应定向验证,不要求重新跑全部 Docker/K8s 回归、ARM、DM8 真测或镜像发行。 + +本轮保留原设计,不自动修订、不拆任务或编码。架构批评为评审建议;实施阶段仍遵守仓库 SDD 确认点。 diff --git a/docs/reviews/vibe-governance-closeout-design-review-2026-10-23.html b/docs/reviews/vibe-governance-closeout-design-review-2026-10-23.html new file mode 100644 index 0000000000..65e7ded648 --- /dev/null +++ b/docs/reviews/vibe-governance-closeout-design-review-2026-10-23.html @@ -0,0 +1,1633 @@ +Vibe 治理收尾 · 当前正式设计

Vibe 治理收尾 · 当前正式设计

F055简化通过;F054/F056仍待修 · 未编码

后续状态:用户已确认F055简化设计,进入实施。 以下为设计评审记录,不代表实现/E2E已完成。

Vibe 治理收尾 · 当前设计评审

+
+

F055 单表/用户确认重建设计:LGTM、独立 PROCEED,待用户设计确认。 三份设计整体仍有 F054 B1(宿主隔离恢复)和 F056 S1(binding回收审计)未关闭;不能以本次F055结论宣称全部设计已通过。

+
+

1. 当前入口与范围

+

用户已确认 F055 手动生效修订规格,92条AC;保存不重启、差异提示、用户确认后复用部署流程。正式设计已覆盖旧三表自动方案。原规格确认和新规格确认的批准hash分别保留。

+ + + + + + + + + + + + + + + + + + + + + +
Feature当前情况
F054用户确认重建/运行快照的消费合同已同步;自身宿主隔离 B1 仍待修、设计未确认
F055单表配置、保存零执行、运行值比较、确认冻结及重建;本轮24项与独立架构复审通过,未编码
F056运行记录查询/导出设计保留,实际binding回收的审计producer S1仍待修、设计未确认
+ +

2. 新旧方案对比

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
事项当前正式设计
数据一张AppSecretConfig,执行/幂等/路由复用RuntimeOperation等既有资产
保存配置+审计短事务,无manager调用,无自动重启
生效用户明确确认后重建当前运行版本,保留原镜像/档位/数据
差异实际运行值与最新保存值比较;未引用/A→B→A不误报,缺上下文unknown
执行衔接既有auto_commit、精确maintenance转换、局部锁图、session-aware凭据producer
保护owner会话写、加密存储、预检/预览空值、保留变量与日志保护;部署key轮换仅运维
+

3. 规格增量逐条追溯

+

共55条当前增量条款:原48新增+4修订,另新增AC-90、AC-91、AC-92;本次又修订其中6条。不代表实现/测试通过;F054 AC-66、AC-68、AC-69仍关联B1,F055 AC-81及F056 AC-59的运行凭据回收部分仍关联S1。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Feature / AC当前条款设计落点
F054 AC-66WHERE 使用受支持的 Docker 单机或 legacy systemd 托管形态, THE SYSTEM SHALL 阻止应用绕过受控出口新建到宿主平台服务的连接,包括经应用网络网关和宿主发布地址访问数据库、backend、对象存储及宿主管理端口;应用经受控出口使用已授权的 SDK、模型和附件能力应继续可用,不因附件业务在 backend 而放开该宿主服务的全部接口。D12 / §4.4③④
F054 AC-67WHEN 宿主访问隔离生效, THE SYSTEM SHALL 保持统一入口的 HTTP、WebSocket、受控探活及其合法返回流量可用,且不得阻断平台其他业务网络;由应用主动建立的禁止连接不能冒充合法返回流量取得放行。D12 / §4.4③
F054 AC-68WHEN 安装重复执行、运行服务重启或模块卸载, THE SYSTEM SHALL 分别保持隔离规则幂等有效,或只撤销本模块拥有的隔离规则;不得复制出重复放行规则、清空宿主全局策略或改变无关服务规则。§4.4③④ / §7
F054 AC-69IF 必需的宿主隔离无法应用、无法确认,或已启用的网络地址族未获得同等保护, THEN THE SYSTEM SHALL 明确报告该运行环境隔离不可用并拒绝把它作为可发布环境;不得只告警后放行,亦不得把未知旧写入者的强制删除当作修复隔离的方法。§2 / §4.4④
F054 AC-70IF 工场运行时已启用且 OBO 签发配置缺失、仅为空白或与平台会话签名配置相同, THEN THE SYSTEM SHALL 对原本有权进入的正式应用访问返回“本环境配置不完整,请联系平台管理员”状态,不转发到应用、不下发可用身份材料,也不留成功访问记录。D6 / §4.4①
F054 AC-71IF 预览访问发生相同 OBO 配置错误, THEN THE SYSTEM SHALL 同样拒绝进入预览;正式与预览入口都须保留既有登录、存在性、跨租户、访问权限及运行层未部署的判定顺序,不能借配置错误暴露调用者本来无权得知的应用信息。D6 / §4.4①
F054 AC-72WHEN backend 启动且工场运行时已启用, THE SYSTEM SHALL 对上述 OBO 配置错误记录不含任何密钥值的错误诊断;运行时层未部署时不制造已启用该层的假象,正常访问仍遵守原未部署提示。D6 / §4.3
F054 AC-73WHERE OBO 使用正确且独立的签发配置, THE SYSTEM SHALL 保持现有正式/预览授权、身份注入、防伪头、短时凭据、撤权及 SDK/OBO 调用行为,并以允许与拒绝的配对验证证明本次收紧没有引入匿名降级。D6 / §6.1 / §7
F055 AC-54THE SYSTEM SHALL 使平台模型、知识库的底层账号与连接配置继续只在原管理面维护;owner 可在应用详情页发布 tab 维护本应用调用第三方系统所需的应用密钥,但不能借此修改平台通道、服务身份或执行环境的安全配置。(2026-10-09 GOV-05 决议对 Discovery N4 的修订)D3 / §4.4⑤
F055 AC-56WHEN 能力声明只包含合法的应用密钥引用名, THE SYSTEM SHALL 接受该声明并按本节密钥规则处理;IF 引用尚未录入值, THEN THE SYSTEM SHALL 输出缺值警告而不以“本版不支持密钥引用”拒绝发布;发布产物含实际明文密钥时,原密钥扫描仍必须拦截。(GOV-05 验收 8、验收 10;替代旧 Discovery N4 排除项)D3 / D5
F055 AC-66WHEN 目标应用的自然人 owner 录入、修改或删除应用密钥值, THE SYSTEM SHALL 仅对该 owner 开放相应业务操作;租户管理员、非 owner 平台超管、审批人、普通成员、服务账号及个人访问令牌不得因管理身份、拥有 app:manage 或知道应用标识而取得该写权限。§2 / §4.4②
F055 AC-67WHEN 查看应用详情页发布 tab 的应用密钥区, THE SYSTEM SHALL 向 owner 展示引用名、固定掩码、已录入/缺少值状态及最近修改时间,并区分批准版本、待审批版本和未被当前版本引用的已存值;本租户管理员只能查看引用名和状态,审批人仅在其有权审阅的审批单内查看该版本的引用与缺值提示。D20 / §4.4①②⑥
F055 AC-68THE SYSTEM SHALL 加密保存应用密钥值;录入后不存在查询明文、导出明文或用界面掩码还原值的能力;应用管理、版本、审批、日志、错误和审计的任何平台输出均不含密钥值、其片段或可复用的访问凭据。D17 / D20 / §4.4⑤
F055 AC-69WHEN 新增或删除应用版本声明中的引用名, THE SYSTEM SHALL 将其作为能力声明变更随发布审批生效并在审批摘要中展示引用名;录入、替换或删除一个值不会擅自修改任何版本的声明,也不产生发布审批单。D6 / D7 / §4.4⑤
F055 AC-70WHEN 已批准的正式版本启动、恢复或经用户确认重建, THE SYSTEM SHALL 只把该版本明确声明的引用名注入为同名应用环境变量,使用本次启动操作受理时选定的已保存配置;未声明的值、其他应用的值与其他租户的值不得进入该实例,代码、依赖和运行环境的批准组合保持不变。§4.4③
F055 AC-71WHEN 执行构建、依赖准备、静态扫描、预检启动或审批期预览, THE SYSTEM SHALL 不注入任何真实应用密钥值;预检启动和审批预览只获得该版本声明的变量名及空值,不能借复用已上线实例或材料取得生产值。(2026-10-23 用户明确确认)§2 / §4.1 / §4.4③⑤
F055 AC-72IF 声明引用缺少已录入值, THEN THE SYSTEM SHALL 在 CLI deploy、发布 tab 和对应审批单显示引用名与缺值状态,且不因缺值本身拒绝提交或生成审批单;仍须执行原启动/探活检查,应用自身因空值无法启动时按原失败流程处理,不跳过真实健康校验。D4 / §7
F055 AC-73WHEN owner 删除仍被在线版本引用的密钥值, THE SYSTEM SHALL 在确认删除影响后仅清除已保存的值,保留版本引用名,不自动改变或停止当前实例;界面提示运行实例仍可能使用旧值。下一次正式启动或用户确认重建时该引用注入为空,仍可由 owner 补录。§4.4②⑥
F055 AC-74WHEN owner 新增、修改或删除应用密钥值, THE SYSTEM SHALL 仅保存配置,不自动停止、启动或重建应用实例;在线应用使用的值与最新保存值不一致时显示未生效变更,是否现在重建由 owner 显式确认。用户取消或仅关闭提示时运行实例保持不变。D18 / §4.4②⑥
F055 AC-75WHEN owner 为已下线、草稿或尚未上线的版本保存值,或修改未被在线版本引用的值, THE SYSTEM SHALL 保持原应用状态且不擅自上线或无关重启;此后按原审批/上线动作启动时使用最新有效值。D18 / §4.4③
F055 AC-76IF owner 已确认的配置重建失败, THEN THE SYSTEM SHALL 保留已保存配置,明确区分“配置已保存”和“实例重建失败”,按实际运行状态显示未生效或无法确认,并允许遵守原生命周期约束的再次确认重试;不得谎报新配置已生效、自动重试重建、静默恢复旧值或回滚代码/业务数据。D18 / §4.4③⑥
F055 AC-77WHEN 配置保存并发发生, THE SYSTEM SHALL 明确保存次序或拒绝过期编辑,已拒绝请求不得改变已存值;WHEN 保存与部署、停用、迁移、恢复、删除并发发生, THE SYSTEM SHALL 保持已有执行的配置选择及单写约束,旧执行结果不得覆盖最新保存配置,后保存内容未进入当前实例时保留差异提示;未知执行结果不得自动重复发起数据维护或重建。D19 / §4.4①③
F055 AC-78IF 引用名无效、重复或企图覆盖平台身份、平台通道、应用启动、语言运行环境及代理隔离所需的保留变量, THEN THE SYSTEM SHALL 在提交声明或保存值时明确拒绝并指出相应字段,不截断或静默替换名称。§2 / §4.4⑤
F055 AC-79WHEN owner 保存合法值, THE SYSTEM SHALL 保留其 Unicode、换行及特殊字符的原始语义,使应用读取到同一值而不执行其中内容;空字符串不能被标为已录入,超过已公布输入限制时明确拒绝,未编辑的掩码不能被当成新值提交。§2 / D20
F055 AC-80WHERE 不同应用或租户使用相同引用名, THE SYSTEM SHALL 分别维护各自的值与状态;对不存在或无权限的应用作查询/写入时,须遵守原应用存在性和租户拒绝语义,不通过状态、列表、错误或掩码泄漏另一作用域的信息。§4.4①②
F055 AC-81WHEN 应用密钥值成功录入、修改或删除, THE SYSTEM SHALL 由本业务写入应用、引用名、操作者、动作、时间和结果的审计,不记录值;应用运行期凭据的签发/回收仍由原凭据 Owner 留痕,并须携带受信的应用关联供治理面追溯。§4.4① / §6.1(回收审计S1仍待修)
F055 AC-82THE SYSTEM SHALL 使真实应用密钥值不进入源码压缩包、版本快照、依赖缓存、构建产物、数据库快照、审批内容或应用附件;受限的正式执行交付可获得其必需的值,但普通查询者、预检执行体、代理和材料传输身份不得因此取得密钥管理能力。§4.1 / §4.4③⑤
F055 AC-83WHEN 应用停用、进程重启、代码升级、移除某版本引用或准备环境缓存被回收, THE SYSTEM SHALL 保留仍在平台存储中的密钥资产而不再向未声明的版本注入;仅 owner 明确删除值或按原 owner 应用删除流程清理相应密钥,历史无值审计仍可追溯。§4.4①③
F055 AC-84WHEN 查看能力声明和审批摘要, THE SYSTEM SHALL 展示该版本的引用名及对应缺值提示而不展示值;只有有效审批关系、owner 或原声明只读权限允许的访问者可见其授权范围内的信息,不能把审批人的有限审阅权限变成密钥管理权限。D7 / §4.4⑥
F055 AC-85WHEN owner 通过密钥区保存一个未被正式版本引用的合法名值, THE SYSTEM SHALL 把它保持为未生效的应用私有配置;正式运行只以审批过的声明决定注入范围,不能靠预存一个新名字绕过引用新增的审批。D19 / §4.4④
F055 AC-86WHERE 使用 Docker 或 K8s 执行后端, THE SYSTEM SHALL 对密钥权限、空值、预检/预览隔离、配置保存、差异提示及用户确认重建提供相同业务行为;实例重建、缓存恢复或执行器切换不得覆盖最新保存配置,也不得把使用旧配置的实例误报为已使用最新值。§4.4③ / §7
F055 AC-87THE SYSTEM SHALL 保持 SDK 的原有公开能力边界,应用从标准环境读取业务密钥;不新增 SDK 密钥读取/管理入口,不将应用密钥变为可用于平台登录、平台管理或其他应用认证的凭据。D13 / §4.4⑤
F055 AC-88WHEN 无应用密钥声明的既有应用升级或按原交付模式运行, THE SYSTEM SHALL 保持原发布、访问及依赖行为;声明使用应用密钥但目标执行形态不能兑现安全注入时,应明确拒绝所缺能力而不忽略声明或把真实值降级写入代码。D3 / §4.4③ / §8
F055 AC-89WHEN 页面保存、缺值加载或实例更新请求失败, THE SYSTEM SHALL 结束相应等待并显示正确失败状态,保留用户尚未提交的本地编辑;不产生成功通知、不回填密钥明文,且不能通过重试误把掩码或过期页面状态覆盖已保存配置。D20 / §4.4⑥
F055 AC-90WHEN 查看在线应用的密钥配置, THE SYSTEM SHALL 根据当前实际运行实例使用的声明和取值与最新已保存配置判断差异,只展示有差异的引用名及状态,不展示明文;修改未被运行版本引用的值不提示该实例需要重建,值修改后恢复为原值时不再误报该项差异。没有运行实例时显示下次启动使用最新配置,无法确认运行配置时明确显示未知而非已一致。D19 / §4.4④
F055 AC-91WHEN owner 主动选择使已保存配置生效, THE SYSTEM SHALL 在明确告知影响当前运行实例及可能短暂中断后要求确认;只有确认才重建当前已批准的运行版本,不创建代码版本或审批单、不启动待审批或待运行的新版本、不改变资源档位、不触发表结构迁移或覆盖 SQLite 数据。已停止应用不得因保存或差异提示自行上线,仍须原重新上线动作。D18 / §4.4②③⑥
F055 AC-92WHEN 配置重建被确认受理, THE SYSTEM SHALL 使用用户确认的当前运行版本及已保存配置;确认后版本或配置已变化时,须明确拒绝过期确认并要求重新核对。操作受理后再保存的配置不被该在途实例自动采用,不影响已受理操作的取值;完成后仍按真实使用配置显示未生效变更,重复确认不得创建重复在途实例。§4.4②③④
F056 AC-26THE SYSTEM SHALL 使任何审计记录、查询响应与导出内容不含密钥明文等敏感值;平台自签发凭据只显示既有前缀与末四位掩码,owner 录入的第三方应用密钥只显示引用名及无值动作信息,不借“掩码”暴露其字符片段。(NFR-2;应用密钥与 ApiCredential 的不同边界)D15 / §4.4②
F056 AC-34THE SYSTEM SHALL 在既有管理后台审计页中以“应用运行记录”tab 承载模型调用、运行期能力调用及应用访问三个子视图,使高频记录可以查询和导出;操作类审计留在原 tab,不把高频事件混入原操作列表,也不另建独立管理页面;未部署工场运行时层时不显示该运行记录 tab。(2026-10-09 GOV-04 方案 A 替代原单列表筛选描述)D11 / §7
F056 AC-46WHEN 有权管理员进入已部署工场运行时层的既有审计页, THE SYSTEM SHALL 提供模型调用、应用能力调用、应用访问三个可独立选择的运行记录子视图,实际请求真实记录;原系统操作与原应用使用记录入口和既有筛选保持可用。D11 / D12 / §4.1
F056 AC-47WHEN 管理员在任一运行记录子视图筛选, THE SYSTEM SHALL 支持时间范围、应用以及有意义的访问用户条件;模型调用还可按调用密钥筛选,并显示密钥掩码而非明文;无效时间范围、非法分页与跨租户筛选值须明确拒绝,不忽略后返回更宽范围。§2 / §4.4①④
F056 AC-48WHEN 查询模型调用, THE SYSTEM SHALL 展示时间、调用归属(服务账号或应用)、模型、已记录 token 数及调用结果,并在有当前访问者信息时展示其身份;不以 app_id 非空为前提漏掉合法服务账号调用,也不把未记录 token 数伪造为零或把匿名/未知访问者替换为 owner。D12 / §4.4②
F056 AC-49WHEN 查询应用能力调用, THE SYSTEM SHALL 展示时间、应用、能力、以谁的名义调用、结果及已有的可用耗时/数量信息;调用归属与访问者归属分别展示,不把两种身份混成一个操作者。§4.4②
F056 AC-50WHEN 查询应用访问, THE SYSTEM SHALL 展示访问用户、应用与访问时间,并保持原“一次进入/合并窗口”的记录语义;不能把已有聚合访问记录宣称为每条 HTTP 请求或因查询重新生成访问事件。D7 / §4.4②
F056 AC-51WHEN 在相同筛选范围内分页、刷新或导出运行记录, THE SYSTEM SHALL 对相同时间的记录提供稳定顺序,不因并发新增导致同一次导出重复、漏掉已选历史记录或无限延长;分页与总数均只反映有权查看的筛选结果。D13 / §4.4①③④
F056 AC-52WHERE 多租户开启, THE SYSTEM SHALL 使租户管理员只能查询本租户;平台超管全局视图可显式选择有权租户,切到目标租户视图后按该目标上下文呈现;前端隐藏筛选不是服务端授权,调用者自报其他租户、应用或密钥标识不能扩大查询或导出范围。§2 / §4.4④
F056 AC-53IF owner、普通用户、审批人、服务账号或个人访问令牌不具有原治理面规定的管理员身份, THEN THE SYSTEM SHALL 拒绝其运行记录查询、筛选候选与导出;拥有应用使用、管理或审批权限不自动赋予审计访问权。§2 / D12 / §4.4④
F056 AC-54WHEN 应用、密钥或访问用户已删除、吊销或重命名, THE SYSTEM SHALL 仍允许有权管理员按历史标识追溯已保存记录;优先使用已有名称快照,历史缺字段时显示标识或未知,不从另一租户或新同名对象补造历史身份。D10 / §4.4②
F056 AC-55WHEN 切换记录类别、租户视图或筛选条件, THE SYSTEM SHALL 同步复位不再适用的筛选和分页,不显示前一个租户/类别的过期返回内容;其后的导出必须使用当前明确选中的授权范围与筛选。§7
F056 AC-56WHEN 管理员导出运行记录, THE SYSTEM SHALL 导出当前筛选条件下受授权的全部记录而非当前页,列含与该子视图对应的追溯信息;导出受理及交付遵守与查询相同的当前角色/租户约束,失权不能继续取得新内容。D13 / D14 / §4.4①④
F056 AC-57IF 运行记录加载或导出失败, THEN THE SYSTEM SHALL 展示失败并结束等待,不能把失败表示为成功空列表、成功下载或沿用旧租户结果;合法无记录、历史字段缺失和服务失败须能被区分。D14 / §7
F056 AC-58THE SYSTEM SHALL 使运行记录查询、导出、错误及筛选候选仅包含已获准的审计元数据,不包含密钥值、OBO/会话凭据、应用环境全文、模型请求正文或知识检索正文;密钥只显示既有掩码。§4.4②
F056 AC-59WHEN 按对象应用筛选操作审计, THE SYSTEM SHALL 能找到由 F055 及凭据 Owner 留下的应用密钥变更和应用运行期凭据签发/回收记录;应用关联须来自原业务目标,不能依靠审计查看者传入 app_id 伪造或补写历史事件。D15 / §4.3 / §6.1
F056 AC-60WHEN 导出包含用户可控名称或其他文本字段的表格文件, THE SYSTEM SHALL 保留可读文本而不把其作为可执行公式;导出不得携带隐藏敏感列,历史空值不得使导出失败或被替换为其他租户内容。D14 / §4.4② / §7
F056 AC-61WHERE 工场运行时层未部署, THE SYSTEM SHALL 不展示应用运行记录入口并对相应查询/导出遵循原层禁用语义,原操作审计继续可用;本次不新增聚合计费账单、owner 自助审计页或跨租户授权方式。D8 / §4.4① / §7
+

4. 证据与阶段

+

本轮只进行设计和源码静态核对,没有产品测试、远端部署或公开发行。当前产品源码与HEAD无未提交差异,设计hash见本轮JSON。源码基线5f2541f4,当前HEAD7b9027101;不能把设计中的新入口当作已有实现。

+

规格已确认,F055设计待单独确认;F054/F056问题另待修,不提前拆任务/勾选。以下三份设计按当前文件全文展示,Mermaid源码保留,HTML流程预览离线可读,不依赖CDN。

+

Design: 托管应用运行时与安全收尾

+
+

2026-10-23 设计评审稿,待用户单独确认。 规格已确认,本文新增设计尚未实现。 +现状基线:5f2541f4bc483c24624fede9031c88c4ec6f58f4;增量规格确认提交:dda16a6502908ba8d58cff61dc350c0213b86c19。旧版 D1–D15 编号保留,内容按当前代码刷新;历史任务的行号、坑编号和 MVP 后置说法不作为当前实现状态。

+

What:spec.md(73 条 AC);流水账:tasks.md;不变量:release-contract.md。本轮只补 AC-66、AC-67、AC-68、AC-69、AC-70、AC-71、AC-72、AC-73,对应历史 T098 / T099,不重开 F059 / F086 验收。

+
+

1. 目标与非目标

+

保持统一应用入口、平台身份注入、持久化数据和独立编排边界,补齐 Docker 应用访问宿主服务的隔离,以及 OBO 配置不完整时的入口拒绝。为 F055 用户确认的配置重建提供现有部署执行与受限运行环境只读协议。

+

非目标:商业授权、重写 manager、重新实现 F059 Kubernetes、扩大第三方 egress 白名单、密钥管理页面或运行记录查询(分别归 F055 / F056);不在 backend 中运行防火墙命令或容器 SDK。

+

2. 关键约束

+
    +
  • 领域真相由 backend 数据库持有;manager 经内部认证下载代码/上传快照,不直连 MinIO。附件已迁 backend。源码、依赖、启动参数由已冻结交付描述决定;本轮不改镜像构建路线。
  • +
  • Docker Engine 仅由 manager 经 socket proxy 访问;K8s 由独立 manager 使用受限 ServiceAccount 操作。没有挂 socket 不意味着编排 API 权限很小。
  • +
  • Docker 隔离规则必须在 Docker 宿主网络命名空间生效。manager 容器中执行 iptables 或检查容器自己的 INPUT,不能证明宿主已隔离。
  • +
  • 原生 nftables 与 iptables 是两种支持路径;本轮 Docker 托管网络只声明支持 IPv4。发现 IPv6、未识别网络后端、无法证明实际隔离时,拒绝新部署,不假装支持。
  • +
  • 宿主规则变更只操作自有链/表,不能清空 INPUT/FORWARD/DOCKER-USER,也不能修改其他网络的全局默认策略。
  • +
  • Constitution Check:遵守 constitution.md C1–C8。新增隔离报告由 Repository 落库(C1/C8);租户对象、权限入口沿用 C3/C4;新字段/迁移用通用类型(C2),错误码沿用 161 段(C5);HMAC 与 OBO 分开配置(C6)。本轮无铁律例外。
  • +
+

3. 方案对比与选定

+

D1:独立 manager + backend 持久意图

+

保留独立进程;选用 F086 的持久 RuntimeOperation、执行事实与路由投影,而非 backend 直接控制 Docker。证据是 runtime_operation_service.py、runtime_delivery_repository.py 的租约/代次/操作围栏;仅 HTTP 一次性命令无法恢复响应丢失。若执行协议整体升级再评估,不增加并列状态机。

+

D2:Docker 经 socket proxy,K8s 经受限 API

+

当前 Docker 配置已经是 socket proxy,不再采用旧设计的“先直挂 docker.sock”。通用代理限制 API 面但不是业务授权器;manager 仍持有高权限编排面。拒绝把三种代理与 backend 合并来省进程。只有权限模型改变且验证隔离等价时重议。

+

D3:交付模式服从 F086 / F059

+

当前有 bundle、完整 image、source-image,支持 Python 3.11 / Node 20 / static,不再是首发 Python 模板单一路线。选用已冻结 delivery/profile/digest;拒绝运行中重新选择可变默认镜像。依赖缓存、离线交付和 K8s 组装的详细契约分别见 F086 design、F059 design。新增运行时时再评估。

+

D4:自愈与切流复用现有围栏

+

探活成功才提交路由;旧写入者确认停止才允许新的 SQLite 写入者;恢复沿用原操作、deadline、资源 UID,不以重启为理由无限延长。禁止只用容器名判定安全。底座证据见 F059 既有验收;本轮用户确认重建只复用这些机制。引入多实例数据模型时重议。

+

D5:app-proxy 保持独立入口

+

nginx → app-proxy → backend 授权与路由投影 → 应用,支持 HTTP/WebSocket;不改为代理向 manager 查路由。proxy 持有 entry 只读角色,不持编排或数据库凭据。若统一入口基础设施替换再评估。

+

D6:OBO 配置失效时 fail closed(本轮变更)

+

选用现有 unavailable 决策并新增 reason=identity_config_incomplete,不增加可放行的“匿名降级”。entry_authz_service 提供同一 obo_configuration_status() 校验器:值缺失、全空白、与 JWT secret 相同均无效;校验不 trim 后再签名,实际有效值原样用于签名与验证。正式入口完成登录、应用存在、租户、可见范围、下线/维护判定后,在 _identity_material 前检查;预览完成会话、预览归属与应用/租户核验后同样检查。任一无效返回拒绝,不生成身份头、OBO、allow 访问记录。

+

app-proxy 的 BackendAuthzClient / preview 授权客户端不再缓存含身份材料的 allow 决策:每个新的 HTTP 请求和 WebSocket 握手到 backend 重新判定,避免 backend 配置失效后旧 allow 缓存继续放行。路由缓存与拒绝缓存维持独立;已经建立的 WebSocket 不承诺逐帧重鉴权。此取舍增加授权 RPC,定向验证请求数与延迟,不把“保留有效配置行为”理解为继续缓存旧身份。若未来有可信配置 epoch 推送协议,再评估恢复 allow 缓存。

+

签发器自身再防御性校验并返回显式失败,调用者不可把失败当缺省可选值;验证器亦拒绝无效配置。请求期间以同一配置快照完成检查/签发,避免动态刷新间隙。应用运行时开启时,FastAPI lifespan 输出一次脱敏 ERROR(字段名和失败原因,不含值),不终止整个平台进程,入口保持拒绝;开关关闭不产生“已启用”或误报。选择此方案而非整站启动失败,因为 spec 要求关闭运行时/其他平台能力不被连带停用。若未来 OBO 采用非对称签名,再重新审查发行/验证契约。

+

D7:拒绝页面沿用既有契约

+

app-proxy 对 unavailable 保留既有不可用页与状态码;当 backend 已完成原登录、对象存在、租户、访问权限和运行层开关判定后,消费可信 reason=identity_config_incomplete,正式/预览均显示 “本环境配置不完整,请联系平台管理员”(三语同义)。该提示不显示配置值、字段值、内部 URL 或真实应用环境;无权者仍得到原来的登录/不存在/无权限等决策,不靠浏览器参数切换到此提示。无需新视觉模式;沿用不可用页模板只增加此原因对应文案。新增状态码需先检查 nginx error_page 合同。

+

D8:领域与列表投影保持兼容

+

App / AppVersion / AppInstance 继续分别表达业务状态、冻结版本、运行观察;构建/广场列表通过既有 UNION 第三支表达 flow_type=35、app_state,不为新安全项改列表含义。若新增状态,需同时审查两个 SPA 的白名单。

+

D9:权限走已验证业务目标

+

应用身份、owner、租户与资源状态先由业务服务核实,业务动作走 F048 统一入口;manager 与代理不自行查 OpenFGA。管理员的通用 shortcut 不能扩大 F055 的 natural owner-only 密钥写权限。新增管理动作时重审 Catalog,而非手写 role_access。

+

D10:SQLite 与平台存储解耦

+

Docker 绑定本地持久目录,K8s 用已验收独占 PVC;manager 执行本地 backup/迁移,经 backend 存取快照。应用附件走 backend 存储接口;manager 不读平台 MinIO 配置,也不需要独立 MinIO 实例/新桶。升级代码不等于恢复数据库;恢复必须显式选快照。存储拓扑变化时重议单写入者证明。

+

D11:容量与资源档位

+

沿用平台档位与持久 reservation;释放依赖停止证据,不以超时自动当作已停。用户确认的配置重建依然需要执行资源预算,不绕过准入。并发拓扑改变时重审预算。

+

D12:宿主 INPUT + FORWARD 的同源隔离(本轮变更)

+

否决仅补 DOCKER-USER(不覆盖 INPUT)、只看 --internal(不证明宿主入口关闭)、在 manager 容器里加 NET_ADMIN(作用域错误且扩大权限)、允许整个 backend 端口给附件(附件也应经 egress)。选定:保留 egress 唯一出口,安装侧 root 工具维护宿主自有规则,backend 持有短时有效的独立隔离证明,新部署消费证明。

+

现有 runtime_manager.egress 已有规则渲染和 CLI,扩展同一规则模型,不另写两套白名单。具体安装、报告和准入合同见 §4.4。已建立连接也不能用宽泛 ESTABLISHED 一律放过,必须区分应用响应与应用主动发起。真实宿主配对探测是依据,命令存在不等于隔离成立。若后续采用经过验证的 eBPF/专用网络插件,可替换证明生产方,不能取消拒绝边界。

+

D13:详情页扩展点

+

保留发布 / 版本 / 数据 / 日志页壳与命名 slot。F055 密钥管理放既有 settingsSlot,不为安全项新增管理菜单。壳变更时需检查三个 Feature 消费者。

+

D14:运行日志与访问记录分开

+

stdout/stderr 是调试日志;backend 入口 allow 产生合并窗口访问记录;F056 的读接口不创造新访问事件。保留既有合并窗口实现/配置,本轮不改频率。安全拒绝不计成功访问;日志包含配置原因但不含身份材料。若审计语义变更,应先改规格。

+

D15:收尾边界与交付声明

+

旧 MVP “后置”不再表示未实现:F086、F059 已有实现/证据,本轮只修改与安全/密钥刷新直接相关的接口。历史未完成验收不自动勾选,ARM、完整离线安装和正式镜像发行不冒充本轮完成。只在本轮 AC 定向证据成立后更新相应任务。

+

4. 系统现状(接手必读)

+

4.1 数据流与实现状态

+

已实现:backend 保存应用/版本/操作/路由/存储绑定;Celery 调发布管线;manager 拉取持久计划、编排 Docker/K8s、上报事实;app-proxy/egress 只读投影。RuntimeDeliveryCoordinator.inject_environment 使用加密 sealed_env 保存可恢复执行环境;K8s 已用操作专属 Secret + secretKeyRef,并非把平台 token 放 ConfigMap。

+

尚未实现的本轮设计:INPUT 规则与可信证明、OBO 无效时拒绝、用户确认重建与运行快照只读适配。现有 egress.firewall_rules 仅渲染 FORWARD/DOCKER-USER 规则;_firewall_check 主要检测后端存在。现有 _issue_obo_token 返回空时两个入口仍允许,是 T099 真缺口。

+
入口授权OBO配置判断平台路由应用;宿主证明准入
流程图源码(与 Markdown 一致)
```mermaid
+flowchart LR
+  Browser --> Nginx --> Proxy[app-proxy]
+  Proxy --> Auth[backend entry authorization]
+  Auth --> Gate{identity configuration valid}
+  Gate -->|no| Deny[unavailable; no identity or access record]
+  Gate -->|yes| Route[backend route projection]
+  Route --> App[application]
+  App --> Egress[egress-proxy] --> Platform[platform APIs]
+  Installer[host root installer / verifier] --> Firewall[owned INPUT and FORWARD rules]
+  Installer --> Report[backend host isolation report]
+  Report --> Admission[new Docker deployment admission]
+```

4.2 既有公共合同(保留原编号语义)

+

① runtime-manager 意图 RPC:旧 POST /v1/intents/{build,deploy,stop} 兼容通道与当前持久 v2 通道不可混淆。当前 backend RuntimeDeliveryCoordinator 创建 RuntimeOperation、wake manager;manager 以 node/role HMAC 从 /api/v1/internal/app-runtime/v2/nodes/plan、operations/{id} 拉取并 claim、上报 facts。冻结字段见 F086/F059,public caller 不可注入 sealed_env/runtime_env。旧 code_object_key 直读 MinIO 的设计不再使用。

+

② backend 状态动作 / 读口:AppStateService.{publish,manual_publish,stop,resume,delete,stage_version} 仍是 F055 的领域入口;GET /api/v1/apps、/{id}、/{id}/instance、/{id}/versions、/{id}/logs,PATCH 元信息与 actions 路由保持。版本列表只读,不提供绕过审批的直接切版本;AppMetaService.update_meta 为管线与页面共用。runtime-status 只供有权管理员;owner/租户范围由读服务决定,不拿 UI can 标志当后端权限。

+

③ app-proxy 注入头:X-BiSheng-User-Id/User-Name/Tenant-Id/Dept-Id/Dept-Name/Dept-Path/Subject-Kind/App-Id/Access-Token/Request-Id;姓名/部门等非 ASCII 值用现有 percent-encoding;入站 header lower + 下划线→连字符归一后剥离整个 x-bisheng 等价类。X-Forwarded-Prefix/Proto/Host 由 proxy 覆盖,不信客户端。OBO 现已有消费者,不能继续引用旧“可无 token 放行”例外。

+

④ manifest 消费:形态归 F055;runtime/port/tier/database/egress/delivery/startup 由当前 schema 冻结后进入执行,见 F055 design §4.2③。新增业务 secret 名仅消费可信 formal 环境,不允许任何运行形态覆盖保留名。

+

⑤ 环境变量:BISHENG_APP_DB_URL、应用 base path、平台/模型/storage endpoint 与 operation-bound 凭据仍按 F086/F059 注入;本轮不改已有公开 SDK 变量名。正式、preview、probe 的身份/数据空间不可混用,构建进程不取得正式环境。

+

⑥ SQLite / 日志:DB URL 指向运行实例的 /data/app.db;Docker bind 与 K8s 独占 PVC 的宿主/集群映射由执行层持有。日志 tail/since/keyword、状态和数据动作先验 owner/租户;backend 只传意图,manager 执行备份/数据维护,经 backend 传快照。

+

4.3 关键模块职责

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
模块 / 落点做什么不做什么
app_runtime/domain/services/entry_authz_service.py统一 OBO 配置检查与正式/预览判定不接触容器、不匿名降级
bisheng/main.py:lifespan运行时开启时输出脱敏启动诊断不因此阻塞整个平台
runtime_manager/egress.py + 新 host verifier CLI / unit同源渲染、宿主安装、只读验证、配对 canary 报告不持有应用真实密钥,不清全局防火墙
app_runtime 新 host isolation Repository/Service/Endpoint验证独立角色、保存证明、节点准入读取不执行宿主命令、不访问 Engine
RuntimeOperationService / RuntimeDeliveryCoordinator部署、恢复、用户确认重建准入和原有围栏不拥有 F055 密钥配置资产
app-proxy / egress-proxy入口/出口数据面、受限投影;入口 allow 不缓存身份不签 OBO、不自行上线
app_storage_service / backend 对象存储适配源码/附件/快照传输授权不替 manager 执行 SQLite 迁移
+

4.4 本轮增量字段与接口合同(拟实现)

+

① 入口:正式 /apps/{slug}、预览 /apps/_preview/{session}/... 保留现有 app-proxy → backend 内部 HMAC 协议;拒绝体仅 decision=unavailable, reason=identity_config_incomplete 与已允许返回的最小上下文;不得带 inject_headers、obo_token、obo_expires_at 或 target。登录/不存在/跨租户/无权限/停用仍先返回对应旧决策,不因配置错误暴露对象存在。

+

② 操作与身份:RuntimeOperation 的 scope_key/idempotency_key/generation/owner_epoch/deadline 继续约束生命周期;K8s 额外 controller epoch、UID 与数据 writer 围栏。内部 HMAC 角色按 manager / entry / egress 隔离,传输受 TLS/受控网络保护;HMAC 只认证请求,不提供加密。F055 手动重建语义见其设计 §4.4③:保存不创建操作;rebuild_current 复用既有 deploy,running_environment_snapshot 只向受限内部调用提供当前正式环境 DTO;不写 F055 配置或 applied 状态。

+

③ 宿主规则合同(拟新增):

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
路径规则与验证
iptables自有 BISHENG_APP_INPUT / BISHENG_APP_FORWARD 链。宿主 INPUT 在其他宽泛 accept 之前跳转;FORWARD 由 DOCKER-USER 首部跳转。跳转匹配实际 app bridge 与源子网;自有链内只允许固定 egress IP/3128 的 ORIGINAL,以及源 app、ctdir REPLY、ESTABLISHED 的合法入口/探活响应,其余 DROP。RELATED 不宽泛放行
nftables自有 inet bisheng_egress,同时具有 input / forward hook,优先级先于普通 filter 接受;匹配 bridge/源子网,允许条件与 iptables 同源生成。nft accept 仍需通过后续链,不能承诺绕过已有宿主拒绝策略
更新root 安装工具获取独占锁,先构建新规则并 dry-run 校验,再以 iptables-restore --noflush 或 nft 单事务切换;初始化跳转失败保持/恢复拒绝态,不先删全部规则再逐条添加。重跑不重复,卸载只移除有标识的跳转/自有链表
动态地址installer 通过 Engine 网络检查冻结 network ID / bridge / 子网 / egress IP、端口、宿主网关/发布地址。代理 IP 变化立即使旧配置证明不匹配,重新安装校验;不在 L3 中放行 hostname 或不受限端口
不支持IPv6/未知 family、Docker rootless/不可确认 bridge、不可识别防火墙后端先报告缺能力;不静默省略一部分网络。K8s 使用 F059 的 CNI 证明,不套用宿主 Docker 规则
+

④ 隔离证明(拟新增):安装工具与只验证的 systemd oneshot/timer 位于宿主,30 秒刷新,60 秒过期;首次安装、宿主重启、dockerd 重启后先验证再放行新部署。持续验证不授予 manager root/NET_ADMIN,不运行在 backend。工具复用现有 Python Engine HTTP 客户端;需要宿主 root、相应 iptables/nft 二进制与受控 Engine API,不要求 docker CLI。

+

新增 RuntimeHostIsolationReport(F054 所有,平台级节点表):id,node_id,engine_id,network_id,config_digest,rule_digest,revision,checked_at,valid_until,status,checks,通用 JsonType;checks 仅结构与探测结果,不能含环境/用户流量。POST /api/v1/internal/app-runtime/host-isolation-reports 只接受独立、限定 node 的 host-isolation 服务角色;manager/entry/egress 无上报权。服务端分配接收时间、限制 TTL、递增 revision/nonce、验证节点与 engine/config 绑定,不信客户端自报无限有效期;重放/倒退拒绝。独立报告表避免 manager heartbeat 覆盖证明;不改 F059 现有摘要算法。

+

生产方为 root 安装脚本,信任边界是客户宿主管理员;不声称防住 root 伪造。报告只在规则读回匹配 + 实际 canary 允许/拒绝配对均成功后标 pass:无业务凭据/PVC 的受限 canary 在 app 网络,测试直连宿主网关、宿主服务地址、published address 被拒绝,同时入口 HTTP/WS/探活响应与经 egress 的平台 API 可达。所有探测端点由管理员固定,拒绝把请求参数当任意扫描目标;canary 有 UID/ID、期限和自有标签,未知资源不强删。

+

backend 在新 prepare/probe/preview/deploy/restore(含恢复和 F055 用户确认重建)的 create/claim/提交服务路由前验证新鲜证明及冻结配置一致性;失败给缺能力原因。stop/destroy/清理仍允许,既有应用不强制删除。证明过期时在途操作不发布新路由,可恢复地等待至原 deadline;不能绕过旧 writer 停止确认。安装报告通过不代替实际业务 API 的权限检查。

+

5. 已知坑 / 反直觉事实

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
坑后果处理位置
DOCKER-USER 不覆盖宿主 INPUT;internal 网络仍需实际验证应用绕过代理直连数据库或管理端口egress.py INPUT+FORWARD 与 host verifier
容器内 iptables 成功不等于宿主规则生效假阳性准入宿主 CLI / 独立报告角色
应用发出的入口响应源地址仍是应用子网无 REPLY 例外会把正常 HTTP/WS/探活阻断同源规则模型和真实配对测试
宽泛 ESTABLISHED 也包含以前建立的恶意 ORIGINAL新规则看似生效、既有绕过仍通ctdir 与连接方向断言
_issue_obo_token 当前返回空仍 allow;现在已有 OBO 消费者登录身份失效但请求被放行正式/预览统一检查和签发器兜底
身份材料先生成后才拒绝即使未转发也可能留下不应产生的头/日志在 _identity_material 前检查
manager API 编排权限仍高于普通服务合并进程扩大入侵面socket proxy / RBAC / 角色隔离
版本回滚不等于 SQLite 回滚旧代码运行新 schema 或误覆盖数据F055/F059 显式快照恢复
旧任务完成与真实环境验收不同把静态/模拟误当上线证据任务证据类型与固定 SHA/digest
+

旧坑编号的历史引用按 dda16a6 的本文件读取;不把其中过期的“尚无 OBO 消费者/manager 直连 MinIO”继续当现状。

+

6. 对外契约与依赖

+

6.1 我提供给别人的(Outgoing)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
提供消费者破坏风险
现有 App 状态动作、内部路由/entry 协议F055/F056、app-proxy改状态/错误决策会破坏显示和转发
OBO 合法配置才签发;TTL/aud/app/tenant 绑定维持现有值F051 模型面、F055 能力总线、SDK放宽空 token 或共享 JWT secret 会破坏身份边界
持久操作围栏 + F055 用户确认重建/受限运行快照F055、Docker/K8s managergeneration/operation payload 缺失时应显式拒绝,不能旧版静默忽略
独立宿主隔离报告与节点能力原因部署/运维/状态查询把 manager heartbeat 当隔离证明会扩大信任面
+

6.2 我依赖别人的(Incoming)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
依赖形式风险
F086 持久操作 / F059 controller 与 writer 围栏HTTP、DB 与执行事实旧 manager 不支持确认重建/敏感输出协议时拒绝
Docker Engine / socket proxy;宿主 iptables-restore 或 nft;systemd timer宿主安装条件无法证明 INPUT 或 family 时阻止新部署
Redis nonce;平台 DB;部署密钥与 OBO 独立配置基础服务不可达或配置失效不能新发身份/伪造 pass
F048 PermissionService 统一授权入口Python application protocol不绕过 verified target 或复刻 OpenFGA
F055 密钥版本/注入契约;F056 审计读侧领域协议本模块只消费,不复制密钥表/查询规则
+

7. 测试与可观测

+
    +
  • 单元/模拟只覆盖变化:INPUT/FORWARD 双后端渲染、方向、幂等、失败恢复、未知 family;证明角色/过期/重放/配置改变与部署拒绝配对;正式/预览三种坏配置与有效配置的决策、无身份/无成功访问副作用;proxy 曾获 allow 后 backend 改坏配置的下一次请求/WS 握手拒绝,route 缓存不干扰此 Gate。
  • +
  • 集成只验证新表/租户无关节点范围、迁移单 head、manager 用户确认重建复用围栏。DM8 仅类型/约束/迁移静态审查,不写成 DM8 真测。
  • +
  • 真实 E2E:在授权专用 Docker 环境先保存自有规则前后差异,执行 canary 直连宿主拒绝 + HTTP/WebSocket/探活 + 经 egress SDK/模型/附件允许;重跑、重启、精确卸载对其他网络无影响。OBO 使用已授权自然人 owner/可见非 owner/不可见/跨租户配对,正式与审批预览都验证。不得把失败的 TCP 连接当“防火墙拒绝”而不先证明目标监听可达。
  • +
  • 定向命令(编码后按实际文件名执行):cd src/runtime-manager && uv run pytest tests/test_egress.py;cd src/backend && uv run pytest test/app_runtime/test_entry_authz.py(补充测试以最终任务命名为准)。手动入口 /apps/{slug} 与 /apps/_preview/{session}/,运维 python -m runtime_manager.egress firewall --print 先看差异,再由 root 在专用宿主安装;未确认设计前不执行变更。
  • +
  • 可观测:identity_config_incomplete、host_isolation_missing/stale/mismatch/failed、node/operation/canary ID、规则 revision;不记录密钥、token、会话或完整环境。失效不刷屏,状态面保留当前失败原因。
  • +
+

8. 后续改进 / 不打算做的事

+

本轮不支持 Docker IPv6/rootless 网络;检测即拒绝,不默认为安全。不新增通用宿主防火墙产品;验证工具只覆盖托管子网。K8s CNI/跨节点/ARM 扩展留在原 Feature 的待验清单,不借安全收尾宣称全部通过。完整设计确认后拆增量任务,保留 T098/T099 原证据与关联。

+

修订历史

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
日期变化依据
2026-10-23(二次修订)按 AC-70 修正 OBO 配置不完整提示;保持原授权顺序和正式/预览同源用户要求修订后复审,尚待设计确认
2026-10-23(简化协议同步)F055保存不重启;消费用户确认重建与当前运行快照协议,不增加F055结果双写用户已确认F055修订规格;F054自身设计未确认,B1仍待修
2026-10-23刷新 F086/F059 后的当前快照;补 INPUT 隔离证明、OBO 拒绝设计;保持 D1–D15 与 §1–§8 编号用户确认增量规格;静态核对基线代码,尚未实现/部署
2026-08-17~18初版与早期 MVP 修订历史正文见 dda16a6;历史测试不充当本轮证据
+

Design: 应用发布管线与应用密钥管理

+
+

2026-10-23 简化设计评审稿,待用户单独确认,未编码。 用户已确认 92 条 AC 的手动生效规格;源码基线 5f2541f4bc483c24624fede9031c88c4ec6f58f4。D1–D20 与 §1–§8 保持编号;正文覆盖旧三表/自动生效设计,不沿用历史设计的确认状态。

+

spec.md · tasks.md · release-contract.md。主导质量属性:无意外停机、秘密隔离、运行配置与保存配置可辨识。F054 B1、F056 S1 的既有发现仍待修,不由本文宣称关闭。

+
+

1. 目标与非目标

+

为已有发布/审批/运行管线补齐 owner 私有应用密钥:仅保存名称引用进版本,值加密存平台数据库,已审批正式实例按当前值注入。保存值不触发重启;页面提示当前实例与最新保存配置的差异,owner 确认后才复用部署流程重建当前版本。预检和审批预览只注入声明名与空值。

+

非目标:第三方 Vault/KMS 托管、第三方凭据有效性探测、密钥 SDK/CLI 管理、商业限额、平台模型供应商密钥编辑、批量应用重启。F054 管生命周期执行,F056 管管理员运行记录查询,不复制这两个领域。

+

2. 关键约束

+
    +
  • 预检、构建、依赖安装、前端编译、扫描、审批预览均不得取得真实值;预览身份和隔离存储原有规则仍有效,不以“无应用业务密钥”取消正常权限检查。
  • +
  • 值只由同租户应用 natural owner 经平台会话修改;管理员、超管、审批人、SAK、PAT 均不能替 owner 写。权限 shortcut 不代替这个业务前置。
  • +
  • 声明属于冻结版本并需审批;值属于应用当前配置,改值/删值不创建版本或审批,也不自动创建运行操作。手动重建只能运行确认时的已批准当前版本,不能借机启动 pending_version。
  • +
  • 字段边界(拟定实现常量):每应用最多 64 项;名称 ASCII [A-Za-z_][A-Za-z0-9_]{0,127},区分大小写且精确匹配;值 UTF-8 不超过 16 KiB,整组不超过 128 KiB,禁止 NUL(无法放入进程 env),支持 Unicode/换行/空格/特殊字符且原样传递。空字符串表示缺失;全空格值不自动 trim 成空。
  • +
  • 遵守 constitution.md C1–C8,不复制全局铁律。Constitution Check:新 Model/Repository/Service/Endpoint 分层;tenant_id 默认 None、非空、索引且注册自动注入;通用 LargeText/JsonType 和迁移单 head;仅 162 段新增错误码;平台持久 DB 为权威;不向 backend 引入容器 SDK。无例外。
  • +
+

3. 方案对比与选定

+

D1:同步接收 + Celery 阶段机

+

保留同步鉴权/安全解包/manifest 校验,异步扫描、准备交付、探活、审批。拒绝把整条分钟级管线放 HTTP 请求或把失败尝试写成已发布版本。现有 AppDeployment 与 worker 已接线;管线可并行分支真正出现时再重议。

+

D2:源码快照由平台保存

+

backend 拥有私有对象存储与代码引用,manager 经受限传输 API 获取。拒绝 manager 直读 MinIO 或通过临时本地文件在 API/worker 间共享;现有 runtime_artifact_service 已提供传输协议。交付物类型改变时重新评估。

+

D3:manifest 严格校验,新增引用而非值

+

CapabilityDeclaration 增加 secrets: list[SecretRef],SecretRef={name: str} 且 extra=forbid,旧 manifest 不带该字段仍通过。采用与 models/knowledge_bases 同处的声明入口而非任意 env 字典;没有平台 issued scope,derive_scopes 不因 secrets 新增开放 API 权限。

+

当前 _reject_secret_references 会泛化拒绝 secret/token 等键,必须只豁免合法 capabilities.secrets[*].name 子树,其他位置仍禁明文/不支持的引用形状;全源码扫描继续先于构建。is_empty、部署适配、预览、摘要/diff、CLI 校验与提示、审批审读都同步识别名称。若未来支持外部 secret provider,另改规格与 schema,不接受 vault://... 偷渡。

+

D4:fail-fast 阶段结果保留

+

沿用 {stage,code,message,details,hints} 失败形状;缺值是 warning,不单凭缺值拒绝发布。应用自身无法启动则正常探活失败,不跳过 Gate。不得将第三方 key 的值放进 details/hints。若发布阶段拆服务再评估传输合同。

+

D5:明文扫描继续阻断

+

合法声明名不视作密钥明文;实际源文件/打包内容仍扫描、只返回文件/行号/规则,不返回匹配值。密钥不进源码或依赖缓存,不能为“用户确实需要 key”放宽扫描。新检测模式只更新规则与配对用例。

+

D6:版本只保存声明快照

+

版本仍在预检通过后建立;只冻结 secret 名称,不保存值/密文/可还原引用令牌。运行时注入值不写入 AppVersion.injections,否则历史版本会变成密钥仓库。未来需要配置历史恢复时需另立规格。

+

D7:审批场景保持现有强制入口

+

仍经统一审批场景/outbox;审读只提供声明名称和按调用时状态计算的缺失提示。审批人不获得 owner 密钥管理能力;声明增删需审批,值修改不触发审批。已有审批规则与申请人过滤保持。改变审批权限时必须读 approval-module skill,不在本次重写中心引擎。

+

D8:既有审批基建修复不重做

+

早期审批解析/seed/通知回退已落地,本轮只核对真实调用点。拒绝把历史设计“阻塞前置”重做成新任务。只有本轮定向用例暴露回归时再改。

+

D9:上线前终检与容量准入

+

冻结交付、当前应用状态、审批结果与容量仍是前置;正式环境在 执行前由 backend 生成并封装,不能由调用者透传 runtime_env。manager 不自行判断“审批过”;信任操作的可信用途和版本绑定。执行协议变化时重议。

+

D10:撤回 / 删除取消保持幂等

+

沿用已有终态守卫和持久取消;用户确认的密钥配置重建也遵守停止/删除围栏。删除应用成功后清除密钥值,审计按既有留存保留;删除未知/清理失败时不抢先把数据与身份当已释放。领域销毁协议改变时重审清理顺序。

+

D11:档位为平台资产

+

资源档位继续独立管理、停用不删;用户确认重建使用当前运行操作冻结的资源预算,不因全局档位配置已改变而重算。支持动态扩缩容时另审。

+

D12:操作审计与运行记录分层

+

生命周期/声明/密钥变更进入操作审计;模型、能力、访问记录各有现存专表。密钥事件只能含 app/ref/actor/action/time/result;API key 事件仍可含原有 key_mask,不混用两种掩码。审计模式改变时与 F056 共同审查。

+

D13:已有能力总线与运行凭据保持

+

runtime_capability_env、AppRuntimeCredentialService、模型面/检索 OBO 已实现,不再“本轮后置”。应用业务密钥无新 SDK API;os.environ / process.env 原样读取。预检/预览继续没有正式 app credential,但有隔离存储环境。真实密钥不能混进模型供应商平台配置。新增平台能力时才扩 scope。

+

D14:审批侧使用当前页面

+

以现有审批详情/审读页实际入口为准,不恢复过期弹窗设计;仅增名称/缺失提示和差异展示,不新增审批操作。若现有布局没有可用组件,跟随现存审读样式,不改设计系统。

+

D15:发布面复用现有 settingsSlot

+

F055 的 AppSecretSettings 加进应用详情的 settingsSlot,与档位/能力并列;查询与持久操作状态由 hook 管理,store 不发 HTTP。natural owner 可编辑,其余有查看资格者仅看名称/缺失;审批人只走审读数据。失败必须 catch 并终止等待,防止新 request 拒绝 Promise 后 UI 卡住。

+

D16:当前交付边界

+

现有发布、预览、结构演进、模型/知识能力与三交付模式已有代码;本轮只补密钥与用户确认的 runtime 重建,不借此重做 F086/F059。旧任务状态与当前证据分开。未来非本轮能力见 §8。

+

D17:一张加密配置表,执行状态复用既有账本

+

否决三张密钥专表及配置独立 applied 状态机:原方案为自动生效维护值、状态和 mutation 意图,但用户已明确保存不重建;现有 RuntimeOperation 已持有幂等、冻结环境、执行事实和失败结果,重复维护会增加双写。选定 AppSecretConfig 每应用一行,整组键值映射加密存储;配置 revision 只防旧页面覆盖,不表示运行实例已经采用。

+

使用既有 app_runtime.runtime_payload_key,密文 envelope 校验 tenant_id/app_id/domain=app-secret-config-v1。整组映射有大小上限,名称在解密后按原大小写精确比较,无须逐名称摘要/专有 collation。不存业务值普通哈希,不另建 AppSecretState、AppSecretMutation、配置专属扫描器或 ConfigurationApplyPort。平台固定部署 key 要与数据备份;误改或缺失明确失败,不降级为缺值;更换仅作运维说明,不开发轮换工具/UI。将来确有跨应用共享或历史值恢复需求才重新审查,不能在本版预建。

+

D18:保存与用户确认重建分离,复用 deploy 执行器

+

否决保存后自动重启、Docker restart(环境不会更新)、再走发布预检/审批/DDL,以及专用配置执行引擎。保存事务只写加密配置、revision 和既有审计,不调用 manager。用户另行确认后,经 F054 rebuild_current 公共应用入口受理 kind=deploy,purpose=config_restart,以新的 RuntimeOperation 重建同一冻结 delivery/profile/镜像/档位。

+

该入口不是原 deploy() 原样直调:需要绕过同版本 serving 快速返回、拒绝串接其他 purpose 的 active deploy,并固定空 migration_plan、禁 snapshot/restore。旧 writer 停止确认、预占、探活、切路由、清理和故障续办均用现有机制。失败后不由 recover 自动再创建 config_restart/替代 deploy;只能用户重新核对并确认新 attempt。续办已受理 operation 和 deadline 不算再次用户动作。未来有应用热加载协议时再讨论免重建,不在本版实现。

+

D19:以当前路由所绑定的冻结环境判断差异

+

否决“revision 不同就要重启”、单独持久 dirty 标志、每次改值清空已生效状态和额外值摘要历史库。当前 RuntimeRoute/AppInstance 决定使用哪次正式 operation,backend 受限读取其 sealed_env,仅比较当前批准声明的名和值与最新配置。后保存未引用项不会误报;A→B→A 与实际运行值相同会消除差异。比较结果只含变量名、same/different/unknown 及安全状态,没有新旧值。

+

保存允许发生在已受理重建期间;本次 operation 的环境一经冻结不变,后续保存仍作为最新配置保留,切路由只提交运行事实、不回写配置。请求丢失复用 RuntimeOperation 的 scope/key/request_digest;不为保存维护历史请求 HMAC。执行上下文缺失、解密失败或路由变化无法取得一致视图时报告 unknown,不能猜已一致。未来若现有执行上下文不再保留而产品要求永久历史配置比较,再另审数据需求。

+

D20:输入草稿与固定掩码(新增)

+

不返回值、不允许“查看明文”。列表只显示固定 ••••••,不与密码框输入绑定;点击替换打开空的输入草稿,初值绝不放掩码。值控件用已落地 Input/Textarea,换行值不通过单行组件截断;写成功后清空内存草稿,失败保留当前未提交草稿。关闭/换应用/退出清理,不写 URL/localStorage/query cache/埋点。避免使用带请求体自动回显的通用错误日志;secret endpoint 的请求校验异常只返回字段位置/规则,过滤 Pydantic input/ctx/raw body,确保非法值也不会被校验错误回显或写日志。

+

取 current shared UI docs 的字体/色彩/多端/输入框规范,Button/Input/Textarea/Tabs 使用 status=done 的组件;Select 尚为 draft,筛选跟随当前页面已存在模式,不另造。表单/确认对话框用当前发布页已有实现;不改组件样式、不新增库。未来需要设计师定义新交互时再申请视觉评审。

+

4. 系统现状(接手必读)

+

4.1 数据流与实现状态

+

已实现:接收包 → 代码快照 → 扫描/结构预检 → manager prepare/probe → 版本/审批 → 正式 deploy;backend 持有对象数据,manager 管 SQLite;RuntimeOperation 已持久保存 sealed_env,RuntimeRoute 标记当前运行 operation,K8s 已有执行专属 Secret。本轮未实现:合法 secret 声明、加密配置资产、管理 API/差异投影、用户确认的同版本重建分支及敏感输出合同。

+
保存配置,不执行比较运行值用户确认既有deploy与路由提交
流程图源码(与 Markdown 一致)
```mermaid
+sequenceDiagram
+  participant Owner
+  participant Backend
+  participant DB
+  participant Manager
+  Owner->>Backend: save secret + expected_revision
+  Backend->>DB: encrypted config + revision + audit only
+  Backend-->>Owner: saved; computed differences; no restart
+  Owner->>Backend: confirm rebuild: request_id + expected version/config/route
+  Backend->>DB: existing operation + frozen env in one short UoW
+  Backend-->>Owner: operation_id
+  Backend->>Manager: wake existing durable deployment
+  Manager->>Backend: claim operation and receive frozen environment
+  Manager->>Manager: stop proof; same delivery; health check
+  Manager->>Backend: ready execution fact
+  Backend->>DB: existing instance and route commit
+  Owner->>Backend: read config and running-operation difference
+  Backend-->>Owner: same / different / unknown, names only
+```

保存路径没有 manager 调用。预检/预览只生成 {NAME:""},不调用正式解密协议;正式部署/重新上线在当次受理中选最新保存配置,已受理操作和进程原地重启使用其冻结环境,不因新保存值改写。

+

4.2 既有公共合同(保留原编号语义)

+

① 管线接收与轮询:/api/v2/apps 由 router_rpc 单一 verify_open_api_access 与端点 open_api_scope("app:manage",modes=("S",)) 保护;本次不把秘密管理挂 v2。

+ + + + + + + + + + + + + + + + + + + + + + + + + +
路径入参 / 返回
GET /deploy-limits返回部署实际 max_package_mb/max_unpacked_mb/max_package_entries,CLI 不写死上限
POST /deploymultipart package/app_id/confirm_schema_change;返回 deployment_id/app_id/version_id;同步失败不进入轮询阶段
GET /deployments/{deployment_id}stage/status/failure/app_id/version_no/approval/app_state/pending_reason 等当前 service 投影;CLI 只读状态,不自行合成成功入口
GET /{app_id}/logstail/since/keyword;转同一个日志服务并增加 resource owner/租户限制,不因 key 的管理员身份放大范围
+

② 发布状态只读接口:GET /api/v1/apps/{app_id}/publish-status 由 PublishStatusService 同时供发布页与工具适配消费;app_state/pending_reason、current/pending version、deployment、approval、tier、capabilities、schema_change、can 是不同维度,缺失 row 不假造成功。手动上线/撤回仍服务端重新检查。

+

③ AppManifest:严格 extra=forbid;manifest_version 1 走兼容 source-image,2 支持 bundle/image/source-image;runtime=python3.11/node20/static,port/tier/capabilities/database/egress 继续当前模型;delivery/dependencies/startup 的准确字段以 schemas/app_manifest.py 与 F086/F059 冻结合同为准。本轮仅新增 capabilities.secrets:[{name}],值不在 manifest。模型/知识能力及声明表结构/egress 现已实际消费,不再“非空即拒绝/只给 hints”。

+

④ 审批场景:app_publish_request;business_resource_type=app / business_resource_id=app_id;申请人为自然人 owner;business_key 对应提交尝试。build_detail 为结构化审读快照,有 app/version/tier/capabilities/visibility/schema_change 与兜底展示键;新增 secrets 仅名称/缺失提示。当前单租户审批来源与多租户解析按已合入代码,不能恢复早期固定 seed 示例。

+

⑤ 表:AppDeployment 保存一次发布尝试的 stage/status/code_object_key/manifest/tier_code/failure/scan_result,预检失败不进 AppVersion;ResourceTier 平台级、code 唯一、启用/停用。新密钥资产见 §4.4①,不修改 ResourceTier 所有权。

+

⑥ 审计:app.release.* 事件族保持;app/version/deployment 关联来自业务事实,事件枚举、后端可见白名单/命名空间、前端 actions/i18n 三语同步。新 app.secret.* 是值动作元数据,不带秘密片段。

+

⑦ 通知:发布/审批事件经现有 outbox 与通知适配触达;action_code 与接收者权限由真实场景决定。密钥值不进入通知正文、detail_snapshot 或前端消息 payload。

+

⑧ 错误码:沿用现有 162 模块与 frontend api_errors 唯一文案;16207 运行时关闭、16223 档位无效、16225 审批场景不可用、16226 容量不足、16228 探活失败语义不混用。原 16230 泛拒绝 secret reference 必须收窄为不支持/非法形状,合法 ref 可发布;模型面仍使用 265xx,不翻译成非模型 162xx。

+

4.3 关键模块职责

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
模块 / 拟新增落点做什么不做什么
F055 domain/models/app_secret_config.py、Repository、AppSecretService、v1 endpoint单表加密配置、owner 权限、revision CAS、审计、差异投影不编排、不创建自动重建意图、不返回明文
F055 application/runtime_secrets.py 公共协议与组合根formal_snapshot / preview_environment、配置清理;内部环境 DTO不暴露 ORM、不自行切路由、不授予管理权限
manifest/schema/CLI/审批摘要合法 secrets 名称、保留名、缺值提示不加载或携带值
F054 AppStateService / RuntimeDeliveryCoordinatorrebuild_current 受理、环境冻结、既有 deploy 执行与提交不写 F055 配置表/已生效结果,不迁移数据
manager Docker/K8s executor / admission用途/敏感名校验,创建实例、停止证明、探活及事实不读平台 DB/MinIO、不管理最新配置、不因保存而重启
现有 node-plan/协调入口续办已有操作,读取原期限及冻结环境不扫描配置差异来创建操作,不自动重试失败手动重建
AppSecretSettings 与 hook空草稿、固定掩码、保存、差异、确认、既有操作进度store 不请求、缓存不存值、不自动确认
F056 / 凭据 Owner查询配置事件;S1 的真实 binding 回收事件另待补齐不从查看者参数伪造回收事实
+

4.4 本轮增量字段与接口合同(拟实现)

+

① 单个持久配置模型

+

AppSecretConfig / app_secret_config:app_id 主键(与 App.id 同型)、tenant_id 默认 None/NOT NULL/索引、revision 非负整数、values_ciphertext 用通用 LargeText、updated_by/updated_at。密文内容为 envelope 与有界 entries 映射:name -> {value,updated_by,updated_at},保留每项最近修改时间而不增加表。最后一项删除后保留空配置行/revision,避免 revision 重置导致旧页面误写;应用真正删除后由既有持久 cleanup 调 F055 公共 delete_for_app 幂等清除,失败保留既有 cleanup 待办;历史无值审计不删除。

+

未建行表示 revision=0、空配置。save Repository 用同一短事务加载受权 App 并锁定,再按 app_id 锁配置/处理唯一约束首建;禁止在该事务调用 manager、锁节点/operation 或持有锁探活。与 App 删除的最终状态写入序列化:已删除不能再保存;先置业务不可写状态再清理配置,不允许清理后重新写入孤儿配置。每个接受的保存/删除使 revision+1;旧 expected_revision 拒绝,不自动覆写。配置与本业务审计由 AppSecretConfigRepository 使用同一个显式 AsyncSession 追加,事务失败一起回滚;复用既有 AuditLog 表及字段约定,不另建表。现有 AuditLogDao.ainsert_v2 自开 session 并 commit,不能调用它宣称同事务,也不为本功能新加 DAO 入口。

+

新模型登记租户发现与 API/worker 导入,缺上下文写入拒绝;DM8 仅通用类型/约束/迁移静态检查。迁移从唯一 Alembic head 接续。应用停用、版本升级、声明移除不删资产;名称精确比较、Unicode/空白/换行按 §2 处理,空值不标为已录入。

+

② 会话 API(拟新增)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
方法 / 路径入参返回 / 副作用
GET /api/v1/apps/{id}/secrets无值参数{revision,updated_at,items:[{name,present,mask,updated_at,approved_reference,pending_reference}],runtime:{comparison,saved_revision,running_operation_id,running_version_id,route_epoch,changed_names,rebuild_operation}};comparison=same/different/unknown/not_running,失败原因安全枚举
PUT /api/v1/apps/{id}/secrets/{name}{value,expected_revision}{revision,present,updated_at},只保存+审计;没有 operation_id、自动 pending 或后台唤醒
DELETE 同路径{expected_revision,confirm_online_impact}同上;删除保存值,不改声明/当前实例;线上引用先说明当前实例仍可能持旧值
POST /api/v1/apps/{id}/secrets/rebuild{request_id,expected_version_id,expected_config_revision,expected_route_epoch,confirm_restart:true}{operation_id,status},owner 明确确认后转 F054 rebuild_current;accepted 不表示已就绪
+

同租户先查业务对象及原可读规则,再验证平台会话的自然人 owner;super shortcut 不变为 owner。tenant admin 只见有资格读取的声明名/状态,不见未引用私有项和 owner mask/修改者;审批人只走批准范围的审读数据,不复用 owner 管理接口。写/重建均拒绝 SAK、PAT、非 owner。无权/不存在按原业务拒绝语义,不泄漏其他租户。公开请求不能传 env、ciphertext、purpose、node、tenant 或任意执行资源。

+

保存不设独立 request_id 历史表:网络结果不明时刷新安全元数据核对,不自动重复 PUT、不凭一个 revision 推断值内容已成功;显示结果待核对,owner 需要时重新提交。重建才用已有 RuntimeOperation 幂等:先做当前会话/业务归属核验,再按 scope=tenant/app 和 secret-rebuild:request_id 查原操作,核对原确认字段摘要;同参返回原操作,异参拒绝,不能因当前配置/版本已变化而先拒绝历史同参重放。新 attempt 才检查当前预期条件;新 attempt 需新确认,不复活原失败操作。摘要只含非秘密确认参数,不存业务值摘要。

+

新增错误按 162 占用检查分配,三语文案同步;不在设计猜编号。Pydantic input/ctx/body、埋点及错误日志不回显值。保存与操作都用既有响应 envelope,前端 catch/finally 结束等待。

+

③ 与现有部署的最小衔接

+
    +
  1. F055 公共 formal_snapshot(app,approved_version,expected_revision?,session) 在 caller 的短事务中返回 {revision,env,sensitive_env_names,declaration_digest};F055 Repository 显式使用传入 AsyncSession,只读/flush,不自行 commit,不依赖另一 Repository 的 ContextVar。调用方验证用途、批准版本和租户,provider 再验证输入绑定,只取声明子集;没有值为空,坏密文/缺 key 为失败,不伪装缺值。preview_environment(declaration) 只生成空值,不读配置密文。
  2. +
  3. F054 rebuild_current 不能把 node→已存在 operation 当作既有统一锁序:claim/fact 实际先锁 operation 再 node,ready commit 还会先锁 App 再进入 node/route。新受理选择先锁 App,再依次锁 node、assignment、route、配置;对已存在的 operation(含 origin 和冲突检查)只作非锁定读取,绝不在持有 App/node 后回锁这些行。只允许锁/更新本事务新插入、尚未发布给任何 manager 的新 operation;幂等重放在这些运行锁前通过只读查找返回,不对旧 operation 再执行 admit。save 只锁 App→配置,不获取运行锁;配置锁拿到后不能回取 App/node/operation。受理事务核验 online/current version/route epoch、当前真实 frozen delivery/profile/tier、没有维护/生命周期冲突或未知 writer(包括已受理尚未 claim 的非终态操作,不只看 assignment 当前指针);选当前 route 对应执行的 tier 预算,不重新解析可能已被管理员修改的档位数值。冻结参数、容量预占、新 RuntimeOperation 与 sealed_env 在同一个既有 UoW 提交;失败整次回滚。未冻结环境的半操作不对 manager 可领取。网络唤醒在提交后,丢唤醒由原 node plan 发现,不新增配置扫描器。
  4. +
+

受理锁集合与现有路径的关系(仅约束本次新分支,不声称底座所有入口已有同一全序):

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
路径会等待的已有行不允许的新增等待边
saveApp → AppSecretConfig不等待 node/operation
新 rebuild 受理App → node → assignment → route → config不锁任何已存在 operation;node 后不再取 App;仅本事务私有的新 op 可由 create/admit 自锁
现有 claim/fact自身 operation → node → assignment/route本次适配不在持 node 后获取 App;ready fact 的事务先结束,再调用 commit
ready commit自身 operation → App → node/assignment → route本次不在持 App 后回锁 origin operation;旧 writer 状态/事实只读,既有停止/容量协议不改
+

同 app 的受理受 App 行串行,既有 node/assignment 围栏仍控制执行;活动冲突读取以拒绝/后续精确 origin 与代次检查保守处理,不把快照里暂未出现的 queued op 当作唯一安全证明。实现阶段用两个真实 SQL session 覆盖与 claim、maintenance fact、ready commit 的交错,不以单进程锁 mock 代替。若实际 helper 引入表外锁/独立 session,必须先修正此局部调用,不将上表当全仓已验证事实。

+
    +
  1. +

    现有 create 会拒绝 caller sealed_env,因此由受信 inject_environment 在同一 UoW 装配 seal,不把 env 放进公开 OperationInput。持久非秘密字段为 purpose=config_restart,auto_commit=true,secret_config_revision,sensitive_env_names,approved_declaration_digest,origin_operation_id,origin_exec_ref,origin_route_epoch 以及既有 expected_app/current/pending 等围栏;秘密只在 sealed_env。普通新部署/恢复也要从其受理快照装配,不留“先创建操作、之后读任意最新值”的窗口。 +同事务环境生产者,不复用独立提交方法冒充原子性:现有 inject_environment → AppRuntimeCredentialService.stage → CredentialService.issue 会通过 CredentialRepository 和 HostedAppSubjectDAO 自开事务提交,签发审计也独立提交。新增受理路径由凭据 Owner 提供显式 stage_in_transaction(...,session) 公共服务入口:受信主体解析/首次创建、ApiCredential/委托子项、RuntimeCredentialBinding、签发审计都使用 caller 的同一个 AsyncSession,只 flush、不 commit;原无 session 的签发入口保持原行为。返回 token DTO 从本事务已 flush 的数据构建,不调用另开 session 的 _to_item/delegate 查询去读未提交行,不在提交前发 Redis/消息/外部回调。seal、任何 flush 或最终提交失败时,候选主体/凭据/binding/审计/op/预占均回滚;原幂等重放先返回已有操作,不再次签发。该新增协议只补本次受理所需的 producer,不宣称修复历史回收审计 S1,也不重写全部签发流程。

    +
  2. +
  3. +

    deploy() 普通同版本快速返回保持原语义;只对显式确认的 config_restart 路径创建新实例。active deploy 的查询/复用必须校验 purpose 和冻结请求,普通发布不得接管 config_restart,反之亦然;冲突复用既有忙/unknown 拒绝。重建固定 migration_plan=[]、不允许 snapshot/restore、preserve_pending_version=true,不调用 schema_evolution_service;源 version/delivery/profile/digest 和原执行 tier 不变,不重建镜像、不创建代码版本、不触发审批。

    +
  4. +
  5. +

    manager 明确声明支持 config_restart 与敏感输出协议,否则重建拒绝并提示升级;保存仍可独立完成。按既有停止证明→新实例→探活→ready 事实执行。Docker 用 Engine Env,K8s 复用执行专属 Secret/secretKeyRef,Secret 只供 serve,helper/preparer/assembler 无真实值。不能仅 docker restart,也不通过 shell 插值。

    +
  6. +
  7. +

    本操作自身的 maintenance 路由转换:原 manager _deploy 在停止旧 writer 前会上报 maintenance,backend fact 调 RuntimeProjectionService.block 会递增 route_epoch;因此不能在 ready 时仍要求原 epoch 原样不变。新增该 purpose 的受围栏投影适配:在同一既有 fact/UoW 中核验 op 的有效归属和冻结 origin route(id/version/operation/exec_ref/epoch),再幂等消费 maintenance 事实、变更路由;在该 RuntimeOperation.payload 记录非秘密 maintenance_transition={fact_sequence,route_id,before_epoch,after_epoch}。同一 sequence 重放只返回原事实/转换收据,不再修改当前路由(即使后来已经 serving);没有重复 block/递增。新的 maintenance 事实若已有转换,只能验证同一尚在途的维护状态,不能追加任意 epoch 或把新路由再封回旧维护态。ready 提交只接受未变的原 route,或本操作这条事实导致的精确 after_epoch、maintenance 状态及原 route 身份,不能用“epoch 更大即可”放行。其他 stop/delete/切流或不同操作造成的改变仍拒绝;不把网络封入口当作 writer 停止证明。该记录只是既有维护事实到路由投影的幂等消费,不是密钥 applied 状态、额外表或新配置成功回执。

    +
  8. +
  9. +

    commit_runtime_delivery 为该 purpose 保留 current/pending 指针和原版本终态,只更新原有实例/route/credential/storage 投影;不调用 mark_runtime_online 重标版本,不写 F055 表或 applied receipt。原 expected state/current/pending/UID/代次校验不放宽,origin route 只允许上条证明的自身维护转换;审批或停用等改变关键指针时按既有冲突清理,不能覆盖新指针。保存配置变化不作为拒绝提交的条件:受理后 revision 可以继续增加,本次仍按被冻结环境完成,页面保留真实差异。

    +
  10. +
  11. +

    明确的异步 ready→commit 消费者:受理冻结 auto_commit=true,由现有 RuntimeOperationService.fact 在 ready 事实事务完成后调用 AppStateService.commit_runtime_delivery 的 config_restart 分支,不依赖一个已返回的 HTTP handler 等待。manager 仍按原协议等 serving;首次 commit 回滚时,持久 ready 事实保留,ready 上报的同参重放也必须再次尝试同一提交,不能因 fact 已存在而跳过 auto_commit。提交前 backend 进程退出由 manager 的原 operation/node-plan ready 续办触发原事实重报;不创建新操作、延长期限或配置专用调度器。manager/backend 都恢复不了则按原 deadline 显式失败,不挂成永久 pending。该消费者本身先结束 op→node 的 fact 事务,再进入 op→App 的 commit 事务,不跨两段持锁。

    +
  12. +
+

route/实例沿既有 UoW 原子提交。DB 回滚则只重试原 ready 提交;响应丢失或 serving/completed 重放,检查既有 op/route/exec_ref 的一致性即可返回原结果,不再增加配置结果双写。未知结果不再创建 operation/执行 DDL,不把已变更的 route 归为旧结果成功。差异仅从实际当前路由计算,不从某个成功回包推导。 +9. 用户请求受理后可以继续保存;既有 operation 的 sealed_env 不替换。手动重建失败/取消/超时后,RuntimeDeliveryCoordinator.recover 与其他调度入口都读取现有 op.purpose/status,不把它自动转换成 recover deploy 再试;同一在途操作仍可按原期限续办/清理。旧 writer 未确认停止或 cleanup 未完成时,拒绝用户新 attempt;终态不等于已停。stop 可按原协议取消并清理,不授权强删。正常新版本发布、用户重新上线和独立崩溃自愈仍按各自既有生命周期;保存本身不会触发它们。

+

④ 差异计算合同

+

F055 查询服务消费 F054 公开 running_environment_snapshot(app_id) 只读协议,返回经租户/当前正式路由校验的内部 DTO(版本声明、operation/exec_ref/route_epoch、受限运行 env);不从 F055 跨域查询 runtime ORM、不调用 manager inspect,不把 env 放进响应。生产方为 F054 route/operation Repository,权限在调用前由业务服务确认。

+

在短时一致读取中选取 current App/version、route 与对应 operation 的 sealed_env;重新确认 route identity 未变化后才报告 same/different。最新配置在本次读的 revision 上比较声明子集,未引用项忽略,缺项归一为空;不作值 trim/编码转换。A→B→A 归为 same;仅配置 revision 增长不能归 different。若无运行实例,not_running;已有执行但快照缺失/无法解密、状态未知或 route 变化不能安全比较,unknown 并给安全原因,不返回空集合冒充 same。读后再发生保存属于下一次查询,响应含所观察 revision/route epoch;前端丢弃旧请求并在保存/操作终态刷新。

+

运行上下文已由 operation 持有;只读服务不能返回任意历史 operation 的明文环境。日志及比较需要的受限上下文按现有操作留存管理;清理后不额外复制秘密历史,比较可用性退化为 unknown。changed_names 仅包含调用者有资格读取的当前声明名;审批人看版本缺值,不读生产差异。

+

⑤ 名称、平台变量与输出保护

+

保留 BISHENG_*、RTM_*、KUBERNETES_*、MINIO_*、OPENAI_*、HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY(含小写),及 PATH/HOME/LD_*/PYTHONPATH/PYTHONHOME/NODE_OPTIONS/PORT/HOST、启动器实际保留项,统一 deny policy、两侧测试锁定。禁止别名覆盖平台身份、模型通道、启动/依赖路径或出口;后续新增平台注入名必须同改 policy。

+

平台不打印写请求体/环境/引擎创建请求,错误只含 ref 与安全原因;runtime status/diagnostic 递归白名单序列化,禁 raw inspect/Secret 内容,日志下载不得拼接 env。客户端埋点不得读取密钥输入。

+

operation 绑定的敏感项合同:backend 从已批准版本的 capabilities.secrets 生成 sensitive_env_names(只含名),与 revision/declaration_digest 一起冻结在可信 operation;值仍只在 sealed_env。普通客户端不能提交或修改该集合。manager 从这一 operation 的 runtime_env 取这些名的值构建内存 SensitiveValues,不从“当前保存值”代替历史执行值;除原有平台 token/代理凭据规则外,这些业务值不受名称启发式或最短长度限制,空值忽略,短值/普通名一样处理。跨重启从受限 operation 重新取值,不能把敏感值清单写本地状态、日志、材料、query cache 或普通 JSON 诊断。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
生产 / 消费路径必须处理的点
backend 正式环境装配 → manager只为批准 formal operation 产生敏感名与真实值;probe/preview 为名称+空值,build/preparer/helper 无真实值;旧 manager/legacy 路径不能消费此合同则在注入前报缺能力
container_runner.redact 与 Docker 正式日志 api/platform_observe.logs共同接收 SensitiveValues;按原执行绑定值处理,不再只猜 KEY/TOKEN,不允许因值短而跳过
K8s executors/kubernetes._diagnostic/logs 与 API 日志出口init/serve 日志、waiting/失败信息、正式日志统一使用同一脱敏器,不遗漏 K8s Secret 中注入的普通名称
legacy api/readonly._redactions / 日志输出同步消费执行绑定的敏感集合;没有可信集合时不能把新密钥应用降级走旧启发式;无密钥旧应用仍保留原行为
manager operations.report/_failure → backend RuntimeOperationService.factmanager 在 diagnostic_tail/错误输出发出前脱敏;backend 按同一 op 的 sealed_env + 敏感名再次处理,再截断/持久化;用户状态/下载只见已处理文本
+

脱敏统一采用最长值优先的一次匹配,覆盖重叠值、Unicode 和整个多行值;先脱敏,再按长度截断/分行/关键字筛选。分块读取须保留最长敏感值长度所需的边界上下文,不能先对每块独立 replace;Engine/Pod API 已截断或缺失上下文、无法安全判断的片段整段省略并返回安全说明,不回退原文。敏感材料缺失/不可解密时,受影响日志/诊断不返回 raw tail;运维只得到引用名/原因和安全元数据。日志留存期间必须保留对应执行的受限脱敏上下文;清理后不再提供无法保护的原始历史输出。

+

这保护平台日志/故障信息出口,不承诺阻止拿到值的应用主动编码、拆分外传或写入自己的业务数据;不增加独立“日志脱敏产品”或新的审计正文采集能力。

+

⑥ 前端/CLI/审读

+

应用页显示“应用密钥”,列名称、配置状态、每项更新时间与批准/待审批/未引用。保存只提示“已保存”;另一区域根据 comparison 显示“运行配置与最新保存配置不一致”、差异名或“无法确认”。无运行实例显示“下次启动使用最新配置”。提供“重建使配置生效”动作及独立确认,说明重建当前版本可能短暂中断,取消不发执行请求;保存按钮、删除确认和关闭表单都不能触发重建。操作进度消费既有 RuntimeOperation;失败保留配置和真实运行状态,需再次确认才试。删除说明当前实例可能继续持旧值;不显示新旧值。PUT 成功刷新安全元数据,不以旧 revision/HTTP 成功判生效。编辑空草稿、固定 mask 不作输入值,冲突保留未提交草稿;关闭/换应用/退出清理内存,过期异步响应丢弃。

+

CLI 不接收值、不提供写密钥命令;manifest 带合法名称可部署,预检/状态提示缺值但不输出任何现存值。审读仅声明名/缺失/差异,由服务端按会话资格构建;不用 owner GET 列表给审批人凑数据。新增文案同时交付 zh-Hans/en/ja(含错误码),不新增中文硬编码。

+

5. 已知坑 / 反直觉事实

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
坑后果处理位置
保存值不会改变容器已有环境;docker restart 也不更新 Env保存后误报生效AppSecretSettings 状态 + F054 rebuild_current
原 deploy 同版本 serving 直接返回用户确认却没有实际重建Coordinator 显式 config_restart 分支
普通 deploy 重新算 schema/tier改变量触发 DDL或变档空 migration_plan、原执行 tier/冻结 delivery,负向用例
配置 revision 不同可能只改了未引用项,A→B→A 也会递增误报重建、制造停机F055 compare + F054 running_environment_snapshot
原 stage/issue/subject 和审计会自行 commitseal失败仍遗留候选凭据,外层回滚是假原子凭据Owner显式session的stage_in_transaction及全部producer同UoW
accepted 后没有 HTTP 等待者,普通deploy靠 wait→commit实例ready后一直等serving至超时auto_commit=true + fact 的持久ready消费/重报
claim/fact 是op→node,commit还要App错把node→已有op/先node后App引入互等§4.4③局部锁集合,无回锁旧op;两会话交错验证
原 create 和 inject_environment 是两次调用受理与冻结之间读到后来值同一受理 UoW封装冻结,manager 不领取无环境操作
原 recover 会自动创建另一 deploy手动失败后未经确认再重建recover/plan 消费 purpose/status,原操作只续办清理
原 active deploy 查找不区分用途发布串接手动重建、误改版本状态Coordinator active 查找和 commit 分支
单表只记整组 updated_at 不能表达逐项最近修改违背 AC-67加密 entries 保存逐项元数据,不新增表
maintenance 由本操作自己推进 route_epochready误拒或直接放宽epoch会接受别人的切流fact→projection 的受围栏幂等转换及commit精确验证
当前 route 指向的环境才是运行事实新值保存或旧结果回包误标最新已生效公开只读快照 + 路由绑定核对,缺上下文 unknown
普通管理员 shortcut 不等于 natural owner管理员越权改第三方值F055 business owner + 平台会话
日志先截断再脱敏会泄漏短值/跨行值错误输出泄露配置§4.4⑤ 全出口操作绑定敏感集合
删保存值不是第三方凭据撤销,也不是清空现有实例owner 误以为旧值已不可用删除提示;显式重建,第三方撤销由 owner 执行
+

历史 MVP 与三表方案只保留在历史评审/提交中;当前接手按本文。F054 B1、F056 S1 独立待办不由本设计覆盖。

+

6. 对外契约与依赖

+

6.1 我提供给别人的(Outgoing)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
契约消费者破坏风险
原 deploy/limits/status、发布/审读/预览CLI、平台、审批合法旧包不能因新 secrets 规则失效;预检空值继续真实探活
capabilities.secrets:[{name}] + 缺值提示F053/F054/F059/F086名称变更审批;值永不写版本/材料
F055 public formal_snapshot / preview_environment / delete_for_appF054 组合根及生命周期显式 session 与用途校验;秘密 DTO 不可进通用响应或日志
secrets GET/PUT/DELETE/rebuild + runtime comparisonAppSecretSettings/hook保存零编排副作用;取消零执行;非 owner 不取得私有值/管理权
app.secret.set/delete 与 app.secret.rebuild 元数据F056 操作审计读侧action 枚举/后端白名单/前端筛选/三语 lockstep;无值无末位
+

6.2 我依赖别人的(Incoming)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
依赖形式风险
固定 runtime_payload_key、平台 DB/审计持久表配置/Python丢 key 不能恢复;审计事务失败则保存回滚,不回退源码 key
F054 rebuild_current、running_environment_snapshot;F086 RuntimeOperation/route内部应用协议和受限 DTO当前代码需要新增分支/只读导出;不能跨层查 ORM或把公共端点变 env 读取接口
凭据Owner新增stage_in_transaction及session-aware下游Repository显式AsyncSession主体/凭据/委托/binding/审计任一仍自行commit就破坏受理原子性;不能跨session读未提交行
既有 node-plan、stop proof、容量、ready提交、cleanup持久执行协议漏用途判断会自动重试或执行DDL;未知 writer 不交接
K8s Secret/RBAC/admission、Docker Engine Env执行基础设施旧 manager 缺能力拒绝重建;拥有编排 API 的管理员在信任边界
平台会话/Permission application/tenant context可信身份协议SAK/PAT 的资源归属不等于自然人 owner
UI 字体/色彩/多端/输入/确认规范、包裹 request/三语TS/组件Button/Input/Textarea/Confirm 用 done 组件;Modal/Select draft 不自造视觉模式,按已有发布页交互;窄屏遵规范
+

§4.4 的公开协议由各 owner 提供,组合根装配;F054不导入 F055 ORM,F055 不查询 F054 ORM。部署 API 权限与平台数据配置保持进程边界,没有 constitution 例外。

+

7. 测试与可观测

+
    +
  • 单元定向:密文绑定/坏 key/缺上下文/保留名/Unicode与多行;owner允许和管理员/超管/审批人/SAK/PAT/跨租户拒绝;保存无 manager 调用、无 RuntimeOperation;CAS 旧编辑拒绝、事务/审计回滚、最后项删除不重置 revision。声明/预览空值、短值/普通名/重叠/多行/分块日志保护覆盖各输出。
  • +
  • 差异定向:当前 route 与 frozen env 精确绑定;只改未引用项、A→B→A、缺值与空值、无实例、坏密文、快照丢失、路由变化分别 same/different/not_running/unknown。不得依赖普通值摘要或新增 applied 表。
  • +
  • 部署定向:取消不建操作;确认过期拒绝;原同参重放在后续改值/换版本后仍返回同一 op、异参拒绝;同版本真的重建、tier/profile/digest 不变、pending 指针不变、无 DDL/restore/构建;本操作maintenance精确转换允许、重复fact不增epoch、其他操作的epoch改变拒绝。受理冻结/凭据签发后seal或flush/最终commit失败,全链候选资产均回滚;已有请求不重复签发。两真实SQL会话覆盖App/node/assignment/op/route交错;ready首次提交失败后同fact重报提交、backend在ready后退出原operation续办;丢唤醒原计划恢复,丢响应不重建第二次;在途继续保存,完成仍显示真实差异。用途不串接、失败不被 recover 自动再试、unknown writer 不覆盖;复用底座围栏用例,不全量跑。
  • +
  • 前端定向:空草稿/固定mask/失败保留输入;保存只提示已保存;差异提示含名不含值;独立确认及取消;过期响应丢弃、PUT冲突不覆盖、操作状态失败结束等待;采用现有组件和三语,无新视觉设计。
  • +
  • Docker/K8s 各一次最小 E2E:owner 在 /build/apps/{id} 保存哨兵,记录容器/Pod UID 不变;确认前访问仍读旧配置;确认后新UID、原代码/档位/PVC及SQLite数据不变、哨兵匹配。预检/审批预览为空;失败不自动再启,用户再次确认才试;测试应用只返回是否匹配,不回显密钥。
  • +
  • 编码后命令:cd src/backend && uv run pytest test/app_publish/test_app_secret_service.py test/app_publish/test_app_secret_runtime.py(拟新增);UI只跑 AppSecretSettings 对应测试,runtime只跑修改的用途/日志相关文件。必要 Ruff/arch-guard/单head 定向检查;DM8 仅模型/迁移静态。当前全部为计划,未执行。
  • +
  • 可观测只含 app/ref/revision/op/purpose/comparison/安全原因/耗时;不采写请求体、env、Secret inspect、普通值摘要。保存与重建分别计动作,GET比较不制造业务事件。
  • +
+

8. 后续改进 / 不打算做的事

+

不做自动配置生效、配置独立状态机、值历史库、外部 Vault、部署 key 轮换工具、热加载、SDK 明文读取或批量重启。只在真实新增产品需求出现时重新审查,不预建表/调度器。当前执行上下文缺失时比较可为 unknown;K8s Secret 不防有权集群管理员,应用自行编码外传不在平台日志脱敏保证内。静态前端不能注入浏览器/编译产物,声明使用却不能安全注入时明确拒绝,不暗中放宽。

+

设计仍需用户单独确认后才拆任务/编码;F054 B1、F056 S1 未关闭,ARM、完整离线与正式镜像发行保持原待办,不由本设计审查替代。

+

修订历史

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
日期变化依据
2026-10-23(手动生效简化)单表配置;保存零执行;按当前冻结环境比较;显式确认复用原部署;移除自动状态/独立请求/回执与写回协议用户已确认修订规格;新增设计尚未确认/编码
2026-10-23(二次修订,已由简化方案替代)明确固定部署 key 与运维边界、执行敏感项全链路、同一 UoW 生效与响应丢失/部分结果恢复用户要求修订并复审;未新增轮换功能,尚未编码
2026-10-23刷新现有能力/存储/运行协议;新增 D17–D20、四组密钥合同、同版本配置刷新与定向验收设计已确认增量规格与实际代码,未编码
2026-08-17 起早期发布管线/审批/档位/能力总线设计旧正文见 dda16a6;本次不倒改历史验收事实
+

Design: 应用广场、治理与运行记录查询

+
+

2026-10-23 增量设计评审稿,待用户确认,未编码。 现状基线 5f2541f4bc483c24624fede9031c88c4ec6f58f4;规格确认提交 dda16a6502908ba8d58cff61dc350c0213b86c19。保留 D1–D11 与 §1–§8 编号,新增 D12–D15;历史 MVP“查询全后置”已不适用于现有操作审计。

+

spec.md(61 条 AC)· tasks.md · release-contract.md。本次新增 AC-46 至 AC-61,修订 AC-26、AC-34;不接管模型/能力/访问记录的业务写入者。

+
+

1. 目标与非目标

+

保留应用广场和统一授权交互,在现有“审计日志”页增加“应用运行”页签,查询/导出模型调用、能力调用和应用访问三类既有数据;让现有操作审计能够按应用找到密钥变更与凭据签发/撤销事件。

+

非目标:计费结算、全链路 tracing 产品、日志全文搜索、把 runtime stdout 当审计、重算历史身份/Token、为每次页面查询生成业务访问事件、给 owner 或开放 API 凭据授予管理员日志读取能力。

+

2. 关键约束

+
    +
  • 管理员级运行记录只接受自然人平台会话:租户管理员只查有效当前租户;全局超管无 admin-scope 时可明确选全局/目标租户,有 scope 时锁定该租户。部门管理员/只有 log 菜单的角色/应用 owner 不能因此取得此新读面。
  • +
  • 现有系统操作审计拥有更宽的菜单/管理范围规则,不直接复用其 _resolve_audit_groups 当新接口权限;保留旧接口行为。
  • +
  • 三类记录并不等于三类成功操作:无 app_id 的服务账号模型调用仍应显示;访问记录是入口 allow 合并窗口,不能展示为每个 HTTP 请求;缺历史字段用 ID/未知,不回填当前无关名称/用户。
  • +
  • 列表默认最近 7 天,可选择任意合法自定义区间或全部已留存时间;UTC ISO8601 带时区传输/规范化,start≤end;limit 1–100。不设 90 天查询或 10,000 行导出总量上限,导出覆盖所选条件下全部受授权记录。内部每批例如 500 行、worker 并发/租约和按字节输出分块仅控制资源,不变成用户可导出总量限制。
  • +
  • Constitution Check:遵守 constitution.md C1–C8。新读侧 Model/Repository/Service/Endpoint 分层;租户自动上下文不手拼通用 tenant 条件;全局超管进入显式审计 scope;通用索引/类型无 JSON_EXTRACT;模块错误码 163 段登记后使用;导出在受限对象存储不放 API 本地盘;无铁律例外。
  • +
+

3. 方案对比与选定

+

D1:广场继续现有 UNION 与批量权限

+

不新建平行应用列表后在前端拼接。托管应用 flow_type=35、slug/app_state 已随列表下发,批量可见性继续走 F048;单卡补拉权限造成 N+1 且越过通用策略。新资源类型/统一列表协议改变时再议。

+

D2:卡片整页访问稳定入口

+

保留跳转 /apps/{slug},不嵌 iframe、不拼 client /workspace base;统一登录/WS/身份均在入口。切换域名模型时再评审链接生成。

+

D3:广场动作与可见范围仍由通用权限驱动

+

app 适用动作集与广场 use 筛选保留,不把“能看到卡片”作为运行记录管理员权限。二者属于不同业务目标。动作 Catalog 改变时审查消费者。

+

D4:管理权限仍复用 PermissionDialog

+

构建卡片与发布页 VisibilityScopeSection 用同一交互/API;不为了审计页再建授权入口。当前 request 拒绝错误已需 catch,异步失败不能留 loading。组件契约变更时复核两个入口。

+

D5:仅 owner 可见继续按 protected grant 区分

+

非 protected 授权行为空才表示无额外放行;默认 owner 行不应算外部授权。不从空列表误推断“无人有权限”。权限返回 schema 改变时重审。

+

D6:可见范围变更由真实写路径审计

+

仍在统一授权 mutation 后按资源回调写 app.visibility_change,不从前端补写。查询/导出仅消费事实。新增授权写入口时必须核对回调是否覆盖。

+

D7:访问记录写 F054、读 F056

+

entry_authz_service 已写合并窗口访问事实,不再“写读一起顺延”。保留既有合并窗口及其测试,本轮只补稳定读/导出适配,不按请求量补记历史访问。日后产品需要 PV/会话分析另立语义。

+

D8:runtime 关闭明确隐藏与拒绝

+

新页签和新列表/候选/导出接口都读取实际 app_runtime_enabled;关闭时后端给部署开关错误,不把空列表当安全机制。旧系统操作审计照常可用。开关语义变更时再议。

+

D9:下线应用仍可进广场

+

广场对托管应用白名单保持 online/stopped,不带草稿/待上线/删除;历史日志则不能以这组存活状态过滤,否则删除后记录消失。是否显示已删除资源是两个读面的不同合同。

+

D10:广场名称搜索与日志候选分开

+

不拿可见应用广场列表当审计候选;它缺已删除资源、范围也不同。日志名称搜索基于受限审计记录/可信历史映射,只在当前管理员 scope 生效。若统一搜索服务支持历史治理数据再替换。

+

D11:当前查询交付范围

+

现有 api/v1/audit.py 与 api/services/audit_log.py 已有系统操作审计、target_app_id 与导出;LogPage 仍只有“应用使用/系统操作”两页签。本轮加第三页签与三子视图,不重做旧审计或重写广场。真正已落地必须以实际调用点而非 tasks 计数判断。

+

D12:三专表适配成管理员读服务(新增)

+

否决运行期高频记录塞普通 AuditLog、查日志文本推导、另造统一大表/回写历史。选定 F056 的 RuntimeAuditQueryService 调三个 Repository 适配器:现有 ModelCallRecordRepository(F051 写)、AppCapabilityCallRecord(F055 写)、AppAccessLog(F054 写)。现有模型读库有 bypass+手动 tenant 模式,新入口改用明确 AuthorizedRuntimeAuditScope,旧调用兼容由 owner 控制,不向客户端暴露 tenant_id=None 作为全局授权。

+

共享过滤/分页/导出规格,但不同视图字段保留语义;服务账号模型调用不强制 inner join App。证据是现有三种表字段不一致,只有模型表已有 cursor/export,能力表只有 insert,访问表已有旧 page DAO。新建读 Repository,不加新 DAO 入口或让 Service 写 SQL。未来跨类型关联检索成为真实需求才考虑物化统一索引。

+

D13:稳定列表与一次导出的固定读取视图(修订)

+

否决仅按秒级时间排序、offset 翻页、开放式追随新日志的无限导出,以及把 10,000 行/90 天预算当产品限制。列表采用 (event_time DESC,id DESC) keyset,首次查询固定 filter/scope 摘要、upper_time 与当前可见最大 id 的 watermark,cursor 绑定该选择条件;未知时间单独排序并以 id 决胜,不能在 NULL 比较上丢页;all 保留未知时间行,recent7d/custom 不能判入区间的未知时间行不伪造时间,筛选语义明确说明。新请求严格校验 cursor,不把解码失败当第一页。

+

列表只保存短时 RuntimeAuditQuerySnapshot 条件元数据并返回第一页;不为了列表先收集所有 ID,不依赖 MinIO 才能看数据。刷新/改变条件重新选择范围。列表与随后导出共享冻结的条件、watermark 和权限;并发写入下不额外承诺两个不同读取时点的数据库成员集合完全相等。导出会展示其实际选取时点及最终行数,不把导出完成数冒充此前列表的即时统计。

+

完整导出在后台的一次受权读取视图中完成:任务受理时冻结 QuerySnapshot 的过滤/范围,source-owner Repository 在 worker 开始扫描时打开一条、范围有界的 ORM SELECT 只读一致游标,按稳定顺序流式输出全部匹配记录。该语句的读视图固定、watermark 固定;后续新增/回填提交不能把本次扫描变成无限流。每批最多 500 行处理/输出,但一直扫描至该读取视图 EOF,不能达到某行数后拒绝或截断。无资格/跨租户条件在源 SELECT 构造/执行时仍走自动租户过滤或显式授权的全局 Repository,而不是把多租户约束留给 CSV 写入端。

+

source Repository 提供 stream_export(scope, frozen_filter, watermark, batch_size),持有只读 source session/游标至本 attempt 扫描结束;任务进度写入用另一短事务 session,不在源读取事务中写状态。底层必须采用真正流式/受限 fetchmany,不用 all()/fetchall() 把整表搬进内存;MySQL/DM8 由各 source-owner 适配其固定读视图游标/事务,定向检查驱动的实际缓冲和语句范围,不能仅设置 yield_per 就宣称有界。占用一个后台读连接/读视图至扫描结束是此方案的显式成本,以导出 worker 并发、可观测持续时间及运维取消控制,不在 HTTP 请求保持长事务,也不转成 90 天/行数硬门槛。

+

worker 崩溃后无法恢复数据库游标读视图,因此未 ready 的 attempt 整次只读重建,不拿旧游标偏移接新视图造成漏/重;同一冻结条件与 watermark 下重新开启读视图,显示新的 selection_started_at。旧 attempt 从未交付字节,只保留待清理私有分块;只有一次完整扫描的 manifest 才能原子发布为 ready。ready 后的文件成员与字节固定,重复下载不会重新查事实表。若历史清理/读取故障导致不能完整输出,显式失败,不标空文件或部分文件成功。

+

D14:受权异步导出与不可变分块产物(修订)

+

否决公开 presigned URL、前端只导当前页、提交时授权永久放行。选用后台固定读取视图 → 标准 CSV → MinIO 私有不可变分块 + 有序 manifest;backend 认证下载按 manifest 依次流式拼接为一个 CSV,不暴露对象 URL。CSV 标题/BOM 只在首块,每个分块在完整行边界收束。任务状态为 pending/running/ready/failed/expired;总记录数不设产品上限。

+

任务通过 DB 租约/epoch 取得单一输出权;对象 key 绑定 job/attempt/epoch/part_no,上传确认后短事务登记分块及累计行数。ready 的 CAS 必须同时校验当前 lease/epoch、同一 source read-view attempt 已 EOF、全部分块已确认且 manifest 有序/完整。旧 worker 只能遗留未引用的私有对象,不能覆盖新 attempt 或把任务标 ready;响应丢失重查持久 ready/manifest,不能追加重复 CSV 行。失败或重启按 D13 从头读取新 attempt,不拼接不同读视图的分块。

+

受理、worker 开始/每批和 ready 发布、状态读取、下载开始/受限块之间都核验当前自然人管理员资格与保存的 scope。失权或无法确认即取消交付/停止任务,不使用提交时缓存永久授权;已交付字节无法收回。条件快照未被任务引用时 10 分钟过期;任务受理后其冻结条件由任务持有,不因排队/扫描超过 10 分钟被清理。下载保留期从 ready_at 开始计算 10 分钟,未完成 attempt 由租约/取消和终态 GC 管理,不能用该保留期限制记录总量或扫描时长。

+

CSV 仅白名单字段;危险公式文本前置单引号后按标准 CSV 转义,测试空白/控制字符绕过、逗号、引号、换行与 Unicode;数值按数值,null 不替成其他主体。对象/worker/source 不可用时明确失败,GC 只删没有活跃 lease/ready manifest 引用的本任务对象;就绪产物到期/取消清理有 durable 状态,不依赖本地盘。未来确需更高吞吐再独立调度,不改变完整导出的规格。

+

D15:可信应用关联的操作审计(新增)

+

F055 负责写 app.secret.*;凭据 owner 在签发/撤销时从持久 ApiCredential.subject_kind/subject_id 与可信 hosted-app 归属映射计算 target_app_id,不取查看者传的 app_id;SAK/PAT 的资源 owner 不等于 app 归属,不能把该人的所有 key 自动关联给某应用。

+

现有 CredentialService._audit 只写 credential_id/subject/scopes/key_mask,需要补可信关联;历史记录只能在原始可信字段足以确定时通过只读关联查询,不补写历史“现在属于”。不增加第三方业务密钥末位/长度,API key 原有前缀/末位 mask 继续保留。若普通凭据没有可信 app 绑定,明确不相关,不硬凑。后续新 credential owner 需提供同一审计关联协议。

+

4. 系统现状(接手必读)

+

4.1 数据流与实现状态

+

已实现:广场第三类型、两处权限 UI、可见范围审计、系统操作日志查询与应用筛选;模型表/能力表/访问表真实写入路径。本轮未实现:管理员 RuntimeAuditQueryService、新 endpoint/候选/稳定导出、新第三页签、可信凭据关联补齐、访问 app 名称快照。

+
管理员范围三类源记录冻结条件/流式导出私有文件认证下载
流程图源码(与 Markdown 一致)
```mermaid
+flowchart LR
+  UI[LogPage / Application runtime] --> API[F056 session API]
+  API --> Scope[server verified admin scope]
+  Scope --> Query[query service / typed filters]
+  Query --> M[ModelCallRecord repository]
+  Query --> C[Capability record repository]
+  Query --> A[Access record repository]
+  Query --> Snapshot[frozen filters and watermark]
+  Snapshot --> Job[authorized worker: stream one fixed read view]
+  Job --> M
+  Job --> C
+  Job --> A
+  Job --> Store[immutable CSV chunks and manifest]
+  Store --> Download[backend authenticated download]
+  Download --> Check[current role and scope check]
+```

4.2 既有公共合同(保留原广场/审计语义)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
字段 / 入口当前约定
广场列表 flow_type/slug/app_state/can_share/user_name托管类型 35;slug 为稳定 /apps/{slug} 入口;应用态仅 online/stopped;can_share=false;owner 显示沿用既有列表投影
GET /api/v1/chat/online现有 page/keyword/tag/type 等筛选;服务端收窄 app 状态和 use 动作,不信客户端 action 扩权
PermissionDialogresourceType=app,resourceId=内部 app.id;两入口共用,protected owner grant 不算额外放行
app.visibility_change变更人在真实授权 mutation 后回调记录;app/slug/added/removed 元数据;不由查询写入
事件登记后端可见 action/namespace、领域 action、前端 log.ts、三语事件名保持 lockstep
现有系统操作审计/api/v1/audit 和 /audit/export/data 已支持 target_app_id/tenant 筛选;不因新增 runtime 读面改变旧菜单/管理范围语义
+

4.3 关键模块职责

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
模块 / 新增落点做什么不做什么
app_governance F056 读侧(按既有领域目录落点核对后实施)Endpoint/Service/Repository受权过滤、分页、候选、snapshot/export不重新写模型/能力/访问事件,不查 OpenFGA 原始 client
三个 source-owner Repository 读适配协议返回白名单列表页与固定读视图流式 export;持有源只读 session,保持事件语义不从通用 Service 跨层 ORM 查询,不 fetchall,不在源事务写任务状态
open_api/.../credential_service.py:_audit 与其可信 app resolver签发/撤销时写 app 关联不由 viewer 参数、owner 人员关系猜 app
app_runtime/.../app_access_log_service.py新事件可选 app 名称快照,合并窗口不变不回填历史事实、不记录拒绝为成功
pages/LogPage/runtime/、API/hook(新增)三视图、filters/table/export、错误和过期状态不含秘密列,不读凭据明文接口
AuditLogService 与现有 systemLog查 app.secret 与可信凭据事件不把 runtime 高频事件塞操作审计
+

实施前确认 F056 现有代码归属:当前主要在 api/services/audit_log.py 与通用列表模块,新增读服务拟建立 bisheng/app_governance/{api,domain},仅管理本 Feature 读侧,不迁移旧广场/权限代码,不重构通用审计。名称不代表新 feature/domain owner;release-contract 仍由 F056 认领。

+

4.4 本轮增量字段与接口合同(拟实现)

+

① 拟新增会话 API:挂 F056 路由 /api/v1/app-runtime-audit;读取者必须通过平台 session dependency,不能走 /api/v2。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
接口参数 / 响应校验
GET /records/{kind}kind=model/capability/access;time_range=recent7d/custom/all(默认 recent7d);custom 提供 start_time/end_time;app_id/visitor_id,model 额外 credential_id;tenant_id 只缩小 scope;cursor/limit;响应 {items,next_cursor,snapshot_id,matched_count}custom 范围无 90 天上限;all 对已留存时间不加下界;非法/不适用筛选明确拒绝。统计仅在受权范围,尚未计算可为 null,不用 10001 冒充总量;无因记录数而不可导出的标志
GET /candidates/{kind}app/visitor/credential,按当前 kind/time/scope 的历史事实给 {id,label,deleted_or_revoked} 候选,分页/搜索有界同查询管理员门禁;credential 只给 mask,无私密 name/value;跨租户对象请求拒绝
POST /exports{snapshot_id,request_id} → {export_id,status}条件快照属于同一调用者/当前合法 scope、未过期,复制条件到任务;不因行数/历史跨度拒绝;幂等 request_id
GET /exports/{id}status + safe_reason、attempt、selection_started_at、processed_rows、ready_at/expires_at、ready 后 final_count重验资格与 scope;processed_rows 是当前 attempt 进度而非精确总量,重试从 0 开始,不报告部分成功
GET /exports/{id}/download认证后返回 CSV 流;Cache-Control: no-store不返回公开 object key/URL,失权/跨 scope 拒绝;下载开始与每个受限 chunk 之间重验,取消后停止继续交付
+

列表/候选只读,下载一次不新增访问事实;业务错误遵循现有 envelope,前端使用包裹 request 的 rejected Promise 处理。新增错误码从 163 段核对占用后分配并同步三语,不重用历史退休编号。

+

② 三视图字段:event_time 为查询统一排序别名,原始字段语义不变。

+ + + + + + + + + + + + + + + + + + + + + + + + + +
kind / 来源响应白名单历史缺项
model / ModelCallRecordid、create_time、app_id、actor_kind/id/name、subject_kind/id、credential_id/mask、model_id/name、prompt/completion/total_tokens、result/error_code/http_status、latency/ttft、request_idapp_id 可空(服务账号调用);subject 未知不冒充 owner;Token 空不填 0;无当时 app 名只显示 ID
capability / AppCapabilityCallRecordid、create_time、app_id/app_name、credential_id、subject_kind/subject_user_id、capability、result/error_code、chunk_count、latency_ms、version_idcredential mask 仅经受限凭据投影能找到时补;无 actor 快照只表达 app 这个 actor,不虚构用户
access / AppAccessLogid、entry_time、app_id、app_name_snapshot(新增可空)、user_id/user_name、request_id老行 app_name_snapshot 空显示 ID;访问为 allow 合并事件,不显示为 HTTP 成功率/逐请求响应
+

不输出 model prompt/completion、检索 chunks/正文/targets 原始 payload、secret/env/cookies/OBO/token/任意 metadata。仅为新 access 记录新增 app_name_snapshot(128 字符 nullable)与写入参数,不重写合并窗口;历史不从当前名称回填。模型表已有 actor_name/model_name 快照,能力表已有 app_name;不为方便前端用 mandatory live join 丢掉已删除/撤销记录。

+

③ QuerySnapshot / RuntimeAuditExport(拟新增):仍为两个申请者私有实体,不新增永久审计大表或全量 ID 选集副本。RuntimeAuditQuerySnapshot 保存 UUID、tenant_id(默认 None/NOT NULL/索引)、creator_user_id、scope_mode/scope_tenant_id、kind、normalized_filter JsonType、filter_digest、watermark、created_at/expires_at;只含条件,不含事实正文或完整 ID 集。tenant_id 取服务端确认的申请者有效工作租户,global 是经超管核验的过滤范围,不以 NULL tenant 绕过自动过滤。

+

RuntimeAuditExport 保存同样的申请者/租户/scope、复制的冻结条件和 watermark、snapshot_id、request_id、status、attempt/selection_started_at、lease_holder/epoch/until、processed_rows、final_count、manifest_object_key/digest、ready_at/download_expires_at、safe_failure;唯一 (creator_user_id,request_id)。分块对象包含 part_no/行数/字节数/hash,由最终不可变 manifest 收束;任务进度是可重建状态,不需要另建一张事实记录表。对象 key 全部服务端生成,客户端不能指定。

+

worker 按持久任务 tenant_execution_scope 初始化租户过滤,只有源数据读取在已授权的 global source Repository 中 bypass;后台每批重验申请者当前资格,不能只信任务里旧角色。源只读扫描与任务短写事务分开;领取/重试/ready/GC 以数据库 CAS 和租约处理多进程。未引用条件快照 10 分钟清理,受理后由 job 保留冻结条件,下载到期从 ready 算起;失败/取消且无活跃 lease 的孤儿块可回收,活跃扫描不删。

+

④ Scope / Cursor 安全:

+
    +
  • AuthorizedRuntimeAuditScope 仅由服务端权限适配生成;自然人角色来自可信登录态与权限 application 协议当前核验,不信请求角色/租户。旧审计允许 log-menu 的逻辑不复用到此处。
  • +
  • 普通租户 Repo 在 tenant_execution_scope 下自动过滤;全局超管 Repo 仅在明确授权 scope 下 bypass;客户端 tenant_id 只允许进一步收窄。跨租户 app/visitor/credential ID(含历史 tombstone)在限权解析器中验证,拒绝与未知 ID 对外同样不泄漏详情,不把 foreign ID 默默当“查无”。候选从事实表/可信删除保留信息产生,不能凭当前用户/应用列表扩范围。
  • +
  • cursor 是服务端签名/摘要校验的 {version,kind,scope_digest,filter_digest,snapshot_id,last_event_time,last_id,expires_at},不包含明文凭据。切租户/视图/筛选不能重用;解码/签名/过期错明确拒绝而非回第一页。
  • +
  • 每次 list/candidates/export/status/download 都重验角色/范围;后台任务实际执行时再验提交者资格。无法确认权限 fail closed;不是仅靠 JWT 缓存角色永久允许。已交付字节不可收回,失权后不开始新下载或继续交付后续 chunk;不承诺撤回浏览器已收到的文件。
  • +
+

5. 已知坑 / 反直觉事实

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
坑后果处理位置
旧 AuditLogService 允许 log 菜单/部门管理者直接复用会放大新 runtime 数据权限新 Scope resolver
ModelCallRecordRepository tenant_id=None 是全局 bypass漏传租户变成全局数据泄漏typed scope + 专用普通/全局 Repo 适配
同秒 create_time 常相同,旧 cursor 错误会被忽略翻页重复/漏行/跳回首页严格签名 cursor + time,id 全序
写入有 write-behind,event_time 不等于提交时间列表与随后导出不同读时点不一定同成员;恢复旧游标会混读视图固定条件/watermark,导出单一读视图,崩溃 attempt 从头只读重建,ready 才发布
live app/credential 内连接会丢删除/撤销行历史审计证据消失以事实为主,可选显示投影/未知
SAK 资源 owner 是人,不代表那人的某个 appapp 筛选错误归因其他 key 的行为credential owner 可信 subject_ref 解析
下载公开 MinIO URL 可绕过后来失权撤权后继续新下载认证代理下载/按块重验
CSV 双引号不是公式防护表格软件执行攻击字段类型白名单 + 首有效危险字符处理
HTTP 失败不是空结果用户误以为无人访问/导出成功独立 error/loading/empty 状态
应用 allow 访问按窗口合并错读为 PV/逐次网络请求UI 字段提示与 F054 同语义
+

历史坑编号见 dda16a6 本文件;旧 300/1800 秒勘误不覆盖当前代码配置,本轮不改窗口。

+

6. 对外契约与依赖

+

6.1 我提供给别人的(Outgoing)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
契约消费者风险
现有广场/授权交互与操作审计client/platform、管理员不改变卡片状态与既有日志范围
三运行视图列表/候选/snapshot/export 会话 APILogPage 新第三页签runtime 关闭/失权返回拒绝,不假空
按 app 查 secret 变更/可信凭据事件系统操作日志页审计登记四处 lockstep 与三语必须齐
白名单响应与 CSV schema version=1UI/管理员导出工具字段语义与缺失值约定不可悄改
+

6.2 我依赖别人的(Incoming)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
依赖形式风险
F051 ModelCallRecord、F055 capability record、F054 access logRepository/Python 数据契约写方列名/追加策略/删除留存改变会破坏快照
F048 可信身份/权限 application,F019 admin-scope当前会话+Python原始请求 tenant/admin 标志不可当授权
平台 DB、私有 MinIO、Celery/既有周期清理后台任务/对象不能只存在 writer 本地盘;过期必须回收自有对象
F055 secret 审计与 credential owner 的可信关联元数据不读/存密钥明文;关联不足就未知
现有 LogPage/Tabs 与共享 UI/包裹 request/localesTS不新增库/样式,Select 未 done 时跟随现存筛选实现
+

7. 测试与可观测

+
    +
  • 数据/权限定向:三表正常与空 app_id、null Token/subject;同秒/跨页/晚到写入/删除快照失效;租户 admin/超管 scoped 与 unscoped 允许,owner/log 菜单/部门 admin/审批人/SAK/PAT/foreign filter 拒绝;候选、导出、下载同样配对,角色变化后失败。
  • +
  • 导出定向:超过 10,000 行、超过 90 天和全部已留存时间都完整导出;同一成功读取视图的结果和文件完全一致,后台新增/回填不把扫描变无限流;进度批次/内存有界;重复 request_id 不创建重复任务。源扫描中崩溃从头新 attempt、不拼接混合读视图;ready 提交丢响应不重复追加;旧 epoch 不发布,GC 不删活跃对象。权限在提交/执行/下载/分块间撤销;MinIO/source 失败不交付部分文件;下载 TTL 从 ready 起;CSV 注入/Unicode/引号/换行负向验证。
  • +
  • 前端:保留两个旧页签,新第三页签里三子视图;选择器无空 ID;filter/kind/tenant 改变 reset cursor,query key 含 scope/type/filter,AbortController/请求序号丢弃旧结果;新错误态与空态分离;导出绑定当前 snapshot 并清楚显示范围/行数,失败结束等待、不显示“已下载”。
  • +
  • UI 取当前共享 docs 的 Input/Button/Tabs 等 status=done 组件,表格/筛选沿用当前 LogPage/systemLog 及其选择器模式。filter 在窄屏换行、表格横向滚动,不引入新视觉模式/库;多端、字体/色彩按当前规范,三语同交付,不手改生成的 api_errors。
  • +
  • E2E 走新会话 API + 实际 LogPage 人工清单:授权测试租户 A/B、tenant admin、global super、ordinary owner;产生模型/能力/allow 记录,按同一受权条件查/导,明确显示实际导出时点/最终数;大于 10,000 行和 90 天范围不被硬拒绝;删除测试 app/撤销 key 后仍查历史;验证 secret 审计没值、无 app 的 SA 模型记录不消失。runtime 关闭新面拒绝,旧系统操作仍可查。
  • +
  • 编码后定向命令:cd src/backend && uv run pytest test/app_governance/test_runtime_audit_query.py test/app_governance/test_runtime_audit_export.py(拟新增);只跑 LogPage/runtime 对应 Jest 文件。复用旧 audit 测试中修改路径的最小集合,不全量重跑;必要 Ruff/arch-guard/迁移 head 单独验证。DM8 仅静态。
  • +
  • 指标/日志:query kind、规范筛选摘要、scope、actor、行数、导出状态/过期/失败码;不记原始秘密/URL token/body。查询不伪造模型/能力/访问业务事实。
  • +
+

8. 后续改进 / 不打算做的事

+

不补造历史 app 名/subject/凭据关联;不建立日志仓库或计费模型;只实现当前审计条件下的完整异步导出,以批次/并发/租约控制资源,不设置 90 天或 10,000 行产品门槛。长导出的一致读视图占用和驱动缓冲必须在定向检查中量化,不以本地临时文件绕过多节点。原 ARM/跨节点/正式镜像发行验收仍保留,不宣称新列表测试证明这些能力。

+

修订历史

+ + + + + + + + + + + + + + + + + + + + + + + + + +
日期变化依据
2026-10-23(二次修订)去除导出总量/时间硬限制;条件元数据与后台单一读取视图分离;私有分块 ready 原子发布、重启从头只读重建用户要求修订并复审;规格 AC 未变,尚未编码
2026-10-23刷新已实现广场/操作审计;新增 D12–D15、三视图/稳定导出/权限合同用户确认规格,静态代码核查;未实现/部署
2026-08-17 起早期广场与治理设计旧正文见 dda16a6;历史任务证据不改写
+
\ No newline at end of file diff --git a/docs/reviews/vibe-governance-closeout-design-review-2026-10-23.json b/docs/reviews/vibe-governance-closeout-design-review-2026-10-23.json new file mode 100644 index 0000000000..ba9953b16d --- /dev/null +++ b/docs/reviews/vibe-governance-closeout-design-review-2026-10-23.json @@ -0,0 +1,163 @@ +{ + "user_spec_confirmation": "确认修订规格,更新简化设计", + "approved_spec_sha256": "33f74ab3b2c3eaac67e64099df4b056ff5e9cf5ec78e2e461f8f34266413804d", + "current_spec_sha256": "b58594c6c912d39928482813df0f0de03ea84a6e100d3890019c91ec7f9524b4", + "design_sha256": "92e1ca11a391d971ddc63d36e4cb11c9ba6d070648b9c8e15ecefe3d179dd2ac", + "spec_confirmed": true, + "design_confirmed": true, + "implementation_started": true, + "date": "2026-10-23", + "result": "NEEDS_FIX_OTHER_FEATURES", + "critic_result": "PROCEED", + "scope": "F055 simplified design and required public integration contracts", + "unresolved_high": 1, + "unresolved_medium": 1, + "unresolved_low": 0, + "remaining_other_feature_findings": [ + "F054-B1", + "F056-S1" + ], + "single_config_table": true, + "automatic_restart_on_save": false, + "product_tests_executed": false, + "full_suite_executed": false, + "remote_mutations": false, + "incremental_tasks_created": true, + "designs": { + "F054": { + "path": "features/v3.0.0/054-app-domain-runtime/design.md", + "sha256": "c04c3fdecefde9d78d81c1423290091739b3e5845e4d8ef8a784aba513530d75" + }, + "F055": { + "path": "features/v3.0.0/055-app-publish-pipeline/design.md", + "sha256": "92e1ca11a391d971ddc63d36e4cb11c9ba6d070648b9c8e15ecefe3d179dd2ac" + }, + "F056": { + "path": "features/v3.0.0/056-app-square-governance/design.md", + "sha256": "01cd39b77745e5fcb0a2e1ef2b981ab0cedb990a2aab16118adce412ca4bb4f3" + } + }, + "sdd_checklist": [ + { + "item": 1, + "result": "PASS" + }, + { + "item": 2, + "result": "PASS" + }, + { + "item": 3, + "result": "PASS" + }, + { + "item": 4, + "result": "PASS" + }, + { + "item": 5, + "result": "PASS" + }, + { + "item": 6, + "result": "PASS" + }, + { + "item": 7, + "result": "PASS" + }, + { + "item": 8, + "result": "PASS" + }, + { + "item": 9, + "result": "PASS" + }, + { + "item": 10, + "result": "PASS" + }, + { + "item": 11, + "result": "PASS" + }, + { + "item": 12, + "result": "PASS" + }, + { + "item": 13, + "result": "PASS" + }, + { + "item": 14, + "result": "PASS" + }, + { + "item": 15, + "result": "PASS" + }, + { + "item": 16, + "result": "PASS" + }, + { + "item": 17, + "result": "PASS" + }, + { + "item": 18, + "result": "PASS" + }, + { + "item": 19, + "result": "PASS" + }, + { + "item": 20, + "result": "PASS" + }, + { + "item": 21, + "result": "PASS" + }, + { + "item": 22, + "result": "PASS" + }, + { + "item": 23, + "result": "PASS" + }, + { + "item": 24, + "result": "PASS" + } + ], + "critic_report": "vibe-app-secrets-manual-apply-architecture-final-2026-10-23.md", + "closed_in_design": [ + "own-maintenance-transition", + "ready-to-commit-consumer", + "scoped-lock-order", + "transaction-aware-credential-producers" + ], + "checks": { + "spec_ac_total": 92, + "mapped_incremental_ac": 55, + "markdown_html_same_source": true, + "product_source_unchanged": true, + "visual_print": "not verified", + "local_links": "PASS", + "html_toc": "PASS", + "offline_assets": "PASS", + "sections_decisions": "PASS", + "spec_ac_ids": "PASS: 1-92", + "incremental_traceability_count": 55, + "canonical_design_hashes": "PASS" + }, + "f055_result": "LGTM", + "latest_review": "vibe-app-secrets-manual-apply-design-review-2026-10-23.json", + "design_confirmation": "确认 F055 简化设计", + "approved_design_sha256": "2ef7247c7fcc71d1e2e44b34a9df8dfe865135adc66c249166a29202e29402d6" +} diff --git a/docs/reviews/vibe-governance-closeout-design-review-2026-10-23.md b/docs/reviews/vibe-governance-closeout-design-review-2026-10-23.md new file mode 100644 index 0000000000..b29ec7ccd1 --- /dev/null +++ b/docs/reviews/vibe-governance-closeout-design-review-2026-10-23.md @@ -0,0 +1,99 @@ +# Vibe 治理收尾 · 当前设计评审 + +> **后续状态:用户已确认 F055 简化设计,任务清单已评审,现进入实施。** 下文保留设计评审时的结论与测试计划;没有据此宣称代码或E2E已完成。 + +> **F055 单表/用户确认重建设计:LGTM、独立 PROCEED,待用户设计确认。** 三份设计整体仍有 F054 B1(宿主隔离恢复)和 F056 S1(binding回收审计)未关闭;不能以本次F055结论宣称全部设计已通过。 + +## 1. 当前入口与范围 + +用户已确认 F055 手动生效修订规格,92条AC;保存不重启、差异提示、用户确认后复用部署流程。正式设计已覆盖旧三表自动方案。原规格确认和新规格确认的批准hash分别保留。 + +| Feature | 当前情况 | +|---|---| +| F054 | 用户确认重建/运行快照的消费合同已同步;自身宿主隔离 B1 仍待修、设计未确认 | +| F055 | 单表配置、保存零执行、运行值比较、确认冻结及重建;本轮24项与独立架构复审通过,未编码 | +| F056 | 运行记录查询/导出设计保留,实际binding回收的审计producer S1仍待修、设计未确认 | + +- [本轮 F055 简化设计评审](vibe-app-secrets-manual-apply-design-review-2026-10-23.md) +- [本轮独立复审](vibe-app-secrets-manual-apply-architecture-final-2026-10-23.md) +- [上轮三份设计的问题](vibe-governance-closeout-design-round3-review-2026-10-23.md) +- [此前简化提案(历史,规格已确认)](vibe-app-secrets-simplification-2026-10-23.md) + +## 2. 新旧方案对比 + +| 事项 | 当前正式设计 | +|---|---| +| 数据 | 一张AppSecretConfig,执行/幂等/路由复用RuntimeOperation等既有资产 | +| 保存 | 配置+审计短事务,无manager调用,无自动重启 | +| 生效 | 用户明确确认后重建当前运行版本,保留原镜像/档位/数据 | +| 差异 | 实际运行值与最新保存值比较;未引用/A→B→A不误报,缺上下文unknown | +| 执行衔接 | 既有auto_commit、精确maintenance转换、局部锁图、session-aware凭据producer | +| 保护 | owner会话写、加密存储、预检/预览空值、保留变量与日志保护;部署key轮换仅运维 | + +## 3. 规格增量逐条追溯 + +共55条当前增量条款:原48新增+4修订,另新增AC-90、AC-91、AC-92;本次又修订其中6条。不代表实现/测试通过;F054 AC-66、AC-68、AC-69仍关联B1,F055 AC-81及F056 AC-59的运行凭据回收部分仍关联S1。 + +| Feature / AC | 当前条款 | 设计落点 | +|---|---|---| +| F054 AC-66 | WHERE 使用受支持的 Docker 单机或 legacy systemd 托管形态, THE SYSTEM SHALL 阻止应用绕过受控出口新建到宿主平台服务的连接,包括经应用网络网关和宿主发布地址访问数据库、backend、对象存储及宿主管理端口;应用经受控出口使用已授权的 SDK、模型和附件能力应继续可用,不因附件业务在 backend 而放开该宿主服务的全部接口。 | D12 / §4.4③④ | +| F054 AC-67 | WHEN 宿主访问隔离生效, THE SYSTEM SHALL 保持统一入口的 HTTP、WebSocket、受控探活及其合法返回流量可用,且不得阻断平台其他业务网络;由应用主动建立的禁止连接不能冒充合法返回流量取得放行。 | D12 / §4.4③ | +| F054 AC-68 | WHEN 安装重复执行、运行服务重启或模块卸载, THE SYSTEM SHALL 分别保持隔离规则幂等有效,或只撤销本模块拥有的隔离规则;不得复制出重复放行规则、清空宿主全局策略或改变无关服务规则。 | §4.4③④ / §7 | +| F054 AC-69 | IF 必需的宿主隔离无法应用、无法确认,或已启用的网络地址族未获得同等保护, THEN THE SYSTEM SHALL 明确报告该运行环境隔离不可用并拒绝把它作为可发布环境;不得只告警后放行,亦不得把未知旧写入者的强制删除当作修复隔离的方法。 | §2 / §4.4④ | +| F054 AC-70 | IF 工场运行时已启用且 OBO 签发配置缺失、仅为空白或与平台会话签名配置相同, THEN THE SYSTEM SHALL 对原本有权进入的正式应用访问返回“本环境配置不完整,请联系平台管理员”状态,不转发到应用、不下发可用身份材料,也不留成功访问记录。 | D6 / §4.4① | +| F054 AC-71 | IF 预览访问发生相同 OBO 配置错误, THEN THE SYSTEM SHALL 同样拒绝进入预览;正式与预览入口都须保留既有登录、存在性、跨租户、访问权限及运行层未部署的判定顺序,不能借配置错误暴露调用者本来无权得知的应用信息。 | D6 / §4.4① | +| F054 AC-72 | WHEN backend 启动且工场运行时已启用, THE SYSTEM SHALL 对上述 OBO 配置错误记录不含任何密钥值的错误诊断;运行时层未部署时不制造已启用该层的假象,正常访问仍遵守原未部署提示。 | D6 / §4.3 | +| F054 AC-73 | WHERE OBO 使用正确且独立的签发配置, THE SYSTEM SHALL 保持现有正式/预览授权、身份注入、防伪头、短时凭据、撤权及 SDK/OBO 调用行为,并以允许与拒绝的配对验证证明本次收紧没有引入匿名降级。 | D6 / §6.1 / §7 | +| F055 AC-54 | THE SYSTEM SHALL 使平台模型、知识库的底层账号与连接配置继续只在原管理面维护;owner 可在应用详情页发布 tab 维护本应用调用第三方系统所需的应用密钥,但不能借此修改平台通道、服务身份或执行环境的安全配置。(2026-10-09 GOV-05 决议对 Discovery N4 的修订) | D3 / §4.4⑤ | +| F055 AC-56 | WHEN 能力声明只包含合法的应用密钥引用名, THE SYSTEM SHALL 接受该声明并按本节密钥规则处理;IF 引用尚未录入值, THEN THE SYSTEM SHALL 输出缺值警告而不以“本版不支持密钥引用”拒绝发布;发布产物含实际明文密钥时,原密钥扫描仍必须拦截。(GOV-05 验收 8、验收 10;替代旧 Discovery N4 排除项) | D3 / D5 | +| F055 AC-66 | WHEN 目标应用的自然人 owner 录入、修改或删除应用密钥值, THE SYSTEM SHALL 仅对该 owner 开放相应业务操作;租户管理员、非 owner 平台超管、审批人、普通成员、服务账号及个人访问令牌不得因管理身份、拥有 app:manage 或知道应用标识而取得该写权限。 | §2 / §4.4② | +| F055 AC-67 | WHEN 查看应用详情页发布 tab 的应用密钥区, THE SYSTEM SHALL 向 owner 展示引用名、固定掩码、已录入/缺少值状态及最近修改时间,并区分批准版本、待审批版本和未被当前版本引用的已存值;本租户管理员只能查看引用名和状态,审批人仅在其有权审阅的审批单内查看该版本的引用与缺值提示。 | D20 / §4.4①②⑥ | +| F055 AC-68 | THE SYSTEM SHALL 加密保存应用密钥值;录入后不存在查询明文、导出明文或用界面掩码还原值的能力;应用管理、版本、审批、日志、错误和审计的任何平台输出均不含密钥值、其片段或可复用的访问凭据。 | D17 / D20 / §4.4⑤ | +| F055 AC-69 | WHEN 新增或删除应用版本声明中的引用名, THE SYSTEM SHALL 将其作为能力声明变更随发布审批生效并在审批摘要中展示引用名;录入、替换或删除一个值不会擅自修改任何版本的声明,也不产生发布审批单。 | D6 / D7 / §4.4⑤ | +| F055 AC-70 | WHEN 已批准的正式版本启动、恢复或经用户确认重建, THE SYSTEM SHALL 只把该版本明确声明的引用名注入为同名应用环境变量,使用本次启动操作受理时选定的已保存配置;未声明的值、其他应用的值与其他租户的值不得进入该实例,代码、依赖和运行环境的批准组合保持不变。 | §4.4③ | +| F055 AC-71 | WHEN 执行构建、依赖准备、静态扫描、预检启动或审批期预览, THE SYSTEM SHALL 不注入任何真实应用密钥值;预检启动和审批预览只获得该版本声明的变量名及空值,不能借复用已上线实例或材料取得生产值。(2026-10-23 用户明确确认) | §2 / §4.1 / §4.4③⑤ | +| F055 AC-72 | IF 声明引用缺少已录入值, THEN THE SYSTEM SHALL 在 CLI deploy、发布 tab 和对应审批单显示引用名与缺值状态,且不因缺值本身拒绝提交或生成审批单;仍须执行原启动/探活检查,应用自身因空值无法启动时按原失败流程处理,不跳过真实健康校验。 | D4 / §7 | +| F055 AC-73 | WHEN owner 删除仍被在线版本引用的密钥值, THE SYSTEM SHALL 在确认删除影响后仅清除已保存的值,保留版本引用名,不自动改变或停止当前实例;界面提示运行实例仍可能使用旧值。下一次正式启动或用户确认重建时该引用注入为空,仍可由 owner 补录。 | §4.4②⑥ | +| F055 AC-74 | WHEN owner 新增、修改或删除应用密钥值, THE SYSTEM SHALL 仅保存配置,不自动停止、启动或重建应用实例;在线应用使用的值与最新保存值不一致时显示未生效变更,是否现在重建由 owner 显式确认。用户取消或仅关闭提示时运行实例保持不变。 | D18 / §4.4②⑥ | +| F055 AC-75 | WHEN owner 为已下线、草稿或尚未上线的版本保存值,或修改未被在线版本引用的值, THE SYSTEM SHALL 保持原应用状态且不擅自上线或无关重启;此后按原审批/上线动作启动时使用最新有效值。 | D18 / §4.4③ | +| F055 AC-76 | IF owner 已确认的配置重建失败, THEN THE SYSTEM SHALL 保留已保存配置,明确区分“配置已保存”和“实例重建失败”,按实际运行状态显示未生效或无法确认,并允许遵守原生命周期约束的再次确认重试;不得谎报新配置已生效、自动重试重建、静默恢复旧值或回滚代码/业务数据。 | D18 / §4.4③⑥ | +| F055 AC-77 | WHEN 配置保存并发发生, THE SYSTEM SHALL 明确保存次序或拒绝过期编辑,已拒绝请求不得改变已存值;WHEN 保存与部署、停用、迁移、恢复、删除并发发生, THE SYSTEM SHALL 保持已有执行的配置选择及单写约束,旧执行结果不得覆盖最新保存配置,后保存内容未进入当前实例时保留差异提示;未知执行结果不得自动重复发起数据维护或重建。 | D19 / §4.4①③ | +| F055 AC-78 | IF 引用名无效、重复或企图覆盖平台身份、平台通道、应用启动、语言运行环境及代理隔离所需的保留变量, THEN THE SYSTEM SHALL 在提交声明或保存值时明确拒绝并指出相应字段,不截断或静默替换名称。 | §2 / §4.4⑤ | +| F055 AC-79 | WHEN owner 保存合法值, THE SYSTEM SHALL 保留其 Unicode、换行及特殊字符的原始语义,使应用读取到同一值而不执行其中内容;空字符串不能被标为已录入,超过已公布输入限制时明确拒绝,未编辑的掩码不能被当成新值提交。 | §2 / D20 | +| F055 AC-80 | WHERE 不同应用或租户使用相同引用名, THE SYSTEM SHALL 分别维护各自的值与状态;对不存在或无权限的应用作查询/写入时,须遵守原应用存在性和租户拒绝语义,不通过状态、列表、错误或掩码泄漏另一作用域的信息。 | §4.4①② | +| F055 AC-81 | WHEN 应用密钥值成功录入、修改或删除, THE SYSTEM SHALL 由本业务写入应用、引用名、操作者、动作、时间和结果的审计,不记录值;应用运行期凭据的签发/回收仍由原凭据 Owner 留痕,并须携带受信的应用关联供治理面追溯。 | §4.4① / §6.1(回收审计S1仍待修) | +| F055 AC-82 | THE SYSTEM SHALL 使真实应用密钥值不进入源码压缩包、版本快照、依赖缓存、构建产物、数据库快照、审批内容或应用附件;受限的正式执行交付可获得其必需的值,但普通查询者、预检执行体、代理和材料传输身份不得因此取得密钥管理能力。 | §4.1 / §4.4③⑤ | +| F055 AC-83 | WHEN 应用停用、进程重启、代码升级、移除某版本引用或准备环境缓存被回收, THE SYSTEM SHALL 保留仍在平台存储中的密钥资产而不再向未声明的版本注入;仅 owner 明确删除值或按原 owner 应用删除流程清理相应密钥,历史无值审计仍可追溯。 | §4.4①③ | +| F055 AC-84 | WHEN 查看能力声明和审批摘要, THE SYSTEM SHALL 展示该版本的引用名及对应缺值提示而不展示值;只有有效审批关系、owner 或原声明只读权限允许的访问者可见其授权范围内的信息,不能把审批人的有限审阅权限变成密钥管理权限。 | D7 / §4.4⑥ | +| F055 AC-85 | WHEN owner 通过密钥区保存一个未被正式版本引用的合法名值, THE SYSTEM SHALL 把它保持为未生效的应用私有配置;正式运行只以审批过的声明决定注入范围,不能靠预存一个新名字绕过引用新增的审批。 | D19 / §4.4④ | +| F055 AC-86 | WHERE 使用 Docker 或 K8s 执行后端, THE SYSTEM SHALL 对密钥权限、空值、预检/预览隔离、配置保存、差异提示及用户确认重建提供相同业务行为;实例重建、缓存恢复或执行器切换不得覆盖最新保存配置,也不得把使用旧配置的实例误报为已使用最新值。 | §4.4③ / §7 | +| F055 AC-87 | THE SYSTEM SHALL 保持 SDK 的原有公开能力边界,应用从标准环境读取业务密钥;不新增 SDK 密钥读取/管理入口,不将应用密钥变为可用于平台登录、平台管理或其他应用认证的凭据。 | D13 / §4.4⑤ | +| F055 AC-88 | WHEN 无应用密钥声明的既有应用升级或按原交付模式运行, THE SYSTEM SHALL 保持原发布、访问及依赖行为;声明使用应用密钥但目标执行形态不能兑现安全注入时,应明确拒绝所缺能力而不忽略声明或把真实值降级写入代码。 | D3 / §4.4③ / §8 | +| F055 AC-89 | WHEN 页面保存、缺值加载或实例更新请求失败, THE SYSTEM SHALL 结束相应等待并显示正确失败状态,保留用户尚未提交的本地编辑;不产生成功通知、不回填密钥明文,且不能通过重试误把掩码或过期页面状态覆盖已保存配置。 | D20 / §4.4⑥ | +| F055 AC-90 | WHEN 查看在线应用的密钥配置, THE SYSTEM SHALL 根据当前实际运行实例使用的声明和取值与最新已保存配置判断差异,只展示有差异的引用名及状态,不展示明文;修改未被运行版本引用的值不提示该实例需要重建,值修改后恢复为原值时不再误报该项差异。没有运行实例时显示下次启动使用最新配置,无法确认运行配置时明确显示未知而非已一致。 | D19 / §4.4④ | +| F055 AC-91 | WHEN owner 主动选择使已保存配置生效, THE SYSTEM SHALL 在明确告知影响当前运行实例及可能短暂中断后要求确认;只有确认才重建当前已批准的运行版本,不创建代码版本或审批单、不启动待审批或待运行的新版本、不改变资源档位、不触发表结构迁移或覆盖 SQLite 数据。已停止应用不得因保存或差异提示自行上线,仍须原重新上线动作。 | D18 / §4.4②③⑥ | +| F055 AC-92 | WHEN 配置重建被确认受理, THE SYSTEM SHALL 使用用户确认的当前运行版本及已保存配置;确认后版本或配置已变化时,须明确拒绝过期确认并要求重新核对。操作受理后再保存的配置不被该在途实例自动采用,不影响已受理操作的取值;完成后仍按真实使用配置显示未生效变更,重复确认不得创建重复在途实例。 | §4.4②③④ | +| F056 AC-26 | THE SYSTEM SHALL 使任何审计记录、查询响应与导出内容不含密钥明文等敏感值;平台自签发凭据只显示既有前缀与末四位掩码,owner 录入的第三方应用密钥只显示引用名及无值动作信息,不借“掩码”暴露其字符片段。(NFR-2;应用密钥与 ApiCredential 的不同边界) | D15 / §4.4② | +| F056 AC-34 | THE SYSTEM SHALL 在既有管理后台审计页中以“应用运行记录”tab 承载模型调用、运行期能力调用及应用访问三个子视图,使高频记录可以查询和导出;操作类审计留在原 tab,不把高频事件混入原操作列表,也不另建独立管理页面;未部署工场运行时层时不显示该运行记录 tab。(2026-10-09 GOV-04 方案 A 替代原单列表筛选描述) | D11 / §7 | +| F056 AC-46 | WHEN 有权管理员进入已部署工场运行时层的既有审计页, THE SYSTEM SHALL 提供模型调用、应用能力调用、应用访问三个可独立选择的运行记录子视图,实际请求真实记录;原系统操作与原应用使用记录入口和既有筛选保持可用。 | D11 / D12 / §4.1 | +| F056 AC-47 | WHEN 管理员在任一运行记录子视图筛选, THE SYSTEM SHALL 支持时间范围、应用以及有意义的访问用户条件;模型调用还可按调用密钥筛选,并显示密钥掩码而非明文;无效时间范围、非法分页与跨租户筛选值须明确拒绝,不忽略后返回更宽范围。 | §2 / §4.4①④ | +| F056 AC-48 | WHEN 查询模型调用, THE SYSTEM SHALL 展示时间、调用归属(服务账号或应用)、模型、已记录 token 数及调用结果,并在有当前访问者信息时展示其身份;不以 app_id 非空为前提漏掉合法服务账号调用,也不把未记录 token 数伪造为零或把匿名/未知访问者替换为 owner。 | D12 / §4.4② | +| F056 AC-49 | WHEN 查询应用能力调用, THE SYSTEM SHALL 展示时间、应用、能力、以谁的名义调用、结果及已有的可用耗时/数量信息;调用归属与访问者归属分别展示,不把两种身份混成一个操作者。 | §4.4② | +| F056 AC-50 | WHEN 查询应用访问, THE SYSTEM SHALL 展示访问用户、应用与访问时间,并保持原“一次进入/合并窗口”的记录语义;不能把已有聚合访问记录宣称为每条 HTTP 请求或因查询重新生成访问事件。 | D7 / §4.4② | +| F056 AC-51 | WHEN 在相同筛选范围内分页、刷新或导出运行记录, THE SYSTEM SHALL 对相同时间的记录提供稳定顺序,不因并发新增导致同一次导出重复、漏掉已选历史记录或无限延长;分页与总数均只反映有权查看的筛选结果。 | D13 / §4.4①③④ | +| F056 AC-52 | WHERE 多租户开启, THE SYSTEM SHALL 使租户管理员只能查询本租户;平台超管全局视图可显式选择有权租户,切到目标租户视图后按该目标上下文呈现;前端隐藏筛选不是服务端授权,调用者自报其他租户、应用或密钥标识不能扩大查询或导出范围。 | §2 / §4.4④ | +| F056 AC-53 | IF owner、普通用户、审批人、服务账号或个人访问令牌不具有原治理面规定的管理员身份, THEN THE SYSTEM SHALL 拒绝其运行记录查询、筛选候选与导出;拥有应用使用、管理或审批权限不自动赋予审计访问权。 | §2 / D12 / §4.4④ | +| F056 AC-54 | WHEN 应用、密钥或访问用户已删除、吊销或重命名, THE SYSTEM SHALL 仍允许有权管理员按历史标识追溯已保存记录;优先使用已有名称快照,历史缺字段时显示标识或未知,不从另一租户或新同名对象补造历史身份。 | D10 / §4.4② | +| F056 AC-55 | WHEN 切换记录类别、租户视图或筛选条件, THE SYSTEM SHALL 同步复位不再适用的筛选和分页,不显示前一个租户/类别的过期返回内容;其后的导出必须使用当前明确选中的授权范围与筛选。 | §7 | +| F056 AC-56 | WHEN 管理员导出运行记录, THE SYSTEM SHALL 导出当前筛选条件下受授权的全部记录而非当前页,列含与该子视图对应的追溯信息;导出受理及交付遵守与查询相同的当前角色/租户约束,失权不能继续取得新内容。 | D13 / D14 / §4.4①④ | +| F056 AC-57 | IF 运行记录加载或导出失败, THEN THE SYSTEM SHALL 展示失败并结束等待,不能把失败表示为成功空列表、成功下载或沿用旧租户结果;合法无记录、历史字段缺失和服务失败须能被区分。 | D14 / §7 | +| F056 AC-58 | THE SYSTEM SHALL 使运行记录查询、导出、错误及筛选候选仅包含已获准的审计元数据,不包含密钥值、OBO/会话凭据、应用环境全文、模型请求正文或知识检索正文;密钥只显示既有掩码。 | §4.4② | +| F056 AC-59 | WHEN 按对象应用筛选操作审计, THE SYSTEM SHALL 能找到由 F055 及凭据 Owner 留下的应用密钥变更和应用运行期凭据签发/回收记录;应用关联须来自原业务目标,不能依靠审计查看者传入 app_id 伪造或补写历史事件。 | D15 / §4.3 / §6.1 | +| F056 AC-60 | WHEN 导出包含用户可控名称或其他文本字段的表格文件, THE SYSTEM SHALL 保留可读文本而不把其作为可执行公式;导出不得携带隐藏敏感列,历史空值不得使导出失败或被替换为其他租户内容。 | D14 / §4.4② / §7 | +| F056 AC-61 | WHERE 工场运行时层未部署, THE SYSTEM SHALL 不展示应用运行记录入口并对相应查询/导出遵循原层禁用语义,原操作审计继续可用;本次不新增聚合计费账单、owner 自助审计页或跨租户授权方式。 | D8 / §4.4① / §7 | + +## 4. 证据与阶段 + +本轮只进行设计和源码静态核对,没有产品测试、远端部署或公开发行。当前产品源码与HEAD无未提交差异,设计hash见本轮JSON。源码基线5f2541f4,当前HEAD7b9027101;不能把设计中的新入口当作已有实现。 + +规格已确认,F055设计待单独确认;F054/F056问题另待修,不提前拆任务/勾选。以下三份设计按当前文件全文展示,Mermaid源码保留,HTML流程预览离线可读,不依赖CDN。 diff --git a/docs/reviews/vibe-governance-closeout-design-revision2-review-2026-10-23.html b/docs/reviews/vibe-governance-closeout-design-revision2-review-2026-10-23.html new file mode 100644 index 0000000000..f32bd228de --- /dev/null +++ b/docs/reviews/vibe-governance-closeout-design-revision2-review-2026-10-23.html @@ -0,0 +1,265 @@ +Vibe 治理收尾 · 修订设计复审

Vibe 应用 · 修订后设计评审

LGTM / PROCEED · 部署加密 key 轮换仅运维说明 · 待用户单独确认设计

历史报告;当前结论见 第三轮评审 NEEDS_FIX。

Vibe 治理收尾 · 修订后设计复审

+

结果:LGTM;独立架构审查 PROCEED。五项原问题已关闭,无未解决 high/medium。设计仍待用户单独确认。

+

日期:2026-10-23。代码基线 7b90271017b74f8c43075dd28e40d895462d6783;当前设计以本报告 JSON 的逐文件 SHA-256 为准。本轮只更新设计与评审文档,未开始增量编码或任务拆解,未运行产品测试/部署。

+

1. 用户确认的范围

+

部署加密密钥 runtime_payload_key 的更换仅为运维说明:固定保存、与加密数据备份、不能直接替换;不提供轮换 UI、自动任务、迁移工具或新的 key 版本产品。owner 修改第三方应用密钥并让实例生效,仍属于本轮。真实值只进入已批准 formal 实例,预检/审批预览为空值,保持已确认规格。

+

2. 五项问题闭环

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
原问题修订落点处理结果
R1F056 §2 / D13 / D14 / §4.4已关闭:完整导出不设行数/90 天上限,批次只限制内存;后台一次读取视图,失败 attempt 从头只读重建、ready 后产物固定。
R2F054 D7 / §4.4①已关闭:在原授权顺序之后显示已确认的“本环境配置不完整,请联系平台管理员”;正式/预览同源,不向无权者泄漏配置/对象。
R3F055 §4.4④ / §7已关闭:批准声明 → operation 敏感名 → 原执行值集合;Docker/K8s/legacy/失败与 backend diagnostic 落库都消费;短值、普通名、多行/分块、先脱敏后截断有合同。
R4F055 D17 / §8已关闭:按用户澄清关闭为运维边界:部署 key 固定保存/备份、误改拒绝,不新增轮换功能;不再承诺重加密即可换 key,历史 HMAC 由固定 key 维持。
R5F055 §4.4③ / §7已关闭:选定同一 AsyncSession/UoW 原子提交 route/instance/applied/receipt;响应丢失、serving/completed 重试、部分事实修复与旧 revision 拒绝均明确。
+

3. 复审结果与证据

+

使用 sdd-review design 的 24 项清单复核三份设计(共 72 项),并由未参与修订的独立 architecture-critic agent 只读核对七个架构维度。独立报告原文:architecture-critique.md。它是评审建议,不是用户设计确认;仓库 SDD 仍要求确认后才能拆任务/编码。

+
LGTM
+
+

三份 spec 的正文与已确认版本一致;没有用改写 AC 来消除设计偏差。上一轮 NEEDS_FIX 及独立报告保留历史,不能把本次文档复审当作原问题已经有产品代码修复或 E2E 通过。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#检查F054F055F056设计证据
1目标简明PASSPASSPASS§1
2非目标明确PASSPASSPASS§1 / §8
3本功能约束PASSPASSPASS§2
4Constitution Check 引用与门禁PASSPASSPASS§2;release-contract 新资产登记
5关键决策PASSPASSPASS§3(D 编号保留)
6备选、选定与原因PASSPASSPASS§3;F054 D6/D12、F055 D17–D20、F056 D12–D15
7重新考虑触发条件PASSPASSPASS各决策末句 / §8
8具体证据PASSPASSPASS§4.1 / 下方源码证据表
9主数据流PASSPASSPASS§4.1 图与文字
10字段与 JSON 契约PASSPASSPASS§4.4
11模块做什么 / 不做什么PASSPASSPASS§4.3
12现状与拟实现区分PASSPASSPASS页首 / §4.1
13反直觉事实PASSPASSPASS§5
14坑的后果PASSPASSPASS§5 表
15处理文件或函数PASSPASSPASS§4.3 / §5
16Outgoing HTTP/Python/数据PASSPASSPASS§4.4 / §6.1
17Incoming 服务/配置/二进制PASSPASSPASS§6.2;F055 执行敏感项/固定 key,F056 source streaming、私有对象与 worker
18依赖破坏风险PASSPASSPASS§6.2;同一 UoW 会话、驱动缓冲/固定读视图、旧执行上下文、配置误改拒绝
19测试层次与范围PASSPASSPASS§7;只定向、不全量
20可操作手验入口/命令/身份PASSPASSPASS§7;真实环境尚未执行
21后续边界与不做理由PASSPASSPASS§8
22修订历史PASSPASSPASS末节,保留旧基线引用
23规格一致 / AC 追溯PASSPASSPASS52 条 AC 追溯;R1/R2 不改 spec,R4 按用户运维边界处理
24tasks 偏差认知回写PASSPASSPASS当前基线覆盖历史过期叙述;旧任务状态限定为历史,增量尚未拆任务
+

4. 还需要在实现阶段验证的事项

+

本次审查确认设计合同可进入用户确认,不宣称代码或环境已经验证:

+
    +
  • 同事务回滚、提交后响应丢失、旧 revision、终态清理与 guard 释放的定向配对验证。
  • +
  • 短值/普通名、多行/分块、历史执行值、缺失上下文安全省略,覆盖所有日志/诊断出口。
  • +
  • 大于 10,000 行和 90 天范围完整导出,驱动实际缓冲、只读视图内存/连接成本;失权、崩溃重试和 ready TTL。DM8 仍按已确认边界仅静态核查,不声称 DM8 真测。
  • +
  • INPUT/FORWARD 与 OBO 的已有安全收尾定向 E2E;不重跑不相关全量回归。ARM、完整离线安装、正式镜像发行仍保留在原待办。
  • +
+

5. 文档检查与 SDD 状态

+

Markdown/HTML 同源生成,规格 AC、目录、52 条追溯、同源设计 hash、本地链接与离线资源做静态检查。HTML 含目录、对比表、三份设计全文和离线流程图;页面视觉/打印实测未执行(此前浏览器策略拒绝 file 协议,本轮不绕过)。

+

下一步是用户单独确认修订设计,之后才拆增量任务并实施。旧任务未提前勾选,未新增轮换功能,未做远端或公开发行操作。

+
静态设计评审 · 未编码 / 未部署
\ No newline at end of file diff --git a/docs/reviews/vibe-governance-closeout-design-revision2-review-2026-10-23.json b/docs/reviews/vibe-governance-closeout-design-revision2-review-2026-10-23.json new file mode 100644 index 0000000000..5b46dc073b --- /dev/null +++ b/docs/reviews/vibe-governance-closeout-design-revision2-review-2026-10-23.json @@ -0,0 +1,146 @@ +{ + "result": "LGTM", + "critic_result": "PROCEED", + "unresolved_high": 0, + "unresolved_medium": 0, + "unresolved_low": 0, + "review": { + "kind": "sdd-review design + independent architecture-critic", + "items_per_feature": 24, + "features": 3, + "result": "LGTM", + "critic_result": "PROCEED", + "unresolved_high": 0, + "unresolved_medium": 0, + "unresolved_low": 0 + }, + "date": "2026-10-23", + "source_commit": "7b90271017b74f8c43075dd28e40d895462d6783", + "spec_confirmed": true, + "design_confirmed": false, + "implementation_started": false, + "incremental_tasks_created": false, + "product_tests_executed": false, + "full_suite_executed": false, + "remote_mutations": false, + "features": { + "F054": { + "file": "features/v3.0.0/054-app-domain-runtime/design.md", + "sha256": "e52973922fe19a9ce0abb957bb41efcdd39bf958675fbaf1b818885d02d83629", + "spec_sha256": "1da2a0126379d1e469278ab1212b2ed710d9bbc9abdafa0beda15dddab299be4" + }, + "F055": { + "file": "features/v3.0.0/055-app-publish-pipeline/design.md", + "sha256": "d45bfeab9ee5464bfb5b8d7ad97f701971a70bd8fe6f43f698a41110b0c663ae", + "spec_sha256": "cf8591704198dbd5cd7131c4bd4dfcf964b60aef7e8d2a0c3fc0c2911f35c70c" + }, + "F056": { + "file": "features/v3.0.0/056-app-square-governance/design.md", + "sha256": "01cd39b77745e5fcb0a2e1ef2b981ab0cedb990a2aab16118adce412ca4bb4f3", + "spec_sha256": "ddceb74c25e8a8f0a4ee858c997b32204f9150804d8a73f76ce27b9045d150a1" + } + }, + "closure": [ + { + "id": "R1", + "status": "closed", + "location": "F056 §2 / D13 / D14 / §4.4", + "resolution": "完整导出不设行数/90 天上限,批次只限制内存;后台一次读取视图,失败 attempt 从头只读重建、ready 后产物固定。" + }, + { + "id": "R2", + "status": "closed", + "location": "F054 D7 / §4.4①", + "resolution": "在原授权顺序之后显示已确认的“本环境配置不完整,请联系平台管理员”;正式/预览同源,不向无权者泄漏配置/对象。" + }, + { + "id": "R3", + "status": "closed", + "location": "F055 §4.4④ / §7", + "resolution": "批准声明 → operation 敏感名 → 原执行值集合;Docker/K8s/legacy/失败与 backend diagnostic 落库都消费;短值、普通名、多行/分块、先脱敏后截断有合同。" + }, + { + "id": "R4", + "status": "closed", + "location": "F055 D17 / §8", + "resolution": "按用户澄清关闭为运维边界:部署 key 固定保存/备份、误改拒绝,不新增轮换功能;不再承诺重加密即可换 key,历史 HMAC 由固定 key 维持。" + }, + { + "id": "R5", + "status": "closed", + "location": "F055 §4.4③ / §7", + "resolution": "选定同一 AsyncSession/UoW 原子提交 route/instance/applied/receipt;响应丢失、serving/completed 重试、部分事实修复与旧 revision 拒绝均明确。" + } + ], + "coverage": { + "F054": { + "66": "D12 / §4.4③④", + "67": "D12 / §4.4③", + "68": "§4.4③④ / §7", + "69": "§2 / §4.4④", + "70": "D6 / §4.4①", + "71": "D6 / §4.4①", + "72": "D6 / §4.3", + "73": "D6 / §6.1 / §7" + }, + "F055": { + "54": "D3 / §4.4④", + "56": "D3 / D5", + "66": "§2 / §4.4②", + "67": "D15 / §4.4②⑤", + "68": "D17 / D20 / §4.4①④", + "69": "D6 / D7 / §4.4⑤", + "70": "D18 / §4.4③", + "71": "D3 / D13 / §4.1 / §4.4③", + "72": "D4 / §7", + "73": "§4.4②③⑤", + "74": "D18 / §4.4③", + "75": "§4.4③", + "76": "D19 / §4.4③⑤", + "77": "D19 / §4.4③", + "78": "§2 / §4.4④", + "79": "§2 / D20 / §4.4④", + "80": "§2 / §4.4①②", + "81": "D12 / §4.4① / §4.3", + "82": "D6 / D17 / §4.4③④", + "83": "D10 / §4.4①", + "84": "D7 / D14 / §4.4⑤", + "85": "§4.4②③⑤", + "86": "D18 / D19 / §4.4③", + "87": "D13 / §4.4⑤", + "88": "D3 / §4.4③ / §8", + "89": "D20 / §4.4⑤ / §7" + }, + "F056": { + "26": "D15 / §4.4②", + "34": "D11 / §7", + "46": "D11 / D12 / §4.1", + "47": "§2 / §4.4①④", + "48": "D12 / §4.4②", + "49": "§4.4②", + "50": "D7 / §4.4②", + "51": "D13 / §4.4①③④", + "52": "§2 / §4.4④", + "53": "§2 / D12 / §4.4④", + "54": "D10 / §4.4②", + "55": "§7", + "56": "D13 / D14 / §4.4①④", + "57": "D14 / §7", + "58": "§4.4②", + "59": "D15 / §4.3 / §6.1", + "60": "D14 / §4.4② / §7", + "61": "D8 / §4.4① / §7" + } + }, + "checks": { + "local_links": "PASS", + "spec_unchanged": true, + "markdown_html_same_canonical_designs": true, + "visual_layout": "NOT VERIFIED: file protocol blocked previously; no workaround attempted", + "responsive_print": "static PASS; not visually verified", + "ac_traceability_count": 52, + "design_sections_and_decisions": "PASS; stable section and D numbering", + "html_toc": "PASS", + "offline_assets": "PASS: no external assets/scripts" + } +} diff --git a/docs/reviews/vibe-governance-closeout-design-revision2-review-2026-10-23.md b/docs/reviews/vibe-governance-closeout-design-revision2-review-2026-10-23.md new file mode 100644 index 0000000000..a0f9bf4e31 --- /dev/null +++ b/docs/reviews/vibe-governance-closeout-design-revision2-review-2026-10-23.md @@ -0,0 +1,73 @@ +# Vibe 治理收尾 · 修订后设计复审 + +> **历史结果:本报告记录上一轮 LGTM,当前第三轮已发现新问题,见[最新评审](vibe-governance-closeout-design-round3-review-2026-10-23.md)。** + +**结果:LGTM;独立架构审查 PROCEED。五项原问题已关闭,无未解决 high/medium。设计仍待用户单独确认。** + +日期:2026-10-23。代码基线 `7b90271017b74f8c43075dd28e40d895462d6783`;当前设计以本报告 JSON 的逐文件 SHA-256 为准。本轮只更新设计与评审文档,未开始增量编码或任务拆解,未运行产品测试/部署。 + +## 1. 用户确认的范围 + +部署加密密钥 `runtime_payload_key` 的更换仅为运维说明:固定保存、与加密数据备份、不能直接替换;不提供轮换 UI、自动任务、迁移工具或新的 key 版本产品。owner 修改第三方应用密钥并让实例生效,仍属于本轮。真实值只进入已批准 formal 实例,预检/审批预览为空值,保持已确认规格。 + +## 2. 五项问题闭环 + +| 原问题 | 修订落点 | 处理结果 | +|---|---|---| +| R1 | F056 §2 / D13 / D14 / §4.4 | 已关闭:完整导出不设行数/90 天上限,批次只限制内存;后台一次读取视图,失败 attempt 从头只读重建、ready 后产物固定。 | +| R2 | F054 D7 / §4.4① | 已关闭:在原授权顺序之后显示已确认的“本环境配置不完整,请联系平台管理员”;正式/预览同源,不向无权者泄漏配置/对象。 | +| R3 | F055 §4.4④ / §7 | 已关闭:批准声明 → operation 敏感名 → 原执行值集合;Docker/K8s/legacy/失败与 backend diagnostic 落库都消费;短值、普通名、多行/分块、先脱敏后截断有合同。 | +| R4 | F055 D17 / §8 | 已关闭:按用户澄清关闭为运维边界:部署 key 固定保存/备份、误改拒绝,不新增轮换功能;不再承诺重加密即可换 key,历史 HMAC 由固定 key 维持。 | +| R5 | F055 §4.4③ / §7 | 已关闭:选定同一 AsyncSession/UoW 原子提交 route/instance/applied/receipt;响应丢失、serving/completed 重试、部分事实修复与旧 revision 拒绝均明确。 | + +## 3. 复审结果与证据 + +使用 sdd-review design 的 24 项清单复核三份设计(共 72 项),并由未参与修订的独立 architecture-critic agent 只读核对七个架构维度。独立报告原文:[architecture-critique.md](vibe-governance-closeout-architecture-critique-round2-2026-10-23.md)。它是评审建议,不是用户设计确认;仓库 SDD 仍要求确认后才能拆任务/编码。 + +``` +LGTM +``` + +三份 spec 的正文与已确认版本一致;没有用改写 AC 来消除设计偏差。上一轮 NEEDS_FIX 及独立报告保留历史,不能把本次文档复审当作原问题已经有产品代码修复或 E2E 通过。 + +| # | 检查 | F054 | F055 | F056 | 设计证据 | +|---|---|---|---|---|---| +| 1 | 目标简明 | PASS | PASS | PASS | §1 | +| 2 | 非目标明确 | PASS | PASS | PASS | §1 / §8 | +| 3 | 本功能约束 | PASS | PASS | PASS | §2 | +| 4 | Constitution Check 引用与门禁 | PASS | PASS | PASS | §2;release-contract 新资产登记 | +| 5 | 关键决策 | PASS | PASS | PASS | §3(D 编号保留) | +| 6 | 备选、选定与原因 | PASS | PASS | PASS | §3;F054 D6/D12、F055 D17–D20、F056 D12–D15 | +| 7 | 重新考虑触发条件 | PASS | PASS | PASS | 各决策末句 / §8 | +| 8 | 具体证据 | PASS | PASS | PASS | §4.1 / 下方源码证据表 | +| 9 | 主数据流 | PASS | PASS | PASS | §4.1 图与文字 | +| 10 | 字段与 JSON 契约 | PASS | PASS | PASS | §4.4 | +| 11 | 模块做什么 / 不做什么 | PASS | PASS | PASS | §4.3 | +| 12 | 现状与拟实现区分 | PASS | PASS | PASS | 页首 / §4.1 | +| 13 | 反直觉事实 | PASS | PASS | PASS | §5 | +| 14 | 坑的后果 | PASS | PASS | PASS | §5 表 | +| 15 | 处理文件或函数 | PASS | PASS | PASS | §4.3 / §5 | +| 16 | Outgoing HTTP/Python/数据 | PASS | PASS | PASS | §4.4 / §6.1 | +| 17 | Incoming 服务/配置/二进制 | PASS | PASS | PASS | §6.2;F055 执行敏感项/固定 key,F056 source streaming、私有对象与 worker | +| 18 | 依赖破坏风险 | PASS | PASS | PASS | §6.2;同一 UoW 会话、驱动缓冲/固定读视图、旧执行上下文、配置误改拒绝 | +| 19 | 测试层次与范围 | PASS | PASS | PASS | §7;只定向、不全量 | +| 20 | 可操作手验入口/命令/身份 | PASS | PASS | PASS | §7;真实环境尚未执行 | +| 21 | 后续边界与不做理由 | PASS | PASS | PASS | §8 | +| 22 | 修订历史 | PASS | PASS | PASS | 末节,保留旧基线引用 | +| 23 | 规格一致 / AC 追溯 | PASS | PASS | PASS | 52 条 AC 追溯;R1/R2 不改 spec,R4 按用户运维边界处理 | +| 24 | tasks 偏差认知回写 | PASS | PASS | PASS | 当前基线覆盖历史过期叙述;旧任务状态限定为历史,增量尚未拆任务 | + +## 4. 还需要在实现阶段验证的事项 + +本次审查确认设计合同可进入用户确认,不宣称代码或环境已经验证: + +- 同事务回滚、提交后响应丢失、旧 revision、终态清理与 guard 释放的定向配对验证。 +- 短值/普通名、多行/分块、历史执行值、缺失上下文安全省略,覆盖所有日志/诊断出口。 +- 大于 10,000 行和 90 天范围完整导出,驱动实际缓冲、只读视图内存/连接成本;失权、崩溃重试和 ready TTL。DM8 仍按已确认边界仅静态核查,不声称 DM8 真测。 +- INPUT/FORWARD 与 OBO 的已有安全收尾定向 E2E;不重跑不相关全量回归。ARM、完整离线安装、正式镜像发行仍保留在原待办。 + +## 5. 文档检查与 SDD 状态 + +Markdown/HTML 同源生成,规格 AC、目录、52 条追溯、同源设计 hash、本地链接与离线资源做静态检查。HTML 含目录、对比表、三份设计全文和离线流程图;页面视觉/打印实测未执行(此前浏览器策略拒绝 file 协议,本轮不绕过)。 + +下一步是用户单独确认修订设计,之后才拆增量任务并实施。旧任务未提前勾选,未新增轮换功能,未做远端或公开发行操作。 diff --git a/docs/reviews/vibe-governance-closeout-design-round3-review-2026-10-23.html b/docs/reviews/vibe-governance-closeout-design-round3-review-2026-10-23.html new file mode 100644 index 0000000000..8d39b37464 --- /dev/null +++ b/docs/reviews/vibe-governance-closeout-design-round3-review-2026-10-23.html @@ -0,0 +1,97 @@ +Vibe 第三轮设计评审

Vibe 治理收尾 · 第三轮设计评审

NEEDS_FIX / REVISE · 未进入编码

Vibe 治理收尾 · 第三轮设计评审

+

结果:NEEDS_FIX;独立架构评审 REVISE。发现 1 项阻塞问题、1 项应修正问题。 上一轮五项问题仍已在设计中关闭,本轮继续沿真实调用链发现新的遗漏;此前 LGTM 是历史评审结果,不再代表当前结论。

+

日期:2026-10-23。源码 HEAD 7b90271017b74f8c43075dd28e40d895462d6783,产品代码基线 5f2541f4bc483c24624fede9031c88c4ec6f58f4。本轮只评审并记录发现,没有改写三份设计、规格或产品代码,没有执行测试、故障注入或远端操作。下述失败序列为静态推演,尚非真实环境复现。

+

1. B1:隔离规则丢失后,既有应用没有恢复或隔离路径

+

严重性:high / blocker。关联 F054 AC-66、AC-68、AC-69。

+
    +
  • 设计位置:F054 design.md:151、同文档:155、同文档:161。§4.4 的宿主 timer 是“只验证”,报告只用于新 create/claim/路由提交准入;安装更新中的失败恢复不等于运行期间持续修复。
  • +
  • 失败序列:应用已运行且报告通过 → 本模块 INPUT 跳转因受控故障丢失(模拟防火墙重载)→ 应用继续运行 → verifier 报 failed → 新部署被拒绝,但既有应用仍可直连宿主监听服务。报告失效本身不会关闭数据面路径。
  • +
  • 不断言普通 dockerd 重启必然删除规则,也不把防御恶意宿主 root 作为目标。缺口是已经检测到的隔离故障没有执行消费者。
  • +
  • 修正方向:明确宿主 unit 在规则丢失、支持范围内的服务重启时如何恢复本模块规则、读回并配对验证;恢复不了时,明确本机隔离或停止受影响执行体的责任方、状态及恢复条件。网络隔离不等于 writer 已停止,仍须原停止证据才能交接数据卷;不能强删未知 writer。
  • +
  • 定向验证方向:仅破坏专用环境的本模块 INPUT 跳转,观察已有应用的禁止连接、恢复或隔离、报告状态和重新准入;同时验证入口/egress 允许路径及无关网络。当前未执行。
  • +
+

2. S1:凭据审计补丁没有覆盖实际 binding 回收写方

+

严重性:medium / should-fix。关联 F056 AC-59、F055 AC-81。

+
    +
  • 设计位置:F056 design.md:95、同文档:142。D15 将具体新增落点指向 CredentialService._audit,但运行期 binding 的失效另有写方。
  • +
  • 实际签发入口:app_credential_service.py:186 的 stage() 经 CredentialService 签发候选凭据,再保存 operation binding。
  • +
  • 实际回收入口:runtime_projection_service.py:50 切路由时直接把旧 binding 标成 revoked;同文件:64 在失败候选/停止终结时同样直接改 binding。两条路径均不调用 CredentialService._audit:305。不能把 binding 失效等同于 ApiCredential 行已撤销。
  • +
  • 失败序列:正式 v1 → 发布 v2 或同版本刷新 → 新凭据签发 → 旧 binding 失效 → 按 app 查询操作审计。仅补 _audit.target_app_id 能找到新签发,仍没有此次旧 binding 回收事实;读取侧不能补造该事件。
  • +
  • 修正方向:将真实 binding 状态变化接入凭据 Owner 的公开应用协议,说明可信 app/credential/operation 关联、事务或持久事件交接,以及重试去重。覆盖切换撤旧、候选失败、停用/删除;区分运行资格回收和 ApiCredential 撤销,避免重复记成两次同一事件。不新建并列凭据管理服务。
  • +
  • 定向验证方向:切换撤旧与失败候选回收均能按 app 查到;重复 activate/terminal 不重复留痕;无状态变化不制造回收事件;关联不能来自查看者参数。当前未执行。
  • +
+

3. SDD 清单核对与边界

+

按 sdd-review design 的 24 项检查逐份核对,记录具体未通过项;独立 architecture-critic 按七维另审。未将新增发现反写成规格变更,也未提前拆任务或编码。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
检查项F054F055F056说明
1–10:目标、约束、决策、数据流及字段PASSPASSPASS已区分当前实现与提案
11:模块职责与生产者/消费者PASSPASSISSUE S1缺 binding 回收的真实事件生产者
12–15:现状、已知坑与落点PASSPASSPASS本轮源码核对未改变已有实现声明
16:Outgoing 接口与数据合同ISSUE B1PASSISSUE S1运行期隔离失败无执行消费者;回收事件交接未闭环
17–22:依赖、风险、验证策略及历史PASSPASSPASS验证策略是拟执行内容,非验收通过证据
23:规格一致性ISSUE B1PASSISSUE S1现有合同不足以兑现持续隔离和回收可查承诺
24:tasks 偏差记录一致PASSPASSPASS历史任务明确不代表本轮增量已交付
+

F055 的固定部署 key、owner 值更新、同 UoW 生效及日志敏感项协议本轮未发现新的独立问题。F056 完整导出、固定读视图、失权检查和重试合同保留;不能把“尚未运行”误称为已证明可用。

+

部署加密 key 轮换仍仅为运维说明。规格已确认;设计未确认。依据 AGENTS.md §6、SDD-Guide §3 和 sdd-review,本轮停留在设计评审,不进入任务拆解与编码。独立架构结论是建议,由用户决定下一步。

+

4. 证据与历史

+ +

本轮只做静态文档/代码核对及本地链接、HTML 目录、离线资源检查;未做页面视觉或打印实测,未跑产品测试或全量回归。三份 spec/design 的 SHA-256 及每项检查状态见同名 JSON。

+
\ No newline at end of file diff --git a/docs/reviews/vibe-governance-closeout-design-round3-review-2026-10-23.json b/docs/reviews/vibe-governance-closeout-design-round3-review-2026-10-23.json new file mode 100644 index 0000000000..581470f3f4 --- /dev/null +++ b/docs/reviews/vibe-governance-closeout-design-round3-review-2026-10-23.json @@ -0,0 +1,483 @@ +{ + "result": "NEEDS_FIX", + "critic_result": "REVISE", + "unresolved_high": 1, + "unresolved_medium": 1, + "unresolved_low": 0, + "review": { + "kind": "sdd-review design + independent architecture-critic", + "items_per_feature": 24, + "features": 3, + "result": "NEEDS_FIX", + "critic_result": "REVISE", + "unresolved_high": 1, + "unresolved_medium": 1, + "unresolved_low": 0 + }, + "date": "2026-10-23", + "source_commit": "7b90271017b74f8c43075dd28e40d895462d6783", + "spec_confirmed": true, + "design_confirmed": false, + "implementation_started": false, + "incremental_tasks_created": false, + "product_tests_executed": false, + "full_suite_executed": false, + "remote_mutations": false, + "features": { + "F054": { + "file": "features/v3.0.0/054-app-domain-runtime/design.md", + "sha256": "e52973922fe19a9ce0abb957bb41efcdd39bf958675fbaf1b818885d02d83629", + "spec_sha256": "1da2a0126379d1e469278ab1212b2ed710d9bbc9abdafa0beda15dddab299be4" + }, + "F055": { + "file": "features/v3.0.0/055-app-publish-pipeline/design.md", + "sha256": "d45bfeab9ee5464bfb5b8d7ad97f701971a70bd8fe6f43f698a41110b0c663ae", + "spec_sha256": "cf8591704198dbd5cd7131c4bd4dfcf964b60aef7e8d2a0c3fc0c2911f35c70c" + }, + "F056": { + "file": "features/v3.0.0/056-app-square-governance/design.md", + "sha256": "01cd39b77745e5fcb0a2e1ef2b981ab0cedb990a2aab16118adce412ca4bb4f3", + "spec_sha256": "ddceb74c25e8a8f0a4ee858c997b32204f9150804d8a73f76ce27b9045d150a1" + } + }, + "closure": [ + { + "id": "R1", + "status": "closed", + "location": "F056 §2 / D13 / D14 / §4.4", + "resolution": "完整导出不设行数/90 天上限,批次只限制内存;后台一次读取视图,失败 attempt 从头只读重建、ready 后产物固定。" + }, + { + "id": "R2", + "status": "closed", + "location": "F054 D7 / §4.4①", + "resolution": "在原授权顺序之后显示已确认的“本环境配置不完整,请联系平台管理员”;正式/预览同源,不向无权者泄漏配置/对象。" + }, + { + "id": "R3", + "status": "closed", + "location": "F055 §4.4④ / §7", + "resolution": "批准声明 → operation 敏感名 → 原执行值集合;Docker/K8s/legacy/失败与 backend diagnostic 落库都消费;短值、普通名、多行/分块、先脱敏后截断有合同。" + }, + { + "id": "R4", + "status": "closed", + "location": "F055 D17 / §8", + "resolution": "按用户澄清关闭为运维边界:部署 key 固定保存/备份、误改拒绝,不新增轮换功能;不再承诺重加密即可换 key,历史 HMAC 由固定 key 维持。" + }, + { + "id": "R5", + "status": "closed", + "location": "F055 §4.4③ / §7", + "resolution": "选定同一 AsyncSession/UoW 原子提交 route/instance/applied/receipt;响应丢失、serving/completed 重试、部分事实修复与旧 revision 拒绝均明确。" + } + ], + "coverage": { + "F054": { + "66": "D12 / §4.4③④", + "67": "D12 / §4.4③", + "68": "§4.4③④ / §7", + "69": "§2 / §4.4④", + "70": "D6 / §4.4①", + "71": "D6 / §4.4①", + "72": "D6 / §4.3", + "73": "D6 / §6.1 / §7" + }, + "F055": { + "54": "D3 / §4.4④", + "56": "D3 / D5", + "66": "§2 / §4.4②", + "67": "D15 / §4.4②⑤", + "68": "D17 / D20 / §4.4①④", + "69": "D6 / D7 / §4.4⑤", + "70": "D18 / §4.4③", + "71": "D3 / D13 / §4.1 / §4.4③", + "72": "D4 / §7", + "73": "§4.4②③⑤", + "74": "D18 / §4.4③", + "75": "§4.4③", + "76": "D19 / §4.4③⑤", + "77": "D19 / §4.4③", + "78": "§2 / §4.4④", + "79": "§2 / D20 / §4.4④", + "80": "§2 / §4.4①②", + "81": "D12 / §4.4① / §4.3", + "82": "D6 / D17 / §4.4③④", + "83": "D10 / §4.4①", + "84": "D7 / D14 / §4.4⑤", + "85": "§4.4②③⑤", + "86": "D18 / D19 / §4.4③", + "87": "D13 / §4.4⑤", + "88": "D3 / §4.4③ / §8", + "89": "D20 / §4.4⑤ / §7" + }, + "F056": { + "26": "D15 / §4.4②", + "34": "D11 / §7", + "46": "D11 / D12 / §4.1", + "47": "§2 / §4.4①④", + "48": "D12 / §4.4②", + "49": "§4.4②", + "50": "D7 / §4.4②", + "51": "D13 / §4.4①③④", + "52": "§2 / §4.4④", + "53": "§2 / D12 / §4.4④", + "54": "D10 / §4.4②", + "55": "§7", + "56": "D13 / D14 / §4.4①④", + "57": "D14 / §7", + "58": "§4.4②", + "59": "D15 / §4.3 / §6.1", + "60": "D14 / §4.4② / §7", + "61": "D8 / §4.4① / §7" + } + }, + "checks": { + "local_links": "PASS", + "spec_unchanged": true, + "markdown_html_same_canonical_designs": true, + "visual_layout": "NOT VERIFIED: file protocol blocked previously; no workaround attempted", + "responsive_print": "static PASS; not visually verified", + "ac_traceability_count": 52, + "design_sections_and_decisions": "PASS; stable section and D numbering", + "html_toc": "PASS", + "offline_assets": "PASS: no external assets/scripts", + "design_unchanged_this_round": true, + "current_findings": 2, + "full_suite_executed": false, + "product_tests_executed": false, + "sdd_pass_points": 67, + "sdd_issue_points": 5 + }, + "review_round": 3, + "prior_review": "vibe-governance-closeout-design-revision2-review-2026-10-23.json", + "design_modified_this_round": false, + "findings": [ + { + "id": "B1", + "severity": "high", + "critic_severity": "blocker", + "feature": "F054", + "location": "design.md:151,155,161", + "requirement": [ + "F054 AC-66", + "F054 AC-68", + "F054 AC-69" + ], + "status": "open" + }, + { + "id": "S1", + "severity": "medium", + "critic_severity": "should-fix", + "feature": "F056/F055", + "location": "F056 design.md:95,142; runtime_projection_service.py:50,64", + "requirement": [ + "F056 AC-59", + "F055 AC-81" + ], + "status": "open" + } + ], + "sdd_checklist": { + "F054": [ + { + "item": 1, + "result": "PASS" + }, + { + "item": 2, + "result": "PASS" + }, + { + "item": 3, + "result": "PASS" + }, + { + "item": 4, + "result": "PASS" + }, + { + "item": 5, + "result": "PASS" + }, + { + "item": 6, + "result": "PASS" + }, + { + "item": 7, + "result": "PASS" + }, + { + "item": 8, + "result": "PASS" + }, + { + "item": 9, + "result": "PASS" + }, + { + "item": 10, + "result": "PASS" + }, + { + "item": 11, + "result": "PASS" + }, + { + "item": 12, + "result": "PASS" + }, + { + "item": 13, + "result": "PASS" + }, + { + "item": 14, + "result": "PASS" + }, + { + "item": 15, + "result": "PASS" + }, + { + "item": 16, + "result": "ISSUE", + "finding": "B1" + }, + { + "item": 17, + "result": "PASS" + }, + { + "item": 18, + "result": "PASS" + }, + { + "item": 19, + "result": "PASS" + }, + { + "item": 20, + "result": "PASS" + }, + { + "item": 21, + "result": "PASS" + }, + { + "item": 22, + "result": "PASS" + }, + { + "item": 23, + "result": "ISSUE", + "finding": "B1" + }, + { + "item": 24, + "result": "PASS" + } + ], + "F055": [ + { + "item": 1, + "result": "PASS" + }, + { + "item": 2, + "result": "PASS" + }, + { + "item": 3, + "result": "PASS" + }, + { + "item": 4, + "result": "PASS" + }, + { + "item": 5, + "result": "PASS" + }, + { + "item": 6, + "result": "PASS" + }, + { + "item": 7, + "result": "PASS" + }, + { + "item": 8, + "result": "PASS" + }, + { + "item": 9, + "result": "PASS" + }, + { + "item": 10, + "result": "PASS" + }, + { + "item": 11, + "result": "PASS" + }, + { + "item": 12, + "result": "PASS" + }, + { + "item": 13, + "result": "PASS" + }, + { + "item": 14, + "result": "PASS" + }, + { + "item": 15, + "result": "PASS" + }, + { + "item": 16, + "result": "PASS" + }, + { + "item": 17, + "result": "PASS" + }, + { + "item": 18, + "result": "PASS" + }, + { + "item": 19, + "result": "PASS" + }, + { + "item": 20, + "result": "PASS" + }, + { + "item": 21, + "result": "PASS" + }, + { + "item": 22, + "result": "PASS" + }, + { + "item": 23, + "result": "PASS" + }, + { + "item": 24, + "result": "PASS" + } + ], + "F056": [ + { + "item": 1, + "result": "PASS" + }, + { + "item": 2, + "result": "PASS" + }, + { + "item": 3, + "result": "PASS" + }, + { + "item": 4, + "result": "PASS" + }, + { + "item": 5, + "result": "PASS" + }, + { + "item": 6, + "result": "PASS" + }, + { + "item": 7, + "result": "PASS" + }, + { + "item": 8, + "result": "PASS" + }, + { + "item": 9, + "result": "PASS" + }, + { + "item": 10, + "result": "PASS" + }, + { + "item": 11, + "result": "ISSUE", + "finding": "S1" + }, + { + "item": 12, + "result": "PASS" + }, + { + "item": 13, + "result": "PASS" + }, + { + "item": 14, + "result": "PASS" + }, + { + "item": 15, + "result": "PASS" + }, + { + "item": 16, + "result": "ISSUE", + "finding": "S1" + }, + { + "item": 17, + "result": "PASS" + }, + { + "item": 18, + "result": "PASS" + }, + { + "item": 19, + "result": "PASS" + }, + { + "item": 20, + "result": "PASS" + }, + { + "item": 21, + "result": "PASS" + }, + { + "item": 22, + "result": "PASS" + }, + { + "item": 23, + "result": "ISSUE", + "finding": "S1" + }, + { + "item": 24, + "result": "PASS" + } + ] + } +} diff --git a/docs/reviews/vibe-governance-closeout-design-round3-review-2026-10-23.md b/docs/reviews/vibe-governance-closeout-design-round3-review-2026-10-23.md new file mode 100644 index 0000000000..136116abfa --- /dev/null +++ b/docs/reviews/vibe-governance-closeout-design-round3-review-2026-10-23.md @@ -0,0 +1,53 @@ +# Vibe 治理收尾 · 第三轮设计评审 + +**结果:NEEDS_FIX;独立架构评审 REVISE。发现 1 项阻塞问题、1 项应修正问题。** 上一轮五项问题仍已在设计中关闭,本轮继续沿真实调用链发现新的遗漏;此前 LGTM 是历史评审结果,不再代表当前结论。 + +日期:2026-10-23。源码 HEAD `7b90271017b74f8c43075dd28e40d895462d6783`,产品代码基线 `5f2541f4bc483c24624fede9031c88c4ec6f58f4`。本轮只评审并记录发现,没有改写三份设计、规格或产品代码,没有执行测试、故障注入或远端操作。下述失败序列为静态推演,尚非真实环境复现。 + +## 1. B1:隔离规则丢失后,既有应用没有恢复或隔离路径 + +**严重性:high / blocker。关联 F054 AC-66、AC-68、AC-69。** + +- 设计位置:[F054 design.md:151](../../features/v3.0.0/054-app-domain-runtime/design.md)、[同文档:155](../../features/v3.0.0/054-app-domain-runtime/design.md)、[同文档:161](../../features/v3.0.0/054-app-domain-runtime/design.md)。§4.4 的宿主 timer 是“只验证”,报告只用于新 create/claim/路由提交准入;安装更新中的失败恢复不等于运行期间持续修复。 +- 失败序列:应用已运行且报告通过 → 本模块 INPUT 跳转因受控故障丢失(模拟防火墙重载)→ 应用继续运行 → verifier 报 failed → 新部署被拒绝,但既有应用仍可直连宿主监听服务。报告失效本身不会关闭数据面路径。 +- 不断言普通 dockerd 重启必然删除规则,也不把防御恶意宿主 root 作为目标。缺口是已经检测到的隔离故障没有执行消费者。 +- 修正方向:明确宿主 unit 在规则丢失、支持范围内的服务重启时如何恢复本模块规则、读回并配对验证;恢复不了时,明确本机隔离或停止受影响执行体的责任方、状态及恢复条件。网络隔离不等于 writer 已停止,仍须原停止证据才能交接数据卷;不能强删未知 writer。 +- 定向验证方向:仅破坏专用环境的本模块 INPUT 跳转,观察已有应用的禁止连接、恢复或隔离、报告状态和重新准入;同时验证入口/egress 允许路径及无关网络。当前未执行。 + +## 2. S1:凭据审计补丁没有覆盖实际 binding 回收写方 + +**严重性:medium / should-fix。关联 F056 AC-59、F055 AC-81。** + +- 设计位置:[F056 design.md:95](../../features/v3.0.0/056-app-square-governance/design.md)、[同文档:142](../../features/v3.0.0/056-app-square-governance/design.md)。D15 将具体新增落点指向 `CredentialService._audit`,但运行期 binding 的失效另有写方。 +- 实际签发入口:[app_credential_service.py:186](../../src/backend/bisheng/app_publish/domain/services/app_credential_service.py) 的 `stage()` 经 CredentialService 签发候选凭据,再保存 operation binding。 +- 实际回收入口:[runtime_projection_service.py:50](../../src/backend/bisheng/app_runtime/domain/services/runtime_projection_service.py) 切路由时直接把旧 binding 标成 revoked;[同文件:64](../../src/backend/bisheng/app_runtime/domain/services/runtime_projection_service.py) 在失败候选/停止终结时同样直接改 binding。两条路径均不调用 [CredentialService._audit:305](../../src/backend/bisheng/open_api/domain/services/credential_service.py)。不能把 binding 失效等同于 ApiCredential 行已撤销。 +- 失败序列:正式 v1 → 发布 v2 或同版本刷新 → 新凭据签发 → 旧 binding 失效 → 按 app 查询操作审计。仅补 `_audit.target_app_id` 能找到新签发,仍没有此次旧 binding 回收事实;读取侧不能补造该事件。 +- 修正方向:将真实 binding 状态变化接入凭据 Owner 的公开应用协议,说明可信 app/credential/operation 关联、事务或持久事件交接,以及重试去重。覆盖切换撤旧、候选失败、停用/删除;区分运行资格回收和 ApiCredential 撤销,避免重复记成两次同一事件。不新建并列凭据管理服务。 +- 定向验证方向:切换撤旧与失败候选回收均能按 app 查到;重复 activate/terminal 不重复留痕;无状态变化不制造回收事件;关联不能来自查看者参数。当前未执行。 + +## 3. SDD 清单核对与边界 + +按 sdd-review design 的 24 项检查逐份核对,记录具体未通过项;独立 architecture-critic 按七维另审。未将新增发现反写成规格变更,也未提前拆任务或编码。 + +| 检查项 | F054 | F055 | F056 | 说明 | +|---|---|---|---|---| +| 1–10:目标、约束、决策、数据流及字段 | PASS | PASS | PASS | 已区分当前实现与提案 | +| 11:模块职责与生产者/消费者 | PASS | PASS | ISSUE S1 | 缺 binding 回收的真实事件生产者 | +| 12–15:现状、已知坑与落点 | PASS | PASS | PASS | 本轮源码核对未改变已有实现声明 | +| 16:Outgoing 接口与数据合同 | ISSUE B1 | PASS | ISSUE S1 | 运行期隔离失败无执行消费者;回收事件交接未闭环 | +| 17–22:依赖、风险、验证策略及历史 | PASS | PASS | PASS | 验证策略是拟执行内容,非验收通过证据 | +| 23:规格一致性 | ISSUE B1 | PASS | ISSUE S1 | 现有合同不足以兑现持续隔离和回收可查承诺 | +| 24:tasks 偏差记录一致 | PASS | PASS | PASS | 历史任务明确不代表本轮增量已交付 | + +F055 的固定部署 key、owner 值更新、同 UoW 生效及日志敏感项协议本轮未发现新的独立问题。F056 完整导出、固定读视图、失权检查和重试合同保留;不能把“尚未运行”误称为已证明可用。 + +部署加密 key 轮换仍仅为运维说明。规格已确认;设计未确认。依据 AGENTS.md §6、SDD-Guide §3 和 sdd-review,本轮停留在设计评审,不进入任务拆解与编码。独立架构结论是建议,由用户决定下一步。 + +## 4. 证据与历史 + +- [当前三份设计全文与 AC 映射](vibe-governance-closeout-design-review-2026-10-23.html) +- [本轮独立审查原文](vibe-governance-closeout-architecture-critique-round3-2026-10-23.md) +- [上一轮修订复审(历史)](vibe-governance-closeout-design-revision2-review-2026-10-23.md) +- [上一轮独立审查原文(历史归档)](vibe-governance-closeout-architecture-critique-round2-2026-10-23.md) + +本轮只做静态文档/代码核对及本地链接、HTML 目录、离线资源检查;未做页面视觉或打印实测,未跑产品测试或全量回归。三份 spec/design 的 SHA-256 及每项检查状态见同名 JSON。 diff --git a/docs/reviews/vibe-governance-closeout-discovery-2026-10-23.md b/docs/reviews/vibe-governance-closeout-discovery-2026-10-23.md new file mode 100644 index 0000000000..27687a70b3 --- /dev/null +++ b/docs/reviews/vibe-governance-closeout-discovery-2026-10-23.md @@ -0,0 +1,29 @@ +# Vibe 治理与安全收尾 · Spec Discovery + +日期:2026-10-23;起点 `feat/3.0.0-vibe@5f2541f4bc483c24624fede9031c88c4ec6f58f4`;工作分支 `codex/vibe-governance-closeout`。 + +## 已确认的需求来源 + +用户要求继续完成:应用密钥管理、应用运行记录查询、F054 T098 和 T099,遵循 SDD,仅做必要定向验证,不跑全量测试。GOV-05 应用密钥最小版与 GOV-04 审计页新增 tab 已在 2026-10-09 PRD 决议,本轮沿用,不重复新定产品方向。 + +本轮额外确认:真实应用密钥仅在已批准的正式实例启动时注入;预检和审批预览只得到声明变量名及空值。用户明确选择“采用:预检和审批预览不注入真实值”。应用自身因空值启动失败仍按真实探活失败,不为缺值警告跳过运行检查。 + +## 实际差额 + +- F055:manifest validator 目前拒绝 secret 引用;没有完整 owner 值管理。修订 AC-54、AC-56 与旧 N4 排除项,新增最小版的角色、状态、注入、重启及失败边界。 +- F056:三种记录已经写入;模型调用存在未接线的查询/导出,能力调用只有写入,访问记录有分页 DAO。原 AC-34 单列表描述已落后于既定三子视图决议。 +- F054:legacy firewall 未保护宿主访问路径;OBO 缺失/同 JWT 配置仍可返回无 OBO 的入口材料,而能力总线已有 reader。 + +## 职责与限制 + +沿用三个现有 Feature 的 spec/design/tasks,不另建一套业务规格;应用密钥的业务写入归 F055,访问/运行安全归 F054,审计读面归 F056。CLI、SDK、Docker/K8s 执行、凭据底座为受影响消费者,不另造同义 Owner。外部密钥不是平台自签发凭据,不改变 INV-28 的哈希承诺。 + +平台层附件已由 backend 处理,不能为了旧“附件端口”表述而向应用放开 backend 全部宿主端口。能力调用仍经受控出口,隔离验证必须有成功与拒绝配对。 + +## SDD 阶段 + +Discovery 范围与唯一新增安全边界已确认。当前输出三个 Owner 的增量 spec 及规格自审;尚未进入设计/任务拆解/编码。根据 AGENTS.md §6 和 SDD-Guide §3,spec 需单独确认,然后生成设计并另行确认,才进入编码。 + +## 验证约束 + +后续只执行新增/修复 AC 与直接受影响调用链的配对单测、权限正反例及专用环境的增量 E2E。ARM 暂缓、DM8 静态;本阶段无应用代码修改、无测试或远端部署操作。已有未覆盖的离线/发行任务不会因本轮启动而标为完成。 diff --git a/docs/reviews/vibe-governance-closeout-handoff-2026-10-24.html b/docs/reviews/vibe-governance-closeout-handoff-2026-10-24.html new file mode 100644 index 0000000000..99e7194422 --- /dev/null +++ b/docs/reviews/vibe-governance-closeout-handoff-2026-10-24.html @@ -0,0 +1,234 @@ +Vibe 应用治理收尾提测报告
主功能已编码 · 真实部署 E2E / 最终 L2 待完成

Vibe 应用治理收尾提测报告

+

日期:2026-10-24。分支:codex/vibe-governance-closeout。本轮起点:818a12968。实现提交见本报告的源码版本说明。

+

1. 当前结论

+

F055 环境变量、F054 两项安全收尾、F056 运行记录查询和导出的主功能已编码,进入集中提测。 F055 简化设计及 F054/F056 修订设计均已获用户确认。按用户要求,本轮只做本功能定向验证,没有运行项目全量测试。

+

本报告区分本地验证和真实部署:本轮没有向 109/189 部署这些改动,没有执行宿主防火墙、真实 Docker/K8s 业务 E2E,也未完成最终 SDD L2 或合入。历史主流程验收不能代替本轮增量验收。没有公开推送、创建 tag 或发行镜像。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
功能实现内容本地状态提测重点
F055 环境变量单表加密保存;保存不重启;显示运行配置与最新配置的差异;owner 确认后复用部署流程重建;预检和审批预览不注入真实值前序定向结果见 F055 验证记录保存/确认重建/失败恢复,Docker 与 Pod 实际注入
F054 OBO正式及预览入口检查专用非空签名密钥;保持登录、存在性、租户和权限检查顺序;校验与签发使用同一配置快照;代理不复用允许缓存定向通过无效配置拒绝,正常 HTTP/WebSocket 可用,三语提示
F054 宿主隔离独立 root 宿主 timer 维护规则并执行真实 canary;独立角色签名上报;证明过期/失效阻断新执行和路由提交;manager 保持非 root规则、适配器和报告模拟通过真实 INPUT/FORWARD、iptables/nft、规则漂移和证明 TTL
F056 运行记录审计页新增模型、能力、访问三类记录;管理员当前作用域校验;历史候选、冻结游标、异步完整 CSV、受权下载及取消/清理定向通过真实记录可见、分页完整、权限允许/拒绝配对、导出一致
F056 凭据审计从可信应用主体建立 app 关联;真实 binding 回收与审计同事务;旧实例回收/失败/停用/删除接线,重放不重复记回收本地真实 SQLite 事务通过部署/更新/停用的实际签发及回收事件可查
+

2. 交付位置与调用链

+ +
浏览器 → nginx → app-proxy → backend 入口授权/OBO 校验 → 应用
+应用 → 既有 SDK/平台接口 → 三类事实记录
+管理员 → backend 查询/导出任务 → Celery → 私有 MinIO 分块+manifest
+管理员 → backend 再次校验作用域 → 流式下载 CSV
+
+Docker 宿主 root timer → Engine 检查+自有防火墙+canary
+                       → 独立 HMAC 上报 backend(60 秒证明)
+manager → backend 读取证明 + 实际 Engine descriptor 比对 → 允许新执行
+K8s → 延用 F059 能力证明,不读取 Docker 宿主证明
+
+

查询接口为 /api/v1/app-runtime-audit/records/{kind} 与 /candidates/{dimension};导出为 /exports、状态、取消和 /download。只接受平台自然人会话,服务凭据不获得管理查询权。源记录只返回白名单元数据,不导出 prompt、响应正文、环境变量值、密钥或 OBO token。凭据只显示掩码。

+

3. 已执行的必要验证

+

下列是分批定向结果,跨命令用例存在重叠,不能直接相加为独立总数。原始输出保存在证据目录;机器可读验证文件记录源码和输出摘要。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
范围实际结果证据边界
新查询、导出、凭据审计、报告等收尾批30 passed;后续逐问题补测SQLite/替身资源,不是真实平台部署
OBO 正式及预览48 passed包括无效 key、配置快照、签发和拒绝顺序
代理页面和旧 allow 缓存5 passed;HTTP 缓存回归另 4 passed渲染/MockTransport、loopback fake upstream
manager 隔离、门禁和 legacy 状态9 passed未执行真实防火墙命令
宿主报告与导出租约交接5 passed模拟证明和本地数据库
下载完整字节数1 passed固定 CSV/manifest
审计页两文件 9 passed;候选分页修改后只重跑对应组件 4 passed组件测试,非浏览器业务部署
真实环境脚本4 条 collected未执行,不计通过
Python / 架构 / 打包配置Ruff 通过;逐文件架构守卫无 VIOLATION;单 Alembic head;Compose/角色配置、bash 语法和三语 keys 检查通过settings.py 一条既有 RULE7 warning 保留
前端改动文件 ESLint 通过,无新增类型错误全部类型检查仍被两条既有问题阻塞,见下
+

两条类型基线问题:BuildPage/bench/OrgKbConfig.tsx 缺 @hello-pangea/dnd 声明;test/clientVoiceDraft.test.tsx 参数签名不符。本轮未更改这两个文件,也没有新增豁免。F055 前序审批卡 Jest 用例因缺少 canvas.node 未执行,仍如实保留。

+

L1 七维度阶段结果为 PASS_WITH_NOTES,详见两个 feature 的 closeout-validation.md;任务拆解审查已完成。真实 E2E、最终 /sdd-code-review 和合入门禁仍待完成,不能据此宣称整个 Vibe 全部 AC 通过。

+

HTML 为离线文件,章节、链接和响应式/打印 CSS 已静态检查;浏览器安全策略拒绝 file: 协议,本轮未完成宽屏、窄屏与打印的视觉验收。

+

4. 部署前置条件

+
    +
  1. backend、默认 Celery worker、Beat、app-proxy、runtime-manager 和前端使用本轮源码构建。Compose 中既有公开镜像标签不会自动包含这些代码;本轮没有发行新镜像。
  2. +
  3. 对已有数据库执行 alembic upgrade head,新增 app_access_log.app_name_snapshot 可空列,无历史回填。新查询快照、导出任务、宿主证明表通过项目现有模型发现/create_all 路径创建。升级时确认实际 schema 齐备,不只检查进程健康。
  4. +
  5. backend/worker 使用一致的平台存储和加密配置,私有 bisheng-apps 桶允许创建、读写和按任务前缀清理。导出不依赖 API 主机本地目录;Beat 每 30 秒续办/清理,默认 worker 必须消费相应任务。
  6. +
  7. BISHENG_RUNTIME_AUDIT_SOURCE_TIMEZONE 应匹配现有三类记录的无时区数据库时间约定;未设置时使用进程 TZ,再回落 UTC。查询将请求 UTC 转为源时区,输出转回 UTC,快照冻结源时区;不做旧记录重写。
  8. +
  9. F055 部署加密密钥沿用运维配置,勿在聊天、仓库、日志或测试报告内写真实环境变量值。配置保存不触发重启,只有用户确认应用后进入既有重建流程。
  10. +
  11. Docker 必须先按安装说明启用宿主隔离单元:宿主 Python 包、可用 Engine API、明确的 iptables/nft 后端、IPv4 internal 应用/构建网络、受控 egress、固定 digest 的 Python canary 镜像,以及实际监听的管理探测目标。宿主工具可用宿主 socket;使用受限 API 代理时需明确允许检查和 canary 的 create/exec/start/stop 操作。不要求宿主 Docker CLI,不给 manager root/NET_ADMIN。
  12. +
  13. backend 独立配置 host-isolation 角色 HMAC 密钥和 node descriptor,manager 保留 manager 角色。先生成 descriptor、更新映射,再启动宿主 timer 和 manager。报告有效期 60 秒,每 30 秒真实重探;心跳不能代替宿主证明。每 Engine 一个宿主单元,覆盖该节点应用和构建网络。
  14. +
  15. K8s 不安装 Docker 宿主单元,保留 F059 的集群准入、CNI、PVC、镜像与权限前置条件。
  16. +
+

5. 提测执行顺序

+

优先覆盖核心功能,不进行项目全量回归。所有测试使用专用应用和非生产资源,记录源码 SHA、镜像 digest、部署配置及脱敏请求结果。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
顺序场景通过标准
1109 Docker 宿主安装证明 ready;实际 host/gateway 管理端口拒绝、egress 允许;HTTP 入站回包可用;明确记录采用的防火墙后端
2F055 保存与应用修改后实例 ID 不变,界面显示差异;owner 确认后才重建,运行值更新;失败不错误显示已生效;缺值可见且不泄露值
3正式发布与审批预览正式批准实例注入真实值;未批准预检/预览只得到声明名与空值;正常 HTTP/WebSocket、升级、停用及恢复可用
4三类真实记录经应用 SDK 发起模型与能力调用、经入口访问,查询对应应用/访问者/凭据/时间,空值及历史名称正确
5查询与完整导出全部筛选记录跨页无重复遗漏;异步状态和取消可用;CSV 与冻结选择一致,包含全部行;下载无公开对象 URL
6权限配对当前租户管理员允许;普通用户、跨租户、服务凭据拒绝;超管切换管理范围后不能继续读旧范围任务或下载
7凭据审计发布签发/旧实例回收/停用的事件从可信绑定归属到应用;回收重放不重复,失败事务不留下虚假审计
8安全增量OBO 空/空白/共用会话 key 拒绝,不泄露身份;修复后下一请求恢复。宿主证明过期/规则漂移拒绝新执行及路由,清理入口仍可用
9189 K8s 对应业务复验上述环境变量、发布/预览、三源查询/导出、权限和回收;保持 F059 网络能力约束,记录 Pod/Secret 实际行为
+

真实 API 脚本:test_e2e_vibe_governance_closeout.py。它要求预先发布一个 e2e-f056- 开头的专用应用,并经真实入口/SDK 生成至少一条模型、能力和访问记录。它不负责部署、播种或修改宿主规则,不能作为全部场景的自动覆盖。

+

测试机安全注入 F056_E2E=1、E2E_API_BASE、F056_E2E_APP_ID、F056_E2E_TENANT_ID、F056_E2E_TENANT_ADMIN_TOKEN、F056_E2E_USER_TOKEN、F056_E2E_FOREIGN_TENANT_ID。管理员必须是租户管理员而非全局超管;执行期间专用应用的模型调用记录保持静止,便于核对冻结导出的完整 ID 集。

+
# From src/backend; credentials are already injected by the test environment.
+uv run pytest -q test/e2e/test_e2e_vibe_governance_closeout.py
+
+

脚本只取消自己创建的导出任务;不删除应用、源记录、容器或 PVC。快照与导出产物由到期清理管理。

+

6. 暂缓和已知边界

+
    +
  • 按用户口径,DM8 只核对通用模型/DDL/约束,未实测驱动或流式导出;arm64 暂不测试;189 单节点不证明跨节点恢复。
  • +
  • 真实 nft 版本兼容、INPUT/FORWARD 与 canary 必须在部署验收验证,本地生成规则通过不能替代。
  • +
  • 低概率组合先列待验:超长暂停旧导出 worker 在 GC 后晚到上传、跨节点时钟偏差、历史混合/DST 时区、宿主网络单向分区和组合故障收敛。本轮不继续扩展状态机。
  • +
  • F055 重建进入维护态后失败,使用既有下线/重新上线入口恢复;界面有提示,不新增自动重试/自动重启。
  • +
  • 原 F056 AC-32 涉及的既有系统操作审计导出仍有历史行数上限;本轮批准设计保留旧审计实现,完整异步导出覆盖新增三类运行记录。该历史差异未修复,不标记整个 F056 所有旧 AC 完成。
  • +
  • 尚未完成:真实部署 E2E、剩余任务验收勾选、最终 L2、分支合入及正式镜像发行。
  • +
+

源码版本说明

+

本报告与源码一并本地保存;本轮实现提交为 932fa37e1aa68f04d3e60a09b5af09394863d65f,机器可读验证文件的 source_commit 指向同一源码提交。它固定的是源码版本,不代表镜像已发行或环境已升级。

\ No newline at end of file diff --git a/docs/reviews/vibe-governance-closeout-handoff-2026-10-24.md b/docs/reviews/vibe-governance-closeout-handoff-2026-10-24.md new file mode 100644 index 0000000000..c7104a0c75 --- /dev/null +++ b/docs/reviews/vibe-governance-closeout-handoff-2026-10-24.md @@ -0,0 +1,112 @@ +# Vibe 应用治理收尾提测报告 + +日期:2026-10-24。分支:`codex/vibe-governance-closeout`。本轮起点:`818a12968`。实现提交见本报告的源码版本说明。 + +## 1. 当前结论 + +**F055 环境变量、F054 两项安全收尾、F056 运行记录查询和导出的主功能已编码,进入集中提测。** F055 简化设计及 F054/F056 修订设计均已获用户确认。按用户要求,本轮只做本功能定向验证,没有运行项目全量测试。 + +本报告区分本地验证和真实部署:本轮没有向 109/189 部署这些改动,没有执行宿主防火墙、真实 Docker/K8s 业务 E2E,也未完成最终 SDD L2 或合入。历史主流程验收不能代替本轮增量验收。没有公开推送、创建 tag 或发行镜像。 + +| 功能 | 实现内容 | 本地状态 | 提测重点 | +|---|---|---|---| +| F055 环境变量 | 单表加密保存;保存不重启;显示运行配置与最新配置的差异;owner 确认后复用部署流程重建;预检和审批预览不注入真实值 | 前序定向结果见 F055 验证记录 | 保存/确认重建/失败恢复,Docker 与 Pod 实际注入 | +| F054 OBO | 正式及预览入口检查专用非空签名密钥;保持登录、存在性、租户和权限检查顺序;校验与签发使用同一配置快照;代理不复用允许缓存 | 定向通过 | 无效配置拒绝,正常 HTTP/WebSocket 可用,三语提示 | +| F054 宿主隔离 | 独立 root 宿主 timer 维护规则并执行真实 canary;独立角色签名上报;证明过期/失效阻断新执行和路由提交;manager 保持非 root | 规则、适配器和报告模拟通过 | 真实 INPUT/FORWARD、iptables/nft、规则漂移和证明 TTL | +| F056 运行记录 | 审计页新增模型、能力、访问三类记录;管理员当前作用域校验;历史候选、冻结游标、异步完整 CSV、受权下载及取消/清理 | 定向通过 | 真实记录可见、分页完整、权限允许/拒绝配对、导出一致 | +| F056 凭据审计 | 从可信应用主体建立 app 关联;真实 binding 回收与审计同事务;旧实例回收/失败/停用/删除接线,重放不重复记回收 | 本地真实 SQLite 事务通过 | 部署/更新/停用的实际签发及回收事件可查 | + +## 2. 交付位置与调用链 + +- [F054 设计](../../features/v3.0.0/054-app-domain-runtime/design.md)、[任务](../../features/v3.0.0/054-app-domain-runtime/tasks.md)、[验证与 AC 映射](../../features/v3.0.0/054-app-domain-runtime/closeout-validation.json)。 +- [F055 验证](../../features/v3.0.0/055-app-publish-pipeline/manual-apply-validation.md)、[E2E 清单](../../features/v3.0.0/055-app-publish-pipeline/e2e-checklist.md)。部署加密密钥轮换仅是运维说明,没有新增轮换功能。 +- [F056 设计](../../features/v3.0.0/056-app-square-governance/design.md)、[任务](../../features/v3.0.0/056-app-square-governance/tasks.md)、[验证与 AC 映射](../../features/v3.0.0/056-app-square-governance/closeout-validation.json)。 +- [宿主隔离安装说明](../../deploy/runtime-manager/README.md)、[定向执行证据](./evidence/vibe-governance-closeout-2026-10-24/)。 + +```text +浏览器 → nginx → app-proxy → backend 入口授权/OBO 校验 → 应用 +应用 → 既有 SDK/平台接口 → 三类事实记录 +管理员 → backend 查询/导出任务 → Celery → 私有 MinIO 分块+manifest +管理员 → backend 再次校验作用域 → 流式下载 CSV + +Docker 宿主 root timer → Engine 检查+自有防火墙+canary + → 独立 HMAC 上报 backend(60 秒证明) +manager → backend 读取证明 + 实际 Engine descriptor 比对 → 允许新执行 +K8s → 延用 F059 能力证明,不读取 Docker 宿主证明 +``` + +查询接口为 `/api/v1/app-runtime-audit/records/{kind}` 与 `/candidates/{dimension}`;导出为 `/exports`、状态、取消和 `/download`。只接受平台自然人会话,服务凭据不获得管理查询权。源记录只返回白名单元数据,不导出 prompt、响应正文、环境变量值、密钥或 OBO token。凭据只显示掩码。 + +## 3. 已执行的必要验证 + +下列是分批定向结果,跨命令用例存在重叠,**不能直接相加为独立总数**。原始输出保存在证据目录;机器可读验证文件记录源码和输出摘要。 + +| 范围 | 实际结果 | 证据边界 | +|---|---|---| +| 新查询、导出、凭据审计、报告等收尾批 | 30 passed;后续逐问题补测 | SQLite/替身资源,不是真实平台部署 | +| OBO 正式及预览 | 48 passed | 包括无效 key、配置快照、签发和拒绝顺序 | +| 代理页面和旧 allow 缓存 | 5 passed;HTTP 缓存回归另 4 passed | 渲染/MockTransport、loopback fake upstream | +| manager 隔离、门禁和 legacy 状态 | 9 passed | 未执行真实防火墙命令 | +| 宿主报告与导出租约交接 | 5 passed | 模拟证明和本地数据库 | +| 下载完整字节数 | 1 passed | 固定 CSV/manifest | +| 审计页 | 两文件 9 passed;候选分页修改后只重跑对应组件 4 passed | 组件测试,非浏览器业务部署 | +| 真实环境脚本 | 4 条 collected | **未执行,不计通过** | +| Python / 架构 / 打包配置 | Ruff 通过;逐文件架构守卫无 VIOLATION;单 Alembic head;Compose/角色配置、bash 语法和三语 keys 检查通过 | settings.py 一条既有 RULE7 warning 保留 | +| 前端 | 改动文件 ESLint 通过,无新增类型错误 | 全部类型检查仍被两条既有问题阻塞,见下 | + +两条类型基线问题:`BuildPage/bench/OrgKbConfig.tsx` 缺 `@hello-pangea/dnd` 声明;`test/clientVoiceDraft.test.tsx` 参数签名不符。本轮未更改这两个文件,也没有新增豁免。F055 前序审批卡 Jest 用例因缺少 `canvas.node` 未执行,仍如实保留。 + +L1 七维度阶段结果为 **PASS_WITH_NOTES**,详见两个 feature 的 closeout-validation.md;任务拆解审查已完成。真实 E2E、最终 `/sdd-code-review` 和合入门禁仍待完成,不能据此宣称整个 Vibe 全部 AC 通过。 + +HTML 为离线文件,章节、链接和响应式/打印 CSS 已静态检查;浏览器安全策略拒绝 `file:` 协议,本轮未完成宽屏、窄屏与打印的视觉验收。 + +## 4. 部署前置条件 + +1. backend、默认 Celery worker、Beat、app-proxy、runtime-manager 和前端使用本轮源码构建。Compose 中既有公开镜像标签不会自动包含这些代码;本轮没有发行新镜像。 +2. 对已有数据库执行 `alembic upgrade head`,新增 `app_access_log.app_name_snapshot` 可空列,无历史回填。新查询快照、导出任务、宿主证明表通过项目现有模型发现/create_all 路径创建。升级时确认实际 schema 齐备,不只检查进程健康。 +3. backend/worker 使用一致的平台存储和加密配置,私有 `bisheng-apps` 桶允许创建、读写和按任务前缀清理。导出不依赖 API 主机本地目录;Beat 每 30 秒续办/清理,默认 worker 必须消费相应任务。 +4. `BISHENG_RUNTIME_AUDIT_SOURCE_TIMEZONE` 应匹配现有三类记录的无时区数据库时间约定;未设置时使用进程 `TZ`,再回落 UTC。查询将请求 UTC 转为源时区,输出转回 UTC,快照冻结源时区;不做旧记录重写。 +5. F055 部署加密密钥沿用运维配置,勿在聊天、仓库、日志或测试报告内写真实环境变量值。配置保存不触发重启,只有用户确认应用后进入既有重建流程。 +6. **Docker 必须先按安装说明启用宿主隔离单元**:宿主 Python 包、可用 Engine API、明确的 iptables/nft 后端、IPv4 internal 应用/构建网络、受控 egress、固定 digest 的 Python canary 镜像,以及实际监听的管理探测目标。宿主工具可用宿主 socket;使用受限 API 代理时需明确允许检查和 canary 的 create/exec/start/stop 操作。不要求宿主 Docker CLI,不给 manager root/NET_ADMIN。 +7. backend 独立配置 `host-isolation` 角色 HMAC 密钥和 node descriptor,manager 保留 `manager` 角色。先生成 descriptor、更新映射,再启动宿主 timer 和 manager。报告有效期 60 秒,每 30 秒真实重探;心跳不能代替宿主证明。每 Engine 一个宿主单元,覆盖该节点应用和构建网络。 +8. K8s 不安装 Docker 宿主单元,保留 F059 的集群准入、CNI、PVC、镜像与权限前置条件。 + +## 5. 提测执行顺序 + +优先覆盖核心功能,不进行项目全量回归。所有测试使用专用应用和非生产资源,记录源码 SHA、镜像 digest、部署配置及脱敏请求结果。 + +| 顺序 | 场景 | 通过标准 | +|---|---|---| +| 1 | 109 Docker 宿主安装 | 证明 ready;实际 host/gateway 管理端口拒绝、egress 允许;HTTP 入站回包可用;明确记录采用的防火墙后端 | +| 2 | F055 保存与应用 | 修改后实例 ID 不变,界面显示差异;owner 确认后才重建,运行值更新;失败不错误显示已生效;缺值可见且不泄露值 | +| 3 | 正式发布与审批预览 | 正式批准实例注入真实值;未批准预检/预览只得到声明名与空值;正常 HTTP/WebSocket、升级、停用及恢复可用 | +| 4 | 三类真实记录 | 经应用 SDK 发起模型与能力调用、经入口访问,查询对应应用/访问者/凭据/时间,空值及历史名称正确 | +| 5 | 查询与完整导出 | 全部筛选记录跨页无重复遗漏;异步状态和取消可用;CSV 与冻结选择一致,包含全部行;下载无公开对象 URL | +| 6 | 权限配对 | 当前租户管理员允许;普通用户、跨租户、服务凭据拒绝;超管切换管理范围后不能继续读旧范围任务或下载 | +| 7 | 凭据审计 | 发布签发/旧实例回收/停用的事件从可信绑定归属到应用;回收重放不重复,失败事务不留下虚假审计 | +| 8 | 安全增量 | OBO 空/空白/共用会话 key 拒绝,不泄露身份;修复后下一请求恢复。宿主证明过期/规则漂移拒绝新执行及路由,清理入口仍可用 | +| 9 | 189 K8s 对应业务 | 复验上述环境变量、发布/预览、三源查询/导出、权限和回收;保持 F059 网络能力约束,记录 Pod/Secret 实际行为 | + +真实 API 脚本:[test_e2e_vibe_governance_closeout.py](../../src/backend/test/e2e/test_e2e_vibe_governance_closeout.py)。它要求预先发布一个 `e2e-f056-` 开头的专用应用,并经真实入口/SDK 生成至少一条模型、能力和访问记录。它不负责部署、播种或修改宿主规则,不能作为全部场景的自动覆盖。 + +测试机安全注入 `F056_E2E=1`、`E2E_API_BASE`、`F056_E2E_APP_ID`、`F056_E2E_TENANT_ID`、`F056_E2E_TENANT_ADMIN_TOKEN`、`F056_E2E_USER_TOKEN`、`F056_E2E_FOREIGN_TENANT_ID`。管理员必须是租户管理员而非全局超管;执行期间专用应用的模型调用记录保持静止,便于核对冻结导出的完整 ID 集。 + +```bash +# From src/backend; credentials are already injected by the test environment. +uv run pytest -q test/e2e/test_e2e_vibe_governance_closeout.py +``` + +脚本只取消自己创建的导出任务;不删除应用、源记录、容器或 PVC。快照与导出产物由到期清理管理。 + +## 6. 暂缓和已知边界 + +- 按用户口径,DM8 只核对通用模型/DDL/约束,未实测驱动或流式导出;arm64 暂不测试;189 单节点不证明跨节点恢复。 +- 真实 nft 版本兼容、INPUT/FORWARD 与 canary 必须在部署验收验证,本地生成规则通过不能替代。 +- 低概率组合先列待验:超长暂停旧导出 worker 在 GC 后晚到上传、跨节点时钟偏差、历史混合/DST 时区、宿主网络单向分区和组合故障收敛。本轮不继续扩展状态机。 +- F055 重建进入维护态后失败,使用既有下线/重新上线入口恢复;界面有提示,不新增自动重试/自动重启。 +- 原 F056 AC-32 涉及的**既有系统操作审计导出仍有历史行数上限**;本轮批准设计保留旧审计实现,完整异步导出覆盖新增三类运行记录。该历史差异未修复,不标记整个 F056 所有旧 AC 完成。 +- 尚未完成:真实部署 E2E、剩余任务验收勾选、最终 L2、分支合入及正式镜像发行。 + +## 源码版本说明 + +本报告与源码一并本地保存;本轮实现提交为 `932fa37e1aa68f04d3e60a09b5af09394863d65f`,机器可读验证文件的 `source_commit` 指向同一源码提交。它固定的是源码版本,不代表镜像已发行或环境已升级。 diff --git a/docs/reviews/vibe-governance-closeout-spec-review-2026-10-23.html b/docs/reviews/vibe-governance-closeout-spec-review-2026-10-23.html new file mode 100644 index 0000000000..7e6bed509d --- /dev/null +++ b/docs/reviews/vibe-governance-closeout-spec-review-2026-10-23.html @@ -0,0 +1,261 @@ +Vibe 收尾增量规格评审 · 2026-10-23

Vibe 治理与安全收尾 · 增量规格评审

+

日期:2026-10-23;基线 feat/3.0.0-vibe@5f2541f4bc483c24624fede9031c88c4ec6f58f4;工作分支 codex/vibe-governance-closeout。

+

1. 当前阶段与结论

+

规格已获用户确认(原文:“确认规格,进入设计”);确认内容对应 dda16a6 及 JSON 中 approved_sha256。增量设计已完成自审,正在等待单独设计确认;尚未编码。下文保留规格评审时的范围与依据。

+

用户已要求收尾四项功能,沿用 2026-10-09 的产品决议,并在本轮明确确认:预检和审批预览不注入真实密钥值,只给声明变量名与空值;真实值仅进入已批准的正式运行实例。 对应用自身因空值导致的启动失败,继续按正常探活失败处理。

+

本次只编写需求与验收标准,保留原业务 Owner、原 AC 编号和原章节引用。规格确认后分别更新三份 design 并提交设计评审;依据 AGENTS.md §6 与 SDD-Guide §3,设计仍需单独确认,不能把“继续收尾”或本轮 Discovery 的确认替代两个后续确认点。

+

2. 修订范围

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Owner需求差额本次修订修订后 AC 总数
F054T098 宿主访问隔离;T099 OBO 配置错误时入口拒绝新增 8 条,原 AC 不改号、不改文73
F055应用密钥最小版新增 24 条;改写 AC-54、AC-56,替换旧 N4 的排除项与延期决议89
F056应用运行记录三子视图与导出新增 16 条;改写 AC-26、AC-34,对齐掩码边界和既定方案 A61
+

合计 48 条新增 AC、4 条旧 AC 修订。F057 只移除一个“密钥尚待 PRD-2”的过期示例,仍明确不新增 SDK 密钥读取或管理入口。release-contract 只登记第三方应用密钥归 F055,不新增并列 Owner、不修改现有 INV。

+

旧条款为何需要修订

+
    +
  • F055 AC-54:平台模型/知识库底层配置仍在原管理面;允许 owner 在发布 tab 管理本应用的第三方值,不把它误当平台管理员凭据配置。
  • +
  • F055 AC-56:从“密钥引用即拒绝”改为“合法引用允许、缺值警告、实际明文仍被扫描拦截”。
  • +
  • F056 AC-26:平台自签发凭据保留现有掩码;外部应用密钥的审计只记录引用名和动作,不输出其值或字符片段。
  • +
  • F056 AC-34:从旧单列表筛选描述改为既定的同一审计页新增 tab、三个子视图,操作类列表保持独立。
  • +
+

3. 核心业务边界

+

应用密钥

+
    +
  • 只有目标应用的自然人 owner 可维护值;管理员只有其租户范围的引用名/状态只读,审批人仅能读其有权审阅版本的引用与缺值信息。
  • +
  • 名字集合由批准的版本声明决定,值是应用私有资产。保存值不改代码版本或审批;新增/移除声明引用名仍随发布必审。预存一个名字的值不能绕过发布审批。
  • +
  • 正式启动只注入该批准版本声明的名字与当前有效值;预检/预览为空。缺值本身不阻断发布,也不跳过实际启动健康检查。
  • +
  • 在线使用中的值保存后刷新该应用;已下线应用不因此上线。保存成功但刷新失败必须分别显示,不假称新值已生效、不隐式恢复旧值或覆盖数据库。
  • +
  • 并发、维护中和未知结果有明确成功/冲突边界;保留单写、原操作期限、代码/依赖/运行环境组合与 SQLite 数据。
  • +
  • 值不回显、不进源码/产物/审批/快照/审计;名称不能覆盖平台身份或执行环境的保留配置。平台不承诺自动消除应用代码主动输出自己所持秘密的所有情况。
  • +
+

应用运行记录

+
    +
  • 复用既有管理后台审计页,新增模型调用、能力调用、应用访问三子视图,并提供相同筛选范围的全量导出。
  • +
  • 管理员才可查询;租户管理员限本租户,平台超管全局/目标租户视图遵守现有上下文。角色、筛选候选、分页、导出都不能成为跨租户旁路。
  • +
  • 服务账号调用与应用调用都可追溯;访问者未知和 token 未记录按实际缺失显示,不假造 owner 或零值。
  • +
  • 已删除/吊销对象仍按历史 ID/快照追溯;历史缺少字段不能靠当前同名对象猜测。分页/导出稳定,错误与合法空列表区分。
  • +
  • 原业务写入 Owner 不变;查询面不伪造旧事件、不把高频明细塞入操作审计列表。
  • +
+

两项历史安全待办

+
    +
  • 阻止应用绕过出口直连宿主数据库、backend、对象存储和管理端口,同时保留代理、统一入口、探活及合法回包。附件已通过 backend 处理,不能为旧“附件端口”说明放开整个 backend。
  • +
  • 支持的网络地址族必须均获保护;安装/应用不可确认时不得报告环境隔离可用或放行新部署。规则幂等且不改无关宿主策略。
  • +
  • 正式和预览的 OBO 配置缺失/空白/同 JWT 配置时均拒绝进入,不能下发无 OBO 的匿名降级材料;仍保持原登录、租户、存在性和权限判定顺序。启动诊断不输出值。
  • +
+

4. 十项自审

+

审查对象为此次增量和受其影响的旧范围/旧决议,不能据此声称三个历史 Feature 的全部实现或旧验收通过。依据为当前 PRD 的 GOV-04、GOV-05、NFR-2,F054 的 T098/T099、release-contract 的领域归属及 INV,和 constitution C1–C8。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
当前 checklist 检查项结果依据
1. 产品场景覆盖PASS管理、声明、审批、注入、生效、查询和安全拒绝均有明确 AC
2. 边界/失败路径PASS缺值、自身启动失败、并发、停用、刷新失败、删除对象、失权及查询失败已覆盖
3. 范围边界PASS不做通用 Vault、自动轮换、跨应用共享、SDK 明文读取、聚合账单或 owner 审计页
4. AC 唯一可追溯PASS三份定义数 73/89/61;新增 48,旧编号不变;静态核对已留存
5. 可测试性PASS可观察的允许/拒绝、空值/真实值、状态、审计与数据不变;没有把“友好/合理”当条件
6. EARSPASS全部新增 AC 均使用 THE SYSTEM SHALL 和相应条件
7. 错误码边界PASS不预占新码;原 16230 拒绝旧行为在修订说明中识别,具体落码留设计
8. Owner 边界PASSF055 写密钥、F054 负责入口/生命周期、F056 只读面;其余模块为已有消费者
9. 不变量/铁律PASS外部值不冒充平台自签发凭据;发布必审、私有作用域、隔离/单写、存储与权限边界保持
10. What/How 分离PASS未选数据库表结构、路由、加密算法、刷新编排或防火墙命令;细节进入待写 design
+

自审修正

+

首轮核对修正:审计表的写入方列明确为 F055;旧 AC-26 的平台凭据掩码规则与应用外部值规则分开;新增 owner/运维/管理员用户故事,并给旧 tasks 的状态增加“本次增量未确认”限定。复审无未关闭 high/medium。

+

当前 spec-checklist 的逐项结果:LGTM。 这是同一代理的规格自审,不是独立代理评审、代码审查或运行验证。

+

5. AC 追溯

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
需求规格条款
宿主隔离/部署幂等/失败拒绝F054 AC-66、AC-67、AC-68、AC-69
OBO 正式/预览/启动诊断/正常行为F054 AC-70、AC-71、AC-72、AC-73
密钥角色与元数据/保密F055 AC-66、AC-67、AC-68、AC-80、AC-84
声明审批、正式注入、预检预览空值、缺值F055 AC-56、AC-69、AC-70、AC-71、AC-72、AC-73
生效、停用、失败与并发F055 AC-74、AC-75、AC-76、AC-77、AC-89
输入、审计、资产、未引用值、兼容与 SDKF055 AC-78、AC-79、AC-81、AC-82、AC-83、AC-85、AC-86、AC-87、AC-88
三子视图、筛选与记录语义F056 AC-34、AC-46、AC-47、AC-48、AC-49、AC-50
分页、租户、权限、历史及切换F056 AC-51、AC-52、AC-53、AC-54、AC-55
导出、错误、安全元数据及原审计F056 AC-26、AC-56、AC-57、AC-58、AC-59、AC-60、AC-61
+

6. 后续必要定向验证范围

+

这是设计前的验证范围约束,不是提前生成的开发任务或“已经通过”的测试。

+
    +
  • 后端:密钥 owner/非owner/管理员/跨租户配对;声明/值不同步的正反例;查询与导出的同范围权限及历史数据;OBO 有效/无效配置配对。
  • +
  • 执行层:正式注入与预检/预览空值配对;并发保存/失败刷新不覆盖后写配置;不迁移或覆盖生产 SQLite;隔离允许/拒绝配对。
  • +
  • 前端:发布密钥区的掩码/缺值/失败/确认;三子视图筛选/空态/失败/导出;跨租户视图切换不保留陈旧结果。只测新增和受影响路径。
  • +
  • 实际环境:在既有专用 Docker/K8s 环境增量验证新行为,不重复原完整安装/依赖/故障矩阵;必要检查先只读盘点。ARM 暂缓,DM8 静态。
  • +
+

当前阶段仅文档与静态编号检查,没有执行产品测试、SSH、Docker/K8s 修改、公开推送或镜像发行。

+

7. 供确认的文件

+ +

确认本轮增量 spec 后,将继续设计与设计自审并提交单独确认;不会重复要求已经确认的 Discovery 边界。

+
+

附录:48 条新增规格全文

+

F054 新增验收标准

+
    +
  • AC-66 — WHERE 使用受支持的 Docker 单机或 legacy systemd 托管形态, THE SYSTEM SHALL 阻止应用绕过受控出口新建到宿主平台服务的连接,包括经应用网络网关和宿主发布地址访问数据库、backend、对象存储及宿主管理端口;应用经受控出口使用已授权的 SDK、模型和附件能力应继续可用,不因附件业务在 backend 而放开该宿主服务的全部接口。
  • +
  • AC-67 — WHEN 宿主访问隔离生效, THE SYSTEM SHALL 保持统一入口的 HTTP、WebSocket、受控探活及其合法返回流量可用,且不得阻断平台其他业务网络;由应用主动建立的禁止连接不能冒充合法返回流量取得放行。
  • +
  • AC-68 — WHEN 安装重复执行、运行服务重启或模块卸载, THE SYSTEM SHALL 分别保持隔离规则幂等有效,或只撤销本模块拥有的隔离规则;不得复制出重复放行规则、清空宿主全局策略或改变无关服务规则。
  • +
  • AC-69 — IF 必需的宿主隔离无法应用、无法确认,或已启用的网络地址族未获得同等保护, THEN THE SYSTEM SHALL 明确报告该运行环境隔离不可用并拒绝把它作为可发布环境;不得只告警后放行,亦不得把未知旧写入者的强制删除当作修复隔离的方法。
  • +
  • AC-70 — IF 工场运行时已启用且 OBO 签发配置缺失、仅为空白或与平台会话签名配置相同, THEN THE SYSTEM SHALL 对原本有权进入的正式应用访问返回“本环境配置不完整,请联系平台管理员”状态,不转发到应用、不下发可用身份材料,也不留成功访问记录。
  • +
  • AC-71 — IF 预览访问发生相同 OBO 配置错误, THEN THE SYSTEM SHALL 同样拒绝进入预览;正式与预览入口都须保留既有登录、存在性、跨租户、访问权限及运行层未部署的判定顺序,不能借配置错误暴露调用者本来无权得知的应用信息。
  • +
  • AC-72 — WHEN backend 启动且工场运行时已启用, THE SYSTEM SHALL 对上述 OBO 配置错误记录不含任何密钥值的错误诊断;运行时层未部署时不制造已启用该层的假象,正常访问仍遵守原未部署提示。
  • +
  • AC-73 — WHERE OBO 使用正确且独立的签发配置, THE SYSTEM SHALL 保持现有正式/预览授权、身份注入、防伪头、短时凭据、撤权及 SDK/OBO 调用行为,并以允许与拒绝的配对验证证明本次收紧没有引入匿名降级。
  • +
+

F055 新增验收标准

+
    +
  • AC-66 — WHEN 目标应用的自然人 owner 录入、修改或删除应用密钥值, THE SYSTEM SHALL 仅对该 owner 开放相应业务操作;租户管理员、非 owner 平台超管、审批人、普通成员、服务账号及个人访问令牌不得因管理身份、拥有 app:manage 或知道应用标识而取得该写权限。
  • +
  • AC-67 — WHEN 查看应用详情页发布 tab 的应用密钥区, THE SYSTEM SHALL 向 owner 展示引用名、固定掩码、已录入/缺少值状态及最近修改时间,并区分批准版本、待审批版本和未被当前版本引用的已存值;本租户管理员只能查看引用名和状态,审批人仅在其有权审阅的审批单内查看该版本的引用与缺值提示。
  • +
  • AC-68 — THE SYSTEM SHALL 加密保存应用密钥值;录入后不存在查询明文、导出明文或用界面掩码还原值的能力;应用管理、版本、审批、日志、错误和审计的任何平台输出均不含密钥值、其片段或可复用的访问凭据。
  • +
  • AC-69 — WHEN 新增或删除应用版本声明中的引用名, THE SYSTEM SHALL 将其作为能力声明变更随发布审批生效并在审批摘要中展示引用名;录入、替换或删除一个值不会擅自修改任何版本的声明,也不产生发布审批单。
  • +
  • AC-70 — WHEN 已批准的正式版本启动、恢复或重建, THE SYSTEM SHALL 只把该版本明确声明的引用名注入为同名应用环境变量,使用当时已成功保存的值;未声明的值、其他应用的值与其他租户的值不得进入该实例,代码、依赖和运行环境的批准组合保持不变。
  • +
  • AC-71 — WHEN 执行构建、依赖准备、静态扫描、预检启动或审批期预览, THE SYSTEM SHALL 不注入任何真实应用密钥值;预检启动和审批预览只获得该版本声明的变量名及空值,不能借复用已上线实例或材料取得生产值。(2026-10-23 用户明确确认)
  • +
  • AC-72 — IF 声明引用缺少已录入值, THEN THE SYSTEM SHALL 在 CLI deploy、发布 tab 和对应审批单显示引用名与缺值状态,且不因缺值本身拒绝提交或生成审批单;仍须执行原启动/探活检查,应用自身因空值无法启动时按原失败流程处理,不跳过真实健康校验。
  • +
  • AC-73 — WHEN 缺值的正式引用被启动或 owner 经影响确认删除仍被在线版本引用的值, THE SYSTEM SHALL 使该变量为空;删除值不等于删除版本引用名,界面仍可显示缺少值并允许 owner 补录,删除必须清楚提示对在线应用的影响。
  • +
  • AC-74 — WHEN owner 保存在线正式版本正在使用的密钥值变更, THE SYSTEM SHALL 保存后刷新该应用实例使最新值生效,不创建新代码版本或审批单,不触发表结构迁移或覆盖 SQLite 数据,不改变其他应用的实例或密钥。
  • +
  • AC-75 — WHEN owner 为已下线、草稿或尚未上线的版本保存值,或修改未被在线版本引用的值, THE SYSTEM SHALL 保持原应用状态且不擅自上线或无关重启;此后按原审批/上线动作启动时使用最新有效值。
  • +
  • AC-76 — IF 值已保存但使其生效的实例更新失败, THEN THE SYSTEM SHALL 明确区分“值已保存”和“应用更新失败”,保持可诊断的执行结果并提供符合原生命周期约束的重试;不得谎报已生效、静默恢复旧值或回滚代码/业务数据。
  • +
  • AC-77 — WHEN 密钥保存并发发生,或与发布切换、停用、迁移、恢复、删除发生冲突, THE SYSTEM SHALL 明确成功顺序或拒绝冲突请求;已拒绝请求不得改变旧值,旧执行不得覆盖更新成功的配置,未知执行结果不得自动重复发起数据维护或绕过单写保护。
  • +
  • AC-78 — IF 引用名无效、重复或企图覆盖平台身份、平台通道、应用启动、语言运行环境及代理隔离所需的保留变量, THEN THE SYSTEM SHALL 在提交声明或保存值时明确拒绝并指出相应字段,不截断或静默替换名称。
  • +
  • AC-79 — WHEN owner 保存合法值, THE SYSTEM SHALL 保留其 Unicode、换行及特殊字符的原始语义,使应用读取到同一值而不执行其中内容;空字符串不能被标为已录入,超过已公布输入限制时明确拒绝,未编辑的掩码不能被当成新值提交。
  • +
  • AC-80 — WHERE 不同应用或租户使用相同引用名, THE SYSTEM SHALL 分别维护各自的值与状态;对不存在或无权限的应用作查询/写入时,须遵守原应用存在性和租户拒绝语义,不通过状态、列表、错误或掩码泄漏另一作用域的信息。
  • +
  • AC-81 — WHEN 应用密钥值成功录入、修改或删除, THE SYSTEM SHALL 由本业务写入应用、引用名、操作者、动作、时间和结果的审计,不记录值;应用运行期凭据的签发/回收仍由原凭据 Owner 留痕,并须携带受信的应用关联供治理面追溯。
  • +
  • AC-82 — THE SYSTEM SHALL 使真实应用密钥值不进入源码压缩包、版本快照、依赖缓存、构建产物、数据库快照、审批内容或应用附件;受限的正式执行交付可获得其必需的值,但普通查询者、预检执行体、代理和材料传输身份不得因此取得密钥管理能力。
  • +
  • AC-83 — WHEN 应用停用、进程重启、代码升级、移除某版本引用或准备环境缓存被回收, THE SYSTEM SHALL 保留仍在平台存储中的密钥资产而不再向未声明的版本注入;仅 owner 明确删除值或按原 owner 应用删除流程清理相应密钥,历史无值审计仍可追溯。
  • +
  • AC-84 — WHEN 查看能力声明和审批摘要, THE SYSTEM SHALL 展示该版本的引用名及对应缺值提示而不展示值;只有有效审批关系、owner 或原声明只读权限允许的访问者可见其授权范围内的信息,不能把审批人的有限审阅权限变成密钥管理权限。
  • +
  • AC-85 — WHEN owner 通过密钥区保存一个未被正式版本引用的合法名值, THE SYSTEM SHALL 把它保持为未生效的应用私有配置;正式运行只以审批过的声明决定注入范围,不能靠预存一个新名字绕过引用新增的审批。
  • +
  • AC-86 — WHERE 使用 Docker 或 K8s 执行后端, THE SYSTEM SHALL 对密钥权限、空值、预检/预览隔离、正式启动及保存后生效提供相同业务行为;实例重建、缓存恢复或执行器切换不得使旧值覆盖最新成功保存值。
  • +
  • AC-87 — THE SYSTEM SHALL 保持 SDK 的原有公开能力边界,应用从标准环境读取业务密钥;不新增 SDK 密钥读取/管理入口,不将应用密钥变为可用于平台登录、平台管理或其他应用认证的凭据。
  • +
  • AC-88 — WHEN 无应用密钥声明的既有应用升级或按原交付模式运行, THE SYSTEM SHALL 保持原发布、访问及依赖行为;声明使用应用密钥但目标执行形态不能兑现安全注入时,应明确拒绝所缺能力而不忽略声明或把真实值降级写入代码。
  • +
  • AC-89 — WHEN 页面保存、缺值加载或实例更新请求失败, THE SYSTEM SHALL 结束相应等待并显示正确失败状态,保留用户尚未提交的本地编辑;不产生成功通知、不回填密钥明文,且不能通过重试误把掩码或过期页面状态覆盖已保存配置。
  • +
+

F056 新增验收标准

+
    +
  • AC-46 — WHEN 有权管理员进入已部署工场运行时层的既有审计页, THE SYSTEM SHALL 提供模型调用、应用能力调用、应用访问三个可独立选择的运行记录子视图,实际请求真实记录;原系统操作与原应用使用记录入口和既有筛选保持可用。
  • +
  • AC-47 — WHEN 管理员在任一运行记录子视图筛选, THE SYSTEM SHALL 支持时间范围、应用以及有意义的访问用户条件;模型调用还可按调用密钥筛选,并显示密钥掩码而非明文;无效时间范围、非法分页与跨租户筛选值须明确拒绝,不忽略后返回更宽范围。
  • +
  • AC-48 — WHEN 查询模型调用, THE SYSTEM SHALL 展示时间、调用归属(服务账号或应用)、模型、已记录 token 数及调用结果,并在有当前访问者信息时展示其身份;不以 app_id 非空为前提漏掉合法服务账号调用,也不把未记录 token 数伪造为零或把匿名/未知访问者替换为 owner。
  • +
  • AC-49 — WHEN 查询应用能力调用, THE SYSTEM SHALL 展示时间、应用、能力、以谁的名义调用、结果及已有的可用耗时/数量信息;调用归属与访问者归属分别展示,不把两种身份混成一个操作者。
  • +
  • AC-50 — WHEN 查询应用访问, THE SYSTEM SHALL 展示访问用户、应用与访问时间,并保持原“一次进入/合并窗口”的记录语义;不能把已有聚合访问记录宣称为每条 HTTP 请求或因查询重新生成访问事件。
  • +
  • AC-51 — WHEN 在相同筛选范围内分页、刷新或导出运行记录, THE SYSTEM SHALL 对相同时间的记录提供稳定顺序,不因并发新增导致同一次导出重复、漏掉已选历史记录或无限延长;分页与总数均只反映有权查看的筛选结果。
  • +
  • AC-52 — WHERE 多租户开启, THE SYSTEM SHALL 使租户管理员只能查询本租户;平台超管全局视图可显式选择有权租户,切到目标租户视图后按该目标上下文呈现;前端隐藏筛选不是服务端授权,调用者自报其他租户、应用或密钥标识不能扩大查询或导出范围。
  • +
  • AC-53 — IF owner、普通用户、审批人、服务账号或个人访问令牌不具有原治理面规定的管理员身份, THEN THE SYSTEM SHALL 拒绝其运行记录查询、筛选候选与导出;拥有应用使用、管理或审批权限不自动赋予审计访问权。
  • +
  • AC-54 — WHEN 应用、密钥或访问用户已删除、吊销或重命名, THE SYSTEM SHALL 仍允许有权管理员按历史标识追溯已保存记录;优先使用已有名称快照,历史缺字段时显示标识或未知,不从另一租户或新同名对象补造历史身份。
  • +
  • AC-55 — WHEN 切换记录类别、租户视图或筛选条件, THE SYSTEM SHALL 同步复位不再适用的筛选和分页,不显示前一个租户/类别的过期返回内容;其后的导出必须使用当前明确选中的授权范围与筛选。
  • +
  • AC-56 — WHEN 管理员导出运行记录, THE SYSTEM SHALL 导出当前筛选条件下受授权的全部记录而非当前页,列含与该子视图对应的追溯信息;导出受理及交付遵守与查询相同的当前角色/租户约束,失权不能继续取得新内容。
  • +
  • AC-57 — IF 运行记录加载或导出失败, THEN THE SYSTEM SHALL 展示失败并结束等待,不能把失败表示为成功空列表、成功下载或沿用旧租户结果;合法无记录、历史字段缺失和服务失败须能被区分。
  • +
  • AC-58 — THE SYSTEM SHALL 使运行记录查询、导出、错误及筛选候选仅包含已获准的审计元数据,不包含密钥值、OBO/会话凭据、应用环境全文、模型请求正文或知识检索正文;密钥只显示既有掩码。
  • +
  • AC-59 — WHEN 按对象应用筛选操作审计, THE SYSTEM SHALL 能找到由 F055 及凭据 Owner 留下的应用密钥变更和应用运行期凭据签发/回收记录;应用关联须来自原业务目标,不能依靠审计查看者传入 app_id 伪造或补写历史事件。
  • +
  • AC-60 — WHEN 导出包含用户可控名称或其他文本字段的表格文件, THE SYSTEM SHALL 保留可读文本而不把其作为可执行公式;导出不得携带隐藏敏感列,历史空值不得使导出失败或被替换为其他租户内容。
  • +
  • AC-61 — WHERE 工场运行时层未部署, THE SYSTEM SHALL 不展示应用运行记录入口并对相应查询/导出遵循原层禁用语义,原操作审计继续可用;本次不新增聚合计费账单、owner 自助审计页或跨租户授权方式。
  • +
+

进入增量设计评审(另需确认设计)

\ No newline at end of file diff --git a/docs/reviews/vibe-governance-closeout-spec-review-2026-10-23.json b/docs/reviews/vibe-governance-closeout-spec-review-2026-10-23.json new file mode 100644 index 0000000000..20472dcdfa --- /dev/null +++ b/docs/reviews/vibe-governance-closeout-spec-review-2026-10-23.json @@ -0,0 +1,62 @@ +{ + "date": "2026-10-23", + "baseline": "5f2541f4bc483c24624fede9031c88c4ec6f58f4", + "branch": "codex/vibe-governance-closeout", + "discovery_confirmed": true, + "preview_secret_values": "empty; explicitly confirmed by user", + "spec_confirmed": true, + "design_started": true, + "implementation_started": false, + "new_ac_count": 48, + "changed_ac_count": 4, + "features": { + "F054": { + "file": "features/v3.0.0/054-app-domain-runtime/spec.md", + "new_ac_count": 8, + "total_ac_count": 73, + "modified_ac_ids": [], + "sha256": "d74400e3e8ae6055b558aaa32710acd1dd09d4e6ebfa99b254b4fbd4c32389e9", + "spec_user_confirmed": true, + "approved_sha256": "d74400e3e8ae6055b558aaa32710acd1dd09d4e6ebfa99b254b4fbd4c32389e9", + "current_sha256_after_status_update": "1da2a0126379d1e469278ab1212b2ed710d9bbc9abdafa0beda15dddab299be4" + }, + "F055": { + "file": "features/v3.0.0/055-app-publish-pipeline/spec.md", + "new_ac_count": 24, + "total_ac_count": 89, + "modified_ac_ids": [ + "AC-54", + "AC-56" + ], + "sha256": "46d8a527db7828428594cebaf7a67b330cbf07feae8a7acf2a3c7bd88684cdfc", + "spec_user_confirmed": true, + "approved_sha256": "46d8a527db7828428594cebaf7a67b330cbf07feae8a7acf2a3c7bd88684cdfc", + "current_sha256_after_status_update": "cf8591704198dbd5cd7131c4bd4dfcf964b60aef7e8d2a0c3fc0c2911f35c70c" + }, + "F056": { + "file": "features/v3.0.0/056-app-square-governance/spec.md", + "new_ac_count": 16, + "total_ac_count": 61, + "modified_ac_ids": [ + "AC-26", + "AC-34" + ], + "sha256": "fc7555d057b0a3dd63da65a826eb8f95d380f6ce71be33ebc7dd42618c0690af", + "spec_user_confirmed": true, + "approved_sha256": "fc7555d057b0a3dd63da65a826eb8f95d380f6ce71be33ebc7dd42618c0690af", + "current_sha256_after_status_update": "ddceb74c25e8a8f0a4ee858c997b32204f9150804d8a73f76ce27b9045d150a1" + } + }, + "review": { + "kind": "same-agent sdd-review spec", + "current_checklist_items": 10, + "result": "LGTM", + "unresolved_high": 0, + "unresolved_medium": 0 + }, + "product_tests_executed": false, + "full_suite_executed": false, + "remote_mutations": false, + "spec_confirmation_commit": "dda16a6502908ba8d58cff61dc350c0213b86c19", + "spec_confirmation_reply": "确认规格,进入设计" +} diff --git a/docs/reviews/vibe-governance-closeout-spec-review-2026-10-23.md b/docs/reviews/vibe-governance-closeout-spec-review-2026-10-23.md new file mode 100644 index 0000000000..c9f68375c7 --- /dev/null +++ b/docs/reviews/vibe-governance-closeout-spec-review-2026-10-23.md @@ -0,0 +1,116 @@ +# Vibe 治理与安全收尾 · 增量规格评审 + +日期:2026-10-23;基线 `feat/3.0.0-vibe@5f2541f4bc483c24624fede9031c88c4ec6f58f4`;工作分支 `codex/vibe-governance-closeout`。 + +## 1. 当前阶段与结论 + +**规格已获用户确认(原文:“确认规格,进入设计”);确认内容对应 dda16a6 及 JSON 中 approved_sha256。增量设计已完成自审,正在等待单独设计确认;尚未编码。下文保留规格评审时的范围与依据。** + +用户已要求收尾四项功能,沿用 2026-10-09 的产品决议,并在本轮明确确认:**预检和审批预览不注入真实密钥值,只给声明变量名与空值;真实值仅进入已批准的正式运行实例。** 对应用自身因空值导致的启动失败,继续按正常探活失败处理。 + +本次只编写需求与验收标准,保留原业务 Owner、原 AC 编号和原章节引用。规格确认后分别更新三份 design 并提交设计评审;依据 AGENTS.md §6 与 SDD-Guide §3,设计仍需单独确认,不能把“继续收尾”或本轮 Discovery 的确认替代两个后续确认点。 + +## 2. 修订范围 + +| Owner | 需求差额 | 本次修订 | 修订后 AC 总数 | +| --- | --- | --- | ---: | +| F054 | T098 宿主访问隔离;T099 OBO 配置错误时入口拒绝 | 新增 8 条,原 AC 不改号、不改文 | 73 | +| F055 | 应用密钥最小版 | 新增 24 条;改写 AC-54、AC-56,替换旧 N4 的排除项与延期决议 | 89 | +| F056 | 应用运行记录三子视图与导出 | 新增 16 条;改写 AC-26、AC-34,对齐掩码边界和既定方案 A | 61 | + +合计 **48 条新增 AC、4 条旧 AC 修订**。F057 只移除一个“密钥尚待 PRD-2”的过期示例,仍明确不新增 SDK 密钥读取或管理入口。release-contract 只登记第三方应用密钥归 F055,不新增并列 Owner、不修改现有 INV。 + +### 旧条款为何需要修订 + +- **F055 AC-54**:平台模型/知识库底层配置仍在原管理面;允许 owner 在发布 tab 管理本应用的第三方值,不把它误当平台管理员凭据配置。 +- **F055 AC-56**:从“密钥引用即拒绝”改为“合法引用允许、缺值警告、实际明文仍被扫描拦截”。 +- **F056 AC-26**:平台自签发凭据保留现有掩码;外部应用密钥的审计只记录引用名和动作,不输出其值或字符片段。 +- **F056 AC-34**:从旧单列表筛选描述改为既定的同一审计页新增 tab、三个子视图,操作类列表保持独立。 + +## 3. 核心业务边界 + +### 应用密钥 + +- 只有目标应用的自然人 owner 可维护值;管理员只有其租户范围的引用名/状态只读,审批人仅能读其有权审阅版本的引用与缺值信息。 +- 名字集合由批准的版本声明决定,值是应用私有资产。保存值不改代码版本或审批;新增/移除声明引用名仍随发布必审。预存一个名字的值不能绕过发布审批。 +- 正式启动只注入该批准版本声明的名字与当前有效值;预检/预览为空。缺值本身不阻断发布,也不跳过实际启动健康检查。 +- 在线使用中的值保存后刷新该应用;已下线应用不因此上线。保存成功但刷新失败必须分别显示,不假称新值已生效、不隐式恢复旧值或覆盖数据库。 +- 并发、维护中和未知结果有明确成功/冲突边界;保留单写、原操作期限、代码/依赖/运行环境组合与 SQLite 数据。 +- 值不回显、不进源码/产物/审批/快照/审计;名称不能覆盖平台身份或执行环境的保留配置。平台不承诺自动消除应用代码主动输出自己所持秘密的所有情况。 + +### 应用运行记录 + +- 复用既有管理后台审计页,新增模型调用、能力调用、应用访问三子视图,并提供相同筛选范围的全量导出。 +- 管理员才可查询;租户管理员限本租户,平台超管全局/目标租户视图遵守现有上下文。角色、筛选候选、分页、导出都不能成为跨租户旁路。 +- 服务账号调用与应用调用都可追溯;访问者未知和 token 未记录按实际缺失显示,不假造 owner 或零值。 +- 已删除/吊销对象仍按历史 ID/快照追溯;历史缺少字段不能靠当前同名对象猜测。分页/导出稳定,错误与合法空列表区分。 +- 原业务写入 Owner 不变;查询面不伪造旧事件、不把高频明细塞入操作审计列表。 + +### 两项历史安全待办 + +- 阻止应用绕过出口直连宿主数据库、backend、对象存储和管理端口,同时保留代理、统一入口、探活及合法回包。附件已通过 backend 处理,不能为旧“附件端口”说明放开整个 backend。 +- 支持的网络地址族必须均获保护;安装/应用不可确认时不得报告环境隔离可用或放行新部署。规则幂等且不改无关宿主策略。 +- 正式和预览的 OBO 配置缺失/空白/同 JWT 配置时均拒绝进入,不能下发无 OBO 的匿名降级材料;仍保持原登录、租户、存在性和权限判定顺序。启动诊断不输出值。 + +## 4. 十项自审 + +审查对象为此次增量和受其影响的旧范围/旧决议,不能据此声称三个历史 Feature 的全部实现或旧验收通过。依据为当前 PRD 的 GOV-04、GOV-05、NFR-2,F054 的 T098/T099、release-contract 的领域归属及 INV,和 constitution C1–C8。 + +| 当前 checklist 检查项 | 结果 | 依据 | +| --- | --- | --- | +| 1. 产品场景覆盖 | PASS | 管理、声明、审批、注入、生效、查询和安全拒绝均有明确 AC | +| 2. 边界/失败路径 | PASS | 缺值、自身启动失败、并发、停用、刷新失败、删除对象、失权及查询失败已覆盖 | +| 3. 范围边界 | PASS | 不做通用 Vault、自动轮换、跨应用共享、SDK 明文读取、聚合账单或 owner 审计页 | +| 4. AC 唯一可追溯 | PASS | 三份定义数 73/89/61;新增 48,旧编号不变;静态核对已留存 | +| 5. 可测试性 | PASS | 可观察的允许/拒绝、空值/真实值、状态、审计与数据不变;没有把“友好/合理”当条件 | +| 6. EARS | PASS | 全部新增 AC 均使用 THE SYSTEM SHALL 和相应条件 | +| 7. 错误码边界 | PASS | 不预占新码;原 16230 拒绝旧行为在修订说明中识别,具体落码留设计 | +| 8. Owner 边界 | PASS | F055 写密钥、F054 负责入口/生命周期、F056 只读面;其余模块为已有消费者 | +| 9. 不变量/铁律 | PASS | 外部值不冒充平台自签发凭据;发布必审、私有作用域、隔离/单写、存储与权限边界保持 | +| 10. What/How 分离 | PASS | 未选数据库表结构、路由、加密算法、刷新编排或防火墙命令;细节进入待写 design | + +### 自审修正 + +首轮核对修正:审计表的写入方列明确为 F055;旧 AC-26 的平台凭据掩码规则与应用外部值规则分开;新增 owner/运维/管理员用户故事,并给旧 tasks 的状态增加“本次增量未确认”限定。复审无未关闭 high/medium。 + +**当前 spec-checklist 的逐项结果:LGTM。** 这是同一代理的规格自审,不是独立代理评审、代码审查或运行验证。 + +## 5. AC 追溯 + +| 需求 | 规格条款 | +| --- | --- | +| 宿主隔离/部署幂等/失败拒绝 | F054 AC-66、AC-67、AC-68、AC-69 | +| OBO 正式/预览/启动诊断/正常行为 | F054 AC-70、AC-71、AC-72、AC-73 | +| 密钥角色与元数据/保密 | F055 AC-66、AC-67、AC-68、AC-80、AC-84 | +| 声明审批、正式注入、预检预览空值、缺值 | F055 AC-56、AC-69、AC-70、AC-71、AC-72、AC-73 | +| 生效、停用、失败与并发 | F055 AC-74、AC-75、AC-76、AC-77、AC-89 | +| 输入、审计、资产、未引用值、兼容与 SDK | F055 AC-78、AC-79、AC-81、AC-82、AC-83、AC-85、AC-86、AC-87、AC-88 | +| 三子视图、筛选与记录语义 | F056 AC-34、AC-46、AC-47、AC-48、AC-49、AC-50 | +| 分页、租户、权限、历史及切换 | F056 AC-51、AC-52、AC-53、AC-54、AC-55 | +| 导出、错误、安全元数据及原审计 | F056 AC-26、AC-56、AC-57、AC-58、AC-59、AC-60、AC-61 | + +## 6. 后续必要定向验证范围 + +这是设计前的验证范围约束,不是提前生成的开发任务或“已经通过”的测试。 + +- 后端:密钥 owner/非owner/管理员/跨租户配对;声明/值不同步的正反例;查询与导出的同范围权限及历史数据;OBO 有效/无效配置配对。 +- 执行层:正式注入与预检/预览空值配对;并发保存/失败刷新不覆盖后写配置;不迁移或覆盖生产 SQLite;隔离允许/拒绝配对。 +- 前端:发布密钥区的掩码/缺值/失败/确认;三子视图筛选/空态/失败/导出;跨租户视图切换不保留陈旧结果。只测新增和受影响路径。 +- 实际环境:在既有专用 Docker/K8s 环境增量验证新行为,不重复原完整安装/依赖/故障矩阵;必要检查先只读盘点。ARM 暂缓,DM8 静态。 + +当前阶段仅文档与静态编号检查,没有执行产品测试、SSH、Docker/K8s 修改、公开推送或镜像发行。 + +## 7. 供确认的文件 + +- [F054 安全增量 spec](../../features/v3.0.0/054-app-domain-runtime/spec.md) +- [F055 应用密钥增量 spec](../../features/v3.0.0/055-app-publish-pipeline/spec.md) +- [F056 运行记录增量 spec](../../features/v3.0.0/056-app-square-governance/spec.md) +- [Discovery 与本轮确认](./vibe-governance-closeout-discovery-2026-10-23.md) +- [结构化规格快照](./vibe-governance-closeout-spec-review-2026-10-23.json) +- [离线评审稿](./vibe-governance-closeout-spec-review-2026-10-23.html) + +确认本轮增量 spec 后,将继续设计与设计自审并提交单独确认;不会重复要求已经确认的 Discovery 边界。 + +## 2026-10-23 规格确认后续 + +[增量设计统一评审](vibe-governance-closeout-design-review-2026-10-23.md)(另需设计确认);规格 AC 正文保持本次确认内容不变。 diff --git a/docs/reviews/vibe-governance-remaining-design-review-2026-10-24.html b/docs/reviews/vibe-governance-remaining-design-review-2026-10-24.html new file mode 100644 index 0000000000..69d453c707 --- /dev/null +++ b/docs/reviews/vibe-governance-remaining-design-review-2026-10-24.html @@ -0,0 +1,556 @@ +Vibe 剩余设计复审

Vibe 剩余设计修订复审

离线评审稿,包含两份正式设计全文。F055 已进入实施;本页的 F054/F056 等待设计确认。

# Vibe 剩余设计修订复审(2026-10-24)
+
+## 结论
+
+F054-B1、F056-S1 已在正式 design.md 修订。按 sdd-review 24 项接手检查,未发现新的 high/medium 设计问题,结果 LGTM。**设计结论不代表代码已经实现;两份剩余设计仍需用户单独确认。** F055 简化设计已获确认且主功能已编码,不重复申请。
+
+## 本次修订
+
+| 问题 | 最小修订 | 核对点 |
+|---|---|---|
+| F054-B1 存量实例隔离丢失无消费者 | 宿主 unit 修复自有规则并配对验证,失败隔离/停止本模块实例 | 只允许宿主执行;无宽泛 root 授权;未知 writer 不释放/PVC 不清理 |
+| F056-S1 binding 撤销未记凭据审计 | 凭据 Owner 提供同 session revoke_runtime_binding,真实转移与审计原子落库 | 切换/失败候选/停止覆盖;可信 app 关联;重放不重复 |
+
+## 审查范围与门禁
+
+读取正式 spec/design/tasks、release-contract、constitution 及实际 runtime projection/credential/host egress 调用点;本次只修改设计,不将新目标写成已有行为。§1–§8 与既有 D 编号保持,无章节改号。
+
+### 054-app-domain-runtime
+
+批准目标 SHA-256:`1762a7e504f032dcc218e554a7fb2748c5234d14d8575a60f98225321d8713dc`。
+
+| 检查 | 结果 |
+|---|---|
+| 1 | PASS |
+| 2 | PASS |
+| 3 | PASS |
+| 4 | PASS |
+| 5 | PASS |
+| 6 | PASS |
+| 7 | PASS |
+| 8 | PASS |
+| 9 | PASS |
+| 10 | PASS |
+| 11 | PASS |
+| 12 | PASS |
+| 13 | PASS |
+| 14 | PASS |
+| 15 | PASS |
+| 16 | PASS |
+| 17 | PASS |
+| 18 | PASS |
+| 19 | PASS |
+| 20 | PASS |
+| 21 | PASS |
+| 22 | PASS |
+| 23 | PASS |
+| 24 | PASS |
+
+# Design: 托管应用运行时与安全收尾
+
+> **2026-10-23 设计评审稿,待用户单独确认。** 规格已确认,本文新增设计尚未实现。
+> 现状基线:`5f2541f4bc483c24624fede9031c88c4ec6f58f4`;增量规格确认提交:`dda16a6502908ba8d58cff61dc350c0213b86c19`。旧版 D1–D15 编号保留,内容按当前代码刷新;历史任务的行号、坑编号和 MVP 后置说法不作为当前实现状态。
+>
+> What:[spec.md](./spec.md)(73 条 AC);流水账:[tasks.md](./tasks.md);不变量:[release-contract.md](../release-contract.md)。本轮只补 AC-66、AC-67、AC-68、AC-69、AC-70、AC-71、AC-72、AC-73,对应历史 T098 / T099,不重开 F059 / F086 验收。
+
+## 1. 目标与非目标
+
+保持统一应用入口、平台身份注入、持久化数据和独立编排边界,补齐 Docker 应用访问宿主服务的隔离,以及 OBO 配置不完整时的入口拒绝。为 F055 用户确认的配置重建提供现有部署执行与受限运行环境只读协议。
+
+非目标:商业授权、重写 manager、重新实现 F059 Kubernetes、扩大第三方 egress 白名单、密钥管理页面或运行记录查询(分别归 F055 / F056);不在 backend 中运行防火墙命令或容器 SDK。
+
+## 2. 关键约束
+
+- 领域真相由 backend 数据库持有;manager 经内部认证下载代码/上传快照,不直连 MinIO。附件已迁 backend。源码、依赖、启动参数由已冻结交付描述决定;本轮不改镜像构建路线。
+- Docker Engine 仅由 manager 经 socket proxy 访问;K8s 由独立 manager 使用受限 ServiceAccount 操作。没有挂 socket 不意味着编排 API 权限很小。
+- Docker 隔离规则必须在 **Docker 宿主网络命名空间**生效。manager 容器中执行 iptables 或检查容器自己的 INPUT,不能证明宿主已隔离。
+- 原生 nftables 与 iptables 是两种支持路径;本轮 Docker 托管网络只声明支持 IPv4。发现 IPv6、未识别网络后端、无法证明实际隔离时,拒绝新部署,不假装支持。
+- 宿主规则变更只操作自有链/表,不能清空 INPUT/FORWARD/DOCKER-USER,也不能修改其他网络的全局默认策略。
+- Constitution Check:遵守 [constitution.md](../../../docs/constitution.md) C1–C8。新增隔离报告由 Repository 落库(C1/C8);租户对象、权限入口沿用 C3/C4;新字段/迁移用通用类型(C2),错误码沿用 161 段(C5);HMAC 与 OBO 分开配置(C6)。本轮无铁律例外。
+
+## 3. 方案对比与选定
+
+### D1:独立 manager + backend 持久意图
+
+保留独立进程;选用 F086 的持久 RuntimeOperation、执行事实与路由投影,而非 backend 直接控制 Docker。证据是 `runtime_operation_service.py`、`runtime_delivery_repository.py` 的租约/代次/操作围栏;仅 HTTP 一次性命令无法恢复响应丢失。若执行协议整体升级再评估,不增加并列状态机。
+
+### D2:Docker 经 socket proxy,K8s 经受限 API
+
+当前 Docker 配置已经是 socket proxy,不再采用旧设计的“先直挂 docker.sock”。通用代理限制 API 面但不是业务授权器;manager 仍持有高权限编排面。拒绝把三种代理与 backend 合并来省进程。只有权限模型改变且验证隔离等价时重议。
+
+### D3:交付模式服从 F086 / F059
+
+当前有 bundle、完整 image、source-image,支持 Python 3.11 / Node 20 / static,不再是首发 Python 模板单一路线。选用已冻结 delivery/profile/digest;拒绝运行中重新选择可变默认镜像。依赖缓存、离线交付和 K8s 组装的详细契约分别见 [F086 design](../086-vibe-runtime-delivery/design.md)、[F059 design](../059-k8s-runtime-backend/design.md)。新增运行时时再评估。
+
+### D4:自愈与切流复用现有围栏
+
+探活成功才提交路由;旧写入者确认停止才允许新的 SQLite 写入者;恢复沿用原操作、deadline、资源 UID,不以重启为理由无限延长。禁止只用容器名判定安全。底座证据见 F059 既有验收;本轮用户确认重建只复用这些机制。引入多实例数据模型时重议。
+
+### D5:app-proxy 保持独立入口
+
+nginx → app-proxy → backend 授权与路由投影 → 应用,支持 HTTP/WebSocket;不改为代理向 manager 查路由。proxy 持有 entry 只读角色,不持编排或数据库凭据。若统一入口基础设施替换再评估。
+
+### D6:OBO 配置失效时 fail closed(本轮变更)
+
+选用现有 `unavailable` 决策并新增 `reason=identity_config_incomplete`,不增加可放行的“匿名降级”。`entry_authz_service` 提供同一 `obo_configuration_status()` 校验器:值缺失、全空白、与 JWT secret 相同均无效;**校验不 trim 后再签名**,实际有效值原样用于签名与验证。正式入口完成登录、应用存在、租户、可见范围、下线/维护判定后,在 `_identity_material` 前检查;预览完成会话、预览归属与应用/租户核验后同样检查。任一无效返回拒绝,不生成身份头、OBO、allow 访问记录。
+
+app-proxy 的 `BackendAuthzClient` / preview 授权客户端不再缓存含身份材料的 allow 决策:每个新的 HTTP 请求和 WebSocket 握手到 backend 重新判定,避免 backend 配置失效后旧 allow 缓存继续放行。路由缓存与拒绝缓存维持独立;已经建立的 WebSocket 不承诺逐帧重鉴权。此取舍增加授权 RPC,定向验证请求数与延迟,不把“保留有效配置行为”理解为继续缓存旧身份。若未来有可信配置 epoch 推送协议,再评估恢复 allow 缓存。
+
+签发器自身再防御性校验并返回显式失败,调用者不可把失败当缺省可选值;验证器亦拒绝无效配置。请求期间以同一配置快照完成检查/签发,避免动态刷新间隙。应用运行时开启时,FastAPI lifespan 输出一次脱敏 ERROR(字段名和失败原因,不含值),**不终止整个平台进程**,入口保持拒绝;开关关闭不产生“已启用”或误报。选择此方案而非整站启动失败,因为 spec 要求关闭运行时/其他平台能力不被连带停用。若未来 OBO 采用非对称签名,再重新审查发行/验证契约。
+
+### D7:拒绝页面沿用既有契约
+
+app-proxy 对 `unavailable` 保留既有不可用页与状态码;当 backend 已完成原登录、对象存在、租户、访问权限和运行层开关判定后,消费可信 `reason=identity_config_incomplete`,正式/预览均显示 **“本环境配置不完整,请联系平台管理员”**(三语同义)。该提示不显示配置值、字段值、内部 URL 或真实应用环境;无权者仍得到原来的登录/不存在/无权限等决策,不靠浏览器参数切换到此提示。无需新视觉模式;沿用不可用页模板只增加此原因对应文案。新增状态码需先检查 nginx error_page 合同。
+
+### D8:领域与列表投影保持兼容
+
+App / AppVersion / AppInstance 继续分别表达业务状态、冻结版本、运行观察;构建/广场列表通过既有 UNION 第三支表达 `flow_type=35`、`app_state`,不为新安全项改列表含义。若新增状态,需同时审查两个 SPA 的白名单。
+
+### D9:权限走已验证业务目标
+
+应用身份、owner、租户与资源状态先由业务服务核实,业务动作走 F048 统一入口;manager 与代理不自行查 OpenFGA。管理员的通用 shortcut 不能扩大 F055 的 natural owner-only 密钥写权限。新增管理动作时重审 Catalog,而非手写 role_access。
+
+### D10:SQLite 与平台存储解耦
+
+Docker 绑定本地持久目录,K8s 用已验收独占 PVC;manager 执行本地 backup/迁移,经 backend 存取快照。应用附件走 backend 存储接口;manager 不读平台 MinIO 配置,也不需要独立 MinIO 实例/新桶。升级代码不等于恢复数据库;恢复必须显式选快照。存储拓扑变化时重议单写入者证明。
+
+### D11:容量与资源档位
+
+沿用平台档位与持久 reservation;释放依赖停止证据,不以超时自动当作已停。用户确认的配置重建依然需要执行资源预算,不绕过准入。并发拓扑改变时重审预算。
+
+### D12:宿主 INPUT + FORWARD 的同源隔离(本轮变更)
+
+**否决**仅补 DOCKER-USER(不覆盖 INPUT)、只看 `--internal`(不证明宿主入口关闭)、在 manager 容器里加 NET_ADMIN(作用域错误且扩大权限)、允许整个 backend 端口给附件(附件也应经 egress)。选定:保留 egress 唯一出口,安装侧 root 工具维护宿主自有规则,backend 持有短时有效的独立隔离证明,新部署消费证明。
+
+现有 `runtime_manager.egress` 已有规则渲染和 CLI,扩展同一规则模型,不另写两套白名单。具体安装、报告和准入合同见 §4.4。已建立连接也不能用宽泛 `ESTABLISHED` 一律放过,必须区分应用响应与应用主动发起。真实宿主配对探测是依据,命令存在不等于隔离成立。若后续采用经过验证的 eBPF/专用网络插件,可替换证明生产方,不能取消拒绝边界。
+
+### D13:详情页扩展点
+
+保留发布 / 版本 / 数据 / 日志页壳与命名 slot。F055 密钥管理放既有 settingsSlot,不为安全项新增管理菜单。壳变更时需检查三个 Feature 消费者。
+
+### D14:运行日志与访问记录分开
+
+stdout/stderr 是调试日志;backend 入口 allow 产生合并窗口访问记录;F056 的读接口不创造新访问事件。保留既有合并窗口实现/配置,本轮不改频率。安全拒绝不计成功访问;日志包含配置原因但不含身份材料。若审计语义变更,应先改规格。
+
+### D15:收尾边界与交付声明
+
+旧 MVP “后置”不再表示未实现:F086、F059 已有实现/证据,本轮只修改与安全/密钥刷新直接相关的接口。历史未完成验收不自动勾选,ARM、完整离线安装和正式镜像发行不冒充本轮完成。只在本轮 AC 定向证据成立后更新相应任务。
+
+## 4. 系统现状(接手必读)
+
+### 4.1 数据流与实现状态
+
+**已实现**:backend 保存应用/版本/操作/路由/存储绑定;Celery 调发布管线;manager 拉取持久计划、编排 Docker/K8s、上报事实;app-proxy/egress 只读投影。`RuntimeDeliveryCoordinator.inject_environment` 使用加密 sealed_env 保存可恢复执行环境;K8s 已用操作专属 Secret + `secretKeyRef`,并非把平台 token 放 ConfigMap。
+
+**尚未实现的本轮安全增量**:INPUT 规则与可信证明、OBO 无效时拒绝。F055 用户确认重建与运行快照公共适配已在 `a5c7722b9` 编码并完成定向本地检查,尚未真实部署验收。现有 `egress.firewall_rules` 仅渲染 FORWARD/DOCKER-USER 规则;`_firewall_check` 主要检测后端存在。现有 `_issue_obo_token` 返回空时两个入口仍允许,是 T099 真缺口。
+
+```mermaid
+flowchart LR
+  Browser --> Nginx --> Proxy[app-proxy]
+  Proxy --> Auth[backend entry authorization]
+  Auth --> Gate{identity configuration valid}
+  Gate -->|no| Deny[unavailable; no identity or access record]
+  Gate -->|yes| Route[backend route projection]
+  Route --> App[application]
+  App --> Egress[egress-proxy] --> Platform[platform APIs]
+  Installer[host root installer / verifier] --> Firewall[owned INPUT and FORWARD rules]
+  Installer --> Report[backend host isolation report]
+  Report --> Admission[new Docker deployment admission]
+```
+
+### 4.2 既有公共合同(保留原编号语义)
+
+**① runtime-manager 意图 RPC**:旧 `POST /v1/intents/{build,deploy,stop}` 兼容通道与当前持久 v2 通道不可混淆。当前 backend `RuntimeDeliveryCoordinator` 创建 RuntimeOperation、wake manager;manager 以 node/role HMAC 从 `/api/v1/internal/app-runtime/v2/nodes/plan`、`operations/{id}` 拉取并 claim、上报 facts。冻结字段见 F086/F059,public caller 不可注入 sealed_env/runtime_env。旧 code_object_key 直读 MinIO 的设计不再使用。
+
+**② backend 状态动作 / 读口**:`AppStateService.{publish,manual_publish,stop,resume,delete,stage_version}` 仍是 F055 的领域入口;`GET /api/v1/apps`、`/{id}`、`/{id}/instance`、`/{id}/versions`、`/{id}/logs`,PATCH 元信息与 actions 路由保持。版本列表只读,不提供绕过审批的直接切版本;`AppMetaService.update_meta` 为管线与页面共用。runtime-status 只供有权管理员;owner/租户范围由读服务决定,不拿 UI can 标志当后端权限。
+
+**③ app-proxy 注入头**:`X-BiSheng-User-Id/User-Name/Tenant-Id/Dept-Id/Dept-Name/Dept-Path/Subject-Kind/App-Id/Access-Token/Request-Id`;姓名/部门等非 ASCII 值用现有 percent-encoding;入站 header lower + 下划线→连字符归一后剥离整个 x-bisheng 等价类。X-Forwarded-Prefix/Proto/Host 由 proxy 覆盖,不信客户端。OBO 现已有消费者,不能继续引用旧“可无 token 放行”例外。
+
+**④ manifest 消费**:形态归 F055;runtime/port/tier/database/egress/delivery/startup 由当前 schema 冻结后进入执行,见 F055 design §4.2③。新增业务 secret 名仅消费可信 formal 环境,不允许任何运行形态覆盖保留名。
+
+**⑤ 环境变量**:`BISHENG_APP_DB_URL`、应用 base path、平台/模型/storage endpoint 与 operation-bound 凭据仍按 F086/F059 注入;本轮不改已有公开 SDK 变量名。正式、preview、probe 的身份/数据空间不可混用,构建进程不取得正式环境。
+
+**⑥ SQLite / 日志**:DB URL 指向运行实例的 `/data/app.db`;Docker bind 与 K8s 独占 PVC 的宿主/集群映射由执行层持有。日志 `tail/since/keyword`、状态和数据动作先验 owner/租户;backend 只传意图,manager 执行备份/数据维护,经 backend 传快照。
+
+### 4.3 关键模块职责
+
+| 模块 / 落点 | 做什么 | 不做什么 |
+|---|---|---|
+| `app_runtime/domain/services/entry_authz_service.py` | 统一 OBO 配置检查与正式/预览判定 | 不接触容器、不匿名降级 |
+| `bisheng/main.py:lifespan` | 运行时开启时输出脱敏启动诊断 | 不因此阻塞整个平台 |
+| `runtime_manager/egress.py` + 新 host verifier CLI / unit | 同源渲染、宿主安装、只读验证、配对 canary 报告 | 不持有应用真实密钥,不清全局防火墙 |
+| `app_runtime` 新 host isolation Repository/Service/Endpoint | 验证独立角色、保存证明、节点准入读取 | 不执行宿主命令、不访问 Engine |
+| `RuntimeOperationService` / `RuntimeDeliveryCoordinator` | 部署、恢复、用户确认重建准入和原有围栏 | 不拥有 F055 密钥配置资产 |
+| app-proxy / egress-proxy | 入口/出口数据面、受限投影;入口 allow 不缓存身份 | 不签 OBO、不自行上线 |
+| `app_storage_service` / backend 对象存储适配 | 源码/附件/快照传输授权 | 不替 manager 执行 SQLite 迁移 |
+
+### 4.4 本轮增量字段与接口合同(拟实现)
+
+**① 入口**:正式 `/apps/{slug}`、预览 `/apps/_preview/{session}/...` 保留现有 app-proxy → backend 内部 HMAC 协议;拒绝体仅 `decision=unavailable, reason=identity_config_incomplete` 与已允许返回的最小上下文;不得带 `inject_headers`、`obo_token`、`obo_expires_at` 或 target。登录/不存在/跨租户/无权限/停用仍先返回对应旧决策,不因配置错误暴露对象存在。
+
+**② 操作与身份**:RuntimeOperation 的 `scope_key/idempotency_key/generation/owner_epoch/deadline` 继续约束生命周期;K8s 额外 controller epoch、UID 与数据 writer 围栏。内部 HMAC 角色按 manager / entry / egress 隔离,传输受 TLS/受控网络保护;HMAC 只认证请求,不提供加密。F055 手动重建语义见其设计 §4.4③:保存不创建操作;rebuild_current 复用既有 deploy,running_environment_snapshot 只向受限内部调用提供当前正式环境 DTO;不写 F055 配置或 applied 状态。
+
+**③ 宿主规则合同(拟新增)**:
+
+| 路径 | 规则与验证 |
+|---|---|
+| iptables | 自有 `BISHENG_APP_INPUT` / `BISHENG_APP_FORWARD` 链。宿主 INPUT 在其他宽泛 accept 之前跳转;FORWARD 由 DOCKER-USER 首部跳转。跳转匹配实际 app bridge 与源子网;自有链内只允许固定 egress IP/3128 的 ORIGINAL,以及源 app、`ctdir REPLY`、ESTABLISHED 的合法入口/探活响应,其余 DROP。`RELATED` 不宽泛放行 |
+| nftables | 自有 `inet bisheng_egress`,同时具有 input / forward hook,优先级先于普通 filter 接受;匹配 bridge/源子网,允许条件与 iptables 同源生成。nft accept 仍需通过后续链,不能承诺绕过已有宿主拒绝策略 |
+| 更新 | root 安装工具获取独占锁,先构建新规则并 dry-run 校验,再以 iptables-restore `--noflush` 或 nft 单事务切换;初始化跳转失败保持/恢复拒绝态,不先删全部规则再逐条添加。重跑不重复,卸载只移除有标识的跳转/自有链表 |
+| 动态地址 | installer 通过 Engine 网络检查冻结 network ID / bridge / 子网 / egress IP、端口、宿主网关/发布地址。代理 IP 变化立即使旧配置证明不匹配,重新安装校验;不在 L3 中放行 hostname 或不受限端口 |
+| 不支持 | IPv6/未知 family、Docker rootless/不可确认 bridge、不可识别防火墙后端先报告缺能力;不静默省略一部分网络。K8s 使用 F059 的 CNI 证明,不套用宿主 Docker 规则 |
+
+**④ 隔离证明与持续收敛(拟新增)**:安装工具与 systemd oneshot/timer 位于宿主,30 秒刷新,60 秒过期;首次安装、宿主重启、dockerd 重启后先验证再放行新部署。定时 unit 不仅验证:发现自有规则漂移后,在同一宿主独占锁下重建自有规则、读回并执行允许/拒绝 canary 配对;仅完全通过才重新签发 pass。恢复失败时由宿主工具针对该 node/network 的受管应用执行网络隔离;不能确认隔离时,通过受控 Engine API 停止这些应用执行体并读回状态,报告失败及资源 ID。禁止扩大到无本模块标签的业务容器,禁止因停止请求已发出就记录停止证明。backend 消费失败证明阻止新执行/路由,manager 沿原生命周期协议确认已停止或隔离的旧实例;未知 writer 不释放容量或删除 PVC/数据。manager 不获得 root/NET_ADMIN,宿主修复不运行在 backend。工具复用现有 Python Engine HTTP 客户端;需要宿主 root、相应 iptables/nft 二进制与受控 Engine API,不要求 docker CLI。
+
+新增 `RuntimeHostIsolationReport`(F054 所有,平台级节点表):`id,node_id,engine_id,network_id,config_digest,rule_digest,revision,checked_at,valid_until,status,checks`,通用 JsonType;checks 仅结构与探测结果,不能含环境/用户流量。`POST /api/v1/internal/app-runtime/host-isolation-reports` 只接受独立、限定 node 的 `host-isolation` 服务角色;manager/entry/egress 无上报权。服务端分配接收时间、限制 TTL、递增 revision/nonce、验证节点与 engine/config 绑定,不信客户端自报无限有效期;重放/倒退拒绝。独立报告表避免 manager heartbeat 覆盖证明;不改 F059 现有摘要算法。
+
+生产方为 root 安装脚本,信任边界是客户宿主管理员;不声称防住 root 伪造。报告只在**规则读回匹配 + 实际 canary 允许/拒绝配对**均成功后标 pass:无业务凭据/PVC 的受限 canary 在 app 网络,测试直连宿主网关、宿主服务地址、published address 被拒绝,同时入口 HTTP/WS/探活响应与经 egress 的平台 API 可达。所有探测端点由管理员固定,拒绝把请求参数当任意扫描目标;canary 有 UID/ID、期限和自有标签,未知资源不强删。
+
+backend 在新 prepare/probe/preview/deploy/restore(含恢复和 F055 用户确认重建)的 create/claim/提交服务路由前验证新鲜证明及冻结配置一致性;失败给缺能力原因。stop/destroy/清理仍允许,既有应用不强制删除;其持续隔离由上述宿主 unit 恢复/隔离闭环负责,不能仅拒绝新部署却让存量实例继续直连宿主。证明过期时在途操作不发布新路由,可恢复地等待至原 deadline;不能绕过旧 writer 停止确认。安装报告通过不代替实际业务 API 的权限检查。
+
+
+## 5. 已知坑 / 反直觉事实
+
+| 坑 | 后果 | 处理位置 |
+|---|---|---|
+| DOCKER-USER 不覆盖宿主 INPUT;internal 网络仍需实际验证 | 应用绕过代理直连数据库或管理端口 | `egress.py` INPUT+FORWARD 与 host verifier |
+| 容器内 iptables 成功不等于宿主规则生效 | 假阳性准入 | 宿主 CLI / 独立报告角色 |
+| 应用发出的入口响应源地址仍是应用子网 | 无 REPLY 例外会把正常 HTTP/WS/探活阻断 | 同源规则模型和真实配对测试 |
+| 宽泛 ESTABLISHED 也包含以前建立的恶意 ORIGINAL | 新规则看似生效、既有绕过仍通 | ctdir 与连接方向断言 |
+| `_issue_obo_token` 当前返回空仍 allow;现在已有 OBO 消费者 | 登录身份失效但请求被放行 | 正式/预览统一检查和签发器兜底 |
+| 身份材料先生成后才拒绝 | 即使未转发也可能留下不应产生的头/日志 | 在 `_identity_material` 前检查 |
+| manager API 编排权限仍高于普通服务 | 合并进程扩大入侵面 | socket proxy / RBAC / 角色隔离 |
+| 版本回滚不等于 SQLite 回滚 | 旧代码运行新 schema 或误覆盖数据 | F055/F059 显式快照恢复 |
+| 旧任务完成与真实环境验收不同 | 把静态/模拟误当上线证据 | 任务证据类型与固定 SHA/digest |
+
+旧坑编号的历史引用按 `dda16a6` 的本文件读取;不把其中过期的“尚无 OBO 消费者/manager 直连 MinIO”继续当现状。
+
+## 6. 对外契约与依赖
+
+### 6.1 我提供给别人的(Outgoing)
+
+| 提供 | 消费者 | 破坏风险 |
+|---|---|---|
+| 现有 App 状态动作、内部路由/entry 协议 | F055/F056、app-proxy | 改状态/错误决策会破坏显示和转发 |
+| OBO 合法配置才签发;TTL/aud/app/tenant 绑定维持现有值 | F051 模型面、F055 能力总线、SDK | 放宽空 token 或共享 JWT secret 会破坏身份边界 |
+| 持久操作围栏 + F055 用户确认重建/受限运行快照 | F055、Docker/K8s manager | generation/operation payload 缺失时应显式拒绝,不能旧版静默忽略 |
+| 独立宿主隔离报告与节点能力原因 | 部署/运维/状态查询 | 把 manager heartbeat 当隔离证明会扩大信任面 |
+
+### 6.2 我依赖别人的(Incoming)
+
+| 依赖 | 形式 | 风险 |
+|---|---|---|
+| F086 持久操作 / F059 controller 与 writer 围栏 | HTTP、DB 与执行事实 | 旧 manager 不支持确认重建/敏感输出协议时拒绝 |
+| Docker Engine / socket proxy;宿主 iptables-restore 或 nft;systemd timer | 宿主安装条件 | 无法证明 INPUT 或 family 时阻止新部署 |
+| Redis nonce;平台 DB;部署密钥与 OBO 独立配置 | 基础服务 | 不可达或配置失效不能新发身份/伪造 pass |
+| F048 PermissionService 统一授权入口 | Python application protocol | 不绕过 verified target 或复刻 OpenFGA |
+| F055 密钥版本/注入契约;F056 审计读侧 | 领域协议 | 本模块只消费,不复制密钥表/查询规则 |
+
+## 7. 测试与可观测
+
+- 单元/模拟只覆盖变化:INPUT/FORWARD 双后端渲染、方向、幂等、失败恢复、未知 family;证明角色/过期/重放/配置改变与部署拒绝配对;正式/预览三种坏配置与有效配置的决策、无身份/无成功访问副作用;proxy 曾获 allow 后 backend 改坏配置的下一次请求/WS 握手拒绝,route 缓存不干扰此 Gate。
+- 集成只验证新表/租户无关节点范围、迁移单 head、manager 用户确认重建复用围栏。DM8 仅类型/约束/迁移静态审查,不写成 DM8 真测。
+- 真实 E2E:在授权专用 Docker 环境先保存自有规则前后差异,执行 canary 直连宿主拒绝 + HTTP/WebSocket/探活 + 经 egress SDK/模型/附件允许;重跑、重启、精确卸载对其他网络无影响。OBO 使用已授权自然人 owner/可见非 owner/不可见/跨租户配对,正式与审批预览都验证。不得把失败的 TCP 连接当“防火墙拒绝”而不先证明目标监听可达。
+- 定向命令(编码后按实际文件名执行):`cd src/runtime-manager && uv run pytest tests/test_egress.py`;`cd src/backend && uv run pytest test/app_runtime/test_entry_authz.py`(补充测试以最终任务命名为准)。手动入口 `/apps/{slug}` 与 `/apps/_preview/{session}/`,运维 `python -m runtime_manager.egress firewall --print` 先看差异,再由 root 在专用宿主安装;未确认设计前不执行变更。
+- 可观测:`identity_config_incomplete`、`host_isolation_missing/stale/mismatch/failed`、node/operation/canary ID、规则 revision;不记录密钥、token、会话或完整环境。失效不刷屏,状态面保留当前失败原因。
+
+## 8. 后续改进 / 不打算做的事
+
+本轮不支持 Docker IPv6/rootless 网络;检测即拒绝,不默认为安全。不新增通用宿主防火墙产品;验证工具只覆盖托管子网。K8s CNI/跨节点/ARM 扩展留在原 Feature 的待验清单,不借安全收尾宣称全部通过。完整设计确认后拆增量任务,保留 T098/T099 原证据与关联。
+
+## 修订历史
+
+| 日期 | 变化 | 依据 |
+|---|---|---|
+| 2026-10-23(二次修订) | 按 AC-70 修正 OBO 配置不完整提示;保持原授权顺序和正式/预览同源 | 用户要求修订后复审,尚待设计确认 |
+| 2026-10-23(简化协议同步) | F055保存不重启;消费用户确认重建与当前运行快照协议,不增加F055结果双写 | 用户已确认F055修订规格;F054自身设计未确认,B1仍待修 |
+| 2026-10-23 | 刷新 F086/F059 后的当前快照;补 INPUT 隔离证明、OBO 拒绝设计;保持 D1–D15 与 §1–§8 编号 | 用户确认增量规格;静态核对基线代码,尚未实现/部署 |
+| 2026-08-17~18 | 初版与早期 MVP 修订 | 历史正文见 `dda16a6`;历史测试不充当本轮证据 |
+
+### 2026-10-24 剩余设计问题复审
+
+F054-B1 修订:宿主 unit 为规则漂移的实际消费者,先修复并配对验证,失败则仅隔离/停止受管执行体并上报;backend 不承担宿主 root 行为,也不把网络隔离冒充 SQLite writer 停止证明。长尾的多防火墙外部重载竞态、不可识别防火墙后端和非支持网络形态列入提测限制,不能标 pass 或静默放行。F055 用户确认重建与运行快照已编码,当前尚未进行真实部署验证;本设计的宿主隔离/OBO 安全增量仍待单独确认。
+
+### 056-app-square-governance
+
+批准目标 SHA-256:`7136417b9ccd2d354903670e784f6a86acea213bd970e3393cb41ae4cd072c6c`。
+
+| 检查 | 结果 |
+|---|---|
+| 1 | PASS |
+| 2 | PASS |
+| 3 | PASS |
+| 4 | PASS |
+| 5 | PASS |
+| 6 | PASS |
+| 7 | PASS |
+| 8 | PASS |
+| 9 | PASS |
+| 10 | PASS |
+| 11 | PASS |
+| 12 | PASS |
+| 13 | PASS |
+| 14 | PASS |
+| 15 | PASS |
+| 16 | PASS |
+| 17 | PASS |
+| 18 | PASS |
+| 19 | PASS |
+| 20 | PASS |
+| 21 | PASS |
+| 22 | PASS |
+| 23 | PASS |
+| 24 | PASS |
+
+# Design: 应用广场、治理与运行记录查询
+
+> **2026-10-23 增量设计评审稿,待用户确认,未编码。** 现状基线 `5f2541f4bc483c24624fede9031c88c4ec6f58f4`;规格确认提交 `dda16a6502908ba8d58cff61dc350c0213b86c19`。保留 D1–D11 与 §1–§8 编号,新增 D12–D15;历史 MVP“查询全后置”已不适用于现有操作审计。
+>
+> [spec.md](./spec.md)(61 条 AC)· [tasks.md](./tasks.md) · [release-contract.md](../release-contract.md)。本次新增 AC-46 至 AC-61,修订 AC-26、AC-34;不接管模型/能力/访问记录的业务写入者。
+
+## 1. 目标与非目标
+
+保留应用广场和统一授权交互,在现有“审计日志”页增加“应用运行”页签,查询/导出模型调用、能力调用和应用访问三类既有数据;让现有操作审计能够按应用找到密钥变更与凭据签发/撤销事件。
+
+非目标:计费结算、全链路 tracing 产品、日志全文搜索、把 runtime stdout 当审计、重算历史身份/Token、为每次页面查询生成业务访问事件、给 owner 或开放 API 凭据授予管理员日志读取能力。
+
+## 2. 关键约束
+
+- 管理员级运行记录只接受自然人平台会话:租户管理员只查有效当前租户;全局超管无 admin-scope 时可明确选全局/目标租户,有 scope 时锁定该租户。部门管理员/只有 log 菜单的角色/应用 owner 不能因此取得此新读面。
+- 现有系统操作审计拥有更宽的菜单/管理范围规则,**不直接复用其 `_resolve_audit_groups` 当新接口权限**;保留旧接口行为。
+- 三类记录并不等于三类成功操作:无 app_id 的服务账号模型调用仍应显示;访问记录是入口 allow 合并窗口,不能展示为每个 HTTP 请求;缺历史字段用 ID/未知,不回填当前无关名称/用户。
+- 列表默认最近 7 天,可选择任意合法自定义区间或全部已留存时间;UTC ISO8601 带时区传输/规范化,start≤end;limit 1–100。**不设 90 天查询或 10,000 行导出总量上限**,导出覆盖所选条件下全部受授权记录。内部每批例如 500 行、worker 并发/租约和按字节输出分块仅控制资源,不变成用户可导出总量限制。
+- Constitution Check:遵守 [constitution.md](../../../docs/constitution.md) C1–C8。新读侧 Model/Repository/Service/Endpoint 分层;租户自动上下文不手拼通用 tenant 条件;全局超管进入显式审计 scope;通用索引/类型无 JSON_EXTRACT;模块错误码 163 段登记后使用;导出在受限对象存储不放 API 本地盘;无铁律例外。
+
+## 3. 方案对比与选定
+
+### D1:广场继续现有 UNION 与批量权限
+
+不新建平行应用列表后在前端拼接。托管应用 `flow_type=35`、slug/app_state 已随列表下发,批量可见性继续走 F048;单卡补拉权限造成 N+1 且越过通用策略。新资源类型/统一列表协议改变时再议。
+
+### D2:卡片整页访问稳定入口
+
+保留跳转 `/apps/{slug}`,不嵌 iframe、不拼 client `/workspace` base;统一登录/WS/身份均在入口。切换域名模型时再评审链接生成。
+
+### D3:广场动作与可见范围仍由通用权限驱动
+
+`app` 适用动作集与广场 use 筛选保留,不把“能看到卡片”作为运行记录管理员权限。二者属于不同业务目标。动作 Catalog 改变时审查消费者。
+
+### D4:管理权限仍复用 PermissionDialog
+
+构建卡片与发布页 VisibilityScopeSection 用同一交互/API;不为了审计页再建授权入口。当前 request 拒绝错误已需 catch,异步失败不能留 loading。组件契约变更时复核两个入口。
+
+### D5:仅 owner 可见继续按 protected grant 区分
+
+非 protected 授权行为空才表示无额外放行;默认 owner 行不应算外部授权。不从空列表误推断“无人有权限”。权限返回 schema 改变时重审。
+
+### D6:可见范围变更由真实写路径审计
+
+仍在统一授权 mutation 后按资源回调写 `app.visibility_change`,不从前端补写。查询/导出仅消费事实。新增授权写入口时必须核对回调是否覆盖。
+
+### D7:访问记录写 F054、读 F056
+
+`entry_authz_service` 已写合并窗口访问事实,不再“写读一起顺延”。保留既有合并窗口及其测试,本轮只补稳定读/导出适配,不按请求量补记历史访问。日后产品需要 PV/会话分析另立语义。
+
+### D8:runtime 关闭明确隐藏与拒绝
+
+新页签和新列表/候选/导出接口都读取实际 `app_runtime_enabled`;关闭时后端给部署开关错误,不把空列表当安全机制。旧系统操作审计照常可用。开关语义变更时再议。
+
+### D9:下线应用仍可进广场
+
+广场对托管应用白名单保持 online/stopped,不带草稿/待上线/删除;历史日志则不能以这组存活状态过滤,否则删除后记录消失。是否显示已删除资源是两个读面的不同合同。
+
+### D10:广场名称搜索与日志候选分开
+
+不拿可见应用广场列表当审计候选;它缺已删除资源、范围也不同。日志名称搜索基于受限审计记录/可信历史映射,只在当前管理员 scope 生效。若统一搜索服务支持历史治理数据再替换。
+
+### D11:当前查询交付范围
+
+现有 `api/v1/audit.py` 与 `api/services/audit_log.py` 已有系统操作审计、target_app_id 与导出;LogPage 仍只有“应用使用/系统操作”两页签。本轮加第三页签与三子视图,不重做旧审计或重写广场。真正已落地必须以实际调用点而非 tasks 计数判断。
+
+### D12:三专表适配成管理员读服务(新增)
+
+否决运行期高频记录塞普通 AuditLog、查日志文本推导、另造统一大表/回写历史。选定 F056 的 `RuntimeAuditQueryService` 调三个 Repository 适配器:现有 ModelCallRecordRepository(F051 写)、AppCapabilityCallRecord(F055 写)、AppAccessLog(F054 写)。现有模型读库有 bypass+手动 tenant 模式,新入口改用明确 `AuthorizedRuntimeAuditScope`,旧调用兼容由 owner 控制,不向客户端暴露 `tenant_id=None` 作为全局授权。
+
+共享过滤/分页/导出规格,但不同视图字段保留语义;服务账号模型调用不强制 inner join App。证据是现有三种表字段不一致,只有模型表已有 cursor/export,能力表只有 insert,访问表已有旧 page DAO。新建读 Repository,不加新 DAO 入口或让 Service 写 SQL。未来跨类型关联检索成为真实需求才考虑物化统一索引。
+
+### D13:稳定列表与一次导出的固定读取视图(修订)
+
+否决仅按秒级时间排序、offset 翻页、开放式追随新日志的无限导出,以及把 10,000 行/90 天预算当产品限制。列表采用 `(event_time DESC,id DESC)` keyset,首次查询固定 filter/scope 摘要、upper_time 与当前可见最大 id 的 watermark,cursor 绑定该选择条件;未知时间单独排序并以 id 决胜,不能在 NULL 比较上丢页;all 保留未知时间行,recent7d/custom 不能判入区间的未知时间行不伪造时间,筛选语义明确说明。新请求严格校验 cursor,不把解码失败当第一页。
+
+列表只保存短时 `RuntimeAuditQuerySnapshot` **条件元数据**并返回第一页;不为了列表先收集所有 ID,不依赖 MinIO 才能看数据。刷新/改变条件重新选择范围。列表与随后导出共享冻结的条件、watermark 和权限;并发写入下不额外承诺两个不同读取时点的数据库成员集合完全相等。导出会展示其实际选取时点及最终行数,不把导出完成数冒充此前列表的即时统计。
+
+**完整导出在后台的一次受权读取视图中完成**:任务受理时冻结 QuerySnapshot 的过滤/范围,source-owner Repository 在 worker 开始扫描时打开一条、范围有界的 ORM SELECT 只读一致游标,按稳定顺序流式输出全部匹配记录。该语句的读视图固定、watermark 固定;后续新增/回填提交不能把本次扫描变成无限流。每批最多 500 行处理/输出,但一直扫描至该读取视图 EOF,不能达到某行数后拒绝或截断。无资格/跨租户条件在源 SELECT 构造/执行时仍走自动租户过滤或显式授权的全局 Repository,而不是把多租户约束留给 CSV 写入端。
+
+source Repository 提供 `stream_export(scope, frozen_filter, watermark, batch_size)`,持有只读 source session/游标至本 attempt 扫描结束;任务进度写入用另一短事务 session,不在源读取事务中写状态。底层必须采用真正流式/受限 fetchmany,不用 `all()/fetchall()` 把整表搬进内存;MySQL/DM8 由各 source-owner 适配其固定读视图游标/事务,定向检查驱动的实际缓冲和语句范围,不能仅设置 yield_per 就宣称有界。占用一个后台读连接/读视图至扫描结束是此方案的显式成本,以导出 worker 并发、可观测持续时间及运维取消控制,不在 HTTP 请求保持长事务,也不转成 90 天/行数硬门槛。
+
+worker 崩溃后无法恢复数据库游标读视图,因此未 ready 的 attempt **整次只读重建**,不拿旧游标偏移接新视图造成漏/重;同一冻结条件与 watermark 下重新开启读视图,显示新的 selection_started_at。旧 attempt 从未交付字节,只保留待清理私有分块;只有一次完整扫描的 manifest 才能原子发布为 ready。ready 后的文件成员与字节固定,重复下载不会重新查事实表。若历史清理/读取故障导致不能完整输出,显式失败,不标空文件或部分文件成功。
+
+### D14:受权异步导出与不可变分块产物(修订)
+
+否决公开 presigned URL、前端只导当前页、提交时授权永久放行。选用后台固定读取视图 → 标准 CSV → MinIO 私有不可变分块 + 有序 manifest;backend 认证下载按 manifest 依次流式拼接为一个 CSV,不暴露对象 URL。CSV 标题/BOM 只在首块,每个分块在完整行边界收束。任务状态为 pending/running/ready/failed/expired;总记录数不设产品上限。
+
+任务通过 DB 租约/epoch 取得单一输出权;对象 key 绑定 job/attempt/epoch/part_no,上传确认后短事务登记分块及累计行数。ready 的 CAS 必须同时校验当前 lease/epoch、同一 source read-view attempt 已 EOF、全部分块已确认且 manifest 有序/完整。旧 worker 只能遗留未引用的私有对象,不能覆盖新 attempt 或把任务标 ready;响应丢失重查持久 ready/manifest,不能追加重复 CSV 行。失败或重启按 D13 从头读取新 attempt,不拼接不同读视图的分块。
+
+受理、worker 开始/每批和 ready 发布、状态读取、下载开始/受限块之间都核验当前自然人管理员资格与保存的 scope。失权或无法确认即取消交付/停止任务,不使用提交时缓存永久授权;已交付字节无法收回。条件快照未被任务引用时 10 分钟过期;任务受理后其冻结条件由任务持有,不因排队/扫描超过 10 分钟被清理。**下载保留期从 ready_at 开始计算 10 分钟**,未完成 attempt 由租约/取消和终态 GC 管理,不能用该保留期限制记录总量或扫描时长。
+
+CSV 仅白名单字段;危险公式文本前置单引号后按标准 CSV 转义,测试空白/控制字符绕过、逗号、引号、换行与 Unicode;数值按数值,null 不替成其他主体。对象/worker/source 不可用时明确失败,GC 只删没有活跃 lease/ready manifest 引用的本任务对象;就绪产物到期/取消清理有 durable 状态,不依赖本地盘。未来确需更高吞吐再独立调度,不改变完整导出的规格。
+
+### D15:可信应用关联的操作审计(新增)
+
+F055 负责写 app.secret.*;凭据 owner 在签发/撤销时从持久 `ApiCredential.subject_kind/subject_id` 与可信 hosted-app 归属映射计算 target_app_id,不取查看者传的 app_id;SAK/PAT 的资源 owner 不等于 app 归属,不能把该人的所有 key 自动关联给某应用。
+
+现有 CredentialService._audit 只写 credential_id/subject/scopes/key_mask,需要补可信关联。补充实际生产者:正式切换和候选终结当前在 RuntimeProjectionService 中将 binding 从 staged/active 改为 revoked,并不经过 _audit。改由凭据 Owner 的公开 `revoke_runtime_binding(session, binding, operation, reason)` 协议,在调用者原事务中锁定 binding、只在真实状态改变时撤销并追加 `open_api.api_key.revoke` 审计。应用关联从绑定的 app/credential 与 hosted-app 主体映射交叉核验获得,不接受查询者提供的归属;审计带 app_id、credential_id、operation_id、reason,事件不带业务环境变量值。以同一 binding 的状态转移作为去重围栏,重复切换/终结不重复记事件;失败回滚状态与审计,不新增审计补偿服务或独立数据库提交。切换撤旧、停止/删除、失败候选清理都调用该协议;签发使用已新增的显式 session 入口,签发审计也带可信 app 关联。
+
+历史记录只能在原始可信字段足以确定时通过只读关联查询;不补写历史“现在属于”。不增加第三方业务密钥末位/长度,API key 原有前缀/末位 mask 继续保留。若普通凭据没有可信 app 绑定,明确不相关,不硬凑。后续新 credential owner 需提供同一审计关联协议。
+
+## 4. 系统现状(接手必读)
+
+### 4.1 数据流与实现状态
+
+**已实现**:广场第三类型、两处权限 UI、可见范围审计、系统操作日志查询与应用筛选;模型表/能力表/访问表真实写入路径。**本轮未实现**:管理员 RuntimeAuditQueryService、新 endpoint/候选/稳定导出、新第三页签、可信凭据关联补齐、访问 app 名称快照。
+
+```mermaid
+flowchart LR
+  UI[LogPage / Application runtime] --> API[F056 session API]
+  API --> Scope[server verified admin scope]
+  Scope --> Query[query service / typed filters]
+  Query --> M[ModelCallRecord repository]
+  Query --> C[Capability record repository]
+  Query --> A[Access record repository]
+  Query --> Snapshot[frozen filters and watermark]
+  Snapshot --> Job[authorized worker: stream one fixed read view]
+  Job --> M
+  Job --> C
+  Job --> A
+  Job --> Store[immutable CSV chunks and manifest]
+  Store --> Download[backend authenticated download]
+  Download --> Check[current role and scope check]
+```
+
+### 4.2 既有公共合同(保留原广场/审计语义)
+
+| 字段 / 入口 | 当前约定 |
+|---|---|
+| 广场列表 `flow_type/slug/app_state/can_share/user_name` | 托管类型 35;slug 为稳定 `/apps/{slug}` 入口;应用态仅 online/stopped;can_share=false;owner 显示沿用既有列表投影 |
+| GET `/api/v1/chat/online` | 现有 page/keyword/tag/type 等筛选;服务端收窄 app 状态和 use 动作,不信客户端 action 扩权 |
+| PermissionDialog | resourceType=app,resourceId=内部 app.id;两入口共用,protected owner grant 不算额外放行 |
+| app.visibility_change | 变更人在真实授权 mutation 后回调记录;app/slug/added/removed 元数据;不由查询写入 |
+| 事件登记 | 后端可见 action/namespace、领域 action、前端 log.ts、三语事件名保持 lockstep |
+| 现有系统操作审计 | `/api/v1/audit` 和 `/audit/export/data` 已支持 target_app_id/tenant 筛选;不因新增 runtime 读面改变旧菜单/管理范围语义 |
+
+### 4.3 关键模块职责
+
+| 模块 / 新增落点 | 做什么 | 不做什么 |
+|---|---|---|
+| `app_governance` F056 读侧(按既有领域目录落点核对后实施)Endpoint/Service/Repository | 受权过滤、分页、候选、snapshot/export | 不重新写模型/能力/访问事件,不查 OpenFGA 原始 client |
+| 三个 source-owner Repository 读适配协议 | 返回白名单列表页与固定读视图流式 export;持有源只读 session,保持事件语义 | 不从通用 Service 跨层 ORM 查询,不 fetchall,不在源事务写任务状态 |
+| `open_api/.../credential_service.py:_audit` 与其可信 app resolver | 签发/撤销时写 app 关联 | 不由 viewer 参数、owner 人员关系猜 app |
+| `app_runtime/.../app_access_log_service.py` | 新事件可选 app 名称快照,合并窗口不变 | 不回填历史事实、不记录拒绝为成功 |
+| `pages/LogPage/runtime/`、API/hook(新增) | 三视图、filters/table/export、错误和过期状态 | 不含秘密列,不读凭据明文接口 |
+| `AuditLogService` 与现有 systemLog | 查 app.secret 与可信凭据事件 | 不把 runtime 高频事件塞操作审计 |
+
+实施前确认 F056 现有代码归属:当前主要在 `api/services/audit_log.py` 与通用列表模块,新增读服务**拟建立** `bisheng/app_governance/{api,domain}`,仅管理本 Feature 读侧,不迁移旧广场/权限代码,不重构通用审计。名称不代表新 feature/domain owner;release-contract 仍由 F056 认领。
+
+### 4.4 本轮增量字段与接口合同(拟实现)
+
+**① 拟新增会话 API**:挂 F056 路由 `/api/v1/app-runtime-audit`;读取者必须通过平台 session dependency,不能走 `/api/v2`。
+
+| 接口 | 参数 / 响应 | 校验 |
+|---|---|---|
+| GET `/records/{kind}` | kind=model/capability/access;time_range=recent7d/custom/all(默认 recent7d);custom 提供 start_time/end_time;app_id/visitor_id,model 额外 credential_id;tenant_id 只缩小 scope;cursor/limit;响应 `{items,next_cursor,snapshot_id,matched_count}` | custom 范围无 90 天上限;all 对已留存时间不加下界;非法/不适用筛选明确拒绝。统计仅在受权范围,尚未计算可为 null,不用 10001 冒充总量;无因记录数而不可导出的标志 |
+| GET `/candidates/{kind}` | app/visitor/credential,按当前 kind/time/scope 的历史事实给 `{id,label,deleted_or_revoked}` 候选,分页/搜索有界 | 同查询管理员门禁;credential 只给 mask,无私密 name/value;跨租户对象请求拒绝 |
+| POST `/exports` | `{snapshot_id,request_id}` → `{export_id,status}` | 条件快照属于同一调用者/当前合法 scope、未过期,复制条件到任务;不因行数/历史跨度拒绝;幂等 request_id |
+| GET `/exports/{id}` | status + safe_reason、attempt、selection_started_at、processed_rows、ready_at/expires_at、ready 后 final_count | 重验资格与 scope;processed_rows 是当前 attempt 进度而非精确总量,重试从 0 开始,不报告部分成功 |
+| GET `/exports/{id}/download` | 认证后返回 CSV 流;Cache-Control: no-store | 不返回公开 object key/URL,失权/跨 scope 拒绝;下载开始与每个受限 chunk 之间重验,取消后停止继续交付 |
+
+列表/候选只读,下载一次不新增访问事实;业务错误遵循现有 envelope,前端使用包裹 request 的 rejected Promise 处理。新增错误码从 163 段核对占用后分配并同步三语,不重用历史退休编号。
+
+**② 三视图字段**:event_time 为查询统一排序别名,原始字段语义不变。
+
+| kind / 来源 | 响应白名单 | 历史缺项 |
+|---|---|---|
+| model / `ModelCallRecord` | id、create_time、app_id、actor_kind/id/name、subject_kind/id、credential_id/mask、model_id/name、prompt/completion/total_tokens、result/error_code/http_status、latency/ttft、request_id | app_id 可空(服务账号调用);subject 未知不冒充 owner;Token 空不填 0;无当时 app 名只显示 ID |
+| capability / `AppCapabilityCallRecord` | id、create_time、app_id/app_name、credential_id、subject_kind/subject_user_id、capability、result/error_code、chunk_count、latency_ms、version_id | credential mask 仅经受限凭据投影能找到时补;无 actor 快照只表达 app 这个 actor,不虚构用户 |
+| access / `AppAccessLog` | id、entry_time、app_id、app_name_snapshot(新增可空)、user_id/user_name、request_id | 老行 app_name_snapshot 空显示 ID;访问为 allow 合并事件,不显示为 HTTP 成功率/逐请求响应 |
+
+不输出 model prompt/completion、检索 chunks/正文/targets 原始 payload、secret/env/cookies/OBO/token/任意 metadata。仅为新 access 记录新增 app_name_snapshot(128 字符 nullable)与写入参数,不重写合并窗口;历史不从当前名称回填。模型表已有 actor_name/model_name 快照,能力表已有 app_name;不为方便前端用 mandatory live join 丢掉已删除/撤销记录。
+
+**③ QuerySnapshot / RuntimeAuditExport(拟新增)**:仍为两个申请者私有实体,不新增永久审计大表或全量 ID 选集副本。`RuntimeAuditQuerySnapshot` 保存 UUID、tenant_id(默认 None/NOT NULL/索引)、creator_user_id、scope_mode/scope_tenant_id、kind、normalized_filter JsonType、filter_digest、watermark、created_at/expires_at;只含条件,不含事实正文或完整 ID 集。tenant_id 取服务端确认的申请者有效工作租户,global 是经超管核验的过滤范围,不以 NULL tenant 绕过自动过滤。
+
+`RuntimeAuditExport` 保存同样的申请者/租户/scope、复制的冻结条件和 watermark、snapshot_id、request_id、status、attempt/selection_started_at、lease_holder/epoch/until、processed_rows、final_count、manifest_object_key/digest、ready_at/download_expires_at、safe_failure;唯一 `(creator_user_id,request_id)`。分块对象包含 part_no/行数/字节数/hash,由最终不可变 manifest 收束;任务进度是可重建状态,不需要另建一张事实记录表。对象 key 全部服务端生成,客户端不能指定。
+
+worker 按持久任务 tenant_execution_scope 初始化租户过滤,只有源数据读取在已授权的 global source Repository 中 bypass;后台每批重验申请者当前资格,不能只信任务里旧角色。源只读扫描与任务短写事务分开;领取/重试/ready/GC 以数据库 CAS 和租约处理多进程。未引用条件快照 10 分钟清理,受理后由 job 保留冻结条件,下载到期从 ready 算起;失败/取消且无活跃 lease 的孤儿块可回收,活跃扫描不删。
+
+**④ Scope / Cursor 安全**:
+
+- `AuthorizedRuntimeAuditScope` 仅由服务端权限适配生成;自然人角色来自可信登录态与权限 application 协议当前核验,不信请求角色/租户。旧审计允许 log-menu 的逻辑不复用到此处。
+- 普通租户 Repo 在 tenant_execution_scope 下自动过滤;全局超管 Repo 仅在明确授权 scope 下 bypass;客户端 tenant_id 只允许进一步收窄。跨租户 app/visitor/credential ID(含历史 tombstone)在限权解析器中验证,拒绝与未知 ID 对外同样不泄漏详情,不把 foreign ID 默默当“查无”。候选从事实表/可信删除保留信息产生,不能凭当前用户/应用列表扩范围。
+- cursor 是服务端签名/摘要校验的 `{version,kind,scope_digest,filter_digest,snapshot_id,last_event_time,last_id,expires_at}`,不包含明文凭据。切租户/视图/筛选不能重用;解码/签名/过期错明确拒绝而非回第一页。
+- 每次 list/candidates/export/status/download 都重验角色/范围;后台任务实际执行时再验提交者资格。无法确认权限 fail closed;不是仅靠 JWT 缓存角色永久允许。已交付字节不可收回,失权后不开始新下载或继续交付后续 chunk;不承诺撤回浏览器已收到的文件。
+
+
+## 5. 已知坑 / 反直觉事实
+
+| 坑 | 后果 | 处理位置 |
+|---|---|---|
+| 旧 AuditLogService 允许 log 菜单/部门管理者 | 直接复用会放大新 runtime 数据权限 | 新 Scope resolver |
+| ModelCallRecordRepository tenant_id=None 是全局 bypass | 漏传租户变成全局数据泄漏 | typed scope + 专用普通/全局 Repo 适配 |
+| 同秒 create_time 常相同,旧 cursor 错误会被忽略 | 翻页重复/漏行/跳回首页 | 严格签名 cursor + time,id 全序 |
+| 写入有 write-behind,event_time 不等于提交时间 | 列表与随后导出不同读时点不一定同成员;恢复旧游标会混读视图 | 固定条件/watermark,导出单一读视图,崩溃 attempt 从头只读重建,ready 才发布 |
+| live app/credential 内连接会丢删除/撤销行 | 历史审计证据消失 | 以事实为主,可选显示投影/未知 |
+| SAK 资源 owner 是人,不代表那人的某个 app | app 筛选错误归因其他 key 的行为 | credential owner 可信 subject_ref 解析 |
+| 下载公开 MinIO URL 可绕过后来失权 | 撤权后继续新下载 | 认证代理下载/按块重验 |
+| CSV 双引号不是公式防护 | 表格软件执行攻击字段 | 类型白名单 + 首有效危险字符处理 |
+| HTTP 失败不是空结果 | 用户误以为无人访问/导出成功 | 独立 error/loading/empty 状态 |
+| 应用 allow 访问按窗口合并 | 错读为 PV/逐次网络请求 | UI 字段提示与 F054 同语义 |
+
+历史坑编号见 `dda16a6` 本文件;旧 300/1800 秒勘误不覆盖当前代码配置,本轮不改窗口。
+
+## 6. 对外契约与依赖
+
+### 6.1 我提供给别人的(Outgoing)
+
+| 契约 | 消费者 | 风险 |
+|---|---|---|
+| 现有广场/授权交互与操作审计 | client/platform、管理员 | 不改变卡片状态与既有日志范围 |
+| 三运行视图列表/候选/snapshot/export 会话 API | LogPage 新第三页签 | runtime 关闭/失权返回拒绝,不假空 |
+| 按 app 查 secret 变更/可信凭据事件 | 系统操作日志页 | 审计登记四处 lockstep 与三语必须齐 |
+| 白名单响应与 CSV schema version=1 | UI/管理员导出工具 | 字段语义与缺失值约定不可悄改 |
+
+### 6.2 我依赖别人的(Incoming)
+
+| 依赖 | 形式 | 风险 |
+|---|---|---|
+| F051 ModelCallRecord、F055 capability record、F054 access log | Repository/Python 数据契约 | 写方列名/追加策略/删除留存改变会破坏快照 |
+| F048 可信身份/权限 application,F019 admin-scope | 当前会话+Python | 原始请求 tenant/admin 标志不可当授权 |
+| 平台 DB、私有 MinIO、Celery/既有周期清理 | 后台任务/对象 | 不能只存在 writer 本地盘;过期必须回收自有对象 |
+| F055 secret 审计与 credential owner 的可信关联 | 元数据 | 不读/存密钥明文;关联不足就未知 |
+| 现有 LogPage/Tabs 与共享 UI/包裹 request/locales | TS | 不新增库/样式,Select 未 done 时跟随现存筛选实现 |
+
+## 7. 测试与可观测
+
+- 数据/权限定向:三表正常与空 app_id、null Token/subject;同秒/跨页/晚到写入/删除快照失效;租户 admin/超管 scoped 与 unscoped 允许,owner/log 菜单/部门 admin/审批人/SAK/PAT/foreign filter 拒绝;候选、导出、下载同样配对,角色变化后失败。
+- 导出定向:超过 10,000 行、超过 90 天和全部已留存时间都完整导出;同一成功读取视图的结果和文件完全一致,后台新增/回填不把扫描变无限流;进度批次/内存有界;重复 request_id 不创建重复任务。源扫描中崩溃从头新 attempt、不拼接混合读视图;ready 提交丢响应不重复追加;旧 epoch 不发布,GC 不删活跃对象。权限在提交/执行/下载/分块间撤销;MinIO/source 失败不交付部分文件;下载 TTL 从 ready 起;CSV 注入/Unicode/引号/换行负向验证。
+- 前端:保留两个旧页签,新第三页签里三子视图;选择器无空 ID;filter/kind/tenant 改变 reset cursor,query key 含 scope/type/filter,AbortController/请求序号丢弃旧结果;新错误态与空态分离;导出绑定当前 snapshot 并清楚显示范围/行数,失败结束等待、不显示“已下载”。
+- UI 取当前共享 docs 的 Input/Button/Tabs 等 status=done 组件,表格/筛选沿用当前 `LogPage/systemLog` 及其选择器模式。filter 在窄屏换行、表格横向滚动,不引入新视觉模式/库;多端、字体/色彩按当前规范,三语同交付,不手改生成的 api_errors。
+- E2E 走新会话 API + 实际 LogPage 人工清单:授权测试租户 A/B、tenant admin、global super、ordinary owner;产生模型/能力/allow 记录,按同一受权条件查/导,明确显示实际导出时点/最终数;大于 10,000 行和 90 天范围不被硬拒绝;删除测试 app/撤销 key 后仍查历史;验证 secret 审计没值、无 app 的 SA 模型记录不消失。runtime 关闭新面拒绝,旧系统操作仍可查。
+- 编码后定向命令:`cd src/backend && uv run pytest test/app_governance/test_runtime_audit_query.py test/app_governance/test_runtime_audit_export.py`(拟新增);只跑 `LogPage/runtime` 对应 Jest 文件。复用旧 audit 测试中修改路径的最小集合,不全量重跑;必要 Ruff/arch-guard/迁移 head 单独验证。DM8 仅静态。
+- 指标/日志:query kind、规范筛选摘要、scope、actor、行数、导出状态/过期/失败码;不记原始秘密/URL token/body。查询不伪造模型/能力/访问业务事实。
+
+## 8. 后续改进 / 不打算做的事
+
+不补造历史 app 名/subject/凭据关联;不建立日志仓库或计费模型;只实现当前审计条件下的完整异步导出,以批次/并发/租约控制资源,不设置 90 天或 10,000 行产品门槛。长导出的一致读视图占用和驱动缓冲必须在定向检查中量化,不以本地临时文件绕过多节点。原 ARM/跨节点/正式镜像发行验收仍保留,不宣称新列表测试证明这些能力。
+
+## 修订历史
+
+| 日期 | 变化 | 依据 |
+|---|---|---|
+| 2026-10-23(二次修订) | 去除导出总量/时间硬限制;条件元数据与后台单一读取视图分离;私有分块 ready 原子发布、重启从头只读重建 | 用户要求修订并复审;规格 AC 未变,尚未编码 |
+| 2026-10-23 | 刷新已实现广场/操作审计;新增 D12–D15、三视图/稳定导出/权限合同 | 用户确认规格,静态代码核查;未实现/部署 |
+| 2026-08-17 起 | 早期广场与治理设计 | 旧正文见 `dda16a6`;历史任务证据不改写 |
+
+### 2026-10-24 剩余设计问题复审
+
+F056-S1 修订:运行绑定的真实撤销生产者由凭据 Owner 公共协议消费,同 session 状态转移与审计,以状态 CAS/行锁去重;查询层不能补造历史事实。保留列表/异步导出的主链路,低概率的外部存储瞬断与 worker 任意指令点崩溃组合列为后续故障验收;权限失效、跨租户拒绝、CSV 安全和完整导出仍属于主功能,不因缩减测试而放宽。实现尚未开始,仍待单独确认本修订设计。
+
\ No newline at end of file diff --git a/docs/reviews/vibe-governance-remaining-design-review-2026-10-24.md b/docs/reviews/vibe-governance-remaining-design-review-2026-10-24.md new file mode 100644 index 0000000000..d3b33dcce7 --- /dev/null +++ b/docs/reviews/vibe-governance-remaining-design-review-2026-10-24.md @@ -0,0 +1,555 @@ +# Vibe 剩余设计修订复审(2026-10-24) + +## 结论 + +F054-B1、F056-S1 已在正式 design.md 修订。按 sdd-review 24 项接手检查,未发现新的 high/medium 设计问题,结果 LGTM。**设计结论不代表代码已经实现;两份剩余设计仍需用户单独确认。** F055 简化设计已获确认且主功能已编码,不重复申请。 + +## 本次修订 + +| 问题 | 最小修订 | 核对点 | +|---|---|---| +| F054-B1 存量实例隔离丢失无消费者 | 宿主 unit 修复自有规则并配对验证,失败隔离/停止本模块实例 | 只允许宿主执行;无宽泛 root 授权;未知 writer 不释放/PVC 不清理 | +| F056-S1 binding 撤销未记凭据审计 | 凭据 Owner 提供同 session revoke_runtime_binding,真实转移与审计原子落库 | 切换/失败候选/停止覆盖;可信 app 关联;重放不重复 | + +## 审查范围与门禁 + +读取正式 spec/design/tasks、release-contract、constitution 及实际 runtime projection/credential/host egress 调用点;本次只修改设计,不将新目标写成已有行为。§1–§8 与既有 D 编号保持,无章节改号。 + +### 054-app-domain-runtime + +批准目标 SHA-256:`1762a7e504f032dcc218e554a7fb2748c5234d14d8575a60f98225321d8713dc`。 + +| 检查 | 结果 | +|---|---| +| 1 | PASS | +| 2 | PASS | +| 3 | PASS | +| 4 | PASS | +| 5 | PASS | +| 6 | PASS | +| 7 | PASS | +| 8 | PASS | +| 9 | PASS | +| 10 | PASS | +| 11 | PASS | +| 12 | PASS | +| 13 | PASS | +| 14 | PASS | +| 15 | PASS | +| 16 | PASS | +| 17 | PASS | +| 18 | PASS | +| 19 | PASS | +| 20 | PASS | +| 21 | PASS | +| 22 | PASS | +| 23 | PASS | +| 24 | PASS | + +# Design: 托管应用运行时与安全收尾 + +> **2026-10-23 设计评审稿,待用户单独确认。** 规格已确认,本文新增设计尚未实现。 +> 现状基线:`5f2541f4bc483c24624fede9031c88c4ec6f58f4`;增量规格确认提交:`dda16a6502908ba8d58cff61dc350c0213b86c19`。旧版 D1–D15 编号保留,内容按当前代码刷新;历史任务的行号、坑编号和 MVP 后置说法不作为当前实现状态。 +> +> What:[spec.md](./spec.md)(73 条 AC);流水账:[tasks.md](./tasks.md);不变量:[release-contract.md](../release-contract.md)。本轮只补 AC-66、AC-67、AC-68、AC-69、AC-70、AC-71、AC-72、AC-73,对应历史 T098 / T099,不重开 F059 / F086 验收。 + +## 1. 目标与非目标 + +保持统一应用入口、平台身份注入、持久化数据和独立编排边界,补齐 Docker 应用访问宿主服务的隔离,以及 OBO 配置不完整时的入口拒绝。为 F055 用户确认的配置重建提供现有部署执行与受限运行环境只读协议。 + +非目标:商业授权、重写 manager、重新实现 F059 Kubernetes、扩大第三方 egress 白名单、密钥管理页面或运行记录查询(分别归 F055 / F056);不在 backend 中运行防火墙命令或容器 SDK。 + +## 2. 关键约束 + +- 领域真相由 backend 数据库持有;manager 经内部认证下载代码/上传快照,不直连 MinIO。附件已迁 backend。源码、依赖、启动参数由已冻结交付描述决定;本轮不改镜像构建路线。 +- Docker Engine 仅由 manager 经 socket proxy 访问;K8s 由独立 manager 使用受限 ServiceAccount 操作。没有挂 socket 不意味着编排 API 权限很小。 +- Docker 隔离规则必须在 **Docker 宿主网络命名空间**生效。manager 容器中执行 iptables 或检查容器自己的 INPUT,不能证明宿主已隔离。 +- 原生 nftables 与 iptables 是两种支持路径;本轮 Docker 托管网络只声明支持 IPv4。发现 IPv6、未识别网络后端、无法证明实际隔离时,拒绝新部署,不假装支持。 +- 宿主规则变更只操作自有链/表,不能清空 INPUT/FORWARD/DOCKER-USER,也不能修改其他网络的全局默认策略。 +- Constitution Check:遵守 [constitution.md](../../../docs/constitution.md) C1–C8。新增隔离报告由 Repository 落库(C1/C8);租户对象、权限入口沿用 C3/C4;新字段/迁移用通用类型(C2),错误码沿用 161 段(C5);HMAC 与 OBO 分开配置(C6)。本轮无铁律例外。 + +## 3. 方案对比与选定 + +### D1:独立 manager + backend 持久意图 + +保留独立进程;选用 F086 的持久 RuntimeOperation、执行事实与路由投影,而非 backend 直接控制 Docker。证据是 `runtime_operation_service.py`、`runtime_delivery_repository.py` 的租约/代次/操作围栏;仅 HTTP 一次性命令无法恢复响应丢失。若执行协议整体升级再评估,不增加并列状态机。 + +### D2:Docker 经 socket proxy,K8s 经受限 API + +当前 Docker 配置已经是 socket proxy,不再采用旧设计的“先直挂 docker.sock”。通用代理限制 API 面但不是业务授权器;manager 仍持有高权限编排面。拒绝把三种代理与 backend 合并来省进程。只有权限模型改变且验证隔离等价时重议。 + +### D3:交付模式服从 F086 / F059 + +当前有 bundle、完整 image、source-image,支持 Python 3.11 / Node 20 / static,不再是首发 Python 模板单一路线。选用已冻结 delivery/profile/digest;拒绝运行中重新选择可变默认镜像。依赖缓存、离线交付和 K8s 组装的详细契约分别见 [F086 design](../086-vibe-runtime-delivery/design.md)、[F059 design](../059-k8s-runtime-backend/design.md)。新增运行时时再评估。 + +### D4:自愈与切流复用现有围栏 + +探活成功才提交路由;旧写入者确认停止才允许新的 SQLite 写入者;恢复沿用原操作、deadline、资源 UID,不以重启为理由无限延长。禁止只用容器名判定安全。底座证据见 F059 既有验收;本轮用户确认重建只复用这些机制。引入多实例数据模型时重议。 + +### D5:app-proxy 保持独立入口 + +nginx → app-proxy → backend 授权与路由投影 → 应用,支持 HTTP/WebSocket;不改为代理向 manager 查路由。proxy 持有 entry 只读角色,不持编排或数据库凭据。若统一入口基础设施替换再评估。 + +### D6:OBO 配置失效时 fail closed(本轮变更) + +选用现有 `unavailable` 决策并新增 `reason=identity_config_incomplete`,不增加可放行的“匿名降级”。`entry_authz_service` 提供同一 `obo_configuration_status()` 校验器:值缺失、全空白、与 JWT secret 相同均无效;**校验不 trim 后再签名**,实际有效值原样用于签名与验证。正式入口完成登录、应用存在、租户、可见范围、下线/维护判定后,在 `_identity_material` 前检查;预览完成会话、预览归属与应用/租户核验后同样检查。任一无效返回拒绝,不生成身份头、OBO、allow 访问记录。 + +app-proxy 的 `BackendAuthzClient` / preview 授权客户端不再缓存含身份材料的 allow 决策:每个新的 HTTP 请求和 WebSocket 握手到 backend 重新判定,避免 backend 配置失效后旧 allow 缓存继续放行。路由缓存与拒绝缓存维持独立;已经建立的 WebSocket 不承诺逐帧重鉴权。此取舍增加授权 RPC,定向验证请求数与延迟,不把“保留有效配置行为”理解为继续缓存旧身份。若未来有可信配置 epoch 推送协议,再评估恢复 allow 缓存。 + +签发器自身再防御性校验并返回显式失败,调用者不可把失败当缺省可选值;验证器亦拒绝无效配置。请求期间以同一配置快照完成检查/签发,避免动态刷新间隙。应用运行时开启时,FastAPI lifespan 输出一次脱敏 ERROR(字段名和失败原因,不含值),**不终止整个平台进程**,入口保持拒绝;开关关闭不产生“已启用”或误报。选择此方案而非整站启动失败,因为 spec 要求关闭运行时/其他平台能力不被连带停用。若未来 OBO 采用非对称签名,再重新审查发行/验证契约。 + +### D7:拒绝页面沿用既有契约 + +app-proxy 对 `unavailable` 保留既有不可用页与状态码;当 backend 已完成原登录、对象存在、租户、访问权限和运行层开关判定后,消费可信 `reason=identity_config_incomplete`,正式/预览均显示 **“本环境配置不完整,请联系平台管理员”**(三语同义)。该提示不显示配置值、字段值、内部 URL 或真实应用环境;无权者仍得到原来的登录/不存在/无权限等决策,不靠浏览器参数切换到此提示。无需新视觉模式;沿用不可用页模板只增加此原因对应文案。新增状态码需先检查 nginx error_page 合同。 + +### D8:领域与列表投影保持兼容 + +App / AppVersion / AppInstance 继续分别表达业务状态、冻结版本、运行观察;构建/广场列表通过既有 UNION 第三支表达 `flow_type=35`、`app_state`,不为新安全项改列表含义。若新增状态,需同时审查两个 SPA 的白名单。 + +### D9:权限走已验证业务目标 + +应用身份、owner、租户与资源状态先由业务服务核实,业务动作走 F048 统一入口;manager 与代理不自行查 OpenFGA。管理员的通用 shortcut 不能扩大 F055 的 natural owner-only 密钥写权限。新增管理动作时重审 Catalog,而非手写 role_access。 + +### D10:SQLite 与平台存储解耦 + +Docker 绑定本地持久目录,K8s 用已验收独占 PVC;manager 执行本地 backup/迁移,经 backend 存取快照。应用附件走 backend 存储接口;manager 不读平台 MinIO 配置,也不需要独立 MinIO 实例/新桶。升级代码不等于恢复数据库;恢复必须显式选快照。存储拓扑变化时重议单写入者证明。 + +### D11:容量与资源档位 + +沿用平台档位与持久 reservation;释放依赖停止证据,不以超时自动当作已停。用户确认的配置重建依然需要执行资源预算,不绕过准入。并发拓扑改变时重审预算。 + +### D12:宿主 INPUT + FORWARD 的同源隔离(本轮变更) + +**否决**仅补 DOCKER-USER(不覆盖 INPUT)、只看 `--internal`(不证明宿主入口关闭)、在 manager 容器里加 NET_ADMIN(作用域错误且扩大权限)、允许整个 backend 端口给附件(附件也应经 egress)。选定:保留 egress 唯一出口,安装侧 root 工具维护宿主自有规则,backend 持有短时有效的独立隔离证明,新部署消费证明。 + +现有 `runtime_manager.egress` 已有规则渲染和 CLI,扩展同一规则模型,不另写两套白名单。具体安装、报告和准入合同见 §4.4。已建立连接也不能用宽泛 `ESTABLISHED` 一律放过,必须区分应用响应与应用主动发起。真实宿主配对探测是依据,命令存在不等于隔离成立。若后续采用经过验证的 eBPF/专用网络插件,可替换证明生产方,不能取消拒绝边界。 + +### D13:详情页扩展点 + +保留发布 / 版本 / 数据 / 日志页壳与命名 slot。F055 密钥管理放既有 settingsSlot,不为安全项新增管理菜单。壳变更时需检查三个 Feature 消费者。 + +### D14:运行日志与访问记录分开 + +stdout/stderr 是调试日志;backend 入口 allow 产生合并窗口访问记录;F056 的读接口不创造新访问事件。保留既有合并窗口实现/配置,本轮不改频率。安全拒绝不计成功访问;日志包含配置原因但不含身份材料。若审计语义变更,应先改规格。 + +### D15:收尾边界与交付声明 + +旧 MVP “后置”不再表示未实现:F086、F059 已有实现/证据,本轮只修改与安全/密钥刷新直接相关的接口。历史未完成验收不自动勾选,ARM、完整离线安装和正式镜像发行不冒充本轮完成。只在本轮 AC 定向证据成立后更新相应任务。 + +## 4. 系统现状(接手必读) + +### 4.1 数据流与实现状态 + +**已实现**:backend 保存应用/版本/操作/路由/存储绑定;Celery 调发布管线;manager 拉取持久计划、编排 Docker/K8s、上报事实;app-proxy/egress 只读投影。`RuntimeDeliveryCoordinator.inject_environment` 使用加密 sealed_env 保存可恢复执行环境;K8s 已用操作专属 Secret + `secretKeyRef`,并非把平台 token 放 ConfigMap。 + +**尚未实现的本轮安全增量**:INPUT 规则与可信证明、OBO 无效时拒绝。F055 用户确认重建与运行快照公共适配已在 `a5c7722b9` 编码并完成定向本地检查,尚未真实部署验收。现有 `egress.firewall_rules` 仅渲染 FORWARD/DOCKER-USER 规则;`_firewall_check` 主要检测后端存在。现有 `_issue_obo_token` 返回空时两个入口仍允许,是 T099 真缺口。 + +```mermaid +flowchart LR + Browser --> Nginx --> Proxy[app-proxy] + Proxy --> Auth[backend entry authorization] + Auth --> Gate{identity configuration valid} + Gate -->|no| Deny[unavailable; no identity or access record] + Gate -->|yes| Route[backend route projection] + Route --> App[application] + App --> Egress[egress-proxy] --> Platform[platform APIs] + Installer[host root installer / verifier] --> Firewall[owned INPUT and FORWARD rules] + Installer --> Report[backend host isolation report] + Report --> Admission[new Docker deployment admission] +``` + +### 4.2 既有公共合同(保留原编号语义) + +**① runtime-manager 意图 RPC**:旧 `POST /v1/intents/{build,deploy,stop}` 兼容通道与当前持久 v2 通道不可混淆。当前 backend `RuntimeDeliveryCoordinator` 创建 RuntimeOperation、wake manager;manager 以 node/role HMAC 从 `/api/v1/internal/app-runtime/v2/nodes/plan`、`operations/{id}` 拉取并 claim、上报 facts。冻结字段见 F086/F059,public caller 不可注入 sealed_env/runtime_env。旧 code_object_key 直读 MinIO 的设计不再使用。 + +**② backend 状态动作 / 读口**:`AppStateService.{publish,manual_publish,stop,resume,delete,stage_version}` 仍是 F055 的领域入口;`GET /api/v1/apps`、`/{id}`、`/{id}/instance`、`/{id}/versions`、`/{id}/logs`,PATCH 元信息与 actions 路由保持。版本列表只读,不提供绕过审批的直接切版本;`AppMetaService.update_meta` 为管线与页面共用。runtime-status 只供有权管理员;owner/租户范围由读服务决定,不拿 UI can 标志当后端权限。 + +**③ app-proxy 注入头**:`X-BiSheng-User-Id/User-Name/Tenant-Id/Dept-Id/Dept-Name/Dept-Path/Subject-Kind/App-Id/Access-Token/Request-Id`;姓名/部门等非 ASCII 值用现有 percent-encoding;入站 header lower + 下划线→连字符归一后剥离整个 x-bisheng 等价类。X-Forwarded-Prefix/Proto/Host 由 proxy 覆盖,不信客户端。OBO 现已有消费者,不能继续引用旧“可无 token 放行”例外。 + +**④ manifest 消费**:形态归 F055;runtime/port/tier/database/egress/delivery/startup 由当前 schema 冻结后进入执行,见 F055 design §4.2③。新增业务 secret 名仅消费可信 formal 环境,不允许任何运行形态覆盖保留名。 + +**⑤ 环境变量**:`BISHENG_APP_DB_URL`、应用 base path、平台/模型/storage endpoint 与 operation-bound 凭据仍按 F086/F059 注入;本轮不改已有公开 SDK 变量名。正式、preview、probe 的身份/数据空间不可混用,构建进程不取得正式环境。 + +**⑥ SQLite / 日志**:DB URL 指向运行实例的 `/data/app.db`;Docker bind 与 K8s 独占 PVC 的宿主/集群映射由执行层持有。日志 `tail/since/keyword`、状态和数据动作先验 owner/租户;backend 只传意图,manager 执行备份/数据维护,经 backend 传快照。 + +### 4.3 关键模块职责 + +| 模块 / 落点 | 做什么 | 不做什么 | +|---|---|---| +| `app_runtime/domain/services/entry_authz_service.py` | 统一 OBO 配置检查与正式/预览判定 | 不接触容器、不匿名降级 | +| `bisheng/main.py:lifespan` | 运行时开启时输出脱敏启动诊断 | 不因此阻塞整个平台 | +| `runtime_manager/egress.py` + 新 host verifier CLI / unit | 同源渲染、宿主安装、只读验证、配对 canary 报告 | 不持有应用真实密钥,不清全局防火墙 | +| `app_runtime` 新 host isolation Repository/Service/Endpoint | 验证独立角色、保存证明、节点准入读取 | 不执行宿主命令、不访问 Engine | +| `RuntimeOperationService` / `RuntimeDeliveryCoordinator` | 部署、恢复、用户确认重建准入和原有围栏 | 不拥有 F055 密钥配置资产 | +| app-proxy / egress-proxy | 入口/出口数据面、受限投影;入口 allow 不缓存身份 | 不签 OBO、不自行上线 | +| `app_storage_service` / backend 对象存储适配 | 源码/附件/快照传输授权 | 不替 manager 执行 SQLite 迁移 | + +### 4.4 本轮增量字段与接口合同(拟实现) + +**① 入口**:正式 `/apps/{slug}`、预览 `/apps/_preview/{session}/...` 保留现有 app-proxy → backend 内部 HMAC 协议;拒绝体仅 `decision=unavailable, reason=identity_config_incomplete` 与已允许返回的最小上下文;不得带 `inject_headers`、`obo_token`、`obo_expires_at` 或 target。登录/不存在/跨租户/无权限/停用仍先返回对应旧决策,不因配置错误暴露对象存在。 + +**② 操作与身份**:RuntimeOperation 的 `scope_key/idempotency_key/generation/owner_epoch/deadline` 继续约束生命周期;K8s 额外 controller epoch、UID 与数据 writer 围栏。内部 HMAC 角色按 manager / entry / egress 隔离,传输受 TLS/受控网络保护;HMAC 只认证请求,不提供加密。F055 手动重建语义见其设计 §4.4③:保存不创建操作;rebuild_current 复用既有 deploy,running_environment_snapshot 只向受限内部调用提供当前正式环境 DTO;不写 F055 配置或 applied 状态。 + +**③ 宿主规则合同(拟新增)**: + +| 路径 | 规则与验证 | +|---|---| +| iptables | 自有 `BISHENG_APP_INPUT` / `BISHENG_APP_FORWARD` 链。宿主 INPUT 在其他宽泛 accept 之前跳转;FORWARD 由 DOCKER-USER 首部跳转。跳转匹配实际 app bridge 与源子网;自有链内只允许固定 egress IP/3128 的 ORIGINAL,以及源 app、`ctdir REPLY`、ESTABLISHED 的合法入口/探活响应,其余 DROP。`RELATED` 不宽泛放行 | +| nftables | 自有 `inet bisheng_egress`,同时具有 input / forward hook,优先级先于普通 filter 接受;匹配 bridge/源子网,允许条件与 iptables 同源生成。nft accept 仍需通过后续链,不能承诺绕过已有宿主拒绝策略 | +| 更新 | root 安装工具获取独占锁,先构建新规则并 dry-run 校验,再以 iptables-restore `--noflush` 或 nft 单事务切换;初始化跳转失败保持/恢复拒绝态,不先删全部规则再逐条添加。重跑不重复,卸载只移除有标识的跳转/自有链表 | +| 动态地址 | installer 通过 Engine 网络检查冻结 network ID / bridge / 子网 / egress IP、端口、宿主网关/发布地址。代理 IP 变化立即使旧配置证明不匹配,重新安装校验;不在 L3 中放行 hostname 或不受限端口 | +| 不支持 | IPv6/未知 family、Docker rootless/不可确认 bridge、不可识别防火墙后端先报告缺能力;不静默省略一部分网络。K8s 使用 F059 的 CNI 证明,不套用宿主 Docker 规则 | + +**④ 隔离证明与持续收敛(拟新增)**:安装工具与 systemd oneshot/timer 位于宿主,30 秒刷新,60 秒过期;首次安装、宿主重启、dockerd 重启后先验证再放行新部署。定时 unit 不仅验证:发现自有规则漂移后,在同一宿主独占锁下重建自有规则、读回并执行允许/拒绝 canary 配对;仅完全通过才重新签发 pass。恢复失败时由宿主工具针对该 node/network 的受管应用执行网络隔离;不能确认隔离时,通过受控 Engine API 停止这些应用执行体并读回状态,报告失败及资源 ID。禁止扩大到无本模块标签的业务容器,禁止因停止请求已发出就记录停止证明。backend 消费失败证明阻止新执行/路由,manager 沿原生命周期协议确认已停止或隔离的旧实例;未知 writer 不释放容量或删除 PVC/数据。manager 不获得 root/NET_ADMIN,宿主修复不运行在 backend。工具复用现有 Python Engine HTTP 客户端;需要宿主 root、相应 iptables/nft 二进制与受控 Engine API,不要求 docker CLI。 + +新增 `RuntimeHostIsolationReport`(F054 所有,平台级节点表):`id,node_id,engine_id,network_id,config_digest,rule_digest,revision,checked_at,valid_until,status,checks`,通用 JsonType;checks 仅结构与探测结果,不能含环境/用户流量。`POST /api/v1/internal/app-runtime/host-isolation-reports` 只接受独立、限定 node 的 `host-isolation` 服务角色;manager/entry/egress 无上报权。服务端分配接收时间、限制 TTL、递增 revision/nonce、验证节点与 engine/config 绑定,不信客户端自报无限有效期;重放/倒退拒绝。独立报告表避免 manager heartbeat 覆盖证明;不改 F059 现有摘要算法。 + +生产方为 root 安装脚本,信任边界是客户宿主管理员;不声称防住 root 伪造。报告只在**规则读回匹配 + 实际 canary 允许/拒绝配对**均成功后标 pass:无业务凭据/PVC 的受限 canary 在 app 网络,测试直连宿主网关、宿主服务地址、published address 被拒绝,同时入口 HTTP/WS/探活响应与经 egress 的平台 API 可达。所有探测端点由管理员固定,拒绝把请求参数当任意扫描目标;canary 有 UID/ID、期限和自有标签,未知资源不强删。 + +backend 在新 prepare/probe/preview/deploy/restore(含恢复和 F055 用户确认重建)的 create/claim/提交服务路由前验证新鲜证明及冻结配置一致性;失败给缺能力原因。stop/destroy/清理仍允许,既有应用不强制删除;其持续隔离由上述宿主 unit 恢复/隔离闭环负责,不能仅拒绝新部署却让存量实例继续直连宿主。证明过期时在途操作不发布新路由,可恢复地等待至原 deadline;不能绕过旧 writer 停止确认。安装报告通过不代替实际业务 API 的权限检查。 + + +## 5. 已知坑 / 反直觉事实 + +| 坑 | 后果 | 处理位置 | +|---|---|---| +| DOCKER-USER 不覆盖宿主 INPUT;internal 网络仍需实际验证 | 应用绕过代理直连数据库或管理端口 | `egress.py` INPUT+FORWARD 与 host verifier | +| 容器内 iptables 成功不等于宿主规则生效 | 假阳性准入 | 宿主 CLI / 独立报告角色 | +| 应用发出的入口响应源地址仍是应用子网 | 无 REPLY 例外会把正常 HTTP/WS/探活阻断 | 同源规则模型和真实配对测试 | +| 宽泛 ESTABLISHED 也包含以前建立的恶意 ORIGINAL | 新规则看似生效、既有绕过仍通 | ctdir 与连接方向断言 | +| `_issue_obo_token` 当前返回空仍 allow;现在已有 OBO 消费者 | 登录身份失效但请求被放行 | 正式/预览统一检查和签发器兜底 | +| 身份材料先生成后才拒绝 | 即使未转发也可能留下不应产生的头/日志 | 在 `_identity_material` 前检查 | +| manager API 编排权限仍高于普通服务 | 合并进程扩大入侵面 | socket proxy / RBAC / 角色隔离 | +| 版本回滚不等于 SQLite 回滚 | 旧代码运行新 schema 或误覆盖数据 | F055/F059 显式快照恢复 | +| 旧任务完成与真实环境验收不同 | 把静态/模拟误当上线证据 | 任务证据类型与固定 SHA/digest | + +旧坑编号的历史引用按 `dda16a6` 的本文件读取;不把其中过期的“尚无 OBO 消费者/manager 直连 MinIO”继续当现状。 + +## 6. 对外契约与依赖 + +### 6.1 我提供给别人的(Outgoing) + +| 提供 | 消费者 | 破坏风险 | +|---|---|---| +| 现有 App 状态动作、内部路由/entry 协议 | F055/F056、app-proxy | 改状态/错误决策会破坏显示和转发 | +| OBO 合法配置才签发;TTL/aud/app/tenant 绑定维持现有值 | F051 模型面、F055 能力总线、SDK | 放宽空 token 或共享 JWT secret 会破坏身份边界 | +| 持久操作围栏 + F055 用户确认重建/受限运行快照 | F055、Docker/K8s manager | generation/operation payload 缺失时应显式拒绝,不能旧版静默忽略 | +| 独立宿主隔离报告与节点能力原因 | 部署/运维/状态查询 | 把 manager heartbeat 当隔离证明会扩大信任面 | + +### 6.2 我依赖别人的(Incoming) + +| 依赖 | 形式 | 风险 | +|---|---|---| +| F086 持久操作 / F059 controller 与 writer 围栏 | HTTP、DB 与执行事实 | 旧 manager 不支持确认重建/敏感输出协议时拒绝 | +| Docker Engine / socket proxy;宿主 iptables-restore 或 nft;systemd timer | 宿主安装条件 | 无法证明 INPUT 或 family 时阻止新部署 | +| Redis nonce;平台 DB;部署密钥与 OBO 独立配置 | 基础服务 | 不可达或配置失效不能新发身份/伪造 pass | +| F048 PermissionService 统一授权入口 | Python application protocol | 不绕过 verified target 或复刻 OpenFGA | +| F055 密钥版本/注入契约;F056 审计读侧 | 领域协议 | 本模块只消费,不复制密钥表/查询规则 | + +## 7. 测试与可观测 + +- 单元/模拟只覆盖变化:INPUT/FORWARD 双后端渲染、方向、幂等、失败恢复、未知 family;证明角色/过期/重放/配置改变与部署拒绝配对;正式/预览三种坏配置与有效配置的决策、无身份/无成功访问副作用;proxy 曾获 allow 后 backend 改坏配置的下一次请求/WS 握手拒绝,route 缓存不干扰此 Gate。 +- 集成只验证新表/租户无关节点范围、迁移单 head、manager 用户确认重建复用围栏。DM8 仅类型/约束/迁移静态审查,不写成 DM8 真测。 +- 真实 E2E:在授权专用 Docker 环境先保存自有规则前后差异,执行 canary 直连宿主拒绝 + HTTP/WebSocket/探活 + 经 egress SDK/模型/附件允许;重跑、重启、精确卸载对其他网络无影响。OBO 使用已授权自然人 owner/可见非 owner/不可见/跨租户配对,正式与审批预览都验证。不得把失败的 TCP 连接当“防火墙拒绝”而不先证明目标监听可达。 +- 定向命令(编码后按实际文件名执行):`cd src/runtime-manager && uv run pytest tests/test_egress.py`;`cd src/backend && uv run pytest test/app_runtime/test_entry_authz.py`(补充测试以最终任务命名为准)。手动入口 `/apps/{slug}` 与 `/apps/_preview/{session}/`,运维 `python -m runtime_manager.egress firewall --print` 先看差异,再由 root 在专用宿主安装;未确认设计前不执行变更。 +- 可观测:`identity_config_incomplete`、`host_isolation_missing/stale/mismatch/failed`、node/operation/canary ID、规则 revision;不记录密钥、token、会话或完整环境。失效不刷屏,状态面保留当前失败原因。 + +## 8. 后续改进 / 不打算做的事 + +本轮不支持 Docker IPv6/rootless 网络;检测即拒绝,不默认为安全。不新增通用宿主防火墙产品;验证工具只覆盖托管子网。K8s CNI/跨节点/ARM 扩展留在原 Feature 的待验清单,不借安全收尾宣称全部通过。完整设计确认后拆增量任务,保留 T098/T099 原证据与关联。 + +## 修订历史 + +| 日期 | 变化 | 依据 | +|---|---|---| +| 2026-10-23(二次修订) | 按 AC-70 修正 OBO 配置不完整提示;保持原授权顺序和正式/预览同源 | 用户要求修订后复审,尚待设计确认 | +| 2026-10-23(简化协议同步) | F055保存不重启;消费用户确认重建与当前运行快照协议,不增加F055结果双写 | 用户已确认F055修订规格;F054自身设计未确认,B1仍待修 | +| 2026-10-23 | 刷新 F086/F059 后的当前快照;补 INPUT 隔离证明、OBO 拒绝设计;保持 D1–D15 与 §1–§8 编号 | 用户确认增量规格;静态核对基线代码,尚未实现/部署 | +| 2026-08-17~18 | 初版与早期 MVP 修订 | 历史正文见 `dda16a6`;历史测试不充当本轮证据 | + +### 2026-10-24 剩余设计问题复审 + +F054-B1 修订:宿主 unit 为规则漂移的实际消费者,先修复并配对验证,失败则仅隔离/停止受管执行体并上报;backend 不承担宿主 root 行为,也不把网络隔离冒充 SQLite writer 停止证明。长尾的多防火墙外部重载竞态、不可识别防火墙后端和非支持网络形态列入提测限制,不能标 pass 或静默放行。F055 用户确认重建与运行快照已编码,当前尚未进行真实部署验证;本设计的宿主隔离/OBO 安全增量仍待单独确认。 + +### 056-app-square-governance + +批准目标 SHA-256:`7136417b9ccd2d354903670e784f6a86acea213bd970e3393cb41ae4cd072c6c`。 + +| 检查 | 结果 | +|---|---| +| 1 | PASS | +| 2 | PASS | +| 3 | PASS | +| 4 | PASS | +| 5 | PASS | +| 6 | PASS | +| 7 | PASS | +| 8 | PASS | +| 9 | PASS | +| 10 | PASS | +| 11 | PASS | +| 12 | PASS | +| 13 | PASS | +| 14 | PASS | +| 15 | PASS | +| 16 | PASS | +| 17 | PASS | +| 18 | PASS | +| 19 | PASS | +| 20 | PASS | +| 21 | PASS | +| 22 | PASS | +| 23 | PASS | +| 24 | PASS | + +# Design: 应用广场、治理与运行记录查询 + +> **2026-10-23 增量设计评审稿,待用户确认,未编码。** 现状基线 `5f2541f4bc483c24624fede9031c88c4ec6f58f4`;规格确认提交 `dda16a6502908ba8d58cff61dc350c0213b86c19`。保留 D1–D11 与 §1–§8 编号,新增 D12–D15;历史 MVP“查询全后置”已不适用于现有操作审计。 +> +> [spec.md](./spec.md)(61 条 AC)· [tasks.md](./tasks.md) · [release-contract.md](../release-contract.md)。本次新增 AC-46 至 AC-61,修订 AC-26、AC-34;不接管模型/能力/访问记录的业务写入者。 + +## 1. 目标与非目标 + +保留应用广场和统一授权交互,在现有“审计日志”页增加“应用运行”页签,查询/导出模型调用、能力调用和应用访问三类既有数据;让现有操作审计能够按应用找到密钥变更与凭据签发/撤销事件。 + +非目标:计费结算、全链路 tracing 产品、日志全文搜索、把 runtime stdout 当审计、重算历史身份/Token、为每次页面查询生成业务访问事件、给 owner 或开放 API 凭据授予管理员日志读取能力。 + +## 2. 关键约束 + +- 管理员级运行记录只接受自然人平台会话:租户管理员只查有效当前租户;全局超管无 admin-scope 时可明确选全局/目标租户,有 scope 时锁定该租户。部门管理员/只有 log 菜单的角色/应用 owner 不能因此取得此新读面。 +- 现有系统操作审计拥有更宽的菜单/管理范围规则,**不直接复用其 `_resolve_audit_groups` 当新接口权限**;保留旧接口行为。 +- 三类记录并不等于三类成功操作:无 app_id 的服务账号模型调用仍应显示;访问记录是入口 allow 合并窗口,不能展示为每个 HTTP 请求;缺历史字段用 ID/未知,不回填当前无关名称/用户。 +- 列表默认最近 7 天,可选择任意合法自定义区间或全部已留存时间;UTC ISO8601 带时区传输/规范化,start≤end;limit 1–100。**不设 90 天查询或 10,000 行导出总量上限**,导出覆盖所选条件下全部受授权记录。内部每批例如 500 行、worker 并发/租约和按字节输出分块仅控制资源,不变成用户可导出总量限制。 +- Constitution Check:遵守 [constitution.md](../../../docs/constitution.md) C1–C8。新读侧 Model/Repository/Service/Endpoint 分层;租户自动上下文不手拼通用 tenant 条件;全局超管进入显式审计 scope;通用索引/类型无 JSON_EXTRACT;模块错误码 163 段登记后使用;导出在受限对象存储不放 API 本地盘;无铁律例外。 + +## 3. 方案对比与选定 + +### D1:广场继续现有 UNION 与批量权限 + +不新建平行应用列表后在前端拼接。托管应用 `flow_type=35`、slug/app_state 已随列表下发,批量可见性继续走 F048;单卡补拉权限造成 N+1 且越过通用策略。新资源类型/统一列表协议改变时再议。 + +### D2:卡片整页访问稳定入口 + +保留跳转 `/apps/{slug}`,不嵌 iframe、不拼 client `/workspace` base;统一登录/WS/身份均在入口。切换域名模型时再评审链接生成。 + +### D3:广场动作与可见范围仍由通用权限驱动 + +`app` 适用动作集与广场 use 筛选保留,不把“能看到卡片”作为运行记录管理员权限。二者属于不同业务目标。动作 Catalog 改变时审查消费者。 + +### D4:管理权限仍复用 PermissionDialog + +构建卡片与发布页 VisibilityScopeSection 用同一交互/API;不为了审计页再建授权入口。当前 request 拒绝错误已需 catch,异步失败不能留 loading。组件契约变更时复核两个入口。 + +### D5:仅 owner 可见继续按 protected grant 区分 + +非 protected 授权行为空才表示无额外放行;默认 owner 行不应算外部授权。不从空列表误推断“无人有权限”。权限返回 schema 改变时重审。 + +### D6:可见范围变更由真实写路径审计 + +仍在统一授权 mutation 后按资源回调写 `app.visibility_change`,不从前端补写。查询/导出仅消费事实。新增授权写入口时必须核对回调是否覆盖。 + +### D7:访问记录写 F054、读 F056 + +`entry_authz_service` 已写合并窗口访问事实,不再“写读一起顺延”。保留既有合并窗口及其测试,本轮只补稳定读/导出适配,不按请求量补记历史访问。日后产品需要 PV/会话分析另立语义。 + +### D8:runtime 关闭明确隐藏与拒绝 + +新页签和新列表/候选/导出接口都读取实际 `app_runtime_enabled`;关闭时后端给部署开关错误,不把空列表当安全机制。旧系统操作审计照常可用。开关语义变更时再议。 + +### D9:下线应用仍可进广场 + +广场对托管应用白名单保持 online/stopped,不带草稿/待上线/删除;历史日志则不能以这组存活状态过滤,否则删除后记录消失。是否显示已删除资源是两个读面的不同合同。 + +### D10:广场名称搜索与日志候选分开 + +不拿可见应用广场列表当审计候选;它缺已删除资源、范围也不同。日志名称搜索基于受限审计记录/可信历史映射,只在当前管理员 scope 生效。若统一搜索服务支持历史治理数据再替换。 + +### D11:当前查询交付范围 + +现有 `api/v1/audit.py` 与 `api/services/audit_log.py` 已有系统操作审计、target_app_id 与导出;LogPage 仍只有“应用使用/系统操作”两页签。本轮加第三页签与三子视图,不重做旧审计或重写广场。真正已落地必须以实际调用点而非 tasks 计数判断。 + +### D12:三专表适配成管理员读服务(新增) + +否决运行期高频记录塞普通 AuditLog、查日志文本推导、另造统一大表/回写历史。选定 F056 的 `RuntimeAuditQueryService` 调三个 Repository 适配器:现有 ModelCallRecordRepository(F051 写)、AppCapabilityCallRecord(F055 写)、AppAccessLog(F054 写)。现有模型读库有 bypass+手动 tenant 模式,新入口改用明确 `AuthorizedRuntimeAuditScope`,旧调用兼容由 owner 控制,不向客户端暴露 `tenant_id=None` 作为全局授权。 + +共享过滤/分页/导出规格,但不同视图字段保留语义;服务账号模型调用不强制 inner join App。证据是现有三种表字段不一致,只有模型表已有 cursor/export,能力表只有 insert,访问表已有旧 page DAO。新建读 Repository,不加新 DAO 入口或让 Service 写 SQL。未来跨类型关联检索成为真实需求才考虑物化统一索引。 + +### D13:稳定列表与一次导出的固定读取视图(修订) + +否决仅按秒级时间排序、offset 翻页、开放式追随新日志的无限导出,以及把 10,000 行/90 天预算当产品限制。列表采用 `(event_time DESC,id DESC)` keyset,首次查询固定 filter/scope 摘要、upper_time 与当前可见最大 id 的 watermark,cursor 绑定该选择条件;未知时间单独排序并以 id 决胜,不能在 NULL 比较上丢页;all 保留未知时间行,recent7d/custom 不能判入区间的未知时间行不伪造时间,筛选语义明确说明。新请求严格校验 cursor,不把解码失败当第一页。 + +列表只保存短时 `RuntimeAuditQuerySnapshot` **条件元数据**并返回第一页;不为了列表先收集所有 ID,不依赖 MinIO 才能看数据。刷新/改变条件重新选择范围。列表与随后导出共享冻结的条件、watermark 和权限;并发写入下不额外承诺两个不同读取时点的数据库成员集合完全相等。导出会展示其实际选取时点及最终行数,不把导出完成数冒充此前列表的即时统计。 + +**完整导出在后台的一次受权读取视图中完成**:任务受理时冻结 QuerySnapshot 的过滤/范围,source-owner Repository 在 worker 开始扫描时打开一条、范围有界的 ORM SELECT 只读一致游标,按稳定顺序流式输出全部匹配记录。该语句的读视图固定、watermark 固定;后续新增/回填提交不能把本次扫描变成无限流。每批最多 500 行处理/输出,但一直扫描至该读取视图 EOF,不能达到某行数后拒绝或截断。无资格/跨租户条件在源 SELECT 构造/执行时仍走自动租户过滤或显式授权的全局 Repository,而不是把多租户约束留给 CSV 写入端。 + +source Repository 提供 `stream_export(scope, frozen_filter, watermark, batch_size)`,持有只读 source session/游标至本 attempt 扫描结束;任务进度写入用另一短事务 session,不在源读取事务中写状态。底层必须采用真正流式/受限 fetchmany,不用 `all()/fetchall()` 把整表搬进内存;MySQL/DM8 由各 source-owner 适配其固定读视图游标/事务,定向检查驱动的实际缓冲和语句范围,不能仅设置 yield_per 就宣称有界。占用一个后台读连接/读视图至扫描结束是此方案的显式成本,以导出 worker 并发、可观测持续时间及运维取消控制,不在 HTTP 请求保持长事务,也不转成 90 天/行数硬门槛。 + +worker 崩溃后无法恢复数据库游标读视图,因此未 ready 的 attempt **整次只读重建**,不拿旧游标偏移接新视图造成漏/重;同一冻结条件与 watermark 下重新开启读视图,显示新的 selection_started_at。旧 attempt 从未交付字节,只保留待清理私有分块;只有一次完整扫描的 manifest 才能原子发布为 ready。ready 后的文件成员与字节固定,重复下载不会重新查事实表。若历史清理/读取故障导致不能完整输出,显式失败,不标空文件或部分文件成功。 + +### D14:受权异步导出与不可变分块产物(修订) + +否决公开 presigned URL、前端只导当前页、提交时授权永久放行。选用后台固定读取视图 → 标准 CSV → MinIO 私有不可变分块 + 有序 manifest;backend 认证下载按 manifest 依次流式拼接为一个 CSV,不暴露对象 URL。CSV 标题/BOM 只在首块,每个分块在完整行边界收束。任务状态为 pending/running/ready/failed/expired;总记录数不设产品上限。 + +任务通过 DB 租约/epoch 取得单一输出权;对象 key 绑定 job/attempt/epoch/part_no,上传确认后短事务登记分块及累计行数。ready 的 CAS 必须同时校验当前 lease/epoch、同一 source read-view attempt 已 EOF、全部分块已确认且 manifest 有序/完整。旧 worker 只能遗留未引用的私有对象,不能覆盖新 attempt 或把任务标 ready;响应丢失重查持久 ready/manifest,不能追加重复 CSV 行。失败或重启按 D13 从头读取新 attempt,不拼接不同读视图的分块。 + +受理、worker 开始/每批和 ready 发布、状态读取、下载开始/受限块之间都核验当前自然人管理员资格与保存的 scope。失权或无法确认即取消交付/停止任务,不使用提交时缓存永久授权;已交付字节无法收回。条件快照未被任务引用时 10 分钟过期;任务受理后其冻结条件由任务持有,不因排队/扫描超过 10 分钟被清理。**下载保留期从 ready_at 开始计算 10 分钟**,未完成 attempt 由租约/取消和终态 GC 管理,不能用该保留期限制记录总量或扫描时长。 + +CSV 仅白名单字段;危险公式文本前置单引号后按标准 CSV 转义,测试空白/控制字符绕过、逗号、引号、换行与 Unicode;数值按数值,null 不替成其他主体。对象/worker/source 不可用时明确失败,GC 只删没有活跃 lease/ready manifest 引用的本任务对象;就绪产物到期/取消清理有 durable 状态,不依赖本地盘。未来确需更高吞吐再独立调度,不改变完整导出的规格。 + +### D15:可信应用关联的操作审计(新增) + +F055 负责写 app.secret.*;凭据 owner 在签发/撤销时从持久 `ApiCredential.subject_kind/subject_id` 与可信 hosted-app 归属映射计算 target_app_id,不取查看者传的 app_id;SAK/PAT 的资源 owner 不等于 app 归属,不能把该人的所有 key 自动关联给某应用。 + +现有 CredentialService._audit 只写 credential_id/subject/scopes/key_mask,需要补可信关联。补充实际生产者:正式切换和候选终结当前在 RuntimeProjectionService 中将 binding 从 staged/active 改为 revoked,并不经过 _audit。改由凭据 Owner 的公开 `revoke_runtime_binding(session, binding, operation, reason)` 协议,在调用者原事务中锁定 binding、只在真实状态改变时撤销并追加 `open_api.api_key.revoke` 审计。应用关联从绑定的 app/credential 与 hosted-app 主体映射交叉核验获得,不接受查询者提供的归属;审计带 app_id、credential_id、operation_id、reason,事件不带业务环境变量值。以同一 binding 的状态转移作为去重围栏,重复切换/终结不重复记事件;失败回滚状态与审计,不新增审计补偿服务或独立数据库提交。切换撤旧、停止/删除、失败候选清理都调用该协议;签发使用已新增的显式 session 入口,签发审计也带可信 app 关联。 + +历史记录只能在原始可信字段足以确定时通过只读关联查询;不补写历史“现在属于”。不增加第三方业务密钥末位/长度,API key 原有前缀/末位 mask 继续保留。若普通凭据没有可信 app 绑定,明确不相关,不硬凑。后续新 credential owner 需提供同一审计关联协议。 + +## 4. 系统现状(接手必读) + +### 4.1 数据流与实现状态 + +**已实现**:广场第三类型、两处权限 UI、可见范围审计、系统操作日志查询与应用筛选;模型表/能力表/访问表真实写入路径。**本轮未实现**:管理员 RuntimeAuditQueryService、新 endpoint/候选/稳定导出、新第三页签、可信凭据关联补齐、访问 app 名称快照。 + +```mermaid +flowchart LR + UI[LogPage / Application runtime] --> API[F056 session API] + API --> Scope[server verified admin scope] + Scope --> Query[query service / typed filters] + Query --> M[ModelCallRecord repository] + Query --> C[Capability record repository] + Query --> A[Access record repository] + Query --> Snapshot[frozen filters and watermark] + Snapshot --> Job[authorized worker: stream one fixed read view] + Job --> M + Job --> C + Job --> A + Job --> Store[immutable CSV chunks and manifest] + Store --> Download[backend authenticated download] + Download --> Check[current role and scope check] +``` + +### 4.2 既有公共合同(保留原广场/审计语义) + +| 字段 / 入口 | 当前约定 | +|---|---| +| 广场列表 `flow_type/slug/app_state/can_share/user_name` | 托管类型 35;slug 为稳定 `/apps/{slug}` 入口;应用态仅 online/stopped;can_share=false;owner 显示沿用既有列表投影 | +| GET `/api/v1/chat/online` | 现有 page/keyword/tag/type 等筛选;服务端收窄 app 状态和 use 动作,不信客户端 action 扩权 | +| PermissionDialog | resourceType=app,resourceId=内部 app.id;两入口共用,protected owner grant 不算额外放行 | +| app.visibility_change | 变更人在真实授权 mutation 后回调记录;app/slug/added/removed 元数据;不由查询写入 | +| 事件登记 | 后端可见 action/namespace、领域 action、前端 log.ts、三语事件名保持 lockstep | +| 现有系统操作审计 | `/api/v1/audit` 和 `/audit/export/data` 已支持 target_app_id/tenant 筛选;不因新增 runtime 读面改变旧菜单/管理范围语义 | + +### 4.3 关键模块职责 + +| 模块 / 新增落点 | 做什么 | 不做什么 | +|---|---|---| +| `app_governance` F056 读侧(按既有领域目录落点核对后实施)Endpoint/Service/Repository | 受权过滤、分页、候选、snapshot/export | 不重新写模型/能力/访问事件,不查 OpenFGA 原始 client | +| 三个 source-owner Repository 读适配协议 | 返回白名单列表页与固定读视图流式 export;持有源只读 session,保持事件语义 | 不从通用 Service 跨层 ORM 查询,不 fetchall,不在源事务写任务状态 | +| `open_api/.../credential_service.py:_audit` 与其可信 app resolver | 签发/撤销时写 app 关联 | 不由 viewer 参数、owner 人员关系猜 app | +| `app_runtime/.../app_access_log_service.py` | 新事件可选 app 名称快照,合并窗口不变 | 不回填历史事实、不记录拒绝为成功 | +| `pages/LogPage/runtime/`、API/hook(新增) | 三视图、filters/table/export、错误和过期状态 | 不含秘密列,不读凭据明文接口 | +| `AuditLogService` 与现有 systemLog | 查 app.secret 与可信凭据事件 | 不把 runtime 高频事件塞操作审计 | + +实施前确认 F056 现有代码归属:当前主要在 `api/services/audit_log.py` 与通用列表模块,新增读服务**拟建立** `bisheng/app_governance/{api,domain}`,仅管理本 Feature 读侧,不迁移旧广场/权限代码,不重构通用审计。名称不代表新 feature/domain owner;release-contract 仍由 F056 认领。 + +### 4.4 本轮增量字段与接口合同(拟实现) + +**① 拟新增会话 API**:挂 F056 路由 `/api/v1/app-runtime-audit`;读取者必须通过平台 session dependency,不能走 `/api/v2`。 + +| 接口 | 参数 / 响应 | 校验 | +|---|---|---| +| GET `/records/{kind}` | kind=model/capability/access;time_range=recent7d/custom/all(默认 recent7d);custom 提供 start_time/end_time;app_id/visitor_id,model 额外 credential_id;tenant_id 只缩小 scope;cursor/limit;响应 `{items,next_cursor,snapshot_id,matched_count}` | custom 范围无 90 天上限;all 对已留存时间不加下界;非法/不适用筛选明确拒绝。统计仅在受权范围,尚未计算可为 null,不用 10001 冒充总量;无因记录数而不可导出的标志 | +| GET `/candidates/{kind}` | app/visitor/credential,按当前 kind/time/scope 的历史事实给 `{id,label,deleted_or_revoked}` 候选,分页/搜索有界 | 同查询管理员门禁;credential 只给 mask,无私密 name/value;跨租户对象请求拒绝 | +| POST `/exports` | `{snapshot_id,request_id}` → `{export_id,status}` | 条件快照属于同一调用者/当前合法 scope、未过期,复制条件到任务;不因行数/历史跨度拒绝;幂等 request_id | +| GET `/exports/{id}` | status + safe_reason、attempt、selection_started_at、processed_rows、ready_at/expires_at、ready 后 final_count | 重验资格与 scope;processed_rows 是当前 attempt 进度而非精确总量,重试从 0 开始,不报告部分成功 | +| GET `/exports/{id}/download` | 认证后返回 CSV 流;Cache-Control: no-store | 不返回公开 object key/URL,失权/跨 scope 拒绝;下载开始与每个受限 chunk 之间重验,取消后停止继续交付 | + +列表/候选只读,下载一次不新增访问事实;业务错误遵循现有 envelope,前端使用包裹 request 的 rejected Promise 处理。新增错误码从 163 段核对占用后分配并同步三语,不重用历史退休编号。 + +**② 三视图字段**:event_time 为查询统一排序别名,原始字段语义不变。 + +| kind / 来源 | 响应白名单 | 历史缺项 | +|---|---|---| +| model / `ModelCallRecord` | id、create_time、app_id、actor_kind/id/name、subject_kind/id、credential_id/mask、model_id/name、prompt/completion/total_tokens、result/error_code/http_status、latency/ttft、request_id | app_id 可空(服务账号调用);subject 未知不冒充 owner;Token 空不填 0;无当时 app 名只显示 ID | +| capability / `AppCapabilityCallRecord` | id、create_time、app_id/app_name、credential_id、subject_kind/subject_user_id、capability、result/error_code、chunk_count、latency_ms、version_id | credential mask 仅经受限凭据投影能找到时补;无 actor 快照只表达 app 这个 actor,不虚构用户 | +| access / `AppAccessLog` | id、entry_time、app_id、app_name_snapshot(新增可空)、user_id/user_name、request_id | 老行 app_name_snapshot 空显示 ID;访问为 allow 合并事件,不显示为 HTTP 成功率/逐请求响应 | + +不输出 model prompt/completion、检索 chunks/正文/targets 原始 payload、secret/env/cookies/OBO/token/任意 metadata。仅为新 access 记录新增 app_name_snapshot(128 字符 nullable)与写入参数,不重写合并窗口;历史不从当前名称回填。模型表已有 actor_name/model_name 快照,能力表已有 app_name;不为方便前端用 mandatory live join 丢掉已删除/撤销记录。 + +**③ QuerySnapshot / RuntimeAuditExport(拟新增)**:仍为两个申请者私有实体,不新增永久审计大表或全量 ID 选集副本。`RuntimeAuditQuerySnapshot` 保存 UUID、tenant_id(默认 None/NOT NULL/索引)、creator_user_id、scope_mode/scope_tenant_id、kind、normalized_filter JsonType、filter_digest、watermark、created_at/expires_at;只含条件,不含事实正文或完整 ID 集。tenant_id 取服务端确认的申请者有效工作租户,global 是经超管核验的过滤范围,不以 NULL tenant 绕过自动过滤。 + +`RuntimeAuditExport` 保存同样的申请者/租户/scope、复制的冻结条件和 watermark、snapshot_id、request_id、status、attempt/selection_started_at、lease_holder/epoch/until、processed_rows、final_count、manifest_object_key/digest、ready_at/download_expires_at、safe_failure;唯一 `(creator_user_id,request_id)`。分块对象包含 part_no/行数/字节数/hash,由最终不可变 manifest 收束;任务进度是可重建状态,不需要另建一张事实记录表。对象 key 全部服务端生成,客户端不能指定。 + +worker 按持久任务 tenant_execution_scope 初始化租户过滤,只有源数据读取在已授权的 global source Repository 中 bypass;后台每批重验申请者当前资格,不能只信任务里旧角色。源只读扫描与任务短写事务分开;领取/重试/ready/GC 以数据库 CAS 和租约处理多进程。未引用条件快照 10 分钟清理,受理后由 job 保留冻结条件,下载到期从 ready 算起;失败/取消且无活跃 lease 的孤儿块可回收,活跃扫描不删。 + +**④ Scope / Cursor 安全**: + +- `AuthorizedRuntimeAuditScope` 仅由服务端权限适配生成;自然人角色来自可信登录态与权限 application 协议当前核验,不信请求角色/租户。旧审计允许 log-menu 的逻辑不复用到此处。 +- 普通租户 Repo 在 tenant_execution_scope 下自动过滤;全局超管 Repo 仅在明确授权 scope 下 bypass;客户端 tenant_id 只允许进一步收窄。跨租户 app/visitor/credential ID(含历史 tombstone)在限权解析器中验证,拒绝与未知 ID 对外同样不泄漏详情,不把 foreign ID 默默当“查无”。候选从事实表/可信删除保留信息产生,不能凭当前用户/应用列表扩范围。 +- cursor 是服务端签名/摘要校验的 `{version,kind,scope_digest,filter_digest,snapshot_id,last_event_time,last_id,expires_at}`,不包含明文凭据。切租户/视图/筛选不能重用;解码/签名/过期错明确拒绝而非回第一页。 +- 每次 list/candidates/export/status/download 都重验角色/范围;后台任务实际执行时再验提交者资格。无法确认权限 fail closed;不是仅靠 JWT 缓存角色永久允许。已交付字节不可收回,失权后不开始新下载或继续交付后续 chunk;不承诺撤回浏览器已收到的文件。 + + +## 5. 已知坑 / 反直觉事实 + +| 坑 | 后果 | 处理位置 | +|---|---|---| +| 旧 AuditLogService 允许 log 菜单/部门管理者 | 直接复用会放大新 runtime 数据权限 | 新 Scope resolver | +| ModelCallRecordRepository tenant_id=None 是全局 bypass | 漏传租户变成全局数据泄漏 | typed scope + 专用普通/全局 Repo 适配 | +| 同秒 create_time 常相同,旧 cursor 错误会被忽略 | 翻页重复/漏行/跳回首页 | 严格签名 cursor + time,id 全序 | +| 写入有 write-behind,event_time 不等于提交时间 | 列表与随后导出不同读时点不一定同成员;恢复旧游标会混读视图 | 固定条件/watermark,导出单一读视图,崩溃 attempt 从头只读重建,ready 才发布 | +| live app/credential 内连接会丢删除/撤销行 | 历史审计证据消失 | 以事实为主,可选显示投影/未知 | +| SAK 资源 owner 是人,不代表那人的某个 app | app 筛选错误归因其他 key 的行为 | credential owner 可信 subject_ref 解析 | +| 下载公开 MinIO URL 可绕过后来失权 | 撤权后继续新下载 | 认证代理下载/按块重验 | +| CSV 双引号不是公式防护 | 表格软件执行攻击字段 | 类型白名单 + 首有效危险字符处理 | +| HTTP 失败不是空结果 | 用户误以为无人访问/导出成功 | 独立 error/loading/empty 状态 | +| 应用 allow 访问按窗口合并 | 错读为 PV/逐次网络请求 | UI 字段提示与 F054 同语义 | + +历史坑编号见 `dda16a6` 本文件;旧 300/1800 秒勘误不覆盖当前代码配置,本轮不改窗口。 + +## 6. 对外契约与依赖 + +### 6.1 我提供给别人的(Outgoing) + +| 契约 | 消费者 | 风险 | +|---|---|---| +| 现有广场/授权交互与操作审计 | client/platform、管理员 | 不改变卡片状态与既有日志范围 | +| 三运行视图列表/候选/snapshot/export 会话 API | LogPage 新第三页签 | runtime 关闭/失权返回拒绝,不假空 | +| 按 app 查 secret 变更/可信凭据事件 | 系统操作日志页 | 审计登记四处 lockstep 与三语必须齐 | +| 白名单响应与 CSV schema version=1 | UI/管理员导出工具 | 字段语义与缺失值约定不可悄改 | + +### 6.2 我依赖别人的(Incoming) + +| 依赖 | 形式 | 风险 | +|---|---|---| +| F051 ModelCallRecord、F055 capability record、F054 access log | Repository/Python 数据契约 | 写方列名/追加策略/删除留存改变会破坏快照 | +| F048 可信身份/权限 application,F019 admin-scope | 当前会话+Python | 原始请求 tenant/admin 标志不可当授权 | +| 平台 DB、私有 MinIO、Celery/既有周期清理 | 后台任务/对象 | 不能只存在 writer 本地盘;过期必须回收自有对象 | +| F055 secret 审计与 credential owner 的可信关联 | 元数据 | 不读/存密钥明文;关联不足就未知 | +| 现有 LogPage/Tabs 与共享 UI/包裹 request/locales | TS | 不新增库/样式,Select 未 done 时跟随现存筛选实现 | + +## 7. 测试与可观测 + +- 数据/权限定向:三表正常与空 app_id、null Token/subject;同秒/跨页/晚到写入/删除快照失效;租户 admin/超管 scoped 与 unscoped 允许,owner/log 菜单/部门 admin/审批人/SAK/PAT/foreign filter 拒绝;候选、导出、下载同样配对,角色变化后失败。 +- 导出定向:超过 10,000 行、超过 90 天和全部已留存时间都完整导出;同一成功读取视图的结果和文件完全一致,后台新增/回填不把扫描变无限流;进度批次/内存有界;重复 request_id 不创建重复任务。源扫描中崩溃从头新 attempt、不拼接混合读视图;ready 提交丢响应不重复追加;旧 epoch 不发布,GC 不删活跃对象。权限在提交/执行/下载/分块间撤销;MinIO/source 失败不交付部分文件;下载 TTL 从 ready 起;CSV 注入/Unicode/引号/换行负向验证。 +- 前端:保留两个旧页签,新第三页签里三子视图;选择器无空 ID;filter/kind/tenant 改变 reset cursor,query key 含 scope/type/filter,AbortController/请求序号丢弃旧结果;新错误态与空态分离;导出绑定当前 snapshot 并清楚显示范围/行数,失败结束等待、不显示“已下载”。 +- UI 取当前共享 docs 的 Input/Button/Tabs 等 status=done 组件,表格/筛选沿用当前 `LogPage/systemLog` 及其选择器模式。filter 在窄屏换行、表格横向滚动,不引入新视觉模式/库;多端、字体/色彩按当前规范,三语同交付,不手改生成的 api_errors。 +- E2E 走新会话 API + 实际 LogPage 人工清单:授权测试租户 A/B、tenant admin、global super、ordinary owner;产生模型/能力/allow 记录,按同一受权条件查/导,明确显示实际导出时点/最终数;大于 10,000 行和 90 天范围不被硬拒绝;删除测试 app/撤销 key 后仍查历史;验证 secret 审计没值、无 app 的 SA 模型记录不消失。runtime 关闭新面拒绝,旧系统操作仍可查。 +- 编码后定向命令:`cd src/backend && uv run pytest test/app_governance/test_runtime_audit_query.py test/app_governance/test_runtime_audit_export.py`(拟新增);只跑 `LogPage/runtime` 对应 Jest 文件。复用旧 audit 测试中修改路径的最小集合,不全量重跑;必要 Ruff/arch-guard/迁移 head 单独验证。DM8 仅静态。 +- 指标/日志:query kind、规范筛选摘要、scope、actor、行数、导出状态/过期/失败码;不记原始秘密/URL token/body。查询不伪造模型/能力/访问业务事实。 + +## 8. 后续改进 / 不打算做的事 + +不补造历史 app 名/subject/凭据关联;不建立日志仓库或计费模型;只实现当前审计条件下的完整异步导出,以批次/并发/租约控制资源,不设置 90 天或 10,000 行产品门槛。长导出的一致读视图占用和驱动缓冲必须在定向检查中量化,不以本地临时文件绕过多节点。原 ARM/跨节点/正式镜像发行验收仍保留,不宣称新列表测试证明这些能力。 + +## 修订历史 + +| 日期 | 变化 | 依据 | +|---|---|---| +| 2026-10-23(二次修订) | 去除导出总量/时间硬限制;条件元数据与后台单一读取视图分离;私有分块 ready 原子发布、重启从头只读重建 | 用户要求修订并复审;规格 AC 未变,尚未编码 | +| 2026-10-23 | 刷新已实现广场/操作审计;新增 D12–D15、三视图/稳定导出/权限合同 | 用户确认规格,静态代码核查;未实现/部署 | +| 2026-08-17 起 | 早期广场与治理设计 | 旧正文见 `dda16a6`;历史任务证据不改写 | + +### 2026-10-24 剩余设计问题复审 + +F056-S1 修订:运行绑定的真实撤销生产者由凭据 Owner 公共协议消费,同 session 状态转移与审计,以状态 CAS/行锁去重;查询层不能补造历史事实。保留列表/异步导出的主链路,低概率的外部存储瞬断与 worker 任意指令点崩溃组合列为后续故障验收;权限失效、跨租户拒绝、CSV 安全和完整导出仍属于主功能,不因缩减测试而放宽。实现尚未开始,仍待单独确认本修订设计。 diff --git a/docs/reviews/vibe-owner-tenant-consistency-fix-plan-2026-10-28-critique-r1.md b/docs/reviews/vibe-owner-tenant-consistency-fix-plan-2026-10-28-critique-r1.md new file mode 100644 index 0000000000..7425f64134 --- /dev/null +++ b/docs/reviews/vibe-owner-tenant-consistency-fix-plan-2026-10-28-critique-r1.md @@ -0,0 +1,49 @@ +**REVISE — 方向符合租户隔离和服务账号独立身份合同,但生命周期并发保护尚未闭合;R2 的重新审批与恢复上线边界也缺少可执行约束。** + +| 维度 | 评分 | 依据 | +|---|---|---| +| 1. 需求与质量属性 | green | 保留旧服务账号租户及其他集成,区分人员禁用与迁租户,三条维护路线明确等待用户选择。 | +| 2. 边界质量 | green | F049 提供身份关系事实、F055 决定发布资格、租户模块负责迁移,避免 Manager 直接查询人员表。 | +| 3. 模式克制 | green | 派生关系状态、复用 SQL 和现有发布记录,不引入缓存状态、分布式锁或通用迁移平台。 | +| 4. 数据与状态建模 | yellow | 冻结 owner 和保留审批历史正确;R2 尚未定义旧 current/pending 版本在目标租户的上线资格隔离。 | +| 5. 变化适应性 | green | 跨域公开接口和精确三应用维护作业具有明确用途,没有明显推测性抽象。 | +| 6. 失败与并发现实 | red | 共同用户行锁仅明确覆盖发布接收和迁租户,未覆盖其他新增启动入口;接收重试和资格拒绝后的结算也未闭合。 | +| 7. 决策记录与可读性 | yellow | 已有事实、建议和未执行验证区分清晰,但数项承诺缺少必要机制,应在方案层注明新增工作。 | + +### blocker + +**B1 — [维度 6] 非 CLI 启动入口没有加入共同事务门闸,仍存在“检查通过后人员迁走,再启动实例”的窗口。** + +- **文档锚点:** §4.2“手工上线、停用恢复、配置重建、预览准备”;§4.3 第 1–3 点只规定 `accept` 建 deployment 占位与迁移写入锁同一用户行。 +- **代码锚点:** `src/backend/bisheng/app_runtime/domain/services/app_state_service.py:293–313`,`_start` 在读取、权限检查之后才调用部署协调器;`runtime_operation_service.py:61–104` 再创建持久操作。目前两者均没有共同人员事务锁。 +- **影响:** 一个稳定停用应用没有 active deployment。恢复请求读取 owner 关系通过后暂停,迁移事务检查不到任何在途记录而提交,恢复请求随后建立 runtime operation 并启动。给 §4.2 加一次重读无法满足 §4.3 的两种顺序保证。 +- **建议:** 明确所有会产生新执行者的入口,均须在同一用户行锁下完成“重读资格+持久操作登记”,且登记在操作完全结算前持续阻断迁移。复用已有 runtime operation;旧协议没有持久记录的入口也须明确保护办法。补一条定向交错验证:暂停在资格检查与登记之间,验证迁移和启动不能同时成功。 + +### should-fix + +**S1 — [维度 4、6] R2 的维护保护不能只挡写入;必须挡住旧版本恢复,直到目标租户的新审批完成。** + +- **文档锚点:** §6.2 C 只写“阻断新发布和目标写入”;E 完成新 owner 投影后恢复可管理;F 写“重建……实例;目标租户重新审批迁移后版本”。 +- **代码锚点:** `app_state_service.py:138–140` 按 `pending_version_id or current_version_id` 取版;`:157–179` 的手工上线和恢复直接调用 `_start`;`:301–313` 没有目标租户审批资格检查。 +- **影响:** 保留原 current version 并把 app 改成租户60后,owner 关系检查会重新通过。只禁止业务写入不能阻止恢复旧 current version;F 的文字顺序还允许先重建运行实例、后审批。该结果与“旧审批不复用”“旧版本不因迁移自动获得上线资格”冲突。 +- **建议:** 把 R2 的维护围栏明确为持久阻断 publish、resume、manual publish、配置重建及自动恢复;审批前只允许隔离的准备/审批预览。先创建目标新版本并完成目标审批,再建立正式实例和路由;成功后解除围栏。回退按独立回执恢复源版本资格,不改写旧审批快照或 `terminal_state`。 + +**S2 — [维度 6、7] 接收占位承诺“重试不产生第二个 app”,但当前没有能定位原请求的持久身份。** + +- **文档锚点:** §4.3 第 5 点“API崩溃/入队响应丢失……按确切 deployment_id……重试不得生成第二个app或审批”。 +- **代码锚点:** `publish_pipeline_service.py:197–205` 的 `accept` 输入没有请求幂等标识;`:268–287` 在服务器生成新 version/deployment;首次请求的 `app_id` 也由服务器创建。 +- **影响:** API 在占位提交后、响应返回前崩溃,客户端没有该 deployment_id。重新提交时,仅靠 owner 的 running 占位不能区分“原请求重试”和“另一份新提交”,也不能保证自动续办原请求。 +- **建议:** 明确本次是否新增接收请求标识及内容摘要、数据库唯一约束和按标识找回原 deployment 的接口合同;或者收窄承诺为“未知结果由维护人员按原占位恢复,禁止客户端盲目重新创建”。不要用包哈希单独代替请求身份,同包可以是合法的新发布。 + +**S3 — [维度 4、6] 历史任务在审批通过后遭资格拒绝,如何结算 deployment/outbox 和回收预览尚未确定。** + +- **文档锚点:** §4.2 要求在 `stage_version` 之前拒绝,并保留已审批历史;§4.3 第 6 点依赖消费点拒绝历史失配任务;§3.3 又把未结算 outbox 和预览作为迁移阻断项。 +- **代码锚点:** `publish_online_service.py:142–149` 当前先 `stage_version`,再执行审批终态预览回收;`approval_outbox_service.py:23–48` 依据执行结果分别标记 `SUCCESS/executed` 或 `FAILED/execute_failed`;`:78–84` 允许重试。 +- **影响:** 在 `stage_version` 前直接抛出 mismatch,会同时跳过预览回收,并让审批进入 execute_failed。若 deployment 还保持 waiting_approval,人员恢复原租户或维护收口可能被自己新加的门闸持续阻断;直接正常返回又可能把未执行上线表现成普通成功。 +- **建议:** 明确资格拒绝是可重试的业务阻断还是发布尝试终结,并规定 deployment、outbox、审批展示和预览清理的对应结果。审批“已通过”事实保留;清理不依赖上线成功。至少增加一次“已通过的历史任务→失配拒绝→合法恢复/终结”的定向验证。 + +### nit + +无。R1 和 R3 已明确各自保留与放弃的事实,目前没有发现需要扩大方案范围的问题。 + +本审阅仅核对本地提案和源码,未修改文件或环境,未执行线上请求,也未重新验证提案中的116数据库事实。 diff --git a/docs/reviews/vibe-owner-tenant-consistency-fix-plan-2026-10-28-critique-r2.md b/docs/reviews/vibe-owner-tenant-consistency-fix-plan-2026-10-28-critique-r2.md new file mode 100644 index 0000000000..735902bd13 --- /dev/null +++ b/docs/reviews/vibe-owner-tenant-consistency-fix-plan-2026-10-28-critique-r2.md @@ -0,0 +1,36 @@ +**PROCEED — 最新提案已关闭首轮 B1、S1、S2、S3,可提交用户评审;此结论仅表示修复方向与必要约束完整,不表示实现或运维方案已验收。** + +| 维度 | 评分 | 一行依据 | +|---|---|---| +| 1. 需求与质量属性 | green | 保留旧服务账号独立租户及其他集成,不因人员迁移自动停用在线应用,维护路线等待用户选择。 | +| 2. 边界质量 | green | 身份事实、发布资格、人员迁移与运行执行的职责清晰,共同事务需通过调用方 session 接线。 | +| 3. 模式克制 | green | 复用现有发布、实例、预览和运行记录;明确放弃新增客户端幂等协议及通用迁移产品。 | +| 4. 数据与状态建模 | green | R2 保存原指针回执、清空旧运行资格、创建目标新版本与新审批,并保留原审批事实。 | +| 5. 变化适应性 | green | 新增状态、维护种类和公开接口均对应明确缺口,且被标明为后续新增工作。 | +| 6. 失败与并发现实 | yellow | 同锁登记、清理确认和显式重试合同已补齐;DAO 事务边界与维护记录生命周期仍须正式设计落地。 | +| 7. 决策记录与可读性 | green | 首轮问题、修订方向、未实施边界及必要定向验证均已记录,用户可据此选择方案。 | + +### 首轮问题关闭情况 + +| 问题 | 结果 | 关闭依据 | +|---|---|---| +| **B1:非 CLI 启动竞态** | **关闭,方案层面** | §4.3 第6点将手工上线、resume、配置重建、预览准备、审批执行及重试全部纳入“同一用户行锁、同一SQL事务内校验+持久登记”;同时补齐 v2 RuntimeOperation 和兼容 v1 pending/preparing 的生产者及迁移查询消费者。§7明确验证资格检查与登记之间的交错。 | +| **S1:R2 旧版本绕过目标审批** | **关闭,方案层面** | §6.2 增加持久维护围栏,覆盖用户启动及后台自愈;清空 current/pending,原值保存回执;正式启动只消费完成目标审批的精确目标版本。§4.4 又要求冻结 deployment/payload 租户与当前 app 租户比较,阻断旧 outbox 重试。 | +| **S2:响应丢失后的幂等承诺无机制** | **关闭** | §4.3 第5点明确不提供客户端自动找回或 exactly-once,不以包摘要认定请求;客户端不自动重投,管理员定位原占位后内部续办或终结。这是与现有接口相容的范围收缩。 | +| **S3:资格拒绝后的结算与清理** | **关闭,方案层面** | §4.4 明确 deployment failed、outbox FAILED、审批 execute_failed,保留审批通过决定;预览回收独立执行,清理未确认继续阻断;合法显式重试重新进入门闸。§6.1/§6.3也引用相同收口规则。 | + +### blocker + +无。 + +### should-fix + +无须在提交用户评审前继续扩大提案。 + +正式设计应落实已经承诺的两个细节:所有登记和迁移写入共用事务/session;R2 新增 `tenant-migration` 维护记录拥有独立于普通运行任务超时的解除条件。后一点尤其需要覆盖现有运行任务分发与超时消费者:当前 `RuntimeOperationService.create` 默认设置运行任务 deadline(`runtime_operation_service.py:97`),`node_operations` 会读取非终态操作(`runtime_delivery_repository.py:185`)。不能让普通任务超时或通用终态判断自动解除迁移围栏。提案已要求“停止/清理确认后释放”和“所有后台消费者接线”,因此这属于正式设计必须兑现的细化项,不构成新增阻塞。 + +### nit + +§8 第1项仍写“仅阻断应用发布/新增实例”,与 §1、§4.2 对稳定在线应用同版本自愈的例外存在措辞差异。建议同步为“阻断新的发布、版本切换及用户发起的实例重建”,避免用户误以为自愈也会停止。 + +本轮仅复读最新版提案并核对本地相关模型和运行记录代码;未改文件、环境或远端数据,未重新验证116现场事实。 diff --git a/docs/reviews/vibe-owner-tenant-consistency-fix-plan-2026-10-28.html b/docs/reviews/vibe-owner-tenant-consistency-fix-plan-2026-10-28.html new file mode 100644 index 0000000000..664ed6d288 --- /dev/null +++ b/docs/reviews/vibe-owner-tenant-consistency-fix-plan-2026-10-28.html @@ -0,0 +1,235 @@ +Vibe 归属人租户一致性修复方案 · 待评审
待用户评审 · 尚未实施

服务账号租户一致性修复方案(简化版)

+

日期:2026-10-28(Asia/Taipei)|状态:简化方案及38条AC规格已于2026-10-28获用户确认;设计已单独确认,代码/L1完成51/53,待真实E2E及最终合入门

+

1. 本次范围

+

按用户确认及本轮追加范围,做以下四项:

+
    +
  1. 服务账号归属人的当前租户与服务账号租户不一致时,拒绝该服务账号的业务请求,明确返回“归属人租户与服务账号租户不一致”。 覆盖使用该账号密钥的业务接口,不再只限应用发布。
  2. +
  3. 存量资源不迁移、不变更。 资源仍属于原租户;归属人切换租户后看不到旧租户资源是正常隔离行为。
  4. +
  5. 服务账号管理页增加超管专属的租户选择。 用于查看、管理和在所选租户创建服务账号;用户已明确:不修改已有服务账号及其密钥的所属租户。
  6. +
  7. 应用管理操作补调用者租户校验。 上线/停用/恢复/删除、元信息、日志/数据及配置管理不能因owner相同跨租户执行;原有入口权限与超管边界不扩大。
  8. +
+

不新增资源迁移器,不恢复人员原租户,不新增人员迁移拦截、迁移作业或维护围栏。服务账号继续以自己的主体、原租户和授权范围调用;不会获得归属人的完整权限。

+

2. 现状与根因(已核对)

+

沿用2026-10-28对116的只读查询、请求日志及OpenFGA核查结果;本次简化仅核对本地代码,没有再次调用远端写接口。

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
对象已确认事实
用户 gzx01user_id=849;当前有效租户60“测试应用审批”
服务账号24“创建服务账号”tenant_id=1“根租户”;resource_owner_user_id=849
三个Vibe应用tenant_id均为1,owner_user_id均为849;owner授权和OpenFGA投影已存在
列表过滤登录用户租户60,与app租户1不同,业务目标校验拒绝并从列表过滤
现有鉴权比较密钥与服务账号的租户,未比较归属人的当前租户;缓存命中时直接复用principal
管理租户能力已有F019超管管理范围、useAdminScope及中间件;service-accounts已在生效路径中,管理页尚无选择入口
+

资源写到租户1,是因为请求采用了旧服务账号密钥的租户1。缺少的是调用时的归属人租户一致性检查。此次不修改历史资源和owner授权,也不放开跨租户可见性。

+

3. 统一业务请求校验

+

3.1 规则和错误

+

在服务账号业务身份准入处校验:

+
密钥有效、服务账号启用、密钥租户 = 服务账号租户
+  → 查询该账号当前归属人的唯一有效租户
+  → 归属人租户 = 服务账号租户:继续原scope、委托及业务权限检查
+  → 不相等:拒绝业务请求,返回明确的租户不一致错误
+
+
    +
  • 新错误使用统一错误码机制,文案为“归属人租户与服务账号租户不一致,请联系管理员在正确租户创建服务账号,或调整资源归属人。”具体编号在正式规格/设计时按注册表分配,本文不预占编号。
  • +
  • 归属人当前有效成员关系不存在或不唯一时,拒绝,并返回“无法确认归属人所属租户”类错误;不能随意取第一条或默认为根租户。
  • +
  • 查询故障返回鉴权依赖不可用,不冒充租户不一致。归属人禁用/删除的既有规则另按当前合同执行,不把“禁用”误判为“迁出租户”。
  • +
  • 不自动撤销旧密钥或停用服务账号。关系恢复一致后,仍有效的密钥可按原规则继续调用;归属人A→B→A不需要人工清除失配状态。
  • +
  • 管理员使用登录会话进行账号管理不受这个业务密钥闸门阻断,仍须满足超管/租户管理员权限。因此可以查看问题、按原规则改绑同租户归属人或显式停用。
  • +
+

3.2 最小实现边界

+

校验逻辑放在open_api公共业务准入链路,HTTP与MCP共用,不在各业务接口重复添加。

+

必须覆盖密钥缓存命中: 当前validate_bearer会缓存整个principal,单改resolve_service_account只在缓存未命中时生效。每次业务准入都要重新取得服务账号当前归属人及其有效租户进行核对,不能以旧principal里的owner或缓存TTL作为判断依据;账户改绑后亦不能继续使用旧owner。令牌哈希、有效期等原缓存可保留,不新增一致性状态表或第二套失配缓存。

+

MCP长连接按实际业务调用重新校验,不能只在建连时检查一次。若现有传输有仅握手鉴权的路径,应在每次工具调用的公共准入点补齐。流式调用已开始后不追溯撤销已执行动作;下一次业务调用必须检查当前关系。

+

本项只约束service_account主体,不扩展到个人PAT或独立的hosted_app运行凭据。已上线应用不会因为服务账号失配而自动停止;其运行期权限仍由原应用凭据与租户隔离控制。应用内部若使用失配服务账号密钥调用平台业务,同样会被拒绝。

+

3.3 发布链路的必要补充

+

新发布在公共准入即拒绝,不能创建应用、保存平台源码快照、投递发布任务或生成审批;HTTP临时接收文件与清理不算平台资源写入。

+

异步任务无法靠一次HTTP鉴权覆盖整个生命周期,保留三个必要复核点:

+
+ + + + + + + + + + + + + + + + + + + + +
时点检查与失败行为
Worker实际开始预检核对发布记录冻结的归属人和租户;失配则失败,不改写为归属人的新租户
创建版本/审批前再检查同一冻结关系;失配不创建新审批,临时产物按原流程清理
审批正式上线前再检查冻结关系,且在登记待生效版本、数据库迁移、停旧实例、启动正式实例前拒绝;当前/待生效版本和路由保持原状,应用已停用而仅登记版本时同样先检查
+

已接收部署以冻结owner/tenant为准,不随服务账号后来改绑而换资源归属。审批已经通过但上线前失配时,审批决定留档,业务执行进入现有失败状态并给出原因;不把失败伪装成成功或自动驳回,也不自动重试到另一租户。预览等后续消费者按同一冻结关系拒绝新增执行,清理独立进行。

+

不为此改造所有人员迁移入口、引入跨模块租约或接收占位协议。本方案提供每个业务准入/执行阶段的校验,不承诺撤销阶段内已执行副作用;正式设计需明确各阶段开始与终结的位置。

+

4. 超管管理页租户选择

+

4.1 界面行为

+
    +
  • 多租户模式下,只有全局超管看到“管理租户”选择器;租户管理员沿用自己的租户,单租户模式不展示切换。
  • +
  • 选择的是页面管理/创建范围,不是修改已有服务账号tenant_id。界面明确显示当前管理租户,列表、详情、密钥管理、资源授权和归属人候选保持同一范围。
  • +
  • 创建时必须是具体租户;账号与新密钥均属于该租户,只能选择该租户的合格归属人。切到租户60后创建的账号,未来新建资源写入60。
  • +
  • 切换成功后返回列表,清空旧详情、搜索结果、已选归属人及未提交表单,重新加载数据。切换中禁用全部服务账号管理写提交;迟到的旧租户响应不覆盖新列表。
  • +
  • 复用已有管理范围能力及现有组件样式,不新增UI库;新增文案同步zh-Hans/en/ja。
  • +
+

4.2 后端和已有能力复用

+

优先复用F019管理范围与useAdminScope,而不是新增账号迁租户API。service-accounts管理API已受该范围控制,新增工作重点是页面接入、候选查询和创建时的范围确认。

+

需要落实三个已有机制的边界:

+
    +
  1. 根租户显式选1。 现有模型页把根租户选项映射到“无管理范围”,服务账号在无范围时可能按owner租户推导创建目标。此次不能直接照搬该映射:根租户管理必须固定到1,创建不得悄悄落到其他租户。
  2. +
  3. F019是超管共享的管理范围。 该状态按管理员保存,会影响已有纳入中间件的其他管理页/标签页;不是人员租户迁移,也不是只存在于本页的局部变量。界面读取实际范围,切换后刷新相关管理缓存。全部服务账号管理写提交携带打开表单时的预期租户,后端与实际管理范围比较,不一致则拒绝并要求刷新,避免另一标签页切换后误建。这个字段用于范围一致性校验,不授予租户权限。
  4. +
  5. 后端不能只依靠隐藏控件。 设置管理范围接口校验全局超管身份、目标租户存在且可用;普通管理员不能伪造范围跨租户。列表/详情/密钥/授权操作仍按实际范围与账号tenant_id匹配,不绕过已有权限检查。归属人候选必须真正限定到选定租户,不能仅显示提示文案。
  6. +
+

本次不修改已有账号和密钥的tenant_id,不复制旧授权。创建新租户账号后,按实际需要签发新密钥、配置所需资源授权,再更新CLI或调用端的凭据。

+

5. 116三个存量应用保持现状

+
+ + + + + + + + + + + + + + + + + + + + + + + + +
应用ID/slug本次处理
今日待办e1aee8f60a214290b85998a938a13231 / today-tasks-demo仍属租户1;保留现有实例、版本、数据库、访问URL,不自动停止或迁移
知识库读写验证c132fd3dcb744c06ba4db686079ca0c0 / knowledge-editor-demo保留原draft及部署失败记录、审批414,不修复为租户60资源
知识库读写验证(无能力声明)bc6df4535f404b918e851ab95b2f8176 / knowledge-editor-no-capabilities保留原draft、版本及待审批415,不自动取消、迁移或改审批归属
+
    +
  • gzx01在租户60看不到这三个应用,按本轮决定视为正常隔离行为。超管及原租户有权主体按现有权限管理,不新增跨租户owner例外。
  • +
  • 服务账号24和其密钥继续属于租户1。修复生效后,由于owner849当前在60,使用该账号密钥的业务调用将明确拒绝。这里是用户要求的行为改变,范围包括该账号的其他业务集成。
  • +
  • 不立即改动审批415;若后续执行上线,新的执行校验应拒绝失配发布并记录失败,不产生正式实例。历史审批414和其他审计不回写。
  • +
  • 如需在租户60继续开发,由超管切换管理租户到60后新建服务账号、选择gzx01并签发新密钥;发布新应用使用新ID和不同slug。现有slug仍被旧应用占用,不承诺沿用原地址,也不替用户重建这三个应用。
  • +
+

6. 必要定向验证与SDD推进

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
范围必要验证
统一准入同租户允许、跨租户明确拒绝;无唯一有效租户拒绝;查询故障不误报;不扩大scope/业务权限
缓存/改绑热缓存后迁租户立即拒绝;改绑不使用旧owner;A→B→A恢复;HTTP和MCP逐次调用一致
异步发布接收后迁租户,Worker/审批创建/正式上线前各自拒绝;不停止旧实例;审批执行失败正确落档,清理仍能推进
管理页面超管可切换、租户管理员不可越权;root显式1、child显式60;owner候选/密钥/授权范围一致;跨标签页范围变化拒绝旧表单;迟到响应不污染页面
应用操作跨租户owner直接请求状态/元信息/日志/数据/配置拒绝;同租户原权限不扩大;可信后台作业仍按原归属运行,缺自然人actor不是通行凭据
存量隔离116三个app的tenant/owner/版本/数据不变;60普通用户仍不可管理;不通过部署新版本或停止在线应用来验此项
+

只做上述定向验证,不跑Vibe全量、K8s或arm64回归。DM8本轮按既定约定静态核对模型和查询约束。需要真实E2E时优先使用专用临时账号和应用,不修改上述三个存量应用。

+

本方案的规格与修订设计已获用户分别确认,已进入完整SDD开发;原因是统一服务账号业务准入改变了此前已确认的调用合同(AGENTS.md §6、SDD-Guide §1/§3)。不新增迁移范围,不把方案确认当作116数据修复或停机授权。

+

7. 源码依据与文档检查边界

+

本地核对基线:release@1a56cd5c6db087c672c543ce0b90336bc19cc9db。116事实来自同日先前只读核查;不以镜像标签推断源码SHA。

+ +

本简化版取代上一版迁移方案,旧版独立架构复审结论不适用于本版。本轮完成静态方案自检;未执行业务测试、部署、迁移或新的独立代理复审。

+

Markdown和HTML由同一源生成,检查目录锚点、表格数量、源摘要及无外部资源依赖。自动浏览器此前因file协议安全策略拒绝预览,本轮不绕过该限制;未完成宽屏、500px或打印实测,CSS配置不作为视觉验收结果。

+

8. SDD推进状态

+

用户已确认本简化方案及“只切换管理和创建范围”的含义,需求发现阶段完成。正式规格文档已产出,共38条AC;规格自审按sdd-review执行。规格已获用户“继续”确认,设计R2三项已修订,R3自审及用户确认完成;当前代码/L1完成51/53,已做本功能定向验证,真实E2E及最终合入门未完成,未改116数据。

+

9. 用户追加范围与规格修订

+

2026-10-28用户明确:不做应用移交或迁移,保持存量资源不变;应用操作租户校验纳入同次修复。生命周期操作、元信息、日志/数据及配置管理均按调用者租户和原权限处理,不能仅因owner相同跨租户操作;平台可信后台任务维持原受控归属,已有应用不自动停用。正式spec新增AC-33至AC-38。

+

两项评审问题已收口:失配发布不得改变当前/待生效版本和路由,停用应用仅登记下次启动版本也要先校验;服务账号管理写动作明确列出创建、编辑、改绑、启停删除、密钥签发/编辑/吊销及授权变更/撤销,均受提交时范围一致性要求约束。旧方案中的“敏感提交”以正式spec §3.1完整清单为准。正式规格与设计已分别确认,已实施;进度及分层证据见F113任务/verification。

+

10. F113设计评审入口

+

设计Markdown · 离线HTML · 24项设计自审。规格及设计确认点已完成;当前处于开发验收阶段。

+

设计修订复审:R3报告,已补齐发布状态、源码/版本审阅与可信批准启动;38条规格AC保持不变,设计已获用户单独确认,当前正在收尾验收。

+

11. 开发与提测入口

+

任务与进度 · 定向验证/提测说明 · 真实E2E人工清单。只做本功能必要验证;未运行全量、未变更存量、未部署环境。用户最新要求先提交推送开发分支,真实验收保留待办。

离线可阅读,无外部字体、CDN或在线图表依赖。表格在窄屏内横向滚动,打印使用A4布局。
Markdown源摘要:eb2f794507f77fe179136a57f9229d4a43202ca78733df8c23a0ce31e37d3228
\ No newline at end of file diff --git a/docs/reviews/vibe-owner-tenant-consistency-fix-plan-2026-10-28.md b/docs/reviews/vibe-owner-tenant-consistency-fix-plan-2026-10-28.md new file mode 100644 index 0000000000..71fdbaaf94 --- /dev/null +++ b/docs/reviews/vibe-owner-tenant-consistency-fix-plan-2026-10-28.md @@ -0,0 +1,164 @@ +# 服务账号租户一致性修复方案(简化版) + +日期:2026-10-28(Asia/Taipei)|状态:**简化方案及38条AC规格已于2026-10-28获用户确认;设计已单独确认,代码/L1完成51/53,待真实E2E及最终合入门** + +## 1. 本次范围 + +按用户确认及本轮追加范围,做以下四项: + +1. **服务账号归属人的当前租户与服务账号租户不一致时,拒绝该服务账号的业务请求,明确返回“归属人租户与服务账号租户不一致”。** 覆盖使用该账号密钥的业务接口,不再只限应用发布。 +2. **存量资源不迁移、不变更。** 资源仍属于原租户;归属人切换租户后看不到旧租户资源是正常隔离行为。 +3. **服务账号管理页增加超管专属的租户选择。** 用于查看、管理和在所选租户创建服务账号;用户已明确:不修改已有服务账号及其密钥的所属租户。 +4. **应用管理操作补调用者租户校验。** 上线/停用/恢复/删除、元信息、日志/数据及配置管理不能因owner相同跨租户执行;原有入口权限与超管边界不扩大。 + +不新增资源迁移器,不恢复人员原租户,不新增人员迁移拦截、迁移作业或维护围栏。服务账号继续以自己的主体、原租户和授权范围调用;不会获得归属人的完整权限。 + +## 2. 现状与根因(已核对) + +沿用2026-10-28对116的只读查询、请求日志及OpenFGA核查结果;本次简化仅核对本地代码,没有再次调用远端写接口。 + +| 对象 | 已确认事实 | +|---|---| +| 用户 gzx01 | user_id=849;当前有效租户60“测试应用审批” | +| 服务账号24“创建服务账号” | tenant_id=1“根租户”;resource_owner_user_id=849 | +| 三个Vibe应用 | tenant_id均为1,owner_user_id均为849;owner授权和OpenFGA投影已存在 | +| 列表过滤 | 登录用户租户60,与app租户1不同,业务目标校验拒绝并从列表过滤 | +| 现有鉴权 | 比较密钥与服务账号的租户,未比较归属人的当前租户;缓存命中时直接复用principal | +| 管理租户能力 | 已有F019超管管理范围、useAdminScope及中间件;service-accounts已在生效路径中,管理页尚无选择入口 | + +资源写到租户1,是因为请求采用了旧服务账号密钥的租户1。缺少的是调用时的归属人租户一致性检查。此次不修改历史资源和owner授权,也不放开跨租户可见性。 + +## 3. 统一业务请求校验 + +### 3.1 规则和错误 + +在服务账号业务身份准入处校验: + +```text +密钥有效、服务账号启用、密钥租户 = 服务账号租户 + → 查询该账号当前归属人的唯一有效租户 + → 归属人租户 = 服务账号租户:继续原scope、委托及业务权限检查 + → 不相等:拒绝业务请求,返回明确的租户不一致错误 +``` + +- 新错误使用统一错误码机制,文案为“归属人租户与服务账号租户不一致,请联系管理员在正确租户创建服务账号,或调整资源归属人。”具体编号在正式规格/设计时按注册表分配,本文不预占编号。 +- 归属人当前有效成员关系不存在或不唯一时,拒绝,并返回“无法确认归属人所属租户”类错误;不能随意取第一条或默认为根租户。 +- 查询故障返回鉴权依赖不可用,不冒充租户不一致。归属人禁用/删除的既有规则另按当前合同执行,不把“禁用”误判为“迁出租户”。 +- 不自动撤销旧密钥或停用服务账号。关系恢复一致后,仍有效的密钥可按原规则继续调用;归属人A→B→A不需要人工清除失配状态。 +- 管理员使用登录会话进行账号管理不受这个业务密钥闸门阻断,仍须满足超管/租户管理员权限。因此可以查看问题、按原规则改绑同租户归属人或显式停用。 + +### 3.2 最小实现边界 + +校验逻辑放在open_api公共业务准入链路,HTTP与MCP共用,不在各业务接口重复添加。 + +**必须覆盖密钥缓存命中:** 当前validate_bearer会缓存整个principal,单改resolve_service_account只在缓存未命中时生效。每次业务准入都要重新取得服务账号当前归属人及其有效租户进行核对,不能以旧principal里的owner或缓存TTL作为判断依据;账户改绑后亦不能继续使用旧owner。令牌哈希、有效期等原缓存可保留,不新增一致性状态表或第二套失配缓存。 + +MCP长连接按实际业务调用重新校验,不能只在建连时检查一次。若现有传输有仅握手鉴权的路径,应在每次工具调用的公共准入点补齐。流式调用已开始后不追溯撤销已执行动作;下一次业务调用必须检查当前关系。 + +本项只约束service_account主体,不扩展到个人PAT或独立的hosted_app运行凭据。已上线应用不会因为服务账号失配而自动停止;其运行期权限仍由原应用凭据与租户隔离控制。应用内部若使用失配服务账号密钥调用平台业务,同样会被拒绝。 + +### 3.3 发布链路的必要补充 + +新发布在公共准入即拒绝,不能创建应用、保存平台源码快照、投递发布任务或生成审批;HTTP临时接收文件与清理不算平台资源写入。 + +异步任务无法靠一次HTTP鉴权覆盖整个生命周期,保留三个必要复核点: + +| 时点 | 检查与失败行为 | +|---|---| +| Worker实际开始预检 | 核对发布记录冻结的归属人和租户;失配则失败,不改写为归属人的新租户 | +| 创建版本/审批前 | 再检查同一冻结关系;失配不创建新审批,临时产物按原流程清理 | +| 审批正式上线前 | 再检查冻结关系,且在登记待生效版本、数据库迁移、停旧实例、启动正式实例前拒绝;当前/待生效版本和路由保持原状,应用已停用而仅登记版本时同样先检查 | + +已接收部署以冻结owner/tenant为准,不随服务账号后来改绑而换资源归属。审批已经通过但上线前失配时,审批决定留档,业务执行进入现有失败状态并给出原因;不把失败伪装成成功或自动驳回,也不自动重试到另一租户。预览等后续消费者按同一冻结关系拒绝新增执行,清理独立进行。 + +不为此改造所有人员迁移入口、引入跨模块租约或接收占位协议。本方案提供每个业务准入/执行阶段的校验,不承诺撤销阶段内已执行副作用;正式设计需明确各阶段开始与终结的位置。 + +## 4. 超管管理页租户选择 + +### 4.1 界面行为 + +- 多租户模式下,只有**全局超管**看到“管理租户”选择器;租户管理员沿用自己的租户,单租户模式不展示切换。 +- 选择的是页面管理/创建范围,不是修改已有服务账号tenant_id。界面明确显示当前管理租户,列表、详情、密钥管理、资源授权和归属人候选保持同一范围。 +- 创建时必须是具体租户;账号与新密钥均属于该租户,只能选择该租户的合格归属人。切到租户60后创建的账号,未来新建资源写入60。 +- 切换成功后返回列表,清空旧详情、搜索结果、已选归属人及未提交表单,重新加载数据。切换中禁用全部服务账号管理写提交;迟到的旧租户响应不覆盖新列表。 +- 复用已有管理范围能力及现有组件样式,不新增UI库;新增文案同步zh-Hans/en/ja。 + +### 4.2 后端和已有能力复用 + +优先复用F019管理范围与useAdminScope,而不是新增账号迁租户API。service-accounts管理API已受该范围控制,新增工作重点是页面接入、候选查询和创建时的范围确认。 + +需要落实三个已有机制的边界: + +1. **根租户显式选1。** 现有模型页把根租户选项映射到“无管理范围”,服务账号在无范围时可能按owner租户推导创建目标。此次不能直接照搬该映射:根租户管理必须固定到1,创建不得悄悄落到其他租户。 +2. **F019是超管共享的管理范围。** 该状态按管理员保存,会影响已有纳入中间件的其他管理页/标签页;不是人员租户迁移,也不是只存在于本页的局部变量。界面读取实际范围,切换后刷新相关管理缓存。全部服务账号管理写提交携带打开表单时的预期租户,后端与实际管理范围比较,不一致则拒绝并要求刷新,避免另一标签页切换后误建。这个字段用于范围一致性校验,不授予租户权限。 +3. **后端不能只依靠隐藏控件。** 设置管理范围接口校验全局超管身份、目标租户存在且可用;普通管理员不能伪造范围跨租户。列表/详情/密钥/授权操作仍按实际范围与账号tenant_id匹配,不绕过已有权限检查。归属人候选必须真正限定到选定租户,不能仅显示提示文案。 + +本次不修改已有账号和密钥的tenant_id,不复制旧授权。创建新租户账号后,按实际需要签发新密钥、配置所需资源授权,再更新CLI或调用端的凭据。 + +## 5. 116三个存量应用保持现状 + +| 应用 | ID/slug | 本次处理 | +|---|---|---| +| 今日待办 | e1aee8f60a214290b85998a938a13231 / today-tasks-demo | 仍属租户1;保留现有实例、版本、数据库、访问URL,不自动停止或迁移 | +| 知识库读写验证 | c132fd3dcb744c06ba4db686079ca0c0 / knowledge-editor-demo | 保留原draft及部署失败记录、审批414,不修复为租户60资源 | +| 知识库读写验证(无能力声明) | bc6df4535f404b918e851ab95b2f8176 / knowledge-editor-no-capabilities | 保留原draft、版本及待审批415,不自动取消、迁移或改审批归属 | + +- gzx01在租户60看不到这三个应用,按本轮决定视为正常隔离行为。超管及原租户有权主体按现有权限管理,不新增跨租户owner例外。 +- 服务账号24和其密钥继续属于租户1。修复生效后,由于owner849当前在60,使用该账号密钥的业务调用将明确拒绝。这里是用户要求的行为改变,范围包括该账号的其他业务集成。 +- 不立即改动审批415;若后续执行上线,新的执行校验应拒绝失配发布并记录失败,不产生正式实例。历史审批414和其他审计不回写。 +- 如需在租户60继续开发,由超管切换管理租户到60后新建服务账号、选择gzx01并签发新密钥;发布新应用使用新ID和不同slug。现有slug仍被旧应用占用,不承诺沿用原地址,也不替用户重建这三个应用。 + +## 6. 必要定向验证与SDD推进 + +| 范围 | 必要验证 | +|---|---| +| 统一准入 | 同租户允许、跨租户明确拒绝;无唯一有效租户拒绝;查询故障不误报;不扩大scope/业务权限 | +| 缓存/改绑 | 热缓存后迁租户立即拒绝;改绑不使用旧owner;A→B→A恢复;HTTP和MCP逐次调用一致 | +| 异步发布 | 接收后迁租户,Worker/审批创建/正式上线前各自拒绝;不停止旧实例;审批执行失败正确落档,清理仍能推进 | +| 管理页面 | 超管可切换、租户管理员不可越权;root显式1、child显式60;owner候选/密钥/授权范围一致;跨标签页范围变化拒绝旧表单;迟到响应不污染页面 | +| 应用操作 | 跨租户owner直接请求状态/元信息/日志/数据/配置拒绝;同租户原权限不扩大;可信后台作业仍按原归属运行,缺自然人actor不是通行凭据 | +| 存量隔离 | 116三个app的tenant/owner/版本/数据不变;60普通用户仍不可管理;不通过部署新版本或停止在线应用来验此项 | + +只做上述定向验证,不跑Vibe全量、K8s或arm64回归。DM8本轮按既定约定静态核对模型和查询约束。需要真实E2E时优先使用专用临时账号和应用,不修改上述三个存量应用。 + +本方案的规格与修订设计已获用户分别确认,已进入完整SDD开发;原因是统一服务账号业务准入改变了此前已确认的调用合同(AGENTS.md §6、SDD-Guide §1/§3)。不新增迁移范围,不把方案确认当作116数据修复或停机授权。 + +## 7. 源码依据与文档检查边界 + +本地核对基线:release@1a56cd5c6db087c672c543ce0b90336bc19cc9db。116事实来自同日先前只读核查;不以镜像标签推断源码SHA。 + +- [服务账号与缓存准入](https://github.com/dataelement/bisheng/blob/1a56cd5c6db087c672c543ce0b90336bc19cc9db/src/backend/bisheng/open_api/domain/services/credential_validator.py) +- [HTTP/MCP公共访问上下文](https://github.com/dataelement/bisheng/blob/1a56cd5c6db087c672c543ce0b90336bc19cc9db/src/backend/bisheng/open_api/domain/services/access_context.py) +- [归属人查询](https://github.com/dataelement/bisheng/blob/1a56cd5c6db087c672c543ce0b90336bc19cc9db/src/backend/bisheng/open_api/domain/repositories/owner_repository.py) +- [服务账号创建与固定租户逻辑](https://github.com/dataelement/bisheng/blob/1a56cd5c6db087c672c543ce0b90336bc19cc9db/src/backend/bisheng/open_api/domain/services/service_account_service.py) +- [已有管理范围中间件](https://github.com/dataelement/bisheng/blob/1a56cd5c6db087c672c543ce0b90336bc19cc9db/src/backend/bisheng/common/middleware/admin_scope.py) +- [已有管理范围Hook](https://github.com/dataelement/bisheng/blob/1a56cd5c6db087c672c543ce0b90336bc19cc9db/src/frontend/platform/src/hooks/useAdminScope.ts) +- [服务账号管理页](https://github.com/dataelement/bisheng/blob/1a56cd5c6db087c672c543ce0b90336bc19cc9db/src/frontend/platform/src/pages/SystemPage/components/ServiceAccount/index.tsx) + +本简化版取代上一版迁移方案,旧版独立架构复审结论不适用于本版。本轮完成静态方案自检;未执行业务测试、部署、迁移或新的独立代理复审。 + +Markdown和HTML由同一源生成,检查目录锚点、表格数量、源摘要及无外部资源依赖。自动浏览器此前因file协议安全策略拒绝预览,本轮不绕过该限制;未完成宽屏、500px或打印实测,CSS配置不作为视觉验收结果。 + + +## 8. SDD推进状态 + +用户已确认本简化方案及“只切换管理和创建范围”的含义,需求发现阶段完成。正式[规格文档](../../features/v3.0.0/113-service-account-tenant-consistency/spec.md)已产出,共38条AC;[规格自审](../../features/v3.0.0/113-service-account-tenant-consistency/spec-review.md)按sdd-review执行。规格已获用户“继续”确认,设计R2三项已修订,R3自审及用户确认完成;当前代码/L1完成51/53,已做本功能定向验证,真实E2E及最终合入门未完成,未改116数据。 + + +## 9. 用户追加范围与规格修订 + +2026-10-28用户明确:不做应用移交或迁移,保持存量资源不变;应用操作租户校验纳入同次修复。生命周期操作、元信息、日志/数据及配置管理均按调用者租户和原权限处理,不能仅因owner相同跨租户操作;平台可信后台任务维持原受控归属,已有应用不自动停用。正式spec新增AC-33至AC-38。 + +两项评审问题已收口:失配发布不得改变当前/待生效版本和路由,停用应用仅登记下次启动版本也要先校验;服务账号管理写动作明确列出创建、编辑、改绑、启停删除、密钥签发/编辑/吊销及授权变更/撤销,均受提交时范围一致性要求约束。旧方案中的“敏感提交”以正式spec §3.1完整清单为准。正式规格与设计已分别确认,已实施;进度及分层证据见F113任务/verification。 + + +## 10. F113设计评审入口 + +[设计Markdown](../../features/v3.0.0/113-service-account-tenant-consistency/design.md) · [离线HTML](../../features/v3.0.0/113-service-account-tenant-consistency/design.html) · [24项设计自审](../../features/v3.0.0/113-service-account-tenant-consistency/design-review.md)。规格及设计确认点已完成;当前处于开发验收阶段。 + + +设计修订复审:[R3报告](../../features/v3.0.0/113-service-account-tenant-consistency/design-review-r3.md),已补齐发布状态、源码/版本审阅与可信批准启动;38条规格AC保持不变,设计已获用户单独确认,当前正在收尾验收。 + +## 11. 开发与提测入口 + +[任务与进度](../../features/v3.0.0/113-service-account-tenant-consistency/tasks.md) · [定向验证/提测说明](../../features/v3.0.0/113-service-account-tenant-consistency/verification.md) · [真实E2E人工清单](../../features/v3.0.0/113-service-account-tenant-consistency/e2e-checklist.md)。只做本功能必要验证;未运行全量、未变更存量、未部署环境。用户最新要求先提交推送开发分支,真实验收保留待办。 diff --git a/docs/reviews/vibe-permission-actions-design-review-2026-10-30.html b/docs/reviews/vibe-permission-actions-design-review-2026-10-30.html new file mode 100644 index 0000000000..c04400b12c --- /dev/null +++ b/docs/reviews/vibe-permission-actions-design-review-2026-10-30.html @@ -0,0 +1,3526 @@ +F114 · 本地开发与提测进度

F114 · 本地开发与提测进度

规格与设计均已确认;本地102/104任务完成。六动作、具体租户、真实提交主体、预览事务与v2阶段授权已接线。只执行必要定向验证;真实部署E2E、最终L2和工作区质量门禁仍待完成。用户明确要求先本地合入feat/3.0.0,自行部署116后再验收;未推送或部署116/189。下方历史设计复审是当时的静态记录。
+

F114 开发与必要定向验证

+

1. 当前结论

+

2026-10-30,用户已分别确认规格和设计,已完成任务拆解与本地实现。102/104 项代码、文档及 L1 任务完成;T063 真实部署 E2E、T064 最终 L2 和质量门禁仍未完成。用户随后明确要求先提交合入 feat/3.0.0,由用户手动部署116后再做E2E;此次按该授权调整交付顺序,不代表验收通过。未执行 backend/前端全量测试,未更新 116/189、迁移存量资源、补历史 Grant、公开推送或发行镜像。

+

本地实现提交:6ac7704998087267af69b72adf542895b2694ff1;该提交之后先补充证据定位与HTML,再按用户要求集成发版线;本次集成范围和定向结果见§7。工作树:codex/114-vibe-permission-actions。开发起点:bb62790ad22a3204493e7dce5878e0a4f7109599。集成前本地发版线为 ac2b09c8a2789021652d585f25d519888cfd0c0f,已于 5d04b8290 合并继承;随后获取远端 56f543e6ee631d000071352baba8ebccd2a69427 并集成,保留双方历史与兼容增强。

+

2. 本地实现范围

+
    +
  • 复用六动作;基础管理五动作 OR;内容、数据、日志、配置与私有依赖 edit;确认配置重建 edit AND publish;删除不再加 owner 状态限制。
  • +
  • 真实服务账号与自然人归属分离;首发沿 F048 初始授权,审批申请人与原 owner 固定;候选、自审、指定/重试和业务撤回接线。
  • +
  • 仅具体租户视角;默认/失效回经验证所属租户,无法确定拒绝;专用管理写范围头、旧 F113 头兼容、前端跨页/跨标签缓存与旧响应防护。
  • +
  • 用户凭据当前动作与系统自愈意图分型;准备/维护前、数据、启动和切换复核;维护确认后才停旧 writer;auto_commit 无授权豁免。
  • +
  • 双用途预览同 Version 锁、同 UoW 登记会话/凭据/operation/容量/封装环境;提交后 wake,ready 开入口;当前用途、任务/动作、冻结版本及签名 OBO 重验;只吊销当前预览凭据。
  • +
  • backend、manager、代理默认且仅支持运行控制 v2;旧控制/导入/upgrade/rebind 不可达,公共构建、SQLite、SDK、平台 API 与交付模式保留。
  • +
+

3. 实际定向执行

+

下表是分阶段实际运行记录,不把重复复跑相加成一个通过总数。原始脱敏输出在 evidence 所列文件;不是远端验证。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
检查实际结果边界
首轮 F114 backend 选集+单 Alembic head139 passed,7个既有依赖警告本特性选集,非 backend 全量;发生在最终仓储收敛前
仓储调整受影响授权/构建列表/预览身份44 passed只复测受影响6个文件
新预览凭据 SQL / rollback / UTC 有效期14 passedSQLite 内存事务;不是 MySQL/DM8 真并发证明
历史 NULL 预览不收养/不阻塞新扫描1 passed,5 deselected部署前必须用旧版回收旧预览;不新增迁移器
私有材料真实主体 edit先2个失败复现旧 owner 门槛;修复后2 passed当前人/SA、撤权、其它应用拒绝配对
manager v2启动与阶段许可顺序6 passedmock平台、受限执行器;非真实 Engine/K8s
离线补包范围与既有目录导入5 passed,4 deselected缺失/非法范围不上传;公开目录不加Vibe动作
app-proxy preview / 平台路由首次17 passed、1过时路径断言失败;仅该用例修正后复跑通过v2签名fake;不代表真实HTTP/WS部署
Platform 动作组件 / 范围 / 顶部单选动作3项,范围2项,顶部2项通过7个定向case;未做真实浏览器验收
Client 审读/预览面板19 passed2个定向suite;不替代真实身份/任务
后端 Ruff / 架构守卫111个声明内Python文件通过;无VIOLATION额外人工将新增SQL收回Repository;未改宪法
manager / proxy / tool Ruff18个声明内Python文件通过仅变更文件
变更前端 ESLintPlatform、Client受影响文件通过不是工作区整体绿灯
三语错误源生成、check-i18n生成成功;无新增i18n漂移未修改baseline或手改派生件
E2E收集3 tests collected仅收集,未向116/189发请求,不能算E2E通过
+

关键后端命令(cwd src/backend,只读配置由 config 环境变量提供,不随文档或 Git 交付):

+
PYTHONPATH=. PYTHONDONTWRITEBYTECODE=1 python -m pytest   test/app_runtime/test_f114_*.py test/app_publish/test_f114_*.py   test/approval/test_f114_*.py test/admin/test_f114_*.py   test/app_governance/test_f114_*.py test/open_api/test_f114_*.py   test/database/test_alembic_single_head.py -q --tb=short -p no:cacheprovider
+
+

该命令记录的是首轮选集。后续仓储只跑上述受影响6文件,新增 SQL/私有材料/历史预览分别按表单独执行;不会为每次修复重复全选集。旧 v1 控制 HTTP fixture 显式 retired skip,未将 skip 算作成功;共用工具和入口覆盖保留。

+

4. 未通过的工作区质量门禁

+ + + + + + + + + + + + + + + + + + + + + + + + + +
门禁实际情况处理边界
pnpm lintPlatform整体通过;Client被未修改 AiChatInput.tsx:375 的 hasAttachedFiles 未使用阻断文件与HEAD及当前发版线相同;不混入无关修复
Platform pnpm typecheck17个错误当前安装缺少/过旧图标、rehype/remark、dnd/router类型等,未把失败隐去
Client pnpm typecheck134个错误当前工作区共享包/图标与依赖类型未满足;包括已存在的 AppPreviewPanel Eye 图标行。未为此重新安装依赖、写suppression或跑无关修复;未做一次独立干净基线对照
+

类型检查失败不能自动等同代码没有问题。上述为6ac770499实现阶段的工作区结果,不能当作最新集成线的质量结论。用户要求先合入供手动部署;正式验收仍须在正确安装/构建依赖的工作区或 CI 完成门禁,只定位新增错误;T064 保持未完成。

+

5. 真实部署 E2E 待办(专用对象、必要范围)

+

依 AGENTS.md §6、已确认设计 §6.5/§7 和 e2e-test,真实 E2E 仍是必需项。本轮设计确认只授权开发;更新 116/189、重启服务和创建真实测试对象尚未获得本轮具体授权。

+

5.1 自动化入口与当前缺口

+

test/e2e/test_f114_permission_actions.py 已准备3个光明/拒绝配对case,使用专用已批准的 e2e-f114-* app 和4种非管理员测试身份;F114_E2E=1 才执行。环境变量包括 E2E_API_BASE、F114_APP_ID、F114_TENANT_ID、F114_EDITOR_TOKEN、F114_PUBLISHER_TOKEN、F114_USE_TOKEN、F114_FOREIGN_TOKEN。凭据仅运行进程内使用,不写报告或Git。setup/teardown只操作该应用 E2E_F114_TEMP 测试配置,不删除业务对象。

+

当前测试依赖预置 fixture,并未自动创建租户/人员/SA;它只覆盖安全摘要与私有内容、保存不重启和旧范围拒绝。不能宣称覆盖71条AC的真实E2E。取得环境授权后,还需补齐以下实执行证据或相应API case:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
场景具体执行与预期当前状态
前置与保护只读盘点镜像/协议/旧预览/路由UID和数据;旧预览用旧版确认回收,原生v2正式应用不主动重建未执行
真实 SA 首发/代发布临时A/B租户,固定自然人owner;SA无动作拒绝,有edit+publish可迭代;owner/tenant不变,审计真实主体未执行
六动作页面配对edit-only、publish-only、delete-only、manage_permission-only、use-only;私有组件无权不发请求,直接API同样拒绝未执行
租户切换超管仅A/B具体范围;Root非全平台;旧表单、延迟响应、另一标签页与失效回退,非超管伪造拒绝未执行
审批自审排除与真实单管理员例外;异常分配和task失效;publish代撤回只作用当前冻结实例未执行
双预览管理本人上限、审批归属人上限;HTTP/WS/OBO/模型/检索;无真实密钥;并发重复启动只一组身份,终态只吊销该凭据未执行
分阶段撤权/故障维护前撤权旧版继续服务;维护后撤权无新启动;回复丢失/重启同序重放,无容量误释放未执行
自愈与取消原提交者停用不阻止已批准意图自愈;停用意图不复活;当前取消者需原操作全部动作,必要清理保持未执行
Docker/K8s v2各一次本特性主流程/拒绝配对,共用构建/SQLite/附件保留;不跑全部旧场景、不做arm或跨节点能力宣称未执行
导出与收尾edit撤权后审计/数据导出下载拒绝;只清理专用对象,核对非测试应用与数据不变未执行
+

DM8仅静态使用JsonType、关系列和500个ID分批,不宣称真实数据库或跨节点/arm验证通过。正式CI镜像发行、公开推送和tag均独立记录。

+

6. L1、预审与交接

+

task-reviews.md 记录104项的L1范围、配对与依赖;code-review.md 仅为当前预审,最终 L2 待真实E2E和门禁后执行。原独立设计复审保留,不冒充独立代码审查。当前仍有验收缺口,不标整体完成;依用户后续明确授权先本地合入发版线,116部署由用户执行,之后再补真实E2E与最终审查。

+

7. 用户授权的先合入交接(2026-10-30)

+

用户原话:“先提交合并到feat/3.0.0 分支。我来手动部署到116环境,之后在进行e2e的验收测试”;随后“继续”。本次只办理本地提交与合并,不更新116/189,不发行镜像或tag,不把T063/T064改成完成。未新获得远端推送授权。

+

详细冲突处理、保留内容、必要测试及部署前置见 integration.md。合并包含本地发版线ac2b09c8a及本轮获取的远端56f543e6e;原工作区两份代码草稿与一份未跟踪文档均先存SHA-256并核对内容,不能覆盖。

+
    +
  • 环境变量摘要读取继续用Repository严格过滤到当前具体租户;授权保留管理上下文并检查真实主体edit,不恢复owner/admin快捷放行。
  • +
  • 远端新增的服务账号全平台只读入口与AC-09至AC-18冲突,按已确认“仅具体租户”收敛;保留租户名称、取消/失效控制和其它兼容增强。
  • +
  • 定向结果:后端首轮22项通过、13项测试夹具不兼容;只复跑这13项后全部通过。前端27项通过;变更文件Ruff/ESLint通过。没有重跑全量测试,也没有进行真实部署验收。
  • +

+

Tasks: Vibe 全操作权限动作统一与具体租户管理视角

+

关联:spec.md · design.md · 设计复审 +版本:v3.0.0 · 分支:codex/114-vibe-permission-actions · 基线:bb62790ad22a3204493e7dce5878e0a4f7109599

+

状态

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
步骤状态备注
discovery✅ 已确认36 项动作盘点;具体租户单选
spec.md✅ 已评审71 条;v2及R3收窄由用户明确;AC正文保持
design.md✅ 已评审2026-10-30 用户“确认设计,进入任务拆解和开发”;R1–R6关闭
tasks.md✅ 已拆解104项;必要接线/消费者和合同同步均登记;同代理按21项清单复核
实现🚧 代码收尾并获先合入授权102 / 104;用户手动部署116后补T063真实E2E,T064最终L2/质量门禁仍未完成
+

执行约定

+
    +
  • 单人按依赖顺序实施;Wave 表示依赖,不自动授权派发子agent。
  • +
  • 后端测试先写允许/拒绝配对再实施;测试任务可记录预期红灯,配对实现必须绿灯后L1打勾。已有fixture优先复用,不搭无关中间件。
  • +
  • 每任务1–2个明确文件;新增接线文件或locale按实际必要内容追加小任务,不能用一个任务隐藏跨前后端改造。相同文件仅增量处理本任务声明的不同职责。
  • +
  • 每任务内联交付接口/行为;设计理由只链接 design 章节。真实owner/tenant、F113/scopes、非创建人资格保护不变;只用六动作,不为共用助手接口添加Vibe管理门槛。
  • +
  • UI每次编辑先读当前设计规范和对应应用AGENTS;用已落地组件,样式不改,三语同交付,Client/Platform分开。
  • +
  • 测试只限本功能、直接受影响路径及必要质量门禁,不运行全量测试;无新增依赖/模拟arm验收,DM8仅静态。未执行真实E2E不宣称提测/交付完成。
  • +
  • 既有dirty/untracked文档保留;禁止全树stash/reset/clean/restore。代码只在本工作树修改,推送/合并/部署不得扩到其它环境或版本线。
  • +
+

任务

+

Wave 0:已确认合同与 additive 基础设施

+
    +
  • +

    [x] T001:发布与预览 ORM 字段 + 类型:基础设施 + 文件:src/backend/bisheng/app_publish/domain/models/app_deployment.py、src/backend/bisheng/app_publish/domain/models/app_preview_session.py + 逻辑/验证:authorization_context 使用 nullable JsonType;预览增加 purpose、instance/task、credential、operation 及 starting 状态;新会话强制完整绑定,旧 NULL 不回填。 + 覆盖 AC:AC-39、AC-50、AC-53、AC-69 + 依赖:无

    +
  • +
  • +

    [x] T002:已有表 DDL 与回退 + 类型:基础设施 + 文件:src/backend/bisheng/core/database/alembic/versions/f114_app_authorization_context.py + 逻辑/验证:读取 migration AGENTS,接当前唯一 head;仅 additive 字段和普通/唯一索引;降级只撤本 revision 的 DDL,运行前停止新写入并备份,绝不把 downgrade 当业务数据恢复。MySQL/DM8 静态核对,不做数据回填。 + 覆盖 AC:AC-39、AC-50、AC-53、AC-69 + 依赖:T001

    +
  • +
  • +

    [x] T003:范围错误码与受信值对象 + 类型:基础设施 + 文件:src/backend/bisheng/common/errcode/admin_scope.py、src/backend/bisheng/app_runtime/domain/schemas/app_authorization.py + 逻辑/验证:复核 19704/19705 未占用;定义范围变化/无有效范围错误及六动作、内容层级、用户/系统来源、操作用途值对象,禁止原令牌或永久 allowed。 + 覆盖 AC:AC-01、AC-04、AC-08、AC-13、AC-14、AC-40、AC-56、AC-60、AC-63 + 依赖:无

    +
  • +
+

Wave 1:六动作授权与具体租户范围

+
    +
  • +

    [x] T004:真实主体与动作组合配对 + 类型:测试 + 文件:src/backend/test/app_runtime/test_f114_action_authorization.py + 逻辑/验证:mock F048 公开端口:use-only 不进管理;任一管理动作基础可见;组合缺一拒绝;先验证目标租户/删除态再鉴权;SA 与 owner 分开;不可判定无效果。 + 覆盖 AC:AC-01、AC-02、AC-03、AC-04、AC-05、AC-06、AC-07、AC-08、AC-62、AC-63、AC-65 + 依赖:T003

    +
  • +
  • +

    [x] T005:应用授权门面 + 类型:后端 Domain + 文件:src/backend/bisheng/app_runtime/domain/services/app_authorization_service.py、src/backend/bisheng/app_runtime/application/authorization.py + 逻辑/验证:D1/D8:AppAuthorizationService 先验证 app/tenant/state,复用 F048 adapter;提供 require/allowed_actions/management 基础信息与内容分级;公开端口不泄露 ORM。 + 覆盖 AC:AC-01、AC-02、AC-03、AC-04、AC-05、AC-06、AC-07、AC-08、AC-62、AC-63、AC-65 + 依赖:T003、T004 + 配对测试:T004

    +
  • +
  • +

    [x] T006:具体租户解析与旧状态拒绝配对 + 类型:测试 + 文件:src/backend/test/admin/test_f114_concrete_scope.py、src/backend/test/admin/test_f114_scope_middleware.py + 逻辑/验证:覆盖超管具体 A/B、Root 非全平台、非超管伪造、无 key/过期/非法回所属租户、非法默认拒绝、null/global 切换不清旧值、实际范围审计。 + 覆盖 AC:AC-09、AC-10、AC-11、AC-12、AC-13、AC-14、AC-15、AC-17、AC-18、AC-64、AC-66 + 依赖:T003

    +
  • +
  • +

    [x] T007:范围真相与默认回退 + 类型:后端 Domain + 文件:src/backend/bisheng/admin/domain/services/tenant_scope.py、src/backend/bisheng/admin/domain/repositories/tenant_scope_repository.py + 逻辑/验证:D3:沿 admin_scope 用户级 Redis key/TTL;选择只接受 active 具体租户;默认/过期回本人合法租户,失败不改旧选择;非超管忽略缓存。读取跨租户事实须窄范围受信端口。 + 覆盖 AC:AC-09、AC-10、AC-11、AC-12、AC-13、AC-14、AC-17、AC-64 + 依赖:T006 + 配对测试:T006

    +
  • +
  • +

    [x] T008:管理范围端口与切换 API + 类型:后端 Domain + 文件:src/backend/bisheng/admin/application/tenant_scope.py、src/backend/bisheng/admin/api/endpoints/tenant_scope.py + 逻辑/验证:返回正整数 scope_tenant_id 和 selected/own 来源;EffectiveManagementScope 公开值对象;新写保护端口核对 X-Management-Tenant,F113 单旧头继续合法。 + 覆盖 AC:AC-09、AC-11、AC-12、AC-13、AC-14、AC-16、AC-17 + 依赖:T007、T006 + 配对测试:T006

    +
  • +
  • +

    [x] T009:范围注入与任务隔离 + 类型:后端 Domain + 文件:src/backend/bisheng/common/middleware/admin_scope.py、src/backend/bisheng/core/context/tenant.py + 逻辑/验证:读取同一有效范围并 pin 请求,具体租户过滤沿 ContextVar;仅明确管理路由接范围,开放 API/SDK/内部执行不继承页面选择;不为共用 GET/调用强制管理头。 + 覆盖 AC:AC-10、AC-15、AC-16、AC-17、AC-18、AC-66 + 依赖:T008、T006 + 配对测试:T006

    +
  • +
+

Wave 2:应用基础信息、内容与生命周期

+
    +
  • +

    [x] T010:应用管理内容和动作配对 + 类型:测试 + 文件:src/backend/test/app_runtime/test_f114_management_content.py、src/backend/test/app_publish/test_f114_secret_actions.py + 逻辑/验证:对 edit/publish/unpublish/delete/manage_permission/use 分别查询和写入;断言基础详情不夹带源码/manifest/failure/env/grants,edit 可管理他人数据日志;保存环境变量不重启、确认重建组合检查。 + 覆盖 AC:AC-07、AC-19、AC-20、AC-21、AC-22、AC-23、AC-24、AC-25、AC-26、AC-27、AC-28、AC-29、AC-30、AC-31、AC-32、AC-33、AC-42、AC-55、AC-61、AC-65 + 依赖:T005、T009

    +
  • +
  • +

    [x] T011:基础查询与版本安全投影 + 类型:后端 API + 文件:src/backend/bisheng/app_runtime/domain/services/app_query_service.py、src/backend/bisheng/app_runtime/api/endpoints/apps.py + 逻辑/验证:列表/基础详情/实例/版本摘要用任一管理动作,返回 allowed_actions;详细日志 edit;保持不存在保密,版本只读和准确状态提示,不能无条件返回私有 manifest。 + 覆盖 AC:AC-07、AC-08、AC-20、AC-21、AC-42、AC-55、AC-61、AC-63 + 依赖:T005、T009、T010 + 配对测试:T010

    +
  • +
  • +

    [x] T012:元信息及生命周期动作 + 类型:后端 Domain + 文件:src/backend/bisheng/app_runtime/domain/services/app_meta_service.py、src/backend/bisheng/app_runtime/domain/services/app_state_service.py + 逻辑/验证:元信息 edit;上线 publish、下线 unpublish、删除 delete;移除 owner/失效创建人资格分支,保留审批/容量/下线前置、确认、资源与 Grant 清理、实际 actor 审计。 + 覆盖 AC:AC-05、AC-19、AC-29、AC-30、AC-31、AC-32、AC-54、AC-63、AC-64、AC-65 + 依赖:T005、T009、T010 + 配对测试:T010

    +
  • +
  • +

    [x] T013:数据读取写入及恢复动作 + 类型:后端 Domain + 文件:src/backend/bisheng/app_runtime/domain/services/app_data_service.py、src/backend/bisheng/app_runtime/domain/services/runtime_data_session_service.py + 逻辑/验证:所有表/行/导出/会话结果 edit;恢复按是否启动要求 edit 或 edit AND publish;保留确认、表范围、票据归属和 writer 停止证据,不增加任意 SQL。 + 覆盖 AC:AC-22、AC-23、AC-28、AC-53、AC-65 + 依赖:T005、T010 + 配对测试:T010

    +
  • +
  • +

    [x] T014:配置管理与确认重建动作 + 类型:后端 Domain + 文件:src/backend/bisheng/app_publish/domain/services/app_secret_service.py、src/backend/bisheng/app_runtime/domain/services/secret_rebuild_service.py + 逻辑/验证:查看/保存配置 edit;确认重建 edit AND publish;沿原 revision/CAS、确认快照与幂等;保存无运行副作用,不改变 owner/密文规则。 + 覆盖 AC:AC-24、AC-25、AC-26、AC-40、AC-56、AC-65 + 依赖:T005、T010 + 配对测试:T010

    +
  • +
  • +

    [x] T067:有效审批审读与范围隔离测试 + 类型:测试 + 文件:src/backend/test/app_publish/test_f114_review_access.py + 逻辑/验证:edit允许长期审读;仅publish拒绝源码;任务与实例均pending才有临时资格;管理视角切到B后,A的有效个人任务仍按受信A读取且退出恢复原范围;SA不借归属人的任务;审批资格仅覆盖待审版本及其正式比较基线。 + 覆盖 AC:AC-18、AC-20、AC-38、AC-49、AC-50、AC-51、AC-53、AC-69 + 依赖:T005、T009

    +
  • +
  • +

    [x] T068:审批拥有的审读资格公开端口 + 类型:后端 Domain + 文件:src/backend/bisheng/approval/domain/repositories/app_review_repository.py、src/backend/bisheng/approval/application/app_review.py + 逻辑/验证:窄范围按已验证viewer、应用及pending实例/任务查询,Python解析冻结version,不暴露ORM;返回只读tenant/app/version/task/instance绑定;无新增状态或审批引擎行为。 + 覆盖 AC:AC-18、AC-20、AC-49、AC-50、AC-53、AC-69 + 依赖:T067 + 配对测试:T067

    +
  • +
  • +

    [x] T069:审批技能与跨域接口说明同步 + 类型:文档 + 文件:.claude/skills/approval-module/SKILL.md + 逻辑/验证:随新增公开端口同步代码锚点和实际pending审读规则;范围章节按已验证的具体租户middleware更新。后续候选/业务撤回实现后再增量同步其真实行为,不预写交付。 + 覆盖 AC:AC-15、AC-49、AC-50、AC-52、AC-66 + 依赖:T068

    +
  • +
  • +

    [x] T015:快照与版本私有内容授权 + 类型:后端 Domain + 文件:src/backend/bisheng/app_publish/domain/services/snapshot_browse_service.py、src/backend/bisheng/app_publish/domain/services/version_diff_service.py + 逻辑/验证:源码快照、文件、完整版本差异 edit;审批临时审读只走可信任务公开端口,不以长期 edit 替代审批,也不凭知晓版本 ID 放行。 + 覆盖 AC:AC-20、AC-27、AC-42、AC-50、AC-53 + 依赖:T005、T010、T068 + 配对测试:T010、T067

    +
  • +
  • +

    [x] T016:应用运行管理用途映射 + 类型:后端 API + 文件:src/backend/bisheng/app_runtime/domain/services/runtime_management_service.py、src/backend/bisheng/app_runtime/api/endpoints/runtime_management.py + 逻辑/验证:移除 natural_owner_required;私有依赖/快照 edit,恢复组合取可信用途,取消按当前取消者原动作集合;公共 profile/依赖仍平台运维;旧 upgrade/rebind 不注册。 + 覆盖 AC:AC-27、AC-28、AC-53、AC-55、AC-56、AC-57、AC-58、AC-70、AC-71 + 依赖:T005、T010 + 配对测试:T010

    +
  • +
  • +

    [x] T017:按应用运行记录资格配对 + 类型:测试 + 文件:src/backend/test/app_governance/test_f114_audit_actions.py + 逻辑/验证:edit 可查看/导出非本人 app 的匹配记录,缺 edit 和跨具体租户拒绝;跨应用审计还需原菜单资格;导出结果取回再次检查。 + 覆盖 AC:AC-15、AC-34、AC-62、AC-64 + 依赖:T005、T009

    +
  • +
  • +

    [x] T018:运行记录查询和导出范围 + 类型:后端 Domain + 文件:src/backend/bisheng/app_governance/domain/services/runtime_audit_scope.py、src/backend/bisheng/app_governance/domain/services/runtime_audit_export_service.py + 逻辑/验证:F056 沿 source owner repository 与现有一致导出;单 app 资格改 edit,保留审计入口及具体租户,禁止凭旧导出 URL 继续越权。 + 覆盖 AC:AC-15、AC-34、AC-62、AC-64 + 依赖:T005、T009、T017 + 配对测试:T017

    +
  • +
+

Wave 3:真实主体首发、代发布与审批

+
    +
  • +

    [x] T019:开放通道真实主体与首发投影 + 类型:测试 + 文件:src/backend/test/app_publish/test_f114_publish_actor.py、src/backend/test/app_runtime/test_f114_creation_actor.py + 逻辑/验证:SA 首发归属自然人但 CREATOR_GRANT 归真实 SA;投影失败不排任务,未知保留围栏;已获授权可代发布他人 app,粗 scope 无动作拒绝,F113/mode/scopes 保留。 + 覆盖 AC:AC-05、AC-35、AC-36、AC-39、AC-43、AC-44、AC-45、AC-46、AC-47、AC-48、AC-64 + 依赖:T005

    +
  • +
  • +

    [x] T020:创建权限投影传递真实主体 + 类型:后端 Domain + 文件:src/backend/bisheng/app_runtime/domain/services/app_provision_service.py、src/backend/bisheng/app_runtime/domain/services/f048_app_permission.py + 逻辑/验证:owner/tenant 保持原事实,authorize_created 接真实 PermissionActor;复用 F048 CURRENT CREATOR_GRANT 并验证 edit AND publish;rollback_created 完成后才删孤立行,未知不盲删。 + 覆盖 AC:AC-05、AC-43、AC-44、AC-48、AC-64 + 依赖:T005、T019 + 配对测试:T019

    +
  • +
  • +

    [x] T021:发布受理冻结真实授权来源 + 类型:后端 API + 文件:src/backend/bisheng/app_publish/domain/services/publish_pipeline_service.py、src/backend/bisheng/app_publish/api/endpoints/deploy.py + 逻辑/验证:已有 app 按真实 actor edit AND publish,首次沿 create;owner 不变,冻结用户来源与 self_review_user_id;scope/mode/F113 和密钥/扫描/预检规则保留,客户端不能传内部 context。 + 覆盖 AC:AC-35、AC-36、AC-39、AC-40、AC-41、AC-43、AC-44、AC-45、AC-47、AC-48、AC-64 + 依赖:T001、T020、T019 + 配对测试:T019

    +
  • +
  • +

    [x] T022:CLI/MCP 查询与管理真实授权 + 类型:后端 Domain + 文件:src/backend/bisheng/open_mcp/tools/apps.py、src/backend/bisheng/app_publish/domain/services/publish_status_service.py + 逻辑/验证:列表与状态不按 SA owner 过滤替代资源动作;复用安全摘要/日志 edit,已有 supported scope/mode 原样;不新增 PAT 通道。 + 覆盖 AC:AC-07、AC-21、AC-43、AC-45、AC-46、AC-47、AC-48 + 依赖:T005、T011、T021、T019 + 配对测试:T019

    +
  • +
  • +

    [x] T023:自审排除及业务撤回配对 + 类型:测试 + 文件:src/backend/test/app_publish/test_f114_review_actor.py、src/backend/test/approval/test_f114_business_withdraw.py + 逻辑/验证:owner 与实际关联自然人双排除;SA 后改绑不改快照;单候选不等单管理员;后续节点/指定/重试不绕过;publish 用户可代撤回但其它场景 applicant 不变。 + 覆盖 AC:AC-37、AC-39、AC-41、AC-49、AC-52、AC-54、AC-67、AC-68 + 依赖:T021

    +
  • +
  • +

    [x] T024:应用发布候选与异常重试 + 类型:后端 Domain + 文件:src/backend/bisheng/app_publish/domain/services/app_publish_scenario_handler.py、src/backend/bisheng/app_publish/domain/services/publish_approval_service.py + 逻辑/验证:handler 消费冻结提交身份;增加仅应用场景候选校验,双重排除,可信租户管理员数量证明单人例外;无合格候选走既有异常,不自动通过。 + 覆盖 AC:AC-39、AC-41、AC-49、AC-52、AC-67、AC-68 + 依赖:T021、T023 + 配对测试:T023

    +
  • +
  • +

    [x] T025:审批引擎扩展点和公开业务撤回 + 类型:后端 Domain + 文件:src/backend/bisheng/approval/domain/services/approval_center_service.py、src/backend/bisheng/approval/application/app_publish.py + 逻辑/验证:仅应用场景可选校验钩子默认不改其它场景;公开可信目标/事务撤回接口复用 withdrawn 引擎、终态与审计;不跨域直接写审批 ORM。 + 覆盖 AC:AC-37、AC-49、AC-52、AC-54、AC-67、AC-68 + 依赖:T024、T023 + 配对测试:T023

    +
  • +
  • +

    [x] T026:发布上线与主动撤回动作 + 类型:后端 Domain + 文件:src/backend/bisheng/app_publish/domain/services/publish_online_service.py、src/backend/bisheng/app_publish/domain/services/publish_terminal_service.py + 逻辑/验证:上线 publish,不再 owner 特判;撤回当前 publish;保留冻结 app/tenant/version、审批结果、取消清理及真实 actor,异常管理当前具体范围和原任务资格。 + 覆盖 AC:AC-29、AC-37、AC-40、AC-41、AC-49、AC-52、AC-54、AC-64 + 依赖:T025、T005、T023 + 配对测试:T023

    +
  • +
+

Wave 4:异步来源、首个效果与 v2 阶段许可

+
    +
  • +

    [x] T027:阶段授权与系统恢复配对 + 类型:测试 + 文件:src/backend/test/app_runtime/test_f114_execution_authorization.py、src/backend/test/app_runtime/test_f114_system_recovery.py + 逻辑/验证:用户凭据撤权、未知上下文拒绝新效果;验证 maintenance 前不影响原 writer,维护后仅安全收尾;recover 只认可信 serving 来源/精确转换,不依赖历史 actor,auto_commit 无豁免;取消组合和阶段限制。 + 覆盖 AC:AC-04、AC-26、AC-28、AC-40、AC-53、AC-54、AC-56、AC-60、AC-62、AC-64、AC-65、AC-70 + 依赖:T005、T021

    +
  • +
  • +

    [x] T028:受信来源与一步授权服务 + 类型:后端 Domain + 文件:src/backend/bisheng/app_runtime/domain/services/runtime_authorization_service.py、src/backend/bisheng/app_runtime/application/runtime_authorization.py + 逻辑/验证:按 design §4.5/§6.2 实现 user_request/system_recovery 的生产/加载/验证值对象,复核 credential/current actor/用途动作或可信运行意图;不接受客户端自证,许可绑定完整执行围栏与短期限。 + 覆盖 AC:AC-04、AC-40、AC-53、AC-56、AC-60、AC-62、AC-64、AC-70 + 依赖:T005、T027 + 配对测试:T027

    +
  • +
  • +

    [x] T029:维护事实与恢复来源事务 + 类型:后端 Domain + 文件:src/backend/bisheng/app_runtime/domain/services/runtime_delivery_coordinator.py、src/backend/bisheng/app_runtime/domain/services/runtime_operation_service.py + 逻辑/验证:recover 同一 UoW 冻结 route/op/version/delivery,保留 pending/幂等;fact 维护前复核并原子记录 maintenance_transition,commit 再复核;用户取消按用途,system 无通用 cancel,由 unpublish 终止意图。 + 覆盖 AC:AC-26、AC-28、AC-40、AC-53、AC-54、AC-56、AC-60、AC-65、AC-70 + 依赖:T028、T012、T027 + 配对测试:T027

    +
  • +
  • +

    [x] T030:内部阶段 API 与值对象 + 类型:后端 API + 文件:src/backend/bisheng/app_runtime/api/endpoints/runtime_delivery.py、src/backend/bisheng/app_runtime/domain/schemas/runtime_delivery.py + 逻辑/验证:新增 manager HMAC/nonce authorize-step,校验 op/node/gen/owner_epoch/stage/lease/deadline;只返回短期 permit,不返回私有 env/用户 token;旧内部 handler 无任意授权写口。 + 覆盖 AC:AC-40、AC-53、AC-60、AC-65 + 依赖:T029、T027 + 配对测试:T027

    +
  • +
  • +

    [x] T031:Celery 与预检阶段重验 + 类型:Worker + 文件:src/backend/bisheng/worker/app_publish/tasks.py、src/backend/bisheng/app_publish/domain/services/precheck_service.py + 逻辑/验证:tenant_id 由 Celery headers 恢复 ContextVar;准备、创建版本/审批前重载冻结来源;无效落可解释终态与清理,旧版本不被提前中断,预检不注入真密钥。 + 覆盖 AC:AC-18、AC-35、AC-36、AC-40、AC-41、AC-51、AC-53、AC-54、AC-64 + 依赖:T021、T030、T027 + 配对测试:T027

    +
  • +
  • +

    [x] T032:manager 维护首效果与许可重放 + 类型:测试 + 文件:src/runtime-manager/tests/test_f114_stage_permits.py + 逻辑/验证:mock 平台客户端验证请求/维护fact确认/停writer/备份迁移/starting/route顺序;失效/未知回复拒绝新效果,同sequence查询重放不双投影;Docker/K8s复用执行器。 + 覆盖 AC:AC-40、AC-53、AC-54、AC-60、AC-65、AC-71 + 依赖:T030

    +
  • +
  • +

    [x] T033:manager 一步许可与首次副作用顺序 + 类型:后端 Domain + 文件:src/runtime-manager/runtime_manager/platform_client.py、src/runtime-manager/runtime_manager/operations.py + 逻辑/验证:沿唯一 v2 客户端请求阶段许可;维护准入→fact确认→停writer→独占数据步骤新许可→启动新许可;结果未知不得自行当成功,必要清理保持原围栏。 + 覆盖 AC:AC-40、AC-53、AC-54、AC-60、AC-65、AC-71 + 依赖:T030、T032 + 配对测试:T032

    +
  • +
+

Wave 5:双用途预览及平台能力

+
    +
  • +

    [x] T034:预览事务、并发、资格与回收 + 类型:测试 + 文件:src/backend/test/app_publish/test_f114_preview_lifecycle.py、src/backend/test/app_publish/test_f114_preview_access.py + 逻辑/验证:Version 串行键含viewer/purpose/task;同 repo 事务登记 session/credential/op/sealed_env/audit;提交未知先查询,commit后wake;starting拒绝,ready激活;失败/过期只吊销该凭据,不动生产;双用途身份/任务/OBO混配拒绝。 + 覆盖 AC:AC-38、AC-49、AC-50、AC-51、AC-53、AC-54、AC-59、AC-65、AC-69 + 依赖:T001、T002、T005、T030、T025

    +
  • +
  • +

    [x] T035:预览生命周期仓储 + 类型:后端 Domain + 文件:src/backend/bisheng/app_publish/domain/repositories/preview_runtime_repository.py、src/backend/bisheng/app_publish/application/preview_runtime.py + 逻辑/验证:提供 caller-owned session 的 Version→operation→session/credential 锁序和 lifecycle值对象,完整绑定查询/ready激活/原子关闭;不独立commit,不跨域暴露ORM。 + 覆盖 AC:AC-38、AC-50、AC-53、AC-54、AC-65、AC-69 + 依赖:T001、T002、T034 + 配对测试:T034

    +
  • +
  • +

    [x] T036:预览独立凭据事务端口 + 类型:后端 Domain + 文件:src/backend/bisheng/open_api/application/runtime_credentials.py、src/backend/bisheng/app_publish/domain/services/app_credential_service.py + 逻辑/验证:复用 issue_in_transaction 签发 per-preview凭据,同session单凭据吊销及审计;不使用正式stage_binding/reissue/revoke_subject;无明文返回浏览器。 + 覆盖 AC:AC-50、AC-51、AC-53、AC-54、AC-59、AC-65、AC-69 + 依赖:T035、T034 + 配对测试:T034

    +
  • +
  • +

    [x] T037:预览启动和用途 API + 类型:后端 API + 文件:src/backend/bisheng/app_publish/domain/services/preview_instance_service.py、src/backend/bisheng/app_publish/api/endpoints/preview.py + 逻辑/验证:edit管理预览与当前任务审批预览分型;显式用途或唯一资格推导;原子starting/凭据/op/env,提交后wake/wait;重试同键复用,数据库和附件命名空间隔离;过期终态同端口回收。 + 覆盖 AC:AC-38、AC-49、AC-50、AC-51、AC-53、AC-54、AC-65、AC-69 + 依赖:T036、T030、T025、T034 + 配对测试:T034

    +
  • +
  • +

    [x] T038:预览运行事实与清理接线 + 类型:后端 Domain + 文件:src/backend/bisheng/app_runtime/domain/services/runtime_cleanup_service.py、src/backend/bisheng/app_runtime/domain/repositories/runtime_delivery_repository.py + 逻辑/验证:preview fact/reclaim进入operation锁之前取得Version锁;ready与route事务激活,失败/取消/超时登记单会话清理;停止证据成立才释放容量;沿既有任务,不加驻留进程。 + 覆盖 AC:AC-50、AC-53、AC-54、AC-60、AC-65、AC-69 + 依赖:T037、T030、T034 + 配对测试:T034

    +
  • +
  • +

    [x] T039:运行身份、用途 OBO 与能力配对 + 类型:测试 + 文件:src/backend/test/open_api/test_f114_preview_identity.py、src/backend/test/app_publish/test_f114_preview_capabilities.py + 逻辑/验证:management使用viewer本人,approval用冻结owner能力且实际viewer审计;冻结version声明,不取current;同步/异步/排队重验,缺OBO或凭据会话混配拒绝,正式app_self不变。 + 覆盖 AC:AC-38、AC-47、AC-50、AC-51、AC-59、AC-69 + 依赖:T037

    +
  • +
  • +

    [x] T040:可信预览访问上下文 + 类型:后端 Domain + 文件:src/backend/bisheng/app_publish/application/preview_access.py、src/backend/bisheng/app_runtime/domain/services/entry_authz_service.py + 逻辑/验证:签名之后仍查完整running会话、viewer、tenant/app/version/purpose/credential/op以及当前资格;公开 CapabilityAccessContext,approval任务scope不随页面选择,management具体scope;缺端口拒绝。 + 覆盖 AC:AC-38、AC-50、AC-51、AC-53、AC-59、AC-69 + 依赖:T037、T005、T039 + 配对测试:T039

    +
  • +
  • +

    [x] T041:开放认证及模型范围消费 + 类型:后端 Domain + 文件:src/backend/bisheng/open_api/domain/services/access_context.py、src/backend/bisheng/open_api/domain/services/model_range_policy.py + 逻辑/验证:正式凭据沿route;预览只认可信会话绑定/OBO;AccessSubject投影保留用途能力主体与冻结声明版本,模型沿原F051租户启用/声明,不能放宽底层授权。 + 覆盖 AC:AC-38、AC-47、AC-50、AC-51、AC-59、AC-69 + 依赖:T040、T039 + 配对测试:T039

    +
  • +
  • +

    [x] T042:能力总线与组合根注册 + 类型:后端 Domain + 文件:src/backend/bisheng/app_publish/domain/services/capability_bus_service.py、src/backend/bisheng/app_publish/composition.py + 逻辑/验证:模型/检索/附件用可信上下文,分别实际viewer与能力主体审计;API/worker组合根均注册端口,异步任务仅传引用并执行时重验;SDK附件不是app.delete。 + 覆盖 AC:AC-18、AC-38、AC-50、AC-51、AC-59、AC-64、AC-69 + 依赖:T041、T039 + 配对测试:T039

    +
  • +
+

Wave 6:运行协议唯一 v2 与公共能力保留

+
    +
  • +

    [x] T043:旧协议拒绝与共用能力保留 + 类型:测试 + 文件:src/backend/test/app_runtime/test_f114_v2_only.py、src/runtime-manager/tests/test_f114_v2_only.py + 逻辑/验证:默认2;显式1/未知进程启动前失败;旧manager RPC、inventory、backend upgrade/rebind不注册无副作用;bundle/image/source-image、SQLite/附件公共工具仍可调用;不运行v1成功链。 + 覆盖 AC:AC-18、AC-27、AC-53、AC-57、AC-58、AC-59、AC-60、AC-65、AC-71 + 依赖:T030、T016

    +
  • +
  • +

    [x] T044:后端唯一控制协议 + 类型:后端 Domain + 文件:src/backend/bisheng/app_runtime/domain/services/runtime_protocol.py、src/backend/bisheng/app_runtime/domain/services/orchestrator_client.py + 逻辑/验证:固定v2且显式非2拒绝,调用沿v2持久operation;按调用者清理旧RPC分支,不改平台API版本/manifest格式;保留构建、数据、存储公共能力。 + 覆盖 AC:AC-53、AC-57、AC-58、AC-59、AC-60、AC-65、AC-71 + 依赖:T043 + 配对测试:T043

    +
  • +
  • +

    [x] T045:manager 旧路由/对账退役 + 类型:后端 Domain + 文件:src/runtime-manager/runtime_manager/config.py、src/runtime-manager/runtime_manager/main.py + 逻辑/验证:默认2并拒绝非2;不注册旧router/inventory/desired-state对账,先提取v2复用builder/appdb工具;不删除资产,不恢复v1fallback。 + 覆盖 AC:AC-53、AC-57、AC-58、AC-59、AC-60、AC-65、AC-71 + 依赖:T044、T043 + 配对测试:T043

    +
  • +
  • +

    [x] T046:入口与出口代理仅用 v2 + 类型:后端 Domain + 文件:src/app-proxy/app_proxy/config.py、src/runtime-manager/runtime_manager/egress_proxy.py + 逻辑/验证:代理默认v2并拒绝旧配置/回退;保留HTTP/WebSocket/OBO与egress公共策略,内部来源校验不放宽。 + 覆盖 AC:AC-57、AC-59、AC-65、AC-71 + 依赖:T045、T043 + 配对测试:T043

    +
  • +
  • +

    [x] T047:旧后端升级路由及配置引用清理 + 类型:后端 API + 文件:src/backend/bisheng/app_runtime/api/router.py、src/backend/bisheng/app_runtime/api/endpoints/runtime_management.py + 逻辑/验证:不注册import/upgrade/rebind;审查同名公共工具的消费者,不顺带删除v2保留能力;源代码删除清单/运维前置记录到设计,不执行存量迁移。 + 覆盖 AC:AC-57、AC-65、AC-71 + 依赖:T045、T016、T043 + 配对测试:T043

    +
  • +
+

Wave 7:前端动作、范围与用途一致性

+
    +
  • +

    [x] T048:平台权限与租户视角组件配对 + 类型:测试 + 文件:src/frontend/platform/src/test/f114AppActions.test.tsx、src/frontend/platform/src/test/f114ConcreteScope.test.tsx + 逻辑/验证:分别用六动作render卡片/详情/tabs/按钮,use-only无管理;无owner特判、不预取私有tabs;switch清query/表单,跨标签刷新;空租户filter,无global项。 + 覆盖 AC:AC-06、AC-07、AC-09、AC-10、AC-11、AC-12、AC-13、AC-14、AC-15、AC-16、AC-17、AC-24、AC-26、AC-29、AC-30、AC-31、AC-32、AC-33、AC-55、AC-61、AC-62、AC-63、AC-65、AC-66 + 依赖:T011、T009、T014、T012

    +
  • +
  • +

    [x] T049:平台 API 动作与租户快照 + 类型:前端 Platform + 文件:src/frontend/platform/src/controllers/API/hostedApp.ts、src/frontend/platform/src/controllers/request.ts + 逻辑/验证:按服务端allowed_actions,专用管理写携预期具体租户,F113旧单头兼容;共用GET/调用不强制头,范围变化导致可解释拒绝并刷新;不在store发HTTP。 + 覆盖 AC:AC-07、AC-15、AC-16、AC-17、AC-18、AC-61、AC-62、AC-63、AC-66 + 依赖:T009、T005、T048 + 配对测试:T048 + 人工验证:仅本功能页面,以有权/无权用户对比可见内容、按钮、直接请求与范围切换;设计 §7.3,执行证据写verification。

    +
  • +
  • +

    [x] T050:顶部租户单选与范围失效 + 类型:前端 Platform + 文件:src/frontend/platform/src/components/HeaderTenantScope.tsx、src/frontend/platform/src/controllers/API/tenant.ts + 逻辑/验证:只列active具体租户,服务端生效值为准;移除全平台/null;切换/失效清旧scope query与可提交表单,监听跨标签变化;不变真实身份。 + 覆盖 AC:AC-09、AC-10、AC-11、AC-12、AC-13、AC-14、AC-15、AC-16、AC-17 + 依赖:T049、T048 + 配对测试:T048 + 人工验证:仅本功能页面,以有权/无权用户对比可见内容、按钮、直接请求与范围切换;设计 §7.3,执行证据写verification。

    +
  • +
  • +

    [x] T051:应用卡片动作一致性 + 类型:前端 Platform + 文件:src/frontend/platform/src/pages/BuildPage/HostedAppCard.tsx、src/frontend/platform/src/pages/BuildPage/useHostedAppActions.ts + 逻辑/验证:以allowed_actions和业务状态展示元信息/上下线/删除/授权;保留上线下线删除确认与禁止在线切版本;不因非owner隐藏有效动作。 + 覆盖 AC:AC-07、AC-19、AC-29、AC-30、AC-31、AC-32、AC-33、AC-42、AC-61、AC-63、AC-65 + 依赖:T050、T012、T048 + 配对测试:T048 + 人工验证:仅本功能页面,以有权/无权用户对比可见内容、按钮、直接请求与范围切换;设计 §7.3,执行证据写verification。

    +
  • +
  • +

    [x] T052:详情内容分层与请求门控 + 类型:前端 Platform + 文件:src/frontend/platform/src/pages/BuildPage/hostedApp/index.tsx、src/frontend/platform/src/pages/BuildPage/hostedApp/hooks/useHostedApp.ts + 逻辑/验证:基础可见与edit内容分开;无edit不请求data/logs/env/private版本详情,不以is_owner授权;范围改变卸载表单与内容。 + 覆盖 AC:AC-06、AC-07、AC-20、AC-21、AC-22、AC-24、AC-33、AC-42、AC-55、AC-61、AC-62 + 依赖:T051、T011、T048 + 配对测试:T048 + 人工验证:仅本功能页面,以有权/无权用户对比可见内容、按钮、直接请求与范围切换;设计 §7.3,执行证据写verification。

    +
  • +
  • +

    [x] T053:发布面板与配置确认资格 + 类型:前端 Platform + 文件:src/frontend/platform/src/pages/BuildPage/hostedApp/tabs/PublishTab.tsx、src/frontend/platform/src/pages/BuildPage/hostedApp/publish/AppSecretSettings.tsx + 逻辑/验证:隐藏无作用提交发布;edit配置保存、edit AND publish确认重建;按动作展示管理预览/授权/运行按钮,保留确认/密文状态与准确拒绝。 + 覆盖 AC:AC-24、AC-25、AC-26、AC-29、AC-30、AC-33、AC-38、AC-42、AC-61、AC-63、AC-65 + 依赖:T052、T014、T048 + 配对测试:T048 + 人工验证:仅本功能页面,以有权/无权用户对比可见内容、按钮、直接请求与范围切换;设计 §7.3,执行证据写verification。

    +
  • +
  • +

    [x] T054:管理预览用途与发布展示 + 类型:前端 Platform + 文件:src/frontend/platform/src/controllers/API/hostedAppPreview.ts、src/frontend/platform/src/pages/BuildPage/hostedApp/publish/OwnerPreviewCard.tsx + 逻辑/验证:显式purpose=management,文案用管理预览,按edit显示;不从角色推审批例外;展示真实提交者及自然人归属区别。 + 覆盖 AC:AC-38、AC-39、AC-50、AC-51、AC-61、AC-64、AC-69 + 依赖:T053、T037、T048 + 配对测试:T048 + 人工验证:仅本功能页面,以有权/无权用户对比可见内容、按钮、直接请求与范围切换;设计 §7.3,执行证据写verification。

    +
  • +
  • +

    [x] T055:审批客户端用途兼容配对 + 类型:测试 + 文件:src/frontend/client/src/api/hostedAppReview.test.ts、src/frontend/client/src/components/approval/AppPreviewPanel.test.tsx + 逻辑/验证:审批预览显式approval,省略单一用途兼容由服务器决定;双用途隔离,失效任务拒绝;不改其它资源的调用动作。 + 覆盖 AC:AC-49、AC-50、AC-51、AC-59、AC-66、AC-69 + 依赖:T037、T041

    +
  • +
  • +

    [x] T056:客户端审批预览参数与展示 + 类型:前端 Client + 文件:src/frontend/client/src/api/hostedAppPreview.ts、src/frontend/client/src/components/approval/AppPreviewPanel.tsx + 逻辑/验证:仅审批客户端显式purpose=approval,沿既有Recoil/API包装/组件;展示实际审批者与提交主体不冒用owner;任务失效清旧预览。 + 覆盖 AC:AC-39、AC-49、AC-50、AC-51、AC-64、AC-66、AC-69 + 依赖:T037、T041、T055 + 配对测试:T055 + 人工验证:仅本功能页面,以有权/无权用户对比可见内容、按钮、直接请求与范围切换;设计 §7.3,执行证据写verification。

    +
  • +
  • +

    [x] T057:错误源文案 zh-Hans + 类型:前端 Platform + 文件:src/frontend/packages/locales/src/api_errors/zh-Hans.json + 逻辑/验证:范围错误和动作拒绝的该语言源文案;三语同交付。使用locale生成命令更新派生件,不手改生成文件或baseline。 + 覆盖 AC:AC-09、AC-13、AC-14、AC-16、AC-63 + 依赖:T053 + 人工验证:仅本功能页面,以有权/无权用户对比可见内容、按钮、直接请求与范围切换;设计 §7.3,执行证据写verification。

    +
  • +
  • +

    [x] T058:错误源文案 en + 类型:前端 Platform + 文件:src/frontend/packages/locales/src/api_errors/en.json + 逻辑/验证:范围错误和动作拒绝的该语言源文案;三语同交付。使用locale生成命令更新派生件,不手改生成文件或baseline。 + 覆盖 AC:AC-09、AC-13、AC-14、AC-16、AC-63 + 依赖:T053 + 人工验证:仅本功能页面,以有权/无权用户对比可见内容、按钮、直接请求与范围切换;设计 §7.3,执行证据写verification。

    +
  • +
  • +

    [x] T059:错误源文案 ja + 类型:前端 Platform + 文件:src/frontend/packages/locales/src/api_errors/ja.json + 逻辑/验证:范围错误和动作拒绝的该语言源文案;三语同交付。使用locale生成命令更新派生件,不手改生成文件或baseline。 + 覆盖 AC:AC-09、AC-13、AC-14、AC-16、AC-63 + 依赖:T053 + 人工验证:仅本功能页面,以有权/无权用户对比可见内容、按钮、直接请求与范围切换;设计 §7.3,执行证据写verification。

    +
  • +
  • +

    [x] T060:平台业务文案 zh-Hans + 类型:前端 Platform + 文件:src/frontend/platform/public/locales/zh-Hans/bs.json + 逻辑/验证:本功能范围/管理预览/实际主体的该语言文案。触及冻结中文的源码文件同时用i18n-localizer清理,不新增库或修改组件视觉。 + 覆盖 AC:AC-09、AC-13、AC-14、AC-16、AC-38、AC-39、AC-63 + 依赖:T053 + 人工验证:仅本功能页面,以有权/无权用户对比可见内容、按钮、直接请求与范围切换;设计 §7.3,执行证据写verification。

    +
  • +
  • +

    [x] T061:平台业务文案 en-US + 类型:前端 Platform + 文件:src/frontend/platform/public/locales/en-US/bs.json + 逻辑/验证:本功能范围/管理预览/实际主体的该语言文案。触及冻结中文的源码文件同时用i18n-localizer清理,不新增库或修改组件视觉。 + 覆盖 AC:AC-09、AC-13、AC-14、AC-16、AC-38、AC-39、AC-63 + 依赖:T053 + 人工验证:仅本功能页面,以有权/无权用户对比可见内容、按钮、直接请求与范围切换;设计 §7.3,执行证据写verification。

    +
  • +
  • +

    [x] T062:平台业务文案 ja + 类型:前端 Platform + 文件:src/frontend/platform/public/locales/ja/bs.json + 逻辑/验证:本功能范围/管理预览/实际主体的该语言文案。触及冻结中文的源码文件同时用i18n-localizer清理,不新增库或修改组件视觉。 + 覆盖 AC:AC-09、AC-13、AC-14、AC-16、AC-38、AC-39、AC-63 + 依赖:T053 + 人工验证:仅本功能页面,以有权/无权用户对比可见内容、按钮、直接请求与范围切换;设计 §7.3,执行证据写verification。

    +
  • +
+

Wave 8:专用验收与特性收尾

+
    +
  • +

    [ ] T063:F114 专用 API E2E 与 AC 证据 + 类型:测试 + 文件:src/backend/test/e2e/test_f114_permission_actions.py、features/v3.0.0/114-vibe-permission-actions/verification.md + 逻辑/验证:按e2e-test生成仅本特性临时A/B租户、owner/授权管理者/use-only/SA/审批主体配对;Docker/K8s只验证v2主流程与本次边界;先只读盘点旧资产,未授权不覆盖;DM8仅静态,未执行项明确。 + 覆盖 AC:AC-01、AC-02、AC-03、AC-04、AC-05、AC-06、AC-07、AC-08、AC-09、AC-10、AC-11、AC-12、AC-13、AC-14、AC-15、AC-16、AC-17、AC-18、AC-19、AC-20、AC-21、AC-22、AC-23、AC-24、AC-25、AC-26、AC-27、AC-28、AC-29、AC-30、AC-31、AC-32、AC-33、AC-34、AC-35、AC-36、AC-37、AC-38、AC-39、AC-40、AC-41、AC-42、AC-43、AC-44、AC-45、AC-46、AC-47、AC-48、AC-49、AC-50、AC-51、AC-52、AC-53、AC-54、AC-55、AC-56、AC-57、AC-58、AC-59、AC-60、AC-61、AC-62、AC-63、AC-64、AC-65、AC-66、AC-67、AC-68、AC-69、AC-70、AC-71 + 依赖:T053、T041、T045、T030、T009

    +
  • +
  • +

    [ ] T064:L1 汇总与 L2 审查提测 + 类型:收尾 + 文件:features/v3.0.0/114-vibe-permission-actions/verification.md、features/v3.0.0/114-vibe-permission-actions/code-review.md + 逻辑/验证:逐任务L1后执行sdd-code-review --base feat/3.0.0,核对实现接线和71AC,不以文档存在宣称交付;只跑必要定向测试/Ruff/arch-guard/单迁移head与前端必要质量门禁,真实E2E与CI/发行分别报告。合并/推送按现有授权,不擅自发tag。 + 覆盖 AC:AC-01、AC-02、AC-03、AC-04、AC-05、AC-06、AC-07、AC-08、AC-09、AC-10、AC-11、AC-12、AC-13、AC-14、AC-15、AC-16、AC-17、AC-18、AC-19、AC-20、AC-21、AC-22、AC-23、AC-24、AC-25、AC-26、AC-27、AC-28、AC-29、AC-30、AC-31、AC-32、AC-33、AC-34、AC-35、AC-36、AC-37、AC-38、AC-39、AC-40、AC-41、AC-42、AC-43、AC-44、AC-45、AC-46、AC-47、AC-48、AC-49、AC-50、AC-51、AC-52、AC-53、AC-54、AC-55、AC-56、AC-57、AC-58、AC-59、AC-60、AC-61、AC-62、AC-63、AC-64、AC-65、AC-66、AC-67、AC-68、AC-69、AC-70、AC-71 + 依赖:T063

    +
  • +
+

必要接线增量(仍属已确认设计)

+
    +
  • +

    [x] T065:运行数据短票据重验与真实主体 + 类型:后端 Domain + 文件:src/backend/bisheng/app_runtime/domain/services/runtime_data_grant_service.py + 逻辑/验证:T013接入后,同一数据会话在签发短票据前也复核当前edit;actor_ref采用受信PermissionActor.fga_subject,不能仍调用旧owner检查或借用归属人。共享签名与5秒期限不变。配对测试为T010中新增数据会话/签发拒绝场景。 + 覆盖 AC:AC-22、AC-23、AC-43、AC-53、AC-62、AC-65 + 依赖:T010、T013 + 配对测试:T010

    +
  • +
  • +

    [x] T066:范围上下文受影响回归 + 类型:测试 + 文件:src/backend/test/tenant/test_tenant_execution_scope.py + 逻辑/验证:既有上下文嵌套/并发/异常恢复保持;旧全平台旗标即使恢复也不再公开全平台数据视角。新增管理范围只改变视角、不改真实membership的断言。 + 覆盖 AC:AC-09、AC-16、AC-17、AC-18 + 依赖:T009

    +
  • +
+

实际偏差记录

+

暂无。推翻已确认AC或设计决策时先按SDD-Guide §3重新确认;实现细节仅回写design并在此留单行指针。

+

L1 任务审查索引

+

实现前无已通过记录;完成时在task-reviews.md逐项登记文件、配对测试、依赖及七项检查,不能以任务打勾代替运行证据。

+

必要接线补充(按已确认设计,不扩展产品范围)

+
    +
  • +

    [x] T070:新 app 创建投影的逆向清理 + 类型:后端 Domain + 文件:src/backend/bisheng/permission/application/runtime.py + 逻辑/验证:已确认D2要求确认 rollback_created 后才能删孤立行;现端口只接受 folder/knowledge_file,扩至新app且仍限制version=0与原创建ledger;未知结果保留行和围栏,不改变其它资源语义。 + 覆盖 AC:AC-43、AC-44、AC-48 + 依赖:T019 + 配对测试:T019

    +
  • +
  • +

    [x] T071:无令牌的延迟认证公开端口 + 类型:后端 Domain + 文件:src/backend/bisheng/open_api/application/runtime_authorization.py、src/backend/bisheng/user/domain/services/auth.py + 逻辑/验证:公开credential-id实时重验并复用既有mode/S/F113/scopes上下文;Web凭据只冻结已验证JWT的exp和token_version,不存原文;缺失/不确定拒绝,保持原JWT和OpenAPI访问规则。 + 覆盖 AC:AC-04、AC-18、AC-39、AC-40、AC-43、AC-47、AC-64 + 依赖:T027 + 配对测试:T027

    +
  • +
  • +

    [x] T072:运行记录应用范围与查询接线 + 类型:后端 Domain + 文件:src/backend/bisheng/app_governance/domain/services/runtime_audit_query_service.py、src/backend/bisheng/app_governance/domain/schemas/runtime_audit.py + 逻辑/验证:已确认AC-34需要将已授权app集合带入source查询/游标和导出;缺edit的app不贡献记录/候选,单app与跨app菜单资格分别保留;全部是具体租户,无全平台范围。 + 覆盖 AC:AC-15、AC-34、AC-62、AC-64 + 依赖:T017 + 配对测试:T017

    +
  • +
  • +

    [x] T073:用户会话当前身份的窄端口 + 类型:后端 Domain + 文件:src/backend/bisheng/user/application/runtime_identity.py、src/backend/bisheng/user/domain/repositories/runtime_identity.py + 逻辑/验证:Web来源续办重读有效人员、唯一当前租户及token_version,返回当前UserPayload值对象;限定user_id,不受页面视角影响,过期或迁租户拒绝,跨域不返回ORM。 + 覆盖 AC:AC-04、AC-40、AC-64 + 依赖:T027 + 配对测试:T027

    +
  • +
  • +

    [x] T074:运行记录源仓储按已授权应用收窄 + 类型:后端 Domain + 文件:src/backend/bisheng/app_governance/domain/repositories/runtime_audit_repository.py + 逻辑/验证:每类记录、候选及一致导出SELECT同样约束具体租户和服务器计算的edit应用集合;空集合返回空,应用范围进入快照和游标,不接受客户端自填。 + 覆盖 AC:AC-15、AC-34、AC-62、AC-64 + 依赖:T072 + 配对测试:T017

    +
  • +
  • +

    [x] T075:审批执行的冻结授权来源值对象 + 类型:后端 Domain + 文件:src/backend/bisheng/app_publish/domain/schemas/release_tenancy.py、src/backend/bisheng/app_publish/domain/services/release_tenancy_service.py + 逻辑/验证:既有受信 ApprovedReleaseSource 携带发布记录冻结的 authorization_context;仅 qualify_approved_release 生成,人工上线仍捕获当前用户,审计不将SA伪装为归属人。 + 覆盖 AC:AC-39、AC-40、AC-41、AC-64 + 依赖:T028 + 配对测试:T027

    +
  • +
  • +

    [x] T076:可信审批候选资格及管理员总数 + 类型:后端 Domain + 文件:src/backend/bisheng/approval/application/app_candidates.py、src/backend/bisheng/approval/domain/repositories/app_candidate_repository.py + 逻辑/验证:应用发布专用端口验证候选当前有效自然人与租户,可信完整管理员集合独立于节点候选,双重排除;Root沿原平台管理员来源,依赖未知不启用自审例外。 + 覆盖 AC:AC-49、AC-67、AC-68 + 依赖:T023 + 配对测试:T023

    +
  • +
  • +

    [x] T077:异常指定与实际任务审批的应用专用钩子 + 类型:后端 Domain + 文件:src/backend/bisheng/approval/domain/services/approval_exception_service.py、src/backend/bisheng/approval/domain/services/approval_runtime_handler_factory.py + 逻辑/验证:构造同一冻结gate request并仅对app场景调用候选钩子;指定/重新指定流程在首次写入之前校验全部指定人;其它场景空钩子保持原逻辑。 + 覆盖 AC:AC-49、AC-67、AC-68 + 依赖:T024、T023 + 配对测试:T023

    +
  • +
  • +

    [x] T078:应用业务撤回的原子审批仓储 + 类型:后端 Domain + 文件:src/backend/bisheng/approval/domain/repositories/app_publish_withdrawal_repository.py + 逻辑/验证:实例行锁下校验tenant/app/deployment/version/scenario且pending,同事务更改实例、在途任务和withdrawn动作日志;不改其它场景公共applicant资格。 + 覆盖 AC:AC-37、AC-49、AC-52、AC-54 + 依赖:T025、T023 + 配对测试:T023

    +
  • +
  • +

    [x] T079:应用撤回管理入口 + 类型:后端 API + 文件:src/backend/bisheng/app_publish/api/endpoints/publish_status.py + 逻辑/验证:新增appid绑定撤回入口,接expected_instance_id与reason,当前publish授权后从平台数据产生冻结审批目标;旧个人审批撤回入口保持applicant限制,前端只用业务入口。 + 覆盖 AC:AC-16、AC-37、AC-49、AC-54、AC-63 + 依赖:T026、T025 + 配对测试:T023

    +
  • +
  • +

    [x] T080:后端启动配置只接受协议2 + 类型:基础设施 + 文件:src/backend/bisheng/core/config/app_runtime.py + 逻辑/验证:默认2,显式非2或未知值在配置加载时拒绝,包括default_factory环境值校验;不更改manifest或平台API版本号。 + 覆盖 AC:AC-57、AC-71 + 依赖:T043 + 配对测试:T043

    +
  • +
  • +

    [x] T081:应用代理路由客户端移除旧回退 + 类型:后端 Domain + 文件:src/app-proxy/app_proxy/clients.py、src/app-proxy/app_proxy/main.py + 逻辑/验证:ManagerRouteClient只从backend读取v2签名路由;旧manager路由不回退,保留backend访问授权HTTP/WebSocket/OBO既有协议。 + 覆盖 AC:AC-57、AC-59、AC-71 + 依赖:T046 + 配对测试:T082

    +
  • +
  • +

    [x] T082:应用代理旧配置拒绝测试 + 类型:测试 + 文件:src/app-proxy/tests/test_f114_v2_only.py + 逻辑/验证:默认2,显式1/未知配置与客户端构造均拒绝,不向旧manager发HTTP;保留访问授权独立HMAC,不混淆API路径里的v1。 + 覆盖 AC:AC-57、AC-59、AC-71 + 依赖:T043

    +
  • +
  • +

    [x] T083:预览物料初始化与存储授权接线 + 类型:后端 Domain + 文件:src/backend/bisheng/app_runtime/domain/services/runtime_material_service.py、src/backend/bisheng/app_runtime/domain/services/app_storage_service.py + 逻辑/验证:starting仅供围栏匹配的初始化物料;存储令牌仍逐次校验会话当前用途。 + 覆盖 AC:AC-18、AC-50、AC-51、AC-53、AC-59、AC-69 + 依赖:T034、T035 + 配对测试:T034、T039

    +
  • +
  • +

    [x] T084:预览执行公开事务门面 + 类型:后端 Domain + 文件:src/backend/bisheng/app_runtime/application/preview_operations.py + 逻辑/验证:同一个repo/UoW内登记并封装环境;commit后wake/wait,不导出跨域ORM。 + 覆盖 AC:AC-18、AC-50、AC-51、AC-53、AC-59、AC-69 + 依赖:T034、T035 + 配对测试:T034、T039

    +
  • +
  • +

    [x] T085:预览当前自然人资格端口 + 类型:后端 Domain + 文件:src/backend/bisheng/user/application/runtime_identity.py + 逻辑/验证:运行期重新读取真实自然人和有效租户,不借页面选择,不复用历史登录有效性。 + 覆盖 AC:AC-18、AC-50、AC-51、AC-53、AC-59、AC-69 + 依赖:T034、T035 + 配对测试:T034、T039

    +
  • +
  • +

    [x] T086:预览当前资格与用途领域策略 + 类型:后端 Domain + 文件:src/backend/bisheng/app_publish/domain/services/preview_access_service.py + 逻辑/验证:管理edit/审批有效任务分型,双资格不猜测;当前人/tenant/app/version/凭据/会话逐次一致,能力主体与实际viewer分开。 + 覆盖 AC:AC-18、AC-38、AC-50、AC-51、AC-59、AC-69 + 依赖:T034、T039 + 配对测试:T034、T039

    +
  • +
  • +

    [x] T087:发布拥有的预览交付投影端口 + 类型:后端 Domain + 文件:src/backend/bisheng/app_publish/application/runtime_delivery.py + 逻辑/验证:预览空基线迁移计划及冻结声明的scope公开投影,不导出ORM或提交独立事务。 + 覆盖 AC:AC-50、AC-51、AC-53、AC-69 + 依赖:T034、T035 + 配对测试:T034

    +
  • +
  • +

    [x] T088:OpenAPI预览能力绑定端口 + 类型:后端 Domain + 文件:src/backend/bisheng/open_api/domain/services/preview_context.py、src/backend/bisheng/open_api/domain/context.py + 逻辑/验证:缺端口默认拒绝,运行期重验完整绑定;排队仅传播引用和已验签OBO截止时间,正式app_self保持。 + 覆盖 AC:AC-18、AC-38、AC-50、AC-51、AC-59、AC-69 + 依赖:T039、T040 + 配对测试:T039

    +
  • +
  • +

    [x] T089:前端动作与管理范围值工具 + 类型:前端 Platform + 文件:src/frontend/platform/src/pages/BuildPage/hostedApp/appActions.ts、src/frontend/platform/src/controllers/managementScope.ts + 逻辑/验证:按已确认六动作/具体租户/用途接线,沿现有组件无视觉改动;覆盖定向允许/拒绝。 + 覆盖 AC:AC-06、AC-07、AC-16、AC-17、AC-33、AC-37、AC-38、AC-50、AC-61、AC-63、AC-66 + 依赖:T048、T049 + 配对测试:T048、T055

    +
  • +
  • +

    [x] T090:详情删除和业务撤回入口 + 类型:前端 Platform + 文件:src/frontend/platform/src/pages/BuildPage/hostedApp/publish/DangerZoneCard.tsx、src/frontend/platform/src/pages/BuildPage/hostedApp/publish/ApprovalStatusCard.tsx + 逻辑/验证:按已确认六动作/具体租户/用途接线,沿现有组件无视觉改动;覆盖定向允许/拒绝。 + 覆盖 AC:AC-06、AC-07、AC-16、AC-17、AC-33、AC-37、AC-38、AC-50、AC-61、AC-63、AC-66 + 依赖:T048、T049 + 配对测试:T048、T055

    +
  • +
  • +

    [x] T091:顶部范围hook与API合同 + 类型:前端 Platform + 文件:src/frontend/platform/src/hooks/useAdminScope.ts、src/frontend/platform/src/controllers/API/admin.ts + 逻辑/验证:按已确认六动作/具体租户/用途接线,沿现有组件无视觉改动;覆盖定向允许/拒绝。 + 覆盖 AC:AC-06、AC-07、AC-16、AC-17、AC-33、AC-37、AC-38、AC-50、AC-61、AC-63、AC-66 + 依赖:T048、T049 + 配对测试:T048、T055

    +
  • +
  • +

    [x] T092:审读角色editor类型 + 类型:前端 Platform + 文件:src/frontend/platform/src/controllers/API/hostedAppReview.ts、src/frontend/client/src/api/hostedAppReview.ts + 逻辑/验证:按已确认六动作/具体租户/用途接线,沿现有组件无视觉改动;覆盖定向允许/拒绝。 + 覆盖 AC:AC-06、AC-07、AC-16、AC-17、AC-33、AC-37、AC-38、AC-50、AC-61、AC-63、AC-66 + 依赖:T048、T049 + 配对测试:T048、T055

    +
  • +
  • +

    [x] T093:顶部租户测试与派生文案生成 + 类型:测试 + 文件:src/frontend/platform/src/test/headerTenantScope.test.tsx + 逻辑/验证:旧全平台期望改为仅active具体租户,并拒绝空ID;错误源三语由既有生成器产出,不手改派生文件。 + 覆盖 AC:AC-09、AC-10、AC-13、AC-16、AC-63 + 依赖:T048、T050

    +
  • +
  • +

    [x] T094:构建混合列表托管应用动作映射 + 类型:后端 API + 文件:src/backend/bisheng/api/services/workflow.py、src/backend/test/app_runtime/test_f114_build_list.py + 逻辑/验证:分页前以托管应用五管理动作OR过滤并投影六动作,工作流助手原动作不变;卡片与详情使用同一资格。 + 覆盖 AC:AC-06、AC-07、AC-15、AC-33、AC-55、AC-61、AC-62 + 依赖:T005、T011 + 配对测试:T048、T010

    +
  • +
  • +

    [x] T095:旧控制测试入口退役与v2认证闭环 + 类型:测试 + 文件:src/runtime-manager/tests/conftest.py、src/runtime-manager/runtime_manager/api/operations.py + 逻辑/验证:旧HMAC v1 HTTP fixture显式retired skip,共用构建/SQLite/配置工具单测保留;v2仅独立HMAC,不保留upgrade绕过。 + 覆盖 AC:AC-50、AC-53、AC-57、AC-58、AC-59、AC-65、AC-71 + 依赖:T043、T045 + 配对测试:T043、T082

    +
  • +
  • +

    [x] T096:入口代理真实v2签名投影fixture + 类型:测试 + 文件:src/app-proxy/tests/fakes.py、src/app-proxy/tests/conftest.py + 逻辑/验证:代理公共HTTP/WS测试fake改为平台v2路由投影,独立实现签名验证;不删除公共入口覆盖。 + 覆盖 AC:AC-50、AC-53、AC-57、AC-58、AC-59、AC-65、AC-71 + 依赖:T043、T045 + 配对测试:T043、T082

    +
  • +
  • +

    [x] T097:预览入口路径断言更新 + 类型:测试 + 文件:src/app-proxy/tests/test_preview.py + 逻辑/验证:改为平台v2预览/正式路由,仍验证身份与空间隔离,不再发送v1成功请求。 + 覆盖 AC:AC-50、AC-53、AC-57、AC-58、AC-59、AC-65、AC-71 + 依赖:T043、T045 + 配对测试:T043、T082

    +
  • +
  • +

    [x] T098:新增查询回收至所属仓储 + 类型:后端 Domain + 文件:src/backend/bisheng/open_api/domain/repositories/credential_repository.py、src/backend/bisheng/app_runtime/domain/repositories/runtime_delivery_repository.py + 逻辑/验证:新预览凭据精确吊销/审计和可用性读取均由OpenAPI仓储承担;应用动作候选读取由runtime仓储承担,单批最多500个ID,继续调用方UoW/自动租户过滤;不新增DAO入口。 + 覆盖 AC:AC-06、AC-07、AC-18、AC-50、AC-53、AC-59、AC-65、AC-69 + 依赖:T004、T034、T039 + 配对测试:T004、T034、T039

    +
  • +
  • +

    [x] T099:私有依赖显式选择按edit授权 + 类型:后端 Domain + 文件:src/backend/bisheng/app_runtime/domain/services/runtime_profile_service.py、src/backend/test/app_runtime/test_f114_private_materials.py + 逻辑/验证:清除唯一剩余自然人owner选择门槛和无调用者旧helper;实际SA或自然人持当前edit可选择同应用材料,跨应用/租户继续拒绝,不改变材料归属。 + 覆盖 AC:AC-27、AC-35、AC-65 + 依赖:T004、T010 + 配对测试:T004

    +
  • +
  • +

    [x] T100:离线补包工具对齐范围写入合同 + 类型:基础设施 + 文件:src/runtime-manager/tools/runtime_materials.py、src/runtime-manager/tests/test_f114_material_tool_scope.py + 逻辑/验证:单应用上传先读取server确认的具体范围,冻结请求头后才打开文件;缺失/非法范围不上传,范围冲突不自动重试;公开基础目录原超管入口不增加动作。 + 覆盖 AC:AC-16、AC-17、AC-20、AC-65 + 依赖:T099、T009 + 配对测试:T009

    +
  • +
  • +

    [x] T101:运行运维说明与F086当前目标 + 类型:文档 + 文件:src/runtime-manager/OPERATIONS.md、features/v3.0.0/086-vibe-runtime-delivery/spec.md + 逻辑/验证:当前运行仅v2,删除旧inventory/import/rebind操作指引;六动作、预览及停写恢复要求写清,历史验收原文保留。 + 覆盖 AC:AC-19、AC-20、AC-57、AC-59、AC-71 + 依赖:T099、T045 + 配对测试:T043

    +
  • +
  • +

    [x] T102:F054/F055当前合同覆盖说明 + 类型:文档 + 文件:features/v3.0.0/054-app-domain-runtime/spec.md、features/v3.0.0/055-app-publish-pipeline/spec.md + 逻辑/验证:顶部增加F114替代目标与未部署状态,保留原编号、原历史证据,不将F114本地代码冒充现场交付。 + 覆盖 AC:AC-01、AC-02、AC-04、AC-50、AC-70 + 依赖:T101 + 配对测试:T004

    +
  • +
  • +

    [x] T103:F056/F059当前合同覆盖说明 + 类型:文档 + 文件:features/v3.0.0/056-app-square-governance/spec.md、features/v3.0.0/059-k8s-runtime-backend/spec.md + 逻辑/验证:记录管理动作/审计及唯一v2阶段授权的后继合同,保留K8s既有存储围栏与验收限制,不改变历史AC。 + 覆盖 AC:AC-18、AC-50、AC-60、AC-70、AC-71 + 依赖:T101 + 配对测试:T027

    +
  • +
  • +

    [x] T104:F113具体租户范围后继合同 + 类型:文档 + 文件:features/v3.0.0/113-service-account-tenant-consistency/spec.md + 逻辑/验证:记录不含全平台的具体管理视角与F113旧请求头继续兼容,真实身份/资源归属/迁租户拒绝不变。 + 覆盖 AC:AC-09、AC-16、AC-17、AC-64、AC-70 + 依赖:T101 + 配对测试:T006

    +
  • +
+

集成记录(不改变104项功能任务完成口径)

+

2026-10-30用户明确要求先提交合入feat/3.0.0,再自行部署116并继续E2E。冲突处理仅落实已确认的权限动作与具体租户合同,未新增产品范围。对应T006–T009范围准入、T014/环境变量编辑接线及T050前端范围防护,补充允许/拒绝测试与L1见integration.md。原T063/T064保持未完成;不把合入或已有设计自审当作验收通过。


+

F114 代码与验收预审

+

阶段:本地实现后的预审,尚非 SDD 第8步最终L2。

+

目标分支:feat/3.0.0。起点bb62790ad22a3204493e7dce5878e0a4f7109599;已集成本地ac2b09c8a及本轮获取的远端56f543e6e,保留兼容增强,冲突按已确认F114具体租户与六动作解决。

+

审查范围:任务声明的实际接线、失败/事务边界及必要定向输出;真实E2E未运行,不给合入PASS。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
维度预审结果依据/剩余项
边界条件本地定向通过,真实待验具体scope默认/失效、阶段与取消围栏、预览用途/绑定/过期;真实网络与部署故障待验
权限与认证本地配对通过,真实待验六动作/真实SA、内容分级、候选与撤回、签名OBO、每阶段复核;需真实F048非管理员验证
并发安全SQL局部通过,真并发待验同UoW rollback含身份/op/容量/审计;Version锁和提交未知同键查询有接线;不能以SQLite替代MySQL/DM8竞争
信息泄漏静态与局部通过详细字段门控、加密不回显、预览空值、单凭据回收;无客户凭据在代码或报告
测试覆盖NEEDS_FIX(交付证据)T063真实E2E未执行,预置3case不能证明71AC全通过;Docker/K8s和UI证据仍待
代码风格/门禁NEEDS_FIX(工作区门禁)变更Ruff/ESLint/架构守卫/i18n通过;工作区lint/typecheck失败见verification §4,未新增suppression
文档同步本地已同步design现状/护栏/合同/历史Feature覆盖/运维说明更新;历史评审不改写为实现验收
+

本轮发现及修复

+
    +
  1. 新预览/凭据公开门面内有ORM查询:已收回所属Repository,调用方事务保持;44项定向回归及SQL回滚证明通过。
  2. +
  3. 唯一私有offline_artifacts选择器残留自然人owner门槛:已改真实主体edit,跨资产仍拒绝;先红后绿。
  4. +
  5. 补包上传工具未携带新写入范围:已从应用状态响应冻结实际租户,缺失/非法不上传,不自动跨范围重试;5项定向通过。
  6. +
  7. 旧NULL用途预览不能被新身份协议收养,可能占满过期批次:新扫描只处理明确用途,部署前旧版回收并确认停止作为明确前置;未部署就不宣称旧环境已处理。
  8. +
+

当前结论

+

NEEDS_FIX / NOT_READY_FOR_ACCEPTANCE(验收与质量门禁尚未完成)。用户后续明确要求先提交合入feat/3.0.0,自行部署116后再验收;本次按该授权调整合入顺序。定向集成检查不替代真实E2E、CI或正式L2,T063/T064仍开放。未变更环境、发行镜像或新增推送授权。详见integration.md。


+

F114 AC 到当前本地证据

+

“入口”是对应测试/静态审查的定位,不表示该AC所有边界均有自动化或真实覆盖。T063真实验收全部待执行。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AC条款摘要当前测试/静态定位真实状态
AC-01THE SYSTEM SHALL 对 Vibe 应用复用 use、edit、manage_permission、delete、publish、`unp…T004待执行
AC-02WHEN 任意主体发起应用操作,THE SYSTEM SHALL 根据该操作要求的具体动作决定资格;原创建人、平台超管、租户管理员及普通授权用户不得由应用业务层另设角色或…T004待执行
AC-03WHEN 用户的授权仅适用于某个应用,THE SYSTEM SHALL 只允许在该应用上使用对应动作,不因模型名称为所有者、管理者或编辑者而推定其它应用或未包含动作的权限…T004待执行
AC-04WHEN 一项操作要求多个动作,THE SYSTEM SHALL 要求全部满足;任一动作缺失或不可判定时拒绝整项操作,且不执行其写入、部署或数据导出。…T004、T027待执行
AC-05WHEN 非原创建人通过有效授权操作应用,THE SYSTEM SHALL 保持应用原归属人与所属租户不变,并将实际操作者与资源归属人分别记录;本期不提供移交或迁租户。…T004、T019待执行
AC-06WHERE 用户仅持 use,THE SYSTEM SHALL 允许其按应用状态使用应用,不授予构建管理页、源码、环境变量、运行日志、平台生产数据或管理审计记录的读取…T004、T048、T094待执行
AC-07WHEN 用户在当前租户视角内具有某应用的任一管理动作(edit、publish、unpublish、delete、manage_permission…T004、T010、T048待执行
AC-08IF 应用权限评估失败、不可判定、目标不存在或已删除,THEN THE SYSTEM SHALL 拒绝相应操作,不返回未过滤的数据、不变更资源或启动新的执行体,并遵守原资…T004待执行
AC-09THE SYSTEM SHALL 从顶部租户选择中移除“整个平台/全部租户”选项,管理视角必须限定一个具体租户;Root 也是一个具体租户,选择 Root 不得显示其它租…T006、T048、T066待执行
AC-10WHEN 非平台超管访问管理模块,THE SYSTEM SHALL 将其管理视角限定为当前所属租户;伪造租户参数、使用历史选择或缓存状态不得改变该范围。…T006、T048、T093待执行
AC-11WHEN 平台超管切换管理视角,THE SYSTEM SHALL 仅接受存在且可用的具体租户,显示实际生效的租户,并记录从原视角到新视角的切换。…T006、T048待执行
AC-12WHEN 平台超管首次访问且没有有效选择,THE SYSTEM SHALL 将有效管理视角设为该登录人当前所属租户;不得把“没有选择”解释为全平台。…T006、T048待执行
AC-13WHEN 已选租户不再可用、选择已过期或选择内容无效,THE SYSTEM SHALL 回到登录人当前所属租户并显示实际生效范围;IF 无法确定一个合法的默认租户,THE…T006、T048、T093待执行
AC-14WHEN 调用方请求空租户、全平台或其它不能唯一确定具体租户的切换,THE SYSTEM SHALL 拒绝切换并保留原有效范围;切换校验失败不得清除原范围。…T006、T048待执行
AC-15WHEN 加载知识库、模型、工作流、助手、托管应用、审批管理、审计、用户组及租户自定义角色等管理资源,THE SYSTEM SHALL 根据同一个有效视角过滤列表、基础详…T006、T017、T048待执行
AC-16WHEN 管理视角切换或失效回退,THE SYSTEM SHALL 清除前一范围的页面数据与可提交表单状态;旧页面或旧表单不得在新范围下操作原租户的资源,多个页面不得展示…T048、T066、T093待执行
AC-17WHEN 管理视角改变,THE SYSTEM SHALL 保持用户真实所属租户、登录身份、服务账号身份、凭据 scope 与已有授权不变;管理视角不是迁租户或取得权限的途…T006、T048、T066待执行
AC-18WHERE 后台任务、开放 API、应用 SDK 或内部运行时请求未使用浏览器管理视角,THE SYSTEM SHALL 按其已经验证的主体及目标租户执行,不继承超管的页…T006、T067、T043待执行
AC-19WHEN 用户修改名称、描述、图标等元信息,THE SYSTEM SHALL 要求 edit 并记录操作;不得仅因与原创建人不同而拒绝。…T010待执行
AC-20WHEN 用户读取源码快照、源码文件、完整声明、版本内容或内容差异,THE SYSTEM SHALL 要求 edit;只有发布/下线/删除等其它动作时仅能获得 AC-…T010、T067、T100待执行
AC-21WHEN 用户查看应用运行输出、错误栈、构建或预检详细诊断,THE SYSTEM SHALL 要求 edit,并保留脱敏、筛选、截断及留存限制;界面、CLI 与 MC…T010待执行
AC-22WHEN 用户读取生产数据的表清单、结构、行数据或导出数据,THE SYSTEM SHALL 要求 edit,并保留表范围、分页、行数及导出数据范围限制;其它动作不包…T010待执行
AC-23WHEN 用户编辑现有生产数据行,THE SYSTEM SHALL 要求 edit、二次确认并记录审计;不开放任意 SQL、表结构修改或本期原本不支持的行级操作。…T010待执行
AC-24WHEN 用户查看环境变量的名称、配置状态、引用或运行配置差异,THE SYSTEM SHALL 要求 edit;获授权管理者可查看完整配置项的管理信息,但已保存密钥…T010、T048待执行
AC-25WHEN 用户新增、替换或删除环境变量值,THE SYSTEM SHALL 要求 edit,保留加密保存、并发冲突提示和审计,不自动重启、停止或重建实例;删除某个值不…T010待执行
AC-26WHEN 用户确认使用最新环境变量配置重建正式实例,THE SYSTEM SHALL 要求 edit 与 publish,保留同版本、当前运行配置和确认对象一致性…T010、T027、T048待执行
AC-27WHEN 用户管理应用私有离线依赖、启停该依赖的可用状态,或读取已有应用数据库快照内容/列表,THE SYSTEM SHALL 要求 edit 并限定该应用的资产,不…T010、T043、T099待执行
AC-28WHEN 用户恢复应用数据快照,THE SYSTEM SHALL 要求 edit 并确认数据覆盖影响;IF 恢复操作包含正式实例启动,THEN THE SYSTEM …T010、T027待执行
AC-29WHEN 用户上线、手动上线或重新上线已有已批准版本,THE SYSTEM SHALL 要求 publish,继续满足审批结论、应用状态、容量准入与运行健康条件,不另…T010、T048待执行
AC-30WHEN 用户下线正式应用,THE SYSTEM SHALL 要求 unpublish、二次确认并记录审计,停止执行体而保留代码、数据库及附件资产。…T010、T048待执行
AC-31WHEN 用户显式删除整个应用,THE SYSTEM SHALL 要求 delete;普通获授权用户、租户管理员、原创建人和平台超管均以 F048 结果为准,不再判断…T010、T048待执行
AC-32WHEN 用户删除应用,THE SYSTEM SHALL 继续拒绝在线态直接删除并提示先下线;非在线可删除状态须二次确认,明确代码、对话历史及生产数据的删除范围,取消不得…T010、T048待执行
AC-33WHEN 用户读取授权主体详情、增删授权、选择权限模型或设置可见范围,THE SYSTEM SHALL 要求 manage_permission,沿用既有授权入口;基…T010、T048、T094待执行
AC-34WHEN 用户查看或导出某个应用的模型/能力调用、访问等运行记录,THE SYSTEM SHALL 要求该应用的 edit 并限制数据范围;跨应用审计页仍要求其既有审…T017待执行
AC-35WHEN 用户上传新的应用内容并提交首发后的迭代发布,THE SYSTEM SHALL 要求 edit 与 publish,不再以调用者是否等于原创建人为资格条件…T019、T099待执行
AC-36WHEN 用户更改资源档位、运行环境、启动配置或平台能力声明并使其通过新版本发布生效,THE SYSTEM SHALL 要求 edit 与 publish,继续扫…T019待执行
AC-37WHEN 有 publish 的用户主动撤回某应用的发布,THE SYSTEM SHALL 允许其代行并记录实际撤回者;仅限该应用可撤回的在途发布,不更改其它业务审批…T023待执行
AC-38WHEN 有 edit 的管理者查看当前在审提交的应用管理预览,THE SYSTEM SHALL 允许其使用受控预览能力,不要求原创建人;注入实际预览者本人身份,平台…T067、T034、T039待执行
AC-39WHEN 非原创建人提交、撤回或重试发布,THE SYSTEM SHALL 保留资源归属事实,继续以应用归属人为审批申请人并保留既有审批路由依据;分别记录实际自然人或服务…T019、T023待执行
AC-40WHEN 在途发布或恢复操作继续执行到新的扩权、正式启动或新路由生效阶段,THE SYSTEM SHALL 再验证该操作所需授权及凭据仍有效;失效时拒绝新增效果并保留可解…T027、T032待执行
AC-41WHERE 首发、迭代或配置声明按原产品合同需要发布审批,THE SYSTEM SHALL 继续在审批通过后才能上线;具备 publish、管理员身份或代行资格都不能…T023待执行
AC-42WHEN 用户查看历史版本、发布说明及提交记录,THE SYSTEM SHALL 保留只读冻结内容和版本切换限制;本次不新增界面造应用、未实现的“提交发布”按钮、任意回滚…T010待执行
AC-43WHEN CLI/MCP/开放 API 调用已有的应用操作,THE SYSTEM SHALL 同时检查该通道原有的 scope、身份模式及对应 F048 动作;服务账号以…T019待执行
AC-44WHEN 经既有创建资格首次创建应用并提交发布,THE SYSTEM SHALL 保留单命令首发体验,在新应用上为真实创建主体登记本次所需且仅限该应用的初始管理授权,使其…T019待执行
AC-45WHEN 调用者在 F048 上获得其它人应用的相应动作,THE SYSTEM SHALL 允许通过已支持的 CLI/MCP 操作该应用,不要求服务账号资源归属人与应用原…T019待执行
AC-46WHEN 开放通道查询应用列表、基础详情、发布状态、日志或数据,THE SYSTEM SHALL 按各内容相应动作过滤或拒绝,不仅返回“资源归属人的应用”,也不因为持有粗…T019待执行
AC-47WHERE 服务账号归属人资格、账号/应用租户一致性、凭据有效性、身份模式或 scope 不符合既有要求,THE SYSTEM SHALL 继续拒绝;本次不新增 PAT …T019、T039待执行
AC-48WHEN 存量凭据只有通道 scope 而没有目标应用所需的 F048 授权,THE SYSTEM SHALL 拒绝并提示需要对应应用动作;不得为了兼容继续按 owner…T019待执行
AC-49WHEN 用户同意/驳回审批任务或处理异常审批,THE SYSTEM SHALL 继续要求既有审批任务或审批管理资格;app.publish 等资源动作不替代审批人或异常…T067、T023、T034待执行
AC-50WHEN 具有该次有效审批任务资格的审批人审读冻结内容或访问审批预览,THE SYSTEM SHALL 保留限于该审批任务/应用/版本/期限的业务授权,不要求原创建人或长…T067、T034、T039待执行
AC-51WHERE 执行预检、管理预览或审批预览,THE SYSTEM SHALL 继续不注入真实环境变量密钥;管理预览按 AC-38 的本人身份与权限执行,审批预览仅按 AC-…T067、T034、T039待执行
AC-52WHEN 审批管理展示应用异常记录、进度或执行允许的异常处理,THE SYSTEM SHALL 限定当前具体租户,保留个人审批与管理审批不同的查看/处理资格,不因删除全平…T023待执行
AC-53WHEN 后台或运行层为已授权操作获取源码、快照、数据或执行生命周期步骤,THE SYSTEM SHALL 验证请求确实属于该应用、版本、操作及用途;前端声称有某动作、仅…T067、T027、T032待执行
AC-54WHEN 应用被删除或在途发布取消,THE SYSTEM SHALL 继续按原生命周期处理审批与运行资源,保留幂等、终态保护及失败审计;权限统一不得使重复处理恢复已经失效…T023、T027、T032待执行
AC-55WHEN 用户读取运行操作的基础状态,THE SYSTEM SHALL 按任一管理动作提供确认对象和进度所需的信息;详细日志、私有依赖及快照内容仍按 edit 控制。…T010、T048、T094待执行
AC-56WHEN 用户取消运行管理操作,THE SYSTEM SHALL 按该操作的原业务用途,对当前取消者检查原业务操作要求的全部动作,具体集合见 §3.1;取消者可以不同于发…T027待执行
AC-57WHEN 用户请求已退役的旧运行绑定导入、升级或重绑操作,THE SYSTEM SHALL 拒绝请求,且不创建执行体、不变更路由或存储资产;同版本环境变量确认重建继续按 …T043、T082、T095待执行
AC-58WHEN 用户维护公共运行环境、公共依赖、默认运行环境、宿主隔离或集群能力,THE SYSTEM SHALL 保留现有平台运维资格,不把拥有某一个应用的 edit 或…T043、T095、T096待执行
AC-59WHEN 应用 SDK 调用平台能力或附件服务,THE SYSTEM SHALL 继续使用可信运行身份、访问者身份、能力声明和底层资源授权限定请求;SDK 业务附件操作不…T034、T039、T043待执行
AC-60WHEN 正常运行应用发生既有实例自愈或旧执行体清理,THE SYSTEM SHALL 按已验证的运行意图执行,不模拟原创建人,不因某个历史操作者个人撤权自动停用应用,也…T027、T032、T043待执行
AC-61WHEN 界面呈现应用卡片、详情、数据、日志、配置和操作按钮,THE SYSTEM SHALL 根据同一动作映射与有效状态显示、隐藏或禁用;不得在动作允许之后另叠加原创建…T010、T048、T094待执行
AC-62WHEN 权限、模型有效动作或凭据资格发生变化,THE SYSTEM SHALL 在后续请求按最新生效结果决定;旧页面缓存、已打开表单或按钮状态不能维持已失去的资格。…T004、T017、T027待执行
AC-63WHEN 操作被拒绝,THE SYSTEM SHALL 区分缺少具体动作、应用状态不允许、审批/运行前置条件未满足等原因;不得继续把已获授权管理者的失败笼统描述为“仅创建…T004、T048、T093待执行
AC-64WHEN 应用管理动作或租户切换成功、失败、取消或由后台继续执行,THE SYSTEM SHALL 按既有留痕合同记录真实操作者、目标租户/应用、相关版本/操作、结果与成…T006、T017、T019待执行
AC-65WHERE 存在原有二次确认、密钥保护、脱敏、数据和附件隔离、资源配额、审批及运行围栏,THE SYSTEM SHALL 保持其作用;权限统一不删除这些非创建人资格类条件…T004、T010、T027待执行
AC-66THE SYSTEM SHALL 保持未纳入本需求的工作流、助手、知识库等资源动作语义不变;具体租户管理视角按 AC-09 至 AC-18 统一收窄,不能由此改变这些资源…T006、T048待执行
AC-67WHEN 解析应用发布的审批候选,THE SYSTEM SHALL 排除应用归属人,以及与该次实际提交关联的自然人;自然人直接提交时排除该本人,服务账号提交时排除提交时固…T023待执行
AC-68IF 按 AC-67 排除后没有合格审批人且不满足原产品“全租户仅一名管理员且无其他候选”条件,THEN THE SYSTEM SHALL 进入异常审批处理并保留原因,不…T023待执行
AC-69WHEN 同一人同时具有应用 edit 和该次审批任务资格,THE SYSTEM SHALL 按本次预览用途分别执行 AC-38 或 AC-50;管理预览不能继承审批预览…T067、T034、T039待执行
AC-70WHEN 当前取消者已失去原业务操作所需任一动作,THE SYSTEM SHALL 拒绝其取消请求且不改变在途操作;仍具全部所需动作的其它主体可以按原规则取消。已生效或按…T027待执行
AC-71THE SYSTEM SHALL 仅支持统一运行控制协议 v2 承载 Docker 与 K8s 应用运行管理,拒绝旧 v1 控制配置及请求,不通过旧控制入口或自动回退继续…T032、T043、T082待执行

+ +
+

Design: Vibe 全操作权限动作统一与具体租户管理视角

+
+

统一 v2 修订设计已确认,实施中。 2026-10-30 用户已确认 70 条 AC 并要求进入设计,随后明确“创建资源还是属于归属人,这个逻辑不变”。下文的“现状”是静态核对代码;“选定/拟改”是已确认目标;本地实现与定向验证见§4.1及verification,真实部署E2E与最终L2尚待完成。用户随后澄清:共用读取/调用接口维持已有可见性与最小权限,本轮不重做助手、工作流等资源鉴权;已据此收窄 D3 和 §4.4;用户随后要求统一保留运行协议 v2,已补 D9、§4.11,并将 spec 收敛为 71 条 AC。用户在 R3 实质评审后采纳建议:旧导入/升级/rebind 一并退役,环境变量确认重建与新版本发布各沿已有流程,不新增独立通用实例重建。独立评审提出 R4/R5/R6 后,用户再次要求“按照建议修改”;本稿补齐系统自愈意图、维护首个副作用前准入及预览原子登记/回收,71 条规格不变。

+
+

关联:spec.md · 规格复审 · 设计自审。版本 v3.0.0,基线 bb62790ad22a3204493e7dce5878e0a4f7109599,分支 codex/114-vibe-permission-actions。2026-10-30 用户已确认整份修订设计,tasks 已拆解并开始实施;设计复审哈希保留为确认前快照。

+

1. 目标与非目标

+

统一 Vibe 已有操作的资源鉴权,以真实主体和现有六个 F048 动作决定资格;平台管理数据始终绑定一个具体租户。保持资源归属自然人、发布必审和现有运行安全边界,补齐代发布、预览和异步执行的权限接线。

+

非目标沿用 spec:不迁移资源或批量补授权,不新增动作、权限体系、PAT 通道、明文读取、回滚/版本切换或编排后端。只保留运行控制协议 v2,不新增独立通用实例重建、编排后端或重写 v2 状态机。不会因保存环境变量自动重启应用。仅做本功能必要的定向验证。助手、工作流、知识库共用读取/调用接口继续按既有可见性、use 和业务条件处理,不新增接口拆分或统一必填请求头;本轮动作映射对象为 Vibe app。可见不等于获得编辑、删除或发布权限。

+

2. 关键约束

+
    +
  • 遵循 constitution.md C1–C7 与 release-contract,不在本文重抄全局规则。
  • +
  • 归属不变:服务账号首次创建 app 时,app.owner_user_id 仍取经 F113 校验的 principal.resource_owner_user_id,tenant 取既有经验证的账号/归属人租户;后续操作不改 owner/tenant。实际服务账号主体进入鉴权、可撤销初始 Grant 和操作者审计,受保护所有者仍是自然人。
  • +
  • 服务账号、自然人会话、运行应用、审批任务、内部运维是不同来源;不能互相伪装。入口 scope、模式、F113、F066 约束继续成立。
  • +
  • 已上线实例、自愈、清理与“新增一次用户授权效果”分开:用户意图复核当前主体/凭据和对应动作;系统自愈复核既有批准运行意图及来源围栏,不复用历史操作者。不得仅凭 deploy、auto_commit 或后台身份豁免。首次准入必须早于 maintenance 投影和停旧 writer;历史操作者撤权不主动停止既有服务。
  • +
  • 同一 API 有不同内容层级;不能先返回源码、配置或数据,再靠前端隐藏。管理基础信息采用五个管理动作的 OR,组合写入采用 AND。
  • +
  • 唯一运行协议:Docker/K8s 共用 v2 任务、身份与阶段校验,不补 v1 阶段授权或保留回退执行;应用包格式、平台 API 版本与运行协议不混同。
  • +
  • 单租户退化为具体 Root 租户;K8s/Docker 产品权限相同。SDK/运行入口和内部任务不继承浏览器租户选择。
  • +
+

3. 方案对比与选定

+

D1:应用业务事实与 F048 动作判定分层

+
    +
  • 备选:继续在每个服务补 owner/admin 特判;或把所有业务状态塞进 permission 模块;或薄的应用授权门面复用 F048。
  • +
  • 选定:在 app_runtime 新增 AppAuthorizationService,经 app_runtime/application/authorization.py 对外发布;负责验证 app/tenant/state、可信操作用途与内容层级,调用已有 app adapter/F048 runtime 判定具体动作。
  • +
  • 原因:现有 业务鉴权入口 对不受数据范围限制的超管有提前允许路径。调用者必须先验证目标事实,不能把这一允许当作存在、租户或状态验证。app adapter 的已加载记录批量校验可复用,不新增授权引擎。
  • +
  • 何时重审:F048 提供等价的统一已验证目标接口时,可收敛薄门面;不能借机改变其他资源动作。
  • +
+

D2:首次创建传递真实主体,复用现有创建授权

+
    +
  • 备选:借用自然人 owner 的权限;创建后由业务代码手写 Grant;重新创建一种发布者模型;复用已有 F048 创建生命周期。
  • +
  • 选定:创建函数同时接收“自然人归属事实”和经准入的真实 PermissionActor,二者不互相推导。authorize_created 继续为自然人登记受保护 owner;实际创建主体为服务账号时,复用 F048 已有、可撤销的 manager CREATOR_GRANT 来源。
  • +
  • 原因:runtime.authorize_created 已经有这条服务账号分支;AppProvisionService._project_owner 当前人为构造自然人 actor,导致该分支未用于 Vibe 首发。只改真实主体传递,保持资源归属和权限投影真相。
  • +
  • 约束:读取 CURRENT Catalog 验证初始化后的真实主体确有 edit 与 publish,记录实际生效模型/动作;不把 manager 名称硬编码成固定动作,不升级成 owner 兜底。初始来源按既有 F048 规则可撤销、仅限新 app;历史 app 不自动补权。已有 CREATOR_GRANT 的 F048 语义原样保留,应用层不再另写创建者特判。
  • +
  • 失败处理:使用既有投影账本及 rollback_created,确认逆向投影完成后才删除孤立业务行;不确定结果保留围栏和可治理记录,返回失败,不排发布任务。不在 FGA 状态不明时直接 SQL 删除 app 留下孤立授权。
  • +
  • 何时重审:CURRENT 模型不覆盖首发所需动作时,先报告权限初始化失败;如需另造模型或扩大授予范围,回到已确认规格边界评审。
  • +
+

D3:具体租户范围与既有共用接口保持解耦

+
    +
  • 备选:对 middleware 前缀下全部接口强制新租户头并重划管理/业务路由;仅移除全平台 UI;收窄既有范围解析、只在明确管理写入口保护预期租户。
  • +
  • 选定:扩展 F019 TenantScopeService 和 application 门面,形成 EffectiveManagementScope。沿用 admin_scope:{user_id}、当前 TTL 和超管验证;空/失效选择回到本人合法租户,切换只接受 active 的具体租户,失败保留原值。
  • +
  • 共用接口边界:助手、工作流、知识库等共用读取/调用继续使用现有最小必要权限和可见性检查,不拆接口、不增加统一必填租户头、不要求 Vibe app 的 edit/publish;原写操作保持自己的原有动作要求。管理列表的具体租户数据过滤按 AC-09 至 AC-18 收窄,与这些资源的权限动作语义分开。
  • +
  • 预期范围保护:X-Management-Tenant 只用于服务端明确登记的 Vibe 管理写,以及会按当前视角确定目标租户的专用管理新建/批量写入口;不按 URL 前缀强制作用于共用读取或业务调用。服务端与已解析范围比较并 pin 本请求;不一致拒绝写入。F113 原 X-Service-Account-Tenant 合同继续有效。
  • +
  • 原因:用户要求保持共用接口既有可见即读/按原 use 调用逻辑;此前全路径强制请求头超出本轮 Vibe 动作映射需要。跨标签页与回退防错写保留在具体管理页及专用管理写入口,查询仍用 ContextVar 和 repository,不引入新鉴权体系或手写 tenant WHERE。
  • +
  • 何时重审:若未来单独提出其它资源权限重构、共用接口存在已证实的数据泄露,或需要标签页独立范围,再另行评审,不在 F114 顺带改造。
  • +
+

D4:归属、审批申请人与实际提交主体分别留存

+
    +
  • 备选:把 app owner 改成提交者;把审批申请人直接换成服务账号;保持申请人与路由,另补提交主体与自审关联人。
  • +
  • 选定:app 和 AppDeployment.owner_user_id 保持资源归属人;ApprovalInstance.applicant_user_id 仍为该自然人,部门路由仍按该人。新增发布授权上下文固定真实主体和自审关联自然人,供审批候选过滤与审计读取。
  • +
  • 原因:避免破坏 F113 冻结来源和自然人申请人合同;同时排除 app owner 与实际提交关联自然人。服务账号使用接收时固定的账号归属人参与排除,审计仍显示服务账号而非声称该自然人实际用钥。
  • +
  • 限制:过滤发生在应用发布 handler,后续节点/异常重试/指定审批人同样调用场景校验。通用审批服务只增加可选的场景校验扩展点,默认行为不变;其他三个场景不会套上 Vibe 规则。仅配置一人不代表全租户单管理员,无候选进入既有异常态。
  • +
  • 何时重审:产品改变申请人或审批路由依据时,作为独立合同变更处理。
  • +
+

D5:预览用途与能力上下文显式绑定

+
    +
  • 备选:按用户是否有 admin/edit 自动启用审批例外;两类预览共用同一会话/凭据;显式区分 management 与 approval。
  • +
  • 选定:在现有预览会话增加用途和审批绑定;管理预览要求 edit,使用本人;审批预览校验当前有效任务,使用本人身份、归属人能力例外。会话、数据库/附件空间和凭据按用途隔离;同一人双重身份也不能混用。
  • +
  • 事务取舍:否决先独立提交 running 会话/凭据再提交 operation。复用同一 SQL session/RuntimeDeliveryRepository UoW,以 AppVersion 行锁串行同版本的短期预览登记,会话、独立 credential、operation、sealed_env 及必要审计同事务提交;提交后才 wakeup。仅扩展既有状态/字段和 application 端口,不新增表、锁服务或补偿框架。
  • +
  • 原因:当前 AccessSubject 只保留 user_id,不能让能力层区分审批例外;只在页面判断角色不能闭合 INV-36。扩展已签名 OBO 的解析投影及现有组合根注册端口,以可信会话解析能力上下文,不信任应用自带的用途字段。
  • +
  • 何时重审:若产品取消审批预览例外,可将两类能力来源合并;若这些控制表迁出同一数据库,则另行评审跨库一致性。当前采用现有单库事务,不提前建设跨库机制。
  • +
+

D6:异步记录身份来源,执行阶段复核当前权限

+
    +
  • 备选:保存上传时的 allowed=true;每阶段模拟 owner;按 deploy/auto_commit 豁免后台操作;区分用户请求与可信系统恢复来源后逐阶段验证。
  • +
  • 选定:只在统一 v2 链路中保存服务端生成的 authorization_context,显式区分 origin_kind=user_request 与 origin_kind=system_recovery。用户来源按用途复核主体、凭据、F113/动作或审批任务;系统恢复来源绑定 backend 已核实的原 serving route、批准版本和原运行操作,逐阶段验证来源仍成立。源码准备、生成版本/审批、进入维护与停旧 writer、迁移、启动及提交路由各有准入;不保存永久允许结论、原 JWT 或密钥。
  • +
  • 原因:Celery、backend、manager 分进程,不能依赖 ContextVar 跨任务继承。现有 recover() 和用户配置重建都生成 deploy/auto_commit,通用标志无法区分授权来源;manager 还会先 report maintenance,再停旧实例,不能等到迁移/启动才查权限。用户来源不完整时拒绝新效果;系统恢复必须能由可信运行记录证明,必要清理独立续办。
  • +
  • 何时重审:若产品将已受理发布定义为不可撤销的系统授权,需重新确认 AC-40,而不能在实现中放宽。
  • +
+

D7:取消依据业务用途,使用当前取消者资格

+
    +
  • 备选:所有 deploy 取消仅校验 publish;复用原发起者的权限;按受信业务用途对当前取消者检查原动作集合。
  • +
  • 选定:服务端写入 operation 的 authorization_context.purpose,映射 spec §3.1。config_restart 和新内容发布准备取消需 edit AND publish,approved_start 需 publish,restore 按冻结的是否启动区分。不登记 runtime_upgrade 用途,旧 upgrade/rebind 入口退役;代码、镜像和运行环境变更沿新版本发布。system_recovery 不提供用户通用取消入口或按钮;当前有 unpublish 的用户通过既有应用下线流程终止运行意图、取消/清理相关在途恢复。系统停止与清理不模拟原发布人,也不依赖原发布人仍持权。
  • +
  • 原因:secret_rebuild_service 与普通上线都使用 deploy kind;kind 不能作为唯一授权依据。操作用途、app/version/generation 一致性由服务端记录,取消请求不能重写它。
  • +
  • 何时重审:新增业务用途时必须登记动作集合和可取消阶段;未知用途拒绝用户取消,系统安全清理照常运行。
  • +
+

D8:基础响应与私有内容分别授权

+
    +
  • 备选:完整详情返回后由组件隐藏;复制 owner 判断到每个 tab;服务器按动作裁剪响应,前端按同一能力渲染。
  • +
  • 选定:基础 app/publish/runtime 状态返回 allowed_actions 和安全摘要,源码/完整 manifest/详细 failure/配置/数据/日志/完整授权名单使用对应独立接口。基础可见不触发所有 tab 的预取。
  • +
  • 原因:只有 publish、delete 或 manage_permission 的用户需要确认对象,但不因此取得 edit 管理内容。界面读取 action 集合和业务状态,移除 is_owner 作为操作资格的判断;后端每次重新验证。
  • +
  • 何时重审:将来增加只读/导出/密钥专用动作时另改 Catalog 与规格,当前不靠 owner 特判拆分 edit。
  • +
+

D9:运行控制只保留 v2,删除旧兼容链

+
    +
  • 备选:给 v1/v2 各补阶段授权;保留双协议并冻结 v1;统一 v2、退役旧入口。
  • +
  • 选定:按用户“尚未正式对外发布”和“收敛统一一下”的决定,仅保留 v2。backend/manager/app-proxy/egress-proxy 使用同一协议;默认及示例为 2,显式非 2 配置在启动工作/对账之前拒绝;旧运行 RPC 与旧绑定导入、升级、rebind 入口不再注册或被消费,不以旧签名/本地记录回退。
  • +
  • 原因:Compose 和 K8s Chart 已选择 v2;v2 的 Docker/Kubernetes executor 已覆盖 bundle、image 和 source-image;旧链主要为历史兼容。两套阶段授权会重复维护并留下旧入口绕过风险。本地代码已默认并强制 v2,旧控制路由不再注册;真实部署与旧环境处置尚未执行。旧 rebind 调用普通 deploy,正常在线同版本会直接返回已有实例;它服务旧导入重绑,不能作为通用 v2 重建依据。保留配置确认重建和正常新版本发布已能覆盖本轮真实需求。
  • +
  • 保留:F048 六动作、现有 v2 操作/状态机/围栏、构建及 SQLite 公共工具、SDK、OBO、入口鉴权和三种交付能力;不按 legacy 文件名整体删除。平台 /api/v1、/api/v2 以及 manifest_version 的数字不由本次退役规则决定。
  • +
  • 何时重审:未来确有已发布的外部兼容承诺或本轮盘点证明 v2 缺必要能力时,先报告证据并评审;不能在实现中自行恢复 v1 回退。
  • +
+

4. 系统现状与拟改数据流

+

4.1 当前关键事实

+

2026-10-30 实施快照:已完成六动作门面、管理范围、内容/生命周期、真实发布主体、候选与撤回、分阶段授权和系统自愈来源、双用途预览、模型/能力及前端接线。新增查询均在所属 Repository;实际任务与必要定向验证见 tasks.md、verification.md。尚未执行真实部署 E2E;工作区 lint/typecheck 仍有未修改文件或既有依赖错误,不能宣称整项交付通过。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
实际文件/入口本地已接线行为仍需证明
app_authorization_service / application.authorization目标事实先验;管理五动作 OR;组合 AND;真实 SA 不冒用 owner真实 F048 授权/撤权配对
app_query / WorkFlowService 混合构建列表分页前收窄 Vibe 动作并投影 allowed_actions;其它类型动作保持页面真实多角色验证
app_state / meta / data / secret六动作替代特殊 owner 门槛;线上删除拒绝、确认、CAS、清理保持生命周期与配置差异实测
runtime_management / runtime_profile_service私有依赖/快照 edit;恢复按是否启动叠加 publish;取消当前人检查原业务全部动作私有材料、恢复与取消实测
snapshot_browse / version_diff私有内容 edit;当前有效审批任务仅可审读冻结待审版本及合法基线任务失效及租户配对
app_provision / F048 adapter自然人 owner 固定,真实创建 actor,投影不确定不盲删真实 SQL/OpenFGA 创建补偿
publish_pipeline / precheck / release_tenancy当前资格前置,用户来源冻结,准备/建版本/审批前复核;worker 原 tenant headers 路径保留真实异步受理与审批
approval app_candidates / scenario handler / exception hooks正常/重试/指定审批者均过滤当前资格与自审;单管理员按真实总人数判定真实异常分配流程
app_publish.withdraw / 审批仓储当前 publish + 冻结实例目标,同事务更新任务、实例、withdrawn日志真实撤回与终态
runtime_authorization / operation / coordinator用户当前凭据动作与系统批准意图分型;维护前许可与fact重验;auto_commit无豁免manager故障与撤权时序
preview_operations / preview_runtime / PreviewRuntimeRepositoryVersion→operation→session/credential 锁序;同UoW登记/封装/回收,提交后wake真并发与提交响应丢失
preview_access / runtime_identity / open_api.preview_context当前用途、viewer/task、credential/op/session、冻结版本、签名OBO;当前访问重新校验实际HTTP/WS/SDK模型检索
模型范围 / capability_bus / composition声明取预览冻结版本,管理本人上限/审批owner上限,审计实际viewer;API/worker注册端口真实底层资源拒绝配对
admin scope service / middleware / permission adapter正整数具体租户;无效回合法所属;明确管理写校验范围;共用GET/use维持跨页与跨标签切换
platform HeaderTenantScope / request / appActions无全平台,真实响应范围快照和旧响应拒绝;按动作门控组件与请求浏览器实际交互
platform/client preview APImanagement/approval显式分开,starting不重复拉起实际审批面板
backend/manager/proxies config / main默认且只接受2;旧RPC/inventory/导入/upgrade/rebind不可达;共用工具保留Docker/K8s各一次定向部署
runtime_materials 工具私有包上传先读取实际范围头,冻结后写;冲突不重试到新租户现场离线材料导入
+

4.2 归属与真实主体的处理

+
已认证服务账号 → F113/通道准入 → 取既有自然人归属事实
+                              ├→ app.owner_user_id / 受保护 owner:自然人
+                              ├→ app.tenant_id:既有一致租户
+                              └→ PermissionActor / 操作者审计:真实服务账号
+
+

首发将真实 actor 传到 app 创建授权;现有 F048 在同一创建投影中包含自然人 protected owner 和服务账号 ordinary CREATOR_GRANT。初始化完成且当前动作足够才保存平台源码快照、创建发布受理记录并投递。slug 冲突、已有 app_id 或重试都不能触发对历史 app 的重新初始化。

+

资源归属与授权主体的例子如下,所有字段均由可信身份/目标记录确定:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
字段/事实服务账号 24(归属人 849、租户 2)首发
app.owner_user_id / 审批申请人自然人 849
app.tenant_id租户 2
F048 判定主体 / 实际审计主体service_account:24
初始授权user:849 的 protected owner + service_account:24 的可撤销 CREATOR_GRANT
submitted_by_user_id0,服务账号类型与 ID 另行留存
+

迭代先加载并验证目标 app。app.owner_user_id、发布记录的 owner 和审批申请人始终取原 app,不改成当前服务账号的归属人;服务账号自己的当前归属关系仍按 F113 校验。归属人迁租户不迁 app,不靠新的 Grant 绕过冻结发布原归属人的资格要求。

+

4.3 管理请求与内容控制

+
登录身份 → 解析/绑定具体管理租户 → 验证 app/版本/状态
+        → 真实 PermissionActor → F048 当前动作集合
+        → 原业务校验与执行 → 按动作裁剪响应/审计
+
+
    +
  • 薄门面接口拟为 authorize_app(identity, app_id, operation)、management_actions(identity, loaded_apps);operation 为服务端枚举,不接收客户端 action 作为权威输入。Web 来源使用已验证的登录身份;开放通道读取公开 access_context 的真实 principal/permission actor,保留 authorization_subject_type/id、mode 与凭据引用,禁止从 resource_owner_user_id 构造自然人 actor。后台根据持久来源重新解析,不能复用进程内残留的当前 actor。
  • +
  • 列表先取当前具体租户的合法候选,五动作批量检查、OR 过滤后组装;复用 adapter 已加载记录批量入口,分页按现有游标/总数契约处理,不混入未授权对象、不逐卡发五个 HTTP 请求。
  • +
  • 详情/状态安全摘要:名称、图标、slug、owner 展示、状态、实例基础状态、版本号、发布说明、审批状态与时间;不包含完整声明、注入参数、环境引用、详细 failure、原始日志或 Grant 主体清单。
  • +
  • edit 内容接口独立校验;完整授权名单与模式变更使用 manage_permission;应用记录 edit 与跨应用审计入口资格分别验证。导出创建、结果查询和下载再次查当前 edit,不能凭旧票据跨租户或延续已撤权资格。
  • +
  • 权限 API 的 app Grant/mode/成员入口也须通过 app 资源 adapter 校验当前具体范围,不能仅在 Build 页面校验。公共 profiles/公共依赖仍是原平台运维资格。
  • +
+

4.4 管理视角协议与并发

+

EffectiveManagementScope 包含 tenant_id、source=selected|own、expires_at;来自已认证身份、当前租户关系与可用租户目录,不由请求头赋权。本节调整具体管理范围,不统一改造助手/工作流等共用接口的权限。

+
    +
  1. 超管 GET scope:没有选择、TTL 过期、目标不可用或缓存无效时返回当前合法所属租户;无法确定则拒绝管理数据。非超管直接使用本人当前租户,不读取超管选择。
  2. +
  3. POST scope:严格正整数 tenant_id、超管和 active 目标校验;null/global/不存在/禁用拒绝且保留原值。单租户使用具体 Root,隐藏切换。
  4. +
  5. 共用读取/调用保持原合同:flows/assistant/knowledge 等被两端共用的读取和使用接口,不新增 X-Management-Tenant 必填条件,不拆分新 API,不要求 Vibe 管理动作。仍按原可见性及相应 use/业务条件执行。不能以“可见”授权编辑、发布、删除或返回原本不可见的敏感字段。
  6. +
  7. 专用管理写保护:Vibe 管理写和明确的管理新建/批量写入口按 HTTP method + 既有写端点登记,校验预期具体租户后 pin ContextVar;旧表单范围不符或缺少规定的预期范围时拒绝并刷新。不能使用包含共用 GET 的前缀通配表执行此门禁。已有资源写还需核对目标资源范围与原动作,不能通过省略头或变更请求参数降级成只读调用。后台任务携带已验证 tenant,不能重读页面选择。
  8. +
  9. F113 服务账号管理仍接受原 X-Service-Account-Tenant,不因本次增加新头而使仅带旧头的合法请求失效;若同时携带新旧头,二者必须一致,并遵守原范围变化错误合同。服务账号页读取同一有效范围。
  10. +
  11. 管理页共享 hook 保存范围快照;切换/失效后清理 query 缓存、详情和可提交表单,通过 localStorage 事件同步标签页并在重新激活时复核。管理数据返回附实际生效范围;前端先核对范围和本地代次再展示,旧响应不能写入新视图。共用读取接口无需因此改变必填参数或调用方式。
  12. +
  13. 依赖故障时,管理读仅能回退到独立验证的本人租户并报告实际范围;无法确认选择的管理写拒绝。真实身份无法确认则拒绝,不退化为全平台。
  14. +
  15. SDK、开放 API、app-proxy HMAC、内部运行回调和个人审批继续使用已验证主体/任务及目标关系。用户提供的头不是身份或授权;本轮不为其它业务增加管理/客户端身份分类或拆分协议。
  16. +
+

管理模块的范围收窄如下;这里只改变管理数据的有效租户范围,Vibe app 以外的权限动作保持既有逻辑。公共预置目录仍按现有合法共享规则读取。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
范围现有入口锚点本轮边界
Vibe appapps 管理路由及 app 权限目标本轮六动作映射、内容裁剪与具体租户范围;个人审批用途单独校验任务
工作流/助手现有 workflow/flows/assistant 管理读取及写端点管理数据范围随具体租户;共用读取/调用的现有最小权限、参数和动作保持,不另拆接口
知识库/模型现有 knowledge/llm 管理入口仅核对管理数据过滤;共用读取/调用沿既有权限,不附加 Vibe 管理条件
审批管理/审计approval/admin、管理进度、audit、app-runtime-audit异常、进度、处理及导出同具体租户;个人任务资格独立
用户组/角色/服务账号既有管理入口具体范围解析统一;专用管理写防旧表单错租户;原资源动作不变
其它既有管理模块既有范围接线清理全平台分支;不因通用前缀把共用业务请求升级为管理请求
+

4.5 发布代行与异步复核

+
用户请求 → 冻结真实主体/用途与目标 → Celery 准备/版本/审批复核
+ → 维护准入 → 维护投影及停旧 writer → 迁移准入 → 启动准入 → 路由提交复核
+可信 observation → backend 固定系统恢复来源 → 新 v2 operation
+ → 同一阶段端口验证原运行意图/围栏 → 仅恢复原批准版本 → 路由提交复核
+
+

两类来源及生产消费:

+ + + + + + + + + + + + + + + + + + + + +
来源生产者及持久化消费者及判定
user_request既有发布/上线/配置重建/预览入口生成 schema=1、actor、credential_ref、mode、self_review_user_id、purpose、target、required_actions;发布记录和新 operation 各保存其受信部分worker、authorize-step、fact 维护投影及 route commit:重载真实主体/凭据/目标,按注册用途查 F113/F048 或有效审批任务。客户端不能自填上下文
system_recoveryRuntimeOperationService.observation 验证 manager 角色、node 与观测围栏后,recover 在同一 repo UoW 重读并冻结 source。保存原 route id/epoch/exec_ref、原 operation id、tenant/app、批准 version/delivery_digest 及已核实的 observation 绑定;不保存历史用户凭据authorize-step、维护 fact 和 route commit:重读上述来源及新 operation 围栏,确认 app 仍 online、current_version 与来源相同、无用户下线/删除/新运行意图取代,只恢复此 delivery;不查历史发起者是否仍有权限
+
    +
  • origin_kind 是服务器内部必填判别字段,两类结构不能混用。只有受信 observation/recover 入口能生成系统来源;普通发布、配置重建、手动上线及运行管理请求不能提交、复制或重标记该来源。manager 的 claim/fact/wakeup 也不能改写来源。系统分支不以空动作列表、deploy kind、auto_commit 或“来自后台”作为允许依据。
  • +
  • 用户来源的 SA/personal 引用是 credential ID,续办重新加载主体、scope/模式及 F113。Web 来源保留可重验的会话版本/过期等材料,不存 JWT 原文。未知 schema、用途/目标不匹配、主体丢失、撤权或依赖不可判定均拒绝用户新增效果;保留审批结论与可解释原因,新的有权用户可按原流程发起新意图。
  • +
  • 系统来源冻结的是当前 serving route 对应的原 operation,而非 assignments 中可能失败的另一次操作。证据来自已提交 route/current_version、原批准版本/RuntimeDelivery 与已验证观测,不要求历史 operation 具有新用户上下文,也不自动给历史用户发布补上下文。来源缺失、非原生 v2、版本或 delivery 不符、目标已下线/删除、新意图取代时拒绝恢复,并保留诊断与清理。
  • +
  • recover 的冻结与 create 使用现有事务及锁重读 app/route/assignment;沿现有 recover:{route.id}:{route_epoch}:{attempt} 幂等键、次数及容量/隔离规则,只设置原批准版本、空 migration_plan,保留 pending_version。代码、profile、能力声明不得借恢复改变;环境变量仍按 F055 AC-70、AC-73 在本次受理时选定已保存配置,不另加“冻结旧值”规则。
  • +
  • 若本操作进入维护,fact(maintenance) 将原 route before/after(id、epoch、operation、version、状态)和本 operation id/generation/owner_epoch/事实 sequence 一起写入现有 payload 的 maintenance_transition。系统来源的后续检查只允许原 route 原状,或这一已验证本操作造成的精确转换;不能因状态叫 maintenance 就允许其它操作接管。route commit 再核对该来源和当前运行意图,提交后重复请求只回读已提交结果,不重新产生效果。
  • +
  • 系统自愈不暴露用户通用 cancel。用户以当前 app.unpublish 调用既有下线流程,先终止 online 意图并令在途恢复不能继续新增效果,再按原 stop/cleanup 围栏收尾;不得把 unpublish 改成要求历史发布人的 publish。系统来源、取消与审计都记为系统恢复并关联 source operation,不能记成自然人或服务账号代行。
  • +
+

首次副作用与阶段顺序:manager 经唯一 v2 HMAC authorize-step 请求一步准入,backend 分别验证用户或系统来源,再检查 app/version、operation、node/generation/owner_epoch、当前 stage、任务/控制租约和 deadline。phase 表示请求的受限业务步骤,必须由当前 stage 允许进入,不等于信任客户端宣称状态;返回相同绑定与短有效期。不持久化永久允许,不给 manager FGA 访问权。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
顺序/步骤准入必须早于什么责任方与重复/拒绝行为
准备、预检、生成版本/审批新源码处理和创建新业务记录worker 复核用户来源;manager 的准备/预览步骤沿同一用途校验;预检/预览不能借此获得生产身份
maintenancebackend 阻断正式 route/出站策略,以及 manager 停旧 writermanager 先请求准入;backend fact(maintenance) 在投影前复核来源及围栏,并与事实/maintenance_transition 同事务提交。manager 得到该事实的有效确认后才停旧 writer;请求失败/结果不确定时不得先停或继续迁移
备份/迁移/数据恢复新的生产数据写入步骤在已确认停写和独占维护之后,再按用途检查;保留快照、writer、PVC/锁与迁移围栏,不沿用维护许可授权迁移
starting创建或启动新正式执行体manager 再请求一步准入,后端仍验证相同受信来源;不以迁移成功或 auto_commit 替代授权
route_commit新正式路由、正式凭据及出站策略生效backend 在提交路径复核来源、预期 route 转换和运行事实,auto_commit 分支同样经过此检查;提交后回收旧执行体属于原操作收尾
+
    +
  • 维护开始前撤权/来源失效时,不得 report 出可生效的 maintenance,不阻断旧 route/策略,也不停旧 writer。fact 的回复丢失时,按原 sequence 查询/幂等重放已提交事实并核对 maintenance_transition;不生成第二次状态转换或把未知结果当成功。
  • +
  • 已经合法进入维护后发生撤权,不回滚已发生的事实。允许完成本步停写和必要清理,后续迁移、启动及新路由仍需新的准入。已变更数据只能按原快照/停止证据及原批准版本规则处理;没有可证明的安全恢复路径时保持可解释的维护/失败状态,由有权用户处理,不承诺无中断,也不启动未授权新版本。
  • +
  • 用户授权失效不阻止既有幂等失败清理。自愈/清理不能扩大版本或能力;旧记录缺上下文时拒绝新的用户效果,但允许按上表由当前可信运行记录验证的系统恢复。唯一 v2 capability 缺失或结果过期/不确定时拒绝新步骤,既有观察、健康检查与必要清理继续。
  • +
+

4.6 应用发布审批的自审与撤回

+
    +
  • 发布 payload 含 submission_actor 与 self_review_user_id,由发布冻结记录投影;申请人仍是 app owner。多节点运行时 handler 从同一冻结来源重建这些信息,服务账号改绑不改写历史。
  • +
  • 应用场景 handler 排除 {app.owner_user_id, self_review_user_id},候选先经过现有有效人员/租户条件,重复 ID 去重。
  • +
  • 单管理员例外需要从租户管理身份的可信端口确认实际管理员数量和候选,不用过滤结果或人工配置候选数代替。Root 的既有平台超管来源按原产品口径统计;无法确认时不启用例外。无合格候选沿现有 approver_empty 异常路径通知处理,不伪造任务或自动通过。
  • +
  • 异常重试、指定审批人和后续节点调用同一应用场景资格钩子;明确候选不合格时拒绝指定。现有合法审批运维处理权仍存在,但不能把指定已排除者包装成正常审批。其它场景钩子为空保持原行为。
  • +
  • 主动撤回由 app publish 资格授权。新增审批 application 层的应用发布业务撤回端口,验证可信 app/deployment/instance 对应、场景及可撤回状态,再复用引擎的 withdrawn 流程与真实操作者审计。保留 withdrawn 与 deleted/cancelled 的区别;公共用户撤回入口的 applicant 检查及其它场景不放宽。
  • +
+

4.7 两种预览及平台能力

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
用途启动/继续访问资格注入身份能力授权主体声明来源
management当前 app edit,且版本仍在允许预览阶段实际预览者本人本次 version 的冻结声明
approval当前有效审批任务绑定该 app/version/instance实际审批人app 原归属人,限审批例外同一冻结 version
+
    +
  • 扩展预览 GET/POST 的 purpose=management|approval;管理页显式传 management,审批页显式传 approval。客户端提交 purpose 是选择用途,服务器仍查 edit 或任务。省略时仅在恰好一种资格成立时推导用途:仅审批资格为 approval,仅 edit 为 management;两种资格同时成立则拒绝并要求显式选择,响应返回实际 purpose。DELETE 按服务器会话用途校验且仅限会话对应人。
  • +
  • management 请求仍绑定当前具体管理租户;approval 请求使用可信任务的 tenant/app/version,属于个人审批业务授权,不因超管切换页面范围而改成另一租户任务,也不能凭 purpose 跳过任务校验。
  • +
  • 沿用现有 approver_user_id 字段保存唯一预览者,避免重命名破坏旧读取;新增 purpose 和审批 instance/task 绑定。查询复用键含 version/viewer/purpose/任务,不把同一用户不同用途共用。
  • +
  • 入口每次打开与能力调用都确认会话未过期/未回收、viewer 相同、用途资格仍有效、tenant/app/version 一致。OBO 签名携带 preview_session;签名通过后仍从可信会话解析用途和能力主体,不信任原始请求头中的 owner/purpose。
  • +
  • 新增 CapabilityAccessContext 端口:返回真实 viewer、实际能力主体、tenant/app/version、purpose 和会话/任务绑定。模型面与检索/附件入口共用端口;组合根在 API 与 worker 所需进程均注册,默认未注册拒绝。OpenAPI 域只依赖端口/值对象,不直接读取 F055 ORM。
  • +
  • 同步/异步认证和排队任务实际执行都校验预览绑定;队列快照保留 credential/session/version/purpose 引用,消费时复核,不缓存永久允许。预览凭据必须与同一会话的可信 OBO 配对;缺 OBO、正式凭据配预览 OBO、不同预览会话混配均拒绝。正式运行的既有 app_self 模型调用规则不因本次扩展改变。
  • +
  • 普通检索仍按访问者;审批预览检索以 app owner 构造授权身份,同时保留实际审批人审计和冻结声明白名单。模型仍沿现有 F051 的租户启用范围/声明规则,不新增逐模型 F048 授权;预览必须读取冻结版本,不能读取正式 current_version 代替待审内容。
  • +
  • 预检及两类预览的用户环境密钥全部为空。预览独立数据库和附件 namespace;不能获得生产数据 session、备份上传或正式部署用途。
  • +
  • 每个新预览单独签发 hosted_app 凭据并在会话中绑定 credential ID;不调用会撤销 app 其它凭据的全局 reissue。预览凭据仅在其会话与用途有效时认证,不依赖 app.state=online,也不能被正式路由凭据检查接受。正式凭据继续用原 RuntimeCredentialBinding/route 校验。
  • +
  • 回收只吊销本预览 credential,不对整个 app 执行 revoke_subject,不能中断正式实例。新预览仅经 v2 operation 创建,必须同时绑定会话凭据和 operation 围栏;不为支持预览而放开任意 staged 正式凭据。
  • +
+

会话/凭据/operation 的原子登记(R6):

+
    +
  1. 先按用途做当前资格检查。进入同一 tenant 的 RuntimeDeliveryRepository.transaction() 后,锁定该 AppVersion 行,再查复用键 (version_id, viewer_id, purpose, approval_task_id),管理预览的 task 为 NULL。所有预览创建入口使用该串行点,活跃状态为 starting|running;索引仅用于查询,不当作唯一性保证。复用时返回既有会话/操作,不能重新签发凭据或重复占容量。
  2. +
  3. 无活跃会话时在此事务内创建 starting 会话、提交尚未对外可见的新 preview operation、签发 per-preview credential、回写 credential_id/operation_id,并保存包含预览凭据和隔离存储凭据的 sealed_env、必要签发审计及容量记录。使用同一 repository 实例和 caller-owned SQL session;既有嵌套 transaction 仅复用此 UoW,不使用先独立 commit 的 _insert_session 或另建 repository。跨域经 application 端口,签发复用 CredentialService.issue_in_transaction,不复用正式 stage_binding,也不调用全局 reissue/revoke_subject。
  4. +
  5. 事务内不发 wakeup、Docker/K8s、MinIO 或其它远端调用;运行材料和节点资格使用已验证输入及本地控制记录,实际执行阶段再复核当前用途资格。成功提交后才 wakeup/wait;node plan 也可发现持久操作。明文凭据仅在本次内存中进入加密 env,不能返回前端或日志。所有新记录提交后必须具备完整 session/credential/operation 双向绑定,未确认 ready 前入口及能力调用拒绝。
  6. +
  7. fact(ready) 经用途/会话资格及运行围栏校验后,在现有事实与 route 事务内调用 preview lifecycle 端口,把同一会话从 starting 改为 running,并对齐会话/operation/凭据/存储的过期边界;不提前呈现可用预览。准备中的启动超时以 operation deadline 为准,运行后以会话 TTL 为上界,不等七天 TTL 才处理已失败的启动。
  8. +
  9. 预览生命周期采用一致锁顺序:先 Version 串行锁,再按现有 runtime 围栏锁操作,最后 Session/单个 credential。preview 的 fact/reclaim 分支须在获取 operation 写锁前安排 Version 锁;不能在持 operation 锁后反向取得 Version。锁内重查当前用途、目标与绑定;跨进程唤醒、执行和等待均在事务外。
  10. +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
情况持久化与回收规则
SQL 提交前签发/容量/环境准备失败整个事务回滚;不留下会话、凭据或操作,也不通知 manager。不得调用独立提交的签发函数
提交回复未知不假定失败,也不立即创建新会话或吊销不确定的凭据;恢复连接后按同一复用键和持久绑定查询。已提交则复用原 operation/密文 env,否则在 Version 锁下重试;仍无法确认则返回可刷新查询的失败原因,不宣称已回收
提交成功但进程在 wakeup 前退出/唤醒回复丢失node plan 或下一次同键请求唤醒同一 operation;不重新签发或创建第二个活跃会话
启动失败/取消/超时、用途资格失效、审批终态、TTL 或人工回收同一数据库事务关闭会话并按 credential_id 吊销仅该预览凭据、登记其 operation 的取消/清理义务;重复回收是幂等的。执行体/附件清理由原 manager 围栏及 RuntimeCleanupService 续办,未确认停止不释放容量或宣称清理完成
+

预览 fact、终态处理、面板 describe/reclaim 与现有超时/cleanup 续办均调用 app_publish.application.preview_runtime 的窄端口核对和收敛绑定;该端口拥有会话状态,签发/吊销经 open_api 的事务端口,runtime 域不直接改 F055 ORM。无需新 Beat 任务或驻留预览服务。只有查询证明完整且当前仍合法的活跃会话才可复用;缺失/冲突绑定拒绝访问并进入原可解释失败清理,不借重新签发掩盖不确定结果。

+

4.8 运行操作、数据与内部存储

+
    +
  • RuntimeOperation.payload.authorization_context 携带 origin_kind、受信 purpose 和来源绑定;用户来源按当前 actor/动作,系统恢复按 §4.5 的原运行意图验证。用户请求/取消/重试先验证 app、scope、operation 归属,未知或不匹配用途拒绝。系统恢复没有用户通用取消,既有 unpublish 终止 online 意图并触发原 stop/cleanup。用户数据恢复是否包含启动在接收时固定。
  • +
  • 数据 session、行修改、CSV 导出/恢复结果与私有快照读取均需当前 edit。内部 transfer 凭据继续绑定 tenant/app/version/op/用途;用户入口再次鉴权,manager 的必要快照上传、已受理维护结果写回和安全清理按原意图执行,不模拟自然人。
  • +
  • 移除 natural_owner_required,不移除暂停 writer、独占 PVC/锁、generation/lease、digest/profile、迁移与恢复确认。环境变量确认重建与新版本发布为 edit AND publish;旧绑定升级/rebind 退役,不新增通用重建;停用为 unpublish;删除为 delete 且仍须先下线、确认及完整资产/Grant 清理。
  • +
  • 在服务端取消请求写 cancel_requested 前,按原用途对当前取消者校验全部动作;serving/retiring 等原不可取消阶段仍拒绝;终态重复请求按原幂等结果处理,不借重复请求重新执行。
  • +
+

4.9 数据结构与数据库变更

+

不新增业务表;拟对既有表做 additive DDL,所有 SQL 变更遵循 C2 和 migration AGENTS,JsonType 仅在 Python 解析。以下是设计新增字段,不是已实现 schema。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
载体新增/沿用字段生产者与使用者
appowner_user_id、tenant_id 原样沿用既有服务账号归属事实;所有后续操作不能改写
app_deployment新增 nullable authorization_context: JsonType;沿用 submitted_by_user_id(SA 保持 0)接收业务生成;worker、审批与上线复核读取;不对 JSON 做 SQL 查询
app_preview_session新增 nullable purpose: String(16)、approval_instance_id: Integer、approval_task_id: Integer、credential_id: BigInteger、operation_id: String(36);沿用 status,增加 starting 值新记录强制完整绑定;credential/operation 各有唯一索引(旧 NULL 保留),复用查询含 version/viewer/purpose/task/status;Version 行锁保证串行,不依赖可空/条件唯一索引;ready 才转 running
runtime_operation沿用 payload,新增内部 authorization_context schema 1 的 origin_kind 判别结构,用户 actor/凭据与系统 source 互斥;沿用/扩展 maintenance_transitionbackend 用户入口或 observation/recover 生成;authorize-step、维护 fact、路由提交及取消分别验证;不新增表/列
approval_instance.payload_snapshot投影提交主体、自审关联自然人及发布记录引用只有业务 handler 生成,不能依赖当前服务账号新归属人
api_credential / RuntimeCredentialBinding沿用表;预览凭据由会话 credential_id 区分,正式 binding 语义保持认证、回收与生产路由各自验证
+

迁移不回填旧 owner/tenant/Grant/发布操作者,不猜测旧 SA 的自然人身份。旧在途发布缺上下文时拒绝新增用户授权效果并提示重新提交或有权重试,不伪造历史 actor;已提交原生 v2 路由可经 §4.5 的当前运行记录重新证明系统自愈来源,保留旧版本观察与必要清理。旧 purpose=NULL 预览不获得新增能力例外,拒绝继续入口,按原终态/TTL 清理,用户重新拉起新会话;不把旧生产凭据误吊销。

+

4.10 前端接线

+
    +
  • platform:HeaderTenantScope/useAdminScope 移除 global/null,展示实际具体租户。管理页增加范围快照、旧响应保护与清表单;预期头仅由登记的专用管理写 API 使用,不对助手/工作流等共用读取加必填头。服务账号页保持 F113 旧头保护,store 不发 HTTP。
  • +
  • Build app:按 allowed_actions 决定列表、详情与 tab;publish-only 等用户仅加载安全摘要。数据/日志/密钥读取、管理预览需 edit,确认重建需 edit 与 publish,Grant 区需 manage_permission;上下线/删除沿原确认与状态守卫。移除额外 owner/admin 分支及未实现的提交发布按钮。
  • +
  • client:现有助手、工作流、知识空间共用读取/调用保持原 API 及最小权限,不为 F114 新增管理租户头或接口分流。Vibe 审批审读/预览继续用任务接口并显式传 approval;通知区分申请人、实际提交主体和归属人。
  • +
  • 已阅读 UI docs/index.md、components/index.mdx、Select 规范;复用现有 Header 中的 @bisheng/ui Select 与已有确认/通知实现。当前 Select demo 标为 draft,故沿用已在 Header 落地的用法,不新增组件或改视觉。其它组件按实施时当前 done 清单复核。
  • +
  • 新提示三语同批交付,错误文案只写 packages/locales 源并生成,不手改产物。当前范围无法确认、范围已变化、缺少动作、审批/运行条件失败分别提示;403 继续由请求拦截器处理。
  • +
+

4.11 v2 收敛清单与测试环境处理

+

以下为拟改边界;本轮不执行真实环境盘点、停用、重建或资产清理。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
位置删除/收窄必须保留/统一
backend 配置与调用分支runtime_protocol_version==1 路径;v1 deploy/build/probe/preview/stop/destroy/schema/数据调用及旧签名 RPC默认仅 2;RuntimeDeliveryCoordinator 和 v2 data/observe/material 端口;页面和 CLI 外部路径不变
manager composition root、auth、reconcile旧 intents/routes/readonly/storage/appdb HTTP routers、本地 desired-state 控制循环、v1 专用 HMAC 校验operations/platform_data/platform_observe、PlatformReconcileLoop;v2 分角色签名/nonce/幂等/租约;节点本地材料与诊断缓存不作为控制真相
app-proxy/egress-proxy向 manager 查询 v1 路由、读取旧本地 egress 真相的回退;协议选择分支及 v1 专用配置从 backend 读取 v2 路由/出站投影;HTTP/WebSocket、backend entry HMAC、OBO 与 v2 身份密钥仍有效
构建及数据库公共逻辑BuildService 等仅服务 v1 的调度/记录壳;旧附件直接 MinIO 服务及专用依赖v2 legacy_prepare/source-image、OCI assembler 仍引用的模板函数;appdb 中 v2 预览/data_worker/K8s helper 使用的备份/迁移/查询工具;必要时先提取公共模块再删壳
旧绑定升级工具manager /v2/upgrade/inventory;backend /apps/{app_id}/runtime/upgrade、/apps/{app_id}/runtime/upgrade/rebind 及其 RuntimeUpgradeService.import_binding/rebind;旧 desired-state/存储凭据导入及专用恢复入口AC-57 明确一并退役,数字带 v2 也不保留旧导入/重绑;AC-26 配置确认重建和 AC-35、AC-36 正常新版本发布保持;不登记 runtime_upgrade,不新增独立通用重建或跨节点数据迁移
交付配置/文档/测试旧 systemd/示例/CI fixture 中默认 1、双协议启动选项和旧协议成功路径Compose/Chart/安装校验统一 v2;更新实际受影响的镜像启动/配置烟测及必要失败配对;部署和运行能力报告不可冒充本轮实测
+

保留能力与执行入口:

+ + + + + + + + + + + + + + + + + + + + + + + + + +
业务操作已有入口与执行链本轮权限/范围
同版本环境变量确认生效POST /api/v1/apps/{app_id}/secrets/rebuild → runtime_secrets.rebuild_current → AppSecretRebuildService;验证当前版本、配置 revision、route/确认指纹和旧 operation,生成 config_restart 新 deploy operationAC-26:edit AND publish;保存环境变量不重启,取消确认不创建操作;保留固定 delivery/版本、幂等及旧 writer 围栏
代码、镜像、profile/运行环境或启动声明变更现有 CLI/MCP/开放发布入口 → 发布管线 → 扫描/预检/冻结新版本 → 审批 → v2 部署AC-35、AC-36:edit AND publish;不直接修改冻结版本,不以同版本重绑绕过审批;公共 profile 运维仍按原平台资格
旧绑定导入、升级与 rebind上表的旧 manager inventory 与 backend upgrade/rebind 均不再注册,返回不可用/未找到AC-57、AC-71:即使拥有 edit/publish 也不执行,不创建 operation/实例或变更路由/存储
+

普通 RuntimeDeliveryCoordinator.deploy 对正常在线同版本返回现有实例是既有幂等行为,本轮保持;不会加 force 开关或新建通用重建入口。runtime_upgrade 不进入新操作用途映射;未知用途拒绝新增用户授权效果,必要失败清理保持原安全边界。原生 v2 应用无需仅为协议清理而重建。

+

协议兼容矩阵(目标):

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
输入/环境结果
v2 Docker 或 K8s,所需 capability 与身份配置完整进入同一持久 operation,按 §4.5 在准备、正式启动及路由提交前复核
未显式设置协议唯一默认值 2;仍需通过既有 v2 身份、材料和运行前置检查
显式 protocol=1、未知值或混合配置控制进程在启动工作前给出明确配置错误,不启动旧对账/部署;禁止静默回退
请求已退役的 manager v1 RPC 或旧绑定 upgrade/import/rebind不注册旧 handler,返回不可用/未找到;无业务、容器或存储副作用;无需另造兼容成功响应
平台 /api/v1、开放 /api/v2、已有 manifest_version:1 源码包保留各自既有合同;旧格式源码经 v2 source-image,不调用旧 manager RPC
v2 历史在途记录缺本次身份上下文保留旧版本服务/清理,拒绝新增效果并提示有权用户重新发起,沿 §4.9;不猜测 owner 代行
测试环境发现 v1 活跃实例/控制状态、旧附件绑定,或名义已为 v2 但仍指向旧导入执行体列出并保留数据,停止该环境的切换实施;先按获授权范围备份、停写、显式以 v2 重发/重建并验证,再切换;不提供产品级双协议或自动收养
+

实施顺序:

+
    +
  1. 在升级实施前只读盘点实际涉及的测试环境:记录协议、镜像/配置、运行应用、SQLite 卷、附件对象及在途操作;同时核对真实 Engine 标签、legacy_exec_ref/导入绑定和旧存储端点引用,不能只看配置值为 2。source-image 或 manifest_version:1 本身不等于 v1 执行体,不因此重建。该文档不授权访问或修改其它环境。
  2. +
  3. 若有 v1 资产,先提供精确处理清单并在授权范围内停写、备份及按原 owner/tenant 受控重建;未完成前不把新镜像覆盖上去破坏管理能力。代码中不为这次测试资产处置保留长期导入 API。
  4. +
  5. 先梳理公共函数的调用者,提取 v2 复用能力,再删除旧控制入口与配置分支。保留 v2 数据/源码/附件对象引用,不改历史版本记录或自动补 Grant。
  6. +
  7. 本地仅跑必要定向验证;部署时统一 backend/worker/manager/proxies 的 v2 配置和所需 capability,再恢复新受理。新旧控制器不得同时管理同一 SQLite 数据卷。
  8. +
  9. 回退只使用理解 v2 任务/资产及本次身份用途字段的兼容构建;不以降到 v1 manager 为回退方式,不 drop 运行表或回写旧数据库快照。DDL 和业务数据恢复分别遵守原确认规则。
  10. +
+

5. 已知坑与护栏

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#已核对的反直觉事实不知道会怎样处理位置
1F048 已支持服务账号创建的可撤销 CREATOR_GRANT,但 Vibe 传的是 owner actor错以为需要另造授权体系,或把 app.owner 改成服务账号D2、AppProvisionService 创建参数
2F048 manager 的动作随 CURRENT Catalog,且已有创建来源语义用模型名字推定权限或意外扩大初始授权D2:复用原政策并检查真实有效动作,不新建模型兜底
3归属人、申请人、实际 SA 主体和自审关联自然人可能不是同一对象代发布后漏排真实提交方,审计冒用自然人D4、发布冻结上下文、场景 handler
4超管业务鉴权入口可能提前允许未验证 app tenant/state 就跨视角读写D1、业务事实先验证、权限 adapter 入口
5admin_scope 无 key 当前代表全平台,且选择按用户跨标签页共享只删 UI 后旧 API 仍读全平台或旧表单写错租户D3、范围解析、管理页快照与专用管理写入口
6set_current_tenant_id 后仍可能受 admin override 影响将目标租户当作调用者身份,或内部任务继承页面范围tenant_execution_scope、真实 actor 独立留存
7deploy kind 同时用于普通上线与配置重建仅有 publish 就能取消要求组合动作的操作D7、受信 purpose 和当前取消者校验
8当前 AccessSubject 丢弃除 user_id 外的信息,默认声明读取正式版本审批例外靠注释“存在”,或预览读错版本D5、能力上下文端口及冻结版本
9AppRuntimeCredentialService.issue 会先撤销该 app 旧凭据拉起预览中断生产模型/知识库调用独立 issue_preview 与按 credential ID 回收
10运行 v2 正式凭据绑定 serving route,草稿不能按正式 app 凭据认证为让预览工作而放开全部 staged 凭据预览会话独立绑定与用途验证
11源码包/manifest/operation payload 不等于可信权限材料客户端伪造 owner/actions/context 获得执行资格接收服务只生成受信字段,不原样透传
12投影不确定时直接删 app 会留下授权真相与资源不一致形成无法治理孤儿 GrantF048 rollback_created 与原账本围栏
13flows/assistant/knowledge 路径包含两端共用读取/调用仅按前缀强制管理头会误拒正常使用,并扩大本轮改造D3、§4.4:原最小权限不变;预期范围门禁限专用管理写
14v2 仍复用 builder/appdb/egress 中的公共函数,旧升级入口甚至以 /v2 命名按文件名或 URL 数字误删 v2 能力,或漏留旧控制入口D9、§4.11 按实际调用链删除控制壳,保留公共工具
15旧 rebind 调用 v2 deploy,但正常 online/serving 同版本只返回原实例;真正配置重建直接 submit 新 operation误保留无实际重建效果的入口,或在权限任务中扩展通用重建D7、D9、§4.11、§6.1:旧重绑退役,配置确认重建沿 AC-26,镜像/运行环境变更沿 AC-35、AC-36
16recover 与配置确认重建都用 deploy/auto_commit,但只有前者是系统恢复按通用标志豁免用户请求,或因历史发布人撤权阻断正常自愈D6、§4.5/§6.2:受信 origin_kind、原 route/version 来源与当前意图复核
17maintenance fact 会先阻断正式 route/出站策略,manager 随后停旧 writer,早于迁移把授权放在迁移前仍会先影响旧服务§4.5:维护准入先于投影/停写,记录精确转换,后续步骤分别准入
18预览原先先提交会话再建 operation;新签发凭据若独立提交会加大部分失败窗口并发双启动、孤立凭据,或未知结果时误吊销已启动预览D5、§4.7/§4.9:Version 串行点、同 repo UoW、starting 完整绑定、提交后 wakeup 与单凭据回收
+

新增实现护栏:PreviewRuntimeRepository 负责预览查询及行状态,CredentialRepository 负责精确单凭据吊销/审计和可用性;公开端口只传调用方 session 与不可变引用,不独立提交。应用候选 ID 读取单批 500 以兼容 DM8。预览 UTC 截止时间转 Unix 秒时显式使用 UTC,避免本机时区使有效期漂移;ready 同事务延长 operation、附件和 egress 到会话截止时间。旧控制 HTTP fixture 明确 retired skip,共用构建/SQLite工具测试继续保留,不能把 skipped 算作 v2 成功证据。

+

6. 对外契约与依赖

+

6.1 HTTP 与内部接口(本地已接线)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
契约设计行为消费者/兼容性
GET /api/v1/admin/tenant-scopescope_tenant_id 必为正整数,expires_at 可空;新增 source=selected/ownHeader、服务账号选择;旧空值不再表示全平台
POST 同路径严格正整数 tenant_id;null/global 拒绝且保留原值旧清空客户端须升级
专用浏览器管理写(method + 写端点)X-Management-Tenant 核对具体范围并 pin;已有对象还验证目标;不使用共用 GET 前缀门禁仅登记的 Vibe 管理写和管理新建/批量写包装入口;旧表单需刷新
共用助手/工作流/知识库读取与调用原路径、参数、可见性/use 和业务条件保持,不增加统一必填管理头或 Vibe 动作platform/client 现有调用兼容,不拆 API
F113 服务账号管理原 X-Service-Account-Tenant 单头继续合法;同时带新旧头必须一致服务账号页沿原合同,不强制双头
app 基础详情/列表、publish-status增加 allowed_actions:string[],安全摘要;不以 is_owner 作操作资格旧字段可保留作展示兼容,但不再授予操作
已有 apps 数据/日志/secrets/snapshots/runtime 操作路径与请求主体保持;按 spec 映射且裁剪敏感内容Web、已有 CLI/MCP;范围和身份来源分别准入
POST /api/v2/apps/deploy 与已有状态查询app:manage/mode S 保留;已有 app 要 edit AND publish;首次初始化真实主体旧仅粗 scope 的历史 app 请求明确拒绝,主体侧正常补授权
GET/POST /api/v1/apps/{app_id}/versions/{version_id}/preview新增 purpose 查询参数;响应含实际 purpose;approval 绑定从可信任务解析新两端显式传用途;省略时仅按服务器可证明的单一资格兼容
新内部 POST /api/v1/internal/app-runtime/v2/operations/{operation_id}/authorize-stepmanager HMAC/nonce;按持久 origin_kind 验证用户资格或系统 source,并核对 app/version/node/generation/owner_epoch、phase、当前 stage、租约/deadline;返回绑定和 valid_until;维护准入早于投影/停 writer唯一 v2 Docker/K8s executor;fact(maintenance) 与 route commit 仍各自复核并原子记录效果;auto_commit 不豁免,不透传用户令牌
已退役运行控制入口manager 旧 v1 routers、/v2/upgrade/inventory;backend /api/v1/apps/{app_id}/runtime/upgrade、/api/v1/apps/{app_id}/runtime/upgrade/rebind 不再注册;无新 operation/实例、路由或存储副作用不改变其它前端 /api/v1 或开放 API /api/v2 合同;不保留 v1 回退或独立通用重建
已有配置确认重建POST /api/v1/apps/{app_id}/secrets/rebuild 请求/响应保持;本轮改为 edit AND publish,保留 expected_config_revision、运行版本/route/确认指纹及幂等检查,创建 config_restart 操作复用现有 F055 交互与运行链路;保存不重启,不经旧 rebind,阶段授权按 §4.5
app_runtime.application.authorization已验证 app 事实 + 操作用途 → F048 动作判定/安全投影app_publish、开放 API/MCP、状态/数据/运行管理
app_publish.application.preview_access签名已验证材料 + 完整且 running 的会话/任务/credential/operation → CapabilityAccessContext模型范围端口、检索/附件;starting、绑定缺失或失效默认拒绝
app_publish.application.preview_runtime、open_api 事务凭据端口caller-owned session 与受信值对象 → 预览签发绑定、ready 激活、关闭与单凭据吊销;不返回 ORM 或明文凭据给浏览器PreviewInstanceService 与 runtime 的 preview fact/cleanup 共用;Version→既有操作围栏→Session/credential 锁顺序,同一 UoW,提交后才 wakeup
审批 application 场景资格/业务撤回端口仅发布场景接入额外候选资格,业务撤回保留终态约束与操作者发布 handler 与审批引擎;通用用户撤回和其它场景不放宽
+

缺省用途规则:仅有审批任务资格时兼容为 approval,仅有 edit 时兼容为 management;两者同时存在而省略 purpose 拒绝并要求明确选择,避免身份歧义。该规则覆盖上表预览 GET/POST,不由页面路径推定授权。

+

6.2 授权上下文示例(服务器内部,不接收用户自证)

+
{
+  "schema": 1,
+  "origin_kind": "user_request",
+  "actor": {"subject_type": "service_account", "subject_id": 24, "tenant_id": 2},
+  "credential_ref": {"kind": "api_key", "id": 123},
+  "self_review_user_id": 849,
+  "purpose": "release_submit",
+  "required_actions": ["edit", "publish"],
+  "target": {"app_id": "example-app-id", "version_id": "example-version-id", "deployment_id": "example-deployment-id"}
+}
+
+

owner 仍取 app 的自然人归属字段,示例 actor 不是资源 owner。续办读取当前凭据、主体及 F048 结果,required_actions 是受信用途的固定要求而不是已授予结果;与注册映射不一致则拒绝。上下文不含密钥/JWT、密文解密材料或完整授权快照。

+

系统恢复使用同一 schema 的另一分支示例,只有 backend 的 verified observation/recover 能生成;各引用在执行时重新核对,不把这段 JSON 本身当作已授权证明:

+
{
+  "schema": 1,
+  "origin_kind": "system_recovery",
+  "purpose": "system_recovery",
+  "source": {
+    "producer": "runtime_observation",
+    "tenant_id": 2,
+    "app_id": "example-app-id",
+    "operation_id": "original-serving-operation-id",
+    "route_id": "original-route-id",
+    "route_epoch": 7,
+    "exec_ref": "original-exec-ref",
+    "version_id": "approved-version-id",
+    "delivery_digest": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
+  },
+  "target": {"app_id": "example-app-id", "version_id": "approved-version-id"}
+}
+
+

系统分支没有用户 actor/credential_ref;运行 operation 自身承载新 node/generation/owner_epoch。原观测的 node、operation、exec_ref、generation、route_epoch 等已验证绑定一同存入 source,示例只展开主要引用。后台每步按 §4.5 检查来源与精确的本操作维护转换;不能经公开请求创建或把用户配置重建重标记为系统恢复。

+

6.3 错误与审计

+
    +
  • 复用现有 app 不存在/状态、19000 动作拒绝、25020/25021 权限依赖、F113 身份/租户与运行数据冲突错误,补 required_actions、reason 等安全详情;不再返回 owner-only 文案。
  • +
  • 拟在现有 197 模块新增 19704 AdminScopeChangedError(预期与实际范围不同)和 19705 AdminScopeUnavailableError(无法安全确定范围)。本基线源码及 v3.0.0 合同静态扫描未发现占用;登记本版本代码表,实施时再次运行错误码冲突检查。null/缺少具体选择沿用 19703,非法目标沿原目标/租户状态错误。
  • +
  • 历史凭据缺 Grant:明确缺少目标动作并指向既有主体侧授权入口,不自动补权。跨租户或不存在对象沿原不可见语义,不泄露其它租户私有内容。
  • +
  • 审计记录实际 actor_type/id、credential_id、actor_tenant、target_tenant/app/version/op、purpose、required_actions、结果/成因;资源 owner、申请人、审批人单独记录。SA 的 submitted_by_user_id 保持 0,不能用归属人冒充实际操作者。
  • +
+

6.4 Incoming 依赖与风险

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
依赖复用方式变化风险
F048 registry/adapter/runtime/Catalog/投影账本唯一资源动作与创建初始授权提前允许、模型变更、CURRENT 不就绪要安全拒绝;不能复制模型动作
F113/开放通道当前凭据/归属关系准入、冻结发布资格SA 改绑后新旧事实分离;人员停用标记不被误当新增租户失配
F019 + Redis + 租户目录共享具体范围、TTL、可用性缓存缺失不再 global;故障不得静默扩大或写到回退租户
F025 审批网关/handler/outbox发布必审、场景候选验证与业务撤回不能修改其它场景资格;运行时 handler 必须读取冻结自审关联人
F055/F086/F059冻结版本/密钥、RuntimeOperation、执行租约、SQLite/PVC 围栏新动作资格不替代既有容量、健康和 writer 停止证明
app-proxy/OBO/模型范围/检索门面可信 viewer 与用途上下文、版本声明只验签不查会话会绕过用途/期限;能力端口必须在所需进程注册
MySQL/DM8、MinIO、Redis、OpenFGA延用现有基础设施无新增二进制或构建器;DDL 可空增列且不运行 JSON SQL,依赖故障明确拒绝
UI 规范与包装请求/三语源复用已有控件、错误拦截和缓存体系不引入样式/状态库,跨页/跨标签旧响应不能污染新租户
+

6.5 上线与回退

+

实施遵循 §4.11 的盘点、v1 测试资产处置和公共能力提取顺序,再执行本次 additive DDL。暂停新受理,统一更新 backend/worker/manager/proxies 的 v2 配置;阶段授权 capability 对齐后恢复新受理。登记的专用管理写旧表单需刷新,共用读取/调用保持原格式。

+

经盘点确认控制记录、真实执行体标签和存储绑定均为原生 v2 的在线实例及正式凭据,不因协议清理被主动重建;旧导入绑定仍按 §4.11 单独处置。自愈按 §4.5 从现存批准路由/版本与真实 observation 生成系统来源;缺新用户上下文的在途记录按 §4.9 拒绝用户新增效果。预览新事务/状态与 lifecycle 端口应在所有 API/worker 和 v2 回调同时启用;旧 purpose=NULL 会话不升级或收养;部署前必须由旧版本完成回收并确认旧写入者已停止,再切新代码,历史行保留。新过期扫描只处理已有明确用途的新合同记录,避免无法识别的历史行占满每批200条。无法确认旧预览停止时不推进部署;不通过SQL补绑定或删除数据放行。必要清理沿原围栏。v1 实例不能套用这条“不重建”承诺,须先完成 §4.11 的精确处置。

+

回退只使用兼容 v2 及当前身份/用途字段的构建,先停新受理与临时预览;DDL 留存,不回退到 v1 控制或覆盖业务数据库。本文不授权本轮更新 116/189、改权限、迁移数据或公开推送。

+

6.6 受影响合同同步范围

+

本次已在原 spec 顶部增加 F114 后继合同说明并保留历史条款;同步范围为本版本 F054 的管理数据/生命周期资格、F055 的首发主体/代发布/预览/撤回、F056 的基础可见与记录查询、F086/F059 的运行管理、v1 退役及唯一 v2 阶段授权、F113 的具体管理范围兼容说明;以本次已确认 F114 替代目标为准,保留原状态与安全语义。F048 的动作/模型政策及 F019 历史版本线不重写。审批扩展点、通知字段或运行时 handler 改动时,在同一改动更新 approval-module skill。版本合同中已登记本轮 v1 退役目标对 F086 旧兼容条款的替代关系;旧 Feature 的历史验收证据保持原样,实施时同步当前部署指南/配置并清除仍宣称支持 v1 的目标条款。任务拆解后逐项登记,不把未实现方案写成交付完成。

+

7. 测试与可观测

+

7.1 定向策略

+
    +
  • 授权门面单元:六动作、五管理动作 OR、组合 AND、真实 SA/自然人、超管目标事实、数据范围、只持 use 与依赖故障;不改其它资源预期。
  • +
  • 创建集成:自然人 owner/tenant 不变;真实 SA 可撤销 CREATOR_GRANT、Catalog 不满足时失败、投影不确定/逆向清理、历史 app 不重复初始化。
  • +
  • 租户:具体 Root/子租户、null/global 拒绝、失效回退、故障管理读/写;F113 仅旧头及新旧头冲突;专用管理写的旧表单/多标签页/旧响应。共用读取按原可见性进行允许/拒绝定向对照,不新增其管理头测试门槛或全量助手回归。
  • +
  • 发布/预览:A 代 B、双重自审排除、管理员数量例外、无候选异常、多节点/异常指定;两种预览与双重身份,预览凭据不影响生产,冻结版本和密钥空值;任务/会话撤销后下一请求拒绝。
  • +
  • 异步与取消:上传后撤权、worker 重启、维护前/正式启动/route commit 前撤权、缺用户上下文;按用途/当前取消者配对。系统恢复验证历史用户撤权后仍恢复原批准版本、伪造来源拒绝、source route/epoch/version 变化拒绝、auto_commit 配置重建不豁免、unpublish 终止在途恢复;两后端共用同一判定。
  • +
  • 首次效果:维护前撤权时 route/出站策略/旧 writer 均不变;maintenance 回复丢失按同 sequence 确认本次精确转换;维护后撤权只收尾、不新增迁移/启动/路由,不虚报恢复完成。
  • +
  • 预览事务:同键并发只一套活跃 session/credential/operation,不同用途/任务不复用;提交前失败全回滚、提交未知不盲重建/吊销、提交后未 wakeup 仍可续办;启动失败/超时/撤销/重复回收只吊销本预览,生产凭据不变。针对新增锁顺序做定向并发检查,不跑全量回归。
  • +
  • 前端:仅加载获授权内容,按钮无 owner 额外条件;现有确认与文案、请求范围和错误刷新;三语/i18n 与本次变更文件定向检查。
  • +
  • 收敛功能界线:旧 inventory/upgrade/rebind 不再注册,授权用户也不能触发旧导入/重绑;配置确认重建沿现有确认指纹创建新 operation,取消确认不创建、重复提交保持幂等、固定版本/delivery 不变;代码/镜像/profile 变化仍产生待审新版本;不测试不存在的独立 runtime_upgrade 成功链。
  • +
  • 协议收敛定向检查:默认仅 2;显式 1/混合配置拒绝且不启动旧控制循环;旧 RPC/导入不产生副作用;v2 bundle/image/source-image 与 SQLite/附件/日志/SDK 的实际调用仍通,不能只测端口404。对旧测试资产只读盘点不冒充已重建。
  • +
  • Docker/K8s 仅用 v2 运行同一权限业务场景,不重跑全套编排故障;DM8 只静态检查模型/DDL。CI 明确要求的 lint/typecheck 为质量门禁,不能用业务全量回归替代本功能用例。
  • +
+

7.2 AC 到设计边界

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AC设计锚点
AC-01、AC-02、AC-03、AC-04、AC-05、AC-06、AC-07、AC-08D1、D8、§4.2、§4.3
AC-09、AC-10、AC-11、AC-12、AC-13、AC-14、AC-15、AC-16、AC-17、AC-18D3、§4.4、§6.1
AC-19、AC-20、AC-21、AC-22、AC-23、AC-24、AC-25、AC-26、AC-27、AC-28D1、D8、§4.3、§4.8、§4.10
AC-29、AC-30、AC-31、AC-32、AC-33、AC-34§4.3、§4.5、§4.8
AC-35、AC-36、AC-37、AC-38、AC-39、AC-40、AC-41、AC-42D4、D5、D6、§4.5、§4.6、§4.7
AC-43、AC-44、AC-45、AC-46、AC-47、AC-48D2、§4.2、§4.5、§6.2
AC-49、AC-50、AC-51、AC-52、AC-53、AC-54D4、D5、D6、§4.6、§4.7、§4.8
AC-55、AC-56、AC-57、AC-58、AC-59、AC-60D6、D7、D9、§4.3、§4.5、§4.7、§4.8、§4.11;AC-57 为旧绑定入口退役,非新增通用重建
AC-61、AC-62、AC-63、AC-64、AC-65、AC-66D1、D3、D8、§4.10、§6.3
AC-67、AC-68、AC-69、AC-70D4、D5、D7、§4.6、§4.7、§4.8
AC-71D6、D9、§4.11、§6.1、§6.5;退役入口与能力/资产保留
+

7.3 可操作的真实验证

+

设计确认并完成代码后,在用户授权的环境使用独立临时租户 A/B、管理者 A、归属人 B、审批人 C、服务账号与应用;通过安全环境变量传入登录材料,不写入 Git/日志。116 既有应用保持原 owner/tenant/状态,不以测试修复存量资源。

+

建议命令入口(实现阶段新增的本特性用例,当前尚不存在,不能当作已运行证据):

+
cd src/backend
+uv run pytest test/app_runtime/test_f114_action_authorization.py test/app_runtime/test_f114_v2_only.py test/app_publish/test_f114_publish_actor.py test/admin/test_f114_concrete_scope.py
+VIBE_E2E_BASE_URL=http://192.168.106.116:3001 uv run pytest test/e2e/test_f114_permission_actions.py -m e2e
+
+

人工核对 /build/apps、应用详情 publish/data/logs、顶部租户选择,以及 /workspace/settings/my-tasks 的审批入口:同一 app 分别用 use-only、edit-only、publish-only、组合权限及另一租户用户登录;验证页面内容、直接 API 与已撤权请求结果一致。浏览器流程采用现有 E2E 工具和本功能清单,截图和脱敏请求结果按 AC 留证,不以 HTTP 200 代替业务成功。

+

7.4 可观测与审查门禁

+

权限/范围拒绝按现有审计与 request_id/trace_id 关联;异步日志额外含 deployment_id/op_id/purpose/phase 与安全原因码。保留 app owner 与真实 actor 的区分,禁止日志输出密钥、完整环境值和原始访问令牌。

+

任务拆解后逐项 L1;仅运行对应配对测试及必要质量门禁;本功能专用真实 E2E 后按 /sdd-code-review --base feat/3.0.0 做 L2。未运行项标为未验证,CI/单测不冒充部署通过。

+

8. 后续边界

+
    +
  • 不提供历史 app 的自动 SA 补授权;失去旧粗 scope 兼容的账号由既有主体侧入口正常授权。
  • +
  • 不做资源/服务账号移交迁移,不更改 owner 或用新提交人覆盖旧归属;其它资源创建逻辑也不在本轮改动。
  • +
  • 不提供独立通用实例重建或 runtime_upgrade;配置确认生效复用已有 config_restart,代码/镜像/运行环境变更通过新版本发布。
  • +
  • 不维护 v1 控制服务、旧绑定升级/rebind、长期 v1→v2 导入 API 或自动资产迁移;测试资产只按实际授权清单处置,保留数据后以 v2 受控重建。
  • +
  • 不做跨请求全局事务、可撤销已完成业务效果或新增容器/K8s 故障恢复框架;沿原状态机和维护围栏。
  • +
  • 不拆细 read/log/export/secret 动作;若未来需要,应重新评审粒度和 Catalog。
  • +
  • 不在本轮扩展“全部租户”审计或管理视角;平台级运维资格与共享目录只读性按显式原合同保留。
  • +
  • 不重构助手/工作流/知识库的共用读取与调用,不为其新增统一鉴权动作或强制管理头;具体管理租户过滤属于已有确认范围,独立于 Vibe app 动作映射。
  • +
+

修订历史

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
日期改动触发原因
2026-10-30初版设计草案,原含 70 条 AC 追溯;资源归属自然人保持不变用户确认规格进入设计,并再次澄清创建资源归属规则
2026-10-30收窄 D3、§4.4/§4.10/§6.1:共用读取/调用保持现有最小权限,撤去全前缀管理头门禁;当时 R2 仍待修订(已在下一条关闭)用户指出本轮重点是 Vibe app 权限映射,助手等共用接口维持既有逻辑
2026-10-30D9/§4.11 统一 v2;新增 AC-71 追溯、收窄 AC-57,关闭 R2;两类预览仅经 v2,旧控制与导入退役,公共工具/格式和资产保留用户了解双协议差异后要求“收敛统一一下”
2026-10-30R3 按用户“按照建议修改”收窄:AC-57/M22 只表示旧导入/升级/rebind 退役,移除 runtime_upgrade 用途;补齐现有配置确认重建与新版本发布的入口/契约/定向验证,不新增通用重建旧 rebind 对正常在线同版本返回原实例,不能作为已存在的通用 v2 重建依据;设计仍待单独确认
2026-10-30修订 R4/R5/R6:用户/系统来源分型并补 observation→recover→阶段准入→路由闭环;维护投影和停 writer 前准入;预览同一 Version 锁/UoW 原子登记及幂等回收用户采纳独立评审建议;71 条 AC 未改,设计仍待单独确认,未编码或部署
+

实施登记(2026-10-30):用户明确确认设计后进入任务拆解与开发。首批字段、scope与动作门面/业务资格已实施;T065补齐现有短票据生产者,T066补直接受影响的上下文回归,属于原设计接线细节,不改已确认动作/归属/协议边界。

+

| 2026-10-30 | 已确认设计实施快照:六动作/具体租户/真实主体、预览事务与阶段许可完成本地接线;补齐私有补包遗留门槛及工具范围头,新增SQL回滚与凭据配对验证 | task-review发现C1新增查询分层与唯一遗留选择器;无产品边界变化。真实E2E及最终L2待完成,未推送部署 |


+

F114 统一 v2 设计复审记录

+

日期:2026-10-30。结论:LGTM;R1–R6 均 CLOSED。用户要求按建议修订后,同一独立 architecture-critic 复审结果为 PROCEED,七维均为绿色,无 blocker、should-fix 或 nit;主代理按 sdd-review 的 24 项检查复审通过。前稿 REVISE 与曾撤回的同代理 LGTM 原样保留为历史,不作为当前结论。本轮仅文档与源码静态核对,不是运行测试或部署验证;整份设计已于2026-10-30获用户单独确认,开始任务拆解与开发。

+

依据:修订 spec 共 71 条 AC、design、design 模板、release-contract、constitution、当前代码基线 bb62790ad22a3204493e7dce5878e0a4f7109599 与用户本轮决定。实际代码仍含 v1;本稿不宣称已删除旧实现。

+

1. 当前审查结果

+

LGTM

+

当前独立复审:architecture-critique-r4-r6.md,结论 PROCEED;修订前报告另存为 历史评审。以下三项原问题与其行号对应修订前设计,关闭依据见 §6。

+

原审查问题(修订前):§4.5/§4.8 未为 observation → recover 创建的新 deploy 定义独立系统授权上下文;沿用户凭据检查会阻断自愈,按 deploy/auto_commit 放行又会与配置重建混淆,无法闭合 AC-60。 | SEVERITY: high | LOC: design.md:185–192、design.md:222

+

原审查问题(修订前):§4.5 的阶段准入未明确先于 maintenance 投影和停止旧 writer;现有执行先阻断旧路由并停实例,再迁移/启动,晚到的授权拒绝已不能保持旧服务。 | SEVERITY: medium | LOC: design.md:189–192

+

原审查问题(修订前):§4.7/§4.9 新增预览凭据,但未定义同一复用键的并发串行点及会话/凭据/operation 的提交、失败和未知结果回收顺序,查询索引不能保证幂等。 | SEVERITY: medium | LOC: design.md:211–218、design.md:235

+

前轮 R3 收窄内容(保持):旧绑定导入、升级、rebind 一并退役;同版本环境变量生效复用 AC-26 和 config_restart,代码/镜像/运行环境变更复用 AC-35、AC-36 正常新版本发布;移除 runtime_upgrade 用途,不新增独立通用重建。R3 原始问题和源码证据见 §2.3。

+
    +
  • 用户原规格已确认;本轮明确共用接口原权限及唯一 v2 支持方向,已形成可审阅的收敛条款。修订设计仍须用户单独确认后才拆任务和编码。
  • +
  • 资源仍归属服务账号配置的自然人,owner/tenant 与受保护 owner 不变;真实服务账号只用于该次授权、可撤销初始来源与操作者审计。
  • +
  • 本轮仅文档;没有业务测试、浏览器视觉测试、镜像发行或 116/189 环境变更。
  • +
+

2. 问题历史与关闭状态

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
问题原严重度原因本次收敛状态
R1 共用接口high前缀通配强制管理头会波及助手/工作流等既有读取与调用D3/§4.4/§6.1 维持原最小权限和调用,不新增统一读门槛/路由拆分;具体管理范围和专用写保护分开CLOSED
R2 v1 阶段授权缺口high旧 manager 在 RPC 内启动/切路由,无法套用 v2 operation 阶段端口用户明确只保留 v2;D9/§4.11 删除旧执行/回退/绑定导入入口,阶段授权只需一套;配置、代理、交付与测试同步,旧资产单独处置CLOSED
R3 通用重建的保留依据medium旧 rebind 仅接入 deploy;正常在线同版本直接返回原实例,不能证明通用 v2 重建已实现用户采纳收窄建议:AC-57/M22 明确退役;D7/D9/§4.11/§6.1 指向现有配置确认重建与正常新版本发布,不新增通用重建CLOSED
R4 系统自愈授权来源(独立 B1)highrecover 新建 deploy/auto_commit,设计却仅定义用户主体与凭据复核,未知来源拒绝补独立系统意图生产、持久化与验证,绑定原可信 route/epoch/版本,不以通用标志豁免CLOSED
R5 首次副作用前的授权(独立 S1)mediummaintenance 事实先阻断路由/出站策略并停旧实例,早于迁移/启动明确首次准入先于维护投影及旧 writer 停止,说明进入维护后撤权的安全收尾CLOSED
R6 预览并发与凭据失败回收(独立 S2)medium会话先查后建且单独提交;新凭据加入后的事务/补偿未定义明确复用键串行点,会话/凭据/operation 的原子关系或可恢复状态及单凭据回收CLOSED
+

2.1 本轮静态核对依据

+
    +
  • Compose 默认 backend 为 2,manager/入口/出口配置为 2;K8s Chart 同样为 2。源码 config 仍存在默认 1,故列为明确清理项,而非声称只改开关即可完成。
  • +
  • DockerExecutor 已有 bundle/image/source-image;RuntimeDeliveryCoordinator 将缺少 delivery 的已有源码包转为 source-image;K8s 使用其现有执行后端,未新增编排体系。
  • +
  • legacy_prepare 及 OCI assembler 仍引用 builder 模板;v2 预览/data_worker/K8s helper 使用 appdb。设计明确提取共用能力后再删除旧 RPC 壳,防止删旧代码时破坏现行功能。
  • +
  • manager main 仍按协议启动不同对账循环并注册旧 routers;旧 inventory 虽以 /v2 命名仍读取旧 desired-state。清理按调用用途而非数字或文件名。
  • +
  • RuntimeUpgradeService 的 import_binding 是旧执行体/存储导入;rebind 虽调用 v2 coordinator,但正常在线同版本会命中既有实例返回分支。上一轮以“调用 v2 coordinator”推断“现有通用 v2 受控重建”的结论不充分,详见 R3。
  • +
  • 唯一阶段授权端口位于现有 /api/v1/internal/app-runtime/v2/operations/{operation_id}/authorize-step,复用 RuntimeRoute、manager 服务角色及 v2 签名/nonce,不另建旧签名通道。
  • +
+

2.2 实施前保留事项

+

此处为既有安全前置,不是未关闭的设计问题:真实环境协议、镜像、Engine 标签、legacy_exec_ref/导入记录、附件端点与卷必须盘点。配置值为 2 不保证没有旧导入执行体;source-image 或 manifest_version:1 也不能作为旧运行协议的判据。仅原生 v2 绑定可沿“不主动重建”规则,旧资产按授权先保留数据再重建,不提供长期双协议。

+

2.3 R3:原问题、证据与本轮修订

+

原问题定位:修订前 design D7(第 75 行)、§4.11 清理矩阵(第 259 行)、§6.1 退役入口(第 317 行),关联 spec AC-57 和 M22。上述行号是上一稿历史定位,现行锚点为 D7/D9/§4.11/§6.1。原稿将“现有 v2 受控实例重建”作为保留项,却未给出与旧 rebind 的执行边界;先前评审把接入 v2 coordinator 当作能力已存在的依据。

+

静态证据:

+
    +
  1. runtime_management.py 第 112–125 行将 /runtime/upgrade 接至 import_binding,将 /runtime/upgrade/rebind 接至 rebind。
  2. +
  3. runtime_upgrade_service.py 第 131–159 行将旧导入 route 写为 maintenance 并返回 requires_rebind;第 161–166 行的 rebind 只加载 current_version 后调用 coordinator.deploy(..., preserve_pending=True)。
  4. +
  5. runtime_delivery_coordinator.py 第 356–358 行:route 为 serving、version 等于 current version 且 app 为 online 时,直接返回既有 exec_ref/operation_id。该分支没有创建重建操作、增加 generation 或启动新实例。旧导入 route 的 maintenance 状态不会命中它,但这不构成原生 v2 通用重建能力。
  6. +
  7. 已有配置确认重建是另一条真实链路:secret_rebuild_service.py 第 117–154 行验证 delivery/确认快照,写入 purpose=config_restart,直接 submit 新 deploy operation。它对应 AC-26,不能作为存在独立 runtime_upgrade 能力的证明。源码扫描也未发现业务代码写入 purpose=runtime_upgrade。
  8. +
+

影响:接手实现者可能保留一个点击后仍返回原实例的“重建”入口,或在权限映射任务中临时增加通用重建行为;AC-57 的交付边界、旧入口删除和验证对象因此不清楚。这是现状依据和执行契约深度问题,未发现因此直接突破权限边界,严重度为 medium。

+

本轮已采纳的修订:用户明确“按照建议修改”,采用更小范围。旧 /runtime/upgrade/rebind 与绑定导入一并退役;同版本环境变量生效保留 AC-26 配置确认重建,代码/镜像/profile/运行环境变更保留 AC-35、AC-36 正常新版本发布。M22 不再分配可授权执行动作,D7 不登记 runtime_upgrade;§4.11/§6.1 明确现有入口、确认快照/幂等和阶段授权。普通 deploy 的同版本幂等返回保持,不增 force 或独立通用重建 API。

+

定向验证计划:检查旧 inventory/upgrade/rebind 均不注册且无运行/存储副作用;配置确认重建仍创建新 operation,保存不重启、取消确认不创建、重复确认幂等,固定版本/delivery 和 writer 围栏保持;代码/镜像/profile 变更仍经新版本发布和审批。此处为后续实现验证计划,本轮未运行测试。

+

本轮文档差异:仅修改 AC-57 正文,其余 70 条 AC 与上一收敛稿一致;discovery、spec、design、release-contract、两份评审及三份离线 HTML 同步。设计仍待单独确认,尚未创建 tasks、修改业务代码、提交、推送或部署。

+

2.4 修订前独立评审:R4、R5、R6(历史)

+

独立代理 /root/f114_convergence_design_critic 使用全新上下文,只读取 spec、design 和源码,未收到设计讨论过程,也未读取既有评审报告。其原文完整保存在 architecture-critique.md,不改写独立结论;原文的 B1/S1/S2 分别对应本表 R4/R5/R6。7 维评审中“数据与状态建模”为红,“失败与规模现实性”为黄,结论 REVISE。该架构结论是建议;SDD 设计本身也尚未获用户单独确认。

+

R4:给系统自愈一个可验证的授权来源(high)

+
    +
  • 当前源头:RuntimeOperationService.observation 第 701–723 行验证观测与 route 围栏后调用 recover;RuntimeDeliveryCoordinator.recover 第 481–542 行新建 deploy,写 auto_commit,重新注入环境;fact 第 571–574 行还会自动提交新路由。
  • +
  • 设计缺口:D6/§4.5/§4.8 只说明用户主体、凭据与 F048 当前资格,缺来源或未知用途拒绝;但 AC-60 要求自愈不依赖历史操作者。配置确认重建也使用 deploy/auto_commit,不能靠这两个通用字段辨认系统资格。
  • +
  • 建议:沿现有 authorization_context 区分用户意图与 backend 生成的系统自愈意图,说明 observation/recover 如何绑定原 operation、route/epoch、tenant、批准版本及当前有效运行意图,authorize-step 和 route commit 如何验证;用户不可自证系统来源。明确系统自愈取消与应用下线的既有动作边界。不新增授权引擎或恢复框架。
  • +
  • 仅需的定向验证:历史发布人撤权后,原批准版本仍能受控自愈;伪造系统来源、版本/route 变化被拒绝;用户配置重建仍须 edit AND publish,不能借 auto_commit 豁免。
  • +
+

R5:检查需要早于旧服务被影响(medium)

+
    +
  • 当前首个效果:OperationExecutor._deploy 第 304–318 行先 report maintenance、stop_instances,之后才备份和迁移;backend fact 第 491–497 行在 maintenance 即调用投影阻断;RuntimeProjectionService.block 第 20–28 行修改 route 并撤销出站策略。
  • +
  • 设计缺口:仅列“迁移/正式启动/新路由”之前检查,不足以保证 AC-40 的旧服务保护;实现者若在迁移前才检查,已撤权请求可能先停掉旧服务。
  • +
  • 建议:给出最小阶段顺序表,首次准入必须在 maintenance 投影与停止旧 writer 之前;分别说明迁移、启动、路由提交的检查和已进入维护后的必要收尾。保留既有围栏和清理,不要求跨进程全局事务。
  • +
  • 仅需的定向验证:准备完成、维护开始前撤权,旧 route、出站策略和 writer 不变;进入维护后拒绝新效果时能够按原意图安全收尾,不重新发布未授权内容。
  • +
+

R6:预览会话、凭据和操作需要共同收敛(medium)

+
    +
  • 当前提交顺序:PreviewInstanceService.start 第 165–181 行先查后建,会话在 _insert_session 第 429–444 行独立提交 running,之后才提交运行操作;启动异常只关闭会话。既有查询索引不保证活跃会话唯一。
  • +
  • 设计缺口:§4.7/§4.9 新增 per-preview credential 和用途复用键,但没有声明并发启动的串行点、凭据签发的事务参与关系、崩溃与未知结果后的幂等回收。认证拒绝未绑定凭据只能限制访问,不能自动消除重复会话或遗留凭据。
  • +
  • 建议:明确同一 version/viewer/purpose/task 的串行点,复用可参与事务的凭据签发方式;定义会话/credential/operation 一起提交或可续办的最小状态,失败只回收本会话 credential,重复处理保持幂等,不影响生产凭据。
  • +
  • 仅需的定向验证:同键并发启动只获得一个有效预览;签发失败、签发后崩溃、operation 提交未知结果、启动失败及重复回收不会留下可用孤立凭据,也不撤销生产凭据。
  • +
+

排除的疑点:不把“自愈启动读取当前保存的环境变量”追加为新问题。F055 spec AC-70、AC-73 已允许正式启动/恢复按本次受理选定配置取值;本轮需补的是系统授权来源,不是另加旧环境值冻结规则。预览附件使用不同凭据也不单独作为缺陷,因为设计已要求会话/用途/operation 绑定,尚未证明另有独立问题。

+

以上为修订前设计缺口和后续定向验证建议,原评审未运行测试或修改设计。用户随后要求修订,现已补齐并按 §6 复审关闭;不重新打开 R1/R2/R3,不改变已确认产品口径。

+

3. 24 项设计检查

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#检查项结果设计证据
1目标简明PASS§1:统一 Vibe 六动作、具体租户及唯一运行协议
2非目标PASS§1/§8:不迁归属、不重写编排、不改助手共享权限、不全量回归
3特有约束PASS§2:原归属、可信主体、组合动作和单协议
4宪法引用及门禁PASS只引用全局规则;C1–C8 核对见 §4,无已发现冲突
5关键决策PASSD1–D9 覆盖所有主要边界
6备选/选定/原因PASS每项含被否方案;D9 明确不维护双协议的依据
7重审条件PASS每个决策明确何时重审,不允许实现中恢复回退
8原因证据PASSCURRENT/CREATOR_GRANT、真实 actor、现有配置/执行器/共用函数有代码依据
9数据流闭合PASS§4.5 明确 observation/recover 生产与阶段/路由消费;§4.7 明确预览原子登记、ready 激活及失败回收
10数据结构与格式PASS§4.5/§4.9/§6.2 明确 origin_kind、source、maintenance_transition 与预览 operation_id/status;用户与系统结构互斥
11模块边界PASS§4.1/§4.11 列移除与保留,公开 application 端口不泄露 ORM
12现状/目标一致PASS§4.1/§4.11 和坑 15 记录旧 rebind 的同版本返回;不再把它写成现有通用重建
13反直觉事实PASS§5 共 18 项;新增 recover 与配置重建同 kind、maintenance 首效果及预览独立提交的现状与处理
14坑的后果PASS权限泄露、生产凭据中断、错租户、误删公共能力均说明
15处理位置PASS各坑定位到决策/函数/§4.11 清单
16OutgoingPASS§4.5/§6.1/§6.2 明确维护前准入、fact 投影再验、有效确认后停 writer,以及预览生命周期事务端口
17IncomingPASS§6.4 权限、身份、审批、运行、存储/代理与 UI,无新增基础设施
18风险PASS§4.5/§4.7 覆盖维护前后撤权、回复未知、Version 串行/锁序、提交前后失败与单凭据回收;原协议清理风险保留
19测试分层PASS§7:动作配对、v2 默认/旧入口拒绝/能力保留、专用 E2E,不运行 v1 成功链或全量
20人工验证PASS§7.3 页面、临时主体、计划命令明确;计划用例未冒充存在/通过
21后续边界PASS§8 不保留旧长期兼容、不做自动迁移/新增运行体系
22修订历史PASS原初版、R1 收窄、D9/v2 收敛分别留痕,未伪造用户确认
23spec 一致PASS§4.5 补齐 AC-60 系统恢复与 AC-40 旧服务保护;§4.7 补齐预览执行设计;71 条 AC 原文未改,六动作和归属不变
24tasks 偏差PASS/N/A未有 tasks、未编码,未记录不存在的实施偏差
+

4. Constitution Check

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
规则核对结果
C1业务通过 application 端口,数据库在所属 repository;backend 不增加 Docker/K8s 权限
C2JsonType 在 Python 解析、nullable 增列/索引;无数据库专用语法或业务数据迁移写入 Alembic
C3具体管理范围与真实身份分开;ContextVar 自动过滤,内部任务携可信 tenant,共用业务权限不变
C4六动作、真实主体及唯一 v2 保持;系统恢复只消费可信原运行意图,用户效果在维护前及后续阶段复核,不能以 deploy/auto_commit 泛化豁免
C5错误来源和 19704/19705 设计预留不变,编码前再检查冲突并三语生成
C6不存 JWT/key 原文;预览凭据与会话/operation 原子绑定、ready 后激活、仅单凭据回收;提交未知先查询,生产凭据不受影响
C7请求包装层与 hook,store 不调用 HTTP
C8平台 DB/Redis/对象存储为真相;本地缓存保留,旧本地控制真相退役;能力端口在 API/worker 注册
+

5. 变更与阶段记录

+

原规格 70 条已获确认;用户随后明确唯一 v2 方向,新增 AC-71。前一轮实质复审发现 R3 后整体结论为 NEEDS_FIX;用户随后采纳更小范围建议,本轮进一步收窄 AC-57/M22,原 R1/R2 仍关闭。R3 经前一轮同代理静态复审关闭,当时整体结论恢复为 LGTM;原始源码证据由 §2.3 保留。用户随后选择继续评审,独立 architecture-critic 发现 B1/S1/S2,主代理核对后记为 R4/R5/R6,当时整体结论改为 NEEDS_FIX;本次按用户要求补齐 R4/R5/R6 后,同一独立代理复审 PROCEED,24 项设计检查恢复 LGTM。未把设计或架构意见当作已实现事实。

+

依据 AGENTS.md §6、SDD-Guide §3、sdd-review,修订设计仍需用户单独确认后才能拆任务和编码。用户此次明确采纳收窄建议是修改授权,不替代对整份设计的单独确认。本轮没有业务测试、浏览器视觉测试、代码、提交、推送、镜像发行或 116/189 环境变更。

+

文档核查范围:核对修订差异、71 条 AC 追溯、36 项盘点编号、24 项设计检查、真实端口/类名/确认参数及退役边界;Markdown/HTML 同步、目录锚点、本地文件链接、离线资源与宽窄屏/打印样式作静态检查。未执行浏览器视觉验证、业务测试、旧入口删除、容器重建或部署,后续只做本功能定向验证。

+

修订前独立评审锚点(历史 SHA-256):

+
    +
  • spec.md:f40eda6ede3a6179b192af74d908fe09b33fc7331a508c4f656173fd5fb24b6b
  • +
  • design.md:01406fbb63ec295d91f360aa97cb3c4f06e851eac9d9ff31a049fb6474b487ab
  • +
+

修订前独立复审记录(历史):代理 /root/f114_convergence_design_critic;完整原文 architecture-critique.md。当轮只更新评审、阶段索引及 HTML,spec/design 当时与上列历史锚点一致;本次用户要求的设计修订及新锚点见 §6。

+

6. R4/R5/R6 本轮修订与复审关闭

+
    +
  • R4:D6、§4.5、§4.8、§6.2 明确 user_request/system_recovery 互斥来源、observation/recover 生产及逐阶段/auto_commit 消费;系统只恢复原可信运行意图,不以历史用户或通用字段授权。系统恢复无用户通用取消,现有 unpublish 终止运行意图与恢复。
  • +
  • R5:§4.5 补首个副作用顺序,准入早于维护投影和停旧 writer;维护事实、精确 route 转换与 sequence 同事务;回复未知只确认同次事实,进入维护后撤权只安全收尾,后续新效果仍拒绝。
  • +
  • R6:D5、§4.7、§4.9、§6.1 补 Version 串行点、同一 repo/UoW、starting/operation_id、事务签发绑定与审计、ready 激活、提交后 wakeup、未知结果及幂等单凭据回收;预览 fact/reclaim 统一锁顺序,不新增表/框架。
  • +
+

71 条规格原文不变,以上均是现有 AC 的执行设计补齐,当前尚未实现。原独立报告 architecture-critique.md 原样保留为上一稿快照,其设计行号对应旧 design SHA-256 01406fbb63ec295d91f360aa97cb3c4f06e851eac9d9ff31a049fb6474b487ab,现行章节锚点不变。复审通过不替代用户对整份设计的单独确认。

+

当前复审结论:LGTM。主代理重新按 24 项设计检查及 Constitution Check 核对,未发现未关闭的设计问题。同一独立代理全文读取当前 spec/design 并核实相关事务、凭据签发和执行顺序源码,结论 PROCEED,七维均为绿色;原 B1/S1/S2 对应 R4/R5/R6,全部闭合。独立意见为设计建议,不代表代码实现或运行验收通过。

+

当前审阅锚点(SHA-256):

+
    +
  • spec.md:f40eda6ede3a6179b192af74d908fe09b33fc7331a508c4f656173fd5fb24b6b(原文未变)
  • +
  • design.md:67af078df54babc7d40a13ecdd0d9720063b86502cb0e24e2ca0b8d8774b9f6b
  • +
+

独立复审原文:architecture-critique-r4-r6.md,逐字保存;原报告另存未覆盖。Markdown/HTML 章节、71 条 AC 与 36 项盘点追溯、目录/本地链接、离线及响应/打印 CSS 仅做静态检查;没有视觉验收、业务测试、代码修改、提交、推送或部署。依 AGENTS.md §6、SDD-Guide §3 和 sdd-review,下一步仍是用户单独确认整份修订设计,然后才拆任务与编码。

+

7. 用户确认与实施阶段

+

2026-10-30用户选择“确认设计,进入任务拆解和开发”。该确认发生在上列完整复审后;历史评审及SHA仍指当时快照。现行design增加实施状态、spec只更新阶段元数据,71条AC正文不变。tasks按21项清单LGTM,当前16/66任务完成,78项定向检查通过;这不是整项代码审查或真实部署验收。


+

PROCEED — 原 B1、S1、S2 均已在修订设计中闭合;未发现新的架构阻塞项,可进入设计确认。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#维度评价一行依据
1需求与质量属性一致性🟢六动作、具体租户、真实主体和唯一 v2 均保持;授权一致性、租户隔离与运行安全未因修订放宽。
2边界质量🟢系统恢复由 runtime 拥有,预览会话由 F055 端口拥有,凭据经 open_api 事务端口处理,职责与依赖方向清楚。
3模式克制🟢采用既有单库事务、行锁、operation 与清理流程,没有新增表、锁服务、授权引擎或补偿框架。
4数据与状态建模🟢用户/系统来源分型明确;恢复来源、维护转换及预览完整绑定均说明生产者、持久化位置和消费者。
5变化容忍与 YAGNI🟢两类来源和窄生命周期端口回应当前需求;跨库一致性、独立重建及权限细分仍留待实际需求重审。
6失败与规模现实性🟢覆盖维护前后撤权、事实回复丢失、提交未知、并发预览、唤醒失败和单凭据回收,验证范围适当。
7决策记录与可读性🟢D5/D6 补充事务与来源取舍,阶段表和失败表将责任、顺序与拒绝行为落实为可拆解的契约。
+

blocker

+

无。

+

原 B1 已闭合。依据为 design.md §4.5:186 及 §6.2:396:

+
    +
  • origin_kind=user_request|system_recovery 区分两类来源,只有已验证的 observation/recover 能生产系统来源。
  • +
  • 系统来源绑定原 serving route、epoch、exec_ref、operation、批准版本和 delivery;维护 fact 与路由提交继续验证,允许的维护转换限定为本操作精确造成的转换。
  • +
  • 明确禁止按 deploy、auto_commit、空动作集合或后台身份豁免,并说明历史记录的系统恢复、自愈终止与审计规则。
  • +
+

源码仍显示 recover():481 与用户配置重建共用部署机制;修订已针对该真实冲突给出可实施的来源区分,未借用历史用户权限。

+

should-fix

+

无。

+

原 S1 已闭合。 design.md §4.5:200 明确规定:

+
    +
  1. manager 先申请维护准入。
  2. +
  3. backend 在维护 fact 投影前复核,并原子记录事实及 maintenance_transition。
  4. +
  5. manager 获得有效确认后才停止旧 writer。
  6. +
  7. 回复丢失按原 sequence 查询或幂等重放;合法进入维护后撤权,仅允许本步收尾,后续效果重新准入。
  8. +
+

这覆盖了源码 _deploy():304 中“先维护、停旧实例,再迁移与启动”的实际顺序;§7.1:460 也补充了相应定向验证。

+

原 S2 已闭合。 design.md §4.7:240 已定义:

+
    +
  • Version 行锁下查询并复用活跃会话,不依赖查询索引承担唯一性。
  • +
  • 同一 repository/UoW 原子登记 starting 会话、独立凭据、operation、密文环境和必要审计,提交后才唤醒。
  • +
  • ready 校验与路由事务内激活会话,完整绑定前拒绝访问。
  • +
  • 提交未知、未唤醒、失败、超时与重复回收各有明确处理;只吊销该预览凭据,容量释放仍要求停止证据。
  • +
  • 生命周期锁顺序与 application 端口归属明确。
  • +
+

已核实 RuntimeDeliveryRepository.transaction():53 支持同实例嵌套事务复用,CredentialService.issue_in_transaction():174 确实存在;该方案具备现有代码基础。

+

nit

+

无。

+

本次复审全文读取规格与设计,核实设计 SHA-256 为 67af078df54babc7d40a13ecdd0d9720063b86502cb0e24e2ca0b8d8774b9f6b;仅做静态核对,未修改文件、运行测试或部署。

+

结论:三项原发现均在设计层闭合,当前方案可进入设计确认及后续任务拆解。


+

F114 任务拆解审查

+

2026-10-30,用户已单独确认修订设计。按 sdd-review tasks 进行同代理静态审查,结论:

+

LGTM

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#检查结果依据
1AC追溯PASS71条均由明确测试任务覆盖,且全部有实现切片;不把E2E完成前标记通过
2测试标注PASS每个测试任务逐条列出AC,无范围缩写
3Test-FirstPASS测试任务ID早于配对实现,前驱依赖包含测试任务
4依赖PASS104个唯一ID(含必要增量),依赖均存在且指向先前任务
5原子化PASS每任务1–2个明确文件;新增必要接线另登记
6自包含PASS文件、实现/验证输入和输出、AC、依赖均内联
7粒度PASS前后端分开,不以大目录代表任务范围
8顺序PASS来源与授权门面先于业务接入,预览/阶段许可先于UI
9重复PASS相同文件按明确职责增量修改,不创建第二套权限体系
10实现覆盖PASS管理内容、首发/代发布、审批、运行、预览、代理、范围与UI均有任务
11接口PASS前驱产出门面、事务端口、阶段许可、用途与范围接口明确
12范围PASS不新增动作/引擎/恢复框架/全量测试或资源迁移
13AC格式PASS完整AC-NN,可逐条grep
14测试纯净PASS测试任务只写验证与证据,不混入生产实现
15无延迟项PASS没有将本特性范围延后,未执行验收保留未完成
16DDL回退PASST002只做additive DDL并说明备份/停止新写后降级,不回填数据
17跨FeaturePASS沿F019/F048/F054/F055/F056归属和公开端口,共享场景默认行为不改
18前端分区PASSPlatform/Client任务、API包装、状态及三语分别登记
19Worker租户PASST031说明Celery headers恢复ContextVar,不继承管理视角
20基础设施PASSORM/DDL/错误值对象位于最前,业务依赖显式
21降级PASS无测试降级,DM8按用户口径只静态核对;真实E2E待执行
+

初始范围为64项计划和71条AC;不是代码审查或运行验证。实现期间逐项L1,新增必要文件须更新任务,不能用本计划审查代替交付证据。

+

必要增量复核:T006增加第二个精确scope中间件测试文件;T065只补同一数据票据生产者,T066只改直接受影响的旧全平台断言和新增范围上下文用例。均无新需求/动作/基础设施;总66项,当前静态计划仍LGTM。

+

本地实现收尾后的必要增量复核

+

T067–T104覆盖调用者、预览原子端口、v2默认/fixture、UI实际消费者、Repository归属、私有材料遗留入口、补包范围与后继合同,共104项,102项L1本地完成。每项最多2个明确文件、依赖可解析、71条AC可追溯;测试准备不等于真实E2E。T063/T064保持未完成,工作区质量门禁缺口未隐藏。计划静态复核:LGTM;不是最终代码或交付结论。


\ No newline at end of file diff --git a/docs/reviews/vibe-permission-actions-discovery-2026-10-30.html b/docs/reviews/vibe-permission-actions-discovery-2026-10-30.html new file mode 100644 index 0000000000..01434d709d --- /dev/null +++ b/docs/reviews/vibe-permission-actions-discovery-2026-10-30.html @@ -0,0 +1,516 @@ +F114 · 需求与实施状态

F114 · 需求与实施状态

规格与设计均已确认;本地102/104任务完成。六动作、具体租户、真实提交主体、预览事务与v2阶段授权已接线。只执行必要定向验证;真实部署E2E、最终L2和工作区质量门禁仍待完成。用户明确要求先本地合入feat/3.0.0,自行部署116后再验收;未推送或部署116/189。下方历史设计复审是当时的静态记录。
+

F114 Vibe 应用操作与 F048 权限动作统一 — 需求发现与映射评审

+
+

状态:2026-10-30 用户已确认 36 项映射并要求进入正式规格;同日追加具体租户单选,并按用户同意的复审建议补充代发布自审、预览用途和取消资格。正式规格见 spec.md,规格与修订设计均已确认;本地实现已接线,真实E2E与最终L2待完成,证据见verification。 +日期:2026-10-30(Asia/Taipei)。基线:origin/feat/3.0.0@cfcefd6577744c7807f30ce77de4e1b4050074ff。 +分支:codex/114-vibe-permission-actions;独立工作树:/Users/zhangguoqing/.codex/worktrees/vibe-permission-actions/bisheng。

+
+

1. 需求变更

+

用户最新要求:“修改下需求,统一下所有的 vibeapp 相关操作 → 映射到现有的权限操作”。这取代上一轮“本轮只统一删除权限”的范围。

+

目标是以已有 app 资源及 F048 具体动作作为同一应用在界面、API、CLI/MCP 的资源授权依据,移除动作校验之后额外叠加的“必须是原创建人”“创建人停用后才能由管理员处理”等资格分支;前端根据同一授权结果显示入口。

+

本轮需求映射已由用户确认,正式 spec 已由用户确认,已完成设计确认并进入实施;本轮替代合同及代码按tasks逐项接线,未完成部分不视为已交付。

+

2. 已核对的现状

+
    +
  • app 已注册六个动作:use、edit、manage_permission、delete、publish、unpublish。read、view_log、export、secret 等不是该资源当前有效动作,本次不另造这些动作,也不扩展其它资源的动作范围。
  • +
  • 已有 F048 viewer/editor/manager/owner 等权限模型;最终是否允许以模型当前生效的具体动作集合为准,不能在业务层硬编码某模型名字等于某权限。
  • +
  • 构建列表和部分详情已经使用 edit;状态服务、数据、日志、密钥、部署和部分运行时管理仍各自有 owner/管理员分支,形成列表、详情、按钮、接口不一致。
  • +
  • 最新已交付删除逻辑是原创建人或超管可删、租户管理员仅清理失效 owner。该逻辑也属于本次待替换对象。
  • +
  • CLI 的 app:manage 是开放通道权限位,当前不向个人令牌签发;发布管线额外按 resource_owner_user_id 比较 app.owner。CLI 日志还会构造归属人的兼容 UserPayload。统一时需要保留真实凭据主体,不能靠替换为 owner 身份获得 app 授权。
  • +
  • 运行时 profile/公共依赖、宿主隔离/K8s 能力报告属于平台运维;SDK 附件和内部 manager 回调另有应用运行期凭据、访问者身份或内部签名。这些并非可以全部替换成一个用户 app:edit 的同类调用。
  • +
+

3. 拟采用的六类动作语义

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
动作Vibe 操作范围不由该动作单独授予的能力
use在应用广场发现并进入允许访问的应用,HTTP/WebSocket 业务访问管理源码、生产表、日志、环境变量或部署实例
edit查看和维护应用管理内容:元信息、源码/版本内容、日志、生产数据、环境变量、应用私有依赖与数据快照发布上线、下线、删除整个应用、修改授权
publish提交发布审批、撤回应用发布、手动上线、恢复上线及启动新的正式运行配置跳过发布审批、代替审批人同意/拒绝、直接改授权
unpublish下线正式应用并停止其对外服务删除代码/数据库/附件等资产
delete显式删除整个应用及其资产删除在线应用、绕过确认或删除其他应用的资产
manage_permission查看和维护该应用的授权、可见范围隐式取得其它管理动作;实际权限仍查 F048
+

有意采用的权限粒度:由于不新增 read/log/export/secret 等动作,edit 同时承载管理内容的读写。被授予 edit 的用户将能查看、编辑和导出应用生产数据、查看日志,以及新增/替换/删除环境变量;不再因为不是原创建人被拒绝。只持 use 的业务用户没有这些管理能力。

+

环境变量仍不提供已保存明文的读取/回显接口,不因权限统一增加密钥明文返回。预检/管理预览/审批预览不注入真实值,保存不自动重建,生效仍需二次确认。

+

4. 全入口映射清单(建议)

+

表中“任一管理动作”是 edit OR publish OR unpublish OR delete OR manage_permission;“且”表示必须同时满足。所有条件都针对同一个 app,不得把应用 A 的动作用于应用 B。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
编号操作/入口拟要求的动作业务状态与数据范围
M01client 广场、统一应用入口、HTTP/WebSocket 访问use;列表按 F048 可见投影仍验证登录、租户、应用态、访问者身份;撤权/停用断开连接
M02构建管理列表、基础详情、实例/运行状态任一管理动作显示获授权的应用;只持 delete/unpublish 的用户也能确认操作对象;只读基础信息不夹带源码/数据/环境值
M03发布进度、版本号、发布说明、审批结果等管理摘要任一管理动作目标应用/版本/发布尝试必须匹配,不暴露未授权的源码正文
M04编辑名称、描述、图标等元信息edit保留并发保护、审计,不变更 app.owner 或 tenant
M05源码快照、源码文件、完整 manifest、版本内容与差异edit版本及路径隔离;审批人审读走 M26 的临时业务授权
M06运行日志、构建/预检的详细诊断edit仍脱敏、保留时间/数量限制;CLI/MCP 同动作
M07数据表清单、结构、行查询、整表/选中数据导出edit只读应用自建表;保留行数、文件、导出数据范围限制
M08编辑生产数据行edit二次确认、审计、主键/字段白名单;不开放任意 SQL 或 DDL
M09环境变量名称、配置状态、配置差异读取edit可管理完整配置项,历史密文不回显明文
M10新增、替换、删除某个环境变量值edit保存加密、审计、CAS;删除配置项不等于 delete 整个应用
M11确认用最新环境配置重建正式实例edit AND publish同版本重建、用户确认、在线版本/配置指纹/租约一致性;保存配置仍不触发重建
M12查看/维护应用私有离线依赖及其可用状态edit不修改公共镜像/profile;保留 digest、架构及缓存隔离
M13读取应用数据库快照列表与内容(现有管理能力)edit下载/恢复票据绑定 app、tenant、快照;不新增不存在的下载入口
M14数据快照恢复edit;若包含正式实例启动还需 publish仍确认破坏性影响、停旧 writer、独占维护、恢复验证;不得借 edit 绕过启动资格
M15CLI 迭代 deploy:代码包/镜像/依赖与发布声明上传后提交发布edit AND publish保留包校验、扫描、预检、冻结快照与必审;实际执行主体查 F048
M16首次创建应用并首次提交发布现有创建资格 + 创建后按本次所需动作初始化/校验 F048无目标 app 时不伪造一个可授权对象;保留单命令首发体验。初始授权须可追溯、限定该 app,不授全租户权限;见 §6
M17更改资源档位、运行 profile、启动配置、能力声明后重新发布edit AND publish改声明须经现有发布管线,不直接编辑冻结版本或 manager 状态
M18上线/手动上线/重新上线已有已批准版本publish审批和容量准入、探活、路由切换继续有效;未批准不能上线
M19下线正式应用unpublish二次确认、停止执行体、保留资产、审计
M20应用发布的主动撤回publish允许有 publish 的非原创建人操作;限本 app 的可撤回在途发布,审计实际操作者;不能更改审批终态
M21取消运行时管理操作当前取消者具备原业务操作所需全部动作已批准版本上线取消需 publish;环境变量配置确认重建/新版本发布取消需 edit AND publish;恢复不启动需 edit,包含启动还需 publish。仍受原可取消阶段和安全清理约束
M22旧运行绑定导入、升级与重绑(退役)不提供;持有 edit/publish 也不能执行AC-57、AC-71;同版本环境变量确认重建复用 M11,代码/镜像/运行环境变更复用 M15、M17;本次不新增独立通用重建
M23删除整个应用delete创建人、管理员、普通授权用户一律同动作;保留先下线、二次确认、资产清理、审批取消及审计
M24查看授权清单/可见范围的管理信息manage_permission基础发布摘要可仅返回是否配置的无主体概览;完整主体列表与授权详情受动作控制
M25增删授权、修改授权模型、设置可见范围manage_permission使用既有 F048 Grant/投影流程;不改变服务账号主体侧授权入口
M26审批任务的详情、冻结源码审读、临时预览审批引擎任务资格签发的临时业务授权绑定实例/版本/任务/期限;审批预览注入审批人本人,平台能力限该版本声明及应用归属人权限;不转换为通用管理授权
M27有编辑权限的管理者查看提交版本的管理预览edit注入实际预览者本人,平台能力限版本声明与本人权限;不能继承审批例外;与审批预览隔离,均不注入真实密钥
M28审批通过/驳回、异常审批重试/指定审批人/取消既有审批任务或审批管理权限不映射成 app.publish 后绕过审批引擎;应用相关的业务提交/主动撤回分别按 M15/M20
M29应用维度的模型/能力/访问运行记录与受权导出edit仍执行 app/tenant 过滤、时间范围和导出限制;不附带模型密钥或请求正文
M30平台审计页跨应用查询既有审计入口资格 + 可管理应用/租户范围有 app:edit 不自动获得整个平台审计权限;单应用记录读按 M29
M31CLI/MCP 的应用列表、详情、状态、数据、日志及发布与上述同语义操作相同的动作开放通道 scope/模式检查仍是额外入口条件;不再按资源归属人只列“我的应用”
M32应用 SDK 的模型/检索/附件调用已授权的 use 会话/运行期身份 + 能力声明及底层资源授权不能按调用方自带 appId 或伪造用户身份放行;SDK 业务附件的删除不是删除整个应用
M33应用管理操作导致的 SQLite 快照/迁移、manager 下载源码/上传快照等继承所属操作经校验后签发的内部授权app、tenant、version、operation、用途绑定;HMAC/票据/幂等/租约继续核验,不能暴露成匿名接口
M34profile/基础镜像/公共依赖导入、默认 profile、宿主隔离和 K8s 能力管理既有平台运维/超管权限这些资源不属于某一个 app;不得因有一个 app.edit 就获得全局编排权限
M35发布审批 outbox、Celery 续办及在途操作重试经过验证并冻结的原始操作授权 + 阶段性重校验记录实际主体及凭据引用;扩权/启动/提交新路由前验证权限仍有效;失败进入已有可解释终态/等待态,旧资源清理不因此被卡住
M36既有正式实例自愈、清理旧执行体与停止确认既有运行期意图和内部执行资格管理者个人撤权不等于停用应用;自愈不得凭空获得新能力或改版本,不用伪造 owner 会话
+

5. 对现有边界的修订与保留

+

5.1 将明确替换的 owner 规则

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
已有合同/落点本次建议
F054 AC-44、AC-53:删除资格;最新超管例外全部归到 app.delete,不再判断创建人账号有效性
F054 AC-55、AC-56:日志和数据的 owner/管理员前置限制统一 app.edit,所有通道同口径
F055 AC-04:CLI 迭代发布/日志按 resource_owner_user_id 限定目标通道资格与 F048 动作同时校验,使用真实服务账号/PAT 主体
F055 的撤回、手动上线、发布面动作 owner 开关根据 publish 等具体动作和发布状态决定
F055 AC-66 等环境变量保存/读取/重建 owner 限制维护为 edit,确认重建为 edit AND publish;不回显明文
F056 运行记录查询、导出及前端 owner/管理员分支应用维度按 edit,平台审计入口保持既有菜单和租户范围
F086/F059 运行时管理的 natural_owner_required按 M12–M22 的实际操作映射,两个执行后端相同
release-contract INV-35 的“除 owner 显式删除流程外”措辞应修订为“除具有 app.delete 资格的显式删除流程外”;资产保护、在线不可删除不变
+

以上是拟修订项索引,不是宣称原合同已经变更。正式 spec 经确认后,设计阶段做完整引用扫描并同步现行合同;不把早期版本线已归档文档当作本次交付目标。

+

5.2 继续保留的条件

+
    +
  1. 租户归属、超管管理视角与 F066 数据范围约束继续执行;用户迁租户后不迁移存量应用和权限。
  2. +
  3. owner_user_id 仍是资源归属事实和审批路由/通知/资产归档的依据,不是额外动作通行证;本期不实现移交、改 owner 或改 tenant。
  4. +
  5. F048 权限模型和动作启停仍是真相;不写业务层角色名到动作的静态映射,不扩大其它资源的 Catalog。
  6. +
  7. 发布必审、包安全校验、密钥扫描、声明白名单、预览隔离、容量准入、探活、路由切换、数据维护围栏及删除范围不变。
  8. +
  9. 仅持 use 的用户仍不能查看管理数据;应用自己的个人会话/业务数据行隔离规则不因平台权限统一而被删除。
  10. +
  11. env 保持加密保存、无明文回显;保存不重启,生效需确认;日志/记录继续脱敏。
  12. +
  13. 审计记录真实自然人/服务账号、凭据引用、目标 tenant/app/version、动作及结果,资源 owner 与实际操作者分开。
  14. +
  15. 内部调用仍需 HMAC/受限凭据和用途绑定,manager 不直接相信前端权限参数;平台运维权限不降为单应用 edit。
  16. +
+

6. 需要确认的产品口径

+

用户已确认本矩阵并进入正式规格。以下三项实质性的行为变化由正式 spec 继续明确,具体租户单选增量见 §9:

+
    +
  • edit 的信息范围:覆盖生产数据查看/修改/导出、日志及环境变量维护,不再单独保留 owner 限制。系统没有 app.read/app.export/app.secret 动作,若要继续分开这些能力,就需要另一项 Catalog 扩展需求,不能用看似统一的额外 owner 判断代替。
  • +
  • 发布代行:具备 publish 的管理者可以为他人的应用提交、撤回应用发布及手动上线;改包并提交同时要求 edit。审批引擎的通过/驳回仍不随 app 权限开放。资源 owner、审批申请人与路由依据保持现状,实际提交/撤回者另记;自审同时排除资源归属人与实际提交关联自然人,仅保留原单管理员例外,无合格候选进入异常处理。
  • +
  • CLI/MCP 与首次创建:按已准入通道的真实主体检查 F048:服务账号查自身授权;支持自然人/PAT 的入口查其真实自然人授权。保留 app:manage 等通道条件,当前不支持 PAT 的 CLI 发布入口不会因本次映射而开放;不能继承资源归属人的全部权限。已有 app 需明确授权;首次创建保留单命令体验,通过受控创建生命周期登记本次所需的 app 初始化授权及审计。初始化授权的具体模型/Grant 与原服务账号主体侧约束如何衔接,列为设计必须解决的合同,不以“创建成功”掩盖后续无法查询/发布的问题。
  • +
+

上述初始化只限新建 app,不给历史资源批量补授权,不改历史 owner/tenant,不通过管理员身份代签调用者。具体实现待设计评审;如果无法在已有授权能力内满足,需要回到规格评审说明差异,不静默添加权限动作或放宽 scope。

+

7. 代码证据索引

+

以下链接固定到本轮静态核查基线;本轮未执行生产环境删除、发布或数据查询。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
文件已核对事实
catalog_policy.pyapp 已有六个动作,share/download 等不在 app 范围
test_app_permission_registration.pyapp 类型及六动作注册断言
app_state_service.py删除、上下线等独立业务资格分支
app_query_service.py平台详情 edit、CLI/MCP 额外 owner 入口及日志判断
app_data_service.py生产数据 owner 校验;运行时数据会话/票据复用该判断
app_secret_service.py变量维护 owner 校验、只读响应按 owner 裁剪
secret_rebuild_service.py重建 owner 判断和确认指纹/幂等围栏
publish_pipeline_service.py发布主体重校验、自然人归属校验、迭代仅原归属人
deploy.pyCLI logs 构造资源归属人兼容身份
publish_status_service.pywithdraw/preview/manual_publish 按 owner 设置
runtime_management_service.py私有物料、运行状态、快照恢复等 natural_owner_required
runtime_management.py应用级管理与平台公共 profile/物料入口
HostedAppCard.tsxF048 canDelete 后又叠加创建人/管理员条件
DangerZoneCard.tsx详情删除区只按 owner/super 显示
approval-module任务决策、业务取消、必审及 outbox 的既有边界
+

8. 验证与交付边界

+
    +
  • 本阶段只做静态入口盘点、既有合同对照和文档一致性检查。没有运行测试、部署 116/189、改授权、改服务账号或迁移存量数据。
  • +
  • 后续只做本功能必要定向验证:六动作的允许/拒绝与组合、只持 use 的拒绝、所有者被撤权不绕过、同租户与跨租户、超管选定视角、SA/PAT 主体、不复用 owner 身份、前端与 API 一致、异步撤权/重试、确认取消不发请求和审计实际操作者。
  • +
  • 按相同产品合同验证 Docker/K8s,权限集中在 backend,不重跑所有编排/故障用例;真实 E2E 使用专用临时对象,范围在设计明确。DM8 按既定要求仅静态检查。
  • +
  • 不新增公开推送、tag、镜像发行或环境更新。当前已完成需求分支本地实现,未合入 feat/3.0.0。
  • +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SDD 阶段状态
需求目标用户已要求扩大为所有 Vibe 相关操作
Discovery 操作映射用户已确认;具体租户单选作为已确认增量目标并入正式规格
正式 spec / spec-review原 70 条已确认;用户明确 v2 及 R3 收窄决定;共 71 条,M22 标为退役,规格原文未变;设计 R4/R5/R6 已修订,24 项静态复审 LGTM、独立复审 PROCEED,整份设计已单独确认,实施证据见verification.md
design✅ 用户已单独确认;R1–R6关闭,静态LGTM/独立PROCEED
tasks / 编码 / E2E / L2本地102/104;真实E2E、最终L2/门禁仍待完成
+

9. 2026-10-30 具体租户视角增量

+

用户明确要求去掉“整个平台”,必须限定某一个租户;同日确认本映射并进入正式规格。此前出现的全平台管理视角不再作为目标行为。Root 仅代表 Root 本身;无选择、过期或无效选择回到所属租户,不能回到全平台。完整验收以 spec.md AC-09 至 AC-18 为准。

+

10. 2026-10-30 复审建议修订

+

用户在查看三项评审问题与推荐口径后回复“按照建议修改”。以下三项口径已写入正式规格,随后整体确认及归属澄清见 §11:

+
    +
  1. 代发布时,应用归属人仍是审批申请人及路由依据;自审同时排除应用归属人与实际提交关联自然人。服务账号以提交时固定的账号归属人参与排除,审计仍保留真实服务账号主体;仅保留原全租户单管理员自审例外,无合格审批人则进入异常处理。见 AC-39、AC-67、AC-68。
  2. +
  3. 管理预览按实际预览者身份及本人底层权限执行;有效审批任务预览保留原归属人能力例外。同一人双重资格时按本次用途分别校验,不能混用;所有预览不注入真实密钥。见 AC-38、AC-50、AC-51、AC-69。
  4. +
  5. 取消由当前取消者满足原业务用途的全部动作,可与发起者不同;环境变量配置确认重建/新版本发布取消需 edit AND publish,恢复按是否包含启动区分。已生效或不可取消阶段仍拒绝,必要失败清理继续。见 AC-56、AC-70 及 spec §3.1。
  6. +
+

正式规格新增 AC-67 至 AC-70,原 AC-ID 不重排;该轮共 70 条并已获确认(后续收敛见 §12);设计待单独确认,任务与业务编码尚未开始。

+

11. 规格确认与归属澄清

+

用户已明确“确认规格,进入设计”,随后重申“创建资源还是属于归属人,这个逻辑不变”。应用和服务账号后续创建资源的自然人归属与租户规则保持;真实服务账号作为鉴权和审计主体,按现有 F048 创建生命周期取得可撤销初始授权,不改成资源 owner。70 条 AC 正文未因这一澄清修改。当前设计见 design.md,仍需单独确认后拆任务和编码。后续协议收敛决定见 §12;本段的 70 条为当时确认记录。

+

12. 唯一 v2 运行控制协议与共用接口边界

+

用户明确共用读取/调用按原最小权限维持,并在说明“功能尚未正式对外发布”后要求“收敛统一一下”。本稿据此统一:Vibe 只保留运行控制协议 v2;不再补 v1 阶段授权、不保留长期双协议或旧绑定导入。源码构建公共函数、SQLite/附件/日志/SDK 及现有平台 API/应用包格式保留;测试资产不自动删除或迁租户。

+

该轮新增 AC-71,原其它 69 条 AC 正文保持。后续实质复审发现 R3:旧 rebind 对正常在线同版本返回既有实例,不能当作现有通用 v2 重建;用户已采纳进一步收窄建议,现行条款以 AC-57 及下节为准。原 R1/R2 关闭结论保持,设计仍须单独确认。

+

13. 旧绑定重绑与现有发布链路收敛

+

用户对 R3 建议回复“按照建议修改”。旧绑定导入、升级和 rebind 一并退役,M22 保留盘点编号但不再对应可授权执行动作。同版本环境变量确认重建仍按 M11/AC-26;代码、镜像、运行环境或启动声明变更仍按 M15、M17/AC-35、AC-36 走新版本发布,两类操作均要求 edit AND publish。取消按这两种真实业务用途判定,本次不新增 runtime_upgrade 或独立通用实例重建。创建资源归属人、租户及受保护自然人 owner 授权保持原规则。

+

本轮只修订文档;设计仍需用户单独确认,未创建 tasks、修改业务代码、运行测试、提交、推送或更新 116/189。

+

14. R4/R5/R6 设计修订范围

+

用户再次“按照建议修改”:补系统自愈授权来源、维护首次副作用前的阶段准入、预览会话/凭据/operation 的原子登记及失败回收;只细化 How,71 条 spec AC 原文保持。无需新增权限动作、引擎、移交迁移或恢复框架。该轮设计24项静态复审LGTM、同一独立代理复审PROCEED,三项原发现均关闭;随后用户确认与开发进度见§15。独立原文见 architecture-critique-r4-r6.md,原 REVISE 快照保留。

+

15. 用户设计确认与开发

+

2026-10-30用户确认整份设计,进入任务拆解和开发;71条AC不变。当前102/104项本地任务完成;逐阶段定向结果及未通过的工作区门禁见verification,真实E2E与最终L2未完成。详见tasks.md与verification.md。


\ No newline at end of file diff --git a/docs/reviews/vibe-permission-actions-spec-review-2026-10-30.html b/docs/reviews/vibe-permission-actions-spec-review-2026-10-30.html new file mode 100644 index 0000000000..2b6a906bac --- /dev/null +++ b/docs/reviews/vibe-permission-actions-spec-review-2026-10-30.html @@ -0,0 +1,657 @@ +F114 · 已确认规格与开发状态

F114 · 已确认规格与开发状态

规格与设计均已确认;本地102/104任务完成。六动作、具体租户、真实提交主体、预览事务与v2阶段授权已接线。只执行必要定向验证;真实部署E2E、最终L2和工作区质量门禁仍待完成。用户明确要求先本地合入feat/3.0.0,自行部署116后再验收;未推送或部署116/189。下方历史设计复审是当时的静态记录。
+

Feature: Vibe 全操作权限动作统一与具体租户管理视角

+
+

规格已确认,修订设计已于2026-10-30单独确认;当前实施中,不是全部已交付。 原 70 条 AC 已由用户确认,确认时全文 SHA-256:f9feba32cc67bd812908f5a3d369ec942ede8955e15ca6206101c5a62305c45b。随后用户明确共用接口维持原权限,并在了解 v1/v2 后要求“收敛统一一下”。本次据此新增 AC-71;用户在收敛稿评审后明确“按照建议修改”,进一步将 AC-57 收窄为旧绑定导入/升级/重绑退役,已有配置确认重建和新版本发布分别复用原条款。本轮只修改 AC-57,其余 70 条 AC 正文不变;当前共 71 条。该修订设计现已单独确认,任务和编码进行中,完整交付仍待后续验收。

+
+

Feature:F114 · 版本:v3.0.0 · 发版线:feat/3.0.0 · 优先级:P0(资源授权一致性与租户隔离)。

+

关联需求:已确认需求映射 §3–§6及本次具体租户视角要求;应用工场 PRD-1的角色与动作、生命周期、管理数据和开放通道场景为既有背景。本文按用户新增要求替换其中创建人特判及全平台管理视角,不沿用已被明确改变的旧限制。

+

依赖与归属:F048 拥有资源授权;F019 拥有管理视角;F054 拥有应用状态与数据/日志;F055 拥有发布与环境变量;F056 拥有广场及记录查询;F049/F052/F053 的既有开放通道、F086/F059 的现有运行能力及 F113 的租户资格继续适用。本特性统一这些既有能力的操作资格,不重建其领域对象。

+

纳入:全部现有 Vibe 用户操作映射到六个现有动作,移除原创建人/创建人失效的额外限制;界面、API、CLI/MCP 及异步续办的一致性;数据、日志、密钥配置、预览、运行管理的资格;具体租户单选及相应管理数据范围;真实操作者审计;唯一运行控制协议 v2 与旧 v1 执行入口退役。

+

排除:新增权限动作/模型体系、移交/迁租户/存量授权补齐、PAT 新通道或凭据轮换、密钥明文读取、新的界面创建/提交入口、回滚/版本切换、未支持的数据 CRUD/DDL、通用审批系统重写、平台运维降权、独立通用实例重建功能、重新实现 Docker/K8s 编排及全量回归。

+

1. 用户故事

+
    +
  • 作为应用管理者,我希望只按已授予的具体动作管理他人的应用,以免列表可见却被创建人特判阻止。
  • +
  • 作为应用使用者,我希望 use 只用于业务访问,不使应用内部管理数据暴露。
  • +
  • 作为集成开发者,我希望 CLI/MCP 按真实主体和应用授权执行,而不借用资源归属人身份。
  • +
  • 作为租户管理员,我希望始终只看到本租户管理数据;作为超管,我希望明确选择一个租户并在该范围操作,避免误操作其它租户。
  • +
  • 作为审批人和运维人员,我希望资源权限统一后仍保留审批资格、发布安全条件、运行隔离及可追溯记录。
  • +
+

2. 验收标准

+
+

所有动作均针对同一目标 app;组合动作使用 AND。基础管理信息的“任一管理动作”不含 use。权限模型以其当前生效动作集合为准。

+
+

2.1 统一授权与管理可见性

+
    +
  • AC-01 — THE SYSTEM SHALL 对 Vibe 应用复用 use、edit、manage_permission、delete、publish、unpublish 六个现有 F048 动作,不新增 app.read、app.log、app.export、app.secret 等动作,也不改变其它资源的有效动作集合。
  • +
  • AC-02 — WHEN 任意主体发起应用操作,THE SYSTEM SHALL 根据该操作要求的具体动作决定资格;原创建人、平台超管、租户管理员及普通授权用户不得由应用业务层另设角色或创建人放行/拒绝分支覆盖 F048 的判定。F048 自身的身份短路、数据范围及模式规则保持有效。
  • +
  • AC-03 — WHEN 用户的授权仅适用于某个应用,THE SYSTEM SHALL 只允许在该应用上使用对应动作,不因模型名称为所有者、管理者或编辑者而推定其它应用或未包含动作的权限。
  • +
  • AC-04 — WHEN 一项操作要求多个动作,THE SYSTEM SHALL 要求全部满足;任一动作缺失或不可判定时拒绝整项操作,且不执行其写入、部署或数据导出。
  • +
  • AC-05 — WHEN 非原创建人通过有效授权操作应用,THE SYSTEM SHALL 保持应用原归属人与所属租户不变,并将实际操作者与资源归属人分别记录;本期不提供移交或迁租户。
  • +
  • AC-06 — WHERE 用户仅持 use,THE SYSTEM SHALL 允许其按应用状态使用应用,不授予构建管理页、源码、环境变量、运行日志、平台生产数据或管理审计记录的读取/修改能力。
  • +
  • AC-07 — WHEN 用户在当前租户视角内具有某应用的任一管理动作(edit、publish、unpublish、delete、manage_permission),THE SYSTEM SHALL 在构建管理列表和基础详情中呈现该应用及确认操作对象所需的名称、状态、实例状态、版本号、发布说明和发布/审批结果;仅有某个管理动作不能使响应夹带需要其它动作的内容。
  • +
  • AC-08 — IF 应用权限评估失败、不可判定、目标不存在或已删除,THEN THE SYSTEM SHALL 拒绝相应操作,不返回未过滤的数据、不变更资源或启动新的执行体,并遵守原资源存在性保密规则。
  • +
+

2.2 必须绑定具体租户的管理视角

+
    +
  • AC-09 — THE SYSTEM SHALL 从顶部租户选择中移除“整个平台/全部租户”选项,管理视角必须限定一个具体租户;Root 也是一个具体租户,选择 Root 不得显示其它租户的业务资源。
  • +
  • AC-10 — WHEN 非平台超管访问管理模块,THE SYSTEM SHALL 将其管理视角限定为当前所属租户;伪造租户参数、使用历史选择或缓存状态不得改变该范围。
  • +
  • AC-11 — WHEN 平台超管切换管理视角,THE SYSTEM SHALL 仅接受存在且可用的具体租户,显示实际生效的租户,并记录从原视角到新视角的切换。
  • +
  • AC-12 — WHEN 平台超管首次访问且没有有效选择,THE SYSTEM SHALL 将有效管理视角设为该登录人当前所属租户;不得把“没有选择”解释为全平台。
  • +
  • AC-13 — WHEN 已选租户不再可用、选择已过期或选择内容无效,THE SYSTEM SHALL 回到登录人当前所属租户并显示实际生效范围;IF 无法确定一个合法的默认租户,THEN THE SYSTEM SHALL 拒绝加载管理数据,不选择任意租户或扩大为全平台。
  • +
  • AC-14 — WHEN 调用方请求空租户、全平台或其它不能唯一确定具体租户的切换,THE SYSTEM SHALL 拒绝切换并保留原有效范围;切换校验失败不得清除原范围。
  • +
  • AC-15 — WHEN 加载知识库、模型、工作流、助手、托管应用、审批管理、审计、用户组及租户自定义角色等管理资源,THE SYSTEM SHALL 根据同一个有效视角过滤列表、基础详情和可操作目标;选择租户 A 时不能列出或操作租户 B 的业务资源。现有公共预置目录的合法共享可读性保留,不把其解释为跨租户业务资源访问。
  • +
  • AC-16 — WHEN 管理视角切换或失效回退,THE SYSTEM SHALL 清除前一范围的页面数据与可提交表单状态;旧页面或旧表单不得在新范围下操作原租户的资源,多个页面不得展示与请求实际生效范围不一致的数据。
  • +
  • AC-17 — WHEN 管理视角改变,THE SYSTEM SHALL 保持用户真实所属租户、登录身份、服务账号身份、凭据 scope 与已有授权不变;管理视角不是迁租户或取得权限的途径。
  • +
  • AC-18 — WHERE 后台任务、开放 API、应用 SDK 或内部运行时请求未使用浏览器管理视角,THE SYSTEM SHALL 按其已经验证的主体及目标租户执行,不继承超管的页面选择,也不得因没有页面选择而扩大范围。
  • +
+

2.3 应用管理内容统一映射到 edit

+
    +
  • AC-19 — WHEN 用户修改名称、描述、图标等元信息,THE SYSTEM SHALL 要求 edit 并记录操作;不得仅因与原创建人不同而拒绝。
  • +
  • AC-20 — WHEN 用户读取源码快照、源码文件、完整声明、版本内容或内容差异,THE SYSTEM SHALL 要求 edit;只有发布/下线/删除等其它动作时仅能获得 AC-07 的基础信息。审批任务的临时审读按 AC-50。
  • +
  • AC-21 — WHEN 用户查看应用运行输出、错误栈、构建或预检详细诊断,THE SYSTEM SHALL 要求 edit,并保留脱敏、筛选、截断及留存限制;界面、CLI 与 MCP 不得另按原创建人身份限制。
  • +
  • AC-22 — WHEN 用户读取生产数据的表清单、结构、行数据或导出数据,THE SYSTEM SHALL 要求 edit,并保留表范围、分页、行数及导出数据范围限制;其它动作不包含该管理数据读取资格。
  • +
  • AC-23 — WHEN 用户编辑现有生产数据行,THE SYSTEM SHALL 要求 edit、二次确认并记录审计;不开放任意 SQL、表结构修改或本期原本不支持的行级操作。
  • +
  • AC-24 — WHEN 用户查看环境变量的名称、配置状态、引用或运行配置差异,THE SYSTEM SHALL 要求 edit;获授权管理者可查看完整配置项的管理信息,但已保存密钥明文仍不允许读取或回显。
  • +
  • AC-25 — WHEN 用户新增、替换或删除环境变量值,THE SYSTEM SHALL 要求 edit,保留加密保存、并发冲突提示和审计,不自动重启、停止或重建实例;删除某个值不要求删除整个应用的 delete 动作。
  • +
  • AC-26 — WHEN 用户确认使用最新环境变量配置重建正式实例,THE SYSTEM SHALL 要求 edit 与 publish,保留同版本、当前运行配置和确认对象一致性检查;取消确认不创建重建操作。
  • +
  • AC-27 — WHEN 用户管理应用私有离线依赖、启停该依赖的可用状态,或读取已有应用数据库快照内容/列表,THE SYSTEM SHALL 要求 edit 并限定该应用的资产,不因创建人账号状态改变结果,也不授予公共依赖或全局运行环境的维护权限。
  • +
  • AC-28 — WHEN 用户恢复应用数据快照,THE SYSTEM SHALL 要求 edit 并确认数据覆盖影响;IF 恢复操作包含正式实例启动,THEN THE SYSTEM SHALL 同时要求 publish;保留旧写入者停止确认、独占维护及恢复验证。
  • +
+

2.4 生命周期、授权与记录查询

+
    +
  • AC-29 — WHEN 用户上线、手动上线或重新上线已有已批准版本,THE SYSTEM SHALL 要求 publish,继续满足审批结论、应用状态、容量准入与运行健康条件,不另要求是原创建人。
  • +
  • AC-30 — WHEN 用户下线正式应用,THE SYSTEM SHALL 要求 unpublish、二次确认并记录审计,停止执行体而保留代码、数据库及附件资产。
  • +
  • AC-31 — WHEN 用户显式删除整个应用,THE SYSTEM SHALL 要求 delete;普通获授权用户、租户管理员、原创建人和平台超管均以 F048 结果为准,不再判断原创建人是否有效或已停用。
  • +
  • AC-32 — WHEN 用户删除应用,THE SYSTEM SHALL 继续拒绝在线态直接删除并提示先下线;非在线可删除状态须二次确认,明确代码、对话历史及生产数据的删除范围,取消不得执行删除;确认后保留资产清理、授权清理、在途审批取消和实际操作者审计。
  • +
  • AC-33 — WHEN 用户读取授权主体详情、增删授权、选择权限模型或设置可见范围,THE SYSTEM SHALL 要求 manage_permission,沿用既有授权入口;基础摘要可显示是否已设置范围,但不能向缺少该动作的用户返回完整授权主体清单。
  • +
  • AC-34 — WHEN 用户查看或导出某个应用的模型/能力调用、访问等运行记录,THE SYSTEM SHALL 要求该应用的 edit 并限制数据范围;跨应用审计页仍要求其既有审计入口资格且受当前具体租户视角限制。
  • +
+

2.5 发布管线与发布代行

+
    +
  • AC-35 — WHEN 用户上传新的应用内容并提交首发后的迭代发布,THE SYSTEM SHALL 要求 edit 与 publish,不再以调用者是否等于原创建人为资格条件。
  • +
  • AC-36 — WHEN 用户更改资源档位、运行环境、启动配置或平台能力声明并使其通过新版本发布生效,THE SYSTEM SHALL 要求 edit 与 publish,继续扫描、预检、冻结待审内容,不直接修改历史版本或正式实例。
  • +
  • AC-37 — WHEN 有 publish 的用户主动撤回某应用的发布,THE SYSTEM SHALL 允许其代行并记录实际撤回者;仅限该应用可撤回的在途发布,不更改其它业务审批或已进入不可撤回终态的记录。
  • +
  • AC-38 — WHEN 有 edit 的管理者查看当前在审提交的应用管理预览,THE SYSTEM SHALL 允许其使用受控预览能力,不要求原创建人;注入实际预览者本人身份,平台能力同时受该版本声明和预览者本人权限限制,不借用应用归属人权限;预览仍绑定该提交内容、隔离执行且受原时限和生命周期约束。
  • +
  • AC-39 — WHEN 非原创建人提交、撤回或重试发布,THE SYSTEM SHALL 保留资源归属事实,继续以应用归属人为审批申请人并保留既有审批路由依据;分别记录实际自然人或服务账号操作者,通知与审批展示区分审批申请人、实际提交主体和应用归属人;发布自审排除按 AC-67、AC-68 执行,不把服务账号调用记成已验证的归属人自然人操作。
  • +
  • AC-40 — WHEN 在途发布或恢复操作继续执行到新的扩权、正式启动或新路由生效阶段,THE SYSTEM SHALL 再验证该操作所需授权及凭据仍有效;失效时拒绝新增效果并保留可解释状态,不影响仍正常提供服务的旧版本,也不阻止必要的失败清理。
  • +
  • AC-41 — WHERE 首发、迭代或配置声明按原产品合同需要发布审批,THE SYSTEM SHALL 继续在审批通过后才能上线;具备 publish、管理员身份或代行资格都不能跳过审批。
  • +
  • AC-42 — WHEN 用户查看历史版本、发布说明及提交记录,THE SYSTEM SHALL 保留只读冻结内容和版本切换限制;本次不新增界面造应用、未实现的“提交发布”按钮、任意回滚或版本切换能力。
  • +
+

2.6 开放通道、首次创建与存量兼容

+
    +
  • AC-43 — WHEN CLI/MCP/开放 API 调用已有的应用操作,THE SYSTEM SHALL 同时检查该通道原有的 scope、身份模式及对应 F048 动作;服务账号以自身授权主体判定,已支持的自然人凭据以其真实身份判定,不模拟资源归属人。
  • +
  • AC-44 — WHEN 经既有创建资格首次创建应用并提交发布,THE SYSTEM SHALL 保留单命令首发体验,在新应用上为真实创建主体登记本次所需且仅限该应用的初始管理授权,使其能查看提交进度并在具备 edit 与 publish 后完成提交;初始授权应可查询、可审计,不授予全租户权限,也不覆盖归属人的既有受保护所有者授权。权限初始化失败不得报告发布成功或遗留无法按既有治理方式管理的应用。
  • +
  • AC-45 — WHEN 调用者在 F048 上获得其它人应用的相应动作,THE SYSTEM SHALL 允许通过已支持的 CLI/MCP 操作该应用,不要求服务账号资源归属人与应用原归属人为同一人;缺少动作则拒绝。
  • +
  • AC-46 — WHEN 开放通道查询应用列表、基础详情、发布状态、日志或数据,THE SYSTEM SHALL 按各内容相应动作过滤或拒绝,不仅返回“资源归属人的应用”,也不因为持有粗粒度 scope 而返回全部应用。
  • +
  • AC-47 — WHERE 服务账号归属人资格、账号/应用租户一致性、凭据有效性、身份模式或 scope 不符合既有要求,THE SYSTEM SHALL 继续拒绝;本次不新增 PAT 的 app:manage 签发或未支持通道,不放宽 delegate 互斥或凭据失效规则。
  • +
  • AC-48 — WHEN 存量凭据只有通道 scope 而没有目标应用所需的 F048 授权,THE SYSTEM SHALL 拒绝并提示需要对应应用动作;不得为了兼容继续按 owner 身份放行。历史资源不迁租户、不移交、不批量补授权,服务账号补授权沿用既有主体侧入口。
  • +
+

2.7 审批资格与可信内部执行

+
    +
  • AC-49 — WHEN 用户同意/驳回审批任务或处理异常审批,THE SYSTEM SHALL 继续要求既有审批任务或审批管理资格;app.publish 等资源动作不替代审批人或异常管理资格,也不使其取得其它场景的审批权。
  • +
  • AC-50 — WHEN 具有该次有效审批任务资格的审批人审读冻结内容或访问审批预览,THE SYSTEM SHALL 保留限于该审批任务/应用/版本/期限的业务授权,不要求原创建人或长期 app 管理授权;审批预览注入审批人本人身份,平台能力继续按该版本声明与应用归属人权限限定并计审计,不把临时审批资格转换成通用管理权限。
  • +
  • AC-51 — WHERE 执行预检、管理预览或审批预览,THE SYSTEM SHALL 继续不注入真实环境变量密钥;管理预览按 AC-38 的本人身份与权限执行,审批预览仅按 AC-50 的任务资格使用既有审批例外,不因权限统一扩大模型/知识库或其它底层资源访问。
  • +
  • AC-52 — WHEN 审批管理展示应用异常记录、进度或执行允许的异常处理,THE SYSTEM SHALL 限定当前具体租户,保留个人审批与管理审批不同的查看/处理资格,不因删除全平台选项而使同租户合格管理员无法查看进度。
  • +
  • AC-53 — WHEN 后台或运行层为已授权操作获取源码、快照、数据或执行生命周期步骤,THE SYSTEM SHALL 验证请求确实属于该应用、版本、操作及用途;前端声称有某动作、仅知道应用标识或持有其它操作的内部凭据均不能代替授权。
  • +
  • AC-54 — WHEN 应用被删除或在途发布取消,THE SYSTEM SHALL 继续按原生命周期处理审批与运行资源,保留幂等、终态保护及失败审计;权限统一不得使重复处理恢复已经失效的版本或授权。
  • +
+

2.8 运行管理与产品一致性

+
    +
  • AC-55 — WHEN 用户读取运行操作的基础状态,THE SYSTEM SHALL 按任一管理动作提供确认对象和进度所需的信息;详细日志、私有依赖及快照内容仍按 edit 控制。
  • +
  • AC-56 — WHEN 用户取消运行管理操作,THE SYSTEM SHALL 按该操作的原业务用途,对当前取消者检查原业务操作要求的全部动作,具体集合见 §3.1;取消者可以不同于发起者,不以原发起者仍有权限代替当前判定,也不因操作统称为“部署”而省略组合动作;不得使单个 edit 或 publish 动作能够取消所有种类的操作。
  • +
  • AC-57 — WHEN 用户请求已退役的旧运行绑定导入、升级或重绑操作,THE SYSTEM SHALL 拒绝请求,且不创建执行体、不变更路由或存储资产;同版本环境变量确认重建继续按 AC-26 执行,代码、镜像或运行环境变更通过 AC-35、AC-36 的正常新版本发布生效,本次不新增独立通用实例重建功能。
  • +
  • AC-58 — WHEN 用户维护公共运行环境、公共依赖、默认运行环境、宿主隔离或集群能力,THE SYSTEM SHALL 保留现有平台运维资格,不把拥有某一个应用的 edit 或其它动作视为全局编排和平台配置权限。
  • +
  • AC-59 — WHEN 应用 SDK 调用平台能力或附件服务,THE SYSTEM SHALL 继续使用可信运行身份、访问者身份、能力声明和底层资源授权限定请求;SDK 业务附件操作不被错误映射成删除整个应用,客户端伪造身份或应用标识必须被拒绝。
  • +
  • AC-60 — WHEN 正常运行应用发生既有实例自愈或旧执行体清理,THE SYSTEM SHALL 按已验证的运行意图执行,不模拟原创建人,不因某个历史操作者个人撤权自动停用应用,也不借自愈修改已授权版本或增加能力;Docker 与 K8s 的产品权限语义应一致。
  • +
+

2.9 前端一致性、拒绝与审计

+
    +
  • AC-61 — WHEN 界面呈现应用卡片、详情、数据、日志、配置和操作按钮,THE SYSTEM SHALL 根据同一动作映射与有效状态显示、隐藏或禁用;不得在动作允许之后另叠加原创建人或创建人停用条件,也不得只在前端检查而使直接调用绕过。
  • +
  • AC-62 — WHEN 权限、模型有效动作或凭据资格发生变化,THE SYSTEM SHALL 在后续请求按最新生效结果决定;旧页面缓存、已打开表单或按钮状态不能维持已失去的资格。
  • +
  • AC-63 — WHEN 操作被拒绝,THE SYSTEM SHALL 区分缺少具体动作、应用状态不允许、审批/运行前置条件未满足等原因;不得继续把已获授权管理者的失败笼统描述为“仅创建人可操作”。
  • +
  • AC-64 — WHEN 应用管理动作或租户切换成功、失败、取消或由后台继续执行,THE SYSTEM SHALL 按既有留痕合同记录真实操作者、目标租户/应用、相关版本/操作、结果与成因,区分资源归属、提交者和审批者,不记录密钥明文或凭据原文。
  • +
  • AC-65 — WHERE 存在原有二次确认、密钥保护、脱敏、数据和附件隔离、资源配额、审批及运行围栏,THE SYSTEM SHALL 保持其作用;权限统一不删除这些非创建人资格类条件。
  • +
  • AC-66 — THE SYSTEM SHALL 保持未纳入本需求的工作流、助手、知识库等资源动作语义不变;具体租户管理视角按 AC-09 至 AC-18 统一收窄,不能由此改变这些资源的授权模型或数据归属。
  • +
+

2.10 代发布、预览与取消的补充边界

+
    +
  • AC-67 — WHEN 解析应用发布的审批候选,THE SYSTEM SHALL 排除应用归属人,以及与该次实际提交关联的自然人;自然人直接提交时排除该本人,服务账号提交时排除提交时固定的服务账号归属人并保留真实服务账号主体记录;二者为同一人时不重复排除,服务账号随后改绑不能改写该次自审关联人,按 AC-68 满足单管理员例外时除外。
  • +
  • AC-68 — IF 按 AC-67 排除后没有合格审批人且不满足原产品“全租户仅一名管理员且无其他候选”条件,THEN THE SYSTEM SHALL 进入异常审批处理并保留原因,不直接通过;WHERE 满足该单管理员条件,THE SYSTEM SHALL 仅允许该管理员按原规则自审并明确审计,不把“仅配置一名候选”或“过滤后仅剩一名”视为符合例外。这一排除与例外同样约束后续节点及异常处理中的重试、指定审批人,不改变其它审批场景的资格规则。
  • +
  • AC-69 — WHEN 同一人同时具有应用 edit 和该次审批任务资格,THE SYSTEM SHALL 按本次预览用途分别执行 AC-38 或 AC-50;管理预览不能继承审批预览的能力例外,两种用途的身份、数据和访问资格保持隔离;缺少该次有效审批任务资格时,即使拥有管理员身份、edit 或声明自己在审批,也不得获得审批例外。
  • +
  • AC-70 — WHEN 当前取消者已失去原业务操作所需任一动作,THE SYSTEM SHALL 拒绝其取消请求且不改变在途操作;仍具全部所需动作的其它主体可以按原规则取消。已生效或按既有规则不可取消的阶段继续拒绝取消,不借取消回退已生效版本或重放已完成效果;必要的失败清理不依赖原操作者继续持有权限。
  • +
+

2.11 运行控制协议统一

+
    +
  • AC-71 — THE SYSTEM SHALL 仅支持统一运行控制协议 v2 承载 Docker 与 K8s 应用运行管理,拒绝旧 v1 控制配置及请求,不通过旧控制入口或自动回退继续执行;协议收敛不得取消既有应用交付、数据、附件及平台能力,也不得擅自删除或迁移测试应用的资产与归属。
  • +
+

3. 动作与业务范围

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
现有动作业务范围
use业务入口与应用使用
edit管理内容读写:元信息、源码、日志、生产数据、环境变量、私有依赖与快照
publish提交/撤回发布及正式启动;改内容后发布、配置重建等需同时有 edit
unpublish下线正式应用
delete显式删除整个应用
manage_permission授权和可见范围管理
+

审批任务资格、平台运维资格、运行身份可信性是不同业务边界,不由单个 app 动作代替。新应用尚无资源授权时的受控创建与初始授权按 AC-44;不是持有 app:manage 就自动有历史应用权限。

+

3.1 取消操作所需动作

+

本表描述可取消阶段的业务资格;不扩大任何操作原有的可取消阶段或效果。所有动作针对被取消操作所属的同一应用,由当前取消者满足;同一运行操作名称可能对应不同业务用途。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
被取消的原业务操作当前取消者所需的全部动作
启动、手动上线或恢复已有已批准版本publish
新内容发布准备(含代码、镜像或运行环境变更)、确认环境变量配置重建edit AND publish
数据快照恢复,不包含正式实例启动edit
数据快照恢复,包含正式实例启动edit AND publish
下线正式应用unpublish
显式删除整个应用delete
其它现有运行管理操作其原业务操作已定义的全部动作,不从操作名称或发起者身份推断
+

4. 与现行合同的变更关系

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
现行内容本稿的明确替换目标
F019 的空选择/全平台视角AC-09 至 AC-18:只能具体租户,默认/回退为所属租户;空或全平台切换拒绝
F054 删除、日志、数据额外 owner 资格AC-21、AC-22、AC-23、AC-31、AC-32;状态与资产保护不变
F055 CLI 迭代、撤回、手动上线及环境变量 owner 资格AC-24 至 AC-26、AC-29、AC-35 至 AC-48;必审和身份资格不变
F055 AC-16、AC-17 申请人与自审排除AC-39、AC-67、AC-68:审批申请人及路由仍基于应用归属人;另排除实际提交关联自然人,限定原单管理员自审例外
F055 AC-27 / INV-36 的预览能力例外AC-38、AC-50、AC-51、AC-69:管理预览使用本人权限;审批任务预览保留既有归属人权限例外,两种用途不互用
F056 运行记录及管理展示差异AC-07、AC-34、AC-61 至 AC-64
F086/F059 运行管理 natural_owner 限制AC-27、AC-28、AC-55 至 AC-60
F086 的 v1 长期兼容、旧控制状态及绑定导入AC-57、AC-71:只保留 v2,旧导入/升级/重绑均退役;配置确认重建沿 AC-26,代码/镜像/运行环境变更沿 AC-35、AC-36;共用能力和测试资产保留
release-contract INV-35 的 owner-only 删除措辞改为 app.delete 资格的显式删除;在线不可删除和资产持久不变
F113 服务账号/应用租户资格保留;管理视角不等于真实租户改变,也不为凭据提供 owner 身份
+

这些是用户明确授权的业务变更目标,本文待确认期间不把现有合同或实现视为已经改变。正式设计需同步当前版本受影响的合同引用;不恢复旧实现、不修改其它版本线。

+

5. 边界情况

+
    +
  • 创建人名称、owner 事实或权限模型名称不等于某项动作的授权结果;后台也不能以 owner 代行补足丢失的权限。
  • +
  • 用户在设计阶段再次明确:创建资源仍归属服务账号配置的自然人,owner 与租户按既有规则写入;真实服务账号用于动作判定、初始授权及操作者审计。本期保持这一归属逻辑和自然人的受保护所有者授权。
  • +
  • 一个人在租户 A 的 app 有动作,不代表迁到租户 B 后仍能在 A 操作;存量资源与授权保持原归属,具体租户视角和主体资格继续约束请求。
  • +
  • 本轮退役运行控制协议 v1,以及旧绑定导入/升级/重绑链路。前端 /api/v1、开放 API /api/v2 和应用包的 manifest_version 是各自独立合同,不按相同数字一并删除;已有源码交付仍可由统一运行层承载。
  • +
  • 助手、工作流、知识库共用读取/调用保持现有可见性及 use/业务条件,不新增 Vibe 管理动作或统一必填管理头;专用管理写保持原写入资格。
  • +
  • Root 是具体租户;公共预置角色等共享目录的读取不代表可以查看/修改其它租户业务对象。
  • +
  • 编辑者可以管理该应用全部已纳入的管理数据;这是采用现有六动作的明确粒度,不能再以隐藏的 owner 特判分拆 edit。更细的只读/导出/密钥专用动作不在本期。
  • +
  • 同一版本保存环境变量与确认应用到正式实例是两个不同操作;保存只需 edit,确认生效需 edit 与 publish。
  • +
  • 删除配置项、编辑数据行和操作 SDK 业务附件不是删除整个应用;不能误用 app.delete 导致一般编辑能力缺失。
  • +
  • 持 publish 不代表有 edit,持 manage_permission 也不直接替代其它动作;具体模型是否同时包含多个动作由 F048 的生效定义决定。
  • +
  • 自动清理、终态回调和自愈不应因操作者撤权卡住旧 writer 停止,但也不能借内部操作产生新的未授权启动或扩权。
  • +
  • 代发布保留应用归属人作为审批申请人与路由依据;实际提交主体另记,自审排除同时考虑应用归属人与提交关联自然人;服务账号关联自然人不是已验证的实际用钥人身份。
  • +
  • 管理预览与审批预览是两种受控用途:即使同一人具备两种资格,管理预览也只能使用本人底层权限;两者都不注入真实密钥。
  • +
  • 取消资格取决于原业务用途和当前取消者的全部有效动作;配置重建取消不能仅凭 publish,原发起者资格与旧按钮状态也不能替代当前判定。
  • +
  • 旧客户端请求全平台视角、旧页面跨租户提交和仅持粗 scope 的历史凭据均按新规则拒绝或回退具体范围;不会自动扩大授权以保持旧体验。
  • +
+

6. 需求发现映射追溯

+

本表保留原 36 项操作盘点编号;M22 按用户采纳的收敛建议标为退役入口,不再分配可执行权限,也不新增运行迁移或通用实例重建功能。租户单选由 AC-09 至 AC-18 覆盖,唯一运行控制协议由 AC-71 覆盖。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
需求发现条目正式验收标准
M01AC-06、AC-09、AC-59
M02AC-07、AC-15
M03AC-07、AC-20、AC-46
M04AC-19
M05AC-20、AC-50
M06AC-21
M07AC-22
M08AC-23
M09AC-24
M10AC-25
M11AC-26
M12AC-27
M13AC-27
M14AC-28
M15AC-35、AC-43
M16AC-44
M17AC-36
M18AC-29
M19AC-30
M20AC-37
M21AC-56、AC-70
M22AC-57、AC-71;旧导入/升级/重绑均退役,无授权动作;配置确认重建及新版本发布分别见 M11、M15、M17
M23AC-31、AC-32
M24AC-33
M25AC-33
M26AC-49、AC-50、AC-69
M27AC-38、AC-51、AC-69
M28AC-49、AC-52、AC-67、AC-68
M29AC-34
M30AC-15、AC-34
M31AC-43、AC-45、AC-46、AC-47、AC-48
M32AC-59
M33AC-53、AC-54
M34AC-58
M35AC-39、AC-40、AC-41、AC-53、AC-64、AC-67、AC-68
M36AC-60
+

7. 设计与验证边界

+

原 70 条规格已获确认;本轮按用户明确的 v2 收敛决定补充 AC-71,并按“按照建议修改”将 AC-57 收窄为旧绑定导入/升级/重绑退役,既有配置确认重建和正常新版本发布不扩展。身份解析、接口兼容、首次创建授权、审批代行衔接、后台阶段重校验、旧客户端失效与范围回退的一致性由 design.md 说明,不在本规格中提前定实现。设计仍须单独评审确认。

+

后续仅做本功能必要的允许/拒绝配对、六动作与组合、租户范围、真实主体、异步续办、前端/API 一致、确认/审计及专用临时对象 E2E;重点覆盖代发布的双重自审排除及单管理员例外、无候选异常、管理/审批双用途预览、撤权后取消与不可取消阶段;不运行 Vibe 全量或编排故障全量。只验证 v2 的 Docker/K8s 主流程及本次权限边界,补旧 v1 配置/接口拒绝、源码/应用包/完整镜像功能保留及数据不误清理的必要定向检查;不再运行 v1 成功路径或旧协议迁移验收。Docker/K8s 使用相同产品验收标准,DM8 按既定口径仅静态核对。未执行的部署、测试和发行不记为通过。

+

相关文档

+ +

8. 本轮收敛记录

+

用户在评审讨论中明确:共用接口按原最小权限维持;Vibe 尚未正式对外发布,运行协议统一保留 v2。已按“收敛统一一下”更新本稿。除 AC-57 的退役范围修订和新增 AC-71 外,原 AC 正文不变;不重新定义工作流/助手等资源动作。

+

R3 收窄决定:用户在收敛稿实质评审后回复“按照建议修改”,明确旧绑定导入、升级和重绑一并退役;同版本环境变量生效复用 AC-26,代码/镜像/运行环境变更复用 AC-35、AC-36,不增加独立通用实例重建。本轮仅 AC-57 正文改变,另外 70 条 AC 正文保持。

+

当前阶段:原规格、v2方向与R3收窄已明确;2026-10-30用户单独确认修订设计,已进入tasks与编码。71条AC原文不变,完成状态及运行证据见tasks/verification。


+

F114 规格收敛复审记录

+

日期:2026-10-30。结果:LGTM(同一代理按 sdd-review 复审;仅静态结论)。用户原已确认 70 条 AC,并在本轮明确共用接口维持原权限、运行控制统一 v2。本稿将该收敛方向补充为 AC-71,并收窄 AC-57 的退役入口范围;其余 69 条原 AC 正文保持不变。当前共 71 条,36 项盘点编号保留,其中 M22 明确为退役入口。本轮用户已采纳 R3 建议,只进一步修改 AC-57,其余 70 条正文保持。

+

确认状态:原 70 条规格已获确认;本轮“收敛统一一下”明确了 v2 取舍,用户随后以“按照建议修改”明确采纳 R3 收窄范围:旧绑定导入/升级/rebind 退役、现有配置确认重建/新版本发布保留;不将此视为整份设计已获单独确认。修订设计仍需用户确认后才能拆任务、编码;本轮仅改文档。

+

依据:spec.md、用户原映射与本轮指令、PRD-1、release-contract、F048 权限架构、后端模块架构、constitution 和当前 bb62790ad22a3204493e7dce5878e0a4f7109599 调用链。未运行单测、业务回归、部署或镜像发行。

+

1. 审查结果

+

LGTM

+

本轮取舍取消旧运行控制协议及其绑定导入/升级/重绑兼容,明确不增加独立通用重建;已有配置确认重建和新版本发布分别有原条款承接。保留原业务能力、六动作、自然人归属、审批及数据保护,不新增编排后端或自动迁移功能。spec 仅定义支持策略与允许/拒绝行为,配置字段、路由、工具复用和处置顺序在 design。

+

2. 修订追溯

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
内容原确认/本轮决定当前条款
代发布、自审与实际主体原三项规格问题已关闭,条款保持AC-39、AC-67、AC-68
管理/审批预览隔离声明与本人/审批例外分开,环境密钥为空AC-38、AC-50、AC-51、AC-69
取消的当前主体与全部动作原业务用途、当前取消者、不可取消阶段及清理规则保持AC-56、AC-70、§3.1
共用接口维持原逻辑助手/工作流等读取/使用不附加 Vibe 动作;管理租户范围继续收窄AC-15、AC-18、AC-66;spec §5
旧 v1 控制兼容退役用户明确统一 v2;不为旧链再补阶段授权AC-71;AC-40 的阶段要求保持
R3 收窄范围M22/AC-57 为旧绑定导入/升级/rebind 退役,无权限可恢复该入口;配置确认重建/新版本发布沿原条款,本次不新增通用重建AC-57、AC-71、AC-26、AC-35、AC-36
共用能力与资产协议收敛不误删源码/SQLite/附件/SDK;不改 owner/tenant 或自动处置测试资产AC-05、AC-59、AC-65、AC-71
+

3. sdd-review 清单

+

按当前 spec-checklist 实际 10 项执行(技能说明中的 14 项为旧数量)。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
检查项结论核对依据
1 需求覆盖PASSM01 至 M36 均追溯,M22 明确退役;AC-57、AC-71 覆盖旧重绑和协议取舍
2 边界与失败PASS旧控制配置/请求拒绝、无自动回退、资产与能力保留;原权限失效和清理场景保持
3 排除范围PASS不重写助手、审批引擎或编排,不新增迁移能力,不全量回归
4 AC 唯一及追溯PASSAC-01 至 AC-71 连续唯一;R3 仅修改 AC-57,其余 70 条与上一收敛稿正文一致;原确认规格的其它 69 条仍保持
5 可测试PASS可观察支持/拒绝结果、运行副作用、原能力可用性和资产保留
6 EARSPASS所有 AC 有 THE SYSTEM SHALL;新增条款为支持范围的普遍约束
7 错误行为PASS规格仅要求明确拒绝,无错误码或技术接口详表
8 领域归属PASSF048/F019/F054/F055/F056/F086/F059 归属不变;只改变既有运行协议支持策略
9 不变量/宪法PASS版本合同明确替代 F086 的旧兼容目标,数据/必审/真实主体/C1–C8 保留
10 纯 WhatPASS规范字段、模块拆分、配置默认值、路由和处置实现归 design D9/§4.11
+

4. 场景推演(静态,未执行)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
场景唯一预期
Docker/K8s 发起新运行操作仅 v2,仍按原业务动作与状态条件执行
旧 v1 配置或控制请求拒绝、不产生新业务/容器效果、不回退旧控制循环
用户持有 edit AND publish 请求旧导入/升级/rebind仍拒绝且无执行体/路由/存储副作用,不因权限足够而恢复退役入口
同版本最新环境变量确认生效按 AC-26 保留确认重建;保存不重启,不借旧 rebind
代码、镜像或运行环境改变按 AC-35、AC-36 走新版本发布及审批,不新增通用重建
已有源码包或 SDK 调用原交付/使用能力保持;协议数字不混同为应用包/API 版本
发现旧测试资产或已导入的旧执行体不自动收养/删除;保持归属与数据,按实际授权处置后再切换
原生 v2 在线应用,其历史发布人被撤权不主动停止旧服务;新增效果按当前资格复核,自愈/必要清理继续
同一人同时有 edit 和审批任务两种预览用途仍隔离;不能靠管理员或 edit 取得审批例外
+

5. 同步与状态

+

已同步 spec、design、discovery、release-contract、两份复审记录及三份离线 HTML。原 F086/F059 历史实现与验收证据不被改写成新目标已交付;当前变更替代关系由本版本合同集中登记。

+

原确认全文 SHA-256 为 f9feba32cc67bd812908f5a3d369ec942ede8955e15ca6206101c5a62305c45b,仅作为历史确认锚点。当前修订不再声称原 70 条全未改变:仅 AC-57 收窄,新增 AC-71,其余 69 条保持。设计待用户单独确认,无 tasks、业务代码、提交、推送或远端操作。

+

本轮 R3 修订核对:仅 AC-57 正文改变,M22 的退役状态和 AC-26、AC-35、AC-36 的承接关系已同步到 discovery/spec/design/release-contract。以上为静态场景推演,未运行真实拒绝、重建或发布测试。

+

本轮复审结果:10 项规格检查通过。71 条 AC 编号、M01 至 M36(含退役的 M22)及正文差异已核对,仅 AC-57 改变;其余 70 条与上一收敛稿一致。未发现新增未关闭问题;设计仍待单独确认,未编码或部署。


\ No newline at end of file diff --git a/docs/reviews/vibe-qa-fixes-2026-10-28.md b/docs/reviews/vibe-qa-fixes-2026-10-28.md new file mode 100644 index 0000000000..84141ab64b --- /dev/null +++ b/docs/reviews/vibe-qa-fixes-2026-10-28.md @@ -0,0 +1,92 @@ +# Vibe 应用及租户管理问题修复记录 + +日期:2026-10-28。分支:`codex/vibe-qa-fixes`。基线:`origin/feat/3.0.0@d2ff49e591e0f602f7ae41387f8b8c90bbccf90b`。 + +## 范围与流程 + +这是 11 项既有功能缺陷的修复批次,按 SDD-Guide §1 的 hotfix 路径恢复已有约定:隔离分支、定位、修复、定向验证和七维度自审。没有新增发布方式、审批流程、数据表或资源迁移。新增的 `approval_status` 是版本查询的可选读字段,原有字段和数据库终态保持兼容。 + +2026-10-28 开发阶段未提交、推送、合并或部署;116 仅执行日志、已部署源码和只读事务查询,未修改存量应用、人员、用户组或审批记录。2026-10-29 用户授权提交本批修复、合入最新 `feat/3.0.0` 并推送,本次 Git 交付不涉及环境部署。 + +## 逐项结果 + +| # | 问题与定位 | 处理与证据 | +|---|---|---| +| 1 | 发布面存在永久禁用的「提交发布」按钮。应用提交实际上由 CLI 完成,没有网页待提交的包。 | 移除该按钮,保留 CLI 发布说明及实际可用的撤回操作。 | +| 2 | 无审批人、路由缺失是否生成异常实例。 | 当前引擎已经创建 `exception` 实例和异常记录,发布侧保留版本,不绕过审批。本轮定向验证审批人为空、路由缺失、异常版本留存。116 还读到应用发布异常实例 432(`route_missing`)。不另建重复异常机制。审批场景本身未启用仍按既有配置错误拒绝,不冒充已提交审批。 | +| 3 | 已上线 Vibe 应用仍可展开版本下拉框。 | 在线状态禁用下拉框。该控件原本只是只读版本列表,没有切换执行入口;未新增离线版本切换能力。 | +| 4 | 草稿点击上线只显示泛化状态错误。 | 在缺少可运行版本时,先读取对应发布/审批状态,返回稳定原因:审批中、驳回、撤回、取消、审批异常、审批执行失败、发布准备中或准备失败等。前端使用三语言文案解释处理方式,操作请求由页面统一展示错误,避免拦截器和页面重复弹出泛化提示。此分支始终拒绝启动,不允许未审批版本上线。 | +| 5 | 上下线需要二次确认。 | 当前源码的列表和详情已经共同调用 `useHostedAppActions`。保留其确认弹窗;定向验证取消不发请求、确认后才执行。确认真实调用者,没有再加第二层弹窗。 | +| 6 | 租户管理员设置用户组返回 23006。 | 用户组选择器允许租户管理员选择组,但成员写入只识别超管/创建者/公开组部门管理员,存在权限判断不一致。另有「原样带回不可管理的已有组也被拒」的问题。补齐本租户管理员写入资格,限制目标组和新操作人员的租户;不可编辑的既有关系保持原样,拒绝新增越权组。 | +| 7 | 租户管理员审计页 403。 | 116 日志确认失败接口为 `/api/v1/audit/session`。后端原先依赖 RBAC 管理员、菜单或用户组管理员身份,遗漏独立租户管理员资格。复用权限模块的实时租户管理员检查,查询仍使用本租户谓词;不开放跨租户审计。 | +| 8 | 通知不能定位对应审批。 | 撤回/业务取消通知接收者是原审批人,改到「我的审批」已处理项。解析 task/instance 两种 ID;同一栏目目标变化重新加载;程序切换筛选项不再用首条记录覆盖目标;不可用目标不跳到无关记录。发布首节点通知不再把首个 task_id 共用给所有接收者,而由接收人按 instance_id 找自己的 task。 | +| 9 | 草稿数据页 500 且无法解释。 | 授权完成后,无当前及待上线版本直接返回既有业务码 16163,不启动数据库 helper/RPC。页面复用未创建数据库的空状态,说明草稿需完成审批上线,以及仅使用浏览器存储的应用不会产生数据表。运行过的应用仍按原数据路径查询,未因停用而隐藏持久化数据库。 | +| 10 | 版本页审批状态为空。 | 原页面把 `terminal_state` 当审批状态;该字段记录最终上线结果,审批时可以为空。现在通过发布记录关联对应审批实例,校验租户、场景、应用和发布记录后投影 `approval_status`;版本页显示真实审批状态。读取分批进行,兼容旧后端的终态回退,不改历史记录。 | +| 11 | 日志行数文案与箭头挤在一起。 | 使用现有共享 Select 和其本地化配置,提供 160px 且不收缩的宽度,行内不足时沿原布局换行。验证选中标签和刷新请求使用一致的行数。未声称已完成真实浏览器各尺寸视觉验收。 | + +## 116 的已核对事实与边界 + +- `gzx0001`(868)、`gzx0004`(871)的当前有效成员关系均为租户 60;截图用户组 182 同属租户 60,创建者为 868。不能将该组数据解释成跨租户错误。 +- 未完整恢复截图中那一次成员保存请求的实际操作者与请求体,因此没有把某一拒绝分支冒充唯一的线上根因;上表第 6 项两条确定的代码缺陷均已修复并配对验证。 +- 草稿应用 `69c165e3ac9b410aac2ec0f6e99d868c` 的 `current_version_id`、`pending_version_id` 均为空;对应最近发布所关联审批为 `withdrawn`。恢复已有终态提示无需修改其数据。 +- 日志能确认另一个应用数据库尚未创建时 manager 返回 404(平台有既定业务映射);未取得截图那次 500 的完整历史堆栈。新增草稿前置检查消除了截图应用所处条件下不必要的数据库 RPC,未把所有 500 都归为同一个原因。 +- 以上为只读核查;本分支的修复尚未在 116 替换服务验证。 + +## 代码入口 + +- 发布操作、版本禁用:[HostedAppCard](../../src/frontend/platform/src/pages/BuildPage/HostedAppCard.tsx)、[useHostedAppActions](../../src/frontend/platform/src/pages/BuildPage/useHostedAppActions.ts)、[ApprovalStatusCard](../../src/frontend/platform/src/pages/BuildPage/hostedApp/publish/ApprovalStatusCard.tsx)。 +- 发布原因及版本审批投影:[version_review_service](../../src/backend/bisheng/app_publish/domain/services/version_review_service.py)、[version_review_repository](../../src/backend/bisheng/app_publish/domain/repositories/version_review_repository.py)、[execution_source](../../src/backend/bisheng/approval/application/execution_source.py)。 +- 数据页:[AppDataService](../../src/backend/bisheng/app_runtime/domain/services/app_data_service.py)。 +- 租户管理:[UserGroupService](../../src/backend/bisheng/user_group/domain/services/user_group_service.py)、[MemberIdentityRepository](../../src/backend/bisheng/user_group/domain/repositories/member_identity_repository.py)、[AuditLogService](../../src/backend/bisheng/api/services/audit_log.py)。 +- 通知:[notificationContent](../../src/frontend/client/src/components/messageApproval/notificationContent.ts)、[ApprovalPane](../../src/frontend/client/src/components/approval/ApprovalPane.tsx)。 + +## 定向验证 + +没有运行仓库全量回归。已通过的用例来自以下限定集合,合计后端 57、平台 47、客户端 13;修复测试问题后仅重跑受影响集合,不重复运行无关集合。 + +### 后端(57) + +| 集合 | 数量 | 验证重点 | +|---|---:|---| +| `test/app_runtime/test_app_data_service.py` | 19 | 数据授权、查询/写入既有路径、草稿未初始化不调用 manager | +| `test/app_runtime/test_app_query_service.py` 指定版本列表用例 | 4 | 版本兼容字段;真实 SQLite 发布记录与审批实例关联,覆盖 pending/rejected/withdrawn | +| `test/app_publish/test_version_review.py` | 14 | 上线阻断原因、审批各状态、跨租户审批拒绝关联 | +| `test/user_group/test_user_group_tenant_admin_access.py` | 11 | 管理身份、同租户允许/跨租户拒绝、不可管理的既有关系保留/新增越权拒绝 | +| `test/audit/test_tenant_admin_entry.py` | 3 | 实时租户管理员与普通用户配对、目标租户范围拒绝 | +| `test/app_runtime/test_app_state_actions.py::test_draft_start_reports_review_reason_before_release_source_lookup` | 1 | 缺少版本时不进入启动和发布来源执行 | +| 审批引擎/发布的四个指定异常用例 | 4 | approver_empty、route_missing、异常时不通知普通审批任务 | +| `test/app_publish/test_version_service.py::test_gate_exception_approver_empty_still_inserts_version` | 1 | 审批异常仍留版本和审计记录 | + +### 前端 + +- 平台:`useHostedAppActions.test.tsx`、`VersionsTab.test.tsx`、`hostedAppDataTab.test.tsx`、`hostedAppMenuTrim.test.tsx`、`PublishTab.test.tsx`、`LogsTab.test.tsx`、`hostedAppApi.test.ts`,共 47 条通过。 +- 客户端:`notificationContent.test.ts`、`ApprovalPaneErrors.test.tsx`,共 13 条通过。 +- 本地 Jest 的可选 `canvas` 原生二进制缺失。仅测试进程将未使用的 canvas 视为不可用后运行上述两份测试;没有改业务代码、依赖版本或共享安装目录。最终运行关闭覆盖率收集,不代表全量覆盖率检查。 +- 平台 8 份、客户端 2 份变更业务源码的定向 TypeScript 语义诊断均为 0;平台和客户端变更业务源码 ESLint 通过。不是整个前端工作区 typecheck/lint 全量通过的声明。 +- 更新错误文案的三语言源文件,并由 `packages/locales/scripts/build.mjs` 生成产物;`--check` 通过。 +- 架构守卫和 `git diff --check` 通过。 +- 除 `audit_log.py` 外变更 Python 文件 Ruff 通过。`audit_log.py` 原基线 36 项、当前 10 项存量诊断,按规则码和消息比对无新增;没有把这份文件标为全量 lint 干净,也未扩展清理无关旧审计逻辑。 + +## 七维度自审 + +| 维度 | 结论 | +|---|---| +| 边界条件 | 空版本、空审批、未知状态、不可用通知目标均受控;未回退打开无关审批。 | +| 权限认证 | 应用鉴权先于原因投影/数据空状态;租户管理员的新增成员写入资格校验人员归属;跨租户审批投影拒绝。 | +| 并发安全 | 不改发布状态机和审批写入;通知列表与详情沿用请求 revision,取消筛选 effect 对指定目标的覆盖。 | +| 错误处理 | 用稳定业务原因和本地化文案,保留既有错误码;未知故障不伪装为未初始化成功。 | +| 前端规范 | 复用共享 Select、已有确认弹窗和本地化设施;无新组件库,变更文件均在 600 行以内。 | +| 数据与兼容 | 无 DDL/数据迁移;业务读取关联显式核对归属,使用兼容 MySQL/DM8 的列查询,不使用数据库 JSON 提取。 | +| 验证证据 | 定向模拟/SQLite/组件测试与 116 只读事实分别记录。真实部署、浏览器尺寸视觉检查、DM8 实测均未宣称完成。 | + +## 提测时需核对 + +更新 backend、worker 及前端到本分支后,在测试环境检查:草稿撤回提示与数据空状态、列表/详情上下线确认、版本审批状态、租户管理员成员保存及本租户审计、撤回/取消通知定向详情;最后检查宽屏与窄屏日志控件文案。存量资源无需迁移,也无需重放历史审批。 + +## 2026-10-29 合流核查 + +- 源修复提交:`6d56b06ed`;合流基线:`origin/feat/3.0.0@1b55e45699`。 +- 业务源码与已评审修复提交一致;与上游重叠仅在多语言文件。 +- 三份错误文案源文件按键合并,保留双方全部文案;生成产物一致性检查通过。平台 `bs.json` 保留全部上游内容,仅加入本批数据空状态文案。 +- 合并基线上定向执行发布状态、用户组和审计入口的 28 条用例,全部通过;未重跑全量测试。 +- 未修改主工作区的 8 份既有未提交文件,未部署 116。 diff --git a/docs/reviews/vibe-release-integration-2026-10-25.md b/docs/reviews/vibe-release-integration-2026-10-25.md new file mode 100644 index 0000000000..d377b88caa --- /dev/null +++ b/docs/reviews/vibe-release-integration-2026-10-25.md @@ -0,0 +1,146 @@ +# Vibe 分支整合与部署记录(2026-10-25) + +## 最新结论(2026-10-26) + +- 三分支业务源码已推送;最新业务热修为 `f8fbe89481`,release 合并提交 `d9e6c4d4fe`。后续仅追加这份记录及脱敏证据。 +- **189 单应用主流程通过**:源码包 → backend/Celery → K8s prepare/probe → 审批 → 正式 Pod/PVC → 授权 HTTP 200。示例保留在线:[打开示例](http://192.168.106.189:30101/apps/e2e-vibe-k8s-20261025/);[平台入口](http://192.168.106.189:30101/)。访问需要登录本平台。 +- **116 部署保持运行,业务验收等待资源释放**,这是用户最新确认的安排,不降低容量门槛、不停止无关服务。 +- 九个普通工作树已清理;六个托管工作树归档仍为 queued,磁盘路径尚存,未计为清理完成。 +- 无全量测试。新建189环境仅验收一个 static bundle 应用,不能扩展为全部语言、交付模式、SDK/OBO、升级、停止或故障场景都已通过。安装报告原15项已通过,本轮未重复执行。 + +## 授权与范围 + +用户明确要求汇总所有 Vibe 开发,依次合入并推送 `feat/3.0.0-vibe`、`feat/3.0.0`、`release`,清理对应 worktree,更新 116 Docker,并在 189 部署独立平台应用层和 Kubernetes 托管层、复用 116 中间件。本轮不新增公开 tag,不运行项目全量测试;DM8 静态、ARM 延后口径保持。 + +## 整合记录 + +- 远端起点:Vibe `5f2541f4bc`、发布线 `cd2822b7e6`、release `113f682575`。主工作区仍停留在较早发布线且有无关改动,保留原样;合并使用隔离工作树。 +- 纳入治理收尾 `85faaa32c`,以及七个旧 F059 子分支。后者的业务内容已在治理基线内,完整历史汇合后对比 `85faaa32c` 无内容差异。CLI 二进制冲突保留较新 wheel,逐文件核对当前 CLI Python 源码与 wheel 一致,manifest 摘要一致;operations 冲突保留 maintenance 持久化及后续异步 purge 围栏。 +- 同步最新发布线:追加文档并集;三语错误码执行按键三方合并,再经官方生成器生成六个消费端文件;用户登录冲突仅注释,保留服务账号边界说明。 +- 两条合法迁移链使用新空 merge revision `vibe_merge_release_20261025` 汇合;不改写既有迁移,不做数据回填。 +- F086 工作树残留的源码有已纳入部分及早于现有权限修复的部分,不用旧草稿覆盖新实现。逐文件摘要保存在证据目录;清理时由应用的 archive_worktree 保留可恢复 Git 快照,独有验收材料可恢复,不将旧验收重新算作本轮通过。 + +## 合入前定向检查 / 七维度代码检查 + +| 维度 | 结果与边界 | +|---|---| +| 边界条件 | 原 F054/F055/F056 定向证据固定到 `932fa37e1`;本轮合并没有回退分页、空值、预览或清理围栏 | +| 权限与认证 | 保留自然人当前租户作用域、可信凭据关联、OBO 拒绝顺序和独立宿主证明;合并未增加放行 | +| 并发安全 | 保留 generation/epoch、单写、异步 purge 和导出租约;不重放旧分支覆盖较新状态机 | +| 信息泄漏 | 合并文档不收集密码、token、完整环境;部署凭据仅存远端安全配置,不进入 Git | +| 测试覆盖 | 迁移单 head:1 passed;K8s purge / destroy 事实合同:30 passed;只针对合并风险,不是全量测试 | +| 代码风格 | 改动 Python Ruff、locale 官方生成器 --check、git diff --check 通过 | +| 文档同步 | F054/F055/F056 设计及提测报告保留;15 个 Vibe worktree 的归属、包含性与草稿摘要已记录 | + +**代码整合审查:PASS_WITH_WARNINGS。** 此结论限本次整合,不能宣称整个 SDD 全 AC、真实增量 E2E、客户离线发行或 ARM 验收已通过。用户此次明确要求先完成合并部署;后续环境实测单独补证,不通过修改勾选状态掩盖缺口。既有两条前端类型基线问题保留。 + +## 部署约束和当前状态 + +- 116 已只读认证成功,现部署目录 `/opt/server/bisheng-test`;内存余量紧张,先更新本次授权的应用服务,配置新 runtime 前复测容量。不停止无关服务、不删除既有业务数据、不降低容量或隔离门槛。 +- 189 复用先前专用 known_hosts 严格核验;8 CPU、约 56 GiB 可用内存、约 465 GiB 可用磁盘。既有 `e2e-f059-*` 应用、PVC、准入和服务继续保留,新平台使用独立命名空间/安装标识。 +- 189 新平台计划复用 116 的独立数据库和 OpenFGA store;116 的 Redis 逻辑库均已有数据,因此新平台另配独立 Redis,不复用或清空现有任务队列。公开/临时桶分别配置。Vibe 私有对象由现有 UUID 应用/任务前缀隔开,当前固定私有桶设计不声称是不同平台的独立桶。 +- 现有 Drone release 流水线构建 backend/frontend/sandbox 并自动更新 116。本轮 runtime 镜像另从最终 release 同一源码构建原生 amd64,固定不可变版本和 digest 交付内网,不把旧 runtime 镜像当作已更新。 +- Docker 宿主证明单元、K8s 能力报告、私有材料路径和实际应用发布/升级/停止/档位选择须部署后分别验证;当前尚未在这份记录中标记通过。 + +证据目录:`docs/reviews/evidence/vibe-release-20261025/`。执行中的 SHA、CI 构建号、镜像摘要、环境入口和清理结果将在各阶段完成后追加。 + +## 实测后的窄范围修复 + +116 真实 `iptables-save` 将父链 `-i/-s` 选项换序,导致等价规则误判 `host_firewall_jump_not_first`。校验现复用字段规范化;仍检查父链前 N 条,额外 ACCEPT 或其他子网必须拒绝。11 个宿主隔离用例通过(包括两网络参数换序、提前 ACCEPT 和错误子网),Ruff 通过;未跑其他全量回归。 + +此 hotfix 七维度检查无新增 HIGH:无 API/权限/事务变化;不扩大规则、日志或清理范围;仅修正合法配置的读回比较,文档和任务已同步。真实 canary 继续复验后记录结果。116 的 systemd219 使用部署 drop-in `ProtectSystem=full` 兼容旧版,不宣称旧版支持 strict/ReadWritePaths。 + +## 17:59 后续实测与交付状态 + +### 分支与清理 + +远端已核对:`feat/3.0.0-vibe` 与 `feat/3.0.0` 均为 `311b81867a4c1541545c3640cd562a78f53ade6b`;`release` 为 `7cf8e14755bf01f43b5f56dcb4c079f35fdb9534`。没有 force push 或新增 tag。 + +已清理八个临时 F059 工作树和干净的 `vibe-app-integration` 工作树,共九个;保留分支引用,后者唯一非缓存忽略文件 `client/junit.xml` 另行备份。六个 Codex 托管工作树受置顶保护,archive 返回保护错误,尚未删除;未绕过应用保护。当前 release 执行工作树保留至部署收尾。主工作区原有四项无关改动未动。 + +### 116 Docker + +- backend、两个既有 worker、frontend、runtime-manager、app-proxy、egress-proxy、docker-socket-proxy 已启动。backend 健康、backend `/health` 和前端 `/` 实测均为 HTTP 200;`/api/v1/env` 返回 `app_runtime_enabled=true`。 +- manager 到 backend 的 heartbeat 与 plan 持续返回 200。宿主隔离真实 canary 成功,systemd 服务 `Result=success`、`ExecMainStatus=0`,定时器已启用。全部九个运行镜像逐一验证了实际 amd64 image ID。 +- 前端使用内网原生构建物,私有仓库 digest 为 `sha256:328f570b3841d421f739688e89841276596f3cd1cfab07dff5fa8ec1eb5a226e`;后端为 `sha256:1963957aff6e15ec223648a687a3e11ac9b80183fbb7bd50e05d5854ae16c21e`。运行镜像基于初始 release `f5db95ecab6934c590fa32486cb15a5ff3dc2737`,宿主工具安装了 `311b81867a` 的窄范围修复;已核对初始 release 到当前 release 的 backend、frontend、nginx 源码无差异,不将旧源码标签改写成新 SHA。 +- 原部署配置和约 1.1 GiB 的一致性 SQL 备份保留在远端 root 专用目录;未重启 MySQL、MinIO、Milvus、ES、Redis 或其他无关服务。 +- **实际应用发布、升级、停止和资源档位业务验收尚未执行。** 自动审批拒绝使用默认应用管理员账号并持久化会话;已改为申请专用账号或明确授权默认测试账号,后续会话仅在内存使用。未绕过认证创建会话。 + +脱敏实测证据:`evidence/vibe-release-20261025/docker116-deployment.json`,仅包含镜像、健康状态和宿主检查结果。 + +### 189 Kubernetes + +- 新建 `bisheng-vibe-runtime-system`、`bisheng-vibe-runtime-apps`、`bisheng-vibe-installer`、`bisheng-vibe-platform`;Helm release `bisheng-vibe-runtime` revision 1 安装成功。manager、app-proxy、egress、admission 四个 Deployment 均为 1/1。 +- 新建四个 1 GiB production、三个 4 GiB scratch、两个 1 GiB preflight 专用有界 ext4 loop 文件系统及 Retain local PV;持久 systemd 挂载和 kubelet 挂载依赖已配置,未重启 kubelet。尚未把这项准备工作当作 SQLite 锁/fsync 或重启验收。 +- 定向 RBAC:允许新 manager 在 execution namespace 创建 Pod;拒绝读取 control Secret,拒绝 exec、attach、portforward。使用显式 `--subresource` 验证,避免 `pods/exec` CLI 简写被错误解释成基础资源。 +- descriptor 的 `probe_report` 仍为空;四个控制进程 Running **不代表运行域已 ready**。未生成虚假报告、未开启应用部署。 +- 新平台 backend、Celery、Redis 和入口尚未接通,15 项真实能力探测与业务 E2E 尚未运行。自动审批拒绝 116→189 中间件凭据传输,已请求最小范围授权:新库专用账号、必要中间件凭据、新 Redis 配置,严格 SCP 直传到远端 root 文件,不传原 MySQL root 密码、不写本地磁盘或日志。未经确认未执行传输。 +- 旧 `e2e-f059-*` 资源未由本次安装修改;不把旧环境证据算作这套新安装通过。单节点 amd64 不证明 ARM 或跨节点恢复。 + +### CI 与剩余项 + +最新 release CI 为 `#5632`(`7cf8e14755`),核对时 backend 构建成功,frontend 仍运行,sandbox 与自动部署尚待执行。较早 `#5629` 已被 CI 标记 killed,不能记为整条成功;未主动取消流水线。116 目前部署来自已固定的 backend 与独立原生 frontend 构建物。 + +下一步依次为:取得最小凭据/应用账号授权 → 接通 189 新平台 → 真实安装探测 → 两环境发布、升级、停止和档位的定向验收 → 完成受保护工作树清理。此次未跑全量测试;最终业务验收和完整镜像发行仍待完成。 + +## 22:40 续办:授权、环境接通和单应用主流程 + +### 本轮边界 + +用户已明确允许新库专用凭据 116→189 传输、创建独立测试账号并授予超管,以及取消置顶后清理工作树。最新测试范围收窄为部署后跑一个主流程,不做全量回归。本文后续以单应用发布、审批、上线和访问为验收目标,不继续扩展故障、升级、权限矩阵等场景。 + +### 已执行 + +1. **最小凭据传输完成。** 新账号仅拥有 `langflow_vibe_k8s_20261025` 的数据库权限,来源限制为 `192.168.106.189`;导出采用必要字段白名单。严格核验两端主机后 SCP 直传到 189 的 root-only 文件,不经本地磁盘,不传原 MySQL root 凭据。 +2. **116 权限模型升级完成。** 正常注册暴露 `authorization_model_migration_required`。官方工具先 plan,再 apply、重启三个平台容器、verify;新增 `app` 的 delete、edit、manage_permission、publish、unpublish、use 六条范围,原子切换到模型 `01M5SP22S6QE32MMFVQFTGV3S2`,CURRENT Catalog 为 196。没有改写既有业务授权或使用 `force_write_model`。 +3. **两环境测试账号已建立。** 116 新用户 ID 867;189 新用户 ID 1。均为本次新建账号,经正常注册/登录和明确授权的超管身份配置;密码只在各自远端 root 专用配置文件中,JWT 和临时发布密钥不进入证据。189 保留全局验证码设置,仅在运维测试进程内使用本次自身生成的验证码挑战。 +4. **189 应用层已运行。** backend、Celery worker、Beat、frontend、TLS 入口和独立 Redis 均为 1/1,入口 `http://192.168.106.189:30101`;材料 HTTPS origin 为 30143。应用层由现有平台 Chart 渲染后施加部署覆盖,固定镜像 digest,通过 Secret 引用配置;运行层独立 Helm release 保持四进程。 +5. **189 安装报告 15/15 通过。** 先前 PVC 绑定/清理失败源于真实控制器使用独立 ServiceAccount。已核对 `--use-service-account-credentials=true` 和对应 ClusterRoleBinding,将精确的 `persistent-volume-binder`、`pvc-protection-controller` 身份及 scheduler 写入 Chart 的同一配置源,升级到 revision 2。旧失败报告保留;修订后的实际 SQLite、原生 CRI、准入、RBAC 和网络探测全部通过,资源清理完成。manager 按既有要求单独重启后接受新基线,业务 API 已返回 `ready=true`。没有再跑业务全量测试。 +6. **两环境运行镜像目录已配置。** Python、Node、static 各导入一个 amd64 原生环境版本。`official=false`,不伪造 ARM 验收或双架构正式发行。 + +### 尚未通过的单应用验收 + +- **116:容量拒绝。** 实测主机约 58 GiB 已使用、可用约 2.7 GiB;Milvus 约 32 GiB,另一个项目 backend 约 6 GiB。现有预约与保留策略计算出的 Vibe 可用预算为 1 MiB,脚本未提交新应用。不调整保留/超卖策略,不停止无关服务;已请求用户指定可释放资源或先保留 116 部署。 +- **189:唯一应用已接收,准备阶段失败。** `e2e-vibe-k8s-20261025` 的 app ID 为 `c63121a2f58441df953762501fdc62d3`;首次 deployment `3685d71782c546f69a10e0adf3670495` 在 `precheck_build` 返回 16227。已定位为 Celery worker 调用 manager 的 wakeup 被部署补充 NetworkPolicy 拦截;初始规则只列 backend。异步执行资源已清理,临时发布密钥已回收;不把接收成功、控制进程健康或安装探测通过写成应用上线通过。 +- **精确网络规则等待明确授权。** 自动审批拒绝将平台 backend/worker 放入 manager 入站来源,理由是部署授权未明确此网络访问范围。已提交唯一精确规则:来源 `bisheng-vibe-platform`,组件 backend 或 worker,目的 `bisheng-vibe-runtime-system` 的 runtime-manager TCP 8091,继续独立 HMAC。该规则尚未执行;批准后只重试原应用主流程。 + +### 交付与运行限制 + +- release CI `#5632` 最终状态 killed:backend 构建成功,frontend 被终止,sandbox 和自动部署跳过。因此不能宣称整条 CI 已通过;当前环境仍使用已核对的 backend 和独立原生 frontend 构建物。 +- 安装报告有效期 24 小时;尚未配置自动刷新服务。当前为单节点测试安装,静态 Retain 的 scratch/preflight 卷须在核对停止证据后由安装者回收,不能无条件清 claimRef。既有测试仓库 CA 到 2026-11-05,后续镜像更新前须处理续期;本轮不改动旧仓库/旧 e2e 资源的证书。 +- 已取消本任务和关联项目的置顶;六个托管工作树的归档调用目前返回 `queued`,磁盘路径仍存在,因此尚未计为清理完成。F086 忽略的本地 config 已单独按 0600 备份;未强删草稿或绕过托管工具。 + +公开证据新增:`k8s189-install-report.json`、`k8s189-descriptor.json`、`k8s189-mainflow.json`;待批准规则为 `k8s189-worker-control-rule-applied.json`。这些文件不含账号密码、会话或 HMAC。 + +## 2026-10-26:精确规则、热修与主流程通过 + +### 仅修正本次阻塞 + +1. 用户明确批准的 worker/backend → manager TCP 8091 精确入站规则已 apply,并同步回安装清单;执行应用的 NetworkPolicy、RBAC 和 HMAC 不变。证据 `k8s189-worker-control-rule-applied.json`。 +2. 新建专用卷缺少 `.bisheng-volume.json`,storage-guard 按预期拒绝。确认新 execution namespace 无 Pod、九个卷均为 Available/Released、local path 与安装身份匹配且为独立 ext4 后,补齐安装标记;没有清理 claimRef 或删除卷数据。 +3. 原版本 prepare 和 probe 均 completed/cleanup complete,审批通过后 outbox 暴露 MySQL `MissingGreenlet`。根因为事务内凭据 flush 未加载服务器默认 create_time,随后同步 DTO 读取触发隐式 IO。`CredentialRepository.create_in_transaction` 增加显式 await refresh,不新增 commit。测试关闭 INSERT RETURNING 后先复现,再配对验证修复及 rollback:**2 passed**;Ruff、定向架构守卫、diff check 通过。按 SDD hotfix 流程完成七维度审查,先推发布线,再由 Vibe 和 release 继承;审查见 `vibe-credential-defaults-hotfix-2026-10-25.md`。 +4. 189 backend/worker/Beat 使用同一固定 hotfix digest;应用与中间件数据保持。通过既有管理员“审批异常重试”API 恢复原审批实例 1,没有手工把状态改成成功,也没有重新上传版本。 +5. app-proxy 原内部 HTTP backendBase 不在本安装网络放行范围内,入口仍503。改为现有已放通的 HTTPS 平台 origin,并给原生 app-proxy 镜像加入**公开**平台 CA,沿用现有系统信任 HTTPS 方案。非 root 导入/TLS 信任烟测通过;保持 TLS 校验,不增加网络权限;同源 Helm values 保存新地址与固定镜像 digest。没有提交额外 Chart 功能。正式 data-helper 启动就绪存在短暂延迟,Service 全部就绪后复查入口成功。 + +### 唯一应用的最终结果 + +| 项目 | 实测结果 | +|---|---| +| 应用 | `e2e-vibe-k8s-20261025` / `c63121a2f58441df953762501fdc62d3` | +| 版本 / deployment | `24fcb79a0b394e6d9634e952375f8091` / `4f599876e25e4bcdaee8e1a213f183b0` | +| 交付方式 | static bundle,无外部新增依赖 | +| 审批与正式状态 | 正常审批 + 异常重试,最终 deployment=succeeded / app=online | +| 正式 Pod | Running;app、data-helper 两容器 ready,重启次数均0 | +| 资源档位 | light:应用500m CPU / 1Gi内存;helper25m / 128Mi | +| 数据卷 | 本次专用 Retain local PV `vibe-release-production-3` | +| 访问 | 经平台 nginx、app-proxy、backend 授权/路由到正式应用,HTTP200,页面标记匹配 | +| 认证材料 | JWT仅留内存;所有临时发布key均在finally正常撤销;不导出账号密码 | + +完整单应用事件保留在 `k8s189-mainflow.json`(含失败历史和最终成功),最终Pod、资源、镜像及CI快照见 `k8s189-deployment.json`。示例保持在线,未为清理测试而删除业务状态。 + +### 明确保留的事项 + +- 116 等待用户释放资源再做业务验收;本轮未停止其他项目服务,也没有降低保留内存或超卖策略。 +- release CI `#5635`(`d9e6c4d4fe`)核对时 backend成功、frontend运行、自动部署pending;不是整条CI成功。本环境使用已固定的内网原生构建物;只含文档/证据的最终提交使用 Drone 官方 `[CI SKIP]`,避免重复镜像构建,规则见 [Drone文档](https://docs.drone.io/pipeline/skipping/)。 +- 六个托管工作树已取消置顶并申请归档,工具仍只返回queued,路径尚存;不宣称已删除,不强删F086历史草稿。普通工作树清理共九个;当前执行及hotfix工作树在记录提交后按干净/包含性/远端一致性检查清理。 +- 报告24小时有效、测试仓库CA将到期、静态Retain临时卷需按停止证据人工回收等安装运维限制继续有效。未扩展为生产高可用、全离线/双架构正式发行或完整SDD全AC验收。 diff --git a/docs/reviews/vibe-runtime-review-design-review-2026-11-02.html b/docs/reviews/vibe-runtime-review-design-review-2026-11-02.html new file mode 100644 index 0000000000..7676cd317a --- /dev/null +++ b/docs/reviews/vibe-runtime-review-design-review-2026-11-02.html @@ -0,0 +1,1012 @@ +F115 设计与自审评审稿
设计已确认 · 本地开发完成,待部署验收

2026-11-02 · 基线 497256d28 · 需求发现与正式规格均已确认 · 尚未修改业务代码

52 条 AC 已确认6 项设计决策24 项设计清单自审通过设计已确认,代码本地实现

Design:F115 应用运行信息、审批当前可见范围与知识库创建

+

关联:已确认 spec · 已确认 discovery。
+版本:v3.0.0;发版线 feat/3.0.0。
+最后更新:2026-11-02。
+状态:用户于 2026-11-02 单独确认设计;代码与本地定向验证已实现,真实部署 E2E 尚未执行;52 条规格已确认。本文区分固定基线现状、实施接线和远端部署,不将方案写成已部署事实。
+源码基线:497256d289793ce016df07cf8ab38f32988faca4,工作分支 codex/vibe-runtime-review-20261102。116/120 只读环境证据沿 discovery,不在设计阶段更新环境。

+

1. 目标与非目标

+

补齐当前实例可观测事实,明确模型调用用户筛选空态,在原审批阅读资格内显示当前有效访问授权,并统一本租管的新建知识库资格。沿用现有运行、权限、审批和知识库领域,不新增并列授权规则。

+

非目标沿 spec:不增加监控系统、不恢复 v1 控制、不改现有应用的 OBO 接线、不回填审计或历史审批、不迁移资源、不补写角色菜单和授权;知识库复制、发布审读/预览及其它平台能力不扩权。

+

2. 关键约束

+

遵循 constitution C1–C8、release-contract 和前后端各自 AGENTS;全局铁律不在本文重抄。

+
    +
  • 两个时间面:版本、发布说明、声明及审批决定属于冻结内容;可见范围是本次查询的当前授权,只作只读装饰,不写入历史快照。
  • +
  • 当前执行体:必须同时匹配应用、节点、操作和执行引用;K8s 额外校验 Pod UID,不能按名称猜测或选第一个容器。
  • +
  • 只读观测:复用 v2 执行器现有观察和应用探活;Docker 保持现有单次 HTTP 探活的 2 秒超时及判定口径,不增加后台任务、定时器或全新探活请求入口。
  • +
  • 完整性优先:只有完整授权与对象资格查询成功才能产生 owner_only=true。投影不就绪、目录服务失败、结果超过本次可完整读取的界限均返回显式不可确认,不输出未经确认的部分名单。
  • +
  • 有界读取:权限来源按既有 500 条游标页读取,本次摘要最多检查 5,000 条来源、最多 5 秒;任一界限触发则整体 unavailable,不把截断后的剩余对象当作不存在。该预算仅用于当前摘要,不改变 F048 通用完整授权目录。目录批量解析,不逐成员展开。
  • +
  • 创建资格不等于管理视角:新资源仍落实际自然人与其当前所属租户。新建表单固定打开时的所属租户,实际写入前重验,管理视角不会覆盖此值。
  • +
  • 不新增数据库表或列、配置顶层键、队列、权限动作、运行协议版本或监控依赖。MySQL/DM8 通过既有持久层与 dialect helpers 保持兼容。
  • +
+

3. 方案对比与选定

+

D1. 用现有执行器观测补齐运行事实

+
    +
  • 备选:只转发 Docker 原生 Health;从历史 AppInstance 补字段;新建定时采样系统;复用现有执行器观察并统一只读状态返回。
  • +
  • 选定:最后一种。在 Docker/K8s observe 的结果中补充当前应用容器的计数、启动时间和探活状态;v2 只读状态端点统一转换,后端仅保留公开字段。
  • +
  • 原因:116 的容器有实际启动时间但未设置原生 HEALTHCHECK;基线状态端点漏转时间,而执行器已有实际 HTTP/Ready 观察。历史 AppInstance 回退可能把旧实例信息当成当前值;新增采样系统超出 D1。
  • +
  • 重新考虑条件:后续明确要求长期监控、历史趋势或改变现有探活判定时,另立需求,不借本次显示修复改变发布就绪门槛。
  • +
+

D2. 模型用户查询保持原合同,只补空态与范围切换

+
    +
  • 备选:用负责人补用户;返回全租户人员;新增历史回填;保持真实记录查询并修前端状态。
  • +
  • 选定:保留现有候选和记录接口、范围校验、分页及真实 user/natural_person 过滤;使用现有共享 Select 的 emptyText/noMatchText/loadingText,在业务层区分失败和重试。
  • +
  • 原因:116/120 当前无应用限制时已返回真实用户候选,截图应用只有应用自身调用;未复现候选接口整体为空。补负责人会改变历史身份事实。现有 Select 已具备相应文案入口,无需改共享控件或候选数据结构。
  • +
  • 重新考虑条件:后续需要真实用户姓名快照、更多身份筛选或新的 OBO 数据采集,单独评估来源和历史兼容;不由本功能推测。
  • +
+

D3. 审批校验之后添加当前摘要,不覆盖冻结快照

+
    +
  • 备选:重建 detail_snapshot;让客户端调用通用授权列表;为审批人临时赋 manage_permission;在原详情读取通过后添加独立的 current_visibility。
  • +
  • 选定:最后一种。审批服务先完成原阅读校验,再经现有场景 Handler 工厂调用应用发布场景的只读补充方法;其它场景不增加业务行为。新字段与两个历史 snapshot 同级。
  • +
  • 原因:基线任务、个人实例及管理实例详情均直接返回保存的 snapshot。直接覆盖会让冻结内容与当前事实混淆;通用授权列表要求长期 manage_permission,不适合已确认的窄审批例外。
  • +
  • 重新考虑条件:另有场景提出相同的动态展示需求后再评估共性。本次只增加一个可选场景补充钩子,不建立新的审批视图框架。
  • +
+

D4. 当前范围读取 F048 有效动作来源,身份目录由业务侧补充

+
    +
  • 备选:按 model 名称/level 判断;业务侧查询 Grant/OpenFGA;枚举全租户每个人做 Check;由 F048 提供可信服务端有效动作来源读取,再批量核对对象资格。
  • +
  • 选定:最后一种。权限应用端口提供目标资源 use 的有效来源;复用 CURRENT Catalog、当前投影和继承规则。现有租户主体目录新增批量只读解析,返回有效、已失效、不可确认三类结果和显示名称。
  • +
  • 原因:已有可信服务端 list_effective_direct_user_ids_by_model 说明来源读取不必借管理页身份,但它只支持直接人员和指定模型,不能直接满足本功能。通用 list_grants 的管理门禁保持;新增窄读取不提供 HTTP 入口、不伪造 owner/超管。
  • +
  • 重新考虑条件:权限模块正式提供同等语义的完整 action 来源公共协议时改用该协议;不保留两套来源判定算法。
  • +
+

D5. 仅放开知识库新建,不修改全局创建菜单

+
    +
  • 备选:给租管批量加菜单;将 create_knowledge 全局并入租管菜单;仅改按钮;知识库新建使用独立资格策略并复用原创建流程。
  • +
  • 选定:最后一种。前端区分 canCreateLibrary 与 canCopyLibrary;后端 Web 新建入口执行本功能的创建资格策略,复制仍保留原菜单及源资源读取门禁。
  • +
  • 原因:两类知识库页面当前将新建资格复用于复制;后端 create/copy 都要求原菜单。单改页面或全局加菜单会分别导致继续拒绝或顺带扩大复制权限。
  • +
  • 重新考虑条件:以后明确要求统一所有资源的创建/复制能力时再做菜单策略调整,不把本次例外推广到其它资源。
  • +
+

D6. 复用真实 Web 身份校验,并把表单租户作为前置条件

+
    +
  • 备选:信任登录响应的 is_child_admin;按页面选中管理租户写入;修改共用 KnowledgeCreate 的所有调用者;Web 新建请求携带表单预期租户,服务端按当前真实身份检查。
  • +
  • 选定:最后一种。仅 Web create 请求增加可选 expected_tenant_id;两个新建对话框均固定发送打开时的 leaf_tenant_id。服务器不把该参数当作目标租户,只用于与已验证所属租户比较;写入者仍为真实登录自然人。
  • +
  • 原因:JWT/表单可能跨越人员迁租户和资格变化,页面管理视角又不同于人员归属。已有 user.application.runtime_identity.resolve_web_runtime_identity 可验证多租户下的人员、唯一有效租户及会话版本;新建用途在同一应用模块增加薄入口处理单租户,不改延迟运行任务原有严格合同,不新增身份系统。
  • +
  • 兼容:旧请求缺少该字段时,以其经验证 JWT 租户作预期值,仍须与实时身份匹配;新页面显式固定预期值,可拒绝表单打开后换会话/换租户的提交。共享知识库请求及开放 API 不因此新增菜单门禁。
  • +
  • 重新考虑条件:未来正式支持跨租户代创建,须另审创建归属和防陈旧合同,不能放宽此处比较。
  • +
+

4. 固定基线与本地实施接线

+

4.1 固定基线现状

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
链路497256d28 的真实接线缺口
运行状态app_query_service.get_instance → orchestrator_client.status → v2 platform_observe.status → 当前绑定检查Docker 只读原生 Health;两形态漏启动时间;缺失计数默认零;后端找不到执行体时回退旧 AppInstance
Docker/K8s 观察executors/docker.py::observe 已发单次应用探活;executors/kubernetes.py::_observed 已核对 Pod Ready 与 EndpointSlice UID/IP返回未覆盖完整显示事实;K8s 找不到 app 状态时求和为零
模型用户候选runtime_audit_query_service.candidates → runtime_audit_repository.statement/candidates;API candidates/visitor?kind=model数据范围与真实身份过滤已存在;Select 使用通用空文案,失败提示与空列表可能同时出现
审批详情approval_center_service.get_task_detail/get_instance_detail 先校验资格,再返回 detail_snapshot/payload_snapshot;app Handler 仅复制空的 visibility_snapshot缺当前授权展示,客户端仅解析冻结 snapshot
权限来源F048ResourcePermissionApi.list_grants 和 runtime list_permission_sources_page 要求 manage_permission;control_state.load_source_page 处理 CUSTOM/INHERIT 与 CURRENT 来源不能让普通审批阅读者直接调用通用 roster;无按 action 返回当前人员/组/部门摘要的端口
对象目录TenantPermissionSubjectDirectory.canonical_source/display_names 已验证或显示对象现有人员 canonical 校验关注有效成员,不能直接当作“人员未停用且名称完整”的完整摘要证明
知识库knowledge.create_knowledge 先断言菜单,再调用同步创建;Doc/QA 两页面相同条件控制新建和复制租管路由可见与新建资格不一致;表单没有固定创建租户前置条件
+

基线注释中的历史 owner-only 或长期 v1 说明不能覆盖 F114 当前合同。orchestrator_client.status 中仍出现 /v1/ 字符串,但 _request/_v2_request 强制 v2 并改写路径,不能据此重新接通 v1。

+

4.2 运行状态:沿当前绑定观察

+

发布页既有实例查询 → 原 F114 状态阅读门禁 → 后端只读状态 RPC → 当前绑定校验 → 执行器 observe → 统一事实 → 显示。

+
    +
  1. platform_observe.status 只读加载当前 app_id/generation/operation_id/exec_ref/node_id。Docker 校验 app、操作、节点及 v2 管理标签;K8s 校验应用、操作、节点、Pod UID 和应用容器名 app。
  2. +
  3. 将显示所需事实补入执行器观察结果。Docker 从同次精确容器 inspect 取得 State.StartedAt 和 RestartCount,健康仍由已有应用 HTTP 探活形成;原生 Health 可作诊断,不能替代统一的应用就绪语义。K8s 仅从匹配 app 的 containerStatuses 取得重启与当前 state.running.startedAt,当前状态为 terminated 时可取其 startedAt;不读 initContainer 或 lastState 冒充当前。
  4. +
  5. observe 原 ready/running/target 语义供生命周期继续使用;新增 probe_state 区分已成功、已失败、尚无结果,不改变原 <500 的 HTTP 判定和发布就绪规则。探活返回失败和探活基础设施不可访问是不同结果;无应用地址、状态缺失不得默认为探活成功。
  6. +
  7. 状态返回前再次确认绑定的 generation、operation、exec_ref(K8s 含 UID)未变化;变化则报告暂不可确认,刷新再读,不能混合新旧事实。不修改平台绑定或尝试自愈。
  8. +
  9. Docker Restarting、K8s Pending/ContainerCreating 或当前操作 starting 且没有完成判定时显示启动中。已有明确探活失败则为异常;Ready/探活成功为正常;缺少有效判定为未知。执行体已停止时 phase 为 stopped,不显示仍健康。
  10. +
  11. 无绑定与绑定的执行体丢失是正常可解释的两种只读结果,见 §4.5。调用错误、归属不匹配、平台/集群不可用走原 16121 故障链。后端 get_instance 不再用旧 AppInstance 的健康、计数、启动时间补“当前值”。
  12. +
  13. 启动时间统一输出带时区的 ISO-8601 UTC;空值、Docker 未启动零日期、无效时间为 null。计数只有明确的非负整数才有效,布尔/缺失/非法值为 null;真实 0 保留。前端先验证时间,再复用 displayTimestamp.formatTimestamp 转浏览器本地时区,不改全局时间工具。
  14. +
+

4.3 访问用户:只改状态与查询范围生命周期

+

当前具体租户与筛选条件 → 原候选 API → 真实自然人记录候选 → 选择后原记录 API。

+
    +
  • CandidateFilter 继续通过 controllers/API/runtimeAudit.ts 获取候选,不新增数据库查询、候选字段或历史身份来源。
  • +
  • 空搜索 + 查询成功 + 空候选:显示“当前范围没有访问用户记录;应用自身调用不代表自然人访问”。有搜索 + 成功空候选:显示无匹配用户。加载中使用 Select loading;失败时使用失败文案和重试按钮,空提示不得同时宣称无记录。
  • +
  • emptyText/noMatchText/loadingText 覆盖该过滤器,不改变共享 Select;真实 ID 原样用于过滤,不把 app/self ID 转成人名。
  • +
  • kind/具体租户/应用/时间等范围变化后重置该过滤器搜索与不再适用的 visitor 选择;请求 key 含完整新范围,不使用旧范围 placeholder 数据。读取途中切换范围的旧响应只能留在旧 key,不回写新状态。
  • +
  • 候选继续使用后端的同一授权范围及其它过滤条件;访客候选无需扩展成全租户人员。保留现有分页,列表和导出仍使用原冻结筛选合同。
  • +
+

4.4 审批摘要:先取得阅读资格,再读取当前授权

+

个人任务/个人申请/管理详情 → 原审批阅读校验 → app 发布 Handler 只读补充 → 业务核对 app/tenant → F048 action 来源 → 业务目录核对 → current_visibility。

+

A. 原阅读门禁与绑定

+
    +
  • 在 get_task_detail、get_instance_detail 的原租户、任务/申请归属或管理资格检查之后调用补充方法。失败的原鉴权不得进入补充逻辑,不能用 app.publish 代替审批阅读资格。
  • +
  • 通过 approval_runtime_handler_factory 为 app 场景取得新 Handler,调用新增可选 read_current_detail;无此方法的其它场景不增加任何查询。注册和调用均在服务层,不在 endpoint 跨模块导入。
  • +
  • 审批模块创建进程内的已验证阅读上下文,包含 instance、scenario、tenant、business_resource_id、当前阅读者和读取用途;不是 HTTP 可提交的对象,不存储凭据,不注册新对外 endpoint。
  • +
  • 发布场景的新增 app_publish_current_visibility_service.py 从该上下文取得应用 ID,核对保存的业务资源绑定及当前应用租户;若 payload 存在 app_id,必须与业务绑定一致。审批申请人、客户端传的 app_id 或历史 owner 名称不能替代绑定检查。
  • +
  • 正常目标由 app 业务适配器生成 VerifiedPermissionTarget;不伪造负责人或超管 actor。个人审批沿所属租户;管理详情仅允许已通过原管理门禁的具体租户。应用删除单独映射为 deleted;跨租户/绑定不一致不返回对象名单。
  • +
+

B. F048 的窄只读来源端口

+
    +
  • 在 permission.application 增加 read_effective_action_sources(target, action) 的可信服务端端口,内部委派 F048 runtime。它不替调用方判断审批资格,不接受客户端“已授权”标记;仅供已验证业务目标和前置业务阅读资格后的服务端调用,首个调用点限定为上段应用发布补充服务。
  • +
  • 复用 runtime 当前 Catalog/Store/model pin、_require_current_target 和权限内部 load_source_page;选取当前有效且 action_codes 含 use 的模型,不按名称或 level 推断。每页读取在权限持久层完成,业务方不查询 Grant 或 OpenFGA。
  • +
  • CUSTOM 取本地有效来源;INHERIT 复用既有最近自定义祖先来源与本资源受保护创建者语义,不引入第二套继承逻辑。目前 app 固定 CUSTOM,仍为可复用端口保留现有继承合同的配对验证。
  • +
  • 必须验证目标及实际涉及祖先投影 CURRENT、版本一致,并在完整分页结束后复核版本与 Catalog 未变化。PROJECTING/FAILED_CLOSED、游标不前进、未知模型或不完整来源均不能变成完整空集合;超预算返回不可确认。
  • +
  • 通用 list_grants/list_permission_sources_page 的 manage_permission 保持原样。新端口不包含授权写入、完整 model/Grant 元数据、编辑能力或来源秘密,不提供给通用 HTTP roster 使用。
  • +
+

C. 名称与有效资格

+
    +
  • 经现有 get_grant_subject_directory 取得业务侧目录,由 TenantPermissionSubjectDirectory 增加批量只读解析方法。新增 SQL 只放所属领域 Repository,权限模块仅依赖应用协议,不导入用户/部门/应用 ORM。
  • +
  • 多租户开启时人员必须未停用且只有一个有效当前人员租户,与应用租户一致;关闭时按有效自然人/tenant=1 语义验证,不要求额外成员行。user 应用模块提供批量 read_current_person_facts(user_ids),只输出当前人员事实,不要求也不制造这些人员的会话;Web 创建身份入口复用这些事实并额外验证真实会话。已明确失效的人员来源排除。部门/组需确认仍存在且属于该租户;批量读取失败、人员所属不能唯一确定或无法确认名称时,整个摘要 unavailable。
  • +
  • 组与部门保留 userset 含义:部门可注明含下级,用户组可注明仅管理员;不展开成员,也不承诺组内每个人都可以访问。按 (type,id,userset_relation,include_children) 去重,不重复同一来源的显示。
  • +
  • 服务账号来源跳过普通访问人员列表;管理员隐式访问只显示统一说明。发现当前合同之外、不能用人员/部门/组准确表达的有效来源(如历史 tenant 来源)时不得丢弃后宣称 owner-only,返回不可确认并记录安全原因。
  • +
  • 负责人当前资格使用真实当前人员和 F048 use 判定,不仅看 protected creator 行或历史快照;该人员失效则不补列“负责人可见”。构造其 PermissionActor 时只使用该人员当前有效成员、真实全局/租户管理员事实(原身份应用入口及 is_tenant_admin 查询),不调用可能返回当前请求阅读者缓存的 resolve_permission_actor 来代验另一人;也不临时给予管理员字段。单独查询负责人事实不等于代其执行业务。有效 owner 若不在普通来源列表则仅为展示去重补入,不补写授权。
  • +
  • 全部成功后才计算 owner_only:负责人有效,且不存在负责人以外的有效普通访问对象。完整成功但负责人无资格且无其它对象显示“当前没有有效普通访问对象”。
  • +
+

D. 失败隔离与页面

+
    +
  • detail_snapshot/payload_snapshot 原样返回;当前摘要是独立字段。过往空占位不回填,后续新审批也不把当前范围写入冻结快照。
  • +
  • 只读补充是明确的非关键展示步骤:其内部依赖异常记录完整服务端异常并返回 sanitized unavailable,不吞成成功空名单;原阅读鉴权和场景绑定检查不放进该降级捕获。未知程序异常同样留错误日志,前端看到不可确认,不泄露异常正文。
  • +
  • 客户端 api/approval.ts 扩展详情类型,AppPublishDetailPanel 只从 current_visibility 读取该区域,其余区域继续读 detail_snapshot;字段缺失的旧服务端显示“暂不可确认”,不回退旧快照冒充当前。
  • +
  • 打开详情重新查询,提供当前详情刷新回调;终态也读当前摘要。平台异常列表已有管理详情在 ExceptionPanel/useApprovalExceptions 中缓存,补充只读区域并在展开/刷新时重读当前管理详情,缓存按具体租户与 instance 隔离,不将终态缓存永久视作当前。
  • +
  • client/platform 各自使用本应用 API 包装与组件组合,不跨 SPA 复制状态或直接导入另一应用组件。平台只新增小型摘要区域,不复制整个客户端审批卡。
  • +
+

4.5 关键对外字段

+

实例状态(原 GET /api/v1/apps/{app_id}/instance,新增/收窄字段含义)

+
{
+  "instance_id": "current-execution-ref",
+  "phase": "running",
+  "health": "healthy",
+  "restart_count": 0,
+  "started_at": "2026-10-30T10:33:15.363Z",
+  "last_probe_at": "2026-11-02T02:00:00Z",
+  "observation_state": "current"
+}
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
字段类型与约束消费规则
phase沿既有 pending/building/starting/running/unhealthy/stopped/failed应用业务状态不由此改变;观察失败不伪装已删除
healthhealthy / unhealthy / starting / nullnull 显示未知;非法旧值也显示未知,不原样露英文
restart_count非负整数或 null类型改为 nullable;禁止 ?? 0
started_at/last_probe_at带时区 ISO-8601 或 null无有效值显示未知/尚未启动;不输出零日期或用当前查询时刻冒充启动时间
observation_statecurrent / no_execution / execution_missingno_execution:无绑定,phase stopped、instance_id null、三项事实 null;execution_missing:绑定有引用但执行体丢失,phase unhealthy、保留引用、事实 null。真正依赖故障返回错误
+

manager /v2/apps/{app_id}/status 使用相同语义,保留内部 app/generation 信息,后端白名单只增 observation_state。AppNotFound 回退不再读取旧实例事实;新 manager 对“无执行体”给出上述明确结果,旧 manager 的模糊 404 只显示无法确认,不能恢复历史事实。旧服务端缺少 observation_state 时新前端将三项事实作为尚未确认的新合同信息显示未知,不把旧默认零值或历史回退当作已验证当前事实;应用业务状态仍独立显示。

+

审批详情的同级增量

+
{
+  "current_visibility": {
+    "state": "available",
+    "checked_at": "2026-11-02T02:00:00Z",
+    "owner_only": false,
+    "owner": {"id": "849", "name": "负责人示例", "eligible": true},
+    "subjects": [
+      {"type": "user", "id": "849", "name": "负责人示例"},
+      {"type": "department", "id": "60", "name": "部门示例", "userset_relation": "subtree_member", "include_children": true},
+      {"type": "user_group", "id": "12", "name": "用户组示例", "userset_relation": "admin", "include_children": false}
+    ],
+    "admin_implicit_access": true,
+    "reason": null
+  }
+}
+
+
    +
  • state=available 仅代表完整确认;owner_only 为布尔。owner 是当前负责人信息,不能从冻结名称推断有效资格。
  • +
  • state=deleted|unavailable 时 owner_only=null、owner=null、subjects=[];空数组不能在前端解释成仅负责人。reason 仅为固定安全值:application_deleted、application_unavailable、authorization_unavailable;不返回 SQL、FGA 响应或身份目录详情。
  • +
  • 新前端始终提示“当前可见范围,授权变更即时生效,不影响审批”及管理员隐式资格说明。已有审批同意/拒绝按钮、审读及预览入口继续遵循原能力,不由当前摘要推导。
  • +
  • JSON 示例只是结构示意,不包含真实凭据或对示例 ID 的权限承诺。
  • +
+

其它接口

+
    +
  • GET /api/v1/app-runtime-audit/candidates/visitor?kind=model:请求/响应不变,仍为 items[{id,label,deleted_or_revoked}],has_more;记录与导出合同不变。
  • +
  • POST /api/v1/knowledge/create:Web 专用请求在既有字段外增加可选正整数 expected_tenant_id。它不是落库字段,不接受由此指定任意租户;Doc/QA 两对话框均发送打开时的所属租户。
  • +
  • 三个审批详情入口:/api/v1/approval/my-tasks/{task_id}、/api/v1/approval/instances/{instance_id}、/api/v1/approval/admin/instances/{instance_id} 只增加同级字段,不改原 snapshot;已核对 approval_user.py 的 router 与路径,不新设别名。
  • +
+

4.6 知识库新建接线与写入前重验

+

Doc/QA 表单固定所属租户 → Web create endpoint → 当前 Web 身份/创建资格 → 原校验 → 写入前再验 → 原创建/投影/索引流程。

+
    +
  1. 新增 knowledge_creation_policy.py,作为 Web 新建专用领域资格策略,消费真实登录会话的服务器引用,并调用 user.application.runtime_identity 新增的 resolve_web_creation_identity 薄入口。多租户开启时复用既有 resolve_web_runtime_identity 的唯一有效成员与租户校验;关闭时由同一 user 应用模块按有效人员、会话期限及 token_version 验证并固定 tenant=1,不要求旧单租户人员凭空补一条 UserTenant。新增读取仍在 user 所属 Repository,不放业务 SQL 到策略服务。最后比较 expected/JWT/实时所属租户;身份不确定、租户停用或会话失效按原身份错误拒绝,既有 runtime 身份函数语义不改。
  2. +
  3. 同一真实自然人需具备以下之一:原有效超管/创建菜单资格;或当前经 permission.application.is_tenant_admin 确认的本租户管理员资格。部门管理员和界面 is_child_admin 不成为后端授权来源。依赖故障不能静默授予租管例外。
  4. +
  5. 新建资格传递的是服务端创建的短生命周期上下文(人员/所属租户/会话版本/预期租户),不是持久对象或客户端 capability。endpoint 调用已有异步 KnowledgeService.acreate_knowledge,仅 Web 路径携带此上下文。
  6. +
  7. 原名称、embedding、租户与其它输入检查后,在 acreate_knowledge 进入 acreate_knowledge_base 之前重验该上下文和当前资格。失败时没有业务行、索引、FGA Grant 或后续任务。开放 API/其它内部调用不携带该 Web 上下文,继续原通道资格,不能被新增菜单检查误拦。
  8. +
  9. 整个 Web 创建使用既有 tenant_execution_scope(verified_tenant) 配合 strict_tenant_filter(),退出即恢复 ContextVar,不能把它误写成接受 tenant 参数的 strict 过滤函数。用户 ID 与租户 ID 仍由服务器覆盖为已验证自然人及其所属租户;客户端 user_id、管理范围和预期租户不能成为写入 authority。继续原创建者投影、失败补偿、索引初始化和审计;不添加菜单或角色。
  10. +
  11. 前端页面 canCreateLibrary=原有菜单/超管资格 OR 当前 is_child_admin;canCopyLibrary=原有菜单/超管资格,再叠加现有源读取条件。表单在打开时固定当前 leaf_tenant_id,不是 useAdminScope 的租户;该值尚未加载时不提交,刷新身份信息,只有服务端确认单租户模式时才固定 1。资格变化时按钮可能等待用户信息刷新,但实际写入始终拒绝过期资格。
  12. +
  13. expected 与当前所属租户不一致使用 §6 的专用前置条件错误,提示刷新表单;不要误用管理视角错误或悄悄改写到新租户。
  14. +
+

4.7 模块职责与影响面

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
现有/拟新增模块做什么不做什么
runtime-manager executors/*、api/platform_observe.py精确执行体观察、nullable 事实及统一状态不恢复控制 v1、不触发修复、不持久化第二份应用主数据
backend app_query_service.py、orchestrator_client.py原阅读门禁、公开字段、区分故障和无执行体不调用 Docker/K8s SDK、不回填旧实例值
platform PublishTab、hostedApp.ts状态、未知、零与有效时间展示不按 UI 猜测健康、不改变全局时间语义
platform RuntimeAuditPanel.tsx原查询、独立状态提示与范围重置不改历史账本、SDK、身份推断或共享 Select 视觉
approval 服务与 Handler 工厂原鉴权之后分派只读补充不认识 F048 Grant 或修改审批决定/路由
拟新增 app_publish 当前摘要服务核对 app/tenant/instance 绑定、协调 F048 来源与目录、输出小 DTO不写 snapshot、不授予权限、不直接查权限表
permission.application 端口与 runtime/control_state现有 Catalog/投影/继承下的 action 来源与完整性不依赖审批、用户或应用业务 ORM,不暴露新的 HTTP roster
tenant 主体目录及所属 Repository批量当前对象资格与名称不展开所有成员、不替代 F048 动作判定
knowledge Web 创建策略及原异步创建流程当前身份/租户和创建资格、写前重验不更改复制/开放通道资格、角色菜单或资源归属
user.application.runtime_identity 与所属 Repository复用当前人员事实,新增批量读取和 Web 新建用途薄入口;兼容关闭多租户不新增身份来源、不为负责人制造会话、不改变原运行/预览严格入口
client 审批面板 / platform 异常详情各自渲染当前范围与重查;其余字段仍冻结不在两个 SPA 之间混用状态、API 包装或页面组件
+

三语键分别落各应用现有 locale;新增业务错误对应 packages/locales/src/api_errors/{zh-Hans,en,ja}.json。实施时同步 approval-module skill 的详情 API 和场景补充接线说明,不更改审批人解析规则。

+

4.8 本地实现补充(2026-11-02)

+
    +
  • user.application.runtime_identity.read_current_person_facts 及 resolve_web_creation_identity 已接线;新建用途区分所属租户变化,原 runtime/preview 身份入口未改。
  • +
  • F048 SqlPermissionControlState.source_read_stamp 经仓储读取继承链的模式/Grant版本,read_effective_action_sources 完整分页后再次复核模式与Catalog。业务摘要在名称、负责人资格查完后再读一次来源,变化返回 unavailable。
  • +
  • AppPublishCurrentVisibilityService 由原审批门禁后的 app Handler 调用,超时预算5秒;目标 app/tenant 校验先于删除态和目录读取。DTO 使用 userset_relation(含 subtree_member),两端按成员、管理员、含下级分别显示。
  • +
  • 新建 endpoint 只使用验证过的真实所属租户,async create 在模型和重名检查后、首个写入前重验会话及资格;Web 专用 expected_tenant_id 不扩展共享 KnowledgeCreate。
  • +
  • 管理异常页重复展开、显式刷新均重查,旧异步响应用序号丢弃;管理租户切换沿既有 bisheng:management-scope-changed 全页重载清空内存。客户端真实父层为 ApprovalPane.tsx,刷新仍调用原详情 API。
  • +
  • 知识库表单缺少所属租户时阻止提交并提示刷新重开,不推测为1;服务端单租户 user/info 已有确认的1语义时才可发送1。新建与复制条件已分开。
  • +
  • manager 状态采用显式 no_execution/execution_missing/current;已停止执行体不保留失败探活相位,归属变化或平台资源读取失败不伪装成执行体丢失。K8s专用只读observe_status仅将精确Pod 404识别为丢失;原observe生命周期入口不变,Service/Endpoint等依赖失败仍不可确认。
  • +
  • 未新增表、权限动作或外部依赖。新增10993及三语产物已落地;E2E脚本仅显式开启后访问真实环境,创建另需写入开关,清理校验精确名称、人员和租户。
  • +
+

5. 已知坑与处理

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#事实若忽略会怎样处理处
1有运行容器不等于配置了原生 Health;平台已有独立应用探活继续显示空健康,或把 Running 冒充健康manager observe/status,D1
2K8s app 状态缺失时现有 sum 默认为 0把未观测到数据当成真实零K8s _observed 与 nullable 映射
3后端无实例回退会读旧 AppInstance旧时间和健康出现在已替换实例上app_query_service.get_instance
4v1 字符串在只读客户端内被强制改成 v2为修显示重新接通已退役控制协议orchestrator_client._v2_request,保持 v2
5模型应用自身调用没有自然人 subject;候选目前已有真实数据补 owner 会篡改审计含义审计仓储保持;过滤器补空态
6授权模型名称/level 不等于当前 action_codes误把只有 edit 的对象当访问者F048 action 来源端口
7承载源的投影中间态、分页截断不能当完整空集合错误宣称“仅负责人可见”CURRENT/版本前后校验、有界失败显式 unavailable
8现有 canonical_source 对人只核成员行,display_names 只装饰名称已停用或迁租户人员仍列为有效主体目录新增批量资格解析,不直接复用为完整证明
9草稿/下线不清空授权;负责人可能仍记在历史单但已失去资格首发永远查不到范围,或无效负责人被认定可见app 业务目标与真实 owner use 分离
10部门 subtree、用户组 admin 与整个对象成员含义不同文案把仅部分 userset 授权夸大成全部成员DTO 保留 relation/include_children,前端标注
11异常进度按 exception ID 缓存详情授权变化后仍永久显示旧范围useApprovalExceptions 按 scope/instance 重查
12新建条件同时控制复制,页面租户也不等于创建归属一次补按钮造成复制扩权或写到所选租户Doc/QA 条件分离,表单 expected tenant + Web 策略
13同步与异步知识库创建、开放通道共用底层创建逻辑将 Web 菜单门禁加到 SDK 或重复创建流程仅 Web 入口带上下文,复用 acreate_knowledge 与原底层
14运行任务身份函数要求有效 UserTenant;旧单租户用户未必有该行复用后把原来可创建的人全拒绝user 应用模块新增用途入口,关闭多租户时固定 1;原 runtime 严格合同不改
15resolve_permission_actor 优先返回当前请求 actor查负责人时错误使用阅读者的超管/普通身份负责人按自身事实构造 actor,不能借请求缓存代验
+

6. 对外契约与依赖

+

6.1 提供与兼容

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
提供项形式/消费者兼容与限制
当前运行事实原 manager v2 status、平台 instance API字段增量;计数 nullable。manager/backend/frontend 一起升级后才算运行信息交付;旧字段缺失表达未知,不保留错误回退
current_visibility原三个审批详情,client 面板和 platform 管理详情同级增量;旧客户端可忽略,但不会获得本功能新显示;新客户端对旧后端显示未知,绝不把旧 snapshot 冒充当前
read_effective_action_sourcespermission.application 可信服务端协议需要业务验证目标与既有前置阅读资格;仅摘要用途首个调用点,通用 roster 管理门禁不变
read_current_detailapp 发布 Handler 可选补充只读,非 app 场景没有补充方法时返回无增量;不改 resolver/outbox
expected_tenant_idWeb knowledge/create 请求增量只作前置条件,不是跨租户创建能力;旧调用以已验证 JWT 租户作预期,不改变共享开放 API 请求
知识库 Web 创建策略原 endpoint 与已有异步创建服务不扩大复制与其它通道;前置失败无创建副作用,成功沿原投影/索引/审计
+

6.2 错误与展示状态

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
情形合同
manager/平台/集群不可用或执行归属不匹配复用 AppOrchestratorUnavailableError 16121,保留安全 reason;不转换成无应用
原审批无阅读资格原 ApprovalRequestPermissionDeniedError 18101;在任何摘要查询前拒绝
授权投影/目录失败或摘要超预算当前区域 state=unavailable 与固定 reason;日志留异常,冻结详情保持;不新增独立错误码
应用已删除当前区域 state=deleted,保留合法可读历史;不恢复应用
当前 Web 身份失效/无创建资格沿原身份及权限错误;前端统一请求层处理
新建预期所属租户与实时身份不一致拟新增 KnowledgeCreationScopeChangedError 10993,文案明确所属租户已变化,请重新打开表单;本次基线全部错误码扫描未占用。仅此一条新业务码,实施前重扫;若已被其它变更占用,选未占用码并同步本文与三语,不能复用冲突码
+

10993 登记在 knowledge 现有模块及版本契约,不新占模块;/api/v1 沿原业务错误 envelope,不擅自修改全站 HTTP 状态。未新增密钥、凭据、敏感字段或真实账号密码。

+

6.3 依赖与风险

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
依赖当前用途变化风险
当前应用绑定及 manager 执行器当前事实与安全边界exec_ref/UID 失配或旧 manager 不能证明当前值,必须未知/错误,不能降级旧绑定
Kubernetes Pod/Ready/EndpointSlice;Docker inspect 和已有 HTTP 探活精确当前应用容器API 失败不得等同负探活;新的容器名或 readiness 口径需同步观察测试
F048 CURRENT Catalog/Store/model/投影action 与完整来源pin 不一致、重发布/投影中必须不可确认;本功能不运行修复或迁移
主体目录、人员唯一当前成员与租户状态名称、对象资格、创建身份目录失败与已失效不同,不能用名称存在推断资格;业务层批量解析保持租户上下文;单租户关闭模式固定 1,不要求额外成员行
审批个人与管理阅读门禁窄只读例外前置资格不能将摘要服务直接暴露到只知道 ID 的请求,也不把 admin 进度资格变成同意权限
原知识库异步创建及投影补偿、embedding 回退复用实际创建不复制创建算法;新策略不得影响开放 API 默认模型或复制路径
共享 Select / 各应用现有 API、i18n、时间工具文案与交互不改共享组件视觉、不新增状态库;两个 SPA 不混用,三语同时交付
MySQL/DM8、OpenFGA、Redis、现有索引服务原平台依赖无新增服务或本地权威存储,无 DDL;DM8 兼容由审查及中央环境回归验证,不伪称本机已实测
+

7. 定向验证与可观测

+

7.1 本地验证策略(实施后执行)

+

只覆盖本功能改变的行为和具体风险,不运行仓库全量测试。新增 backend 测试放所属 test/<module>/;没有业务代码的设计阶段不跑业务测试。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
层必要验证
manager 单元/接口Docker 有/无 Health 的真实探活、启动/异常/未知、0/正数/null、未启动日期;K8s app/init 区分、Ready/Endpoint UID、缺 containerStatuses;停止/丢失/切换绑定与查询无写副作用
backend 状态只读字段和 nullable、依赖错误 vs 无绑定、拒绝旧 AppInstance 数据回退、原状态阅读权限不变
审计后端/前端原真实身份/授权范围配对;app-self 与人混合时候选/筛选;空、搜索空、加载失败重试;切租户和过滤条件后的旧响应不污染
permission/目录use 动作与 model 名称分离、直接与继承、projection/catalog 切换、分页完整性/预算、停用迁租户、对象失效/服务失败、userset 不展开、未知 tenant 来源不误报 owner-only
审批服务与页面先鉴权后补充;有任务无长期 manage_permission 可读摘要但通用 roster 拒绝;进行中/终态、个人/管理三入口,owner-only/多类对象/负责人失效/删除/故障;snapshot 深度相等、其它三个审批场景不增加摘要调用
知识库无菜单租管 Doc/QA 成功;普通无菜单拒绝/有菜单成功;部门管理不新增资格;新建与复制分离;迁租户/停用/撤资格/会话变化前置拒绝;关闭多租户且无成员行仍保持原创建资格;原 embedding/索引与创建者投影不绕过
三语与静态变更文件 lint/typecheck/相关组件测试、三语键及错误码冲突检查;不因修文案引入全量回归
+

后续测试命令以明确文件选择执行,例如:

+
# 在 src/backend 中,仅指定本功能新增/受影响用例;不传整个 test/。
+uv run pytest test/app_runtime/test_app_query_service_entry.py test/app_governance/test_runtime_audit_query.py
+# 新增的摘要、Web 创建资格用例在任务阶段列出精确文件名。
+# manager 在 src/runtime-manager 中选择 v2 状态与 K8s 观察用例,不跑已退役 v1 测试作交付证明。
+python -m pytest tests/test_operations_v2.py tests/test_k8s_observe.py
+
+

这些是待实施验证入口,不是本轮已运行的记录;最终命令、环境、数量和失败基线记 verification。

+

7.2 必需真实 E2E 与操作步骤

+

真实验收仍是 SDD 必需步骤,但本轮只确认设计,不自动授权环境服务更新或业务写入;镜像、质量门禁及账号准备后按用户另行授权执行一次本功能定向验收。

+
    +
  1. 核对 116 的 backend/worker/platform/client 与 runtime-manager 实际镜像和本次提交;由用户授权更新相关服务后再测,不将旧 F114 manager 当作新状态合同的证明。
  2. +
  3. 使用专用 e2e-f115 人员、租户、应用与可清理知识库,不修改 gzx01 或三个存量应用归属;凭据经安全环境注入,会话仅进程内。
  4. +
  5. 在 [116:3001]/build/apps/{test_app} 核对健康、重启次数、浏览器本地启动时间与精确容器事实一致;用专用应用验证停止/无执行体;K8s 用具备同版本部署的专用环境验证,未授权或未部署则标未验,不冒充 116 已验证双形态。
  6. +
  7. 在 /log 的模型调用查询中,用真实 OBO 自然人调用与应用自身调用配对;检查候选、过滤与切范围,用无记录范围验证提示,不修补现有示例应用或历史日志。
  8. +
  9. 在 /workspace/settings/my-tasks、my-requests 及 /approval 的测试审批详情,依次读取仅负责人、增人员/部门/组、撤销/失效;刷新后变化但版本/说明/声明/决策快照不变。用有阅读资格但无 app.manage_permission 的人员做正向摘要和反向通用 roster 验证。
  10. +
  11. 本租管无创建菜单时在 /filelib 新建文档和 QA 库;源读取和创建菜单不足时复制仍拒绝。专用人员表单打开后迁租户或撤资格,提交不产生旧/新租户资源;普通有/无菜单及超管归属规则配对。
  12. +
  13. 清理仅本轮已创建的测试对象,并记录未清理原因;不停止、迁移、重建无关实例。审批引擎其它场景仅做本次补充钩子“不调用”合同验证,不改通用 resolver,因此不跑无关全场景业务回归。
  14. +
+

7.3 日志与发布核对

+
    +
  • manager 记录 app/exec_ref/operation/node/UID、观察分类及安全失败原因,不记录应用环境变量值。
  • +
  • app 发布摘要日志记录 instance/app/tenant、检查结果/耗时、来源数与安全 reason;失败带异常,不输出完整授权目录到日志。不得把当前摘要日志作为历史授权变更审计的替代品。
  • +
  • 知识库拒绝记录人员/预期租户/经验证所属租户及失败类型;成功继续现有创建审计,不记录会话引用的 token 或密钥。
  • +
  • 发布次序:先上能识别“新字段缺失=未知”的兼容 platform/client,再上 manager 与 backend;避免新 backend 的 null 计数被旧 UI 的 ??0 显示成零。知识库 UI 与 Web create 增量配套,全部镜像到位才进行交付验收。无 DDL、无数据迁移;回滚代码不会重写数据,但旧 UI 不提供本次新展示,旧后端不会识别新资格。部署和回滚均须明确服务范围。
  • +
+

7.4 AC 追溯

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
规格范围设计落点
AC-01–AC-10D1、§4.2、§4.5 实例状态、§7.1 manager/backend
AC-11–AC-21D2、§4.3、§7.1 审计
AC-22–AC-27D3/D4、§4.4 A–C、§4.5 当前摘要
AC-28–AC-31§4.4 C–D、§4.5 available/不可确认区分与冻结保持
AC-32–AC-38§4.4 A/D、§6.1、§7.1 三入口和反向权限验证
AC-39–AC-44D5/D6、§4.6、§6.2、§7.1 写前资格
AC-45–AC-50D5、§4.6 原菜单/复制/通道/模型保持
AC-51、AC-52§2、§4.7、§6、§7.1–§7.3
+

8. 不投入项与重审条件

+
    +
  • 长期监控、重启历史趋势、统一探活门槛:本轮只补当前事实;后续有明确运维需求再立项。
  • +
  • 用户姓名审计快照和历史访问者回填:现有日志没有事实就不造数据;新身份采集由独立 SDK/OBO 需求推进。
  • +
  • 超大授权范围的完整分页审批浏览:本次超过预算表达不可确认,不增加新通用目录 UI;真实遇到大规模场景再审只读分页与身份一致性。
  • +
  • 资源迁移/移交、管理员代创建到任意租户、知识库复制扩权:均未授权,不作为创建异常的兜底。
  • +
  • 通用审批 resolver、路由、outbox 与其它场景的当前授权展示:不在本次改动;若未来修改,需要按审批 skill 重新界定跨场景验证。
  • +
+

修订历史

+ + + + + + + + + + + + + + + +
日期改动触发原因
2026-11-02初版设计,52 条 AC 追溯;区分既有现状与拟实施方案用户确认正式规格并要求进入设计;设计尚待单独确认
+

| 2026-11-02 | 用户确认后按44个原子任务落地;补源版本双读、userset_relation、真实父层、Nullable运行事实与创建前重验;本地门禁和真实E2E分开记录 | 代码接线核对与定向验证;未改已确认AC或授权环境更新 |

+
+ +

F115 设计自审记录

+

阶段更新:用户已于2026-11-02单独确认本设计,随后已实施;下文保留设计确认前的自审结论,开发与部署状态以verification.md为准。

+

日期:2026-11-02。对象:design.md。方式:同一代理按 sdd-review/references/design-checklist.md 的 24 项清单自审,非独立代理复审。

+

结论

+

LGTM

+

设计可提交用户评审;本结论不代替用户单独确认设计,也不表示业务代码、镜像或真实环境已验证。spec 的 52 条 AC 已获用户确认,本轮没有更改其验收要求。

+

本轮自审已修正的问题

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
项观察到的问题已落到设计
R1把原运行任务的唯一 UserTenant 身份前置直接用到 Web 新建,会破坏部分旧单租户创建行为D6、§4.6:同一 user 应用模块提供新建薄入口;关闭多租户固定 1,不要求补成员行;原 runtime 严格合同不变
R2用请求上下文的 actor resolver 查询负责人,可能取到审批阅读者身份§4.4 C、§5:负责人使用自身真实事实,明确不得借用请求 actor 缓存或强设超管
R3单纯采用 grant 来源会遗漏失效人员、投影中间态或分页截断,错误产生 owner-only§4.4 B–C:CURRENT/版本/Catalog 前后复核、目录三态、预算失败显式 unavailable;非支持来源不能静默忽略
R4backend 先返回 null 时旧 UI 仍会 ??0§4.5、§7.3:先兼容前端识别未知,再升级运行层/后端,整组镜像到位后验收
+

24 项检查

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#检查结论证据
1目标可在 1–3 句话说明PASS§1 定义四条链路的一致性修复
2非目标明确PASS§1、§8 保留不迁移、不回填、不扩其它能力边界
3特有约束PASS§2 仅定义执行体、两时间面、完整性预算、创建所属租户等
4Constitution 引用及门禁PASS引用 C1–C8,不重抄铁律;具体核对见下节
5有关键决策PASSD1–D6 覆盖运行、审计、审批、F048 和创建
6每项有备选/选定/原因PASSD1–D6 均记录被否方案及证据
7重审触发条件PASS每项都有重新考虑条件
8理由有可核对事实PASS116 只读证据、基线实际观察/快照/创建复用路径及既有协议
9输入到输出可快速理解PASS§4.2、§4.3、§4.4、§4.6 四条数据流
10字段与格式明确PASS§4.5 实例状态、摘要 DTO、空/失败状态与 Web 创建前置字段
11模块做/不做完整PASS§4.7 职责表,权限模块不依赖业务 ORM,两个 SPA 不混用
12现状描述与既有合同一致PASS§4.1 固定源码基线;方案均标为拟实施,未把旧 owner-only/v1 注释当当前合同
13有反直觉事实PASS§5 记录 15 条
14坑有后果PASS每条有若忽略的后果
15坑有处理落点PASS每条标具体模块/函数或设计小节
16输出合同充分PASS§6.1 HTTP 增量、内部应用端口与数据类型
17输入依赖充分PASS§6.3 覆盖绑定、集群/容器、权限、身份、审批和既有存储;无新增系统二进制或服务
18依赖风险明确PASS§6.3 每项说明哪些变化使其失效
19单元/集成/E2E 策略PASS§7.1 按边界选测试,§7.2 真实验收;不跑全量
20可操作手动验证PASS§7.2 给出环境镜像核对、专用身份、页面、配对操作与清理;凭据不入文档
21不投入项与理由PASS§8,含超大范围、历史采集、复制扩权与跨租户代创建
22修订记录PASS初版记录日期、触发与待确认状态,未宣称 Feature 完成
23与 spec 一致PASS§7.4 覆盖 AC-01–AC-52,合同只读例外和创建归属保持
24偏差同步PASStasks 尚未创建,无实施偏差;自审修订均在设计内更新,未倒改已确认 AC
+

Constitution Check

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
条款结论本设计核对
C1PASS状态编排留 manager,backend 不持编排 SDK;新 SQL 在所属 Repository,服务层场景调用不跳 endpoint
C2PASS无 DDL、新数据库对象或方言 SQL;沿既有持久层,未伪称已在本机实测 DM8
C3PASS具体租户及创建所属租户复核,使用既有执行 scope/严格过滤;身份批量读取在所属仓储并只按已绑定人员标识读取
C4PASS有效动作、投影与继承由 F048 提供;通用 roster 仍要求 manage_permission;审批只读例外先验原资格,没有自建授权或伪装 owner
C5PASS仅预留现有 knowledge 段未占用的 10993,实施前重扫并同步三语;其它情况沿原错误或显式展示状态
C6PASS无凭据、密钥、真实登录密码或新持久 token;示例均非敏感结构
C7PASSHTTP 仍经各自 API 包装与组件查询,不放 store 直连 HTTP
C8PASS无新本地权威状态或跨进程文件;当前摘要只读查询,不存第二份授权主数据
+

结构与追溯检查

+
    +
  • spec 的 AC 定义仍为 52 个连续唯一编号;设计 §7.4 分组覆盖全部编号,未增加或减少 AC。
  • +
  • 三个审批详情路由已核对实际 router;新端口/薄入口在设计中标明为拟新增,不伪称当前已有。
  • +
  • 设计代码块为合法 JSON/sh 示例,文档链接、章节引用及 HTML 导航在生成评审页时作静态核对。
  • +
  • 本轮只更新 SDD 文档、版本契约登记和 HTML 评审稿,未运行业务测试或全量测试;未更新 116、120、189。
  • +
+

下一步与暂停依据

+

依据 AGENTS.md §6、SDD-Guide §3,正式设计须用户单独确认后才可拆任务和编码。当前停在设计确认点,后续真实部署 E2E 仍须按明确环境授权执行。

\ No newline at end of file diff --git a/docs/reviews/vibe-runtime-review-development-2026-11-02.html b/docs/reviews/vibe-runtime-review-development-2026-11-02.html new file mode 100644 index 0000000000..c5ce1ca823 --- /dev/null +++ b/docs/reviews/vibe-runtime-review-development-2026-11-02.html @@ -0,0 +1,443 @@ +F115 开发与定向验证记录
按用户指令先交付发版线 · 真实部署验收待执行

2026-11-02 · 基线 497256d28 · 规格与设计已确认 · 开发及验收状态见正文

F115 开发与定向验证记录

+

日期:2026-11-02。分支 codex/vibe-runtime-review-20261102;源码基线 497256d289793ce016df07cf8ab38f32988faca4。规格52条及设计均获用户分别确认。本轮未部署;不运行全量测试。

+

本次 Git 交付安排

+

用户在本地开发与验收缺口已披露后,明确要求“提交推送 feat/3.0.0 分支”。按该指令先提交本功能、合入最新远端发版线并推送,保留目标分支后续增强。真实部署 E2E 及既有类型门禁问题仍按下表记录,不将 Git 交付视为部署验收完成。本次不更新环境或存量资源。

+

本地结果

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
范围实际结果证据/限制
后端新增链路与直接相关知识库回归63 passed;5个真实E2E用例默认skippedpytest实际组合运行;非空业务断言、拒绝先于名单、原子创建补偿及开放API兼容
manager Docker/K8s只读观察71 passedtest_f115_observation.py + test_k8s_observe.py;fake外部平台/执行器,非真实部署
Platform组件和直接审计回归19 passed / 4 files运行状态、候选失败/空/切范围、知识库两种入口与旧表单、审批重查
Client审批组件/父层相关断言22 passed / 3 files现有Jest受未构建canvas与ESM p-limit启动问题阻断;临时Vitest兼容副本仅替换测试全局名称/模块定位和JSX运行配置,执行真实产品组件及同一断言。没有修改仓库runner或依赖安装
前端 lintPASS按根脚本检查两个SPA与共享包
i18n与错误码产物PASScheck-i18n;locales build/check,三语源和6个生成文件
前端类型门禁未全绿;无新增诊断同一基线/同一依赖对照:Platform23条、Client134条;依赖缺失、旧图标/API等既有报错。本轮曾新增一处测试fixture类型错误,已修正并重新验证
Ruff无新增问题修改文件与git HEAD同配置对照;旧文件剩余问题按基线登记,新文件通过。所有修改Python执行format及未定义名检查
架构守卫无VIOLATIONknowledge endpoint旧database/models导入仅RULE-3既有WARNING
真实部署 E2E未执行5个用例只作离线收集/默认skip;未访问或写入116/189。必须依e2e-test及design §7.2在明确授权和部署后执行
+

可复现命令

+

工作目录分别为worktree的 src/backend、src/runtime-manager、src/frontend/platform。Python复用主工作区现有venv;config只指定既有本地文件路径,不复制或输出凭据。pytest使用 -q -p no:cacheprovider --tb=short --disable-warnings。

+
pytest test/app_runtime/test_f115_instance_observation.py test/user/test_f115_current_identity.py test/tenant/test_f115_visibility_subjects.py test/permission/test_f115_action_sources.py test/app_publish/test_f115_current_visibility.py test/approval/test_f115_current_detail.py test/knowledge/test_f115_creation_policy.py test/knowledge/test_f115_creation_entry.py test/knowledge/test_f115_e2e_cleanup.py test/knowledge/test_knowledge_create_atomicity.py test/knowledge/test_v2_filelib_unified.py test/e2e/test_e2e_f115_runtime_review.py
+pytest tests/test_f115_observation.py tests/test_k8s_observe.py
+vitest run src/test/f115RuntimeReview.test.tsx src/test/f115KnowledgeCreation.test.tsx src/test/f115ApprovalVisibility.test.tsx src/test/runtimeAudit.test.tsx
+pnpm lint
+pnpm typecheck
+pnpm check-i18n
+node packages/locales/scripts/build.mjs --check
+
+

临时日志位于 /private/tmp/f115-*.log;兼容运行及基线只存在临时目录,没有提交对依赖或测试环境的改写。

+

task-review(L1)

+

44个原子任务逐项检查命名/分层、响应序列化、SQL归属与严格租户、权限应用端口、两SPA/i18n、敏感信息、设计同步。实现任务配对用例及修改文件与tasks对应;基础设施和测试任务按skill的差异化清单检查。代码项PASS;前端旧类型/Ruff基线与真实E2E门禁为显式备注,不能记作远端已验收。

+
    +
  • T001–T003:10993未冲突、Web专用DTO不扩展共享创建目标、三语产物一致。
  • +
  • T004–T008:真实当前绑定/UID、HTTP事实、nullable/0及UTC归一;旧观察404不回退历史AppInstance。
  • +
  • T009–T016、T041:实时人员资格、批量目录、CURRENT及完整分页/版本复核;真实owner资格与阅读者分离,普通审批人不获得manage_permission。
  • +
  • T017–T019:三类详情均在原守卫后追加;非app场景无动态查询,两个snapshot不写回。
  • +
  • T020–T023:准备和写前重验,严格所属租户;copy/开放API不增加菜单门禁,原创建补偿保留。
  • +
  • T024–T033、T043:前端状态与真实字段一致,表单固定leaf、不以当前管理视角写入;管理异常页重查与序号拒绝旧响应。
  • +
  • T034–T038、T042:Client当前范围只读、typed userset、失败/删除/owner-only与冻结区域分开,父层真实API刷新。
  • +
  • T039、T044:专用E2E准备、显式启用和精确清理护栏;非测试名称与跨租户清理用例拒绝。
  • +
  • T040:记录本页和L2结论,完整实现52条目标的本地接线,不声称52条均在真实环境验收。
  • +
+

AC追溯

+

下表中的“本地”指单元/组件/相关回归与代码路径核对;所有真实部署项均待验,具体步骤见 e2e-checklist.md。不把测试任务写过覆盖编号当成真实验收证据。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AC本地证据入口真实环境
AC-01manager test_f115_observation/test_k8s_observe;backend instance;Platform f115RuntimeReview待部署验收
AC-02manager test_f115_observation/test_k8s_observe;backend instance;Platform f115RuntimeReview待部署验收
AC-03manager test_f115_observation/test_k8s_observe;backend instance;Platform f115RuntimeReview待部署验收
AC-04manager test_f115_observation/test_k8s_observe;backend instance;Platform f115RuntimeReview待部署验收
AC-05manager test_f115_observation/test_k8s_observe;backend instance;Platform f115RuntimeReview待部署验收
AC-06manager test_f115_observation/test_k8s_observe;backend instance;Platform f115RuntimeReview待部署验收
AC-07manager test_f115_observation/test_k8s_observe;backend instance;Platform f115RuntimeReview待部署验收
AC-08manager test_f115_observation/test_k8s_observe;backend instance;Platform f115RuntimeReview待部署验收
AC-09manager test_f115_observation/test_k8s_observe;backend instance;Platform f115RuntimeReview待部署验收
AC-10manager test_f115_observation/test_k8s_observe;backend instance;Platform f115RuntimeReview待部署验收
AC-11既有审计repository可信身份过滤核对;runtimeAudit及f115RuntimeReview组件待部署验收
AC-12既有审计repository可信身份过滤核对;runtimeAudit及f115RuntimeReview组件待部署验收
AC-13既有审计repository可信身份过滤核对;runtimeAudit及f115RuntimeReview组件待部署验收
AC-14既有审计repository可信身份过滤核对;runtimeAudit及f115RuntimeReview组件待部署验收
AC-15既有审计repository可信身份过滤核对;runtimeAudit及f115RuntimeReview组件待部署验收
AC-16既有审计repository可信身份过滤核对;runtimeAudit及f115RuntimeReview组件待部署验收
AC-17既有审计repository可信身份过滤核对;runtimeAudit及f115RuntimeReview组件待部署验收
AC-18既有审计repository可信身份过滤核对;runtimeAudit及f115RuntimeReview组件待部署验收
AC-19既有审计repository可信身份过滤核对;runtimeAudit及f115RuntimeReview组件待部署验收
AC-20既有审计repository可信身份过滤核对;runtimeAudit及f115RuntimeReview组件待部署验收
AC-21既有审计repository可信身份过滤核对;runtimeAudit及f115RuntimeReview组件待部署验收
AC-22action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility待部署验收
AC-23action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility待部署验收
AC-24action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility待部署验收
AC-25action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility待部署验收
AC-26action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility待部署验收
AC-27action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility待部署验收
AC-28action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility待部署验收
AC-29action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility待部署验收
AC-30action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility待部署验收
AC-31action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility待部署验收
AC-32action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility待部署验收
AC-33action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility待部署验收
AC-34action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility待部署验收
AC-35action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility待部署验收
AC-36action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility待部署验收
AC-37action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility待部署验收
AC-38action_sources/current_identity/visibility_subjects/current_visibility/current_detail;两端CurrentAppVisibility待部署验收
AC-39creation_policy/creation_entry;knowledge_create_atomicity/v2_filelib;Platform f115KnowledgeCreation待部署验收
AC-40creation_policy/creation_entry;knowledge_create_atomicity/v2_filelib;Platform f115KnowledgeCreation待部署验收
AC-41creation_policy/creation_entry;knowledge_create_atomicity/v2_filelib;Platform f115KnowledgeCreation待部署验收
AC-42creation_policy/creation_entry;knowledge_create_atomicity/v2_filelib;Platform f115KnowledgeCreation待部署验收
AC-43creation_policy/creation_entry;knowledge_create_atomicity/v2_filelib;Platform f115KnowledgeCreation待部署验收
AC-44creation_policy/creation_entry;knowledge_create_atomicity/v2_filelib;Platform f115KnowledgeCreation待部署验收
AC-45creation_policy/creation_entry;knowledge_create_atomicity/v2_filelib;Platform f115KnowledgeCreation待部署验收
AC-46creation_policy/creation_entry;knowledge_create_atomicity/v2_filelib;Platform f115KnowledgeCreation待部署验收
AC-47creation_policy/creation_entry;knowledge_create_atomicity/v2_filelib;Platform f115KnowledgeCreation待部署验收
AC-48creation_policy/creation_entry;knowledge_create_atomicity/v2_filelib;Platform f115KnowledgeCreation待部署验收
AC-49creation_policy/creation_entry;knowledge_create_atomicity/v2_filelib;Platform f115KnowledgeCreation待部署验收
AC-50creation_policy/creation_entry;knowledge_create_atomicity/v2_filelib;Platform f115KnowledgeCreation待部署验收
AC-51三语/架构守卫/拒绝场景与对应路径待部署验收
AC-52三语/架构守卫/拒绝场景与对应路径待部署验收
+
+ +

F115 特性代码审查(L2)

+

日期:2026-11-02。对照已确认spec52条、design和tasks;固定基线497256d289793ce016df07cf8ab38f32988faca4,含本地新增/未提交文件。方式:同一代理按sdd-code-review七维清单审查,非独立代理复审。

+

判定

+

代码静态审查:通过。SDD合入判定:NEEDS_FIX(真实验收门禁未完成)。

+

当前未发现未修复的本功能代码阻断项。真实部署E2E未授权/未执行,按AGENTS.md §6、SDD-Guide及已确认design §7.2不能标为验收完成。前端类型门禁亦受同一基线已有23/134条诊断限制,须在交付环境解决或依据用户明确的交付安排处理。没有因此擅自部署、推送或跳过门禁。

+

用户后续交付指令(2026-11-02)

+

用户获知上述结果后明确要求“提交推送 feat/3.0.0 分支”,因此按用户指令先完成提交、合入和推送。上方 NEEDS_FIX 保留为真实验收未完成的原审查结论;本次 Git 交付不将其改为 E2E 通过,不新增环境更新授权。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
维度静态/本地检查结论
边界条件0/null、无效日期、无绑定/缺失/替换、分页不推进/预算、CURRENT/Catalog变化、人员歧义及目录失败均有拒绝或显式状态PASS
权限认证原任务/个人/管理阅读先行;摘要app/tenant绑定;真实owner actor;仅可信F048服务端端口读取,通用授权管理仍manage_permission;新建真实会话/资格写前复核PASS
并发安全观察绑定前后核对;来源前后stamp和身份解析后再读;旧前端响应序号丢弃;表单预期租户固定;新建复用原失败补偿PASS
信息泄漏无凭据落库/输出;摘要失败只给固定reason且不返回部分名单;跨租户在目录前拒绝;专用E2E清理精确限制PASS
测试覆盖本地后端/manager/两端组件及直接相关回归;真实5例仅收集,手动清单未执行;Jest启动限制已说明NEEDS_FIX:真实E2E待验
风格与分层SQL只在Repository;业务权限走application;不引入Docker/K8s SDK到backend;两SPA无状态混用;三语和生成产物、lint、无新增Ruff问题PASS_WITH_NOTES:旧基线类型检查未全绿
文档同步设计已记录真实接线、userset字段、来源双读、父层路径与错误码;release-contract/审批skill/tasks及验收记录同步;无AC倒改PASS
+

审查中修正的事项

+
    +
  • 平台resource及K8s辅助资源404不能当作执行体丢失;观察归属变化拒绝,不回退历史实例。
  • +
  • 应用删除态必须在app/tenant匹配后判断,避免通过删除态绕过绑定。
  • +
  • 已停止执行体不能保留失败探活的unhealthy相位。
  • +
  • 两端DTO统一为后端实际 userset_relation,包含subtree_member;提示明确授权即时生效、不影响审批。
  • +
  • 新增测试fixture类型错误已修正,新增导入与未使用变量均修正;现存类型诊断与基线逐项对照。
  • +
+

交付范围

+

未新增表、外部服务、权限动作或数据迁移。未修改其它审批场景resolver/outbox,未扩大复制及源码/预览资格。下一阶段只按 e2e-checklist.md 对专用对象作必要验收;无需全量回归。

\ No newline at end of file diff --git a/docs/reviews/vibe-runtime-review-discovery-2026-11-02.html b/docs/reviews/vibe-runtime-review-discovery-2026-11-02.html new file mode 100644 index 0000000000..f38b4e4d24 --- /dev/null +++ b/docs/reviews/vibe-runtime-review-discovery-2026-11-02.html @@ -0,0 +1,151 @@ +F115 已确认需求发现
需求发现 D1–D4 · 已确认

2026-11-02 · 基线 497256d28 · 需求发现与正式规格均已确认 · 尚未修改业务代码

F115 应用运行信息、审批可见范围与知识库创建:需求发现

+

日期:2026-11-02。阶段:用户已确认 D1–D4,进入正式规格;尚未修改业务代码。

+
    +
  • 基线:origin/feat/3.0.0@497256d28。
  • +
  • 分支:codex/vibe-runtime-review-20261102;独立工作树,主工作区保留。
  • +
  • 来源:用户本轮五项问题与六张截图;116 Docker 环境只读核对。
  • +
  • 交付约束:沿 SDD 推进,只做本功能定向验证;不迁移存量应用、不改角色绑定、不替用户修改应用代码、不运行全量回归。
  • +
+

1. 已核实的问题

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
项目当前事实与原因建议范围
1. 健康、重启、启动时间页面已有三个字段。116 的实例 API 返回 health=null、restart_count=0,缺少 started_at。同一容器实际 StartedAt=2026-10-30T10:33:15.363879485Z、重启 0 次,未配置 Docker HEALTHCHECK。v2 状态投影遗漏启动时间,并只读取 Docker Health,未用平台已有 HTTP 探活结果;K8s 投影也遗漏启动时间。补齐 Docker/K8s 当前应用执行体的启动时间;健康沿已有实际探活/Ready 事实呈现,不能把 Running 或缺失数据冒充健康。真实 0 次保留为 0,未知不可显示成 0。
2. 模型调用访问用户为空当前 116 Root、近 7 天查询返回 34 条记录,访问用户候选已有用户 ID 3。截图中的“应用自身”记录未保存自然人访问身份,现有候选只枚举日志中真实存在的 user/natural_person。未复现当前候选整体为空。保留真实身份过滤;存在用户记录时应能选、能筛选。只有应用自身调用时明确说明无访问用户记录;查询失败、搜索无匹配与无用户记录不能混为一谈。不从应用负责人、登录人或访问时间推测历史访问者,不回填旧日志。
3. 审批可见范围为空发布场景 Handler 只复制 payload.visibility_snapshot,发布管线未填充此字段;客户端始终读取历史 detail_snapshot,该字段为空就展示空文案。打开审批详情时读取应用当前有效的人员、部门和用户组授权摘要;只读展示,修改授权仍走既有权限流程并立即生效。保留原审批快照,不重写历史记录。
4. 首次提交空文案客户端把空数组统一翻译为“暂无可见范围信息”,没有区分“确认只有负责人”与“未知/查询失败”。仅当当前权限查询成功、确认没有负责人以外的有效普通访问授权时,显示“仅负责人可见”。首发后若已新增授权,仍展示最新对象。缺失数据、失败、应用已删除不能伪装为负责人可见。
5. 租管没有新建知识库按钮116 正式 GET /tenants/60/admins 确认 gzx01(849)为租户 60 管理员。角色菜单查询没有 create_knowledge。前端路由为租管补了 knowledge,但页面新建按钮仍只认 admin/create_knowledge;后端 create 也只认 create_knowledge 菜单,单改按钮会继续被拒绝。统一前后端的本租户知识库新建资格:当前租户管理员可新建文档/QA 知识库;普通用户继续走原创建菜单。租管不能借此选择其它租户。保留模型可用性、容量等既有检查。
+

已通过截图使用的 120:3002 入口再次对照:近 7 天、不限制应用时访问用户候选为 1;限定截图中的应用自身调用示例 c117490880544bf9b695019394488478 时候选为 0,两次均为业务码 200。先核对 120/116 鉴权公钥一致,再使用内存会话查询;未更新任一环境服务。

+

2. 已确认的业务边界

+

用户于 2026-11-02 明确回复“确认边界,进入正式规格”,确认以下 D1–D4;这不代替后续 spec、design 的单独确认。

+

D1. 运行信息表示真实当前执行体

+
    +
  • Docker 使用精确绑定的当前容器;K8s 使用精确绑定的当前 Pod/应用容器,不混入初始化/维护容器。
  • +
  • 健康优先反映已有平台探活/Ready 结果,明确区分正常、异常、启动中、未知;本次不新增独立监控系统或定时探活体系。
  • +
  • 重启次数表示当前应用执行体的实际重启计数,不把版本升级、替换实例和应用历史发布次数累加为重启次数。
  • +
  • 不重建现有应用来补显示信息;时间按现有统一时间展示工具转换。
  • +
+

D2. 模型调用保留真实身份

+
    +
  • 候选来自当前授权租户/应用/时间范围内的真实用户调用记录;过滤使用相同范围。
  • +
  • 应用以自身身份调用模型时,记录不代表某个自然人。需要按人查询的新调用,应由应用按现有 SDK/OBO 契约转发访问者身份,本次不替已有应用改码。
  • +
  • 不新增虚构人员候选,不把“用户不存在于这些记录”解释为全租户人员都可筛选;无记录提示三语交付。
  • +
+

D3. 审批只读展示当前授权

+
    +
  • 这是对 F055 AC-24“提交时可见范围快照”的明确修订:代码、声明、版本、审批决策仍冻结;仅可见范围区改为查询时的最新授权,并标注“当前可见范围,授权变更即时生效,不影响审批”。
  • +
  • 按现有 F048 use 生效动作识别访问授权,涵盖直接及现有继承语义;不能按模型名字认定权限。
  • +
  • 展示人员、部门、用户组名称与类型;部门/组不展开成全部成员。服务账号不是普通浏览者,不作为人员行列出。
  • +
  • 不枚举所有因管理员身份而隐式可见的账号;这些身份继续按既有平台规则生效。
  • +
  • 当前审批阅读资格仍须校验。跨租户、失去资格、权限依赖不可用时不得泄露当前授权目录;不能借当前摘要获得应用 edit/manage_permission 或源码访问权。
  • +
  • 进行中和已进入终态的、仍有合法阅读资格的审批详情,均显示当前授权;历史快照不改写。已删除应用与不可确认权限分别提示原因。
  • +
  • 人员迁租户、停用、授权撤销与模型动作变化不能被误写成“仍可访问”;不迁移应用或授权,不修补存量历史数据。
  • +
+

D4. 知识库创建资格

+
    +
  • 当前有效的租户管理员默认可在自己所属租户新建文档/QA 知识库,前端入口和后端资格一致;不信任前端传来的管理员标记。
  • +
  • 新资源仍归当前创建人及其所属租户,不用页面管理视角改变归属。超管与普通用户沿现有创建规则。
  • +
  • 不扩大一般部门管理员的权限,不新增角色或批量补写用户授权。
  • +
  • 本次只修新建,复制仍保留原创建菜单和源资源读取资格;页面共享条件需拆清,不能顺带放开复制。
  • +
+

3. 证据锚点

+

以下源码均已在固定基线 497256d28 核对,116 运行层仍使用 10 月 30 日的本机 F114 镜像;后端和前端 release 镜像已有后续更新。截图中“整个平台”不作为当前最新代码仍保留该选项的证据。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
链路源码位置
v2 实例状态投影src/runtime-manager/runtime_manager/api/platform_observe.py::status
Docker 实际 HTTP 探活src/runtime-manager/runtime_manager/executors/docker.py::observe
K8s Ready 与应用容器重启计数src/runtime-manager/runtime_manager/executors/kubernetes.py::_observed
后端实例字段白名单src/backend/bisheng/app_runtime/domain/services/app_query_service.py::get_instance
发布页已有显示字段src/frontend/platform/src/pages/BuildPage/hostedApp/tabs/PublishTab.tsx
审计候选/过滤src/backend/bisheng/app_governance/domain/repositories/runtime_audit_repository.py::candidates / statement
审批快照占位与客户端读取app_publish/.../app_publish_scenario_handler.py::build_detail、client/.../approval/AppPublishDetailPanel.tsx
租管前端路由补全src/frontend/platform/src/routes/index.tsx::CHILD_ADMIN_ROUTE_PERMISSIONS
知识库按钮与后端创建菜单KnowledgePage/KnowledgeFile.tsx、KnowledgeQa.tsx、knowledge/api/endpoints/knowledge.py::create_knowledge
+

4. 定向验证计划

+
    +
  1. 状态投影:Docker 有/无原生健康检查、探活成功/失败/未知、重启为 0/正数/缺失;启动时间、执行体身份匹配;K8s 应用容器与初始化容器分离。
  2. +
  3. 模型记录:自然人/OBO 与应用自身配对;候选和列表过滤一致;租户、应用、时间与无候选/搜索空/加载失败边界;不篡改历史记录。
  4. +
  5. 审批范围:负责人可见、多类授权、授权变更后刷新、进行中/终态、use 与其它动作区分、继承、人员失效、跨租户与无权拒绝;当前摘要失败不影响不可变审批数据。
  6. +
  7. 新建知识库:无 create_knowledge 菜单的本租管允许;无菜单普通用户拒绝、有菜单普通用户允许;跨租户拒绝、文档与 QA 一致、复制资格不扩展。
  8. +
  9. 仅运行上述相关测试、变更文件静态检查与必要页面验证;真实部署/写入验收另行明确,不自动更新 116 或 120 服务。
  10. +
+

5. 下一阶段与暂停依据

+

依据仓库 AGENTS.md §6 与 SDD-Guide §1/§3,第 3 项修改已经确认的审批快照契约,第 5 项涉及创建菜单资格的前后端一致性,采用完整 SDD。D1–D4 已获用户确认;当前进入正式 spec 编写和评审,经用户单独确认 spec 后进入设计。设计仍须单独确认,之后才能拆任务与编码。

\ No newline at end of file diff --git a/docs/reviews/vibe-runtime-review-spec-review-2026-11-02.html b/docs/reviews/vibe-runtime-review-spec-review-2026-11-02.html new file mode 100644 index 0000000000..68bea5cbe2 --- /dev/null +++ b/docs/reviews/vibe-runtime-review-spec-review-2026-11-02.html @@ -0,0 +1,365 @@ +F115 已确认规格与自审记录
正式规格 · 已确认

2026-11-02 · 基线 497256d28 · 需求发现与正式规格均已确认 · 尚未修改业务代码

52 条验收标准10 项清单自审通过正式规格已获用户确认设计草案待确认

F115:应用运行信息、审批当前可见范围与知识库创建

+
+

本文档定位:纯 What。 只定义需求、验收标准与边界;实现选择、接口、数据模型及文件变更在设计阶段确定。

+
+

状态:用户于 2026-11-02 明确确认正式规格,进入设计;需求发现 D1–D4 同日已确认,尚未进行业务编码。

+

优先级:P0(权限边界与错误信息一致性)。
+所属版本:v3.0.0;发版线 feat/3.0.0。
+编写基线:origin/feat/3.0.0@497256d289793ce016df07cf8ab38f32988faca4。此基线及只读排查记录不代表 F115 已实现或环境已更新。
+关联 PRD:本轮已确认需求发现 D1–D4(直接需求来源);应用工场 PRD-1 RT-01、RT-03、GOV-01、GOV-04(既有产品背景)。
+依赖:F048 权限动作;F054 应用与运行状态;F055 发布审批;F056 运行审计查询;F059/F086 运行形态;F113 身份与租户资格;F114 权限映射及具体租户视角。审批引擎、知识库创建与组织身份继续由既有领域负责。

+

范围

+

本次纳入:

+
    +
  • 发布页显示当前执行体的真实健康状态、实际重启次数与启动时间,区分未知和真实零值。
  • +
  • 模型调用的访问用户候选与筛选保持真实、范围一致,明确无用户记录、搜索无匹配、加载与失败状态。
  • +
  • 应用发布审批只读展示当前有效的人员、部门、用户组访问授权;能确认时显示“仅负责人可见”。
  • +
  • 当前有效的租户管理员可在所属租户新建文档和 QA 知识库,入口与实际创建资格一致。
  • +
+

本次明确排除:

+
    +
  • 不建设新的监控系统,不扩大探活体系,不为补显示而重启或重建现有应用。
  • +
  • 不猜测、回填模型调用的历史访问身份,不替现有应用改码转发访问者身份。
  • +
  • 不修改审批决定、版本内容或历史快照,不把可见范围变更变成发布审批事项。
  • +
  • 不迁移、移交应用或知识库,不批量补授权,不更改已有角色、部门或用户组绑定。
  • +
  • 不新增权限动作,不扩大普通用户、一般部门管理员或知识库复制资格。
  • +
  • 不扩展源码审读、审批预览、模型调用或其它平台能力的授权。
  • +
+

1. 用户故事

+
    +
  1. 作为应用管理者,我希望看到当前实例实际的健康、重启和启动情况,以便判断运行是否正常,并识别信息暂不可用。
  2. +
  3. 作为有审计资格的管理员,我希望按真实访问用户筛选模型调用,并知道候选为空的原因,以便准确定位调用而不误判应用自身调用。
  4. +
  5. 作为有权阅读应用发布审批的人,我希望看到应用当前已授权的人员、部门和用户组,以便理解当前访问范围,且不把它误认为冻结的发布内容。
  6. +
  7. 作为首次发布的负责人或审批阅读者,我希望在确实只有负责人可见时看到明确说明,以便知道是否还需要设置可见范围。
  8. +
  9. 作为租户管理员,我希望无需额外创建菜单授权即可在所属租户新建文档或 QA 知识库,以便履行本租户管理职责。
  10. +
+

术语

+
    +
  • 当前可见范围:当前有效的普通访问授权对象,按既有 use 动作及身份、租户规则判定;不等同于应用此刻已经上线、可以建立运行连接。草稿、待审批或已下线本身不应清空其有效授权范围。
  • +
  • 负责人:应用当前记录的资源归属自然人;是否仍具有效访问资格应另行确认,不因名字仍在审批单上而推定有效。
  • +
  • 租户管理员:具备当前有效租户管理资格的自然人;不同于只有部门管理资格的人员。
  • +
+

2. 验收标准

+

2.1 当前执行体的运行信息(D1)

+
    +
  • AC-01 — WHEN 有权查看运行状态的用户打开或刷新应用发布页,THE SYSTEM SHALL 仅以该应用当前精确绑定的执行体展示运行信息,不混入其它应用、旧实例、审批预览或维护执行体的信息。
  • +
  • AC-02 — WHEN 当前执行体已有可用的应用探活或就绪结果,THE SYSTEM SHALL 按真实结果展示健康正常或异常;Docker 未配置原生健康检查不能成为忽略已有平台应用探活结果的理由。
  • +
  • AC-03 — WHILE 当前执行体仍在启动且尚无已完成的健康判定,THE SYSTEM SHALL 表达启动中;IF 无法确认当前健康,THEN THE SYSTEM SHALL 表达未知,不以“进程运行中”或缺失结果推断健康正常。
  • +
  • AC-04 — IF 运行信息查询失败或观测依赖不可用,THEN THE SYSTEM SHALL 明确表示相关信息暂不可确认并允许刷新;仍可独立确认的信息保持可读,不把查询故障伪装成应用健康、零次重启或不存在。
  • +
  • AC-05 — WHEN 显示重启次数,THE SYSTEM SHALL 使用当前执行体中应用容器的真实重启计数,不累加应用历史发布次数、版本升级次数或被替换执行体的计数,也不混入初始化或维护容器的计数。
  • +
  • AC-06 — WHEN 可确认重启次数为零或正整数,THE SYSTEM SHALL 如实显示该值;IF 无法取得或确认计数,THEN THE SYSTEM SHALL 显示未知,不以零代替。
  • +
  • AC-07 — WHEN 可取得当前应用容器的实际启动时间,THE SYSTEM SHALL 展示该时间,不用审批时间、发布时间、Pod 创建时间或初始化容器的启动时间代替。
  • +
  • AC-08 — WHEN 展示有效启动时间,THE SYSTEM SHALL 遵循平台已有的统一时区展示口径;IF 时间缺失、无效或未实际启动,THEN THE SYSTEM SHALL 显示未知或尚未启动,不显示虚构日期。
  • +
  • AC-09 — WHEN 应用停止、执行体不存在或已被替换,THE SYSTEM SHALL 区分这些状态,且不得把已失效执行体的健康、重启或启动信息继续标为当前实例信息;刷新后应反映当前绑定。
  • +
  • AC-10 — WHERE 应用运行于 Docker 或 K8s,THE SYSTEM SHALL 对上述状态采用一致的产品含义,且读取或刷新运行信息不得触发发布、重启、重建或资产变更。
  • +
+

2.2 模型调用访问用户候选与过滤(D2)

+
    +
  • AC-11 — WHEN 查询模型调用的访问用户候选,THE SYSTEM SHALL 将候选限定在调用者当前有权查询的具体租户、应用、时间及其它已选筛选条件内,与相应记录查询的范围一致。
  • +
  • AC-12 — WHEN 记录中存在经平台验证的自然人执行身份,THE SYSTEM SHALL 允许该真实身份成为访问用户候选;不得依据应用负责人、当前登录人、调用时间接近或应用标识推测访问用户。
  • +
  • AC-13 — WHEN 模型调用仅以应用自身或服务账号自身身份执行,THE SYSTEM SHALL 不把该调用者冒充为自然人访问用户;应用缺少访问者身份不等于数据查询失败。
  • +
  • AC-14 — WHEN 当前查询范围存在自然人访问记录,即使同时存在应用自身调用,THE SYSTEM SHALL 提供对应自然人候选,并允许选择和按该用户筛选;不得因混合身份记录而使整个候选列表为空。
  • +
  • AC-15 — WHEN 展示访问用户候选,THE SYSTEM SHALL 保留能够对应记录的真实身份标识;不得混入无相关调用记录的全租户人员,或用负责人等其它账号替换已不存在或不可确认的历史身份。
  • +
  • AC-16 — WHEN 选择访问用户并查询记录,THE SYSTEM SHALL 仅返回该真实身份与其它已选条件同时匹配的模型记录,不能将同一应用的其它用户或应用自身调用归给该用户。
  • +
  • AC-17 — IF 查询成功且当前范围没有自然人访问记录,THEN THE SYSTEM SHALL 明确提示当前范围没有访问用户记录,并说明应用自身调用不代表自然人访问,不仅显示无含义的空白。
  • +
  • AC-18 — IF 访问用户搜索成功但没有匹配候选,THEN THE SYSTEM SHALL 提示没有匹配用户,区别于整个范围没有自然人记录;清除搜索后可恢复当前范围的真实候选。
  • +
  • AC-19 — WHILE 候选正在加载,THE SYSTEM SHALL 表达加载状态;IF 加载失败,THEN THE SYSTEM SHALL 表达失败并允许重试,不将失败展示成“没有访问用户记录”或“没有匹配用户”。
  • +
  • AC-20 — WHEN 具体租户、应用、时间或相关查询条件改变,THE SYSTEM SHALL 使候选及筛选结果对应新范围,不保留或泄露旧范围的候选与结果;不再适用的已选身份不得导致界面悄悄查询旧范围。
  • +
  • AC-21 — WHEN 提供上述查询与提示,THE SYSTEM SHALL 保留已有可信访问者与 SDK/OBO 身份规则,不回填或改写历史记录;需要按人记录的新调用仍须带有依既有规则验证的访问者身份。
  • +
+

2.3 审批中的当前可见范围(D3)

+
    +
  • AC-22 — WHEN 有合法阅读资格的用户打开或刷新应用发布审批详情,THE SYSTEM SHALL 展示查询时应用当前有效的可见范围,不继续把提交时的空占位或旧授权快照当作当前范围。
  • +
  • AC-23 — WHEN 识别当前可见范围,THE SYSTEM SHALL 以现有 F048 use 生效动作和既有身份、租户条件为准,涵盖直接授权及现有继承语义,包括归属人的有效所有者访问资格;草稿、待审批或已下线本身不应清空有效授权范围,也不表示应用已经可以运行访问。
  • +
  • AC-24 — WHEN 存在有效的普通访问授权对象,THE SYSTEM SHALL 按人员、部门、用户组分别展示对象名称及类型,能区分同名不同类型对象;部门和用户组不展开成全部成员。
  • +
  • AC-25 — WHEN 展示普通访问授权对象,THE SYSTEM SHALL 排除服务账号,并说明管理员仍按平台既有规则访问,不把所有因管理员身份而隐式可见的账号逐一列作普通人员授权。
  • +
  • AC-26 — IF 某对象只有其它管理动作而没有生效的 use,THEN THE SYSTEM SHALL 不将其认作当前访问授权对象;不能单凭“所有者”“管理者”等权限模型名称判定可见。
  • +
  • AC-27 — WHEN 授权被撤销、相关人员停用或迁离租户,或权限模型动作发生变化,THE SYSTEM SHALL 在后续打开或刷新时按现有有效资格反映变化,不把已失效对象继续表达为可以访问;部门或用户组的展示不承诺其每一名成员都已获得访问资格。
  • +
  • AC-28 — IF 当前范围查询成功且完整、负责人当前具备有效访问资格,且没有负责人以外的有效普通访问授权对象,THEN THE SYSTEM SHALL 明确显示“仅负责人可见”;此文案不否定 AC-25 所述管理员隐式访问规则。IF 完整查询确认负责人也没有有效访问资格且无其它有效普通访问授权对象,THEN THE SYSTEM SHALL 表达当前没有有效普通访问对象,而非“仅负责人可见”。
  • +
  • AC-29 — WHEN 首次发布的审批详情被打开,THE SYSTEM SHALL 依据当前有效范围决定显示“仅负责人可见”或具体授权对象;首次发布本身、历史快照为空、负责人失效或当前查询失败均不能单独作为“仅负责人可见”的依据。
  • +
  • AC-30 — WHEN 显示该区域,THE SYSTEM SHALL 明确标注“当前可见范围,授权变更即时生效,不影响审批”的含义;变更可见范围仍走既有授权流程,不创建新发布审批、不重置现有审批进度,也不要求重新提交版本。
  • +
  • AC-31 — WHEN 读取当前可见范围,THE SYSTEM SHALL 保持原审批快照和历史记录不变;代码、版本、能力声明、资源档位、发布说明及审批决定继续遵守各自原有冻结规则,不能因刷新可见范围被替换成应用最新内容。
  • +
  • AC-32 — WHERE 应用发布审批处于进行中或已经进入通过、驳回、撤回、取消等终态,THE SYSTEM SHALL 对仍具合法阅读资格的用户提供相同含义的当前可见范围;进入终态本身不新增阅读资格。
  • +
  • AC-33 — WHEN 用户具备该次审批的合法阅读资格,THE SYSTEM SHALL 允许其只读查看该审批所对应应用的当前人员、部门、用户组访问授权摘要,不额外要求长期应用 manage_permission;该例外只用于当前审批展示,不成为任意查询应用授权目录的资格。
  • +
  • AC-34 — WHEN 用户通过 AC-33 查看当前摘要,THE SYSTEM SHALL 不因此授予授权增删、权限模型选择、应用管理、源码审读或预览资格;审批之外的完整授权主体查询及授权变更仍须遵守 F114 的 manage_permission 规则,原有审批审读与预览资格另行判定。
  • +
  • AC-35 — IF 用户没有该审批的阅读资格、已失去资格,或请求违反现有租户隔离,THEN THE SYSTEM SHALL 拒绝读取当前可见范围,且不泄露授权对象信息;知道审批或应用标识不能代替资格。
  • +
  • AC-36 — IF 对应应用已删除,THEN THE SYSTEM SHALL 明确表示无法读取当前可见范围的原因,不显示“仅负责人可见”,也不恢复应用、授权或审批;其它依法可读的冻结审批内容保持可读。
  • +
  • AC-37 — IF 不能完整确认当前授权或对象资格,THEN THE SYSTEM SHALL 明确表示当前可见范围暂不可确认,并允许重新查询;不得把失败、部分结果或缺失信息当作完整名单、无人可见或“仅负责人可见”,其它依法可读的冻结审批内容仍保持可读。
  • +
  • AC-38 — WHERE 既有“我的审批”“我的申请”或审批管理详情允许阅读该应用发布审批,THE SYSTEM SHALL 保持上述当前范围含义一致;本次不新增审批管理入口或异常处理资格。
  • +
+

2.4 所属租户的知识库新建资格(D4)

+
    +
  • AC-39 — WHEN 当前有效的租户管理员进入文档知识库页面,THE SYSTEM SHALL 提供新建入口,并允许其在自己所属租户创建文档知识库,不要求另有创建知识库菜单授权。
  • +
  • AC-40 — WHEN 当前有效的租户管理员进入 QA 知识库页面或提交新建,THE SYSTEM SHALL 提供与文档知识库一致的本租户新建资格,不因知识库类型不同而缺失入口或被创建菜单拒绝。
  • +
  • AC-41 — WHEN 判断是否可以新建知识库,THE SYSTEM SHALL 使页面入口与实际创建资格一致,并以服务端确认的当前身份和所属租户判定;仅修改界面、伪造管理员标记或绕过页面直接请求均不能获得额外资格。
  • +
  • AC-42 — WHEN 按上述资格创建知识库,THE SYSTEM SHALL 保持资源归属为实际创建自然人及其所属租户,不把页面管理视角、选中的其它租户或服务账号改写为创建人和资源租户。
  • +
  • AC-43 — IF 租户管理员试图在所属租户以外创建知识库,THEN THE SYSTEM SHALL 拒绝,不允许通过篡改租户信息或沿用其它租户管理页面完成创建。
  • +
  • AC-44 — IF 打开新建表单后,人员被停用、租户管理资格被撤销或所属租户变化,THEN THE SYSTEM SHALL 在实际提交时重新按当前有效资格判定;不再有创建资格或表单目标不再属于当前所属租户时拒绝,不能写入旧租户或静默改写到其它租户。
  • +
  • AC-45 — WHEN 无创建菜单且不具租户管理员或其它既有创建资格的普通用户尝试新建,THE SYSTEM SHALL 保持拒绝,不因本次修复取得创建资格。
  • +
  • AC-46 — WHEN 普通用户已有有效创建菜单授权,THE SYSTEM SHALL 保留其原有新建资格及其它既有校验,不要求该用户同时成为租户管理员。
  • +
  • AC-47 — WHEN 平台超管新建知识库,THE SYSTEM SHALL 保持其既有创建资格与归属规则,不把管理视角切换改为创建到所选租户的能力。
  • +
  • AC-48 — WHEN 用户只有一般部门管理员身份,THE SYSTEM SHALL 不因该身份获得本次新增的租户管理员创建资格;其其它合法创建途径不受影响。
  • +
  • AC-49 — WHEN 用户复制知识库,THE SYSTEM SHALL 继续要求原有创建菜单资格与源资源的读取资格;本次本租户新建例外不自动放开复制、源资源读取或其它资源动作。
  • +
  • AC-50 — WHEN 租户管理员按新增资格提交新建,THE SYSTEM SHALL 继续遵守已有模型可用性、容量、名称、租户状态等创建条件;不得通过新增角色、补写个人菜单或批量授权来改变已有账号配置。
  • +
+

2.5 共同约束

+
    +
  • AC-51 — WHEN 展示本功能新增或修改的状态、提示及范围说明,THE SYSTEM SHALL 在简体中文、英文和日文中提供含义一致的文案,保留真实零值、未知、无记录、查询失败及“仅负责人可见”的区别。
  • +
  • AC-52 — WHEN 使用上述运行信息、审计查询、审批摘要或新建资格,THE SYSTEM SHALL 保持现有身份有效性、具体租户视角及资源权限边界,不迁移存量资源、改写历史记录或自动补授权;本规格的明确例外仅为 AC-33 的审批只读摘要与 AC-39、AC-40 的本租户知识库新建。
  • +
+

3. 边界情况

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
场景期望的可观测结果验收关联
Docker 没有原生健康检查,但已有平台探活事实使用真实应用探活结果;没有可确认结果才表达未知AC-02、AC-03
进程运行但探活失败、执行体正在启动、观测依赖失败分别表达异常、启动中、暂不可确认,不能统称健康AC-02–AC-04
重启计数为零、缺失;应用容器与初始化容器时间不同零与未知不同;只显示当前应用容器事实AC-05–AC-08
停止、删除执行体、版本替换后刷新不把旧执行体信息当作当前值;读取没有生命周期副作用AC-09、AC-10
历史记录只有应用自身身份,或与自然人记录混合前者明确无访问用户;后者保留真实用户候选AC-12–AC-17
搜索无匹配、加载失败、具体租户切换三者不能混为无记录;重试与新范围不泄露旧信息AC-18–AC-20
首发审批之后授权增加、减少,或人员迁租户/停用刷新显示当前有效对象;不回写审批快照AC-22–AC-32
审批阅读者没有长期管理授权只读当前摘要;不因此获得授权管理、源码或预览权限AC-33、AC-34
已无任何有效普通访问对象,且负责人也失效不能显示“仅负责人可见”;完整查询可表达当前无有效普通访问对象,并与依赖失败区分AC-23、AC-27–AC-29、AC-37
应用删除、权限或组织信息不能完整确认明确原因,不显示误导性的负责人可见或完整名单;保留合法冻结内容AC-35–AC-37
租管无创建菜单,或表单打开后资格变化前者可在所属租户新建;后者重新校验并按当前资格拒绝不再合法的提交AC-39–AC-44
复制知识库、一般部门管理员、普通有/无菜单用户本次不扩大复制或部门管理资格,普通用户沿原创建规则AC-45–AC-50
+

4. 与既有规格的关系

+

本节登记需求增量,不将旧规格原文或旧验收证据改写为本功能已交付。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
既有合同本规格关系
PRD-1 RT-03 审批人侧“可见范围:提交时快照”依本轮已确认 D3,按本规格当前授权展示口径修订;仅此展示项发生变化,不把发布其它内容改为动态
F054 运行状态;F059/F086 双形态AC-01–AC-10 补齐当前事实与未知语义;不改变运行控制协议或生命周期权限
F056 审计查询;F051 调用身份事实AC-11–AC-21 明确真实身份与空态,不新增自然人身份来源、不改历史账本
F055 AC-24 的可见范围快照部分由 AC-22–AC-32、AC-36、AC-37 修订为查询时的当前授权展示;其余冻结审读与审批行为保持
F114 AC-33 的完整授权主体查看限制追加 AC-33 的限定审批只读例外;其它授权目录查询、授权编辑仍按 AC-34 和原 manage_permission 合同
F114 审批任务/管理资格及 F113 租户边界AC-32–AC-38、AC-52 保留原资格;当前摘要不产生新的审批、管理或跨租户能力
知识库既有新建菜单资格AC-39、AC-40 追加当前租户管理员的所属租户新建资格;AC-42–AC-50 保留归属、其它校验及复制规则
版本领域归属及 INV不新增独立领域对象或权限动作,不修改不变量;只协调原有领域需求增量
+

5. 设计与实施

+

正式规格已获用户单独确认,当前进入设计;任务拆解与业务编码尚未开始。设计再单独确认后进入任务与开发。接口、错误码、数据流、实现文件及定向验证步骤在相应阶段定义。

+

验证边界沿已确认发现:只做本功能必要的定向验证,不跑全量测试;真实环境写入验收和服务更新不由本次规格确认自动授权。

+

相关文档

+ +
+ +

F115 正式规格自审记录

+

日期:2026-11-02。对象:spec.md,AC-01 至 AC-52,共 52 条。审查方式:同一代理按 sdd-review 的 spec 清单自审,非独立代理复审。

+

阶段更新:用户于 2026-11-02 回复“确认规格,进入设计”,正式规格已确认;下文保留当次自审与当时暂停点。

+

结论

+

LGTM

+

此结论仅表示当前规格通过静态自审,仍须用户单独确认正式规格;不表示设计已确认、业务代码已完成或真实环境已通过验证。

+

审查依据

+
    +
  • 已获用户确认的 discovery.md D1–D4 与五项原始问题。
  • +
  • 应用工场 PRD-1 的 RT-01、RT-03、GOV-01、GOV-04;本轮 D3 是用户明确采纳的可见范围展示合同修订,D4 是明确采纳的创建资格增量。
  • +
  • features/_templates/spec.md、docs/SDD-Guide.md、docs/constitution.md、v3.0.0 release-contract.md。
  • +
  • F055 AC-24、F114 AC-33 及既有审批/租户资格;后端领域与权限架构说明。
  • +
  • .claude/skills/sdd-review/references/spec-checklist.md 的实际 10 项检查。技能入口中的“14 项”及“spec 合并设计”说明与现行清单、模板和 SDD Guide 不一致,本次按现行清单与纯 What 口径执行,没有冒记额外检查。
  • +
+

清单结果

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#检查项结果核对证据
1功能与场景覆盖PASSD1→AC-01–AC-10;D2→AC-11–AC-21;D3→AC-22–AC-38;D4→AC-39–AC-50;共同约束→AC-51、AC-52,五项问题均有对应
2边界与错误场景PASS运行未知/失败/替换、真实零值、混合调用身份、搜索/加载失败、失效对象、终态审批、删除与不完整授权、新建资格变化及复制边界均有可观测结果
3明确排除项PASS不新建监控体系、不猜测身份、不改历史快照、不改已有应用源码、不迁移资源、不批量补授权、不扩展复制与其它角色资格
4唯一且可追溯 ACPASS52 个定义,连续且唯一为 AC-01–AC-52;范围表、边界表和合同关联均引用本规格已定义编号
5可测试性PASS零值/未知、合法范围、真实身份、只读摘要、可见对象、创建拒绝均有可观察条件与结果;无“响应快”等不可测要求
6EARS 句型PASS每条 AC 使用 WHEN / WHILE / IF / WHERE 与 THE SYSTEM SHALL;条件变化、异常拒绝与正常结果分明
7错误码边界PASS规格只要求可观测错误含义,未分配错误码或维护实现错误码表
8领域归属PASS只协调既有运行信息、审计、发布场景及知识库创建资格;版本契约表与增量记录同步登记 F115,没有取得权限/审批/组织对象所有权
9不变量与架构铁律PASS保持可信身份、租户隔离、双形态一致、发布必审、资产持久;不要求直接访问权限引擎或编排器。F055/F114 两处既有合同修订已明示,不伪装为原规则不变
10纯 WhatPASS不含接口形态、数据模型、分层接线、实现文件清单或性能指标;仅定义用户结果与权限边界,设计尚未开始
+

本轮自审补齐

+
    +
  1. 显式登记 F114 AC-33 的限定审批摘要例外,防止将“审批人可读”误扩展为通用授权目录读取资格。
  2. +
  3. 明确“当前授权范围”与“此刻运行可达”不同:草稿、待审批和下线本身不清空已生效授权,否则首发审批永远无法准确表达范围。
  4. +
  5. 明确负责人已失效且无其它有效普通授权时的空态;完整查询失败或部分结果不能宣称“仅负责人可见”。
  6. +
+

以上是对已确认 D3 边界的明晰化,不新增资源移交、授权写入或发布行为。

+

验证范围与暂停点

+
    +
  • 仅核对 Markdown 结构、AC 唯一与引用、链接、HTML 导航及源文一致性;本轮没有业务代码变更,不运行业务测试或全量回归。
  • +
  • D1–D4 已于 2026-11-02 获用户确认;正式 spec 尚待确认,依据 AGENTS.md §6、SDD-Guide §3 停在 spec 确认点。
  • +
  • 用户确认后进入 design,并在设计中说明真实身份候选、当前授权查询边界、运行事实及知识库创建资格的具体实现与定向验收。
  • +
\ No newline at end of file diff --git a/docs/reviews/vibe-square-return-hotfix-2026-10-27.md b/docs/reviews/vibe-square-return-hotfix-2026-10-27.md new file mode 100644 index 0000000000..d1c1bb3ad4 --- /dev/null +++ b/docs/reviews/vibe-square-return-hotfix-2026-10-27.md @@ -0,0 +1,23 @@ +# 返回应用广场热修 + +## 根因和目标 + +用户访问 `/apps/today-tasks-demo` 的停用提示页,点击“返回广场”进入404。代码与116实际配置均未指定 `APP_PROXY_SQUARE_URL`,默认 `/workspace`。116的nginx仅以 `/workspace/` 分流client;裸路径进入platform SPA。正确目标是client应用广场 `/workspace/apps/explore`(`ExplorePlaza`),不是需要构建权限的后台页面。 + +源码修复:`app_proxy.config.DEFAULT_SQUARE_URL` 指向完整广场路由,页面渲染器共用此默认值,保留环境变量覆盖。116以Compose环境变量显式配置正确目标,保留原镜像,仅重建app-proxy;隔离绑定IP不变,未修改应用或审批状态。配置原件已在116 root-only目录备份。 + +## 定向验证与审查 + +| 维度 | 结果 | +|---|---| +| 边界 | 默认、空环境变量及自定义部署前缀;停用/拒绝/不存在页面均指向client路由 | +| 认证 | 既有认证和权限判断不变;真实验证使用已授权专用测试账号正常登录,会话仅在内存 | +| 并发 | 仅默认路径/配置修改,不增加缓存或状态 | +| 信息泄漏 | 不新增用户、凭据、日志或跨应用读取 | +| 测试 | `tests/test_square_target.py` 8 passed;未跑全量。既有WebSocket fixture需要本机回环监听,因此在沙箱外执行 | +| 风格 | Ruff、架构守卫、diff检查通过 | +| 文档 | F054设计及任务追加记录同步 | + +结论:热修代码自审 PASS。本记录与源码修复一起交付,提交及远端SHA以Git核验结果为准。 + +116真实登录只读验证:`/apps/today-tasks-demo` 提示页HTTP200,实际按钮href为 `/workspace/apps/explore`;目标HTTP200且加载client SPA。未执行浏览器视觉点击验收,HTTP200本身不等于完整浏览器功能验收。用户刷新停用提示页后即可获取新href。 diff --git a/docs/reviews/vibeapp-progress-2026-10-23.html b/docs/reviews/vibeapp-progress-2026-10-23.html new file mode 100644 index 0000000000..8e310f8e9f --- /dev/null +++ b/docs/reviews/vibeapp-progress-2026-10-23.html @@ -0,0 +1,276 @@ +VibeApp 开发与测试进度 · 2026-10-23

代码与证据快照 · 运行层已实现 / 整体交付未完成

VibeApp 开发与测试进度报告

+

日期:2026-10-23。评估对象:feat/3.0.0-vibe;以本次发布线同步后的代码、任务正文和已有脱敏运行证据为依据。

+

1. 结论

+

托管运行层的 Docker/K8s 开发已收口,平台发布及运行主链路已有真实验证;整体 Vibe 产品尚不能标为“开发与测试全部完成”。

+

需要分开看三件事:

+
    +
  1. 已完成的运行交付实现:开源独立 manager/app-proxy/egress-proxy,平台存储、应用包与依赖复用、Docker/K8s 编排、SQLite 维护、Chart/准入/能力报告和镜像流水线代码。
  2. +
  3. 仍有真实功能缺口:应用密钥管理最小版(GOV-05)、管理面“应用运行记录”查询(GOV-04);旧 F054 还保留 systemd 出站 INPUT 规则与 OBO 配置错误时入口拒绝两项安全待办。不能把这些归为“只差测试”。
  4. +
  5. 剩余验收与发行:完整离线平台新装/目录导入、剩余故障及独立卸载保留、部分 CLI/MCP/审计/页面 AC、当前整合版本正式双架构镜像发行。ARM 按用户要求暂缓,DM8 仅静态;不是本轮补跑范围。
  6. +
+

任务勾选是工程账本,不是功能完成率或 AC 通过率。特别是 GOV-04/GOV-05 只追加了文字决议,没有完整任务展开;不能据 F055 的 69/70、F056 的 32/33 宣称仅剩一次 E2E。

+

2. 最新发布线合并结果

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
项目固定值 / 结果
最新远端发布线origin/feat/3.0.0@38395b19804effe7a8474809f54f5f7396e5b940
合并前 Vibe276ec191b6c9e20b903232ad078d00378454010a
合并提交8121d5efdb28b8f887469dca39e34cea61151e63
合并范围43 个源分支独有提交;相对 merge-base 686 个文件,55 个双方改动文件
冲突22 个文件;逐 hunk 处理,无整文件覆盖另一方独有业务
本轮验证48 个唯一测试通过,来自定向分组;不重复累计中间失败或重跑;未跑全量或远端部署验收
Git 工作区在独立工作树完成;主工作区其他权限修复文件保留
+

本次继承 React Query v4、共享聊天、请求错误处理、排序/依赖治理、知识文件可见性与工具权限修复等发布线成果。源分支的业务改动按原提交进入 Vibe,不把 686 文件都称为本次新开发的托管功能。

+

合并时保留和适配的行为

+
    +
  • 必审:托管应用场景仍不提供免审选项,存量 pass 配置编辑时转为 flow;其他场景的免审能力保留。
  • +
  • 审批人来源:保留预置场景允许列表和单租户名称语义,同时使用发布线迁移后的组件;不把来源字符串误当为带 value 的对象。
  • +
  • 客户端审批:同时保留拒绝理由必填与详情/选中任务一致性守卫,加载失败时不对旧任务执行决定。
  • +
  • 慢停用:卡片开关保留等待和重复操作锁,并接入统一错误提示;失败时释放锁。
  • +
  • 新请求错误合同:可见范围、资源档位两个 Vibe 页面补齐 catch/finally;失败不走成功通知,不丢编辑草稿,不一直加载。四个新错误场景先复现失败后通过。
  • +
  • 多语言与共享包:合并 shared 的 hostedApp 与 app_chat 两套键,再由生成脚本生成两端文件;保留 file-viewers/chat/api-client 的 Jest 与样式扫描范围。
  • +
  • 后端检索:保留 Vibe 的 RetrievalEngine/统一 facade 接线,同时继承发布线 verified batch 可见性接口测试;服务账号身份与权限依赖失败行为有定向验证。
  • +
+

静态核对显示,本次发布线同步没有改动 manager、app-proxy、app-runtime、CLI/SDK、backend app_runtime/app_publish 的产品文件。共享权限/配置/前端发生了变化,因此合并后的完整平台兼容性仍需后续分项确认;此前 189 结果保留原镜像和源码,不能改标成本次合并版本的部署证明。

+

3. 开发进度:按实际模块

+

以下数字从各目录 tasks.md 的复选框重新统计;F049 已由 beta2 鉴权底座承接并归档,F050/F058 的既有取消/替代决定不作为当前未开发量。表内 F086 指 086-vibe-runtime-delivery,与新合入的 086-shared-app-chat 同号目录分别识别。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
模块账本完成已有实现尚未闭合
模型协议面27/30托管应用模型调用已接线;部分 SDK/OBO 调用有实证T025、T027、T028:指定环境端到端、本地引擎及审计账本核对
MCP 能力面33/35沿用统一 MCP 底座及应用工具接线T104、T302:存储层拒绝断言、真 MCP 客户端/CI 中间件
CLI / 开发工具52/53打包、认证、发布、补包、分发 wheel 已实现T033:原 114 验收清单需与 109/189 新证据逐 AC 对齐
应用领域 / 访问 / SQLite102/106应用与权限、入口代理、数据与生命周期已实现T074、T096、T098、T099;含两项历史安全待办,见下文
发布 / 审批 / 能力69/70发布预检、必审、预览、上线及失败态已实现T049 原环境验收;另有未纳入复选框的 GOV-05 应用密钥缺口
广场 / 授权 / 治理32/33广场入口、可见范围与生命周期审计已有实现T021 原环境闭环;另有未纳入复选框的 GOV-04 查询页缺口
SDK42/45SDK0.1.1、模型/OBO、附件与部分业务调用有实证T032、T040、T042:旧环境构建/手动验证及模型评测 6/6
K8s 托管268/272执行器、Pod 内物化、PVC/单写、准入、探测、Chart、helper 已实现T067、T070、T071、T072:剩余回归/故障卸载、发行、最终交付汇总
Docker / 开源交付92/94应用包/依赖复用、完整镜像、源码兼容、存储解耦、离线补包已实现T055、T056:完整离线安装、剩余故障及最终交付汇总
+

不将上述计数相加为全产品交付百分比:任务粒度不同、部分新决议尚未拆任务、旧指定环境条目与新运行证据也未逐 AC 汇合。

+

3.1 确定仍需开发的产品项

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
项目当前代码/记录完成所需工作
GOV-05 应用密钥最小版发布面没有完整密钥录入/状态区域;manifest validator 仍调用 _reject_secret_references,返回 16230;F055 2026-10-09 决议明确写“待展开”补已决定范围的规格/设计/任务,owner 密钥录入与加密存储、掩码/审计、声明缺值提示、启动注入与保存重启;不能把服务 HMAC/应用运行期凭据等同用户业务密钥
GOV-04 应用运行记录已有发布生命周期审计及运行记录写入;未找到 PRD 决定的管理面三类记录查询 tab/子视图完整接线;F056 仍只记录方案 A 决议模型调用、应用能力调用、应用访问的授权查询/筛选/导出与管理页面;补凭据审计 app_id 关联并验证可追溯
F054 T098:legacy systemd 宿主入口规则tasks 仍未完成;egress legacy 防火墙代码仅有 DOCKER-USER 路径,未见该模块的 INPUT 规则实现明确仍支持的 legacy systemd 形态,补 INPUT 拒绝与实际允许/拒绝配对;不能用 K8s NetworkPolicy 实证替代
F054 T099:错误 OBO 配置时入口拒绝_issue_obo_token 缺 secret 或与 JWT secret 相同时返回 None;入口仍可返回不带 OBO 的材料,而代码已经有 OBO reader;任务仍未完成在正式/预览入口落实对应拒绝语义并做配置正反例;本轮未修改这项历史合同
+

证据:F055 密钥决议、manifest validator、F056 查询面决议、F054 安全待办、入口身份代码、legacy 防火墙。以上是静态核查,未新执行攻击或故障注入。

+

4. 测试进度:已有真实证据

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
层次 / 环境已有通过证据尚未证明
Docker,109CLI/服务账号发布、预检与审批、上线、HTTP/WS、SDK/OBO/附件、SQLite/恢复/删除;多租户与关键权限负例;部分故障及旧应用升级/回退;历史公开服务镜像启动检查本次合并后的再次部署;完整客户断网新装及目录导入;全部失败/取消/失联组合
K8s 安装,189单节点 amd64 专用资源;受限身份、准入、隔离、卷与 SQLite 能力、镜像启动的分项报告;R32 manager-only 交付多节点、NodeLost、其他 StorageClass 全面兼容;当前合并版本成套新发行安装
K8s 业务,189Python/Node/static 应用包、完整镜像、source-image 五类交付已有发布记录;r11 人工审批/预览、HTTP/WS/SDK/OBO/附件、owner/非owner/跨租户配对;在线与停用数据、CSV、停用恢复;R31 安全删除全部页面/CLI/MCP/开关兜底、全部档位变化时序、资源限额故障、CLI/通知/审计全链及全部缓存失效输入矩阵
K8s 故障与升级,189真实取消;manager 停机访问;容量前置拒绝;迁移失败后的 gen4 显式恢复;R31 升级数据保留;R32 在途 prepare 单次 SIGKILL 续办额外 init 失败、响应丢失、UID 替换/未知 writer 完整矩阵、独立安装域卸载保留;跨节点恢复
镜像与离线物料旧测试 Tag v3.0.0-test-vibe@755d4456e5 双原生七镜像 CI/目录与 109 启动;F059 helper/assembler/Chart/CI 实现及局部私仓 amd64 交付最终整合源码的九镜像双原生正式 CI、成套 digest/目录、完整离线客户安装;旧 Tag 不含全部后续修复
数据库历史 MySQL 竞争/事务实证;SQLite 数据主链路;DM8 模型/约束静态检查DM8 真驱动/DDL/事务本轮按用户要求不测;不把静态方言编译当真实 DM8 通过
开发审查F086/F059 开发整合 L2 分工审查、已发现问题修复及证据归档完整 Feature 的逐 AC 交付签收、全部验收门关闭
+

4.1 R32 在途重启的准确范围

+
    +
  • manager 源码:0f2db1f9a29213ca94d4596e64b00ad8fcbac528;内网 manager digest:sha256:bc488d16d3b75ccd30e729d7ef79b0c4591abaa40c0037057697f405d9e4900b,Helm revision 32。
  • +
  • 一次 SIGKILL 后 controller 30→31;原 prepare、deadline、安装 Pod UID/CID 保持,240 秒 hook 自然完成,8 个 prepare 资源在停止证明后自然清理并释放预占。
  • +
  • 不包含批准上线或生产路由操作;后续 probe 的 SQL completed/cleanupcomplete 不扩大成 probe 物理清理证明。旧失败与 unknown writer 围栏保留。
  • +
+

权威分项记录:安装、业务、故障升级、Docker 真实记录。这些是保存的历史证据,本轮没有 SSH 刷新环境状态。

+

5. 本次合并的必要定向验证

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
分组唯一用例数最终结果
Platform:必审路由、来源词汇、共享选择器、卡片慢停用、个人令牌、资源档位及可见范围错误39通过;按受影响文件分组执行,未重复计算重跑
Client:审批列表/详情失败及陈旧任务决定守卫3通过
Backend:检索两分支、索引前后过滤、服务账号 actor、依赖失败 5036通过
合计48仅本次选择,不等于全仓/所有 AC
+

静态检查:冲突标记为零;13 个受影响 TS/TSX 语法检查通过(不等于完整 TypeScript 类型检查);Platform 冲突/修复文件定向 ESLint 自动 prune 通过,未增加任何超出父分支的 suppression;Python 冲突测试 Ruff/format 与逐文件架构检查通过;i18n parity 和生成物 check 通过;迁移静态图与运行模块保留检查通过。

+

本地依赖使用已有缓存,未重新 pnpm install;workspace 包指向本工作树。客户端审批 DOM 测试在进程中禁用无构建二进制的可选 canvas,未模拟业务判断;最初依赖缺失/未构建属于测试环境准备失败,不计作通过。当前缓存缺少部分锁定的 dnd/SheetJS/本地编辑器物料,未据此声称完整前端构建可重复。

+

全差异 git diff --check 发现上游四个文件的既有空白/CRLF 问题;逐文件确认与发布线字节相同,没有批量格式化。双方改动交集及本轮修复范围的 diff 检查通过。未跑全量测试、全项目 lint/typecheck/build、未重做 Docker/K8s 部署、未发新 Tag 或公开镜像。

+

6. 账本与证据需要收敛的地方

+
    +
  1. F054 的 T074 仍写 Compose 从未起过,而 F086 已有 109 实证;F059 e2e-checklist 的“真实状态”表仍大量写未执行,与后续 JSON 分项证据不同步。应逐 AC 关联,不能整表勾选。
  2. +
  3. 部分旧任务指定 114,新证据在 109/189;有些业务断言可复用,但原环境/CLI/MCP/页面要求不会自动完成。
  4. +
  5. F059 validation.json 顶层大套件检查仍对应旧源码 f8863f376...,追加段才记录后续 R31/R32;只读顶层会低估进展,把旧大套件数当新 merge 实测又会高估。
  6. +
  7. 上轮“开发整合已收口”针对 F086/F059 的运行交付工作,不覆盖本报告发现的 GOV-04/GOV-05 全产品缺口。不能沿用为全部 Vibe 产品完成结论。
  8. +
+

7. 建议的收尾顺序

+
    +
  1. 先解决真实开发缺口:GOV-05、GOV-04 单独补正式规格/设计/任务;T098/T099 按仍支持的部署形态关闭或做明确的范围决定,不伪装成已测试。
  2. +
  3. 按新合并版本做最小兼容确认:一次实际发布→审批预览→上线访问,普通 owner/非owner/跨租户配对;选现有专用应用,不重跑已无变化的全部安装与依赖组合。
  4. +
  5. 定向补故障差额:init 失败、响应丢失、必要 UID/未知 writer 负例、独立安装域卸载保留;每项有明确缺口才执行,不反复跑完整套件。
  6. +
  7. 最后做成套发行:冻结源码/profile/digest,运行正式九镜像流水线并验证离线目录与完整断网新装;ARM 继续按用户当前优先级后置,不能先写双架构通过。
  8. +
  9. 逐 AC 汇总签收:将跨 Feature 的历史证据和新差额按实际断言合并,更新账本和最终交付审查。到此才适合标记整体可正式交付。
  10. +
+

8. 资料与范围

+ +

本报告是当前仓库静态评估+既有证据核对;没有把未执行、skip、模拟端口、历史通过结果,转写成当前整合版本的真实验收通过。

+
\ No newline at end of file diff --git a/docs/reviews/vibeapp-progress-2026-10-23.json b/docs/reviews/vibeapp-progress-2026-10-23.json new file mode 100644 index 0000000000..849b092149 --- /dev/null +++ b/docs/reviews/vibeapp-progress-2026-10-23.json @@ -0,0 +1,150 @@ +{ + "date": "2026-10-23", + "branch": "feat/3.0.0-vibe", + "release_source": "38395b19804effe7a8474809f54f5f7396e5b940", + "vibe_before": "276ec191b6c9e20b903232ad078d00378454010a", + "merge_commit": "8121d5efdb28b8f887469dca39e34cea61151e63", + "source_only_commits": 43, + "source_changed_files_from_merge_base": 686, + "overlap_files": 55, + "conflict_files": 22, + "task_counts": { + "051-model-protocol-gateway": { + "complete": 27, + "total": 30, + "pending": [ + "**T025**: 114 托管应用端到端(2h)**依赖 F055 T056 部署到 114**", + "**T027**: 本地引擎手动验证(Qwen Code / Codex CLI 对话补全模式 / Kimi Code / Claude Code 反例)(3h)", + "**T028**: 审计与账本核对(1h)" + ] + }, + "052-mcp-server-face": { + "complete": 33, + "total": 35, + "pending": [ + "**T104**: 集合相等 + fail-closed 集成用例(CI 中间件分组)——**部分落地,未完成**(勾选留空:`[x]` 会让本条在「哪些还没做」的检索里消失,而 AC-40 / AC-42 / AC-44 的存储层断言确实还没有)", + "**T302**: 端到端(真 MCP 客户端 + CI 中间件)" + ] + }, + "053-dev-cli-skills": { + "complete": 52, + "total": 53, + "pending": [ + "**T033**: `[MVP-核心]` 114 部署与手动验证清单(演示剧本步 2–3)" + ] + }, + "054-app-domain-runtime": { + "complete": 102, + "total": 106, + "pending": [ + "**T074**: `[MVP-核心]` compose 两个 service + profile 表达「整层不装」 **🟡 部分完成——配置面已交付并有机器守卫,但 compose 形态从未真机起过,故不勾**", + "**T096**: E2E(`/e2e-test`)+ 页面手动验证清单", + "**T098**: 出站白名单 systemd 形态缺口——补宿主 INPUT 规则", + "**T099**: `obo_secret` 缺失或等于 `jwt_secret` 时入口改为拒绝进入" + ] + }, + "055-app-publish-pipeline": { + "complete": 69, + "total": 70, + "pending": [ + "**T049**: `[MVP-核心]` 114 部署与手动验证清单(演示剧本步 3–4)" + ] + }, + "056-app-square-governance": { + "complete": 32, + "total": 33, + "pending": [ + "**T021**: `[MVP-核心]` 114 部署与 MVP-核心闭环手动验证清单(演示剧本步 5–6)" + ] + }, + "057-bisheng-sdk": { + "complete": 42, + "total": 45, + "pending": [ + "**T032**: 114 构建期真机验证〔1.5h,`needs_114`〕", + "**T040**: 评测跑分(模型判定)并回填〔1.5h〕", + "**T042**: 114 手动验证(`/e2e-test features/v3.0.0/057-bisheng-sdk`)〔3h,`needs_114`〕" + ] + }, + "059-k8s-runtime-backend": { + "complete": 268, + "total": 272, + "pending": [ + "**T067** — Docker 与平台回归", + "**T070** — 故障与升级卸载验收", + "**T071** — 模型静态兼容与发行证据", + "**T072** — L2 代码审查及交付核对" + ] + }, + "086-vibe-runtime-delivery": { + "complete": 92, + "total": 94, + "pending": [ + "**T055**:API E2E与故障证据", + "**T056**:最终审查和证据汇总" + ] + } + }, + "focused_tests": { + "platform": 39, + "client": 3, + "backend": 6, + "unique_total": 48, + "whole_suite_run": false + }, + "development_gaps": [ + "GOV-05 application secrets", + "GOV-04 runtime record query UI", + "F054 T098 legacy systemd INPUT rules", + "F054 T099 invalid OBO config entry rejection" + ], + "pending_acceptance": [ + "full offline platform installation and catalog import", + "remaining fault and independent uninstall retention matrix", + "selected CLI/MCP/UI/audit AC", + "current revision public nine-image release" + ], + "arm": "deferred by user", + "dm8": "static only", + "remote_environment_refreshed": false, + "full_product_delivered": false, + "git_full_diff_whitespace": "inherited in four byte-identical release files; overlap/fix scoped check passed", + "local_test_environment": "existing dependency cache; optional canvas disabled for approval DOM tests; no reinstall", + "focused_test_files": { + "platform": [ + "src/test/approvalRouteGuard.test.tsx", + "src/test/approvalApproverSources.test.ts", + "src/pages/ApprovalPage/selectionControls.test.tsx", + "src/test/hostedAppCardComponent.test.tsx", + "src/test/personalTokenSettingsInteraction.test.tsx", + "src/test/resourceTierTab.test.tsx", + "src/pages/BuildPage/hostedApp/publish/VisibilityScopeSection.errors.test.tsx" + ], + "client": [ + "src/components/approval/ApprovalPaneErrors.test.tsx" + ], + "backend": [ + "test/knowledge/test_openapi_retrieve_file_visibility.py" + ] + }, + "static_checks": { + "i18n_parity": "passed", + "locale_artifacts": "passed", + "platform_eslint": "focused files passed and automatic prune only", + "new_suppression_increases_vs_parents": 0, + "ts_tsx_syntax_files": 13, + "full_typecheck": false, + "alembic_revisions": 115, + "alembic_head": "f059_resource_phases", + "architecture_guard": "focused files passed", + "runtime_modules_changed_by_merge": false, + "report_links_tables_css": "static checks passed; no browser visual claim" + }, + "compatibility_repairs": [ + "mandatory approval options + newer Select", + "source allowlist strings + newer Dropdown", + "stale task guard + required rejection comment", + "slow stop pending lock + request errors", + "request rejection handling in visibility and resource tiers" + ] +} diff --git a/docs/reviews/vibeapp-progress-2026-10-23.md b/docs/reviews/vibeapp-progress-2026-10-23.md new file mode 100644 index 0000000000..05ee26ef9b --- /dev/null +++ b/docs/reviews/vibeapp-progress-2026-10-23.md @@ -0,0 +1,128 @@ +# VibeApp 开发与测试进度报告 + +日期:2026-10-23。评估对象:`feat/3.0.0-vibe`;以本次发布线同步后的代码、任务正文和已有脱敏运行证据为依据。 + +## 1. 结论 + +**托管运行层的 Docker/K8s 开发已收口,平台发布及运行主链路已有真实验证;整体 Vibe 产品尚不能标为“开发与测试全部完成”。** + +需要分开看三件事: + +1. **已完成的运行交付实现**:开源独立 manager/app-proxy/egress-proxy,平台存储、应用包与依赖复用、Docker/K8s 编排、SQLite 维护、Chart/准入/能力报告和镜像流水线代码。 +2. **仍有真实功能缺口**:应用密钥管理最小版(GOV-05)、管理面“应用运行记录”查询(GOV-04);旧 F054 还保留 systemd 出站 INPUT 规则与 OBO 配置错误时入口拒绝两项安全待办。不能把这些归为“只差测试”。 +3. **剩余验收与发行**:完整离线平台新装/目录导入、剩余故障及独立卸载保留、部分 CLI/MCP/审计/页面 AC、当前整合版本正式双架构镜像发行。ARM 按用户要求暂缓,DM8 仅静态;不是本轮补跑范围。 + +任务勾选是工程账本,不是功能完成率或 AC 通过率。特别是 GOV-04/GOV-05 只追加了文字决议,没有完整任务展开;不能据 F055 的 69/70、F056 的 32/33 宣称仅剩一次 E2E。 + +## 2. 最新发布线合并结果 + +| 项目 | 固定值 / 结果 | +| --- | --- | +| 最新远端发布线 | `origin/feat/3.0.0@38395b19804effe7a8474809f54f5f7396e5b940` | +| 合并前 Vibe | `276ec191b6c9e20b903232ad078d00378454010a` | +| 合并提交 | `8121d5efdb28b8f887469dca39e34cea61151e63` | +| 合并范围 | 43 个源分支独有提交;相对 merge-base 686 个文件,55 个双方改动文件 | +| 冲突 | 22 个文件;逐 hunk 处理,无整文件覆盖另一方独有业务 | +| 本轮验证 | 48 个唯一测试通过,来自定向分组;不重复累计中间失败或重跑;未跑全量或远端部署验收 | +| Git 工作区 | 在独立工作树完成;主工作区其他权限修复文件保留 | + +本次继承 React Query v4、共享聊天、请求错误处理、排序/依赖治理、知识文件可见性与工具权限修复等发布线成果。源分支的业务改动按原提交进入 Vibe,不把 686 文件都称为本次新开发的托管功能。 + +### 合并时保留和适配的行为 + +- **必审**:托管应用场景仍不提供免审选项,存量 pass 配置编辑时转为 flow;其他场景的免审能力保留。 +- **审批人来源**:保留预置场景允许列表和单租户名称语义,同时使用发布线迁移后的组件;不把来源字符串误当为带 value 的对象。 +- **客户端审批**:同时保留拒绝理由必填与详情/选中任务一致性守卫,加载失败时不对旧任务执行决定。 +- **慢停用**:卡片开关保留等待和重复操作锁,并接入统一错误提示;失败时释放锁。 +- **新请求错误合同**:可见范围、资源档位两个 Vibe 页面补齐 catch/finally;失败不走成功通知,不丢编辑草稿,不一直加载。四个新错误场景先复现失败后通过。 +- **多语言与共享包**:合并 shared 的 hostedApp 与 app_chat 两套键,再由生成脚本生成两端文件;保留 file-viewers/chat/api-client 的 Jest 与样式扫描范围。 +- **后端检索**:保留 Vibe 的 RetrievalEngine/统一 facade 接线,同时继承发布线 verified batch 可见性接口测试;服务账号身份与权限依赖失败行为有定向验证。 + +静态核对显示,本次发布线同步没有改动 manager、app-proxy、app-runtime、CLI/SDK、backend app_runtime/app_publish 的产品文件。共享权限/配置/前端发生了变化,因此合并后的完整平台兼容性仍需后续分项确认;此前 189 结果保留原镜像和源码,不能改标成本次合并版本的部署证明。 + +## 3. 开发进度:按实际模块 + +以下数字从各目录 tasks.md 的复选框重新统计;F049 已由 beta2 鉴权底座承接并归档,F050/F058 的既有取消/替代决定不作为当前未开发量。表内 F086 指 `086-vibe-runtime-delivery`,与新合入的 `086-shared-app-chat` 同号目录分别识别。 + +| 模块 | 账本完成 | 已有实现 | 尚未闭合 | +| --- | --- | --- | --- | +| 模型协议面 | 27/30 | 托管应用模型调用已接线;部分 SDK/OBO 调用有实证 | T025、T027、T028:指定环境端到端、本地引擎及审计账本核对 | +| MCP 能力面 | 33/35 | 沿用统一 MCP 底座及应用工具接线 | T104、T302:存储层拒绝断言、真 MCP 客户端/CI 中间件 | +| CLI / 开发工具 | 52/53 | 打包、认证、发布、补包、分发 wheel 已实现 | T033:原 114 验收清单需与 109/189 新证据逐 AC 对齐 | +| 应用领域 / 访问 / SQLite | 102/106 | 应用与权限、入口代理、数据与生命周期已实现 | T074、T096、T098、T099;含两项历史安全待办,见下文 | +| 发布 / 审批 / 能力 | 69/70 | 发布预检、必审、预览、上线及失败态已实现 | T049 原环境验收;另有未纳入复选框的 GOV-05 应用密钥缺口 | +| 广场 / 授权 / 治理 | 32/33 | 广场入口、可见范围与生命周期审计已有实现 | T021 原环境闭环;另有未纳入复选框的 GOV-04 查询页缺口 | +| SDK | 42/45 | SDK0.1.1、模型/OBO、附件与部分业务调用有实证 | T032、T040、T042:旧环境构建/手动验证及模型评测 6/6 | +| K8s 托管 | 268/272 | 执行器、Pod 内物化、PVC/单写、准入、探测、Chart、helper 已实现 | T067、T070、T071、T072:剩余回归/故障卸载、发行、最终交付汇总 | +| Docker / 开源交付 | 92/94 | 应用包/依赖复用、完整镜像、源码兼容、存储解耦、离线补包已实现 | T055、T056:完整离线安装、剩余故障及最终交付汇总 | + +不将上述计数相加为全产品交付百分比:任务粒度不同、部分新决议尚未拆任务、旧指定环境条目与新运行证据也未逐 AC 汇合。 + +### 3.1 确定仍需开发的产品项 + +| 项目 | 当前代码/记录 | 完成所需工作 | +| --- | --- | --- | +| GOV-05 应用密钥最小版 | 发布面没有完整密钥录入/状态区域;manifest validator 仍调用 `_reject_secret_references`,返回 16230;F055 2026-10-09 决议明确写“待展开” | 补已决定范围的规格/设计/任务,owner 密钥录入与加密存储、掩码/审计、声明缺值提示、启动注入与保存重启;不能把服务 HMAC/应用运行期凭据等同用户业务密钥 | +| GOV-04 应用运行记录 | 已有发布生命周期审计及运行记录写入;未找到 PRD 决定的管理面三类记录查询 tab/子视图完整接线;F056 仍只记录方案 A 决议 | 模型调用、应用能力调用、应用访问的授权查询/筛选/导出与管理页面;补凭据审计 app_id 关联并验证可追溯 | +| F054 T098:legacy systemd 宿主入口规则 | tasks 仍未完成;egress legacy 防火墙代码仅有 DOCKER-USER 路径,未见该模块的 INPUT 规则实现 | 明确仍支持的 legacy systemd 形态,补 INPUT 拒绝与实际允许/拒绝配对;不能用 K8s NetworkPolicy 实证替代 | +| F054 T099:错误 OBO 配置时入口拒绝 | `_issue_obo_token` 缺 secret 或与 JWT secret 相同时返回 None;入口仍可返回不带 OBO 的材料,而代码已经有 OBO reader;任务仍未完成 | 在正式/预览入口落实对应拒绝语义并做配置正反例;本轮未修改这项历史合同 | + +证据:[F055 密钥决议](../../features/v3.0.0/055-app-publish-pipeline/tasks.md)、[manifest validator](../../src/backend/bisheng/app_publish/domain/services/manifest_validator.py)、[F056 查询面决议](../../features/v3.0.0/056-app-square-governance/tasks.md)、[F054 安全待办](../../features/v3.0.0/054-app-domain-runtime/tasks.md)、[入口身份代码](../../src/backend/bisheng/app_runtime/domain/services/entry_authz_service.py)、[legacy 防火墙](../../src/runtime-manager/runtime_manager/egress.py)。以上是静态核查,未新执行攻击或故障注入。 + +## 4. 测试进度:已有真实证据 + +| 层次 / 环境 | 已有通过证据 | 尚未证明 | +| --- | --- | --- | +| Docker,109 | CLI/服务账号发布、预检与审批、上线、HTTP/WS、SDK/OBO/附件、SQLite/恢复/删除;多租户与关键权限负例;部分故障及旧应用升级/回退;历史公开服务镜像启动检查 | 本次合并后的再次部署;完整客户断网新装及目录导入;全部失败/取消/失联组合 | +| K8s 安装,189 | 单节点 amd64 专用资源;受限身份、准入、隔离、卷与 SQLite 能力、镜像启动的分项报告;R32 manager-only 交付 | 多节点、NodeLost、其他 StorageClass 全面兼容;当前合并版本成套新发行安装 | +| K8s 业务,189 | Python/Node/static 应用包、完整镜像、source-image 五类交付已有发布记录;r11 人工审批/预览、HTTP/WS/SDK/OBO/附件、owner/非owner/跨租户配对;在线与停用数据、CSV、停用恢复;R31 安全删除 | 全部页面/CLI/MCP/开关兜底、全部档位变化时序、资源限额故障、CLI/通知/审计全链及全部缓存失效输入矩阵 | +| K8s 故障与升级,189 | 真实取消;manager 停机访问;容量前置拒绝;迁移失败后的 gen4 显式恢复;R31 升级数据保留;R32 在途 prepare 单次 SIGKILL 续办 | 额外 init 失败、响应丢失、UID 替换/未知 writer 完整矩阵、独立安装域卸载保留;跨节点恢复 | +| 镜像与离线物料 | 旧测试 Tag `v3.0.0-test-vibe@755d4456e5` 双原生七镜像 CI/目录与 109 启动;F059 helper/assembler/Chart/CI 实现及局部私仓 amd64 交付 | 最终整合源码的九镜像双原生正式 CI、成套 digest/目录、完整离线客户安装;旧 Tag 不含全部后续修复 | +| 数据库 | 历史 MySQL 竞争/事务实证;SQLite 数据主链路;DM8 模型/约束静态检查 | DM8 真驱动/DDL/事务本轮按用户要求不测;不把静态方言编译当真实 DM8 通过 | +| 开发审查 | F086/F059 开发整合 L2 分工审查、已发现问题修复及证据归档 | 完整 Feature 的逐 AC 交付签收、全部验收门关闭 | + +### 4.1 R32 在途重启的准确范围 + +- manager 源码:`0f2db1f9a29213ca94d4596e64b00ad8fcbac528`;内网 manager digest:`sha256:bc488d16d3b75ccd30e729d7ef79b0c4591abaa40c0037057697f405d9e4900b`,Helm revision 32。 +- 一次 SIGKILL 后 controller 30→31;原 prepare、deadline、安装 Pod UID/CID 保持,240 秒 hook 自然完成,8 个 prepare 资源在停止证明后自然清理并释放预占。 +- 不包含批准上线或生产路由操作;后续 probe 的 SQL completed/cleanupcomplete 不扩大成 probe 物理清理证明。旧失败与 unknown writer 围栏保留。 + +权威分项记录:[安装](../../features/v3.0.0/059-k8s-runtime-backend/e2e-evidence/installation.json)、[业务](../../features/v3.0.0/059-k8s-runtime-backend/e2e-evidence/platform.json)、[故障升级](../../features/v3.0.0/059-k8s-runtime-backend/e2e-evidence/fault-upgrade.json)、[Docker 真实记录](../../features/v3.0.0/086-vibe-runtime-delivery/live-validation.md)。这些是保存的历史证据,本轮没有 SSH 刷新环境状态。 + +## 5. 本次合并的必要定向验证 + +| 分组 | 唯一用例数 | 最终结果 | +| --- | ---: | --- | +| Platform:必审路由、来源词汇、共享选择器、卡片慢停用、个人令牌、资源档位及可见范围错误 | 39 | 通过;按受影响文件分组执行,未重复计算重跑 | +| Client:审批列表/详情失败及陈旧任务决定守卫 | 3 | 通过 | +| Backend:检索两分支、索引前后过滤、服务账号 actor、依赖失败 503 | 6 | 通过 | +| 合计 | **48** | 仅本次选择,不等于全仓/所有 AC | + +静态检查:冲突标记为零;13 个受影响 TS/TSX 语法检查通过(不等于完整 TypeScript 类型检查);Platform 冲突/修复文件定向 ESLint 自动 prune 通过,未增加任何超出父分支的 suppression;Python 冲突测试 Ruff/format 与逐文件架构检查通过;i18n parity 和生成物 check 通过;迁移静态图与运行模块保留检查通过。 + +本地依赖使用已有缓存,未重新 pnpm install;workspace 包指向本工作树。客户端审批 DOM 测试在进程中禁用无构建二进制的可选 canvas,未模拟业务判断;最初依赖缺失/未构建属于测试环境准备失败,不计作通过。当前缓存缺少部分锁定的 dnd/SheetJS/本地编辑器物料,未据此声称完整前端构建可重复。 + +全差异 `git diff --check` 发现上游四个文件的既有空白/CRLF 问题;逐文件确认与发布线字节相同,没有批量格式化。双方改动交集及本轮修复范围的 diff 检查通过。未跑全量测试、全项目 lint/typecheck/build、未重做 Docker/K8s 部署、未发新 Tag 或公开镜像。 + +## 6. 账本与证据需要收敛的地方 + +1. F054 的 T074 仍写 Compose 从未起过,而 F086 已有 109 实证;F059 e2e-checklist 的“真实状态”表仍大量写未执行,与后续 JSON 分项证据不同步。应逐 AC 关联,不能整表勾选。 +2. 部分旧任务指定 114,新证据在 109/189;有些业务断言可复用,但原环境/CLI/MCP/页面要求不会自动完成。 +3. F059 `validation.json` 顶层大套件检查仍对应旧源码 `f8863f376...`,追加段才记录后续 R31/R32;只读顶层会低估进展,把旧大套件数当新 merge 实测又会高估。 +4. 上轮“开发整合已收口”针对 F086/F059 的运行交付工作,不覆盖本报告发现的 GOV-04/GOV-05 全产品缺口。不能沿用为全部 Vibe 产品完成结论。 + +## 7. 建议的收尾顺序 + +1. **先解决真实开发缺口**:GOV-05、GOV-04 单独补正式规格/设计/任务;T098/T099 按仍支持的部署形态关闭或做明确的范围决定,不伪装成已测试。 +2. **按新合并版本做最小兼容确认**:一次实际发布→审批预览→上线访问,普通 owner/非owner/跨租户配对;选现有专用应用,不重跑已无变化的全部安装与依赖组合。 +3. **定向补故障差额**:init 失败、响应丢失、必要 UID/未知 writer 负例、独立安装域卸载保留;每项有明确缺口才执行,不反复跑完整套件。 +4. **最后做成套发行**:冻结源码/profile/digest,运行正式九镜像流水线并验证离线目录与完整断网新装;ARM 继续按用户当前优先级后置,不能先写双架构通过。 +5. **逐 AC 汇总签收**:将跨 Feature 的历史证据和新差额按实际断言合并,更新账本和最终交付审查。到此才适合标记整体可正式交付。 + +## 8. 资料与范围 + +- [本次结构化快照](./vibeapp-progress-2026-10-23.json):源/目标/merge SHA、任务统计、定向验证与边界。 +- [上轮运行开发整合报告](./vibe-development-integration-2026-10-23.md):历史整合来源及原分工 L2。 +- [离线 HTML](./vibeapp-progress-2026-10-23.html):与本 Markdown 同源生成,无外部字体、CDN 或在线脚本。 + +本报告是当前仓库静态评估+既有证据核对;没有把未执行、skip、模拟端口、历史通过结果,转写成当前整合版本的真实验收通过。 diff --git a/docs/security-response.md b/docs/security-response.md new file mode 100644 index 0000000000..4192b682ff --- /dev/null +++ b/docs/security-response.md @@ -0,0 +1,103 @@ +# 安全响应流程 + +对外承诺见根目录 [`SECURITY.md`](../SECURITY.md)。本文是内部怎么兑现它。 + +--- + +## 为什么需要这份文档 + +2026 年 4 月到 9 月,至少 7 份漏洞报告在 GitHub 上公开,其中多份的正文里写着同一句话:**"先走私密通道报过,没有收到任何回应。"** + +- `#2189` / `#2190`:分别通过 GHSA 私密公告提交,正文注明"reported on 1 June 2026, no response" +- `#2415`:正文写"private security-advisory drafts on this repository have historically received no response" +- `#1959`:正文建议我们开启私密上报(其实**已经开着**,只是没人看) + +后果不是漏洞本身,而是:带完整 PoC 的攻击步骤在补丁发布前就全网公开,而且每份报告都附带一句"我先私下说过了"。**私密通道开着却没人值守,比不开更糟** —— 研究者白等一轮,然后带着火气公开。 + +当前事实(2026-09 核查): + +| 项 | 状态 | +|---|---| +| GitHub 私密漏洞上报 | **已开启**(`private-vulnerability-reporting` → `enabled: true`) | +| 已发布的安全公告 | **0 份** | +| 公开未关闭的安全类 issue | 6 份以上 | + +--- + +## 值守 + +**每周至少查看一次**,查看人固定到具体的人,不是"团队"。 + +要查的三个地方: + +1. `https://github.com/dataelement/bisheng/security/advisories` —— 草稿公告**不会**给 issue 订阅者发通知,只有仓库管理员能看到,最容易漏的就是这里 +2. 仓库 issue 列表里带安全字样的新条目 +3. Dependabot 告警(当前 264 条,另行排期,不走本流程) + +--- + +## 分级与时限 + +时限从**收到**算起,不是从开始处理算起。 + +| 级别 | 判据 | 首次回应 | 给出结论 | 修复发布 | +|---|---|---|---|---| +| 紧急 | 未认证即可远程执行代码、越权读取他人数据 | 1 个工作日 | 3 个工作日 | 尽快,可走热修版本 | +| 高 | 已认证用户可提权、越权、任意文件读写 | 3 个工作日 | 10 个工作日 | 下一个发布版本 | +| 中 / 低 | 需要特定配置或本地权限才能利用 | 3 个工作日 | 10 个工作日 | 正常排期 | +| 不成立 | 路径已不存在、不可利用、或属于部署配置 | 3 个工作日 | 10 个工作日 | 说明原因并关闭 | + +**"首次回应"不需要结论,一句"收到,正在核实"即可。** 这一句就能避免绝大多数"等不到就公开"。 + +--- + +## 处理流程 + +### 1. 确认能不能复现 + +不要凭报告正文下判断,**对着当前代码核实**,结论只有三种: + +- **成立** → 按下面走 +- **路径已不存在** → 查清是哪个提交、哪个版本移除的,回复里写明版本号(例:#2162、#2067、#1995 都是 v2.4.0 已移除) +- **报告正确但不可利用** → 说清挡住它的是什么。如果挡住它的是一个**巧合**而不是一道检查,仍然要加固(例:#1994 的 `save_download_file` 靠哈希前缀意外挡住) + +### 2. 定级、开分支 + +安全修复走轻量流程,不要求完整 SDD,但**必须有测试**,且测试要覆盖报告里给出的每一个 PoC 载荷。 + +分支落在**当前发布线**上(参见根 `AGENTS.md` 的分支规则)。跨版本线只能 cherry-pick。 + +### 3. 修复的两条原则 + +- **在根上修,不要在入口上堵。** 删掉一个接口不等于删掉它背后的能力:#1995 里接口在 v2.4.0 就删了,但那份含 8 处 `exec()` 的文件在仓库里又躺了半年。 +- **不要用黑名单做沙箱。** 限制 builtins、禁用模块、AST 校验这类方案在 Python 里挡不住绕过,别把它当成修复。 + +### 4. 发布与公告 + +修复发布后: + +1. 把对应的草稿公告补全并**发布**,申请 CVE 编号 +2. 回复公开 issue,写明修复在哪个版本 +3. 需要客户动手的(改配置、改密钥、跑迁移脚本),写进部署文档并单独通知 + +**发布公告不是可选项。** 客户做合规审计时要的是公告记录,不是散落在 issue 里的讨论。 + +--- + +## 回复的写法 + +这批报告的作者水平很高,PoC 都能复现。回复要满足三点: + +1. **承认属实的部分**,不要含糊其辞 +2. **没修的就说没修**,写清楚现状和计划,不要用"已优化"之类的措辞掩盖 +3. **前期失联是我们的问题**,直接认,不要解释 + +关于修复范围也要诚实:部分修复要说明剩下什么。例如 #2190 的模板归属,存量模板在被迁移前仍可被读取 —— 这一条主动说明,比等对方按 PoC 验出来再问要好。 + +--- + +## 已知教训 + +- **验收时记下的"Follow-up"没有归口就等于没记。** F014 验收文档里早就写明"环境变量覆盖不生效",一年后被外部研究者当成漏洞报回来(#2415)。 +- **删接口时要连带清理它背后的实现。** +- **默认配置就是绝大多数客户实际在跑的配置。** 自带的 compose 里写死的密钥、映射出去的管理端口,都要按"一定会被原样使用"来对待。 diff --git a/docs/status/app-description-20261028.md b/docs/status/app-description-20261028.md new file mode 100644 index 0000000000..dbbe01804f --- /dev/null +++ b/docs/status/app-description-20261028.md @@ -0,0 +1,31 @@ +# 应用对话简介显示修复 + +- 日期:2026-10-28。 +- 目标:`dataelement/bisheng:feat/bisheng-work-120-main`,基线 `8dc820f0afcd75ff8942deb553c072d97a691b15`。 +- 分支:`codex/app-chat-description`。 +- 范围:Client 助手详情数据读取层;应用对话入口和页面布局沿用现状。 + +## 根因与实现 + +助手详情接口使用 `desc` 保存简介。应用对话将接口数据直接写入会话状态,侧栏读取 `description`,产生已保存简介与会话显示不一致。 + +`getAssistantDetailApi` 在成功响应时将 `desc` 映射为统一的 `description`,同时保留其他字段。已清空的简介保持空字符串,已规范化的响应继续使用 `description`。v1/v2、分享请求与已有错误处理保持原有调用约定。新建会话、历史会话、应用侧栏和独立应用页共用此读取层。 + +## 验证 + +- 新增 7 项接口回归;原代码 5 项失败、2 项通过,修复后 7 项通过。 +- 应用对话相关回归合计 5 组、28 项通过。 +- Client ESLint 通过;工具自动收缩一项历史 any 豁免。 +- Client 生产构建、i18n 一致性、改动文件架构守卫及差异格式检查通过。 +- Client 严格类型检查通过,覆盖 1,488 个文件。 +- 自动回归使用接口夹具;测试环境部署及 macOS 原生客户端真实账号复测待执行。 + +## 代码审查 + +按仓库 code-review 流程检查边界、权限、并发、信息暴露、测试、风格及文档。数据转换集中在请求边界,使用新对象保留响应数据,继续沿用认证和错误处理。审查结果:PASS。 + +## 合并同步 + +- PR:[#2542](https://github.com/dataelement/bisheng/pull/2542)。 +- 初始提交的 GitHub Frontend Quality 全部通过;同步目标 `174394b32`,状态文档保留双方交付记录。 +- 同步后覆盖应用对话、知识空间聊天交接及主布局的 7 组 / 43 项回归全部通过。 diff --git a/docs/status/app-explore-chat-return-20261028.md b/docs/status/app-explore-chat-return-20261028.md new file mode 100644 index 0000000000..6839f03721 --- /dev/null +++ b/docs/status/app-explore-chat-return-20261028.md @@ -0,0 +1,33 @@ +# 发现广场应用对话返回修复 + +## 范围与基线 + +- 修改仓库:`dataelement/bisheng`,目标 `feat/bisheng-work-120-main`。 +- 开始基线:`e468e2f2bcfe3c855e159193c0f96e18aec1453d`;PR 交付基线:`174394b3237d10460477fdefd0d4ba8871c7a913`;冲突处理同步目标:`d9cb1394a4fa1370f2fe40d7de41ab3f37194a70`。 +- 工作目录:`.worktrees/bisheng-app-explore-chat-return`;分支:`codex/app-explore-chat-return`。 +- 本次修改平台网页客户端。参考 macOS 测试运行时:Electron `43.0.0`;本机毕昇 Work 依赖 Harness `0.2.1-rc.2`。DSH Desktop v0.9.0 集成目录已查为干净,本次源码归属毕昇客户端。 + +## 根因与实现 + +- `MainLayout` 将应用中心和应用对话放入同一 `apps_tab` 缓存。应用中心会话 → 发现广场 → 应用分类进入会话后,地址更新,但缓存组件仍读旧会话与旧返回目标。 +- 应用探索卡片保存旧 `/apps/explore` 地址,发现广场实际入口已为 `/square?type=apps`。 +- 应用对话路由改为读取当前路由上下文;来源保存为当前广场的应用分类。返回目标白名单纳入该明确地址,同时保持应用中心、移动端探索及首页的已有返回路径。 + +## 验证 + +- 使用实际 MainLayout、AliveScope、React Router、ExplorePlaza、AppChatEntry、AppRoot、SideNav。应用列表、账号及对话服务使用夹具。 +- 原始实现下 3 项回归失败,其中跨入口场景复现地址与页面内部会话不一致。 +- 修复后相关 4 组 / 25 项回归通过:广场新会话与历史会话、跨入口重入、应用中心返回、移动端返回、来源白名单及原有频道导航。 +- 客户端、平台、UI 和文件预览库 ESLint;客户端与平台严格类型检查、文件预览库类型检查;生产构建、i18n、架构守卫和差异检查通过。 +- macOS Electron 43.0.0 本地组件窗口:应用中心会话 → 广场分类 → 新会话 → 返回广场应用,随后应用中心会话 → 返回应用中心,两条路径通过。 +- 提交 PR 前同步知识空间聊天导航修复,应用与知识空间组合回归 5 组 / 26 项通过;客户端 1,490 个严格类型文件、ESLint 与生产构建通过。平台 619 个严格类型文件及 ESLint 检查记录保留在前轮证据。 +- 证据目录:`outputs/app-explore-chat-return-20261028/`,包含原始失败、回归、静态检查、构建和 Electron 结果。 + +## PR 冲突处理 + +- `docs/STATUS.md` 的并发末尾追加产生冲突;保留双方记录,将本条记录放入独立位置,验证详情继续由本文件承载。 +- 同步目标分支后应用返回、知识空间导航、频道导航、助手详情兼容回归共 6 组 / 33 项通过;客户端 ESLint、1,495 个严格类型文件、生产构建、i18n、架构守卫和差异检查通过。 + +## 交付边界 + +源码修复及自动验证完成。测试环境部署、真实账号数据和已安装 macOS 客户端完整验收待执行。Electron 验证范围为实际导航组件与服务夹具。 diff --git a/docs/status/app-pin-state-20261028.md b/docs/status/app-pin-state-20261028.md new file mode 100644 index 0000000000..f660b3f09c --- /dev/null +++ b/docs/status/app-pin-state-20261028.md @@ -0,0 +1,30 @@ +# 应用置顶状态修复 — 2026-10-28 + +## 根因与修复 + +应用中心通过统一目录读取助手与工作流。置顶接口原先仅查询 Flow 表,实际保存在 Assistant 表的助手被判定为不存在,保存流程提前结束。Client 将 HTTP 成功响应中的业务错误继续作为成功处理,随后刷新出原状态。 + +置顶接口改用与列表相同的 `FlowDao.aget_all_apps`,统一解析应用类型、租户范围与助手删除状态,再校验上线状态与 `use` 权限,保存当前用户的置顶记录。Client 以业务状态码 200 确认保存成功,成功后读取服务端状态,失败时保留列表并显示对应操作提示。 + +## 基线与范围 + +- 仓库:`dataelement/bisheng`;PR 目标:`feat/bisheng-work-120-main`。 +- 初始远端基线:`d3d832a0e0f8299e2497734e7b72da6072c13937`。 +- 组合验证基线:`b872e4775b4fabf84cefd75fbdabaa419897647a`。 +- 工作目录:`.worktrees/bisheng-app-pin-state`;分支:`codex/app-pin-state`。 +- 本机应用包:`BISHENG 1.2.0-test.2`;包内 Harness:`0.2.1-rc.2`。 +- 交付位置:毕昇后端及 Client 网页资源。 + +## 自动验证 + +- 原实现对照:新增后端用例 3 项失败、5 项通过;新增前端用例 2 项失败、2 项通过。 +- 修复后:22 项后端与 22 项前端组合回归通过。 +- 后端测试通过 SQLite 文件数据库运行真实应用查询、置顶记录写入/删除和列表读取,使用新数据库会话验证保存后重读。覆盖助手/工作流、分页置顶排序、取消后的最近使用顺序、用户隔离、其他租户、已删除/下线/缺失应用与权限拒绝。权限服务、历史列表、标签和分享元数据使用夹具。 +- 前端通过真实 React Hook 与既有 Recoil 状态,验证保存、卸载重挂后读取、取消及业务/网络错误;接口响应使用夹具。组合覆盖应用分享权限和发现广场返回路径。 +- Client/Platform ESLint、严格类型检查、共享 UI/File Viewers lint、File Viewers 类型、i18n 与 Client 生产构建通过。 +- Ruff、差异检查通过。架构守卫保留现有 Endpoint 直接引用 DAO 的 RULE-3 迁移期警告;本次沿用既有 DAO 入口。 +- 本地证据:工作区 `outputs/app-pin-state-20261028/`。 + +## 发布后验收 + +测试环境部署、真实账号、macOS 原生客户端以及 MySQL/DM8 验收待执行。发布后使用有权限的普通账号,分别对助手和工作流完成置顶、页面刷新、重新进入应用页、取消置顶、再次刷新,确认按钮文案与列表排序一致;切换账号确认各自置顶记录独立。 diff --git a/docs/status/app-square-dark-theme-20261028.md b/docs/status/app-square-dark-theme-20261028.md new file mode 100644 index 0000000000..7c52be519e --- /dev/null +++ b/docs/status/app-square-dark-theme-20261028.md @@ -0,0 +1,8 @@ +# 发现广场应用卡片暗色修复 + +- 根因:应用卡片的渐变中心与操作遮罩使用固定白色;桌面既有广场渐变适配识别十六进制色值,应用卡片使用 RGB 写法。 +- 修复:AgentCard 统一使用主题表面变量,暗色取 `--fill-1`,浅色取 `--bg-page`;暗色渐变沿用知识空间的 8% 品牌色。简介使用语义文字颜色,操作遮罩、标签淡出和窄屏按钮使用同一表面变量。精选标签同步三语言并清理对应旧 lint 豁免。 +- 验证:Client / Platform / UI / File Viewers lint、Client 1,493 个严格类型文件、Platform 619 个严格类型文件、File Viewers 类型检查、Client 生产构建、i18n 检查通过;分享权限 3 项既有回归通过。macOS Electron 使用实际 AgentCard、共享 Button、生成的 Client CSS 和桌面主题适配代码,12 组宽度/品牌色/浅色与网页暗色及桌面暗色检查通过。 +- 基线:已同步远端 `dataelement/bisheng:feat/bisheng-work-120-main@9a077fdce4aa471b6e91b796dbd0842419ca37c2`,组合代码再次通过 Client / Platform lint、类型检查、Client 构建、i18n、18 项应用权限与导航组合回归;卡片与主题代码保持已通过 12 组渲染验证的内容。目录 `.worktrees/bisheng-app-square-dark-theme`,分支 `codex/app-square-dark-theme`。 +- 交付边界:本地源码修复与组件验证完成。已创建 [PR #2544](https://github.com/dataelement/bisheng/pull/2544),目标为 `dataelement/bisheng:feat/bisheng-work-120-main`;代码发布、13001 真实账号及 Windows 验收待执行;发布位置为毕昇 Client 网页资源。组件验证的账号数据、权限服务和头像使用夹具。 +- 证据:工作区 `outputs/app-square-dark-theme-20261028/`,含 12 张渲染截图、计算样式、点击检查与构建日志。 diff --git a/docs/status/assets/subscription-layout-20261030/baseline-960.png b/docs/status/assets/subscription-layout-20261030/baseline-960.png new file mode 100644 index 0000000000..350702d479 Binary files /dev/null and b/docs/status/assets/subscription-layout-20261030/baseline-960.png differ diff --git a/docs/status/assets/subscription-layout-20261030/fixed-960.png b/docs/status/assets/subscription-layout-20261030/fixed-960.png new file mode 100644 index 0000000000..92099c6d5e Binary files /dev/null and b/docs/status/assets/subscription-layout-20261030/fixed-960.png differ diff --git a/docs/status/channel-route-navigation-20261028.md b/docs/status/channel-route-navigation-20261028.md new file mode 100644 index 0000000000..1abf52deb1 --- /dev/null +++ b/docs/status/channel-route-navigation-20261028.md @@ -0,0 +1,29 @@ +# 订阅频道导航修复记录(2026-10-28) + +## 基线与范围 + +- 仓库:dataelement/bisheng;目标:feat/bisheng-work-120-main。 +- 开始基线:e83fad4b8f3f9081e0d68a071402ecf5217e9a87。 +- 分支:codex/channel-route-navigation。 +- 实现位置:client MainLayout 的路由缓存边界;覆盖 Web 与桌面嵌入页面。 + +## 根因与修改 + +13001 Chrome 中进入频道详情,切换知识空间,再点击订阅,地址变为频道首页,但画面仍显示原详情。MainLayout 将频道首页、详情及设置路由缓存为同一个 channel_tab;恢复缓存时 Subscription 接收到旧路由上下文,旧详情继续显示。 + +频道路由与已有实时路由页面共用直接渲染分支,当前路由决定页面状态。频道自行提供移动端顶栏及页面滚动容器;聊天维持原有 KeepAlive 与草稿行为。按仓库要求,将本次触及文件内的系统通知文案迁入中英日词典,并用 ESLint 工具清理相应历史豁免。 + +## 验证 + +- 新增 MainLayout.navigation.test.tsx:9 项,覆盖桌面嵌入、桌面 Web、移动端路由,详情/首页/创建/设置/分享路径、查询参数、历史导航、跨模块恢复与聊天草稿。 +- 使用真实 MainLayout、React Router、AliveScope 与 Subscription 的故障回归:原始 MainLayout 下失败,修复后通过。界面子组件与服务使用测试数据。 +- 频道及 DesktopShellBridge 相关回归:9 个测试文件、31 项通过。 +- 客户端、平台、UI 与文件预览包 ESLint 通过;客户端 1485 个、平台 619 个严格类型文件通过,文件预览包类型检查通过。 +- i18n 一致性、客户端生产构建通过。 +- macOS Chrome 本地组件验证:详情 → 聊天 → 首页、频道切换、前进后退、设置路径和聊天草稿保留通过。服务采用本地测试数据。 + +## 验收边界与复测步骤 + +本次验证覆盖源码和本地浏览器。13001 部署与实际 macOS 客户端修复后验收待执行。 + +更新 BISHENG 前端后,在 macOS 客户端进入订阅并打开频道 A;切到知识空间,再回订阅;确认地址与首页内容一致。继续切换频道 A/B、创建、设置、分享链接并执行前进后退,确认页面对应当前地址。返回聊天确认草稿保留。 diff --git a/docs/status/channel-state-consistency-20261028.md b/docs/status/channel-state-consistency-20261028.md new file mode 100644 index 0000000000..d162849691 --- /dev/null +++ b/docs/status/channel-state-consistency-20261028.md @@ -0,0 +1,29 @@ +# Subscription state consistency — 2026-10-28 + +## Scope and source + +The reported macOS/13100 symptoms originate in the shared BISHENG platform client and channel services. The Desktop shell displays the embedded platform UI. + +Initial analysis baseline: `dataelement/bisheng:feat/bisheng-work-120-main@1a8ac45e1d58a6f0bde7f0fe13552f2beb7b46a1`. Build-verified integration baseline: `c8ae351ee`. The subsequent `8821e490d` assistant-description change was retained before delivery and the channel regressions, client lint and strict types were rechecked. Repair branch: `codex/channel-state-consistency`. + +## Behavior + +- Detail and square subscription status use effective `visible` permission, matching the followed list and article access checks. Membership rows supply application progress. Organization grants therefore show subscribed; stale ACTIVE records with revoked access show the subscription entry. +- Public subscriptions commit a PENDING membership, project its permission through the existing F048 policy/audit runtime, then activate the membership. A failed projection leaves a retryable subscription entry. +- Manual and automatic approvals use the durable approval outbox. The channel handler activates membership after its permission projection succeeds. Existing execution-failure reporting and retry remain available. The legacy message approval handler follows the same ordering. +- Switching a review channel to public grants access before activating each pending member. Existing restricted administrator roles retain their role and require administrator authorization changes. +- Preview, recommendations and square use server subscription results. Persistent local overrides have been removed. +- Visible active surfaces refresh on focus, visibility restoration and every 30 seconds. KeepAlive activation also refreshes. The square revalidates all loaded pages while retaining the displayed list during requests. +- Open content views close on refreshed access revocation; preview content uses the same access decision. The locked-content message covers both subscription approval and authorization. + +## Verification + +- Backend targeted set: 55 passed. The same checks against the initial baseline produced 12 failures and 43 passes, including the stale ACTIVE/no-access case, organization access without membership, and failed permission writes. +- Frontend component/hook regression set: 15 cases for approval, revocation, visible/hidden/KeepAlive lifecycle, query invalidation and multi-page square refresh. +- Final combined-branch platform/client lint, strict types, shared-package lint/types, i18n, Ruff, architecture checks and client production build passed. The installed pnpm launcher attempted dependency installation on shared dependency links; the equivalent package-script executables were run directly against the existing dependencies. + +## Delivery and acceptance + +The user authorized direct integration into `dataelement/bisheng:feat/bisheng-work-120-main`. Release requires the matching backend, approval worker and client assets. Deployment to 13100, real-account approval history/permission data inspection, and native macOS acceptance remain pending. + +A deployment smoke should use a regular account and cover pending→approved, approval projection failure→retry, access revoke→regrant, organization grant, and a channel changing from review to public. Compare detail status/actions, followed-list membership and article availability after each transition. diff --git a/docs/status/chat-knowledge-session-isolation-20261028.md b/docs/status/chat-knowledge-session-isolation-20261028.md new file mode 100644 index 0000000000..0116fed9e3 --- /dev/null +++ b/docs/status/chat-knowledge-session-isolation-20261028.md @@ -0,0 +1,50 @@ +# 普通 Chat 知识空间按会话保存 + +日期:2026-10-28。状态:源码修复与定向自动化验证完成,等待部署及 Windows 真实账号验收。 + +## 问题与修复 + +普通 Chat 原先共用 `selectedOrgKbs` 全局状态,`ChatView` 将最后一次选择写入账号级 `bs:{uid}:selectedOrgKbs`。会话切换保持该状态;发送与重新生成读取该状态。因此 C2 的 K2 会同时成为 C1 的展示值和后续请求范围。 + +现在 `useAiChat` 持有会话知识选择,输入框直接使用同一份状态。`useConversationKnowledge` 按账号与会话 ID 保存选择;主 Chat 与订阅面板的初始草稿分别保存。选择操作同步保存,空列表也是有效选择。路由切换时,会话 ID、消息桶和知识选择在同一次提交中生效。首条消息收到正式 ID 时,保存该轮捕获的选择;后台响应保持前台正在查看的会话。 + +持久化键:`bs-conversation:{uid}:knowledge:{conversationId}`;草稿 ID 为 `new:chat` 或 `new:subscription`。该数据属于会话上下文,使用独立于登录时清理的 `bs:*` 偏好键的命名空间。账号隔离由 key 中的 uid 保证,读取范围受浏览器 origin 隔离。记录保存在当前设备的浏览器/桌面 WebView 本地存储,跨设备同步需要服务端会话存储支持。 + +历史账号级记录只有最后一次选择,无法还原各历史会话的选择。已有会话缺少独立记录时使用空范围,用户重新选择后独立保存。新会话按管理员配置应用组织知识库默认值;知识空间初始为空。管理员关闭组织知识库后,知识空间选择继续生效。 + +发送和重新生成沿用现有 API 字段 `use_knowledge_base.organization_knowledge_ids` 与 `knowledge_space_ids`,从当前会话选择生成整数 ID 列表。后端现有 `_resolve_user_kb_selection` 与 `_build_knowledge_search_tool` 根据本次请求构建检索白名单;本次检查确认该代码路径,真实检索与引用验证留在验收阶段。 + +## 基线与验证 + +- 仓库:`dataelement/bisheng`,目标 `feat/bisheng-work-120-main`,远端核对 SHA `2a726f5c97be74670d99718cdc475cb1f7331b62`。 +- 工作目录:`.worktrees/bisheng-chat-knowledge-session-isolation`;分支 `codex/chat-knowledge-session-isolation`。 +- 本次修改属于桌面嵌入的 Web Chat。桌面参考目录 `.worktrees/dsh-desktop-v090-integration` 检查时干净;已安装参考 Harness `0.2.1-rc.2`。DSH 旧仓库的远端 `v0.9.0` 分支查询返回空,Web 修复采用上述毕昇目标分支。 +- 定向 Jest:4 个文件、31 项通过,覆盖 C1/K1 与 C2/K2 往返、组件重挂载、发送、重新生成、清空、首条消息 ID 迁移、迟到响应、账号隔离、主 Chat 与订阅草稿隔离、默认值、组织库开关及存储异常。 +- 客户端生产构建通过。依赖使用现有安装目录;测试通过本地启动辅助文件提供 jsdom 的 TextEncoder/TextDecoder,Babel runtime 通过 NODE_PATH 定位。依赖版本与锁文件保持。 +- 扩展输入框回归:17 项中 13 项通过,4 项工具/技能显示预期失败。对同一 SHA 的原始源码独立运行后,4 项失败名称与断言相同,记录为基线失败。相关组件与这些测试保持原有内容。 +- 架构检查与差异空白检查通过。客户端、管理端、共享 UI 和文件预览库 ESLint 通过;客户端、管理端和文件预览库类型检查通过。 +- 本地证据:`outputs/chat-knowledge-session-20261028/`。 + +## Windows 验收清单 + +使用可访问 K1、K2 的同一普通账号,给两个空间准备互相可区分的资料和答案。 + +- [ ] C1 选择 K1;C2 选择 K2;往返切换至少三次,选择标签分别显示 K1、K2。 +- [ ] 关闭窗口并重开、重启客户端后分别进入 C1、C2,选择分别恢复。 +- [ ] C1、C2 各发送后续问题,检查请求中的空间 ID 与实际检索来源、引用来源分别属于各自空间。 +- [ ] 分别重新生成回复,检查检索范围和引用来源。 +- [ ] 清空 C1 后重开,C1 保持空范围,C2 保持 K2。 +- [ ] 新 Chat 选 K1 并发送,正式 ID 生成后重开,保持 K1。 +- [ ] 首条消息仍在生成时切换 C2,返回的响应保存在原会话,前台 C2 保持 K2。 +- [ ] 旧会话缺少独立选择记录时显示空选择,重新选定后再执行上述流程。 + +## 审查 + +按仓库 code-review 技能完成边界、权限、并发、信息泄漏、测试、代码风格和文档七项复核。修复直接替换普通 Chat 的共享选择读取和账号级持久化;请求协议与后端权限链沿用现有实现。Windows 原生、真实提供方、真实知识检索与引用、跨设备同步分别按上述证据边界记录。 + +## 提交 PR 前同步 + +- 已同步最新目标 `894d4d7de7e0593fdd239c9c88848be38941f224`,保留目标分支新增的知识空间提问与模型传递行为。 +- 回归测试冲突按用例合并,新增的上游用例改为使用当前会话知识选择接口,保留原有行为断言。 +- 合并后 6 个文件 / 48 项回归全部通过;客户端全量 ESLint、类型检查、生产构建与差异检查通过。 +- Windows 原生与真实检索引用仍待部署后验收。 diff --git a/docs/status/knowledge-ask-submit-20261028.md b/docs/status/knowledge-ask-submit-20261028.md new file mode 100644 index 0000000000..b362b59c22 --- /dev/null +++ b/docs/status/knowledge-ask-submit-20261028.md @@ -0,0 +1,39 @@ +# 知识空间跳转新聊天自动提交修复 + +日期:2026-10-28(北京时间)。 + +## 范围与基线 + +- 仓库:`dataelement/bisheng`,目标分支 `feat/bisheng-work-120-main`。 +- 开始基线:`fd6c95ca1b7986c60ea72d16d896a3de0ed4e9c2`,已读取远端分支确认。 +- 交付同步基线:`e468e2f2bcfe3c855e159193c0f96e18aec1453d`,含频道路由、知识分享和工作流修复。 +- 修复分支:`codex/knowledge-ask-submit`,工作目录 `.worktrees/bisheng-knowledge-ask-submit`。 +- 本次修改平台网页客户端。Desktop 安装包与 Harness 保持原版本。 + +## 复现与根因 + +- 在 macOS Chrome 的 13001 环境中,首次从知识空间提问获得回复;返回相同空间再次提问后,地址进入 `/workspace/c/new`,页面停在欢迎页,问题没有提交。 +- 部署资源 `index.DgxAiZvf.js` / `main.BoOLjQaW.js` 仍使用早期交接实现:问题保存在 ref,发送依赖知识选择和发送回调变化,并在发送之前清空待发送问题。当前发布分支已有会话就绪与发送接受检查,测试环境仍需更新客户端。 +- 当前源码中继续存在两个可复现条件:React 18 `createRoot` 与聊天 KeepAlive 的自动冻结组合,使缓存恢复后 `useLocation()` 留在旧聊天;准备阶段只更新 ref,选择相同且 setter 保持原状态时缺少推进提交的渲染。 +- 真实 MainLayout + Router + AliveScope 集成测试明确复现:外部地址回到 `/c/new`,缓存中的 location 仍是 `/c/previous`。macOS 浏览器组件夹具复现相同行为。 + +## 修改 + +- 聊天页缓存保留组件和会话状态,并保持路由与流式状态的更新能力:对 `chat_tab` 关闭 `autoFreeze`,符合已安装 react-activation 对 React 18 createRoot 的兼容要求。 +- 发送交接使用独立的准备完成状态,准备后推进到下一次渲染,读取已经应用的模型和知识空间后提交。 +- 保持新会话就绪检查、发送接受后完成交接、重复激活去重和附件准备流程。 +- 同步最新目标分支中的频道路由与系统通知本地化改动,复用目标分支文案和 ESLint 存量记录。 + +## 验证 + +- 原始交接实现:新增的 5 个准备/连续提问用例失败;使用独立准备状态后通过。 +- 原始聊天缓存:真实 MainLayout 集成用例得到 `/c/previous`;聊天缓存保持更新后得到 `/c/new`。 +- 初始 6 组 / 62 项回归通过;同步目标分支并加入频道导航回归后,7 组 / 71 项通过,覆盖真实发送 Hook、会话知识隔离、模型持久化、首轮与连续提问、配置延迟、重复激活、发送重试与文章附件。 +- 客户端全量 ESLint、1487 个严格类型文件检查、生产构建、三语言一致性通过;差异与架构检查记录随交付保存。 +- macOS Chrome 本地组件验证连续 3 次提交,均进入 `new`,模型 ID 和空间 ID 一致,每个问题提交一次。使用真实 KnowledgeAskBar、交接 Hook、知识选择 Hook 和 KeepAlive;后端发送由验证记录替代。 +- 本地证据:`outputs/knowledge-ask-submit-20261028/`,包含原始失败日志、修复后回归、构建、静态检查与 macOS 浏览器截图。 + +## 发布验收 + +- 发布路径:合入目标分支并部署配套 client。 +- 13001 更新后的真实模型回复与已安装 macOS 原生客户端完整验收待执行。浏览器组件验证单独记录。 diff --git a/docs/status/knowledge-exit-access-20261028.md b/docs/status/knowledge-exit-access-20261028.md new file mode 100644 index 0000000000..62102bc8a0 --- /dev/null +++ b/docs/status/knowledge-exit-access-20261028.md @@ -0,0 +1,37 @@ +# 知识空间退出后旧资料页面访问修复 · 2026-10-28 + +## 基线与范围 + +- 已读取远端 `dataelement/bisheng:feat/bisheng-work-120-main`,基线为 `1a8ac45e1d58a6f0bde7f0fe13552f2beb7b46a1`。 +- 合并前同步目标分支 `8dc820f0afcd75ff8942deb553c072d97a691b15`,保留同期状态记录并验证组合源码。 +- 工作目录:`.worktrees/bisheng-knowledge-exit-access`;分支:`codex/knowledge-exit-access`。 +- 本次修改毕昇后端。Desktop、Harness 及客户端代码沿用现有版本。 +- 对应入口:`/workspace/knowledge/file/{fileId}?spaceId=...` 调用的知识空间资料预览接口。 + +## 根因与实现 + +退出订阅已经通过成员授权同步撤销订阅来源,再删除成员记录。资料预览接口原实现仅查询文件,随后直接签发原文、转换预览及网页快照地址,因此保存的页面链接能够绕过已撤销的成员授权。路由中的空间 ID 也被丢弃。 + +预览现在先核对空间与文件归属,再通过统一权限入口校验当前文件的 `visible` 权限,授权通过后生成内容地址。签发地址复用已核验的文件记录。独立文件授权和继承授权交由现有权限运行时决定;下载继续使用 `download` 动作。 + +同步清理所改路由文件的既有静态检查问题:三个重复函数名改为对应操作名称,可选查询参数使用显式可空类型。 + +## 自动验证 + +- 新增 8 项回归在原实现得到 7 失败、1 通过,复现授权绕过和空间归属缺失。 +- 初始修复后,5 个相关模块共 42 项通过;同步目标 `8dc820f0a` 后相同模块共 54 项通过,包含预览、下载、退出订阅、空间业务及容器权限。 +- 覆盖普通成员退出后复用相同链接、独立文件授权、网页快照与原文地址的授权顺序、空间不匹配、目录类型、权限服务异常、预览与下载动作分离。 +- Ruff 检查与格式检查、架构守卫及差异空白检查通过。 +- 使用 Python 3.11 与现有本机依赖环境;数据库、对象存储和权限服务使用测试夹具。真实 OpenFGA、数据库和浏览器端到端验收待执行。 +- 日志:工作区 `outputs/knowledge-exit-access-20261028/before.log`、`after.log`、`rebased.log`。 + +## 交付与复测 + +当前为源码与自动回归交付。测试环境部署和真实账号验收待执行。 + +1. 普通成员加入空间,打开资料并保存页面链接。 +2. 退出订阅后,在新标签打开保存的页面链接,接口应返回权限错误,页面停止加载受保护资料。 +3. 对照仍在空间内的成员,以及拥有独立文件授权的账号,确认相应文件可访问。 +4. 补充文件所属空间不匹配、原文、PDF 转换预览、网页快照、重新加入和下载权限验证。 + +对象存储已签发直链采用现有签名有效期(默认 7 天)。本次交付保护资料页面重新获取内容地址的入口;已签发直链的即时撤销需要单独的对象交付鉴权与存量链接失效方案。已下载文件由使用者持有。 diff --git a/docs/status/knowledge-preview-resource-routing-20261028.md b/docs/status/knowledge-preview-resource-routing-20261028.md new file mode 100644 index 0000000000..9e8b03812e --- /dev/null +++ b/docs/status/knowledge-preview-resource-routing-20261028.md @@ -0,0 +1,34 @@ +# 知识空间资料预览与下载地址修复 + +日期:2026-10-28 + +## 根因与证据 + +13001 的知识空间 6 / file/4(FILE120.pdf)在已登录的 macOS Chrome 中复现 PDF 加载失败。预览接口返回 `/bisheng/original/4.pdf`,原实现将其转换成 `/workspace/bisheng/original/4.pdf`。线上该路径返回 HTTP 200、Content-Type `text/html`、4494 字节站点首页,浏览器继续进入 404 页面。使用相同签名访问后端返回的根路径,响应 HTTP 200、2024 字节、文件头 `%PDF-1.3`。PDF 主库和 worker 均为 3.10.111,worker 正常返回 JavaScript。 + +PPTX 的代码路径优先读取后端生成的 `preview_url` PDF,并复用同一个地址解析函数,受同一问题影响。后端尚未生成转换结果时,页面保留既有转换失败状态。用户补充 PPTX 来自同事测试,本轮按代码链路和转换结果夹具验证。 + +## 实现 + +- 对象存储资源使用站点根路径 `/bisheng/`、`/tmp-dir/`;历史页面前缀与重复前缀统一还原为对象根路径。 +- 签名查询参数按原字节保留;绝对对象存储地址继续通过站点代理访问,外部文档地址、根相对资源、blob/data 地址保留各自语义。 +- 知识空间列表下载和独立预览页下载共用地址处理;下载继续先调用已有受权限控制的下载接口。 +- PDF、PPTX 生成的 PDF、富文档、媒体与转写内容通过现有共享解析入口获得修复。 + +## 基线与验证 + +- 初始远端基线 `53737ea4fc0cb8b2d27f043c428d08d209209483`;验证期间快进同步 `0e84344aeb02f980c003584b32013960b29dc429`。新增目标内容仅涉及后端审计及其文档,前端验证代码保持一致。 +- 分支 `codex/knowledge-preview-resource-routing`,工作目录 `.worktrees/bisheng-knowledge-preview-resource-routing`。 +- 新增 28 项测试通过,覆盖 10 种资源路径、历史前缀、签名保留、外部资源、路由页格式选择、PPTX 转换失败与原文件下载。将原地址解析实现放回同组测试,复现 19 项失败。 +- 6 组 / 69 项定向回归通过,涵盖引用定位、引用失败、附件收集和 Linsight 引用转换。 +- 前端 workspace 的 ESLint 与类型检查全部通过,涵盖 Client、Platform 和共享文件预览包;Client 包含 1484 个严格类型文件。Client 生产构建、逐文件架构守卫与差异检查通过。 +- `TopBar.test.tsx` 在当前与未修改的参考分支均因 `filenamify` ESM 加载失败而在用例执行前终止;此项为测试环境限制。 +- macOS Chrome 本地组件验证使用修复后的解析函数、真实 PDF.js 与现有 PdfViewer,实际 file/4 渲染为 2 页;PPTX 转换结果夹具亦渲染为 2 页。夹具代理只服务本地已取得的 PDF,未执行真实 PPTX 转换。 + +## 发布与验收 + +本次交付为前端源码修复,目标分支为 `dataelement/bisheng:feat/bisheng-work-120-main`;合并状态以 PR 记录为准。13001 尚待部署配套 Client;Windows Chrome、同事原始 PPTX 的转换与预览、其他格式真实文件仍待测试环境验收。无需桌面或 Harness 代码变更。 + +复测:打开 PDF 和已完成转换的 PPTX,确认正文与页数正常;执行下载确认取得原始格式;抽查 Word、Excel、图片和文本;检查文件请求返回实际内容。保留一份转换失败的 PPTX,确认页面呈现转换失败状态。 + +本机证据:主工作区 `outputs/knowledge-preview-20261028/`,含构建与静态检查日志、原实现失败日志、PDF/PPTX 夹具截图及本地验证入口。访问凭据与带签名地址保持在报告之外。 diff --git a/docs/status/knowledge-share-preview-20261028.md b/docs/status/knowledge-share-preview-20261028.md new file mode 100644 index 0000000000..65a20fb041 --- /dev/null +++ b/docs/status/knowledge-share-preview-20261028.md @@ -0,0 +1,32 @@ +# 知识空间分享介绍与加入入口修复 · 2026-10-28 + +## 范围与基线 + +- 问题:分享空间关闭“发布到广场”后,接收者通过分享链接无法打开空间介绍与加入入口。 +- 当前远端基线经 GitHub 读取确认:`dataelement/bisheng:feat/bisheng-work-120-main@fd6c95ca1b7986c60ea72d16d896a3de0ed4e9c2`。 +- 推送前同步基线:`1a8ac45e1d58a6f0bde7f0fe13552f2beb7b46a1`;目标分支 `dataelement/bisheng:feat/bisheng-work-120-main`。 +- 工作目录:`.worktrees/bisheng-knowledge-share-preview`;分支:`codex/knowledge-share-preview`。 +- 本次为毕昇后端修复,使用现有网页分享抽屉与申请接口;Desktop 与 Harness 沿用现有运行版本。 + +## 根因与实现 + +`_require_space_info_permission` 在调用者缺少内容可见权限时,以 `is_released` 和 `auth_type` 共同判定介绍访问。`subscribe_space` 已按 `auth_type` 判定加入流程。两处条件差异使未发布的开放/审批空间在读取介绍时返回权限错误,前端因此无法显示已有介绍和加入按钮。 + +介绍访问现在依据开放/审批设置;`is_released` 负责广场发现范围。介绍响应中的内容权限仍为空;文件列表、搜索、预览与下载继续通过原权限入口授权。私有空间介绍继续要求可见权限,资源查询沿用租户过滤。 + +## 自动验证 + +- 修复前,新增分享介绍用例得到 2 失败、4 通过;失败精确覆盖未发布的开放/审批空间。 +- 修复后,6 个相关测试模块共 58 项通过,覆盖介绍、私有空间限制、广场查询、文件容器、申请审批、订阅额度与预览下载权限。 +- 广场查询夹具加入未发布的开放/审批空间,查询结果保持仅包含已发布空间。 +- 新增普通用户内容访问回归,覆盖两类加入设置、两种发布状态及列表/搜索入口。 +- Ruff 检查、格式检查、变更文件架构守卫、差异空白检查通过。 +- 测试使用 Python 3.11 与本机已有依赖环境,数据库和外部服务采用测试夹具;配置为本地空配置。 + +## 交付与验收 + +代码修复及自动回归完成,按用户要求直接交付目标分支。测试环境部署、macOS 浏览器/桌面真实账号验收待执行。 + +复测:创建审批加入的分享空间,关闭广场发布;另一普通账号打开链接,应显示名称、介绍与加入按钮。提交申请后刷新,应显示申请中;审批通过后进入内容页。补充开放加入、私有空间、已有成员与广场检索验证。 + +测试日志:工作区 `outputs/knowledge-share-preview-20261028/before.log`、`after.log`。 diff --git a/docs/status/knowledge-txt-preview-20261028.md b/docs/status/knowledge-txt-preview-20261028.md new file mode 100644 index 0000000000..66544d2068 --- /dev/null +++ b/docs/status/knowledge-txt-preview-20261028.md @@ -0,0 +1,32 @@ +# 知识空间搜索结果 TXT 正文预览复核 + +日期:2026-10-28 + +## 根因与修复 + +macOS Chrome 在 13001 知识空间 11 搜索“权限验收”,打开命中的“权限验收.txt”(file/6),复现应用首页 HTML 源码占据正文。预览接口返回 `/bisheng/original/6.txt` 与签名查询参数,`preview_url` 为空。 + +相同签名的响应对照: + +| 路径 | HTTP | Content-Type | 响应长度 | 内容 | +| --- | --- | --- | --- | --- | +| `/bisheng/original/6.txt` | 200 | application/octet-stream | 107 字节 | 实际 TXT 正文 | +| `/workspace/bisheng/original/6.txt` | 200 | text/html | 4494 字节 | 应用首页 HTML | + +根因是文件资源错误进入 SPA 页面路径。PR [#2535](https://github.com/dataelement/bisheng/pull/2535) 的共享地址解析修复已覆盖该 TXT 路由;合并提交为 `e83fad4b8f3f9081e0d68a071402ecf5217e9a87`。复核沿用这项根因修复,文本查看器继续原样呈现文件内容。本轮新增正文加载回归测试。 + +## 基线与验证 + +- 远端发布分支:`dataelement/bisheng:feat/bisheng-work-120-main@fd6c95ca1b7986c60ea72d16d896a3de0ed4e9c2`,由远端读取确认,包含 #2535。 +- 工作目录:`.worktrees/bisheng-knowledge-txt-preview`;分支:`codex/knowledge-txt-preview`。本轮修改范围为 BISHENG Client 测试与证据文档,运行时代码沿用发布分支。 +- 新增 5 项回归:从搜索结果使用的预览路由,经预览 API 数据、实际地址解析函数及真实 TextViewer,验证根路径、历史页面前缀、重复前缀、绝对存储 URL 和优先预览地址均加载 TXT 正文;签名、中文、换行与文本中的 HTML 字面量保持原样。 +- 测试夹具模拟真实代理行为:错误路径返回 HTTP 200 应用 HTML。换回 #2535 之前的解析函数,5 项全部失败;恢复当前解析函数后,相关 3 组共 33 项通过。 +- 客户端全量 ESLint、1485 个严格类型文件检查、架构守卫及差异检查通过。依赖沿用已有工作区安装;Jest 使用其 pnpm 模块路径。 +- macOS Chrome 本地组件验证:使用真实 file/6 的 107 字节正文、发布分支的解析函数和 TextViewer,页面成功显示正文。验证页面使用独立布局,文件响应来自本机下载副本。 +- 回归提交:[PR #2538](https://github.com/dataelement/bisheng/pull/2538)。提交期间目标分支合入频道导航修复 #2536,本分支已同步 `1a8ac45e1d58a6f0bde7f0fe13552f2beb7b46a1`;组合后的 4 组 / 42 项回归、客户端全量 ESLint 与 1486 个严格类型文件检查通过。 + +## 发布与验收边界 + +13001 在本轮实测时仍请求旧路径并呈现 HTML;该环境需要部署包含 #2535 的 Client 后,重新执行“搜索 → 打开 TXT → 正文显示”的完整验收。源码修复已合并,真实文件的本地组件验证已通过,13001 部署后验收与 Safari 验收待执行。 + +本机证据:主工作区 `outputs/knowledge-txt-preview-20261028/`,含 `tests.log`、`old-routing-regression.log`、`lint.log`、`typecheck.log` 与 `txt-preview.png`。访问凭据和签名 URL 保存在证据范围之外。 diff --git a/docs/status/permission-search-empty-20261030.md b/docs/status/permission-search-empty-20261030.md new file mode 100644 index 0000000000..029518bb2b --- /dev/null +++ b/docs/status/permission-search-empty-20261030.md @@ -0,0 +1,16 @@ +# 授权对象搜索空态文案 — 2026-10-30 + +- 目标:`dataelement/bisheng:feat/bisheng-work-120-main`,基线 `06ffc6fba6543e774676f8a1f45838c29e97a327`;分支 `codex/permission-search-empty`。 +- 根因:部门、用户组和已授权列表的搜索空态共同使用用户专属文案键。部门与用户组现在分别显示“暂无匹配部门”“暂无匹配用户组”;已授权列表按当前对象类型选择文案。中英日同步,空数据提示和搜索接口沿用现有逻辑。 +- 新增 9 项组件行为回归,覆盖中英日部门/用户组搜索、清空搜索恢复,以及三种授权对象的列表空态。相关 14 项通过;目标基线运行相同回归时 8 项失败、6 项通过。 +- 权限模块整体:57 项通过、2 项失败。这两项在目标基线同样失败:`PermissionDraftPickerDialog.test.tsx` 仍断言旧 Tabs 结构;`permissionI18n.test.ts` 将未改动的 `SourceBadge.tsx` 中文注释识别为硬编码文案。原文件保留,范围明确记录。 +- 前端 workspace 完整 lint/typecheck、Client 生产构建、三语言 key 检查、架构守卫与差异检查通过。验证复用锁文件一致的本地依赖,使用项目指定 pnpm 9.15.9。 +- 按仓库 code-review 七维检查完成,代码变更集中在空态文案选择和对应翻译;边界、权限、并发、信息、覆盖、风格与文档检查均未发现本次新增问题。 +- 本地证据:总工作区 `outputs/permission-search-empty-20261030/`。部署、Windows 安装版及真实账号验收待执行。 + +## PR #2561 合并冲突处理 + +- 2026-10-30 同步目标分支 `feat/bisheng-work-120-main@8d93affff9dfc8ee44b67d3cb8c23018b29dc9ec`。唯一冲突为 `docs/STATUS.md` 开头双方新增记录;按日期保留授权空态和订阅抓取失败说明两节。 +- 三语言文件自动合并后逐一核对,完整保留目标分支订阅文案和本 PR 的授权空态文案;权限组件与测试保持原 PR 内容。 +- 同步后重新验证:2 个测试文件 / 14 项通过,三语言 key 检查与 `git diff --check` 通过。测试通过 `NODE_PATH` 指向本地现有依赖运行;完整 lint/typecheck 与构建结果为初始提交时的证据。 +- 部署、Windows 安装版与真实账号验收继续待执行。 diff --git a/docs/status/subscription-layout-20261030.md b/docs/status/subscription-layout-20261030.md new file mode 100644 index 0000000000..7b8a552ea8 --- /dev/null +++ b/docs/status/subscription-layout-20261030.md @@ -0,0 +1,30 @@ +# 订阅首页比例调整验证 — 2026-10-30 + +## 问题与变更 + +订阅首页在 960px 内容窗口中使用三列布局,两个频道分布在前两列,页面左右留白为 12px。此次调整页面边距与栏目宽度,保持现有横线、列间竖线、编号、字体、字号、字重、行高及频道内部间距。 + +- 页面左右留白在 16–32px 间按容器宽度变化,上下留白为 24px。 +- 栏目最小宽度为 320px,窄窗口下以容器宽度为上限;列数继续根据实际可用宽度计算。 +- 960px 内容窗口中呈现两列,单频道与多频道分区沿用一致的列宽规则。 +- 创建频道、频道选择、置顶和未读状态保持现有业务行为。 + +## 版本与验证 + +- 初始基线:`1954f60c4acba5de5cca5e9385b76fc6b21effc0`。 +- 提交基线:`dataelement/bisheng:feat/bisheng-work-120-main@250fdce4cdc2ad7f0ba85c1d4ae18d9fdebba183`。两版基线的订阅首页与共享模块框架一致。 +- macOS Electron 43.0.0 中渲染实际 `ChannelHomePage`、共享组件与项目 Tailwind 样式;业务 Hook 和日期服务使用固定示例。 +- 内容窗口 960 / 720 / 480 / 320px × zoom 100% / 125% / 150% × 示例频道 / 多频道长标题 / 空列表:原版与调整版共 72 组采样,调整版 36 组边界和交互回调检查通过。 +- 每组校验实际视口宽度;调整版与原版的字体、字号、字重、行高、编号和分隔线计算样式一致。 +- 提交基线 Client 全量 ESLint、1504 个严格类型文件检查及生产构建通过,差异检查通过。 +- 比例预览已确认。Client 部署、真实账号流程及 Windows 安装版系统 DPI 100% / 125% / 150% 验收待执行;本地 zoom 检查属于布局证据。 + +## 同尺寸预览(内容区 960px) + +原版: + +![原版](assets/subscription-layout-20261030/baseline-960.png) + +调整版: + +![调整版](assets/subscription-layout-20261030/fixed-960.png) diff --git a/docs/status/subscription-url-errors-20261029.md b/docs/status/subscription-url-errors-20261029.md new file mode 100644 index 0000000000..24b582bf6b --- /dev/null +++ b/docs/status/subscription-url-errors-20261029.md @@ -0,0 +1,49 @@ +# 订阅新增网站:抓取失败原因与人工处理说明 + +## 交付范围与基线 + +- 日期:2026-10-29。 +- 目标仓库与分支:`dataelement/bisheng` / `feat/bisheng-work-120-main`。 +- 创建修复分支及提交前复核的远端基线:`3950752273b75a120a0f8e25ecd5d903c94f8106`。 +- 修复分支:`codex/work120-subscription-url-errors`;独立目录:`.worktrees/bisheng-subscription-url-errors`。 +- 本次采用现有行为缺陷修复流程,覆盖 Web 订阅界面;Desktop Harness 版本与安装包交付属于独立验证范围。 + +## 根因与实现 + +1. 抓取队列在任务结束后统一折叠,失败原因只通过 Tooltip 展示。失败结束后保留展开状态,原因作为队列正文展示;人工处理入口和移除失败记录分别提供按钮,支持键盘操作。移除后可重新提交相同网址。 +2. 网址直接进入抓取请求,缺少格式校验。提交前校验 HTTP/HTTPS 地址,已有域名输入形式补全 HTTPS;格式错误直接显示说明。队列保留用户输入供现有去重逻辑使用,服务请求使用规范地址。 +3. 添加信息源失败统一映射成解析失败,丢失服务返回的原因。抓取和保存共用错误分类,保留服务授权、服务异常、访问限制、页面类型、额度、网络与超时信息;服务仅返回通用解析失败时,显示检查链接、页面可访问性及栏目列表页的操作指引。 +4. 人工处理说明默认文案包含 `XXXX@XX`,配置为空或请求失败时直接展示。三语默认文案统一为联系管理员并提供网站及抓取内容;有效配置按原文显示,历史占位配置使用管理员指引。打开弹窗时重新获取配置,关闭后的旧请求结果由生命周期控制丢弃。 + +接口、权限、数据库和抓取服务契约沿用当前发布分支。服务端对抓取目标的安全校验继续由既有后端负责。 + +## 自动验证 + +- 33 项行为回归通过:URL 校验与规范化、抓取和保存错误分类、失败队列保持展开、直接显示原因、移除失败记录、正常保存、配置为空/占位/异常/租户包装、重新打开与旧请求竞态。 +- 2 项已有队列契约回归通过。该测试声明 Node 环境,运行时通过临时 Jest 配置移除了全局浏览器初始化;仓库全局初始化会直接访问 `Element`。 +- 对同一发布基线运行针对性行为回归:9 项失败、1 项通过、其余跳过;失败覆盖折叠队列、隐藏原因、占位说明及旧配置残留。 +- Client 与 Platform ESLint 通过;严格类型检查分别通过 1496 / 620 个文件。 +- UI、file-viewers 的 workspace lint 通过;file-viewers 类型检查通过。 +- 三语一致性、生成语言包一致性、架构守卫与 `git diff --check` 通过。 +- Client 生产构建通过。保留既有字体路径、CSS 压缩等构建警告。 +- 验证使用隔离目录的依赖链接与现有缓存,依照 1.2.0 main 分支的检查脚本重新执行。各脚本使用本地可执行文件直接运行,依赖清单与锁文件保持目标分支现有版本。 + +## 代码审查 + +按 `.claude/skills/code-review/SKILL.md` 完成单人审查: + +| 维度 | 结果与证据 | +| --- | --- | +| 边界条件 | PASS:空网址、非法协议、带凭据网址、空配置、异常与额度码均有回归。 | +| 权限与认证 | PASS:沿用包装请求、既有接口和鉴权。 | +| 并发安全 | PASS:保留并发上限与取消机制;新增弹窗旧响应丢弃回归。 | +| 信息泄漏 | PASS:错误码映射本地化说明,服务内部异常详情留在原错误链路。 | +| 测试覆盖 | PASS:真实 Hook 和 React 组件配合接口夹具验证;原代码反向验证捕获缺陷。 | +| 代码风格 | PASS:共享按钮与 Tooltip、三语文案、静态检查通过。 | +| 文档同步 | PASS:根因、修复、验证与验收边界记录在本文件及 STATUS。 | + +## 待验收 + +- 测试环境部署后,由测试同学使用原始无效网址复测新增网站、重新提交及人工处理入口。 +- 使用真实可抓取栏目网址和实际拒绝访问/额度限制账号复测完整链路。 +- 真实浏览器、触屏与 Windows/macOS 安装包验收待执行。当前证据为源码、组件行为自动测试与生产构建。 diff --git a/docs/status/work-knowledge-release-gate-20261028.md b/docs/status/work-knowledge-release-gate-20261028.md new file mode 100644 index 0000000000..e14834af32 --- /dev/null +++ b/docs/status/work-knowledge-release-gate-20261028.md @@ -0,0 +1,36 @@ +# Work 知识检索发布流程修复 · 2026-10-28 + +## 原因与行为 + +PR #2546 提供完整的数据升级命令。原 `docker/deploy.sh update` 只拉镜像和 +启动容器,随后输出更新完成,部署操作与知识工具的数据升级仍然分开。 + +本次把现有升级命令接入部署入口。包含 backend 的更新先校验 Store 确认值和 +操作人参数,再拉取镜像、等待 backend 就绪、执行知识工具登记与权限校准, +成功后继续更新其余服务并报告完成。失败状态向调用方传递,后续阶段停止。 +backend 已更新而维护失败时,错误信息明确说明当前状态和重试方式。 + +预览采用目标镜像的一次性容器,并继承 backend 的配置、挂载及网络。 +权限维护沿用 F048 统一入口;API 启动沿用已有结构迁移职责。 +worker/frontend 单独更新及现有版本重启继续保持指定范围。 + +## 验证 + +- 基线:`feat/bisheng-work-120-main@5dfb2e7de875f0e1612438a5049a6f87da48ba9a`。 +- 提交前同步 `0856c3e875bcc2aa81f90cc03a9c88737a0fc32c`,保留同期 TXT + 预览测试和状态文档;本次部署逻辑及相关后端代码与组合回归时一致。 +- 原部署脚本运行同组命令边界测试:19 项失败、4 项通过。 +- 修复后 23 项部署测试通过;与登记、权限、目录、工具启用及检索端点组合运行, + 共 107 项及 17 个参数子项通过。Ruff、Bash 语法和差异检查通过。 +- 部署测试实际执行 shell 脚本,以记录型 Docker 替身验证参数、顺序、失败退出、 + 后续阶段停止、重试和含空格路径;数据库登记使用 SQLite,权限外部服务使用夹具。 + +## 发布边界 + +使用与目标镜像配套的部署目录,执行步骤见 `docker/README.md`。 +每个环境在维护窗口运行一个发布作业。其他流水线及直接 Compose 更新需要显式 +调用同一维护命令并检查成功结果。维护失败时,环境可能已经完成 backend 和数据库 +结构更新,运维按发布流程保留维护窗口并处理错误。 + +本轮验证覆盖源码与命令编排。13001 的实际部署方式、镜像发布、真实数据库及 +OpenFGA 校准、dsh-sara5 的 K1/K2 引用验收继续单独记录。 diff --git a/docs/status/work-knowledge-upgrade-20261028.md b/docs/status/work-knowledge-upgrade-20261028.md new file mode 100644 index 0000000000..e018f35063 --- /dev/null +++ b/docs/status/work-knowledge-upgrade-20261028.md @@ -0,0 +1,37 @@ +# Work 知识检索升级修复 · 2026-10-28 + +## 问题与证据 + +TC-120-KSP-042 的已有执行记录显示:macOS test3 中可选 K1/K2,两次知识检索均返回通用错误,同一资料在云端 Chat 可检索。本轮通过测试环境的已登录管理员只读核对目录,内置工具接口返回 17 类 / 38 个工具,知识检索键 `bisheng_knowledge_search` 缺失。本机 test2 通过原有宿主接口执行两次单选和一次多选,均复现通用错误。 + +Work 在检索前检查知识工具的可用权限。目录缺项会在这一阶段产生 403;桌面适配器将业务错误转为通用提示。实际报错账号及 test3 仍以部署后的原用例验收为准。 + +原有旧库维护流程把工具登记和权限校准分为两个操作,登记命令完成只代表数据库记录已创建。修复将两步纳入同一升级操作,并以权限投影 CURRENT 作为完成条件。项目的数据维护约束要求该操作在发布维护窗口执行。 + +## 改动 + +- 默认预览同时返回工具登记计划、权限状态及当前权限 Store/model 标识。 +- Apply 明确绑定预览得到的 Store 和审计操作人,先核验权限运行时,再创建缺失记录,随后通过统一权限入口校准该知识工具分类。 +- 数据库自动分配 ID;管理员配置、当前权限模式、个人启用开关和其他工具保持原值。 +- 权限失败保留已提交的工具记录,命令返回失败;重复执行继续校准权限。现有非 CURRENT 状态按现有权限修复流程处理。 +- 源码与 Docker 两个入口统一提供 `upgrade-work-knowledge`,部署命令见 `src/backend/scripts/README.md`。 + +## 基线与验证 + +- 目标:`dataelement/bisheng:feat/bisheng-work-120-main`,起始基线 `b872e4775`。 +- 提交前同步目标 `502edc33b`,状态文档保留同期应用置顶修复记录。 +- 分支:`codex/work-knowledge-registration`。 +- 本轮修改后端升级操作和部署入口,Desktop/Harness 沿用原版本。 +- 9 个相关测试模块共 84 项及 17 个参数子项通过,覆盖完整升级、普通调用门禁、工具启用状态、选择范围和文档来源。目录 SQL 使用真实 SQLite,权限协调沿用实际 F048 校准器,外部服务与授权写入使用夹具。 +- 新增 16 项升级回归,覆盖只读预览、重复执行、部分失败恢复、配置保留、精确分类范围、错误 Store、运行时就绪检查及两个容器入口的参数与退出码。 +- 在起始基线独立复跑同组既有用例,确认两项端点测试已因缺少 `login_user` 夹具失败。本次补齐身份及可用工具夹具,保留真实门禁,并增加缺项拒绝和双空间来源断言。 +- Ruff、格式、Shell 语法、架构守卫及差异检查通过。真实 MySQL/DM8、OpenFGA 及 test3 验收单独执行。 + +## 部署与验收 + +1. 部署本 PR 的后端代码,使用与服务相同的配置执行升级预览。 +2. 核对 Store 和操作人后执行 Apply,保存 `ready: true` 及 CURRENT 回执。 +3. 用普通账号检查知识检索工具可见和启用,分别单选 K1、K2,查询各自唯一标记。 +4. 多选 K1/K2,确认返回两份资料及正确引用;补充未选空间、账号切换、权限撤销和工具关闭的拒绝检查。 + +本轮交付范围为代码修复、自动验证和 PR 合入;测试环境数据升级与原账号真实验收待执行。 diff --git a/docs/status/workflow-offline-20261028.md b/docs/status/workflow-offline-20261028.md new file mode 100644 index 0000000000..5969debe70 --- /dev/null +++ b/docs/status/workflow-offline-20261028.md @@ -0,0 +1,30 @@ +# 工作流下线后旧卡片提交处理 + +日期:2026-10-28 + +## 根因与修复 + +工作流 WebSocket 原先仅在初始化和状态查询时检查发布状态。保持连接的旧表单、文本输入和交互卡片通过 input 动作继续执行。前端将工作流下线错误 13010 当作普通提示处理,processing/close 又把 running 设为 true,导致停止按钮和等待状态持续显示。 + +- 初始化、状态查询和每次输入提交共用实时发布状态校验。下线后返回既有 13010 错误与结束事件,阻止继续派发执行任务。 +- 停止标记保留到执行进程处理,随后由正常终态流程清理。 +- 前端将下线错误存入会话状态,停止等待、关闭输入表单和重跑入口,并禁用历史交互卡片。后到的结束、输入和历史状态事件保留下线状态;重复提交在发送层拦截。 +- 正常完成的会话设置 running=false,保留原有手动重跑;运行状态已过期的旧卡片收到明确结束事件。 +- 沿用现有错误码及三语提示;构建器草稿预览沿用空 chat_id 的运行约定。现有权限入口和租户隔离保持原有职责。 + +## 基线与验证 + +- 目标:dataelement/bisheng 的 feat/bisheng-work-120-main;初始远端 SHA e83fad4b8f3f9081e0d68a071402ecf5217e9a87,经 GitHub 连接器核验。合并前快进同步 fd6c95ca1b7986c60ea72d16d896a3de0ed4e9c2,新增内容为侧栏缩进调整,随后验证组合代码。CI 通过后目标更新到 1a8ac45e1(频道导航修复);合并保留双方状态文档条目,继续执行组合回归与构建。 +- 工作目录:.worktrees/bisheng-workflow-offline;分支 codex/workflow-offline。 +- 截图入口属于桌面内嵌 Client。本机 BISHENG 1.2.0-test.2、包内 Harness 0.2.1-rc.2;DSH v0.9.0 远端分支搜索为空,既有 v090 集成目录干净。本次改动归属毕昇服务端与 Client。 +- 自动回归:后端 16 项,覆盖同一连接上线到下线、旧输入、运行状态过期、草稿预览和历史会话重开;前端 21 项,覆盖会话切换、下线错误、后到事件、四种重复提交及正常完成,并联跑自动重跑、文件接收与媒体附件回归。 +- Client ESLint、1485 个严格类型文件、Platform ESLint 和 619 个严格类型文件、共享 UI lint、文件预览包 lint 与类型检查通过;Client 生产构建通过。后端 Ruff 和差异检查通过。 +- 新增前端状态回归在原实现复现 2 项失败;后端原实现复现旧卡片校验与运行状态过期处理失败。修复后定向回归通过。 +- 架构守卫在 WorkflowClient 报告两项既有反向依赖;相同基线文件复现相同结果,本次沿用现有导入及 DAO。 +- 本机过程证据:outputs/workflow-offline-20261028/。测试使用模拟 WebSocket、数据库状态与任务调度;实际执行进程的停止时效由环境复测确认。 + +## 发布与验收 + +本次交付包括后端和 Client 源码,需要测试环境同步发布二者。真实账号下线操作、macOS 安装包内页面验收和 Windows 实机验收待执行。 + +复测顺序:上线工作流并打开输入卡片;管理员下线;返回旧卡片提交,确认出现“当前应用未上线,无法直接对话”,停止按钮消失、输入与旧交互卡片禁用、后端保持既有执行任务数量。随后刷新并打开已下线应用,再上线后打开新会话,确认正常提交恢复。 diff --git a/docs/superpowers/plans/2026-10-22-frontend-dependency-cleanup-verification.md b/docs/superpowers/plans/2026-10-22-frontend-dependency-cleanup-verification.md new file mode 100644 index 0000000000..bfc69df7b5 --- /dev/null +++ b/docs/superpowers/plans/2026-10-22-frontend-dependency-cleanup-verification.md @@ -0,0 +1,60 @@ +# 前端依赖治理验证与验收 + +2026-10-22;分支 `codex/frontend-dependency-cleanup`,基线 `6331d7cfc`。开发及技术验证完成,未合并、未推送。执行计划见 [implementation plan](2026-10-22-frontend-dependency-cleanup.md)。 + +## 已完成 + +- 两端列表排序切换到 catalog 固定的 `@hello-pangea/dnd@18.0.1`,移除 `react-beautiful-dnd`;保留原业务 ID、保存接口和提交时机。 +- 删除不可达的 Mention / PromptsCommand / TaggingSheet 及失效引用,移除 `react-virtualized` 和 `@types/axios`。 +- 两端 `react-window` 统一通过 catalog 固定 1.8.11;实际使用中的 `react-dnd` 与 `react-color` 保留。旧上传拖拽监听没有重新接入。 +- 补齐必要占位元素;取消或原位置放下不误保存。推荐应用限制提示补齐三语言。 +- 锁文件保留既有 SheetJS 0.20.3 SHA-512,增加防丢失回归检查。未升级无关依赖。 + +## 技术验证 + +| 检查 | 结果 | +| --- | --- | +| workspace 配置与依赖契约 | 30 项通过,含 SheetJS 校验值 | +| platform 排序、文件预览、语音、看板配色回归 | 35 项通过,单 worker | +| client 分类排序、文件类型和输入、共享会话回归 | 24 项通过,单进程 | +| i18n / 配置类型检查 / architecture guard / diff 空白检查 | 通过 | +| 修改文件 ESLint | 通过;生成的 suppression 仅减少 | +| 冻结离线锁文件安装验证 | 7 个 workspace 项目通过(临时目录,lockfile-only) | +| platform / client 生产构建 | 均通过,串行执行 | +| 独立 review | PASS;发现的 SheetJS 校验值丢失问题已修复并经 RED/GREEN 验证 | + +完整 lint / typecheck 仍有既存基线问题,不能称为全绿:platform lint 85 errors,client lint 119 errors / 2 warnings;platform 类型 1 处(clientVoiceDraft 测试参数),client 类型 7 处(skillCenter / useSkillCenter / SpeechToText 测试)。与执行前一致,无新增 suppression 或 strict-ignore。 + +client 的三个 node 环境旧测试首次被全局 DOM setup 阻挡(Element 未定义);复跑时仅对这三个纯逻辑测试移除 DOM setup,12 项通过,仓库测试配置未修改。 + +### 浏览器验证边界 + +使用真实业务组件、原有编译样式、隔离 API 数据,未修改测试环境业务数据。8 类排序均完成鼠标和键盘检查:工作流选项、工具、组织知识库、推荐应用、切分规则、变量、模板、客户端分类。 + +重点通过:工作流排序后继续编辑及重载;模板顺序保存载荷和重载;真实变量弹窗;客户端真实弹窗和 body portal;Esc 取消后恢复顺序与焦点并能继续排序保存;20 项工具列表滚动到底部后排序,默认选中状态保留。浏览器未记录排序运行时错误。 + +生产静态启动:platform 显示登录控件;client 加载主入口后等待配置接口。静态服务缺少后端,出现预期 404;未见 HighlightJS 初始化或 Provider 异常。此检查不代表业务联调通过。临时服务和浏览器页均已关闭。 + +### 产物体积 + +相同口径:递归汇总各端 `build/assets` 内 `.js` / `.css`,逐文件 gzip 后求和;不包括外置静态资源。 + +| 应用 | 调整前 gzip | 调整后 gzip | 差异 | +| --- | ---: | ---: | ---: | +| platform | 3,232,137 B | 3,230,231 B | -1,906 B | +| client | 2,519,365 B | 2,519,178 B | -187 B | + +本轮主要收益是维护性和清除失效依赖,产物体积基本持平。 + +## 真实业务环境验收(待执行) + +每行按业务模块验收,保存后刷新或退出再进入,核对顺序和项目内容。 + +- [ ] 工作流:输入/输出节点多选项排序,编辑、删除后保存再打开;取消拖动无误保存。 +- [ ] 工作台:工具、组织知识库、推荐应用分别排序;默认项状态和刷新顺序正确;长列表滚动正常。 +- [ ] 知识库:多条切分规则排序后编辑、保存,规则表达式和切分位置保持一致。 +- [ ] 变量与模板:变量调整后取消不落库、保存生效;模板排序刷新后保持。 +- [ ] 客户端分类:真实弹窗鼠标及键盘排序,Esc 取消后继续操作,取消无落库,保存后导航顺序正确。 +- [ ] 关联回归:工作流画布拖动/缩放、代码编辑、一次拖入仅上传一次、文件预览长列表、收藏拖拽、主题/看板配色。 + +以上业务验收完成后,再由用户决定合并和推送;不自动同步 release。 diff --git a/docs/superpowers/plans/2026-10-22-frontend-dependency-cleanup.md b/docs/superpowers/plans/2026-10-22-frontend-dependency-cleanup.md new file mode 100644 index 0000000000..41a9ea70c3 --- /dev/null +++ b/docs/superpowers/plans/2026-10-22-frontend-dependency-cleanup.md @@ -0,0 +1,104 @@ +# Frontend Dependency Cleanup Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. + +**Goal:** 清理无效依赖,将两端仍在使用的列表排序切到受维护的实现,保持业务行为与页面样式。 + +**Architecture:** 已有 pnpm workspace + catalog 继续作为共享版本入口。列表排序采用 `@hello-pangea/dnd`,通用拖拽仍按实际用途保留 `react-dnd`;先证明旧组件不可达再删除依赖,不为清理依赖重新接回旧功能。 + +**Tech Stack:** React 18.3.1、TypeScript、pnpm 9.15.9、Vitest / Jest;固定 `@hello-pangea/dnd` 18.0.1、共享 `react-window` 1.8.11。 + +**Spec:** 本会话确认的依赖治理方向及下述验收范围;用户于 2026-10-22 批准实施;开发和技术验证完成,真实业务环境验收待用户执行,见同目录 verification 文档。 + +## Global Constraints + +- 先拉取最新 `feat/3.0.0`,已在 `codex/frontend-dependency-cleanup` 分支执行,基线为 `6331d7cfc`。 +- 走 `docs/SDD-Guide.md` §1 维护路径:依赖替换、删除不可达代码,不改变产品契约。出现排序语义、交互或数据契约变化时,暂停该项并找用户判断。 +- 不升级 React,不全局开启 StrictMode;在隔离测试中验证拖拽组件兼容 StrictMode。 +- 不调整视觉,不迁移状态库,不顺带替换 `react-color` 或尚在使用的 `react-dnd`。 +- 每个状态只有一个来源;保持原有排序 ID、保存接口、提交时机和顺序持久化行为。 +- 真正跨应用共用的依赖纳入 catalog;客户端专用依赖仍在 client 管理,不为形式统一扩大共享范围。 +- 不使用整仓 stash/reset/clean/restore,不收录其他任务的文档;不自动推送或同步 release。 +- 测试单 worker,两个生产构建串行;避免全工作区重装引起 canvas 等无关依赖变化。 +- 修改带旧中文字符串的源文件时按仓库 i18n 规则处理;若形成明显额外工作量,单列说明,不借机重构业务。 + +## Review Focus + +1. 工作流选项拖动后仍能继续编辑文字,取消或拖回原位置不误保存。 +2. 抽屉、弹窗和滚动容器内拖动位置正确,滚动、点击、输入不会被拖拽劫持。 +3. 排序保存后刷新仍保持一致;空列表、单条、列表重新加载不丢失或重复项目。 +4. 鼠标和键盘排序可用;隔离 StrictMode 测试没有注册丢失或节点定位错误。 +5. 清理旧拖拽文件不恢复已经移除的第二条文件上传监听,单次拖入只上传一次。 + +## 现状与范围 + +- `react-beautiful-dnd` 13.1.1:platform 8 个源文件、client 1 个源文件直接导入。其中 `TaggingSheet` 的已知调用被注释,先追踪入口再决定删除或迁移。 +- `@types/axios` 0.14.4:platform 的废弃占位类型包,Axios 已自带类型。 +- `react-virtualized` 9.22.6:直接引用仅见于 client 的 `Mention.tsx` / `PromptsCommand.tsx`,初查未找到组件调用,需排除动态导入与导出链。 +- `react-window`:两端实际锁定均为 1.8.11,只统一声明,不跨到新的大版本。 +- `react-dnd` / `react-dnd-html5-backend` 16.0.1:client 仍有收藏拖拽等引用,保留;旧 `DragDropWrapper` 不重新接入。 +- `react-color` 2.19.3:platform 的主题和看板配色仍使用,本轮保留并继续冻结新增调用。 + +## 第一批:低风险清理和版本集中管理 + +**Files:** `src/frontend/platform/package.json`、`src/frontend/client/package.json`、`src/frontend/pnpm-workspace.yaml`、`src/frontend/pnpm-lock.yaml`;按入口审计结果删除 client 的 `src/components/Chat/Input/{Mention,PromptsCommand}.tsx`,更新对应 app 的 `AGENTS.md` / ESLint 规则。 + +**接口边界:** 不改变运行中的组件或 API;删除项必须有完整的入口不可达证据。 + +- [x] 记录当前 lint / 类型基线和构建大小;建立依赖及调用入口清单,覆盖静态引用、导出链、路由、动态导入与 glob 加载。 +- [x] 删除 `@types/axios` 声明及只属于它的锁文件节点;运行 platform 类型检查,与基线逐项比较。 +- [x] 证明两个旧输入组件不可达后,一起删除旧文件及 `react-virtualized` 声明;共享工具只删除确认没有其他调用者的部分。如果发现有效入口,保留该项并单独记录待迁移,不强删。 +- [x] 将两端 `react-window` 改用 catalog,固定 1.8.11;不升级其他依赖。核对 `react-dnd` 的有效入口并保留依赖和 Provider。 +- [x] 收缩生成的 ESLint suppressions,不手工放宽规则;验证被删组件不存在运行时引用,确认原有冻结规则覆盖剩余遗留依赖。 +- [ ] 串行构建两端已通过;文件预览长列表、真实聊天上传和收藏入口仍待业务验收。 +- [x] 单独提交本批清理结果,记录删除依据与依然保留的依赖。 + +## 第二批:按业务模块替换列表排序 + +**共同修改:** 两端 `package.json`、workspace catalog / lockfile,以及冻结规则和文档。拟采用的 18.0.1 官方 peer 声明支持 React 18 / 19;实施前再次核对已发布包元数据、许可证和安装来源。新包自带类型,不新增旧库的类型依赖。 + +**接口边界:** 保留现有组件 Props、业务排序 ID 和持久化回调;不抽象新的通用业务组件,不维护两套列表排序实现。 + +| 顺序 | 模块 | 修改文件(相对 `src/frontend/`) | 人工验收 | +| --- | --- | --- | --- | +| 1 | 工作流交互选项 | `platform/src/pages/BuildPage/flow/FlowNode/component/DragOptions.tsx` | 输入/输出节点选项排序、继续编辑、删除、保存再打开 | +| 2 | 工作台配置 | `platform/src/components/LinSight/ToolSelector.tsx`;`platform/src/pages/BuildPage/bench/{OrgKbConfig,RecommendedAppsConfig}.tsx` | 工具、知识库、推荐应用排序和保存 | +| 3 | 知识库切分规则 | `platform/src/pages/KnowledgePage/components/FileUploadSplitStrategy.tsx` | 多条规则排序、编辑与保存 | +| 4 | 变量和应用模板 | `platform/src/components/VariablesComponent/VarDialog.tsx`;`platform/src/pages/BuildPage/appTemps.tsx` | 变量顺序、模板顺序,刷新后结果一致 | +| 5 | 客户端应用分类 | `client/src/pages/apps/components/MarkLabel.tsx` | 分类排序、取消、保存后应用导航顺序 | + +每个模块执行相同的小闭环: + +- [x] 先记录现有排序结果和保存载荷;补一个真实交互回归,而不只 mock 排序库调用回调。 +- [x] 引入 catalog 固定的新包,迁移该模块导入和确有必要的类型/兼容处理;遇到拖拽占位、ref 或数据属性依赖时逐处核对。 +- [x] 用隔离 StrictMode 测试执行至少一次键盘排序,断言顺序、回调次数和项目 ID;取消操作不产生保存。工作流复用并补强 `DragOptions.test.tsx`,明确覆盖排序后继续编辑。 +- [ ] 已用真实组件和隔离 API 验证鼠标/键盘、滚动和保存载荷,工作流/模板重载通过;各模块真实后端保存后刷新待业务验收。 +- [x] 测试通过后按模块提交,避免把多个尚未验收的模块混成一次提交。 + +收尾: + +- [x] 再次追踪 `platform/src/components/bs-comp/sheets/TaggingSheet.tsx`;确认无入口则删除死组件及无效引用,否则按相同规则迁移并列入验收。 +- [x] 全仓搜索后移除两端 `react-beautiful-dnd` 声明、锁文件节点及旧类型残留;保留禁止重新引入旧库的 lint 规则。 +- [x] 不改 `react-dnd` 收藏拖拽,不重新连接旧文件上传拖拽监听。 + +## 第三批:整体验证和交付 + +- [x] 检查 catalog 与锁文件:两端排序库版本唯一;`react-beautiful-dnd` / `@types/axios` 不再解析;`react-virtualized` 删除或有明确保留入口与理由。 +- [x] 在 `src/frontend/` 运行 `pnpm lint`、`pnpm typecheck`、`pnpm check-i18n`、`pnpm test:config`;已有问题逐项对照基线,不新增 suppression 或 strict-ignore。发生新错误必须修复。 +- [x] 运行各模块的 Vitest / Jest 回归,单 worker;按上表逐模块完成浏览器验证,不用纯单元测试代替真实拖动。 +- [x] 串行运行 `pnpm build:platform`、`pnpm build:client`;检查生产页面启动,以及拖拽模块按需加载没有运行时错误。 +- [x] 用相同压缩与构建口径比较本批前后产物;记录 gzip 差异,不预先承诺一定减少体积。 +- [ ] 特别回归工作流画布拖动/缩放、代码编辑、文件上传一次、长列表预览及主题/看板配色;已通过相关输入/文件/配色自动化测试,真实页面组合验收待用户执行。 +- [x] 完成对照 `feat/3.0.0` 的独立维护变更 review(无 spec/design,按维护路径审查),交付测试结果和逐模块验收表;用户明确要求后再合并、推送和删除开发分支。 + +## 暂不实施 + +- 不统一所有拖拽场景到一个库:列表排序、画布拖拽、文件上传的语义不同。 +- 不替换 `react-color`,不新增颜色选择器设计;后续由共享库已有设计和落地组件决定。 +- 不迁移 Recoil,不扩大本轮为框架或 React 大版本升级。 + +## Sources + +- 官方归档与替代方向:https://github.com/atlassian/react-beautiful-dnd/issues/2672 +- 拟采用包的版本、类型和 React peer 声明:https://github.com/hello-pangea/dnd/blob/main/package.json +- 工作区真实版本与入口:`src/frontend/{client,platform}/package.json`、`src/frontend/pnpm-lock.yaml` 及上述业务文件(2026-10-22 核查)。 diff --git a/docs/superpowers/plans/2026-10-22-platform-react-query-v4.md b/docs/superpowers/plans/2026-10-22-platform-react-query-v4.md new file mode 100644 index 0000000000..6bc7e03922 --- /dev/null +++ b/docs/superpowers/plans/2026-10-22-platform-react-query-v4.md @@ -0,0 +1,61 @@ +# Platform React Query v4 maintenance plan + +User approved migrating platform from react-query 3.39.3 to the client's resolved +@tanstack/react-query 4.44.0. Native implementation in this session. Work branch: +`codex/platform-react-query-v4`, based on latest origin/feat/3.0.0. No automatic +release synchronization or deployment. + +1. Pin the shared query dependency at 4.44.0 in the workspace catalog; use it in + both applications, remove platform's v3 dependency and update the lockfile. + Reuse installed workspace dependencies; avoid reinstalling client/canvas. +2. Migrate all production imports, test providers/mocks and string query keys + (including their cache removal callers). Preserve existing cache lifetimes, + refresh flags and mutation behavior. Keep v3 request behavior while offline. +3. Cover disabled/manual chart loading, model-list cache key separation, + publish cache updates, department-tree caching and swallowed config errors. + Query success must never cache undefined; preserve existing notices/defaults. +4. Run targeted tests serially with one worker, changed-file lint, platform + strict types against its existing baseline, both app builds and production + startup smoke. Review the diff and record a business acceptance checklist. +5. Update app/root architecture instructions to describe the unified version; + retain lint's ban on legacy react-query imports to prevent reintroduction. + Leave the result on the development branch for acceptance. + +Review focus: disabled queries must not stay on a spinner; offline intranet +requests must still execute; label/workflow/model keys and invalidations must +agree; failed workbench config loads must keep the prior fallback behavior; +publish status must update both string/numeric-ID caches without a list visit. + +## Verification — 2026-10-22 + +- Workspace catalog and both applications resolve @tanstack/react-query 4.44.0; + no platform source imports the removed v3 package. Frozen offline lockfile + validation passed in an isolated workspace without reinstalling local modules. +- 53 tests across 16 files passed with one worker. Coverage includes disabled + charts, explicit reload, model cache separation/invalidation, publish caches, + departments, handled config errors, offline initial requests and reconnect. +- Keep `networkMode: offlineFirst` for queries and mutations, as recommended by + the official v4 migration guide to preserve v3 initial requests, paused retries + and reconnect refresh. An observer regression failed with `always` and passed + after this correction. Independent review found no remaining issues. +- Changed-file ESLint and diff whitespace checks passed. Generated lint + suppressions only shrank. Full platform lint retains 85 existing violations; + platform strict typecheck retains the existing clientVoiceDraft.test.tsx:47 + argument-count error and reports no migration errors. +- Platform production build passed. Static production startup rendered the login + page with no captured console errors; API 404 responses were expected because + the smoke server serves files only. Temporary server and browser tab stopped. +- Client production build and workspace i18n drift checks passed. Existing build + warnings (chunk sizes/cycles and CSS syntax) remain outside this migration. + +## Business acceptance + +1. Model management: switch model types, update status and save workbench/assistant + configuration; verify lists refresh and saved selections remain correct. +2. Workflow builder: open tool/label/template lists, change a label and reopen; + verify cached lists refresh correctly. +3. Dashboards: create/open/edit a dashboard, configure and refresh a chart, publish + directly from the editor, then verify published status in the list. +4. Departments: expand lazy-loaded children, change members and refresh; verify + counts and selections update. Reconnect after a temporary network interruption + and verify an open stale list refreshes. diff --git a/docs/superpowers/plans/2026-10-22-request-error-contract.md b/docs/superpowers/plans/2026-10-22-request-error-contract.md new file mode 100644 index 0000000000..aee1479185 --- /dev/null +++ b/docs/superpowers/plans/2026-10-22-request-error-contract.md @@ -0,0 +1,77 @@ +# 请求错误契约统一计划(历史草案) + +> 本文保留 2026-10-22 的早期讨论,不作为当前实施计划。后续规格、实现与验证以 [F088](../../../features/v3.0.0/088-request-error-contract/) 为准;下方未勾选项为历史状态。 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task after the repository's SDD confirmation gates are complete. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** platform 和 client 的请求失败均保留完整错误,业务层可以恢复状态和处理业务分支,默认提示不会重复。 + +**Architecture:** 分离错误标准化、全局处理、业务处理。先在 platform 提供完整错误并兼容旧包装层,逐模块迁移后收敛旧行为;client 随后对齐同一错误契约。统一契约不要求合并两端请求实现或成功响应结构。 + +**Tech Stack:** TypeScript、现有 Axios 请求封装、两端现有查询库、Vitest / Jest。 + +**Spec:** 本会话已接受的方向;正式 spec/design 尚未建立。本文是范围与顺序草案,不替代 SDD 确认。 + +## Global Constraints + +- 这是跨模块错误契约变更,实施前按 `docs/SDD-Guide.md` Full SDD 路径完成需求、spec、design 确认;版本线和 feature 编号在实施准备时核实。 +- 保留两端成功响应结构;不引入新的请求、状态或 UI 库。 +- 登录失效、会话被踢、通用权限跳转、许可证和维护遮罩继续集中处理;具体业务不新增通用 403 分支。 +- 默认提示与错误透传独立;取消请求不弹失败提示,静默请求仍向调用方传递错误。 +- UI 改动遵守当前设计规范;触及旧中文字符串时同步完成三语言提取,不手改错误文案生成文件。 +- 当前工作区有其他改动,实施需隔离,不能混入查询库迁移等其他工作。 + +## Review Focus + +- HTTP 200 中携带业务失败时,严格契约下必须进入失败分支。 +- 错误已经全局提示时,调用方仍能取得完整错误,且不再次提示。 +- 旧包装层返回 false/undefined 的调用方不能在迁移时误走成功流程。 +- 登录/权限/许可证/维护并发异常仍只触发既定全局处理;错误数据保持可辨认。 +- 网络失败、取消、非标准响应和文件下载不能被误判为普通业务响应。 + +## 1. 盘点并确定验收边界 + +**Files:** `src/frontend/platform/src/controllers/request.ts`、`src/frontend/client/src/api/request.ts` 及实际调用方;正式 feature 文档路径待版本线核实后确定。 + +- [ ] 建立调用清单:直接请求、旧错误包装层、自定义提示、按错误码分支、依赖 false/undefined 的后续逻辑。 +- [ ] 明确 HTTP 状态与业务错误码的区别,定义错误对象保留消息、附加数据、原始响应、原始异常及提示状态;网络错误允许没有响应。 +- [ ] 明确旧特殊返回码(包括 platform 的 11010)、Blob 下载和 client 非统一响应接口的成功判据,禁止把缺少 status_code 一律当失败。 +- [ ] 将上述 Review Focus 写成可检验 AC,完成 SDD 需求、spec、design 确认。 + +## 2. platform 保留完整错误 + +**Files:** `src/frontend/platform/src/controllers/request.ts`;新增同目录错误模型与拦截器测试文件,具体拆分在 design 中固定。 + +- [ ] 先补标准业务失败、HTTP 失败、网络异常、取消、静默请求及全局处理分支测试。 +- [ ] 将普通失败标准化为错误对象并 reject,默认提示不再以丢弃错误对象为代价。 +- [ ] 兼容旧错误包装层的返回值和提示方式,避免结构化对象显示为 JSON 或重复提示;新调用方走严格失败契约。 +- [ ] 运行拦截器测试及现有 DSH 请求兼容测试,验证已有 preserveError/silent 场景。 + +## 3. platform 调用方分批迁移 + +**Files:** 第 1 步清单中的 API、页面和 hooks;每批在 tasks 中列出实际文件。 + +- [ ] 优先迁移表单提交、编辑保存、删除和业务冲突处理,再覆盖其余旧包装层调用。 +- [ ] 每批先补关键失败行为测试,再将 false/undefined 判断改为明确失败处理,确保 loading 恢复、输入保留、弹窗不会错误关闭、失败不显示成功提示。 +- [ ] 自定义提示的请求关闭默认提示;仅恢复状态的调用方继续使用默认提示。 +- [ ] 全部相关调用迁移并验证后再清理旧包装层和冗余开关,不能提前全局改变其返回语义。 + +## 4. client 对齐同一契约 + +**Files:** `src/frontend/client/src/api/request.ts` 及第 1 步定位的 API、hooks 和页面;拦截器测试在 design 中固定文件名。 + +- [ ] 先覆盖当前 HTTP 200 + 业务失败仍 resolve 的场景,以及依赖返回体判断错误的调用方。 +- [ ] 分批切换为同一错误模型和 reject 语义,同步迁移调用方;避免把开关全局翻转后再补页面。 +- [ ] 提示开关仅决定提示行为,不能决定请求是否算失败;保留并验证维护遮罩、会话和通用权限处理的既定效果。 +- [ ] 删除迁移后的重复错误构造、手工解析和重复提示,运行相关 Jest 回归。 + +## 5. 完整验收和收尾 + +**Files:** 两端受影响测试、正式 feature 的 AC / tasks / E2E 报告、适用范围内的开发约定。 + +- [ ] 完成 `/e2e-test`:覆盖成功、业务失败、网络失败、登录过期、权限不足、表单失败恢复、下载及并发错误提示。 +- [ ] 从 `src/frontend/` 运行 `pnpm lint`、`pnpm typecheck`、`pnpm check-i18n`,并运行受影响的 platform Vitest 和 client Jest 测试。 +- [ ] 检查剩余吞错、失败后成功提示、未处理 rejection 和重复 toast;仅保留明确的业务恢复行为。 +- [ ] 完成 `/sdd-code-review`,记录兼容边界和迁移完成情况;验收依据是运行结果及调用清单全部闭环。 + +**验收结果:** 两端业务层取得一致、完整的错误;请求失败进入失败流程;每个请求按配置提示且不重复;已有全局处理和成功响应保持约定行为。 diff --git a/docs/superpowers/plans/2026-10-22-user-facing-error-messages-test-cases.md b/docs/superpowers/plans/2026-10-22-user-facing-error-messages-test-cases.md new file mode 100644 index 0000000000..2cbf9d5a4f --- /dev/null +++ b/docs/superpowers/plans/2026-10-22-user-facing-error-messages-test-cases.md @@ -0,0 +1,86 @@ +# 错误处理:统一验收清单 + +适用分支:`feat/3.0.0`,合并提交 `46a62373b`。合并本次错误提示用例与另一会话「评估 platform 错误传递方式」整理的标签、PDF 用例,重复的网络、超时场景只测一次。共 21 项,**尚未逐条在真实后端执行**;已完成的自动化与浏览器模拟结果见 [验证记录](2026-10-22-user-facing-error-messages-verification.md)。 + +测试前准备:使用测试环境、管理员账号、可编辑的知识库和测试文件。每次只点击一次提交按钮;记录提示内容/次数、弹窗/输入是否保留、loading 是否结束。切换语言后等待语言资源加载完成再测试。 + +## 一、可直接操作验收 + +无需修改后端数据。网络失败可在浏览器开发者工具 Network 中找到本次提交请求,启用 Request blocking,只阻断该请求的路径,再重新操作;测完立即关闭阻断。不要通过清空登录态模拟网络失败,也不要阻断整站请求。 + +| ID | 页面与场景 | 操作 | 预期 | +| --- | --- | --- | --- | +| U01 | 审批管理:提交网络失败 | 打开新增或编辑弹窗,填写/选择内容,阻断对应提交接口,点击一次提交 | 仅一条网络失败提示;没有 `[object Object]`、URL、堆栈或 JSON;弹窗及内容保留,不提示成功 | +| U02 | 审批管理:失败后恢复 | 接 U01,解除阻断,在原弹窗再次提交 | 恢复正常提交,页面刷新为正确结果;不残留失败 loading,不因上次失败多发一次请求 | +| U03 | 知识库:段落删除失败 | 使用可丢弃的测试段落,阻断删除接口,执行删除 | 一条可读提示;失败后列表回滚或重载为实际数据,段落没有在界面永久消失;按钮可继续操作 | +| U04 | 知识库:段落编辑失败 | 修改测试段落,阻断保存接口,点击保存 | 一条可读提示,不显示成功;编辑内容保留或可恢复,loading 结束;解除阻断后仍可提交 | +| U05 | QA 导入:本地 Excel 校验 | 分别选择空表、缺少 question 或 answer 列的 Excel,再选择一份合格 Excel | 空表/缺列仍有具体校验原因,不退化成通用错误;不合格文件不上传,合格文件可继续原有流程 | +| U06 | client:文件上传网络失败 | 在知识库上传测试文件,只阻断文件上传请求 | 摘要指出失败文件,文案可读,不拼接 Axios/HTTP/URL 等技术串;不误显示上传成功;解除阻断后可重试 | +| U07 | platform/client:切换语言 | 分别切换中文、英文、日文,重做一次网络失败 | 使用当前语言;不显示 `api_errors...` 键、`undefined`、未替换的占位符;每次操作的提示不重复 | +| U08 | 2FA:既有认证提示(入口开放时) | 使用专用测试账号,在原有验证步骤输入错误验证码;再用正确验证码验证 | 错误码仍显示原有本地化提示,正确验证码仍按原流程继续;不要用真实个人账号反复试错。部署未开放入口时记录“不适用”,不能记通过 | +| U09 | client 知识库:搜索标签加载失败 | 进入知识库详情页,在文件列表上方找到搜索框;阻断标签查询请求,进入页面或聚焦搜索框 | 显示一条可读失败提示;搜索框仍可输入,不把加载失败误当成成功的空标签列表 | +| U10 | client 知识库:标签失败后恢复 | 接 U09,解除阻断,再次聚焦搜索框 | 已有标签正常显示,可选择标签搜索;没有上次失败遗留的 loading | +| U11 | client 知识库:快速切换 | 在 Network 开启限速,进入 A 知识库触发标签查询,尚未返回时切到 B;让 A 的旧请求失败,B 请求正常返回 | 不弹 A 的过期错误,不把 A 的标签填到 B;B 的标签正常显示、可搜索。若无法稳定制造顺序,交由 QA 延迟/拦截指定请求,不记通过 | +| U12 | client 知识库:标签编辑失败与重试 | 在测试文件的标签编辑弹窗,依次验证新增标签、删除标签、单文件保存及批量保存:阻断对应请求后操作,再解除阻断重试 | 每项失败仅提示一次,不误显示成功;失败删除的标签仍在,保存失败不关闭弹窗,选择/输入保留、按钮解锁;重试后正常生效。具体业务原因另测 M02,未知错误另测 M01 | +| U13 | client 知识库:PDF 正常预览 | 打开多页 PDF,缩放、跳页,再切换另一份 PDF 和关闭预览 | 页面正常渲染,缩放/跳页可用,不出现失败提示;取消旧渲染不误报错误 | + +建议按页面连续测:审批 U01–U02 → 知识库段落 U03–U04 → 导入/上传 U05–U06 → client 标签 U09–U12 → PDF U13 → 三语言 U07 → 2FA U08(如开放)。接口被阻断属于前端失败验证,不能据此证明后端错误分支正确。 + +## 二、开发/QA 的模拟失败验收 + +通过测试环境接口 Mock 或本地响应替换模拟,**不修改生产配置、配额或数据库**。需要同时检查响应原文仍可在 Network / 错误证据中取得,但不会进入 toast 或文件失败提示。以下 HTTP 200 响应均表示业务失败,不应显示成功。 + +| ID | 注入位置与输入 | 预期 | +| --- | --- | --- | +| M01 | 审批提交返回 HTTP 200,`status_code: 49999`,`status_message: {"detail":"PRIVATE_TEST"}`;再把 status_message 分别改为数组及普通技术字符串 | 均使用通用本地化兜底,一次操作只提示一次;不出现 PRIVATE_TEST、对象 JSON 或 `[object Object]`;弹窗不误关闭 | +| M02 | 上传返回已登记业务码 19403,data 为 `{"used_gb":10,"quota_gb":10}`;标签操作另返回已登记业务码 10987 | 上传显示存储配额的具体本地化提示,10/10 参数正确;批量上传保留早停及合并摘要。标签显示对应的具体本地业务原因,不统一抹成“操作失败”;不直接采用后台技术字符串 | +| M03 | 解析失败返回 10019,data.exception 为 `PRIVATE_TEST`;另用 10014 验证未配置模型 | 保留 ASR 初始化/未配置的具体指引,提示中没有 PRIVATE_TEST;文件保持失败状态,不误标为成功 | +| M04 | 文件状态轮询为失败,remark 分别是普通技术串、含 exception 的 JSON、未知错误码对象;对同一文件连续轮询 | platform 显示“解析失败”等本地兜底,client 显示本地兜底;同一轮上传中同一失败文件不重复通知;原始 remark 独立保留,不能复用 `_raw` 重试字段,也不新增全量控制台输出 | +| M05 | 已登记业务码的 data 加入 `defaultValue: "PRIVATE_TEST"`、`lng: "missing"`、`ns: "missing"` | 仍显示当前语言登记的业务文案;后端参数不能覆盖翻译配置或通过兜底透出 PRIVATE_TEST | +| M06 | 两端各人为延迟受测请求直至超时;另验证取消上传/请求 | 超时提示“请求超时,请先确认操作是否完成,再重试”或对应语言,不显示底层异常;主动取消不弹失败提示、不误显示成功;可重新操作。重试前先确认原操作是否已完成,避免重复写入 | +| M07 | client PDF 已成功打开,Mock 页面读取或渲染失败;让多页同时失败 | 显示本地化 PDF 加载失败提示,同一文档只提示一次,不出现原始异常或未处理 Promise 拒绝;当前文档渲染过程中改变缩放后发生的真实失败仍应提示 | +| M08 | 延迟 PDF A 的页面渲染,在切换到 PDF B 或关闭预览后,再让 A 渲染失败;另模拟正常渲染取消 | 不弹旧文件或取消错误,不干扰 B 的内容和正常操作;B 自己的真实失败仍能正常提示 | + +M01 响应示例(仅用于 Mock): + +```json +{ + "status_code": 49999, + "status_message": { "detail": "PRIVATE_TEST" }, + "data": { "exception": "PRIVATE_TEST" } +} +``` + +M04 只修改文件状态查询响应中的目标测试文件,保留其原 id 和文件名,设置原有失败状态值及 remark;不要把整个查询接口改成请求失败,否则测不到异步解析链路。未知诊断原文不会自动作为提示展示,故后端应通过登记错误码提供业务原因。 + +M07–M08 需要 QA/开发在 PDF 页面读取或渲染环节注入失败。**把 PDF 下载接口改成 500 只能验证下载失败,不能替代这两项。** 没有注入条件时标记“未测”,不要用截图正常或下载失败替代。 + +## 记录方式 + +按 ID 记录“通过 / 失败 / 不适用 / 未测”,失败附:当前语言、操作步骤、目标请求、响应状态/错误码、实际提示及出现次数、弹窗/输入/loading 状态。不把接口 Mock 通过记为真实后端验收通过。 + +可复制下面的记录表;U12 的四种操作分别记录,全部通过后才把 U12 记为通过。 + +| ID | 结果 | 备注/缺陷 | +| --- | --- | --- | +| U01 | 未测 | | +| U02 | 未测 | | +| U03 | 未测 | | +| U04 | 未测 | | +| U05 | 未测 | | +| U06 | 未测 | | +| U07 | 未测 | | +| U08 | 未测 | | +| U09 | 未测 | | +| U10 | 未测 | | +| U11 | 未测 | | +| U12 | 未测 | 新增 / 删除 / 单文件保存 / 批量保存 | +| U13 | 未测 | | +| M01 | 未测 | | +| M02 | 未测 | | +| M03 | 未测 | | +| M04 | 未测 | | +| M05 | 未测 | | +| M06 | 未测 | | +| M07 | 未测 | | +| M08 | 未测 | | diff --git a/docs/superpowers/plans/2026-10-22-user-facing-error-messages-verification.md b/docs/superpowers/plans/2026-10-22-user-facing-error-messages-verification.md new file mode 100644 index 0000000000..047489d91a --- /dev/null +++ b/docs/superpowers/plans/2026-10-22-user-facing-error-messages-verification.md @@ -0,0 +1,68 @@ +# 错误提示文案修复:验证与验收 + +2026-10-22;基线 `2844b274b`;隔离开发分支 `codex/user-facing-error-messages`。已实现并完成技术验证;合并目标为 `feat/3.0.0`。实施计划见 [plan](2026-10-22-user-facing-error-messages.md)。 + +## 改动结果 + +- 两端请求失败只采用本地登记的业务文案或通用兜底;网络与超时使用三语言提示。原始异常、对象、未知后端串不再作为兜底展示。 +- 18 个既有错误码模板删除技术异常插值,保留具体失败场景及文件名、模型名、配额等业务参数。后端数据只填充占位符,不再作为翻译配置传入。 +- 保留 ApiError 身份、原始 cause/response/status_message/data、错误码恢复分支、取消/静默与提示去重行为;已标准化的错误也不会绕过文案收口。 +- 上传摘要、异步解析详情和预校验异常同步收口;唯一的本地 Excel 预校验返回文案仍保留,非法对象不会转成字符串。 +- 两端失败文件状态独立保留诊断证据;client 的 failureEvidence 不复用重复文件 `_raw`。删除 platform 原有全量 files 调试日志,避免自动输出新增证据。 +- 2FA 仅删除 5 个未被调用的错误回调及 Props;实际认证请求的本地化失败处理不变。 +- 触及文件中的旧敏感内容提示已提取三语言;未修改页面视觉和后端接口。 + +## 验证结果 + +| 检查 | 结果 | +| --- | --- | +| 共享错误包 | 13 项测试通过;lint / 类型检查通过 | +| platform 请求、审批、段落元数据、上传 | 43 项测试通过,单 worker | +| client 请求、业务错误转换、知识库与上传 | 70 项测试通过,单进程 | +| 修改文件 ESLint | 两端通过;suppression 仅收缩,未新增 strict-ignore | +| 类型检查 | client 全部通过;platform 保留原有 1 处测试错误 | +| i18n / locales 生成一致性 | 通过,无新增漂移 | +| architecture guard / git diff --check | 通过 | +| 生产构建 | platform 23.73s、client 51.72s,串行通过 | +| 独立 review | PASS;诊断证据遗漏及全量日志问题均已修复 | + +完整 lint 仍有实施前已存在的问题:platform 85 errors,client 29 errors / 2 warnings。平台类型基线为 `clientVoiceDraft.test.tsx:47` 参数数量错误。本次修改文件无新增违规,没有为了通过检查放宽规则。 + +测试经过 RED → GREEN:普通/循环对象与原始异常、技术模板插值、翻译参数覆盖、已标准化静默错误、上传原始串与映射证据均先复现后修复。回归保留已知业务码、配额早停、原有一次重试预算、重命名元数据与敏感词提示。 + +## 浏览器验证及边界 + +本机临时 Vite 夹具使用真实组件、真实两端请求适配器、现有样式和模拟接口,不连接业务后端、不写入环境数据。通知显示时间仅在夹具延长,生产实现未修改。 + +- 真实 ApprovalPage 新增弹窗:未知异常、业务冲突 26130、网络失败分别验证。每次只显示一条对应提示,弹窗与选择内容保留,没有误显示成功。 +- 真实 FileUploadStep4:未知 remark 显示“解析失败”,失败行不暴露原始串。重复轮询去重和诊断证据另有组件测试验证。 +- client 真实错误出口:普通异常显示中文兜底,超时显示中文超时提示。 +- 真实段落删除 API 配合测试表单:未知、业务冲突、网络失败三类均单次提示,输入保留、按钮恢复。此项没有挂载完整 Paragraphs 页面,不能称为完整页面验收。 + +浏览器仍观察到既有上传组件的嵌套 p 和 updater 中触发通知的 React 开发警告;相关写法本轮未改,不称为控制台全绿。临时验收页自身的首次路径/语法问题已修正后复测。临时浏览器页与服务已关闭。 + +![真实审批弹窗在模拟未知异常后保留选择并显示兜底](assets/2026-10-22-user-errors-approval.png) + +## 真实业务验收(待执行) + +可直接使用 [业务测试 case](2026-10-22-user-facing-error-messages-test-cases.md),按 U01–U08 与 M01–M06 分别记录结果。 + +- [ ] 审批新增/编辑失败:只提示一次,保留输入,不误关闭或显示成功;配额/冲突等具体业务原因仍可读。 +- [ ] 完整知识库段落页:删除/编辑失败后回滚与重载正常,未知异常不展示对象或技术串。 +- [ ] 上传:本地 Excel 校验仍具体,网络/解析失败可读;ASR 配置类错误仍有操作指引;重试与批量配额早停正常。 +- [ ] 切换中/英/日语言检查提示;真实后端 2FA 流程未验收,不以死回调清理代替认证验收。 + +用户已授权合并、提交并推送到 `feat/3.0.0`;本轮不自动同步 release。 + +## 合并回归(2026-10-22) + +用户确认将另一任务的网络/超时提示、标签编辑、PDF 预览及搜索输入错误处理一同提交合并。该任务先提交为 `fb96baa6d`,随后合入 `codex/user-facing-error-messages`(`dedd41717`)。 + +- 请求封装同时保留安全文案过滤与网络/超时分类;统一使用 `network_error` / `request_timeout` 三语言键,删除重复键,重新生成两端语言资源。 +- 合并测试同时覆盖双方行为。标签测试改用已登记业务码验证具体原因,未知后台串验证通用兜底;重试、取消、全局提示及编辑保留断言不变。 +- 新增语言资源尚未初始化时的回归,先复现 `undefined` 被转为提示,再修复为可读兜底。 +- 共享包 19 项、platform 47 项、client 94 项,共 160 项相关测试全部通过,单 worker / 单进程运行。 +- 两端全部改动文件 ESLint、共享包 lint、语言资源生成检查、i18n 漂移检查、架构检查和差异空白检查通过。 +- 合并后类型检查:client 与共享包通过;platform 仍只有原有的 `clientVoiceDraft.test.tsx:47` 参数数量错误,没有新增错误。 +- 合并后串行生产构建:platform 31.74s、client 1m18s,均成功。保留既有字体、CSS、产物体积等构建警告。 +- 合并前已重新 fetch,远端 `feat/3.0.0` 仍为 `2844b274b`,没有遗漏上游新增提交。 diff --git a/docs/superpowers/plans/2026-10-22-user-facing-error-messages.md b/docs/superpowers/plans/2026-10-22-user-facing-error-messages.md new file mode 100644 index 0000000000..a4ff83298b --- /dev/null +++ b/docs/superpowers/plans/2026-10-22-user-facing-error-messages.md @@ -0,0 +1,85 @@ +# User-facing Error Messages Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task after user confirmation. Steps use checkbox (`- [x]`) syntax for tracking. + +**Goal:** 原始对象、技术异常和未识别的后端错误不直接进入用户提示,保留完整诊断证据及既有业务错误分支。 + +**Architecture:** 在两端请求适配层收紧用户文案选择;共享包继续只保留错误结构与无 UI 的模板处理。上传异步解析失败单独收口,因为其成功轮询响应不经过请求失败出口。不靠格式猜测把任意字符串认定为安全文案,也不把全部业务失败抹平成通用错误。 + +**Tech Stack:** 现有 TypeScript、@bisheng/api-client、i18next、Vitest / Jest、React Testing Library;浏览器拦截接口验证,不新增依赖。 + +**Spec:** `features/v3.0.0/088-request-error-contract/spec.md` AC-03、AC-14、AC-19 及 design.md 的“message 是本端翻译后可读消息”约定。本计划修复既有契约的遗漏,采用维护路径;如发现必须改变产品恢复行为,停止该项并找用户判断。用户于 2026-10-22 确认开发。已在隔离分支实现,验证状态见同目录 verification 文档。 + +## Global Constraints + +- 实施前拉取最新 `feat/3.0.0`,在 `codex/user-facing-error-messages` 开发;不混入其他任务,不自动合并、推送或同步 release。 +- 保留 `cause`、`response`、`status_message`、`data`、错误种类、对象身份和 presentation 去重语义;不为友好文案吞掉异常或修改成功响应。 +- 用户文案优先采用已登记错误码的本地化模板;网络/超时使用本地化提示;未知错误使用既有通用兜底。取消请求不提示,已全局处理或已展示的不重复提示。 +- 只有已有明确业务契约的本地化业务文案可以保留。任意 Error.message、服务端 status_message、JSON、对象字段都不自动视为可信用户文案。先盘点依赖现有 message 的有效调用者,避免断开既有恢复流程。 +- 模板参数按字段语义审计:保留业务需要的文件名、数量等;不能把 exception、堆栈或任意对象通过插值重新带回用户提示。检查已有错误翻译模板及后端实际数据,不只扫描 toast 调用。 +- 不修改设计样式,不新增库;新文案补齐 zh-Hans/en/ja,错误码生成文件通过既有脚本更新,不手改。 +- 2FA 的 5 个 error.message 回调未被子组件调用,只清理死接线,不计作 5 个可触发缺陷;不恢复死入口。 +- 不新增自动原始异常日志,避免重复记录或引入敏感数据输出;原始证据继续随错误向调用方传播。 +- 测试单 worker,两端构建串行,记录并对照现有 lint / 类型基线。 + +## Review Focus + +1. 已知业务码仍有具体可读提示,配额/冲突等恢复分支继续工作;见任务 1。 +2. 未知码、普通异常、对象、数组、循环对象及含堆栈字符串不泄露原始内容、不导致二次异常;见任务 1。 +3. 已是 ApiError 的对象不会因幂等返回绕过文案收口,且身份与证据不丢失;见任务 1。 +4. 上传轮询 HTTP 成功但文件解析失败,只提示一次;未知 remark 兜底,已知 ASR 等场景保持具体提示;见任务 2。 +5. silent、取消、登录/权限/许可证全局处理仍按原契约执行,不新增重复 toast;见任务 1、任务 3。 + +## Task 1: 收紧两端统一错误出口 + +**Files:** +- Modify: `src/frontend/platform/src/controllers/request.ts` +- Modify: `src/frontend/client/src/api/request.ts` +- Audit / necessary modify: `src/frontend/client/src/utils/apiStatusError.ts` 及两端错误码翻译模板的有效消费链 +- Test: `src/frontend/platform/src/controllers/requestErrors.test.ts`、`requestCompatibilityMatrix.test.ts`、`requestSessionFailure.test.ts` +- Test: `src/frontend/client/src/api/requestUserMessages.test.ts`(新增)及 `src/frontend/client/src/utils/apiStatusError.test.ts` +- Only if required: `src/frontend/packages/api-client/src/apiError.ts`、`apiError.test.ts`;共享层不引入翻译或 UI。 + +**Interfaces:** 保留 `showApiError(cause: unknown)` 的现有返回类型、调用约定和去重行为。用户显示文案由适配层决定,normalizeApiError 继续保留原始证据;不增加靠 error.message 判断的业务分支。 + +- [x] 盘点两端消息构造、已存在 ApiError 的创建者及 message 消费者;区分业务提示、诊断、错误分支。记录应保留的具体错误提示和业务恢复分支,核实错误模板是否插入技术异常。 +- [x] 补失败测试:普通 Error('SQL / stack / URL')、未知错误码、原始字符串/对象/数组、循环对象、已存在 ApiError 和含技术 exception 的模板参数,最终提示必须为本地化兜底或已登记的安全业务文案;已知码和必要业务参数保持正确。 +- [x] 运行新增及现有请求测试,确认新测试因文案透出而失败,记录 RED;不能因测试配置故障认定 RED。 +- [x] 统一请求标准化、兼容包装层及 showApiError 的文案选择,移除把原始消息/JSON作为用户兜底的路径;处理已存在 ApiError 的显示路径,保持对象身份与原始证据。先改创建者,再确认旧对象显示也不会漏出。 +- [x] 运行同组测试确认 GREEN,断言原始证据引用、业务码、重试分类、取消/静默及 presentation 去重没有变化;运行 `pnpm check-i18n`。 +- [x] 单独提交统一出口与回归测试。 + +## Task 2: 收口上传与异步解析失败提示 + +**Files:** +- Modify: `src/frontend/platform/src/pages/KnowledgePage/knowledgeParseFailureMessage.ts` +- Modify: `src/frontend/platform/src/pages/KnowledgePage/components/FileUploadStep4.tsx` +- Modify: `src/frontend/platform/src/components/bs-ui/upload/simple.tsx` +- Audit / necessary modify: `src/frontend/client/src/api/knowledgeParseFailureMessage.ts`、`src/frontend/client/src/pages/knowledge/hooks/useFileUpload.ts`;复用任务 1 的请求出口,不重复写转换器。 +- Test: platform `FileUploadStep4.errors.test.tsx`、`simple.errors.test.tsx`;client `uploadFailureContract.test.ts`、`uploadTerminalErrors.test.tsx`;根据解析器可测性补同目录纯函数测试。 + +**Interfaces:** 保持上传回调、文件状态及轮询次数;known parse failure → 既有本地化文案,unknown parse failure → 既有解析失败提示。文件对象中的原始失败证据不被用户展示字符串覆盖;若目前没有独立字段,先核对有效消费者再做最小调整。 + +- [x] 追踪上传接口失败、preCheck、异步解析 remark 到 toast/行内提示的所有有效路径;审计客户端同类逻辑,已安全的路径只补回归,不机械改写。 +- [x] 补失败测试:未知 remark 字符串/JSON/对象使用解析失败兜底;已知 ASR 和业务码仍正确;重复轮询同一失败文件只提示一次;preCheck 抛技术异常不直出,既有本地校验提示保持。 +- [x] 运行并确认 RED,移除解析器及调用者的原始串兜底,preCheck 的任意异常交给统一出口;明确 preCheck 本地用户文案的产生者,不凭对象里有 message 就信任。 +- [x] 同组测试 GREEN;验证单次上传、失败状态、文件重试与 loading 恢复不变。 +- [x] 单独提交上传修复。 + +## Task 3: 清理无效 2FA 回调并完成验收 + +**Files:** `src/frontend/client/src/components/Nav/SettingsTabs/Account/TwoFactorAuthentication.tsx`、`TwoFactorPhases/{SetupPhase,QRPhase,VerifyPhase,BackupPhase,DisablePhase}.tsx`;本计划同目录 verification 文档(实施时新增)。 + +- [x] 再次确认 5 个阶段组件未调用 onError,删除父组件传参和对应未使用 Props;保留实际 mutation 的本地化失败提示。不为清理死接线新增一套 2FA 实现。 +- [x] 跑相关测试及类型检查,搜索确认没有遗留失效回调;执行两端请求、上传与共享错误包测试。修改触及的旧中文文案按仓库规则提取。 +- [x] 运行 `pnpm lint`、`pnpm typecheck`、`pnpm check-i18n`,与实施前基线比较;不增加 suppression。串行构建两端,必要时生产启动检查。 +- [x] 浏览器已使用真实 ApprovalPage 新增弹窗、FileUploadStep4 及两端错误出口,模拟失败接口验证;段落仅验证真实删除 API 与测试表单的错误出口,完整段落页面未联调,列入业务验收。 +- [x] 静态确认 2FA 的既有错误提示不变;没有可用后端时不宣称真实认证流程验收通过。 +- [x] 独立 review 并输出验证/业务验收清单,分别列明已测、未测和基线问题;提交清理和交付文档。用户明确要求后再合并推送。 + +## 暂不实施 + +- 不把所有异常都变成同一句话;保留已登记的业务原因。 +- 不全面重做后端错误码;未知错误码单列后续,不能以展示原始异常替代登记。 +- 不重复迁移已经改好的 Paragraphs 和审批 catch,也不把旧行号当当前缺陷依据。 +- 不扩展到模型生成正文、工具输出、诊断控制台等非失败 toast 场景。 diff --git a/docs/superpowers/plans/assets/2026-10-22-user-errors-approval.png b/docs/superpowers/plans/assets/2026-10-22-user-errors-approval.png new file mode 100644 index 0000000000..84494b1716 Binary files /dev/null and b/docs/superpowers/plans/assets/2026-10-22-user-errors-approval.png differ diff --git a/docs/verification/app-attachment-download-20261102.md b/docs/verification/app-attachment-download-20261102.md new file mode 100644 index 0000000000..ca17b949e3 --- /dev/null +++ b/docs/verification/app-attachment-download-20261102.md @@ -0,0 +1,44 @@ +# 应用历史附件下载路由修复 + +## 基线与范围 + +- 目标分支:`dataelement/bisheng:feat/bisheng-work-120-main`。 +- 实现前与提交前在线核对的基线:`3120e714fa4ed492884cade11a128341d03eb105`。 +- 修复分支:`codex/app-attachment-download`;独立目录:`bisheng-app-attachment-download-20261102`。 +- Client `3.0.0-beta5`,pnpm `9.15.9`,按现有锁文件安装,依赖声明与锁文件保持原内容。 + +## 根因与修正 + +应用消息的 `AppChatFileList` 经 `AppChatFileChip` 将历史文件 URL 交给 `ChatFile`。原下载处理把绝对 URL 的主机名替换为 Client 的 `BASE_URL`,例如存储地址 `/bisheng/chat/...pdf` 被改成 `/workspace/bisheng/chat/...pdf`。请求进入 SPA 路由,前端 catch-all 返回 HTTP 200 的 HTML;下载工具按原文件名保存响应,用户得到带 `.pdf` 扩展名的 HTML。 + +`ChatFile` 现在复用现有 `resolveKnowledgePreviewUrl`,让已知对象存储路径经 origin 级 bucket 代理访问,兼容历史 Client 前缀及根相对路径,保留原签名查询、编码后的对象名和下载文件名。第三方资源、API URL 与 blob URL 沿用其自身地址。空地址继续保持原处理。 + +组件补齐 Props 类型并纳入 strict 检查,调用方改为具名导入。空扩展名沿用 `FileIcon` 的文本图标默认值;现有卡片样式保持原内容。通过 ESLint 官方 prune 命令移除了该文件已失效的正则转义 suppression。 + +## 验证证据 + +- 新增组件回归使用真实 `AppChatFileList`、`AppChatFileChip`、`ChatFile` 和 URL 解析函数。下载传输接本地 HTTP 夹具:正确对象路径返回合成 PDF 响应,其余路径返回前端 HTML;断言点击后的请求地址、响应类型与字节。首轮旧实现 16 项中 13 项失败,其中点击合法存储地址得到 `text/html`。 +- 最终新增 17 项覆盖历史字段别名、历史前缀、根相对及协议相对路径、临时桶、签名参数、编码对象名、中文文件名、第三方/API/blob URL、空路径和 HTTP 路由。 +- 应用聊天、历史附件与预览 URL 组合:11 组 / 88 项通过。 +- workspace 全量 `lint` 通过,包含 Platform、Client、UI 和 file-viewers。 +- workspace 全量 `typecheck` 通过:Platform 620 个 strict 文件、Client 1513 个 strict 文件,以及 file-viewers 类型检查。 +- Client 生产构建、变更文件架构守卫及 `git diff --check` 通过。 +- 本机未构建的可选 canvas 导致初次 Jest 环境启动失败;按 Client 现有规则移开安装目录中的可选链接后,测试正常执行。未修改项目测试配置或依赖声明。 + +网页部署、真实存储对象、真实账号、Windows/macOS 安装版下载及 PDF 打开验收待执行。HTTP 夹具验证与生产环境验收分别记录。 + +## Code Review + +按仓库 code-review 检查表审查本次 hotfix;范围为附件卡片的地址解析及回归,基线为上述版本。 + +| 维度 | High | Medium | 结论 | +| --- | --- | --- | --- | +| 边界条件 | 0 | 0 | 空路径、历史前缀与编码参数已覆盖 | +| 权限与认证 | 0 | 0 | 复用既有桶代理和签名;服务端认证授权保持原链路 | +| 并发安全 | 0 | 0 | 点击时纯地址解析,沿用下载生命周期 | +| 信息泄漏 | 0 | 0 | 新增夹具使用合成地址和签名;未引入凭据或日志 | +| 测试覆盖 | 0 | 0 | 旧版复现 HTML,修正版验证 PDF 响应 | +| 代码风格 | 0 | 0 | 复用现有解析器,类型及 lint 通过 | +| 文档同步 | 0 | 0 | STATUS 与本记录同步;沿用 hotfix 轻量流程 | + +Overall: PASS。发布及真实账号验收按上一节单独执行。 diff --git a/docs/verification/app-chat-back-20261031.md b/docs/verification/app-chat-back-20261031.md new file mode 100644 index 0000000000..86006f1b8a --- /dev/null +++ b/docs/verification/app-chat-back-20261031.md @@ -0,0 +1,22 @@ +# 应用会话返回按钮 — 2026-10-31 + +## 基线 + +- 仓库与版本:`dataelement/bisheng:feat/bisheng-work-120-main@4cc8f565fb5bc8bd918973ce74f876e6fd66af26`,通过远端 fetch 核对。 +- 工作目录:`.worktrees/bisheng-app-chat-back-visible`;分支:`codex/app-chat-back-visible`。 +- 本次修改属于 BISHENG 网页应用会话导航。已安装 BISHENG 包版本 `1.2.0-test.2`,应用包内 `@deepseek-ai/dsh-harness-dev-context` 版本 `0.2.1-rc.2`,通过 asar 元数据读取。 + +## 根因和行为 + +- 紧凑布局(内容区宽度 ≤1023px)使用 MobileNav。旧逻辑在会话标题加载后隐藏顶栏返回按钮;SideNav 的返回区域同时受 `touch-desktop` 控制,紧凑布局隐藏该区域。侧栏收起时 AppRoot 的宽屏悬浮返回也退出渲染,导致返回入口消失。 +- 标题栏现在根据应用返回动作显示返回按钮,标题加载与历史展开均保持入口可用。沿用已有按钮样式,给长标题预留菜单和返回所需的宽度。 +- 菜单共用同一处理逻辑:桌面切换应用侧栏,移动端打开系统菜单;操作前关闭历史下拉。 +- 返回继续调用现有来源解析:发现广场进入的会话回到 `/square?type=apps`,应用中心进入的会话回到 `/apps`,移动探索回到 `/apps/explore`。 + +## 验证 + +- 旧代码对照:新增 3 项标题加载/移动端返回测试均失败,已有 12 项通过。 +- 修正后 3 个测试文件、33 项通过。AppRoot 与真实 MobileNav、真实来源解析组合测试覆盖标题加载、侧栏展开/收起、历史展开后的三种来源返回,以及此前菜单、新建和历史下拉行为;探索入口测试使用真实路由验证进入与返回。 +- Client 全量 ESLint、1504 个严格类型文件;Platform 全量 ESLint、620 个严格类型文件;UI/file-viewers ESLint、file-viewers tsc 检查通过。Client 生产构建、i18n 与受影响文件架构检查通过。 +- macOS Electron 隔离验证使用真实 MobileNav 和生产 CSS,服务与回调采用夹具。390/768/820/920/1000/1023px × 标题加载前后 × 侧栏展开/收起,24 组检查通过:按钮命中区域、与长标题分离、返回回调触发。证据:主工作区 `outputs/app-chat-back-20261031/`。 +- 当前状态为源码修复和本地自动验证完成。测试环境部署、macOS/Windows 已安装客户端真实账号的发现广场进入与返回验收待执行。 diff --git a/docs/verification/app-chat-header-20261030.md b/docs/verification/app-chat-header-20261030.md new file mode 100644 index 0000000000..0ef808b501 --- /dev/null +++ b/docs/verification/app-chat-header-20261030.md @@ -0,0 +1,37 @@ +# 应用会话顶部交互 — 2026-10-30 + +## 基线与范围 + +- 代码仓库:`dataelement/bisheng`,目标 `feat/bisheng-work-120-main@1954f60c4acba5de5cca5e9385b76fc6b21effc0`。提交前远端复核一致。 +- 工作目录:`.worktrees/bisheng-app-chat-header`;分支 `codex/app-chat-header`。 +- 对照桌面源码:`dataelement/bisheng-desktop:V1.2.0@e928c8c3bd5d2f188f17b7d93e0fbd7bc13eaf90`。已安装 BISHENG 应用内 Harness 为 `0.2.1-rc.2`。 +- 早期 DSH `v0.9.0` 远端分支查询为空;其参考集成工作树检查时干净。截图行为归属当前毕昇应用会话页面,本次修复提交到网页代码仓库。 + +## 根因与实现 + +1. `MobileAppHistoryDropdown` 原来通过 body portal 声明 `role=dialog / aria-modal=true`。桌面 `observeEnterpriseModals` 按该语义扩展原生视图到整个窗口,触发 `AppRoot` 的 1023px media query。宽屏卸载下拉组件,桌面随后恢复内容区宽度,紧凑布局再次挂载组件,循环继续。现在历史列表作为有名称的 `region` 展示,窗口级 modal 继续用于真正的对话框。 +2. 合并标题栏的菜单原来总是设置移动系统菜单状态;桌面嵌入模式和 768–1023px 浏览器布局都有自己的应用侧栏。`AppRoot` 通过 `onMenuClick` 提供桌面侧栏动作,移动端继续使用原系统菜单。历史展开期间桌面菜单保持可用。 +3. `handleCreateNewAppChat` 原来强制收起应用侧栏。现在创建会话保留侧栏状态,列表内的新建/选择操作沿用 `useAppSidebar` 的导航及来源记录。 +4. 历史列表支持 Escape 关闭与触发按钮焦点恢复;生命周期清理键盘监听。组件中的两条旧硬编码回退文字复用现有多语言键,ESLint 基线通过工具清理对应的两条历史豁免。 + +## 自动验证 + +- `client/src/routes/AppRoot.test.tsx` 联合运行真实 AppRoot、MobileNav 和 MobileAppHistoryDropdown,账号/会话数据与页面导航采用夹具;新增 12 项。覆盖侧栏初始展开/收起、菜单往返、加号及列表新建、列表选择、窗口宽度反馈循环、Escape、重渲染焦点恢复、卸载与移动菜单。 +- 基线对照:原实现 9 项失败、3 项通过;反馈循环显示 `[1200,900,1200,900,1200,900]`。修复后连续保持 900px。 +- 相关 5 个测试文件 / 26 项通过:AppRoot、appChatOrigin、desktopEmbedding、usePrefersMobileLayout、DesktopShellBridge。 +- 前端全量 ESLint 通过(Client、Platform、UI、file-viewers);类型检查通过(Client 1502 个严格文件、Platform 620 个严格文件、file-viewers tsc)。使用现有依赖树执行与工作区脚本对应的工具,依赖锁文件保持一致。 +- Client Vite 生产构建、语言资源一致性、受影响文件架构检查及 `git diff --check` 通过。 + +## 原生隔离验证与验收边界 + +- macOS Electron 使用临时 profile、本机 HTTP 夹具、真实 MobileAppHistoryDropdown、真实 media query、真实 Desktop EnterpriseChatView 及 preload。页面数据服务采用夹具,标题按钮通过 DOM 事件激活;Escape 使用 Electron 键盘输入。 +- 1100/1200/1280px 窗口各保持 280px 宿主侧栏,内容宽度为 820/920/1000px;历史展开期间每组 20 次原生边界采样稳定,Escape 后保持原边界。 +- 本地证据:主工作区 `outputs/app-chat-header-20261030/`,含原生脚本、日志、截图、采样结果与自动验证日志。 +- 交付状态为源码修复与本地验证完成。测试环境网页部署、Windows 安装版、真实账号应用会话新建/切换与侧栏持久状态复测待执行。 + +## PR #2567 目标分支同步 + +- 目标分支更新到 `05d3290219afd28f8aa242757d246da2a71935f7`,PR 远端 head 核对为 `cd47949306a2e00aa07863768f09c1fd59a0e6fc`。唯一冲突位于 `docs/STATUS.md`,现保留双方状态记录。 +- 应用会话修复代码保持一致;目标分支新增的透明弹窗展示代码完整保留。 +- 同步后组合回归 7 文件 / 38 项通过,覆盖原有 26 项及 DialogPresentation、KnowledgeSpaceDialog。Client 全量 ESLint、1504 个严格类型文件、生产构建、语言资源与差异检查通过。依赖锁文件与项目规则保持一致,沿用已安装依赖。 +- 部署、Windows 安装版及真实账号验收继续按原清单执行。 diff --git a/docs/verification/app-chat-scaled-layout-20261102.md b/docs/verification/app-chat-scaled-layout-20261102.md new file mode 100644 index 0000000000..9dc5ec4f85 --- /dev/null +++ b/docs/verification/app-chat-scaled-layout-20261102.md @@ -0,0 +1,42 @@ +# 应用会话缩放后侧栏挤占内容区修复 + +## 基线 + +- 版本:`dataelement/bisheng:feat/bisheng-work-120-main@780723543c408013d0011a2d8053fdc6bb712420`,本轮 fetch 核对。 +- 独立目录 `.worktrees/bisheng-app-chat-scale-20261102`,分支 `codex/app-chat-scaled-layout`。 +- 本轮改动位于网页 AppRoot。桌面参考沿用已核对 `V1.2.0@09c22fe34e5c89193c15387685810fbaa45e73fc`;本机 Harness 依赖 `0.2.1-rc.2`,Electron `43.0.0`,pnpm `9.15.9`。 + +## 发现和修正 + +用户反馈 Windows 当前仍出现问题,提出字体字号可能相关。系统缩放与客户端页面缩放具体值暂无现场信息。 + +当前源码在桌面嵌入模式保持桌面身份,即使内容视口很窄也会保留 240px 应用侧栏。紧凑标题栏随后仅获得剩余宽度。实际 Electron 测量:359px 的应用视口分成 240px 侧栏和 119px 对话区域;居中的标题下拉及箭头与返回按钮重叠,点击命中进入下拉元素。进一步放大页面后,返回按钮落到内容裁剪边界之外。 + +对照把标题从 16px Arial 改为 24px 等宽字体,失败组合相同;直接复现因素是可用内容宽度与固定侧栏并排。该实验支持缩放触发布局缺陷,Windows 用户原机的确切触发参数仍待复测确认。 + +AppRoot 现在按现有 767px 断点选择内容导航:窄桌面内容区复用已有历史 region,通过菜单或标题访问应用卡片与会话列表;对话获得完整宽度。放宽到并排布局时恢复原有侧栏偏好;窄布局新建对话保留该偏好。跨过 1023px 宽布局断点时关闭紧凑历史,恢复窗口后由用户重新打开。浏览器移动端继续使用系统菜单。原字体、字号、按钮视觉、来源返回解析与宿主代码沿用当前实现。 + +## 验证 + +- 新增回归在旧实现有 4 项失败;最终 AppRoot、来源解析和发现广场入口共 3 组 / 38 项通过,覆盖窄桌面菜单开关、返回、侧栏偏好、创建对话、最大化/恢复、移动系统菜单。 +- macOS Electron 隔离实验使用真实 AppRoot、MobileNav、历史列表和生产 CSS,数据服务与 SideNav 内容采用夹具。模型以 1278→1920→1278 物理像素窗口、280 DIP 宿主侧栏计算内容区;设备缩放 100/125/150/200%、页面缩放 100/125/150/200%、侧栏偏好展开/收起、标题 16/24px,共 192 组。旧版 28 组返回命中或区域重叠失败;修正版 192 组按钮命中和历史开关几何检查全部通过,控制台错误为零。 +- 另行连续改变同一窗口内容宽度 359→1200→359→900→359,确认窄视口对话 x=0、宽视口按原侧栏布局。原生鼠标事件打开历史、Esc 关闭、点击返回到 `/square?type=apps` 全部通过。 +- Client / Platform / UI / file-viewers 全量 lint 通过;类型检查 Client 1513 个、Platform 620 个 strict 文件和 file-viewers 均通过。Client 生产构建、i18n、架构守卫和差异检查通过。 +- 初始环境默认 pnpm 版本尝试自动核对依赖时因共享目录保护退出。后续使用项目版本 pnpm 9.15.9 完成构建,并通过现有 npm scripts 执行等效质量门;依赖声明和锁文件沿用当前基线。 + +设备缩放模拟、页面缩放和字体压力对照分别记录,macOS 上的字体替代与设备比例实验属于布局验证。Windows 系统 DPI、Windows 真实字体、安装包和真实账号端到端验收待执行。前轮已核对 13001 仍提供旧版返回/历史脚本,部署状态与本轮额外源码修复分别记录;用户人工复现及 TC-120-DSQ-001/012 的 Windows 失败状态继续保留。 + +## 交付 + +本轮修复及自动验证完成。PR、网页部署和 Windows 原机验收各自按后续证据更新。主工作区证据:`outputs/app-chat-font-scale-20261102/`;核心文件 `summary.json`、`cycle.json`、`regression.log`、各项质量日志及 `fixed-200-percent.png`。 + +## Code review + +按 hotfix 范围自检:导航与宽度采用同一个布局条件;菜单和标题共享已有非模态历史列表;侧栏偏好与窄布局临时开关分别保存;新建和窗口恢复有回归;真实账号与 Windows 运行结果保持单列。源码、权限及依赖范围保持应用导航边界。 + +## PR #2581 目标分支同步 + +- 在线核对 PR head `a6c3aec9d751e3d8964fa6188e46aa103d96cc11`;目标分支新增 PR #2580,当前目标为 `037d6037a1cf6a906caf51f3657b7c688ac42bd7`。 +- 冲突集中在 STATUS 的同一插入位置,现同时保留应用会话缩放和整窗弹窗蒙层记录。AppRoot 两个文件与本修复原提交逐字节一致,弹窗展示两个文件与目标分支逐字节一致。 +- 同步后的 AppRoot、来源解析、发现广场入口、DialogPresentation、KnowledgeSpaceDialog 组合回归 5 组 / 52 项通过。全量前端 lint/typecheck、Client 生产构建通过;Client 1513 / Platform 620 个严格类型文件通过。依赖和锁文件沿用原版本。 +- 网页部署及 Windows 安装版真实账号验收按原边界记录。同步验证日志位于主工作区 `outputs/app-chat-font-scale-20261102/merge-2581/`。 diff --git a/docs/verification/app-chat-share-20261102.md b/docs/verification/app-chat-share-20261102.md new file mode 100644 index 0000000000..390d7a629a --- /dev/null +++ b/docs/verification/app-chat-share-20261102.md @@ -0,0 +1,34 @@ +# 应用会话分享入口 + +## 规则与根因 + +2026-11-02 用户截图要求:会话已有用户提交内容时显示顶栏分享按钮,仅展示欢迎语的新会话保持隐藏。侧栏“分享应用”继续使用应用分享权限。 + +原实现的宽屏 `ChatView` 和紧凑布局顶栏均仅检查 `can_share`。工作流启动即可产生欢迎语、输出消息和输入表单,因此消息条数无法表达用户是否已参与会话。 + +## 实现 + +在应用会话入口按路由 `cid` 取得会话,使用应用分享权限与 `category === 'question'` 用户消息共同决定分享资格。实时文字、表单和附件提交,以及加载的历史用户消息均使用这一类别。宽屏顶栏通过属性接收结果,紧凑顶栏同步同一结果;已有只读判断继续生效。按路由取值使切换会话期间的判断属于目标会话。 + +范围为 BISHENG Client 两处逻辑文件。Desktop 原生宿主、依赖和服务端沿用当前版本。 + +## 基线与验证 + +- 基线:`upstream/feat/bisheng-work-120-main@23a4b97e5d86e38d8a2f53a5de3fe17ffb8671c4`,通过 GitHub 连接器和 Git fetch 核实。 +- 分支:`codex/app-chat-share-user-input`;工作目录:`.worktrees/bisheng-app-chat-share-20261102`。 +- 原代码对照:新增 13 项回归中 9 项失败,4 项通过。 +- 修复后:新增 13 项通过,连同流式停止、工作流状态、应用路由回归共 4 文件 / 46 项通过。 +- 测试执行真实 AppChat、ChatView、HeaderTitle 和 Recoil 状态,消息渲染、网络、输入组件与分享弹窗使用测试替身;紧凑布局验证实际共享顶栏状态。 +- 覆盖空会话、工作流欢迎语、配置开场白、待填表单、自动输出、文字/表单/附件提交、助手/工作流历史、权限关闭、只读分享、切换到新会话。 + +前端 workspace 全量 ESLint、Platform/Client 严格类型检查及 file-viewers 类型检查、Client 生产构建、i18n、变更文件架构守卫、差异检查通过。使用已安装依赖的二进制执行对应 package scripts,依赖与锁文件保持原样。生产构建含现有 pdfjs/vm-browserify 的 eval 提示。本地日志存于外层工作区 `outputs/app-chat-share-20261102/`,生成产物与依赖存放仓库快照之外。 + +## 部署后验收 + +Client 部署和真实账号验收待执行。部署后在浏览器及 macOS/Windows 安装版各验证: + +1. 打开截图中的分诊工作流,欢迎语出现时顶栏保持隐藏分享入口。 +2. 提交问题后显示分享入口,生成链接并核对只读历史内容。 +3. 新建会话及切换历史会话,确认分享入口随当前会话更新。 +4. 检查文字、附件、表单输入,分享权限关闭及只读链接。 +5. 在宽窗口、窄窗口和缩放触发紧凑顶栏时重复上述操作。 diff --git a/docs/verification/app-deleted-session-history-20261102.md b/docs/verification/app-deleted-session-history-20261102.md new file mode 100644 index 0000000000..327543d3e3 --- /dev/null +++ b/docs/verification/app-deleted-session-history-20261102.md @@ -0,0 +1,25 @@ +# 应用删除会话后旧链接读取 — 2026-11-02 + +## 根因与修复 + +客户端调用 `DELETE /api/v1/chat/{chat_id}`,服务端将会话行的 `is_delete` 设为 true。消息行保留给现有审计与数据管理流程。历史列表按删除标记过滤,而 `ChatSessionService.get_chat_history()` 只检查会话存在和应用 ID,原账号通过旧链接仍能取得原消息;同一服务的详情读取也会返回该会话。 + +历史读取现在先检查会话删除状态,已删除会话返回空历史,并在查询消息表前结束。详情读取将已删除会话按缺失会话处理:普通详情返回空值,指定 subject 的访问沿用原有资源不可用处理。应用 ID 校验、正常历史分页及有效分享读取沿用原有逻辑。 + +本次修改集中于历史与详情的读取语义,数据库继续使用现有软删除方案。 + +## 基线 + +- 当前远端 `dataelement/bisheng:feat/bisheng-work-120-main@611fb0f1ac949d3b03fa960cbbc3ff442c0bbfbd`,通过 GitHub 连接器核实。 +- 初始本地基线 `de390d4fb` 与远端 `3120e714f` 源码树一致;交付时同步刚合入的附件下载修复,远端基线源码树为 `0d67fdce2d93e3c116eb694bb6138e1d70ca83be`。 +- 受影响服务文件在远端的原始 blob 为 `ca73793d99b6ddd17f3a1e6db2606b2fd940748a`,与本地修复前一致。 +- 本次工作目录 `.worktrees/bisheng-app-deleted-session-history-20261102`,分支 `codex/app-deleted-session-history`。 + +## 验证 + +- 新增 8 项定向回归:原版 4 项失败、4 项通过;修复后 8 项通过。 +- 覆盖同账号删除前后刷新旧历史 URL、分享入口读取、已删除详情、正常详情、缺失会话、应用 ID 不匹配及正常历史分页。 +- HTTP 测试执行从源码编译的 FastAPI 路由及服务方法,DAO、存储、遥测和历史序列化使用夹具;断言已删除会话在消息查询前结束。 +- Ruff 格式与静态检查、受影响服务架构检查、差异检查通过。 +- 执行命令:`python -m pytest --noconftest -c src/backend/pyproject.toml src/backend/test/chat_session/test_deleted_session_access.py -q --disable-warnings`。 +- 当前本地 Python 环境缺少 `langchain_classic` 等完整服务依赖,既有附件测试在收集阶段受阻。定向测试使用源码方法隔离加载,并保留相同业务分支;完整服务启动、真实数据库、网页部署与真实账号旧链接复测待执行。 diff --git a/docs/verification/app-stop-terminal-20261103.md b/docs/verification/app-stop-terminal-20261103.md new file mode 100644 index 0000000000..27a6a0c34f --- /dev/null +++ b/docs/verification/app-stop-terminal-20261103.md @@ -0,0 +1,58 @@ +# 应用助手手动停止终态修复 — 2026-11-03 + +## 问题与范围 + +TC-120-APP-012 在 Windows BISHENG 1.2.0-test.7 的复测反馈:停止于第 235 行,当前页面显示 Stopped,输入持续禁用;刷新后历史显示完整 2000 行,停止标识消失。此前 PR #2577 提供前端停止展示,本次修复助手后端的任务取消、历史保存和结束确认。 + +基线:`feat/bisheng-work-120-main@d7f478b12d82a70937d259a60040ad13dba1596c`。按既有停止契约的 hotfix 流程交付。 + +用户提供的 test.7 记录作为待回归依据,原结论保留为失败。本次读取源码并做确定性本地复现;该次 Windows 会话的服务日志、数据库原始记录和证据图片尚待现场关联。源码复现说明可触发同类缺陷的机制,现场的具体执行路径仍需部署后核对。 + +## 根因与实现 + +原助手将生成任务和停止任务以 WebSocket 的同一 trace ID 提交到任务池。停止路径虽然截掉最后一个 task ID,任务池仍按 key 取消整组任务,包含停止处理本身。遇到会挂起的发送操作时,停止任务可能在发送持久化 ID、结束确认之前被取消。取消后的生成任务与停止任务还各自负责发送 close;若模型调用吞掉取消并返回结果,原正常路径继续保存完整回答。 + +当前每个助手连接持有自身的生成 Task。停止请求在接收路径等待这些任务完成,取消范围以连接拥有的 Task 为准。生成任务统一负责取消收尾: + +1. 从本轮缓冲提取正文和思考,各自保存内容及 `break_answer`。 +2. 发出已保存回答的消息 ID。 +3. 发出一次 `processing/close`,前端沿现有逻辑恢复输入。 + +模型调用返回后检查 Task 的取消请求,取消分支负责保存截断终态。正常保存和结束确认阶段继续完成,重复停止沿用同一次收尾。已记录问题但首个 token 尚未到达时保存空的停止回答;初始化阶段取消则完成收尾,历史仅包含已接受的问题。停止历史保存失败会发出既有业务错误,再完成输入状态收尾。 + +数据结构、停止请求格式和历史 API 沿用既有契约。SQL 写入继续使用现有 ChatMessageDao,数据库字段保持原定义。运行时改动集中在 `common/chat/client.py`;Ruff 按项目要求格式化该文件。工作流执行引擎保持原实现。 + +## 验证 + +- Python 3.11:后端 11 项通过。覆盖 234 条完整标记加第 235 条截断、历史重读、下一轮、思考、首 token 前停止、重复停止、模型吞取消、正常完成、结束确认发送中停止、初始化取消、存储失败、同 trace 的其他连接隔离。 +- 将同一测试用于原基线:11 项中 8 项失败(6 failure、2 error),3 项通过。 +- Client 应用对话目录:10 组 / 89 项通过。新增一项运行 Python 后端测试驱动,将生产方法产生的事件交给真实 React/Recoil/WebSocket hook,验证停止后输入可用、下一轮回答正确,以及 SQLite 重读的截断内容与 `break_answer`。 +- 后端回归以 AST 加载生产 ChatClient、原任务池和流式 callback 方法,使用真实 asyncio 和 SQLite;模型输出、外部服务及数据 schema 为夹具。前端 WebSocket 为测试替身。这些结果属于本地回归,真实网络、模型服务、MySQL/DM8 和 Windows 安装包验收仍待执行。 +- Client 全量 ESLint、1518 个严格文件类型检查通过。 +- Ruff format/check、语言资源检查、git diff --check 通过。 +- 架构守卫在原基线与修复文件均报告相同的两条 RULE-1 既有分层告警;本次减少一条任务池导入,其他导入关系沿用基线。 + +执行命令: + +```sh +python3.11 -m unittest discover -s src/backend/test/chat -p 'test_assistant_stop*.py' +# cwd: src/frontend/client +NODE_PATH="$PWD/../node_modules/.pnpm/node_modules" PYTHON=python3.11 node node_modules/jest/bin/jest.js --runInBand --watch=false --coverage=false --silent src/pages/appChat +``` + +## 代码审查 + +按仓库 code-review 技能的七个维度审查:边界、权限、并发、信息泄漏、测试、风格、文档。权限入口与租户上下文沿用现有 WebSocket 路径;`asyncio.create_task` 继承调用上下文。AST 对比确认业务变动集中在任务创建与跟踪、停止收尾、取消分支;格式化涉及的方法保持原语义。总体 PASS_WITH_WARNINGS:本地验证完成;既有分层告警与现场验收限制如上。 + +## 部署与 Windows 复测清单 + +本次包含后端修复,验收环境应更新实际提供应用对话 WebSocket 的后端服务,并记录运行提交。客户端加载包含 PR #2577 的网页代码。 + +- [ ] 在实际持续流式输出时停止,观察 Stopped 和输入恢复。 +- [ ] 保持会话等待,确认截断正文保持稳定。 +- [ ] 直接发送下一轮唯一标记,确认精确返回且前轮保留。 +- [ ] 刷新同一会话,确认截断内容、停止标识及下一轮均保留。 +- [ ] 检查正常完成、首 token 前停止、思考中停止、重复停止。 +- [ ] 将对应会话的前端事件、服务端取消/持久化记录与历史返回关联。 + +TC-120-APP-012 的完成结论以更新后环境的上述复测结果为准。 diff --git a/docs/verification/app-stream-stop-20261102.md b/docs/verification/app-stream-stop-20261102.md new file mode 100644 index 0000000000..a7b0e32b5b --- /dev/null +++ b/docs/verification/app-stream-stop-20261102.md @@ -0,0 +1,25 @@ +# 应用流式回答手动停止标识 — 2026-11-02 + +## 基线与问题 + +- 仓库 `dataelement/bisheng`,目标 `feat/bisheng-work-120-main@9a985f4214be93eb74e5d4f04c0dca681ffd16ac`。 +- 工作目录 `.worktrees/bisheng-app-stream-stop-status`,分支 `codex/app-stream-stop-status`。 +- 应用页仅用 `end` 表示消息结束,手动停止后的截断回答与正常完成共用展示。助手后端已用 `remark=break_answer` 存储中断回答,前台历史渲染尚未消费该语义。 +- 工作模式对照:本地安装的 Harness `@deepseek-ai/dsh-client-ui-chat@0.2.1-rc.2` 在 interrupted 回答下方显示“已停止”。本次修改属于 BISHENG 网页应用对话,Desktop 宿主及 Harness 依赖沿用各自基线。 + +## 修复行为 + +- 用户手动停止时,将本轮仍在输出的消息标为 stopped;已有正文、思考内容和已完成消息继续保留。进行中的工具卡片使用既有 interrupted 状态。 +- 停止后到达的内容事件由停止状态处理;结束事件可补齐对应回答的持久化消息 ID。新的用户输入开启正常消息处理。 +- 回答下方显示“已停止”,纯思考回答同样可见;中断思考使用停止状态。正常完成保留原有展示。 +- 助手历史回答通过既有 `break_answer` 恢复停止标识。工作流即时回答显示停止状态,历史数据继续由现有工作流持久化链路提供;本次交付覆盖前台状态处理及助手既有中断历史标记。 +- 工作流手动停止保留手动重跑入口,正常结束显示原有本轮完成分隔;中文、英文、日文同步。 + +## 验证 + +- 原版对照选取 7 项新增状态/渲染用例:2 组中 5 项失败、2 项通过;修复实现全部通过。 +- 应用对话目录 8 组 / 48 项通过,覆盖首个 token 到达前停止、流式停止与正常结束、延迟内容/结束事件、消息 ID、工具中断、前轮隔离、后续提问、思考、助手历史恢复、工作流离线、重跑与附件。 +- Client 全量 ESLint 通过;ESLint suppression 经工具收缩一项既有未使用变量记录。 +- Client 1511 个严格文件、Platform 620 个严格文件及 file-viewers 类型检查通过;Client、Platform、UI、file-viewers 全量 ESLint 通过;Client Vite 生产构建、语言资源、受影响文件架构检查和差异检查通过。 +- 验证使用已有依赖树;新工作目录单独连接工作区包及已安装的 react-query 4.44.0。锁文件保持一致。测试工具使用安装树的 `.pnpm/node_modules` 解析 Babel runtime。 +- 测试采用 React/Recoil/WebSocket 与 API 夹具。测试环境网页部署、真实账号端到端、Windows 安装版实机验收待执行。 diff --git a/docs/verification/builtin-skill-localization-20261030.md b/docs/verification/builtin-skill-localization-20261030.md new file mode 100644 index 0000000000..7d208a0cc8 --- /dev/null +++ b/docs/verification/builtin-skill-localization-20261030.md @@ -0,0 +1,47 @@ +# 内置 Skill 语言切换修复 — 2026-10-30 + +## 问题与根因 + +PR #2524 为工具目录复用工具词典;「工具与技能」中的技能区域实际由 `SkillCenterPane`、`useSkillCenter` 和 `/api/v1/dsh/skills` 提供数据。技能接口把 `builtin` 与其他企业技能统一归为 `platform`,前端据此展示服务端原始中文。三个随版本发布的技能为 `bisheng-docx`、`bisheng-pptx`、`bisheng-xlsx`。 + +## 实现 + +- 接口增加可选展示字段 `builtin_key`:由已授权技能记录的 `source == "builtin"` 派生,值为稳定的技能 `name`;其他来源返回 `null`。原来的 `source`、资源 ID、执行名称、内容版本、账号开关保持原合同。 +- 客户端保留 `builtin_key`,在共享 `useSkillCenter` 渲染阶段解析中、英、日名称及面向用户的功能说明。查询缓存继续保存服务端原始数据,语言切换即时刷新列表和打开的详情。 +- 技能列表和广场搜索共用解析逻辑,可匹配当前语言、原始名称/说明和稳定标识。 +- 企业自定义、个人上传及管理员编辑后转为 `manual` 的技能使用配置文案;旧接口和新版本未知技能使用原始文案。执行用的 SKILL.md、包内容和权限判定沿用原实现。 + +## 基线与交付范围 + +- 已核对远端 `dataelement/bisheng:feat/bisheng-work-120-main@b82c38b09e829cd8e740b20ba00bc6133d79c10c`。 +- 工作目录 `.worktrees/bisheng-work120-builtin-skill-localization`;分支 `codex/work120-builtin-skill-localization`。 +- 修改范围为 BiSheng 后端与 Client 网页,Desktop/Harness 源码保持现状。 +- 生效需要同时更新后端与 Client 网页资源。数据库结构沿用现状。 + +## 自动化验证 + +- 36 项前端回归通过:3 个技能 × 3 种语言、稳定执行标识、原始文案搜索、未知/旧接口兼容、个人技能、真实 API 适配与共享 Hook、列表/详情、打开详情后的语言切换和按资源 ID 切换可用状态,同时回归原工具本地化。 +- 39 项后端回归通过:16 项技能来源投影,以及 23 项既有目录、资源权限、账户开关和只读试用回归。数据库/对象存储等外部依赖使用项目测试夹具与替身。 +- 同一组 4 项页面回归在基线 API 适配器与 Hook 上全部失败;恢复修复后通过。 +- 全量前端 ESLint 通过:Platform、Client、UI、File Viewers。严格类型检查通过:Client 1,499 文件、Platform 620 文件,以及 File Viewers 类型检查。 +- Client 生产构建、三语言键一致性、共享语言资源一致性、修改文件 Ruff、架构守卫和差异检查通过。 +- 页面测试运行真实列表、详情、数据适配器和 Hook;请求、布局基础组件与存储边界使用替身。以上证据属于自动化验证。 + +## 提交前审查 + +按仓库 `code-review` 七个维度检查本次差异:边界条件、权限认证、并发状态、信息泄漏、测试覆盖、代码风格与文档同步。结论 PASS。翻译资格由服务端来源确定;展示转换保持原数据缓存,运行标识与可用状态独立于翻译;变更集中于本次缺陷。 + +## PR #2563 合并冲突处理 + +- 2026-10-30 同步目标分支 `feat/bisheng-work-120-main@78815c66e00b4628b96c80393000b25ff4aabc22`。唯一冲突为 `docs/STATUS.md` 开头的新增记录,完整保留内置 Skill、授权空态和订阅抓取三项记录。 +- 同步后技能 API、Hook、页面及后端实现保持原 PR 内容。三语言文件逐键核对,完整保留目标分支全部文案与本 PR 每种语言新增的 6 个键。 +- 本轮重新运行 API 适配、翻译解析及真实列表/详情测试,3 个文件 / 18 项通过;三语言 key 检查、文案完整性检查与差异检查通过。完整静态检查、构建及后端测试结果为初始提交时的证据。 +- 后端与 Client 网页部署、真实账号和安装客户端验收继续待执行。 + +## 发布后验收(待执行) + +- [ ] 真实账号进入「工具与技能」,选择英文,确认 Word、PowerPoint、Excel 名称与说明。 +- [ ] 搜索英文名称和原始中文名称,分别打开三个技能详情。 +- [ ] 保持详情打开切换中英文,确认列表和详情同步;刷新后复核。 +- [ ] 复核企业自定义与个人上传技能文案,以及技能开关、刷新后的账号状态。 +- [ ] macOS / Windows 安装客户端载入更新后的网页资源,执行相同验收。 diff --git a/docs/verification/chat-space-access-20261103.md b/docs/verification/chat-space-access-20261103.md new file mode 100644 index 0000000000..799c8708c9 --- /dev/null +++ b/docs/verification/chat-space-access-20261103.md @@ -0,0 +1,30 @@ +# 旧会话知识空间访问状态 — 2026-11-03 + +## 缺陷与根因 + +关联 TC-120-KSP-038,从 reczz28SlUbxQhuS 拆分。用户提供的 macOS Chrome 无痕实测:dsh-sara6 退出仅通过审批加入的 space/14 后,旧会话标签、选择器计数与权限说明继续沿用退出前状态。 + +`useConversationKnowledge` 按账号与会话恢复本地选择;输入区与发送范围直接消费该记录。知识空间选择器另行查询当前列表并保留初始草稿。两条路径缺少服务端权限核验,造成已退出空间的标签、计数和空检索回复继续呈现有效选择的语义。 + +## 实现 + +- `useKnowledgeSpaceAccess` 使用现有 `/api/v1/permissions/check` 对已选空间核验 `visible`;与知识空间问答的权限动作一致。按账号、会话和已选 ID 隔离结果,挂载、窗口恢复焦点与网络恢复时重新查询。 +- 权限返回 `allowed: false` 的空间移出有效标签和已选计数,输入区持续展示失效空间名称与重新选择引导。原选择记录保留为失效原因,刷新后仍能解释状态;用户重新选择或在选择器确认清空后更新记录。 +- 核验中、接口失败和权限失效分别呈现说明;上述状态下发送及重新生成在建立流之前停止,保留问题草稿与历史消息。权限接口失败保留原选择,并引导刷新重试。 +- 选择器独立核验草稿,确认时提交有效空间;选择上限按有效数量计算。搜索仅过滤展示列表,权限判断依据服务端结果;直接、部门、用户组等仍有效授权由服务端统一裁定。 +- 中、英、日三语言文案同步。输入区既有回归断言同步采用当前图标标签的语义文本,jsdom 服务端渲染使用 Node 入口。 + +## 基线与验证 + +- Web 仓库:`dataelement/bisheng`,目标 `feat/bisheng-work-120-main`。 +- 远端读取基线:`d4ada988a44e82016a3dd427fc6cb6e14ff5e92a`。 +- 工作树:`.worktrees/bisheng-chat-space-access-20261103`;分支 `codex/chat-space-access-20261103`。 +- 原基线替换逻辑后,3 项缺陷回归全部失败;恢复修复后,6 文件 / 76 项通过,覆盖旧会话刷新、跨标签页焦点核验、混合授权、错误恢复、账号/会话切换、有效计数、发送/重新生成、草稿与知识问答交接。 +- 全量前端 lint、Client / Platform strict typecheck、共享 file-viewers 类型检查、Client 生产构建、i18n 一致性、架构守卫与差异检查通过。 +- 自动化使用确定性权限接口替身,验证真实 React hooks、选择器和输入区组件。日志保留于外层工作区 `outputs/chat-space-access-20261103/`。 + +## 部署后验收 + +代码与自动化验证已完成。Client 部署、原 dsh-sara6 账号会话 `6af4baeba0014ba3960666fdf17835bf` 的浏览器复测,以及 Windows 客户端验收待执行。本轮保持测试账号退出状态、原会话与业务数据。 + +部署后复查:刷新旧会话显示空间失效原因,选择器显示已选 0 个;输入 D6-W 时保留草稿并引导重新选择。重新选择可访问空间或确认清空后恢复发送。另行验证仍具备直接/部门/用户组授权的空间保持可用,以及权限接口异常后的恢复。 diff --git a/docs/verification/dialog-modal-semantics-20261103.md b/docs/verification/dialog-modal-semantics-20261103.md new file mode 100644 index 0000000000..4850522494 --- /dev/null +++ b/docs/verification/dialog-modal-semantics-20261103.md @@ -0,0 +1,42 @@ +# Desktop 弹窗模态识别:2026-11-03 + +## 现场证据与根因 + +用户截图为工具与技能页面的上传技能弹窗:右侧网页存在灰色遮罩,原生左侧栏保持明亮,弹窗居中基准落在右侧。 + +本轮核对运行实例为正式 `1.2.0-test9`,页面来自已部署 `e5941f38` 的 13001。读取正式安装包内 `out/preload/enterprise-chat.cjs`,确认监听依据为弹窗 `aria-modal=true`、原生 `:modal` 或根节点 `inert` / `aria-hidden=true`。 + +实际页面 DOM 中存在两个 `aria-live=polite` 通知区域。Radix 的 `hideOthers` 保留这些区域的可访问性,因而隐藏 `#root` 内的业务内容,保持 `#root` 本身可访问。弹窗 DOM 的 `aria-modal` 同时为空,导致 Desktop 判定模态关闭,原生网页视图维持右侧边界。 + +先前原生验证页面省略了通知区域,Radix 能直接隐藏根节点,旧监听在该简化结构下通过。本轮补齐真实结构后,旧代码稳定出现 5 项组件回归失败,原生验证首项超时于整窗展开。 + +## 实现 + +- `Dialog` / `OriginalDialog` 的 Root 将实际 `modal` 配置通过各自 Context 传递到 Content,并输出对应 `aria-modal`。Portal 保留 Context,`modal=false` 输出 false。 +- `AlertDialogContent` 声明其固定的模态语义。 +- 原生验证页面加入通知区域和业务子树,断言根节点保持可访问;可通过 `DESKTOP_PRELOAD` 指定已发布包内的监听代码,并在超时时保存上下文。 + +Client 基线:`feat/bisheng-work-120-main@e5941f38e40a32590d2c7a9b618cd6f370de12e2`。 +Desktop 本轮作为验证宿主:已有 `703ad964` 源码,Harness `0.2.1-rc.2` / Electron `43.0.0`;监听代码直接提取自实际 test9 安装包。实现修改范围为 Client。 + +## 验证 + +| 检查 | 结果 | +| --- | --- | +| 旧代码 + 新组件回归 | 5 失败、9 通过,覆盖三种弹窗及两种可配置 Root | +| 修复后 DialogPresentation / KnowledgeSpaceDialog | 2 文件、20 项通过 | +| 全量前端 lint / typecheck | 通过 | +| Client 生产构建 / i18n | 通过 | +| macOS 原生矩阵 + test9 原版 preload | 36 组通过 | + +原生矩阵为 900/1084 宽度 × 0.8/1/1.25 缩放 × 亮/暗主题 × Dialog/OriginalDialog/AlertDialog。检查视图整窗展开、业务内容原区域保持、遮罩尺寸、侧栏覆盖像素 `[0,0,0,102]`、点击命中、嵌套确认及关闭恢复。焦点、键盘与 Escape 由组件测试覆盖。 + +首次修复后原生运行通过 18 组后等待超时;该次日志保留,具体等待原因尚未确认。补充阶段诊断后,完整重跑 36 组通过。最终结果以完整重跑的 JSON 为准。 + +本地证据目录为外层工作区 `outputs/window-overlay-release-audit-20261103/`,包括 baseline、组件与质量检查日志、`fixed-native-recheck/native-results.json` 及视图截图。视图截图是隔离验证服务的单层捕获,侧栏透明区域需结合像素与原生边界断言解读。 + +## 交付与验收边界 + +本地实现和上述自动化验证已完成。新修复合入、Client 发布至 13001、macOS/Windows 安装版真实账号原场景复测待执行。当前 13001 的 e5941f38 版本仍具有上述识别缺陷。 + +部署后在现有 test9 打开工具与技能 → 上传技能,验收整窗蒙层、整窗居中、侧栏交互阻断、关闭恢复、嵌套确认及不同缩放。实际安装版与账号验收单独记录。 diff --git a/docs/verification/knowledge-preview-layout-20261103.md b/docs/verification/knowledge-preview-layout-20261103.md new file mode 100644 index 0000000000..9f8a5dbe94 --- /dev/null +++ b/docs/verification/knowledge-preview-layout-20261103.md @@ -0,0 +1,53 @@ +# 知识空间预览布局修复 + +## 问题与实现 + +Windows Desktop 从发现广场打开知识空间预览,返回入口和正文左缘错位,标题前留白叠加,加入状态按钮落在统计行右侧。 + +源码中 `touch-mobile` 对应视口宽度 ≤1023px。该宽度下正文使用外层 16px 留白,返回栏额外保留 24px,Button 自带 16px 水平内边距,形成 40px 错位。Sheet 的 16px 子项间距与标题 24px 顶部内边距共同形成 40px 空隙;文件区上方同样叠加两层间距。 + +本次修复由 SheetContent 统一持有水平留白,子区域沿用共同边界。返回按钮保留共享组件尺寸,其图标与正文对齐。相邻区域间距分别由标题区和文件区的 16px 内边距负责。加入状态按钮移至标题右侧,使用共享 Button 的 medium 尺寸;标题自动换行,统计数量按可用宽度换行。浏览器手机布局为右上关闭入口保留专用顶部空间,判定与现有关闭入口共用 `usePrefersMobileLayout`。 + +## 基线与范围 + +- 目标:`dataelement/bisheng:feat/bisheng-work-120-main`,基线 `1e495e15a46bcb749a86642e292ff2c35897d847`。 +- 分支:`codex/knowledge-preview-layout-20261103`。 +- 工作区:`.worktrees/bisheng-knowledge-preview-layout-20261103`。 +- 变更范围:Client 预览布局、现有渲染验证脚本及交付记录。导航、权限与订阅业务沿用现有实现。 +- 本地渲染宿主:Desktop `011e8e2d0ad3b01941e7b2b39f3b5c4d137ee061`,目录 `.worktrees/bisheng-splash-locale-20261102`;实际 Harness `0.2.1-rc.2`、Electron `43.0.0`,macOS。该 checkout 作为验证夹具提供现有原生宿主和 preload。 +- 使用现有已安装依赖及 pnpm `9.15.9`。依赖清单和锁文件沿用发布基线。 + +## 验证 + +- 旧版对照确定性失败:返回图标 x=57、标题和文件入口 x=17,水平差值 40px;标题 y=88、返回按钮底边 y=48,间距 40px。旧版操作按钮 y=164,标题 y=88。 +- 相关组件回归 3 文件 / 15 项通过:返回、浏览器关闭、路由导航、权限撤销。 +- 前端全量 ESLint、类型检查、Client 生产构建、i18n、变更文件架构守卫与差异检查通过。 +- macOS Electron 实际宿主布局与导航 54 组通过:窗口宽度 900 / 1280 / 1600、缩放 100% / 125% / 150%、深浅主题、已加入 / 待审核长标题 / 加入三种状态。返回图标与标题的最大横向偏差为 0px。 +- 独立浏览器模式 390 / 900 / 1400 三档宽度的布局和关闭验证通过。已检查桌面浅色、长标题深色及手机宽度截图。 + +渲染脚本使用实际预览组件、共享 Sheet / Button、生产 CSS、Desktop 宿主及 preload。知识 API、权限、列表入口、插图和文件卡片为隔离夹具。断言覆盖图标/标题/文件入口左缘一致、顶部间距、操作与标题同行、长标题边界、水平溢出以及两条返回路径。浏览器分支由独立窗口直接加载页面,验证关闭操作与移动端关闭按钮占位。 + +复现命令:先构建 Client,然后执行: + +```sh +DESKTOP_SOURCE=/absolute/path/to/bisheng-desktop \ + node scripts/verify-knowledge-square-navigation.mjs /absolute/path/to/evidence +``` + +本地证据:外层工作区 `outputs/knowledge-preview-layout-20261103/`,包括构建、代码检查、组件回归、旧版对照日志,以及 `native/` 的布局测量和截图。构建保留现有字体路径、第三方 eval、PWA 图标警告;渲染日志保留现有 SheetOverlay ref 和夹具 CSP 提示。 + +## Windows 交付验收 + +### PR #2591 合并冲突处理 + +2026-11-03 同步目标分支 `e7319073fbd69ac21b5e720ca760724e8f346ef6`。冲突集中于 `docs/STATUS.md` 首部的独立新增记录,已完整合并双方内容。两份布局组件及原生验证脚本与原提交 `afe6c982ac986c86ce32f5348055cd1f41d0a803` 逐字节一致;目标分支同期的权限和导航修复一并保留。 + +同步后 3 文件 / 15 项组件回归、前端全量 ESLint、Platform 620 个及 Client 1520 个严格类型文件与 file-viewers 类型检查、Client 生产构建、i18n 和差异检查通过。沿用 pnpm 9.15.9 与现有依赖,日志位于外层工作区 `outputs/knowledge-preview-layout-20261103/pr-sync/`。原生布局证据沿用本记录前述结果,本轮完成合并组合验证。 + +状态:Client 部署、Windows 安装版和真实账号验收待执行。 + +1. 部署包含本次提交的 Client,重新打开发现广场中的知识空间详情。 +2. 在截图对应窗口尺寸确认返回图标、空间标题和文件列表左缘一致,顶部间距紧凑,加入状态位于标题右侧。 +3. 覆盖加入、待审核、已加入,以及长标题、有说明、空列表和文件列表。 +4. 覆盖窗口缩放和 Windows 系统 DPI 100% / 125% / 150%、深浅主题,确认返回、文件预览和滚动。 +5. 浏览器桌面与手机宽度验证关闭入口,核对真实账号的订阅及权限结果。 diff --git a/docs/verification/knowledge-return-tab-20261031.md b/docs/verification/knowledge-return-tab-20261031.md new file mode 100644 index 0000000000..ddd1b2e312 --- /dev/null +++ b/docs/verification/knowledge-return-tab-20261031.md @@ -0,0 +1,35 @@ +# 知识空间返回列表保留标签 — 2026-10-31 + +## 问题与根因 + +在“我加入的”或“我创建的”打开空间详情,点击顶部返回后,列表选中“部门知识空间”。列表标签原本保存在组件本地状态中,进入详情会卸载列表,再次挂载时恢复默认值。页面外层 KeepAlive 还会冻结路由上下文,离开知识模块后重新进入指定标签可能继续显示旧标签。 + +## 修复 + +- 把列表标签作为 URL 查询参数 `tab`,支持 `department`、`created`、`joined`。缺省和未知值使用部门知识空间。 +- 切换标签替换当前历史记录并保留其他查询参数;进入空间、文件夹及返回列表时携带标签。 +- 知识页面使用实时路由上下文,与现有频道页采用相同渲染方式;数据继续使用 React Query 缓存。 +- 保留原有页面组件与视觉样式。 + +## 基线与验证 + +- 仓库:`dataelement/bisheng`,目标:`feat/bisheng-work-120-main`。 +- 提交前核对远端基线:`4cc8f565fb5bc8bd918973ce74f876e6fd66af26`。 +- 实现分支:`codex/knowledge-return-tab`。 +- 新增 11 项 jsdom 集成回归使用真实 Knowledge 主页面、MainLayout/AliveScope、列表 Segmented 和详情返回按钮。接口、数据密集子组件及激活回调采用夹具。覆盖三种标签、文件夹逐级返回、浏览器前进后退、直接打开详情、默认标签、查询参数与历史替换、离开模块后重入。 +- 原版导航逻辑对照:10 项中 6 项失败;修复后 10 项通过。接入实际 MainLayout 缓存后,新增模块重入用例失败;修复实时路由后 11 项全部通过。 +- 最终相关回归 6 组 / 37 项通过,包括共享空间访问、分享导航、空间错误处理、布局导航与知识问答。 +- Client 全量 ESLint、1506 个严格类型文件及生产构建通过;Platform ESLint、620 个严格类型文件通过;UI/file-viewers ESLint、file-viewers 类型检查、语言资源检查通过。 +- 测试证据保存在本地 `outputs/knowledge-return-tab-20261031/`。 + +## 验收范围 + +本轮完成源码修复与本地自动化验证。测试环境部署、真实账号与 Windows 安装版验收待执行。部署后应分别从三个标签打开空间并返回,再验证文件夹逐级返回和浏览器前进后退。 + +## PR #2574 合并冲突处理 + +- 同步时远端目标 `feat/bisheng-work-120-main@2ebff30cbd5d6be7d100c59f6705443247cfb46d`,PR 远端头 `f3b2dae8513938d10c9c29609d0e340ff5f34b00`。 +- 冲突位于 `docs/STATUS.md` 顶部新增记录;整合两侧条目,保留目标分支全部状态记录。6 个知识空间源码与测试文件与原修复提交逐字节一致。 +- 同步后组合回归 8 组 / 59 项通过,涵盖知识空间返回标签、分享导航、访问与错误处理、布局知识问答、应用返回入口及桌面字号。 +- Client 全量 ESLint 与 1507 个严格类型文件检查、Platform ESLint 与 620 个严格类型文件检查、UI/file-viewers ESLint、file-viewers 类型检查、语言资源和差异检查通过。 +- 测试环境部署、真实账号和 Windows 安装版验收继续按上文清单执行。 diff --git a/docs/verification/knowledge-settings-type-20261031.md b/docs/verification/knowledge-settings-type-20261031.md new file mode 100644 index 0000000000..3eb4158d12 --- /dev/null +++ b/docs/verification/knowledge-settings-type-20261031.md @@ -0,0 +1,30 @@ +# 桌面知识空间设置字号 — 2026-10-31 + +## 基线与范围 + +- BISHENG 网页仓库,版本分支 `feat/bisheng-work-120-main@6d5651ad4849964cd587fee5e53ee4a8df3bc9d5`;提交前远端核对一致。 +- 工作目录 `.worktrees/bisheng-knowledge-settings-desktop-type`,分支 `codex/knowledge-settings-desktop-type`。 +- 已安装 BISHENG 包版本 `1.2.0-test.2`;同会话读取应用包元数据确认 Harness 包 `@deepseek-ai/dsh-harness-dev-context@0.2.1-rc.2`。 +- 本次变更属于网页前端。知识空间设置页面及共享控件继续使用原有文字层级、交互和业务逻辑;桌面窄内容区使用桌面字号。 + +## 根因及修正 + +- 桌面宿主侧栏占用窗口宽度后,内嵌网页的内容宽度可能落入 ≤768px 的移动断点。布局 Hook 已通过 `embed=dsh` 和会话标记识别桌面身份,CSS 仅按宽度选择移动字号。 +- 字体语义变量把正文 14/22 切为 16/24;两处移动输入控件规则还会放大输入框及文本域。截图中的局部字号差异可由这一组合稳定复现。 +- 网页启动时把既有桌面身份同步到根元素 `data-bisheng-surface`。移动字体映射、共享输入控件防缩放规则和客户端旧输入控件防缩放规则共同使用该身份,桌面内容区沿用原始桌面字阶。 +- 浏览器/H5 保留原有窄屏字阶与 16px 输入体验。桌面窗口尺寸和页面排版断点仍参与原有布局适配。 +- 此处修复作用于使用同一身份与字阶的桌面嵌入页面;字号数值与组件样式定义保持原值。 + +## 验证 + +- 7 文件、35 项相关自动回归通过,含首次桌面入口、路由重定向/重载、独立浏览器、存储不可用时的显式入口、桌面主题和布局、空间设置结构及此前应用返回按钮行为。 +- 使用真实 KnowledgeSpaceSettingsPage、Input/Textarea、权限设置控件、生产 CSS 和桌面暗色主题代码,在 macOS Electron 中执行原版/修正版对照;数据服务和授权名单使用夹具。 +- 原版/修正版 × 桌面/浏览器 × 752/768/769/920px CSS 内容宽度 × 100%/125%/150% Chromium 页面缩放,共 48 组检查通过。每组核对实际内容宽度、标题/字段标签/输入框/文本域字号与横向溢出;真实保存处理函数调用夹具保存并返回空间页。 +- 原版 752/768px 桌面:16px/24px;修正版同宽桌面:14px/22px;同宽浏览器保留 16px/24px。 +- Client/Platform/UI/file-viewers 全量 ESLint,Client 1505 个与 Platform 620 个严格类型文件,UI/file-viewers 类型检查,Client 生产构建、i18n 和差异检查通过。 +- 证据位于主工作区 `outputs/knowledge-settings-type-20261031/`,包括 `before.png`、`after.png`、`native-results.json` 和验证脚本。 + +## 验收边界 + +- 当前状态为源码修复和本地自动验证完成。上述缩放为 macOS Electron 的 Chromium 页面缩放。 +- 测试环境镜像部署、Windows 已安装客户端及其 100%/125%/150% 系统 DPI、真实账号空间设置读取和保存验收待执行。 diff --git a/docs/verification/knowledge-space-approval-reapplication-20261103.md b/docs/verification/knowledge-space-approval-reapplication-20261103.md new file mode 100644 index 0000000000..d634adddf1 --- /dev/null +++ b/docs/verification/knowledge-space-approval-reapplication-20261103.md @@ -0,0 +1,50 @@ +# 知识空间历史执行失败单误报待审修复 + +日期:2026-11-03。修复分支 `codex/knowledge-approval-failed-20261103`,初始基于发布线 `origin/feat/3.0.0@234c154b3a`。按 hotfix 流程处理既有错误状态;不改成功响应字段、知识空间状态枚举、审批路由、权限或数据库结构。提交交付按发布线优先,再定向集成到最新 Work120;实际提交以 Git 历史为准。本记录不表示已部署,未修改 109 业务数据。 + +## 原因与修复行为 + +109 DSH 的 user10/dsh-sara5 对知识空间77的旧审批实例16已被审批人通过,但业务执行失败,异常1未处理。重新申请按相同业务键命中实例16,旧逻辑把 execute_failed 映射成 pending,未创建任务,却写入 PENDING 成员行并让界面显示申请中。 + +- 失败单继续参与去重;再次申请返回 18120,明确说明旧申请已通过但执行失败,需要管理员处理或取消异常。不会创建第二个实例、任务、outbox,或重复执行旧授权。 +- 正常 pending 和 exception 的既有去重行为保留。管理员通过原异常管理入口取消旧单后,新申请可正常创建审批实例和待办。 +- 空间详情和广场只对无当前有效可见权限的 PENDING 状态补查真实审批状态;命中最新在途执行失败单时,使用既有 not_subscribed、is_pending=false,按钮不再误显示申请中。再次点击申请会显示上述具体错误,不会显示申请提交成功。 +- 当前直接或继承授权仍显示已加入;创建者不查失败申请。不修改成员表、审批历史或权限投影。 +- 查询固定申请人的验证租户、用户、场景和业务键,按100个键分批,按实例ID倒序与审批入口的去重选择保持一致;不引入逐条查询或新表。读取失败会传播,不伪装正常状态。 +- 更新了共享错误码源文案和两端生成文件(中/英/日),以及审批模块指引、错误码登记。 + +保留失败单是为了防止旧 outbox 可被管理员重试时又产生第二次授权。不能直接把 execute_failed 从全场景去重集合中删除,也不能在用户刷新详情时自动恢复已撤销的授权。若产品未来需要用户自助结束失败申请,应单独设计取消/执行竞态保护后再开放。 + +## 验证 + +使用既有 Python 3.11 独立解释器依赖,不安装或改动其他工作区依赖;测试替换外部服务或使用隔离 SQLite。没有以这些结果替代真实 MySQL/DM8 或109部署验证。 + +```text +cd src/backend +PYTHONPATH=. -m pytest \ + test/approval/test_failed_request_reapplication.py \ + test/approval/test_approval_gate.py \ + test/approval/test_approval_exception_service.py \ + test/approval/test_channel_subscription_approval_integration.py \ + test/approval/test_knowledge_space_subscription_approval_integration.py \ + test/knowledge/test_square_revoked_membership.py \ + test/knowledge/test_knowledge_space_info_optimization.py -q +``` + +结果:107 passed。包含四个审批场景的失败单重复申请、正常待审去重、真实隔离SQL的用户/租户/场景过滤、批量查询上限、新待审覆盖旧失败单、取消后新建审批任务、知识空间详情/广场一致性、失败申请不写成员/权限。 + +未修改发布线基线相同选择为88 passed/5 failed:两个审批测试缺少 outbox/action_log/审计替身,三个知识空间测试缺少创建者user_id。已补齐这些受影响路径的测试设施,并保留原断言和增加正常 outbox 调度断言,最终组合运行全部通过。租户事件测试在结束时卸载自己注册的监听器并恢复 ContextVar,避免污染其它用例。 + +- Ruff format/check:全部变更Python文件通过。 +- 架构守卫与 git diff --check:通过。 +- 共享文案生成一致性和 check-i18n:通过。 +- 完整 pnpm lint:通过。 +- 完整 pnpm typecheck:未通过,停在平台22项既有图标导出/依赖版本错误。使用同样依赖的未修改发布线基线完全相同22项,无新增类型错误;下游类型检查因串行脚本中止未执行。未改动依赖或抑制类型错误。 + +原始测试和质量日志:`/private/tmp/dsh-space77-approval-1103/`,以 `regression-verified.log`、`frontend-lint.log`、`frontend-typecheck.log` 和 `frontend-typecheck-baseline.log` 为最终证据。初次失败及其基线对照日志保留。 + +## 自审(sdd-code-review hotfix 清单) + +边界、权限/租户、并发、信息泄漏、测试、风格、文档七维度检查通过;保留前端全量类型检查基线限制。没有新增审批写入并发路径;旧单仍受同一去重保护,异常重试/取消沿用既有入口。无SQL、底层异常、原文、凭据或其他租户信息进入18120响应。 + +上线后需另行验收:普通申请人刷新77预览不再显示假申请中;点击申请收到18120;管理员处理或取消旧异常后再申请可创建新待办。当前109的实例16/异常1仍保持原状,本轮没有取消、重试或补授权限。 diff --git a/docs/verification/knowledge-square-navigation-20261102.md b/docs/verification/knowledge-square-navigation-20261102.md new file mode 100644 index 0000000000..d99ef2bbec --- /dev/null +++ b/docs/verification/knowledge-square-navigation-20261102.md @@ -0,0 +1,57 @@ +# 发现广场知识空间预览导航 + +## 问题与根因 + +2026-11-02 测试记录 `reczz28TecWh76aD` 重新打开:Windows Desktop 从发现广场进入需审核的知识空间后,页面缺少明确返回入口,重复点击发现广场仍停留在详情。 + +记录原始附件对应 `/square` 中的知识空间预览抽屉。原逻辑将抽屉状态保存在组件内,打开后 URL 仍为 `/square`;Desktop `EnterpriseChatView.navigate()` 对相同 URL 提前返回,组件因此继续展示抽屉。原抽屉的收起按钮位于左侧中部,固定 1000px 宽度还会使窄视口中的左缘超出可视区域。此前知识空间文件列表的来源 tab 修复覆盖另一条导航路径。 + +## 实现 + +- 使用 `previewSpace` 查询参数表示当前预览空间。原生发现广场入口指向列表 URL,路由变化直接关闭预览。 +- 关闭预览时清理该参数,保留分类及其他来源参数;列表继续挂载,保留本次列表搜索状态。浏览器前进、后退和直接预览链接均由同一状态来源驱动。 +- Desktop 嵌入预览顶部使用共享 Button 和已有翻译展示“返回”;浏览器保留原有关闭入口。抽屉宽度随视口收缩,上限 1000px。 +- 交付范围为 Client 网页。知识权限、成员、审批与服务端配置沿用现有实现。 + +## 基线与环境 + +- BISHENG:`upstream/feat/bisheng-work-120-main@725ee5bf38d4ff2d067763fb305817a730b238a4`,交付前再次核对远端一致。 +- 分支:`codex/knowledge-square-navigation`;工作目录:`.worktrees/bisheng-knowledge-square-navigation-20261102`。 +- 原生验证宿主:Desktop `V1.2.0` 基线上的 `011e8e2d0ad3b01941e7b2b39f3b5c4d137ee061`,工作目录 `.worktrees/bisheng-splash-locale-20261102`;Harness `0.2.1-rc.2`,Electron `43.0.0`,macOS。 +- 前端工具版本:pnpm `9.15.9`;沿用已安装依赖,依赖清单和锁文件保持一致。 + +## 自动验证 + +| 项目 | 结果 | +| --- | --- | +| 原代码对照:新增预览路由回归 | 5 项失败,确定性复现 | +| 修复后导航、撤销权限、来源 tab、嵌入识别、布局、MainLayout 回归 | 7 文件 / 38 项通过 | +| 前端 workspace ESLint | 通过 | +| 前端 workspace typecheck | 通过;Platform 620、Client 1518 个严格类型文件 | +| Client 生产构建、i18n、架构守卫、差异检查 | 通过 | +| macOS 原生 Electron 联合验证 | 12 组通过 | + +原生验证使用实际 `KnowledgeSquarePane`、预览抽屉、Sheet、共享 Button、生产 CSS、`useDesktopNavigation`、Desktop preload 和 `EnterpriseChatView`。知识数据、权限 API、列表和插图采用隔离夹具。矩阵覆盖窗口宽度 900/1280、缩放 100%/125%/150%、深浅主题。每组调用实际 `openPage('square')` 并触发顶部返回按钮,验证抽屉关闭、路由参数清理及按钮边界位于视口内。按钮触发通过 DOM;Windows 原生鼠标与系统 DPI 验收单列。 + +截图已检查窄窗口浅色和宽窗口深色视图。日志包含现有 SheetOverlay ref 和夹具 CSP 提示;生产安全策略沿用原配置。构建包含既有 PWA maskable icon 匹配提示。 + +复现入口(先构建 Client,Desktop checkout 提供实际宿主源码和 Electron/esbuild 依赖): + +```sh +DESKTOP_SOURCE=/absolute/path/to/bisheng-desktop \ + node scripts/verify-knowledge-square-navigation.mjs /absolute/path/to/evidence +``` + +本地证据存放外层工作区 `outputs/knowledge-square-navigation-20261102/`:红绿测试、lint/typecheck/build/i18n 日志,`native/electron-results.json` 和视图截图。截图、构建产物和依赖存放仓库快照之外。 + +## 部署后 Windows 验收 + +状态:Client 部署、Windows 安装版真实账号验收待执行。 + +1. 部署包含此提交的 Client,重新进入发现广场,核对网页资源版本。 +2. 使用原复现账号打开原知识空间预览,确认顶部出现“返回”。点击后应回到发现广场知识空间列表,分类及搜索上下文保留。 +3. 再次打开预览,通过左侧发现广场入口回到广场首页或对应分类。 +4. 覆盖审批前、待审核、已加入、权限撤销状态,以及窄窗口、100%/125%/150% Windows 系统缩放、深浅主题。 +5. 检查浏览器中的关闭、历史前进/后退和直接链接,核对 Desktop 窗口级模态关闭后侧栏恢复交互。 + +上述自动化证明路由根因修复及 macOS 原生宿主集成;Windows 安装包、真实服务与账号的最终结果由此清单补齐。 diff --git a/docs/verification/knowledge-square-revoked-access-20261102.md b/docs/verification/knowledge-square-revoked-access-20261102.md new file mode 100644 index 0000000000..546c4039ac --- /dev/null +++ b/docs/verification/knowledge-square-revoked-access-20261102.md @@ -0,0 +1,40 @@ +# 知识广场撤销查看授权后的加入状态 — 2026-11-02 + +## 问题与边界 + +关联 TC-120-DSQ-014。用户报告在 Windows BISHENG 1.2.0-test6 / Harness 0.2.1-rc.2,合成空间 KS1030T46(74)撤销 dsh-sara5 的直接查看者后,广场、抽屉显示已加入,正文按当前权限拒绝读取。用户已将空间配置恢复,文件 KSP_VIEW_1031R.txt 保留。 + +本次仅修改源码并使用本地夹具验证。用户记录证明实际读取被拦截,问题按状态一致性与重新申请入口处理。服务器、真实账号、空间配置和数据本轮保持用户恢复后的状态。 + +## 根因 + +- 成员表的 ACTIVE 记录与当前有效权限可以在撤权后分离。`_resolve_effective_subscription_status` 原来只会把具备权限的未加入状态提升为已加入,ACTIVE 记录始终保留已加入。 +- 详情接口还会使用 ACTIVE 成员的旧角色;重新申请接口对 ACTIVE / 管理角色记录直接返回 subscribed,申请被旧记录短路。 +- 抽屉已有独立 visible 检查,它拒绝内容后只更新正文可读标记,卡片和按钮仍使用原加入状态。 + +## 修复 + +- 广场与详情对 subscribed / not_subscribed 使用当前有效 visible 判定;pending / rejected 沿用已有申请状态。撤权后的成员旧角色参与详情展示前先满足内容权限。 +- 重新加入仅在当前有效权限成立时沿用已加入。权限已撤销时重新进入既有审核门禁,按结果持久化 pending 或 subscribed。申请存储使用 MEMBER 角色,已撤销的旧管理角色保持撤销;创建者沿用所有权。 +- 抽屉收到明确 allowed=false 时同步清除已加入信号,并通知广场显示可加入;权限服务异常保持原成员状态。提取原有状态、按钮与文件过滤规则以遵循文件行数约束,保留原有视觉。 +- “用户数”继续按现有 ACTIVE 成员记录统计。本次修复加入状态及申请流程;撤权历史记录的全量清理、把成员人数改为有效访问人数属于独立统计变更。重新申请进入 pending 后,当前用户按原规则退出 ACTIVE 统计。 + +## 验证 + +- 基线:`dataelement/bisheng:feat/bisheng-work-120-main@780723543c408013d0011a2d8053fdc6bb712420`。 +- 提交前同步目标 `037d6037a`,保留新合入的桌面弹窗展示与验证记录;知识空间修复保持本次范围;组合回归 9 组 / 52 项通过。 +- 原版对照:后端新增用例 7 项失败、11 项通过;抽屉新增用例 1 项失败、5 项通过。 +- 修复后:后端 18 项通过,覆盖广场/详情一致、有效直接或继承授权、重新申请、审核门禁返回 pending / pass、创建者与普通成员角色存储。服务方法从源码隔离加载,审批/权限端口使用夹具;存储分支执行实际 SQLModel / SQLite UPDATE 和 INSERT。 +- 前端相关 8 组 / 43 项通过,覆盖明确拒绝、旧卡片/新详情、重新申请、有效权限、pending/rejected、权限服务异常,以及知识返回标签、分享与设置回归。 +- Client ESLint、1515 个严格文件、生产构建、语言资源和差异检查通过;服务与新增测试 Ruff 通过。公共成员模型原有 5 项 Ruff 发现与基线相同(4 项旧枚举、1 项旧 Optional),新增发现 0。 +- 架构守卫在公共成员模型报告原有 `common → user.domain` 引用,基线与修复版本一致;本次新增反向依赖 0。其余受影响业务/前端文件的架构检查通过。 +- 完整后端服务依赖在当前本地环境仍不完整;定向用例与完整服务启动、真实 OpenFGA/审批/数据库是独立证据状态。 +- 部署、Windows/macOS 安装版和真实账号 TC-120-DSQ-014 回归待执行。用户报告未重跑的审批同意/拒绝/免审核实机矩阵继续待验收。 + +## PR #2582 冲突同步 + +- PR 原 head `e3a1b3fd47cbc6d14c3476131712766e474be9d5`,目标分支更新为 `26eb25bcc898f43e2e7d961620d5cbb3e1f1c309`。 +- 唯一冲突为 `docs/STATUS.md` 顶部两段新增记录,合并保留知识广场与应用会话缩放两项交付记录。 +- 原 PR 的 7 个 src 路径逐文件核对一致,目标 AppRoot 实现和测试逐文件核对一致。 +- 组合回归 10 组 / 75 项通过,覆盖知识广场、权限拒绝/申请、知识返回标签、分享与设置、弹窗和应用缩放。Client ESLint、1515 个严格文件、语言资源和差异检查通过。 +- 部署、真实权限/审批及 Windows 实机验收继续按原记录执行。 diff --git a/docs/verification/shared-chat-empty-20261102.md b/docs/verification/shared-chat-empty-20261102.md new file mode 100644 index 0000000000..eef5dde744 --- /dev/null +++ b/docs/verification/shared-chat-empty-20261102.md @@ -0,0 +1,32 @@ +# 只读分享页空状态 + +## 问题与根因 + +用户在 macOS Chrome、BS 1.2.0 测试环境打开 `/workspace/share/` 的空会话分享,点击“开始一个新的对话吧”后仍停留在原页。 + +应用会话的空状态向所有页面提供新建回调,分享页也拿到了这个入口。该回调从普通应用路由读取 `fid` 和 `type`,分享路由仅包含 `token`,因此点击后提前返回。分享页的产品边界是只读历史;复用可交互空状态遗漏了这一边界。同时原空状态条件依赖个人会话列表及应用开场内容,造成同一份空分享在不同上下文下展示不同。 + +## 修复 + +- 只读分享根据共享消息数量显示空状态,文案为“该分享暂无对话记录”,复用现有 StateView 视觉样式。 +- 空状态按当前页面是否提供新建能力显示操作。只读分享传入静态展示;普通应用会话、应用入口和独立应用页继续传入新建回调。 +- 共享历史已有消息时继续按只读会话展示。中英日翻译同步。 + +## 基线与验证 + +- 基线:`upstream/feat/bisheng-work-120-main@ac625c821396dcc628a8c71845e7a808f7b95b9b`,包含已合并的发送端分享入口修复 #2586。 +- 分支:`codex/shared-chat-empty-state`;工作区:`.worktrees/bisheng-shared-chat-empty-20261102`。 +- 原代码对照:新增场景中 8 项失败;修复后分享、工作流状态、路由及应用入口共 5 文件 / 61 项回归通过。 +- 新增覆盖:助手/工作流 × 有/无个人历史 × 有/无开场白共 8 种空分享;普通应用新建跳转;已有分享历史只读展示。 +- 路由回归使用真实 Share、AppChat、ChatView、ChatEmptyState、StateView 与 Recoil 状态;分享 API、消息渲染、网络及插画使用夹具。断言空分享文案、操作入口、输入框与路由,以及普通页面新建后的目标 URL。 + +质量检查结果见 `docs/STATUS.md`。本地日志存放于外层工作区 `outputs/shared-chat-empty-20261102/`。 + +## 部署后验收 + +Client 部署与测试环境 macOS Chrome 真实账号验收待执行: + +1. 打开原空分享链接,确认显示“该分享暂无对话记录”。 +2. 分别在已有个人会话和全新浏览器上下文访问,核对相同的只读空状态。 +3. 打开普通应用空会话,点击新建入口,确认进入新的会话地址。 +4. 打开有历史消息的分享,核对消息与只读行为;覆盖助手、工作流和窄屏。 diff --git a/docs/verification/window-dialog-overlay-20261102.md b/docs/verification/window-dialog-overlay-20261102.md new file mode 100644 index 0000000000..66c3c989d1 --- /dev/null +++ b/docs/verification/window-dialog-overlay-20261102.md @@ -0,0 +1,51 @@ +# 桌面弹窗整窗蒙层 — 2026-11-02 + +## 展示规则与实现 + +桌面内打开技能上传、预览或二次确认时,弹窗使用黑色 40% 蒙层。左侧会话栏及右侧内容统一变暗,弹窗本身保持清晰。取值沿用 `src/frontend/packages/ui/docs/组件-Modal弹窗.md` §5 的设计规范。 + +`dialogOverlayStyle` 为三类 Client 弹窗统一生成桌面蒙层样式,并保留调用方传入的层级等布局属性。浏览器入口继续使用组件及调用方自身的展示规则。 + +Desktop 的既有窗口级模态协议负责将 body portal 展示到整个原生窗口,页面根节点保持原内容区坐标。Client 负责蒙层颜色,最后一层弹窗关闭后由 Desktop 恢复内容区。知识空间使用的 `@bisheng/ui` Modal 已采用相同的 40% 蒙层。 + +## 基线 + +- Client:`dataelement/bisheng:feat/bisheng-work-120-main@3120e714fa4ed492884cade11a128341d03eb105`。 +- 提交 PR 前同步目标 `780723543c408013d0011a2d8053fdc6bb712420`,保留同期应用附件下载和已删除会话读取修复及双方状态记录。 +- 原生组合验证:`dataelement/bisheng-desktop:V1.2.0@09c22fe34e5c89193c15387685810fbaa45e73fc`,Harness `0.2.1-rc.2`,Electron `43.0.0`,macOS arm64。 +- Frontend 依赖使用相同锁文件及包清单的独立副本;命令使用仓库指定的 pnpm `9.15.9`。 + +## 验证结果 + +- `DialogPresentation.test.tsx` 更新展示契约,并覆盖布局样式保留、输入对象稳定、浏览器展示、主题、路由切换、焦点约束及 Esc。新契约在原逻辑上出现 4 项失败;修复后与 `KnowledgeSpaceDialog.test.tsx` 合计 14 项通过。 +- `Skills/__tests__/skillPreview.test.ts` 使用其 Node 测试配置执行,17 项通过,覆盖技能文件解析和个人技能持久化。 +- 同步目标后,将弹窗、知识空间及历史附件下载组合复测,3 组 / 31 项通过;连同技能文件的 Node 检查,本轮相关回归共 48 项通过。 +- Workspace 全量 ESLint、Platform 的 620 个严格类型文件、Client 的 1,511 个严格类型文件、file-viewers 类型检查通过;Client 生产构建、`check-i18n` 和 `git diff --check` 通过。 +- 原生联合验证 36 组通过:Dialog / OriginalDialog / AlertDialog × 900 / 1084 窗口宽度 × 0.8 / 1 / 1.25 缩放 × 深 / 浅主题。执行实际 Client 组件、当前构建的生产 CSS、Desktop 原生视图及 production preload/IPC,企业账号服务采用隔离夹具。 +- 每组验证原生视图和蒙层覆盖整个窗口、页面根节点坐标保持、蒙层计算值为 `rgba(0, 0, 0, 0.4)`、弹窗不透明。子视图截图在侧栏坐标的像素为黑色且 alpha=102/255,证明该区域携带相同蒙层。截图记录的是原生子视图表面;侧栏内容由下层宿主 renderer 绘制。 +- 原生检查验证嵌套确认期间的窗口覆盖、侧栏坐标命中蒙层、取消及关闭后的内容区恢复;通过渲染器中的实际取消按钮触发 React/Radix 关闭处理。Tab、Esc、焦点约束及焦点返回由组件用例覆盖,系统级键鼠交互列入安装版验收。 + +## 复现命令 + +在 `src/frontend/` 运行 `pnpm lint`、`pnpm typecheck`、`pnpm build:client` 和 `pnpm check-i18n`。 + +在 `src/frontend/client/` 运行: + +```sh +NODE_PATH=../node_modules/.pnpm/node_modules node node_modules/jest/bin/jest.js src/components/ui/DialogPresentation.test.tsx src/components/Chat/Input/KnowledgeSpaceDialog.test.tsx --runInBand --coverage=false +NODE_PATH=../node_modules/.pnpm/node_modules node node_modules/jest/bin/jest.js --config jest.skill-center.config.mjs src/components/Skills/__tests__/skillPreview.test.ts --runInBand +``` + +`NODE_PATH` 仅供该测试命令解析 Babel runtime;应用运行方式沿用原有契约。技能文件测试使用仓库提供的 Node 配置。 + +原生组合验证需准备已安装依赖的 Desktop V1.2.0 目录,并先构建 Client。在仓库根目录运行: + +```sh +DESKTOP_SOURCE=/path/to/bisheng-desktop node scripts/verify-desktop-dialog-overlay.mjs /path/to/evidence +``` + +脚本使用临时 Profile,输出原生检查 JSON 与深浅主题子视图截图。 + +## 验收范围 + +本轮交付包括代码、自动化验证和 macOS 原生组合验证。Client 网页发布、真实账号技能上传、Windows 安装包及系统 DPI 交互验收待执行。安装包需包含既有 Desktop 窗口级模态协议,网页需包含本次 Client 展示修复。 diff --git a/docs/verification/work120-join-approval-20261103.md b/docs/verification/work120-join-approval-20261103.md new file mode 100644 index 0000000000..c4a8d13fbf --- /dev/null +++ b/docs/verification/work120-join-approval-20261103.md @@ -0,0 +1,30 @@ +# Work120 申请加入审批假待审修复 + +2026-11-03;直接基于 `origin/feat/bisheng-work-120-main@2169eeb538cb7c5f97a4d055713c241d42ade9fb` 开发,分支 `codex/work120-join-approval-20261103`。未移植此前发布线修复提交。没有数据库迁移、部署或109业务数据操作。 + +## 问题与修复 + +历史审批已通过但授权执行失败时,同一申请人重新申请命中旧 execute_failed 实例;原代码返回 pending,但没有新审批任务,并把成员写为 PENDING,导致界面一直显示申请中。 + +- execute_failed 继续参与去重,再申请明确返回18120:请管理员处理或取消旧异常后再申请。不新建第二个实例、任务或outbox,也不重试旧授权。 +- 详情和广场仅对无当前可见权限的PENDING成员核对最新在途审批,执行失败时使用既有not_subscribed/is_pending=false。读取不写成员、权限或审批状态。 +- 正常pending/exception、现有权限判断、分页和Work120已有状态优先级保持不变。取消旧单后不再命中在途去重,用户可以走正常新申请流程。 +- 新增审批域内只读状态仓储与应用端口,不改变公共租户模块。端口按申请人验证租户绑定已有ContextVar,退出及异常均恢复;每100个业务键一次查询,只读取业务键和状态,不读取审批快照正文。 +- 错误18120补齐共享中/英/日源文案及两端生成文件。没有引入该分支原本不存在的其它错误码或功能。 + +## 验证与自审 + +```text +PYTHONPATH=. -m pytest \ + test/approval/test_failed_join_requests.py \ + test/knowledge/test_failed_join_display.py \ + test/knowledge/test_square_revoked_membership.py \ + test/approval/test_approval_exception_service.py \ + test/approval/test_channel_subscription_approval_integration.py -q +``` + +43 passed。覆盖四场景执行失败重复申请、正常状态、最新在途选择、真实隔离SQL的租户/用户/场景过滤、取消后不再去重、100键分批及上下文异常恢复、详情/广场一致性、可见空间原状态保留、失败申请不写成员或授权。SQLite用例仅创建自身表;MySQL/DM8实测及109部署验收未执行。 + +Ruff、架构守卫、git diff --check、文案生成一致性及check-i18n通过。按hotfix自审清单检查边界、租户、并发、信息泄漏、测试和文档:无新增授权路径或写入竞态;响应不暴露SQL、内部异常或审批快照。原始日志保存在 `/private/tmp/work120-join-approval-20261103/`。 + +本次前端仅修改文案JSON,无TS/UI逻辑改动;未重新安装依赖或运行全仓前端类型检查。上线后仍需普通用户实测18120显示,并由管理员通过既有异常入口处理历史单;本轮未自动取消或恢复109的实例16/异常1。 diff --git a/examples/apps/form-survey/README.md b/examples/apps/form-survey/README.md new file mode 100644 index 0000000000..eef897abe6 --- /dev/null +++ b/examples/apps/form-survey/README.md @@ -0,0 +1,48 @@ +# 表单问卷 —— 托管应用示例 + +一个零第三方依赖的问卷应用:收集回答、落 SQLite、给出统计。它用来验证 BiSheng 应用工场的最小闭环 —— **本地写的应用 → `bisheng deploy` → 平台托管上线 → 所有人在应用广场里访问它**。 + +## 本地跑 + +```bash +python main.py # 然后开 http://127.0.0.1:8080 +``` + +没有依赖要装,没有 Dockerfile 要写。想验证平台上的路径行为,加一个环境变量即可: + +```bash +BISHENG_APP_BASE_PATH=/apps/form-survey python main.py +# 页面里所有链接与 form action 都会带上前缀,跟平台上一致 +``` + +## 部署到平台 + +```bash +bisheng login <平台地址> --api-key bs-sak-... +bisheng deploy . # 读同目录的 bisheng-app.yaml +``` + +`deploy` 会依次做:密钥扫描 → 托管预检(构建 + 启动探活)→ 生成发布审批单。审批通过后应用上线,入口是 `<平台地址>/apps/form-survey`。 + +## 这个示例在演示什么 + +它同时是**托管运行契约**(PRD-1 DEV-04)的一份可执行说明。四条约定,缺一条都会在平台上出问题,而每一条在本地直接 `python main.py` 时都恰好是无害的默认值 —— 这是契约设计的目标:**同一份代码同一条路径,本地与平台的差别只在环境变量取值**。 + +| 约定 | 违反的后果 | +|---|---| +| 监听 `PORT` / `BISHENG_APP_PORT` | 硬编码端口 → 探活必失败 | +| 绑 `0.0.0.0` | 绑 `127.0.0.1` 在容器里只有进程自己连得上,健康探针一直失败**而日志干干净净** —— 最难查的一种「起来了但不健康」 | +| 外链带 `BISHENG_APP_BASE_PATH` | app-proxy 会剥掉前缀再转发,所以应用**收到**的是根路径;但应用**发出**的链接必须自己带前缀,漏一处那一处就跳到平台根路径去了 | +| 只往 `/data` 写 | 容器根文件系统只读,往别处写会在**运行期**才炸,而不是构建期 | + +平台注入的相关变量:`PORT` · `BISHENG_APP_PORT` · `BISHENG_APP_BASE_PATH` · `BISHENG_APP_DB_PATH` · `BISHENG_APP_DB_URL` · `BISHENG_APP_HEALTH_PATH`。 + +## 目录 + +``` +bisheng-app.yaml 应用清单(deploy 读的唯一配置文件) +main.py 应用本体,只用标准库 +requirements.txt 故意留空 +``` + +`requirements.txt` 空着是有意的:演示要验的是部署链路本身,不是依赖解析。空依赖意味着构建不需要能访问 PyPI —— 在 114 和信创基线上,「构建卡在 pip 拉不到包」是最常见、也最容易被误判成平台故障的失败。 diff --git a/examples/apps/form-survey/bisheng-app.yaml b/examples/apps/form-survey/bisheng-app.yaml new file mode 100644 index 0000000000..ec2f11d6ad --- /dev/null +++ b/examples/apps/form-survey/bisheng-app.yaml @@ -0,0 +1,15 @@ +# 表单问卷 —— BiSheng 托管应用示例 +# +# 这份 manifest 是 `bisheng deploy` 读的唯一配置文件。字段定义见 +# src/backend/bisheng/app_publish/domain/schemas/app_manifest.py(未知字段会被 +# 拒绝,不是忽略——写错的键名会当场告诉你,而不是静默不生效)。 +manifest_version: 1 + +name: 表单问卷 +description: 一个零依赖的问卷示例:收集回答、落 SQLite、给出统计。用来验证「本地开发的应用能部署到 BiSheng 平台」这条最小闭环。 +slug: form-survey + +runtime: python3.11 +port: 8080 + +# 不写 tier 即「轻量」档。示例应用没有任何理由占更多资源。 diff --git a/examples/apps/form-survey/main.py b/examples/apps/form-survey/main.py new file mode 100644 index 0000000000..b669dff873 --- /dev/null +++ b/examples/apps/form-survey/main.py @@ -0,0 +1,246 @@ +"""表单问卷 —— BiSheng 托管应用示例(零第三方依赖)。 + +这个示例存在的理由是验证一条最小闭环:本地写的应用 → `bisheng deploy` → +平台托管上线 → 所有人在应用广场里访问它。它刻意只用标准库,因为演示要验的是 +部署链路本身,不是依赖解析——「构建卡在 pip 拉不到包」在 114 和信创基线上是 +最常见、也最容易被误判成平台故障的失败。 + +它同时是托管运行契约(PRD-1 DEV-04)的一份可执行说明。四条约定,缺一条就会在 +平台上出问题,而每一条在本地直接 `python main.py` 时都恰好是无害的默认值: + +* **监听 PORT**。平台按 manifest 的 `port` 注入 `PORT` 与 `BISHENG_APP_PORT`。 + 硬编码端口的应用探活必失败。 +* **绑 0.0.0.0**。绑 127.0.0.1 的进程在容器里只有自己能连上,健康探针会一直 + 失败,而日志里什么错都没有——这是最难查的一种"应用起来了但不健康"。 +* **外链带 BISHENG_APP_BASE_PATH**。平台把应用挂在 `/apps/{slug}` 下, + app-proxy 会把前缀剥掉再转发,所以应用**收到的**是根路径;但应用**发出的** + 链接(form action、跳转、静态资源)必须自己带上前缀,否则点一下就跳到平台 + 根路径上去了。本地跑时这个变量为空,拼出来就是原来的根路径。 +* **只往 /data 写**。容器根文件系统是只读的(AC-17),/data 是唯一的可写卷, + 平台通过 BISHENG_APP_DB_URL / BISHENG_APP_DB_PATH 告诉应用它在哪。往别处写 + 会在运行期才炸,而不是在构建期。 + +本地跑:``python main.py`` 然后开 http://127.0.0.1:8080 +""" + +from __future__ import annotations + +import html +import json +import os +import sqlite3 +import urllib.parse +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +# 平台注入的四个变量,每个都给了本地可用的默认值。这不是"兜底"——本地与平台 +# 跑的是同一份代码同一条路径,差别只在变量取值,所以本地验过的行为在平台上成立。 +PORT = int(os.environ.get("PORT") or os.environ.get("BISHENG_APP_PORT") or 8080) +BASE_PATH = (os.environ.get("BISHENG_APP_BASE_PATH") or "").rstrip("/") +DB_PATH = os.environ.get("BISHENG_APP_DB_PATH") or os.path.join(os.path.dirname(os.path.abspath(__file__)), "survey.db") + +QUESTIONS: tuple[tuple[str, str, tuple[str, ...]], ...] = ( + ("role", "你的角色是?", ("业务人员", "开发者", "管理员")), + ("channel", "你从哪里知道这个平台?", ("同事推荐", "搜索", "会议或文章")), + ("useful", "这次部署体验如何?", ("顺利", "还行", "遇到了问题")), +) + + +def url(path: str) -> str: + """把应用内部路径拼成对外可点的地址。 + + 平台上 BASE_PATH 是 ``/apps/form-survey``,本地是空串。所有 form action 与 + 跳转都必须过这里——漏一处,那一处在平台上就会跳到平台根路径。 + """ + return f"{BASE_PATH}{path}" + + +def connect() -> sqlite3.Connection: + conn = sqlite3.connect(DB_PATH) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS response ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + answers TEXT NOT NULL, + comment TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """ + ) + conn.commit() + return conn + + +PAGE = """ + + +{title} +{body} +""" + + +def render(title: str, body: str) -> bytes: + return PAGE.format(title=html.escape(title), body=body).encode("utf-8") + + +def form_page() -> bytes: + blocks = [] + for key, label, options in QUESTIONS: + choices = "".join( + f'' + for opt in options + ) + blocks.append(f"
{html.escape(label)}{choices}
") + return render( + "表单问卷", + f""" +

表单问卷

+

一个跑在 BiSheng 托管运行时上的示例应用。

+
+ {"".join(blocks)} +
还想说点什么?(选填) + +
+ +
+

看看大家怎么答的 →

+ """, + ) + + +def thanks_page() -> bytes: + return render( + "已收到", + f""" +

已收到,谢谢

+

你的回答已经存进这个应用自己的数据卷里。

+

查看统计 · 再答一次

+ """, + ) + + +def results_page() -> bytes: + with connect() as conn: + rows = conn.execute("SELECT answers FROM response").fetchall() + total = len(rows) + tally: dict[str, dict[str, int]] = {key: dict.fromkeys(opts, 0) for key, _, opts in QUESTIONS} + for (payload,) in rows: + try: + answers = json.loads(payload) + except json.JSONDecodeError: + continue + for key, value in answers.items(): + if key in tally and value in tally[key]: + tally[key][value] += 1 + + sections = [] + for key, label, options in QUESTIONS: + rows_html = "" + for opt in options: + count = tally[key][opt] + width = 0 if total == 0 else round(count * 100 / total) + rows_html += ( + f"{html.escape(opt)}" + f' {count}' + ) + sections.append(f"

{html.escape(label)}

{rows_html}
") + + return render( + "问卷统计", + f""" +

问卷统计

+

共 {total} 份回答。

+ {"".join(sections)} +

← 回到问卷

+ """, + ) + + +class Handler(BaseHTTPRequestHandler): + server_version = "bisheng-form-survey/1.0" + + def _send(self, status: int, body: bytes, content_type: str = "text/html; charset=utf-8") -> None: + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self) -> None: + path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/" + if path == "/": + self._send(200, form_page()) + elif path == "/thanks": + self._send(200, thanks_page()) + elif path == "/results": + self._send(200, results_page()) + elif path == "/healthz": + # 平台默认探 `/`,这条只是给运维一个不渲染页面的探针。 + self._send(200, b'{"status":"ok"}', "application/json") + else: + self._send(404, render("找不到页面", f'

404

回到问卷

')) + + def do_POST(self) -> None: + if urllib.parse.urlparse(self.path).path.rstrip("/") != "/submit": + self._send(404, render("找不到页面", "

404

")) + return + + length = int(self.headers.get("Content-Length") or 0) + form = urllib.parse.parse_qs(self.rfile.read(length).decode("utf-8")) + answers = {key: form.get(key, [""])[0] for key, _, _ in QUESTIONS} + comment = form.get("comment", [""])[0][:500] + + with connect() as conn: + conn.execute( + "INSERT INTO response (answers, comment) VALUES (?, ?)", + (json.dumps(answers, ensure_ascii=False), comment), + ) + conn.commit() + + # 303 而不是 302:提交后刷新不该重复写一条回答。 + self.send_response(303) + self.send_header("Location", url("/thanks")) + self.send_header("Content-Length", "0") + self.end_headers() + + def log_message(self, fmt: str, *args) -> None: + # 平台按容器 stdout 收日志,`bisheng logs` 读的就是这里。 + print(f"{self.address_string()} {fmt % args}", flush=True) + + +def main() -> None: + connect().close() + # 必须是 0.0.0.0:绑 127.0.0.1 在容器里只有进程自己连得上,健康探针会一直 + # 失败而日志干干净净——这是最难查的一种"起来了但不健康"。 + server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler) + print(f"form-survey listening on 0.0.0.0:{PORT} base_path={BASE_PATH!r} db={DB_PATH}", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/examples/apps/form-survey/requirements.txt b/examples/apps/form-survey/requirements.txt new file mode 100644 index 0000000000..15b501a14e --- /dev/null +++ b/examples/apps/form-survey/requirements.txt @@ -0,0 +1,5 @@ +# 故意留空:这个示例只用 Python 标准库。 +# +# 演示的目的是验证部署闭环本身,而不是验证依赖解析。空依赖意味着构建不需要能 +# 访问 PyPI —— 在 114 和信创基线上,「构建卡在 pip 拉不到包」是最常见、也最容易 +# 被误判成平台故障的失败。 diff --git a/features/README.md b/features/README.md index d5dba792b2..35d1470b7e 100644 --- a/features/README.md +++ b/features/README.md @@ -27,7 +27,7 @@ ↓ 7.5. /e2e-test E2E 测试(强制) ↓ -8. /code-review --base 2.5.0-PM 多维度代码审查(自动) +8. /sdd-code-review --base <目标分支> 多维度代码审查(自动) ↓ 9. 合并回 2.5.0-PM ``` @@ -90,7 +90,7 @@ feat/v2.5.0/{NNN}-{short-name} | `/sdd-review spec` | spec.md 编写后 | 11 项需求+架构检查 | | `/sdd-review tasks` | tasks.md 编写后 | 17 项拆解质量检查(自动) | | `/task-review ` | 每个任务完成后 | L1 约定合规(6 项) | -| `/code-review --base 2.5.0-PM` | Feature 全部完成后 | L2 多维度深度审查 | +| `/sdd-code-review --base <目标分支>` | Feature 全部完成后 | L2 多维度深度审查 | | `/e2e-test ` | 全部任务完成后 | 生成并运行 E2E 测试 | --- diff --git a/features/local/capability-center/demo_service.py b/features/local/capability-center/demo_service.py new file mode 100644 index 0000000000..869caed3ba --- /dev/null +++ b/features/local/capability-center/demo_service.py @@ -0,0 +1,57 @@ +"""Read-only synthetic business data, exposed through real HTTP and MCP protocols.""" +from contextlib import asynccontextmanager +from uuid import uuid4 + +from fastapi import FastAPI, HTTPException, Path +from mcp.server.fastmcp import FastMCP +from mcp.server.transport_security import TransportSecuritySettings + +mcp = FastMCP("Bisheng inventory demonstration", stateless_http=True, json_response=True, + streamable_http_path="/mcp", host="0.0.0.0", + transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=True, + allowed_hosts=["capability-demo:8790", "capability-demo"], allowed_origins=[])) + +ORDERS = { + "DEMO-20261022-001": {"order_id": "DEMO-20261022-001", "customer": "示例客户·华东制造", "product_code": "MAT-001", + "product_name": "工业传感器", "quantity": 120, "unit_price": 280, "total": 33600, + "currency": "CNY", "delivery_date": "2026-10-28", "status": "待交付"}, + "DEMO-20261022-002": {"order_id": "DEMO-20261022-002", "customer": "示例客户·华南设备", "product_code": "MAT-002", + "product_name": "控制模块", "quantity": 40, "unit_price": 650, "total": 26000, + "currency": "CNY", "delivery_date": "2026-10-30", "status": "待交付"}, +} +INVENTORY = { + "MAT-001": {"product_code": "MAT-001", "product_name": "工业传感器", "available": 85, + "incoming": 60, "incoming_date": "2026-10-26", "warehouse": "示例·上海仓"}, + "MAT-002": {"product_code": "MAT-002", "product_name": "控制模块", "available": 120, + "incoming": 0, "incoming_date": None, "warehouse": "示例·苏州仓"}, +} + +@mcp.tool() +def demo_check_inventory(product_code: str) -> dict: + """查询演示库存,返回可用数量、在途数量和到货日期。数据仅用于演示。""" + record = INVENTORY.get(product_code.upper()) + if record is None: + raise ValueError("Use demonstration product MAT-001 or MAT-002") + return {**record, "demo_data": True, "request_id": str(uuid4())} + +@asynccontextmanager +async def lifespan(app): + async with mcp.session_manager.run(): + yield + +app = FastAPI(title="订单查询 · 免配置演示", description="只读合成订单,数据仅用于演示。", + servers=[{"url": "http://capability-demo:8790"}], lifespan=lifespan) + +@app.get("/health", include_in_schema=False) +def health(): + return {"status": "ready"} + +@app.get("/orders/{order_id}", operation_id="demo_get_order", description="查询演示订单的客户、物料、数量和交付日期。") +def order(order_id: str = Path(description="演示订单编号,如 DEMO-20261022-001")): + record = ORDERS.get(order_id.upper()) + if record is None: + raise HTTPException(404, "Use demonstration order DEMO-20261022-001 or DEMO-20261022-002") + return {**record, "demo_data": True, "request_id": str(uuid4())} + +# Keep protocol handling separate while sharing one internal-only demo process. +app.mount("/", mcp.streamable_http_app()) diff --git a/features/local/capability-center/design.md b/features/local/capability-center/design.md new file mode 100644 index 0000000000..ec3f1bd125 --- /dev/null +++ b/features/local/capability-center/design.md @@ -0,0 +1,17 @@ +# 交互与实现 + +网页工作区以“工具与技能”聚合能力发现、详情、试用和 Work 指令。后台“构建 → 工具”增加技能分类,复用原导入、新建、编辑、删除、单项启停及权限接口。工作台设置去掉工具展示名单和技能管理区域。连接器保持服务账号授权概念。 + +用户在输入框按任务选择能力:工具默认空集,技能由任务模式显式多选;用户可以关闭全部工具并单独选择知识空间。保留用户亲自操作过的工具偏好,管理员旧默认值退出执行路径。原配置字段保持历史兼容,读取端直接使用权限目录。 + +CapabilityCatalogService 通过 ToolServices(action=use) 投影安全字段;picker_tools 返回全部授权工具的分组及空默认选择。WorkCapabilitiesService 读取相同权限资源与已启用租户技能;历史 publication 表保留审计兼容,发布接口返回 410。执行仍验证权限、参数、工具依赖、内容哈希并记录审计。凭据留在宿主侧执行器。技能中途停用使下载作废。 + +网页前台基于 fa7fcf01a → 9db82c31a;管理端以实际 3007 平台构建 c14a4f0c4 建立独立分支,保留积分和模型管理增量。后台与实际 enterprise-work-v120 源码逐文件匹配后更新。演示服务为既有内网只读服务。 + +## 输入菜单层级 + +加号菜单一级按任务模式、技能、工具、文件、知识空间排序。技能与工具使用同一个 ComposerSubmenu 机制,各自在弹层内打开二级内容;具体工具及联网搜索统一在工具二级页。返回恢复一级,关闭后重置导航。任务与资源选中状态独立于菜单导航。 + +## 工具正文引用 + +技能、知识空间与工具使用同一个 ComposerEditor 文档。工具引用以 tool: 映射原完整元数据,正文使用 /工具名称,引用文字与提示词分开序列化。删除、菜单取消选择和原生撤销同步工具状态。工具列表以名称、说明和选中标记构成,底部工具栏仅保留任务模式等独立控件。 diff --git a/features/local/capability-center/spec.md b/features/local/capability-center/spec.md new file mode 100644 index 0000000000..8d8bf7475b --- /dev/null +++ b/features/local/capability-center/spec.md @@ -0,0 +1,11 @@ +# 企业工具与技能中心 + +用户授权先在 3007 网页实现统一目录与管理;桌面复用同一企业 MCP。 + +- AC-01:前台“工具与技能”提供全部、技能、内置工具、API、MCP 分类与搜索。 +- AC-02:后台“构建 → 工具”提供内置工具、API、MCP、技能管理;工作台设置专注模型、提示词、知识库与外观。 +- AC-03:目录按资源 use 权限和租户隔离计算。技能遵循单项启用状态,取消工作台展示名单、总入口开关、管理员默认勾选与单独 Work 发布。 +- AC-04:用户首次工具、技能选择为空,按任务选择并可全部关闭;保留用户自行选择的工具偏好。任务模式支持多选技能和行内引用。 +- AC-05:时间、订单 API、库存 MCP 可试用,复用既有权限、参数校验、执行器和审计。订单与库存为明确标识的合成数据,通过真实 HTTP/MCP 演示且无需外部 API Key。 +- AC-06:Work 读取同一资源与单项状态;缺少连接配置的工具初始化失败时,其他工具保持可发现。调用与技能下载重新验证当前权限、启用状态、内容版本及工具依赖。 +- AC-07:保留 3007 现有用户数据、积分与模型配置;3006 保持。新增 Skill 的模型驱动报告生成作为独立桌面验收项。 diff --git a/features/local/capability-center/tasks.md b/features/local/capability-center/tasks.md new file mode 100644 index 0000000000..50e4875052 --- /dev/null +++ b/features/local/capability-center/tasks.md @@ -0,0 +1,12 @@ +# 实施与验证 + +- [x] 核对网页、平台与运行后台基线。 +- [x] 统一权限目录与按任务选择规则。 +- [x] 技能管理移入构建工具,移除工作台重复展示配置。 +- [x] 网页工具和技能首次默认空集,单独 Work 发布退出执行路径。 +- [x] 保留单项技能停用,验证网页与 Work 同步撤销。 +- [x] 后端 11 项、前端选择与目录 9 项、工具权限 1 项通过;两端严格类型、lint、生产构建通过。 +- [x] 3007 实际接口与浏览器验证完成。 +- [x] 更新状态与交付证据。 + +原 AiChatInput 的旧测试在未修改基线上也有 4 项失败,断言仍基于旧附件栏和 textarea;当前编辑器已采用行内引用和 contenteditable。此次变更的选择逻辑与权限验证通过,完整旧编辑器测试保持存量边界。订单 Skill 模型编排及报告生成保留独立桌面实测。 diff --git a/features/local/monthly-credit-input/spec.md b/features/local/monthly-credit-input/spec.md new file mode 100644 index 0000000000..85f6053c79 --- /dev/null +++ b/features/local/monthly-credit-input/spec.md @@ -0,0 +1,19 @@ +# Monthly credit input limits + +Confirmed on 2026-10-29: administrators configure 0–1,000,000 credits per person per month, using integers or one decimal place. The default remains 500. + +## Acceptance criteria + +- Frontend validation and the credit-rule write API accept 0, 0.1, 999999.9, 1000000, and 1000000.0. +- Both boundaries reject values above 1000000 and values with more than one decimal place, including trailing zero digits. +- The Chinese, English, and Japanese hints describe the same range and precision; saving an invalid draft stays disabled. +- Stored historical allowances and legacy migration retain their exact values. Editing an allowance uses the current input rules; cancellation retains the saved value. +- Consumption, balance calculation, model multipliers, and the accounting storage range retain their existing precision. + +## Design decision + +The former form limit came from the signed 64-bit accounting maximum divided by 1,000,000. The administrator input contract now has a business limit. `CreditRuleInput` validates that contract, while `CreditRuleValue` carries exact historical amounts through migration and persistence. This separation preserves migration fidelity when the input precision changes. + +## Verification + +See `docs/STATUS.md` for the automated evidence and deployment acceptance status. diff --git a/features/local/tools-skills-authorization/design.md b/features/local/tools-skills-authorization/design.md new file mode 100644 index 0000000000..5792dafe5b --- /dev/null +++ b/features/local/tools-skills-authorization/design.md @@ -0,0 +1,62 @@ +# 接入方案与当前证据 + +状态:采用所属租户成员的可撤销 Grant,沿用 F048 持久投影。 + +## 已确认的根因 + +1. 平台 HeaderMenu 使用 build.tools,三个语言包仍显示工具。 +2. BuildPage/tools 主动排除内置工具的 manage_permission 查询与按钮。 +3. F048ToolPermissionAdapter 将内置工具操作限制为 visible/use;管理页 visible 列表直接包含内置工具。 +4. OwnerService 的系统资源创建直接写入 user:* 的 visible/system_use_marker。添加普通用户 Grant 后,撤销该 Grant 仍保留系统全员权限。 +5. 技能目前仅接入所有者权限投影。资源注册表、Catalog 操作范围缺少完整 linsight_skill 资源接入;UserSkillsService.records 仅检查启用状态、个人归属与个人开关。 +6. 技能 runtime catalog、bundle、云任务 provisioning 和 availability middleware 共享 UserSkillsService,适合作为统一使用授权入口;开放 API 的 check_skills 另外检查租户内启用列表,需要同步核对执行身份。 + +## 实施方向 + +- 沿用 PermissionDialog 与资源授权接口,补齐内置工具的授权管理能力和技能资源适配器。 +- 将技能纳入统一 Catalog 和授权模型发布契约,覆盖 API 与 worker 的资源注册。 +- 对已有系统全员权限进行有持久操作记录的迁移;迁移保留原有全员可用范围。 +- 全员可用由 tenant:#member 普通 Grant 表示,限定 use/download 模型。每次进程启动保留管理员已修改的授权状态。 +- 列表与执行通过同一个业务权限入口检查 use;管理操作单独检查 manage_permission。 +- 数据迁移作为显式运维步骤执行,记录模型/Catalog 发布、旧元组处理、资源版本和幂等恢复结果。 + +## 验证计划 + +- 普通用户授权前、授权后、撤销后分别验证列表、选择、直接请求和执行。 +- 验证部门/用户组授权、跨租户拒绝、个人技能归属、全局启用开关和个人开关。 +- 验证后台直接调用授权接口的权限校验、投影失败恢复和重复迁移。 +- 前端验证授权弹窗、按钮决策和授权保存后的重新加载。 +- 运行相关后端测试、前端测试、类型与构建检查,并记录基线差异。 +- 真实账号与部署环境验收独立记录。 + + +## 合并注意事项(2026-10-30) + +工具与技能以租户成员普通 Grant 表示默认全员授权。系统资源首次创建时设置默认值; +已有 CURRENT 投影保持管理员当前设置,包括撤销全员授权后的空授权状态。 +不能在初始化中检查或自动补齐旧 `user:* visible/system_use_marker`,也不能将缺少 +租户全员 Grant 直接认定为损坏,否则会误报初始化失败或恢复管理员已经撤销的权限。 +非 CURRENT 投影继续阻塞,交由已有持久操作恢复流程处理。历史公共权限转换使用 +显式迁移脚本,不由启动流程补回。 + +发布线的 `tenant_execution_scope` 与 OpenFGA 启动并发锁继续保留。内置技能本地化 +合并到发布线新的 `/api/v1/linsight/skill/center` 数据链路,服务端提供 `builtin_key`, +前端只对企业内置技能做显示翻译,保持资源 ID、原始名称和授权状态不变。 + + +## 本次修复审查与验证(2026-10-30) + +审查范围:120 合并冲突处理与系统资源初始化修复。按 sdd-code-review 的边界、权限、 +并发、信息泄漏、测试、风格、文档七项检查,未发现本次新增阻塞;完整环境验证仍待执行。 + +- 后端定向回归:223 passed,5 skipped;另外两个既有失败在合并前 a13758ef3 同样复现 + (匿名 API 测试缺少数据库配置、Skill Catalog 动作数量断言为 3 而实际为 4)。 +- 客户端合并相关回归:75 passed;管理端:163 passed,另有三个既有的 Tabs role 断言失败, + 在相同依赖下的 a13758ef3 基线也全部复现。 +- 前端 pnpm lint 与 check-i18n 通过;Ruff 与架构守卫通过。 +- 类型检查使用本机已安装依赖:platform 21 个、client 23 个错误,规范化路径后错误集合 + 与合并前完全一致,涉及旧图标包和缺失依赖。未通过添加抑制或修改业务代码掩盖这些错误。 +- 本机 MCP SDK 不支持现有代码的 max_request_body_size 参数,两个涉及完整 main 导入的 + 测试文件无法收集;本次未修改对应 SDK 契约。 +- 测试使用本地 SQLite 和假的 OpenFGA transport,真实 runtime、owner service 与持久投影 + 代码参与执行。未在 166 重新部署,未执行生产迁移;测试结果不替代完整部署验收。 diff --git a/features/local/tools-skills-authorization/spec.md b/features/local/tools-skills-authorization/spec.md new file mode 100644 index 0000000000..e5bc1401ba --- /dev/null +++ b/features/local/tools-skills-authorization/spec.md @@ -0,0 +1,22 @@ +# 工具与技能授权 + +状态:范围与默认授权规则已确认,实现与自动验证完成后提交 PR。 + +目标分支:dataelement/bisheng 的 feat/bisheng-work-120-main。 +核验基线:d4d60f77e41d58753a76969c6f40465a36054fa6(2026-10-26)。 + +## 验收条件 + +- AC-01:构建区顶部入口显示“工具与技能”,英文和日文同步。 +- AC-02:内置工具和企业技能提供与 API/MCP 一致的授权入口,使用现有人员、部门、用户组选择和权限管理交互。 +- AC-03:授权管理按钮依据后端 manage_permission 决策展示,保存和撤销经过统一权限服务及持久投影流程。 +- AC-04:用户列表、选择器、技能包下载、云任务装载和执行阶段按照当前授权过滤;个人开关在企业授权范围内生效。 +- AC-05:技能所属租户和个人技能归属约束持续生效。权限系统故障时返回明确错误。 +- AC-06:已有资源的使用范围按确认后的升级规则迁移,权限目录、模型和运行时版本保持一致。 +- AC-07:内置工具配置管理继续遵守现有系统管理员规则。 + +## 已确认的业务决策 + +已有内置工具和企业技能保留全员可用,管理员可切换为指定人员。全员授权在弹窗中直接展示并支持撤销。新增企业资源沿用这一默认值,个人技能保持所属账户范围。 + +当前用户授权覆盖实现、自动验证与提交 PR。真实账号验收、部署和合并分别记录。 diff --git a/features/local/tools-skills-authorization/tasks.md b/features/local/tools-skills-authorization/tasks.md new file mode 100644 index 0000000000..1a240a1d0d --- /dev/null +++ b/features/local/tools-skills-authorization/tasks.md @@ -0,0 +1,11 @@ +# 实施进度 + +- [x] 核验目标分支与远端提交,创建独立工作目录和功能分支。 +- [x] 确认已有资源保留全员可用,管理员可改为指定人员。 +- [x] 顶部入口中英日改名。 +- [x] 内置工具与技能接入现有授权弹窗、权限目录及持久投影。 +- [x] 完成使用链路授权校验、全员授权撤销和显式升级脚本。 +- [x] 自动回归、类型、构建、lint、i18n 与架构检查;对照原始分支记录扩大回归差异。 +- [x] 完成自查与升级说明。 +- [x] 向 feat/bisheng-work-120-main 提交 [PR #2518](https://github.com/dataelement/bisheng/pull/2518)。 +- [ ] 上线环境执行迁移与真实账号验收(独立交付环节)。 diff --git a/features/local/tools-skills-authorization/upgrade.md b/features/local/tools-skills-authorization/upgrade.md new file mode 100644 index 0000000000..b699a0e41e --- /dev/null +++ b/features/local/tools-skills-authorization/upgrade.md @@ -0,0 +1,19 @@ +# 上线与回退 + +此变更包含 OpenFGA 模型 f048-v6、权限目录发布和已有资源授权迁移。运行前备份 SQL、OpenFGA Store 和配置,记录原 CURRENT Catalog 与模型 ID。运行命令位于 src/backend,使用目标环境的 config 与依赖环境。 + +1. 停止 API、worker 与任务进程,等待 F048 runtime heartbeat 清空,完成正在进行的权限投影恢复。 +2. 使用新版本执行 `PYTHONPATH=. python scripts/publish_authorization_model_change.py` 查看 dry-run。核对 Store、target checksum 和受影响目录。 +3. 执行同一脚本,加上 `--apply --confirm-store-id --confirm-target-model-checksum --operator-id `。该步骤发布 v6 并在已有 tool 操作范围上加入技能作用域,保留自定义模型选择与等级策略。 +4. 执行 `PYTHONPATH=. python scripts/migrate_capability_authorization.py --operator-id ` 查看资源清单。Catalog 中需有启用、具备 use 且操作集合限于 use/download 的模型;缺失时先经目录发布流程配置。 +5. 执行同一迁移命令,加上 `--apply --confirm-store-id `。内置工具旧 user:* visible/system_use_marker 与所属租户成员 Grant 在同一持久投影内转换。已有企业技能补齐默认 Grant;个人技能沿用所属账户授权。 +6. 再运行 dry-run,全部资源应显示 preserved。管理员后来撤销全员授权后,重跑迁移仍保留撤销结果。发生未完成投影时先恢复该操作,再重跑;脚本会停止并给出原因。 +7. 启动全部新版进程,核对健康和权限版本。使用管理员与普通成员验证:全员可用 → 关闭全员并指定人员 → 撤销指定授权;核对工具菜单/调用、技能目录/直接下载/任务装载,以及跨租户与个人技能边界。 + +仅部署前端按钮无法完成此变更。模型、目录、数据迁移与进程版本须在同一维护窗口完成。 + +回退采用维护窗口下恢复匹配的 SQL/OpenFGA 快照、配置和旧进程版本。单独回滚代码会令模型校验失败。上线后已有业务数据时,先制定数据保留方案,再执行恢复。 + +本次 PR 提供脚本和单元/接口回归;生产迁移及真实账号验收由上线环境执行并留存报告。 + +根租户成员由当前 active UserTenant 快照生成请求级授权事实,与部门上下文一起传入 OpenFGA。上线核验应包含根租户普通成员的真实 use 检查;管理员操作入口单独验收。 diff --git a/features/v2.5.1/019-admin-tenant-scope/spec.md b/features/v2.5.1/019-admin-tenant-scope/spec.md index 85be39eb73..3809da1bea 100644 --- a/features/v2.5.1/019-admin-tenant-scope/spec.md +++ b/features/v2.5.1/019-admin-tenant-scope/spec.md @@ -328,3 +328,13 @@ multi_tenant: - 技术方案 §11.7 关键修改文件清单(4 行新增) - 技术方案 §11.8 新增测试用例 - release-contract INV-T14 / 模块编码 197 + +## 2026-10-29 用户确认的管理视角修正 + +本会话用户要求将管理视角从局部页面统一至平台右上角:超管可选整个平台或一个明确租户;子租户管理员默认仅自己的租户。管理列表采用该视角,SQL SELECT 和 UNION 子查询同范围;非超管不得读取超管 Redis scope。全局查询标记只在验证过的浏览器管理请求设置,后台执行作用域清除此标记;不改 JWT 的所属租户,不影响 SDK/RPC/个人审批。 + +审批管理显式使用选中租户;全平台列表可枚举各租户,创建/更新仍需具体租户。异常进度提供独立管理只读入口,允许同租户管理员或对应视角内超管读取,不授予任务处理权。服务账号管理要求具体租户的预期范围确认继续保留。存量资源不迁移。实现与本次验证边界见 docs/reviews/vibe-116-management-fixes-2026-10-29.md。 + +共享角色目录例外:角色列表的显式 tenant 参数同步管理视角;全局预置角色仍属于可读公共目录,不能因为收紧租户过滤而让合法角色详情返回 403。RoleDao 的管理读取将公共 global 类型与当前视角内自定义角色并集,不放开其他租户的 custom role。定向 SQLite 查询覆盖指定租户与全平台两种情况。 + +后续用户确认的范围修正:管理视图已移至平台全局入口,应用、知识空间及工作台列表统一随所选租户展示,也允许影响全局平台管理员的前台工作台视图。仅全局平台管理员读取并使用 Redis 管理视图;普通用户及子租户管理员仍受自身租户与权限约束,不跟随其他管理员的选择。 diff --git a/features/v3.0.0-beta1/048-rebac-permission-model-grants/design.md b/features/v3.0.0-beta1/048-rebac-permission-model-grants/design.md index df81af6297..648eb3bb88 100644 --- a/features/v3.0.0-beta1/048-rebac-permission-model-grants/design.md +++ b/features/v3.0.0-beta1/048-rebac-permission-model-grants/design.md @@ -10,7 +10,7 @@ **关联**:[spec.md](./spec.md) · [产品说明](./product-authorization-model-and-migration-guide.md) · [release contract](../release-contract.md) **版本**:v3.0.0-beta1 **功能分支**:`feat/v3.0.0-beta1/048-rebac-permission-model-grants` -**最后更新**:2026-08-13 +**最后更新**:2026-11-02 --- @@ -1135,7 +1135,7 @@ scope/version/operation 不匹配继续转人工分析。重复传入 `FINALIZED | `permission/domain/services/catalog_service.py` | draft、影响分析、标准模型生成、Catalog publish 编排 | 不直接 ORM/FGA HTTP | | `.../projection_service.py` | operation、tuple phase、幂等提交、reconcile、recent marker | 不决定谁可授权 | | `.../visibility_projection_service.py` | 从 canonical Grant assignee 编译单槽浅层 visible contribution、跨来源引用计数、checksum/reconcile;迁移器与运行时复用同一纯编译器 | 不展开部门/用户组成员;system/public/shared 仍由各 Owner projection 处理;不接受业务编辑;不参与 Check/List fallback | -| `.../grant_service.py` | 来源、模型、版本、protected 和同级规则编排 | 不读取 Config | +| `.../grant_service.py` | 来源、模型、版本、protected 和同级规则编排;新增目标要求 active,既有来源管理复用相同等级规则但不要求模型 active | 不读取 Config | | `.../mode_service.py` | 已验证 parent/mode context 的 impact/snapshot/projection | 不查询或移动业务资源,不维护第二 parent | | `.../permission_service.py` | 唯一权限 facade:具体 action check/batch-check、visible check/batch-check/完整流式枚举、Grant 与 projection | 不查询业务资源,不判断状态/父级,不 fallback ALLOW;个人 visible 不做管理员扩权短路 | | `.../permission_explain_service.py` | 已经 FGA 判定后的 opaque subject/source/model ID、scope 与 protected 说明 | 不产生 ALLOW,不查询用户/部门/用户组/资源名称 | @@ -1167,6 +1167,9 @@ domain 对业务 model/repository 的 import 必须由架构测试禁止。正 ## 5. 已知坑 / 反直觉事实 +- 构建页 `managed=true` 的租户管理员快捷查询须在 `strict_tenant_filter()` 中执行完整游标扫描;默认可读范围含根租户,直接跳过逐资源校验会把根租户应用当成本租户可管理应用返回,后续 `my-permissions` 再拒绝。使用既有过滤上下文可在 SQL 分页前收敛三类应用,结束或异常时恢复读取范围;普通用户、超管和非管理读取保留原路径。存量资源不迁移。 +- 权限旧错误码 `19001–19007` 与信息源冲突,现迁至 `25020–25026`(按原顺序一一映射),`19000` 不变。错误类名及 `PermissionFGAUnavailableError` 别名不变;三语文案从 locales 单一源生成。旧号只保留信息源含义,不能通过改 `19003` 文案兼容权限,否则信息源解析失败会显示成权限错误。 + - 不同资源表的数字 ID 可以相同;只比较 parent_id/resource_id 会把合法根目录上传误判为自关联。 - 上传失败项可能没有权限镜像。整批先加载镜像再删除会令一个缺失项阻断所有已创建权限的清理; 上传补偿须逐文件基于创建 ledger 撤销,且不能在权限撤销失败的 finally 中硬删业务记录。 @@ -1206,6 +1209,9 @@ domain 对业务 model/repository 的 import 必须由架构测试禁止。正 | 31 | 列表路径成本取决于候选规模、可见率、业务过滤和继承比例 | 把 joined 的 ID-first 或文件的 candidate-first 机械推广到所有列表 | §3 决策14入口登记 + BENCH-01 业务链路阶段 | | 32 | 文件夹本级授权不意味着整个知识空间可见;读取指定文件夹时只要求该容器可见,子项仍逐项执行包含继承的最终判断 | 先校验空间 visible 会使只有某层文件夹权限的账号无法列出或搜索其下级;直接放行全部子项又会越过 CUSTOM 边界 | `KnowledgeSpaceService._require_container_read_permission`,由普通列表与搜索共用;根目录仍校验空间 visible | | 33 | 服务账号继承需要每层资源的 `service_account:*` mode/permission_enabled 技术标记;这些标记不授予任何账号资源权限 | 旧数据只有 `user:*` 时会出现空间 visible 成功而子项不可见、上传失败;新增 Grant 不会补齐存量后代的技术标记 | 创建与迁移编译器写入两类主体标记;模型发布脚本 `publish_authorization_model_change.py` 和完整对账脚本 `reconcile_f048_visible_projection.py` 在维护窗口按 CURRENT 模式补齐,保留 CUSTOM 边界;模型已一致也必须补齐并校验后才返回成功 | +| 34 | 预置工具的历史 `user_id` 可以非空;预置身份只由 `GptsToolsType.is_preset=1` 决定;权限 mirror 为 `CURRENT` 也可能缺失系统 tuples | 把创建者为空作为额外条件会从工作台剔除预置工具,并错误地按 USER 迁移;只修分类或只检查 mirror 不能补齐存量系统授权 | 运行时、迁移、清单与租户复制统一判定;显式系统资源审计读取真实 wildcard `visible/system_use_marker`,仅为有效预置工具经版本围栏和 durable ledger 补缺,保留既有 Grant,拒绝 non-CURRENT;预置工具仍只支持 `visible/use` 并经实际动作鉴权 | +| 35 | 成员退出使历史 assignee 版本递增;重新加入会复用稳定来源 ID,不能把来源版本重新设为 1 | SQL assignee 与 visible contribution 版本不一致,投影收尾把实际已写入的可见关系误标为 RETIRED;后续退出漏编译 visible 删除 | `sync_business_source_model` 在编译计划前读取同资源历史来源,按历史版本加 1;`_upsert_assignee` 在资源版本围栏内复核状态与版本,持久化同一版本。空间、频道连续加入/退出、角色移动后重新加入及重复操作由 `test_f048_membership_reactivation.py` 验证;存量孤儿关系经既有维护工具单独对账,不在普通请求中自动清理 | +| 36 | 模型 inactive 只禁止新分配,既有 ACTIVE Grant/assignee 仍可提供 visible、具体动作和管理能力 | 名单、我的权限或审批当前授权摘要按 model.active 过滤,会出现“实际可访问但授权页无来源”;复用可分配模型列表管理旧绑定又会无法撤销 | `load_source_page`、`PermissionExplainService` 和 `read_effective_action_sources` 保留既有来源;名单返回真实 model.active;服务端按相同等级策略计算 editable,两套前端区分旧来源管理与新目标选择 | --- @@ -1239,6 +1245,13 @@ Grant 列表使用 `PageInfiniteCursorData`,不返回需要全量权限扫描 请求参数为 `cursor`(opaque、可空)与 `page_size`(默认 50、最大 100);cursor 绑定 tenant/resource/current Catalog/resource version,任一变化返回 25002,不能跨资源复用。 +名单与搜索保留已停用模型的既有有效来源,`model.active` 返回 Catalog 中真实的可分配状态。 +`editable` 同时满足本级 CUSTOM、非 protected 和服务端 manage_permission/等级/同级策略; +停用模型不会因此成为新目标。`grants:mutate` 响应同样保留未撤销的停用模型来源,不能在 +一次无关操作后让它从表单消失。两套 SPA 显示停用提示;Client 知识空间及频道设置表单 +保留 `modelActive` 元数据,允许依法精确撤销或移动到可分配模型。新增、MOVE 目标和复制 +仍沿用 active 校验,原版本围栏、租户边界、继承和受保护来源规则保持生效。 + #### 具体 action check ```json @@ -1262,7 +1275,7 @@ tenant/resource/current Catalog/resource version,任一变化返回 25002, 正常 DENY 同样是 200 且 `data.allowed=false`;业务操作端点再统一翻译为 19000。 tenant mismatch 与“未共享的跨 tenant ID”不能通过不同错误文案泄露资源存在性。 -OpenFGA 不可用返回 `19002` 业务错误,不伪装为 `{"allowed": false}`; +OpenFGA 不可用返回 `25021` 业务错误,不伪装为 `{"allowed": false}`; 未知/不适用 action 返回 `25001`。 #### Grant mutation @@ -1386,6 +1399,13 @@ Grant/assignee 引用均为零,并要求 source projection/live tuple 残留 ### 6.2 内部 Python API +`F048PermissionRuntime.manageable_model_keys(actor, target)` 供权限 API 序列化既有来源的 +管理资格使用:先执行 manage_permission 与 CURRENT target 校验,再对有界 Catalog 模型 +应用 `GrantService.manageable_models_for_capabilities`。它与新分配的等级/同级策略共用判断, +只取消来源模型的 active 要求;不加载完整名单、不代替写入时的最终校验。分页读取复用 +同一策略细化每条来源的 editable;审批的内部 action 来源摘要仍只在原阅读门禁后调用, +不得把 SQL 来源说明当作 OpenFGA 授权结果。 + | 契约 | 调用方 | 说明 | |---|---|---| | `PermissionService.check_action(target, action, actor)` | 已加载资源的业务 Service | 唯一具体动作入口;target 必须是服务端 verified DTO | @@ -1449,7 +1469,7 @@ release contract 分配模块 `250`: | 25013 | PermissionMutationTooLarge | tuple 原子上限无法满足 | | 25014 | PermissionEnumerationIncomplete | StreamedListObjects 未正常结束、结果超过入口容量或无法证明集合完整 | -通用 deny 继续用 19000,OpenFGA unavailable 用 19002,非法 resource type/id 用 19003; +通用 deny 继续用 19000,OpenFGA unavailable 用 25021,非法 resource type/id 用 25022; 不得创建相同含义的新码。 ### 6.4 Incoming 依赖 @@ -1699,6 +1719,7 @@ direct member、department member 和无权限用户;文档/日志不保存固 | 旧事实 | 新事实 | 规则 | |---|---|---| | 直接 owner/manager/editor/viewer tuple,无 binding | standard model Grant assignee | 只迁直接 tuple,不迁 computed/parent 蕴含 | +| `user_group:{id}#admin` 的旧 `manager/can_manage` | `LEGACY_USER_GROUP_ADMIN_MANAGER_RETIRED` INFO 审计项 | 新用户组不再按管理员身份单独授予资源管理权限;无论是否匹配 binding 均不创建 Grant/visible contribution,不改授 `#member`,旧 tuple 仍进入核对后的退役计划;成员授权和创建者 owner 保留 | | tuple 唯一匹配 binding | binding 映射后的 model Grant assignee | 不额外创建 standard Grant | | 编辑过的旧 system model | `legacy-system--` historical custom | 不污染新标准模型 | | Config custom model | custom model snapshot | 仅含 `view_*` 时保留 active visibility-only 模型,action 为空但 Grant 仍可见;不得映射到带 level-1 动作的 Viewer 标准模型 | @@ -1743,7 +1764,7 @@ migration coordinator 只接收规范化事实,不 import 这些业务 ORM/Rep | knowledge_library | `Knowledge.user_id/type` | 当前 `user_id`→protected;其他 direct owner→ordinary | | folder / knowledge_file | `KnowledgeFile.user_id/file_type/path` + 所属 `Knowledge.tenant_id` | 当前 `user_id`→protected;其他 direct owner→ordinary;tenant 以根知识库/空间为 canonical,修正历史 child tenant 漂移;父目录已删除的 FAILED 文件由 knowledge adapter 标为 `STALE_FAILED_RESOURCE`,只审计并退休遗留 tuple,不创建资源权限图 | | workflow / assistant | `Flow.user_id` / `Assistant.user_id` | 当前 `user_id`→protected;其他 direct owner→ordinary;builtin 走 system allowlist | -| tool | `GptsTools.user_id/is_preset` | 普通工具当前 `user_id`→protected;其他 owner→ordinary;preset 可为 system-owned | +| tool | `GptsToolsType.user_id/is_preset` | 仅 `is_preset=1` 判定预置工具并作为 system-owned,历史 `user_id` 非空不影响该判定;普通 API/MCP 工具当前 `user_id`→protected,其他 owner→ordinary | | channel | `Channel.user_id` + active channel CREATOR membership | 唯一 CREATOR→protected;若 `user_id` 不同则作为 ordinary owner 保留;缺 CREATOR 可按有效 `user_id` 修复,多个 CREATOR 阻断 | | dashboard | `Dashboard.user_id/dashboard_type` | CUSTOM;包括 preset 示例在内,当前 `user_id`→protected,其他 owner→ordinary,不使用 system predicate | @@ -1820,6 +1841,12 @@ migration run。使用 `scripts/reconcile_f048_visible_projection.py` 执行独 ### 8.3 正式迁移命令与启服门禁 +仍使用旧 RBAC、尚未完成 F006 的环境,必须先升级到包含 F006 的 2.5.x 版本,按该版本 +说明完成权限数据迁移与验证(regression=0,并核对非管理员账号的资源访问),再升级到 +后续目标版本并执行 F048。F048 的输入是已迁移的旧 ReBAC tuple 与 Config,不承担旧 +`roleaccess` 的首次转换;当前版本不恢复 F006,也不得在 F048 环境运行它。已完成 F006 +的旧 ReBAC 环境无需重复中间阶段,升级时保留原数据库和 OpenFGA Store。 + Schema 命令只有 `alembic upgrade head`,revision 只做 DDL。F048 数据迁移入口固定为 `src/backend/scripts/migrate_f048_permission_data.py`;它提供 `migrate` 和 `verify`, 没有 `inventory/dry-run/cleanup/rollback` 子命令,也不能被 Alembic、lifespan、API 或 @@ -1969,8 +1996,17 @@ D3 已完成全部旧运行数据退役,D6 没有延后的 cleanup 窗口。 ## 修订历史 +- 2026-11-02:修复业务成员来源重新激活时的历史版本同步,写 OpenFGA 前校验版本,保持稳定来源 ID;新增连续加入/退出与角色移动回归。无表结构、公开 API 或权限语义变更,存量孤儿关系仍走独立运维对账。 + +- 2026-11-02:修正停用模型既有授权在名单、搜索、我的权限及审批当前授权摘要中的遗漏;真实返回 model.active,按原等级规则开放旧来源管理并保留新目标 active 校验。依据空间 3254 的只读排查与用户确认方案,不迁移或撤销存量授权。验证见 [本次记录](./verification-inactive-existing-grants-20261102.md)。 + +- 2026-10-29:修复租户管理员构建列表的跨租户快捷放行;权限冲突码迁至 25020–25026,保留信息源原码及存量资源归属;仅执行本缺陷定向验证。 + | 日期 | 改动 | 触发原因 | |---|---|---| +| 2026-10-23 | 工具预置身份仅依据 `is_preset=1`;统一运行时、清单、迁移和租户复制;显式补齐 CURRENT 存量工具缺失的 wildcard 系统授权,保留既有 Grant,并走版本围栏与投影账本 | 用户确认移除历史创建者限制,并修复被旧迁移按 USER 处理的预置工具;仅交付代码与回归,不执行线上数据变更 | +| 2026-10-22 | 明确纯 RBAC 老环境先在 2.5.x 完成 F006 及验证,再升级到后续版本执行 F048;当前分支不恢复 F006 | 用户选择分阶段升级路径 | +| 2026-10-22 | F048 迁移退役旧用户组管理员默认 manager 授权;旧 F006 入口已删除,不恢复;成员授权和创建者权限继续保留 | 用户明确新用户组只有成员与创建人,不再为管理员分别授予资源管理权限 | | 2026-10-08 | 父子资源按类型与 ID 判定自关联;上传失败逐文件按创建 ledger 撤权并清理镜像、存储对象及业务记录,保留补偿审计和失败恢复边界 | beta3 根目录上传 ID 碰撞及整批权限清理中断 | | 2026-09-14 | 修正指定文件夹列表/搜索的容器鉴权,补充服务账号多级继承、编辑者/所有者动作、CUSTOM 隔断、撤权与历史标记修复回归 | v2 服务账号访问知识空间与独立授权文件夹的排查 | | 2026-08-13 | 对单槽浅层 visible、inactive 既有授权保持、删除零引用门禁、旧系统单次迁移、列表路径、契约/依赖/测试/可观测执行 24 项 Design 接手测试与 Constitution Check;复审 LGTM,停在 Design ★ | `/sdd-review ... design` | diff --git a/features/v3.0.0-beta1/048-rebac-permission-model-grants/product-authorization-model-and-migration-guide.md b/features/v3.0.0-beta1/048-rebac-permission-model-grants/product-authorization-model-and-migration-guide.md index cc91ca75f7..d6b148fc1b 100644 --- a/features/v3.0.0-beta1/048-rebac-permission-model-grants/product-authorization-model-and-migration-guide.md +++ b/features/v3.0.0-beta1/048-rebac-permission-model-grants/product-authorization-model-and-migration-guide.md @@ -590,6 +590,11 @@ type knowledge_space ## 12. 数据迁移:现有数据从哪里来 +仍使用旧 RBAC 的环境必须先升级到包含 F006 的 2.5.x 版本,按该版本说明完成 +RBAC→ReBAC 迁移及验证,再升级到后续目标版本执行 F048。F048 不直接转换旧 +`roleaccess`;当前版本不恢复 F006,也不得在 F048 环境运行旧迁移脚本。已经完成 F006 +的旧 ReBAC 环境无需重复这一中间阶段。 + 迁移需要同时读取三类来源: ### 12.1 OpenFGA 旧 tuple @@ -628,6 +633,7 @@ type knowledge_space | 与 CREATOR 不同的 space/channel `user_id` | 所有者标准模型普通 assignee | 保留 F018 后仍有效的新 owner,不删除旧 CREATOR | | 其他直接 `owner` | binding 指向模型或所有者标准模型的普通 assignee | 可以存在多个;分别保留,不因已有 protected owner 而删除 | | `manager` | binding 指向模型或管理者标准模型 | 保留用户、部门、用户组主体 | +| `user_group:{id}#admin` 的 `manager/can_manage` | 退役审计项,不生成授权 | 新用户组不再单独授予管理员资源管理权限;不转授给全组成员,已有成员授权和创建者权限照常迁移 | | `editor` | binding 指向模型或编辑者标准模型 | 同上 | | `viewer` | binding 指向模型或查看者标准模型 | 同上 | | `can_read / can_edit / can_manage / can_delete` | 不迁移 | 它们是计算结果,不是直接 tuple | diff --git a/features/v3.0.0-beta1/048-rebac-permission-model-grants/spec.md b/features/v3.0.0-beta1/048-rebac-permission-model-grants/spec.md index 8757698c18..367693a623 100644 --- a/features/v3.0.0-beta1/048-rebac-permission-model-grants/spec.md +++ b/features/v3.0.0-beta1/048-rebac-permission-model-grants/spec.md @@ -408,6 +408,9 @@ - **AC-71** — WHEN Alembic schema upgrade 已成功且全部权限读写进程停止, THE SYSTEM SHALL 由 `src/backend/scripts/` 专用脚本启动唯一正式 data migration run,先校验 schema、源环境、 迁移锁和旧数据完整性,再直接写入规范化关系表与现有 Store;不要求先执行独立 dry-run。 + IF 源环境仍为旧 RBAC、尚未完成 F006, THEN THE OPERATOR SHALL 先升级到包含 F006 的 + 2.5.x 版本完成迁移及验证,再升级到后续版本执行 F048;当前版本不恢复 F006,F048 不直接 + 转换旧 `roleaccess`,已完成 F006 的旧 ReBAC 环境无需重复中间阶段。 - **AC-72** — THE SYSTEM SHALL 在数据迁移脚本的源校验阶段盘点旧动作、四档关系、自定义模型、 Config binding、资源授权 tuple、直接/部门/用户组来源、受保护所有者、资源父级和异常记录。 - **AC-73** — THE SYSTEM SHALL 输出旧动作到新动作、旧系统/自定义模型到新模型、旧授权到目标 Grant、资源到目标权限模式的可追溯映射。 @@ -544,6 +547,7 @@ OpenFGA Authorization Model 不可原地修改;每次发布都会产生新的 | 业务表/成员事实确认的受保护创建者 | 进入“资源 + 新所有者标准模型”的 Grant,并标记为受保护 assignee;缺少直接 tuple 但事实唯一时审计修复 | | 其他资源直接 `owner` | 有唯一匹配 binding 时使用其迁移后模型,否则进入新所有者标准模型 Grant | | 资源直接 `manager` | 进入“资源 + 新管理者标准模型”的 Grant;保留原 userset 主体 | +| `user_group:{id}#admin` 的旧 `manager/can_manage` | 退役并记录审计差异;不创建 Grant,不改授给用户组成员 | | 资源直接 `editor` | 进入“资源 + 新编辑者标准模型”的 Grant;保留原 userset 主体 | | 资源直接 `viewer` | 进入“资源 + 新查看者标准模型”的 Grant;保留原 userset 主体 | | 有匹配 binding 的四档 tuple | 不按四档默认模型迁移,改用 binding 指向的已迁移 PermissionModel | @@ -555,7 +559,7 @@ OpenFGA Authorization Model 不可原地修改;每次发布都会产生新的 - **AC-118** — WHEN 读取非受保护的旧 `owner/manager/editor/viewer` tuple, THE SYSTEM SHALL 先按资源、主体、relation 和范围查找唯一匹配的旧 binding;有 binding 时使用其 model_id,无 binding 时才映射到对应标准模型。 - **AC-119** — THE SYSTEM SHALL 只为直接 tuple 创建 Grant assignee,不得把 owner 蕴含 manager/editor/viewer、manager 蕴含 editor/viewer 或 `parent` 继承得到的用户物化为新 assignee。 - **AC-120** — WHEN 迁移资源创建者或其他受保护 owner, THE SYSTEM SHALL 结合资源业务表和既有成员事实识别它,并映射到所有者标准模型的受保护 assignee;仅凭 relation 名称不能推断所有 owner 都受保护。IF canonical 事实唯一但缺少直接 owner tuple, THEN THE SYSTEM SHALL 补建受保护来源并记录修复;IF 存在多个 CREATOR、主体冲突或冲突 binding, THEN THE SYSTEM SHALL 阻断迁移。knowledge_space/channel 的唯一 CREATOR 与 `user_id` 因既有 F018 不一致时,迁移须保留 CREATOR 为 protected、差异 `user_id` 为 ordinary 并登记差异,不得静默删除任一当前有效 owner 来源。 -- **AC-121** — WHEN 旧主体为 `department:{id}#member`、`user_group:{id}#member` 或 `user_group:{id}#admin`, THE SYSTEM SHALL 原样保留主体类型与 userset 语义,不展开为用户。 +- **AC-121** — WHEN 旧主体为 `department:{id}#member` 或 `user_group:{id}#member`, THE SYSTEM SHALL 原样保留主体类型与 userset 语义,不展开为用户。WHEN 旧资源授权为 `user_group:{id}#admin` 的 `manager/can_manage`, THE SYSTEM SHALL 将其记为退役审计差异,不创建 Grant 或 visible contribution,也不得改授给 `#member`;普通成员授权和受保护创建者权限继续按原规则迁移。 - **AC-122** — WHEN 旧部门 binding 标记 `include_children=true`, THE SYSTEM SHALL 以 binding 中的根部门和范围作为唯一业务授权来源;历史上为子部门展开出的多个 tuple 不得被误迁为多个独立授权。 - **AC-123** — WHEN 旧 tuple 找不到资源、主体或合法租户,或者一个 tuple 匹配多个互相冲突的 binding, THE SYSTEM SHALL 将其列为阻断项,不得猜测目标模型。 - **AC-124** — WHEN binding 存在但对应直接 tuple 不存在, THE SYSTEM SHALL 把它标记为孤儿 binding 审计项,不创建有效 Grant,也不得仅凭 Config 恢复已不存在的授权;该项本身不阻断迁移。 diff --git a/features/v3.0.0-beta1/048-rebac-permission-model-grants/tasks.md b/features/v3.0.0-beta1/048-rebac-permission-model-grants/tasks.md index 96451e742b..c0c9086c7e 100644 --- a/features/v3.0.0-beta1/048-rebac-permission-model-grants/tasks.md +++ b/features/v3.0.0-beta1/048-rebac-permission-model-grants/tasks.md @@ -1683,6 +1683,31 @@ - **验收**:T197、F048 permission focused suite、Ruff、arch-guard 与 diff-check 通过。 - **依赖**:T197 +## Wave 17 — 停用模型既有授权展示纠正(2026-11-02) + +本轮落实已确认 AC-15/164/166 与用户认可的修复方案,不重新定义授权规则。 + +- [x] **T199:既有停用授权的后端定向合同验证** + - **文件**:`test/permission/test_inactive_model_existing_roster.py`,以及 F048 explain、catalog runtime、resource API、grant policy/source 与 F115 action sources 的定向测试。 + - **覆盖 AC**:AC-15, AC-164, AC-166。 + - **验收**:55 项通过;覆盖直接/继承来源、分页/搜索、同级和等级限制、protected、权限拒绝、Catalog 读取冲突及 mutation 响应。 + +- [x] **T200:修复后端来源读取及既有来源管理资格** + - **文件**:`permission/application/{control_state,resource_api,runtime}.py`,`permission/domain/services/{grant_service,permission_explain_service}.py`。 + - **逻辑**:保留有效既有来源,返回真实 model.active,服务端细化 editable;不放开新目标 active 校验。 + - **依赖**:T199。 + +- [x] **T201:两套 SPA 展示状态与表单管理修复** + - **文件**:两套 `PermissionGrantTab` / `PermissionListTab` / `assigneePolicy` / `PermissionModelStatus`,Client `PermissionDraftEditor` / draft policy / row type、知识空间和频道表单映射,三语文案及定向测试。 + - **覆盖 AC**:AC-15, AC-164, AC-166。 + - **验收**:Platform 9 项、Client 临时 runner 25 项通过;lint 与 i18n 通过。TypeScript 基线限制与 runner 差异见验证记录。 + - **依赖**:T200。 + +- [ ] **T202:部署后定向 E2E** + - **范围**:3254 只读核对来源;专用测试资源验证停用、拒绝新分配、精确撤销/移动、多来源并集与非管理员权限拒绝。 + - **状态**:本轮未部署,真实环境 E2E 未执行;不在空间 3254 上撤权或修改模型。 + - **依赖**:T200, T201 与目标环境更新。 + ## 实际偏差记录 > 只记录一句话指针;设计原因和反直觉事实回写 [design.md](./design.md)。 @@ -1719,3 +1744,10 @@ `{"result":{"object":"..."}}` 包络;client 增加该官方运行时形态并保留未包络代理兼容, 单槽集合语义不变。T189 当前为 PARTIAL:真实 MySQL/DM8、业务 API/UI、故障注入、5,001 容量和 D0~D6 尚无环境证据,因此 T189/T190 不标完成。 +- 用户于 2026-10-22 确认停止迁移用户组管理员默认 manager 授权,AC-121 与实现同步收紧; + 处理边界见 [design.md §8.1](./design.md#81-来源映射)。mapper/coordinator 首轮 45 passed, + 扩展迁移与旧入口退役检查 136 passed、1 个既有前端静态检查失败(已对照 HEAD 确认); + Ruff、arch-guard、diff-check 通过,未执行真实环境迁移或存量授权清理。 +- 用户于 2026-10-22 改为采用分阶段升级:旧 RBAC 环境先在 2.5.x 完成 F006 及验证, + 再升级到后续版本执行 F048;撤回本次临时恢复的 F006 文件,保留已完成的 F048 + 管理员默认 manager 授权退役修改。升级前置条件见 [design.md §8.3](./design.md#83-正式迁移命令与启服门禁)。 diff --git a/features/v3.0.0-beta1/048-rebac-permission-model-grants/verification-inactive-existing-grants-20261102.md b/features/v3.0.0-beta1/048-rebac-permission-model-grants/verification-inactive-existing-grants-20261102.md new file mode 100644 index 0000000000..7c111f2736 --- /dev/null +++ b/features/v3.0.0-beta1/048-rebac-permission-model-grants/verification-inactive-existing-grants-20261102.md @@ -0,0 +1,101 @@ +# 停用模型既有授权:修复与验证记录 + +日期:2026-11-02。分支:`codex/permission-inactive-grant-20261102`。 +基线:最新远端 `feat/3.0.0` 的 `368894d9a`;主工作树及其他未提交内容未改动。 + +## 1. 已确认规则与问题 + +本次实现用户认可的方案,纠正 F048 AC-15、AC-164、AC-166 的实现偏差: + +- 停用权限模型只阻止新增绑定、移动到该模型及复制新的该模型绑定;既有有效授权继续生效。 +- 名单、搜索、我的权限说明与审批当前授权摘要应保留这些真实来源。 +- 原管理权限、等级/同级策略、protected、继承、租户边界和乐观锁继续约束撤销及替换。 + +116 只读排查发现:空间 3254 的部门 310 来源仍有效,引用已停用自定义模型“1111”; +wangxinlei1 属于该部门,因此在“我加入的”中可见符合既有规则。问题是权限说明按模型 +active 过滤,导致管理页面漏掉这项授权。此处保留排查结论,未在本轮重新写入数据库或 FGA。 + +## 2. 实现 + +| 层次 | 修改 | +|---|---| +| SQL 来源读取 | `load_source_page` 保留已知模型的既有来源;仍限定同租户、ACTIVE/CURRENT Grant、ACTIVE assignee 和合法继承路径;分页有界 | +| 权限说明 | `PermissionExplainService` 保留停用模型的直接/继承来源和动作;inactive Grant / assignee 仍排除 | +| 审批摘要 | `read_effective_action_sources` 继续按具体 action、CURRENT 状态、读围栏和容量限制返回来源,不按模型可分配状态删除来源 | +| 既有来源管理 | 与新分配共用等级/同级判断,仅去掉旧来源模型 active 要求;服务端以此细化 editable,无完整名单扫描 | +| API | 名单返回真实 model.active;若来源模型不属于读取的 Catalog 快照则明确返回版本冲突;mutation 响应保留其他未撤销的停用模型来源 | +| 两套 SPA | 显示“该权限已停用,已有授权仍有效。”;管理旧来源与选择新目标分开,保留服务端只读限制 | +| Client 设置表单 | 知识空间、频道 hydrate 保留 modelActive;移动到可分配目标后更新该元数据 | + +无数据库结构变更、新错误码、新依赖或 OpenFGA 模型变更。没有迁移、启用或撤销 116 的存量授权。 + +## 3. 定向验证 + +| 检查 | 结果 | 范围/限制 | +|---|---|---| +| Backend pytest | **55 passed** | `test_inactive_model_existing_roster.py`、F048 explain/resource API/grant policy/grant sources、F115 action sources,以及 catalog runtime 中有界分页和最近 CUSTOM 祖先的两个测试(含 active 参数);覆盖读取时 Catalog 模型缺失的冲突响应 | +| Platform Vitest | **9 passed** | `src/test/f048PermissionGrantTab.test.tsx`;包含停用来源精确 REMOVE、只读行拒绝、新目标排除及已有版本冲突路径 | +| Client 组件/纯函数 | **25 passed** | `PermissionGrantTab`、`PermissionListTab`、`permissionDraftPolicy`,在临时 Vitest 适配器执行原断言 | +| 前端 lint | **PASS** | 从 `src/frontend` 执行 `pnpm lint`,覆盖两个 SPA 及共享包 | +| 三语 key / i18n | **PASS** | `node scripts/check-i18n.mjs`;无新增漂移,既有 84 项 frontend-only 基线保留 | +| TypeScript | **基线阻塞,无新增诊断** | Platform 23 项、Client 135 项;在 `368894d9a` 临时只读源码副本、同一组已安装依赖下逐条比较一致(忽略绝对路径与新增 import 引起的行号偏移) | +| Ruff / arch-guard / diff-check | **PASS** | 本次改动范围;不执行全量测试 | +| 真实部署 E2E | **未执行** | 本轮未更新 116;见下方验收清单,T202 保持未完成 | + +### 本地 runner 限制 + +Client 原 Jest 在测试初始化时报缺少 `canvas.node`;node 环境纯函数用例还会被全局 setup +的 Element 引用阻断。未安装依赖或修改仓库 runner。临时 Vitest 适配只转换 jest/vi API、 +同步 locale 读取及导入路径,并对齐 clearMocks/restoreMocks;使用原 Button、共享 UI 和 +原插画组件,绕过未使用的 barrel 导出,权限逻辑和断言不变。临时文件均在 `/private/tmp`。 + +额外运行的 Platform `f048PermissionDialog` 有一个旧用例把 subject Tab 查询成 button; +在未修改基线 `368894d9a` 上同样复现。未改业务 Tab 语义或扩大本次修复范围。 + +架构守卫以仓库相对路径检查全部改动通过。传入绝对工作树路径时,RULE-9 会将路径中的 +`/bisheng/` 仓库名误当应用包前缀,从而对 permission 测试中原有的 OpenFGA import 报警; +已核对其 import 与基线完全相同,未修改守卫或添加例外。 + +## 4. 任务与静态复审 + +按 `task-review` 检查 T199–T201:分层、命名、序列化、数据库、前端约定、信息泄漏及设计 +同步通过;无新增 ORM/原生 SQL、凭证、菜单或鉴权短路。新增文案三语同时提供,两个 SPA +组件分别实现;修改的前端文件均不超过 600 行。 + +本缺陷按 `sdd-code-review` 七维度由同一代理复审: + +| 维度 | 结论 | +|---|---| +| 边界 | 空页/搜索/直接与继承、停用模型、inactive Grant/assignee 均有定向验证;缺失模型不凭空恢复 | +| 权限与认证 | 普通主体测试管理等级及拒绝路径;原 OpenFGA 鉴权、租户/目标及新目标 active 限制保留 | +| 并发 | 未更改写协议;资源/Catalog/assignee 版本、幂等和投影账本继续由原写服务校验 | +| 信息泄漏 | 没有加入环境凭证、内部地址或跨租户数据;说明 API 仍位于原阅读门禁之后 | +| 测试 | 本地定向验证通过;TypeScript/旧 Dialog 用例有已复现的基线限制,真实部署证据待补 | +| 风格 | Ruff、lint、架构守卫通过;无新增忽略/抑制或依赖 | +| 文档 | F048 design 的职责、名单字段、已知坑、内部 API 与修订历史已同步 | + +代码静态复审完成;不以此宣称整个 F048、CI 或真实环境 E2E 全部通过。 + +## 5. 部署后最小验收(待执行) + +1. 对空间 3254 只读核对授权名单/搜索:可见部门 310、“1111”、active=false 及停用提示; + wangxinlei1 仍可在“我加入的”看到空间,普通使用/下载动作保持原结果,设置权限不额外放开。 +2. 专用 `e2e-inactive-grant-` 测试资源:创建模型和部门/用户组绑定后停用模型; + 管理者仍能读取来源,普通成员可见性及具体动作保持;无管理权限者拒绝读完整名单或写入。 +3. 新增/移动/复制到已停用模型均拒绝;高等级、protected、继承来源或过期版本仍拒绝修改。 +4. 在专用资源精确撤销或移动旧来源;保留同一用户的另一有效来源,确认其可见性仍存在; + 最后一项来源撤销后才失去对应访问。 +5. 仅清理本次创建的测试对象;空间 3254、Grant 15396 及存量模型不作变更。 + +交付目标:用户于 2026-11-02 要求将本修复提交并推送到 `feat/3.0.0`。 +本记录覆盖代码及本地定向验证;真实环境 E2E 待部署后补充,不包含环境更新。交付提交号以 Git 历史为准。 + +## 6. 合入验证(2026-11-02) + +- 修复提交:`ff2b89aa077d1f155542433f8e6b81c5d75b6923`。 +- 合入基线:刷新后的远端 `feat/3.0.0`,`a6ea4a7987e05259f5779f0e011971e7c4c0709c`。 +- 保留该基线的成员关系历史版本修复及知识空间查询优化;`control_state.py`、`runtime.py` + 自动合并,设计文档逐段保留双方内容,并将本次已知坑编号调整为 36。 +- 合并后原 55 项回归加成员关系重新激活的 8 项回归,**63 passed**;Ruff、架构守卫、 + 冲突标记扫描和 diff-check 通过。前端与修复提交逐文件一致,沿用已完成的 34 项定向结果。 +- 未执行全量测试或更新 116;T202 真实部署 E2E 仍待完成。 diff --git a/features/v3.0.0-beta1/053-openapi-auth-and-identity/generate_openapi_contract.py b/features/v3.0.0-beta1/053-openapi-auth-and-identity/generate_openapi_contract.py index cfba8986a7..b7a2e7da34 100644 --- a/features/v3.0.0-beta1/053-openapi-auth-and-identity/generate_openapi_contract.py +++ b/features/v3.0.0-beta1/053-openapi-auth-and-identity/generate_openapi_contract.py @@ -10,6 +10,12 @@ REPOSITORY_ROOT = Path(__file__).resolve().parents[3] BACKEND_ROOT = REPOSITORY_ROOT / "src" / "backend" sys.path.insert(0, str(BACKEND_ROOT)) +# The model protocol face (F051) is the one v2 sub-router mounted only where +# ``open_platform.enabled`` is on, and ``bisheng.api.router`` decides that at +# import time. The customer contract documents the complete surface, so force +# the switch on **before** importing the app rather than depending on whichever +# config.yaml the person running this happens to have. +importlib.import_module("bisheng.common.services.config_service").settings.open_platform.enabled = True app = importlib.import_module("bisheng.main").app OUTPUT = Path(__file__).with_name("openapi-v2-key-auth-api.json") @@ -20,8 +26,7 @@ def _schema_references(value: object) -> set[str]: references = { item["$ref"].removeprefix("#/components/schemas/") for item in [value] - if isinstance(item.get("$ref"), str) - and item["$ref"].startswith("#/components/schemas/") + if isinstance(item.get("$ref"), str) and item["$ref"].startswith("#/components/schemas/") } for item in value.values(): references.update(_schema_references(item)) @@ -56,9 +61,7 @@ def generate() -> dict: "paths": paths, "components": { "schemas": dict(sorted(schemas.items())), - "securitySchemes": { - "OpenApiBearer": {"type": "http", "scheme": "bearer", "bearerFormat": "API Key"} - }, + "securitySchemes": {"OpenApiBearer": {"type": "http", "scheme": "bearer", "bearerFormat": "API Key"}}, }, "x-websocket-endpoints": [ { diff --git a/features/v3.0.0-beta1/053-openapi-auth-and-identity/openapi-v2-key-auth-api.json b/features/v3.0.0-beta1/053-openapi-auth-and-identity/openapi-v2-key-auth-api.json index ffc986ee85..2322aabf69 100644 --- a/features/v3.0.0-beta1/053-openapi-auth-and-identity/openapi-v2-key-auth-api.json +++ b/features/v3.0.0-beta1/053-openapi-auth-and-identity/openapi-v2-key-auth-api.json @@ -2455,6 +2455,303 @@ } ] } + }, + "/api/v2/apps/deploy-limits": { + "get": { + "tags": [ + "HostedAppPublish" + ], + "summary": "Package limits of this deployment", + "description": "What the CLI checks a package against **before** uploading it (F053 AC-32).\n\nThe limits are deployment configuration rather than a constant precisely so\nthis endpoint can exist: a CLI that hardcoded 50 MiB would be a second copy\nof a contract that only the server can know. A CLI that cannot reach this\nendpoint uploads anyway and gets 16201 — a soft check must never be able to\nblock a publish.", + "operationId": "deploy_limits_api_v2_apps_deploy_limits_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnifiedResponseModel_dict_" + } + } + } + } + }, + "security": [ + { + "OpenApiBearer": [] + } + ] + } + }, + "/api/v2/apps/deploy": { + "post": { + "tags": [ + "HostedAppPublish" + ], + "summary": "Submit an application package", + "description": "Receive one package and start the pipeline (AC-01).\n\nReturns as soon as the fast checks pass; everything slow runs on a worker\nand is observed through ``GET /deployments/{id}``.\n\n``confirm_schema_change`` answers the ``precheck_schema`` gate (AC-09): an\niteration that drops or modifies a column of the online version's declared\ntables is refused with 16229 unless it is ``true``; the answer is recorded\non the deployment and the worker never asks a second time.", + "operationId": "deploy_api_v2_apps_deploy_post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_deploy_api_v2_apps_deploy_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnifiedResponseModel_dict_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "OpenApiBearer": [] + } + ] + } + }, + "/api/v2/apps/deployments/{deployment_id}": { + "get": { + "tags": [ + "HostedAppPublish" + ], + "summary": "Poll one publish attempt", + "description": "The CLI's polling payload: stage, status, the failure five-tuple, the approval.\n\nDeclared before ``/{app_id}/logs`` because Starlette matches in declaration\norder and an application whose id happened to be \"deployments\" would\notherwise shadow this route.", + "operationId": "get_deployment_api_v2_apps_deployments__deployment_id__get", + "parameters": [ + { + "name": "deployment_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Deployment Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnifiedResponseModel_dict_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "OpenApiBearer": [] + } + ] + } + }, + "/api/v2/apps/{app_id}/logs": { + "get": { + "tags": [ + "HostedAppPublish" + ], + "summary": "Recent application output", + "description": "``bisheng logs`` (F053).\n\nForwarded to the **same** service method the detail page's log tab uses, with\n``entry=\"cli\"`` — which narrows it to the credential's resource owner. A\ntenant administrator's key must not read every application's logs in the\ntenant: that would widen the open API past what the key holder was granted.", + "operationId": "get_logs_api_v2_apps__app_id__logs_get", + "parameters": [ + { + "name": "app_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "App Id" + } + }, + { + "name": "tail", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer", + "maximum": 5000, + "minimum": 1 + }, + { + "type": "null" + } + ], + "title": "Tail" + } + }, + { + "name": "since", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "epoch seconds, or a window like 30m / 2h / 7d", + "title": "Since" + }, + "description": "epoch seconds, or a window like 30m / 2h / 7d" + }, + { + "name": "keyword", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Keyword" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnifiedResponseModel_dict_" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "OpenApiBearer": [] + } + ] + } + }, + "/api/v2/model/v1/chat/completions": { + "post": { + "tags": [ + "OpenAPI", + "Model" + ], + "summary": "Chat Completions", + "operationId": "chat_completions_api_v2_model_v1_chat_completions_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChatCompletionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Streamed when stream=true", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChatCompletionResponse" + } + }, + "text/event-stream": {} + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "OpenApiBearer": [] + } + ] + } + }, + "/api/v2/model/v1/models": { + "get": { + "tags": [ + "OpenAPI", + "Model" + ], + "summary": "List Models", + "operationId": "list_models_api_v2_model_v1_models_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelList" + } + } + } + } + }, + "security": [ + { + "OpenApiBearer": [] + } + ] + } } }, "components": { @@ -2651,18 +2948,50 @@ ], "title": "Body_delete_metadata_fields_api_v2_knowledge_delete_metadata_fields_delete" }, - "Body_invoke_workbench_asr_api_v2_llm_workbench_asr_post": { + "Body_deploy_api_v2_apps_deploy_post": { "properties": { - "file": { + "package": { "type": "string", "format": "binary", - "title": "File" - } - }, - "type": "object", - "title": "Body_invoke_workbench_asr_api_v2_llm_workbench_asr_post" - }, - "Body_invoke_workbench_tts_api_v2_llm_workbench_tts_post": { + "title": "Package", + "description": "tar.gz built by `bisheng deploy`" + }, + "app_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "App Id", + "description": "Omit on a first publish; required for an iteration" + }, + "confirm_schema_change": { + "type": "boolean", + "title": "Confirm Schema Change", + "default": false + } + }, + "type": "object", + "required": [ + "package" + ], + "title": "Body_deploy_api_v2_apps_deploy_post" + }, + "Body_invoke_workbench_asr_api_v2_llm_workbench_asr_post": { + "properties": { + "file": { + "type": "string", + "format": "binary", + "title": "File" + } + }, + "type": "object", + "title": "Body_invoke_workbench_asr_api_v2_llm_workbench_asr_post" + }, + "Body_invoke_workbench_tts_api_v2_llm_workbench_tts_post": { "properties": { "text": { "type": "string", @@ -2983,22 +3312,323 @@ "type": "null" } ], - "title": "Separator Rule", - "description": "Segmentation before or after the segmentation rule;before/after" + "title": "Separator Rule", + "description": "Segmentation before or after the segmentation rule;before/after" + }, + "chunk_size": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Chunk Size", + "description": "Split text length, default if not passed" + }, + "chunk_overlap": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Chunk Overlap", + "description": "Split text overlap length, default if not passed" + }, + "hierarchy_level": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Hierarchy Level", + "default": 3 + }, + "append_title": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Append Title", + "default": false + }, + "max_chunk_size": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Max Chunk Size", + "default": 1000 + }, + "callback_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Callback Url", + "description": "Return URL" + }, + "file_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "File Url", + "description": "File URL" + }, + "file": { + "anyOf": [ + { + "type": "string", + "format": "binary" + }, + { + "type": "null" + } + ], + "title": "File", + "description": "Upload file" + }, + "retain_images": { + "anyOf": [ + { + "type": "integer", + "maximum": 1.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Retain Images", + "description": "Keep document image: 0 or 1", + "default": 1 + }, + "force_ocr": { + "anyOf": [ + { + "type": "integer", + "maximum": 1.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Force Ocr", + "description": "Enable OCR: 0 or 1", + "default": 0 + }, + "enable_formula": { + "anyOf": [ + { + "type": "integer", + "maximum": 1.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Enable Formula", + "description": "LaTeX formula recognition: 0 or 1", + "default": 1 + }, + "filter_page_header_footer": { + "anyOf": [ + { + "type": "integer", + "maximum": 1.0, + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Filter Page Header Footer", + "description": "Filter header/footer: 0 or 1", + "default": 0 + }, + "excel_rule": { + "anyOf": [ + { + "$ref": "#/components/schemas/ExcelRule" + }, + { + "type": "null" + } + ], + "description": "excel rule", + "default": {} + }, + "parent_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Parent Id", + "description": "Target folder id; knowledge-space only, must exist. Ignored for knowledge bases." + } + }, + "type": "object", + "title": "Body_upload_file_api_v2_filelib_file__knowledge_id__post" + }, + "ChatCompletionChoice": { + "properties": { + "index": { + "type": "integer", + "title": "Index", + "default": 0 + }, + "message": { + "$ref": "#/components/schemas/ChatCompletionMessage" + }, + "finish_reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Finish Reason" + } + }, + "type": "object", + "required": [ + "message" + ], + "title": "ChatCompletionChoice" + }, + "ChatCompletionMessage": { + "properties": { + "role": { + "type": "string", + "title": "Role", + "default": "assistant" + }, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Content" + }, + "reasoning_content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reasoning Content" + }, + "tool_calls": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/ToolCall" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Tool Calls" + } + }, + "type": "object", + "title": "ChatCompletionMessage" + }, + "ChatCompletionRequest": { + "properties": { + "model": { + "type": "string", + "minLength": 1, + "title": "Model" + }, + "messages": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "minItems": 1, + "title": "Messages" + }, + "stream": { + "type": "boolean", + "title": "Stream", + "default": false + }, + "stream_options": { + "anyOf": [ + { + "$ref": "#/components/schemas/StreamOptions" + }, + { + "type": "null" + } + ] + }, + "temperature": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Temperature" }, - "chunk_size": { + "top_p": { "anyOf": [ { - "type": "integer" + "type": "number" }, { "type": "null" } ], - "title": "Chunk Size", - "description": "Split text length, default if not passed" + "title": "Top P" }, - "chunk_overlap": { + "max_tokens": { "anyOf": [ { "type": "integer" @@ -3007,10 +3637,9 @@ "type": "null" } ], - "title": "Chunk Overlap", - "description": "Split text overlap length, default if not passed" + "title": "Max Tokens" }, - "hierarchy_level": { + "max_completion_tokens": { "anyOf": [ { "type": "integer" @@ -3019,22 +3648,26 @@ "type": "null" } ], - "title": "Hierarchy Level", - "default": 3 + "title": "Max Completion Tokens" }, - "append_title": { + "stop": { "anyOf": [ { - "type": "boolean" + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" }, { "type": "null" } ], - "title": "Append Title", - "default": false + "title": "Stop" }, - "max_chunk_size": { + "n": { "anyOf": [ { "type": "integer" @@ -3043,133 +3676,159 @@ "type": "null" } ], - "title": "Max Chunk Size", - "default": 1000 + "title": "N" }, - "callback_url": { + "presence_penalty": { "anyOf": [ { - "type": "string" + "type": "number" }, { "type": "null" } ], - "title": "Callback Url", - "description": "Return URL" + "title": "Presence Penalty" }, - "file_url": { + "frequency_penalty": { "anyOf": [ { - "type": "string" + "type": "number" }, { "type": "null" } ], - "title": "File Url", - "description": "File URL" + "title": "Frequency Penalty" }, - "file": { + "seed": { "anyOf": [ { - "type": "string", - "format": "binary" + "type": "integer" }, { "type": "null" } ], - "title": "File", - "description": "Upload file" + "title": "Seed" }, - "retain_images": { + "response_format": { "anyOf": [ { - "type": "integer", - "maximum": 1.0, - "minimum": 0.0 + "additionalProperties": true, + "type": "object" }, { "type": "null" } ], - "title": "Retain Images", - "description": "Keep document image: 0 or 1", - "default": 1 + "title": "Response Format" }, - "force_ocr": { + "tools": { "anyOf": [ { - "type": "integer", - "maximum": 1.0, - "minimum": 0.0 + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array" }, { "type": "null" } ], - "title": "Force Ocr", - "description": "Enable OCR: 0 or 1", - "default": 0 + "title": "Tools" }, - "enable_formula": { + "tool_choice": { "anyOf": [ { - "type": "integer", - "maximum": 1.0, - "minimum": 0.0 + "type": "string" + }, + { + "additionalProperties": true, + "type": "object" }, { "type": "null" } ], - "title": "Enable Formula", - "description": "LaTeX formula recognition: 0 or 1", - "default": 1 + "title": "Tool Choice" }, - "filter_page_header_footer": { + "parallel_tool_calls": { "anyOf": [ { - "type": "integer", - "maximum": 1.0, - "minimum": 0.0 + "type": "boolean" }, { "type": "null" } ], - "title": "Filter Page Header Footer", - "description": "Filter header/footer: 0 or 1", - "default": 0 + "title": "Parallel Tool Calls" }, - "excel_rule": { + "user": { "anyOf": [ { - "$ref": "#/components/schemas/ExcelRule" + "type": "string" }, { "type": "null" } ], - "description": "excel rule", - "default": {} + "title": "User" + } + }, + "additionalProperties": true, + "type": "object", + "required": [ + "model", + "messages" + ], + "title": "ChatCompletionRequest", + "description": "The promised subset of the OpenAI chat completion request." + }, + "ChatCompletionResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id" }, - "parent_id": { + "object": { + "type": "string", + "const": "chat.completion", + "title": "Object", + "default": "chat.completion" + }, + "created": { + "type": "integer", + "title": "Created" + }, + "model": { + "type": "string", + "title": "Model" + }, + "choices": { + "items": { + "$ref": "#/components/schemas/ChatCompletionChoice" + }, + "type": "array", + "title": "Choices" + }, + "usage": { "anyOf": [ { - "type": "integer" + "$ref": "#/components/schemas/Usage" }, { "type": "null" } - ], - "title": "Parent Id", - "description": "Target folder id; knowledge-space only, must exist. Ignored for knowledge bases." + ] } }, "type": "object", - "title": "Body_upload_file_api_v2_filelib_file__knowledge_id__post" + "required": [ + "model", + "choices" + ], + "title": "ChatCompletionResponse" }, "ChunkInput": { "properties": { @@ -3336,6 +3995,28 @@ ], "title": "FileUserMetaDataInfo" }, + "FunctionCall": { + "properties": { + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + }, + "arguments": { + "type": "string", + "title": "Arguments", + "default": "" + } + }, + "type": "object", + "title": "FunctionCall" + }, "HTTPValidationError": { "properties": { "detail": { @@ -3601,6 +4282,67 @@ "title": "MetadataFieldType", "description": "Metadata field type" }, + "ModelList": { + "properties": { + "object": { + "type": "string", + "const": "list", + "title": "Object", + "default": "list" + }, + "data": { + "items": { + "$ref": "#/components/schemas/ModelObject" + }, + "type": "array", + "title": "Data" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ModelList" + }, + "ModelObject": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "object": { + "type": "string", + "const": "model", + "title": "Object", + "default": "model" + }, + "created": { + "type": "integer", + "title": "Created" + }, + "owned_by": { + "type": "string", + "title": "Owned By" + }, + "bisheng_model_type": { + "type": "string", + "title": "Bisheng Model Type", + "default": "llm" + }, + "bisheng_qualified_name": { + "type": "string", + "title": "Bisheng Qualified Name" + } + }, + "type": "object", + "required": [ + "id", + "created", + "owned_by", + "bisheng_qualified_name" + ], + "title": "ModelObject" + }, "ModifyKnowledgeFileMetaDataReq": { "properties": { "knowledge_file_id": { @@ -4294,6 +5036,46 @@ ], "title": "RetrieveReq" }, + "StreamOptions": { + "properties": { + "include_usage": { + "type": "boolean", + "title": "Include Usage", + "default": false + } + }, + "additionalProperties": true, + "type": "object", + "title": "StreamOptions" + }, + "ToolCall": { + "properties": { + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Id" + }, + "type": { + "type": "string", + "title": "Type", + "default": "function" + }, + "function": { + "$ref": "#/components/schemas/FunctionCall" + } + }, + "type": "object", + "required": [ + "function" + ], + "title": "ToolCall" + }, "UnifiedResponseModel_OpenCitationResponse_": { "properties": { "status_code": { @@ -4357,6 +5139,29 @@ ], "title": "UnifiedResponseModel[WhoamiResponse]" }, + "UnifiedResponseModel_dict_": { + "properties": { + "status_code": { + "type": "integer", + "title": "Status Code" + }, + "status_message": { + "type": "string", + "title": "Status Message" + }, + "data": { + "additionalProperties": true, + "type": "object", + "title": "Data" + } + }, + "type": "object", + "required": [ + "status_code", + "status_message" + ], + "title": "UnifiedResponseModel[dict]" + }, "UpdateKnowledgeMetadataFieldsReq": { "properties": { "knowledge_id": { @@ -4489,6 +5294,45 @@ "title": "UploadFileResponse", "description": "Upload file response schema." }, + "Usage": { + "properties": { + "prompt_tokens": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Prompt Tokens" + }, + "completion_tokens": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Completion Tokens" + }, + "total_tokens": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Total Tokens" + } + }, + "type": "object", + "title": "Usage" + }, "ValidationError": { "properties": { "loc": { @@ -4612,6 +5456,11 @@ } ], "title": "Expires At" + }, + "model_base_url": { + "type": "string", + "title": "Model Base Url", + "default": "" } }, "type": "object", diff --git a/features/v3.0.0-beta1/053-openapi-auth-and-identity/prd-deviation-review.md b/features/v3.0.0-beta1/053-openapi-auth-and-identity/prd-deviation-review.md new file mode 100644 index 0000000000..00f3dec267 --- /dev/null +++ b/features/v3.0.0-beta1/053-openapi-auth-and-identity/prd-deviation-review.md @@ -0,0 +1,44 @@ +# F053 开放 API 鉴权:实现与 PRD 偏离清单(评审转交) + +日期:2026-09-09 范围:`src/backend/bisheng/open_api/` 及相关模块 基准:`docs/PRD/3.0-beta2/3.0 开放 API 鉴权与身份传递 PRD.md`(含 §7 决策记录 D1–D20) +性质:代码静态审计,未跑接口。首轮对 `feat/3.0.0-beta2`(da2be5697)审计,随后按 `feat/3.0.0-beta1` 最新提交(59b27d1c1,2026-09-09 20:27)逐项复核;行号以 beta2 为准,复核结论以 beta1 为准。 + +## 结论 + +实现总体忠于 PRD,端点接入、委托、个人令牌、审计、检索文件级过滤都已落地。design §2.1 列出的 10 项改判中有 5 项偏离 PRD 且未经产品追认。其中两项实现事故(D17、D19)highway 已于 9 月 9 日在 beta1 上修复(f6bf9f51f),尚未合入 beta2;其余需产品裁定后改 PRD 或改代码。 + +## 一、已修复,待合入 beta2(beta1 f6bf9f51f,2026-09-09) + +| # | 决议 | 现状 | 期望 | 锚点 | +|---|---|---|---|---| +| 1 | D17:管理员持有的个人令牌继承管理员短路,但可见租户集合恒为密钥租户 | beta2 上对所有 v2 主体硬编码 `super_admin=False`(`open_api/api/dependencies.py:98-104`) | **beta1 已修**:自然人主体解析时加载 `_check_is_global_super` / `is_tenant_admin` 注入 `PermissionActor`,租户集合限密钥租户 | 请 dolphin 合入 beta2 | +| 2 | D19(9 月 3 日拍板):换租户令牌不失效、随人迁移 | beta2 上换租户仍 `cascade_revoke`(`user_tenant_sync_service.py:353-366`) | **beta1 已修**:改为 `PersonalTokenService.migrate_tenant` | 同上;design.md:290 的旧口径请同步改 | + +## 二、需产品裁定(改 PRD 还是改代码,二选一) + +| # | 决议 | 现状 | 备注 | +|---|---|---|---| +| 3 | D4:服务账号复用用户表,用类型字段区分 | 独立 `service_account` 表、自增主键、与 `user` 无关系(design 第 56、93 行明写作废 vibe 写法) | 工程上更干净,免掉登录守卫、选人排除、对账豁免三项成本;但推翻 PRD 已定条目。若追认,需回写 PRD §4.5 与 D4,并补错误码表中 26012 的去留说明 | +| 4 | 身份头名 `X-Bisheng-On-Behalf-Of` | 改为 `X-On-Behalf-Of`(design K20),spec 第 50 行仍写旧名,PRD 的 AC-6/AC-10/AC-26/AC-38/AC-P15 逐字失效 | 与 OAuth OBO 同名更易混读。定一个名字,三处对齐 | +| 5 | §4.6.3:日常模式本次交付含知识库挂载、非流式、业务上下文指令;对外契约不暴露平台内部概念;会话列表/历史明确不做 | 无知识库入参(`to_internal()` 硬写 `use_knowledge_base=None`);只有 SSE;无业务上下文字段;对外 schema 暴露 `clientTimestamp/parentMessageId/overrideParentMessageId/responseMessageId/isCreatedByUser/isContinued/generation`;新增 `GET /api/v2/chat/list`、`GET /api/v2/chat/info` | 锚点 `open_api/domain/schemas/workstation.py:25-39,52`、`open_endpoints/api/endpoints/workstation.py:40-45`、`chat.py:15-33`、`domain/scopes.py:50-57`。裁定:补齐还是降级到下一版;两个新端点是否追认 | +| 6 | §4.9 铁律 1:移除固定身份兜底、`default_operator` 与 `enable_guest_access` 废弃 | v2 已清干净;默认操作员搬到新开的匿名 `/api/v3` 面继续以 `enable_guest_access` 为闸(design 第 96 行「只从 v2 移除」) | 缓解措施到位(`super_admin=False`、租户与资源双重收窄、仅已发布资源)。裁定是否追认 v3 匿名面 | + +## 三、次要缺陷(均按 beta1 最新提交复核仍成立) + +- 个人令牌默认有效期 30 天,PRD §4.10.5 为 365 天:`models/open_api_tenant_setting.py:13` 及迁移 server_default。 +- 技能包缺 `meta.json` 与 `references/api.md`(PRD §4.10.8 必备件表):`services/skill_pack_service.py:19-56`。分发路径自建 `/api/v1/open-api/skill-packs/{pack}`,PRD 点名的 `/api/v1/dev-toolkit/skills/{pack}` 在两个分支都不存在,PRD 该处取证已过期。 +- `delegate` 位与 `model:invoke / identity:read / app:manage` 三扩展位未做互斥硬阻断(PRD §4.2.4):`services/credential_service.py:260-275`。三位当前不可签发,点亮即成漏洞。 +- 交付文档 `openapi-v2-key-auth-api.md:34` 的 curl 示例传 `messages` 且 `clientTimestamp:0`,schema 是 `extra="forbid"` 且该字段为字符串,照抄必 400(beta1 最新提交仍如此)。 +- 存量对客文档未清:`docs/api/filelib-retrieve.md:26` 仍写「网络层负责访问控制」,`docs/api/知识库接口文档.md:27,211,292` 仍把 `user_id` 作为入参(PRD AC-P22 / AC-26)。 +- 错误码表少 26012(服务账号禁止登录);26029 语义与 vibe 侧不同(vibe:服务账号不可作资源侧授权主体;beta2:不能当归属人)。 + +## 四、给后续合并 3.0-vibe 的提醒 + +- 两侧 `service_account` / `api_credential` 同名不同构(主键、外键、列集不同)。beta2 迁移带 `table_exists` 幂等守卫,跑过 vibe 的库升级会跳过建表、留旧表、启动即崩且无告警;`subject_kind` 的 CHECK 不接受 vibe 的 `hosted_app`。 +- vibe 独有需要补回的:`open_api_subject` 端点级鉴权工厂、权限位注册表的分组/文案/高危提示/`visible_scopes`、错误码 26028;合计不到 200 行。vibe 的密钥泄漏扫描器要补登记 `bs-pat-` 前缀(PRD §4.2.6),beta 线没有扫描器,与 highway 无关。 +- 应用工场(app_publish / app_runtime / dev_toolkit)在 beta2 上不存在;vibe 侧三处引用 open_api 的符号适配不到 60 行,难点是托管应用的运行期凭据主体要进 beta2 的数据模型。 + +## 五、口径订正(审计时发现 PRD 或口头说法有误) + +- 不暴露的会话端点是 6 个 `/chat/*`(PRD §4.6),不是 8 个。 +- D9 已改判为「PRD 不裁定日常模式形态」,不用 OpenAI Responses 子集不算偏离;偏离的是 §4.6.3 的业务入参与硬边界。 diff --git a/features/v3.0.0-beta1/054-unified-agent-workbench/skill-center-preview.md b/features/v3.0.0-beta1/054-unified-agent-workbench/skill-center-preview.md new file mode 100644 index 0000000000..991aa16a06 --- /dev/null +++ b/features/v3.0.0-beta1/054-unified-agent-workbench/skill-center-preview.md @@ -0,0 +1,115 @@ +# 技能中心前端预览 + +## 本轮范围 + +2026-09-10 用户要求先在 `192.168.106.119:3006` 开发前端。初版将企业赋予和个人上传的技能放在同一列表。2026-09-16 用户调整为“我的技能”和“企业技能”两个分类,来源继续在详情中呈现。任务模式路由和自动选择技能、工具由另一位同事负责。 + +初版预览沿用 3006 提交 `4d16a5030e6ca7dcb375c2f86e2bd5985b12ad9f`,在独立分支 `codex/personal-skill-center-3006` 完成。入口开关优先读取部署环境的 `window.APP_CONFIG.skillCenterPreviewEnabled`;未配置时沿用构建开关 `VITE_SKILL_CENTER_PREVIEW=true`。 + +## 页面与操作 + +| 位置 | 用户操作 | 页面结果 | +| --- | --- | --- | +| 工作台侧栏 | 点击“技能中心” | 打开技能中心,默认显示“我的技能”,保留对话缓存 | +| 技能中心分类 | 切换“我的技能”或“企业技能” | 分别展示个人上传和管理员分配的技能,数量按分类统计 | +| 输入框加号菜单 | 展开“技能” | 同一面板内展示可用技能列表、上传入口和“管理技能” | +| 技能菜单 | 点击某个技能 | 打开该技能详情 | +| 技能菜单 | 点击“上传技能” | 弹窗选择文件,原对话保留 | +| 技能菜单 | 点击“管理技能” | 打开管理页 | +| 技能中心 | 搜索名称或说明 | 列表即时过滤,可清空搜索 | +| 上传弹窗 | 选择或拖入已有 Skill 文件 | 校验后显示名称、说明、大小和文件清单,确认后添加 | +| 技能详情 | 查看任意技能 | 展示名称、说明和来源 | +| 个人技能详情 | 更新、启停、删除 | 更新保留原 ID 和启停设置;删除前确认 | + +技能中心按个人与企业分类展示;个人技能保留更新、启停和删除,企业技能详情提供只读查看。企业技能由已有的 `/api/v1/linsight/skill/selectable` 读取,列表包含当前账号实际可用的技能。企业维护详情说明跟随现有能力口径。 + +2026-09-10 根据用户补充的 Kimi 截图,输入框加号菜单统一使用一个“技能”一级入口。桌面端展开右侧面板;移动端在当前菜单内进入技能面板,并提供返回入口。面板上方合并展示可用技能的名称和说明,列表可以滚动;底部固定展示上传已有技能和管理技能。个人停用的技能仍保留在完整管理页中。点击技能进入详情,任务按现有逻辑自动选用技能。 + +按用户后续反馈,技能列表沿用原选择器的紧凑行布局。名称或说明被截断时,鼠标悬停或键盘聚焦该行,在右侧展示完整名称和说明;空间不足时由现有 Tooltip 组件避让。截断检测与原 `SkillSelector` 共用 `useIsClipped`,面板宽度变化后重新检测。桌面技能面板宽 280px,高度随内容变化,最高 440px;底部分隔线下仅保留“上传技能”“管理技能”两个普通菜单项。文件格式说明和前端预览提示保留在上传弹窗及完整管理页面。 + +技能中心保留两列,采用四行共 8 张卡片的首屏密度目标。页面内容最大宽度 960px,卡片最小高度 128px、间距 12px,页面超出视口时继续滚动。卡片展示名称、标识和两行说明;启停操作保留在个人技能详情中。整张卡片可点击进入详情;“查看详情”位于右上角,在鼠标悬停或键盘聚焦时显示,触屏设备保持可见。技能数量按实际数据展示。 + +## 前端预览的数据边界 + +个人 Skill 文件和元数据存入当前浏览器 IndexedDB,按当前账号及 `/api/v1/user/current-tenant` 返回的租户隔离。技能中心顶部的预览横幅已按用户要求移除;上传弹窗和个人详情继续说明当前浏览器的存储范围。个人技能的启用状态用于预览管理交互;正式上传接口、跨设备同步和任务调用接入留待后端阶段完成。 + +文件支持 `.md`、`.zip`、`.skill`,预览上限为上传 50 MiB、解压声明体积 200 MiB、1000 个文件或目录、SKILL.md 正文文件 1 MiB。压缩包包含一个 SKILL.md,元数据要求 name 和 description,正文包含技能说明。仅解析文本并保存原始文件。 + +同一账号个人技能标识重复时,引导到已有技能的更新操作。与企业技能同名时保留两个记录,并提示在详情查看来源。更新失败保留原版本;读取与写入失败显示具体恢复提示;并发操作依据记录 revision 校验,提示刷新后重试。 + +## 验证与后续交接 + +代码检查覆盖类型、国际化、架构守卫、构建,以及压缩包校验、账号隔离、重复上传、更新、启停、删除和并发冲突的单元测试。发布记录另列实际执行结果。用户在 3006 预览页面确认交互后,再推进正式个人技能服务与任务调用接入。 + +### 本轮检查结果 + +| 检查 | 结果 | +| --- | --- | +| 工作台严格类型检查 | 悬停与布局调整合入已部署导航后 PASS,1304 个严格文件 | +| 专项单元测试 | 初版 PASS,14 项;菜单归并沿用已验证的上传及存储逻辑;`pnpm --filter bishengchat exec jest --config jest.skill-center.config.mjs --runInBand` | +| 国际化检查 | PASS,新增文案同步中文、英文、日文 | +| 工作台 lint | PASS,使用 `--pass-on-unpruned-suppressions` 保留仓库原有抑制记录;默认命令提示清理未使用记录 | +| 管理后台、共享 UI、文件预览包 lint | PASS | +| 文件预览包类型检查 | PASS | +| 全仓类型检查 | 管理后台两个既有测试文件报错:`f048DashboardPermissions.test.tsx:91`、`routeFilterPurity.test.ts:16`;本轮沿用部署中的管理后台产物 | +| 架构守卫、差异空白检查 | PASS | +| 悬停与布局调整后的构建、国际化、工作台 lint | PASS,沿用旧 lint 抑制记录 | +| 技能中心卡片收紧、状态隐藏与详情悬停 | 构建、1304 个文件的严格类型、工作台 lint、国际化及架构检查 PASS | +| 登录态浏览器验收、真实任务调用 | NOT_RUN;本轮交付前端预览 | + +发布基线核验发现 3006 已更新到工作台导航版本 `99c2f9cb4`。本轮合入该已部署版本,重新执行类型、构建、lint、国际化、导入大小写和架构检查;相对该版本的交付差异为本次技能菜单调整。 + +## 2026-09-14 调整 + +用户确认上传文件上限为 50 MiB,解压后内容合计上限为 200 MiB,沿用 1024 换算。前端校验、中文/英文/日文提示和文件大小展示同步更新;正式后端仍需执行实际字节与解压内容的最终校验。 + +在 3006 登录态页面复现详情点击问题:进入技能中心后离开超过一秒再返回,点击卡片只更新 URL。`react-activation` 冻结后的页面保留旧路由上下文,`useSearchParams` 无法取得最新技能 ID。技能中心改为在实时路由上下文中渲染,聊天页面继续使用缓存。回归用例使用真实 KeepAlive,等待冻结后从聊天返回并打开、关闭详情;修复前同一用例失败。 + +本轮以 3006 已部署的 `8925ac18230ef9c72b6c324b7071571de8e066a3` 为源码基线,保留最新导航与设置页改动。原 PRD 第 4 章同步统一技能菜单、紧凑列表、悬停详情和上传限制;个人上传服务与任务调用仍属于后续后端接入范围。 + +本轮确定性检查:Skill 包与持久化测试 17 项通过;导航及真实缓存返回回归测试 38 项通过;工作台 1311 个严格文件类型检查、lint、国际化、导入大小写、架构检查和生产构建均通过。浏览器复核结果与发布信息见本次部署记录。 + +## 2026-09-16 恢复 3006 入口 + +3006 已部署源码 `c3b332357a683e414d98f11e5e9e638d9e122ac6` 保留了技能中心及详情修复,生产包中的预览开关为 false。后续打包遗漏构建参数,导致侧栏技能中心、上传技能和管理技能入口一同关闭。本轮基于该已部署提交修复配置读取,保留当前环境的其他功能。 + +三个入口和技能中心路由共用一个布尔开关,优先读取 HTML 在主程序前同步加载的 `APP_CONFIG`。运行时配置中的 true 或 false 为该环境的明确设置;未设置时采用既有构建参数。原有账号与菜单权限校验继续生效。 + +3006 将下列配置保存在发布目录之外的 `/data/bs119/independent/djh-unified-3006/nginx/conf.d/client-runtime-config.js`: + +```js +window.APP_CONFIG = { + disableJa: true, + skillCenterPreviewEnabled: true +}; +``` + +Nginx 的 3001 server 使用精确路径提供该环境配置,并关闭缓存: + +```nginx +location = /workspace/assets/bisheng/config.js { + alias /etc/nginx/conf.d/client-runtime-config.js; + default_type application/javascript; + add_header Cache-Control "no-store" always; +} +``` + +日后替换 `current` 发布目录和工作台产物时,继续保留此环境配置与 location。调整其他 `APP_CONFIG` 默认项时同步核对环境文件。个人技能仍采用浏览器内的前端预览存储,上传与解压限制分别保持 50 MiB 和 200 MiB。本轮发布记录单独列出构建、测试、环境健康和回滚信息。 + +## 2026-09-16 技能分类调整 + +用户进一步确认技能中心提供“我的技能”“企业技能”两个分类。默认进入“我的技能”;分类分别统计数量、筛选搜索结果、呈现加载与错误状态。切换分类清空搜索词,保持两列紧凑卡片及悬停查看详情的布局。 + +企业技能由管理员维护,用户在技能中心只查看详情。个人技能保留更新、启停、删除操作。从输入框菜单直接打开企业技能详情时,列表同步定位到企业分类,关闭详情后保留该分类。在任意分类上传成功后转到“我的技能”展示保存结果。企业分类为空时提示等待管理员分配。 + +移除技能中心顶部的“前端预览”横幅;个人文件的浏览器存储方式、企业技能接口、50 MiB 上传及 200 MiB 解压限制保持当前实现。输入框技能菜单继续统一列出可用技能。 + +## release 分支交付 + +本次 PR 以 `upstream/release` 的 `42dec9c26103a9fb4d22d083cf66749631a3baa8`(3.0.0-beta3)为基线,仅迁入技能功能相关提交。技能组件、上传解析与存储、分类页面保持 3006 已确认实现;侧栏入口接入 release 现有的 MainLayout 导航结构,继续使用首页菜单权限和审批占位页。3006 的其他导航重构与管理后台功能分别交付。 + +预览入口继续由运行时 `APP_CONFIG.skillCenterPreviewEnabled` 或构建变量 `VITE_SKILL_CENTER_PREVIEW=true` 开启。正式环境默认沿用关闭状态,个人 Skill 的服务端持久化与任务调用由后续后端实现接入。 + +release 适配新增 5 项导航测试:开关显隐、首页权限、审批占位、会话返回地址保留、真实 KeepAlive 冻结后再次打开和关闭详情。分类页面 7 项、上传与配置 22 项,共 34 项测试通过;工作台 1329 个严格文件类型检查、变更文件 lint、生产构建、导入大小写及技能文案三语一致性检查通过。 + +全仓门禁的基线问题单独记录:管理后台 lint 93 项;工作台 3 个未修改文件的 lint 42 项;管理后台 API 示例文件存在大小写路径冲突,产生 3 项类型错误;全仓国际化检查缺少后端错误码 27001 的文案。这些问题对应的源码、lint 配置及错误码定义均与 release 基线一致。本次 PR 保留这些基线事实,具体检查输出见交付审计记录。3006 页面交互已获用户确认,release 适配完成组件回归,真实登录态 UI 验收未另行执行。 diff --git a/features/v3.0.0-beta1/067-commercial-license-expiry-reminder/design.md b/features/v3.0.0-beta1/067-commercial-license-expiry-reminder/design.md deleted file mode 100644 index 3627c3a94e..0000000000 --- a/features/v3.0.0-beta1/067-commercial-license-expiry-reminder/design.md +++ /dev/null @@ -1,387 +0,0 @@ -# Design: 商业授权统一到期提醒(F067) - -> **本文档定位 — 现状快照(Why this How)** -> -> - `spec.md` 回答 **做什么** -> - `design.md`(本文)回答 **为什么这么实现**、三路怎么写入、聚合怎么读 -> -> **关联**: [spec.md](./spec.md) · PRD《3.0 beta2》§5.3 -> **版本**: v3.0.0-beta1 -> **最后更新**: 2026-09-14 - ---- - -## 1. 目标与非目标 - -- **目标**:把 Gateway / ETL / 商业版看板三路授权收成平台级 `license_info` 的当前状态,管理后台超管 Banner 只读聚合接口,文案点名授权对象。 -- **非目标**:见 [spec.md](./spec.md) 范围边界。实现上额外钉死: - - **不反向调用 Gateway**(前端先读 Gateway,再上报后端)。 - - **不向商业看板拉授权**(看板服务自己更新对应行)。 - - **不新增过期降级**(不在 `DashboardService` / ETL 解析路径上新加拦截)。 - - **表里不存密文 / 私钥 / 设备指纹**。 - ---- - -## 2. 关键约束 - -遵循 `docs/constitution.md` C1–C7。本功能特有: - -- **授权是部署级的,不是租户级的。** `license_info` 不得带 `tenant_id`,否则租户过滤器会让超管在子租户上下文里读不到行。 -- **三路写入主体已经裁定,不能合并成「后端去拉三源」:** - - ETL:BISHENG 调 `GET {etl_origin}/api/license_info`(ETL4LM 2.1.0-beta1 §7)后 upsert。 - - 看板:商业看板服务定期更新 `license_code='dashboard'` 那一行。 - - Gateway:前端 `GET /api/license/status`,再 `POST` 给 BISHENG 落库。 -- **ETL 配置地址不是 origin。** `knowledges.etl4lm.url` 形如 `http://host:port/v1/etl4llm/predict`。授权查询必须剥到 origin 再拼 `/api/license_info`。 -- **后端调不到 Gateway。** 商业版拓扑是 `浏览器 → Gateway → Backend`。F037 已否决后端 HMAC 上报;本期仍由前端转发状态。 -- **展示阈值与 F037 的 severity 分档不同。** 本期只保留 `normal / expiring / expired / unknown`,30 天一条线,不再向前端下发 `warning / critical`。 -- **新表走 SQLModel `table=True` + 启动 `create_all`。** 不必为整表创建单独 Alembic revision;`update_time` 用项目统一 server default。实现时错误码模块号登记 **270**(11x 是灵思;11001 是 Gateway 业务码,不是 BISHENG 模块号)。 - ---- - -## 3. 方案对比与选定 - -### 决策 1:授权真相放在 `license_info` 表,不放内存聚合 - -- **备选**: - - A. Banner 请求时并行拉 Gateway / ETL / 看板,不落库。实现快,重启即丢,看板没有可拉的 HTTP。 - - B.(选定)平台级表做当前状态;三路按各自合同写入;聚合接口只读表(读时按到期日重算剩余天数)。 -- **选定**:B -- **原因**:用户已裁定必须有 `license_info`。看板只保证写表,不保证提供查询接口。表还能让 ETL Beat 与超管刷新解耦。 -- **何时该重新考虑**:三源都提供稳定只读 HTTP,且产品不再要求持久化。 - -### 决策 2:Gateway 由前端上报,后端不反调 - -- **备选**: - - A. 后端配置 `gateway.internal_url` 自己打 `/api/license/status`。 - - B.(选定)前端已能打到 Gateway;把 `data` 原样 POST 给后端 upsert。 -- **选定**:B -- **原因**:用户已裁定该路径。A 要新配内部地址,开源 / 配错都会静默失败,且是反向调用。 -- **何时该重新考虑**:出现「无浏览器也要刷新 Gateway 行」的运维需求。 - -### 决策 3:看板只写表,BISHENG 不拉 - -- **备选**: - - A. 为看板再做一个类似 ETL 的 HTTP 适配器。 - - B.(选定)看板服务定期 upsert `dashboard` 行;BISHENG 只读。本期不提供看板专用 HTTP 写入口(与用户裁定一致)。 -- **选定**:B -- **原因**:本仓看板在 `telemetry_search` 进程内,但商业授权由看板侧持有,本仓没有到期字段。写表是已确认合同。 -- **何时该重新考虑**:看板无法直连同一 MySQL/DM8,再补内部 HMAC upsert,复用同一 `upsert` 服务。 - -### 决策 4:读时按 `expire_date` 重算剩余天数和 `display_state` - -- **备选**: - - A. 展示源写入的 `days_remaining` / `display_state`。看板一天写一次时,剩余天数会停住。 - - B.(选定)有 `expire_date` 则按「今天」重算;没有则回退到存下来的 `days_remaining`。永久授权(无到期日且无剩余天数)→ `normal`,Banner 不出现。 -- **选定**:B -- **原因**:阈值是日期事实,不该取决于谁最后写了一次整数。 -- **何时该重新考虑**:产品改成「以源系统当时计算为准、不允许平台重算」。 - -### 决策 5:取源失败不把成功行改成 expired - -- **备选**: - - A. 失败则把该行 `display_state=unknown` 并清空到期日。一次 ETL 抖动,超管会看不到本仍有效或已过期的提醒。 - - B.(选定)失败只打日志,保留上次成功写入;从未成功则无行,聚合视为未取得。 -- **选定**:B -- **原因**:AC-05 禁止把未取得展示成已过期;也不该因为一次超时抹掉仍可用的到期日。 -- **何时该重新考虑**:产品要求「超过 N 小时未成功检查即视为未知并隐藏」,再加 `checked_at` 过期窗口。 - -### 决策 6:ETL 用 Beat 小时级刷新,聚合接口可补拉过期行 - -- **备选**: - - A. 只在超管打开 Banner 时拉 ETL。 - - B. 只靠 Beat。 - - C.(选定)Beat 每小时拉一次(部署级,不按租户循环);超管聚合查询时若 `etl` 行不存在或 `checked_at` 超过 1 小时,同步补拉,单源超时 2–3s。 -- **选定**:C -- **原因**:Banner 不轮询;只靠 Beat 时,刚配上 ETL 的环境可能要等一小时。补拉失败不得拖垮聚合。 -- **何时该重新考虑**:ETL 授权查询成为计费接口或明显变慢。 - -### 决策 7:Banner 可见人用平台超级管理员,不是 `role === "admin"` - -- **备选**: - - A. 维持 F037 的 `user.role === "admin"`。 - - B.(选定)前端闸门与聚合接口都用 `is_global_super`;非超管聚合返回空列表(200)。 -- **选定**:B -- **原因**:PRD 写的是平台超级管理员。`role === "admin"` 在多租户里可能把 Child Admin 算进去。空列表而不是 403,避免刷新刷出错误 Toast。 -- **何时该重新考虑**:产品明确要求子租户管理员也看全局授权。 - -### 决策 8:推翻 F037「前端只直连 Gateway 画 Banner」 - -- **备选**: - - A. 保留 `LicenseBanner` 只读 `/api/license/status`,另做 ETL / 看板。 - - B.(选定)Banner 改为:先(能拿到就)上报 Gateway,再读聚合接口画统一文案。`GET /api/license/status` 仍留给 Gateway 自己的降级链路,Banner 不再单独渲染它。 -- **选定**:B -- **原因**:F037 决策 1 成立的前提是「只有 Gateway 一项」。本期三项必须走同一文案和同一阈值。 -- **何时该重新考虑**:无。这是对本期目标的必要推翻。 - ---- - -## 4. 系统现状(接手必读) - -### 4.1 今天(缺口) - -``` -超管进入 platform MainLayout - → LicenseBanner 挂载(role === "admin") - → GET /api/license/status ← Gateway 自处理,BISHENG 不参与 - → severity ∈ {warning, critical, expired} 才渲染 - → 文案:「软件授权将在 N 天后到期 / 已过期」 - 黄 / 红分档,不写授权名,不写到期日 - -ETL:knowledges.etl4lm.url 只给 Etl4lmLoader POST 解析,无 license 查询 -看板:BISHENG_DASHBOARD_PRO 只是功能开关,telemetry_search 无到期字段 -``` - -锚点:`LicenseBanner.tsx`、`controllers/API/license.ts`、`MainLayout.tsx`、F037 `GET /api/license/status`。 - -### 4.2 改造后数据流 - -``` -写入① ETL - Beat 每小时 / 聚合补拉 - 剥 etl4lm.url → origin - GET {origin}/api/license_info - 映射 → upsert license_code='etl' - -写入② 看板服务 - 定期 UPDATE/INSERT license_code='dashboard' - BISHENG 不发起 - -写入③ Gateway - LicenseBanner 挂载 - GET /api/license/status(silent) - 有 data → POST /api/v1/commercial-licenses/gateway - 映射 → upsert license_code='gateway' - -读取 - GET /api/v1/commercial-licenses/status - 读 license_info(最多 3 行) - 按 expire_date 重算 days_remaining / display_state - 超管返回列表;非超管 [] - LicenseBanner 只渲染 expiring / expired -``` - -### 4.3 表与对外字段 - -**`license_info`(平台级,无 `tenant_id`)** - -| 列 | 类型 | 说明 | -|---|---|---| -| `license_code` | varchar(32) PK | `gateway` / `etl` / `dashboard` | -| `expire_date` | date NULL | 当地日期;永久为 NULL | -| `days_remaining` | int NULL | 源返回值,读时优先用到期日重算 | -| `display_state` | varchar(16) | 写入时带上;读时重算。`normal` / `expiring` / `expired` / `unknown` | -| `source_status` | varchar(32) NULL | Gateway `severity` 或 ETL `license_type` | -| `checked_at` | datetime NOT NULL | 最近一次成功写入 | -| `extra` | JSON | 源字段子集(`version`、`usable`、`expired`…),不含密文 | -| `create_time` / `update_time` | datetime | `update_time` 用统一 server default | - -一行一个模块。upsert 键 = `license_code`。看板不得改其它 code。 - -**读时映射** - -| 条件 | `display_state` | Banner | -|---|---|---| -| 无行;或到期日与剩余天数都空(永久 / 未取得) | `unknown` 或 `normal` | 隐藏 | -| 剩余 > 30 | `normal` | 隐藏 | -| 剩余 1–30 | `expiring` | 展示即将到期 | -| 剩余 ≤ 0 | `expired` | 展示已过期 | - -**ETL `GET /api/license_info` → 行** - -| ETL | 表 | -|---|---| -| `expiration_time`(Unix 秒) | `expire_date` | -| `remaining_days`(已到期为 0,永久为 null) | `days_remaining` | -| `license_type` | `source_status` | -| `expired` / `usable` / `disabled` / `license_type` | `extra` | -| `perpetual` / `execution` 或 `expiration_time=null` | 到期日空,`display_state=normal` | - -**Gateway `data` → 行** - -| Gateway | 表 | -|---|---| -| `expire_day` | `expire_date` | -| `days_remaining`(可为负) | `days_remaining` | -| `severity` | `source_status` | -| `version` / `expired` / `checked_at` | `extra` | -| `version=pro` 且无到期日 | `display_state=normal` | - -Gateway 请求/响应示例(已核实): - -```json -{ - "status_code": 200, - "data": { - "version": "trial", - "expire_day": "2026-07-02", - "days_remaining": -74, - "severity": "expired", - "expired": true, - "checked_at": "2026-09-14T10:00:00.025346068+08:00" - } -} -``` - -**聚合 `GET /api/v1/commercial-licenses/status`** - -```json -{ - "licenses": [ - { - "license_code": "etl", - "license_name": "etl", - "display_state": "expiring", - "expire_date": "2026-09-30", - "days_remaining": 16, - "checked_at": "2026-09-14T10:00:00+08:00" - } - ], - "checked_at": "2026-09-14T10:05:00+08:00" -} -``` - -`license_name` 与 code 相同,展示名走 i18n。未部署且无行的源不出现。 - -**上报 `POST /api/v1/commercial-licenses/gateway`** - -- 需登录且 `is_global_super` -- body = Gateway `data`(上表字段) -- 只 upsert `gateway` - -### 4.4 模块职责 - -| 模块 / 文件 | 职责 | 不做什么 | -|---|---|---| -| `bisheng/commercial_license/` 新领域模块 | 模型、upsert、ETL 同步、聚合、Gateway 上报 | 不解密、不拦业务、不写看板行 | -| Beat `refresh_etl_license` | 小时级拉 ETL | 不按租户循环 | -| 看板服务(仓外) | 写 `dashboard` 行 | 不改 `gateway` / `etl` | -| `LicenseBanner` + `license.ts` | 上报 Gateway → 读聚合 → 统一文案 | 不再按 Gateway severity 分色 | -| `MainLayout` | 仅 `is_global_super` 挂 Banner | — | -| Gateway 仓 | 保持 `/api/license/status` 与 11001 | 不负责 ETL / 看板 | - -建议目录: - -``` -commercial_license/ - api/endpoints/status.py - api/endpoints/gateway_report.py - domain/models/license_info.py - domain/repositories/... - domain/services/upsert.py - domain/services/aggregator.py - domain/services/etl_sync.py - domain/mappers.py -``` - -路由挂 `/api/v1/commercial-licenses`。 - -### 4.5 前端文案 - -统一样式(不再分黄/红)。多项用 `;` 连接,最后追加续期句。建议 key: - -```json -{ - "license": { - "name": { - "gateway": "Gateway 授权", - "etl": "ETL 授权", - "dashboard": "商业版看板授权" - }, - "expiring": "{{name}}将于 {{date}} 到期,剩余 {{days}} 天", - "expired": "{{name}}已过期", - "renewHint": "请联系授权提供方续期。", - "businessExpired": { - "gateway": "Gateway 授权已过期,当前无法使用该能力", - "etl": "ETL 授权已过期,当前无法解析该文件", - "dashboard": "商业版看板授权已过期,当前无法访问该看板" - } - } -} -``` - -去掉 `FORCE_DEBUG_VISIBLE`。11001 拦截器改为使用服务端文案或 `businessExpired.gateway`,不再写死「软件授权已过期」。`api_errors.11001` 源文件同步点名 Gateway(改 `packages/locales`,不手改 gen)。 - ---- - -## 5. 已知坑 / 反直觉事实 - -| # | 反直觉事实 | 如果不知道会怎样 | 在哪处理 | -|---|---|---|---| -| 1 | `etl4lm.url` 是 predict 全路径,不是 origin | 在后面拼 `/api/license_info` 会 404,`etl` 行永远不出现 | `etl_sync` 剥 origin | -| 2 | 租户过滤器会拦截所有带 `tenant_id` 的 SELECT | 给授权表加 `tenant_id` 后,子租户上下文读不到全局行 | 表不加该列 | -| 3 | F037 Banner 直连 Gateway,开源 404 | 继续只读 Gateway,ETL / 看板永远不上 Banner;开源可能打出噪声 | Banner 改聚合;Gateway 用 `silent` | -| 4 | platform 11001 拦截器丢弃 `status_message`,强制 `t('license.expired')` | Gateway 改了文案也仍显示「软件授权」 | 拦截器走点名文案 | -| 5 | ETL 已到期是 `remaining_days=0`,Gateway 可以是负数 | 按「只有负数才过期」会漏掉 ETL | `<= 0` 均为 expired | -| 6 | 看板若写错 `license_code` 会覆盖其它源 | Gateway 行被看板刷掉 | 文档钉死枚举;本仓 upsert 校验 code | -| 7 | Beat 默认按租户扫是本仓常见写法 | 授权被扫成「每个租户拉一次 ETL」 | 部署级一次,必要时 `bypass_tenant_filter` | -| 8 | `role === "admin"` ≠ 平台超管 | 子租户管理员看到全局授权 Banner | 决策 7 | -| 9 | 架构文档 11-gateway §9.1 仍写过期 `System.exit` | 按旧文档会以为进程已死、状态接口不存在 | 以 F037 + 现网 `/api/license/status` 为准 | - ---- - -## 6. 对外契约与依赖 - -### 6.1 我提供给别人的(Outgoing) - -| 契约 | 形式 | 谁在用 | -|---|---|---| -| `license_info` 表 | MySQL/DM8,一行一个 `license_code` | 看板服务写 `dashboard`;BISHENG 写 `etl` / `gateway` 并读聚合 | -| `GET /api/v1/commercial-licenses/status` | HTTP | platform `LicenseBanner` | -| `POST /api/v1/commercial-licenses/gateway` | HTTP,body = Gateway `data` | platform `LicenseBanner` | -| 错误码模块 270 | `common/errcode/`,实现时写入 constitution C5 | 上报校验失败等 | - -看板写表示例: - -```sql -INSERT INTO license_info - (license_code, expire_date, days_remaining, display_state, source_status, checked_at, extra) -VALUES - ('dashboard', '2026-09-30', 20, 'expiring', NULL, NOW(), '{}') -ON DUPLICATE KEY UPDATE - expire_date = VALUES(expire_date), - days_remaining = VALUES(days_remaining), - display_state = VALUES(display_state), - checked_at = VALUES(checked_at), - extra = VALUES(extra); -``` - -看板可以只写 `expire_date` + `checked_at`,其余由读时重算。 - -### 6.2 我依赖别人的(Incoming) - -| 依赖 | 形式 | 风险点 | -|---|---|---| -| Gateway `GET /api/license/status` | 前端经网关访问 | 字段改名会破坏上报映射;开源 404 必须静默 | -| ETL4LM 2.1.0-beta1 `GET /api/license_info` | BISHENG 服务端访问 | 旧 ETL 无此接口 → 失败不覆盖;url 必须能剥 origin | -| 看板服务定期写表 | 同库 upsert | 看板未上线则无 `dashboard` 行,属正常 | -| `knowledges.etl4lm.url` | DB/YAML 知识库配置 | 空 = 不拉 ETL | -| `UserPayload.is_global_super` | 登录身份 | 闸门不得退回 `role === "admin"` | - -`GET /api/license/status` 保留,供 Gateway 降级与本期上报;Banner 不再单独渲染它。 - ---- - -## 7. 测试与可观测 - -- **映射单测**:Gateway 31/30/1/0/-74;ETL `remaining_days=0`、永久 `expiration_time=null`;失败不覆盖已有行;剥 origin。 -- **API**:超管看到临期 / 过期项;非超管 `licenses=[]`;无行不出现对应 code。 -- **前端**:单项 / 多项文案;unknown 不展示;开源无 Banner、无 Toast。 -- **手动**:构造 Gateway trial 过期 → 上报后 Banner 写「Gateway 授权已过期」;mock ETL `remaining_days=16` → 出现 ETL 即将到期句;向表写入 `dashboard` 过期行 → 同一 Banner 逐项列出;停 ETL 再刷,不得变成「ETL 已过期」(除非上次到期日已过)。 -- **日志**:ETL 拉取失败、上报非法 code、剥 origin 失败打 INFO/WARNING,带 `license_code`,不含密文。 - ---- - -## 8. 后续改进 / 不打算做的事 - -- 看板无法直连库时的内部 HMAC upsert:等部署形态出现再补,复用 `upsert.py`。 -- `checked_at` 过期窗口(久未检查视为 unknown):本期按决策 5 保留上次成功行。 -- 订阅信息服务授权、续期入口、邮件 / 飞书、看板 / ETL 新降级:PRD 排除。 -- 后端反调 Gateway:与决策 2 相反,除非出现无浏览器刷新需求。 - ---- - -## 修订历史 - -| 日期 | 改动 | 触发原因 | -|---|---|---| -| 2026-09-14 | 初版 | 《3.0 beta2》§5.3 + 用户裁定:`license_info` 表、ETL 拉接口写入、看板写表、Gateway 前端上报、后端聚合查询 | diff --git a/features/v3.0.0-beta1/067-commercial-license-expiry-reminder/spec.md b/features/v3.0.0-beta1/067-commercial-license-expiry-reminder/spec.md deleted file mode 100644 index b90d68ef56..0000000000 --- a/features/v3.0.0-beta1/067-commercial-license-expiry-reminder/spec.md +++ /dev/null @@ -1,116 +0,0 @@ -# Feature: 商业授权统一到期提醒(F067) - -> **本文档定位 — 纯 What(需求口径,不随代码漂移)** -> -> spec 只回答 **做什么 / 验收标准 / 不做什么**。 -> **所有 How 一律不写在这里。** How 的唯一真相在 [design.md](./design.md)。 - -**关联 PRD**: 《3.0 beta2》§5.3 统计看板/ETL到期提醒 -**优先级**: P1 -**所属版本**: v3.0.0-beta1 -**依赖**: v2.6.0 F037(Gateway 状态接口与管理后台 Banner 入口);ETL4LM 2.1.0-beta1 授权查询能力;商业看板服务写入对应授权记录。无本版本未交付 Feature 前置。 - -> **范围边界** -> - **本次纳入**: -> - 平台统一提醒 Gateway 授权、ETL 授权、商业版看板授权;提示中写明授权名称、到期日期和剩余天数。 -> - 各商业模块的当前授权状态持久保存,每个模块至多一条当前记录(已裁定:`license_info`)。 -> - ETL 授权由平台向 ETL 授权查询接口拉取后写入上述记录。 -> - 商业看板授权由看板服务定期更新对应记录;平台不向看板拉授权。 -> - Gateway 授权由管理后台先读取 Gateway 状态接口,再交给平台写入对应记录;平台不反向调用 Gateway。 -> - 管理后台从平台聚合查询读取全部授权状态,供超管 Banner 使用。 -> - **本次明确排除**: -> - 订阅信息服务授权(PRD 本期不纳入)。 -> - 在线续期、授权文件上传、邮件或飞书通知。 -> - 授权过期后的新功能降级(Gateway 既有付费接口降级保持不动;不新增看板 / ETL 过期拦截)。 -> - 用 ETL 服务地址、`BISHENG_DASHBOARD_PRO` 等配置推断授权是否有效或何时到期(它们只表示装没装)。 -> - 开源版(无对应商业模块)展示到期 Banner。 - ---- - -## 1. 用户故事 - -作为 **平台超级管理员**, -我希望 **Gateway、ETL、商业版看板任一授权临近到期或已过期时,在管理后台顶部看到写明名称、到期日和剩余天数的提醒**, -以便 **立刻知道该续哪一项,而不是对着「软件授权」猜测**。 - -作为 **普通用户**, -我希望 **使用因授权过期而受限的能力时,错误指出具体是哪一项授权**, -以便 **把问题交给管理员续对的那一项,而不是当成系统故障**。 - ---- - -## 2. 验收标准 - -> AC-ID 在本特性内唯一。字段名 / 表结构 / 接口路径等实现细节见 design.md。 - -### 2.1 提醒规则与可见性 - -- **AC-01** — THE SYSTEM SHALL 只把 Gateway 授权、ETL 授权、商业版看板授权纳入本期统一提醒;不得把订阅信息服务授权纳入。 -- **AC-02** — WHERE 某项授权剩余天数大于 30, THE SYSTEM SHALL 不为该项展示到期提醒。 -- **AC-03** — WHERE 某项授权剩余天数为 1 至 30(含 1 与 30), THE SYSTEM SHALL 为该项展示即将到期提醒,并写明授权名称、到期日期和剩余天数。 -- **AC-04** — WHERE 某项授权剩余天数小于或等于 0, THE SYSTEM SHALL 继续展示同一条提醒,文案明确为该项「已过期」,并写明授权名称。 -- **AC-05** — IF 某项授权的状态未能取得, THEN THE SYSTEM SHALL 不得把该项展示为已过期,也不得用功能开关推断它已过期或仍有效。 -- **AC-06** — WHEN 平台超级管理员登录或刷新管理后台, THE SYSTEM SHALL 获取一次聚合授权状态;页面持续打开期间不得轮询。 -- **AC-07** — WHEN 非平台超级管理员进入管理后台, THE SYSTEM SHALL 不展示到期提醒 Banner。 -- **AC-08** — THE SYSTEM SHALL 不按剩余天数区分黄色预警、红色紧急等展示级别;即将到期与已过期使用同一种 Banner 样式。 -- **AC-09** — THE SYSTEM SHALL 不提供关闭 Banner 的入口;Banner 在当前页面会话中持续展示。 - -### 2.2 文案 - -- **AC-10** — WHEN 仅有一项授权需要处理, THE SYSTEM SHALL 直接展示完整信息,语义等价于:「{授权名称}将于 {到期日期} 到期,剩余 {N} 天。请联系授权提供方续期。」或该项已过期的对应完整句。 -- **AC-11** — WHEN 多项授权同时需要处理, THE SYSTEM SHALL 在同一条 Banner 中逐项写明授权名称和状态,不得只展示汇总数量。 -- **AC-12** — THE SYSTEM SHALL 使 Banner 与业务错误中的授权名称使用业务用词(Gateway 授权 / ETL 授权 / 商业版看板授权),不得再使用「软件授权」这类无法区分对象的通用文案。 -- **AC-13** — THE SYSTEM SHALL 为新增用户可见文案提供 zh-Hans / en / ja,且不在源码中硬编码中文。 - -### 2.3 状态来源与持久化 - -- **AC-14** — THE SYSTEM SHALL 为每个纳入本期的商业模块持久保存至多一条当前授权状态;聚合提醒只读取这些已保存的标准状态。 -- **AC-15** — THE SYSTEM SHALL 不接收、不存储授权密文、私钥或设备指纹。 -- **AC-16** — WHERE ETL 解析服务已配置, THE SYSTEM SHALL 通过 ETL 提供的授权查询接口取得状态并写入对应记录;IF 该接口失败或未配置 ETL, THEN THE SYSTEM SHALL 按 AC-05 处理 ETL 项,且不得覆盖为已过期。 -- **AC-17** — WHERE 商业看板服务会更新对应记录, THE SYSTEM SHALL 在聚合时读取该记录,不得由平台向看板服务拉取授权。 -- **AC-18** — WHEN 管理后台能够从 Gateway 状态接口取得授权数据, THE SYSTEM SHALL 由平台把该数据写入 Gateway 对应记录后再纳入聚合;IF Gateway 状态接口不可达(含开源部署), THEN THE SYSTEM SHALL 按 AC-05 处理 Gateway 项。 -- **AC-19** — WHEN 平台超级管理员请求聚合授权状态, THE SYSTEM SHALL 返回各项的授权标识、展示用授权名、展示状态、到期日期、剩余天数和最近检查时间;展示状态只允许「正常 / 即将到期 / 已过期 / 未取得」四档,不得再下发预警、紧急等展示等级。 -- **AC-20** — WHEN 非平台超级管理员请求聚合授权状态, THE SYSTEM SHALL 不返回可供展示的到期项(不得 403 打断页面)。 - -### 2.4 业务错误点名 - -- **AC-21** — WHEN 普通用户因某项商业授权过期而无法使用对应能力, THE SYSTEM SHALL 使业务错误指出该具体授权(例如「商业版看板授权已过期,当前无法访问该看板」),并携带可区分授权对象的标识。 -- **AC-22** — THE SYSTEM SHALL 不在本期新增看板或 ETL 过期后的功能拦截;仅当既有能力已经拒绝时,按 AC-21 点名。Gateway 既有过期拦截保持有效,但文案须满足 AC-12。 - -### 2.5 回归 - -- **AC-23** — WHERE 开源部署没有 Gateway、未配置 ETL、也没有商业看板授权记录, THE SYSTEM SHALL 不展示到期 Banner,且不因状态接口不可达而弹出错误。 -- **AC-24** — THE SYSTEM SHALL 保持 Gateway 既有「只拦网关付费接口、透传平台核心接口」的降级范围不变。 - ---- - -## 3. 边界情况 - -- 永久有效的授权(无到期日):不展示到期提醒。 -- 某源从未成功写入过记录:该项视为未取得,不展示。 -- 某源本次拉取失败、但上次成功写入过到期日:不得改写成已过期;提醒是否出现以可读的到期信息为准,见 design.md。 -- ETL 已到期时剩余天数为 0;Gateway 剩余天数可为负数。两者都按 AC-04 展示已过期。 -- 只部署了其中一部分商业模块:只提醒已部署且状态已取得、且落入 30 天阈值的项。 -- 跨过 30 天阈值后,须等下一次登录或刷新才出现或消失 Banner(AC-06)。 -- 子租户管理员不是平台超级管理员:不展示 Banner(AC-07)。 - ---- - -## 4. 设计与实现(指针,不复制) - -| 你想知道 | 去哪看 | -|---|---| -| 为什么用表做真相、三路写入怎么拆 | design.md §3 | -| 表结构、接口、字段映射、Beat | design.md §4、§6 | -| ETL 地址带 predict 路径、租户过滤、失败不覆盖 | design.md §5 | -| 任务拆解 | [tasks.md](./tasks.md) | - ---- - -## 相关文档 - -- 设计真相: [design.md](./design.md) -- 版本契约: [../release-contract.md](../release-contract.md) -- PRD: 《3.0 beta2》§5.3 -- ETL 授权查询: ETL4LM 2.1.0-beta1 接口文档 §7 `GET /api/license_info` -- 现状基线: v2.6.0 [F037 Gateway 到期提醒](../../v2.6.0/037-gateway-license-expiry-reminder/design.md) diff --git a/features/v3.0.0-beta1/067-unified-remote-mcp-service/design.md b/features/v3.0.0-beta1/067-unified-remote-mcp-service/design.md new file mode 100644 index 0000000000..dece9bd120 --- /dev/null +++ b/features/v3.0.0-beta1/067-unified-remote-mcp-service/design.md @@ -0,0 +1,371 @@ +# Design: F067 通用开放能力的统一远程 MCP 服务 + +> **本文档定位 — 现状快照(Why this How)** +> +> - [spec.md](./spec.md) 定义做什么和验收边界。 +> - 本文定义统一 MCP 服务为什么采用当前方案、现有系统基线、对外合同与实现护栏。 +> - `tasks.md` 在 Design ★ 确认后创建,记录实施顺序与实际偏差。 + +**关联**: [spec.md](./spec.md) · [discovery.md](./discovery.md) · [tool-contracts.md](./tool-contracts.md) · [release-contract.md](../release-contract.md) +**版本**: v3.0.0-beta1 +**最后更新**: 2026-09-18 +**状态**: ✅ Design 评审问题已整改并于 2026-09-18 确认;已生成 `tasks.md` + +--- + +## 1. 目标与非目标 + +- **目标**:在每个 BISHENG 部署内提供一个统一、远程、可发现的 MCP 服务,把[中粮 SeedMind Apifox 文档](https://s.apifox.cn/a0e36780-865a-4179-b7df-b51a01d5ebcc)当前列出的 10 项知识资源/文件 HTTP API 一对一转换为稳定工具;MCP 与开放 API 共用身份、权限、业务服务、业务结果语义和审计,协议形态差异只在 MCP 兼容层处理。 +- **非目标**:不把现有 `mcp_manage` 客户端改造成服务端,不新增业务能力或密钥体系,不为每个工具复制一套业务实现,不依赖单机内存维持授权或业务会话,也不替换现有 `/api/v2`。 + +--- + +## 2. 关键约束与 Constitution Check + +- 遵循 [docs/constitution.md](../../../docs/constitution.md) C1–C8;MCP 调用链保持 `MCP Transport → Compatibility Facade → Domain/Application Service → Repository → DB`,权限仍只经 F048,跨副本状态不落本地文件或进程内会话。 +- 遵守版本契约 **INV-29~35**:MCP 只能使用 F053/F066 已签发凭据与 S/D/PAT 规则;PAT 数据范围优先于管理员短路;身份或权限不可判定时失败关闭。 +- 锁文件当前解析 `mcp==1.27.1`,实施前必须按[官方安全公告 GHSA-jpw9-pfvf-9f58](https://github.com/modelcontextprotocol/python-sdk/security/advisories/GHSA-jpw9-pfvf-9f58)升级到已修复会话鉴权问题的 `mcp>=1.27.2` 并重新锁定。服务端只使用 Streamable HTTP 与低层 Server/会话管理能力;升级 SDK 必须重跑协议与多副本回归。 +- 一个部署只有一个逻辑 MCP 地址。后端可以有多个无状态副本,但不得要求客户端固定到某个副本。 +- MCP 工具参数是 JSON;认证信息只来自 HTTP 请求头。文件类工具不得接收服务器本地路径,避免把节点文件系统变成外部合同。 +- 本期范围是独立的 10 项 API allowlist,不是 F053 全量开放路由。Apifox 后续新增接口、`OPEN_API_SCOPES` 其他路由或任何 WS/SSE 能力都不会自动扩入 F067。 +- 本期 10 项 API 全部为普通 HTTP/JSON 业务操作(文件上传需转换 multipart),不需要对原始 SSE 或 WebSocket 业务协议做 MCP 适配。 +- F067 不新增数据库表、Alembic revision 或业务错误码段;协议错误使用 MCP/JSON-RPC 标准错误,工具执行错误按 MCP `isError` 结果返回明确 `code/message`。已有稳定业务码时复用业务码,但不把 HTTP 状态或 API 错误信封带入 MCP 工具输出。 +- 现有 10 项 `/api/v2` API 的接口逻辑、校验与分支、调用顺序、副作用、路由、参数位置、字段、默认值、响应字段、错误信封和 HTTP 状态是只读兼容基线;F067 不得修改它们。不能原样用于 MCP 的协议形态由 MCP 工具 schema 和 compatibility adapter 单向适配。 + +### Constitution Check + +| 条款 | 结论 | 设计落实 | +|---|---|---| +| C1 分层 | 通过 | MCP compatibility facade 只调用既有业务 Service;MCP transport 不直接查 ORM,现有 HTTP endpoint 无须为 F067 改合同 | +| C2 双 DB | 通过 | 无新 schema;共享业务能力继续走既有 MySQL/DM8 兼容路径 | +| C3 多租户 | 通过 | 每次请求从凭据设置 tenant ContextVar,结束时按 token 恢复 | +| C4 权限 | 通过 | 复用 `current_permission_actor` 与业务 Service 的 F048 action 检查,不直连 OpenFGA | +| C5 错误码 | 通过 | 不占新业务码模块;已有业务失败复用业务码,MCP 专有失败使用稳定符号码,协议层使用标准 JSON-RPC 码 | +| C6 密钥 | 通过 | 文档只写占位符;日志、审计、结果不记录 Authorization 或文件内容 | +| C7 前端 store | 不涉及 | 本 Feature 无站内前端状态改造 | +| C8 多节点 | 通过 | Streamable HTTP 无状态;业务会话仍在 DB/Redis/对象存储,不以进程内 MCP session 为真相 | + +--- + +## 3. 方案对比与选定 + +### 决策 1:服务部署形态与传输 + +- **备选**: + - A. 新建独立 MCP 容器和端口——可独立扩缩,但需要复制配置、生命周期、鉴权上下文和商业网关路由。 + - B. 挂载到现有 FastAPI 应用,采用有状态 Streamable HTTP——接入简单,但 MCP session 要求副本亲和或共享 session store。 + - C. 挂载到现有 FastAPI 应用,采用无状态 Streamable HTTP——复用部署入口且不要求副本亲和。 +- **选定**:C。对外地址固定为 `$BASE/api/v2/mcp`,与 `/api/v2` 共享后端端口和商业网关入口;每个 HTTP 请求重新认证,MCP transport session 不承载业务授权状态。 +- **原因**:当前业务能力、凭据 ContextVar、审计 flusher 与应用生命周期都在 `bisheng.main`;C 同时满足“一个服务地址”和 C8 多副本要求。A 会制造第二套运行面,B 会把进程内 session 变成隐式基础设施依赖。 +- **何时该重新考虑**:MCP 流量需要独立 SLA/扩缩容,或 SDK 后续协议能力明确要求服务端持久 session 时,再拆独立进程;拆分前必须先提供共享 Redis/DB session 真相与同一身份上下文。 + +### 决策 2:业务能力复用方式 + +- **备选**: + - A. 从 OpenAPI schema 自动生成工具并通过本机 HTTP 回调 `/api/v2`——初始代码少,但 multipart、二进制、SSE、WS 和错误信封无法可靠自动转换;还会产生递归 ASGI/网络调用与重复审计。 + - B. MCP handler 直接复制每个 endpoint 的业务调用——交付快,但 10 项能力仍会形成两套参数校验、权限与错误分支。 + - C. 建立显式 `OpenCapabilityRegistry` 与 MCP compatibility facade,由 facade 把 MCP 参数转换后调用既有业务 Service,并把业务结果/异常转换为 MCP 合同;现有 HTTP endpoint 的接口逻辑和编码路径保持不变。 +- **选定**:C。每项能力显式登记 `tool_name / source_routes / scope / modes / input_model / handler / result_kind`。F067 先冻结现有 API 行为快照,再新增 MCP facade/mapper;现有 endpoint 不因 F067 重写校验、分支、调用顺序、响应或异常处理。 +- **原因**:`OPEN_API_SCOPES` 可提供这 10 项路由的 scope 基线,但它不是 F067 的范围真相;F067 必须以显式 10 项 allowlist 进行注册和差集测试。MCP 与 HTTP 的输入载体和结果包装本来就不同,单向 compatibility facade 能复用既有业务 Service,同时避免为追求线缆级一致而改变已发布 API。公共复用优先使用现有 application/domain Service;确有重复业务编排时,只允许抽取最小、传输无关且无协议包装的公共 capability,并以 API 行为快照证明抽取前后零差异。MCP 参数、结果和错误的协议适配始终留在 MCP 层。 +- **何时该重新考虑**:正式 OpenAPI 文档具备稳定、完整的 operation id、文件/流式扩展和 MCP 映射元数据,并能生成等价契约测试时,可考虑代码生成;在此之前不以自动生成替代人工能力声明。 + +### 决策 3:认证分两阶段、复用 F053 上下文 + +- **备选**: + - A. 使用 MCP SDK 自带 token verifier,独立实现 scope 与委托——会形成第二套凭据解释和缓存失效规则。 + - B. 连接初始化时认证一次,后续工具调用信任 MCP session——撤销、过期和权限变化不能按既有上界影响长连接。 + - C. 抽取 F053 现有认证上下文:每个 transport 请求完成凭据与身份准入;工具发现按 registry 过滤;每次 `call_tool` 再按工具声明校验 scope/mode 并安装 permission actor。 +- **选定**:C。`verify_open_api_access` 与 MCP auth adapter 共用一个 `OpenApiAccessContext` 服务,不共享 FastAPI endpoint marker 这一 transport 细节。 +- **原因**:现有 `verify_open_api_access` 已处理凭据、PAT 两层开关与数据范围、S/D、特权目标、tenant/visible tenant 以及 permission actor;只复用底层服务才能保持 INV-35 且支持 MCP 的“同一路径、不同工具 scope”。 +- **何时该重新考虑**:平台正式引入 OAuth/OIDC MCP authorization server 时,允许替换 transport 凭据交换;交换后的 principal、scope、租户、委托和 F048 执行合同仍不得分叉。 + +### 决策 4:工具粒度与 allowlist 治理 + +- **备选**: + - A. 一个万能 `call_api(method, path, body)` 工具——工具数量少,但模型可以构造任意路径,无法可靠做发现过滤和参数描述。 + - B. 按业务组提供少数巨型工具,以 `action` 字段分支——schema 宽泛,权限和必填项难以静态表达。 + - C. Apifox allowlist 每个 API 业务操作对应一个稳定工具,不自动暴露其他开放 API。 +- **选定**:C。工具名和完整 allowlist 见 §4.3;本期恰好 10 个工具。 +- **原因**:用户已明确限定本次需求的 Apifox 接口范围。一对一映射能让参数、权限、副作用和覆盖测试都可精确表达。 +- **何时该重新考虑**:需求方明确要求扩展 Apifox allowlist 时,先重新确认 spec/design,再增加工具;不以 API 自身新增作为自动扩围信号。 + +### 决策 5:结果、错误与文件合同 + +- **备选**: + - A. 所有结果转成自然语言文本——模型易读,但会丢字段、错误码和分页合同。 + - B. 只返回原 API JSON 字符串——字段完整,但调用方无法按 schema 校验业务结果和错误状态。 + - C. 成功返回逐工具结构化结果,失败使用 MCP tool error result;上传用受限 base64 文件对象或原 API 已允许的 `file_url`。 +- **选定**:C。成功对象直接写入 `structuredContent` 并符合逐工具 `outputSchema`;失败置 `isError=true`、不设置 `structuredContent`,模型可见 JSON 明确返回 `error.code/error.message`。工具参数禁止 `file_path`、`user_id` 和认证字段。 +- **原因**:[MCP Tools 规范](https://modelcontextprotocol.io/specification/2025-11-25/server/tools)已区分成功结构化结果、工具执行错误与 JSON-RPC 协议错误,无需复制 HTTP `status_code/http_status`。保留稳定业务错误码和安全消息即可让调用方判定失败;base64/URL 适配避免读取任意节点路径并满足 C8/C6。 +- **何时该重新考虑**:平台提供带同凭据授权的对象存储 resource server 后,大文件上传可改用短期 MCP ResourceLink;在此之前不得接受裸 MinIO 路径或节点文件路径。 + +### 决策 6:覆盖守卫与审计粒度 + +- **备选**: + - A. 只维护文档清单——代码演进后容易静默漏工具。 + - B. 每个 MCP HTTP 请求复用现有 `OpenApiAuditMiddleware`——一个请求可能包含 discovery 或多个 JSON-RPC 消息,无法保证逐工具审计且会把路径当成业务目标。 + - C. 构建期/测试期比较 registry 与 F067 显式 10 项 allowlist,运行期由每个工具 handler 显式复用 `OpenApiCallAuditService` 记一条调用审计。 +- **选定**:C。`/api/v2/mcp` 从普通 v2 endpoint 审计中排除,避免 transport 与 tool 双记;认证失败仍记 MCP transport 拒绝,成功/业务失败按工具逐条记录。 +- **原因**:以全量 `OPEN_API_SCOPES` 作为期望集合会错误地扩大范围;显式 allowlist 的双向差集既能阻止漏映射,也能阻止额外暴露。逐工具审计才具有 actor/subject/tool/scope/result 的业务意义。 +- **何时该重新考虑**:如果 MCP SDK 禁止 JSON-RPC batch,可简化“一请求多调用”分支,但仍保留逐工具审计;若审计服务改为统一事件总线,只替换 sink,不改变字段合同。 + +--- + +## 4. 系统现状与目标结构(接手必读) + +### 4.1 当前基线 + +- `bisheng/api/router.py` 把正式开放路由挂在 `/api/v2`,统一依赖 `verify_open_api_access`。 +- 中粮 SeedMind Apifox 文档当前列出 10 个 `/api/v2` 知识资源/文件 API;这 10 项是 F067 唯一范围基线。 +- `bisheng/open_api/domain/scopes.py` 的 `OPEN_API_SCOPES` 登记了更多业务 route;F067 只从中取用 allowlist 对应路由的 scope,不把全量 registry 当作 MCP 覆盖集合。 +- `bisheng/open_api/api/dependencies.py:verify_open_api_access` 同时安装 tenant、visible tenant、`OpenApiPrincipal` 与 `PermissionActor`,并在请求结束恢复 ContextVar。 +- `bisheng/open_api/api/middleware.py` 按 HTTP/WS 请求写 `open_api.call` 审计;ContextVar 的 principal 也复制到 ASGI `scope`,避免外层中间件读不到子任务上下文。 +- `bisheng/open_endpoints/api/endpoints/` 的各 endpoint 目前直接编排业务 Service;它们的对外参数、响应与异常编码是 F067 的冻结基线。MCP facade 可以调用同一批既有业务 Service,但不要求先改造 endpoint。 +- `bisheng/mcp_manage/` 只负责 BISHENG 作为客户端连接第三方 MCP(stdio/SSE/Streamable HTTP)并转为 LangChain Tool;它不是服务端模块。 + +### 4.2 目标数据流 + +**工具发现**: + +`POST /api/v2/mcp (initialize/tools/list)` → `OpenApiAccessContext` 每请求认证并解析 S/D/PAT → `OpenCapabilityRegistry.list_for(principal)` 按 scope/mode 过滤 → MCP SDK 返回当前可用工具定义。 + +**工具执行**: + +`tools/call` → 重新认证 → registry 按 tool name 取得声明 → 校验 scope/mode/输入 → 安装 tenant/principal/permission actor → `McpCompatibilityFacade` 重建既有业务调用参数 → 既有业务 Service/F048 → `McpResultAdapter` 显式映射为 MCP 输出 → 逐工具审计 → finally 恢复全部 ContextVar。 + +**异步业务**: + +`McpCompatibilityFacade` → 由 F053 `OpenApiExecutionSnapshot` 固化最小主体 → Celery/后台执行边界重新校验凭据与 PAT 数据范围 → 业务结果回到当前工具调用或既有 session/result 查询工具。MCP 不把完整凭据写入任务载荷。 + +### 4.3 工具 allowlist 与 source route + +工具名是 F067 对外稳定合同;本表是范围 SSOT。完整顶层入参与出参见 [tool-contracts.md](./tool-contracts.md),并经 §4.4 转换为 MCP JSON Schema。`source route` 用于覆盖测试,不表示 MCP 内部发起 HTTP 请求。 + +| # | 业务能力 | scope | modes | PAT | MCP 工具 | source route | +|---:|---|---|---|---|---|---| +| 1 | 查询知识资源列表 | `knowledge:read` | S/D | 是 | `bisheng_knowledge_list` | `GET /api/v2/filelib/` | +| 2 | 创建知识资源 | `knowledge:write` | S/D | 否 | `bisheng_knowledge_create` | `POST /api/v2/filelib/` | +| 3 | 更新知识资源 | `knowledge:write` | S/D | 否 | `bisheng_knowledge_update` | `PUT /api/v2/filelib/` | +| 4 | 删除知识资源 | `knowledge:write` | S/D | 否 | `bisheng_knowledge_delete` | `DELETE /api/v2/filelib/{knowledge_id}` | +| 5 | 清空知识资源内容 | `knowledge:write` | S/D | 否 | `bisheng_knowledge_clear` | `DELETE /api/v2/filelib/clear/{knowledge_id}` | +| 6 | 检索知识资源分段 | `knowledge:read` | S/D | 是 | `bisheng_knowledge_retrieve` | `POST /api/v2/filelib/retrieve` | +| 7 | 上传文件到知识资源 | `knowledge:write` | S/D | 否 | `bisheng_knowledge_file_upload` | `POST /api/v2/filelib/file/{knowledge_id}` | +| 8 | 查询文件列表 | `knowledge:read` | S/D | 是 | `bisheng_knowledge_file_list` | `GET /api/v2/filelib/file/list` | +| 9 | 删除文件 | `knowledge:write` | S/D | 否 | `bisheng_knowledge_file_delete` | `DELETE /api/v2/filelib/file/{file_id}` | +| 10 | 批量删除文件 | `knowledge:write` | S/D | 否 | `bisheng_knowledge_files_delete` | `POST /api/v2/filelib/delete_file` | + +### 4.4 对外数据合同 + +#### 请求头 + +```text +Authorization: Bearer # 必填 +X-On-Behalf-Of: # D 模式传第三方用户唯一 ID;不进入 tool arguments +X-End-User: # 可选,仅沿用 F053 会话分区语义 +``` + +MCP SDK 所需的协议头由 SDK 处理;服务端不从 MCP session id 推导身份。客户端每次请求都必须发送认证头。 + +#### 工具输入 + +- 每个 registry 项必须显式声明 `title / description / input_model / output_model / scope / modes / annotations`;`tools/list` 输出的 `inputSchema` 和 `outputSchema` 由这些模型生成,不返回无字段说明的空泛 schema。10 个工具的 `readOnlyHint / destructiveHint / idempotentHint / openWorldHint` 固定值见 [tool-contracts.md §2.3](./tool-contracts.md#23-工具行为-annotations),annotations 只影响客户端风险提示,不替代服务端授权。 +- path/query/body 业务字段扁平为一个 JSON object,字段名、类型、必填项、默认值、枚举和上限以中粮 SeedMind Apifox 合同为对外基线,并由对应 Pydantic model 落地。 +- Apifox 中的 `X-On-Behalf-Of` / `X-End-User` 只作为 MCP HTTP 请求头,不进入 tool arguments。`user_id`、`tenant_id`、`on_behalf_of`、`authorization`、服务器 `file_path` 也不进入任何工具 schema;业务主体只由受信任认证上下文确定。 +- `bisheng_knowledge_files_delete` 将原 API 的顶层 `array` 包装为 MCP object `{"file_ids": integer[]}`,避免对输入字段产生多种实现。 +- `bisheng_knowledge_file_upload` 将 multipart `file` 转换为 `{"file_name": string, "mime_type": string|null, "content_base64": string}`;原 API 支持的 `file_url` 与 `file` 二选一。MCP 必须先校验目标资源/父目录的写权限与租户边界,通过后才允许解码 base64 或发起 URL 下载;下载结束后既有业务 Service 仍再次鉴权以关闭权限变化窗口。base64 与 URL 路径必须经过 [tool-contracts.md §2.2](./tool-contracts.md#22-上传入口限制) 的 body/编码长度、流式落盘、目标地址、重定向、下载大小与清理限制,再进入原 API 的文件类型、单文件大小、角色总配额和内容安全检查。 +- 实施时必须从 registry 生成并提交对外工具文档,每个工具包含用途、完整输入/输出 schema、成功示例与主要错误示例;生成结果必须与 [tool-contracts.md](./tool-contracts.md) 和 Apifox 合同一致并纳入契约测试。 + +#### 工具结果 + +```json +{ + "data": [], + "page_size": 10, + "has_more": false, + "next_cursor": null +} +``` + +- 普通成功:逐工具成功 DTO 直接放 `structuredContent`,并必须符合该工具的 `outputSchema`;同时提供内容等价的 JSON `TextContent` 作为不支持 structured output 的客户端回退。MCP 不复制 API 的 `status_code/status_message/data` 信封,也不新增 `ok/http_status`。 +- 空成功:删除、清空等 API 数据为空的操作返回 `{"success": true}`,使 MCP `outputSchema` 保持 object 根类型。 +- 工具执行失败:返回 `isError=true`,不设置 `structuredContent`;模型可见 `TextContent` 使用 JSON `{"error":{"code":...,"message":"..."}}`,确保错误码和错误信息均明确可读。 +- 无效 JSON-RPC 消息、未知方法或无法形成工具调用的协议错误:使用 MCP/JSON-RPC 标准错误且不执行业务 Service;进入 `tools/call` 后的业务参数/能力执行失败按上一条 tool error result 处理。 +- 本期 10 项工具的业务结果均为 JSON,不定义二进制下载、SSE 或 WebSocket 结果适配。 +- `structuredContent` 是 MCP 自身的成功输出合同,不要求现有 API 原始 JSON 直接通过 `outputSchema`;字段映射、联合类型和结果包装必须按 [tool-contracts.md §2.4](./tool-contracts.md#24-api--mcp-单向兼容规则) 显式实现,且不得反向要求 API 增删字段。`actions` 与结构化 `TagItem[]` 可被 MCP 原样表达,不做 `actions → permission_ids` 或 `TagItem → name` 转换。 + +#### 错误归一化合同 + +现有 API 继续使用当前 FastAPI 校验、`HTTPException`、`BaseErrorCode` 与全局异常处理链路,F067 不改变其任何接口逻辑、异常类型、错误信封或 HTTP 状态。MCP 专用 `McpCompatibilityAdapter` 负责把调用公共业务能力时得到的异常归一化为 `McpErrorPayload(code, message)`,再编码为 MCP 结果: + +| 失败来源 | 现有 API 行为(冻结) | MCP adapter 映射 | 是否执行业务 Service | +|---|---|---|---| +| 缺失/无效/过期凭据,S/D/PAT transport 准入失败 | F053 既有 401/403/503 信封 | Streamable HTTP 401/403/503,不生成成功 JSON-RPC result | 否 | +| 无效 JSON-RPC、未知方法、未知工具、arguments 非 object | source API 不涉及此协议错误,其既有请求校验保持不变 | MCP/JSON-RPC 标准 error response | 否 | +| arguments 是 object 但 MCP `inputSchema` 校验不通过 | source API 不涉及此工具错误,其既有请求校验保持不变 | `isError=true`,`code=INVALID_ARGUMENT` 或 SDK 的等价稳定码,并返回可操作的安全 `message` | 否 | +| scope/mode 不允许 | source API 继续走既有 scope/mode 校验与错误编码 | `isError=true`;已有稳定业务码时复用,否则使用 `PERMISSION_DENIED`,并返回安全 `message` | 否 | +| 公共业务能力抛 `BaseErrorCode` | 当前全局异常处理保持不变 | `isError=true`,`code` 使用既有业务码,`message` 使用允许公开的业务消息 | 是 | +| 公共业务能力抛 `HTTPException` | 当前全局异常处理保持不变 | 提取允许公开的 detail,映射为稳定 MCP 符号码与 `message`;不返回 HTTP status 字段,不泄漏 header、堆栈或路径 | 是 | +| 未预期服务端异常 | 当前 500 处理保持不变 | 标准 JSON-RPC Internal Error(`code=-32603`、固定安全 `message`);真实异常只进入脱敏日志 | 可能 | + +MCP 专有符号码只在没有既有业务码时使用,固定映射如下;映射所依据的 HTTP status 只用于 adapter 内部分支,不进入 MCP 输出: + +| MCP code | 适用情况 | +|---|---| +| `INVALID_ARGUMENT` | 工具 `inputSchema` 或业务前置参数校验失败 | +| `UNAUTHENTICATED` | 已进入 MCP 调用链后发现主体凭据上下文无效;transport 前置认证失败仍走 HTTP 401 | +| `PERMISSION_DENIED` | scope、mode、数据范围或资源授权拒绝,且没有既有业务码 | +| `NOT_FOUND` | 目标业务资源不存在,且没有既有业务码 | +| `CONFLICT` | 当前业务状态与请求冲突,且没有既有业务码 | +| `RATE_LIMITED` | MCP 工具调用被限流,且没有既有业务码 | +| `DEPENDENCY_UNAVAILABLE` | 可预期的下游依赖不可用或超时,且没有既有业务码 | +| `TOOL_EXECUTION_FAILED` | 其它已预期、允许向调用方公开的工具执行失败 | + +所有 tool error result 均不设置 `structuredContent`;其 JSON `TextContent` 至少包含非空 `error.code` 与 `error.message`,可选在 `_meta["bisheng/error"]` 重复同一结构供客户端编程读取,但 `_meta` 不能替代模型可见内容。协议错误则使用标准 JSON-RPC `error.code/error.message`。支持既有 `HTTPException` 与 `BaseErrorCode` 是 MCP compatibility adapter 的责任;不得以 F067 为由要求既有 API 调用链先统一异常类型。契约测试冻结 API 错误行为,并分别断言 tool error 与 protocol error 的错误码、消息和信号字段,同时断言不存在 HTTP 信封字段。 + +#### 客户端配置示例 + +```json +{ + "mcpServers": { + "bisheng": { + "type": "streamable-http", + "url": "https:///api/v2/mcp", + "headers": { + "Authorization": "Bearer " + } + } + } +} +``` + +代表他人时只在可信客户端配置中增加 `X-On-Behalf-Of`;示例文档不得出现真实凭据。 + +### 4.5 关键模块职责 + +| 模块 / 文件 | 职责 | 不做什么 | +|---|---|---| +| `bisheng/open_mcp/server.py` | 创建 MCP Server、注册 list/call handler、暴露无状态 Streamable HTTP ASGI app | 不认证业务主体,不调用 ORM | +| `bisheng/open_mcp/registry.py` | 声明 10 个 allowlist 工具的名称、schema、source route、scope、modes、handler、结果类型 | 不从 URL 动态执行任意 endpoint,不自动注册 allowlist 之外的 API | +| `bisheng/open_mcp/auth.py` | 把 MCP HTTP 请求接入共享 `OpenApiAccessContext`,保证每请求 set/reset | 不实现新 token verifier,不缓存永久授权 | +| `bisheng/open_mcp/contracts.py` | 定义 MCP 专用输入/成功输出 DTO 与 `McpErrorPayload` | 不替换或反向复用为现有 API response model,不直接暴露 ORM/SQLModel | +| `bisheng/open_mcp/compatibility.py` | 按 `tool-contracts.md` 重建既有调用参数,并把既有业务结果、`HTTPException` 与 `BaseErrorCode` 适配为 MCP 合同 | 不要求修改现有 API 接口逻辑、字段或异常类型,不以反射自动暴露新增字段 | +| `bisheng/open_mcp/result.py` | 成功 DTO 转换为 `structuredContent`,错误 DTO 转换为 `isError` + JSON `TextContent` | 不把 HTTP 状态/信封带入 MCP,不捕获并吞掉未知异常,不改写业务成功/失败结论 | +| `bisheng/open_mcp/upload.py` | 资源预检通过后的严格 base64 流式解码、受控文件 URL、业务兼容文件名和请求私有临时目录清理 | 不复用可读本地路径、长期 cache 或整响应入内存的通用下载入口;本版不暴露异步 `callback_url` | +| `bisheng/open_mcp/tools/*` | 薄工具绑定与文件/顶层数组参数适配 | 不复制业务授权、错误映射或持久化逻辑 | +| `bisheng/open_endpoints/domain/services/*` | MCP facade 复用的既有业务能力;确有重复时可在快照保护下抽取最小、传输无关的公共 capability | 不感知 MCP JSON-RPC;不承载 MCP 参数/输出/错误适配;F067 不改变现有 API 接口逻辑和对外合同 | +| `bisheng/open_api/domain/services/access_context.py` | 从现有 dependency 抽出凭据、S/D/PAT、tenant、permission actor 生命周期 | 不根据 tool 参数确定身份 | +| `bisheng/open_api/domain/services/call_audit_service.py` | 接收 HTTP/MCP 统一调用审计事件并批量写既有表 | 不记录请求正文、密钥或文件内容 | +| `bisheng/main.py` | 把 `/api/v2/mcp` 与 SDK lifespan 纳入现有 app | 不创建第二端口或节点本地 session store | + +--- + +## 5. 已知坑 / 反直觉事实 + +| # | 反直觉事实 | 如果不知道会怎样 | 在哪处理 | +|---|---|---|---| +| 1 | `mcp_manage` 名字像服务模块,实际只有出站客户端 | 在其中加入 server 会把内外两个方向的生命周期和依赖混在一起 | 新建 `bisheng/open_mcp/`;`mcp_manage/` 保持不变 | +| 2 | `verify_open_api_access` 通过 FastAPI endpoint 上的 marker 找 scope;MCP 的 10 个工具共用一个 HTTP endpoint | 直接复用 dependency 会得到“endpoint 未登记”,或只校验一次公共 scope | `open_api/domain/services/access_context.py` + `open_mcp/registry.py` | +| 3 | ASGI 外层中间件读不到依赖子任务里仅写 ContextVar 的 principal | MCP 审计会丢 actor/subject,甚至串用后续请求身份 | auth context 显式传 principal;逐工具审计不依赖外层回读 ContextVar | +| 4 | `/api/v2/mcp` 会命中现有 `OpenApiAuditMiddleware` 前缀 | 不排除会同时生成 transport 和 tool 两条成功审计,统计翻倍 | `open_api/api/middleware.py` 精确排除 MCP 路径,MCP handler 逐调用记账 | +| 5 | F067 的范围只是 Apifox 指定的 10 项 API,小于 `OPEN_API_SCOPES` 全量集合 | 从全量 scope registry 自动生成会把未授权能力也暴露为 MCP 工具 | 契约测试对 §4.3 allowlist 与 registry 做双向差集 | +| 6 | 批量删除 API 请求体是顶层 `array`,MCP tool arguments 必须是 object | 不显式定义包装字段会导致客户端与服务端合同分叉 | 固定使用 `{"file_ids": [...]}` | +| 7 | Apifox 说明中的代用户语义由 `X-On-Behalf-Of` 表达,不是普通业务参数 | 把 `user_id` 放入 tool arguments 会让模型尝试改变执行主体 | schema 排除身份字段,只从受信任请求头构建 principal | +| 8 | MCP 工具列表可见只证明 credential scope;具体资源仍需业务 action | 仅在 discovery 过滤会造成跨资源越权 | 每次 handler 进入既有业务 Service/F048;见 `open_mcp/auth.py` | +| 9 | MCP tool arguments 不能安全代表本机上传文件 | 接受 `/tmp/x` 或 `/app/data/x` 会读取某个副本文件并泄漏路径 | 文件工具只接受受限 base64/受控 file_url;见 `tool-contracts.md` §2.2 与 `open_mcp/upload.py` | +| 10 | MCP 请求可能被商业网关缓冲或剥离非标准头 | 直连后端可用、商业部署却初始化失败或 D 模式恒失效 | 网关验收必须覆盖 Streamable HTTP、`X-On-Behalf-Of`、`X-End-User` 和长响应 | +| 11 | 当前 `filelib` endpoint/Service 混有 `HTTPException` 与 `BaseErrorCode` | 若强行统一异常会改变已发布 API;若原样泄漏又会污染 MCP 协议 | API 异常链路保持不变;`open_mcp/compatibility.py` 显式兼容两类异常并生成含 `code/message` 的 MCP tool error result | +| 12 | base64 JSON 会同时放大请求体和解码内存,`file_url` 还是服务端主动出网;旧 API 的异步 `callback_url` 无法在 MCP 入口固定最终发送地址 | 只做“解码后文件校验”会在到达业务限制前耗尽内存或形成 SSRF | JSON 解析前 body cap、编码长度检查、流式临时文件、URL DNS pin/redirect/size 限制;MCP 本版删除 `callback_url` | + +--- + +## 6. 对外契约与依赖 + +### 6.1 我提供给别人的(Outgoing) + +| 契约 | 形式 | 谁在用 | +|---|---|---| +| `$BASE/api/v2/mcp` | 无状态 Streamable HTTP MCP endpoint | Claude Desktop、IDE、智能体平台、自动化客户端 | +| §4.3 的 10 个稳定工具名与 JSON schema | MCP `tools/list` / `tools/call` | 外部 MCP 客户端与模型 | +| §4.4 逐工具成功 DTO 与 `McpErrorPayload` | MCP `structuredContent` / tool error result | 调用方的分页、结果校验与错误处理 | +| `OpenCapabilityRegistry` | 内部 Python registry | MCP server、覆盖测试、工具文档生成 | +| `McpCompatibilityFacade` 与显式 mapper | 内部 async Python API | MCP tools;通过既有业务 Service 复用能力,不反向约束 HTTP endpoint | +| `open_api.call` + `target_type=mcp_tool` | `audit_log` 事件 | 管理审计、故障追踪 | + +工具名移除或输入字段破坏性变更必须按对外 API 的版本治理处理;不能因 HTTP route 重命名而静默改变 MCP tool name。 + +### 6.2 我依赖别人的(Incoming) + +| 依赖 | 形式 | 风险点 | +|---|---|---| +| F053 `ApiCredential` / `OpenApiPrincipal` / S-D / execution snapshot | Python domain contract + DB/Redis | principal 字段、委托强制规则或 5 秒失效上界变化会直接影响 MCP | +| F066 PAT data scope | tenant policy + permission actor | 若只在 HTTP dependency 应用,MCP PAT 会绕过管理员收窄 | +| 中粮 SeedMind Apifox 10 项 allowlist | 范围合同 | 只有重新确认 Spec/Design 才能增删工具 | +| `OPEN_API_SCOPES` | route/scope registry | 只取 allowlist 对应路由的 scope,不得以全量 registry 扩围 | +| F048 permission application API | `current_permission_actor` + business action | 不得从 MCP 直接调用 OpenFGA 或 SQL Grant 兜底 | +| 各业务 Service | Python async/sync contracts | MCP facade 必须覆盖 API endpoint 的业务编排语义;以 API 快照和副作用对比防止漂移,不能靠改 API 合同消除差异 | +| `mcp>=1.27.2` | Python SDK | Streamable HTTP lifecycle、错误/structuredContent 行为升级时可能变化;禁止回退到已知受影响版本 | +| 商业网关/反向代理 | HTTP path/header/stream forwarding | 路径未代理、身份头丢失、body limit 与 `open_mcp.max_inline_upload_bytes` 公式不一致或无限 buffering 会导致失败/资源放大 | +| MinIO/临时上传服务 | 文件输入输出 | 不得改用本地路径;多副本必须通过对象存储保持可读;MinIO origin 必须显式配置进受控 URL 目标 | + +--- + +## 7. 测试与可观测 + +### 7.1 分层策略 + +- **单元测试**:registry 工具名唯一、schema 无身份字段、scope/mode/PAT 过滤、四项 annotations、`McpCompatibilityAdapter` 对 `HTTPException`/`BaseErrorCode` 的转换、成功 structured output、工具失败 `isError` + `code/message`、未预期异常 JSON-RPC `-32603` 且均无 HTTP 信封、顶层数组与文件适配、ContextVar finally reset、审计脱敏;上传覆盖畸形 base64、编码/解码/body 三层边界、清理,以及 file URL 的 scheme/host/IP pin/redirect/timeout/截断,并断言 schema 不含 `callback_url`。 +- **契约测试**:先冻结 §4.3 的 10 个 source API 的接口逻辑、请求、成功响应和主要错误快照,后续 F067 测试必须断言快照不变;registry 与 allowlist 做双向差集,逐工具校验 `inputSchema/outputSchema/annotations` 与生成文档。相同业务场景分别走既有 API 和 MCP facade,比较业务码语义、关键业务数据与副作用;只有 MCP 成功 `structuredContent` 必须通过 `outputSchema`,错误结果必须无 `structuredContent` 且含非空 `error.code/error.message`。MCP 输出保留 `actions` 与结构化 `TagItem[]`,不增加 `permission_ids`、不把标签扁平为名称数组。 +- **集成测试**:真实 ASGI MCP client 覆盖 initialize/list/call;S、D、PAT、撤销后复用连接、权限变化、OpenFGA/Redis 故障、multipart/base64/受控 URL 上传和批量删除数组包装;网关与直连分别验证 `413`、身份头转发和临时文件零残留。 +- **E2E**:MySQL + Redis + OpenFGA 环境验证 3 个只读工具与 7 个写工具的主要成功/拒绝路径,并对 10 个工具至少各做一次成功调用;商业网关再跑相同 smoke。DM8 由集中回归验证 MCP facade 对既有业务 Service 的调用与审计写入。 + +### 7.2 手动验证 + +实现后从 `src/backend/` 执行(token 只放环境变量,不出现在 shell history 或日志): + +```bash +export BISHENG_MCP_URL='https:///api/v2/mcp' +export BISHENG_API_KEY='' +.venv/bin/python scripts/verify_open_mcp.py --url "$BISHENG_MCP_URL" --expected-profile full +``` + +脚本至少输出:协议初始化结果、可见工具数、缺失/多出 source route、选定只读工具结果、撤销后拒绝结果;任何凭据值必须掩码。 + +### 7.3 可观测 + +- 审计:`audit_log.action='open_api.call'`、`target_type='mcp_tool'`、`target_id=`,metadata 包含 channel、credential/actor/subject、scope、result、error_code、latency、trace_id,不含 arguments。 +- 结构化日志:`open_mcp.tool_call`(tool/scope/mode/result/latency)、`open_mcp.coverage_mismatch`、`open_mcp.auth_failed`、`open_mcp.protocol_error`。 +- 健康判据:MCP server 注册失败或 coverage 不完整时,不把 MCP endpoint 标记为可用;不得影响 `/health` 对基础进程的判断,也不得绕过发布验收。 + +--- + +## 8. 发布、后续改进与不做事项 + +### 8.1 发布顺序 + +1. 先冻结 10 项现有 API 的接口逻辑、请求、成功响应和主要错误快照;保持现有 endpoint 校验、分支、调用顺序、响应和异常处理不变,再新增 MCP compatibility facade/mapper。公共能力优先直接复用现有 Service;只有出现真实重复且可证明 API 行为零差异时,才抽取最小的传输无关业务 capability。 +2. 注册 §4.3 的 10 个 allowlist 工具并让 registry/allowlist 双向差集及逐项映射覆盖测试通过;生成含完整输入/输出 schema 的工具文档和配置示例。 +3. 挂载 `/api/v2/mcp`,配置 `open_mcp.max_inline_upload_bytes`、允许的文件 URL host、transport Host/Origin,并按 `tool-contracts.md` §2.2 同步商业网关 path/header/body/stream 上限;本版 MCP 不配置或接收 `callback_url`。 +4. 先用只读服务账号 smoke,再验证 D、PAT、写入、文件上传和撤销。 +5. 对外公布地址前确认审计、脱敏、allowlist 覆盖与 API→MCP 显式映射覆盖均完整;API/MCP 可有已记录的协议形态差异,但不得有未解释的业务语义差异。 + +**回滚**:F067 无 schema/data migration;发现协议、网关或工具问题时可撤回 `/api/v2/mcp` 路由、registry 与 compatibility adapter 并回滚后端镜像。现有 `/api/v2` 接口逻辑、合同和异常链路不属于 F067 改动面,因此回滚前后 API 行为不变;回滚只使 MCP 地址暂时不可用,不得撤销或改写既有凭据。 + +### 8.2 后续 / 不做 + +- 不预注册中粮 SeedMind Apifox 10 项 allowlist 之外的隐藏工具;不因其他 API 已发布就自动扩围。 +- 不提供万能 HTTP 代理工具;它会绕过工具发现与 schema 边界。 +- 不把 MCP session 作为登录 session,也不在内存中保存永久 principal。 +- 暂不拆独立服务;当前没有独立容量/SLA 证据,拆分只会增加身份和部署漂移面。 +- 暂不承诺 MCP 原生 task/resource server;待协议和主流客户端形成稳定互操作后再评估。 + +--- + +## 修订历史 + +| 日期 | 改动 | 触发原因 | +|---|---|---| +| 2026-09-15 | 初版(已由下行范围裁定取代):确定同进程无状态 Streamable HTTP、43 项显式 registry、共享能力层、两阶段认证、结构化结果与逐工具审计 | Spec ★ 已确认,进入 Design | +| 2026-09-15 | 范围收缩为中粮 SeedMind Apifox 指定的 10 项知识资源/文件 API;删除其他业务组、WS/SSE/二进制下载适配设计,补充一对一 allowlist 与逐工具 schema 交付要求 | 用户明确以新 Apifox 文档为本次唯一能力范围 | +| 2026-09-18 | 冻结现有 API 入参、出参与异常链路;改为由 MCP 专用 schema、facade 和 mapper 单向兼容协议差异,删除“先改 endpoint/统一异常”的前置要求 | 用户明确现阶段不得改动已有 API 入参和出参 | +| 2026-09-18 | 修复设计评审项:审计统一复用 `audit_log`;输出合同对齐当前 API 模型;补充上传 body/base64/URL 安全边界、MCP 专用错误兼容映射和逐工具 annotations | Design 评审整改 | +| 2026-09-18 | 再次收紧 API 冻结边界为“不修改任何既有 API 接口逻辑”;MCP 成功输出改为逐工具 structured output,错误改为标准 tool error result;取消 `actions → permission_ids` 与 `TagItem → name` 的无依据转换,并限定公共抽取为传输无关业务 capability | 用户对 Design 二次评审的四项明确反馈 | diff --git a/features/v3.0.0-beta1/067-unified-remote-mcp-service/discovery.md b/features/v3.0.0-beta1/067-unified-remote-mcp-service/discovery.md new file mode 100644 index 0000000000..3b3c6439bb --- /dev/null +++ b/features/v3.0.0-beta1/067-unified-remote-mcp-service/discovery.md @@ -0,0 +1,126 @@ +# Spec Discovery — 通用开放 API 的统一远程 MCP 服务(F067) + +> **本文定位**:Spec Discovery 调研记录,只回答需求边界、当前基线与待确认口径;不替代 `spec.md` / `design.md`。 +> **日期**: 2026-09-15 +> **状态**: ✅ 能力范围已于 2026-09-15 按中粮 SeedMind Apifox 文档重新确认;尚未生成 `tasks.md` +> **上游需求**: [《0923 中粮需求响应》§3「提供通用 API 与 MCP 接口」](https://dataelem.feishu.cn/wiki/YPnUwk9ZjiNYPdkmh1DcmiRnnac#doxcnWFZOYA02TvKHbI3ksU9Tvh) +> **范围基线**: [中粮 SeedMind Apifox 开发者文档](https://s.apifox.cn/a0e36780-865a-4179-b7df-b51a01d5ebcc) +> **代码基线**: `feat/923-3.0.0-beta1-mcp` @ `9b16841117`,工作区于 2026-09-15 核实 +> **相关 Feature**: F053(开放 API 鉴权与身份传递)· F066(PAT 数据范围) + +--- + +## 1. 结论先行 + +本需求应作为 **F053 之后的独立 Full SDD Feature**:每个 BISHENG 部署提供一个统一远程 MCP 服务,仅把中粮 SeedMind Apifox 文档当前列出的 **10 个知识资源/文件 HTTP API** 转换为 10 个可发现、可调用的 MCP 工具,同时保持 API 与 MCP 在身份、权限、数据归属、业务结果、错误和审计上的同一语义。 + +这不是新增一套业务 API,也不是重做 F053。F053 原 PRD v2.6 曾把“MCP 复用同一密钥体系”列为 P2、本期不做;0923 PRD §3 是新的范围裁定,明确承接并要求交付。为了不改写已经确认和上线验证过的 F053 范围,建议由 F067 依赖 F053,而不是把 MCP 追加入 F053。 + +当前仓库只有 **MCP 客户端**能力(连接第三方 MCP 服务并转为 LangChain Tool),没有面向外部调用方的 MCP 服务端;`mcp>=1.27.0` 依赖已存在,但不能据此视为服务端能力已交付。 + +--- + +## 2. PRD 已明确的业务口径 + +### 2.1 服务与接入 + +- 每个 BISHENG 部署提供 **一个统一远程 MCP 服务地址**。 +- 调用方沿用开放 API 的凭据获取方式,只需配置服务地址与认证信息,即可发现并调用其权限范围内的工具。 +- 随服务交付接入地址、客户端配置示例与工具说明;示例中的凭据只能使用占位符。 +- MCP 工具保持正式公开 API 的业务字段含义、默认值、限制、分页和结果语义;若 MCP JSON object、文件载体、结构化成功结果或标准错误结果无法原样表达 API 线格式,则由 MCP 工具定义显式兼容。既有 API 的接口逻辑与合同全部不变;工具拆分、名称、转换规则、传输与部署形态属于后续 Design 决策。 + +### 2.2 能力覆盖 + +2026-09-15 用户将需求收缩为 Apifox 链接当前展示的以下 10 个 API。本清单是显式白名单,不随 F053 其他开放路由自动扩张: + +| 业务组 | Apifox 指定的能力范围 | API 数 | 沿用权限位 | +|---|---|---| +| 知识资源 | 查询列表、创建、更新、删除、清空内容、检索分段 | 6 | `knowledge:read` / `knowledge:write` | +| 文件 | 上传、查询列表、删除、批量删除 | 4 | `knowledge:read` / `knowledge:write` | + +本期固定采用“一个 API 业务操作对应一个 MCP 工具”,共 10 个工具。Apifox 后续新增接口不会自动进入 F067;扩围必须先更新 spec/design 并重新确认。 + +### 2.3 身份、权限与执行 + +- 服务账号凭据继续支持 F053 已定义的“自身身份”与“代表他人”模式;主体、租户和委托目标都由服务端可信认证上下文确定。 +- 模型生成的普通工具参数只承载业务输入,不得借此指定或替换执行主体。 +- 个人访问令牌继续只允许 F053/F066 已开放的 `knowledge:read` 工具,不得获得写入或委托能力。 +- 工具发现时按凭据权限位和主体限制过滤;每次工具执行时重新校验凭据、身份模式、资源权限、数据范围和业务状态。 +- 凭据撤销/过期、主体失效、权限或数据范围变化,按 API 的同一生效规则影响后续调用;已有连接不得缓存为永久授权。 +- 权限校验失败时终止业务执行;同一业务操作通过 API 或 MCP 调用,应产生相同的数据归属、业务结果与错误语义。 +- 身份解析、资源授权、业务状态与审计复用 F053/F048 现有规则;MCP 不得形成第二套权限执行面。 + +--- + +## 3. 当前代码与契约基线 + +| 项 | 当前事实 | 对本 Feature 的影响 | +|---|---|---| +| v2 路由 | `/api/v2` 统一挂载 `verify_open_api_access`,每个开放端点用 `open_api_scope` 声明权限位、允许身份模式和会话语义 | MCP 必须消费同一主体与权限合同,不能仅在工具列表层做一次校验 | +| 需求能力文档 | 中粮 SeedMind Apifox 文档列出 10 个本期 API | Design 使用显式 allowlist 建立一对一覆盖,不以 F053 全量路由作为扩围依据 | +| 现有 API 合同 | F053 公开合同中已有这 10 个 `/api/v2` 路由 | 可复用其已验证的鉴权、scope、业务码与数据语义,但 MCP 输入/输出 schema 仍需显式生成和校验 | +| MCP 现状 | `bisheng/mcp_manage/` 只有 stdio/SSE/streamable HTTP 客户端和 LangChain Tool 适配 | 不能复用为服务端交付证明;服务端边界需新设计 | +| 凭据与身份 | F053 已实现服务账号、PAT、S/D、执行主体、会话主体和审计 | F067 不新造密钥类型或身份模式 | +| PAT 数据范围 | F066 可把自然人 PAT 收窄到本人创建数据 | MCP 知识只读工具同样受调用期数据范围约束,管理员短路不能绕过 | +| 旧 PRD 范围 | F053 spec/design 明确“MCP 本期不建设” | F067 必须在版本契约中登记为后续增量,不能暗改 F053 已确认事实 | + +--- + +## 4. 建议范围边界 + +### 4.1 本次纳入 + +- 一个统一远程 MCP 服务及工具发现、工具执行能力。 +- 仅覆盖中粮 SeedMind Apifox 文档当前列出的 10 个知识资源/文件 API。 +- 服务账号 S/D 与 PAT 知识只读的完整身份/权限矩阵。 +- 与上述 API 等价的成功、空结果、分页、业务错误、权限拒绝、依赖故障和文件上传语义;线格式允许按 MCP 工具合同显式转换,错误结果使用 MCP `isError` 与明确 `code/message`,不复制 HTTP 状态信封。 +- 工具清单过滤与执行时二次校验。 +- 接入地址、占位符配置示例、工具说明与能力覆盖清单。 +- allowlist/MCP 契约漂移检查:10 个指定 API 不能漏工具,MCP 也不能多出 allowlist 之外的工具。 + +### 4.2 明确排除 + +- 不纳入 Apifox 本期清单之外的任何开放 API,包括工作流、应用/知识助手、日常对话、语音、引用、问答对、元数据、分段上传与下载统计。 +- 不新增密钥类型、身份模式、OAuth 授权服务器或面向特定第三方平台的专用适配。 +- 不替换、下线或改变现有 `/api/v2` 调用方式。 +- 不把工具名称、工具粒度、协议版本、传输方式或部署进程写死在 spec;这些属于 Design。 +- 不在文档、日志、错误或审计中记录明文凭据、Authorization 头、请求文件内容或其它敏感输入。 + +--- + +## 5. Spec 拟覆盖的验收主题 + +1. 单一服务地址、标准接入资料与占位符示例。 +2. 10 个 allowlist API 与 10 个 MCP 工具一对一完整映射,不多开放、不漏开放。 +3. 工具发现按权限位、凭据类型和身份限制过滤。 +4. 每次执行重新校验;撤销、过期、主体/租户/权限变化对已有连接生效。 +5. 服务账号自身身份、代表他人与 PAT 知识只读矩阵。 +6. 委托身份只来自可信认证上下文,业务参数不能改执行主体。 +7. 资源权限、租户隔离、PAT 数据范围、会话归属与审计同 API。 +8. API/MCP 同一操作的业务含义、错误含义和副作用一致;已有 API 的全部接口逻辑保持冻结,协议形态无法原样复用时由 MCP 工具定义和 adapter 单向兼容。 +9. 文件上传、分页、删除与批量删除不弱化原 API 限制。 +10. 工具适配层失败关闭,不绕过 API 或 F048 业务门禁。 + +--- + +## 6. 已确认的 Discovery 裁定 + +用户已确认按以下口径进入 spec: + +- F067 依赖 F053/F066,作为 0923 PRD 对旧“MCP 本期不做”裁定的独立增量。 +- 能力范围不再是 F053 正式公开合同的全量业务路由,而是中粮 SeedMind Apifox 文档当前列出的 10 个 API 显式 allowlist。 +- 本期每个 allowlist API 对应一个 MCP 工具;任何新增工具都需先重新确认范围。 + +上述收缩口径已于 2026-09-15 由用户确认,并同步写入 `spec.md` 与 `design.md`。 + +--- + +## 相关文档 + +- [F053 spec](../053-openapi-auth-and-identity/spec.md) +- [F053 design](../053-openapi-auth-and-identity/design.md) +- [F053 v2 API 合同](../053-openapi-auth-and-identity/openapi-v2-key-auth-api.md) +- [中粮 SeedMind Apifox 范围基线](https://s.apifox.cn/a0e36780-865a-4179-b7df-b51a01d5ebcc) +- [F066 spec](../066-pat-data-scope-and-ai-access/spec.md) +- [版本契约](../release-contract.md) +- [SDD 指南](../../../docs/SDD-Guide.md) diff --git a/features/v3.0.0-beta1/067-unified-remote-mcp-service/spec.md b/features/v3.0.0-beta1/067-unified-remote-mcp-service/spec.md new file mode 100644 index 0000000000..4b7dc477af --- /dev/null +++ b/features/v3.0.0-beta1/067-unified-remote-mcp-service/spec.md @@ -0,0 +1,120 @@ +# Feature: F067 通用开放能力的统一远程 MCP 服务 + +> **本文档定位 — 纯 What(需求口径,不随代码漂移)** +> +> 本文只定义业务范围与验收标准;实现方案、协议适配、部署形态、接口与文件清单在设计确认后记录于 +> [design.md](./design.md)。 + +**关联 PRD**: [《0923 中粮需求响应》§3「提供通用 API 与 MCP 接口」](https://dataelem.feishu.cn/wiki/YPnUwk9ZjiNYPdkmh1DcmiRnnac#doxcnWFZOYA02TvKHbI3ksU9Tvh) +**范围基线**: [中粮 SeedMind Apifox 开发者文档](https://s.apifox.cn/a0e36780-865a-4179-b7df-b51a01d5ebcc) +**优先级**: P1 +**所属版本**: v3.0.0-beta1 +**依赖**: F053 开放 API 鉴权与身份传递、F066 PAT 数据范围 +**状态**: ✅ 能力范围已于 2026-09-15 按中粮 SeedMind Apifox 文档重新确认;Design 已于 2026-09-18 确认并生成 `tasks.md` + +> **范围边界** +> - **本次纳入**: +> - 每个 BISHENG 部署提供一个供外部客户端接入的统一远程 MCP 服务。 +> - 仅覆盖中粮 SeedMind Apifox 文档当前列出的 10 个知识资源/文件 API,每个 API 对应一个 MCP 工具。 +> - 沿用开放 API 的服务账号自身身份、服务账号代表他人、个人访问令牌知识只读,以及租户、资源权限、数据范围、业务状态和审计规则。 +> - 交付服务地址、客户端配置示例、工具说明和可核验的能力覆盖清单。 +> - **本次明确排除**: +> - 不开放上述 10 个 API 之外的任何能力,包括工作流、应用/知识助手、日常对话、语音、引用、问答对、元数据、分段上传与下载统计。 +> - Apifox 后续新增的 API 不自动进入本 Feature;扩围必须重新更新并确认 Spec/Design。 +> - 不新增凭据类型、身份模式、授权服务器或特定第三方平台的专用接入。 +> - 不替换、下线或改变现有开放 API 的调用合同。 + +--- + +## 1. 用户故事 + +作为 **需要把 BISHENG 能力接入智能体或自动化平台的集成方**, +我希望 **只配置一个远程 MCP 服务地址和一份既有凭据,即可发现并调用获准的 BISHENG 业务工具**, +以便 **用统一方式接入本期指定的知识资源和文件能力,而不必为每项能力单独集成**。 + +作为 **租户管理员与安全负责人**, +我希望 **MCP 与开放 API 使用完全一致的身份、权限、数据范围、失效和审计规则**, +以便 **增加接入方式时不产生第二套授权边界或越权通道**。 + +作为 **平台维护者**, +我希望 **正式开放能力与 MCP 覆盖关系能够被明确验收**, +以便 **开放能力演进时不会出现 MCP 静默漏能力或额外暴露能力**。 + +--- + +## 2. 验收标准 + +### 2.1 服务接入与能力覆盖 + +- **AC-01** — THE SYSTEM SHALL 为每个 BISHENG 部署提供一个统一远程 MCP 服务,使调用方通过同一服务完成工具发现和工具调用。 +- **AC-02** — WHEN 调用方取得部署方提供的服务地址和有效的既有开放凭据, THE SYSTEM SHALL 允许其按交付说明完成接入,无需为不同业务组配置不同服务地址或签发新类型凭据。 +- **AC-03** — THE SYSTEM SHALL 随服务交付可执行的客户端配置示例、工具用途与参数说明、认证说明及能力覆盖清单,且所有示例凭据均为不可用占位符。 +- **AC-04** — THE SYSTEM SHALL 为中粮 SeedMind Apifox 范围基线中当前列出的 10 个 API 各提供一个 MCP 工具,包括知识资源查询、创建、更新、删除、清空、检索,以及文件上传、查询、删除、批量删除。 +- **AC-05** — THE SYSTEM SHALL 保证上述 10 个工具全部可实际调用,且 `tools/list` 不得返回本期 allowlist 之外的 BISHENG 业务工具。 +- **AC-06** — WHEN Apifox 文档后续新增、删除或修改 API, THE SYSTEM SHALL 保持本 Feature 的 10 项已确认 allowlist 不自动扩张;只有在 Spec/Design 重新确认后才能增删 MCP 工具。 +- **AC-07** — THE SYSTEM SHALL 把现有开放 API 的全部接口逻辑视为冻结兼容基线,包括校验与分支、调用顺序、副作用、路由、请求字段/位置、默认值、响应字段、错误信封与 HTTP 状态;调用方可独立选择开放 API 或 MCP,F067 不得为适配 MCP 改变任何既有 API 接口逻辑。 + +### 2.2 工具发现与身份 + +- **AC-08** — WHEN 已认证调用方请求工具清单, THE SYSTEM SHALL 只返回该凭据类型、权限位和身份模式允许使用的工具。 +- **AC-09** — IF 凭据缺失、无效、过期、已撤销或其主体已失效, THEN THE SYSTEM SHALL 拒绝工具发现与工具执行,不返回可供继续调用的业务能力。 +- **AC-10** — WHEN 服务账号以自身身份调用工具, THE SYSTEM SHALL 以该服务账号作为业务执行主体和数据归属主体。 +- **AC-11** — WHEN 服务账号以代表他人模式调用工具, THE SYSTEM SHALL 仅在被代表用户满足既有全部委托准入条件时,以该用户作为业务执行主体和数据归属主体。 +- **AC-12** — IF 代表他人模式缺少有效被代表用户、目标不满足准入条件或委托范围不允许, THEN THE SYSTEM SHALL 拒绝调用,不得回退为服务账号自身身份。 +- **AC-13** — THE SYSTEM SHALL 仅从受信任的认证上下文确定租户和执行主体;任何业务工具参数均不得选择、替换或提升执行主体。 +- **AC-14** — WHEN 调用方使用个人访问令牌, THE SYSTEM SHALL 只允许其发现和调用本期 allowlist 中标记为 `knowledge:read` 的查询列表、检索分段和查询文件列表工具,不得提供知识或文件写入、删除、清空或委托能力。 + +### 2.3 调用期授权与失效 + +- **AC-15** — WHEN 调用方执行任一工具, THE SYSTEM SHALL 在该次调用时重新校验凭据、身份模式、租户、权限位、资源权限、数据范围和业务状态,不得把此前的工具发现结果当作永久授权。 +- **AC-16** — WHEN 凭据撤销、过期、主体停用、权限变更或数据范围变更已经按开放 API 合同生效, THE SYSTEM SHALL 对后续 MCP 调用同步生效,包括通过既有连接发起的调用。 +- **AC-17** — IF 调用所需的身份、权限、租户或数据范围无法确定,或授权依赖不可用, THEN THE SYSTEM SHALL 失败关闭,且不得执行部分业务、返回未过滤结果或降级为其他主体。 +- **AC-18** — WHEN 调用涉及具体资源, THE SYSTEM SHALL 应用与对应开放 API 相同的资源动作、租户隔离、数据所有权和业务状态限制,即使该工具已出现在发现结果中也必须重新判定。 + +### 2.4 业务语义、安全与审计 + +- **AC-19** — WHEN 同一主体以等价业务输入分别通过开放 API 和 MCP 发起同一操作, THE SYSTEM SHALL 产生相同的业务结果含义、数据归属、持久副作用和权限边界;MCP 因 JSON object、base64、structuredContent 或 `oneOf` 需要采用不同线格式时,由 MCP adapter 按工具合同转换,不反向修改开放 API。 +- **AC-20** — THE SYSTEM SHALL 在 MCP 中保持对应开放 API 的成功、空结果、分页、业务拒绝和失败语义,使调用方能够区分成功、无数据、参数错误、权限拒绝与服务失败;成功结果按逐工具 `outputSchema` 返回 MCP `structuredContent`,工具执行失败按 MCP 规范返回 `isError=true`、不设置 `structuredContent`,并在模型可见内容中返回明确、稳定的错误 `code` 与 `message`;未知工具、畸形请求和未预期服务端异常使用标准 JSON-RPC error 并返回协议 `code/message`。两类错误均不得要求沿用 HTTP `status_code/http_status` 或 API 错误信封。 +- **AC-21** — WHEN 调用文件上传工具, THE SYSTEM SHALL 保持对应 API 的文件类型、大小、配额、解析参数、异步处理和错误语义;同时在 JSON 解析前限制 MCP 请求体,在解码/下载前后限制文件字节数,并约束 URL 协议、目标、重定向、超时和临时文件清理,不得因 base64 或 `file_url` 适配而弱化资源与网络安全边界。 +- **AC-22** — WHEN MCP 工具调用被接受或拒绝, THE SYSTEM SHALL 形成与开放 API 同等级别、可关联实际凭据与执行主体的调用审计,同时不得记录明文凭据、认证头、请求文件内容或其它受保护输入。 +- **AC-23** — IF MCP 调用在完成业务操作前失败, THEN THE SYSTEM SHALL 返回明确失败,不得把未执行、部分执行或结果未知报告为成功;业务操作自身的既有幂等与恢复语义保持不变。 +- **AC-24** — THE SYSTEM SHALL 保持所有未进入本期 10 项 allowlist 的能力在 F067 MCP 中不可发现、不可调用,即使它们已经是其他开放 API 的正式能力。 +- **AC-25** — THE SYSTEM SHALL 为每个工具提供可校验的 MCP `outputSchema`,并为不能原样表达的业务结果提供逐工具兼容映射;schema 只校验成功时的 MCP `structuredContent`,不得要求或修改现有 API 输出。MCP 输出直接保留业务能力提供的 `actions` 与结构化 `TagItem[]`;不得无依据地转换为 `permission_ids` 或标签名称数组。 +- **AC-26** — THE SYSTEM SHALL 为每个工具声明与实际副作用一致的只读、破坏性、幂等和开放世界 annotations;这些提示不得作为跳过服务端鉴权、资源动作检查或人工确认的依据。 + +--- + +## 3. 边界情况 + +- 工具曾在发现结果中出现,但凭据、权限或主体随后失效时,执行必须以调用时状态为准并拒绝。 +- 凭据可发现某类工具但无权访问某个具体资源时,只拒绝该次资源调用,不得由工具可见性推导资源授权。 +- 个人访问令牌持有人具有管理员身份时,仍须遵守 F066 的 PAT 数据范围;数据范围收窄不得被管理员能力绕过。 +- Apifox 文档中用于代用户查询的身份信息不进入 MCP tool arguments;代表他人仍只能通过受信任的 `X-On-Behalf-Of` 认证上下文生效。 +- 多租户环境中,任何身份、资源或数据结果都不得跨越凭据所属租户的既有边界。 +- 本期覆盖采用一对一规则:10 个 allowlist API 必须恰好对应 10 个 MCP 工具。 +- 现有 API 是只读业务基线:接口逻辑和请求/响应合同保持不变;任何 path/query/body 展平、顶层数组包装、文件转换、字段联合、结构化成功结果或 MCP 错误包装都只发生在 MCP adapter 内。 +- 可以让 API 与 MCP 复用既有 application/domain Service;只有在出现真实重复时才允许抽取最小、传输无关的公共业务 capability。HTTP 与 MCP 的参数、响应及错误协议适配不得进入公共 capability,也不得借公共抽取改变既有 API 行为。 + +--- + +## 4. 设计与实现(指针,不复制) + +| 你想知道 | 去哪看 | +|---|---| +| 服务形态、工具拆分、协议与能力映射 | design.md(Spec 确认后创建) | +| 10 个工具的稳定名称、顶层入参与出参 | [tool-contracts.md](./tool-contracts.md) | +| 任务拆解与验证记录 | tasks.md(Design 确认后创建) | +| 版本级归属、依赖与不变量 | [release-contract.md](../release-contract.md) | +| 架构与安全约束 | [docs/constitution.md](../../../docs/constitution.md) | + +--- + +## 相关文档 + +- [Discovery 记录](./discovery.md) +- [F053 spec](../053-openapi-auth-and-identity/spec.md) +- [F053 v2 API 合同](../053-openapi-auth-and-identity/openapi-v2-key-auth-api.md) +- [中粮 SeedMind Apifox 范围基线](https://s.apifox.cn/a0e36780-865a-4179-b7df-b51a01d5ebcc) +- [MCP 工具合同](./tool-contracts.md) +- [F066 spec](../066-pat-data-scope-and-ai-access/spec.md) +- [版本契约](../release-contract.md) diff --git a/features/v3.0.0-beta1/067-unified-remote-mcp-service/tasks.md b/features/v3.0.0-beta1/067-unified-remote-mcp-service/tasks.md new file mode 100644 index 0000000000..10582e4040 --- /dev/null +++ b/features/v3.0.0-beta1/067-unified-remote-mcp-service/tasks.md @@ -0,0 +1,261 @@ +# Tasks: F067 通用开放能力的统一远程 MCP 服务 + +**关联规格**: [spec.md](./spec.md) +**关联设计**: [design.md](./design.md) +**工具合同**: [tool-contracts.md](./tool-contracts.md) +**版本**: v3.0.0-beta1 + +--- + +## 状态· +| 步骤 | 状态 | 备注 | +|------|------|------| +| spec.md | ✅ 已评审 | 2026-09-15 按中粮 SeedMind Apifox 10 API allowlist 确认 | +| design.md | ✅ 已评审 | 2026-09-18 确认 API 全量冻结、MCP 单向适配、标准错误输出与最小公共能力抽取 | +| tasks.md | ✅ 已拆解 | 2026-09-18 完成 21 项任务、5 个 Wave 与发布门禁拆分 | +| 实现 | 🟡 本地实现基本完成,待补齐 E2E 与集中验收 | 19 / 21 完成。T020 覆盖仍不完整;T021 仍需集中环境、商业网关与 DM8 证据 | + +--- + +## 开发模式与硬门禁 + +- **按 Wave 推进**:同一 Wave 中无依赖的任务可并行;不得跳过依赖任务。 +- **后端 Test-First**:每组实现先提交会失败的测试,再完成最小实现使其通过。测试统一放在 `src/backend/test/open_mcp/`,E2E 放在 `src/backend/test/e2e/`。 +- **Gate A — API 冻结**:T001 先让现有 10 个 `/api/v2/filelib/` API 基线测试可执行并冻结接口逻辑、请求/响应、错误和副作用。此后每个 Wave 都必须重跑该基线;F067 不得修改任何现有 API 接口逻辑。 +- **Gate B — 协议与安全**:registry、输入/输出合同、错误结果、认证上下文和上传安全测试通过前,不得挂载 MCP 服务。 +- **Gate C — 范围完整性**:registry 与 10 API allowlist 的双向差集必须为空;工具多一个或少一个都阻断发布。 +- **Gate D — 公共抽取限制**:优先直接复用既有 application/domain Service。只有出现真实重复,且 T001 快照可证明 API 行为零差异时,才允许抽取最小、传输无关的公共 capability;HTTP/MCP 参数、响应和错误适配不得进入该 capability。 +- **Gate E — 发布验证**:单元/契约/集成/E2E、架构守卫、直连和商业网关 smoke 均完成后,才能把 MCP 地址标记为可用。 +- **集中环境边界**:MySQL、Redis、OpenFGA、DM8 和商业网关用集中环境/CI 验证;本地测试不得伪称为真实中间件或 DM8 证明。 + +--- + +## Tasks + +### Wave 0 — 冻结 API 基线与准备依赖 + +- [x] **T001**: 修复并冻结 10 项现有开放 API 契约基线 + **文件**: `src/backend/test/knowledge/test_v2_filelib_unified.py`, `src/backend/test/open_mcp/test_api_freeze_contract.py`, `src/backend/test/open_mcp/fixtures/filelib_api_contracts.json` + **逻辑**: + - 修复旧测试仍 monkeypatch 已移除的 `get_default_operator_async` / `resolve_operator` 等失效锚点,改为当前 `get_open_api_operator_async` / `get_open_api_operator`,只改测试,不改 API。 + - 为 design §4.3 的 10 个 source route 冻结路由、参数位置、字段、默认值、成功响应、主要错误信封、HTTP 状态、调用顺序和关键副作用;知识资源输出按当前 `actions`,文件标签按结构化 `TagItem[]` 记录,不引入 `permission_ids` 或标签名称数组。 + - 分别覆盖知识资源类型 0/1/3、空结果、分页、批量删除顶层数组、文件上传 multipart,以及权限/不存在/业务状态拒绝。 + **测试**: `uv run pytest test/knowledge/test_v2_filelib_unified.py test/open_mcp/test_api_freeze_contract.py` + **覆盖 AC**: AC-07, AC-19, AC-20, AC-25 + **依赖**: 无 + +- [x] **T002**: 编写 MCP SDK 与配置合同测试 + **文件**: `src/backend/test/open_mcp/test_sdk_contract.py`, `src/backend/test/open_mcp/test_settings.py` + **逻辑**: + - 断言实际解析的 MCP SDK 版本不低于 `1.27.2`,Streamable HTTP 能协商设计指定的协议版本,且输入/输出 schema 根节点为 object。 + - 先定义 `open_mcp` 配置合同:请求体/base64/下载大小限制、允许的文件 URL host、transport Host/Origin、连接/读取超时和重定向上限;默认值必须安全,非法或无上限配置启动失败。MCP 本版不暴露旧 API 的异步 `callback_url`。 + **测试**: `uv run pytest test/open_mcp/test_sdk_contract.py test/open_mcp/test_settings.py` + **覆盖 AC**: AC-01, AC-20, AC-21 + **依赖**: 无 + +- [x] **T003**: 升级 MCP SDK 并落地服务端配置 + **文件**: `src/backend/pyproject.toml`, `src/backend/uv.lock`, `src/backend/bisheng/core/config/open_platform.py`, `src/backend/bisheng/core/config/settings.py` + **逻辑**: + - 将 MCP SDK 升级并锁定到 `>=1.27.2` 的兼容版本,不回退到已知会话鉴权漏洞版本。 + - 新增独立 `OpenMcpConf` 并接入 `Settings.open_mcp`;不复用出站客户端 `McpConf`,不改变现有开放 API 配置语义。 + **测试**: T002 全部通过;现有配置加载测试通过 + **覆盖 AC**: AC-01, AC-21 + **依赖**: T002 + +### Wave 1 — 认证、注册表、结果与上传基础层 + +- [x] **T004**: 编写共享开放访问上下文测试 + **文件**: `src/backend/test/open_mcp/test_access_context.py`, `src/backend/test/open_api/test_dependencies.py`, `src/backend/test/open_api/test_data_scope_matrix.py`, `src/backend/test/open_api/test_identity_e2e.py` + **逻辑**: + - 覆盖 S/D/PAT 凭据解析、scope/mode、tenant/visible tenant、principal、PermissionActor 的安装和 finally 恢复。 + - 覆盖缺失/过期/撤销凭据、主体失效、委托准入失败、PAT 管理员仍受数据范围约束、授权依赖故障时失败关闭。 + - 断言现有 FastAPI dependency 的 endpoint marker、错误信封和 HTTP 状态不变;MCP 可显式传入工具声明的 scope/modes,而不是伪造 endpoint。 + **测试**: `uv run pytest test/open_mcp/test_access_context.py test/open_api/test_dependencies.py test/open_api/test_data_scope_matrix.py test/open_api/test_identity_e2e.py` + **覆盖 AC**: AC-08~AC-18 + **依赖**: T001 + +- [x] **T005**: 抽取传输无关的 `OpenApiAccessContext` 并接入 MCP 认证 + **文件**: `src/backend/bisheng/open_api/domain/services/access_context.py`, `src/backend/bisheng/open_api/api/dependencies.py`, `src/backend/bisheng/open_mcp/__init__.py`, `src/backend/bisheng/open_mcp/auth.py` + **逻辑**: + - 从现有 dependency 抽取凭据验证、S/D/PAT、租户、principal、PermissionActor 生命周期;公共层不读取 MCP 参数、不依赖 FastAPI endpoint marker。 + - 现有 dependency 继续作为薄 HTTP wrapper,保留原校验顺序、异常、状态和 ContextVar 行为;MCP wrapper 每次 list/call 都重新认证且绝不缓存永久授权。 + **测试**: T004 与 T001 全部通过 + **覆盖 AC**: AC-08~AC-18, AC-19 + **依赖**: T004 + +- [x] **T006**: 编写 10 工具 registry 与 schema 契约测试 + **文件**: `src/backend/test/open_mcp/test_registry.py`, `src/backend/test/open_mcp/test_contracts.py` + **逻辑**: + - 对 design §4.3 allowlist 与 registry 做双向差集,断言工具恰好 10 个、名称唯一、source route/scope/modes/PAT/handler 全部一一对应,禁止从 `OPEN_API_SCOPES` 自动扩展。 + - 逐工具校验 `title/description/inputSchema/outputSchema/annotations`,排除 `authorization/user_id/tenant_id/on_behalf_of/file_path` 等身份或本地路径字段。 + - 固定批量删除 `{"file_ids": [...]}`、上传 base64/file_url 二选一、知识资源 `actions`、结构化 `TagItem[]` 和逐工具 output model;禁止 `actions → permission_ids`、`TagItem → name`。 + **测试**: `uv run pytest test/open_mcp/test_registry.py test/open_mcp/test_contracts.py` + **覆盖 AC**: AC-04~AC-06, AC-13, AC-24~AC-26 + **依赖**: T001, T003 + +- [x] **T007**: 实现 MCP 合同模型与固定 allowlist registry + **文件**: `src/backend/bisheng/open_mcp/contracts.py`, `src/backend/bisheng/open_mcp/registry.py` + **逻辑**: + - 为 10 个工具定义显式输入与成功输出 DTO;所有模型都以 JSON object 为根,并与 `tool-contracts.md` 完全一致。 + - registry 显式声明工具元数据、source route、scope、modes、PAT 可用性、annotations、handler 和结果类型;范围不随路由或 SDK 自动发现增长。 + **测试**: T006 全部通过 + **覆盖 AC**: AC-04~AC-06, AC-24~AC-26 + **依赖**: T006 + +- [x] **T008**: 编写 MCP 成功结果与错误归一化测试 + **文件**: `src/backend/test/open_mcp/test_results.py`, `src/backend/test/open_mcp/test_errors.py` + **逻辑**: + - 成功结果必须同时返回符合 outputSchema 的 `structuredContent` 和内容等价的 JSON `TextContent`;删除/清空等空成功固定为 `{"success": true}`。 + - 参数、权限和业务错误返回 `isError=true`、无 `structuredContent`,模型可见 JSON 至少含非空 `error.code/error.message`;已有业务码优先复用,兼容 `BaseErrorCode` 与 `HTTPException`。 + - 未知方法/未知工具/畸形 JSON-RPC 使用标准协议 error;未预期异常固定为 `-32603` 安全文案。所有 MCP 输出都不得出现 `status_code/status_message/http_status`、堆栈、请求头或内部路径。 + **测试**: `uv run pytest test/open_mcp/test_results.py test/open_mcp/test_errors.py` + **覆盖 AC**: AC-20, AC-23, AC-25 + **依赖**: T003, T007 + +- [x] **T009**: 实现 compatibility 与 MCP result/error adapter + **文件**: `src/backend/bisheng/open_mcp/compatibility.py`, `src/backend/bisheng/open_mcp/result.py` + **逻辑**: + - 按 `tool-contracts.md` 做 MCP 单向入参/出参转换,协议差异不得反向写入 API schema、endpoint 或公共业务 capability。 + - 实现 `McpErrorPayload`、稳定符号码、已有业务码透传、成功 structured output、tool error 与 JSON-RPC error 的明确分界;未知异常继续向协议层交由 `-32603` 处理并只写脱敏日志。 + **测试**: T008 与 T001 全部通过 + **覆盖 AC**: AC-19, AC-20, AC-23, AC-25 + **依赖**: T008 + +- [x] **T010**: 编写 MCP 文件入口安全测试 + **文件**: `src/backend/test/open_mcp/test_upload.py` + **逻辑**: + - 覆盖 JSON 解析前 body cap、严格 base64 校验、编码与解码大小上限、流式临时文件、超时/截断/异常后的 finally 清理。 + - `file_url` 仅允许配置的 HTTPS host;DNS 解析后固定批准 IP,拒绝私网/环回/链路本地/保留/CGNAT IP,每次重定向重新校验,下载流超过上限立即终止。仅精确匹配配置的 MinIO scheme/host/port 可使用部署所需例外;schema 明确排除 `callback_url`。 + - 禁止服务器本地路径,禁止调用会整响应入内存或允许任意 URL 的通用 `async_file_download`;测试零临时文件残留。 + **测试**: `uv run pytest test/open_mcp/test_upload.py` + **覆盖 AC**: AC-13, AC-21, AC-23 + **依赖**: T003, T007 + +- [x] **T011**: 实现受控 base64/URL 上传适配 + **文件**: `src/backend/bisheng/open_mcp/upload.py` + **逻辑**: + - 在 MCP JSON 解析前执行请求体限制;base64 和 URL 下载均流式写入受控临时文件,并执行 design §5.12 的三层大小、SSRF、重定向、超时与清理护栏。 + - 安全适配完成后再交给既有文件类型、单文件大小、配额、解析参数和内容安全校验;不增加本地路径入口。 + **测试**: T010 全部通过 + **覆盖 AC**: AC-21, AC-23 + **依赖**: T010 + +### Wave 2 — 10 个业务工具 + +- [x] **T012**: 编写 6 个知识资源工具测试 + **文件**: `src/backend/test/open_mcp/test_knowledge_tools.py` + **逻辑**: + - 覆盖 `bisheng_knowledge_list/create/update/delete/clear/retrieve` 的完整输入、成功 DTO、空结果、分页、type 0/1/3、业务拒绝和资源动作检查。 + - 同一主体/等价输入分别走 API 与 MCP facade,对比业务含义、数据归属、关键副作用和权限边界;明确允许的差异只限 MCP object/structuredContent/error 包装。 + - 验证 PAT 只可调用 list/retrieve,D 模式主体与数据归属正确,调用时权限变化立即生效。 + **测试**: `uv run pytest test/open_mcp/test_knowledge_tools.py test/open_mcp/test_api_freeze_contract.py` + **覆盖 AC**: AC-04, AC-07, AC-10~AC-20, AC-23, AC-25, AC-26 + **依赖**: T005, T007, T009 + +- [x] **T013**: 实现 6 个知识资源 MCP 工具 + **文件**: `src/backend/bisheng/open_mcp/tools/__init__.py`, `src/backend/bisheng/open_mcp/tools/knowledge.py`, `src/backend/bisheng/open_mcp/compatibility.py`; **仅在 Gate D 成立时可新增** `src/backend/bisheng/open_endpoints/domain/services/filelib_capability_service.py` + **逻辑**: + - 工具绑定只做 registry 调度、MCP 参数转换和结果适配;业务授权、状态判断、持久化与异步副作用复用既有业务 Service/F048。 + - 如果 endpoint 中确有无法复用且重复的业务编排,只抽取最小传输无关 capability;不得修改现有 endpoint 的校验、分支、调用顺序、异常和响应,也不得把 MCP 合同下沉到公共层。 + **测试**: T012、T001 及相关 knowledge/open_api 测试全部通过 + **覆盖 AC**: AC-04, AC-07, AC-10~AC-20, AC-23, AC-25, AC-26 + **依赖**: T012 + +- [x] **T014**: 编写 4 个文件工具测试 + **文件**: `src/backend/test/open_mcp/test_file_tools.py` + **逻辑**: + - 覆盖 `bisheng_knowledge_file_upload/list/delete/files_delete`;上传的 `content_base64` 与 `file_url` 必须二选一,完整传递文件名、MIME、父级、解析与异步参数。 + - 覆盖批量删除 object wrapper、分页、结构化 `TagItem[]`、空成功、资源权限/配额/类型/大小/状态错误及关键副作用。 + - 同一主体/等价输入分别走 API 与 MCP facade,比对业务语义与副作用;断言现有 multipart 和顶层数组 API 合同未变。 + **测试**: `uv run pytest test/open_mcp/test_file_tools.py test/open_mcp/test_upload.py test/open_mcp/test_api_freeze_contract.py` + **覆盖 AC**: AC-04, AC-07, AC-10~AC-23, AC-25, AC-26 + **依赖**: T005, T007, T009, T011 + +- [x] **T015**: 实现 4 个文件 MCP 工具 + **文件**: `src/backend/bisheng/open_mcp/tools/file.py`, `src/backend/bisheng/open_mcp/compatibility.py`; **仅在 Gate D 成立时可使用** `src/backend/bisheng/open_endpoints/domain/services/filelib_capability_service.py` + **逻辑**: + - 使用 T011 的受控上传入口和既有业务 Service/F048;只在 MCP 层转换 multipart/base64/file_url、query/path 展平、批量删除数组包装和成功 DTO。 + - 保持既有 API 文件类型、大小、配额、解析任务、删除副作用和错误链路不变;公共 capability 抽取继续受 Gate D 约束。 + **测试**: T014、T001 及相关 knowledge/open_api 测试全部通过 + **覆盖 AC**: AC-04, AC-07, AC-10~AC-23, AC-25, AC-26 + **依赖**: T014 + +### Wave 3 — MCP Server、挂载与审计 + +- [x] **T016**: 编写 Streamable HTTP、调用期认证与审计集成测试 + **文件**: `src/backend/test/open_mcp/test_server.py`, `src/backend/test/open_mcp/test_transport_auth.py`, `src/backend/test/open_mcp/test_audit.py` + **逻辑**: + - 用真实 ASGI MCP client 覆盖 initialize、tools/list、tools/call、协议错误、无状态/多请求生命周期和完整 10 工具发现;未知或 allowlist 外工具不可调用。 + - S/D/PAT 每个请求重新认证;复用既有连接后撤销凭据、停用主体或变更权限/数据范围,下一次调用必须拒绝,不得依赖旧 list 结果或 session principal。 + - `/api/v2/mcp` 不产生 endpoint 级重复审计;每次工具接受/拒绝只记一条 `open_api.call`、`target_type=mcp_tool`,含 credential/actor/subject/scope/result/error/latency/trace,且不含 token、header、arguments、文件内容。 + - 覆盖 body 超限 `413`、ContextVar 清理、授权依赖故障失败关闭和 server/registry 初始化失败时 MCP endpoint 不可用。 + **测试**: `uv run pytest test/open_mcp/test_server.py test/open_mcp/test_transport_auth.py test/open_mcp/test_audit.py` + **覆盖 AC**: AC-01, AC-02, AC-05, AC-08~AC-18, AC-20~AC-24 + **依赖**: T005, T013, T015 + +- [x] **T017**: 实现 MCP Server、路由挂载与逐工具审计 + **文件**: `src/backend/bisheng/open_mcp/server.py`, `src/backend/bisheng/main.py`, `src/backend/bisheng/open_api/api/middleware.py`, `src/backend/bisheng/open_api/domain/services/call_audit_service.py` + **逻辑**: + - 创建无状态 Streamable HTTP MCP app,注册固定 registry 的 list/call handler并纳入应用 lifespan;直接挂载 `/api/v2/mcp`,不借 `/api/v2` endpoint marker 推导单一 scope,不新增端口或节点本地身份 session。 + - `tools/list` 按当前 principal 的 scope/mode/PAT 过滤;`tools/call` 重新认证、重新检查声明和资源权限后调用工具。 + - 现有 HTTP audit middleware 只精确排除 MCP transport 路径,其他 `/api/v2` 行为保持不变;MCP handler 复用现有审计 service 逐工具记账并脱敏。 + **测试**: T016、T001 以及 `test/open_api/` 回归全部通过 + **覆盖 AC**: AC-01, AC-02, AC-05, AC-08~AC-18, AC-20~AC-24 + **依赖**: T016 + +### Wave 4 — 文档、验证脚本与发布验收 + +- [x] **T018**: 编写生成文档与验证脚本的契约测试 + **文件**: `src/backend/test/open_mcp/test_generated_docs.py`, `src/backend/test/open_mcp/test_verify_script.py` + **逻辑**: + - 断言工具文档从 registry 生成且恰好覆盖 10 个工具,每项含用途、输入/输出 schema、annotations、成功例、主要错误例、认证头说明;示例只能用不可用占位符。 + - 验证脚本测试覆盖 initialize、双向覆盖差集、tools/list、选定只读工具调用、撤销后拒绝和非零退出码;输出必须掩码 token。 + **测试**: `uv run pytest test/open_mcp/test_generated_docs.py test/open_mcp/test_verify_script.py` + **覆盖 AC**: AC-03~AC-06, AC-09, AC-24~AC-26 + **依赖**: T017 + +- [x] **T019**: 生成对外文档并实现验证脚本 + **文件**: `src/backend/scripts/verify_open_mcp.py`, `src/backend/scripts/README.md`, `docs/api/open-mcp.md` + **逻辑**: + - 脚本可从 `src/backend/` 运行,只从环境变量读取凭据,打印协议初始化、可见工具、缺失/多出 route、只读调用和撤销检查结果,任何凭据只显示掩码。 + - 文档提供统一 URL、Streamable HTTP 客户端配置、S/D/PAT 认证、10 工具完整合同、错误分界、上传限制、网关配置和排障说明;工具段落由 registry 生成或经测试证明与 registry 同源。 + **测试**: T018 全部通过;按 `src/backend/scripts/AGENTS.md` 更新脚本索引 + **覆盖 AC**: AC-02~AC-06, AC-09, AC-20~AC-26 + **依赖**: T018 + +- [ ] **T020**: 编写 F067 端到端测试 + **文件**: `src/backend/test/e2e/test_e2e_f067_open_mcp.py`, `src/backend/test/e2e/helpers/open_mcp.py` + **逻辑**: + - 在 MySQL + Redis + OpenFGA 环境对 10 个工具至少各完成一次成功调用,并覆盖 S、D、PAT 的发现/执行、权限与数据范围、撤销、主体失效、资源拒绝、审计、上传和错误输出。 + - 相同用例可配置为后端直连和商业网关两套 base URL;网关覆盖 path、`Authorization`、`X-On-Behalf-Of`、`X-End-User`、`413`、长响应和无临时文件残留。 + - DM8 集中回归至少验证 MCP facade 对既有业务 Service 的调用、结果 DTO 与审计写入;不得用 mock 结果替代 DM8 结论。 + **测试**: `uv run pytest test/e2e/test_e2e_f067_open_mcp.py -m e2e` + **覆盖 AC**: AC-01~AC-26 + **依赖**: T017, T019 + +- [ ] **T021**: 执行最终质量门禁并记录验收结果 + **文件**: `features/v3.0.0-beta1/067-unified-remote-mcp-service/tasks.md`(仅勾选任务与记录真实偏差);如合同发生已确认变更,同步更新 `spec.md` / `design.md` / `tool-contracts.md` + **逻辑**: + - 执行:`uv run pytest test/open_mcp/ test/open_api/ test/knowledge/test_v2_filelib_unified.py`、相关 knowledge 回归、`uv run ruff check`(仅本 Feature 变更 Python 文件)、仓库根目录 `scripts/arch-guard.sh`。 + - 在集中环境执行 T020;再用 `scripts/verify_open_mcp.py` 分别验证直连和商业网关。确认 registry 双向差集为空、所有 outputSchema 可校验、审计无重复/泄密、临时文件零残留、现有 10 API 快照零变化。 + - 任一失败不得标记实现完成;真实偏差按 SDD 规则先回写 design 并在需要时重新向用户确认,不得用放宽测试掩盖差异。 + **测试**: 上述门禁全部通过并保存 CI/集中环境证据 + **覆盖 AC**: AC-01~AC-26 + **依赖**: T020 + +--- + +## 实际偏差记录 + +> 只记录指向 design.md 的一句话索引,不在此复制论证。推翻已确认设计或扩出 10 API allowlist 时,必须先暂停并重新取得用户确认。 + +- 暂无。 + +## 验收记录 + +- 2026-09-18:本地单元、契约与 ASGI 集成用例已完成;F067 E2E 骨架已编写,但因本地未配置 + MySQL、Redis、OpenFGA、商业网关和 DM8 验收环境而按环境门禁跳过。当前用例已覆盖 S + 模式 10 工具成功链路、D/PAT 发现与执行、PAT 写拒绝、`file_url` 及直连/网关 `413`;尚未 + 完整覆盖撤销、主体失效、资源拒绝、审计、网关 `X-End-User`、长响应与临时文件残留检查, + 因此 T020、T021 均保持未完成,不将现有 E2E 骨架或本地 mock/契约测试表述为集中环境或 + DM8 证明。 diff --git a/features/v3.0.0-beta1/067-unified-remote-mcp-service/tool-contracts.md b/features/v3.0.0-beta1/067-unified-remote-mcp-service/tool-contracts.md new file mode 100644 index 0000000000..a09b7db16a --- /dev/null +++ b/features/v3.0.0-beta1/067-unified-remote-mcp-service/tool-contracts.md @@ -0,0 +1,288 @@ +# F067 MCP 工具合同 + +> **范围真相**:[中粮 SeedMind Apifox 开发者文档](https://s.apifox.cn/a0e36780-865a-4179-b7df-b51a01d5ebcc),核对日期 2026-09-15。该文档只固定本期能力 allowlist 和业务语义;MCP 的线协议、输入/输出 schema 与错误结果以本文为准,不要求复制 HTTP API 信封。 +> +> 本文固定本期 10 个 MCP 工具的名称、顶层入参与顶层出参。Apifox 后续变更不自动改变本文;扩围或破坏性变更必须重新确认 `spec.md` / `design.md`。 + +## 1. 通用规则 + +- 所有工具入参均为 JSON object;未声明字段必须拒绝。 +- `Authorization`、`X-On-Behalf-Of`、`X-End-User` 是 MCP HTTP 请求头,不属于工具入参。 +- `X-On-Behalf-Of` 与 `X-End-User` 互斥;身份、租户、`user_id`、`tenant_id` 和凭据不得由工具参数指定。 +- MCP schema 保持 Apifox 对应 API 的业务字段含义、必填语义、默认值、枚举和限制;凡因 path/query/body、顶层数组、文件载体或 MCP schema 约束无法原样复用的字段名称/类型/结构,必须按 §2.4 显式转换。现有 API 的接口逻辑、校验、调用顺序、入参、出参、错误信封和 HTTP 状态均不随 MCP schema 改动。 +- 为兼容本期 SDK 协商的 MCP 2025-11-25 协议,所有 `inputSchema` 与成功 `outputSchema` 均以 JSON object 为根;列表放入具名字段,资源变体在 object schema 内使用 `oneOf/$defs`,不返回裸数组或裸 `null`。 +- 成功调用直接把下表声明的输出对象写入 MCP `structuredContent`,并同时提供内容等价的 JSON `TextContent` 作为兼容回退;不复制 HTTP API 的 `status_code/status_message/data` 信封,也不新增 `ok/http_status` 字段。 +- `outputSchema` 只校验成功时的 `structuredContent`。删除、清空等原 API 成功数据为空的操作,在 MCP 中统一返回 `OperationResult`,保证成功结果仍是可校验的 JSON object: + +```json +{ + "success": true +} +``` + +工具执行失败按 MCP 规范返回 `isError=true`,不提供 `structuredContent`;模型可见的 `TextContent` 必须是包含明确 `code` 与 `message` 的 JSON。已有稳定业务错误码时复用该业务码;MCP 专有输入校验使用稳定符号码。不得把 HTTP `status_code`、`http_status` 或 API 错误信封塞进 MCP 工具输出: + +```json +{ + "error": { + "code": 10962, + "message": "Knowledge resource does not exist" + } +} +``` + +未知工具、无效 JSON-RPC 消息、未预期服务端异常等协议级失败使用 MCP/JSON-RPC 标准错误(包括 Internal Error `-32603`)且明确返回协议 `code/message`。HTTP 认证或请求体上限在进入 MCP handler 前失败时,继续使用 transport HTTP 状态,也不伪造成工具结果。 + +没有既有业务码的工具执行错误只允许使用 `design.md` §4.4 固定的 MCP 符号码;不得临时拼接新错误码,也不得把 HTTP status 数字当成 MCP 业务错误码。 + +## 2. 工具清单与顶层 schema + +| MCP 工具 | source API | 输入字段 | MCP 成功输出 | +|---|---|---|---| +| `bisheng_knowledge_list` | `GET /api/v2/filelib/` | `type?: 0\|1\|3=0`;`name?: string`;`sort_by?: update_time\|create_time\|name=update_time`;`page_size?: integer=10`;`cursor?: string` | `ResourceListData` | +| `bisheng_knowledge_create` | `POST /api/v2/filelib/` | `name: string`;`type?: 0\|1\|3=0`;`description?: string\|null`;`model?: string\|null`(type 0/1 必填,type 3 忽略);`auth_type?: public\|private\|approval=public`(仅 type 3);`is_released?: boolean=false`(仅 type 3) | `Resource` | +| `bisheng_knowledge_update` | `PUT /api/v2/filelib/` | `knowledge_id: integer`;`name?: string\|null`;`description?: string\|null`。保持原 API 语义:`name` 不传不修改,`description` 不传则置空 | `Resource` | +| `bisheng_knowledge_delete` | `DELETE /api/v2/filelib/{knowledge_id}` | `knowledge_id: integer` | `OperationResult` | +| `bisheng_knowledge_clear` | `DELETE /api/v2/filelib/clear/{knowledge_id}` | `knowledge_id: integer` | `OperationResult` | +| `bisheng_knowledge_retrieve` | `POST /api/v2/filelib/retrieve` | `query: string(minLength=1)`;`knowledge_base_ids: integer[](minItems=1)`;`filters?: RetrieveFilters\|null`;`top_k?: integer[1,200]=10`;`max_content?: integer(minimum=1)=15000` | `RetrieveData` | +| `bisheng_knowledge_file_upload` | `POST /api/v2/filelib/file/{knowledge_id}` | `knowledge_id: integer`;文件来源二选一:`file_name: string` + `content_base64: string(maxLength=§2.2)`(`mime_type?: string\|null`)或 `file_url: string(uri, §2.2 受控目标)`;`parent_id?: integer\|null`;`split_mode?: auto\|custom\|hierarchical=auto`;`separator?: string[]`;`separator_rule?: string[]`;`chunk_size?: integer`;`chunk_overlap?: integer`;`retain_images?: 0\|1=1`;`force_ocr?: 0\|1=0`;`enable_formula?: 0\|1=1`;`filter_page_header_footer?: 0\|1=0` | `FileRecord` | +| `bisheng_knowledge_file_list` | `GET /api/v2/filelib/file/list` | `knowledge_id: integer`;`parent_id?: integer`;`keyword?: string`;`status?: integer[]`(1 处理中、2 成功、3 失败、4 重建中、5 排队中、6 超时、7 内容安全违规);`page_size?: integer=10`;`cursor?: string` | `FileListData` | +| `bisheng_knowledge_file_delete` | `DELETE /api/v2/filelib/file/{file_id}` | `file_id: integer` | `OperationResult` | +| `bisheng_knowledge_files_delete` | `POST /api/v2/filelib/delete_file` | `file_ids: integer[]` | `OperationResult` | + +### 2.1 两处协议适配 + +1. Apifox 上传接口的 multipart `file` 在 MCP 中表示为 `file_name`、可选 `mime_type` 和 `content_base64`;不得接受服务器本地 `file_path`。`file_url` 与 base64 文件仍严格二选一,并遵守 §2.2 的入口级限制;解码或下载后的临时文件继续执行原 API 的类型、单文件大小、角色总配额与内容安全校验。 +2. Apifox 批量删除接口的请求体是顶层 `integer[]`;MCP arguments 必须是 object,因此固定包装为 `{ "file_ids": [5001, 5002] }`。 + +### 2.2 上传入口限制 + +- 新增部署配置 `open_mcp.max_inline_upload_bytes`,默认 `50 * 1024 * 1024`。base64 路径实际允许的解码字节数为 `min(open_mcp.max_inline_upload_bytes, get_max_upload_bytes(file_name))`;大于该值的文件必须改用受控 `file_url`,不得通过调高 JSON body limit 绕过。 +- `content_base64` 的 JSON Schema 必须声明 `maxLength = 4 * ceil(open_mcp.max_inline_upload_bytes / 3)`。ASGI 在认证成功后、JSON 解析前以 `maxLength + 1 MiB` 为整个 MCP POST body 上限;商业网关使用相同上限并关闭无限缓冲。超过任一上限返回 HTTP `413`,不进入 MCP handler 或业务 Service。 +- base64 必须使用严格校验模式并按块解码到受管临时文件;禁止同时长期保留完整编码串和完整解码字节。无论成功、参数错误、取消或异常,临时文件都必须在 `finally` 中清理。 +- 解码或下载前必须先校验目标知识资源、可选父目录的写权限和租户边界;文件准备完成后仍由既有业务 Service 再次鉴权。受管文件只存在于请求私有临时目录,文件名保留既有处理链需要的内容摘要前缀,不得复制到无请求级清理机制的长期 cache。 +- `file_url` 只接受 `https`;平台配置的 MinIO share origin 可显式允许 `http`。目标 host 必须是该 MinIO origin 或 `open_mcp.file_url_allowed_hosts` 中的精确项;每次 DNS 解析和每个重定向跳转都重新校验,默认拒绝 loopback、link-local、metadata 地址和私网地址,除非该 origin 是平台明确配置的对象存储地址。 +- URL 下载必须流式写临时文件:先检查可信 `Content-Length`,读取时再次按累计字节硬截断,限制为 `get_max_upload_bytes(file_name)`;连接/读取超时和最大重定向次数必须固定。不得调用当前可一次性读取完整响应体、接受任意 URL 的通用 `async_file_download` 路径。 +- 本版 MCP schema 不暴露 `callback_url`。现有 HTTP API 的异步 worker 会在稍后重新解析并发送该 URL;在不改变冻结 API 逻辑的前提下,MCP 入口无法保证发送时 DNS 固定和禁止重定向,因此 MCP 明确收窄该可选参数并始终向公共业务能力传 `None`。后续若提供专用持久化 callback relay,需重新评审后再加入 MCP 合同。 + +### 2.3 工具行为 annotations + +annotations 只描述客户端风险与重试提示,不参与服务端授权。`tools/list` 必须返回下表固定值并纳入契约测试: + +| MCP 工具 | `readOnlyHint` | `destructiveHint` | `idempotentHint` | `openWorldHint` | +|---|---:|---:|---:|---:| +| `bisheng_knowledge_list` | true | false | true | false | +| `bisheng_knowledge_create` | false | false | false | false | +| `bisheng_knowledge_update` | false | false | true | false | +| `bisheng_knowledge_delete` | false | true | true | false | +| `bisheng_knowledge_clear` | false | true | true | false | +| `bisheng_knowledge_retrieve` | true | false | true | false | +| `bisheng_knowledge_file_upload` | false | false | false | true | +| `bisheng_knowledge_file_list` | true | false | true | false | +| `bisheng_knowledge_file_delete` | false | true | true | false | +| `bisheng_knowledge_files_delete` | false | true | true | false | + +### 2.4 API → MCP 单向兼容规则 + +现有 source API 是冻结兼容基线;本文件只定义 MCP 对外合同。实现不得为满足 MCP schema 修改 source API 的 endpoint 接口逻辑、校验与分支、调用顺序、副作用、签名、path/query/body 位置、默认值、响应字段、错误信封或 HTTP 状态。不能原样复用时按下表只在 MCP adapter 内转换: + +| 场景 | 现有 API 保持 | MCP 兼容方式 | +|---|---|---| +| path/query/body 参数 | 原参数位置和 FastAPI 校验不变 | 展平为工具 arguments object;adapter 重建业务 command | +| 批量删除 | 顶层 `integer[]` 不变 | MCP 使用 `{ "file_ids": [...] }`,adapter 解包后调用既有能力 | +| 文件上传 | multipart `UploadFile` / `file_url` 不变 | MCP base64 流式落临时文件并构造成上传输入;或使用 §2.2 受控 URL | +| 成功信封 | API 继续返回当前 `status_code/status_message/data` | MCP 去除 HTTP 信封,只把本文件定义的成功 DTO 直接写入 `structuredContent`;空成功映射为 `OperationResult` | +| 异构资源/文件响应 | API 当前 type 0/1/3、知识库/知识空间形态不变 | MCP `outputSchema` 使用明确 `oneOf`/可选字段,复制实际存在字段,不合成不存在字段 | +| 工具执行错误 | API 现有业务校验/异常分支、业务码、消息、data、错误信封和 HTTP 状态不变 | MCP adapter 返回 `isError=true`,不设置 `structuredContent`;JSON `TextContent` 明确给出 `error.code/error.message`,不沿用 HTTP 状态字段 | +| 协议错误 | API 不涉及未知 MCP 工具、畸形 JSON-RPC 或 MCP server crash | 返回标准 JSON-RPC error 的 `code/message`,不伪造成工具业务结果 | + +输出映射函数必须按工具显式登记,例如 `map_knowledge_resource_to_mcp`、`map_file_item_to_mcp`;禁止反射式返回任意 ORM 字段。若 source API 后续新增字段,MCP 不自动暴露,须重新确认本合同。`actions` 与 `TagItem[]` 已能被 MCP JSON Schema 原样表达,因此不做 `actions → permission_ids` 或 `TagItem → name` 转换;这两类转换既不是业务需要,也不是协议需要。 + +## 3. 输出模型 + +### `OperationResult` + +```text +success: true +``` + +### `Resource` + +`Resource` 必须覆盖当前业务能力返回的 `KnowledgeRead`、知识空间列表装饰字段,以及知识空间更新返回的持久化模型。公共字段 `id / name / type` 必须存在;带 `?` 的字段允许缺失,带 `| null` 的字段允许显式 `null`。`additionalProperties=false`;有效动作字段原样使用 `actions`,不引入 `permission_ids` 别名。 + +```text +id: integer +name: string +type: 0 | 1 | 3 +user_id?: integer | null +tenant_id?: integer | null +description?: string | null +model?: string | null +collection_name?: string | null +index_name?: string | null +state?: integer | null +auth_type: public | private | approval +is_released: boolean +is_shared: boolean +auto_tag_enabled: boolean +auto_tag_library_id?: integer | null +metadata_fields?: MetadataField[] | null +create_time?: string(date-time) | null +update_time?: string(date-time) | null + +# KnowledgeRead / 列表装饰字段 +user_name?: string | null +copiable?: boolean | null +is_pinned?: boolean | null +actions?: string[] | null +is_followed?: boolean +subscription_status?: subscribed | pending | rejected | not_subscribed +user_role?: creator | admin | member | null + +# 知识空间 update 当前持久化返回可能携带 +creation_request_id?: string | null +creation_payload_hash?: string | null +``` + +实现使用一个显式的 MCP 专用 `KnowledgeResource` DTO:`id/name/type` 与公共布尔字段固定,知识库视图和知识空间持久化形态的差异字段按上表声明为可选;不得直接把 ORM/SQLModel 当作 MCP 输出模型。这样保持 `outputSchema` 的 object 根节点,也不会因 source API 某一分支缺少装饰字段而伪造值。映射器从现有 API/业务返回对象读取当前字段生成 MCP DTO,但现有 HTTP adapter 继续沿用原响应模型,不切换为 MCP DTO。 + +### `ResourceListData` + +```text +data: Resource[] +page_size: integer +has_more: boolean +next_cursor: string | null +``` + +### `RetrieveFilters` + +```text +knowledge_base_filters: Array<{ + knowledge_base_id: integer, + tags: string[], + tag_match_mode: "ANY" | "ALL" = "ANY" +}> +``` + +`ALL` 按当前 API 合同保留但尚未投入业务使用;调用结果不得伪装为已支持的匹配语义。 + +此处 `tags: string[]` 是检索过滤器自身的标签名称入参,不是把文件列表输出中的 `TagItem[]` 扁平化;两份合同分别保持各自类型。 + +### `RetrieveData` + +```text +chunks: Array<{ + content: string, + knowledge_id: integer, + document_id: integer, + document_name: string, + document_update_time: string, + chunk_index: integer +}> +total: integer +``` + +### `FileRecord` + +`FileRecord` 是上传返回的公共文件记录。除 `id / knowledge_id / file_name / file_type` 外,以下字段按现有业务模型允许缺失或为 `null`: + +```text +id: integer +knowledge_id: integer +file_name: string +file_type: 0 | 1 +user_id?: integer | null +user_name?: string | null +tenant_id?: integer | null +thumbnails?: string | null +file_source?: string | null # 当前已知值:upload/channel/space_upload/audio_transcript/video_transcript/web_link +level?: integer | null +file_level_path?: string | null +abstract?: string | null +file_size?: integer | null +md5?: string | null +parse_type?: string | null +split_rule?: string | null +preview_file_object_name?: string | null +bbox_object_name?: string | null +status?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | null +object_name?: string | null +user_metadata?: object | null +remark?: string | null +file_encoding?: string | null +simhash?: string | null +similar_status?: integer +updater_id?: integer | null +updater_name?: string | null +create_time?: string(date-time) | null +update_time?: string(date-time) | null + +# 知识空间上传扩展 +old_file_level_path?: string | null +approval_request_id?: integer | null +approval_status?: string | null +approval_reason?: string | null +is_pending_approval?: boolean +version_no?: integer | null +is_multi_version?: boolean +has_similar?: boolean +``` + +### `FileListData` + +```text +data: FileItem[] +page_size: integer +has_more: boolean +next_cursor: string | null +writeable: boolean +``` + +### `FileItem` + +`FileItem` 以 `FileRecord` 为基础,并允许知识库与知识空间列表各自已有的装饰字段: + +```text +...FileRecord +title?: string | null +tags?: TagItem[] +has_failed_files?: boolean +has_abnormal_files?: boolean +success_file_num?: integer +processing_file_num?: integer +version_no?: integer | null +is_multi_version?: boolean +has_similar?: boolean +``` + +`MetadataField` 对齐当前知识资源元数据字段: + +```text +field_name: string(maxLength=255, pattern=^[a-z][a-z0-9_]*$) +field_type: string | number | time +updated_at: integer +``` + +`TagItem` 对齐当前标签响应: + +```text +id?: integer | null +name?: string | null +business_type?: string +business_id?: string | null +user_id?: integer +tenant_id?: integer | null +create_time?: string(date-time) | null +update_time?: string(date-time) | null +``` + +实现必须为 `OperationResult / Resource / MetadataField / FileRecord / FileItem / TagItem` 生成完整 MCP JSON Schema,不能用无约束顶层 `object` 代替。只有现有 API 本身定义为动态键值映射的 `user_metadata` 允许 `additionalProperties`,其值 schema 必须直接复用当前业务模型;知识库与知识空间响应允许通过 `oneOf` 表达。MCP 成功结果必须等于“既有业务结果经过 §2.4 MCP 适配”的结果,不要求 API 原始 JSON 直接通过 MCP `outputSchema`。 + +## 4. 交付校验 + +- `tools/list` 恰好注册本文件的 10 个工具,并按 scope/凭据类型过滤可见集合;registry 与本清单双向差集必须为空。 +- 每个工具必须同时输出 `inputSchema`、`outputSchema` 与 §2.3 annotations;MCP schema 以本文件为准,并通过 §2.4 映射兼容当前 source API 与 Apifox 合同,不反向改变 API。 +- 10 个工具至少各完成一次成功调用,并覆盖参数错误、资源无权限、凭据失效和依赖不可用的拒绝路径;工具执行错误必须断言 `isError=true`、无 `structuredContent`,且模型可见 JSON 中存在非空 `error.code` 与 `error.message`;未知工具、畸形请求和未预期服务端异常必须断言为标准 JSON-RPC error 且存在协议 `code/message`。 +- 同一主体、等价业务输入经 API 与 MCP 调用时,数据归属、持久副作用、权限结论和业务结果含义必须一致;线格式差异只允许来自 §2.4 的显式映射。 +- 契约测试必须先冻结 source API 的接口逻辑、请求/响应和主要错误快照,再用 type 0、1、3 分别覆盖资源列表/创建/更新,并用知识库与知识空间分别覆盖文件上传/列表;将业务结果经过对应 mapper 后再用 MCP `outputSchema` 校验,同时断言 API 快照未变,MCP 保留 `actions` 与结构化 `TagItem[]`,没有无依据的 `permission_ids` 或标签名称扁平化。 diff --git "a/features/v3.0.0-beta1/068-code-execution-sandbox/LocalExecutor\345\256\211\345\205\250\344\274\230\345\214\226\346\226\271\346\241\210-\347\254\254\344\270\200\346\255\245-uid\351\232\224\347\246\273.md" "b/features/v3.0.0-beta1/068-code-execution-sandbox/LocalExecutor\345\256\211\345\205\250\344\274\230\345\214\226\346\226\271\346\241\210-\347\254\254\344\270\200\346\255\245-uid\351\232\224\347\246\273.md" new file mode 100644 index 0000000000..c4956ae669 --- /dev/null +++ "b/features/v3.0.0-beta1/068-code-execution-sandbox/LocalExecutor\345\256\211\345\205\250\344\274\230\345\214\226\346\226\271\346\241\210-\347\254\254\344\270\200\346\255\245-uid\351\232\224\347\246\273.md" @@ -0,0 +1,490 @@ +# LocalExecutor 安全优化方案 · 第一步:普通 uid + 私有运行目录 + +状态:已实现,默认 `off`(Compose 默认开启,见 [沙箱部署说明](./沙箱部署说明.md) 3.4)。实现与本文的出入见"代码改动"表下方的说明。本机模式停在这一步。第二步(chroot 监狱)暂时不做,文档保留在 [LocalExecutor安全优化方案-第二步-chroot监狱.md](./LocalExecutor安全优化方案-第二步-chroot监狱.md),不排期、不实现。原始设想 [LocalExecutor安全优化方案.md](./LocalExecutor安全优化方案.md) 里的"同会话目录交给临时 uid""硬链接 venv"两处设计已被本文替代。 + +## 目标 + +只改后端代码和配置,不动镜像,把 `type=local` 的子进程从"跟 worker 同为 root、继承全部环境变量、直接在任务工作区里运行"改成"换成普通 uid、环境变量白名单、只在私有运行目录里运行"。私有运行目录有两种寿命:代码节点每次调用一个、用完即删;灵思按任务保留一个,任务结束、工作区被删时一起删(见"运行目录的两种寿命")。 + +解决: + +- 用户代码拿不到 worker 的环境变量(数据库、Redis、MinIO 密钥);读不到 `/root` 下的东西(`/root/.cache/bisheng/` 里有所有用户的上传缓存和各任务工作区)。 +- 用户代码改不了、也换不掉任务工作区里的任何东西;写不了工作区和 `/root`。 +- 输出、磁盘、进程数、内存有上限;超时后按 uid 清掉残留进程;进程被杀后遗留的运行目录会被清掉,不会被下一个拿到同一 uid 的调用读到。 +- 去掉 `insert_set_font_code` 每次删除进程共享字体缓存的竞态(仅在隔离模式下)。 + +不解决(见"剩余风险"): + +- **对所有用户可读的文件仍然可读**。`/app/bisheng`(含挂进来的 `config.yaml`)和 `/app/data` 由"权限收紧"一节单独关上,启动时实测;其余如 `/etc`、`/usr`、`/app/.venv`、`/app/bisheng_langchain` 仍可读。 +- **`/tmp`、`/var/tmp`、`/dev/shm` 这类全局可写目录**:脚本可以在里面写文件,也可以读别的运行留在那里的、权限为 `0644` 的文件。是否收紧是另一项可选加固,不在本方案内。 +- 网络、内核漏洞。 + +这些不在本方案内。chroot 监狱暂时不做;需要挡住它们时用 `type=container`。 + +## 现状缺口(已对照代码核实) + +| 缺口 | 位置 | +|---|---| +| 子进程与 worker 同为 root | `local_executor.py::_execute_code`,镜像没有 `USER` | +| 环境整份拷进子进程 | `_child_env`:`os.environ.copy()` | +| 子进程直接在任务工作区里运行 | `run`:`run_with_dir(dir_path=self.local_sync_path)`,灵思的 `file_dir` 是 `/root/.cache/bisheng/linsight/`(`task_exec.py:931`) | +| 输出无上限 | `proc.communicate()` 把 stdout / stderr 全读进 worker 内存 | +| 无资源上限 | 容器模式的 runner 有 `RLIMIT_AS` / `NPROC` / `CPU`(`sandbox-runner/execute.py:34`),本机模式一个都没有 | +| 共享字体缓存被并发删除 | `insert_set_font_code`:`os.remove(fontlist*)` | +| 只有文本检查 | `workspace_escape_guard`,不是边界 | + +## 核心设计:子进程从不接触任务工作区 + +直接把 `file_dir` 交给临时 uid 行不通:`file_dir` 在 `/root` 下,普通 uid 进不去;而且父进程还要往里写脚本、写知识库图片、收文件,这些写入都可能被用户代码事先放好的符号链接带出去。 + +所以改成和容器模式一样的数据流: + +```text +任务工作区 W(root 独占) --复制进来--> 私有运行目录 R(归当前 uid) --只收普通文件--> W +``` + +一次 `execute_code(work_dir=W)` 的流程。`work_dir` 为 `None` 时不进入这条流程,直接失败:原来的默认值是共享的 `extensions/` 目录,不能把它当工作区复制进去再收回来。 + +1. 取一个 uid(见"uid 池")。取到之后,先删掉 `local_run_root` 下所有属主是它的目录。保留模式下,该 W 已经有 uid 和 R,直接沿用,跳过第 1、2 步。 +2. 在 `local_run_root` 下 `mkdtemp` 建 R,`chown` 给该 uid,权限 `0700`。R 里再建 `.tmp/`、`.mpl/`。 +3. 把 W 复制进 R(规则见下)。临时模式全量复制;保留模式只复制 W 里新增或变化的文件。两种模式都记录每个文件的 `(size, mtime_ns)`。知识库图片暂存发生在 `execute_code` 之前,已经在 W 里,会被一起带进去,`KbImageStage` 不用改。 +4. 把脚本写进 R(脚本名沿用 `tmp_code_.py`,收文件时按名字排除)。 +5. 以该 uid 启动子进程,`cwd=R`,限制资源,读输出直到退出或超时。 +6. 无论如何都清场:以该 uid 发 `kill(-1, SIGKILL)`,确认 `/proc` 里该 uid 已无存活进程。 +7. **只有第 6 步确认成功**,才把 R 里新增或改变的普通文件收回 W(规则见下)。临时模式随后 `rmtree(R)`,删标记,释放 uid;保留模式留下 R 和 uid,等工作区被删时再释放。 +8. 第 6 步确认失败:不收回任何文件,不删 R,保留标记,这个 uid 不再分配,记 error 日志。R 留给后续回收(见"uid 池")。保留模式下同时把这个 W 的登记作废,下次调用重新取 uid、重新建 R、全量复制。 +9. 返回日志前,把日志里的 R 替换成 W(模型看到的路径保持一致)。 + +`run_with_dir` 的快照差分、`output/` 收割、`sync_to_workspace`、`sync_files_to_local` 都不动:它们看到的还是 W,只是 W 里的新文件由第 7 步写入。 + +### 运行目录的两种寿命 + +| | 临时模式 | 保留模式 | +|---|---|---| +| 谁用 | 工作流代码节点(`work_dir` 是每次新建的临时目录,没有稳定的 W) | 灵思(W 是整个任务期间不变的 `file_dir`) | +| R 的寿命 | 一次调用 | 从该 W 的第一次调用起,到该 W 被删时止 | +| 复制进来 | 全量 | 增量,只复制 W 里新增或变化的文件 | +| uid | 每次调用取、调用完还 | 第一次调用取,W 被删时还 | + +**为什么能做保留**:灵思的 W 在 `task_exec.py::_cleanup_resources` 里被 `shutil.rmtree`(任务上下文的 `finally`),寿命与任务一致。R 跟着 W 走,不需要另设空闲超时和独立的会话生命周期。 + +**怎么选模式**:灵思在构造代码解释器时,往 `code_config["config"]["local"]` 里写 `keep_run_dir = True`(和现有的 `local_sync_path`、`workspace_prefix` 写在同一处,`workbench_impl.py`);没有这个标记就是临时模式。 + +**登记表**:保留模式在 worker 进程内维护一张表,键是 `realpath(W)`,值是: + +- uid 和 R(uid、R 在第一次调用时才真正领取,但**登记条目本身在构造执行器时就创建**,见"释放钩子的覆盖"第 3 点)。 +- 同步索引:`{相对路径: (size, mtime_ns)}`,表示 R 与 W 此刻一致的那些文件。 +- 两把锁和一个 `closing` 标记(见下面"同一个 W 的调用串行"):外层 `asyncio.Lock` 负责排队,内层 `threading.Lock` 负责真正的互斥。 +- 登记条目的句柄存在执行器上,条目有"已关闭"状态;调用看到已关闭就直接返回"任务已结束",不会重新注册。同一个 W 的下一轮会新建句柄,不受旧条目的"已关闭"影响。 + +**释放钩子**:新增 `release_local_run(W)`,在 `_cleanup_resources` 里、`rmtree(W)` 之前调用(要放进线程池,因为里面有杀进程和删目录的阻塞操作;worker 进程里所有任务共用一个事件循环,阻塞会拖住同进程的其他任务)。它只碰内层 `threading.Lock` 和 `closing`,**不碰外层 `asyncio.Lock`**。放置要求: + +- **放在自己的 `try` 里,排在 `_cleanup_resources` 的第一位**,不与 `_stop_termination_monitor()` 共用同一个 `try`。现有代码里后者抛异常会跳过后面的 `rmtree`,钩子不能因此被跳过。 +- **按 `self.file_dir` 操作,不套用现有的 `os.path.exists` 判断**:W 目录已经不在,R 照样要释放;`file_dir` 为 `None`(驱动在初始化工作区之前就失败)时直接返回。 +- **用 `asyncio.shield(asyncio.to_thread(...))` 调用**。清理阶段被再次取消(例如 worker 关闭)时,释放线程仍会跑完,不会让"调用方去删 W、释放还没做完"的顺序失控。 + +顺序: + +1. 置 `closing = True`,对该 uid 清场并确认;这会让正在跑的那次调用提前返回。 +2. 等内层锁(带超时),正在跑的那次调用发现 `closing` 后不做收回,直接返回"任务已结束"。 +3. 删 R,删标记,归还 uid,从登记表里去掉。排队中的调用手里还握着这个登记条目的引用,拿到锁后看到 `closing`,同样直接返回。 +4. 清场未确认时和平时一样:不删 R,不还 uid,留给启动清扫。 + +任务结束时不做收回,因为 W 马上就删。 + +#### 释放钩子的覆盖(已对照 `task_exec.py` 核实) + +**已覆盖的路径**: + +| 入口 | 上下文管理器 | 清理 | +|---|---|---| +| `async_run`(新任务) | `_managed_execution` | `finally` 里 `_cleanup_resources` | +| `async_resume`(回答 ask_user 后续跑) | `_managed_resume` | 同上 | +| `async_continue`(继续对话) | `_managed_resume` | 同上 | + +- **停靠**:任务停靠时协程直接返回,同样走这条 `finally`,W 和 R 在停靠时就释放了。续跑、继续对话每次都重新 `_init_file_directory`、重建工具,每一轮是新的 W、新的登记。 +- **谁会设 `keep_run_dir`**:`_init_bisheng_code_tool` 在生产代码里只有 `task_exec._generate_tools` 一个调用方,其余都是测试。工作站日常对话建代码工具时没有 `local_sync_path`,走临时模式,不会遗留登记,不需要钩子。 +- **三处 `one.close()`**(`_execute_workflow`、`_resume_workflow`、`_continue_workflow` 的 `finally`):`LocalExecutor.close()` 来自基类,是空操作,释放不能指望它。它们在驱动结束时先执行,早于 `_cleanup_resources`。 +- **驱动在初始化工作区之前失败**(例如 `TaskAlreadyInProgressError` 在 `try` 之前抛出、或附件摄取失败):`file_dir` 还是 `None`,也没有构造过代码执行器,钩子直接返回,不会误释放正在运行的另一个驱动的登记。 + +**需要补进设计的四处**: + +1. **钩子独立于 `_cleanup_resources` 里现有的 `try`**,且按 `self.file_dir` 操作,不依赖 `exists`。见"释放钩子"的放置要求。 +2. **终止时线程还在跑**:终止路径是 `task.cancel(); await task`。`agent_task` 如果正阻塞在 `await asyncio.to_thread(...)`,取消只让等待方返回,线程里的子进程仍在执行,驱动随后进入 `_cleanup_resources`。释放钩子的"置 `closing`、清场杀进程、等内层锁"就是为这种情况设计的;钩子的调用要用 `asyncio.shield` 包住,见上。 +3. **释放之后到达的调用,不能重新建登记**:登记条目在 `_init_bisheng_code_tool` 构造执行器时创建,句柄存在执行器上;`release_local_run(W)` 把它标为已关闭,之后到达的调用直接返回"任务已结束",不会为已删除的 W 新建 R 和 uid。 +4. **启动清扫放在每个 worker 进程里**:`ScheduleCenterProcess.run` 里、进入主循环之前,每个进程各跑一次。判断依据是 uid 池标记里的 pid 加启动时间是否对应存活进程,不能一律清掉,否则会误删同一台机器上其他 worker 进程正在用的 R(`--worker_num` 默认 4 个进程同时启动)。现有的 `check_and_terminate_incomplete_tasks` 只处理数据库里的会话状态,不管磁盘,这里要新加。 + +**钩子覆盖不了、只能靠启动清扫的场景**:进程崩溃(`kill -9`、OOM、容器重启)时 `finally` 不会执行,W 和 R 都留下。清扫只清 R;W 目录本身没人清,这是现状,与本方案无关。 + +**已知但不处理的**:Redis 故障时 `_acquire_session_run_lock` 会放行,理论上同一个会话可能被两个驱动同时跑。同一进程内它们会共用一个登记条目,一个结束会连带释放另一个的 R。现有代码里 `rmtree(W)` 本来也会把另一个驱动的工作区删掉,这是已有的风险,本方案不新增也不解决,只在日志里留痕。 + +#### 同一个 W 的调用串行 + +**为什么要串**:同一个 uid 下的两个进程共用一个 R,其中一次调用结束时的清场(`kill(-1)`)会把另一次调用的进程也杀掉;两次调用对 R 的复制进来、收回也会互相踩。串行是清场安全的前提之一,详见"uid 池"里的"清场的范围与前提"。 + +**确实会并行**:灵思用 `create_deep_agent`,工具节点 `ToolNode._afunc` 对同一条模型回复里的全部工具调用做 `asyncio.gather`。`bisheng_code_interpreter` 不在子代理的禁用名单里,主图和研究员子代理用的是同一个工具实例,没有任何锁。所以同一条消息里的两次代码调用,或者主图与子代理的代码调用,现在会同时在 W 里运行。`--max_concurrency` 限制的是不同任务,管不到任务内部。 + +**不能当作"一个任务一个工具对象"来设计锁**:当前每次驱动只构造一个 `CodeInterpreterTool`(`_init_bisheng_code_tool` 构造后把工具 id 从选择列表里删掉,不会再造副本;子代理拿的是同一份列表)。但 `create_deep_agent` 会对描述被覆盖的工具做 `model_copy`,是浅拷贝,工具对象变两个而 `executor` 还是同一个。所以锁不放在工具对象上,放在按 `realpath(W)` 索引的登记条目上。 + +**范围**:只在 `local_isolation=uid` 的保留模式下生效。`off`、容器模式、E2B 以及代码节点的行为不变,锁不加在 `CodeInterpreterTool` 的所有路径上。 + +**怎么串**:`LocalExecutor` 新增 `arun`,工具的 `_arun` 只调它;`off` 下 `arun` 直接转到原来的同步 `run`。保留模式下: + +```text +async with entry.queue: # 外层 asyncio.Lock:FIFO 排队,不占线程 + await asyncio.to_thread(run_locked) # run_locked 里先拿内层锁再执行 +# run_locked: +# 循环 inner.acquire(timeout=1),每次失败检查 entry.closing,为真就返回"任务已结束" +# 拿到后执行:复制进来、跑子进程、清场、收回 +``` + +- **外层 `asyncio.Lock`**:`gather` 按模型发出的顺序启动各个调用,FIFO 保持"先发出的先执行",同一批里先写文件、后读文件的依赖大多能成立(`threading.Lock` 不保证这个顺序)。排队的调用在这里等,不占线程池线程。 +- **内层 `threading.Lock`**:真正的互斥。任务被取消、`await to_thread(...)` 被打断时,外层 `async with` 会立刻释放,但线程里的子进程可能还在跑;内层锁在线程退出前一直持有,下一个调用不会进到同一个 R 里。 +- **加锁顺序固定**:先外层再内层。`release_local_run` 只碰内层和 `closing`,所以不会死锁。 +- **等锁时间不计入脚本的超时**,超时只从拿到内层锁后开始计。 +- **等待可以被打断**:内层 `acquire` 用带超时的循环,每秒检查一次 `closing`。任务被终止时,排队的调用很快返回,不用等持锁的那次自然结束。 + +**为什么排队不占线程**:worker 进程只有一个事件循环,所有任务共用一个默认线程池(`min(32, CPU 核数 + 4)`),一次代码调用要占一个线程,可能占很久。排队的调用若都在线程里等 `threading.Lock`,会加剧线程池耗尽,所以排队放在 `asyncio.Lock` 上。 + +**代价**:同一条消息里的多个代码调用,总耗时变成各次相加;批量并行调用一般不多。 + +**不采用的备选**: +- 忙时退回临时模式(另开 uid、全量复制,各自跑完收回):保留并行,但多占 uid、多一次全量复制,两次调用同时写 W 的竞态也没有解决。 +- 只用 `threading.Lock`:等待者各占一个线程,也不保证调用顺序。 + +**崩溃**:进程被 `kill -9` 时,登记表丢了,W 和 R 都留在原地。R 靠"uid 池"里的遗留回收和上面的启动清扫清掉;同一个任务再次启动时没有登记,按第一次调用处理,全量复制。 + +**不跨进程**:登记表在进程内,同一个 W 不会被两个进程同时使用,灵思一个任务只在一个 worker 进程里执行。 + +**事件循环**(已对照 `linsight/worker.py` 核实):每个 worker 进程只有一个事件循环,进程内所有灵思任务共用它,每个任务是这个循环上的一个 `asyncio.create_task`;`--worker_num` 个进程用 `spawn` 启动,互相独立;`asyncio.Semaphore(max_concurrency)` 按进程限制并发。因此 `asyncio.Lock` 不会遇到跨循环的问题,登记条目随 `release_local_run` 一起销毁,不会带到别的任务。任务停靠(等待用户输入)时走 `_cleanup_resources`,W 和 R 都已释放;续跑可能落在别的进程或节点上,那里没有登记,按第一次调用处理。 + +### 复制进来 + +- 只复制普通文件。跳过符号链接、套接字、FIFO、设备,以及隐藏名和 `__pycache__`(与 `_walk_regular_files` 同口径)。 +- 单个文件超过 `local_copy_in_file_bytes` 就跳过,并和容器模式一样在日志末尾加一条 `[SYSTEM NOTICE]`,说明哪些文件没带进去。总量超过 `local_copy_in_total_bytes` 同样停止并提示。 +- **不复用容器模式的 `max_copy_in_bytes`(默认 50MB)。** 上传的原始文件进工作区时上限本来就是 50MB(`workbench_impl.py` 的 `_RAW_KEEP_MAX_BYTES`),所以它们不会被挡住;会被挡住的是模型自己写进 `scratch/`、`output/` 的大中间文件。本机模式现在直接在工作区里读,没有这道上限,沿用 50MB 会让第二次调用读不到上一次写出的大文件。 +- 用 `shutil.copy2` 保留 mtime,目标文件 `lchown` 给该 uid,权限设为 `0644`(原来有可执行位的保留 `0755`)。不用硬链接:硬链接的文件仍归 root,脚本想在原位修改上一步的产物会被拒绝。 +- **临时模式**(代码节点):每次全量复制。工作区一般很小,不做增量。 +- **保留模式**(灵思):只复制"需要复制"的文件,对每个 W 里的文件做一次 `lstat`,满足下面任一条才复制: + - 不在同步索引里(新增)。 + - W 里的 `(size, mtime_ns)` 与索引不同(变了;以 W 为准,覆盖 R 里的同名文件)。 + - 在索引里,但 R 里已经没有了(被脚本删了;按 W 补回)。 + + 另外,索引里有而 W 里已经没有的文件,把 R 里的副本删掉并从索引去掉,让 R 与 W 保持一致。与容器模式相比:只用 `lstat`,不读文件内容算 md5。 +- 每次复制记一条 debug 日志(文件数、字节数、耗时、是否增量),方便线上确认效果。 +- `local_copy_in_total_bytes` 管的是**本次调用复制进来的字节数**;R 的总量由磁盘监视(`local_disk_limit_bytes`)兜底。保留模式下 R 与 W 各占一份磁盘。 + +### 收回来 + +只在清场确认成功之后才做(流程第 7 步)。即便如此,仍按"R 里全是不可信内容"处理: + +- 逐级用 `O_NOFOLLOW` 打开(目录加 `O_DIRECTORY`,文件加 `O_NONBLOCK`),对**打开得到的这个 fd** 做 `fstat` 判断:必须是普通文件,`st_nlink == 1`,大小不超过上限。之后的读取也用这同一个 fd,不按路径重新打开。符号链接、FIFO、设备、硬链接文件一律不收。 +- 只收"不在同步索引里,或 `(size, mtime_ns)` 变了"的文件(临时模式的索引就是本次复制进来的清单);跳过隐藏名、`__pycache__` 和脚本文件。 +- 写入 W 时先写同目录下的临时名,再 `os.replace`;目标文件归 root、`0644`。W 只有 root 能写,路径上若发现符号链接就放弃这个文件。 +- 写完后用 `os.utime` 把 W 里这个文件的 mtime 设成和 R 里一致,并把 `(size, mtime_ns)` 写进同步索引。这样下一次调用的差分不会把它当成"W 里新变的文件"又复制一遍。(保留模式用;临时模式不需要,但做了也无妨。) +- 单文件上限用 `local_rlimit_fsize_bytes`,总量上限用 `local_disk_limit_bytes`。 +- 不同步删除:脚本在 R 里删掉的文件,W 里还在。这与容器模式一致,也符合 `sync_to_workspace` 对删除的现有约定;保留模式下,下一次调用还会按 W 把它补回 R。本机模式以前脚本可以直接删 W 里的文件,这是一处行为变化。 +- 无论退出码是不是 0 都收,保持本机模式现有语义(失败前写出的文件仍留在工作区)。因磁盘超限被杀的运行除外,不收。 + +### 绝对路径兼容 + +模型会在日志里看到宿主路径(`os.getcwd()`、traceback),也可能把 W 的绝对路径写进脚本。R 与 W 不同,所以: + +- 脚本入库前,把代码文本里出现的 W 字面量原样替换为 R。只替换这一个精确前缀,并且要卡边界:只替换"后面是字符串结尾,或者紧跟 `/`"的那一处,`W` 是更长路径前缀的情况不动(例如 `/…/linsight/ab12cd34` 不能误伤 `/…/linsight/ab12cd34ef`)。 +- 日志返回前把 R 替换回 W(第 9 步),同样卡边界。 +- `workspace_escape_guard` 保留,对 W 前缀的豁免不变,仍按 W 判断。 + +## 子进程怎么启动 + +```python +subprocess.Popen( + ["prlimit", "--as=…", "--cpu=…", "--nproc=…", "--fsize=…", "--nofile=…", "--core=0", "--", + sys.executable, "-s", "-B", "-X", "utf8", script_name], + cwd=R, env=env, user=uid, group=uid, extra_groups=[], + stdin=subprocess.DEVNULL, stdout=PIPE, stderr=PIPE, + start_new_session=True, close_fds=True, +) +``` + +- `user=` / `group=` / `extra_groups=` 是 `subprocess` 自带参数(3.9 起),换 uid 发生在 fork 与 exec 之间的 C 代码里,在 `-P threads` 的 worker 里是安全的。不用 `preexec_fn`,不引入 `chroot` 命令。 +- `extra_groups=[]` 必须给,否则 root 的附加组会被带进子进程。 +- 资源上限用 `prlimit` 命令套一层:它先以子 uid 身份给自己设上限,再 `exec` 解释器,没有引导脚本,traceback 里也没有多余的栈帧。`RLIMIT_NPROC` 按真实 uid 计数,所以必须在换 uid 之后生效,这也是不能把进程数上限加在 worker 本身的原因。 +- `prlimit` 来自 util-linux。**已在 107 部署上核实**:`bisheng-backend`、`bisheng-backend-worker`(镜像 `bisheng-backend:feat_v3.0.0-beta1_068-container-sandbox`,Debian 13)里都有 `/usr/bin/prlimit`(util-linux 2.41),沙箱 runner 里也有。util-linux 是 Debian 的基础必装包,换成别的 Debian 系基础镜像也会有;但其他发行版的基础镜像或精简镜像仍要靠预检兜底。 +- 保留现有的 `lang` 处理:`python*` 用 `sys.executable`,`sh` / `bash` 仍可运行,同样以该 uid 和同样的上限运行。第一步不收紧语言。 + +### 环境变量 + +只给白名单,其余全部不带: + +| 变量 | 值 | +|---|---| +| `PATH` | 取父进程的值(LibreOffice 等仍可用) | +| `LANG` / `LC_ALL` | `C.UTF-8` | +| `TZ` | 父进程有则带上 | +| `HOME` | R(沿用现有设计:`~` 落在工作区内) | +| `TMPDIR` / `TEMP` / `TMP` | `R/.tmp` | +| `MPLCONFIGDIR` | `R/.mpl` | +| `USER` / `LOGNAME` | `sandbox`(`getpass.getuser()` 先读这两个变量,随机 uid 没有 passwd 条目也不会报错) | + +不继承 `BS_*`、`PYTHONPATH`、`LD_*`、代理、各类密钥变量。 + +### 字体缓存 + +- 在隔离模式下不再删 `fontlist*`。`insert_set_font_code` 里那段删除只保留在 `local_isolation=off` 的路径上,这样 `off` 与现状完全一致;做法是把"删缓存"和"插入字体设置代码"拆成两步,隔离模式只做后者。 +- 父进程第一次用到时(加锁,进程内一次)`import matplotlib.font_manager`,让 root 自己的缓存目录(`matplotlib.get_cachedir()`,在 `/root/.cache` 下)生成缓存;检查缓存里有 `WenQuanYi Zen Hei`,没有就强制重建一次。 +- 每次运行把 `fontlist-*.json` 复制进 `R/.mpl`,归该 uid。这个文件很小,复制代价可以忽略。 +- 普通 uid 读不到 `/root/.cache`,不预填充的话每次都会重建缓存,这就是要复制的原因。 + +### 输出读取 + +用 `selectors` 同时读 stdout 和 stderr,不用 `communicate()`。每个流保留开头 4 MiB 和结尾 512 KiB,两者之和以内的内容原样拼回,超出的中间部分丢弃并插入一行提示。开头取 4 MiB 是为了让代码节点的一条结果(JSON 上限 4 MiB,再加很短的前缀)整行留下;结尾留 512 KiB,因为失败日志取 stderr 末尾(traceback 在末尾)。两个流合计超过 256 MiB 立即杀掉并报错。 + +## uid 池 + +- 区间 `30000`–`39999`,常量写在代码里,不进配置。该区间不能与容器里已有的 uid 冲突:清场的 `kill(-1)` 会杀掉这个 uid 的全部进程,若别的服务恰好用了这段 uid 会被误杀。后端容器里的服务目前都是 root,没有冲突;预检时扫一遍 `/proc/*/status`,发现区间内已有不属于池标记的进程就拒绝启用隔离并写明 uid(见"开关与失败策略")。 +- 标记目录 `/var/run/bisheng-local-uids`(root,`0700`)。标记内容是占用进程的 pid 和启动时间(`/proc//stat` 第 22 列)。 +- 发布标记:先写本线程私有的临时文件,再用 `os.link` 链成 ``。链接是原子的,目标已存在就失败,所以多线程、多进程之间只有一个能拿到,也没有别人读到半写标记的窗口。 +- 发现标记时,对应进程还在就换下一个;不在了,或启动时间对不上(pid 被复用),当作残留:先清场、确认无进程,再删标记重取。 +- 清场:以该 uid 起一个小进程执行 `os.kill(-1, 9)`(`Popen(user=uid, group=uid, extra_groups=[])`)。内核不会把信号发给调用者自己。随后扫 `/proc/*/status` 的 `Uid:` 行确认没有存活进程,僵尸(`State: Z`)不算。确认失败就保留标记、不再分配这个 uid,并记 error 日志;此时不收回文件、不删 R(流程第 8 步)。 +- **清场的范围与前提**:`kill(-1)` 以该 uid 的身份发信号,只影响本容器、同一个 uid 的进程,不会碰别的 uid、别的任务。它安全的前提是**同一时刻只有一次调用在用这个 uid**,由两点保证: + - uid 不跨 W 共用:每个 uid 在池里只有一个原子标记,只会被一个登记条目持有;保留模式一个 W 一个 uid,临时模式每次调用各取一个。 + - 同一个 W 内串行:见"同一个 W 的调用串行"。串行一旦被绕过,一次调用的清场会把另一次调用的进程一起杀掉,同一个 R 里还可能把另一次写到一半的文件当成完整文件收回。 +- **清场会清掉后台进程**:每次调用结束都清场,脚本里用 `&`、`nohup` 拉起的后台进程不会留到下一次调用,不能靠它"启动一个服务、下一次调用再连"。现在的本机模式没有这个限制,这是一处行为变化。常见脚本用 `subprocess.run` 调 soffice、pandoc 等会等子进程结束,不受影响。 +- 不用 `killpg` 代替:脚本里的子进程可以 `setsid` 或双 fork 脱离进程组,杀不干净;按 uid 清场并扫 `/proc` 确认,才能保证不留残余。 +- **遗留运行目录的回收**:worker 被 `kill -9` 时,标记还在,R 也留在 `local_run_root` 下,属主就是那个 uid,下一个领到它的调用可以读到。所以: + - 回收残留标记、或领走一个 uid 时,先删掉 `local_run_root` 下所有属主是它的目录(用 `lstat` 判断属主,`rmtree` 不跟随符号链接)。 + - 进程启动时扫一遍 `local_run_root`:没有对应存活进程标记的目录和标记一起清掉。 + - 因清场未确认而被隔离的 uid,只有在确认它名下已无进程之后,才删它的 R、释放标记。 +- 保留模式下,uid 由登记表持有,直到该 W 被释放才还给池子;标记里的 pid 就是持有它的 worker 进程,进程没了,标记就按上面的规则被当作残留回收。 +- 实现参照 `docs/workflow/code.py` 里的 `_acquire_uid` / `_reap_uid` / `_uid_has_live_process`,抽成独立模块 `local_uid_pool.py`。chroot 方案若以后重开,可以复用它。 +- 同一台宿主机上多个后端容器会共用这段 uid,`RLIMIT_NPROC` 按 uid 在内核里合计,别的容器里同 uid 的进程会占额度。清场的 `kill(-1)` 只在本容器的 PID 命名空间内生效。 + +## 磁盘 + +运行期间每 5 秒统计一次 R 的总大小和文件数,超过 `local_disk_limit_bytes` 就杀进程并返回明确错误。`RLIMIT_FSIZE` 只限单文件,挡不住写很多文件。保留模式下 R 在任务期间一直在,统计的是整个 R,包含复制进来的部分;因超限被杀后,该 W 的登记作废、R 删掉,下次调用重新全量复制。 + +本方案**不限制同时运行的次数**。灵思 worker 已有 `--max_concurrency 5`,商业版网关限制了 HTTP 请求并发,这里再加一层会让原本能跑的请求被拒绝,数值也没有依据。uid 池有 10000 个,不会成为瓶颈。代价是工作流代码节点所在的 Celery worker 最多 100 个线程,最坏情况下并发占用的内存是 100 乘以 `local_rlimit_as_bytes`,这要靠部署侧控制 worker 并发。若以后在线上看到过载,再带着实测数据单独加。 + +## 权限收紧 + +换 uid 之后,子进程仍能读所有"对其他用户可读"的文件。容器里真正敏感的是三处,本节在不改镜像、不动 `/usr` 的前提下把它们对池里的 uid 关上;LibreOffice、pandoc、shell、ffmpeg 仍在原位,功能不变。 + +**容器里的布局**(`src/backend/Dockerfile`:`WORKDIR /app`、`COPY ./ ./`;`docker/docker-compose.yml` 的 `backend`、`backend_worker`): + +| 路径 | 来源 | 内容 | +|---|---|---| +| `/app/bisheng/` | 镜像 | 后端源码 | +| `/app/bisheng/config.yaml` | 宿主机只读挂载 `bisheng/config/config.yaml:/app/bisheng/config.yaml:ro` | 数据库、Redis、MinIO 等连接信息(密码为 Fernet 密文,密钥在源码里) | +| `/app/data/` | 宿主机读写挂载 `data/bisheng:/app/data`,`backend` 与 `backend_worker` 共用 | 助手对话历史、统计日志、性能快照等 | +| `/app/.venv/` | 镜像 | 解释器和依赖,**子进程必须能读、能执行,不能动** | + +### 处理 + +1. **`/app/bisheng`:启动时 `chmod 0700`。** + - 只改这一层目录,不递归。目录没有其他用户的 `x` 位,普通 uid 连路径都穿不过去,下面的源码和 `config.yaml` 挂载点一并被挡住,与 `config.yaml` 自己的权限无关。 + - `/app` 本身保持 `0755`:子进程要穿过它去用 `/app/.venv`。 + - 子进程用不到 `/app/bisheng`:`cwd=R`,脚本目录 R 在 `sys.path` 上,`/app` 不在;技能包和知识库图片由 root 先复制进 W 再进 R。 + - 这是对容器可写层的元数据修改,`docker restart` 后仍在,容器重建后丢失,所以每次启动都做(幂等)。 +2. **`config.yaml`:宿主机上设为 `0600`、属主 root,作为第二道防线。** + - 容器里改不了它:只读挂载,`chmod` 会得到 `EROFS`,而且权限本来就取自宿主机。 + - 有了第 1 条,即使宿主机上是 `0644` 也读不到;`0600` 防的是它从别的路径被访问到(另一个挂载点、宿主机上的其他用户)。部署文档写明这条要求。 +3. **`/app/data`:启动时对挂载根目录 `chmod 0700`。** + - 同样只改顶层目录,不递归。这是绑定挂载,改的是宿主机上 `data/bisheng` 目录本身的权限,部署文档要写明;该目录在宿主机上本来就属主 root。 + - 宿主机上如果有非 root 的运维脚本直接读这个目录下的日志,会受影响,需要改成 root 读或走 `filelib/download_statistic` 接口。 + - 存储不允许 root 改权限时(例如 NFS 开了 `root_squash`),`chmod` 会失败,见下面的启动检查。 + +**不需要处理的**: +- `/app/entrypoint.sh`:只有启动命令,没有密钥(已核对 `src/backend/entrypoint.sh`)。 +- `/proc//cmdline`:各进程的命令行是 `uvicorn …`、`celery -A bisheng.worker.main …`、`python bisheng/linsight/worker.py …`,不带密钥(同上核对);`/proc//environ` 是 `0400` 属主 root,子进程本来就读不到,预检会实测。 +- `/app/bisheng_langchain` 等其他源码目录:没有配置和密钥,不收紧,避免扩大改动面。 + +### 启动检查 + +放在预检里(进程内一次,见"开关与失败策略"),先做上面的 `chmod`,再**以池里的一个 uid 实测**,不只看权限位(ACL、挂载选项、宿主机上的权限都会影响结果,实测才算数): + +| 检查 | 期望 | 不满足时 | +|---|---|---| +| 以 uid 打开 `/app/bisheng/config.yaml` | `PermissionError` | **拒绝启用隔离**,抛 `LocalIsolationUnavailable`,日志写明哪一步可读 | +| 以 uid 列 `/app/bisheng` | `PermissionError` | 同上 | +| 以 uid 列 `/app/data` | `PermissionError` | 只打 WARNING,不拒绝(内容是日志和历史,且有些存储改不了权限);WARNING 写明"沙箱代码可读 `/app/data`" | +| 以 root `stat` 宿主机挂进来的 `config.yaml` | group / other 没有读位 | 只打 WARNING,提示宿主机上改成 `0600` | +| 以 uid 运行 `/app/.venv/bin/python -c "import numpy"` | 成功 | 拒绝启用隔离:收紧把解释器也挡住了 | + +`chmod` 本身失败(`EPERM`、`EROFS`)时不中断,继续做实测,以实测结果决定。`local_isolation=off` 时这一节整个不做,不改任何权限。 + +## 开关与失败策略 + +`SandboxConf.local_isolation`: + +| 值 | 行为 | +|---|---| +| `uid` | 上述流程。不是 Linux、不是 root、没有 `prlimit`、预检失败,都直接失败,不退回旧的同用户执行 | +| `off`(默认) | 完全保持现状,不做任何隔离。部署时在镜像或 compose 里显式设 `uid`;首次使用时打一条 WARNING | + +取值容错:空串或 `off`(不分大小写、去空格)为 `off`,其余任何值都按 `uid` 处理;配置读不出来时也按 `uid`,不会因为写错值悄悄退回不隔离。 + +`off` 下**没有 R**:子进程仍直接以 `cwd=W` 运行,不复制进来、不收回,不换 uid,不占 uid 池、不写标记,不建登记表,`keep_run_dir` 被忽略,环境变量整份继承,也没有 `prlimit` 上限。灵思在 `_cleanup_resources` 里调用的 `release_local_run(W)` 在 `off` 下、或者该 W 没有登记时,必须是空操作,直接返回,不报错、不碰文件系统。 + +预检(进程内只做一次,成功后缓存): + +1. 先检查系统条件:Linux;`geteuid() == 0`;`prlimit` 可用;进程持有所需的能力。只判断 `euid == 0` 不够,丢了能力的容器会走到含糊的失败。用 `/proc/self/status` 的 `CapEff` 逐项检查,失败时日志写明缺的是哪一项: + + | 能力 | 用途 | + |---|---| + | `CAP_SETUID` / `CAP_SETGID` | `Popen(user=…, group=…, extra_groups=[])` 换 uid、清附加组 | + | `CAP_CHOWN` | 把 R 和复制进去的文件交给 uid | + | `CAP_DAC_OVERRIDE` | root 读写属主是该 uid、权限 `0700` 的 R | + | `CAP_KILL` | 超时后向该 uid 的进程组发信号 | + + Docker 默认能力集包含这些。已在 107 部署上核实:`bisheng-backend` 与 `bisheng-backend-worker` 的 `CapEff` 都是 `0xa80425fb`(Docker 默认集),5 项都在,另有 `CAP_SYS_CHROOT`(chroot 方案需要;该方案暂时不做)。`docker/docker-compose.yml` 里沙箱 runner 显式列了 `SETUID`、`SETGID`、`CHOWN`、`DAC_OVERRIDE`,没有 `CAP_KILL`;本机隔离不在 runner 里跑,不受影响。 + +2. 扫 `/proc/*/status`:`30000`–`39999` 区间内如果已有进程,且不属于池标记记录的占用者,说明有别的服务在用这段 uid,清场会误杀它们,拒绝启用隔离,日志写明 uid 和进程。 +3. 做"权限收紧"里的 `chmod`(`/app/bisheng`、`/app/data` 顶层目录 `0700`)。 +4. 再从池里领一个 uid,在一个临时 R 里跑一小段脚本,确认:uid 非 0;读 `/proc/1/environ` 被拒;能导入 numpy、pandas、matplotlib;能在 R 里写文件;以及"权限收紧"里启动检查表的各项。**无论成败,都在 `finally` 里按清场流程收尾:清场、删临时 R、删标记、把 uid 还回池里。** + +失败时抛 `LocalIsolationUnavailable`,原因写进服务端日志,给模型的文案只说"本机代码执行环境未就绪"。 + +`LocalExecutor.run` 捕获该异常返回 `{"exitcode": 1, "log": ..., "file_list": []}`;代码节点走 `execute_code` 直接抛出,节点按现有方式失败。 + +## 配置字段 + +加在现有的 `SandboxConf`(`settings.py`),不新增 YAML 顶层键;环境变量覆盖沿用 `BS_SANDBOX_CONF__`。 + +| 字段 | 默认 | 说明 | +|---|---|---| +| `local_isolation` | `off` | `uid` / `off`;环境变量 `BS_SANDBOX_CONF__LOCAL_ISOLATION` | +| `local_run_root` | `/tmp/bisheng-local-run` | R 的父目录。启动时建好,root,`0711`:uid 能穿过,不能列目录 | +| `local_rlimit_as_bytes` | `2 GiB` | 沿用 `sandbox-runner` 的值,两种模式行为一致。已确认够灵思里处理大表格,不再上调 | +| `local_rlimit_nproc` | `256` | 同上,给 BLAS 线程和 LibreOffice 留余量 | +| `local_rlimit_cpu_s` | `600` | 同上 | +| `local_rlimit_fsize_bytes` | `1 GiB` | 单文件 | +| `local_rlimit_nofile` | `1024` | | +| `local_disk_limit_bytes` | `2 GiB` | R 总量,包含复制进来的内容和这次新写的文件 | +| `local_copy_in_file_bytes` | `1 GiB` | 复制进来的单文件上限。不复用容器模式的 `max_copy_in_bytes` | +| `local_copy_in_total_bytes` | `1 GiB` | 本次调用复制进来的总量上限(保留模式只算增量部分),小于 `local_disk_limit_bytes`,给本次运行的产物留出空间 | + +默认值是起点,不是实测值:`local_copy_in_*` 要能容下知识库图片(250 MiB)加上传文件加上一轮的中间产物。 + +这些字段不能被工具配置里的 JSON 覆盖。实现上不需要改 `factory.py`:隔离流程的所有参数都在执行时直接读 `settings.sandbox_conf`,`LocalExecutor` 构造时收到的 kwargs 不参与,工具配置里写了 `local_*` 也不起作用。`keep_run_dir`、`local_sync_path`、`workspace_prefix` 这类灵思按会话注入的键照常经构造函数传入。 + +## 代码改动 + +| 文件 | 改动 | 状态 | +|---|---|---| +| `local_isolation.py`(新) | 流程 1–9(临时模式)、预检(含能力检查)、环境白名单、受限读输出、磁盘监视;每次调用都确认 `local_run_root` 存在且为 root `0711`(改过路径后不必重启) | 已实现 | +| `local_uid_pool.py`(新) | uid 池、领 uid 时回收遗留运行目录;领 uid 与回收残留在标记目录的 `flock` 下进行 | 已实现 | +| `workspace_copy.py`(新) | 复制进来 / 收回来,含 `O_NOFOLLOW` 与对同一 fd 的 `fstat` 检查;保留模式的增量复制 `sync_in` | 已实现 | +| `local_executor.py` | `execute_code` 按 `local_isolation` 分流,`work_dir=None` 在隔离模式下拒绝;`off` 走原有 `_execute_code`,并保留原来的删字体缓存行为;隔离模式下不删;`run` 捕获 `LocalIsolationUnavailable`;`__init__` 读 `keep_run_dir` 并登记;`arun` 按"外层排队、内层互斥"执行,没有登记时直接转到同步 `run` | 已实现 | +| `base_executor.py` | `run_with_dir` 里的 `execute_code` 调用改经 `_execute_in_dir`,`LocalExecutor` 借它把 W 上的调用交给保留模式;快照差分、收割逻辑不动 | 已实现 | +| `container_executor.py` | `supports_retained_run = False`:它整体替换了 `execute_code`,不能被分到本机保留模式 | 已实现 | +| `settings.py` | `SandboxConf` 新字段 | 已实现 | +| `local_isolation.py` 的预检 | "权限收紧":`chmod 0700 /app/bisheng`、`/app/data`(顶层目录、不递归、失败不中断),再以池里的 uid 实测启动检查表 | 已实现 | +| `local_isolation.py` 的 `run_retained`、`sweep_on_startup` | 保留模式的一次调用;进程启动清扫 | 已实现 | +| `local_run_registry.py`(新) | 保留模式的登记表(`realpath(W)` → uid、R、同步索引、外层 `asyncio.Lock`、内层 `threading.Lock`、`closing`);`release_local_run(W)` | 已实现 | +| `code_interpreter/tool.py` | `CodeInterpreterTool` 新增 `_arun`,调 `executor.arun`;执行器没有 `arun`(E2B)时退回同步 `_run`;锁不放在工具对象上 | 已实现 | +| `task_exec.py` | `_cleanup_resources` 里,在自己的 `try` 中、排在第一位,按 `self.file_dir` 调用 `release_local_run(file_dir)`(`asyncio.shield` 加线程池),早于 `_stop_termination_monitor()` 和 `rmtree` | 已实现 | +| `worker.py` | `ScheduleCenterProcess.run` 进入主循环之前,每个进程跑一次启动清扫:按标记里 pid 加启动时间判断存活,清掉没有存活属主的 R 和标记 | 已实现 | +| `workbench_impl.py` | 构造代码解释器配置时,`config.local.keep_run_dir = True`(与 `local_sync_path` 同处) | 已实现 | +| `沙箱部署说明.md` | 记录 `local_isolation` 的开启方式;写明宿主机 `config.yaml` 设 `0600`、属主 root,`data/bisheng` 目录会被改为 `0700`,依赖它的非 root 运维脚本要调整 | 已更新 | + +`factory.py` 不改,原因见"配置字段"。 + +实现时在本文之外补了几处: + +- **保留的 R 在每次调用前先复位**:R 在两次调用之间归该 uid,脚本可能改了它的权限,或把 `.tmp`、`.mpl` 换成指向别处的符号链接。root 往 R 里写任何东西之前,先把 R 设回 `0700`,删掉并重建 `.tmp`、`.mpl`,脚本文件也先删再写。 +- **增量复制按 R 的实际状态判断**:除了"新增、W 变了、R 里没了",R 里的副本被脚本改过、换成符号链接或硬链接时也重新复制;写入前先删掉原来的条目,不会顺着脚本放的链接写出去。W 里已删的文件在 R 里删除时,路径上任何一级不是真目录就跳过。 +- **磁盘超限在保留模式下丢弃整个 R**:超限的内容留在 R 里会让之后每次调用都立刻超限,所以清场后释放 uid 和 R,下次调用重新全量复制。 +- **释放能打断正在跑的子进程**:读输出的循环每 0.5 秒检查一次 `closing`,覆盖"检查 `closing` 之后、子进程启动之前"正好发生释放的窗口;释放在 30 秒内等不到内层锁时不强行回收,由那次调用结束时自己释放 uid 和 R。 +- **领 uid 与清扫互斥**:4 个 worker 进程同时启动清扫时,一个进程判定为残留的 uid 可能刚被另一个进程重新领走、建好 R。领 uid(含回收残留)和清扫都在标记目录下的 `.lock` 上取 `flock`。 + +`ContainerExecutor` 继承 `LocalExecutor` 但整体覆盖了 `execute_code`,不受影响。`LocalExecutor.execute_code` 目前是类方法,`test_code_interpreter_timeout.py` 直接以类调用,所以保持类方法签名,配置在函数内部从 `settings.sandbox_conf` 取。 + +默认值是 `off`,现有的本机执行器测试不用加夹具。隔离相关的新测试单独放:`test/sandbox/test_local_isolation.py`、`test/sandbox/test_local_retained_run.py`(增量复制、登记与释放、串行、灵思接线)不需要 root;`test/sandbox/test_local_isolation_root.py` 只在 root 且 Linux 上跑,含保留模式的端到端用例。已在 107 的 backend 镜像里以 root 跑过这两份,并把 `test/linsight/test_code_interpreter_*.py` 里六组现有测试在 `BS_SANDBOX_CONF__LOCAL_ISOLATION=uid` 下重跑,全部通过。 + +## 与其他路径的关系 + +- **代码节点**:本分支的代码节点在 `type=local` 时走 `LocalExecutor.execute_code(work_dir=<临时目录>)`(`code_parse.py:260`),同样被这套隔离覆盖,用临时模式。代码节点的结果通道和结果上限另见 [代码节点执行约定加固](./代码节点执行约定加固.md):结果上限是 4 MiB。代码解释器和代码节点共用本文的输出窗口(开头 4 MiB、结尾 512 KiB),一条 4 MiB 的结果行落在窗口内,不再按调用方单独放宽。代码节点不单设超时,沿用执行器现有的超时。 +- **知识库图片暂存**:暂存在 `run` 里、`run_with_dir` 之前写进 W,由复制进来带入 R。暂存代码不用改,父进程也不会再往用户可控的目录里写。 +- **灵思**:W 就是 `local_sync_path`,用保留模式。任务结束时 `_cleanup_resources` 会 `rmtree(W)`,同一处先调用 `release_local_run(W)` 释放 R 和 uid。工具实例上的 `close()` 不负责释放。 +- **`sync_to_workspace` / `sync_files_to_local`**:看到的是收回后的 W,行为不变。 + +## 测试 + +不依赖 root 的: + +- 复制进来:跳过符号链接、超大文件、隐藏文件;超总量时有提示。 +- 收回来:符号链接、FIFO、硬链接文件、超限文件不被收;`(size, mtime_ns)` 未变的文件不被收;路径穿越名被拒。 +- 路径改写:W 字面量被换成 R,日志里 R 被换回 W;以 W 为前缀的更长路径不被误换。 +- `work_dir=None` 在隔离模式下直接失败,不碰 `extensions/`。 +- `off` 模式下仍会删除字体缓存,隔离模式下不删。 +- `off` 模式下不产生 R、不占 uid、不写标记;`release_local_run(W)` 在 `off` 或没有登记时是空操作。 +- 预检失败(缺能力、`prlimit` 缺失)时日志写明缺哪一项;预检用过的 uid 已还回池里。 +- 保留模式的增量复制:第二次调用不重复复制没变的文件;W 里新增、改动的文件会被复制;R 里被删的文件会按 W 补回;W 里被删的文件在 R 里也被删;收回的文件下一次不会再被当成"W 里新变"重复复制。 +- 保留模式的释放:`release_local_run(W)` 之后 R、标记、登记都没了,uid 回到池里;释放时有调用在跑,那次调用不做收回、返回"任务已结束"。 +- 钩子的放置:`_stop_termination_monitor()` 抛异常时,`release_local_run` 仍然已经执行;`file_dir` 对应目录不存在时 R 仍被释放;`file_dir` 为 `None` 时钩子直接返回,不影响别的登记。 +- 释放之后到达的调用:看到条目已关闭,直接返回"任务已结束",不新建 R、不占 uid;同一个 W 的下一轮能建新句柄。 +- 终止时线程仍在跑:取消 `agent_task` 之后进入清理,子进程被清场杀掉,R 被释放,不会留下持锁的线程。 +- 启动清扫:标记对应的进程已不存在的 R 和标记被清掉;对应进程仍存活的(同一台机器上其他 worker 进程)不被清掉。 +- 保留模式下同一个 W 的两次并发调用串行,且按发起顺序执行;等锁的时间不计入超时。 +- 保留模式下,持锁的调用所在的 `await to_thread(...)` 被取消后,下一个排队的调用不会进入同一个 R,要等线程里的上一次退出。 +- 释放时有调用在排队:排队的调用拿到锁后看到 `closing`,直接返回"任务已结束",不执行脚本。 +- `off`、容器模式下 `arun` 不加锁,行为与改动前一致。 +- 环境白名单只含上表的变量。 +- 受限读输出:超大输出只保留头尾;超硬上限被杀。 +- uid 池:两个线程抢同一个 uid 只有一个成功;记录的进程已不在的标记被回收;还在的不被回收。 +- `off` 模式与现有测试行为一致。 + +只在 `os.geteuid() == 0` 且 Linux 上跑的: + +- 子进程 uid 在 30000–39999,附加组为空,环境只有白名单变量。 +- 读 `/proc/1/environ`、`/root`、`/app/bisheng/config.yaml` 被拒;**宿主机上 `config.yaml` 是 `0644` 时也被拒**(靠 `/app/bisheng` 的 `0700`)。 +- 列 `/app/bisheng`、`/app/data` 被拒;`/app/.venv/bin/python` 仍能启动并导入 numpy。 +- 启动检查:人为把 `/app/bisheng` 改回 `0755` 后预检拒绝启用隔离,日志写明可读的路径;`/app/data` 改权限失败时只出 WARNING,隔离照常启用。 +- `local_isolation=off` 时不改任何目录权限。 +- 在 R 里建指向 `/etc/passwd` 的符号链接、硬链接、FIFO,收回后 W 里没有它们。 +- 超时被杀,之后该 uid 在 `/proc` 里没有进程,标记已删除。 +- 模拟 worker 被杀:遗留 R 和标记,下一个领到同一 uid 的调用开始前,R 已被清掉,读不到上一份内容。 +- 模拟清场未确认:不收回任何文件,R 保留,这个 uid 不再被分配;保留模式下该 W 的登记作废,下次调用全量复制。 +- 保留模式下,同一任务内第二次调用的子进程仍然是新进程,上一次留下的后台进程已被清掉。 +- 同一个 W 并发发起两次调用,一次很长、一次很短:短的先返回,长的仍然正常完成,没有被短的清场误杀,结果完整。 +- 两个不同的 W 并发,各自拿到不同的 uid;终止或清场其中一个,另一个不受影响。 +- 预检:区间内存在不属于池标记的进程时,拒绝启用隔离并写明 uid。 +- 画一张含中文的 matplotlib 图,不触发字体缓存重建。 + +## 剩余风险 + +这些在第一步之后仍然存在。chroot 监狱暂时不做,不作为下一步;本机模式就停在这里: + +- **全局可读的文件仍然可读**:`/app/.venv`、`/app/bisheng_langchain` 等源码、`/etc`、`/usr`。`/app/bisheng`(含 `config.yaml`)和 `/app/data` 已由"权限收紧"关上;其中 `/app/data` 在存储不允许改权限时只打 WARNING,仍可能可读。`config.yaml` 另要求宿主机上设为 `0600`、属主 root。 +- **全局可写目录**:`/tmp`、`/var/tmp`、`/dev/shm` 通常是 `1777`,子进程可以在里面落文件,也可以读别的运行留在那里的、权限为 `0644` 的文件。可选加固是启动时以 root 把这三处改成 `0755` 并清掉现有文件上的"其他用户可写";这会改变容器里这些目录的行为,需要单独评估后再决定,不并进本方案。 +- **`/proc//cmdline` 等对所有人可读的进程信息**。现有进程的命令行不带密钥(见"权限收紧"),以后如果有人把密钥写进启动参数,就会泄露。 +- **网络不隔离**:子进程仍能连容器网络内的 MySQL、Redis、MinIO、OpenFGA 等;环境变量里没有密钥,不等于连不上,也不等于这些服务没有默认凭据。 +- **保留模式下同一任务的多次调用共享一个 R**:前一次调用写下的文件、符号链接,后一次调用的脚本都能看到(与容器 session 一致,同一任务、同一用户,不跨任务)。符号链接和硬链接依旧不会被收回 W。每次调用结束仍会清场,不会有进程跨调用留下来。 +- 没有 cgroup:内存只靠 `RLIMIT_AS` 近似,磁盘靠监视线程,不是硬隔离。 +- 内核漏洞、共享内核。 + +因此第一步之后,多租户且用户不可信的生产环境仍应使用 `type=container`。 + +## 不做 + +- 不改镜像,不引入 `chroot`。第二步(chroot 监狱)暂时不做。 +- 不收紧语言,`sh` / `bash` 仍可运行。 +- 不做网络隔离。 +- 不在非 Linux、非 root 的机器上模拟这套隔离,那种环境用 `off`。 +- 不同步删除。 +- 代码节点(临时模式)不做增量复制:工作区小,每次全量复制。 +- 不做空闲超时回收:保留模式的 R 随工作区删除而释放,崩溃的残留靠启动清扫。 +- 不做跨进程共享 R:登记表只在进程内。 + +## 待确认 + +1. ~~`local_isolation` 默认值取 `uid` 还是 `off`~~ **已确认:取 `off`**,沿用现状,镜像和 compose 里显式设 `uid`。代价是自行部署、没设这个值的客户默认不隔离,首次执行时有一条 WARNING。 +2. ~~基础镜像里是否有 `prlimit`~~ **已核实**:107 部署的后端和 worker 镜像里都有(util-linux 2.41,Debian 13)。仍保留预检:换了基础镜像、缺 `prlimit` 时直接失败并写明原因;备选是在 Dockerfile 里装 util-linux,或改用一小段引导脚本在子进程里 `setrlimit`(代价是 traceback 里多一层 `runpy` 栈帧)。 +3. ~~灵思里是否会并行发起代码调用~~ **已核实:会**,见"同一个 W 的调用串行"。保留模式下同一个 W 的调用改为串行,会让同一条消息里的多个代码调用总耗时变成各次相加,这是用速度换正确性。仍需确认:线程池大小是否被调整过(本文按 Python 默认值 `min(32, CPU 核数 + 4)` 论证,没有查 `linsight_conf`)。 +4. ~~释放钩子是否覆盖所有路径~~ **已核实**:新任务、续跑、继续对话三条路径都经 `_managed_execution` 或 `_managed_resume`,`finally` 里都调用 `_cleanup_resources`;停靠也走这条路径。详见"释放钩子的覆盖"。需要补进的四处(钩子独立于现有 `try`、终止时线程仍在跑、释放后到达的调用、每个进程的启动清扫)已写入设计。崩溃类场景钩子覆盖不到,靠启动清扫;清扫里仍要记日志,能看出有残留被清掉。 +5. ~~`keep_run_dir` 是否能经 `factory.py` 传到 `LocalExecutor`~~ **已核实:能传到构造函数,但会在那里被丢掉**。 + - 现状的 `build_code_executor` 先把 `config[kind]` 合并进 `kwargs`;只有 `kind == "container"` 时才丢键,丢的是写死的 `_CONTAINER_POOL_KEYS`(`endpoints`、`token`、`max_copy_in_bytes` 等),并非 `SandboxConf` 的全部字段。`local` 分支直接 `LocalExecutor(**kwargs)`,不做任何过滤,所以 `config.local.keep_run_dir` 会作为关键字参数传进去。 + - 但 `BaseExecutor.__init__` 只从 `kwargs` 里取 `local_sync_path` 和 `workspace_prefix`,其余键读完即丢。实现时必须在 `LocalExecutor.__init__` 里显式接住:`self.keep_run_dir = bool(kwargs.get("keep_run_dir", False))`,否则标记到不了运行逻辑。 + - 本方案要给 `local` 加的过滤(丢掉 `local_*` 隔离字段、注入 `sandbox_conf`,见"配置字段")只能针对 `SandboxConf` 里的隔离字段,`keep_run_dir` 不是 `SandboxConf` 字段,不能被列进去。需要一条测试:经 `build_code_executor("local", config={"local": {"keep_run_dir": True, "local_isolation": "off"}})` 构造后,`keep_run_dir` 为真,而 `local_isolation` 仍取自 `settings.sandbox_conf`。 +6. ~~`local_rlimit_as_bytes` 默认 2 GiB 是否够灵思里处理大表格~~ **已确认:够**,默认保持 2 GiB,与容器模式一致。本机模式此前没有上限,超过 2 GiB 的单次运行会得到 `MemoryError`,这是预期行为。 +7. ~~`/app/data` 收紧失败时只打 WARNING、不拒绝隔离,是否接受~~ **已确认:接受**。理由是内容为日志和历史、且部分存储(如开了 `root_squash` 的 NFS)改不了权限;代价是这类部署里子进程仍能读到它,WARNING 里写明这一点。 diff --git "a/features/v3.0.0-beta1/068-code-execution-sandbox/LocalExecutor\345\256\211\345\205\250\344\274\230\345\214\226\346\226\271\346\241\210-\347\254\254\344\272\214\346\255\245-chroot\347\233\221\347\213\261.md" "b/features/v3.0.0-beta1/068-code-execution-sandbox/LocalExecutor\345\256\211\345\205\250\344\274\230\345\214\226\346\226\271\346\241\210-\347\254\254\344\272\214\346\255\245-chroot\347\233\221\347\213\261.md" new file mode 100644 index 0000000000..05eeafbf44 --- /dev/null +++ "b/features/v3.0.0-beta1/068-code-execution-sandbox/LocalExecutor\345\256\211\345\205\250\344\274\230\345\214\226\346\226\271\346\241\210-\347\254\254\344\272\214\346\255\245-chroot\347\233\221\347\213\261.md" @@ -0,0 +1,241 @@ +# LocalExecutor 安全优化方案 · 第二步:chroot 监狱 + +状态:暂时不做。文档保留,不排期、不实现。本机模式停在 [第一步:普通 uid + 私有运行目录](./LocalExecutor安全优化方案-第一步-uid隔离.md);需要更强隔离时用 `type=container`。下文是搁置前的设计,重开时再按当时的第一步实现核对。本文只写相对第一步的增量,复制进来 / 收回来、uid 池、环境白名单、资源上限、输出读取、磁盘监视都沿用第一步,不再重复。 + +## 目标 + +第一步之后,子进程仍然看得到整个容器文件系统里所有"全局可读"的文件,也有完整的 shell 和外部程序。第二步把它关进一棵只读的 `chroot` 树里: + +- 看不到 `/app` 下的后端源码和 `config.yaml`,看不到 `/proc`、`/etc` 里的宿主内容。 +- 没有 shell,没有 LibreOffice、pandoc 等外部程序,只剩 Python 和白名单里的库。 +- 只执行 Python。 + +第一步剩余风险与第二步的对应关系: + +| 第一步剩余风险 | 第二步 | +|---|---| +| 全局可读文件(源码、`config.yaml`、`/app/data`) | 解决:监狱里没有这些路径,不再依赖宿主文件权限 | +| `/proc//cmdline` 等进程信息 | 解决:监狱里没有 `/proc` | +| 子进程可以起 shell、调外部程序 | 解决:监狱里没有 | +| 网络不隔离 | 不解决 | +| 内核漏洞、共享内核 | 不解决 | + +## 相对第一步改什么 + +| | 第一步 | 第二步 | +|---|---|---| +| `local_isolation` | `uid` | `jail` | +| 运行目录 R | `local_run_root/<随机>` | `/opt/local-root/work/<随机>`(子进程看到 `/work/<随机>`) | +| 启动方式 | `Popen(user=…)` 直接 exec 解释器 | root 父进程 `prlimit … chroot --userspec … --skip-chdir` | +| 可执行内容 | `sh` / `bash` / Python | 只有 Python | +| 镜像 | 不改 | 构建 `/opt/local-root` | +| 外部程序 | PATH 里的都能用 | 都不能用 | + +## 监狱里有什么 + +根目录固定 `/opt/local-root`,所有调用进入同一棵树,属主 root,目录 `555`、文件 `444` / `555`,没有 `setuid` / `setgid` 文件(构建时检查,发现即失败)。 + +放进去的(按原绝对路径): + +- 解释器、标准库、`libpython`、动态链接器,以及对整棵站点包里所有 `.so` 做 `ldd` 得到的全部共享库。**不能只扫标准库**,numpy、pandas、PyMuPDF、Pillow 的扩展模块各自依赖系统库。 +- `/app/.venv`:只放"允许列表"里的包及其依赖闭包(见下)。路径与宿主相同,`sys.executable` 在监狱内外都是 `/app/.venv/bin/python`。 +- 构建时预编译好的 `__pycache__`。 +- `/etc/ld.so.cache`(`ldconfig -r` 生成,不设 `LD_LIBRARY_PATH`)、`/etc/passwd` 和 `/etc/group`(只有 root 和 nobody)、`/etc/localtime`、`/usr/share/zoneinfo`。 +- `/usr/share/fonts`、`/usr/local/share/fonts`、`/etc/fonts`。`insert_set_font_code` 会设置 `WenQuanYi Zen Hei`,没有字体中文图表会变方块。 +- 预生成的 matplotlib 字体缓存:`/usr/share/bisheng/mplcache/`。 +- `/dev/null`、`/dev/zero`、`/dev/urandom`(`mknod`,`0666`)。 +- `/work`:属主 root,`0711`,运行时在里面建 R,不打进镜像。 + +明确不放的: + +| 不放 | 后果 | +|---|---| +| `/proc`、`/sys` | `os.listdir('/proc')` 失败;`psutil` 一类依赖它的库不可用 | +| `/bin/sh` 和任何 shell | `subprocess(shell=True)`、`os.system` 失败 | +| LibreOffice、pandoc、`pdftoppm` | 调它们的脚本失败,见"兼容性影响" | +| `/tmp` | `tempfile` 用 `TMPDIR`(指向 `R/.tmp`);硬编码 `/tmp` 的库会失败 | +| `/dev/shm` | `multiprocessing` 的信号量、`ProcessPoolExecutor` 不可用 | +| `/etc/resolv.conf`、`/etc/hosts`、`nsswitch.conf` | 主机名解析失败。这只是副作用,不是网络隔离,直接连 IP 照样通 | + +## 镜像构建 + +### 放在哪一步 + +在 `src/backend/Dockerfile` 里,放在最后一次 `uv sync` 和 `dmssl` 整理之后: + +```text +RUN uv sync --frozen --no-dev … +RUN python -c "import nltk; …" +COPY ./ ./ +RUN … dmssl … +RUN sh build-local-root.sh # 新增 +``` + +脚本模仿 `docs/workflow/build-code-root.sh`(Python heredoc),放在 `src/backend/build-local-root.sh`。 + +### 为什么复制,不用硬链接 + +原始设想用硬链接让镜像"只存一份"。对当前 Dockerfile 不成立:`FROM dataelement/bisheng-backend:base.v10`,venv 的大头在 base 镜像的层里,这个 Dockerfile 里的 `uv sync` 只补差异。在后面的 `RUN` 里对 base 层文件做硬链接,overlayfs 会先 copy-up,等于还是多存一份,而且还留下两个隐患: + +- 之后任何一次 `uv sync` 替换了某个包,监狱里的硬链接指向旧 inode,监狱和 `/app/.venv` 静默地版本不一致。 +- 硬链接共享 inode,对监狱文件做 `chmod` / `chown` 会同时改动真实 venv。 + +所以直接复制。因为它和 `/app/.venv` 在同一个 Dockerfile 构建里、从同一份最终状态生成,构建当下不会不一致;运行期再加一道校验(见"启动预检")。 + +### 站点包用允许列表,不整棵复制 + +整棵 `/app/.venv` 很大(本机开发环境约 1.5 GiB,镜像内以实际为准),而且里面有 `pymysql`、`redis`、`minio`、`sqlalchemy`、各家 LLM SDK 这类子进程完全不需要、却能直接用来连内网服务的库。构建脚本只复制允许列表及其依赖闭包(用 `importlib.metadata` 的 `Requires-Dist` 递归求闭包,按每个分发包的 `RECORD` 取文件): + +- 默认列表:`pandas`、`numpy`、`matplotlib`、`openpyxl`、`xlsxwriter`、`python-docx`、`python-pptx`、`pillow`、`reportlab`、`pymupdf`,再加 `pyproject.toml` 里已有、数据处理常用的 `scipy`、`sympy`、`xlrd`、`jieba`、`bs4`。 +- 列表写在脚本顶部的一个常量里。**这份列表就是对模型的承诺**,要和代码执行器的 `LOCAL_DESCRIPTION` 里的"AVAILABLE LIBRARIES"保持一致;在监狱模式下没复制进去的库会 `ImportError`。 +- 列表里任何一个包在镜像里找不到,构建失败。 + +### 其他构建步骤 + +1. 复制前先对 `/app/.venv` 跑 `python -m compileall -q`,让监狱带着 `.pyc`。监狱只读且运行时用 `-B`,没有它每次调用都会重新编译 pandas、matplotlib 这类大库。 +2. 用 `MPLCONFIGDIR=/opt/local-root/usr/share/bisheng/mplcache` 导入一次 `matplotlib.font_manager`,生成字体缓存,并检查其中含 `WenQuanYi Zen Hei`。 +3. 建 `/dev` 节点、`/work`,规范权限,检查没有 `setuid` / `setgid` 文件。 +4. 把 `sha256(uv.lock)` 写进 `/opt/local-root/.lock-hash`。 +5. 构建期自检,用 uid 65534、与运行时相同的 `chroot` 参数: + - 导入允许列表里的每个模块; + - 在一个临时目录里各生成一份 xlsx、docx、pptx、pdf 和一张含中文的 PNG; + - `/`、`/app/.venv`、解释器目录都不可写; + - `/proc`、`/bin/sh`、`/etc/shadow`、`/app/bisheng` 都不存在; + - `os.listdir('/')` 恰好等于预期集合; + - 从 cwd 出发 `os.listdir('..')` 仍在监狱内(防 `--skip-chdir` 误用); + - `getpass.getuser()` 在设置 `USER` 时可用。 + + 自检失败则镜像构建失败。构建日志打印 `du -sh /opt/local-root`,镜像体积增量以此为准。 + +### 体积 + +监狱是"精简站点包 + 系统库"的副本,比整棵 venv 小得多,但仍是额外的一份。具体数字我没有测,**以第一次构建的 `du` 输出为准**。若以后要彻底只存一份,需要改 base 镜像,让 venv 直接建在监狱里,`/app/.venv` 在外面做符号链接。那要动 base 镜像的构建流程,不在本方案内。 + +## 运行流程的差异 + +沿用第一步的 1–9 步,只改以下几处: + +**运行目录**:R 建在 `/opt/local-root/work/<随机>`,`chown` 给 uid,`0700`。`/work` 本身 `0711`,当前 uid 能穿过它,列不出别人的目录,也打不开别人的 R。 + +**启动**:父进程仍是 root,不用 `Popen(user=…)`,而是用 `chroot` 命令完成"切根、降权、exec"。线程化 worker 里不能 `os.fork()` 之后继续跑 Python,也不能在 `preexec_fn` 里调 `os.chroot`。 + +```python +subprocess.Popen( + ["prlimit", "--as=…", "--cpu=…", "--nproc=…", "--fsize=…", "--nofile=…", "--core=0", "--", + "chroot", f"--userspec={uid}:{uid}", f"--groups={uid}", "--skip-chdir", + "/opt/local-root", "/app/.venv/bin/python", "-s", "-B", "-X", "utf8", script_name], + cwd=f"/opt/local-root/work/{rid}", env=env, + stdin=subprocess.DEVNULL, stdout=PIPE, stderr=PIPE, + start_new_session=True, close_fds=True, +) +``` + +- `prlimit` 在 `chroot` 之前由 root 执行:rlimit 随进程保留,换 uid 之后 `RLIMIT_NPROC` 按新 uid 计数。root 不受 `NPROC` 限制,所以不会打断 worker。 +- `--groups=` 必须显式给出,数字 uid 不在 `/etc/passwd` 里时,不同 coreutils 版本对附加组的处理不一样,不能依赖默认行为。 +- `chroot(8)` 默认会把 cwd 改成 `/`。要让脚本在 `/work/<随机>` 里运行,就把父进程的 `cwd` 设成这个目录在监狱内的路径,再加 `--skip-chdir`,此时 cwd 在新根之内。**不能**在 cwd 位于监狱之外时用 `--skip-chdir`:cwd 在新根之外,普通用户沿 `..` 就能走出去。构建自检里的 `listdir('..')` 就是防这个。 +- 解释器写绝对路径。`chroot` 之后按子进程环境里的 `PATH` 找命令,而容器 `PATH` 以 `/app/.venv/bin` 开头。 +- 清场仍是以该 uid 在监狱外起 `kill(-1)` 的小进程(第一步),不需要进监狱。 + +**环境变量**:在第一步的白名单上改 `PATH=/app/.venv/bin:/usr/local/bin:/usr/bin:/bin`(监狱里只有这些),其余不变。`HOME` 为 `/work/<随机>`,`TMPDIR` 为 `/work/<随机>/.tmp`,`MPLCONFIGDIR` 为 `/work/<随机>/.mpl`,其中预填充的是监狱里预生成的缓存,不再用 root 自己的缓存目录。 + +**路径改写**:代码里的 W 字面量改写为 `/work/<随机>`;日志里 `/work/<随机>` 和 `/opt/local-root/work/<随机>` 都替换回 W。 + +**复制进来 / 收回来**:逻辑不变,只是目标目录换成 `/opt/local-root/work/<随机>`。 + +## 只执行 Python + +在 `lang` 层统一拒绝,不靠"输入以什么开头": + +- `LocalExecutor.run` 里 `infer_lang` 之后,监狱模式下 `lang` 不是 `python*` 一律不执行,返回 `{"exitcode": 1, "log": <说明>, "file_list": []}`。 +- `infer_lang` 对以 `pip` / `python ` 开头的输入返回 `sh`,对围栏里写 ` ```bash` 的会走 `_cmd`。这些都落在上面的拒绝里,说明文案是:"本机代码执行环境只运行 Python 脚本,不支持 shell 命令和 pip;直接写 Python 脚本本身。" +- 工具描述 `LocalExecutor.description` 在监狱模式下追加一句:"没有 shell,也没有 LibreOffice、pandoc 等外部程序;`subprocess` 调用它们会失败。"(`linsight/AGENTS.md` 要求描述必须写明环境提供什么,提示词和工具要同步。) + +`infer_lang` 对语法错误返回 `unknown` 并抛 `NotImplementedError` 的旧问题不在本方案内,不动。 + +## 兼容性影响 + +必须在发布说明里写明: + +| 影响 | 谁会碰到 | +|---|---| +| 内置 skills 的 `bisheng-xlsx/scripts/recalc_check.py`、`bisheng-pptx/scripts/render_deck.py`、`bisheng-docx/scripts/render_docx.py` 依赖 `soffice`(它们用 `shutil.which` 找,找不到会自己报错) | 灵思选了 `type=local` 并用这几个 skill 的用户 | +| `sh` / `bash` / `pip` 不再可用 | 依赖它们的模型输出 | +| 允许列表之外的库不可导入 | 以前碰巧能用的库 | +| `multiprocessing`、`ProcessPoolExecutor`、硬编码 `/tmp` 的库 | 少数脚本 | +| 部署必须是 root + 默认能力集 | 见"部署要求" | + +对需要 LibreOffice 的 skills,两条路:让 `type=local` 的灵思在这几个 skill 上给出明确提示并引导改用 `type=container`;或者把 LibreOffice 放进监狱。后者会让监狱增加几百 MB 并扩大攻击面,不建议。 + +## 开关与失败策略 + +`local_isolation` 增加取值 `jail`,同一个版本里把默认值从 `uid` 改为 `jail`。没带监狱的镜像(旧镜像、开发镜像)需要显式设为 `uid`。 + +以下情况在 `jail` 模式下直接失败,不退回 `uid`,更不退回旧的同用户执行: + +- 父进程不是 root,或不是 Linux +- `/opt/local-root` 不存在,或其中没有 `/app/.venv/bin/python` +- 系统没有 `chroot` 或 `prlimit` 命令,或 `chroot` 不支持 `--skip-chdir` +- `/opt/local-root/.lock-hash` 与 `/app/uv.lock` 的哈希不一致(有人在派生镜像里又跑了 `uv sync`,监狱已过期) +- 预检失败 + +预检沿用第一步的做法,改为在监狱里跑:uid 非 0、`/proc` 不存在、`/app/bisheng` 不存在、能导入允许列表里的模块、能在 R 里写文件。失败抛 `LocalIsolationUnavailable`,处理方式同第一步。 + +## 部署要求 + +- 后端、各 Celery worker、灵思 worker 的容器以 root 运行(现在的镜像没有 `USER`)。 +- 保留 Docker 默认能力集里的 `CAP_SYS_CHROOT`、`CAP_SETUID`、`CAP_SETGID`;不要 `cap_drop: ALL`,不要 `runAsNonRoot`。不需要 `CAP_SYS_ADMIN`。 +- 根文件系统不能是只读的:`/opt/local-root/work` 和 `/var/run/bisheng-local-uids` 要可写(只读根的部署用 `emptyDir` 挂这两处)。 +- seccomp 保持默认即可。现有 `docker/docker-compose.yml` 的后端用 `seccomp:unconfined`,也能工作。 +- 这些要求写进 `沙箱部署说明.md`。 + +## 代码改动 + +| 文件 | 改动 | +|---|---| +| `src/backend/build-local-root.sh`(新) | 构建监狱:允许列表、`ldd` 闭包、预编译、字体缓存、`/dev`、权限、哈希、自检 | +| `src/backend/Dockerfile` | 最后加一行 `RUN sh build-local-root.sh` | +| `local_isolation.py` | 增加 `jail` 分支:R 的位置、`chroot` 启动参数、监狱专用环境、预检、哈希校验 | +| `local_executor.py` | 监狱模式下拒绝非 Python;描述追加一句;预生成字体缓存的来源换成监狱里的 | +| `settings.py` | `local_isolation` 加 `jail` 取值,默认改为 `jail`;新增 `local_jail_root`(默认 `/opt/local-root`) | +| `沙箱部署说明.md`、`src/backend/AGENTS.md` | 部署要求、发布说明、开发机和 CI 设成 `off` | + +## 测试 + +构建期自检见上。运行期,在 root、Linux 且存在 `/opt/local-root` 时才跑: + +- 一次成功执行:生成 xlsx、docx、pptx、pdf、含中文的 PNG,都能收回 W,字体缓存没有被重建。 +- 在脚本里读 `/app/bisheng/config.yaml`、`/proc/self/environ`、`/etc/shadow`:都是 `FileNotFoundError`,不是 `PermissionError`。 +- `os.listdir('/')` 只有监狱里的内容;`os.listdir('/work')` 被拒;打不开别的 R。 +- `subprocess.run(['soffice'])`、`os.system('ls')` 失败;`import pymysql`、`import redis` 报 `ImportError`。 +- 提交 ` ```bash` 和以 `pip install` 开头的输入,得到"只运行 Python"的说明,没有进程被启动。 +- 子进程 uid 在 30000–39999,附加组只有 ``,环境只有白名单变量。 +- 在 R 里建符号链接、硬链接、FIFO,收回后 W 里没有。 +- 超时之后按 uid 清场,`/proc` 里无残留,标记已删除。 +- 哈希不一致、监狱缺失、非 root 时都失败,且没有任何子进程被启动。 + +不依赖 root 的:启动参数拼装(`--groups`、`--skip-chdir`、`prlimit` 的位置)、`lang` 层拒绝、路径改写,用假的 `Popen` 断言。 + +## 剩余风险 + +- **网络仍不隔离**:`chroot` 管不到套接字。子进程仍可能直连容器网络里的 MySQL、Redis、MinIO、OpenFGA 和内网地址。允许列表里没有这些库的客户端,只是提高了门槛,用标准库 `socket` 照样能连。要堵住需要独立的网络命名空间或按 uid 的防火墙,超出本方案。 +- `chroot` 不是对抗 root 的边界。它的前提是子进程是普通 uid 且没有 `CAP_SYS_CHROOT`,所以监狱里不能出现任何 `setuid` 文件,构建期已检查。 +- 共享内核:内核漏洞可以绕过一切。 +- 没有 cgroup,内存靠 `RLIMIT_AS`,磁盘靠监视线程。 +- 同一宿主机上多个容器共用 uid 区间,`NPROC` 按 uid 在内核里合计。 +- 因此多租户、用户不可信的生产环境,主选仍是 `type=container`;`type=local` + 监狱是"没有容器沙箱时的下限"。 + +## 不做 + +- 不给每个 uid 复制一棵 `chroot`。 +- 不把 LibreOffice、pandoc 放进监狱。 +- 不做网络隔离。 +- 不在非 Linux、非 root 的机器上模拟监狱,那种环境用 `off`。 +- 不动 `type=container` 的 sandbox 镜像,也不动 `/opt/code-root`(2.6 热补丁线上的代码节点方案)。 +- 不修 `infer_lang` 对语法错误返回 `unknown` 的旧问题。 + +## 待确认 + +1. 允许列表的内容。它决定模型在监狱里能 `import` 什么,也决定镜像体积。默认列表是否要加或减。 +2. 依赖 `soffice` 的三个 skill,在 `type=local` 下是给提示并引导用 container,还是接受不可用。 +3. 默认值从 `uid` 切到 `jail` 是否和监狱镜像同一个版本发布;使用旧镜像、自建镜像的客户升级时要显式设 `uid`,需要提前通知。 +4. 是否保留 `off`。保留的话 macOS 开发机和 CI 可以继续用 local;不保留则这些环境只能用 container。 diff --git "a/features/v3.0.0-beta1/068-code-execution-sandbox/LocalExecutor\345\256\211\345\205\250\344\274\230\345\214\226\346\226\271\346\241\210.md" "b/features/v3.0.0-beta1/068-code-execution-sandbox/LocalExecutor\345\256\211\345\205\250\344\274\230\345\214\226\346\226\271\346\241\210.md" new file mode 100644 index 0000000000..543215a6ff --- /dev/null +++ "b/features/v3.0.0-beta1/068-code-execution-sandbox/LocalExecutor\345\256\211\345\205\250\344\274\230\345\214\226\346\226\271\346\241\210.md" @@ -0,0 +1,59 @@ +# LocalExecutor 安全优化方案 + +状态:已拆成两步,本文保留为原始设想,不再作为实现依据。第一步已实现,是当前依据。第二步 chroot 暂时不做,文档保留但不排期。 + +- [第一步:普通 uid + 私有运行目录](./LocalExecutor安全优化方案-第一步-uid隔离.md)(已实现) +- [第二步:chroot 监狱](./LocalExecutor安全优化方案-第二步-chroot监狱.md)(暂时不做) + +评审后被替换的设计:会话目录直接交给临时 uid(改为每次调用复制进私有目录);用硬链接让 venv 只存一份(对当前 Dockerfile 不成立,改为允许列表复制);以 `pip` / `python ` / `bash` 开头来判断拒绝 shell(改为在 `lang` 层统一拒绝)。 + +`LocalExecutor` 的安全边界做成一棵共用的 `chroot` 监狱:虚拟环境只留一份,每次调用用临时 uid,可写目录按会话分开。监狱缺失、进程不是 root、或 `chroot` 失败时直接失败,不再退回现在这种跟 worker 同用户的子进程。 + +这只加固 `type=local`。代码节点继续用 `/opt/code-root`(只有标准库、全程只读)。`type=container` 继续走独立的 sandbox 镜像,不把这套监狱塞进后端镜像去替代它。 + +## 现状缺口 + +`LocalExecutor` 用 `sys.executable` 拉起子进程,用户和 worker 相同。镜像里 worker 是 root,权限位挡不住写。环境是 `os.environ.copy()`,数据库、Redis、MinIO 的密钥会进子进程。`communicate()` 把标准输出全部读进 worker。`workspace_escape_guard` 只检查脚本文本。`insert_set_font_code` 每次运行都删掉进程共享的 matplotlib 字体缓存。 + +## 监狱里有什么 + +监狱根目录固定为 `/opt/local-root`,所有调用进入同一棵树。 + +- 解释器、`/app/.venv`、动态链接器和 `libpython` 按原绝对路径放进监狱。虚拟环境在构建它的同一步里硬链接,镜像只存一份。系统动态库在更早的镜像层里,后面这一层会多存几十 MB。跨镜像层硬链接会在写入时复制出第二份,所以不能在后面的 `RUN` 里再链一次 `/app/.venv`。 +- 这些路径属 root,目录 `555`、文件 `444` / `555`。子进程已换成普通 uid,写不进去。 +- 监狱内生成 `/etc/ld.so.cache`,不设置 `LD_LIBRARY_PATH`。 +- `/work` 属 root,权限 `755`。会话目录在运行时建在这里,不打进镜像。 + +默认 Docker 能力集有 `CAP_SYS_CHROOT`、`CAP_SETUID`、`CAP_SETGID`,没有 `CAP_SYS_ADMIN`。worker 不能在运行时把活的虚拟环境挂进监狱,必须在镜像构建时放好。 + +LibreOffice、pandoc 不放进这棵树。子进程若再调它们,调用失败。需要这些程序时走 `type=container`。 + +## 谁可以写哪里 + +uid 从 `30000`–`39999` 临时领取,和代码节点的 `20000`–`29999` 分开。占用标记放在监狱外的 `/var/run/bisheng-local-uids`。跑完并确认该 uid 下没有残留进程后,标记才删除。 + +可写目录按会话分,不按 uid 分: + +- 灵思任务用 `/opt/local-root/work/<会话 id>/`。同一次任务的多次调用共用它,上次的 `output/`、`scratch/` 还在。 +- 没有会话时,每次调用用 `/opt/local-root/work/<随机 id>/`,父进程收完文件就删。 + +执行前父进程把该目录交给当前 uid(`0700`),并把 `cwd`、`HOME`、`TMPDIR`、`TEMP`、`TMP`、`MPLCONFIGDIR` 都指到这下面。子进程写的交付物、临时文件和字体缓存都落在这里。脚本由父进程事先写入同一目录。 + +其他会话目录保持 root、`0700`。当前 uid 能穿过 `/work`,打不开别的会话,也不能在 `/work` 下新建目录。调用结束后所有权收回 root,这个 uid 再被领走时也进不了上一份目录。 + +父进程不进入 `chroot`。子进程看到的 `/work/`,父进程打开 `/opt/local-root/work/`。收文件时每一级都不跟随符号链接,只读普通文件;把目录收回 root 时同样不跟随。收完后按现有逻辑上传 MinIO,并同步回灵思的 `local_sync_path`。 + +## 子进程约束 + +环境只保留 `PATH`、`LANG`、`TZ`、`HOME`、`TMPDIR`、`TEMP`、`TMP`、`MPLCONFIGDIR`。Python 加 `-B`。不再把父进程环境整份拷进去,也不再删除共享字体缓存。 + +只执行 Python。以 `pip`、`python `、`bash` 开头而被当成 shell 的输入直接拒绝。 + +资源上限加在这个独立 uid 上:地址空间、CPU、可写文件大小、打开文件数、进程数。进程数上限不能加在与 Celery worker 相同的 root 上,否则会打断 `-P threads` 的 worker。墙钟超时保持现有的 600 秒,超时后按进程组杀掉,再按 uid 清残留。标准输出在读取时截断,避免把 worker 撑满。 + +## 明确不做 + +- 不给每个 uid 复制一棵 `chroot`。 +- 不把完整虚拟环境再拷一份进镜像,也不把它链进 `/opt/code-root`。 +- 不做网络隔离。`chroot` 之后子进程仍使用容器的网络,这是剩余风险。 +- 不在非 Linux、非 root 的开发机上模拟这套监狱;那种环境直接拒绝执行。 diff --git a/features/v3.0.0-beta1/068-code-execution-sandbox/design.md b/features/v3.0.0-beta1/068-code-execution-sandbox/design.md index e279d77d10..c7f7b2b6ff 100644 --- a/features/v3.0.0-beta1/068-code-execution-sandbox/design.md +++ b/features/v3.0.0-beta1/068-code-execution-sandbox/design.md @@ -1,8 +1,8 @@ # Design: 代码执行沙箱统一底座 -**关联**: [spec.md](./spec.md) · [沙箱架构设计说明书.md](./沙箱架构设计说明书.md)(评审用) · tasks.md(尚未编写) +**关联**: [spec.md](./spec.md) · [沙箱架构设计说明书.md](./沙箱架构设计说明书.md)(评审用) · [tasks.md](./tasks.md) **版本**: v3.0.0-beta1 -**最后更新**: 2026-09-16 +**最后更新**: 2026-09-20 --- @@ -145,14 +145,15 @@ - **原因**:读、写、删在 DAC 上是同一套权限:读要目录搜索+文件读;写要文件写;删(unlink)要**父目录写权限**。独立 uid + 目录 `0700` 挡住读和写;父目录 `/tmp/sessions` 仅 root 可写(`0711`)挡住把邻居目录项删掉。A 在同一 uid 下三项全假。仅靠文件系统不够:用户代码与 runner 同 netns,可以用 `DELETE /v1/sessions/<邻居>` 删别人的租约——必须再加**按会话的 lease_token**,全局 runner token 只给 worker 的 `POST /v1/sessions`。C 信创内核经常没有,不能当默认。 - **何时该重新考虑**:F103 按次新容器后同容器多 session 不再需要;或目标内核统一有 Landlock,可把 B 的 cap 再收紧回去。 -### 决策 12:副本列表由 worker 在领租约时发现,runner 不自注册 +### 决策 12:副本列表只认配置里的服务地址,runner 不自注册,也不做主机名扫描 - **备选**: - - A. 静态 `endpoints` 写死每一台(原稿) + - A. 静态 `endpoints` 写死每一台 - B. runner 启动后向 Redis / FastAPI 注册,worker 读注册表 - - C. worker 领租约时发现:对约定主机名模式做 DNS/连通探测(短 TTL 缓存**集合**),粘滞仍绑到解析出的那一台具体 URL -- **选定**:C 为默认;A 作为显式覆盖(探测失败或离线手工拓扑时) -- **原因**:B 要求 runner 能连 Redis 或 API。用户代码与 runner 同容器、同 netns,这条注册通道等于把中间件暴露给执行侧,直接推翻 AC-12。C 是反向的:执行侧继续只听 `:8080`、零出站;发现发生在可信的 worker 上,用的是编排已经写入 DNS 的名字(compose 服务名 / K8s headless + StatefulSet 序号),不引 docker/k8s 客户端。粘滞对象仍然是「这一次解析到的那台 URL」,不是发现用的通配名。 + - C. worker 领租约时按主机名模式做 DNS/连通探测(短 TTL 缓存**集合**),粘滞仍绑到解析出的那一台具体 URL +- **选定**:A +- **原因**:B 要求 runner 能连 Redis 或 API。用户代码与 runner 同容器、同 netns,这条注册通道等于把中间件暴露给执行侧,直接推翻 AC-12。C 曾作为默认:执行侧继续只听 `:8080`、零出站,扫描发生在可信的 worker 上。落地后扫描结果与粘滞绑在同一进程的后台缓存上,清空地址或关掉扫描都接不回「重新按主机名找副本」。副本列表改为只认 `endpoints`。compose 与 Helm 把每一台 runner 的 URL 写进 `BS_SANDBOX_CONF__ENDPOINTS`。粘滞仍绑具体 URL,不是 Service 名或 VIP。应用不读 kube-apiserver / docker。 +- **配置不含编排类型,也不含扫描字段**:`sandbox_conf` **没有** `deploy_mode` / `orchestrator: k8s|compose`,也**没有** `discover_host_pattern` / `discover_index_start` / `discover_max` / `discover_ttl_s` / `discover_port`。旧 yaml 里的 `discover_*` 被忽略;环境变量 `BS_SANDBOX_CONF__DISCOVER_*` 不再生效。`endpoints` 为空表示没有副本,领租约时报不可达,平台仍能启动。 - **何时该重新考虑**:F103 提供带会话亲和的注册面;或执行侧为用户代码单独做了 network namespace,注册通道与用户代码网络彻底断开。 --- @@ -219,7 +220,7 @@ flowchart TB 要点: - 五条业务路径(灵思 / 日常 / 助手 / 工作流 Agent / 工作流工具节点)加上工作流代码节点,全部汇合到 `BaseExecutor`,不各自连沙箱。 -- 只有 **worker** 同时连可信网与 `sandbox_net`。runner 不挂到承载 MySQL / Redis / MinIO 的网络,因此即使用户代码扫内网,也解析不到中间件主机名(AC-12)。 +- **backend**(编辑器「运行一次」在 API 进程里执行)和 **worker** 同时连可信网与 `sandbox_net`。runner 不挂到承载 MySQL / Redis / MinIO 的网络,因此即使用户代码扫内网,也解析不到中间件主机名(AC-12)。 - 产物进 MinIO 由 worker 上传,执行侧零凭据(决策 3)。 - 云沙箱是 `BaseExecutor` 的另一个子类,不伪装成副本。 @@ -236,15 +237,32 @@ flowchart TB `模型发起 tool_call` → `CodeInterpreterTool._run` → `ContainerExecutor.run`(继承共享的 `run_with_dir`)→ `execute_code`:`领租约 → copy-in 工作目录(tar, md5 delta)→ exec → copy-out` → 回到共享实现:`快照 diff → 根级产物归位 output/ → upload_minio → sync_to_workspace` → `{exitcode, log, file_list}` 进 `ToolMessage`。 -- 装配入口:`bisheng_langchain/gpts/load_tools.py:_get_native_code_interpreter`(按 `type` 三分支 dispatch) -- 五条路径的共同装配层:`bisheng/tool/domain/services/executor.py:ToolExecutor`(它负责给代码执行器注入 MinIO 配置) -- 灵思独有的绑定:`bisheng/linsight/domain/services/workbench_impl.py:_init_bisheng_code_tool`(新增 `config.container` 分支) +需要进沙箱的,是 **会执行不可信 Python** 的入口。五条路径不各自连 runner,装配汇合如下: + +| # | 产品入口 | 今天的装配锚点 | F068 怎么用沙箱 | +|---|---|---|---| +| 1 | 灵思任务模式 | `linsight/.../workbench_impl.py:_init_bisheng_code_tool` → `ToolExecutor` | `keep_session=True`;`run()` 时刻 copy-in 整个任务工作区(含已物化 `skills/`);产物 `sync_to_workspace`;任务结束才 DELETE | +| 2 | 工作台日常会话 | `workstation/.../chat_service.py:_prepare_tools` → `ToolExecutor`(`DAILY_CHAT`) | `keep_session=False`;**原件不进沙箱**;一次 `run` 结束 DELETE | +| 3 | 助手 | `api/services/assistant_agent.py` → `ToolExecutor`(`ASSISTANT`) | 同路径 2 | +| 4 | 工作流 Agent 节点 | `workflow/nodes/agent/agent.py:_init_tools` | 一次一清;copy-in 该次工具工作目录 | +| 5 | 工作流工具节点 | `workflow/nodes/tool/tool.py` `invoke` | 同路径 4,无多轮粘滞 | + +共同 dispatch:`tool/domain/services/executor.py:ToolExecutor` 注入 MinIO → `bisheng_langchain/gpts/load_tools.py:_get_native_code_interpreter` 按 `type` 显式三分支 `local | container | e2b`(禁止 `else` 吞未知 type,§5 坑 7)。 **档 B:工作流代码节点** `CodeNode._run` → `SandboxCodeParser.exec_method('main', **params)` → 生成 wrapper → 同一执行端点 → 解析哨兵行得到出参 dict → `_parse_code_output` 校验字段 → 写入 `graph_state`。 `parse_code()` 仍在本地做 `ast.parse` 静态语法校验(不 `exec`),保持搭建期报错时机不变。 +**明确不是「增加沙箱」的入口**(本期不改其执行位置): + +| 入口 | 原因 | +|---|---| +| 灵思 `ls` / `read_file` / `write_file` / `grep` | MinIO 工作区,worker 进程内;FilesystemMiddleware 不是隔离边界 | +| 知识库 / 附件解析 LibreOffice | 平台可信代码,留在解析链 | +| 工作台附件预览 | 不 exec 模型代码 | +| 技能包物化 | 写进工作区后再被路径 1 copy-in,本身不是执行器 | + ### 4.2 关键数据结构 / 字段约定 **执行环境 HTTP 协议**(内部契约,仅 worker 侧调用;不暴露宿主端口) @@ -252,7 +270,7 @@ flowchart TB | 方法 | 路径 | 鉴权 | 语义 | |---|---|---|---| | `POST` | `/v1/sessions` | 全局 `token`(只给 worker) | 领租约 → `{session_id, lease_token, lease_expires_at}` | -| `PUT` | `/v1/sessions/{id}/files` | 该会话 `lease_token` | tar.gz 流式 copy-in,随带 `{rel_path: md5}` 清单做 delta | +| `PUT` | `/v1/sessions/{id}/files` | 该会话 `lease_token` | body:4 字节大端长度 + UTF-8 JSON `{rel_path: md5}` + tar.gz | | `POST` | `/v1/sessions/{id}/exec` | 该会话 `lease_token` | `{code, lang, timeout_s}` → `{exitcode, stdout, stderr, duration_ms}` | | `GET` | `/v1/sessions/{id}/files` | 该会话 `lease_token` | tar.gz 拉回本轮新增/修改文件 | | `DELETE` | `/v1/sessions/{id}` | 该会话 `lease_token` | 擦除运行目录、释放租约 | @@ -269,7 +287,7 @@ copy-in: 2. 只收普通文件和目录;跳过 socket / 设备;相对路径与 tar 成员都拒绝 `..` 和绝对路径(zip-slip)。 3. 单文件超过 `max_copy_in_bytes`:跳过并在日志点名(AC-10),不拆整租约。 4. 按相对路径算 md5,对比 runner 内存表 `md5_index`:相同则本轮不进 tar。 -5. `PUT .../files`,body 为流式 tar.gz,旁路带 `{rel_path: md5}` 清单(请求头或并列 JSON,实现时二选一)。不要按文件拆多次 HTTP。 +5. `PUT .../files`。body 依次是 4 字节大端长度、UTF-8 JSON 清单 `{rel_path: md5}`、tar.gz。清单不放请求头:请求头在没收齐时只缓冲 16 KiB,几百个文件就会被 HTTP 解析器直接丢掉。不要按文件拆多次 HTTP。 6. runner 校验租约 → 跳过已命中路径 → 解到本 UUID 目录 → 更新 `md5_index`。独立 uid 模式:root 写入再 `chown -R`。 7. copy-in 是按相对路径 **upsert**。worker 侧已删除的文件本期不从 runner 删(整租约 DELETE 才清空)。sticky 第二轮因此只补增量。 @@ -304,11 +322,24 @@ sequenceDiagram 各路径实际拷什么:灵思 = 当前工作区全量(含技能包),后续轮次只传 md5 变化;日常 / 助手 = 通常空目录,脚本写出的文件仍走 `file_list`、不回写会话附件;工作流 Agent / 工具 = 该次工具目录;代码节点 = 通常无业务文件,出参走 stdout 哨兵。 +**何时整目录打 tar**:只发生在 `type=container` 的 `execute_code` 里、`POST /exec` 之前(以及成功后的 copy-out)。`local` 就地跑、`e2b` 走逐文件 `files.write`,都没有目录 tar。扫描是全目录;**进 tar 的只有 md5 对不上的文件**——「整目录打包」指一次 HTTP、一份 tar.gz,不是每次把全部字节再传一遍。 + +| 时机 | tar 里实际是什么 | +|---|---| +| 新租约第一次 copy-in | 工作区里未超 `max_copy_in_bytes` 的普通文件,接近全量 | +| 灵思 sticky 后续轮次、文件未改 | 空包或几乎空(md5 命中跳过) | +| sticky 后续轮次、脚本刚写出文件 | 只含变更相对路径 | +| 租约 404 / 副本重启后再 exec | 重新领约,从 worker 工作区 **全量** 再打一份 | +| copy-out(`GET .../files`) | 相对 **执行前快照** 新增或内容变了的文件,不是整盘 | +| 日常 / 助手 | `keep_session=False`,每次新租约;`dir_path` 常为空,tar 常为空 | + +容器模式 **没有** E2B 那条 5MB 自动推送天花板(那是 F035 产品阈值,见 §4.8 / §5 坑 17)。单文件超 `max_copy_in_bytes` 跳过并点名(AC-10),其余仍进同一份 tar。 + **工具结果**(模型可见,与本地模式一致):`{"exitcode": int, "log": str, "file_list": [presigned_url]}` **工具配置** `gpts_tools.extra`:`type` 取值扩展为 `local | e2b | container`;新增 `config.container = {profile, timeout}`。接入参数(endpoint / token / 池容量)**不进** `extra`,走系统配置。 -**系统配置**:新增 `Settings.sandbox_conf`。发现相关:`endpoints`(显式覆盖,非空则跳过发现)/ `discover_host_pattern`(默认 `code-runner-{n}`,K8s 配 `code-runner-{n}.code-runner`)/ `discover_index_start`(compose 1,K8s 0)/ `discover_max` / `discover_ttl_s` / `discover_port`。其余:`token` / `pool_lease_ttl_s` / `max_sessions_per_replica` / `pool_acquire_timeout_s` / `default_timeout_s` / `max_copy_in_bytes` / `code_node_enabled`。env 覆盖形如 `BS_SANDBOX_CONF__DISCOVER_HOST_PATTERN`。 +**系统配置**:新增 `Settings.sandbox_conf`。副本地址只认 `endpoints`(URL 列表;空列表表示没有副本)。其余:`token` / `pool_lease_ttl_s` / `max_sessions_per_replica` / `pool_acquire_timeout_s` / `default_timeout_s` / `max_copy_in_bytes` / `code_node_enabled`。env 覆盖形如 `BS_SANDBOX_CONF__ENDPOINTS`。**不要**增加 `deploy_mode` / `orchestrator`,也不要加主机名扫描字段(决策 12)。 **错误码**:模块 **280**,`28001` 执行环境不可达 / `28002` 容量已满 / `28003` 执行超时 / `28004` copy-in 超限 / `28005` 代码节点出参不可序列化 / `28006` 执行环境响应不合契约。落码时按 C5 回写 `docs/constitution.md` 与本版 release-contract。 @@ -316,7 +347,7 @@ sequenceDiagram | 模块 / 文件 | 职责 | 不做什么 | |---|---|---| -| `code_interpreter/base_executor.py` | 路径规则文案、日志裁剪、逃逸拒绝、产物归位、MinIO 上传、工作区镜像;`execute_code` 抽象 | 不知道代码在哪执行 | +| `code_interpreter/base_executor.py` | 路径规则文案、日志裁剪、逃逸拒绝、产物归位、MinIO 上传、工作区镜像;`execute_code` 为可覆盖缝(**不是** `@abstractmethod`:冻结的 `E2bCodeExecutor` 只 override `run`,标成抽象会让它无法实例化) | 不知道代码在哪执行 | | `code_interpreter/local_executor.py` | 以子进程在本机执行 | 不再持有产物收割逻辑(上提共享) | | `code_interpreter/container_executor.py`(新) | 租约、tar copy-in/out、调 exec | 不做快照 diff、不上传 MinIO | | 执行侧 runner 服务(新) | 收文件、跑子进程、回文件、清目录 | 不认识 `main`、不认识工作区、不连任何中间件 | @@ -343,8 +374,8 @@ sequenceDiagram | 基础 | `python:3.11-slim` | 与后端同一条 Python 线 | | 办公二进制 | **完整** LibreOffice(须含 Writer / Impress / Calc,不能只装 `libreoffice-writer`)、`pandoc` 3.6.4(`/usr/bin/pandoc`)、中文字体 `fonts-wqy-zenhei` | 灵思价值链是 docx / pptx / xlsx / pdf;缺组件时技能包探测并降级,不把整任务打成失败 | | 媒体 | `ffmpeg`(随 base 带入) | 存量技能 / 代码节点可能调到 | -| Python | 按后端 `uv.lock` **全量** `uv sync --frozen --no-dev`,venv 在镜像内(如 `/app/.venv`) | 代码节点能 `import` 后端环境里的任意包;不对等 = 某客户某条工作流 `ImportError`(决策 7) | -| 进程 | runner HTTP 监听 8080,不映射到宿主 | 租约 / tar / exec / 清理;与执行环境同一容器 | +| Python | 按后端 `uv.lock` **全量** `uv sync --frozen --no-dev --no-install-project`,venv 在镜像内(如 `/app/.venv`) | 代码节点能 `import` 后端环境里的任意第三方包;`--no-install-project` 把 FastAPI / Celery / 灵思源码留在镜像外(AC-15 / AC-28)。不对等 = 某客户某条工作流 `ImportError`(决策 7) | +| 进程 | runner HTTP 监听 8080(PID 1 = `serve.py`),不映射到宿主。监督进程读 `SANDBOX_TOKEN`;worker 侧对应 `BS_SANDBOX_CONF__TOKEN`,compose 用同一插值,token 不进镜像层 | 租约 / tar / exec / 清理;与执行环境同一容器 | | 运行时挂载(不进镜像层) | `/tmp` tmpfs:`sessions//`、LibreOffice profile、fontconfig | 只读 rootfs 下 soffice 不能写镜像路径;按 session 覆盖 `HOME`/`TMPDIR` | 对模型承诺、须写进工具描述的库(与本地模式对齐,AC-04):`pandas` · `numpy` · `matplotlib` · `openpyxl` / `XlsxWriter` · `python-docx` · `python-pptx` · `Pillow` · `reportlab` · `PyMuPDF`(`import fitz`)。不承诺 `pdfminer` / `pdfplumber` / `PyPDF2`;`pip install` 不可用(只读根文件系统 + 执行侧无外网)。 @@ -508,77 +539,60 @@ AC-13 在此模式下收窄为:用户代码非 root、只读 rootfs、默认 s **领租约(worker 侧,决策 9 + 决策 12)** ``` -urls = sandbox_conf.endpoints 或 discover() # 短 TTL 缓存的具体 URL 集合 +urls = list(sandbox_conf.endpoints) # 配置里的具体 URL,不扫描 DNS +if not urls: + raise 28001 执行环境不可达 shuffle(urls) +saw_503 = False for url in urls: - r = POST url/v1/sessions + try: + r = POST url/v1/sessions + except 连不上: + continue if r.status == 200: - bind (url, session_id, lease_token) on this executor instance # 粘滞这台,不再走发现名 + bind (url, session_id, lease_token) on this executor instance # 粘滞这台 return if r.status == 503: + saw_503 = True continue -raise 28002 容量已满 +if saw_503: + raise 28002 容量已满 +raise 28001 执行环境不可达 ``` -**执行流程(发现 + 领租约)** +**执行流程(领租约)** ```mermaid flowchart TB Start([业务路径调用 execute_code]) --> Sticky{本实例已绑定 url?} Sticky -->|是 keep_session 续租| Bound[files / exec / DELETE 只打绑定 URL] - Sticky -->|否 新租约| Override{endpoints 非空?} - Override -->|是 显式覆盖| Urls[得到 URL 列表] - Override -->|否| Cache{发现缓存 TTL 内?} - Cache -->|命中| Urls - Cache -->|过期或空| Init["n = discover_index_start
found = 空列表"] - Init --> Resolve["解析 discover_host_pattern
替换 n → host"] - Resolve --> Dns{DNS 能解析?} - Dns -->|否 NXDOMAIN| Stop[停止递增] - Dns -->|是| Health["可选 GET http://host:port/health"] - Health --> Ok{可达?} - Ok -->|是| Add["found 追加 http://host:port
n = n+1"] - Ok -->|否| Stop - Add --> Cap{n 超过 discover_max?} - Cap -->|否| Resolve - Cap -->|是| Stop - Stop --> Save["缓存 found 主机名 URL
TTL = discover_ttl_s"] - Save --> Urls + Sticky -->|否 新租约| Urls[读取 endpoints] Urls --> Empty{列表为空?} Empty -->|是| Unreachable[28001 执行环境不可达] Empty -->|否| Shuffle[shuffle 列表] Shuffle --> Try["POST url /v1/sessions"] Try --> Resp{响应} - Resp -->|200| Bind["bind 该 url + session_id
本轮不再走发现"] - Resp -->|503 槽满| Next{还有下一台?} - Resp -->|连不上 / 其它| Next + Resp -->|200| Bind["bind 该 url + session_id"] + Resp -->|503 槽满| Mark[记下见过 503] --> Next{还有下一台?} + Resp -->|连不上| Next Next -->|是| Try - Next -->|否| Full[28002 容量已满] + Next -->|否| Which{见过 503?} + Which -->|是| Full[28002 容量已满] + Which -->|否| Unreachable Bind --> Bound ``` -**时序(发现 + 领租约 + 粘滞)** — 算法与 HTTP 在两种编排下相同;下面用 compose 主机名举例。DNS 问谁、序号从几开始,见 §4.6 两条对照时序。 +**时序(领租约 + 粘滞)** — HTTP 在两种编排下相同;下面用 compose 里配置的两台地址举例。地址写在哪,见 §4.6。 ```mermaid sequenceDiagram participant Path as 业务路径 participant W as ContainerExecutor - participant DNS as 集群 DNS participant R1 as code-runner-1 participant R2 as code-runner-2 Path->>W: execute_code 新租约 - Note over W: endpoints 为空且发现缓存过期 - W->>DNS: 解析 code-runner-1 - DNS-->>W: 命中 - W->>R1: GET /health - R1-->>W: 200 - W->>DNS: 解析 code-runner-2 - DNS-->>W: 命中 - W->>R2: GET /health - R2-->>W: 200 - W->>DNS: 解析 code-runner-3 - DNS-->>W: NXDOMAIN - Note over W: 停止递增;缓存两台主机名 URL
TTL = discover_ttl_s + Note over W: endpoints 已列出两台 W->>R1: POST /v1/sessions R1-->>W: 503 槽满 W->>R2: POST /v1/sessions @@ -588,13 +602,13 @@ sequenceDiagram W->>R2: PUT files / POST exec / GET files R2-->>W: 执行结果 Path->>W: execute_code keep_session - Note over W: 已绑定,跳过发现 + Note over W: 已绑定,不再挑选 W->>R2: POST exec R2-->>W: 执行结果 W->>R2: DELETE session ``` -`discover()`:按 `discover_host_pattern` 从 `discover_index_start` 递增替换 `{n}`,对 `host:port` 做 DNS 解析(可选再探 `GET /health`)。连续 NXDOMAIN / 连不上则停止(StatefulSet / 具名 compose 服务都是连续序号)。结果缓存 `discover_ttl_s`(建议 15~30s),缓存的是主机名 URL 不是 Pod IP。TTL 过期后下次领租约再发现,扩容无需重启 worker。已绑定的 sticky 会话不重新发现。 +`endpoints` 是唯一的副本列表。compose 默认写出 `http://code-runner-1:8080` 与 `http://code-runner-2:8080`;Helm 默认列出 `code-runner-0`、`code-runner-1` 的 Pod 地址。加副本要把新 URL 追加进这份列表并升级平台,不会按主机名模式扫出来。列表为空则 `28001`。已绑定的 sticky 会话不再重新挑选,直到租约 404 或实例结束。 之后所有 `files` / `exec` / `DELETE` 都打到绑定的那台 `url`,不再重新挑选。灵思 `keep_session=True` 时,同一 `ContainerExecutor` 实例跨多轮 tool_call 复用这组 `(url, session_id)`。日常 / 助手 / 工作流工具 / 代码节点 `keep_session=False`:一次 `run()` 结束就 `DELETE`。 @@ -640,7 +654,7 @@ sequenceDiagram | exec / copy 中途 **协议不合 / 连不上** | 能杀则 `killpg` | worker 尽最大努力 DELETE;失败交给 TTL | `28001` / `28006`,禁止回落进程内 `exec`(AC-05) | | **worker 崩溃** / DELETE 丢失 | 当时无感知 | idle TTL 到期视同 DELETE | 槽不会永久占死 | | sticky 间隙 **租约过期** 后再 exec | 回 404,目录已擦 | 已释放 | worker 解绑,重新领约 + 全量 copy-in | -| **runner 容器重启** | tmpfs + 内存表清空 | 立刻 | 粘滞失败 → 重新发现 / 领约 + copy-in | +| **runner 容器重启** | tmpfs + 内存表清空 | 立刻 | 粘滞失败 → 按 endpoints 重新领约 + copy-in | | K8s **缩容**掉被粘滞的 Pod | 同重启 | 旧 Pod 消失即释放 | 按租约 404 处理,换一台 | | 池满,**根本没领到**租约 | 无 session 可回收 | — | `28002`,与执行失败分列(AC-27) | @@ -654,34 +668,32 @@ sequenceDiagram ### 4.6 compose 与 K8s 如何挂沙箱 -两种编排支持的是**同一件事**:声明 N 个 `bisheng-sandbox` 副本,worker 在领租约时按主机名模式发现它们,并用网络策略把 runner 从中间件网段里摘出去。应用代码看不到 docker / kube-apiserver。**本期交付 compose;K8s 协议兼容,不强制交清单、不要求客户新上集群。** +两种编排支持的是**同一件事**:声明 N 个 `bisheng-sandbox` 副本,把每一台的 URL 写进平台的 `endpoints`,并用网络策略把 runner 从中间件网段里摘出去。应用代码看不到 docker / kube-apiserver,也不按主机名扫描副本。**本期交付 compose;K8s 协议兼容,不强制交清单、不要求客户新上集群。** | 维度 | docker compose(本期交付) | Kubernetes(协议兼容) | |---|---|---| -| 副本怎么声明 | 显式服务 `code-runner-1` / `code-runner-2`,**不用** `deploy.replicas`(匿名实例没有稳定 DNS,粘滞会丢) | `StatefulSet` + headless Service;**不用** `Deployment` + ClusterIP | -| worker 怎么找到副本 | 默认发现 `code-runner-{n}`(n 从 1 递增到连不上为止);`endpoints` 非空则覆盖 | 发现 `code-runner-{n}.code-runner`(n 从 0);粘滞具体主机名,不粘滞 Service 名 | -| 网络隔离怎么做 | 独立网络 `sandbox_net`:runner 只连它;worker 双挂(default + sandbox_net);**不**把 runner 端口映射到宿主 | `NetworkPolicy`:只允许来自 worker Pod 的 8080;runner 不加入中间件 Service 的 selector | +| 副本怎么声明 | 显式服务 `code-runner-1` / `code-runner-2`,**不用** `deploy.replicas`(匿名实例没有稳定主机名,粘滞会丢) | `StatefulSet` + headless Service;**不用** `Deployment` + ClusterIP | +| worker 怎么找到副本 | `BS_SANDBOX_CONF__ENDPOINTS` 列出 `http://code-runner-1:8080`、`http://code-runner-2:8080` | `sandbox.endpoints` 列出每个 Pod URL(从 `code-runner-0` 起);粘滞具体 URL,不粘滞 Service 名 | +| 网络隔离怎么做 | 独立网络 `sandbox_net`:runner 只连它;backend 与 worker 双挂(default + sandbox_net);**不**把 runner 端口映射到宿主 | `NetworkPolicy`:允许来自 backend 与 worker Pod 的 8080;runner 不加入中间件 Service 的 selector | | 加固怎么落 | compose:`cap_drop: ALL`、`read_only: true`、`mem_limit` / `pids_limit`、`security_opt: no-new-privileges`、`tmpfs`、`user: 65534` | 等价的 `securityContext` + `resources.limits` + `emptyDir.medium: Memory` 作为 `/tmp` | -| 扩缩容 | 加一个具名服务;worker 在 TTL 后自动发现,不必改 endpoints | 改 `spec.replicas`;同样等 TTL 后发现。缩容中被粘滞的副本按租约 404 处理 | -| 明确不做 | runner 向 Redis/API 自注册;前面加 nginx / VIP;挂 `docker.sock` | 普通 ClusterIP 当唯一入口;backend 引 `kubernetes` 包;runner 出站注册 | +| 扩缩容 | 加一个具名服务,并把其 URL 追加进 `endpoints` | 改 `spec.replicas` 后,把每个新 Pod URL 追加进平台 `sandbox.endpoints` 再升级。缩容中被粘滞的副本按租约 404 处理 | +| 明确不做 | runner 向 Redis/API 自注册;按主机名模式扫描;前面加 nginx / VIP;挂 `docker.sock` | 普通 ClusterIP 当唯一入口;backend 引 `kubernetes` 包;runner 出站注册;按 ordinal 自动扫描 | -发现时序**结构相同**,差别只在「问哪套 DNS、主机名怎么写」: +地址**写法不同**,领租约的 HTTP 相同。差别是 URL 写在哪、主机名长什么样,不是两套发现算法: | | docker compose | Kubernetes | |---|---|---| -| DNS 谁答 | Compose 项目内嵌 DNS(`sandbox_net`) | CoreDNS;headless Service 把 StatefulSet 序号写成 A 记录 | -| 查询名 | `code-runner-1`、`code-runner-2`、… | `code-runner-0.code-runner`、`code-runner-1.code-runner`、… | -| `{n}` 起点 | 1(compose 服务名习惯从 1) | 0(StatefulSet ordinal) | -| 停的条件 | 下一个服务名 NXDOMAIN | 下一个 ordinal NXDOMAIN | +| 地址写在哪 | backend / worker 的 `BS_SANDBOX_CONF__ENDPOINTS` | 平台 chart 的 `sandbox.endpoints`,注入同一环境变量 | +| 默认两台 | `http://code-runner-1:8080`、`http://code-runner-2:8080` | `http://code-runner-0.code-runner.bisheng-sandbox.svc.cluster.local:8080`、`…-1…` | | 粘滞绑什么 | `http://code-runner-2:8080` | `http://code-runner-1.code-runner:8080` | | 与 runner 的 HTTP | 相同:`POST /v1/sessions` → files/exec/DELETE | 相同 | -| 不做什么 | 不查一个叫 `code-runner` 的 VIP | 不把 Service 名 `code-runner` 当唯一入口(那会变成多 A / kube-proxy 打散) | +| 不做什么 | 不扫描 `code-runner-{n}`,不查一个叫 `code-runner` 的 VIP | 不按 ordinal 递增解析到 NXDOMAIN;不把 Service 名 `code-runner` 当唯一入口(那会变成多 A / kube-proxy 打散) | -worker 代码是同一段 `discover()`,只换 `discover_host_pattern` 与 `discover_index_start`。 +worker 只读 `endpoints`。`config.yaml` **不**写「部署方案 = k8s / compose」,也**不**写主机名模式:没有作用对象(零 docker / 零 k8s 客户端),也避免和编排清单各写一份互相漂移。加副本改地址列表。 **docker compose 运行拓扑** -worker 必须同时加入默认网络(访问 MySQL / Redis / MinIO)和 `sandbox_net`(访问 runner)。runner **只**加入 `sandbox_net`,且不 `ports:` 发布到宿主——隔离靠网络不可达,不靠「不告诉用户端口」。 +backend 与 worker 都必须同时加入默认网络(访问 MySQL / Redis / MinIO)和 `sandbox_net`(访问 runner)。编辑器「运行一次」在 backend 进程里执行。runner **只**加入 `sandbox_net`,且不 `ports:` 发布到宿主——隔离靠网络不可达,不靠「不告诉用户端口」。 ```mermaid flowchart TB @@ -696,8 +708,9 @@ flowchart TB R2["code-runner-2"] end end - Worker -->|"发现 code-runner-n 后直连"| R1 - Worker -->|"发现 code-runner-n 后直连"| R2 + Worker -->|"endpoints 直连"| R1 + Worker -->|"endpoints 直连"| R2 + API -->|"运行一次"| R1 Worker --> DB API --> DB API --> Worker @@ -712,9 +725,8 @@ networks: services: backend_worker: networks: [default, sandbox_net] - # 默认发现 code-runner-{n};不必再写 ENDPOINTS - # environment: - # BS_SANDBOX_CONF__ENDPOINTS: "http://code-runner-1:8080,http://code-runner-2:8080" + environment: + BS_SANDBOX_CONF__ENDPOINTS: '["http://code-runner-1:8080","http://code-runner-2:8080"]' code-runner-1: &runner image: dataelement/bisheng-sandbox:${TAG} @@ -725,26 +737,15 @@ services: <<: *runner ``` -**compose 发现时序**(Docker 内嵌 DNS;n 从 1) +**compose 领租约时序**(地址来自 `BS_SANDBOX_CONF__ENDPOINTS`,不扫描 DNS) ```mermaid sequenceDiagram participant W as worker - participant DNS as Compose DNS participant R1 as code-runner-1 participant R2 as code-runner-2 - W->>DNS: A code-runner-1 - DNS-->>W: 命中 sandbox_net IP - W->>R1: GET /health - R1-->>W: 200 - W->>DNS: A code-runner-2 - DNS-->>W: 命中 - W->>R2: GET /health - R2-->>W: 200 - W->>DNS: A code-runner-3 - DNS-->>W: NXDOMAIN - Note over W: 缓存 http://code-runner-1:8080
http://code-runner-2:8080 + Note over W: endpoints 已是两台 URL W->>R1: POST /v1/sessions R1-->>W: 503 W->>R2: POST /v1/sessions @@ -797,38 +798,27 @@ spec: ports: [{ port: 8080 }] ``` -worker **不必**列出每一台 Pod。配 `discover_host_pattern=code-runner-{n}.code-runner`、`discover_index_start=0`,领租约时从 0 递增解析直到 NXDOMAIN。粘滞的是 `http://code-runner-0.code-runner:8080` 这种具体主机名,不是 Service 名 `code-runner`。headless Service 的作用是让这些序号 DNS 存在,不是当 VIP。 +worker **必须**列出每一台 Pod。平台 `sandbox.endpoints` 写入 `http://code-runner-0.code-runner.bisheng-sandbox.svc.cluster.local:8080` 这种具体 URL,领租约时只打这份列表。粘滞的是这份 URL,不是 Service 名 `code-runner`。headless Service 的作用是让序号 DNS 能被这份列表引用,不是当 VIP,也不是给 worker 做扫描。 **不要**用普通 ClusterIP 当唯一入口——kube-proxy 会把第二次 `exec` 打到另一台 Pod。也**不要**让 runner 向 Redis / API 注册:用户代码与 runner 同 netns,注册出站等于给执行侧开中间件通道(决策 12)。 -backend / worker **不**引入 `kubernetes` / `docker` Python 包,不挂 `docker.sock`,不访问 kube-apiserver。编排系统负责把 Pod 拉起来并写入 DNS;应用只做 DNS 查询 + HTTP。 +backend / worker **不**引入 `kubernetes` / `docker` Python 包,不挂 `docker.sock`,不访问 kube-apiserver。编排系统负责把 Pod 拉起来;应用只按配置里的 URL 做 HTTP。 -**K8s 发现时序**(CoreDNS + headless;n 从 0。与 compose 相比只换查询名,HTTP 段不变) +**K8s 领租约时序**(地址来自 `sandbox.endpoints`。与 compose 相比只换 URL 写法,HTTP 段不变) ```mermaid sequenceDiagram participant W as worker - participant DNS as CoreDNS participant P0 as code-runner-0 participant P1 as code-runner-1 - Note over W: 不要查 Service 名 code-runner
那是多 A / 无粘滞 - W->>DNS: A code-runner-0.code-runner - DNS-->>W: 命中 Pod IP - W->>P0: GET /health - P0-->>W: 200 - W->>DNS: A code-runner-1.code-runner - DNS-->>W: 命中 - W->>P1: GET /health - P1-->>W: 200 - W->>DNS: A code-runner-2.code-runner - DNS-->>W: NXDOMAIN - Note over W: 缓存 http://code-runner-0.code-runner:8080
http://code-runner-1.code-runner:8080 + Note over W: 不要用 Service 名 code-runner
那是多 A / 无粘滞 + Note over W: endpoints 已是两台 Pod URL W->>P0: POST /v1/sessions P0-->>W: 503 W->>P1: POST /v1/sessions P1-->>W: 200 - Note over W: bind 主机名 URL 不是 Pod IP + Note over W: bind 配置里的 Pod URL,不是 Service 名 ``` ### 4.7 沙箱抽象层与云后端插拔 @@ -855,13 +845,13 @@ flowchart TB ```yaml # 发布稿里整段注释掉(坑 9) # sandbox_conf: -# discover_host_pattern: "code-runner-{n}" -# discover_index_start: 1 -# discover_ttl_s: 15 +# endpoints: +# - "http://code-runner-1:8080" +# - "http://code-runner-2:8080" # token: "..." # pool_lease_ttl_s: 900 # max_sessions_per_replica: 1 -# # endpoints: ["http://code-runner-1:8080"] # 非空则跳过发现 +# # 不要加 deploy_mode / orchestrator,也不要加主机名扫描字段(决策 12) ``` 云后端**不**走 `/v1/sessions`。E2B 继续用 SDK 的 `Sandbox` / `files.write`;新云厂商实现同一对 `execute_code` + `close`,在自己的 `__init__` 里读自己的密钥。把云 API 伪装成自建副本只会把两种生命周期缠在一起。 @@ -870,11 +860,23 @@ flowchart TB ### 4.8 后续若要正式支持 E2B(本期不做,只定接法) -本期冻结 E2B 功能改进(spec 排除项)。代码里 `E2bCodeExecutor` 已经能跑;「正式支持」是指把它收进 F068 同一套 `execute_code` 语义,并还掉已知债,而不是再接一套业务路径。 +本期冻结 E2B 功能改进(spec 排除项)。代码里 `E2bCodeExecutor` 已经能跑;「正式支持」是指把它收进 F068 同一套 `execute_code` 语义,并还掉已知债,而不是再接一套业务路径。现网依赖 `e2b-code-interpreter==1.5.2`(`Sandbox(api_key, domain, timeout)` 构造,不是较新的 `Sandbox.create()`)。 + +**E2B 线上是三层协议,不是一条 `/exec`**(所以不能伪装成副本池的 `/v1/sessions`): + +| 层 | 地址 | 鉴权 | 毕昇实际调用 | +|---|---|---|---| +| ① 控制面 | `https://api.e2b.app` REST:`POST/DELETE /sandboxes` 等 | `X-API-Key` | `Sandbox(...)` / `sandbox.kill()` | +| ② 数据面 envd | 默认端口 **49983**:`POST/GET /files`、ConnectRPC `Filesystem.*` / `Process.Start` | `E2b-Sandbox-Id` + `E2b-Sandbox-Port`;`secure: true` 时还有 `X-Access-Token` | `files.write` / `read` / `list` / `make_dir`。解释器 **不用** Process RPC | +| ③ Code Interpreter | 端口 **49999**:`POST /execute` NDJSON 流;健康检查 `/health` | 同上 access token | `sandbox.run_code(code)` → Jupyter kernel(沙箱内再转 `ws://localhost:8888/api/kernels/{context_id}/channels`) | + +`run_code` **跨调用保留 kernel 变量**(上一轮 `x = 1` 下一轮还能用)。F068 `/exec` 是一次独立子进程,磁盘靠 sticky 目录保留,Python 进程不保留。访问沙箱的新写法是共享主机 `sandbox.{domain}` + 两个路由 header;旧写法 `https://{port}-{sandboxID}.e2b.app`。worker **直连**云控制面;runner 零出站。 + +**`SIZE_AUTOPUSH = 5MB` 不是 E2B 接口上限。** 是 F035(design §9.3.9)给「自动 copy-in」定的产品阈值:沙箱碰不到 MinIO,worker 必须 `f.read()` 整文件再 `files.write`;每次 `run` 把工作区全量出境会变成 silent bulk transfer。设计意图是 ≤5MB 自动 delta push,>5MB 由模型在 `required_files` 声明后再中转。现状对不上:工具 schema 只有 `python_code`;`_materialize_working_set()` 固定 `{}`;`workbench_impl._init_bisheng_code_tool` 装配时把 oversized 从 `file_list` 剔除。所以 **E2B 模式下 >5MB 等于进不去沙箱**。本地执行器不受影响(cwd=`file_dir`,原件可到 `_RAW_KEEP_MAX_BYTES` 50MB)。本期不还这笔债;容器模式用 `max_copy_in_bytes` + AC-10 点名跳过,不套 5MB 自动推送。 **不要做的** -- 不要把 E2B 伪装成副本池:不实现 `/v1/sessions`,不走 `discover_host_pattern`,不把 `Sandbox` 登记进 runner DNS。 +- 不要把 E2B 伪装成副本池:不实现 `/v1/sessions`,不把 `Sandbox` 登记进 runner 的地址列表。 - 不要在 runner 容器里调 E2B SDK。worker 直连 E2B 控制面,执行侧零出站(AC-12)继续成立。 - 不要把决策 13 的 uid / `lease_token` 套到 E2B 上。E2B 的隔离单元是 **一台 `Sandbox`(Firecracker)**,两个会话 = 两个 Sandbox,不是一个 VM 里两个目录。 - 不要继续在 `else: E2bCodeExecutor` 里吞掉未知 `type`。dispatch 必须显式 `local | container | e2b`。 @@ -915,11 +917,12 @@ sequenceDiagram |---|---| | `POST /v1/sessions` 领常驻副本上的目录 | `Sandbox.create`(或 pause 后 resume) | | `(url, session_id, lease_token)` 粘滞 | `sandbox_id`;SDK 鉴权用 `api_key`,没有 localhost DELETE 问题 | -| `files` tar HTTP | `sandbox.files.write` / 读;worker 居中,沙箱仍不连 MinIO | -| `exec` | `sandbox.run_code` | +| `files` tar HTTP | `sandbox.files.write` / 读(逐文件,不是目录 tar);worker 居中,沙箱仍不连 MinIO | +| `exec` 一次子进程 | `sandbox.run_code` → `:49999/execute` Jupyter 流;kernel 状态跨轮保留 | | `DELETE` | `sandbox.kill` | | 并发隔离 = 另一台 runner 容器 | 并发隔离 = 另一个 Sandbox | -| `sandbox_conf.endpoints` / 发现 | `config.e2b.api_key` + 可选 `domain`(有 domain = 私有,无 = 官方云) | +| `sandbox_conf.endpoints` + token | `config.e2b.api_key` + 可选 `domain`(有 domain = 私有,无 = 官方云)。**不要**把 E2B 写进 `sandbox_conf` | +| 无 5MB 自动推送天花板(有 `max_copy_in_bytes`) | `SIZE_AUTOPUSH` 5MB 自动推送;超限须 `required_files`(现状未接到工具 schema) | **改造清单(独立 Feature,不并进 F068)** @@ -956,6 +959,8 @@ sequenceDiagram | 14 | 同容器、同 uid 下的兄弟目录**不是**会话数据隔离;全局 Bearer 也挡不住同网 DELETE 邻居租约 | 把槽位调到 2 或只靠目录权限,用户代码能读/写邻居文件,或打 localhost 删邻居 session | 决策 13:独立 uid + 父目录 0711 + `/tmp` 0755 + 按会话 lease_token | | 15 | tar 解包若不校验成员路径,`../` 或绝对路径会写出 session 目录(zip-slip) | copy-in 被用户代码或被污染的工作区带着逃出 `work_dir` | PUT/GET 两侧都拒绝 `..` 与绝对路径;成员必须落在该 UUID 目录内 | | 16 | 决策 7 的「venv 对等」会把 `minio` / `playwright` 等客户端库带进沙箱,但 Chromium 二进制默认不装、中间件也连不上 | 以为能 `playwright.launch()` 或 `Minio(...)` 上传;或反过来把 playwright 包从锁文件里删掉导致代码节点 ImportError | 工具描述只承诺办公/数据那批库;浏览器技能运行时探测;`import minio` 成功 ≠ 能连 MinIO | +| 17 | E2B 自动 copy-in 的 5MB(`SIZE_AUTOPUSH`)是 F035 防 silent bulk transfer 的产品阈值,**不是** E2B `files.write` 的协议上限;`required_files` 设计了但工具 schema 未接线,oversized 在装配期被静默剔出 `file_list` | 以为容器模式也要 5MB 自动推送;或以为声明 `required_files` 就能把大文件推进现网 E2B | 容器模式只用 `max_copy_in_bytes` + AC-10 点名;E2B 改进冻结(§4.8) | +| 18 | `sandbox_conf` 没有 `deploy_mode`,也没有主机名扫描字段 | 加枚举却零编排客户端,两套清单必然漂移;或按 `code-runner-{n}` 扫描,扩容和清空地址都接不回一份确定的列表 | 决策 12:只写 `endpoints`。旧的 `discover_*` 配置被忽略 | --- @@ -966,7 +971,7 @@ sequenceDiagram | 契约 | 形式 | 谁在用 | |---|---|---| | 工具结果 `{exitcode, log, file_list}` | 隐式数据契约(进 `ToolMessage`) | 模型本身、灵思中间件(循环检测 / 部分结果打捞 / 二进制守卫)、既有用例 | -| `BaseExecutor.execute_code` 抽象 | 内部 Python 契约 | 三种执行后端;未来新后端 | +| `BaseExecutor.execute_code` 可覆盖缝(非 ABC 抽象,见 §4.3) | 内部 Python 契约 | 三种执行后端;未来新后端 | | 执行侧 `/v1/*` HTTP 协议 | 内部 HTTP | 仅 worker 侧执行器;不对外暴露 | | `gpts_tools.extra.type` 取值集 | 数据契约 | 管理端配置弹窗、`ToolExecutor` | | 错误码模块 280 | 对外可观测行为 | 运维、日志检索 | @@ -1027,7 +1032,8 @@ docker compose exec code-runner env | grep -i -E 'minio|hmac|mysql' - **按次新建容器**:等 3.0 应用工场 F103 `runtime-manager` 落地后复用其编排面,不在本 Feature 自造特权控制面(决策 2)。 - **工具结果契约归一**:E2B 下线、只剩两种后端时一次性做(决策 5)。 - **日常会话把上传原件送进执行环境**:今天只抽文本进 prompt,因此「打开我刚传的 xlsx 改完另存」在日常会话做不到。这是产品缺口而非本 Feature 的技术债,需产品先定语义。 -- **runner 向 Redis / API 自注册**:执行侧与用户代码同 netns,注册出站等于给不可信代码开中间件通道(决策 12)。副本发现由 worker 做 DNS/连通探测。 +- **runner 向 Redis / API 自注册**:执行侧与用户代码同 netns,注册出站等于给不可信代码开中间件通道(决策 12)。 +- **按主机名模式扫描副本**:`discover_*` 配置已删除。副本只认 `endpoints`。加副本改列表并升级平台。 - **知识库解析链路的 LibreOffice 不进沙箱**:那是平台自有可信代码,占用执行容量只会互相拖慢。 - **E2B 模式不再投入**:含其产物枚举只列一层目录的缺陷(§5 坑 12);冻结为境外部署与存量兼容。正式支持的接法见 §4.8,不并进本 Feature。 @@ -1042,3 +1048,6 @@ docker compose exec code-runner env | grep -i -E 'minio|hmac|mysql' | 2026-09-16 | §4.6 补 compose / K8s 发现对照表与两条时序:算法相同,仅 DNS 名与序号起点不同 | 用户问两种编排的发现实现和时序是否有区别 | | 2026-09-16 | 补 §4.8:后续正式支持 E2B 走 execute_code 插件,不进副本池;列出技能包/递归 copy-out/结果形状等必还债 | 用户问后续要支持 E2B 应如何实现 | | 2026-09-16 | 从评审说明书回写:五条路径表;§4.2 HTTP tar copy-in/out 步骤与 zip-slip;§4.4 镜像分层 / 承诺库 / 不装 Chromium;§4.5 异常回收双保险与 sticky 拆租约边界;坑 15–16 | 说明书补路径、文件进出、镜像内容、异常回收后要求同步 design | +| 2026-09-20 | §4.1 补调用点锚点与「不是调用点」;§4.2 澄清何时整目录打 tar(扫描≠全量字节);决策 12 / §4.6 / §4.7 明确配置无 k8s\|compose 枚举;§4.8 补 E2B 三层协议、kernel 粘滞、`SIZE_AUTOPUSH` 5MB 为产品阈值且 `required_files` 未接线;坑 17–18 | 评审讨论:调用点、E2B 协议、5MB 原因、何时打 tar、配置要不要写部署方案 | +| 2026-09-20 | 实现收口:`execute_code` 不以 `@abstractmethod` 落地(保住冻结 E2B 可实例化);沙箱镜像 `uv sync --no-install-project`;runner PID 1 读 `SANDBOX_TOKEN`,与 worker `BS_SANDBOX_CONF__TOKEN` 同源插值 | Wave 2–10 落地后回写与代码一致的契约 | +| 2026-10-21 | 决策 12、§4.2、§4.5、§4.6、§4.7、坑 18、§8:去掉主机名发现。副本只认 `endpoints`;compose 与 Helm 列出两台 runner URL | 主机名扫描与粘滞缓存接不回「清空地址后重新发现」,改为显式地址列表 | diff --git a/features/v3.0.0-beta1/068-code-execution-sandbox/spec.md b/features/v3.0.0-beta1/068-code-execution-sandbox/spec.md index 7c396dc007..9e864dba9e 100644 --- a/features/v3.0.0-beta1/068-code-execution-sandbox/spec.md +++ b/features/v3.0.0-beta1/068-code-execution-sandbox/spec.md @@ -115,19 +115,19 @@ | 评审投影 / 给未读 SDD 的人(结论 + 图 + 会上确认项) | [沙箱架构设计说明书.md](./沙箱架构设计说明书.md) | | 为什么这么实现(决策 + 备选 + 何时该推翻) | design.md §3 方案对比 | | 关键约束(部署形态 / 容量 / 隔离口径 / 错误码段) | design.md §2 | -| 五条业务路径各走哪个 worker、拷什么文件 | design.md §4.1 | -| HTTP tar copy-in / copy-out 步骤 | design.md §4.2 | +| 五条业务路径各走哪个 worker、拷什么文件、调用点锚点 | design.md §4.1 | +| HTTP tar copy-in / copy-out;何时整目录打 tar | design.md §4.2 | | 沙箱镜像装什么、不装什么 | design.md §4.4 | | runner 内会话数据如何加固;两 session 如何互不可读 | design.md §4.5 · 决策 11–13 | | session 异常回收(超时 / OOM / worker 崩溃 / TTL) | design.md §4.5 | | 领租约时如何发现副本(流程图 · 时序图) | design.md §4.5 · 决策 12 | | 今天的数据流、模块职责、字段 / 协议约定 | design.md §4.1–§4.3 | -| 后续若正式支持 E2B 怎么接 | design.md §4.8 · 决策 10 | -| compose / K8s 如何发现副本(对照表 + 两条时序) | design.md §4.6 · 决策 12 | +| 后续若正式支持 E2B 怎么接;三层协议与 5MB 阈值 | design.md §4.8 · 决策 10 | +| compose / K8s 如何发现副本;配置无编排类型枚举 | design.md §4.6 · 决策 12 | | 代码里看不出的坑、反直觉事实 | design.md §5 | | 对外契约、改了会破坏谁、依赖谁 | design.md §6 | | 性能 / 安全 / 可观测非功能指标 | design.md §2 + §7 | -| 任务拆解、文件清单、执行顺序、踩坑落档 | tasks.md | +| 任务拆解、文件清单、执行顺序、踩坑落档 | [tasks.md](./tasks.md) | --- @@ -135,7 +135,7 @@ - 讨论用: [沙箱架构设计说明书.md](./沙箱架构设计说明书.md) - 设计真相: [design.md](./design.md)(接手第一入口) -- 执行与落档: tasks.md(尚未编写) +- 执行与落档: [tasks.md](./tasks.md)(29 任务 / 10 Wave,待实现) - 版本契约: [features/v3.0.0-beta1/release-contract.md](../release-contract.md) - 架构文档: `docs/architecture/`、`docs/constitution.md` - 选型调研: `docs/PRD/2.6 灵思 deepagents 迁移 PRD/灵思代码执行沙箱选型调研(私有化场景).md` diff --git a/features/v3.0.0-beta1/068-code-execution-sandbox/tasks.md b/features/v3.0.0-beta1/068-code-execution-sandbox/tasks.md new file mode 100644 index 0000000000..a17816a295 --- /dev/null +++ b/features/v3.0.0-beta1/068-code-execution-sandbox/tasks.md @@ -0,0 +1,319 @@ +# Tasks: 代码执行沙箱统一底座(F068) + +**关联规格**: [spec.md](./spec.md) · **设计真相**: [design.md](./design.md) +**版本**: v3.0.0-beta1 + +--- + +## 状态 + +| 步骤 | 状态 | 备注 | +|------|------|------| +| spec.md | ✅ 已评审 | 2026-09-20 用户确认 ★;AC-01–AC-28 | +| design.md | ✅ 已评审 | 2026-09-20 用户确认 ★;2026-10-21 去掉主机名发现,副本只认 `endpoints` | +| tasks.md | ✅ 已拆解 | 用户已确认审查;29 个任务 / 10 个 Wave。原 T015 / T016 的主机名扫描已从代码删除,以 design.md 决策 12 为准 | +| 实现 | ✅ 代码 29 / 29 | Wave 1–10;T028 业务 E2E 待 compose 实跑(见实际偏差记录) | + +--- + +## 开发模式 + +- **后端 Test-First**:测试任务排在配对实现之前;实现任务的「测试」字段写明要转绿的用例。 +- **无新表 / 无 Alembic / 无新对外路径**。浏览器与 FastAPI 不直连 runner。错误码模块 **280** 已在 release-contract 登记,落码时回写 constitution C5。 +- **自包含**:任务内联文件与逻辑;**为什么这么做指向 design §3 决策编号,不复制论证**。 +- **共享文件只做增量**:`load_tools.py` 只改 dispatch;`workbench_impl.py` 只改 `_init_bisheng_code_tool` 的 container 分支(**不要删** E2B `file_list` 预扫,E2B 冻结);`docker-compose.yml` 只加 `sandbox_net` 与具名 runner;`local_executor.py` 瘦身为 `execute_code`;`settings.py` 只加 `sandbox_conf`。 +- **禁止**:`deploy_mode` / `orchestrator` 配置项;`discover_*` 主机名扫描;runner 向 Redis/API 注册;`else: E2bCodeExecutor`;容器模式装配时预扫 `file_list`;静默回落进程内 `exec`;把 E2B 的 5MB `SIZE_AUTOPUSH` 套到 container。 +- **无新 Celery 任务**:执行发生在既有 Linsight / workflow / default worker 进程内,tenant 已由既有 ContextVar 注入;**不得**把 `tenant_id` / MinIO 密钥写进 runner 子进程 env。 +- **前端**:只改 Platform 内置工具弹窗;Client 无新页面。 +- **Docker / 加固断言**标测试降级(需 compose 实跑),理由见对应任务。 + +--- + +## Tasks + +### Wave 1 — 基础设施(错误码 / 文案 / 配置) + +- [x] **T001**: 错误码模块 280 + constitution C5 回写 + **文件**: `src/backend/bisheng/common/errcode/sandbox.py`, `docs/constitution.md` + **逻辑**: 新建 `SandboxUnreachableError` 28001、`SandboxCapacityExceededError` 28002、`SandboxExecTimeoutError` 28003、`SandboxCopyInLimitError` 28004、`SandboxCodeNodeOutputError` 28005、`SandboxProtocolError` 28006,继承 `BaseErrorCode`,`Msg` 用英文短句(用户可见文案走 T002)。`constitution.md` 模块登记表补 `28x | 280 sandbox`,并加一句 **280 is assigned**(与 260/270 同款)。`release-contract.md` 已有 280,**不要改表 1 所有权**。无 DDL,无需回滚。 + **设计依据**: design §2 · §4.2 错误码 · C5 + **依赖**: 无 + +- [x] **T002**: 280 段 `api_errors` 三语文案 + **文件**: `src/frontend/packages/locales/src/api_errors/{en,zh-Hans,ja}.json` + **逻辑**: 只改 **源文件**,禁止手改 `platform/public/locales/*/api_errors.json` 与 `client/src/locales/*/api_errors.gen.json`。键 `28001`–`28006` 与 T001 语义对齐(不可达 / 容量已满 / 超时且未完成产物不存在 / copy-in 超限已跳过并点名 / 代码节点出参无法序列化 / 执行环境响应不合契约)。三语同一 PR。跑 `pnpm --filter @bisheng/locales check`(或仓库约定的 locales 构建)确认生成物更新。 + **覆盖 AC**: AC-05, AC-16, AC-19, AC-27 + **依赖**: T001 + +- [x] **T003**: `sandbox_conf` 缺省 / env / 无编排枚举 单测 + **文件**: `src/backend/test/sandbox/test_sandbox_conf.py` + **逻辑**: 构造 `Settings`:① YAML **无** `sandbox_conf` 段仍能实例化。默认 `endpoints=[]`(没有副本),模型上**没有** `discover_*` 字段;`pool_lease_ttl_s=900`、`max_sessions_per_replica=1`、`code_node_enabled=True`。② `BS_SANDBOX_CONF__DISCOVER_HOST_PATTERN` 不生效,读不到该属性。③ 模型 **没有** `deploy_mode` / `orchestrator` 字段(赋值应被拒绝或 extra ignore 后读不到)。④ `endpoints` 默认空列表;空列表领租约走 28001,本任务不测 HTTP。⑤ `max_sessions_per_replica>1` 时须有显式 uid 隔离开关,否则校验失败(决策 11/13)。 + **覆盖 AC**: AC-13, AC-21, AC-25 + **依赖**: 无 + +- [x] **T004**: `Settings.sandbox_conf` + 发布稿注释掉该段 + **文件**: `src/backend/bisheng/core/config/settings.py`, `docker/bisheng/config/config.yaml` + **逻辑**: 新增 `SandboxConf`(字段见 design §4.2,**不要**加 `deploy_mode`)。`Settings.sandbox_conf: SandboxConf = Field(default_factory=SandboxConf)`。发布 `config.yaml` **整段注释**(坑 9:旧镜像遇未知顶层 key 会起不来)。env 形如 `BS_SANDBOX_CONF__TOKEN`。`token` 不要写进代码常量。 + **设计依据**: design 决策 12 · §4.2 · §5 坑 9、18 + **跨 Feature 影响**: `Settings` 被全平台加载;只加可选段 + 默认值,不得改其它 Conf 的校验。 + **测试**: T003 全绿 + **覆盖 AC**: AC-21, AC-25 + **依赖**: T003 + +### Wave 2 — 上提 `run_with_dir`(决策 4) + +- [x] **T005**: `execute_code` / `run_with_dir` 契约单测 + **文件**: `src/backend/test/sandbox/test_execute_code_contract.py` + **逻辑**: 用可注入的 `execute_code` 假实现挂在 `BaseExecutor` 子类上。①成功:根级新文件被归位到 `output/`,`file_list` 只含本轮新增/修改,形状 `{exitcode, log, file_list}`。②失败非 0:`file_list` 为空且 stdout 仍在 `log`。③ `sync_to_workspace` 被调用且入参含归位后的 `output/` 路径(monkeypatch 假 backend,不断 MinIO)。④超限文件不在本测(属 copy-in)。本文件 **不** 起真实 runner。现有 `test/linsight/test_code_interpreter_*` 必须在 T006 后仍绿(本任务列出作为回归名单即可)。 + **覆盖 AC**: AC-03, AC-07, AC-08, AC-09 + **依赖**: 无 + +- [x] **T006**: `run_with_dir` 上提到 `BaseExecutor`,本地只留 `execute_code` + **文件**: `src/backend/bisheng_langchain/gpts/tools/code_interpreter/base_executor.py`, `src/backend/bisheng_langchain/gpts/tools/code_interpreter/local_executor.py` + **逻辑**: `BaseExecutor.execute_code` 改为抽象;把 `LocalExecutor.run_with_dir` 的快照 diff / 根级归位 / `upload_minio` / `sync_to_workspace` / `sync_files_to_local` 上提为共享实现。`LocalExecutor.execute_code` 仍是本机 `subprocess`(现有实现可原样上提为实例方法)。`run()` 出口形状不变。禁止在本任务引入 HTTP。 + **设计依据**: design 决策 4 · §4.3 + **跨 Feature 影响**: 本地 / E2B 调用链;E2B **本任务不改** `e2b_executor.py`(冻结)。 + **测试**: T005 全绿;既有 `test_code_interpreter_*` 全绿 + **覆盖 AC**: AC-03, AC-07, AC-08, AC-09 + **依赖**: T005 + +### Wave 3 — runner HTTP(独立包,不 import `bisheng`) + +- [x] **T007**: 租约协议单测(ASGI + httpx) + **文件**: `src/backend/test/sandbox/conftest.py`, `src/backend/test/sandbox/test_runner_leases.py` + **逻辑**: `conftest.py` 把仓库 `src/sandbox-runner` 加入 `sys.path`(backend pytest 默认收集不到该独立包)。对 runner 应用(可先对将在 T008 落地的模块做 import,红灯允许)。全局 `token` 才能 `POST /v1/sessions`,成功返回 `session_id` + `lease_token` + `lease_expires_at`。槽满(默认 1)→ 503。错 token → 401。用 A 的 `lease_token` `DELETE` B → 403。`DELETE` 后同 id 再 exec → 404。idle 超过 TTL 视同 DELETE。路径字符串不得包含 `config`。 + **覆盖 AC**: AC-17, AC-27 + **依赖**: T004 + +- [x] **T008**: runner 租约:`POST /v1/sessions` + `DELETE` + idle 扫描 + **文件**: `src/sandbox-runner/app.py`, `src/sandbox-runner/leases.py` + **逻辑**: **禁止** `import bisheng`。Starlette/FastAPI 迷你应用监听 8080。内存租约表;默认 `max_sessions_per_replica=1`;工作目录 `/tmp/sessions//`。`GET /health` 200。idle 扫 `pool_lease_ttl_s`。子进程 env **不得**出现全局 token / 任何 lease_token。 + **设计依据**: design 决策 2、11 · §4.5 + **测试**: T007 全绿 + **覆盖 AC**: AC-15, AC-17, AC-27 + **依赖**: T007 + +- [x] **T009**: tar copy-in/out + zip-slip 单测 + **文件**: `src/backend/test/sandbox/test_runner_files.py` + **逻辑**: `PUT /v1/sessions/{id}/files` 要该会话 `lease_token`。成员含 `../` 或绝对路径 → 4xx,工作目录外无新文件。md5 命中的路径不覆盖。`GET` 只返回相对执行前快照新增/修改,且 **递归**(`output/sub/a.txt` 必须出现)。超 `max_copy_in_bytes` 的单个成员跳过并在响应/日志点名,其它文件仍写入。 + **覆盖 AC**: AC-07, AC-10 + **依赖**: T008 + +- [x] **T010**: runner `PUT/GET .../files`(流式 tar.gz + md5 清单) + **文件**: `src/sandbox-runner/files.py`(在 `app.py` 注册路由,不新开第三文件以外的包) + **逻辑**: 解包前校验每个成员落在该 UUID 目录内。清单与 tar 二选一旁路(实现选定后写进 design 一行,tasks 不改协议)。不要按文件拆多次 HTTP。copy-in upsert;worker 侧删除本期不从 runner 删。 + **设计依据**: design 决策 3 · §4.2 · §5 坑 12、15 + **测试**: T009 全绿 + **覆盖 AC**: AC-10, AC-17 + **依赖**: T008, T009 + +- [x] **T011**: exec 超时 / env 白名单 / OOM 回收 单测 + **文件**: `src/backend/test/sandbox/test_runner_exec.py` + **逻辑**: `POST .../exec` `{code, lang, timeout_s}` → `{exitcode, stdout, stderr, duration_ms}`。超时:进程组被杀,无半成品 copy-out。子进程 env 无 `MINIO`/`MYSQL`/`token`/`lease_token`。模拟 137:租约被 DELETE(半残目录不可复用)。同一 session 两次 exec 串行。`HOME`/`TMPDIR` 指向该 UUID。断言 runner 对子进程设置了内存 / nproc / CPU 的 `setrlimit`(可用 monkeypatch 捕获参数;compose 的 `mem_limit` 在 T026 测)。 + **覆盖 AC**: AC-14, AC-15, AC-16, AC-17 + **依赖**: T010 + +- [x] **T012**: runner `POST .../exec` + `killpg` + 异常回收 + **文件**: `src/sandbox-runner/execute.py`(注册到 `app.py`) + **逻辑**: `start_new_session=True`、超时 `killpg`、`setrlimit`。默认 uid 65534;`max_sessions_per_replica>1` 时走决策 13(独立 uid + 目录 0700 + 父目录 0711 + `/tmp` 0755),否则启动拒绝。可观测:单次 `duration_ms`、exitcode(137 单列)。 + **设计依据**: design 决策 8、11、13 · §4.5 · §5 坑 3、5 + **测试**: T011 全绿 + **覆盖 AC**: AC-13, AC-14, AC-16, AC-17, AC-26 + **依赖**: T011 + +### Wave 4 — `ContainerExecutor` + +- [x] **T013**: `FakeRunnerClient` + `ContainerExecutor` 单测 + **文件**: `src/backend/test/sandbox/test_container_executor.py`, `src/backend/test/sandbox/fake_runner.py` + **逻辑**: Fake **必须尊重路径参数**(坑 12)。① `endpoints` 非空:shuffle 后 `POST /v1/sessions`,503 换下一台,全 503 → 抛 `SandboxCapacityExceededError`(28002),与 28001/超时/协议错误在断言上可区分。② 一台都不可达或列表为空 → 28001。③ 成功路径出口 `{exitcode, log, file_list}` 与本地模式字段名一致。④ copy-in 在 `run()` 时刻扫描 `dir_path`(含 `skills/`);超限点名且不静默;记录 copy-in 字节数。⑤ `keep_session=True` 第二次 exec 打同一 URL,不再挑选。⑥ 协议乱响应 → 28006,**不得**调用 `LocalExecutor`。⑦ 超时映射 28003。⑧ `description` 含 design §4.4 库清单中的 `pandas` / `openpyxl` / `python-pptx` / `pymupdf`(与镜像声明对齐,二进制是否真装在 T025)。本任务不写实现文件。 + **覆盖 AC**: AC-01, AC-03, AC-04, AC-05, AC-06, AC-10, AC-11, AC-26, AC-27 + **依赖**: T001, T006, T012 + +- [x] **T014**: 实现 `ContainerExecutor` + **文件**: `src/backend/bisheng_langchain/gpts/tools/code_interpreter/container_executor.py` + **逻辑**: 只实现 `execute_code` / `close`(领租约、tar、exec、copy-out、finally DELETE 除非 `keep_session`)。产物收割走 T006 共享 `run_with_dir`。`description` 与本地模式库清单对齐且 `path_namespace_rules(include_skills=True)`。错误映射 T001 的 28001–28004、28006。记录 copy-in 字节数。禁止 import docker / kubernetes。副本 URL **只**来自已注入的 `endpoints`,不实现主机名扫描。 + **设计依据**: design 决策 4、5、9、10 · §4.1–§4.2 · §5 坑 1、4、17 + **测试**: T013 全绿 + **覆盖 AC**: AC-01, AC-03, AC-04, AC-05, AC-06, AC-11, AC-26 + **依赖**: T001, T013 + +### Wave 5 — 主机名扫描(已删除,决策 12 只认 endpoints) + +- [x] **T015**: 原 `discover()` 单测,2026-10-21 删除 + **文件**: 原 `src/backend/test/sandbox/test_discover.py` 已删除。替代断言在 `test/sandbox/test_sandbox_conf.py`:模型没有 `discover_*`,`BS_SANDBOX_CONF__DISCOVER_HOST_PATTERN` 不生效。 + **逻辑**: 不再测主机名扫描。`endpoints` 是唯一副本列表。 + **覆盖 AC**: AC-01, AC-05 + **依赖**: T004 + +- [x] **T016**: 原 `discover.py`,2026-10-21 删除 + **文件**: `discover.py` 已删除。`container_executor.py` 的副本 URL 只来自 `endpoints`。 + **逻辑**: 不替换 `{n}`、不解析主机名模式、不缓存扫描结果。空列表领租约即不可达。 + **设计依据**: design 决策 9、12 · §4.5–§4.6 · §5 坑 18 + **测试**: T003 断言没有 `discover_*`;T013 在注入 `endpoints` 时仍绿 + **覆盖 AC**: AC-01, AC-05 + **依赖**: T014, T015 + +### Wave 6 — dispatch 与灵思装配 + +- [x] **T017**: `load_tools` 三分支 + 未知 type 单测 + **文件**: `src/backend/test/sandbox/test_load_tools_dispatch.py` + **逻辑**: `type=local` → `LocalExecutor`;`container` → `ContainerExecutor`;`e2b` → `E2bCodeExecutor`。未知 `type` **报错**,不得落入 e2b。`config.e2b.type`(private/official)经 `kwargs.update` 不得变成执行器构造参数(坑 7:断言构造 kwargs 无该键)。缺 `type` 时默认 **local**(AC-23:存量 extra 为空的行升级后不得变 container)。另用源码级断言(读文件或 AST,不断真实会话)确认五条路径仍只经 `ToolExecutor.init_by_tool_id(s)`,没有直接 `LocalExecutor(`:`workbench_impl.py`、`chat_service.py`、`assistant_agent.py`、`workflow/nodes/agent/agent.py`、`workflow/nodes/tool/tool.py`。 + **覆盖 AC**: AC-01, AC-02, AC-22, AC-23 + **依赖**: T014 + +- [x] **T018**: `load_tools._get_native_code_interpreter` 显式三分支 + **文件**: `src/backend/bisheng_langchain/gpts/load_tools.py` + **逻辑**: 去掉 `else: E2bCodeExecutor`。`container` 读 `config.container`(timeout/profile),池参数 **不** 从 extra 读。切换不需要重启(无模块级全局单例绑死 type)。 + **设计依据**: design 决策 10 · §4.7 · §5 坑 7 + **跨 Feature 影响**: 所有走 `bisheng_code_interpreter` 的路径共用此函数;未知 type 从静默 e2b 变为显式失败。 + **测试**: T017 全绿 + **覆盖 AC**: AC-01, AC-02, AC-22, AC-23 + **依赖**: T017 + +- [x] **T019**: 灵思 container 绑定单测(不预扫 `file_list`) + **文件**: `src/backend/test/linsight/test_init_config_tools.py`(增量,不改 E2B 5MB 用例的既有断言) + **逻辑**: 当工具 `type=container`:`config.container` 含工作区路径 / `keep_session` 语义;**没有**为 container 生成 E2B `file_list`。`path` 规则含 `skills/`。`type=e2b` 的 oversized 过滤用例保持原样(冻结)。 + **覆盖 AC**: AC-06, AC-09, AC-11 + **依赖**: T018 + +- [x] **T020**: `_init_bisheng_code_tool` 增加 `config.container`,容器模式不预扫 + **文件**: `src/backend/bisheng/linsight/domain/services/workbench_impl.py` + **逻辑**: `keep_session` / `local_sync_path` / `workspace_prefix` 与本地模式对齐。container **不要** `os.walk` 填 `file_list`(copy-in 在 `run()`,AC-11)。E2B 分支原样保留。 + **设计依据**: design §4.1 路径 1 · §5 坑 4 + **跨 Feature 影响**: 仅灵思工具装配;日常/助手不走此函数。 + **测试**: T019 全绿 + **覆盖 AC**: AC-06, AC-09, AC-11 + **依赖**: T019 + +### Wave 7 — 工作流代码节点(档 B) + +- [x] **T021**: wrapper / `ast.parse` / 28005 / 开关 单测 + **文件**: `src/backend/test/sandbox/test_sandbox_code_parser.py` + **逻辑**: ①语法错误在 `parse_code()` 阶段抛出,不调 runner。②合法 `main` 经 wrapper 跑 Fake `execute_code`,出参 dict 字段与改造前一致。③返回不可 JSON 序列化 → 28005,不是空 dict。④ `code_node_enabled=False` → 走改造前 `exec_method` 进程内 exec(可用 monkeypatch 断言未 HTTP)。 + **覆盖 AC**: AC-18, AC-19, AC-20, AC-21 + **依赖**: T006, T001 + +- [x] **T022**: `SandboxCodeParser`:本地 `ast.parse` + wrapper 调同一 `execute_code` + **文件**: `src/backend/bisheng/workflow/nodes/code/code_parse.py`, `src/backend/bisheng/workflow/nodes/code/code.py` + **逻辑**: 执行侧不认识 `main`。wrapper:`json.loads` 入参 → 调 `main` → 哨兵行 `json.dumps`。`CodeNode._run` 改调新解析器;`code_node_enabled=False` 回退旧 `exec`。失败禁止回落(开关关闭是唯一回退)。 + **设计依据**: design 决策 8 · §4.1 档 B · §5 坑 8 + **跨 Feature 影响**: 存量工作流出参仍是 dict;不可序列化从静默空值改为 28005。 + **测试**: T021 全绿 + **覆盖 AC**: AC-18, AC-19, AC-20, AC-21 + **依赖**: T021, T014 + +### Wave 8 — 前端 Platform(工具配置弹窗) + +- [x] **T023**: 隔离执行方式三语文案 + **文件**: `src/frontend/platform/public/locales/{zh-Hans,en-US,ja}/tool.json` + **逻辑**: 新增 `executionContainerLabel`(及必要 hint:接入参数在系统配置,弹窗不填 endpoint)。禁止硬编码中文。不要借用「副本」「租约」等内部词。三语同一任务。 + **覆盖 AC**: AC-01, AC-22 + **依赖**: 无 + +- [x] **T024**: `CodeExecutor` 增加 `container` 选项 + **文件**: `src/frontend/platform/src/pages/BuildPage/tools/builtInTool/CodeExecutor.tsx` + **逻辑**: `RadioGroup` 增加 `value="container"`(非空字符串)。选 container **不**展示 E2B domain/key。submit `type: "container"`,可带 `config.container: { timeout }`,**禁止**把 runner token/URL 写入 extra。保留 local/e2b 原校验。无新 API、不 `import axios`。 + **设计依据**: design §4.2 工具配置 · AC-22 + **覆盖 AC**: AC-01, AC-22 + **手动验证**: + - 打开管理端构建 → 工具 → 代码执行器,看到三种执行方式 + - 选隔离执行并保存,再打开仍是 container,且 extra 无 endpoint + - 存量 e2b 配置打开仍显示 e2b + **依赖**: T023, T018 + +### Wave 9 — 镜像与 compose(本期交付) + +- [x] **T025**: `dataelement/bisheng-sandbox` Dockerfile + **文件**: `docker/bisheng-sandbox/Dockerfile`(从 `src/backend/base.Dockerfile` 派生,COPY `src/sandbox-runner`) + **逻辑**: 全量 venv + 完整 LibreOffice/pandoc/fonts-wqy-zenhei + ffmpeg + runner 为 PID 1。不拷 `config.yaml` / `entrypoint.sh` / 平台业务代码。不装 Chromium 浏览器二进制。多架构同一 Dockerfile。 + **设计依据**: design 决策 7 · §4.4 + **覆盖 AC**: AC-04, AC-12, AC-15, AC-28 + **测试降级**: 镜像构建与 office 二进制需 CI/本地 docker,单测无法替代;加固/网络在 T026 手动 + inspect + **依赖**: T012 + +- [x] **T026**: compose:具名 `code-runner-1/2` + `sandbox_net` + worker 双挂 + **文件**: `docker/docker-compose.yml` + **逻辑**: **不用** `deploy.replicas`。`sandbox_net.internal: true`。runner 只挂该网、**无** `ports:`。`backend` 与 `backend_worker` 挂 `default` + `sandbox_net`,并设置 `BS_SANDBOX_CONF__ENDPOINTS` 为 `http://code-runner-1:8080` 与 `http://code-runner-2:8080`。不写 `DISCOVER_*`。加固:`cap_drop: ALL`、`read_only: true`、`mem_limit`(≥2g,坑 5)、`pids_limit`、`security_opt: no-new-privileges`、`tmpfs` `/tmp` `mode=755`、`user: "65534"`。平台容器上 `config.yaml` / `entrypoint.sh` 改为 `:ro`(AC-28);runner **不挂**这两文件。只增量服务与网络,不改 mysql/redis 口令。加第三台 runner 时把其 URL 追加进这份列表。 + **设计依据**: design 决策 2、12 · §4.6 · §5 坑 5、6、13 + **跨 Feature 影响**: compose 是全平台启动面;runner 失败不得让 mysql healthcheck 挂掉(runner 不要写进中间件 `depends_on` 链的关键路径,或允许 worker 在 28001 下降级可见而非卡死启动)。 + **覆盖 AC**: AC-12, AC-13, AC-14, AC-28 + **测试降级**: 需 docker compose;用下面手动验证代替 CI 中间件 + **手动验证**: + - `docker compose up -d` 后 `getent hosts code-runner-1` 在 worker 容器内可解析 + - `docker inspect` 断言 CapDrop / ReadonlyRootfs / Memory / PidsLimit + - worker 内 `curl -sS http://code-runner-1:8080/health` 200;runner 内连 `mysql:3306` 失败 + - `docker compose exec code-runner-1 env` 无 minio/mysql/hmac + **依赖**: T025, T016 + +### Wave 10 — 新装默认、可观测、收口 + +- [x] **T027**: 新装默认 / 升级不改存量 单测 + **文件**: `src/backend/test/sandbox/test_code_interpreter_seed_default.py` + **逻辑**: 解析 `t_gpts_tools.json` 中 `tool_key=bisheng_code_interpreter` 的 `extra.type == container`(红灯直到 T029)。读 `init_data.py`(或它调用的预设工具函数)断言对已存在的 `gpts_tools` **没有** `UPDATE extra` / 按 id upsert extra。本任务不改 JSON、不改 `init_data.py`。 + **覆盖 AC**: AC-23, AC-24 + **依赖**: T018 + +- [x] **T029**: 种子 JSON 写入 `type=container` + **文件**: `src/backend/bisheng/database/data/t_gpts_tools.json` + **逻辑**: 仅 `tool_key=bisheng_code_interpreter` 那条 `extra` 增加 `"type": "container"`(AC-24)。**禁止**改 `init_data.py`、禁止给其它内置工具改 extra。 + **设计依据**: design §4.7 · AC-23 / AC-24 + **跨 Feature 影响**: `t_gpts_tools.json` 是全平台内置工具种子;只动代码执行器这一行,其它 `tool_key` 字节级不变。 + **测试**: T027 全绿 + **覆盖 AC**: AC-23, AC-24 + **依赖**: T027 + +- [x] **T028**: 回归 + 端到端手动验证 + 落档 + **文件**: 本文件「实际偏差记录」段 + **逻辑**: ①重跑 `test/sandbox/`、`test/linsight/test_code_interpreter_*`、`test/linsight/test_init_config_tools.py`、T021。②确认 `e2b_executor.py` 无功能改动(git diff)。③按 design §7:管理端切 container → 日常勾选工具跑一段写 `output/` 的脚本;灵思任务模式技能包可见;代码节点存量 `main` 出参仍进下游。④实现期改变系统认知的偏差回写 design,此处只留一行指针。 + **覆盖 AC**: AC-02, AC-04, AC-12, AC-18, AC-22, AC-26 + **手动验证**: + - 日常:隔离模式生成含中文的 `output/report.docx`,结果区可下载;附件原件仍不进沙箱 + - 灵思:`skills/` 下脚本可 `open`;sticky 第二轮能读到上一轮 `output/` + - 代码节点:语法错误仍在搭建/初始化报;运行期不可序列化见 28005 + - 停掉全部 runner → 工具失败为 28001,worker 进程内无用户代码 `exec` + **依赖**: T020, T022, T024, T026, T029 + +--- + +## AC 覆盖对照 + +| AC | 覆盖任务 | +|----|----------| +| AC-01 | T013, T014, T015, T016, T017, T018, T023, T024 | +| AC-02 | T017, T018, T028 | +| AC-03 | T005, T006, T013, T014 | +| AC-04 | T013, T014, T025, T028 | +| AC-05 | T002, T013, T014, T015, T016 | +| AC-06 | T013, T014, T019, T020 | +| AC-07 | T005, T006, T009 | +| AC-08 | T005, T006 | +| AC-09 | T005, T006, T019, T020 | +| AC-10 | T009, T010, T013 | +| AC-11 | T013, T014, T019, T020 | +| AC-12 | T025, T026, T028 | +| AC-13 | T003, T012, T026 | +| AC-14 | T011, T012, T026 | +| AC-15 | T008, T011, T012, T025 | +| AC-16 | T002, T011, T012 | +| AC-17 | T007, T008, T010, T011, T012 | +| AC-18 | T021, T022, T028 | +| AC-19 | T002, T021, T022 | +| AC-20 | T021, T022 | +| AC-21 | T003, T004, T021, T022 | +| AC-22 | T017, T018, T023, T024, T028 | +| AC-23 | T017, T018, T027, T029 | +| AC-24 | T027, T029 | +| AC-25 | T003, T004 | +| AC-26 | T012, T013, T014, T028 | +| AC-27 | T002, T007, T008, T013 | +| AC-28 | T025, T026 | + +--- + +## 实际偏差记录 + +> **只留一行指针**,论证写进 design.md(决策 / 坑),这里不重复。 +> 推翻已 ★ 确认的决策时,先停下与用户重新确认,再记录。 + +- 2026-09-20 实现收口:`execute_code` 非 `@abstractmethod`、镜像 `--no-install-project`、runner `SANDBOX_TOKEN`。见 design 修订历史 2026-09-20。T028 业务 E2E(日常 docx / 灵思 sticky / 代码节点 28005 / 停 runner→28001)待 compose 实跑。 +- 2026-10-21 去掉主机名发现:删除 `discover.py` 与 `test_discover.py`。副本只认 `endpoints`。见 design 修订历史 2026-10-21。 diff --git "a/features/v3.0.0-beta1/068-code-execution-sandbox/\344\273\243\347\240\201\346\211\247\350\241\214\345\231\250\350\216\267\345\217\226\347\237\245\350\257\206\345\272\223\345\233\276\347\211\207\346\226\271\346\241\210.md" "b/features/v3.0.0-beta1/068-code-execution-sandbox/\344\273\243\347\240\201\346\211\247\350\241\214\345\231\250\350\216\267\345\217\226\347\237\245\350\257\206\345\272\223\345\233\276\347\211\207\346\226\271\346\241\210.md" new file mode 100644 index 0000000000..c8491564ef --- /dev/null +++ "b/features/v3.0.0-beta1/068-code-execution-sandbox/\344\273\243\347\240\201\346\211\247\350\241\214\345\231\250\350\216\267\345\217\226\347\237\245\350\257\206\345\272\223\345\233\276\347\211\207\346\226\271\346\241\210.md" @@ -0,0 +1,210 @@ +# 代码执行器获取知识库图片方案 + +状态:方案,尚未实现。 + +目标:日常对话里,用户让模型把知识库中的图片做成 PPT 或其它文件时,本机执行器(`local`)和隔离执行器(`container`)都能读到这些图片。 + +## 现象与根因 + +2026-10-12 在 `192.168.106.107` 上做「找到所有图片并合并到 PPT」,先后出现下面几种失败。 + +| 现象 | 日志位置 | 根因 | 影响范围 | +|---|---|---|---| +| `FileNotFoundError: '/bisheng/knowledge/images/1/27/image1.png'` | trace `89269c75`,`add_picture` | 脚本把检索结果里的路径当成磁盘文件,执行目录是空的临时目录 | 本机、隔离 | +| `Failed to resolve 'bisheng.cloud'` | trace `7ffbba48`,`requests.get` | 模型不知道图片在哪,自己编了一个主机名;执行进程解析不了 | 本机、隔离 | +| `FileNotFoundError: 'output/combined_images.pptx'` | trace `f5cd4daa` | 隔离模式的执行目录里没有 `output/`;`ContainerExecutor.execute_code` 没有像本机那样建目录,copy-in 又只带普通文件,空目录不会过去 | 隔离 | + +图片本身是存在的:同一路径经 `http://192.168.106.107:3001` 访问返回 200。页面能显示图片,是因为浏览器经前端转发到了 MinIO。代码执行器没有这条通路,而且也不应该有。 + +## 设计原则 + +1. 执行进程不访问 MinIO,不开外网。取图在后端进程里完成。 +2. 只放行本轮检索结果里真实出现过的图片。模型自己写的路径不会触发下载,权限就是用户检索时已有的权限。 +3. 不改写 `open`、`requests`,不做代理。让模型拿到一个真实存在的相对路径。 +4. 取不到的图要如实告诉模型,不静默跳过。 +5. 不改最终答案里的 Markdown 图片格式。 + +## 方案 + +### 1. 登记检索到的图片 + +新增请求级对象 `KbImageStage`,与现有 `ImageRegistry` 并列,登记入口是 `chat_service.py` 的 `_annotate_retrieved_chunk`。两个对象各管一件事: + +| 对象 | 服务对象 | 何时有值 | +|---|---|---| +| `ImageRegistry` | `view_image`,让视觉模型读像素 | 模型开启视觉能力 | +| `KbImageStage` | 代码执行器,把图片放进工作目录 | 本次请求选了 `bisheng_code_interpreter` | + +两者互不依赖,不能共用 `ImageRegistry` 的开关:今天 `image_registry=image_registry if visual_enabled else None`,关闭视觉时 `_annotate_retrieved_chunk` 直接返回原文,什么都不登记。 + +只登记路径符合下面形式的图片: + +```text +/{public_bucket}/knowledge/images/{知识库id}/{文件id}/{文件名} +``` + +查询串去掉。其它路径、其它主机一律不登记。同一路径重复出现只登记一次。 + +#### 对 `_annotate_retrieved_chunk` 的修改 + +当前实现(`chat_service.py`): + +```python +def _annotate_retrieved_chunk(chunk: str, image_registry: ImageRegistry | None) -> str: + if image_registry is None: + return chunk + return annotate(chunk, image_registry) +``` + +修改后: + +```python +def _annotate_retrieved_chunk( + chunk: str, + image_registry: ImageRegistry | None, + kb_image_stage: KbImageStage | None = None, +) -> str: + # The stage serves the code interpreter and image_registry serves view_image. + # They are independent, so the stage must run before the image_registry early return. + if kb_image_stage is not None: + chunk = kb_image_stage.annotate(chunk) + if image_registry is None: + return chunk + return annotate(chunk, image_registry) +``` + +`KbImageStage.annotate(chunk)` 扫描片段里的 `![alt](url)`,对符合上面形式的 `url` 登记并在该图后面追加 `⟦file: uploads/kb/…⟧`,其它图片原样保留。两个锚点同时存在时,`⟦img#N⟧` 紧跟在图片后面,`⟦file: …⟧` 在其后,互不干扰。 + +#### 需要同步改的调用链 + +`_annotate_retrieved_chunk` 有两个调用点,都要把 stage 传进去: + +| 调用点 | 当前 | 修改 | +|---|---|---| +| `DailyChatCitationToolWrapper._dump_knowledge_chunks` | 传 `self.image_registry` | 新增字段 `kb_image_stage`,一并传入;`wrap`、`_wrap_daily_chat_citation_tool` 增加同名参数 | +| `_build_knowledge_search_tool` 内 `results = [_annotate_retrieved_chunk(chunk, image_registry) for chunk in chunks]` | 传闭包里的 `image_registry` | 增加参数 `kb_image_stage` 并传入 | + +`_prepare_tools` 增加参数 `kb_image_stage`,并转交给上述两处。 + +stage 在 `_agent_event_stream_impl` 里、`image_registry = ImageRegistry()` 附近创建,调用 `_prepare_tools` 时传入。只有本次请求的 `tool_payloads` 里包含 `tool_key == "bisheng_code_interpreter"` 才传,否则传 `None`:没选代码执行器时,检索结果不多出任何锚点,也不增加 token。 + +#### 挂载到执行器 + +`_prepare_tools` 初始化到 `bisheng_code_interpreter` 之后,把同一个 stage 挂到工具的执行器上。stage 在检索时才被填充,挂载顺序与知识库工具的创建顺序无关。 + +实现时要确认两点: + +- `ToolExecutor.init_by_tool_id` 返回的对象仍能取到 `.executor`。取不到就不挂载并记一条 warning,行为退回到今天的样子。 +- `E2bCodeExecutor` 没有 `attach_stage`。`BaseExecutor` 提供一个空实现,E2B 调用后什么也不做。 + +#### 范围限制 + +stage 只在本轮请求内有效。用户下一轮说「把刚才那几张图做成 PPT」时,如果历史里没有带回上一轮的检索结果,模型需要重新检索,stage 才会再次登记。历史是否带回工具结果,实现时再确认;本方案只保证本轮检索到的图片。 + +### 2. 在检索结果里告诉模型路径 + +在每张图后面追加一个锚点,原 Markdown 不动: + +```text +![image1.png](/bisheng/knowledge/images/1/27/image1.png)⟦file: uploads/kb/1/27/image1.png⟧ +``` + +最终答案仍然保留原始 `![](...)`,页面渲染和系统提示词里「保留原始 Markdown 图片格式」的规则都不受影响。 + +代码执行器的工具说明增加一句:检索结果中 `⟦file: …⟧` 给出的路径可以直接 `open` 或传给 `add_picture`;不要访问 URL,不要自己写主机名。 + +### 3. 执行前把图片写进工作目录 + +`_prepare_tools` 里,初始化 `bisheng_code_interpreter` 之后,把 `KbImageStage` 挂到执行器上。 + +`LocalExecutor.run` 在调用 `run_with_dir` 之前,把已登记的图片写到: + +```text +{dir_path}/uploads/kb/{知识库id}/{文件id}/{文件名} +``` + +取图使用执行器已有的 `self.minio` 配置建 MinIO 客户端(`BaseExecutor._minio_client`),也就是后端进程用的同一份配置。 + +- 本机:子进程的工作目录里直接有这些文件。 +- 隔离:`ContainerExecutor` 继承同一个 `run`,现有 `_copy_in` 会把 `uploads/` 带进沙箱。runner 不需要 MinIO 地址、密钥或外网。 +- 先落盘再快照:这些文件在运行前就存在,不会被当成产物上传,也不会出现在 `file_list` 里。 + +### 4. 限制与反馈 + +上限都做成可配置项。 + +| 限制 | 作用 | 默认值 | +|---|---|---| +| 单次运行最多写入的图片张数 | 限制后端逐张读 MinIO 的次数和隔离模式 copy-in 的包大小 | 100 | +| 单次运行写入的总字节数 | 同上,按字节兜底 | 5 × `max_copy_in_bytes`,当前 `max_copy_in_bytes` 默认 50MB,即 250MB | +| 单张图片大小 | 超过则跳过 | `max_copy_in_bytes`,当前默认 50MB | + +- 总字节数的默认值是按 `max_copy_in_bytes` 推出来的,不单独写死 250MB。`max_copy_in_bytes` 改了,总上限随之变化;显式配置了总字节数则以显式值为准。 +- `max_copy_in_bytes`(默认 50MB)本身是隔离模式 copy-in 的**单文件**上限,不是总量。这里只是借它的数值做基准,两者含义不同。 +- `max_copy_in_bytes` 在配置里属于隔离执行的配置。本机模式也用同一个基准值,所以读取它的位置不能依赖 `ContainerExecutor` 的实例属性,要从配置读。 +- 内存风险:隔离模式 `_copy_in` 会把所有待发送文件 `fh.read()` 进内存,再打成 tar.gz。总量按 250MB 算,一次运行可能在后端进程里同时占用 250MB 以上。上线前要确认后端容器的内存余量,必要时把总字节数的默认值调低,或让 copy-in 改为流式,后者超出本方案范围。 +- `view_image` 每轮最多 3 张是为了控制视觉模型的上下文,和这里无关,不沿用。 +- 已知样本是文件 27 的 `image1.png`,约 333KB。按这个大小,100 张约 33MB,远低于总字节上限;实际由张数先触顶。 +- 配置放在现有配置体系里,不新增 YAML 顶层 key,具体位置实现时按 `src/backend/AGENTS.md` 的配置约定确定。 +- 超限、对象不存在、读取失败,都跳过该图,并在返回给模型的 `log` 里列出哪几张没取到及原因。超限时要写明是哪一条限制,以及当前上限。 +- 写盘时只用路径中的数字 id 和文件名拼目标路径,文件名含 `/` 或 `..` 的直接拒绝。 + +### 5. 隔离模式补 `output/` 和 `scratch/` + +不要用 `output/.keep` 占位:`_walk_regular_files` 和 `_snapshot_files` 都会跳过以 `.` 开头的文件,占位文件过不去。 + +改在 runner 侧:`sandbox-runner/execute.py` 的 `handle_exec` 里,写 `_run.py` 之后、`_chown_tree` 之前,确保 `output/` 和 `scratch/` 存在。这样模型直接 `prs.save('output/x.pptx')` 也能成功,行为与本机一致。需要重新构建并发布沙箱镜像。 + +### 6. 失败时的提示 + +脚本文本里出现 `knowledge/images/`,且执行失败时,沿用 `absolute_path_advisory` 的做法,在日志末尾追加一句:请改用检索结果里 `⟦file: …⟧` 的相对路径。只提示,不拦截。 + +## 改动清单 + +| 文件 | 改动 | +|---|---| +| `src/backend/bisheng/workstation/domain/services/chat_service.py` | `_annotate_retrieved_chunk` 增加 `kb_image_stage` 参数,登记并追加锚点,且在 `image_registry` 提前返回之前执行;`DailyChatCitationToolWrapper`、`_wrap_daily_chat_citation_tool`、`_build_knowledge_search_tool`、`_prepare_tools` 逐层传递;`_agent_event_stream_impl` 创建 stage;`_prepare_tools` 挂载到代码执行器 | +| 新增 `src/backend/bisheng/workstation/domain/services/kb_image_stage.py` | 路径解析、登记、限额、文件名校验 | +| `src/backend/bisheng_langchain/gpts/tools/code_interpreter/base_executor.py` | `attach_stage`;落盘实现;失败提示 | +| `src/backend/bisheng_langchain/gpts/tools/code_interpreter/local_executor.py` | `run` 中执行前落盘;工具说明加一句 | +| `src/sandbox-runner/execute.py` | `handle_exec` 建 `output/`、`scratch/` | +| `test/workstation/`、`test/sandbox/` | 见下文 | + +`bisheng_langchain` 不 import 业务域模块,`KbImageStage` 通过属性注入,由 `chat_service` 在业务侧创建并挂载。 + +## 测试 + +- 路径解析:合法路径被登记;其它主机、其它桶、带 `..` 的路径不登记。 +- `_annotate_retrieved_chunk` 四种组合(可扩展 `test/workstation/test_daily_chat_image_view.py`): + - 视觉关、stage 开:追加 `⟦file: …⟧` 并登记,没有 `⟦img#N⟧`(这是今天会漏掉的情况)。 + - 视觉开、stage 开:两种锚点都在,`⟦img#N⟧` 在前。 + - 视觉开、stage 关:与现在完全一致。 + - 都关:返回原文。 +- 调用链:`DailyChatCitationToolWrapper` 和 `search_knowledge_bases` 两个入口都能登记。 +- 没选 `bisheng_code_interpreter` 时,检索结果里没有 `⟦file: …⟧`。 +- 越权:模型写的路径不会触发下载,只有登记过的对象会被读。 +- 落盘位置:文件出现在 `uploads/kb/...`,且不进 `file_list`。 +- 限额:分别超过张数、总字节数、单张大小时跳过,`log` 里点名是哪条限制;调整配置后上限随之变化。 +- 隔离:copy-in 的成员里包含 `uploads/kb/...`;runner 创建会话后 `output/` 存在。 +- 回归:未启用知识库、没有图片时,执行器行为与现在一致。 + +## 验证步骤 + +1. 在 107 上用文件 `BISHENG workflow存在远程代码执行漏洞.docx`(知识库 1,文件 27)提问:把知识库里的图片合并到 PPT。 +2. 本机模式、隔离模式各跑一遍。 +3. 日志里不再出现 `/bisheng/knowledge/images/...` 的 `FileNotFoundError` 和 `bisheng.cloud` 的解析失败。 +4. 下载的 PPT 里有 `image1.png` 对应的一页。 + +## 不做的事 + +- 不让本机子进程或沙箱直接访问 MinIO,也不给沙箱开外网。 +- 不改写或代理 `open`、`requests`。 +- 不动 E2B 执行器。 +- 不处理灵思、工作流 Agent、助手:它们的工作目录和检索链路不同。 +- 「代码有语法错误时整段被 `sh -c` 执行」(`sh: 1: from: not found`)是另一个问题,单独处理。 +- 视觉模型看图走 `view_image`,不经过代码执行器,本方案不改它。 + +## 待确认 + +- 锚点格式用 `⟦file: …⟧`,只出现在模型看到的检索结果里。如果更希望把相对路径写进 Markdown 的 alt 文本,需要先确认前端渲染不受影响。 +- 总字节数默认 250MB 的内存占用:见 §4 的内存风险,上线前确认后端容器内存。 diff --git "a/features/v3.0.0-beta1/068-code-execution-sandbox/\344\273\243\347\240\201\350\212\202\347\202\271\344\270\216\344\273\243\347\240\201\346\211\247\350\241\214\345\231\250\345\205\261\347\224\250\346\211\247\350\241\214\345\220\216\347\253\257.md" "b/features/v3.0.0-beta1/068-code-execution-sandbox/\344\273\243\347\240\201\350\212\202\347\202\271\344\270\216\344\273\243\347\240\201\346\211\247\350\241\214\345\231\250\345\205\261\347\224\250\346\211\247\350\241\214\345\220\216\347\253\257.md" new file mode 100644 index 0000000000..ff66410368 --- /dev/null +++ "b/features/v3.0.0-beta1/068-code-execution-sandbox/\344\273\243\347\240\201\350\212\202\347\202\271\344\270\216\344\273\243\347\240\201\346\211\247\350\241\214\345\231\250\345\205\261\347\224\250\346\211\247\350\241\214\345\220\216\347\253\257.md" @@ -0,0 +1,78 @@ +# 代码节点与代码执行器共用执行后端 + +状态:已按本方案实现。 + +代码节点和代码执行工具共用一个模块来选择并调用 `local`、`container`、`e2b`。两边要的结果不同,所以公共模块只负责「选后端、跑一段代码」。工具入口的收割、路径检查,以及代码节点的 `main` 返回值解析,都留在各自的调用方。 + +## 结论 + +可行。`local` 和 `container` 已经实现同一条执行缝: + +```text +execute_code(code, lang, work_dir, timeout) -> (exitcode, logs, stderr) +``` + +代码执行工具的 `run` 和代码节点的 `exec_method("main")` 都停在这条缝之上,不合成一个函数。`e2b` 目前只有自己的 `run`,工厂可以先把它登记给工具;代码节点要等它补上同一签名之后才能用。 + +## 两条入口 + +| | 代码执行工具 | 代码节点 | +|---|---|---| +| 入口 | `CodeInterpreterTool._run` → `executor.run` | `CodeNode._run` → `exec_method("main")` | +| 结果 | `{exitcode, log, file_list}` | `main` 返回的字典 | +| 共用点 | `execute_code` | 同一签名 | + +工具在 `run` 里抽代码块、做路径检查、注入字体、收割 `output/`、上传。代码节点在 `SandboxCodeParser` 里包一层脚本,从标准输出的标记里取出 JSON。这些都不进公共模块。 + +## 公共模块 + +放在 `src/backend/bisheng_langchain/gpts/tools/code_interpreter/`,与三个执行器放在一起。工作流节点已经从这里引用 `ContainerExecutor`。放进工作流包会让工具层反向依赖节点。 + +```python +def build_code_executor(kind: str, **kwargs) -> BaseExecutor: + """kind: local | container | e2b""" +``` + +构造规则从 `load_tools._get_native_code_interpreter` 搬过来: + +- `container` 丢掉工具配置里的副本池字段,副本地址和 token 读 `settings.sandbox_conf`。 +- `e2b` 丢掉配置里的 `type`。前端把 `config.e2b.type` 写成 `private` / `official`,那不是执行器参数;自建还是官方只看 `domain` 是否为空。 +- 未知 `kind` 直接报错,不改走另一种。 + +## 代码执行工具 + +`_get_native_code_interpreter` 只负责读工具上的种类,调用 `build_code_executor`,再包成 `CodeInterpreterTool`。`_run` 仍调用 `executor.run`。 + +- `local` / `container` 的 `run` 继续走共享的 `run_with_dir`,里面再调 `execute_code`。 +- `e2b` 继续走现有的 `run`,直到它也实现 `execute_code`。 + +灵思在读完配置之后,再绑 `local_sync_path`、`keep_session`、`workspace_prefix`。这些是会话工作区,不属于公共模块。 + +## 代码节点读哪一份配置 + +节点的后端和代码执行工具读同一份配置:内置工具「代码执行器」(`tool_key=bisheng_code_interpreter`)extra 里的 `type`。 + +`t_gpts_tools.type` 是分类 id(代码执行器是整数),不是 `local` / `container` / `e2b`。执行器种类在 extra 的 JSON 字段 `type` 里。管理端保存时写在分类表 `t_gpts_tools_type.extra`,并把工具行的 extra 置空。节点必须走和灵思相同的 `ToolExecutor.parse_preset_extra`:工具行 extra 非空就用工具行,否则用分类 extra。 + +在工作流所属租户的上下文里,用同步的 `GptsToolsDao.get_tool_by_tool_key("bisheng_code_interpreter")` 查询。每次 `exec_method` 读一次,不在进程里缓存。缺 `type` 时默认 `local`,和工具装载一致,避免空配置被当成 `container`。未知值直接报错。 + +`code.py` 不改调用方式。`workflow.code_node_enabled` 仍是总闸:关闭时节点在构造时拒绝,不会去查工具表。它打开之后,进程内 `CodeParser` 不再作为 `local` 的实现。 + +## 三种 type 在节点上怎么跑 + +节点仍然只调用 `execute_code`,不调用工具的 `run`。从 extra 里只取种类,以及 `e2b` 的 `api_key`、`domain`。灵思事后绑上的 `local_sync_path`、`keep_session`、`workspace_prefix` 不带过来,节点没有工作区。 + +| extra.type | 节点行为 | +|---|---| +| `local` | `LocalExecutor.execute_code`,每次新建临时目录。`work_dir=None` 会写到共享的 `extensions/` | +| `container` | `ContainerExecutor`,`keep_session=False`,`work_dir=None`,不把 worker 的 `/app` 拷进沙箱。副本地址和 token 仍来自 `sandbox_conf` | +| `e2b` | 密钥和 domain 用同一份 `config.e2b`。要先补上 `E2bCodeExecutor.execute_code`:只跑这段代码,把 stdout / stderr 收成 `(exitcode, logs, stderr)`,不做工作区拷入和产物收割。适配器还没有时,节点报错,不回落到 `local` 或进程内 `exec` | + +包装脚本、哨兵和从标准输出取 JSON 留在 `SandboxCodeParser.exec_method`。 + +## 不放进公共模块 + +- 工具的 `run`:抽代码、把以 `pip` / `python ` 开头的内容推断成 shell、删字体缓存、工作区逃逸检查、收割上传。 +- 代码节点的包装脚本和哨兵解析。 +- 进程内 `exec`。它不是 `local` / `container` / `e2b` 的第四种后端。 +- 灵思的会话目录、粘滞租约、工作区镜像。 diff --git "a/features/v3.0.0-beta1/068-code-execution-sandbox/\344\273\243\347\240\201\350\212\202\347\202\271\346\211\247\350\241\214\347\272\246\345\256\232\345\212\240\345\233\272.md" "b/features/v3.0.0-beta1/068-code-execution-sandbox/\344\273\243\347\240\201\350\212\202\347\202\271\346\211\247\350\241\214\347\272\246\345\256\232\345\212\240\345\233\272.md" new file mode 100644 index 0000000000..f9be051d73 --- /dev/null +++ "b/features/v3.0.0-beta1/068-code-execution-sandbox/\344\273\243\347\240\201\350\212\202\347\202\271\346\211\247\350\241\214\347\272\246\345\256\232\345\212\240\345\233\272.md" @@ -0,0 +1,185 @@ +# 代码节点执行约定加固 + +状态:已实现(`code_parse.py`、`errcode/sandbox.py`、`api_errors` 28007/28008、`test/sandbox/test_sandbox_code_parser.py`);真实 E2B 与容器环境的验证尚未执行。 + +参照 2.x 代码节点漏洞补丁(`docs/workflow/代码节点漏洞补丁说明.md`、`docs/workflow/code_parse.py`),挑出对 3.0 仍然成立的检查。3.0 的代码节点已经不在后端进程里执行用户代码,执行走代码执行器的 `execute_code`(见 [代码节点与代码执行器共用执行后端](./代码节点与代码执行器共用执行后端.md));隔离边界是容器 runner、E2B,或本机模式的 uid 隔离(见 [第一步](./LocalExecutor安全优化方案-第一步-uid隔离.md))。本文只补"执行约定"上的缺口,不改隔离方式。 + +## 目标 + +- 缺 `main` 在上线、调试或运行构建节点时报错,不等到进沙箱。保存草稿不检查。 +- 节点的返回值和用户的 `print` 走两条通道,上游数据里的任何字符串都不能让结果被误判。 +- 结果有大小上限,超限给出明确错误,不出现"截断后 JSON 解析失败"这种看不出原因的报错。 +- 失败信息只带末尾一段,不把整份 stderr 写进运行日志和页面。 + +超时不单设。代码节点继续用各执行器现有的超时:本机、容器默认 600 秒,E2B 用 SDK 默认值。`_invoke_execute` 不传 `timeout`。 + +**不解决**:用户代码能做什么(能导入哪些库、能不能联网、能不能读写文件)。这由执行器所在的隔离环境决定,本文不加静态白名单,理由见"不做"。 + +## 现状(已对照代码核实) + +| 位置 | 现状 | +|---|---| +| `SandboxCodeParser.parse_code`(`workflow/nodes/code/code_parse.py`) | 只做 `ast.parse`。没有 `main` 时,要到沙箱里执行包装脚本才报 `NameError` | +| `build_code_node_wrapper` | 用户源码原样拼在前面,后面追加 `_bisheng_*` 变量调用 `main`,结果以 `__BISHENG_CODE_NODE_RESULT__` 加 JSON 打到 stdout;序列化失败打 `__BISHENG_CODE_NODE_UNSERIALIZABLE__` | +| `_parse_sentinel` | stdout 里**任意位置**出现 `UNSERIALIZABLE` 就报 28005;结果取最后一个 `RESULT` 之后的那一行。用户 `print` 和结果混在同一个流里 | +| `SandboxCodeParser.exec_method` | 非零退出时 `RuntimeError(logs)`,`logs` 是执行器返回的整份 stderr | +| `LocalExecutor._execute_code` | 非零退出返回整份 stderr,不截断(只有超时分支用 `_tail` 截到 8000 字符);成功返回 stdout | +| `ContainerExecutor._logs_from` | 成功返回 stdout,失败返回 stderr(为空时退回 stdout);runner 端不截断 | +| `E2bCodeExecutor.execute_code` | 成功返回 stdout,失败返回 stderr 加错误文本 | +| `_invoke_execute` | 调 `execute_code` 不传 `timeout`。本机、容器默认 600 秒,E2B 不传就用 SDK 默认值 | + +两个具体问题: + +- **上游数据可以触发误判。** 例如 `main` 里 `print(上游节点输出)`,而上游内容恰好含 `__BISHENG_CODE_NODE_UNSERIALIZABLE__`,节点会报"输出不可序列化"。哨兵是固定字符串,写在源码里,谁都能拼出来。 +- **结果行必须落在第一步的输出窗口里。** 本机模式每个流保留开头 4 MiB、结尾 512 KiB,两者之和以内的内容会原样拼回。结果上限 4 MiB 再加 nonce 前缀,小于这个窗口,整行不会被截断。窗口装不下时中间被丢掉,`json.loads` 失败,报出来的会是 28005,与真实原因无关。 + +## 设计 + +### 1. 构建节点时检查 `main` + +`SandboxCodeParser.parse_code` 在 `ast.parse` 之后,检查模块顶层是否有 `def main`: + +- 有 `FunctionDef`,名字是 `main`:通过。 +- 只有 `async def main`:拒绝,提示 `main` 不能是 `async`。包装脚本同步调用它,得到的是协程对象,序列化必然失败。 +- 都没有:拒绝,提示需要一个顶层 `main` 函数。 + +检查写在 `CodeNode.__init__` 调用的 `parse_code` 里,不在保存草稿的接口上。真正构建节点的地方有三处:上线时 `update_flow_status` 会构建一次 `Workflow`;单节点调试 `run_once` 会构建这个节点;每次工作流运行都会构建图。保存草稿不经过这里。报错沿用现有方式:`CodeNode._parse_code` 把异常包成 `CodeNode {name} exec code error: …`,和语法错误一样在构建节点时失败,不会进沙箱。 + +只认模块顶层的 `def main`,这一点已确认,不再等现场数据扫描。`main = …`、`from x import main`、写在 `if` / `try` 里或类方法上的 `def main`,构建节点时都拒绝,提示需要一个顶层 `main` 函数。运行时本来就是执行完整个模块再按全局名字调用,这些写法今天能跑。 + +**已上线的工作流。** 编辑器里已经是这种写法的节点,升级后下一次运行就会在构建图时失败,不是等用户再次保存才被拒。上线、单节点调试同样会失败。发布说明里要写明:先把这些节点改成模块顶层的 `def main`,再升级。按默认模板写的 `def main(...)` 不受影响。 + +**不检查** `main` 的参数是否与节点输入对应:运行时 `main(**params)` 不匹配会得到明确的 `TypeError`,提前检查要处理默认值和 `**kwargs`,收益小。 + +### 2. 结果通道与日志分开 + +包装脚本改成下面的结构(示意): + +```python +import json as _j, linecache as _lc, os as _o, sys as _s, traceback as _tb, types as _t + +if "ipykernel" in _s.modules: + # E2B run_code: logs.stdout is this Python stream, not fd 1. + _out = _s.stdout + _s.stdout = _s.stderr +else: + # Script (local / container): the parent reads fd 1 through a pipe. + # dup is non-inheritable; utf-8 so the locale cannot break non-ASCII JSON + _out = _o.fdopen(_o.dup(1), "w", encoding="utf-8") + _o.dup2(2, 1) # print and child processes follow fd 1 + _s.stdout = _s.stderr + # The built-in hook reads source from disk, not linecache; skip this + # script's own frame when the error comes from user code. + _s.excepthook = lambda t, v, tb: _tb.print_exception(t, v, tb.tb_next or tb) + +_src = USER_SOURCE # same string constant passed to compile +_lines = _src.splitlines(True) +if _lines and not _lines[-1].endswith("\n"): + _lines[-1] += "\n" +# mtime is None: traceback calls checkcache first, and a real mtime would +# stat "", fail, and drop this entry before the line is read. +_lc.cache[""] = (len(_src), None, _lines, "") + +# A plain dict would set __module__ to "__main__" without putting the class +# on sys.modules["__main__"], and pickle would fail to find it. +_user = _t.ModuleType("__main__") +_s.modules["__main__"] = _user +exec(compile(_src, "", "exec"), _user.__dict__) +_ret = _user.__dict__["main"](**_j.loads(PARAMS_JSON)) +try: + _data = _j.dumps(_ret, ensure_ascii=False).encode("utf-8") +except (TypeError, ValueError): + _line = NONCE + ":unserializable\n" +else: + if len(_data) > LIMIT: + _line = NONCE + ":too_large:" + str(len(_data)) + "\n" + else: + _line = NONCE + ":ok:" + _data.decode("utf-8") + "\n" +_out.write(_line) +_out.flush() +``` + +要点: + +- **先改道,再执行用户代码。** 用 `"ipykernel" in sys.modules` 区分,结果都写回改道前留下的那个流对象。没有 ipykernel 时是本机和容器:它们用 `python 脚本` 起进程,父进程用管道接 1 号文件描述符。`os.fdopen(os.dup(1), "w", encoding="utf-8")` 留下这根管道,再 `dup2(2, 1)` 并把 `sys.stdout` 指到 stderr,用户的 `print` 和子进程的输出都进 stderr。有 ipykernel 时是 E2B:`sandbox.run_code` 在 Jupyter 内核里执行,`execution.logs.stdout` 收的是内核换成的 `sys.stdout`,不是 1 号文件描述符。先留下这个对象,再把 `sys.stdout` 指到 `sys.stderr`。`os.write(1, …)` 进的是内核进程自己的日志,`logs.stdout` 里没有结果行,节点会因没有合法结果行失败。内核里子进程继承的是真实文件描述符,输出本来就不会进 `logs.stdout`,所以这条路径不改 1 号描述符。两种路径都对留下的流对象 `write` 结果行并 `flush`。stdout 上只有这一行。脚本方式的结果流必须写明 `encoding="utf-8"`:结果是 `ensure_ascii=False` 的 JSON,常含中文;`local_isolation=off` 时子进程直接继承后端进程的环境变量,区域设置不是 UTF-8 时,不写明编码会在 `main` 跑完之后、写结果那一步抛 `UnicodeEncodeError`,报错看起来像用户代码出错。后端按 UTF-8 解码这根管道,两端一致。 +- **用户代码用 `compile(…, "", …)` 单独编译**,以字符串常量(`repr`)嵌进包装脚本,再 `exec` 进一个新建的模块。好处是 traceback 的行号就是用户编辑器里的行号,文件名是 ``,不暴露 R 或临时目录路径。包装脚本自己的名字留在原来的全局变量里,用户的顶层名字(包括 `_bisheng_*`)落在这个新模块上,两边不撞。`compile` 只把文件名和行号写进 code object,源码文本不保留。traceback 再靠 `linecache.getline` 取那一行,而 `""` 这种尖括号名字在 `linecache.updatecache` 里直接返回空列表,所以帧下面不会出现源码。登记必须写在包装脚本里、`exec` 之前(traceback 在子进程里格式化,登记在后端进程没有用):`linecache.cache[""] = (len(source), None, lines, "")`。`mtime` 用 `None`,否则打印前的 `checkcache` 会对这个名字做 `os.stat`,失败后把登记删掉,源码行仍然出不来。脚本方式下还要换掉 `sys.excepthook`:解释器自带的钩子是 C 实现,按文件名直接读磁盘、不查 `linecache`,未捕获异常照样不带源码行。换成 `traceback.print_exception` 之后登记才起作用;顺带跳过包装脚本自己那一帧,临时文件路径不会出现。用户代码之外的错误(例如参数名不匹配,报在包装脚本的调用行上)只有这一帧,原样打印。E2B 内核用 IPython 自己的格式化,本来就查 `linecache`,不换钩子。 +- **这个模块要放进 `sys.modules["__main__"]`。** `ModuleType("__main__")` 的 `__dict__` 自带 `__name__ == "__main__"`,用户定义的类和函数的 `__module__` 因此是 `"__main__"`。pickle(以及 `multiprocessing`、`joblib`、`pandas.to_pickle`)按 `sys.modules[类.__module__]` 找这个类,找不到就抛 `PicklingError`。只传 `{"__name__": "__main__"}` 这种普通字典时,`__module__` 写对了,类却不在 `sys.modules["__main__"]` 上,今天能 `pickle.dumps` 的自定义对象会失败。换成模块对象之后,`sys.modules["__main__"].类 is 类`,同一次执行里的 `dumps` / `loads` 与现在一致。 +- **`NONCE` 每次调用由后端用 `secrets.token_hex(16)` 生成。** 后端只接受这样的 stdout:去掉末尾换行后恰好一行,并以 `NONCE + ":"` 开头。上游数据里出现什么字符串都不会被当成结果。用户代码自己往保存下来的结果流写东西不在防范范围内,那是作者自己的代码。 +- **结果上限 `LIMIT = 4 * 1024 * 1024`(4 MiB)**,与 2.x 补丁的 `STDOUT_LIMIT` 相同。比的是 JSON 的 UTF-8 字节数,不含 nonce 前缀。包装脚本里先判断,后端解析时再判断一次。三种后端用同一个值。 +- **不另给代码节点放宽 stdout。** 本机模式开头 4 MiB、结尾 512 KiB,已经装得下一条「4 MiB JSON + nonce 前缀」。容器 runner 从管道读完整 stdout。E2B 成功时返回的是 `logs.stdout` 里收齐的文本,也不按 512 KiB 截断;这份文本来自改道前的 `sys.stdout`,不是 1 号文件描述符。三种后端的结果上限相同,写结果的那一端不相同。stdout 实际写出的字节多于保留下来的(被截断)时,不拿残行去 `json.loads`,按没有合法结果行处理(28008)。超限时包装脚本写的是很短的 `too_large` 行,会被完整读到,后端报 28007。 +- 解析结果: + + | stdout | 处理 | + |---|---| + | `NONCE:ok:` | `json.loads` 后交给 `_parse_code_output`,后者照旧检查是 `dict`、包含每个输出字段 | + | `NONCE:unserializable` | 28005 `SandboxCodeNodeOutputError`,不变 | + | `NONCE:too_large:` | 新错误码 28007 `SandboxCodeNodeOutputTooLargeError`,文案写明上限和实际大小 | + | 退出码非零 | 见第 3 条 | + | 退出码为零但没有合法的结果行(例如 `main` 里或顶层调用 `sys.exit(0)` / `os._exit(0)`,或用户往留下的结果流里写了别的内容) | 新错误码 28008 `SandboxCodeNodeNoResultError`,文案写明代码运行结束但没有返回结果。现在这种情况报 28005「不可序列化」,用户会去查返回值,而真正的原因是根本没有返回 | + +- 成功时用户的 `print` 进 stderr,三种执行器成功时都只返回 stdout,所以不会出现在节点结果和运行日志里。这与 2.x 补丁一致;失败时 `print` 会出现在第 3 条的末尾片段里,用来排错。 + +### 3. 失败信息截尾 + +`exec_method` 在退出码非零时截断 `logs` 再抛出。截断写在 `code_parse.py` 里,三种后端共用,不调用 `LocalExecutor._tail`。`_tail` 是本机执行器的私有方法,只服务于解释器交给模型的失败日志:默认 8000 个字符,超出时加的是英文 `"[... earlier output truncated ...]\n"`。代码节点在容器和 E2B 上也会走到这里,工作流层不应依赖它。 + +上限是 **4096 个字符**(Python `str` 的 `len`,一个汉字算 1)。`logs` 已经是字符串,按字节切会切在 UTF-8 序列中间。不超过 4096 原样抛出;超出时保留末尾 4096 个字符,前面加一行 `"[... earlier output omitted ...]\n"`(与其他后端错误信息一样用英文,后端不做多语言)。这一行不算进 4096,避免把 traceback 末尾的原因挤掉。 + +改成 `` 编译后,用户代码那几帧本来就不含路径,包装脚本自己那一帧由上面换掉的 `excepthook` 跳过。出错那一行源码靠上面的 `linecache` 登记出现在 `File "", line N` 的下一行。 + +## 不做 + +| 2.x 补丁里的检查 | 不做的原因 | +|---|---| +| 导入白名单(16 个标准库模块加 `pymysql`) | 补丁自己说明它"不是安全边界"。3.0 的执行环境是带 pandas、requests 的完整 Python,白名单会挡掉大量正常写法。拦截清单里 2.x 的 48 个节点在白名单下就有 15 个被拦(`codecs`、`eval`、`type(obj).__name__`、`requests`) | +| 禁用 `eval`、`exec`、`open`、`getattr` 等名字,禁止下划线开头的属性,禁 `class`、装饰器、非常量默认值,顶层只能是字面量赋值 | 同上。这些规则配合补丁的进程内 runner 才有意义;3.0 把代码当脚本整体执行,`import os` 本来就允许,单独禁 `open` 没有意义 | +| 受限 builtins、只读模块代理 | 只适用于"在受控的 `exec` 环境里跑"。3.0 没有这层可挂 | +| `chroot /opt/code-root` | 要改镜像。本机的 chroot 方案暂时不做;代码节点需要这层隔离时走容器模式 | +| 环境变量白名单、只保留管道 fd、独立 uid 池、按 uid 清场、失败不退回进程内执行 | 本机模式由第一步覆盖(代码节点走临时模式);容器模式由 runner 负责;E2B 在远端 | +| `RLIMIT_CPU` 25 秒、`AS` 1 GiB、`FSIZE` 0、`NPROC` 1 | 第一步已有上限(`AS` 2 GiB、`nproc` 256)。照搬 `NPROC=1` 会让 numpy 的 BLAS 线程起不来,因为 3.0 允许这些库。若要给代码节点单独一档更紧的上限,另行评估 | +| 代码节点单独的超时(2.x 补丁为 30 秒) | 不增加。沿用执行器现有超时:本机、容器 600 秒,E2B 用 SDK 默认值 | +| `patch-mcp-stdio.py` | 与代码节点无关 | + +`local_isolation=off` 或第一步尚未上线时,本机模式的代码节点以 root 跑、继承全部环境变量。这时静态检查也挡不住,靠的是 `workflow.code_node_enabled` 默认关闭。 + +## 代码改动 + +| 文件 | 改动 | +|---|---| +| `bisheng/workflow/nodes/code/code_parse.py` | `SandboxCodeParser.parse_code` 加 `main` 检查;`build_code_node_wrapper` 按第 2 条改写,接收 `nonce` 和 `limit`(4 MiB);`_parse_sentinel` 换成按 nonce 解析单行结果;`exec_method` 失败时按第 3 条截到末尾 4096 个字符 | +| `bisheng/common/errcode/sandbox.py` | 新增 28007 `SandboxCodeNodeOutputTooLargeError`、28008 `SandboxCodeNodeNoResultError` | +| `src/frontend/packages/locales`(`api_errors`) | 28007、28008 的中、英、日文案,同一个 PR 里提交,并重新生成产物 | +| `test/sandbox/test_sandbox_code_parser.py` | 见"测试" | + +`code.py` 不改:`_parse_code_output` 的字典与字段检查保持原样。 + +## 测试 + +不需要 root: + +- 没有 `main`、只有 `async def main`、`main = …`、`from x import main`、或 `def main` 写在 `if` / `try` / 类里面时,`parse_code` 失败,错误信息说明原因;模块顶层有 `def main` 时通过。 +- `main` 里 `print("__BISHENG_CODE_NODE_UNSERIALIZABLE__")`、`print` 一个伪造的 `xxx:ok:{}` 行,结果仍是 `main` 的真实返回值。 +- 脚本方式下,顶层 `print` 和 `subprocess.run(["echo", "x"])` 的输出不出现在 stdout。 +- 返回 `set()` 等不可序列化对象得到 28005;返回超过 4 MiB 的字典得到 28007,信息里有实际大小。 +- `main` 里 `sys.exit(0)`、顶层 `os._exit(0)`、往留下的结果流里写一行非 nonce 开头的内容,都得到 28008,不是 28005。 +- 脚本方式下,用户代码第 3 行抛异常,失败信息里是 `File "", line 3`,紧接着的下一行是这一行源码,且不含临时目录路径。 +- 脚本方式下,子进程环境的区域设置不是 UTF-8(例如 `LANG=C`、不设 `PYTHONUTF8`)时,返回含中文的 dict 仍能完整解析。 +- stderr 超过 4096 个字符时只保留末尾 4096 个字符,前面有省略提示行,且末尾含异常类型和消息。不超过 4096 个字符时原文抛出,不加这行提示。 +- 用户代码定义名为 `_bisheng_params` 等的顶层变量,不影响调用。 +- 用户代码里的类实例可以 `pickle.dumps` 再 `pickle.loads`,类能按 `__main__` 找到。 + +需要 root(`local_isolation=uid`): + +- 返回约 3 MiB 的结果,经本机输出窗口(开头 4 MiB、结尾 512 KiB)后完整解析。 + +容器模式: + +- 同一组用例对 `type=container` 跑一遍,结果一致。 + +E2B: + +- mock `sandbox.run_code`:只有写进改道前 `sys.stdout` 的结果行算 stdout,`os.write(1, …)` 不算;`print` 不出现在这份 stdout 里。 +- 在真实 E2B 上跑以下几项。mock 盖不住内核是否把这个流对象收进 `logs.stdout`,也盖不住 IPython 的 traceback 格式: + - 顶层 `def main` 返回一个小 dict,结果行能被解析。 + - 用户代码第 3 行抛异常,失败信息里能看到 `` 和行号 3,以及这一行源码。IPython 的格式和脚本方式不同(形如 `File :3`),断言不能照搬脚本方式那一条。错误文本来自 `execution.error.traceback`,常带 ANSI 颜色控制字符,会占 4096 个字符的截尾预算,这是现状,不在本文处理。 + - 用户代码里的类实例可以 `pickle.dumps` 再 `pickle.loads`。在内核里替换 `sys.modules["__main__"]` 会换掉 IPython 自己的用户命名空间模块,需要实测确认不影响这次执行。 diff --git "a/features/v3.0.0-beta1/068-code-execution-sandbox/\346\262\231\347\256\261\344\275\277\347\224\250\350\257\264\346\230\216.md" "b/features/v3.0.0-beta1/068-code-execution-sandbox/\346\262\231\347\256\261\344\275\277\347\224\250\350\257\264\346\230\216.md" new file mode 100644 index 0000000000..f331ca82cd --- /dev/null +++ "b/features/v3.0.0-beta1/068-code-execution-sandbox/\346\262\231\347\256\261\344\275\277\347\224\250\350\257\264\346\230\216.md" @@ -0,0 +1,69 @@ +# 沙箱使用说明 + +本文写**页面上怎么选执行方式、任务里文件该写到哪、提示词怎么约束**。Runner、token、服务地址、环境变量见 [沙箱部署说明.md](./沙箱部署说明.md)。 + +选「沙箱运行」之前,部署侧要先把 `code-runner` 和 token 配好。存量升级不会自动改成沙箱。地址和令牌也可以在这个弹窗里改,见下一节。页面改不了网络可达性:backend、worker、灵思仍然要能访问你填的地址。 + +--- + +## 1. 选择执行方式 + +只有全局超级管理员能改。打开管理端的**构建 → 工具**,左侧停在**内置工具**,找到**代码执行器**,点这一行右侧的设置图标。弹出的对话框标题是「编辑内置工具」。 + +对话框里「代码执行方式」有三个单选: + +| 选项 | 选它的时候 | +|------|------------| +| 本地隔离运行 | 代码在 backend / worker 本机执行。`sandbox_conf.local_isolation`(或 `BS_SANDBOX_CONF__LOCAL_ISOLATION`)为 `uid` 时,子进程使用临时 uid;为 `off` 时仍以服务进程自己的用户跑。弹窗里不填地址。开启条件和目录权限见部署说明「本机执行的 uid 隔离」 | +| 沙箱运行 | 代码进入自建 `code-runner`。选中后填写每台副本的地址和访问令牌。留空的那一项回退到部署配置;清空已保存的页面值即恢复为部署配置。页面上的值优先于环境变量和 `config.yaml` | +| E2B沙箱运行 | 走云端 E2B。选中后才出现服务提供方、Domain、API Key,与自建副本池无关 | + +点**保存**。执行方式写入 `t_gpts_tools_type.extra` 的 `type`(`local` / `container` / `e2b`)。若这次改了沙箱地址或令牌,会先单独保存那两项,成功后再保存执行方式。任务模式、日常会话、助手、工作流里的代码执行器,以及代码节点,都按这一份来。 + +保存后的地址和令牌在 60 秒内对下一次执行生效:日常会话的下一条消息、已经打开的助手的下一轮对话(不用断开重连)、工作流的下一次运行。写缓存失败时,旧值仍靠缓存自己的 60 秒 TTL 到期后让出。正在跑的任务本轮仍用旧副本。测试连接只代表处理这次请求的 backend,不代表 worker 或灵思。缓存 key `config:sandbox_endpoint` 所在的 Redis db 跟 `redis_url` 走,发行配置是 `/1`。 + +--- + +## 2. 任务模式(灵思) + +1. 打开工作台客户端,路径 `/workspace`。Helm 部署下客户端在前端 Service 的 `4001` 端口,默认只在集群内可达,Ingress 也只转发 `3001`。要从浏览器打开,需在部署时配 `frontend.service.clientNodePort`(Service 类型为 NodePort)或 `clientHostPort`。 +2. 切到**任务模式**,工具里勾选**代码执行器**;要用知识库就一并勾选。 +3. 执行方式选的是「沙箱运行」时,token 和服务地址还要能打到 runner。Compose 里灵思在 worker 容器内,已能访问。Helm 的灵思是单独 Deployment,沙箱 chart 的网络策略默认已放行。老环境升级时若沿用了旧的放行列表,任务模式会连不上 runner,见部署说明「Linsight 进程」。 + +任务工作区在 MinIO `workspace/{session_version_id}/`。每一轮执行另有一个本地目录,沙箱只拷贝这个目录。本轮结束会删掉它,工作区里的文件还在。`/workbench/continue` 和 `ask_user` 恢复都沿用同一个会话版本:启动时先把工作区里的 `output/`、`uploads/`、`large_tool_results/` 放回本地目录,再 copy-in。`scratch/` 不放回。这些放回来的文件记入本轮基线,不当作本轮新生成的交付物;本轮改过的文件仍会交出去。 + +会进入沙箱的路径: + +| 路径 | 用途 | +|------|------| +| `output/` | **交付区**。只有这里会被收获给用户下载 | +| `scratch/` | 中间稿,跨轮不播种 | +| `uploads/` | 用户上传原件与解析出的 `.md` | +| `skills/` | 本轮技能包,只读 | +| `large_tool_results/` | 工具结果太大时的落盘,给模型稍后读取,**不是交付物** | + +文件工具带前导斜杠(`/output/x`),沙箱 cwd 是工作区根,代码里必须写成相对路径 `output/x`。 + +--- + +## 3. 工作流代码节点 + +代码节点有两个互不相干的设置,不要合成一个开关: + +| 要回答的问题 | 在哪里设置 | 说明 | +|------|------|------| +| 能不能跑 | 系统配置 `workflow.code_node_enabled`,必须是字面量 `true`(配置键是 `workflow`,种子在 `initdb_config.yaml`) | 默认关闭。不是 `true` 时节点直接拒绝执行,与执行方式无关 | +| 在哪里跑 | 管理端「代码执行方式」,见第 1 节 | 页面写入 `extra.type`。「沙箱运行」进入 `code-runner`,「本地隔离运行」在本机执行 | + +其他说明: + +- 编辑器「运行一次」在 backend 进程里执行,异步运行在 Celery worker。仓库默认的 Compose / Helm 已让这两个进程访问 runner。 +- 节点里写的是模块顶层的 `def main`。语法检查仍在本地 `ast.parse`,只有真正执行才进入所选的执行方式。 + +--- + +## 4. 日常会话 / 助手 + +同样勾选代码执行器。用户自己上传的附件只抽文本进 prompt,原件不送进沙箱。日常会话里,知识库检索引用到的图片会写到工作区的 `uploads/kb/`,代码里用这个相对路径读;执行方式为 `e2b` 时不写。助手没有这一步。一次 `run` 结束即 `DELETE` 租约。 + + diff --git "a/features/v3.0.0-beta1/068-code-execution-sandbox/\346\262\231\347\256\261\346\236\266\346\236\204\350\256\276\350\256\241\350\257\264\346\230\216\344\271\246.md" "b/features/v3.0.0-beta1/068-code-execution-sandbox/\346\262\231\347\256\261\346\236\266\346\236\204\350\256\276\350\256\241\350\257\264\346\230\216\344\271\246.md" index 36d209f6db..6da165191c 100644 --- "a/features/v3.0.0-beta1/068-code-execution-sandbox/\346\262\231\347\256\261\346\236\266\346\236\204\350\256\276\350\256\241\350\257\264\346\230\216\344\271\246.md" +++ "b/features/v3.0.0-beta1/068-code-execution-sandbox/\346\262\231\347\256\261\346\236\266\346\236\204\350\256\276\350\256\241\350\257\264\346\230\216\344\271\246.md" @@ -2,8 +2,8 @@ **用途**:评审 / 对齐用。讲「做成什么样、为什么、会上要拍板什么」。 **真相源**:验收 → [spec.md](./spec.md);决策原文与备选 → [design.md](./design.md)。本文不替代二者。 -**版本**:v3.0.0-beta1 · 2026-09-16 · 错误码模块 **280** -**状态**:Spec + Design 已出,待 ★ 确认;代码与 tasks.md 尚未开工。 +**版本**:v3.0.0-beta1 · 2026-10-21 · 错误码模块 **280** +**状态**:Spec + Design 已 ★ 确认(2026-09-20);tasks 29/29 已拆解并落地。2026-10-21 起副本只认配置里的服务地址,不再做主机名扫描。 --- @@ -19,7 +19,7 @@ | 镜像 | **只新增 1 个** `dataelement/bisheng-sandbox`:后端 venv + 完整 LibreOffice/pandoc/字体 + runner。内容见 §2 | | 并发 | 默认 **一副本一会话**;并发租户 = 加容器,不是同容器加目录 | | 异常回收 | worker `DELETE` + runner idle TTL(建议 15min)双保险;OOM/容器重启立刻让槽;sticky 单次超时不拆租约 | -| 发现 | worker 领租约时 DNS 探测;runner **不向 Redis/API 自注册** | +| 地址 | 副本只认配置里的服务地址列表。runner **不向 Redis/API 自注册**,worker **不按主机名扫描** | | 本期交付 | compose 必交;K8s 协议兼容,不强制交清单 | | 明确不做 | 按次新建容器、gVisor、日常会话原件进沙箱、改进 E2B、修 Fernet 主密钥 | @@ -54,6 +54,21 @@ 代码节点 ──► SandboxCodeParser(wrapper)─┘ ``` +调用点落在现有装配层,**不新增对外 API**。灰度按工具切 `type`,不是按页面各接一套沙箱。 + +| # | 产品入口 | 今天的装配锚点 | 沙箱里有什么 | +|---|---|---|---| +| 1 | 灵思任务模式 | `workbench_impl._init_bisheng_code_tool` → `ToolExecutor` | 任务工作区全量(`uploads/` `output/` `scratch/` `skills/`);sticky | +| 2 | 工作台日常会话 | `chat_service._prepare_tools`(`DAILY_CHAT`) | 通常空临时目录;用户原件不进 | +| 3 | 助手 | `assistant_agent.py`(`ASSISTANT`) | 同日常 | +| 4 | 工作流 Agent 节点 | `workflow/nodes/agent/agent.py:_init_tools` | 该次工具目录 | +| 5 | 工作流工具节点 | `workflow/nodes/tool/tool.py` | 同路径 4 | +| B | 工作流代码节点 | `CodeNode` → wrapper → 同一 `/exec` | 通常无业务文件 | + +共同 dispatch:`ToolExecutor` 注入 MinIO → `load_tools._get_native_code_interpreter` 显式三分支 `local` / `container` / `e2b`。 + +**不是调用点**:灵思 `ls`/`read_file`/`grep`(MinIO 工作区)、知识库解析 LibreOffice、附件预览、技能包物化(物化后再被路径 1 copy-in)。 + ### 明确不改的产品语义 - 日常会话附件:**只抽文本进 prompt**,原件不进执行环境(既有缺口,另立项)。 @@ -70,7 +85,7 @@ ## 2. 总体架构 -平台进程拓扑不变。沙箱不是第五套对外服务:浏览器和 FastAPI **都不直连** runner。只有 worker 同时挂可信网和 `sandbox_net`。 +平台进程拓扑不变。沙箱不是第五套对外服务:浏览器不直连 runner。编辑器「运行一次」在 API 进程里执行,所以 backend 与 worker 都挂可信网和 `sandbox_net`。 ```mermaid flowchart TB @@ -124,7 +139,7 @@ flowchart TB | 谁 | 做什么 | 明确不做什么 | |---|---|---| | `BaseExecutor` | 路径规则、逃逸拒绝、快照 diff、产物归位、MinIO、工作区镜像 | 不知道代码在哪跑 | -| `ContainerExecutor` | 发现副本、领租约、tar copy-in/out、调 exec | 不上传 MinIO | +| `ContainerExecutor` | 按配置地址领租约、tar copy-in/out、调 exec | 不上传 MinIO,不扫描主机名 | | runner(容器内 HTTP) | 收文件、起子进程、回文件、清目录 | 不认识 `main`、不连中间件 | | 云后端子类 | 自己的 create / files / run / kill | 不假装成 `/v1/sessions` 副本 | @@ -246,7 +261,7 @@ sequenceDiagram 2. 递归走目录,只收普通文件和目录。跳过 socket / 设备;拒绝相对路径里的 `..`(后面解包同样拒绝,防 zip-slip)。 3. 单个文件超过 `max_copy_in_bytes`:**跳过并在日志点名该文件**(AC-10),不得静默丢弃,也不因此拆掉整次租约。 4. 对每个相对路径算 md5。对比本 session 已缓存的 `md5_index`(worker 与 runner 各持一份,以 runner 内存表为准):相同则本轮 **不进 tar**;不同或 runner 没有则写入 tar。 -5. `PUT /v1/sessions/{id}/files`,鉴权用该会话 `lease_token`。body 是 **流式 tar.gz**;旁路带上 `{rel_path: md5}` 清单(请求头或与流并列的 JSON,实现时二选一)。不要按文件拆成多次 HTTP。 +5. `PUT /v1/sessions/{id}/files`,鉴权用该会话 `lease_token`。body 依次是 4 字节大端长度、UTF-8 JSON 清单 `{rel_path: md5}`、tar.gz。不要按文件拆成多次 HTTP。 6. runner 校验租约未过期 → 按清单跳过已命中路径 → 把 tar 解到该 UUID `work_dir`(成员路径必须落在该目录内)→ 更新 `md5_index`。若启用独立 uid:监督进程 root 写入后再 `chown -R` 给 session uid。 7. copy-in 是 **按相对路径 upsert**。md5 命中则不传、不覆盖。worker 侧已删除的文件本期 **不** 从 runner 删(整租约 DELETE 才会清空)。灵思 sticky 第二轮因此只补增量。 @@ -302,6 +317,8 @@ sequenceDiagram | 工作流 Agent / 工具节点 | 该次工具工作目录 | 产物 URL 回节点;一次一清 | | 工作流代码节点 | 通常无业务文件,wrapper 在 exec 的 `code` 字段里 | 出参走 stdout 哨兵,一般不依赖 copy-out | +**何时整目录打 tar**:只在 `type=container` 的 `execute_code` 里发生(`local` / `e2b` 都不打 tar)。扫描是全目录;进 tar 的只有 md5 对不上的文件。新租约第一次 ≈ 全量;灵思 sticky 后续轮次只打变更;租约 404 / 副本重启后再 exec 才重新全量。日常会话 `dir_path` 常为空。容器模式 **没有** E2B 那条 5MB 自动推送天花板。 + **实现时不要踩的** - tar 成员必须校验落在 `work_dir` 内,拒绝绝对路径和 `..`。 @@ -340,7 +357,7 @@ flowchart TB - 池容量 ≈ **副本数**(默认 `max_sessions_per_replica=1`)。 - 灵思 sticky 会话会占槽直到任务结束或 idle TTL(建议 15min),所以槽位是「同时活着的会话数」,不是 QPS。 - 副本之间 **不共享磁盘、不共享 Redis**。状态只活在那一台的内存 + tmpfs。这是必须粘滞到具体 URL 的原因。 -- 扩容:加具名 compose 服务 / 调 StatefulSet replicas;worker 发现缓存过期后自动看到,不必重启。 +- 扩容:加具名 compose 服务或调 StatefulSet replicas,并把新 URL 写入 `endpoints` 后升级平台。 **为什么不用「前面挂 VIP / ClusterIP」**:灵思同一任务的多次 exec 必须打到 **同一副本的同一目录**。负载均衡会把第二轮打到别的机器,copy-in 过的文件就没了。 @@ -386,7 +403,7 @@ sequenceDiagram | exec / copy 中途 **协议不合 / 连不上** | 能杀则 `killpg` | worker 尽最大努力 DELETE;失败交给 TTL | `28001` / `28006`,禁止回落到进程内 `exec`(AC-05) | | **worker 崩溃** / DELETE 丢失 | 当时无感知 | idle TTL 到期视同 DELETE | 槽不会永久占死;任务本身按 worker 崩溃处理 | | sticky 间隙 **租约过期** 后再 exec | 回 404,目录已擦 | 已释放 | worker 解绑,重新领约 + 全量 copy-in | -| **runner 容器重启** | tmpfs + 内存表清空,槽位自然空,数据不可恢复 | 立刻 | 粘滞失败 → 重新发现 / 领约 + copy-in | +| **runner 容器重启** | tmpfs + 内存表清空,槽位自然空,数据不可恢复 | 立刻 | 粘滞失败 → 按 endpoints 重新领约 + copy-in | | K8s **缩容**掉被粘滞的 Pod | 同重启 | 旧 Pod 消失即释放 | 按租约 404 处理,换一台 | | 池满,**根本没领到**租约 | 无 session 可回收 | — | `28002`,与执行失败分列(AC-27) | @@ -399,64 +416,55 @@ sequenceDiagram --- -## 5. 副本怎么被找到(领租约时发现) +## 5. 副本地址从哪来(领租约) -默认不写死每一台 URL。worker 在 **第一次领租约** 时按主机名模式做 DNS,缓存一个 URL **集合**(短 TTL,建议 15–30s)。粘滞绑的是解析出的那一台具体 URL,不是通配名。 +副本列表就是 `endpoints`。worker 在 **第一次领租约** 时按这份 URL 去 `POST /v1/sessions`,不按主机名模式做 DNS 扫描。粘滞绑的是列表里的那一台具体 URL,不是 Service 名。 -`endpoints` 非空则跳过下面的 DNS 段,直接拿列表去 `POST /v1/sessions`(离线手工拓扑或探测失败时的逃生舱)。下图按默认路径、compose 主机名举例;K8s 只换查询名(`code-runner-0.code-runner` 起),HTTP 段不变。 +列表为空则 `28001` 不可达。下图按 compose 里配置的两台地址举例;K8s 只换 URL 写法(`code-runner-0` 的 Pod 地址起),HTTP 段不变。 ```mermaid sequenceDiagram participant Path as 业务路径 participant W as ContainerExecutor - participant DNS as 集群 DNS participant R1 as code-runner-1 participant R2 as code-runner-2 Path->>W: execute_code 新租约 - Note over W: endpoints 为空且发现缓存过期 - W->>DNS: 解析 code-runner-1 - DNS-->>W: 命中 - W->>R1: GET /health - R1-->>W: 200 - W->>DNS: 解析 code-runner-2 - DNS-->>W: 命中 - W->>R2: GET /health - R2-->>W: 200 - W->>DNS: 解析 code-runner-3 - DNS-->>W: NXDOMAIN - Note over W: 停止递增;缓存两台主机名 URL + Note over W: endpoints 已列出两台 W->>R1: POST /v1/sessions R1-->>W: 503 槽满 W->>R2: POST /v1/sessions R2-->>W: 200 session_id + lease_token - Note over W: bind R2,后续不再走发现 + Note over W: bind R2,后续不再挑选 W->>R2: PUT files / POST exec / GET files R2-->>W: 执行结果 Path->>W: execute_code keep_session - Note over W: 已绑定,跳过发现 + Note over W: 已绑定,不再挑选 W->>R2: POST exec R2-->>W: 执行结果 W->>R2: DELETE session ``` -全台 503 → `28002` 容量已满;一台都解析不到 → `28001` 不可达。两种失败都 **不得** 静默回落到进程内 `exec`(AC-05)。 +全台都连不上,或列表为空 → `28001` 不可达。至少一台回过 503 且没有一台领到租约 → `28002` 容量已满。两种失败都 **不得** 静默回落到进程内 `exec`(AC-05)。 --- -## 6. compose 与 K8s:同一协议,两套 DNS +## 6. compose 与 K8s:同一协议,两份地址列表 -本期 **必交 compose**。K8s 用同一套 HTTP 与发现算法,换主机名写法即可;不引入 k8s 客户端,也不要求客户新上集群。 +本期 **必交 compose**。K8s 用同一套 HTTP,换 URL 写法即可;不引入 k8s 客户端,也不要求客户新上集群,也不按主机名扫描。 | | docker compose(必交) | Kubernetes(协议兼容) | |---|---|---| | 怎么声明 N 份 | 具名服务 `code-runner-1`、`code-runner-2`(**不用**匿名 `deploy.replicas`) | StatefulSet + **headless** Service(**不用** Deployment + ClusterIP) | -| 发现名 | `code-runner-{n}`,n 从 **1** | `code-runner-{n}.code-runner`,n 从 **0** | -| 停的条件 | 下一个名字 NXDOMAIN | 下一个 ordinal NXDOMAIN | -| 粘滞绑什么 | `http://code-runner-2:8080` | `http://code-runner-1.code-runner:8080`(主机名,不是 Pod IP) | -| 网络 | runner 只进 `sandbox_net`;worker 双挂;runner **不** `ports:` 到宿主 | NetworkPolicy:只允许 worker 打 8080;runner 不进中间件 Service | -| 应用代码 | 只做 DNS + HTTP | 只做 DNS + HTTP | +| 地址写在哪 | `BS_SANDBOX_CONF__ENDPOINTS` | 平台 chart `sandbox.endpoints`,注入同一环境变量 | +| 默认两台 | `http://code-runner-1:8080`、`http://code-runner-2:8080` | `http://code-runner-0.code-runner.bisheng-sandbox.svc.cluster.local:8080`、`…-1…` | +| 粘滞绑什么 | `http://code-runner-2:8080` | 配置里的 Pod URL(不是 Service 名,也不是临时 Pod IP) | +| 网络 | runner 只进 `sandbox_net`;backend 与 worker 双挂;runner **不** `ports:` 到宿主 | NetworkPolicy:允许 backend 与 worker 打 8080;runner 不进中间件 Service | +| 应用代码 | 只按配置 URL 做 HTTP | 只按配置 URL 做 HTTP | +| `config.yaml` | **不**写 `deploy_mode`,**不**写主机名模式 | 同左。加副本改 `endpoints` | + +两种编排下,**与 runner 的 HTTP 时序完全相同**;差别只是地址列表里的 URL 怎么写。应用看不见 docker / kube-apiserver。配「部署方案」枚举没有作用对象,还会和编排清单各写一份而漂移。 ```mermaid flowchart TB @@ -477,8 +485,6 @@ flowchart TB API --> DB ``` -两种编排下,**与 runner 的 HTTP 时序完全相同**;差别只是问哪套 DNS、主机名怎么写。 - --- ## 7. 隔离分层(会上最容易争的部分) @@ -560,9 +566,21 @@ type=e2b → 已有 SDK,本期冻结功能改进 未来云厂商 → 再写一个子类,不要实现假的 /v1/sessions ``` -E2B 的隔离单元是一台 Firecracker `Sandbox`,两个会话 = 两个 Sandbox。不要把 uid / 副本发现套过去。worker 直连云控制面;自建 runner 继续零出站。 +E2B 的隔离单元是一台 Firecracker `Sandbox`,两个会话 = 两个 Sandbox。不要把 uid 套过去。worker 直连云控制面;自建 runner 继续零出站。 + +线上协议是 **三层**,不是一条 `/exec`(现网 SDK `e2b-code-interpreter==1.5.2`): + +| 层 | 做什么 | 毕昇调用 | +|---|---|---| +| 控制面 `api.e2b.app` | REST 建/杀 VM(`X-API-Key`) | `Sandbox(...)` / `kill()` | +| envd :49983 | 逐文件 `POST /files` + ConnectRPC 文件系统 | `files.write` / `read` / `list` | +| Jupyter :49999 | `POST /execute` NDJSON;kernel **跨轮保留变量** | `run_code` | + +这和 F068 的「常驻副本上领租约 + 一次子进程 `/exec` + 目录 tar」不是同一类生命周期,所以禁止把云 API 适配成假 `/v1/sessions`。 + +自动 copy-in **5MB**(`SIZE_AUTOPUSH`)是 F035 防 worker 整文件读进内存后 silent bulk 出境的产品阈值,**不是** E2B 接口上限。设计里超限本应由 `required_files` 声明;现状工具 schema 只有 `python_code`,oversized 在装配期剔出 `file_list`,>5MB 等于进不去 E2B。本期不还。容器模式用 `max_copy_in_bytes` 点名跳过,不套这条 5MB。 -本期不还 E2B 的已知债(技能包 copy-in 时机、产物只列一层目录等)。若以后要「正式支持」,对齐的是 `run_with_dir` 语义,不是再开一条业务路径。 +本期不还 E2B 的已知债(技能包 copy-in 时机、产物只列一层目录、5MB 声明未接线等)。若以后要「正式支持」,对齐的是 `run_with_dir` 语义,不是再开一条业务路径。 按次新容器、带会话亲和的编排面,留给 3.0 应用工场 **F103** `runtime-manager`。本 Feature 不自造第二个持 docker 的特权面。 @@ -571,8 +589,9 @@ E2B 的隔离单元是一台 Firecracker `Sandbox`,两个会话 = 两个 Sandb ## 9. 配置、灰度、容量 - 执行模式在 **单个工具** 上切:`gpts_tools.extra.type = local | container | e2b`,切换不必重启。 -- 池参数走系统配置 `Settings.sandbox_conf`(发现模式、token、TTL、槽位、copy-in 上限、代码节点开关)。**发布稿整段注释掉**,缺段用内置默认启动。 -- 接入 URL / token **不进** 工具 `extra`。 +- 池参数走系统配置 `Settings.sandbox_conf`(服务地址、token、TTL、槽位、copy-in 上限、代码节点开关)。**发布稿整段注释掉**,缺段用内置默认启动。 +- **没有** `deploy_mode` / `orchestrator`,也**没有**主机名扫描字段。副本只认 `endpoints`。空列表表示没有副本。旧的 `discover_*` 配置被忽略。 +- 接入 URL / token **不进** 工具 `extra`。E2B 的 `api_key` / `domain` 留在工具 `config.e2b`,不写进 `sandbox_conf`。 - 代码节点另有系统级开关:关掉则回到改造前的进程内 `exec`。 - 池满是独立事件(错误码 `28002`),不要记成执行失败。 @@ -594,7 +613,7 @@ E2B 的隔离单元是一台 Firecracker `Sandbox`,两个会话 = 两个 Sandb 1. **隔离口径**:同意「共享内核 + 容器边界」,对外不承诺 VM;龙芯等信创只走这条。 2. **拓扑**:同意预声明副本池,backend 不持 docker.sock;按次新容器等到 F103。 3. **默认一副本一会话**:同意并发靠加容器;槽位 >1 视为高级模式且必须 uid 隔离。 -4. **发现方式**:同意 worker DNS 发现,反对 runner 向 Redis 自注册。 +4. **地址列表**:同意副本只认配置里的服务地址,反对 runner 向 Redis 自注册,也反对按主机名扫描;配置不写 k8s/compose 枚举。 5. **K8s**:同意本期只保证协议兼容、不交清单。 6. **E2B**:同意冻结改进、插件缝预留;不把云 API 适配成假副本。 7. **产品语义**:同意日常会话原件仍不进沙箱、解析链 LibreOffice 仍留在平台侧。 @@ -619,7 +638,7 @@ E2B 的隔离单元是一台 Firecracker `Sandbox`,两个会话 = 两个 Sandb | 9 | worker 自己挑副本并粘滞,前面不加 VIP | | 10 | 云沙箱是 BaseExecutor 插件,不是假副本 | | 11 | 默认一副本一会话 | -| 12 | 领租约时 DNS 发现,runner 不自注册 | +| 12 | 副本只认 `endpoints`,runner 不自注册,不做主机名扫描;配置无编排类型枚举 | | 13 | 同容器多 session 才上独立 uid + lease_token | ## 附录 B · 文档怎么用 @@ -629,7 +648,10 @@ E2B 的隔离单元是一台 Firecracker `Sandbox`,两个会话 = 两个 Sandb | 验收条款 AC-01–AC-28 | spec.md §2 | | 每个决策的否决理由 | design.md §3 | | 沙箱镜像装什么、不装什么 | design.md §4.4 | +| 五条路径 + 代码节点调用点、何时打 tar | design.md §4.1–§4.2 | | HTTP tar copy-in / copy-out 步骤 | design.md §4.2 | +| E2B 三层协议、5MB 自动推送、后续接法 | design.md §4.8 · 决策 10 | +| 配置无 k8s/compose 枚举;compose / K8s 地址列表 | design.md §4.6 · 决策 12 | | session 异常回收 | design.md §4.5 | | 模块文件职责、坑、对外契约 | design.md §4.3 · §5 · §6 | | 本文(沙箱架构设计说明书) | 评审投影 / 发给未读 SDD 的人 | diff --git "a/features/v3.0.0-beta1/068-code-execution-sandbox/\346\262\231\347\256\261\351\203\250\347\275\262\350\257\264\346\230\216.md" "b/features/v3.0.0-beta1/068-code-execution-sandbox/\346\262\231\347\256\261\351\203\250\347\275\262\350\257\264\346\230\216.md" new file mode 100644 index 0000000000..2c79aa1b16 --- /dev/null +++ "b/features/v3.0.0-beta1/068-code-execution-sandbox/\346\262\231\347\256\261\351\203\250\347\275\262\350\257\264\346\230\216.md" @@ -0,0 +1,292 @@ +# 沙箱部署说明 + +隔离环境把模型 / 搭建者写的 Python 从 backend、Celery、Linsight 进程里挪到独立的 `bisheng-sandbox` 副本上执行。平台侧**不挂 docker.sock、不调 Kubernetes API**,也不按主机名扫描副本。副本就是配置里的服务地址列表,再走 HTTP 租约。 + +架构评审材料见同目录 [沙箱架构设计说明书.md](./沙箱架构设计说明书.md)。页面上怎么选执行方式、任务模式怎么交文件,见 [沙箱使用说明.md](./沙箱使用说明.md)。本文只写**怎么配、怎么开、怎么验收**。 + +--- + +## 1. 组件与调用关系 + +| 组件 | 镜像 | 作用 | +|------|------|------| +| `code-runner-*` | `bisheng-sandbox` | 加固容器;提供 `/health`、`/v1/sessions`、copy-in/exec/copy-out | +| backend | `bisheng-backend` | 日常会话、助手里的代码解释器;工作流编辑器「运行一次」 | +| Celery worker | 同上 | 工作流运行时的代码节点,以及 Agent 节点、工具节点里的代码解释器 | +| Linsight | 同上(独立进程;Compose 里在 worker 容器内,Helm 里是单独 Deployment,见 2.3) | 任务模式代码解释器(`keep_session` 粘滞同一副本) | + + +## 2. 部署 + +### 2.1 Docker Compose + +仓库 `docker/docker-compose.yml` 预声明具名副本(默认 `code-runner-1`、`code-runner-2`),挂在 `sandbox_net` 上。这张网默认可以访问外网(`internal: false`),不映射宿主机端口。`backend` 和 worker 都必须同时加入 `default` 和 `sandbox_net`,才能解析 `code-runner-1`,同时仍能访问 MySQL / Redis。已经建好的 `sandbox_net` 如果当时是 internal,改这份文件不会改那张网,需要删网重建后出站才会通。 + +backend、backend_worker、frontend 和沙箱要用开发分支同一次构建出的镜像: + +| 用途 | 镜像 | +|------|------| +| backend、backend_worker | `cr.dataelem.com/dataelement/bisheng-backend:feat_3.0.0` | +| frontend | `cr.dataelem.com/dataelement/bisheng-frontend:feat_3.0.0` | +| `code-runner-*` | `cr.dataelem.com/dataelement/bisheng-sandbox:feat_3.0.0` | + +`feat_3.0.0` 是当前的内部构建,会被后续构建覆盖,不是发布版。沙箱进入正式发布 tag 之后,本文的镜像改回版本号,不要把 `feat_3.0.0` 当长期版本钉死。 + +token 写进 `docker/.env`,不要只在当前终端 `export`。换一个终端再执行 `docker compose up` 时,export 的值带不过去,runner 和 backend 会一起回退到默认的 `bisheng-sandbox-local`:照样能连上、不报错,生产 token 就这样丢了。 + +```bash +# docker/.env —— 与 runner 共用;生产务必改掉默认值 +SANDBOX_TOKEN=替换成足够长的随机串 +``` + +```bash +cd docker +docker compose up -d code-runner-1 code-runner-2 backend backend_worker frontend +``` + +Compose 把两台 runner 写进 `BS_SANDBOX_CONF__ENDPOINTS`:`http://code-runner-1:8080` 和 `http://code-runner-2:8080`。再加一台就要把新地址追加进 backend 和 backend_worker 的这份列表。 + +日常会话、助手和编辑器「运行一次」都在 backend 进程里执行,所以 `backend` 也要带同一份 `BS_SANDBOX_CONF__TOKEN`。任务模式的灵思跑在 `backend_worker` 容器里(见 2.3),不需要额外接网络。 + +扩容:复制一份 `code-runner-N` 服务(具名,不要用 `deploy.replicas`),否则 `keep_session` 粘不住同一台。 + +### 2.2 Helm(两张独立 chart) + +中间件(MySQL / Redis / MinIO / ES / Milvus / OpenFGA)**不在** chart 里,先指到已有地址。 + + +```bash +export SANDBOX_TOKEN='替换成足够长的随机串' +export REGISTRY=cr.dataelem.com/dataelement +export TAG=feat_3.0.0 + +# 1. 沙箱:Service 名保持 code-runner(默认 fullnameOverride) +helm upgrade --install sandbox docker/helm/bisheng-sandbox \ + --namespace bisheng-sandbox --create-namespace \ + --set token.value="$SANDBOX_TOKEN" \ + --set image.repository="$REGISTRY/bisheng-sandbox" \ + --set image.tag="$TAG" + +# 2. 平台(token 必须与上一张相同) +helm upgrade --install bisheng docker/helm/bisheng \ + --namespace bisheng --create-namespace \ + --set sandbox.token="$SANDBOX_TOKEN" \ + --set sandbox.endpoints='{http://code-runner-0.code-runner.bisheng-sandbox.svc.cluster.local:8080,http://code-runner-1.code-runner.bisheng-sandbox.svc.cluster.local:8080}' \ + --set backend.image.repository="$REGISTRY/bisheng-backend" \ + --set backend.image.tag="$TAG" \ + --set worker.image.repository="$REGISTRY/bisheng-backend" \ + --set worker.image.tag="$TAG" \ + --set linsight.image.repository="$REGISTRY/bisheng-backend" \ + --set linsight.image.tag="$TAG" \ + --set frontend.image.repository="$REGISTRY/bisheng-frontend" \ + --set frontend.image.tag="$TAG" +``` + +同一个 tag 重新构建后再升级,删 Pod 换不上新层,见 4.2。 + +K8s StatefulSet 序号从 **0** 起,平台侧把每台 Pod 的地址列出来: + +```yaml +sandbox: + enabled: true + endpoints: + - http://code-runner-0.code-runner.bisheng-sandbox.svc.cluster.local:8080 + - http://code-runner-1.code-runner.bisheng-sandbox.svc.cluster.local:8080 +``` + +执行方式只看内置工具的 `extra.type`,见 3.3。 + +沙箱 chart 的 NetworkPolicy 默认开启,入站放行命名空间 `bisheng` 里标签 `app.kubernetes.io/component` 为 `worker`、`backend` 或 `linsight` 的 Pod。编辑器「运行一次」、Celery 上的执行和任务模式因此都能打到 runner。旧版本 chart 升级时,如果用 `--reuse-values` 沿用了旧的 `allowFrom`,不会带上新增的两条,要显式重设,见 2.3。 + +**不要**给 runner 只挂一个 ClusterIP VIP。`keep_session` 粘的是主机名(`http://code-runner-0.code-runner…`),经 kube-proxy 会把下一跳打到别的 Pod,工作目录丢失。 + +扩容时先加沙箱副本,再把新 Pod 的地址补进平台 chart 的 `sandbox.endpoints` 并升级平台。没有后台 DNS 扫描。 + +```bash +helm upgrade sandbox docker/helm/bisheng-sandbox --reuse-values --set replicaCount=4 +``` + +Helm 安装细节另见 [docker/helm/README.md](../../../docker/helm/README.md)。 + +### 2.3 Linsight 进程 + +任务模式由 Linsight 进程执行。Compose 和 Helm 里它的来源不同,两边都不要再手动多起一份:多份进程会消费同一个 Redis 队列。 + +两份入口脚本在这一点上不一致,部署前要知道自己用的是哪份: + +| 部署 | 实际使用的入口脚本 | worker 模式是否后台启动灵思 | +|------|--------------------|------------------------------| +| Compose | 宿主机挂载的 `docker/bisheng/entrypoint.sh`(只读挂到 `/app/entrypoint.sh`) | 是(`start_linsight &`) | +| Helm | 镜像里烘焙的 `src/backend/entrypoint.sh` | 否(该行被注释) | + +**Compose。** `backend_worker` 的命令是 `entrypoint.sh worker`,在后台执行 `start_linsight`(`python bisheng/linsight/worker.py --worker_num 1 --max_concurrency 5`),和 Celery 在同一个容器里。不要再执行 `entrypoint.sh linsight`。灵思因此已经在 `sandbox_net` 上,带着同一份 token。 + +**Helm。** worker Pod 不跑灵思。chart 自带 `bisheng-linsight` Deployment,命令是 `entrypoint.sh linsight`,装好即有,不要再进 Pod 手动起一份。它的标签是 `component: linsight`,沙箱 chart 的 NetworkPolicy 默认已放行它,新装的环境不用再改。 + +升级老环境要留意:`helm upgrade --reuse-values` 会沿用旧 release 里保存的 `networkPolicy.allowFrom`,只有 `worker`,新增的 `backend` 和 `linsight` 不会生效,任务模式和编辑器「运行一次」仍然连不上 runner。升级时不要带 `--reuse-values`,或者显式传入包含这三项的 `allowFrom`。自己改过 `allowFrom`(例如换了命名空间)的环境,要把 `linsight` 一并补进去。 + +多套平台共用一个 Redis 时,把任务队列和会话库错开(例如平台 A 用 `/5` + Celery `/4`,平台 B 用 `/1`),否则别人的 Linsight 会抢走任务。 + +--- + +## 3. 配置项 + +配置分两边:**平台访问沙箱**(`BS_SANDBOX_CONF__*`)和 **runner 自身**(`SANDBOX_*`)。两边 token 必须一致。 + +`config.yaml` 可以写 `sandbox_conf:` 块;**更老的镜像遇到未知顶层键会拒启**,所以发行文件里默认整段注释掉,靠环境变量覆盖。 + +### 3.1 平台侧 `BS_SANDBOX_CONF__*` + +对应 `SandboxConf`,环境变量是双下划线 + 字段大写。表里的默认值就是要填进环境变量的字面量。字节数只接受十进制整数:`int(raw)` 不认 `50MiB` 这种写法,照填会在启动时报错。50 MiB = `52428800`。 + +| 环境变量 | 含义 | Compose 默认 | Helm 默认 | +|----------|------|--------------|-----------| +| `BS_SANDBOX_CONF__TOKEN` | 调用 runner 的 Bearer,与 `SANDBOX_TOKEN` 相同 | `bisheng-sandbox-local` | values `sandbox.token` | +| `BS_SANDBOX_CONF__ENDPOINTS` | 沙箱服务地址,JSON 数组,一台副本一个。空列表表示没有副本 | `code-runner-1` 与 `code-runner-2` | values `sandbox.endpoints` 里的两台 Pod | +| `BS_SANDBOX_CONF__POOL_ACQUIRE_TIMEOUT_S` | 等空闲副本的最长时间 | `30` | `30` | +| `BS_SANDBOX_CONF__DEFAULT_TIMEOUT_S` | 单次 exec 默认超时 | `600` | `600` | +| `BS_SANDBOX_CONF__MAX_COPY_IN_BYTES` | 超过则跳过该文件,不打进 tar。容量 50 MiB | `52428800` | `52428800` | + +地址和 token 要配在真正发起调用的进程上,或改在代码执行器弹窗里(构建 → 工具 → 内置工具 → 代码执行器 → 沙箱运行)。弹窗里的非空地址、非空令牌优先于环境变量和 yaml;留空回退到这里的部署值,清空已保存的页面值即恢复。配了还不够,该进程还得在网络上够得到这些地址(见 2.1、2.2 的默认范围)。页面改不了网络可达性。 + +保存后 60 秒内,新的执行使用页面值:日常会话的下一条消息、已经打开的助手的下一轮对话(不用断开重连)、工作流的下一次运行。写缓存失败时,旧缓存仍在自身 60 秒 TTL 内有效。正在跑的任务本轮仍用旧副本。弹窗里的测试连接只代表 backend。缓存 key `config:sandbox_endpoint` 的 db 以 `redis_url` 为准,发行配置是 `/1`。 + +- **Celery worker**:工作流运行(代码节点、Agent 节点、工具节点)。Compose 和 Helm 默认都覆盖这条 +- **backend**:日常会话、助手、编辑器「运行一次」。Compose 已双挂 `sandbox_net` 并带 token;Helm 网络策略已放行 `component: backend` +- **Linsight**:任务模式。Compose 跑在 `backend_worker` 容器里,已在 `sandbox_net` 上。Helm 是独立 Deployment,网络策略已放行 `component: linsight` + +### 3.2 Runner 侧 `SANDBOX_*` + +| 环境变量 | 含义 | 默认 | +|----------|------|------| +| `SANDBOX_TOKEN` | 鉴权,须与平台 token 相同 | 空则进程拒绝启动 | +| `SANDBOX_MAX_SESSIONS` | 每副本并发会话数 | `50` | +| `SANDBOX_LEASE_TTL_S` | 空闲租约回收(秒) | `900` | +| `SANDBOX_ENABLE_UID_ISOLATION` | 会话 uid 隔离 | `true` | +| `SANDBOX_SESSIONS_ROOT` | 会话工作目录 | `/tmp/sessions` | +| `SANDBOX_MAX_COPY_IN_BYTES` | copy-in 单文件上限,容量 50 MiB。填十进制字节数,不要写 `50MiB` | `52428800` | +| `SANDBOX_PORT` | 监听端口 | `8080` | +| `SANDBOX_LOG_LEVEL` | 日志级别 | `INFO` | + +每副本默认 50 个并发会话。会话数大于 1 时必须开着 uid 隔离,否则 runner 拒绝启动。还要更多并发时再加副本。 + + +### 3.3 本机执行的 uid 隔离(`local` 时) + +还在用 `local`(包括代码节点关掉沙箱时)的部署,可以打开本机隔离:每次执行都在一个私有目录里、以 `30000`–`39999` 中的一个临时 uid 运行,环境变量只保留白名单,并加上内存、进程数、CPU 时间、文件大小上限。设计见 [第一步方案](./LocalExecutor安全优化方案-第一步-uid隔离.md)。 + +| 环境变量 | 默认 | 说明 | +|----------|------|------| +| `BS_SANDBOX_CONF__LOCAL_ISOLATION` | `uid` | 未设置就是 `uid`。只有空串或 `off` 表示关闭,其他值都按 `uid` | +| `BS_SANDBOX_CONF__LOCAL_RUN_ROOT` | `/tmp/bisheng-local-run` | 私有运行目录的父目录 | + +代码里的默认值是 `uid`。环境变量和配置文件都没写这项时,本机执行走 uid 隔离。仓库自带的 `docker/docker-compose.yml` 在 `backend`、`backend_worker` 上仍写 `${LOCAL_ISOLATION:-uid}`,要关闭就在 `docker/.env` 里写 `LOCAL_ISOLATION=off`。Helm 不写这项时同样是 `uid`;要关闭就在 `backend`、`worker`、`linsight` 的 `extraEnv` 里设 `BS_SANDBOX_CONF__LOCAL_ISOLATION=off`。 + +其余限额字段(`LOCAL_RLIMIT_*`、`LOCAL_DISK_LIMIT_BYTES`、`LOCAL_COPY_IN_*`)见方案的"配置字段"一节,一般不用改。 + +开启前提:Linux,进程是 root,镜像里有 `prlimit`,容器保留 Docker 默认能力集(含 `SETUID`、`SETGID`、`CHOWN`、`DAC_OVERRIDE`、`KILL`)。不满足时**不会退回不隔离**,两条路径的表现不同: + +- 代码执行器(任务模式、日常会话、助手、工作流 Agent 节点和工具节点)返回一段英文提示(`The local code execution environment is not ready, so nothing was executed. Do not retry; tell the user that code execution is currently unavailable.`),模型据此告诉用户暂时不能执行代码。 +- 代码节点不经过这段提示,直接抛出具体原因(例如 `not running as root (euid=1000)`),节点按失败处理,异常原文就是失败信息。 + +两条路径的服务端日志里都有一条 `local isolation unavailable: …` 的 error,排查看这条。 + +改权限发生在该进程第一次以 `uid` 模式执行代码时,不是容器启动的瞬间。预检通过后会改两处: + +- `/app/bisheng`(后端代码目录)和 `/app/data`(Compose 对应宿主机 `data/bisheng`)顶层改为 `0700`。依赖以非 root 身份读写 `data/bisheng` 的运维脚本要调整。 +- 宿主机上挂进来的 `config.yaml` 建议设为 `0600`、属主 root。它在 `/app/bisheng` 下面,已经被上一条挡住,这是第二道保险。 + +关闭(`off`)时完全保持原来的行为,首次执行打一条 WARNING。 + +--- + +## 4. 验收与排障 + +### 4.1 连通 + +```bash +# Compose:backend(日常会话、助手、编辑器「运行一次」)和 backend_worker(工作流运行 / 灵思)都要通 +for c in bisheng-backend bisheng-backend-worker; do + docker exec $c getent hosts code-runner-1 + docker exec $c curl -sS -m 5 http://code-runner-1:8080/health +done + +# Helm:worker、backend、linsight 三个 Deployment 都要通 +kubectl -n bisheng-sandbox get sts,svc,po +for d in bisheng-worker bisheng-backend bisheng-linsight; do + kubectl -n bisheng exec deploy/$d -- python -c \ + "import urllib.request; print(urllib.request.urlopen( + 'http://code-runner-0.code-runner.bisheng-sandbox.svc.cluster.local:8080/health', + timeout=5).read())" +done +``` + +`bisheng-linsight` 超时而另外两个正常,通常是沙箱 chart 里的 `allowFrom` 还是旧值,见 2.3。 + +健康检查应返回 `{"status":"ok"}`。真正执行时 runner 日志会出现: + +```text +POST /v1/sessions +PUT /v1/sessions/{id}/files # copy-in +POST /v1/sessions/{id}/exec +GET /v1/sessions/{id}/files # copy-out(仅 exitcode=0) +DELETE /v1/sessions/{id} +``` + +只有 `/health`、没有 `/exec`:代码解释器仍是 `local`,或请求打在了另一套平台上。页面上改执行方式见使用说明。 + +### 4.2 更新镜像 + +用新 tag,再改 values 里的 `image.tag` 后 `helm upgrade`。两张 chart 都要改:平台 chart 的 `backend`、`worker`、`linsight`、`frontend` 各有一个 `image.tag`,沙箱 chart 另有一个。chart 默认 `imagePullPolicy: IfNotPresent`。节点上已经有这个 tag 时,删掉 Pod 只会用本地旧镜像再起,不会去仓库拉新层。Compose 同 2.1:backend、backend_worker、frontend、`code-runner-1` 四处 `image` 一起换,只改 `SANDBOX_IMAGE_TAG` 换不了仓库,也换不到 backend 和 frontend。同一个 tag 重新构建后,要先 `docker compose pull` 再 `up -d`,否则仍用本地旧镜像。 + +在宿主机上 `docker pull` 也不够:Kubernetes 用的是容器运行时自己的镜像库(常见是 containerd),和 Docker 的镜像库不是同一份。 + +chart 里前端的更新策略无条件写死为 `Recreate`。`hostPort` 只在 `frontend.service.clientHostPort` 非空时才挂上,默认为空,不会抢端口;写死 `Recreate` 是为了以后打开 `clientHostPort` 时不用再改策略。配了 `clientHostPort` 后更新仍卡住时,删掉停在 Terminating 的旧 Pod,再等新的 Ready。 + +### 4.3 部署侧常见现象 + +| 现象 | 原因 | 处理 | +|------|------|------| +| 任务模式不打沙箱 | Compose:`backend_worker` 没起来(灵思在它里面),或挂载的入口脚本不是仓库里的 `docker/bisheng/entrypoint.sh`。Helm:没有 `bisheng-linsight` Deployment。两边都可能是两套平台抢了同一个 Redis | 看 2.3,不要再手动起一份;错开 Redis db | +| 勾了代码执行器仍走本机 | `t_gpts_tools_type.extra.type` 为 `local` | 页面改成「沙箱运行」,或把 `extra.type` 改为 `container` | +| 代码节点直接失败,提示 Code node is disabled | 系统配置 `workflow.code_node_enabled` 不是字面量 `true` | 在系统配置里打开。执行方式由 `extra.type` 决定,见 3.3 | +| 日常会话、助手或编辑器「运行一次」选了沙箱仍失败 | backend 没带与 runner 相同的 token,或没同时挂上 `default` 和 `sandbox_net`;Helm 上 `component: backend` 被移出 `allowFrom` | 与 worker 对齐 token,并确认 backend 在沙箱网络的放行列表里 | +| 任务模式或编辑器「运行一次」选了沙箱仍失败(Helm) | 沙箱 release 里的 `allowFrom` 是旧值(升级时用了 `--reuse-values`,或自定义过),缺 `linsight` / `backend`,连 runner 超时 | 重新 `helm upgrade` 沙箱 chart 且不带 `--reuse-values`,或把缺的 component 补进 `allowFrom` | +| 下载 403 | 预签名 Host 与 nginx 反代 Host 不一致 | nginx 反代 MinIO 时 `Host` 设为 `sharepoint` | +| 选了沙箱但没有可用副本 | `BS_SANDBOX_CONF__ENDPOINTS` 为空,或列表里的地址连不上 | 把每台 runner 的地址写进列表;Compose 检查 `sandbox_net`,Helm 检查 Pod DNS 与 `allowFrom` | + +文件写错路径、交付物被丢掉,见 [沙箱使用说明](./沙箱使用说明.md)「产物对不上时」。任务结束后也可以直接查库: + +```sql +SELECT id, status, + JSON_EXTRACT(output_result, '$.final_files'), + JSON_EXTRACT(output_result, '$.invalid_deliverables'), + JSON_EXTRACT(output_result, '$.phantom_deliverables') +FROM linsight_session_version +WHERE id = ''; +``` + +`final_files` 为空且 `invalid_deliverables` 有记录:文件写过但格式不对(例如文本冒充 xlsx)。 +`phantom_deliverables` 有记录:答案里报了文件名,工作区里没有对应文件。 + +--- + +## 5. 安全边界 + +- 口径:挡住误操作和机会主义访问,**不承诺**抗定向内核 0day。 +- runner:只读根文件系统、能力全裁(再加回 SETUID/SETGID/CHOWN/DAC_OVERRIDE 做会话 uid)、cgroup 限额。默认可以访问外网;runner 仍不挂中间件网络、不映射宿主机端口。 +- runner 无中间件凭据、不挂 docker.sock。 +- 本地执行器的 `local_isolation` 在代码里默认是 `uid`:子进程用临时 uid,读不到配置和进程环境变量,但网络仍不隔离。设为 `off` 时与平台同用户、同网络,可以读到工作目录之外的路径。见 3.3。生产应使用 `container`。 + +--- + +## 6. 相关路径 + +| 路径 | 内容 | +|------|------| +| `docker/helm/bisheng-sandbox/` | runner StatefulSet | +| `docker/helm/bisheng/` | 平台 + `BS_SANDBOX_CONF__*` | +| `docker/docker-compose.yml` | `code-runner-1/2` + `sandbox_net` | +| `src/sandbox-runner/` | runner HTTP 实现 | +| `src/backend/bisheng/core/config/settings.py` | `SandboxConf` | +| `src/backend/bisheng_langchain/gpts/tools/code_interpreter/` | `ContainerExecutor` / 发现 | diff --git a/features/v3.0.0-beta1/068-nvdb-security-fixes/design.md b/features/v3.0.0-beta1/068-nvdb-security-fixes/design.md deleted file mode 100644 index 6b9f5b662d..0000000000 --- a/features/v3.0.0-beta1/068-nvdb-security-fixes/design.md +++ /dev/null @@ -1,101 +0,0 @@ -# Design: NVDB 漏洞修复(F068) - -**关联规格**: [spec.md](./spec.md) -**版本**: v3.0.0-beta2 - -> 本文档是 How 的唯一真相。接手时先读这里。 - ---- - -## 0. 调整原则 - -- 每个漏洞只修根因所在的那一层,另加一道"最后一道门"式的防御(接口层拒绝 + 数据层再校验),不顺手重构周边。 -- 不引入新表、新错误码、新对外 API、新前端改动。 -- 代码节点 RCE 根因不在本 Feature(用户明确要求另议)。 - ---- - -## 1. V1 JWT 密钥(AC-01~05) - -**根因**:`Settings.jwt_secret` 在代码里有默认值(2.4 为 `secret`,2.6+ 为 `secret_cF2k…`),且默认部署不覆盖;令牌校验只看 `user_id`,伪造 `user_id=1` 即超管。 - -**决策 1 — 默认值置空,两个历史值列入黑名单。** -`bisheng/core/config/settings.py` `jwt_secret: str = ""`。新模块 `bisheng/user/domain/services/jwt_secret.py` 持有 `LEGACY_JWT_SECRETS`,命中即视为未配置。 - -**决策 2 — 未配置时生成一次、存系统配置表。** -`ConfigKeyEnum.JWT_SECRET = "jwt_secret"`,值为 `secrets.token_urlsafe(48)`。选配置表而非本地文件:api / worker / linsight worker 是不同进程甚至不同节点,必须共用同一份;配置表本来就是它们的共同真相源。首次启动多进程竞争插入时靠 `key` 唯一约束,输家捕获 `IntegrityError` 后回读胜者的值。 - -**决策 3 — 解析顺序与缓存策略。** -`resolve_jwt_secret()`:yaml 值(非黑名单)→ 进程内缓存的生成值 → 读库 / 生成。yaml 值每次实时读、不缓存(读的是内存对象,零成本;也让测试替换 `settings` 后立刻生效),只缓存生成值。`AuthJwt.__init__` 改为调用它;模块内的 DAO / 数据库导入延迟到真正落库时才发生,避免把数据库包拉进早期导入链。 - -**决策 4 — 影响面。** -只有登录 cookie 走这把密钥。PAT / 开放 API 凭据是库表比对,不受影响。升级后旧 cookie 失效 = 全员重登,写进发版说明。 - -**坑 1**:`test/tenant/test_tenant_auth.py` 用"模块未导入时才预置 MagicMock"的方式隔离 `config_service`;合跑时拿到的是真实 settings,密钥为空就会去碰库。已加 autouse fixture 钉住密钥。以后新 JWT 测试一律用 `conftest.mock_settings` 或显式 `monkeypatch settings.jwt_secret`。 - ---- - -## 2. V2 排序参数 SQL 注入(AC-06~08) - -**根因**:`SpaceFileDao.order_field_text()` 把 `order_field` / `order_sort` 原样 f-string 进 `ORDER BY`,`/space/{id}/children` 与 `/space/{id}/search` 直接透传查询参数。 - -**决策 5 — 两道门。** -- 接口层:`knowledge_space_file.py` 定义 `SpaceFileOrderField = Literal["file_type","file_name","file_size","update_time","create_time"]`、`SpaceFileOrderSort = Literal["asc","desc","ASC","DESC"]`,两个端点的参数改用这两个类型 → FastAPI 对集合外值返回 422。 -- 数据层:`order_field_text()` 开头按同一集合校验,不通过抛 `ValueError`。`knowledge_file.py` 里另一处调用同一函数,自动受保护。 - -取值集合来源:client `SortType` 枚举(file_name / file_type / file_size / update_time)+ 既有 `update_time` 默认排序 + `create_time`(表上有列、语义合理)。大小写两种方向都收,是为了不改前端。 - ---- - -## 3. V3 HTML 任意文件读取(AC-09~10) - -**根因**:`HTML2MarkdownConverter.convert()` 把源 HTML 所在目录以 `file://` URI 作为 `base_url`,`_download_media_file()` 对 `file` scheme 直接 `shutil.copy`。于是 `file:///etc/passwd` 与相对 `../../etc/passwd`(`urljoin` 后同样落到 `file://`)都能把服务器文件复制进知识库媒体目录,再经对象存储回传。 - -**决策 6 — 目录围栏而非删分支。** -新增 `_is_local_media_allowed(candidate)`:`candidate.resolve()` 必须位于 `Path(source_html_filepath).resolve().parent` 之下,否则记 warning 并返回 `None`。保留分支是因为"HTML 旁边带一张图"在语义上是合法的(虽然当前上传路径不会产生),删分支收益不大而围栏已足够。`resolve()` 同时处理了符号链接。 - -**未做**:`http/https` 分支的 SSRF 限制(见 spec 排除项)。 - ---- - -## 4. V4 本地路径任意文件读取(AC-11~12) - -**根因**:`core/cache/utils.py` 的 `file_download` / `async_file_download` 第一分支——"字符串去掉 `?` 后是个存在的本地文件就直接返回"。微调预置文件接口把请求体里的 `files` 原样传入,再上传到对象存储。 - -**决策 7 — 在共用工具里修,而不是在微调接口修。** -调用方有 6 处(微调、日常聊天附件、灵思提交文件、知识库预览、开放接口文件库、图片识图);只堵微调等于留 5 个入口。新增 `_ensure_allowed_local_file(path)`:真实路径必须位于 `CACHE_DIR`(`save_download_file` 的落盘目录)或 `tempfile.gettempdir()` 之下,否则 `ValueError`。同步版有两处本地路径出口(首分支与末尾兜底),都加了。 - -**为什么这两个根目录**:追了全部调用方,本地路径只可能来自本进程先前的下载落盘;3.0 的上传接口返回的已是对象存储分享链接,前端从不发本地路径。注释里那句"挂载存储卷时可直接读"的场景没有任何代码路径产生,视为未支持。 - ---- - -## 5. V5 建应用权限绕过(AC-13~14) - -**根因**:`web_menu` 的 `create_app` 只控制前端按钮;`POST /workflow/create` 与 `POST /assistant` 只挂 `@require_quota`。 - -**决策 8 — 新依赖 `LoginUser.get_app_creator_user`。** -超管直接放行;否则 `assert_effective_web_menu_contains(user_id, "create_app")`(既有方法,含部门管理员合并与孤儿菜单剥离,与登录接口返回给前端的 `web_menu` 同源)。两个创建端点把 `Depends(UserPayload.get_login_user)` 换成它。判定口径与 `platform/.../BuildPage/apps.tsx` 的 `canCreateApp` 完全一致(超管 或 菜单含 create_app;部门管理员不再单独放行——前端已如此)。 - -**未做**:复制应用、模板创建走的是同两个端点,天然覆盖;`run_once` 对非 code 节点的权限不动。 - ---- - -## 6. 测试 - -| 文件 | 覆盖 | -|------|------| -| `test/user/test_jwt_secret_resolution.py` | 解析顺序、黑名单、缓存策略、落库、首启竞争、代码无默认值 | -| `test/knowledge/test_space_file_order_whitelist.py` | DAO 白名单拒绝注入串;两个端点参数注解为 Literal 别名 | -| `test/knowledge/rag/test_html_media_local_file_guard.py` | 绝对 `file://`、相对穿越、符号链接均不复制;同目录文件仍复制 | -| `test/core/test_file_download_local_guard.py` | 同步 / 异步两版:缓存目录内放行,目录外 / 穿越 / 符号链接 / 带签名参数一律拒绝 | -| `test/api/test_create_app_permission_gate.py` | 依赖三态;两个创建端点确实挂了该依赖 | - -复现类验证(用报告里的 PoC 打修复后的环境)放在 tasks.md 的 e2e 项。 - ---- - -## 7. 运维与文档 - -- `docker/bisheng/config/config.yaml` 增加注释掉的 `jwt_secret` 示例与说明。 -- `docs/architecture/08-deployment.md` 配置系统一节补 `jwt_secret` 语义;升级 checklist 加 v3.0 条目。 -- Release Notes 三条见 spec §4。 diff --git a/features/v3.0.0-beta1/068-nvdb-security-fixes/spec.md b/features/v3.0.0-beta1/068-nvdb-security-fixes/spec.md deleted file mode 100644 index 58ddc369b7..0000000000 --- a/features/v3.0.0-beta1/068-nvdb-security-fixes/spec.md +++ /dev/null @@ -1,89 +0,0 @@ -# Feature: NVDB 漏洞修复(F068) - -> **本文档定位 — 纯 What(需求口径,不随代码漂移)** -> -> spec 只回答 **做什么 / 验收标准 / 不做什么**。 -> **所有 How 一律不写在这里。** How 的唯一真相在 [design.md](./design.md)。 - -**关联来源**: NVDB(工信部网络安全威胁和漏洞信息共享平台)2026-09-02 批次 7 份漏洞报告(去重后 5 个独立漏洞) -**优先级**: P0(安全发版) -**所属版本**: v3.0.0-beta2(目录沿 F067 先例挂在 `features/v3.0.0-beta1/`) -**依赖**: 无本版本未交付 Feature 前置 - -> **范围边界** -> - **本次纳入**: -> - V1 JWT 签名密钥硬编码,可伪造任意用户(含超管)。 -> - V2 知识空间目录 / 搜索接口的排序参数拼接进 SQL,可时间盲注。 -> - V3 知识库上传 HTML 时,`file://` 与相对路径引用可把服务器任意文件复制进对象存储。 -> - V4 微调「预置训练文件」接口接受任意本地路径,可读服务器任意文件。 -> - V5 前端隐藏「新建应用」入口但后端创建接口不校验,普通用户可绕过直接建应用(工作流代码节点 RCE 链的第一跳)。 -> - **本次明确排除**(已与用户确认,另立 Feature): -> - 工作流代码节点的执行沙箱与权限策略(RCE 根因)。V5 只封第一跳,不改变代码节点本身的执行方式。 -> - HTML 解析时对 `http/https` 外部资源的 SSRF 限制(内网地址、元数据服务)——与本批报告无关的独立问题。 -> - 单节点试运行(`run_once`)接口对非 code 节点的进一步权限收紧。 -> - 向 2.x 旧版本线回移(是否 backport 由发版决策另定)。 - ---- - -## 1. 用户故事 - -作为 **平台运维**, -我希望 **升级后不改任何配置,登录态就不再能被公开密钥伪造**, -以便 **不用逐个客户排查 config.yaml 有没有改过密钥**。 - -作为 **平台管理员**, -我希望 **关掉普通用户的「新建应用」入口后,后端接口同样拒绝**, -以便 **菜单权限是真的权限,而不是只藏一个按钮**。 - -作为 **普通用户**, -我希望 **上传知识库文件、浏览知识空间的正常操作不受影响**, -以便 **安全修复对我是透明的**。 - ---- - -## 2. 验收标准 - -> AC-ID 在本特性内唯一。字段名 / 表结构 / 接口路径等实现细节见 design.md。 - -### 2.1 V1 JWT 密钥 - -- **AC-01** — THE SYSTEM SHALL 不在代码中携带任何可用的 JWT 签名密钥默认值。 -- **AC-02** — WHERE `config.yaml` 配置了 `jwt_secret` 且其值不是历史版本曾内置的默认值, THE SYSTEM SHALL 使用该值签发与校验登录态。 -- **AC-03** — WHERE `config.yaml` 未配置 `jwt_secret`、为空或其值等于历史内置默认值, THE SYSTEM SHALL 在首次需要时生成一个随机密钥并持久化,之后同一部署的所有进程使用同一密钥。 -- **AC-04** — WHEN 部署从携带内置默认值的旧版本升级且未配置 `jwt_secret`, THE SYSTEM SHALL 使旧密钥签发的登录态全部失效(用户需重新登录);个人访问令牌与开放 API 凭据不受影响。 -- **AC-05** — WHEN 运维修改 `config.yaml` 的 `jwt_secret` 或删除已持久化的生成密钥并重启, THE SYSTEM SHALL 以新密钥工作(轮换手段)。 - -### 2.2 V2 排序参数 SQL 注入 - -- **AC-06** — THE SYSTEM SHALL 只接受固定集合内的排序字段(文件类型、文件名、文件大小、更新时间、创建时间)与排序方向(升序 / 降序);集合外的取值在接口层以参数校验失败拒绝,不进入 SQL。 -- **AC-07** — IF 任何调用方把集合外的排序字段或方向传到数据访问层, THEN THE SYSTEM SHALL 抛出错误而不是拼接执行。 -- **AC-08** — THE SYSTEM SHALL 保持现有前端使用的全部排序取值继续可用。 - -### 2.3 V3 HTML 任意文件读取 - -- **AC-09** — WHEN 解析用户上传的 HTML 且其中引用了以 `file://` 或相对路径指向本地文件的媒体, THE SYSTEM SHALL 仅在该文件位于该 HTML 自身所在目录(含子目录,符号链接按真实路径判定)内时复制,否则跳过并记录日志。 -- **AC-10** — THE SYSTEM SHALL 保持 `data:` 内嵌媒体与 `http/https` 远程媒体的既有处理不变。 - -### 2.4 V4 本地路径任意文件读取 - -- **AC-11** — WHEN 文件下载工具收到一个指向本地已存在文件的路径, THE SYSTEM SHALL 仅在该文件位于本进程的下载缓存目录或系统临时目录内(按真实路径判定)时返回,否则拒绝。 -- **AC-12** — THE SYSTEM SHALL 保持对象存储分享链接、带签名的对象存储相对路径、`http/https` 链接的既有下载行为不变。 - -### 2.5 V5 建应用权限绕过 - -- **AC-13** — WHEN 非超级管理员用户调用创建工作流或创建助手的接口且其生效菜单权限不含「新建应用」, THE SYSTEM SHALL 拒绝(403)。 -- **AC-14** — THE SYSTEM SHALL 对超级管理员与生效菜单含「新建应用」的用户保持创建行为不变;判定口径与前端「新建应用」入口的显示条件一致。 - ---- - -## 3. 不做什么 - -见「范围边界 — 本次明确排除」。 - ---- - -## 4. 发版说明要点(写入 Release Notes) - -- 升级后所有用户需重新登录一次(V1)。 -- 若运维曾把老版本代码里的默认 `jwt_secret` 抄进 `config.yaml`,该值会被忽略,行为等同未配置。 -- 关闭了普通用户「新建应用」菜单的租户,普通用户直接调接口建应用会开始返回 403(V5);这是修复而非回归。 diff --git a/features/v3.0.0-beta1/068-nvdb-security-fixes/tasks.md b/features/v3.0.0-beta1/068-nvdb-security-fixes/tasks.md deleted file mode 100644 index 65c338ecee..0000000000 --- a/features/v3.0.0-beta1/068-nvdb-security-fixes/tasks.md +++ /dev/null @@ -1,67 +0,0 @@ -# Tasks: NVDB 漏洞修复(F068) - -**关联规格**: [spec.md](./spec.md) · [design.md](./design.md) -**版本**: v3.0.0-beta2 - ---- - -## 状态 - -| 步骤 | 状态 | 备注 | -|------|------|------| -| spec.md | ✅ 已确认 | 用户在会话中确认修复范围与 JWT 处理口径(2026-09-14);代码节点沙箱另议 | -| design.md | ✅ 已确认 | 同上 | -| tasks.md | ✅ 已拆解 | 3 Wave / 11 项 | -| 实现 | 🔄 进行中 | 后端代码、单测、基线回归完成(commit 16a8c6694);e2e 复现验证与合入待做 | - ---- - -## 开发模式 - -安全修复走轻量轨道:每个漏洞一组"先写会失败的测试 → 修 → 测试通过"。不建 Alembic、不加错误码、不改前端。 - ---- - -## Tasks - -### Wave 1 — 后端修复(可并行) - -- [x] **T001**: JWT 密钥解析模块 + 去掉代码默认值 - **文件**: `bisheng/user/domain/services/jwt_secret.py`(新)、`bisheng/core/config/settings.py`、`bisheng/common/models/config.py`、`bisheng/user/domain/services/auth.py` - **AC**: AC-01、AC-02、AC-03、AC-05 -- [x] **T002**: 排序参数白名单(接口 Literal + DAO 校验) - **文件**: `bisheng/knowledge/domain/models/knowledge_space_file.py`、`bisheng/knowledge/api/endpoints/knowledge_space.py` - **AC**: AC-06、AC-07、AC-08 -- [x] **T003**: HTML 本地媒体目录围栏 - **文件**: `bisheng/knowledge/rag/pipeline/loader/utils/md_from_html.py` - **AC**: AC-09、AC-10 -- [x] **T004**: 文件下载工具本地路径围栏 - **文件**: `bisheng/core/cache/utils.py` - **AC**: AC-11、AC-12 -- [x] **T005**: 建应用权限依赖并挂到两个创建端点 - **文件**: `bisheng/user/domain/services/auth.py`、`bisheng/api/v1/workflow.py`、`bisheng/api/v1/assistant.py` - **AC**: AC-13、AC-14 - -### Wave 2 — 测试与文档 - -- [x] **T006**: 五组单测(见 design §6)+ 修正 `test/tenant/test_tenant_auth.py` 合跑时的密钥来源 -- [x] **T007**: `docker/bisheng/config/config.yaml` 注释示例、`docs/architecture/08-deployment.md` 配置说明与升级 checklist -- [x] **T008**: `release-contract.md` 登记 F068(表 1 无新增领域对象、INV-36、表 3、变更历史) - -### Wave 3 — 验证与发版 - -- [x] **T009**: 基线对比回归:在改动前后的工作树对同一测试选集跑 pytest,确认无新增失败(2026-09-14:14 个目录 3900+ 用例,失败集合与基线一致;唯一差异 `test_user_string_lengths` 单次抖动、单跑通过) -- [ ] **T010**: e2e 复现验证(部署到测试机后用 NVDB 报告 PoC 逐条打): - - 用旧默认密钥伪造 `user_id=1` 的 cookie → 422/401,不再是超管 - - `children?order_field=if(1=1,sleep(5),1)` → 422,且响应无延迟 - - 上传含 `file:///etc/passwd` 与 `../../etc/passwd` 的 HTML → 解析成功但无对应媒体文件 - - `POST /finetune/job/file/preset {"files":"/etc/passwd"}` → 错误,对象存储无新对象 - - 关闭普通用户 `create_app` 后 `POST /workflow/create` / `POST /assistant` → 403 - - 正常路径:普通用户登录、知识空间按四种字段排序、上传普通 HTML、管理员建工作流与助手均正常 -- [ ] **T011**: 发版:合入 `feat/3.0.0-beta2`,Release Notes 写入 spec §4 三条 - ---- - -## 实际偏差记录 - -- 无。 diff --git a/features/v3.0.0-beta1/070-commercial-license-expiry-reminder/design.md b/features/v3.0.0-beta1/070-commercial-license-expiry-reminder/design.md new file mode 100644 index 0000000000..2c50682591 --- /dev/null +++ b/features/v3.0.0-beta1/070-commercial-license-expiry-reminder/design.md @@ -0,0 +1,387 @@ +# Design: 商业授权统一到期提醒(F070) + +> **本文档定位 — 现状快照(Why this How)** +> +> - `spec.md` 回答 **做什么** +> - `design.md`(本文)回答 **为什么这么实现**、三路怎么写入、聚合怎么读 +> +> **关联**: [spec.md](./spec.md) · PRD《3.0 beta2》§5.3 +> **版本**: v3.0.0-beta1 +> **最后更新**: 2026-09-14 + +--- + +## 1. 目标与非目标 + +- **目标**:把 Gateway / ETL / 商业版看板三路授权收成平台级 `license_info` 的当前状态,管理后台超管 Banner 只读聚合接口,文案点名授权对象。 +- **非目标**:见 [spec.md](./spec.md) 范围边界。实现上额外钉死: + - **不反向调用 Gateway**(前端先读 Gateway,再上报后端)。 + - **不向商业看板拉授权**(看板服务自己更新对应行)。 + - **不新增过期降级**(不在 `DashboardService` / ETL 解析路径上新加拦截)。 + - **表里不存密文 / 私钥 / 设备指纹**。 + +--- + +## 2. 关键约束 + +遵循 `docs/constitution.md` C1–C7。本功能特有: + +- **授权是部署级的,不是租户级的。** `license_info` 不得带 `tenant_id`,否则租户过滤器会让超管在子租户上下文里读不到行。 +- **三路写入主体已经裁定,不能合并成「后端去拉三源」:** + - ETL:BISHENG 调 `GET {etl_origin}/api/license_info`(ETL4LM 2.1.0-beta1 §7)后 upsert。 + - 看板:商业看板服务定期更新 `license_code='dashboard'` 那一行。 + - Gateway:前端 `GET /api/license/status`,再 `POST` 给 BISHENG 落库。 +- **ETL 配置地址不是 origin。** `knowledges.etl4lm.url` 形如 `http://host:port/v1/etl4llm/predict`。授权查询必须剥到 origin 再拼 `/api/license_info`。 +- **后端调不到 Gateway。** 商业版拓扑是 `浏览器 → Gateway → Backend`。F037 已否决后端 HMAC 上报;本期仍由前端转发状态。 +- **展示阈值与 F037 的 severity 分档不同。** 本期只保留 `normal / expiring / expired / unknown`,30 天一条线,不再向前端下发 `warning / critical`。 +- **新表走 SQLModel `table=True` + 启动 `create_all`。** 不必为整表创建单独 Alembic revision;`update_time` 用项目统一 server default。实现时错误码模块号登记 **270**(11x 是灵思;11001 是 Gateway 业务码,不是 BISHENG 模块号)。 + +--- + +## 3. 方案对比与选定 + +### 决策 1:授权真相放在 `license_info` 表,不放内存聚合 + +- **备选**: + - A. Banner 请求时并行拉 Gateway / ETL / 看板,不落库。实现快,重启即丢,看板没有可拉的 HTTP。 + - B.(选定)平台级表做当前状态;三路按各自合同写入;聚合接口只读表(读时按到期日重算剩余天数)。 +- **选定**:B +- **原因**:用户已裁定必须有 `license_info`。看板只保证写表,不保证提供查询接口。表还能让 ETL Beat 与超管刷新解耦。 +- **何时该重新考虑**:三源都提供稳定只读 HTTP,且产品不再要求持久化。 + +### 决策 2:Gateway 由前端上报,后端不反调 + +- **备选**: + - A. 后端配置 `gateway.internal_url` 自己打 `/api/license/status`。 + - B.(选定)前端已能打到 Gateway;把 `data` 原样 POST 给后端 upsert。 +- **选定**:B +- **原因**:用户已裁定该路径。A 要新配内部地址,开源 / 配错都会静默失败,且是反向调用。 +- **何时该重新考虑**:出现「无浏览器也要刷新 Gateway 行」的运维需求。 + +### 决策 3:看板只写表,BISHENG 不拉 + +- **备选**: + - A. 为看板再做一个类似 ETL 的 HTTP 适配器。 + - B.(选定)看板服务定期 upsert `dashboard` 行;BISHENG 只读。本期不提供看板专用 HTTP 写入口(与用户裁定一致)。 +- **选定**:B +- **原因**:本仓看板在 `telemetry_search` 进程内,但商业授权由看板侧持有,本仓没有到期字段。写表是已确认合同。 +- **何时该重新考虑**:看板无法直连同一 MySQL/DM8,再补内部 HMAC upsert,复用同一 `upsert` 服务。 + +### 决策 4:读时按 `expire_date` 重算剩余天数和 `display_state` + +- **备选**: + - A. 展示源写入的 `days_remaining` / `display_state`。看板一天写一次时,剩余天数会停住。 + - B.(选定)有 `expire_date` 则按「今天」重算;没有则回退到存下来的 `days_remaining`。永久授权(无到期日且无剩余天数)→ `normal`,Banner 不出现。 +- **选定**:B +- **原因**:阈值是日期事实,不该取决于谁最后写了一次整数。 +- **何时该重新考虑**:产品改成「以源系统当时计算为准、不允许平台重算」。 + +### 决策 5:取源失败不把成功行改成 expired + +- **备选**: + - A. 失败则把该行 `display_state=unknown` 并清空到期日。一次 ETL 抖动,超管会看不到本仍有效或已过期的提醒。 + - B.(选定)失败只打日志,保留上次成功写入;从未成功则无行,聚合视为未取得。 +- **选定**:B +- **原因**:AC-05 禁止把未取得展示成已过期;也不该因为一次超时抹掉仍可用的到期日。 +- **何时该重新考虑**:产品要求「超过 N 小时未成功检查即视为未知并隐藏」,再加 `checked_at` 过期窗口。 + +### 决策 6:ETL 用 Beat 小时级刷新,聚合接口可补拉过期行 + +- **备选**: + - A. 只在超管打开 Banner 时拉 ETL。 + - B. 只靠 Beat。 + - C.(选定)Beat 每小时拉一次(部署级,不按租户循环);超管聚合查询时若 `etl` 行不存在或 `checked_at` 超过 1 小时,同步补拉,单源超时 2–3s。 +- **选定**:C +- **原因**:Banner 不轮询;只靠 Beat 时,刚配上 ETL 的环境可能要等一小时。补拉失败不得拖垮聚合。 +- **何时该重新考虑**:ETL 授权查询成为计费接口或明显变慢。 + +### 决策 7:Banner 可见人用平台超级管理员,不是 `role === "admin"` + +- **备选**: + - A. 维持 F037 的 `user.role === "admin"`。 + - B.(选定)前端闸门与聚合接口都用 `is_global_super`;非超管聚合返回空列表(200)。 +- **选定**:B +- **原因**:PRD 写的是平台超级管理员。`role === "admin"` 在多租户里可能把 Child Admin 算进去。空列表而不是 403,避免刷新刷出错误 Toast。 +- **何时该重新考虑**:产品明确要求子租户管理员也看全局授权。 + +### 决策 8:推翻 F037「前端只直连 Gateway 画 Banner」 + +- **备选**: + - A. 保留 `LicenseBanner` 只读 `/api/license/status`,另做 ETL / 看板。 + - B.(选定)Banner 改为:先(能拿到就)上报 Gateway,再读聚合接口画统一文案。`GET /api/license/status` 仍留给 Gateway 自己的降级链路,Banner 不再单独渲染它。 +- **选定**:B +- **原因**:F037 决策 1 成立的前提是「只有 Gateway 一项」。本期三项必须走同一文案和同一阈值。 +- **何时该重新考虑**:无。这是对本期目标的必要推翻。 + +--- + +## 4. 系统现状(接手必读) + +### 4.1 今天(缺口) + +``` +超管进入 platform MainLayout + → LicenseBanner 挂载(role === "admin") + → GET /api/license/status ← Gateway 自处理,BISHENG 不参与 + → severity ∈ {warning, critical, expired} 才渲染 + → 文案:「软件授权将在 N 天后到期 / 已过期」 + 黄 / 红分档,不写授权名,不写到期日 + +ETL:knowledges.etl4lm.url 只给 Etl4lmLoader POST 解析,无 license 查询 +看板:BISHENG_DASHBOARD_PRO 只是功能开关,telemetry_search 无到期字段 +``` + +锚点:`LicenseBanner.tsx`、`controllers/API/license.ts`、`MainLayout.tsx`、F037 `GET /api/license/status`。 + +### 4.2 改造后数据流 + +``` +写入① ETL + Beat 每小时 / 聚合补拉 + 剥 etl4lm.url → origin + GET {origin}/api/license_info + 映射 → upsert license_code='etl' + +写入② 看板服务 + 定期 UPDATE/INSERT license_code='dashboard' + BISHENG 不发起 + +写入③ Gateway + LicenseBanner 挂载 + GET /api/license/status(silent) + 有 data → POST /api/v1/commercial-licenses/gateway + 映射 → upsert license_code='gateway' + +读取 + GET /api/v1/commercial-licenses/status + 读 license_info(最多 3 行) + 按 expire_date 重算 days_remaining / display_state + 超管返回列表;非超管 [] + LicenseBanner 只渲染 expiring / expired +``` + +### 4.3 表与对外字段 + +**`license_info`(平台级,无 `tenant_id`)** + +| 列 | 类型 | 说明 | +|---|---|---| +| `license_code` | varchar(32) PK | `gateway` / `etl` / `dashboard` | +| `expire_date` | date NULL | 当地日期;永久为 NULL | +| `days_remaining` | int NULL | 源返回值,读时优先用到期日重算 | +| `display_state` | varchar(16) | 写入时带上;读时重算。`normal` / `expiring` / `expired` / `unknown` | +| `source_status` | varchar(32) NULL | Gateway `severity` 或 ETL `license_type` | +| `checked_at` | datetime NOT NULL | 最近一次成功写入 | +| `extra` | JSON | 源字段子集(`version`、`usable`、`expired`…),不含密文 | +| `create_time` / `update_time` | datetime | `update_time` 用统一 server default | + +一行一个模块。upsert 键 = `license_code`。看板不得改其它 code。 + +**读时映射** + +| 条件 | `display_state` | Banner | +|---|---|---| +| 无行;或到期日与剩余天数都空(永久 / 未取得) | `unknown` 或 `normal` | 隐藏 | +| 剩余 > 30 | `normal` | 隐藏 | +| 剩余 1–30 | `expiring` | 展示即将到期 | +| 剩余 ≤ 0 | `expired` | 展示已过期 | + +**ETL `GET /api/license_info` → 行** + +| ETL | 表 | +|---|---| +| `expiration_time`(Unix 秒) | `expire_date` | +| `remaining_days`(已到期为 0,永久为 null) | `days_remaining` | +| `license_type` | `source_status` | +| `expired` / `usable` / `disabled` / `license_type` | `extra` | +| `perpetual` / `execution` 或 `expiration_time=null` | 到期日空,`display_state=normal` | + +**Gateway `data` → 行** + +| Gateway | 表 | +|---|---| +| `expire_day` | `expire_date` | +| `days_remaining`(可为负) | `days_remaining` | +| `severity` | `source_status` | +| `version` / `expired` / `checked_at` | `extra` | +| `version=pro` 且无到期日 | `display_state=normal` | + +Gateway 请求/响应示例(已核实): + +```json +{ + "status_code": 200, + "data": { + "version": "trial", + "expire_day": "2026-07-02", + "days_remaining": -74, + "severity": "expired", + "expired": true, + "checked_at": "2026-09-14T10:00:00.025346068+08:00" + } +} +``` + +**聚合 `GET /api/v1/commercial-licenses/status`** + +```json +{ + "licenses": [ + { + "license_code": "etl", + "license_name": "etl", + "display_state": "expiring", + "expire_date": "2026-09-30", + "days_remaining": 16, + "checked_at": "2026-09-14T10:00:00+08:00" + } + ], + "checked_at": "2026-09-14T10:05:00+08:00" +} +``` + +`license_name` 与 code 相同,展示名走 i18n。未部署且无行的源不出现。 + +**上报 `POST /api/v1/commercial-licenses/gateway`** + +- 需登录且 `is_global_super` +- body = Gateway `data`(上表字段) +- 只 upsert `gateway` + +### 4.4 模块职责 + +| 模块 / 文件 | 职责 | 不做什么 | +|---|---|---| +| `bisheng/commercial_license/` 新领域模块 | 模型、upsert、ETL 同步、聚合、Gateway 上报 | 不解密、不拦业务、不写看板行 | +| Beat `refresh_etl_license` | 小时级拉 ETL | 不按租户循环 | +| 看板服务(仓外) | 写 `dashboard` 行 | 不改 `gateway` / `etl` | +| `LicenseBanner` + `license.ts` | 上报 Gateway → 读聚合 → 统一文案 | 不再按 Gateway severity 分色 | +| `MainLayout` | 仅 `is_global_super` 挂 Banner | — | +| Gateway 仓 | 保持 `/api/license/status` 与 11001 | 不负责 ETL / 看板 | + +建议目录: + +``` +commercial_license/ + api/endpoints/status.py + api/endpoints/gateway_report.py + domain/models/license_info.py + domain/repositories/... + domain/services/upsert.py + domain/services/aggregator.py + domain/services/etl_sync.py + domain/mappers.py +``` + +路由挂 `/api/v1/commercial-licenses`。 + +### 4.5 前端文案 + +统一样式(不再分黄/红)。多项用 `;` 连接,最后追加续期句。建议 key: + +```json +{ + "license": { + "name": { + "gateway": "Gateway 授权", + "etl": "ETL 授权", + "dashboard": "商业版看板授权" + }, + "expiring": "{{name}}将于 {{date}} 到期,剩余 {{days}} 天", + "expired": "{{name}}已过期", + "renewHint": "请联系授权提供方续期。", + "businessExpired": { + "gateway": "Gateway 授权已过期,当前无法使用该能力", + "etl": "ETL 授权已过期,当前无法解析该文件", + "dashboard": "商业版看板授权已过期,当前无法访问该看板" + } + } +} +``` + +去掉 `FORCE_DEBUG_VISIBLE`。11001 拦截器改为使用服务端文案或 `businessExpired.gateway`,不再写死「软件授权已过期」。`api_errors.11001` 源文件同步点名 Gateway(改 `packages/locales`,不手改 gen)。 + +--- + +## 5. 已知坑 / 反直觉事实 + +| # | 反直觉事实 | 如果不知道会怎样 | 在哪处理 | +|---|---|---|---| +| 1 | `etl4lm.url` 是 predict 全路径,不是 origin | 在后面拼 `/api/license_info` 会 404,`etl` 行永远不出现 | `etl_sync` 剥 origin | +| 2 | 租户过滤器会拦截所有带 `tenant_id` 的 SELECT | 给授权表加 `tenant_id` 后,子租户上下文读不到全局行 | 表不加该列 | +| 3 | F037 Banner 直连 Gateway,开源 404 | 继续只读 Gateway,ETL / 看板永远不上 Banner;开源可能打出噪声 | Banner 改聚合;Gateway 用 `silent` | +| 4 | platform 11001 拦截器丢弃 `status_message`,强制 `t('license.expired')` | Gateway 改了文案也仍显示「软件授权」 | 拦截器走点名文案 | +| 5 | ETL 已到期是 `remaining_days=0`,Gateway 可以是负数 | 按「只有负数才过期」会漏掉 ETL | `<= 0` 均为 expired | +| 6 | 看板若写错 `license_code` 会覆盖其它源 | Gateway 行被看板刷掉 | 文档钉死枚举;本仓 upsert 校验 code | +| 7 | Beat 默认按租户扫是本仓常见写法 | 授权被扫成「每个租户拉一次 ETL」 | 部署级一次,必要时 `bypass_tenant_filter` | +| 8 | `role === "admin"` ≠ 平台超管 | 子租户管理员看到全局授权 Banner | 决策 7 | +| 9 | 架构文档 11-gateway §9.1 仍写过期 `System.exit` | 按旧文档会以为进程已死、状态接口不存在 | 以 F037 + 现网 `/api/license/status` 为准 | + +--- + +## 6. 对外契约与依赖 + +### 6.1 我提供给别人的(Outgoing) + +| 契约 | 形式 | 谁在用 | +|---|---|---| +| `license_info` 表 | MySQL/DM8,一行一个 `license_code` | 看板服务写 `dashboard`;BISHENG 写 `etl` / `gateway` 并读聚合 | +| `GET /api/v1/commercial-licenses/status` | HTTP | platform `LicenseBanner` | +| `POST /api/v1/commercial-licenses/gateway` | HTTP,body = Gateway `data` | platform `LicenseBanner` | +| 错误码模块 270 | `common/errcode/`,实现时写入 constitution C5 | 上报校验失败等 | + +看板写表示例: + +```sql +INSERT INTO license_info + (license_code, expire_date, days_remaining, display_state, source_status, checked_at, extra) +VALUES + ('dashboard', '2026-09-30', 20, 'expiring', NULL, NOW(), '{}') +ON DUPLICATE KEY UPDATE + expire_date = VALUES(expire_date), + days_remaining = VALUES(days_remaining), + display_state = VALUES(display_state), + checked_at = VALUES(checked_at), + extra = VALUES(extra); +``` + +看板可以只写 `expire_date` + `checked_at`,其余由读时重算。 + +### 6.2 我依赖别人的(Incoming) + +| 依赖 | 形式 | 风险点 | +|---|---|---| +| Gateway `GET /api/license/status` | 前端经网关访问 | 字段改名会破坏上报映射;开源 404 必须静默 | +| ETL4LM 2.1.0-beta1 `GET /api/license_info` | BISHENG 服务端访问 | 旧 ETL 无此接口 → 失败不覆盖;url 必须能剥 origin | +| 看板服务定期写表 | 同库 upsert | 看板未上线则无 `dashboard` 行,属正常 | +| `knowledges.etl4lm.url` | DB/YAML 知识库配置 | 空 = 不拉 ETL | +| `UserPayload.is_global_super` | 登录身份 | 闸门不得退回 `role === "admin"` | + +`GET /api/license/status` 保留,供 Gateway 降级与本期上报;Banner 不再单独渲染它。 + +--- + +## 7. 测试与可观测 + +- **映射单测**:Gateway 31/30/1/0/-74;ETL `remaining_days=0`、永久 `expiration_time=null`;失败不覆盖已有行;剥 origin。 +- **API**:超管看到临期 / 过期项;非超管 `licenses=[]`;无行不出现对应 code。 +- **前端**:单项 / 多项文案;unknown 不展示;开源无 Banner、无 Toast。 +- **手动**:构造 Gateway trial 过期 → 上报后 Banner 写「Gateway 授权已过期」;mock ETL `remaining_days=16` → 出现 ETL 即将到期句;向表写入 `dashboard` 过期行 → 同一 Banner 逐项列出;停 ETL 再刷,不得变成「ETL 已过期」(除非上次到期日已过)。 +- **日志**:ETL 拉取失败、上报非法 code、剥 origin 失败打 INFO/WARNING,带 `license_code`,不含密文。 + +--- + +## 8. 后续改进 / 不打算做的事 + +- 看板无法直连库时的内部 HMAC upsert:等部署形态出现再补,复用 `upsert.py`。 +- `checked_at` 过期窗口(久未检查视为 unknown):本期按决策 5 保留上次成功行。 +- 订阅信息服务授权、续期入口、邮件 / 飞书、看板 / ETL 新降级:PRD 排除。 +- 后端反调 Gateway:与决策 2 相反,除非出现无浏览器刷新需求。 + +--- + +## 修订历史 + +| 日期 | 改动 | 触发原因 | +|---|---|---| +| 2026-09-14 | 初版 | 《3.0 beta2》§5.3 + 用户裁定:`license_info` 表、ETL 拉接口写入、看板写表、Gateway 前端上报、后端聚合查询 | diff --git a/features/v3.0.0-beta1/070-commercial-license-expiry-reminder/spec.md b/features/v3.0.0-beta1/070-commercial-license-expiry-reminder/spec.md new file mode 100644 index 0000000000..d1f5bdcda9 --- /dev/null +++ b/features/v3.0.0-beta1/070-commercial-license-expiry-reminder/spec.md @@ -0,0 +1,116 @@ +# Feature: 商业授权统一到期提醒(F070) + +> **本文档定位 — 纯 What(需求口径,不随代码漂移)** +> +> spec 只回答 **做什么 / 验收标准 / 不做什么**。 +> **所有 How 一律不写在这里。** How 的唯一真相在 [design.md](./design.md)。 + +**关联 PRD**: 《3.0 beta2》§5.3 统计看板/ETL到期提醒 +**优先级**: P1 +**所属版本**: v3.0.0-beta1 +**依赖**: v2.6.0 F037(Gateway 状态接口与管理后台 Banner 入口);ETL4LM 2.1.0-beta1 授权查询能力;商业看板服务写入对应授权记录。无本版本未交付 Feature 前置。 + +> **范围边界** +> - **本次纳入**: +> - 平台统一提醒 Gateway 授权、ETL 授权、商业版看板授权;提示中写明授权名称、到期日期和剩余天数。 +> - 各商业模块的当前授权状态持久保存,每个模块至多一条当前记录(已裁定:`license_info`)。 +> - ETL 授权由平台向 ETL 授权查询接口拉取后写入上述记录。 +> - 商业看板授权由看板服务定期更新对应记录;平台不向看板拉授权。 +> - Gateway 授权由管理后台先读取 Gateway 状态接口,再交给平台写入对应记录;平台不反向调用 Gateway。 +> - 管理后台从平台聚合查询读取全部授权状态,供超管 Banner 使用。 +> - **本次明确排除**: +> - 订阅信息服务授权(PRD 本期不纳入)。 +> - 在线续期、授权文件上传、邮件或飞书通知。 +> - 授权过期后的新功能降级(Gateway 既有付费接口降级保持不动;不新增看板 / ETL 过期拦截)。 +> - 用 ETL 服务地址、`BISHENG_DASHBOARD_PRO` 等配置推断授权是否有效或何时到期(它们只表示装没装)。 +> - 开源版(无对应商业模块)展示到期 Banner。 + +--- + +## 1. 用户故事 + +作为 **平台超级管理员**, +我希望 **Gateway、ETL、商业版看板任一授权临近到期或已过期时,在管理后台顶部看到写明名称、到期日和剩余天数的提醒**, +以便 **立刻知道该续哪一项,而不是对着「软件授权」猜测**。 + +作为 **普通用户**, +我希望 **使用因授权过期而受限的能力时,错误指出具体是哪一项授权**, +以便 **把问题交给管理员续对的那一项,而不是当成系统故障**。 + +--- + +## 2. 验收标准 + +> AC-ID 在本特性内唯一。字段名 / 表结构 / 接口路径等实现细节见 design.md。 + +### 2.1 提醒规则与可见性 + +- **AC-01** — THE SYSTEM SHALL 只把 Gateway 授权、ETL 授权、商业版看板授权纳入本期统一提醒;不得把订阅信息服务授权纳入。 +- **AC-02** — WHERE 某项授权剩余天数大于 30, THE SYSTEM SHALL 不为该项展示到期提醒。 +- **AC-03** — WHERE 某项授权剩余天数为 1 至 30(含 1 与 30), THE SYSTEM SHALL 为该项展示即将到期提醒,并写明授权名称、到期日期和剩余天数。 +- **AC-04** — WHERE 某项授权剩余天数小于或等于 0, THE SYSTEM SHALL 继续展示同一条提醒,文案明确为该项「已过期」,并写明授权名称。 +- **AC-05** — IF 某项授权的状态未能取得, THEN THE SYSTEM SHALL 不得把该项展示为已过期,也不得用功能开关推断它已过期或仍有效。 +- **AC-06** — WHEN 平台超级管理员登录或刷新管理后台, THE SYSTEM SHALL 获取一次聚合授权状态;页面持续打开期间不得轮询。 +- **AC-07** — WHEN 非平台超级管理员进入管理后台, THE SYSTEM SHALL 不展示到期提醒 Banner。 +- **AC-08** — THE SYSTEM SHALL 不按剩余天数区分黄色预警、红色紧急等展示级别;即将到期与已过期使用同一种 Banner 样式。 +- **AC-09** — THE SYSTEM SHALL 不提供关闭 Banner 的入口;Banner 在当前页面会话中持续展示。 + +### 2.2 文案 + +- **AC-10** — WHEN 仅有一项授权需要处理, THE SYSTEM SHALL 直接展示完整信息,语义等价于:「{授权名称}将于 {到期日期} 到期,剩余 {N} 天。请联系授权提供方续期。」或该项已过期的对应完整句。 +- **AC-11** — WHEN 多项授权同时需要处理, THE SYSTEM SHALL 在同一条 Banner 中逐项写明授权名称和状态,不得只展示汇总数量。 +- **AC-12** — THE SYSTEM SHALL 使 Banner 与业务错误中的授权名称使用业务用词(Gateway 授权 / ETL 授权 / 商业版看板授权),不得再使用「软件授权」这类无法区分对象的通用文案。 +- **AC-13** — THE SYSTEM SHALL 为新增用户可见文案提供 zh-Hans / en / ja,且不在源码中硬编码中文。 + +### 2.3 状态来源与持久化 + +- **AC-14** — THE SYSTEM SHALL 为每个纳入本期的商业模块持久保存至多一条当前授权状态;聚合提醒只读取这些已保存的标准状态。 +- **AC-15** — THE SYSTEM SHALL 不接收、不存储授权密文、私钥或设备指纹。 +- **AC-16** — WHERE ETL 解析服务已配置, THE SYSTEM SHALL 通过 ETL 提供的授权查询接口取得状态并写入对应记录;IF 该接口失败或未配置 ETL, THEN THE SYSTEM SHALL 按 AC-05 处理 ETL 项,且不得覆盖为已过期。 +- **AC-17** — WHERE 商业看板服务会更新对应记录, THE SYSTEM SHALL 在聚合时读取该记录,不得由平台向看板服务拉取授权。 +- **AC-18** — WHEN 管理后台能够从 Gateway 状态接口取得授权数据, THE SYSTEM SHALL 由平台把该数据写入 Gateway 对应记录后再纳入聚合;IF Gateway 状态接口不可达(含开源部署), THEN THE SYSTEM SHALL 按 AC-05 处理 Gateway 项。 +- **AC-19** — WHEN 平台超级管理员请求聚合授权状态, THE SYSTEM SHALL 返回各项的授权标识、展示用授权名、展示状态、到期日期、剩余天数和最近检查时间;展示状态只允许「正常 / 即将到期 / 已过期 / 未取得」四档,不得再下发预警、紧急等展示等级。 +- **AC-20** — WHEN 非平台超级管理员请求聚合授权状态, THE SYSTEM SHALL 不返回可供展示的到期项(不得 403 打断页面)。 + +### 2.4 业务错误点名 + +- **AC-21** — WHEN 普通用户因某项商业授权过期而无法使用对应能力, THE SYSTEM SHALL 使业务错误指出该具体授权(例如「商业版看板授权已过期,当前无法访问该看板」),并携带可区分授权对象的标识。 +- **AC-22** — THE SYSTEM SHALL 不在本期新增看板或 ETL 过期后的功能拦截;仅当既有能力已经拒绝时,按 AC-21 点名。Gateway 既有过期拦截保持有效,但文案须满足 AC-12。 + +### 2.5 回归 + +- **AC-23** — WHERE 开源部署没有 Gateway、未配置 ETL、也没有商业看板授权记录, THE SYSTEM SHALL 不展示到期 Banner,且不因状态接口不可达而弹出错误。 +- **AC-24** — THE SYSTEM SHALL 保持 Gateway 既有「只拦网关付费接口、透传平台核心接口」的降级范围不变。 + +--- + +## 3. 边界情况 + +- 永久有效的授权(无到期日):不展示到期提醒。 +- 某源从未成功写入过记录:该项视为未取得,不展示。 +- 某源本次拉取失败、但上次成功写入过到期日:不得改写成已过期;提醒是否出现以可读的到期信息为准,见 design.md。 +- ETL 已到期时剩余天数为 0;Gateway 剩余天数可为负数。两者都按 AC-04 展示已过期。 +- 只部署了其中一部分商业模块:只提醒已部署且状态已取得、且落入 30 天阈值的项。 +- 跨过 30 天阈值后,须等下一次登录或刷新才出现或消失 Banner(AC-06)。 +- 子租户管理员不是平台超级管理员:不展示 Banner(AC-07)。 + +--- + +## 4. 设计与实现(指针,不复制) + +| 你想知道 | 去哪看 | +|---|---| +| 为什么用表做真相、三路写入怎么拆 | design.md §3 | +| 表结构、接口、字段映射、Beat | design.md §4、§6 | +| ETL 地址带 predict 路径、租户过滤、失败不覆盖 | design.md §5 | +| 任务拆解 | [tasks.md](./tasks.md) | + +--- + +## 相关文档 + +- 设计真相: [design.md](./design.md) +- 版本契约: [../release-contract.md](../release-contract.md) +- PRD: 《3.0 beta2》§5.3 +- ETL 授权查询: ETL4LM 2.1.0-beta1 接口文档 §7 `GET /api/license_info` +- 现状基线: v2.6.0 [F037 Gateway 到期提醒](../../v2.6.0/037-gateway-license-expiry-reminder/design.md) diff --git a/features/v3.0.0-beta1/067-commercial-license-expiry-reminder/tasks.md b/features/v3.0.0-beta1/070-commercial-license-expiry-reminder/tasks.md similarity index 100% rename from features/v3.0.0-beta1/067-commercial-license-expiry-reminder/tasks.md rename to features/v3.0.0-beta1/070-commercial-license-expiry-reminder/tasks.md diff --git a/features/v3.0.0-beta1/071-knowledge-space-chat-history-retention/design.md b/features/v3.0.0-beta1/071-knowledge-space-chat-history-retention/design.md new file mode 100644 index 0000000000..3d7c0c2114 --- /dev/null +++ b/features/v3.0.0-beta1/071-knowledge-space-chat-history-retention/design.md @@ -0,0 +1,413 @@ +# Design: F071 知识空间问答与目录解耦、历史对话按空间保留 + +> **本文档定位 — 现状快照(Why this How)** +> +> - [spec.md](./spec.md) 定义做什么和验收边界。 +> - 本文定义为什么采用 `MessageSession.entry_flow_id` 作为展示入口覆盖、删除/清空/移动如何触发回收、存量数据与并发如何处理。 +> - `tasks.md` 在 Design ★ 确认后创建,记录实施顺序与实际偏差。 + +**关联**: [spec.md](./spec.md) · [discovery.md](./discovery.md) · [release-contract.md](../release-contract.md) +**版本**: v3.0.0-beta1 +**最后更新**: 2026-09-18 +**状态**: ✅ best-effort 异步收敛及 `clear_space` 范围方案已于 2026-09-18 确认;`tasks.md` 已生成 + +--- + +## 1. 目标与非目标 + +- **目标**:在不改写既有会话和消息 `flow_id` 的前提下,为知识空间问答会话增加可变的展示入口;资源删除、清空知识空间内容或跨空间移出后,以定向异步任务 best-effort 回收到原空间根目录,正常收敛后历史可见且可按全空间范围继续问答。 +- **非目标**:不新建会话作用域表或在线回收 outbox,不重写通用会话体系,不移动或复制消息,不把历史带到目标空间,不恢复用户删除内容,不改变其它会话类型,也不为低概率并发/派发故障引入分布式锁、会话创建复查或全量巡检。 + +--- + +## 2. 关键约束与 Constitution Check + +- 遵循 [docs/constitution.md](../../../docs/constitution.md) C1–C8;知识域 Service 负责编排,新增 `KnowledgeChatSessionRepository` 负责 F071 会话查询与批量更新,资源授权仍只经 F048;不为新功能扩展 legacy `MessageSessionDao` entry point。 +- 遵守版本契约 **INV-36**:原空间、本人会话、根目录回收、可继续问答、幂等、存量恢复、同空间不触发和回收后不回绑同时成立。 +- `message_session.flow_id` 与 `chat_message.flow_id` 是已有内容链标识,多个会话类型和历史查询都依赖二者相等;F071 不改写它们。 +- `MessageSession.update_time` 当前不是“最后一条消息时间”:知识空间写入/删除消息不会 touch session,知识空间会话列表固定按 `create_time` 排序;更新 `entry_flow_id` 会按 MySQL/DM8 既有机制刷新 session `update_time`,这是会话元数据变化,不影响消息时间、消息顺序或当前列表顺序。 +- `MessageSession.tenant_id` 是用户叶子租户,不等于资源拥有租户;列表继续按 session tenant/owner 过滤,空间可见性由业务权限校验决定。 +- 文件/文件夹删除是硬删除,跨空间移动保留资源 ID 但修改 `knowledge_id`;存量恢复依赖旧 `flow_id` 解析原空间和原资源类型/ID。 +- 同一文件夹可有多个会话,同一文件当前通常复用一个会话;不得假定 `(space, entry)` 唯一,会话唯一键始终是 `chat_id`。 +- Alembic 只增加 nullable 列和索引;存量识别/回填由 `src/backend/scripts/` 独立执行,默认 dry-run。 +- F071 不新增对外 URL、响应字段、错误码段或领域表;存量入口不可判定时失败关闭并记录结构化错误。 +- 在线删除、`clear_space` 与跨空间移动的会话入口回收采用 best-effort 异步收敛:资源操作不等待任务完成,派发失败也不改变资源结果;允许短暂或在派发/执行故障后持续无展示入口。任务只处理本次操作冻结的 flow 集合,不建设 durable outbox,也不周期扫描全量知识空间会话。 +- 知识空间会话创建、既有重命名和消息生成链路保持原样,不增加锁、资源二次校验或回收状态感知。 + +### Constitution Check + +| 条款 | 结论 | 设计落实 | +|---|---|---| +| C1 分层 | 通过 | endpoint/worker → Service → `KnowledgeChatSessionRepository` → DB;Service 不直接写 ORM,不扩展 legacy DAO | +| C2 双 DB | 通过 | nullable `VARCHAR` 列、标准索引和 set-based UPDATE;MySQL/DM8 均验证 | +| C3 多租户 | 通过 | 在线列表走 tenant-aware 模型;资源回收是对所有会话所有者的受控系统 fan-out,Repository 仅消费资源操作冻结的精确 flow 集合,并以知识空间 flow type、活动状态和 null entry 限定更新;迁移逐租户处理 | +| C4 权限 | 通过 | 根入口先校验原空间,普通目录/文件入口继续校验具体资源;`entry_flow_id` 不产生 ALLOW | +| C5 错误码 | 通过 | 不新增错误码,复用知识空间/会话既有错误与通用服务错误 | +| C6 密钥 | 不涉及 | 脚本报告不输出配置密码或消息内容 | +| C7 前端 store | 通过 | 前端仍经 `chatApi.ts`,不新增 store HTTP | +| C8 多节点 | 通过 | `message_session.entry_flow_id` 是 DB 中的唯一入口覆盖;定向任务经共享队列执行,不以 React 内存、节点本地文件或进程缓存判定归属 | + +--- + +## 3. 方案对比与选定 + +### 决策 1:用会话字段覆盖展示入口,不新增投影表 + +- **备选**: + - A. 删除/清空/移动时把 `message_session`、`chat_message` 和 `message_citation` 的 `flow_id` 全部改为根目录——需要跨三张表一致更新,并与生成中的迟到消息竞态。 + - B. 新增 `knowledge_chat_scope` 表——结构化能力最完整,但当前只需要决定展示入口,会增加表、join、双写、完整性校验和迁移面。 + - C. 在 `message_session` 增加 nullable `entry_flow_id`;null 表示入口沿用 `flow_id`,回收时只写根目录 flow ID。 +- **选定**:C。`flow_id` 是内容链标识,`entry_flow_id` 是可见入口覆盖;两者职责严格分开。 +- **原因**:当前业务只需要“入口是否仍跟随原 flow,还是固定到原空间根目录”这一位状态。原空间和原资源仍能从不可变 `flow_id` 得到,无需为恢复原因、时间等非当前需求字段建表。 +- **何时该重新考虑**:只有产品要求多种独立作用域、展示恢复来源、全局历史或复杂入口生命周期时,才升级为结构化 SessionScope 表;当前不提前设计。 + +### 决策 2:入口查询使用显式覆盖语义 + +- **有效入口**:`effective_entry_flow_id = entry_flow_id if entry_flow_id is not null else flow_id`。 +- **选定**:知识空间列表和会话打开先按 effective entry 验证位置;消息历史和持久化始终使用真实 `session.flow_id`。 +- **查询形态**:避免 `COALESCE` 让索引整体失效,Repository 使用两个互斥分支合并并统一按 `create_time DESC` 排序: + +```sql +entry_flow_id = :target +OR (entry_flow_id IS NULL AND flow_id = :target) +``` + +- **原因**:原生根目录会话命中第二分支,回收会话命中第一分支;一个 chat 只会命中一支,不会重复。 +- **何时该重新考虑**:若数据量和双方言执行计划证明 OR 无法稳定使用索引,可改为两个索引查询 `UNION ALL` 后外层分页;语义不变。 + +### 决策 3:资源 DB 变更提交后 best-effort 定向回收 + +- **备选**: + - A. 删除/清空/移动与会话入口处于同一事务或以分布式锁阻断新会话——一致性强,但扩大正常业务链路、增加长事务或大子树锁成本。 + - B. 周期任务全量扫描所有知识空间会话——无需传递影响集合,但绝大多数行无需修复,扫描成本与历史总量线性增长。 + - C. 复用资源操作已计算的受影响资源集合,在对应资源 DB 变更提交后按 flow 分片尝试投递短延时回收任务。 +- **选定**:C。删除、清空空间内容和跨空间移动不等待会话回收;文件/目录硬删除的触发点是该次 `KnowledgeFileDao.adelete_batch(...)` 提交返回后,`clear_space` 的触发点是 `KnowledgeDao.async_delete_knowledge(..., only_clear=True)` 提交返回后,跨空间移动的触发点是 `move_items` 元数据 `session.commit()` 返回后。F071 只固定自身派发点,不重排既有文件清理、channel binding 清理、空间更新时间、权限或检索迁移动作;`clear_space` 派发位于既有空索引重建之前,跨空间移动派发仍位于既有 post-commit 权限与检索迁移之前。 +- **执行方式**:派发在隔离的 `try/except` 中 best-effort 执行,失败只记录日志,不改变资源操作结果。F071 沿用平台现有 Celery 发布配置,本期不单独增加 broker 发布超时、发布重试强约束或可用性门禁。成功入队的任务默认短延时 5 秒,以覆盖“资源校验完成、会话稍后提交”的常见竞态;flow 默认每 500 个一批,执行索引驱动的幂等 set-based UPDATE,不读取或更新 `chatmessage/message_citation`。任务使用 `acks_late` 和有界重试提高已入队任务的成功率;不启动全量定时巡检。 +- **失败方向**:本期不建设 durable operation/outbox,不承诺资源提交与 Celery 入队原子,也不承诺派发失败或 worker 终态失败一定自动恢复。失败不回滚、不中断已提交的资源删除、清空或移动;会话和消息仍在原内容链,但入口可能持续不可见。能捕获的派发/执行失败记录 source space、reason 和精确 flow chunk,供人工重试或受控运行迁移脚本;进程在提交后、记录/入队前退出仍属于已接受的 best-effort 缺口。 +- **原因**:并发创建窗口很短且现状已存在竞态,业务接受 best-effort 异步收敛;定向延时任务不修改会话创建链路,也不让上万文件的目录产生上万把锁或全库扫描。 +- **何时该重新考虑**:若线上持续失联超过可接受水平、回收 SLA 成为产品承诺,或 best-effort 缺口无法通过运维修复,再引入 durable operation/outbox 或更强一致性,不预先改造正常问答链路。 + +### 决策 4:接受并发残余窗口,生成继续写原内容链 + +- **选定**:删除、清空和移动不取消在途生成,也不为 F071 修改新建会话。迟到消息继续写原 `chat_id/flow_id`;延时任务执行时已提交且命中受影响 flow 的 session 会被回收到 root,任务扫描之后才提交的极低概率 session 允许保留现状竞态并进入运维修复范围。 +- **原因**:知识空间 session 在 RAG 生成前创建,常见迟到写入可由短延时覆盖;消息写入不会更新 session,首次标题生成使用字段级 UPDATE,不会清空 `entry_flow_id`。用 best-effort 收敛换取不增加创建校验、分布式锁和跨业务事务。 +- **额外约束**:已回收资源日后移回原空间时,单文件会话查找不得只按原 `flow_id` 复用 recovered session;必须要求 effective entry 仍等于当前文件入口,否则创建新会话。 +- **何时该重新考虑**:若产品要求删除资源立即终止相关回答,需要独立内容治理 Feature 明确终止和历史裁剪语义。 + +### 决策 5:存量只更新真正失联的活动会话 + +- **备选**:Alembic 全量 backfill、运行时懒修复、独立脚本识别失联会话。 +- **选定**:Alembic 只加 nullable 字段;独立 DB-only 脚本扫描知识空间 session,仅对“原资源不存在或已属于其它空间”的活动会话写 root `entry_flow_id`。正常 root/folder/file 会话保持 null。 +- **原因**:nullable override 让正常存量天然兼容,无需更新每一条 `message_session`,减少锁和 `update_time` 刷新范围;同时一次性恢复未访问用户的失联历史。 +- **何时该重新考虑**:若实测数据量超过维护窗口,脚本可带 checkpoint 分批执行;仍须保留 checksum、幂等和启用门禁。 + +### 决策 6:前端不新增视图或归属状态 + +- **选定**:复用现有根目录会话列表、历史面板和输入框;后端返回既有 `FolderSession`,内部 `entry_flow_id` 不进入响应。 +- **原因**:现有 `useFolderChat` 已按 space/folder 重新加载;前端只需在删除、清空或跨空间移动成功后刷新源空间根会话列表,不保存入口真相。 +- **何时该重新考虑**:产品后续要求显示“来自已删除目录”或全局历史时,再新增只读展示合同;当前不暴露内部字段。 + +--- + +## 4. 系统现状与目标结构(接手必读) + +### 4.1 当前基线 + +- folder flow 为 `space_{space_id}_folder_{folder_id}`,根目录固定 `folder_0`;单文件 flow 为 `space_{space_id}_file_{file_id}`。 +- `get_chat_folder_session/history/chat_folder` 和 `single_file_history` 先校验当前空间/资源,再精确匹配 `flow_id`;资源硬删除或 `knowledge_id` 改到目标空间后入口失配。 +- `ChatSessionService.get_chat_history` 同时要求 session.flow_id 和 message.flow_id 相等,因此不能用根目录 flow 替换内容链 flow。 +- 知识空间消息新增、清空不会调用 `MessageSessionDao.touch_session`;首次生成标题会调用 `update_session_name`。知识空间会话 DAO 按 `create_time DESC` 排序。 +- `delete_folder`/`batch_delete` 不处理会话;`move_items` 跨空间会修改整棵子树和版本链的 `knowledge_id`。 +- `useFolderChat(spaceId, folderId)` 本地只记忆当前选择,不能作为恢复真相。 + +### 4.2 `MessageSession` 增量字段 + +不新增表。在既有 `message_session` 增加: + +| 字段 | 类型 / 约束 | 语义 | +|---|---|---| +| `entry_flow_id` | `VARCHAR(255) NULL` | 知识空间会话的展示入口覆盖;null 表示沿用 `flow_id`,非空时本期只能是该会话原空间的 `space_{space_id}_folder_0` | + +索引:新增 `idx_message_session_entry_flow_id(entry_flow_id)`;保留既有 `flow_id`、`user_id`、`tenant_id` 和 `create_time` 索引。实施时以 MySQL/DM8 的列表执行计划决定是否需要包含 tenant/user/flow_type 的复合索引,不预设超宽索引。 + +字段不进入 `FolderSession` 或其它公共响应;若当前 endpoint 直接序列化 ORM,必须改用显式响应 schema/字段选择,避免扩展既有 API。 + +状态约束: + +- 只有 `FlowType.KNOLEDGE_SPACE` 可以写 `entry_flow_id`;其它会话类型必须保持 null。 +- 普通 root/folder/file 会话保持 null;本期非空值只表示 recovered root。 +- `entry_flow_id` 中的 space 必须与原 `flow_id` 中的 space 相同,目标空间不得写入。 +- 已有非空入口后,删除、重复移动或资源移回不得清空/覆盖,保证 sticky 和幂等。 +- 完整删除知识空间沿用现状,不清理旧 session 或已有 `entry_flow_id`;此前已排队的 best-effort 任务即使在空间删除后执行,也允许留下指向已删除空间的失效入口元数据。空间权限/存在性校验保证该入口不可访问。 +- 写入口可以刷新 `MessageSession.update_time`;不得修改 `create_time`、原 `flow_id`、消息/引用内容及其时间。 + +### 4.3 在线数据流 + +#### 新建和查找会话 + +- 新建知识空间会话仍只写原 `flow_id`,`entry_flow_id=NULL`,不增加双写。 +- 会话创建、资源校验和提交顺序保持现状,不感知回收任务,不增加分布式锁或创建后的资源复查。 +- 文件夹列表按 effective entry 过滤,可保留多个会话。 +- 单文件“查找或新建”必须按 effective entry 查找:已回收到 root 的旧 session 不得因资源同 ID 移回而被复用。 + +#### 列表、历史与继续问答 + +1. endpoint 仍接收 `space_id + folder_id/file_id + chat_id`。 +2. Service 校验当前用户对 space 的访问;非 root 再校验具体 folder/file。 +3. Repository 按 effective entry 查询 `MessageSession`,同时过滤 tenant、`user_id`、知识空间 flow type 与 `is_delete=false`,按既有 `create_time DESC` 排序。 +4. 打开具体会话时先验证 chat_id 属于请求入口,再把数据库中的真实 `session.flow_id` 传给 `ChatSessionService.get_chat_history`;不信任客户端 flow_id。 +5. 从 recovered root 继续问答时使用原空间全量可见文件构建 retriever;消息仍写原 `session.flow_id`。 + +`chat_id` 是客户端输入,读取历史或写入本轮问题前必须完成以下校验,不允许“只按 chat_id 找到 session 就继续”: + +```text +requested_entry = flow generated from request space_id + folder_id/file_id +session = session matched by chat_id + tenant + owner + knowledge-space flow type + is_delete=false +effective_entry = session.entry_flow_id or session.flow_id + +require effective_entry == requested_entry +content_flow = session.flow_id +retrieval_scope = whole source space when requested_entry is source root +``` + +| 请求与会话状态 | 结果 | 历史/消息 flow | 检索范围 | +|---|---|---|---| +| 原生根会话:`entry_flow_id=NULL`,从同一空间 root 请求 | 允许 | 原 root `flow_id` | 原空间全量可见文件 | +| 普通旧目录/文件会话:`entry_flow_id=NULL`,伪装成 root 请求 | 拒绝;不得读取或新增消息 | 不执行 | 不执行 | +| 已回收会话:`entry_flow_id=原空间 root`,从该 root 请求 | 允许 | 保持旧 folder/file `flow_id` | 原空间全量可见文件 | +| 已回收会话从旧 folder/file 入口请求 | 拒绝;不得重新绑定 | 不执行 | 不执行 | +| chat 属于其他空间、其他用户、已删除或非知识空间 flow type | 拒绝 | 不执行 | 不执行 | + +因此 recovered chat 在展示意义上已经属于 root,但内容链仍属于原 `flow_id`;入口归属、消息归属和检索范围分别由 effective entry、session flow、当前合法入口决定,不能混用。 + +#### 删除 + +1. `batch_delete` 在执行任何删除前规范化服务端输入:folder/file ID 各自去重;若选中父文件夹,则从顶层 folder/file 输入中移除已被该父文件夹子树覆盖的后代;随后沿用既有逐项授权。这样父子重复输入不会在父项硬删除后再次查询已消失的子项。 +2. `delete_folder/delete_file` 复用既有子树枚举和版本链扩展结果;在对应 `KnowledgeFileDao.adelete_batch(...)` 前,把该删除单元实际影响的 folder/file ID 冻结为原空间 flow 集合。既有权限 tuple 清理和硬删除顺序保持不变,不把全部批量操作合并成长事务。 +3. 每次 `adelete_batch(...)` 成功提交返回后,立即将该删除单元的 flow 集合去重、分片并 best-effort 投递延时任务;F071 不调整既有清理动作的相对顺序——原本在硬删除 commit 前执行或投递的动作仍在前,原本在 commit 后执行的动作仍在后。F071 自身不等待 commit 后剩余的 channel binding 清理、空间更新时间或整个 batch 全部结束;后续步骤失败时,已经提交的删除单元仍保留其派发结果。 +4. worker 调用 retention service,以受控跨租户 fan-out 更新这些 flow 下所有会话所有者的活动 session,而不是只处理当前操作者;已回收行保持不变。 +5. 任务重复投递因 `entry_flow_id IS NULL` 保持幂等;派发或执行失败不改变删除结果,并按 best-effort 边界允许会话持续失联。 + +#### 移动 + +1. `move_items` 完成 valid/invalid 筛选后,只处理实际移动的 valid rows。 +2. 同空间移动不更新会话入口。 +3. 跨空间移动在既有 DB transaction 中继续只更新资源、版本和文档元数据;在提交前从 source space 与本次 valid item 的直接 move rows(item 本身及目录后代)冻结原 flow ID 集合。当前可见主版本文件必然位于直接 move rows 内,其单文件会话正常回收。 +4. `session.commit()` 返回后、执行 FGA parent tuple、tag 和检索数据迁移前,分片 best-effort 投递延时回收任务;后续副作用失败不撤销已经提交的资源变更或已完成的任务派发。 +5. `_collect_version_chain_file_ids` 额外带出的、但不在直接 move rows 内的 sibling file 不纳入本期在线 flow 集合;它们通常是当前列表隐藏的非主版本,其旧 file_id 会话可能继续失联。这不影响当前可见文件问答,属于已接受的历史残余范围。 + +#### 清空知识空间内容 + +1. `clear_space` 继续使用其在清理前已取得的 `child_resources`;按资源类型将其中全部 folder/file ID 冻结为该空间的 flow 集合,不额外扫描会话或在资源删除后反查子树。 +2. 既有 child permission projection、向量/ES/MinIO 清理和 `KnowledgeDao.async_delete_knowledge(..., only_clear=True)` 顺序保持不变;该调用只删除子资源并保留知识空间本身。 +3. `async_delete_knowledge(..., only_clear=True)` 提交返回后,立即对冻结 flow 去重、分片并 best-effort 投递延时任务;空集合不派发。F071 派发完成后再沿用既有流程重建空索引,后续空索引重建失败不撤销已提交清空或已完成派发。 +4. 完整删除知识空间的 `delete_space` 明确不新派发入口回收任务,也不新增会话清理;此前已排队的删除/`clear_space` 回收任务不取消,即使随后写入指向已删除空间 root 的 `entry_flow_id` 也作为现状兼容残留接受。由于所有列表、历史和继续问答均先校验空间存在性与权限,该元数据不构成可访问入口。 + +#### 定向任务、批量执行与一致性边界 + +- 变更量按“受影响会话数”计算,不按消息数计算;每个会话至多更新一列一次,消息和引用表零写入。 +- 资源操作在相关行消失或改写 `knowledge_id` 前,把本期纳入范围的删除、`clear_space` 或移动计划转换为原空间 flow 集合:每个 folder 生成 `space_{source_space_id}_folder_{folder_id}`,每个 file 生成 `space_{source_space_id}_file_{file_id}`;root entry 固定为 `space_{source_space_id}_folder_0`。删除包含其实际硬删除的版本链扩展文件;`clear_space` 包含既有 `child_resources` 中的全部 folder/file;跨空间移动只包含 valid item 的直接 move rows,不包含 `_collect_version_chain_file_ids` 额外扩展的 sibling。任务不得在资源操作完成后重新扫描已删除子树。 +- flow 集合按 `flow_batch_size=500` 分片作为任务载荷,避免上万 ID 进入单个消息或 SQL `IN`;任务只执行下列等价更新,不加载 session 对象、消息正文或引用。不得添加当前操作者 `user_id` 条件,因为同一资源可能存在多个用户的会话: + +```sql +UPDATE message_session +SET entry_flow_id = :source_root_flow +WHERE flow_type = :knowledge_space_flow_type + AND is_delete = false + AND entry_flow_id IS NULL + AND flow_id IN (:affected_original_flows); +``` + +- `affected_original_flows` 只能来自已授权资源操作的服务端计划,不接受客户端 flow 字符串;`source_root_flow` 必须与这些 flow 的 source space 相同。Repository 在受控 `bypass_tenant_filter()` 中执行跨会话所有者更新,精确 flow + flow type 是系统 fan-out 的硬边界。 +- 删除、清空、移动和任务之间不共享数据库事务或连接。触发边界分别是每个硬删除 commit、`clear_space` 子资源删除 commit 和跨空间移动 metadata commit;派发调用失败被捕获,已入队任务可重复执行并由 Celery 有界重试,但整体仍是 best-effort,不宣称端到端至少一次。 +- 任务默认 `countdown=5s`,用于覆盖常见的会话迟到提交;若 session 在最后一次任务扫描后才提交,可能继续保持旧入口。该低概率竞态由产品接受,不以修改会话创建链路或全量周期扫描消除。 +- 任务不做全库/全空间会话扫描;候选 flow 数、批次数、匹配 session 数、更新数、排队与执行耗时、重试次数和终态结果进入结构化日志。 + +#### 并发生成 + +- 在途请求开始时已通过旧 folder/file 权限,可以完成;消息仍写原 `chat_id/flow_id`。 +- 入口 UPDATE 与首次标题 UPDATE 由数据库行锁串行化,两个 SQL 都只写各自字段;标题生成不得使用整对象 merge 覆盖 `entry_flow_id`。 +- 迟到消息不会触碰 session 入口;任务扫描前已提交的 session 在任务成功执行后回到 root,扫描后才提交的极低概率 session 可能保持失联,记录为已接受的 best-effort 残余窗口。 +- 回收完成后的后续请求重新按 effective entry 验证,root 入口只走全空间检索。 + +### 4.4 存量迁移 + +脚本:`src/backend/scripts/migrate_f068_knowledge_chat_entries.py`;登记到 `src/backend/scripts/README.md`。 + +识别 grammar: + +```text +space_{space_id}_folder_0 -> 原生 root,不更新 +space_{space_id}_folder_{id>0} -> 检查 folder +space_{space_id}_file_{id>0} -> 检查 file +``` + +判定规则: + +| 存量状态 | `entry_flow_id` 结果 | +|---|---| +| 原空间存在,资源仍属于原空间 | 保持 null,入口沿用原 flow | +| 原空间存在,资源不存在 | 设置原空间 root flow | +| 原空间存在,资源存在但属于其它空间 | 设置原空间 root flow | +| 原知识空间不存在 | 脚本不新增、不清空入口;null 保持 null,已有非空 entry 也保持原值;报告非阻断分类 `deleted_space_skipped`,不计入可恢复失联集合 | +| `MessageSession.is_delete=true` | 保持 null,不恢复用户删除会话 | +| 活动会话 flow 无法解析、跨租户事实冲突 | 不猜测,计入 blocker,脚本非零退出 | + +安全运行合同: + +- “可恢复失联集合”只包含原知识空间仍存在、资源不存在或已属于其它空间、`is_delete=false` 且 flow 可可靠解析的会话;`deleted_space_skipped`、用户软删会话不属于该集合,无法解析或跨租户事实冲突属于 blocker。 +- 默认 dry-run,只读并输出数据库身份(不含密码)、分类数量、chat_id 掩码样例和输入 SHA-256。manifest 覆盖当前 `--tenant-id` 范围内全部知识空间 session,而不是只包含 `entry_flow_id IS NULL` 的待更新行;SHA-256 基于按 `(tenant_id, chat_id)` 稳定排序的不可变输入 manifest 计算,manifest 至少包含 `tenant_id/chat_id/flow_id/flow_type/is_delete` 与资源事实分类,不包含脚本会修改的 `entry_flow_id/update_time`。已由本次脚本处理的行仍在 manifest 内,因此分批写入本身不会改变摘要。 +- `--apply` 必须提供 dry-run 的 `--expected-input-sha256`;首次 apply 和断点续跑均重新计算同一 manifest,摘要变化时拒绝写。 +- 支持 `--tenant-id`、`--batch-size` 和 checkpoint;checkpoint 保存 expected manifest hash 与最后完成的 `(tenant_id, chat_id)` 游标,每批独立提交,只更新 `entry_flow_id IS NULL` 的可恢复失联会话。进程中断后从已提交游标之后继续,已处理行仍参与 manifest 校验但不会重复更新。 +- apply 后全量校验:`recoverable_orphan_remaining=0`,非知识空间 entry、跨空间 entry、错误 grammar 和 blocker 均为零;`deleted_space_skipped` 单独报告且不阻断发布。第二次 apply 必须零更新。 +- 脚本只访问 DB,不初始化 OpenFGA/Redis/Milvus/ES;跨租户盘点使用 `bypass_tenant_filter()`,写操作显式限制 session tenant。 +- apply 会按现有 DB 机制刷新被恢复 session 的 `update_time`;报告该数量,但不得更新消息、引用或 `create_time`。 + +### 4.5 前端行为 + +- `chatApi.ts` 的 URL、`FolderSession` 类型和请求参数不变,`entry_flow_id` 不返回前端。 +- `useFolderChat` 的 `spaceId/folderId` 变化继续触发 session reload;当前目录删除后导航到根目录,即可读取 recovered session。 +- 删除、清空空间内容或跨空间移动成功后不等待异步回收,也不为 F071 增加前端轮询或全局状态;用户在任务完成前可能暂时看不到回收会话,重新进入或刷新根目录后按现有列表接口读取最终结果。 +- 同名会话不合并;UI 继续使用 chat_id 做选择、改名和删除。 +- 会话重命名维持既有通用接口和权限行为,F071 不增加 entry 校验或修改其它会话类型。 +- 无视觉样式变化;实施时若触及 UI 结构,仍须先读当时最新的 `src/frontend/packages/ui/docs/`。 + +### 4.6 关键模块职责 + +| 模块 / 文件 | 职责 | 不做什么 | +|---|---|---| +| `database/models/session.py` | 只声明 `entry_flow_id` 字段 | 不增加 F071 DAO entry point,不改变通用 flow 语义 | +| `knowledge_chat_session_repository.py` 及实现 | effective-entry 查询、按精确 flow 集合执行幂等回收 UPDATE | 不解析 HTTP,不决定资源权限,不扫描全量会话 | +| `knowledge_space_chat_history_retention_service.py` | 校验服务端 flow chunk 与 source root,编排定向幂等更新 | 不直接写 ORM,不决定资源是否可删除、清空或移动 | +| `knowledge_space_service.py` | 规范化 batch 父子输入,冻结删除/`clear_space`/移动纳入范围的 flow;在每个硬删除 commit、`clear_space` 子资源删除 commit 或 move metadata commit 后立即 best-effort 分片派发 | 不等待回收完成,不从 session 推导资源权限,不为完整 `delete_space` 建立入口,不建设 outbox | +| `worker/knowledge/knowledge_chat_history_retention.py` | 短延时消费 flow chunk、调用 retention service、重试与记录终态 | 不重新扫描已删除子树,不回滚资源操作 | +| `worker/__init__.py` | 显式导入 `rehome_knowledge_chat_sessions` 完成 Celery task 注册;任务名保持在 `bisheng.worker.knowledge.*` 命名空间并由既有 router 投递到 `knowledge_celery` | 不为 F071 新增独立 worker、queue 或 broker 发布配置 | +| `knowledge_space_chat_service.py` | 列表/打开/继续问答使用 effective entry;消息使用原 flow | 不把 `entry_flow_id` 返回客户端,不动态跟随移回资源 | +| `chat_session/domain/chat.py` | 使用已验证 session 的真实 flow_id 读取消息 | 不全局放宽 session.flow_id 与 message.flow_id 一致性校验 | +| `migrate_f068_knowledge_chat_entries.py` | dry-run、校验、仅恢复失联存量、终态对账 | 不恢复消息内容,不访问外部存储或权限引擎 | +| `useFolderChat.ts` / 知识空间页面 mutation | 消费既有 session list 与既有重命名/删除能力 | 不轮询回收任务,不保存或推断权威入口 | + +--- + +## 5. 已知坑 / 反直觉事实 + +| # | 反直觉事实 | 如果不知道会怎样 | 在哪处理 | +|---|---|---|---| +| 1 | 当前历史通常没有物理删除;是入口先校验资源、再按 flow 精确匹配导致不可见 | 误做消息恢复或从备份复制,制造重复会话 | chat service + migration dry-run | +| 2 | 不能直接修改 `MessageSession.flow_id` | session 与 message flow 不一致,历史读取失败;还会与迟到消息竞态 | 只写 `entry_flow_id`,原 flow 全程不变 | +| 3 | nullable override 查询必须排除已有 entry 的旧 flow | recovered session 会同时出现在根和旧目录 | effective-entry Repository;两个互斥条件 | +| 4 | 更新 `entry_flow_id` 会刷新 session `update_time`,但知识空间消息写入不会、列表也按 `create_time` 排序 | 把 update_time 误当最后消息时间,或为避免刷新而引入无必要新表 | Spec 明确元数据时间语义;测试列表顺序不变 | +| 5 | 跨空间移动后再移回可能保留相同 file/folder ID | 单文件按原 flow 查找会错误复用已回收会话,破坏 sticky | 查找/复用按 effective entry,不只按 flow_id | +| 6 | `MessageCitation.flow_id` 主要用于内容链,不是展示入口 | 为改入口批量更新 citation,增加失败面 | citation 保持原样 | +| 7 | `MessageSession.tenant_id` 是会话所有者的用户叶子 tenant,不是知识空间 owner tenant;管理员删除资源必须回收该资源下所有用户会话 | 按当前操作者 user/tenant 更新会漏掉其他合法会话;无界跨租户 UPDATE 又违反 C3 | Repository 仅对任务携带的精确 flow + knowledge flow type 做受控 fan-out | +| 8 | 用户清空历史会硬删消息但保留 session;删除会话则 `is_delete=true` | 以无消息当删除会误跳过空会话,或恢复软删会话 | entry 管 session 入口;迁移过滤 is_delete;消息不回填 | +| 9 | 文件夹删除会硬删子树,版本链还会扩展出目录外 sibling file;跨空间移动也会联动版本链 | 任务启动后再查资源已经无法还原影响范围,或误以为在线移动覆盖全部历史版本会话 | 删除按实际硬删除集合冻结;移动只冻结 valid item 的直接 move rows,显式排除额外版本 sibling | +| 10 | bulk UPDATE 无 tenant 自动注入,且资源操作与任务运行在不同进程 | 无界跨租户更新或依赖进程内状态都会出错 | 只允许 Repository 对精确 flow chunk 做受控 bypass;DB 字段为唯一入口真相 | +| 11 | best-effort 任务只覆盖其扫描时已提交的 session | 把任务误当强一致门禁,会对极晚提交会话作错误承诺 | 默认短延时覆盖常见竞态;任务后提交的极低概率失联进入告警/运维修复,不改正常创建链路 | +| 12 | 完整删除空间不会删除旧会话,也不取消此前排队的回收任务 | 把失效 `entry_flow_id` 误判为可访问入口,或为清理元数据扩大删除链路 | `delete_space` 不新派发也不清 session;空间存在性与权限校验阻断访问,deleted-space 元数据作为非阻断残留保留 | + +--- + +## 6. 对外契约与依赖 + +### 6.1 我提供给别人的(Outgoing) + +| 契约 | 形式 | 谁在用 | +|---|---|---| +| `GET /api/v1/knowledge/space/{space_id}/chat/folder/session?folder_id=0` | 既有 HTTP,响应结构不变但包含 recovered sessions | client 根目录历史面板 | +| folder session/history/chat endpoints | 既有 HTTP/SSE,服务端按 effective entry 验证 | `useFolderChat` | +| single-file history/chat endpoints | 既有 HTTP/SSE,服务端按 effective entry 查找/复用 | `useFileChat` | +| `KnowledgeSpaceChatHistoryRetentionService.rehome_by_flows(...)` | 内部 async Python API | 定向回收 worker、迁移验证 | +| `KnowledgeChatSessionRepository` effective-entry/rehome 方法 | 内部 Repository contract | chat service、retention service、migration verification | +| `bisheng.worker.knowledge.knowledge_chat_history_retention.rehome_knowledge_chat_sessions` | 显式注册的 Celery task;source space + flow chunk,默认延时 5 秒,经既有通配 router 投递到 `knowledge_celery` | delete folder/file、batch delete、clear space、cross-space move | +| `message_session.entry_flow_id` | DB nullable 字段 | 所有 API 副本和维护脚本 | + +现有 `FolderSession.flow_id` 继续返回原内容 flow;前端和新代码不得用返回值推断当前入口。内部 `entry_flow_id` 不进入响应。 + +### 6.2 我依赖别人的(Incoming) + +| 依赖 | 形式 | 风险点 | +|---|---|---| +| `MessageSession` / `ChatMessage` | chat_id、flow_id、user_id、tenant_id、is_delete、时间字段 | 改 flow grammar 或删除 session 过滤语义会破坏迁移与历史读取 | +| `KnowledgeSpaceService.delete_folder/delete_file/batch_delete/clear_space/move_items` | 已授权且本期纳入范围的资源影响集合 | 必须在资源行消失或改空间前冻结 ID;删除按每个 hard-delete commit、清空按子资源删除 commit、移动按 metadata commit 触发;完整 `delete_space` 不触发;未派发时接受 best-effort 失联 | +| `KnowledgeFile.file_level_path/knowledge_id/file_type` | 子树与当前空间事实 | hard delete 前必须取全;跨空间判断不能只看 id | +| F034 move 返回与 `skip_invalid` | valid/invalid 集合 | 只能回收实际 moved 项,不能处理被跳过的 invalid 项 | +| F048 permission application API | space/folder/file action | entry 不能代替资源权限,root 仍须校验原空间 | +| `ChatSessionService.get_chat_history` | session.flow_id 与 message.flow_id 精确一致 | recovered root 必须传数据库 session 的原 flow | +| client `useFolderChat` | folderId 变化触发 reload、chatId 驱动历史 | 若客户端直接按返回 flow 判断入口,会破坏 recovered root 行为 | +| Celery / Redis broker | 定向延时任务、`acks_late` 与有界重试 | 入队失败、worker 终态失败或提交后进程退出可能使会话持续不可见;不把 broker 当成与资源 DB 原子提交的 outbox | +| MySQL/DM8 | nullable column、索引、set-based UPDATE、update_time | 查询计划、bind 参数和 session update_time 刷新需要双方言验证 | + +--- + +## 7. 测试与可观测 + +### 7.1 分层策略 + +- **单元测试**:effective-entry 条件、原生 root/recovered root 不重复、普通 folder/file、sticky、同空间 no-op、父子重复去重、deleted session 过滤、非知识空间 entry 拒绝、原空间权限。 +- **Repository/Service 集成测试**:单文件/文件夹/多层子树/版本兄弟删除,清空有内容空间、清空空空间、完整删除空间,跨空间移动,`skip_invalid`,batch 混合项和父子重复输入;验证输入先规范化、只更新 session.entry_flow_id,原 flow、消息、引用和 create_time 不变,列表仍按 create_time。`clear_space` 使用提交前 `child_resources` 且只在保留空间时派发;完整 `delete_space` 不新派发、不清旧 session,已排队任务可留下失效 entry,但所有会话接口因空间不存在而拒绝访问。跨空间移动覆盖直接 move rows 内的当前可见文件会话,并验证额外版本 sibling 不进入在线 flow 集合。构造超过 500 个 flow 的影响集合,验证任务正确分片、只处理实际提交项且重复执行零副作用。 +- **历史与继续问答测试**:入口用 root 验证,历史用原 flow 读取;继续问答使用 whole-space retriever,新消息仍写原 flow。负向覆盖“未回收旧 folder/file chat_id 传给 root”“recovered chat 传给旧入口”“其他空间/用户/flow type chat_id”,均须在读写消息和构造 retriever 前拒绝。 +- **异步与并发测试**:每个 `adelete_batch` commit 后立即派发,但不重排各删除路径已有的 commit 前后清理动作;`clear_space` 子资源删除 commit 后、空索引重建前派发;move metadata commit 后且其它 post-commit 副作用前派发。验证任务已由 `worker/__init__.py` 注册、任务名命中 `bisheng.worker.knowledge.*` 并路由到 `knowledge_celery`;后续清理失败不漏掉已提交单元的派发,派发失败不改变资源结果。覆盖任务默认延时、`acks_late`、失败重试与终态日志;任务扫描前提交的并发 session 被回收,扫描后提交的 session 保持已声明的竞态,不为此修改创建链路。标题与消息始终完整,无 lost update。 +- **回绑测试**:跨空间移出后再移回原空间,旧 recovered session 保持 root;单文件入口创建/选择新的有效 session。 +- **迁移测试**:root/folder/file、missing/moved/deleted space、soft-deleted、空会话、未知 grammar、checksum 变化、部分批次提交后以同一 manifest hash 续跑、第二次 apply 零更新;验证 `recoverable_orphan_remaining` 归零而 `deleted_space_skipped` 非阻断;MySQL 与 DM8 集中验证。 +- **E2E**:页面创建 folder/file 会话 → 单项/批量删除、清空空间内容或跨空间移动 → 等待定向任务完成 → 回原空间根查看、打开、继续、改名、删除;任务完成前允许暂时不可见。完整删除空间后,即使旧 session 或排队任务留下 entry 元数据,列表、历史与继续问答仍不可访问;目标空间始终不可见,无原空间权限用户不可见,存量 fixture 可恢复。 + +### 7.2 手动验证 + +升级前从 `src/backend/` 运行 dry-run: + +```bash +export config=/Users/zhangguoqing/works/bisheng/src/backend/bisheng/config.yaml +uv run python scripts/migrate_f068_knowledge_chat_entries.py +``` + +核对数据库身份、待恢复分类、`unparseable=0`、`cross_tenant_conflict=0` 后,使用摘要执行: + +```bash +uv run python scripts/migrate_f068_knowledge_chat_entries.py --apply --expected-input-sha256 +``` + +随后用两个用户分别验证:原用户可在源空间根目录看到并继续 recovered chat;其他用户和目标空间均不可见;回收前后消息内容/时间与会话 create_time 不变,session update_time 允许刷新。整个过程不得输出消息正文或真实配置密码。 + +### 7.3 可观测 + +- 结构化日志:`knowledge_chat_entry.task_dispatched`、`knowledge_chat_entry.rehome`(source_space/reason/flow_count/matched_count/updated_count/queue_delay_ms/duration_ms/retry_count)、`knowledge_chat_entry.task_failed`、`knowledge_chat_entry.migration_summary`、`knowledge_chat_entry.invalid`。 +- 不记录 query、answer、文件内容或完整 citation payload;chat_id 在批量脚本样例中掩码。 +- 资源删除、清空或移动日志记录触发点、source space、reason、flow chunk 数量和 hash;派发异常与任务终态失败额外记录该批精确 flow(每批最多 500),供日志仍可用时人工重试。日志不是 durable outbox,不对提交后进程退出提供恢复保证。 +- 在线路径不回退为全库扫描;运维可在维护窗口受控运行迁移脚本修复失联会话。发布迁移验收仍要求未解析、非法非知识空间 entry 和跨空间 entry 为零。 + +--- + +## 8. 发布、后续改进与不做事项 + +### 8.1 发布顺序 + +1. Alembic 为 `message_session` 增加 nullable `entry_flow_id` 与索引;只做 DDL,不回填。 +2. 部署识别 effective entry、按明确 commit 触发点 best-effort 投递定向延时回收且内部字段不出 API 的后端与 worker;迁移完成前知识空间问答入口处于维护窗口。 +3. 运行迁移 dry-run,核对数据库身份、分类和 checksum;显式 apply,仅更新失联活动会话。 +4. blocker、`recoverable_orphan_remaining`、非法 entry 和跨空间 entry 归零;`deleted_space_skipped` 已单独核对后开放知识空间问答入口。 +5. 验证新删除/清空/移动不等待任务、任务最终回收、完整删除空间不恢复、存量恢复、目标空间隔离、移回不回绑和已声明的并发窗口;再结束维护窗口。 + +**回滚**:新增列为纯增量,可保留不删。旧镜像会忽略 `entry_flow_id`,recovered 会话将再次失去入口,因此功能启用后不得把旧镜像回滚当作可用业务终态;应临时关闭知识空间问答及文件删除、空间清空、跨空间移动入口,保留字段值并前向修复。禁止为兼容旧镜像清空 entry 或改写原 flow,因为这会丢失恢复状态或破坏消息链。 + +### 8.2 后续 / 不做 + +- 当前不新增独立 SessionScope/投影表;只有入口模型扩展为多状态、多来源时再演进。 +- 暂不保存或展示 recovered 原因/时间/来源名称;日志用于运维追踪,避免扩大 schema 和泄露已删除资源名称。 +- 不清理旧 flow_id,也不移动消息/citation;它们仍是历史内容链的稳定标识。 +- 不为已删除知识空间创建隐藏恢复区;产品明确排除空间级恢复。 +- 不为在线竞态引入分布式锁、会话创建复查或全量周期巡检;定向 worker 是在线回收的正常路径,存量脚本仍只负责上线前历史恢复和受控运维修复。 + +--- + +## 修订历史 + +| 日期 | 改动 | 触发原因 | +|---|---|---| +| 2026-09-15 | 初版采用独立 `KnowledgeChatScope` 投影 | Spec ★ 已确认,进入 Design | +| 2026-09-15 | 按评审改为 `MessageSession.entry_flow_id` nullable 覆盖字段;取消新表与新会话双写,明确 update_time 是元数据时间、知识空间列表按 create_time | 用户确认单字段方案更符合当前需求复杂度 | +| 2026-09-18 | 改为资源操作成功后按精确 flow 分片投递定向延时回收;新增 Repository 而非扩展 DAO;取消同步门禁、同事务、分布式锁、创建复查和全量巡检 | 用户确认接受低频并发窗口与异步收敛,要求减小对原业务流程的改动 | +| 2026-09-18 | 在线回收降级为 best-effort;明确删除按每个 hard-delete commit、移动按 metadata commit 后触发;batch 先规范化父子输入;跨空间移动暂不覆盖直接 move rows 外的版本 sibling | 用户确认维持现状一致性级别并接受残余失联风险 | +| 2026-09-18 | 沿用全局 Celery 发布策略;补充 `worker/__init__.py` 显式注册和 `knowledge_celery` 路由约束;F071 派发不重排各删除路径既有清理顺序 | 用户确认不单独强化发布校验,并采纳任务注册与顺序描述修订 | +| 2026-09-18 | 将保留空间本身的 `clear_space` 纳入在线回收,完整 `delete_space` 仍排除;明确迁移可恢复集合、非阻断 deleted-space 分类及稳定 manifest/checkpoint 合同 | 用户采纳设计复审意见 | +| 2026-09-18 | 接受完整删除空间后保留旧 session,以及此前排队任务写入失效 entry 的现状;以空间存在性/权限阻断访问,不扩大 `delete_space` 清理链路 | 用户确认该残留状态无需治理 | diff --git a/features/v3.0.0-beta1/071-knowledge-space-chat-history-retention/discovery.md b/features/v3.0.0-beta1/071-knowledge-space-chat-history-retention/discovery.md new file mode 100644 index 0000000000..dfeea742d1 --- /dev/null +++ b/features/v3.0.0-beta1/071-knowledge-space-chat-history-retention/discovery.md @@ -0,0 +1,142 @@ +# Spec Discovery — 知识空间问答与目录解耦、历史对话按空间保留(F071) + +> **本文定位**:Spec Discovery 调研记录,只回答用户可见目标、当前真实链路与待确认口径;不替代 `spec.md` / `design.md`。 +> **日期**: 2026-09-15 +> **状态**: ✅ Discovery、Spec、best-effort Design 及 `clear_space` 范围修订已于 2026-09-18 确认;尚未生成 `tasks.md` +> **上游需求**: [《0923 中粮需求响应》§10](https://dataelem.feishu.cn/wiki/YPnUwk9ZjiNYPdkmh1DcmiRnnac#doxcnRszbttZbgrh4uKeJExEcSf) +> **原始问题**: 0902-01|知识空间问答与目录解耦,历史对话按空间保留 +> **代码基线**: `feat/923-3.0.0-beta1` @ `f43692b17d`,工作区于 2026-09-15 核实 +> **相关 Feature**: F034(文件/文件夹移动)· F048(资源权限唯一执行面)· 既有知识空间问答 + +--- + +## 1. 结论先行 + +PRD 的核心不是“删除目录时删除会话”,而是相反:**目录对象可以消失或离开原空间,但用户在原知识空间产生的历史对话必须保留,并在原空间根目录重新可见。** + +当前数据并未真的丢失。根因是会话把空间和目录编码进 `flow_id`(例如 `space_12_folder_34` / `space_12_file_56`),而会话列表、历史读取、继续问答和删除操作都要求请求中的 `space_id + folder_id/file_id` 与该编码精确匹配,并先校验关联目录对象仍可见。目录被硬删除或跨空间移动后,`message_session` 和 `chat_message` 仍在,但入口失配,因此表现为历史对话消失。 + +本需求涉及会话归属、文件/目录删除、跨空间移动、历史数据和权限边界,建议按 **Full SDD** 处理。 + +--- + +## 2. PRD 已明确的业务口径 + +- 用户在知识空间中的文件夹内发起问答后,即使关联文件或文件夹被删除,历史对话也不能随目录入口消失。 +- 关联文件或文件夹被移出当前知识空间后,对应历史对话仍归原知识空间。 +- 上述失去目录入口的历史对话统一显示在 **原知识空间根目录**。 +- PRD 明确这是当前客户需求的先行实现,后续仍可能由产品重新做完整设计和更新;因此本期应控制范围,不提前扩成全局会话重构。 + +--- + +## 3. 当前代码基线事实 + +### 3.1 会话与入口绑定方式 + +| 场景 | 当前绑定 | 当前读取条件 | 失配结果 | +|---|---|---|---| +| 知识空间根目录问答 | `space_{space_id}_folder_0` | 原空间可见 + flow_id 精确匹配 | 正常 | +| 文件夹问答 | `space_{space_id}_folder_{folder_id}` | 文件夹仍属于该空间且可见 + flow_id 精确匹配 | 文件夹删除/跨空间移动后不可读取 | +| 单文件问答 | `space_{space_id}_file_{file_id}` | 文件仍属于该空间且可见 + flow_id 精确匹配 | 文件删除/跨空间移动后不可读取 | + +`message_session` 当前没有独立的知识空间或问答范围字段;空间/文件/目录归属只存在于 `flow_id` 和 `flow_name` 字符串中。 + +### 3.2 删除与移动链路 + +- 删除文件夹会收集整棵子树、清理权限和业务数据并硬删除文件/文件夹行,但不会处理对应会话。 +- 批量删除复用单文件/文件夹删除链路,因此存在同样问题。 +- `clear_space` 会在保留知识空间本身的同时,通过 `KnowledgeDao.async_delete_knowledge(..., only_clear=True)` 一次性硬删除全部子资源;它在删除前已有完整 `child_resources`,但当前不会处理对应会话。 +- 同空间移动保持资源 ID 和知识空间 ID,不会让当前 `flow_id` 失配;PRD 也只要求“移出当前知识空间”时回收历史。 +- 跨空间移动会把文件、目录及子树的 `knowledge_id` 改为目标空间,但旧会话的 `flow_id` 仍带原空间 ID;它既不能在目标空间按新 flow_id 找到,也不能在原空间通过已离开的目录校验。 +- 完整删除知识空间本身不在 PRD §10 的触发范围;保留空间本身、只删除全部子资源的 `clear_space` 纳入 F071。 + +### 3.3 前端行为 + +- client 的 `useFolderChat(spaceId, folderId)` 按当前空间/目录加载会话、历史并发起 SSE。 +- 根目录只请求 `folder_id=0`;因此只有被归入根目录范围的会话才会显示。 +- 历史列表已经支持多会话、切换、改名和删除;本期不需要另造一个“丢失会话”页面。 +- 当前 active chat 由 `(space, folder)` 做前端内存记忆;目录消失后不能依靠该内存映射恢复,服务端归属必须成为真相。 + +--- + +## 4. 建议的业务范围 + +### 4.1 本次纳入 + +- 文件、文件夹或文件夹子树被删除时,把其关联的知识空间会话保留在原空间根目录。 +- 清空知识空间内容但保留空间本身时,使用清空前已有的全部子资源集合,把关联会话保留在该空间根目录。 +- 文件、文件夹或文件夹子树跨空间移动时,把其在源空间产生的会话保留在源空间根目录;不把历史对话带到目标空间。 +- 同时覆盖文件夹问答和单文件问答,避免 PRD“关联文件或文件夹”只修一半。 +- 覆盖单项删除、批量删除和跨空间移动等所有现有业务入口;直接调用 API 与页面操作结果一致。 +- 会话标题、消息、引用、点赞/点踩、创建人、会话创建时间、消息时间和用户手动删除状态保持不变;会话元数据更新时间可随入口归属更新,且当前不参与知识空间会话排序。 +- 根目录按既有会话排序、切换、改名和删除规则展示回收后的会话。 +- 用户仍需具有原知识空间的可见/使用权限;回收不能让无权用户重新看到空间或他人会话。 +- 对上线前已经因文件/目录删除或跨空间移动而失去入口、但会话与消息仍存在的数据,提供可验收的恢复口径。 + +### 4.2 明确排除 + +- 同一知识空间内仅改变父目录时不回收到根目录,仍跟随原文件/文件夹 ID。 +- 文件或文件夹重命名不改变会话归属。 +- 不把源空间历史迁移或复制到跨空间移动后的目标空间。 +- 不恢复用户主动删除的会话或已清空的消息。 +- 不恢复已删除知识空间的会话入口。 +- 不改变普通工作台、助手、工作流、灵思或频道文章会话。 +- 不在本期新增跨用户共享会话、会话导出或全局历史中心。 + +--- + +## 5. 必须在 spec 固化的边界 + +1. **原空间归属**:目录移出后,历史只回原空间根目录,不进入目标空间。 +2. **子树完整性**:删除/跨空间移动文件夹时,文件夹本身、所有后代文件夹和文件的关联会话都按同一规则处理。 +3. **清空与删除空间边界**:`clear_space` 删除全部子资源但保留空间,因此回收会话;完整 `delete_space` 不建立历史入口。 +4. **幂等**:同一业务动作重试不能复制会话或消息,不能让同一会话在根目录重复出现。 +5. **并发**:目录操作与会话创建并发时不删除会话或消息;在线入口回收接受 best-effort 收敛和极低概率迟到提交,不为此增加正常创建校验或分布式锁。 +6. **权限**:回收只改变会话在原空间内的展示范围,不改变会话所有者,也不绕过原空间访问权限。 +7. **best-effort 失败边界**:目录删除、空间清空或跨空间移动成功语义不依赖异步入口回收;能捕获的派发/执行失败记录影响范围,但本期不以 outbox 保证自动恢复或精确重放。 +8. **历史数据**:上线前已失去入口的存量会话是否恢复,必须有明确验收结果,不能只保证新操作。 +9. **后续问答**:用户从原空间根目录打开回收会话后,是只能查看历史,还是可以按根目录(全空间)范围继续问答,必须由产品拍板。 +10. **回绑行为**:已回收到根目录的会话,在原文件/文件夹随后重新出现或移回原空间时,是否仍留在根目录,必须固定口径。 + +--- + +## 6. 已确认的 Discovery 裁定 + +用户已确认采用以下最小且一致的产品口径: + +- **可继续问答**:回收会话在原空间根目录可查看,也可继续提问;后续检索范围按原空间根目录(全空间)计算,而不是引用已失效目录。 +- **包含存量恢复**:上线时恢复仍有会话/消息、但关联文件或目录已不存在或已不属于原空间的历史记录;用户主动删除的数据不恢复。 +- **回收后不自动回绑**:一旦因删除、清空空间内容或跨空间移动回收到根目录,后续即使同 ID 资源重新回到原空间,会话仍留在根目录,避免历史入口来回跳动。 +- **同空间移动不触发**:资源仍在原空间且未被清空时,会话继续绑定资源 ID;删除、`clear_space` 或跨空间移出才触发根目录回收。 +- **在线 best-effort 收敛**:每个硬删除 commit、`clear_space` 子资源删除 commit 或跨空间移动 metadata commit 后按冻结的 flow 集合尝试定向延时回收;允许任务完成前短暂不可见,也接受派发/执行故障和任务扫描后迟到提交造成的残余失联,不增加 outbox、锁、会话创建复查或全量巡检。 +- **批量父子输入先规范化**:删除前移除已被选中祖先文件夹覆盖的后代输入,避免父项提交后再次查询已删除子项。 +- **空间清空纳入、空间删除排除**:`clear_space` 复用删除前 `child_resources`,在子资源删除 commit 后定向回收全部关联会话;完整 `delete_space` 不新派发、不提供可访问入口,但沿用现状保留旧 session,也不取消此前已排队的回收任务。其可能留下的失效 entry 元数据不纳入本期治理。 +- **版本链在线边界**:删除仍按实际硬删除集合处理;跨空间移动只覆盖 valid item 的直接 move rows,`_collect_version_chain_file_ids` 额外扩展的 sibling file 会话暂不纳入在线回收。当前可见主版本文件位于直接 move rows 内,因此不影响当前文件问答。 + +上述产品决策已写入 `spec.md`,并以 EARS 验收标准覆盖删除、批量删除、空间清空、子树、跨空间移动、best-effort 边界、权限和存量恢复;Spec 与修订后的 Design 已确认,尚未生成 `tasks.md`。 + +--- + +## 7. 代码证据锚点(供后续 Design 使用) + +- `src/backend/bisheng/knowledge/domain/services/knowledge_space_chat_service.py` + - `generate_flow_id_for_file` / `generate_flow_id_for_folder` + - `get_chat_folder_session` / `get_chat_folder_history` / `chat_folder` + - `single_file_history` +- `src/backend/bisheng/knowledge/domain/services/knowledge_space_service.py` + - `delete_folder` / `delete_file` / `batch_delete` / `clear_space` / `move_items` +- `src/backend/bisheng/database/models/session.py` + - `MessageSession` / `MessageSessionDao.afilter_session` +- `src/frontend/client/src/hooks/useFolderChat.ts` +- `src/frontend/client/src/api/chatApi.ts` + +这些仅是现状证据;具体字段、迁移、事务/补偿和 API 形态留到 Design,不写进 spec。 + +--- + +## 相关文档 + +- [版本契约](../release-contract.md) +- [SDD 指南](../../../docs/SDD-Guide.md) +- [F034 移动能力 spec](../../../v2.6.0/034-knowledge-space-file-move/spec.md) +- [F048 权限模型 spec](../048-rebac-permission-model-grants/spec.md) diff --git a/features/v3.0.0-beta1/071-knowledge-space-chat-history-retention/e2e-checklist.md b/features/v3.0.0-beta1/071-knowledge-space-chat-history-retention/e2e-checklist.md new file mode 100644 index 0000000000..3cf9d4a5e5 --- /dev/null +++ b/features/v3.0.0-beta1/071-knowledge-space-chat-history-retention/e2e-checklist.md @@ -0,0 +1,99 @@ +# E2E 验证清单:F071 知识空间历史对话按空间保留 + +**状态**:待在已升级的专用测试租户执行 +**前置环境**:API、`knowledge_celery` worker、MySQL/DM8、Redis、Milvus、ES、MinIO、OpenFGA 均运行当前分支;已执行 `alembic upgrade head` +**自动化入口**:`src/backend/test/e2e/test_e2e_f068_knowledge_chat_history_retention.py` +**数据前缀**:`e2e-f068-`;仅清理该前缀的知识空间 + +## 1. 自动化 API 场景 + +运行前为专用测试租户用户设置 JWT: + +```bash +cd src/backend +export E2E_API_BASE=http://localhost:7860/api/v1 +export E2E_F071_TOKEN= +.venv/bin/pytest test/e2e/test_e2e_f068_knowledge_chat_history_retention.py -v +``` + +- [ ] AC-02/04/10/15:文件夹硬删除提交后,定向任务最终只在源空间根目录展示一次原会话。 +- [ ] AC-05/06/07/18:文件夹跨空间移动后,源空间根目录可见,目标空间根目录不可见。 +- [ ] AC-08:同空间移动后,会话仍在该文件夹入口,根目录不出现。 +- [ ] AC-22:用户已删除的会话不会因后续资源删除恢复。 +- [ ] 所有 session 响应都不包含内部字段 `entry_flow_id`,返回的 `flow_id` 始终是原内容 flow。 + +## 2. Client 页面主流程 + +以下每个场景都以用户 A 登录 Client `/workspace`,使用 `e2e-f068-` 前缀创建数据;资源操作完成后允许等待定向任务的默认短延时并刷新源空间。 + +### AC-01/10/11/12/13:单文件删除 + +- [ ] 在源空间文件夹内上传一个文件,从单文件入口发起至少两轮问答,记录标题、消息顺序、引用、反馈状态和时间。 +- [ ] 删除该文件;确认删除接口成功时不等待会话回收完成。 +- [ ] 任务完成前允许根目录暂时不可见;任务完成后刷新源空间根目录,会话出现且只出现一次。 +- [ ] 打开会话,核对原消息、标题、引用、反馈、创建时间与消息时间不变。 +- [ ] 从该会话继续提问,答案检索范围为源空间当前全部可见文件,不依赖已删除文件。 +- [ ] 执行会话切换、重命名和删除,行为与原生根目录会话一致。 + +### AC-02/03/15:多层文件夹与批量删除 + +- [ ] 创建父文件夹、子文件夹、兄弟文件夹,并分别建立文件夹/单文件会话。 +- [ ] 批量选择父文件夹、其子文件夹、子文件和一个独立文件,验证父子重复输入只删除一次。 +- [ ] 任务完成后,父子树及独立文件的活动会话均在根目录;未受影响兄弟目录的会话仍在原入口。 +- [ ] 重复投递相同 flow chunk,确认没有重复 session、消息或入口。 + +### AC-04A/22:清空内容与完整删除空间 + +- [ ] 在一个空间的根、文件夹和文件入口分别建立会话,执行“清空空间内容”(保留空间)。 +- [ ] 清空提交后空间仍存在且可进入;任务完成后文件夹/文件会话在根目录,原生根会话保持单份。 +- [ ] 对空空间再次清空,确认 no-op 且没有额外会话变化。 +- [ ] 另建空间并建立会话,执行完整删除空间;确认不出现任何可访问历史入口。 +- [ ] 数据库只读核对可允许旧 session 或排队任务留下失效 entry,但所有 API 均因空间不存在而拒绝。 + +### AC-05~09/14:移动、重命名和 sticky 归属 + +- [ ] 把单文件和多层文件夹分别跨空间移动;任务完成后仅源空间根目录出现历史,目标空间不出现。 +- [ ] 核对直接 move rows 内的当前文件/后代被回收;额外版本 sibling 不作为本期在线保证失败项。 +- [ ] 在同一空间内移动文件/文件夹,确认会话仍归原资源入口,不回收到根。 +- [ ] 仅重命名资源,确认会话归属不变。 +- [ ] 把跨空间移出的资源移回源空间,确认旧 recovered 会话仍留在根目录;从文件入口问答时创建或选择新的有效会话,不回绑旧会话。 + +## 3. 权限与负向入口校验 + +准备用户 A(会话所有者)、用户 B(同空间有访问权)和用户 C(无源空间访问权)。 + +- [ ] AC-16:用户 B 在源空间根目录看不到用户 A 的 recovered 会话。 +- [ ] AC-17:撤销用户 A 的源空间访问权后,A 无法列表、打开或继续 recovered 会话;恢复合法权限后可再次访问。 +- [ ] 把普通旧文件夹/文件会话的 `chat_id` 传给根目录 history/chat API,确认在读取历史、构造 retriever和写消息前拒绝。 +- [ ] 把 recovered `chat_id` 传给旧文件夹/文件入口,确认拒绝且不重新绑定。 +- [ ] 使用其他空间、其他用户、已软删或非知识空间会话的 `chat_id` 请求,确认拒绝且消息数不变。 +- [ ] AC-18:目标空间用户不会因资源移入获得源空间会话或内容权限。 + +## 4. best-effort、并发与运维恢复 + +- [ ] AC-19:暂停 broker 或模拟 publish 失败后删除资源,确认资源操作仍成功,日志含 `stage=dispatch`、source space、reason 和精确 flow chunk。 +- [ ] 恢复 broker,人工重投相同 chunk 或运行迁移脚本,确认会话最终恢复且幂等。 +- [ ] 模拟 worker 前两次失败、后续成功,确认有界 retry、`acks_late` 和 retry count 日志;终态失败不回滚资源操作。 +- [ ] AC-20:在删除/移动期间创建会话;任务扫描前已提交的会话被回收,扫描后才提交的极低概率残余按已接受边界记录,不要求创建链路阻塞。 +- [ ] AC-21:先执行 dry-run,核对数据库身份、分类、掩码样例、`unparseable=0`、`cross_tenant_conflict=0` 和 manifest SHA。 +- [ ] 用 reviewed SHA 执行 apply,模拟中断后用 checkpoint 续跑;终态 `recoverable_orphan_remaining=0`,第二次 apply `updated_count=0`。 +- [ ] 核对迁移前后 session 原 `flow_id/create_time`、消息、引用和消息时间不变;只有被恢复 session 的 `entry_flow_id/update_time` 可变化。 + +## 5. 非功能回归 + +- [ ] AC-23:页面操作与直接 API 调用对相同资源产生一致的源空间归属和权限结果。 +- [ ] AC-24:普通工作台、助手、工作流、灵思和频道文章会话的列表、历史、继续问答、重命名和删除均保持原行为。 +- [ ] 根目录会话列表仍按既有 `create_time DESC` 排序;入口回收刷新 `update_time` 不改变顺序。 +- [ ] 浏览器 Network 响应没有 `entry_flow_id`;前端没有新增入口状态、轮询或 API URL。 +- [ ] API/worker 日志不含 query、answer、文件内容、完整 citation payload 或未掩码的批量 chat_id 样例。 + +## 6. 结果记录 + +| 范围 | 结果 | 证据/备注 | +|---|---|---| +| 自动化 API E2E | 待执行 | 需要专用测试租户 JWT 和运行中的 worker | +| Client 页面 | 待执行 | 需要双用户/三用户权限场景 | +| MySQL | 待执行 | migration、OR 查询计划、set-based UPDATE | +| DM8 | 待执行 | migration、OR 查询计划、set-based UPDATE | +| 迁移 dry-run/apply/续跑 | 待执行 | 需要脱敏快照和维护窗口 | +| 非知识空间会话回归 | 待执行 | 工作台/助手/工作流/灵思/频道文章 | diff --git a/features/v3.0.0-beta1/071-knowledge-space-chat-history-retention/spec.md b/features/v3.0.0-beta1/071-knowledge-space-chat-history-retention/spec.md new file mode 100644 index 0000000000..dc5e461d8e --- /dev/null +++ b/features/v3.0.0-beta1/071-knowledge-space-chat-history-retention/spec.md @@ -0,0 +1,123 @@ +# Feature: F071 知识空间问答与目录解耦、历史对话按空间保留 + +> **本文档定位 — 纯 What(需求口径,不随代码漂移)** +> +> 本文只定义业务范围与验收标准;实现方案、数据迁移、事务边界、接口与文件清单在设计确认后记录于 +> [design.md](./design.md)。 + +**关联 PRD**: [《0923 中粮需求响应》§10「知识空间问答与目录解耦,历史对话按空间保留」](https://dataelem.feishu.cn/wiki/YPnUwk9ZjiNYPdkmh1DcmiRnnac#doxcnRszbttZbgrh4uKeJExEcSf) +**原始需求**: 0902-01 +**优先级**: P1 +**所属版本**: v3.0.0-beta1 +**依赖**: v2.6.0 F034 文件/文件夹移动、F048 资源权限模型、既有知识空间问答 +**状态**: ✅ Spec、会话时间语义、best-effort 异步收敛及 `clear_space` 范围修订已于 2026-09-18 确认;`tasks.md` 已生成 + +> **范围边界** +> - **本次纳入**: +> - 文件、文件夹及文件夹子树被删除后,其关联历史对话在原知识空间根目录继续可见。 +> - 清空知识空间内容(`clear_space`,保留空间本身)后,被清空文件和文件夹的关联历史对话在该空间根目录继续可见。 +> - 文件、文件夹及文件夹子树跨知识空间移动后,其源空间历史对话在原知识空间根目录继续可见,且不进入目标空间。 +> - 同时覆盖文件夹问答与单文件问答、单项与批量操作、页面与直接业务调用。 +> - 回收后的会话可按原知识空间根目录的全空间范围继续问答。 +> - 恢复上线前已经失去目录入口、但会话历史仍存在的数据。 +> - 在线删除、清空空间内容或跨空间移动后的入口回收采用 best-effort 异步收敛,允许定向任务完成前短暂不可见,也接受派发/执行故障造成的持续失联残余。 +> - **本次明确排除**: +> - 完整删除知识空间不主动派发回收,也不提供可访问的历史入口;既有旧会话和此前已排队任务可能留下的不可访问入口元数据允许保留。仅保留空间本身的 `clear_space` 属于本期范围。 +> - 同一知识空间内的移动和资源重命名不触发根目录回收。 +> - 不恢复用户主动删除的会话、用户主动清空的消息或已删除知识空间的会话入口。 +> - 不改变普通工作台、助手、工作流、灵思或频道文章会话。 +> - 不新增跨用户共享会话、会话导出或全局历史中心。 +> - 跨空间移动因版本链联动搬迁、但不在 valid item 直接 move rows 内的 sibling file 会话不纳入本期在线回收;当前可见主版本文件及其会话仍在范围内。 + +--- + +## 1. 用户故事 + +作为 **在知识空间文件夹或单个文件中发起过问答的用户**, +我希望 **关联文件或文件夹被删除或移出原知识空间后,仍能在原空间找到完整历史对话**, +以便 **目录整理不会让已经形成的工作记录失去入口**。 + +作为 **需要继续利用历史上下文的用户**, +我希望 **从原空间根目录打开回收会话后仍可继续提问**, +以便 **在原目录已失效时按整个原知识空间继续工作**。 + +作为 **知识空间管理员**, +我希望 **历史保留不改变空间权限、会话所有者和目标空间数据边界**, +以便 **修复可见性问题时不产生跨用户或跨空间泄漏**。 + +--- + +## 2. 验收标准 + +> AC-01~AC-18(含 AC-04A)描述定向任务成功执行后的目标态;在线派发和执行失败以 AC-19 的 best-effort 边界为准,不构成资源操作回滚条件。 + +### 2.1 删除后的历史保留 + +- **AC-01** — WHEN 用户成功删除一个文件, THE SYSTEM SHALL 将该文件关联且未被用户主动删除的知识空间问答会话保留在原知识空间根目录。 +- **AC-02** — WHEN 用户成功删除一个文件夹, THE SYSTEM SHALL 将该文件夹以及其全部后代文件夹和文件关联且未被用户主动删除的问答会话保留在原知识空间根目录。 +- **AC-03** — WHEN 用户通过批量操作成功删除文件、文件夹或混合选中项, THE SYSTEM SHALL 对每个被删除项及其后代应用与单项删除相同的历史保留规则。 +- **AC-04** — WHEN 一个资源删除单元的硬删除已经提交, THE SYSTEM SHALL 立即 best-effort 尝试派发仅面向该单元影响范围的会话入口回收;已成功执行的任务应使匹配会话从原知识空间根目录找到,派发/执行故障不得改变删除结果。 +- **AC-04A** — WHEN 用户成功清空一个知识空间但保留空间本身, THE SYSTEM SHALL 在清空数据提交后 best-effort 尝试回收清空前全部文件和文件夹的活动会话,使任务成功处理的会话从该空间根目录找到;完整删除知识空间自身不得新派发该回收,但不要求取消此前已经排队的任务。 + +### 2.2 跨空间移动后的历史保留 + +- **AC-05** — WHEN 用户成功将一个文件移出原知识空间, THE SYSTEM SHALL 将该文件在源空间产生且未被用户主动删除的问答会话保留在源空间根目录。 +- **AC-06** — WHEN 用户成功将一个文件夹移出原知识空间, THE SYSTEM SHALL 对该文件夹以及本次直接 move rows 中的后代文件夹和文件 best-effort 回收源空间问答会话;版本链额外扩展、但不在直接 move rows 内的 sibling file 不在本期在线保证范围。 +- **AC-07** — THE SYSTEM SHALL 不在目标知识空间展示、复制或继承上述源空间历史会话。 +- **AC-08** — WHEN 文件或文件夹只在同一知识空间内移动, THE SYSTEM SHALL 保持其现有会话归属,不将会话回收到根目录。 +- **AC-09** — WHEN 文件或文件夹仅发生重命名, THE SYSTEM SHALL 保持其现有会话归属。 + +### 2.3 回收会话的可用性与完整性 + +- **AC-10** — WHEN 有权用户在原知识空间根目录查看历史列表, THE SYSTEM SHALL 将回收会话与原有根目录会话一并按既有排序规则展示,且同一会话只出现一次。 +- **AC-11** — WHEN 用户打开回收会话, THE SYSTEM SHALL 展示该会话的完整现存消息,并保持会话标题、消息顺序、引用、反馈状态、创建人、会话创建时间及消息创建/更新时间不变;会话元数据更新时间可因入口归属变化按既有机制刷新,且不作为知识空间会话排序或最后消息时间。 +- **AC-12** — WHEN 用户对回收会话执行既有的打开、切换、重命名或删除操作, THE SYSTEM SHALL 提供与普通根目录会话一致的用户可见行为。 +- **AC-13** — WHEN 用户从回收会话继续提问, THE SYSTEM SHALL 保留可用的历史上下文,并按原知识空间根目录的全空间范围处理后续知识检索,不再依赖已删除或已移出的文件/文件夹范围。 +- **AC-14** — WHEN 会话已因删除、清空空间内容或跨空间移动被回收到原空间根目录, THE SYSTEM SHALL 保持该归属;即使同一资源标识随后重新出现或资源被移回原空间,也不得自动把会话重新绑定到该资源。 +- **AC-15** — WHEN 同一删除、清空、移动或历史恢复动作被重复执行, THE SYSTEM SHALL 保持结果幂等,不复制会话或消息,也不在根目录生成重复入口。 + +### 2.4 权限、失败与历史数据 + +- **AC-16** — THE SYSTEM SHALL 只向会话所有者展示其本人可访问的回收会话,不得因回收扩大到同空间其他用户。 +- **AC-17** — WHEN 用户查看、打开或继续使用回收会话, THE SYSTEM SHALL 继续校验其对原知识空间的现行访问权限;无权访问原空间的用户不得通过历史入口读取会话或空间内容。 +- **AC-18** — THE SYSTEM SHALL 只改变历史会话在原空间内的可见范围,不改变会话所有者,也不把源空间权限、内容或会话授予目标空间用户。 +- **AC-19** — IF 异步入口回收派发或执行失败, THEN THE SYSTEM SHALL 不回滚或阻断已经提交的删除、清空空间内容或跨空间移动;在进程能够捕获失败时记录影响范围和失败阶段,并允许人工重试或维护期迁移修复。本期不承诺资源提交与任务入队原子,也不保证提交后进程退出或任务终态失败一定自动恢复。 +- **AC-20** — WHILE 会话创建与删除、清空空间内容或跨空间移动并发发生, THE SYSTEM SHALL 不删除会话或消息;定向回收任务成功执行时,对扫描前已经提交的受影响会话写入原空间根入口。任务扫描后才提交的极低概率会话允许保留既有竞态,不要求为此阻塞或复查正常会话创建。 +- **AC-21** — WHEN 本 Feature 上线, THE SYSTEM SHALL 使上线前因关联文件/文件夹已删除或已移出原空间而失去入口、但会话仍存在的历史按相同规则恢复到原空间根目录。 +- **AC-22** — THE SYSTEM SHALL 不恢复用户主动删除的会话、用户主动清空的消息,也不为已经删除的知识空间提供可访问的历史入口;完整删除空间不要求物理删除旧会话、清空既有 `entry_flow_id` 或取消此前已排队的回收任务。 +- **AC-23** — WHEN 相同目录操作分别通过页面或直接业务调用完成, THE SYSTEM SHALL 产生一致的历史保留、原空间归属和权限结果。 +- **AC-24** — THE SYSTEM SHALL 保持本 Feature 范围外的普通工作台、助手、工作流、灵思和频道文章会话行为不变。 + +--- + +## 3. 边界情况 + +- 一个文件夹包含多层子目录、单文件问答和文件夹问答时,所有受影响会话都应保留,未受影响的兄弟目录会话不得改变。 +- 清空知识空间时应使用清空前已经取得的完整子资源集合回收会话;空空间清空为 no-op。完整删除空间不主动派发且不可访问,但允许保留旧会话及排队任务写入的失效入口元数据。 +- 一次批量操作同时包含父文件夹和其后代时,服务端必须在执行删除前移除已被祖先覆盖的顶层后代输入;同一资源只删除一次,同一会话只回收一次。 +- 源空间与目标空间中存在相似名称或相同资源标识时,源空间历史仍不得出现在目标空间,也不得自动回绑。 +- 回收会话与原根目录会话标题相同不构成合并条件;两者都应保留且不能互相覆盖。 +- 根目录请求携带的 `chat_id` 若尚未回收到该根入口、属于其他空间/用户或已删除,必须拒绝且不得读取或写入消息;只有当前入口已是原空间根目录的会话才能按全空间范围继续问答。 +- 用户在资源操作后失去原空间权限时,历史仍归原空间,但在重新获得合法权限前不可见、不可继续使用。 +- 存量历史无法可靠证明其原空间、会话所有者或未被用户主动删除时,必须失败关闭,不得猜测归属或扩大可见范围。 +- 在线回收只检查本次删除、清空或移动冻结的影响范围;不得为消除并发、派发或执行故障窗口而周期性扫描全部知识空间会话。允许在明确维护窗口受控运行存量迁移脚本。 + +--- + +## 4. 设计与实现(指针,不复制) + +| 你想知道 | 去哪看 | +|---|---| +| 会话归属、历史恢复、并发与一致性方案 | design.md(Spec 确认后创建) | +| 任务拆解与验证记录 | tasks.md(Design 确认后创建) | +| 版本级归属、依赖与不变量 | [release-contract.md](../release-contract.md) | +| 架构与权限约束 | [docs/constitution.md](../../../docs/constitution.md) | + +--- + +## 相关文档 + +- [Discovery 记录](./discovery.md) +- [F034 文件/文件夹移动 spec](../../../v2.6.0/034-knowledge-space-file-move/spec.md) +- [F048 权限模型 spec](../048-rebac-permission-model-grants/spec.md) +- [版本契约](../release-contract.md) diff --git a/features/v3.0.0-beta1/071-knowledge-space-chat-history-retention/tasks.md b/features/v3.0.0-beta1/071-knowledge-space-chat-history-retention/tasks.md new file mode 100644 index 0000000000..b8acc5b816 --- /dev/null +++ b/features/v3.0.0-beta1/071-knowledge-space-chat-history-retention/tasks.md @@ -0,0 +1,249 @@ +# Tasks: F071 知识空间问答与目录解耦、历史对话按空间保留 + +**关联规格**: [spec.md](./spec.md) +**设计入口**: [design.md](./design.md) +**版本**: v3.0.0-beta1 / F071 + +--- + +## 状态 + +| 步骤 | 状态 | 备注 | +|---|---|---| +| spec.md | ✅ 已评审 | 2026-09-18 用户确认最终一致性、`clear_space` 与完整删除空间边界 | +| design.md | ✅ 已评审 | 2026-09-18 用户确认 best-effort 定向异步回收方案 | +| tasks.md | ✅ 已拆解 | 2026-09-18 按依赖 Wave 与 AC 覆盖自检 | +| 实现 | 🟡 开发完成,待环境门禁 | 12 / 15 完成;T013~T015 需 MySQL/DM8、运行中 worker、浏览器和脱敏快照 | + +--- + +## 开发模式 + +- 后端按 Test-First 实施:先用 schema、Repository、Service、Worker、资源触发和迁移脚本测试锁定行为,再提交对应实现。 +- 会话入口真相只落在 `message_session.entry_flow_id`;`flow_id`、消息和引用保持原内容链。F071 数据库逻辑只进入新增 `KnowledgeChatSessionRepository`,不得为此扩展 legacy `MessageSessionDao`。 +- 在线回收采用资源提交后的 best-effort 定向 Celery 任务:默认 `countdown=5`、每批最多 500 个 flow、`acks_late`、有界重试;不增加 outbox、分布式锁、创建后复查或全量巡检。 +- 删除、`clear_space` 与跨空间移动共享同一套 flow 冻结和派发合同;完整删除知识空间不新派发、不清理旧会话或入口元数据。 +- 存量恢复由独立 DB-only 脚本完成,Alembic 只做 DDL;脚本默认 dry-run,apply 必须带输入摘要,并支持 checkpoint 分批提交。 +- 前端 URL、`FolderSession` 与状态管理不变,不新增轮询或视觉改动;通过 API 契约测试和 E2E 验证既有页面可消费回收结果。 +- 本地缺少 worktree 配置时,从 `src/backend/` 运行命令前使用 + `export config=/Users/zhangguoqing/works/bisheng/src/backend/bisheng/config.yaml`;MySQL、DM8、Celery 和浏览器场景在可用集成环境验证。 + +--- + +## Tasks + +### Wave 1:数据结构与 Repository 基础 + +- [x] **T001**: `entry_flow_id` schema 合同测试 + **文件**: `src/backend/test/knowledge/test_knowledge_chat_entry_schema.py` + **逻辑**: 先固定 `MessageSession.entry_flow_id` 为 nullable `VARCHAR(255)`、索引名为 + `idx_message_session_entry_flow_id`;验证既有 `flow_id/create_time/update_time` 定义不变,且 + `MessageSessionDao` 不新增 F071 查询或批量回收入口。对新增 Alembic revision 做 upgrade/downgrade + 结构检查,保证 revision 只含列与索引 DDL、可幂等处理模型先建列场景,且迁移图仍为单 head。 + **覆盖 AC**: AC-11, AC-24 + **验证**: `cd src/backend && uv run pytest test/knowledge/test_knowledge_chat_entry_schema.py test/database/test_alembic_single_head.py` + **依赖**: 无 + +- [x] **T002**: ORM 字段与双数据库 Alembic DDL + **文件**: `src/backend/bisheng/database/models/session.py`, + `src/backend/bisheng/core/database/alembic/versions/.py` + **逻辑**: 在 `message_session` 增加 nullable `entry_flow_id` 和单列索引;revision 的 + `down_revision` 取实施时唯一 head,使用共享 `column_exists/index_exists` 类 helper 防止 + `create_all()` 与升级路径冲突,upgrade/downgrade 对称且不做任何数据回填。复核 MySQL/DM8 + 标识符、VARCHAR 与索引兼容性;不改变通用会话 DAO 或其它会话类型。 + **测试**: T001 全部通过;`uv run alembic heads` 只输出一个 head。 + **覆盖 AC**: AC-11, AC-24 + **设计依据**: design §3 决策 1、§4.2、§8.1 + **依赖**: T001 + +- [x] **T003**: effective-entry Repository 测试 + **文件**: `src/backend/test/knowledge/test_knowledge_chat_session_repository.py` + **逻辑**: 覆盖 `entry_flow_id=target OR (entry_flow_id IS NULL AND flow_id=target)` 的互斥查询; + 验证原生 root 与 recovered root 同列且不重复、按 `create_time DESC`、tenant/owner/知识空间 + flow type/`is_delete=false` 过滤;按 `chat_id + requested_entry` 查询必须拒绝旧入口、其他空间、 + 其他用户、已删除和非知识空间会话。批量回收覆盖精确 flow 集、所有合法会话 owner、仅 + `entry_flow_id IS NULL` 行,重复执行零更新;空集和超过 500 flow 的调用不产生无界 SQL。 + **覆盖 AC**: AC-10, AC-14, AC-15, AC-16, AC-18, AC-24 + **验证**: `cd src/backend && uv run pytest test/knowledge/test_knowledge_chat_session_repository.py` + **依赖**: T002 + +- [x] **T004**: `KnowledgeChatSessionRepository` 合同与实现 + **文件**: `src/backend/bisheng/knowledge/domain/repositories/interfaces/knowledge_chat_session_repository.py`, + `src/backend/bisheng/knowledge/domain/repositories/implementations/knowledge_chat_session_repository_impl.py`, + 两级 `__init__.py` + **逻辑**: 提供按 effective entry 列表、按 chat+entry 取活动会话、单文件有效会话查找、按精确 + flow chunk 幂等回收到 source root 的接口;列表查询保留租户自动过滤,系统 fan-out UPDATE + 仅在实现内部受控使用 `bypass_tenant_filter()`,并显式限定知识空间 flow type、活动状态、 + `entry_flow_id IS NULL` 与传入 flow 集。Repository 不解析 HTTP、不校验资源权限、不扫描全表。 + **测试**: T003 全部通过。 + **覆盖 AC**: AC-10, AC-14, AC-15, AC-16, AC-18, AC-24 + **设计依据**: design §3 决策 2、§4.3、§4.6、坑 3/7/10 + **依赖**: T002, T003 + +### Wave 2:在线列表、历史与继续问答语义 + +- [x] **T005**: knowledge chat effective-entry 行为测试 + **文件**: `src/backend/test/knowledge/test_knowledge_space_chat_entries.py`, + `src/backend/test/knowledge/test_knowledge_space_chat_service_retrieve.py`, + `src/backend/test/knowledge/test_knowledge_space_chat_permissions.py` + **逻辑**: 覆盖 root 列表合并与排序、folder/file 正常入口、recovered chat 只从原空间 root 打开、 + 历史始终以数据库中真实 `session.flow_id` 读取、继续问答使用原空间 whole-space retriever 且新消息 + 仍写原 flow。负向用例在消息读取/写入和 retriever 构造前拒绝:普通旧目录 chat 冒充 root、 + recovered chat 从旧入口访问、其他空间/用户/flow type/已删除 chat。验证跨空间移出再移回或同 ID + 资源重现时,单文件查找不复用 recovered session;标题、消息、引用、反馈、create_time 与消息时间 + 不变,知识空间列表不因 `update_time` 变化改序。 + **覆盖 AC**: AC-10, AC-11, AC-12, AC-13, AC-14, AC-16, AC-17, AC-18, AC-22, AC-24 + **验证**: `cd src/backend && uv run pytest test/knowledge/test_knowledge_space_chat_entries.py test/knowledge/test_knowledge_space_chat_service_retrieve.py test/knowledge/test_knowledge_space_chat_permissions.py` + **依赖**: T004 + +- [x] **T006**: chat service 与响应契约接入 effective entry + **文件**: `src/backend/bisheng/knowledge/domain/services/knowledge_space_chat_service.py`, + `src/backend/bisheng/knowledge/api/endpoints/knowledge_space.py`, + `src/backend/bisheng/knowledge/domain/schemas/knowledge_space_schema.py`, + `src/backend/bisheng/chat_session/domain/chat.py`(仅在需要接收已验证 session 时修改) + **逻辑**: Service 先做原空间/具体资源权限校验,再通过 Repository 验证 requested entry 与 chat; + 把真实 `session.flow_id` 传给历史与消息链。recovered root 的检索范围切为原空间全量可见文件; + 单文件查找/复用按 effective entry。若 endpoint 当前直接序列化 ORM,改为显式既有字段 schema, + 确保 `entry_flow_id` 不出 API。保持会话重命名/删除通用接口、URL、错误码和非知识空间路径不变, + 不全局放宽 `ChatSessionService` 的 flow 一致性校验。 + **测试**: T005 全部通过;现有 knowledge chat citation、visibility 和 service 测试保持通过。 + **覆盖 AC**: AC-10, AC-11, AC-12, AC-13, AC-14, AC-16, AC-17, AC-18, AC-22, AC-24 + **设计依据**: design §3 决策 2/4/6、§4.3、坑 2/5/6 + **依赖**: T004, T005 + +### Wave 3:定向异步回收与资源触发点 + +- [x] **T007**: retention service 与 Celery worker 测试 + **文件**: `src/backend/test/knowledge/test_knowledge_chat_history_retention.py`, + `src/backend/test/knowledge/test_knowledge_chat_history_retention_worker.py`, + `src/backend/test/knowledge/test_celery_wiring.py` + **逻辑**: 验证 service 只接受同一 source space 的合法 folder/file flow chunk,source root 固定为 + `space_{source_space_id}_folder_0`,空集 no-op,越界/错误 grammar 失败关闭;worker task 名固定为 + `bisheng.worker.knowledge.knowledge_chat_history_retention.rehome_knowledge_chat_sessions`,由 + `worker/__init__.py` 显式注册并命中 `bisheng.worker.knowledge.* -> knowledge_celery` 路由。覆盖 + `acks_late`、有界 retry、幂等重放,以及 dispatch/rehome/failure 日志字段和敏感内容不落日志。 + **覆盖 AC**: AC-04, AC-04A, AC-15, AC-19, AC-20 + **验证**: `cd src/backend && uv run pytest test/knowledge/test_knowledge_chat_history_retention.py test/knowledge/test_knowledge_chat_history_retention_worker.py test/knowledge/test_celery_wiring.py` + **依赖**: T004 + +- [x] **T008**: retention service、worker 与任务注册实现 + **文件**: `src/backend/bisheng/knowledge/domain/services/knowledge_space_chat_history_retention_service.py`, + `src/backend/bisheng/worker/knowledge/knowledge_chat_history_retention.py`, + `src/backend/bisheng/worker/__init__.py` + **逻辑**: service 校验服务端生成的 source space/root/flow chunk 后调用 Repository;worker 通过共享 + worker asyncio bridge 执行,使用 `acks_late` 与有限次数 retry,输出 design §7.3 定义的结构化日志。 + 提供统一 best-effort 派发 helper:去重后每 500 flow 分片、默认 `countdown=5`;捕获可预期发布异常并 + 记录精确 chunk,不向资源操作抛出,不增加新 queue、Beat、broker 配置或周期巡检。 + **测试**: T007 全部通过。 + **覆盖 AC**: AC-04, AC-04A, AC-15, AC-19, AC-20 + **设计依据**: design §3 决策 3/4、§4.6、§7.3 + **依赖**: T004, T007 + +- [x] **T009**: 删除、清空与移动触发合同测试 + **文件**: `src/backend/test/knowledge/test_knowledge_space_chat_rehome_dispatch.py`, + `src/backend/test/knowledge/test_knowledge_space_move.py`, + `src/backend/test/knowledge/test_v2_filelib_unified.py` + **逻辑**: 用调用顺序与冻结集合断言覆盖:单文件、文件夹多层子树、版本链删除;batch 混合项、重复项 + 与父子同时选择先规范化;每个 `KnowledgeFileDao.adelete_batch(...)` commit 返回后立即派发且不等待 + 整批结束。`clear_space` 使用删除前 `child_resources`,仅在 `only_clear=True` 提交后、空索引重建前 + 派发,空空间 no-op;完整 `delete_space` 不派发、不清 session。跨空间 move 只纳入 valid item 的 + 直接 move rows,在 metadata commit 后、FGA/tag/vector 等副作用前派发;同空间、invalid item 与额外 + version sibling 不派发。覆盖超过 500 flow 分片、发布失败不改变资源结果、后续副作用失败不撤销已派发 + 单元、页面和直接 service 调用一致。 + **覆盖 AC**: AC-01, AC-02, AC-03, AC-04, AC-04A, AC-05, AC-06, AC-07, AC-08, AC-09, AC-15, AC-19, AC-20, AC-22, AC-23 + **验证**: `cd src/backend && uv run pytest test/knowledge/test_knowledge_space_chat_rehome_dispatch.py test/knowledge/test_knowledge_space_move.py test/knowledge/test_v2_filelib_unified.py` + **依赖**: T008 + +- [x] **T010**: 资源影响集合冻结与 commit 后 best-effort 派发 + **文件**: `src/backend/bisheng/knowledge/domain/services/knowledge_space_service.py` + **逻辑**: batch 在任何删除前对 file/folder 去重,并移除已被选中祖先覆盖的顶层后代;删除在资源行消失 + 前冻结实际硬删除 folder/file(含删除路径扩展出的版本链)flow,并在每个 hard-delete commit 后派发。 + `clear_space` 复用 `_list_space_child_resources` 的提交前结果,在 `async_delete_knowledge(..., + only_clear=True)` 返回后派发,完整删空间不走此分支。跨空间 move 从 valid 直接 move rows 冻结 flow, + 在 `session.commit()` 后派发;不包含只由 `_collect_version_chain_file_ids` 补出的 sibling。所有派发均 + best-effort,不改变既有权限、channel binding、空间时间、tag、vector、MinIO 与数据库清理的相对顺序, + 不延长资源事务或等待 worker。 + **测试**: T009 全部通过;已有 delete/move/clear 回归测试保持通过。 + **覆盖 AC**: AC-01, AC-02, AC-03, AC-04, AC-04A, AC-05, AC-06, AC-07, AC-08, AC-09, AC-15, AC-19, AC-20, AC-22, AC-23 + **设计依据**: design §3 决策 3/4、§4.3、坑 9/11/12 + **依赖**: T008, T009 + +### Wave 4:存量恢复脚本 + +- [x] **T011**: F071 存量迁移脚本测试 + **文件**: `src/backend/test/knowledge/test_migrate_f068_knowledge_chat_entries.py` + **逻辑**: fixture 覆盖原生 root、资源仍在原空间、missing、moved、deleted space、软删会话、空消息会话、 + 已有 entry、非知识空间 flow、未知 grammar 与跨 tenant 事实冲突。验证 manifest 覆盖指定 tenant 的全部 + 知识空间 session,按 `(tenant_id, chat_id)` 稳定排序并排除 `entry_flow_id/update_time`;dry-run 只读、 + 数据库身份不含密码、chat_id 样例掩码。apply 无 expected SHA、SHA 变化或 blocker 时拒写;分批提交后 + checkpoint 能以同一 manifest hash 续跑,且只更新活动、`entry_flow_id IS NULL` 的 recoverable orphan。 + 终态必须 `recoverable_orphan_remaining=0`,非法 entry/blocker 为零;`deleted_space_skipped` 独立非阻断, + 第二次 apply 零更新且消息/引用/create_time 不变。 + **覆盖 AC**: AC-11, AC-14, AC-15, AC-16, AC-18, AC-21, AC-22, AC-24 + **验证**: `cd src/backend && uv run pytest test/knowledge/test_migrate_f068_knowledge_chat_entries.py` + **依赖**: T002, T004 + +- [x] **T012**: DB-only 存量迁移脚本与运维说明 + **文件**: `src/backend/scripts/migrate_f068_knowledge_chat_entries.py`, + `src/backend/scripts/README.md` + **逻辑**: 实现 folder/file/root flow grammar、资源事实分类、稳定 manifest SHA、默认 dry-run、 + `--apply --expected-input-sha256` 门禁、`--tenant-id`、`--batch-size`、checkpoint cursor 与每批独立 commit; + 跨租户读取使用受控 bypass,UPDATE 显式带 session tenant,只访问数据库且不初始化 OpenFGA/Redis/ + Milvus/ES。报告 matched/updated/update_time 刷新数、blocker 与 `deleted_space_skipped`,遵守脚本 docstring、 + argparse、非零退出码和 README 可发现性要求,不输出消息正文或配置密码。 + **测试**: T011 全部通过。 + **覆盖 AC**: AC-11, AC-14, AC-15, AC-16, AC-18, AC-21, AC-22, AC-24 + **设计依据**: design §3 决策 5、§4.4、§7.2、§8.1 + **依赖**: T004, T011 + +### Wave 5:集成、E2E 与交付门禁 + +- [ ] **T013**: Repository/Service/Worker 集成与双数据库验证 + **文件**: `src/backend/test/knowledge/test_knowledge_chat_history_retention_integration.py`, + `features/v3.0.0-beta1/071-knowledge-space-chat-history-retention/verification.md` + **逻辑**: 在可用 MySQL 与 DM8 环境执行 migration、effective-entry 查询和 set-based UPDATE;记录 OR 查询 + 执行计划,只有双方言证明确有必要时才按 design §3 决策 2 改为 `UNION ALL` 或补复合索引。贯通 + delete/batch/clear/cross-space move → worker → root list/history/continue,核对只改 session entry,原 flow、 + 消息、引用与 create_time 不变;任务扫描前提交的并发 session 被回收,扫描后提交的残余按已接受边界 + 记录,不增加创建链路校验。完整删空间场景允许残留 entry,但所有 API 因空间不存在而拒绝。 + **覆盖 AC**: AC-01~AC-24(含 AC-04A) + **验证**: `cd src/backend && uv run pytest test/knowledge/test_knowledge_chat_history_retention_integration.py` + **依赖**: T006, T010, T012 + +- [ ] **T014**: F071 E2E 与前端零改动合同验证 + **文件**: `features/v3.0.0-beta1/071-knowledge-space-chat-history-retention/e2e-checklist.md` + **逻辑**: 执行 `/e2e-test features/v3.0.0-beta1/071-knowledge-space-chat-history-retention`。用两个用户覆盖 + folder/file 会话的单删、父子批删、`clear_space`、跨空间 move、同空间 move、改名、任务重复、移出再移回; + 等任务完成后从源空间 root 查看/打开/切换/继续/改名/删除,确认检索为源空间全量、目标空间和其他用户 + 不可见。验证任务完成前短暂不可见可接受、完整删除空间不可访问、页面与直接 API 一致;确认 + `chatApi.ts` URL、`FolderSession` 字段及 `useFolderChat/useFileChat` 无需新增入口状态或轮询。 + **覆盖 AC**: AC-01~AC-24(含 AC-04A) + **依赖**: T013 + +- [ ] **T015**: 全量质量门禁、迁移演练与交付复核 + **文件**: 本 Feature 全部改动、`features/v3.0.0-beta1/071-knowledge-space-chat-history-retention/verification.md` + **逻辑**: 运行 F071 聚焦测试、knowledge/chat_session 相关回归、ruff、架构守卫与 Alembic single-head;在 + 脱敏快照先 dry-run,核对 DB identity、分类、`unparseable=0`、`cross_tenant_conflict=0` 和 SHA,再以 + expected SHA 演练 apply、checkpoint 续跑、终态对账和第二次零更新。复核 Celery 注册/路由、默认延时、 + retry、日志脱敏与非知识空间回归;把 MySQL/DM8、API/E2E、未执行项和环境限制写入 verification,最后执行 + `/code-review --base `,不得把旧镜像回滚或清空 entry 当成功回滚方案。 + **覆盖 AC**: AC-01~AC-24(含 AC-04A) + **验证**: + - `cd src/backend && uv run pytest test/knowledge/ -k "chat or move or filelib"` + - `cd src/backend && uv run pytest test/chat_session/ test/database/test_alembic_single_head.py` + - `cd src/backend && uv run ruff format --check bisheng/knowledge bisheng/worker/knowledge scripts/migrate_f068_knowledge_chat_entries.py test/knowledge` + - `cd src/backend && uv run ruff check bisheng/knowledge bisheng/worker/knowledge scripts/migrate_f068_knowledge_chat_entries.py test/knowledge` + - `cd /Users/zhangguoqing/works/bisheng/.worktrees/feat-923-3.0.0-beta1 && bash scripts/arch-guard.sh` + **依赖**: T014 + +--- + +## 实际偏差记录 + +> 只留一行指针,论证写入 design.md。若实施发现必须推翻已确认的 entry 字段、best-effort 一致性、 +> 完整删除空间边界或存量迁移合同,先暂停并重新确认,再更新 design 与本节。 + +- 测试基建适配:共享 SQLite `knowledge` DDL 补齐既有 F050 creation-idempotency 两列,v2 filelib + 单测改用当前 `get_open_api_operator[_async]` 接口;仅修正测试与现行模型/接口的漂移,不改变生产语义。 +- T014 已生成环境门禁式 API E2E 与手工清单;当前无专用测试租户 JWT 和运行中 worker,测试收集结果为 + `4 skipped`,不标记任务完成。 diff --git a/features/v3.0.0-beta1/071-knowledge-space-chat-history-retention/verification.md b/features/v3.0.0-beta1/071-knowledge-space-chat-history-retention/verification.md new file mode 100644 index 0000000000..dd1abc7d20 --- /dev/null +++ b/features/v3.0.0-beta1/071-knowledge-space-chat-history-retention/verification.md @@ -0,0 +1,66 @@ +# Verification: F071 知识空间历史对话按空间保留 + +**日期**:2026-09-18 +**分支 / worktree**:`feat/923-3.0.0-beta1` / `.worktrees/feat-923-3.0.0-beta1` +**当前结论**:代码与本地自动化验证通过;真实 MySQL/DM8、运行中 Celery/API E2E、浏览器和脱敏快照迁移演练尚未执行,不能作为发布完成证据。 + +## 1. 已实现合同 + +- `message_session.entry_flow_id` nullable 字段及单列索引;Alembic 只做 DDL。 +- effective entry Repository:列表、chat+entry 校验、文件会话复用和精确 flow 批量回收。 +- 原空间/具体资源权限先于 session 使用;历史与新消息始终使用原 `session.flow_id`。 +- recovered root 使用原空间全量可见文件检索;内部 `entry_flow_id` 不进入 HTTP 响应。 +- 文件/文件夹删除、batch、`clear_space` 和跨空间移动在各自 DB commit 后 best-effort 派发;同空间移动和完整删除空间不派发。 +- Celery 默认延时 5 秒、每批 500 flow、`acks_late`、最多 3 次 retry;重复执行只更新 null entry。 +- DB-only 迁移脚本提供稳定 manifest SHA、apply 确认、blocker、掩码样例、checkpoint 和终态校验。 +- 前端零代码改动;新增环境门禁式 API E2E 与页面/运维手工清单。 + +## 2. 已执行验证 + +所有命令从 `src/backend/` 执行,并使用主 worktree 的 `config.yaml`。 + +| 验证 | 结果 | 说明 | +|---|---|---| +| F071 聚焦测试、知识 chat 回归、Celery wiring、迁移图 | `66 passed` | 覆盖 schema、Repository、入口校验、检索范围、worker、触发顺序、迁移脚本和 single head | +| `test/chat_session/` | `5 passed` | 通用会话路径回归 | +| `test/knowledge/ -k "chat or move or filelib"` | `113 passed, 1 skipped` | 1 项为既有环境条件 skip;其余知识问答/移动/filelib 回归通过 | +| move + v2 filelib + knowledge space service | `43 passed` | 修正旧测试 fixture 漂移后通过 | +| F071 API E2E 收集 | `4 skipped` | 缺少 `E2E_F071_TOKEN`、运行中 API/worker;按设计不伪造通过 | +| affected-file Ruff | 通过 | 新增/修改的 F071 Python 文件与测试无新增 lint 问题 | +| `git diff --check` | 通过 | 无 whitespace error | +| `scripts/arch-guard.sh` | 通过 | 无架构守卫违规 | + +本地测试使用 SQLite 或 mock 锁定行为,不替代双数据库与真实异步基础设施验证。 + +## 3. 迁移脚本验收状态 + +脚本:`src/backend/scripts/migrate_f068_knowledge_chat_entries.py` + +已自动验证: + +- root/present/missing/moved/deleted-space/soft-deleted/unparseable/cross-tenant/type-conflict 分类; +- manifest 按 `(tenant_id, chat_id)` 稳定排序,entry 改写不改变 SHA; +- 数据库身份不输出密码、chat 样例掩码; +- 无 reviewed SHA、SHA 不同、blocker 或非法 checkpoint cursor 时拒写; +- 只更新活动、null entry、知识空间 session,保留原 flow/create_time,重复 apply 零更新。 + +待运维环境验证: + +- [ ] 在脱敏快照 dry-run,核对 DB identity、分类、blocker 和 SHA。 +- [ ] MySQL apply、批次中断、checkpoint 续跑、终态及第二次零更新。 +- [ ] DM8 apply、批次中断、checkpoint 续跑、终态及第二次零更新。 +- [ ] 核对消息、引用、session create_time 与原 flow 的前后 checksum。 + +## 4. 发布前硬门禁 + +- [ ] MySQL 与 DM8 分别执行 Alembic upgrade/downgrade 演练,确认唯一 head。 +- [ ] 两种数据库记录 effective-entry OR 查询和批量 UPDATE 执行计划;只有证据显示必要时才调整为 `UNION ALL` 或复合索引。 +- [ ] 运行 API + `knowledge_celery`,执行 [E2E 清单](./e2e-checklist.md) 与自动化文件。 +- [ ] 两个以上用户验证 owner/permission/目标空间隔离;浏览器验证历史、继续问答、重命名和删除。 +- [ ] broker publish 失败、worker retry/终态失败、并发提交窗口和人工修复演练。 +- [ ] 在 reviewed SHA 下完成生产维护窗口 dry-run/apply;`unparseable=0`、`cross_tenant_conflict=0`、非法 entry=0、`recoverable_orphan_remaining=0`。 +- [ ] 完成最终代码评审与 CI;未完成以上门禁前不得把本文件结论提升为发布完成。 + +## 5. 回滚边界 + +新增列可保留,不能通过清空 `entry_flow_id` 或改写原 `flow_id` 回滚。旧镜像会忽略 entry,导致 recovered 会话再次失去入口;功能启用后应关闭相关入口并前向修复,而不是把旧镜像当作可用业务终态。 diff --git a/features/v3.0.0-beta1/071-linsight-citation-compare/e2e-checklist.md b/features/v3.0.0-beta1/071-linsight-citation-compare/e2e-checklist.md deleted file mode 100644 index 987fe0c87d..0000000000 --- a/features/v3.0.0-beta1/071-linsight-citation-compare/e2e-checklist.md +++ /dev/null @@ -1,116 +0,0 @@ -# E2E 验证清单: F071 任务模式报告与来源对照查看 - -**测试环境**: http://192.168.106.120:3002/workspace(test,前端 `feat/3.0.0-beta2` 95add03,后端 116 release) -**前置条件**: 登录一个能看到「OKR 制度差异分析」任务会话的账号;该会话的报告 `okr-v2-evaluation-report.md` 引用了知识库里的两份 docx(OKR绩效管理运营规则 2025 / 2026),共 33 个角标。 -**API 层**: F071 未改任何后端接口,无新增 pytest 用例;角标解析沿用 F047 / F054 的 `/api/v1/citations/resolve`。 - -## 3.1 对照布局 - -### AC-01: 点文档角标进入对照 -- [ ] 打开会话 → 「已整理输出文件」点 report.md -- [ ] 点报告里任一蓝色角标 -- [ ] 预期: 报告在左、来源 docx 在右并排,互不遮挡 - -### AC-02: 对话列收起与恢复(≥1024px) -- [ ] 进入对照后中间对话列不可见,两栏占满 -- [ ] 点来源栏「收起来源」→ 对话列恢复原宽 - -### AC-03: 分隔线拖动与最小宽度 -- [ ] 拖动分隔线向左 → 比例变化;拖到极限时左栏不小于 400px(≥1280)/ 360px(1024–1279) -- [ ] 刷新页面重新进入对照 → 比例保持 - -### AC-04: 对照中点另一个角标只替换右栏 -- [ ] 对照中点另一份文件的角标 → 右栏换成那份文件,页面上没有新增浮层 - -### AC-05: 仅收起按钮 / Esc 关闭来源栏 -- [ ] 点报告正文、滚动报告 → 来源栏不关闭 -- [ ] 按 Esc → 只关来源栏,报告仍在 - -### AC-06: <1024px 分段切换 -- [ ] 窗口缩到 1024 以下,点角标 → 出现「报告 / 来源」分段控制器,自动在「来源」 -- [ ] 点「回到报告」→ 报告停在刚才那个角标附近 - -### AC-07: 对照 + 全屏 -- [ ] 对照中点全屏 → 两栏铺满,当前引用与定位不变 - -### AC-19 / AC-20 / AC-21: 两级关闭 -- [ ] AC-19: 点报告栏 ✕「关闭工作区」→ 报告与来源都关闭、对话列恢复;全屏中则同时退出全屏 -- [ ] AC-20: 点报告栏 ←「返回文件列表」→ 来源栏关闭,显示文件列表 -- [ ] AC-21: 「收起来源」与「关闭工作区」图标不同,悬停提示分别为「收起来源(Esc)」「关闭工作区」 - -## 3.2 定位与关联 - -### AC-08: PDF bbox 定位(需有 PDF 来源的会话) -- [ ] 点 PDF 来源角标 → 被引片段高亮并滚到可视区 - -### AC-09: docx 文本定位 -- [ ] 点 docx 来源角标 → 右栏自动滚到被引段落,段落有浅蓝底 + 左侧竖线 - -### AC-10 / AC-11: 定位失败 / xlsx -- [ ] 来源定位不到或为 xlsx 时,右栏顶部出现「没能自动找到被引段落,下面是引用的原文」及原文 -- 说明: 真实数据难以构造,匹配失败路径由 `citationLocate.test.ts` 覆盖 - -### AC-12: 当前引用标记 -- [ ] 被点角标保持加深底色;来源段落保持高亮,直到切换或关闭 - -### AC-13 / AC-14: 同一文件的逐处切换 -- [ ] 右栏显示「第 k 处,共 n 处」;点下一处 → 报告滚到下一个同文件角标并加深,右栏跳到对应段落 -- [ ] 同一段被引两次时,点第二次出现的角标,加深的是第二个而不是第一个 -- [ ] AC-14: 只被引用 1 次的文件不显示上一处 / 下一处 - -## 3.3 不变量 - -- [ ] AC-15: 点网页(紫色)角标 → 新标签页打开原网页,不进入对照 -- [ ] AC-16: 无权限 / 已失效的角标 → 仍显示原有状态,不进入对照 -- [ ] AC-17: 日常对话里的角标仍打开对话旁的引用面板,行为不变 -- [ ] AC-18: 来源栏、分隔线没有 backdrop-filter - -## 回归检查 -- [ ] 工作区文件列表、单文件预览、另存为、全屏开关正常 -- [ ] 控制台无新增报错 - ---- - -## 执行记录(2026-09-29,test 环境 120:3002,前端 95add03) - -执行方式:Chrome 浏览器,已登录账号,会话「OKR 制度差异分析」(33 个角标,均为知识库 docx 来源)。 - -| AC | 结果 | 实测 | -|---|---|---| -| AC-01 | ✅ | 报告栏右边界 = 来源栏左边界,无遮挡 | -| AC-02 | ✅ | 对照时对话输入框宽度 0;收起来源后恢复 742px | -| AC-03 | ✅ | 拖到最左时报告栏停在 400px(比例 27%);刷新后重新进入仍为 27% | -| AC-04 | ✅ | 对照中点另一份文件的角标,右栏换成 2025.docx,来源栏仍只有 1 个、无新增浮层 | -| AC-05 | ✅ | 点击 / 滚动报告不关闭来源栏;真实按 Esc 只收起来源栏 | -| AC-06 | ⚠️ 本地已测 | test 上浏览器窗口无法缩到 1024 以下;同一份代码在本地(代理同一 test 后端)960px 下验证过:分段控制器自动切到「来源」,「回到报告」后当前角标在可视区 | -| AC-07 | ✅ | 全屏后仍为对照,当前角标与「第 2 处,共 17 处」不变 | -| AC-08 | ⏭️ 无数据 | 该会话没有 PDF 来源;PDF 路径代码未改 | -| AC-09 | ✅ | docx 自动定位,可视区内 29 个高亮段落 | -| AC-10 / AC-11 | ⏭️ 单测覆盖 | 真实数据全部定位成功,失败路径由 `citationLocate.test.ts` 覆盖 | -| AC-12 | ✅ | 当前角标底色 rgb(190,218,255)(brand-100),普通角标 rgb(232,243,255) | -| AC-13 | ✅ | 下一处 / 上一处在 第 1 ⇄ 第 2 处切换,报告同步滚动;点同段第二次出现的角标(第 6 个)标记落在它自己,显示「第 4 处」 | -| AC-14 | ⏭️ 无数据 | 该会话每个来源都被引用 ≥ 2 次 | -| AC-15 | ⏭️ 无数据 | 该会话无网页来源 | -| AC-16 | ⏭️ 无数据 | 该账号对两份文件都有权限 | -| AC-17 | ⏭️ 未执行 | 日常对话不传 `onOpenSourcePreview`,走原逻辑;未在界面上回归 | -| AC-18 | ✅ | 页面上 backdrop-filter 非 none 的元素数为 0 | -| AC-19 | ✅ | 全屏对照中点「关闭工作区」:来源关闭、退出全屏、对话列恢复 742px(收起动画约 1.5 秒) | -| AC-20 | ✅ | 「返回工作区」后来源关闭、显示文件列表 | -| AC-21 | ✅ | 两个按钮 aria-label 分别为「收起来源(Esc)」「关闭工作区」,图标不同 | - -结论:21 条 AC 中 13 条在 test 上通过,1 条(AC-06)仅本地验证,7 条未在界面上覆盖:AC-08、AC-14、AC-15、AC-16 缺测试数据,AC-10、AC-11 由单测覆盖,AC-17 未执行。 - -## 补充验收(2026-09-30,test 环境 120:3002,前端 3e44d8f):上一处 / 下一处改按被引段落计数 - -改动:来源栏的计数和上一处 / 下一处改为按去重后的被引段落切换,不再逐个角标切换。同一段按 chunk 本身识别(chunkId → chunkIndex → 正文),不按引用组识别;段落按在原文件中的先后排序;每步报告滚到离当前最近的、引用该段的角标,引用该段的角标全部加深。不显示"本段被引用 x 次"。 - -会话「测试工程师考核标准」:报告 31 个角标,引用 1 份 docx 的 6 个段落,分布在 3 个引用组里(第 5 段三个组都有)。 - -| 检查项 | 结果 | 实测 | -|---|---|---| -| 计数按段落 | ✅ | 点第 21 个角标(标号 5)→「第 5 处,共 6 处」,报告里 5 个"5"同时加深 | -| 下一处 / 上一处 | ✅ | 5 → 6 → 5 → 4,每步右栏高亮的都是新段落 | -| 首尾循环 | ✅ | 第 6 处点下一处 → 第 1 处;第 1 处点上一处 → 第 6 处,右栏高亮跟着换 | -| 点角标进入 | ✅ | 点第 1 个角标 →「第 1 处,共 6 处」 | -| Esc | ✅ | 只收起来源栏,报告保留,角标加深全部清除 | -| 单测 | ✅ | `sourcePassages.test.ts` 6 个用例通过 | diff --git a/features/v3.0.0-beta1/072-daily-chat-citation-handles/e2e-checklist.md b/features/v3.0.0-beta1/072-daily-chat-citation-handles/e2e-checklist.md deleted file mode 100644 index 05a0325f56..0000000000 --- a/features/v3.0.0-beta1/072-daily-chat-citation-handles/e2e-checklist.md +++ /dev/null @@ -1,37 +0,0 @@ -# E2E 验收记录: F072 工作台日常模式引用改用短句柄 - -**测试环境**: http://192.168.106.120:3002/workspace(test,release `8bc7ae7`,后端 116 同版本) -**账号**: 用户 3(管理员) -**会话**: `d008924f253c40ebbf959b3b1b3f2fa0`,模型 deepseek-v4-flash(774),知识空间「鲁力的部门的知识空间」(3812) + 「有视频【测试溯源】」(4195),联网检索开 -**执行时间**: 2026-09-30 17:09–17:25 - -## 第 1 轮:「OKR 绩效管理运营规则 2026 版相比 2025 版有哪些主要变化?……并简要联网补充」 - -| AC | 结果 | 实测 | -|---|---|---| -| AC-01 | ✅ | 落库的工具结果:知识库为 `S11`…,联网为 `"ref": "S1"`…;工具结果中 ``、`citation_key`、`knowledgesearch_`、`websearch_` 出现次数均为 0 | -| AC-02 / AC-03 | ✅ | 后端请求日志里的系统提示词:租户保存的旧「# 引用规则」段已替换为「# 来源编号」,其后「## 其他信息 当前时间:2026-09-30 17:09:35」保留;不含 `` 与私有区字符 | -| AC-05 | ✅ | 流式期间每 150ms 采样页面文本共 194 次,字面 `[Sn]` 命中 0 次;角标边写边出现(知识库蓝色、联网紫色) | -| AC-06 | ✅ | 落库 `msg` 含 3 组标记、0 个字面编号;执行记录 5 个文本段均已转换 | -| AC-13 | ✅ | `[daily-citation-audit] chat=d008924f… model=774 sources_seen=83 cited=8 unknown_handles=0 legacy_markers=0` | -| AC-14 | ✅ | `message_citation_relation` 只绑定正文实际引用的 2 个来源(1 知识库 + 1 联网) | -| 决策 1 | ✅ | Redis `linsight:cite_handles:d008924f…` 共 44 个编号,**无** `meta:enabled`(日常模式建表不钉任务模式契约) | - -## 第 2 轮:「你上面说试行期 6 个月,这个说法出自哪里?请不要重新检索……」 - -| AC | 结果 | 实测 | -|---|---|---| -| AC-11 | ✅ | 模型思考内容写出「上一轮回答中……标注了来源[S28]」——历史回放给模型的是编号而非长 key | -| AC-12 | ✅ | 本轮无任何工具调用(`sources_seen=0`),正文引用转成上一轮来源 `knowledgesearch_89cb2dea:0`,并绑定到本轮消息 441496;点击角标在停靠面板打开 `OKR绩效管理运营规则 2025.docx` | -| AC-15 | ✅ | 点复制:剪贴板文本无私有区字符、无内部 key、无字面 `[Sn]` | -| AC-16 | ✅ | 刷新后两轮角标照常渲染与解析 | - -## 未在界面覆盖 - -- AC-04(三语默认模板)、AC-07~AC-10、AC-17、AC-19:由单测覆盖(`test/citation/test_handle_stream_converter.py`、`test_daily_handle_rules.py`、`test/workstation/test_daily_citation_tools.py`、`test/common/test_image_view_react_loop.py`) -- AC-20(知识空间 / 频道不变):由 `test/citation/test_citation_prompt_rules.py`、`test/channel`、知识空间对话引用用例覆盖,未在界面回归 - -## 观察 - -1. **思考内容里有字面 `[S28]`**:模型的 reasoning(折叠的「已深度思考」)会提到编号。转换只作用于回答正文,思考内容原样展示。展开思考时用户能看到 `[S28]`。2026-09-30 用户决定保持现状(思考是模型原始推理,与任务模式一致),spec AC-05 已明确范围为回答正文。 -2. **首次知识库检索失败**:第 1 轮第一次检索返回 Milvus `service unavailable`(后端刚重启、向量库连接冷启动),与本 Feature 无关;模型随后重试成功。 diff --git a/features/v3.0.0-beta1/072-nvdb-security-fixes/design.md b/features/v3.0.0-beta1/072-nvdb-security-fixes/design.md new file mode 100644 index 0000000000..29aff4ea0e --- /dev/null +++ b/features/v3.0.0-beta1/072-nvdb-security-fixes/design.md @@ -0,0 +1,101 @@ +# Design: NVDB 漏洞修复(F072) + +**关联规格**: [spec.md](./spec.md) +**版本**: v3.0.0-beta2 + +> 本文档是 How 的唯一真相。接手时先读这里。 + +--- + +## 0. 调整原则 + +- 每个漏洞只修根因所在的那一层,另加一道"最后一道门"式的防御(接口层拒绝 + 数据层再校验),不顺手重构周边。 +- 不引入新表、新错误码、新对外 API、新前端改动。 +- 代码节点 RCE 根因不在本 Feature(用户明确要求另议)。 + +--- + +## 1. V1 JWT 密钥(AC-01~05) + +**根因**:`Settings.jwt_secret` 在代码里有默认值(2.4 为 `secret`,2.6+ 为 `secret_cF2k…`),且默认部署不覆盖;令牌校验只看 `user_id`,伪造 `user_id=1` 即超管。 + +**决策 1 — 默认值置空,两个历史值列入黑名单。** +`bisheng/core/config/settings.py` `jwt_secret: str = ""`。新模块 `bisheng/user/domain/services/jwt_secret.py` 持有 `LEGACY_JWT_SECRETS`,命中即视为未配置。 + +**决策 2 — 未配置时生成一次、存系统配置表。** +`ConfigKeyEnum.JWT_SECRET = "jwt_secret"`,值为 `secrets.token_urlsafe(48)`。选配置表而非本地文件:api / worker / linsight worker 是不同进程甚至不同节点,必须共用同一份;配置表本来就是它们的共同真相源。首次启动多进程竞争插入时靠 `key` 唯一约束,输家捕获 `IntegrityError` 后回读胜者的值。 + +**决策 3 — 解析顺序与缓存策略。** +`resolve_jwt_secret()`:yaml 值(非黑名单)→ 进程内缓存的生成值 → 读库 / 生成。yaml 值每次实时读、不缓存(读的是内存对象,零成本;也让测试替换 `settings` 后立刻生效),只缓存生成值。`AuthJwt.__init__` 改为调用它;模块内的 DAO / 数据库导入延迟到真正落库时才发生,避免把数据库包拉进早期导入链。 + +**决策 4 — 影响面。** +只有登录 cookie 走这把密钥。PAT / 开放 API 凭据是库表比对,不受影响。升级后旧 cookie 失效 = 全员重登,写进发版说明。 + +**坑 1**:`test/tenant/test_tenant_auth.py` 用"模块未导入时才预置 MagicMock"的方式隔离 `config_service`;合跑时拿到的是真实 settings,密钥为空就会去碰库。已加 autouse fixture 钉住密钥。以后新 JWT 测试一律用 `conftest.mock_settings` 或显式 `monkeypatch settings.jwt_secret`。 + +--- + +## 2. V2 排序参数 SQL 注入(AC-06~08) + +**根因**:`SpaceFileDao.order_field_text()` 把 `order_field` / `order_sort` 原样 f-string 进 `ORDER BY`,`/space/{id}/children` 与 `/space/{id}/search` 直接透传查询参数。 + +**决策 5 — 两道门。** +- 接口层:`knowledge_space_file.py` 定义 `SpaceFileOrderField = Literal["file_type","file_name","file_size","update_time","create_time"]`、`SpaceFileOrderSort = Literal["asc","desc","ASC","DESC"]`,两个端点的参数改用这两个类型 → FastAPI 对集合外值返回 422。 +- 数据层:`order_field_text()` 开头按同一集合校验,不通过抛 `ValueError`。`knowledge_file.py` 里另一处调用同一函数,自动受保护。 + +取值集合来源:client `SortType` 枚举(file_name / file_type / file_size / update_time)+ 既有 `update_time` 默认排序 + `create_time`(表上有列、语义合理)。大小写两种方向都收,是为了不改前端。 + +--- + +## 3. V3 HTML 任意文件读取(AC-09~10) + +**根因**:`HTML2MarkdownConverter.convert()` 把源 HTML 所在目录以 `file://` URI 作为 `base_url`,`_download_media_file()` 对 `file` scheme 直接 `shutil.copy`。于是 `file:///etc/passwd` 与相对 `../../etc/passwd`(`urljoin` 后同样落到 `file://`)都能把服务器文件复制进知识库媒体目录,再经对象存储回传。 + +**决策 6 — 目录围栏而非删分支。** +新增 `_is_local_media_allowed(candidate)`:`candidate.resolve()` 必须位于 `Path(source_html_filepath).resolve().parent` 之下,否则记 warning 并返回 `None`。保留分支是因为"HTML 旁边带一张图"在语义上是合法的(虽然当前上传路径不会产生),删分支收益不大而围栏已足够。`resolve()` 同时处理了符号链接。 + +**未做**:`http/https` 分支的 SSRF 限制(见 spec 排除项)。 + +--- + +## 4. V4 本地路径任意文件读取(AC-11~12) + +**根因**:`core/cache/utils.py` 的 `file_download` / `async_file_download` 第一分支——"字符串去掉 `?` 后是个存在的本地文件就直接返回"。微调预置文件接口把请求体里的 `files` 原样传入,再上传到对象存储。 + +**决策 7 — 在共用工具里修,而不是在微调接口修。** +调用方有 6 处(微调、日常聊天附件、灵思提交文件、知识库预览、开放接口文件库、图片识图);只堵微调等于留 5 个入口。新增 `_ensure_allowed_local_file(path)`:真实路径必须位于 `CACHE_DIR`(`save_download_file` 的落盘目录)或 `tempfile.gettempdir()` 之下,否则 `ValueError`。同步版有两处本地路径出口(首分支与末尾兜底),都加了。 + +**为什么这两个根目录**:追了全部调用方,本地路径只可能来自本进程先前的下载落盘;3.0 的上传接口返回的已是对象存储分享链接,前端从不发本地路径。注释里那句"挂载存储卷时可直接读"的场景没有任何代码路径产生,视为未支持。 + +--- + +## 5. V5 建应用权限绕过(AC-13~14) + +**根因**:`web_menu` 的 `create_app` 只控制前端按钮;`POST /workflow/create` 与 `POST /assistant` 只挂 `@require_quota`。 + +**决策 8 — 新依赖 `LoginUser.get_app_creator_user`。** +超管直接放行;否则 `assert_effective_web_menu_contains(user_id, "create_app")`(既有方法,含部门管理员合并与孤儿菜单剥离,与登录接口返回给前端的 `web_menu` 同源)。两个创建端点把 `Depends(UserPayload.get_login_user)` 换成它。判定口径与 `platform/.../BuildPage/apps.tsx` 的 `canCreateApp` 完全一致(超管 或 菜单含 create_app;部门管理员不再单独放行——前端已如此)。 + +**未做**:复制应用、模板创建走的是同两个端点,天然覆盖;`run_once` 对非 code 节点的权限不动。 + +--- + +## 6. 测试 + +| 文件 | 覆盖 | +|------|------| +| `test/user/test_jwt_secret_resolution.py` | 解析顺序、黑名单、缓存策略、落库、首启竞争、代码无默认值 | +| `test/knowledge/test_space_file_order_whitelist.py` | DAO 白名单拒绝注入串;两个端点参数注解为 Literal 别名 | +| `test/knowledge/rag/test_html_media_local_file_guard.py` | 绝对 `file://`、相对穿越、符号链接均不复制;同目录文件仍复制 | +| `test/core/test_file_download_local_guard.py` | 同步 / 异步两版:缓存目录内放行,目录外 / 穿越 / 符号链接 / 带签名参数一律拒绝 | +| `test/api/test_create_app_permission_gate.py` | 依赖三态;两个创建端点确实挂了该依赖 | + +复现类验证(用报告里的 PoC 打修复后的环境)放在 tasks.md 的 e2e 项。 + +--- + +## 7. 运维与文档 + +- `docker/bisheng/config/config.yaml` 增加注释掉的 `jwt_secret` 示例与说明。 +- `docs/architecture/08-deployment.md` 配置系统一节补 `jwt_secret` 语义;升级 checklist 加 v3.0 条目。 +- Release Notes 三条见 spec §4。 diff --git a/features/v3.0.0-beta1/072-nvdb-security-fixes/spec.md b/features/v3.0.0-beta1/072-nvdb-security-fixes/spec.md new file mode 100644 index 0000000000..c9a6f05859 --- /dev/null +++ b/features/v3.0.0-beta1/072-nvdb-security-fixes/spec.md @@ -0,0 +1,89 @@ +# Feature: NVDB 漏洞修复(F072) + +> **本文档定位 — 纯 What(需求口径,不随代码漂移)** +> +> spec 只回答 **做什么 / 验收标准 / 不做什么**。 +> **所有 How 一律不写在这里。** How 的唯一真相在 [design.md](./design.md)。 + +**关联来源**: NVDB(工信部网络安全威胁和漏洞信息共享平台)2026-09-02 批次 7 份漏洞报告(去重后 5 个独立漏洞) +**优先级**: P0(安全发版) +**所属版本**: v3.0.0-beta2(目录沿 F067 先例挂在 `features/v3.0.0-beta1/`) +**依赖**: 无本版本未交付 Feature 前置 + +> **范围边界** +> - **本次纳入**: +> - V1 JWT 签名密钥硬编码,可伪造任意用户(含超管)。 +> - V2 知识空间目录 / 搜索接口的排序参数拼接进 SQL,可时间盲注。 +> - V3 知识库上传 HTML 时,`file://` 与相对路径引用可把服务器任意文件复制进对象存储。 +> - V4 微调「预置训练文件」接口接受任意本地路径,可读服务器任意文件。 +> - V5 前端隐藏「新建应用」入口但后端创建接口不校验,普通用户可绕过直接建应用(工作流代码节点 RCE 链的第一跳)。 +> - **本次明确排除**(已与用户确认,另立 Feature): +> - 工作流代码节点的执行沙箱与权限策略(RCE 根因)。V5 只封第一跳,不改变代码节点本身的执行方式。 +> - HTML 解析时对 `http/https` 外部资源的 SSRF 限制(内网地址、元数据服务)——与本批报告无关的独立问题。 +> - 单节点试运行(`run_once`)接口对非 code 节点的进一步权限收紧。 +> - 向 2.x 旧版本线回移(是否 backport 由发版决策另定)。 + +--- + +## 1. 用户故事 + +作为 **平台运维**, +我希望 **升级后不改任何配置,登录态就不再能被公开密钥伪造**, +以便 **不用逐个客户排查 config.yaml 有没有改过密钥**。 + +作为 **平台管理员**, +我希望 **关掉普通用户的「新建应用」入口后,后端接口同样拒绝**, +以便 **菜单权限是真的权限,而不是只藏一个按钮**。 + +作为 **普通用户**, +我希望 **上传知识库文件、浏览知识空间的正常操作不受影响**, +以便 **安全修复对我是透明的**。 + +--- + +## 2. 验收标准 + +> AC-ID 在本特性内唯一。字段名 / 表结构 / 接口路径等实现细节见 design.md。 + +### 2.1 V1 JWT 密钥 + +- **AC-01** — THE SYSTEM SHALL 不在代码中携带任何可用的 JWT 签名密钥默认值。 +- **AC-02** — WHERE `config.yaml` 配置了 `jwt_secret` 且其值不是历史版本曾内置的默认值, THE SYSTEM SHALL 使用该值签发与校验登录态。 +- **AC-03** — WHERE `config.yaml` 未配置 `jwt_secret`、为空或其值等于历史内置默认值, THE SYSTEM SHALL 在首次需要时生成一个随机密钥并持久化,之后同一部署的所有进程使用同一密钥。 +- **AC-04** — WHEN 部署从携带内置默认值的旧版本升级且未配置 `jwt_secret`, THE SYSTEM SHALL 使旧密钥签发的登录态全部失效(用户需重新登录);个人访问令牌与开放 API 凭据不受影响。 +- **AC-05** — WHEN 运维修改 `config.yaml` 的 `jwt_secret` 或删除已持久化的生成密钥并重启, THE SYSTEM SHALL 以新密钥工作(轮换手段)。 + +### 2.2 V2 排序参数 SQL 注入 + +- **AC-06** — THE SYSTEM SHALL 只接受固定集合内的排序字段(文件类型、文件名、文件大小、更新时间、创建时间)与排序方向(升序 / 降序);集合外的取值在接口层以参数校验失败拒绝,不进入 SQL。 +- **AC-07** — IF 任何调用方把集合外的排序字段或方向传到数据访问层, THEN THE SYSTEM SHALL 抛出错误而不是拼接执行。 +- **AC-08** — THE SYSTEM SHALL 保持现有前端使用的全部排序取值继续可用。 + +### 2.3 V3 HTML 任意文件读取 + +- **AC-09** — WHEN 解析用户上传的 HTML 且其中引用了以 `file://` 或相对路径指向本地文件的媒体, THE SYSTEM SHALL 仅在该文件位于该 HTML 自身所在目录(含子目录,符号链接按真实路径判定)内时复制,否则跳过并记录日志。 +- **AC-10** — THE SYSTEM SHALL 保持 `data:` 内嵌媒体与 `http/https` 远程媒体的既有处理不变。 + +### 2.4 V4 本地路径任意文件读取 + +- **AC-11** — WHEN 文件下载工具收到一个指向本地已存在文件的路径, THE SYSTEM SHALL 仅在该文件位于本进程的下载缓存目录或系统临时目录内(按真实路径判定)时返回,否则拒绝。 +- **AC-12** — THE SYSTEM SHALL 保持对象存储分享链接、带签名的对象存储相对路径、`http/https` 链接的既有下载行为不变。 + +### 2.5 V5 建应用权限绕过 + +- **AC-13** — WHEN 非超级管理员用户调用创建工作流或创建助手的接口且其生效菜单权限不含「新建应用」, THE SYSTEM SHALL 拒绝(403)。 +- **AC-14** — THE SYSTEM SHALL 对超级管理员与生效菜单含「新建应用」的用户保持创建行为不变;判定口径与前端「新建应用」入口的显示条件一致。 + +--- + +## 3. 不做什么 + +见「范围边界 — 本次明确排除」。 + +--- + +## 4. 发版说明要点(写入 Release Notes) + +- 升级后所有用户需重新登录一次(V1)。 +- 若运维曾把老版本代码里的默认 `jwt_secret` 抄进 `config.yaml`,该值会被忽略,行为等同未配置。 +- 关闭了普通用户「新建应用」菜单的租户,普通用户直接调接口建应用会开始返回 403(V5);这是修复而非回归。 diff --git a/features/v3.0.0-beta1/072-nvdb-security-fixes/tasks.md b/features/v3.0.0-beta1/072-nvdb-security-fixes/tasks.md new file mode 100644 index 0000000000..58759c3bb5 --- /dev/null +++ b/features/v3.0.0-beta1/072-nvdb-security-fixes/tasks.md @@ -0,0 +1,67 @@ +# Tasks: NVDB 漏洞修复(F072) + +**关联规格**: [spec.md](./spec.md) · [design.md](./design.md) +**版本**: v3.0.0-beta2 + +--- + +## 状态 + +| 步骤 | 状态 | 备注 | +|------|------|------| +| spec.md | ✅ 已确认 | 用户在会话中确认修复范围与 JWT 处理口径(2026-09-14);代码节点沙箱另议 | +| design.md | ✅ 已确认 | 同上 | +| tasks.md | ✅ 已拆解 | 3 Wave / 11 项 | +| 实现 | 🔄 进行中 | 后端代码、单测、基线回归完成(commit 16a8c6694);e2e 复现验证与合入待做 | + +--- + +## 开发模式 + +安全修复走轻量轨道:每个漏洞一组"先写会失败的测试 → 修 → 测试通过"。不建 Alembic、不加错误码、不改前端。 + +--- + +## Tasks + +### Wave 1 — 后端修复(可并行) + +- [x] **T001**: JWT 密钥解析模块 + 去掉代码默认值 + **文件**: `bisheng/user/domain/services/jwt_secret.py`(新)、`bisheng/core/config/settings.py`、`bisheng/common/models/config.py`、`bisheng/user/domain/services/auth.py` + **AC**: AC-01、AC-02、AC-03、AC-05 +- [x] **T002**: 排序参数白名单(接口 Literal + DAO 校验) + **文件**: `bisheng/knowledge/domain/models/knowledge_space_file.py`、`bisheng/knowledge/api/endpoints/knowledge_space.py` + **AC**: AC-06、AC-07、AC-08 +- [x] **T003**: HTML 本地媒体目录围栏 + **文件**: `bisheng/knowledge/rag/pipeline/loader/utils/md_from_html.py` + **AC**: AC-09、AC-10 +- [x] **T004**: 文件下载工具本地路径围栏 + **文件**: `bisheng/core/cache/utils.py` + **AC**: AC-11、AC-12 +- [x] **T005**: 建应用权限依赖并挂到两个创建端点 + **文件**: `bisheng/user/domain/services/auth.py`、`bisheng/api/v1/workflow.py`、`bisheng/api/v1/assistant.py` + **AC**: AC-13、AC-14 + +### Wave 2 — 测试与文档 + +- [x] **T006**: 五组单测(见 design §6)+ 修正 `test/tenant/test_tenant_auth.py` 合跑时的密钥来源 +- [x] **T007**: `docker/bisheng/config/config.yaml` 注释示例、`docs/architecture/08-deployment.md` 配置说明与升级 checklist +- [x] **T008**: `release-contract.md` 登记 F072(表 1 无新增领域对象、INV-36、表 3、变更历史) + +### Wave 3 — 验证与发版 + +- [x] **T009**: 基线对比回归:在改动前后的工作树对同一测试选集跑 pytest,确认无新增失败(2026-09-14:14 个目录 3900+ 用例,失败集合与基线一致;唯一差异 `test_user_string_lengths` 单次抖动、单跑通过) +- [ ] **T010**: e2e 复现验证(部署到测试机后用 NVDB 报告 PoC 逐条打): + - 用旧默认密钥伪造 `user_id=1` 的 cookie → 422/401,不再是超管 + - `children?order_field=if(1=1,sleep(5),1)` → 422,且响应无延迟 + - 上传含 `file:///etc/passwd` 与 `../../etc/passwd` 的 HTML → 解析成功但无对应媒体文件 + - `POST /finetune/job/file/preset {"files":"/etc/passwd"}` → 错误,对象存储无新对象 + - 关闭普通用户 `create_app` 后 `POST /workflow/create` / `POST /assistant` → 403 + - 正常路径:普通用户登录、知识空间按四种字段排序、上传普通 HTML、管理员建工作流与助手均正常 +- [ ] **T011**: 发版:合入 `feat/3.0.0-beta2`,Release Notes 写入 spec §4 三条 + +--- + +## 实际偏差记录 + +- 无。 diff --git a/features/v3.0.0-beta1/073-openapi-task-mode/design.md b/features/v3.0.0-beta1/073-openapi-task-mode/design.md index 0d59de248c..1ef8002e81 100644 --- a/features/v3.0.0-beta1/073-openapi-task-mode/design.md +++ b/features/v3.0.0-beta1/073-openapi-task-mode/design.md @@ -206,7 +206,7 @@ | 字段 / 结构 | 类型 / 格式 | 说明 | 谁会消费 | |---|---|---|---| -| 提交 body | `run_mode:"task"`、`execution:"async"`、`text`、`instructions?`、`model`、`skills?[]`、`tools?[]`、`knowledge_ids?[]`、`knowledge_space_ids?[]`、`files?[]`、`clientTimestamp` | `extra=forbid`;`text` 1–20000 字符;`instructions` ≤ 4000;两类知识各 ≤ 50;`files` 为 `/api/v2/knowledge/upload` 返回的引用 | 调用方 | +| 提交 body | `run_mode:"task"`、`execution:"async"`、`text`、`instructions?`、`model`、`skills?[]`、`tools?[]`、`knowledge_ids?[]`、`knowledge_space_ids?[]`、`files?[]`、`clientTimestamp` | `extra=forbid`;`text` 1–100000 字符;`instructions` ≤ 4000;两类知识各 ≤ 50;`files` 为 `/api/v2/knowledge/upload` 返回的引用 | 调用方 | | 提交响应 `data` | `{task_id, status, queue_position}` | UnifiedResponseModel 信封 | 调用方 | | 任务视图 `data` | `{task_id, status, queue_position?, progress?, partial?, failure?, result?, created_at, updated_at}` | `result` 仅 `completed` 时出现 | 调用方 | | `linsight_session_version.api_meta` | JSON,可空 | `{channel, instructions, credential_id, identity_mode}`;工作台发起的任务为空 | worker、审计排障 | diff --git a/features/v3.0.0-beta1/073-openapi-task-mode/task-mode-api.md b/features/v3.0.0-beta1/073-openapi-task-mode/task-mode-api.md index 4e7f6b0cfe..2deb40b7aa 100644 --- a/features/v3.0.0-beta1/073-openapi-task-mode/task-mode-api.md +++ b/features/v3.0.0-beta1/073-openapi-task-mode/task-mode-api.md @@ -88,7 +88,7 @@ | `run_mode` | 是 | 固定 `"task"` | | `execution` | 是 | 固定 `"async"`;缺省或 `"sync"` 返回 400 / `26060` | | `clientTimestamp` | 是 | 客户端时间,如 `2026-09-30T10:00:00` | -| `text` | 是 | 任务描述(客户应用内置的提示词放这里),1–20000 字符,不能全是空白 | +| `text` | 是 | 任务描述(客户应用内置的提示词放这里),1–100000 字符,不能全是空白;长篇参考材料请用 `files` 上传,不要拼进 `text` | | `instructions` | 否 | 业务上下文指令,≤ 4000 字符;叠加在平台规则之下,不替换平台规则 | | `model` | 是 | 模型 id | | `skills` | 否 | 技能名数组,≤ 50,重复项自动去重;不传则不加载任何技能 | diff --git a/features/v3.0.0-beta1/074-linsight-citation-compare/e2e-checklist.md b/features/v3.0.0-beta1/074-linsight-citation-compare/e2e-checklist.md new file mode 100644 index 0000000000..8798a38d17 --- /dev/null +++ b/features/v3.0.0-beta1/074-linsight-citation-compare/e2e-checklist.md @@ -0,0 +1,116 @@ +# E2E 验证清单: F074 任务模式报告与来源对照查看 + +**测试环境**: http://192.168.106.120:3002/workspace(test,前端 `feat/3.0.0-beta2` 95add03,后端 116 release) +**前置条件**: 登录一个能看到「OKR 制度差异分析」任务会话的账号;该会话的报告 `okr-v2-evaluation-report.md` 引用了知识库里的两份 docx(OKR绩效管理运营规则 2025 / 2026),共 33 个角标。 +**API 层**: F074 未改任何后端接口,无新增 pytest 用例;角标解析沿用 F047 / F054 的 `/api/v1/citations/resolve`。 + +## 3.1 对照布局 + +### AC-01: 点文档角标进入对照 +- [ ] 打开会话 → 「已整理输出文件」点 report.md +- [ ] 点报告里任一蓝色角标 +- [ ] 预期: 报告在左、来源 docx 在右并排,互不遮挡 + +### AC-02: 对话列收起与恢复(≥1024px) +- [ ] 进入对照后中间对话列不可见,两栏占满 +- [ ] 点来源栏「收起来源」→ 对话列恢复原宽 + +### AC-03: 分隔线拖动与最小宽度 +- [ ] 拖动分隔线向左 → 比例变化;拖到极限时左栏不小于 400px(≥1280)/ 360px(1024–1279) +- [ ] 刷新页面重新进入对照 → 比例保持 + +### AC-04: 对照中点另一个角标只替换右栏 +- [ ] 对照中点另一份文件的角标 → 右栏换成那份文件,页面上没有新增浮层 + +### AC-05: 仅收起按钮 / Esc 关闭来源栏 +- [ ] 点报告正文、滚动报告 → 来源栏不关闭 +- [ ] 按 Esc → 只关来源栏,报告仍在 + +### AC-06: <1024px 分段切换 +- [ ] 窗口缩到 1024 以下,点角标 → 出现「报告 / 来源」分段控制器,自动在「来源」 +- [ ] 点「回到报告」→ 报告停在刚才那个角标附近 + +### AC-07: 对照 + 全屏 +- [ ] 对照中点全屏 → 两栏铺满,当前引用与定位不变 + +### AC-19 / AC-20 / AC-21: 两级关闭 +- [ ] AC-19: 点报告栏 ✕「关闭工作区」→ 报告与来源都关闭、对话列恢复;全屏中则同时退出全屏 +- [ ] AC-20: 点报告栏 ←「返回文件列表」→ 来源栏关闭,显示文件列表 +- [ ] AC-21: 「收起来源」与「关闭工作区」图标不同,悬停提示分别为「收起来源(Esc)」「关闭工作区」 + +## 3.2 定位与关联 + +### AC-08: PDF bbox 定位(需有 PDF 来源的会话) +- [ ] 点 PDF 来源角标 → 被引片段高亮并滚到可视区 + +### AC-09: docx 文本定位 +- [ ] 点 docx 来源角标 → 右栏自动滚到被引段落,段落有浅蓝底 + 左侧竖线 + +### AC-10 / AC-11: 定位失败 / xlsx +- [ ] 来源定位不到或为 xlsx 时,右栏顶部出现「没能自动找到被引段落,下面是引用的原文」及原文 +- 说明: 真实数据难以构造,匹配失败路径由 `citationLocate.test.ts` 覆盖 + +### AC-12: 当前引用标记 +- [ ] 被点角标保持加深底色;来源段落保持高亮,直到切换或关闭 + +### AC-13 / AC-14: 同一文件的逐处切换 +- [ ] 右栏显示「第 k 处,共 n 处」;点下一处 → 报告滚到下一个同文件角标并加深,右栏跳到对应段落 +- [ ] 同一段被引两次时,点第二次出现的角标,加深的是第二个而不是第一个 +- [ ] AC-14: 只被引用 1 次的文件不显示上一处 / 下一处 + +## 3.3 不变量 + +- [ ] AC-15: 点网页(紫色)角标 → 新标签页打开原网页,不进入对照 +- [ ] AC-16: 无权限 / 已失效的角标 → 仍显示原有状态,不进入对照 +- [ ] AC-17: 日常对话里的角标仍打开对话旁的引用面板,行为不变 +- [ ] AC-18: 来源栏、分隔线没有 backdrop-filter + +## 回归检查 +- [ ] 工作区文件列表、单文件预览、另存为、全屏开关正常 +- [ ] 控制台无新增报错 + +--- + +## 执行记录(2026-09-29,test 环境 120:3002,前端 95add03) + +执行方式:Chrome 浏览器,已登录账号,会话「OKR 制度差异分析」(33 个角标,均为知识库 docx 来源)。 + +| AC | 结果 | 实测 | +|---|---|---| +| AC-01 | ✅ | 报告栏右边界 = 来源栏左边界,无遮挡 | +| AC-02 | ✅ | 对照时对话输入框宽度 0;收起来源后恢复 742px | +| AC-03 | ✅ | 拖到最左时报告栏停在 400px(比例 27%);刷新后重新进入仍为 27% | +| AC-04 | ✅ | 对照中点另一份文件的角标,右栏换成 2025.docx,来源栏仍只有 1 个、无新增浮层 | +| AC-05 | ✅ | 点击 / 滚动报告不关闭来源栏;真实按 Esc 只收起来源栏 | +| AC-06 | ⚠️ 本地已测 | test 上浏览器窗口无法缩到 1024 以下;同一份代码在本地(代理同一 test 后端)960px 下验证过:分段控制器自动切到「来源」,「回到报告」后当前角标在可视区 | +| AC-07 | ✅ | 全屏后仍为对照,当前角标与「第 2 处,共 17 处」不变 | +| AC-08 | ⏭️ 无数据 | 该会话没有 PDF 来源;PDF 路径代码未改 | +| AC-09 | ✅ | docx 自动定位,可视区内 29 个高亮段落 | +| AC-10 / AC-11 | ⏭️ 单测覆盖 | 真实数据全部定位成功,失败路径由 `citationLocate.test.ts` 覆盖 | +| AC-12 | ✅ | 当前角标底色 rgb(190,218,255)(brand-100),普通角标 rgb(232,243,255) | +| AC-13 | ✅ | 下一处 / 上一处在 第 1 ⇄ 第 2 处切换,报告同步滚动;点同段第二次出现的角标(第 6 个)标记落在它自己,显示「第 4 处」 | +| AC-14 | ⏭️ 无数据 | 该会话每个来源都被引用 ≥ 2 次 | +| AC-15 | ⏭️ 无数据 | 该会话无网页来源 | +| AC-16 | ⏭️ 无数据 | 该账号对两份文件都有权限 | +| AC-17 | ⏭️ 未执行 | 日常对话不传 `onOpenSourcePreview`,走原逻辑;未在界面上回归 | +| AC-18 | ✅ | 页面上 backdrop-filter 非 none 的元素数为 0 | +| AC-19 | ✅ | 全屏对照中点「关闭工作区」:来源关闭、退出全屏、对话列恢复 742px(收起动画约 1.5 秒) | +| AC-20 | ✅ | 「返回工作区」后来源关闭、显示文件列表 | +| AC-21 | ✅ | 两个按钮 aria-label 分别为「收起来源(Esc)」「关闭工作区」,图标不同 | + +结论:21 条 AC 中 13 条在 test 上通过,1 条(AC-06)仅本地验证,7 条未在界面上覆盖:AC-08、AC-14、AC-15、AC-16 缺测试数据,AC-10、AC-11 由单测覆盖,AC-17 未执行。 + +## 补充验收(2026-09-30,test 环境 120:3002,前端 3e44d8f):上一处 / 下一处改按被引段落计数 + +改动:来源栏的计数和上一处 / 下一处改为按去重后的被引段落切换,不再逐个角标切换。同一段按 chunk 本身识别(chunkId → chunkIndex → 正文),不按引用组识别;段落按在原文件中的先后排序;每步报告滚到离当前最近的、引用该段的角标,引用该段的角标全部加深。不显示"本段被引用 x 次"。 + +会话「测试工程师考核标准」:报告 31 个角标,引用 1 份 docx 的 6 个段落,分布在 3 个引用组里(第 5 段三个组都有)。 + +| 检查项 | 结果 | 实测 | +|---|---|---| +| 计数按段落 | ✅ | 点第 21 个角标(标号 5)→「第 5 处,共 6 处」,报告里 5 个"5"同时加深 | +| 下一处 / 上一处 | ✅ | 5 → 6 → 5 → 4,每步右栏高亮的都是新段落 | +| 首尾循环 | ✅ | 第 6 处点下一处 → 第 1 处;第 1 处点上一处 → 第 6 处,右栏高亮跟着换 | +| 点角标进入 | ✅ | 点第 1 个角标 →「第 1 处,共 6 处」 | +| Esc | ✅ | 只收起来源栏,报告保留,角标加深全部清除 | +| 单测 | ✅ | `sourcePassages.test.ts` 6 个用例通过 | diff --git a/features/v3.0.0-beta1/071-linsight-citation-compare/spec.md b/features/v3.0.0-beta1/074-linsight-citation-compare/spec.md similarity index 100% rename from features/v3.0.0-beta1/071-linsight-citation-compare/spec.md rename to features/v3.0.0-beta1/074-linsight-citation-compare/spec.md diff --git a/features/v3.0.0-beta1/072-daily-chat-citation-handles/design.md b/features/v3.0.0-beta1/075-daily-chat-citation-handles/design.md similarity index 100% rename from features/v3.0.0-beta1/072-daily-chat-citation-handles/design.md rename to features/v3.0.0-beta1/075-daily-chat-citation-handles/design.md diff --git a/features/v3.0.0-beta1/075-daily-chat-citation-handles/e2e-checklist.md b/features/v3.0.0-beta1/075-daily-chat-citation-handles/e2e-checklist.md new file mode 100644 index 0000000000..cdeca0b884 --- /dev/null +++ b/features/v3.0.0-beta1/075-daily-chat-citation-handles/e2e-checklist.md @@ -0,0 +1,37 @@ +# E2E 验收记录: F075 工作台日常模式引用改用短句柄 + +**测试环境**: http://192.168.106.120:3002/workspace(test,release `8bc7ae7`,后端 116 同版本) +**账号**: 用户 3(管理员) +**会话**: `d008924f253c40ebbf959b3b1b3f2fa0`,模型 deepseek-v4-flash(774),知识空间「鲁力的部门的知识空间」(3812) + 「有视频【测试溯源】」(4195),联网检索开 +**执行时间**: 2026-09-30 17:09–17:25 + +## 第 1 轮:「OKR 绩效管理运营规则 2026 版相比 2025 版有哪些主要变化?……并简要联网补充」 + +| AC | 结果 | 实测 | +|---|---|---| +| AC-01 | ✅ | 落库的工具结果:知识库为 `S11`…,联网为 `"ref": "S1"`…;工具结果中 ``、`citation_key`、`knowledgesearch_`、`websearch_` 出现次数均为 0 | +| AC-02 / AC-03 | ✅ | 后端请求日志里的系统提示词:租户保存的旧「# 引用规则」段已替换为「# 来源编号」,其后「## 其他信息 当前时间:2026-09-30 17:09:35」保留;不含 `` 与私有区字符 | +| AC-05 | ✅ | 流式期间每 150ms 采样页面文本共 194 次,字面 `[Sn]` 命中 0 次;角标边写边出现(知识库蓝色、联网紫色) | +| AC-06 | ✅ | 落库 `msg` 含 3 组标记、0 个字面编号;执行记录 5 个文本段均已转换 | +| AC-13 | ✅ | `[daily-citation-audit] chat=d008924f… model=774 sources_seen=83 cited=8 unknown_handles=0 legacy_markers=0` | +| AC-14 | ✅ | `message_citation_relation` 只绑定正文实际引用的 2 个来源(1 知识库 + 1 联网) | +| 决策 1 | ✅ | Redis `linsight:cite_handles:d008924f…` 共 44 个编号,**无** `meta:enabled`(日常模式建表不钉任务模式契约) | + +## 第 2 轮:「你上面说试行期 6 个月,这个说法出自哪里?请不要重新检索……」 + +| AC | 结果 | 实测 | +|---|---|---| +| AC-11 | ✅ | 模型思考内容写出「上一轮回答中……标注了来源[S28]」——历史回放给模型的是编号而非长 key | +| AC-12 | ✅ | 本轮无任何工具调用(`sources_seen=0`),正文引用转成上一轮来源 `knowledgesearch_89cb2dea:0`,并绑定到本轮消息 441496;点击角标在停靠面板打开 `OKR绩效管理运营规则 2025.docx` | +| AC-15 | ✅ | 点复制:剪贴板文本无私有区字符、无内部 key、无字面 `[Sn]` | +| AC-16 | ✅ | 刷新后两轮角标照常渲染与解析 | + +## 未在界面覆盖 + +- AC-04(三语默认模板)、AC-07~AC-10、AC-17、AC-19:由单测覆盖(`test/citation/test_handle_stream_converter.py`、`test_daily_handle_rules.py`、`test/workstation/test_daily_citation_tools.py`、`test/common/test_image_view_react_loop.py`) +- AC-20(知识空间 / 频道不变):由 `test/citation/test_citation_prompt_rules.py`、`test/channel`、知识空间对话引用用例覆盖,未在界面回归 + +## 观察 + +1. **思考内容里有字面 `[S28]`**:模型的 reasoning(折叠的「已深度思考」)会提到编号。转换只作用于回答正文,思考内容原样展示。展开思考时用户能看到 `[S28]`。2026-09-30 用户决定保持现状(思考是模型原始推理,与任务模式一致),spec AC-05 已明确范围为回答正文。 +2. **首次知识库检索失败**:第 1 轮第一次检索返回 Milvus `service unavailable`(后端刚重启、向量库连接冷启动),与本 Feature 无关;模型随后重试成功。 diff --git a/features/v3.0.0-beta1/072-daily-chat-citation-handles/spec.md b/features/v3.0.0-beta1/075-daily-chat-citation-handles/spec.md similarity index 100% rename from features/v3.0.0-beta1/072-daily-chat-citation-handles/spec.md rename to features/v3.0.0-beta1/075-daily-chat-citation-handles/spec.md diff --git a/features/v3.0.0-beta1/072-daily-chat-citation-handles/tasks.md b/features/v3.0.0-beta1/075-daily-chat-citation-handles/tasks.md similarity index 100% rename from features/v3.0.0-beta1/072-daily-chat-citation-handles/tasks.md rename to features/v3.0.0-beta1/075-daily-chat-citation-handles/tasks.md diff --git a/features/v3.0.0-beta1/086-sandbox-endpoint-ui/design.md b/features/v3.0.0-beta1/086-sandbox-endpoint-ui/design.md new file mode 100644 index 0000000000..039a6885bf --- /dev/null +++ b/features/v3.0.0-beta1/086-sandbox-endpoint-ui/design.md @@ -0,0 +1,219 @@ +# Design: 沙箱接入参数页面配置 + +> **本文档定位 — 现状快照(Why this How)** +> +> - `spec.md` 回答做什么;本文回答为什么这么实现;`tasks.md` 是流水账。 +> - 实现变化 → 覆盖更新本文档;推翻已 ★ 确认的决策要停下与用户重新确认。 + +**关联**: [spec.md](./spec.md) · [tasks.md](./tasks.md) +**版本**: v3.0.0-beta1 +**最后更新**: 2026-10-21 + +--- + +## 1. 目标与非目标 + +- **目标**:让超级管理员在代码执行器配置弹窗里填写沙箱服务地址与访问令牌,保存后 60 秒内,三类进程(backend、Celery worker、Linsight)新发起的沙箱执行改用新值;没填的项继续用部署配置里的服务地址列表。 +- **非目标**: + - 不恢复按主机名扫描副本。地址只来自页面或 `endpoints`。 + - 不把超时、副本池大小等参数搬到页面(spec 范围排除)。 + - 不改 F068 的执行、租约、失败与隔离语义,不改 runner。 + - 不按租户区分沙箱配置。 + - 不解决 Fernet 主密钥是开源常量的问题(F068 已排除,见 §8)。 + +--- + +## 2. 关键约束 + +- 遵循 `docs/constitution.md` C1–C8,版本契约见 `release-contract.md`(F086 行、280 错误码段)。本功能特有的约束: +- **生效链路必须覆盖三类进程**:执行器在 backend(日常会话、助手、编辑器「运行一次」)、Celery worker(工作流运行)、Linsight(任务模式)里各自构建。只在 backend 内存里改值不算生效。 +- **新建执行器都经过 `build_code_executor`**(`bisheng_langchain/gpts/tools/code_interpreter/factory.py`)。日常会话每条消息在 `_prepare_tools` 里新建;工作流每次运行新建。助手的 WebSocket 在连接期间复用同一个执行器,每轮对话开始时就地改地址与令牌(坑 8),不再次调用 `build_code_executor`。任务模式在任务开始时构建一次并粘滞到本轮结束(AC-11)。 +- **`build_code_executor` 是同步函数**,会在 Celery 线程、Linsight 的 asyncio 任务里被调用。读配置只能用同步客户端,且要足够便宜(一次 Redis GET)。 +- **工具配置按租户分行**:`t_gpts_tools_type` 有 `tenant_id`,而沙箱副本池是部署级的。spec AC-03 要求全局一份。 +- **runner 只在创建会话时校验 token**(`src/sandbox-runner/app.py` 的 `create_session`),`/health` 不校验。验证 token 必须真的建一个会话再删掉。 +- **没有页面配置时只用部署地址**(AC-10):生效地址等于部署配置的 `endpoints`。列表为空则没有副本,不扫描主机名。 + +--- + +## 3. 方案对比与选定 + +### 决策 1:页面配置存在哪里 + +- **备选**: + - A. 内置工具 `t_gpts_tools_type.extra.config.container` — 与 E2B 的 `api_key` 同一处,前端改动最小;但该表按租户分行,多租户下每个租户各一份,违背 AC-03;且 F068 的 `factory.py` 明确把沙箱参数从 `extra` 剥掉(`_CONTAINER_POOL_KEYS`),这条约定是为了防止残留工具配置覆盖池参数。 + - B. 系统配置 YAML(`config` 表 `initdb_config`)新增一段 — 已有 Redis 缓存与保存即删缓存的机制;但 `GET /api/v1/settings` 原样返回整段 YAML 文本,令牌只能明文展示,无法满足 AC-04。 + - C. `config` 表新增一条独立记录,key 为 `sandbox_endpoint`,值为 JSON — 表无 `tenant_id`,天然全局;值可单独加密、脱敏、校验。 +- **选定**:C。 +- **原因**:只有 C 同时满足全局一份(AC-03)和令牌不明文(AC-04)。先例:`jwt_secret`(`user/domain/services/jwt_secret.py`)也是 `config` 表独立记录。key 不叫 `sandbox_conf`,避免与 YAML 顶层段 `sandbox_conf` 混淆。 +- **何时该重新考虑**:出现按租户分配沙箱池的需求时,改为带租户维度的存储。 + +### 决策 2:生效值怎么合并、在哪里读 + +- **备选**: + - A. 保存时写进每个进程的 `settings.sandbox_conf` — 跨进程做不到,需要广播或重启。 + - B. 每次 `build_code_executor` 时读页面配置,与 `settings.sandbox_conf` 合并成本次的生效值 — 每次构建多一次 Redis GET。 + - C. 进程内缓存几十秒再读 — 省 Redis 调用,但「保存后多久生效」变成取决于各进程缓存的时间窗。 +- **选定**:B。读取逻辑放在 `ConfigService.get_sandbox_conf()`(`common/services/config_service.py`),`factory.py` 在新建执行器时调用它。助手不在每轮重建执行器:`refresh_open_code_executors` 再调一次 `get_sandbox_conf()`,就地改已有 `ContainerExecutor` 的 `endpoints` 与 `token`(坑 8)。 +- **原因**: + - 执行器构建频率远低于 Redis 承受能力(新建时一次,助手每轮对话再读一次)。缓存 TTL 是 60 秒,与 AC-06 的时限相同:保存时把新值写入缓存,写入失败不让保存失败。未命中后的回填只在 key 不存在时写入,避免一次还在进行的旧读取盖掉刚保存的新值。写失败后若 key 里仍是旧值,过期后下一次执行仍会读到库里的新值。 + - 放在 `ConfigService` 与既有的热加载读法同处(`get_workflow_conf`、`get_cofco_forwarding_conf`),`factory.py` 原本就只依赖这个模块,不新增 `bisheng_langchain` 对领域模块的依赖。 + - 合并规则:两项分别判定。页面 `endpoints` 非空则覆盖;页面 `token` 非空则覆盖;否则沿用部署值。用 `settings.sandbox_conf.model_copy(update=...)` 生成新对象(原因见 §5 坑 1)。 +- **何时该重新考虑**:执行器构建进入高频热路径(例如每个 token 流式片段都构建),或 Redis 成为瓶颈时,改为带版本号的进程内缓存(参考 `ConfigDao.aget_config_version`)。 + +### 决策 3:读写接口放在哪个模块、页面怎么保存 + +- **备选**: + - A. 新建顶层模块 `bisheng/sandbox/`(api + domain) — 归属最清楚,但只有三个接口,还要注册新路由。 + - B. 放进 `tool` 模块,挂在 `/api/v1/tool/sandbox_conf` 下 — 入口本来就是工具页的代码执行器弹窗,权限判定与 `update_tool_config` 一致(`is_global_super`)。 + - C. 复用 `/api/v1/tool/config`:前端把地址和令牌塞进 `extra`,后端识别代码执行器后拆出来存全局 — 一次请求;但工具配置是租户维度的写操作,混入全局写会让一个接口同时有两种作用域,且违背决策 1 不进 `extra` 的约定。 +- **选定**:B。弹窗保存时前端先调沙箱配置保存接口,成功后再调原有的工具配置保存。 +- **原因**:B 的改动面最小且作用域清晰。两次请求的不一致窗口只有一种:沙箱配置已保存、执行方式没保存成功。此时执行方式仍是旧值,地址只在选「沙箱运行」时才被使用,不会造成错误执行。 +- **何时该重新考虑**:沙箱配置出现工具页以外的入口(例如系统设置页)时,迁到独立模块。 + +### 决策 4:令牌怎么存、怎么脱敏 + +- **备选**: + - A. 把 `token` 加进 `JsonFieldMasker.sensitive_fields`(`utils/mask_data.py`),复用工具配置的脱敏合并 — 该表是全局的,会把其他工具 `extra` 里所有名为 `token` 的字段一并改为掩码,影响面不可控。 + - B. 在沙箱配置服务内自行处理:存储时用 `encrypt_token`(Fernet,`core/config/settings.py`)加密;读取接口只返回 `********` 与「是否已设置」;保存时收到 `********` 视为不修改,收到空串视为清空。 +- **选定**:B。 +- **原因**:避免 A 的连带影响;掩码字面量沿用 `JsonFieldMasker.mask_api_key` 的 `********`,前端已有处理这种掩码的写法。 +- **何时该重新考虑**:Fernet 主密钥外置立项后(§8),改用新的密钥管理方式。 + +### 决策 5:测试连接怎么判定 + +- **备选**: + - A. 只探 `/health` — 无副作用,但无法判断令牌对不对(`/health` 不校验令牌),满足不了 AC-14。 + - B. 先探 `/health`,再用令牌 `POST /v1/sessions`,成功则立即 `DELETE` 释放 — 能区分四种结果,代价是短暂占用一个槽位。 +- **选定**:B。请求里地址为空时,逐个探测部署配置的 `endpoints`;这份列表也空则结果为空,不发请求。待探测的地址先按保存规则校验,不合法则返回 28009 且不发请求。合法地址去掉末尾 `/` 后再拼 `/health` 与 `/v1/sessions`。探测用 `httpx.AsyncClient`,不在事件循环里调用同步 HTTP。每个地址的这两次请求各等待 5 秒,超时视为不可达。令牌为 `********` 时用已保存的令牌,页面也没有就用部署令牌。结果映射:`/health` 不通或超时 → 不可达;建会话 401 → 令牌不被接受;503 → 副本繁忙、令牌未能验证;200 → 可用,并立即释放(AC-16)。 +- **原因**:令牌校验点只有建会话这一个;占槽时间是一次往返,可以接受。 +- **何时该重新考虑**:runner 增加不占槽的鉴权探测接口时,改用该接口。 + +--- + +## 4. 系统现状(接手必读) + +### 4.1 数据流 + +**保存**: +`CodeExecutor 弹窗「保存」 → saveSandboxConfApi → POST /api/v1/tool/sandbox_conf → SandboxConfService.save(校验并去掉地址末尾 / → 令牌合并加密 → json.dumps 后 await ConfigDao.insert_or_update_config 写 sandbox_endpoint → 把解析后的 dict 写入 Redis → 审计) → 前端再调 updateToolApi 保存执行方式` + +**执行时读取**: +`日常会话每条消息在 _prepare_tools 里新建执行器。工作流每次运行新建执行器。助手的 WebSocket 在连接期间复用同一个执行器对象:AssistantAgent.run 开头,若未粘滞的 ContainerExecutor 仍占着会话则先 _release(),再把 endpoints(去掉末尾 /)与 token 改成 settings.get_sandbox_conf() 的当前值。灵思任务在任务开始时构建一次并粘滞,本轮不刷新(AC-11)。` +`build_code_executor("container") → settings.get_sandbox_conf()(Redis 中的合法 dict 直接用;未命中、抛错或结构不合法则 json.loads 库里的字符串。结构合法指 dict,endpoints 为字符串列表,token_enc 为字符串。库合法则本次用库的值。只有 key 不存在时 setNx 回填;坏缓存或 Redis 抛错都不在读取路径上写入。库也不合法或解密失败则回退部署配置) → ContainerExecutor(sandbox_conf=生效值)。副本就是生效 endpoints;空列表则不可达` + +**测试连接**: +`弹窗「测试连接」 → POST /api/v1/tool/sandbox_conf/test(async 路由) → AC-12 校验,不合法则 28009 → 去掉地址末尾 / → httpx.AsyncClient 逐地址 /health(5 秒)→ POST /v1/sessions(5 秒)→ DELETE → 每个地址一条结果` + +### 4.2 关键数据结构 / 字段约定 + +| 字段 / 结构 | 类型 / 格式 | 说明 | 谁会消费 | +|---|---|---|---| +| `config` 表 key `sandbox_endpoint` 的 `value` | `str`,内容是 `json.dumps` 后的 `{"endpoints": [str], "token_enc": str}` | `Config.value` 与 `insert_or_update_config` 只收字符串。`token_enc` 为 Fernet 密文,空串表示页面未设令牌;行不存在等价于两项都未设。读出后 `json.loads` | `ConfigService.get_sandbox_conf` | +| Redis key `config:sandbox_endpoint` | 解析后的 dict `{"endpoints", "token_enc"}`,TTL 60 秒 | 只缓存这份密文 dict,不缓存解密后的 `SandboxConf`、令牌明文,也不缓存 JSON 字符串。`endpoints` 的每一项与 `token_enc` 都是字符串,列表可以为空。保存时用 `get_redis_client_sync().set(..., expiration=60)` 覆盖写入刚落库的 dict。未命中回填用 `setNx`,key 已存在则不改值。命中但结构不合法,或 Redis 抛错时,改读库且读取路径不再写入;坏 key 留到自身 TTL 结束。写入失败只记日志,已经读到的合法库值仍然使用。位于 `redis_url` 指定的 db。读写都走该客户端(值经 pickle) | 三类进程 | +| `GET /api/v1/tool/sandbox_conf` 响应 `data` | `{"endpoints": [str], "token": "********" \| "", "address_source": "page" \| "deployment" \| "default", "token_source": 同, "deployment_address": str}` | `endpoints` 是页面存的列表,可以为空,不是与部署配置合并后的生效地址。来源按字段分开:该项页面值非空为 `page`;否则该项部署值非空为 `deployment`;否则 `default`。地址看页面 `endpoints`,令牌看页面 `token_enc`。`deployment_address` 是部署配置 `endpoints` 的逗号拼接,只用于展示来源;部署没配地址时为空串 | platform 前端 | +| `POST /api/v1/tool/sandbox_conf` 请求体 | `{"endpoints": [str], "token": str}` | `token` 为 `********` 表示不改,空串表示清空;`endpoints` 为空数组表示回退部署配置 | platform 前端 | +| `POST /api/v1/tool/sandbox_conf/test` 请求体 / 响应 `data` | 请求同上;响应 `{"results": [{"url": str, "status": "ok" \| "unreachable" \| "token_rejected" \| "busy"}]}` | 不回传 runner 响应体 | platform 前端 | +| 错误码 `28009` | `SandboxEndpointInvalidError` | 地址不合法(非 http/https、无主机名、端口越界、空地址、去掉末尾 `/` 后重复、超过 32 个) | platform 前端 `api_errors` | + +### 4.3 关键模块职责 + +| 模块 / 文件 | 职责 | 不做什么 | +|---|---|---| +| `common/models/config.py` | 新增 `ConfigKeyEnum.SANDBOX_ENDPOINT` | 不放读写逻辑 | +| `common/services/config_service.py` `get_sandbox_conf()` | 同步读取页面配置。Redis 命中且结构合法则直接用。未命中、抛错或结构不合法都改读库并 `json.loads`。结构合法指 dict,`endpoints` 为字符串列表(可为空),`token_enc` 为字符串(可为空串)。库合法则本次用库的值。只有 key 不存在时 `setNx` 回填。坏缓存与 Redis 抛错都不在读取路径上写入。库也不合法、解析失败或解密失败则回退部署配置并记 error 日志。回填失败仍用已经读到的合法库值。解密只用于本次合并。另提供 `write_sandbox_endpoint_cache(payload)`,`payload` 是刚落库的合法 dict,仅保存路径调用 | 不写业务配置、不做地址校验;读取路径不得用 `set` 覆盖缓存;未命中回填不得用 `set` 覆盖已有 key;不得把明文令牌、JSON 字符串或坏结构写入 Redis | +| `tool/domain/services/sandbox_conf_service.py`(新) | 读取(脱敏 + 按字段判来源)、保存(校验、去掉地址末尾 `/`、令牌合并、加密、`json.dumps` 后写入、再把 dict 写入缓存、审计)、测试连接。`get()` 遇到坏结构时按没有页面配置返回。`save` 与 `test` 均为 async。`test` 用 `httpx.AsyncClient` | 不碰 `gpts_tools.extra`;不在事件循环里做同步 HTTP;`get()` 不因坏结构抛异常 | +| `tool/api/tool.py` | 三个接口,`is_global_super` 判定 | 不含业务逻辑 | +| `bisheng_langchain/.../factory.py` | container 分支改为 `settings.get_sandbox_conf()`。另提供 `refresh_open_code_executors(tools)`:未粘滞的 `ContainerExecutor` 若仍占会话则先 `_release()`,再就地改 `endpoints`(去掉末尾 `/`)与 `token` | 不读库、不读 Redis 以外的配置;不扫描主机名;不替换执行器对象;不手清 `_bound_url` / `_session_id` | +| `api/services/assistant_agent.py` `run` | 每轮对话开始时调用 `refresh_open_code_executors(self.tools)` | 不重建整份工具列表,不刷新 `keep_session` 的执行器 | +| `common/errcode/sandbox.py` | 新增 `28009` | — | +| `api/services/audit_log.py` | 新增 `update_sandbox_conf(user, ip, note)`,内部走 `_system_log(..., group_ids=[], ..., object_id="sandbox_endpoint", object_name="sandbox_endpoint", note=note)` | 不记令牌值;不让调用方填 `group_ids` | +| platform `controllers/API/tools.ts` | 三个 API 封装 | — | +| platform `builtInTool/SandboxEndpointFields.tsx`(新) | 地址多行输入、令牌输入、来源标注、测试连接与结果展示 | 不直接发请求以外的保存逻辑 | +| platform `builtInTool/CodeExecutor.tsx` | 打开时读取沙箱配置;选「沙箱运行」时渲染上面的子组件;仅在选「沙箱运行」且字段有改动时,保存先存沙箱配置再交给 `onSubmit`,切到其他方式保存不动沙箱配置(AC-05) | 不把地址和令牌写进 `extra`(仍是 `container: {}`) | +| F068 `沙箱部署说明.md`、`沙箱使用说明.md` | 写明页面配置入口、与环境变量的优先级、清空即回退、Redis db 注意事项(AC-18) | — | + +--- + +## 5. 已知坑 / 反直觉事实 + +| # | 反直觉事实 | 如果不知道会怎样 | 在哪处理 | +|---|---|---|---| +| 1 | `SandboxConf` 的 `overlay_env` 是 `model_validator`:每次用 `SandboxConf(**dict)` 或 `model_validate` 构造,都会把 `BS_SANDBOX_CONF__*` 环境变量重新盖上去 | 用构造函数合并页面值,环境变量会反过来覆盖页面值,Compose / Helm 部署(都设了 `TOKEN`)上页面令牌永远不生效 | `get_sandbox_conf` 只用 `model_copy(update=...)`,不重新校验 | +| 2 | 配置缓存在 `redis_url` 指定的 db(发行配置是 `/1`),不是 db0。`RedisClient.setNx` 在 key 已存在时仍会 `expire`,只刷新 TTL,不改值。保存时写缓存失败不会让保存失败 | 去 db0 写 key 等于没写;或把回填写成 `set`,进行中的旧读取会盖掉刚保存的新值,再撑满 60 秒。写失败后若 key 里仍是旧值,这 60 秒就是 AC-06 的时限 | 运维说明写明 db;保存用 `set` 覆盖,回填只用 `setNx`;`save` 捕获写缓存异常并记 error 日志 | +| 3 | runner 只在建会话时校验令牌 | 测试连接只探 `/health` 会把令牌错误判为可用,上线后所有沙箱执行 401 | 决策 5;`SandboxConfService.test` | +| 4 | 任务模式每个任务只构建一次执行器,且开着粘滞会话 | 保存后发现正在跑的任务仍打旧副本,会被当成「没生效」 | spec AC-11 已把它定为预期行为;使用说明写明 | +| 5 | Fernet 的 `secret_key` 是开源代码里的常量 | 把「令牌已加密」当成真正的保密措施;拿到库的人可以解密 | 本期接受(与 `config.yaml` 密码同级),§8 | +| 6 | 页面只填一个负载均衡地址(K8s ClusterIP Service)时,粘滞会话的下一跳可能落到别的副本 | 任务模式第二次执行读不到第一次写的文件 | spec AC-19 的页面提示;部署说明已有同样警告 | +| 7 | 测试连接接口能让超级管理员从 backend 探测任意 `host:port`。工具路由是 `async def`,同步 `httpx` 会占住事件循环 | 被当成内网 HTTP 探测器读取内部服务内容。32 个地址逐个同步等待时,这个进程上的其他请求一起卡住 | 先按 AC-12 拒绝非法地址且不发请求;只接受 http/https、最多 32 个地址;`httpx.AsyncClient`,每次请求 5 秒超时;只返回四种状态不回传响应体 | +| 8 | 助手的代码执行器挂在 WebSocket 的 `gpts_agent` 上,只在 `gpts_agent is None` 时构建。工具可能被浅拷贝,但共享同一个 executor 对象。`_release()` 在 `_bound_url` 或 `_session_id` 为空时直接返回,不会 `DELETE` | 只在 `build_code_executor` 里读新配置,已打开的助手下一轮仍打旧地址。换成新的执行器对象,浅拷贝仍拿着旧对象。手清这两个字段会留下远端会话,`_lease_token` 也清不掉。就地赋值若不去掉末尾 `/`,URL 会变成双斜杠 | 仍占会话时先 `_release()`,再就地改 `endpoints`(去掉末尾 `/`)与 `token`。日常会话每条消息走 `_prepare_tools` 新建执行器,不用这条刷新 | +| 9 | `Config.value` 是字符串,`insert_or_update_config(key, value: str)` 不收 dict。`json.loads` 对 `null`、数组或缺字段的对象不会抛错。`setNx` 在 key 已存在时不替换值。读取路径上的 `set` 会盖掉刚保存的新值。`_system_log` 必填 `group_ids` 与 `object_name` | 把 dict 写入 `value` 得到的不是合法 JSON。坏缓存若直接回退部署配置,会挡住库里仍然正确的那一行。读取路径再用 `set` 把刚读到的旧库记录写回,会盖掉保存刚写入的新缓存,最多再撑 60 秒。读取方按字段取值会抛异常。审计调用少参数会在运行时抛错 | 落库 `json.dumps`,读库 `json.loads`。结构合法指 dict,`endpoints` 为字符串列表,`token_enc` 为字符串。缓存不合法或 Redis 抛错时改读库,本次用库的值,读取路径不写缓存。只有 key 不存在时 `setNx`。保存仍用 `set`。库也不合法再回退部署配置。弹窗 `get()` 按没有页面配置返回。Redis 只放合法 dict。审计内部传 `group_ids=[]`、`object_name="sandbox_endpoint"` | + +--- + +## 6. 对外契约与依赖 + +### 6.1 我提供给别人的(Outgoing) + +| 契约 | 形式 | 谁在用 | +|---|---|---| +| `GET /api/v1/tool/sandbox_conf` | HTTP API(仅全局超级管理员) | platform 代码执行器弹窗 | +| `POST /api/v1/tool/sandbox_conf` | HTTP API(仅全局超级管理员) | 同上 | +| `POST /api/v1/tool/sandbox_conf/test` | HTTP API(仅全局超级管理员) | 同上 | +| `settings.get_sandbox_conf() -> SandboxConf` | 内部 Python API | `factory.build_code_executor`(新建执行器)、`refresh_open_code_executors`(助手每轮就地更新) | +| `config` 表 `sandbox_endpoint` 行 | 数据格式 | 本 Feature 独占写入 | +| 错误码 `28009` | 错误码 | platform 前端 `api_errors` 三语文案 | + +### 6.2 我依赖别人的(Incoming) + +| 依赖 | 形式 | 风险点 | +|---|---|---| +| `SandboxConf` 字段名 `endpoints` / `token` 与 `overlay_env` 行为(F068) | 内部数据结构 | 字段改名或 `overlay_env` 改为非 validator 实现,合并逻辑要同步改 | +| `ContainerExecutor` 只使用 `endpoints`(F068,主机名扫描已删除) | 隐式行为 | 若再引入自动找副本,空地址会重新连上未配置的 runner | +| runner `POST /v1/sessions` 的 401 / 503 语义与 `DELETE /v1/sessions/{id}` 的 `x-lease-token` | HTTP 协议 | runner 改状态码,测试连接分类失准 | +| `encrypt_token` / `decrypt_token` 与 `secret_key` | 内部 Python API | 主密钥轮换后旧密文无法解密 → 与 Redis、库失败相同,回退部署配置并记 error 日志,不把明文写入缓存 | +| Redis 与 `config` 表可用 | 中间件 | 都不可用时回退部署配置(spec §3),页面配置暂时失效 | + +--- + +## 7. 测试与可观测 + +- **单元测试**(`src/backend/test/sandbox/`): + - 合并规则:页面两项分别覆盖、留空回退、无记录时与 `settings.sandbox_conf` 逐字段相同;设置了 `BS_SANDBOX_CONF__TOKEN` 时页面令牌仍生效(坑 1)。 + - 读取降级:Redis 抛错或结构不合法时改读库,本次用库的值,读取路径不写缓存。库失败、密文解密失败、JSON 解析失败,或库的结构也不是「`endpoints` 为字符串列表、`token_enc` 为字符串」的 dict,再回退部署配置并记日志。已经读到合法库值之后,回填失败仍用这份库值。缓存与日志都无令牌明文。只有 key 不存在时用 `setNx` 回填这份 dict,不是库里的 JSON 字符串。 + - 保存:校验各类非法地址返回 28009(含空地址、去掉末尾 `/` 后重复、超过 32 个);`********` 不改、空串清空;令牌密文落库;保存后缓存是新值,随后的 `setNx` 不覆盖它;审计 `note` 不含令牌。来源按字段分开。 + - `factory`:container 分支使用 `get_sandbox_conf()` 的 `endpoints`,不扫描主机名。`refresh_open_code_executors` 对未粘滞的容器执行器先 `_release()` 再就地更新,地址去掉末尾 `/`;`keep_session` 为真的不变;浅拷贝共享的执行器一起变。 + - 测试连接:用 `httpx.MockTransport` 配 `httpx.AsyncClient` 覆盖四种状态与「成功后必定 DELETE」。非法地址返回 28009 且没有任何请求。带末尾 `/` 的地址先去掉再拼路径。超时为 5 秒,超时记为不可达。探测不在事件循环里调用同步 `httpx`。 + - 权限:非全局超级管理员调用三个接口均被拒绝。 +- **前端**:`SandboxEndpointFields` 的地址解析与校验写成纯函数,加 vitest 用例(参照 `src/frontend/platform/src/test/configValue.test.ts`)。 +- **e2e**:按 spec 全部 AC 走 `/e2e-test`;其中「三类进程 60 秒内生效」需在 Compose 环境实测。 +- **手动验证**(Compose): + 1. 部署说明 2.1 起两台 runner,确认任务模式正常。 + 2. 超级管理员打开「构建 → 工具 → 内置工具 → 代码执行器」,选「沙箱运行」,地址填 `http://code-runner-1:8080`,点测试连接,应显示可用;保存。 + 3. 跑一个工作流代码节点、一次日常会话代码执行,并在一条已经连着的助手里再发一条会跑代码的消息。`docker logs bisheng-code-runner-1` 有 `/exec`、`code-runner-2` 没有。 + 4. 令牌改成错的再测试,应显示「令牌不被接受」;清空地址与令牌保存,两台 runner 都重新有流量。 +- **可观测**:保存与测试各写一条 info 日志(不含令牌);`get_sandbox_conf` 降级时写 error 日志 `sandbox endpoint config unreadable; using deployment config`。 + +--- + +## 8. 后续改进 / 不打算做的事 + +- **Fernet 主密钥外置**:令牌加密的强度受限于开源常量 `secret_key`,与 `config.yaml` 密码同级。影响全部密文,归独立立项(F068 已排除)。 +- **按主机名找副本**:已从产品去掉,不计划加回。扩容时把新副本的地址写进 `endpoints` 或页面。 +- **测试连接从 worker / Linsight 进程发起**:需要跨进程下发探测任务,复杂度高;本期在页面明示结果只代表 backend(AC-15)。 + +--- + +## 修订历史 + +| 日期 | 改动 | 触发原因 | +|---|---|---| +| 2026-10-20 | 初版 | spec 确认后编写 | +| 2026-10-20 | 决策 3 与权限判定经用户确认:保存分两次请求(先沙箱配置,再执行方式);鉴权用 `is_global_super`,不走 OpenFGA | 用户确认 | +| 2026-10-21 | AC-06 时限改为 60 秒,与缓存 TTL 相同;删缓存失败不让保存失败 | 方案评审 | +| 2026-10-21 | 去掉主机名发现及 `discover_*` 配置。副本只来自页面或部署配置的 `endpoints` | 用户要求 | +| 2026-10-21 | 来源按字段分开;保存写入新缓存,回填用 `setNx`;地址去掉末尾 `/` 后判重,并拒绝空地址与超过 32 个 | 方案评审 | +| 2026-10-21 | 读取回显页面存的地址列表;缓存只放含 `token_enc` 的 JSON;测试连接先去掉末尾 `/`;密文解不开回退部署配置 | 方案评审 | +| 2026-10-21 | 助手每轮对话就地刷新未粘滞执行器的地址与令牌;测试连接先做 AC-12 校验,单次请求超时 5 秒 | 方案评审 | +| 2026-10-21 | 库内存 JSON 字符串、Redis 存解析后的 dict;审计补 `group_ids` 与 `object_name`;助手刷新先 `_release()` 再去掉地址末尾 `/`;测试连接用异步客户端 | 方案评审 | +| 2026-10-21 | 保存写入 Redis 的是解析后的 dict;JSON 结构不对与读失败一样回退部署配置 | 方案评审 | +| 2026-10-21 | 缓存结构不合法或 Redis 抛错时改读库且读取路径不写缓存;弹窗坏结构按没有页面配置返回 | 方案评审 | diff --git a/features/v3.0.0-beta1/086-sandbox-endpoint-ui/spec.md b/features/v3.0.0-beta1/086-sandbox-endpoint-ui/spec.md new file mode 100644 index 0000000000..3f81827111 --- /dev/null +++ b/features/v3.0.0-beta1/086-sandbox-endpoint-ui/spec.md @@ -0,0 +1,114 @@ +# Feature: 沙箱接入参数页面配置 + +> **本文档定位 — 纯 What(需求口径,不随代码漂移)** +> +> spec 只回答 **做什么 / 验收标准 / 不做什么**。How 的唯一真相在 [design.md](./design.md) 与 [tasks.md](./tasks.md)。 + +**关联 PRD**: 无独立 PRD。需求来源:2026-10-20 需求「沙箱的地址在页面上配置,实时生效」,Spec Discovery 已与需求方确认三项决策(见 §5) +**优先级**: P1 +**所属版本**: v3.0.0-beta1 +**依赖**: F068-code-execution-sandbox(沙箱执行模式、服务地址列表 `endpoints`、管理端「代码执行方式」弹窗) + +> **范围边界** +> - **本次纳入**: +> - 在管理端代码执行器的配置弹窗里选「沙箱运行」后,在弹窗中填写沙箱服务地址与访问令牌 +> - 保存后 60 秒内,backend、Celery worker、Linsight 三类进程上新发起的执行使用新参数,不重启任何进程 +> - 页面配置与部署配置(环境变量 / `config.yaml`)并存:页面填了的字段优先,留空的字段沿用部署配置 +> - 「测试连接」:保存前验证地址可达、令牌被接受 +> - **本次明确排除**: +> - **按主机名自动找副本**——产品里不再有这套扫描和对应配置(主机名模式、起始序号、端口、刷新间隔)。副本只来自页面或部署配置里的服务地址列表 +> - **超时、副本池大小、发现刷新间隔、copy-in 上限、本机隔离等参数的页面化**——运维调优参数,误改代价高,继续只走部署配置 +> - **runner 自身的参数**(会话数、租约 TTL、会话 uid 隔离)——属于 runner 容器,平台改不到 +> - **按租户分别配置沙箱**——沙箱副本池是部署级基础设施,没有按租户隔离的需求 +> - **废弃环境变量配置**——保留为兜底,存量 Compose / Helm 部署靠它 +> - **网络可达性**——页面改得了地址,改不了网络。新地址能否被三类进程访问(Compose 的 `sandbox_net`、Helm 的 NetworkPolicy)仍是部署侧责任 +> - **E2B 模式与本机模式的配置**——不改 + +--- + +## 1. 用户故事 + +作为 **平台超级管理员**, +我希望 **在管理端直接改沙箱的接入地址和令牌,保存后 60 秒内生效**, +以便 **换沙箱集群、扩缩副本或轮换令牌时,不必改环境变量再重启 backend、worker、Linsight**。 + +作为 **私有化交付的实施人员**, +我希望 **不在页面上填写时,沙箱只用部署配置里的服务地址列表;需要时再在页面上覆盖**, +以便 **清空页面配置就退回这份列表**。列表为空时沙箱执行按不可达失败,不再按主机名去找副本。 + +--- + +## 2. 验收标准 + +### 2.1 配置入口与权限 + +- **AC-01** — WHERE 管理端代码执行器配置弹窗中「代码执行方式」选中「沙箱运行」, THE SYSTEM SHALL 在同一弹窗中展示两项输入:沙箱服务地址(可填一个或多个,每个地址对应一台沙箱副本)与访问令牌,并随弹窗的「保存」一起保存。 +- **AC-19** — THE SYSTEM SHALL 在沙箱服务地址的提示中写明:多台副本要逐个填写各自的地址,不要只填一个负载均衡地址,否则任务模式同一任务的多次执行可能落到不同副本、读不到上一步的文件。 +- **AC-02** — THE SYSTEM SHALL 仅允许全局超级管理员读取与保存沙箱接入参数;其他身份调用读取或保存一律被拒绝。 +- **AC-03** — THE SYSTEM SHALL 使沙箱接入参数在整个部署中只有一份:在任一租户下保存后,对全部租户的执行生效。 +- **AC-04** — THE SYSTEM SHALL 在读取时只回显访问令牌的掩码;保存时提交掩码视为不修改令牌。令牌不得以明文存入数据库或缓存,明文也不得出现在任何读取接口响应、日志与审计记录中。 +- **AC-05** — WHERE 执行方式切换为「本地隔离运行」或「E2B沙箱运行」, THE SYSTEM SHALL 保留已保存的沙箱接入参数,切回「沙箱运行」时原值仍在。 + +### 2.2 生效与优先级 + +- **AC-06** — WHEN 管理员保存沙箱接入参数, THE SYSTEM SHALL 使 backend、Celery worker、Linsight 三类进程上此后新发起的沙箱执行在 60 秒内改用新参数,不重启任何进程。日常会话的每条消息、助手的每一轮对话、工作流的每一次运行都算新发起的执行。任务模式中已经绑定副本的任务不在此列,仍按 AC-11。保存时把新值写入缓存;写入失败不让保存失败。当时还没写完的旧读取不得盖掉这份新值。写失败后若缓存里仍是旧值,该缓存在 60 秒后过期,仍落在此时限内。 +- **AC-07** — THE SYSTEM SHALL 对沙箱服务地址与访问令牌两项分别判定:页面上填写了的项优先于部署配置;留空的项使用部署配置(环境变量 `BS_SANDBOX_CONF__*`,其次 `config.yaml`,再次代码默认值)。地址只来自这两处的服务地址列表,不按主机名查找副本。 +- **AC-08** — THE SYSTEM SHALL 在弹窗中为沙箱服务地址与访问令牌分别标明当前生效值的来源(页面配置 / 部署配置 / 默认值)。某一项的来源只看这一项:页面上该项有值为页面配置,否则部署配置里该项有值为部署配置,否则为默认值。地址输入回显的是页面保存的列表,可以为空;部署地址不写入该输入。令牌只显示来源和掩码,不显示值。 +- **AC-09** — WHEN 管理员清空全部页面配置并保存, THE SYSTEM SHALL 使执行行为恢复为仅由部署配置决定,与本 Feature 上线前一致。 +- **AC-10** — WHERE 部署从未在页面上保存过沙箱接入参数, THE SYSTEM SHALL 只用部署配置中的服务地址列表。列表为空时,沙箱执行按不可达失败,不按主机名查找副本。 +- **AC-11** — WHILE 保存发生时已有沙箱执行在进行, THE SYSTEM SHALL 让其在已占用的副本上完成,不因保存而中断;任务模式中已绑定副本的任务在本轮结束前继续使用该副本,新任务与新一轮使用新参数。 + +### 2.3 校验与测试连接 + +- **AC-12** — IF 提交的沙箱服务地址不合法(不是 `http` / `https` 地址、缺少主机名、端口越界、含空地址、去掉末尾 `/` 后同一地址重复、或超过 32 个), THEN THE SYSTEM SHALL 拒绝保存并返回 28009,已保存的配置保持不变。比较与落库前先去掉每个地址末尾的 `/`。 +- **AC-13** — THE SYSTEM SHALL 允许保存未通过测试连接的接入参数(例如副本尚未启动),保存不以测试通过为前提。 +- **AC-14** — WHEN 管理员点击「测试连接」, THE SYSTEM SHALL 使用页面当前填写值(未保存也可,令牌为掩码时用已保存的令牌)从平台服务进程逐个探测(页面未填地址时探测部署配置里的服务地址;部署也没有地址则没有可探测的目标)。探测前按 AC-12 校验地址,不合法则返回 28009 且不发请求;合法地址先去掉末尾的 `/`。每个地址的连通与建会话各等待 5 秒,超时视为不可达。返回每个地址的结果;结果区分「不可达」「令牌不被接受」「副本繁忙、令牌未能验证」与「可用」。 +- **AC-15** — THE SYSTEM SHALL 在测试结果旁明示:结果只代表平台服务进程的网络可达性,Celery worker 与 Linsight 进程能否访问需按部署说明另行验收。 +- **AC-16** — THE SYSTEM SHALL 使测试连接在结束后不残留占用的副本会话。 + +### 2.4 审计与文档 + +- **AC-17** — WHEN 管理员保存沙箱接入参数, THE SYSTEM SHALL 写一条审计记录,包含操作人、时间、来源 IP 与变更的字段名;令牌只记录「已修改」,不记录值。 +- **AC-18** — THE SYSTEM SHALL 在《沙箱部署说明》与《沙箱使用说明》中写明页面配置入口、字段优先级、生效时限,以及「页面改不了网络可达性」。 + +--- + +## 3. 边界情况 + +- 缓存里的内容不是约定结构,或缓存不可用时,改读数据库,本次使用库里的值。这条读取不写缓存,坏缓存留到它自己过期;保存仍会覆盖缓存。库里的内容也不是约定结构、密文无法解密,或数据库也不可用时,退回部署配置并记录错误日志。已经读到合法库值之后,写缓存失败仍使用这份库值。不得因此改走本机执行,也不得因此让沙箱执行中断。弹窗读取遇到坏结构时按没有页面配置展示,以便重新保存。日志与缓存不得出现令牌明文。 +- 两位管理员先后保存时,以后保存者为准,不做合并。 +- 页面填写服务地址后,沙箱执行只使用这些地址。清空页面地址后,改用部署配置里的服务地址列表;该列表为空则没有副本。 +- 新地址不可达时,沙箱执行按既有沙箱不可用的失败语义返回(F068 AC-05),不回落到本机执行。 +- 令牌被修改后,已在进行中的执行使用旧令牌完成;新执行使用新令牌。 + +--- + +## 4. 设计与实现(指针,不复制) + +| 你想知道 | 去哪看 | +|---|---| +| 配置存在哪里、为什么不放工具 `extra` 或系统配置 YAML | design.md §3 | +| 合并优先级、各进程如何读到新值、缓存如何写入 | design.md §4 | +| 已知坑(Redis db 编号、粘滞会话、测试连接占槽) | design.md §5 | +| 新增接口、错误码段 | design.md §6 | +| 任务拆解与文件清单 | tasks.md | + +--- + +## 5. Spec Discovery 决策记录 + +| 问题 | 结论 | +|------|------| +| 页面开放哪些字段 | 代码执行器配置弹窗选「沙箱运行」后,填沙箱服务地址(一个或多个)与访问令牌。没有按主机名找副本。其余参数仍走部署配置 | +| 页面配置与环境变量的优先级 | 页面填了的字段覆盖部署配置,留空的字段回退部署配置 | +| 挂在哪个版本线 | v3.0.0-beta1,随 F068 沙箱特性 | + +--- + +## 相关文档 + +- 设计真相: [design.md](./design.md) +- 执行与落档: [tasks.md](./tasks.md) +- 版本契约: [features/v3.0.0-beta1/release-contract.md](../release-contract.md) +- 依赖 Feature: [068-code-execution-sandbox/spec.md](../068-code-execution-sandbox/spec.md) +- 部署与使用: [沙箱部署说明](../068-code-execution-sandbox/沙箱部署说明.md)、[沙箱使用说明](../068-code-execution-sandbox/沙箱使用说明.md) diff --git a/features/v3.0.0-beta1/086-sandbox-endpoint-ui/tasks.md b/features/v3.0.0-beta1/086-sandbox-endpoint-ui/tasks.md new file mode 100644 index 0000000000..e31792b91a --- /dev/null +++ b/features/v3.0.0-beta1/086-sandbox-endpoint-ui/tasks.md @@ -0,0 +1,201 @@ +# Tasks: 沙箱接入参数页面配置 + +**关联规格**: [spec.md](./spec.md) · [design.md](./design.md) +**版本**: v3.0.0-beta1 + +--- + +## 状态 + +| 步骤 | 状态 | 备注 | +|------|------|------| +| spec.md | ✅ 已评审 | 用户确认 | +| design.md | ✅ 已评审 | 用户确认;保存分两次请求、鉴权用 `is_global_super` | +| tasks.md | ✅ 已拆解 | 8 Wave / 21 项 | +| 实现 | 🔲 进行中 | 20 / 21 完成。T020 需要两台 runner 与已连接的助手,这次没有跑 Compose 联调 | + +--- + +## 开发模式 + +按 Wave 组织。后端先写测试再写实现。前端把地址解析、保存顺序抽成纯函数,用 vitest 覆盖;弹窗交互用手动验证。 + +没有新的 Celery 任务,也没有 Client 改动。新建执行器仍走 `build_code_executor`,在那里改为读 `settings.get_sandbox_conf()`。日常会话每条消息、工作流每次运行都会新建。助手连接复用执行器,由 `refresh_open_code_executors` 在每轮对话开始时就地更新地址与令牌。`config` 表没有 `tenant_id`,不传租户。 + +鉴权只判断 `login_user.is_global_super`,不调用 PermissionService,不写 OpenFGA。保存时前端先 `POST /api/v1/tool/sandbox_conf`,成功后再走原有的工具配置保存。 + +不建表、不改列。回滚是删掉 `config` 表 `key=sandbox_endpoint` 的行,并删掉 Redis key `config:sandbox_endpoint`(db 以 `redis_url` 为准,发行配置是 `/1`)。 + +--- + +## Tasks + +### Wave 1 — 基础设施 + +- [x] **T001**: 错误码与配置 key + **文件**: `src/backend/bisheng/common/errcode/sandbox.py`, + `src/backend/bisheng/common/models/config.py` + **逻辑**: 新增 `SandboxEndpointInvalidError`,`Code=28009`,`Msg` 用英文。`ConfigKeyEnum` 增加 `SANDBOX_ENDPOINT = "sandbox_endpoint"`。不建表。 + **依赖**: 无 + +- [x] **T002**: 28009 三语文案 + **文件**: `src/frontend/packages/locales/src/api_errors/zh-Hans.json`, + `src/frontend/packages/locales/src/api_errors/en.json`, + `src/frontend/packages/locales/src/api_errors/ja.json` + **逻辑**: 增加 `"28009"`。中文说明地址必须是 http/https、带主机名、端口合法、不能是空地址、去掉末尾 `/` 后不能重复、不超过 32 个。不手改 `platform/public/locales/*/api_errors.json` 与 `client/src/locales/*/api_errors.gen.json`。 + **覆盖 AC**: AC-12 + **依赖**: 无 + +- [x] **T003**: 审计事件 + **文件**: `src/backend/bisheng/database/models/audit_log.py`, + `src/backend/bisheng/api/services/audit_log.py` + **逻辑**: `EventType` 增加 `UPDATE_SANDBOX_CONF = "update_sandbox_conf"`。`AuditLogService.update_sandbox_conf(user, ip, note)` 内部调用 `_system_log(user, ip, group_ids=[], event_type=UPDATE_SANDBOX_CONF, object_type=ObjectType.TOOL, object_id="sandbox_endpoint", object_name="sandbox_endpoint", note=note)`。`note` 由调用方传入,本方法不拼令牌。调用方不传 `group_ids`。 + **依赖**: 无 + +### Wave 2 — 生效值读取 + +- [x] **T004**: `get_sandbox_conf` 测试 + **文件**: `src/backend/test/sandbox/test_get_sandbox_conf.py` + **逻辑**: mock Redis 与 `ConfigDao.get_config`。库记录的 `value` 是 JSON 字符串,不是 dict。结构合法指 dict,且 `endpoints` 是字符串列表(可为空)、`token_enc` 是字符串(可为空串)。`null`、数组、缺字段、列表里有非字符串,都算不合法。无记录时返回值与 `settings.sandbox_conf` 逐字段相同。页面 `endpoints` 非空只覆盖地址,页面 `token_enc` 非空只覆盖令牌,另一项留空仍用部署值。空数组与空 `token_enc` 回退部署值。设置 `BS_SANDBOX_CONF__TOKEN` 后页面令牌仍生效(合并必须用 `model_copy`,不能重新构造 `SandboxConf`)。Redis 抛错或命中的值结构不合法时都改读库。库结构合法则本次用库里的值,且不调用 `set` 或 `setNx`。库也抛错、`value` 不是合法 JSON、或结构不合法时,回退部署配置并有 error 日志,不向外抛异常,日志不含令牌明文。`decrypt_token` 抛错同样回退部署配置并记 error 日志。未命中回填调用 `setNx`,写入的是结构合法的 dict `{"endpoints", "token_enc"}`,不是 JSON 字符串,也不是合并后的 `SandboxConf`,缓存中无令牌明文;已有合法 key 时 `setNx` 不覆盖。`setNx` 抛错时仍返回库里的值。先写入新缓存再回填,读到的仍是新值。 + **覆盖 AC**: AC-06, AC-07, AC-09, AC-10 + **依赖**: T001 + +- [x] **T005**: `ConfigService.get_sandbox_conf` + **文件**: `src/backend/bisheng/common/services/config_service.py` + **逻辑**: 同步方法。Redis key `config:sandbox_endpoint`,TTL 60 秒,客户端用 `get_redis_client_sync()`(db 跟 `redis_url`)。结构合法指 dict,且 `endpoints` 是字符串列表(可为空)、`token_enc` 是字符串(可为空串)。命中且结构合法则直接用,不读库。未命中、抛错或结构不合法都 `ConfigDao.get_config(ConfigKeyEnum.SANDBOX_ENDPOINT)`,对 `config.value` 做 `json.loads`。库结构合法则本次用这份 dict。只有最初未命中(key 不存在)时 `setNx` 回填(已有 key 不改值;`setNx` 在 key 已存在时仍会 `expire`,只刷新 TTL)。缓存结构不合法或 Redis 抛错时不再写入,坏 key 留到自身 TTL 结束。`setNx` 抛错仍返回这份库值,不回退部署配置。库结构不合法或 `json.loads` 失败则不回填,回退部署配置。解密只用于本次合并,结果不写入 Redis。`decrypt_token` 抛错视同读失败。合并只用 `settings.sandbox_conf.model_copy(update=...)`。同文件提供 `write_sandbox_endpoint_cache(payload)`,`payload` 是刚落库并确认结构后的 dict,用 `set(key, payload, expiration=60)` 覆盖写入;只有保存路径调用。不要自己 pickle,不要把 JSON 字符串或坏结构写入 Redis。库不可用、JSON 解析失败、库结构不合法与解密失败都记 `sandbox endpoint config unreadable; using deployment config`,日志不含令牌明文,异常不向外抛。 + **测试**: T004 全部通过 + **覆盖 AC**: AC-06, AC-07, AC-09, AC-10 + **依赖**: T004 + +### Wave 3 — 执行器接入 + +- [x] **T006**: factory 改读页面配置的测试 + **文件**: `src/backend/test/sandbox/test_factory_page_conf.py` + **逻辑**: `build_code_executor("container")` 把 `settings.get_sandbox_conf()` 的返回值传给 `ContainerExecutor`,不再读 `settings.sandbox_conf`。执行器只用这份 `endpoints`,空列表不可达,不扫描主机名。先构建出的执行器在随后配置变化后仍持有构建时的地址与令牌。`local` / `e2b` 分支不读沙箱配置。 + **覆盖 AC**: AC-07, AC-11 + **依赖**: T005 + +- [x] **T007**: factory 接入 + **文件**: `src/backend/bisheng_langchain/gpts/tools/code_interpreter/factory.py` + **逻辑**: container 分支改为 `kwargs["sandbox_conf"] = settings.get_sandbox_conf()`。`_CONTAINER_POOL_KEYS` 仍从调用方 kwargs 剥掉(含已退役的 `discover_*`)。不改 local / e2b。不扫描主机名。 + **测试**: T006 全部通过 + **覆盖 AC**: AC-06, AC-07, AC-11 + **依赖**: T006 + +- [x] **T021**: 助手每轮刷新执行器 + **文件**: `src/backend/test/sandbox/test_refresh_open_code_executors.py`,`src/backend/bisheng_langchain/gpts/tools/code_interpreter/factory.py`,`src/backend/bisheng/api/services/assistant_agent.py` + **逻辑**: `refresh_open_code_executors(tools)` 只处理名为 `bisheng_code_interpreter`、执行器是 `ContainerExecutor`、且 `keep_session` 为假的工具。若 `_bound_url` 与 `_session_id` 都在,先调用 `_release()`(它会 `DELETE` 远端会话,并清掉 `_bound_url`、`_session_id`、`_lease_token`)。不要自己把这两个字段置空。然后就地改这个执行器对象的 `endpoints` 与 `token` 为 `settings.get_sandbox_conf()` 的当前值,每个地址去掉末尾 `/`。不新建执行器对象。`keep_session` 为真、`local`、`e2b` 都不改。浅拷贝后共享同一执行器的工具,刷新后两边看到同一组新地址。`AssistantAgent.run` 在调用模型之前调用它,传入 `self.tools`。不重跑 `init_tools`。日常会话的 `_prepare_tools` 不调用它。 + **测试**: 先写测试再改实现。未粘滞且仍占会话的容器执行器会先 `_release()`,再被就地更新;带末尾 `/` 的地址更新后不带 `/`。粘滞的不变,也不调用 `_release()`。浅拷贝共享的执行器一起变。 + **覆盖 AC**: AC-06, AC-11 + **依赖**: T007 + +### Wave 4 — 读取视图与保存 + +- [x] **T008**: 沙箱配置服务测试 + **文件**: `src/backend/test/sandbox/test_sandbox_conf_service.py` + **逻辑**: mock `ConfigDao`、Redis、`AuditLogService`。保存写入的 key 是 `sandbox_endpoint`,`insert_or_update_config` 的 `value` 是 `json.dumps` 得到的字符串,payload 无 `tenant_id`。比较前去掉每个地址末尾的 `/`。非法地址(非 http/https、无主机名、端口越界、去掉末尾 `/` 后重复、超过 32 个、空串项)抛 `SandboxEndpointInvalidError`,不写库。合法地址落库时不带末尾 `/`。`token` 为 `********` 时 `token_enc` 不变;空串时 `token_enc` 变为空;其他值落库为 `encrypt_token` 密文,库内与日志、审计 `note` 都无明文。`note` 只含变更字段名,令牌变更写「已修改」。审计调用是 `update_sandbox_conf(user, ip, note)`,其内部传给 `_system_log` 的 `group_ids` 为 `[]`、`object_name` 为 `sandbox_endpoint`。保存后调用 `write_sandbox_endpoint_cache(解析后的 dict)`,参数是 `{"endpoints", "token_enc"}`,不是 JSON 字符串,不含明文令牌;该调用抛错时保存仍然成功,并记 error 日志。保存不发起任何 HTTP。读取响应的 `endpoints` 是页面存的列表:页面地址为空、部署有地址时,响应 `endpoints` 为 `[]`,`address_source` 为 `deployment`。库里的 `value` 不是合法 JSON,或不是「`endpoints` 为字符串列表、`token_enc` 为字符串」的 dict 时,`get()` 按没有页面配置返回(`endpoints` 为 `[]`,来源按部署配置是否有值),不抛异常。来源按字段分开:页面该项非空则该项为 `page`(令牌显示 `********`);该项页面空且部署该项非空为 `deployment`;该项两边都空为 `default`。页面只填地址、部署只设令牌时,`address_source=page` 且 `token_source=deployment`;反过来 `token_source=page` 且 `address_source=deployment`。`deployment_address` 在部署配了 `endpoints` 时为这些地址的逗号拼接,否则为空串。 + **覆盖 AC**: AC-03, AC-04, AC-06, AC-08, AC-09, AC-12, AC-13, AC-17 + **依赖**: T001, T003, T005 + +- [x] **T009**: `SandboxConfService` 的读取与保存 + **文件**: `src/backend/bisheng/tool/domain/services/sandbox_conf_service.py` + **逻辑**: `get()` 返回 design §4.2 的读取结构。响应里的 `endpoints` 是页面存的列表,页面没填地址时为 `[]`,不填入部署地址。库里的 `value` 解析失败或结构不合法(不是 dict、`endpoints` 不是字符串列表、或 `token_enc` 不是字符串)时,按没有页面配置返回,不抛异常。`save` 是 async。`save(endpoints, token, user, ip)` 做 T008 的校验、去掉末尾 `/` 与令牌合并,必须 `await ConfigDao.insert_or_update_config("sandbox_endpoint", json.dumps(...))`(`value` 只能是字符串;只有异步版本;`get_config` 才是同步的),再 `write_sandbox_endpoint_cache(json.loads 后的 dict)`(dict 只含 `endpoints` 与 `token_enc`;失败只记 error 日志,不让保存失败;缓存 TTL 60 秒,与 AC-06 相同),再 `AuditLogService.update_sandbox_conf(user, ip, note)`。来源按字段分开:该项页面值非空为 `page`;否则该项部署值非空为 `deployment`;否则 `default`。地址只看页面 `endpoints`,令牌只看页面 `token_enc`。不读不写 `gpts_tools.extra`。不读取 `discover_*`。 + **测试**: T008 全部通过 + **覆盖 AC**: AC-03, AC-04, AC-08, AC-09, AC-12, AC-13, AC-17 + **依赖**: T008 + +### Wave 5 — 测试连接 + +- [x] **T010**: 测试连接分类测试 + **文件**: `src/backend/test/sandbox/test_sandbox_conf_probe.py` + **逻辑**: `test` 是 async,用 `httpx.AsyncClient` 与 `httpx.MockTransport`,不调用同步 `httpx`。非法地址(非 http/https、无主机名、端口越界、去掉末尾 `/` 后重复、超过 32 个、空串项)抛 `SandboxEndpointInvalidError`,transport 收到 0 次请求。合法地址先去掉末尾 `/`,再用规范化后的地址拼 `/health` 与 `/v1/sessions`(`http://host:8080/` 不得请求成双斜杠)。每次请求超时 5 秒,超时记为 `unreachable`。`/health` 失败 → `unreachable`,且不调用建会话。建会话 401 → `token_rejected`。503 → `busy`。200 → `ok`,并且用响应里的 `lease_token` 调用 `DELETE /v1/sessions/{id}`。请求地址为空时,逐个探测部署配置的 `endpoints`(同样先校验);这份列表也空则结果为空。请求令牌为 `********` 时用已保存令牌,页面也没有则用部署令牌。结果里没有 runner 响应体。 + **覆盖 AC**: AC-12, AC-14, AC-16 + **依赖**: T009 + +- [x] **T011**: `SandboxConfService.test` + **文件**: `src/backend/bisheng/tool/domain/services/sandbox_conf_service.py` + **逻辑**: 只新增 async `test(endpoints, token)`,不改 T009 的 `get` / `save`。先按与保存相同的规则校验地址,不合法抛 `SandboxEndpointInvalidError` 且不发请求。通过后再去掉末尾 `/`,用 `httpx.AsyncClient` 按 T010 的分类探测,不在事件循环里调用同步 HTTP。每个地址 `GET {url}/health` 与 `POST {url}/v1/sessions`(`Authorization: Bearer`)各等待 5 秒,超时记为 `unreachable`;成功则 `DELETE`(`x-lease-token`)。写一条 info 日志,不含令牌与响应体。 + **测试**: T010 全部通过 + **覆盖 AC**: AC-12, AC-14, AC-16 + **依赖**: T010 + +### Wave 6 — HTTP 接口 + +- [x] **T012**: 三个接口的权限与保存测试 + **文件**: `src/backend/test/sandbox/test_sandbox_conf_api.py` + **逻辑**: `GET/POST /api/v1/tool/sandbox_conf` 与 `POST /api/v1/tool/sandbox_conf/test`。`is_global_super` 为假时三个接口都是 `UnAuthorizedError`,不写库。为真时委托 `SandboxConfService`,响应为 `resp_200`。保存请求不要求先测试成功。 + **覆盖 AC**: AC-02, AC-13 + **依赖**: T009, T011 + +- [x] **T013**: 挂到工具路由 + **文件**: `src/backend/bisheng/tool/api/tool.py` + **逻辑**: 三个接口只做参数接收与 `is_global_super` 判断(否则 `UnAuthorizedError`),业务交给 `SandboxConfService`。不注册新 router,不调用 PermissionService。 + **测试**: T012 全部通过 + **覆盖 AC**: AC-02 + **依赖**: T012 + +### Wave 7 — Platform 弹窗 + +- [x] **T014**: 地址解析与保存顺序测试 + **文件**: `src/frontend/platform/src/test/sandboxEndpoint.test.ts` + **逻辑**: 多行文本解析成地址数组:去空白、去掉空行、去掉每个地址末尾的 `/`。去掉斜杠后的重复、非法地址、超过 32 个能被标出。`sandboxSavePlan("container", true)` 为先沙箱后工具;`sandboxSavePlan("local", true)` 与 `sandboxSavePlan("container", false)` 为只保存工具配置。三语 `tool.json` 含地址提示(逐个填副本、不要只填负载均衡)、测试结果只代表平台服务进程、四条测试状态文案。 + **覆盖 AC**: AC-01, AC-05, AC-12, AC-15, AC-19 + **依赖**: 无 + +- [x] **T015**: 纯函数 + **文件**: `src/frontend/platform/src/pages/BuildPage/tools/builtInTool/sandboxEndpoint.ts` + **逻辑**: 实现 T014 依赖的解析、校验与 `sandboxSavePlan`。不发请求。 + **测试**: T014 中纯函数部分通过 + **覆盖 AC**: AC-01, AC-05, AC-12 + **依赖**: T014 + +- [x] **T016**: 弹窗文案三语 + **文件**: `src/frontend/platform/public/locales/zh-Hans/tool.json`, + `src/frontend/platform/public/locales/en-US/tool.json`, + `src/frontend/platform/public/locales/ja/tool.json` + **逻辑**: 替换 `executionContainerHint`。新增地址、令牌、来源(页面配置 / 部署配置 / 默认值)、测试连接、四条结果、AC-15 说明、AC-19 提示的 key。三语同时加。 + **测试**: T014 中文案部分通过 + **覆盖 AC**: AC-08, AC-15, AC-19 + **依赖**: T014 + +- [x] **T017**: 接口封装 + **文件**: `src/frontend/platform/src/controllers/API/tools.ts` + **逻辑**: 用现有 request 封装 `getSandboxConfApi`、`saveSandboxConfApi`、`testSandboxConfApi`,路径与 T013 一致。不直接 `import axios`。 + **覆盖 AC**: AC-01 + **依赖**: T013 + +- [x] **T018**: 弹窗 + **文件**: `src/frontend/platform/src/pages/BuildPage/tools/builtInTool/SandboxEndpointFields.tsx`, + `src/frontend/platform/src/pages/BuildPage/tools/builtInTool/CodeExecutor.tsx` + **逻辑**: 打开弹窗时 `getSandboxConfApi`。地址输入只用响应里的 `endpoints`(空数组保持空白),`deployment_address` 只用于来源说明,不填进输入框。选中「沙箱运行」才渲染地址(多行)与令牌,并标明两项来源;令牌只显示掩码。测试连接用当前输入调用 `testSandboxConfApi`,结果旁展示 AC-15 说明。保存时按 `sandboxSavePlan`:需要存沙箱时先 `saveSandboxConfApi`,成功后再 `onSubmit`;切到本地或 E2B 时不调用沙箱保存。`extra` 里的 `container` 仍是 `{}`。 + **覆盖 AC**: AC-01, AC-05, AC-08, AC-13, AC-14, AC-15, AC-19 + **手动验证**: + - 超级管理员打开「构建 → 工具 → 内置工具 → 代码执行器」,选「沙箱运行」,能看到地址、令牌、来源与测试连接 + - 填非法地址点保存,请求被拒绝,弹窗不关 + - 改成合法地址保存成功;再打开令牌为掩码;不改令牌再保存,令牌不变 + - 切到「本地隔离运行」保存后再切回,地址仍在 + - 非超级管理员打开同一弹窗,看不到或保存被拒绝 + **依赖**: T015, T016, T017 + +### Wave 8 — 文档与联调 + +- [x] **T019**: 两份说明 + **文件**: `features/v3.0.0-beta1/068-code-execution-sandbox/沙箱部署说明.md`, + `features/v3.0.0-beta1/068-code-execution-sandbox/沙箱使用说明.md` + **逻辑**: 写明页面入口(代码执行器弹窗 → 沙箱运行)、页面优先于环境变量与 yaml、留空回退、清空即恢复、保存后 60 秒内新执行生效(日常会话的下一条消息、已打开助手的下一轮对话、工作流的下一次运行;不用断开重连。写缓存失败时旧值仍靠 TTL 落在这 60 秒内)、正在跑的任务本轮仍用旧副本、页面改不了网络、测试结果只代表 backend、缓存 key 在 `redis_url` 的 db。不改 F068 的部署步骤本身。 + **覆盖 AC**: AC-18 + **测试降级**: 文档没有可执行断言,对照 AC-18 的四句话逐条出现在两份文档中 + **依赖**: 无 + +- [ ] **T020**: Compose 联调 + **文件**: 无代码改动 + **逻辑**: 按 design §7 的四步,生效等待按 60 秒计:两台 runner;页面把地址指到 `code-runner-1` 并测试连接;60 秒内工作流代码节点、一次日常会话,以及一条已经连着的助手的下一条消息,只打到 1;令牌写错时测试结果为令牌不被接受;非法地址测试连接返回 28009。清空页面配置后,执行改用部署配置里的两台地址。正在跑的任务在本轮结束前仍打旧副本。 + **覆盖 AC**: AC-06, AC-09, AC-11, AC-12, AC-14 + **测试降级**: 要同时起 backend、worker、Linsight 与两台 runner,单测覆盖不了跨进程生效 + **依赖**: T007, T013, T018, T019, T021 + +--- + +## 实际偏差记录 + +(实现中如有偏离,只留一行指针,论证回写 design.md。) diff --git a/features/v3.0.0-beta1/README.md b/features/v3.0.0-beta1/README.md index 32013cb995..23d3c1f6f7 100644 --- a/features/v3.0.0-beta1/README.md +++ b/features/v3.0.0-beta1/README.md @@ -25,9 +25,11 @@ | F063 | [workbench-content-safety](./063-workbench-content-safety/) | P1 | Spec + Design 已确认;tasks 已拆解(16 项 / 6 Wave) | 既有 sensitive_word、工作台聊天入口 | | F064 | [kb-list-file-abnormal](./064-kb-list-file-abnormal/) | P1 | Spec / Design / Tasks 已出,实现完成 | F027, F048, F051 | | F065 | [model-name-trim](./065-model-name-trim/) | P1 | Spec / Design 已确认;实现完成 | 既有模型管理页、`POST/PUT /api/v1/llm` | -| F067 | [commercial-license-expiry-reminder](./067-commercial-license-expiry-reminder/) | P1 | 已实现(21 / 21);Banner 改聚合,错误码 270,11001 点名 Gateway | v2.6.0 F037、ETL4LM 2.1.0-beta1、商业看板写表 | -| F068 | [code-execution-sandbox](./068-code-execution-sandbox/) | P0 | Spec + Design 已出,待 ★ 确认;tasks 未写。讨论用 [沙箱架构设计说明书](./068-code-execution-sandbox/沙箱架构设计说明书.md)。错误码模块 280 | 无(与 3.0 应用工场 F103 sandbox-core 并行,不依赖其交付) | +| F070 | [commercial-license-expiry-reminder](./070-commercial-license-expiry-reminder/) | P1 | 已实现(21 / 21);Banner 改聚合,错误码 270,11001 点名 Gateway | v2.6.0 F037、ETL4LM 2.1.0-beta1、商业看板写表 | +| F068 | [code-execution-sandbox](./068-code-execution-sandbox/) | P0 | Spec + Design 已出,待 ★ 确认;tasks 未写。讨论用 [沙箱架构设计说明书](./068-code-execution-sandbox/沙箱架构设计说明书.md);部署见 [沙箱部署说明](./068-code-execution-sandbox/沙箱部署说明.md),页面用法见 [沙箱使用说明](./068-code-execution-sandbox/沙箱使用说明.md)。错误码模块 280 | 无(与 3.0 应用工场 F103 sandbox-core 并行,不依赖其交付) | | F069 | [linsight-citation-handles](./069-linsight-citation-handles/) | P1 | Spec + Design 已出(PRD 2026-09-20 评审通过),待 ★ 确认;tasks 未写 | F047, F054, F029 | +| F067 | [unified-remote-mcp-service](./067-unified-remote-mcp-service/) | P1 | Discovery、Spec、Design 已确认,Tasks 未生成 | F053, F066 | +| F071 | [knowledge-space-chat-history-retention](./071-knowledge-space-chat-history-retention/) | P1 | Discovery、Spec、修订 Design 已确认,Tasks 未生成 | v2.6.0 F034, F048, 既有知识空间问答 | --- @@ -83,8 +85,12 @@ | 2026-09-10 | 登记 F064 文档知识库外层列表文件解析异常。 | | 2026-09-10 | 登记 F065 模型名称首尾空格兼容;Design 为《3.0 beta2》§5.7 修改方案。等待 spec + design ★ 确认;tasks 未写。 | | 2026-09-10 | 用户确认 F065 spec + design;tasks.md 完成 4 项拆解并落地前后端写入清洗。 | -| 2026-09-14 | 登记 F067 商业授权统一到期提醒;Spec / Design 按《3.0 beta2》§5.3 与用户裁定的 `license_info` 三路写入写成。等待 spec + design ★ 确认;tasks 未写。 | -| 2026-09-14 | 用户确认 F067 spec + design;tasks.md 完成 20 项拆解(6 个 Wave)。 | -| 2026-09-14 | F067 实现完成:`license_info` + 聚合/上报 API + ETL Beat + Banner 改聚合 + 11001 点名 Gateway。 | +| 2026-09-14 | 登记 F070 商业授权统一到期提醒;Spec / Design 按《3.0 beta2》§5.3 与用户裁定的 `license_info` 三路写入写成。等待 spec + design ★ 确认;tasks 未写。 | +| 2026-09-14 | 用户确认 F070 spec + design;tasks.md 完成 20 项拆解(6 个 Wave)。 | +| 2026-09-14 | F070 实现完成:`license_info` + 聚合/上报 API + ETL Beat + Banner 改聚合 + 11001 点名 Gateway。 | | 2026-09-16 | 登记 F068 代码执行沙箱统一底座;Spec / Design 按《灵思代码执行沙箱选型调研(私有化场景)》§6 与《3.0 应用工场 产品方案》§5.2 写成,选定加固容器 + compose 副本池拓扑,申领错误码模块 280。等待 spec + design ★ 确认;tasks 未写。 | | 2026-09-20 | 登记 F069 灵思任务模式引用溯源可靠性:上游契约换后端短句柄 `[Sn]`、完成时审计与前端一行 i18n 提示、P2 导出烘焙;下游(标记字法 / resolve / message_citation / 两套解析器)冻结。Spec + Design 按《灵思任务模式引用溯源优化方案》D1~D8 写成,待 ★ 确认。 | +| 2026-09-15 | 登记 F067 统一远程 MCP 服务与 F071 知识空间历史会话保留;Discovery 已确认,Spec 待确认。 | +| 2026-09-15 | F067/F071 Spec 已确认;形成两项 Design 草案,停在 Design ★ 门禁。 | +| 2026-09-15 | F071 Design 经讨论取消独立入口投影表,改为 `MessageSession.entry_flow_id` nullable 覆盖字段;修订稿重新停在 Design ★ 门禁。 | +| 2026-09-15 | 用户确认 F067/F071 Design ★;两项文档就绪,尚未生成 tasks.md 或进入编码。 | diff --git a/features/v3.0.0-beta1/release-contract.md b/features/v3.0.0-beta1/release-contract.md index fe733e6b86..8189ec0d0a 100644 --- a/features/v3.0.0-beta1/release-contract.md +++ b/features/v3.0.0-beta1/release-contract.md @@ -11,6 +11,9 @@ > 改为打开单行操作菜单后按资源查询,保持 F048 最终动作判定与 F027 列表筛选契约;F052 为工作流 > 独立会话增加系统级统一的“打开已结束会话时自动重新运行”能力;F060 替代 v2.6.0 F031 > 以租户本地元数据推断远端订阅状态的旧语义,建立平台级订阅对账、公共文章同步和知识空间一次投递。 +> 独立会话增加系统级统一的“打开已结束会话时自动重新运行”能力;F067 仅将中粮 SeedMind Apifox +> 文档确认的 10 项知识资源/文件 API 以统一远程 MCP 服务交付,不随 F053 其他开放能力自动扩围,且不建立第二套身份与权限合同;F071 在文件/目录删除、空间内容清空或跨空间移出后, +> 将仍存在的知识空间问答历史保留到原空间根目录。 --- @@ -47,12 +50,15 @@ | InformationArticleSyncState | F060-information-source-subscription-reconciliation | 每个公共信息源一份文章增量进度和已处理远端水位;不带租户归属,不保存知识投递状态 | | **ApiCredential**(平台自签发凭据底座:服务账号密钥 `bs-sak-` + 个人访问令牌 `bs-pat-`;同一张表、同一条校验路径、同一套哈希 / 撤销 / 租户隔离语义;P2 属性 IP 白名单 / 限流 / 日配额同表) | **F053-openapi-auth-and-identity** | 拥有凭据的生成、哈希存储、校验、权限位清单(含三扩展位登记,beta1 不建其消费面)、过期、软撤销与批量撤销、最后使用时间;`delegate` 位与委托范围(`api_credential_delegate_scope`)随同一 Feature 的身份传递工作流追加。代码底座自 `3.0-vibe` 移植(PRD §4.2 / §4.10) | | **ServiceAccount**(不可登录的服务账号主体:`user.user_type='service'` + 伴生表 + 租户归属直写 + 资源归属人 + 登录守卫 + 选人排除 + 主体侧授权页) | **F053-openapi-auth-and-identity** | PRD R4 / R6。不拥有 `User` 表本身(自然人侧行为不变、仅加 `user_type` 列)。服务账号不得被授予管理员身份 / 角色、不得加入用户组 / 部门 | -| **OpenApiCallLog**(开放 API 逐调用审计:actor 凭据 · subject 被代表用户 · 外部标识 · 端点 · 状态 · IP · 耗时) | **F053-openapi-auth-and-identity** | PRD R7 / §4.4.5 双归属。独立于 `audit_log` 操作审计表;批量落库、按保留期清理 | +| —(无新增领域对象;开放 API 逐调用审计复用既有 `AuditLog`) | **F053-openapi-auth-and-identity** | PRD R7 / §4.4.5 双归属。`audit_log.action='open_api.call'`,actor / subject / 外部标识 / 端点 / 状态 / IP / 耗时写公共列与 `metadata`;经既有有界队列批量落库并跟随统一审计保留策略,不建 `open_api_call_log` | | **OpenApiTenantSetting**(租户级 PAT 开关与默认有效期) | **F053-openapi-auth-and-identity** | PRD §4.10.7 闸门一的租户级半边;部署级半边在进程 Settings | | **ShareLink**(既有对象;本版增量 = `share_scope` 列 + 撤销写入 + 有效期强制生效 + share-token 会话执行主体) | **F053-openapi-auth-and-identity** | 两个免登录分享页改走 share_link 通道;只拥有本版对该对象的写行为增量,不拥有既有创建 / 读取 | | **MessageSession / ChatMessage**(既有对象;本版增量 = `external_user_id` 分区键列,只写不读) | **F053-openapi-auth-and-identity**(列)| 会话本体仍归既有会话模块;本 Feature 只拥有该列的写入语义(PRD §4.3.4 / §4.6.3 四) | | **OpenApiTenantSetting**(既有对象,本体归 F053;**本增量 = `pat_data_scope` 数据范围列 + 租户策略变更操作审计写入**) | **F066-pat-data-scope-and-ai-access** | PRD v2.9 §4.10.7 闸门一之二(D21)。只拥有该列与策略审计的写行为增量;开关 + 默认有效期的既有写行为仍归 F053 | | —(无新增领域对象;在 F029 拥有的 citation 链路上扩展第三种来源类型与其载荷) | **F054-unified-citation-entries** | 频道文章 AI 问答接入统一溯源:新增「文章」来源类型及其来源载荷(真实稳定定位标识 = 文章文档标识 / 原文链接,不伪造知识库片段标识)、「来源已失效」状态、来源详情对**无已登录用户**调用一律不返回。只读 / 调用现有 `MessageCitation` 与 citation 注册 / 解析服务;**不拥有** `message_citation` schema,不改 F041 已登记的 `accessScope` 两档语义,不改灵思任务模式(归 F047),不新增表 / Alembic / 对外 API / 错误码。**不再拥有**「工作流临时来源是否出角标」的写语义——该条由 F062 取代(仅 `ingest_to_temp_kb` 召回) | +| —(无新增领域对象) | **F067-unified-remote-mcp-service** | 仅将中粮 SeedMind Apifox 范围基线确认的 10 项知识资源/文件 API 一对一投影为统一远程 MCP 工具;复用 ApiCredential、`audit_log` 的 `open_api.call` 事件、身份、资源权限、PAT 数据范围与业务 Service,不建立第二套凭据、授权、审计或业务对象,也不随 F053 其他路由或 Apifox 后续变更自动扩围 | +| **MessageSession**(既有对象;本增量 = nullable `entry_flow_id` 展示入口覆盖列) | **F071-knowledge-space-chat-history-retention**(列) | null 时入口沿用原 `flow_id`;文件/目录删除、保留空间的内容清空或跨空间移出的对应 DB commit 后,按纳入范围的 flow 分片 best-effort 异步写原空间根目录,拥有 sticky root 与存量恢复语义;完整删除空间不新派发、不提供可访问入口,但不清理旧 session/entry 或取消已排队任务;在线派发不建设 outbox,不改写原 `flow_id`、ChatMessage 或 MessageCitation,session `update_time` 沿用既有元数据更新机制 | +| —(无新增领域对象;在 F029 拥有的 citation 链路上扩展第三种来源类型与其载荷) | **F054-unified-citation-entries** | 频道文章 AI 问答接入统一溯源:新增「文章」来源类型及其来源载荷(真实稳定定位标识 = 文章文档标识 / 原文链接,不伪造知识库片段标识)、「来源已失效」状态、来源详情对**无已登录用户**调用一律不返回、工作流输入节点临时文件停止登记来源。只读 / 调用现有 `MessageCitation` 与 citation 注册 / 解析服务;**不拥有** `message_citation` schema,不改 F041 已登记的 `accessScope` 两档语义,不改灵思任务模式(归 F047),不新增表 / Alembic / 对外 API / 错误码 | | —(无新增) | F054-contextual-department-membership | 复用组织域 canonical 成员/祖先事实作为请求时权限输入,消除 OpenFGA 部门子树递归;仅演进 F048 授权模型与运行时装配,不新增组织对象、永久成员闭包或业务接口 | | —(无新增领域对象;在 F029 拥有的 citation 链路上扩展第四种来源类型与其载荷) | **F062-workflow-temp-kb-citation** | 工作流输入节点「解析并存入临时知识库」召回可点击溯源:新增 `citation_type=temp`(前缀 `tempsearch_`,原件定位 = UUID + F043 `objectName`,不伪造知识库整数 id)。只读 / 调用现有 `MessageCitation` 与 citation 注册 / 解析服务;**不拥有** `message_citation` schema,不改 INV-7 / `view_file`,不把临时来源纳入知识空间 OpenFGA,不新增表 / Alembic / 对外 API / 错误码 | | —(无新增) | F061-qa-active-image-read | 日常模式 / 知识空间 / 频道订阅三条问答链路按需查看检索结果或文章正文中的 markdown 图片;复用既有工作台模型视觉开关、检索入口与 `view_file` / 敏感文过滤,不新增领域对象、表、对外 API、错误码或不变量 | @@ -60,15 +66,16 @@ | —(无新增) | F064-kb-list-file-abnormal | 文档知识库外层列表只读展示库级文件解析异常,并支持仅异常筛选;复用既有 `Knowledge` / `KnowledgeFile` 与 F027 游标、F048 可见性,不新增领域对象、表、错误码或不变量。Alembic 仅增加 `knowledgefile` 复合索引 | | —(无新增) | F065-model-name-trim | 模型管理写入时去掉 `models[].model_name` 首尾空白;复用既有 `LLMModel` / `POST/PUT /api/v1/llm`,不新增领域对象、表、错误码、不变量或 Alembic | | —(无新增领域对象;在既有 `BaseExecutor` 执行后端抽象上新增第三种执行模式,并接管工作流代码节点的执行位置) | **F068-code-execution-sandbox** | 把模型/搭建者写的 Python 从 backend 与 Celery worker 进程搬入受控隔离执行环境,覆盖灵思任务模式、工作台日常会话、助手、工作流 Agent 节点、工作流工具节点五条既有路径,并迁入工作流代码节点。只读 / 调用现有 `ToolExecutor`、`gpts_tools.extra`、灵思任务工作区与 MinIO 产物链路;**不拥有** `gpts_tools` schema、不改工具装配的权限校验、不改日常会话「附件只抽文本进 prompt」语义、不改知识库解析链路的 LibreOffice 调用。不新增表 / Alembic / 对外 API 路径 / 领域对象 / 不变量;新增错误码模块 280 与系统配置段 `sandbox_conf`(发布配置中注释掉,避免旧镜像启动失败) | -| **LicenseInfo**(平台级当前授权状态:每个商业模块一行;无 `tenant_id`;不含密文 / 私钥 / 设备指纹) | **F067-commercial-license-expiry-reminder** | 拥有 `license_info` 的写入语义与聚合读取。`etl` 由 BISHENG 拉 ETL 授权接口后 upsert;`dashboard` 由商业看板服务定期更新对应行;`gateway` 由管理后台读取 Gateway 状态后再交给 BISHENG upsert。不拥有 Gateway / ETL / 看板各自的授权密文与解密 | -| —(无新增) | F068-nvdb-security-fixes | NVDB 2026-09-02 批次 5 个漏洞的修复:JWT 密钥去代码默认值(未配置时生成一次存 `config` 表,键 `jwt_secret`)、知识空间排序参数白名单、HTML 本地媒体目录围栏、文件下载工具本地路径围栏、创建工作流 / 助手接口校验 `create_app` 菜单权限。复用既有 `Config` 表与 `RoleAccess` WEB_MENU,不新增领域对象、错误码、对外 API 或 Alembic。工作流代码节点沙箱不在本 Feature | +| **LicenseInfo**(平台级当前授权状态:每个商业模块一行;无 `tenant_id`;不含密文 / 私钥 / 设备指纹) | **F070-commercial-license-expiry-reminder** | 拥有 `license_info` 的写入语义与聚合读取。`etl` 由 BISHENG 拉 ETL 授权接口后 upsert;`dashboard` 由商业看板服务定期更新对应行;`gateway` 由管理后台读取 Gateway 状态后再交给 BISHENG upsert。不拥有 Gateway / ETL / 看板各自的授权密文与解密 | +| —(无新增) | F072-nvdb-security-fixes | NVDB 2026-09-02 批次 5 个漏洞的修复:JWT 密钥去代码默认值(未配置时生成一次存 `config` 表,键 `jwt_secret`)、知识空间排序参数白名单、HTML 本地媒体目录围栏、文件下载工具本地路径围栏、创建工作流 / 助手接口校验 `create_app` 菜单权限。复用既有 `Config` 表与 `RoleAccess` WEB_MENU,不新增领域对象、错误码、对外 API 或 Alembic。工作流代码节点沙箱不在本 Feature | | —(无新增领域对象;在 F029 拥有的 citation 链路上为灵思任务模式增加会话级来源编号表与完成时审计) | **F069-linsight-citation-handles** | 拥有 Redis `linsight:cite_handles:` / `linsight:cite_seen:` 的写入语义与 `output_result.citation_audit` 字段。不拥有 registry item 生成、`message_citation` 写入(仍经 F047 路径)、resolve 与权限过滤 | -| —(无新增领域对象;日常模式成为 F069 会话级来源编号表的第二个写入方) | **F072-daily-chat-citation-handles** | 工作台日常模式检索结果改呈现 `[Sn]` 短编号、流式下发前转回私有区标记、零引用不落库、历史回放标记转编号。编号表 `linsight:cite_handles:` 的写入语义仍归 F069,本 Feature 只调用其分配接口且**不写** `meta:enabled`。不拥有 registry item 生成、`message_citation` schema、resolve 与权限过滤;不改知识空间 / 频道 / 工作流 / 助手的引用链路与共享的 `citation.yaml`、`format_retrieved_chunk`、`select_registry_items_for_persistence` | +| —(无新增领域对象;日常模式成为 F069 会话级来源编号表的第二个写入方) | **F075-daily-chat-citation-handles** | 工作台日常模式检索结果改呈现 `[Sn]` 短编号、流式下发前转回私有区标记、零引用不落库、历史回放标记转编号。编号表 `linsight:cite_handles:` 的写入语义仍归 F069,本 Feature 只调用其分配接口且**不写** `meta:enabled`。不拥有 registry item 生成、`message_citation` schema、resolve 与权限过滤;不改知识空间 / 频道 / 工作流 / 助手的引用链路与共享的 `citation.yaml`、`format_retrieved_chunk`、`select_registry_items_for_persistence` | +| —(无新增领域对象;复用既有 `Config` 表新增一条部署级记录) | **F086-sandbox-endpoint-ui** | 拥有沙箱接入参数页面配置(接入地址与访问令牌)的写入语义;页面填写的字段覆盖 F068 `sandbox_conf` 的部署配置,留空回退。不拥有 `gpts_tools.extra`(代码执行方式仍存其中、沙箱参数仍不进 `extra`),不改 runner 参数、网络可达性与 F068 执行语义 | | `LinsightSessionVersion.api_meta`(增量字段,本体归灵思既有 Feature) | **F073-openapi-task-mode** | 开放 API 发起的任务模式任务的元数据(`channel=open_api_v2`、业务上下文指令、凭据 id、身份模式);worker 据此无人值守执行(不装配 `ask_user`、技能缺失即失败)。只写本列,不改 `LinsightSessionVersion` 其余字段的写入语义;工作台任务该列为空、行为不变。复用 F053 `SessionSubject` 标记会话归属,不新增表 / 领域对象 / 不变量 | > ⚠️ **F054 编号冲突未决**:上表两行都占用 F054。`unified-citation-entries` 在 `feat/3.0.0-beta1` 取号,`contextual-department-membership` 在 `feat/3.0.0-beta1-test` 上已从 F053 改号而来,合并后再次撞号。两行都保留,改哪一个由各自 Feature owner 决定。 > -> ⚠️ **F068 编号冲突未决**:上表两行都占用 F068。`nvdb-security-fixes` 在 `feat/3.0.0-beta2` 取号(且已写明「工作流代码节点沙箱不在本 Feature」),`code-execution-sandbox` 在本提交取号。两行都保留,改哪一个由各自 Feature owner 决定。 +> **F068 曾三方占号,已解决**:统一 3.0.0 分支合并时,`code-execution-sandbox` 保留 F068,`knowledge-space-chat-history-retention` 改为 F071,`nvdb-security-fixes` 改为 F072。F071 的 Alembic revision id 仍是 `f068_knowledge_chat_entry`:它已在测试与定制环境执行过,改 id 会被当成新迁移重跑。 **规则**: - 非 Owner Feature 的 AC 中不得出现其他对象的"创建/修改/删除"行为,只能"读取"或"调用" Owner 的 Service @@ -109,10 +116,12 @@ | INV-30 | **平台自签发凭据统一底座**:服务账号密钥与个人访问令牌必须密码学随机生成、服务端仅存单向哈希、明文仅签发时一次性展示、此后只见掩码、撤销为软撤销并保留审计、撤销 / 停用 / 编辑生效上界 5 秒(主动清缓存,不依赖 TTL);有效性判据唯一 = 未撤销且未过期,不引入可独立漂移的状态枚举列;不留第二类不受治理的令牌形态 | ApiCredential | F053 | | INV-31 | **服务账号主体不可登录、不进任何选人场景、授权只走主体侧**:租户归属声明式写入、任何以部门树为真相的对账结构性跳过它;不计入用户数配额;不出现在任何面向人的选人场景(含资源侧授权选人框,「已授权对象列表」仅可显示),排除做在数据访问层、失败方向是"看不到"而非"泄漏";资源授权唯一入口在其详情页主体侧;不得被授予管理员身份 / 角色、不得加入用户组 / 部门 | ServiceAccount | F053 | | INV-32 | **开放面权限评估 fail-closed**(INV-19 在开放 API 上的加强):权限引擎不可用、评估失败或结果不可判定时返回错误,**绝不返回未过滤或部分过滤的结果集**;不存在"缺身份即降级返回某个子集"的路径;P2 的限流 / 配额 / 幂等在 Redis 不可用时同样拒绝 | ApiCredential, PermissionGrant | F053 | -| INV-33 | **身份模式只有两种、委托是纯替换且必须凭据先行**:权限基准 = 密钥主体(自身身份)或经五道准入的被代表用户(代表他人);`delegate` 是唯一开关、持有即强制(漏传身份头报错、不落回自身身份)、范围必填且只有 `user` / `department` 两类;委托目标必须是自然人、非超管非租户管理员、同租户、在范围内,判定在调用期;模式 D 下资源与会话归被代表用户且不回授服务账号;三扩展位与 `delegate` 互斥硬阻断;`X-Bisheng-End-User` 不是身份模式、不参与任何权限判定 | ApiCredential | F053 | +| INV-33 | **身份模式只有两种、委托是纯替换且必须凭据先行**:权限基准 = 密钥主体(自身身份)或经五道准入的被代表用户(代表他人);`delegate` 是唯一开关、持有即强制(漏传身份头报错、不落回自身身份)、范围必填且只有 `user` / `department` 两类;委托目标必须是自然人、非超管非租户管理员、同租户、在范围内,判定在调用期;模式 D 下资源与会话归被代表用户且不回授服务账号;三扩展位与 `delegate` 互斥硬阻断;`X-End-User` 不是身份模式、不参与任何权限判定 | ApiCredential | F053 | | INV-34 | **个人访问令牌的治理**:主体只能是自然人本人、权限动态继承持有人(不快照)、本期只可授予 `knowledge:read`,`identity:read` 与 `delegate` 永久禁令;随持有人停用 / 删除 5 秒内级联失效(**换租户不失效、随人迁移**——PRD v2.6 D19,原「离开租户级联失效」表述作废);管理员短路照常生效但可见租户集合恒为密钥所属租户(超管不放开租户过滤),**且「短路照常」仅在默认数据范围(all_visible)下成立——租户级数据范围收窄(PRD v2.9 D21)优先于管理员短路,对含管理员在内的全体持有人一致生效**;两层能力开关默认关、关闭 = 停用不撤销、按主体类型独立(关 PAT 不得影响服务账号密钥);数据范围同为调用期准入检查、不写凭据行、可逆;管理员台账只返回元数据 | ApiCredential, OpenApiTenantSetting | F053、F066 | -| INV-35 | **商业授权状态是部署级当前值**:`license_info` 每个 `license_code` 至多一行,不带 `tenant_id`,不存授权密文 / 私钥 / 设备指纹。功能开关(ETL 地址、`BISHENG_DASHBOARD_PRO` 等)不得用来推断是否有效或何时到期。取源失败不得把已有成功记录改写成已过期;未取得不得展示为已过期 | LicenseInfo | F067 | -| INV-36 | **登录态签名密钥不得来自代码**:`Settings.jwt_secret` 无可用默认值;历史内置默认值(`secret`、`secret_cF2k…`)即使写入 `config.yaml` 也视为未配置。未配置时由首次需要的进程随机生成并持久化到 `config.jwt_secret`,同一部署所有进程共用;轮换只经改 yaml 或删该记录后重启。任何直接拼接 `ORDER BY` / 读取调用方给定本地路径的代码必须先按固定集合 / 固定根目录校验 | Config, KnowledgeFile | F068 | +| INV-35 | **商业授权状态是部署级当前值**:`license_info` 每个 `license_code` 至多一行,不带 `tenant_id`,不存授权密文 / 私钥 / 设备指纹。功能开关(ETL 地址、`BISHENG_DASHBOARD_PRO` 等)不得用来推断是否有效或何时到期。取源失败不得把已有成功记录改写成已过期;未取得不得展示为已过期 | LicenseInfo | F070 | +| INV-36 | **登录态签名密钥不得来自代码**:`Settings.jwt_secret` 无可用默认值;历史内置默认值(`secret`、`secret_cF2k…`)即使写入 `config.yaml` 也视为未配置。未配置时由首次需要的进程随机生成并持久化到 `config.jwt_secret`,同一部署所有进程共用;轮换只经改 yaml 或删该记录后重启。任何直接拼接 `ORDER BY` / 读取调用方给定本地路径的代码必须先按固定集合 / 固定根目录校验 | Config, KnowledgeFile | F072 | +| INV-35 | **统一远程 MCP 只承载已确认的 10 项 API allowlist,既有 API 接口逻辑冻结**:每个指定 API 必须恰好对应一个可调用工具,工具发现不得返回 allowlist 之外的 BISHENG 业务工具,API/Apifox 后续变化不得自动扩围;工具发现和每次执行都必须使用平台既有凭据,并遵守与对应开放 API 相同的服务账号 S/D、PAT、租户与数据范围、资源动作、业务状态、`audit_log` 逐调用审计、结果与错误含义;F067 不得修改已有 API 的校验、分支、调用顺序、副作用、入参、出参或错误处理,协议形态不能原样复用时只允许在 MCP 工具 schema/adapter 内作显式兼容;MCP 成功输出遵循逐工具 `outputSchema`,工具错误遵循 `isError` 并返回明确 `code/message`,不复制 HTTP 状态信封;业务参数不得选择执行主体,任何认证或授权结果不可判定时失败关闭 | ApiCredential, AuditLog, PermissionGrant | F067、F053、F066 | +| INV-36 | **知识空间问答历史归原空间所有**:关联文件/目录删除、保留空间本身的内容清空或跨空间移出后,未被用户主动删除且仍存在的会话与消息在在线任务正常执行后通过 `MessageSession.entry_flow_id` 在原空间根目录可见并可按全空间范围继续问答,目标空间不得继承;完整删除知识空间不新派发、不提供可访问历史入口,但沿用现状保留旧 session/entry,也不取消此前排队的回收任务,其写入的失效 entry 元数据由空间存在性与权限校验阻断访问;在线回收在每个 hard-delete commit、`clear_space` 子资源删除 commit 或跨空间 move metadata commit 后按 flow 分片 best-effort 投递,允许任务完成前短暂不可见,并接受派发/执行故障、任务扫描后迟到提交及直接 move rows 外的版本 sibling 留下的残余失联;不为此阻塞资源操作、建设 outbox、修改会话创建链路或周期全量扫描;回收不得改写原 `flow_id`、消息/引用内容及其时间,不得改变会话所有者或绕过原空间权限;session `update_time` 可按既有元数据更新机制变化,但不作为知识空间会话排序或最后消息时间;删除覆盖实际硬删除集合,`clear_space` 覆盖删除前已取得的全部子资源,移动覆盖 valid item 的直接 move rows,批量父子输入先规范化,处理保持幂等且回收后不自动回绑,同空间移动不触发;存量发布门禁只要求可恢复失联集合归零,已删除空间会话与其失效 entry 单独报告且不恢复、不清理 | MessageSession, ChatMessage, MessageCitation, Knowledge | F071 | (INV-1~7 为 v2.6.0 存量不变量,继续有效,见 `features/v2.6.0/release-contract.md`。) @@ -146,10 +155,14 @@ | F063-workbench-content-safety | 既有 `sensitive_word` 租户策略(F036)、工作台首页、F035 日常/任务统一 `chat/completions`、商业版 `BISHENG_PRO` | 接线型;新枚举值 `workbench_chat` 一份策略管两种模式。开源版不展示、不审查。不改工作流 Gateway 词表,不审文件/任务输出 | | F064-kb-list-file-abnormal | F027、F048、F051 | 文档知识库外层列表增加只读异常标记与 SQL 下推筛选;保持游标分页、可见-first 与行操作懒加载,不改 QA / 知识空间 / 解析写状态 | | F065-model-name-trim | 既有模型管理页、`POST/PUT /api/v1/llm` | 写入侧收紧 `model_name` 首尾空白;不改 19802、不回填存量、不新增错误码 | -| F067-commercial-license-expiry-reminder | v2.6.0 F037(Gateway 状态接口与 Banner 入口);ETL4LM 2.1.0-beta1 `GET /api/license_info`;商业看板服务写 `license_info` | 统一到期提醒;推翻 F037「Banner 只直连 Gateway」的展示路径,不改 Gateway 降级拦截范围 | -| F068-nvdb-security-fixes | 既有 `Config` 表、`RoleAccess` WEB_MENU(`create_app`)、F027 目录 / 搜索排序参数、知识库 HTML 解析管线、`core/cache/utils` 下载工具 | 安全修复;每处只修根因所在层并加一道数据层再校验;不改前端、不加错误码。升级后全员重新登录 | +| F070-commercial-license-expiry-reminder | v2.6.0 F037(Gateway 状态接口与 Banner 入口);ETL4LM 2.1.0-beta1 `GET /api/license_info`;商业看板服务写 `license_info` | 统一到期提醒;推翻 F037「Banner 只直连 Gateway」的展示路径,不改 Gateway 降级拦截范围 | +| F072-nvdb-security-fixes | 既有 `Config` 表、`RoleAccess` WEB_MENU(`create_app`)、F027 目录 / 搜索排序参数、知识库 HTML 解析管线、`core/cache/utils` 下载工具 | 安全修复;每处只修根因所在层并加一道数据层再校验;不改前端、不加错误码。升级后全员重新登录 | | F069-linsight-citation-handles | F047(下游契约全部沿用);F054(AC-07 / AC-12 导出契约,P2 改为烘焙);F029 / F041(`view_file` 与 INV-7,本 Feature **不改**) | 上游契约替换型:模型只写 `[Sn]`,写盘边界转回私有区标记;下游零改动。不做自动补引用与事后归因写回;kill switch 可整体回退到 F047 契约 | -| F072-daily-chat-citation-handles | F069(编号表、转换文法、`assign_handles`);F047 / F054(私有区标记、`message_citation`、resolve 分态、会话导出剥标 AC-07 / AC-12);F029 / F041(`view_file` 与 INV-7,本 Feature **不改**) | 上游契约替换型:日常模式模型只写 `[Sn]`,下游零改动;无开关、无过渡期;无新增错误码 / 对外 API / 表 / Alembic / 系统配置项 | +| F054-unified-citation-entries | F029、F041(均为 v2.6.0 存量,已上线);与 F047 共用同一 citation 链路但互不阻塞 | 接线 + 扩展型:新增「文章」来源类型、失效态、匿名收紧、临时文件停发角标(导出烘焙不在本 Feature,归 F047 Phase 2)。**与 F053 有一处待对齐**:F053 把免登录分享页改走 share_link 通道并引入 share-token 会话执行主体,本 Feature AC-14「无已登录用户即不返回来源详情」的判据需与之对齐(见 spec §2.4 待澄清)。灵思任务模式不在本 Feature,归 F047 | +| F067-unified-remote-mcp-service | F053、F066 | 只为中粮 SeedMind Apifox 范围基线明确列出的 10 项知识资源/文件 API 增加统一 MCP 访问面;凭据、S/D 身份、PAT 数据范围、资源授权、审计和业务语义仍由既有 Feature 定义;F053 其他开放路由不在本期范围 | +| F071-knowledge-space-chat-history-retention | v2.6.0 F034、F048、既有知识空间问答 | 在文件/文件夹删除及跨空间移动的既有成功语义上增加源空间历史保留;权限继续走 F048,其他会话类型不受影响 | +| F075-daily-chat-citation-handles | F069(编号表、转换文法、`assign_handles`);F047 / F054(私有区标记、`message_citation`、resolve 分态、会话导出剥标 AC-07 / AC-12);F029 / F041(`view_file` 与 INV-7,本 Feature **不改**) | 上游契约替换型:日常模式模型只写 `[Sn]`,下游零改动;无开关、无过渡期;无新增错误码 / 对外 API / 表 / Alembic / 系统配置项 | +| F086-sandbox-endpoint-ui | F068(`SandboxConf`、服务地址列表 `endpoints`、`build_code_executor`、管理端「代码执行方式」弹窗) | 页面配置覆盖部署配置、不重启生效;不改 F068 的执行、失败与隔离语义 | | F073-openapi-task-mode | F053(密钥管线、`OpenApiPrincipal`、`SessionSubject`、会话附件上传、`/api/v2/workstation/*`);F063(任务模式输入内容安全);F048(`knowledge_library` 的 `use`、`knowledge_space` 的可见性、`tool` 的 `use`);灵思任务模式执行内核(v2.6.0 F035 存量) | 接线型:在 F053 会话接口上点亮「任务 × 异步」,查询 / 下载 / 终止 / 可用配置查询;全部权限在提交时判定,worker 执行期无用户级判定;抽出共享提交核心 `submit_task_turn` 与 `LinsightWorkbenchImpl.terminate`(工作台行为不变)。Alembic 一列 | --- @@ -172,9 +185,11 @@ | F031-channel-source-subscription-reconcile | F060 替代其“各租户 `channel_info_source` 行存在即代表已订阅、按租户分别对账”的运行语义。频道来源意图改为全部活跃租户并集,远端 `/information/subscriptions` 完整分页成为实际订阅真相;`channel_info_source` 改为平台公共展示目录。F031 已交付的频道创建/编辑能力继续保留,但不得再以本地元数据行推断远端订阅状态 | | 既有 `/api/v2` 开放 API(`open_endpoints/`)与两个免登录分享页 | F053:全部 43 HTTP + 2 WS 端点接入凭据校验,6 个 `/chat/*` 不暴露,裸 `user_id` 参数移除,`download_statistic` 入参 `file_path → file_name`;分享页改走 share-token;`user` 表加 `user_type`、`_filter_users_statement` 默认排除服务账号(8 处消费点无感);F048 `authorize_created` 增 `autogrant_user_id` kwarg 与来源值 `SERVICE_ACCOUNT_AUTOGRANT`(非 protected、可撤销) | | F029-knowledge-qa-permission-filter(AC-20)· F041(匿名分档) | **F054 覆盖其匿名放行语义**。F029 AC-20 当初有意为分享链接 / 公开流程保留「匿名调用不过滤」,F041 的分档同样在匿名时全放行——这正是本期要堵的越权口子。F054 起:**无已登录用户的调用不再返回知识库与文章来源详情(含 `shared` 档),网页来源仍放行**;已登录用户的 `per_user` / `shared` 两档语义完全不变,INV-7 及其 F041 例外不受影响。F029 AC-20 与 F041 匿名断言的三个既有用例随 F054 T006 一并改写为新预期 | -| v2.6.0 F037-gateway-license-expiry-reminder | **F067 覆盖其 Banner 数据源与文案合同**。`GET /api/license/status` 与「只拦网关付费接口」的降级范围保持不动;管理后台 Banner 改为先上报 Gateway 状态再读平台聚合接口,文案必须点名授权对象,不再单独按 Gateway `warning/critical` 分色渲染「软件授权」 | +| v2.6.0 F037-gateway-license-expiry-reminder | **F070 覆盖其 Banner 数据源与文案合同**。`GET /api/license/status` 与「只拦网关付费接口」的降级范围保持不动;管理后台 Banner 改为先上报 Gateway 状态再读平台聚合接口,文案必须点名授权对象,不再单独按 Gateway `warning/critical` 分色渲染「软件授权」 | | v3.0.0-beta1 F054-unified-citation-entries | **F069 P2 修订其 AC-07 / AC-12 导出契约**:任务模式报告导出(Word / PDF / 打包 md / 单文件转换 / 前端另存 md)允许出现可见编号 `[n]` 与文末参考资料段,仍不得出现内部引用键、片段标识与协议标记;会话导出(日常模式)不在 F069 范围、维持剥标 | -| v3.0.0-beta1 F054-unified-citation-entries(AC-18)· F069 | **F072 修订 F054 AC-18 对日常模式的约束**:日常模式的溯源上游契约改为短编号,零引用不再全量落库(工作流 / 助手 / 知识空间仍受 AC-18 约束)。**F069 编号表改为两模式共写**:日常模式先建表时不钉 `meta:enabled`,任务模式开关语义不变 | +| F053 / F066 开放 API 凭据、身份与数据范围 | F067 仅为指定 10 项知识资源/文件 API 增加统一 MCP 访问面,不改变开放 API 的地址、参数、凭据治理、S/D 身份、PAT 能力与数据范围合同,也不自动暴露 F053 其他能力;MCP 工具发现和执行不得成为绕过调用期授权的旁路 | +| v2.6.0 F034 与既有知识空间问答 | F071 使删除、随 `clear_space` 清空或跨空间移出的文件/目录关联历史回收到源空间根目录并可继续问答;完整删除空间不恢复;同空间移动、资源重命名、目标空间历史和其他会话类型保持原合同 | +| v3.0.0-beta1 F054-unified-citation-entries(AC-18)· F069 | **F075 修订 F054 AC-18 对日常模式的约束**:日常模式的溯源上游契约改为短编号,零引用不再全量落库(工作流 / 助手 / 知识空间仍受 AC-18 约束)。**F069 编号表改为两模式共写**:日常模式先建表时不钉 `meta:enabled`,任务模式开关语义不变 | | 既有工作流「代码节点」 | **代码节点改为默认关闭**(系统配置 `workflow.code_node_enabled`,缺省即关闭)。该节点执行用户填写的 Python,当前版本无执行沙箱,开启后所有能编辑工作流的用户等同于持有服务器命令执行权限,因此在沙箱(F068-code-execution-sandbox)落地前默认不开。门禁位于节点构造处而非运行处——解析代码本身即执行代码。**存量含代码节点的工作流在管理员开启前不可运行**,需在发版说明中明示 | | v3.0.0-beta1 F053-openapi-auth-and-identity · 灵思任务模式(v2.6.0 F035) | **F073 修订 F053**:`26017` 含义收窄为「枚举外运行模式」(`run_mode="task"` 合法);`POST /api/v2/workstation/chat/completions` 改收原始 body 按 `run_mode` 分派(日常分支校验与响应不变,请求体 schema 以 `oneOf` 发布);`GET /api/v2/workstation/config` 增加 `run_mode` 参数;日常模式「模型不可用」由裸 400 改为 `26066`;`chat:invoke` 在管理界面更名「调用会话与任务」并覆盖任务模式(存量密钥升级后即可发起任务模式,见 PRD §7.1)。**F073 对灵思任务模式**:提交编排抽成共享核心、终止主体下沉为 `LinsightWorkbenchImpl.terminate`,v1 端点判定与响应不变 | @@ -188,12 +203,13 @@ |----------------|------|---------------| | —(不新增) | 既有功能体验优化与引用溯源 | F043 复用工作流/报告既有错误响应;F044 验证失败是业务结果(状态=异常)而非错误响应,不占码;F045/F046 纯前端;F047 复用 citation 子系统与 F029 权限过滤的既有错误响应 | | —(不新增) | 工作流会话打开时自动重新运行 | F052 复用既有系统配置、工作流状态与重新运行错误响应 | +| —(不新增) | 统一远程 MCP 与知识空间历史保留 | F067 复用开放 API 的认证、授权和业务错误语义;F071 复用知识空间、文件/目录操作与会话的既有错误语义 | | 250 | ReBAC 权限 Catalog、Grant、投影、迁移与完整枚举 | F048;25001~25014,具体语义见 F048 Design §6.3 | | —(不新增) | 信息源订阅对账、公共文章同步与知识空间一次投递 | F060 仅调整内部任务与状态,不新增对外 API 或业务错误码 | | 260 | 开放 API 鉴权、身份传递、个人访问令牌、日常模式会话、限流 / 幂等 | F053;26001~26043 分段见 `053-openapi-auth-and-identity/design.md` §6.3(26013 / 26014 已废止不复用);`26044`(PAT 数据范围受限,403)随 F066 增补(PRD v2.9 附录 C);`26060`~`26068`(任务模式开放 API,F073;跳过 `3.0-vibe` 已占用的 `26050`~`26052`);落码时按 C5 回写 `docs/constitution.md` | -| 270 | 商业授权状态聚合与上报 | F067;实现时按 C5 回写 `docs/constitution.md`;不得占用 11x(灵思)或把 Gateway 11001 当成 BISHENG 模块号 | -| —(不新增) | NVDB 漏洞修复 | F068-nvdb-security-fixes 复用 403 / 422 与既有 `ValueError` 路径,不占模块号 | -| 280 | 代码执行沙箱(执行环境可达性、容量、超时、copy-in 超限、代码节点出参序列化、协议不符) | F068-code-execution-sandbox;28001~28006,具体语义见该 Feature Design §4.2。落码时按 C5 回写 `docs/constitution.md`。已按 C5 重新派生占用列表确认 280 空闲;不得占用 11x(灵思)或 150(tool) | +| 270 | 商业授权状态聚合与上报 | F070;实现时按 C5 回写 `docs/constitution.md`;不得占用 11x(灵思)或把 Gateway 11001 当成 BISHENG 模块号 | +| —(不新增) | NVDB 漏洞修复 | F072-nvdb-security-fixes 复用 403 / 422 与既有 `ValueError` 路径,不占模块号 | +| 280 | 代码执行沙箱(执行环境可达性、容量、超时、copy-in 超限、代码节点出参序列化、协议不符) | F068-code-execution-sandbox;28001~28006,具体语义见该 Feature Design §4.2。落码时按 C5 回写 `docs/constitution.md`。已按 C5 重新派生占用列表确认 280 空闲;不得占用 11x(灵思)或 150(tool)。代码中已用到 28008;F086 沙箱接入参数校验失败从 28009 起,具体码号见 F086 Design §6 | --- @@ -226,12 +242,29 @@ | 2026-09-10 | 登记 F064 文档知识库外层列表文件解析异常:表 1 标无新领域对象;表 3 记依赖 F027/F048/F051;Alembic 仅加 `knowledgefile` 复合索引,无新增错误码/不变量 | F064、F027、F048、F051 | | 2026-09-10 | 登记 F065 模型名称首尾空格兼容:表 1 标无新领域对象;表 3 记依赖既有模型管理写入;无新增错误码/不变量/Alembic;不回填存量脏名 | F065 | | 2026-09-13 | 登记 F066 PAT 数据范围收窄与「AI 助手接入」界面(PRD v2.9 D21 / D22):表 1 新增 OpenApiTenantSetting 增量归属行(`pat_data_scope` 列 + 策略变更审计归 F066);**修订 INV-34**——数据范围收窄优先于管理员短路、「短路照常」仅默认档成立,并同步订正其与 D19 相抵的「离开租户级联失效」残句为「换租户随人迁移」;错误码 260 段补 `26044` | F066、F053 | -| 2026-09-14 | 登记 F067 商业授权统一到期提醒:新增 LicenseInfo 与 INV-35;分配错误码模块 270;Banner 数据源改为 `license_info` 聚合,覆盖 F037 的「只直连 Gateway + 软件授权」展示合同,不改 Gateway 降级范围 | F067、v2.6.0 F037 | -| 2026-09-14 | 编号冲突改号:`feat/3.0.0-beta1` 合入 beta2 后,beta2 的商业授权统一到期提醒与 beta1 的 PAT 数据范围收窄同占 F066;商业授权一侧改号 **F067**(目录、表 1 归属行、INV-35、表 3 依赖、F037 影响行、错误码 270 归属同步),F066 专指 `pat-data-scope-and-ai-access` | F067、F066 | -| 2026-09-14 | 登记 F068 NVDB 漏洞修复:表 1 标无新领域对象;新增 INV-36(登录态签名密钥不得来自代码);表 3 记依赖既有 Config / WEB_MENU / 排序参数 / HTML 解析 / 下载工具;无新增错误码、对外 API、Alembic;代码节点沙箱另立 Feature | F068-nvdb-security-fixes | +| 2026-09-14 | 登记 F070 商业授权统一到期提醒:新增 LicenseInfo 与 INV-35;分配错误码模块 270;Banner 数据源改为 `license_info` 聚合,覆盖 F037 的「只直连 Gateway + 软件授权」展示合同,不改 Gateway 降级范围 | F070、v2.6.0 F037 | +| 2026-09-14 | 编号冲突改号:`feat/3.0.0-beta1` 合入 beta2 后,beta2 的商业授权统一到期提醒与 beta1 的 PAT 数据范围收窄同占 F066;商业授权一侧改号 **F070**(目录、表 1 归属行、INV-35、表 3 依赖、F037 影响行、错误码 270 归属同步),F066 专指 `pat-data-scope-and-ai-access` | F070、F066 | +| 2026-09-14 | 登记 F072 NVDB 漏洞修复:表 1 标无新领域对象;新增 INV-36(登录态签名密钥不得来自代码);表 3 记依赖既有 Config / WEB_MENU / 排序参数 / HTML 解析 / 下载工具;无新增错误码、对外 API、Alembic;代码节点沙箱另立 Feature | F072-nvdb-security-fixes | | 2026-09-16 | 登记 F068 代码执行沙箱统一底座:表 1 标无新增领域对象(在既有 `BaseExecutor` 抽象上加第三种执行模式 + 接管工作流代码节点执行位置);分配错误码模块 **280**(28001~28006,已按 C5 重新派生占用列表确认空闲);新增系统配置段 `sandbox_conf`(发布配置中注释掉,避免旧镜像因未知顶层 key 启动失败)。选定加固容器 + compose 副本池拓扑,**不新建持 docker 的编排控制面**(留给 3.0 应用工场 F103 `runtime-manager`);不新增表 / Alembic / 对外 API / 领域对象 / 不变量 | F068-code-execution-sandbox | | 2026-09-20 | 登记 F069 灵思任务模式引用溯源可靠性(PRD《灵思任务模式引用溯源优化方案》2026-09-20 评审通过,D1~D8):表 1 标无新领域对象(会话级来源编号表 + `citation_audit` 字段);表 3 记依赖 F047 / F054 / F029;表 4 记 P2 修订 F054 AC-07 / AC-12 为烘焙措辞;无新增错误码 / 对外 API / 不变量 / Alembic;新增系统开关 `linsight.citation_handles_enabled` | F069、F047、F054 | | 2026-09-21 | F069 P0 / P1 已上 release(A/B uncited 44% → 0%);P2 导出烘焙交付:F054 AC-07 / AC-12 补任务模式报告例外措辞(导出件出现可见编号与参考资料段,来源按导出者 `view_file` 过滤,日常模式会话导出维持剥标);无新增错误码 / 对外 API / 表 | F069、F054 | +| 2026-08-31 | 登记 F053 开放 API 鉴权与身份传递(全量 P0+P1+P2,代码底座自 `3.0-vibe` 移植、需求以 PRD v2.4 为准):新增 ApiCredential / ServiceAccount / OpenApiTenantSetting 领域对象与 ShareLink、MessageSession 增量;新增 INV-29~34;分配错误码模块 260;登记对既有 `/api/v2`、分享页、`user` 表与 F048 `authorize_created` 的影响。早期曾登记独立 OpenApiCallLog,最终 F053 设计已作废该专表并复用 `audit_log` | +| 2026-09-13 | 登记 F066 PAT 数据范围收窄与「AI 助手接入」界面(PRD v2.9 D21 / D22):表 1 新增 OpenApiTenantSetting 增量归属行(`pat_data_scope` 列 + 策略变更审计归 F066);**修订 INV-34**——数据范围收窄优先于管理员短路、「短路照常」仅默认档成立,并同步订正其与 D19 相抵的「离开租户级联失效」残句为「换租户随人迁移」;错误码 260 段补 `26044` | F066、F053 | +| 2026-09-15 | 登记 0923 PRD §3 / §10:F067 以统一远程 MCP 服务交付开放业务能力,新增 INV-35;F071 新增 KnowledgeChatScope 作用域投影,将删除或跨空间移出后的知识空间问答历史保留到原空间根目录,新增 INV-36;两项均不新增错误码 | F067、F071、F053、F066、F034、F048、既有知识空间问答 | +| 2026-09-15 | 修订 F071 数据设计:取消 KnowledgeChatScope 新表,改由既有 MessageSession 的 nullable `entry_flow_id` 覆盖展示入口;原 flow 与消息链不变,会话元数据更新时间沿用既有更新机制 | F071、既有知识空间问答 | +| 2026-09-18 | 修订 F071 在线一致性:资源操作成功后按精确 flow 分片投递定向延时回收;接受短暂不可见与极低概率迟到提交,不增加分布式锁、会话创建复查、跨业务长事务或全量巡检 | F071、F034、既有知识空间问答 | +| 2026-09-18 | F071 在线回收降级为 best-effort:删除按每个 hard-delete commit、移动按 metadata commit 后触发;不建设 outbox,接受派发/执行残余失联;batch 先规范化父子输入,跨空间移动暂不处理直接 move rows 外的版本 sibling 会话 | F071、F034、既有知识空间问答 | +| 2026-09-18 | F071 纳入保留空间本身的 `clear_space`,在子资源删除 commit 后按清空前 `child_resources` 回收;完整 `delete_space` 仍不恢复;存量迁移门禁改为可恢复失联集合归零,deleted-space 分类单独报告 | F071、F030、既有知识空间问答 | +| 2026-09-18 | 明确 F071 接受完整删除空间后保留旧 session/entry 及此前排队任务写入的失效 entry;依赖空间存在性与权限校验保证不可访问,不扩大 `delete_space` 清理职责 | F071、既有知识空间问答 | +| 2026-09-15 | 按中粮 SeedMind Apifox 范围基线收缩 F067:仅保留 6 项知识资源 API 与 4 项文件 API 的 10 工具 allowlist;F053 其他开放能力和 Apifox 后续变化不自动进入本期范围 | F067、F053、F066 | +| 2026-09-18 | 清理 F053/F067 审计合同残留:删除 OpenApiCallLog 领域对象引用,统一为既有 `audit_log` 的 `open_api.call` 事件;不新增审计表、清理任务或保留期策略 | F053、F067 | +| 2026-09-18 | 订正 INV-33 的外部使用者请求头名称为代码、测试及 F053 API 合同实际使用的 `X-End-User`;仅修正文档残留,不改变 API 行为 | F053、F067 | +| 2026-09-18 | 明确 F067 单向兼容边界:现有 API 的校验、分支、调用顺序、副作用、入参、出参与错误处理全部冻结;无法与 MCP 线格式完全一致的部分只在 MCP 工具 schema/adapter 内兼容,成功与错误分别遵循 MCP structured output 和 error 规范 | F067 | +| 2026-09-21 | 统一 3.0.0 分支合并三条线后解决跨线编号冲突:F067 归统一远程 MCP 服务(beta1/923 线),F068 归代码执行沙箱(beta2 线);商业授权统一到期提醒改号 **F070**,知识空间问答历史保留改号 **F071**,NVDB 漏洞修复改号 **F072**。仅改目录名与文档引用,不改 Alembic revision id、错误码模块号与代码行为 | F067、F068、F070、F071、F072 | | 2026-09-22 | 代码节点默认关闭开关(沙箱落地前的过渡措施,对应 GitHub issue #2189):系统配置新增 `workflow.code_node_enabled`,仅字面量 `true` 视为开启;门禁置于 `CodeNode` 构造处,覆盖正式运行与 `run_once` 调试两条路径;拒绝以 `IgnoreException` 呈现为本次运行的失败原因;`/api/v1/env` 增加 `workflow.code_node_enabled` 供编排页展示禁用提示。表 4 记存量工作流受影响;无新增错误码 / 领域对象 / 表 / Alembic | F068-code-execution-sandbox | -| 2026-09-30 | 登记 F072 工作台日常模式引用改用短句柄:表 1 标无新领域对象(日常模式为 F069 编号表第二写入方,不写 `meta:enabled`);表 3 记依赖 F069 / F047 / F054 / F029;表 4 记修订 F054 AC-18(仅日常模式)与 F069 编号表共写;无开关、无新增错误码 / 对外 API / 表 / Alembic | F072、F069、F054 | +| 2026-09-30 | 登记 F075 工作台日常模式引用改用短句柄:表 1 标无新领域对象(日常模式为 F069 编号表第二写入方,不写 `meta:enabled`);表 3 记依赖 F069 / F047 / F054 / F029;表 4 记修订 F054 AC-18(仅日常模式)与 F069 编号表共写;无开关、无新增错误码 / 对外 API / 表 / Alembic | F075、F069、F054 | | 2026-09-30 | 登记 F073 任务模式开放 API(PRD《3.0 任务模式开放 API PRD》v1.4、父 PRD v2.11):表 1 记 `LinsightSessionVersion.api_meta` 增量归 F073;表 3 记依赖 F053 / F063 / F048 / 灵思执行内核;表 4 记修订 F053(`26017` 收窄、提交端点分派、`/config` 增 `run_mode`、日常模式 `26066`、`chat:invoke` 覆盖任务模式)与灵思任务模式(共享提交核心与终止,行为不变);260 段增补 `26060`~`26068`;Alembic `f073_linsight_api_meta` | F073、F053 | + +| 2026-10-09 | beta3 合并到统一 3.0.0 分支时解决编号冲突:F071 保留知识空间历史保留、F072 保留 NVDB 修复;来源对照查看改为 F074、日常引用短句柄改为 F075。仅改文档与注释引用,不改运行行为。 | F071、F072、F074、F075 | +| 2026-10-20 | 登记 F086 沙箱接入参数页面配置:表 1 标无新增领域对象(复用 `Config` 表一条部署级记录);表 3 记依赖 F068;280 段从 28009 起分配校验错误码;无新增表 / Alembic / 不变量 | F086、F068 | +| 2026-10-21 | 订正 F086 对 F068 的依赖表述:副本只认服务地址列表 `endpoints`,不再写主机名发现 | F086、F068 | diff --git a/features/v3.0.0-beta2/062-dsh-desktop-model-access/audit-guardrails-proposal/client-api-audit-extension.md b/features/v3.0.0-beta2/062-dsh-desktop-model-access/audit-guardrails-proposal/client-api-audit-extension.md index 6268f36f75..cef5060e4b 100644 --- a/features/v3.0.0-beta2/062-dsh-desktop-model-access/audit-guardrails-proposal/client-api-audit-extension.md +++ b/features/v3.0.0-beta2/062-dsh-desktop-model-access/audit-guardrails-proposal/client-api-audit-extension.md @@ -222,7 +222,7 @@ HTTP 200、SSE 正常结束和能力存在均不保证审计已持久化。客 | title_event_seq | 必填严格整数,0至9007199254740991,布尔值无效;取SessionTitleSnapshot.eventSeq或session/title的event.seq,不使用Date.now、HTTP到达顺序或进程内自增替代 | | 总体 | 仅接受上述三个键,未知键拒绝;JSON必须是对象;请求UTF-8最多4096字节,流式计数限制,不回显非法值 | -租户、用户和登录会话均从DSH凭据取得。服务端须先找到同认证租户/用户下的既有审计请求并核验精确会话绑定,才能写标题;其他用户同名会话不构成命中。METADATA、CONTENT均允许,OFF或模式读取失败/超时不更新。标题接收事务的模式生效点和存储一致性见设计§4.7.3。 +租户、用户和登录会话均从DSH凭据取得。服务端须先找到同认证租户/用户下的既有审计请求并核验精确会话绑定,才能写标题;其他用户同名会话不构成命中。METADATA、CONTENT均允许;OFF时只允许补齐本人、本租户已保存且关联有效敏感词检查事实的会话标题,不新增请求摘要。摘要尚未落库返回NOT_READY,允许现有有界重试;只有普通历史记录或检查全关闭的摘要返回DISABLED。模式读取失败/超时仍不更新。已保留检查摘要可继续维护标题,不把标题用于推断当前全局敏感词开关。标题接收事务的模式生效点和存储一致性见设计§4.7.3。 ### 8.3 结果、幂等与重试 @@ -238,7 +238,7 @@ HTTP 200、SSE 正常结束和能力存在均不保证审计已持久化。客 | UNCHANGED | 相同序号、相同标题已保存 | 幂等成功 | | STALE | 已保存序号高于候选 | 终止旧候选,不返回保存内容或对端序号 | | CONFLICT | 相同序号但标题不同,未覆盖 | 终止并记录固定冲突原因,不任意换序号重发 | -| DISABLED | 当前模式OFF或模式读取失败降级OFF,未更新 | 丢弃本候选,不记为成功同步;后续有效触发可再尝试 | +| DISABLED | 模式读取失败,或OFF且仅有不含有效检查事实的历史摘要,未更新 | 丢弃本候选,不记为成功同步;后续有效触发可再尝试 | | NOT_READY | 当前本人范围没有可绑定的审计记录,可能尚未异步落库 | 有界重试;其他身份/租户中的同名记录也返回这一结果,不披露存在性 | 输入错误沿既有DSH HTTP400/26101;凭据及席位错误沿既有DSH错误,不新增标题身份例外;服务端SQL/容量/超时复用26128及HTTP500标准错误体,客户端内部单并发忙碌返回429。错误不附标题或完整请求。未知status视为协议异常停止该候选,不误认成功。 diff --git a/features/v3.0.0-beta2/062-dsh-desktop-model-access/audit-guardrails-proposal/design.md b/features/v3.0.0-beta2/062-dsh-desktop-model-access/audit-guardrails-proposal/design.md index 3174f19815..e6815fa194 100644 --- a/features/v3.0.0-beta2/062-dsh-desktop-model-access/audit-guardrails-proposal/design.md +++ b/features/v3.0.0-beta2/062-dsh-desktop-model-access/audit-guardrails-proposal/design.md @@ -278,7 +278,7 @@ Gateway的DshRoutes.backend是两个旧过滤器的分类,不是路由白名 依照仓库模型发现规则建立完整新表,并注册租户过滤;不改请求大表、不回填原件、不增加标题检索索引。MySQL/DM8均用通用ORM、String/BigInteger和PreciseDateTime;不使用ON DUPLICATE KEY或数据库专有JSON操作。 -Service先经现有有界模式读取器读取,失败或超时按OFF,不再调度标题写入。允许时在独立SQL短事务中锁租户模式行并再次检查(无行即OFF),将锁定读取作为该标题更新的模式生效点;OFF返回DISABLED,配置变更与标题事务按同一模式行串行,不用旧缓存继续写。 +Service先经现有有界模式读取器读取,失败或超时返回DISABLED,不再调度标题写入。正常读取时在独立SQL短事务中锁租户模式行并再次检查(无行即OFF)。METADATA/CONTENT沿原绑定规则;OFF只允许已有同本人、同租户、精确会话绑定且关联可信安全来源的请求,并要求至少一阶段为PASSED/BLOCKED/ERROR。无可绑定摘要返回NOT_READY,沿客户端原有有界重试等待异步安全回传;仅有普通模型历史或全DISABLED/未观测条目返回DISABLED。不读取Gateway实时配置、不把标题请求接入模型准入,不以租户当前开关否定已接收请求的冻结检查事实。两项关闭后无新安全事实,不创建新的请求摘要或独立标题会话;已保留检查摘要的标题可继续补齐、改名,这是历史元数据维护。模式变更和标题事务仍按同一模式行串行,不放宽读失败防护。 Repository用认证tenant/user及conversation_key查已有请求,走ix_dma_request_conversation等值前缀并LIMIT 1,再核验原始conversation_id。已存在的目录也须核验绑定;目录未准备好不妨碍已存在请求的标题同步。找不到本人关联请求统一返回NOT_READY,不能据此返回其他用户或租户是否存在。标题本身不创建审计请求/目录,不改变请求模式、四轴状态、统一状态、用量或父子关系。 @@ -318,7 +318,7 @@ Platform沿用现有节点标题行:显示用户名及conversation_title,无 自审修正:标题不能只随下一次模型调用发送;不能把事实唯一存于可重建目录;同一身份重登也须轮换epoch;Gateway不需要新增分类。以上是设计要求,实际测试及未验收项单列于audit-title-validation.md,不把矩阵当作通过证据。 -实现后手动操作:在隔离租户开启METADATA,以受控DSH账号通过正式客户端发一次企业模型请求;等待对应审计可见后,在不发第二次模型请求的情况下改名,再到客户端管理/审计刷新并核对新标题、原编号及请求数量不变;关闭审计后再次改名,刷新应仍保留关闭前的标题。子Agent分别改名、退出重登、通过实际Gateway访问均另测。身份和凭据现场提供,不写入文档;HTTP载荷和固定状态见契约§8,可用同一JSON向该端点发送,不能用直接SQL写入代替上报验收。 +实现后手动操作:在隔离租户开启METADATA,以受控DSH账号通过正式客户端发一次企业模型请求;等待对应审计可见后,在不发第二次模型请求的情况下改名,再到客户端管理/审计刷新并核对新标题、原编号及请求数量不变;关闭审计且该会话没有已保存的有效敏感词检查事实后再次改名,刷新应仍保留关闭前的标题;仅开敏感词检查时,输入拦截和检查通过均应保存标题与模型名称,且对象存储、正文块和manifest保持为空。两项关闭且没有安全摘要的新会话不得因标题上报创建记录。子Agent分别改名、退出重登、通过实际Gateway访问均另测。身份和凭据现场提供,不写入文档;HTTP载荷和固定状态见契约§8,可用同一JSON向该端点发送,不能用直接SQL写入代替上报验收。 ## 5 已知坑与防护位置 diff --git a/features/v3.0.0-beta2/062-dsh-desktop-model-access/audit-guardrails-proposal/spec.md b/features/v3.0.0-beta2/062-dsh-desktop-model-access/audit-guardrails-proposal/spec.md index d2842d40da..44020b9a63 100644 --- a/features/v3.0.0-beta2/062-dsh-desktop-model-access/audit-guardrails-proposal/spec.md +++ b/features/v3.0.0-beta2/062-dsh-desktop-model-access/audit-guardrails-proposal/spec.md @@ -46,7 +46,7 @@ | D-07(兼容基线及增量范围已确认) | 老客户端兼容与异常关联字段 | 旧客户端不提供附加信息仍正常调用,开启模式下产生请求级审计并标记未关联。既有 conversation_id 与可选 request_kind 继续有效;本轮补充消息来源类型、会话来源与子 Agent 直接父会话信息,按能力协商逐项启用。ID 不限定 UUID、前缀或字符集合,不验证真实会话对应关系。新客户端只发送服务端公布且正式文档定义的字段;无效附加字段软降级,不为探测能力重试模型。消息标注异常或超限不得连带丢弃有效的会话标识及请求用途 | 不要求源消息 ID、响应后补报、完整任务树、重试归组或压缩摘要来源边;旧客户端无法可靠还原新增类型;原有通用 JSON、请求体积和模型业务参数规则不变;新字段及预算由已确认设计与新版客户端契约固定,已实现但未发布 | | D-08(已确认) | 正文去重范围 | 只在同租户、同用户、相同有效客户端会话 ID 内复用相同正文;无有效会话 ID 时仅在单次请求内复用。不同用户或不同有效会话 ID 不共享正文副本,但各次请求及内容出现事实始终独立 | 去重作用域以客户端声明的 ID 为准,不意味着服务端已核实真实对话边界;相同文本不等于同一次调用 | | D-09(开发后评估) | 异步可见性、性能与覆盖率数值 | 开发前暂不冻结数值目标,也不以 PoC 作为进入开发的门槛。开发完成后在测试环境按 §4.7 测量元数据/正文可见时效、PENDING 与候选缺口窗口、覆盖率、告警和模型调用性能,并与固定来源的现有 DSH Token 用量展示作参考对照;据实决定调参及后续验收数值 | 有界资源、写入失败不阻断模型、状态真实及权限隔离仍是一期功能要求;未评估前不宣称实时、零丢失或无性能影响 | -| D-10(已确认) | 标题采集与展示 | METADATA、CONTENT均可接收已审计会话的客户端标题,并按审计元数据权限展示;OFF或模式读取失败时不新增、更新标题,已保存标题仍可查看 | 标题可能含用户输入或业务信息,属于本次明确增加的短文本元数据,不再把METADATA解释为完全不保存任何客户端文本。2026-10-19用户明确确认本项 | +| D-10(已确认) | 标题采集与展示 | METADATA、CONTENT均可接收已审计会话的客户端标题,并按审计元数据权限展示;OFF仅允许维护本人、本租户已保留有效敏感词检查摘要的标题,模式读取失败禁止更新,已保存标题仍可查看 | 标题可能含用户输入或业务信息,属于本次明确增加的短文本元数据,不再把METADATA解释为完全不保存任何客户端文本。2026-10-19用户确认标题元数据;2026-10-28进一步确认仅开启敏感词检查时也保存标题和模型名称 | ## 2 产品目标与范围 @@ -184,7 +184,7 @@ 同一标题重复上报应幂等,较旧的变更迟到不得覆盖已保存的新标题;同一更新标识携带不同标题时不能任意覆盖。排序依据来自同一客户端会话的标题变更顺序,不以客户端墙上时钟或网络到达顺序作为唯一依据。主会话及各子Agent会话分别保存自己的标题,不互相覆盖;不同租户或不同历史用户的同名会话编号完全隔离。 -METADATA与CONTENT均可保存、展示标题,明确标题属于可能包含业务信息的短文本元数据;既有模型输入输出正文仍仅由CONTENT采集。标题接收时租户为OFF,或模式读取失败、超时时,不新增或更新标题;已有标题仍按历史审计权限可读。标题缺失、读取失败、上报失败或不支持新能力不改变模型结果、用量、正文留存状态或统一正常/异常状态。标题须有长度和请求体积上限,按纯文本处理,不解释HTML或执行脚本,服务日志不记录标题内容。 +METADATA与CONTENT均可保存、展示标题,明确标题属于可能包含业务信息的短文本元数据;既有模型输入输出正文仍仅由CONTENT采集。标题接收时租户为OFF,仅允许为本人、本租户已保留的有效敏感词检查摘要补齐或更新标题,不新增请求摘要;模式读取失败、超时时禁止更新。已有标题仍按历史审计权限可读。标题缺失、读取失败、上报失败或不支持新能力不改变模型结果、用量、正文留存状态或统一正常/异常状态。标题须有长度和请求体积上限,按纯文本处理,不解释HTML或执行脚本,服务日志不记录标题内容。 会话目录、父子节点及会话定位入口优先展示可用标题,保留查看或复制会话编号的方式;未取得标题时回退现有编号展示。展示的是最新已接收的客户端声明标题,不宣称为每条历史请求发生时的标题,也不承诺与客户端实时一致。本期不做标题搜索、全量本地历史扫描或服务端推断补齐;没有再次同步的历史会话继续显示编号。 @@ -365,7 +365,7 @@ CONTENT 模式的流式输出按统一接口实际返回顺序采集文本及其 | AC-T01 | 当受控新客户端已获标题同步能力,且关联审计和存储正常时,客户端已有的自动标题、回退标题及用户改名应可同步并在会话节点展示;不新增模型调用,不从模型正文推断标题 | | AC-T02 | 当标题在最后一次模型调用之后产生或被用户修改时,即使没有下一次模型请求,也应可异步同步;标题及其上报不进入模型业务入参,不改变流式首字或完成条件 | | AC-T03 | 当同一会话的更新重复、乱序或并发到达时,重复应幂等、旧更新不得覆盖新标题;相同更新标识但标题不同不能任意覆盖;不单靠客户端时间或到达顺序决胜 | -| AC-T04 | 当租户为METADATA或CONTENT时,标题可作为明确声明的短文本元数据保存和显示;当标题接收时为OFF或模式读取失败时,不新增或更新标题,已保存标题仍可按既有权限查看;所有模式下标题不改变模型正文留存边界 | +| AC-T04 | 当租户为METADATA或CONTENT时,标题可作为明确声明的短文本元数据保存和显示;当标题接收时为OFF,仅允许为本人、本租户已保存且存在有效敏感词检查事实的会话补齐或更新标题;没有可绑定摘要时不创建标题或请求记录,模式读取失败仍禁止标题更新。已保存标题按既有权限查看;所有模式下标题不改变模型正文留存边界 | | AC-T05 | 当普通用户同步其他用户或其他租户的会话标题、伪造身份字段、或仅上报没有关联审计记录的会话时,系统不得改写其他范围的数据或创建审计会话;管理员查看仍按原有历史归属范围执行 | | AC-T06 | 当标题事件先于异步审计记录到达时,应允许有界延后重试;网络、认证、存储故障或重试预算耗尽不得阻塞或重放模型调用;能力缺失的旧服务端及旧客户端保持原有行为 | | AC-T07 | 当客户端卸载插件、切换账号或租户、退出登录、重连时,应清理或隔离旧范围的订阅及待发送任务,不重复注册或把旧标题发到新身份下;队列、并发和重试有界,失败不记为模型调用异常 | diff --git a/features/v3.0.0-beta2/062-dsh-desktop-model-access/design.md b/features/v3.0.0-beta2/062-dsh-desktop-model-access/design.md index 96a0ba7a83..b19f100abf 100644 --- a/features/v3.0.0-beta2/062-dsh-desktop-model-access/design.md +++ b/features/v3.0.0-beta2/062-dsh-desktop-model-access/design.md @@ -9,6 +9,10 @@ **状态**:用户已确认官方链路边界、单 Nginx 入口、接口冻结规则与逐模型修订;当前客户端契约为 `0.5.0`(本次解绑不升版);逐模型额度、取消未知用量冻结及部门同步/筛选修订已实现;完整发布验收仍独立保留 **最后更新**:2026-09-11 +### 2026-10-19:桌面与独立网页语言隔离 + +桌面嵌入页采用当前 Harness 语言,独立网页继续采用自身保存的偏好。复用现有展示桥的可选 locale 字段及文档事件,只更新当前 i18next 实例;不把宿主覆盖写入网页偏好。完整契约、选择原因、刷新与旧偏好清理的边界及验证记录以 [语言隔离修订](./desktop-language-isolation.md) 为准,不改变本设计的身份、授权、席位或模型接口。 + ### 2026-09-21:免费席位与管理接口对齐 本修订覆盖下文“免费席位也必须由签名授权提供”的旧约定。Gateway 内置免费 10 席;商业 License 用于提高席位上限,未配置、无效或过期时的实际授权以 Gateway 有效快照为准,BiSheng 不自行推算或补发席位。 diff --git a/features/v3.0.0-beta2/062-dsh-desktop-model-access/desktop-language-isolation.md b/features/v3.0.0-beta2/062-dsh-desktop-model-access/desktop-language-isolation.md new file mode 100644 index 0000000000..9978cdd26a --- /dev/null +++ b/features/v3.0.0-beta2/062-dsh-desktop-model-access/desktop-language-isolation.md @@ -0,0 +1,45 @@ +# 桌面嵌入页与独立网页的语言隔离 + +用户要求:桌面以桌面语言为准,独立网页以网页语言为准;2026-10-19 确认基于 `feat/bisheng-work-120` 完成同步。 + +## 验收范围 + +- 桌面首次打开及中英文切换后,嵌入聊天、知识空间、频道、应用和默认导航文案采用当前 Harness 语言。 +- 路由切换、新页面挂载、完整重载及设置中隐藏的嵌入页继续采用桌面语言;切换语言不重新加载会话。 +- 独立网页继续使用自身保存的语言,网页语言切换、持久化及日语配置沿用已有机制。 +- 宿主覆盖不写网页的 `lang` cookie、`localStorage.lang` 或 `i18nextLng`;导航的管理员自定义名称和用户内容保留原文。 +- 拒绝不支持的宿主 locale;订阅在卸载后释放。没有原生展示桥的普通网页忽略桌面样式的参数及事件。 + +## 实现契约 + +桌面宿主插件从 `ctx.locale.getSnapshot().active` 读取语言并订阅变更,复用现有 presentation IPC 的可选 `locale: zh | en` 字段。主进程校验该字段,在 entry URL 传 `dshLocale`,并向当前隔离文档写入 `data-dsh-host-locale`、派发 `dsh:locale`。该字段只影响呈现,不改变认证、权限、插件解析或后端启动阶段;旧调用方未传 locale 时保留既有行为。传送失败记录 `locale_delivery_failed`,保留会话及原有重连入口。 + +网页同时要求桌面嵌入状态与原生 `dshChatPresentation` 展示桥。将 `zh` 映射到既有 `zh-Hans` 词典,在初始化 i18next 前读取宿主语言;嵌入状态禁用语言检测器的偏好缓存。运行中只更新当前 i18next 实例和文档语言,不修改持久化 Recoil lang atom。`useLocalize` 挂载时优先采用当前宿主覆盖,避免原网页偏好覆盖刚送达的语言。会话级宿主 locale 保存在 sessionStorage,以支持路由去掉 entry 参数后的硬刷新;它随原生隔离 session 一起释放。 + +宿主初次 entry 参数仍存在时,语言变更同步更新该参数并保留 `history.state`,避免硬刷新采用旧值。已有 `defaultLang` 会把 atom 保存的 JSON 字符串当作不合法语言清理;原生覆盖存在时跳过这段网页偏好修复,保留有效保存值。禁用日语时的 i18next 历史值清理也限定到独立网页。普通网页继续使用原机制。 + +Shell 持有唯一语言事件监听器,卸载时解绑;导航继续消费原有本地化函数并按现有上报机制更新宿主,无需复制菜单或增加状态库。宪法 C1–C8 的后端、权限、数据与存储边界保持不变;不新增网络请求。 + +## 验证 + +分别覆盖原生主进程参数校验与文档投递、宿主插件语言订阅、网页真实 i18next 初始化/切换、页面重挂载、独立网页隔离、偏好缓存及监听器清理。真实原生窗口、3006 部署、安装包及 Windows 验收须单独记录,不以源码或 DOM 测试代替。 + +### 2026-10-19 源码验证记录 + +- 桌面定向 4 文件、36 项通过;typecheck 与 build 通过。全量 220 文件通过、1 文件失败:1883 项通过、1 项跳过、Office 一项 60 秒超时;独立复跑该文件 6 项全部通过。全量结果保留失败,不以复跑改写历史。 +- 网页定向 7 文件、13 项通过,覆盖语言协议、原有导航/外观桥兼容、真实 i18next、重新挂载及清理。当前安装的可选 canvas 缺少原生二进制,使用临时 NODE_OPTIONS preload 仅让测试进程的 canvas 解析失败,以采用 jsdom 原本支持的无 canvas 模式;未改 node_modules、配置或锁文件。 +- 全仓 typecheck、改动文件 eslint、check-i18n 与 client build 通过。全仓 lint 在未修改的 platform 文件报 95 个错误、1 个警告;命令未继续到后续包,不能报告全仓通过。 +- 在临时 userData 的真实 Electron 隔离窗口中使用正式 `enterprise-chat.cjs` preload、实际语言模块、真实 i18next 与 lang atom,验证首次中文、切英文后刷新、再切中文、页面重挂载、去掉 entry 参数后刷新、保存的 web 语言保持英文。无 preload 的普通窗口保持英文并忽略宿主样式的参数/事件。测试使用临时页面,未执行完整企业认证和业务操作。 +- 3006 仅执行 GET 检查入口与脚本,确认现有导航桥存在、语言桥缺失;本次尚未部署、提交、推送或重启用户窗口。安装包及 Windows 验收仍待执行。 + +### 2026-10-19 部署与实际桌面验证 + +用户随后明确授权部署网页并更新本地桌面。Web 快进到线上已使用的 `5f601e7fd`,语言工作目录差异在快进前后完全一致,构建包含已发布的技能页修复。 + +3006 于北京时间 14:19 发布 `desktop-locale-20261019-r1`,保留原 release,克隆其管理后台与其他输入后覆盖本次 client 源码和构建;475 项 SHA-256 核验通过。原子切换 current,仅重建 frontend;管理后台首页指纹、其他容器 ID 保持。网页与 API 返回 200,实际语言 chunk `locales.DAWDuCJ1.js` 返回 JavaScript 且指纹与构建一致。 + +本地正常退出旧进程,用原 userData 启动桌面并恢复企业登录。通过可见 UI 验证应用页中文、知识空间中文、中文→英文实时切换及英文→中文恢复;知识空间标题、创建按钮、三组分区与侧栏均同步。最终停留在中文知识空间,保留现有内容;未发送测试消息。本次验证为开发应用,正式安装包、Windows、完整各业务功能仍独立验收。发布元数据与校验日志在桌面项目 `outputs/desktop-locale-20261019/`。 + +### 2026-10-19 最新桌面基线复验 + +桌面快进到 `V1.2.0@feee3b5` 并保留语言、主题和加载超时改动后,重新执行 npm ci、typecheck、build、完整 npm test 与 diff-check 全部通过:221 文件、1885 项通过、1 项跳过。真实开发窗口确认原生新会话、插件、更多入口及中文知识空间。Web 基线仍为 `5f601e7fd`,本地语言改动保持;未重新部署。 diff --git a/features/v3.0.0-beta2/062-dsh-desktop-model-access/desktop-language-review.md b/features/v3.0.0-beta2/062-dsh-desktop-model-access/desktop-language-review.md new file mode 100644 index 0000000000..a15f1c313b --- /dev/null +++ b/features/v3.0.0-beta2/062-dsh-desktop-model-access/desktop-language-review.md @@ -0,0 +1,34 @@ +# Code Review Report + +**Feature**: 桌面嵌入页与独立网页语言隔离 +**Review scope**: 本次未提交的语言改动及新增文件;桌面原有主题与超时 WIP 保留,未归入本次实现 +**Base branch**: `feat/bisheng-work-120`(源码验证基线 `bc05600f1`;部署前快进到线上已用的 `5f601e7fd`,保留既有技能页修复,语言 WIP 完全一致) +**Changed files**: Web 14 个(含本报告与设计修订);桌面语言实现、回归及文档另行核对 + +## Summary + +| Dimension | High | Medium | Low | Status | +|---|---|---|---|---| +| Boundary Conditions | 0 | 0 | 0 | PASS | +| Permission & Auth | 0 | 0 | 0 | PASS | +| Concurrency Safety | 0 | 0 | 0 | PASS | +| Information Leakage | 0 | 0 | 0 | PASS | +| Test Coverage | 0 | 1 | 0 | PASS_WITH_WARNINGS | +| Code Style | 0 | 0 | 0 | PASS | +| Docs Sync (design.md) | 0 | 0 | 0 | PASS | + +## Findings + +### MEDIUM + +- 3006 已发布,当前开发应用已恢复企业身份并通过知识空间与应用页的真实中文界面、知识空间实时中英切换验证;完整各业务功能、Windows 及正式安装包仍待验收。网页全仓 lint 的既有失败保留;桌面合入最新导航后已完整重跑,221 文件、1885 项通过、1 项跳过,早期 Office 超时仍保留在历史记录。 + +## Review notes + +- locale 仅接受 zh/en;无原生展示桥的普通网页忽略覆盖,原有认证、来源、主 frame 与身份代际校验保持。 +- 首次参数、后续事件与 sessionStorage 的归属清晰;保留 history.state,支持刷新及页面重新挂载。外部语言状态不修改冻结的 atom,也不增加全局状态库。 +- Shell 与宿主 locale 订阅均有清理;语言更新不重新加载会话,异步失败有日志出口。 +- 未新增密钥、网络 API、依赖或锁文件;管理员自定义名称与用户内容保持原文。 +- design.md 已链接语言契约修订,已记录旧偏好清理与过时 entry 参数两项易复发问题。 + +## Overall: PASS_WITH_WARNINGS diff --git a/features/v3.0.0-beta2/068-realtime-workbench-asr/design.md b/features/v3.0.0-beta2/068-realtime-workbench-asr/design.md new file mode 100644 index 0000000000..8581007437 --- /dev/null +++ b/features/v3.0.0-beta2/068-realtime-workbench-asr/design.md @@ -0,0 +1,46 @@ +# 实现设计 + +历史开发基线:3007 源码 225995a246a64985ab0613e46c6f5680b9d5f5ab;F068@54d0719e 仅作已审阅参考。遵循 docs/constitution.md 的分层、租户、限流及凭据规则。 + +1. 用户要求界面简化。一个 ASR 配置和一个语音按钮,已登记的实时型号自动使用 WebSocket。暂停立即停止采集,内部完成协议收尾;期间普通麦克风暂时禁用,收尾后可继续下一次录音。发送动作在该次识别完成后开放,保留手动发送。 +2. Paraformer 与 Qwen 使用独立协议映射,统一输出带分段 ID 的全文片段事件。供应商识别完成标志先于资源关闭;发送完成指令后继续读最终结果。旧实现先等待结果再发停止,会丢失最后一句。 +3. 流式模型同样提供文件转写桥接:沿用 BaseASRClient 的 ffmpeg 标准化,按实时协议发送 PCM 并收集最终文字。这使原检测、知识库文件识别及旧 POST 仍可使用同一配置,避免双配置的迁移成本。检测要求有效最终文本;正常用户无语音得到空字符串。 +4. 模型能力精确登记;模型名称含 realtime 不能推导协议。16 kHz 型号通过 capabilities.py 登记:阿里 paraformer-realtime-v2/v1、qwen3-asr-flash-realtime;火山 bigmodel;百度 1537/15372/15376/1737/17372;腾讯官方列出的 16k 引擎。供应商地址从现有 Base URL 解析,北京旧域名/业务空间域名及 Qwen 海外地域按配置使用。 +5. 两个前端共用纯 TypeScript 音频和会话模块,保留各自请求包装及组件。每次开始绑定独立插入范围;中间结果替换该范围,最终结果幂等提交。手工编辑范围外的内容平移锚点;编辑范围内的内容交还用户并停止录音。 +6. 3007 HTTP 内网入口受浏览器麦克风安全上下文约束;部署健康与协议测试不能代替真实浏览器及真实模型验证。 +7. 两个前端统一通过 `getAsrWebSocketUrl` 构造同源识别地址,先规范化部署前缀,再连接 API 路径,并保留访问端口。Client 的实际 `BASE_URL` 为 `/workspace`,直接拼接 `api/...` 曾产生 `/workspaceapi/...`,被页面路由接收并使 WebSocket 握手失败;回归覆盖前缀有无尾斜杠、平台根路径及 HTTPS。 + +协议参考: +- https://help.aliyun.com/zh/model-studio/websocket-for-paraformer-real-time-service +- https://help.aliyun.com/zh/model-studio/qwen-asr-realtime-client-events +- https://help.aliyun.com/zh/model-studio/qwen-asr-realtime-server-events + +## 多厂商适配 + +- 保留现有 qwen、volcengine、qianfan、tencent 服务商类型。语音凭据使用 server.config 的 speech_* 字段,LLM 与 ASR 分别读取各自凭据。数据库结构保持现状。 +- 连接对象分别实现供应商的 start/send_audio/finish/events,统一复用流式收尾、文件桥接和取消清理。工作台接口继续输出 partial/final/completed。 +- 火山:bigmodel_async 双向流式端点;实现大端长度、gzip、序号和终帧标记。请求 result_type=full,整段累计文本绑定同一 segment_id,允许服务商修订此前文本。以最终服务器帧结束,解析器校验长度和解压上限。 +- 火山新控制台使用 speech_api_key,旧控制台选择 app_key 模式后使用 speech_app_id/speech_access_key;speech_resource_id 对应已开通的 bigasr 或 seedasr 时长/并发资源。 +- 百度:使用语音应用 speech_app_id/speech_api_key,发送 START、PCM、FINISH;按 sn 区分句子并去重。FINISH 后收到最终句子及正常 1000 关闭才成功,异常关闭和遗留中间句明确报错。15376 自动附带 user 参数。 +- 腾讯:speech_app_id/speech_secret_id/speech_secret_key 生成 HMAC-SHA1 签名,签名前原文排序,签名后 URL 编码。先校验握手,按 index/slice_type 合并句子,收到 final=1 后完成。每个连接独立 voice_id,并按音频时长控制发送节奏,消化浏览器握手期间积压的数据。 +- 新语音密钥纳入 JsonFieldMasker 的脱敏与编辑回填规则。配置页仅含 ASR 时展示语音字段,同时存在其他类型时分别展示通用字段和语音字段。火山/腾讯只增加 ASR 能力,TTS 入口沿用已有范围。 +- 模型配置组件拆分到 600 行以内,原中文文案提取到三语资源;高级参数模板仅作编辑提示,打开对话框保持已保存参数,用户保存时提交。 + +官方协议依据(2026-09-20 查阅): +- 火山:https://docs.volcengine.com/docs/DoubaoVoice/LargemodelstreamingautomaticspeechrecognitionAPI?lang=zh +- 百度:https://ai.baidu.com/ai-doc/SPEECH/jlbxejt2i +- 腾讯:https://cloud.tencent.com/document/product/1093/48982 + +实际账号的权限、额度和识别质量需要分别验证。当前接入边界是以上原生协议和登记的型号。 + + +## 2026-10-15 合入当前 3.0 + +目标基线为 `upstream/feat/3.0.0@fbb9bc60651d4b60d5b40eda8f538be207958cc5`。从 3007 分支选取 `2bd865d69`(流式及连续输入)、`b3a0e3e07`(WebSocket 地址)、`044bbd241`(三家厂商扩展),按当前接口整合。 + +- 四家流式适配实现当前 `BaseASRClient.transcribe_file()` 合同,返回 `ASRTranscript`,保留句子文本并沿用公共文本拼接规则。当前统一事件提供文本分段,时间字段保持空值。文件转写覆写型号时创建独立客户端并保持原有地域和凭据,已绑定的客户端保持原配置。 +- 公共 ASR 厂商能力判断与客户端工厂使用同一映射,知识媒体文件转写可识别新增厂商。原 OpenAI 代理规范化保持当前行为。 +- Client 通过现有 `useVoiceTarget` / `transcribeVoice` 路由文件转写。发布应用访客沿用带 `flow_id` 的 v3 接口;登录工作台按模型能力选择 v1 实时或文件接口。实时接口沿用登录依赖、租户中间件、配置绑定与限流。 +- 模型配置保留当前 `ModelItem` 拆分、型号去空格、高级参数和服务商默认字段。新增独立语音配置区;仅 ASR 的厂商校验语音凭据,混合配置同时校验通用字段。 +- 输入框保留当前附件类型、视觉模型与上传状态校验。语音识别期间先完成本次识别,再开放手动发送。 +- 数据库结构和既有配置继续兼容。代码回退可恢复原语音入口,新增的 `speech_*` 配置字段可保留在服务商配置中。 diff --git a/features/v3.0.0-beta2/068-realtime-workbench-asr/spec.md b/features/v3.0.0-beta2/068-realtime-workbench-asr/spec.md new file mode 100644 index 0000000000..6119dfb813 --- /dev/null +++ b/features/v3.0.0-beta2/068-realtime-workbench-asr/spec.md @@ -0,0 +1,24 @@ +# 流式 ASR 模型接入与连续语音输入 + +确认依据:2026-09-20 用户在本任务明确要求按精简交互开发并部署 119:3007,覆盖此前飞书草案的状态设计。已确认 PRD: https://dataelem.feishu.cn/wiki/Vx80wZZgzibNuQkHKCOcBxNJnsp#doxcnIZhVIG7iS7CIkNdcQooc1e (2026-09-20,revision 183,多厂商扩展 v0.3)。 + +- AC-01:模型管理允许配置阿里 Paraformer、Qwen,以及火山引擎、百度智能云语音、腾讯云实时 ASR;检测发送样音并获得最终识别文本。 +- AC-02:沿用系统模型配置中的 ASR 选择,按适配能力决定调用方式。 +- AC-03:工作台及知识空间保留一个语音入口,输入框有文字和附件时仍可开始录音。 +- AC-04:点击开始后录音;点击暂停立即关麦、恢复普通按钮,后台收尾接收最后文字。 +- AC-05:非流式暂停后提交整段音频,流式在说话期间持续更新文字。每次录音在已有文字上继续输入。 +- AC-06:输入框保持已有内容。用户修改识别范围时以用户输入为准,结束该次录音;最终文字和中间文字按段替换,重复事件保持幂等。 +- AC-07:保持当前代码的 10 分钟上限。界面只表达开始/暂停;空识别结果保留原输入。 +- AC-08:卸载或取消及时释放资源,迟到回调保持隔离。基本调用错误沿用提示,自动重连、无语音专门判断和连接/收尾状态展示属于后续范围。 +- AC-09:在当前 3007 版本上部署,保留技能中心及其他功能;各厂商真实模型效果由用户配置后验证。 + +2026-09-20 追加确认:用户要求继续支持火山、百度千帆及其他主流厂商接口。沿用现有 ASR 选择和工作台交互,新增火山豆包语音、百度智能云语音、腾讯云语音三种原生协议。 + +- AC-10:每家厂商分别配置其语音服务凭据。仅配置 ASR 时只校验语音凭据;同时配置 LLM 时分别校验两组凭据,编辑保留已有配置。 +- AC-11:新增型号通过精确能力登记切换为实时输入;文件识别和模型检测使用同一适配器完成最终文本收集。 +- AC-12:语音密钥在配置响应中脱敏;保留脱敏值再次保存时恢复原密钥。 + + +## 2026-10-15 交付范围 + +用户已确认阿里云语音效果,并要求将完整语音功能提交 PR 到 3.0 分支。目标为 `dataelement/bisheng:feat/3.0.0`,包含上述连续语音输入及四家厂商协议。沿用 3.0 已发布应用的访客鉴权与文件转写路径;实时 WebSocket 入口服务于登录后的工作台及知识空间。PR 的实现、历史 3007 发布和各厂商真实账号验收分别记录。 diff --git a/features/v3.0.0-beta2/068-realtime-workbench-asr/tasks.md b/features/v3.0.0-beta2/068-realtime-workbench-asr/tasks.md new file mode 100644 index 0000000000..65bf4c13b7 --- /dev/null +++ b/features/v3.0.0-beta2/068-realtime-workbench-asr/tasks.md @@ -0,0 +1,33 @@ +# 实施与交付记录 + +## 已完成的实现 + +- [x] 按 2026-09-20 确认的 PRD 实现单一 ASR 选择、开始/暂停、连续输入与已有 10 分钟限制。 +- [x] 接入阿里 Paraformer/Qwen、火山豆包语音、百度智能云语音、腾讯云实时 ASR。 +- [x] 完成精确模型能力登记、文件转写桥接、模型检测、凭据脱敏和独立语音配置。 +- [x] 修复部署路径前缀导致的 WebSocket 地址错误。 +- [x] 2026-09-20 发布 3007,固定历史源码 `044bbd2411c5e75b1509ca9b8d5831cd8edb3a9a`。当日发布记录包含源码与构建哈希、服务健康、未登录 WebSocket 拒绝及邻接环境检查。 +- [x] 2026-10-15 基于官方 `feat/3.0.0@fbb9bc606` 整合完整语音功能,适配当前 ASR 公共接口、模型配置页及发布应用语音路由。 +- [x] 完成本次确定性测试与生产构建,记录基线差异和验证边界。 + +## 2026-10-15 验证 + +| 检查 | 结果 | +| --- | --- | +| 后端相关回归 | 111 项通过;覆盖四家协议、最后一句、鉴权参数、取消/超时、文件转写合同和型号覆写、配置/共享/原 ASR 客户端兼容 | +| Platform 语音回归 | 24 项通过;覆盖会话、输入草稿、地址与实际配置表单 | +| Client 语音与发布应用回归 | 14 项通过;覆盖 v1/v3 路由、访客文件转写、工作台流式选择及既有 TTS | +| Client、Platform 类型检查 | 全部通过 | +| Client、Platform 生产构建 | 通过 | +| 本次变更文件及公共语音模块 ESLint | 通过;失效抑制项由 ESLint 自动清理 | +| 全量 ESLint | Client 21 处、Platform 93 处存量错误,与同一基线、同一依赖下的结果逐项一致 | +| 共享 UI、文件预览包检查 | ESLint 通过,文件预览包类型检查通过 | +| Ruff / 国际化 / 架构 | Ruff 与国际化通过;架构检查保留 `api/v1/schemas.py` 既有跨层导入提示,本次依赖关系检查通过 | + +后端测试使用 Python 3.11 和仓库服务替身;对测试导入链中的 DOCX/Pandoc 模块使用严格替身,ASR 测试若调用该模块会立即失败。前端使用 pnpm 9.15.9;本地可选 canvas 原生依赖安装失败,移除其失效安装链接后使用仓库已有 jsdom/canvas mock 运行测试。依赖清单和锁文件保持原版本。 + +## 真实环境边界 + +- 用户此前反馈阿里云语音输入效果可用;该反馈对应历史 3007 版本。 +- 火山、百度、腾讯的真实账号权限、额度、录音效果、准确率与延迟:NOT_RUN,待分别配置后验证。 +- 本次 3.0 候选版本的原生浏览器麦克风及部署验证:NOT_RUN。此次交付为用户已授权的 GitHub PR,历史 3007 发布记录与候选版本分别追踪。 diff --git a/features/v3.0.0-beta3/076-personal-skill-center/design.md b/features/v3.0.0-beta3/076-personal-skill-center/design.md new file mode 100644 index 0000000000..04504ab35a --- /dev/null +++ b/features/v3.0.0-beta3/076-personal-skill-center/design.md @@ -0,0 +1,224 @@ +# Design: 个人技能上传与技能中心 + +> **本文档定位 — 现状快照(Why this How)** +> +> spec 回答做什么。本文回答为什么这么实现。原先待确认的配额、同名再上传、运行中删除和临时文件都已定案。 + +**关联**: [spec.md](./spec.md) +**版本**: v3.0.0-beta3 +**最后更新**: 2026-10-19 + +--- + +## 1. 目标与非目标 + +- **目标**:让用户把已有技能包存进服务端,技能中心统一展示「分给我的」和「我自己的」。每次任务把任务候选(已启用的企业技能和个人技能)整包拷进会话;勾选只在提示词里标明「用户选中了这个技能」。 +- **非目标**:Kimi 创建、Office 转技能、个人技能分享或公开、浏览器本地预览库。平台管理员维护租户技能的现有入口保持原样,本需求不收紧那条路径的体积上限。 + +--- + +## 2. 关键约束 + +- 遵循 `docs/constitution.md` C1–C7。个人技能的写入只经过灵思技能服务;接口层不做仓库访问。 +- 技能包今天整包进内存再写入对象存储(`SkillStore`)。个人上传必须用需求确认过的更紧上限:上传 50 MiB、解压 200 MiB、`SKILL.md` 1 MiB、条目 1000。这组数字小于现网租户技能的默认上限(上传 200 MiB、解压 500 MiB),两套上限并存。 +- 现表唯一约束是 `(tenant_id, name)`。个人技能若仍用技能名当全局键,会和平台技能、其他用户的个人技能撞车。 +- 任务启动时按内容哈希把每个候选的整包拷进会话。勾选只在提示词里标明「用户选中了这个技能」,未勾选的同样拷入整包。会话版本记下每个目录对应的技能 ID、归属用户和内容哈希,供后续读取和执行判断这个目录属于哪一行。已经放进会话目录的文件留到该任务结束;这份会话副本不依赖技能对象,删除和覆盖时直接回收旧对象。 + +--- + +## 3. 方案对比与选定 + +### 决策 1:个人技能挂在 `linsight_skill`,用归属用户区分 + +- **备选**: + - A. 同一张 `linsight_skill`,增加 `owner_user_id`。`0` 表示租户技能(平台分配、内置、管理员上传),非 0 表示该用户的个人技能 + - B. 新建个人技能表,执行路径再合并 +- **选定**:A +- **原因**:启用开关、对象路径、内容哈希、会话物化已经全走这张表和 `SkillStore`。第二张表会让自动匹配和物化各写一套。`owner_user_id` 不用 NULL:MySQL 唯一索引里 NULL 互不相等,平台技能的重名挡不住 +- **何时该重新考虑**:个人技能要独立审核流、独立计费,或字段已经和租户技能分叉到无法共表 + +### 决策 2:唯一键改成「租户 + 归属用户 + 技能名」 + +- **备选**: + - A. 保持 `(tenant_id, name)`,个人技能自动加用户前缀 + - B. 唯一键改为 `(tenant_id, owner_user_id, name)`,对外和运行时用技能 id +- **选定**:B +- **原因**:前缀会改写用户包里的技能名,和「导入已有技能文件」冲突。技能标识相同仍在「我的技能」的两个分组里都列出,AC-12 要求标来源。「技能广场」只保留企业技能。展示名相同而标识不同,按新技能保存。技能 id 已经是主键 +- **何时该重新考虑**:产品要求个人技能与平台技能在自动匹配时按展示名合并成一个 + +### 决策 3:服务端出两份列表,共用归属条件,不由前端拼接 + +- **备选**: + - A. 前端分别拉「平台技能」和「我的技能」再合并 + - B. 技能中心和任务候选用同一份列表 + - C. 服务端提供两个读取方法,共用同一套归属条件:本租户 `owner_user_id = 0` 的企业技能,加上本租户 `owner_user_id = 当前用户` 的个人技能。技能中心列已启用的企业技能和该用户全部个人技能(含停用,并返回启停状态)。任务候选只取已启用的企业技能和已启用的个人技能 +- **选定**:C +- **原因**:AC-06 和 AC-09 要求停用的个人技能仍留在「我的技能」里可以再打开,而 AC-11 要求停用的不进入新任务。用一份列表必然有一边做错。归属条件只写一处,两边就不会在「谁的技能」上分叉。平台技能的使用范围是租户:已启用则本租户每个登录用户都能用,没有按人的使用授权 +- **调用方**:技能中心调用技能中心列表。输入框菜单、任务装载、开放接口的任务模式调用任务候选 +- **何时该重新考虑**:候选集大到单次查询不可接受。当前量级是每人可管理的技能,不是全库检索 + +### 决策 4:同名再次上传先提示,确认后再覆盖 + +- **备选**: + - A. 同一用户同一技能名再次上传直接覆盖,不提示 + - B. 再次上传直接拒绝,只能从详情里更新 + - C. 先提示该技能已存在;用户确认覆盖后,用新文件替换,保留原 ID 和启停状态 +- **选定**:C +- **原因**:静默覆盖会让用户分不清旧技能是不是被换掉。先提示再覆盖,和「上传时发现重复」是同一次操作,不必先失败再去详情里找更新。未确认不写文件。 +- **何时该重新考虑**:产品要求再次上传直接生成另一个技能,或禁止覆盖 + +### 决策 5:个人技能的单条操作先核对归属,再走统一授权 + +- **备选**: + - A. 详情、更新、删除只判断 `owner_user_id` 是不是当前用户,不调用 `require_business_action` + - B. 先调用 `require_business_action`,拒绝再返回无权限 + - C. 先查出当前租户的这一行。没有这条,或归属不是本人,返回 11053,不调用统一授权。归属是本人,再调用 `require_business_action` +- **选定**:C +- **原因**:C4 要求具体资源先由业务核对,再交给统一授权。先问授权时,拒绝是 11054,调用方因此知道这条技能存在。创建个人技能时写下的所有者就是上传者本人,归属核对通过后用的就是这条记录。企业技能不能走这条:那条所有者是当时的管理员,拿它当使用权限会把同租户的其他人全部挡住 +- **何时该重新考虑**:个人技能要按人或按部门再授权给所有者以外的人 + +### 决策 6:技能中心沿用现在的页面 + +- **备选**: + - A. 按需求原文做成一个「我的技能」列表,企业技能和个人技能排在一起,卡片上不显示来源,只有一个总数 + - B. 沿用现在已合入的页面:页签「我的技能」和「技能广场」 +- **选定**:B。这是讨论后的最终页面,不再按需求原文改回一个列表 +- **页面**: + - 「我的技能」是一个列表,分成「我创建的」和「企业提供」。个人技能可以启停,更新和删除在详情里;企业技能始终启用。每一行是名称、来源和一行用途说明,点击整行打开详情。可以搜索,并按最近更新或名称排序。页签上的数量是个人技能个数 + - 「技能广场」只放本租户已启用的企业技能,只读卡片,点击卡片打开详情。页签上的数量是企业技能个数 + - 搜索只筛当前页签里的名称、技能标识和用途说明。切换页签时清空搜索词 + - 标识相同的个人技能和企业技能,在「我的技能」的两个分组里都出现;「技能广场」只有企业技能那一张 +- **何时该重新考虑**:产品要求拿掉「技能广场」,或要求卡片上不再显示来源 + +--- + +## 4. 系统现状(接手必读) + +本节描述 **2026-10-14 这条开发线上的代码**。个人归属、技能中心页面、服务端个人库都不在这条线上。需求文档里提到的前端预览把文件放在浏览器,那不是本方案要交付的行为。 + +### 4.1 数据流 + +1. 管理员或内置播种写入 `linsight_skill`(`source` 为 `manual` / `builtin` / `sop_migrated`),包在对象存储前缀 `linsight/skills`,键里带内容哈希。 +2. 用户在任务里打开选择器,客户端请求 `GET /api/v1/linsight/skill/selectable`。 +3. 任务启动时 `materialize_session_skills` 按哈希把包落到该次会话的本地目录。工作区权威副本仍在对象存储,本地只是缓存和执行目录。 + +个人技能接通后,步骤 1 增加「当前用户上传」,个人技能包写入独立前缀 `linsight/personal-skills`,不写入 `linsight/skills`。步骤 2 的可选列表改为决策 3 的任务候选。步骤 3 要改:每个候选在任务开始时把整包拷进 `/skills/{名字}/`,名称和用途说明进入提示词。同一用户的个人技能与企业技能同名时只拷个人技能的整包。勾中的技能在提示词里标明「用户选中了这个技能」,未勾选的不加这句。装载时把每个目录对应的技能名、技能 ID、归属用户和内容哈希记到该次会话版本。 + +### 4.2 模块职责 + +| 模块 | 今天 | 本需求 | +|---|---|---| +| `linsight/domain/models/linsight_skill.py` | 租户级元数据,唯一 `(tenant_id, name)`,`created_by` 只是创建人 | 增加 `owner_user_id`,改唯一键。`created_by` 仍是创建人,不拿来当归属 | +| `linsight/domain/services/skill_store.py` | 校验包、写入对象、体积上限走系统配置;对象键在 `linsight/skills` | 个人上传走固定的 50 / 200 MiB 等上限,不读那套更大的租户默认值。个人技能改写独立前缀 `linsight/personal-skills` | +| `linsight/api/endpoints/skill.py` | 租户技能的上传与可选列表 | 个人技能的上传、列表、启停、删除走同一服务,按归属用户鉴权 | +| 客户端任务选择器 `SkillSelector.tsx` | 只拉可选技能;点一行勾选本次任务 | 技能中心是单独页面:卡片、搜索、上传,点卡片进详情。输入框菜单继续勾选,改为读取任务候选,不跳转详情。技能中心读技能中心列表,含停用的个人技能 | + +### 4.3 数据模型 + +在 `linsight_skill` 增加: + +| 列 | 含义 | +|---|---| +| `owner_user_id` | `0` 租户技能;否则为个人技能所属用户。非空,默认 `0` | +| `last_import_id` | 最近一次个人技能导入标识,可空。企业技能保持空。同一用户的非空值唯一 | + +唯一约束从 `uq_linsight_skill_tenant_name (tenant_id, name)` 改为 `(tenant_id, owner_user_id, name)`。迁移要先确认现网没有同租户同名重复行(按现约束不应有),再把存量行的 `owner_user_id` 填 `0`。 + +租户技能继续放在 `linsight/skills/{租户}/{名字}/{内容哈希}.zip`,存量对象不搬。删除按 `linsight/skills/{租户}/{名字}/` 前缀进行。 + +个人技能放到独立前缀: + +```text +linsight/personal-skills/{租户}/{用户}/{名字}/{内容哈希}.zip +``` + +个人技能若仍放在 `linsight/skills/{租户}/{名字}/` 下,用户删掉自己的技能会连同同名的企业技能一起删掉。也不能在原前缀下加一层 `u{用户}`:`u123` 本身是合法技能名,会和同名技能的前缀撞上。`SkillStore` 的对象键、本地缓存、读取和删除按归属选择对应前缀。 + +--- + +## 5. 已知坑 + +- **`created_by` 不是归属。** 管理员替租户上传的技能 `created_by` 是管理员,但所有有权用户都能用。个人技能必须看 `owner_user_id`,不能用创建人过滤。 +- **租户过滤默认会带上根租户。** `LinsightSkillDao` 的查询已经用 `strict_tenant_filter()` 收成当前租户。个人技能查询必须留在这个过滤里,再加归属条件,否则子租户会看到根租户技能。 +- **平台技能的使用范围是租户,不是个人。** 已启用且 `owner_user_id = 0` 的技能对本租户每个登录用户可用;停用后谁都不能用。创建时写入的 OpenFGA 所有者不参与企业技能的使用校验,那条记录上的人是当时的管理员。按人或按部门分配另行立项。技能入口和默认勾选不是授权。 +- **个人技能的单条操作分两步。** 详情、读取文件、更新、启停和删除先查当前租户的这一行。没有这条,或 `owner_user_id` 不是当前用户,返回 11053,不调用 `require_business_action`。归属是本人之后再调用,资源类型仍是 `linsight_skill`,资源编号是技能 ID。更新和启停的动作是 `edit`,删除的动作是 `delete`,详情和读取文件也用 `edit`:模型里没有单独的查看动作,所有者对这条记录的可访问用同一个动作表达。现有模型的 `edit`、`delete` 还没有列入 `linsight_skill`,实现时补进这两种动作的资源范围,不新建资源类型。列表和任务候选仍是一次查询,不逐条调用。企业技能的使用不进入这次调用。 +- **运行中删除不拒绝。** 技能记录立即从候选和技能中心消失。已经返回给模型的文件内容留在当前步骤。会话目录里的副本留到该任务结束,不靠删这些文件来停用。下一步 `read_file` 读 `/skills/{名字}/` 之前,以及代码执行器执行之前,再查一次:已停用或已删除就拒绝这次读取或执行。本地代码执行直接打开会话目录,只拦 `read_file` 挡不住。同一次会话的 “Available Skills” 写在检查点的 `skills_metadata` 里,已有这份名单就不再扫描磁盘,恢复和继续提问不会把停用的技能从提示词里拿掉;停用靠上面两次拒绝生效。新任务不再拷入该技能。正在跑的任务读的是会话自己的副本,不再回到技能对象,所以删除时在本次请求里直接删掉对象,覆盖时删掉这一行不再指向的旧对象。 +- **取消或失败的导入不留文件。** 校验在内存中完成,确认覆盖且校验通过后才写入对象存储。请求结束时删除本次解压临时目录。崩溃留下的、没有技能记录的对象由后台清扫删除,不面向用户承诺保留时间。 +- **解压条目上限和路径穿越**沿用 `SkillStore` 已有校验。个人路径只是加更紧的体积和条目上限,不另写一套解压。 +- **个人技能配额已定。** 每个用户在当前租户最多 20 个个人技能,上传文件合计最多 200 MiB。停用的仍占用额度,企业技能不计入。超出时拒绝新增,不删除已有技能。当前没有服务端存量。上传前展示已用个数和已用容量。 + +--- + +## 6. 对外契约 + +- 读取方法有两个,共用归属条件,见决策 3。技能中心列表含停用的个人技能;任务候选只含已启用的。输入框菜单、任务装载、开放接口的任务模式都读任务候选。每个候选在任务开始时整包拷进会话,不另调模型生成名单。用户勾中的技能在提示词里标明「用户选中了这个技能」;未勾选的同样拷入整包,但不加这句。同一用户的个人技能与企业技能同名时,只拷个人技能的整包。企业技能记录保留。 +- 错误码沿用技能段:校验失败 11051、单文件过大 11052、不存在 11053、无权限 11054、同范围重名 11055、解压过大 11059。本需求追加并写入三语 `api_errors`:11061 个人技能个数超出,11062 合计容量超出,11063 压缩包已加密,11064 版本过期,11065 文件类型与内容不一致,11066 同一次导入的文件不一致,11067 压缩包损坏,11068 缺少 `SKILL.md`,11069 路径越界,11071 文件为 0 字节,11072 压缩包内含多份技能。11068 的中文是「压缩包中缺少 SKILL.md,请检查后重新上传。」11060 和 11070 已有指导手册检索的三语文案,不改写、不挪作本需求。技能段延伸到 11079,11080 已是工作台工具初始化失败。这些原因不共用 11051,否则文件区只会显示通用校验失败。体积四条限制分开报:上传超过上限用 11052,解压后超过上限用 11059,`SKILL.md` 超过 1 MiB 用 11073,压缩包内文件超过 1000 个用 11074。11052 和 11059 由企业上传、GitHub 导入和个人上传共用,上限不同,而前台有译文时不显示 `status_message`,所以具体数值放进响应的 `data`,由译文代入:11052 改为「技能文件超过 {{limit_mb}} MiB,请精简后重新上传」,11059 改为「技能包解压后超过 {{limit_mb}} MiB,请精简后重试」。企业路径传系统配置里的上限,个人路径传 50 和 200。11073 和 11074 同样带上限值。上传接口、`_parse_upload`、GitHub 导入里每一处抛这两个错误码的地方都要带 `limit_mb`,并和三语文案在同一次改动里提交;漏掉一处,页面会原样显示 `{{limit_mb}}`。访问他人的个人技能按不存在返回 11053,不用 11054,且不先调用统一授权。11054 表示调用方已知资源存在但没有管理权限;用在他人的个人技能上会暴露这条技能存在。归属是本人的详情、更新和删除再调用 `require_business_action`,见决策 5。用户能看到的句子以 `api_errors` 为准,同一错误码不靠 `status_message` 再写另一句中文。 +- 租户技能管理接口的请求和响应不增加必填字段。`owner_user_id` 对旧客户端不可见;旧的按名更新仍只命中 `owner_user_id = 0` 的行。 +- 历史行全部视为租户技能(`owner_user_id = 0`)。没有个人技能可迁移。`last_import_id` 留空。 + +--- + +## 7. 需求中其余行为的做法 + +本节补上需求里已经写明、前面各节还没落地的行为。用户可见中文以需求原文为准,并写入三语包。 + +### 7.1 审计记在现有审计表 + +- **备选**:新建技能审计表;或只打应用日志。 +- **选定**:写入现有 `audit_log`。个人技能的新增、覆盖更新、启停发生实际变化、删除各记一条。`system_id` 用 `linsight`,这样租户管理员在现有「系统操作」里能看到。不为此单开页面。 +- **原因**:需求要的是操作者、租户、技能 ID、来源、版本和结果。这些列审计表已经有:操作者、`tenant_id`、`target_id`。来源、内容哈希和结果放 `audit_metadata`,只这三项。不写技能正文、用途说明、文件字节、密码或密钥。 +- **不记的情况**:启停提交后状态没有变化,不另记一条。审计插入失败只记应用日志,不把已经保存成功的技能改成失败。企业技能的原有管理接口本期不补审计。 + +### 7.2 版本用内容哈希,不用更新时间 + +- **备选**:用 `update_time` 当版本;或另加递增版本号。 +- **选定**:个人技能详情和「已存在」提示都返回当前 `content_hash`。覆盖请求带上客户端看到的 `expected_content_hash`。不一致则不写对象、不改行,返回 11064,文案为「该 Skill 已更新,请刷新后重试」。一致时先写新对象,再改这一行的哈希、对象路径和大小;技能 ID 和启停状态不动。行更新成功后删掉旧哈希的对象;新旧哈希相同则不删,删除失败同样交给后台清扫。行更新失败时旧哈希仍指向旧对象,新对象没有技能行引用,交给已定的后台清扫。 +- **原因**:启停也会改 `update_time`。用它做版本,会把「只是停用过」误判成文件冲突。内容哈希只在文件被替换时变化,和对象键是同一套版本。 + +### 7.3 启停和删除的重复提交 + +- 启停:技能还在,且已经是请求里的状态,返回当前技能,不报错,不改 `update_time`。失败时开关保持操作前的值,并提示重试。 +- 删除:当前用户名下已经没有这个 ID,返回与删除成功相同的空结果,不报错。响应里没有名称和正文。他人的个人技能也落在这条上,因此不会变成 11054,也不会露出这条技能是否存在。 +- 启停打到一个当前用户名下不存在的 ID,返回 11053。启停没有「已删除」这种可回放的当前结果;删除才有。 + +### 7.4 导入标识,响应丢了先查再重试 + +- **备选**:服务端生成导入标识;或另建导入任务表。 +- **选定**:浏览器在一次保存开始时生成 `import_id`,创建和确认覆盖都带上它,重试沿用同一个。成功后写进该行的 `last_import_id`。唯一约束是 `(tenant_id, owner_user_id, last_import_id)`。MySQL 唯一索引里空值互不相等,企业技能和尚未导入的行可以都是空,不会互相挡住。 +- **原因**:需求要求重试不重复创建,并且保存响应中断后先查服务端。技能行本身就是「已保存」的标志,不必再维护一张导入表。 +- **查询**:按当前登录用户和 `import_id` 查。查到则把这次保存当成功,不再上传。查不到返回 11053。 +- **同一标识再提交**:已有行且内容哈希与本次文件一致,返回该技能。哈希不一致返回 11066,不覆盖。两个请求同时写入时,唯一约束只留一行,后到的请求改为按上面的规则回读。 + +### 7.5 加密压缩包和 MIME + +- 解包前看 ZIP 条目的加密标志(通用标志位 bit 0)。任一文件加密,返回 11063,文案说明压缩包已加密、请解除密码后重新选择。不尝试解密,也不把密码写进日志。今天加密包会落到 `BadZipFile`,和损坏包同一句,所以要分开。 +- 扩展名决定格式。`.md` 的 MIME 只接受 `text/markdown`、`text/plain`、`application/octet-stream` 或空值。`.zip` 与 `.skill` 只接受 `application/zip`、`application/x-zip-compressed`、`application/octet-stream` 或空值。内容还要和扩展名一致:`.md` 不能是 ZIP;`.zip` 和 `.skill` 必须是 ZIP。对不上返回 11065。MIME 只是声明,字节才算数。 +- 压缩包损坏返回 11067,缺少 `SKILL.md` 返回 11068,路径越界返回 11069,文件为 0 字节返回 11071,压缩包内含多份技能返回 11072。文件区展示对应译文,并提供【重新选择】。这些原因不用 11051:前台在错误码有译文时显示那句译文,11051 只有通用的校验失败,盖不住「压缩包中缺少 SKILL.md,请检查后重新上传」。11060 与 11070 不使用。 + +### 7.6 删除文案、文件缺失和详情失效 + +- 删除确认句用需求原文:「删除‘{名称}’后,后续任务将无法使用该 Skill,已有对话和生成结果保留。」`{名称}` 用展示名。按钮为【取消】【删除】。请求失败时卡片留着,提示「删除失败,请重试」。 +- 技能行还在、对象存储里的包读不出来:详情返回 200,并带 `bundle_missing`。详情里说明原因,提供【更新技能】。该按钮走同一套覆盖:带上当前内容哈希,校验通过后替换,保留 ID 和启停状态。不要把缺文件报成 11053,否则页面无法区分「被删了」和「文件丢了」。 +- 当前详情对应的技能已删除,或平台技能已被管理员停用、删除:提示原因,关闭详情回到列表,其他卡片保留。用户停用自己的个人技能时留在详情里,只把开关关掉;停用后的个人技能仍在技能中心里。本期没有按人收回平台技能,需求里的「撤销授权」就是管理员停用。 +- 技能表上没有隐藏开关。不新增隐藏字段。技能中心是否列出,由归属和启停决定;新任务能否装载,由候选规则决定。 + +### 7.7 输入框同名仍是两行 + +候选接口按技能 ID 返回,不按名字合并。勾选按技能 ID 记。同名去重只在任务候选里做:已启用的个人技能盖住同名企业技能;个人技能停用或删除后,新任务拷企业技能的整包。两行都在时,勾了任意一行,对留下的个人技能加上「用户选中了这个技能」,菜单里不藏企业技能那一行。菜单里只有企业技能那一行时,勾中它就强调这份企业技能。 + +### 7.8 候选刷新、整包拷入,以及工具不可用 + +- 新建任务取当前用户的任务候选,把每个候选的整包拷进 `/skills/{名字}/`,并把名称和用途说明写入 “Available Skills”。同名时只拷个人技能的整包。 +- 同一次会话的恢复和继续提问沿用检查点里已有的名单。会话里已经有的技能目录不再写入另一份技能:同名个人技能停用后,同名企业技能进入候选,但不拷进这个目录。否则企业技能的文件会写进同一个目录,新包里没有的旧文件不会被删,目录里会混着两份技能的文件。同名企业技能等新任务再装载;新一轮对话不继承上一轮的 `skills/`,会按当时的候选重新拷。 +- 用户勾中的技能(含默认勾选且未摘掉的)在提示词里标明「用户选中了这个技能」。未勾选的候选同样拷入整包,但不加这句。`SKILL.md`、脚本和参考文件在任务开始时一次拷完。 +- 这句由平台在贴 “Available Skills” 时追加,不写进 `SKILL.md`,技能正文保持上传时的样子。现有 `SkillsMiddleware` 列出每个技能时只有名字、用途说明、许可、兼容性和 `SKILL.md` 路径,读不到勾选。实现时在它外面包一层:按会话版本上的勾选名单,在对应技能那一行后面加这句。勾选按技能 ID 记,先换成实际拷入的目录名再比对;勾的是被同名个人技能盖住的企业技能时,强调落到留下的个人技能上。 +- 整包要在建代码执行器之前拷进会话目录。E2B 在建工具时就拍下要推进沙箱的文件清单,技能晚于这一步拷入,E2B 里就没有技能包。本地执行和容器沙箱在执行时读会话目录,不受这个顺序影响。单个文件超过 5 MiB 仍推不进 E2B,这个限制不变;技能里有这类文件时,该技能在 E2B 下用不全,任务里如实说明。 +- 装载时在 `linsight_session_version` 增加 JSON 列,记下每个目录名对应的技能:技能名、技能 ID、归属用户和内容哈希。MySQL 和 DM8 都要迁移。会话上原有的 `skills` 只存勾选的技能名,同名时分不清目录里是个人技能还是企业技能,所以不能用那一列。这份快照只用来回答「这个目录属于哪一行」,不用来决定对象留不留。 +- 已经返回给模型的文件内容留在当前步骤,已完成的结果保留。会话目录里的副本留到任务结束,不靠删除这些文件来停用。 +- 下一步再使用之前有两处检查,都先按快照找到这个目录对应的技能 ID,再查这一行。只按目录名查不对:同名时这个名字上有两行,个人技能已停用、企业技能仍启用,按名字查会放行,模型用的却是目录里的个人技能文件。`read_file` 读到 `/skills/{名字}/` 时,这一行已停用或已删除就拒绝这次读取。代码执行器执行前再查一次:本地执行的工作目录就是会话目录,脚本用 `skills/{名字}/...` 直接打开文件,不经过 `read_file`。容器沙箱也是从这份会话目录送文件进去。只拦 `read_file` 时,这两处仍会用到已停用的技能。 +- “Available Skills” 在代理开始前扫描一次 `SKILL.md`,写入检查点的 `skills_metadata`。之后每次叫模型只重贴这份名单。检查点里已经有名单时不再扫描磁盘,所以同一次会话的恢复和继续提问不会因为停用而改写提示词。新任务没有这份检查点,会按当时的候选重新生成名单,已停用或已删除的技能不会再拷入。 +- 正在跑的任务不需要保留技能对象。拷入时写的是本地目录和这次会话自己的对象存储副本 `workspace/{会话}/skills/{名字}/`;之后 `read_file`、恢复和换机器继续,读的都是这份会话副本,不再回到技能对象。所以删除和覆盖时,在本次请求里直接删掉这一行已经不再指向的旧对象,不查哪些会话还没结束。当前行仍指向的哈希不删。 +- 恢复和继续提问不再把已停用或已删除的技能装进候选,但第一次拷进会话目录的文件还在,仍由上面两处检查拒绝下一次读取和执行。 +- 技能前言里的 `allowed-tools` 若有条目不在本次任务的工具里,该技能在 “Available Skills” 中加一句「该技能需要的工具当前不可用」。真正调用到缺失工具时,任务结果用同一句,不用堆栈。没有声明工具则不加这句。不因此拒绝整个任务,也不在上传时安装工具。上传不改变用户原有的工具和数据权限。 + +--- diff --git a/features/v3.0.0-beta3/076-personal-skill-center/spec.md b/features/v3.0.0-beta3/076-personal-skill-center/spec.md new file mode 100644 index 0000000000..0312ba0bf3 --- /dev/null +++ b/features/v3.0.0-beta3/076-personal-skill-center/spec.md @@ -0,0 +1,114 @@ +# Feature: 个人技能上传与技能中心 + +> **本文档定位 — 纯 What(需求口径,不随代码漂移)** +> +> spec 只回答做什么、验收标准、不做什么。实现决策在 [design.md](./design.md)。 + +**关联 PRD**: 《0930 中粮需求响应》「个人 Skill 上传与技能中心」 +**优先级**: P1 +**所属版本**: v3.0.0-beta3 +**依赖**: 无(本版本内)。运行时仍走既有灵思技能的启用与物化。每次任务把每个候选的整包拷进会话;勾选只在提示词里标明「用户选中了这个技能」 + +> **范围边界** +> - **本次纳入**: +> - 用户导入已有技能文件(`.md` / `.zip` / `.skill`),服务端保存,换设备仍在 +> - 个人技能的列表、搜索、启用/停用、删除 +> - 技能中心把「平台分配给该用户的技能」和「该用户自己的技能」放在同一候选目录 +> - 每次任务把每个候选的整包拷进会话;勾选只在提示词里标明「用户选中了这个技能」 +> - **本次明确排除**: +> - 用 Kimi 创建技能、把 Office 文件转成技能(产品明确不做) +> - 个人技能分享、公开到技能市场(产品明确不做) +> - 只存在浏览器本地、不落服务端的预览存法。那是前端试验,不是本需求的交付形态 + +--- + +## 1. 用户故事 + +作为 **使用灵思任务的业务用户**, +我希望 **把自己已有的技能文件传到平台,并在技能中心里和平台分配给我的技能一起看到**, +以便 **换一台电脑还能用,任务也能从这些技能里自动选用**。 + +作为 **同一租户里的其他用户**, +我希望 **看不到、也调不到别人上传的个人技能**, +以便 **个人技能不会变成未经授权的共享能力**。 + +--- + +## 2. 验收标准 + +### 2.1 上传与保存 + +- **AC-01** — WHEN 用户上传一份通过校验的 `.md`、`.zip` 或 `.skill`,且该文件内含合法的 `SKILL.md`,THE SYSTEM SHALL 把技能保存到当前用户名下,并在技能中心「我的技能」中列出。 +- **AC-02** — WHEN 同一用户在另一台浏览器再次打开技能中心,THE SYSTEM SHALL 列出 AC-01 已保存的技能,不依赖本机缓存。 +- **AC-03** — IF 扩展名不是 `.md`、`.zip`、`.skill`,或压缩包内没有合法 `SKILL.md`,THEN THE SYSTEM SHALL 拒绝保存,技能中心不出现新条目。 +- **AC-04** — IF `SKILL.md` 超过 1 MiB(1048576 字节),或压缩包内条目超过 1000 个,或上传体超过 50 MiB(52428800 字节),或解压后总大小超过 200 MiB(209715200 字节),THEN THE SYSTEM SHALL 拒绝保存并说明超出的是哪一条限制。四条限制各用一个错误码,前台显示对应的那一句,并写出这条限制的具体数值。 +- **AC-05** — WHERE 多租户开启,THE SYSTEM SHALL 只在当前租户内保存和列出该用户的个人技能。 + +### 2.2 技能中心与隔离 + +- **AC-06** — WHEN 用户打开技能中心,THE SYSTEM SHALL 提供「我的技能」和「技能广场」两个页签。「我的技能」是一个列表,分成「我创建的」和「企业提供」两组:个人技能可以启停,更新和删除在详情里;企业技能始终启用。每一行展示名称、来源和一行用途说明,点击整行打开详情。「技能广场」只展示本租户已启用的企业技能,为只读卡片,点击卡片打开详情。页签数量分别为个人技能个数和企业技能个数。 +- **AC-07** — WHEN 用户在当前页签搜索,THE SYSTEM SHALL 只筛选该页签中的名称、技能标识和用途说明,不把其他用户的个人技能算进来。切换页签时清空搜索词。 +- **AC-08** — IF 用户请求另一用户的个人技能内容、下载或选用,THEN THE SYSTEM SHALL 按不存在拒绝(11053),不调用统一授权。响应中不出现名称、正文或对象地址,也不表明这条技能是否存在。归属是本人的详情、读取文件、更新、启停和删除,在返回内容前调用 `require_business_action`,资源类型为 `linsight_skill`。 +- **AC-09** — WHEN 用户停用自己的个人技能,THE SYSTEM SHALL 使该技能不再进入新任务、运行恢复和重新规划的候选。已经返回给模型的文件内容留在当前步骤。会话目录里的副本留到该任务结束,不靠删除这些文件来停用。下一步 `read_file` 读到 `/skills/{名字}/`,或代码执行器再执行前,发现该技能已停用则拒绝这次读取或执行。同一次会话的 “Available Skills” 名单写在检查点里,恢复和继续提问不重读磁盘来改这份名单。 +- **AC-10** — WHEN 用户删除自己的个人技能,且没有任务正在使用它,THE SYSTEM SHALL 从技能中心移除该技能,其他用户的列表不变。 + +### 2.3 自动选用 + +- **AC-11** — WHEN 用户发起灵思任务,THE SYSTEM SHALL 把「本租户已启用的平台技能 ∪ 该用户已启用的个人技能」中每个候选的整包拷进会话的 `/skills/{名字}/`,名称和用途说明进入同一次任务的提示词。用户勾中的技能(含默认勾选且未摘掉的)标明「用户选中了这个技能」。未勾选的候选同样拷入整包,但不加这句。这句由平台加在提示词里,不改写 `SKILL.md`。停用的个人技能仍在技能中心列出,但不进入候选。不另调模型生成名单。 +- **AC-12** — IF 个人技能与平台技能的技能标识相同,THEN THE SYSTEM SHALL 在「我的技能」的两个分组里都列出,并标来源;「技能广场」只保留企业技能那一张卡片。同名去重只在任务候选里做:个人技能已启用时,该用户的任务只装载个人技能,不把同标识的企业技能文件放进这次任务。个人技能停用或删除后,新任务改为装载同名的企业技能。展示名相同而技能标识不同时,按新技能保存。 + +### 2.4 个人配额 + +- **AC-13** — IF 当前用户在当前租户的个人技能已有 20 个,或这些技能的上传文件合计已达 200 MiB(209715200 字节),再上传会使个数或合计容量超出,THEN THE SYSTEM SHALL 拒绝本次新增,已保存的技能保持不变。停用的个人技能仍占用个数和容量。企业技能不计入。上传前界面展示已用个数和已用容量。 + +### 2.5 同名再次上传 + +- **AC-14** — IF 当前用户上传的个人技能与自己已有的个人技能标识相同,THEN THE SYSTEM SHALL 先提示该技能已存在,不写入。用户确认覆盖后,用新文件替换原技能,保留原技能 ID 和启停状态。未确认时保留旧文件。个数不变;合计容量按新文件重新计算,替换后超出 200 MiB 则拒绝覆盖。与企业技能同名不走这条,「我的技能」的两个分组里仍然都列出。 + +### 2.6 删除与临时文件 + +- **AC-15** — WHEN 用户删除自己的个人技能,且已有任务正在使用它,THE SYSTEM SHALL 立即从技能中心和之后的候选中移除该技能。新任务、运行恢复和重新规划不再装载它。已经返回给模型的文件内容留在当前步骤。会话目录里的副本保留到该任务结束,不靠删除这些文件来停用。下一步 `read_file` 读到 `/skills/{名字}/`,或代码执行器再执行前,发现该技能已删除则拒绝这次读取或执行。装载时在该次会话版本上记下每个目录对应的技能 ID 和归属用户,读取和执行前按这一行判断,不按目录名判断。同一次会话恢复时,已有的技能目录不写入同名的另一份技能。正在跑的任务读的是会话自己的副本,所以本次删除请求中直接删除该技能的对象。 +- **AC-16** — WHEN 用户取消上传,或校验失败,或同名时未确认覆盖,THE SYSTEM SHALL 不把技能包写入对象存储。请求结束时删除本次解压用的临时目录。不按天数保留临时文件。 + +### 2.7 管理操作、校验与任务提示 + +- **AC-17** — WHEN 个人技能新增、覆盖更新、启停状态发生实际变化或删除成功,THE SYSTEM SHALL 在现有审计日志中记录操作者、租户、技能 ID、来源、版本和结果。日志不包含技能正文、用途说明、文件字节或凭据。启停状态没有变化时不另记一条。 +- **AC-18** — IF 覆盖请求携带的内容哈希与当前技能不一致,THEN THE SYSTEM SHALL 不写入新文件,并提示「该 Skill 已更新,请刷新后重试」。哈希一致时,保存成功后保留原技能 ID 和启停状态。 +- **AC-19** — WHEN 启停请求的目标状态与当前状态相同,THE SYSTEM SHALL 返回当前技能且不报错。WHEN 删除请求对应的技能已不在当前用户名下,THE SYSTEM SHALL 返回与删除成功相同的结果,响应中不出现名称或正文。 +- **AC-20** — WHEN 用户删除个人技能,THE SYSTEM SHALL 使用确认文案「删除‘{名称}’后,后续任务将无法使用该 Skill,已有对话和生成结果保留。」按钮为【取消】【删除】。删除请求失败时保留卡片,并提示「删除失败,请重试」。 +- **AC-21** — IF 个人技能的记录还在、技能包已无法读取,THEN THE SYSTEM SHALL 在详情中说明原因并提供【更新技能】。该更新沿用 AC-14 的覆盖,保留原技能 ID 和启停状态。 +- **AC-22** — IF 当前打开的技能已被删除,或平台技能已被停用或删除,THEN THE SYSTEM SHALL 提示原因并返回列表,其他技能保持显示。用户停用自己的个人技能时留在详情中,只更新开关。 +- **AC-23** — IF 压缩包内有加密条目,THEN THE SYSTEM SHALL 拒绝导入并说明压缩包已加密。IF 扩展名与 MIME 不在需求允许的组合内,或文件内容与扩展名不一致,THEN THE SYSTEM SHALL 拒绝导入。`.md` 接受 `text/markdown`、`text/plain`、`application/octet-stream` 或空值;`.zip` 和 `.skill` 接受 `application/zip`、`application/x-zip-compressed`、`application/octet-stream` 或空值。压缩包损坏、缺少 `SKILL.md`、路径越界、文件为 0 字节、压缩包内含多份技能,各自用独立错误码说明原因,文件区展示该原因并提供【重新选择】。缺少 `SKILL.md` 的文案为「压缩包中缺少 SKILL.md,请检查后重新上传。」 +- **AC-24** — WHEN 保存响应中断后用户重试,THE SYSTEM SHALL 用同一次导入标识先查询服务端。已经保存成功则不再创建第二条;尚未保存才再次提交。同一导入标识配上不同文件时拒绝写入。 +- **AC-25** — IF 技能声明的工具不在本次任务的可用工具中,THEN THE SYSTEM SHALL 在任务中说明「该技能需要的工具当前不可用」,并且不因此拒绝整个任务。 +- **AC-26** — WHEN 同一用户的个人技能与企业技能标识相同且两份都已启用,THE SYSTEM SHALL 在输入框菜单里分两行展示并标出来源,装入任务目录时只保留个人技能。个人技能停用或删除后,输入框只显示企业技能那一行,新任务装载企业技能。 + +--- + +## 3. 边界情况 + +- 当压缩包解压路径试图逃出技能目录时,系统应拒绝该包,不落任何文件 +- 当两个请求同时为同一用户写入同一技能标识时,系统应只保留一次成功写入,另一次得到明确失败,不得写出半份包 +- 当管理员停用某平台技能时,该技能立即退出本租户所有用户的候选目录;用户自己的个人技能不受影响 +- 当技能已停用时,技能中心仍能看到它并允许重新启用。新任务、运行恢复和重新规划不再装载它。已经返回的内容留在当前步骤;会话目录里的副本留到该任务结束。下一步读取或代码执行前再查一次,已停用则拒绝 + +--- + +## 4. 设计与实现(指针,不复制) + +| 你想知道 | 去哪看 | +|---|---| +| 为什么挂在现有技能表上、同名如何区分 | design.md §3 | +| 今天的技能存储和本需求还缺什么 | design.md §4 | +| 配额、运行中删除、同名上传、临时文件怎么落地 | design.md §3 决策 4、§5、§7.8。配额为每人 20 个、合计 200 MiB。同名再次上传为先提示、确认后覆盖。每个候选在任务开始时整包拷入会话。删除和覆盖时直接回收旧对象,正在跑的任务用会话自己的副本。取消或失败的导入不落对象存储 | +| 对外契约、会破坏谁 | design.md §6 | +| 技能中心页面为什么是「我的技能」加「技能广场」 | design.md 决策 6。验收为 AC-06、AC-07、AC-12 | +| 审计、版本、重复提交、导入重试、加密包与 MIME、删除文案、详情失效、菜单两行、工具不可用 | design.md §7。验收为 AC-17、AC-18、AC-19、AC-20、AC-21、AC-22、AC-23、AC-24、AC-25、AC-26 | + +--- + +## 相关文档 + +- 设计真相: [design.md](./design.md) +- 版本契约: [features/v3.0.0-beta3/release-contract.md](../release-contract.md) +- 执行拆分: [tasks.md](./tasks.md) diff --git a/features/v3.0.0-beta3/076-personal-skill-center/tasks.md b/features/v3.0.0-beta3/076-personal-skill-center/tasks.md new file mode 100644 index 0000000000..efe7d559b5 --- /dev/null +++ b/features/v3.0.0-beta3/076-personal-skill-center/tasks.md @@ -0,0 +1,394 @@ +# Tasks: 个人技能上传与技能中心 + +**关联规格**: [spec.md](./spec.md) · [design.md](./design.md) +**版本**: v3.0.0-beta3 + +--- + +## 状态 + +| 步骤 | 状态 | 备注 | +|------|------|------| +| spec.md | ✅ 已评审 | 2026-10-19 审查后确认不改 | +| design.md | ✅ 已评审 | 2026-10-19 审查后确认不改 | +| tasks.md | ✅ 已拆解 | 2026-10-19 按 tasks-checklist 拆解;47 项 | +| 实现 | ✅ 完成 | 47 / 47 完成。偏差处理见 design.md 顶部调整原则 + `docs/SDD-Guide.md` §3-§4 | + +--- + +## 开发模式 + +- 按 Wave 组织。同一 Wave 内无相互依赖的任务可并行。 +- 后端 Test-First:测试任务在配对实现之前。测试放 `src/backend/test/linsight/`(已有目录),`asyncio_mode=auto`。不新建根级 `test/` 文件。 +- 设计论证指向 design 的决策或章节,这里不复述理由。 +- 本 Feature 无 Platform 页面。管理端技能页不改;个人技能不得出现在管理列表里,由 T007、T013、T025 保证。 +- 前端只改 `src/frontend/client/`。页面结构沿用已合入的技能中心,数据源从浏览器换成服务端(design 决策 6)。 +- 新的用户可见句子进 client 三语 `src/frontend/client/src/locales/{zh-Hans,en,ja}/translation.json`,复用已有 `com_skill_center_*`。错误码文案只改 `src/frontend/packages/locales` 的 `api_errors`,不手改生成物。 +- 不新增 YAML 顶层键。配额 20 个、200 MiB(209715200 字节)写在服务代码里。 +- 多 agent 并行时禁止仓库级 git 写操作(根 `AGENTS.md` §7)。 + +--- + +## Tasks + +### Wave 1 — 基础设施(无测试配对,互不依赖) + +- [x] **T001**: `linsight_skill` 归属列与迁移 + **文件**: `src/backend/bisheng/linsight/domain/models/linsight_skill.py`,`src/backend/bisheng/core/database/alembic/versions/v3_0_0b3_f076_linsight_skill_owner.py` + **逻辑**: 增加非空 `owner_user_id`(默认 0)和可空 `last_import_id`。唯一约束从 `uq_linsight_skill_tenant_name (tenant_id, name)` 改为 `(tenant_id, owner_user_id, name)`。另加 `(tenant_id, owner_user_id, last_import_id)`:MySQL 与 DM8 上两行空的 `last_import_id` 都要能插入(design §7.4)。升级前若同租户同名已有重复行则中止,不填业务数据;存量行 `owner_user_id` 填 0。降级删两个新约束、恢复旧约束、删这两列。不搬对象。本任务只改模型和迁移,不改 DAO 方法(T007)。租户技能管理的请求和响应不加字段(design §6,release-contract 里这张表的个人技能归属 F076,存量行仍是租户技能)。 + **测试**: MySQL 与 DM8 各执行一次 upgrade / downgrade;两行空 `last_import_id` 同时成功。DM8 若把空值当成相等,停下,不要自行换成另一种唯一约束。 + **依赖**: 无 + +- [x] **T002**: 会话版本技能快照列 + **文件**: `src/backend/bisheng/linsight/domain/models/linsight_session_version.py`,`src/backend/bisheng/core/database/alembic/versions/v3_0_0b3_f076_session_skill_snapshot.py` + **逻辑**: 增加可空 JSON 列 `skill_snapshot`。每一项是 `{name, skill_id, owner_user_id, content_hash}`,表示这个会话目录属于哪一行(design §7.8)。不回填。原 `skills` 列仍只存勾选的技能名,本任务不改它的含义。对外的 dump 不输出这一列。降级删列。 + **测试**: MySQL 与 DM8 各执行一次 upgrade / downgrade + **依赖**: 无 + +- [x] **T003**: 个人技能错误码类 + **文件**: `src/backend/bisheng/common/errcode/linsight.py` + **逻辑**: 新增 11061 个数超出、11062 容量超出、11063 压缩包已加密、11064 版本过期、11065 类型与内容不一致、11066 同一次导入的文件不一致、11067 压缩包损坏、11068 缺少 `SKILL.md`、11069 路径越界、11071 文件为 0 字节、11072 压缩包内含多份技能、11073 `SKILL.md` 超过 1 MiB、11074 条目超过 1000。11052、11059 增加 `limit_mb`,经基类 kwargs 进入响应 `data`。不改 11051、11053、11054、11055 的码,不改 11060、11070 的类和文案。用户能看到的句子只写在 T004,不在 `status_message` 再写一句中文(design §6)。 + **依赖**: 无 + +- [x] **T004**: 错误码三语文案 + **文件**: `src/frontend/packages/locales/src/api_errors/zh-Hans.json`,`src/frontend/packages/locales/src/api_errors/en.json`,`src/frontend/packages/locales/src/api_errors/ja.json` + **逻辑**: 为 T003 的新码补齐三语。11052 改为「技能文件超过 {{limit_mb}} MiB,请精简后重新上传」,11059 改为「技能包解压后超过 {{limit_mb}} MiB,请精简后重试」。11068 的中文是「压缩包中缺少 SKILL.md,请检查后重新上传。」11064 的中文是「该 Skill 已更新,请刷新后重试」。11063 说明压缩包已加密、请解除密码后重新选择。跑既有生成脚本,不手改 `platform/public/locales/*/api_errors.json` 和 `client/src/locales/*/api_errors.gen.json`。`pnpm check-i18n` 通过。与 T011 同一 PR 提交,避免页面留下未替换的 `{{limit_mb}}`。 + **依赖**: 无 + +- [x] **T005**: 把 `linsight_skill` 补进 edit / delete 的资源范围 + **文件**: `src/backend/bisheng/permission/domain/services/catalog_policy.py` + **逻辑**: `edit` 的资源范围加入 `linsight_skill`。`delete` 目前等于 `MIGRATED_RESOURCE_TYPES`,而 catalog 校验拒绝该集合以外的类型,所以把 `linsight_skill` 加入 `MIGRATED_RESOURCE_TYPES`,`delete` 和 `manage_permission` 会一并带上它。不新建资源类型,不加授权页面。本期仍不按人把个人技能授给别人;单条操作的归属核对在 T023。企业技能的使用继续不读这条所有者记录(design 决策 5、§5)。跑 `src/backend/test/permission/` 里按该集合参数化的用例;失败时只补这个类型在 catalog 上的最小登记,不把知识库的授权流程抄过来。 + **依赖**: 无 + +### Wave 2 — 归属查询与对象存储(依赖 Wave 1) + +- [x] **T006**: DAO 归属条件测试 + **文件**: `src/backend/test/linsight/test_skill_owner_dao.py` + **逻辑**: 同一租户造三行同名技能:企业 `owner_user_id=0`、用户 A、用户 B。`get_by_name`、`set_enabled`、`set_default_checked`、`delete_by_name`、`list_enabled`、`get_page` 只命中调用方指定的归属。不带 `tenant_id` 和 `owner_user_id` 的批量更新不得改到别的行。子租户查询看不到根租户的行。默认管理查询只看到 `owner_user_id=0`。 + **覆盖 AC**: AC-05, AC-10, AC-12 + **依赖**: T001 + +- [x] **T007**: DAO 查询带上归属 + **文件**: `src/backend/bisheng/linsight/domain/models/linsight_skill.py` + **逻辑**: 只改 DAO 方法,不动 T001 的列。`get_by_name`、`set_enabled`、`set_default_checked`、`delete_by_name`、`list_enabled`、`get_page` 增加归属条件,并保留 `strict_tenant_filter()`。批量更新显式带 `tenant_id` 和 `owner_user_id`。管理端调用固定 `owner_user_id=0`,旧请求响应不加字段(design §4.3、§5、§6)。 + **测试**: T006 全部通过;`test/linsight/test_skill_dao.py`、`test_skill_api.py`、`test_skill_list_paging.py` 不回归 + **覆盖 AC**: AC-05, AC-10, AC-12 + **依赖**: T001, T006 + +- [x] **T008**: 个人技能包存储与校验测试 + **文件**: `src/backend/test/linsight/test_personal_skill_store.py` + **逻辑**: 个人对象键是 `linsight/personal-skills/{租户}/{用户}/{名字}/{哈希}.zip`,删除只删这个前缀,不删 `linsight/skills/{租户}/{名字}/` 下的企业包,也不删其他用户。个人上限固定:上传超过 52428800 字节为 11052 且 `data.limit_mb` 为 50;解压超过 209715200 字节为 11059 且 `limit_mb` 为 200;`SKILL.md` 超过 1048576 字节为 11073;条目超过 1000 为 11074。企业路径仍用系统配置的上限,`limit_mb` 等于那份配置。加密条目为 11063,损坏为 11067,缺少 `SKILL.md` 为 11068,路径越界为 11069,0 字节为 11071,多份技能为 11072,扩展名或 MIME 与内容不符为 11065(允许的 MIME 组合见 design §7.5)。这些失败都不调用对象写入。扩展名不是 `.md`、`.zip`、`.skill` 时拒绝。 + **覆盖 AC**: AC-03, AC-04, AC-16, AC-23 + **依赖**: T003, T004 + +- [x] **T009**: `SkillStore` 按归属选择前缀和上限 + **文件**: `src/backend/bisheng/linsight/domain/services/skill_store.py` + **逻辑**: 对象键、本地缓存、读取和删除按 `owner_user_id` 选前缀。`0` 仍用 `linsight/skills`,存量对象不搬。个人上传用 T008 的固定上限,不读系统配置里更大的默认值。解压和路径校验沿用现有函数,不另写一套(design §4.3、§5)。 + **测试**: T008 中属于 `SkillStore` 的用例通过;`test_skill_store.py`、`test_skill_upload_limit.py` 不回归 + **覆盖 AC**: AC-03, AC-04, AC-16, AC-23 + **依赖**: T001, T008 + +- [x] **T010**: 现有体积错误补 `limit_mb` 的测试 + **文件**: `src/backend/test/linsight/test_skill_limit_mb.py` + **逻辑**: 管理端上传、`SkillService` 创建、GitHub 导入三处抛出 11052 或 11059 时,响应 `data.limit_mb` 等于该路径当时使用的上限。个人路径不在本测试里(T008 已覆盖)。 + **覆盖 AC**: AC-04 + **依赖**: T003, T004 + +- [x] **T011**: 现有三处体积错误带上 `limit_mb` + **文件**: `src/backend/bisheng/linsight/api/endpoints/skill.py`,`src/backend/bisheng/linsight/domain/services/skill_service.py`,`src/backend/bisheng/linsight/domain/services/github_skill_fetcher.py` + **逻辑**: 这三处抛 11052、11059 时传入实际上限的 MiB 数。企业路径用系统配置,不改成个人技能的 50 / 200。不改这三处以外的校验结果。与 T004 同一 PR。 + **测试**: T010 全部通过 + **覆盖 AC**: AC-04 + **依赖**: T003, T004, T010 + +### Wave 3 — 个人技能服务(依赖 Wave 2、T005) + +- [x] **T012**: 重名范围测试 + **文件**: `src/backend/test/linsight/test_skill_duplicate_scope.py` + **逻辑**: 个人技能可以与本租户企业技能同名,也可以与其他用户的个人技能同名,两种都不返回 11055。同一归属下技能标识重复仍返回 11055。展示名相同而技能标识不同时,按新技能保存。两个请求同时写入同一用户的同一技能标识,只成功一行,后到的请求得到明确失败,不留下半份包。 + **覆盖 AC**: AC-12, AC-14 + **依赖**: T007 + +- [x] **T013**: 重名检查收进同一归属 + **文件**: `src/backend/bisheng/linsight/domain/services/skill_service.py` + **逻辑**: `_check_duplicate` 只在同一 `owner_user_id` 内检查技能标识和展示名。管理端调用仍传 `owner_user_id=0`。不改管理端创建的其他步骤(design 决策 2、§4.3)。 + **测试**: T012 全部通过;`test_skill_api.py`、`test_skill_service.py` 不回归 + **覆盖 AC**: AC-12, AC-14 + **依赖**: T007, T012 + +- [x] **T014**: 创建、配额、导入标识测试 + **文件**: `src/backend/test/linsight/test_personal_skill_create.py` + **逻辑**: 校验通过的 `.md` / `.zip` / `.skill` 写入当前用户名下,再次列出仍在,不依赖调用方缓存。当前租户已有 20 个或上传字节合计达到 209715200 时拒绝新增,已有行不变;停用的个人技能计入,企业技能不计入;超出个数为 11061,超出容量为 11062。校验失败、调用方取消、同名未确认时不写对象,并删掉本次解压临时目录。同名个人技能返回 11055,`data` 带 `skill_id` 和 `content_hash`,不写对象;与企业技能同名不走 11055。`import_id` 已保存且哈希一致时返回原技能,不再插第二行;查不到返回 11053;同一 `import_id` 配不同文件返回 11066 且不覆盖。并发同名只留一行。创建成功写一条审计:操作者、租户、技能 ID、来源、内容哈希、结果;正文和文件字节不在日志里。审计插入失败不把已保存的技能改成失败。另一租户列不出这些行。 + **覆盖 AC**: AC-01, AC-02, AC-05, AC-13, AC-14, AC-16, AC-17, AC-24 + **依赖**: T003, T007, T009 + +- [x] **T015**: `PersonalSkillService` 创建、用量和导入查询 + **文件**: `src/backend/bisheng/linsight/domain/services/personal_skill_service.py` + **逻辑**: 新增该服务,本任务只加 `create`、`usage`、`find_by_import`,以及内部的 `_load_own_skill`:按当前租户加载行,没有这行或归属不是本人就返回 11053。后续 T017、T019 只用这个加载;T023 在归属通过后补 `require_business_action`,不改这里的创建流程。`create` 写 `owner_user_id=当前用户`,复用 `SkillService` 已有的 `authorize_created`(资源类型 `linsight_skill`,所有者是上传者),不新写 OpenFGA。个数和容量与插入放在同一事务,超出则回滚并返回 11061 或 11062。同名返回 11055 且带 `skill_id`、`content_hash`。成功后把 `import_id` 写入 `last_import_id`,并按 design §7.1 记审计。请求结束删除解压临时目录。校验未通过不调用对象写入。`usage` 返回已用个数、已用字节和上限 20、209715200。 + **测试**: T014 全部通过 + **覆盖 AC**: AC-01, AC-02, AC-05, AC-13, AC-14, AC-16, AC-17, AC-24 + **依赖**: T005, T009, T013, T014 + +- [x] **T016**: 覆盖更新测试 + **文件**: `src/backend/test/linsight/test_personal_skill_overwrite.py` + **逻辑**: `expected_content_hash` 与当前行不一致时不写对象、不改行,返回 11064。一致时先写新对象,再改哈希、对象路径和大小;技能 ID 和启停状态不变。行更新成功后删除旧哈希的对象;新旧哈希相同则不删。替换后合计容量超过 209715200 时拒绝,旧对象和旧行都保留,返回 11062。个数不变。覆盖成功记一条审计。行更新失败时旧哈希仍指向旧对象。他人的技能 ID 返回 11053,且不调用 `require_business_action`。 + **覆盖 AC**: AC-14, AC-17, AC-18 + **依赖**: T003, T015 + +- [x] **T017**: 覆盖更新 + **文件**: `src/backend/bisheng/linsight/domain/services/personal_skill_service.py` + **逻辑**: 增加 `overwrite`,加载行只用 T015 的 `_load_own_skill`。顺序按 design §7.2:哈希不一致直接 11064;一致则先写新对象,再改行,保留技能 ID 和启停状态;行更新成功后删旧对象,新旧哈希相同不删,删除失败留给 T035 清扫。容量按新文件重算。本任务不在 `_load_own_skill` 里调用统一授权。 + **测试**: T016 全部通过 + **覆盖 AC**: AC-14, AC-17, AC-18 + **依赖**: T015, T016 + +- [x] **T018**: 启停与删除测试 + **文件**: `src/backend/test/linsight/test_personal_skill_lifecycle.py` + **逻辑**: 目标状态与当前相同则返回当前技能,不报错,不改 `update_time`,不记审计。状态确有变化时记一条审计。当前用户名下已没有该 ID 的删除,返回与成功相同的空结果,响应无名称和正文。他人的删除也是这条空结果,不是 11054。启停打到不存在的 ID 返回 11053。删除成功后该行不在,并在本次请求删除该行指向的对象;其他用户的行还在。没有技能行指向这个对象时同样删除对象。停用后行还在,`enabled` 为 false。技能中心和任务候选是否还能看见,由 T020 断言,本测试直接查行和对象。 + **覆盖 AC**: AC-09, AC-10, AC-15, AC-17, AC-19 + **依赖**: T015, T003 + +- [x] **T019**: 启停与删除 + **文件**: `src/backend/bisheng/linsight/domain/services/personal_skill_service.py` + **逻辑**: 增加 `set_enabled` 和 `delete`,加载行只用 `_load_own_skill`。重复提交的结果按 design §7.3。删除在本次请求删除对象,不查哪些会话未结束(design §5、§7.8)。停用不删对象、不删会话目录。不在本任务里做列表过滤。 + **测试**: T018 全部通过 + **覆盖 AC**: AC-09, AC-10, AC-15, AC-17, AC-19 + **依赖**: T015, T018 + +- [x] **T020**: 技能中心列表与任务候选测试 + **文件**: `src/backend/test/linsight/test_personal_skill_lists.py` + **逻辑**: 技能中心返回本租户已启用企业技能,加上本人全部个人技能(含停用和启停状态);不含其他用户的个人技能,不含已停用的企业技能。任务候选只含已启用的企业技能和已启用的个人技能。同名且个人技能已启用时,技能中心两边都在,任务候选只留个人技能;个人技能停用或删除后,候选改为企业技能。搜索只匹配当前页签的名称、技能标识和用途说明。管理员停用企业技能后,该技能离开所有用户的候选,这些用户的个人技能仍在。页签数量等于这两组的个数。 + **覆盖 AC**: AC-06, AC-07, AC-09, AC-11, AC-12, AC-26 + **依赖**: T007, T019 + +- [x] **T021**: 两个读取方法 + **文件**: `src/backend/bisheng/linsight/domain/services/personal_skill_service.py` + **逻辑**: 增加 `list_center` 和 `list_candidates`,共用同一套归属条件(design 决策 3)。列表一次查询,不逐条调用 `require_business_action`。同名去重只发生在 `list_candidates`。`GET /api/v1/linsight/skill/selectable` 不在本任务修改。 + **测试**: T020 全部通过 + **覆盖 AC**: AC-06, AC-07, AC-09, AC-11, AC-12, AC-26 + **依赖**: T007, T019, T020 + +- [x] **T022**: 单条读取与授权顺序测试 + **文件**: `src/backend/test/linsight/test_personal_skill_access.py` + **逻辑**: 当前租户没有这行,或 `owner_user_id` 不是当前用户:详情、读文件、更新、启停、删除都返回 11053,响应无名称、正文和对象地址,且断言没有调用 `require_business_action`。归属是本人时,这五项都先调用它,资源类型 `linsight_skill`,资源编号是技能 ID;详情、读文件、更新、启停的动作是 `edit`,删除是 `delete`。技能行还在但对象读不出时,详情返回 200 且 `bundle_missing=true`,不是 11053。企业技能被停用或删除后,按该技能 ID 取详情得到可区分的结果(停用为 `enabled=false` 的 200,删除为 11053),供前端回列表。 + **覆盖 AC**: AC-08, AC-21, AC-22 + **依赖**: T005, T017, T019 + +- [x] **T023**: 详情、读文件和统一授权 + **文件**: `src/backend/bisheng/linsight/domain/services/personal_skill_service.py` + **逻辑**: 增加 `get` 和 `read_file`。只改 `_load_own_skill`:归属通过后再 `require_business_action`(design 决策 5)。不改 T017、T019 里覆盖、启停、删除的其余步骤。`get` 在对象缺失时返回 `bundle_missing=true`。不把企业技能的使用改成这条授权。 + **测试**: T022 全部通过;T016、T018 仍通过 + **覆盖 AC**: AC-08, AC-21, AC-22 + **依赖**: T005, T017, T019, T022 + +### Wave 4 — 接口(依赖 Wave 3) + +- [x] **T024**: 个人技能接口测试 + **文件**: `src/backend/test/linsight/test_personal_skill_api.py` + **逻辑**: 普通登录用户可调用下面这些路径;未登录拒绝。管理端 `GET/POST/PUT/PATCH/DELETE /api/v1/linsight/skill` 的响应与改前一致,列表里没有个人技能。`GET /selectable` 仍只返回本租户已启用企业技能。新路径:`POST /personal` 创建,同名时 11055 且不写对象;`POST /personal/{id}/overwrite`;`GET /personal/import/{import_id}`;`GET /personal/usage`;`GET /center`;`GET /candidates`;`GET /personal/{id}`;`GET /personal/{id}/file`;`PATCH /personal/{id}/status`;`DELETE /personal/{id}`。字面路径不被 `/{name}` 吃掉。错误响应使用 T004 的译文。 + **覆盖 AC**: AC-01, AC-02, AC-06, AC-08, AC-13, AC-14 + **依赖**: T015, T017, T019, T021, T023 + +- [x] **T025**: 个人技能路由 + **文件**: `src/backend/bisheng/linsight/api/endpoints/personal_skill.py`,`src/backend/bisheng/linsight/api/router.py` + **逻辑**: 新路由用普通登录用户,委托 `PersonalSkillService`,接口层不访问仓库。路径与 T024 一致,注册顺序保证 `center`、`candidates`、`personal` 不会被现有 `/{name}` 当成技能名。不改 `skill.py` 里管理端和 `/selectable` 的响应形状(design §6;输入框、装载和开放接口改读 `list_candidates`,见 T027、T029)。 + **测试**: T024 全部通过 + **覆盖 AC**: AC-01, AC-02, AC-06, AC-08, AC-13, AC-14 + **依赖**: T015, T017, T019, T021, T023, T024 + +- [x] **T026**: 开放接口任务候选测试 + **文件**: `src/backend/test/open_api/test_task_mode_personal_skills.py` + **逻辑**: `run_mode=task` 的可用技能和提交校验改读当前调用方的 `list_candidates`。调用方自己已启用的个人技能可用;其他用户的个人技能按不存在拒绝,不出现在可用列表。已停用的个人技能不可用。同名时只出现个人技能。请求字段仍是技能名,不改开放接口的请求体。日常模式响应不变。 + **覆盖 AC**: AC-08, AC-11, AC-12 + **依赖**: T021 + +- [x] **T027**: 开放接口改读任务候选 + **文件**: `src/backend/bisheng/open_api/domain/services/task_mode_service.py` + **逻辑**: `enabled_skill_rows` 改为当前身份的 `list_candidates`。个人技能只对所有者生效。不改请求 schema,不改日常模式。 + **测试**: T026 全部通过;`test/open_api/` 里既有任务模式用例不回归 + **覆盖 AC**: AC-08, AC-11, AC-12 + **依赖**: T021, T026 + +### Wave 5 — 任务装载与运行时拒绝(依赖 T002、T021、T019) + +- [x] **T028**: 整包拷入与恢复测试 + **文件**: `src/backend/test/linsight/test_personal_skill_provisioning.py` + **逻辑**: 新建任务把每个任务候选的整包拷进 `/skills/{名字}/`,名称和用途说明进入名单。同名只拷个人技能。停用或删除的个人技能不拷。装载写入 `skill_snapshot`(技能名、技能 ID、归属用户、内容哈希)。请求带企业技能的 `skill_id`、同名个人技能被拷入时,会话 `skills` 列写入留下的个人技能目录名;只传技能名的旧请求仍按名字写入。同一次会话恢复或继续时,快照里已有的目录不再写入另一行技能;同名企业技能因此不会混进该目录。拷贝发生在建立代码执行器、拍下 E2B `file_list` 之前,清单里能看到技能包。不另调模型生成名单。 + **覆盖 AC**: AC-09, AC-11, AC-12, AC-15 + **依赖**: T002, T009, T021 + +- [x] **T029**: 整包拷入与恢复时不覆盖已有目录 + **文件**: `src/backend/bisheng/linsight/domain/services/skill_provisioning.py`,`src/backend/bisheng/linsight/domain/services/workbench_impl.py`,`src/backend/bisheng/linsight/domain/schemas/linsight_schema.py` + **逻辑**: `materialize_session_skills` 改为拷入 `list_candidates` 的整包,并写 `skill_snapshot`。恢复和继续提问时跳过快照里已有的目录名(design §7.8)。`workbench_impl` 里给 E2B 拍 `file_list` 的调用保持在拷贝之后;不改单个文件 5 MiB 的限制。既有四个调用点仍调用这一个函数。`linsight_schema.py` 的提交请求增加可选 `skill_ids`。装载时把这些 ID 换成实际拷入的目录名,写入现有 `skills` 列;同名时勾了企业技能 ID,列里写留下的个人技能目录名。只传技能名的旧请求原样写入该列。 + **测试**: T028 全部通过;`test_skill_provisioning.py` 不回归 + **覆盖 AC**: AC-09, AC-11, AC-12, AC-15 + **依赖**: T002, T009, T021, T028 + +- [x] **T030**: 勾选强调与工具不可用测试 + **文件**: `src/backend/test/linsight/test_skill_selection_prompt.py` + **逻辑**: 外层中间件按勾选在 “Available Skills” 对应行后追加「用户选中了这个技能」,不改 `SKILL.md` 文件。未勾选的候选仍在名单里,但没有这句。勾选按技能 ID 比对,先换成实际拷入的目录名;勾的是被同名个人技能盖住的企业技能时,这句话落在留下的个人技能上。只传技能名的旧请求按名字匹配留下的目录。检查点里已有 `skills_metadata` 时不重扫磁盘。`allowed-tools` 里有本次任务没有的工具时,该行加「该技能需要的工具当前不可用」;没有声明则不加。不因此拒绝任务。真正调用到缺失工具时,结果用同一句,不用堆栈。 + **覆盖 AC**: AC-11, AC-25, AC-26 + **依赖**: T002, T029 + +- [x] **T031**: 包一层技能名单中间件 + **文件**: `src/backend/bisheng/linsight/domain/services/agent_factory.py` + **逻辑**: 在现有 `SkillsMiddleware` 外面包一层,按会话 `skills` 列里的目录名追加两句。同名覆盖后,该列已是 T029 留下的目录名。不改 deepagents 里的 `SkillsMiddleware`,不把勾选写进技能包,不加请求字段(design §7.8)。 + **测试**: T030 全部通过 + **覆盖 AC**: AC-11, AC-25, AC-26 + **依赖**: T029, T030 + +- [x] **T032**: 读取和代码执行前拒绝测试 + **文件**: `src/backend/test/linsight/test_skill_runtime_guard.py` + **逻辑**: `read_file` 读到 `/skills/{名字}/` 时,按 `skill_snapshot` 里的技能 ID 查行,该行已停用或已删除则拒绝这次读取。同名的企业技能仍启用时也不放行,因为目录里是个人技能的文件。代码执行器执行前做同一次检查:脚本路径 `skills/{名字}/...` 命中已停用或已删除的快照行则拒绝执行。快照里没有的路径不拦截。已返回给模型的内容不回收。 + **覆盖 AC**: AC-09, AC-15 + **依赖**: T002, T019, T029 + +- [x] **T033**: 两处运行时检查 + **文件**: `src/backend/bisheng/linsight/domain/services/workspace_backend.py`,`src/backend/bisheng/linsight/domain/services/skill_runtime_guard.py` + **逻辑**: `WorkspaceBackend.read` / `aread` 在路径落在 `/skills/{名字}/` 时调用守卫。新建 `skill_runtime_guard.py`,在 `bisheng_code_interpreter` 执行前扫描本次代码引用的 `skills/{名字}/`,按快照拒绝。不改 `bisheng_langchain` 里的执行器。只拦 `read_file` 不够,两处都要有(design §5、§7.8)。 + **测试**: T032 全部通过 + **覆盖 AC**: AC-09, AC-15 + **依赖**: T029, T032 + +### Wave 6 — 无主对象清扫(依赖 T009) + +- [x] **T034**: 无主个人技能对象清扫测试 + **文件**: `src/backend/test/linsight/test_personal_skill_orphan_sweep.py` + **逻辑**: `linsight/personal-skills/{租户}/{用户}/{名字}/` 下,没有任何技能行的 `content_hash` 指向的对象会被删掉。仍被当前行指向的哈希不删。企业前缀 `linsight/skills/` 不在清扫范围内。任务按租户执行:只删该租户前缀下的无主对象。 + **覆盖 AC**: AC-16 + **依赖**: T001, T009 + +- [x] **T035**: 按租户清扫无主对象 + **文件**: `src/backend/bisheng/worker/linsight/personal_skill_sweep.py`,`src/backend/bisheng/core/config/settings.py` + **逻辑**: Beat 按租户各发一条子任务。子任务参数带 `tenant_id`,发布时写入 Celery headers,worker 的 `task_prerun` 经 `worker/tenant_context.py` 恢复 `current_tenant_id` 后再查技能行。不在一条任务里跨租户查询。不新增 YAML 顶层键,默认调度写在 `settings.py` 既有 `beat_schedule` 默认项旁。清扫失败只记日志,不影响用户请求里已经返回的删除结果(design §5)。 + **测试**: T034 全部通过 + **覆盖 AC**: AC-16 + **约束**: `tenant_id` 走 Celery headers → ContextVar,不从任务体内临时改租户后再漏回默认租户 + **依赖**: T009, T034 + +### Wave 7 — 客户端(手动验证;测试用 mock,不依赖后端进程) + +接口形状以 T024 为准:技能中心分 `personal` 与 `enterprise` 两组,带 `source`、`enabled`、`content_hash`、`bundle_missing` 和两个页签计数;任务候选按技能 ID 返回、不按名字合并;用量为已用个数 / 20、已用字节 / 209715200。 + +- [x] **T036**: 输入框勾选测试 + **文件**: `src/frontend/client/src/components/Skills/__tests__/skillMenuSelection.test.tsx` + **逻辑**: 点一行切换勾选,菜单不关闭,不跳进技能中心详情。同名的企业技能和个人技能各一行,并标来源。个人技能停用后只剩企业技能那一行。名称或说明被截断时,悬停或聚焦在右侧展开全文,不打开详情。 + **覆盖 AC**: AC-26 + **依赖**: 无 + +- [x] **T037**: 技能中心页面测试 + **文件**: `src/frontend/client/src/pages/skills/SkillCenterPage.test.tsx` + **逻辑**: mock 服务端数据。页签为「我的技能」和「技能广场」;「我的技能」分「我创建的」「企业提供」,计数分别是个人技能个数和企业技能个数。搜索只筛当前页签,切换页签清空搜索词。删除确认文案是「删除‘{名称}’后,后续任务将无法使用该 Skill,已有对话和生成结果保留。」按钮为【取消】【删除】。删除请求失败时卡片还在,并提示「删除失败,请重试」。`bundle_missing` 时说明原因并提供【更新技能】。当前详情对应的技能已删除,或企业技能已停用、已删除时,提示原因并回到列表,其他技能仍在。用户停用自己的个人技能时留在详情里,只更新开关。 + **覆盖 AC**: AC-06, AC-07, AC-20, AC-21, AC-22 + **依赖**: 无 + +- [x] **T038**: 去掉预览开关的测试 + **文件**: `src/frontend/client/src/components/Skills/__tests__/skillPreviewConfig.test.ts`,`src/frontend/client/src/layouts/skillCenterNavigation.test.tsx` + **逻辑**: 技能中心入口不再读取 `skillCenterPreviewEnabled`。导航在该字段缺失时仍显示技能中心。 + **覆盖 AC**: AC-02, AC-06 + **依赖**: 无 + +- [x] **T039**: 技能中心改为调用服务端 + **文件**: `src/frontend/client/src/api/skillCenter.ts`,`src/frontend/client/src/components/Skills/useSkillCenter.ts`,`src/frontend/client/src/components/Skills/skillPreviewStore.ts` + **逻辑**: 列表、详情、用量、创建、覆盖、启停、删除、按 `import_id` 查询都走 T025 的接口,使用现有 `request`,不直接 `import axios`。去掉 IndexedDB 读写;`skillPreviewStore.ts` 无引用后删除。保存成功才算添加成功。 + **覆盖 AC**: AC-01, AC-02, AC-06 + **手动验证**: 在一台浏览器保存后,换一台浏览器打开技能中心仍能看到;断网时不把失败显示成已保存 + **依赖**: T025, T037 + +- [x] **T040**: 技能中心页面接上服务端数据 + **文件**: `src/frontend/client/src/pages/skills/SkillCenterPage.tsx`,`src/frontend/client/src/pages/skills/SkillList.tsx`,`src/frontend/client/src/pages/skills/SkillGrid.tsx` + **逻辑**: 数据源换成 T039。页面结构不改:两组列表、广场只读卡片、点击整行或卡片打开详情、搜索和排序留下。页签计数用接口返回的个数。 + **覆盖 AC**: AC-06, AC-07, AC-12 + **手动验证**: 同名技能在「我的技能」两组都出现并标来源,「技能广场」只有企业技能;搜索不带出另一个页签的结果;切换页签后搜索框为空 + **依赖**: T037, T039 + +- [x] **T041**: 上传弹窗 + **文件**: `src/frontend/client/src/components/Skills/SkillUploadDialog.tsx` + **逻辑**: 打开时展示已用个数 / 20 和已用容量 / 200 MiB。一次保存生成 `import_id`,重试沿用同一个;重试前先按 `import_id` 查询,已成功则不再提交。11055 时先提示已存在,确认后才调用覆盖,并带上当时的 `content_hash`。未确认不发保存请求。11064 显示「该 Skill 已更新,请刷新后重试」。校验失败的译文显示在文件区,并提供【重新选择】。文件区走现有 `translateApiErrorMessage`,让 11052、11059、11073、11074 代入 `limit_mb`,不在组件里写死这四句。取消关闭弹窗且不发保存请求。 + **覆盖 AC**: AC-04, AC-13, AC-14, AC-16, AC-18, AC-23, AC-24 + **手动验证**: 超过 20 个或 200 MiB 时按钮不可提交并看到对应错误;故意上传超限文件时,文件区出现带具体数字的那一句,不是 `{{limit_mb}}`,也不是通用的校验失败;取消后刷新,技能条数不变;同名先出现提示,取消后旧技能还在,确认后 ID 不变、启停状态不变 + **依赖**: T004, T039 + +- [x] **T042**: 详情里的删除、缺文件和失效 + **文件**: `src/frontend/client/src/components/Skills/SkillDetails.tsx` + **逻辑**: 删除确认句和按钮按 AC-20。失败保留详情并提示「删除失败,请重试」。`bundle_missing` 时提供【更新技能】,走 T041 的覆盖,带当前 `content_hash`。当前技能已删除,或企业技能已停用、已删除时,提示原因并关闭详情回到列表。用户停用自己的个人技能时不关闭详情。本文件里新增的中文改走 T047 的键,不新增硬编码中文。 + **覆盖 AC**: AC-20, AC-21, AC-22 + **手动验证**: 按 AC-20、AC-21、AC-22 各走一遍;停用自己的技能后开关关掉且详情仍在 + **依赖**: T037, T039, T041 + +- [x] **T043**: 输入框恢复勾选 + **文件**: `src/frontend/client/src/components/Skills/SkillMenuPanel.tsx`,`src/frontend/client/src/components/Skills/SkillMenuRow.tsx` + **逻辑**: 数据改为 T025 的任务候选。点一行切换勾选并保持菜单打开,不 `navigate` 到详情。勾选沿用现有任务请求里的技能名单,并额外提交 `skill_ids`。同名两行都显示。底部【上传技能】【管理技能】保留,管理技能仍打开技能中心。详情只从技能中心的卡片进入。 + **覆盖 AC**: AC-11, AC-26 + **手动验证**: 点一行后菜单还在,输入框上方出现或取消该技能;再点进技能中心才打开详情;个人技能停用后菜单只剩同名企业技能 + **依赖**: T036, T039 + +- [x] **T044**: 去掉预览开关 + **文件**: `src/frontend/client/src/components/Skills/types.ts`,`src/frontend/client/src/vite-env.d.ts`,`src/frontend/client/src/routes/index.tsx` + **逻辑**: 删除 `skillCenterPreviewEnabled`。技能中心路由不再包在这个开关里。不改 `public/assets/bisheng/config.js`(那是本地未提交覆盖);字段删掉后不再被读取。 + **测试**: T038 全部通过 + **覆盖 AC**: AC-02, AC-06 + **依赖**: T038 + +- [x] **T045**: 侧栏入口去掉预览开关 + **文件**: `src/frontend/client/src/layouts/MainLayout.tsx`,`src/frontend/client/src/components/Skills/SkillMenuEntries.tsx` + **逻辑**: 这两处不再按 `skillCenterPreviewEnabled` 决定出不出现技能中心。入口始终在。不改【+】菜单里的勾选行为(T046)。 + **测试**: T038 全部通过 + **覆盖 AC**: AC-06 + **手动验证**: 侧栏能进入技能中心,不依赖本地 config.js 里的开关 + **依赖**: T038, T044 + +- [x] **T046**: 【+】菜单去掉预览分叉 + **文件**: `src/frontend/client/src/components/Linsight/Input/PlusMenu.tsx`,`src/frontend/client/src/components/Chat/Input/ChatKnowledge.tsx` + **逻辑**: 去掉按 `skillCenterPreviewEnabled` 分叉的两套技能菜单。桌面和移动宽度都使用 T043 的勾选菜单。不改知识库菜单的其他行为。 + **测试**: T036、T038 全部通过 + **覆盖 AC**: AC-26 + **手动验证**: 桌面和移动宽度下,【+】里的技能行可以勾选,菜单不关闭 + **依赖**: T036, T038, T043, T044 + +- [x] **T047**: 上传、删除和缺文件的三语文案 + **文件**: `src/frontend/client/src/locales/zh-Hans/translation.json`,`src/frontend/client/src/locales/en/translation.json`,`src/frontend/client/src/locales/ja/translation.json` + **逻辑**: 补齐 T041、T042 新出现的句子。删除确认句的中文用 AC-20 的原文,`{名称}` 为展示名。失败句为「删除失败,请重试」。三语键同时补上。不改 `api_errors`。 + **覆盖 AC**: AC-20, AC-21 + **依赖**: T041, T042 + +--- + +## AC 追溯 + +| AC | 测试任务 | +|----|----------| +| AC-01 | T014, T024 | +| AC-02 | T014, T024, T038 | +| AC-03 | T008 | +| AC-04 | T008, T010 | +| AC-05 | T006, T014 | +| AC-06 | T020, T024, T037, T038 | +| AC-07 | T020, T037 | +| AC-08 | T022, T024, T026 | +| AC-09 | T018, T020, T028, T032 | +| AC-10 | T006, T018 | +| AC-11 | T020, T026, T028, T030 | +| AC-12 | T006, T012, T020, T026, T028 | +| AC-13 | T014, T024 | +| AC-14 | T012, T014, T016, T024 | +| AC-15 | T018, T028, T032 | +| AC-16 | T008, T014, T034 | +| AC-17 | T014, T016, T018 | +| AC-18 | T016 | +| AC-19 | T018 | +| AC-20 | T037 | +| AC-21 | T022, T037 | +| AC-22 | T022, T037 | +| AC-23 | T008 | +| AC-24 | T014 | +| AC-25 | T030 | +| AC-26 | T020, T030, T036 | + +--- + +## 实际偏差记录 + +> 只留一行指针,论证在 design.md。推翻已确认的决策时,先停下与用户重新确认。 + +- 输入框菜单读技能中心里已启用的个人技能和企业技能,不读 `/candidates`。候选接口在同名时只留个人技能,菜单需要两行(AC-26)。提交的 `skill_ids` 仍交给装载,装载按候选规则同名只拷个人包。 diff --git a/features/v3.0.0-beta3/076-personal-skill-center/unimplemented.md b/features/v3.0.0-beta3/076-personal-skill-center/unimplemented.md new file mode 100644 index 0000000000..02320721c8 --- /dev/null +++ b/features/v3.0.0-beta3/076-personal-skill-center/unimplemented.md @@ -0,0 +1,190 @@ +# 个人 Skill 上传与技能中心:未完成功能与实现方案 + +对照《0930 中粮需求响应》「个人 Skill 上传与技能中心」,以及已合入当前分支的 [PR 2501](https://github.com/dataelement/bisheng/pull/2501) 前端预览。技能中心页面以这个预览为准,见 [design.md](./design.md) 决策 6。 + +需求口径见 [spec.md](./spec.md),实现决策见 [design.md](./design.md)。本文只记录相对需求仍未落地的部分。 + +页面预览已经合入。个人技能仍只存在当前浏览器。要落到服务端、换设备还能用、并进入任务自动匹配的部分都还没有实现。 + +Kimi 创建、Office 转技能、个人技能分享和公开发布,需求已明确不在本期,不列入下面的未完成项。 + +## 当前影响:输入框里点技能进了详情,不能勾选 + +点开详情是技能中心里的动作:点击整张卡片打开右侧详情。输入框【+】菜单里点一行,仍是为本次任务勾选或取消,和原来的「添加技能」一样。菜单里名称或说明被截断时,只在悬停或聚焦时于右侧展开全文。 + +预览把这两处做成了同一件事。`SkillMenuPanel` 点一行会跳到技能中心并打开详情,同时藏起原来的 `SkillSelector`。任务只装载输入框上方已经挂着的技能名(`materialize_session_skills`)。菜单不能勾选之后,任务只拿得到管理员设成默认勾选、且用户没有摘掉的技能。 + +已定:输入框恢复勾选。点一行保持菜单打开,选中后挂到输入框上方,再点一次取消。名称或说明被截断时,悬停或聚焦只在右侧展开全文。进入详情只从技能中心的卡片进入。预览里 `SkillMenuPanel` 的跳转要改回来。 + +## 未完成 + +### 保存与归属 + +- 按登录身份把文件、元信息和所有者写到服务端。现在写入 IndexedDB,换浏览器或换电脑就没有了。 +- 用稳定技能 ID 做新增、更新、详情、读取和删除。现在的个人技能 ID 是浏览器本地生成的。 +- 保存成功才算添加成功。现在校验通过后写进本机就提示成功。 +- 保存中断后先向服务端查询再决定重试;失败重试复用同一次导入标识。做法见 [design.md](./design.md) §7.4,验收 AC-24。 +- 服务端按实际字节校验:上传 50 MiB(11052)、解压后 200 MiB(11059)、`SKILL.md` 1 MiB(11073)、最多 1000 项(11074)、路径不得越界。现在只在浏览器里检查。11052 和 11059 与企业上传共用,译文改为带 `{{limit_mb}}`,每处抛错都要带上实际上限。做法见 [design.md](./design.md) §6。 +- 加密压缩包给出单独原因。现在只报「压缩包读取失败」。损坏、缺少 `SKILL.md`、路径越界、0 字节和多技能包用 11067、11068、11069、11071、11072,不共用 11051,也不占用已有文案的 11060 和 11070。做法见 [design.md](./design.md) §7.5,验收 AC-23。 +- 按需求列出的 MIME 类型校验:Markdown 接受 `text/markdown`、`text/plain`;ZIP 接受 `application/zip`、`application/x-zip-compressed`;另接受 `application/octet-stream` 或空值。实际内容须与所选格式一致。做法见 [design.md](./design.md) §7.5,验收 AC-23。`.skill` 与 ZIP 使用同一组 MIME。 + +### 权限与隔离 + +- 个人技能只对当前租户的上传者本人可见、可改、可调用。 +- 其他人用 ID 访问时按不存在处理,响应里不出现名称、正文、附件,也不暴露这条技能是否存在。 +- 客户端传来的所有者和来源以服务端登录身份为准。 +- 管理操作记审计日志:操作者、租户、技能 ID、来源、版本和结果,不记录正文和凭据。做法见 [design.md](./design.md) §7.1,验收 AC-17。 +- 管理端的技能列表、启停、默认勾选和删除不能看到或改到个人技能。需求把个人技能的管理员浏览列为后续独立需求。 +- 开放接口的任务模式按名字读取本租户已启用技能(`task_mode_service.py`)。个人技能落库后,不能经这个接口被其他调用方选用。 + +现有技能权限只到租户和管理员:管理接口要求租户管理员;已登录用户通过 `GET /api/v1/linsight/skill/selectable` 能看到本租户全部已启用技能。创建时写入的 OpenFGA 所有者记录不参与使用鉴权。 + +### 更新、启停、删除的生效 + +- 更新保留稳定 ID 和启停状态,新包校验并保存成功后整体切换;失败继续用旧版本。 +- 并发更新校验原版本,过期时提示「该 Skill 已更新,请刷新后重试」。现在这条提示只对同一浏览器里的本地记录有效。版本用内容哈希,不用更新时间。做法见 [design.md](./design.md) §7.2,验收 AC-18。 +- 启停和删除的重复提交返回当前结果,不报错。做法见 [design.md](./design.md) §7.3,验收 AC-19。 +- 停用后不再进入新任务、恢复和重新规划的候选。已经返回的内容留在当前步骤;会话目录里的副本留到任务结束。下一步 `read_file` 和代码执行器执行前再查一次,已停用则拒绝。做法见 [design.md](./design.md) §7.8,验收 AC-09。 +- 删除成功后立即从技能中心和之后的候选中移除。已装进本次任务会话目录的文件留到该任务结束。正在跑的任务读会话自己的副本,删除和覆盖时直接回收旧对象,做法见 [design.md](./design.md) §7.8,验收 AC-15。 +- 删除确认文案改为需求原文:「删除‘{名称}’后,后续任务将无法使用该 Skill,已有对话和生成结果保留。」按钮为【取消】【删除】,失败提示「删除失败,请重试」。预览现在写的是从当前浏览器删除,按钮是「删除技能」。做法见 [design.md](./design.md) §7.6,验收 AC-20。 + +### 技能中心与自动调用 + +- 服务端提供两个读取方法,共用归属条件。技能中心列已启用的企业技能和本人全部个人技能(含停用);任务候选只取已启用的企业技能和已启用的个人技能。现在企业技能来自 `/api/v1/linsight/skill/selectable`,个人技能由前端另查本地库再拼在一起。做法见 [design.md](./design.md) 决策 3。 +- 每次任务把这份候选整包拷进会话:本租户已启用的平台技能,加上本人已启用的个人技能。勾选只在提示词里强调「用户选中了这个技能」,未勾选的同样拷入整包。个人技能现在不进入任务。 +- 新建任务按当时的候选整包拷入,并重新生成 “Available Skills”。同一次会话的恢复和继续提问不重写检查点里的名单。已经返回的内容留在当前步骤;下一步读取和代码执行前再查启停和删除。做法见 [design.md](./design.md) §7.8,并与 AC-09、AC-15 一致。 +- 技能中心仍同时列出同名的平台技能和个人技能,并标来源。两份都已启用时,输入框菜单分两行,不按名字藏掉企业技能,任务目录只装载个人技能。个人技能停用或删除后,输入框只显示企业技能那一行,新任务装载企业技能。做法见 [design.md](./design.md) §7.7,验收 AC-26。 +- 依赖的工具或执行环境缺失时,任务中说明「该技能需要的工具当前不可用」。做法见 [design.md](./design.md) §7.8,验收 AC-25。 +- 个人技能文件缺失时,详情里说明原因并提供「更新技能」。做法见 [design.md](./design.md) §7.6,验收 AC-21。 +- 当前打开的技能被停用、撤权或删除时,提示原因并返回列表,保留其他技能。用户停用自己的个人技能时留在详情里。本期的撤权就是管理员停用。做法见 [design.md](./design.md) §7.6,验收 AC-22。预览里地址指向不存在的技能时,详情直接不打开,没有提示。 + +- 技能中心页面已定,见 [design.md](./design.md) 决策 6 和 `spec.md` AC-06、AC-07、AC-12。正式实现保留现在的「我的技能」列表(「我创建的」「企业提供」两组)和「技能广场」卡片,只把数据源从浏览器换成服务端。 + +## 实现方案 + +个人技能加在现有 `linsight_skill` 上,不新建一套表,也不再把 IndexedDB 当正式存储。 + +### 已定:平台技能按租户授权 + +平台技能的使用范围定在租户。租户管理员启用后,该租户每个登录用户都能用;停用后,该租户谁都不能用。本期不加按人或按部门的授权。 + +创建时写入的 OpenFGA 所有者是上传的管理员,不作为使用权限。拿它做使用校验会把同租户的其他用户全部挡住。 + +和工作台里另外两个开关分开: + +- **启用 / 停用**决定这个技能能否被本租户使用、能否进入候选。 +- **技能入口**(`skillEntry.enabled`)只决定输入框里出不出现技能菜单。 +- **默认勾选**只决定新任务要不要预先挂上,用户可以摘掉。 + +平台技能的正文和附件继续只给租户管理员。普通用户在技能中心只看名称、用途说明和来源。按人或按部门分配平台技能另行立项。 + +### 已定:输入框勾选,技能中心进详情 + +输入框【+】里的技能列表用来勾选本次任务。点一行不关闭菜单、不跳转:未选中则挂到输入框上方,已选中再点一次则取消。勾选结果随请求提交,用来在提示词里强调,不是唯一的装载名单。未勾选的候选技能仍会带上,见下文「已定:不选也会带上」。 + +底部的【上传技能】【管理技能】保留。管理技能打开技能中心。技能中心里点击整张卡片才打开右侧详情。 + +预览当前点一行会跳到技能中心详情,这与本决定不符,实现时改回勾选。 + +### 已定:不选也会带上,选中的在提示词里强调 + +每次任务都带上当前用户的候选:本租户已启用的平台技能,加上该用户已启用的个人技能。同名时只带个人技能。不另调一个模型来生成名单。这些技能的名字和用途说明写进同一次任务的 “Available Skills”,由任务模型自己判断要不要读 `SKILL.md`。 + +用户在输入框勾中的技能,在这段提示里单独标明「用户选中了这个技能」。默认勾选且用户没有摘掉的,也算选中。用户没勾的候选仍然出现在名单里,但不加这句强调。这句由平台在贴名单时按会话上的勾选追加,不写进 `SKILL.md`;现有 `SkillsMiddleware` 读不到勾选,要在外面包一层。整包要在建代码执行器之前拷入,否则 E2B 拍下的文件清单里没有技能包。做法见 [design.md](./design.md) §7.8。 + +现在的装载只拷贝请求里点名的技能整包。实现时改为在任务开始时把每个候选的整包都拷进会话。用户勾中的在提示词里标明「用户选中了这个技能」,未勾选的不加这句。停用、删除和他人的个人技能在装载前剔除。同名时只拷个人技能的整包,见下一节。 + +### 已定:同名时个人技能覆盖企业技能 + +「我的技能」的两个分组都保留同名技能,用来看来源。任务目录里不同时保留同名的两份文件。 + +同名去重只在任务候选里做。该用户有同名且已启用的个人技能时,只把个人技能的整包拷到 `/skills/{名字}/`。同名的企业技能不进入这次任务,提示词里也只有个人技能这一条。该用户没有同名个人技能,或同名个人技能已停用、已删除时,仍把企业技能的整包拷进这次任务。企业技能本身不删除,其他没有这份个人技能的用户照常使用。 + +用户勾选了这个名字时,对留下的个人技能标明「用户选中了这个技能」。勾的是企业技能卡片、但被个人技能覆盖时,同样强调留下的个人技能。 + +### 已定:个人技能配额 + +每个用户在当前租户最多 20 个个人技能,上传文件合计最多 200 MiB(209715200 字节)。容量按上传字节计,不是解压后的大小。停用的个人技能仍占用个数和容量,删除后释放。企业技能不计入。 + +超出时拒绝这次新增,不删除已有技能。当前没有服务端上的个人技能,没有存量需要处理。上传前展示「已用个数 / 20」和「已用容量 / 200 MiB」。个数超出为 11061,容量超出为 11062。 + +### 已定:同名再次上传先提示,确认后再覆盖 + +同一用户再次上传与自己已有个人技能相同的标识时,先提示该技能已存在,不写入。用户确认覆盖后,用新文件替换,保留原技能 ID 和启停状态。未确认则保留旧文件。个数不变;合计容量按新文件重算,替换后超过 200 MiB 则拒绝覆盖。 + +与企业技能同名不在这里覆盖企业技能。「我的技能」的两个分组仍然都列出;任务里按已定规则只放个人技能。 + +### 已定:运行中删除与临时文件 + +删除立即成功。技能从技能中心和之后的候选里消失。新任务、恢复和重新规划不再装载它。已经返回给模型的内容留在当前步骤。会话目录里的副本留到该任务结束,不靠删这些文件来停用。下一步 `read_file` 读 `/skills/{名字}/` 之前,以及代码执行器执行之前,再查一次,已删除则拒绝。装载时在会话版本上记下每个目录对应的技能 ID 和归属用户,读取和执行前按这一行判断。正在跑的任务读的是会话自己的副本 `workspace/{会话}/skills/`,不再回到技能对象,所以这次请求里直接删掉对象。 + +取消上传、校验失败、同名未确认覆盖,都不写入对象存储。校验在内存中完成。请求结束时删除本次解压用的临时目录,不按天数保留。进程崩溃留下的、没有技能记录的对象由后台清扫删除。 + +### 动工前还要定下的事 + +无。 + +### 1. 归属与数据 + +表上增加 `owner_user_id`。`0` 表示现有租户技能(内置、管理员上传),非 0 表示该用户的个人技能。`created_by` 继续只表示创建人。用 `0` 而不用空值:MySQL 唯一索引里空值互不相等,挡不住平台技能重名。 + +唯一键从「租户 + 技能名」改为「租户 + 归属用户 + 技能名」,这样个人技能可以和平台技能、其他人的个人技能同名。 + +迁移要同时兼容 MySQL 和 DM8:先确认存量行无同租户重名,把存量行的 `owner_user_id` 填 `0`,再替换唯一约束 `uq_linsight_skill_tenant_name`。 + +只改唯一键不够。下面这些地方现在都只按「租户 + 名字」定位,都要加上归属条件: + +- `LinsightSkillDao` 的 `get_by_name`、`set_enabled`、`set_default_checked`、`delete_by_name`,以及 `list_enabled`、`get_page`。批量更新不经过租户过滤,必须显式带上租户和归属条件,和现有 `set_enabled` 显式带租户的做法一致。 +- `SkillService._check_duplicate` 还要求整个租户内展示名唯一,这会挡住与平台技能同名的个人技能。重名检查改为只在同一归属范围内进行。 +- 管理端接口固定只操作 `owner_user_id = 0` 的行,旧客户端的请求和响应不变。 + +### 2. 对象存储 + +现在的对象键是 `linsight/skills/{租户}/{名字}/{哈希}.zip`,删除按 `linsight/skills/{租户}/{名字}/` 前缀批量删除。个人技能若沿用这个前缀,用户删掉自己的技能,会连同同名的平台技能和别人的同名个人技能一起删掉。 + +也不能在原前缀下加一层 `u{用户}`:`u123` 本身就是合法的技能名,同样可能撞前缀。 + +个人技能放到独立前缀: + +```text +linsight/personal-skills/{租户}/{用户}/{名字}/{哈希}.zip +``` + +租户技能保留原路径,存量对象不搬。`SkillStore` 的对象键、本地缓存目录、读取和删除都按归属选择对应前缀。 + +### 3. 上传与候选目录 + +个人技能单独开路由,用普通登录用户的鉴权,不复用要求租户管理员的管理接口。上传复用 `SkillStore` 现有的解包和路径校验,但使用固定上限:50 MiB、解压 200 MiB、`SKILL.md` 1 MiB、1000 项,不读管理员那套更大的系统配置。 + +登录用户写入 `owner_user_id`。同一用户再次上传相同标识时,先提示重复、不写入;用户确认覆盖后替换文件,保留原 ID 和启停状态。文件、元信息和归属都写完才返回成功。 + +两个读取方法共用同一套归属条件。平台技能按已定的租户粒度放进来,不再按用户过滤: + +- 技能中心:`owner_user_id = 0` 且本租户已启用的平台技能,加上 `owner_user_id = 当前用户` 的全部个人技能,停用的也列出并返回启停状态 +- 任务候选:`owner_user_id = 0` 且本租户已启用的平台技能,加上 `owner_user_id = 当前用户` 且已启用的个人技能。输入框菜单、任务装载、开放接口的任务模式都读这一份 + +访问他人的个人技能时按不存在处理,返回 11053,不返回 11054,以免暴露这条技能存在。与 `design.md` 第 6 节、`spec.md` AC-08 一致。 + +个人技能的单条操作已定,见 [design.md](./design.md) 决策 5 和 `spec.md` AC-08。先查出当前租户的这一行;归属不是本人就返回 11053,不调用 `require_business_action`。归属是本人,再调用统一授权,资源类型仍是 `linsight_skill`。列表和任务候选继续按归属做一次查询,不逐条授权。企业技能的使用不读创建时的所有者记录。 + +### 4. 任务装载与自动匹配 + +已定,不另做一次模型调用。每个候选在任务开始时把整包拷进 `/skills/{名字}/`,名字和用途说明进入同一次任务的 “Available Skills”。用户勾中的技能(含默认勾选且未摘掉的)在提示词中标明「用户选中了这个技能」。未勾选的同样拷入整包,只是没有这句强调。 + +同一用户的个人技能与企业技能同名时,只拷个人技能的整包,企业技能不进入这次任务的目录,提示词里也只列出个人技能。企业技能记录保留,供没有这份个人技能的其他用户使用。用户勾选了这个名字时,对留下的个人技能加「用户选中了这个技能」。 + +装载时在会话版本上记下每个目录对应的技能名、技能 ID、归属用户和内容哈希。新建任务重新取候选并整包拷入。同一次会话恢复和继续提问时,已有的技能目录不写入同名的另一份技能;读取和执行前按快照里的技能 ID 判断,不按目录名判断。“Available Skills” 写入检查点的 `skills_metadata`,同一次会话恢复和继续提问不再扫描磁盘。已经返回的内容留在当前步骤。下一步 `read_file` 和代码执行器执行前再查启停和删除,已停用或已删除则拒绝;本地执行直接打开会话目录,只拦 `read_file` 不够。开放接口的任务模式改为读取任务候选,个人技能只对其所有者生效。 + +### 5. 运行中删除与临时文件 + +已定。删除或停用后,立即从之后的候选中移除。新任务、运行恢复和重新规划不再装载。已经返回的内容留在当前步骤。会话目录里的副本留到该任务结束。下一步读取和代码执行前再查一次,已停用或已删除则拒绝。删除时直接删掉对象,覆盖时删掉这一行不再指向的旧对象;正在跑的任务读的是会话自己的副本。同一次会话恢复时,已有的技能目录不写入同名的另一份技能,快照记下目录对应的技能 ID 和归属用户。 + +取消或失败的导入不写对象存储。请求结束时删除解压临时目录。没有技能记录的残留对象由后台清扫删除,不设面向用户的保留时限。 + +### 6. 前端 + +去掉 IndexedDB 预览库和预览开关,技能中心改为调用上述接口。页面结构留下现在的实现:「我的技能」是「我创建的」「企业提供」两组列表,「技能广场」是企业技能只读卡片。搜索、排序、上传弹窗、启停和删除留下,数据源换成服务端。 + +输入框里的技能列表按已定口径恢复勾选,不跳转详情。两份都已启用时同名的企业技能和个人技能各一行;个人技能停用或删除后只剩企业技能那一行。进入详情只从技能中心的卡片进入。审计、版本、导入重试、加密包、删除文案和工具不可用的做法见 [design.md](./design.md) §7,这里不另写一套。 + +个数和容量按已定额度展示:已用个数 / 20,已用上传容量 / 200 MiB。停用的个人技能仍计入,企业技能不计入。超出时拒绝新增。 diff --git a/features/v3.0.0-beta3/release-contract.md b/features/v3.0.0-beta3/release-contract.md new file mode 100644 index 0000000000..d7d8b0ce86 --- /dev/null +++ b/features/v3.0.0-beta3/release-contract.md @@ -0,0 +1,51 @@ +# Release Contract — v3.0.0-beta3 + +> 本文件是 v3.0.0-beta3 的领域归属与全局约束。spec 动笔前先读本文件。 +> 编号从 v3.0.0-beta1 契约的 F075 续编,不在本版本内从 001 重开。 + +--- + +## 表 1:领域对象归属 + +| 领域对象 | Owner Feature | 说明 | +|---------|--------------|------| +| 个人技能(`linsight_skill` 中归属到用户的行,及其对象存储包) | F076-personal-skill-center | 创建、替换、停用、删除只由技能服务写入。任务执行只读取并物化 | + +**规则**:非 Owner 只读取或调用 Owner 的 Service,不在自己的 AC 里创建、修改、删除对方的对象。 + +--- + +## 表 2:跨 Feature 不变量(INV-N) + +| ID | 不变量描述 | 涉及领域对象 | 来源 spec | +|----|-----------|------------|---------| +| INV-1 | 个人技能只对所属用户可见、可调用;同租户其他用户、其他租户都不可读 | 个人技能 | F076 | +| INV-2 | 任务自动选用的候选集 = 该用户已被授权的平台技能 ∪ 该用户自己已启用的个人技能 | 个人技能 | F076 | + +--- + +## 表 3:Feature 依赖图 + +| Feature | 依赖(必须先完成) | 说明 | +|---------|-----------------|------| +| F076-personal-skill-center | 无(本版本内) | 落在既有灵思技能表与对象存储之上 | + +--- + +## 已分配模块编码(MMMEE) + +本版本不新开模块段。 + +| 模块编码 (MMM) | 模块 | Owner Feature | +|----------------|------|---------------| +| 110 | linsight(技能段 11051–11059 已占用;11060、11070 保留既有文案。个人技能新增为 11061–11069、11071–11074,段位延伸到 11079。11080 已占用) | F076 | + +--- + +## 变更历史 + +| 日期 | 变更内容 | 影响范围 | +|------|---------|---------| +| 2026-10-14 | 建立契约,登记 F076 | 本文件 | +| 2026-10-20 | 本版本不再纳入实时语音识别 | 本文件 | +| 2026-10-19 | F076 个人技能错误码从 11061 起,跳过已有文案的 11060、11070,段位延伸到 11079;11073、11074 为 `SKILL.md` 过大和条目过多 | 本文件 | diff --git a/features/v3.0.0-beta5/work-knowledge-tool/spec.md b/features/v3.0.0-beta5/work-knowledge-tool/spec.md new file mode 100644 index 0000000000..bb32b020ae --- /dev/null +++ b/features/v3.0.0-beta5/work-knowledge-tool/spec.md @@ -0,0 +1,24 @@ +# 内置知识检索工具登记 + +## 范围与验收 + +把已有鉴权检索能力接入平台通用工具执行器,工具键为 `bisheng_knowledge_search`,显示名称为“知识库检索”。桌面 DSH 继续通过现有登录代理调用 `/api/v1/knowledge/space/retrieve`;云端工具在服务端调用同一检索服务。 + +- AC-01:内置工具种子目录包含名称、说明及 query / space_ids 输入;已有数据库通过幂等登记脚本补齐。 +- AC-02:同步和异步通用执行入口绑定真实调用用户与当前租户;模型参数只包含查询和资源 ID。 +- AC-03:每次检索复用空间、知识库、文件权限及主版本过滤;权限异常向调用方传播。 +- AC-04:返回与桌面 API 相同的 chunks 字段及文档来源;空结果返回空数组。 +- AC-05:保留原任务专用 search_knowledge_base 和桌面适配器。通用工具的登记与新云端 Work 调用端接线分别验收。 + +## 实现决定 + +使用平台 ToolExecutor 的受信执行上下文,完整身份只在服务端生成。工具与 HTTP 接口共享结果序列化服务。沿用既有工具 use 权限检查与执行审计。已有部署的数据登记走人工运维脚本,默认预览,显式 --apply 执行,随后同步系统工具权限投影。执行检索要求当前租户与构造时的租户一致。 + +## 验证记录 + +- 基线:`feat/bisheng-work-120-main@789850929`。 +- AC-01:种子和存量脚本覆盖;3006 登记工具 40 / 分类 18,F048 投影 CURRENT;登记默认预览和重复执行保持现有配置的回归通过。 +- AC-02、AC-03、AC-04:59 项定向回归及 8 子测试通过,覆盖执行身份、租户、参数边界、空间/文件可见性、主版本及来源格式。3006 经通用执行器实际检索返回 8 片段及来源;实测身份为管理员,普通用户真库验收待补。 +- AC-05:桌面 HTTP 路径保持现有登录态入口;原任务专用工具保持现有实现。云端 Work / MCP 调用方接线由独立集成工作负责。 +- 3006 发布:`knowledge-tool-20261022-221700`,运行中任务为 0 后重启,健康检查通过;基于当前发布增量更新 7 文件,保留其他会话已发布的前端和配置。 +- Ruff:新增代码及工具执行器通过;endpoint 的 4 项既有 Ruff 报告与基线一致。架构检查与 diff 空白检查通过。 diff --git a/features/v3.0.0/000-prd1-discovery/baseline-recheck.md b/features/v3.0.0/000-prd1-discovery/baseline-recheck.md new file mode 100644 index 0000000000..a419b3c7d8 --- /dev/null +++ b/features/v3.0.0/000-prd1-discovery/baseline-recheck.md @@ -0,0 +1,79 @@ +# F049 基线核实速查(3.0-vibe / feat/3.0.0-beta1,含 F048) + +> 2026-08-06 三路并行代码核实的浓缩结论,供 F049 design 与 F050 spec 直接引用。 +> 上一轮 `research/` 11 份是在 **main(pre-F048)** 上做的,与本基线相差 242 个后端文件; +> 本文件覆盖其中与 F049/F050 相关的三块,**冲突时以本文件为准**。 +> 全部为代码核实;标「推断」的除外。行号对应 3.0-vibe rebase 前的 `584f2c253`,rebase 后内容未变。 + +--- + +## 一、v2 开放 API 端点盘点(改造工作量基数) + +**44 个端点**(不是 45/50+),8 个路由文件,全部零鉴权。`router_rpc`(`api/router.py:94-102`)构造时**没有 `dependencies=`**,8 次 `include_router` 也没带 —— 加 router 级依赖技术可行、零冲突。挂载点 `main.py:285`。 + +按身份来源分类(这是逐端点改造的真实基数): + +| 身份来源 | 数量 | 改造性质 | +|---|---|---| +| `Depends(get_default_operator_async)` | 9 | 最轻,换依赖即可(knowledge.py 全部 8 个 + citation.py:23) | +| 函数体内联 `get_default_operator*()` | 25 | 中等,需改函数体;其中 4 个结果被丢弃、仅用于播种租户上下文 | +| `resolve_operator(裸 user_id)` 代用户 | 3 | 重,需接受限委托准入(filelib.py:157/362/623) | +| 裸 user_id 直写 | 1 | 重(chat.py:80 `/sync/messages`,连用户存在性都不查) | +| **完全无身份** | **6** | 重,函数体内无任何调用者信息可用 | + +**6 个无身份端点**:`chat.py:23/31/48/60/74`(gen_title / history / liked / solved / comment —— 任何人可改任意会话)+ `filelib.py:496`(`download_statistic`,见下)。 + +`filelib.py:496-506` 的 `download_statistic_file(file_path: str)`:只校验后缀为 `.log` 且路径以 `/app/data` 开头(含一个 `.` 字符检查),随后 `FileResponse(file_path)` —— **调用方可指定路径取走服务器上任意 `.log`**,无身份无归属校验。 + +**三个身份解析函数**(`open_endpoints/domain/utils.py`):`get_default_operator` :26-50 / `get_default_operator_async` :53-74 / `resolve_operator` :77-104,三者末尾各自 `set_current_tenant_id`(:49/:73/:103)。`resolve_operator` 的租户种子来自**调用方指定的目标用户**(:96-97)。全仓 `open_endpoints/` 之外零调用者 → **这是唯一入口,是最省工的改造点**。 + +**两层改造方案(推断,需实测)**:① router 级 `dependencies=[Depends(verify_credential)]` 保证「无凭据 401」对 44 个端点无死角;② 新依赖把解析出的 UserPayload 写进 ContextVar,再改上述三个函数「先读 ContextVar,读不到才按开关决定回落或 401」→ **34 个端点零改动**完成身份切换,真正要单独动刀的只剩 10 个(2 个 WS + 6 个无身份 + 3 个代用户,有重叠)。 + +**易漏点**:`flow.py:8` 是从 `endpoints.assistant` 转导入 `get_default_operator`,不是从 `domain/utils` 直取;`api/dependencies.py:61-77` 的 `get_knowledge_space_chat_service_for_openapi` **全仓无调用者**(死代码,只能当样板)。 + +**前端 v2 依赖面**:`ApiAccess.tsx:38/57/111`、`ApiAccessFlow.tsx:53/78/82`、`client/src/api/chat/api-endpoints.ts:51`。另 `chatShare.tsx:25` 引用的 `/api/v2/chat/ws/{flowId}` **后端不存在**(前端死路径)。 + +### ⚠️ 两个 WS 端点同时是平台免登录分享页的通道 + +- `workflow.py:157` `/api/v2/workflow/chat/{workflow_id}`:JWT 校验被注释在 :165-166,docstring :162 写 "Use Exempt Login Link" ← `chatShare.tsx:16` 在连 +- `assistant.py:298` `/api/v2/assistant/chat/{assistant_id}`:无鉴权,且 `get_default_operator()` 在 :304 位于 try(:305) **之外** → 配置缺失时握手裸崩 ← `chatAssitantShare.tsx:11` 在连 +- 路由注册:`platform/src/routes/index.tsx:24-25` + +浏览器原生 WebSocket **无法设置请求头** → 分享页不可能携带密钥。强制鉴权 = 已发布分享链接全部失效。这是 spec §4 待澄清-1 的事实基础。 + +WS 握手读 Authorization 的可行性:FastAPI 的 `WebSocket` 对象自带 `.headers`,新依赖直接读即可,**不需要改中间件、不需要改 AuthJwt**。WS 中间件(`http_middleware.py:396-412`)已在读 `scope["headers"]`,只是当前只取 cookie。 + +--- + +## 二、身份体系与 User 表(凭据鉴权的接入面) + +- **JWT**:`AuthJwt`(`auth.py:129-193`),HS256,cookie 名 `access_token_cookie`,`path=/`、`domain=None`、`httponly=True`、`secure=False`、`samesite=None`(`settings.py:487-499`)。payload 只有 `sub`/`exp`/`iss`,**无 jti/iat/typ**。 +- **v1 端点只认 cookie**:`get_subject` 的 `auth_from="headers"` 分支存在(`auth.py:178-180`)但**全仓零调用方**,且有 NPE 缺陷(缺 header 时 `.split()` 于 None)。→ **凭据鉴权必须新写依赖,不能复用它。** +- **中间件会先看到 Bearer 头**:`_extract_http_access_token`(`http_middleware.py:60-73`)cookie 优先、其次 Bearer,会尝试当 JWT 解码;`bs-sak-` 解不开返回 None,不 500,但**也不会注入租户上下文** → 凭据依赖必须自己 `set_current_tenant_id`。 +- **`/api/v2/*` 不在中间件豁免清单**,但因无 JWT,token_version 校验/禁用检查/租户状态检查全部跳过。 +- **身份构造**:`init_login_user`(`auth.py:531-565`,async,**推荐入口**)≈ 1 次 DB(user_role)+ 1 次 Redis(is_super);支持 `role_ids=` 注入省掉 DB。⚠️ `LoginUser.__init__`(:219-232)在未传 `user_role` 时有**同步 DB 副作用**,async 上下文会阻塞事件循环 —— 别直接 `LoginUser(user_id=...)`。 +- ⚠️ **`init_login_user_sync`(:567-589)不计算 `is_global_super`(恒 False)**,而 F048 的 `resolve_permission_actor` 正是读这个字段判超管短路。当前多数 v2 端点走 sync 路径 → 同一个 default_operator 在 sync/async 两条路径下的 F048 判定结果不同。统一走 async 会**放宽**这些端点的现有权限(spec §4 待澄清-2)。同类隐患:`tool/domain/services/executor.py:96-105` 自造 UserPayload 也不带该字段。 +- **User 表无 `user_type`**(全仓 grep 零命中)→ 服务账号主体需 Alembic 加列(既有表加列必须走 revision)。 +- **不可登录性半免费**:`UserDao.aget_login_candidates_by_account`(`user.py:211-223`)是 `/user/login` 唯一候选查询,条件只有 `delete == 0 AND external_id == acc` —— **user_name 根本不参与匹配** → `external_id IS NULL` 的服务账号结构性进不了密码登录。(附带:`user.py:425` 注释「支持用户名或 external_id」与实现不符,是过期注释。) +- **`/user/list` 有统一过滤入口**:`UserDao._filter_users_statement`(`user.py:259-264`),被 `filter_users`/`afilter_users` 共用 → 加一个默认 `user_type == human` 条件即可覆盖两个消费点。**不经过它**的查询需单独处理:`get_all_users`(:400)、`search_user_by_name`(:295)、`get_user_with_group_role`(:411)、`get_user_by_ids/aget_user_by_ids`(:177/:183)、`get_unique_user_by_name`(:289)。 +- **禁用传导挂接点仍可用**:`UserService.ainvalidate_jwt_after_account_disabled`(`user.py:70-90`)是唯一收敛点,6 个调用点;但 `user_tenant_sync_service.py:117` 与 `wecom_gateway_absent_reconcile.py:117` **绕过漏斗直连 DAO**。 +- **撤销 ≤5s 该抄的范式**:`UserDao.aincrement_token_version`(`user.py:512-538`)—— 原子 UPDATE + **主动 `aset` 覆盖缓存**(非 DEL),TTL 300s 只是兜底。把 TTL 降到 5s 即满足要求,形状可一比一复制。「按主体批量吊销」可借鉴 `PermissionCache` 的双前缀 SCAN+DEL(`permission_cache.py:112-140`),key 设计成 `cred:{owner_id}:{key_id}` 便于扫。 +- ⚠️ **失败语义分歧(须在 spec/design 显式声明)**:中间件 token_version 校验是 **fail-open**(`http_middleware.py:140-141`,注释「don't lock users out on cache/DB hiccup」);凭据校验属鉴权,必须 **fail-closed**。这是刻意的不一致。 + +--- + +## 三、F048 权限运行时(pre-F048 结论全部作废) + +- **唯一业务入口**:`check_business_action` / `require_business_action` / `batch_check_business_actions`(`permission/application/business_authorization.py:27/46/62`)。`PermissionService` 已降格为身份/LLM 兼容桥,对 F048 业务资源类型直接 raise。 +- **短路只剩两个 allow-all**:super_admin、当前租户的租户管理员(`permission_action_service.py:379-384`)。**Root 租户(id=1)没有租户管理员这一档**(`identity.py:30-37`)。 +- **委托红线该用的谓词**:`resolve_permission_actor(login_user)` → `actor.super_admin` / `actor.tenant_admin_tenant_ids`;仓内已有等价实现 `F048PermissionRuntime._system_authorized`(`runtime.py:1074-1081`)。**不要用 legacy `login_user.is_admin()`**(那是 RBAC AdminRole 位,与 F048 super_admin 不是一回事)。 +- **fail-closed 已是现状**:FGA 异常 → `PermissionFGAUnavailableError`;Catalog 未就绪 → `PermissionPublishNotReadyError`;资源镜像非 CURRENT(含 FAILED_CLOSED)→ 同上;动作未分级 → `InvalidCatalogActionError`;模型 checksum 不符 → 进程标 migration_required、**全站 503**。**无任何 owner/creator 兜底**。 + - 唯一还带 fail-open 味道的:`_check_is_global_super`(`http_middleware.py:181-197`)在 FGA 异常时回落 legacy AdminRole 判定 → 故障时反而可能把人判成超管并命中 allow-all。**这是 D6 残留项的具体锚点。** +- **列表过滤范式**:SQL 出业务候选 → `batch_check_business_actions`(每 100 一批);ListObjects 生产装配 `DenyListObjectsPolicy`(`sql_runtime.py:638-648`),**无生产调用方**。 +- **性能**:F048 路径**完全无缓存**,每次 check ≈ 3 次 SQL + 1~3 次 Redis + 1 次 FGA Check,批量路径每个 target 重复前 3 次 SQL(N+1,推断为风险)。MCP/应用运行时高频检索会放大。 +- **新增资源类型(如 app)要改 12 处后端 + 3 处前端**: + 1. `authorization_model_f048.py:32` `MIGRATED_RESOURCE_TYPES`;2. `:55` `RESOURCE_ACTION_SCOPES`;3. `:80` `PARENT_TYPES`(若有父层级);4. `:93` `SYSTEM_SHARED_ACTION_TYPES`(若要 user:* 共享);5. `catalog_policy.py:31` `MIGRATED_RESOURCE_TYPES`(**必须与 1 同步**);6. `:45` `ACTION_RESOURCE_SCOPES`;7. `:16` `REGISTERED_ACTION_CODES`(若新增动作);8. `resource_lifecycle_policy.py:14-26` `FLEXIBLE_MODE_TYPES`/`FIXED_CUSTOM_TYPES`(否则 `default_permission_mode` 抛错);9. `owner_service.py:34` allowlist(若 system-owned);10. 新建 adapter(照抄 `api/services/f048_application_permission.py:122-225`);11. `f048_permission_runtime.py:166-195` 注册进 registry(**这是 resource_type 的实际白名单**);12. `f048_source_inventory.py:16/30`。 + 前端:`platform/controllers/API/permission.ts:5-15`、`platform/components/bs-comp/permission/types.ts:3-13`(**两处重复定义**)、`client/src/api/permission.ts:3-13`。 +- ⚠️ **存量环境生效缺口(真实、可落地到 spec)**:授权模型本身有 checksum + 503 闸门(比 pre-F048 好),但 **Catalog 的「动作↔资源类型范围表」只在首次迁移与草稿发布时写入**,且 `CatalogChangeType` 枚举**没有「改资源范围」这一种**,迁移脚本又因 checksum 不符无法重跑 → 给存量环境加资源类型**必须新写运维脚本或扩展变更类型**(推断为唯一出路)。 +- **创建即 owner 的现行契约**:adapter 的 `authorize_created`(非 `PermissionService.authorize`)→ protected owner grant + 投影账本(prepare → SQL 预写 → 投影 OpenFGA → finalize)。失败 → 置 `FAILED_CLOSED` → **该资源任何权限判定都 fail closed(资源变砖)**,且**业务行不回滚**(`api/v1/workflow.py:312-318` 先落库再 hook)。人工恢复走 `scripts/reconcile_f048_projection_operations.py`。 +- **存量隐患**:前端三处类型都含 `linsight_skill`,但后端 registry 未注册 → 对它调通用授权 API 直接报 `PermissionInvalidResourceError`。 diff --git a/features/v3.0.0/000-prd1-discovery/discovery.md b/features/v3.0.0/000-prd1-discovery/discovery.md new file mode 100644 index 0000000000..05716b96c7 --- /dev/null +++ b/features/v3.0.0/000-prd1-discovery/discovery.md @@ -0,0 +1,186 @@ +# Spec Discovery — 3.0 应用工场 PRD-1(专业开发者通道与应用运行时) + +> **定位**:SDD 全流程第 1 步(Spec Discovery)的产出。回答三件事:现状代码能承载 PRD-1 多少、缺口在哪、怎么拆 Feature——并汇总必须由用户拍板的关键决策(★ 暂停点)。 +> **输入(v2,2026-08-17)**:《3.0 应用工场 PRD-1 专业开发者通道与应用运行时》**v2.0**(21 项:DEV×6 / RT×7 / GOV×8;§1.4 功能全集、§3.0 全册通用约定〔承载面 platform「构建 → 应用」/ 状态机 / 事件触达〕)+ 伴生《3.0 开放 API 鉴权与身份传递 PRD》**v2.1 草案**(R1–R9)+《3.0 应用工场 产品方案》§4–§5(F100–F113 技术特性)。 +> **方法**:11 维并行代码调研(每维含文件/符号级锚点),完整记录在 [research/](./research/)(11 个维度文件,2026-08-06 于 `main` pre-F048 完成)+ 按 F048 基线重核的 [baseline-recheck.md](./baseline-recheck.md)——spec/design 阶段编写时**必读对应维度文件**,与 baseline-recheck 冲突时以后者为准。 +> +> ⚠️ **本分支(`3.0-vibe`)含未修复安全缺口的行级定位**(research/ 各文件 + 伴生 PRD 附录)。origin 是公开仓——**未经确认不得推送本分支**,也不要把这些内容并入任何会推公开仓的分支。 +> **日期**:2026-08-06 初版(输入 PRD-1 v1.5);**2026-08-17 按 PRD-1 v2.0 重排为拆分 v2** | 状态:**★ 已过(2026-08-17 用户授权全部按建议拍板)**——拆分 v2 与 §4 决策 N1–N6 生效,进入 design / tasks 阶段(MVP 纵切见 [mvp-114-path.md](../mvp-114-path.md)) + +--- + +## 1. 总体结论 + +1. **地基厚、门面缺**。PRD-1 所需的领域能力(权限双层过滤、审批引擎、站内信、标签/广场、模型管理、MinIO 快照、审计)在代码中全部存在且可复用;缺的是四个「对外门面」——**API key 凭据体系、MCP Server、OpenAI 兼容模型面、CLI/SDK**——以及一整块绿地:**托管应用运行时**(per-app 实例编排 / 路由 / 隔离,全仓零先例;v2.0 起还要**双执行后端 compose + k8s** 与 k8s 多节点必需的**镜像构建与分发**)。 +2. **伴生 PRD 是一切的地基**。开发者 key、应用运行期凭据、CLI/MCP/SDK 三面鉴权全部踩在《开放 API 鉴权与身份传递 PRD》R1 统一凭据底座 + R4 服务账号(含资源归属人)上;v2 开放面的鉴权现状与代用户通道存在已核实的安全缺口。**F049 必须首批交付,且不得脱离 F050 单独对外发版**(裸 `user_id` 须随 v3.0.0 移除)。 +3. **检索强度分叉已被代码证实**。工作台双层过滤路径真实存在且能被任意 key 解析身份驱动;旧 v2 `/filelib/retrieve` 只有库级一次校验。PRD-1 DEV-02 / GOV-05 与伴生 §4.4.6 都要求收敛到同一条文件级双层路径 + fail-closed——需要一个**调用面隔离的统一检索门面**(F052 拥有,F050 模式 D / F055 托管运行期 / F057 retrieve 共用)。 +4. **PRD-1 v2.0 已自行消解 v1.5 时的两处「与代码字面冲突」中的一处**:审批处理面落位——§3.0.3 明确「审批类事件进入审批中心待办、管理后台不设消息面、审批人在工作台侧接收」,与现状(处理面只在 client 工作台弹窗、platform 只有场景配置)吻合,原决策 D5 关闭。**另一处仍在**:现有版本机制是可变指针(`change_current_version` 原地切换),与 RT-05「只增不改、终态标注」语义相反——托管应用独立建版本表,不复用 `flow_version`。 +5. **v2.0 把三块「待建能力被当成既有」的错误已在 PRD 侧订正**(存储配额只覆盖知识库文件、用量表无 app/key 维度、新建租户 seed 是新机制),Discovery 侧无需再替 PRD 兜底;但 PRD-1 v2.0 与伴生 v2.1 各仍有**内部残留**(见 §4 N5 / N6),spec 已按较新决议写,需产品回修。 + +--- + +## 2. 现状地图(按 PRD 域,Top 结论) + +详细锚点见 research/ 对应文件;此处只列影响拆分与决策的要点。**每小节末「PRD v2.0 影响」为 2026-08-17 追加**,说明该维度的哪些结论因 PRD 变更而失效、加重或新增。 + +### 2.1 凭据与开放 API(DEV-01 / GOV-08 / 伴生 PRD)→ research: v2-open-api, auth-identity;baseline-recheck §1–§2 + +- **可复用**:合格随机源 `generate_short_high_entropy_string`;恒时比较 + fail-closed 鉴权依赖的唯一正确姿势 `sso_sync/hmac_auth.py`;「非 cookie 凭据 → UserPayload + set_current_tenant_id」收口模式(`get_default_operator_async`);撤销 ≤5s = 照抄 `aincrement_token_version` 的「原子 UPDATE + 主动刷 Redis 缓存」;账号禁用联动挂接点 `ainvalidate_jwt_after_account_disabled`。 +- **缺口**:全仓无任何 ApiKey 表 / bs- 前缀 / require_api_key 配置;v2 约 45 个端点(8 个路由文件)多数**内联调用**身份解析(非 Depends),鉴权改造必须逐端点替换;WS 握手鉴权需改走 Authorization 头(现有查询参数路径不生效,中间件已读 scope headers,可行)。 +- **风险**:v1 端点只认 cookie(headers 分支零调用方)——密钥不能复用 v1 面,必须独立 router;`User` 表无 `user_type` 字段(服务账号主体需 Alembic)。 +- **PRD v2.0 影响**:① 伴生 §4.9 改「平台侧零迁移」——原「默认操作员转型降权闸口」整条作废、`default_operator` 与 `enable_guest_access` 两配置项直接移除;② 6 个 `/chat/*` 端点不暴露(含全清单风险最高的 `sync/messages`),改造基数 44 → 36 HTTP + 2 WS;③ 新增**资源归属人**(服务账号必填自然人,创建关系落归属人 + 回授可编辑)与主体侧授权页 v2.1 形态(单列表 + 来源列);④ 三扩展位 `model:invoke` / `identity:read` / `app:manage` 并入同一张权限位清单(无独立 scope 维度、无独立开发者 key 界面),与 `delegate` 互斥(INV-31);⑤ 应用运行期凭据不作产品概念、无管理入口(由 F055 自动签发 / 回收)。 + +### 2.2 权限与可见范围(GOV-01 / RT-02 过滤 / NFR-1)→ research: permission-rebac ⚠️**已按 F048 基线重核,本节为重核后结论** + +> research/permission-rebac.md 是在 `main`(pre-F048)上做的,其「七级链 + owner/manager/editor/viewer 四档 + FGA 故障降级 owner 兜底」结论**在本基线已全部作废**——`PermissionService` 降格为身份/LLM 兼容桥,对 F048 业务资源类型直接 raise。阅读该文件时以本节为准。 + +- **今天的判定链**:业务侧唯一入口是 `check_business_action` / `require_business_action` / `batch_check_business_actions`;链路 = 解析 actor → 业务 adapter 产出已验证目标 → runtime 检查动作。短路只剩两个 allow-all:**super_admin** 与**当前租户的租户管理员**(`_identity_shortcut` 中 `TENANT_ADMIN` 与 `SUPER_ADMIN` 并列,**批量列表路径同样适用**;Root 租户没有租户管理员这一档)。 +- **fail-closed 已经是现状**:OpenFGA 故障、Catalog 未就绪、资源镜像非 CURRENT、动作未分级——全部 raise 明确错误,**没有任何 owner/creator 兜底分支**。NFR-1.4 在权限侧天然满足(INV-30 只补「部分过滤亦属失败」)。 +- **列表过滤范式变了**:不再用 ListObjects,改为「SQL 出业务候选 → 批量 check 具体动作」,知识文件可见性预过滤就是这个形状。admin 短路仍在且位于策略检查之前。 +- **可复用**:通用授权 API(`/permissions/resources/{type}/{id}/` 下的 grants / mode-drafts / my-permissions 等)+ platform `PermissionDialog` 及其子组件(user / department / user_group 三种主体检索);创建即 owner 走 adapter 的 `authorize_created`(protected owner grant + 投影账本 + `scripts/reconcile_f048_projection_operations.py` 人工续跑)。 +- **缺口**:新增 `app` 资源类型要动 **后端 12 处 + 前端 3 处联合类型**(`ResourceType` 前端硬编码 union);且**存量环境仍有「只写一次」缺口**——授权模型有 checksum 检测 + 全站 503 迁移闸门,但 Catalog 的「动作↔资源类型范围表」只在首次迁移与草稿发布时写入,变更类型枚举里**没有「改资源范围」**,迁移脚本又因 checksum 不符无法重跑。给存量环境加资源类型必须新写运维脚本或扩展变更类型。 +- **风险**:① 委托红线的正确谓词是 runtime 的 system-authorized 判定(super_admin ∪ 当前租户管理员),不是 legacy 的 `is_admin()`;② 身份构造有两条路径,同步路径不计算 super 标记,后台/工具执行路径自造的身份也不带;③ F048 判定**无缓存且每目标 3 次 SQL**,MCP/应用运行时的高频检索会放大;④ 前端三处类型里有 `linsight_skill` 但后端 registry 未注册(存量隐患)。 +- **PRD v2.0 影响**:① `TENANT_ADMIN` 身份短路已被 PRD 吸收——GOV-09「管理后台应用列表」整条取消(列表天然可见)、「租户管理员代调可见范围」是既有行为而非待做功能、「草稿对管理员不可见」取消;② GOV-01 改为**完全复用**卡片 ⚙️ →「管理权限」(`PermissionDialog`),零新建授权界面,本册只需注册 `app` 类型 + 发布面第二触发点;③ 服务账号「不出现在任何选人场景」要求**资源侧授权弹窗的主体选择器**(`grant_subject_service.py`,不走 `/user/list`)同步过滤——伴生附录 E.2 已点名,F049 AC-16 覆盖面含此入口。 + +### 2.3 检索(DEV-02 / GOV-05 / NFR-1.4)→ research: knowledge-retrieval + +- **可复用**:双层过滤引擎可整体复用——完整双层(索引层 + 结果层)现有 2 条链(chat_folder / space_flow_retrieval),仅结果层兜底 2 条链(workstation / citation);知识库清单 `KnowledgeService.get_knowledge`(天然 ⊆ UI 可见范围);Milvus + ES 双召回 + RRF。 +- **缺口**:无「声明白名单」概念;无会话解耦的纯检索门面(`_retrieve_and_filter` 是聊天服务私有方法);`/api/v2/filelib/retrieve` 只做知识资源级一次校验(调用方注释宣称已有 per-user 过滤,**与实现不符**)。 +- **待澄清**:「与工作台检索集合相等」的基准锚点(chat_folder 有索引层预过滤 / workstation 变体没有);type=0 文档知识库无文件级权限模型,「含文件级权限」是否只指 type=3。 +- **PRD v2.0 影响**:① 伴生 §4.4.6 把「检索补文件级过滤」列为 P1 交付前提,PRD-1 DEV-02 ① / GOV-05 均明令「不得复用仅库级校验的旧路径」——统一检索门面成为**三处共用件**(F050 模式 D 集合相等 / F055 托管期「声明白名单 ∩ 当前访问用户」/ F057 retrieve),拆分 v2 定其 owner 为 **F052**;② 本地 dev 与 MCP 面锚定「开发者服务账号被显式授予范围」(决议-4 后的 NFR-1 口径),无个人权限继承。 + +### 2.4 审批(RT-03 / GOV-02)→ research: approval-center;`.claude/skills/approval-module` + +- **可复用**:ApprovalGate 统一网关 + 或签 + 撤回 + 富 JSON payload(detail_snapshot 无 schema 约束)+ outbox + 站内信 + 审计,「应用发布」新场景 = 三件套注册(preset + Gate + handler 工厂分支);`approver_resolver` 已支持 `direct_user` / `department_admin` / `tenant_admin` 三种来源,`node_mode=or` 现成。 +- **缺口**:`approver_resolver` 的 `tenant_admin` 分支**错用系统超管近似**(`AdminRole=1`),多租户下会把所有租户的发布审批压到超管一人;`TenantAdminService.list_tenant_admins` 对 **Root 租户恒返回空**;`_init_default_approval_scenarios` 硬编码默认租户,唯一真实租户创建路径 `tenant_mount_service` 对 approval 零引用;`department_admin` 只查 owner 主部门、不上溯,`applicant_department_id` 须业务入口显式传;`withdraw_instance` 缺终态守卫;审批单详情四分区定制渲染 + 审读视图 / 预览试用入口全新。 +- **PRD v2.0 影响**:GOV-02 改为**平台预置流程、部署即生效**(部门管理员 ∪ 租户管理员或签;单租户回退平台超管;新建租户同步落库;提交人自动跳过;两来源皆空进异常态不放行)——上列三条缺口被 PRD-1 §3.3 锚点表列为**⚠️ 阻塞前置**,全部归 F055;原决策 D5(审批处理面落位)已由 §3.0.3 关闭。 + +### 2.5 模型面(DEV-02 模型协议面 / GOV-04)→ research: model-llm + +- **可复用**:llm_server / llm_model 双表 + Root 共享 + `BishengLLM`(19 服务商、全链路流式)+ 模型收回错误族(LlmModelOfflineError 等)+ telemetry 每调用全覆盖。 +- **缺口**:OpenAI 兼容**入站**端点零现状(现有 `/api/v2/assistant/chat/completions` 是助手外壳、非模型直连);按名称解析模型不存在(只认数字 model_id,且租户内跨服务商同名 model_name 合法——歧义规则要定义);持久化用量表 `LLMTokenLog` **无 `app_id` / `api_key_id`**,app 维度只活在 ES 遥测事件里。 +- **PRD v2.0 影响**:① 模型协议面收敛为**仅 OpenAI 兼容**(Anthropic 面本版不做;只讲 Anthropic 的工具如客户自装 Claude Code 只能走自带订阅);② `model:invoke` 与 `chat:invoke` 互不蕴含(裸透传 vs 平台会话机制);③ GOV-04 **聚合账单顺延 v3.1**、本版降为逐条调用审计(用量表补 key / app 列即够);④ 模型名即平台模型管理原名、无逻辑档位转换。 + +### 2.6 广场 / 版本 / 构建页(RT-02 / RT-05 / §3.0.1)→ research: marketplace-versions + +- **可复用**:标签体系整套(tag/tag_link + HOME_TAGS + 打标组件);client 广场页(explore.tsx 标签 tab + 卡片 + 滚动加载);platform 构建页 `BuildPage/apps.tsx`(卡片流 + 搜索 + 类型 / 状态 / 标签筛选 + 上下线开关 + 版本下拉 + `create_app` 权限点控新建入口);菜单 gating(web_menu key)。 +- **缺口**:第三应用类型接入 ≈ 8 处硬编码扩展点(FlowType / SUPPORTED_APP_TYPES / UNION 子查询 / FGA 映射 / TagDao / client flow_type map…);RT-05 版本模型全新(现 flow_version 是可变指针语义,**建议独立版本表**);卡片 ⚙️ 菜单按类型裁剪(现「删除」项在已上线时整项不渲染而非置灰,「创建模板 / 复制」为工作流与助手共用组件,有回归面)。 +- **风险**:应用列表 UNION 子查询绕过租户 auto-filter(docstring 明示),新增分支必须手工注入租户条款(C3);版本快照大 JSON 有 DM8 写放大前科——快照体建议 MinIO 引用而非行内 JSON。 +- **PRD v2.0 影响**:① **界面承载面改到 platform「构建 → 应用」**——托管应用作为构建列表第三种类型(正式名「托管应用」),点卡片进应用详情页(发布 / 数据 / 运行日志 / 版本四 tab,无对话区无管家),PRD-2 最小面 WB-14 / WB-06 / WB-13 / WB-15 随本册落在这里;owner「我的应用列表」= 同一页按归属过滤视图,租户管理员因身份短路天然全租户可见;② GOV-09 管理后台应用列表**取消**;③ 应用标签设置、平台侧回滚、迭代变更摘要**顺延 v3.1**(版本记录本身保留);④ 删除对标工作流:二次确认不要求输入应用名,改以「已上线态须先下线」前置状态闸补强。 + +### 2.7 审计 / 站内信(GOV-04 / §3.0.3 事件触达)→ research: audit-notify + +- **可复用**:v2 结构化审计 `ainsert_v2`(新增事件 = 按命名空间加 action,建议 `app.*` 前缀);超管跨租户查询后端已支持(tenant_scope=None);站内信 `send_generic_notify` 任意模块→任意用户,铃铛 / 角标 / 弹窗全链路现成(展示面只在 client 工作台,platform 无消息中心——与 PRD「管理后台不设消息面」吻合);审批引擎自动发 pending / approved / rejected / withdrawn / 异常站内信,首节点通知需场景侧自发。 +- **缺口**:新增一类审计事件需同时改**四处**(后端枚举 + `_UI_VISIBLE_V2_ACTIONS` 白名单 + 前端 `controllers/API/log.ts` 常量 + 3 份 i18n),漏一处即「写了查不到」;「对象应用」筛选为新增;**访问记录 / 运行期能力调用是高频事件,直接进 auditlog 单表会写放大**——需分层(独立表或扩 llm_call_log);`NotificationsDialog.tsx` 已超 600 行硬规,扩前先拆。 +- **PRD v2.0 影响**:① 按 key / 按应用聚合账单顺延;② 审计双归属(actor=应用 / subject=访问用户)保留在 GOV-04 事件清单,其字段与伴生 §4.8.1 一次对齐(F050 引入 subject 列,F055 运行期复用);③ 事件触达表已固化 11 类事件的接收方与渠道(PRD-1 §3.0.3),催办 / 超时提醒本版不做。 + +### 2.8 角色权限 / 部署开关 / 配额(GOV-07 / GOV-10 / GOV-03)→ research: quota-tenant-roles + +- **可复用**:「菜单 + 功能点子开关」有 `create_app` 完整先例(`roleMenuSelection.ts` 中 `build` 的子项,`AccessType.WEB_MENU=99`;新建角色默认开 `build`、不开 `create_app`);部署开关三段式(settings → /api/v1/env → appConfig)照抄 multi_tenant.enabled;admin-scope 切租户视图(F019)现成(只在管理后台两页接线);tenant.quota_config JSON + `QuotaService`。 +- **缺口**:资源档位实体全新(表 + 系统管理页 tab + seed 机制);工场运行时层 / 开放能力层两个部署 flag;「整层不装」的条件路由注册模式(现 router 无条件注册)。 +- **已核实缺陷(本版不再阻塞)**:租户配额是**整体覆盖写**(TenantQuotaDialog 只序列化自己的 quotaFields + `aset_quota` 整体替换)——租户实例配额已砍,本版不再触碰该入口;降为已知缺陷登记,补做实例 / 资源量配额前必先修。 +- **PRD v2.0 影响**:① **GOV-07 零新增菜单、零新增权限点**——界面通道复用 `create_app`,CLI 通道由密钥 `app:manage` 位把关,两条通道互不校验(服务账号无角色);② GOV-10 独立成条:三层部署形态(平台核心 / 开放能力层 / 工场运行时层),运行时 **compose 单机与 k8s 集群二选一、产品面一致**;③ 租户级实例数配额**已砍**(补做须以 CPU / 内存为单位);GOV-03 只剩三档预置档位 + 上线终检「运行环境容量不足 → 待上线态」;④ 应用代码存档与附件本版均不计入存储配额(`storage_gb` 口径只 SUM knowledgefile,正文已按现状订正)。 + +### 2.9 运行时基建(RT-01 / RT-07 / RT-08 / DEV-04 托管 / GOV-10)→ research: mcp-runtime-infra, appdb-storage-sdk + +- **可复用**:`mcp>=1.27.0` SDK 已在依赖内(server 原语可用,需 POC);MinioStorage 全套 + 灵思 workspace 前缀隔离 / copy-forward 快照范式(代码快照可照搬);cookie path=/ host-only → `/apps/{slug}` 同源免二次登录零后端改造;client 401→LOGIN_PATHNAME→登录→回跳链路现成(需补 query/hash)。 +- **绿地(全仓零先例)**:per-app 实例编排(拉起 / 限额 / 健康探测 / ≤5min 自愈)——无 docker SDK、无进程管理器、无 cgroup 操作;`/apps/{slug}` 动态路由(nginx 纯静态 conf;商业版 Java 网关只认 /api,需 app-proxy 统一承接);per-app 数据库供给(无动态建库代码);schema diff 引擎与迁移前自动快照;bisheng-sdk / bisheng CLI(无任何可发布包工程、无 [project.scripts]);安装件分发端点;内网 pip 镜像全仓不存在(托管契约「依赖包经平台内网镜像安装」= 全新建设)。 +- **风险**:单机已合跑全家桶(114 曾因 linsight worker OOM 死机),叠 per-app 实例必须靠档位限额 + 运行环境容量准入强约束,否则与 RT-08「平台必须正常」直接冲突;docker.sock ≈ host root 权限(方案:独立最小权限编排器 runtime-manager 唯一持有、backend 零 docker 依赖);app 附件不能混入 public bucket(匿名读策略)。 +- **PRD v2.0 影响**:① **k8s 由 v3.1 提前到本版**——第二套执行后端(Deployment / Service reconcile、命名空间 RBAC、档位→requests / limits、RuntimeClass、NetworkPolicy)+ **镜像构建与分发**(多节点 Pod 调度到任意节点,镜像必须进仓库;不交 docker.sock → 无守护进程构建器)+ 三项集群前置探测(CNI NetworkPolicy / StorageClass / RuntimeClass);k8s 侧 RBAC 反而比 docker.sock 更好收窄,不是额外风险面;② per-app 数据库形态已由方案定为 **SQLite-per-app(小)/ Postgres(中)**、与平台 MySQL / DM8 解耦(`bisheng dev` 本地起 sqlite 注入同名环境变量,DEV-05 / DEV-07)——原 D7「appdb 双库」问题消解,只剩 C2 适用性确认;③ 单应用单实例、无扩缩容;④ RT-08 两个过渡态(发布中 / 应用恢复中)不落报错页,两形态验收口径与时限相同。 + +--- + +## 3. Feature 拆分方案 v2(提议,待 ★ 确认) + +编号从 **F049** 起(F043–F048 已被 `features/v3.0.0-beta1/` 占用);不变量编号从 **INV-27** 起(详见 release-contract 编号约定)。**保留 F049–F057 编号与目录名(语义仍对得上),新增 F058 / F059**;F050 / F051 / F053 / F054 / F055 / F056 / F057 的范围相对 v1 拆分有实质变化,逐行见 release-contract 变更历史 2026-08-17。 + +| # | Feature | 批次 | 覆盖 PRD-1 v2.0 条目 / 伴生 PRD | 依赖 | 方案 F1xx | +|---|---------|------|--------------|------|-----------| +| F049 | openapi-auth-baseline | A | 伴生 P0:R1 底座 + R4 服务账号(含**资源归属人**)+ R5 端点接入(36 HTTP + 2 WS;6 个 `/chat/*` 不暴露)+ R6 管理界面(v2.1 授权页形态)+ R9 升级须知(**零迁移**)+ 三扩展位登记入权限位清单 + 开放能力层部署开关 + 分享链接通道 + 四处既有缺陷修复;PRD-1 DEV-01 表单层增量 + GOV-08 四条承诺 | — | F101 | +| F050 | identity-modes | A | 伴生 P1:R2 两种模式 + 外部用户标识头 + R3 五道准入 + R7 审计双归属 + 裸 `user_id` 收口 + **`delegate` 位与委托配置区**(含伴生 AC-48 互斥硬阻断 / AC-46 归属优先级) | F049(伴生 AC-12 检索等价依赖 F052) | F101 | +| F051 | model-protocol-gateway | A | DEV-02 模型协议面(**仅 OpenAI 兼容**、`model:invoke`、按名解析、流式;可用范围 = GOV-05 租户模型配置)+ GOV-04 模型调用逐条审计带 key / 应用维度(聚合账单顺延)+ 入口拒绝 `delegate`(INV-31) | F049 | F102 | +| F052 | mcp-server-face | A | DEV-02 MCP 六类工具(应用数据 / 状态日志两类随 F054 / F055 接线)+ **统一检索门面**(文件级双层 + fail-closed;伴生 §4.4.6 交付前提;GOV-05 运行时 / F057 共用)+ `identity:read` 组织查询 + 入口拒绝 `delegate` | F049 | F105 | +| F053 | dev-cli-skills | A 尾 / B | DEV-03 两包 + DEV-04 CLI `login`(自动 sync)/ `skills sync` / `dev` / `logs`(`deploy` 随 F055 启用)+ DEV-05 本地身份注入 + 本地 sqlite 同名注入 + CLI 安装件分发 + **DEV-01 接入信息区与一键复制** + `login` 拒 `delegate` | F049, F051, F052 | F106(部分) | +| F057 | bisheng-sdk | A 尾 / B | DEV-07 三件套(auth / retrieve / storage)+ 开发者指南;chat / appdb 刻意不进 SDK | F052, F053;storage 依赖 F054 | F106(部分) | +| F058 | openapi-responses(**新增,登记项**) | A | 伴生 P1「日常模式会话 Responses 子集契约」——**不在 PRD-1**;补齐版本层归属;是否进 v3.0.0 由产品定(N2) | F050 | F101 | +| F054 | app-domain-runtime | B | 托管应用领域模型 / 状态机(§3.0.2)+ GOV-01 `app` 资源类型注册 + compose 执行后端(runtime-manager)+ app-proxy + RT-01 入口与四类兜底页 + RT-07 资产持久 / 下线 / 重新上线 / 显式删除 + RT-08 稳定性 + GOV-03 档位→容器限额 + per-app 数据库 / 附件 + 构建页「托管应用」类型 + 应用详情页壳(**WB-13 / WB-06**)+ GOV-10 工场运行时层开关与未部署引导页。**spec 阶段可再拆** | F049 | F103, F107, F104(FGA) | +| F055 | app-publish-pipeline | B | RT-03 / RT-04 / RT-05 + DEV-04 deploy 管线(预检 / 扫描 / 审批 / 终检)+ **GOV-02 预置审批流 + 三项阻塞前置** + GOV-03 ResourceTier / 管理 tab / 选档 / 终检 / 待上线态 / 手动上线 + GOV-05 能力总线(声明→注入 / 收回错误态)+ 应用运行期凭据自动签发回收 + 审批人审读视图与临时预览实例 + **WB-14 / WB-15** + CLI `deploy` / `logs` 接线。**spec 阶段可再拆** | F054, F049, F051, F052 | F112, F104(审批) | +| F056 | app-square-governance | B | RT-02 广场 + GOV-01 授权两个入口接线与验收 + GOV-04 审计扩展 / 查询面 / 导出 + GOV-07 `create_app` 复用与 ⚙️ 菜单裁剪 + §3.0.3 事件触达接线 | F054, F055 | F104 | +| F059 | k8s-runtime-backend(**新增**) | B | GOV-10 k8s 形态:reconcile / RBAC / 档位映射 / **镜像构建与分发** / 集群能力探测 / 双形态一致性回归;方案 F113「不可裁剪」 | F054 | F113 | + +**PRD-1 §1.4 覆盖矩阵**(21 项 → owner;多个 owner 时首个为主承接): + +| 条目 | Owner | 条目 | Owner | 条目 | Owner | +|---|---|---|---|---|---| +| DEV-01 | F049(表单层)/ F053(接入信息区) | RT-01 | F054 | GOV-01 | F054(类型注册)/ F056(交互与验收)/ F055(发布面入口) | +| DEV-02 | F052(MCP)/ F051(模型面) | RT-02 | F056 | GOV-02 | F055 | +| DEV-03 | F053 | RT-03 | F055 | GOV-03 | F055(档位实体 / 选档 / 终检)/ F054(限额落地) | +| DEV-04 | F053(四命令)/ F055(deploy 管线与 `deploy` / `logs`) | RT-04 | F055 | GOV-04 | F056(审计面)/ F051(模型调用逐条)/ F050(双归属字段) | +| DEV-05 | F053 | RT-05 | F055 | GOV-05 | F055(注入 / 收回)/ F052(检索门面)/ F051(模型范围) | +| DEV-07 | F057 | RT-07 | F054 | GOV-07 | F056(界面通道)/ F049(`app:manage` 位登记) | +| — | — | RT-08 | F054 / F059 | GOV-08 | F049 | +| — | — | — | — | GOV-10 | F054(层开关 / compose)/ F059(k8s)/ F051–F053(开放能力层 WHERE 条款) | + +不映射项:DEV-06 / GOV-09 已取消;RT-06 / GOV-06 属 PRD-2;RT-09 顺延(方案 F109);GOV-05 **密钥引用**(录入面 WB-05 属 PRD-2)待产品确认(N4);DEV-07 验收 3 随 PRD-2 WB-01。 + +说明: +- **批次 A = 开放能力层**,按 GOV-10 可独立于工场运行时交付,且是批次 B 的鉴权 / 凭据地基。建议顺序 **F049 → F052 → F051 → F053 → F050 →(F058)→ F057**(F052 早于 F050 因检索门面是模式 D 集合相等的前提;F057 的 storage 面等 F054)。 +- **批次 B**:F054 → F055 → F056,F059 与 F055 并行(须在发布管线联调前可用,方案 Wave-2)。 +- F054 / F055 体量大,spec 阶段允许再拆(F054:编排后端 vs 领域模型 / 界面壳;F055:审批与版本 vs 能力总线注入);拆分时在 release-contract 表 3 更新。 +- **PRD-2 最小承载面逐面归属**:WB-13 运行日志 / WB-06 数据面 → F054;WB-14 发布面 / WB-15 版本与差异 → F055;WB-01 模板随 PRD-2。 +- 审批中心前置修复(`tenant_admin` resolver 改用真租户管理员)属行为变更(存量频道订阅 / 知识空间加入两场景的实际审批人会变),需在 release note 声明。 + +--- + +## 4. 关键决策清单(★ 需用户逐项拍板) + +### 4.1 v1 决策(2026-08-06)状态 + +| # | 决策 | 状态 | +|---|------|------| +| **D1** | 首批启动 feature:批次 A 先行、F049 第一个进 spec | **已定**(F049 spec ★ 已过;本次按 v2.1 重写后需第二次 ★) | +| **D2** | 开发基线分支 | **已定:`3.0-vibe`(含 F048)**,INV-8~26 无条件生效 | +| **D3** | app 资产载体:复用 flow 表新增 FlowType vs 独立 app 表 | **仍开放,随 F054 design 定**。倾向独立 app 表 + 构建页列表 UNION 第三支(版本 / 档位 / 状态机字段装不进 flow 表;PRD-1 §3.0.1 要求同列表 + 复用版本下拉与上下线开关) | +| **D4** | 运行时隔离选型 | **形态已由 PRD-1 GOV-10 + 方案 §4.5 定**:compose 单机 = docker API 经独立最小权限编排器 runtime-manager(backend 零 docker 依赖);k8s 集群 = 命名空间 RBAC。**残留**:信创无 docker / 无 k8s 环境的沙箱档位(方案:加固 Docker 档兜底),F054 design 定 | +| **D5** | 审批处理界面落位 | **已解**:PRD-1 §3.0.3 明确处理面在 client 审批中心、配置面在 platform,与现状一致;审读视图 + 临时预览实例是 client 审批弹窗上的新增内容(F055) | +| **D6** | strict fail-closed 边界 | **已解**(INV-30)。残留「全局超管判定在 OpenFGA 故障时回落 legacy 角色位」是否收紧,随 F049 design | +| **D7** | appdb 双库范围 | **方案已定 SQLite-per-app / Postgres、与平台 MySQL / DM8 解耦**(PRD-1 DEV-05 / DEV-07 已按此写);**残留**:宪法 C2 双 DB 法对 per-app 库的适用性 / 豁免在 F054 design Constitution Check 确认 | + +### 4.2 v2 新增决策(2026-08-17 提出;**同日用户授权「全部按建议拍板」,以下各行「建议」即为决议**) + +| # | 决策 | 决议(= 原建议) | +|---|------|-----------| +| **N1** | **拆分 v2 确认**:11 个 Feature(F049–F059)及批次 / 依赖 / 建议顺序(§3);F054 / F055 是否**现在就拆**(各拆两个)还是留到 spec 阶段 | **已定**:11 个;F054 / F055 留到各自 spec 阶段再拆(拆早了依赖图先于 What 固化) | +| **N2** | **F058 openapi-responses 是否进 v3.0.0**:伴生 PRD 要求它与 P1 身份传递同期交付(12–15 人天,且估算需重算),但 PRD-1 全册零引用 | **已定**:登记为 v3.0.0 版本层 Feature、排期由产品定;不在 MVP-114 纵切上(见 [mvp-114-path.md](../mvp-114-path.md)) | +| **N3** | **统一检索门面 owner = F052**,批次 A 顺序 F049 → F052 → F051 → F053 → F050;F050 的模式 D 集合相等(伴生 AC-12)依赖 F052 | **已定**:是(门面归 F052) | +| **N4** | **GOV-05 密钥引用是否随本册**:录入面 WB-05 属 PRD-2、不在 PRD-1 §1 最小承载面清单;无录入面则 CLI 应用的密钥引用无值可注 | **已定**:本册能力总线只做**模型 + 知识库**两项,密钥引用整体随 PRD-2(F055 spec 按此写、注入通道预留引用名位) | +| **N5** | **伴生 PRD v2.1 草案内部残留三处**:§4.7 引言「绑定使用人可选 / 应用 token 另占一 tab」、§4.7.2「删除阻断」——与 §4.7.1 / §4.7.4 / AC-31 / PRD-1 GOV-08 矛盾。F049 spec 已按较新决议写(AC-23 / AC-48 / 排除应用运行期凭据 tab) | **已定并执行**:2026-08-17 已回修伴生 PRD 三处(只删残留、不动决议,见其修订记录) | +| **N6** | **PRD-1 GOV-10 / GOV-08「未部署开放能力层则 API key 管理页不出现」vs DEV-01 ⑥「服务账号模块本身随该 PRD 交付」**。F049 spec 按后者(AC-49:服务账号模块恒在,开关只 gate 三扩展位与接入信息区)——v2 开放 API 发布即带鉴权,服务账号与密钥是唯一接入方式,不能被部署开关藏起来 | **已定并执行**:spec 口径确认(AC-49);2026-08-17 已回修 PRD-1 GOV-10 规则表 / 验收 4 与 GOV-08 界面表措辞 | + +次级决策(spec / design 阶段随对应 feature 定,先登记):模型名歧义规则(跨服务商同名 model_name,F051);MCP Server 部署形态(同进程 mount vs 独立进程,F052);`/apps/{slug}` 路由承接者(app-proxy 与 nginx / 商业版 Java 网关的接线,F054);高频审计事件分层(F056);CLI 安装包形态(wheel + 内网镜像 vs 单文件二进制,F053);k8s 镜像仓库对接方式(复用客户 Harbor vs 平台自带,F059);`app_factory` 错误码段位(161 起,F054);F049 决议-6 f(`delegate` 位随 F050 而非 F049 只存不生效)与 决议-6 i(share-token 会话以分享创建者为执行主体)——**2026-08-17 已按建议拍板**(F049 spec §4)。 + +--- + +## 5. 重大风险登记(进 design 阶段必须消化) + +1. **新资源类型的存量生效缺口**(按 F048 基线重核):Catalog 的「动作↔资源类型范围表」只在首次迁移与草稿发布时写入,变更类型枚举里没有「改资源范围」,迁移脚本又因 checksum 不符无法重跑——给存量环境加 `app` 类型必须新写运维脚本或扩展变更类型。F054 design 必须前置演练(F048 存量被拦教训)。 +2. **审批人来源三前置**(F055):`tenant_admin` 来源错用系统超管 / Root 租户恒空 / 新建租户不 seed——任一未修,GOV-02「部署即生效」在多租户或单租户形态下即失效(首次发布卡死或全压超管一人)。修正对既有两场景是**行为变更**,须 release note 声明。 +3. **单机容量**:租户实例配额已砍,per-app 实例叠在已合跑全家桶的单机上,RT-08「平台必须正常」的护栏只剩**档位限额 + 上线终检 / 重新上线的运行环境容量准入**(GOV-03)——F054 / F055 必须把容量准入做成硬闸门而非提示。 +4. **k8s 形态三前置与镜像分发**(F059):CNI 不支持 NetworkPolicy 则出站白名单为空、StorageClass 缺失则 SQLite-per-app 的 RWO 卷不成立、RuntimeClass 缺失则沙箱档位降级——必须部署前探测、不满足显式告警;镜像构建与分发是 k8s 多节点的**唯一新增必需件**(不交 docker.sock → 无守护进程构建器 + 仓库),排期未含。 +5. **DM8**:版本快照大 JSON 有写放大前科(快照体走 MinIO 引用);per-app 库已解耦 MySQL / DM8,但平台侧 app 表 / 版本表仍受 C2 约束。 +6. **委托红线贯穿**:应用工场全册无模式 D 场景(INV-31)——MCP / 模型面 / CLI 三面入口必须按位拒绝 `delegate` 密钥、不得静默落回模式 S;托管运行期身份走平台登录态(app-proxy 注入),不得挂在委托头上(伴生检查 3 会把管理员挡在所有应用之外)。 +7. **三个「漏一处即静默失效」的机械性坑**:应用列表 UNION 子查询手工注入租户条款;新表注册 `_TENANT_AWARE_MODEL_MODULES`;审计事件四处 lockstep(后端枚举 / UI 白名单 / 前端常量 / i18n)——写进各 feature design 的已知坑清单。 +8. **PRD 残留被当成需求实现**(N5 / N6):伴生 §4.7.2 删除阻断、§4.7 引言应用 token tab、PRD-1 GOV-10 API key 页隐藏——若开发只读 PRD 不读 spec 会实现出已被否决的行为;两 PRD 回修前以 spec 为准。 +9. **审计与访问记录写放大**:应用运行期能力调用 / 访问记录是高频事件,直接进 auditlog 单表不可取(F056 design 分层)。 + +--- + +## 6. 下一步 + +**进度(2026-08-17)**:拆分 v2、release-contract(表 1 / 表 2 / 表 3、INV-31、候选 INV-32~36)、F049 spec(65 条 AC,经 `/sdd-review` 独立审查修订)全部就绪;**用户同日授权全自动模式:N1–N6 与 F049 决议-6 f / g / h / i 全部按建议拍板,后续 ★ 不再等待确认**。 + +1. 目标锚定 **MVP-114 纵切**([mvp-114-path.md](../mvp-114-path.md)):在 114 服务器上以 CLI 导入一个表单问卷小应用 → 预置审批流 → 上线 → 设可见范围为全员 → 所有人从应用广场打开使用。 +2. 文档顺序:F049 design + tasks → F054 spec / design / tasks → F055 → F053 → F056(各按 MVP 纵切标注首波任务);F050 / F051 / F052 / F057 / F058 / F059 的 spec 随后补齐、不在纵切上。 +3. 每份 design 经 `/sdd-review design`(Constitution Check)、tasks 经 `/sdd-review tasks`;实现阶段按 tasks 波次推进,`/task-review` → `/e2e-test`。 +4. **research/ 的时效性提醒**:11 份调研在 `main`(pre-F048)上完成,与本基线相差 242+ 个后端文件;本分支 2026-08-16 又合入 `feat/3.0.0-beta1` 最新提交。已按 F048 重核的三块结论收在 baseline-recheck.md,**与 research/ 冲突时以它和 §2.2 为准**;其余维度在 design 阶段被引用前由各 design 的探查阶段重核(尤其检索、模型面、审批锚点以 PRD-1 §3.3 锚点表为准)。 +5. UI 参考:Claude Design 交互稿已拉取到 [ui-demo/](./ui-demo/)(含结构化摘要 README),design 引用时以 PRD 为准、demo 为参考。 diff --git a/features/v3.0.0/000-prd1-discovery/research/appdb-storage-sdk.md b/features/v3.0.0/000-prd1-discovery/research/appdb-storage-sdk.md new file mode 100644 index 0000000000..5417168b8c --- /dev/null +++ b/features/v3.0.0/000-prd1-discovery/research/appdb-storage-sdk.md @@ -0,0 +1,63 @@ +# appdb-storage-sdk + +## summary +应用数据库/附件存储/SDK-CLI 三块地基中,现状可复用的是「单实例平台库的引擎与双库方言层」「MinIO 全套对象操作与 prefix 隔离范式」「v2 检索 RPC 与 BishengLLM 模型封装」;而 PRD 要求的 per-app 数据库供给、schema diff 迁移引擎、迁移前快照、bisheng-sdk 包、bisheng CLI、平台侧安装包分发全部为零基础新建。仓库今天既无任何 CLI(无 [project.scripts]、无 click/typer),也无可分发的 wheel 打包管线(backend pyproject 无 [build-system],bisheng_langchain 在本 monorepo 中是无打包元数据的 vendored 目录)。除 alembic 之外不存在任何 schema 快照/备份机械——「迁移前自动快照」目前只是运维 runbook 里的手工 mysqldump,且 DM8 侧连等价工具集成都没有,是 C2 双库法则下的最大技术风险。 + +## current_state +【(a) MySQL/DM8 供给与访问 — 已在代码核实】平台库是单 URL 单例:`bisheng/core/database/connection.py` 的 `DatabaseConnectionManager` 持有一对 sync/async engine(pymysql→aiomysql、dmPython→dmAsync 自动换驱动),DM8 的 schema 经 `_normalize_dm_url` 移入 `?schema=` 查询串(DaMeng 以 connect kwarg 选 schema,URL path 会被 dmPython 拒绝)——这是「DM8 天然 schema-per-app」的关键锚点。双库差异全部收敛在 `core/database/dialect_helpers.py`:Boolean→SMALLINT、JSON/LONGTEXT→CLOB、Computed 列转触发器、`JsonType`/`LargeText` TypeDecorator、以及一组反射探测 primitives(`table_exists`/`column_exists`/`index_exists`/`get_column_type`,带 DM8 大小写三态回退)。迁移采用双轨契约(src/backend/AGENTS.md 明文):整表新建走 SQLModel `create_all`(alembic env.py 在每次 online upgrade 前先跑 `alembic_helpers/online.py::create_missing_model_tables`),存量表变更走 alembic revision(`core/database/alembic/versions/` 现 58 个),autogenerate 只反射 MySQL、DM8 兼容靠人工审。`alembic_helpers/mysql_impl.py::BishengMySQLImpl` 只做等价索引去重。全仓 grep 无 CREATE DATABASE/CREATE SCHEMA 动态建库代码、无任何快照/备份模块(scripts/ 全是 backfill 一次性脚本);「迁移前备份」只存在于运维记忆(手工 mysqldump),DM8 无对应集成。多租户注意:平台库新表须被 `core/database/model_discovery.py::import_all_sqlmodel_models` 发现,tenant_filter 只拦 SELECT;但 per-app 库在 SQLModel metadata 之外,隔离靠库/schema 边界而非 ContextVar。【(b) MinIO — 已核实】`core/storage/minio/minio_storage.py::MinioStorage`:双 client(endpoint 内部直连 + `sharepoint` 公网地址走 `share_minio_client`),bucket 固定两只(`MinioConf.public_bucket` 默认 "bisheng"(其 `knowledge/images/*`、`tmp/images/*` 挂匿名读策略)+ `tmp_bucket` "tmp-dir" 带生命周期过期),完整 put/get/list/copy/remove 同异步双份 API;预签名只有 GET(`get_share_link[_sync]` → `presigned_get_object`,`clear_host` 剥掉 host 交前端 nginx 代理),无 presigned PUT。prefix 级隔离的成熟范式在灵思:`linsight/domain/services/workspace_backend.py` 以 `workspace/{svid}/` 为对象键前缀圈定工作区,且已有跨版本 copy-forward 快照机械(166-181 行 `list_objects`+`copy_object_sync` 整前缀复制)——正是「应用代码版本快照」可照搬的形态。`core/storage/tenant_storage.py` 的 4 个租户前缀函数零调用方(grep 证实,dead code,F008 未落地)。【(c) SDK/打包 — 已核实】`src/backend/pyproject.toml` name="backend",无 [build-system]、无 [project.scripts]、无 entry_points——后端从不被打成 wheel,uv sync 就地运行;`src/backend/bisheng_langchain/` 无 setup.py/pyproject(仅 ruff known-first-party 引用),在本仓是 vendored 模块非发布物(PyPI 上的 bisheng-langchain 来自上游公开仓,属推断)。平台今天不提供任何安装件下载:main.py/router.py 无 StaticFiles,文件下发全走 MinIO share link + nginx。【(d) CLI — 已核实】全仓无 CLI:无 click/typer 导入,find 无 *cli* 模块(仅 mcp_manage/clients 误中)。【DEV-07 五件套对应现状】auth:会话态走 `common/dependencies/user_deps.py::UserPayload.get_login_user`;v2 开放面现状零鉴权(`open_endpoints/api/dependencies.py` 走 `get_default_operator_async`),SAK(bs-sak-)体系在伴生 PRD(213/232/286 行)待建。retrieve:`open_endpoints/api/endpoints/filelib.py:621 retrieve_chunks` → `chat_svc.aretrieve_chunks`(F030 RPC)已存在。chat:服务端有 `llm/domain/llm/llm.py:193 class BishengLLM`(LangChain BaseChatModel 包平台模型);HTTP 面只有 workbench 专用 `workstation/api/endpoints/chat.py:94 /chat/completions`(LoginUserDep 会话鉴权,非开放 OpenAI 兼容端点;PRD-1 第 91 行明示要新的兼容 base URL)。storage:MinioStorage 可包。appdb:零现状。 + +## key_files +- src/backend/bisheng/core/database/connection.py — DatabaseConnectionManager 单 URL 引擎单例;_normalize_dm_url 证明 DM8 以 ?schema= 选 schema(schema-per-app 的落点);per-app engine 池可以此为模板但需重管 pool_size(默认 100/engine 会爆连接) +- src/backend/bisheng/core/database/dialect_helpers.py — 双库方言收敛层:类型映射 + table_exists/column_exists 等反射 primitives,appdb diff 引擎的现成积木 +- src/backend/bisheng/core/database/alembic_helpers/online.py — create_missing_model_tables:平台库「新表 create_all + 存量表 revision」双轨契约的实现;appdb 不能复用 alembic_version 单表机制,只能借鉴 +- src/backend/bisheng/core/database/alembic/env.py — online 迁移入口(361 行 run_migrations_online),无任何快照钩子 +- src/backend/bisheng/core/storage/minio/minio_storage.py — MinIO 全套对象操作;双 client(endpoint/sharepoint);get_share_link 仅 presigned GET(608 行);bucket 策略与 tmp 生命周期在 _init_bucket_conf +- src/backend/bisheng/linsight/domain/services/workspace_backend.py — workspace/{svid}/ 前缀隔离 + copy-forward(166-181 行整前缀 copy_object_sync)——应用附件命名空间与代码版本快照的现成范式 +- src/backend/bisheng/core/storage/tenant_storage.py — get_minio_prefix 等 4 个租户前缀函数零调用方=dead code(F008 未落地),勿当既有能力引用 +- src/backend/pyproject.toml — 无 [build-system]/[project.scripts]:后端非 wheel、无 CLI 入口;SDK/CLI 必须另起独立包工程 +- src/backend/bisheng/open_endpoints/api/endpoints/filelib.py — 621 行 retrieve_chunks → aretrieve_chunks:SDK retrieve 面可直接包的 v2 RPC(fail-closed 与 type=3 view_file 缺口需同步修) +- src/backend/bisheng/llm/domain/llm/llm.py — 193 行 BishengLLM:平台托管模型的服务端封装,chat 面的服务端积木;HTTP 兼容端点需新建 +- src/backend/bisheng/database/models/flow_version.py — FlowVersion:既有「应用版本记录」表设计范式,RT-05 版本列表/回滚记录可参照 +- src/backend/bisheng/open_endpoints/api/dependencies.py — v2 现状零鉴权(get_default_operator_async),SDK auth 面依赖伴生 PRD 的 SAK 落地 + +## reuse +- storage 面:MinioStorage(core/storage/minio/minio_storage.py)put/get/list/copy 同异步全套 + get_share_link 预签名 GET 可直接薄包为 SDK storage 模块;应用附件命名空间照搬 linsight workspace_backend.py 的 `/{id}/` 键前缀隔离范式 +- 应用代码版本快照:workspace_backend.py 166-181 行的整前缀 copy-forward(list_objects+copy_object_sync)就是 MinIO 侧不可变版本快照的现成实现模式,RT-05 版本内容存储可复用 +- retrieve 面:open_endpoints/api/endpoints/filelib.py:621 retrieve_chunks → chat_svc.aretrieve_chunks(F030 RPC)可包为 SDK retrieve;权限过滤骨架(view_file 双层过滤)已在 F029 存在 +- chat 面服务端:llm/domain/llm/llm.py:193 BishengLLM 已封装平台托管模型调用(含各厂商适配),新的 OpenAI 兼容端点可在其上薄包 +- appdb 迁移引擎的探测积木:dialect_helpers.py 的 table_exists/column_exists/get_column_type/is_column_nullable(含 DM8 大小写回退)+ JsonType/LargeText 类型映射可直接用于 per-app schema diff +- per-app 引擎管理模板:DatabaseConnectionManager(connection.py)的 URL 归一化(尤其 DM8 ?schema=)、sync/async 双引擎、会话回滚语义可派生为 AppDbConnectionManager +- auth 面:会话态 UserPayload(common/dependencies/user_deps.py)+ 伴生 PRD 的 bs-sak- 服务账号密钥体系(PRD-1 v1.5 已对齐:应用 token=SAK)——SDK auth 读平台注入身份属新建,但校验端复用伴生 PRD 的 R1 凭据体系 +- 版本记录表设计:database/models/flow_version.py FlowVersion 的「data 快照 + 版本行」范式可参照设计应用版本表(版本号/类型/终态标注) + +## gaps +- per-app 应用数据库供给全链路:动态建库/建 schema(全仓无 CREATE DATABASE/SCHEMA 代码)、按应用签发最小权限 DB 凭据、per-app 引擎池与生命周期管理(现有 manager 是单 URL 单例) +- app schema 声明与 diff 引擎:PRD 未指定声明载体(bisheng-app.yaml? SDK model?),需新建「加列自动迁移 / 改删列检测→显式确认位(--confirm-schema-change)传递到发布管线」的整套 diff+执行机制;不可复用平台 alembic(版本链与 alembic_version 均是平台库专属) +- 迁移前自动快照:仓内零快照机械(scripts/ 无备份脚本,运维靠手工 mysqldump);需为 MySQL 与 DM8 各建自动快照通道 + 快照存储位(MinIO?)+ 保留/恢复策略 +- bisheng-sdk 独立包工程:五件套(auth/retrieve/chat/storage/appdb)从零写;仓库现无任何可发布 Python 包(backend 无 build-system,bisheng_langchain vendored 无打包元数据) +- bisheng CLI 从零建:login/skills sync/dev/deploy/logs 五命令;全仓无 click/typer/console_scripts +- 平台侧安装件分发:CLI/SDK 安装包下载端点(DEV-01 接入信息区)——现平台无 StaticFiles/artifact 端点,需新建(可落 MinIO+share link);「依赖包经平台内网镜像安装」的内网 pip 镜像属部署假设,代码零支撑 +- OpenAI/Anthropic 兼容 base URL 端点(PRD-1 line 91):现仅有 workbench 专用 /chat/completions(LoginUserDep 会话鉴权),开放兼容面需新建并挂 SAK 鉴权 +- storage SDK 缺 presigned PUT 与 app 级命名空间/配额记账(现只有 public/tmp 两只 bucket,public 带匿名读策略——app 附件不能直接混入) +- deploy 托管预检:契约检测(自带中间件识别、依赖构建、启动探活)与 schema 破坏性变更检测全新建 + +## risks +- C2 双库法则是最大风险:DM8 侧无 mysqldump 等价物集成(dexp 是外部二进制),「迁移前自动快照」在 DM8 上如何自动化未证实;且有 DM8 undo 写放大致 -7120 的历史事故(灵思 history 大 UPDATE),大快照/大迁移在 DM8 上有把实例拖垮的前科 +- MySQL 与 DM8 的隔离原语不对称:MySQL 的 database≈schema、DM8 是同实例内 schema(?schema= connect kwarg)+ 用户权限体系——schema-per-app 与 db-per-app 两方案在双库上语义不一致,方案必须双库各自验证 DDL 权限模型 +- 连接数爆炸:DatabaseConnectionManager 默认 pool_size=100+overflow 20,若每 app 一对 sync/async 引擎照抄默认值,几十个应用即可打满 MySQL/DM8 连接上限;需全新的小池+LRU 引擎缓存策略 +- 平台 alembic autogenerate 只反射 MySQL(AGENTS.md 明文),appdb diff 引擎若基于 alembic autogenerate 复用,DM8 侧的类型反射(CLOB/SMALLINT 回读)会与声明类型不等值,误报「改列」触发确认闸口——需用 dialect_helpers 的归一化探测而非裸 autogenerate +- SDK 面挂 v2 的前提是伴生 PRD P0 落地:v2 现状零鉴权(get_default_operator_async 匿名超管越权是活缺口),SDK auth/retrieve 上线顺序被伴生 PRD 硬依赖锁定 +- retrieve fail-closed 红线:现 aretrieve_chunks 的 type=3 分支漏 view_file 过滤(已知越权缺口),SDK retrieve 若直接薄包会把缺口带进新面;必须先修再包 +- app 附件若沿用 public_bucket 会继承 knowledge/images/* 匿名读策略与 sharepoint 公网可达性,泄漏面大;新 bucket 则牵动 MinIO 顺序约束与商业版 nginx 代理配置(sharepoint SigV4 签 Host 的既有坑) +- C3/C4 边界:per-app 库在 tenant_filter ContextVar 体系之外,租户隔离完全靠库边界+凭据,等于为 appdb 新开一条隔离审计线(constitution C3 的例外需在 design 阶段 Constitution Check 里显式论证) +- CLI 分发形态若选 wheel+内网 pip 镜像,客户纯内网环境的镜像基础设施是部署假设非产品能力,交付时容易变成实施黑洞;若选单文件二进制(PyInstaller)则需新建跨平台构建管线 + +## open_questions +- 应用数据库隔离粒度拍板:schema-per-app(DM8 天然、MySQL 用独立 database 模拟)还是 db-per-app?以及 DB 凭据模型——每 app 独立 DB 账号(GRANT 限本 schema)还是平台统一账号+逻辑隔离? +- app schema 的声明载体与真源:bisheng-app.yaml 内声明表结构、SDK 端 model 元数据、还是首个版本由平台从运行时 DDL 捕获?diff 的比较基线存哪(平台库记录 vs 现库反射)? +- 迁移前快照的范围与保留策略:只快照受影响表还是整库?保留几份/多久?存 MinIO 还是 DB 侧?是否计入租户存储配额(PRD-1 已定附件不计配额,快照未提及)?恢复动作是产品能力还是运维手册? +- appdb SDK 的接线方式:托管运行期直连 DB(平台注入连接串+凭据)还是经平台 API 代理?本地 dev 期(bisheng dev)appdb 连什么——平台侧真库、开发者本地库、还是平台代开的 dev 库? +- CLI 安装包形态:pip wheel(依赖内网 pip 镜像假设)vs 单文件二进制?「平台自身分发」的下载端点挂 v1 还是独立静态面?SDK 与 CLI 是否同一个包? +- OpenAI/Anthropic 兼容 base URL(DEV-01)与 SDK chat 面的关系:chat 模块走该兼容端点还是私有 RPC?兼容端点是否属于本 PRD 交付边界(还是伴生开放 API PRD)? +- DM8 客户是否在 PRD-1 首发范围内?若允许「appdb 首发仅 MySQL、DM8 二期」可大幅降险,但违背 C2 需要显式豁免决策 +- 改/删列显式确认的授权语义:--confirm-schema-change 只需 owner 的 key 即可,还是审批单上也要展示并由审批人二次把关(RT-05 说发布时不再二次确认,但审批人视角未明确)? diff --git a/features/v3.0.0/000-prd1-discovery/research/approval-center.md b/features/v3.0.0/000-prd1-discovery/research/approval-center.md new file mode 100644 index 0000000000..0232afa305 --- /dev/null +++ b/features/v3.0.0/000-prd1-discovery/research/approval-center.md @@ -0,0 +1,88 @@ +# approval-center + +## summary +审批中心(F025)是一套通用多场景审批引擎(统一网关 ApprovalGate → 路由分支 → 流程/节点 → 实例/任务 → outbox 异步执行业务 → 站内信),新增「应用发布」场景在框架层无阻塞:或签、撤回、富 JSON payload、outbox、站内信、审计全部现成可复用,接入成本集中在"三件套"注册(preset + 业务入口 Gate + runtime handler 工厂分支)。真正的缺口在 PRD 的四条场景特化规则上:未配置兜底租户管理员(现状是直接抛错或落异常单,且 tenant_admin 解析器错用系统超管近似)、提交人自动跳过(完全没有)、删除应用取消在途单(无系统级 cancel API)、定制审批单详情页与审读视图入口(前端是通用 key-value 渲染,无分区扩展点)。最大歧义是 PRD 把审批单详情写在"平台审批中心",而现状审批处理界面只存在于 client 端 /workspace 弹窗,platform 端只有场景配置与异常处理。 + +## current_state +【均为代码核实,路径相对 src/backend/bisheng/ 除注明外】 + +一、统一网关与主流程。所有场景经 `ApprovalGate.request_or_pass()`(approval/domain/services/approval_gate.py:92)进入:先按 tenant+scenario_code+business_key+applicant_user_id 查在途去重(`ApprovalInstanceRepository.find_duplicate_active_instance`,活跃状态集=pending/exception/execute_failed,repositories/approval_instance_repository.py:16,52-69,命中时静默返回已有实例而非报错);再取 handler 生成 `detail_snapshot`/`business_name`(handler.build_detail/build_title);场景行不存在或 disabled → 直接 `raise ApprovalScenarioDisabledError`(gate.py:109-111,即业务调用失败);路由按 sort_order 自上而下匹配(`_match_first_route`,gate.py:417-460,支持 catch-all `{}`、`applicant_role` 身份标签"包含即命中"、payload 字段等值三种条件);pass 分支→实例 APPROVED+建 outbox 立即 dispatch;flow 分支→实例 PENDING+首节点每个审批人一条 task;无路由命中/审批人解析为空→实例 EXCEPTION(route_missing/approver_empty)+站内信通知管理员(`_notify_admins_of_exception`)。全部分支写审计(AuditLogDao.ainsert_v2)。 + +二、场景注册"三件套"(以知识空间加入审批为实证):① `ApprovalRegistry.with_default_presets()`(services/approval_registry.py:14-45)注册 preset(scenario_code/名称/条件字段/审批人来源类型),是管理后台"新增场景"下拉的唯一来源(`approval_scenario_admin_service.list_presets`:21-22 + platform ApprovalPage 的 AddScenarioDialog 只能从 presets 选,src/frontend/platform/src/pages/ApprovalPage/index.tsx:219-270);② 业务入口自建 Gate 并注册 handler(`KnowledgeSpaceService._build_space_approval_gate`,knowledge/domain/services/knowledge_space_service.py:4700-4710;调用点 subscribe_space:4607-4664,先过网关再落 membership);③ `build_runtime_handler()` 硬编码 if 链(services/approval_runtime_handler_factory.py:17-35),供 outbox 执行、多节点 advance、reject/withdraw 钩子重建 handler。Handler 协议(鸭子类型,见 knowledge_space_subscribe_scenario_handler.py):build_title/build_detail/resolve_approvers/on_approved/on_rejected/on_withdrawn/on_cancelled。 + +三、审批人配置。节点 `approver_config.sources` 支持 direct_user(任意用户,platform 有用户选择器 UI)/department_admin/tenant_admin/资源角色类(owner/manager,由场景 handler 自解析)。⚠️核实到关键失真:`resolve_approvers_from_sources` 的 tenant_admin 分支(services/approver_resolver.py:63-74)用 AdminRole(role_id=1 系统超管) 做"pragmatic approximation",并非真租户管理员;而通知侧 `ApprovalNotificationService._get_admin_recipient_ids`(approval_notification_service.py:122-150)才真正走 `TenantAdminService.list_tenant_admins`。 + +四、或签/会签与流转。`decide_task`(approval_center_service.py:658-801):node_mode=or 任一人通过→同节点其余 PENDING task 置 SKIPPED→`_advance_after_node_approved`(803-966,有后续节点则解析下节点审批人建 task+站内信,无则实例 APPROVED+建 outbox+通知申请人);拒绝→其余置 CANCELLED、实例 REJECTED、通知申请人、调 on_rejected。and 模式全员通过才 advance。 + +五、outbox。通过后写 approval_outbox(PENDING)→Celery 默认队列 `execute_approval_outbox`(worker/approval/tasks.py)→`ApprovalOutboxService.execute_outbox`(approval_outbox_service.py:13-76)执行 on_approved:成功→instance=executed;失败→execute_failed+异常单+通知管理员,管理端可重试。 + +六、撤回。`withdraw_instance`(approval_center_service.py:418-492):仅申请人本人(430-431);PENDING task 置 CANCELLED、实例 WITHDRAWN、审计+站内信通知有 task 的审批人、调 on_withdrawn。⚠️无状态守卫——不校验实例当前状态,已 approved/executed 的实例也能被翻成 WITHDRAWN(API 层 approval_user.py:83-97 也无守卫)。 + +七、富 payload 与详情页。`payload_snapshot`/`detail_snapshot` 均为 JSON 列(models/approval_instance.py:61-62),无 schema 约束,可承载任意富快照;`get_task_detail`(approval_center_service.py:99-203)原样返回两个 snapshot+flow_nodes+tasks+action_logs。前端渲染:client `ApprovalCenterDialog.tsx` 的 TaskDetailPanel(src/frontend/client/src/components/approval/ApprovalCenterDialog.tsx:682-724)把 detail_snapshot 用 Object.entries 过滤 DETAIL_INTERNAL_KEYS 后渲染为通用两列 key-value 网格,无分区/无场景定制渲染机制;唯一场景特例是 menu_access_request 的撤销授权按钮分支(line 380)。审批处理界面(我的审批/我的申请/时间线)只存在于 client 端此弹窗;platform 端 ApprovalPage(2524 行)只有场景/分支/流程/节点配置与异常处理,无审批单处理面。 + +八、取消与初始化。实例 CANCELLED 状态与 on_cancelled 钩子约定已存在,但唯一触达路径是管理员对 EXCEPTION 态实例的异常取消(approval_exception_service.py:177-215)——没有"业务资源被删→系统取消在途单"的服务方法。场景 seed:`_init_default_approval_scenarios`(common/init_data.py:342-436)只为 DEFAULT_TENANT 幂等 seed 频道/知识空间两场景(catch-all 分支→单 or 节点→资源 owner/manager 来源),菜单场景与新租户均不自动 seed。 + +## key_files +- .claude/skills/approval-module/SKILL.md — 审批模块权威架构参考,本次核实其关键声明均与代码一致;含维护契约(改代码须同步改 skill) +- src/backend/bisheng/approval/domain/services/approval_gate.py — 统一网关:在途去重(92-103)、场景缺失抛错(109-111)、pass/flow/exception 分流、路由匹配(417-460);「未配置兜底租户管理员」缺口的核心改造点 +- src/backend/bisheng/approval/domain/services/approval_center_service.py — decide_task 或签/会签流转(658-801)、_advance_after_node_approved(803-966)、withdraw_instance(418-492,无状态守卫)、get_task_detail(99-203) +- src/backend/bisheng/approval/domain/services/approver_resolver.py — 审批人来源解析;tenant_admin 分支(63-74)错用 AdminRole 系统超管近似真租户管理员——兜底需求的前置修复点 +- src/backend/bisheng/approval/domain/services/approval_registry.py — preset 目录(with_default_presets:14-45)+handler 注册表;新场景第一件套 +- src/backend/bisheng/approval/domain/services/approval_runtime_handler_factory.py — build_runtime_handler 硬编码 if 链(17-35);新场景第三件套,outbox/advance/钩子共用 +- src/backend/bisheng/approval/domain/services/knowledge_space_subscribe_scenario_handler.py — 场景 Handler 协议实证:build_title/build_detail/resolve_approvers/on_approved/on_rejected/on_withdrawn/on_cancelled +- src/backend/bisheng/knowledge/domain/services/knowledge_space_service.py — 业务入口接网关范式(subscribe_space:4607-4664 先过网关再落业务态;_build_space_approval_gate:4700-4710);新场景第二件套模板 +- src/backend/bisheng/approval/domain/models/approval_instance.py — 9 状态实例模型;payload_snapshot/detail_snapshot JSON 列(61-62)可承载富 payload;CANCELLED 枚举已存在(21) +- src/backend/bisheng/approval/domain/repositories/approval_instance_repository.py — find_duplicate_active_instance(52-69):在途单去重键含 applicant_user_id、活跃集不含 approved/executing,且命中是静默返回非报错——与 PRD「须先撤回」语义有差 +- src/backend/bisheng/approval/domain/services/approval_exception_service.py — 唯一的实例取消路径(177-215,仅限 EXCEPTION 态、管理员操作);cancel-on-delete 缺口对照物 +- src/backend/bisheng/approval/domain/services/approval_outbox_service.py — outbox 执行/重试;成功→executed、失败→execute_failed+异常+通知;发布「终检+上线」动作的承载位 +- src/backend/bisheng/approval/domain/services/approval_notification_service.py — 站内信统一封装;_get_admin_recipient_ids(122-150)是真租户管理员解析的正确参照实现 +- src/backend/bisheng/common/init_data.py — _init_default_approval_scenarios(342-436):场景 seed 范式(仅 DEFAULT_TENANT),应用发布场景 seed 可仿此 +- src/frontend/client/src/components/approval/ApprovalCenterDialog.tsx — 唯一的审批处理界面(client 端弹窗,1037 行);TaskDetailPanel(682-724) 通用 key-value 渲染 detail_snapshot,menu_access 特例分支(380)是场景定制的最小先例 +- src/frontend/platform/src/pages/ApprovalPage/index.tsx — 管理后台审批页(2524 行):仅场景配置(preset 下拉 219-270、审批人来源 UI 591-840)+异常处理,无审批单处理面 + +## reuse +- 统一审批网关与全套流转:应用发布作为新 scenario_code 走 ApprovalGate.request_or_pass → 路由 → 节点 task → decide_task,零框架改动(approval_gate.py + approval_center_service.py) +- 或签语义(GOV-02 多名租户管理员任一人处理即出终态):node_mode=or 已完整实现——任一人通过→其余 SKIPPED→advance,拒绝→其余 CANCELLED→REJECTED(approval_center_service.py:781-790,691-696) +- 场景配置 UI(GOV-02 复用既有配置能力):platform ApprovalPage 的 preset 下拉新增场景、分支/流程/节点编辑、direct_user 任意用户选择器、tenant_admin 来源选项与 i18n label 均现成(ApprovalPage/index.tsx:136,181-186,591-840);后端只需在 with_default_presets 加一条 preset +- 审批人候选=租户内任意用户:direct_user source + platform 用户选择器已满足(approver_resolver.py:45-50 + ApprovalPage user picker:622-713) +- outbox 异步执行(RT-03 step5 终检+上线):通过→approval_outbox→Celery 默认队列→handler.on_approved,失败→execute_failed 异常+管理员通知+可重试(approval_outbox_service.py:13-84,worker/approval/tasks.py) +- 站内信触达(RT-03 step4 审批人收站内消息与待办 + §3.2):创建任务/下节点/通过/拒绝/撤回/异常全矩阵现成(ApprovalNotificationService + _send_approval_notify),新场景只需新增 action_code 文案 +- 撤回(RT-03 验收6 审批中 owner 可撤回、撤回后再提交生成新单):withdraw_instance 申请人本人限定+on_withdrawn 钩子+通知审批人现成(approval_center_service.py:418-492);再提交自然生成新实例(WITHDRAWN 不在去重活跃集) +- 富 payload 承载(能力声明摘要/可见范围快照/资源档位/变更摘要):payload_snapshot/detail_snapshot 双 JSON 列无 schema 约束,handler.build_detail 可写入任意结构,get_task_detail 原样透出(approval_instance.py:61-62, approval_center_service.py:169-170)——存储与 API 层零改动,仅前端渲染需定制 +- 同一应用至多一个在途单(部分复用):find_duplicate_active_instance 以 business_key=app:{id} 即可获得近似约束(approval_instance_repository.py:52-69),但语义差异见 gaps +- 驳回理由/审批意见:decide_task 的 comment 字段+action_log+通知 reason 链路现成(必填校验需补) +- 全链审计(自审强制审计标注的承载点):提交/pass/通过/拒绝/撤回/handler 成败均写 AuditLogDao.ainsert_v2,metadata 可扩展 self_approval 标注 +- 场景 seed 范式:_init_default_approval_scenarios 的『场景→catch-all 分支→单 or 节点』幂等 seed 可直接仿写应用发布默认流程(init_data.py:316-436) + +## gaps +- 未配置兜底=租户管理员(GOV-02 核心规则):现状三种失败路径没有一条会兜底——场景行缺失/禁用直接 raise ApprovalScenarioDisabledError(发布请求失败)、路由不命中→EXCEPTION(route_missing) 卡异常单、审批人解析为空→EXCEPTION(approver_empty)。需新做兜底机制(gate 级、handler.resolve_approvers 级或按需 seed 级,取向待定) +- tenant_admin 审批人来源解析必须先修:approver_resolver.py:63-74 用 AdminRole(系统超管) 近似,多租户下兜底会落到错误人群;应改用 TenantAdminService.list_tenant_admins(通知侧 approval_notification_service.py:139-141 已有正确实现可搬) +- 提交人自动跳过(GOV-02):gate 首节点建 task(gate.py:232-248)与 advance 下节点建 task(approval_center_service.py:937-953)均不排除 applicant,需新增排除逻辑 + 『全租户仅一名管理员时允许自审+审计自审标注』分支——现有三场景均无此语义,属新框架能力或场景特化 +- 删除应用取消在途审批单(cancel-on-delete):无系统级 cancel_instance 服务方法;现有终止路径仅申请人撤回与管理员异常取消(仅限 EXCEPTION 态)。CANCELLED 枚举与 on_cancelled 钩子约定已在,需新增可由业务侧(应用删除流程)调用的系统取消方法(置 CANCELLED+取消 PENDING task+通知+审计) +- 应用发布场景『三件套』注册:registry preset + 发布入口构造 Gate/注册 handler + build_runtime_handler if 分支 + AppPublishScenarioHandler(build_detail 组装四类快照、resolve_approvers、on_approved 驱动终检/上线、on_rejected/on_withdrawn/on_cancelled 驱动应用状态机)——全新但有成熟模板 +- 定制审批单详情页(RT-03 四分区+操作区):client TaskDetailPanel 是通用 key-value 网格,需为应用发布做场景定制渲染(头部基本信息/能力声明摘要/可见范围快照+『仅供参考』标注/资源档位/迭代变更摘要分行/『无变更』显式态),现仅 menu_access_request 一个最小特例分支可作先例,建议做成按 scenario_code 分发的详情渲染扩展点 +- 『查看待上线版本』审读视图入口 + 预览试用状态区(RT-03 操作区/临时预览实例四态):审批中心侧需新增入口按钮与状态展示;审读视图/预览实例本体属运行时侧(非审批中心范畴,但详情页需要集成点) +- 驳回理由必填:decide_task 的 comment 可选,需按场景加必填校验(后端 per-scenario 校验或前端强制) +- 在途单语义对齐:PRD『再次提交须先撤回』=显式拒绝,现 gate 去重是静默返回已有实例;且去重键含 applicant_user_id(owner 变更期可能双在途)、活跃状态集不含 approved/executing(审批通过到 executed 之间的窗口可再提交)——发布提交入口需业务层显式 pre-check + 明确报错 +- withdraw_instance 状态守卫:需补『仅 PENDING 可撤回』终态守卫,否则发布通过已上线后仍可被翻成 WITHDRAWN(对发布场景是状态机破坏) +- 新租户/存量租户的发布场景初始化:seed 仅覆盖 DEFAULT_TENANT;若走 seed 路线兜底,需要覆盖新建租户钩子或首次提交时懒初始化 +- 站内信新 action_code 文案与跳转:应用发布场景的通知文案(build_notify_content)与点击跳转目标(现跳 client 审批弹窗)需按最终处理界面落位调整 + +## risks +- 【界面落位歧义·高】PRD RT-03/GOV-02 写审批单详情在『平台审批中心』(platform 管理后台),但现状审批任务处理界面只存在于 client 端 /workspace 的 ApprovalCenterDialog,platform ApprovalPage 只有场景配置+异常处理、无任何审批单处理面。若按 PRD 字面在 platform 新建整套『我的审批+详情+操作』是大工作量;若复用 client 弹窗则 PRD 表述需修订,且发布审批人(可能是管理后台角色)要有 client 入口 +- 【tenant_admin 解析修正的存量影响】把 approver_resolver 的 tenant_admin 从 AdminRole 改为 TenantAdminService 会同时改变存量已配置 tenant_admin source 节点的实际审批人(从系统超管变为真租户管理员)——修复正确但属行为变更,需评估存量租户影响并在 release note 声明 +- 【C4/C3 合规】on_approved 上线动作涉及广场可见性/权限写入必须走 PermissionService→OpenFGA(constitution C4);outbox 在 Celery worker 执行,handler 内查询需 set_current_tenant_id(skill §11 已有范式),新增表需注册 _TENANT_AWARE_MODEL_MODULES(C3) +- 【模块反向依赖】build_runtime_handler 将 approval 模块指向应用运行时模块(on_approved 调上线服务),与现有 channel/knowledge 同模式可接受,但需局部 import 防循环依赖(现有实现均如此) +- 【假成功红线】skill §6 明确:on_approved 前置条件缺失必须 raise、不得 return 状态字符串伪装成功(现 knowledge handler 的 missing_membership return 即为反例被 skill 点名);发布 handler 的终检不通过(配额不足→待上线态)与执行失败必须严格区分——PRD 语义里『终检配额不足』是合法业务结果而非失败,on_approved 返回语义要精确设计,否则会落 execute_failed 异常单误导管理员 +- 【审批通知时点】『通过』站内信在最后节点 finalize 时发、不等 outbox 执行完(skill §8 注);发布场景『通过≠已上线』(还有终检),通知文案若写『已上线』会说谎,需要在 outbox 成功回调补真正的上线通知或措辞收敛 +- 【多节点审批链】GOV-02 说『审批人/审批链』,引擎支持多节点顺序流转已验证;但提交人自动跳过在多节点链中的语义(跳过整节点 vs 仅从该节点候选中剔除)PRD 未细化,or 节点剔除后若候选为空会落 approver_empty 异常,与『发布不因未配置而卡死』冲突,兜底逻辑必须覆盖此路径 +- 【审批人预览身份/审计(NFR-1.2)】属运行时侧实现,但审批单操作区要携带入口;审批例外条款(按 owner 权限放行)与审批中心的权限模型无交集,不要试图在 approval 模块内实现 + +## open_questions +- 应用发布审批单的处理界面落在哪:client 审批中心弹窗(现状唯一处理面,改造小但入口在工作台)还是 platform 管理后台新建审批处理面(符合 PRD 字面但工作量大)?这是前端工作量的最大变量,需产品拍板 +- 兜底『租户管理员』的实现取向:A) gate 通用兜底(改框架,所有场景 route_missing/approver_empty 都受影响);B) 应用发布 handler 内兜底(resolve_approvers 为空时补租户管理员,局部无副作用);C) 租户首次提交时懒 seed 默认流程(数据态,管理后台可见可改)——三者对『未配置时场景行显式提示』的 UI 需求支持度不同 +- 『提交人自动跳过』是应用发布场景特有规则还是审批中心通用能力升级?若通用,存量三场景(如知识空间 owner 自己申请加入自己管理的空间)行为会变 +- 在途单唯一性按『应用』还是『应用+提交人』?现 gate 去重键含 applicant_user_id;应用 owner 转移或多管理员代提场景下会出现双在途,business_key 设计(app:{id} vs app:{id}:user:{uid})需定 +- 驳回/撤回/取消对应用状态机的驱动细节:首发回草稿、迭代弃待上线版本、回滚单终止——这些由 on_rejected/on_withdrawn/on_cancelled 钩子驱动,payload_snapshot 需携带哪些判别字段(version_id、publish_type=首发/迭代/回滚、target_version)需与运行时侧状态机设计对齐 +- tenant_admin resolver 修正是否随本特性一并落地并接受存量行为变更,还是为应用发布场景单独新增一个正确的 fallback source 类型(如 tenant_admin_fga)保持存量不动? +- 审批单详情四分区的数据冻结口径:detail_snapshot 在提交时一次性冻结(现机制),审批期间可见范围实时变更『仅供参考』标注是否意味着详情页要同时展示快照值与实时值(后者需详情 API 加实时查询)? diff --git a/features/v3.0.0/000-prd1-discovery/research/audit-notify.md b/features/v3.0.0/000-prd1-discovery/research/audit-notify.md new file mode 100644 index 0000000000..9ffed0ead2 --- /dev/null +++ b/features/v3.0.0/000-prd1-discovery/research/audit-notify.md @@ -0,0 +1,84 @@ +# audit-notify + +## summary +审计与站内消息两套子系统均已存在且机制完备,PRD GOV-04 与「事件触达」大部分可在既有骨架上扩展。审计侧:单表 auditlog 双轨写入(legacy EventType 枚举 + v2.5.1 结构化 ainsert_v2),后端已支持平台超管跨租户查询(tenant_scope=None)与 F019 admin-scope 切换,但审计页前端无租户筛选/租户列、操作日志无导出;「按 key 用量账单」是最大缺口——llm_token_log/llm_call_log 无 key 维度、无聚合 API/UI,且 API key 体系本身(开放 API PRD)尚未实现,构成硬前置依赖。站内消息侧:bisheng/message 模块提供 send_generic_notify/send_generic_approval 通用门面,任意后端模块可向任意 user_id 列表发消息,client 工作台铃铛+未读角标+消息弹窗全链路在用,管理后台无消息面(与 PRD 一致);~8 条新事件通知 ≈ 每条一个后端调用点 + 新 action_code + client i18n/跳转映射,工作量小(约 3-5 人天),审计扩展中等(页面增量 2-4 人天 + 双归属 schema 1-2 人天),按 key 账单需新建(约 5-8 人天,另加 key 体系本身)。 + +## current_state +【审计/操作日志(全部 verified)】 +■ 表:单表 `auditlog`,模型 `AuditLog`(src/backend/bisheng/database/models/audit_log.py:97-169),uuid 主键。双轨字段并存:legacy(operator_id/operator_name/group_ids(JSON)/system_id/event_type/object_type/object_id/object_name/note/ip_address)+ v2.5.1 F011 结构化字段(tenant_id=资源侧叶租户、operator_tenant_id=操作者侧租户、action(String64 索引)、target_type/target_id/reason/audit_metadata(SQL 列名 metadata, JSON))。老调用方继续写 legacy 列(新列 NULL),新调用方写结构化列。 +■ 写入 API 两条路:(1) legacy 门面 `AuditLogService`(src/backend/bisheng/api/services/audit_log.py,918 行)按模块提供语义化方法(create_chat_workflow/create_knowledge/update_user/user_login/create_channel/create_knowledge_space...),内部拼 AuditLog → `AuditLogDao.insert_audit_logs`;事件枚举 `EventType`(约 33 值)+ `SystemId`(7 模块)+ `ObjectType`(audit_log.py:24-94)。(2) v2 结构化 `AuditLogDao.ainsert_v2(tenant_id, operator_id, operator_tenant_id, action, target_type, target_id, reason, metadata, ...)`(audit_log.py:365-432),action 为点分字符串(tenant.mount / approval.task.approve 等),15+ 调用方分布在 tenant/llm/approval/sso_sync/org_sync/admin 各域服务;operator_name 缺省自动查 UserDao 回填,operator_id=0 记 'system'。 +■ UI 可见性白名单:`_UI_VISIBLE_V2_ACTIONS` 元组(audit_log.py:178-202)——v2 action 不在此 tuple 内则只写库不进审计页;注释明确要求与前端 `getActionsApi`/`getActionsByModuleApi` 白名单 lockstep。合成模块映射 `_V2_NAMESPACE_TO_ACTION_PREFIX`(audit_log.py:207,tenant./llm.server./approval. 三个命名空间)。 +■ 租户 scoping 与跨租户:`AuditLogDao.get_audit_logs(..., tenant_scope)`(audit_log.py:239-313):tenant_scope=None → 平台超管全租户视图;=X → `_visible_for_tenant`(tenant_id==X OR operator_tenant_id==X)。DAO 全程 `bypass_tenant_filter()` 自管隔离。判定在 `AuditLogService._get_audit_tenant_scope`(api/services/audit_log.py:59-75):is_global_super 且无 F019 admin-scope → None(跨租户),否则 get_current_tenant_id()。即「平台超管跨租户查询」后端已存在,还可经 F019 admin-scope 切到目标租户视角。 +■ 查询接口:`/api/v1/audit`(GET,筛选 group_ids/operator_ids/start_time/end_time/system_id/event_type + 分页,api/v1/audit.py:13-27)、`/audit/operators`(操作人下拉)、`/audit/session` + `/audit/session/export/data`(会话审计及其导出)。操作日志本体无导出端点。权限:is_admin() 或角色 web_menu 含 'log' 或用户组管理员(组内交集)。 +■ 平台审计页:platform/src/pages/LogPage/systemLog/index.tsx(264 行)。筛选器:操作用户(多选)/用户组/起止日期/系统模块/操作行为(模块与行为下拉硬编码在 platform/src/controllers/API/log.ts 的 getModulesApi/getActionsApi);表列:审计ID/用户名/操作时间/系统模块/操作行为/对象类型/操作对象/IP/备注。无导出按钮、无租户筛选、无租户列。会话审计页 useAppLog/index.tsx 有 CSV 导出先例(exportCsvDataApi + exportCsv 前端拼 CSV)。 +■ 用量数据:`llm_token_log`(bisheng/llm/domain/models/llm_token_log.py:31-82,tenant_id/user_id/model_id/server_id/session_id/prompt/completion/total_tokens/created_at),写入方 `LLMTokenTracker.record_usage`(llm/domain/services/token_tracker.py:38,依赖 tenant ContextVar,缺失即抛 TenantContextMissingError)与 workflow/callback/llm_usage_callback.py;`llm_call_log`(llm/domain/models/llm_call_log.py:24-73,含 endpoint/status/latency_ms/error_msg = 调用次数与成败)。读侧目前仅 QuotaService._count_tokens_monthly 用原生 SQL 求和做配额(role/domain/services/quota_service.py:654),没有任何「用量账单」查询 API 或 UI。两表均无 api key / 应用 token 维度;仓内不存在 ApiKey 模型(grep database/models 与 open_endpoints 无 api_key,与开放 API PRD「v2 现状零鉴权」一致)。 +■ 双归属:audit_log 只有 operator_*(actor)侧,无 subject(被代表用户)列;audit_metadata JSON 可承载但不可索引筛选。 + +【站内消息(全部 verified)】 +■ 表:`inbox_message`(bisheng/message/domain/models/inbox_message.py:22-87):content=JSON 数组(结构化 items:user/system_text/business_url/target/tooltip_text/agree_reject_button)、sender(int)、message_type(notify|approve)、receiver=JSON int 数组、status(wait_approve|approved|rejected)、action_code(varchar64,驱动前端文案与路由)、operator_user_id、tenant_id;配套 `inbox_message_read` 已读表。两表均注册在 `_TENANT_AWARE_MODEL_MODULES`(core/database/tenant_filter.py:79-80),读写受 F013 租户自动过滤/回填。 +■ API:`/api/v1/message/*`(message/api/endpoints/message_endpoint.py):list(tab=all|request)、unread_count、mark_read、mark_all_read、approve(同意/拒绝按钮回传)、delete。tab=request 用 `APPROVAL_CENTER_NOTIFY_ACTION_CODES`(message_service.py:32,10 个审批类 action code)过滤。 +■ 发送门面:`MessageService.send_generic_notify(sender, receiver_user_ids, content_item_list, action_code)`(message_service.py:401)与 `send_generic_approval(...)`(:474,含 agree_reject_button + approval_id 回填)。任意模块可向任意 user_id 列表发消息——现有调用方:channel_service(4 处)、knowledge_space_service(3 处)、permission/resource_permission_notification_service(3 处)、approval_service/approval_center_service/approval_notification_service、tenant/domain/services/inbox_helper.py(无请求上下文的 Celery/SSO 场景 best-effort 发送,sender=0,并有 list_global_super_admin_ids 的 FGA 收件人解析先例)。内容统一由 `notification_content.build_notify_content` 构造。send_message 尾部挂 `maybe_forward_external`(notification/forwarder.py,中粮 E+ 定制外发钩子,按 action_code 路由)。 +■ 审批中心触达:`approval/domain/services/approval_notification_service.py` 封装 notify_user/notify_users → send_generic_notify(action code 如 approval_task_pending/approval_instance_approved);审批「待办」本体在 approval 模块自有表(ApprovalInstance/ApprovalTask,approval/domain/models/approval_instance.py),站内消息只是通知镜像,处理动作回审批中心界面完成——与 PRD「消息不承载操作」的约定天然一致。 +■ Client 工作台入口:铃铛 + 未读角标在 client/src/layouts/UserPopMenu.tsx(Outlined.Bell,>99 显示 99+),计数 hook useNotificationCount.ts(150s 轮询 + focus/visibilitychange 即时刷新),消息中心弹窗 NotificationsDialog.tsx(1221 行,tab 全部/申请、action_code→i18n label 映射与跳转逻辑硬编码在组件内、agree/reject 按钮调 /message/approve)。API 封装 client/src/api/message.ts。 +■ Platform 管理端无任何消息 UI(grep message/list、unread_count 无命中)——与 PRD「管理后台不设消息面」现状即吻合。 + +## key_files +- src/backend/bisheng/database/models/audit_log.py — AuditLog 模型(legacy+v2 双轨字段)、EventType/SystemId/ObjectType 枚举、_UI_VISIBLE_V2_ACTIONS 白名单、AuditLogDao.get_audit_logs(tenant_scope)/ainsert_v2 —— 审计扩展的核心锚点 +- src/backend/bisheng/api/services/audit_log.py — AuditLogService:legacy 语义化写入门面 + _get_audit_tenant_scope(超管跨租户/F019 admin-scope 判定)+ 查询权限(is_admin/web_menu 'log'/组管理员) +- src/backend/bisheng/api/v1/audit.py — /api/v1/audit 查询路由(无操作日志导出端点;/session/export/data 是会话导出) +- src/frontend/platform/src/pages/LogPage/systemLog/index.tsx — 管理后台审计页(PRD 说的「既有审计页」):5 筛选器 + 9 列;无导出、无租户筛选/列 +- src/frontend/platform/src/controllers/API/log.ts — 前端模块/行为枚举白名单硬编码处(getModulesApi/getActionsApi/actionToI18nKey),需与后端 _UI_VISIBLE_V2_ACTIONS lockstep;useAppLog 页有 exportCsv 先例 +- src/backend/bisheng/llm/domain/models/llm_token_log.py — token 用量表(tenant/user/model/server/session 维度,无 key 维度)——按 key 账单的改造对象 +- src/backend/bisheng/llm/domain/services/token_tracker.py — LLMTokenTracker.record_usage 写入口(依赖 tenant ContextVar,缺失抛 TenantContextMissingError) +- src/backend/bisheng/llm/domain/models/llm_call_log.py — 调用次数/成败/耗时表(endpoint/status/latency_ms),账单的『调用次数』数据源 +- src/backend/bisheng/message/domain/models/inbox_message.py — 站内消息表 InboxMessage(content JSON items/receiver JSON 数组/action_code/tenant_id) +- src/backend/bisheng/message/domain/services/message_service.py — MessageService:send_generic_notify/send_generic_approval 通用发送门面 + APPROVAL_CENTER_NOTIFY_ACTION_CODES(tab=request 过滤) +- src/backend/bisheng/message/api/endpoints/message_endpoint.py — /api/v1/message/* 路由:list/unread_count/mark_read/mark_all_read/approve/delete +- src/backend/bisheng/message/domain/services/notification_content.py — build_notify_content:通知内容协议统一构造器(新事件按此拼 content items) +- src/backend/bisheng/approval/domain/services/approval_notification_service.py — 审批中心站内信触达封装(notify_users → send_generic_notify),发布审批类事件的复用入口 +- src/backend/bisheng/tenant/domain/services/inbox_helper.py — 无请求上下文(Celery/SSO)发站内信的先例 + list_global_super_admin_ids(FGA 解析管理员收件人) +- src/frontend/client/src/layouts/UserPopMenu.tsx — 工作台铃铛图标 + 未读角标(PRD『消息提醒』入口,已存在) +- src/frontend/client/src/components/NotificationsDialog.tsx — 消息中心弹窗(1221 行):action_code→i18n 文案与跳转映射硬编码于此,新事件需扩展 +- src/frontend/client/src/hooks/useNotificationCount.ts — 未读数轮询(150s + focus 刷新) +- src/backend/bisheng/core/database/tenant_filter.py — inbox_message/inbox_message_read 注册于 _TENANT_AWARE_MODEL_MODULES(:79-80)——消息读写受租户自动过滤,跨租户投递需留意 +- src/backend/bisheng/notification/forwarder.py — send_message 尾部的外部转发钩子(中粮 E+ 定制),新增 action_code 不影响但会经过其路由判断 + +## reuse +- 审计写入机制:12 类新事件全部可走既有 v2 结构化写入 AuditLogDao.ainsert_v2(database/models/audit_log.py:365),按点分命名空间新增 action(如 app.create/app.publish/apikey.issue),与 15+ 既有调用方同一模式;不需要新表(audit_metadata JSON 可放能力声明 diff、版本号等特有要素) +- 平台超管跨租户审计查询:后端已完整支持——AuditLogDao.get_audit_logs 的 tenant_scope=None 全租户视图 + _visible_for_tenant OR 谓词 + F019 admin-scope 切租户视角(api/services/audit_log.py:59-75);只差前端租户筛选器/租户列与接口透传参数 +- 审计查询面:直接扩展 platform LogPage/systemLog 页 + /api/v1/audit 接口;模块/行为下拉的扩展点即 log.ts getModulesApi/getActionsApi + 后端 _V2_NAMESPACE_TO_ACTION_PREFIX/_UI_VISIBLE_V2_ACTIONS;导出可照抄 useAppLog 页的 exportCsvDataApi+exportCsv 前端 CSV 先例 +- 用量原始数据:token 计量复用 llm_token_log + LLMTokenTracker.record_usage(llm/domain/services/token_tracker.py:38),调用次数/耗时/成败复用 llm_call_log——账单只需加 key 归属维度和聚合读路径,不必新造计量链路 +- 站内消息发送:MessageService.send_generic_notify(message/domain/services/message_service.py:401)已是『任意模块→任意 user_id 列表』的通用门面,PRD ~8 条事件通知全部可用它实现;内容协议走 notification_content.build_notify_content;无请求上下文场景(Celery/发布流水线)照抄 tenant/domain/services/inbox_helper.py 先例 +- 审批类事件触达:PRD 明确『复用审批中心既有触达』——approval_notification_service.notify_users + send_generic_approval + 审批中心待办(ApprovalInstance/ApprovalTask)整链已在生产使用;应用发布审批接入审批中心场景(scenario_code)后触达自动获得 +- 工作台『消息提醒』入口:铃铛+未读角标+消息弹窗+已读标记+150s 轮询全部现成(UserPopMenu.tsx / NotificationsDialog.tsx / useNotificationCount.ts),新事件仅需 action_code→i18n label 与跳转落点映射 +- 『管理后台不设消息面』:platform 现状本来就没有消息 UI(已 grep 验证零调用),该 PRD 约定零工作量 +- 收件人为『租户管理员』的解析:inbox_helper.list_global_super_admin_ids 提供了经 OpenFGA list_users 解析管理员收件人的现成写法 + +## gaps +- 审计双归属(actor=应用 token / subject=当前访问用户):audit_log 无 subject 列——需新增列(alembic,兼顾 DM8)或约定 audit_metadata JSON 字段;若要按 subject 筛选/追责必须加索引列。companion PRD §4.4.5/§4.8.1 同样要求,两册需统一字段定义 +- ~12 类新审计事件的写入点:分散在应用工场各新功能(造应用/能力声明/发布回滚/密钥事件/改码权交接/可见范围变更/生产数据行编辑/访问记录),每个功能落地时埋点 ainsert_v2 + 三处白名单同步(后端 _UI_VISIBLE_V2_ACTIONS、前端 getModulesApi/getActionsApi、i18n + logActions.test.ts) +- 审计页前端增量:租户筛选器 + 租户列(仅平台超管视角)+ /api/v1/audit 接口透传 tenant 参数;操作日志导出(后端无导出端点,前端无按钮——会话导出不覆盖操作日志) +- 按 key 用量账单(最大块新建):① API key/应用 token 体系本身未实现(无 ApiKey 表,v2 零鉴权——硬前置依赖 companion 开放 API PRD 落地);② llm_token_log/llm_call_log 加 key_id(credential 归属)与 actor/subject 维度列;③ 聚合查询 API(按 key/时间段/模型拆分的 token 用量+调用次数);④ platform『API key 管理页』行内用量入口 UI(该管理页本身也属新建);⑤ client 工作台『我的 API key』本人用量自查 UI +- 访问记录(RT-01 谁在用)与运行期能力调用审计:高频写入场景,audit_log 现无异步/批量写入与归档能力——需要决定是否进 audit_log 或单独访问日志表 +- ~8 条事件通知的新 action_code 全链路:后端常量 + 各业务调用点(配额不足通知 owner+租户管理员、下线/重新上线/代调可见范围、应用 token 强制吊销等)+ client NotificationsDialog 的 label/跳转映射 + 三语 i18n;跳转落点(发布面/应用管理列表)本身是 PRD-2/RT 的新页面 +- 『租户管理员』收件人解析服务:现有先例只解析 global super_admin,需按租户解析 tenant_admin 用户列表的通用 helper + +## risks +- audit_log 单表容量/性能:uuid 主键 + 8 个索引,现有事件全部低频管理动作;PRD 把『访问记录』『运行期能力调用』这类高频事件也归入审计——直接写 audit_log 会造成写放大与查询变慢,且平台无归档/TTL 机制(DM8 环境已有大表写放大事故先例)。建议高频类走独立表或复用 llm_call_log 扩展(C2 双库都要过) +- 三处白名单 lockstep 漂移:后端 _UI_VISIBLE_V2_ACTIONS ↔ 前端 getModulesApi/getActionsApi ↔ i18n,源码注释已明示必须同步;一次新增 12 类事件极易漏一处导致『写了查不到』(写库成功但 UI 白名单过滤掉) +- 计量写入依赖 tenant ContextVar:LLMTokenTracker.record_usage 缺租户上下文直接抛 TenantContextMissingError——开放 API/应用 runtime 的调用链(应用 token 鉴权后)必须正确 set_current_tenant_id,否则整条计费链路崩;v2 现状零鉴权无此保障,账单功能强依赖 companion PRD 的密钥→租户解析落地(C3) +- inbox_message 受 F013 租户自动过滤(tenant_filter.py:79 注册,verified):消息 tenant_id 由写入时上下文自动回填,读取按读者租户上下文过滤——跨租户投递(如平台超管收子租户事件通知)会被隐藏(inferred,需实测);PRD 事件接收方含租户管理员,多租户部署下要核实写入上下文与接收方租户一致 +- 双归属的『操作人』筛选语义未定:审计页现有『操作人』下拉按 operator_id;应用 token 调用时 operator 是应用还是访问用户需拍板,否则筛选器与 get_all_operators 下拉的行为会混乱 +- DM8 兼容(C2):inbox repo 与 audit DAO 均已为 JSON 查询做 MySQL/DM8 方言分叉(json_contains vs text LIKE);新账单聚合与新审计筛选若用 JSON 函数必须同样分叉,估算时不可按纯 MySQL 计 +- 错误码与模块号(C5/C6):新账单/导出 API 需按 MMMEE 规范注册错误码;新表(如 api key、账单聚合物化表)须注册 _TENANT_AWARE_MODEL_MODULES 否则漏租户过滤(C3) +- NotificationsDialog 已 1221 行,超过前端单文件 600 行硬规——再往里加 ~8 个 action_code 映射前应先按 react-component-refactor 拆分,否则违反前端 P0 规则 + +## open_questions +- 高频事件分层:『访问记录(谁在用)』『运行期能力调用』是否与管理动作同进 audit_log 单表?还是访问记录独立表 + 能力调用挂 llm_call_log/llm_token_log 扩展?(影响审计页『事件类型』枚举呈现与容量方案) +- 双归属字段形态:subject(被代表用户)做可索引新列还是 audit_metadata JSON 约定?审计页『操作人』筛选对双归属事件按 actor 还是 subject 匹配?需与 companion 开放 API PRD §4.8.1 的字段清单一次对齐 +- 按 key 账单的数据通道:实时聚合查询 llm_token_log(量大时慢)还是引入按日聚合表/物化任务?是否随 Beat 定时跑(多租户迭代坑) +- 审计导出的范围与格式:照抄会话导出的前端拼 CSV(当前上限逻辑在前端),还是后端流式导出?超管跨租户导出是否含租户列? +- 操作日志本身要不要出现在应用工场事件里做保留期/合规要求(PRD 未提留存时长,现无 TTL/归档) +- 消息接收方为『租户管理员』时的精确定义:tenant_admin 角色全体?含部门管理员?平台超管是否兜底接收(涉及上文跨租户过滤问题) +- 个人 key 用量自查入口(工作台账户菜单『我的 API key』)与冻结的 knowledge-space-quota-display 账户菜单改造是否共用布局(该分支已冻结,是否复活其账户菜单骨架) +- ~12 类审计事件是否全部要进『审计页 UI 白名单』:现有产品决策(2026-05-06)是 v2 action 只精选子集曝光——应用工场事件是全量曝光还是同样分『合规留痕』与『UI 可见』两档 diff --git a/features/v3.0.0/000-prd1-discovery/research/auth-identity.md b/features/v3.0.0/000-prd1-discovery/research/auth-identity.md new file mode 100644 index 0000000000..5a4302d05d --- /dev/null +++ b/features/v3.0.0/000-prd1-discovery/research/auth-identity.md @@ -0,0 +1,74 @@ +# auth-identity + +## summary +平台登录是「PyJWT HS256 + HttpOnly cookie(access_token_cookie, path=/, host-only)」的无状态体系:账号密码走 /api/v1/user/login,SSO/扫码由商业网关经 HMAC login-sync 回调完成;v1 端点鉴权统一收口在 LoginUser.get_login_user(只认 cookie),token_version(F012) 是唯一的服务端失效机制。PRD 所需的个人 API key、MCP/双协议/CLI 三面、/apps/{id} 入口均无现成实现,但「构造 UserPayload + set_current_tenant_id」的并行鉴权收口模式(get_default_operator_async 已示范)、client 401→LOGIN_PATHNAME→platform 登录→回跳的免二次登录链路、client 头像下拉 AccountSettings.tsx 均可直接承接。账号禁用经 aincrement_token_version 即时废除 JWT,可作为 key 自动失效的挂接点。 + +## current_state +【登录流|verified】账号密码:POST /api/v1/user/login(src/backend/bisheng/user/api/user.py:150)→ UserService.user_login(src/backend/bisheng/user/domain/services/user.py:420-660):验证码(Redis)→RSA 解密密码→同名候选逐一校验→「无可用菜单拒登」守卫(_reject_login_if_user_has_no_usable_access)→多租户 leaf 解析(UserTenantSyncService.sync_user, F012)→读 fresh token_version→LoginUser.create_access_token→set_access_cookies(同时把 access_token 放响应体)→写 Redis USER_CURRENT_SESSION(只写不读,OSS 无单会话/异地登录强制;10604 异地登录码来自商业网关,platform request.ts:173 有处理分支)→审计(AuditLogService.user_login)+telemetry。SSO/扫码:OSS 侧 /api/v1/user/sso(user.py:59,已标 deprecated,需 bisheng_pro)自动建用户+发 token;新链路 = 商业 Java Gateway 完成 IdP/企微交互后回调 HMAC 认证的 /api/v1/internal/sso/login-sync(src/backend/bisheng/sso_sync/api/endpoints/login_sync.py + domain/services/hmac_auth.py)。前端侧:platform 登录页(src/frontend/platform/src/pages/LoginPage/login.tsx:43-84)启动时调 getSSOurlApi()(API/pro),若配置 redirect_login_url 则整页跳 IdP,并把 THIRD_PARTY_LOGIN_URL/THIRD_PARTY_LOGOUT_URL 存 localStorage 供 401/登出跳转;loginBridge.tsx 渲染 SSO/企微扫码按钮(oauthData.sso / oauthData.wx,点击 location.href 跳转);/admin-login 为绕过 SSO 自动跳的后门(routes/index.tsx:161)。 +【JWT 签发与校验|verified】库 = PyJWT(`import jwt`),HS256,自研 AuthJwt 类(src/backend/bisheng/user/domain/services/auth.py:157-221):payload sub=json{user_id,user_name,tenant_id,token_version},exp=now+cookie_conf.jwt_token_expire_time(默认 86400s),iss=jwt_iss(默认 bisheng),secret=settings.jwt_secret。Cookie 配置 CookieConf(src/backend/bisheng/core/config/settings.py:482-493):名 access_token_cookie,path="/",domain=None(host-only),httponly=True,secure=False,samesite=None——path=/ 且 host-only 意味着同源下 /workspace、/admin(以及未来的 /apps/{id})天然共享登录态,这是 RT-01「免二次登录」的现成物理基础。端点鉴权依赖 = LoginUser.get_login_user(auth.py:625,classmethod Depends)→ AuthJwt.get_subject() 默认 auth_from="request" **只读 cookie**;auth_from="headers"(读 Authorization Bearer)分支存在但全仓无调用方(死分支,verified by grep);WebSocket 的 get_login_user_from_ws(auth.py:651)在 get_subject("websocket") 中 `if websocket: token = websocket.cookies.get(...)` 无条件覆盖传入的 t 参数(t 永不生效)。UserPayload = class UserPayload(LoginUser)(src/backend/bisheng/common/dependencies/user_deps.py:10),全仓 v1 端点統一 Depends 它或 LoginUser。HTTP 中间件(src/backend/bisheng/utils/http_middleware.py):_extract_http_access_token 兼收 cookie 与 Bearer → 解 JWT → set_current_tenant_id(C3)+ _validate_token_version(F012:与 UserDao.aget_token_version 比对,Redis key user:{id}:token_version TTL=300s,fail-open,mismatch→401「token_version mismatch」);TENANT_CHECK_EXEMPT_PATHS 豁免登录/验证码/login-sync 等。 +【会话存储|verified】无服务端 session,JWT 全无状态;Redis USER_CURRENT_SESSION(user_current_session:{uid})仅在 login/sso/switch-tenant 写入,无任何读取方——不能当会话依据。 +【前端携带】platform(verified):axios.defaults.withCredentials=true + 请求拦截器把 localStorage ws_token 注入 Authorization: Bearer(src/frontend/platform/src/controllers/request.ts:13-21);但 ws_token 仅在 iframe 内嵌时保存(login.tsx:161 `window.self===window.top ? remove : set`),顶层窗口纯 cookie;注意 Bearer 只被中间件消费,端点依赖仍只认 cookie。client /workspace(verified):~/api/request.ts 纯同源 cookie,setTokenHeader 是 no-op(src/frontend/client/src/api/chat/headers-helpers.ts:7-9),用户态来自 /api/v1/user/info(api/chat/api-endpoints.ts:4);client 自带的 /workspace/login 页(routes/index.tsx:128)POST /api/auth/login 是 LibreChat 遗留死路由(后端无 /api/auth),生产不生效——真登录永远在 platform SPA。 +【登录回跳现状(RT-01 相关)|verified】client 生产 401:request.ts:242-244 `localStorage.setItem('LOGIN_PATHNAME', location.pathname)` → 跳 getPlatformAdminPanelUrl()(src/frontend/client/src/utils/platformAdminUrl.ts,BISHENG_HOST 默认 /admin);platform 登录成功后 login.tsx:163-176 读 LOGIN_PATHNAME 并 location.href 回跳,否则按 default_entry(LoginUser.default_web_entry,auth.py:759)落工作台/后台。已知局限:只存 pathname(丢 query/hash);platform 自身 401 只回根不存回跳地址(request.ts:200-204);SSO 自动跳 IdP 部署下回跳由网关 redirect 配置决定,LOGIN_PATHNAME 机制不参与(网关内部行为为 inferred,login-sync 端点为 verified)。 +【账号禁用/删除传导|verified】禁用 = /user/update 把 delete 置 1(user.py:697-731)→ UserService.ainvalidate_jwt_after_account_disabled(user/domain/services/user.py:70-90)→ UserDao.aincrement_token_version(原子 UPDATE + **主动 aset 刷新 Redis 缓存**,使失效即时而非等 300s TTL;user/domain/models/user.py:513-538)+ TenantScopeService.clear_on_token_version_bump → 中间件下一请求 401。登录侧候选查询排除 delete=1 并单独提示 UserForbiddenError。无用户硬删除端点(delete 字段即软删/禁用,org-sync 禁用带 disable_source 仅超管可恢复)。token_version 机制只覆盖 JWT——PAT 不是 JWT,key 自动失效需另行挂接。 +【(a) PAT 并行鉴权集成点】v2 open_endpoints 现状(verified):零鉴权 + get_default_operator_async / resolve_operator(src/backend/bisheng/open_endpoints/domain/utils.py)按配置 default_operator 构造 UserPayload 并 set_current_tenant_id——这就是「非 cookie 身份 → UserPayload 收口」的现成模式(伴生 PRD 已判此链路改 SAK)。MCP 面/OpenAI-Anthropic 双协议面/CLI 面在代码中不存在(mcp_manage 仅出站 client:clients/{sse,stdio,streamable}.py,无 FastMCP/inbound server,无 /v1/chat/completions 兼容端点,verified by grep)。PAT 鉴权应是与 LoginUser.get_login_user 平行的新 FastAPI dependency:解析 Authorization: Bearer bs-pat-* → hash 查表 → init_login_user(user_id, tenant) + set_current_tenant_id(init_login_user 已支持任意 user_id/tenant_id,auth.py:565)。 +【(c) 账户菜单|verified】src/frontend/client/src/components/Nav/AccountSettings.tsx —— 头像下拉 DropdownMenu(个人知识 MyKnowledgeView、管理后台入口 canShowPlatformAdminEntry、语言子菜单、退出登录);「我的 API key」DropdownMenuItem 加在 DropdownMenuContent(第 100-140 行区间),弹窗形态可仿 MyKnowledgeView/Settings 的受控 open state。 + +## key_files +- src/backend/bisheng/user/domain/services/auth.py — AuthJwt(PyJWT HS256, cookie 名 access_token_cookie)+LoginUser:create_access_token/get_subject(只认 cookie, headers 分支无调用方)/get_login_user 依赖(:625)/init_login_user(:565, PAT 依赖可复用)/default_web_entry(:759 登录落点) +- src/backend/bisheng/common/dependencies/user_deps.py — UserPayload(LoginUser) 全仓 v1 鉴权依赖入口(:10)+get_tenant_admin_user(:78)——PAT 平行依赖应落此层 +- src/backend/bisheng/user/domain/services/user.py — user_login 全流程(:420-660);ainvalidate_jwt_after_account_disabled(:70) 禁用即时废 JWT——key 自动失效的挂接点 +- src/backend/bisheng/user/api/user.py — /user/login(:150) /user/sso(:59 deprecated) /user/logout(:261);/user/update 禁用分支(:697-731) 调 ainvalidate +- src/backend/bisheng/user/domain/models/user.py — UserDao.aget_token_version/aincrement_token_version(:479-538):Redis TTL300s+bump 时主动刷新缓存——PAT 吊销 5s 上界可照抄此模式 +- src/backend/bisheng/utils/http_middleware.py — cookie/Bearer 双收→tenant ContextVar+token_version 校验(fail-open);TENANT_CHECK_EXEMPT_PATHS 豁免清单——新 PAT 面路径需在此定位 +- src/backend/bisheng/core/config/settings.py — CookieConf(:482-493):path=/ host-only httponly——/apps/{id} 同源免二次登录的物理基础 +- src/backend/bisheng/open_endpoints/domain/utils.py — get_default_operator_async/resolve_operator:非 cookie 身份→UserPayload+set_current_tenant_id 的现成收口模式(伴生 PRD 改 SAK 的对象) +- src/backend/bisheng/sso_sync/api/endpoints/login_sync.py — HMAC 认证的网关 SSO 回调(/api/v1/internal/sso/login-sync),SSO 身份进入平台的唯一新链路 +- src/frontend/platform/src/pages/LoginPage/login.tsx — 平台登录页:SSO 自动跳(:43-84)+登录成功读 LOGIN_PATHNAME 回跳(:163-176)+default_entry 分流 +- src/frontend/platform/src/controllers/request.ts — platform axios:withCredentials+ws_token Bearer 注入(:13-21);401→回根(:185-206)、403/404→Page403、10604 异地登录分支 +- src/frontend/client/src/api/request.ts — client axios:纯 cookie;生产 401→存 LOGIN_PATHNAME(仅 pathname)→跳 platform 登录(:242-245)——回跳机制的写端 +- src/frontend/client/src/components/Nav/AccountSettings.tsx — 工作台头像下拉菜单——「我的 API key」入口的落点(DropdownMenuContent :100-140) +- src/frontend/client/src/hooks/AuthContext.tsx — client 登录态容器:user 来自 /api/v1/user/info;logout 走 THIRD_PARTY_LOGOUT_URL 或回 platform +- src/frontend/client/src/utils/platformAdminUrl.ts — client→platform 跳转 URL 构造(BISHENG_HOST/PLATFORM_ORIGIN),回跳链路的另一半 +- src/frontend/client/src/routes/index.tsx — client 路由:/apps 广场已存在(:181)、/share/app 落地页、/workspace/login 为 LibreChat 死路由(:128)——/apps/{id} 新路由的挂载处 + +## reuse +- 免二次登录基础:CookieConf path=/ + host-only(core/config/settings.py:482)使同源下任何新路径(含 /apps/{id})自动带 access_token_cookie,后端 LoginUser.get_login_user 直接可用——RT-01「已登录点开即用」零后端改造 +- 登录回跳骨架:client request.ts:242-244 LOGIN_PATHNAME 写入 + platform login.tsx:163-176 读取回跳,已覆盖「无登录态→登录页→回原地址」主链路,/apps/{id} 挂 client SPA 即可复用(需补 query/hash 保留) +- SSO 复用:登录页 SSO 自动跳/扫码按钮(login.tsx+loginBridge.tsx)与网关 login-sync 回调(sso_sync/)即 RT-01『含平台已配置的 SSO 方式』的现成实现,无需新做 +- PAT 身份收口模式:open_endpoints/domain/utils.py get_default_operator_async 已示范『非 cookie 凭据→查 User→UserPayload.init_login_user+set_current_tenant_id』,bs-pat- 依赖照此结构写即可;init_login_user(auth.py:565) 支持任意 user_id/tenant_id +- 撤销即时生效模式:UserDao.aincrement_token_version(user/domain/models/user.py:513)的『原子 UPDATE+主动刷新 Redis 缓存(TTL300s)』正是 PRD『吊销生效上界 5 秒、主动清缓存』要的形状,PAT 校验缓存可照抄 +- 账号禁用联动:UserService.ainvalidate_jwt_after_account_disabled(user.py:729 唯一调用点)是『禁用→名下 key 全部失效』的天然挂接点;登录候选查询已排除 delete=1 用户 +- 身份/组织注入数据源:LoginUser.init_login_user + /api/v1/user/info(client 已消费)已含 user/role/dept 信息,RT-01『应用取得访问者身份与组织信息』可直接复用该 payload 结构 +- 审计先例:AuditLogService.user_login + telemetry_service.log_event(USER_LOGIN) 给 key 签发/吊销/调用审计提供了现成写入模式 +- 账户菜单落点:AccountSettings.tsx 的 DropdownMenu 结构与 MyKnowledgeView 弹窗形态即『我的 API key』入口+弹窗的模板;canShowPlatformAdminEntry 是入口条件渲染的先例(GOV-07 隐藏可仿) + +## gaps +- 个人 API key 全套后端:新表(key hash、bs-pat- 前缀、scope JSON、过期、吊销态、持有人、最近调用)+ DAO + 签发/吊销/scope 调整服务——注意新表须注册 tenant-aware(_TENANT_AWARE_MODEL_MODULES) +- PAT 鉴权 FastAPI dependency:解析 Authorization: Bearer bs-pat-* → hash 查表 → 校验状态/过期/scope → 查 user.delete(fail-closed) → UserPayload+set_current_tenant_id;含吊销缓存(主动失效,5s 上界) +- 三个消费面全部新建:MCP Server 面(仓内只有出站 MCP client,无 inbound server)、OpenAI/Anthropic 双协议兼容端点(无 /v1/chat/completions 类路由)、CLI(bisheng login/dev/deploy/logs,凭据存本地用户目录) +- /apps/{id} 统一入口路由 + 四类兜底页(无权限/未部署引导/已下线/不存在)——client routes 只有 /apps 广场与 /share/app 跳转,无按应用标识的入口页 +- 回跳机制补强:LOGIN_PATHNAME 只存 pathname 丢 query/hash;platform 自身 401 不记回跳地址;SSO 自动跳 IdP 部署下的回原应用链路需打通(网关 redirect 配置) +- 管理后台 API key 管理页(platform 三类 key 同页 GOV-08)+ 工作台『我的 API key』弹窗(AccountSettings 新 DropdownMenuItem)+ 一次性展示/掩码组件 +- 账号禁用→名下 key 自动失效联动 + 事件审计(现有 ainvalidate 只 bump token_version,不涉及任何 key 概念) +- GOV-07『未部署开放能力层则入口不展示』的配置开关与前端判定链(现无对应 bsConfig/env 字段) +- key 前缀模式进发布前密钥扫描规则集(RT-03/DEV-04 侧,本仓无扫描器) + +## risks +- 端点鉴权只认 cookie(AuthJwt.get_subject 默认分支;headers 分支零调用方):任何『用 PAT 调既有 v1 端点』的想法都会静默失败——PAT 必须走独立 router+独立依赖,不能幻想复用 v1 面(C1/C4:新面权限仍须经 PermissionService) +- 多租户 ContextVar(C3):http_middleware 只从 JWT 设 tenant context,PAT 请求无 JWT——依赖内必须 set_current_tenant_id(get_default_operator 的 NoTenantContextError 教训已写在其 docstring),且持有人 active tenant 需在校验时实时解析 +- token_version 的 300s Redis TTL + fail-open 只因『bump 时主动刷缓存』才达到即时失效——PAT 吊销若只做 TTL 缓存会违背 PRD 5 秒上界承诺,必须复刻主动清缓存;且 fail-open 语义(Redis 挂了放行)对凭据类校验是否可接受需明确改判 fail-closed +- SSO 自动跳 IdP 部署(THIRD_PARTY_LOGIN_URL 配置时登录页整页跳走)下,未登录访问 /apps/{id} 的回跳目标由商业网关 redirect 配置控制,OSS 侧 LOGIN_PATHNAME 机制完全不参与——免二次登录验收项 3 在该形态需网关配合(网关行为 inferred,需与商业侧确认) +- client /workspace/login 是 LibreChat 死代码(POST 不存在的 /api/auth/login)——评审/设计时勿把它当活的登录面(判断在用要追前端的教训) +- 无用户硬删除端点:PRD 写『账号被禁用/删除』,代码只有 delete 软删/禁用一种——『删除』语义需在 spec 里对齐为禁用,或 key 校验对 user 不存在也 fail-closed +- USER_CURRENT_SESSION 只写不读、10604 异地登录来自网关:不要把它当可复用的会话吊销机制 +- cookie secure=False/samesite=None 默认值:手机扫码经外部浏览器进入 /apps/{id} 的场景若将来要求跨站嵌入或 https 严格模式,现配置是隐患(现状 verified,影响 inferred) +- PRD 要求每次调用审计到人+按 key 计量:现有 AuditLogService 是低频操作审计,per-call 写放大需要新的计量通道设计(勿直接往 audit 表灌) + +## open_questions +- /apps/{id} 由哪个 SPA 承载:client(/workspace) 下新路由(天然复用 cookie+回跳+AccountSettings)还是独立轻量 shell?决定回跳改造与四类兜底页的落点 +- SSO 自动跳 IdP 的部署形态下,登录成功回跳原 /apps/{id} 是否需要商业网关改造(把原始 URL 透传进 redirect 链)?OSS 侧无法单方面闭环 +- PAT 三面(MCP/双协议/CLI 网关路径)挂 /api/v2 之下还是新顶级前缀?影响 http_middleware 豁免清单、商业网关转发规则与 GOV-07 开关粒度 +- GOV-07『未部署开放能力层』的判定来源:环境级配置(BS_ env)、DB config、还是 license?前端两个入口(管理页/账户菜单)需要统一的探测接口 +- key 校验对账号状态的策略拍板:每次调用实时查 user.delete(fail-closed,天然满足验收 7)vs 禁用事件批量吊销 key(PRD 两种表述都有,语义需统一) +- PAT 与伴生 PRD 的 SAK(bs-sak-)是否共用同一张凭据表+同一校验底座(GOV-08 说同源)——影响表设计一步到位还是两步走 +- 回跳保真度要求:/apps/{id} 是否会带 query(如渠道参数)?若是,LOGIN_PATHNAME 需改存 pathname+search+hash 并评估开放跳转风险 diff --git a/features/v3.0.0/000-prd1-discovery/research/knowledge-retrieval.md b/features/v3.0.0/000-prd1-discovery/research/knowledge-retrieval.md new file mode 100644 index 0000000000..d4c8b4af57 --- /dev/null +++ b/features/v3.0.0/000-prd1-discovery/research/knowledge-retrieval.md @@ -0,0 +1,55 @@ +# knowledge-retrieval + +## summary +工作台检索的双层过滤(F029)在代码中真实存在且已服务三条调用链:索引层预过滤由 KnowledgeFileVisibilityService.build_index_prefilter 下推 Milvus expr/ES filter,结果层由 post_filter_visible_files 按 view_file 有效权限批量兜底。旧 v2 开放 API 的 aretrieve_chunks(/api/v2/filelib/retrieve)确证只做库级一次校验、无文件级过滤(其端点注释声称有 view_file 过滤,与实现不符),与 PRD 禁令一致。双层路径可用任意 key 解析身份驱动(request=None + UserPayload.init_login_user + set_current_tenant_id 均有生产先例),但当前权限引擎失败语义是「静默降级为缩小集/空集」而非 PRD NFR-1.4 要求的「明确错误态」,且仓库内只有 MCP 客户端、没有 MCP Server——严格 fail-closed 调用面与 MCP 服务端均需新建。 + +## current_state +【工作台双层过滤路径(已在代码核实)】规范实现是 src/backend/bisheng/knowledge/domain/services/knowledge_space_chat_service.py 的 KnowledgeSpaceChatService._retrieve_and_filter(约 :481-570,即开放 API PRD 附录 B 所指 :484-570),由 chat_folder(:694,工作台知识空间/文件夹对话)调用。流程:(1) _require_space_view_permission(:88,库级 view_space 门);(2) 索引层预过滤——knowledge_file_visibility_service.py 的 KnowledgeFileVisibilityService.build_index_prefilter(约 :117-210)先调 PermissionService.list_accessible_ids(relation="can_read", object_type="knowledge_file") 取全租户可读文件集,与空间主版本文件集、文件夹/标签业务范围求交后按 IN/NOT-IN/none/empty 策略矩阵(IndexFilter dataclass)生成 Milvus `document_id in [...]` expr 与 ES terms filter,empty 策略直接短路返回空;(3) 双路召回——KnowledgeRag.init_knowledge_milvus_vectorstore(:112)/init_knowledge_es_vectorstore(:130)(knowledge/domain/knowledge_rag.py),KnowledgeRetrieverTool._arun(tool/domain/langchain/knowledge.py:34,Milvus+ES 并召回 + _rrf_rerank RRF 融合),带两轮扩量重试(retrieval_initial/expansion_multiplier);(4) 结果层兜底——post_filter_visible_files(visibility_service 约 :215-280)对召回 document_id 去重后批量解析有效权限,委托 KnowledgeSpaceService._get_child_item_effective_permission_ids(knowledge_space_service.py:1158,与列表 UI 同一 primitive,INV-6 保证检索=列表可见性一致),只保留含 "view_file" 的文件;admin 短路放行。工作台另一条变体是 workstation/domain/services/workstation_service.py 的 queryChunksFromDB(:1058):Stage1 _filter_visible_space_kb_ids(:954)逐库 is_space_visible 探测(探测异常→视为不可见,fail-closed deny),org 桶走 KnowledgePermissionService.filter_knowledge_ids_by_permission_async(…,"use_kb") 库级收口;Stage3 _post_filter_kb_docs_by_view_file(:1011)复用 post_filter_visible_files——注意该变体**没有索引层文件级预过滤**(只有库级门+结果层兜底),与 chat_folder 路径强度不完全等同。【知识库清单 API】GET /knowledge(knowledge/api/endpoints/knowledge.py:376 get_knowledge,permission_id∈{view_kb,use_kb})→ KnowledgeService.get_knowledge(knowledge_service.py:230):login_user.rebac_list_accessible("can_read","knowledge_library") 给候选,并 creator_ids 合并,再经 permission_service.get_knowledge_permission_map_async 按细粒度 permission id 收口后 KnowledgeDao.aget_user_knowledge 分页。知识空间(type=3)清单在 knowledge/api/endpoints/knowledge_space.py 拆多入口(/mine /managed /joined /department /square 等),底层同样走 PermissionService.list_accessible_ids(knowledge_space_service.py:1844/1868)。【旧 v2 开放 API 弱路径(PRD 禁复用,已核实)】open_endpoints/api/endpoints/filelib.py:620 POST /retrieve → resolve_operator(req.user_id)(open_endpoints/domain/utils.py:77,裸 user_id 无任何准入即构造 UserPayload 并 set_current_tenant_id,即 memory 所记「活的匿名代用户」)→ KnowledgeSpaceChatService.aretrieve_chunks(:808)→ _aretrieve_chunks_dispatch(:927)按 type 分派:type=3 走 _aretrieve_chunks_for_kb(:891)——仅 _require_space_view_permission 库级一次校验 + _build_folder_search_kwargs(:429,只有主版本排除与标签过滤,无任何权限下推)+ 直接返回召回结果,**既无 build_index_prefilter 也无 post_filter_visible_files,无文件级过滤**;type=0 走 _aretrieve_chunks_for_knowledge_base(:960),仅 ensure_knowledge_read_async 库级校验 + 全库/标签检索;type=1/2 抛 KnowledgeTypeNotSupportedError。filelib.py:635 的注释「existing per-user view_file/view_space filtering in aretrieve_chunks (INV-7)」与实现不符(只有 view_space 半句成立),开放 API PRD 附录 B(913-946 行)已澄清同一事实并给出两条路径行号对照。【任意身份可驱动性(已核实)】UserPayload.init_login_user(user/domain/services/auth.py:565)仅需 user_id/user_name/tenant_id 即可构造,自动加载 roles 与 is_global_super(bypass_tenant_filter 内查 UserRoleDao);KnowledgeFileVisibilityService(request=None, login_user=…) 已被 workstation 生产路径使用(workstation_service.py:980/1041),KnowledgeSpaceService 的 self.request 仅用于审计 IP 等且已判空(knowledge_space_service.py:4633)——即双层过滤引擎完全由 login_user + 租户 ContextVar 驱动,key 解析出的身份可直接接入;唯 chat_folder 本体与 MessageSession 会话耦合,不能原样当纯检索 API 用。【fail-closed 现状(已核实,三处全是静默降级非报错)】(a) PermissionService.list_accessible_ids(permission/domain/services/permission_service.py:218):FGA client 不可用→warning 后仅返回 fallback scopes(_finalize_accessible_ids :1405 = creator 自建 + 子租户 admin scope);FGAConnectionError→同 fallback;其它异常→return []。返回缩小集→build_index_prefilter 得 empty→静默返回空结果,方向安全(deny)但无「明确错误态」。(b) PermissionService.check(:82,:137-200):FGA 不可用/连接错→owner/implicit 兜底(注释明言原为硬 fail-closed 后改为兜底以对齐列表路径)。(c) post_filter_visible_files 单文件解析异常→logger.exception 后丢弃该文件(deny),不抛;workstation is_space_visible 探测异常→视为不可见。结论:现状=fail-degrade-to-deny,无任何一处向调用方返回 PRD NFR-1.4 要求的明确错误。【MCP 现状】src/backend/bisheng/mcp_manage/ 只有 MCP 客户端(sse/streamable/stdio ClientManager,平台消费外部 MCP 工具,tool/domain/services/tool.py:356 parse_mcp_schema),仓库无 MCP Server 侧实现。 + +## key_files +- src/backend/bisheng/knowledge/domain/services/knowledge_file_visibility_service.py — F029 双层过滤核心:IndexFilter 策略矩阵、build_index_prefilter(索引层,can_read on knowledge_file)、post_filter_visible_files(结果层 view_file 批量兜底,委托 _get_child_item_effective_permission_ids 与列表 UI 同源)、is_space_visible;构造 request=None 可用 +- src/backend/bisheng/knowledge/domain/services/knowledge_space_chat_service.py — chat_folder(:694)→_retrieve_and_filter(:481-570)=工作台规范双层路径;aretrieve_chunks(:808)/_aretrieve_chunks_for_kb(:891)/_aretrieve_chunks_for_knowledge_base(:960)=v2 弱路径(仅库级校验,无文件级过滤,PRD 禁复用);_build_folder_search_kwargs(:429) 只做主版本/标签过滤 +- src/backend/bisheng/open_endpoints/api/endpoints/filelib.py — POST /retrieve(:620) 旧开放 API 检索端点;:635 注释声称有 view_file 过滤与实现不符;resolve_operator 注入代用户身份 +- src/backend/bisheng/open_endpoints/domain/utils.py — resolve_operator(:77):裸 user_id→UserPayload+set_current_tenant_id,零准入(未来 SAK 委托六道准入的改造点) +- src/backend/bisheng/permission/domain/services/permission_service.py — list_accessible_ids(:218)——FGA 失败静默降级 fallback scopes/[];check(:82)——FGA 失败降级 owner/implicit 兜底;_finalize_accessible_ids(:1405);fail-closed 改造的靶点 +- src/backend/bisheng/workstation/domain/services/workstation_service.py — queryChunksFromDB(:1058) 工作台多库检索变体:Stage1 view_space 门(:954)+Stage3 view_file 后过滤(:1011),无索引层预过滤;:980/1041 证明 request=None 生产可用 +- src/backend/bisheng/knowledge/domain/services/knowledge_service.py — KnowledgeService.get_knowledge(:230):知识库清单权限过滤(rebac can_read on knowledge_library + creator 合并 + permission_map 收口 view_kb/use_kb)——MCP『知识库清单查询』工具可直接复用 +- src/backend/bisheng/knowledge/api/endpoints/knowledge.py — GET /knowledge(:376) 清单端点,permission_id∈{view_kb,use_kb} +- src/backend/bisheng/knowledge/domain/knowledge_rag.py — KnowledgeRag(:18):init_knowledge_milvus_vectorstore(:112)/init_knowledge_es_vectorstore(:130)/get_multi_knowledge_vectorstore——Milvus+ES 双路召回入口 +- src/backend/bisheng/tool/domain/langchain/knowledge.py — KnowledgeRetrieverTool(:34):双路召回+RRF 融合;search_kwargs 的 milvus expr/es filter 即索引层预过滤注入点 +- src/backend/bisheng/user/domain/services/auth.py — UserPayload.init_login_user(:565):任意 user_id 构造完整登录态(roles+is_global_super);rebac_list_accessible(:498) +- src/backend/bisheng/knowledge/domain/services/knowledge_space_service.py — _get_child_item_effective_permission_ids(:1158)/_build_child_permission_context(:1137)——结果层 view_file 有效权限解析 primitive(lineage+nearest_binding_wins);self.request 仅审计用已判空(:4633) +- docs/PRD/3.0-beta2/3.0 开放 API 鉴权与身份传递 PRD.md — 附录 B(:913-946):两条检索路径强度对照表+行号;澄清 FGA relation 实为 can_read/visible,view_file/view_space 是描述性叫法(代码中 view_file 是 effective permission id 非 FGA relation) + +## reuse +- 双层过滤引擎整体可复用:KnowledgeFileVisibilityService(build_index_prefilter + post_filter_visible_files)与 _retrieve_and_filter 的循环骨架(knowledge_file_visibility_service.py / knowledge_space_chat_service.py:481-570),且已被三条链共用(chat_folder、workstation queryChunksFromDB、CitationResolveService——见 visibility service 模块 docstring),MCP 检索工具接同一引擎即可满足『与平台 UI 同源』 +- 任意身份驱动已有生产先例:UserPayload.init_login_user(auth.py:565)+ set_current_tenant_id(resolve_operator 即此模式,open_endpoints/domain/utils.py:77)+ KnowledgeFileVisibilityService(request=None)(workstation_service.py:980)——key 解析出的用户可直接驱动全套权限判定,无需 HTTP 会话 +- MCP『知识库清单查询』工具可直接复用 KnowledgeService.get_knowledge(knowledge_service.py:230,rebac can_read + view_kb/use_kb 收口)与 knowledge_space 各清单入口,天然满足『⊆ 开发者平台 UI 可见范围』 +- Milvus+ES 双路召回与 RRF 融合原样复用:KnowledgeRag.init_knowledge_milvus_vectorstore/init_knowledge_es_vectorstore(knowledge_rag.py:112/:130)+ KnowledgeRetrieverTool(tool/domain/langchain/knowledge.py:34);索引层预过滤经 search_kwargs expr/filter 注入的机制无需改动 +- 声明白名单收窄的入参形态可借鉴 aretrieve_chunks 的多库签名(knowledge_base_ids + kb_filters),但过滤强度必须替换为双层引擎;主版本排除复用 version_filter.build_primary_only_filter + version_repo 注入模式 +- fail-closed 的『空可见集→空结果』短路(IndexFilter.strategy=empty)与结构化 permission_filter 日志(AC-27)可直接沿用作 MCP 检索的审计基础 + +## gaps +- MCP Server 侧全新建:仓库只有 MCP 客户端(mcp_manage/ 是 ClientManager 消费外部工具),无任何 MCP 服务端;个人 API key 鉴权、scope 过滤、六类工具面均为新建 +- 无会话、跨库、跨 type 的『纯检索服务』:chat_folder 与 MessageSession 耦合且单空间,_retrieve_and_filter 是聊天服务私有方法;需抽出可被 MCP/SDK 调用的检索门面,把 F029 双层过滤应用到多库(含 type=0 文档知识库与 type=3 知识空间),替代(或改造)aretrieve_chunks 的弱分支 _aretrieve_chunks_for_kb/_aretrieve_chunks_for_knowledge_base +- 严格 fail-closed 调用面:list_accessible_ids/check/post_filter_visible_files 三处现全是静默降级(fallback scopes、owner 兜底、丢文件),需新增 strict 模式——权限引擎(FGA/缓存)失败即 raise 明确错误码,绝不静默缩集;这是 PRD 验收 9/GOV-05 验收 7 的硬前提,现有代码无一处满足 +- 『声明白名单 ∩ 用户可见范围』的声明层收窄逻辑:现有任何路径都没有『应用声明白名单』概念,需在检索门面入口新增白名单校验(未声明库直接拒绝,即使用户本人有权限) +- type=0 文档知识库的文件级过滤能力:现权限模型只有库级(ensure_knowledge_read_async),无 view_file/lineage 概念;若 PRD『含文件级权限』覆盖 type=0 则是权限模型级新建 +- 修正 filelib.py:635 的失实注释,并按开放 API PRD 附录 B 对 POST /filelib/retrieve 做参数收口/SAK 化改造(resolve_operator 的裸 user_id 通道需替换为委托准入) + +## risks +- C4/一致性风险:MCP 检索若绕开 _get_child_item_effective_permission_ids 这一与列表 UI 同源的 primitive(INV-6),会破坏 PRD 验收 8『与工作台检索集合相等』;且工作台内部两条路径本身强度不一致(chat_folder 有索引层预过滤,workstation queryChunksFromDB 没有),『同一条双层过滤路径』需先指定锚点 +- C3 租户上下文:MCP/SDK 请求不走 HTTP middleware,必须手动 set_current_tenant_id(resolve_operator 模式),漏掉即 NoTenantContextError 或跨租户读;子租户 IN-list 陷阱(memory:租户过滤只拦 SELECT)同样适用 +- fail-closed 语义与现有降级哲学冲突:check 曾是硬 fail-closed 后被刻意改成 owner 兜底(permission_service.py:194-200 注释),strict 模式若全局生效会回退这次修复,必须做成调用面隔离而非全局翻转 +- admin 短路放大面:list_accessible_ids 对 admin 返回 None(不过滤)、post_filter 对 admin 短路——个人 key 解析出管理员身份则 MCP 检索=全量库全量文件;须复用开放 API PRD 的『禁代表 super_admin/tenant_admin』准入,否则 key 泄露=全租户知识外泄 +- 性能:build_index_prefilter 每次调 list_accessible_ids 列举全租户 can_read knowledge_file(memory:ListObjects 曾被禁用/无缓存×2,F037 优化在途),MCP 高频检索会放大;缓存则引入『撤销后陈旧窗口』与 fail-closed 判定的交互 +- 术语映射陷阱:开放 API PRD 附录 B 声明 FGA 关系模型里不存在 view_file/view_space(实为 visible/can_read),代码中 view_file 是 effective permission id——spec/design 必须按代码事实写,避免照 PRD 字面造出不存在的 relation +- 文件级过滤只在结果层是概率兜底:strategy=none(双侧过大)时索引层不过滤、全靠 post_filter,两轮扩量后仍可能返回空(可见文件排名靠后被截断)——MCP 验收『集合相等』在该策略下与工作台一致,但需在测试样本中覆盖 + +## open_questions +- NFR-1.4 的『权限评估失败』边界:FGA 连接失败、FGA client 未配置、缓存不可用、单文件解析异常四种情形是否都必须返回明确错误?现 check 的 owner/implicit 兜底(FGA down 时按 DB creator 放行)在 MCP 面是否允许保留,还是一并视为失败? +- 『含文件级权限』是否只指 type=3 知识空间?type=0 文档知识库现无文件级权限模型——若声明白名单里是 type=0 库,用户层过滤是否退化为库级即可(与工作台 org 桶 use_kb 收口同口径)? +- 声明白名单是否允许包含 QA 库(type=1)/个人库(type=2)?现 aretrieve_chunks 对二者直接抛 KnowledgeTypeNotSupportedError——是在声明期禁止还是运行期报错? +- 『与平台工作台检索集合相等』的基准锚点选哪条:chat_folder 的 _retrieve_and_filter(单空间、含索引层预过滤)还是 workstation queryChunksFromDB(多库、无索引层预过滤)?二者结果集在边界样本上可能不同 +- MCP 检索的错误码归属:fail-closed 错误用开放 API PRD 附录 C 的 260 模块码(如 26003/26005)还是知识模块新码?权限引擎不可用是否需要区别于无权限(403 vs 503)? +- 版本化知识空间的主版本过滤(version_repo 注入)在 MCP 面是否与工作台完全一致(只检非主版本排除)?以及 top_k/max_content 等检索参数是否允许 SDK 调用方自定义、上限多少? diff --git a/features/v3.0.0/000-prd1-discovery/research/marketplace-versions.md b/features/v3.0.0/000-prd1-discovery/research/marketplace-versions.md new file mode 100644 index 0000000000..7ced201fa6 --- /dev/null +++ b/features/v3.0.0/000-prd1-discovery/research/marketplace-versions.md @@ -0,0 +1,80 @@ +# marketplace-versions + +## summary +应用广场/标签/版本三块的现状是:一套以 flow+assistant UNION 为核心的「应用列表」机制(后端 `/api/v1/workflow/list` 与 `/api/v1/chat/online` 两条链路,均走 ReBAC 可见性过滤),一套完整可复用的标签体系(tag/tag_link 表 + 打标组件 + 首页标签配置),以及一套语义上与 RT-05 完全不符的 flow_version「可变指针」版本机制。工场应用作为第三应用类型接入卡片流与标签体系的扩展点明确且数量有限(约 7-8 处硬编码点),但 RT-05 版本快照/终态/前向回滚和 GOV-09 管理列表(档位/来源/改码权列)基本全新。最大的未决问题是「应用工场」入口页落在 client 还是 platform——两个 SPA 的列表/表格技术栈完全不同。 + +## current_state +【应用卡片列表的两条后端链路(均已在代码验证)】 +1) 构建页链路:`GET /api/v1/workflow/list`(src/backend/bisheng/api/v1/workflow.py:331 `read_flows`)→ `WorkFlowService.get_all_flows_envelope`(src/backend/bisheng/api/services/workflow.py:565)。F027 cursor 分页(keyset (update_time,id),cursor 已兼容 int 与 UUID 混合 id)。数据源是 `FlowDao.aget_all_apps`(database/models/flow.py:596)→ `_build_apps_subquery`(flow.py:748):`select(Flow...) UNION ALL select(Assistant...)`,且 docstring 明示租户 auto-filter 对 subquery 失效、每个内层 SELECT 手工注入 `build_tenant_filter_clause`。助手固定以 `FlowType.ASSISTANT.value` 字面量并入。 +2) 广场链路:`GET /api/v1/chat/online`(api/v1/chat.py:17 `get_online_chat`)→ `WorkFlowService.get_online_flows_page` / `get_all_flows`,status 固定 `FlowStatus.ONLINE`(flow.py:28,OFFLINE=1/ONLINE=2),支持 tag_id、常用应用置顶排序(ranking_user_id)。未分类默认区:`GET /api/v1/workstation/app/uncategorized`(workstation/api/endpoints/apps.py:80)→ `get_uncategorized_flows`(services/workflow.py:1031)= 全量应用标签取补集(id_list_not_in)。 + +【可见范围过滤】非 admin 用户走两段:a) 预过滤 `_app_type_ids_for_permission`(services/workflow.py:699)对 targets 硬编码 [("workflow"), ("assistant")] 两个 OpenFGA object type 调 `PermissionService.list_accessible_ids`;b) 逐行细过滤 `ApplicationPermissionService.get_app_permission_map_async`(permission/domain/services/application_permission_service.py,其中 flow_type→FGA object type 映射在该文件 :35)。权限位→FGA relation 映射 `_APP_PERMISSION_TO_MIN_RELATION`(services/workflow.py:59,view/use→can_read、publish/unpublish→can_manage)。FGA 授权模型是代码定义:core/openfga/authorization_model.py:288 `_standard_resource_type('workflow')`。类型白名单 `SUPPORTED_APP_TYPES = {WORKFLOW, ASSISTANT}`(services/workflow.py:58)+ `filter_supported_apps`(:73)双重把关,其他 FlowType(WORKSTATION=15/LINSIGHT=20/CHANNEL_ARTICLE=25/KNOLEDGE_SPACE=30,flow.py:33)不会进入应用列表。 + +【标签体系(PRD 要求复用的部分,完整存在)】表:`tag` + `tag_link`(database/models/tag.py,Tag 带 business_type/business_id/tenant_id;TagLink 带 resource_id+resource_type+唯一约束 resource_tag_uniq)。应用标签统一用 `business_type=APPLICATION`(api/services/tag.py:26)。API:`/api/v1/tag` CRUD(建/改/删标签限 `get_admin_user`)+ `/tag/link`(打标,登录用户)+ `/tag/home`(广场首页展示标签集,存 `ConfigKeyEnum.HOME_TAGS` 配置,api/v1/tag.py:72-92)。打标权限 `TagService.check_tag_link_permission`(api/services/tag.py:72):admin 或对资源有写权限者均可自助打标,且 resource_type 只认 ASSISTANT/WORK_FLOW 否则 NotFoundError——即现状与 PRD「打标仅租户管理员、收口管理列表」不同。列表打标筛选的资源枚举 `ResourceTypeEnum`(database/models/group_resource.py:14,ASSISTANT=3/WORK_FLOW=5 等)。tag 过滤在列表侧的硬编码点:services/workflow.py:192、:607 `get_resources_by_tags_batch([tag_id],[WORK_FLOW,ASSISTANT])`、:540、:1052-1053。 + +【前端两个 App 的呈现】client(端用户广场):`pages/apps/explore.tsx`(ExplorePlaza,滚动加载调 `getChatOnlineApi`→/chat/online;分类 tab 组件 `components/AgentNavigation.tsx` 用 `getHomeLabelApi`(/tag/home) + 固定「未分类」tab 调 `getUncategorized`);`pages/apps/index.tsx`(AppCenter 应用中心,hooks/useAppCenter.ts 同样走 chat/online);卡片 `components/AgentCard.tsx` 展示 logo/name/description/tags(无 tags 时显示「精选」占位),**未展示 owner**。API 封装 `src/api/apps.ts`(getAppsApi/getChatOnlineApi/getUncategorized/getRecommendedAppsApi;getAppsApi 内 hardcode `map={assistant:5,skill:1,flow:10}`)。platform(构建页):`pages/BuildPage/apps.tsx`(卡片流,`useInfiniteCursorTable` + controllers/API/flow.ts:206 getAppsApi 带 cursor/managed/status/tag_id/permission_id;标签筛选用 `useQueryLabels`(BuildPage/hook.ts);卡片上打标组件 `components/bs-comp/cardComponent/LabelShow.tsx` + `selectComponent/LabelSelect.tsx`,调 controllers/API/label.ts 的 createLinkApi/deleteLinkApi,并内嵌标签建/改/删)。 + +【版本机制现状】只有工作流/技能有版本:`flow_version` 表(database/models/flow_version.py,字段 data=完整 DAG JSON 快照、is_current、is_delete、original_version_id、flow_type)。assistant 表无任何版本概念(database/models/assistant.py grep "version" 为空;client apps.ts:393 给助手补空 version_list)。版本 API:`/api/v1/workflow/versions*` + `POST /change_version`(api/v1/workflow.py:214-271)。语义关键点:`FlowVersionDao.change_current_version`(flow_version.py:207)= 原地切换 is_current 指针并把 version.data 回写 flow.data——**切换/回退不产生新版本记录**;`update_version` 同理直接覆盖。上线 `PATCH /workflow/status`→`WorkFlowService.update_flow_status`(services/workflow.py:829)校验 publish_app/unpublish_app 权限位后直接改 status,**不经审批中心**(F025 审批场景仅菜单权限/频道订阅/知识空间加入)。无终态标注、无审批单关联、无能力声明/配置/档位快照、无结构迁移与数据快照。 + +【GOV-09 可克隆的管理表格模式(platform)】标准三件套 `useTable`(@/util/hook)+ bs-ui `Table` + `SearchInput` + `AutoPagination`:最典型是 `pages/SystemPage/components/Users.tsx`(:92 useTable、:196 搜索、:203 表格、含 useResizableColumns 列宽拖拽)与 `pages/LogPage/useAppLog/index.tsx`(:65 useTable + 多筛选 Select + :396 分页)——后者本身就是「按应用检索」的列表,最接近 GOV-09 形态。导航:`layout/MainLayout.tsx` 侧边栏按 `user.web_menu` key('build'/'sys'/'model'…)+ `isMenu()`/`showAdminNav()` 控制入口(:93-113),路由注册在 `routes/index.tsx`(permission 字段同名)。全仓 grep「应用工场/appFactory」无任何既有代码。 + +## key_files +- src/backend/bisheng/database/models/tag.py — Tag/TagLink 模型 + TagDao 全部打标/查询方法;business_type=APPLICATION 即应用标签命名空间 +- src/backend/bisheng/api/services/tag.py — TagService:标签 CRUD(admin)、打标权限 check_tag_link_permission(resource_type 只认 ASSISTANT/WORK_FLOW,owner 可自助打标——与 PRD 收口要求相悖处)、HOME_TAGS 首页标签配置 +- src/backend/bisheng/api/v1/tag.py — /api/v1/tag 路由:CRUD + /link 打标 + /home 广场分类配置 +- src/backend/bisheng/database/models/flow.py — FlowType/FlowStatus 枚举、FlowDao.aget_all_apps(F027 cursor)、_build_apps_subquery(flow+assistant UNION,租户条款手工注入的陷阱说明) +- src/backend/bisheng/database/models/flow_version.py — 版本表:data JSON 快照 + is_current 指针;change_current_version=原地切换非前向新建(RT-05 语义缺口的核心证据) +- src/backend/bisheng/api/services/workflow.py — SUPPORTED_APP_TYPES(:58)、权限位→relation 映射(:59)、get_all_flows_envelope(:565)、_app_type_ids_for_permission(:699 硬编码 workflow/assistant 两 FGA 类型)、add_extra_field(:87 附 user_name/version_list/tags)、get_uncategorized_flows(:1031)、update_flow_status(:829 上线不走审批) +- src/backend/bisheng/api/v1/workflow.py — /workflow/list(:331) 与 /workflow/versions* + change_version(:214-271) 路由 +- src/backend/bisheng/api/v1/chat.py — /chat/online 广场列表端点(status=ONLINE + ReBAC + 常用排序) +- src/backend/bisheng/workstation/api/endpoints/apps.py — 广场辅助端点:/app/uncategorized(未分类默认区)、/app/recommended、/app/used +- src/backend/bisheng/permission/domain/services/application_permission_service.py — FlowType→OpenFGA object type 映射(:35)与应用细粒度权限图谱查询——第三类型接入 ReBAC 的改造点 +- src/backend/bisheng/core/openfga/authorization_model.py — FGA 授权模型代码定义,_standard_resource_type('workflow')(:288)——新增对象类型在此扩展并需模型迁移 +- src/backend/bisheng/database/models/group_resource.py — ResourceTypeEnum(:14)——打标/授权共用的资源类型枚举,工场应用需新增成员 +- src/frontend/client/src/pages/apps/explore.tsx — client 应用广场页(ExplorePlaza):home 标签 tab + 未分类 tab + 滚动加载 +- src/frontend/client/src/pages/apps/components/AgentCard.tsx — 广场卡片:logo/name/description/tags,当前不展示 owner(RT-02 卡片构成差一项) +- src/frontend/client/src/api/apps.ts — client 应用 API 封装;getAppsApi 内 flow_type map hardcode {assistant:5,skill:1,flow:10}——第三类型需扩 +- src/frontend/platform/src/pages/BuildPage/apps.tsx — platform 构建页卡片流(cursor 无限滚动 + 状态/标签筛选 + LabelShow 打标)——PRD 明确 GOV-09 不并入此页 +- src/frontend/platform/src/components/bs-comp/selectComponent/LabelSelect.tsx — 打标交互组件(含标签建/改/删内嵌),GOV-09 行操作「标签设置」可直接移植 +- src/frontend/platform/src/pages/SystemPage/components/Users.tsx — GOV-09 最佳克隆模板之一:useTable+bs-ui Table+SearchInput+AutoPagination+列宽拖拽 +- src/frontend/platform/src/pages/LogPage/useAppLog/index.tsx — 另一克隆模板:多筛选 Select + 按应用检索的表格列表,形态最接近应用管理列表 +- src/frontend/platform/src/layout/MainLayout.tsx — 侧边导航 web_menu key 机制(isMenu/showAdminNav)——新增「应用工场」一级入口的落点 +- src/frontend/platform/src/routes/index.tsx — platform 路由表(permission 字段与菜单 key 对应) + +## reuse +- 标签体系整套复用(RT-02 明确要求):tag/tag_link 表、/api/v1/tag CRUD+link、HOME_TAGS 首页分类配置、未分类补集逻辑(services/workflow.py:1031 get_uncategorized_flows)全部现成;工场应用接入只需 ResourceTypeEnum 加成员并在 4 处 tag 查询硬编码点(services/workflow.py:192/:540/:607/:1052)扩列表 +- 广场页与卡片 UI 复用:client pages/apps/explore.tsx + AgentCard + AgentNavigation 已是「标签 tab + 卡片 + 滚动加载 + 未分类默认区」的完整实现,工场应用只是列表数据里多一种 flow_type(卡片按 flow_type 分流头像 AppAvator 已参数化) +- 可见范围过滤复用:ReBAC 两段式过滤(PermissionService.list_accessible_ids 预过滤 + ApplicationPermissionService.get_app_permission_map_async 细过滤 + F027 cursor 补扫 _scan_visible_flows_cursor)对新类型是通用机制,新增 FGA object type 后即可套用(authorization_model.py _standard_resource_type 模式) +- 打标交互组件复用:platform LabelSelect/LabelShow(controllers/API/label.ts createLinkApi/deleteLinkApi)可直接作为 GOV-09 行操作「标签设置」的弹层 +- 管理表格模式复用:SystemPage/components/Users.tsx 与 LogPage/useAppLog/index.tsx 的 useTable+bs-ui Table+SearchInput+AutoPagination 组合即 GOV-09 表格骨架;审计入口可链 LogPage/audit(audit_log 模型已存在) +- 上线/下线状态与权限位复用:FlowStatus ONLINE/OFFLINE + publish_app/unpublish_app→can_manage 映射(services/workflow.py:59)可作 RT-07 下线/启用的权限基础 +- 版本快照存储 pattern 可参考:flow_version 的 data-JSON 快照 + original_version_id 溯源字段是「版本=内容快照」的既有先例(但仅作蓝本,语义需重做,见 gaps) +- 菜单可见性机制复用:web_menu key + MainLayout isMenu/showAdminNav + routes permission 字段,新增『应用工场』只是加一个 key(GOV-07 一份控制的现成载体) + +## gaps +- 第三应用类型「工场应用」本体:FlowType 新枚举值(避开 15/20/25/30 已占用的非应用成员)、SUPPORTED_APP_TYPES 与 filter_supported_apps 扩类型、_build_apps_subquery UNION 第三支(或复用 flow 表新 flow_type)、_app_type_ids_for_permission targets 扩项、application_permission_service 映射表扩项、FGA 授权模型新 object type + 存量环境模型迁移、ResourceTypeEnum 新成员、client api/apps.ts 的 flow_type map 扩项——扩展点明确但分散约 7-8 处硬编码 +- RT-05 版本模型全新:现 flow_version 是可变指针(change_current_version 原地切换 + 回写 flow.data,删除是软删),无「版本记录不可变、终态标注(已上线/被驳回/已撤回)、回滚=前向新建记录、单一在途、审批单关联、档位/能力声明/注入配置入快照、结构迁移确认与迁移前数据快照」中的任何一项;且 assistant 根本无版本概念 +- GOV-09 应用管理列表后端:无任何接口输出 owner/发布状态机/资源档位/来源(平台内造 vs CLI 导入)/改码权归属组合列;「档位」「来源」「改码权」三个概念在数据模型中完全不存在,需新字段/新表 + 新列表端点(/workflow/list?managed=true 只是最接近的雏形) +- GOV-09 前端页面全新:「应用工场」一级导航入口页(应用管理 tab + 我的应用列表 tab)+ 新 web_menu key + 路由 + 菜单权限(GOV-07 部署开关) +- 应用实例数配额用量条(GOV-03):v2.5 配额收窄后只剩 storage_gb,应用实例数配额的模型、校验与 UI 全新 +- 打标权限收口改造:现 check_tag_link_permission 允许 owner/写权限者自助打标且 resource_type 白名单只有 ASSISTANT/WORK_FLOW;PRD 要求工场应用打标仅租户管理员且入口唯一在 GOV-09——需要按资源类型分派不同打标权限规则(工作流/助手保持现状 vs 工场应用 admin-only) +- 广场卡片展示 owner:AgentCard 现不渲染 owner(后端 /workflow/list 的 add_extra_field 已附 user_name,/chat/online 返回是否带 user_name 未逐行核实)——小改造但涉及与既有工作流/助手卡片的一致性决策 +- 发布/下线接审批:现 update_flow_status 直改 status 不经 F025 审批中心;PRD 的发布审批(RT-03/GOV-02)与状态机(§3.2)需在新类型上新建(且不应改动存量工作流/助手的直接上线行为) + +## risks +- C4 风险:新 FGA object type 必须改 core/openfga/authorization_model.py 并对存量环境做授权模型迁移——记忆中 F048 迁移在存量环境被 permissions_explicit 缺省值拦截的教训直接适用,迁移设计需前置演练 +- C3 风险:应用列表的 UNION 子查询绕过租户 auto-filter(_build_apps_subquery docstring 明示),第三支 SELECT 必须手工注入 build_tenant_filter_clause;任何新表还须注册 tenant-aware 模块,否则跨租户泄漏 +- C2/DM8 风险:RT-05 版本快照若含大 JSON(代码包元数据/能力声明/DAG),每次发布写入 flow_version 式大 JSON 在 DM8 有 undo 写放大前科(灵思 history -7120 事故),快照存储建议 MinIO 引用而非行内 JSON,需在 design 阶段定 +- 语义冲突风险:PRD『回滚=前向新建版本记录』与现有 change_current_version『指针切换』两套语义若共存于同一 flow_version 表,is_current/is_delete 字段含义会分叉;建议工场应用独立版本表而非复用 flow_version,否则存量工作流版本 UI(CardSelectVersion 等多处消费 version_list)会被波及 +- 前端双栈风险:client(Recoil/shadcn/react-query v4)没有 platform 的 useTable/bs-ui Table 组件体系;若「应用工场」入口页落在 client,GOV-09 表格模式无法直接克隆,需在 client 重建表格组件或页面落 platform——技术选型受产品定位牵制 +- 标签查重现状:TagDao.get_tag_by_name 仅按 name 查(依赖租户 auto-filter 隔离,不含 business_type 条件),跨 business_type 重名会被误判存在——扩标签用途时需留意 +- chat/online 与 /workflow/list 两条列表链路行为已有细微分叉(排序/分页/字段),工场应用要同时进两条链路,验收需覆盖双链路一致性 + +## open_questions +- 「应用工场」入口页落在哪个 SPA?PRD 称 GOV-09 在『应用工场』主导航入口页内 tab 且与 owner『我的应用列表』并列(PRD-2 §3.1)——广场在 client、构建/管理表格模式在 platform,这决定 GOV-09 是克隆 platform Users.tsx 模式还是在 client 重建表格体系,是本切片最大的产品/技术分叉点 +- 工场应用的存储载体:复用 flow 表新增 flow_type(自动进 UNION 与 cursor 分页,但混入既有表语义)还是独立新表 + UNION 第三支(id 类型自由但 4 处 tag 查询与权限映射都要显式扩)? +- 版本模型归属:RT-05 版本列表在 WB-14/15(PRD-2 工作台发布面),版本表/审批联动是否随 PRD-1 先建后端模型、UI 留待 PRD-2?被驳回/已撤回终态依赖 RT-03 审批流,切片顺序需拍板 +- 打标双规则是否可接受:存量工作流/助手保留 owner 自助打标,工场应用 admin-only 收口 GOV-09——还是全平台统一收口(会改变存量行为)? +- 广场卡片加 owner 展示是只给工场应用卡片加,还是三类应用卡片统一加(AgentCard 是共用组件)? +- 『未分类』tab 现状(未打标应用落 uncategorized 补集)是否即满足 RT-02『默认分类中正常可见』的验收,还是需要新的『默认分类』概念? +- GOV-09 的『来源(平台内造/CLI 导入)』『资源档位』『改码权归属』三字段的数据来源定义在 DEV-04/DEV-06(CLI 通道),本切片是否只预留列与枚举、由 CLI 切片回填? diff --git a/features/v3.0.0/000-prd1-discovery/research/mcp-runtime-infra.md b/features/v3.0.0/000-prd1-discovery/research/mcp-runtime-infra.md new file mode 100644 index 0000000000..660c5379b9 --- /dev/null +++ b/features/v3.0.0/000-prd1-discovery/research/mcp-runtime-infra.md @@ -0,0 +1,65 @@ +# mcp-runtime-infra + +## summary +MCP 方向上仓内只有完备的 MCP client 基建(mcp_manage/ + 工具体系 ToolPresetType.MCP),平台自身对外的 MCP server 面完全不存在,但官方 `mcp>=1.27.0` SDK 已在依赖内、server 端原语可直接用,且六类工具背后的 domain service 全部已有,MCP 工具面本质是"薄封装+鉴权"。应用托管运行时则是彻底的绿地:仓内无 docker SDK、无进程管理器、无资源限制配置,最近的参照物只有 linsight 独立 worker(固定常驻,非 per-app)与容器级 restart:on-failure;/apps/{slug} 动态路由在现有纯静态 nginx 配置上也无挂点。单机资源限制最兼容的路线是 docker SDK+docker.sock(compose 单机拓扑天然契合),代码快照存储可复用 MinioStorage+FlowVersion DAO 双模式。 + +## current_state +【MCP 现状(均已在代码核实)】平台今天的 MCP 全部是"客户端消费外部 MCP server":`src/backend/bisheng/mcp_manage/manager.py` 的 `ClientManager` 按配置 JSON(mcpServers 键)分派三种传输——`clients/sse.py`/`clients/stdio.py`/`clients/streamable.py`,共同基类 `clients/base.py:BaseMcpClient` 直接用官方 `mcp` SDK 的 `ClientSession`(list_tools/call_tool,每次调用新建会话、无连接池);`async_runner.py` 维护一条专用事件循环线程(bisheng-mcp-async)供同步上下文调 MCP。MCP 作为一种工具类型接入工具体系:`tool/domain/const.py` `ToolPresetType.MCP=2`,`tool/domain/services/tool.py` 的 `parse_mcp_schema`/`refresh_all_mcp`/`refresh_mcp_tools` 负责用户注册与刷新外部 MCP server(stdio 可被 `core/config/settings.py:572 McpConf.enable_stdio` 配置禁用),`tool/domain/services/executor.py:_init_mcp_tool` 经 `mcp_manage/langchain/tool.py:McpTool` 包装成 LangChain BaseTool,供助手/工作流/灵思消费(`linsight/domain/services/agent_factory.py:42` 注释确认灵思 tools 含用户配置的 MCP 工具)。角色授权侧已有 `database/models/role_access.py:56 AccessType.MCP="mcp"` 资源类型。前端 platform 有完整的 MCP 工具配置 UI(`pages/BuildPage/tools/EditMcp.tsx` + `controllers/API/tools.ts` 的 getMcpServeByConfig/testMcpApi);client 端 `types/chat/mcp.ts` 是 LibreChat 遗留的 zod schema,非平台自建。全仓 grep 无任何 `mcp.server`/FastMCP 引用——平台对外暴露的 MCP server 为零;同时 `database/models/` 下无任何 ApiKey/ServiceAccount 表,v2 开放接口(`open_endpoints/api/endpoints/{knowledge,chat,workflow,...}.py`)经 `open_endpoints/domain/utils.py:get_default_operator_async` 解析操作者、无鉴权(与开放 API PRD 的"零鉴权现状"一致)。【执行基建】代码执行有两套且都不 per-app:① 灵思 code interpreter `src/backend/bisheng_langchain/gpts/tools/code_interpreter/`——`local_executor.py:LocalExecutor`(在 worker 容器内直接 subprocess 执行、tempfile 工作目录、DEFAULT_TIMEOUT=600s、零隔离零资源限制)与 `e2b_executor.py:E2bCodeExecutor`(E2B 云沙箱,worker 中介 copy-in/out,SIZE_AUTOPUSH=5MB),由 `tool/domain/services/executor.py:157` 注入 minio 配置、`linsight/domain/task_exec.py:492` 会话尾清理;② 工作流 CodeNode `workflow/nodes/code/{code.py,code_parse.py}`——进程内 AST 解析后 compile+`exec()`,连子进程都没有。后端零 docker SDK 使用、compose 无 docker.sock 挂载。【容器拓扑】`docker/docker-compose.yml` 单机 compose:backend(api,uvicorn --workers 2)与 backend_worker 共用同一镜像,`docker/bisheng/entrypoint.sh` worker 模式 = min_worker(celery 四队列合一) + linsight worker + beat 同容器合跑;另有 frontend(nginx)、mysql、redis、openfga(+migrate)、es、milvus(etcd+minio)。全 compose 无任何 cpus/mem_limit;崩溃恢复仅靠容器级 `restart: on-failure`。【路由】`docker/nginx/conf.d/default.conf`(frontend 容器挂载,`src/frontend/nginx.conf` 镜像内置同构):3001 端口,`/`→platform 静态、`/workspace/`→client 静态 alias、正则 `^(/workspace)?/api(/|$)`→backend:7860(含 websocket upgrade map、proxy_read_timeout 300s)、`/bisheng|/tmp-dir`→minio:9000。纯静态配置文件,无动态 upstream/lua/consul,`/apps` 无任何现有路由。商业版前面还有 Java gateway(docs/architecture/11-gateway.md,本仓外)。【MinIO】`core/storage/minio/minio_manager.py` MinioManager/get_minio_storage(app_context 生命周期)+ `minio_storage.py:MinioStorage`(bucket 管理、put_object/put_object_tmp/copy_object/get_share_link、_metered 指标、S3Error 解冻)。租户前缀函数 `core/storage/tenant_storage.py:get_minio_prefix` 存在但是死代码(F008 未落地,调用点未接线——记忆核实过的现状)。版本化先例:工作流版本走 MySQL 表 `database/models/flow_version.py:FlowVersion`(图 JSON 全量入库);灵思工作区走 MinIO per-chat 前缀 + copy-forward(`linsight/domain/services/workbench_impl.py` 以 chat_id 限定 workspace 前缀,put_object 到 minio_client.bucket)。【部署开关先例】`common/services/config_service.py:224` BISHENG_PRO 环境变量、`settings.multi_tenant.enabled`、McpConf 等 settings 开关;路由目前在 `bisheng/api/router.py` 无条件注册,没有"整层不装"的条件注册模式。 + +## key_files +- src/backend/bisheng/mcp_manage/manager.py — ClientManager:MCP client 三传输(SSE/STDIO/Streamable)分派,纯客户端 +- src/backend/bisheng/mcp_manage/clients/base.py — BaseMcpClient:官方 mcp SDK ClientSession 封装,list_tools/call_tool 每次新建会话 +- src/backend/bisheng/tool/domain/services/tool.py — parse_mcp_schema/refresh_all_mcp:用户注册外部 MCP server 为工具(ToolPresetType.MCP) +- src/backend/bisheng/tool/domain/services/executor.py — ToolExecutor:_init_mcp_tool 包装 McpTool;:157 code_interpreter 注入 minio 配置 +- src/backend/bisheng_langchain/gpts/tools/code_interpreter/local_executor.py — LocalExecutor:worker 容器内 subprocess 直执行,零隔离零限额——现有最接近'执行体'的组件 +- src/backend/bisheng_langchain/gpts/tools/code_interpreter/e2b_executor.py — E2bCodeExecutor:外部云沙箱路线先例(copy-in/out 中介模式) +- src/backend/bisheng/workflow/nodes/code/code_parse.py — 工作流 CodeNode:进程内 exec(),无隔离 +- docker/docker-compose.yml — 单机拓扑全貌:无资源限制、restart:on-failure 是唯一自愈机制、无 docker.sock 挂载 +- docker/bisheng/entrypoint.sh — worker 容器合跑 celery+linsight+beat;api 模式先跑 alembic +- docker/nginx/conf.d/default.conf — 生产 nginx 路由:/、/workspace/、(/workspace)?/api→7860、minio;/apps/{slug} 的天然挂点,但纯静态配置 +- src/backend/bisheng/core/storage/minio/minio_storage.py — MinioStorage:put/copy/share_link 全套,app 代码快照的存储层直接可用 +- src/backend/bisheng/database/models/flow_version.py — FlowVersion:版本快照领域模型的现成 DAO 范式 +- src/backend/bisheng/open_endpoints/api/dependencies.py — v2 开放接口现状:无鉴权、get_default_operator_async——PRD 明令不得复用其旧检索路径 +- src/backend/bisheng/core/config/settings.py — McpConf(enable_stdio)/multi_tenant.enabled/bisheng_pro:部署级开关先例,GOV-07 分层开关可循此模式 +- src/backend/bisheng/database/models/role_access.py — AccessType.MCP:角色-资源授权模式,GOV-07 两个角色权限配置项的既有面 +- src/frontend/platform/src/pages/BuildPage/tools/EditMcp.tsx — platform 端 MCP server 配置+测试 UI(消费方向) + +## reuse +- MCP 协议栈零新依赖:pyproject.toml:50 已锁 `mcp>=1.27.0`,官方 SDK 自带 server 端原语(FastMCP/streamable-http ASGI app,可直接 mount 进 FastAPI)——此点为 SDK 事实推断,仓内未用过 server 侧,需 POC 验证版本行为 +- DEV-02 六类工具的后端能力全部有现成 domain service 可薄封装:知识库检索走 F029 双层过滤路径(knowledge 模块 view_file 双层过滤,PRD:147 明确要求与平台工作台同源,恰好只能复用这条)、知识库清单(knowledge domain + ReBAC 可见范围)、模型清单(llm 模块)、身份/组织查询(user/tenant 模块);MCP 工具面本体≈鉴权层+工具注册层 +- API key 鉴权的载体设计已由伴生 PRD 定稿(个人 key + 服务账号密钥 bs-sak-,v2 迁移改 SAK)——实现是新建,但方案无需再设计 +- 应用代码快照存储:MinioStorage(core/storage/minio/minio_storage.py 的 put_object/copy_object/get_share_link)+ FlowVersion(database/models/flow_version.py)的表模式双拼即可承载 WB-15/RT-05 双保险;目录树型代码包可仿灵思 workspace 的 per-前缀+manifest 模式(linsight/domain/services/workbench_impl.py) +- RT-01 登录复用:/apps/* 同域部署下 JWT cookie 直接可用(common/dependencies/user_deps.py UserPayload.get_login_user),登录回跳复用平台既有登录页与 SSO 配置;四类兜底页的降级页机制可借鉴 client 的 bs:service-maintenance overlay 模式(request.ts 拦截器) +- GOV-07 部署开关:BISHENG_PRO env(common/services/config_service.py:224)与 settings.multi_tenant.enabled 是既有的部署级开关范式;『未部署开放能力层则入口不出现』可经 /api/v1/env 下发前端(api/v1/endpoints.py:90 已下发 pro 标志的先例) +- GOV-07 角色权限两配置项:role_access.py AccessType + 既有菜单权限体系(v2.6 已拆 workbench/admin scope)——纯配置项新增,零新机制 +- nginx 挂点:docker/nginx/conf.d/default.conf 是 /apps/{slug} location 的天然落点;WebSocket upgrade map 与 minio 转发的写法可直接沿用 + +## gaps +- MCP server 面整体(DEV-02):FastMCP/lowlevel server 装配、API key 鉴权中间件、per-key scope 过滤、按 key 计量账单——全仓无一行;且其前置的 API key 体系本身也是 0→1(database/models 无 ApiKey 表,需建表+hash 校验+管理页) +- 模型双协议直连面(OpenAI/Anthropic 兼容 endpoint + 流式 + key 计量):现有 llm 模块只做平台内调用,无对外协议兼容层 +- 应用托管运行时=完整绿地:per-app 实例编排器(拉起/停止/1-4 核限额/崩溃检测/<=5min 自动重启/发布切换与回滚)、实例注册表、健康探测、端口分配——仓内无 docker SDK、无 supervisor、无 cgroup 操作、无任何按需进程管理代码;最近参照 linsight worker 也只是固定常驻进程 +- /apps/{slug} 动态路由层:现 nginx 纯静态 conf 无法按 slug 动态转发到 per-app 实例;需三选一新建——backend FastAPI 反代(含 WebSocket 透传)、openresty/模板 render+reload、或专职 ingress 组件;商业版还需 Java gateway 侧同步适配(本仓外) +- 四类兜底页(无权限/未部署工场/已下线/不存在)+ 发布中/恢复中 holding page 及其状态机驱动切换:全新(含路由层在实例不可达时返回 holding page 而非 502 的机制) +- 应用领域模型:app 表、发布版本快照表、行级归属、状态机(草稿/待上线/运行/下线/已删除)、GOV-03 实例配额——全新表(须注册 _TENANT_AWARE_MODEL_MODULES) +- MCP 工具面第 5 项『应用数据表结构与数据查询』:平台无 per-app 数据库/schema 概念,从存储选型到工具全新 +- MCP 工具面第 6 项『应用状态/日志自查』:无 per-app 日志采集通道(现日志=loguru 落容器 stdout),需新建日志捕获/留存/按 owner 过滤查询 +- GOV-07『整层不装』的条件注册模式:bisheng/api/router.py 现无条件注册所有路由,需引入按部署配置裁剪路由+前端入口的机制 + +## risks +- 权限红线(C4/NFR-1.4):MCP 检索若图省事复用 open_endpoints 旧检索(仅知识库级一次校验、无文件级过滤,伴生 PRD 附录 B 已核实)即成活越权;必须走 F029 双层过滤且 fail-closed,验收样本须含同库部分文件无权场景(PRD:161-162) +- 单机容量硬约束:compose 全栈已合跑在一两台机上(entrypoint.sh worker 容器合跑 celery+linsight+beat;114 曾因重启 linsight worker OOM 死机),再叠 per-app 1-4 核实例,无 GOV-03 配额强约束会把平台核心挤死——与 RT-08『应用可以不稳定,平台必须正常』直接冲突 +- 资源限制技术路线未定且各有硬伤:docker SDK+docker.sock 路线与现单机 compose 拓扑最兼容(--cpus/--memory 即 cgroup,restart policy 现成),但把 docker.sock 交给平台组件≈host root 权限,且信创/DM8 客户环境(105/116 类)docker 权限不确定;裸进程+cgroup v2 路线无容器依赖但镜像内无 systemd、跨发行版 cgroup 挂载差异大;两条路线都未在仓内有任何先例 +- C3 多租户:per-app 实例是长驻独立进程,平台的 ContextVar 租户注入完全不适用;app 回调平台必须全部走带 SAK 的开放 API/MCP 面显式传身份(与伴生 PRD 对齐),任何让 app 进程直连 MySQL 的捷径都会绕过租户过滤 +- 5 分钟自动恢复承诺(RT-08/NFR-6)超出 restart:on-failure 能力:容器重启只覆盖进程退出不覆盖 hang,需主动健康探测+超时判定+holding page 切换的可靠性工程,属可测量的产品承诺,测试成本高 +- MCP streamable-http 长连接经 nginx(proxy_read_timeout 300s、buffering)与商业版 Java gateway 双层代理的兼容性需实测;勿把 McpConf.enable_stdio(client 侧禁 stdio)与 server 面混淆 +- C1 分层:MCP server 面必须作为新 api 层模块复用 domain services + PermissionService,禁止在 MCP 工具 handler 内直查 DB(arch-guard 8 RULE 会拦,但 MCP handler 是新形态入口,规则覆盖需确认) +- MinIO 租户前缀函数是死代码(F008 未落地):若 app 快照要求租户隔离前缀,不能假设 get_minio_prefix 已在写路径生效,需显式接线,否则对外宣称隔离会翻车 + +## open_questions +- 工场运行时层隔离技术选型:docker(平台组件挂 docker.sock)vs 裸进程+cgroup v2 vs 预留 k8s?关键输入=目标客户(含信创环境)是否保证有 docker 且允许平台持有 socket 权限 +- /apps/{slug} 路由承接者:nginx/openresty 增强、backend FastAPI 反代、还是独立 ingress 组件?商业版 Java gateway(本仓外)由谁改、何时改?——直接决定 RT-01 登录回跳与 holding page 的实现位置 +- MCP server 部署形态:与 backend 同进程 mount(GOV-07『无新增常驻负担』倾向此)还是独立进程/端口(『整层可不装』更干净)?传输选 streamable-http 单一还是兼容 SSE? +- 『1-4 核』是 cgroup hard quota 还是调度参考?单机超卖策略与 GOV-03 配额的换算关系? +- 应用运行时契约:托管的 app 是任意 HTTP web 进程还是固定框架模板?决定健康探测协议、端口约定、发布切换是蓝绿双实例还是单实例停换(后者『发布中』窗口更长) +- 应用自有数据(MCP 工具第 5 项)落库形态:主 MySQL per-app schema、sqlite per-app、还是应用自带?涉及备份承诺(RT-07『存档随平台数据一同备份』) +- 模型双协议直连面是否复用现 llm 模块出口做协议转换,还是引入现成网关(如 litellm 类)?自研 Anthropic 兼容层的维护成本需拍板 diff --git a/features/v3.0.0/000-prd1-discovery/research/model-llm.md b/features/v3.0.0/000-prd1-discovery/research/model-llm.md new file mode 100644 index 0000000000..cbfa6f8b2e --- /dev/null +++ b/features/v3.0.0/000-prd1-discovery/research/model-llm.md @@ -0,0 +1,62 @@ +# model-llm + +## summary +模型管理面(llm_server/llm_model 双表 + 租户隔离 + Root→Child FGA 共享 + 5 组系统默认模型配置)和统一调用抽象 BishengLLM(按 model_id 解析、19 类服务商映射 langchain client、全链路流式)已经非常完整,GOV-05「配置一次、租户内共享」的底座现成;模型下线的明确错误态(LlmModelOfflineError 10012 等)也已存在。但 PRD DEV-02 需要的「OpenAI/Anthropic 兼容入站模型直连代理」在代码库中完全不存在(仅有的 /api/v2/assistant/chat/completions 是零鉴权的助手外壳,model 字段传的是 assistant_id),且无个人 API key 实体、无按名称解析模型的路径;计量存在三条互不打通的轨道(telemetry MODEL_INVOKE 全覆盖但在 ES、llm_token_log/llm_call_log 结构合适但只在 workflow 节点写入、Redis 每日服务商级限流计数),均无 key 维度,「按 key 的用量账单」需在此之上新建。 + +## current_state +【表与租户模型】模型管理后端在 /Users/lilu/Projects/bisheng/.claude/worktrees/prd1-cred-alignment/src/backend/bisheng/llm/ 模块(DDD 布局)。核心表两张(domain/models/llm_server.py):`llm_server`(服务商:name 租户内唯一 uk_llm_server_tenant_name、type=CHAR(20) 服务商类型、config JSON 存凭据与端点、limit_flag/limit 每日调用上限、tenant_id)与 `llm_model`(server_id+model_name 组合唯一 server_model_uniq、name 显示名、model_name 调用参数名、model_type=llm/embedding/rerank/asr/tts、status 0正常/1异常/2未知、online bool 上下线、tenant_id 镜像父行)。多租户:子租户经 strict_tenant_filter 只见自有行,Root 服务商经 OpenFGA `llm_server#shared_with→tenant:{leaf}` tuple 共享(LLMDao.aget_shared_server_ids_for_leaf,FGA 不可用时 fail-closed 返空),列表合并后打 is_root_shared_readonly(services/llm.py get_all_llm L382-500)。系统级默认模型选择在 `tenant_system_model_config`(F022,domain/models/tenant_system_model_config.py):knowledge/assistant/evaluation/workflow/linsight 五个 key per-tenant,aresolve() 返回 (value, inherited_from_root, fallback_blocked)。【管理 API】llm/api/router.py 挂 /api/v1/llm:GET ''(列表,登录即可)、POST/PUT/DELETE ''(服务商 CRUD,get_tenant_admin_user 门槛,Root 行仅超管可写 _assert_root_writable)、GET /info(单服务商详情)、POST /online(模型上下线)、以及 F022 五组系统模型配置端点 + workbench ASR/TTS 直调;写路径落审计(_write_llm_audit)。【凭据存储——已核实非 Fernet】llm_server.config 是**明文 JSON**(JsonType 列)存 openai_api_key/api_key/endpoint 等;全仓 Fernet 只在 core/config/settings.py(config.yaml 密码),llm 模块零加密。防护全在读/写路径:列表 get_all_llm 构造 LLMServerInfo 时 exclude={"config"} 整体剔除;详情 get_one_llm(services/llm.py L551,docstring 明言"Contains key")对租户管理员**返回明文 key** 供编辑;更新时 JsonFieldMasker.update_json_with_masked(utils/mask_data.py,api_key 类字段掩码为 '********',正则识别掩码值避免回写覆盖真 key);审计只落 sha256 前 16 位指纹(_llm_api_key_hash L92)。另有 endpoint_whitelist 配置对非超管建服务商做端点白名单校验(llm_server.py L157)。【调用面】唯一抽象 = BishengLLM(domain/llm/llm.py,继承 BishengBase+BaseChatModel):按数字 model_id 解析(**无按名称解析路径**),经 LLMDao.aget_model_by_id/aget_server_by_id(wrapper_bisheng_llm_info Redis 缓存 key_prefix llm:server:/llm:model: + LLM_CACHE TTLCache ttl=60)+ share_fallback 处理 Root 共享行;_llm_node_type 字典把 19 种 LLMServerType(const.py:openai/azure/qwen/zhipu/anthropic/deepseek/volcengine/MindIE…)映射到 langchain client(ChatOpenAIReasoning/ChatAnthropic=langchain_anthropic/ChatQwen…,core/ai/__init__.py)+ params_handler(把 server.config 凭据翻译成各家参数)。流式:_stream/_astream 原生透传,_generate 对 streaming 模型聚合流以保 on_llm_new_token 回调。初始化校验链(_init_client L220-232)即模型收回的现成错误态:模型行删除→LlmModelConfigDeletedError、服务商删除→LlmProviderDeletedError、类型不符→LlmModelTypeError、online=False→LlmModelOfflineError(10012,common/errcode/server.py L71,支持 ignore_online 逃逸)。消费方全走 LLMService.get_bisheng_llm(model_id=…)(已核实 grep):assistant(api/services/assistant_agent.py L229/245)、workflow 四类节点(workflow/nodes/{llm,agent,rag,input})、工作台 chat(workstation/domain/services/chat_service.py)、频道、知识库、灵思(linsight/domain/services/workbench_impl.py 直用 BishengLLM)。【入站兼容协议——已核实不存在模型直连代理】全仓无任何把请求直转底层模型的 OpenAI /v1/chat/completions 或 Anthropic /v1/messages handler。仅有:(a) POST /api/v2/assistant/chat/completions(open_endpoints/api/endpoints/assistant.py L33)——OpenAI 报文格式**外壳**,req.model 传的是 assistant_id(UUID),实际跑助手 agent,支持真流式 SSE/伪流式,**零鉴权**、get_default_operator() 代系统配置的默认用户(与开放 API 鉴权 PRD 判定的匿名越权同源);(b) POST /api/v1/workstation/chat/completions(workstation/api/endpoints/chat.py L94)——工作台自家聊天 SSE,非 OpenAI 协议、登录态。"/v1/messages" 只出现在 xinference 出站 client。【计量三轨】① telemetry(全覆盖、每次调用):BishengLLM 的 generate/stream 四方法被 wrapper_bisheng_model_generator(_async)/limit_check 装饰,成功/失败都经 upload_telemetry_log(llm/domain/utils.py L192-250)发 MODEL_INVOKE 事件到 telemetry ES + F042 emit_metric 指标行,字段含 input/output/cache/total token(parse_token_usage 兼容 usage_metadata/token_usage 多形状)、model_id/server_id/app_id/app_type/user_id/TTFT/时延/status/is_stream。② MySQL 账单表(F017,结构合适但覆盖残缺):llm_token_log(tenant_id/user_id/model_id/server_id/session_id/prompt/completion/total_tokens,全索引)+ llm_call_log(status/latency_ms/endpoint/error_msg 每次调用一行含失败),由 LLMTokenTracker/ModelCallLogger 写入、缺 tenant ContextVar 时抛 TenantContextMissingError 拒写(INV-T13:记 caller leaf tenant);但入口 LLMUsageCallbackHandler(workflow/callback/llm_usage_callback.py)**只在 workflow llm 节点注册**(workflow/nodes/llm/llm.py L123,grep 全仓仅此一处)——助手/工作台/灵思/频道调用不落这两张表。读侧 QuotaService._count_tokens_monthly(role/domain/services/quota_service.py L656)按 tenant_id/user_id 对 llm_token_log 做月度 SUM 供 model_tokens_monthly 配额。③ Redis 每日限流计数:model_limit:{date}:{server_id} INCR 超 llm_server.limit 抛"Quota used up"(llm/domain/utils.py L98-118)——服务商级/天,无 per-user/per-key。【个人 API key】后端不存在任何 API key 实体(grep database/models + user 模块零命中);v2 开放面零鉴权靠 default_operator(open_endpoints/domain/utils.py get_default_operator,顺带 set tenant ContextVar)。【前端】模型管理页 = platform pages/ModelPage/manage/index.tsx(服务商表格 + 模型 online Switch → controllers/API/llm.ts L86 POST /api/v1/llm/online)+ ModelConfig.tsx(服务商/凭据表单)+ SystemModelConfig.tsx 与 tabs/(五组系统模型配置,含 inherited/fallback banner)。 + +## key_files +- src/backend/bisheng/llm/domain/models/llm_server.py — llm_server/llm_model 双表 + LLMDao(CRUD、online/status 更新、Root 共享 FGA 查询、Redis 缓存装饰);config JSON 明文存凭据 +- src/backend/bisheng/llm/domain/llm/llm.py — BishengLLM 统一调用抽象:19 种服务商→langchain client 映射(_llm_node_type)、params_handler 凭据翻译、流式 _stream/_astream、offline/删除校验链 +- src/backend/bisheng/llm/domain/llm/base.py — BishengBase:model_id→(model_info,server_info) 解析入口,含 Root 共享 fallback 与 telemetry 必填字段(app_id/app_type/user_id) +- src/backend/bisheng/llm/domain/services/llm.py — LLMService(1549 行):get_all_llm(剔凭据+FGA 共享合并)、get_one_llm(含明文 key)、get_bisheng_llm 工厂、JsonFieldMasker 掩码合并更新、api_key sha256 审计指纹 +- src/backend/bisheng/llm/api/router.py — /api/v1/llm 管理 API:服务商 CRUD、POST /online 上下线、F022 五组系统模型配置端点(envelope 含 inherited_from_root) +- src/backend/bisheng/llm/domain/utils.py — 每次调用的 telemetry MODEL_INVOKE 上报(token/TTFT/status)+ Redis 每日服务商限流 + parse_token_usage 多厂商 token 提取 + llm:server/model Redis 缓存装饰器 +- src/backend/bisheng/llm/domain/models/llm_token_log.py — F017 token 用量表(tenant/user/model/server/session + prompt/completion/total),月度配额 SUM 数据源;无 key 维度 +- src/backend/bisheng/llm/domain/models/llm_call_log.py — F017 每次调用审计表(success/error、latency_ms、endpoint、error_msg),为「未来按次成本核算」预留 +- src/backend/bisheng/workflow/callback/llm_usage_callback.py — LLMUsageCallbackHandler:on_llm_end→双表落账;全仓唯一注册点在 workflow/nodes/llm/llm.py L123——其它调用面不写账单表 +- src/backend/bisheng/open_endpoints/api/endpoints/assistant.py — 现存唯一 OpenAI 格式入站端点 /api/v2/assistant/chat/completions:助手外壳非模型直连,零鉴权走 get_default_operator,SSE 流式组装可参考 +- src/backend/bisheng/role/domain/services/quota_service.py — _count_tokens_monthly(L656):llm_token_log 月度 SUM(Python 算月初界,MySQL/DM8 双兼容)——账单聚合查询的现成范式 +- src/backend/bisheng/common/errcode/server.py — 模型失效错误态族:NoLlmModelConfig/LlmModelConfigDeleted/LlmProviderDeleted/LlmModelOffline(10012)/InitLlmError——GOV-05 能力收回可直接复用 +- src/backend/bisheng/llm/domain/models/tenant_system_model_config.py — F022 per-tenant 系统默认模型选择(5 key),aresolve 含 Root 继承/阻断语义 +- src/frontend/platform/src/pages/ModelPage/manage/index.tsx — 「既有模型管理页」(GOV-05 锚点):服务商列表 + 模型 online Switch;同目录 ModelConfig.tsx 凭据表单、SystemModelConfig.tsx 系统模型配置 +- src/backend/bisheng/open_endpoints/domain/utils.py — get_default_operator:v2 开放面现行「鉴权」=系统配置默认用户 + 手动 set tenant ContextVar——新直连面须替换的旧机制 + +## reuse +- GOV-05「模型配置一次、租户内共享」底座现成:llm_server/llm_model per-tenant + Root→Child FGA 共享 + 助手/工作流/工作台/灵思已同源消费同一份配置(LLMService.get_bisheng_llm,src/backend/bisheng/llm/domain/services/llm.py L1157);工场应用运行时注入模型通道可直接复用该工厂,零新建配置面成立 +- 模型收回→明确错误态已有完整错误族:LlmModelOfflineError(10012)/LlmModelConfigDeletedError/LlmProviderDeletedError/LlmModelTypeError(common/errcode/server.py L47-77,BishengLLM._init_client 校验链),加上前端上下线 Switch(platform ModelPage/manage/index.tsx → /api/v1/llm/online)——GOV-05「能力收回得到明确错误态」的模型侧可直接映射 +- DEV-02 流式能力已通:BishengLLM._stream/_astream 对 19 类服务商全链路流式(llm/domain/llm/llm.py L336-356);OpenAI SSE chunk 组装与 OpenAIChatCompletionReq/Resp schema 可参考 open_endpoints/api/endpoints/assistant.py 与 api/v1/schemas(含伪流式降级、reasoning_content 透传) +- 计量地基三件可复用:① telemetry MODEL_INVOKE(llm/domain/utils.py upload_telemetry_log)已对每次模型调用全覆盖记录 input/output/cache/total token+模型+应用+用户,装饰器层挂点即「在 BishengLLM 统一落账」的现成注入位;② llm_token_log/llm_call_log 表结构(F017)与 INV-T13 租户归属规则、TenantContextMissingError fail-closed 语义可扩展 key 维度直接当账单表;③ QuotaService._count_tokens_monthly 的 MySQL/DM8 双兼容聚合 SQL 范式可复制成按 key/按模型的账单查询 +- MCP「模型清单查询」工具数据源现成:get_all_llm 已做租户过滤+Root 共享合并+凭据整体剔除(exclude config),按 online 过滤即可输出可用模型清单 +- Anthropic 出站客户端已在依赖树内(langchain_anthropic.ChatAnthropic,core/ai/__init__.py L1),Anthropic 兼容入站面的下游调用无需新增依赖 +- 凭据脱敏工具链可复用:JsonFieldMasker(utils/mask_data.py)掩码+掩码识别回写合并、_llm_api_key_hash sha256 指纹审计——新增 key 管理/账单 UI 的凭据展示可沿用 +- 服务商级每日调用限流(Redis model_limit:{date}:{server_id},llm/domain/utils.py L98)可作为按 key 限流的实现参照(同为 INCR 计数模式) + +## gaps +- OpenAI 兼容 /v1/chat/completions 模型直连代理端点:零现状。需新建入站路由(协议解析、按 key 鉴权、模型名→llm_model 解析、译成 BishengLLM 调用或直透 upstream、SSE 流式、OpenAI 原生错误体、usage 回填响应) +- Anthropic 兼容 /v1/messages 入站端点:仓内完全没有 Anthropic 入站协议处理(/v1/messages 仅存在于 xinference 出站 client),报文/流式事件/错误体全套需新写 +- 按「模型管理原名」解析模型:现有解析只认数字 model_id(BishengBase.get_model_server_info),无 name→model 查找路径;且 LLMModel.name/model_name 均无租户内唯一约束(model_name 只在 server 内唯一),需定义歧义规则并可能补约束 +- 个人 API key(DEV-01)实体与鉴权中间件:后端无任何 API key 表(grep 零命中);v2 现行 default_operator 零鉴权机制必须被替换,key→user→tenant ContextVar 的解析链需新建(与开放 API PRD 的 bs-sak- 服务账号密钥体系对齐) +- 按 key 的用量账单:llm_token_log/llm_call_log 无 key 维度字段;两表写入目前只覆盖 workflow llm 节点(LLMUsageCallbackHandler 唯一注册点),直连面及其它调用面需统一落账(建议下沉到 BishengLLM wrapper 层);账单查询 API(按 key 归属、token+调用次数、按模型拆分)与持有人自查/管理员查询界面全部新建 +- 「租户管理员配置的可用模型范围」按 key/按应用收窄:现状 online=true 即全租户可用,无 per-key/per-app 模型白名单机制(GOV-05 应用能力声明写模型名→审批→运行时注入的关联表与校验均需新建) +- 能力收回的应用侧联动:模型侧错误态已有,但「应用声明使用的模型被下线→owner 在发布面/资源面可见失效提示」的声明关联与提示面为零 + +## risks +- 凭据明文存储与 C6 落差:llm_server.config 明文 JSON 存 api key,get_one_llm 对租户管理员返回明文;PRD 强调底层账号零暴露,若直连代理长期持有转发凭据,是否本期升级加密存储(涉及存量迁移+DM8 双库)是成本敏感决策 +- 收回生效延迟:模型/服务商信息有 Redis 缓存(llm:server:/llm:model: 前缀)+ 进程内 LLM_CACHE(ttl=60s),下线/删除后最长一个 TTL 内仍可能初始化成功——GOV-05「明确错误态」若要求即时生效需做缓存失效设计 +- token 提取双轨口径不一致:账单若基于 LLMUsageCallbackHandler._extract_token_usage(只认 llm_output.token_usage/usage)而 telemetry 走 parse_token_usage(兼容 usage_metadata/流式 chunk/cache token),同一调用两处计数可能不同;按 key 账单必须统一到一套提取逻辑,否则与既有配额(model_tokens_monthly)对不上账 +- 高频直连面的写放大:llm_token_log/llm_call_log 每 call 双 INSERT(MySQL 同步),Claude Code 等本地 agent 直连是高频场景,DM8 环境有历史写放大事故(灵思 -7120),账单落库需评估批量/异步化 +- C3 租户上下文:直连面绕开 JWT 中间件,LLMTokenTracker/ModelCallLogger 在 ContextVar 缺失时抛 TenantContextMissingError(fail-closed),key 鉴权中间件必须承担 set_current_tenant_id 职责,否则计量整面拒写;多租户共享模型下须沿用 INV-T13(记 caller leaf tenant)避免 Root/Child 记账串扰 +- C5 错误码体系冲突:OpenAI/Anthropic 协议要求各自原生错误体(非 resp_200 包裹、非 MMMEE 码),需要一层协议错误映射;现存 /api/v2/assistant/chat/completions 用 500+裸字符串是反面教材,且该端点零鉴权本身是活的越权面(开放 API PRD 已判定),新直连面不能沿用其 default_operator 模式 +- 限流缺口:现有限流仅服务商级/天(Redis INCR),无 per-key 限流轮子(记忆与代码一致:全仓无限流基建);直连面暴露给本地工具后滥用防护需从零建 +- 「模型名即原名」的名称歧义是产品级风险:租户内跨服务商同名 model_name 合法存在(如两个服务商都配 qwen-max),解析规则不定义清楚会导致应用声明绑定到错误凭据 + +## open_questions +- DEV-01 个人 API key 与《开放 API 鉴权 PRD》的服务账号密钥(bs-sak-)是否同一实体/同一鉴权中间件(仅 scope 与前缀不同),还是独立表?按 key 账单的 key_id 外键指向哪张表? +- 「模型名即平台模型管理原名」指 LLMModel.name(显示名)还是 model_name(调用参数名)?租户内同名冲突(跨服务商)时的解析规则:报错、要求唯一约束、还是 server/name 组合名? +- 直连面架构选型:统一译成 BishengLLM 再出(自动继承 telemetry/限流/错误分类/离线校验,但 Anthropic 入站语义经 OpenAI 化转换有损,如 cache_control、tool_use 块),还是同协议直透 upstream+旁路计量?Anthropic 入站是否只对 anthropic 类型服务商开放? +- 按 key 账单数据源:扩展 llm_token_log 加 key_id 并把落账下沉到 BishengLLM 层全面覆盖(顺带补齐助手/灵思等既有调用面的账单缺口),还是新表只覆盖直连面?账单聚合粒度(日/月)、按模型拆分的展示口径与数据保留期? +- GOV-05「可用模型范围」首版是否= 既有 online 全租户粒度即满足(key 只看租户内 online 模型),还是要求 per-key 模型白名单?应用能力声明中的模型与个人 key 的模型范围是否同一套机制? +- 凭据加密存储是否纳入本期(存量 llm_server.config 迁移 + DM8 兼容 + get_one_llm 读路径收紧),还是维持明文仅靠读侧脱敏? +- 直连面的限流策略需要产品定义:per-key QPS/日调用/月 token 上限各要哪些?超限错误按 OpenAI 429 语义还是平台错误码? diff --git a/features/v3.0.0/000-prd1-discovery/research/permission-rebac.md b/features/v3.0.0/000-prd1-discovery/research/permission-rebac.md new file mode 100644 index 0000000000..c88d7fd7f2 --- /dev/null +++ b/features/v3.0.0/000-prd1-discovery/research/permission-rebac.md @@ -0,0 +1,69 @@ +# permission-rebac + +## summary +权限体系已具备 PRD GOV-01/RT-02 所需的全部积木:PermissionService 七级检查链(超管短路→缓存→租户门→子租户管理员短路→OpenFGA→创建者兜底→降级回退)、通用授权 API(/api/v1/permissions/resources/{type}/{id}/authorize,按用户/部门/用户组)、platform 端通用授权弹窗 PermissionDialog、以及 ListObjects∪绑定表 的列表预过滤模式,均已在 workflow/assistant 上跑通。核心缺口是「app」作为新资源类型的全链路注册:OpenFGA 静态模型文件、VALID_RESOURCE_TYPES、permission_service 内约 7 处 per-type dispatch 分支、细粒度权限模板、两个前端的 ResourceType 联合类型;最大风险是 FGA 授权模型只在「store 无模型」时写入,存量环境新增类型必须运维介入(force_write_model),无自动升级通道。 + +## current_state +【入口与检查链(已核实)】核心引擎是 src/backend/bisheng/permission/domain/services/permission_service.py 的 PermissionService(全 @classmethod 无状态)。check()(L82-215)实际是七级链(文件头 docstring 明示):L1 超管短路——login_user.is_admin() 直接 return True(is_admin 定义在 src/backend/bisheng/user/domain/services/auth.py:270,判 user_role 含 AdminRole,即 super_admin,这是第一个 allow-all 短路);L3/L4 租户门 _evaluate_tenant_gate()(L999-1036)——先 _resolve_resource_tenant()(L1743,仅 workflow/assistant/knowledge_space/knowledge_library 四类解析 tenant_id,其余返回 None 跳过门)比对 login_user.get_visible_tenants(),不可见且非 shared_to 即拒绝(tenant mismatch 拒绝级);同函数内做子租户管理员短路——has_tenant_admin(resource_tenant_id) 为真直接返回 PermissionLevel.owner(第二个 allow-all 短路,Root 租户按 INV-T3 无 tenant#admin 元组故不吃此短路);L2 Redis 缓存(10s,UNCACHEABLE_RELATIONS 绕过,刻意放在租户门之后防止陈旧 allow 绕门);L5 OpenFGA fga.check();L6 DB creator 兜底 _get_implicit_permission_level_after_gate()(L1039,require_no_active_owner=True 时仅当无其它 owner 元组才把 creator 当 owner,knowledge_space 例外为永久 owner);L7 FGAConnectionError 时不再硬 fail-closed,而是回退 owner/implicit 档位(L194-212 注释说明三条 FGA 不可用路径已对齐)。RBAC 菜单权限是独立平行体系:role_access 表 AccessType.WEB_MENU=99(src/backend/bisheng/database/models/role_access.py:89),配置面在 src/backend/bisheng/role/domain/services/role_service.py(:541/:784),并由 permission/domain/services/legacy_rbac_sync_service.py 的 LegacyRBACSyncService 把 role_access 行同步成 FGA 元组。 +【OpenFGA 模型(已核实)】DSL 以 Python 静态 dict 形式放在 src/backend/bisheng/core/openfga/authorization_model.py:MODEL_VERSION='v2.0.2',16 个类型 = user/system/tenant/department/user_group 基础类型 + 11 个资源类型(knowledge_space、knowledge_library、folder、knowledge_file、channel、workflow、assistant、tool、dashboard、llm_server、llm_model)。资源类型由 _standard_resource_type() 工厂生成:owner>manager>editor>viewer 金字塔 + can_manage/can_edit/can_read computed + can_delete + shared_with:[tenant](F013 跨租户共享)。授权主体三源固定为 user / department#member / user_group#member(+admin)(_user_types())。模型写入在 core/openfga/manager.py::_async_initialize(L60-75):仅当 store 无既有模型或配置 force_write_model=true(core/config/openfga.py:16)时才 POST 新模型,否则复用最新既有模型——即改了静态文件存量环境不会自动生效;另有 F013 遗留的 dual_model_mode/legacy_model_id 双模型灰度配置。llm_server/llm_model 当年就是「静态文件预分配+发版时写新模型」的先例。 +【资源创建授权与补偿(已核实)】创建资源写 owner 元组的契约是 permission/domain/services/owner_service.py::OwnerService.write_owner_tuple(L44,默认 best-effort,enforce_fga_success 可选强制)。PermissionService.authorize()(L314-408):_expand_subject 展开主体(部门 include_children 展开整棵子树为多条 department:{id}#member)、write-over-delete 去重、交给 batch_write_tuples()(L413,每批 100,FGAWriteError 时回退逐条写并容忍幂等错误 _is_idempotent_tuple_error),失败落 FailedTuple 表(database/models/failed_tuple.py),并按 affected_user_ids 逐用户失效 PermissionCache。补偿重试是 Celery beat 任务 worker/permission/retry_failed_tuples.py:每 30s、Redis 分布式锁、批量→逐条回退、最多 3 次后 mark_dead + logger.critical。 +【通用授权 API 与细粒度模板(已核实)】前端弹窗对接 permission/api/endpoints/resource_permission.py:POST /api/v1/permissions/resources/{resource_type}/{resource_id}/authorize(:1457 authorize_resource,入口先校验 VALID_RESOURCE_TYPES),配套 grant-subjects/users、departments/children(懒加载部门树)、模板端点(:2161-:2205 按类型各一个)。resource_type→细粒度权限 id 的映射由各 *_permission_template.py 提供(application_permission_template.py 定义 workflow/assistant 共用的 view_app/use_app/edit_app/delete_app/publish_app/share_app/manage_app_* 及 relation 默认档位),dispatch 在 resource_permission.py::_default_permission_ids_for_relation(:562,硬编码 if-chain)。自定义关系模型(F048)与绑定表以 JSON 存 Config 表(_save_relation_models/_save_bindings,ConfigDao),带版本化进程内缓存 relation_roster_cache。permission/api/endpoints/permission_check.py 提供前端自查端点 POST /permissions/check(支持 permission_id 细粒度)与 GET /permissions/objects(admin 返回 null=不过滤)。 +【前端通用授权弹窗(已核实)】platform(管理端):src/frontend/platform/src/components/bs-comp/permission/PermissionDialog.tsx——Tabs 按用户/部门/用户组(SUBJECT_TABS),PermissionListTab(已授权列表)+ PermissionGrantTab(新增授权,支持关系模型选择 getGrantableRelationModelsApi、部门含子部门 include_children)。使用方:构建页 BuildPage/apps.tsx:476(workflow/assistant 卡片授权,即 PRD 说的「像其他平台资源一样授权」的现成交互)、flow/Header.tsx:532、assistant/editAssistant/Header.tsx:113、tools/index.tsx:232、KnowledgePage/KnowledgeFile.tsx:727、KnowledgeQa.tsx:594、Dashboard/DashboardSidebar.tsx:248。resourceType 联合类型在 bs-comp/permission/types.ts(9 类,无 app)。client(工作台):src/frontend/client/src/components/permission/PermissionDialog.tsx 只是 KnowledgeSpaceShareDialog 的薄壳(知识空间成员管理走它),频道另有 pages/Subscription/ChannelPermissionDialog.tsx;client 的 ResourceType 联合类型在 client/src/api/permission.ts:3。 +【列表可见性过滤模式(已核实)】构建页与工作台应用列表共用 GET /api/v1/workflow/list(client/src/api/apps.ts:391 也调它)。后端 api/services/workflow.py::get_all_flows_envelope(:565,F027 游标):is_admin_bypass = user.is_admin() 且非 scoped_super_admin(:612-613)→ 整页不过滤;普通用户走两段式——先 _app_type_ids_for_permission(:699)做 ID 预过滤 = PermissionService.list_accessible_ids(FGA ListObjects,带 Redis 缓存)∪ ApplicationPermissionService.get_bound_app_type_ids_async(绑定表推导,该方法 docstring 自述是对「list_objects 在元组缺失/不稳时返回空」的兜底),再用预过滤 ID 集做 DB keyset 扫描;行级细粒度(can_share/writeable)再经 get_app_permission_map_async 逐行求有效权限集(并发 20 semaphore + 请求级 ApplicationPermissionContext 复用)。list_accessible_ids(permission_service.py:218)对 admin 返回 None(调用方免过滤),普通用户 = FGA ids + creator 自建 ids + 子租户管理员 scope ids(_resource_ids_child_tenant_admin_scope :1347)再过租户门 _filter_ids_by_tenant_gate。 + +## key_files +- src/backend/bisheng/permission/domain/services/permission_service.py — PermissionService 核心:check() 七级链、list_accessible_ids()、authorize()、batch_write_tuples()、_evaluate_tenant_gate/_resolve_resource_tenant/_get_resource_creator 等 per-type dispatch(新增 app 类型的主要改造面) +- src/backend/bisheng/core/openfga/authorization_model.py — OpenFGA 授权模型 DSL(静态 Python dict,MODEL_VERSION v2.0.2,16 类型);新增 app = _standard_resource_type('app') + 版本号 +- src/backend/bisheng/core/openfga/manager.py — 模型写入仅在 store 无模型或 force_write_model=true 时发生(存量环境升级卡点);dual_model_mode 灰度配置 +- src/backend/bisheng/permission/domain/schemas/permission_schema.py — VALID_RESOURCE_TYPES(9 类,无 app)——/check、/objects、/authorize 的入口白名单 +- src/backend/bisheng/permission/api/endpoints/resource_permission.py — 通用授权 API:authorize_resource(:1457)、grant-subjects 搜索、模板端点、_default_permission_ids_for_relation(:562) 类型 dispatch、关系模型/绑定 JSON 存 Config 表 +- src/backend/bisheng/permission/domain/application_permission_template.py — workflow/assistant 细粒度权限模板(view/use/edit/delete/publish/share/manage_*)——app 类型模板的样板 +- src/backend/bisheng/permission/domain/services/owner_service.py — OwnerService.write_owner_tuple——资源创建写 owner 元组的 F008 契约(INV-2) +- src/backend/bisheng/worker/permission/retry_failed_tuples.py — FailedTuple 补偿:Celery beat 每 30s,Redis 锁,3 次后 dead + critical 告警 +- src/backend/bisheng/database/models/failed_tuple.py — 失败元组补偿队列表 +- src/backend/bisheng/api/services/workflow.py — get_all_flows_envelope(:565)/_app_type_ids_for_permission(:699)——列表可见性过滤范式(admin bypass + ListObjects∪绑定预过滤 + 行级细粒度),RT-02 广场过滤直接复用 +- src/backend/bisheng/permission/domain/services/application_permission_service.py — 应用细粒度权限求值 + get_bound_app_type_ids_async(ListObjects 不稳兜底);_FLOW_TYPE_TO_OBJECT_TYPE 仅 WORKFLOW/ASSISTANT +- src/backend/bisheng/permission/api/endpoints/permission_check.py — 前端自查端点 /permissions/check + /permissions/objects +- src/backend/bisheng/database/models/role_access.py — AccessType.WEB_MENU=99——RBAC 菜单权限(GOV-07 应用工场菜单/新建权限的接入点) +- src/backend/bisheng/permission/domain/services/legacy_rbac_sync_service.py — role_access(RBAC)→FGA 元组同步 +- src/frontend/platform/src/components/bs-comp/permission/PermissionDialog.tsx — platform 通用授权弹窗(按用户/部门/用户组三 Tab + 关系模型),GOV-01 复用目标 +- src/frontend/platform/src/components/bs-comp/permission/types.ts — platform 端 ResourceType 联合类型(需加 app) +- src/frontend/client/src/components/permission/PermissionDialog.tsx — client 端「通用」弹窗实为 KnowledgeSpaceShareDialog 薄壳——若发布面在工作台需真正通用化 +- src/frontend/client/src/api/permission.ts — client 端 ResourceType 联合类型与授权 API 封装 +- src/frontend/platform/src/pages/BuildPage/apps.tsx — :476 构建页应用卡片挂 PermissionDialog——「应用像其他资源一样授权」的现成交互样板 + +## reuse +- GOV-01 通用授权交互(按用户/部门/用户组)可整体复用:后端 POST /api/v1/permissions/resources/{type}/{id}/authorize(resource_permission.py:1457)+ platform PermissionDialog.tsx(BuildPage/apps.tsx:476 已在应用卡片上用同款弹窗),新 app 类型只需进白名单与类型联合 +- GOV-01「授权变更自下一次请求起生效」已由现有机制保证:authorize() 按 affected_user_ids 逐用户失效 PermissionCache(permission_service.py:403-408),部门/用户组授权经 _affected_user_ids_for_subject 展开;检查链 L2 缓存也仅 10s TTL +- RT-02 广场按可见范围过滤可直接套用现有列表范式:get_all_flows_envelope 的「admin bypass + ListObjects∪绑定表预过滤 + DB keyset + 行级细粒度」(api/services/workflow.py:565-740),client 应用广场本就走同一 /api/v1/workflow/list +- GOV-01 租户管理员代 owner 调整:_evaluate_tenant_gate 的子租户管理员 owner 级短路(permission_service.py:1026-1034)使租户管理员天然能打开/修改本租户任意资源的授权关系,无需新权限逻辑 +- 应用创建即 owner:OwnerService.write_owner_tuple(owner_service.py:44)是现成的创建契约,含 enforce_fga_success 强一致选项与 FailedTuple 补偿(worker/permission/retry_failed_tuples.py 30s 重试) +- 细粒度动作权限(发布/下线/分享等)可仿 application_permission_template.py 建 app 模板并接入 _default_permission_ids_for_relation dispatch,F048 关系模型/绑定存储(Config 表 JSON + relation_roster_cache)零改动复用 +- GOV-07 应用工场菜单与「新建应用」角色配置可复用 RBAC 菜单体系:AccessType.WEB_MENU(role_access.py:89)+ role_service.py 配置面 + LegacyRBACSyncService 同步 +- NFR-1.3/入口访问检查可复用 /permissions/check 端点(permission_check.py:24,支持 relation 或 permission_id 细粒度) + +## gaps +- OpenFGA 模型新增 app 类型:core/openfga/authorization_model.py 加 _standard_resource_type('app') + MODEL_VERSION 升版;且必须配套存量环境模型升级手段——manager.py 仅在 store 无模型或 force_write_model=true 时写模型,目前没有『版本比对自动升级』机制(llm_server/llm_model 当年靠发版前预分配绕过了这个问题) +- permission_service.py 内约 7 处 per-type dispatch 需加 app 分支:VALID_RESOURCE_TYPES、_resolve_resource_tenant(租户门)、_get_resource_creator(creator 兜底)、_TENANT_GATED_RESOURCE_TYPES、_resource_ids_by_creator_user_ids、_resource_ids_in_tenants/_resource_tenant_map(列表租户过滤)、必要时 _IMPLICIT_SCOPE_RESOURCE_TYPES +- app 细粒度权限模板:新建 permission/domain/app_permission_template.py(可含 use/view/manage 等)+ resource_permission.py 的 _default_permission_ids_for_relation dispatch + 模板端点;application_permission_service.py 的 _FLOW_TYPE_TO_OBJECT_TYPE 仅覆盖 WORKFLOW/ASSISTANT,app 若不入 flow 表需新的求值路径 +- 两个前端的 ResourceType 联合类型均需加 'app'(platform bs-comp/permission/types.ts、client api/permission.ts);若发布面/应用管理在 client 工作台,client 的『通用弹窗』需真正通用化(现为 KnowledgeSpaceShareDialog 薄壳,platform 版才是完整三 Tab + 关系模型实现) +- RT-02 广场对 app 的列表接入:现有过滤范式绑定 FlowType 枚举与 flow 表(get_all_flows_envelope / TagDao ResourceTypeEnum),app 作为新资产需要新增 FlowType 值或独立列表通道,标签体系 TagDao 也需扩 ResourceTypeEnum +- GOV-01 授权变更计审计:未发现 authorize_resource 接审计中心的证据(仅见 _dispatch_authorize_notifications_in_background 站内通知)——『owner 调可见范围计审计、租户管理员代调计审计』需新建审计埋点(未逐行核实 authorize_resource 全文,此条为待确认缺口) +- GOV-09 租户管理员『应用管理列表行上打开同款授权弹窗』的管理面页面与入口为新建(platform 侧现无应用工场管理页) + +## risks +- 【存量环境模型升级是硬风险】FGA 模型只写一次的机制意味着 app 类型上线必须有运维介入(force_write_model 或等价 runbook);参考 F048 迁移在存量环境被拦的教训(memory),需要把『模型升版+重启顺序+灰度(dual_model_mode 尚在)』写进发布方案,否则新类型的 check 全部 fail 向兜底路径 +- 【fail-closed 语义与现状有出入】PRD NFR-1.4 要求『权限评估失败即报错,绝不回退』,但现有 check() 在 FGA 不可用时刻意回退 owner/implicit(permission_service.py:194-212 注释),list 回退 creator+租户管理员 scope——回退是收窄而非放行,但『报错 vs 收窄放行 owner』的口径需要与 PRD 对齐,app 运行时链路若要更严必须显式传参或新增严格模式 +- 【allow-all 短路与受限委托红线】super_admin(is_admin L1)与子租户管理员(tenant gate owner 短路)是仅有的两个 allow-all 短路;开放 API PRD 已判定禁止代表这两类身份做委托——app 运行时以用户身份评估权限时必须继承该六道准入约束(memory:v2 openapi auth D2 改判) +- 【ListObjects 可靠性】代码自述 list_objects 在元组缺失/不稳时会返回空(application_permission_service.py:108-114 docstring),现网靠绑定表并集兜底;app 可见性若只依赖 FGA ListObjects 会复现同类空窗,建议沿用双源并集模式 +- 【C4/C3 宪法】新 app 类型的一切检查必须走 PermissionService→OpenFGA(C4),新表须注册租户感知模块并注意租户过滤只拦 SELECT 的坑(C3;批量 UPDATE/DELETE 手写 tenant 条件) +- 【多处硬编码类型映射易漏】FlowType↔object_type 映射散落(application_permission_service._FLOW_TYPE_TO_OBJECT_TYPE、workflow.py object_type_for_flow_type、TagDao ResourceTypeEnum、legacy_rbac_sync_service._fga_object_types)——加 app 时漏一处即静默失效 +- 【缓存一致性】PermissionCache 10s TTL + relation_roster_cache 版本缓存 + F037 浏览缓存方向——GOV-01『即时生效』依赖 invalidate_user 全覆盖,若 app 授权引入新主体展开路径需保证 affected_user_ids 计算完整(部门子树展开已有实现可复用) + +## open_questions +- app 的『可见范围』映射到哪个 relation?最小方案 = viewer(can_read 即入口+广场可见),但 PRD 还有 use(使用)语义——是 viewer 一档搞定还是仿 application 模板拆 view_app/use_app 细粒度?决定模板设计与授权弹窗档位下拉 +- app 资产落库形态:复用 flow 表新增 FlowType(列表/标签/广场全链路自动继承,但 per-type dispatch 走 workflow 同构路径)还是独立新表(更干净但 7 处 dispatch + 列表通道 + TagDao 全要新做)?直接决定权限接入工作量 +- 发布面(owner 打开授权弹窗的地方)在哪个前端?若在 client 工作台,需要把 platform 的完整 PermissionDialog 移植/通用化到 client(Recoil/shadcn 技术栈不同,不能直接复制);若在 platform 构建页则近乎零成本 +- FGA 模型升级的部署策略要拍板:一次性 force_write_model 运维步骤、还是新增『MODEL_VERSION 比对自动写新模型』机制(后者是平台级改进,超出本 PRD 但一劳永逸);以及是否启用 dual_model_mode 灰度 +- GOV-01 审计的实现口径:授权变更事件接现有 audit 模块(api/v1/audit)还是审批中心事件流?租户管理员代调的『操作人以本人计』需要审计记录结构支持 actor≠owner +- 租户管理员在『应用管理列表』代 owner 调整时,是否允许其看到 owner-only 动作(删除/回滚)置灰而非隐藏(PRD §矩阵注明确 owner-only 不可代行,但 UI 呈现方式未定) diff --git a/features/v3.0.0/000-prd1-discovery/research/quota-tenant-roles.md b/features/v3.0.0/000-prd1-discovery/research/quota-tenant-roles.md new file mode 100644 index 0000000000..6fbda6977e --- /dev/null +++ b/features/v3.0.0/000-prd1-discovery/research/quota-tenant-roles.md @@ -0,0 +1,76 @@ +# quota-tenant-roles + +## summary +配额/租户管理/角色权限三条线的现状基础设施都已存在且可直接承载 GOV-03/GOV-07 的大部分需求:租户配额存 tenant.quota_config JSON(白名单校验 + 三级配额引擎 QuotaService),租户管理页配额弹窗 quotaFields 是按多字段设计的数组(当前仅 storage_gb 一项);admin-scope 切换(F019)是 Redis + 中间件按 API 前缀注入的管理视图机制;角色菜单权限走 roleaccess 表 type=99 (WEB_MENU) 且后端不校验菜单键白名单,新增「应用工场」菜单与「新建应用」权限点近似零后端 schema 改动(有 create_app 子开关先例);部署级可选层可照抄 multi_tenant.enabled / BISHENG_PRO 的 settings→/env→appConfig 三段式。真正要新建的是:资源档位表 + 系统管理页新 tab、应用实例计数源(依赖应用实例表)、待上线状态机与终检通知、工场运行时层部署 flag。最大隐患是 tenant quota 的整体覆盖写语义(弹窗保存会清掉 quotaFields 之外的已存 key)与终检的 check-then-act 竞态。 + +## current_state +【存储配额 storage_gb】配额值不落独立表:租户级存 `tenant.quota_config` JSON 列(src/backend/bisheng/database/models/tenant.py:95),角色级存 `role.quota_config` JSON(src/backend/bisheng/database/models/role.py:26)。核心引擎 = `QuotaService`(src/backend/bisheng/role/domain/services/quota_service.py):三级计算(超管短路 -1 → 角色级多角色聚合 `_compute_role_quotas` → 租户链硬顶 `_apply_tenant_chain_cap`,Root 用量聚合 `_aggregate_root_usage` 含全部 active 子租户 INV-T9);合法 key 白名单 `VALID_QUOTA_KEYS` = DEFAULT_ROLE_QUOTA(9 项) + `_TENANT_ONLY_QUOTA_KEYS` {storage_gb, user_count, model_tokens_monthly} + 菜单模式元数据 keys,`validate_quota_config` 拒绝未知 key(24005)。用量统计走 `_RESOURCE_COUNT_TEMPLATES` 原生 SQL 字典(storage_gb = knowledgefile.file_size SUM,file_source IN ('channel','space_upload'))。语义:缺 key / -1 = 不限——与 PRD「未设置=不限」天然一致。 +【quota endpoints】用户视角 `GET /api/v1/quota/effective` 与 `/quota/usage`(src/backend/bisheng/role/api/endpoints/quota.py,F005 产物,client 端额度条曾复用,见冻结分支记忆);管理视角 `GET/PUT /api/v1/tenants/{id}/quota` + `GET /api/v1/tenants/quota/tree`(src/backend/bisheng/tenant/api/endpoints/tenant_crud.py:129-167,`UserPayload.get_admin_user` 依赖),落到 `TenantService.aget_quota/aset_quota/aget_quota_tree`(src/backend/bisheng/tenant/domain/services/tenant_service.py:363-430)。⚠️已核实:`aset_quota` 是整体替换写(line 392 `aupdate_tenant(tenant_id, quota_config=data.quota_config)`)。 +【执行点】两类:① 创建类 endpoint 装饰器 `require_quota(QuotaResourceType.X)`(quota_service.py:826)——knowledge.py:101、knowledge_space.py:52、assistant.py:84、workflow.py:185、tool/api/tool.py:31;内部走 `check_quota`(先租户链 blocker → 19401/19403,再角色级 → 19402,错误码在 common/errcode/tenant_quota.py)。② 存储字节级前置额度计算 `get_knowledge_space_upload_limit_bytes` + `get_tenant_storage_remaining_bytes`,调用点:knowledge_space_service.py:3176/3384/3603、workstation/api/endpoints/knowledge.py:30、open_endpoints/api/endpoints/filelib.py:334/454/481(v2 开放 API 也在执行)。 +【租户管理页配额弹窗】platform 前端 `TenantPage/index.tsx`(路由 routes/index.tsx:116,permission:'sys')列表展示 storage_used_gb/storage_quota_gb(后端批量 `QuotaService.get_storage_used_gb_batch` quota_service.py:730);弹窗 `TenantPage/components/TenantQuotaDialog.tsx`——`quotaFields` 是数组(line 56-61,当前仅 `{key:'storage_gb'}` 一行),输入校验镜像后端 GB float 规则,保存时**只序列化 quotaFields 中的 key 并整体覆盖**(line 77-98)。给 PRD「实例配额与存储配额同弹窗并列」加一行即可,但两入口写同 JSON 的覆盖语义要先修。 +【admin-scope 租户管理视图】F019 三件套:服务 `TenantScopeService`(src/backend/bisheng/admin/domain/services/tenant_scope.py)——Redis key `admin_scope:{user_id}`、TTL=settings.multi_tenant.admin_scope_ttl_seconds(默认 14400s 滑动)、非 JWT、审计 admin.scope_switch、logout/token-bump 清除钩子;endpoint `POST/GET /api/v1/admin/tenant-scope`(admin/api/endpoints/tenant_scope.py,仅全局超管);中间件 `AdminScopeMiddleware`(src/backend/bisheng/common/middleware/admin_scope.py)只对 `MANAGEMENT_API_PREFIXES`(/api/v1/llm、/workstation、/linsight、/tool、/knowledge、/chat/online、/admin)把 scope 注入 ContextVar `_admin_scope_tenant_id`(core/context/tenant.py:65,get_current_tenant_id 优先返回它),每请求重验 `_check_is_global_super`,fail-open。前端:hook `useAdminScope`(platform/src/hooks/useAdminScope.ts)+ UI 仅存在于模型管理页 `pages/ModelPage/manage/ScopeBar.tsx`(v2.5.1 产品评审后从全局 header 收缩为页面级切换器),且 `manage/index.tsx:198` 要求 `appConfig.multiTenantEnabled` 才渲染。 +【角色权限配置】存储:`roleaccess` 表(database/models/role_access.py,RoleAccess: role_id+third_id+type),type 枚举 `AccessType`(资源读写 1-12 + `WEB_MENU=99`);菜单键"注册表" = `WebMenuResource` enum(同文件 line 42-72):一级 workstation/admin,管理侧 build/knowledge/model/tool/mcp/channel/…,工作台侧 home/linsight_task_mode/apps,且已有**功能权限点子开关先例**:`CREATE_APP='create_app'`(BUILD 下「新建应用/管理应用模板」入口开关)与 CREATE_KNOWLEDGE。写路径:`RoleService.create_role_with_menu/update_role_with_menu`(role/domain/services/role_service.py:132/431)→ `_normalize_menu_ids`(line 766:只去重+剔除 system_config,**不做枚举白名单校验**)→ `_replace_menu_access_in_session`(line 778:删旧插新 type=99 行)。读路径:登录信息 `user/api/user.py:187` → `get_roles_web_menu`(user/domain/services/auth.py:668,多角色 union)返回 `web_menu` 列表;后端有菜单权限断言先例 `assert_effective_web_menu_contains(user_id,'model')`(auth.py:647)。菜单模式 flags `menu_approval_mode_workbench/_admin`(workbench/admin scope split)存 role.quota_config(quota_service.py `_MENU_APPROVAL_MODE_KEYS`)。前端配置面:`SystemPage/components/EditRole.tsx`——admin 菜单 MENU_LIST + 工作台菜单 `WORKBENCH_MENU_LIST`(line 80-82,'workbenchMenu' 独立面板 line 368),保存时合并 `[...menuPermissionsToSave, ...form.useWorkbenchMenu]`(line 563)一起提交。菜单 gating:platform `layout/MainLayout.tsx` `isMenu()`/`showAdminNav()`(line 93-113,超管/子租户管理员全放行、审批模式显示可申请菜单占位);client `layouts/MainLayout.tsx`(web_menu 映射为 plugins,line 105;`showWorkbenchItem('home'/'apps')` line 131-132 控 tab;menu_approval_mode_workbench 控 /menu-unavailable 占位)。 +【多租户 vs 单租户 & 部署 flag 模式】后端开关 `settings.multi_tenant.enabled`(core/config/multi_tenant.py MultiTenantConf,default False;settings.py:748);经 `GET /api/v1/env` 暴露 `multi_tenant_enabled`(api/v1/endpoints.py:94)与 `pro`(line 90,BISHENG_PRO);前端 `contexts/locationContext.tsx:91` 收进 `appConfig.multiTenantEnabled` 后各处 gating:租户管理菜单仅 `isSuperAdmin && appConfig.multiTenantEnabled`(platform MainLayout.tsx:249)、TenantPage:135 单租户降级、ScopeBar、Departments 挂租户操作、ModelPage 权限函数 canManageModelSettings。系统管理页 tab 框架:`SystemPage/index.tsx`——按 isSuperAdmin/isDeptAdmin/isChildAdmin 布尔条件挂 TabsTrigger(组织/角色/orgSync/系统配置),新增「资源档位」tab 照此模式加即可。 + +## key_files +- src/backend/bisheng/role/domain/services/quota_service.py — 配额核心引擎:VALID_QUOTA_KEYS 白名单、三级计算、租户链硬顶、_RESOURCE_COUNT_TEMPLATES 用量 SQL、require_quota 装饰器、validate_quota_config——实例数配额的预检/终检直接扩展此处 +- src/backend/bisheng/tenant/api/endpoints/tenant_crud.py — GET/PUT /tenants/{id}/quota + /tenants/quota/tree(get_admin_user 依赖),line 129-167 +- src/backend/bisheng/tenant/domain/services/tenant_service.py — aget_quota/aset_quota(line 363-395,⚠️quota_config 整体替换写)/aget_quota_tree +- src/backend/bisheng/database/models/tenant.py — tenant.quota_config JSON 列(line 95)——租户实例数配额的自然落点 +- src/frontend/platform/src/pages/TenantPage/components/TenantQuotaDialog.tsx — 租户配额弹窗;quotaFields 数组(line 56-61)加一行即得「实例数与存储同弹窗并列」;⚠️handleSave 只序列化 quotaFields 内的 key 后整体覆盖 +- src/frontend/platform/src/pages/TenantPage/index.tsx — 租户管理页;multiTenantEnabled gating(line 62/135)、配额弹窗入口、storage 用量条展示 +- src/backend/bisheng/admin/domain/services/tenant_scope.py — F019 admin-scope:Redis 4h 滑动、审计、清除钩子 +- src/backend/bisheng/common/middleware/admin_scope.py — MANAGEMENT_API_PREFIXES 列表(line 63-71)——工场管理 API 若需超管切租户视图须加前缀于此 +- src/frontend/platform/src/pages/ModelPage/manage/ScopeBar.tsx — 页面级 admin-scope 切换器 UI 先例(仅超管+多租户渲染),可复制到工场管理面 +- src/backend/bisheng/database/models/role_access.py — RoleAccess 表 + AccessType(WEB_MENU=99) + WebMenuResource 菜单键枚举(含 create_app 权限点子开关先例) +- src/backend/bisheng/role/domain/services/role_service.py — 角色菜单写路径 _normalize_menu_ids(不校验白名单,line 766)+ _replace_menu_access_in_session(line 778) +- src/backend/bisheng/user/domain/services/auth.py — get_roles_web_menu(line 668,多角色 union) + assert_effective_web_menu_contains(line 647,后端菜单权限断言先例) +- src/frontend/platform/src/pages/SystemPage/components/EditRole.tsx — 角色权限配置面:MENU_LIST + WORKBENCH_MENU_LIST(line 73-82)双面板;「应用工场」菜单/「新建应用」权限点在此加条目 +- src/frontend/platform/src/pages/SystemPage/index.tsx — 系统管理页 tab 框架(按管理员身份布尔挂 TabsTrigger)——「资源档位」新 tab 照此模式 +- src/frontend/platform/src/layout/MainLayout.tsx — platform 菜单 gating:isMenu/showAdminNav(line 93-113);租户管理入口 isSuperAdmin && multiTenantEnabled(line 249) +- src/frontend/client/src/layouts/MainLayout.tsx — client 工作台菜单 gating:web_menu→plugins、showWorkbenchItem('home'/'apps')、menu-unavailable 占位——「应用工场」工作台入口照此 +- src/backend/bisheng/api/v1/endpoints.py — /api/v1/env 暴露 multi_tenant_enabled(line 94)/pro(line 90)——部署级可选层 flag 的现成暴露通道 +- src/backend/bisheng/core/config/multi_tenant.py — MultiTenantConf——新增部署 flag(工场运行时层/开放能力层)的 settings section 范本 +- src/frontend/platform/src/contexts/locationContext.tsx — appConfig.multiTenantEnabled 注入点(line 91)——前端消费部署 flag 的单一入口 +- src/backend/bisheng/common/errcode/tenant_quota.py — 19401/19402/19403 配额错误码——实例配额超限错误码在同模块续号 + +## reuse +- 租户实例数配额存储:直接复用 tenant.quota_config JSON(database/models/tenant.py:95)加新 key(如 app_instance_count),VALID_QUOTA_KEYS + validate_quota_config(quota_service.py:57/790)加白名单即可,无需新表、无迁移(JSON 列);「未设置=不限」语义(缺 key/-1=unlimited)与 PRD 一致(verified) +- 配额弹窗并列展示:TenantQuotaDialog.quotaFields 本就是数组(line 56-61),加 {key:'app_instance_count'} 一行 + i18n label 即得「与既有存储配额同弹窗并列」;输入校验模式(镜像后端规则)可照抄(verified) +- 预检+终检执行模式:复用 QuotaService.check_quota / _apply_tenant_chain_cap 的租户链检查骨架与 194xx 错误码族(common/errcode/tenant_quota.py);提交发布预检可仿 require_quota 装饰器(quota_service.py:826,5 个资源 endpoint 在用),上线终检在服务层显式调 check(channel_service.py:1423 有服务层直调先例)(verified) +- 「应用工场」菜单 + 「新建应用」权限点:完全复用 roleaccess 表 type=99 机制——WebMenuResource 加枚举成员(文档性质,后端 _normalize_menu_ids 不校验白名单故零 schema 改动,role_service.py:766);CREATE_APP='create_app' 已是「菜单+功能点子开关」的完整先例(role_access.py:50);前端 EditRole.tsx WORKBENCH_MENU_LIST/MENU_LIST 加条目;后端拦截复用 assert_effective_web_menu_contains 先例(auth.py:647);变更审计走既有角色保存路径(verified) +- 默认值策略(菜单默认仅租户管理员开):platform MainLayout isMenu() 已对 isSuperAdmin/isChildAdmin 全放行(MainLayout.tsx:99),普通角色缺 key 即不可见——与 PRD「默认关闭、管理员勾选开通」的灰度模型天然一致(verified) +- 部署级可选层开关:照抄 multi_tenant.enabled / BISHENG_PRO 三段式——settings 新 section(core/config/ 下仿 MultiTenantConf)→ /api/v1/env 加字段(api/v1/endpoints.py:90-94)→ locationContext appConfig → 前端 gating(TenantPage:135 有「关闭时降级页」先例);「未部署时资源档位 tab 不出现」即 SystemPage/index.tsx 的布尔挂 tab 模式(verified) +- 平台超管切租户上下文:F019 admin-scope 全套可复用——工场管理 API 前缀加入 MANAGEMENT_API_PREFIXES(admin_scope.py:63)后 get_current_tenant_id 自动生效,页面复制 ScopeBar 组件模式(verified;注意该机制当前 UI 仅模型管理页一处) +- 本租户配额视图(GOV-09 用量条):GET /api/v1/quota/effective + /usage(role/api/endpoints/quota.py)与 client useEffectiveQuota 先例(记忆:知识空间额度条冻结分支 feature/knowledge-space-quota-display 已实现同类 UI,单位已 GB)可参考复用 +- 单租户部署写配额:tenant 表在单租户模式也始终有 ROOT 行(ROOT_TENANT_ID=1,tenant quota endpoints 本身无 multi_tenant gating,只有前端菜单 gating)——单租户时实例配额仍可写 Root 行 quota_config,由「资源档位」tab 内的实例配额区调用同一 PUT /tenants/1/quota(推断,端点无模式限制已核实,产品动线需确认) + +## gaps +- 资源档位(Tier)实体:全仓无任何服务器资源档位/CPU/内存规格概念——需新表(名称/CPU/内存/适用说明/启用状态,平台级无 tenant_id)+ CRUD service/endpoints + 「使用中应用数」统计(依赖应用实例表 join)+ 档位停用只拦新选择的规则层 +- 系统管理页「资源档位」tab:SystemPage/index.tsx 新 TabsTrigger + 新组件(超管可见 && 工场运行时层已部署双条件);单租户形态下 tab 内还要嵌「实例配额区」子块(多租户走 TenantQuotaDialog,单租户此处)——同一配额两个写入口需统一写语义 +- 应用实例数计数源:_RESOURCE_COUNT_TEMPLATES 需新条目,但被计数的应用实例表本身属 PRD-1 其它调研线的新建物;计数 SQL 须排除审批期临时预览实例(RT-03 不计入)与已下线实例——状态字段设计要预留 +- 「待上线(配额不足)」状态机分支 + 终检失败通知:审批单保持通过、应用转待上线态、站内消息提示 owner 与租户管理员(可复用 notification/ 站内信模块与 approval outbox 模式,但接线是新活);配额释放后 owner 手动上线免重新审批的路径 +- 工场运行时层/开放能力层两个部署 flag:settings 新 section + /env 字段 + 前端 appConfig gating + 未部署时统一引导页(RT-01)+ API key 管理入口整体隐藏(GOV-08 联动)——flag 本身是新建,模式可抄 +- CLI 首发 deploy 的「新建应用」权限点校验:v2 开放 API(SAK 鉴权,见开放 API PRD)此前无 web_menu 校验先例——需要把 SAK 解析出的用户身份接到角色 web_menu 检查(assert_effective_web_menu_contains 的 v2 变体),且区分首发 deploy(校验)与迭代 deploy(放行) +- 档位规格「部署配置项提供初始默认值」的 seed 机制:三档默认规格如何初始化进 DB(启动 seed / initdb_config / 配置链 YAML→env→DB)需设计——现状无档位类 seed 先例 +- 配额下调至低于存量的提示文案与「存量不受影响」语义:现有 check_quota 是 remaining=max(limit-used,0)=0 即拦——天然满足「只拦新增」,但管理 UI 下调时的影响提示是新前端交互 + +## risks +- 【已核实的覆盖写缺陷】TenantQuotaDialog.handleSave(line 77-98)只从 quotaFields 构建 config 后整体 PUT,后端 aset_quota(tenant_service.py:392)整体替换 quota_config——只要实例配额与存储配额有任一入口不含对方字段(如单租户的资源档位 tab 实例配额区 vs 多租户弹窗),保存即互相清掉对方 key。设计必须改成后端合并(PATCH 语义)或强制所有入口全量读改写(本仓已有同类事故先例:workstation 模型配置整体覆盖抹 models) +- 终检竞态:check_quota 是 check-then-act 无锁,两个应用并发上线可同时通过终检导致超限 1 个——存量配额体系同样非原子(可作先例接受),但 PRD 终检语义更严肃,design 阶段需显式声明或加 Redis 锁/DB 行锁 +- admin-scope 前缀误伤:MANAGEMENT_API_PREFIXES 含宽前缀 /api/v1/knowledge、/tool——工场新 API 路由命名若落在这些前缀下会意外被超管 scope 改写租户上下文;反之若工场管理面需要 scope 又忘加前缀则切换失效。前缀取名是隐性架构决策 +- C3 多租户:资源档位表是平台级(无 tenant_id),DAO 查询若在租户上下文中执行需注意 bypass_tenant_filter 惯例;若应用实例表含 tenant_id,须注册 _TENANT_AWARE_MODEL_MODULES(记忆中的既有约束),且实例计数是聚合 SQL——租户过滤监听器只拦 SELECT ORM 查询、raw text() 无注入,须手写 tenant_id 条件(已有两次泄漏教训) +- C2 双库:档位表/实例表 DDL 与计数 SQL 须 DM8 兼容(quota_service 已有 user_count/model_tokens_monthly 因 DM8 保留字与日期函数单独处理的先例,照抄其模式) +- _normalize_menu_ids 不校验菜单键白名单:加「应用工场」「新建应用」零后端改动是便利,但拼写错误 key 会静默入库且永不生效——建议在 design 中评估是否补枚举校验(会影响存量脏数据) +- 菜单默认值迁移:PRD 要求存量环境升级后「应用工场」菜单默认仅租户管理员角色开启——现状 isMenu() 对 child admin/超管代码级放行已覆盖管理员,但若「租户管理员」是普通角色(非 is_child_admin 标记)则需数据迁移给角色补 WEB_MENU 行;升级默认值语义需核实租户管理员的实现形态 +- 单租户部署差异面广:TenantPage/ScopeBar/挂租户等均以 appConfig.multiTenantEnabled gating——工场配额 UI 要在两种形态下分别验收(PRD 明确两条动线),E2E 需双模式覆盖;另注意 /api/v1/env 的 version 字段硬编码不可靠(记忆),新 flag 不要依赖 version 判断 + +## open_questions +- 实例配额存储形态:进 tenant.quota_config JSON(复用最大化,但需先修整体覆盖写语义)还是随资源档位建独立表?两个写入口(多租户弹窗 / 单租户资源档位 tab)如何共享同一后端接口避免互踩? +- 「租户实例数」的计数对象定义:仅『运行中』实例?含「待上线(配额不足)」?停用后重新启用要终检——状态机哪些态占额度需要产品拍板(直接决定计数 SQL 的状态过滤条件) +- 工场运行时层部署 flag 的检测方式与命名:纯 settings 静态 flag(如 app_factory.enabled,PRD 倾向)还是运行时探测运行时层健康?与开放能力层 flag(GOV-08 API key 入口隐藏)是一个 section 两个键还是两个 section? +- 「新建应用」权限点在 CLI/SAK 路径的检查时点:SAK(bs-sak-,服务账号密钥,见开放 API PRD)解析出的操作者身份是否总能映射到有角色的平台用户?受限委托模式下按被代表者还是密钥归属者的角色判「新建应用」? +- 档位三档默认规格的 seed 与调整持久化:部署配置项提供初始默认值、平台超管可调——调整后写 DB 覆盖配置文件?重新部署时配置文件默认值是否回灌?(config 链 YAML→env→DB→Redis 的优先级如何应用于档位) +- 「租户管理员」在权限默认值语境下的确切实现形态:is_child_admin 标记(代码级放行)还是具名角色(需迁移补 WEB_MENU 行)?决定存量升级是否需要数据迁移脚本 +- 工场管理 API 是否需要超管 admin-scope 切换视图(PRD 未明说):若需要,加 MANAGEMENT_API_PREFIXES + 页面 ScopeBar;若不需要(超管在租户管理页直接设各租户配额已够),可省一层复杂度 +- 本租户配额视图(应用管理 tab 用量条)走既有 /quota/effective 扩展还是工场自己的接口?(effective 接口按 DEFAULT_ROLE_QUOTA 枚举资源类型,实例配额是租户级无角色维度,塞进去语义略歪) diff --git a/features/v3.0.0/000-prd1-discovery/research/v2-open-api.md b/features/v3.0.0/000-prd1-discovery/research/v2-open-api.md new file mode 100644 index 0000000000..0a1d630692 --- /dev/null +++ b/features/v3.0.0/000-prd1-discovery/research/v2-open-api.md @@ -0,0 +1,77 @@ +# v2-open-api + +## summary +v2 开放 API(/api/v2/**,实现在 src/backend/bisheng/open_endpoints/,8 个路由文件约 50+ 端点)现状为零鉴权:全部端点以 DB 配置的 default_operator 固定用户身份执行,4 处接受裸 user_id 代用户(resolve_operator 只查用户存在)。伴生 PRD《3.0 开放 API 鉴权与身份传递》v2.0 已定稿 R1-R9 需求(bs-sak-/bs-pat- 双轨凭据、SHA-256 哈希存储、三身份模式、受限委托六道准入、撤销≤5s),与 PRD-1 GOV-08 的三类 key 体系(个人 key=bs-pat- / 应用 token=bs-sak- 服务账号密钥 / 会话 key)互链对齐。代码核实:凭据层为纯新建(全仓无任何 ApiKey 表/bs- 前缀/require_api_key 配置),但随机源、恒时比较 HMAC 校验、bypass_tenant_filter 查 token 先例、租户 ContextVar 机制均有现成件可复用;鉴权挂点在 api/router.py:94 的 router_rpc 及各端点内联的 get_default_operator 调用(多数非 Depends 形态,需逐端点改造)。 + +## current_state +【伴生 PRD 消化(已全文通读 1014 行)】需求编号实为 R1-R9:R1 凭据体系(P0 服务账号密钥 bs-sak- / P1 个人访问令牌 bs-pat-;SHA-256 单向哈希+唯一索引、不加盐、不用 Fernet 可逆加密(其密钥硬编码在 core/config/settings.py:20 随开源分发);明文仅创建时一次展示,列表只显前缀+末四位;有效性=「撤销时间为空且未过期」单一判据、不设 status 枚举列;校验六步流程=提取 Bearer → bypass_tenant_filter 下按 sha256 查行 → fail-closed 恒时比较/未撤销/未过期/主体有效/主体租户==密钥租户 → set_current_tenant_id → 构造 UserPayload → Redis 缓存 TTL 5s+撤销主动删缓存即「撤销≤5s」);R2 三种互斥身份模式(S 默认服务账号 / D 委托头 X-Bisheng-On-Behalf-Of / E 外部标识头 X-Bisheng-End-User 纯标签不参与鉴权);R3 三条铁律(无凭据 401、缺身份≠放行全部、权限引擎失败 5xx fail-closed)+ 受限委托六道准入(delegate 权限位/委托模式非 none/目标存在未禁用同租户/目标非超管非租户管理员=防提权核心/scoped 类型化范围/端点白名单),D2 已定为纯替换非取交集(ListObjects 被 DenyListObjectsPolicy 禁用、无缓存、创建类无前置检查、交集致委托头静默失效四条理由);R4 服务账号=复用 User 表新增 user_type 列+source=service_account+external_id=NULL 结构性免登录+租户关联声明式直写+三层对账豁免+/user/list DAO 层默认排除;R5 端点接入含两个 WS 握手鉴权(走 Authorization 头,明确不用查询参数——t 参数路径是坏的)+补工作流上线状态/停止会话归属校验;R6 管理界面;R7 审计双归属 actor+subject;R8 限流配额幂等 P2(平台无任何 HTTP 限流基建);R9 存量迁移(open_api.require_api_key 开关默认 false 兼容窗口、default_operator 转型为默认服务账号并强制降权)。附录 B 关键事实澄清=旧检索路径不可原样沿用:/api/v2/filelib/retrieve 走的 knowledge_space_chat_service.py:812-872 只做知识库级一次校验、无文件级过滤;工作台文件夹对话路径(同文件:484-570)才有索引层预过滤+结果层兜底两层;且权限模型中不存在 view_file/view_space 关系、实际统一用 visible——PRD-1 v1.5 的「声明白名单∩用户文件级权限+fail-closed」检索语义必须基于两层过滤路径而非旧 retrieve 路径。附录 E 措辞红线:受限委托是「提权有上界」非「恒为降权」、模式 E 不等于不做权限过滤。 +【代码现状(均已在本 worktree 核实)】路由拓扑:bisheng/api/router.py:94 `router_rpc = APIRouter(prefix='/api/v2')` 聚合 8 个子路由(:95-102),bisheng/main.py:144 `app.include_router(router_rpc)` 挂载;子路由文件在 open_endpoints/api/endpoints/{workflow,assistant,chat,filelib,knowledge,flow,citation,llm}.py,经 open_endpoints/api/router.py 汇出。端点面:workflow(POST /invoke :31 无上线状态校验、POST /stop :144 无会话归属校验、WS /chat/{workflow_id} :157 中 jwt_required 被注释 :165-166 且 docstring 明写 Use Exempt Login Link);assistant(POST /chat/completions :33、GET /info/{id} :259 与 GET /list :274 有 enable_guest_access 配置门但身份仍是 default_operator、/list 收裸 user_id 参数但当前只写日志不参与身份、WS /chat/{assistant_id} :294 无鉴权且 get_default_operator() 在 :300 位于 try 块外配置缺失会裸崩);chat(6 端点,POST /sync/messages :80 直接把请求体裸 user_id 写入 ChatMessage、连用户存在都不查、缺省回落 default_operator 配置值);filelib(20 端点含匿名建/改/删知识库与上传,GET / :173、GET /file/list :378、POST /retrieve :637 三处经 resolve_operator(user_id) 代用户);knowledge(8 个 metadata 端点,走 Depends(get_default_operator_async));flows GET /{flow_id}、citation GET /{citation_id}、llm POST /workbench/asr、/tts。身份解析:open_endpoints/domain/utils.py——get_default_operator(:26)/get_default_operator_async(:53) 从 DB 配置 default_operator.user 取固定用户构造 UserPayload;resolve_operator(:77) 传了 user_id 就 UserDao.aget_user 查到即用(不查禁用、不查任何凭据),F030 上线的活越权通道。调用形态分两种:knowledge.py/citation.py/dependencies.py 走 FastAPI Depends(get_default_operator_async);其余大多数端点在函数体内联调用 get_default_operator*/resolve_operator(非 Depends)——鉴权改造不能只在 router 层加 dependencies,必须逐端点替换身份来源。 +【租户处理】HTTP 中间件(utils/http_middleware.py:105-118)只从 JWT cookie 设租户 ContextVar,v2 调用无 cookie 故 ContextVar 为空;三个操作员解析函数在末尾 set_current_tenant_id(解析出用户的活跃 UserTenant 租户)(domain/utils.py:49/:73/:103)补种上下文,此后 tenant_filter 的 do_orm_execute 监听自动注入 WHERE tenant_id(仅拦 SELECT,core/database/tenant_filter.py)。注意 resolve_operator 的租户来自目标用户=调用方可控。WS 中间件已读 scope['headers'](http_middleware.py:395-412),服务端到服务端可传自定义头;而 AuthJwt.get_subject(user/domain/services/auth.py:196-211)在 auth_from='websocket' 且 websocket 非空时无条件用 cookie 覆盖传入 token——get_login_user_from_ws(:651) 的 t 查询参数是死的,WS 鉴权 100% 依赖 cookie,PRD「不采用查询参数」结论与代码一致。 +【既有 api-key 类机制】grep 全仓证实无任何 API key 表/模型/bs- 前缀/require_api_key 配置。最接近的四件均不可复用(与 PRD §4.2.1 逐条一致):share_link token 明文存储且 expire_time 注释明写 intentionally not enforced(share_link_service.py:52-58);gpts_tools.api_key(tool/domain/models/gpts_tools.py:61)是用户手填的第三方密钥非平台签发;invite_code 非密码学随机;sso_sync 网关 HMAC 是共享密钥签名不签发凭据。User 模型(user/domain/models/user.py:71)无 user_type 字段,服务账号主体需 Alembic 变更。 + +## key_files +- docs/PRD/3.0-beta2/3.0 开放 API 鉴权与身份传递 PRD.md — 伴生 PRD v2.0 全文 1014 行已通读;R1-R9、六步校验流程、六道准入、附录 B 端点改造清单与旧检索路径事实澄清、附录 C 错误码 26001-26012 +- src/backend/bisheng/api/router.py — :94-102 router_rpc = APIRouter(prefix='/api/v2') 聚合 8 子路由——HTTP 层鉴权依赖的天然挂点(router 级 dependencies) +- src/backend/bisheng/main.py — :144 app.include_router(router_rpc) 挂载 v2 +- src/backend/bisheng/open_endpoints/domain/utils.py — get_default_operator(:26)/get_default_operator_async(:53)/resolve_operator(:77) 三个身份解析函数=改造的核心替换点;均在末尾 set_current_tenant_id 补种租户上下文 +- src/backend/bisheng/open_endpoints/api/endpoints/workflow.py — invoke :31 无上线校验、stop :144 无归属校验、WS :157 鉴权被注释(:165-166) +- src/backend/bisheng/open_endpoints/api/endpoints/assistant.py — WS :294 无鉴权且 get_default_operator 在 try 外(:300);/list :274 收裸 user_id 仅记日志;info/list 有 enable_guest_access 门 +- src/backend/bisheng/open_endpoints/api/endpoints/filelib.py — 20 端点;resolve_operator 代用户三处 :173/:378/:637;匿名建改删知识库与上传 +- src/backend/bisheng/open_endpoints/api/endpoints/chat.py — sync/messages :80 裸 user_id 直写 ChatMessage 不经任何解析校验 +- src/backend/bisheng/open_endpoints/api/dependencies.py — 既有 Depends 链先例:get_knowledge_space_chat_service_for_openapi 经 Depends(get_default_operator_async) 注入身份——新鉴权依赖的替换样板 +- src/backend/bisheng/core/context/tenant.py — 租户 ContextVar:set_current_tenant_id/bypass_tenant_filter/strict_tenant_filter;密钥校验第 2 步与第 4 步的直接依赖 +- src/backend/bisheng/core/database/tenant_filter.py — :39-102 _TENANT_AWARE_MODEL_MODULES 手工注册清单——新密钥表必须注册,:36-38 注释记录 v2.5 漏注册事故;自动过滤只拦 SELECT +- src/backend/bisheng/utils/http_middleware.py — :105-118 HTTP 中间件仅从 JWT cookie 设租户;:395-412 WS 中间件读 scope headers(WS 握手传 Authorization 头可行的代码依据) +- src/backend/bisheng/user/domain/services/auth.py — AuthJwt.get_subject :196-211——websocket 分支无条件用 cookie 覆盖传入 token,t 查询参数是死路径;UserPayload.get_login_user :625 为 v1 鉴权依赖形态参照 +- src/backend/bisheng/sso_sync/domain/services/hmac_auth.py — :44-109 verify_hmac——全仓唯一恒时比较+fail-closed 的 FastAPI 鉴权依赖实现,姿势应被新密钥校验抄用(含 body 重放技巧) +- src/backend/bisheng/common/utils/util.py — :28-55 generate_short_high_entropy_string——全仓唯一合格随机源,密钥生成复用 +- src/backend/bisheng/share_link/domain/services/share_link_service.py — :32-60 bypass_tenant_filter 下按 token 查行的同构先例(注释语义=token 本身就是授权);同时是明文存储/过期不校验的反面教材 +- src/backend/bisheng/user/domain/models/user.py — :71 class User——无 user_type 字段,服务账号主体需 Alembic 变更;_filter_users_statement 为 /user/list 分叉的下沉点(PRD 引用,函数存在性已核) +- docs/PRD/3.0-release/3.0 应用工场 PRD-1 专业开发者通道与应用运行时.md — :747-787 GOV-08 三类 key 总纲:应用 token=bs-sak- 服务账号密钥、存量迁移用 SAK 非个人 key、兼容窗口为部署配置项、掩码=前缀+末四位 + +## reuse +- 密钥生成随机源:common/utils/util.py:28-55 generate_short_high_entropy_string(os.urandom+HMAC-SHA256+urlsafe base64),PRD 点名全仓唯一合格随机源,直接复用 +- 校验姿势:sso_sync/domain/services/hmac_auth.py:44-109 verify_hmac——hmac.compare_digest 恒时比较、secret 未配置即拒绝的 fail-closed、作为 FastAPI 依赖(async def verify_x(request: Request))的完整形态,新密钥校验依赖照此抄写 +- 跨租户查凭据先例:share_link_service.py:32-60 在 bypass_tenant_filter() 下按 token 查行并附注释论证「token 本身就是授权」——密钥校验第 2 步同构复用该模式 +- 租户上下文机制:core/context/tenant.py 的 set_current_tenant_id / bypass_tenant_filter 全套现成;密钥校验通过后 set_current_tenant_id(密钥租户) 即接入既有自动过滤(PRD 六步流程第 4 步) +- 身份对象构造:UserPayload.init_login_user(get_default_operator_async :66-74 已示范 user_id+user_name+tenant_id → UserPayload),密钥解析出主体后复用同一构造,下游服务(KnowledgeSpaceChatService 等)零改动 +- Depends 注入样板:open_endpoints/api/dependencies.py:61-77 与 knowledge.py/citation.py 已用 Depends(get_default_operator_async)——新 auth 依赖可原位替换这些 Depends;router_rpc(api/router.py:94)可加 router 级 dependencies 兜底强制 +- 服务账号主体:D4 已定复用 User 表(新增 user_type 列),权限授予/OpenFGA 投影/资源归属链路全部沿用现有用户主体机制,不建平行主体体系 +- 委托 scoped 范围的部门子树判定:复用 database/models/department.py 的 UserDepartment+Department.path 物化路径单次索引查询(PRD 引用,本次未逐行核) +- WS 握手读头能力:utils/http_middleware.py:395-412 已在读 scope['headers'],服务端集成经 Authorization 头传密钥无需新中间件能力 +- 新密钥表建表走既有 SQLModel create_all(checkfirst) 机制(backend AGENTS.md 规约),仅 user_type 等存量表改列需 Alembic revision + +## gaps +- 凭据层纯新建(grep 全仓证实零基础):密钥表(tenant_id/主体类型+ID/名称/前缀+末四位/SHA-256 哈希唯一索引/权限位/资源白名单/委托模式+范围/过期/最后使用节流/软撤销时间)+ 生成/签发/校验/撤销服务 + Redis 5s 缓存与撤销主动失效 +- 开放 API 鉴权依赖:新 FastAPI Depends(HTTP)+ WS 握手校验(Authorization 头),替换 8 个路由文件里全部 get_default_operator*/resolve_operator 调用点——多数为函数体内联调用而非 Depends,需逐端点改造(附录 B 清单约 50+ 端点) +- 三种身份模式解析器(S/D/E 互斥判定、双头冲突 400)+ 受限委托六道准入检查(含特权主体禁令 26007、类型化委托范围匹配) +- 服务账号主体全链路:User.user_type 列(Alembic)+ source=service_account 枚举 + 密码哨兵 + 登录守卫(公共守卫 1 处+单独 3 处)+ 租户关联声明式直写与对账豁免(worker/tenant_reconcile/tasks.py 加 human 过滤)+ /user/list DAO 层分叉 + 配额统计排除 +- 管理面:密钥管理页(创建一次性展示/列表掩码/编辑不轮换/撤销与批量撤销)+ 服务账号管理新端点 + PRD-1 GOV-08 的三 tab 结构(个人 key/应用 token/服务账号)+ 兼容窗口状态展示 +- 存量迁移件:open_api.require_api_key 配置开关 + 无密钥回落时的 WARN/迁移告警 + default_operator 转型脚本(scripts/,非 DB 迁移)+ 文档修订(删除「建议配超管」表述) +- 错误码模块 260 新增(26001-26012)注册进 common/errcode/ +- 开放 API 审计(R7 双归属 actor+subject)——现有 audit_log 无此语义,需新记录面 +- 补漏的业务校验:workflow invoke 上线状态校验、stop 会话归属校验、assistant WS 身份解析移入 try 块 +- WS 鉴权若要支持浏览器直连需子协议或票据换取机制(服务端集成走头可先行);t 查询参数路径已证实是死代码,不修不用 +- P2 项全部无基建:HTTP 限流(无 slowapi 类依赖)、幂等键、IP 白名单 + +## risks +- C3 租户隔离:新密钥表若漏注册 _TENANT_AWARE_MODEL_MODULES(tenant_filter.py:39-102)会静默把子租户密钥写进根租户——:36-38 注释记录的 v2.5 事故同型;且自动过滤只拦 SELECT,密钥表批量 update/delete 必须手写租户条件(PRD 已明令禁止批量写) +- 现行 resolve_operator 的租户上下文由调用方指定的目标用户派生(domain/utils.py:96-103)=攻击者可控租户种子;改造期兼容窗口内该路径仍活着,窗口策略(R9)必须显式覆盖此点 +- 逐端点改造的遗漏面大:身份解析是内联调用非统一 Depends,漏改一个端点即残留匿名通道;建议配 router 级强制依赖+全端点测试矩阵兜底(PRD AC-1/AC-14 的测试矩阵思路) +- 检索路径事实(C4 相关):/api/v2/filelib/retrieve 现走的 knowledge_space_chat_service.py:812-872 只有知识库级校验、无文件级过滤;PRD-1 v1.5 已定「声明白名单∩用户文件级权限+fail-closed」——若沿用旧路径将直接违约,需切到两层过滤路径(同文件:484-570)并专项验证无「过滤器异常被吞→返回全量」路径(铁律 F3) +- 不得回退到「权限取交集」:ListObjects 被 DenyListObjectsPolicy 禁用(sql_runtime.py 装配)、主链路无缓存——D2 已改判受限委托,设计评审时勿再提回(memory 明确标注) +- 委托模式的提权语义:受限委托=「提权有上界」非「恒为降权」,服务账号窄权限+delegate 可获得目标用户的更宽权限;特权主体禁令(检查 4)是唯一防无界提权的闸,实现与测试(AC-7)必须优先 +- chat.py sync_message 裸 user_id 直写消息(连 resolve_operator 都不走、不查用户存在)——比 PRD 引述的 F4 更松,收口清单须包含它 +- 兼容窗口默认 require_api_key=false 期间 AC-1 不成立,安全敞口持续存在;窗口长度(D1)未决且 PRD-1 说是部署配置项,需在发布说明显式披露 +- 错误码 260 段占用未确认(D5),与 constitution C5 的 MMMEE 分配需先对账 +- Fernet 不可用于密钥存储(其 key 硬编码 settings.py:20 随开源分发)——任何「加密存储便于找回」的提议都应按 PRD §4.2.5 驳回,存哈希是唯一口径 + +## open_questions +- D1 兼容窗口长度:PRD 建议一个大版本,PRD-1 GOV-08 定为部署配置项——默认值取多少、窗口结束是否需要显式运维动作确认 +- D5 错误码模块号 260 是否与既有规划冲突(需产品/架构确认分配表) +- D6 已部署商业版网关的客户是否由网关充当令牌签发方(一期不做,但请求头抽象要不要预留网关透传约定) +- D7 服务账号是否允许更换租户(建议禁止、禁用后新建),涉及名下资源归属与密钥缓存失效 +- PRD-1 §6:个人 key(DEV-01)与伴生 PRD 的个人访问令牌(P1)是否合并为同一凭据实例(一把 key 同时通行 MCP/模型/CLI 与开放 API)——随 P1 落地定夺 +- assistant info/list 现有 enable_guest_access 配置门在新体系下的去留(保留为额外开关还是随 R9 移除) +- WS 浏览器直连场景本期是否交付(子协议 vs 票据换取),或一期仅支持服务端集成走 Authorization 头 +- 委托范围(scoped)管理界面的粒度与交互:users/departments/user_groups/roles/tenant_wide 五型全部一期做,还是先做 users+departments diff --git a/features/v3.0.0/000-prd1-discovery/ui-demo/README.md b/features/v3.0.0/000-prd1-discovery/ui-demo/README.md new file mode 100644 index 0000000000..6b91711584 --- /dev/null +++ b/features/v3.0.0/000-prd1-discovery/ui-demo/README.md @@ -0,0 +1,208 @@ +# 应用工场 PRD-1 交互 Demo(Claude Design)—— 结构化交互规格摘要 + +| 项 | 值 | +|---|---| +| 来源 | Claude Design 项目「API 鉴权交互 Demo」:`https://claude.ai/design/p/035e4d4a-f55d-4266-8933-7ab140d00e86?file=应用工场 v2.dc.html` | +| 拉取日期 | 2026-08-17 | +| 原文件(本目录,逐字拉取) | `应用工场 v2.dc.html`(187,150 B,sha256 `39f50256…553c`)· `服务账号管理.dc.html`(101,697 B,sha256 `cfcbc5c0…ef39`)· `support.js`(69,150 B,sha256 `8fe7df74…8cbe`)。远端另有 `应用工场.dc.html`(旧版,192,641 B)与 `服务账号管理 copy.dc.html`、`assets/bisheng-logo.png`:旧版只做差异对比(§5)未入库,后两者未拉取 | +| 拉取方式 | 会话内没有 DesignSync 工具,改经用户已登录的 Chrome 会话调用 Claude Design 的只读接口 `OmeletteService/GetFile` 读取(未调用任何写方法、未运行 `/design-sync`);本地文件已与远端 base64 解码字节做 SHA-256 逐一比对,完全一致 | +| **定位** | **demo 是参考稿,不是需求真相。** 需求以《docs/PRD/3.0-release/3.0 应用工场 PRD-1 专业开发者通道与应用运行时.md》**v2.0** 与《3.0 开放 API 鉴权与身份传递 PRD.md》**v2.1** 为准;demo 自称「按 PRD v1.9 重做」,与 v2.0/v2.1 的出入见 §3 | + +**一句话结论**:v2 demo 已把承载面搬到 platform「构建 → 应用」、类型定名「托管应用」、卡片 ⚙️ 只留「管理权限 / 删除」、删除加「先下线」状态闸、版本 tab 只读无回滚、审批预置或签流程、`delegate` 与本地开发三位硬阻断——**这些与 PRD v2.0 一致,可直接作交互参考**。需要过滤掉的是:demo 自带的演示脚手架(视角切换、Tweaks、部署形态面板、CLI 模拟面板、运行时形态标签)、把「应用广场 / 运行入口 / 消息铃铛」都塞进 platform 壳的简化、以及若干与 PRD 相左的细节(下线无二次确认、资源档位无行内编辑、审计列缺「结果」、服务账号 demo 里删除仍是阻断且缺 delegate 硬阻断等,见 §3)。 + +--- + +## §1 画面清单 + +`.dc.html` 是 Claude Design 单文件交互稿:`` 内为模板(`sc-if` / `sc-for` 条件与循环),` + + + + + + + + + + + + +
+ +
+
+ BISHENG + BISHENG + 应用工场 PRD-1 · v1.9 交互 demo +
+ +
+ +
{{ b.label }}
+
+
+
+
+
+ + +
+
视角 + + {{ p.label }} + +
+
+
{{ meInitial }}
+ {{ meName }} + {{ meRole }} +
+
+
+ +
+ + +
+ +
+
+ +
{{ t.label }}
+
+
+
+
+ +
+ + + +
+
+
+ + +
+ +
+ {{ sel.value }} + +
+
+
+ +
+ + {{ sc.label }} + +
+
+ {{ createPermText }} +
+
+ +
+ {{ listHint }} +
+ +
+
+ 本地 bisheng CLI + deploy 管线:托管预检 → 密钥扫描 → 审批 → 上线托管。运行时形态:{{ formLabel }} +
+
+ + {{ d.label }} + +
+ +
{{ deployLog }}
+
+
+ +
+ +
+
+
+ {{ a.logoText }} +
+ {{ a.versionText }} + + + + + + +
+
+
{{ a.name }}
+
{{ a.desc }}
+
+ {{ a.ownerInitial }} + {{ a.owner }} + {{ a.statusText }} +
+ +
{{ a.badge }}
+
+ 托管应用 + +
+
管理权限
+
删除{{ a.delSuffix }}
+
托管应用只保留这两项——「创建模板 / 复制」涉及代码与数据拷贝,本版不提供(GOV-01)
+
+
+
+
+
+ +
+
没有符合条件的托管应用
+
首发 bisheng deploy 后应用出现在这里,owner = 该 key 所属服务账号的资源归属人。
+
+
+
+
+ + + +
+
+ 返回构建 · 应用 +
+
+ {{ app.logoText }} + {{ app.name }} + {{ app.statusText }} + owner {{ app.owner }} · {{ app.source }} · {{ app.tierName }}档 · {{ formLabel }} +
+
{{ app.desc }}
+ +
+ +
{{ d.label }}
+
+ 本册形态:无左侧对话区、无管家 +
+ + + +
+ +
+ + + +
+
已上线,但目前仅 owner 可见
+
上线 ≠ 可用。设置可见范围是首发后的必经一步——不设,同事在广场里看不到它。
+
+ +
+
+ +
+
+ +
+
+ 发布管线 +
+ + + + + + + + + +
+
+
+ +
+
+ {{ st.mark }} + +
+
{{ st.label }}
+
+
+
+

{{ app.approvalText }}

+ +
驳回理由全文:{{ app.reject }}
+
+
+ +
+
+ 能力声明 + 完整白话清单 · 取自 bisheng-app.yaml,审批通过 = 应用侧授权 +
+
+ +
+ {{ c.iconText }} +
+
{{ c.name }}
+
{{ c.note }}
+
+ + 能力已被收回 · 调用返回明确错误态 + +
+
+
+
运行期检索可及范围 = 声明的知识库 ∩ 当前访问用户可见范围(文件级、fail-closed),两层缺一不可。
+
+ +
+
+ 资源档位 + 只能从平台预置档位选;CLI 路径在应用包中声明,未声明取轻量档 +
+
+ +
+
+ + {{ t.name }} +
+
{{ t.spec }}
+
{{ t.desc }}
+
+
+
+
+ +
+
危险操作
+

删除会一并带走代码、对话历史与应用生产数据,不可恢复;在途审批单自动取消并通知审批人。仅 owner 可删除(租户管理员的兜底手段是下线)。已上线态入口置灰——须先下线。

+ +
+
+ +
+
+
+ 可见范围 + 设置 +
+
+ + {{ c.name }} + +
+
与卡片 ⚙️「管理权限」是同一个弹窗。变更即时生效、不经审批、计审计,不属版本快照。
+
+ +
+
入口链接与二维码
+
+
{{ app.url }}
+ 复制 +
+
+
+ + + +
+
扫码访问内网入口;无登录态先跳平台登录页(含已配置 SSO),登录后回跳原地址,应用侧零登录代码。
+
+
+ +
+
运营动作
+
+ + +
+
下线即时止血、无需审批,资产不受影响;重新上线叠加运行环境容量校验。owner 与租户管理员均可操作、均计审计。
+
+ +
+
元信息
+
+ 名称{{ app.name }} + 描述{{ app.desc }} + 标签未设标签(本版不做标签设置) +
+
取自 bisheng-app.yaml、随 deploy 更新。元信息不属能力声明,修改不触发审批、计审计。
+
+
+
+
+
+ + + +
+
+ 应用数据 + 应用自有业务表 · 查改导出(WB-06);行编辑计审计 +
+
+
+ 表名列数行数最近写入操作 +
+ +
+ {{ t.name }} + {{ t.cols }} + {{ t.rows }} + {{ t.last }} + 导出 +
+
+
+
结构演进:加列无需确认、自动迁移;改 / 删列在 CLI deploy 时显式确认,迁移前自动留生产数据快照。删除应用是唯一会删除这些数据的路径。
+
+
+ + + +
+
+ 运行日志 + 只读 · 与 bisheng logs、MCP 日志工具同权限口径与内容范围 +
+
+ +
{{ l }}
+
+
+
三个入口按操作者选:MCP 工具(本地 agent)· CLI logs(终端)· 本页(图形界面)。查他人应用日志只有本页这条路,且仅租户管理员——经 MCP 查他人应用一律被拒。
+
+
+ + + +
+
+ 版本 + 只增不改 · 只读,本版无回滚入口 +
+
+ +
+
+ {{ v.no }} +
+
{{ v.type }}
+
{{ v.time }} · {{ v.caps }}
+
+
+ {{ v.stateText }} +
+
+
+
本版不做平台侧回滚:本地有完整 git 历史,等价手段是 git checkout 旧版本后重新 bisheng deploy,走同一条审批管线。应急止血始终是下线。版本快照的四要素(代码 / 能力声明 / 注入配置 / 资源档位)已按同一快照存储,将来补回滚可统一回退。
+
+
+
+
+ + + +
+
应用发布审批
+

首发与迭代每次发布均需审批,无免审路径。平台预置流程:单节点或签,审批人 = 应用 owner 所属部门的部门管理员 ∪ 租户管理员,任一人处理即出终态。

+
+ +
+
+
+ {{ t.appName }} + {{ t.kind }} + {{ t.stateText }} +
+
{{ t.meta }}
+
+ +
+
+ +
暂无待处理的发布审批单
+
+
+
+
+ + + +
+
+ 返回审批中心 +
+
+
+
+
+ {{ ticket.appName }} + {{ ticket.kind }} +
+
+ owner{{ ticket.owner }} · {{ ticket.dept }}(部门管理员由此解析) + 来源{{ ticket.source }} + 提交时间{{ ticket.submitted }} + 资源档位{{ ticket.tier }} + 审批人{{ approverText }} +
+
+ +
+
+ 能力声明白话摘要 + 待上线版本快照 +
+
+ +
+ {{ c.iconText }} +
{{ c.name }}
{{ c.note }}
+
+
+
+
本版不做迭代变更摘要(顺延)——变更请经审读视图的文件差异自行比对。
+
+ +
+
可见范围(提交时快照)
+
+ + {{ c }} + +
+
仅供参考——可见范围变更即时生效、不经审批,审批期间可能已被改。
+
+
+ +
+
+
审读与试用
+ +
审读的权威对象 = 提交发布时冻结的待上线版本快照。owner 的本地工程与草稿不对审批人开放。
+
+
+
处理
+ +
+ + +
+
或签:任一审批人处理即出终态,另一方待办同步关闭。通过后走上线终检(运行环境容量)。
+
+
+
+
+
+ + + +
+
+
+ 返回审批单 +
+
+ {{ previewNote }} + + 手动回收 + + +
+
+
+ +
+
审批期临时预览实例:{{ previewStateText }}
+
{{ previewDesc }}
+ +
失败原因:依赖 psycopg2-binary==2.9.9 在平台内网镜像中不存在,启动探活未通过。可重新拉起,或驳回要求 owner 修复后重新 deploy。
+
+
+ + 模拟拉起失败 + +
+
+ +
{{ t.label }}
+
+
+
+
+ +
{{ f.label }}
+
+
+
+ +
+
{{ activeFile }} · 只读
+
{{ fileBody }}
+
+
+ +
+
相对上一已发布版本的文件差异(迭代时提供)
+ +
{{ d.text }}
+
+
+
+ +
+
能力声明完整清单 · 待上线版本
+ +
+ {{ c.iconText }} +
{{ c.name }}
{{ c.note }}
+
+
+
+
+ +
+ +
+ {{ v.no }} + {{ v.type }} · {{ v.time }} + {{ v.stateText }} +
+
+
+
+
+
+
预览期注入身份 = 审批人本人,平台能力按 owner 权限放行(审批例外条款、计审计);复用 deploy 预检的启动探活能力,不额外占用运行实例名额;审批出终态或超时(默认 7 天)即回收,回收后可再次拉起。
+
+
+ + + +
+
+ 场景配置 · 应用发布 + 平台预置 · 部署即生效 +
+

管理员不需要先做任何配置,第一次发布就能跑通;不提供免审配置项。此页可在预置基础上改配,改配后平台升级不会重置。

+
+ +
+ {{ r.k }} +
+
{{ r.v }}
+
{{ r.note }}
+
+
+
+
+
+
+
+
改配审批人(可选)
+
不选 = 沿用预置来源;提交人若在候选中则该节点自动跳过
+
+ {{ sceneStateText }} +
+
+ +
{{ a.name }}
+
+
+
+
两个来源同时解析为空时进审批中心异常态并通知管理员——不放行、也不静默卡死。预置须覆盖新建租户(租户创建时同步落库),否则新租户的第一次发布会因场景缺失失败。
+
+
+ + + +
+
+
+
审计
+

造 / 接 / 发布 / 删除 / 数据编辑 / 密钥事件全链路留痕。租户管理员限本租户,跨租户仅平台超管。记录中不出现密钥明文。

+
+ +
+
+
+ 2026-08-01 ~ 2026-08-17 +
+ + + + {{ f.label }} + +
+
+
+ 时间操作人事件类型对象详情 +
+ +
+ {{ r.time }} + {{ r.actor }} + {{ r.type }} + {{ r.object }} + {{ r.detail }} +
+
+
+
模型调用逐条留痕(含模型与 token 数),本版不做聚合账单;运行期能力调用为双归属:actor = 应用,subject = 当前访问用户。
+
+
+ + + +
+
+ 角色与权限 + 本册零新增菜单、零新增权限点 +
+

托管应用是构建页第三种类型,天然被「构建」→「创建应用」这对既有父子权限点管住。升级前后角色配置面的项数完全一致。

+
+
+ 角色「构建」菜单「创建应用」子项 +
+ +
+
+
{{ r.name }}
+
{{ r.note }}
+
+
{{ r.buildText }}
+
{{ r.createText }}
+
+
+
+
+
两条通道,各有各的闸,互不校验
+
+
界面通道(构建 → 应用 → 新建入口):既有 create_app 权限点。关闭父项「构建」时级联失效。
+
CLI 通道(bisheng deploy 首发):闸在 key 的 app:manage 位——不查资源归属人的角色权限,否则「归属人一停用其名下密钥即失效」。
+
+
代价:复用既有权限点意味着无法单独灰度托管应用(勾了就三种类型都能建)。真正的灰度闸是部署开关(环境级)与按人签发密钥(人级)。
+
+
+
+ + + +
+
能力总线
+

配置一次、声明使用、零新建配置面。本版总线范围 = 模型 · 知识库 · 密钥引用,配置动作全部回既有界面完成。

+
+
+ 模型 + 既有「模型管理」页配置一次 · 助手 / 工作流与托管应用共享;协议面本版仅 OpenAI 兼容 +
+ +
+
+
{{ m.name }}
+
{{ m.meta }}
+
+
+ {{ m.stateText }} + {{ m.toggleText }} +
+
+
+
下线已被声明的模型:该能力调用返回明确错误态、不回退旧值,应用整体保持可用;owner 在发布面可见失效提示。
+
+
+
+
知识库
+

无新增配置动作。运行期可及范围 = 声明白名单 ∩ 当前访问用户可见范围(逐资源鉴权、文件级、fail-closed),两层缺一不可。声明层是白名单收窄,不是两个主体求交集。

+
+
+
密钥引用
+

owner 录入应用自有第三方凭证(随 PRD-2),引用名进入能力声明,运行时按引用注入值——一切界面只见引用名,审计不记录密钥值。

+
+
+
+
+ + + +
+
部署形态
+

三层可选部署。本面板用于在 demo 中验证「不装某层 / 换运行时形态 / 资源不足」时界面如何变化。

+
+ +
+
+
{{ l.name }}
+
{{ l.note }}
+
+ {{ l.btnText }} +
+
+
+
+
+
运行时形态:{{ formLabel }}
+

compose 单机与 k8s 集群部署期二选一,产品面完全一致:发布管线、资源档位、审批、权限、日志、崩溃自愈的行为与验收在两形态下不得有任何差异,差异一律压在编排器内部。档位在 compose 下是容器资源限额,在 k8s 下映射为 Pod requests / limits。

+
形态在右侧 Tweaks 面板切换(部署期设定,运行期不可迁移)。
+
+
+
+ 模拟运行环境容量不足 + {{ scarceText }} +
+

开启后,审批通过的上线终检会失败:审批单保持通过,应用进入「待上线(资源不足)」态({{ formLabel }}下的成因 = {{ scarceCause }}),资源释放后 owner 手动上线、无需重新审批。本版不做租户级实例数配额。

+
+
+
+
+ + + +
+
资源档位
+

平台预置档位模板,规格由平台超管维护;业务侧发布时只能选档,无自定义规格、无实例数 / 并发调节。停用只拦新选择,存量应用沿用原档位继续运行与迭代。

+
+
+ 档位规格适用说明状态使用中操作 +
+ +
+ {{ t.name }} + {{ t.spec }} + {{ t.desc }} + {{ t.stateText }} + {{ t.usedText }} + {{ t.toggleText }} +
+
+
+
本版不做租户级实例数配额(顺延,将来须以 CPU / 内存为单位而非实例数)。应用代码存档与附件均不计入平台存储配额;token 用量只计量、不设上限。
+
+
+ + + +
+
服务账号与密钥
+

全平台只有服务账号一类密钥主体(bs-sak-),无自然人个人凭据、无独立「开发者 key」界面。应用工场在鉴权 PRD 的服务账号模块上只有两处增量——权限位新增「本地开发工具包」三位与接入信息区。

+
+
+
+ 密钥签发 + 服务账号「中粮 · 李工开发者」· 资源归属人 = 李工 +
+ + +
+
+ {{ g.title }} + {{ g.note }} +
+
+ +
+ {{ b.check }} +
+
{{ b.id }} · {{ b.label }}
+
{{ b.note }}
+
+
+
+
+
+
+ +
+ + 硬阻断,无法保存:配了委托(delegate)的 key 不能用于本地开发,请另发一把。这样的 key 在 MCP / 模型协议 / CLI 三面必然全废,签出来即废件。 +
+
+ +
默认全不勾。未勾任何位的 key 仅可 login / skills sync。权限位签发后可编辑、即时生效、无需重签,变更计审计。
+
+ +
+
+
+ 接入信息 + 应用工场增量 +
+
+
MCP Server
https://bisheng.cofco.com/mcp
+
OpenAI 兼容 base URL
https://bisheng.cofco.com/v1
+
CLI 安装包(链接,非文件)
https://bisheng.cofco.com/dl/bisheng-cli
+
登录指引
bisheng login --key <你的 key>
+
+ +
文本不含 key 值,也不含任何可换取 key 值的凭据——接入信息不机密、可随时重发;key 值机密、只有一次,由管理员单独转交。未部署开放能力层时本区不出现。
+
+
+
已签发密钥
+
+ +
+
+
{{ k.masked }}
+
{{ k.bits }}
+
+ {{ k.actText }} +
+
+
+
吊销生效上界 5 秒,MCP / 模型协议 / CLI 三面同时被拒绝;支持按服务账号一键吊销名下全部 key。无开发者自助吊销入口。
+
+
+
+
+
+ + + +
+
+
+
应用广场
+

托管应用与工作流 / 助手并列,共用同一套标签体系;只显示你可见范围内的应用,点卡片即用、免二次登录。

+
+
+
模拟访问分支(RT-01 / RT-08)
+
+ + {{ b.label }} + +
+
+
+
+ +
+
+ {{ c.iconText }} + {{ c.name }} +
+
{{ c.desc }}
+
+ 托管应用 + owner {{ c.owner }} +
+
+
+
+ +
你的可见范围内暂无托管应用 —— 上线 ≠ 可用,owner 还需设置可见范围
+
+
+
+ +
+
+
+ + + +
+
+
+ +
{{ runtimeUrl }}
+ 关闭 +
+
+ +
+
+
+
{{ runtimeApp }}
+
当前访问者:{{ runtimeWho }}(平台注入身份 · 应用侧零登录代码)
+
+ 运行中 +
+
+
检索问答 —— 结果 = 应用声明的知识库 ∩ 你本人可见范围(含文件级)
+
「差旅住宿标准是多少?」
+
+
参考来源 · 集团差旅管理办法 v6(你有权限)
+
高管差旅补充规定(你无权限 → 不出现在结果里,且不报错)
+
+
+
+
+ +
+
+ +
+
{{ fallbackTitle }}
+

{{ fallbackBody }}

+ +
+
+
+
+
+
+ + + +
+
+
+
+
管理权限 · {{ authAppName }}
+

平台通用授权弹窗(与工作流 / 助手同一个)。变更即时生效、不经审批、计审计。{{ authNote }}

+
+ ✕ +
+
+ +
{{ t.label }}
+
+
+
+ + +
+
+ +
+ {{ o.check }} + +
+
{{ o.name }}
+
{{ o.sub }}
+
+
+
+ +
未找到匹配项
+
+
+
+ 已选: +
+ + {{ c.name }}✕ + + + 未选择 —— 提交后仍为「仅 owner 可见」 + +
+
+ 授权 +
可使用 + +
+
+
+
「可使用」= 入口可访问 + 广场可见。托管应用不开放「可编辑」——改码权恒在本地,平台侧对导入应用的代码只读。
+
+ + +
+
+
+
+ + + +
+
+
+ +
删除「{{ authAppName }}」?
+
+

这不只是删一条编排——删除会一并带走:

+
+ +
+ •{{ d }} +
+
+
+

在途审批单自动取消并站内消息通知审批人;存量入口链接与二维码此后呈「应用不存在或未上线」。删除计审计。

+
+ + +
+
+
+
+ + + +
+
+
密钥已签发 —— 明文只展示这一次
+

服务端仅存单向哈希,明文不可还原;关闭后任何界面只见掩码。请由你单独转交开发者,不要和接入信息放在同一条消息里。

+
{{ newKeyValue }}
+
+ {{ keySavedCheck }} + 我已保存该密钥 +
+
+ +
+
+
+
+ + + +
+
+
消息提醒
+
+ +
+
+ + {{ m.title }} + {{ m.time }} +
+
{{ m.body }}
+
+
+
+
站内消息为通知性质、不承载操作,处理动作回各自界面完成。催办与超时提醒本版不做。
+
+
+
+ + +
{{ toast }}
+
+
+
+ + + diff --git "a/features/v3.0.0/000-prd1-discovery/ui-demo/\346\234\215\345\212\241\350\264\246\345\217\267\347\256\241\347\220\206.dc.html" "b/features/v3.0.0/000-prd1-discovery/ui-demo/\346\234\215\345\212\241\350\264\246\345\217\267\347\256\241\347\220\206.dc.html" new file mode 100644 index 0000000000..5e96cbd6a5 --- /dev/null +++ "b/features/v3.0.0/000-prd1-discovery/ui-demo/\346\234\215\345\212\241\350\264\246\345\217\267\347\256\241\347\220\206.dc.html" @@ -0,0 +1,900 @@ + + + + + + + + + + + + + + + + + +
+ +
+
+ BISHENG + BISHENG +
+
+
+ +
+
中文 + +
+
+
+
王
+ 王敏 +
+
+
+ +
+ + +
+
+
+ +
{{ tab.label }}
+
+
+
+ +
+ + +
+
+
+
服务账号
+

服务账号是开放 API 密钥的唯一主体:不可登录平台、权限单独授予、不占租户席位。密钥与资源授权都在服务账号详情内管理。

+
+
+
+ + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
名称状态密钥数委托最后调用创建人操作
+ {{ row.name }} +
{{ row.desc }}
+
{{ row.statusText }}{{ row.keyCountText }}{{ row.delegateText }}{{ row.lastCall }}{{ row.creator }} + 管理 + {{ row.toggleText }} +
+
+ 共 {{ rowCount }} 条 + 1 +
+
+
+ + +
+
+ 返回服务账号列表 +
+
+ {{ acct.name }} + {{ acct.statusText }} +
+
{{ acct.desc }}
+ +
+ +
{{ tab.label }}
+
+
+ + +
+
+
基本信息
+
+ 名称{{ acct.name }} + 描述{{ acct.desc }} + 租户{{ acct.tenant }}创建后不可更换 + 主体类型服务账号 · 不可登录平台 · 不占用租户席位 + 资源归属人{{ acct.ownerPerson }}该账号经 API / CLI 创建的资源统一归属此人;变更只对此后创建的资源生效 + 创建信息{{ acct.creator }} · {{ acct.created }} + 最后调用{{ acct.lastCallFull }} +
+
+
+
停用 / 启用
+

停用后名下全部密钥立即拒绝调用,授权与配置全部保留,启用后原样恢复。这是推荐的常规下线方式。

+ +
+
删除
+

名下仍有未撤销的资源授权时不可删除,需先在资源授权页撤销。

+ +
+
+
+ + +
+
+
+
+ 接入信息 + 随密钥一并转交开发者 · 平台自身分发,无公网依赖 +
+ +
+
+ MCP Serverhttps://bisheng.cofco.com/mcp + 模型 base URLhttps://bisheng.cofco.com/v1OpenAI 兼容 · 本版无 Anthropic 协议面 + CLI下载 bisheng CLI 安装包安装后执行 bisheng login 粘贴本页签发的密钥 +
+
一键复制生成一段可直接粘贴的接入说明(企微 / 邮件转交开发者),不含密钥明文——明文只在签发弹窗出现一次,需管理员另行安全转交。
+
+
+
密钥明文仅在签发时展示一次,列表只显示前缀与末四位。
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + +
名称密钥权限位 / 委托最后使用过期时间操作
+
{{ k.name }}
+ {{ k.stateText }} +
{{ k.masked }} +
+ + {{ b.label }} + +
+
{{ k.delegateSummary }}
+
{{ k.lastUsed }}{{ k.expire }} + 编辑 + 撤销 +
+
编辑可改名称、权限位、委托配置与过期时间,无需轮换密钥,改完即时生效。
+
+
+ + +
+
+
一次调用能不能通过,由两层「与」关系决定
+

两层分别由不同的界面配置,缺任一层都会被拒绝,且错误码不同。本页只配置第二层。

+
+
+
+ 1 + 密钥权限位 · 能做哪一类动作 +
+
在「API 密钥」页配置 · 不足返回 26003
+
+ + {{ b.label }} + +
+
名下 {{ activeKeyCount }} 把有效密钥合计持有以上权限位
+
+
+
+ 2 + 主体资源授权 · 对这一个资源有什么权限 +
+
在本页配置 · 不足返回各业务模块的资源权限错误
+
已授予 {{ grantedCount }} 个资源
+
+
+
+ + +
+ +
+
该账号名下存在持有 delegate 的密钥,本页授权不是全局上限
+

委托生效时权限基准整个换成被代表用户,服务账号自身的资源授权此刻完全不参与判定。即使本页零授权,这把密钥仍可读到被代表用户能读的全部内容。

+
+
+
+ +
+
被授予资源管理员在本页授予,可在此撤销
+
+ + +
+
+
+ +
+
+ {{ g.iconText }} +
+
{{ g.name }}
+
{{ g.meta }}
+
+ + {{ g.warnText }} + +
+
+ {{ g.model }} + 撤销 +
+
+
+ +
尚未授予任何资源 — 该服务账号的密钥调用任何资源都会失败
+
+
+ +
服务账号经 API 创建的资源统一归属「资源归属人」(见概览),不在本页汇总,管理与转移到资源自身界面处理。
+
+
+
+
+
+
+
+ + +
+
+
创建服务账号
+

租户取当前管理员所在租户(中粮集团),创建后不可更换。

+ + + + + +
+ +
{{ o.name }}
+
+
+
+ +
选错难纠正:该账号首发 bisheng deploy 的应用以此人为 owner,而删除 / 回滚 / 撤回在途审批单与交接改码权仅 owner 可为,平台超管与租户管理员均不可代行。归属在创建时固化、改字段不追溯已有应用,选错只能等归属转移,管理侧兜底手段仅下线。知识库等其它资源无此约束——管理员随时可另授「可管理」给他人。
+
+
+ + +
+
+
+
+ + +
+
+
+
{{ issueTitle }}
+
主体:{{ acct.name }} · 权限位默认全不勾,按最小必要授予
+
+
+
基本
+
+
+ + +
+
+ + +
+
+ +
+ 权限位 + 第一层判定 · 决定这把密钥能做哪一类动作,与资源授权是「与」关系 +
+
开放 API 侧共 8 位(含 delegate):会话的历史 / 列表查询、反馈类与消息回填不对外暴露——调用方自建系统有自己的消息存储,反馈类无场景,/chat/sync/messages 可向任意 chat_id 注入消息、风险最高,故原 session:read / session:write / session:sync 三位一并取消。
+
+ +
+
{{ grp.label }}
+ +
+ {{ b.check }} +
+
{{ b.key }}
+
{{ b.desc }}
+
+
+
+
+
+
+ + +
+ +
+
已勾选 identity:read —— 高危位
+

勾选即本租户组织架构全量可读(姓名、部门、层级),平台不提供「只授予某些部门」这类收窄——服务账号刻意不属于任何部门,没有可推导可见范围的立足点。边界由「给不给这个位」承担。租户隔离仍然生效:跨租户的部门与用户一律读不到。

+
+
+
+ +
+ 转交个人使用的 key 建议仅配「本地开发工具包」权限位——已勾选开放 API 端点权限位或 delegate(提示不阻断)。 +
+
+
+
+ {{ delegateCheck }} +
+
delegate
+
代表平台用户执行 · 独立权限位,不被任何其他权限蕴含;勾选即强制委托,每次调用都必须传 X-Bisheng-On-Behalf-Of,漏传返回 400(26016),不降级执行
+
+
+ + +
+
+ +
开启委托意味着这把密钥能以他人身份读取数据。委托目标必须是自然人,且不能是超级管理员或租户管理员。
+
+
可代表范围 *范围为空即谁都代表不了,没有「允许代表任意人」这一档
+
+
+
按部门(含子树)
+ +
+ {{ d.check }} + {{ d.name }} + {{ d.count }} +
+
+
+
+
按用户
+
+ +
+ +
+ {{ u.check }} + {{ u.name }} + {{ u.meta }} +
+
+
选人框中不会出现服务账号
+
+
+
+
+
+ +
+
网络 · IP 白名单(P2,暂未开放)
+
+
+
+ {{ saveHint }} +
+ + +
+
+
+
+
+ + +
+
+
密钥已签发
+

明文仅此一次展示,关闭后将无法再次查看;列表页只显示前缀与末四位。

+
+ {{ secretPlain }} + +
+
+ {{ savedCheck }} + 我已保存该密钥 +
+
+ +
+
+
+
+ + +
+
+
+ +
无法删除该服务账号26013
+
+

名下仍有未撤销的资源授权。请先处理以下清单,或改用「停用」——停用保留全部授权与配置,是推荐的常规下线方式。

+
+ +
+
+ {{ g.iconText }} +
+
{{ g.name }}
+
{{ g.meta }}
+
+
+ {{ g.action }} +
+
+
+
+ + +
+
+
+
+ + +
+
+
撤销密钥「{{ revokeName }}」?
+

撤销后该密钥自下一次调用起被拒绝,MCP / 模型协议 / CLI 三面同时被拒绝,开放 API 亦一并拒绝;生效上界 5 秒(撤销主动清缓存,通常即时)。

+

撤销为软撤销,记录保留供审计追溯;本账号的资源授权与委托配置不受影响。无开发者自助吊销入口,恢复须由租户管理员重新签发。

+
+ + +
+
+
+
+ + +
+
+
撤销该账号全部密钥?
+

用于密钥泄漏应急。撤销后全部密钥在 5 秒内失效,记录保留用于审计;资源授权与委托配置不受影响。

+
+ + +
+
+
+
+ + +
{{ toast }}
+
+
+
+ + + diff --git a/features/v3.0.0/049-openapi-auth-baseline/design.md b/features/v3.0.0/049-openapi-auth-baseline/design.md new file mode 100644 index 0000000000..f9e95e1640 --- /dev/null +++ b/features/v3.0.0/049-openapi-auth-baseline/design.md @@ -0,0 +1,387 @@ +# Design: 开放 API 鉴权底座(默认拒绝 + 服务账号密钥 + 资源归属人) + +> **本文档定位 — 现状快照(Why this How)** +> +> - `spec.md` 回答 **做什么**(65 条 AC、边界、决议) +> - `design.md`(本文)回答 **为什么这么实现**:关键决策(含被否决的备选)、运行时不直观的事实、对外契约 +> - `tasks.md` 是 **流水账**:拆了哪些任务、做了什么改动 +> +> 调整原则(详见 `docs/SDD-Guide.md` §3-§4):实现变化 → 覆盖更新本文档、只留"今天的状态";但每个决策保留"为什么 + 被否方案"和坑。推翻已 ★ 确认的决策 → 停下与用户重新确认;纯实现细节 → 直接改 design。 +> +> **代码事实口径**:本文所有 `文件:行号` 均按 `3.0-vibe`(HEAD `b63a320f2`,含 F048)在 2026-08-17 核实,路径以 `src/backend/bisheng/` 为根(前端另注 `platform/` = `src/frontend/platform/src/`、`client/` = `src/frontend/client/src/`)。行号会漂移,符号名不会——落地前以符号名重定位。凡文档锚点已在代码中消失的,标「已失效」。 +> +> **⚠️ 2026-09-10 归档标注**:open_api 底座整体改接 beta2 F053(见 [beta2-openapi-base-migration.md](../beta2-openapi-base-migration.md)),本文保留为历史、不再更新。已被取代的口径:D1「复用 `User` 表 + `user_type` 列」→ beta2 独立 `service_account` 表(M4,`user_type` 列已用 drop 迁移移除);`open_api_subject(scope)` `Depends` 工厂 → 端点 `@open_api_scope` marker + `router_rpc` 的 `verify_open_api_access` 单一管线(M8);个人 key `bs-pat-`「整条废除」→ 伴生 PRD D13 改判交付(主体类型 `natural_person`)。 + +**关联**: [spec.md](./spec.md) · [tasks.md](./tasks.md)(待写)· [release-contract.md](../release-contract.md)(表 1 ApiCredential / ServiceAccount / ShareLink,INV-27~31)· [mvp-114-path.md](../mvp-114-path.md) · [baseline-recheck.md](../000-prd1-discovery/baseline-recheck.md) +**版本**: v3.0.0 +**最后更新**: 2026-08-17(初版 + 同日审查修订;尚未开工,本文是"要建成的样子",实现后按现状覆盖) + +> **2026-08-18 增补 — AC-23 归属人租户口径:超管不必切 ScopeBar(用户反馈落地)** +> 原实现:创建服务账号时账号租户 = 当前 scope,归属人必须匹配该 scope;超管想给子租户的人当归属人须先用 ScopeBar 切进那个子租户。用户反馈"超管本就该有最大权限、不该为此切视图"。改法(`service_account_service.py`):**未切 scope 的全局超管,账号租户由所选归属人推导**(选谁就建在谁的租户、归他名下);已切 scope 的超管与租户/子管理员仍限本 scope、归属人必须匹配。配套:未切 scope 的超管其读/写 scope = 全租户(`_scope_tenant_id` 返 None → list/get/update/enable/disable/delete 跨租户),这样超管建完能看见能管;归属人若落在**归档**租户仍拒(`_resolve_owner` 加租户 active 守卫,租户行缺失时回落到 `UserTenant.status`)。**不变量守住**:账号与归属人始终同租户;`update` 改归属人仍须匹配账号既有租户(不能借改归属人迁租户);审计 `operator_tenant_id` 仍记操作者本租户。前端弹窗归属人选择器本就按可见子树取人(超管无 scope = 全租户可见),仅改 `tenantFixedTip` 文案为"归入所选归属人所在的租户"。测试:`test_service_account_service.py` 新增 2 例(超管跨租户派生 / 归档租户仍拒),存量 13 例全绿。 + +--- + +## 1. 目标与非目标 + +- **目标**:给 `/api/v2/**` 装上唯一一条凭据校验路径——服务账号密钥 `bs-sak-`(只存哈希、软撤销、5 秒内失效、按位授权),让每个外部调用都能定位到"哪个集成、哪个租户、哪些权限位",并把匿名超管通道彻底关掉。为此交付三块地基:服务账号主体(不可登录、不进选人、必填资源归属人)、管理界面(列表 / 新建 / 详情三 tab,含主体侧唯一授权入口)、开放能力层开关与三扩展位登记(F051–F053 复用)。两个免登录分享页同步改走 share-token 通道。 +- **非目标**(详见 spec 范围边界,此处防扩范围):身份传递(模式 D / OBO / End-User 头 / `delegate` 位 / 审计双归属)→ F050;三扩展位的运行期消费与入口拒绝 → F051 / F052 / F053;接入信息区 → F053;应用运行期凭据编排 → F055;Responses 契约 → F058;文件级检索过滤 → F052;限流 / 配额 / IP 白名单 / 幂等 → P2;个人 key(`bs-pat-`)整条废除(**⚠️ 2026-08-27 改判:PAT 交付,伴生 PRD D13,实现在 beta2 F053;本条保留为历史**);平台侧任何存量迁移。 + +--- + +## 2. 关键约束 + +> 全局铁律(DDD 分层 / 双 DB / 多租户自动注入 / 权限唯一入口 / 错误码 / 无硬编码密钥 / 前端 store 不直连 HTTP)一律遵循 [`docs/constitution.md`](../../../docs/constitution.md) **C1–C7**,本节不重抄。以下只写本 Feature 特有的硬约束。 + +| # | 约束 | 出处 / 后果 | +|---|---|---| +| K1 | **撤销 / 停用 / 删除 / 编辑 → 5 秒内生效**,且靠**主动失效**(撤销时删缓存),不靠 TTL 自然过期兜底 | INV-28 / AC-03、08、09、21、47、56。任何"进程内 dict 缓存"都被 K7 否决 | +| K2 | **哈希恒时比较 + fail-closed**:Redis 或 DB 抖动时**拒绝**(与平台既有会话续期 `_validate_token_version` 的 fail-open `utils/http_middleware.py:139-141` 刻意相反) | spec §3 已声明是刻意差异;INV-30 | +| K3 | **有效性判据唯一** = `revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now)`;密钥表**不设** status 枚举列 | INV-28;反例 `share_link.status` INACTIVE 全仓无写入端点(`share_link/domain/models/share_link.py:62`) | +| K4 | **HTTP 状态语义必须真实**:AC-01/04/33/34 要求真 401 / 403 / 5xx,但平台 `handle_http_exception`(`main.py:22-36`)把 `HTTPException` 与 `BaseErrorCode` 统一压成 **HTTP 200 + 信封**。本 Feature 的鉴权层错误必须走**专属 exception handler**(先例 `main.py:165-167` `AuthJWTException → JSONResponse(401)`) | E1 已用最小 FastAPI 复现验证 | +| K5 | **DM8 双库对新表 / 改表的影响**:新表靠 `create_all(checkfirst=True)`;给 `user` 加 `user_type` 必须 Alembic revision(模板 `core/database/alembic/versions/v2_5_1_f012_user_token_version.py`);哈希 / 前缀列用 `VARCHAR` 不用 `CHAR`(DM8 CHAR 去尾空格补丁 `dialect_helpers.py:117-141` 是历史包袱);权限位列用 `JsonType`(DM8 落 CLOB → **不能在 SQL 里按位过滤**,只能取回后 Python 判);不给 `share_link.resource_type`(`SQLEnum`,MySQL 原生 ENUM)加枚举值 | C2;E1 §6.3 | +| K6 | **密钥表 / 服务账号表禁批量 UPDATE / DELETE 与 `text()`**:租户自动过滤只拦 SELECT(`core/database/tenant_filter.py:164`),批量写路径无租户注入。撤销 = 单行 ORM 更新;最后使用时间 = 带主键单行 UPDATE;按哈希查凭据必须在 `bypass_tenant_filter()` 下(此时尚无租户上下文) | C3 + backend AGENTS.md 已知陷阱;PRD 附录 E.1 | +| K7 | **所有改动默认多节点**:进程内缓存只能当性能加速、不能承载正确性;失效只能靠 Redis | memory `feedback_multinode_default_assumption` | +| K8 | **开放能力层开关三段式**:进程级 Settings(`config.yaml`,需重启)→ `GET /api/v1/env` → 前端 `appConfig`;**不是** DB 热配置(100s Redis TTL、语义是租户偏好而非部署形态)。服务账号模块恒在,开关只 gate 三扩展位与接入信息区(AC-49) | 先例 `multi_tenant.enabled`(E1 §3) | +| K9 | **凭据校验先于租户上下文,且必须无条件覆盖 ContextVar**:`http_middleware.py:60-73 → _set_tenant_context :87-122` 对解不开的 Bearer **预种租户 1**(不是"不注入");依赖忘了 `set_current_tenant_id(密钥租户)` 时多租户下**静默按 Root 查**而非报错 | E1 §0-2;C3 | +| K10 | **MVP-114 首波边界**(`mvp-114-path.md` §2 F049 行):先交付凭据底座 + 服务账号主体 + 权限位清单 / 开关 + 管理界面(列表 / 新建 / 概览 / API 密钥 tab)+ 供 F053 / F055 复用的 `Depends` + `app:manage` 判定;36 端点接入 / WS / share-token / 资源授权 tab / 缺陷修复 / 配置移除 / 审计全覆盖排第二波(见 D13) | 纵切裁剪只改顺序不改口径 | +| K11 | **F049 不得脱离 F050 单独对外发版**(裸 `user_id` 须随 v3.0.0 移除);开发期内 `resolve_operator` 保留、只补状态与同租户校验 | spec 范围边界 / AC-35、39 | +| K12 | **错误码 260 段**:C5 登记表写"reserved / not yet implemented",落码同一改动回写;新增 260xx 不补三语 `packages/locales/src/api_errors/*.json` 即 CI 失败(`src/frontend/scripts/check-i18n.mjs:97-101/141-147`,仓库根 `scripts/` 下无此文件) | C5;E1 §5.1 | + +**Constitution Check(自查)**:C1 新模块 `open_api/` 按 `api/ + domain/` 分层,v2 端点不跨模块导入 `open_api/api/*`(RULE-5,见 D3);C2 见 K5;C3 见 K6 / K9;C4 授权写路径只经 F048 runtime(D5 / D6)、无 OpenFGA 直连、反查走 SQL 投影账本;C5 见 K12 / D10;C6 密钥明文不落盘不进日志;C7 前端只经 `controllers/API/`。 + +--- + +## 3. 方案对比与选定 + +> 每条 3 段:备选 / 选定 / 原因 + 何时该重新考虑。这里是"想当然会走但被否决"的路的登记处。 + +### 决策 1(D1):服务账号主体形态 = 复用 `User` 表 + `user_type` 列 + 伴生属性表 `service_account` + +- **备选**: + - A. **独立 `service_account` 主体表**,与 `user` 平行 — 优点:干净、不污染用户表;缺点:F048 授权主体类型是 `user`(`tenant/domain/services/f048_permission_subject.py:53-91 canonical_source`、OpenFGA 模型 `directly_related_user_types`、投影 `owner_service.py`)——新主体类型要改授权模型 / 主体校验 / 投影 / 前端 roster 全链路,且会话 `MessageSession.user_id`、审计 `operator_id`、创建人列全部按 `user_id` 建模 + - B. **复用 `User` 表**:`user.user_type`(`human` / `service`,索引,Alembic 加列)+ `source='service_account'` + `external_id=NULL` + 密码哨兵;SA 专属属性(资源归属人 / 描述 / 创建人 / 停用 / 删除时间)放伴生表 `service_account`(PK = `user_id`,物理带 `tenant_id`) — 优点:F048 零改动、会话 / 审计 / 创建人语义直接成立;缺点:三处成本(租户归属直写 + 对账豁免、登录入口封堵、选人场景排除)与 `uk_user_source_external_id(source, external_id)` 多行 `(service_account, NULL)` 的 DM8 风险 + - C. B 但 SA 属性全塞 `user` 表新列 — 缺点:`user` 是热表、多列 Alembic、`user` 无 `tenant_id` 无法靠自动过滤做 AC-07 +- **选定**:**B** +- **原因**:PRD §4.5 D4 已定复用;E2 核实 F048 `runtime.authorize_created`(`permission/application/runtime.py:206-247`)`owner_user_id` 与 `actor` 本就是两个入参、按主体反查索引 `ix_perm_assignee_subject_state`(`permission/domain/models/grant.py:118`)已现成——主体类型仍是 `user` 才能白吃这些。伴生表让"服务账号列表按租户过滤"(AC-07)直接由 `_TENANT_AWARE_MODEL_MODULES` 自动过滤兜住。`external_id=NULL` 使 `aget_login_candidates_by_account`(`user/domain/models/user.py:212-223`)、login-sync 匹配(`sso_sync/domain/services/login_sync_service.py:263-266`)、公开改密(`user/api/user.py:1103`)三条路径**结构性不命中**(AC-15 的零代码防线)。DM8 部分 NULL 唯一性:仓内同构先例 `uk_user_active(user_id, is_active)`(`database/models/tenant.py:185`)已在 105 DM8 跑过多条同 `user_id` 的 NULL 行(E2 §1.1)——作旁证,但**建 ≥2 个服务账号的 DM8 用例列入 §7 必测**。 +- **停用 / 删除表达**:`service_account.disabled_at` / `deleted_at`(可空时间戳)是**唯一状态源**;`user.delete` 只是它的**同事务写穿投影**(任一时间戳非空 → `user.delete=1`,为空 → `0`),作用仅是让全部面向人的 `delete==0` 过滤自然隐藏它。**读侧口径统一**:凭据校验、服务账号列表「状态」列、详情启停按钮、`GET /{id}` 全部只读伴生表两列,不读 `user.delete`;名称水合走 `aget_user_by_ids`(`user/domain/models/user.py:184`,**不过滤 `delete`**,E2 已核)故停用 / 已删账号在授权列表仍能显名。`user.delete` 可被 SSO / `/user/update`(`user/api/user.py:709`)单独翻转的风险由 D7 `assert_natural_persons` 守(对服务账号 → 26022);即便被翻,凭据校验与状态列不受影响,只可能出现"人向过滤下重新可见"这一种偏差,且下一次启停操作会把投影重写一致。这与 K3「不引入可独立漂移的状态」一致:漂移的只是投影,不是真相。 +- **建号事务**:`ServiceAccountService.create` 在**一个** `async with get_async_db_session()` 内依次 `session.add(User)` → `flush()` 取 `user_id` → `add(ServiceAccount)` → `add(UserTenant(status='active', is_active=1))` → 单次 `commit()`;**不复用** `UserDao.create_user`(`user/domain/models/user.py:302-307`,sync、独立会话、独立提交)与 `aactivate_user_tenant`(`database/models/tenant.py:867`,独立 async 会话;其"先降级既有活跃行"一步对全新用户也无意义)——三者各自提交无法构成事务,中途失败会留下"有 user 行无伴生行 / 无租户行"的孤儿(凭据校验按 D2 的"主体存在且伴生表可读且活跃租户 == 密钥租户"三连判会拒绝它,但列表看不到、`/user/list` DAO 过滤又不显示 → 只能进 DB 清)。单事务下失败即全无、无需恢复语义;审计 `open_api.service_account.create` 在 commit 后写、失败只记日志不回滚建号。新写 `ServiceAccountDao.acreate_with_user(session, ...)` 一类"接受外部 session"的方法,不在 DAO 内开会话。 +- **何时该重新考虑**:DM8 用例证明 `(source, NULL)` 多行冲突 → 退化为 `external_id='sa:{uuid}'`(登录免疫退化为纯靠守卫,测试矩阵不变);或 F050 之后出现第二类非人主体(如托管应用要成为授权主体)→ 那时再评估独立主体表。 + +### 决策 2(D2):凭据校验位置与形态 = router 级 v2 依赖(HTTP + WS 同一函数)+ 专属异常处理器 + Redis 短缓存 + 主动失效 + +- **备选**: + - A. **中间件**(仿 `utils/http_middleware.py`)— 优点:一处覆盖;缺点:中间件对所有路径生效、要靠 path 前缀判断;拿不到 FastAPI 依赖链与路由元数据(权限位);WS 与 HTTP 在中间件层分叉(`WebSocketLoggingMiddleware :385`);异常只能手写 `JSONResponse` + - B. **router 级 `dependencies=[Depends(verify_open_api_access)]`** 挂在 `api/router.py:94` `APIRouter(prefix='/api/v2')` 构造处;依赖签名 `conn: HTTPConnection`,`isinstance(conn, WebSocket)` 分流 — FastAPI 0.121 `include_router` 把 router 级依赖同样合并到 `APIWebSocketRoute`(`fastapi/routing.py:1425-1434`,E1/E4 已核) + - C. 逐端点手写 `Depends`(36+2 处)— 缺点:漏一处即匿名端点,靠 review 保证 + - **缓存**:C1 无缓存每请求查库(5 秒上界零成本满足)vs C2 Redis 正缓存 TTL ≤ 5s + 撤销主动删 vs C3 进程内 LRU(K7 否决) +- **选定**:**B + C2**。缓存 key `oapi:cred:{sha256}`(值 = 最小载荷:`credential_id / tenant_id / subject_user_id / subject_kind / scopes / expires_at / resource_owner_user_id / subject_disabled`,**不 pickle `UserPayload`**)、TTL **3 秒**(AC 上界 5 秒的一半,多节点时钟差留余量);撤销 / 编辑 / 停用 / 删除后按该主体名下密钥哈希**逐个 `adelete`**(DB 里有清单,不需要 `SCAN`);`get_redis_client()` / `aget` / DB 任一异常 → **拒绝**(K2)。校验顺序固定为 PRD 附录 E.1:提取 Bearer → `bypass_tenant_filter()` 下按 `sha256(明文)` 查行 → 恒时比较(`hmac.compare_digest`,先例 `sso_sync/domain/services/hmac_auth.py:103`)→ 未撤销 → 未过期 → **按 `subject_kind` 分派主体解析器**(见下「主体解析器」):`service_account` → 主体存在且 `user_type=='service'` 且伴生表 `disabled_at IS NULL ∧ deleted_at IS NULL` → 主体活跃租户(`UserTenant.status=='active' ∧ is_active==1`)== 密钥租户 → 回填缓存 → **每请求(含缓存命中)再做两项**:`expires_at` 与 `now` 比对(AC-05 在 TTL 窗口内到期也要拒)+ 租户禁用 / 归档黑名单 `DISABLED_TENANT_KEY`(`tenant/domain/services/tenant_service.py:44`,`aupdate_tenant_status :268-303` 对 `disabled` / `archived` 都写、恢复时删;Redis 异常按 K2 拒绝)→ **`set_current_tenant_id(密钥租户)`**(K9)+ `set_visible_tenant_ids(...)`(见下「可见租户」)→ **直接构造 `UserPayload(user_id, user_name, user_role=[], tenant_id, token_version=0, is_global_super=False)`**(`LoginUser` 是 pydantic 模型 `user/domain/services/auth.py:196`;**不调 `init_login_user`**——它无条件跑 `_check_is_global_super`(`auth.py:542-543 → utils/http_middleware.py:145-207`:Redis `user:{id}:is_super` + FGA `super_admin` Check + AdminRole 回落),既让每个 v2 请求多 1 次 Redis / 未命中时多 1 次 FGA,又把服务账号"永不超管"从结构性事实变成依赖 FGA tuple / AdminRole 数据状态;`role_ids=[]` 只能关掉 AdminRole 回落、关不掉 FGA Check。也不用 `init_login_user_sync :558-580`)→ 写 ContextVar `current_open_api_principal` → 权限位判定(见 D3)→ 身份传递头存在则 403(AC-33)→ 最后使用时间节流写(`SET NX EX 60` 闸 + 单行 UPDATE)。**残余的一次 FGA**:`resolve_permission_actor`(`permission/application/identity.py:19-38`)对非 Root 租户主体会调 `is_tenant_admin`(`relation_api.py:368-376`)——服务账号在子租户每次进 F048 判定多 1 次 FGA Check;能否成"租户管理员"由 D7 守 `grant_tenant_admin`(26022)保证,不改 `PermissionActor`(C4:permission 模块不认识业务主体)。 +- **主体解析器(`subject_kind` 扩展点)**:`credential_validator` 内部维护 `SUBJECT_RESOLVERS: dict[str, SubjectResolver]`,F049 **只注册 `service_account`**;`hosted_app` 凭据(F055 签发)在 F049 期出现在 `/api/v2` 上时**因无解析器而按 `26002` 拒绝**——它的执行身份 / 租户 / `UserPayload` / `open_api_principal.subject_user_id` 取谁**随 F055 定义并注册**(候选:托管应用的 owner 自然人或应用专属服务账号,本文不预判)。F049 对 `hosted_app` 承诺的只有:`CredentialService.issue / revoke / update` 接受该 kind、`api_credential.subject_kind/subject_id` 能存、`open_api_subject(...)` 工厂在解析器注册后无需改动。 +- **HTTP 错误形态**(K4):新异常基类 `OpenApiAuthError(BaseErrorCode)` 携带 `http_status`;`main.py` 注册专属 handler:请求路径以 `/api/v2` 开头时 → `JSONResponse(status_code=http_status, content={status_code, status_message, data})`(信封形状不变,只把 HTTP 状态真实化)。**WS 分支**:`raise WebSocketException(code=1008, reason=str(错误码))`(Starlette `ExceptionMiddleware.websocket_exception` 在 accept 前 `close()` → uvicorn 403 拒握手,AC-31「优雅关闭」);**禁止**在依赖里对 WS 抛 `HTTPException` / `BaseErrorCode.http_exception`(那正是今天助手 WS 裸崩的形态:HTTP 200 的 denial response,E1 §0-4)。**503 映射清单**(AC-34,统一用正名):`PermissionServiceUnavailableError`(19002,`common/errcode/permission.py:15-17`;`PermissionFGAUnavailableError :21` 只是它的兼容别名,本文不再使用该名)与 `PermissionBackendUnavailableError`(19201,`common/errcode/tenant_fga.py:8`)两族在 `/api/v2` 上经同一 handler 返回 HTTP 503;本 Feature 自己的 `26030`(凭据校验依赖不可用)同样 503。 +- **可见租户**:**复刻** JWT 用户的规则 `_compute_visible_tenant_ids`(`http_middleware.py:210-231`):密钥租户 == 1 → `set_visible_tenant_ids({1})`;子租户 → `{leaf, 1}`(docstring `:216-222`:"own leaf plus Root for shared resource visibility"——是子租户成员看 Root 下发共享资源的语义,与"在父租户导航"无关)。**必须由依赖自己 set**:`/api/v2` 上携带 `bs-sak-` 时中间件 `_apply_token_version_and_visible`(`:233-249`)因 subject 解不开直接 `return None`、不 set,`tenant_filter.py:172-197` 在 visible 为 None 时退回严格 `tenant_id == current` → 子租户服务账号将看不到 Root 共享的模型 / 知识库,而同租户自然人能看到,违反 AC-32「可见范围等于该主体的权限边界」。写路径与计数仍按既有 `strict_tenant_filter()` 约定;`super_admin` 结构性 False 故永不走 `None`(无过滤)分支。 +- **WS 连接期失效**:握手校验 + 连接期 **watchdog**(端点内 `asyncio.create_task`,每 3 秒复查同一 Redis 键 / share_link 状态,失败即 `close(1008)`)——这样"后续调用 5 秒内被拒绝"对长连接同样成立,且不动 `common/chat/manager.py dispatch_client` 的按消息校验。 +- **原因**:B 是唯一"零死角 + 拿得到路由元数据 + HTTP/WS 一份代码"的落点;A 已被 E1 证明拿不到 WS 依赖语义、C 靠人。缓存:F051 模型面 / F052 MCP 面是高频调用(每次校验若走 DB 是 4 次 SQL:凭据 / user / service_account / user_tenant),5 秒上界与 3 秒 TTL 兼容;命中路径的固定开销 = 1 次 Redis `GET`(凭据载荷)+ 1 次 Redis `GET`(租户黑名单),无 FGA。**K1「主动失效」的覆盖面如实划清**:撤销 / 编辑 / 停用 / 删除 / 批量撤销 / 主体删除 → 主动 `adelete` 缓存键;密钥**到期** → 不需主动失效(`expires_at` 在载荷内、每请求比对);**租户禁用 / 归档** → 靠 `DISABLED_TENANT_KEY` 黑名单每请求查(租户模块已有的主动写入),不靠 TTL;TTL 只兜"多节点 Redis 删键与请求交错"的窗口。恒时比较与 fail-closed 姿势抄 `hmac_auth.py:58-109`,但**HTTP 状态语义别抄**(它抛 `HTTPException(19301)` 也被压成 200)。 +- **何时该重新考虑**:Redis 可用性低于 DB(那时无缓存反而更稳,5 秒上界不受影响);F051 压测显示 3 秒 TTL 命中率不足 → 提高 TTL 必须同时把主动删除的覆盖面(编辑 / 停用 / 删除 / 过期)重新审一遍;Starlette 升级改变 `WebSocketException` 处理路径。 + +### 决策 3(D3):端点接入策略 = 两层:router 级依赖(凭据 + 租户 + 权限位)+ 端点级**权限位标记**(domain 级装饰器),不是逐端点 `Depends` + +- **备选**: + - A. baseline-recheck「两层改造」原案:router 级依赖只管"无凭据 401",把解析出的身份写 ContextVar,再让 `open_endpoints/domain/utils.py` 三个函数改读 ContextVar → 34 端点零改动 — 缺点:`get_default_operator*` 随 AC-52 整体作废,"零改动"实际是"保留一个名不副实的函数";权限位(AC-04 需指明缺哪一位)无处声明 + - B. 每个端点加 `login_user = Depends(require_scope("workflow:invoke"))` — 优点:显式;缺点:`open_endpoints/api/endpoints/*.py` 要 import `open_api/api/dependencies.py` → **RULE-5 违规**(API 层跨模块导入);漏写一处即"有凭据但不判位",只能靠测试兜 + - C. **端点级标记 + router 级统一判定**:`open_api/domain/scopes.py` 提供纯标记装饰器 `@open_api_scope("workflow:invoke", allow_share_token=False)`(写在端点函数上,只设属性、无 FastAPI 依赖);router 级依赖读 `conn.scope["endpoint"]`(Starlette `Route.matches` 设置,`starlette/routing.py:263/348`)上的标记:**没有标记 → 拒绝(500 级 `26031` 未登记端点)**,有标记 → 按位判定;端点体里的身份取 `Depends(get_open_api_login_user)`(`open_endpoints/domain/utils.py` 内新函数,读 ContextVar;替换 **10 处** `Depends(get_default_operator_async)`——端点内 9 处(`citation.py:23` + `knowledge.py:21/42/65/90/111/136/162/188`)加 `open_endpoints/api/dependencies.py:63 get_knowledge_space_chat_service_for_openapi`(全仓无调用方 = 死代码,但该模块被 `endpoints/knowledge.py:11` import,删函数不删这处则**模块导入即失败**——随 AC-52 一并删除该死函数)——与 25 处内联调用) +- **选定**:**C** +- **原因**:C 对"漏标记"是**结构性 fail-closed**(router 依赖拒绝未登记端点),B 是漏一处即静默放宽;C 的标记留在端点旁、review 可见,且能承载 F050 的 `modes=("S","D")`;C 只让 v2 端点文件 import `bisheng.open_api.domain.scopes`(domain 级常量),RULE-5 不触发(它只拦 `bisheng..api.` 导入);router 级依赖的挂接点在 `bisheng/api/router.py:94`(全局路由文件,RULE-5 显式排除 `bisheng.api.`)。A 的三个函数中 `get_default_operator` / `get_default_operator_async` **删除**(AC-52),`resolve_operator(user_id)` 保留到 F050、改为:读 ContextVar 主体 → `user_id` 为空即返回主体;非空则 `aget_user` + `delete==0`(AC-39)+ 目标活跃租户 == 密钥租户(spec §3 跨租户组合一律拒绝)→ 否则 403。**注意 `flow.py:8` 从 `endpoints.assistant` 转导入 `get_default_operator`**(E1 §1.2)——删函数时一起改。 +- **权限位 ↔ 端点映射**(38 个,B.1 口径;`OPEN_API_SCOPES` 常量同时给签发表单的悬停提示 AC-44 供数):`workflow:invoke` = invoke / stop / WS chat(3);`workflow:read` = `GET /flows/{id}`(1);`assistant:invoke` = chat/completions / WS / `llm/workbench/asr` / `tts`(4);`assistant:read` = list / info(2);`knowledge:read` = `GET /filelib/`、`file/list`、`retrieve`、`download_statistic`、`detail_qa`、`query_qa`、`citation/{id}`(7);`knowledge:write` = filelib 其余 13 个写端点 + `knowledge/*metadata*` 8 个(21)。合计 3+1+4+2+7+21 = **38** ✓。另加 `GET /api/v2/auth/whoami`(**新增**,只验密钥不判位,供 F053 `login` 与 114 手动验证;见 §6.1)——它**同样必须带标记**,写作 `@open_api_scope(None)`:router 级依赖的规则是「无标记 → 26031;标记 `scope=None` → 跳过位判定;标记有位 → 判位」,"无位"与"漏标"在结构上可区分。 +- **完整性守卫**:单测枚举 `app.routes` 下所有 `/api/v2/**` 路由,断言每个都带标记,且标记的位 ∈ `OPEN_API_SCOPES` **或为 `None`**(`None` 只允许出现在显式白名单 `{"/api/v2/auth/whoami"}`,白名单增项要改测试)(AC-29 的机器化)。 +- **随接入一并落地的两处既有缺陷(AC-37 / AC-40,此前只出现在测试清单)**: + - **AC-37 未上线工作流 invoke 拒绝**:`open_endpoints/api/endpoints/workflow.py:43-49 invoke_workflow` 取到 `workflow_info` 后、构造 `RedisCallback` 之前加 `if workflow_info.status != FlowStatus.ONLINE.value: raise WorkflowOfflineError.http_exception()`(复用 `common/errcode/chat.py:29` 13010,已有三语;同一判据在 `common/chat/clients/workflow_client.py:98` 与 `workstation/api/endpoints/apps.py:161` 各有一份,v2 invoke 是唯一漏掉的入口)。`/workflow/stop` 不加(停一个未上线流的会话是幂等空操作)。 + - **AC-40 `download_statistic` 收口**:`filelib.py:496-507` 现状是接受调用方任意 `file_path`、只靠"后缀 `.log` + 目录名不含 `.` + 前缀 `/app/data`"三条字符串守卫读服务器文件,且**全仓无产出该类统计文件的代码**(grep `download_statistic` / `statistic` 只命中 ES 统计连接,无写盘方)——它事实上是"读 `/app/data` 下任意 `.log`"。改为:入参 `file_path` → `file_name`(`os.path.basename(file_name) == file_name` 且不含 `..`,否则 400 `ServerError`);固定目录常量 `STATISTIC_DIR = /statistic`(`data_dir` 取既有配置根,不新增 Settings 键);`os.path.realpath(join)` 必须以 `realpath(STATISTIC_DIR) + os.sep` 开头,否则 404;仍需 `knowledge:read`。契约变化(`file_path` → `file_name`)进发布说明与 `docs/api`(AC-54 同批)。 +- **何时该重新考虑**:FastAPI / Starlette 不再在 scope 里暴露 `endpoint`(则退回 B 并补完整性测试);F050 引入委托后如果"允许模式"需要按请求动态判定而非静态标记。 + +### 决策 4(D4):6 个 `/chat/*` 端点的关闭方式 = **从 router 移除**(真 404),不留 410 桩 + +- **备选**: + - A. 移除路由(不 `include_router(chat.py)`,删文件)→ Starlette 默认 404 `{"detail":"Not Found"}`(E1 已验证) + - B. 保留路由、返回 410 + 发布说明链接(先例 `tenant/api/endpoints/tenant_users.py:57-73`,故意不挂鉴权依赖) + - C. 保留路由、挂鉴权后返回 404 +- **选定**:**A** +- **原因**:AC-30「端点不存在类响应、不执行任何业务逻辑」A 天然满足;B 需要在 `/api/v2` 下保留**不挂凭据依赖的路由**——与 AC-29「不存在绕过该路径的端点」相悖、且 D3 完整性测试要为它开例外;6 端点从未进过对外文档(PRD §4.6),发布说明逐个列出即可(AC-50)。`chat.py:7-9` 那行无用的 `a = WSGIMiddleware` 随文件删除。 +- **何时该重新考虑**:升级后客户大量反馈"404 看不出为什么"→ 加 410 桩,但必须挂在凭据依赖**之后**(无凭据仍 401),并把它加进 D3 的标记完整性例外表。 + +### 决策 5(D5):资源归属人落地 = 业务 Service 传 `owner_user_id`(创建关系落归属人)+ F048 runtime `authorize_created` 计划内追加**非 protected 回授行**(新 `source_type`) + +- **备选**: + - A. 只在 F048 adapter / runtime 层做(检测 actor 是服务账号 → owner 换成归属人 + 回授)— 缺点:业务行 creator 列(`knowledge.user_id`)仍硬写 `login_user.user_id`(`knowledge/domain/services/knowledge_service.py acreate_knowledge_base :795-799`、同步版 `create_knowledge_base :741-749`——**不是** `create_knowledge :674`,后者只是它们的上层入口),归属人的"我创建的"列表与创建人显示都不对;且 C4 禁止 permission 模块查 `user_type`,runtime 根本拿不到"actor 是服务账号"这个事实 + - B. 只在 v2 API 层做(端点里把 `login_user` 换成归属人再调 Service)— 缺点:**同时打破 AC-25 与定义 6 边界 c**——会话 / 审计 / 权限基准全变成归属人的(E2 §6 明确警告) + - C. **业务 Service 加 `owner_user_id` 覆盖参数 + runtime 计划内回授(业务侧显式下令,runtime 不判主体类型)**:`login_user` / `actor` 仍是服务账号;业务 Service 判 `login_user.open_api_principal is not None and .subject_kind == 'service_account'` → 业务行 creator 列写 `principal.resource_owner_user_id`,并在权限记录上置 `creator_autogrant_user_id = login_user.user_id`;runtime `authorize_created`(`runtime.py:206-260`)**新增显式 kwarg `autogrant_user_id: int | None = None`**:非空时要求 `autogrant_user_id == actor.user_id ∧ != owner_user_id`(结构约束:只能"创建者把自己回授进来",不能借此给第三方授权),把一条 `subject=user:{autogrant_user_id}`、`source_type=SERVICE_ACCOUNT_AUTOGRANT`、`protected=False`、档位 = 该资源类型可授档位中包含 `edit` 动作的最低档(经 Catalog 解析,不硬编码 `model_key`)的 assignee 行并入**同一份创建计划** +- **选定**:**C** +- **输入契约与进入点(把"runtime 怎么知道"写死)**: + - `PermissionActor`(`permission/domain/services/permission_action_service.py:27-31`:只有 `user_id / current_tenant_id / super_admin / tenant_admin_tenant_ids`)与 `resolve_permission_actor`(`permission/application/identity.py:19-38`)**都不改、不读 `user_type`**(C4)。"是否回授、回授给谁"由业务层通过 record → adapter → runtime 的**显式参数**传入;runtime 只校验结构约束(同 actor、异 owner、资源版本 0)。 + - 进入点 = `authorize_created` 本身(不新开 `owner_grant` / `build_create_plan` 的旁路):现状硬拒 `source_type != 'CREATOR' or not protected`(`:216-223`)保持不变——那两个参数描述的是 owner 行;回授行走新字段。`OwnerProjectionContext`(`permission/domain/services/owner_service.py:38-53`)加 `extra_grants / extra_deltas`(**不复用** `copy_grants / copy_deltas`:`_validate_copy :236-239` 对 `RESOURCE_CREATE` 见到 copy_* 即抛 `PermissionInvalidResourceError`,且 INHERIT 下拒绝),`project_created :113-166` 把 extra deltas 与 owner 的 protected deltas 一起进 `build_create_plan(protected_deltas=...)`(`resource_lifecycle_policy.py:93`,同一 plan / prepare / execute / finalize),`control_state._owner_projection_grants :956-963` 与 `_state.prepare / finalize` 把 extra grant 的 assignee 行一并落账本。幂等键不变(同资源同 owner 的重放得到同一计划)。 + - **只对 CUSTOM 起始模式的类型回授**:`knowledge_library / knowledge_space` 等 `FIXED_CUSTOM_TYPES` 起始 CUSTOM,回授行有效;`folder / knowledge_file`(`FLEXIBLE_MODE_TYPES`,`resource_lifecycle_policy.py:14`)起始 **INHERIT**,INHERIT 下非 protected 本地行被忽略 / 丢弃(`control_state.py:355 protected_only=normalized_mode=="INHERIT"`、`mode_service.py:72-76`)——服务账号对文件 / 文件夹的写权限**来自父库上的回授行或 DIRECT 行**(正是允许它上传的那条权限),因此文件 / 文件夹**只落 owner=归属人、不加回授行**;`authorize_created` 收到 `autogrant_user_id` 且目标起始模式为 INHERIT 时**忽略回授**(不报错,记 debug 日志),避免业务层逐类型判断。 +- **v2 面创建 F048 资源的全部路径(必须全部经过接缝,漏一条即 AC-61 / AC-24 失守)**: + 1. `POST /filelib/` **type≠3** → `KnowledgeService.acreate_knowledge_base :795`(`db_knowledge.user_id = login_user.user_id :799`)→ `_project_library_created :212-223` → `_new_library_permission_record :194-208 owner_user_id=int(knowledge.user_id)`;同步版 `create_knowledge_base :741-749` 同改(`create_knowledge :674` 是其上层)。 + 2. `POST /filelib/` **type=3(知识空间)** → `filelib.py:99-101` 走 `KnowledgeSpaceService.create_knowledge_space`(`knowledge/domain/services/knowledge_space_service.py:1100 user_id=self.login_user.user_id` → `:1110-1117 authorize_created(owner_user_id=int(knowledge_space.user_id))`)——**不经路径 1**,单独参数化。 + 3. `POST /filelib/file/{knowledge_id}`(`filelib.py:338 aprocess_knowledge_file :1346`)与 `POST /filelib/chunks` / `chunks_string`(`:459 sync_process_knowledge_file :1375`)都进 `process_one_file :1483` → `KnowledgeFile(user_id=login_user.user_id :1544)` → `_project_library_file_created :257-268` → `_new_library_file_permission_record :242-254 owner_user_id=int(file.user_id)` → `knowledge_permission_service.py:995-1000 authorize_created(mode='INHERIT', protected=True)`;资源类型 `knowledge_file` / `folder` 均在 F048 registry(`api/services/f048_permission_runtime.py:171-195`)。此处 `KnowledgeFile.user_id` 写归属人(`updater_id` 保持服务账号),**不回授**(INHERIT,见上)。 + 4. QA 问答对(`add_qa` 等)与 `knowledge/*metadata*` 不是 F048 资源,不经接缝。 + §4.3 模块表相应扩到 `knowledge_space_service.py` 与 `process_one_file`。 +- **原因**:runtime 已把 `owner_user_id` 与 `actor` 分离(E2 §0),业务侧只差把硬写处参数化;回授行必须**非 protected**(受保护源不可经 `mutate` 移除,`grant_service.py:308/326`,与 AC-63「可单条撤销」冲突)且必须有**可识别的新 `source_type`**(复用 `DIRECT` 则「全部撤销」分不出回授,AC-63 失效;`OTHER` 不校验 subject 类型且语义模糊)。新增来源值要同步 `SOURCE_TYPES`(`permission/domain/services/grant_source_service.py:16-26`)、`_validate_source_subject`(`:178`,允许 `user`)、`_source_locator`(`:193`);`f048_mode_mapper.py:80` 与 `control_state.py:379` 对非 `CREATOR/SYSTEM` 源按普通源处理,与"可单条撤销"一致。回授行进创建计划而非事后 `mutate`:失败即整个创建 `FAILED_CLOSED`(`permission/domain/models/grant.py:26-31`)而不是"资源建成但集成写不进"的半成品。(原句"v2 面本期能创建的 F048 资源只有知识库"**已订正**:知识空间与文件 / 文件夹两条投影路径同样存在,见上「全部路径」清单。) +- **何时该重新考虑**:F050 模式 D 上线("一次调用两者并存以被代表用户为准",AC-46 归 F050,同一接缝加分支);F054 `app` 类型注册后若 CLI `deploy` 走同一 runtime 创建 → 复用本接缝(`app` owner = 归属人,PRD-1 DEV-01 ④)。 + +### 决策 6(D6):主体侧授权页反查 = SQL 反查 `permission_grant_assignee`(`permission.application` 新只读 API)+ 来源列取值映射;写路径复用 runtime 但**不复用资源侧 HTTP 端点** + +- **备选**: + - A. OpenFGA ListObjects — 被 `DenyListObjectsPolicy`(`permission/application/sql_runtime.py:638`,装配 `runtime.py:1113`)显式禁用 + - B. 服务账号模块自建"授权镜像表" — 双份真相 + - C. **`permission.application` 增只读 `subject_api.list_subject_grants(subject_type='user', subject_id, tenant_id, resource_type?, cursor)`**:`SELECT ... FROM permission_grant_assignee a JOIN permission_grant g WHERE a.subject_type='user' AND a.subject_id=:sa AND a.state='ACTIVE'`(命中 `ix_perm_assignee_subject_state`,`tenant_id` 由自动过滤注入),资源名水合走 registry 白名单(`api/services/f048_permission_runtime.py:171-195`,**不照抄前端 union**——它含未注册的 `linsight_skill`);来源列:`DIRECT` → 「管理员授予」、`SERVICE_ACCOUNT_AUTOGRANT` → 「创建时自动回授」、其它(`CREATOR` / `DEPARTMENT` / …)→ 标「异常来源」并进日志(AC-61:出现即缺陷) + - **写路径**:W1 前端直接调既有 `POST /api/v1/permissions/resources/{t}/{id}/grants:mutate`(PRD 附录 E.4 原案)vs W2 **服务账号模块新端点 `POST /api/v1/service-accounts/{id}/grants:mutate`**(校验管理员 + 服务账号存在 → 逐资源取 context → 调同一 `resource_api.mutate_grants` / runtime,携带 `allow_service_account_subject=True`) +- **选定**:**C + W2** +- **原因**:C4 要求 Grant 变更只经 F048 runtime 且先落账本——W2 仍经同一 runtime,只是 HTTP 入口不同;选 W2 是因为 D7 要在 `canonical_source`(`f048_permission_subject.py:68-71`)拒绝服务账号主体以硬保证 INV-29「授权只走主体侧」(否则绕开界面直接 POST 资源侧端点仍能把服务账号授进去,E2 §1.4)——那么资源侧端点必须拒绝、主体侧端点必须放行,靠一个 application 层显式参数区分。N 个资源 = N 次 mutate、逐条反馈、不做事务(PRD E.4);管理员对目标资源无需另持权限(`_system_authorized`,`runtime.py:1077-1083`)。「全部撤销」= 反查后过滤掉 `SERVICE_ACCOUNT_AUTOGRANT` 再逐条 REMOVE(AC-63);单条撤销回授项需前端二次确认文案。删除服务账号 = 先反查、逐资源 REMOVE、再撤销密钥、再置 `deleted_at`(assignee 外键 `ondelete=RESTRICT` 指向 grant 而非主体,删用户行不会级联,`grant.py:137`)。 +- **何时该重新考虑**:F048 引入按主体的 ListObjects 或专门的主体侧 API;资源类型超过两位数导致名称水合退化为 N 次查询(那时在反查 SQL 里 join 各业务表或加缓存)。 + +### 决策 7(D7):服务账号选人排除 = DAO 默认参数(fail-safe 方向)+ 授权候选查询同步过滤 + 授权主体校验层拒绝 + +- **备选**: + - A. 接口层 opt-in 参数(`/user/list?exclude_service=true`)— 漏传即泄漏,方向反了 + - B. 只在前端过滤 — 直接调接口即泄漏 + - C. **数据访问层默认排除**:`UserDao._filter_users_statement`(`user/domain/models/user.py:259-264`)增 `user_type: str | None = "human"` 默认参数(覆盖 `filter_users :267` / `afilter_users :279` → `/user/list` 全部 8 处 platform 消费点,E2 §1.3);`grant_subject_service.list_candidate_users`(`permission/domain/services/grant_subject_service.py:70-121`,条件在 `:90`)同步加 `User.user_type=='human'`(两个前端资源侧授权弹窗的独立查询,**不走 `/user/list`**);`department_service` 全局成员搜索(`department/domain/services/department_service.py:2255-2330`,JOIN 主部门、SA 结构性不命中)也显式加条件作纵深;`f048_permission_subject.canonical_source`(`:68-71`)对 `user` 主体查到 `user_type=='service'` 时**拒绝**,除非 mutate 上下文带 `allow_service_account_subject=True`(D6 W2);`aget_user_by_ids`(`:184`,已授权对象名称水合)**保持不过滤**(AC-16 可显示不可选) +- **选定**:**C** +- **原因**:PRD §4.5 成本 3「漏传的后果是看不到而非泄漏」;E2 已核实两条查询路径都只查 `delete==0`;`display_names`(`f048_permission_subject.py:110`)走 `aget_user_by_ids` 不经统一过滤,AC-16「可显示」天然满足。管理接口层拒绝(AC-22)另加共享断言 `assert_natural_persons(user_ids)`(放 `user/domain/services/`),在 `/user/role_add`(`user/api/user.py:874-965`)、部门加成员 `aadd_members`(`department_service.py:1749`)、部门管理员 `aset_admins`(`:1986`)、用户组 `replace_user_groups` / `set_group_admin` / `set_group_members`(`api/services/role_group_service.py:175/238/271`)、租户管理员 `grant_tenant_admin`(`tenant/domain/services/tenant_admin_service.py:37-58`)、`/user/update`(`user/api/user.py:663-724`,含 `:709` 直写 `delete` 位)、**管理员改密 `/user/reset_password`**(`user/api/user.py:1032-1066`,按 `user_id` 直改密码——AC-20「改密」在 D10 26022 清单里,此前漏列入口)入口调用——这些是"主动加入 / 主动改写",DAO 过滤兜不住直接 POST。`/user/change_password`(`:1067`,改自己)与 `change_password_public`(`:1095`,按 `external_id`)对服务账号结构性不可达(不能登录 / `external_id=NULL`),不加断言。 +- **何时该重新考虑**:出现合法的"要看到服务账号"的人向场景(目前无;审计筛选 `search_user_by_name :296` 按名筛会话日志属另一路径、不受影响)。 + +### 决策 8(D8):share-token 通道 = 分享创建者为执行主体 + WS 双凭据并存 + `share_link` 加 `share_scope` 列 + 三个 share-link 作用域 HTTP 端点 + 撤销 / 有效期 + +- **事实修正(★ 需回写 spec 决议-1——已在修订历史「需回写上游」登记,tasks 前须与用户确认;不改 AC 文字,但改其解释基础)**:spec 决议-1「实际受影响面只有两个 WS」的核实基础已过时;本设计新增 3 个 `/api/v1/share-link/{token}/*` 端点、`share_scope` 列与"应用级分享必填有效期",AC-55–58 只写了 WS,应补一句"guest 页的信息 / 历史 / 标题读取经 share-link 作用域端点"。另 AC-55「仅限该分享所指向的单一资源」在 history 端点上按「资源 = flow」解释(同 flow 下任意 chat_id 可读,与今日 v2 history 完全匿名相比已是收窄;逐访客绑定等 F050 分区键)——此口径也随本项一并回写。两个免登录分享页**已不在 platform**(`chatShare.tsx` / `chatAssitantShare.tsx` / `platform/routes/index.tsx:24-25` 随 `36beaa00f` 删除,**已失效**),真身是 client `pages/standaloneChat/StandaloneChatPage.tsx`(guest → `apiVersion='v2'` `:82`;路由 `client/routes/index.tsx:264-265`,AuthLayout 之外);它除 2 个 WS(`pages/appChat/useChatHelpers.ts:35-36`)外还打 4 个 v2 HTTP:`GET /flows/{id}`(`client/api/apps.ts:39`)、`GET /assistant/info/{id}`(`:64`)、**`GET /chat/history`**(`:133`)、**`POST /chat/gen_title`**(`api/chat/api-endpoints.ts:51` / `useWebsocket.ts:82`)——后两个在 AC-30 关闭清单里。 +- **备选**: + - A. share-token 通行 v2 上述 4 个 HTTP 端点(保留 history / gen_title 的 share-token 版)— 与 AC-30 / INV-27「靠关闭解决」冲突,且 v2 HTTP 面出现第二种凭据 + - B. guest 页改打 v1 同名端点(已接受 `share-token` 头:`api/v1/flows.py:20-24`、`api/v1/assistant.py:71-74`、`chat_session/api/endpoints/chat.py:53`)并把它们的 `Depends(get_login_user)` 放宽成"登录或有效 share-token" — 给 v1 三个端点新增匿名模式,扩大前端面攻击面 + - C. **share-token 只在两处出现**:① 两个 WS(查询参数 `?share_token=`,spec §3 明示允许);② `share_link` 模块自己的**匿名**作用域端点(前缀在 `TENANT_CHECK_EXEMPT_PATHS`,`http_middleware.py:39`——匿名端点正需要它):`GET /api/v1/share-link/{token}/resource`(按 `resource_type` 返回工作流或助手信息)、`GET /api/v1/share-link/{token}/chat/history?chat_id=`、`POST /api/v1/share-link/{token}/chat/gen_title` — v2 HTTP 面保持"只有 `bs-sak-`" + - **登录态管理端点的落点**(撤销 / 列表):`TENANT_CHECK_EXEMPT_PATHS` 是 `startswith` 前缀匹配(`http_middleware.py:311`),命中后**整条调用链 `_bypass_tenant_filter=True`、跳过 token_version 校验、跳过租户禁用检查**(`:322-343`)——任何以 `/api/v1/share-link` 开头的路径(含 `/share-links`、`/share-link-admin`)都会被豁免。因此撤销 / 列表**不能**放在该前缀下(列表会在 bypass 下跨租户可见),改挂**新的非豁免前缀 `/api/v1/app-shares`**:`GET /api/v1/app-shares?resource_type&resource_id`、`POST /api/v1/app-shares/{id}/revoke`(`share_link/api/endpoints/share_link_manage.py`,`Depends(get_login_user)`,租户自动过滤 + token_version + 租户状态检查全部生效)。既有 `POST /api/v1/share-link/generate_share_link`(`share_link.py:12-17`,登录态)**本就**落在豁免前缀下运行于 bypass、无 token_version 校验——属既有暴露,F049 不搬它(生成 `share_scope='app'` 仍复用它,service 内显式 `tenant_id = login_user.tenant_id`),登记为坑 26。 + - **资源类型表达**:R1 给 `ResourceTypeEnum` 加 `workflow_app / assistant_app`(`SQLEnum` → MySQL 原生 ENUM 要 Alembic ALTER,K5 反例)vs R2 **加列 `share_scope VARCHAR(16) server_default 'session'`**(`app` = 本 Feature 的应用级分享,`resource_id` = flow id)。**现状如实**:`ShareChat.tsx:59` 生成的 `workflow` / `assistant` 行 `resource_id` 是会话 id,**6 个**消费点按 `resource_id == session/chat id` 校验(`workstation/api/endpoints/chat.py:57,81`、`chat_session/api/endpoints/chat.py:59`、`linsight/api/endpoints/linsight.py:152,554,620`);但另有 **2 个 flow 级消费点**已承认"`resource_id` 即 flow id"这一形状(`api/services/flow.py:236-246`、`api/services/assistant.py:293-303`:`meta_data.flowId` 优先、否则 `resource_id` 当 flow id,注释明写 direct flow shares)。所以"语义已被占用"只对 6 个会话级消费点成立,R2 加列的理由是**同一行同时被两种口径读**、必须有一列把它们分开,而不是"不能复用"。D8 落地后这 2 处 flow 级消费点**逻辑不改**(`app` 行 `resource_id` 即 flow id,与其兜底分支天然一致),只在注释登记 `share_scope` 语义;6 个会话级消费点也不改(`session` 行行为不变)。 +- **选定**:**C + R2**。执行主体(AC-58):以创建者构造 `UserPayload`(同 D2,直接构造、`is_global_super=False`、`user_role=[]`,**不**调 `init_login_user`——分享创建者若是超管,guest 会话也不应继承超管短路),创建者已禁用 / 已删除或租户不活跃 → 拒绝(fail-closed);`set_current_tenant_id(share.tenant_id)`;WS 路径参数 `workflow_id` / `assistant_id` 必须 == `share.resource_id` 且 `share_scope=='app'`(AC-55 单一资源);`ContextVar` 主体标记 `subject_kind='share_link'` + `share_link_id`;F048 `use` 校验仍以创建者为 actor(`common/chat/manager.py:195-208` 每条消息的既有校验保留)。撤销:`POST /api/v1/app-shares/{id}/revoke`(登录 + 创建者或管理员;非豁免前缀,见备选段)写 `status=INACTIVE`(读侧 `get_share_link_by_token :57-58` 已按 `status != ACTIVE` 拒绝,写入端点从此存在、枚举不再"死");有效期:`expire_time` 语义**定为"自 `create_time` 起的相对秒数,0 = 永不"**,`get_share_link_by_token` 统一强制(`:52-56` 注释删除),应用级分享生成时 `expire_time` **必填 > 0**(platform `NoLoginLink` 默认 30 天),会话级分享保持 0 兼容;`access_count` 不动。撤销 / 过期对**新建连接**立即生效(无缓存),对已建立的 WS 靠 D2 watchdog(≤ 5 秒)。前端:platform `ChatLink.tsx:77-81` 的免登录 URL 改为 `${origin}${BASE_URL}/workspace/chat/{flow|assistant}/{id}?share_token=…`(新增 platform `controllers/API/shareLink.ts`:生成 / 列表 / 撤销);client guest 页从 query 取 token,`useChatHelpers.ts:23-40` 拼 WS URL 时带 `share_token`,`api/apps.ts` 三处与 `api-endpoints.ts:51` 改打 share-link 作用域端点。 +- **原因**:C 让"v2 HTTP = 密钥、WS = 密钥 ∪ share-token、share-link 模块 = share-token"三个面各自单一;A 违反 INV-27;B 改 v1 语义。R2 比 R1 便宜且双库安全(加列 + `server_default` 是 revision 允许的唯一数据效果)。执行主体取创建者与「分享 = 分享者把自己的应用开放给匿名访客」一致(spec 决议-6 i),且比现状(固定配置身份)收窄。 +- **已知代价(如实登记)**:guest 会话的 `MessageSession.user_id` = 分享创建者 → **会出现在创建者的工作台会话列表**(`workstation/api/endpoints/apps.py:196-202` 按 `user_ids=[login_user.user_id]`);现状是落在 `default_operator` 名下(同样"串",只是串给了别人)。缓解:本期 `MessageSession` 不加列(F050 才加分区键 `external_user_id`、只写不读,memory 已定),F050 / F058 落地时用 `share_link_id` 作分区值把 guest 会话从创建者列表里摘出——列入 §8。history 端点只校验 `session.flow_id == share.resource_id`(chat_id 是 128 位随机、当前 v2 history 本就完全匿名),逐访客绑定同样等分区键。 +- **何时该重新考虑**:F050 分区键落地(则 history 按 `share_link_id` 收窄、会话列表摘除);产品决定分享页需要登录(则整条通道退役);出现服务端集成用 WS 的证据(E1 §7 未找到 WS 出现在 platform API 接入文档中)——不影响双凭据并存,只影响文档。 + +### 决策 9(D9):权限位清单 = 代码常量注册表;开放能力层开关 = 进程级 Settings 三段式;`chat:invoke` 登记为「端点待开放」位、`delegate` 本期不登记 + +- **备选**: + - A. 权限位存表(可运营配置)— 运行期消费者(F051–F053)按常量判位,表只会漂移 + - B. **`open_api/domain/scopes.py` `OPEN_API_SCOPES`**:每位 = `code / group / label_key / desc_key / endpoints[] / requires_open_platform`;三扩展位 `model:invoke` / `identity:read` / `app:manage` `requires_open_platform=True`、组 `local_dev_toolkit` + - 开关:S1 DB 热配置(`initdb_config`,100s TTL)vs S2 **`core/config/open_platform.py OpenPlatformConf(enabled: bool = False)` → `Settings.open_platform`(`core/config/settings.py:782` `multi_tenant` 旁)→ `config.yaml` `open_platform: enabled:` → `api/v1/endpoints.py:60-108 get_env` 增 `open_platform_enabled` → platform `contexts/locationContext.tsx:75-94` 增 `appConfig.openPlatformEnabled`**;后端签发 / 编辑校验直接读 `settings.open_platform.enabled`,未部署时三位入参 → `26023`;F054 的「工场运行时层开关」是同形态兄弟键、不合并 + - `chat:invoke`:**按 spec「本次纳入」口径登记**(spec 范围边界明写权限位清单含 `chat:invoke`,「开放前签发表单对该位以说明标注『端点随后续版本开放』、其余行为同普通位」;决议-6 f 只裁决了 `delegate`,未推及 `chat:invoke`——本文初版把它踢出注册表属未经再确认的推翻,**已撤回**):`OPEN_API_SCOPES` 中 `code='chat:invoke'`、`group='assistant'`、`endpoints=()`、`pending_note_key`(三语「端点随后续版本开放」);签发 / 编辑可勾、持久化、`whoami` 原样返回;D3 完整性测试对该位的约束是"允许注册表里有位而无端点"(反向:端点标了注册表没有的位才失败);F058 落端点时只需给端点加标记 + 清 `pending_note_key`。 + - `delegate`(F050):本期**不进注册表、不进表单**(AC-14;入参 → `26024`「委托能力尚未启用」)——决议-6 f 判据:"管理员能勾一个什么都不做的位"是负资产;`chat:invoke` 不适用该判据,因为 spec 已把它定为「有说明的待开放位」而非「什么都不做」。 +- **选定**:**B + S2 + `chat:invoke` 登记 / `delegate` 不登记** +- **原因**:先例 `multi_tenant.enabled` 就是这条三段式(E1 §3);开关是部署形态不是租户偏好;`load_settings_from_yaml`(`common/services/config_service.py:91-107`)对未知顶层键 `KeyError` 拒启 → **先发代码再加 yaml 键**(114 部署顺序坑,§5)。 +- **何时该重新考虑**:F058 落端点 → 给端点加标记并清 `pending_note_key`(常量已在);产品要求运营期按租户开关能力 → 那是另一层(租户级配额 / 能力总线),不动本开关。 + +### 决策 10(D10):错误码 = 260 段落码 `common/errcode/open_api.py` + constitution C5 同改动回写;开放面 / 管理面分号段 + +- **备选**:全部塞 26001–26016 vs **开放面用附录 C 编号、管理面另起 26020+**(同一模块 260,`EE` 到 99) +- **选定**:后者。本期启用:`26001` 缺少 / 格式非法(401)、`26002` 无效 / 已撤销 / 已过期(401)、`26003` 缺权限位(403,`data.required` 指明缺哪位)、`26004` 身份传递能力尚未启用(403;本期任何 `X-Bisheng-On-Behalf-Of` / `X-Bisheng-End-User` 头一律触发,F050 收窄其语义为"未授予委托 / 不在范围")、`26012` 服务账号禁止登录(403;登录守卫用。**落地形态必须是 `raise`,不能是 `return` 一个 26012 响应**:`_reject_login_if_user_has_no_usable_access` 是"返回响应对象"式守卫(`user/domain/services/user.py:373-379`),其调用方 `tenant_service.py:663-665` / `login_sync_service.py:228-230` 的映射是 `if guard.status_code == UserNoRoleForLoginError.Code: raise UserNoRoleForLoginError() else: raise UserNoWebMenuForLoginError()`——即 `UserNoRoleForLoginError` 原样透传、**其它任何返回码(含 26012)都被压成「无菜单」**。所以服务账号分支不走返回值通道,直接 `raise`,四个入口的异常路径都原样透传);管理面草案:`26020` 服务账号不存在、`26021` 资源归属人须为本租户已启用自然人、`26022` 对服务账号禁止的操作(改密 / 启停登录 / 授角色 / 加组 / 加部门 / 租户管理员)、`26023` 扩展位未部署、`26024` 委托能力尚未启用、`26025` 未知权限位、`26026` 密钥不存在或不属于该账号、`26027` 服务账号已停用 / 已删除、`26028` 分享链接无效 / 已撤销 / 已过期(WS 与 share-link 端点)、`26029` 服务账号不能作为资源侧授权主体、`26030` 凭据校验依赖不可用(503,Redis / DB 抖动 fail-closed)、`26031` 端点未登记权限位(500,D3 完整性兜底)。`26013 / 26014` 不复用;`26005–26007 / 26010 / 26016` 随 F050。**信封形状**:`{status_code, status_message, data}`(与平台一致),差别只在 v2 面 HTTP 状态真实(D2)。 +- **原因**:C5;E1 重跑登记命令确认 260 空;三语文案只落 `packages/locales/src/api_errors/{zh-Hans,en,ja}.json`(K12)。 +- **何时该重新考虑**:F050 / F058 落码时若与草案号冲突以先落码者为准并回写此表。 + +### 决策 11(D11):审计 = `AuditLogDao.ainsert_v2` + `open_api.*` 命名空间 + 四处 lockstep;HTTP 逐调用审计不进本期 + +- **备选**:新建独立审计表 vs **复用审计 v2 结构化字段**(`database/models/audit_log.py:117-158`:`action` String(64) / `target_type` / `target_id` / `metadata`) +- **选定**:复用。事件(AC-12 / 65 / 58):`open_api.service_account.{create,update,enable,disable,delete}`、`open_api.api_key.{issue,update,revoke,revoke_all,expire,invalidate_by_subject}`(`metadata` 含掩码 / 权限位 / 触发原因;**明文永不出现**)、`open_api.grant.{add,update,remove,remove_all}`(含前后档位)、`open_api.share_link.{revoke,expire}`、`open_api.ws.connect`(每次 WS 建连一条:凭据种类、密钥 id 或 share_link id、执行主体、资源;AC-58 的"审计记录 share-token 标识与分享创建者"落此)。`operator_id=0` 时 DAO 自动 `operator_name='system'`(`:387-402`)——到期 / 主体停用触发的失效事件用它。**到期事件的触发者(AC-12「到期自动失效」;过期在 D2 是被动判据,需要有人写 `revoke_reason='expired'` 与审计)——双通道、同一幂等闸**:① **惰性**:凭据校验判到 `expires_at <= now` 拒绝时,附带一条单行 `UPDATE api_credential SET revoke_reason='expired' WHERE id=:id AND revoke_reason IS NULL`(`revoked_at` **保持 NULL**,让"到期"与"人工撤销"在列上可区分;K3 有效性判据不受影响),`rowcount==1` 才写审计 `open_api.api_key.expire`(多节点并发下只有一个赢,天然去重);② **兜底**:Celery Beat 任务 `bisheng.worker.open_api.tasks.expire_credentials`(默认队列,每小时;`Settings.celery_task.beat_schedule` 缺省注入,先例 `core/config/settings.py:176-189`)在 `bypass_tenant_filter()` 下枚举 `expires_at <= now AND revoke_reason IS NULL`,逐行 `set_current_tenant_id(row.tenant_id)` 后做同一条单行 UPDATE + 审计(backend AGENTS.md「Beat × 多租户」陷阱:跨租户枚举查询在 bypass 内、写入按行切租户;同 `worker/tenant_reconcile/tasks.py:55-80` 写法)——覆盖"到期后再没人调用"的密钥。114 若未起 Beat(memory:默认 worker / Beat 曾缺)惰性通道仍保证被拒时有记录;列表页「已过期」状态按 `expires_at` 现算,不等审计。**四处 lockstep**(E1 §5.2):后端 `_UI_VISIBLE_V2_ACTIONS`(`audit_log.py:178-201`)+ `_V2_NAMESPACE_TO_ACTION_PREFIX`(`:207-211`,加 `open_api.`);前端 `platform/controllers/API/log.ts` `actions :53-108` 与 `getModulesApi :35-51`;i18n `bs.json` `log.systemIdEnum` / `log.eventTypeEnum` 三语。**HTTP 逐调用审计**(PRD §4.8.1 R7)本期只落结构化日志行 `open_api.call`(key_id / subject / endpoint / status / latency),审计表化随 F050 双归属一并做。 +- **何时该重新考虑**:F050 落审计双归属时统一升级为表记录;审计量级让 `audit_log` 单表承压(那时按月分区或专表)。 + +### 决策 12(D12):`default_operator` / `enable_guest_access` 移除与 6 端点删除的对外表现 + +- **备选**:A. 保留键但 no-op + 启动告警;B. 发现残留键即拒绝启动;C. **删 yaml 键(`initdb_config.yaml:35-38`)+ 删全部读取点(后端 `open_endpoints/domain/utils.py:36/59`、`assistant.py:264/284`、`flow.py:21`、`chat.py:87` 随文件删;前端 i18n `api.noLoginLinkDescription` 三语 + `ChatLink.tsx:106-108`;`docs/api/filelib-retrieve.md:25-28/297-298` 两处错误引导;`ApiAccess.tsx:57-58` 示例 `api_key="empty"` → `bs-sak-…`;测试注释 `test/e2e/test_e2e_knowledge_resource_unified.py:4`)** +- **选定**:**C**。对外表现:存量 DB `config` 表里残留键**无害**(`get_from_db` 缺消费者、`merge_old_config :301-313` 只合并新键),不写清理脚本(AC-51 零迁移);`GET /assistant/info`、`/assistant/list`、`GET /flows/{id}` 从"guest 开关 + 固定身份"变为"密钥 + `assistant:read` / `workflow:read`",guest 分享页改走 D8;6 端点真 404;无密钥调用一律 401(AC-50)。发布说明四项由 tasks 交付。 +- **原因**:A 正是 PRD §4.9 否决的"死配置误导运维";B 拿 DB 键做启动闸没有意义(它不是 Settings 顶层键,`load_settings_from_yaml` 根本不看它)。 +- **何时该重新考虑**:只有 PRD §4.9「平台侧零迁移 / 无兼容窗口」决议本身被产品推翻(INV-27 改写)时;否则不变。 + +### 决策 13(D13):MVP-114 首波边界 = "建号 → 签发 → whoami → `app:manage` 判定"纵切 + 结构性封口;既有 v2 面收口整体排第二波(tasks 里以 `[MVP-114]` 排最前) + +- **备选**: + - A. 按模块顺序(先做完全部 v2 端点接入再做管理界面)— 纵切剧本步 1–2(`mvp-114-path.md`)在 v2 接入完成前无法演示,且 F053 / F055 首波拿不到 `Depends` 工厂 + - B. 只做能演示的最小面、其余"以后再说" — 首波部署到 114 后存在"服务账号可被资源侧授权 / 可被选人"的空窗,INV-29 在开发环境就先破 + - C. **纵切 + 结构性封口先行**:首波交付纵切所需件 + 所有"不做就会在 114 留下漏洞"的结构性拒绝(选人 DAO 排除、资源侧授权主体拒绝、登录守卫),把"既有 v2 面 36+2 端点收口"整体推到第二波 +- **选定**:**C** +- **首波(Wave 1)**:`open_api` 模块骨架 + `api_credential` / `service_account` 表 + `user.user_type` 迁移 + 凭据服务(生成 / 哈希 / 掩码 / 有效性 / 软撤销 / 批量撤销 / 缓存与主动失效 / 最后使用节流 / 到期惰性记录)+ **router 级依赖与 `Depends` 工厂**(可挂在 F053 / F055 新 router 上)+ `OPEN_API_SCOPES`(含三扩展位与 `chat:invoke` 待开放位)+ `open_platform` 开关三段式 + `GET /api/v2/auth/whoami` + 服务账号 CRUD / 启停 / 删除 + 登录公共守卫 + **三处结构性封口**:`/user/list` DAO 排除、`grant_subject_service.list_candidate_users` 过滤、`canonical_source` 拒绝服务账号主体(D7 前三项)——有它们在,Wave 1 期间服务账号名下**结构性不可能**出现任何授权行(主体侧入口未建、资源侧被拒、D5 接缝未开),因此 **Wave 1 的删除 = 撤销全部密钥 + 置 `deleted_at` + 审计,无需反查 REMOVE**;`26012 / 2600x` 错误码与三语 + platform 「服务账号」tab(列表 / 新建 / 详情概览 / API 密钥 tab:签发 / 编辑 / 撤销 / 批量撤销 / 一次性展示)+ 审计事件(账号 / 密钥两族)。 +- **第二波(Wave 2,release 必做)**:38 端点标记接入 + 6 端点删除 + `get_default_operator*` 删除 + `resolve_operator` 收紧 + WS 双凭据 + watchdog + share-link 撤销 / 有效期 / `share_scope` / 三个作用域端点 + `/app-shares` 管理端点 + platform `ChatLink` 与 client guest 页改造 + 「资源授权」tab(`subject_api.list_subject_grants` 反查 / 授予 / 撤销 / 全部撤销 / AC-64 提示)+ **删除流程升级为"反查 → 逐资源 REMOVE → 撤销密钥 → 置 `deleted_at`"且删除弹窗按 AC-48 列全部授权(含回授项)** + D5 回授接缝(三条创建路径)+ AC-37 / 38 / 39 / 40 四处缺陷修复 + `default_operator` / `enable_guest_access` 移除与文档 + 对账豁免与配额排除 + 管理接口拒绝(AC-22 矩阵其余项)+ 到期 Beat 兜底任务 + 授权页 / share-link / WS 审计事件 + 前端 `ApiAccessFlow.tsx` i18n 偿债。 +- **原因**:纵切剧本步 1–2 只需要"建号 → 签发 → CLI 用密钥 login(`whoami`)→ `app:manage` 判定";三处封口成本极低(各一行条件 + 一个显式参数)却让 Wave 1 的删除语义自洽、INV-29 在 114 上不留空窗;Wave 2 全部是既有 v2 面的收口,与纵切正交但 INV-27 要求发版前必做。 +- **何时该重新考虑**:F053 / F055 首波提前需要 `resource_owner_user_id`(deploy 的 owner)→ 已在 Wave 1(伴生表字段随建号即有);不需要调整。 + +--- + +## 4. 系统现状(接手必读;实现后按现状覆盖) + +### 4.1 数据流 + +**A. 管理面(platform,`/api/v1`,JWT cookie)** + +`租户管理员进 /sys「服务账号」tab → POST /api/v1/service-accounts(名称 / 描述 / 资源归属人)→ ServiceAccountService.create:校验归属人为本租户已启用自然人(26021)→ 单个 async session 内 add(user: user_type='service', source='service_account', external_id=NULL, password=哨兵) → flush 取 user_id → add(service_account: tenant_id 取管理员当前租户 / admin-scope) → add(user_tenant: status='active' ∧ is_active=1) → 一次 commit(D1「建号事务」;不调 UserDao.create_user / aactivate_user_tenant)→ commit 后审计 open_api.service_account.create → 前端直达详情·API 密钥并打开签发表单(AC-43)→ POST /api/v1/service-accounts/{id}/keys(名称 / 权限位 / 过期)→ CredentialService.issue:secrets.token_urlsafe(32) → 明文 = 'bs-sak-' + 43 字符 → sha256 → 写 api_credential(tenant_id / subject_kind='service_account' / subject_id / key_prefix / last4 / token_hash / scopes / expires_at)→ 响应一次性返回明文 → 审计 open_api.api_key.issue → 前端 KeyRevealDialog(复制 + 「我已保存」)` + +**B. 开放面(`/api/v2`,Bearer)** + +`调用方 Authorization: Bearer bs-sak-… → HTTP 中间件(读 Bearer、JWT 解不开、预种租户 1、放行)→ router 级 verify_open_api_access(open_api/api/dependencies.py,挂在 api/router.py:94):Redis oapi:cred:{sha256} 命中?否 → bypass_tenant_filter 下查 api_credential + 主体 + 租户 → 恒时比较 / 未撤销 / 未过期 / 主体活跃 / 租户一致 → 回填缓存 TTL 3s → 每请求:expires_at 比对 + DISABLED_TENANT_KEY 黑名单 → set_current_tenant_id(密钥租户) + set_visible_tenant_ids({leaf,1} / {1}) → 直接构造 UserPayload(is_global_super=False, user_role=[])→ ContextVar 主体 → 读 conn.scope['endpoint'] 上的 @open_api_scope 标记 → 缺位 26003 / 无标记 26031 → 身份头存在 26004 → 最后使用节流写 → 端点体 Depends(get_open_api_login_user) 取身份 → 业务 Service → F048 require/check_business_action(资源级,失败回业务既有错误码,与 26003 可区分 AC-36;FGA 不可用 → 503 AC-34)→ 创建类端点经 D5 接缝(owner=归属人 + 回授)→ 响应` + +**C. WS 分支**(`/api/v2/workflow/chat/{id}`、`/api/v2/assistant/chat/{id}`) + +`握手 → 同一依赖,isinstance(conn, WebSocket):Authorization 头(服务端集成)或 ?share_token=(guest 页)二选一(同时给以密钥为准、share_token 忽略并记日志)→ 密钥分支同 B(位 = workflow:invoke / assistant:invoke);share-token 分支:ShareLinkService.get_share_link_by_token(bypass 查、status ACTIVE、未过期)→ share_scope=='app' ∧ resource_id == 路径 id → 以创建者直接构造 UserPayload + set_current_tenant_id(share.tenant_id) → 失败一律 WebSocketException(1008, reason=码) → 成功 accept → 审计 open_api.ws.connect → watchdog 每 3s 复查(撤销 / 过期 / 停用 → close(1008))→ dispatch_client(common/chat/manager.py 既有逐消息 use 校验保留)` + +**D. share-link 作用域 HTTP**(guest 页信息 / 历史 / 标题) + +`GET /api/v1/share-link/{token}/resource | /chat/history | POST /chat/gen_title → header/查询 token → 同上校验 → 创建者身份 + 租户 → 只放行 share.resource_id 对应资源 / 该 flow_id 下的会话 → 响应` + +**E. 撤销 / 停用 / 删除的失效链** + +`管理端点单行 ORM 更新(revoked_at / disabled_at / deleted_at)→ 同一请求内按 DB 清单 adelete 每个 oapi:cred:{hash} → 审计 → 最坏 3s 内所有节点新请求被拒;已建 WS 由 watchdog ≤ 3s 断开` + +### 4.2 关键数据结构 / 字段约定(对外可见契约) + +| 字段 / 结构 | 类型 / 格式 | 说明 | 谁会消费 | +|---|---|---|---| +| `Authorization: Bearer bs-sak-<43 位 urlsafe-b64>` | HTTP / WS 握手请求头 | 唯一密钥传递方式;查询参数**不接受**密钥(会进访问日志) | 全部 `/api/v2/**`、F051–F053 三面、F055 应用运行期凭据 | +| `?share_token=<32 位>`(WS)/ `share-token` 头或路径段(HTTP) | 查询参数 / 请求头 | 仅两个 WS 与 `/api/v1/share-link/{token}/*` 接受 | client guest 页 | +| 密钥前缀 / 掩码 | `bs-sak-`;掩码 = `bs-sak-********` + 末四位(8 个 `*`) | AC-02 / 54:前缀模式公开给 secret scanning 与 F055 扫描规则集:`\bbs-sak-[A-Za-z0-9_-]{43}\b` | 列表 / 详情 / 审计 metadata / F055 | +| `api_credential` 行(对外只经 API 暴露掩码字段) | `id` int PK · `tenant_id` · `subject_kind` VARCHAR(32)(`service_account` / `hosted_app`)· `subject_id` VARCHAR(64) · `name` · `key_prefix` VARCHAR(16) · `last4` VARCHAR(4) · `token_hash` VARCHAR(64) unique · `scopes` JsonType(list[str]) · `expires_at` / `revoked_at` / `last_used_at` DateTime nullable · `revoke_reason` VARCHAR(32)(`manual` / `batch` / `subject_disabled` / `subject_deleted` / `expired`)· `created_by` · `create_time` / `update_time` | 有效 = `revoked_at IS NULL ∧ (expires_at IS NULL ∨ expires_at>now)`(K3);注册进 `_TENANT_AWARE_MODEL_MODULES` | CredentialService / F055(`subject_kind='hosted_app'`)| +| `service_account` 行 | `user_id` PK(FK user) · `tenant_id` · `resource_owner_user_id` · `description` · `created_by` · `disabled_at` / `deleted_at` nullable · `create_time` / `update_time` | 名称 = `user.user_name`;启用 = 两时间戳皆空 | 管理端点 / 凭据校验 / F053 / F055(取归属人)| +| `user` 新增列 | `user_type` VARCHAR(16) NOT NULL server_default `'human'`,索引 | Alembic revision;`service` = 服务账号 | DAO 过滤 / 登录守卫 / 对账 / 配额 | +| 权限位标识 | `workflow:invoke` `workflow:read` `knowledge:read` `knowledge:write` `assistant:invoke` `assistant:read` + 扩展位 `model:invoke` `identity:read` `app:manage`(组 `local_dev_toolkit`,仅 `open_platform.enabled`)| 字符串常量,`OPEN_API_SCOPES` 唯一来源;`GET /api/v1/service-accounts/scopes` 返回当前部署可见清单(含分组 / 说明 key / 覆盖端点) | 签发 / 编辑表单、F051–F053 判位 | +| `UserPayload.open_api_principal` | `{credential_id, subject_kind: 'service_account'|'share_link', subject_user_id, resource_owner_user_id, share_link_id, scopes}`,普通登录用户为 `None` | 业务 Service 据此取归属人(D5);会话 / 审计仍用 `user_id` | 知识库创建、F053 deploy owner、审计 | +| `GET /api/v1/env` | 新增 `open_platform_enabled: bool` | 三扩展位显隐(AC-13 / 49) | platform `appConfig.openPlatformEnabled`、client(备用)| +| Settings | `open_platform.enabled`(默认 false);`open_api.service_account_idle_days`(默认 90,AC-42);`open_api.credential_cache_ttl_seconds`(默认 3,上限 5) | 进程级 config.yaml | 后端 | +| 管理端点(`/api/v1/service-accounts`,依赖 `open_api/api/dependencies.py get_service_account_admin`:内部调 `UserPayload.get_tenant_admin_user`(`common/dependencies/user_deps.py:50-75`,超管 ∪ 当前租户 Child Admin 的唯一既有判定)并把它抛出的 `LLMModelSharedReadonlyError`(**19801,文案「Root-shared LLM server/model is read-only…」,`common/errcode/llm_tenant.py:9-13`**——直接复用会让 AC-41 / AC-59 的拒绝在前端显示 LLM 只读文案)**改抛 `UnAuthorizedError`(`common/errcode/http_error.py:4`,信封 `status_code=403`)**;platform 拦截器 `controllers/request.ts:160` 对信封 403 统一处理,业务代码不再加分支。**AC-59「返回 403」的满足口径 = 信封 `status_code=403`,HTTP 状态仍为 200**(`/api/v1` 全局 `handle_http_exception` 语义不改,K4 只对 `/api/v2` 真实化)——E2E 断言信封码而非 HTTP 状态) | `GET /`(分页 `PageData`,列:名称 / 状态 / 有效密钥数 / 归属人(含禁用标记)/ 最后调用 / 创建人 / 创建时间;`idle_days` 阈值随响应 meta)· `POST /` · `GET /{id}` · `PATCH /{id}`(名称 / 描述 / 归属人)· `POST /{id}/enable` · `POST /{id}/disable` · `DELETE /{id}` · `GET /{id}/keys` · `POST /{id}/keys`(唯一返回明文处)· `PATCH /{id}/keys/{key_id}`(名称 / 权限位 / 过期)· `POST /{id}/keys/{key_id}/revoke` · `POST /{id}/keys/revoke-all` · `GET /{id}/grants`(分页、按 `resource_type` 筛,行形状 = `PermissionGrantAssignee` 前端既有类型 + `source_label`)· `POST /{id}/grants:mutate`(`{changes:[{resource_type,resource_id,op:ADD|MOVE|REMOVE,model_key,expected_assignee_version?}]}` 逐条结果)· `POST /{id}/grants:revoke-all`(排除回授)· `GET /scopes`(`data = {scopes: [{code, group, label_key, desc_key, endpoints:[{method,path}], requires_open_platform, pending_note_key, hint_keys}], open_platform_enabled}`);`DELETE /{id}` 的 `data = {id, grants: []}`(Wave 1 恒空,T065 填充);`GET /` 的 `data = {data:[…], total, idle_days}` | 路径加入 `MANAGEMENT_API_PREFIXES`(`common/middleware/admin_scope.py:63-71`)以让超管 ScopeBar 生效(AC-23 "租户取当前管理员所在租户"在多租户下唯一入口)——这是 F019 元组的一次可审计扩展 | platform `controllers/API/serviceAccount.ts` | +| share-link 增量 | 匿名(豁免前缀下):`GET /api/v1/share-link/{token}/resource`、`GET /api/v1/share-link/{token}/chat/history?chat_id=`、`POST /api/v1/share-link/{token}/chat/gen_title`;登录态(**非豁免前缀** `/api/v1/app-shares`):`GET /api/v1/app-shares?resource_type&resource_id`(列表,只返回 `share_scope=app`)、`POST /api/v1/app-shares/{id}/revoke`;生成请求新增 `share_scope`(默认 `session`);`expire_time` = 相对秒、`app` 必填 > 0 | 既有 `generate_share_link` / `GET /{token}` 不变 | platform `ChatLink`、client | +| `GET /api/v2/auth/whoami` | 返回 `{subject_kind, service_account:{id,name}, tenant_id, scopes, key_mask, expires_at}` | 标记 `@open_api_scope(None)`(不判位,但必须带标记);F053 `login` 校验点 | F053 CLI、114 手动验证 | +| 审计 action | `open_api.service_account.*` / `open_api.api_key.*` / `open_api.grant.*` / `open_api.share_link.*` / `open_api.ws.connect` | `metadata` 永不含明文 | 系统操作日志页 | +| 错误码 | 见 D10;`/api/v2` 上 HTTP 状态真实(401 / 403 / 404 / 503),`/api/v1` 管理端点仍 HTTP 200 + 信封(AC-59 的「403」= 信封码 403) | 信封 `{status_code, status_message, data}` | 调用方 / 前端 | + +### 4.3 关键模块职责 + +| 模块 / 文件 | 职责 | 不做什么 | +|---|---|---| +| **`open_api/`(新模块)** `api/router.py`(`/service-accounts` 挂 `/api/v1`;`/auth/whoami` 挂 `router_rpc`)· `api/exception_handlers.py`(`/api/v2` 专属 handler 的函数体,`main.py` 只调 `register_open_api_exception_handlers`)· `api/endpoints/service_account.py` · `api/endpoints/service_account_keys.py` · `api/endpoints/service_account_grants.py` · `api/dependencies.py`(`verify_open_api_access` router 级依赖 + `open_api_subject(scope=…)` 依赖工厂(返回可调用对象,用法 `Depends(open_api_subject(...))`)供 F053 / F055 新 router)· `domain/models/{api_credential,service_account}.py` · `domain/services/{credential_service,credential_validator,service_account_service}.py` · `domain/scopes.py`(`OPEN_API_SCOPES` + `@open_api_scope` 标记)· `domain/context.py`(ContextVar)· `domain/schemas/` | 凭据全生命周期、服务账号 CRUD、权限位判定、主体侧授权页读端点与 mutate 编排、审计事件 | 不写 OpenFGA、不自建授权镜像、不判具体资源权限(交 F048)、不含 UI 文案 | +| `open_endpoints/`(既有 v2 业务端点) | 8 个路由文件加 `@open_api_scope` 标记、身份改 `Depends(get_open_api_login_user)`;`chat.py` 删除;`domain/utils.py` 只剩 `get_open_api_login_user` + 收紧后的 `resolve_operator`;`api/dependencies.py:61-70 get_knowledge_space_chat_service_for_openapi` 死函数删除;`endpoints/workflow.py invoke_workflow` 加 `FlowStatus.ONLINE` 守卫(AC-37);`endpoints/filelib.py download_statistic_file` 改 `file_name` + 固定目录 realpath 校验(AC-40) | 不再解析任何身份、不读配置身份、不 import `open_api/api/*` | +| `bisheng/api/router.py:94` | `router_rpc = APIRouter(prefix='/api/v2', dependencies=[Depends(verify_open_api_access)])`;注册 `open_api` v1 router | — | +| `main.py` | 在 `create_app` 内调 `register_open_api_exception_handlers(app)`(注册 `OpenApiAuthError` 与 v2 上 `PermissionServiceUnavailableError`(19002)/ `PermissionBackendUnavailableError`(19201)→ 503) | 不改 `handle_http_exception` 全局语义;handler 函数体在 `open_api/api/exception_handlers.py` | +| `common/errcode/open_api.py` | 260xx 定义 | — | +| `core/config/open_platform.py` + `settings.py` + `api/v1/endpoints.py get_env` | 开关三段式后端两段 | — | +| `user/domain/models/user.py`(`_filter_users_statement` 默认参数、`alist_users_after_id` 加类型条件、`aget_login_candidates_by_account` 第二道锁)· `user/domain/services/user.py`(`_reject_login_if_user_has_no_usable_access` **最前**拒绝 SA → 26012;`assert_natural_persons`)· `user/api/user.py:663`(`/user/update` 拒绝 SA) | 服务账号的登录 / 选人 / 对账免疫 | 不改 `TenantResolver`、不改 guest 兜底三条路径 | +| `permission/`(`grant_source_service.py` 新 `SERVICE_ACCOUNT_AUTOGRANT`;`runtime.authorize_created` 回授分支;`application/subject_api.py` 主体反查;`f048_permission_subject.canonical_source` 拒 SA 主体除非显式允许;`grant_subject_service.list_candidate_users` 过滤) | 授权真相 | 不认识"服务账号"业务对象,只认 `user_type` 与显式参数 | +| `knowledge/domain/services/knowledge_service.py`(`acreate_knowledge_base :795-799` / `create_knowledge_base :741-749` 的 `user_id` 硬写处参数化;`process_one_file :1483-1544` `KnowledgeFile.user_id` 改归属人;`_new_library_permission_record :194-208` 带 `creator_autogrant_user_id`)· `knowledge/domain/services/knowledge_space_service.py`(`create_knowledge_space :1100` `user_id` 参数化 + `:1110-1117` 记录带回授字段)· `knowledge_permission_service.py:995-1000`(adapter 透传 `autogrant_user_id`) | 创建关系落归属人(D5 三条路径) | 不判归属人权限;文件 / 文件夹不回授(INHERIT) | +| `share_link/`(模型加 `share_scope`;service 强制过期 + revoke;新增 3 个匿名作用域端点(豁免前缀下);新增 `api/endpoints/share_link_manage.py` 挂 `/api/v1/app-shares`(登录态,非豁免前缀);`api/dependencies.py:30-48 header_share_token_parser` 复用) | share-token 通道 | 不出现在 v2 HTTP 面;登录态端点不落豁免前缀 | +| `worker/tenant_reconcile/tasks.py:68` ← `alist_users_after_id`;`tenant/domain/services/user_tenant_sync_service.py:76 sync_user` 入口守卫;`role/domain/services/quota_service.py:622-651 _count_user_count` 加类型条件 | 对账豁免 / 配额排除 | — | +| `worker/workflow/redis_callback.py:118-123 set_workflow_status` | 状态载荷加 `owner_user_id` / `tenant_id`(供 `/workflow/stop` 归属校验,AC-38) | — | +| `worker/open_api/tasks.py expire_credentials`(新,Beat 每小时,默认队列) | 到期密钥 `revoke_reason='expired'` 兜底写入 + 审计(D11) | 不改有效性判据、不删缓存(到期不需要) | +| `database/models/audit_log.py` + `platform/controllers/API/log.ts` + `bs.json` | 审计四处 lockstep | — | +| **platform** `pages/SystemPage/index.tsx`(新 tab,`isSuperAdmin ∪ isChildAdmin`,不含部门管理员)· `pages/SystemPage/components/ServiceAccount/`(列表 / `CreateServiceAccountDialog`(归属人 = `DepartmentUsersSelect multiple={false}`)/ 详情三子 tab / `KeyIssueDialog` / `KeyRevealDialog` / `ServiceAccountGrantsTab`)· `controllers/API/serviceAccount.ts` · `controllers/API/shareLink.ts` · `types/api/serviceAccount.ts` · `public/locales/*/serviceAccount.json` ×3(加进 `i18n.js:45` ns)· `contexts/locationContext.tsx` `openPlatformEnabled` · `components/bs-comp/apiComponent/{ChatLink,ApiAccess,ApiAccessFlow}.tsx` | 管理界面 | 不用 `react-query`(冻结);不复用 `PermissionDialog` 整体(只复用 `getGrantablePermissionModelsApi` / `SourceBadge` / `PermissionGrantAssignee` 类型);不新增 web_menu | +| **client** `pages/standaloneChat/StandaloneChatPage.tsx` · `pages/appChat/useChatHelpers.ts` · `api/apps.ts` · `api/chat/api-endpoints.ts` · `utils/shareToken.ts` | guest 页带 share-token | 不再打任何 `/api/v2` HTTP | + +--- + +## 5. 已知坑 / 反直觉事实 + +| # | 反直觉事实 | 如果不知道会怎样 | 在哪处理 | +|---|---|---|---| +| 1 | **平台把 `HTTPException` / `BaseErrorCode` 一律压成 HTTP 200 + 信封**(`main.py:22-36 handle_http_exception`,`_EXCEPTION_HANDLERS :46-51`);路由里 `raise HTTPException(403)` 客户端收到 200 | AC-01/04/33/34 的真 401/403/5xx 全部不成立,E2E 全红且调用方按 HTTP 状态判错的 SDK 全坏 | `open_api` 专属 handler(`main.py` 仿 `:165-167`);WS 用 `WebSocketException`(D2) | +| 2 | **中间件对解不开的 Bearer 预种租户 1**(`http_middleware.py:60-73 → _set_tenant_context :87-122 → :101-106`),基线写的"不注入"不准确 | 依赖漏 `set_current_tenant_id` 时多租户**静默按 Root 查**、不报 `NoTenantContextError`,跨租户串数据无任何症状 | `verify_open_api_access` 校验通过后无条件 `set_current_tenant_id(密钥租户)`;集成测试断言子租户密钥查不到 Root 数据 | +| 3 | 平台"登录入口"是 10 个,但守卫只有 1 处公共 + 3 处单独:公共 = `_reject_login_if_user_has_no_usable_access`(`user/domain/services/user.py:373`)4 个调用点(本地 `:460` / 旧 SSO `user/api/user.py:97` / login-sync `login_sync_service.py:221` / 切租户 `tenant_service.py:662`);单独 3 处 = PRD 附录 E.2 所列的 `resolve_operator`(`open_endpoints/domain/utils.py:77`,F050 前保留)、`get_default_operator*`(`:36/:59`,随 AC-52 删除)、`/user/update`(`user/api/user.py:663`,D7 断言)。**公共守卫第一段就是 `AdminRole → return`(`:394`)**,且它是"返回响应对象"式守卫 | 守卫放在 bypass 之后 → 被误授 AdminRole 的服务账号直接放行;用 `return` 通道返回 26012 → 被 `tenant_service.py:663-665` / `login_sync_service.py:228-230` 的 `else: raise UserNoWebMenuForLoginError()` 压成"无菜单"(**只有 `UserNoRoleForLoginError` 会原样透传**) | 守卫放 `:388 with bypass_tenant_filter()` 之前,**`raise`** `26012`(D10) | +| 4 | **`is_global_super` 不对称**:`init_login_user`(`auth.py:522-556`)**无条件**调 `_check_is_global_super`(`:542-543`),`init_login_user_sync`(`:558-580`)恒 False;`_check_is_global_super`(`http_middleware.py:145-207`)= Redis `user:{id}:is_super` → FGA `super_admin` Check → legacy AdminRole 回落(`:187-198`),`role_ids=[]` 只关得掉最后一段 | 走 `init_login_user` → 每个 v2 请求多 1 次 Redis / 未命中多 1 次 FGA,且服务账号"永不超管"退化成数据状态(有人写了 FGA tuple 就短路);走 sync 版 → 与其它路径不对称 | D2 **直接构造 `UserPayload(is_global_super=False, user_role=[])`**,两版都不调;D7 `assert_natural_persons` 守 `/user/role_add`(`user/api/user.py:874-965`)与 `grant_tenant_admin` 是纵深不是唯一防线 | +| 5 | **`external_id=NULL` 是隐式防线**:登录候选 `aget_login_candidates_by_account`(`user.py:212-223`)、公开改密(`user/api/user.py:1103`)、login-sync 匹配(`login_sync_service.py:263-266`)都只按 `external_id`;`user_name` 不参与;旧 SSO `/user/sso`(`user/api/user.py:67`)**按用户名**匹配 | 给服务账号写了 `external_id`(哪怕为了"看起来完整")→ 三条免疫全失;旧 SSO 同名账号可被匹配到、只剩守卫 | 创建路径固定 `external_id=NULL`;`aget_login_candidates_by_account` 加 `user_type=='human'` 第二道锁;测试断言 | +| 6 | **guest 部门自动兜底有三条路径**:启动 `_backfill_guest_department_membership`(`common/init_data.py:439-481`,判据是 `user_department` 全表任意一行 `:458-461`,整表为空时全用户入 guest)、SSO/网关推送 `_reconcile_guest_membership`(`login_sync_service.py:409`,`:202` / `sso_sync/api/endpoints/gateway_wecom_org_sync.py:123`)、自助注册 `_ensure_user_guest_department_membership`(`user.py:293/300`) | 服务账号被塞进 guest 部门 → 全局成员搜索能搜到、"无部门"前提破产;创建路径若复用 `user_register` / `add_user_and_default_role` 同时违反 AC-22 | 不改三条路径(PRD E.2 决议);创建路径**新写纯插入**(`UserDao.create_user` 或新方法);`department_service` 全局搜索加类型条件;测试断言 | +| 7 | **对账会把子租户的服务账号搬回 Root**:`worker/tenant_reconcile/tasks.py:68` 经 `alist_users_after_id`(`user.py:541-556`,只查 `delete==0`)→ 无主部门 expected=ROOT(`user_tenant_reconcile_service.py:63-64`)→ `sync_user` 搬家 + bump token_version + 重写 tenant#member | 6 小时后子租户服务账号"消失"、密钥校验租户不一致全部 401;卸载子租户(`tenant_mount_service.py:487-600`)/ 删租户(`tenant_service.py:355-397` → `adelete_by_tenant`)留下悬空主体 | `alist_users_after_id` 收口 + `sync_user :76` 入口守卫;凭据校验把"主体活跃租户存在且 status=active"纳入 fail-closed | +| 8 | **`aadd_user_to_tenant`(`database/models/tenant.py:614-628`)不写 `is_active`**,而授权主体校验(`f048_permission_subject.py:70`)与 `_count_user_count` / `aget_active_user_tenant` 都要 `is_active==1` | 建号成功、授权页整页不可用、密钥校验取不到活跃租户 | 创建用 `aactivate_user_tenant`(`:867`)语义 | +| 9 | **`/user/list` 8 处消费点 + `grant_subject_service.list_candidate_users` 独立查询**(`grant_subject_service.py:70-121`,条件 `:90`)——DAO 改一处覆盖不到资源侧选人框;`canonical_source`(`f048_permission_subject.py:68-71`)不看类型 | 资源侧弹窗仍能选到服务账号;直接 POST `grants:mutate` 仍能从资源侧授权 → INV-29 破 | D7 三处 | +| 10 | **`_filter_users_statement` 之外还有旁路**:`get_all_users :401`、`search_user_by_name :296`、`get_user_with_group_role :412`、`get_user_by_ids/aget_user_by_ids :178/:184`、`get_unique_user_by_name :290`、`aget_users_by_username :204`、`aget_by_source :557` | 以为改一处就全覆盖 | 逐个判定:名称水合**保持不过滤**(AC-16 可显示);筛选类不动;`get_unique_user_by_name` 只服务旧 SSO 靠守卫 | +| 11 | **`_count_user_count`(`quota_service.py:622-651`)会计入服务账号,但 `aget_quota` / 租户列表用户数走部门子树**(`tenant_service.py:184/223/362/410` → `acount_users_by_tenant_subtree`)结构性不含;`user_count` 配额无任何创建期强制 | 两处口径不一致、以为"配额已排除"其实只排了一半 | `_count_user_count` 加类型条件;测试两处都断言 | +| 12 | **回授不能用 `protected=True` 表达**(`grant_service.py:308/326` 禁止 mutate 触碰受保护源),也不能复用 `DIRECT`(分不出回授);`SOURCE_TYPES` 是 frozenset(`grant_source_service.py:16-26`),新值须同步 `_validate_source_subject :178` 与 `_source_locator :193`;`sync_business_source_model` 白名单(`runtime.py:647-649`)与之无关 | 回授行不可撤销(AC-63 失效)或「全部撤销」把回授一起删(切断集成写入) | D5 新 `SERVICE_ACCOUNT_AUTOGRANT` | +| 13 | **Catalog「动作↔资源类型范围表」只写一次**:`_ensure_catalog`(`permission/migration/f048_runtime_storage.py:834-912`)只写 INITIAL release 且 checksum 不符即抛;草稿发布 `_replace_children`(`catalog_api.py:769-823`)不引入新类型 | F049 本身不加资源类型故不触雷;但若把 `app:manage` 判定做成"F048 资源动作"就会撞上——F054 才处理 | `app:manage` 在 F049 只是权限位常量判定,不进 Catalog | +| 14 | **`visible_tenant_ids` 是 IN-list 不是严格隔离**:子租户管理员的可见集是 `{leaf, Root}`(`http_middleware.py:210-231`),自动过滤对管理面读取会一并放出 Root 的服务账号 | AC-07「跨租户查询返回空」不成立:子租户管理员能列出并按 id 打开 Root 的服务账号 | `ServiceAccountService.get_row / get_detail / list_page` 显式带 `tenant_id` 条件(T024),不依赖自动过滤 | +| 14b | **UNION / 子查询绕过租户过滤**:租户监听器只对顶层 SELECT 注入(`tenant_filter.py:164`);反查 SQL 若写成 UNION 或 `text()`,子查询不带 `tenant_id`;密钥表批量 UPDATE/DELETE 同样无注入 | 跨租户看到别人的授权 / 撤销别人的密钥 | 反查用单条 select + join;密钥写路径单行 ORM(K6) | +| 15 | **WS 查询参数鉴权是坏的**:`AuthJwt.get_subject` websocket 分支无条件用 cookie 覆盖传入 token(`auth.py:175-177`),`t` 参数是死路径;`headers` 分支(`:178-180`)零调用且缺头 NPE | 复用 `AuthJwt` 做密钥鉴权必崩 | 新写依赖,不碰 `AuthJwt` | +| 16 | **助手 WS 裸崩机制**:`assistant.py:304 get_default_operator()` 在 `try(:305)` 之外 → `HTTPException(500)` → 全局 handler 在 WS scope 上以 HTTP 200 denial 拒握手;工作流 WS(`workflow.py:163-174`)`close(1011)` before accept → 403 但 reason 丢 | 修成"移进 try"仍是 200 denial;在依赖里 raise `HTTPException` 也一样 | D2:身份解析全部进依赖 + `WebSocketException(1008)` | +| 17 | **`generate_short_high_entropy_string`(`common/utils/util.py:28-54`)上限约 43 字符**(sha256 → b64 截断),`length` 传更大值静默变短 | 以为签出 64 位密钥其实 43 位(仍够强,但文档口径错) | 用 `secrets.token_urlsafe(32)`(43 字符,同等强度);share_link 继续用原函数 | +| 18 | **`share_link` 语义今天是"会话级"**:`ShareChat.tsx:59` 传 `resource_id=chatId`,`workflow`/`assistant` 类型的 `resource_id` 是会话 id;5 个后端消费点按 `resource_id == chat_id` 校验;`expire_time` 单位 / 绝对相对未定;`status` INACTIVE 无写入端点;`share_token` 存 CHAR(36) 实写 32 位(DM8 靠去空格补丁) | 直接复用两枚举值做应用级分享 → 与既有会话分享冲突;改 `SQLEnum` 撞 MySQL ENUM ALTER | D8 `share_scope` 列 + 相对秒语义 + revoke 端点 | +| 19 | **`withdraw_instance` 无终态守卫**(`approval/domain/services/approval_center_service.py:418-489`,`:427-431` 只查存在与申请人)与**租户配额整体覆盖写**(`tenant_service.py:419-435 aset_quota`;前端 `TenantQuotaDialog.tsx:57-61` 只序列化 `storage_gb`)——都在 F049 探查中被核实但**不属本 Feature** | 顺手去修 → 越界;不知道 → F055 撤回 / 租户配额页踩雷 | 登记给 F055(契约表 3)与 discovery §5 已知缺陷;F049 不碰 | +| 20 | **DM8 写放大**:`api_credential.last_used_at` 若每请求 UPDATE,DM8 undo 会被高频调用撑爆(同灵思 -7120 事故形态) | 生产 DM8 环境高频集成把库写死 | 60 秒 `SET NX` 闸 + 单行 UPDATE(AC-10 允许 60 秒合并) | +| 21 | **F048 `check` 曾被刻意从硬 fail-closed 改成兜底**的只剩 `_check_is_global_super`(`http_middleware.py:187-198`)一处,判定链本身无 owner/creator 兜底(`permission_action_service.py:125-166`) | 以为"资源没授权也能兜底看到"→ 服务账号零授权时一切 v2 调用都失败是**正确行为**(授权页空态文案要说清) | 授权页空态 + AC-64 提示 | +| 22 | **`config.yaml` 顶层未知键拒绝启动**(`config_service.py:104-106`) | 114 先加 `open_platform:` 键再发代码 → 后端起不来 | 部署顺序:先发代码、再加键、再重启(tasks 部署步骤) | +| 23 | **admin-scope 只对 `MANAGEMENT_API_PREFIXES` 生效**(`admin_scope.py:63-71`);超管默认在 Root、无租户管理员档,多租户下超管给子租户建号只有 ScopeBar 一条路 | 不加前缀 → ScopeBar 切换无效、创建落 Root;加了 → 是 F019 元组的扩展(审计项) | 把 `/api/v1/service-accounts` 加进元组并在 tasks 登记 | +| 24 | **platform `react-query` 已冻结**(`eslint.config.mjs:45,51`),`ApiAccessFlow.tsx` 冻结 188 条中文(`eslint-suppressions.json`),改它就要整文件 i18n | 新页面照抄旧页 import 即 lint 失败;改示例文本被 CI 拦 | 新页用 `useTable` / `useState+useEffect`;`ApiAccessFlow.tsx` 改动单列 i18n 工时 | +| 25 | **`resolve_operator` 的租户种子来自调用方指定的目标用户**(`open_endpoints/domain/utils.py:96-97`) | F049 期保留该函数若不加"目标活跃租户 == 密钥租户",一把密钥可跨租户取数 | D3 收紧 | +| 26 | **`TENANT_CHECK_EXEMPT_PATHS` 是 `startswith` 前缀**(`http_middleware.py:311`)且命中即整链 `_bypass_tenant_filter=True` + 跳过 token_version + 跳过租户禁用检查(`:322-343`);`/api/v1/share-link` 在列(`:39`),既有登录态 `generate_share_link`(`share_link/api/endpoints/share_link.py:12-17`)已在此暴露面下运行 | 把撤销 / 列表放在任何 `share-link*` 前缀下 → 列表在 bypass 下跨租户可见、撤销不受 token_version 保护 | D8:登录态端点挂新的非豁免前缀 `/api/v1/app-shares`;匿名作用域端点留在豁免前缀(它们需要 bypass);`generate_share_link` 既有暴露只登记不搬 | +| 27 | **INHERIT 起始模式的资源上非 protected 本地行是无效的**:`control_state.py:355 protected_only=normalized_mode=="INHERIT"`、`mode_service.py:72-76`;`folder / knowledge_file` 起始 INHERIT(`resource_lifecycle_policy.py:14,29-33`);`_validate_copy`(`owner_service.py:236-239`)对 `RESOURCE_CREATE` 见 `copy_grants` 即抛 | 给服务账号上传的文件也写回授行 → 行落账本却不生效(或被 `_validate_copy` 拒创建);复用 `copy_grants` 携带回授 → 创建直接 `PermissionInvalidResourceError` | D5:新 `extra_grants` 字段、INHERIT 目标忽略回授、文件写权限来自父库授权 | + +--- + +## 6. 对外契约与依赖 + +### 6.1 我提供给别人的(Outgoing) + +| 契约 | 形式 | 谁在用 | +|---|---|---| +| `Authorization: Bearer bs-sak-…` 凭据格式、前缀模式 `\bbs-sak-[A-Za-z0-9_-]{43}\b`、掩码格式 | 对外接口文档 + secret scanning 规则 | 调用方、F055 发布前扫描 | +| `/api/v1/service-accounts/**` 管理端点(§4.2) | HTTP API(JWT,租户管理员及以上) | platform 服务账号页 | +| `GET /api/v2/auth/whoami` | HTTP API(Bearer) | F053 CLI `login`、114 验证 | +| `verify_open_api_access`(router 级)与 `open_api_subject(scope: str | None, modes=…)`(`Depends` 工厂)、`@open_api_scope(...)` 标记、`get_open_api_login_user` | 内部 Python API(`bisheng.open_api.api.dependencies` / `bisheng.open_api.domain.scopes`) | F050(加 delegate 分支)、F051 模型面、F052 MCP 面、F053 CLI 端点、F055 部署管线端点(`subject_kind='hosted_app'`)、F058 | +| `CredentialService.issue / revoke / revoke_by_subject / update`(接受 `subject_kind='hosted_app'` 存储 / 签发 / 回收)+ `credential_validator.SUBJECT_RESOLVERS` 注册点 | 内部 Python API | F055 应用运行期凭据自动签发 / 回收;**F055 须自行注册 `hosted_app` 解析器**(执行身份 / 租户 / `subject_user_id` 取谁由 F055 定义),未注册前该 kind 在 `/api/v2` 上按 `26002` 拒绝(D2「主体解析器」) | +| `OPEN_API_SCOPES` 常量与三扩展位标识 | 常量 | F051–F053 判位;签发表单 | +| `settings.open_platform.enabled` / `GET /api/v1/env.open_platform_enabled` / `appConfig.openPlatformEnabled` | 配置三段式 | F051 / F052 / F053 显隐与拒绝 | +| `UserPayload.open_api_principal`(含 `resource_owner_user_id`) | 内部数据契约 | 知识库创建、F053 / F055 deploy owner | +| `SERVICE_ACCOUNT_AUTOGRANT` 来源值、`subject_api.list_subject_grants` | 内部 Python API(permission.application) | 主体侧授权页;F050 授权页「delegate 提示」 | +| share-link 增量:`share_scope`、`POST /app-shares/{id}/revoke` / `GET /app-shares`、`/share-link/{token}/{resource|chat/history|chat/gen_title}`、WS `?share_token=` | HTTP / WS 契约 | client guest 页、platform `ChatLink` | +| 审计 action 命名空间 `open_api.*` | 数据契约 | 审计页 | +| 错误码 260xx | 数据契约(三语) | 前端 / 调用方 | + +### 6.2 我依赖别人的(Incoming) + +| 依赖 | 形式 | 风险点 | +|---|---|---| +| `user` 表 + `UserDao`(`_filter_users_statement :259`、`aget_login_candidates_by_account :212`、`alist_users_after_id :541`、`aget_user_by_ids :184`)与 `LoginUser` pydantic 模型(`auth.py:196`,直接构造,不经 `init_login_user`) | ORM / 内部 API | `LoginUser` 字段增删(`is_global_super` / `user_role` 默认值);建号不复用 `create_user`(D1 单事务) | +| `user_tenant`(`aactivate_user_tenant :867`、`uk_user_active :185`、`aget_active_user_tenant`) | ORM | 对账 / 挂载 / 卸载路径新增调用点未过守卫 → 服务账号被搬家 | +| F048 权限运行时:`authorize_created`(`runtime.py:206-260`,本 Feature 加 `autogrant_user_id` kwarg 与 `OwnerProjectionContext.extra_grants`)、`mutate_grants :603-623`、`resource_api.mutate_grants`、`canonical_source`、`grant_source_service`、`ix_perm_assignee_subject_state`、`check/require_business_action`(`business_authorization.py:27/48/64`)、`grantable_models` | 内部 Python API + 表结构 | 索引被删 → 反查全表扫;`SOURCE_TYPES` 校验收紧漏掉新值 → 回授失败即创建 `FAILED_CLOSED`(资源变砖);ListObjects 策略变化不影响我 | +| `share_link` 模型 / `ShareLinkService.get_share_link_by_token`(bypass 先例)/ `header_share_token_parser` | ORM / 内部 API | 别人把 `expire_time` 语义改成绝对时间戳 → 我的强制过期算错 | +| Redis(`core/cache/redis_manager.get_redis_client`,`aset` pickle + `setex`、`asetNx`、`adelete`) | 基础设施 | Redis 不可用 = 开放面整体 503(K2 刻意);`akeys` 用 `KEYS`(`redis_conn.py:196-203`)不可用于批量、我不依赖它 | +| MySQL / DM8(Alembic 加列;新表 create_all;`JsonType`) | 基础设施 | DM8 部分 NULL 唯一(D1);DM8 CLOB 不能按位过滤 | +| `AuditLogDao.ainsert_v2`(`audit_log.py:366-430`) | 内部 API | 签名变更;`_UI_VISIBLE_V2_ACTIONS` 未同步则页面不显示 | +| `Settings` / `config_service.load_settings_from_yaml`(未知键拒启)、`get_env` | 配置 | 部署顺序(坑 22) | +| FastAPI 0.121 / Starlette 0.49.3(router 级依赖传播到 WS 路由;`scope["endpoint"]`;`WebSocketException`)、uvicorn 0.38 | 框架 | 升级改变任一 → D2/D3 需复核(单测枚举路由可及早发现) | +| `common/middleware/admin_scope.py MANAGEMENT_API_PREFIXES` | 常量 | 我加一项;F019 审计口径 | +| platform:`bs-ui`(dialog / table / tabs / checkBox / alert / tooltip / calendar)、`DepartmentUsersSelect`、`useTable`、`bsConfirm`、`copyText`、`getGrantablePermissionModelsApi` / `mutateResourceGrantsApi` 类型、`SourceBadge`、`appConfig` | 组件 / API 封装 | `PermissionGrantAssignee` 类型变化 → 授权 tab 行形状漂移;`DepartmentUsersSelect` 走 `/user/list` 或全局成员搜索两条路径都必须已排除服务账号 | +| client:`StandaloneChatPage` guest 模式、`useChatHelpers`、`api/apps.ts` | 页面 | guest 页若被别的改动重新指回 `/api/v2` HTTP → 升级后白屏 | +| `docs/api/filelib-retrieve.md`、`ApiAccess.tsx` 示例 | 文档 | AC-54 | + +--- + +## 7. 测试与可观测 + +**分层策略**(不重复 tasks 清单): + +- **单元**(`test/open_api/`):凭据生成 / 哈希 / 掩码 / 有效性判据(撤销 · 过期 · 边界时刻);`OPEN_API_SCOPES` 与三扩展位可见性随 `open_platform.enabled`;**路由完整性**——枚举 `app.routes` 下 `/api/v2/**`,每条都带 `@open_api_scope`,位在注册表内或为 `None`(`None` 只允许 `/api/v2/auth/whoami` 白名单)、注册表可含无端点的位(`chat:invoke`)、6 个 `/chat/*` 路径不存在;`download_statistic` 的 `file_name` 校验(`../`、绝对路径、子目录、非 `.log` 全拒);`_filter_users_statement` 默认排除 / 显式 `user_type=None` 才含;`assert_natural_persons`;`resolve_operator` 收紧;错误码三语对账(复用 `check-i18n`)。 +- **集成**(pytest + httpx,连 test 中间件 MySQL / Redis / OpenFGA,`asyncio_mode=auto`):TestClient 断言 **真 HTTP 状态**(无头 401 / 坏头 401 / 缺位 403 含 `data.required` / 身份头 403 / FGA 不可用 503);WS:无凭据握手被拒(1008/403)、坏 share-token 被拒、有效 share-token 但路径资源不符被拒、密钥与 share-token 同传以密钥为准;**5 秒失效矩阵**:撤销 / 编辑权限位 / 停用 / 删除 / 批量撤销 / share-link 撤销与过期 → 下一次请求即拒(多次采样 ≤ 3s);Redis 拔掉 → 503(不是放行);**登录守卫矩阵**:本地登录 / `/user/sso` / login-sync(HMAC)/ 切租户 四入口对服务账号返回 26012;**对账豁免**:子租户服务账号跑 `reconcile_user_tenant_assignments` 后归属不变;`_count_user_count` 不计;**选人排除**:`/user/list`(超管与非超管两分支)、`grant-subjects/users`、全局成员搜索均不含服务账号,已授权列表 `display_names` 仍能显示;直接 POST 资源侧 `grants:mutate` 授服务账号 → 26029、主体侧端点 → 成功;**归属人**:v2 建知识库 → `knowledge.user_id` = 归属人、CREATOR 行在归属人、服务账号只有 `SERVICE_ACCOUNT_AUTOGRANT` 行且可 REMOVE、「全部撤销」不动它;换归属人不追溯;会话仍归服务账号;**四缺陷**:未上线工作流 invoke → 13010、停别人会话 403、`download_statistic` 传路径 / 越界文件名被拒、助手 WS 无凭据不产生未捕获异常;**到期**:过期密钥首次被拒后 `revoke_reason='expired'` 且恰一条 `open_api.api_key.expire` 审计(并发两请求只产生一条)、Beat 任务对从未再被调用的过期密钥补写;**归属人三路径**:v2 建知识库 / 建知识空间 / 上传文件 → creator 列与 CREATOR 行均在归属人、库 / 空间上服务账号只有 `SERVICE_ACCOUNT_AUTOGRANT` 行、文件 / 文件夹上服务账号**零行**且仍可继续上传;**可见租户**:子租户密钥能列出 Root 共享模型 / 知识库(AC-32);**AC-59**:非管理员调 `/api/v1/service-accounts/**` → 信封 403(非 19801);**多租户**:子租户密钥查不到 Root 数据(坑 2);**DM8**(105 回归):建 ≥ 2 个服务账号 + 上述矩阵抽样。 +- **E2E**(`/e2e-test`,spec AC 全覆盖 + 页面手动清单):AC-41–49、59–65 的页面路径;非管理员进 tab 不可见、直调管理端点 403。 + +**114 手动验证(对应 `mvp-114-path.md` §1 步 1–2;Wave 1 即可)**: +1. `bash /opt/bisheng-ops/deploy.sh` 部署(先代码后 `config.yaml` 加 `open_platform: enabled: true`,再重启后端);`curl -s https://114/api/v1/env | jq .open_platform_enabled` → `true`。 +2. 以**租户管理员**(非 admin,避免超管短路)登录 platform → 系统管理 → 服务账号 → 新建(归属人 = 开发者本人)→ 直达签发 → 勾 `app:manage`(「本地开发工具包」组可见)→ 复制明文 → 勾「我已保存」关闭;再打开列表:只见 `bs-sak-********xxxx`。 +3. `curl -H "Authorization: Bearer bs-sak-…" https://114/api/v2/auth/whoami` → 200 含 `scopes:["app:manage"]`;去掉头 → **HTTP 401** `26001`;改一位 → 401 `26002`;用 `platform` 撤销 → 3 秒内再 curl → 401 `26002`;停用账号 → 同上;启用 → 恢复。 +4. (Wave 2 后)`curl -H … https://114/api/v2/assistant/list` 未勾 `assistant:read` → **HTTP 403** `26003 data.required=assistant:read`;加 `X-Bisheng-On-Behalf-Of: 1` → 403 `26004`;`GET /api/v2/chat/history` → 404。 +5. 用普通用户账号看用户管理 / 审批人 / 部门加成员 / 资源授权弹窗:搜不到该服务账号;服务账号密码登录 → 26012。 + +**关键日志 / 指标**:`open_api.call`(结构化行:`credential_id / subject / endpoint / scope / status_code / latency_ms / cache_hit`);`open_api.auth.reject`(原因码);`open_api.cache.invalidate`(键数);watchdog 断连原因;审计页 `open_api.*`。Redis 键:`oapi:cred:{sha256}`、`oapi:cred:lastused:{id}`。 + +--- + +## 8. 后续改进 / 不打算做的事 + +- **P1(随后续 Feature)**:HTTP 逐调用审计表化 + 双归属(F050);`delegate` 位、委托配置区、`26005–26007/26010/26016`(F050);guest 会话从分享创建者会话列表摘出 + history 逐访客绑定(等 F050 `MessageSession` 分区键);`chat:invoke` 端点标记与清 `pending_note_key`(F058);`hosted_app` 主体解析器(F055);文件级检索过滤(F052,本期 `POST /filelib/retrieve` 只加鉴权,过滤强度维持知识资源级 + fail-closed);接入信息区(F053);应用运行期凭据编排(F055,本底座只保证 `subject_kind='hosted_app'` 可用);WS 吊销事件驱动断连(F054 app-proxy 的 connection 索引方案,本期用 3s 轮询 watchdog 够用)。 +- **P2(PRD R8)**:限流 / 配额 / IP 白名单 / 幂等键——平台无 HTTP 限流基础设施,最近原语 fail-open(PRD 附录 E.5),不可按"接现成库"估。 +- **不做**:兼容窗口 / 鉴权开关 / 迁移期放行(INV-27);密钥级资源白名单(PRD D3);个人 key(⚠️ 2026-08-27 改判为交付,伴生 D13,实现在 beta2 F053;保留为历史);平台侧存量迁移或转型脚本;`share_link.status` 改成时间戳(既有读路径按枚举判,本期只补写入端点);给 `share_link.resource_type` 加枚举值;把 `default_operator` 残留 DB 键清理成脚本;密钥经查询参数传递;F018 归属转移接口。 +- **重写 / 拆分触发条件**:F051–F053 上线后凭据校验 QPS 使 Redis 成瓶颈 → 评估本地 LRU 只作"正缓存 + 版本号校验"的两级方案(仍靠 Redis 版本号保证 5 秒);服务账号数量或授权条目达到万级 → 反查与名称水合改批量 join。 + +--- + +## 修订历史 + +| 日期 | 改动 | 触发原因 | +|---|---|---| +| 2026-08-17 | 初版(D1–D13 决策 + 25 条坑 + 契约 + 测试策略);登记两处需回写上游:spec 决议-1「实际受影响面只有两个 WS」的核实基础已过时(D8)、`GET /api/v2/auth/whoami` 与 26020+ 管理面错误码需回写伴生 PRD 附录 B.1 / C | F049 design 编写(四份探查笔记 E1–E4 + spec 65 AC) | +| 2026-08-17 | **审查修订:处理 26 条**(1 high / 12 medium / 13 low)。要点:D5 接缝扩到知识空间与文件 / 文件夹三条创建路径并写死输入契约(`autogrant_user_id` kwarg + `extra_grants`,INHERIT 不回授);D2 身份改直接构造 `UserPayload`(不经 `init_login_user`)、复刻 `{leaf,1}` 可见租户、每请求比对 `expires_at` + 租户黑名单、`hosted_app` 解析器注册点、503 码清单统一正名;D1 建号单事务 + 状态源口径;D3 补 AC-37 / AC-40 落点、`dependencies.py:63` 入删除清单、whoami `scope=None`;D8 管理端点改挂非豁免前缀 `/app-shares`、消费点计数订正为 6+2、★ 回写 spec 决议-1 项显式化;D9 `chat:invoke` 按 spec 登记为待开放位(撤回初版推翻);D10 26012 必须 `raise` 的论据订正;D11 到期事件双通道(惰性 + Beat);D13 补备选并消解 Wave 1 删除矛盾;D7 补 `/user/reset_password`;坑 3 / 4 / 6 / 13 订正,新增坑 26 / 27;§1 目标改 3 句。**需回写上游新增**:spec 决议-1 + AC-55「资源 = flow」口径(tasks 前 ★);spec / 伴生 PRD 附录 B.1 登记 `/api/v1/app-shares` 与 `download_statistic` 入参 `file_path → file_name` 契约变化 | `/sdd-review design` 审查发现(两轮) | + + diff --git a/features/v3.0.0/049-openapi-auth-baseline/spec.md b/features/v3.0.0/049-openapi-auth-baseline/spec.md new file mode 100644 index 0000000000..6ff0e07206 --- /dev/null +++ b/features/v3.0.0/049-openapi-auth-baseline/spec.md @@ -0,0 +1,280 @@ +# Feature: 开放 API 鉴权底座(默认拒绝 + 服务账号密钥 + 资源归属人) + +> **本文档定位 — 纯 What(需求口径,不随代码漂移)** +> +> spec 只回答 **做什么 / 验收标准 / 不做什么**。所有 How(决策、数据流、字段、API、Service、前端、文件清单、性能指标)一律不写在这里, +> How 的唯一真相在 [design.md](./design.md) 与 [tasks.md](./tasks.md)。 + +**关联 PRD**: [docs/PRD/3.0-beta2/3.0 开放 API 鉴权与身份传递 PRD.md](../../../docs/PRD/3.0-beta2/3.0%20开放%20API%20鉴权与身份传递%20PRD.md) **v2.1** §1.4(P0 阶段)/ §4.2(R1 凭据体系,含 §4.2.4 三扩展位登记)/ §4.4.1–4.4.2(两层判定 + 三条铁律)/ §4.5(R4 服务账号主体,含定义 6 资源归属人)/ §4.6(R5 端点接入,含 §4.6.1 WS、§4.6.2 既有缺陷、6 个不暴露端点)/ §4.7(R6 管理界面,含 §4.7.4 主体侧授权)/ §4.9(R9 升级须知,平台侧零迁移)/ 附录 B.1;上册《3.0 应用工场 PRD-1》**v2.0** DEV-01(签发表单增量)/ GOV-08(四条产品承诺)/ GOV-10(开放能力层开关) +**同步记录**: 2026-08-17 按伴生 PRD v2.1 + PRD-1 v2.0 整体重写(§4 决议-6,AC 重编号,旧→新映射见文末附表;同日按 `/sdd-review` 独立审查 24 条发现修订,AC 61 → 65);2026-08-15 曾按 v2.0 对齐(决议-5);2026-08-06 初稿(决议 1–4) +**优先级**: P0 +**所属版本**: v3.0.0 +**依赖**: 无(本版本第一个 Feature,是 F050–F059 全部 key 类能力的地基) + +> **范围边界** +> +> 本 Feature 只承接伴生 PRD 的 **P0 阶段目标:关闭匿名通道**(§1.4:服务账号主体含资源归属人 + 服务账号密钥 + 全端点接入 + 管理界面)。身份传递(P1)随 F050,日常模式会话 Responses 契约(P1)随 F058。 +> +> - **本次纳入**: +> - **统一凭据底座**:密码学随机生成、服务端仅存单向哈希、软撤销、掩码展示(前缀 + 末四位)、明文仅签发时一次性展示、按主体批量撤销、撤销生效上界 5 秒、有效性单一判据(未撤销且未过期)、密钥全生命周期事件计审计;底座支持两类主体绑定——**服务账号**(管理员签发,本 Feature 全部管理界面围绕它)与**托管应用**(F055 随发布自动签发 / 重签 / 撤销、不进服务账号列表、无资源归属人要求;本 Feature 只保证底座接口支持该主体类型,其生命周期编排归 F055) +> - **服务账号主体**:不可登录的一等主体;租户归属声明式写入(创建时取管理员当前租户、不可更换)、对账豁免;不占租户席位;**不出现在任何面向人的选人场景**(含资源侧授权选人框,PRD §4.5 定义 5);**创建时必填资源归属人**(PRD §4.5 定义 6 及其四条边界 a–d,本期承接模式 S 下的全部语义;归属人被禁用不联动);不得被授予任何角色 / 管理员身份、不得被主动加入用户组 / 部门 +> - **服务账号密钥(`bs-sak-`)**:权限位(模块 + 动作级;清单 = 伴生 §4.2.4 基础位 `workflow:invoke/read`、`knowledge:read/write`、`assistant:invoke/read`、`chat:invoke`——其中 `chat:invoke` 的消费端点(日常模式会话 Responses)随 F058 开放,开放前签发表单对该位以说明标注「端点随后续版本开放」、其余行为同普通位)、可选过期;不做密钥级资源白名单、不预留(PRD D3 / §6);**三扩展位(`model:invoke` / `identity:read` / `app:manage`)登记入同一张权限位清单**——WHERE 开放能力层已部署时在签发表单成组「本地开发工具包」出现(PRD §4.2.4 扩展位表 / PRD-1 DEV-01),本期只做**签发与持久化**,三面的运行期消费随 F051–F053 +> - **开放能力层部署开关**(PRD-1 GOV-10 三层形态之一)的定义、读取与对前端暴露由本 Feature 拥有(它决定三扩展位是否出现);F051 / F052 / F053 复用同一开关 +> - **全部开放 API 端点接入鉴权**:36 个 HTTP + 2 个 WebSocket 端点无死角;**6 个 `/chat/*` 端点本次不暴露**(PRD §4.6,2026-08-16 决议);默认身份模式 = 密钥主体本身(模式 S) +> - **分享链接通道改造**:platform 两个免登录分享页改走既有 share_link 通道(share-token);share_link 补撤销入口并强制有效期;两个 WS 端点在同一校验点上接受密钥与 share-token 两类凭据(§4 决议-1);share-token 会话的执行主体与范围(§4 决议-6 i) +> - **两层判定 + 三条铁律**(PRD §4.4.1–4.4.2):①密钥权限位 ∧ ②主体资源授权,两层失败给出可区分的错误;铁律 1 无凭据即拒绝、铁律 2 缺身份即报错(本期形态:任何身份传递头一律拒绝、不静默忽略;持 `delegate` 密钥漏传头 → 400 的形态随 F050)、铁律 3 评估失败即报错(绝不返回未过滤或部分过滤的结果集) +> - **管理界面**(PRD §4.7,仅管理员):服务账号列表 / 新建(名称 · 描述 · 资源归属人)/ 详情页三 tab——概览(基本信息 · 启停 · 删除)、API 密钥(签发 · 编辑 · 撤销 · 批量撤销)、**资源授权**(主体侧唯一入口,v2.1 形态:单列表 + 来源列 + 「全部撤销」排除回授项);创建成功后直接引导签发第一把密钥。**服务账号模块随本 Feature 恒在**,开放能力层部署开关只 gate 三扩展位与接入信息区(§4 决议-6 g) +> - **存量升级**:**发布即带鉴权,不设兼容开关、不设兼容窗口,平台侧不做任何自动迁移**(PRD §4.9 / D1);`default_operator` 与 `enable_guest_access` 两个配置项随本版移除;对外接口文档中错误引导表述的修订与密钥前缀模式的公开;升级前三步指引、接入空窗、存量裸分享链接失效、6 个不暴露端点清单以发布说明交付 +> - **顺带修复的既有缺陷**(PRD §2.2 F4 / §4.6.2,均在同一改造面上):代用户解析不校验账号状态、工作流调用不校验上线状态、工作流停止不校验会话归属、助手 WebSocket 身份解析异常导致握手裸崩、统计文件下载端点可由调用方以任意路径参数取走服务器上任意 `.log` 文件(按最严收口) +> - **本次明确排除**: +> - **两种身份模式(S / D)+ 外部用户标识头 + 受限委托五道准入 + `delegate` 位与委托配置区 + 审计双归属**(PRD R2 / R3 / R7,P1)——随 F050。**本期签发与编辑表单不提供 `delegate` 位**(§4 决议-6 f),伴生 AC-48「`delegate` 与三扩展位互斥硬阻断」的实际校验随 F050 的委托配置区一并交付;AC-46(模式 D 下创建的资源归属被代表用户)同随 F050 +> - **三扩展位的运行期入口拒绝**(伴生 AC-49:持 `delegate` 的密钥接 MCP / 模型协议面 / CLI `login` 一律被拒、不得静默落回模式 S)——分别随 F052 / F051 / F053,本 Feature 只保证权限位清单与签发侧 +> - **服务账号详情页「接入信息区」与一键复制**(PRD-1 DEV-01 ⑤)——其内容(MCP 地址 / OpenAI 兼容 base URL / CLI 下载链接 / login 指引)依赖 F051–F053,随 F053 交付 +> - **应用运行期凭据的自动签发与回收**(PRD-1 GOV-08:不作产品概念、无管理入口)——由 F055 编排,复用本底座;**发布前密钥扫描规则集**(内置 `bs-sak-` 前缀模式)随 F055,本 Feature 只公开前缀模式 +> - **日常模式会话 Responses 契约**(PRD §4.6.3,P1)——随 F058(登记项,是否进 v3.0.0 由产品定) +> - **知识库检索补文件级过滤**(PRD §4.4.6,P1 前提)——随 F052 统一检索门面;本期 `POST /filelib/retrieve` 只加鉴权、过滤强度维持现状(知识资源级一次校验 + fail-closed),F052 上线后收敛到同一条双层路径 +> - **密钥的浏览器直连传递方式**(PRD §4.6.1「子协议或票据换取」)——本期密钥只经请求头传递(HTTP 与 WS 一致);浏览器分享场景经 share-token 纳入本期,其余浏览器直连场景不做 +> - **个人访问令牌(绑定自然人、继承其权限的密钥)**——伴生 PRD 整条否决(§1.4 / §4.2.2 / §6);PRD-1 DEV-01 亦已改判(§4 决议-4):全平台**只存在服务账号一类密钥主体、`bs-sak-` 一类凭据**(**⚠️ 2026-08-27 改判:PAT 交付,伴生 PRD D13,实现在 beta2 F053;本条保留为历史**) +> - **裸 `user_id` 参数的收口与移除**(含 `add_relative_qa` 死参数清理)——随 F050 委托准入一并做;PRD §4.9 要求随 v3.0.0 移除、不保留过渡期,因此 **F049 不得脱离 F050 单独对外发版**。F049 开发期内该参数按既有语义解析、仅按 AC-39 补账号状态校验——它是本期**唯一残余的「经参数指定执行身份」路径**,AC-35「无第三种路径」以 F049 + F050 合并发版为前提成立 +> - **限流、配额、IP 白名单、幂等性**(PRD R8,P2) +> - **完整 OAuth 2.0 授权服务器、最终用户令牌透传、即时建号、`/api/v1` 前端接口鉴权改造、按单个端点的细粒度权限、Webhook 回调方向鉴权**(PRD §六 非目标,原样继承) +> - **已废止的旧范围**(记录以防回流):默认操作员转型为服务账号并强制降权的「上线闸口」(旧 AC-35/36,随 PRD §4.9「零迁移」作废);5 个会话反馈端点的「宽松档」鉴权(旧 AC-28,随「6 个 `/chat/*` 不暴露」作废);授权页「名下资源」分组(随资源归属人机制作废);删除持有授权的服务账号时硬阻断(`26013`,降为二次确认) + +--- + +## 1. 用户故事 + +作为 **平台管理员 / 租户管理员**, +我希望 **每个调用开放 API 的外部系统都持有独立、可单独撤销、权限最小的凭据**, +以便 **出事时能定位到具体调用方并即时止血,而不是"谁都能调、也查不出是谁调的"**。 + +作为 **客户侧的集成开发者**, +我希望 **升级前就能按发布说明建好服务账号、拿到密钥并完成加请求头这一处改造,升级后立即按既有方式继续调用**, +以便 **迁移是一次可预先排期、可预先验证的改造,而不是升级后才发现调用被拒**。(无兼容窗口——PRD D1;升级前三步指引与接入空窗见 §4.9) + +作为 **安全与合规负责人**, +我希望 **平台不存在无凭据即可访问业务数据的接口,凭据本身即使数据库泄漏也无法还原**, +以便 **通过等保测评中「内部接口鉴权」这一明确失分项,并把凭据泄漏的爆炸半径限制在单把密钥**。 + +作为 **服务账号的使用方(自建业务系统)**, +我希望 **集成的权限锚定在一个不可登录的服务主体上**, +以便 **生产集成不因某位员工离职或调岗而中断**。 + +作为 **资源归属人(负责某个集成的自然人)**, +我希望 **集成经 API 创建出的知识库等资源在我的平台界面里像自己建的一样可见可管**, +以便 **集成产出的资产始终有人认领,而不是只有管理员靠权限短路才看得见的孤儿**。 + +--- + +## 2. 验收标准 + +> AC 编号在本特性内唯一。与伴生 PRD §五验收标准的对应关系在括号中标注(如 `↔AC-1`),便于双向追溯;伴生 PRD 未单列 AC 的需求以章节号标注(如 `↔§4.7.1`)。 +> 错误码在此仅作为可观测的对外行为引用,其定义与归属写在 design.md。 + +### 2.1 凭据校验与生命周期 + +- **AC-01** — WHEN 请求未携带有效密钥, THE SYSTEM SHALL 返回 401 且不执行任何业务逻辑、不返回任何业务数据;**不存在任何回落路径**(无鉴权开关、无兼容窗口,PRD D1)。唯一例外是两个 WebSocket 端点上的 share-token(AC-55–58),它同样是平台签发的凭据、不构成回落。(↔AC-1) +- **AC-02** — THE SYSTEM SHALL 使密钥明文仅在创建响应中出现一次;此后任何接口与界面对该密钥只返回掩码(前缀 + 末四位)。(↔AC-2 / AC-40) +- **AC-03** — WHEN 密钥被撤销, THE SYSTEM SHALL 使其后续调用在 5 秒内被拒绝。(↔AC-3) +- **AC-04** — IF 密钥缺少目标端点所需的权限位, THEN THE SYSTEM SHALL 返回 403,且错误信息指明缺少哪一位。(↔AC-4) +- **AC-05** — WHEN 密钥的过期时间到达, THE SYSTEM SHALL 拒绝其后续调用,无需任何人工干预。 +- **AC-06** — THE SYSTEM SHALL 在创建密钥时默认不勾选任何权限位;未显式授予的权限位一律视为未授予。 +- **AC-07** — WHEN 租户管理员查询密钥列表, THE SYSTEM SHALL 只返回本租户的密钥,跨租户查询返回空。(↔AC-5) +- **AC-08** — WHEN 管理员修改密钥的名称 / 权限位 / 过期时间, THE SYSTEM SHALL 使原密钥立即按新配置生效,不要求轮换密钥。(↔AC-21) +- **AC-09** — WHEN 管理员对某一服务账号执行「撤销该账号全部密钥」, THE SYSTEM SHALL 使其名下全部密钥在 5 秒内失效。(↔AC-22) +- **AC-10** — WHEN 密钥被成功用于一次调用, THE SYSTEM SHALL 更新其最后使用时间(允许 60 秒内的写合并)。 +- **AC-11** — WHEN 密钥被撤销, THE SYSTEM SHALL 保留该密钥记录及其历史归属,供审计追溯;撤销不等于删除。 +- **AC-12** — WHEN 密钥发生签发、名称 / 权限位 / 过期时间变更、撤销(含批量)、到期自动失效、因主体停用或删除而失效, THE SYSTEM SHALL 记录审计事件(操作者或触发原因、密钥掩码、所属服务账号、动作、时间);审计记录中不出现密钥明文。(↔PRD-1 GOV-08 承诺 4 / 验收 3;DEV-01 ② ④) +- **AC-13** — WHERE 开放能力层已部署, THE SYSTEM SHALL 在权限位清单及签发 / 编辑表单中提供「本地开发工具包」分组的三个扩展位(`model:invoke` / `identity:read` / `app:manage`)——与既有权限位同一张清单、同一个表单、同样默认全不勾;每位附白话用途说明;`identity:read` 旁以区别于普通说明的醒目样式常驻展示「勾选即本租户组织架构全量可读、无法收窄到部分部门」;`app:manage` 附「部署应用需勾选」;勾选开放 API 端点权限位时给出不阻断的软提示「转交个人使用的 key 建议仅配本地开发工具包权限位」。WHERE 开放能力层未部署, THE SYSTEM SHALL 不展示三位,且以三位入参的签发 / 编辑请求被拒绝。(↔§4.2.4 扩展位表;PRD-1 DEV-01 ①) +- **AC-14** — THE SYSTEM SHALL 在本 Feature 交付形态下不提供 `delegate` 位:签发 / 编辑表单不出现该位与委托配置区;任何以 `delegate` 入参的签发 / 编辑请求被拒绝并提示「委托能力尚未启用」。(§4 决议-6 f;委托随 F050,届时 `delegate` 与三扩展位互斥硬阻断 ↔AC-48 随之交付) + +### 2.2 服务账号主体 + +- **AC-15** — THE SYSTEM SHALL 使服务账号无法通过任何登录入口获得会话(覆盖平台全部登录入口,含本地登录、SSO、网关登录同步、租户选择)。(↔AC-14) +- **AC-16** — THE SYSTEM SHALL 使服务账号**不出现在任何面向人的选人场景**——用户管理、审批人选择、部门加成员、用户组加成员、创建租户选管理员、**资源侧授权选人框**一概不可见;任何消费点即使未显式请求排除也不得看到服务账号(失败方向是"看不到"而非"泄漏");资源侧「已授权对象列表」仍能正确**显示**已获授权的服务账号——可显示、不可选择。(↔AC-15) +- **AC-17** — WHEN 租户对账任务执行, THE SYSTEM SHALL 保持服务账号的租户归属不被改写。(↔AC-16) +- **AC-18** — THE SYSTEM SHALL 不把服务账号计入租户用户数配额。(↔AC-17) +- **AC-19** — WHEN 管理员创建服务账号, THE SYSTEM SHALL 使其立即处于「可被授权」状态(可作为授权主体在其详情页被授予资源权限)。 +- **AC-20** — IF 对服务账号调用面向自然人的用户更新操作(如改密、启用登录、修改类型), THEN THE SYSTEM SHALL 拒绝。 +- **AC-21** — WHEN 服务账号被停用或删除, THE SYSTEM SHALL 使其名下全部密钥在 5 秒内失效。 +- **AC-22** — IF 试图为服务账号授予任何角色(含管理员角色)、租户管理员身份,或经管理接口把它加入用户组 / 部门, THEN THE SYSTEM SHALL 拒绝(承接原决议-2「不让一把密钥背后站着超管」的目的;组织侧自动兜底路径的处置见 §3)。 +- **AC-23** — WHEN 管理员创建服务账号, THE SYSTEM SHALL 要求填写名称、描述与**资源归属人**(必填、必须为本租户的自然人;该选人框同样不出现服务账号);缺失或非自然人时拒绝创建。租户取当前管理员所在租户、不提供选择、创建后不可更换。表单须一句话说明「该集成创建出的知识库等资源将归属此人、由其在平台内管理」;WHERE 开放能力层已部署, 归属人选择处另附提示「该账号首发部署的应用以此人为 owner;删除 / 撤回审批单仅 owner 可为、管理员不可代行;归属创建时固化、改归属人不追溯已有应用」。(↔§4.7.1 / §4.5 定义 3;PRD-1 DEV-01 界面表) +- **AC-24** — WHEN 服务账号经开放 API(模式 S)创建资源, THE SYSTEM SHALL 把创建关系落到其资源归属人名下——该资源在归属人的平台界面中可见可管理、如同其本人所建;同时服务账号自动获得对该资源的可编辑档位(「创建时自动回授」)并可继续写入。(↔AC-42;定义 6 边界 b) +- **AC-25** — THE SYSTEM SHALL 使模式 S 下经开放 API 产生的会话仍归属服务账号、**不出现在资源归属人的会话列表**中。(↔AC-43;定义 6 边界 a) +- **AC-26** — THE SYSTEM SHALL 使资源归属人本人对某资源的权限变化(被加入 / 移出某知识库授权)不改变该服务账号经开放 API 的可见范围。(↔AC-44;定义 6 边界 c) +- **AC-27** — WHEN 管理员更换服务账号的资源归属人, THE SYSTEM SHALL 保持此前已创建资源的归属不变,此后创建的资源归新归属人。(↔AC-45;定义 6 边界 d) +- **AC-28** — IF 服务账号的资源归属人被禁用或删除, THEN THE SYSTEM SHALL 不使该服务账号及其密钥自动失效(回收由管理员停用 / 删除该服务账号完成,AC-21);列表页对该账号高亮提示(AC-42)。(↔PRD-1 DEV-01 ④ / GOV-08 验收 5) + +### 2.3 端点接入与三条铁律 + +- **AC-29** — THE SYSTEM SHALL 使全部开放 API 端点(36 个 HTTP + 2 个 WebSocket)经过同一条凭据校验路径;不存在绕过该路径的端点。两个 WebSocket 端点在该校验点上额外接受 share-token(AC-55–58,§4 决议-1),不构成绕过。(↔附录 B.1) +- **AC-30** — THE SYSTEM SHALL 不再提供以下 6 个 `/chat/*` 端点(`GET /chat/history`、`POST /chat/gen_title`、`/chat/liked`、`/chat/solved`、`/chat/comment`、`POST /chat/sync/messages`):调用得到「端点不存在」类响应、不执行任何业务逻辑。(↔§4.6;旧「宽松档」口径作废;发布说明要求见 AC-50) +- **AC-31** — WHEN WebSocket 握手携带无效密钥, THE SYSTEM SHALL 拒绝握手并优雅关闭连接(不产生未捕获异常)。(↔AC-18) +- **AC-32** — WHEN 使用有效密钥且未传任何身份头, THE SYSTEM SHALL 以密钥主体(服务账号)身份执行,返回结果的可见范围等于该主体的权限边界。 +- **AC-33** — IF 请求携带身份传递类请求头(代表平台用户 / 外部用户标识), THEN THE SYSTEM SHALL 返回 403 并提示该能力尚未启用——不得静默忽略该头而按密钥主体身份继续执行。 +- **AC-34** — IF 权限引擎不可用或权限评估失败, THEN THE SYSTEM SHALL 返回 5xx 且不返回任何业务结果;绝不返回未过滤或部分过滤的结果集。(↔AC-13、铁律 3) +- **AC-35** — THE SYSTEM SHALL NOT 存在任何"缺身份即降级返回某个子集"的路径:本期不存在"必须携带最终用户身份"的端点级配置,未传身份头 = 模式 S(AC-32),传了 = 拒绝(AC-33);两者之外没有第三种"返回公开资源 / 空集"的静默降级行为。(铁律 2 的 P0 形态;持 `delegate` 密钥漏传身份头 → 400/`26016` 随 F050。**已知残余**:开发期内 7 个端点的裸 `user_id` 参数仍按既有语义解析,它随 F050 移除、F049 不单独发版——见范围边界) +- **AC-36** — IF 密钥持有目标端点所需权限位、但其主体对具体资源无授权, THEN THE SYSTEM SHALL 返回业务模块既有的资源权限错误——**与缺权限位的 403(AC-04)可区分**,不得合并成同一个笼统 403。(↔§4.4.1 两层判定) +- **AC-37** — WHEN 调用未上线的工作流, THE SYSTEM SHALL 返回业务错误而非执行该工作流。(↔AC-19) +- **AC-38** — WHEN 请求停止一个不属于当前密钥主体的会话, THE SYSTEM SHALL 返回 403。(↔AC-20) +- **AC-39** — IF 代用户解析(F049 期仍存在的裸 `user_id` 路径)的目标账号已被禁用或不存在, THEN THE SYSTEM SHALL 拒绝该请求,不构造出可用身份。 +- **AC-40** — WHEN 调用统计文件下载端点, THE SYSTEM SHALL 要求有效密钥与对应权限位,且只返回平台自身生成的统计数据文件;THE SYSTEM SHALL NOT 接受由调用方指定的任意服务器文件路径作为取数依据。(↔§4.6.2) + +### 2.4 管理界面 + +- **AC-41** — WHEN 管理员进入服务账号管理入口, THE SYSTEM SHALL 提供列表、新建、详情页三 tab(概览:名称 / 描述 / 租户 / 创建信息 + 停用启用 + 删除;API 密钥;资源授权);全模块仅租户管理员及以上可见可操作,不存在面向普通用户的自助入口。(↔§4.7 / §4.7.2) +- **AC-42** — THE SYSTEM SHALL 在服务账号列表中展示名称、状态(启用 / 停用)、有效密钥数(为 0 时高亮提示)、资源归属人(该人被禁用时高亮提示)、最后调用时间(为空或距今超过阈值时提示可考虑停用,阈值为部署配置项、默认 90 天)、创建人 / 创建时间。(↔§4.7.1;「委托」列随 F050 出现) +- **AC-43** — WHEN 服务账号创建成功, THE SYSTEM SHALL 直接给出签发第一把密钥的入口,而不是把管理员送回列表。(↔§4.7.1) +- **AC-44** — THE SYSTEM SHALL 在「API 密钥」tab 的密钥列表中展示名称、掩码、权限位、最后使用时间、过期时间与状态;签发表单含名称(必填)、过期时间(可空 = 长期有效)、按能力分组展示的权限位(默认全不勾,悬停显示该位覆盖哪些接口)。(↔§4.7.3;委托范围摘要与委托配置区随 F050) +- **AC-45** — WHEN 密钥创建成功, THE SYSTEM SHALL 在一次性展示界面同时提供复制操作与「关闭后不可再查看」的明确提示,且关闭前需勾选「我已保存」。(↔AC-40 / §4.7.3) +- **AC-46** — WHEN 管理员撤销单把密钥, THE SYSTEM SHALL 要求二次确认(WHERE 开放能力层已部署, 确认文案补「MCP / 模型协议 / CLI 三面同时被拒绝」);「API 密钥」tab 顶部提供「撤销该账号全部密钥」快捷操作(AC-09)。(↔§4.7.3;PRD-1 DEV-01 界面表) +- **AC-47** — WHEN 管理员在概览 tab 停用服务账号, THE SYSTEM SHALL 在 5 秒内拒绝其名下密钥的调用(AC-21),但保留全部授权与配置;重新启用后原样恢复。(↔AC-31 停用部分;§4.7.2) +- **AC-48** — WHEN 管理员删除服务账号, THE SYSTEM SHALL 弹出二次确认并列出该账号当前持有的全部资源授权(含创建时自动回授项)、明示删除后这些授权一并失效、依赖它的集成将立即失败;**确认即可删除、不阻断**。删除后这些授权失效,被授权的资源本身及其归属人不受任何影响。(↔AC-31;旧硬阻断口径作废) +- **AC-49** — THE SYSTEM SHALL 使服务账号模块(列表 / 详情 / 密钥 / 授权)不受开放能力层部署开关影响而恒在;WHERE 开放能力层未部署, 仅「本地开发工具包」三扩展位(AC-13)与接入信息区(F053)不出现。(§4 决议-6 g) + +### 2.5 存量升级 + +- **AC-50** — WHEN 存量调用方在升级后不带密钥调用, THE SYSTEM SHALL 返回 401、无回落路径(同 AC-01);THE SYSTEM SHALL NOT 提供任何"兼容窗口 / 鉴权开关 / 迁移期放行"配置项。发布说明的升级前必读章节 SHALL 包含:升级前三步(创建服务账号并指定归属人、授权 → 签发密钥 → 调用方加 `Authorization` 头)、「升级完成到配好密钥之间存在接入空窗」、存量裸分享链接升级后失效须重新分享、本次不再提供的 6 个 `/chat/*` 端点清单(AC-30)。(↔§4.9 / D1) +- **AC-51** — THE SYSTEM SHALL 在升级过程中不做任何平台侧迁移:不写转型脚本、不改动任何存量用户与资源;原 `default_operator` 配置项指向的用户及其名下资源的归属与可见性**零变化**。(↔AC-27) +- **AC-52** — WHEN 升级部署执行, THE SYSTEM SHALL 移除 `default_operator` 配置项;升级后该配置项的任何取值对开放 API 行为无影响。(↔§4.9) +- **AC-53** — WHEN 升级部署执行, THE SYSTEM SHALL 移除 `enable_guest_access` 配置项;升级后该配置项的任何取值对开放 API 行为无影响。(↔§4.9 / 附录 B.1) +- **AC-54** — THE SYSTEM SHALL 使对外接口文档中不再出现「由网络层负责访问控制」与「建议将默认操作员配置为超级管理员」这两处表述,并公开 `bs-sak-` 密钥的前缀模式(供代码托管平台 secret scanning 规则与 F055 发布前密钥扫描规则集引用)。(↔§4.9 / §4.2.6;PRD-1 GOV-08 承诺 3) + +### 2.6 分享链接通道 + +- **AC-55** — WHEN 分享页持有效 share-token 建立 WebSocket 连接, THE SYSTEM SHALL 允许会话,且其可访问范围仅限该分享所指向的单一资源。 +- **AC-56** — WHEN share link 被撤销或到期, THE SYSTEM SHALL 使其后续访问与新建连接在 5 秒内被拒绝。 +- **AC-57** — THE SYSTEM SHALL 提供 share link 的撤销入口,并强制执行其有效期(到期即拒)——二者当前均不存在(§4 决议-1)。 +- **AC-58** — WHEN 以 share-token 建立的 WebSocket 会话执行, THE SYSTEM SHALL 以该分享的创建者为执行主体、租户上下文取该资源所属租户、可访问范围仅限该分享指向的单一资源(AC-55);审计记录该 share-token 标识与分享创建者。share-token 是平台签发的凭据,不构成 INV-27 意义上的回落身份。(§4 决议-6 i) + +### 2.7 主体侧授权(服务账号详情页「资源授权」) + +> PRD §4.7.4:服务账号被排除出全部选人场景后(AC-16),这是它获得资源权限的**唯一**通道,缺了它模式 S 的全部能力失效。 + +- **AC-59** — THE SYSTEM SHALL 把对服务账号的资源授权收口在其详情页「资源授权」这一个入口,查看与变更**仅管理员**(租户管理员及以上);非管理员访问该页的读写端点返回 403。(↔AC-30) +- **AC-60** — WHEN 管理员在该页授予某资源, THE SYSTEM SHALL 允许选择资源与由平台既有权限模型提供的权限档位(本页不自行定义档位);授予后该主体名下持对应权限位的密钥即可按该档位访问该资源,条目来源标为「管理员授予」。(↔§4.7 / §4.7.4) +- **AC-61** — THE SYSTEM SHALL 在该页以**单一列表**展示该服务账号当前持有的全部资源授权,每条带「来源」列(管理员授予 / 创建时自动回授);页面列出的集合与该主体的有效授权记录**一一对应**,且该主体不存在任何「创建者」类型的授权记录(创建关系落在资源归属人身上,出现即为缺陷)。(↔AC-28;旧「名下资源 / 被授予资源」分组作废) +- **AC-62** — WHEN 管理员在该页撤销某资源, THE SYSTEM SHALL 使该主体名下密钥对该资源的开放 API 调用返回资源权限错误(AC-36);重新授予后恢复。(↔AC-29) +- **AC-63** — WHEN 管理员点击「全部撤销」, THE SYSTEM SHALL 只撤销「管理员授予」来源的条目、**不撤销「创建时自动回授」条目**;对回授条目单条撤销时给出二次确认并明示「撤销后该集成将无法再写入这个资源」;撤销后该服务账号对应的写入调用失败,重新授予后恢复。(↔AC-47) +- **AC-64** — THE SYSTEM SHALL 对"本页已授权、但名下没有任何密钥持有对应权限位"的资源,在列表内逐条提示并指明缺少的是哪一位;WHEN 该主体名下不存在任何有效密钥, THE SYSTEM SHALL 改为给出一条整体提示而不逐条重复。两层判定(AC-04 ∧ AC-36)缺一不可,不让另一层的缺口浮出来,管理员必然遇到"授了权仍失败"的困惑。**不在页面顶部另设密钥概览**——密钥数与各自的权限位由 API 密钥页承载,本页重复一份只会把授权列表挤下去。(↔§4.7.4) +- **AC-65** — WHEN 该页发生授予 / 变更 / 撤销, THE SYSTEM SHALL 记录审计:操作管理员、目标服务账号、资源类型与 ID、权限档位的变更前后值、时间。(↔§4.7.4 审计) + +--- + +## 3. 边界情况 + +> 只写期望的对外行为;实现层的落点、缓存与数据访问细节见 design.md §5。 + +- **凭据校验先于租户上下文**:密钥校验本身不依赖任何已建立的租户上下文;校验通过后一切业务查询按该密钥所属租户过滤。跨租户的密钥与主体组合一律拒绝。 +- **撤销与缓存的 5 秒窗口**:撤销后允许最多 5 秒的旧结果窗口;主动失效是常态路径,不得依赖被动过期兜底。批量撤销、主体停用与删除同此上界。 +- **只有一条路径**:升级后开放 API 只存在「带密钥」一条路径,不存在无密钥回落(PRD D1);存量集成从固定超管身份切到最小授权的服务账号后能力**会**收窄——这是改造目的而非事故,靠升级前的授权配置与发布说明承接(AC-50)。平台侧不替客户做任何转型(AC-51)。 +- **密钥有效性的单一判据**:有效 = 未撤销且未过期。不引入可独立漂移的状态枚举列(平台已有「枚举存在但无写入端点」的负资产先例)。 +- **服务账号的租户归属是声明式写入**,不由部门树派生;任何以部门树为真相的对账任务都必须结构性跳过服务账号,而不是"恰好没扫到"。 +- **服务账号"没有部门"不是代码保证的不变量**:组织侧存在把无部门用户兜底进访客部门的自动路径(PRD 附录 E.2)。本 Feature **不修改这些真人场景的兜底逻辑**,要求服务账号结构性不命中并以测试断言固化;管理接口层面主动加入部门 / 用户组 / 角色一律拒绝(AC-22)。委托侧的自然人判定随 F050 在调用期兜底。 +- **资源归属人是"归属"不是"权限"**(定义 6 边界 c):它只决定服务账号创建出的资源在界面上归谁认领;服务账号能创建什么、能读写什么,仍只由密钥权限位 ∧ 它自身被授予的资源决定,与归属人本人的权限完全无关。归属人也不因此获得对服务账号或密钥的任何管理能力;归属人被禁用不联动服务账号(AC-28)。 +- **创建时自动回授不受「全部撤销」影响**(AC-63):这是资源归属人机制引入的唯一新增操作风险——界面上这条授权看起来与管理员手动授予的毫无区别,一次点击就会切断集成对自己维护的知识库的写入。 +- **密钥(`bs-sak-`)的传递方式 WebSocket 与 HTTP 一致**(请求头),不使用查询参数——密钥会进入访问日志。share-token 例外:允许经查询参数传给 WS——它本就承载于分享 URL 中,不构成新增泄漏面。 +- **服务账号不出现在任何选人列表,授权走主体侧**:排除无例外(失败方向是"看不到"而非"泄漏"),资源侧授权弹窗的选人也在覆盖面内(AC-16);模式 S 的能力靠服务账号详情页的「资源授权」(§2.7)获得,而非靠在资源侧选人框里被选中——否则任何资源所有者都能单方面扩大某把密钥的数据边界而管理员无从感知(PRD §4.7.4)。 +- **托管应用凭据不是服务账号密钥**:F055 为每个应用签发的运行期凭据以「托管应用」为主体、不出现在服务账号列表、不需要资源归属人(应用 owner 即归属);它复用本底座的生成 / 哈希 / 校验 / 撤销 / 审计能力,管理面对它零可见(PRD-1 GOV-08)。 +- **服务账号模块恒在,开放能力层开关只 gate 增量**:v2 开放 API 发布即带鉴权,服务账号与密钥是它唯一的接入方式,因此不能被任何部署开关隐藏;开关只决定三扩展位(AC-13)与接入信息区(F053)是否出现(§4 决议-6 g)。 +- **本期 `POST /filelib/retrieve` 的过滤强度维持现状**(知识资源级一次校验 + fail-closed),文件级双层过滤随 F052 收敛到同一条路径。模式 S 下服务账号的授权由管理员集中收敛(§2.7),过渡期风险可控;这不是"已经做过文件级过滤",调用方注释的相反宣称与实现不符(PRD 附录 B.1)。 +- **写放大与租户过滤**:最后使用时间的更新不得成为热路径上的每请求写;密钥表的写路径必须与多租户隔离兼容(细节 design §5)。 +- **凭据校验取 fail-closed,与平台既有会话续期校验的取向相反**:会话续期在缓存 / 存储抖动时刻意宽松("不因抖动把用户锁在门外");凭据校验属于鉴权而非会话续期,缓存或存储不可用时**必须拒绝**。这一差异是刻意的,不是不一致。 + +--- + +## 4. 待澄清决议 + +> ★ 决议 1–4 已于 2026-08-06 与产品逐项确认;决议-5 为 2026-08-15 与伴生 PRD v2.0 的同步记录;**决议-6 为 2026-08-17 与伴生 PRD v2.1 + PRD-1 v2.0 的同步记录,其中 f / g / h / i 四项于 2026-08-17 由用户授权「全部按建议拍板」定案,本 spec 第二次 ★ 已过**。历史决议中被后续 PRD 修订推翻的部分以各条末尾的「状态」为准,原文保留供追溯。 + +- **[决议-1] 两个 WebSocket 端点 × 免登录分享页 → ① share-token 全量方案。** + 核实基础:两个分享页 URL 仅含资源 id、零凭据;平台已有 share_link 通道(`share-token` 请求头、高熵 token、资源类型已含工作流 / 助手,client 端工作台 / 灵思分享在用),但**无撤销写入端点、有效期刻意不生效**;实际受影响面只有工作流 + 助手两个 WS;这两个 WS 同时写在 platform 的 API 接入文档页中,服务端集成方也在用——两类凭据必须并存。 + 决议:分享页改走 share_link 通道并补齐撤销 + 有效期强制(AC-55–57);WS 端点密钥(请求头)与 share-token(查询参数)双凭据并存。**状态(2026-08-17):仍有效**。存量裸分享链接升级后即失效、需重新分享,列入发布说明(AC-50);share-token 会话的执行主体见决议-6 i。 + +- **[决议-2] 统一身份构造放宽权限 → 默认操作员转型降权提升为上线闸口。** + 原决议(2026-08-06):升级部署强制完成默认操作员 → 服务账号的转型与降权,闸口拦开放 API 鉴权启用、不拦全站;2026-08-15 修订为主体级 fail-closed。 + **状态(2026-08-17):整条作废。** 伴生 PRD §4.9 已定「平台侧不做任何迁移」——不写转型脚本、不动存量用户与资源、`default_operator` 配置移除、其指向的用户保持普通用户身份(AC-51/52)。原决议的**目的**(不让一把密钥背后站着超管)由两处结构性约束承接:密钥只签发给服务账号(自然人无凭据),且服务账号不得被授予任何角色或管理员身份(AC-22)。「存量集成收窄」的承接=升级前授权配置 + 发布说明(AC-50)。 + +- **[决议-3] 5 个会话反馈端点的归属校验基准 → 宽松档:有效密钥即可。** + **状态(2026-08-17):作废。** 伴生 PRD §4.6(2026-08-16 决议)把 `history` / `gen_title` / `liked` / `solved` / `comment` 与 `sync/messages` 共 6 个 `/chat/*` 端点**整体移出本次范围、不暴露**(`session:*` 三位随之取消),归属校验问题不复存在(AC-30)。这 6 个端点从未出现在对外接口文档中,不构成已承诺能力的回收,但目前匿名可达、须在发布说明逐个列出(AC-50)。统计下载端点不受影响,仍按最严收口(AC-40)。 + +- **[决议-4] 两册「绑定自然人的密钥」口径冲突 → 统一成服务账号,不做个人 key。** + 决议:**全平台只存在服务账号一类密钥主体、`bs-sak-` 一类凭据**;PRD-1 DEV-01 改判为服务账号密钥、`bs-pat-` 作废;已确认接受代价(权限不随人走、离职需手动回收)。**状态(2026-08-17):仍有效,且已被两 PRD 正文吸收**——PRD-1 v2.0 DEV-01「开发者 key 只是服务账号密钥的一种用途称呼」;原「绑定使用人」概念升格为伴生 PRD §4.5 定义 6「资源归属人」(必填、承接归属不出借权限),本 spec 按定义 6 落 AC-23–28。 + **⚠️ 2026-08-27 改判:PAT 交付(伴生 PRD D13,主体类型 `natural_person`、前缀 `bs-pat-`),实现在 beta2 F053(`features/v3.0.0-beta1/053-openapi-auth-and-identity/`);本条保留为历史。** + +- **[决议-5] 2026-08-15 与伴生 PRD v2.0(深度 Review 版)同步——5 处硬冲突逐项裁决。** + ① 兼容窗口 / 鉴权开关 → 废止(D1);② 裸 `user_id` 收口随 F050,但 F049 不得脱离 F050 单独发版;③ 铁律 2 形态改为「不存在任何缺身份即降级的路径」,`delegate` 即强制、无独立开关;④ 服务账号不出现在任何选人场景、授权走主体侧唯一入口(新增 §2.7);⑤ Responses 契约无特性承接、待版本层归属。另同步:两层可区分错误、密钥级白名单不做不预留、`enable_guest_access` 随升级移除。**状态(2026-08-17):①–④ 仍有效并已并入正文;⑤ 已由 release-contract 登记为 F058。** + +- **[决议-6] 2026-08-17 与伴生 PRD v2.1 + PRD-1 v2.0 同步——逐项裁决如下。** + 1. **升级零迁移**(伴生 §4.9 / AC-27 新义):转型降权闸口整条作废(见决议-2 状态);新增 `default_operator` 移除(AC-52)。 + 2. **6 个 `/chat/*` 端点不暴露**(伴生 §4.6):宽松档作废(见决议-3 状态);端点数由 44 改为 36 HTTP + 2 WS(AC-29/30)。 + 3. **主体侧授权页 v2.1 形态**(伴生 §4.7.4):删「名下资源」分组(资源归属人机制下结构性恒空),改单列表 + 来源列 + 「全部撤销」排除回授项 + 一一对应且无创建者记录(AC-61/63)。 + 4. **删除服务账号不阻断**(伴生 §4.7.4 / AC-31):`26013` 硬阻断降为「二次确认列清单即可删」(AC-48)——资源归属人机制下服务账号不拥有资源,删除不产生孤儿。 + 5. **资源归属人**(伴生 §4.5 定义 6):创建必填自然人 + 四条边界落 AC-23–27,归属人禁用不联动 AC-28;AC-46(模式 D 优先级)随 F050。 + 6. **三扩展位登记与 `delegate` 位归属 [已定 f]**:三扩展位(`model:invoke` / `identity:read` / `app:manage`)在本 Feature 登记入权限位清单并随开放能力层开关出现(AC-13);**本 Feature 表单不提供 `delegate` 位**(AC-14)——委托能力整体属 P1,`delegate` 位、委托配置区、伴生 AC-48 互斥硬阻断随 F050,运行期入口拒绝(伴生 AC-49)随 F051 / F052 / F053。备选=F049 就把 `delegate` 位做进表单并持久化(只存不生效),代价是 P0 期管理员能勾一个什么都不做的位。**已定:前者。** 无论哪种,"三扩展位仅模式 S 且与 `delegate` 互斥"登记为版本级不变量 INV-31。 + 7. **服务账号模块恒在 vs GOV-10 措辞 [已定 g]**:PRD-1 GOV-10 规则与验收 4、GOV-08 界面表写「未部署开放能力层:管理后台『API key 管理页』不展示」,而 DEV-01 ⑥ 写「服务账号模块本身随该 PRD 交付」、伴生 §4.7 写「仅部署标准版(无应用工场)时按本节原样交付」。两者矛盾。本 spec 按后者(AC-49):v2 开放 API 发布即带鉴权,服务账号与密钥是唯一接入方式,不能被部署开关隐藏;开关只 gate 三扩展位与接入信息区。**已定并执行:2026-08-17 已回修 PRD-1 GOV-10 规则表 / 验收 4 与 GOV-08 界面表措辞。** + 8. **伴生 PRD v2.1 草案内部残留三处 [已定 h]**:① §4.7 引言仍写「服务账号新增可选『绑定使用人』字段」与「应用 token 另占一个 tab」——前者已被 §4.7.1「资源归属人必填」取代,后者已被 PRD-1 GOV-08「应用运行期凭据不设管理界面」取消;② §4.7.2 概览 tab 仍写「若名下仍有未撤销的授权资源,弹窗列出待处理清单并阻止删除」——与 §4.7.4 生命周期表 / AC-31「确认即可删除,不阻断」矛盾。本 spec 一律按较新的决议写(AC-23 / AC-48 / 排除应用运行期凭据 tab)。**已定并执行:2026-08-17 已回修伴生 PRD 这三处(只删残留、不动决议)。** + 9. **share-token 会话的执行主体 [已定 i]**(2026-08-17 `/sdd-review` 独立审查指出决议-1 未定义此项):两个免登录分享页的访问者是匿名的,其 WS 会话必须有一个执行主体来确定租户上下文、权限基准与审计归属。本 spec 定为**以分享创建者为执行主体、范围仅限该单一资源、审计记 share-token 与分享创建者**(AC-58)——这与「已发布分享链接 = 分享者把自己的应用开放给匿名访客使用」的产品语义一致,且比现状(两个 WS 以固定配置身份执行)收窄。备选=为每个分享派生一次性受限主体(体量更大,本期不推荐)。**已定:以分享创建者为执行主体。** + 另同步:AC 全表重编号(原 24a / 37a 消除;旧→新映射见文末附表);用户故事新增「资源归属人」视角;范围边界补「已废止的旧范围」清单防回流;开放能力层部署开关的定义归本 Feature(F051–F053 复用);`/sdd-review` 独立审查(2026-08-17)24 条发现的处置:新增 AC-12(密钥生命周期审计)/ AC-28(归属人禁用不联动)/ AC-58(share-token 主体)/ AC-60(授予动作),AC-13 / 23 / 41 / 42 / 46 / 47 / 50 / 54 补齐 DEV-01 表单层增量、租户不可换、概览内容、阈值、5 秒口径、发布说明清单、前缀模式;AC-01 / 35 补 share-token 例外与裸 `user_id` 残余说明;AC-22 扩到任何角色;AC-16 / 40 / 52 / 57 与 §3 去实现措辞;排除项补浏览器直连;ShareLink 对象在 release-contract 表 1 补登记。 + +--- + +## 5. 设计与实现(指针,不复制) + +> **本节刻意不写内容。** spec 不承载 How。 + +| 你想知道 | 去哪看 | +|---|---| +| 为什么这么实现(决策 + 备选 + 何时该推翻) | design.md §3 方案对比 | +| 关键约束(双 DB / 多租户 / 权限 / 运行时依赖 / 错误码段) | design.md §2 | +| 今天的数据流、模块职责、字段 / API / 文件名约定 | design.md §4 系统现状 | +| 代码里看不出的坑、反直觉事实(含选人场景的两条查询路径、组织侧兜底路径、密钥表写路径与租户过滤、会话续期 vs 凭据校验的取向差异) | design.md §5 | +| 对外契约、改了会破坏谁、依赖谁 | design.md §6 | +| 任务拆解、文件清单、执行顺序、踩坑落档 | tasks.md | +| 基线代码核实速查(44 端点分类、两层改造方案、WS 分享页取证、F048 判定链) | [../000-prd1-discovery/baseline-recheck.md](../000-prd1-discovery/baseline-recheck.md) | + +--- + +## 相关文档 + +- 设计真相: [design.md](./design.md)(接手第一入口,尚未编写) +- 执行与落档: [tasks.md](./tasks.md)(尚未编写) +- 版本契约: [features/v3.0.0/release-contract.md](../release-contract.md)(写 spec 前必须先阅读;INV-27–31 与本 Feature 直接相关) +- Spec Discovery: [features/v3.0.0/000-prd1-discovery/discovery.md](../000-prd1-discovery/discovery.md) +- PRD: `docs/PRD/3.0-beta2/3.0 开放 API 鉴权与身份传递 PRD.md` v2.1(伴生册,本 Feature 的需求真相);`docs/PRD/3.0-release/3.0 应用工场 PRD-1 专业开发者通道与应用运行时.md` v2.0 DEV-01 / GOV-08 / GOV-10(上册总纲) + +--- + +## 附:AC 编号映射(2026-08-15 版 → 2026-08-17 终版) + +| 旧编号(2026-08-15 版) | 新编号 | 说明 | +|---|---|---| +| AC-01–AC-11 | AC-01–AC-11 | 不变(AC-01 补 share-token 例外;AC-08 补「名称」可编辑;AC-09 措辞改「撤销该账号全部密钥」) | +| — | AC-12 | 新增:密钥全生命周期事件计审计 | +| — | AC-13 | 新增:三扩展位随开放能力层出现(含 DEV-01 表单层增量) | +| — | AC-14 | 新增:本期不提供 `delegate` 位 | +| AC-12–AC-18 | AC-15–AC-21 | 顺移;旧 AC-13→AC-16 措辞按定义 5 | +| — | AC-22 | 新增:不得授予任何角色 / 管理员身份 / 主动加入组织单元 | +| — | AC-23–AC-27 | 新增:资源归属人(创建必填 + 租户不可换 + 风险提示;定义 6 边界 a–d) | +| — | AC-28 | 新增:归属人被禁用不联动 | +| AC-19 | AC-29 | 端点数 44 → 36 HTTP + 2 WS | +| AC-28(宽松档) | **作废** → AC-30(不暴露) | 6 个 `/chat/*` 端点不暴露 | +| AC-20–AC-24, AC-24a, AC-25–AC-27, AC-29 | AC-31–AC-40 | 顺移(AC-24→AC-35 补裸 `user_id` 残余说明;AC-24a→AC-36;旧 AC-29 统计下载→AC-40) | +| AC-30–AC-33 | AC-41–AC-46 | 管理界面重述为服务账号模块结构(新增概览内容、列表列、创建后引导签发、吊销文案、批量撤销入口) | +| — | AC-47 | 新增:停用保留授权(5 秒内拒绝) | +| AC-44(删除阻断) | **改写** → AC-48 | 删除不阻断、二次确认列清单 | +| — | AC-49 | 新增:服务账号模块恒在 | +| AC-34 | AC-50 | 升级 401 + 发布说明四项(三步指引 / 空窗 / 分享链接失效 / 6 端点清单) | +| AC-35 / AC-36(转型降权闸口) | **作废** → AC-51 | 零迁移、原默认操作员用户零变化 | +| — | AC-52 | 新增:`default_operator` 移除 | +| AC-37a | AC-53 | `enable_guest_access` 移除 | +| AC-37 | AC-54 | 文档修订 + 公开前缀模式 | +| AC-38–AC-40 | AC-55–AC-57 | 分享链接通道 | +| — | AC-58 | 新增:share-token 会话执行主体(决议-6 i) | +| AC-41 | AC-59 | 授权页唯一入口 | +| — | AC-60 | 新增:授予动作 | +| AC-42(两分组) | **改写** → AC-61 | 单列表 + 来源列 + 无创建者记录 | +| AC-43 | AC-62 | 撤销后资源权限错误 | +| — | AC-63 | 新增:全部撤销排除回授 | +| AC-45 | AC-64 | 另一层可见 | +| — | AC-65 | 新增:授权页审计 | diff --git a/features/v3.0.0/049-openapi-auth-baseline/tasks.md b/features/v3.0.0/049-openapi-auth-baseline/tasks.md new file mode 100644 index 0000000000..482c35610b --- /dev/null +++ b/features/v3.0.0/049-openapi-auth-baseline/tasks.md @@ -0,0 +1,692 @@ +# Tasks: 开放 API 鉴权底座(默认拒绝 + 服务账号密钥 + 资源归属人) + +**关联规格**: [spec.md](./spec.md)(65 条 AC,What 的唯一真相)· [design.md](./design.md)(How 的唯一真相,D1–D13 / 坑 1–27 / §4.2 契约) +**版本**: v3.0.0 +**纵切**: [mvp-114-path.md](../mvp-114-path.md) §2 F049 行——Wave 1–2 全部为 `[MVP-114]` 任务,其后 Wave 为 release 必做 +**代码事实口径**: 本文所有 `文件:行号` 沿用 design.md(`3.0-vibe` HEAD `b63a320f2`,2026-08-17 核实,路径以 `src/backend/bisheng/` 为根;前端另注 `platform/` = `src/frontend/platform/src/`、`client/` = `src/frontend/client/src/`)。行号会漂移,符号名不会——落地前以符号名重定位。 + +--- + +## 状态 + +| 步骤 | 状态 | 备注 | +|------|------|------| +| spec.md | ✅ 已评审 | 2026-08-17 ★ 已过(决议-6 f/g/h/i 拍板,65 AC 定稿) | +| design.md | ✅ 已评审 | 本轮(2026-08-17 初版 + 同日 `/sdd-review design` 两轮 26 条修订);接手时的第一入口 | +| tasks.md | ✅ 已拆解(2026-08-17) | 本文;同日 `/sdd-review tasks` 一轮 21 条修订(1 high / 8 medium / 12 low)已就地吸收 | +| 实现 | 📦 归档(2026-09-10) | vibe 实现至合并前止于 33 / 76(Wave 1 T001–T019 + Wave 2 T020–T033);实现由 beta2 F053 承接,见下方状态说明 | + +> **⚠️ 2026-09-10 状态说明**:open_api 底座整体改接 beta2 F053(合并 `feat/3.0.0-beta2`,见 [beta2-openapi-base-migration.md](../beta2-openapi-base-migration.md))。Wave 1–2(T001–T033)的 vibe 实现已归档、由 beta2 同等能力取代;**T034–T075 不再逐条推进**,去向按该方案 §5:T034–T046(端点接入 / 缺陷修复 / 配置移除)已由 beta2 实现、T047–T056(share-token)beta2 明确不采纳、T057–T071(资源归属人 / 主体侧授权 / 对账豁免 / 管理接口矩阵)由 beta2 `ResourceGrantsTab` + `owner_repository` 承接、T072–T075 随 beta2 F053 发布验收。本文其余内容保留为历史,不再更新。 + +--- + +## 开发模式 + +**按 Wave 组织任务**:Wave 1–2 = `[MVP-114]` 纵切首波(design D13 / K10:建号 → 签发 → `whoami` → `app:manage` 判定 + 三处结构性封口),部署 114 后即可支撑 F053 `login` / F055 复用 `Depends`;Wave 3–6 = 既有 v2 面收口与 release 必做项(INV-27 要求发版前全做,**F049 不得脱离 F050 单独对外发版**,K11)。每个任务标 `依赖:`,无依赖的可并行。 + +**后端 Test-First**:测试任务先于配对实现任务,`覆盖 AC` 逐条列举(禁范围写法)。基础设施任务(Alembic / ORM / 错误码 / Settings / conftest / 注册表)无测试配对、排最前。单测放 `src/backend/test/open_api/` 等 `test//`(不放 `test/` 根),`asyncio_mode=auto`。集成测试连 test 中间件(MySQL / Redis / OpenFGA),在 CI 跑;DM8 用例在 105 回归。 + +**HTTP 状态断言口径**(design K4 / D2 / §4.2):`/api/v2/**` 断言**真 HTTP 状态**(401 / 403 / 404 / 503);`/api/v1/service-accounts/**` 等管理端点断言**信封 `status_code`**(HTTP 仍 200)。 + +**前端**:Platform 任务附「手动验证」步骤(Playwright 未落地);本 Feature **含 Client 任务**(design D8:两个免登录分享页真身在 client `pages/standaloneChat/`,platform 旧页已随 `36beaa00f` 删除——原「本 Feature 无 Client」的假设已被 design 推翻)。platform `react-query` 已冻结(坑 24),新页面用 `useTable` / `useState + useEffect`;新增 i18n key 三语同 PR。 + +**自包含任务**:每个任务内联文件、逻辑、AC 覆盖;设计论证指向 design §X 不复制。 + +**编号 ≠ 执行顺序**(评审修订):Wave 1 内 validator 测试需要真实服务账号主体,故实际执行顺序为 T009 → T010 → T013 → T014 → T011 → T012(`依赖:` 已登记,编号保留不重排);T047(`share_link.share_scope` Alembic)是无依赖的基础设施任务,实际与 T001 同批执行(Wave 1),编号保留在 Wave 4 只为与 share-token 通道叙事相邻。 + +**跨 Feature 副作用登记**(release-contract 表 1 / 检查项 17):T001(`User` 表加列,ServiceAccount 对象归 F049 但 `User` 表本身不归)· T016 / T019 / T058 / T059 / T064(改 `user` DAO 默认过滤、`grant_subject_service` / `canonical_source` / F048 runtime / `permission.application`——PermissionGrant 归 F048,本 Feature 只增显式参数与新来源值)· T024(`MANAGEMENT_API_PREFIXES` 加项 = F019 元组扩展)· T040(`open_endpoints` router 挂全局依赖 + 删 `chat.py`)· T042(`worker/workflow/redis_callback.py set_workflow_status` 状态载荷加 `owner_user_id / tenant_id`——workflow worker 写 / `stop` 端点读的共享载荷,加键不删键、老读者无感)· T047 / T049(ShareLink 写增量,表 1 已登记归 F049)· T050(`bisheng/api/router.py` 新增 `app_shares_router` 直接挂接)· T069(`tenant_reconcile` / `quota_service` 加类型条件)。 + +--- + +## Tasks + +### Wave 1 · `[MVP-114]` 基础设施(无测试配对,排最前) + +- [x] **T001**: `[MVP-114]` Alembic:`user.user_type` 列 + **文件**: `src/backend/bisheng/core/database/alembic/versions/v3_0_0_f049_user_user_type.py`(新,模板 `v2_5_1_f012_user_token_version.py`), `src/backend/bisheng/user/domain/models/user.py`(`User` 模型加 `user_type: str = Field(default='human', max_length=16, index=True)`) + **逻辑**: `upgrade()`:`ALTER TABLE user ADD COLUMN user_type VARCHAR(16) NOT NULL DEFAULT 'human'` + `ix_user_user_type` 索引;`VARCHAR` 不用 `CHAR`(K5 DM8);`server_default` 是 revision 允许的唯一数据效果(AC-51 零迁移:不 UPDATE 任何存量行、存量全部按缺省 `human`)。 + **回滚**: `downgrade()` 删索引 + 删列;服务账号行(`user_type='service'`)在 downgrade 后退化为普通用户行——因此 downgrade 前须先删全部服务账号(T014 delete)或接受其变为可见普通用户;说明写在 revision docstring。 + **跨 Feature**: `User` 表加列,自然人侧行为不变(缺省 `human`);影响面 = 全部 `User` 查询无感、`_filter_users_statement` 默认过滤在 T016 才生效。 + **依赖**: 无 + +- [x] **T002**: `[MVP-114]` ORM `api_credential` / `service_account` 模型 + DAO + 租户感知注册 + **文件**: `src/backend/bisheng/open_api/domain/models/api_credential.py`(新), `src/backend/bisheng/open_api/domain/models/service_account.py`(新), `src/backend/bisheng/core/database/tenant_filter.py`(`_TENANT_AWARE_MODEL_MODULES` 登记 `bisheng.open_api.domain.models`) + **逻辑**: 按 design §4.2 建两表(继承 `SQLModelSerializable`,含 `tenant_id / create_time / update_time`):`api_credential`(`subject_kind VARCHAR(32)` / `subject_id VARCHAR(64)` / `key_prefix VARCHAR(16)` / `last4 VARCHAR(4)` / `token_hash VARCHAR(64) unique` / `scopes JsonType` / `expires_at` / `revoked_at` / `last_used_at` / `revoke_reason VARCHAR(32)` / `created_by`;**无 status 枚举列**,K3);`service_account`(`user_id` PK FK user / `tenant_id` / `resource_owner_user_id` / `description` / `created_by` / `disabled_at` / `deleted_at`)。DAO 只提供**单行 ORM**读写:`ApiCredentialDao.aget_by_hash(session)`(调用方负责 `bypass_tenant_filter()`)/ `alist_by_subject` / `aupdate_row`;`ServiceAccountDao.acreate_with_user(session, ...)`(接受外部 session、不在 DAO 内开会话,D1 建号事务)/ `aget` / `alist_page` / `aset_timestamps`。**禁**批量 UPDATE / DELETE 与 `text()`(K6)。新表靠 `create_all(checkfirst=True)`(K5)。 + **回滚**: 新表无 Alembic;回滚 = 删两表(本 Feature 之前无数据、无外部依赖);`api_credential` 撤销记录是审计资产,删表前 dump。 + **依赖**: T001 + +- [x] **T003**: `[MVP-114]` 错误码 260 段 + C5 登记 + 三语 + **文件**: `src/backend/bisheng/common/errcode/open_api.py`(新), `docs/constitution.md`(C5 登记表 260 段由 reserved 改 implemented), `src/frontend/packages/locales/src/api_errors/{zh-Hans,en,ja}.json`(三语视为一组) + **逻辑**: 按 design D10 定义 `OpenApiAuthError(BaseErrorCode)` 基类(携带 `http_status`)与本期启用码:`26001`(401) / `26002`(401) / `26003`(403,`data.required` 指明缺哪位) / `26004`(403) / `26012`(403) / `26020`–`26031`(含 `26030` 503、`26031` 500)。`26013 / 26014` 不复用;`26005–26007 / 26010 / 26016` 留 F050。三语文案落 `packages/locales`(K12:缺任一语 CI `check-i18n` 失败);生成物 `platform/public/locales/*/api_errors.json` / `client/src/locales/*/api_errors.gen.json` 由脚本生成、不手改。 + **依赖**: 无 + +- [x] **T004**: `[MVP-114]` 开放能力层开关三段式(后端两段)+ `open_api` Settings + **文件**: `src/backend/bisheng/core/config/open_platform.py`(新:`OpenPlatformConf(enabled: bool = False)`), `src/backend/bisheng/core/config/settings.py`(`:782` `multi_tenant` 旁加 `open_platform: OpenPlatformConf` 与 `open_api: OpenApiConf(service_account_idle_days=90, credential_cache_ttl_seconds=3 上限 5)`), `src/backend/bisheng/api/v1/endpoints.py`(`get_env :60-108` 增 `open_platform_enabled`) + **逻辑**: 进程级 config.yaml、非 DB 热配置(K8 / D9 S2,先例 `multi_tenant.enabled`)。**部署顺序坑 22**:`load_settings_from_yaml` 对未知顶层键拒启 → 先发代码再加 yaml 键(写进 T033)。 + **依赖**: 无 + +- [x] **T005**: `[MVP-114]` `OPEN_API_SCOPES` 注册表 + `@open_api_scope` 标记 + ContextVar + **文件**: `src/backend/bisheng/open_api/domain/scopes.py`(新), `src/backend/bisheng/open_api/domain/context.py`(新) + **逻辑**: `OPEN_API_SCOPES`:每位 `code / group / label_key / desc_key / endpoints[] / requires_open_platform / pending_note_key`(D9)——基础位 `workflow:invoke/read`、`knowledge:read/write`、`assistant:invoke/read`、`chat:invoke`(`endpoints=()` + `pending_note_key`「端点随后续版本开放」)+ 三扩展位 `model:invoke / identity:read / app:manage`(`group='local_dev_toolkit'`,`requires_open_platform=True`);**不登记 `delegate`**(AC-14)。端点映射表按 D3(38 个:`workflow:invoke` 3 / `workflow:read` 1 / `assistant:invoke` 4 / `assistant:read` 2 / `knowledge:read` 7 / `knowledge:write` 21)。`@open_api_scope(scope: str | None, allow_share_token=False)` 纯标记装饰器(只设函数属性、无 FastAPI 依赖,v2 端点文件 import 它不触 RULE-5);`visible_scopes(open_platform_enabled)` 供 `/scopes` 端点与签发校验。`context.py`:`current_open_api_principal: ContextVar[OpenApiPrincipal | None]`(`credential_id / subject_kind / subject_user_id / resource_owner_user_id / share_link_id / scopes`,§4.2 `UserPayload.open_api_principal` 形状)。 + **依赖**: 无 + +- [x] **T006**: `[MVP-114]` 请求 / 响应 schemas + **文件**: `src/backend/bisheng/open_api/domain/schemas/service_account.py`(新), `src/backend/bisheng/open_api/domain/schemas/credential.py`(新) + **逻辑**: pydantic:`ServiceAccountCreate(name, description, resource_owner_user_id)` / `ServiceAccountUpdate` / `ServiceAccountItem`(列表列:名称 / 状态 / 有效密钥数 / 归属人(含 `owner_disabled`)/ 最后调用 / 创建人 / 创建时间;分页 `PageData` + meta `idle_days`);`KeyIssueRequest(name, scopes, expires_at?)` / `KeyUpdateRequest` / `KeyItem`(掩码 = `bs-sak-********` + 末四位)/ `KeyIssuedResponse`(唯一含 `plaintext` 的模型)/ `WhoamiResponse`(`subject_kind / service_account{id,name} / tenant_id / scopes / key_mask / expires_at`)。 + **依赖**: T005 + +- [x] **T007**: `[MVP-114]` 审计动作后端 lockstep 登记 + **文件**: `src/backend/bisheng/database/models/audit_log.py`(`_UI_VISIBLE_V2_ACTIONS :178-201` + `_V2_NAMESPACE_TO_ACTION_PREFIX :207-211` 加 `open_api.`) + **逻辑**: 登记 D11 全部 action:`open_api.service_account.{create,update,enable,disable,delete}`、`open_api.api_key.{issue,update,revoke,revoke_all,expire,invalidate_by_subject}`、`open_api.grant.{add,update,remove,remove_all}`、`open_api.share_link.{revoke,expire}`、`open_api.ws.connect`(后三族 Wave 4–5 才产生事件,登记一次到位避免二次改同一处)。前端两处 lockstep 见 T032。 + **依赖**: 无 + +- [x] **T008**: `[MVP-114]` `test/open_api/conftest.py` 测试基础设施 + **文件**: `src/backend/test/open_api/conftest.py`(新), `src/backend/test/open_api/__init__.py`(新) + **逻辑**: fixtures:`tenant_admin_payload`(非超管的租户管理员 `UserPayload`,避免 admin 短路)/ `human_user`(本租户已启用自然人,作归属人)/ `service_account_factory`(经 T014 `ServiceAccountService.create` 建号——**fixture 体内惰性 `import`**,conftest 顶层不 import T010 / T014 模块,T009 阶段整包收集不因模块缺失而失败)/ `credential_factory`(经 T010 签发,返回明文 + 行;同样惰性 import)/ `redis_client`(真 Redis,`oapi:*` 键用后清理)/ `v2_client`(`TestClient(app, raise_server_exceptions=False)`,断言真 HTTP 状态)/ `sub_tenant`(多租户子租户 + 其管理员,用于跨租户断言)/ `fga_down`(monkeypatch 让 F048 判定抛 `PermissionServiceUnavailableError`)。SOCKS 代理清理:`test/conftest.py` **无**任何 proxy / socks 处理(原锚点已失效),仓内先例只有 `test/linsight/test_e2e_llm_resilience.py`(清 `HTTP(S)_PROXY / ALL_PROXY` env)——本 conftest 自带 `autouse` fixture 清代理 env(memory:缺 `socksio` 整批误报 ERROR)。 + **依赖**: T002 + +### Wave 1 · `[MVP-114]` 后端 Domain(Test-First) + +- [x] **T009**: `[MVP-114]` `CredentialService` 单元测试 + **文件**: `src/backend/test/open_api/test_credential_service.py`(新) + **逻辑**: `test_issue_returns_plaintext_once_and_stores_hash_only`(明文以 `bs-sak-` 起、43 位 urlsafe、DB 只有 sha256、`key_prefix / last4` 正确、掩码格式 `bs-sak-********xxxx`)→ AC-02;`test_default_scopes_empty_and_unknown_scope_rejected_26025` → AC-06;`test_issue_hosted_app_kind_accepted`(`subject_kind='hosted_app'` 可存可撤,D2「主体解析器」承诺);`test_validity_predicate`(撤销 / 过期 / 边界时刻 `expires_at == now`)→ AC-05;`test_revoke_soft_keeps_row_and_history`(`revoked_at` 置位、行与归属保留、`revoke_reason='manual'`)→ AC-11;`test_revoke_deletes_cache_key`(撤销后 `oapi:cred:{hash}` 不存在,主动失效不靠 TTL)→ AC-03;`test_update_scopes_name_expires_invalidates_cache_immediately` → AC-08;`test_revoke_by_subject_batch`(名下全部 `revoked_at`、`revoke_reason='batch'`、逐键 `adelete`)→ AC-09;`test_touch_last_used_throttled_60s`(`SET NX EX 60` 闸内只写一次单行 UPDATE)→ AC-10;`test_expire_lazy_marks_reason_once`(过期首次校验拒绝时 `revoke_reason='expired'`、`revoked_at` 仍 NULL、并发两次只一次 `rowcount==1`)→ AC-05 / AC-12;`test_audit_events_never_contain_plaintext`(issue / update / revoke / revoke_all / expire 五事件的 `metadata` 只含掩码)→ AC-12。 + **覆盖 AC**: AC-02, AC-03, AC-05, AC-06, AC-08, AC-09, AC-10, AC-11, AC-12 + **依赖**: T002, T003, T005, T008 + +- [x] **T010**: `[MVP-114]` `CredentialService` 实现 + **文件**: `src/backend/bisheng/open_api/domain/services/credential_service.py`(新) + **逻辑**: `issue(subject_kind, subject_id, tenant_id, name, scopes, expires_at, created_by)`:`secrets.token_urlsafe(32)`(坑 17:不用 `generate_short_high_entropy_string`)→ 明文 `'bs-sak-' + 43 位` → `sha256` → 单行写 → 审计 `open_api.api_key.issue`(`AuditLogDao.ainsert_v2`,`metadata` 只掩码)→ 返回明文(唯一出口)。`update / revoke(reason) / revoke_by_subject(reason) / list_by_subject / touch_last_used`;每次写后 `invalidate_cache(hashes)`(按 DB 清单逐个 `adelete('oapi:cred:{hash}')`,不 `SCAN`,K1 / K7;每次调用累加 `open_api.cache.invalidate` 计数 / 结构化日志,D11 指标由此产生);`touch_last_used`:`asetNx('oapi:cred:lastused:{id}', 1, ex=60)` 成功才做带主键单行 UPDATE(坑 20 DM8 写放大);`mark_expired_lazy(id)`:`UPDATE ... SET revoke_reason='expired' WHERE id=:id AND revoke_reason IS NULL`,`rowcount==1` 才审计 `open_api.api_key.expire`(`operator_id=0` → `system`)。scopes 校验:`∉ OPEN_API_SCOPES` → 26025;含 `delegate` → 26024;三扩展位而 `settings.open_platform.enabled` 为 False → 26023。 + **测试**: T009 全部通过 + **覆盖 AC**: AC-02, AC-03, AC-05, AC-06, AC-08, AC-09, AC-10, AC-11, AC-12 + **依赖**: T009 + +- [x] **T011**: `[MVP-114]` `credential_validator` 单元 + 集成测试 + **文件**: `src/backend/test/open_api/test_credential_validator.py`(新) + **逻辑**: `test_missing_or_malformed_bearer_26001` / `test_unknown_hash_26002_constant_time`(`hmac.compare_digest`)→ AC-01;`test_revoked_rejected_within_5s`(撤销后立即校验被拒;采样多次 ≤ 3s)→ AC-03;`test_expired_rejected_even_when_cache_hit`(缓存命中路径每请求比对 `expires_at`)→ AC-05;`test_subject_disabled_or_deleted_rejected_26027`(伴生表两时间戳任一非空 → 拒)→ AC-21 / AC-47;`test_subject_tenant_mismatch_rejected`(主体活跃租户 ≠ 密钥租户 → 26002;spec §3 跨租户组合一律拒绝);`test_sets_tenant_context_and_visible_tenants`(校验通过后 `get_current_tenant_id()==密钥租户`;子租户密钥 `visible == {leaf, 1}`、Root `{1}`;坑 2 / D2「可见租户」)→ AC-32;`test_user_payload_constructed_directly`(`is_global_super=False`、`user_role=[]`、`open_api_principal` 填充、**不调 `init_login_user`**)→ AC-32;`test_hosted_app_without_resolver_26002`(`SUBJECT_RESOLVERS` 只注册 `service_account`);`test_redis_down_fail_closed_26030` / `test_db_down_fail_closed_26030`(monkeypatch `get_redis_client` / session 抛错 → 拒绝而非放行,K2)→ AC-34;`test_disabled_tenant_blacklist_rejects`(`DISABLED_TENANT_KEY` 命中 → 拒);`test_cache_payload_minimal_not_pickled_userpayload`。 + **覆盖 AC**: AC-01, AC-03, AC-05, AC-21, AC-32, AC-34, AC-47 + **依赖**: T010, T014, T008(`service_account_factory` 需 T014 建号 Service,见「编号 ≠ 执行顺序」) + +- [x] **T012**: `[MVP-114]` `credential_validator` 实现 + **文件**: `src/backend/bisheng/open_api/domain/services/credential_validator.py`(新), `src/backend/bisheng/user/domain/services/auth.py`(`LoginUser :196` 加可选字段 `open_api_principal: 'OpenApiPrincipal | None' = None`——`UserPayload(LoginUser)`(`common/dependencies/user_deps.py:8`)自动继承、该文件不改;类型经 `TYPE_CHECKING` 前向引用或把 `OpenApiPrincipal` 定义为不 import `user` 域的独立 pydantic 模型(`open_api/domain/context.py`,T005),避免 `user ↔ open_api` 循环 import) + **逻辑**: 纯 domain 函数 `validate_bearer(token: str) -> OpenApiPrincipal + UserPayload`,校验顺序固定为 D2:提取 Bearer → `bypass_tenant_filter()` 下 `aget_by_hash(sha256)`(K6)→ 恒时比较(先例 `sso_sync/domain/services/hmac_auth.py:103`)→ 未撤销 → 未过期(过期则 `mark_expired_lazy` + 拒 26002)→ 按 `subject_kind` 分派 `SUBJECT_RESOLVERS`(F049 只注册 `service_account`:`user_type=='service'` ∧ 伴生表两时间戳皆空 ∧ `UserTenant.status=='active' ∧ is_active==1` 且 == 密钥租户;`hosted_app` 无解析器 → 26002)→ 回填 Redis `oapi:cred:{sha256}`(最小载荷、TTL = `settings.open_api.credential_cache_ttl_seconds`)→ **每请求(含命中)**:`expires_at` 比对 + `DISABLED_TENANT_KEY` 黑名单(`tenant/domain/services/tenant_service.py:44`)→ `set_current_tenant_id(密钥租户)`(K9 无条件覆盖)+ `set_visible_tenant_ids`(复刻 `http_middleware.py:210-231 _compute_visible_tenant_ids`)→ 直接构造 `UserPayload(user_id, user_name, user_role=[], tenant_id, token_version=0, is_global_super=False, open_api_principal=...)`(坑 4:不调 `init_login_user` / `init_login_user_sync`)→ 写 `current_open_api_principal`。任何 Redis / DB 异常 → `26030`(503,K2 fail-closed)。`open_api_principal` 字段位置见上文文件清单(`LoginUser :196`,缺省 `None`)。 + **测试**: T011 全部通过 + **覆盖 AC**: AC-01, AC-03, AC-05, AC-21, AC-32, AC-34, AC-47 + **依赖**: T011 + +- [x] **T013**: `[MVP-114]` `ServiceAccountService` 单元测试 + **文件**: `src/backend/test/open_api/test_service_account_service.py`(新) + **逻辑**: `test_create_single_transaction`(一个 session 内 `User(user_type='service', source='service_account', external_id=None, password=哨兵)` → flush → `ServiceAccount` → `UserTenant(status='active', is_active=1)` → 单次 commit;模拟中途异常 → 三表皆无残行)→ AC-19;`test_create_requires_owner_human_active_same_tenant`(缺归属人 / 归属人是服务账号 / 已禁用 / 他租户 → 26021)→ AC-23;`test_tenant_taken_from_admin_context_not_from_body`(请求体带 `tenant_id` 被忽略;`admin-scope` 下取当前作用域租户)→ AC-23;`test_created_account_is_grantable_immediately`(`aget_active_user_tenant` 有行、`is_active==1`,坑 8)→ AC-19;`test_disable_invalidates_keys_within_5s`(`disabled_at` 置位 → 名下缓存键全删 → 校验拒 26027;`revoke_reason` 不改、密钥行不动)→ AC-21 / AC-47;`test_enable_restores`(清 `disabled_at` → 校验恢复、授权与配置未动)→ AC-47;`test_delete_wave1_shape`(撤销全部密钥 `revoke_reason='subject_deleted'` + `deleted_at` + `user.delete=1` 投影 + 审计;Wave 5 T065 升级为反查 REMOVE 前置步)→ AC-21 / AC-48;`test_change_owner_not_retroactive`(换归属人只改伴生表列,历史 creator 列不动——断言 Service 不发起任何对 `knowledge` 等业务表的写)→ AC-27;`test_owner_disabled_does_not_cascade`(禁用归属人 → 服务账号与密钥仍有效;列表行 `owner_disabled=True`)→ AC-28;`test_audit_account_family_events`(create / update / enable / disable / delete 五事件 + 密钥失效 `invalidate_by_subject` 带触发原因)→ AC-12。 + **覆盖 AC**: AC-12, AC-19, AC-21, AC-23, AC-27, AC-28, AC-47, AC-48 + **依赖**: T010, T008 + +- [x] **T014**: `[MVP-114]` `ServiceAccountService` 实现 + **文件**: `src/backend/bisheng/open_api/domain/services/service_account_service.py`(新) + **逻辑**: `create(admin, name, description, resource_owner_user_id)`(D1 建号事务:`async with get_async_db_session()` 内三 add + 一次 commit;**不复用** `UserDao.create_user` / `aactivate_user_tenant` / `user_register` / `add_user_and_default_role`(坑 6:后者会把它塞进 guest 部门);`external_id=NULL` 固定,坑 5);`update(name/description/owner)`(归属人校验同 create);`enable / disable`(写伴生表时间戳 + 同事务写穿 `user.delete` 投影 + `CredentialService.invalidate_cache(名下全部)` + 审计);`delete`(Wave 1 形态:`revoke_by_subject('subject_deleted')` → `deleted_at` → 投影 → 审计;T065 前插反查 REMOVE);`list_page(tenant, q, page)`(伴生表自动租户过滤兜住 AC-07;水合:`user_name` 走 `aget_user_by_ids`、有效密钥数、归属人名 + `owner_disabled`、`last_used_at = max(名下密钥)`、`idle_days` 阈值随 meta);`get(id)`;读侧状态只读伴生表两列不读 `user.delete`(D1「读侧口径统一」)。归属人校验:`aget_user` 存在 ∧ `delete==0` ∧ `user_type=='human'` ∧ 活跃租户 == 当前租户,否则 26021。 + **测试**: T013 全部通过 + **覆盖 AC**: AC-12, AC-19, AC-21, AC-23, AC-27, AC-28, AC-47, AC-48 + **依赖**: T013 + +- [x] **T015**: `[MVP-114]` 登录守卫 / `assert_natural_persons` / DAO 排除 测试 + **文件**: `src/backend/test/user/test_service_account_login_guard.py`(新) + **逻辑**: **登录守卫矩阵**(集成):本地登录 `/user/login`(`user/domain/services/user.py:460`)、旧 SSO `/user/sso`(`user/api/user.py:97`,按用户名匹配,坑 5)、login-sync HMAC(`sso_sync/domain/services/login_sync_service.py:221`)、切租户(`tenant/domain/services/tenant_service.py:662`)四入口对服务账号一律 **`raise` 26012**(断言不是被压成「无菜单」的 `UserNoWebMenuForLoginError`,坑 3);`test_guard_runs_before_admin_role_shortcut`(给服务账号误挂 AdminRole 仍 26012;守卫在 `:388 bypass` 与 `:394 AdminRole return` 之前)→ AC-15;`test_login_candidates_exclude_service`(`aget_login_candidates_by_account` 第二道锁)→ AC-15。**测试降级(AC-15 其余入口)**:Java 网关登录同步 / SSO 网关回调 / 商业版 license 登录等入口只能在 109 联调环境手动验证(网关不在本仓、无 TestClient 可达)——理由:这些入口最终都汇入上述四条守卫路径之一(design 坑 3「公共守卫 4 个调用点」),自动化四条 + 手动其余;手动步骤写进 T075。**DAO 排除**:`_filter_users_statement` 默认 `user_type='human'`、显式 `user_type=None` 才含;`/user/list` 超管与非超管两分支均不含服务账号;`aget_user_by_ids` **仍能**取到(名称水合不过滤)→ AC-16。**管理接口**:`assert_natural_persons([sa_id])` 抛 26022;`/user/update` 对服务账号(含只翻 `delete` 位)→ 26022;`/user/reset_password` → 26022 → AC-20;`/user/role_add`(任意角色含管理员角色)→ 26022;`grant_tenant_admin` → 26022 → AC-22。 + **覆盖 AC**: AC-15, AC-16, AC-20, AC-22 + **依赖**: T014, T008 + +- [x] **T016**: `[MVP-114]` 用户域守卫与 DAO 排除实现 + **文件**: `src/backend/bisheng/user/domain/services/user.py`(`_reject_login_if_user_has_no_usable_access :373`:**最前**(`:388 bypass` 之前)`if user.user_type == 'service': raise ServiceAccountLoginForbiddenError()`(26012);新增 **同步** `assert_natural_persons(user_ids: list[int]) -> None`(`UserDao.get_user_by_ids`,供同步调用方 T071 `role_group_service` 三方法)与 **异步** `aassert_natural_persons(user_ids)`(`aget_user_by_ids`,供 async 端点 / Service:T017 `/user/update` `:664` 等三端点均 `async def`、`grant_tenant_admin :37` async、T071 `aadd_members :1749` / `aset_admins :1986` async);两者语义相同:任一目标 `user_type=='service'` → 26022), `src/backend/bisheng/user/domain/models/user.py`(`_filter_users_statement :259-264` 增 `user_type: str | None = 'human'` 默认参数并透传到 `filter_users :267` / `afilter_users :279`;`aget_login_candidates_by_account :212-223` 加 `User.user_type == 'human'` 第二道锁) + **逻辑**: 见 D7 / 坑 3 / 坑 5 / 坑 10(`get_all_users / search_user_by_name / get_user_with_group_role / get_user_by_ids / aget_user_by_ids / get_unique_user_by_name / aget_users_by_username / aget_by_source` 旁路**逐个判定不动**:名称水合保持不过滤)。`raise` 而非 `return`(`tenant_service.py:663-665` / `login_sync_service.py:228-230` 会把非 `UserNoRoleForLoginError` 返回值压成「无菜单」)。 + **跨 Feature**: `_filter_users_statement` 是 `/user/list` 全部 8 处 platform 消费点的共同底座(E2 §1.3)——默认排除即全部生效;影响面 = 用户管理 / 审批人 / 部门加成员 / 用户组加成员 / 创建租户选管理员等选人框不再显示服务账号(AC-16 预期行为)。 + **测试**: T015 守卫 / DAO / `assert_natural_persons` 部分通过 + **覆盖 AC**: AC-15, AC-16, AC-20, AC-22 + **依赖**: T015 + +- [x] **T017**: `[MVP-114]` 管理接口拒绝(用户 API + 租户管理员) + **文件**: `src/backend/bisheng/user/api/user.py`(`/user/update :663-724`(含 `:709` 直写 `delete` 位)、`/user/reset_password :1032-1066`、`/user/role_add :874-965` 入口调 `assert_natural_persons`), `src/backend/bisheng/tenant/domain/services/tenant_admin_service.py`(`grant_tenant_admin :37-58` 入口调 `assert_natural_persons`) + **逻辑**: 三个用户 API 与租户管理员授予(均 async)在做任何写之前调 `aassert_natural_persons` 断言目标为自然人,否则 26022(AC-20 / AC-22 的「管理员身份 / 角色」部分;部门 / 用户组其余矩阵项 Wave 6 T071)。`/user/change_password :1067`(改自己)与 `change_password_public :1095`(按 `external_id`)对服务账号结构性不可达,不加。 + **测试**: T015 管理接口部分通过 + **覆盖 AC**: AC-20, AC-22 + **依赖**: T016 + +- [x] **T018**: `[MVP-114]` 授权候选过滤 + 授权主体校验拒绝 测试 + **文件**: `src/backend/test/permission/test_service_account_subject_exclusion.py`(新) + **逻辑**: `test_list_candidate_users_excludes_service`(`grant_subject_service.list_candidate_users`——两个资源侧授权弹窗的独立查询,不走 `/user/list`)→ AC-16;`test_display_names_still_resolves_service_account`(`f048_permission_subject.display_names :110` 走 `aget_user_by_ids`,已授权对象列表可显示)→ AC-16;`test_canonical_source_rejects_service_account_subject_26029`(资源侧 `POST /api/v1/permissions/resources/{t}/{id}/grants:mutate` 授服务账号 → 26029);`test_canonical_source_allows_when_explicit_flag`(`allow_service_account_subject=True` 上下文放行——主体侧端点 T065 用)→ INV-29 结构性保证(AC-16 失败方向 = 看不到 / 授不进)。 + **覆盖 AC**: AC-16 + **依赖**: T014, T008 + +- [x] **T019**: `[MVP-114]` 授权候选过滤 + `canonical_source` 拒绝 实现 + **文件**: `src/backend/bisheng/permission/domain/services/grant_subject_service.py`(`list_candidate_users :70-121`,条件 `:90` 加 `User.user_type == 'human'`), `src/backend/bisheng/tenant/domain/services/f048_permission_subject.py`(`canonical_source :53-71` 对 `user` 主体查到 `user_type=='service'` → 26029,除非 `allow_service_account_subject=True`), `src/backend/bisheng/permission/application/resource_api.py`(`PermissionSubjectDirectoryPort.canonical_source :45` 协议签名加 keyword-only `allow_service_account_subject: bool = False`;`mutate_grants :288` 加同名 kw(缺省 False)并在 `:308` 调用处透传) + **逻辑**: D7 三处结构性封口的后两处(第一处在 T016)。`allow_service_account_subject` 经 application 层显式参数传入(D6 W2):`resource_api.mutate_grants(allow_service_account_subject=...)` → Port → `canonical_source`;资源侧端点永不设置,主体侧端点 T065 传 True。`channel/domain/services/f048_channel_permission.py:292` 也有同名 `canonical_source` 但签名不同(带 `actor`、无 `tenant_id / userset_relation`,非该 Port 的实现)——落地时核实:若它被当作 `PermissionSubjectDirectoryPort` 注入则同步加缺省参数,否则不动。 + **跨 Feature**: 改 F048 主体校验层(PermissionGrant 归 F048)——只加拒绝条件与显式放行参数,不改判定链;资源侧授权弹窗与 `grants:mutate` 对自然人行为不变。 + **测试**: T018 全部通过 + **覆盖 AC**: AC-16 + **依赖**: T018 + +### Wave 2 · `[MVP-114]` 后端 API 层(Test-First) + +- [x] **T020**: `[MVP-114]` 鉴权依赖 + 专属 handler + `whoami` 集成测试 + **文件**: `src/backend/test/open_api/test_open_api_auth_api.py`(新) + **逻辑**: 对 `GET /api/v2/auth/whoami` 断言**真 HTTP 状态**:无头 → 401 `26001`、改一位 → 401 `26002`(信封 `{status_code,status_message,data}` 形状不变)→ AC-01;`test_scope_none_marker_skips_scope_check`(`whoami` 标 `@open_api_scope(None)`,无位也通过);`test_unregistered_endpoint_26031`(临时挂一个无标记路由到带依赖的 router → 500 `26031`,结构性 fail-closed);`test_missing_scope_403_with_required`(临时挂 `@open_api_scope('assistant:read')` 路由,密钥无该位 → 403 `26003` `data.required=='assistant:read'`)→ AC-04;`test_app_manage_scope_check_via_factory`(`Depends(open_api_subject('app:manage'))` 工厂:有位通过 / 无位 403——F053 / F055 复用面)→ AC-04 / AC-13;`test_extension_scope_issue_rejected_when_platform_off`(`open_platform.enabled=False` 下签发含 `app:manage` → 26023;开后放行且 `whoami.scopes` 含之)→ AC-13;`test_identity_headers_rejected_26004`(`X-Bisheng-On-Behalf-Of` / `X-Bisheng-End-User` 任一存在 → 403 `26004`,且业务未执行)→ AC-33;`test_no_header_runs_as_key_subject`(`whoami.service_account.id == 主体`、`tenant_id == 密钥租户`)→ AC-32;`test_only_two_outcomes_no_silent_downgrade`(同一端点:无头 = 模式 S 结果;有头 = 26004;不存在返回空集 / 公开子集的第三种响应)→ AC-35;`test_fga_unavailable_503`(`fga_down` fixture + 临时路由内调 `require_business_action` → HTTP 503,无业务数据)与 `test_redis_down_503_26030` → AC-34;`test_v1_envelope_unchanged`(`/api/v1` 任一错误仍 HTTP 200 + 信封,K4 只对 v2 真实化)。 + **覆盖 AC**: AC-01, AC-04, AC-13, AC-32, AC-33, AC-34, AC-35 + **依赖**: T012, T008 + +- [x] **T021**: `[MVP-114]` `open_api/api/dependencies.py` + `whoami` 端点 + **文件**: `src/backend/bisheng/open_api/api/dependencies.py`(新), `src/backend/bisheng/open_api/api/endpoints/auth.py`(新:`GET /auth/whoami`,`@open_api_scope(None)`) + **逻辑**: `verify_open_api_access(conn: HTTPConnection)`(router 级依赖,HTTP + WS 同一函数;`isinstance(conn, WebSocket)` 分流——WS 分支的 share-token 解析随 T052 填入,本任务 WS 只走密钥分支):调 `credential_validator.validate_bearer` → 读 `conn.scope['endpoint']` 上的 `@open_api_scope` 标记:**无标记 → 26031**;`scope=None` → 跳过位判定;有位 → `scope ∉ principal.scopes` → 26003(`data.required`)→ 身份传递头存在 → 26004(AC-33)→ `CredentialService.touch_last_used`。**可观测(design D11 / §7)**:依赖出口统一写一条结构化日志 `open_api.call`(`credential_id / subject_kind / subject_user_id / tenant_id / path / scope / outcome(ok|错误码) / latency_ms`,`logger.bind(event=...)`,成功与拒绝都写、不写明文);拒绝路径累加计数 `open_api.auth.reject{code}`(沿用仓内既有 metrics 出口,若无则先只落结构化日志、指标名保留);`open_api.cache.invalidate` 计数由 T010 `invalidate_cache` 内累加(本任务不重复)。`open_api_subject(scope: str | None) -> Depends` 工厂(供 F053 / F055 新 router:等价于「router 级依赖 + 端点级位判定」一体化);`get_service_account_admin`(管理端点依赖:内部调 `UserPayload.get_tenant_admin_user`(`common/dependencies/user_deps.py:50-75`),把它抛出的 `LLMModelSharedReadonlyError`(19801)**改抛 `UnAuthorizedError`**(信封 403,§4.2)。`whoami` 返回 `WhoamiResponse`。 + **测试**: T020 通过(与 T022 合并验证) + **覆盖 AC**: AC-01, AC-04, AC-13, AC-32, AC-33, AC-34, AC-35 + **依赖**: T020 + +- [x] **T022**: `[MVP-114]` 专属异常 handler + 路由注册 + **文件**: `src/backend/bisheng/main.py`(仿 `:165-167` 注册 `OpenApiAuthError` handler:路径以 `/api/v2` 开头 → `JSONResponse(status_code=http_status, content=信封)`;同 handler 把 `PermissionServiceUnavailableError`(19002)/ `PermissionBackendUnavailableError`(19201)在 `/api/v2` 上映射 HTTP 503;**不改** `handle_http_exception :22-36` 全局语义), `src/backend/bisheng/open_api/api/router.py`(新:`/service-accounts` 子 router 挂 `/api/v1`;`/auth` 子 router **自带** `dependencies=[Depends(verify_open_api_access)]` 挂 `/api/v2`), `src/backend/bisheng/api/router.py`(`include_router` 两处;`router_rpc :94` **本任务不改**) + **逻辑**: **顺序说明(不改 design 口径,只定挂接时点)**:design D2 的挂接点是 `router_rpc :94`;MVP-114 阶段先把依赖挂在 `open_api` 自己的 v2 子 router 上(`whoami` 立即受保护、`Depends` 工厂立即可用),全局 `router_rpc` 的挂接与 38 端点标记在同一任务 T040 落地——否则 Wave 2 部署 114 后既有 v2 端点全部 26031、guest 分享页白屏(design §7 步 4 亦标「Wave 2 后」)。 + **测试**: T020 全部通过 + **覆盖 AC**: AC-01, AC-04, AC-13, AC-32, AC-33, AC-34, AC-35 + **依赖**: T021 + +- [x] **T023**: `[MVP-114]` 服务账号管理端点集成测试 + **文件**: `src/backend/test/open_api/test_service_account_api.py`(新) + **逻辑**: 以 `tenant_admin_payload` 调 `/api/v1/service-accounts/**`(断言**信封码**):`test_non_admin_403_envelope_not_19801`(普通用户 GET / POST → 信封 `status_code==403`,非 LLM 只读文案)→ AC-41 / AC-59;`test_create_requires_name_and_human_owner`(缺归属人 / 非自然人 → 26021;成功响应含 `id`)→ AC-23;`test_create_tenant_from_admin_scope`(多租户下超管经 admin-scope 头给子租户建号落子租户;未加前缀前 ScopeBar 无效——断言 `MANAGEMENT_API_PREFIXES` 含 `/api/v1/service-accounts`,坑 23)→ AC-23;`test_list_tenant_isolated`(子租户管理员只见本租户;跨租户 `GET /{id}` → 26020)→ AC-07;`test_list_columns`(名称 / 状态 / 有效密钥数(0 → 前端高亮的字段)/ 归属人 + `owner_disabled` / 最后调用 / 创建人 / 创建时间;meta `idle_days==90`)→ AC-42;`test_owner_disabled_flag_and_keys_still_valid` → AC-28;`test_created_account_grantable`(`aget_active_user_tenant` 有活跃行)→ AC-19;`test_human_user_endpoints_reject_service_account`(`/user/update` / `/user/reset_password` 对该 id → 26022)→ AC-20;`test_disable_enable_keeps_config`(disable → 密钥 401;enable → 恢复;`PATCH` 名称 / 归属人不受影响)→ AC-47;`test_delete_second_confirm_payload_and_effect`(`DELETE /{id}` 成功、密钥失效、`GET /{id}` → 26020;Wave 1 授权清单为空列表——T063 补反查断言)→ AC-48;`test_module_always_on_when_platform_off`(`open_platform.enabled=False` 下全部**账号**管理端点照常;`GET /scopes` 的三位断言归 T025 `test_scopes_endpoint_reflects_platform_switch`——该端点 T026 才实现)→ AC-49。 + **覆盖 AC**: AC-07, AC-19, AC-20, AC-23, AC-28, AC-41, AC-42, AC-47, AC-48, AC-49, AC-59 + **依赖**: T014, T021, T022, T017 + +- [x] **T024**: `[MVP-114]` 服务账号管理端点实现 + admin-scope 前缀 + **文件**: `src/backend/bisheng/open_api/api/endpoints/service_account.py`(新:`GET /` 分页 · `POST /` · `GET /{id}` · `PATCH /{id}` · `POST /{id}/enable` · `POST /{id}/disable` · `DELETE /{id}`,全部 `Depends(get_service_account_admin)`,`resp_200` / `PageData` 包装), `src/backend/bisheng/common/middleware/admin_scope.py`(`MANAGEMENT_API_PREFIXES :63-71` 加 `/api/v1/service-accounts`) + **逻辑**: 端点只做参数校验 + 委托 `ServiceAccountService`;`DELETE` 响应 `data` 含被删账号当前授权清单(Wave 1 恒空,T065 起反查填充)供前端二次确认(AC-48)。 + **跨 Feature**: `MANAGEMENT_API_PREFIXES` 是 F019 元组,加项 = 可审计扩展(坑 23);影响面 = 该前缀下超管 ScopeBar 切租户生效。 + **测试**: T023 全部通过 + **覆盖 AC**: AC-07, AC-19, AC-20, AC-23, AC-28, AC-41, AC-42, AC-47, AC-48, AC-49, AC-59 + **依赖**: T023 + +- [x] **T025**: `[MVP-114]` 密钥管理端点 + `scopes` 端点集成测试 + **文件**: `src/backend/test/open_api/test_service_account_keys_api.py`(新) + **逻辑**: `test_issue_plaintext_only_in_create_response`(`POST /{id}/keys` 响应含 `plaintext`;`GET /{id}/keys` / `GET /{id}` / 审计 metadata 只有 `bs-sak-********xxxx`)→ AC-02;`test_issue_default_no_scopes_and_unknown_26025` → AC-06;`test_issue_and_list_tenant_isolated`(子租户管理员操作 Root 账号密钥 → 26020 / 26026)→ AC-07;`test_patch_name_scopes_expires_effective_immediately`(改位后同一把明文对 `whoami` 立即按新位;不换密钥)→ AC-08;`test_revoke_all_within_5s`(`POST /{id}/keys/revoke-all` → 名下全部 401,采样 ≤ 3s)→ AC-09 / AC-46;`test_revoke_single_requires_belongs_to_account`(他账号 key_id → 26026)→ AC-46;`test_scopes_route_not_shadowed_by_id`(`GET /api/v1/service-accounts/scopes` 返回 200 信封而非 422 / 26020——防被 T024 `GET /{id}` 遮蔽);`test_scopes_endpoint_reflects_platform_switch`(`GET /scopes`:关 → 无三位;开 → 三位在 `local_dev_toolkit` 组、含 `desc_key` / `identity:read` 醒目提示 key / `app:manage` 部署提示 key / `chat:invoke` 带 `pending_note_key`)→ AC-13 / AC-49;`test_issue_extension_scope_when_platform_off_26023` → AC-13;`test_issue_or_patch_with_delegate_26024` → AC-14;`test_key_list_columns`(名称 / 掩码 / 权限位 / 最后使用 / 过期 / 状态(按 `revoked_at` / `expires_at` 现算))→ AC-44;`test_key_lifecycle_audit_events`(issue / update / revoke / revoke_all 四事件、操作者 = 管理员、掩码、所属账号)→ AC-12。 + **覆盖 AC**: AC-02, AC-06, AC-07, AC-08, AC-09, AC-12, AC-13, AC-14, AC-44, AC-46, AC-49 + **依赖**: T024 + +- [x] **T026**: `[MVP-114]` 密钥管理端点 + `scopes` 端点实现 + **文件**: `src/backend/bisheng/open_api/api/endpoints/service_account_keys.py`(新:`GET /{id}/keys` · `POST /{id}/keys`(唯一返回明文处)· `PATCH /{id}/keys/{key_id}` · `POST /{id}/keys/{key_id}/revoke` · `POST /{id}/keys/revoke-all` · `GET /scopes`), `src/backend/bisheng/open_api/api/router.py`(T022 建的 `/service-accounts` 子 router 追加 include:**`GET /scopes` 所在 router 必须先于 T024 的 `service_account.py` router 注册**——FastAPI 按注册顺序匹配,否则 `/service-accounts/scopes` 被 `GET /{id}` 吃掉、`"scopes"` 解析 int 失败返回 422;实现上把 `GET /scopes` 单独放 `scopes_router` 并第一个 include,keys router 其次,`service_account.py` router 最后) + **逻辑**: 委托 `CredentialService`;`key_id` 不属该账号 → 26026;账号已停用 / 删除时签发 → 26027;`GET /scopes` 返回 `visible_scopes(settings.open_platform.enabled)`(含 `group / label_key / desc_key / endpoints / pending_note_key`,供签发表单悬停与分组)。 + **测试**: T025 全部通过 + **覆盖 AC**: AC-02, AC-06, AC-07, AC-08, AC-09, AC-12, AC-13, AC-14, AC-44, AC-46, AC-49 + **依赖**: T025 + +### Wave 2 · `[MVP-114]` 前端 Platform(手动验证) + +- [x] **T027**: `[MVP-114]` platform API 封装 + 类型 + **文件**: `src/frontend/platform/src/controllers/API/serviceAccount.ts`(新), `src/frontend/platform/src/types/api/serviceAccount.ts`(新) + **逻辑**: 经既有 `request` 封装(C7,不 `import axios`)封 §4.2 全部管理端点:`getServiceAccountsApi / createServiceAccountApi / getServiceAccountApi / updateServiceAccountApi / enable / disable / deleteServiceAccountApi / getServiceAccountKeysApi / issueKeyApi / updateKeyApi / revokeKeyApi / revokeAllKeysApi / getOpenApiScopesApi`(grants 三个随 T066 追加同文件)。类型:`ServiceAccountItem / ServiceAccountDetail / ApiKeyItem / KeyIssuedResponse / OpenApiScope`。 + **覆盖 AC**: AC-41, AC-44 + **手动验证**: 无 UI;`pnpm typecheck` 通过。 + **依赖**: T024, T026 + +- [x] **T028**: `[MVP-114]` i18n 命名空间 + `appConfig.openPlatformEnabled` + **文件**: `src/frontend/platform/public/locales/{zh-Hans,en-US,ja}/serviceAccount.json`(新,三语视为一组;目录名实为 `en-US` 非 `en`,`i18n.js:48 fallbackLng: 'en-US'`;`dev/` 目录不放正式文案), `src/frontend/platform/src/i18n.js`(`:45` `ns` 数组加 `'serviceAccount'`), `src/frontend/platform/src/contexts/locationContext.tsx`(`:75-94` `appConfig` 增 `openPlatformEnabled`,取 `GET /api/v1/env.open_platform_enabled`) + **逻辑**: 本任务只建命名空间与 Wave 2 首批 key;**后续每个新增文案的前端任务(T029–T031 / T054 / T066 / T067)自行把 `serviceAccount.json` 三语列进文件清单、同 PR 补 key**(规则「新增 key 三语同 PR」)。首批 key 覆盖列表列 / 表单 / 三扩展位说明(`identity:read` 醒目常驻文案「勾选即本租户组织架构全量可读、无法收窄到部分部门」、`app:manage`「部署应用需勾选」、软提示「转交个人使用的 key 建议仅配本地开发工具包权限位」、`chat:invoke`「端点随后续版本开放」)/ 归属人说明句 / 开放能力层下归属人附加提示 / 一次性展示提示 / 撤销确认(含开放能力层下「MCP / 模型协议 / CLI 三面同时被拒绝」)/ 空态(零授权时一切 v2 调用失败是正确行为,坑 21)。 + **覆盖 AC**: AC-13, AC-23, AC-45, AC-46, AC-49 + **手动验证**: 切三语无缺 key 警告;`pnpm check-i18n` 通过。 + **依赖**: T004 + +- [x] **T029**: `[MVP-114]` 「服务账号」tab:列表 + 新建弹窗 + **文件**: `src/frontend/platform/src/pages/SystemPage/index.tsx`(新 tab,可见性 `isSuperAdmin ∪ isChildAdmin`,不含部门管理员;不新增 web_menu), `src/frontend/platform/src/pages/SystemPage/components/ServiceAccount/ServiceAccountList.tsx`(新), `src/frontend/platform/src/pages/SystemPage/components/ServiceAccount/CreateServiceAccountDialog.tsx`(新), `src/frontend/platform/src/pages/SystemPage/components/ServiceAccount/ServiceAccountPanel.tsx`(新:tab 内容容器,持有 `view: 'list' | 'detail'` / `detailId` / `detailInitialTab: 'overview' | 'keys' | 'grants'` / `autoOpenIssue: boolean` 四个 `useState`——**SystemPage 各 tab 是 `` 内部状态、无子路由**(`SystemPage/index.tsx:54-83`),详情不走路由,列表 ↔ 详情在容器内切换;`CreateServiceAccountDialog` 成功回调 `onCreated(id)` → 容器 `setView('detail'); setDetailInitialTab('keys'); setAutoOpenIssue(true)`;`ServiceAccountDetail` 通过 props `initialTab / autoOpenIssue / onBack` 接收,T030 / T031 据此实现), `src/frontend/platform/public/locales/{zh-Hans,en-US,ja}/serviceAccount.json`(本任务新增 key 三语) + **逻辑**: 列表用 `useTable`(不用 react-query,坑 24):列 = 名称 / 状态 / 有效密钥数(0 高亮)/ 资源归属人(`owner_disabled` 高亮)/ 最后调用时间(空或超 `idle_days` 提示可考虑停用)/ 创建人 / 创建时间;行点击进详情(T030)。新建弹窗:名称 · 描述 · 资源归属人(`DepartmentUsersSelect multiple={false}`——其两条查询路径均已在 T016 / T071 排除服务账号)+ 一句话说明「该集成创建出的知识库等资源将归属此人、由其在平台内管理」+ `appConfig.openPlatformEnabled` 时附「首发部署应用以此人为 owner…」提示;租户不可选(取当前作用域);创建成功 → **直接跳详情 · API 密钥 tab 并打开签发弹窗**(AC-43)。模块恒在,不受 `openPlatformEnabled` 影响(AC-49)。 + **覆盖 AC**: AC-23, AC-41, AC-42, AC-43, AC-49 + **手动验证**: 以**租户管理员**(非 admin)登录 114 platform → 系统管理 → 「服务账号」tab 可见;普通用户不可见;新建(缺归属人被拦;选人框搜不到任何服务账号)→ 成功后停在详情 · API 密钥 tab 且签发弹窗已打开;返回列表:有效密钥数 0 高亮;把 `open_platform.enabled` 关掉重启后 tab 仍在。 + **依赖**: T027, T028 + +- [x] **T030**: `[MVP-114]` 详情页壳 + 概览 tab + **文件**: `src/frontend/platform/src/pages/SystemPage/components/ServiceAccount/ServiceAccountDetail.tsx`(新:三 tab 壳——概览 / API 密钥 / 资源授权(占位,T066 填); 接入信息区不做,归 F053), `src/frontend/platform/src/pages/SystemPage/components/ServiceAccount/OverviewTab.tsx`(新), `src/frontend/platform/public/locales/{zh-Hans,en-US,ja}/serviceAccount.json`(本任务新增 key 三语) + **逻辑**: 详情壳 props 契约见 T029 `ServiceAccountPanel`(`initialTab` 决定默认激活 tab、`autoOpenIssue` 透传给 `ApiKeysTab` 首帧打开签发弹窗、`onBack` 回列表并触发列表刷新)。概览:名称 / 描述 / 租户 / 创建人 / 创建时间 / 资源归属人(可编辑:`PATCH`);停用 / 启用按钮(`bsConfirm` 二次确认,文案说明「停用后 5 秒内密钥被拒、授权与配置保留」);删除按钮:`bsConfirm` 列出 `DELETE` 预检返回的授权清单(Wave 1 恒空,T067 升级文案)+「依赖它的集成将立即失败」,确认即删、不阻断,删后回列表。 + **覆盖 AC**: AC-41, AC-47, AC-48 + **手动验证**: 停用 → 3 秒内 `curl whoami` 401;启用 → 恢复且归属人 / 密钥配置原样;删除 → 二次确认 → 列表消失、密钥 401。 + **依赖**: T029 + +- [x] **T031**: `[MVP-114]` API 密钥 tab:签发 / 一次性展示 / 编辑 / 撤销 / 批量撤销 + **文件**: `src/frontend/platform/src/pages/SystemPage/components/ServiceAccount/ApiKeysTab.tsx`(新:列表 + 顶部「撤销该账号全部密钥」), `src/frontend/platform/src/pages/SystemPage/components/ServiceAccount/KeyIssueDialog.tsx`(新:签发 / 编辑共用), `src/frontend/platform/src/pages/SystemPage/components/ServiceAccount/KeyRevealDialog.tsx`(新), `src/frontend/platform/public/locales/{zh-Hans,en-US,ja}/serviceAccount.json`(本任务新增 key 三语) + **逻辑**: `ApiKeysTab` 接收 `autoOpenIssue` prop(T029 契约),为 true 时挂载后即打开 `KeyIssueDialog`(AC-43)。列表列 = 名称 / 掩码 / 权限位 / 最后使用 / 过期 / 状态;签发表单:名称(必填)· 过期时间(可空 = 长期)· 权限位按 `GET /scopes` 分组 `checkBox`(**默认全不勾**;悬停 `tooltip` 显示覆盖端点;`chat:invoke` 附「端点随后续版本开放」;`appConfig.openPlatformEnabled` 时出现「本地开发工具包」组三位 + `identity:read` 醒目常驻提示 + `app:manage` 说明;勾任一开放 API 端点位时显示不阻断软提示);**无 `delegate` 位、无委托配置区**(AC-14)。`KeyRevealDialog`:明文 + `copyText` + 「关闭后不可再查看」+ 「我已保存」勾选后才可关闭。撤销单把:`bsConfirm`(开放能力层下补三面文案);顶部批量撤销:`bsConfirm`。编辑复用签发弹窗(名称 / 权限位 / 过期)。 + **覆盖 AC**: AC-02, AC-06, AC-13, AC-14, AC-44, AC-45, AC-46 + **手动验证**: 114 步 2(design §7):签发勾 `app:manage`(组可见)→ 复制 → 未勾「我已保存」关不掉 → 关闭后列表只见掩码;编辑权限位 → 同一明文 `whoami.scopes` 立即变化;撤销单把 / 全部 → 3 秒内 401;`open_platform.enabled=false` 时三位不出现、表单无 `delegate`。 + **依赖**: T029 + +- [x] **T032**: `[MVP-114]` 审计页 lockstep(前端两处) + **文件**: `src/frontend/platform/src/controllers/API/log.ts`(`actions :53-108` + `getModulesApi :35-51` 加 `open_api` 模块与 D11 全部 action), `src/frontend/platform/public/locales/{zh-Hans,en-US,ja}/bs.json`(`log.systemIdEnum` / `log.eventTypeEnum` 三语) + **逻辑**: 与 T007 后端登记一一对应(E1 §5.2 四处 lockstep)。 + **覆盖 AC**: AC-12 + **手动验证**: 系统操作日志页可筛选「开放 API」模块,能看到 T031 产生的 issue / revoke 事件,metadata 只见掩码。 + **依赖**: T007, T028 + +- [x] **T033**: `[MVP-114]` 114 部署步骤 + 首波手动验证清单 + **文件**: `features/v3.0.0/049-openapi-auth-baseline/tasks.md`(本文「实际偏差记录」上方追加「114 部署记录」小节) + **逻辑**: 部署顺序(坑 22):① `bash /opt/bisheng-ops/deploy.sh`(代码 + Alembic upgrade + create_all)→ ② `config.yaml` 加 `open_platform: {enabled: true}`(可选 `open_api: {service_account_idle_days: 90}`)→ ③ 重启后端 → `curl -s /api/v1/env | jq .open_platform_enabled` == `true`。手动验证 = design §7 步 1–3 与步 5(用普通用户看用户管理 / 审批人 / 部门加成员 / 资源授权弹窗搜不到服务账号;服务账号密码登录 → 26012;F048 验证用非 admin)。 + **依赖**: T022, T024, T026, T029, T030, T031, T032 + +### Wave 3 · 既有 v2 面接入(38 端点标记 + 6 端点关闭 + 缺陷修复 + 配置移除) + +- [ ] **T034**: v2 路由完整性 + 全端点鉴权矩阵测试 + **文件**: `src/backend/test/open_api/test_v2_route_integrity.py`(新) + **逻辑**: `test_every_v2_route_has_scope_marker`(枚举 `app.routes` 下 `/api/v2/**` 含 `APIWebSocketRoute`,每条带 `@open_api_scope`;位 ∈ `OPEN_API_SCOPES` 或 `None`,`None` 只允许白名单 `{'/api/v2/auth/whoami'}`;注册表可含无端点的位 `chat:invoke`)→ AC-29;`test_scope_endpoint_mapping_matches_registry`(38 端点归位与 `OPEN_API_SCOPES.endpoints` 一致)→ AC-04;`test_six_chat_routes_absent_404`(`GET /chat/history`、`POST /chat/gen_title`、`/chat/liked`、`/chat/solved`、`/chat/comment`、`POST /chat/sync/messages` → 404 `{"detail":"Not Found"}`,无业务副作用)→ AC-30;`test_all_http_endpoints_401_without_key`(参数化 36 个 HTTP 端点:无头 → 401 `26001`,不执行业务——对写端点断言 DB 无新行)→ AC-01 / AC-50;`test_all_endpoints_403_missing_scope_with_required`(有效密钥无位 → 403 `26003` `data.required` = 该端点位)→ AC-04;`test_two_layer_errors_distinguishable`(持 `knowledge:read` 但主体对该知识库无授权 → 业务既有资源权限错误码,≠ 26003)→ AC-36;`test_no_config_switch_can_reopen`(`initdb_config` / Settings 中不存在任何键能让无头请求通过:grep 代码无 `enable_guest_access` / `default_operator` 消费者 + 运行时断言)→ AC-50;`test_no_third_path_across_endpoints`(抽样 5 端点:无头 = 模式 S 结果集;有身份头 = 26004;无「返回公开资源 / 空集」响应)→ AC-35。 + **覆盖 AC**: AC-01, AC-04, AC-29, AC-30, AC-35, AC-36, AC-50 + **依赖**: T022, T008 + +- [ ] **T035**: `resolve_operator` 收紧 + `get_open_api_login_user` 测试 + **文件**: `src/backend/test/open_api/test_resolve_operator.py`(新) + **逻辑**: `test_get_open_api_login_user_reads_contextvar`(无 ContextVar → 抛 26001 而非兜底身份);`resolve_operator(user_id)`:空 → 返回密钥主体 → AC-35;目标 `delete==1` 或不存在 → 403(不构造可用身份)→ AC-39;目标活跃租户 ≠ 密钥租户 → 403(坑 25 跨租户取数)→ AC-39;`test_get_default_operator_symbols_removed`(`open_endpoints.domain.utils` 无 `get_default_operator` / `get_default_operator_async`;`open_endpoints.api.dependencies` 无 `get_knowledge_space_chat_service_for_openapi`)。 + **覆盖 AC**: AC-35, AC-39 + **依赖**: T012, T008 + +- [ ] **T036**: `open_endpoints/domain/utils.py` 重写 + 死函数删除 + **文件**: `src/backend/bisheng/open_endpoints/domain/utils.py`(删 `get_default_operator :26` / `get_default_operator_async :53`(AC-52 读取点);新增 `get_open_api_login_user() -> UserPayload`(读 `current_open_api_principal` 对应 `UserPayload`,供 `Depends`);`resolve_operator :77` 收紧:读 ContextVar 主体 → `user_id` 空即返回主体;非空 → `aget_user` + `delete==0` + 目标活跃租户 == 密钥租户 → 否则 403), `src/backend/bisheng/open_endpoints/api/dependencies.py`(删 `:61-70 get_knowledge_space_chat_service_for_openapi` 死函数——`endpoints/knowledge.py:11` 仍 import 该模块,删函数不删 import 处即模块导入失败,随 T037 一并改) + **逻辑**: D3;`utils.py` 不 import `open_api/api/*`(RULE-5),只 import `bisheng.open_api.domain.context`。 + **测试**: T035 全部通过 + **覆盖 AC**: AC-35, AC-39 + **依赖**: T035 + +- [ ] **T037**: 标记接入:`knowledge.py` + `citation.py` + `llm.py` + **文件**: `src/backend/bisheng/open_endpoints/api/endpoints/knowledge.py`(8 个 `*metadata*` 端点 `@open_api_scope('knowledge:write')`;`Depends(get_default_operator_async)` ×8(`:21/42/65/90/111/136/162/188`)→ `Depends(get_open_api_login_user)`;`:11` 对 `dependencies` 的 import 按 T036 调整), `src/backend/bisheng/open_endpoints/api/endpoints/citation.py`(`GET /citation/{id}` `knowledge:read`;`:23` 依赖替换), `src/backend/bisheng/open_endpoints/api/endpoints/llm.py`(`/llm/workbench/asr` / `tts` `assistant:invoke`;`:13/:21` 内联调用替换) + **逻辑**: 只 import `bisheng.open_api.domain.scopes`(domain 常量,RULE-5 不触发);端点体逻辑不动。 + **测试**: T034 相关端点通过(`pytest -k "knowledge or citation or llm"`) + **覆盖 AC**: AC-04, AC-29 + **依赖**: T036 + +- [ ] **T038**: 标记接入:`filelib.py`(19 端点:6 读 + 13 写;`knowledge:write` 位总数 21 = 本文件 13 + `knowledge.py` 8) + **文件**: `src/backend/bisheng/open_endpoints/api/endpoints/filelib.py` + **逻辑**: 按 D3 映射:`knowledge:read` = `GET /filelib/`(`:157`)、`file/list`(`:362`)、`retrieve`(`:623`)、`download_statistic`(`:496`)、`detail_qa` / `query_qa`(`:614` / `:676` 之读端点,以函数名核);其余 13 个写端点 + `chunks` / `chunks_string` 等 = `knowledge:write`。全部 `get_default_operator*` 内联调用(`:88/132/211/241/301/349/357/444/475/515/539/557/590/618/680`)→ `get_open_api_login_user()`;`resolve_operator(user_id)` 三处(`:176/381/640`)保留(K11,F050 移除)。`POST /filelib/retrieve` 只加鉴权、过滤强度维持现状(spec §3;文件级双层过滤归 F052)。 + **测试**: T034 相关端点通过(`pytest -k filelib`) + **覆盖 AC**: AC-04, AC-29 + **依赖**: T036 + +- [ ] **T039**: 标记接入:`workflow.py` + `flow.py` + `assistant.py`(HTTP + WS 标记) + **文件**: `src/backend/bisheng/open_endpoints/api/endpoints/workflow.py`(`invoke :31` / `stop :144` `workflow:invoke`;WS `:157` `@open_api_scope('workflow:invoke', allow_share_token=True)`;`:41/149/167` 身份改 `get_open_api_login_user()`;WS 端点内旧的 `close(1011) before accept` 身份分支删除——身份已在依赖里判定,坑 16), `src/backend/bisheng/open_endpoints/api/endpoints/flow.py`(`GET /flows/{id}` `workflow:read`;`:8` 从 `endpoints.assistant` 转导入 `get_default_operator` 删除、`:21-23` guest 开关读取删除), `src/backend/bisheng/open_endpoints/api/endpoints/assistant.py`(`chat/completions :30` `assistant:invoke`,**端点体 `:48` 内联 `get_default_operator()` 一并改为 `get_open_api_login_user()`**;`list :256` / `info :271` `assistant:read`(`:262-286` `enable_guest_access` 读取删除);WS `:298` `assistant:invoke` + `allow_share_token=True`,`:304 get_default_operator()` 删除;`:24` 的 `get_default_operator` import 删除——共 4 处调用 `:48/:266/:286/:304`,漏一处即 T036 删函数后模块 import 失败) + **逻辑**: WS 端点体保留 `accept` 后的 `dispatch_client` 逻辑;share-token 分支与 watchdog 由 T052 / T053 接入(本任务后 WS 只接受密钥)。 + **测试**: T034 相关端点通过(`pytest -k "workflow or flow or assistant"`) + **覆盖 AC**: AC-04, AC-29 + **依赖**: T036 + +- [ ] **T040**: `chat.py` 删除 + 全局 `router_rpc` 挂依赖 + **文件**: `src/backend/bisheng/open_endpoints/api/endpoints/chat.py`(**删除**,含 `:7-9` 无用 `a = WSGIMiddleware`), `src/backend/bisheng/open_endpoints/api/router.py`(去 `chat_router_rpc` import / `__all__`), `src/backend/bisheng/api/router.py`(`:94` `router_rpc = APIRouter(prefix='/api/v2', dependencies=[Depends(verify_open_api_access)])`;`include_router(chat_router_rpc)` 删除;`open_api` v2 子 router 的自带依赖去重(避免同一请求跑两次校验)) + **逻辑**: D4 真 404、不留 410 桩;与 T037–T039 同 wave 落地(顺序:T037–T039 先、本任务最后合并),保证挂全局依赖那一刻不存在未标记端点。 + **跨 Feature**: `open_endpoints` 全部路由从此经同一凭据路径(INV-27);`docs/api` 与发布说明(T045 / T074)同步 6 端点清单。 + **测试**: T034 全部通过(含 `test_every_v2_route_has_scope_marker` / `test_six_chat_routes_absent_404`) + **覆盖 AC**: AC-01, AC-29, AC-30, AC-50 + **依赖**: T037, T038, T039 + +- [ ] **T041**: 三处既有缺陷测试(AC-37 / 38 / 40) + **文件**: `src/backend/test/open_api/test_v2_known_defects.py`(新) + **逻辑**: `test_invoke_offline_workflow_13010`(`status != ONLINE` → `WorkflowOfflineError` 业务错误、不进 `RedisCallback`)→ AC-37;`test_stop_session_not_owned_403`(他主体的 `unique_id` 会话 → 403;本主体 → 200;`set_workflow_status` 载荷含 `owner_user_id / tenant_id`)→ AC-38;`test_download_statistic_rejects_paths`(`file_path` 参数不再接受;`file_name` 含 `..` / 绝对路径 / 子目录 / 非 `.log` → 400;`realpath` 不在 `STATISTIC_DIR` 内 → 404;合法 → 只返回该目录内文件;无 `knowledge:read` → 403)→ AC-40。 + **覆盖 AC**: AC-37, AC-38, AC-40 + **依赖**: T040, T039, T038, T008(T040 之前全局 `router_rpc` 依赖未挂,v2 端点全部 26001,本测试的有效密钥请求打不到业务逻辑) + +- [ ] **T042**: `invoke` 上线守卫 + `stop` 归属校验 + **文件**: `src/backend/bisheng/open_endpoints/api/endpoints/workflow.py`(`invoke_workflow :43-49` 取到 `workflow_info` 后、构造 `RedisCallback` 前 `if workflow_info.status != FlowStatus.ONLINE.value: raise WorkflowOfflineError.http_exception()`(`common/errcode/chat.py:29` 13010,已有三语);`stop :144` 读 `RedisCallback` 状态载荷 `owner_user_id / tenant_id`,≠ 当前主体 → 403), `src/backend/bisheng/worker/workflow/redis_callback.py`(`set_workflow_status :118-123` 状态载荷加 `owner_user_id / tenant_id`) + **逻辑**: `/workflow/stop` **不加**上线守卫(停未上线流的会话是幂等空操作)。 + **跨 Feature**: `set_workflow_status` 状态载荷是 workflow worker(写)与 `stop` 端点(读)的共享结构——只加 `owner_user_id / tenant_id` 两键、不删不改既有键,worker 侧其它读者无感(已登记顶部跨 Feature 表)。 + **测试**: T041 前两项通过 + **覆盖 AC**: AC-37, AC-38 + **依赖**: T041, T040 + +- [ ] **T043**: `download_statistic` 收口 + **文件**: `src/backend/bisheng/open_endpoints/api/endpoints/filelib.py`(`download_statistic_file :496-507`:入参 `file_path` → `file_name`;`os.path.basename(file_name) == file_name` 且不含 `..` 否则 400 `ServerError`;`STATISTIC_DIR = /statistic`(既有配置根,不新增 Settings 键);`os.path.realpath(join)` 须以 `realpath(STATISTIC_DIR) + os.sep` 开头否则 404) + **逻辑**: D3「AC-40 收口」;契约变化(`file_path → file_name`)进 T045 文档与 T074 发布说明。 + **测试**: T041 第三项通过 + **覆盖 AC**: AC-40 + **依赖**: T041 + +- [ ] **T044**: 升级零迁移 + 配置移除 + 文档断言测试 + **文件**: `src/backend/test/open_api/test_upgrade_zero_migration.py`(新) + **逻辑**: `test_alembic_f049_revisions_touch_only_new_columns`(F049 两个 revision 的 upgrade 只 ADD COLUMN / 索引;对种子用户 / 知识库 / 权限行 upgrade 前后逐行相等;`default_operator` 指向的用户仍是普通用户、其名下资源归属零变化)→ AC-51;`test_default_operator_config_has_no_effect`(DB `config` 表残留 `default_operator.user=<超管>` 时:无头调用仍 401,且代码库无 `get_default_operator` 符号)→ AC-52;`test_enable_guest_access_has_no_effect`(残留 `enable_guest_access: true` 时 `GET /assistant/info` / `/flows/{id}` 无头 401、有头有位 200)→ AC-53;`test_initdb_config_keys_removed`(`initdb_config.yaml` 无 `default_operator` / `enable_guest_access` 键)→ AC-54。**降级**:`docs/api/*.md` 与 `platform/.../ApiAccess.tsx` 的禁词 / 前缀断言**不进 pytest**(跨树脆弱断言,spec AC-54 未要求自动化)——改为 T045 / T046 完成标准里的一条 `grep` 审阅命令:`grep -rnE "由网络层负责访问控制|默认操作员配置为超级管理员|default_operator|转为服务账号|会被降权" docs/api src/frontend/platform/src/components/bs-comp/apiComponent/` 应为空,且 `grep -lE "\bbs-sak-[A-Za-z0-9_-]{43}\b" docs/api/*.md` 非空。 + **覆盖 AC**: AC-51, AC-52, AC-53, AC-54 + **依赖**: T040, T001, T047(「F049 两个 revision」含 T047 的 `share_link.share_scope`) + +- [ ] **T045**: `initdb_config.yaml` 删键 + `docs/api` 修订与前缀模式公开 + **文件**: `src/backend/bisheng/initdb_config.yaml`(`:35-38` `default_operator` 块含 `enable_guest_access` 删除), `docs/api/filelib-retrieve.md`(`:25-28` / `:297-298` 两处错误引导改为「持 `bs-sak-` 密钥 + `knowledge:read`,租户取密钥所属租户」;说明「知识资源级一次校验 + fail-closed,文件级过滤随 F052」), `docs/api/开放 API 接口方案.md`(新增「凭据格式与前缀模式」小节:`Authorization: Bearer bs-sak-<43>`、正则 `\bbs-sak-[A-Za-z0-9_-]{43}\b`、掩码格式;`download_statistic` 入参 `file_path → file_name`;6 个 `/chat/*` 不再提供;**订正 `:1594-1596`**「该用户会被转为服务账号并降权 / 升级后该身份会被降权」两句——与 AC-51 / AC-52 零迁移决议相悖,改为「原默认操作员用户**不做任何变更**(仍是普通用户、名下资源归属与权限原样),只是不再被任何 v2 调用当作兜底身份;请新建服务账号并授权」;同表 `:1592` 裸 `user_id` 行注明随 F050 移除、`delegate` 位随 F050) + **逻辑**: D12;存量 DB `config` 残留键无害、不写清理脚本(AC-51);测试注释 `test/e2e/test_e2e_knowledge_resource_unified.py:4` 顺带改。 + **测试**: T044 全部通过 + T044 登记的 `grep` 审阅命令为空 / 非空(含「转为服务账号」「会被降权」两词) + **覆盖 AC**: AC-52, AC-53, AC-54 + **依赖**: T044 + +- [ ] **T046**: platform 接入文档页示例修订 + `ApiAccessFlow` i18n 偿债 + **文件**: `src/frontend/platform/src/components/bs-comp/apiComponent/ApiAccess.tsx`(`:57-58` 示例 `api_key="empty"` → `bs-sak-…` 占位 + 说明「密钥经 Authorization 头传递」), `src/frontend/platform/src/components/bs-comp/apiComponent/ApiAccessFlow.tsx`(同类示例修订;该文件冻结 188 条中文,触碰即整文件 `/i18n-localizer` 提取(坑 24),三语 key 落 `flow.json`) + **逻辑**: AC-54 前端面;`ChatLink.tsx` 与 `noLoginLinkDescription` 文案随 T054 一并改(同文件不二次动)。 + **覆盖 AC**: AC-54 + **手动验证**: 接入文档页示例含 `bs-sak-` 占位;`pnpm lint` 通过且 `eslint-suppressions.json` 经 `pnpm lint:prune` 只减不增。 + **依赖**: T045 + +### Wave 4 · WebSocket 双凭据 + share-token 通道(后端 + Platform + Client) + +> **前置 ★**:design D8 登记了两处需回写 spec 的口径(决议-1「实际受影响面只有两个 WS」的核实基础已过时——guest 页另打 4 个 v2 HTTP,改由 3 个 `/api/v1/share-link/{token}/*` 端点承接;AC-55「单一资源」在 history 端点上按「资源 = flow」解释)。这两处**不改 AC 文字**,但开工 T047 前须经用户确认并回写 spec 决议-1(design 修订历史「需回写上游」)。 + +- [ ] **T047**: Alembic:`share_link.share_scope` 列 + 模型 + **文件**: `src/backend/bisheng/core/database/alembic/versions/v3_0_0_f049_share_link_share_scope.py`(新), `src/backend/bisheng/share_link/domain/models/share_link.py`(`ShareLink` 加 `share_scope: str = Field(default='session', max_length=16)`;`resource_type` `SQLEnum` **不加枚举值**(K5);`expire_time` 注释改为「自 `create_time` 起相对秒,0 = 永不」) + **逻辑**: `ALTER TABLE share_link ADD COLUMN share_scope VARCHAR(16) NOT NULL DEFAULT 'session'`;`app` = 应用级分享(`resource_id` = flow / assistant id)。现状 6 个会话级消费点与 2 个 flow 级消费点逻辑不改(D8「资源类型表达」)。 + **回滚**: `downgrade()` 删列;`app` 行 downgrade 后会被 flow 级消费点按兜底分支读、被会话级消费点当会话 id 读——因此 downgrade 前先经 T050 revoke 全部 `app` 行(写在 docstring)。 + **跨 Feature**: ShareLink 写增量已在 release-contract 表 1 登记归 F049。 + **排位**: 基础设施任务、无依赖——**实际执行提前到 Wave 1 与 T001 同批**(一次 Alembic 升级两个 revision;T044 依赖它),编号保留在此只为与 share-token 通道叙事相邻(见「编号 ≠ 执行顺序」)。 + **依赖**: 无 + +- [ ] **T048**: share-link 服务 + 匿名作用域端点 + `/app-shares` 管理端点测试 + **文件**: `src/backend/test/share_link/test_share_link_app_scope.py`(新) + **逻辑**: `test_expire_time_enforced_relative_seconds`(`expire_time>0` 且 `create_time + expire_time < now` → `get_share_link_by_token` 拒;`0` 永不过期;`session` 行兼容)→ AC-56 / AC-57;`test_generate_app_scope_requires_positive_expire`(`share_scope='app'` 且 `expire_time<=0` → 拒;service 内显式 `tenant_id = login_user.tenant_id`)→ AC-57;`test_revoke_endpoint_writes_inactive_and_immediate`(`POST /api/v1/app-shares/{id}/revoke`:创建者或管理员可撤;撤后 `status=INACTIVE`、匿名端点立即 26028)→ AC-56 / AC-57;`test_app_shares_list_tenant_isolated_and_requires_login`(`GET /api/v1/app-shares?resource_type&resource_id` 只返回本租户 `share_scope=app`;未登录拒;**路径不在 `TENANT_CHECK_EXEMPT_PATHS` 前缀下**——断言方式:枚举 `app.routes` 取两个端点的**最终挂接路径**恰为 `/api/v1/app-shares` / `/api/v1/app-shares/{id}/revoke`,且对 `TENANT_CHECK_EXEMPT_PATHS` 每一项 `startswith` 均为 False(只查豁免列表抓不到「被挂在 `/api/v1/share-link` 前缀下」的错挂);再以 `token_version` 已 bump 的旧登录态调 `GET /api/v1/app-shares` → 被拒(证明 token_version / 租户状态检查生效,坑 26));`test_share_link_expire_audit_once`(过期 share-link 首次被 `get_share_link_by_token` 拒绝 → 单行 `status=INACTIVE` + 恰一条 `open_api.share_link.expire` 审计(`operator=system`),并发两次只一条)→ AC-56;匿名端点:`GET /api/v1/share-link/{token}/resource`(按 `resource_type` 返回工作流 / 助手信息、只放行 `share.resource_id`)、`GET .../chat/history?chat_id=`(`session.flow_id == share.resource_id` 才返回,他 flow 的 chat_id → 26028)、`POST .../chat/gen_title`;坏 token / 过期 / INACTIVE → 26028;创建者已禁用或已删除 → 拒(fail-closed)→ AC-55;`test_v2_http_face_does_not_accept_share_token`(`share-token` 头打任一 v2 HTTP → 401 `26001`)→ AC-55。 + **覆盖 AC**: AC-55, AC-56, AC-57 + **依赖**: T047, T008 + +- [ ] **T049**: `ShareLinkService` 增量实现 + **文件**: `src/backend/bisheng/share_link/domain/services/share_link_service.py`(`get_share_link_by_token :52-58`:删「有效期刻意不生效」注释、统一强制相对秒过期;过期首次命中时惰性 `UPDATE share_link SET status=INACTIVE WHERE id=:id AND status=ACTIVE`,`rowcount==1` 才写审计 `open_api.share_link.expire`(`operator_id=0` → `system`)——T007 登记的该动作由此产生事件(同 T010 密钥惰性到期的幂等闸),不另起 Beat;新增 `revoke(share_id, actor)`(创建者或管理员,写 `status=INACTIVE` + 审计 `open_api.share_link.revoke`)/ `list_app_shares(resource_type, resource_id)` / `generate` 支持 `share_scope`(`app` 必填 `expire_time>0`,`resource_id` = flow / assistant id)/ `resolve_guest_principal(token, expected_resource_type, expected_resource_id) -> UserPayload`(以创建者**直接构造** `UserPayload(is_global_super=False, user_role=[], open_api_principal(subject_kind='share_link', share_link_id))` + `set_current_tenant_id(share.tenant_id)`;创建者禁用 / 删除 / 租户不活跃 → 26028)) + **逻辑**: D8;`share_link` 继续用 `generate_short_high_entropy_string`(坑 17 只影响密钥);`access_count` 不动。 + **测试**: T048 service 部分通过 + **覆盖 AC**: AC-55, AC-56, AC-57 + **依赖**: T048 + +- [ ] **T050**: share-link 匿名作用域端点 + `/app-shares` 管理端点 + **文件**: `src/backend/bisheng/share_link/api/endpoints/share_link.py`(新增 3 个匿名端点 `GET /{token}/resource` · `GET /{token}/chat/history` · `POST /{token}/chat/gen_title`,复用 `api/dependencies.py:30-48 header_share_token_parser`;留在豁免前缀 `/api/v1/share-link` 下——匿名端点正需要 bypass), `src/backend/bisheng/share_link/api/endpoints/share_link_manage.py`(新:`app_shares_router = APIRouter(prefix='/app-shares', tags=['ShareLink'])`:`GET /` · `POST /{id}/revoke`,`Depends(get_login_user)`,**非豁免前缀**,租户自动过滤 + token_version + 租户状态检查全部生效), `src/backend/bisheng/share_link/api/router.py`(现有 `router = APIRouter(prefix='/share-link')` `:4` 只 include 3 个匿名端点;`app_shares_router` **不得** include 进它——否则最终路径变成 `/api/v1/share-link/app-shares`,`startswith` 命中 `TENANT_CHECK_EXEMPT_PATHS`(`utils/http_middleware.py:39`,匹配在 `:311`),正是坑 26 要规避的 bypass + 跳过 token_version;此文件只 `export` `app_shares_router`), `src/backend/bisheng/api/router.py`(`:76` `include_router(share_link_router)` 旁**独立** `router.include_router(app_shares_router)`,直接挂 `/api/v1`) + **逻辑**: 坑 26:撤销 / 列表绝不能落 `share-link*` 前缀——最终路径必须恰为 `/api/v1/app-shares*`(T048 以 `app.routes` 最终路径断言);既有 `generate_share_link :12-17` 的豁免暴露只登记不搬。history / gen_title 端点内部复用 chat_session 既有 Service,以创建者身份 + `flow_id == share.resource_id` 收窄。 + **测试**: T048 全部通过 + **覆盖 AC**: AC-55, AC-56, AC-57 + **依赖**: T049 + +- [ ] **T051**: WS 双凭据 + watchdog + 审计 集成测试 + **文件**: `src/backend/test/open_api/test_ws_dual_credentials.py`(新) + **逻辑**: 对 `/api/v2/workflow/chat/{id}` 与 `/api/v2/assistant/chat/{id}`(TestClient `websocket_connect`):`test_no_credential_handshake_rejected_gracefully`(无头无 `share_token` → 握手被拒(1008 / HTTP 403),进程无未捕获异常、无 HTTP 200 denial(坑 16))→ AC-31;`test_bad_key_rejected` / `test_key_without_invoke_scope_rejected` → AC-29 / AC-31;`test_share_token_valid_app_scope_accepts`(有效 `?share_token=` 且 `share_scope=='app'` 且路径 id == `resource_id` → accept;执行主体 = 分享创建者、`get_current_tenant_id()==share.tenant_id`、`open_api_principal.subject_kind=='share_link'`)→ AC-55 / AC-58;`test_share_token_wrong_resource_rejected`(路径 id ≠ `resource_id` 或 `session` 行 → 26028)→ AC-55;`test_key_and_share_token_both_present_key_wins`;`test_query_param_key_not_accepted`(`?token=bs-sak-…` 不被当密钥);`test_ws_connect_audit_written`(每次建连一条 `open_api.ws.connect`:凭据种类、密钥 id 或 `share_link_id`、执行主体、资源)→ AC-58;**watchdog 5 秒矩阵**:建连后撤销密钥 / 批量撤销 / 停用账号 / share-link revoke / share-link 到期 → 连接在 ≤ 5s 内 `close(1008)` → AC-03 / AC-09 / AC-21 / AC-47 / AC-56。 + **覆盖 AC**: AC-03, AC-09, AC-21, AC-29, AC-31, AC-47, AC-55, AC-56, AC-58 + **依赖**: T050, T039, T008 + +- [ ] **T052**: `verify_open_api_access` WS 分支 + watchdog + **文件**: `src/backend/bisheng/open_api/api/dependencies.py`(WS 分支:`Authorization` 头(密钥)或 `?share_token=` 二选一(同时给以密钥为准、share_token 忽略并记日志);share-token 分支要求端点标记 `allow_share_token=True`,调 `ShareLinkService.resolve_guest_principal(token, 资源类型, 路径 id)`;任何失败一律 `raise WebSocketException(code=1008, reason=str(错误码))`——**禁止** `HTTPException` / `BaseErrorCode.http_exception`(坑 16);成功后写 ContextVar 主体), `src/backend/bisheng/open_api/domain/services/ws_watchdog.py`(新:`start_watchdog(ws, principal) -> asyncio.Task`,每 3 秒复查同一 Redis 键 `oapi:cred:{hash}` 或 share_link 状态(revoke / 过期 / 主体停用),失败即 `close(1008)`;连接关闭时取消 task) + **逻辑**: D2「WS 连接期失效」;不动 `common/chat/manager.py dispatch_client` 的按消息校验(F048 `use` 校验仍以执行主体为 actor)。 + **测试**: T051 握手 / 双凭据部分通过 + **覆盖 AC**: AC-03, AC-09, AC-21, AC-29, AC-31, AC-47, AC-55, AC-56, AC-58 + **依赖**: T051 + +- [ ] **T053**: 两个 WS 端点接 watchdog + 建连审计 + **文件**: `src/backend/bisheng/open_endpoints/api/endpoints/workflow.py`(WS `:157`:`accept` 后 `start_watchdog` + 审计 `open_api.ws.connect`;`finally` 取消 watchdog), `src/backend/bisheng/open_endpoints/api/endpoints/assistant.py`(WS `:298` 同上;`:305` 原 `try` 内不再有身份解析) + **逻辑**: 端点体只做 `accept → 审计 → watchdog → dispatch_client`;身份全部来自依赖。 + **测试**: T051 全部通过 + **覆盖 AC**: AC-31, AC-56, AC-58 + **依赖**: T052 + +- [ ] **T054**: platform 免登录链接改走 share-token + 撤销入口 + **文件**: `src/frontend/platform/src/components/bs-comp/apiComponent/ChatLink.tsx`(`:77-81` 免登录 URL 改为 `${origin}${BASE_URL}/workspace/chat/{flow|assistant}/{id}?share_token=…`(生成 `share_scope='app'`、`expire_time` 默认 30 天可改);`:106-108` `enable_guest_access` 文案删除;增「已生成分享」列表 + 撤销按钮), `src/frontend/platform/src/controllers/API/shareLink.ts`(新:`generateAppShareLinkApi / listAppSharesApi / revokeAppShareApi`), `src/frontend/platform/public/locales/{zh-Hans,en-US,ja}/bs.json`(`api.noLoginLinkDescription` 三语改为「分享链接带有效期、可随时撤销」口径) + **逻辑**: D8 前端;存量裸分享链接升级后失效、须重新分享(发布说明 T074)。 + **覆盖 AC**: AC-55, AC-57 + **手动验证**: 工作流 / 助手接入页生成免登录链接 → 新开无痕窗口打开可对话(步 T056 完成后);撤销 → 已开页面 ≤ 5s 断连、刷新被拒;到期同理;旧格式链接(无 `share_token`)打开被拒。 + **依赖**: T050 + +- [ ] **T055**: client guest 页:share-token 读取 + WS URL + **文件**: `src/frontend/client/src/utils/shareToken.ts`(新:从 URL query 取 `share_token`、会话内保存), `src/frontend/client/src/pages/appChat/useChatHelpers.ts`(`:23-40` 拼 WS URL 时 guest 模式带 `?share_token=`;`:35-36` 两个 WS 路径不变) + **逻辑**: 密钥不经查询参数、share-token 例外(spec §3)。 + **覆盖 AC**: AC-55 + **手动验证**: 带 `share_token` 打开 guest 页 → WS 建连成功;去掉参数 → 建连被拒并显示无权限态。 + **依赖**: T053 + +- [ ] **T056**: client guest 页:HTTP 改打 share-link 作用域端点 + **文件**: `src/frontend/client/src/api/apps.ts`(`:39` `GET /flows/{id}`、`:64` `GET /assistant/info/{id}`、`:133` `GET /chat/history` 在 guest 模式改打 `/api/v1/share-link/{token}/resource` / `.../chat/history`), `src/frontend/client/src/api/chat/api-endpoints.ts`(`:51` `gen_title` guest 模式改打 `/api/v1/share-link/{token}/chat/gen_title`), `src/frontend/client/src/pages/standaloneChat/StandaloneChatPage.tsx`(`:82` guest → 注入 share-token 上下文;**不再打任何 `/api/v2` HTTP**) + **逻辑**: D8 备选 C;`useWebsocket.ts:82` 处 gen_title 调用经 `api-endpoints.ts` 已覆盖。 + **覆盖 AC**: AC-30, AC-55 + **手动验证**: guest 页全程 Network 面板无 `/api/v2/*` HTTP 请求;信息 / 历史 / 标题正常;撤销后刷新被拒。 + **依赖**: T055 + +### Wave 5 · 主体侧授权页 + 资源归属人回授(D5 / D6) + +- [ ] **T057**: F048 runtime 回授分支 + 新来源值 单元测试 + **文件**: `src/backend/test/permission/test_service_account_autogrant.py`(新) + **逻辑**: `test_authorize_created_with_autogrant_adds_non_protected_row`(`autogrant_user_id == actor.user_id != owner_user_id` → 创建计划含 `subject=user:{sa}`、`source_type=SERVICE_ACCOUNT_AUTOGRANT`、`protected=False`、档位 = 该类型含 `edit` 动作的最低档(经 Catalog 解析);同一 plan / prepare / execute / finalize;幂等键不变)→ AC-24;`test_autogrant_structural_constraints`(`autogrant_user_id != actor` 或 `== owner` → 拒;`INHERIT` 起始类型(`folder / knowledge_file`)→ 忽略回授、记 debug、不报错)→ AC-24;`test_source_type_registered_everywhere`(`SOURCE_TYPES` / `_validate_source_subject`(允许 `user`)/ `_source_locator` 均识别新值;`f048_mode_mapper` / `control_state` 按普通源处理 → 可 `mutate REMOVE`)→ AC-63;`test_owner_projection_context_extra_grants_not_copy_grants`(`extra_grants` 不触发 `_validate_copy :236-239` 的 `PermissionInvalidResourceError`);`test_no_creator_row_for_service_account`(CREATOR 行在 owner(归属人),服务账号名下无 CREATOR)→ AC-61。 + **覆盖 AC**: AC-24, AC-61, AC-63 + **依赖**: T014, T008 + +- [ ] **T058**: `grant_source_service` 新来源值 + `OwnerProjectionContext.extra_grants` + **文件**: `src/backend/bisheng/permission/domain/services/grant_source_service.py`(`SOURCE_TYPES :16-26` 加 `SERVICE_ACCOUNT_AUTOGRANT`;`_validate_source_subject :178` 允许 `user`;`_source_locator :193`), `src/backend/bisheng/permission/domain/services/owner_service.py`(`OwnerProjectionContext :38-53` 加 `extra_grants / extra_deltas`;`project_created :113-166` 把 extra deltas 与 owner protected deltas 一起进 `build_create_plan(protected_deltas=...)`;**不复用** `copy_grants / copy_deltas`(坑 27)) + **跨 Feature**: PermissionGrant 归 F048——只增来源值与显式上下文字段,不改既有来源语义;`sync_business_source_model` 白名单(`runtime.py:647-649`)与之无关。 + **测试**: T057 来源值 / context 部分通过 + **覆盖 AC**: AC-24, AC-63 + **依赖**: T057 + +- [ ] **T059**: `runtime.authorize_created` `autogrant_user_id` + `control_state` 落账本 + **文件**: `src/backend/bisheng/permission/application/runtime.py`(`authorize_created :206-260` 新增显式 kwarg `autogrant_user_id: int | None = None`:校验 `== actor.user_id ∧ != owner_user_id ∧ 资源版本 0`;目标起始 `INHERIT` → 忽略并 debug;否则组回授 delta 进 `OwnerProjectionContext.extra_grants`;`:216-223` 对 owner 行的硬拒不变), `src/backend/bisheng/permission/application/control_state.py`(`_owner_projection_grants :956-963` 与 `_state.prepare / finalize` 把 extra grant 的 assignee 行一并落账本;`:355 protected_only` 逻辑不动) + **逻辑**: D5 输入契约:`PermissionActor` / `resolve_permission_actor` **不改、不读 `user_type`**(C4);回授行进创建计划而非事后 `mutate`(失败即整个创建 `FAILED_CLOSED`)。 + **跨 Feature**: F048 runtime 公共入口加可选 kwarg,缺省行为零变化。 + **测试**: T057 全部通过 + **覆盖 AC**: AC-24, AC-61, AC-63 + **依赖**: T058 + +- [ ] **T060**: 资源归属人三条创建路径集成测试 + **文件**: `src/backend/test/open_api/test_resource_owner_paths.py`(新) + **逻辑**: 以服务账号密钥(持 `knowledge:write`、被授予父库可编辑档)。**前置授权途径**:主体侧端点 T065 排在本任务之后、资源侧 `grants:mutate` 已被 T019 拒绝——本文件的授权 fixture **直接调 `resource_api.mutate_grants(..., allow_service_account_subject=True)`**(T019 加的显式参数,actor 用 `tenant_admin_payload` 解析的 `PermissionActor`),不经任何 HTTP 端点;T063 再经 `POST /{id}/grants:mutate` 端点验一遍同一路径。`test_owner_permission_change_does_not_affect_key` 里对归属人的加入 / 移出走普通资源侧 `grants:mutate`(自然人主体,不受 T019 影响)。用例:`test_create_knowledge_base_owner_and_autogrant`(`POST /filelib/` type≠3 → `knowledge.user_id == 归属人`、CREATOR 行主体 = 归属人、服务账号只有一条 `SERVICE_ACCOUNT_AUTOGRANT` 行且可继续写入)→ AC-24 / AC-61;`test_create_knowledge_space_same`(type=3 走 `KnowledgeSpaceService`)→ AC-24 / AC-61;`test_upload_file_owner_no_autogrant`(`POST /filelib/file/{kid}` / `chunks`:`KnowledgeFile.user_id == 归属人`、`updater_id == 服务账号`、文件 / 文件夹上服务账号**零行**且仍能再上传)→ AC-24 / AC-61;`test_owner_sees_resource_as_own`(归属人 `GET /api/v1/knowledge` 列表含之、可管理)→ AC-24;`test_session_stays_with_service_account`(v2 `chat/completions` 产生的 `MessageSession.user_id == 服务账号`,归属人会话列表不含)→ AC-25;`test_owner_permission_change_does_not_affect_key`(把归属人加入 / 移出另一知识库授权 → 服务账号对该库的 v2 读写结果不变)→ AC-26;`test_change_owner_not_retroactive`(换归属人 → 旧库 creator 不变、新建库归新归属人)→ AC-27。 + **覆盖 AC**: AC-24, AC-25, AC-26, AC-27, AC-61 + **依赖**: T059, T040, T008 + +- [ ] **T061**: `knowledge_service.py` 三处硬写参数化 + 回授字段 + **文件**: `src/backend/bisheng/knowledge/domain/services/knowledge_service.py`(`acreate_knowledge_base :795-799` / `create_knowledge_base :741-749`:`login_user.open_api_principal` 存在且 `subject_kind=='service_account'` → `db_knowledge.user_id = principal.resource_owner_user_id`;`_new_library_permission_record :194-208` 带 `creator_autogrant_user_id = login_user.user_id`(经 `_project_library_created :212-223` 透传);`process_one_file :1483-1544` `KnowledgeFile.user_id` 同规则改归属人、`updater_id` 保持服务账号;`_new_library_file_permission_record :242-254` 不带回授(INHERIT)) + **逻辑**: D5 路径 1 与 3;`create_knowledge :674` 只是上层入口不改。 + **测试**: T060 知识库 / 文件用例通过 + **覆盖 AC**: AC-24, AC-25, AC-26, AC-27 + **依赖**: T060 + +- [ ] **T062**: `knowledge_space_service.py` + adapter 透传 + **文件**: `src/backend/bisheng/knowledge/domain/services/knowledge_space_service.py`(`create_knowledge_space :1100` `user_id` 同规则参数化;`:1110-1117 authorize_created(owner_user_id=…)` 记录带 `autogrant_user_id`), `src/backend/bisheng/knowledge/domain/services/knowledge_permission_service.py`(`:995-1000` adapter 把记录上的 `creator_autogrant_user_id` 透传为 `authorize_created(autogrant_user_id=…)`) + **逻辑**: D5 路径 2;QA 问答对与 `knowledge/*metadata*` 不是 F048 资源、不经接缝。 + **测试**: T060 全部通过 + **覆盖 AC**: AC-24, AC-61 + **依赖**: T061 + +- [ ] **T063**: 主体反查 + 授权端点 + 删除反查 集成测试 + **文件**: `src/backend/test/open_api/test_service_account_grants_api.py`(新) + **逻辑**: `test_grants_endpoints_admin_only_403_envelope`(非管理员 GET / mutate / revoke-all → 信封 403)→ AC-59;`test_grant_add_via_subject_side_and_key_can_access`(`POST /{id}/grants:mutate` ADD 某知识库档位 → `GET /{id}/grants` 一条 `source_label='管理员授予'`(`DIRECT`)→ 持 `knowledge:read` 密钥可读该库)→ AC-19 / AC-60;`test_resource_side_still_rejects_26029`(资源侧端点授同一服务账号 → 26029;已授权对象列表仍显示该服务账号)→ AC-16 / AC-59;`test_list_is_single_list_with_source_and_bijective`(管理员授予 + 回授两类同列表;集合与 `permission_grant_assignee` 有效行一一对应;无 CREATOR 行;异常来源标「异常来源」并进日志)→ AC-61;`test_remove_then_key_gets_resource_error_then_restore`(REMOVE → v2 读该库返回业务资源权限错误(≠ 26003);重新 ADD → 恢复)→ AC-62;`test_revoke_all_excludes_autogrant`(`POST /{id}/grants:revoke-all` 只删 `DIRECT`;回授行仍在;单条 REMOVE 回授行 → 集成写入失败;重新授予 → 恢复)→ AC-63;`test_page_header_keys_and_scope_gap_hint`(`GET /{id}/grants` meta 含名下密钥数与各自权限位、以及「已授权但无密钥持对应位」的资源 id 列表)→ AC-64;`test_grant_audit_events`(add / update / remove / remove_all:操作管理员、目标账号、资源类型与 id、前后档位、时间)→ AC-65;`test_delete_lists_grants_and_removes_all_including_autogrant`(`DELETE /{id}` 预检响应列全部授权含回授项;执行后逐资源 REMOVE、密钥失效、`deleted_at`;被授权资源与归属人不受影响)→ AC-48;`test_reverse_lookup_single_select_tenant_filtered`(子租户管理员反查 Root 服务账号 → 26020;SQL 无 UNION / `text()`(坑 14))。 + **覆盖 AC**: AC-16, AC-19, AC-48, AC-59, AC-60, AC-61, AC-62, AC-63, AC-64, AC-65 + **依赖**: T059, T024, T008 + +- [ ] **T064**: `permission.application.subject_api.list_subject_grants` + **文件**: `src/backend/bisheng/permission/application/subject_api.py`(新:`list_subject_grants(subject_type='user', subject_id, tenant_id, resource_type=None, cursor=None)`:单条 `select` + join `permission_grant_assignee a JOIN permission_grant g WHERE a.subject_type='user' AND a.subject_id=:sa AND a.state='ACTIVE'`(命中 `ix_perm_assignee_subject_state`,`tenant_id` 由自动过滤注入);资源名水合走 registry 白名单 `api/services/f048_permission_runtime.py:171-195`(不照抄前端 union);返回行形状 = `PermissionGrantAssignee` + `source_type`) + **逻辑**: D6 备选 C;只读 API,不改 F048 写路径。 + **跨 Feature**: `permission.application` 新增只读入口,供 F050 授权页「delegate 提示」复用。 + **测试**: T063 反查用例通过 + **覆盖 AC**: AC-61, AC-64 + **依赖**: T063 + +- [ ] **T065**: 授权端点 + 删除流程升级 + **文件**: `src/backend/bisheng/open_api/api/endpoints/service_account_grants.py`(新:`GET /{id}/grants`(分页、按 `resource_type` 筛、`source_label` 映射:`DIRECT`→管理员授予 / `SERVICE_ACCOUNT_AUTOGRANT`→创建时自动回授 / 其它→异常来源+日志;meta 含名下密钥位与 AC-64 缺口列表)· `POST /{id}/grants:mutate`(`{changes:[{resource_type,resource_id,op,model_key,expected_assignee_version?}]}`:校验管理员 + 账号存在 → 逐资源取 context → 调同一 `resource_api.mutate_grants` / runtime,携带 `allow_service_account_subject=True`;N 次 mutate 逐条结果、不做事务;审计 `open_api.grant.*` 含前后档位)· `POST /{id}/grants:revoke-all`(反查后过滤掉 `SERVICE_ACCOUNT_AUTOGRANT` 再逐条 REMOVE)), `src/backend/bisheng/open_api/domain/services/service_account_service.py`(`delete` 升级:反查 → 逐资源 REMOVE(含回授)→ `revoke_by_subject('subject_deleted')` → `deleted_at` → 审计;`DELETE` 预检返回授权清单) + **逻辑**: D6 W2;管理员对目标资源无需另持权限(`_system_authorized`,`runtime.py:1077-1083`);assignee 外键 `ondelete=RESTRICT` 指向 grant 非主体(`grant.py:137`),故必须显式 REMOVE。 + **测试**: T063 全部通过 + **覆盖 AC**: AC-16, AC-19, AC-48, AC-59, AC-60, AC-61, AC-62, AC-63, AC-64, AC-65 + **依赖**: T064 + +- [ ] **T066**: platform 「资源授权」tab + **文件**: `src/frontend/platform/src/pages/SystemPage/components/ServiceAccount/ServiceAccountGrantsTab.tsx`(新), `src/frontend/platform/src/pages/SystemPage/components/ServiceAccount/GrantResourceDialog.tsx`(新:选资源类型 / 资源 + 档位(`getGrantablePermissionModelsApi`,本页不自定义档位)), `src/frontend/platform/src/controllers/API/serviceAccount.ts`(追加 `getServiceAccountGrantsApi / mutateServiceAccountGrantsApi / revokeAllServiceAccountGrantsApi`), `src/frontend/platform/public/locales/{zh-Hans,en-US,ja}/serviceAccount.json`(资源授权 tab 全部文案:来源标签 / 缺口提示 / 回授撤销确认 / 全部撤销说明 / 空态,三语同 PR) + **逻辑**: 顶部:名下密钥数与各自权限位 + 「已授权但无密钥持对应位」提示(AC-64);单一列表 + 来源列(`SourceBadge` 复用)+ 单条撤销(回授项 `bsConfirm`「撤销后该集成将无法再写入这个资源」)+ 「全部撤销」(文案明示不含回授项)+ 授予按钮;空态文案说明「零授权时该账号的一切开放 API 调用都会失败」(坑 21);不复用 `PermissionDialog` 整体。 + **覆盖 AC**: AC-59, AC-60, AC-61, AC-62, AC-63, AC-64 + **手动验证**: 授予知识库可读 → 密钥可 `GET /filelib/`;撤销 → 资源权限错误;「全部撤销」后回授行仍在;无 `knowledge:read` 密钥时顶部出现缺口提示;非管理员直接访问 tab 路由不可见、直调端点 403。 + **依赖**: T065, T030 + +- [ ] **T067**: platform 删除弹窗升级 + 回授撤销确认 + **文件**: `src/frontend/platform/src/pages/SystemPage/components/ServiceAccount/DeleteServiceAccountDialog.tsx`(新:列出 `DELETE` 预检返回的全部授权(含回授项)+ 「删除后这些授权一并失效、依赖它的集成将立即失败」,确认即删、不阻断), `src/frontend/platform/src/pages/SystemPage/components/ServiceAccount/OverviewTab.tsx`(删除按钮改用该弹窗), `src/frontend/platform/public/locales/{zh-Hans,en-US,ja}/serviceAccount.json`(删除弹窗清单 / 「一并失效」文案三语) + **覆盖 AC**: AC-48, AC-63 + **手动验证**: 有授权的账号点删除 → 清单含管理员授予与回授两类 → 确认 → 删除成功、被授权知识库与归属人无变化。 + **依赖**: T066 + +### Wave 6 · 对账豁免 / 配额 / 管理接口矩阵 / 到期 Beat / 发布说明 / E2E + +- [ ] **T068**: 对账豁免 + 配额排除测试 + **文件**: `src/backend/test/tenant/test_service_account_reconcile_exempt.py`(新) + **逻辑**: `test_reconcile_skips_service_account_structurally`(子租户服务账号(无部门)跑 `reconcile_user_tenant_assignments` → 归属不变、`token_version` 不 bump、`tenant#member` 不重写;`alist_users_after_id` 结果不含服务账号;`sync_user` 直接传入服务账号 id → 入口守卫跳过)→ AC-17;`test_unmount_and_delete_tenant_paths_do_not_touch`(`tenant_mount_service :487-600` / `tenant_service.py:355-397` 路径不产生悬空)→ AC-17;`test_quota_user_count_excludes_service`(`_count_user_count` 与 `acount_users_by_tenant_subtree` 两处口径一致均不计,坑 11)→ AC-18。 + **覆盖 AC**: AC-17, AC-18 + **依赖**: T014, T008 + +- [ ] **T069**: 对账 / 配额 实现 + **文件**: `src/backend/bisheng/user/domain/models/user.py`(`alist_users_after_id :541-556` 加 `User.user_type == 'human'`), `src/backend/bisheng/tenant/domain/services/user_tenant_sync_service.py`(`sync_user :76` 入口:目标 `user_type=='service'` → return,日志 debug), `src/backend/bisheng/role/domain/services/quota_service.py`(`_count_user_count :622-651` 加类型条件) + **逻辑**: 坑 7 / 坑 11;不改 `TenantResolver`、不改 guest 兜底三条路径(坑 6)。 + **跨 Feature**: `worker/tenant_reconcile/tasks.py:68` 经 `alist_users_after_id` 自动生效;自然人对账行为不变。 + **测试**: T068 全部通过 + **覆盖 AC**: AC-17, AC-18 + **依赖**: T068 + +- [ ] **T070**: AC-22 管理接口矩阵其余项 + 全局成员搜索测试 + **文件**: `src/backend/test/user/test_service_account_admin_ops_reject.py`(新) + **逻辑**: 部门加成员 `aadd_members`、部门管理员 `aset_admins`、用户组 `replace_user_groups / set_group_admin / set_group_members` 五入口对服务账号 → 26022 → AC-22;`test_department_global_member_search_excludes`(`department_service :2255-2330` 全局成员搜索不含服务账号,即使被误塞 guest 部门也不含)→ AC-16;`test_guest_backfill_paths_do_not_hit`(`_backfill_guest_department_membership` / `_reconcile_guest_membership` / `_ensure_user_guest_department_membership` 三路径跑过后服务账号无 `user_department` 行——以测试断言固化,不改路径,坑 6)→ AC-22。 + **覆盖 AC**: AC-16, AC-22 + **依赖**: T017, T008 + +- [ ] **T071**: 部门 / 用户组管理接口拒绝 + 全局搜索纵深 + **文件**: `src/backend/bisheng/department/domain/services/department_service.py`(`aadd_members :1749` / `aset_admins :1986` 调 `assert_natural_persons`;全局成员搜索 `:2255-2330` 加 `User.user_type=='human'`), `src/backend/bisheng/api/services/role_group_service.py`(`replace_user_groups :175` / `set_group_admin :238` / `set_group_members :271` 均为**同步**方法 → 调同步版 `assert_natural_persons`(T016 提供双版本);department 两处 async → `aassert_natural_persons`) + **测试**: T070 全部通过 + **覆盖 AC**: AC-16, AC-22 + **依赖**: T070 + +- [ ] **T072**: 到期兜底 Beat 任务测试 + **文件**: `src/backend/test/open_api/test_expire_credentials_task.py`(新) + **逻辑**: `test_task_marks_never_called_expired_keys`(跨两租户造过期且从未再被调用的密钥 → 任务后 `revoke_reason='expired'`、`revoked_at` 仍 NULL、每把恰一条 `open_api.api_key.expire` 审计、`operator_name=='system'`)→ AC-05 / AC-12;`test_idempotent_with_lazy_channel`(先经校验惰性标记再跑任务 → 不重复审计)→ AC-12;`test_tenant_context_per_row`(写入时 `get_current_tenant_id() == row.tenant_id`;枚举在 `bypass_tenant_filter()` 内)。 + **覆盖 AC**: AC-05, AC-12 + **依赖**: T010, T008 + +- [ ] **T073**: Worker `expire_credentials` Beat 任务 + **文件**: `src/backend/bisheng/worker/open_api/tasks.py`(新,含 `__init__.py`:`expire_credentials`,默认队列、每小时), `src/backend/bisheng/core/config/settings.py`(`celery_task.beat_schedule :167-189` 缺省注入 `open_api_expire_credentials`) + **约束(tenant_id 传递)**: Beat 触发无租户上下文——任务体在 `bypass_tenant_filter()` 下枚举 `expires_at <= now AND revoke_reason IS NULL`,**逐行 `set_current_tenant_id(row.tenant_id)`** 后调 `CredentialService.mark_expired_lazy(row.id)`(同一条单行 UPDATE + 审计幂等闸);写法同 `worker/tenant_reconcile/tasks.py:55-80`(backend AGENTS.md「Beat × 多租户」陷阱)。不删缓存(到期靠每请求比对)。114 若未起 Beat,惰性通道仍保证被拒时有记录。 + **测试**: T072 全部通过 + **覆盖 AC**: AC-05, AC-12 + **依赖**: T072 + +- [ ] **T074**: 发布说明「升级前必读」章节 + **文件**: `docs/api/开放 API 升级前必读(v3.0.0).md`(新) + **逻辑**: 四项(AC-50):① 升级前三步(创建服务账号并指定归属人、在其详情页「资源授权」授权 → 签发密钥 → 调用方加 `Authorization: Bearer bs-sak-…` 头);② 「升级完成到配好密钥之间存在接入空窗」,无兼容窗口 / 鉴权开关;③ 存量裸分享链接升级后失效、须在接入页重新生成(带有效期、可撤销);④ 不再提供的 6 个 `/chat/*` 端点清单;另附:`default_operator` / `enable_guest_access` 配置移除说明(残留键无害)、`download_statistic` 入参 `file_path → file_name`、密钥前缀模式、`GET /api/v2/auth/whoami` 自检方法、`F049 + F050 合并发版`(裸 `user_id` 参数随 F050 移除)。 + **测试降级**: 文档交付物,无自动化断言——手动审阅四项齐全(AC-50 的 401 行为由 T034 自动化覆盖)。 + **覆盖 AC**: AC-50, AC-54 + **依赖**: T045 + +- [ ] **T075**: E2E(`/e2e-test`)+ 页面手动清单 + **文件**: `src/backend/test/e2e/test_e2e_openapi_auth_baseline.py`(新,由 `/e2e-test features/v3.0.0/049-openapi-auth-baseline` 生成), `features/v3.0.0/049-openapi-auth-baseline/tasks.md`(本文追加「页面手动验证清单」结果) + **逻辑**: API 层 E2E 走 design §7 步 3–5 剧本(真 HTTP 状态;非 admin 账号);页面手动清单覆盖 AC-41–49 / 59–65 的界面路径,尤其 **AC-43**(创建成功直达签发入口,前端行为)与 **AC-45**(一次性展示:复制 + 「关闭后不可再查看」+ 「我已保存」才可关闭)——两者为纯前端行为、无后端可断言点,Playwright 未落地故只能在此覆盖。DM8 / SSO / 网关外部环境验证拆到 T075a。 + **测试降级**: 前端交互(AC-43 / AC-45)= 手动验证——理由:Playwright 未落地。 + **覆盖 AC**: AC-41, AC-42, AC-43, AC-44, AC-45, AC-46, AC-47, AC-48, AC-49, AC-59, AC-60, AC-61, AC-62, AC-63, AC-64, AC-65 + **依赖**: T033, T046, T056, T067, T071, T073, T074 + +- [ ] **T075a**: 外部环境回归:DM8 105 + SSO / 网关 109(AC-15 手动部分) + **文件**: `features/v3.0.0/049-openapi-auth-baseline/tasks.md`(本文「114 部署记录」下追加「105 / 109 回归记录」结果) + **逻辑**: **DM8 105 回归**(CICD:push → Drone 打镜像 → 105 pull && up,tag 可变须先 pull):建 ≥ 2 个服务账号(`(source='service_account', external_id=NULL)` 多行唯一性,D1 风险)+ 5 秒失效矩阵抽样(撤销 / 停用 / 到期)+ `JsonType` scopes 读回 + T001 / T047 两 revision 在 DM8 上 upgrade / downgrade 各一次;**AC-15 手动部分**:109 联调环境(入口 120:3003 → 3001 → 8098 Gateway → 7860)用服务账号用户名走 Java 网关登录同步 / SSO 网关回调 → 均 26012(不是「无菜单」)。 + **测试降级**: SSO / 网关入口(AC-15 部分)= 手动验证——理由:网关不在本仓、无 TestClient 可达;DM8 无本地实例。 + **覆盖 AC**: AC-15, AC-19, AC-47 + **依赖**: T075 + +--- + +## AC → 任务追溯表(核对用) + +> 每条 AC 至少出现在一个**测试任务**的「覆盖 AC」里;标 † 的仅由 E2E / 手动清单(T075)覆盖,理由见该任务。 + +| AC | 测试任务 | AC | 测试任务 | AC | 测试任务 | +|---|---|---|---|---|---| +| AC-01 | T011, T020, T034 | AC-23 | T013, T023 | AC-45 † | T075 | +| AC-02 | T009, T025 | AC-24 | T057, T060 | AC-46 | T025, T075 | +| AC-03 | T009, T011, T051 | AC-25 | T060 | AC-47 | T011, T013, T023, T051, T075, T075a | +| AC-04 | T020, T034 | AC-26 | T060 | AC-48 | T013, T023, T063, T075 | +| AC-05 | T009, T011, T072 | AC-27 | T013, T060 | AC-49 | T023, T025, T075 | +| AC-06 | T009, T025 | AC-28 | T013, T023 | AC-50 | T034(401 无回落), T074(发布说明·手动) | +| AC-07 | T023, T025 | AC-29 | T034, T051 | AC-51 | T044 | +| AC-08 | T009, T025 | AC-30 | T034 | AC-52 | T044 | +| AC-09 | T009, T025, T051 | AC-31 | T051 | AC-53 | T044 | +| AC-10 | T009 | AC-32 | T011, T020 | AC-54 | T044, T074 | +| AC-11 | T009 | AC-33 | T020 | AC-55 | T048, T051 | +| AC-12 | T009, T013, T025, T072 | AC-34 | T011, T020 | AC-56 | T048, T051 | +| AC-13 | T020, T025 | AC-35 | T020, T034, T035 | AC-57 | T048 | +| AC-14 | T025 | AC-36 | T034 | AC-58 | T051 | +| AC-15 | T015(四入口自动化), T075a(SSO / 网关手动) | AC-37 | T041 | AC-59 | T023, T063, T075 | +| AC-16 | T015, T018, T063, T070 | AC-38 | T041 | AC-60 | T063, T075 | +| AC-17 | T068 | AC-39 | T035 | AC-61 | T057, T060, T063, T075 | +| AC-18 | T068 | AC-40 | T041 | AC-62 | T063, T075 | +| AC-19 | T013, T023, T063, T075a(DM8) | AC-41 | T023, T075 | AC-63 | T057, T063, T075 | +| AC-20 | T015, T023 | AC-42 | T023, T075 | AC-64 | T063, T075 | +| AC-21 | T011, T013, T051 | AC-43 † | T075 | AC-65 | T063, T075 | +| AC-22 | T015, T070 | AC-44 | T025, T075 | | | + +**未被任何测试任务覆盖的 AC:无。** + +**汇总**:任务 76 个(T001–T075 + T075a);Wave 6 个;`[MVP-114]` 任务 33 个(T001–T033,Wave 1–2 全部);测试任务 22 个(T009 / T011 / T013 / T015 / T018 / T020 / T023 / T025 / T034 / T035 / T041 / T044 / T048 / T051 / T057 / T060 / T063 / T068 / T070 / T072 / T075 / T075a);数据库变更 3 个(T001 / T002 / T047,均含回滚方案;T047 实际与 T001 同批执行);Client 任务 2 个(T055 / T056)。 + +--- + +## 114 部署记录 + +> T033 执行时填写:部署时间 / commit / `open_platform` 键写入时间 / 手动验证结果。 + +### 部署顺序(坑 22:`config.yaml` 顶层未知键会让后端拒绝启动,所以先发代码、再加键、再重启) + +1. **发代码 + 建表**:`bash /opt/bisheng-ops/deploy.sh` + - 该脚本内含 `alembic upgrade head`(带上 T001 的 `user.user_type` revision)与 `create_all(checkfirst=True)`(建 `api_credential` / `service_account` 两张新表)。 + - 校验:`SHOW COLUMNS FROM user LIKE 'user_type'` 有行;`SHOW TABLES LIKE 'api_credential'` / `'service_account'` 各有行。 +2. **前端产物**:platform 需重新 build(新增 `serviceAccount` i18n 命名空间 + 新 tab)。⚠️外网 50071 是静态 build 快照,需手动重建,否则页面上看不到「服务账号」tab。 +3. **加配置键**(**必须在第 1 步之后**):`config.yaml` 追加 + ```yaml + open_platform: + enabled: true + open_api: + service_account_idle_days: 90 + credential_cache_ttl_seconds: 3 + ``` + `open_platform.enabled=false` 时服务账号模块照常存在(AC-49),只是「本地开发工具包」三位不出现——两种取值都要验一遍(见清单第 7 条)。 +4. **重启后端**(⚠️pgrep pattern 只能写在脚本文件里,否则会自杀);随后 + `curl -s http://127.0.0.1:7860/api/v1/env | jq .open_platform_enabled` → `true`。 + +### 首波手动验证清单(design §7 步 1–3、步 5;**必须用非 admin 账号**,超管会短路 ReBAC) + +| # | 步骤 | 期望 | +|---|---|---| +| 1 | `curl -s <114>/api/v1/env \| jq .open_platform_enabled` | `true`(第 3 步做完之后) | +| 2 | 以**租户管理员**(非 admin)登录 platform → 系统管理 | 出现「服务账号」tab;普通用户登录看不到该 tab,直接调 `GET /api/v1/service-accounts` 得信封 `status_code=403`(HTTP 仍 200,且**不是** 19801 的 LLM 只读文案)→ AC-41 / AC-59 | +| 3 | 新建服务账号:只填名称提交 | 被拦(归属人必填)→ AC-23 | +| 4 | 新建服务账号:归属人选人框里搜刚建的服务账号名 | 搜不到(选人路径已排除服务账号)→ AC-16 | +| 5 | 填齐名称 + 归属人(= 开发者本人)提交 | 停在**详情 · API 密钥 tab 且签发弹窗已打开**(不是回列表)→ AC-43 | +| 6 | 签发表单:不勾任何位直接看默认态 | 全不勾;无 `delegate` 位、无委托配置区 → AC-06 / AC-14 | +| 7 | 观察「本地开发工具包」分组 | `open_platform.enabled=true` 时出现 `model:invoke` / `identity:read` / `app:manage` 三位,`identity:read` 旁常驻醒目提示「勾选即本租户组织架构全量可读…」,`app:manage` 附「部署应用需勾选」;改成 `false` 重启后三位消失、tab 与列表仍在 → AC-13 / AC-49 | +| 8 | 勾任一开放 API 端点位(如 `assistant:read`) | 出现不阻断软提示「转交个人使用的 key 建议仅配本地开发工具包权限位」→ AC-13 | +| 9 | 悬停某个权限位的问号 | 显示该位覆盖的接口清单;`chat:invoke` 显示「端点随后续版本开放」→ AC-44 | +| 10 | 勾 `app:manage` 提交签发 | 一次性展示弹窗:明文可复制;**未勾「我已保存」时关不掉**(无右上角 ×、Esc / 点遮罩无效)→ AC-02 / AC-45 | +| 11 | 勾「我已保存」关闭,回到密钥列表 | 只见 `bs-sak-********xxxx` 掩码,任何界面不再出现明文 → AC-02 | +| 12 | `curl -H "Authorization: Bearer bs-sak-…" <114>/api/v2/auth/whoami` | HTTP **200**,`scopes:["app:manage"]` | +| 13 | 去掉 Authorization 头 / 改一位字符 | HTTP **401**,`26001` / `26002`(真 HTTP 状态,不是信封) | +| 14 | 在密钥 tab 编辑该 key 的权限位(加 `assistant:read`)后立刻重跑第 12 步 | **同一把明文**的 `whoami.scopes` 立即变化,密钥不换 → AC-08 | +| 15 | 撤销单把密钥(二次确认;`open_platform.enabled=true` 时确认文案含「MCP / 模型协议 / CLI 三面同时被拒绝」) | 3 秒内 `whoami` → 401 `26002` → AC-46 | +| 16 | 概览 tab 停用账号(二次确认文案说明「停用后 5 秒内密钥被拒、授权与配置保留」) | 3 秒内其名下密钥 401;重新启用后原样恢复(归属人 / 密钥配置不变)→ AC-47 | +| 17 | 密钥 tab 顶部「撤销该账号全部密钥」 | 二次确认后名下全部密钥 3 秒内 401 → AC-09 / AC-46 | +| 18 | 概览 tab 删除账号(二次确认列出授权清单——Wave 1 恒为空态文案 + 「依赖它的集成将立即失败」) | 确认即删、不阻断;列表中消失、密钥 401 → AC-48 | +| 19 | 列表页观察 | 有效密钥数为 0 的行高亮;归属人被禁用的行高亮;最后调用为空 / 超 `idle_days` 的行给出「可考虑停用」提示(阈值取响应里的 `idle_days`,不是前端写死的 90)→ AC-42 | +| 20 | 用**普通用户**看:用户管理 / 审批人选择 / 部门加成员 / 资源授权弹窗 | 都搜不到服务账号 → AC-16 | +| 21 | 用服务账号名 + 任意密码登录 | 被拒,错误码 `26012` → AC-15 | +| 22 | 系统管理 → 系统操作日志:模块筛选「开放 API」 | 能筛到,看到第 10 / 15 / 17 步产生的 `签发 API 密钥` / `撤销 API 密钥` / `批量撤销 API 密钥` 事件,metadata 只有掩码、无明文 → AC-12 | +| 23 | (多租户)超管在「模型管理」页 ScopeBar 切到子租户,再回到「服务账号」tab | 列表是子租户的数据(tab 切换会重新挂载拉取);在该视角建号落子租户 → AC-23 / 坑 23 | + +> 记录(部署时填):部署时间 = ;commit = ;`open_platform` 键写入时间 = ;逐条结果 = 。 + +### 105 / 109 回归记录 + +> T075a 执行时填写:DM8 镜像 tag / 两 revision upgrade-downgrade 结果 / 5 秒失效矩阵抽样 / 109 网关与 SSO 入口 26012 截图或日志。 + +(未开始) + +--- + +## 实际偏差记录 + +> **只留一行指针**,论证在 design.md(决策 / 坑),这里不重复(见 `docs/SDD-Guide.md` §4)。 +> 推翻已 ★ 确认的决策时,先停下与用户重新确认(§3 第四个 ★),再记录。 + +| 任务 | 偏差 | 回写到 design | 原因(一句话) | +|---|---|---|---| +| T024 | 管理面读写显式带 `tenant_id` 条件(`ServiceAccountService.get_row/get_detail/list_page` + `ServiceAccountDao.alist_page(tenant_id=)`),不只靠自动租户过滤 | design §4.3「open_api/」行 + 坑 14 旁注 | 子租户管理员的 `visible_tenant_ids` 是 `{leaf, Root}` IN-list,只靠自动过滤会让他看到 Root 的服务账号,违反 AC-07 | +| T021 | `open_api_subject(scope)` 返回**依赖可调用对象**(用法 `Depends(open_api_subject(...))`),不是 `Depends` 实例 | design §4.3「`open_api_subject(scope=…)` `Depends` 工厂」措辞 | tasks T020 的验收写法即 `Depends(open_api_subject('app:manage'))`;返回 `Depends` 会变成 `Depends(Depends(...))` | +| T022 | handler 函数定义在 `open_api/api/exception_handlers.py`,`main.py` 只调 `register_open_api_exception_handlers(app)` | design §4.3「main.py」行 | 让测试能在同一函数上验证 handler,且 `main.py` 不再堆 open_api 细节;注册点仍是 `main.create_app` | +| T021 | 依赖出口不再重复调 `CredentialService.touch_last_used`(T012 已在 `validate_bearer` 内做) | 无(D2 顺序不变,只是落点在 validator) | 重复调用只会多一次同样被 60s 闸拦下的写,无额外信息 | +| T030 | 删除二次确认展示的是**恒定空态文案**(`overview.deleteGrantsEmpty`),没有单独的 `DELETE` 预检调用 | design §4.3「详情三子 tab」行旁注 | Wave 1 没有主体侧授权反查端点,`DELETE /{id}` 本身即执行删除、其 `grants` 是删后回执;预检位置留给 T066 的 `GET /{id}/grants`,届时只换数据源不换交互 | +| T030 | 概览 tab 的可编辑范围含名称 / 描述(不只资源归属人) | 无(AC-41 概览本就列这两项,`PATCH` 后端已支持) | 同一个 `PATCH` 端点、同一个编辑态,拆成两个入口反而多一次请求 | +| T031 | `components/bs-ui/dialog` 的 `DialogContent` 补 `close?: boolean` 类型声明 | 无(运行期早已支持,仅类型缺失) | 一次性展示弹窗必须去掉右上角 ×(AC-45),而 strict 文件传 `close={false}` 会类型报错;补声明同时让既有 5 处调用点合法 | +| T031 | 密钥列表「权限位」列额外拉一次 `GET /scopes` 做 code → 标签映射,未知 code 回落原始 code | 无 | 密钥行只存 code,直接显示 `app:manage` 这类标识对业务管理员不可读;回落保证「开关关掉后仍能看懂历史密钥」 | +| T029 | 服务账号 tab 内不放 ScopeBar(超管切租户视角仍走「模型管理」页那一个入口) | 无(design §4.3 前端行未列 ScopeBar) | Radix `TabsContent` 非激活即卸载,切回本 tab 必然重新挂载拉取,切换视角后的数据天然是新的;再放一个切换器会出现两处真相 | + +(实现期每条偏差一行,design 同步覆盖为「今天的状态」。) + +--- + +## 跨 Feature 回写受理 + +> 别的 Feature 请求本 Feature 交付的增量,逐条记「谁请求 / 请求什么 / 交付没交付 / 在哪」。**只追加,不改上面任何任务的勾选**。 + +| 请求方 | 请求内容 | 状态 | 交付物 | +|---|---|---|---| +| **F053 T034 ①** | `WhoamiResponse` 增 `resource_owner: {user_id, user_name}` —— 服务端 `OpenApiPrincipal.resource_owner_user_id` 早就有(`open_api/domain/context.py:37-39`),但 `whoami` 不吐,于是 `bisheng login` 那一刻**看不见这把 key 的资源归属人是谁**。而资源归属人正是此后 `deploy` 出的应用 owner,选错要等到 deploy 之后甚至换人接手才暴露——F049 自己在签发表单上加的那条风险提示,说的就是这个错 | ✅ **已交付**(commit `874bd688b`,2026-08-18) | `open_api/domain/schemas/credential.py` 新增 `WhoamiResourceOwner` + `WhoamiResponse.resource_owner`(可选,`None` 表示该主体无归属人);CLI 侧 `login` 输出随之解除降级(F053 AC-06 的 `[受阻于 F049 回写]` 标记可撤) | +| **F053 T034 ④** | 裁定 F053 spec AC-05 的措辞冲突:「`login` 校验入口在开放能力层未部署时不可达」与本 Feature「服务账号模块恒在 + `whoami` 恒在注册」(`api/router.py:123-126`;`core/config/open_platform.py:17-18` 注释逐字 "the service-account module is always on")矛盾 | ✅ **已裁定**(同 commit `874bd688b`) | **改 F053 spec,不改 F049**——把 `whoami` 也置于开关下等于让 F049 的显式设计破例,并波及已落码的鉴权面。F053 改为「CLI 的 `login` 在该环境不可用并给出可读原因」,由其 T008 的前置探测 + exit 8 兑现 | + +(2026-08-19 由收口批登记;本表不影响上方任务勾选状态。) diff --git a/features/v3.0.0/050-identity-modes/spec.md b/features/v3.0.0/050-identity-modes/spec.md new file mode 100644 index 0000000000..ab591799ea --- /dev/null +++ b/features/v3.0.0/050-identity-modes/spec.md @@ -0,0 +1,249 @@ +# Feature: 开放 API 身份传递(两种身份模式 + 受限委托五道准入 + 审计双归属 + 裸 `user_id` 收口) + +> **本文档定位 — 纯 What(需求口径,不随代码漂移)** +> +> spec 只回答 **做什么 / 验收标准 / 不做什么**。所有 How(决策、数据流、字段、API、Service、前端、文件清单、性能指标)一律不写在这里, +> How 的唯一真相在 [design.md](./design.md) 与 [tasks.md](./tasks.md)。 + +**关联 PRD**: [docs/PRD/3.0-beta2/3.0 开放 API 鉴权与身份传递 PRD.md](../../../docs/PRD/3.0-beta2/3.0%20开放%20API%20鉴权与身份传递%20PRD.md) **v2.1** §1.4(P1「身份传递」行)/ §3.2 场景二 / §3.4 场景与模式映射 / **§4.3 R2 两种身份模式全部**(§4.3.1 请求处理流程、§4.3.2 模式 S、§4.3.3 模式 D、§4.3.4 外部用户标识头、§4.3.5 模式对比)/ **§4.4 R3 全部**(§4.4.1 两层判定、§4.4.2 三条铁律含铁律 2 完整形态、§4.4.3 委托信任基础、§4.4.4 五道准入检查、§4.4.5 审计双归属、§4.4.6 纯替换等价性——门面归 F052)/ §4.2.3 密钥属性「委托范围」行 / §4.2.4 `delegate` 位与「三扩展位只走模式 S 且与 `delegate` 互斥」段 / §4.5 定义 6「与模式 D 的关系」/ §4.7.1 列表「委托」列 / §4.7.3 签发表单「委托配置」分组与编辑互斥 / §4.7.4「委托生效时本页授权不参与判定」/ §4.8.1 审计 / §4.9 三步指引第 3 步与「裸 `user_id` 随本版移除」/ §五 验收 AC-6~13、AC-23、AC-26、AC-37、AC-38、AC-39、AC-41、AC-46、AC-48 / §七 D2 / D8 / 附录 B.1(各端点允许模式列)/ 附录 C(26004–26007 / 26010 / 26016)/ 附录 E.2「自然人校验两个落点」「服务账号可能获得部门归属的两条自动路径」/ 附录 E.5 特权主体短路;上册《3.0 应用工场 PRD-1》**v2.0** DEV-01(签发表单互斥提示文案)/ DEV-02 ④(三面仅模式 S、全册无模式 D 场景)/ DEV-05 ①(模式 D 只作用于 V2 面)/ GOV-04(双归属字段)/ GOV-05「信任机制分界」 +**同步记录**: 2026-08-17 初稿(按伴生 PRD v2.1 + PRD-1 v2.0 + release-contract 拆分 v2 表 3 F050 行 + F049 spec 排除项「随 F050」清单 + Discovery §2.1 / §2.2 编写;§4 决议 1–12 于同日按用户授权的全自动模式直接定案并留痕) +> **2026-08-28 术语订正(就地修订,范围不变)**:伴生 PRD v2.4 新增自然人主体(个人访问令牌,§4.10)后,「模式 S = 服务账号身份」这一定义**不再成立**——模式 S 的判据是「以密钥主体**自己**的身份执行」,主体可以是服务账号或自然人。本 spec 两处术语表述随之订正为「**S = Self · 自身身份**」;**AC 与范围一字未改**,仅消除会让实现方写错假设的术语歧义。对外文档一律用中文名「自身身份」/「代表他人」,不用字母代号(伴生附录 F)。 + +**优先级**: P1(伴生 PRD P1 阶段;估算 12–14 人天,release-contract 表 3) +**所属版本**: v3.0.0 +**依赖**: **F049**(凭据底座与权限位清单——本 Feature 追加 `delegate` 位与委托范围属性、不改底座语义(release-contract 表 1);服务账号主体与资源归属人;全端点同一凭据校验路径与端点标记;开放 API 审计事件与结构化调用日志的既有形态);**F052**(统一检索门面——伴生 AC-12「模式 D 检索集合相等」的能力由门面交付、本 Feature 承接验收;批次 A 顺序 F049 → F052 → F051 → F053 → F050,Discovery N3)。**F049 不得脱离本 Feature 单独对外发版**(裸 `user_id` 参数须随 v3.0.0 一并移除,伴生 §4.9 / F049 spec 排除项)。**被依赖**:F058(日常模式会话 Responses 契约的模式 D 是其主场,伴生 §4.6.3;F058 消费本 Feature 的模式分流、外部标识分区键与审计口径);F051 / F055(审计双归属 subject 列的口径由本 Feature 定义、二者各自写入时遵守);F051 / F052 / F053(三面对持 `delegate` 密钥的入口拒绝以本 Feature 的 `delegate` 位为判据) +**批次**: A(开放能力层);**不在 MVP-114 纵切上**([../mvp-114-path.md](../mvp-114-path.md) §2:表单问卷不走委托) + +> **范围边界** +> +> 本 Feature 是 F049 的 **P1 续篇**:F049 关闭匿名通道并把身份定死为「密钥主体本身」;本 Feature 在同一条鉴权路径上打开**唯一一条**受控的身份传递通道——**模式 D 受限委托**,并把 F049 期一律 403 的两个身份头(`X-Bisheng-On-Behalf-Of` / `X-Bisheng-End-User`)赋予正式语义。范围 = F049 spec 排除项中标「随 F050」的全部内容 ∪ release-contract 表 3 F050 行,逐条如下。 +> +> - **本次纳入**: +> - **两种身份模式与请求处理流程**(伴生 §4.3):凭据 → 权限位 → 有无 `X-Bisheng-On-Behalf-Of` → 模式分流 → 执行;**模式 S ·「自身身份」**(默认,以密钥主体自己的身份)与**模式 D ·「代表他人」**(代表平台用户),判据 = 权限基准是谁;一把密钥的身份模式在签发时即确定(持 `delegate` 即模式 D 专用);F049 AC-33「任何身份头一律 403」由本 Feature 的正式语义**取代** +> - **外部用户标识头 `X-Bisheng-End-User`**(伴生 §4.3.4,**不是身份模式**):只决定会话分区键与审计留痕、不改任何权限判定;分区键本版**只写不读**;与 `X-Bisheng-On-Behalf-Of` 并存 → 400;缺省行为(有会话语义端点未传 → 按密钥归属 + WARN 不阻断);不作配额维度 +> - **`delegate` 权限位与委托配置区**(伴生 §4.2.3 / §4.2.4 / §4.7.3;PRD-1 DEV-01):`delegate` 独立成位、不被任何位蕴含、只可绑定服务账号主体;勾选后委托范围**必填**、两类条目 `user` / `department` 各自多选可混用、无「任意人」档;范围为空保存禁用;**与三扩展位(`model:invoke` / `identity:read` / `app:manage`)互斥硬阻断**——签发与编辑同一校验、双向、不得靠「先签发后编辑」绕过(**INV-31 的互斥校验落点归本 Feature**);编辑委托配置即时生效不轮换密钥;密钥列表委托范围摘要 +> - **受限委托五道准入**(伴生 §4.4.4):检查 1 持 `delegate`(`26004`)/ 检查 2 目标是自然人、存在、未禁用、同租户(`26005`,四种情形响应无差异,**自然人判定在调用期**)/ 检查 3 禁以超级管理员、租户管理员为目标(`26007`)/ 检查 4 目标在委托范围内(`26004`;`department` 子树调用期动态展开)/ 检查 5 端点在委托支持列表内(`26006`);保存期 `user` 条目校验自然人(早报错,伴生 AC-37) +> - **铁律 2 完整形态**(伴生 §4.4.2):持 `delegate` 的密钥漏传 `X-Bisheng-On-Behalf-Of` → 400 `26016`,不返回任何业务数据、不静默落回模式 S;F049 AC-35 的 P0 形态由本条补全 +> - **纯替换语义**(伴生 §4.3.3 / §7.2 D2):模式 D 权限基准整体替换为被代表用户,服务账号自身授权不参与判定;检索结果与被代表用户平台内自行检索**集合相等**(伴生 AC-12,经 F052 门面);两层判定中的密钥权限位层在模式 D 下**照常生效** +> - **审计双归属**(伴生 §4.4.5 / §4.8.1 R7):开放 API **逐调用审计记录**(时间、密钥、主体、身份模式、被代表用户、外部标识、端点、来源 IP、结果状态、耗时;含被拒调用),模式 D 记 actor = 密钥主体、subject = 被代表用户;**引入 subject 列的口径**(F051 模型调用记录、F055 运行期能力调用记录复用同一口径);F049 期的结构化调用日志升级为审计记录 +> - **定义 6 优先级**(伴生 §4.5 定义 6「与模式 D 的关系」):模式 D 下创建的资源归属**被代表用户**而非资源归属人(伴生 AC-46);模式 D 下的会话归属被代表用户 +> - **裸 `user_id` 参数收口与移除**(伴生 §4.9 / AC-26 / 附录 B.1):附录 B.1 标「参数收口」与「死参数」的全部端点——`POST /filelib/retrieve`、`GET /filelib/`、`GET /filelib/file/list`、`POST /filelib/add_qa`(四处真实读写路径)、`GET /assistant/list`(只写日志的死参数)、`POST /filelib/add_relative_qa`(声明了却从不读取的死参数)——升级后携带该参数返回参数错误并指向 `X-Bisheng-On-Behalf-Of`;F049 期保留的 `resolve_operator` 代用户路径随本 Feature 移除,平台从此**不存在任何经参数指定执行身份的路径**(`POST /chat/sync/messages` 已随 F049 AC-30 不暴露) +> - **管理界面增量**(伴生 §4.7.1 / §4.7.3 / §4.7.4):签发 / 编辑表单「委托配置」分组(风险提示 + 范围选择 + 互斥提示)、服务账号列表「委托」列、密钥列表「委托范围摘要」列、资源授权页「名下存在 `delegate` 密钥」显著提示 +> - **端点允许模式落地**(伴生附录 B.1「允许模式」列):每个开放 API 端点声明 S 或 S/D,模式 D 打到仅 S 端点 → 检查 5 拒绝;两个 WebSocket 端点在握手期完成模式判定与准入 +> - **与应用工场的分界**(PRD-1 DEV-02 ④ / DEV-05 ① / GOV-05;INV-31):模式 D **只作用于 V2 开放 API 面、只对服务账号密钥成立**;`delegate` 不得出现在托管应用运行期凭据上;托管应用运行期与本地开发三面均不承载委托,托管运行期的访问用户身份以平台注入为唯一可信来源(INV-32),不得复用委托头管道 +> - **本次明确排除**: +> - **F049 已交付的 P0 全部**(凭据底座、服务账号主体与资源归属人、全端点接入、管理界面主体、主体侧授权页、分享链接通道、升级零迁移)——本 Feature 只在其上追加,不改其语义 +> - **日常模式会话 Responses 契约**(伴生 §4.6.3;`POST /responses` 等端点、`chat:invoke` 位、伴生 AC-32 / AC-34~36)——F058;本 Feature 只保证这些端点上线时可直接套用同一模式分流、分区键与审计口径 +> - **统一检索门面本身**(文件级过滤 + fail-closed,伴生 §4.4.6)——F052 拥有;本 Feature 依赖并承接伴生 AC-12 的验收,不实现过滤 +> - **三面对持 `delegate` 密钥的运行期入口拒绝**(伴生 AC-49)——F051(模型面)/ F052(MCP 面)/ F053(CLI `login`);本 Feature 拥有签发侧互斥(伴生 AC-48)与 `delegate` 位定义 +> - **限流、配额、IP 白名单、幂等性**(伴生 R8,P2)——含「外部标识不作配额维度」的反面:本 Feature 不实现任何配额 +> - **完整 OAuth 2.0 授权服务器、最终用户令牌透传、即时建号(模式 D 目标必须预先存在)、`/api/v1` 鉴权改造、按端点的细粒度权限、Webhook 回调鉴权**(伴生 §六 非目标,原样继承) +> - **托管应用运行期的访问用户身份注入(OBO)**——F054 app-proxy / F055 注入契约 / F057 SDK auth;它与模式 D 是**两套信任机制**(PRD-1 GOV-05 信任机制分界),本 Feature 只立「不得混用」的边界 +> - **审计查询面的托管应用维度扩展**(对象应用筛选、导出)——F056;本 Feature 的开放 API 逐调用审计在平台既有审计查询面可查即可 +> - **平台侧任何存量迁移**(伴生 §4.9 / D1):裸 `user_id` 的移除是破坏性变更,靠发布说明与升级前沟通承接(F049 AC-50 三步指引第 3 步),不写兼容层、不设过渡期 +> - **已否决、防回流**(伴生 §七 D2 / D8):权限取交集;「委托模式」枚举;「强制委托」独立开关(`delegate` 即强制);`any` / `tenant_wide` / `roles` / `user_group` 范围类型;密钥级资源白名单(D3);用户名形态的 `X-Bisheng-On-Behalf-Of` 取值(本版只接受平台 user_id,§4 决议-1) +> +> **对 F049 spec 的替代 / 补全关系**(接手时按此更新心智,不重复登记):F049 AC-14(不提供 `delegate` 位)→ 由本 Feature §2.3 取代;F049 AC-33(任何身份头一律 403)→ 由 §2.1 / §2.2 取代;F049 AC-35 的 P0 形态 → 由 AC-30 补全为铁律 2 完整形态;F049 AC-39(裸 `user_id` 目标状态校验)→ 随 §2.7 移除该路径而作废;F049 AC-42「委托」列 / AC-44「委托范围摘要与委托配置区」的占位 → 由 §2.8 落地。F049 其余 AC 不受影响。 + +--- + +## 1. 用户故事 + +作为 **自建业务系统的集成方(如集团 OA、CRM)**, +我希望 **拿一把管理员配好委托范围的密钥,在请求里声明「这次是替张三问的」,就能让张三在我们系统里搜到的知识库和他在 BiSheng 里搜到的一模一样、创建的资源直接归张三**, +以便 **把 BiSheng 的能力嵌进员工每天在用的系统里,而不必让员工切到 BiSheng 平台,也不必自己维护一份权限镜像**。 + +作为 **被代表的员工(张三)**, +我希望 **集成方替我发起的对话与创建的知识库,回到 BiSheng 工作台就能像我自己做的一样看到、续问、管理;而任何人都无法借集成之名让我"看到"我本无权访问的内容**, +以便 **在自建系统与平台之间无缝切换,同时我的权限边界不因走了 API 而放宽或收窄**。 + +作为 **面向外部访客的应用运营方(客服机器人)**, +我希望 **在每次调用里带上访客的外部标识,让会话按访客分区、审计能区分是哪个访客的对话,而数据边界仍然只由服务账号被授予的知识库决定**, +以便 **既不为访客批量建号,也不因"外部用户"就丢掉权限过滤——爆炸半径始终锁在服务账号的授权范围内**。 + +作为 **租户管理员**, +我希望 **在签发密钥时一眼看清「这把密钥能替谁说话」(部门 / 具体人)、勾了委托就必须配范围且不能同时配本地开发工具包、名下有委托密钥的服务账号在列表和授权页上被显著标出**, +以便 **委托边界由我集中收敛,不会出现"配了却谁都代表不了"或"授权页看着零授权、实际能读全员数据"的误判**。 + +作为 **安全与合规负责人**, +我希望 **每一次开放 API 调用都能同时回答"哪个系统调的"和"以谁的名义调的";委托永远不能指向管理员、不能指向另一个服务账号、不能跨租户;缺了身份就报错而不是给个"安全"的子集;升级后旧的裸 `user_id` 参数彻底失效**, +以便 **委托是有上界的提权、可追责的替换,而不是"请求里写谁就是谁"的匿名冒充**。 + +--- + +## 2. 验收标准 + +> AC 编号在本特性内唯一。与伴生 PRD §五验收标准的对应关系在括号中标注(如 `↔AC-6`),便于双向追溯;伴生 PRD 未单列 AC 的需求以章节号标注(如 `↔§4.3.4`)。 +> 错误码(`26004`–`26007` / `26010` / `26016`,另引用 F049 已启用的 `26003`)在此仅作为可观测的对外行为引用,其定义与归属写在 design.md(模块号 260,release-contract 已登记「委托类错误码的实际启用随 F050」)。 +> 术语:**模式 S ·「自身身份」** = 以**密钥主体自己**的身份执行(**S = Self,不是 service account**——主体可以是服务账号,也可以是自然人,见伴生 §4.10 个人访问令牌);**模式 D ·「代表他人」** = 代表平台用户执行;**被代表用户** = `X-Bisheng-On-Behalf-Of` 指向并通过五道准入的自然人;**外部标识** = `X-Bisheng-End-User` 的取值。 + +### 2.1 身份模式与请求处理流程 + +- **AC-01** — THE SYSTEM SHALL 对每一次开放 API 请求按固定顺序处理:凭据校验(F049)→ 密钥权限位判定(F049 AC-04,**所有模式通用、先于模式分流**)→ 依「密钥是否持 `delegate`」与「是否携带 `X-Bisheng-On-Behalf-Of`」分流为模式 S 或模式 D → 执行;**只存在这两种身份模式**,不存在第三种模式或任何"部分身份"的中间态。(↔§4.3 / §4.3.1) +- **AC-02** — WHEN 密钥不持 `delegate` 且请求未携带 `X-Bisheng-On-Behalf-Of`, THE SYSTEM SHALL 以模式 S 执行:权限基准 = 密钥主体、资源归属 = 密钥主体(经资源归属人机制,F049 AC-24)、会话归属 = 密钥主体(F049 AC-25);返回结果的可见范围等于该主体的权限边界。(↔§4.3.2;承接 F049 AC-32) +- **AC-03** — WHEN 密钥持 `delegate` 且请求携带 `X-Bisheng-On-Behalf-Of` 且五道准入全部通过, THE SYSTEM SHALL 以模式 D 执行:执行身份、权限基准、资源归属、会话归属**全部**为被代表用户;密钥权限位层(AC-01 的第二步)在模式 D 下**照常生效**——持 `delegate` 但缺目标端点权限位仍返回 403 `26003`。(↔§4.3.3 / §4.4.1 / §4.3.5) +- **AC-04** — IF 密钥**不持** `delegate` 而请求携带 `X-Bisheng-On-Behalf-Of`, THEN THE SYSTEM SHALL 返回 403 `26004`,不执行任何业务逻辑、不返回任何业务数据、**不静默忽略该头而按模式 S 执行**。(↔AC-6;检查 1) +- **AC-05** — THE SYSTEM SHALL 使一把密钥的身份模式在签发时即确定:持 `delegate` 的密钥只能以模式 D 调用(漏传头见 AC-30)、不持 `delegate` 的密钥只能以模式 S 调用(传头见 AC-04);调用方无法逐次选择模式;需要两种模式的集成须持两把密钥。(↔§4.3.2 / §4.2.4「勾选即强制」/ §7.3 D8) +- **AC-06** — THE SYSTEM SHALL 只接受 `X-Bisheng-On-Behalf-Of` 的取值为**单个平台用户 ID**(被代表对象永远是单个用户);IF 取值为空、非单值或不是该形态, THEN THE SYSTEM SHALL 返回 400 参数错误、不进入准入检查;本版**不接受用户名形态**(§4 决议-1);调用方标识与平台标识不一致时的映射由调用方负责,平台不承担映射。(↔§4.3.3 / §4.4.4 引言 / §3.2) +- **AC-07** — THE SYSTEM SHALL 为每个开放 API 端点声明允许模式——按伴生附录 B.1「允许模式」列(绝大多数 S/D;`GET /filelib/download_statistic`、`POST /llm/workbench/asr` / `/tts` 仅 S;F058 端点上线时按其行声明);**不存在未声明允许模式的可达端点**(与 F049 AC-29 同一条校验路径、同等完整性);WS 两端点与 HTTP 同一套声明。(↔附录 B.1;检查 5) +- **AC-08** — WHEN 两个 WebSocket 端点的握手携带身份传递头(`X-Bisheng-On-Behalf-Of` / `X-Bisheng-End-User`,经握手请求头传递、与密钥传递方式一致), THE SYSTEM SHALL 在**握手期**完成模式判定与五道准入;准入失败即拒绝握手并优雅关闭(不产生未捕获异常,形态同 F049 AC-31);握手通过后整个连接期内的执行身份、会话归属与分区键按握手结果固定。(↔附录 B.1 WS 两行 ⧗ S/D;§4.6.1) +- **AC-09** — IF 以 share-token 建立的 WebSocket 连接(F049 AC-55–58)携带任一身份传递头, THEN THE SYSTEM SHALL 拒绝该连接——身份传递只对服务账号密钥有意义,share-token 会话的执行主体恒为分享创建者(F049 决议-6 i)。(§4 决议-11) +- **AC-10** — THE SYSTEM SHALL 自本 Feature 起以 §2.1 / §2.2 的正式语义处理两个身份传递头,**取代** F049 AC-33「任何身份传递头一律 403 并提示尚未启用」;除本 spec 明文的拒绝情形外,携带身份头不再产生「能力尚未启用」类响应。(↔§4.3;F049 AC-33 替代关系) + +### 2.2 外部用户标识头(非身份模式) + +- **AC-11** — WHEN 模式 S 请求携带任意 `X-Bisheng-End-User`, THE SYSTEM SHALL 使返回结果的**权限范围与不传时完全一致**——权限基准仍为密钥主体,任何权限判定不读取该头;仅会话分区与审计留痕不同。这条同时是「它不是一种身份模式」的验收依据。(↔AC-11 / §4.3.4 语义声明) +- **AC-12** — WHEN 有会话语义的端点(伴生附录 B.1 标 ⧗ 者)在模式 S 下携带 `X-Bisheng-End-User`, THE SYSTEM SHALL 把外部标识作为该会话的分区键写入(**本版只写不读**:不存在任何按外部标识过滤或列举会话的端点)并写入审计留痕;同一密钥、不同外部标识产生的会话互相独立。(↔§4.3.4 / §4.6.3「仍然要写分区键」/ §3.4 表) +- **AC-13** — WHEN 有会话语义的端点在模式 S 下**未**携带 `X-Bisheng-End-User`, THE SYSTEM SHALL 使会话按密钥粒度归属并记录 WARN 级日志;**不阻断、不报错**(前瞻性约束,不是强制项)。(↔§4.3.4 缺省行为;`26014` 已废止不复活) +- **AC-14** — IF 请求同时携带 `X-Bisheng-On-Behalf-Of` 与 `X-Bisheng-End-User`, THEN THE SYSTEM SHALL 返回 400 `26010`,不执行任何业务逻辑(模式 D 下会话已归属被代表用户,外部标识没有语义)。(↔AC-10 / §4.3.4 互斥) +- **AC-15** — THE SYSTEM SHALL 对 `X-Bisheng-End-User` 的取值**不认识、不校验、不解析**——只做形式约束(长度上限、可打印字符,超限 → 400 参数错误),不做任何存在性或语义判断;不以外部标识作为配额 / 限流 / 计量维度(一律按密钥计);对外 API 文档须**原样写入**伴生 §4.3.4 的语义声明措辞。(↔§4.3.4「语义声明」「配额归属」;§4 决议-6) +- **AC-16** — WHEN 无会话语义的端点在模式 S 下携带 `X-Bisheng-End-User`, THE SYSTEM SHALL 接受该请求、只把外部标识写入审计留痕,不产生任何其它效果、不报错。(§4 决议-6) + +### 2.3 `delegate` 权限位与委托配置区 + +- **AC-17** — THE SYSTEM SHALL 在权限位清单中提供 `delegate` 位(代表平台用户执行):独立成位、不被任何其它权限位蕴含、也不蕴含任何其它位(持 `delegate` 而无 `knowledge:read` 仍读不了知识库,AC-03);**只可绑定服务账号主体的密钥**——为托管应用运行期凭据或任何非服务账号主体签发 / 编辑入 `delegate` 一律拒绝;`delegate` 位**不受开放能力层部署开关影响**(委托属开放 API 面本身,标准版同样提供)。F049 AC-14「本期不提供 `delegate` 位」自此作废。(↔§4.2.4;PRD-1 GOV-05 信任机制分界;F049 AC-49 同向) +- **AC-18** — WHEN 管理员在签发或编辑表单勾选 `delegate`, THE SYSTEM SHALL 展开「委托配置」分组:可代表范围**必填**,由 `user`(所列每个用户本人)与 `department`(所列部门及其子树全部成员)两类条目组成、各自可多选、两类可混用;**不提供**「代表任意人 / 全租户」这一档(填根部门即为全员);分组内须显著风险提示「开启委托意味着这把密钥能以他人身份读取数据,且每次调用都必须声明被代表用户(漏传即 400);需要以服务账号身份调用请另签密钥」。(↔§4.7.3 委托配置行 / §4.4.4 检查 4 / §7.3 D8) +- **AC-19** — WHILE `delegate` 已勾选且可代表范围为空, THE SYSTEM SHALL 禁用保存并说明原因;THE SYSTEM SHALL 在**接口层同样拒绝**「持 `delegate` 但范围为空」的签发 / 编辑请求(绕过界面直接调接口亦不能保存)。(↔AC-39 / §4.4.4「范围不可为空」) +- **AC-20** — IF 签发或编辑请求中 `delegate` 与「本地开发工具包」三扩展位(`model:invoke` / `identity:read` / `app:manage`)中的任一位同时出现, THEN THE SYSTEM SHALL 拒绝保存(**硬阻断,非软提示**)并给出可读提示「配了委托的密钥不能用于本地开发,请另签一把」;该校验**签发与编辑同一套、双向生效**——已持三扩展位的密钥不能编辑加入 `delegate`,已持 `delegate` 的密钥不能编辑加入任一扩展位——不得靠「先签发后编辑」绕过;界面与接口层同一校验。(↔AC-48 / §4.2.4「签发期硬阻断」/ §4.7.3 编辑;PRD-1 DEV-01 验收 10;**INV-31 互斥校验落点**) +- **AC-21** — WHEN 保存委托范围的 `user` 条目, THE SYSTEM SHALL 校验目标为**本租户、存在、未禁用的自然人**,任一不满足即拒绝保存并提示(含绕过选人框直接调接口的路径——服务账号的 user_id 同样被拒);WHEN 保存 `department` 条目, THE SYSTEM SHALL 校验目标为本租户存在的部门。这是**早报错**,安全防线是调用期的 AC-25。(↔AC-37 / §4.4.4 / 附录 E.2「自然人校验两个落点」) +- **AC-22** — WHEN 管理员编辑既有密钥的委托配置(勾选 / 取消 `delegate`、增删范围条目), THE SYSTEM SHALL 使原密钥**立即**按新配置生效、不要求轮换密钥(撤销与失效上界沿用 F049 AC-08 语义);WHEN 取消 `delegate`, THE SYSTEM SHALL 一并清除该密钥的委托范围(不留「配好了范围但没勾 `delegate`」的死配置,§4 决议-5);上述变更计入密钥审计事件(F049 AC-12 同一事件流,记变更前后值)。(↔AC-21 类推 / §4.7.3 编辑与撤销 / §7.3 D8) +- **AC-23** — THE SYSTEM SHALL 在「API 密钥」tab 的密钥列表中为每把密钥展示**委托范围摘要**(如「销售部及下级 + 2 人」;不持 `delegate` 显示为无),并可查看完整条目清单。(↔§4.7.3 密钥列表;F049 AC-44 占位) + +### 2.4 受限委托五道准入 + +> 五道检查依次回答**谁可以委托、可以代表谁、这次调用允不允许委托**;全过后执行身份**纯替换**为被代表用户,此后逐资源鉴权与平台内该用户本人操作完全一致。检查 1 见 AC-04,检查 5 见 AC-07 / AC-28。 + +- **AC-24** — IF 委托目标**不存在、已禁用、与密钥主体不同租户、或不是自然人**(服务账号——含密钥自身所属的服务账号), THEN THE SYSTEM SHALL 返回 403 `26005`,且**四种情形的响应完全一致**(状态码、错误码、消息、响应体形状与时序特征均无差异),不泄露目标是否存在或属何类型。(↔AC-8 / AC-41;检查 2) +- **AC-25** — THE SYSTEM SHALL 在**每次调用期**判定委托目标是自然人,不得只依赖保存期校验;须覆盖「委托范围配的是部门、而某服务账号恰在该部门子树内」这一用例——该服务账号作为目标同样返回 AC-24 的响应;该判定不依赖「服务账号永远没有部门」这一并非代码保证的假设。(↔AC-41 / §4.4.4「检查 2 为什么要判自然人」/ 附录 E.2 两条自动路径) +- **AC-26** — IF 委托目标是**超级管理员或当前租户的租户管理员**, THEN THE SYSTEM SHALL 返回 403 `26007`(与 `26005` 可区分——目标合法存在只是被禁止);判定基准 = 平台权限运行时的「系统级无条件放行」谓词(超级管理员 ∪ 当前租户的租户管理员),**不是**旧的 RBAC 管理员角色位;该判定结果须与被代表用户本人登录时权限运行时对其的判定一致,不因身份构造路径不同而漏判;默认(Root)租户不存在租户管理员这一档,该租户下本检查退化为只判超级管理员。部门管理员等有界身份**不在**禁令内。(↔AC-7;检查 3;附录 E.5;Discovery §2.2 风险 ①②) +- **AC-27** — WHEN 委托目标是自然人且非特权主体, THE SYSTEM SHALL 判定其是否落在该密钥的委托范围内:`user` 条目按用户本人匹配;`department` 条目按**调用时**该部门及其子树的成员关系动态展开——成员被移入 / 移出、子部门增删**无需重新保存密钥即时生效**;范围外 → 403 `26004`。IF 密钥持 `delegate` 但当前有效范围为空(保存路径不允许为空,但条目所指部门 / 用户事后被删除可致此), THEN THE SYSTEM SHALL 对任何目标返回 403 `26004`。(↔AC-9 / §4.4.4 检查 4) +- **AC-28** — IF 模式 D 请求打到允许模式为「仅 S」的端点(AC-07), THEN THE SYSTEM SHALL 返回 403 `26006`,不执行业务逻辑。(↔检查 5 / 附录 B.1) +- **AC-29** — THE SYSTEM SHALL 使五道准入是**一次性准入判定**:任一检查失败即整体拒绝——不执行任何业务逻辑、不返回任何业务数据、**不降级为模式 S**;全部通过后不再逐资源重复准入,后续逐资源鉴权与平台内该用户本人操作同一条路径、无额外开销;IF 准入所需的任一依赖(用户 / 组织 / 权限运行时 / 缓存)不可用或判定不了, THEN THE SYSTEM SHALL 返回 5xx 并拒绝,**绝不放行**(铁律 3 在准入期的形态;INV-30)。(↔§4.4.4 引言 / §4.4.2 铁律 3 / AC-13) + +### 2.5 铁律 2 完整形态与纯替换 + +- **AC-30** — IF 密钥持 `delegate` 而请求未携带 `X-Bisheng-On-Behalf-Of`, THEN THE SYSTEM SHALL 返回 400 `26016`,**不返回任何业务数据**——既不返回全量、也不返回公开子集、也不静默落到模式 S 按服务账号权限执行;不存在任何"缺身份即降级返回某个子集"的路径。本条补全 F049 AC-35 的 P0 形态为铁律 2 完整形态。(↔AC-38 / §4.4.2 铁律 2「为什么是报错不是降级」/ §7.3) +- **AC-31** — WHILE 模式 D 执行, THE SYSTEM SHALL 使权限基准**整体替换**为被代表用户:服务账号自身在资源授权页的授权(F049 §2.7)**完全不参与判定**——服务账号零授权、被代表用户可读的资源仍可读;反之被代表用户无权、服务账号有权的资源**不可读**(不取交集、不取并集)。(↔§7.2 D2 / §4.7.4「委托生效时本页授权不参与判定」) +- **AC-32** — WHEN 模式 D 下以张三身份调用检索端点(`POST /filelib/retrieve`,经 F052 统一检索门面), THE SYSTEM SHALL 使返回结果与张三在平台内自行检索的结果**集合相等**——无意外收窄、无超集(不出现张三无权访问的文件的正文片段或文件名);本条的过滤能力由 F052 交付(F052 AC-25 / AC-43),本 Feature 承接验收;F052 门面未上线前模式 D **不得**在检索端点放行(伴生 §4.4.6「模式 D 的交付前提」)。(↔AC-12 / §4.4.6;Discovery N3) +- **AC-33** — WHEN 模式 D 下调用写路径端点(如 `POST /filelib/` 建知识库、`POST /filelib/add_qa` 写问答对、`POST /workflow/invoke` 触发工作流), THE SYSTEM SHALL 以被代表用户为执行身份完成权限校验与执行——「以某人名义创建 / 写入」即模式 D,其可为与不可为与该用户本人在平台内操作一致;模式 S 下的资源归属人机制(F049 AC-24)在模式 D 下**不介入**。(↔附录 B.1 `POST /filelib/` 行「以某人名义创建即模式 D」/ `add_qa` 行) +- **AC-34** — THE SYSTEM SHALL 使模式 D 只在 **V2 开放 API 面**上、只对**服务账号密钥**成立:托管应用运行期凭据(F055 签发)不得持 `delegate`(AC-17),其调用携带任一身份传递头一律拒绝;托管运行期的访问用户身份以平台注入为唯一可信来源(INV-32),不复用委托头管道(复用会让检查 3 把管理员挡在所有应用之外);本地开发三面(MCP / 模型协议 / CLI)不承载委托,其对持 `delegate` 密钥的入口拒绝分别由 F052 AC-28 / F051 AC-26 / F053 AC-09 交付,本 Feature 只提供 `delegate` 位判据与签发侧互斥(AC-20)。**应用工场全册无模式 D 场景。**(↔PRD-1 DEV-02 ④ / DEV-05 ① / GOV-05 信任机制分界;INV-31 / INV-32) + +### 2.6 审计双归属与归属优先级 + +- **AC-35** — THE SYSTEM SHALL 为每一次开放 API 调用记录审计:时间、密钥(掩码 / 标识)、密钥主体(服务账号)、身份模式(S / D)、被代表用户(模式 D)、外部标识(模式 S 且传了 `X-Bisheng-End-User` 时)、端点、来源 IP、结果状态、耗时;**被拒绝的调用同样记录**(含五道准入失败、`26010` / `26016`,记录尝试指向的目标标识与失败原因码,供追责与探测检测);记录中不出现密钥明文;F049 期仅落结构化日志的开放 API 逐调用记录自本 Feature 起升级为审计记录,在平台既有审计查询面对租户管理员可查(限本租户;平台超管可跨租户)。(↔§4.8.1 R7 / §4.4.3 信任来源 3;F049 AC-12 同口径) +- **AC-36** — WHEN 模式 D 调用被记录, THE SYSTEM SHALL 使审计记录同时含 **actor**(密钥主体 + 密钥标识,回答"哪个系统调的")与 **subject**(被代表用户,回答"以谁的名义调的"),可据此还原完整调用链;subject 列只在模式 D 填被代表用户,模式 S 为空(外部标识另列,不写入 subject)。本 Feature 定义 subject 列的**口径**(actor = 执行凭据的主体,subject = 以谁的名义),F051 模型调用记录(F051 AC-21)与 F055 运行期能力调用记录(F055 AC-55)各自写入时遵守同一口径。(↔AC-23 / §4.4.5;PRD-1 GOV-04 双归属;§4 决议-7) +- **AC-37** — WHEN 模式 D 下经开放 API 创建资源(知识库、问答对等), THE SYSTEM SHALL 把资源归属落到**被代表用户**名下——如同其本人在平台内所建、可见可管——**而非**该服务账号的资源归属人(一次调用两者并存以模式 D 为准:委托是显式的、逐次声明的,静态字段不该盖过它);模式 D 下**不**向服务账号自动回授该资源(回授只是模式 S 下"服务账号不能登录、创建关系落他人"的补偿,模式 D 不存在该缺口,§4 决议-9)。(↔AC-46 / §4.5 定义 6「与模式 D 的关系」) +- **AC-38** — WHEN 模式 D 下产生会话(`POST /workflow/invoke`、WS 两端点、`POST /assistant/chat/completions`;F058 端点上线时同理), THE SYSTEM SHALL 使会话归属**被代表用户**、按被代表用户分区,**不归服务账号**(与 F049 AC-25 模式 S 相反——定义 6 边界 a「会话不算资源」只约束模式 S,§4 决议-8);该会话在平台内的可见性与被代表用户本人发起的同类会话一致(工作台会话列表可见与续问的端到端验收随 F058 伴生 AC-32)。(↔§3.4 表「会话分区 · 按被代表用户」/ §4.6.3 身份与会话隔离表) +- **AC-39** — THE SYSTEM SHALL 使模式 D 下的审计 subject、资源归属与会话归属**三者指向同一个被代表用户**(不存在"归属张三、审计记李四"的分叉);被代表用户事后被禁用 / 删除不改写既有审计与归属记录。(↔§4.4.4「委托生效后的执行身份、资源归属与审计 subject 全都是那一个具体的人」) + +### 2.7 裸 `user_id` 参数收口与移除 + +- **AC-40** — WHEN 升级后调用方在请求参数中携带裸 `user_id`(伴生附录 B.1 标「参数收口」的 `POST /filelib/retrieve`、`GET /filelib/`、`GET /filelib/file/list`、`POST /filelib/add_qa` 与标「死参数」的 `GET /assistant/list`), THE SYSTEM SHALL 返回 400 参数错误、**不执行业务逻辑**,且错误信息明确指向改用 `X-Bisheng-On-Behalf-Of` 请求头;不保留任何过渡期或兼容开关。(↔AC-26 / §4.9 三步指引第 3 步「原先在参数里传 `user_id` 的调用改为请求头」) +- **AC-41** — THE SYSTEM SHALL 移除 `POST /filelib/add_relative_qa` 声明了却从不读取的 `user_id` 死参数,使对外契约与实现一致(此前调用方以为能指定归属、实际被静默忽略);升级后携带该参数同样按 AC-40 处理。(↔附录 B.1 `add_relative_qa` 行;F049 排除项) +- **AC-42** — THE SYSTEM SHALL NOT 在开放 API 上保留任何「经请求参数指定执行身份」的路径:F049 期保留的代用户解析路径随本 Feature 移除,`X-Bisheng-On-Behalf-Of` + 五道准入是代表他人执行的**唯一**通道;F049 AC-35「无第三种路径」自 F049 + F050 合并发版起无残余成立。(↔§4.9「裸 `user_id` 参数随本版本一并移除」/ §2.2 F4;F049 排除项) +- **AC-43** — THE SYSTEM SHALL 在发布说明的升级前必读章节(F049 AC-50 同一章节)中写明:裸 `user_id` 参数移除是**破坏性变更**、涉及的端点清单(AC-40 / AC-41)、改用 `X-Bisheng-On-Behalf-Of` 须先为密钥勾选 `delegate` 并配置委托范围(三步指引第 2 步「若集成需要代表平台用户,另配委托范围」)、以及模式 D 的对外语义边界(委托让集成方按指定用户的权限边界执行并留痕,**不能证明**是该用户本人在操作;提权有上界而非恒为降权,伴生附录 F 措辞红线)。(↔§4.9 / 附录 F) + +### 2.8 管理界面增量 + +- **AC-44** — THE SYSTEM SHALL 在服务账号列表中展示「委托」列:名下是否有有效密钥持 `delegate`,并摘要显示可代表范围(如「销售部及下级」);无委托密钥时显示为无。F049 AC-42 的占位由本条落地。(↔§4.7.1 列表) +- **AC-45** — WHILE 服务账号名下存在持 `delegate` 的有效密钥, THE SYSTEM SHALL 在其详情页「资源授权」tab 以区别于普通说明的显著样式常驻提示:委托生效时本页授权不参与判定——持委托的密钥即使本页零授权,仍可读到被代表用户能读的全部内容;本页授权只约束模式 S 调用(不持 `delegate` 的密钥)。名下无委托密钥时不显示。(↔§4.7.4「委托生效时本页授权不参与判定」;F049 AC-64 同页) +- **AC-46** — WHEN 管理员在签发或编辑表单勾选 `delegate`, THE SYSTEM SHALL 使「本地开发工具包」分组(WHERE 开放能力层已部署时可见,F049 AC-13)呈现为不可与之同时保存的状态并就地提示互斥原因(AC-20 的界面侧);反向勾选任一扩展位时对 `delegate` 同理;提示文案与 AC-20 一致。(↔§4.7.3 / PRD-1 DEV-01 表单层) +- **AC-47** — WHEN 管理员在密钥列表或详情查看某把持 `delegate` 的密钥, THE SYSTEM SHALL 展示其完整委托范围条目(`user` 条目显示用户名、`department` 条目显示部门名并标注「含子树」),并对已失效的条目(所指用户 / 部门已不存在)给出标注。(↔§4.7.3;AC-27 空范围情形的可见性) + +### 2.9 错误码启用与文档 + +- **AC-48** — THE SYSTEM SHALL 自本 Feature 起启用委托类错误码 `26004`(未授予委托 / 目标不在范围内)、`26005`(委托目标无效,四情形无差异)、`26006`(端点不支持代表模式)、`26007`(目标为特权主体)、`26010`(身份头冲突)、`26016`(持 `delegate` 漏传头),并把 F049 期借用 `26004` 表达的「身份传递能力尚未启用」语义**收窄**为伴生附录 C 定义;错误码文案随 i18n 三语同 PR 交付;对外接口文档同步登记两个身份头、模式 D 的准入规则与错误码含义。(↔附录 C;release-contract 模块编码表「委托类错误码的实际启用随 F050」;§4 决议-4) + +--- + +## 3. 边界情况 + +> 只写期望的对外行为;实现层的落点、缓存与数据访问细节见 design.md §5。 + +- **`department` 范围是"圈候选人的方式",不是"以部门身份执行"**:被代表对象永远是单个自然人;范围条目在**调用期**动态展开为当时的成员集合,组织架构变动即时反映到准入结果(AC-27),管理员不必回头重存密钥。填根部门 = 全员,这是场景二的常规配法(伴生 §7.3)。 +- **服务账号可能被组织侧自动兜底进「临时访客」部门**(伴生附录 E.2 两条自动路径;该部门挂在根部门之下):本 Feature **不修改**这些真人场景的兜底逻辑,安全不变量靠调用期的自然人判定(AC-25)守住,**不依赖**「服务账号永远没有部门」这一并非代码保证的假设。保存期校验(AC-21)只是早报错,够不到 `department` 条目在调用期才展开的成员。 +- **检查 3 的判定基准必须与权限运行时一致**:平台内身份构造存在多条路径且对「是否超级管理员」的计算不对称(Discovery §2.2 / baseline-recheck §二);本 Feature 要求被代表用户身份的构造与检查 3 的判定都以权限运行时的「系统级放行」谓词为准(AC-26)——判漏会让一把窄权限密钥换到管理员的无界权限,是全 spec 最重的单点。默认租户下没有租户管理员这一档,测试用例须按租户分别设计(伴生附录 E.5)。 +- **`26005` 的无差异不只是同一个码**:不存在 / 已禁用 / 跨租户 / 非自然人四种情形的响应体形状、消息与可观测时序特征都不得成为探测哪些 user_id 是服务账号或属于哪个租户的旁路(AC-24);而 `26007` 是**刻意可区分**的(目标合法只是被禁止),两者不能混同。 +- **委托 = 「提权有上界」而非「恒为降权」**(伴生 §7.2 代价):模式 D 下调用方最多获得委托范围内某个真实自然人的真实权限,永远够不到无条件放行;服务账号自身零授权也不妨碍模式 D 读全员可读的内容(AC-31)——这是设计意图,授权页的显著提示(AC-45)就是为防它被误读成"本页授权是全局上限"。对外措辞按伴生附录 F 红线。 +- **模式 D 与资源归属人两套归属机制并存时以模式 D 为准**(AC-37):定义 6 是模式 S 下"服务账号不能登录、创建关系落他人"的补偿;模式 D 的被代表用户本人就是自然人,创建关系直接落他,也不再回授服务账号——否则一把委托密钥就能给服务账号攒出一批跨越全员的资源写权限,正是 PRD-1 DEV-02 ④ 指出的爆炸半径放大。 +- **模式 D 会话归被代表用户,模式 S 会话归服务账号**(AC-38 vs F049 AC-25):定义 6 边界 a「会话不算资源」只约束模式 S(防止资源归属人在工作台看到全部外部访客对话);模式 D 下会话回到员工工作台正是场景二的完整形态(伴生 §4.6.3),两者不矛盾。 +- **外部标识分区键"只写不读"**:本版没有任何端点按 `X-Bisheng-End-User` 过滤或列举会话(会话列表 / 历史查询已于 2026-08-16 移出范围),串档的成立条件不齐;写分区键是前瞻性设计,让存量会话从一开始带上终端用户维度(AC-12)。F049 决议-6 i 已知代价——share-token 会话落在分享创建者会话列表——不由本 Feature 修复,本 Feature 只保证分区键机制存在(§4 决议-11)。 +- **两个身份头在 WebSocket 上经握手请求头传递**(AC-08),与密钥一致、不走查询参数;握手期完成准入后,连接期内身份不可变。share-token 通道不承载身份头(AC-09)。 +- **审计双归属对既有审计流的影响**:本 Feature 把 F049 期的结构化调用日志升级为审计记录并引入 subject 列的**口径**(AC-35 / AC-36);F049 密钥事件、授权页事件、share-token 建连事件维持原形态;F051 / F055 各自的调用记录复用口径而非合并成同一张记录(是否同表属 design)。审计量级随开放 API 调用量线性增长,承压方案见 design。 +- **裸 `user_id` 移除是破坏性变更**(AC-40–43):随发布说明交付,不设过渡期;F049 + F050 必须合并发版——否则 F049 单独上线时开放 API 仍存在一条经参数指定执行身份的残余路径(F049 AC-35 已知残余)。 +- **F052 门面是模式 D 检索的交付前提**(AC-32):门面未就绪时模式 D 对检索端点返回被代表用户可见集的真超集,违背「不多不少」;因此批次 A 顺序 F052 早于 F050,模式 D 在检索端点的放行以门面上线为前提。 +- **准入检查与凭据校验同取 fail-closed**(AC-29):任何依赖不可用即拒绝,与 F049 §3「凭据校验取 fail-closed」同向、与平台会话续期的刻意宽松取向相反,这一差异是刻意的。 +- **不存在「委托能力尚未启用」的中间形态**:F049 期任何身份头一律 403,本 Feature 上线后两个头有正式语义(AC-10);不设特性开关让委托"存在但未启用"——那会复活 D8 否决的「勾了但没生效」死配置形状。 + +--- + +## 4. 待澄清决议 + +> ★ 决议 1–12 于 2026-08-17 由用户授权「全部按建议拍板」定案并在此留痕(全自动模式);如后续伴生 PRD 修订推翻,以各条末尾「状态」为准,原文保留供追溯。 + +- **[决议-1] `X-Bisheng-On-Behalf-Of` 头值形态 = 单个平台用户 ID;本版不接受用户名。**(AC-06) + 伴生 §4.4.4 引言写「用户 ID 或用户名」、§3.2 写「换成用户名亦可」。建议只接受用户 ID:① 用户名可改名、跨租户可重名,按名解析会引入一层"名 → 人"的歧义与存在性探测面(与 `26005` 无差异要求相悖);② 伴生 §4.9 三步指引本就是「原先在参数里传 `user_id` 的调用改为请求头」,存量集成手里已有 user_id;③ 场景二的前提是「用户 ID 全域一致」,映射本就由调用方负责。备选 = 同时接受用户名,代价见 ①。**已定:只接受用户 ID**;建议回写伴生 PRD §4.4.4 / §3.2 措辞(本 spec 不动 PRD)。 + +- **[决议-2] 委托范围校验的落点两处:保存期早报错 + 调用期安全防线,两处都做、语义分明。**(AC-21 / AC-25) + 伴生附录 E.2 已定两处;本 spec 落 AC 时明确:保存期失败 = 拒绝保存并提示(含绕过界面直接调接口),调用期失败 = `26005` 无差异响应;保存期校验的存在**不得**被当作调用期可省略的理由(`department` 条目在调用期才展开)。**已定。** + +- **[决议-3] `26005` 四情形无差异 + `26007` 刻意可区分。**(AC-24 / AC-26) + 伴生附录 C 已定;本 spec 补「响应体形状与时序特征亦无差异」——只同一个码不够挡探测。**已定。** + +- **[决议-4] 错误码只作引用;`26004` 语义自本 Feature 起收窄为伴生附录 C 定义。**(AC-48) + F049 期借 `26004` 表达「身份传递能力尚未启用」(F049 design 决议);本 Feature 上线后该语义消失,`26004` = 未授予委托 / 不在范围。定义与归属在 design(模块号 260,落码时按宪法 C5 回写登记表)。**已定。** + +- **[决议-5] 取消 `delegate` 时委托范围一并清除;不允许「范围非空但未勾 `delegate`」的持久化状态。**(AC-22) + 理由 = 伴生 §7.3 否决「委托模式」枚举的同一理由:不留任何一种"开了但没配 / 配了但没开"的死配置。备选 = 保留范围以便重新勾选时复用——代价是列表与授权页提示要多解释一种"范围存在但不生效"的状态。**已定:清除。** + +- **[决议-6] `X-Bisheng-End-User` 只做形式约束、不做语义校验;无会话语义端点携带它 = 接受并只留审计。**(AC-15 / AC-16) + 伴生 §4.3.4 定「不认识、不校验、不解析」;本 spec 补两条:① 请求头必有形式上限(长度、可打印字符),超限 400,这不是语义校验;② 伴生只写了有会话语义端点的行为,无会话语义端点收到它不应报错(对齐 Dify / Coze 把它当普通参数的口径),只留审计。**已定。** + +- **[决议-7] subject 列引入方式 = 本 Feature 定义口径(actor = 执行凭据的主体 / subject = 以谁的名义),F051 / F055 各自的记录遵守同一口径。**(AC-36) + release-contract 变更历史与 Discovery §2.7 已定「F050 引入 subject 列,F055 运行期复用」;F051 spec AC-21 / F055 spec AC-55 已按此写。本 spec 只立口径(What),是否同一张表 / 同一字段名属 design;模式 S 的 subject 为空、外部标识另列,避免把"不认识的字符串"混进"经准入的自然人"一列。**已定。** + +- **[决议-8] 模式 D 会话归属被代表用户;定义 6 边界 a 只约束模式 S。**(AC-38) + 伴生 §3.4 表与 §4.6.3 表明文;边界 a 的目的(防资源归属人看到全部外部访客对话)在模式 D 不存在——被代表用户看到自己名义下的会话正是场景二的诉求。工作台可见与续问的端到端验收随 F058 伴生 AC-32;本 Feature 保证既有 ⧗ 端点在模式 D 下会话归属正确。**已定。** + +- **[决议-9] 模式 D 下创建的资源不回授服务账号。**(AC-37) + 伴生定义 6 边界 b 的回授只写在模式 S 语境;模式 D 下执行身份是自然人、次日续写仍以模式 D 进行,无回授必要;回授反而让委托密钥为服务账号攒出跨全员的资源写权限(PRD-1 DEV-02 ④ 指出的爆炸半径放大)。备选 = 同样回授,否决。**已定:不回授**;建议回写伴生 PRD 定义 6「与模式 D 的关系」补一句。 + +- **[决议-10] `delegate` 位只可绑定服务账号主体、不受开放能力层开关影响。**(AC-17) + 委托属开放 API 面本身(伴生 R2 / R3 是标准版能力);托管应用运行期凭据不得持 `delegate`(PRD-1 GOV-05 信任分界 + INV-31「全册无模式 D 场景」)——底座校验点由 F049 底座提供、规则由本 Feature 定义。**已定。** + +- **[决议-11] share-token 通道不承载身份头(携带即拒);F049 已知代价「share-token 会话落在分享创建者会话列表」不由本 Feature 修复。**(AC-09;§3) + 身份传递以服务账号密钥 + `delegate` 为唯一信任基础,share-token 会话执行主体恒为分享创建者(F049 决议-6 i);F049 design 把 guest 会话摘出列入其后续项并等待本 Feature 的分区键机制——本 Feature 只保证分区键机制存在,摘出动作归 F049 / F058 后续,不在本 spec 登记 AC。**已定。** + +- **[决议-12] 端点允许模式落地方式 = 与 F049 同一条完整性校验路径(不存在未声明允许模式的可达端点);F058 端点上线时按附录 B.1 自行声明。**(AC-07 / AC-28) + F049 已为每个端点建立「权限位声明 + 完整性兜底」并预留允许模式的扩展位置(F049 design 决策 3);本 spec 只要求「无未声明允许模式的可达端点」这一可验收结果,声明形态属 design。**已定。** + +--- + +## 5. 设计与实现(指针,不复制) + +> **本节刻意不写内容。** spec 不承载 How。 + +| 你想知道 | 去哪看 | +|---|---| +| 为什么这么实现(决策 + 备选 + 何时该推翻) | design.md §3 方案对比 | +| 关键约束(双 DB / 多租户 / 权限 / 运行时依赖 / 错误码段 260) | design.md §2 | +| 今天的数据流、模块职责、字段 / API / 文件名约定(模式分流点、准入检查落点、委托范围存储、分区键、审计记录形态) | design.md §4 系统现状 | +| 代码里看不出的坑、反直觉事实(身份构造两条路径的超管标记不对称、组织侧兜底进访客部门的两条自动路径、默认租户无租户管理员、`department` 子树展开、F049 期 `26004` 借用与代用户路径) | design.md §5 | +| 对外契约、改了会破坏谁、依赖谁(F049 鉴权依赖扩展、F052 门面调用、F051 / F055 subject 口径、F058 消费点) | design.md §6 | +| 任务拆解、文件清单、执行顺序、踩坑落档 | tasks.md | +| 基线代码核实速查(v2 端点分类、三个身份解析函数、F048 判定链与系统级放行谓词) | [../000-prd1-discovery/baseline-recheck.md](../000-prd1-discovery/baseline-recheck.md) | +| F049 已交付面(凭据校验路径、端点标记、服务账号与归属人、审计事件形态) | [../049-openapi-auth-baseline/design.md](../049-openapi-auth-baseline/design.md) | + +--- + +## 相关文档 + +- 设计真相: [design.md](./design.md)(接手第一入口,尚未编写) +- 执行与落档: [tasks.md](./tasks.md)(尚未编写) +- 版本契约: [features/v3.0.0/release-contract.md](../release-contract.md)(写 spec 前必须先阅读;表 1 ApiCredential「F050 追加 `delegate` 位与委托范围属性、不改底座」、表 2 INV-27~31(**INV-31 互斥校验归本 Feature**)、INV-32、表 3 F050 行、模块编码表 260) +- 上游 spec: [features/v3.0.0/049-openapi-auth-baseline/spec.md](../049-openapi-auth-baseline/spec.md)(本 Feature 是其 P1 续篇;排除项「随 F050」清单 = 本 spec 范围) +- 依赖 spec: [features/v3.0.0/052-mcp-server-face/spec.md](../052-mcp-server-face/spec.md)(统一检索门面 AC-19 / AC-25 / AC-43;`delegate` 入口拒绝 AC-28);[051-model-protocol-gateway/spec.md](../051-model-protocol-gateway/spec.md)(AC-21 subject 口径 / AC-26 入口拒绝);[053-dev-cli-skills/spec.md](../053-dev-cli-skills/spec.md)(AC-09 `login` 拒 `delegate`);[055-app-publish-pipeline/spec.md](../055-app-publish-pipeline/spec.md)(AC-55 运行期双归属) +- Spec Discovery: [features/v3.0.0/000-prd1-discovery/discovery.md](../000-prd1-discovery/discovery.md) §2.1 / §2.2 / N3;[baseline-recheck.md](../000-prd1-discovery/baseline-recheck.md) §一–§三 +- MVP 纵切: [features/v3.0.0/mvp-114-path.md](../mvp-114-path.md)(本 Feature 不在纵切上) +- PRD: `docs/PRD/3.0-beta2/3.0 开放 API 鉴权与身份传递 PRD.md` v2.1(伴生册,本 Feature 的需求真相:R2 / R3 / R7 / §4.9 / 附录 B.1 / C / E.2 / E.5 / F);`docs/PRD/3.0-release/3.0 应用工场 PRD-1 专业开发者通道与应用运行时.md` v2.0 DEV-01 / DEV-02 ④ / DEV-05 ① / GOV-04 / GOV-05(上册总纲:三面仅模式 S、信任机制分界) +- 架构文档: `docs/architecture/`(权限 / 多租户);`docs/constitution.md` C4(权限唯一入口)/ C5(错误码 260 登记) diff --git a/features/v3.0.0/051-model-protocol-gateway/design.md b/features/v3.0.0/051-model-protocol-gateway/design.md new file mode 100644 index 0000000000..660eb56813 --- /dev/null +++ b/features/v3.0.0/051-model-protocol-gateway/design.md @@ -0,0 +1,388 @@ +# Design: 模型协议直连面(OpenAI 兼容子集 + `model:invoke` + 逐条调用审计) + +> **本文档定位 — 现状快照(Why this How)** +> +> - `spec.md` 回答 **做什么**(36 条 AC、边界、决议 1–8) +> - `design.md`(本文)回答 **为什么这么实现**:关键决策(含被否决的备选)、运行时不直观的事实、对外契约 +> - `tasks.md` 是 **流水账**:拆了哪些任务、做了什么改动 +> +> 调整原则(详见 `docs/SDD-Guide.md` §3-§4):实现变化 → 覆盖更新本文档、只留"今天的状态";但每个决策保留"为什么 + 被否方案"和坑。推翻已 ★ 确认的决策 → 停下与用户重新确认;纯实现细节 → 直接改 design。 +> +> **代码事实口径**:本文所有 `文件:行号` 均按 `3.0-vibe`(HEAD `fe10f75ea`,含 beta2 F053 开放 API 底座)在 2026-09-16 核实,路径以 `src/backend/bisheng/` 为根(前端另注 `platform/` = `src/frontend/platform/src/`;`locales/` = `src/frontend/packages/locales/src/`)。行号会漂移,符号名不会——落地前以符号名重定位。 +> +> **全自动模式说明**:用户已豁免本 Feature 的 ★ 暂停点。§3 每条决策均标「全自动模式定案」并留理由与备选,供事后追溯或翻案。 + +**关联**: [spec.md](./spec.md) · [tasks.md](./tasks.md) · [release-contract.md](../release-contract.md)(表 1 **ModelCallRecord** 归本 Feature;INV-27 / INV-28 / INV-30 / INV-31)· [mvp-114-path.md](../mvp-114-path.md)(F051 不在纵切上)· [000-prd1-discovery/research/model-llm.md](../000-prd1-discovery/research/model-llm.md) +**上游 / 姊妹**: [F049 design](../049-openapi-auth-baseline/design.md)(历史;**实际底座 = beta2 F053**:`router_rpc` 单一依赖 `verify_open_api_access` + 端点 `@open_api_scope` marker)· [F055 design D13](../055-app-publish-pipeline/design.md)(能力总线模型项落点方向、`BISHENG_APP_TOKEN` 注入)· [F055 tasks T055 / T056](../055-app-publish-pipeline/tasks.md)(`hosted_app` 主体解析器、模型能力注入——本文 §6.2 依赖项)· [F054 contracts-runtime-manager.md §5](../054-app-domain-runtime/contracts-runtime-manager.md)(注入环境变量清单唯一来源,本文 D2 提回写请求)· [F053 design](../053-dev-cli-skills/design.md)(接入信息区 / `dev` 同名环境变量的消费方) +**版本**: v3.0.0 +**最后更新**: 2026-09-16(初版;尚未开工,本文是"要建成的样子",实现后按现状覆盖) + +--- + +## 1. 目标与非目标 + +- **目标**:在 `/api/v2` 上开一条**裸协议透传**的 OpenAI 兼容子集——`POST …/chat/completions`(含 SSE 流式、工具调用)与 `GET …/models`——让讲 OpenAI 协议的本地引擎(Qwen Code / Codex CLI 对话补全模式 / Kimi Code)和托管应用只凭 base URL + 一把密钥 + 模型管理页原名就能调到租户已启用的对话模型;鉴权与身份完全复用 beta2 开放 API 底座(`model:invoke` 位、仅模式 S、`delegate` 入口拒绝、5 秒撤销上界);模型名解析(含跨服务商同名的限定名规则)落在 `llm` 域、供 F052 模型清单工具与 F055 预检**同一函数**消费;每次到达模型解析阶段的调用写一行 **ModelCallRecord**(新表、异步批量写),token 数与平台既有 MODEL_INVOKE 遥测同源。 +- **非目标**(spec 范围边界已定,此处防扩范围):Anthropic 兼容面、embeddings / Responses / 其它 OpenAI 端点(只拒不转);F052 MCP 模型清单工具本身;F058 会话契约;按 key / 按应用聚合账单与用量可视(v3.1);per-key / per-app 限流与 token 上限;模型管理页改动;托管应用运行期凭据的签发 / 重签(F055 T055)、「当前生效能力声明」的定义(F055)、短时访问凭据的签发(F054);接入信息区界面(F053 AC-44);`delegate` ⊗ 三扩展位的签发期互斥(已由 beta2 `26050` 交付)。 + +--- + +## 2. 关键约束 + +> 全局铁律(DDD 分层 / 双 DB / 多租户自动注入 / 权限唯一入口 / 错误码 / 无硬编码密钥 / 前端 store 不直连 HTTP / 本地盘不承载共享状态)一律遵循 [`docs/constitution.md`](../../../docs/constitution.md) **C1–C8**,本节不重抄。以下只写本 Feature 特有的硬约束。 + +| # | 约束 | 出处 / 后果 | +|---|---|---| +| K1 | **鉴权只能复用、不能重写**:`router_rpc = APIRouter(prefix="/api/v2", dependencies=[Depends(verify_open_api_access)])`(`api/router.py:149`)是 `/api/v2` 唯一凭据校验路径;子 router 只能靠端点 `@open_api_scope(...)` marker 声明位与模式,**无标记即 26031 fail-closed**(`open_api/api/dependencies.py:101-102`)。凭据缓存 TTL ≤ 5s(`core/config/open_platform.py:13` 默认 3、`:47-50` 校验器夹到 5)、`delegate` 入口拒绝 26051(`dependencies.py:113-114`)、缺位 26003 带 `required`(`:115-116`)、身份头拒绝(`identity_service.assert_no_removed_identity_headers`,`:118`)全部**白吃**——AC-05 / AC-06 / AC-07 / AC-26 / AC-27 不写一行鉴权代码 | INV-27 / INV-31;`test/open_api/test_open_api_route_matrix.py:38-56` 断言每条 v2 路由都带 marker 且登记在 `OPEN_API_SCOPES` | +| K2 | **HTTP 状态必须真实且错误体必须是 OpenAI 形状**:v2 handler 已把 `BaseErrorCode` 映射成真状态(`open_api/api/exception_handlers.py:45-77`),但 body 是 BiSheng 信封 `{status_code,status_message,data}`;官方 `openai` 客户端只解析 `{"error":{message,type,code,param}}`——本面路径前缀下必须换体、且不能影响其它 v2 路径 | AC-08;D4 | +| K3 | **模型解析今天不存在**:`BishengBase.get_model_server_info` 只按数字 `model_id` 取模型(`llm/domain/llm/base.py:44-57`);`(server_id, model_name)` 才唯一(`llm/domain/models/llm_server.py:102 server_model_uniq`),**跨服务商同名合法**;服务器名租户内唯一(`:95 uk_llm_server_tenant_name`) | AC-11 / AC-12;D5 | +| K4 | **可用范围 = `LLMService.get_all_llm` 的服务器集合**(`llm/domain/services/llm.py:398-505`:leaf 自有行 `strict_tenant_filter()` + FGA `shared_with` Root 服务器 + 继承的系统默认服务器,模型经 `bypass_tenant_filter()` 取)——但它返回 `LLMServerInfo` 给前端用、签名带 `operator: UserPayload`;本面需要**无 UserPayload、可缓存、fail-closed** 的版本。**坑**:`LLMDao.aget_shared_server_ids_for_leaf` 在 FGA 异常时 `return []`(`llm_server.py:492-512`)——对前端列表是"少看到",对本面是**静默缩窄后放行**,违反 AC-35 | AC-09 / AC-35;D6 | +| K5 | **模型下线生效上界 60s = `LLM_CACHE` 的 TTL**(`llm/domain/const.py:45` `TTLCache(maxsize=30, ttl=60)`;`aget_model_by_id_with_share_fallback(cache=True)` 走它);`update_model_online` 已调 `invalidate_llm_info_cache`(`utils.py:430-441`)但只清本进程。本面目录缓存 TTL 同样 ≤ 60s、多节点各自过期即可,不做主动跨节点失效(spec 决议-4) | AC-14 | +| K6 | **BishengLLM 的附赠与代价**:`LLMService.get_bisheng_llm(model_id=…, app_id, app_type, app_name, user_id, streaming, …)` → `BishengLLM`(`llm/domain/llm/llm.py:223`)= 19 家服务商映射、`bind_tools`(`:356-362`,`convert_to_openai_tool`)、`_astream`(`:376-384`)、`normalize_reasoning_content`、下线 / 删除 / 类型错误族(`common/errcode/server.py:53-78`,10009 / 10010 / 10011 / 10012 / 10013)、服务商日调用上限(`utils.py:118-126`)、自动 MODEL_INVOKE 遥测 + `emit_metric`(`utils.py:212-271`)。代价:① 遥测必填 `app_id / app_type / app_name / user_id`(`base.py:21-25`);② 上限超出抛**裸 `Exception("… Quota used up")`**(`utils.py:126`);③ 流式遥测只解析**最后一个 chunk** 的 usage(`utils.py:382-409` `wrapper_bisheng_model_generator_async`,`result=item`) | D1 / D10 / D13;坑 3、坑 5 | +| K7 | **审计写放大**:本面是高频面,`OpenApiAuditMiddleware` 已对每个 `/api/v2` 请求(含 401 / 403)写一行 `open_api.call` AuditLog(`open_api/api/middleware.py:98-157`,批量 `OpenApiCallAuditService`,队列 1000 / 批 100 / 1s,`call_audit_service.py:12-15`)。ModelCallRecord 必须是**独立表**(不进 `audit_log`,否则审计页被淹)、写入必须异步批量、**不能拖慢首字** | AC-20 / AC-24 / spec §3「审计写放大」;D9 | +| K8 | **token 口径唯一**:平台既有的模型用量账本是 MODEL_INVOKE 遥测事件(`ModelInvokeEventData.input_token/output_token/total_token`,`utils.py:236-253`,数值来自 `parse_token_usage`);`llm_token_log`(`LLMTokenTracker.record_usage`)**只有工作流回调写**(`workflow/callback/llm_usage_callback.py:85`),无读侧消费。ModelCallRecord 的 token 数必须用**同一个结果对象上的同一个 `parse_token_usage`**,不另算 | AC-23;D10 | +| K9 | **开放能力层开关是进程级**(`settings.open_platform.enabled`,`core/config/open_platform.py:8`;先例 `api/router.py:140-141` `if settings.open_platform.enabled: router.include_router(dev_toolkit_router)`);未开时本面**不挂 router → Starlette 裸 404**(不透露存在,AC-28),开后不依赖 runtime-manager / app-proxy(AC-29) | GOV-10 | +| K10 | **base URL 必须可从平台 origin 推导且部署形态无关**:`resolve_public_base_url(request)`(`open_api/api/public_base_url.py:77-96`:配置 `open_api.public_base_url` > `X-Forwarded-*` > Host);nginx `location ~ ^(/workspace)?/api(/|$)` 直通后端并带 `X-Forwarded-Proto/Host`(`docker/nginx/conf.d/default.conf:117-124`),商业版网关代理 `/api/v2/**`(`docs/architecture/11-gateway.md`)——路径放 `/api/v2` 下即三种形态都可达 | AC-30;D2 | +| K11 | **SSE 经 nginx 必须逐块透传**:`default.conf:117-124`(`location ~ ^(/workspace)?/api(/|$)`)有 `proxy_read_timeout 300s`、**无 `proxy_buffering off`**——同一份配置里托管应用块 `:94` 是显式关了缓冲的(注释「缓冲会把流式响应攒成一坨」),`/api` 块没跟上,所以不能指望它;响应必须带 `X-Accel-Buffering: no` + `Cache-Control: no-cache`,且中途失败以 SSE 错误事件收尾后 `[DONE]` 并关闭(AC-16)。已有 SSE 精度参考:`assistant/domain/services/published_assistant_service.py:139-190`(无 tool_calls / usage,**不可直接复用**) | AC-16 / AC-18 | +| K12 | **托管应用路径被上游阻塞**:`api_credential.subject_kind` CHECK 只允许 `service_account / natural_person`(`open_api/domain/models/api_credential.py:42-44`)、`SUBJECT_RESOLVERS` 只有两项(`credential_validator.py:105-108`)、manifest `capabilities` 非空被 16231 拒(`app_publish/domain/services/manifest_validator.py:299-303`)、OBO 令牌只签不验(`app_runtime/domain/services/entry_authz_service.py:379-426`,`aud="bisheng-app-obo"`,头名 `X-BiSheng-Access-Token` `:175`)。本面对 AC-21 / AC-22 / AC-34 只能**先定钩子接口、后接线**(D7),任务标「依赖 F055 T055 / T056、F054 OBO 验签」。**2026-09-16 解除**:四处全部落地(CHECK 已含 `hosted_app`、`SUBJECT_RESOLVERS` 三项、`HostedAppDeclarationAdapter` / `AccessSubjectVerifier` 由 `app_publish/composition.py` 一并注册、`verify_obo_token` 已实现),T022–T024 改为真实断言,见 §6.2 | §6.2 | +| K13 | **`check-i18n.mjs:106` 只识别 `Code:\s*int\s*=` 写法**;`common/errcode/open_api.py` 全部用 `Code = NNNNN`,故 260 段的三语覆盖**没有**被 CI 校验。新文件 `model_face.py` 一律写 `Code: int = 265xx`,让 CI 真正拦缺文案 | C5;坑 12 | + +**Constitution Check(自查)**:C1 新代码分 `open_api/api/endpoints/model_gateway.py`(端点)→ `open_api/domain/services/model_gateway_service.py`(编排)→ `llm/domain/services/model_catalog.py`(目录 / 解析,跨模块 domain 调用允许)→ `open_api/domain/repositories/model_call_record_repository.py`(ORM);端点不 import 其它模块 `api/*`(RULE-5)。C2 新表全部 `dialect_helpers`,`VARCHAR` 不用 `CHAR`,无 JSON 过滤(D9)。C3 `verify_open_api_access` 已 `current_tenant_id.set(principal.tenant_id)`(`dependencies.py:76`),目录查询按当前租户;**写入器在后台任务里跑、无请求 ContextVar**——批量 INSERT 每行显式带 `tenant_id`(before_flush 兜底靠不住:`tenant_filter.py:233-241` 在无 ContextVar 时,单租户部署填 `DEFAULT_TENANT_ID`、**多租户部署直接 `return` 什么也不填**——那一行就会以 `tenant_id=None` 落库),`credential_mask` 水合的 `IN` 查询在 `bypass_tenant_filter()` 下做(批内跨租户);无批量 UPDATE / DELETE。新表模块必须 import 进 `open_api/domain/models/__init__.py`(D9)。C4 本面不做资源级授权(可用范围是租户配置,不是 F048 资源权限);`shared_with` 反查经 `permission.application`(K4,`get_permission_relation_api`)、无 OpenFGA 直连。C5 新模块 **265**(D11)。C6 密钥 / 服务商配置不进日志与记录(D14)。C8 目录缓存只是加速、真相在 DB;写入器队列是进程内缓冲、丢失可观测。 + +--- + +## 3. 方案对比与选定 + +> 每条 3 段:备选 / 选定 / 原因 + 何时该重新考虑。均为「全自动模式定案」。 + +### D1:协议面承载 = 自研薄层(OpenAI 请求 ⇄ `BishengLLM` 翻译),不引入现成代理、不做原始 HTTP 透传 + +- **备选**: + - A. **现成代理**(LiteLLM / One-API 一类)单独部署,平台把服务商凭据同步过去 — 优点:协议长尾(Anthropic / embeddings)免费;缺点:第二份服务商凭据(AC-36 明文外泄面翻倍)、自带管理 UI / DB 要额外封(AC-36)、它不知道「租户可见且已启用」「Root 共享」「服务商日上限」——可用范围要靠同步,同步延迟 = 静默越权;商业版网关 / 信创部署再多一个组件 + - B. **原始 HTTP 透传**(只对 `LLMServerType.OPENAI` 一类服务商把 body 原样转发到 `base_url`)— 优点:零翻译损耗;缺点:19 家服务商里只有 openai / azure / vllm / deepseek 等一半讲 OpenAI 协议,其余(qwen / zhipu / spark / minimax / anthropic…)仍要翻译 → 两套代码;遥测 / 上限 / 下线错误族全要重做 + - C. **自研薄层**:pydantic 校验 OpenAI 请求 → `convert_to_messages` → `LLMService.get_bisheng_llm(...)`(K6)→ `ainvoke` / `astream` → 组装 OpenAI 响应 / chunk +- **选定**:**C**(全自动模式定案) +- **原因**:产品方案 §4.6「BishengLLM 复用度」与 spec 决议-1 已把 Anthropic 面排除,现成代理最大的收益(协议长尾)本版用不上,而其成本条条命中 AC-36 / AC-09;C 让「租户已启用模型 = 平台内助手 / 工作流能调的模型」这一等价(AC-15)成为结构性事实——同一个 `BishengLLM`、同一个 `limit_flag`、同一个 `LlmModelOfflineError`。翻译损耗只在 tool_calls / usage 两处需要精细组装(D8)。 +- **何时该重新考虑**:v3.1 立项 Anthropic 面或 embeddings 承诺面(那时也优先在同一 base URL 下扩薄层,只有"三种以上协议同时承诺"才值得引代理);`langchain` 抽象在流式 tool_calls 上出现不可修补的丢字段(坑 6 的检测手段能证明)。 + +### D2:挂载点与 base URL = `/api/v2/model/v1`,环境变量 `OPENAI_BASE_URL` / `OPENAI_API_KEY` + 平台保留名 `BISHENG_MODEL_BASE_URL` + +- **备选**: + - A. `/api/v2/openai/v1/…` — 路径把第三方名字写进平台契约;将来若在同一 base 下扩其它协议子集(决议-1「何时重新考虑」)名字就错了 + - B. 独立顶级前缀 `/model/v1`(不在 `/api/v2` 下)— 脱离 `router_rpc` 就要复制 K1 全部鉴权、审计中间件(`OPEN_API_V2_PREFIX`)、网关代理白名单、nginx location 四处 + - C. **`/api/v2/model/v1`**:子 router `prefix="/model/v1"` 挂进 `router_rpc`(`api/router.py:149-166`),端点 `POST /chat/completions`、`GET /models` +- **选定**:**C**(全自动模式定案)。**base URL = `{resolve_public_base_url(request)}/api/v2/model/v1`**(无尾斜杠),由新 helper `model_gateway_base_url(request)`(`open_api/api/public_base_url.py`)唯一产出;`GET /api/v2/auth/whoami` 响应新增字段 `model_base_url`(`WhoamiResponse`,`open_api/domain/schemas/credential.py`)——F053 `login` 与接入信息区都从这一个出口取值,不各自拼。 +- **环境变量契约**(供 F053 `dev` 与 F054 runtime-manager 同名注入,本 Feature 只定名、不注入):`OPENAI_BASE_URL` = base URL、`OPENAI_API_KEY` = 凭据明文(`bs-sak-…` 或应用运行期凭据)——官方 `openai` 客户端零配置直读;另设平台保留名 `BISHENG_MODEL_BASE_URL`(= 同值)作为与 `BISHENG_PLATFORM_API_BASE` 平行的稳定名,供技能包文案与不读 OpenAI 惯例变量的引擎使用。**保留 `/v1` 段**是因为多数引擎把「以 `/v1` 结尾」当作 OpenAI 兼容地址的启发式校验(Codex CLI provider 配置、部分 IDE 插件)。 +- **原因**:C 白吃 K1 全部鉴权与 K7 审计;网关 / nginx 零改动(K10);`/model/v1` 协议中性;官方客户端只在 base URL 后拼 `/chat/completions`、`/models`,不再补 `/v1`,故 base 里自带 `/v1` 是安全的。 +- **何时该重新考虑**:runtime-manager 注入清单(F054 contracts §5)正式加入本组变量时若 F054 / F057 已用了别的名字——以本文为准回写它们(§6.1);商业版网关若对 `/api/v2/model` 单独限流。 + +### D3:承诺面之外的路径 = 子 router 内显式 catch-all 拒绝路由(26501)+ Anthropic `/messages` 专用 26502;开关未开 → 整个子 router 不挂 + +- **备选**: + - A. 不写多余路由,让未知路径落到 Starlette 默认 404 `{"detail":"Not Found"}` — 不是 OpenAI 错误体、且无凭据也 404、与 AC-02「明确的『本版不提供该端点』可读响应」不符 + - B. 逐个枚举 `/embeddings`、`/completions`、`/responses`、`/images/*`… 每个一条路由 — 清单会漏,且 `test_open_api_route_matrix.py:48-56` 要求每条真实路由登记进 `OPEN_API_SCOPES.endpoints`,枚举越多登记越多 + - C. **一条 `api_route("/{rest:path}", methods=[GET, POST, PUT, DELETE, PATCH])` catch-all**,标 `@open_api_scope("model:invoke", modes=("S",))`(**先过凭据与位判定再拒**——无凭据仍 401、缺位仍 403,不透露端点清单);其中 `rest == "messages"`(Anthropic Messages API 路径)→ 26502「本版仅提供 OpenAI 兼容面」,其余 → 26501「本版不提供该端点」,HTTP 404、`type="invalid_request_error"`、`code="endpoint_not_supported"`;路由矩阵登记 `("*", "/api/v2/model/v1/{rest}")`(测试的 `actual_v2_routes()` 按 `route.methods` 展开,登记时按五个方法各一条) +- **选定**:**C**(全自动模式定案) +- **原因**:一条路由覆盖全部 OpenAI 协议族(`/embeddings`、`/completions`、`/responses`、`/images/generations`、`/audio/*`、`/files`、`/fine_tuning/*`、`/assistants`…)与 Anthropic 路径,不会漏;仍经 K1 依赖故不破 INV-27。Anthropic 单列一码是 AC-03 / AC-32 的可测试点(Claude Code 打到 `…/v1/messages`)。 +- **开关未开(AC-28)**:`api/router.py` 中 `if settings.open_platform.enabled: router_rpc.include_router(model_gateway_router)`(仿 `:140-141`;`open_api/api/router.py` 今天只导出 `management_router` / `rpc_router`,`api/router.py:49-50` 以别名 `open_api_rpc_router` 引入——本面另导出 `model_gateway_router`,**不**塞进 `rpc_router`,否则条件挂载失效);未开时路径不存在 → Starlette 404,与其它不存在路径无差别。**与既有 v2 子 router 的差别要说清**:`app:manage`(F055)的 `/api/v2/apps/**` 是**无条件**挂载、只靠位不可签发来关(`api/router.py:164`),本面是唯一条件挂载的 v2 子 router——因为 AC-28 要求「不透露存在」,403 缺位做不到。 +- **两个既有测试因此要改口径**(T007 / T016):① `test_open_api_route_matrix.py:44-56` 断言 `OPEN_API_SCOPES` 登记的端点集合 == 实际挂载集合,`app` 来自 `from bisheng.main import app`(`:4`,import 时按 config 求值开关,`monkeypatch` 事后改开关不会重挂);② `test_openapi_schema_contract.py:34-56` 断言 `app.openapi()` 的 v2 操作集合 == 生成的 `openapi-v2-key-auth-api.json`。两者在**开关关闭的进程**里都会看到「登记 / 契约有、实际无」。处理:以 `exception_handlers.MODEL_GATEWAY_PATH_PREFIX` 为唯一来源,两测试在 `settings.open_platform.enabled` 为假时把该前缀下的操作从**期望集合**剔除(不改 `OpenApiScope` 数据类、不加字段);开关开启态由 T012 switch 用例用 `importlib.reload(bisheng.api.router)` + 独立 `FastAPI()` 挂 `router_rpc` 断言存在。契约 json 用 `open_platform.enabled: true` 的 config 生成(生成脚本同样 `import bisheng.main`,`generate_openapi_contract.py:13`),因此 json 里恒含本面三条操作。`open_platform_enabled` / `open_platform_disabled` fixture 今天是 `test/open_api/test_scope_issuability.py:36-42` 的**模块内**定义,T005 把它们提升到 `test/open_api/conftest.py` 供本面各测试复用。 +- **何时该重新考虑**:承诺面扩到 embeddings 时把它从 catch-all 前面"抠"出来成真路由,catch-all 不变。 + +### D4:OpenAI 错误体 = 本面前缀专属渲染 + 平台码映射表;`bisheng_code` 保留在 `error.code` 之外 + +- **备选**: + - A. 改 `open_api/api/exception_handlers.py` 全局体形状 — 破坏既有 v2 客户契约(`openapi-v2-key-auth-api.json` + 客户脚本已按信封解析) + - B. 在本面端点里 `try/except` 自己渲染 — 依赖层(26001 / 26003 / 26051)抛出时端点还没执行,接不到 + - C. **在 `_register_v2_handler` 的 `dispatch` 前加一层判断**:`path.startswith("/api/v2/model/v1")` → 走 `render_openai_error(exc)`;否则原逻辑不变。`RequestValidationError` 同样在该前缀下渲染成 `invalid_request_error`(`param` 取 `loc` 末段)。**依赖层错误也会经此路径**(`open_api_auth_exception_handler` 与 `dispatch` 都先看前缀) +- **选定**:**C**(全自动模式定案)。错误体:`{"error": {"message": "<可读原因>", "type": "", "code": "<稳定字符串>", "param": null|"<字段>", "bisheng_code": 265xx|260xx}}`——`bisheng_code` 是 OpenAI 体之外的扩展键,官方客户端忽略未知键、平台侧脚本据此精确分类。 +- **映射表**(HTTP 状态 / `type` / `code`): + +| 平台码 | 场景 | HTTP | `type` | `code` | +|---|---|---|---|---| +| 26001 / 26002 / 26027 | 无凭据 / 无效 / 主体停用(AC-05) | 401 | `authentication_error` | `invalid_api_key` | +| 26003 | 缺 `model:invoke`(AC-06;message 含 `required=model:invoke`) | 403 | `permission_error` | `insufficient_scope` | +| 26051 | 委托专用密钥(AC-26;message 原文「委托专用、本地开发另签一把」) | 403 | `permission_error` | `delegate_only_credential` | +| 26004 / 26005 / 26010 / 26018 / 26019 | 委托类身份头 / `user_id` 入参(AC-27):`X-On-Behalf-Of` 而主体非服务账号或密钥无 `delegate` → 26004(`OpenApiDelegationNotAllowedError`,403,`identity_service.py:76-77`);`X-On-Behalf-Of` 值非法 → 26005(`:44-52`);两头同时出现 → 26010(`OpenApiIdentityHeaderConflictError`,`:38-39`);`X-End-User` 值非法 → 26018(`:55-57`);旧式 `*-on-behalf-of` / `*-end-user` 头(`:25-30`)或 query / form / body 里的 `user_id`(`dependencies.py:191-214`)→ 26019。**26016 在本面不可达**——它是 `OpenApiDelegationHeaderRequiredError`(持 `delegate` 却没带 `X-On-Behalf-Of`,`:71-73`),而持 `delegate` 的密钥在依赖层已被 26051 更早拒(K1) | 400/403 按原码 | `invalid_request_error` | `identity_header_not_accepted` | +| **26505** | **合法值的 `X-End-User` 单独出现(AC-27 的底座缺口,坑 18)**:底座对它只做格式校验,合法即 `principal.model_copy(update={"end_user_id": …})` 静默放行(`identity_service.py:71-74`)——AC-27 要求「不得静默忽略该头继续执行」,故本面自己拒 | 403 | `permission_error` | `identity_header_not_accepted` | +| 26501 / 26502 | 端点不支持 / Anthropic 路径(AC-02 / AC-03) | 404 | `invalid_request_error` | `endpoint_not_supported` / `anthropic_protocol_not_supported` | +| 26503 | 请求体不合法(`RequestValidationError`、`messages` 为空等) | 400 | `invalid_request_error` | `invalid_request` | +| 26504 | 服务账号密钥附带访问凭据(AC-22) | 403 | `permission_error` | `access_token_not_accepted` | +| 26511 | 模型不存在(含他租户 / 非对话类 / 名称不匹配,AC-13) | 404 | `invalid_request_error` | `model_not_found` | +| 26512 | 模型已下线(AC-13 / AC-14) | 404 | `invalid_request_error` | `model_offline` | +| 26513 | 服务商已删除 / 已收回(AC-13) | 404 | `invalid_request_error` | `model_revoked` | +| 26514 | 裸名歧义(AC-12;`error.candidates=[限定名…]`) | 400 | `invalid_request_error` | `model_name_ambiguous` | +| 26515 | 能力未声明(托管应用,AC-34) | 403 | `permission_error` | `capability_undeclared` | +| 26516 | 目录 / 声明不可判定 → fail-closed(AC-35) | 503 | `server_error` | `model_catalog_unavailable` | +| 26517 | 服务商日调用上限(AC-15) | 429 | `rate_limit_error` | `provider_daily_limit_exceeded` | +| 26531 | 上游失败(连接 / 5xx / 初始化 10013) | 502 | `server_error` | `upstream_error` | +| 26532 | 上游拒绝请求(上游 4xx:上下文超长 / 参数不支持 / 内容拦截;message 带上游原文) | 上游状态(400/413/422),取不到则 400 | `invalid_request_error` | `upstream_rejected` | +| 26533 | 上游限流(上游 429) | 429 | `rate_limit_error` | `upstream_rate_limited` | +| 26534 | 流式中途中断(只在 SSE 错误事件出现) | — | `server_error` | `stream_interrupted` | +| 19002 / 19201 / 26030 | 权限引擎 / 凭据依赖不可用 | 503 | `server_error` | `service_unavailable` | + +- **上游异常识别**:`openai.APIStatusError`(`status_code`、`message`)与各服务商 SDK 异常经 `classify_upstream_error(exc)` 归入 26531 / 26532 / 26533;无法识别 → 26531,message 取 `str(exc)` 前 500 字(**不含**服务商 URL / key,D14)。 +- **原因**:AC-08 要求官方客户端能按其错误约定解析并让 agent 自行纠正;`type` / `code` 用 OpenAI 已有词汇(`authentication_error` / `permission_error` / `invalid_request_error` / `rate_limit_error` / `server_error`),`code` 用稳定 snake_case 字符串而非数字,客户端才能 `except` 到位。 +- **何时该重新考虑**:`openai` SDK 大版本改错误体约定。 + +### D5:模型名基准 = `LLMModel.model_name`;限定名 = `{服务商名}/{模型名}`;解析算法固定 + +- **事实**:模型管理页「模型名称」输入框绑定的是 `model.model_name`(`platform/pages/ModelPage/manage/ModelConfig.tsx:297-303`,i18n `model.modelName` = 「模型名称」);`LLMModel.name` 是「显示名」(`llm_server.py:58`)——spec 决议-3「用户在页面看到并输入的调用名」= `model_name`。 +- **备选分隔符**:`::`(无生态先例)/ `@`(与 Ollama `name@digest`、部分网关的版本后缀撞)/ `:`(Ollama tag `qwen2.5:7b` 就在 `model_name` 里,撞)/ **`/`**(OpenRouter / LiteLLM 生态的 `provider/model` 写法,引擎已接受) +- **选定**:**`/`**(全自动模式定案)。`LLMServer.name` 租户内唯一(K3),限定名在租户内恒唯一。 +- **解析算法 `resolve_model_name(tenant_id, requested) -> ResolvedModel`**(落 `llm/domain/services/model_catalog.py`,三处同源): + 1. 取可用集合 `C`(D6:对话类 `model_type == 'llm'` 且 `online == True` 的模型,带服务器名 / 类型 / id); + 2. **精确匹配 `model_name == requested`** → 命中 1 个:返回;命中 ≥ 2:抛 26514,`candidates` = 各命中的 `f"{server.name}/{model_name}"`; + 3. 命中 0 且 `requested` 含 `/`:对 `C` 中每个 `server.name` 满足 `requested.startswith(server.name + "/")` 的服务器,检查 `model_name == requested[len(server.name)+1:]`;命中 1 个返回(限定名在名称唯一时同样可用,AC-12);命中 0 进第 4 步;命中 ≥ 2 结构上不可能(服务器名唯一); + 4. 未命中:在**未过滤集合**里再查一次以区分原因——同名模型存在但 `online == False` → 26512;存在但 `model_type != 'llm'` / 属他租户 / 完全不存在 → 26511(**不区分**"没有"与"不属于你",AC-13);服务器被删(模型行残留、`server_id` 无对应)→ 26513。 +- **`GET /models` 的 `id`**:`model_name` 在 `C` 内唯一 → 原名;否则每个同名项只给限定名(AC-12「模型列表对歧义模型只给出限定名」);`owned_by` = 服务商名;扩展键 `bisheng_model_type="llm"`、`bisheng_qualified_name`(恒给限定名,供 agent 想写稳定名时用)。 +- **原因**:精确匹配保证「页面看到什么名字、代码里就写什么」;先裸名后限定名保证 `model_name` 本身含 `/`(OpenRouter 风格 `qwen/qwen-2.5-72b`)且唯一时仍可裸名直调;第 4 步的两次查询只在失败路径发生,不影响热路径。 +- **何时该重新考虑**:模型管理页引入租户内唯一名约束(本规则退化为空转,契约不改)。 + +### D6:可用范围计算与缓存 = `llm` 域新增 `model_catalog.py`(服务器集合 helper 从 `get_all_llm` 抽出)+ 进程内 60s TTL 缓存 + fail-closed + +- **备选**: + - A. 直接调 `LLMService.get_all_llm(operator=None)` 再过滤 — 它做前端专用的 `is_root_shared_readonly` / Root 租户名水合、每次两个额外查询(`_check_is_global_super`、`TenantDao`),且 FGA 异常被吞(K4) + - B. 在 `open_api` 域自己写一份"租户可见服务器"查询 — 与 `get_all_llm` 漂移;F052 / F055 再各抄一份就是三份 + - C. **抽公共 helper** `LLMService.acollect_visible_server_ids(tenant_id, *, strict: bool) -> list[int]`(把 `llm.py:419-453` 的 own + shared + inherited 合并逻辑原地抽出——`leaf_id = get_current_tenant_id() or ROOT_TENANT_ID` 到 `llm_servers = list(own)` 为止,`get_all_llm` 改调它,行为不变);`model_catalog.list_callable_chat_models(tenant_id) -> list[CallableModel]` = helper → `bypass_tenant_filter()` 下 `LLMDao.aget_server_by_ids` + `aget_model_by_server_ids` → 过滤 `model_type=='llm' and online`;`CallableModel(model_id, model_name, server_id, server_name, server_type, qualified_name, is_unique)` +- **选定**:**C**(全自动模式定案) +- **fail-closed(AC-35)**:给 `LLMDao.aget_shared_server_ids_for_leaf` 加 keyword-only `raise_on_error: bool = False`(默认行为不变,前端列表照旧"少看到");catalog 以 `raise_on_error=True` 调用,任何 FGA / DB 异常 → 26516(503),**绝不**用旧缓存或缩窄集合放行。 +- **缓存**:`model_catalog` 内独立 `TTLCache(maxsize=256, ttl=settings.open_api.model_catalog_ttl_seconds)`(新 Settings 键,默认 30、上限 60;**键 = tenant_id**),命中 → 直接解析;未命中 → 重算。**只缓存成功结果**,异常不缓存。60s 上界(AC-14)由 `ttl ≤ 60` + `LLM_CACHE` 60s 共同保证:本面解析拿到 `model_id` 后 `get_bisheng_llm` 仍走 `aget_model_by_id_with_share_fallback(cache=True)`(K5),两层都 ≤ 60s。 +- **原因**:C 让 F052 模型清单工具(AC-13)与 F055 预检(AC-07 / T060)`from bisheng.llm.domain.services.model_catalog import resolve_model_name, list_callable_chat_models` 即三处同源;缓存按租户而非按 key(AC-09「租户级、非 per-key」)。 +- **何时该重新考虑**:租户数 × 服务器数让 `maxsize=256` 频繁淘汰(观测 `model_catalog.cache_miss` 指标)→ 改 Redis 缓存并加主动失效。 + +### D7:主体范围策略与托管应用钩子 = `ModelRangePolicy` 按 `actor_kind` 分派 + 两个 Port(能力声明读取 / 访问凭据验签)由 F055 / F054 注册 + +- **今天能落地的**:`OpenApiPrincipal`(`open_api/domain/context.py:11-33`;`actor_kind: Literal["service_account", "natural_person", "hosted_app"]` 在 `:15`,同文件 `:43` 的 `OpenApiExecutionSnapshot` 带同一 Literal——**2026-09-16 F055 T055 已把两处一起扩完,T022 据此去掉了测试夹具里的 `model_construct` 变通**)`actor_kind in {"service_account", "natural_person"}` → 租户级范围(D6),subject = 主体自身(`subject_kind = actor_kind`, `subject_id = actor_id`)。**自然人(PAT)今天不可能到达本面**:`_validate_personal_token` 只放行 `["knowledge:read"]`(`credential_service.py:295-296`),持 PAT 请求本面必 26003;本面不为它写特殊分支,只保证不崩。 +- **钩子接口(本 Feature 定义、本 Feature 注册默认实现、F055 / F054 替换)**,落 `open_api/domain/services/model_range_policy.py`: + - `class HostedAppDeclarationPort(Protocol): async def declared_model_names(self, app_id: str, tenant_id: int) -> frozenset[str] | None`——返回该应用**当前生效能力声明**中的模型名集合(原名或限定名,按 D5 规则解析后与 `C` 求交);`None` = 无法读取 → 26516;空集 = 声明了零个模型 → 一切模型请求 26515。默认实现:抛 `NotImplementedError` 包装成 26516(未注册即 fail-closed)。F055 T056 注册真实实现(读 `AppDeployment.manifest.capabilities`,`app_publish/domain/models/app_deployment.py:158`)。 + - `class AccessSubjectVerifierPort(Protocol): def verify(self, token: str, *, app_id: str, tenant_id: int) -> AccessSubject | None`——`AccessSubject(user_id: int)`;验签失败 / 过期 / `app_id` 不匹配 → `None`。默认实现恒返 `None`。F054 把 `_issue_obo_token` 的 secret / aud / iss 常量抽到共享模块并提供 `verify_obo_token`(`entry_authz_service.py:379-426` 的对偶),注册为实现。 + - `register_hosted_app_declaration_port(port)` / `register_access_subject_verifier(port)`——进程启动期注册(**API 进程 lifespan 即可**:本面只在 API 进程执行,C8 无 worker 侧)。 +- **hosted_app 分支**(`principal.actor_kind == "hosted_app"`;F055 T055 已在 `OpenApiPrincipal.actor_kind` Literal 与 `SUBJECT_RESOLVERS` 落地 —— **应用标识取 `principal.subject_ref`(= `app.id` uuid),不是 `actor_name`**,后者是应用显示名,见 §4.2 ④):范围 = `declared ∩ C`;`/models` 只返回交集;请求模型 ∈ `C` 但 ∉ `declared` → 26515(与 26512 / 26513「已收回」可区分,AC-34);`declared` 中的模型已下线 → 26512(F055 AC-53 据此在发布面标「已失效」)。subject:请求头 `X-BiSheng-Access-Token` 存在且验签有效 → `subject_kind="user"`, `subject_id=user_id`;不存在 → `subject_kind="app_self"`, `subject_id=None`(显式标注、不拒绝,spec 决议-5);存在但无效 → **拒绝** 26504(伪造 / 过期的访问凭据不能落成「应用自身」——那会让归属可被操纵)。 +- **服务账号分支**:请求头 `X-BiSheng-Access-Token` **存在即 26504**(AC-22,无论值是否有效);`X-On-Behalf-Of` 由依赖层拒(26004 / 26005,K1),但 **`X-End-User` 依赖层会静默放行**(坑 18)——故 `resolve_range_and_subject` 的**第一步**就是 `if headers.get("X-End-User") is not None: raise ModelFaceIdentityHeaderRefusedError()`(26505),对全部 `actor_kind` 一视同仁(托管应用同样不承载委托)。 +- **判定顺序固定**(可测试):① 26505 身份头拒 → ② 26504 访问凭据拒(服务账号附带 / 托管应用验签失败)→ ③ 范围确立(26516 / 26515)→ ④ 名称解析(26511–26514)。先拒后判,保证「带了不该带的头」永远不会因为模型名恰好也错而收到 26511,agent 的纠错顺序才稳定。 +- **原因**:范围与 subject 都不经任何请求头字段决定(决议-5);Port 让 F051 今天就能把 hosted_app 分支写完并用 fake port 测通(T022–T025),F055 / F054 落地时只注册实现、不改本面;默认实现 fail-closed 保证「F055 没接、凭据却先出现了」不会放行。 +- **何时该重新考虑**:F055 决定托管应用凭据的 `scopes` 不含 `model:invoke`(那时 26003 先于本策略生效,本策略永不触发——无害)。 + +### D8:请求翻译与流式组装 = pydantic `extra="allow"` 白名单透传 + `convert_to_messages` + `llm.bind(tools, tool_choice)` + 手工 chunk 组装 + +- **请求模型** `ChatCompletionRequest`(`open_api/domain/schemas/model_gateway.py`):`model: str`、`messages: list[dict]`(≥ 1)、`stream: bool = False`、`stream_options: {include_usage: bool} | None`、`temperature / top_p / max_tokens / max_completion_tokens / stop / n / presence_penalty / frequency_penalty / seed / response_format / tools / tool_choice / parallel_tool_calls / user`,`extra="allow"`(AC-18「超出枚举的字段原样透传但不作承诺」——未知字段收进 `model_extra` 一并作为 `**kwargs` 透传给 `astream / ainvoke`)。**硬拒**:`n > 1`(`BishengLLM` 单候选)→ 26503;`model` 空 → 26503。**不改写**任何采样参数、不注入 system 提示(AC-17);`messages` 原样经 `langchain_core.messages.utils.convert_to_messages`(识别 `system / user / assistant(+tool_calls) / tool(tool_call_id)` 字典,多模态 content 数组原样保留)。 +- **实例化**:`LLMService.get_bisheng_llm(model_id=resolved.model_id, app_id="model_gateway", app_type=ApplicationTypeEnum.MODEL_GATEWAY(新增枚举值 `"model_gateway"`), app_name=f"model_gateway:{principal.actor_kind}:{principal.actor_id}", user_id=principal.effective_user_id or principal.resource_owner_user_id or 0, streaming=req.stream, temperature=req.temperature, …)`——`user_id` 取法与 `open_endpoints/domain/utils.py:19-26 _principal_user_id` 一致(服务账号 → 资源归属人,遥测按人可追溯)。`tools` 存在 → `llm = llm.bind(tools=req.tools, tool_choice=req.tool_choice)`(`BishengLLM.bind_tools` 会再 `convert_to_openai_tool`,OpenAI 形状的 dict 进出不变)。 +- **非流式**:`await llm.ainvoke(messages, **kwargs)` → `AIMessage` → `chat.completion`:`id="chatcmpl-"+uuid`、`choices[0].message = {role:"assistant", content, reasoning_content?, tool_calls:[{id, type:"function", function:{name, arguments(JSON 字符串)}}]}`、`finish_reason = "tool_calls" if tool_calls else "stop"`(上游给了 `length` / `content_filter` 则透传 `response_metadata.finish_reason`)、`usage = {prompt_tokens, completion_tokens, total_tokens}`(`parse_token_usage` 的 `ChatResult` 分支要求——用 `agenerate` 路径拿 `ChatResult`,或对 `AIMessage.usage_metadata` 直接取,两者数值一致,实现取后者但**同一份 `get_token_from_usage`**)。 +- **流式**:`StreamingResponse(gen(), media_type="text/event-stream", headers={"Cache-Control":"no-cache","X-Accel-Buffering":"no"})`;`async for chunk in llm.astream(messages, **kwargs)`: + - 首块先发 `delta={"role":"assistant","content":""}`;文本 → `delta.content`;`reasoning_content`(`extract_reasoning_content(chunk)`)→ `delta.reasoning_content`(DeepSeek / Qwen 思考模型的事实扩展键,官方客户端忽略);`chunk.tool_call_chunks` → `delta.tool_calls=[{index, id?, type:"function", function:{name?, arguments}}]`(**index 由本面按 tool_call 出现顺序分配并稳定**,坑 6); + - 记住**最后一个 chunk**;结束时 `finish_reason`(`tool_calls` / `stop` / 上游 `length`)作为独立 chunk;`stream_options.include_usage` 为真 → 追加 `choices=[]` + `usage` 的 chunk(`parse_token_usage(last_chunk)`,K8);最后 `data: [DONE]`; + - 中途异常 → `data: {"error": {…26534 或分类后的上游码…}}` 一行 + `data: [DONE]`,生成器 `return`(AC-16 不挂起);客户端断开(`request.is_disconnected()` 或 `asyncio.CancelledError`)→ 记录 `result="client_disconnected"`,不重试。 +- **原因**:`BishengLLM` 的 `_astream` 已 `normalize_reasoning_content`(K6),tool_call 增量在 `AIMessageChunk.tool_call_chunks` 里齐全;手工组装比复用 `published_assistant_service` 精度高(后者无 tool_calls / usage,K11)。 +- **何时该重新考虑**:langchain-openai 提供官方「chunk → OpenAI dict」序列化(届时替换组装函数、测试不变)。 + +### D9:ModelCallRecord = 独立表 `model_call_record` + 泛化的批量异步写入器;被拒调用由既有 `open_api.call` AuditLog 承接 + +- **备选**: + - A. 写进 `audit_log`(`action="open_api.model_call"`)— K7:高频写淹审计页;`audit_log` 是 uuid 主键 + 多索引宽表,写放大在 DM8 上更糟 + - B. 加列到 `llm_call_log` / `llm_token_log` — 两表无 key / app / subject 维度且各有既定 owner;release-contract 表 1 已单独登记 ModelCallRecord + - C. **新标准表**(`create_all` 建,无 Alembic)+ **`BatchedRecordWriter[T]`**(把 `OpenApiCallAuditService` 的队列 / 批 / 定时 / 关停逻辑抽成泛型基类 `open_api/domain/services/batched_writer.py`;`OpenApiCallAuditService(BatchedRecordWriter[AuditLog])` 保持公开名与常量、行为不变;`ModelCallRecordWriter(BatchedRecordWriter[ModelCallRecord])` 用 `ModelCallRecordRepository.ainsert_batch`);`main.py` lifespan 起停两者(`:108 / :154` 旁) +- **选定**:**C**(全自动模式定案) +- **表**(`open_api/domain/models/model_call_record.py`,`SQLModelSerializable`):见 §4.2 ④。**注册方式是坑**:`_TENANT_AWARE_MODEL_MODULES` 登记的是**包** `"bisheng.open_api.domain.models"`(`core/database/tenant_filter.py:107`),`_force_import_all_models` 只 import 包 `__init__`——`__init__.py` 今天显式 import 四个模块并列 `__all__`;新模块**必须**加进 `__init__.py` 的 import 与 `__all__`,否则 `create_all` 看不到这张表(升级不建表)、租户过滤也不发现它(坑 17)。`create_time` 用 `server_default=text("CURRENT_TIMESTAMP")`(同 `api_credential.py:82`,双 DB 已验证),不用 `func.now()`。索引:`ix_mcr_tenant_time (tenant_id, create_time, id)`、`ix_mcr_credential_time (credential_id, create_time)`、`ix_mcr_app_time (app_id, create_time)`——AC-24 三个筛选器各命中一条;服务账号 / 模型 / token 是记录列不建索引。 +- **写入时机**:解析成功后 `record = ModelCallRecord(…result=None)` 先构造;调用结束(成功 / 上游失败 / 客户端断开)填 `result / tokens / latency_ms / ttft_ms / error_code` 后 `writer.enqueue(record)`;解析失败(26511–26516)也 `enqueue`(`result="model_unavailable"`,`requested_model` 保留、`model_id` 空)——AC-20「到达模型解析阶段…无论成功、上游失败或模型不可用」。**在解析前被拒**(401 / 403 / 26051)不写本表:中间件的 `open_api.call` 行已带 `credential_id / actor / scope / error_code`、`target_id="POST /api/v2/model/v1/chat/completions"`(`middleware.py:102-133`),即 AC-20 的「被拒调用审计事件」。 +- **队列满 / 写失败**:`logger.error("open_api.model_call_record.write_failed | reason=…")` + `emit_metric("model_call_record", status="dropped")`——不阻塞请求、不静默(C8 末行)。队列上限 5000、批 200、1s(高于审计的 1000 / 100:本面每请求恰一行、体积小)。 +- **查询 API**(供 F056):`ModelCallRecordRepository.alist(tenant_id, *, credential_id=None, app_id=None, time_from=None, time_to=None, cursor=None, limit=100)`,排序 `(create_time DESC, id DESC)`(坑 9),游标 = `(create_time, id)`;`aiter_export(...)` 同条件生成器(F056 导出用)。本 Feature **不提供 HTTP 查询端点**(AC-24 的管理面接线归 F056)。 +- **何时该重新考虑**:单租户日调用 > 1e6 行(那时按月分表或转 ES,读接口不变)。 + +### D10:token 口径 = MODEL_INVOKE 遥测同源;ModelCallRecord 不写 `llm_token_log`;未知 = NULL + +- **备选**:写 `LLMTokenTracker.record_usage`(`token_tracker.py:38-72`,`user_id` 必填)— 它是工作流回调专用账本(K8),本面再写就是第三处;且服务账号无 `user_id`,只能借归属人。 +- **选定**(全自动模式定案):`BishengLLM` 包装器自动写 MODEL_INVOKE 遥测(`app_type=MODEL_GATEWAY`,AC-23「计入平台既有 token 用量口径」由此成立);ModelCallRecord 的 `prompt_tokens / completion_tokens / total_tokens` 从**同一个最终结果对象**经 `parse_token_usage` 取(非流式 = `AIMessage`,流式 = 最后一个 chunk,与 `utils.py:389-408` 的 `result=item` 完全一致);`total_tokens == 0 and prompt_tokens == 0` → 三列写 **NULL**(未知,AC-23),不估算。已知口径差异:遥测无「未知」概念、会记 0——登记为坑 5,不改遥测。 +- **`llm_token_log` 不写**;将来 v3.1 账单按 spec 决议-6 在 **ModelCallRecord 上直接聚合**(已带 key / app / subject / model 维度),不反查遥测。 + +### D11:错误码 265 段 = `common/errcode/model_face.py`,base 26500,四个子段 + +| 子段 | 用途 | 本期启用 | +|---|---|---| +| 26500–26509 | 协议面 / 端点 / 请求形状 | 26501 端点不支持 · 26502 Anthropic 路径 · 26503 请求不合法 · 26504 访问凭据不被接受(服务账号附带 / 托管应用无效)· **26505 委托类身份头在本面不被接受**(补底座对合法 `X-End-User` 的静默放行,坑 18) | +| 26510–26529 | 可用范围与名称解析 | 26511 不存在 · 26512 已下线 · 26513 已收回(服务商删除)· 26514 裸名歧义 · 26515 能力未声明 · 26516 目录不可判定(503)· 26517 服务商日上限(429) | +| 26530–26549 | 上游与流式 | 26531 上游失败(502)· 26532 上游拒绝(透传状态)· 26533 上游限流(429)· 26534 流式中断(仅 SSE 事件) | +| 26550–26559 | 记录与账本(预留) | 本期无对外码;写入失败只记日志 / 指标 | + +- 基类 `ModelFaceError(OpenApiAuthError)`——**继承 `OpenApiAuthError` 是为了白吃 `http_status` 属性与 `open_api_http_status()` 的 `issubclass(OpenApiAuthError)` 分支**(`exception_handlers.py:54-55`),不是语义上的"鉴权错误";额外类属性 `openai_type: str`、`openai_code: str`(D4 表)。`Code: int = 265xx`(K13)。26532 的 `http_status` 在实例上覆盖(基类 `__init__` 已支持 `http_status=` kwarg,`open_api.py:11-21`)。 +- 三语文案 `locales/api_errors/{zh-Hans,en,ja}.json`(**文件名是 `en.json`**,与 `platform/public/locales/en-US/` 的目录命名不同,别找错)同 PR;`docs/constitution.md` C5 表 26x 行加 `265 model_face` + 子段说明;`release-contract.md`「已分配模块编码」加 265 行(T001)。**权限位本身的三语文案不必补**:`openApiManagement.scopes.model_invoke.{label,desc}` 已在 `platform/public/locales/{zh-Hans,en-US,ja}/bs.json` 三语齐备(F049 登记位时随手写全了),D12 翻 `issuable` 后签发表单直接有文案。 + +### D12:`model:invoke` 翻 `issuable=True` + 端点登记 + 客户契约再生成 + +- `open_api/domain/scopes.py:139-147`:`endpoints=(("POST", "/api/v2/model/v1/chat/completions"), ("GET", "/api/v2/model/v1/models"), + catch-all 五条)`、删 `issuable=False` 与注释;`requires_open_platform=True` 保持(开关未开时签发表单不出现,AC-28 反向「无面无位」)。 +- **受影响测试的完整清单(必须同批改,逐条 grep 核实过)**: + 1. `test/open_api/test_scope_issuability.py:66-69` — `@pytest.mark.parametrize("scope", ["model:invoke", "identity:read"])` 去掉 `model:invoke`,另加「开关开启即可签发」正向断言; + 2. 同文件 `:176` — `{"model:invoke","identity:read"}.isdisjoint(codes)` 改 `"identity:read" not in codes and "model:invoke" in codes`; + 3. 同文件 `:4` — 模块 docstring「``model:invoke`` / ``identity:read`` stay unissuable (F051 / F052 pending)」改只留 `identity:read`; + 4. `test/open_api/test_scopes.py:44-51` `test_only_app_manage_becomes_issuable_with_open_platform` — **函数名要改**(`test_app_manage_and_model_invoke_become_issuable_with_open_platform`),且 `:49` 与 `:50`(`codes == ALWAYS_ISSUABLE_OPEN_API_SCOPE_CODES | {"app:manage"}`)**两条断言都会红**; + 5. 同文件 `:35-41` `test_extension_scopes_are_not_issuable_without_open_platform` — **不用改**:`requires_open_platform=True` 保留,开关关闭时 `issuable_scopes()` 仍把它挡掉(`scopes.py:201`); + 6. `test_open_api_route_matrix.py` 与 `test_openapi_schema_contract.py:34-56` — 见 D3 末段的「期望集合剔除」方案;契约 json 重跑 `features/v3.0.0-beta1/053-openapi-auth-and-identity/generate_openapi_contract.py` 再生成,**并把本面三条操作的 OpenAI 形状 response_model 挂上**(`GET /models` 用 `ModelList`,`POST /chat/completions` 用 `ChatCompletionResponse`;流式分支靠 `responses=` 补 `text/event-stream` 描述)。 + +### D13:上限与限流 = 沿用服务商 `limit_flag / limit`(typed 异常);不新增任何闸 + +- `utils.py:126 / :136` 的裸 `Exception("… Quota used up")` 改为新类 `LlmProviderDailyLimitExceededError(Exception)`(同 message、同基类——既有 `except Exception` 调用方零感知),本面据类型映射 26517(429,`Retry-After` 不给:日限额到次日零点,写进 message)。**跨 Feature 副作用**:改的是 `llm` 域共享 util,登记 §6 / tasks 表。 +- 不做 per-key / per-app 限流、不做 token 上限(AC-15 / AC-25)。 + +### D14:日志与脱敏 = 结构化 `model_gateway.call` 一行 / 请求;密钥 / 服务商配置 / 消息正文三不进 + +- 端点出口 `logger.bind(event="model_gateway.call").info(...)` 字段:`credential_id / actor_kind / actor_id / tenant_id / requested_model / model_id / server_id / is_stream / result / error_code / prompt_tokens / completion_tokens / latency_ms / ttft_ms / trace_id`。**不记** `Authorization`、`X-BiSheng-Access-Token`、`messages`、`tools` 内容、上游 URL / key(`LLMServer.config`)。错误 message 进响应前经 `redact_secrets(text)`(正则抹 `sk-…` / `bs-sak-…` / `Bearer …`),防服务商 SDK 把 key 回显进异常文本。 +- ModelCallRecord `credential_mask` 用 `ApiCredential.key_mask`(前缀 + 末四位,INV-28);`OpenApiPrincipal` 不带掩码且不为此改 beta2 契约,故请求路径入队时留空、写入器在 `write_batch` 前按 `credential_id` **批内一次 `IN` 查询水合**(在 `bypass_tenant_filter()` 下——写入器无请求 ContextVar、批内行跨租户,§2 C3),热路径零额外 DB 读。 + +--- + +## 4. 系统现状(接手必读) + +> 本节写"落地后代码长什么样";未开工前是目标形态,实现后按现状覆盖。 + +### 4.1 数据流 + +**对话补全(流式)**: +`POST /api/v2/model/v1/chat/completions` → `router_rpc` 依赖 `verify_open_api_access`(`dependencies.py:57-163`:凭据 → 租户 ContextVar → marker → `delegate` 拒 26051 → 位判定 26003 → 身份头拒 → `PermissionActor`)→ 端点 `chat_completions`(`open_api/api/endpoints/model_gateway.py`,`@open_api_scope("model:invoke", modes=("S",))`,`Depends(get_open_api_execution)`)→ `ModelGatewayService.complete(principal, request, req)`(`open_api/domain/services/model_gateway_service.py`):① `ModelRangePolicy.resolve_range_and_subject(principal, headers)` → `(range, subject)`(D7 的四步判定序:`X-End-User` → 26505 / 服务账号附带访问凭据 → 26504 / 范围不可判定 → 26516 / 未声明 → 26515)② `model_catalog.resolve_model_name(tenant_id, req.model, range=range)`(D5 / D6;失败 → 26511–26516,仍 `enqueue` 记录)③ `LLMService.get_bisheng_llm(...)`(`limit_flag` 超 → 26517;10009 / 10010 / 10012 / 10013 → 26513 / 26512 / 26531)④ `bind(tools)` → `astream` → SSE 组装(D8)⑤ 结束 / 异常 / 断开 → `ModelCallRecordWriter.enqueue(record)` + 结构化日志(D14)→ `OpenApiAuditMiddleware` 另写一行 `open_api.call`(K7;SSE 尾部解析对本面无 `event:close` 结构、`sse_final_result` 恒 `success`——坑 10)。 + +**模型列表**:`GET /api/v2/model/v1/models` → 同一依赖 → `ModelRangePolicy.resolve_range_and_subject`(同样的 26505 / 26504 前置拒绝)→ `model_catalog.list_callable_chat_models(tenant_id)` → 按 range 过滤 → `ModelList`(D5 命名规则)。不写 ModelCallRecord(列表不是调用)。 + +**承诺面之外**:`/api/v2/model/v1/{rest}` → 同一依赖(无凭据仍 401)→ 26501 / 26502(D3)。 + +**开关关闭**:路径不存在 → 404 `{"detail":"Not Found"}`(D3)。 + +### 4.2 关键数据结构 / 字段约定(对外契约) + +① **端点**(base URL = `{public_base_url}/api/v2/model/v1`) + +| 方法 路径 | 位 / 模式 | 请求 | 响应 | +|---|---|---|---| +| `POST /chat/completions` | `model:invoke` / S | `ChatCompletionRequest`(D8) | `stream=false`: `ChatCompletionResponse`;`stream=true`: `text/event-stream`,每行 `data: {chat.completion.chunk}`,末 `data: [DONE]` | +| `GET /models` | `model:invoke` / S | — | `{"object":"list","data":[{"id","object":"model","created","owned_by","bisheng_model_type","bisheng_qualified_name"}]}` | +| `* /{rest}` | `model:invoke` / S | — | 404 OpenAI 错误体(26501 / 26502) | +| `GET /api/v2/auth/whoami`(既有) | `None` | — | 新增 `model_base_url: str` | + +② **错误体**:`{"error":{"message","type","code","param","bisheng_code"}}`,映射表见 D4;HTTP 状态真实。流式中途错误:`data: {"error":{…}}\n\n` + `data: [DONE]\n\n`。 + +③ **环境变量名**(本 Feature 定名;F053 `dev` 与 F054 runtime-manager 注入):`OPENAI_BASE_URL`、`OPENAI_API_KEY`、`BISHENG_MODEL_BASE_URL`。托管应用凭据变量名 `BISHENG_APP_TOKEN` 归 F055 D13(本面对其值只当 Bearer)。 + +④ **表 `model_call_record`** + +| 列 | 类型 | 说明 | +|---|---|---| +| `id` | BigInteger PK autoincrement | | +| `tenant_id` | Integer NOT NULL | 密钥租户(自动过滤) | +| `credential_id` | BigInteger NOT NULL | `api_credential.id` | +| `credential_mask` | VARCHAR(32) | `bs-sak-********xxxx` | +| `actor_kind` / `actor_id` / `actor_name` | VARCHAR(32) / BigInteger / VARCHAR(128) | 服务账号 / 自然人 / 托管应用。**`hosted_app` 的语义按 F055 T055 `resolve_hosted_app` 回填(2026-09-16 核实)**:`actor_id = hosted_app_subject.id`(每应用一行的代理主键,不是 `app.id`、也不是 owner 的 user id)、`actor_name = app.name`(**显示名**)、`resource_owner_user_id = app.owner_user_id`。应用的机器标识只有一个来源 —— `principal.subject_ref = app.id`(uuid),落到下一行的 `app_id` 列 | +| `resource_owner_user_id` | BigInteger NULL | 归属人(AC-20 可追溯) | +| `app_id` | VARCHAR(64) NULL | 托管应用(AC-21)。值 = `principal.subject_ref`(`app.id` uuid);**声明读取、访问凭据验签、`ix_mcr_app_time` 查询三处都键在它上面**,写成 `actor_name` 会让三者同时指向一个显示名 | +| `subject_kind` / `subject_id` | VARCHAR(16) / BigInteger NULL | `service_account` \| `natural_person` \| `user` \| `app_self` | +| `requested_model` | VARCHAR(255) | 请求原文 | +| `model_id` / `server_id` | Integer NULL | 解析结果(失败为空) | +| `model_name` / `server_name` / `server_type` | VARCHAR(255) / VARCHAR(255) / VARCHAR(20) NULL | | +| `is_stream` | Boolean | | +| `prompt_tokens` / `completion_tokens` / `total_tokens` | Integer NULL | NULL = 未知 | +| `result` | VARCHAR(32) | `success` \| `upstream_failed` \| `model_unavailable` \| `limit_exceeded` \| `capability_undeclared` \| `client_disconnected` | +| `error_code` / `http_status` | Integer NULL / Integer | 平台码 / 对外状态 | +| `latency_ms` / `ttft_ms` | Integer / Integer NULL | | +| `request_id` / `trace_id` | VARCHAR(64) / VARCHAR(64) | `chatcmpl-…` / `trace_id_var` | +| `create_time` | DateTime NOT NULL `server_default=text("CURRENT_TIMESTAMP")` | 索引见 D9 | + +⑤ **`llm` 域公开函数**(三处同源):`list_callable_chat_models(tenant_id) -> list[CallableModel]`、`resolve_model_name(tenant_id, requested, *, range: ModelRange | None = None) -> ResolvedModel`(异常 = D11 的 26511–26516 类)、`CallableModel.qualified_name`。 + +⑥ **Port**:`HostedAppDeclarationPort`、`AccessSubjectVerifierPort` 与注册函数(D7);请求头 `X-BiSheng-Access-Token`。 + +⑦ **新 Settings 键**:`open_api.model_catalog_ttl_seconds: int = 30`(上限 60)。加在既有 `open_api` 子键下——**不是**新顶级键,老镜像不受影响。 + +### 4.3 关键模块职责 + +| 模块 / 文件 | 职责 | 不做什么 | +|---|---|---| +| `open_api/api/endpoints/model_gateway.py` | 三条路由、marker、`StreamingResponse` 头、把 `Request` 交给 service | 不解析模型、不组装 chunk | +| `open_api/domain/services/model_gateway_service.py` | 编排:范围 → 解析 → 实例化 → 调用 → 组装 → 记录 | 不查 ORM、不判凭据 | +| `open_api/domain/services/openai_codec.py` | OpenAI ⇄ langchain 翻译(messages、tools、chunk / response 组装、usage) | 无 I/O | +| `open_api/domain/services/model_range_policy.py` | 按 `actor_kind` 决定范围与 subject;Port 定义与注册 | 不读 manifest(F055 实现 Port) | +| `open_api/domain/services/model_call_record_writer.py` + `batched_writer.py` | 批量异步写;泛型基类 | 不做查询 | +| `open_api/domain/repositories/model_call_record_repository.py` | `ainsert_batch / alist / aiter_export` | 无业务判断 | +| `open_api/domain/models/model_call_record.py` | 表定义 | | +| `open_api/domain/schemas/model_gateway.py` | `ChatCompletionRequest / Response / Chunk / ModelList / OpenAIError` | | +| `open_api/api/exception_handlers.py` | 本面前缀的 OpenAI 错误体分支 `render_openai_error` | 不改其它 v2 路径 | +| `llm/domain/services/model_catalog.py` | 目录、缓存、名称解析(三处同源) | 不知道凭据 / 主体 | +| `llm/domain/services/llm.py` | 抽 `acollect_visible_server_ids`;`get_all_llm` 改调 | 行为不变 | +| `common/errcode/model_face.py` | 265 段 | | + +--- + +## 5. 已知坑 / 反直觉事实 + +| # | 反直觉事实 | 如果不知道会怎样 | 在哪处理 | +|---|---|---|---| +| 1 | 现存两个 v2 `chat/completions` 都不是模型直连:`open_endpoints/api/endpoints/assistant.py:35-36` 的 `model` 字段是 **assistant_id**(`UUID(req_data.model)`),`workstation.py:27` 是日常会话(建会话、持久化) | 拿它们"顺手改一改"当模型面 → 建会话、留正文,AC-17 / AC-19 全破 | D1 新路径、不碰两者 | +| 2 | `aget_shared_server_ids_for_leaf` FGA 异常时**返回空列表**(`llm_server.py:508-510`),前端列表只是少显示 Root 共享服务商 | 本面照抄 → FGA 抖动期间子租户密钥调 Root 共享模型得到 26511「不存在」,看起来像配置错;AC-35 要求 503 拒绝 | D6 `raise_on_error=True` | +| 3 | 服务商日上限超出是**裸 `Exception`**(`utils.py:126`)且发生在生成器包装器 `bisheng_model_limit_check` 里——`astream` 第一次迭代时才抛 | 按异常类型分类做不了;流式已发 `200` 头后才炸 → 客户端收到半截 | D13 typed 异常;D8 流式采用**"预取首块"**:端点先 `agen = llm.astream(...)`、`first = await anext(agen)`,此步抛 26517 / 26511 族 / 上游 4xx 时以普通 JSON 错误体返回(真 HTTP 状态),成功后才构造 `StreamingResponse`,生成器先 yield 已预取的 `first` 再续 `agen`。**不要**在外面再调一次 `bisheng_model_limit_check`——包装器内已 `INCR`,会计两次 | +| 4 | `BishengLLM` 遥测必填 `app_id / app_type / app_name / user_id`(`base.py:21-25`),`ApplicationTypeEnum` 无本面成员 | 借用 `DAILY_CHAT` 之类会把本面用量混进工作台统计 | D8 新增 `MODEL_GATEWAY` | +| 5 | 流式遥测的 usage 只看**最后一个 chunk**(`utils.py:382-409`),且遥测没有「未知」——取不到就记 0 | ModelCallRecord 若从累加或首块取,与遥测两套口径(AC-23 破);反过来若照遥测记 0,AC-23「未知≠0」破 | D10:同一最后 chunk;记录侧 0 → NULL;差异登记 | +| 6 | langchain `AIMessageChunk.tool_call_chunks` 的 `index` 在部分服务商(qwen / zhipu 经各自 SDK)为 `None`,且同一 tool_call 的 `id` 只在首块出现 | 客户端按 `index` 聚合 arguments,`None` → 全部拼到一个调用里,多工具调用错乱 | D8 本面按首次出现顺序分配并缓存 `index`;测试用双工具调用夹具 | +| 7 | `open_api_scope` 的 `modes` 默认 `("S","D")`(`scopes.py:227`)——本面**必须显式 `modes=("S",)`**;另外 `LOCAL_DEV_TOOLKIT_SCOPE_CODES` 的 26051 判定看的是 `marker.scope`,catch-all 路由若标 `None` 就不会拒 `delegate` 密钥 | 持 `delegate` 的密钥打承诺面之外路径得到 26501 而非 26051(AC-26「不得只回参数错误码」) | D3 catch-all 也标 `model:invoke` | +| 8 | `_register_v2_handler` 的 `dispatch` 先于 `open_api_auth_exception_handler` 注册,且 `app.add_exception_handler(OpenApiAuthError, …)` 是**单独**一条(`exception_handlers.py:104`)——本面前缀分支要在**两处**都判 | 只改 `dispatch` → 401 / 403(依赖层抛的 `OpenApiAuthError`)仍是信封体,官方客户端报 "unexpected response" | D4 两处同判 | +| 9 | `create_time` 秒精度,同秒多行无序(backend AGENTS.md 已知陷阱) | 游标分页重复 / 漏行;导出与列表不一致 | D9 排序 `(create_time DESC, id DESC)`,游标含 `id` | +| 10 | `OpenApiAuditMiddleware._record_sse_result` 只认平台 SSE 的 `event:close` 结构(`middleware.py:71-95`),对本面 chunk 恒判 `success` | 审计页 `sse_final_result` 对本面永远 success,错误看 ModelCallRecord 才对 | 本面在中途错误时 `scope["open_api_error_code"]=码`(`mark_open_api_error`),审计行 `error_code` 至少正确 | +| 11 | `test_openapi_schema_contract.py:34-56` 断言 v2 全部 HTTP 操作 == `openapi-v2-key-auth-api.json` | 新路由不再生成契约 → CI 红;且 catch-all `{rest}` 会以五个方法进契约 | D12 再生成;契约 md 按类别文档补「模型协议面」章 | +| 12 | `check-i18n.mjs:106` 只匹配 `Code:\s*int\s*=`;260 段没被校验 | 抄 `open_api.py` 写法 → 漏三语不报错、线上英文兜底 | K13 | +| 13 | `settings.open_platform.enabled` 在 `api/router.py` **import 时**求值(`:140`)——测试里 `monkeypatch` 开关不会重新挂 router;`test_open_api_route_matrix.py:4` 与 `test_openapi_schema_contract.py` 都 `from bisheng.main import app`,单测进程默认开关**关闭**(`OpenPlatformConf.enabled` 默认 `False`,`open_platform.py:9`) | 单测以为"开关关了本面 404"其实 router 早挂上;反过来两个既有集合断言在开关关闭的进程里会因「登记有、实际无」直接红 | D3 末段:期望集合按 `MODEL_GATEWAY_PATH_PREFIX` 剔除;T012 用 `importlib.reload` + 独立 app 工厂验证两态 | +| 14 | `resolve_public_base_url` 的 Host 回退会按进程记一次 warning(`public_base_url.py:64-74`),文案写死"Skill packs" | 本面触发时日志误导运维 | T020 文案泛化为"外部客户端地址" | +| 15 | `RequestValidationError` 在依赖之前发生,`_authenticate_parse_failure`(`exception_handlers.py:133-147`)会重跑鉴权 | 本面无凭据 + 坏 body → 期望 401 不是 400;实现别把 400 分支放在鉴权前 | D4 沿用该顺序 | +| 16 | `X-BiSheng-Access-Token` 由 app-proxy 注入到**应用**(`entry_authz_service.py:175`),不是浏览器 → 本面调用时它必须由应用代码显式转发(SDK / 技能包教) | 应用不转发 → 全部 `app_self`,审计失去用户维度但不报错(spec 决议-5 允许) | F057 SDK / F053 技能包(§6.1 提醒) | +| 17 | `_TENANT_AWARE_MODEL_MODULES` 对 `open_api` 登记的是包名(`tenant_filter.py:107`,注释原文就写着 "package-level registration — the package `__init__` imports …"),发现靠 `open_api/domain/models/__init__.py` 的显式 import 链 | 新表文件写好、`metadata` 里却没有它:`create_all` 不建表,首次写入 `ProgrammingError: Table doesn't exist`,且租户过滤静默不覆盖 | D9 / T003:`__init__.py` 加 import + `__all__`;`test_database_contract.py` 风格断言表在 `SQLModel.metadata.tables` | +| 18 | **合法值的 `X-End-User` 单独出现时,底座不拒、静默采信**:`assert_no_removed_identity_headers` 把 `X-End-User` 列进 `allowed`(`identity_service.py:26`),`parse_identity_headers` 只校验格式(非法才 26018,`:55-57`),`resolve_request_identity` 走 `target_id is None` 分支后 `return principal.model_copy(update={"end_user_id": external_user_id})`(`:71-74`)——**没有任何拒绝**。同理 26016 只在「持 `delegate` 却不带 `X-On-Behalf-Of`」时抛,本面根本到不了(26051 更早) | 照抄「鉴权白吃」的结论 → AC-27「不得静默忽略该头继续执行」直接破;而且它破得很安静:请求正常返回 200,只是 `end_user_id` 被应用方随手指定了一个值 | D4 / D7:本面自己在 `resolve_range_and_subject` 第一步拒 **26505**;T012 有专门用例 | + +--- + +## 6. 对外契约与依赖 + +### 6.1 我提供给别人的(Outgoing) + +| 契约 | 形式 | 谁在用 | +|---|---|---| +| base URL `{origin}/api/v2/model/v1` + `whoami.model_base_url` + 环境变量名 `OPENAI_BASE_URL / OPENAI_API_KEY / BISHENG_MODEL_BASE_URL` | HTTP / 字段 / 命名 | F053 接入信息区(AC-44)、`bisheng dev`(AC-27)、技能包模型一节(AC-17);F054 runtime-manager 注入清单(contracts §5 **须回写加入三名**);F055 T056;F057 指南 | +| `POST …/chat/completions`、`GET …/models`、错误体(D4) | OpenAI 兼容 HTTP | 本地引擎、托管应用(`openai` 官方客户端) | +| `llm.domain.services.model_catalog.{list_callable_chat_models, resolve_model_name}` | Python API | F052 模型清单工具(AC-13)、F055 预检 T060(AC-07) | +| `ModelCallRecordRepository.alist / aiter_export`、表 `model_call_record` | Python API / 表 | F056 审计查询面(AC-25) | +| `HostedAppDeclarationPort` / `AccessSubjectVerifierPort` + 注册函数;头名 `X-BiSheng-Access-Token` | Python Protocol | F055 T056(注册声明读取)、F054(注册 OBO 验签)、F057 SDK / F053 技能包(应用侧转发头) | +| `LlmProviderDailyLimitExceededError`、`LLMService.acollect_visible_server_ids`、`aget_shared_server_ids_for_leaf(raise_on_error=)` | Python API(`llm` 域改动) | 既有调用方零感知;新调用方可用 | +| `ApplicationTypeEnum.MODEL_GATEWAY` | 遥测枚举 | 统计页按应用类型筛选(ES 事件侧;`emit_metric` 指标行不带该维度,§7) | +| **模型能力的运行期错误码归属**:模型的「已收回」= 26512 / 26513、「未声明」= 26515,**一律走 265 段**;F055 的 `16273`(能力已被收回)/ `16274`(未在能力声明中的能力,`055 design` §错误码表 16270–16289)**只用于知识库等非模型能力** | 错误码边界 | F055 T058(发布面「已失效」按需计算时读本面规则)、F052(工具面报错口径) | + +### 6.2 我依赖别人的(Incoming) + +| 依赖 | 形式 | 风险点 | +|---|---|---| +| beta2 F053 底座:`router_rpc` 依赖、`@open_api_scope`、`OpenApiPrincipal`、26003 / 26051 / 头拒绝、凭据缓存 ≤ 5s、`resolve_public_base_url`、`OpenApiCallAuditService` | 代码 | marker 语义变化(如 `modes` 默认)会静默放宽本面;坑 7 | +| `LLMService.get_bisheng_llm` / `BishengLLM` / `parse_token_usage` / `LLM_CACHE` 60s | 代码 | K5 / K6;服务商新增类型自动获得 | +| **F055 T055**:`hosted_app` 主体(CHECK 放宽迁移 + `SUBJECT_RESOLVERS` + `OpenApiPrincipal.actor_kind` Literal 扩) | 代码 | **✅ 已落地(2026-09-16 核实)**:`app_publish/domain/services/app_credential_service.py:resolve_hosted_app`,经 `app_publish/composition.py:register()` 注册 | +| **F055 T056**:注册 `HostedAppDeclarationPort` 实现;凭据 `scopes` 含 `model:invoke`;`BISHENG_APP_TOKEN` 注入 | 代码 / 契约 | **✅ 已落地**:`capability_bus_service.HostedAppDeclarationAdapter` + `derive_scopes` + `runtime_capability_env`。未注册 → 默认 fail-closed 26516(不是放行) | +| **F054**:OBO 验签实现(`verify_obo_token`)注册为 `AccessSubjectVerifierPort`;runtime-manager 注入 `OPENAI_BASE_URL` 等三名 | 代码 / 契约 | **✅ 验签已落地**:`app_runtime/domain/services/entry_authz_service.py:verify_obo_token` + `AccessSubjectVerifier`,同样由 F055 组合根注册(两个 Port 必须同进同退)。未注册 → 全部 `app_self`;未注入 → 应用需自拼。⚠️ **入口侧仍是 fail-open**:secret 缺失或与 `jwt_secret` 相同时 `_issue_obo_token` 不签、只记一条日志,应用会收到匿名访问 —— 该收紧归 F054(其代码注释已自记「OBO 有了消费方就要改 fail-closed」),本面这边的表现是全部落 `app_self` | +| F056:查询面与导出接线 | 前端 / 端点 | 本面只保证 repository 与索引 | +| 开关 `open_platform.enabled`、`open_api.public_base_url` | 配置 | 商业版网关形态必须配 `public_base_url`(否则 Host 回退可能是内网地址) | + +--- + +## 7. 测试与可观测 + +- **单测(无中间件)**:`test/open_api/test_model_gateway_{auth,errors,resolution,stream,record,switch}.py` + `test/llm/test_model_catalog.py`;fake `BishengLLM`(`monkeypatch LLMService.get_bisheng_llm` 返回带脚本化 `astream / ainvoke` 的对象);fake catalog rows;`fake_redis` / `open_api_db` 沿用 `test/open_api/conftest.py:28-97`。**官方客户端契约**:`openai.AsyncOpenAI(base_url=..., api_key=..., http_client=httpx.AsyncClient(transport=httpx.ASGITransport(app=app)))` 直打 ASGI,断言成功体 / 401 / 403 / 404 / SSE(含 tool_calls)能被 SDK 解析(`openai>=2.26` 已是后端依赖,`pyproject.toml:53`)。 +- **集成(CI 中间件)**:真 Redis + MySQL 下的 catalog 缓存与 60s 上界、FGA 下线 → 26516、批量写入落表与游标分页;DM8 用例在 105 回归(新表 + `(create_time, id)` 排序)。 +- **114 手动 / E2E**(tasks T026–T028):`open_platform.enabled=true` → 签一把带 `model:invoke` 的 `bs-sak-` → `curl $BASE/models` → `openai` CLI 流式 → Qwen Code 配 `OPENAI_BASE_URL` 跑一次带工具调用的任务 → Claude Code 指向 base 得 26502 → 模型管理页下线该模型,≤ 60s 新调用 26512、在途流不断 → 撤销密钥 ≤ 5s 401 → `SELECT … FROM model_call_record ORDER BY id DESC LIMIT 5` 与 `audit_log` 的 `open_api.call` 各一行。 +- **可观测**:结构化日志 `model_gateway.call`(D14);`BishengLLM` 自带两条——ES 遥测事件 `ModelInvokeEventData`(**带 `app_type`,按 `model_gateway` 筛本面**)与 `emit_metric("model_invoke", …)`(`utils.py:264-270` 的 kwargs 只有 `model_id / status / is_stream / ttft_ms / total_ms`,**没有 `app_type`,指标行筛不出本面**——要按面看走 ES 事件或 `model_call_record`,别在 Grafana 上找一个不存在的标签);新增 `emit_metric("model_call_record", status=written|dropped, batch_size=…)`、`emit_metric("model_catalog", status=hit|miss|unavailable)`。 + +--- + +## 8. 后续改进 / 不打算做的事 + +- **聚合账单 / 用量可视 / 限流 / token 上限**(v3.1):在 `model_call_record` 上聚合(已带维度),入口加闸按 OpenAI 429 语义——不改现有契约。 +- **Anthropic 面 / embeddings / Responses**:同一 base URL 下扩承诺面(决议-1),把对应路径从 catch-all 抠出。 +- **目录缓存改 Redis + 主动失效**:D6「何时该重新考虑」。 +- **不做**:把 `open_api.call` 与 ModelCallRecord 合并(K7);给模型管理页加唯一名约束(决议-2 备选 ①);`llm_token_log` 补维度(D10)。 +- **AC-25「不做聚合账单 / 用量可视 / 上限」是有测试看住的**(T017 `test_no_aggregation_or_quota_surface`):断言本面路由只有 §4.2 ① 那三条、`ModelCallRecordRepository` 无任何聚合方法、`OpenApiConf` 无 `*_limit` / `*_quota` 新键。SHALL NOT 类 AC 只写进「不打算做」章节是会漂的——半年后有人顺手加个 `/usage` 端点,没有任何东西会红。 + +--- + +## 修订历史 + +| 日期 | 改动 | 触发原因 | +|---|---|---| +| 2026-09-16 | 初版(D1–D14、坑 1–16、§4.2 契约、§6 依赖;全自动模式定案) | spec 定稿后按 HEAD `fe10f75ea` 核实代码事实编写 | +| 2026-09-16 | `/sdd-review design` 二轮(含 Constitution Check 复核):**新增 26505 与坑 18**——原文把 AC-27 整条算作「底座白吃」,实测合法值的 `X-End-User` 单独出现时底座静默采信(`identity_service.py:71-74`),AC-27 会破,改为本面自拒;D4 身份头行的四个码逐一订正(26016 在本面不可达、补 26005 / 26018);D7 增「四步判定序」;D12 受影响测试从 4 条补到 6 条(`test_scopes.py:44-51` 两条断言 + 函数名、`test_scope_issuability.py:4` docstring;并注明 `test_scopes.py:35-41` 不必改);§7 订正 `emit_metric("model_invoke")` **不带 `app_type`**(只有 ES 事件带);§2 C3 订正 before_flush 在多租户模式是「完全不填」而非填 `DEFAULT_TENANT_ID`;K11 补同文件 `:94` 的反证;§6.1 增「模型能力运行期错误码归 265、F055 16273/16274 只管非模型能力」一行;行号订正(`llm.py:419-453`、`open_api.py:11-21`);补「权限位三语文案已齐」事实 | 逐条 grep 核实 `文件:行号` 与符号;AC 逐条回读 spec | +| 2026-09-16 | `/sdd-review design` 修订:D9 表注册改为「必须 import 进 `open_api/domain/models/__init__.py`」(原文误以为包级登记自动覆盖,新增坑 17);D3 补条件挂载 vs `app:manage` 无条件挂载的差别、两个既有集合断言(路由矩阵 / OpenAPI 契约)在开关关闭进程下的处理与 fixture 真实位置;§2 C3 补写入器无 ContextVar 的显式 `tenant_id` / `bypass_tenant_filter()`;D4 身份头四码逐一落到 `identity_service.py` 行号;D7 补 `OpenApiPrincipal` 文件与第二处 Literal;`create_time` 改 `text("CURRENT_TIMESTAMP")`;行号订正(K1 / K6 / 坑 1 / 坑 5) | 逐条 grep 核实 `文件:行号` 与符号 | diff --git a/features/v3.0.0/051-model-protocol-gateway/spec.md b/features/v3.0.0/051-model-protocol-gateway/spec.md new file mode 100644 index 0000000000..a6e94907e4 --- /dev/null +++ b/features/v3.0.0/051-model-protocol-gateway/spec.md @@ -0,0 +1,196 @@ +# Feature: 模型协议直连面(OpenAI 兼容子集 + `model:invoke` + 逐条调用审计) + +> **本文档定位 — 纯 What(需求口径,不随代码漂移)** +> +> spec 只回答 **做什么 / 验收标准 / 不做什么**。所有 How(决策、数据流、字段、API、Service、前端、文件清单、性能指标)一律不写在这里, +> How 的唯一真相在 [design.md](./design.md) 与 [tasks.md](./tasks.md)。 + +**关联 PRD**: [docs/PRD/3.0-release/3.0 应用工场 PRD-1 专业开发者通道与应用运行时.md](../../../docs/PRD/3.0-release/3.0%20应用工场%20PRD-1%20专业开发者通道与应用运行时.md) **v2.0** DEV-01(① 权限位与三面映射:`model:invoke` 行、② 凭据规则「token 用量只计量不设上限、每次调用按 key 计入审计」、验收 1 / 5)/ **DEV-02 模型协议直连面表**(仅 OpenAI 兼容、流式、鉴权 = 服务账号密钥、可用范围 = GOV-05 租户模型配置、计量按 key 入审计、适用对象与 Claude Code 只能走自带订阅)/ DEV-02 ④(三面仅模式 S、`delegate` 入口拒绝)/ DEV-02 验收 4 / 10 / DEV-03「平台能力接线」表·模型一节(官方 OpenAI 兼容客户端 + 平台注入 base_url 与 key、模型名即模型管理原名、无逻辑档位转换)/ DEV-07「刻意不进 SDK 的两样」·chat 行 / GOV-04 事件清单「模型调用记录」行与验收 3 / GOV-05 界面表·模型项 / GOV-10(开放能力层开关)/ §5.1「token 用量限制不做」/ §5.2「按 key / 按应用聚合账单顺延」;伴生 [docs/PRD/3.0-beta2/3.0 开放 API 鉴权与身份传递 PRD.md](../../../docs/PRD/3.0-beta2/3.0%20开放%20API%20鉴权与身份传递%20PRD.md) **v2.1** §4.2.4 扩展位表 `model:invoke` 行(与 `chat:invoke` 互不蕴含)与「三个扩展位只走模式 S 且与 `delegate` 互斥」段 / 附录 B.2 模型协议面行 / AC-49;《3.0 应用工场 产品方案》§3.4(模型代理仅 OpenAI 面、现成代理承载、Anthropic 附赠不在承诺范围)/ §4.6(BishengLLM 复用度)/ §5.1 F102 +**同步记录**: 2026-08-17 初稿(按 PRD-1 v2.0 + 伴生 PRD v2.1 + release-contract 拆分 v2 表 3 F051 行;§4 决议 1–7 于同日按用户授权的全自动模式直接定案并留痕);同日按独立审查 16 条裁定就地修订:AC-07 / 08 / 09 / 10 / 15 / 18 / 20 / 21 / 22 / 23 / 24 / 33 改写,末尾追加 AC-34–36,决议-5 / 6 / 7 改写、新增决议-8,依赖行改写;表 1 新登记 ModelCallRecord 归本 Feature +**优先级**: P0 +**所属版本**: v3.0.0 +**依赖**: **F049**(服务账号与密钥底座、`model:invoke` 位登记入权限位清单、开放能力层部署开关、供本面复用的凭据校验路径与 5 秒撤销上界);**F054**(AC-34 由 app-proxy 签发、与访问用户绑定、平台可验签的短时访问凭据——本面据此确立托管应用调用的 subject);**F055**(AC-57 托管应用运行期凭据主体类型,与该应用「当前生效能力声明」的查询——本面据此收窄托管应用凭据的可用模型范围;F055 AC-49 / AC-51 / AC-53 / AC-55 是本面在应用运行期的消费方);**F050**(审计双归属 subject 列;`delegate` 相关验收〔伴生 AC-48 / AC-49 全景〕须待其委托配置区交付后方可端到端验证)。**被依赖**:F053(接入信息区与 `bisheng dev` 注入的 OpenAI 兼容 base URL)、F055(能力总线「模型」项的运行期注入通道)、F052(模型清单工具与本面的名称解析同源) +**批次**: A(开放能力层,GOV-10:可独立于工场运行时层部署);**不在 MVP-114 纵切上**([../mvp-114-path.md](../mvp-114-path.md) §2:表单问卷不用模型面),spec / design / tasks 正常推进 + +> **范围边界** +> +> 本 Feature 是 PRD-1 DEV-02 的**模型协议直连面**:讲 OpenAI 协议的客户端凭一把服务账号密钥(托管应用则凭其运行期凭据)直连租户已启用的平台托管模型并流式返回,每次调用逐条留痕。它站在 F049 的凭据地基上,是 F053(接入信息与本地 dev)与 F055(能力总线「模型」项)的运行期通道。**MCP 工具面归 F052,本 Feature 与其只共享「可用模型范围」这一判定。** +> +> - **本次纳入**: +> - **OpenAI 兼容模型协议直连面**:对外只承诺 **OpenAI 兼容子集**——**对话补全(含流式)** 与 **模型列表** 两类端点为承诺面;讲 OpenAI 协议的客户端按官方 OpenAI 兼容客户端默认用法(base URL + 密钥 + 模型名)即可直连并流式返回,无需平台专有 SDK 或本地插件;承诺面之外的 OpenAI 协议路径本版不暴露、以明确响应拒绝(§4 决议-1);鉴权 = 服务账号密钥 + `model:invoke` 位(缺位 403 指明缺哪一位);仅模式 S(以服务账号自身身份执行) +> - **可用模型范围**:服务账号密钥 = 密钥所属租户在模型管理页**可见且已启用**的对话类模型(GOV-05 模型项:租户管理员配置一次、租户内共享,含 Root 共享给该租户的模型;租户级、非 per-key);托管应用运行期凭据 = 该应用**当前生效能力声明**中的模型 ∩ 上述租户已启用集合(未声明 → 「能力未声明」可区分错误,GOV-05 验收 3 / F055 AC-51 在模型面的执行点,§4 决议-8);模型名 = 模型管理页原名、无逻辑档位转换;**租户内跨服务商同名的歧义规则**(§4 决议-2:拒绝裸名 + 提供限定名,不取默认);不可用 / 已下线 / 已删除模型的明确错误(GOV-05 能力收回错误态在模型面的表现);本面「哪些模型可用、某名字解析为谁」的判定与 F052 模型清单工具、F055 能力声明预检**三处同源** +> - **逐条模型调用审计带维度**(release-contract 表 1 **ModelCallRecord**,本 Feature 写入、F056 只读):每次调用记录密钥 / 服务账号(可追溯资源归属人)、模型(含所属服务商)、时间、token 数、结果;托管应用运行期经本面调用时带出应用维度(actor = 应用、subject = 由 F054 AC-34 短时访问凭据确立的当前访问用户,与 F055 AC-55 对齐——本 Feature 负责在调用记录上带出这些维度并可按 key / 对象应用 / 时间筛选);被拒调用计审计事件、不入用量;记录不含消息正文与密钥明文;用量计入平台既有 token 用量口径(同一账本);**聚合账单本版不做**(§5.2 顺延);token 用量只计量、不设上限 +> - **`delegate` 入口拒绝**(INV-31 的模型面):持 `delegate` 的密钥在本面入口按权限位直接拒绝,错误指向「委托专用、本地开发另签一把」,不得静默落回模式 S、不得只回参数错误码;任何委托类身份传递头一律拒绝 +> - **与 `chat:invoke` 互不蕴含**:本面是裸协议透传——不建会话、不持久化历史、不注入平台提示词、不挂平台工具与知识库;持 `chat:invoke` 而无 `model:invoke` 的密钥调用本面被拒,反之亦然(反向由 F049 / F058 承接) +> - **开放能力层开关(F049 拥有)**未开时本面不可达;已开时不依赖工场运行时层即可用;对外提供**单一稳定的 base URL** 供 F053 接入信息区展示与 F054 / F055 运行期注入 +> - **本地引擎适配的对外承诺**:讲 OpenAI 对话补全协议的本地引擎(Qwen Code / Codex CLI / Kimi Code 等)可直连并流式返回;只讲 Anthropic 协议的工具(如客户自装的 Claude Code)连接被拒并给出明确提示;托管应用运行期与本地 `bisheng dev` 期对本面的接法完全一致 +> - **本次明确排除**: +> - **Anthropic 兼容面**——本版**只承诺、只测试、只对外暴露 OpenAI 兼容子集**(PRD-1 DEV-02 / 产品方案 §3.4 决议);design 若采用的现成代理自带 Anthropic 端点,那是附赠、**不在承诺、测试与对外宣传范围内**、且不暴露到本面承诺面(§4 决议-1);只讲 Anthropic 协议的引擎走其自带订阅(产品方案 §4.3 路径 B) +> - **模型清单查询的 MCP 工具**——F052;本 Feature 只保证「可用模型范围」的判定与 F052 一致(AC-10) +> - **`chat:invoke` 日常模式会话 Responses 契约**(伴生 §4.6.3)——F058;OpenAI Responses 协议路径在本面不承诺、不暴露(它属另一数据面而非本面缺件,§3) +> - **按 key / 按应用的聚合用量账单与用量可视**(PRD-1 §5.2 顺延 v3.1)——本版只逐条计量与留痕(AC-25);将来补做的注意事项(用量表加维度而非反查遥测)作为 design 约束登记(§4 决议-6) +> - **token 用量上限、per-key / per-app 调用频率限流**(PRD-1 §5.1「token 用量限制不做」/ §5.2「限流顺延」)——本面沿用模型管理页对服务商的既有上限配置,不新增任何上限 +> - **模型管理页本身**(服务商 / 模型配置、上下线、Root 共享)——既有能力,本 Feature 只读其结果作为可用范围 +> - **引擎凭据档案与强 / 中 / 快三档映射**(PRD-2 GOV-06,且已随 Anthropic 面一并搁置)——本面无任何逻辑档位转换(AC-11) +> - **SDK chat 模块**——PRD-1 DEV-07 刻意不进 SDK:用官方 OpenAI 兼容客户端 + 平台注入的 base URL 与凭据即可(F053 技能包教用法) +> - **应用运行期凭据的签发 / 重签 / 撤销**(F055 AC-57 编排、F049 底座)与**「当前生效能力声明」的定义与生效时机**(F055)——本面只读该声明并据此收窄托管应用凭据的可用模型范围(AC-34)、在记录上带出应用维度;短时访问凭据的签发与验签能力归 F054 AC-34,本面只消费其验签结果确立 subject +> - **接入信息区与一键复制界面**(F053 AC-44)——本 Feature 只提供 base URL 这一稳定值 +> - **`delegate` 与三扩展位的签发侧互斥硬阻断**(伴生 AC-48)——F050;本 Feature 是运行期入口拒绝(伴生 AC-49 的模型面) +> - **平台既有模型消费方(助手 / 工作流 / 工作台 / 灵思)的计量补齐**——它们不经本面;本 Feature 只保证本面的每次调用留痕、且 token 口径与平台既有计量一致(AC-23) + +--- + +## 1. 用户故事 + +作为 **开发者的本地 coding agent(Qwen Code / Codex CLI / Kimi Code 等讲 OpenAI 协议的引擎)**, +我希望 **只配一个 base URL、一把密钥、一个模型名就能流式调到企业托管的模型**, +以便 **不改工具、不装插件就在企业模型上工作,报错时能自己看懂并纠正**。 + +作为 **专业开发者(同时是托管应用 owner)**, +我希望 **本地开发期与线上运行期用同一套官方 OpenAI 客户端写法、同一个模型名调平台模型**, +以便 **代码零差异,不为平台学一套私有 SDK,也不担心线上悄悄换了模型或档位**。 + +作为 **租户管理员**, +我希望 **在模型管理页配置一次模型,开发者与托管应用即可使用;同名模型不会被悄悄接到错的服务商;下线一个模型后调用会明确失败而不是继续消耗**, +以便 **能力的供给与收回都可控、可预期,不需要为工场再配一遍**。 + +作为 **租户管理员 / 平台超管(审计视角)**, +我希望 **每一次模型调用都能追溯到密钥、服务账号(及其资源归属人)或应用与访问用户,含模型与 token 数**, +以便 **出问题时定位到人、了解谁在用什么模型用了多少——即便本版没有账单**。 + +作为 **安全与合规负责人**, +我希望 **模型面不建会话、不留正文、不承载委托、缺位即拒**, +以便 **一把密钥泄漏的爆炸半径限制在该租户模型的使用权本身,任何业务数据都不会经模型面流出**。 + +--- + +## 2. 验收标准 + +> AC 编号在本特性内唯一。与 PRD-1 / 伴生 PRD 的对应关系在括号中标注(如 `↔DEV-02 验收 4`、`↔伴生 AC-49`),便于双向追溯。 +> 错误码在此仅作为可观测的对外行为引用,其定义与归属写在 design.md。「OpenAI 兼容错误体」指讲 OpenAI 协议的客户端能按其错误约定解析并呈现的响应,其字段形状属 design。 + +### 2.1 协议面与鉴权 + +- **AC-01** — THE SYSTEM SHALL 提供一个 OpenAI 兼容的模型协议直连面,承诺面为两类端点:**对话补全**(含流式)与**模型列表**;讲 OpenAI 协议的客户端只需配置平台给出的 base URL、一把服务账号密钥与模型名,按官方 OpenAI 兼容客户端的默认用法即可调用,不需要平台专有 SDK 或本地端插件。(↔DEV-02 模型协议直连面表·协议 / 验收 4;DEV-07「chat 不进 SDK」/ 验收 2;伴生 B.2 模型协议面行) +- **AC-02** — THE SYSTEM SHALL 使本面只承诺、只测试、只对外暴露上述 OpenAI 兼容子集;OpenAI 协议族中承诺面之外的路径(如 embeddings、旧式 completions、responses、images / audio / files / fine-tuning / assistants 等)本版不暴露——请求得到明确的「本版不提供该端点」可读响应,不静默转发到任何上游、不以未定义错误或 5xx 收场。(§4 决议-1;DEV-02 表·协议) +- **AC-03** — IF 客户端按 Anthropic 协议路径请求本面(只讲 Anthropic 协议的工具接入), THEN THE SYSTEM SHALL 返回可读的明确提示「本版仅提供 OpenAI 兼容面」并拒绝,不做协议错译或静默转发;本 Feature 交付的任何文档、接入信息与对外材料只讲 OpenAI 兼容面。(↔DEV-02 表·协议 / 验收 4;产品方案 §3.4;§4 决议-1) +- **AC-04** — WHEN 请求携带有效服务账号密钥且该密钥持 `model:invoke`, THE SYSTEM SHALL 以该服务账号自身身份(模式 S)执行,租户上下文取密钥所属租户;本面不存在任何按他人身份执行的路径。(↔DEV-02 ④;伴生 §4.2.4「三个扩展位只走模式 S」/ B.2;INV-31) +- **AC-05** — IF 请求未携带凭据、凭据无效、已撤销、已过期,或其服务账号已停用 / 删除, THEN THE SYSTEM SHALL 返回 401 且不执行调用、不返回任何模型信息(含模型列表);撤销 / 停用 / 删除的生效上界与 F049 一致(5 秒),无任何回落身份。(↔DEV-01 ④ / 验收 2 / 验收 7;F049 AC-01 / AC-03 / AC-21;INV-27 / INV-28) +- **AC-06** — IF 凭据有效但缺少 `model:invoke`(含仅持 `chat:invoke` 或未勾任何位的密钥), THEN THE SYSTEM SHALL 对对话补全与模型列表两类端点均返回 403 并指明缺少 `model:invoke`;`chat:invoke` 与 `model:invoke` **互不蕴含**——持 `model:invoke` 而无 `chat:invoke` 的密钥同样不能调用日常模式会话端点(该侧由 F049 / F058 承接)。(↔DEV-01 ① 映射表 / 验收 5;伴生 §4.2.4 扩展位表 `model:invoke` 行;F049 AC-04) +- **AC-07** — WHEN 管理员编辑密钥权限位(授予或移除 `model:invoke`), THE SYSTEM SHALL 使本面自下一次调用起**立即**按新配置放行或拒绝,无需重签密钥(同 F049 AC-08)。(↔DEV-01 ② / 验收 5;F049 AC-08) +- **AC-08** — THE SYSTEM SHALL 使本面的一切错误响应对 OpenAI 兼容客户端可解析(HTTP 状态语义正确、错误体含可读原因与平台错误标识),使本地 agent 能自行区分以下类别并纠正:「凭据错」/「缺位」/「委托专用密钥被拒」/「模型不存在或已收回」/「能力未声明」(托管应用凭据,AC-34)/「超出上游 / 平台上限」/「端点不支持」/「上游失败」;不出现裸 5xx 字符串或平台内部堆栈。(↔DEV-03「调错了平台报错明确(404 / 401),agent 自己就能纠正」;产品方案 §4.6 现存端点反面教材) + +### 2.2 可用模型范围与名称解析 + +- **AC-09** — THE SYSTEM SHALL 使本面的可用模型范围按凭据主体分两档:**服务账号密钥** = 密钥所属租户在模型管理页**可见且已启用**的对话类模型(含 Root 共享给该租户的模型),租户级、非 per-key;**托管应用运行期凭据** = 该应用当前生效能力声明中的模型 ∩ 上述租户已启用集合(AC-34)。租户管理员在模型管理页配置一次即对本面生效,无需任何额外配置或重签密钥。(↔DEV-02 表·可用模型范围;GOV-05 界面表·模型 / 验收 1;伴生 B.2 模型清单行「非 per-user」) +- **AC-10** — WHEN 持 `model:invoke` 的凭据请求模型列表, THE SYSTEM SHALL 返回该凭据可用范围(AC-09 / AC-34)内每个模型**可直接用于调用的名称**(唯一时为原名,歧义时为限定名,AC-12)与是否对话类,不返回服务商凭据、端点等任何配置;本面的名称解析规则是唯一出处——F052 AC-13(模型清单工具返回可直接调用的唯一名称与对话类标注)与 F055 AC-07(预检要求所引模型按本规则可唯一解析、裸名歧义拒绝)均按本面规则执行,三处对「哪些模型可用、某个名字解析为谁」得到同一结论(**三处同源**)。(↔DEV-02 工具表「模型清单查询」;F052 AC-13;F055 AC-07 / AC-49) +- **AC-11** — THE SYSTEM SHALL 以模型管理页「模型名称」原文作为本面的模型名:精确匹配、无逻辑档位转换(不存在强 / 中 / 快之类映射)、无别名与模糊匹配。(↔GOV-05 模型项「模型管理页原名,无逻辑档位转换」;DEV-03 表·平台能力接线;DEV-07;§4 决议-3) +- **AC-12** — IF 租户内存在跨服务商同名的多个可用模型(歧义), THEN THE SYSTEM SHALL 拒绝以裸名发起的调用并在错误中列出可用的限定名(服务商名 + 模型名的组合形式);以限定名调用可唯一解析(限定名在名称唯一时同样可用);模型列表对歧义模型只给出限定名。THE SYSTEM SHALL NOT 在歧义时按任何默认规则(最早创建 / 首个服务商等)静默择一。(§4 决议-2;Discovery §2.5 / §4.2 次级决策「模型名歧义规则」) +- **AC-13** — IF 请求的模型名在该租户可见范围内不存在(含属于其它租户、非对话类、名称不匹配)、已下线(未启用)或其服务商已删除, THEN THE SYSTEM SHALL 返回 4xx 的 OpenAI 兼容错误体并指明模型名与原因(不存在 / 已下线 / 已收回)——可与普通调用失败区分;对其它租户的模型一律按「不存在」处理,不透露存在性。(↔GOV-05 规则「能力收回后行为」/ 验收 4;F055 AC-53 在模型面的表现;DEV-02 表·可用模型范围) +- **AC-14** — WHEN 租户管理员在模型管理页下线或删除某模型 / 服务商, THE SYSTEM SHALL 使本面对该模型的**新**调用自变更起最多 60 秒内开始返回 AC-13 的错误(生效上界);已在途的响应(含流式)不被强行切断。(§4 决议-4) +- **AC-15** — THE SYSTEM SHALL 使本面对某模型「能否调用」的判定与平台内助手 / 工作流消费同一模型时一致(启用状态、模型管理页对服务商的既有调用上限等既有配置一体沿用),超出既有上限时返回 AC-08「超出上游 / 平台上限」类别的明确错误;THE SYSTEM SHALL NOT 为本面新增 per-key / per-app 的调用频率限制或 token 用量上限。(↔DEV-01 ②「token 用量只计量、不设上限」;§5.1「token 用量限制不做」;§5.2 限流顺延) + +### 2.3 流式与透传 + +- **AC-16** — WHEN 对话补全请求要求流式, THE SYSTEM SHALL 以 OpenAI 流式事件形状边生成边转发(不缓冲整段后一次性返回);IF 上游在流式过程中失败或中断, THEN THE SYSTEM SHALL 以可读的错误收尾并关闭连接、不挂起。(↔DEV-02 表·协议「支持流式」/ 验收 4) +- **AC-17** — THE SYSTEM SHALL 使本面为裸协议透传:不创建平台会话、不持久化任何消息正文、不注入平台提示词、不改写调用方消息与采样参数、不挂载知识库与平台工具;除模型名解析、鉴权与计量所需的元数据外,请求与响应语义与调用方直连该服务商等价。(↔伴生 §4.2.4 `model:invoke` 行「裸协议透传,不建会话、不持久化历史」;DEV-01 ① 映射表) +- **AC-18** — THE SYSTEM SHALL 使对话补全对以下对话要素透传可用:system / user / assistant 消息(多轮)、流式、temperature / top_p / max_tokens / stop、**工具调用(tools / tool_choice 与 tool 角色消息的结果回传)**——本地 coding agent 依赖工具调用,必须透传——以及按协议约定返回的用量;超出该枚举的请求字段原样透传但不作承诺;IF 上游模型不支持调用方使用的某要素, THEN THE SYSTEM SHALL 把上游错误以可读形式返回,不静默丢弃该要素。(↔DEV-02 验收 4;DEV-07 验收 2) +- **AC-19** — THE SYSTEM SHALL 使经本面的调用不产生平台会话记录:不出现在任何用户或服务账号的会话列表、不可经 V2 会话 / Responses 类接口取回、不留历史——与 `chat:invoke` 的数据面完全不同。(↔伴生 §4.2.4「二者数据面不同,需要哪个给哪个」) + +### 2.4 逐条调用审计维度 + +- **AC-20** — WHEN 一次经鉴权的调用到达模型解析阶段(无论成功、上游失败或模型不可用), THE SYSTEM SHALL 记录一条模型调用记录:密钥(掩码或标识)、服务账号(据此可追溯其资源归属人)、模型(解析后的模型与所属服务商)、时间、输入 / 输出 / 合计 token 数、结果(成功 / 失败及原因类别);记录不含消息正文、不含密钥明文。IF 调用在到达模型解析阶段前被拒(无凭据 / 凭据无效 / 缺位 / 委托专用密钥, AC-05 / AC-06 / AC-26), THEN THE SYSTEM SHALL 记录一条**被拒调用的审计事件**(凭据可识别时归属到该凭据与其主体,不可识别时只记事件与拒绝原因;时间;不入用量、token 为空),使每次调用(含被拒)可归属。(↔DEV-01 验收 1「每次调用可归属」;GOV-04 事件清单「模型调用记录」/ 验收 2;NFR-2;§4 决议-7) +- **AC-21** — WHEN 调用凭据的主体为托管应用(F055 AC-57 签发的应用运行期凭据), THE SYSTEM SHALL 在调用记录上带出应用维度(actor = 应用);WHEN 该调用随运行期凭据一同呈现 F054 AC-34 由 app-proxy 签发、平台可验签的**短时访问凭据**且验签有效, THE SYSTEM SHALL 记录 subject = 该访问凭据所绑定的用户;无访问凭据的调用(如应用后台任务)subject 记为「应用自身」并显式标注,**不得**记为任意用户。subject 只用于审计归属,不改变可用模型范围(由凭据主体决定,AC-09 / AC-34)与任何权限判定;subject 的确立不经任何请求头字段。(↔GOV-04 事件清单「应用运行期能力调用」双归属;F054 AC-34;F055 AC-49 / AC-55;§4 决议-5) +- **AC-22** — IF 服务账号密钥发起的调用附带任何访问凭据、或以任何方式试图指定他人为 subject, THEN THE SYSTEM SHALL 拒绝该调用(本地开发三面无第三方在场);服务账号密钥的调用 subject 恒为该服务账号自身。(↔INV-31;DEV-02 ④;§4 决议-5) +- **AC-23** — THE SYSTEM SHALL 使 token 数计量不依赖调用方是否在请求中要求返回用量;IF 上游未提供用量, THEN THE SYSTEM SHALL 把 token 数记为「未知」(区别于 0)、不估算伪造;本面调用**计入平台既有 token 用量统计(同一账本,不另立一套)**,同一次调用在本面记录的 token 数与既有口径一致,不出现两套口径。(↔DEV-01 ②;GOV-04 验收 3;release-contract 表 1 ModelCallRecord) +- **AC-24** — WHEN 租户管理员按密钥 / 对象应用 / 时间范围筛选查询, THE SYSTEM SHALL 返回逐条模型调用记录,每条含服务账号(及其资源归属人)、模型(含服务商)、时间、token 数、结果、subject 等记录列并可导出;服务账号 / 模型 / token 数为**记录列**而非筛选器(本版不承诺按其筛选,与 F056 一致);租户管理员限本租户,平台超管可跨租户;管理面审计查询面的接线与可查性由 F056 保证。(↔GOV-04 验收 3 / 验收 4;F056 AC-25) +- **AC-25** — THE SYSTEM SHALL NOT 提供按 key / 按应用的聚合用量账单、用量可视化或用量上限;本版只逐条计量与留痕。(↔GOV-04 事件清单「逐条留痕,本版不做聚合账单」;§5.2 顺延) + +### 2.5 `delegate` 与模式 S + +- **AC-26** — IF 密钥持有 `delegate`, THEN THE SYSTEM SHALL 在本面入口按权限位直接拒绝,错误信息指向「这把密钥配置为委托专用,本地开发请另签一把」;THE SYSTEM SHALL NOT 表现为按服务账号身份正常执行(静默落回模式 S),也不得只返回参数缺失类错误码。(↔伴生 AC-49;DEV-02 ④ / 验收 10;INV-31) +- **AC-27** — IF 请求携带任何委托类身份传递头(代表平台用户 / 外部用户标识), THEN THE SYSTEM SHALL 拒绝并提示本面不承载委托,不得静默忽略该头继续执行。(↔F049 AC-33 同形;伴生 §4.2.4「三面只走模式 S」;INV-31) + +### 2.6 开放能力层开关与接入信息 + +- **AC-28** — WHERE 开放能力层未部署, THE SYSTEM SHALL 使本面不可达(得到「端点不存在」类响应、不透露本面存在与否);服务账号模块与 V2 开放 API 不受影响。(↔GOV-10 规则 / 验收 4;F049 AC-49) +- **AC-29** — WHERE 开放能力层已部署而工场运行时层未部署, THE SYSTEM SHALL 使本面对服务账号密钥完整可用(不依赖任何工场运行时能力)。(↔GOV-10 验收 3) +- **AC-30** — THE SYSTEM SHALL 对外提供**单一、稳定**的 OpenAI 兼容 base URL——不因租户或密钥而异、不随平台升级变化——供 F053 接入信息区展示与一键复制、`bisheng dev` 同名环境变量注入,以及 F054 / F055 托管运行期注入使用;本 Feature 不拥有任何展示界面。(↔DEV-01 界面表「接入信息区」/ ⑤;F053 AC-27 / AC-44;F055 AC-49) + +### 2.7 引擎适配承诺 + +- **AC-31** — WHEN 讲 OpenAI 对话补全协议的本地引擎(如 Qwen Code、Codex CLI 的对话补全接线模式、Kimi Code 等)配置本面 base URL、服务账号密钥与模型名, THE SYSTEM SHALL 使其可直连平台托管模型并流式返回,无需任何本地端专有插件或平台改造。(↔DEV-02 表·适用对象 / 验收 4;§5.1「本地端专有插件不做」;产品方案 §4.2) +- **AC-32** — IF 本地引擎只讲 Anthropic 协议(如客户自装的 Claude Code), THEN THE SYSTEM SHALL 使其连接本面被拒并得到 AC-03 的明确提示;此类引擎的模型走其自带订阅,本面与接入信息不宣传对其的支持。(↔DEV-02 表·协议 / 适用对象 / 验收 4;产品方案 §3.4 / §4.3 路径 B) +- **AC-33** — THE SYSTEM SHALL 使托管应用运行期与本地 `bisheng dev` 期对本面的接法完全一致——同一 base URL、同一模型名(无档位转换)、同一 OpenAI 兼容客户端用法,代码零差异;两者仅凭据不同(应用运行期凭据 vs 开发者服务账号密钥),可用模型范围随之不同:`dev` 期 = 该租户已启用模型(服务账号密钥,AC-09),运行期 = 当前生效能力声明 ∩ 租户已启用(AC-34)——本地能调的模型可能多于线上声明的,发布前须把用到的模型写进能力声明(F055 AC-07 预检把关)。(↔DEV-05;DEV-07 验收 2;F053 AC-27;F055 AC-07 / AC-49 / AC-51) + +### 2.8 追加(2026-08-17 审查补齐,末尾追加不重排) + +- **AC-34** — WHEN 调用凭据是托管应用运行期凭据(F055 AC-57 签发、主体类型「托管应用」), THE SYSTEM SHALL 把可用模型范围收窄为该应用**当前生效能力声明**中的模型 ∩ 租户已启用集合(模型列表同样只返回该范围);IF 请求的模型未在生效声明中, THEN THE SYSTEM SHALL 拒绝并返回「能力未声明」的可区分错误(与 AC-13「不存在 / 已下线 / 已收回」及 AC-06 缺位可区分);声明变更随发布生效(F055 AC-57 重签);该收窄不是 per-user、不构成模式 D。服务账号密钥维持 AC-09 的租户级范围。(↔GOV-05 验收 3;F055 AC-51 / AC-53;§4 决议-8) +- **AC-35** — IF 模型配置读取失败、名称解析不可判定(含歧义判定所需信息不可得)或可用范围(含托管应用当前生效声明)无法确立, THEN THE SYSTEM SHALL 拒绝该调用并返回明确错误,绝不放行、也不落回任何缓存中更宽的集合执行(第二道门 fail-closed)。(↔INV-30 精神;F049 AC-34 同向;§3「凭据校验与模型解析是两道门」) +- **AC-36** — THE SYSTEM SHALL 使本面自身的运行日志、访问日志、错误响应与任何管理面均不出现密钥明文(服务账号密钥、应用运行期凭据、短时访问凭据、服务商凭据);WHERE design 采用现成代理承载协议面, 其自带的管理界面 / 管理端点不对开发者与外部可达,对外只有承诺面(AC-02)。(↔NFR-2;GOV-04 规则「审计记录不出现密钥明文等敏感值」) + +--- + +## 3. 边界情况 + +> 只写期望的对外行为;实现层的落点、缓存与数据访问细节见 design.md §5。 + +- **同名模型歧义是产品级风险,不是实现细节**:租户内跨服务商同名的「模型名称」在模型管理页是合法配置(Discovery §2.5),静默择一等于把调用绑到错误的服务商凭据上、且不报错只错。因此裸名歧义一律拒绝、限定名恒可解析(AC-12);歧义只在「租户内可见且已启用」的集合上判定——同名模型之一下线后裸名恢复唯一即可直接用;F055 能力声明预检与 F052 清单工具用同一规则(AC-10),不会出现「本地能跑、发布被拒」或「清单里有、调不通」。 +- **模型下线中途的流式响应**:下线 / 删除是能力配置变更而非安全事件——已在途的响应允许完成,新调用自变更起最多 60 秒内开始被拒(AC-14);密钥撤销 / 服务账号停用属安全事件——后续调用 5 秒内被拒(AC-05),在途响应不作切断承诺(凭据面以「下一次调用」为界,与 F049 一致)。 +- **密钥权限位编辑即时生效**(AC-07):agent 无需重新 `login` 或换密钥;缺位错误告知缺的是 `model:invoke`(AC-06),而不是笼统 403——本地 agent 才知道该找管理员补哪一位。 +- **跨租户模型不可见**:其它租户的模型按「不存在」处理(AC-13),不区分「没有」与「不属于你」;Root 共享给本租户的模型属可见范围(模型管理页既有语义,AC-09)。资源归属人对可用范围零影响(伴生定义 6 边界 c)——归属人只在审计追溯时出现。 +- **WHERE 未部署开放能力层**:本面「端点不存在」(AC-28);同时 F049 AC-13 使 `model:invoke` 位不出现在签发表单,不存在「有位无面」的状态;反向「有面无位」= 403 缺位(AC-06)。仅部署开放能力层(无工场运行时层)本面完整可用(AC-29)。 +- **与 `chat:invoke` 数据面不同**(AC-06 / AC-17 / AC-19):需要会话留痕、知识库、平台工具或在平台 UI 可见的对话,走 `chat:invoke`(F058);本面不是会话 API,也不是「便宜版 chat」——两位互不顶替,需要哪个给哪个。 +- **承诺面之外的路径一律不暴露**(AC-02 / AC-03):本地引擎若默认走 OpenAI 的其它协议(如 Codex CLI 默认的 Responses 接线)须切到其对话补全模式接入;OpenAI Responses 端点在本面不承诺——它属 F058 日常模式会话契约(`chat:invoke`,另一数据面),不是本面缺件。无论 design 采用现成代理还是自研薄层,对开发者可达的只有承诺面;代理自带的管理面、UI 与其它协议端点一概不对外。 +- **审计写放大**:本面暴露给本地 agent 是高频场景。对外行为只要求两点——逐条模型调用记录可按 key / 对象应用 / 时间查到(AC-24),且逐条记录的写入**不拖慢既有审计页**与其它审计事件的查询;被拒调用(无凭据 / 缺位 / 委托专用)计审计事件、不入用量(AC-20 / 决议-7);上游失败与模型不可用仍逐条记录(结果 = 失败)。记录对象是表 1 ModelCallRecord(决议-6),落点见 design。 +- **token 数「未知」不是 0**(AC-23):上游未返回用量时不估算;客户端未请求返回用量也不影响记账;同一调用不得在本面与平台既有计量之间出现两套 token 口径。 +- **既有服务商上限沿用、无新增限流**(AC-15):模型管理页对服务商的既有调用上限是可用范围配置的一部分,超限得到明确错误;per-key 限流与 token 上限属 §5.1 / §5.2 明确不做——本面不因「暴露给外部工具」而单方面加闸。 +- **非对话类模型不经本面**:embedding / rerank / asr / tts 类模型不出现在模型列表、按名请求按「不存在」处理(AC-13;§4 决议-3)。 +- **默认拒绝语义与 INV-27 等价**:伴生 B.2 把本面与 V2 端点分表登记,但「无凭据即拒、无回落身份、无兼容窗口」对本面同样成立(AC-05);商业版前置网关部署形态下本面须可达(design 落点,与 F053 同一约束)。 +- **托管应用实例的网络可达**:应用实例经出站策略可达本面由 F054 保证;本 Feature 只保证 base URL 单一稳定(AC-30),运行期注入的环境变量名与 F053 `dev` 同名(F053 AC-27 / F054 / F057 对齐)。 +- **凭据校验与模型解析是两道门,都 fail-closed**:凭据校验与 F049 一致(缓存 / 存储不可用即拒);模型信息的读取延迟(AC-14 的 60 秒)不得让已撤销 / 缺位的密钥通过——第一道门失败不进第二道门;第二道门(模型配置读取 / 名称解析 / 范围确立)失败同样拒绝、不落回更宽集合(AC-35)。 +- **托管应用凭据的两类拒绝可区分**:声明中的模型被管理员下线 → 「已收回」(AC-13,F055 AC-53 据此在发布面标「已失效」);模型未写进能力声明 → 「能力未声明」(AC-34)——owner 与本地 agent 据此分辨是「忘了声明」还是「被收回」;本地 `dev` 期用服务账号密钥不受声明收窄,故「本地能跑、线上未声明」由 F055 预检在发布前拦下(AC-33)。 +- **上游错误可读透传**:上游 4xx(内容拦截、上下文超长、参数不支持等)以可读错误体原样返回(AC-08 / AC-18),不包装成 5xx,也不改写成平台内部错误。 + +--- + +## 4. 待澄清决议 + +> 2026-08-17 用户授权全自动模式:以下各项按建议直接定案,记录理由与备选供追溯;spec 评审(`/sdd-review`)后如有 high 级发现再增补。Discovery §4.2 次级决策清单中「模型名歧义规则(跨服务商同名 model_name,F051)」在此定案(决议-2)。 + +- **[决议-1] 对外只承诺 OpenAI 兼容子集(对话补全 + 模型列表);承诺面之外的路径一律不暴露并以明确响应拒绝;Anthropic 协议路径给「本版仅提供 OpenAI 兼容面」提示。协议面用现成代理还是自研薄层归 design。** 理由:PRD-1 DEV-02 与产品方案 §3.4 已定「仅 OpenAI 兼容、Anthropic 面本版不承诺」;三个候选引擎全讲 OpenAI 协议,主路径不依赖 Anthropic 面;DEV-02 验收 4 明写「仅讲 Anthropic 协议的工具连接被拒并给出明确提示」,因此即便 design 采用的现成代理自带 Anthropic 端点,也**不暴露到本面承诺面**——这与产品方案「附赠不在承诺、测试与宣传范围」一致(不暴露即无需宣传),且避免形成开发者会依赖的隐性契约(升级时可无痛开放,反向收回会破坏调用方)。承诺面之外的 OpenAI 端点(embeddings / Responses 等)同理:本版无真实消费者(应用内 RAG 走 F052 / F057 检索面,不用裸 embeddings),不暴露、明确拒绝。**措辞红线**:对外不说「不实现 Anthropic」,说「本版只承诺 / 只测试 / 只暴露 OpenAI 兼容面」。**何时重新考虑**:v3.1 出现讲 Anthropic 协议的真实客户诉求,或托管应用出现 embeddings / Responses 的真实诉求时,在同一 base URL 下扩承诺面,不改既有契约。 +- **[决议-2] 同名歧义规则 = 拒绝裸名 + 提供限定名(服务商名 + 模型名),不取默认。** 理由:静默择一等于把调用绑到错误的服务商凭据(Discovery §2.5 风险「应用声明绑定到错误凭据」),是「不报错只错」的静默失败形状——与全册「不静默降级」取向相悖;限定名给 agent 一个恒可用的稳定写法(唯一时也接受),模型列表对歧义模型只返回限定名,agent 从列表拿到的名字都能直接调通;F055 预检与 F052 清单用同一规则避免三处漂移。备选 ① 给模型管理页加租户内唯一名约束——改动既有模型管理、破坏存量合法配置,否决;备选 ② 按最早创建 / 首个服务商取默认——静默错绑,否决。**何时重新考虑**:模型管理页将来引入租户内唯一名后裸名恒唯一,本规则退化为空转但契约不需改。 +- **[决议-3] 模型名基准 = 模型管理页「模型名称」字段原文(用户在页面看到并输入的调用名,非显示别名);仅对话类模型经本面暴露;精确匹配、无别名 / 模糊匹配 / 档位转换。** 理由:PRD-1 DEV-03 / GOV-05 / DEV-07 三处一致要求「模型名即平台模型管理原名、无逻辑档位转换」,强 / 中 / 快三档映射已随 Anthropic 面一并搁置(产品方案 §4.3);对话补全端点只对对话类模型有意义,embedding / rerank / asr / tts 走各自协议、本版无诉求(决议-1);精确匹配是让「模型管理页看到什么名字、代码里就写什么」成立的最简规则,模糊匹配会重新引入歧义。 +- **[决议-4] 模型下线 / 删除对本面新调用的生效上界 60 秒;已在途的响应(含流式)不切断;密钥撤销 / 停用沿用 F049 的 5 秒。** 理由:模型下线是能力配置变更而非安全事件,平台内助手 / 工作流对同一变更本就存在同量级的生效延迟(Discovery §2.5 风险「收回生效延迟」),不对开放面许下比平台自身更严的承诺;切断在途响应只会产生半截输出与不可诊断错误,下线的目的(停止新用量)由「新调用起拒绝」达成;凭据撤销属安全事件、维持 INV-28 的 5 秒上界。备选:即时生效——需对全部模型消费方做缓存主动失效改造,本版不做。**何时重新考虑**:平台内消费方对模型下线的生效延迟收敛得更快时,本面同步收敛,不需改契约。 +- **[决议-5] 应用运行期调用的 subject 采信对象 = F054 AC-34 由 app-proxy 签发、与访问用户绑定、平台可验签的短时访问凭据(随托管应用运行期凭据一同呈现),不是任何请求头字段;验签有效 → 记 subject;无访问凭据(应用后台任务)→ subject 记「应用自身」并显式标注、不拒绝、不得记为任意用户;服务账号密钥 → subject 恒为自身,附带访问凭据或试图指定他人则拒绝;委托类身份头一律拒绝(AC-27)。** 理由:请求头可由应用代码任意填写,采信它等于允许伪造审计归属;短时访问凭据由 app-proxy 在验平台会话后签发、与访问用户绑定且不可由客户端伪造(F054 AC-34),是唯一可信的 subject 来源,与 F055 AC-55 的口径一字不差。本面的可用范围由凭据主体决定(服务账号 = 租户级,托管应用 = 生效声明)、不 per-user,subject 只承担审计归属,缺失时没有越权面、fail-closed 没有对象;应用内确实存在无用户上下文的模型调用(后台任务、定时汇总),拒绝会误伤——检索面的 fail-closed(无法取得访问用户即拒绝)归 F052 / F055(INV-36),两面取向不同是刻意的。服务账号侧拒绝是为防止本地开发密钥伪造归属,与 INV-31「本地开发三面无第三方在场」一致;`bisheng dev` 期不存在 app-proxy 也不签发访问凭据,合法流程不会触发拒绝。**访问凭据随请求的呈现形式(与运行期凭据的组合)属 design,须与 F054 AC-34 / F055 / F057 SDK 一致;F057 SDK 与 F053 技能包负责让应用侧带出,本 Feature 负责验签、采信与落记。** +- **[决议-6] 审计落点:逐条模型调用记录的对象 = release-contract 表 1 新登记的 ModelCallRecord(本 Feature 写入、F056 查询面只读);spec 只要求「可按 key / 对象应用 / 时间查到逐条、可导出」(AC-24)与「不拖慢既有审计页」(§3);PRD-1 §5.2「将来补账单须在用量表加 key / app 维度并打通调用链上下文,而不是从遥测侧反查」作为 design 约束登记,不作 spec 契约。** 理由:本面是高频写入面,与低频人为操作的审计事件在查询体验上必须分离,否则审计页被淹没;F056 只需保证审计查询面能按 key / 应用追溯到逐条记录(F056 AC-25),记录落在哪、怎么写是 How;把「加维度而非反查」写进 design 是为了 v3.1 聚合账单能在同一份记录上直接出账、不返工;用量同时计入平台既有 token 口径(AC-23)避免两套账本。 +- **[决议-7] 计量边界:缺位 / 凭据失败 / 委托专用密钥的被拒调用计审计事件、不入用量(凭据可识别时归属到该凭据,不可识别时只记事件与原因);上游失败与模型不可用逐条记录(结果 = 失败、token 未知);模型管理页对服务商的既有调用上限沿用、超限明确报错;不新增 per-key / per-app 限流或 token 上限。** 理由:DEV-01 验收 1 要求「key 的每次调用可在审计中归属」,被拒的调用同样是「调用」——只记成功会让「谁在拿哪把 key 反复试」不可追溯;不入用量是因为被拒调用没有 token、也不该污染账本;失败调用留痕是排障与「谁在试图用什么模型」追溯的需要(GOV-04 逐条留痕不区分成败);既有服务商上限是租户模型配置的一部分,本面若绕开会与平台内消费方行为不一致;限流与 token 上限是 PRD-1 §5.1 / §5.2 明确不做的项,本面不单方面加闸。**何时重新考虑**:v3.1 「精细成本控制 / 开发者 key 调用频率限流」立项时,在本面入口加闸、错误按 OpenAI 429 语义返回,不改既有契约。 +- **[决议-8] 托管应用运行期凭据的可用模型范围在本面收窄为「当前生效能力声明 ∩ 租户已启用」;声明的语义与生效时机归 F055,执行点在本面。** 理由:模型面是运行期凭据调模型的唯一咽喉,F055 AC-51「未声明的能力不可调用」若不在此执行就是空文;GOV-05 验收 3「生产实例实际可用能力 = 其声明且经审批的能力集合」需要一个可判定的拒绝点;「能力未声明」与「已收回」分列两类错误(AC-13 / AC-34),owner 与本地 agent 才能分辨是忘了声明还是被管理员收回。该收窄按凭据主体判定、与访问用户无关,不构成 per-user 过滤或模式 D(INV-31 不受影响);`dev` 期服务账号密钥不受声明收窄,「本地能跑、线上未声明」由 F055 AC-07 预检在发布前拦下。备选:本面不收窄、由 F055 在注入侧限制——F055 无法在协议面之外拦截一次具体的模型调用,否决。 + +--- + +## 5. 设计与实现(指针,不复制) + +> **本节刻意不写内容。** spec 不承载 How。 + +| 你想知道 | 去哪看 | +|---|---| +| 为什么这么实现(现成代理 vs 自研薄层的取舍、BishengLLM 作 provider 路由唯一真相、决策 + 备选 + 何时该推翻) | design.md §3 | +| 关键约束(凭据校验路径复用 F049 / 多租户 ContextVar / 双 DB / 错误码段与 OpenAI 错误体映射 / 开放能力层开关 / 商业版网关可达) | design.md §2 | +| 今天的数据流、模块职责、字段 / API / 文件名约定(端点路径与 base URL、模型名解析与限定名形式、限定名分隔规则、短时访问凭据随请求的呈现形式、ModelCallRecord 的表与维度列、缓存与失效) | design.md §4 | +| 代码里看不出的坑、反直觉事实(现存 `/api/v2/assistant/chat/completions` 是助手外壳非模型直连、按名解析不存在、跨服务商同名合法、`LLMTokenLog` 无 key / app 维度、两套 token 提取口径、模型缓存 TTL、写放大与 DM8) | design.md §5;[../000-prd1-discovery/research/model-llm.md](../000-prd1-discovery/research/model-llm.md) | +| 对外契约(base URL 与环境变量名与 F053 / F054 / F057 对齐、可用模型范围与解析规则供 F052 / F055 消费、ModelCallRecord 形状供 F056 审计面查询、应用运行期凭据主体类型来自 F049 / F055、短时访问凭据验签来自 F054、当前生效能力声明来自 F055)、改了会破坏谁、依赖谁 | design.md §6 | +| 性能 / 安全 / 可观测非功能指标(流式首字延迟、写路径异步化、并发) | design.md §2 + §7 | +| 任务拆解、文件清单、执行顺序、踩坑落档 | tasks.md | + +--- + +## 相关文档 + +- 设计真相: [design.md](./design.md)(接手第一入口,尚未编写) +- 执行与落档: [tasks.md](./tasks.md)(尚未编写) +- 版本契约: [features/v3.0.0/release-contract.md](../release-contract.md)(表 3 F051 行为本 Feature 范围的权威来源;表 1 ApiCredential 归 F049——本 Feature 只读凭据主体与权限位;表 1 **ModelCallRecord** 归本 Feature、F056 只读;INV-27 / INV-28 / INV-31 直接相关) +- MVP 纵切: [features/v3.0.0/mvp-114-path.md](../mvp-114-path.md)(F051 不在纵切上) +- Spec Discovery: [features/v3.0.0/000-prd1-discovery/discovery.md](../000-prd1-discovery/discovery.md) §2.5(可复用 llm_server / llm_model / BishengLLM 19 服务商流式 / 模型收回错误族;缺口:无 OpenAI 兼容入站端点、按名解析不存在且跨服务商同名合法、`LLMTokenLog` 无 app / key 维度)/ §4.2 次级决策「模型名歧义规则」;研究底稿 [research/model-llm.md](../000-prd1-discovery/research/model-llm.md) +- 同版 spec: [../049-openapi-auth-baseline/spec.md](../049-openapi-auth-baseline/spec.md)(AC-01 / 03 / 04 / 08 / 21 凭据与撤销、AC-13 三扩展位、AC-33 身份头拒绝、AC-49 开放能力层开关);[../053-dev-cli-skills/spec.md](../053-dev-cli-skills/spec.md)(AC-17 技能包模型一节 / AC-27 `dev` 同名环境变量 / AC-44 接入信息区);[../054-app-domain-runtime/spec.md](../054-app-domain-runtime/spec.md)(AC-34 短时访问凭据);[../052-mcp-server-face/spec.md](../052-mcp-server-face/spec.md)(AC-13 模型清单工具与本面解析同源);[../055-app-publish-pipeline/spec.md](../055-app-publish-pipeline/spec.md)(AC-07 预检按本面规则可唯一解析 / AC-49 能力总线模型项 / AC-51 声明白名单 / AC-53 能力收回 / AC-55 双归属 / AC-57 应用运行期凭据);[../056-app-square-governance/spec.md](../056-app-square-governance/spec.md)(AC-25 审计面对模型调用逐条记录的可查性) +- PRD: `docs/PRD/3.0-release/3.0 应用工场 PRD-1 专业开发者通道与应用运行时.md` v2.0(DEV-01 / DEV-02 / DEV-03 / DEV-07 / GOV-04 / GOV-05 / GOV-10 / §5.1 / §5.2);`docs/PRD/3.0-beta2/3.0 开放 API 鉴权与身份传递 PRD.md` v2.1(§4.2.4 / 附录 B.2 / AC-49);`docs/PRD/3.0-release/3.0 应用工场 产品方案.md` §3.4 / §4.3 / §4.6 / §5.1 F102 diff --git a/features/v3.0.0/051-model-protocol-gateway/tasks.md b/features/v3.0.0/051-model-protocol-gateway/tasks.md new file mode 100644 index 0000000000..0110f122c8 --- /dev/null +++ b/features/v3.0.0/051-model-protocol-gateway/tasks.md @@ -0,0 +1,393 @@ +# Tasks: 模型协议直连面(OpenAI 兼容子集 + `model:invoke` + 逐条调用审计) + +**关联规格**: [spec.md](./spec.md)(36 条 AC,What 的唯一真相)· [design.md](./design.md)(How 的唯一真相,K1–K13 / D1–D14 / 坑 1–18 / §4.2 契约) +**版本**: v3.0.0 +**纵切**: 不在 [mvp-114-path.md](../mvp-114-path.md) 上;Wave 1–5 可在本地全部完成,Wave 6 依赖 F055 / F054,Wave 7 需 114 +**代码事实口径**: 本文所有 `文件:行号` 沿用 design.md(`3.0-vibe` HEAD `fe10f75ea`,2026-09-16 核实,路径以 `src/backend/bisheng/` 为根;`locales/` = `src/frontend/packages/locales/src/`)。行号会漂移,符号名不会——落地前以符号名重定位。 + +--- + +## 状态 + +| 步骤 | 状态 | 备注 | +|------|------|------| +| spec.md | ✅ 已评审 | 2026-08-17 定稿(决议 1–8 全自动模式定案,同日审查 16 条修订) | +| design.md | ✅ 已评审(2026-09-16,`/sdd-review design` 两轮) | 用户豁免 ★,D1–D14 标「全自动模式定案」;接手时的第一入口。二轮发现 AC-27 底座缺口 → 新增 26505 / 坑 18 | +| tasks.md | ✅ 已拆解并评审(2026-09-16,`/sdd-review tasks`) | 本文 | +| 实现 | 🚧 进行中 | **24 / 30 完成**(2026-09-16:Wave 1–5 全部落地 T001–T021;**Wave 6 的 T022 / T023 / T024 于同日解除阻塞并落地**——F055 T055 / T056 与 F054 `verify_obo_token` 均已合入 `3.0-vibe`,三者由 `app_publish/composition.py:register()` 一并注册;余下 T025 与 Wave 7 T026–T029 全部需要 114)。同日复核并修正 6 处(见「实际偏差记录」8–13),Wave 6 的核实见 15–18。本地用例 131 → **`test/open_api` 739 → 760 passed / 3 skipped**(新增 `test_model_gateway_hosted_app.py` 21 条;739 为主检出 `3.0-vibe` 同选择基线)、**`test/app_publish` 655 → 660 passed**(同样对主检出基线)。本地证据:`ruff check` / `ruff format --check` 对本切片文件零输出,`arch-guard.sh` 零输出。偏差处理见 design.md 顶部调整原则 + `docs/SDD-Guide.md` §3-§4 | + +--- + +## 开发模式 + +**按 Wave 组织任务**:Wave 1 基础设施(无测试配对)→ Wave 2 目录与解析(`llm` 域)→ Wave 3 协议面(路由 / 错误体 / 流式)→ Wave 4 逐条记录 → Wave 5 base URL 契约与官方客户端契约测试 → **Wave 6 托管应用路径(阻塞于 F055 T055 / T056、F054 OBO 验签)** → Wave 7 114 E2E 与引擎适配。每个任务标 `依赖:`,无依赖的可并行。 + +**后端 Test-First**:测试任务先于配对实现任务;`覆盖 AC` 逐条写全 id(禁范围写法)。单测放 `src/backend/test/open_api/` 与 `src/backend/test/llm/`(不放 `test/` 根),`asyncio_mode=auto`。fake `BishengLLM` 与 fake catalog 在 T005 一次建好,其后测试不再各造。集成用例连 CI 中间件;DM8 用例在 105 回归。 + +**HTTP 状态断言口径**(design K2 / D4):本面路径断言**真 HTTP 状态 + OpenAI 错误体** `{"error":{...,"bisheng_code":N}}`;其它 `/api/v2` 路径仍断言信封(T012 含一条"邻居不受影响"用例)。 + +**前端**:本 Feature 无界面任务;三语 `api_errors` 文案(K13)与 `whoami` 字段是唯一前端侧改动。 + +**自包含任务**:每个任务内联文件、逻辑、AC 覆盖;设计论证指向 design §X 不复制。 + +**执行顺序 ≠ 编号**:T005(fake 夹具)应在 T006 / T007 之前跑通;T009(catalog 实现)会同时改 `llm.py` 与 `llm_server.py`,与 T006(typed 异常,同在 `llm/domain/utils.py`)合成一个 PR 提交以减少 `llm` 域两次 review。 + +**工时口径**:括号内为估算人时;合计 ≈ 101h / 30 个任务(其中 Wave 6 ≈ 12h 要等上游;Wave 7 ≈ 8h 需 114)。 + +**跨 Feature 副作用登记**(release-contract 表 1 / 检查项 17): +- **T006**(`llm/domain/utils.py:126/136` 裸 `Exception` → `LlmProviderDailyLimitExceededError(Exception)`;`common/constants/enums/telemetry.py` `ApplicationTypeEnum` 加 `MODEL_GATEWAY`)—— `llm` 域共享 util 与遥测枚举;同基类同 message,既有 `except Exception` 与统计页零感知;统计页按类型筛选会多出一项。 +- **T009**(`llm/domain/services/llm.py:419-453` 抽 `acollect_visible_server_ids`;`llm/domain/models/llm_server.py:492` `aget_shared_server_ids_for_leaf(raise_on_error=False)`)—— `get_all_llm` 行为不变(T008 含回归断言);新 kw 默认值保持旧行为。 +- **T007**(`open_api/domain/scopes.py:139-147` `model:invoke` 翻 issuable + 端点登记)—— 签发表单在 `open_platform.enabled` 下开始展示该位(F049 AC-13 语义),**位本身的三语文案已存在、不必补**;**六条**既有测试同批改(design D12 清单,其中 `test_scopes.py:44-51` 连函数名一起改、`test_scopes.py:35-41` 刻意不动)。 +- **T004**(`open_api/domain/services/call_audit_service.py` 重构为 `BatchedRecordWriter` 子类;`main.py:108/154` lifespan 增一对起停)—— 公开名 / 常量 / 行为不变,`test/open_api/test_call_audit*.py` 既有用例必须原样通过。 +- **T013**(`open_api/api/exception_handlers.py` 增本面前缀分支两处;`api/router.py:149-166` 条件挂载)—— 其它 v2 路径体形状不变(T012 断言)。 +- **T001**(`docs/constitution.md` C5 表 + `release-contract.md` 已分配模块编码)—— 登记 265。 +- **T005**(`test/open_api/conftest.py` 收编 `open_platform_enabled` / `open_platform_disabled`;`test/open_api/test_scope_issuability.py:36-42` 删本地定义)—— 共享测试夹具,既有用例零改动(fixture 名不变,只换供给位置)。 +- **T023**(`bisheng/app_runtime/domain/services/obo_token.py` 新建、`entry_authz_service.py:379-426` 抽常量与签发逻辑)—— **改的是 F054 领域对象**:`OBO_AUDIENCE`(`entry_authz_service.py:78`)已是模块级常量,抽取必须保持签发侧字节级等价(app-proxy 已发出的 OBO 令牌要继续验得过),实现 PR 由 F054 出或本 Feature 代出后由 F054 review。 +- **T020**(回写 F054 `contracts-runtime-manager.md §5`、F053 design §6.2 表、F055 design D13 环境变量名)—— 只加名不改既有名。 +- **依赖上游任务 ID**:F055 **T055**(`hosted_app` 主体解析器 + CHECK 放宽迁移,**✅ 2026-09-16 已落地**)· **T056**(注册 `HostedAppDeclarationPort`、凭据 `scopes` 含 `model:invoke`、`BISHENG_APP_TOKEN`,**✅ 已落地**)· F054(OBO 验签实现 + runtime-manager 注入三名,无任务号,见 F054 tasks 追加项;**✅ 验签已落地**,入口侧 fail-open 仍在 F054 手上,见偏差 18)· F056(查询面接线)· F052 / F055 T060(消费 `model_catalog`)。 + +--- + +## Tasks + +### Wave 1 · 基础设施(无测试配对,排最前) + +- [x] **T001**: 错误码 265 段 + C5 / release-contract 登记 + 三语文案(3h) + **文件**: `src/backend/bisheng/common/errcode/model_face.py`(新), `docs/constitution.md`(C5 表 `26x–27x` 行加 `265 model_face`,并加一段「265 is assigned」子段说明,同 260 段体例), `features/v3.0.0/release-contract.md`(「已分配模块编码」加 265 行,Owner F051), `src/frontend/packages/locales/src/api_errors/{zh-Hans,en,ja}.json`(三语视为一组) + **逻辑**: 按 design D11 定义 `ModelFaceError(OpenApiAuthError)`(类属性 `http_status / openai_type / openai_code`,**`Code: int = 265xx` 写法**,K13)与本期码:26501 / 26502 / 26503 / 26504 / **26505**(`ModelFaceIdentityHeaderRefusedError`,403,坑 18)· 26511 / 26512 / 26513 / 26514(`candidates` kwarg)/ 26515 / 26516 / 26517 · 26531 / 26532(实例级 `http_status`)/ 26533 / 26534。三语 message 面向 agent 可读(26502 原文「本版仅提供 OpenAI 兼容面」;26514 提示「请使用限定名 服务商名/模型名」;26505 提示「本面不承载委托,请去掉 X-End-User / X-On-Behalf-Of 头」)。文件名是 `en.json` 不是 `en-US.json`(那是 `platform/public/locales/` 的目录命名)。跑 `pnpm check-i18n`(`src/frontend/`)确认新码全部有文案、生成物不手改。 + **依赖**: 无 + **完成证据**: `common/errcode/model_face.py`(`ModelFaceError` + 16 个码,一律 `Code: int =` 写法,故 `check-i18n` 真的能看到);三语文案落 `packages/locales/src/api_errors/{zh-Hans,en,ja}.json` 并跑 `node scripts/build.mjs` 重生成产物;`docs/constitution.md` C5 表 + 「265 is assigned」段、`release-contract.md` 已分配模块编码表各加一行。 + +- [x] **T002**: 请求 / 响应 schemas(3h) + **文件**: `src/backend/bisheng/open_api/domain/schemas/model_gateway.py`(新) + **逻辑**: pydantic v2:`ChatCompletionRequest`(design D8 字段清单,`extra="allow"`,`messages` `min_length=1`,`n` 只允许 1,`stream_options.include_usage`)/ `ChatCompletionMessage` / `ToolCall` / `ChatCompletionChoice` / `Usage` / `ChatCompletionResponse(object="chat.completion")` / `ChatCompletionChunk(object="chat.completion.chunk")` / `ModelObject(id, object="model", created, owned_by, bisheng_model_type, bisheng_qualified_name)` / `ModelList(object="list", data)` / `OpenAIErrorBody(error: {message,type,code,param,bisheng_code, candidates?})`。响应模型序列化用 `exclude_none=False`(OpenAI 客户端接受 null 字段),`reasoning_content` 为 `None` 时**不输出**(单独 `exclude_none` 的字段级处理)。 + **依赖**: 无 + **完成证据**: `open_api/domain/schemas/model_gateway.py`;`ChatCompletionRequest.forwarded_kwargs()` 把枚举字段与 `model_extra` 一起透传,`test_openai_codec.py::test_enumerated_and_unknown_request_fields_are_both_forwarded` 守住。 + +- [x] **T003**: `model_call_record` ORM + Repository(4h) + **文件**: `src/backend/bisheng/open_api/domain/models/model_call_record.py`(新), `src/backend/bisheng/open_api/domain/models/__init__.py`(**必改**), `src/backend/bisheng/open_api/domain/repositories/model_call_record_repository.py`(新) + **逻辑**: 表按 design §4.2 ④(`SQLModelSerializable`,`BigInteger` 主键,`VARCHAR` 不用 `CHAR`,`create_time` 用 `sa_column=Column(DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"))`——**不用 `func.now()`**,与 `api_credential.py:82` 一致、双 DB 已验证;三条索引 D9)。**`__init__.py` 必须加 `from ... import ModelCallRecord` 与 `__all__` 条目**(坑 17:`_TENANT_AWARE_MODEL_MODULES` 登记的是**包名** `"bisheng.open_api.domain.models"`,`core/database/tenant_filter.py:107`,发现全靠包 `__init__` 的显式 import 链——漏了就是 `create_all` 不建表 + 租户过滤不覆盖,两个都静默);加一条 `test_database_contract.py` 风格断言 `"model_call_record" in SQLModel.metadata.tables`。Repository:`ainsert_batch(rows)`(一个 session `add_all` + 单次 commit;行显式带 `tenant_id`)/ `alist(tenant_id, *, credential_id, app_id, time_from, time_to, cursor, limit)`(排序 `(create_time DESC, id DESC)`,游标 `(create_time, id)` 编码为 base64 字符串)/ `aiter_export(...)`(同条件按游标翻页的 async 生成器)。**禁**批量 UPDATE / DELETE。 + **回滚**: 新表无 Alembic;回滚 = 删表(本 Feature 前无数据)。 + **依赖**: 无 + **完成证据**: `open_api/domain/models/model_call_record.py` + `models/__init__.py` 已加 import/`__all__`(坑 17);`repositories/model_call_record_repository.py` 提供 `ainsert_batch / alist / aiter_export`,排序 `(create_time DESC, id DESC)`、游标含 id;`test_model_call_record.py::test_paging_is_stable_when_rows_share_a_second` 用 20 行同秒数据断言两页拼接 == 全序。 + +- [x] **T004**: `BatchedRecordWriter` 泛化 + `ModelCallRecordWriter` + lifespan(4h) + **文件**: `src/backend/bisheng/open_api/domain/services/batched_writer.py`(新:`BatchedRecordWriter[T]`,构造参数 `max_queue_size / batch_size / flush_interval_seconds / name / write_batch: Callable[[list[T]], Awaitable[None]]`,方法 `enqueue / start / stop / flush_now`,逻辑逐行搬自 `call_audit_service.py:18-108`), `src/backend/bisheng/open_api/domain/services/call_audit_service.py`(改为子类;`AUDIT_*` 常量与 `open_api_call_audit_service` 单例名不变), `src/backend/bisheng/open_api/domain/services/model_call_record_writer.py`(新:队列 5000 / 批 200 / 1s,`write_batch=ModelCallRecordRepository.ainsert_batch`;失败 `logger.error("open_api.model_call_record.write_failed | reason=…")` + `emit_metric("model_call_record", status="dropped", …)`), `src/backend/bisheng/main.py`(`:108` 旁 `model_call_record_writer.start()`,`:154` 旁 `await model_call_record_writer.stop()`) + **逻辑**: design D9。既有 `test/open_api/` 中审计写入器用例必须原样通过(重构不改行为)。 + **依赖**: T003 + **完成证据**: `open_api/domain/services/batched_writer.py`(泛型基类,逻辑逐行搬自审计写入器);`call_audit_service.py` 改为子类、公开名与常量不变,既有 `test_call_audit.py` 原样通过;`model_call_record_writer.py` 队列 5000 / 批 200 / 1s,掩码批内水合;`main.py` lifespan 起停。 + +- [x] **T005**: 测试夹具:fake `BishengLLM` / fake catalog / ASGI `openai` 客户端(4h) + **文件**: `src/backend/test/open_api/model_gateway_fixtures.py`(新), `src/backend/test/open_api/conftest.py`(追加 fixture 引用;顶层不 import 尚未存在的实现模块,fixture 体内惰性 import) + **逻辑**: `fake_llm_factory(script)`:返回对象,`astream` 按脚本 yield `AIMessageChunk`(支持 content / `reasoning_content` / `tool_call_chunks(index=None)` / 末块 `usage_metadata` / 中途抛异常 / 抛 `LlmProviderDailyLimitExceededError`),`ainvoke` 返回 `AIMessage`,`bind` 记录 `tools / tool_choice` 并返回自身;`monkeypatch LLMService.get_bisheng_llm`。`catalog_rows`:构造 `LLMServer` / `LLMModel` 行(含跨服务商同名、下线、embedding 类型、他租户、服务器已删)写入 `open_api_db`(沿用 `conftest.py:28`)。`credential_with_scopes(scopes)`:经 `CredentialService.issue` 签发返回明文。`openai_client(app, api_key)`:`openai.AsyncOpenAI(base_url="http://test/api/v2/model/v1", api_key=..., http_client=httpx.AsyncClient(transport=httpx.ASGITransport(app=app)))`。**`open_platform_enabled` / `open_platform_disabled` 今天是 `test/open_api/test_scope_issuability.py:36-42` 的模块内 fixture,本任务把这两个 fixture 原样搬到 `test/open_api/conftest.py`**(原文件删掉定义、靠 conftest 注入,既有用例零改动),供本面各测试与 D3 的开关用例复用。代理 env 清理沿用既有 autouse。 + **依赖**: 无 + **完成证据**: `test/open_api/model_gateway_fixtures.py`(fake `BishengLLM` / catalog 行 / 独立 app 工厂 / 记录捕获 / chunk 构造器);`open_platform_enabled` / `open_platform_disabled` 已从 `test_scope_issuability.py` 提升到 `test/open_api/conftest.py`,原文件删定义、既有用例零改动。 + +- [x] **T006**: typed 日上限异常 + `ApplicationTypeEnum.MODEL_GATEWAY`(1h) + **文件**: `src/backend/bisheng/llm/domain/utils.py`(`:126 / :136`:`raise LlmProviderDailyLimitExceededError(f"...")`,新类定义于同文件顶部、继承 `Exception`), `src/backend/bisheng/common/constants/enums/telemetry.py`(`ApplicationTypeEnum.MODEL_GATEWAY = "model_gateway"`) + **逻辑**: design D13 / 坑 4。`grep -rn "Quota used up" src/backend` 确认无字符串匹配的调用方。 + **依赖**: 无 + **完成证据**: `llm/domain/utils.py` 两处裸 `Exception` 改 `LlmProviderDailyLimitExceededError`(同基类同 message,既有 `except Exception` 零感知);`ApplicationTypeEnum.MODEL_GATEWAY` 已加;`test_model_gateway_stream.py::test_the_daily_provider_limit_is_a_real_429_not_half_a_stream` 断言它映射成 26517。 + +- [x] **T007**: `model:invoke` 翻 issuable + 端点登记 + 既有测试同批改(2h) + **文件**: `src/backend/bisheng/open_api/domain/scopes.py`(`:139-147`:`endpoints` 填 `("POST", "/api/v2/model/v1/chat/completions")`、`("GET", "/api/v2/model/v1/models")`、五个方法 × `"/api/v2/model/v1/{rest}"`;删 `issuable=False` 行,**`requires_open_platform=True` 保留**), `src/backend/test/open_api/test_scope_issuability.py`, `src/backend/test/open_api/test_scopes.py`, `src/backend/test/open_api/test_open_api_route_matrix.py` + **逻辑**: design D12 的**六条受影响测试清单**,逐条落实:① `test_scope_issuability.py:66-69` 参数化只留 `identity:read`,新增 `test_model_invoke_issuable_once_open_platform_on`;② 同文件 `:176` 改 `assert "identity:read" not in codes and "model:invoke" in codes`;③ 同文件 `:4` 模块 docstring 去掉 `model:invoke`;④ `test_scopes.py:44-51` **函数改名**为 `test_app_manage_and_model_invoke_become_issuable_with_open_platform`,`:49` 与 `:50`(`codes == ALWAYS_ISSUABLE_OPEN_API_SCOPE_CODES | {"app:manage"}`)**两条断言都要改**;⑤ `test_scopes.py:35-41`(开关关闭态)**不要动**——`requires_open_platform=True` 仍把它挡在 `issuable_scopes()` 外(`scopes.py:201`);⑥ `test_open_api_route_matrix.py` 按 D3 末段改为「开关为假时从**期望集合**剔除 `MODEL_GATEWAY_PATH_PREFIX` 前缀下的操作」,不要试图 `monkeypatch` 开关(坑 13:`api/router.py:140` 在 import 时求值,`from bisheng.main import app` 拿到的是进程启动时的挂载结果)。 + **不需要做的**:`openApiManagement.scopes.model_invoke.{label,desc}` 三语文案**已存在**于 `platform/public/locales/{zh-Hans,en-US,ja}/bs.json`(已核实),翻 issuable 后签发表单直接有文案,本任务不碰 i18n。 + 路由矩阵测试在本任务会因路由尚不存在而红——**允许**,T013 落地后转绿(本任务 PR 与 T013 同批合入,或先标 `xfail(strict=True)`)。 + **依赖**: T005 + **完成证据**: `scopes.py` 的 `model:invoke` 删 `issuable=False` 并登记七条端点(两条承诺面 + catch-all 五方法);design D12 六条受影响测试逐条落实(`test_scope_issuability.py` 参数化/断言/docstring、`test_scopes.py` 函数改名 + 两条断言 + `issuable_scopes()` 分组列表、`test_open_api_route_matrix.py` 期望集合按开关剔除);`test_scopes.py:35-41` 按设计刻意未动。 + +### Wave 2 · 目录与名称解析(`llm` 域,Test-First) + +- [x] **T008**: `model_catalog` 单元测试(4h) + **文件**: `src/backend/test/llm/test_model_catalog.py`(新) + **逻辑**: `test_list_callable_only_llm_and_online`(embedding / rerank / 下线不出现)→ AC-09;`test_root_shared_server_visible_to_child`(子租户目录含 FGA `shared_with` 的 Root 服务器模型;Root 租户只见自有)→ AC-09;`test_other_tenant_model_invisible` → AC-13;`test_exact_name_unique_resolves` / `test_exact_name_with_slash_prefers_bare_match`(`model_name` 本身含 `/`)→ AC-11;`test_ambiguous_bare_name_26514_lists_qualified`(跨服务商同名 → `candidates == {"A/x","B/x"}`)→ AC-12;`test_qualified_name_resolves_when_unique_and_when_ambiguous` → AC-12;`test_offline_26512_vs_missing_26511_vs_provider_deleted_26513` → AC-13;`test_non_llm_type_is_26511`(不透露类型)→ AC-13;`test_list_ids_qualified_only_for_ambiguous_rows` → AC-10 / AC-12;`test_cache_ttl_bounded_60s`(`monkeypatch settings.open_api.model_catalog_ttl_seconds=61` → 校验器夹到 60;缓存命中后下线模型仍可解析、`ttl` 过后 26512)→ AC-14;`test_fga_failure_raises_26516_not_narrower_set`(`monkeypatch get_permission_relation_api` 抛 → 26516;断言**未**返回仅自有服务器的集合)→ AC-35;`test_get_all_llm_unchanged_after_helper_extraction`(同一数据下 `get_all_llm` 返回集合与抽取前快照一致)→ 回归;`test_hosted_range_intersection`(`range=ModelRange(declared={"x"})` 时 `y` → 26515、`x` 下线 → 26512)→ AC-34。 + **覆盖 AC**: AC-09, AC-10, AC-11, AC-12, AC-13, AC-14, AC-34, AC-35 + **依赖**: T001, T005, T006 + **完成证据**: `test/llm/test_model_catalog.py` 18 passed(复核后:假 DAO 改为按 `server_ids` 过滤,「服务商已删」一条拆成三条真实用例,偏差 8)。 + +- [x] **T009**: `model_catalog` 实现 + `get_all_llm` helper 抽取 + fail-closed 参数(6h) + **文件**: `src/backend/bisheng/llm/domain/services/model_catalog.py`(新:`CallableModel` / `ModelRange(kind: Literal["tenant","declared"], declared: frozenset[str] | None)` / `ResolvedModel(model_id, server_id, model_name, server_name, server_type, qualified_name)` / `list_callable_chat_models(tenant_id)` / `resolve_model_name(tenant_id, requested, *, range=None)` / `invalidate_catalog(tenant_id)`;进程内 `TTLCache(maxsize=256, ttl=settings.open_api.model_catalog_ttl_seconds)`,只缓存成功;`emit_metric("model_catalog", status=hit|miss|unavailable)`), `src/backend/bisheng/llm/domain/services/llm.py`(`:419-449` 抽 `acollect_visible_server_ids(tenant_id, *, raise_on_error=False) -> list[int]`;`get_all_llm` 改调、行为不变), `src/backend/bisheng/llm/domain/models/llm_server.py`(`:492` `aget_shared_server_ids_for_leaf(leaf_id, *, raise_on_error: bool = False)`,`True` 时重抛), `src/backend/bisheng/core/config/open_platform.py`(`OpenApiConf.model_catalog_ttl_seconds: int = 30`,校验器夹到 `[1, 60]`) + **逻辑**: design D5 解析四步 + D6 集合与缓存;`range.kind == "declared"` 时先按租户集合解析(得到唯一模型)再判 `qualified_name ∈ declared or model_name ∈ declared`,否则 26515——保证「已收回」与「未声明」的判序:下线先于未声明(spec §3 末段)。 + **测试**: T008 全部通过 + **覆盖 AC**: AC-09, AC-10, AC-11, AC-12, AC-13, AC-14, AC-34, AC-35 + **依赖**: T008 + **完成证据**: `llm/domain/services/model_catalog.py`(`CallableModel` / `ModelRange` / `ResolvedModel` / `list_callable_chat_models` / `resolve_model_name` / `invalidate_catalog`,进程内按租户 TTL 缓存、只缓存成功);`llm.py` 抽出 `acollect_visible_servers` / `acollect_visible_server_ids`,`get_all_llm` 改调、行为不变;`llm_server.py` 的 `aget_shared_server_ids_for_leaf` 增 keyword-only `raise_on_error`(默认 False 保持旧行为);`OpenApiConf.model_catalog_ttl_seconds` 默认 30、校验器夹到 60。 + +- [x] **T010**: `ModelRangePolicy` + 两个 Port 单元测试(2h) + **文件**: `src/backend/test/open_api/test_model_range_policy.py`(新) + **逻辑**: `test_service_account_tenant_range_subject_self` → AC-04 / AC-09;`test_service_account_with_access_token_header_26504`(头存在即拒,值任意)→ AC-22;`test_end_user_header_refused_26505_for_every_actor_kind`(服务账号与 hosted_app 各一例,值合法,断言在读任何 Port 之前就抛——坑 18)→ AC-27;`test_identity_header_check_precedes_access_token_check`(同时带 `X-End-User` 与坏的 `X-BiSheng-Access-Token` → 得 26505 而非 26504,锁住判定序)→ AC-27;`test_natural_person_tenant_range`(结构上可达即可,PAT 无 `model:invoke` 由 T012 覆盖);`test_hosted_app_without_declaration_port_26516`(默认 Port fail-closed)→ AC-35;`test_hosted_app_declared_range_and_subject_user_when_token_valid`(fake 两个 Port)→ AC-21 / AC-34;`test_hosted_app_no_token_subject_app_self` → AC-21;`test_hosted_app_invalid_token_26504` → AC-21;`test_registration_replaces_default_port`。hosted_app 用例以 `OpenApiPrincipal.model_construct(actor_kind="hosted_app", …)` 绕过 Literal(F055 T055 落地后改为正常构造,见 T022)。 + **覆盖 AC**: AC-04, AC-09, AC-21, AC-22, AC-27, AC-34, AC-35 + **依赖**: T005 + **完成证据**: `test/open_api/test_model_range_policy.py` 14 passed(复核补 `test_a_forged_token_is_refused_even_when_the_declaration_is_unreadable`,偏差 9)。 + +- [x] **T011**: `ModelRangePolicy` + Port 定义与默认实现(3h) + **文件**: `src/backend/bisheng/open_api/domain/services/model_range_policy.py`(新:`HostedAppDeclarationPort` / `AccessSubjectVerifierPort` Protocol、`AccessSubject`、`ResolvedSubject(subject_kind, subject_id)`、`register_hosted_app_declaration_port` / `register_access_subject_verifier`、`resolve_range_and_subject(principal, headers) -> tuple[ModelRange, ResolvedSubject]`;常量 `ACCESS_TOKEN_HEADER = "X-BiSheng-Access-Token"`) + **逻辑**: design D7 的**四步判定序**,顺序不可换:① `headers.get("X-End-User") is not None` → `ModelFaceIdentityHeaderRefusedError`(26505,全部 `actor_kind` 一视同仁,坑 18);② 访问凭据头判定(服务账号存在即 26504 / 托管应用验签失败即 26504);③ 范围确立(Port 不可用 → 26516);④ 交给 `model_catalog` 解析。默认 Port:声明读取抛 → 26516;验签恒 `None`。 + **测试**: T010 全部通过 + **覆盖 AC**: AC-04, AC-09, AC-21, AC-22, AC-27, AC-34, AC-35 + **依赖**: T010, T009 + **完成证据**: `open_api/domain/services/model_range_policy.py`,四步判定序按 design D7 固定,`test_identity_header_is_judged_before_the_access_token` 锁住顺序;两个 Port 默认实现 fail-closed。 + +### Wave 3 · 协议面:路由、错误体、开关、流式(Test-First) + +- [x] **T012**: 鉴权穿透 / 错误体 / 承诺面外 / 开关 集成测试(5h) + **文件**: `src/backend/test/open_api/test_model_gateway_auth.py`(新), `src/backend/test/open_api/test_model_gateway_errors.py`(新), `src/backend/test/open_api/test_model_gateway_switch.py`(新) + **逻辑**: **auth**:无凭据 → 401 `error.type=="authentication_error"` `bisheng_code==26001`;改一位 → 401 26002;只持 `chat:invoke` → 403 26003 且 `message` 含 `model:invoke`(`/models` 与 `/chat/completions` 都断)→ AC-06;持 `model:invoke` 的密钥打 `/api/v2/workstation/chat/completions` → 403 26003(互不蕴含反向)→ AC-06;编辑密钥补位后同一明文立即通过 → AC-07;撤销后 ≤ 3s 401 且 `/models` 无数据 → AC-05;持 `delegate` → 403 26051 `code=="delegate_only_credential"`(承诺面与 catch-all 都断,坑 7)→ AC-26;**身份头四例逐一断言(坑 18,本面最容易漏的一组)**:`X-On-Behalf-Of: 9`(密钥无 `delegate`)→ 403 26004;`X-End-User: abc`(**合法值、单独出现**)→ 403 **26505** `code=="identity_header_not_accepted"`(若不加本面拒绝,底座会 200 放行并把 `end_user_id` 记成 `abc`);两头同时出现 → 26010;`x-foo-on-behalf-of` 旧式头 / body 里 `user_id` → 26019;**四例都断言 fake LLM 的 `astream` / `ainvoke` 零调用**且 `model_call_record` 零行 → AC-27;PAT(`knowledge:read`)→ 403 26003。**errors**:`RequestValidationError`(`messages=[]`)→ 400 26503 `param=="messages"`;`n=2` → 26503;`/embeddings` POST / `/responses` / `/images/generations` / `/audio/speech` / `/files` GET → 404 26501 `code=="endpoint_not_supported"` → AC-02;`/messages` POST → 404 26502 → AC-03 / AC-32;上述均无凭据时先 401 → AC-05;**邻居不变**:`/api/v2/auth/whoami` 无凭据仍返回信封 `{status_code:26001,...}`;被拒路径 `audit_log` 有 `open_api.call` 行、`model_call_record` 无行 → AC-20。**switch**:`open_platform.enabled=False` 下用独立 app 工厂 / `importlib.reload(bisheng.api.router)` 构建 → `/api/v2/model/v1/models` 与 `/chat/completions` 均 404 `{"detail":"Not Found"}`(有无凭据同)→ AC-28;`True` 下不依赖任何 runtime-manager / app-proxy 配置即可 200 → AC-29。 + **覆盖 AC**: AC-02, AC-03, AC-05, AC-06, AC-07, AC-08, AC-20, AC-26, AC-27, AC-28, AC-29, AC-32 + **依赖**: T005, T007, T001 + **完成证据**: `test_model_gateway_auth.py` 14 passed · `test_model_gateway_errors.py` 18 passed · `test_model_gateway_switch.py` 4 passed(复核补 AC-06 反向蕴含与 Anthropic `v1/messages` 拼法,偏差 11 / 13)。 + +- [x] **T013**: 路由 / catch-all / 条件挂载 / OpenAI 错误体分支(5h) + **文件**: `src/backend/bisheng/open_api/api/endpoints/model_gateway.py`(新:`router = APIRouter(prefix="/model/v1", tags=["OpenAPI","Model"])`;`POST /chat/completions`、`GET /models`、`api_route("/{rest:path}", methods=[...])` 三者均 `@open_api_scope("model:invoke", modes=("S",))` + `Depends(get_open_api_execution)`;`/models` 与 `/chat/completions` 挂 `response_model`),`src/backend/bisheng/open_api/api/router.py`(新 `model_gateway_router` 导出), `src/backend/bisheng/api/router.py`(`:166` 后 `if settings.open_platform.enabled: router_rpc.include_router(model_gateway_router)`), `src/backend/bisheng/open_api/api/exception_handlers.py`(新 `MODEL_GATEWAY_PATH_PREFIX = "/api/v2/model/v1"`、`_is_model_gateway_path(conn)`、`render_openai_error(exc) -> JSONResponse`(D4 映射表:`ModelFaceError` 直接读 `openai_type/openai_code`;`OpenApiAuthError` 其它码与权限族按表;`RequestValidationError` → 26503);在 `_register_v2_handler.dispatch` 与 `open_api_auth_exception_handler` **两处**前置该判断,坑 8) + **逻辑**: design D3 / D4。catch-all 端点体只做 `rest == "messages"` 判断后 raise。`chat_completions` 端点本任务先返回 26531 占位(T015b 填实)。 + **测试**: T012 auth / errors / switch 通过(`chat/completions` 成功路径留 T014) + **覆盖 AC**: AC-02, AC-03, AC-05, AC-06, AC-07, AC-08, AC-26, AC-27, AC-28, AC-29, AC-32 + **依赖**: T012, T011 + **完成证据**: `open_api/api/endpoints/model_gateway.py` 三条路由(catch-all 同样带 `model:invoke` marker,坑 7);`api/router.py` 条件挂载;`exception_handlers.py` 增 `MODEL_GATEWAY_PATH_PREFIX` / `render_openai_error` 并在 `dispatch` 与 `open_api_auth_exception_handler` 两处前置判断(坑 8)。 + +- [x] **T014**: 对话补全翻译与流式 单元测试(6h) + **文件**: `src/backend/test/open_api/test_model_gateway_stream.py`(新), `src/backend/test/open_api/test_openai_codec.py`(新) + **逻辑**: **codec**:`messages` 含 system / user / assistant(tool_calls) / tool(tool_call_id) / 多模态数组 → `convert_to_messages` 结果类型与字段逐一断言 → AC-18;`tools` / `tool_choice` 原样进 `bind`、采样参数原样进 `kwargs`、未知字段(`extra`)透传 → AC-17 / AC-18;非流式响应形状(`chat.completion`、`tool_calls` 的 `arguments` 为 JSON 字符串、`finish_reason=="tool_calls"`、`usage` 三数);流式 chunk 序列(首块 `role`、文本 delta、`reasoning_content` 只在非空时出现、`tool_calls` 的 `index` 在 fake 给 `None` 时被按顺序分配且同一调用稳定——双工具夹具,坑 6、`finish_reason` 独立 chunk、`include_usage` 为真时 `choices=[]` 的 usage chunk、`[DONE]`)→ AC-16 / AC-18;usage 未知(末块无 `usage_metadata`)→ 非流式 `usage` 三 null、流式不发 usage chunk → AC-23。**stream 端点**:SSE 头 `text/event-stream` + `X-Accel-Buffering: no`;中途异常 → `data: {"error":{...26234/26531}}` 后 `[DONE]` 且连接关闭(`httpx` 读到结尾不超时)→ AC-16;预取首块阶段抛 `LlmProviderDailyLimitExceededError` → **非 SSE** 429 26517 → AC-15;首块阶段上游 `APIStatusError(400/413/422)` → 同状态 26532 `message` 含上游原文、上游 429 → 26533、连接错 → 502 26531 → AC-08 / AC-18;不建会话(`message_session` 表零新增行)、不写消息表 → AC-17 / AC-19;`BishengLLM` 实例化参数断言(`app_type==MODEL_GATEWAY`、`user_id==resource_owner_user_id`、`streaming==req.stream`)→ AC-23 前提;客户端中途断开 → 记录 `result=="client_disconnected"`(与 T017 合并断言)。 + **覆盖 AC**: AC-08, AC-15, AC-16, AC-17, AC-18, AC-19, AC-23 + **依赖**: T005, T002, T006 + **完成证据**: `test_openai_codec.py` 17 passed · `test_model_gateway_stream.py` 18 passed(复核补 `_LLM_ERROR_TRANSLATION` 五条映射与 `client_disconnected`,偏差 8 / 13)。 + +- [x] **T015a**: `openai_codec` 纯翻译层实现(无 I/O)(5h) + **文件**: `src/backend/bisheng/open_api/domain/services/openai_codec.py`(新) + **逻辑**: design D8。导出 `to_langchain_messages(messages)`(经 `langchain_core.messages.utils.convert_to_messages`,多模态 content 数组原样保留)、`build_completion(ai_message, model, request_id) -> ChatCompletionResponse`(`tool_calls` 的 `arguments` 序列化成 JSON 字符串、`finish_reason` 取 `response_metadata.finish_reason` 兜底 `stop` / `tool_calls`)、`ChunkAssembler`(状态 `request_id / created / model / tool_index_map / last_chunk / sent_role`;方法 `first(chunk) / next(chunk) / finish(finish_reason) / usage(last_chunk, include_usage) / error(exc)` 各返回一行 SSE 文本;**`tool_index_map` 按 tool_call 首次出现顺序分配 index 并缓存**——坑 6:qwen / zhipu 的 `tool_call_chunks.index` 是 `None`、`id` 只在首块出现)、`usage_from(result) -> Usage | None`(经 `llm/domain/utils.py` 的 `parse_token_usage`,**全零 → `None`**,坑 5)、`classify_upstream_error(exc) -> ModelFaceError`(`openai.APIStatusError` 的 `status_code` → 26532 / 26533;连接类 → 26531;无法识别 → 26531 且 message 取 `str(exc)` 前 500 字)、`redact_secrets(text)`(正则抹 `sk-…` / `bs-sak-…` / `Bearer …`)。本模块**不 import** 任何 repository / DAO / Settings。 + **测试**: T014 的 `test_openai_codec.py` 全部通过 + **覆盖 AC**: AC-08, AC-16, AC-17, AC-18, AC-23 + **依赖**: T014, T002 + **完成证据**: `open_api/domain/services/openai_codec.py`(`to_langchain_messages` / `build_completion` / `ChunkAssembler` / `usage_from` / `classify_upstream_error` / `classify_stream_error` / `redact_secrets`),零 I/O;`tool_index_map` 按首次出现顺序分配 index(坑 6),双工具夹具守住。 + +- [x] **T015b**: `ModelGatewayService` 编排 + 端点接线(6h) + **文件**: `src/backend/bisheng/open_api/domain/services/model_gateway_service.py`(新:`complete(principal, request, req)`:范围 → 解析 → `get_bisheng_llm` → `bind` → 非流式 `ainvoke` / 流式预取首块(坑 3)→ 返回 `ChatCompletionResponse` 或 `StreamingResponse`;`list_models(principal, headers)`;每条路径末尾调 `ModelCallRecordWriter.enqueue`(字段由 T018 填全)+ `logger.bind(event="model_gateway.call")`), `src/backend/bisheng/open_api/api/endpoints/model_gateway.py`(`chat_completions` 从 T013 的 26531 占位改接 service;`StreamingResponse(..., headers={"Cache-Control":"no-cache","X-Accel-Buffering":"no"})`) + **逻辑**: design D8 / D13 / D14。**流式必须预取首块**(坑 3:服务商日上限的异常在 `astream` 第一次迭代时才抛,`StreamingResponse` 一旦构造就已发 200 头)——先 `agen = llm.astream(...)`、`first = await anext(agen)`,此步的异常以普通 JSON 错误体 + 真状态返回,成功后才构造 `StreamingResponse` 并先 yield 已预取的 `first`;**不要**在外面再调一次 `bisheng_model_limit_check`(包装器内已 `INCR`,会计两次)。`request.is_disconnected()` 在每块 yield 前查一次;`asyncio.CancelledError` 记 `client_disconnected` 后重抛。`BishengLLM` 错误族映射:10009 / 10010 → 26513,10012 → 26512,10011 → 26511,10013 → 26531(K6)。 + **测试**: T014 的 `test_model_gateway_stream.py` 全部通过;T012 全部通过 + **覆盖 AC**: AC-08, AC-15, AC-16, AC-17, AC-18, AC-19, AC-23 + **依赖**: T015a, T013 + **完成证据**: `open_api/domain/services/model_gateway_service.py`;流式按坑 3 预取首块——日上限与上游 4xx 因此是真状态码而不是半截 200 流;`BishengLLM` 错误族 10009/10010→26513、10012→26512、10011→26511、10013→26531。 + +- [x] **T016**: 路由矩阵 / OpenAPI 契约再生成 / 契约文档补章(2h) + **文件**: `features/v3.0.0-beta1/053-openapi-auth-and-identity/openapi-v2-key-auth-api.json`(跑 `python features/v3.0.0-beta1/053-openapi-auth-and-identity/generate_openapi_contract.py` 再生成), 同目录 `openapi-v2-key-auth-api.md` / `openapi-v2-key-auth-api-by-category.md`(补「模型协议面」章:三条操作、错误体、base URL), `src/backend/test/open_api/test_open_api_route_matrix.py`(T007 的 xfail 去掉), `src/backend/bisheng/open_api/api/openapi_schema.py`(如需:本面 `StreamingResponse` 的 `text/event-stream` 描述) + **逻辑**: design D12 / 坑 11。断言 `test_openapi_schema_contract.py` 三条全绿。 + **依赖**: T013, T015b + **完成证据**: `generate_openapi_contract.py` 在 import app 前强制打开 `open_platform.enabled`(契约文档完整面,不依赖跑脚本的人手上的 YAML),重跑后 `openapi-v2-key-auth-api.json` 含本面两条承诺面操作(catch-all `include_in_schema=False`,不进契约);`test_openapi_schema_contract.py` 与 `test_open_api_route_matrix.py` 在开关关闭的进程里按前缀剔除期望集合。 + **未做的一半(2026-09-16 复核记录,偏差 8)**: 同目录两份**手写**客户文档 `openapi-v2-key-auth-api.md` / `openapi-v2-key-auth-api-by-category.md` **没有**补「模型协议面」章。它们是 beta1 冻结的四分类(知识库 / 日常模式会话 / 工作流 / 助手)交付物,抬头写死「HTTP 接口:42 条」「分类顺序:知识库 → 日常模式会话 → 工作流 → 助手」,而同目录 json 现在是 **48 条**——差额里 F055 的 `/api/v2/apps/**` 四条同样不在 md 里。也就是说这两份 md 的滞后是先于本 Feature 的、且范围比本 Feature 大;只补模型面一章会让计数与分类总览更不自洽。模型面的对外文档改由 **T029**(`docs/api/` 新增「模型协议面」页)承接,见该任务。 + +### Wave 4 · 逐条调用记录(Test-First) + +- [x] **T017**: ModelCallRecord 写入与查询 单元 + 集成测试(4h) + **文件**: `src/backend/test/open_api/test_model_call_record.py`(新) + **逻辑**: `test_success_row_fields`(成功调用 → 一行:`credential_id / credential_mask` 掩码格式、`actor_kind/actor_id/actor_name`、`resource_owner_user_id`、`subject_kind=="service_account"`、`requested_model / model_id / server_id / model_name / server_name / server_type`、`is_stream`、三 token 数与 `parse_token_usage(last_chunk)` 相等、`result=="success"`、`latency_ms>0`、`ttft_ms` 非空(流式)、`request_id` 与响应 `id` 相等、`trace_id`)→ AC-20 / AC-23;`test_unknown_usage_is_null_not_zero` → AC-23;`test_upstream_failure_row`(`result=="upstream_failed"`、`error_code==26531`、tokens NULL)→ AC-20;`test_model_unavailable_row`(26511 / 26512 / 26514 各一行 `result=="model_unavailable"`、`model_id` 空、`requested_model` 保留)→ AC-20;`test_limit_exceeded_row` → AC-15 / AC-20;`test_rejected_before_resolution_writes_no_row_but_audit_row`(401 / 403 / 26051:本表零行、`audit_log.open_api.call` 一行含 `credential_id` 与 `error_code`)→ AC-20;`test_models_endpoint_writes_no_row`;`test_no_message_body_no_secret_in_row_or_logs`(行内任何列与 `caplog` 均不含 `Authorization` 明文、`bs-sak-` 全串、`messages` 文本、`LLMServer.config` 值)→ AC-36;`test_batch_writer_flushes_within_interval_and_on_stop`;`test_queue_full_logs_error_and_metric`(C8 不静默);`test_alist_filters_and_cursor_order`(按 `credential_id` / `app_id` / 时间范围筛;同秒 20 行两页拼接 == 全序,坑 9)→ AC-24;`test_tenant_isolation_on_list`(子租户查不到 Root 行)→ AC-24;**`test_no_aggregation_or_quota_surface`**(反向断言 AC-25:`app.routes` 中 `/api/v2/model/v1` 前缀下只有 design §4.2 ① 登记的三条路由,没有任何 usage / billing / quota 路径;`ModelCallRecordRepository` 的公开方法只有 `ainsert_batch / alist / aiter_export`,没有任何 `asum_*` / `aggregate_*`;`OpenApiConf` 未新增任何 `*_limit` / `*_quota` 键——把「本版只逐条计量、不做聚合与上限」钉成可回归的事实,否则它只是 design §8 里一句无人执行的话)→ AC-25;`test_telemetry_event_same_tokens`(`monkeypatch telemetry_service.log_event_sync` 捕获 `ModelInvokeEventData`,`app_type=="model_gateway"`、三 token 数与本表相等)→ AC-23。 + **覆盖 AC**: AC-15, AC-20, AC-23, AC-24, AC-25, AC-36 + **依赖**: T003, T004, T005, T015b + **完成证据**: `test/open_api/test_model_call_record.py` 13 passed(含 `test_no_aggregation_or_quota_surface_exists` 反向断言 AC-25;复核把满队列用例改为断言 `emit_metric(status="dropped")` 真的发出,偏差 13)。 + +- [x] **T018**: 记录接线 + token 同源 + 脱敏(4h) + **文件**: `src/backend/bisheng/open_api/domain/services/model_gateway_service.py`(`_start_record(principal, subject, req)` / `_finish_record(record, result, error, usage, ttft)`;`credential_mask` **不在请求路径查**(`OpenApiPrincipal` 无掩码字段、不动 beta2 契约):行入队时 `credential_mask=None`,由 `ModelCallRecordWriter` 在 `write_batch` 前按 `credential_id` 批量水合 `ApiCredential.key_mask`(一次 `IN` 查询 / 批,design D14)), `src/backend/bisheng/open_api/domain/services/model_call_record_writer.py`(批内水合), `src/backend/bisheng/open_api/domain/services/openai_codec.py`(`redact_secrets` 接入所有 message 出口) + **逻辑**: design D9 / D10 / D14;坑 5(0 → NULL)。中途错误同时 `conn.scope["open_api_error_code"] = code`(坑 10)。 + **测试**: T017 全部通过 + **覆盖 AC**: AC-15, AC-20, AC-23, AC-24, AC-25, AC-36 + **依赖**: T017 + **完成证据**: `model_gateway_service._start_record / _attach_model / _finish_record` + 结构化日志 `model_gateway.call`;掩码在 `ModelCallRecordWriter` 批内一次 `IN` 查询水合(`test_the_credential_mask_is_hydrated_per_batch_not_per_call`);流式中途错误同时 `mark_open_api_error`(坑 10)。 + +### Wave 5 · base URL 契约与官方客户端契约测试 + +- [x] **T019**: 官方 `openai` 客户端 ASGI 契约测试(3h) + **文件**: `src/backend/test/open_api/test_model_gateway_openai_sdk.py`(新) + **逻辑**: 用 T005 `openai_client`:`client.models.list()` 能解析且 `id` 集合符合 D5(歧义只有限定名)→ AC-01 / AC-10;`client.chat.completions.create(model=..., messages=..., stream=False)` 得 `ChatCompletion` 且 `usage` 可读 → AC-01;`stream=True` 迭代 `ChatCompletionChunk`,含 `tool_calls` 增量可被 SDK 累加、`stream_options={"include_usage":True}` 末块 `usage` → AC-16 / AC-18;错误:401 → `openai.AuthenticationError`、403 → `openai.PermissionDeniedError`(`body["error"]["bisheng_code"]` 可读)、404 模型 → `openai.NotFoundError`、429 → `openai.RateLimitError`、400 歧义 → `openai.BadRequestError` 且 `body.error.candidates` 列出限定名 → AC-08 / AC-12;`client.embeddings.create(...)` → `NotFoundError` 26501 → AC-02;限定名 `A/x` 调用成功 → AC-12。 + **覆盖 AC**: AC-01, AC-02, AC-08, AC-10, AC-12, AC-16, AC-18 + **依赖**: T015b, T016 + **完成证据**: `test/open_api/test_model_gateway_openai_sdk.py` 9 passed——官方 `openai.AsyncOpenAI` 经 ASGITransport 直打,能解析成功体、按 index 累加双工具调用的 arguments、并把 401/403/404/400/429 抛成自己的异常类(`body` 里读得到 `bisheng_code` 与 `candidates`)。 + +- [x] **T020**: base URL helper + `whoami.model_base_url` + 环境变量名回写三处(3h) + **文件**: `src/backend/bisheng/open_api/api/public_base_url.py`(`model_gateway_base_url(request) -> str` = `resolve_public_base_url(request) + "/api/v2/model/v1"`;`_warn_once` 文案泛化,坑 14), `src/backend/bisheng/open_api/domain/schemas/credential.py`(`WhoamiResponse.model_base_url: str`), `src/backend/bisheng/open_api/api/endpoints/auth.py`(`whoami` 填值;`open_platform.enabled=False` 时为空串), `src/backend/test/open_api/test_model_gateway_base_url.py`(新:配置 `public_base_url` / `X-Forwarded-*` / Host 三来源各一例;两把不同密钥、两个租户得到同一 base → AC-30;关开关时空串), `features/v3.0.0/054-app-domain-runtime/contracts-runtime-manager.md`(§5 清单追加 `OPENAI_BASE_URL` · `OPENAI_API_KEY` · `BISHENG_MODEL_BASE_URL`,来源标 F051 design D2), `features/v3.0.0/053-dev-cli-skills/design.md`(§6.2 表末行同步), `features/v3.0.0/055-app-publish-pipeline/design.md`(D13「模型注入」段:`BISHENG_PLATFORM_API_BASE` → 改引 `OPENAI_BASE_URL` / `BISHENG_MODEL_BASE_URL`;凭据仍 `BISHENG_APP_TOKEN`,且同时以 `OPENAI_API_KEY` 注入) + **逻辑**: design D2。三处回写只加名不改既有名。 + **覆盖 AC**: AC-30, AC-33 + **依赖**: T013 + **完成证据**: `public_base_url.py` 增 `MODEL_GATEWAY_BASE_PATH` / `model_gateway_base_url`,`_warn_once` 文案泛化(坑 14);`WhoamiResponse.model_base_url` + `auth.py` 填值(开关关时空串);`test_model_gateway_base_url.py` 6 passed(三来源各一例 + 两租户两把密钥同一地址);三处回写:F054 `contracts-runtime-manager.md` §5、F053 design §6.2 表、F055 design D13。 + +- [x] **T021**: 技能包 / 文档配合项登记(1h) + **文件**: `features/v3.0.0/053-dev-cli-skills/tasks.md`(追加一行「平台能力接线包·模型一节按 F051 §4.2 ①③ 与 D5 限定名规则编写;附 `X-BiSheng-Access-Token` 转发说明」,指向本文), `features/v3.0.0/057-bisheng-sdk/spec.md` 或 tasks(如存在:登记「SDK 不封 chat;示例用 `openai` 客户端读 `OPENAI_BASE_URL`;应用侧转发访问凭据头」) + **逻辑**: design §6.1 / 坑 16。本 Feature 不写技能包正文。 + **覆盖 AC**: AC-31, AC-33 + **依赖**: T020 + **完成证据**: F053 tasks.md 追加 **T038a**(模型章改写 + `X-BiSheng-Access-Token` 转发说明);F057 tasks.md 追加 **T035a**(模型章两条回归断言改口径,`test_no_sdk_wrapper_for_model_or_appdb` 不动)。 + +### Wave 6 · 托管应用运行期路径(~~依赖 F055 T055 / T056、F054 OBO 验签~~ **上游已落地,2026-09-16 解除**) + +> ~~在上游落地前,本 Wave 的测试用 fake Port + `model_construct` 构造 `hosted_app` principal 通过(T010 已含),代码可先合;下列任务是**接线与真实断言**,须待上游。~~ +> +> **2026-09-16**:三项上游全部在 `3.0-vibe` 上:`resolve_hosted_app`(F055 T055)、`HostedAppDeclarationAdapter` + `derive_scopes` + `runtime_capability_env`(F055 T056)、`verify_obo_token` + `AccessSubjectVerifier`(F054)。两个 Port 由 **F055 的组合根 `app_publish/composition.py:register()` 一并注册**(两者必须同进同退,理由见该文件注释),组合根本身由 `main.py` 与 `worker/main.py` 各调一次。本面**代码零改动**即接通——这正是 D7 的 Port 设计要换来的结果;T022–T024 落的是真实断言。 + +- [x] **T022**: `hosted_app` principal 正式构造 + 范围收窄真实断言(3h)~~**依赖 F055 T055**~~ + **文件**: `src/backend/test/open_api/test_model_gateway_hosted_app.py`(新), `src/backend/bisheng/open_api/domain/services/model_range_policy.py`(若 F055 把 `actor_kind` Literal 扩为含 `"hosted_app"`,去掉 `model_construct` 变通;T055 的解析器返回的 principal 字段(`actor_id = app 内部 id`、`actor_name = slug`、`resource_owner_user_id = owner`)按 F055 实际回填本文 §4.2 ④ 的 `actor_*` 语义) + **逻辑**: 经真实 `CredentialService.issue(subject_kind="hosted_app", ...)` 签发 → `/models` 只返回 `declared ∩ C` → AC-34;`declared` 外模型 → 26515;`declared` 内已下线 → 26512 → AC-13 / AC-34;声明 Port 未注册 → 26516 → AC-35。 + **覆盖 AC**: AC-13, AC-34, AC-35 + **依赖**: T011, T015b, F055 T055 + **完成证据**(2026-09-16): + - `src/backend/test/open_api/test_model_gateway_hosted_app.py`(新,21 条,与 T023 共用)——范围半:`/models` 只回 `declared ∩ C`(声明里有、租户没有的 `claude-3` 与租户有、没声明的 `qwen-max` 都不出现)、未声明 → 26515、已下线 → 26512、租户没有 → 26511、缓存窗口内被摘 → **26513(不是 26515)**、声明为空 → 26515 而非 26516、Port 未注册 / 声明读不到 / 他租户声明 → 26516 且**绝不回退租户范围**。声明侧用的是 F055 的真 `HostedAppDeclarationAdapter`(只桩 `load_effective_declaration`),`None` ⟷ 空集的区分因此是真代码在判。 + - `src/backend/test/app_publish/test_capability_model.py`(+3 条端到端)——走**真实** `AppRuntimeCredentialService.issue` 签发 `bs-app-` 明文 → 真实 `validate_bearer` → `resolve_hosted_app` → 本面:声明内可调、租户其它模型 26515、声明内模型下线 26512 且声明本身不变。夹具 `published_app_client` 只把模型目录换成 F051 套件的**同一个** `install_catalog` 假件(模型管理的表不在 `test/app_publish` 的 schema 里),两侧对「租户有什么」的认知因此不可能分叉。 + - `model_construct` 变通已删:`test/open_api/model_gateway_fixtures.py:hosted_app_principal` 改为完整校验构造(`OpenApiPrincipal.actor_kind` Literal 已含 `hosted_app`)。 + - §4.2 ④ 的 `actor_*` 语义按 `resolve_hosted_app` 实际回填进 design.md(`actor_id = hosted_app_subject.id` / `actor_name = app.name` 显示名 / 应用机器标识只有 `subject_ref = app.id` 一个来源)。 + +- [x] **T023**: 访问凭据验签 Port 接线(F054 提供实现)+ subject 落记录(3h)~~**依赖 F054 OBO 验签**~~ + **文件**: `src/backend/bisheng/app_runtime/domain/services/obo_token.py`(**F054 归属**:从 `entry_authz_service.py:379-426` 抽 `OBO_AUDIENCE` / secret 读取 / `issue` 与新 `verify_obo_token(token, *, app_id, tenant_id) -> AccessSubject | None`;本任务只登记需求,实现 PR 由 F054 出或本 Feature 代出后由 F054 review), `src/backend/bisheng/main.py`(lifespan 注册 `register_access_subject_verifier(verify_obo_token)`——**放 `app_runtime` 组合根**,不在 `open_api` 内 import `app_runtime`), `src/backend/test/open_api/test_model_gateway_hosted_app.py`(增:有效头 → 行 `subject_kind=="user"`、`subject_id==user`;无头 → `app_self`;过期 / 错 `app_id` / 错签名 → 26504;任何情况下范围不随 subject 变) + **逻辑**: design D7;spec 决议-5。`app_id` 维度:行 `app_id = principal.actor_name`(slug)或 F055 定义的应用标识——以 T055 回填为准。 + **覆盖 AC**: AC-21, AC-22, AC-34 + **依赖**: T022, T018 + **完成证据**(2026-09-16): + - **实现已由上游提供,本任务不新写代码**:`verify_obo_token(token, *, app_id, tenant_id) -> int | None` 与其适配器 `AccessSubjectVerifier` 落在 `bisheng/app_runtime/domain/services/entry_authz_service.py`(F054 归属,非本 Feature 代出),注册点是 `bisheng/app_publish/composition.py:register()` 里的 `register_access_subject_verifier(AccessSubjectVerifier())` —— 与 T023 原计划的「放 `app_runtime` 组合根、在 `main.py` lifespan 注册」等价且更严:**两个 Port 在同一个函数里同进同退**,不会出现「声明读得到、令牌验不了 → 全部记成应用自身却照常服务」这种半接线。`main.py` 与 `worker/main.py` 各调一次组合根。 + - `test/open_api/test_model_gateway_hosted_app.py` 的 subject 半(用**真** `_issue_obo_token` 签 + 真 `AccessSubjectVerifier` 验,非 fake):有效令牌 → 行 `subject_kind == "user"` / `subject_id == 77` / `app_id == app.id`;无令牌 → `app_self` + `subject_id is None`,同时 `resource_owner_user_id` 照记(归属人与「谁发起的调用」是两列两义);**五种坏令牌**(他应用、他租户、非 JWT、已过期、错签名)参数化 → 一律 26504、不降级成 `app_self`、且在摸模型之前就拒(`records == []`);范围不随 subject 变(带/不带令牌的 `/models` 响应逐字节相同);判定序 ②→③(声明读不到 + 坏令牌 → 26504 而不是 26516)。 + - 注册本身也有守卫:`test_the_composition_root_installs_both_ports`(调真组合根后两个 Port 的类型断言)与 `test_both_process_entry_points_call_the_composition_root`(AST 断言 `main.py` / `worker/main.py` 都 import 了组合根)。 + - `app_id` 维度的悬而未决项按 T055 实际回填:**是 `principal.subject_ref`(`app.id` uuid),不是 `actor_name`(slug/显示名)**;design §4.2 ④ 与 D7 已同步。 + +- [x] **T024**: F055 声明 Port 注册验证 + 能力收回错误对齐(2h)~~**依赖 F055 T056 / T058**~~ + **文件**: `src/backend/test/app_publish/test_capability_model.py`(F055 归属,增:经本面 26515 / 26512 / 26513 与 F055 `16273` / `16274` 的对应关系——本面**不**改码,F055 在发布面读接口按 `declared ∖ 当前可解析` 计算「已失效」), `features/v3.0.0/055-app-publish-pipeline/design.md`(D13 与错误码表 16270–16289 段各补一句:**模型能力的运行期判定一律走 265 段**——「已收回」= 26512 / 26513、「未声明」= 26515;F055 的 `16273`(能力已被收回)/ `16274`(未在能力声明中的能力)**只用于知识库等非模型能力**) + **逻辑**: 消除两个错误码族对同一事件的重复——**两边各有一对码**(16273 ⟷ 26512/26513、16274 ⟷ 26515),只登记 16273 一半会留下同样的歧义。F055 AC-53 在模型面的表现 = 26512 / 26513(spec AC-13)、AC-51 的表现 = 26515(spec AC-34)。 + **覆盖 AC**: AC-13, AC-34 + **依赖**: T022 + **完成证据**(2026-09-16): + - **本面不改码**(任务原文即如此)。F055 侧两处文档补齐:`055/design.md` 错误码表 16270–16289 行加了「两码只用于知识库等非模型能力」的限定,「一码一义」清单加了第四条,写全**双向**对应(16273 ⟷ 26512/26513、16274 ⟷ 26515)并点名只登记一半会留下同样的歧义;D13 原有的那句保持不变(已正确)。 + - `test/app_publish/test_capability_model.py::test_this_features_capability_codes_are_never_raised_for_a_model` —— AST 遍历 `capability_bus_service`,断言每一处 `AppCapabilityRevokedError` / `AppCapabilityNotDeclaredError` 都显式带 `kind=CAPABILITY_KIND_KNOWLEDGE`;哪天有人给模型路径加一处 162 段的 raise,这条会点名文件行号。比散文声明强的地方就在这里。 + - `test_the_model_face_and_the_publish_surface_read_the_same_resolution` —— 同一份声明(`gpt-4o` 在线 / `retired` 下线)下,发布面的 `model_capability_status` 标记与本面的拒绝同源(都出自 `resolve_model_name`),避免「发布面说健康、面上拒」或反过来。 + - 本面 26513 的托管应用侧也补了用例(见 T022 证据行),因为 16273「已收回」对应的是 26512 **和** 26513 两个码。 + +- [ ] **T025**: 114 托管应用端到端(2h)**依赖 F055 T056 部署到 114** + **文件**: 本文「114 部署记录」小节 + **逻辑**: 发布一个声明了模型 `x` 的示例应用 → 容器内 `env | grep OPENAI_` 三名齐 → 应用内 `openai` 客户端调 `x` 成功、调 `y` 26515 → 管理员下线 `x` → ≤ 60s 26512、发布面标「已失效」→ `model_call_record` 行 `app_id` 与 `subject_kind` 正确(浏览器访问 → `user`,`curl` 容器内后台调用 → `app_self`)。 + **覆盖 AC**: AC-21, AC-33, AC-34 + **依赖**: T023, T024 + **⚠️ 部署前置(2026-09-16 复核补入,不做这一步「浏览器访问 → `user`」永远跑不出来)**: 114 的 `config.yaml` 必须配 `app_runtime.obo_secret`(`openssl rand -hex 32`,且**不等于** `jwt_secret`)。出厂默认是空串(`core/config/app_runtime.py:88`),`docker/bisheng/config/config.yaml:216` 也是注释掉的;空串时 `_issue_obo_token` **不签令牌、只按进程记一条 error 日志**,入口照常放行 —— 于是所有托管应用调用都落 `subject_kind=app_self`,与「应用没转发令牌」在本面完全不可区分,T025 的 subject 那一半会「通过得毫无破绽」却什么都没验到。核对方式:改完重启后端 → 浏览器进一次应用 → 后端日志无 `obo_secret is not configured` → 再跑本任务。 + + + +### Wave 7 · 114 E2E 与引擎适配(需 114) + +- [x] **T026**: 114 部署 + `/e2e-test` 脚本(pytest + httpx 打 114)(3h) + **文件**: `src/backend/test/e2e/test_model_gateway_114.py`(新,`-m e2e`,读 `BISHENG_E2E_BASE / BISHENG_E2E_SAK` 环境变量), 本文「114 部署记录」小节 + **逻辑**: 部署顺序:`bash /opt/bisheng-ops/deploy.sh`(`create_all` 建 `model_call_record`)→ `config.yaml` 确认 `open_platform.enabled: true`、`open_api.public_base_url`(114 用 `http://192.168.106.114:3001`)→ 重启 8 unit → 平台签一把 `model:invoke` 密钥。脚本覆盖:AC-01 / AC-05 / AC-06 / AC-12 / AC-13 / AC-14(下线 → 轮询 ≤ 60s;在途流式在下线后仍完整读到 `[DONE]`)/ AC-16(经 nginx 首字延迟 < 3s 且分块到达,证明 `X-Accel-Buffering` 生效)/ AC-18(tools 往返)/ AC-26 / AC-28(可选:改开关重启一次)。 + **覆盖 AC**: AC-01, AC-05, AC-06, AC-12, AC-13, AC-14, AC-16, AC-18, AC-26, AC-28 + **依赖**: T016, T018, T020 + + **114 验证记录(2026-09-16,`3.0-vibe` @ `3cf0df62a`)**:用一把带 `model:invoke` 的服务账号密钥(本轮新签,验完即停用)直打 114 的面: + - `GET /api/v2/model/v1/models` → 200,返回的是该租户**真实已启用**的模型目录(含 `bisheng_qualified_name` 限定名)。 + - `POST /api/v2/model/v1/chat/completions` → 200,真实上游返回,`usage` 为 `prompt_tokens=90 / completion_tokens=20 / total_tokens=110`。 + - 面下未支持路径 → 404;不带凭据 → 401。 + - **逐条记录已落库**:`model_call_record` 首行 `result=success`、`actor_kind=service_account`、`credential_mask=bs-sak-********…`、`server_name=阿里百炼`、token 三项与响应一致。 + - 顺带验证了权限位改造:`identity:read` / `model:invoke` 在 114 上**已可签发**(两个面都已交付),与本轮 `test_scopes.py` 的集合断言一致。 +- [ ] **T027**: 本地引擎手动验证(Qwen Code / Codex CLI 对话补全模式 / Kimi Code / Claude Code 反例)(3h) + **文件**: 本文「114 部署记录」小节(记录每个引擎的配置片段与结果;配置里密钥用占位符) + **逻辑**: `export OPENAI_BASE_URL=http://192.168.106.114:3001/api/v2/model/v1 OPENAI_API_KEY=bs-sak-…`:Qwen Code 跑一个需要 `read_file` 工具调用的任务 → 流式 + 工具往返成功 → AC-31;Codex CLI 切 chat completions provider → 成功;Kimi Code 同;Claude Code `ANTHROPIC_BASE_URL` 指向 base → 得到 26502 可读提示 → AC-32;`/api/v1/env` 关开关后(AC-28)Qwen Code 报 404。任一引擎失败 → 记入「实际偏差记录」并回 design 坑表。 + **覆盖 AC**: AC-03, AC-31, AC-32, AC-33 + **依赖**: T026 + +- [ ] **T028**: 审计与账本核对(1h) + **文件**: 本文「114 部署记录」小节 + **逻辑**: 114 MySQL:`SELECT credential_mask, model_name, server_name, prompt_tokens, completion_tokens, result FROM model_call_record ORDER BY id DESC LIMIT 10`;`audit_log` 中 `action='open_api.call' AND target_id LIKE 'POST /api/v2/model/v1/%'` 行数 ≥ 本表行数(含被拒);ES 遥测 `MODEL_INVOKE` 事件 `app_type=model_gateway` 的 token 数与本表同一 `trace_id` 行一致 → AC-23;grep 后端日志无 `bs-sak-` 全串与 `Authorization` → AC-36。 + **覆盖 AC**: AC-20, AC-23, AC-24, AC-36 + **依赖**: T026 + +- [x] **T029**: 发布说明 + docs 补章(1h) + **文件**: `docs/api/`(如有 v2 文档目录:新增「模型协议面」页,内容 = design §4.2 ①②③ + 引擎配置片段), `features/v3.0.0/release-contract.md`(变更历史加一行:F051 design / tasks 定稿、262 登记、`model:invoke` 可签发) + **并入本任务(从 T016 移来,2026-09-16)**: 模型面的对外客户文档写在这里,不再往 beta1 那两份手写 md 里塞——理由见 T016「未做的一半」。写这一页时顺带确认 `openapi-v2-key-auth-api.json` 是最新的(catch-all 不进契约是有意的),以及 `whoami.model_base_url` 是文中唯一的 base URL 出处。 + **覆盖 AC**: AC-30 + **依赖**: T027, T028 + **完成证据(2026-09-16,切片 `wt/f057-tail`)**: 新增 `docs/api/model-gateway.md`(照 `docs/api/mcp-server.md` 的房式,七节:接入 / 模型名解析四步 / 承诺参数 / OpenAI 错误体与码表 / 身份与边界 / 引擎配置片段 / 调用记录);`features/v3.0.0/release-contract.md` 变更历史加「2026-09-16(F051 收口)」一行。**逐条对着实现侧核过**:`model_gateway.py`(两条端点 + catch-all、`ANTHROPIC_MESSAGES_PATHS` 两种拼法)、`schemas/model_gateway.py`(承诺字段表、`extra="allow"` 原样转发、`reasoning_content` 非空才出现)、`model_gateway_service.py:148`(`n != 1` → 26503)、`common/errcode/model_face.py`(26501–26505 / 26511–26517 / 26531–26534 逐个)、`credential.py:136` 的 `model_base_url`。 + **顺带核对的两处,结论都是不改**: ① `features/v3.0.0-beta1/053-openapi-auth-and-identity/openapi-v2-key-auth-api.json` 已含 `/api/v2/model/v1/chat/completions` 与 `/models`(全表 48 条操作),是最新的、catch-all 如设计不进契约;② release-contract 错误码表 265 行(`:105`)早已登记且与 `model_face.py` 一致,未动。 + **偏差**: 「发布说明」在本仓没有独立的 release note 文件(`docs/blog/` 只有两篇长文,无版本发布说明),故按本任务文件清单落在 release-contract 变更历史;若后续建立版本发布说明文件,本行内容可原样搬过去。 + **同批复核订正(2026-09-16,切片内复核,四处失实已改)**:① §2 第 4 步原写「名称没命中时区分模型已下线 `26512` / 服务商已删除 `26513`」——`model_catalog._explain_miss()` 的注释明写它**不出 26513**(删服务商连同模型行一起删,与「从未存在」无从分辨),26513 只在目录缓存窗口内 `get_bisheng_llm` 发现行已消失时出现,已按实现改写并写明窗口来自 `open_api.model_catalog_ttl_seconds`(默认 30s、上限 60s);② §1 原写「页面也从 `whoami.model_base_url` 取」——接入信息区实际读 `GET /api/v1/dev-toolkit/versions` 的 `model.base_url`(`AccessInfoPanel.tsx:88`),两者同出 `model_gateway_base_url()`,改成「只有一个生产者、两个字段」;③ §3 原写 `reasoning_content`「为空时不出现」——只有流式增量成立(`openai_codec.sse_line` 用 `exclude_none=True`),非流式响应体没有 `response_model_exclude_none`,为空时是 `null`;④ §4 码表补 `26043` 与各码的 `openai_code`,`26505` 单独成行(它是 `permission_error`,与共用底座那五个码的 `invalid_request_error` 不同)。同批把 `docs/README.md` 的 `api/` 一行从「filelib 纯检索、知识空间/知识库接口」补上 MCP 面与本页。 + +--- + +## AC 追溯表(36 / 36 全覆盖,2026-09-16 `/sdd-review tasks` 复核:每条 AC 至少一个带「覆盖 AC」标注的任务) + +| AC | 任务 | 阶段 | +|---|---|---| +| AC-01 | T019, T026 | 本地 / 114 | +| AC-02 | T012, T013, T019 | 本地 | +| AC-03 | T012, T013, T027 | 本地 / 114 | +| AC-04 | T010, T011 | 本地 | +| AC-05 | T012, T013, T026 | 本地 / 114 | +| AC-06 | T012, T013, T026 | 本地 / 114 | +| AC-07 | T012, T013 | 本地 | +| AC-08 | T012, T013, T014, T015a, T015b, T019 | 本地 | +| AC-09 | T008, T009, T010, T011 | 本地 | +| AC-10 | T008, T009, T019 | 本地 | +| AC-11 | T008, T009 | 本地 | +| AC-12 | T008, T009, T019, T026 | 本地 / 114 | +| AC-13 | T008, T009, T022, T024, T026 | 本地 / [后] | +| AC-14 | T008, T009, T026 | 本地 / 114 | +| AC-15 | T014, T015b, T017, T018 | 本地 | +| AC-16 | T014, T015a, T015b, T019, T026 | 本地 / 114 | +| AC-17 | T014, T015a, T015b | 本地 | +| AC-18 | T014, T015a, T015b, T019, T026 | 本地 / 114 | +| AC-19 | T014, T015b | 本地 | +| AC-20 | T012, T017, T018, T028 | 本地 / 114 | +| AC-21 | T010, T011, T023, T025 | [后] | +| AC-22 | T010, T011, T023 | 本地 / [后] | +| AC-23 | T014, T015a, T015b, T017, T018, T028 | 本地 / 114 | +| AC-24 | T017, T018, T028 | 本地 / 114(查询面 UI 归 F056) | +| AC-25 | T017, T018(`test_no_aggregation_or_quota_surface` 反向断言,必做;design §8 同步登记「不做」) | 本地 | +| AC-26 | T012, T013, T026 | 本地 / 114 | +| AC-27 | T010, T011, T012, T013 | 本地 | +| AC-28 | T012, T013, T026 | 本地 / 114 | +| AC-29 | T012, T013 | 本地 | +| AC-30 | T020, T029 | 本地 | +| AC-31 | T021, T027 | 114 | +| AC-32 | T012, T013, T027 | 本地 / 114 | +| AC-33 | T020, T021, T025, T027 | 114 / [后] | +| AC-34 | T008, T009, T010, T011, T022, T023, T024, T025 | 本地 / [后] | +| AC-35 | T008, T009, T010, T011, T022 | 本地 / [后] | +| AC-36 | T017, T018, T028 | 本地 / 114 | + +~~`[后]` = 依赖 F055 T055 / T056 或 F054 OBO 验签,上游未落地前只有 fake Port 用例通过。~~ +**2026-09-16 起 `[后]` 全部解除**:上游三项已在 `3.0-vibe` 上,T022 / T023 / T024 已按真实实现断言(fake Port 的单元用例 T010 保留——它守的是判定序与 Port 契约本身,与真实现的用例互补而非重复)。AC-13 / AC-21 / AC-22 / AC-34 / AC-35 的本地覆盖因此不再打折;标 `[后]` 的行里只剩 T025 需要 114。 + +--- + +## 114 部署记录 + +> 未开工。T026 起在此追加:部署命令 / 配置片段(密钥占位)/ 每个引擎的结果 / SQL 核对结果。 + +--- + +## 实际偏差记录 + +> **只留一行指针**,论证在 design.md(决策 / 坑),这里不重复(见 `docs/SDD-Guide.md` §4)。 +> 推翻已定案的决策时,先停下与用户重新确认(§3 第四个 ★),再记录。 + +**2026-09-16 Wave 1–5 实施(T001–T021)** + +1. **`acollect_visible_server_ids` 实际抽成了两个函数** —— design D6 只写了 id 版;catalog 还要服务商的 `name` / `type` 才能算限定名与 `owned_by`,只给 id 会逼出第二次 `aget_server_by_ids`。落地为 `acollect_visible_servers(leaf_id, *, strict)` 返回行,`acollect_visible_server_ids` 是它的薄封装(签名与 design 一致,供只要 id 的调用方)。`get_all_llm` 改调前者,行为不变。 +2. **开关关闭时本面返回的是 v2 信封 404(`{"status_code":404,"status_message":"Not Found"}`),不是 design D3 写的 Starlette 裸 `{"detail":"Not Found"}`** —— 路径落在 `/api/v2` 前缀下,既有的 `open_api_http_exception_handler` 会把任何 404 统一渲染成信封,这在本面之前就是全部不存在的 v2 路径的行为。AC-28 要的是「与不存在的路径无差别」,这一条仍然成立且更强:`test_model_gateway_switch.py::test_the_face_is_indistinguishable_from_nothing_when_not_deployed` 直接断言本面路径与一个从来不存在的 `/api/v2/no-such-surface/models` **响应体逐字节相同**,而不是断言某个固定的 JSON 形状。 +3. **契约生成脚本自己打开开关,而不是要求运行者改 config.yaml** —— design D3 末段说「契约 json 用 `open_platform.enabled: true` 的 config 生成」。实际落地是在 `generate_openapi_contract.py` 里 import app **之前**把开关置 true 并留注释。理由:客户契约文档描述的是完整承诺面,不该取决于跑脚本的人手上那份 YAML;而 `test_openapi_schema_contract.py` 已按前缀剔除期望集合,两态都对得上。 +4. **范围策略阶段的拒绝(26505 / 26504 / 26516)不写 ModelCallRecord** —— design D9 写「解析失败(26511–26516)也 enqueue」,但 26516 有两个来源:能力声明读不到(范围确立阶段,尚未到模型解析)与目录读不到(解析阶段)。按 AC-20 的措辞「到达模型解析阶段」取前者不写、后者写。被拒调用仍由中间件的 `open_api.call` 审计行承接,`test_a_call_refused_before_model_resolution_gets_no_usage_row` 守住。 +5. **`ChunkAssembler` 的 `finish_reason` 优先采信上游 `response_metadata.finish_reason`,`tool_calls` 只是兜底** —— design D8 的措辞容易读成「有 tool_call 就写 tool_calls」。上游是权威的(`length` / `content_filter` 必须原样传出),所以顺序是先读上游、读不到才按有无 tool_call 推断。 +6. **`_explain_miss` 的两次补查也走 `strict=True`** —— 失败路径再遇到权限后端故障时,答案应当是 26516 而不是把它误判成 26511「不存在」。 +7. **Wave 6(T022–T025)与 Wave 7(T026–T029)未做**:前者阻塞于 F055 T055(`hosted_app` 主体解析器 + `OpenApiPrincipal.actor_kind` Literal 扩容)/ F055 T056(注册 `HostedAppDeclarationPort`)与 F054 的 OBO 验签实现;本面的 hosted_app 分支已按契约写完并以 fake Port 测通(T010 / T011),上游落地后只需注册实现、不改本面代码。后者需要 114。 + —— **2026-09-16 更新**:阻塞解除,T022 / T023 / T024 已落地(见下方 15–18);「上游落地后只需注册实现、不改本面代码」这句话被证实了,本面一行没改。T025 与 Wave 7 仍未做,全部需要 114。 + +**2026-09-16 复核(Wave 1–5 实施后的审查与修正)** + +8. **26513「已收回」在解析阶段不可达,改由实例化阶段承担** —— design D5 第 4 步写「服务器被删(模型行残留、`server_id` 无对应)→ 26513」,但 `LLMDao.adelete_server_by_id` 删服务商时**一并删掉它的模型行**,而 `aget_model_by_server_ids` 又只查可见服务商下的模型:两条一叠,「服务商已删、模型行残留」这个状态数据库根本产生不了,`_explain_miss` 里那个分支是死代码。原 `test_offline_missing_and_provider_deleted_are_distinguishable` 之所以绿,是因为 T005 的 `install_catalog` 假 DAO **忽略了 `server_ids` 过滤**,凭空造出一行数据库给不出的数据。修正:① 假 DAO 改为真的按 `server_ids` 过滤(假件与真 DAO 契约一致,否则测试守的是想象中的代码);② 删掉 `_explain_miss` 的死分支,并在函数 docstring 写明 26513 的真实来源;③ 测试拆成「下线 vs 不存在可区分」「他租户模型读作不存在」「服务商删干净后只能答不存在」三条,另在 `test_model_gateway_stream.py` 补 `test_a_model_withdrawn_inside_the_cache_window_is_reported_as_such` —— 参数化断言 `_LLM_ERROR_TRANSLATION` 五条映射(10009 / 10010 → 26513、10012 → 26512、10011 → 26511、10013 → 26531),这条链路此前**一条测试都没有**。对 AC-13 的净效果:目录缓存窗口内(≤60s)「已收回」仍可区分,窗口过后与「不存在」合并——后者本就是 AC-13 要求的不透露存在性的答案。 +9. **D7 判定序在 hosted_app 分支被写反(②③ 对调),已改回** —— 原实现先读能力声明 Port、再验访问凭据,于是「伪造/过期令牌 + 声明读不到」会答 26516(「暂时判不了,重试」)而不是 26504。令牌永久无效的调用方会被这句话送进一个永远成功不了的重试循环。已把验签移到声明读取之前,并补 `test_a_forged_token_is_refused_even_when_the_declaration_is_unreadable`。 +10. **`model_catalog` 的 `tenant_id` 参数此前不是权威的** —— `acollect_visible_servers(leaf_id)` 只拿 `leaf_id` 做 FGA / 继承查询,自有行那一条走的是 `strict_tenant_filter()` + 租户 ContextVar;而本模块**按 `tenant_id` 缓存**。两者一旦不一致(后台任务、或 F052 / F055 在别的租户上下文里带 tenant_id 调用),就是把甲租户的行缓存到乙租户键下、有效期 60 秒的跨租户泄漏。新增 `_as_tenant(tenant_id)` 上下文管理器,在目录加载与 `_explain_miss` 期间把 `current_tenant_id` / admin-scope 覆盖 / `visible_tenant_ids` 一起绑到参数上,使公开签名名副其实。本面自身行为不变(`verify_open_api_access` 本就设了同样的三项)。 +11. **Anthropic 路径只认了 `messages`,认不出真实客户端发的 `v1/messages`** —— Anthropic 客户端拿到的是 origin、自己补 `/v1/messages`;把 `ANTHROPIC_BASE_URL` 指到我们公布的地址(已以 `/v1` 结尾)就会打到 `…/model/v1/v1/messages`,原实现答 26501「没这个端点」而不是 AC-03 / AC-32 承诺的协议提示。已改为两种拼法都答 26502,测试参数化。 +12. **`_instantiate` 失败一律记 `model_unavailable`,与 D9 的 result 词表不符** —— 10013(provider 客户端初始化失败)映射到 26531 上游失败,却在账本里记成「模型不可用」。已按错误族分流:26511 / 26512 / 26513 记 `model_unavailable`,其余记 `upstream_failed`。 +13. **三处测试补强** —— ① `client_disconnected` 分支此前无任何用例(T014 / T017 都写了要断言),补 `test_a_client_that_walks_away_mid_stream_is_recorded_as_such`;② `test_a_full_queue_is_reported_rather_than_silently_dropped` 原本只断言 `enqueue` 返回 `False`,`caplog` 拿到手却没断言(loguru 不进 `caplog`,断了也是空转),改为断言 `emit_metric("model_call_record", status="dropped")` 真的发出——「不静默」这半句这才有人守;③ AC-06 的反向蕴含(持 `model:invoke` 不能调 `/api/v2/workstation/chat/completions`)T012 写了但没落地,补 `test_model_invoke_does_not_open_the_daily_chat_data_plane`。 +14. **仍未覆盖、且本地覆盖不了的一条(Wave 1–5 口径)**:T017 列的 `test_telemetry_event_same_tokens`(AC-23「与平台既有 token 账本同源」的端到端断言)没有落地,也不打算在单测里造——本面的用例把 `BishengLLM` 整个替换成假件,遥测那一段根本不会跑,造一个假遥测只会守住假件自己。今天的证据是 `test_the_model_is_built_for_this_face_and_charged_to_the_resource_owner`(`app_type == MODEL_GATEWAY`、`user_id == resource_owner_user_id`)+ `usage_from` 与遥测取同一个最终结果对象;真正的同源核对是 T028 在 114 上按 `trace_id` 比对 ES 事件与 `model_call_record`。 + +**2026-09-16 Wave 6 实施(T022–T024,上游解除阻塞后)** + +15. **T023 计划的注册落点改了,而且比原计划严** —— 任务原文写「新建 `app_runtime/domain/services/obo_token.py`、在 `main.py` lifespan 调 `register_access_subject_verifier`」。实际上游已经把 `verify_obo_token` 留在 `entry_authz_service.py`(与 `_issue_obo_token` 同文件,签发与验签的常量共享因此是编译期保证的,不是约定),适配器 `AccessSubjectVerifier` 也在那里;**注册点是 F055 的组合根 `app_publish/composition.py:register()`,两个 Port 同一个函数装**。没有另建模块、没有在 `open_api` 里 import `app_runtime`(依赖方向仍是 F055 → F054 → 无),并且避免了「只注册一个」的半接线。按「上游已给出更好的实现就不重复造」处理,本任务不新写实现代码。 +16. **T022 要求的「经真实 `CredentialService.issue` 签发」落在 `test/app_publish/`,不在 `test/open_api/`** —— 真实签发要 `app` / `hosted_app_subject` / `api_credential` 三张表加凭据缓存,这套 SQLite 夹具只有 `test/app_publish/conftest.py` 有(`publish_db` / `app_factory` / `credential_redis` / `hosted_app_resolver`),在 `test/open_api/` 里重建一份等于把 F055 的 schema 抄第二遍。落法:端到端三条进 `test/app_publish/test_capability_model.py`(该文件本就是 T024 指定的 F055 归属落点),面行为的 21 条进 `test/open_api/test_model_gateway_hosted_app.py`(T022 指定的文件名,用 `hosted_app_principal()` + 真 Port)。两边共用 `test/open_api/model_gateway_fixtures.py` 的 `install_catalog`,所以「租户有哪些模型」不会两处分叉。 +17. **`hosted_app_resolver` 夹具从 `test_app_credential.py` 移进 `test/app_publish/conftest.py`** —— 它要拆五个进程级注册表,第二份拷贝迟早漂移。`test_app_credential.py` 的 40 条用例逐字未改,仍全绿。 +18. **T023 顺手核实到一条 F054 归属的 fail-open,未在本 Feature 修** —— `_issue_obo_token` 在 `obo_secret` 缺失、或与 `jwt_secret` 相同时**不签令牌、只记一条进程级日志**(`entry_authz_service.py:_warn_once`),入口照常放行。该函数的注释自己写了「等 OBO 有了第一个消费方就必须改 fail-closed」——本 Feature 就是那个消费方,但收紧的是**入口放行判据**(F054 的 AC-34 领域),不是本面的行为:本面这侧的表现是所有调用落 `app_self`,与「应用没转发令牌」无法区分,符合 spec 决议-5 允许的降级。已写进 design.md §6.2 的 F054 依赖行,留给 F054 处置。 + +**2026-09-16 Wave 6 复审(reviewer 复核 T022–T024 后补记)** + +19. **「组合根被两个进程都调到」这条守卫原先只断言了 import** —— `test_both_process_entry_points_call_the_composition_root` 遍历 AST 找 `ImportFrom`,但 `main.py` / `worker/main.py` 都是在 `_register_app_publish_composition()` 里 `from ... import register as register_app_publish` 再调用;**删掉调用、留下 import**,原断言照样绿,而那个进程里两个 Port 全停在 fail-closed 默认值上——正是这条用例声称要拦的事。已改为先取 `register` 的本地别名、再断言存在对该别名的 `ast.Call`(用把 `register_app_publish()` 换成 `pass` 的变异验证过:改前通过、改后失败)。 +20. **已知未覆盖的一个角落:声明写裸名、事后变歧义** —— 范围判定是 `ModelRange.allows` 的**名称集合成员测试**(`model_catalog.py:80-84`:`model.model_name in declared or model.qualified_name in declared`),不是「按 D5 重新解析一次」。声明写 `gpt-4o`、发布后管理员又接入第二个同样提供 `gpt-4o` 的服务商时:发布面 `model_capability_status` 走 `resolve_model_name(tenant, "gpt-4o")` 得 26514 → 标 `REASON_AMBIGUOUS` / 「已失效」,本面却仍然放行**限定名**请求(`azure-openai/gpt-4o` 与 `other/gpt-4o` 都过),等于应用拿到了一个它从未声明过的服务商。两侧因此并不同源,`test_the_model_face_and_the_publish_surface_read_the_same_resolution` 只覆盖了在线 / 下线 / 不存在三档(该用例 docstring 已写明边界)。**本轮不改语义**:AC-34 原文就是「声明中的模型 ∩ 租户已启用」的集合口径,而 D7 那句「按 D5 规则解析后与 C 求交」指向另一种口径,先由 F051 / F055 裁定该角落答 26514(与发布面一致)还是维持现状,再动 Wave 1–5 的 `allows`。触发条件窄(必须发布之后才出现同名服务商,此时再发布会被 F055 预检 16224 拦下),不阻塞交付。 diff --git a/features/v3.0.0/052-mcp-server-face/design.md b/features/v3.0.0/052-mcp-server-face/design.md new file mode 100644 index 0000000000..4fde8f8bf3 --- /dev/null +++ b/features/v3.0.0/052-mcp-server-face/design.md @@ -0,0 +1,389 @@ +# Design: MCP Server 工具面与统一检索门面(六类工具 + 文件级过滤门面 + `delegate` 入口拒绝) + +> **本文档定位 — 现状快照(Why this How)** +> +> - `spec.md` 回答 **做什么**(47 条 AC、边界、决议 1–12) +> - `design.md`(本文)回答 **为什么这么实现**:关键决策(含被否决的备选)、运行时不直观的事实、对外契约 +> - `tasks.md` 是 **流水账**:拆了哪些任务、做了什么改动 +> +> 调整原则(详见 `docs/SDD-Guide.md` §3-§4):实现变化 → 覆盖更新本文档、只留"今天的状态";但每个决策保留"为什么 + 被否方案"和坑。推翻已 ★ 确认的决策 → 停下与用户重新确认;纯实现细节 → 直接改 design。 +> +> **代码事实口径**:本文所有 `文件:行号` 均按 `3.0-vibe`(HEAD `fe10f75ea`,含 F048 + beta2 F053/F066 开放 API 底座 + F054/F055 MVP-核心)在 2026-09-16 核实、并于同日 `/sdd-review design` 独立审查时逐条重 grep 订正,路径以 `src/backend/bisheng/` 为根(前端另注 `platform/` = `src/frontend/platform/src/`)。**`mcp` SDK 的行号以主检出的虚拟环境为准**:`/Users/lilu/Projects/bisheng/src/backend/.venv/lib/python3.11/site-packages/`(worktree 内没有 `.venv`,别在 worktree 里找),版本 `mcp==1.27.1`(`uv.lock:3316-3317`;`pyproject` 约束 `>=1.27.0`,升级前先复核 K11 与坑 14 / 19 / 20)。行号会漂移,符号名不会——落地前以符号名重定位。凡文档锚点已在代码中消失的,标「已失效」。 +> +> **本文是"要建成的样子"**:F052 尚未开工——树上零 MCP *服务端*代码(`grep -rn "from mcp" bisheng/` 只命中 `mcp_manage/clients/{sse,stdio,streamable,base}.py` 四个客户端),零会话解耦的检索门面。实现后按现状覆盖本文。 +> +> **全自动模式**:2026-09-16 用户已豁免本 Feature 的 ★ 暂停点;§3 每条决策均标「全自动模式定案」,理由与备选留痕供追溯。 + +**关联**: [spec.md](./spec.md) · [tasks.md](./tasks.md) · [release-contract.md](../release-contract.md)(INV-27~31 / INV-36;表 3 F052 行;错误码表 **263** 段由本 Feature 落定)· 姊妹 [F049 design](../049-openapi-auth-baseline/design.md)(已归档,D2「主体解析器」段仍有效)· [beta2 F053 spec](../../v3.0.0-beta1/053-openapi-auth-and-identity/spec.md)(现行开放 API 底座)· [F054 design](../054-app-domain-runtime/design.md) D10(数据面落点)· [F055 design](../055-app-publish-pipeline/design.md) D13 / D15 · [F053 tasks](../053-dev-cli-skills/tasks.md) T046(接入信息区)· [F051 spec](../051-model-protocol-gateway/spec.md) AC-10~12(名称解析)· [F057 spec](../057-bisheng-sdk/spec.md) AC-11~16(SDK retrieve) +**版本**: v3.0.0 +**最后更新**: 2026-09-16(初版,全自动模式定案;尚未开工) + +--- + +## 1. 目标与非目标 + +- **目标**:两件事。① 在平台 API 进程内、`/api/v2/mcp` 这一条精确路径上起一个标准 **MCP Server**(streamable-http、无状态、JSON 响应),凭 `bs-sak-` / `bs-pat-` 密钥鉴权、一律模式 S、工具清单按权限位过滤、每次调用计审计、拒绝一切委托——让本地 coding agent 零改造调平台六类工具(检索 / 知识库清单 / 模型清单 / 身份组织 / 应用数据 / 应用状态日志)。② 把「文件级过滤 + fail-closed」的检索能力从聊天服务里抽成一个**会话解耦的统一检索门面** `RetrievalFacadeService`(入参 = 执行身份 + 查询 + 可选声明白名单),MCP 检索工具、v2 `POST /filelib/retrieve`、F055 托管运行期、F057 SDK retrieve 四处共用;F050 模式 D「集合相等」由它兜底。 +- **非目标**(防扩范围):模型协议面与模型调用(F051,本 Feature 只做清单工具);CLI / 技能包 / 接入信息区界面(F053,本 Feature 只交付 MCP 地址字段);SDK(F057);服务账号 / 密钥 / 权限位定义 / 开放能力层开关本身(beta2 F053 已交付,本 Feature 只消费并翻转 `identity:read` 可签发);模式 D 准入与验收(F050);能力声明冻结 / 审读 / 注入通道 / 收回错误态 16273(F055,本 Feature 只给出「能力已收回」信号);每应用数据库服务端 `AppDataService` 与 manager 数据面 RPC(F054 T086/T087,**HEAD 树上尚不存在、由未合并的 data-plane 切片交付**,本 Feature 只接线,见 D9 / §6.2);QA 库 / 个人库经门面检索;per-tool 授权粒度;MCP 限流与账单;平台内对话检索链的取向翻转(决议-9);SSE / 有状态 MCP 会话 / 服务端主动通知(D1)。 + +--- + +## 2. 关键约束 + +> 全局铁律(C1–C8)一律遵循 [`docs/constitution.md`](../../../docs/constitution.md),本节不重抄。以下只写本 Feature 特有的硬约束。 + +| # | 约束 | 出处 / 后果 | +|---|---|---| +| K1 | **MCP 面不新增常驻进程 / 端口**:随 API 进程条件注册(`settings.open_platform.enabled`),未部署时路径 404、零常驻负担 | spec 决议-1 / AC-37;先例 `api/router.py:140-141` 对 `dev_toolkit_router` 的条件 include | +| K2 | **鉴权语义与 `/api/v2` 完全同一**:同一 `validate_bearer`(`open_api/domain/services/credential_validator.py:117`,Redis 缓存 TTL 由 `core/config/open_platform.py:47-51 cap_credential_cache_ttl` 封顶 5s,默认 3s)、同一租户 / 可见租户 ContextVar 装配、同一 `PermissionActor`;**但 MCP 面没有端点 marker**(它注册成 Starlette `Route`/`Mount`,都不是 `APIRoute`,`router_rpc` 的 `verify_open_api_access` 路由级依赖对它不生效;即便生效,`dependencies.py:100` 读 `conn.scope.get("endpoint")` 也拿不到带 `@open_api_scope` 的函数 → 26031)——鉴权必须由 MCP 自己的 ASGI 闸完成(D2) | AC-02 / AC-05 / AC-06;INV-27 / INV-28 | +| K3 | **一律模式 S、拒绝一切委托**:持 `delegate` 的密钥在入口按位拒(复用 26051);任何身份传递头(`X-On-Behalf-Of` / `X-End-User` 及 `*-on-behalf-of` / `*-end-user` 变体)一律拒(新码 26303),**不得**像 v2 那样解析它们(`identity_service.py:61 resolve_request_identity`) | AC-28~30;INV-31 | +| K4 | **主体两类皆可**:`service_account` 与 `natural_person`(PAT)都是模式 S「自身身份」(spec 2026-08-28 订正;伴生 PRD §4.10.8 明写「个人令牌只会看到检索与知识空间清单两个工具」);PAT 的租户策略与 `data_scope`(F066)按 `dependencies.py:86-98` 原样执行 | AC-03 | +| K5 | **`/api/v2` 下的守卫测试**必须适配:`test/open_api/test_open_api_route_matrix.py:36-41 test_every_real_v2_route_is_globally_key_protected_and_marked` 对 `app.routes` 中所有 `/api/v2` 前缀路由取 `route.endpoint` 并断言 `get_open_api_scope_marker(...) is not None`——MCP 那条 Starlette `Route` 的 `endpoint` 是 ASGI 闸对象、没有 marker → 断言必红(若改用 `Mount` 则更糟:`Mount` 连 `.endpoint` 属性都没有 → AttributeError)。同文件的 `actual_v2_routes() :25-34` **已经**用 `isinstance(route, (APIRoute, APIWebSocketRoute))` 过滤,无需改动。`test_openapi_schema_contract.py:34-52` 要求 `app.openapi()` 里的 `/api/v2/**` 操作与 beta2 契约文件相等——非 `APIRoute` 不进 `app.openapi()`,故不撞 | D1 | +| K6 | **错误码 263 段**(本 Feature 落定):`common/errcode/mcp_face.py`,`McpFaceError(BaseErrorCode)` 基类带 `http_status`;子段 `26300–26319` MCP 传输 / 工具面、`26320–26339` 统一检索门面(四处调用方共用)、`26340+` 保留。**不占** 260 段(`open_api.py:219` 注释保留段 26032–26039 / 26045–26049 由 `test/open_api/test_error_codes.py:49-51` 钉死)。每个新码三语文案同 PR(`packages/locales/src/api_errors/{zh-Hans,en,ja}.json`,CI `pnpm check-i18n`) | C5;release-contract 错误码表 | +| K7 | **门面 fail-closed 只作用于门面调用面**:`PermissionServiceUnavailableError`(19002)/ `PermissionBackendUnavailableError`(19201)原样上抛,由 v2 handler 映射 503(`open_api/api/exception_handlers.py:56-69` 的权限族 `issubclass` 分支)、由 MCP 错误层映射 `permission_unavailable`;**不改** `KnowledgeSpaceChatService` 平台内对话路径、也不改 `WorkstationService` 工作台检索链(`workstation_service.py:1401-1405`)的任何行为(决议-9;核实结论见坑 3) | AC-24 / AC-44;INV-30 | +| K8 | **审计不记检索正文、响应不回显密钥**:审计 metadata 只含工具名 / 目标摘要(id 列表)/ 结果类别 / 时延;任何 MCP 响应体与日志不得出现 `bs-sak-` / `bs-pat-` 前缀字符串 | AC-07 / AC-47;NFR-2 | +| K9 | **应用类工具 owner 收窄是业务规则前置拦截**:`AppQueryService._require_log_access`(`app_runtime/domain/services/app_query_service.py:263-290`)对 `_OWNER_ONLY_ENTRIES={cli,mcp}` 已按「先比租户、再比 owner、管理员不放行」实现;但 `get_instance → _load_visible :246-261` 与 `PublishStatusService._require_viewer`(`app_publish/domain/services/publish_status_service.py:181-197`)**仍放行租户管理员**——本 Feature 给两者加 `entry` 参数(D9) | AC-34~36 | +| K10 | **多节点默认**:MCP 面无状态(每个工具调用 = 一次 HTTP POST),不得引入进程内会话表;凭据失效只靠 Redis(K2 的 5s 上界) | C8;memory `feedback_multinode_default_assumption` | +| K11 | **`mcp==1.27.1` SDK 的两处默认值必须显式覆盖**:`FastMCP(host="127.0.0.1")` 时自动装 DNS-rebinding 防护、只放行 `127.0.0.1:* / localhost:* / [::1]:*`(`mcp/server/fastmcp/server.py:177-183`)——nginx `location ~ ^(/workspace)?/api(/|$)` 透传的是真实 `Host`(`docker/nginx/conf.d/default.conf:121 proxy_set_header Host $host`),会被 421 拒;`StreamableHTTPSessionManager.run()` 必须作为 lifespan 上下文进入且**每实例只能进一次**(`mcp/server/streamable_http_manager.py:98-137`),否则 `handle_request` 抛 RuntimeError | D1;坑 1 / 坑 2 | + +**Constitution Check(自查)**:C1 新包 `open_api/mcp/` 是入口层(与 `open_api/api/` 平级),只调各模块的 `domain/services`(门面 / `OrgDirectoryService` / `PublishStatusService` / `AppQueryService` / `AppDataService`)与 `permission.application`,不直接碰 ORM、不 import 任何其它模块的 `api/`;`knowledge/domain/services/retrieval_facade_service.py` 与 `retrieval_engine.py` 是领域服务,不 import 任何 `api/`(`f048` 资源类型名只作字符串常量使用,判定经 `permission.application.business_authorization`)。C2 无新表、无 Alembic、不碰 per-app SQLite(数据工具只透传 F054 `AppDataService`)。C3 MCP 闸无条件 `current_tenant_id.set(principal.tenant_id)` + `visible_tenant_ids.set({1, tenant})`(复刻 `dependencies.py:76-80`);组织查询在该 ContextVar 下走自动过滤。C4 所有可见性判定经 `permission.application.business_authorization.batch_check_business_actions` / `runtime.list_visible_objects`,无 OpenFGA 直连;owner 收窄是业务规则、显式不依赖权限运行时。C5 见 K6。C6 密钥明文不落盘不进日志不进响应(K8)。C7 前端仅 F053 消费一个只读字段,经 `controllers/API/`。C8 见 K10。RULE-5:`open_api/mcp/*` 只 import 本模块 `open_api/api/dependencies.py` 的导出与各模块的 `domain/services`,不 import 其它模块的 `api/`。 + +--- + +## 3. 方案对比与选定 + +> 每条:备选 / 选定 / 原因 / 何时该重新考虑。均为 **全自动模式定案**(2026-09-16)。 + +### D1:MCP 传输形态 = FastMCP streamable-http · **stateless + json_response** · 以**精确 `Route`**(不是 `Mount`)注册在 `/api/v2/mcp` + +- **备选**: + - A. **独立进程 / 端口**(`uvicorn` 起第二个 app)— 部署干净;但每环境多一个进程 + nginx location + 网关放行,鉴权与租户注入要跨进程重做;违反 K1 + - B. 同进程、`app.mount("/api/v2/mcp", fastmcp.streamable_http_app())` — **否决,见下** + - B′. **同进程、精确路由**:`app.router.routes.append(Route("/api/v2/mcp", endpoint=McpAccessGate(StreamableHTTPASGIApp(server.session_manager)), methods=["GET", "POST", "DELETE"]))`,无状态(每请求新 transport)、JSON 响应(不开 SSE 流) + - C. 同进程、有状态会话 + SSE(`stateless_http=False`)— 支持服务端通知 / 进度;但会话表落在进程内(K10 违规)、nginx `/api` location(`docker/nginx/conf.d/default.conf:117-132`)**没有** `proxy_buffering off`(全仓只有 `/apps/` 那段关了缓冲,`:94`)、商业网关对长响应未验证 + - 路径:`/mcp`(顶级)vs `/api/v2/mcp`(v2 前缀下) +- **选定**:**B′**,路径 **`/api/v2/mcp`**(单一稳定 URL,客户端不追加任何子路径、也不需要尾斜杠)。装配: + 1. `FastMCP(name="bisheng", stateless_http=True, json_response=True, streamable_http_path="/", transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False))`(坑 1;显式给了 `transport_security` 后 `host` 参数与本面无关,不必也不要写 `host="0.0.0.0"`); + 2. **先调一次 `server.streamable_http_app()`**——`session_manager` 属性在此之前读会抛 `RuntimeError("Session manager can only be accessed after calling streamable_http_app().")`(`mcp/server/fastmcp/server.py:260-276`,manager 是懒建的);它返回的那个 Starlette app **丢弃不用**,只留 `server.session_manager`; + 3. `create_app()` 内 `if settings.open_platform.enabled:` 追加上面那条 `Route`(K1,先例 `api/router.py:140-141`);`main.py lifespan` 内同一开关下 `async with server.session_manager.run():`(K11 / 坑 2)。 +- **为什么不是 `Mount`(B 被否的硬理由)**:Starlette `Mount` 的路径正则是 `compile_path(self.path + "/{path:path}")`(`starlette/routing.py:395`),即 `^/api/v2/mcp/(?P.*)$`——**裸路径 `/api/v2/mcp` 根本不匹配**,FastAPI 根 router 的 `redirect_slashes`(`starlette/routing.py:751`)会先回一个 307 指向 `/api/v2/mcp/`。官方 python SDK 的 httpx 默认 `follow_redirects=True`(`mcp/shared/_httpx_utils.py:70`)能兜住,所以本地测试会「看起来是通的」,但**每次 `tools/call` 都多一次往返**,而 Claude Code / Cursor 等非 SDK 客户端与商业 Java 网关都不保证对 POST 跟随 307。本仓咬过同款坑(memory `project_hosted_app_white_screen_trailing_slash`:app-proxy 的 308)。精确 `Route` 没有这个语义,且 `Route(endpoint=)` 是 SDK 自己就在用的形态(`fastmcp/server.py:1013-1021` 把 `StreamableHTTPASGIApp` 当 endpoint 塞进 `Route`)。 +- **原因**:六类工具全是「请求-响应」型薄封装,无一需要流式或服务端推送;无状态让「撤销 / 到期 / 权限位编辑即时生效」(AC-05 / AC-06)**结构性**成立——不存在需要主动断开的已建会话,每次 `tools/call` 都重新过 `validate_bearer`;JSON 响应让 nginx(`proxy_read_timeout 300s`,`default.conf:120`)与商业 Java 网关(`docs/architecture/11-gateway.md:37` 代理 `/api/v2/**`)零改造穿透;`/api/v2` 前缀让所有部署形态(compose nginx `location ~ ^(/workspace)?/api(/|$)`、systemd 114 `bisheng-lilu.conf`、网关)现成放行。这条 `Route` 不是 `APIRoute`:不会被 `router_rpc` 的路由级依赖覆盖(所以 D2 自建闸)、不进 `app.openapi()`(`test_openapi_schema_contract` 不撞)、但会撞 `test_open_api_route_matrix`(K5)。 +- **何时该重新考虑**:出现需要服务端推送的工具(审批结果订阅、长任务进度)→ 改 `stateless_http=False` + `json_response=False`,届时会话状态必须落 Redis(SDK `event_store` 可插拔)、nginx `/api` 补 `proxy_buffering off`;MCP 流量成为 API 进程负载源 → 抽独立进程,工具面契约不变(决议-1 已预留)。 + +### D2:鉴权落点 = 自建 ASGI 闸 `McpAccessGate` 包住 FastMCP app,复用 `validate_bearer` 与 v2 的执行身份装配;**不用** FastMCP 自带 `token_verifier` + +- **备选**: + - A. `FastMCP(token_verifier=BishengTokenVerifier(), auth=AuthSettings(...))` — SDK 原生;但 `AuthSettings.issuer_url` / `resource_server_url` 为必填(`mcp/server/auth/settings.py:15-29`,OAuth RS 元数据语义)、401/403 响应体是 `WWW-Authenticate` 形态(`mcp/server/auth/middleware/bearer_auth.py:78-100`)与 v2 信封不一致、且 `AuthenticationMiddleware` 只给 `request.user`,装不上租户 / 权限 ContextVar + - B. **自建 ASGI 闸**:`open_api/mcp/gate.py: McpAccessGate(app)`——每请求:取 `Authorization` → `validate_bearer` → PAT 策略(复用 `dependencies.py:86-98` 逻辑)→ **`delegate` 位 → 26051**(不看 marker,直接按位)→ **任何身份头 → 26303** → `current_tenant_id` / `visible_tenant_ids` / `current_open_api_principal` / `current_permission_actor` 四个 ContextVar 装配 → 调内层 app → `finally` 逐个 reset。失败 → `JSONResponse(http_status, exc.to_dict())`(与 `open_api/api/exception_handlers.py:41` 同形)+ `scope["open_api_error_code"]` + - C. 把 `verify_open_api_access` 挂成 `Depends` — Mount 不是 APIRoute,FastAPI 依赖不生效 +- **选定**:**B**。为不复制 `dependencies.py:76-153` 的装配块,把 `open_api_access_context` 拆成两段并导出:`admit_open_api_principal(conn) -> (principal, pat_data_scope)`(校验 + PAT 策略)与 `open_api_execution_scope(conn, principal, *, data_scope) -> AsyncContextManager`(四个 ContextVar + `PermissionActor` 构造 + 自然人 admin 事实解析);`open_api_access_context` 本身改为「admit → marker / delegate / scope / 身份头 / 模式判定 → execution_scope」的组合,对外行为与现有 `test/open_api` 40 个测试文件逐字不变。MCP 闸只调这两段,在两段之间插入 K3 的两条拒绝。 +- **拆分的唯一风险点(落地必须守住)**:今天的顺序是 **先装租户 ContextVar(`:76-80`)、再读 PAT 租户策略(`:86-98 TenantSettingService.get_policy`)**;把 PAT 策略并进 `admit_open_api_principal` 会把这两步**倒过来**。`get_policy(tenant_id)` 显式收租户 id,但它落到 DAO 时仍在自动租户过滤(C3)之下。因此 `admit_open_api_principal` **自己要先 `current_tenant_id.set(principal.tenant_id)`(并在异常路径 reset)**,或者把 PAT 策略读留在 `open_api_execution_scope` 的开头——二选一,T201a 用 `test_execution_scope_installs_and_resets_four_contextvars` 之外再加一条「PAT 策略读发生在租户 ContextVar 已装之后」的断言钉死。 +- **原因**:AC-02「拒绝连接、不列出工具」= HTTP 层拒(`initialize` 请求本身 401/403,MCP 客户端表现为连接失败);AC-28「通道入口 / 首次能力协商」= 同一处;v2 与 MCP 的凭据语义「同一条代码」而非「两份相同的代码」,撤销 5s / 编辑即时生效不必再验一遍。自然人 PAT 的 super/tenant-admin 事实解析(`dependencies.py:131-142`)随 `open_api_execution_scope` 一起复用——PAT 持有人若是管理员,其 MCP 检索同样享受管理员短路(伴生 PRD D17 改判,2026-08-28),但**租户过滤不放开**(`visible_tenant_ids={1, tenant}`)。 +- **何时该重新考虑**:平台接入 OAuth 2.1 授权服务器(伴生 PRD D18 已明确本期不做)→ 那时切到 A,闸只保留 ContextVar 装配。 + +### D3:工具注册表 = 声明式 `TOOL_REGISTRY` + 子类化 `FastMCP` 覆盖 `list_tools` / `call_tool` + +- **备选**: + - A. 逐工具用 `@mcp.tool()` 注册 + 在每个 handler 体内判位 — 漏一处即静默放宽;清单无法按位过滤 + - B. **`open_api/mcp/registry.py: TOOL_REGISTRY: tuple[McpToolSpec, ...]`**(`name / category / scope / requires_app_runtime / handler / input_model / description`),`server.py: class BishengMcpServer(FastMCP)` 覆盖 `async def list_tools()`(`mcp/server/fastmcp/server.py:315`)按 `get_current_open_api_principal().scopes` 与 `settings.app_runtime.enabled` 过滤,覆盖 `async def call_tool(name, arguments)`(`:343`)先查注册表:不存在 → 26301;存在但缺位 → 26302(`data.required=`);应用类且运行时层未部署 → 16207;再 `super().call_tool(...)` +- **选定**:**B**。工具 ↔ 权限位映射(DEV-01 ① / 伴生 B.2): + +| 类别 | 工具名 | 权限位 | 依赖运行时层 | 服务端 | +|---|---|---|---|---| +| ① 检索 | `bisheng_knowledge_search` | `knowledge:read` | 否 | 门面 `retrieve`(D5) | +| ② 清单 | `bisheng_knowledge_list` | `knowledge:read` | 否 | 门面 `list_accessible_knowledge` | +| ③ 模型清单 | `bisheng_model_list` | `model:invoke` | 否 | `LLMService.get_all_llm` + **F051 名称解析**(D10) | +| ④ 身份组织 | `bisheng_identity_get_user` / `bisheng_org_tree` / `bisheng_dept_members` | `identity:read` | 否 | 租户全量 DAO 读(D8) | +| ⑤ 应用数据 | `bisheng_app_db_tables` / `bisheng_app_db_schema` / `bisheng_app_db_rows` / `bisheng_app_db_row_update` / `bisheng_app_db_row_insert` / `bisheng_app_db_row_delete` | `app:manage` | **是** | F054 `AppDataService.list_tables / get_table_schema / get_rows / update_row`(D9;后两个工具待 F054 补 insert / delete,`available()=False` 时不进清单) | +| ⑥ 应用状态日志 | `bisheng_app_status` / `bisheng_app_logs` | `app:manage` | **是** | `PublishStatusService.get_publish_status` + `AppQueryService.get_instance / get_logs`(D9) | + +- **原因**:清单过滤 + 调用校验两者都做(决议-6);子类覆盖是 SDK 允许的最小侵入——`FastMCP.__init__` 在 `_setup_handlers`(`mcp/server/fastmcp/server.py:302-312`)里把**绑定方法** `self.list_tools` / `self.call_tool` 注册给低层 `Server`,子类方法自然生效;`bisheng_` 前缀避免与 agent 本地其它 MCP server 撞名。覆盖 `list_tools` 时按父类同一形状组装 `MCPTool(name, title, description, inputSchema=info.parameters, outputSchema=info.output_schema, annotations, icons, _meta)`(`:315-329`)——**别漏 `outputSchema`**,漏了 agent 就看不到出参结构。 +- **何时该重新考虑**:工具数 > 30 或需要按租户开关工具 → 注册表改 DB 驱动,过滤逻辑不变。 + +### D4:错误形态 = `McpToolError(ToolError)`,`str()` 即 JSON 三要素;传输层拒绝走 v2 信封 + +- **备选**:A. 返回正常结果里带 `{"ok": false}` — agent 会当成功处理;B. 抛裸异常 — SDK 变成 `isError=True` + `str(e)` 文本(`mcp/server/lowlevel/server.py:467 _make_error_result`,由 `:583-584` 的 `except Exception as e: return self._make_error_result(str(e))` 调用),三要素丢失;C. **抛 `McpToolError`,其 `__str__` 输出 `{"code", "category", "reason", "next_step", "data"}` JSON** → SDK 原样放进 `CallToolResult(isError=True, content=[TextContent(text=json)])` +- **选定**:**C**(工具层)+ HTTP 信封(传输层:无 / 无效凭据 26001/26002、依赖不可用 26030、`delegate` 26051、身份头 26303 → `JSONResponse(http_status, {status_code, status_message, data})`)。 +- **C 有一个必须拆掉的机关(坑 19)**:handler 抛出的**任何**异常都会被 `Tool.run` 重新包一层——`mcp/server/fastmcp/tools/base.py:116-117` 写死 `except Exception as e: raise ToolError(f"Error executing tool {self.name}: {e}") from e`。照字面实现的话,客户端拿到的是 `Error executing tool bisheng_knowledge_search: {"code": 26321, …}`,前面那截前缀让 `json.loads` 当场失败,AC-09 的「结构化可解析」落空。**解法**:`BishengMcpServer.call_tool` 的覆盖体里,在 `await super().call_tool(...)` 外层 `except ToolError as e:` → `raise e.__cause__ if isinstance(e.__cause__, McpToolError) else to_tool_error(e)`(`raise … from e` 已经把原异常挂在 `__cause__` 上)。覆盖层在 `_tool_manager` **之上**,所以重新抛出的 `McpToolError` 直达低层 `:583-584`,`str(e)` 就是干净 JSON。T203a 必须有一条用例把返回文本 `json.loads` 一遍(只断言 `isError` 会漏掉这个坑)。 +- **出参注解口径(同批定案)**:每个 handler 的返回类型注解写成**一个 pydantic 输出模型**(字段即 §4.2 ②),让 FastMCP 生成 `outputSchema`、agent 无需猜字段。代价是多一条失败路径(坑 20:输出与 schema 不符时低层在 `lowlevel/server.py:559-569` 直接返回 `Output validation error: …`,**绕过**三要素),故 T203a 另加一条守卫:每个工具的一次成功调用都断言 `structuredContent` 非空且无 `Output validation error` 字样。`category` 枚举(`open_api/mcp/errors.py`):`credential_invalid | scope_missing | delegate_only | identity_header_refused | unreachable | capability_revoked | not_your_app | runtime_disabled | permission_unavailable | invalid_argument | scope_too_large | internal`;映射表 `ERROR_CATEGORY_MAP: dict[int, tuple[category, next_step_key]]` 覆盖 26001/26002/26003/26027/26030/26051/26301–26306/26320–26323/19002/19201/16207/16101/16161/16162/16163/16254/16205,未映射的 `BaseErrorCode` → `internal`(`reason` = 其 `Msg`,`next_step` = 「稍后重试或联系管理员」,**永不**回显异常正文)。`next_step` 文案**由后端自有**:`open_api/mcp/errors.py: NEXT_STEP_COPY: dict[int, dict[str, str]]`(zh-Hans / en / ja 三语,按 `Accept-Language` 选、缺省 zh-Hans)——**不放** `packages/locales/api_errors`:后端进程不装前端 locale 包、读不到那份 JSON(坑 18),且 `next_step` 只给 agent 看、SPA 永不渲染;`api_errors` 三语仍只放每个码的主文案(C5 常规)。 +- **原因**:AC-09 三要素 + AC-04「指明缺位」+ AC-11 / AC-27「同一响应」都要求错误是**结构化且可 JSON 解析**的;`isError=True` 是 MCP 客户端唯一稳定识别失败的位。 +- **何时该重新考虑**:MCP 规范正式化 `CallToolResult.structuredContent` 在错误上的语义(1.27.1 只对成功结果填 `structuredContent`,`server.py:575-577`)→ 把 JSON 移过去,`content` 保留人可读文本。 + +### D5:统一检索门面 = `knowledge/domain/services/retrieval_facade_service.py`,引擎从聊天服务**抽出**而非复制;执行身份 = `RetrievalIdentity(actor, login_user)` + +- **备选**: + - A. 门面内部 `KnowledgeSpaceChatService(request=None, login_user)` 直接调 `aretrieve_chunks` — 最省;但该类构造需 `Request`(`knowledge_space_chat_service.py:57-58`)且 `_permission_service` / `_visibility_service`(`:61-76`)把 `self.request` 透传给 `KnowledgeSpaceService` / `KnowledgeFileVisibilityService`,`None` 是否安全无人保证;且 AC-19「会话解耦」变成靠约定 + - B. **抽出**:新建 `knowledge/domain/services/retrieval_engine.py: RetrievalEngine(login_user, version_repo=None)`,把 `_retrieve_and_filter :393` / `_aretrieve_chunks_for_kb :809` / `_aretrieve_chunks_for_knowledge_base :869` / `_resolve_kb_target_file_ids :695` / `_resolve_kb_file_ids_by_tags :907` / `_attach_document_update_time :788` **整体搬入**(不带 `request`;`KnowledgeFileVisibilityService(request=None, login_user)` 的 `request` 形参保留、核实其判定链不读 `self.request`——坑 4),`KnowledgeSpaceChatService.aretrieve_chunks :726` 改为**薄委托**到引擎(签名、400 行为、返回形状一字不变——决议-9 平台内路径不翻转);门面在引擎之上做**可及性预判 + 白名单 + 上限 + 收回信号 + 身份 fail-closed** + - C. 门面另写一条检索链 — 第三条强度不一的路径,正是伴生 B.1 已核实的分叉再现 +- **选定**:**B**。接口(契约见 §4.2 ③):`RetrievalFacadeService.retrieve(identity, req) -> RetrievalFacadeResult`、`list_accessible_knowledge(identity) -> list[AccessibleKnowledge]`、`check_reachable(identity, knowledge_ids) -> ReachabilityReport`、`is_supported_knowledge_type(type) -> bool`(`SUPPORTED_KNOWLEDGE_TYPES = {NORMAL=0, SPACE=3}`)。执行身份 `RetrievalIdentity(actor: PermissionActor, login_user: UserPayload)`,门面体内 `set_current_permission_actor(identity.actor)` → 执行 → reset(调用方不必自己装,但 v2 / MCP 已装时取同一对象无副作用);`identity is None` 或 `actor.subject_id` 为空 → 26320 拒绝(AC-23)。构造器 `RetrievalIdentity.from_open_api_principal(principal)`(S:`subject_type=principal.authorization_subject_type`;D:`user` + `effective_user_id`——F050 只需传模式 D 的 principal)与 `RetrievalIdentity.from_user(user_id, tenant_id, *, data_scope=ALL)`(F055 访问用户;管理员事实由 `resolve_permission_actor` 解析)。 +- **可及性判定**(一次批量,不逐库串行):`KnowledgeDao.aget_list_by_ids(targets)` → 过滤 `type ∈ {0,3}` → `batch_check_business_actions(login_user, resource_type="knowledge_space", ids, actions=("visible",))` 与 `resource_type="knowledge_library"`(两个资源类型名都已在 `api/services/f048_permission_runtime.py:185-186` / `:202-203` 的 registry 里注册)`actions=("use",)`——与今天两条链各自的门一致(`_require_space_view_permission :91-94` 走 `_require_action("knowledge_space", id, "visible")`;`ensure_knowledge_use_async`(`knowledge_permission_service.py:194-201`)走 `resource_type="knowledge_library"` 的 `"use"`),故「集合相等」在两种类型上分别成立。**未指定目标 + 无白名单** → `list_accessible_knowledge`:常规主体走 `runtime.list_visible_objects(actor, resource_type=…, max_results=RETRIEVAL_SCOPE_MAX)`(`permission/application/runtime.py:222`),管理员短路主体走租户内 DB 扫描(同 `KnowledgeService.get_knowledge` 的 admin bypass,`knowledge_service.py:508-515`:判据是 `actor.super_admin or actor.current_tenant_id in actor.tenant_admin_tenant_ids`,且 `actor.data_scope != DATA_SCOPE_ALL` 时**强制关掉** bypass);候选 > `RETRIEVAL_SCOPE_MAX=200` → 26323(不静默截断)。 +- **白名单语义**(AC-21 / AC-46):`whitelist=None` = 无白名单;`whitelist=[]` = 空范围 → 任何目标皆不可及 26321。有白名单时:显式目标 ∉ 白名单 → 26321(与不存在同响应);白名单条目**行不存在 / 已删除 / 类型不再受支持** → **26322 `KnowledgeCapabilityRevokedError(data.knowledge_id)`**,整请求失败、不剔除续跑;白名单内存在但执行身份不可见 → 未显式指定时静默不出现(AC-21 第二句),显式指定时 26321。**「应用侧授权已收回」由 F055 在调用门面前按能力状态判定并直接抛 16273**(它拥有能力状态,门面不感知)——门面的 26322 只覆盖「库本身没了」这一类;F055 AC-53 把 26322 也转成 16273。 +- **上限与可见截断**:`RETRIEVAL_TOP_K_MAX=200`(与 v2 `RetrieveReq.top_k le=200` 一致,`open_endpoints/domain/schemas/filelib.py:47`)、`RETRIEVAL_MAX_CONTENT_MAX=60000`、`RETRIEVAL_TARGETS_MAX=50`;`top_k` / `max_content` 超限 → 夹到上限并在 `RetrievalFacadeResult.truncated_params` 列出(AC 边界「超出即按上限截断并在响应中可见」);目标数超限 → 26323。 +- **原因**:B 让「开放面唯一检索路径」(AC-26)与「平台内取向不变」(决议-9)同时成立——引擎只有一份,聊天服务与门面是两个**入口**,强度由引擎保证;抽出后门面天然无 `Request`,AC-19 会话解耦是结构事实。三种「不可及」同一响应、白名单收回可区分,是 spec 决议-7 / 决议-10 / AC-46 的字面要求。 +- **何时该重新考虑**:文档知识库引入文件级权限模型 → 引擎不变,门面验收样本补例(决议-3);QA 库要经开放面检索 → `SUPPORTED_KNOWLEDGE_TYPES` 加 1 并给引擎加 QA 召回分支。 + +### D6:v2 `POST /filelib/retrieve` 收敛 = 端点改调门面;请求 / 响应 schema 不变;空 `knowledge_base_ids` 语义保持 400 + +- **备选**:A. 端点不动(现状已是文件级双层过滤 + fail-closed,见坑 3)— 但 AC-11 / AC-27「三种不可及同一响应」不成立(现为 404 `NotFoundError` / 403 `SpacePermissionDeniedError` / 10962 三态);B. **改调 `RetrievalFacadeService.retrieve(RetrievalIdentity.from_open_api_principal(principal), ...)`**,`RetrieveReq` / `RetrieveResp` 不改;C. 顺带把 `knowledge_base_ids` 改可选(= 全部授予范围,AC-22) +- **选定**:**B**,不做 C。`RetrieveReq.knowledge_base_ids min_length=1` 保留(`open_endpoints/domain/schemas/filelib.py:43-45`;对外契约「除结果集因过滤收窄外无变化」,AC-25);AC-22「未指定目标 → 全部授予范围」只在 MCP 工具与 SDK 上提供。`kb_filters`(tag 过滤)原样透传引擎。错误:不可及 → 26321(HTTP 404,`data.unreachable_ids`),替换现三态;权限不可用 → 19002/19201 原样(handler 503,删除现 `:721-722` 的 `OpenApiAuthDependencyUnavailableError` 包装——26030 是「凭据校验依赖不可用」,检索期的权限引擎故障用它是语义错位)。`docs/api/filelib-retrieve.md:181-186 / :287` 「default operator 是否对该 KB 有 view 权限」订正为门面口径。 +- **两处对外可见的收紧,本轮显式定案(全自动模式)**,两条都登记为 AC-25「保持兼容」的**受控偏离**、写进 `docs/api/filelib-retrieve.md` 与 tasks 偏差预案: + - **① F066 `data_scope` 拒绝折叠进 26321。** 今天 `test/open_api/test_data_scope_matrix.py:41` 把 `POST /api/v2/filelib/retrieve` 归为 `"raise"`:窄化后的个人令牌碰非本人创建的库,由单资源闸抛 26044 / 403。门面改走**批量**判定,而批量检查对被窄化目标是「返回 `False` 而不是抛」(`permission/domain/services/permission_action_service.py:328-333` 注释逐字 "Batch checks carry filtering semantics: narrowed-out targets resolve to False instead of raising (design decision 2)"),于是同一场景落到 26321 / 404。**这不是意外,是 AC-11 / AC-27 的必然**:若保留 26044,窄化令牌就能区分「库存在但不是我创建的」与「库不存在」,正是「存在性不泄露」要堵的通道。落地动作:把该分类从 `"raise"` 改为新增档 `"unreachable"`,并在同文件补一条断言「窄化令牌检索非本人创建的库 → 404 / 26321,且响应体与『库不存在』逐字相同」。**不得**再声称「`test_data_scope_matrix` 必须仍绿」——它必须被改。技能包文案 `open_api/skill_packs/knowledge-search/SKILL.md:85` 与 `references/api.md:78` 里对 26044 的检索期指引同批订正(26044 仍存在于清单类端点,只是不再出现在 retrieve 上)。 + - **② `max_content` 补上限。** `RetrieveReq.max_content` 今天只有 `ge=1`、**没有上限**(`filelib.py:48-52`)。门面的 `RETRIEVAL_MAX_CONTENT_MAX=60000` 若只是默默夹取,对 v2 调用方就是**静默**收窄——`RetrieveResp` 没有 `truncated_params` 字段可以承载「可见的截断」,直接违反 spec §3 边界「超出即按上限截断并在响应中可见(不静默)」。定案:在 `RetrieveReq.max_content` 上加 `le=60000`,超限由 pydantic 在端点层 422 明确拒绝。理由:60000 是默认值 15000 的 4 倍、远高于现实用量;显式拒绝比静默夹取更符合「不静默」。`top_k` 无此问题(schema 已有 `le=200`,与门面上限同值,永不触发截断)。**何时重新考虑**:若发现存量集成方真的在传 >60000,改为门面对 v2 不夹取、并给 `RetrieveResp` 加一个可选 `truncated_params` 字段(那才是真正的契约变更,走 v2 版本化)。 +- **原因**:AC-41「同 key 经 v2 与经 MCP 集合相等」由「同一门面 + 同一执行身份构造器」结构性保证;契约的**形状**(请求/响应 schema 字段集)不变是 AC-25 硬要求,上面两条动的是**错误码取值**与**入参取值域**,各自有 AC 级理由并已留痕。 +- **何时该重新考虑**:外部集成方明确需要「不传 ids = 全范围」→ 走 v2 版本化契约变更(F058 一类),非本 Feature。 + +### D7:MCP 逐调用审计 = `open_api.mcp.tool_call` 一个 action,在 `call_tool` 包装层写;`OpenApiAuditMiddleware` 对 `/api/v2/mcp` 前缀**跳过** + +- **备选**:A. 沿用中间件 — 只会记 `POST /api/v2/mcp`,无工具名 / 目标(`open_api/api/middleware.py:100-160` 按 `METHOD route_path` 记 `open_api.call`);B. 中间件 + 工具层双记 — 每次调用两行;C. **工具层单记**(`open_api/mcp/audit.py: audit_tool_call(principal, tool, target_summary, outcome, latency_ms)` → `open_api_call_audit_service.enqueue(AuditLog(action="open_api.mcp.tool_call", target_type="mcp_tool", target_id=, audit_metadata={credential_id, actor_kind, actor_id, resource_owner_user_id, tool, category, target:{knowledge_ids|app_id|dept_id|user_id}, outcome:"success"|"denied:"|"error:", latency_ms, trace_id})`);传输层拒绝(闸内 401/403)也用同一 action、`target_id="-"` +- **选定**:**C**。中间件 `__call__` 首行加 `path.startswith("/api/v2/mcp")` 短路。action 登记三处 lockstep:`database/models/audit_log.py:198 _UI_VISIBLE_V2_ACTIONS`(`open_api.` 前缀已在 `_V2_NAMESPACE_TO_ACTION_PREFIX :296`)、`platform/controllers/API/log.ts:153 V2_ACTIONS`、`platform/public/locales/{zh-Hans,en-US,ja}/bs.json` 键 `openApiMcpToolCall`(派生规则见 `test/app_runtime/test_audit_action_registry_lockstep.py` docstring)。**不记** `query` 正文、不记片段、不记 `arguments` 全文(只记目标 id 摘要)。 +- **原因**:AC-07 要求归属到工具名与目标;批量入库 `OpenApiCallAuditService`(`call_audit_service.py:18`)现成、有界;决议-12 高频分层留给 F056。 +- **何时该重新考虑**:审计写放大成瓶颈 → 与 F056 高频事件分层一并处理(口径不变)。 + +### D8:身份 / 组织工具 = 租户全量 DAO 读(不经 `aget_tree(login_user)` 的可管范围计算),字段白名单,跨租户与不存在同响应 + +- **备选**:A. 复用 `DepartmentService.aget_tree(login_user)`(`department/domain/services/department_service.py:1085`)— 它按 `login_user` 的部门管理员 / 租户管理员子树计算可见范围,服务账号无任何管理身份 → **空树**;B. **新建 `department/domain/services/org_directory_service.py: OrgDirectoryService`**(`atree() / amembers(dept_id, page, size, keyword) / aget_user(user_id)`),在 `current_tenant_id` 下走自动过滤的 DAO 全量读(`Department` / `UserDepartment` / `User` / `UserTenant`),**不做任何范围收窄**(AC-31) +- **选定**:**B**。出参白名单:用户 = `{user_id, user_name, departments:[{dept_id,name,path}], roles:[name], status:"active"|"disabled"}`;部门 = `DepartmentTreeNode` 子集 `{dept_id, name, parent_id, path, sort_order, source, status}`(`department/domain/schemas/department_schema.py:85-99`),**去掉** `is_tenant_root / mounted_tenant_id`(租户拓扑不属组织信息)。凭据类字段(`password`、`token_version`、`external_id`、任何 token)**结构性不进 schema**。点查:`UserTenantDao.aget_active_user_tenant(user_id)` 租户 ≠ 当前 → 26306(与不存在同响应);服务账号在独立 `service_account` 表(beta2)、无 `user` 行 → 组织结果天然不含(决议-11)。 +- **原因**:spec AC-31「本租户全量、无收窄」与既有部门树接口的「按可管范围」语义正交,复用会得到错误的空结果;一个只读服务放在 department 模块是它的领域归属(C1)。 +- **何时该重新考虑**:产品决定从能力侧收窄(只允许点查、不给列举,PRD-1 DEV-02 ② 末句)→ 从注册表摘掉 `bisheng_org_tree` / `bisheng_dept_members` 即可。 + +### D9:应用状态 / 日志工具 = 服务端方法加 `entry` 参数做 owner-only;数据工具直接透传 F054 `AppDataService`(签名已核实,尚未合并);缺 insert / delete 向 F054 提回写 + +- **备选**:A. 工具层自己比 `owner_user_id` 再调服务 — 两处判定、可漂移;B. **服务方法加 `entry: str = "detail"` 形参**:`PublishStatusService.get_publish_status(app_id, *, actor, entry)` → `_require_viewer(app, actor, entry)` 在 `entry in {"cli","mcp"}` 时只认 owner(管理员不放行);`AppQueryService.get_instance(app_id, *, actor, entry)` → `_load_visible` 同理;`get_logs` 已有 `entry=LOG_ENTRY_MCP`(`app_query_service.py:46-50`) +- **选定**:**B**。actor 构造与 `app_publish/api/endpoints/deploy.py:206-211` 同形:`UserPayload(user_id=resource_owner_of(principal), user_name=principal.actor_name, user_role=[], tenant_id=principal.tenant_id)`(`deploy.py` 那处 `user_name=""`、不传 `is_global_super`;本面填 `actor_name` 只为审计可读,**不得**传任何会抬高权限的字段)——`resource_owner_of`(`publish_pipeline_service.py:111-127`)对 `resource_owner_user_id is None` 的主体抛 16205 而不是当成 user 0,PAT 主体因此**天然不能用应用类工具**(PAT 也签不出 `app:manage`,双保险)。**调用期取归属人当前值** → AC-36 自动成立。不可及应用(不存在 / 他租户 / 非 owner)统一映射为 **26305 `not_your_app`**(16101 / 16161 / **16162** / 16254 / 16205 五码在工具层折叠,响应不带 owner 名)。 +- **数据工具 = 纯透传,不加 `entry`**(2026-09-16 审查订正,原文按假设签名写,现已核对真实实现):`AppDataService` 在 HEAD `fe10f75ea` 的 **3.0-vibe 树上仍不存在**(`grep -rn AppDataService src/backend/bisheng/` 无命中;F054 `tasks.md:696-720` 的 T086a/T086/T087a/T087 均 `[ ]`),但**本波次的 data-plane 切片已经实现了它**——只读依据:worktree `/Users/lilu/Projects/bisheng/.claude/worktrees/wf_f464b35c-e86-3`,`src/backend/bisheng/app_runtime/domain/services/app_data_service.py:56+`。**已核实的真实契约**(落地时以合并后的该文件为准,与本文不符即记偏差): + - `list_tables(app_id, *, actor) -> dict`(`{tables:[{name, column_count}]}`) + - `get_table_schema(app_id, table, *, actor) -> dict`(**不叫** `get_schema`;`{table, columns[], key{column,kind}, editable}`) + - `get_rows(app_id, table, *, actor, page=None, size=None, order=None) -> dict`(**不叫** `read_rows`;`DEFAULT_PAGE_SIZE=50` / `MAX_PAGE_SIZE=200`) + - `update_row(app_id, table, key, values, *, actor) -> dict`(行键形参叫 **`key`** 不是 `pk`;服务内自记 `app.data_row_edit` 审计,带 before / after) + - `export_table(app_id, table, *, actor) -> tuple[str, bytes]`(本 Feature **不**接线:MCP 不传文件) + - **没有 `entry` 形参**:owner-only 是无条件内建的(`_require_owner` 明写「Deliberately does not consult `is_global_super` or the tenant-admin check」),所以 AC-34 / AC-35 在数据工具上**零额外代码**成立,D9 的 `entry` 方案只适用于 ⑥ 的两个只读接口。 + - 错误:`AppNotFoundError`(16101,不存在 / 已删除 / 他租户同一响应)、`AppDataForbiddenError`(**16162**,`common/errcode/app_factory.py:221-231`)、以及 manager 侧「应用尚未建库」的 16163 —— 前两者折叠成 26305;16163 折叠成 `unreachable` 类别的 26301?**不**:它是真实业务态,按 `internal` 之外单独映射到 `category="unreachable"` 并保留原码 16163,`next_step` = 「该应用还没有创建过数据库表,先在应用里写一次数据」。 + - 落点约束不变:backend `AppDataService` → `orchestrator_client.db_*` → manager RPC `GET|PATCH /v1/apps/{id}/db/tables[...]`(`orchestrator_client.py:227-267`);**工具层不得直连 manager**(F054 D10 明令)。 + - **缺口**:manager 数据面只有 `PATCH` 单行,没有 insert / delete——spec 决议-8「行级增删改」要 F054 补 `insert_row / delete_row`(manager `POST /v1/apps/{id}/db/tables/{t}/rows` / `DELETE .../rows/{key}`);本 Feature 在 F054 tasks「跨 Feature 回写受理」登记该请求(T209 / T305),工具 `bisheng_app_db_row_insert / _delete` 随其落地启用,未落地时注册表 `available()` 为假、不进清单。 +- **原因**:AC-35「租户管理员名下服务账号的密钥同样拒」要求判定在服务层且不受权限运行时短路影响——`entry` 参数让「只有一处实现」(F055 design D15 的原则)继续成立;数据面则已经把这条内建成无分支的 owner-only,接线时**不要**再加一层工具层的 owner 比对(两处判定必漂)。 +- **何时该重新考虑**:data-plane 切片合并时签名若已变 → 只改 `open_api/mcp/tools/apps.py` 的适配层,记 tasks 偏差。 + +### D10:模型清单工具 = `LLMService.get_all_llm` 过滤 `online ∧ model_type=='llm'`;`qualified_name` **只经 F051 的解析函数**,F051 未落地前工具不注册 + +- **备选**:A. 本 Feature 自定「`{server_name}/{model_name}` 即限定名」占位 — F051 决议-2 定分隔规则与歧义判定,本 Feature 自定就是第二份规则(F051 AC-10「三处同源」被破坏);B. **工具实现依赖 `bisheng.llm.domain.services.model_name_resolver`(F051 design 待定名,由 F051 tasks 落)导出的 `resolve_callable_names(tenant_id) -> list[CallableModel{name, qualified_name, is_ambiguous, model_type, is_chat, server_name}]`**;导入失败(F051 未合入)→ 注册表把 ③ 标 `unavailable`、不进清单、直调 26301 +- **选定**:**B**。出参 `{name, qualified_name, model_type, is_chat, server_name, callable_name}`(`callable_name` = 歧义时 `qualified_name`、唯一时 `name`——即 F051 AC-10「可直接用于调用的名称」)。`model:invoke` 的 `issuable=False → True` 翻转归 F051(`scopes.py:145`),本 Feature **不动**——位不可签发时工具无从被列出,两者顺序无关。 +- **原因**:spec 决议-4 + F051 AC-10「名称解析规则是唯一出处」。 +- **何时该重新考虑**:无。 + +### D11:`identity:read` 可签发翻转归本 Feature;MCP 地址 = `/api/v1/dev-toolkit/versions` 增 `mcp` 段 + +- **备选**(地址):A. 新端点 `GET /api/v1/service-accounts/access-info` — 多一个端点;B. **扩展 F053 `dev_toolkit/api/endpoints/distribution.py:55-56 get_dev_toolkit_versions` 返回体加 `"mcp": {"url": f"{resolve_public_base_url(request)}/api/v2/mcp", "transport": "streamable-http", "auth": "bearer"}`**(该 router 只在 `open_platform.enabled` 时挂载,`api/router.py:140-141` → AC-37「地址不出现」零代码成立;`resolve_public_base_url` `open_api/api/public_base_url.py:77` 已被技能包使用。注意现签名是**同步** `def get_dev_toolkit_versions()` 且**不收任何参数**,要加 `request: Request` 形参);F051 的 `model.base_url` 留同级槽位(本 Feature 写 `"model": None`,F051 填) +- **选定**:**B**(地址);`scopes.py:154 identity:read issuable=False` → `True`(`requires_open_platform=True` 不变),同步改 `test/open_api/test_scope_issuability.py:66-68 / :176` 与 `test_scopes.py:49` 的钉住集合(`model:invoke` 仍不可签发)。 +- **原因**:地址不含凭据、匿名可读无害(F053 CLI 本就匿名探测该端点,`distribution.py:7`);「同一份接入信息真相」避免 F053 T046 再拼一遍 URL。 +- **何时该重新考虑**:接入信息需要按服务账号个性化(如 per-SA 的 MCP 子路径)→ 那时才建 A。 + +### D12:测试策略 = 进程内 ASGI 直连的真 MCP 客户端 + 注册表驱动矩阵 + 引擎级 fake;集合相等在 CI 中间件分组 + +- **选定**:`test/open_api/conftest.py` 增 `mcp_client` fixture:`httpx.ASGITransport(app)` + `mcp.client.streamable_http.streamable_http_client(url, http_client=...)`(`mcp/client/streamable_http.py:601-605`,1.27.1 已支持注入 `httpx.AsyncClient`)+ `ClientSession.initialize / list_tools / call_tool`;矩阵测试遍历 `TOOL_REGISTRY` 生成「仅持 X 位」用例(防漏,仿 `test_data_scope_matrix.py`);门面单测用 fake 引擎(记录调用、返回可控 Document)+ monkeypatch `batch_check_business_actions` / `list_visible_objects`;**集合相等(AC-40~43)与 fail-closed(AC-44)** 用例放 `test/knowledge/test_retrieval_facade_equality.py`,标 `@pytest.mark.e2e`,CI 中间件阶段跑(MySQL + Redis + OpenFGA + Milvus/ES),本地无中间件默认跳过。 +- **原因**:AC-01「任何标准 MCP 客户端零改造」只有用真客户端才算验;本地无中间件(HARNESS.md),集合相等必须在 CI。 + +--- + +## 4. 系统现状(接手必读) + +### 4.1 数据流(要建成的样子) + +**A. MCP 工具调用**(一次 `tools/call` = 一次 `POST /api/v2/mcp`) + +`MCP 客户端 POST /api/v2/mcp(Authorization: Bearer bs-sak-…;JSON-RPC)` → nginx `/api` / 网关 `/api/v2/**` → `create_app()` 里条件追加的 `Route("/api/v2/mcp", endpoint=McpAccessGate(StreamableHTTPASGIApp(session_manager)), methods=["GET","POST","DELETE"])`(仅 `open_platform.enabled`;D1)→ **`McpAccessGate`**(`open_api/mcp/gate.py`):`admit_open_api_principal` → `delegate` 位 → 26051 · 身份头 → 26303 → `open_api_execution_scope`(四个 ContextVar)→ `StreamableHTTPASGIApp` → 低层 `Server` 分发 `initialize` / `tools/list` / `tools/call` → **`BishengMcpServer.list_tools`**(按位 + 运行时层过滤)/ **`call_tool`**(注册表校验 → handler,并在 `ToolError` 外层解包回 `McpToolError`——坑 19)→ handler 调领域服务(门面 / `OrgDirectoryService` / `PublishStatusService` / `AppQueryService` / `AppDataService`)→ 成功:`CallToolResult(structuredContent=dict)`;失败:`McpToolError` JSON(D4)→ `audit_tool_call`(D7)→ `finally` reset ContextVar。 + +**B. 门面检索**(四个调用方同一条) + +`调用方构造 RetrievalIdentity` → `RetrievalFacadeService.retrieve(identity, RetrievalRequest)` → 身份 fail-closed(26320)→ 参数夹取上限(可见截断)→ **范围解析**:显式目标 / 白名单 / 全部授予(D5)→ **可及性批判**(一次 `aget_list_by_ids` + 每类型一次 `batch_check_business_actions`;白名单条目缺失 → 26322;任一目标不可及 → 26321)→ `RetrievalEngine.retrieve_many(targets, query, tag_filters, max_content)`(`asyncio.gather` 逐库:知识空间 `_retrieve_and_filter` 双层 `visible` 过滤;文档库 库级 `use` + 同一 `_retrieve_and_filter`)→ 扁平 `[:top_k]` → `_attach_document_update_time` → `RetrievalFacadeResult`。权限引擎异常(19002 / 19201)在任何一步**原样上抛**、不产生结果。 + +**C. v2 `POST /filelib/retrieve`**:`verify_open_api_access`(不变)→ `retrieve_chunks`(`open_endpoints/api/endpoints/filelib.py:689`)→ `RetrievalIdentity.from_open_api_principal(get_current_open_api_principal())` → 门面 → `RetrieveResp`(不变)。 + +### 4.2 关键数据结构 / 字段约定(对外契约) + +**① MCP 接入** + +| 项 | 值 | +|---|---| +| URL | `{public_base_url}/api/v2/mcp`(`resolve_public_base_url`;114 = `http://192.168.106.114:4101/api/v2/mcp`)——**精确路径,不带尾斜杠、不追加子路径**(D1:用 `Route` 而不是 `Mount` 正是为了让裸路径直接命中,不走 307) | +| 传输 | MCP streamable-http,`stateless`,`json_response`(`Content-Type: application/json`);`GET`(SSE 长流)与 `DELETE`(结束会话)在无状态模式下由 SDK 自己回绝,路由仍登记这两个方法,好让客户端拿到 SDK 的标准回应而不是 Starlette 的 405 | +| 鉴权 | `Authorization: Bearer bs-sak-…` 或 `bs-pat-…`;查询参数 / cookie 一律不认(`extract_bearer_token`,`credential_validator.py:47-53`,只读该头且要求 `Bearer ` 前缀) | +| 传输层拒绝 | HTTP 401 `{status_code:26001|26002}` · 503 `26030` · 403 `26051`(delegate)· 403 `26303`(身份头)· 404(开关关闭,路径不存在) | +| 客户端配置示例(Claude Code)| `claude mcp add --transport http bisheng http://…/api/v2/mcp --header "Authorization: Bearer bs-sak-…"` | + +**② MCP 工具入参 / 出参**(所有出参经 `structuredContent`;`isError` 见 D4) + +| 工具 | 入参 | 出参 | +|---|---|---| +| `bisheng_knowledge_search` | `query: str(1..2000)` · `knowledge_ids: list[int] \| None`(省略 = 全部授予范围)· `top_k: int = 10`(≤200)· `max_content: int = 15000`(≤60000)· `tags: dict[int, list[str]] \| None` | `{chunks:[{knowledge_id, knowledge_type, knowledge_name, document_id, document_name, chunk_index, content, document_update_time}], total, effective_scope:[knowledge_id], truncated_params:{top_k?, max_content?}}` | +| `bisheng_knowledge_list` | `name: str \| None` · `limit: int = 200` | `{items:[{knowledge_id, name, type: "library"\|"space", description}], total}`(集合 = 显式授予 ∩ 支持类型;QA / 个人库不出现) | +| `bisheng_model_list` | — | `{models:[{name, qualified_name, callable_name, model_type, is_chat, server_name}]}` | +| `bisheng_identity_get_user` | `user_id: int` | `{user_id, user_name, status, departments:[{dept_id,name,path}], roles:[str]}` | +| `bisheng_org_tree` | — | `{departments:[{dept_id, name, parent_id, path, sort_order, source, status, children:[…]}]}` | +| `bisheng_dept_members` | `dept_id: str` · `page=1` · `size=50`(≤200)· `keyword?` | `{members:[{user_id, user_name, status}], total}` | +| `bisheng_app_status` | `app_id: str` | `{app_id, app_state, instance:{phase, health, current_version_id, started_at, restart_count}, publish:{pending_reason, current_version, pending_version, deployment, approval:{instance_id, status, submitted_at, decided_at, reject_reason, approver_names}}}`(`publish` = `PublishStatusService.get_publish_status` 原样字段,F055 design §4.2 ②;`instance` = `AppQueryService.get_instance` 原样) | +| `bisheng_app_logs` | `app_id` · `tail: int = 200`(≤2000)· `since?: str` · `keyword?` | `{lines:[str], app_state, pending_reason}`(= `get_logs(entry="mcp")` 原样 + F053 T034 ② 补的两字段) | +| `bisheng_app_db_tables` / `_schema` / `_rows` / `_row_update` | `app_id` · `table` · `page`(≥1) / `size`(≤200) / `order` · `key: str` · `values: dict` | F054 `AppDataService.list_tables / get_table_schema / get_rows / update_row` 返回体原样透传(无 DDL;写操作审计 `app.data_row_edit` 在 F054 侧记,带 before / after) | +| `bisheng_app_db_row_insert` / `_row_delete` | 同上 | **待 F054 补 `insert_row` / `delete_row`**(manager 侧今天只有 `PATCH …/rows/{key}`);未落地时 `available()=False`、不进清单、直调 26301 | + +**③ 门面契约**(`knowledge/domain/schemas/retrieval_facade.py`) + +> **已落地(2026-09-16,T105 回写)**:以下为实现后的真实签名,`knowledge/domain/schemas/retrieval_facade.py` 与 `knowledge/domain/services/retrieval_facade_service.py` 为准。 + +``` +RetrievalIdentity(actor: PermissionActor, login_user: UserPayload) # frozen dataclass + .from_open_api_principal(principal) # 同步;S→主体自身,D→("user", effective_user_id) + await .from_user(user_id, tenant_id, *, user_name="", data_scope=DATA_SCOPE_ALL) # 异步:管理员事实经 resolve_permission_actor 解析 +RetrievalRequest(query: str, knowledge_ids: list[int] | None = None, + whitelist: list[int] | None = None, top_k: int = 10, max_content: int = 15000, + tag_filters: dict[int, list[str]] | None = None) # tag_filters = {knowledge_id: [tag_name]} +RetrievalChunk(knowledge_id, knowledge_type, knowledge_name, document_id, document_name, + chunk_index, content, document_update_time) +RetrievalFacadeResult(chunks: list[RetrievalChunk], total: int, effective_scope: list[int], + truncated_params: dict[str, int]) +AccessibleKnowledge(knowledge_id, name, type, description) # type = "space" | "library" +ReachabilityReport(reachable: list[int], unreachable: list[int], revoked: list[int]) +knowledge_type_label(knowledge_type: int) -> "space" | "library" + +await RetrievalFacadeService.retrieve(identity, req, *, version_repo=None) -> RetrievalFacadeResult +await RetrievalFacadeService.list_accessible_knowledge(identity, *, name=None, limit=200) -> list[AccessibleKnowledge] +await RetrievalFacadeService.check_reachable(identity, knowledge_ids, *, whitelist=None) -> ReachabilityReport + RetrievalFacadeService.is_supported_knowledge_type(t) -> bool # 纯函数,非 async + +常量 SUPPORTED_KNOWLEDGE_TYPES={0,3} · RETRIEVAL_TOP_K_MAX=200 · RETRIEVAL_MAX_CONTENT_MAX=60000 + · RETRIEVAL_TARGETS_MAX=50 · RETRIEVAL_SCOPE_MAX=200 +``` + +**两处落地时定案的细节**(design 原文留了「二选一」): + +- **`version_repo` 走关键字传参**(`retrieve(..., version_repo=)`),不由门面内部调 `get_knowledge_document_version_repository()`。后者是 FastAPI 依赖工厂,领域服务去调它就是领域层反向依赖入口层(C1);而且 F055 / F057 未必有 `Request`。v2 端点把自己的 `Depends` 结果原样递进来。 +- **`from_user` 是 `async`**:管理员事实必须经 `resolve_permission_actor` 解析(一次可能的 FGA 往返),不能由调用方猜。`from_open_api_principal` 保持同步——闸已经解析过,actor 里就有。 +- **执行身份的两条权限事实来源(落地时踩过,写死在此)**: + - `from_open_api_principal` **沿用闸装的 actor**(`get_current_permission_actor()`,仅当 `subject_type` / `subject_id` / `tenant_id` 与 principal 的授权主体三者相等时采纳,否则才按 principal 现构一个)。principal 本身**不带** `data_scope` 与管理员事实(`open_api/domain/context.py` 的字段表),闸才有(`dependencies.py` 的 `pat_data_scope` + `_check_is_global_super` / `is_tenant_admin`)。现构一个 `PermissionActor(data_scope=DATA_SCOPE_ALL)` 不是「少带了点信息」——门面随后 `set_current_permission_actor(identity.actor)` 会把闸装的那个盖掉,于是**被 F066 窄化的个人令牌在检索面拿回全量范围**(窄化完全由 `actor.data_scope` 驱动)。坑 8 说的「管理员事实取自已装 actor」就是这一条。 + - `from_user` **解析前先 `set_current_permission_actor(None)`**、`finally` 复位。`resolve_permission_actor` 先读 ContextVar(坑 5),而 F055 是带着应用自己的凭据 actor 调它的——不清空就把访问用户换成了应用的可见范围(AC-42 反向)。 + +**`check_reachable` 的口径**:只评估 `knowledge_ids` 里的目标,`whitelist` 只用于判定「是否属于声明范围」与「声明条目是否已消失」;不会去遍历 `whitelist` 中未被 `knowledge_ids` 点名的条目。F055 预检时把能力声明同时传进两个参数即可得到逐条结论。 + +**④ 错误码 263 段**(`common/errcode/mcp_face.py`;`McpFaceError(BaseErrorCode)` 带 `http_status`) + +| 码 | 类 | HTTP | category | 说明 | +|---|---|---|---|---| +| 26301 | `McpUnknownToolError` | 404 | `unreachable` | 注册表无此工具(含 F051 未落地时的 ③、F054 未落地时的数据工具) | +| 26302 | `McpToolScopeMissingError` | 403 | `scope_missing` | `data.required=`;与 26003 分开:26003 是 HTTP 端点缺位,本码是工具缺位(一码一义) | +| 26303 | `McpIdentityHeaderRefusedError` | 403 | `identity_header_refused` | 「MCP 面不承载委托」(AC-30) | +| 26304 | `McpToolArgumentInvalidError` | 400 | `invalid_argument` | `data.errors` 为 pydantic 摘要 | +| 26305 | `McpAppNotOwnedError` | 403 | `not_your_app` | 非你名下应用 / 不存在,同响应、不带 owner 名(AC-34) | +| 26306 | `McpIdentityNotFoundError` | 404 | `unreachable` | 用户 / 部门不存在或跨租户,同响应(AC-32) | +| 26320 | `RetrievalIdentityMissingError` | 403 | `internal` | 执行身份缺失或无法确立(AC-23) | +| 26321 | `KnowledgeUnreachableError` | 404 | `unreachable` | `data.unreachable_ids`;不存在 / 未授予 / 类型不支持 / 白名单外 同响应(AC-11 / AC-27) | +| 26322 | `KnowledgeCapabilityRevokedError` | 409 | `capability_revoked` | `data.knowledge_id`;白名单条目已删除 / 不再受支持(AC-46) | +| 26323 | `RetrievalScopeTooLargeError` | 400 | `scope_too_large` | 目标 > 50 或授予范围 > 200,请显式指定 | + +复用不新增:26001 / 26002 / 26027(凭据缺失 / 无效 / 服务账号停用)、26030(凭据校验依赖不可用)、26051(`OpenApiDelegateLocalDevRefusedError`,`common/errcode/open_api.py:250`)、16207(`AppPublishRuntimeLayerDisabledError`,`common/errcode/app_publish.py:109`)、16101 / 16161 / 16254(应用不存在 / 日志非 owner / 发布面 owner-only,`common/errcode/app_factory.py` · `app_publish.py`)、**16162 / 16163**(数据面非 owner / 应用尚未建库——两码只存在于**未合并**的 data-plane 切片 `wf_f464b35c-e86-3` 的 `app_factory.py:221` / `:237`,合并前 `ERROR_CATEGORY_MAP` 里对它们用字面整数、不 import)、19002 / 19201(权限引擎,503 / `permission_unavailable`)。 + +**⑤ 审计**:`action="open_api.mcp.tool_call"`、`target_type="mcp_tool"`、`target_id=`、metadata 见 D7。 + +**⑥ 接入信息**:`GET /api/v1/dev-toolkit/versions` 增 `mcp: {url, transport, auth}`(D11)。 + +### 4.3 关键模块职责 + +| 模块 / 文件 | 职责 | 不做什么 | +|---|---|---| +| `open_api/mcp/server.py` | `BishengMcpServer(FastMCP)` 装配(D1 参数)、`list_tools` / `call_tool` 覆盖(含坑 19 的 `ToolError` 解包)、注册表 → `add_tool`、`build_mcp_route()` / `mcp_session_manager_run()` 两个给 `main.py` 用的工厂 | 不鉴权、不碰 ContextVar | +| `open_api/mcp/gate.py` | `McpAccessGate` ASGI 闸:admit → K3 两拒 → execution_scope → 传输层错误信封 → 传输层审计 | 不认识任何工具 | +| `open_api/mcp/registry.py` | `TOOL_REGISTRY` / `McpToolSpec` / `visible_tools(principal)` / `require_tool(principal, name)` | 不执行工具 | +| `open_api/mcp/errors.py` | `McpToolError`、`ERROR_CATEGORY_MAP`、`to_tool_error(exc)`(任意异常 → 三要素) | 不定义错误码(那在 `common/errcode/mcp_face.py`) | +| `open_api/mcp/audit.py` | `audit_tool_call(...)`、`audit_transport_refusal(...)` | 不写 query 正文 | +| `open_api/mcp/tools/{knowledge,models,identity,apps}.py` | 六类 handler:入参 pydantic 模型、构造执行身份 / actor、调领域服务、把结果整形为 §4.2 ② | 不做权限判定(门面 / 服务层做)、不直连 manager | +| `open_api/api/dependencies.py` | 拆出 `admit_open_api_principal` / `open_api_execution_scope`(D2);`open_api_access_context` 行为不变 | — | +| `knowledge/domain/services/retrieval_engine.py` | 从聊天服务搬出的检索引擎(双层过滤、tag 解析、更新时间水合) | 不判可及性、不知道白名单 | +| `knowledge/domain/services/retrieval_facade_service.py` | 门面:身份 fail-closed、范围解析、可及性、白名单、上限、收回信号、清单 | 不创建会话、不写消息 | +| `knowledge/domain/services/knowledge_space_chat_service.py` | `aretrieve_chunks` 薄委托引擎;对话链其余不变 | — | +| `department/domain/services/org_directory_service.py` | 租户全量组织只读(D8) | 不收窄、不返回凭据字段 | +| `app_publish/domain/services/publish_status_service.py` · `app_runtime/domain/services/app_query_service.py` | 加 `entry` owner-only(D9,只针对 ⑥ 的状态 / 日志) | — | +| `app_runtime/domain/services/app_data_service.py`(**F054 交付,本 Feature 不改一行**) | ⑤ 的服务端;owner-only 已无条件内建 | 本 Feature 不加 `entry`、不加工具层 owner 比对 | +| `dev_toolkit/api/endpoints/distribution.py` | `versions` 增 `mcp` 段(D11) | — | +| `common/errcode/mcp_face.py` | 263 段 | — | + +--- + +## 5. 已知坑 / 反直觉事实 + +| # | 反直觉事实 | 如果不知道会怎样 | 在哪处理 | +|---|---|---|---| +| 1 | `FastMCP(host="127.0.0.1")`(默认)自动装 `TransportSecuritySettings(allowed_hosts=["127.0.0.1:*","localhost:*","[::1]:*"])`(`server.py:177-183`);DNS-rebinding 校验对 `Host: 192.168.106.114` 返回 421。nginx 恰恰**透传真实 Host**(`default.conf:121 proxy_set_header Host $host`) | 本地测试全绿、上 114 经 nginx 全 421 | D1:显式 `transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False)`(nginx / 网关已是入口信任根) | +| 2 | `StreamableHTTPSessionManager.handle_request` 要求先进入 `run()` 任务组(`streamable_http_manager.py:98-137`),且 `run()` **每实例只能进一次**(`_has_started` 守卫,重入直接 RuntimeError);另外 `session_manager` 属性在 `streamable_http_app()` 被调用前读也抛 RuntimeError(`fastmcp/server.py:260-276`,懒建) | 注册了但 lifespan 没进 → 每个请求 RuntimeError 500;先读 `session_manager` 再建 app → 启动就崩;uvicorn `--workers N` 每进程各进一次是正确的(各自一个实例) | D1 的三步装配顺序:先 `streamable_http_app()`(丢弃返回值)→ 再取 `session_manager` 建 `Route` → `main.py lifespan` 内 `async with server.session_manager.run():`(只在开关开时) | +| 3 | v2 `POST /filelib/retrieve` **今天已是文件级双层过滤 + fail-closed**:`aretrieve_chunks :726 → _aretrieve_chunks_dispatch :836 → _retrieve_and_filter :393`(`build_index_prefilter :120` + `post_filter_retrievable_files :237`,`knowledge_file_visibility_service.py:216-217` 明写 "this service has no creator/admin fallback"),权限引擎故障由 `permission_action_service.py` 抛 19002 → `filelib.py:721-722` 包成 26030。Discovery / 伴生 B.1「只做库级一次校验」结论**已过时**;`filelib.py:700-702` 注释与实现一致。第三条链(工作台 `workstation_service.py:1401-1405`)同样经 `post_filter_retrievable_files` 做文件级过滤 | 按 spec 字面「从库级升级到文件级」去重写检索链 = 造第四条路径 | D5 抽出而非重写;D6 只改错误形态与身份构造;`docs/api/filelib-retrieve.md:287` 才是失实处;工作台链不动(决议-9) | +| 4 | `KnowledgeSpaceChatService` / `KnowledgeFileVisibilityService` / `KnowledgeSpaceService` 构造都收 `request: Request`,但检索判定链(`_require_action` / `batch_check_business_actions` / `resolve_permission_actor`)读的是 `login_user` 与 ContextVar;`self.request` 只出现在 `KnowledgeFileVisibilityService:81`(转手建 `KnowledgeSpaceService`)与 `KnowledgeSpaceService` 的审计 / 上传路径(`:1200 / :1244 / :1690 / :4894`,其中 `:4894` 本就写成 `if self.request else None`)。**生产代码里已经有一处 `request=None` 的先例**:`workstation_service.py:1244 KnowledgeFileVisibilityService(request=None, login_user=login_user)` | 假设「必须有 Request」→ 门面伪造 Request 或绕不开聊天服务 | T101 搬出引擎时照 `workstation_service.py:1244` 的先例用 `request=None` 构造可见性服务并加守卫测试;若发现判定链读 `self.request`,改为可选并记偏差 | +| 5 | `resolve_permission_actor(login_user)` **先读 ContextVar** `current_permission_actor`(`permission/application/identity.py:40-42`)——v2 与 MCP 闸已装的 actor 会覆盖 `login_user` 派生;F055 / F050 调门面时若忘装 actor,就按 `login_user` 派生(自然人事实正确,但服务账号会被当 `user` 类型) | 服务账号执行身份类型错位 → FGA 查错主体 → 全部不可见(fail-closed 方向,但结果错) | D5:门面体内自己 `set_current_permission_actor(identity.actor)`,调用方无需关心 | +| 6 | `aretrieve_chunks` 对空 `knowledge_base_ids` 抛 `HTTPException(400)`(`:749-750`);`KnowledgeTypeNotSupportedError` 是 10962;`NotFoundError` 404;空间权限 `SpacePermissionDeniedError` 18040 —— 三种「不可及」今天是**三个可区分响应** | 以为不可及已统一 | D5 门面在引擎之前做批判并统一为 26321;引擎内部异常只在「批判通过后行被并发删除」这类竞态才会冒出 → 门面兜成 26321 | +| 7 | `KnowledgeService.get_knowledge` 的 admin bypass(`knowledge_service.py:507-519`)对 `data_scope != DATA_SCOPE_ALL` 关闭;服务账号永不为管理员(beta2 `PermissionActor` 对 service_account 强制清零 super/tenant_admin,`open_api/domain/context.py:47-53` 注释) | 在门面「全部授予范围」枚举里给服务账号走 DB 扫描 = 全租户泄露 | D5 只对 `actor.super_admin ∨ tenant_admin` 且 `data_scope==ALL` 走扫描 | +| 8 | `get_open_api_operator_async()`(`open_endpoints/domain/utils.py:62-81`)会对自然人调 `init_login_user`(多 1 次 Redis / FGA)并对服务账号构造 `force_non_admin` 的 `UserPayload` | 门面执行身份若经它构造,每次检索多两次远程调用 | `RetrievalIdentity.from_open_api_principal` 直接构造 `UserPayload(is_global_super=False, user_role=[])`,管理员事实取自已装 actor | +| 9 | `_OWNER_ONLY_ENTRIES` 只在 `_require_log_access` 生效;`get_instance → _load_visible :246-261` 与 `PublishStatusService._require_viewer :181-197` 放行租户管理员与超管 | 应用状态工具违反 AC-35 | D9 `entry` 参数 | +| 10 | `AppQueryService._load :237-244` 会 `set_current_tenant_id(app.tenant_id)` **覆盖** ContextVar,之后再比租户就恒真 | 工具层在服务返回后做租户比对 = 无效 | 租户比对由服务层按 actor 做(`:266-284` 注释已说明);工具层不重复判 | +| 11 | `OpenApiAuditMiddleware` 按前缀 `/api/v2` 无差别记 `open_api.call`(`middleware.py:22-27`) | MCP 每次调用双记、且那条只有 `POST /api/v2/mcp` | D7 中间件短路 | +| 12 | `test_open_api_route_matrix.py:36-41 test_every_real_v2_route_is_globally_key_protected_and_marked` 对 `app.routes` 里**每一条** `/api/v2` 前缀路由取 `route.endpoint` 并要求有 `@open_api_scope` marker;同文件 `actual_v2_routes() :25-34` 已 isinstance 过滤、无需动 | 注册 MCP 路由后该断言必红(`Route.endpoint` 是闸对象、没有 marker);若用 `Mount` 则更早地 AttributeError | T203 只改那一个函数:加 `isinstance(route, (APIRoute, APIWebSocketRoute))` 过滤,另加 `test_mcp_route_present_iff_open_platform_enabled` | +| 13 | `settings.open_platform.enabled` 是进程级 YAML 键(`core/config/open_platform.py:8-9`),`api/router.py:140` 在**导入期**判断;`main.py` 注册路由亦在 `create_app` 期判断 → 翻开关必须重启;114 `config.yaml` 未跟踪、新增顶级键让老镜像拒启 | 改了 yaml 不重启 / 先加键后发代码 | 部署顺序:先发代码、再加键、全量重启(memory `project_compose_vs_systemd_form_divergence`) | +| 14 | `mcp` 低层 `call_tool` 对 handler 异常统一 `_make_error_result(str(e))`(`lowlevel/server.py:583-584`)——异常类型信息丢失,只剩字符串 | 想靠异常类让客户端区分失败类别 = 不可能 | D4:JSON 进 `str(e)` | +| 15 | FastMCP 的 `Context.request_context`(`server.py:1153-1159`)在 stateless 模式下能拿到当次 `Request`,但**`list_tools()` 的签名里没有 Context 参数**(`server.py:315`) | 想从 Request 拿 principal 过滤清单 → 拿不到 | 用 ContextVar `get_current_open_api_principal()`(闸已装) | +| 16 | `resource_owner_of(principal)`(`publish_pipeline_service.py:111-127`)对 `resource_owner_user_id=None` 抛 16205 而非当 0 | PAT 主体调应用工具得到「归属不符」而非「缺位」 | 注册表先判 `app:manage`(PAT 签不出该位 → 26302 先于 16205) | +| 17 | 服务账号在 beta2 是独立 `service_account` 表(无 `user` 行),组织查询天然不含;但 `UserPayload.user_id` 对服务账号取的是 `authorization_subject_id`(服务账号 id,非 user id) | 用该 id 去 `UserDao.aget_user` 会拿到**同 id 的自然人** | 门面 / 工具层**从不**用服务账号的 `user_id` 查 `user` 表;应用类工具用 `resource_owner_of` | +| 18 | `pnpm check-i18n`(`src/frontend/scripts/check-i18n.mjs`)对后端码的识别靠正则 `/Code:\s*int\s*=\s*(\d+)/g`(`:106`,在 `collectErrorCodes() :96`)——写成 `Code = 26301` 的类**不被计入**、缺三语也不报(`common/errcode/open_api.py` 整份就是这个写法,constitution C5 为此专门说明「derive command 两种写法都认」,但 check-i18n **只认带注解的那种**);三语键一致性(`collectParity() :77-94`)对 `packages/locales/src/api_errors/*.json` 生效、多一语少一语都拦;后端进程**不装**前端 locale 包,`api_errors` JSON 在运行期读不到 | 错误码类写法漏 `: int` → 无三语也绿;把 `next_step` 放进 `api_errors` → 后端拿不到、只能再复制一份 | T001 子类一律 `Code: int = 263xx`;`next_step` 三语放后端 `errors.py: NEXT_STEP_COPY`(D4),T203a 断言 `ERROR_CATEGORY_MAP` 每码三语齐全 | +| 19 | **`Tool.run` 把 handler 的任何异常重包一层**:`mcp/server/fastmcp/tools/base.py:116-117` `except Exception as e: raise ToolError(f"Error executing tool {self.name}: {e}") from e` | D4 精心构造的三要素 JSON 变成 `Error executing tool bisheng_knowledge_search: {…}`,客户端 `json.loads` 失败,AC-09 白做——而只断言 `isError=True` 的测试**看不出来** | D4 / T203:`BishengMcpServer.call_tool` 覆盖体 `except ToolError as e: raise e.__cause__ if isinstance(e.__cause__, McpToolError) else to_tool_error(e)`;T203a 必须把返回文本真的 `json.loads` 一次 | +| 20 | handler 有返回类型注解时 FastMCP 会生成 `outputSchema`,低层在 `lowlevel/server.py:559-569` 校验 `structuredContent`,不符即 `_make_error_result("Output validation error: …")` | 出参模型与实际返回漂移 → 工具变成一条没有 `code` / `category` 的纯文本错误,**绕过**整个 D4 错误层,排查时看不出是 schema 问题 | D4「出参注解口径」:每个工具一次成功调用的用例断言 `structuredContent` 非空且文本里无 `Output validation error` | +| 21 | `view_file` **不是权限引擎里的关系名**——F048 上真正的 action 是 `knowledge_file` 资源的 `visible`(`knowledge_file_visibility_service.py:230-236`)。`view_file` 只活在注释与文档里:`filelib.py:700-702`、`workstation_service.py:1155 / :1225 / :1401`、`citation/domain/models/message_citation.py:17`、`citation/domain/schemas/citation_schema.py:109` | 按 `grep view_file` 找判定点 → 只找到注释,误以为没有文件级过滤(伴生 B.1 的错误结论多半就是这么来的);或者照着造一个 `view_file` action 传给 `batch_check_business_actions` → 恒 False,fail-closed 方向但结果全错 | 门面一律用 `visible`(知识空间 / 文件)与 `use`(文档库);T103 顺手把 `filelib.py:700-702` 的注释改掉 | +| 22 | `McpConf.enable_stdio`(`core/config/settings.py:671`,`initdb_config.yaml:166`)**与本面无关**——它是平台作为 MCP **客户端**时允不允许跑 stdio 传输的开关,唯一消费者是 `tool/domain/services/tool.py:393` | 排查「MCP 起不来」时去翻这个键、或以为要顺手把它打开 | 本面的开关只有 `settings.open_platform.enabled`(K1 / 坑 13);两者不要混 | +| 23 | **权限判定没有缓存**:门面每次检索对每种资源类型各发一次 `batch_check_business_actions`,其内部按候选 × action 逐个解析(`business_authorization.py:120-126` 的注释把这条成本写得很清楚,超管才有短路);MCP 与托管运行期都是高频调用面 | 把「一次检索 = 一次 FGA 往返」当默认,上量后 p95 被权限判定吃掉;或者反过来,为了提速加一层带 TTL 的可见性缓存,撞碎 INV-28 的撤销 5 秒上界与 INV-30 的 fail-closed | 本版**不加缓存**(spec §3 边界:正确性优先);只做批量化(一次 `aget_list_by_ids` + 每类型一次 batch check,D5)。要加缓存时先回答「撤销 5 秒怎么办」,答不上就别加 | + +--- + +## 6. 对外契约与依赖 + +### 6.1 我提供给别人的(Outgoing) + +| 契约 | 形式 | 谁在用 | +|---|---|---| +| `POST /api/v2/mcp`(§4.2 ①)+ 六类工具(§4.2 ②) | MCP streamable-http | 开发者本地 agent;F053 T041 跨 Feature 旅程;PAT 持有人的 AI 助手(两工具) | +| `RetrievalFacadeService.retrieve / list_accessible_knowledge / check_reachable / is_supported_knowledge_type` + `RetrievalIdentity`(§4.2 ③) | 内部 Python | v2 `filelib.retrieve_chunks`(本 Feature)· **F055** T057(`from_user(访问用户)` + `whitelist=能力声明`)/ T060 预检(`is_supported_knowledge_type` / `check_reachable`)· **F057** SDK retrieve 的服务端(经 v2 端点或 F055 注入通道)· **F050** AC-43(`from_open_api_principal(模式 D principal)`) | +| 26322 `KnowledgeCapabilityRevokedError(data.knowledge_id)` | 异常 | F055 AC-53 转 16273 | +| `GET /api/v1/dev-toolkit/versions.mcp.url` | HTTP | F053 T046 接入信息区 | +| `open_api.mcp.tool_call` 审计行 | audit_log | F056 查询面 | +| `PublishStatusService.get_publish_status(..., entry=)` / `AppQueryService.get_instance(..., entry=)` | 内部 Python(加参,缺省行为不变) | F055 发布面 / F054 详情页(无感) | + +### 6.2 我依赖别人的(Incoming) + +| 依赖 | 形式 | 风险点 | +|---|---|---| +| `validate_bearer` / `open_api_access_context` 装配块(beta2 F053) | 内部 Python(本 Feature 拆分导出,行为不变) | `test/open_api` 40 个测试文件是回归护栏 | +| `PermissionActor(data_scope)`(F066)· `batch_check_business_actions` / `runtime.list_visible_objects`(F048) | 内部 Python | `list_visible_objects` 有 5000 上限;门面自设 200 | +| `KnowledgeSpaceChatService` 三个私有检索方法(F029 / F030) | 代码搬迁 | 搬后聊天服务的 `test/knowledge` 既有用例必须仍绿 | +| `AppQueryService.get_logs(entry=LOG_ENTRY_MCP)`(F054 已预埋)· `PublishStatusService.get_publish_status`(F055) | 内部 Python | 加 `entry` 是本 Feature 改动,F055 design §4.2 ② 字段原样透传 | +| **F054 `AppDataService`(T086/T087;HEAD `fe10f75ea` 树上不存在,本波次 data-plane 切片已实现但**尚未合并**)** | 内部 Python。**签名已按未合并实现核实**(只读来源:worktree `/Users/lilu/Projects/bisheng/.claude/worktrees/wf_f464b35c-e86-3` 的 `bisheng/app_runtime/domain/services/app_data_service.py:56+`):`list_tables(app_id, *, actor)` / `get_table_schema(app_id, table, *, actor)` / `get_rows(app_id, table, *, actor, page, size, order)` / `update_row(app_id, table, key, values, *, actor)` / `export_table(...)`;**无 `entry`**,owner-only 内建;错误 16101 / 16162 / 16163。对外端点 `/api/v1/apps/{id}/data/*`、manager RPC `/v1/apps/{id}/db/*` | **合并时间未定,是本 Feature 的待定依赖**:落地后签名若异 → 只改 `tools/apps.py` 适配层、记 tasks 偏差;insert / delete 今天不存在,需 F054 补 RPC(T209 阻塞 + T305 回写请求) | +| **F051 `resolve_callable_names`(未落地)** | 内部 Python | 未落地时工具 ③ 不注册(26301) | +| `mcp==1.27.1`(uv.lock 已有) | 依赖 | 升级需复核 K11 两处与 D4 的 `_make_error_result` 行为 | +| nginx `/api` location · 网关 `/api/v2/**` 代理 | 部署 | JSON 响应无长连接,现配置即可;114 系统形态与 compose 形态各验一次 | + +--- + +## 7. 测试与可观测 + +- **单元(本地可跑,无中间件)**:`test/open_api/test_mcp_*.py`(闸 / 注册表矩阵 / 错误三要素 / 审计 / 开关 / delegate 与身份头拒绝 / 密钥零回显)用 `mcp_client` fixture + monkeypatch 领域服务;`test/knowledge/test_retrieval_facade.py`(身份 fail-closed / 范围解析 / 白名单三态 / 上限截断可见 / 26321 同响应 / 26322)用 fake 引擎;`test/knowledge/test_retrieval_engine_extraction.py`(搬迁等价:聊天服务 `aretrieve_chunks` 与引擎同输入同输出)。 +- **集成(CI 中间件分组,`-m e2e`)**:`test/knowledge/test_retrieval_facade_equality.py`——样本按 AC-40:未授予库、同一空间部分文件无权(单文件直接授权 / 自定义模式脱钩 / 文件夹差异三种来源各一)、文档库库级、白名单外库、被代表用户;三处(MCP / v2 / 门面直调)集合相等;`fga_down` 三处零结果明确错误(AC-44);撤销后 ≤ 5s 拒绝(AC-05)。 +- **114 手动**(`bash /opt/bisheng-ops/deploy.sh` 后;`config.yaml` `open_platform.enabled: true`):① `curl -s -o /dev/null -w '%{http_code}' -X POST http://192.168.106.114:4101/api/v2/mcp` → 401;② `claude mcp add --transport http bisheng http://192.168.106.114:4101/api/v2/mcp --header "Authorization: Bearer "` → `/mcp` 列出与权限位一致的工具;③ 用非管理员 `shuiwu` 名下服务账号(只授 1 个知识空间且其中 1 个文件单独收权)验证 `bisheng_knowledge_search` 不返回该文件;④ 撤销密钥 → 5s 内工具调用 401;⑤ `app_runtime.enabled: false` 的环境上两类应用工具不在清单、直调 16207。 +- **日志 / 指标**:闸与工具层各一条结构化日志 `open_api.mcp | tool=… credential_id=… outcome=… latency_ms=…`(不含 query);门面复用引擎既有 `permission_filter | …` 行(`knowledge_space_chat_service.py:414` 与 `:466` 两处,随 `_retrieve_and_filter` 搬入引擎);审计行按 D7。 + +--- + +## 8. 后续改进 / 不打算做的事 + +- **有状态会话 / SSE / 服务端通知**(D1);**OAuth 2.1 RS 元数据 / `token_verifier`**(D2);**MCP 限流与按 key 账单**(PRD-1 §5.2);**第三方 MCP 网关化**(§5.2);**QA 库检索**(决议-10);**文档知识库文件级权限**(决议-3);**审计高频分层**(决议-12 → F056);**应用数据工具的写二次确认**(决议-8「何时重新考虑」)。 +- **tools/list 分页**:SDK 支持 cursor,本版工具 ≤ 15 个,一页返回。 +- **触发重写的条件**:MCP 规范把错误结构化正式化 → D4 迁移;工具 > 30 → D3 改 DB 驱动。 + +--- + +## 修订历史 + +| 日期 | 改动 | 触发原因 | +|---|---|---| +| 2026-09-16 | 初版(全自动模式定案 D1–D12;坑 1–18;263 段错误码;门面契约 §4.2 ③) | F052 design/tasks 编写工作流 | +| 2026-09-16(续) | 接手续写:全部 `文件:行号` 按 HEAD `fe10f75ea` 重新 grep 核实并订正(`_attach_document_update_time :788`、`distribution.py:56`、`filelib.py:689/:722`、`test/open_api` 40 文件等);**D4 `next_step` 三语改为后端自有 `NEXT_STEP_COPY`**(原拟放 `api_errors` 的 `.next_step` 键作废——后端运行期读不到前端 locale 包,坑 18 重写);D9 / §6.2 标注 `AppDataService` 由本波次 data-plane 切片同期实现 | 前序 agent 中断,全自动模式续写定案 | +| 2026-09-16(`/sdd-review design` + `tasks` 独立审查后就地修订) | **4 条 high**:① **D1 `app.mount` → 精确 `Route`**——`Mount` 的正则是 `^/api/v2/mcp/(?P.*)$`,裸路径会被 `redirect_slashes` 回 307,官方 SDK 因 `follow_redirects=True` 掩盖了它、非 SDK 客户端与网关不保证跟随(K5 / 坑 12 / §4.1 / §4.2 ① 同步改);② **D4 补坑 19**——`Tool.run`(`fastmcp/tools/base.py:116-117`)把 handler 异常重包成 `ToolError(f"Error executing tool …: {e}")`,三要素 JSON 会被加前缀,必须在 `call_tool` 覆盖层按 `__cause__` 解包;③ **D6 显式定案两处受控收紧**——F066 `data_scope` 拒绝折叠进 26321(`test_data_scope_matrix.py:41` 的 `"raise"` 分类**必须改**,原文「必须仍绿」是错的)、`RetrieveReq.max_content` 补 `le=60000`(否则对 v2 是静默夹取);④ **D9 按已实现但未合并的真实签名重写**(`get_table_schema` / `get_rows` / `key`、**无 `entry`**、owner-only 内建、16162 / 16163),来源与合并状态写进 §6.2。**medium**:D2 补拆分的 ContextVar 顺序风险;D3 补 `MCPTool` 组装字段;D4 补出参注解口径与坑 20;新增坑 21(`view_file` 只是口头名)/ 坑 22(`McpConf.enable_stdio` 与本面无关)/ 坑 23(权限判定无缓存与「别加缓存」的红线)——三条都是 spec §5 指针承诺过而初版漏写的。**low**:约 20 处行号 / 符号名订正(`router.py:140`、`open_platform.py:47-51`、`exception_handlers.py:56-69`、`server.py:177-183`、`streamable_http_manager.py:98-137`、`lowlevel/server.py:467`、`check-i18n.mjs:106`、`aretrieve_chunks` 的 400 在 `:749`、`permission_filter` 在 `:414/:466`、`deploy.py:206-211` 等),以及 SDK 行号口径改指主检出 `.venv` | `/sdd-review`(design 24 条 + tasks 21 条)独立审查 | diff --git a/features/v3.0.0/052-mcp-server-face/spec.md b/features/v3.0.0/052-mcp-server-face/spec.md new file mode 100644 index 0000000000..4248fd6643 --- /dev/null +++ b/features/v3.0.0/052-mcp-server-face/spec.md @@ -0,0 +1,224 @@ +# Feature: MCP Server 工具面与统一检索门面(六类工具 + 文件级过滤门面 + `delegate` 入口拒绝) + +> **本文档定位 — 纯 What(需求口径,不随代码漂移)** +> +> spec 只回答 **做什么 / 验收标准 / 不做什么**。所有 How(决策、数据流、字段、API、Service、前端、文件清单、性能指标)一律不写在这里, +> How 的唯一真相在 [design.md](./design.md) 与 [tasks.md](./tasks.md)。 + +**关联 PRD**: [docs/PRD/3.0-release/3.0 应用工场 PRD-1 专业开发者通道与应用运行时.md](../../../docs/PRD/3.0-release/3.0%20应用工场%20PRD-1%20专业开发者通道与应用运行时.md) **v2.0** §3.1 引言(本域操作者是 agent)/ **DEV-02**(六类工具表、规则 ①②③④、验收 1 / 2 / 3 / 5 / 6 / 7 / 8 / 9 / 10——验收 4 与模型协议面归 F051)/ DEV-01 ①(权限位 ↔ MCP 工具映射表)与 ③(`identity:read` 高危位、授权入口唯一、资源归属人两条落地口径)/ DEV-01 验收 1 / 2 / 5 / 6 / 7(MCP 部分)/ DEV-04「日志三入口」表与验收 12 / DEV-05 ③(`dev` 期经 MCP 调用按服务账号授予范围)/ **GOV-05「知识库检索范围」**(两层过滤、口径澄清、信任机制分界、文件级、fail-closed;验收 5 / 6 / 7)/ GOV-10(开放能力层:MCP 工具面随之可达 / 不可达)/ NFR-1(1 / 4)/ §5.1(本地端专有插件不做)/ §5.2(第三方 MCP 网关顺延);伴生 [docs/PRD/3.0-beta2/3.0 开放 API 鉴权与身份传递 PRD.md](../../../docs/PRD/3.0-beta2/3.0%20开放%20API%20鉴权与身份传递%20PRD.md) **v2.1** §4.2.4(扩展位表、三面仅模式 S 且与 `delegate` 互斥)/ §4.4.1–4.4.2(两层判定 + 三条铁律)/ **§4.4.6(纯替换的等价性要求:检索须补文件级过滤)**/ 附录 B.1「关于知识库检索的权限过滤(重要事实澄清)」/ **附录 B.2**(本地开发三面:MCP 各工具 ↔ 权限位、仅 S)/ 验收 AC-12 / AC-13 / AC-49 / §六 非目标;《3.0 应用工场 产品方案》§5.1 F105 +**同步记录**: 2026-08-17 初稿(按 PRD-1 v2.0 + 伴生 PRD v2.1 + release-contract 拆分 v2 表 3 F052 行 + Discovery §2.3 / §2.9 / N3 编写;§4 决议 1–12 于同日按用户授权的全自动模式直接定案并留痕;候选 INV-36 随本 spec 定稿转正);同日按独立审查 17 条裁定就地修订(应用数据工具改读写、「类型不支持」口径统一为检索面「不可及」、能力收回信号 AC-46、NFR-2 不回显 AC-47、AC 去 How、依赖补 F050、§3 / 决议-9 去代码现状叙述;AC 编号稳定、新增末尾追加) +> **2026-08-28 术语订正(就地修订,范围不变)**:AC-03 原文写的是「以密钥主体(**服务账号**)自身身份执行」。伴生 PRD v2.4 起模式 S 的主体有两类——服务账号与自然人(个人访问令牌,§4.10)——**S = Self「自身身份」,不是 service account**。本 spec 的括注随之去掉。**AC 语义与范围一字未改**:本 Feature 交付时 MCP 面的凭据仍只有服务账号一类;订正的目的是避免实现方按字面把自然人主体挡在门外——伴生 §4.10.8 已把 MCP 列为个人令牌的**可选增强路径**,届时同一条 AC 应当直接成立。 + +> **2026-10-10 实现收敛(用户拍板)**:vibe 合入 `feat/3.0.0` 时,平台上同时存在本 Feature 的 MCP 服务与 F067 统一远程 MCP 服务,二者占用同一路径 `/api/v2/mcp`。用户决定以 F067 为底座、鉴权逻辑以 F067 的共享 `access_context` 为准。落地结果:身份 / 组织 3 个工具与应用 6 个工具并入 F067 的 `open_mcp`,工具名不变,仅模式 S、PAT 不可见、持 `delegate` 的密钥不可见;本 Feature 的「知识库检索」由 F067 的 `bisheng_knowledge_retrieve` 承接,并改走统一检索门面(文件级过滤);「知识库清单」由 F067 的 `bisheng_knowledge_list` 承接;「模型清单」工具移除,改由模型协议面 `GET /api/v2/model/v1/models` 提供(技能包本来就引导 agent 走这个接口)。本 Feature 自己的 MCP 传输层、鉴权闸、审计与 `next_step` 三语文案随之删除。受影响的 AC:AC-04、AC-13、AC-45 中的「模型清单」项与「指明缺哪一位」的拒绝形态,以及 F052 专有的错误信封,均以 F067 的契约为准。 + +**优先级**: P0 +**所属版本**: v3.0.0 +**依赖**: **F049**(服务账号主体与资源归属人、`bs-sak-` 凭据校验与开放 API 同一鉴权语义及供新入口复用的鉴权依赖、权限位清单——`knowledge:read` 既有位与 `model:invoke` / `identity:read` / `app:manage` 三扩展位、开放能力层部署开关);**F050**(AC-28–30 的验收依赖持 `delegate` 位的密钥、AC-43 的验收依赖模式 D 的准入与被代表用户——本 Feature 的入口拒绝与门面能力可先交付,这两组 AC 的验收随 F050)。**应用类两工具的服务端能力**由 **F054**(实例状态 / 运行日志只读接口 AC-23、每应用数据库服务端接口——表清单 / 表结构 / 行数据读写 AC-56 / AC-45)与 **F055**(审批状态只读接口 AC-38)提供——本 Feature 只做工具面接线与 owner 收窄判定,两工具随其就绪启用。**被依赖**:F050(模式 D 集合相等经本门面)、F053(接入信息区的 MCP 地址、技能包接线内容、`logs` 与 MCP 日志工具口径一致)、F055(托管运行期检索经本门面)、F057(SDK retrieve 经本门面) +**批次**: A(开放能力层);**不在 MVP-114 纵切上**([../mvp-114-path.md](../mvp-114-path.md) §2:表单问卷不用平台能力、不需 MCP);批次 A 顺序 **F049 → F052 → F051 → F053 → F050**——门面早于 F050 因它是模式 D 集合相等(伴生 AC-12)的前提(Discovery N3) + +> **范围边界** +> +> 本 Feature 是专业开发者通道的**调用面**(PRD-1 DEV-02「工具走 MCP」的那一半)与整个开放面的**检索地基**:让开发者的本地 coding agent 凭一把服务账号密钥、以标准 MCP 协议调平台六类工具;并把「文件级过滤 + fail-closed」的检索能力抽成一个会话解耦的**统一检索门面**——MCP 检索工具、v2 `POST /filelib/retrieve`、F055 托管运行期、F057 SDK retrieve 四处共用,F050 模式 D 集合相等依赖它。模型协议面(DEV-02 的另一半)归 F051。 +> +> **候选 INV-36 的承接**(随本 spec 定稿转正):本 Feature 承接「托管运行期检索 = 能力声明白名单 ∩ 当前访问用户可见范围(文件级、双层过滤、fail-closed)」中**门面**这一层——以「执行身份 + 可选声明白名单」为入参、对任何执行身份做同一强度的文件级过滤、无法确立执行身份即拒绝、评估失败即报错;白名单内的库被删除或应用侧授权已收回时门面给出带库标识的「能力已收回」可区分信号(AC-46,供 F055 转成应用可见的收回错误、不静默剔除);F055 承接注入通道与审批期例外(预览实例以 owner 为执行身份调用门面、计审计),F057 承接 SDK retrieve;本地 dev 与 MCP 面锚定开发者服务账号被显式授予的范围。 +> +> - **本次纳入**: +> - **MCP Server 对外工具面**:标准 MCP 协议、经网络传输、任何标准 MCP 客户端零改造接入;服务账号密钥鉴权(与开放 API 同一鉴权语义,F049);**一律模式 S**(以服务账号自身身份执行,可见范围 = 密钥权限位 ∧ 该服务账号被显式授予的资源);工具清单按密钥权限位过滤;撤销 / 到期 / 停用 5 秒上界;权限位编辑即时生效;每次工具调用可归属到密钥与服务账号;MCP 会话与平台会话解耦;错误对本地 agent 可诊断 +> - **六类工具**(权限位映射按 DEV-01 ① / 伴生 B.2):① **知识库检索**(`knowledge:read`,经统一检索门面)② **知识库清单查询**(`knowledge:read`)③ **模型清单查询**(`model:invoke`;模型调用本身归 F051)④ **用户身份 / 组织查询**(`identity:read`)⑤ **应用数据表结构与数据读写**(`app:manage`,表清单 / 表结构读取 + 行级增删改,**不含 DDL**——§4 决议-8)⑥ **应用状态 / 日志自查**(`app:manage`,含审批终态与驳回理由全文)——⑤⑥ 的服务端能力由 F054 / F055 提供,本 Feature 只做工具面接线与 owner 收窄判定 +> - **统一检索门面**:会话解耦的纯检索能力;入参 = 执行身份(服务账号 / 自然人访问用户)+ 查询 + 可选声明白名单 + 检索参数;**文件级过滤且过滤发生在返回前**;fail-closed(执行身份缺失即拒绝、评估失败即报错、绝不返回未过滤或部分过滤的结果集);知识空间按文件级、文档知识库按库级(现权限模型无文件级,§4 决议-3);检索面对库只有「可及 / 不可及」两态(存在性不泄露);能力收回信号(AC-46);四处消费——MCP 检索工具(执行身份 = 服务账号)、**v2 `POST /filelib/retrieve`**(F049 期只加鉴权、过滤强度维持现状,本 Feature 把它收敛到同一门面)、F055 托管运行期(执行身份 = 当前访问用户,白名单 = 能力声明)、F057 SDK retrieve;模式 D 集合相等(伴生 AC-12)由门面保证、F050 承接验收 +> - **三面入口拒绝持 `delegate` 密钥中的 MCP 面**(INV-31):在通道入口按权限位直接拒绝、错误指向「委托专用、本地开发另发一把」、不得静默落回模式 S、不得只回参数错误码;MCP 面亦拒绝任何身份传递类请求头 +> - **`identity:read` 组织查询的边界**:本租户全量可读、跨租户读不到、无收窄机制、平台不做更细收窄;结果只含自然人(服务账号不出现) +> - **应用类工具按 owner 收窄**:只对该密钥所属服务账号的资源归属人 owner 的应用放行;租户管理员名下服务账号的密钥同样拒(管理员查他人应用走应用详情页);日志内容范围同 WB-13;判定为业务规则前置拦截 +> - **开放能力层开关**(F049 拥有)的消费:未部署时 MCP 面不可达、无新增常驻负担;统一检索门面不受该开关影响(它服务恒在的 v2 开放 API) +> - **MCP Server 接入地址**:供 F053 接入信息区展示与一键复制;地址不含密钥或任何可换取密钥的凭据 +> - **本次明确排除**: +> - **模型协议面与模型调用**(OpenAI 兼容直连、流式、按名解析、逐条审计)——F051;本 Feature 只做「模型清单查询」工具(§4 决议-4) +> - **CLI 五命令、两包技能包、`bisheng dev` 身份注入、接入信息区界面本身**——F053;本 Feature 只提供 MCP 地址与工具面 +> - **SDK 三件套**(auth / retrieve / storage)——F057;SDK retrieve 只是门面的一个调用方 +> - **服务账号 / 密钥 / 权限位清单 / 签发表单 / 开放能力层开关的定义**——F049;本 Feature 只消费 +> - **委托模式 D、外部用户标识头、五道准入、审计双归属字段**——F050;本 Feature 只保证门面能以任一被代表用户为执行身份被调用(伴生 AC-12 的能力由本 Feature 交付、验收由 F050 承接) +> - **托管运行期的能力声明冻结 / 审读 / 注入通道 / 收回错误态、审批期临时预览实例、应用运行期凭据**——F055;本 Feature 只提供门面与 MCP 应用状态工具 +> - **实例状态 / 日志只读接口、每应用数据库及其服务端接口(表清单 / 结构 / 行读写,F054 AC-56)、WB-13 运行日志 tab、WB-06 数据面**——F054;MCP 两个应用工具只消费 +> - **受控联网搜索工具**(PRD-1 v1.x 曾列,v2.0 已砍——六类工具表中不存在) +> - **第三方 MCP server 接入网关化**(PRD-1 §5.2 顺延)与**本地端专有插件**(§5.1 不做——四面供给全走标准协议) +> - **MCP 工具的 per-tool 授权粒度**:权限位到模块 + 动作级即止(伴生 §六「不做按端点细粒度权限」原样继承);不做密钥级资源白名单(伴生 D3 / §六) +> - **文档知识库的文件级权限模型**:现状无该模型——门面对其按库级过滤(§4 决议-3);新建该权限模型不在本 Feature +> - **应用数据表结构变更(DDL)经 MCP 工具**:表结构由 `bisheng-app.yaml` 声明、平台建表、变更走发布管线(F055);本工具只做表清单 / 结构读取与行级读写(§4 决议-8) +> - **MCP 调用限流、token 用量上限、按 key 聚合账单**(PRD-1 §5.2 顺延) +> - **平台内既有对话检索链的 fail-closed 取向翻转**:门面的严格 fail-closed 只作用于门面调用面(§4 决议-9) + +--- + +## 1. 用户故事 + +作为 **开发者的本地 coding agent**(本域主用户,PRD-1 §3.1「操作者是 agent,不是人」), +我希望 **拿到一个 MCP 地址和一把密钥就能列出、调用平台的知识库检索 / 清单、模型清单、组织查询、应用数据与状态日志工具,被拒时得到能自行判断下一步的结构化错误**, +以便 **在本地对话里独立完成「查能力声明该填哪些库 → 写代码 → deploy → 读审批结果与日志 → 修 → 再发」的闭环,不需要开发者中途接管**。 + +作为 **专业开发者**, +我希望 **本地经 MCP 检索到的知识库内容与我这把 key 的服务账号在平台里被授予的范围完全一致——不多不少,也与我用同一把 key 调 v2 检索接口拿到的一样**, +以便 **开发期据此写代码不会「本地能跑、线上越权」,最坏只是「本地看得少」**。 + +作为 **租户管理员**, +我希望 **开发者能力的边界只由我在签发表单勾的权限位与在服务账号详情页授的资源决定;`identity:read` 这种高危位一旦给出,我清楚它意味着本租户组织架构全量可读、无法收窄;开发者的 key 只能碰他自己 deploy 的应用**, +以便 **一把 key 泄漏的爆炸半径可预估、可一键吊销收口**。 + +作为 **安全与合规负责人**, +我希望 **开放面上的一切检索走同一条文件级过滤路径且 fail-closed——旧的「只查到知识库一级」的检索路径不再被任何调用方使用;委托专用的 key 在 MCP 面直接被拒、绝不静默变成以服务账号身份跑**, +以便 **模式 D「不多不少」的承诺与托管应用「声明白名单 ∩ 访问用户可见范围」的承诺有同一个可审计的实现基础**。 + +作为 **托管应用的普通使用者**(间接用户,经 F055 能力总线到达门面), +我希望 **应用里检索到的内容不超过我自己在平台里能看到的范围,也不超过应用声明接入的范围**, +以便 **用同事造的应用不会看到不该看的东西,也不会因为应用作者权限大而放大给我**。 + +--- + +## 2. 验收标准 + +> AC 编号在本特性内唯一。括号内标注对应 PRD-1 条目与验收序号(如 `↔DEV-02 验收 8`)或伴生 PRD 章节 / AC(如 `↔伴生 §4.4.6`);与姊妹 Feature 存在调用关系的条目注明「F0xx 调用 / 承接 / 消费」。错误码仅作可观测行为引用,定义在 design.md。 + +### 2.1 MCP Server 接入与鉴权 + +- **AC-01** — WHERE 开放能力层已部署, THE SYSTEM SHALL 对外提供一个遵循标准 MCP 协议、经网络传输可达的 MCP Server;任何标准 MCP 客户端(本地 coding agent)以「地址 + 服务账号密钥」配置即可接入并列出工具,平台不提供、也不要求任何本地端专有插件。(↔DEV-02 需求描述 / §5.1;DEV-01 ⑤) +- **AC-02** — WHEN MCP 客户端建立连接或调用任一工具, THE SYSTEM SHALL 要求携带有效服务账号密钥(`bs-sak-`,与开放 API 同一鉴权语义);缺失 / 无效 / 已撤销 / 已过期 → 拒绝连接或调用、不列出工具、不执行任何工具逻辑;**不存在无凭据回落路径**(INV-27)。密钥经请求头 / 传输层凭据传递,不接受经查询参数传递。(↔铁律 1;DEV-01 验收 2) +- **AC-03** — THE SYSTEM SHALL 使 MCP 面一切工具以**密钥主体自身**的身份执行(模式 S ·「自身身份」):可见范围 = 密钥权限位 ∧ 该主体被显式授予的资源;**不继承任何其他主体的权限**(服务账号不继承其资源归属人,也不继承任何自然人);两层任一不满足即拒绝,且两种失败可区分(缺权限位 vs 无资源授权)。(↔DEV-01 ① / 验收 6;伴生 §4.4.1;NFR-1.1) +- **AC-04** — WHEN MCP 客户端请求工具清单, THE SYSTEM SHALL 只列出该密钥持有权限位所覆盖的工具(按 DEV-01 ① 映射:`knowledge:read` → 检索 + 清单;`model:invoke` → 模型清单;`identity:read` → 身份 / 组织;`app:manage` → 应用数据 + 应用状态 / 日志);未勾选任何权限位的密钥可完成 MCP 连接握手(握手不属能力)、工具清单为空、任何工具调用被拒——与 DEV-01 验收 5「仅可 `login` / `skills sync`」一致;IF 客户端直接调用一个该密钥未持位的工具, THEN THE SYSTEM SHALL 返回**指明缺少哪一位**的拒绝,而非泛化「未知工具」。(↔DEV-02 需求描述「按密钥权限位过滤」/ 验收 1;DEV-01 验收 5;§4 决议-6) +- **AC-05** — WHEN 密钥被撤销 / 到期、或其服务账号被停用 / 删除, THE SYSTEM SHALL 使 MCP 面自下一次工具调用起拒绝(生效上界 5 秒,INV-28);已建立的 MCP 会话不得继续调用工具。(↔DEV-01 ④ / 验收 2 / 7) +- **AC-06** — WHEN 租户管理员编辑密钥权限位, THE SYSTEM SHALL 使 MCP 面自下一次工具清单请求或工具调用起按新权限位判定,无需重连、无需重签。(↔DEV-01 ② / 验收 5) +- **AC-07** — WHEN 任一 MCP 工具被调用, THE SYSTEM SHALL 使该调用可在审计中归属到密钥(掩码)与其服务账号(含资源归属人):工具名、目标对象摘要(知识库 / 应用标识等)、结果(成功 / 拒绝类别)、时间;不记录密钥明文、不记录检索片段正文(可能含知识库内容)。(↔DEV-01 验收 1;GOV-04;§4 决议-12) +- **AC-08** — THE SYSTEM SHALL 使 MCP 面的连接与工具调用不创建、不读取、不写入任何平台会话——不产生会话记录、不出现在任何人(含资源归属人)的会话列表;MCP 会话与平台会话解耦。(↔伴生 §4.5 定义 6 边界 a 同向) +- **AC-09** — WHEN 工具调用因凭据无效、缺权限位、无资源授权 / 不可及、非 owner、权限评估失败等任一原因被拒, THE SYSTEM SHALL 返回结构化错误,含**类别 + 原因 + 下一步指引**三要素(下一步指引如:找管理员加权限位 / 申请资源授权 / 换一把 key / 稍后重试);不得以泛化「未知工具」「内部错误」掩盖。(↔伴生 §4.4.1「两种失败可区分」;DEV-02 ④「对本地 agent 可诊断」) + +### 2.2 六类工具 + +- **AC-10** — WHEN 持 `knowledge:read` 的密钥调用「知识库检索」工具, THE SYSTEM SHALL 接受查询文本(及可选的目标知识库范围与返回条数上限),经统一检索门面(§2.3)以该服务账号为执行身份执行文件级过滤检索,返回含知识库 / 文件标识与片段正文、足以引用溯源的结果,结果 ⊆ 该服务账号被显式授予的知识库范围(入参出参字段清单见 design §6)。(↔DEV-02 六类工具表 / ① / 验收 2;伴生 B.2) +- **AC-11** — IF 检索工具显式指定的目标知识库中任一**不可及**(不存在 / 未授予 / 类型不支持三种情形一律归为「不可及」), THEN THE SYSTEM SHALL 以「不可及」拒绝**整个请求**并指出哪些标识不可及,不静默剔除后返回缩水结果;三种情形给**同一响应**(存在性不泄露,检索面对库只有「可及 / 不可及」两态);未指定目标 → 在全部被授予范围内检索。(↔伴生 §7.2 同标准;§4 决议-7) +- **AC-12** — WHEN 持 `knowledge:read` 的密钥调用「知识库清单查询」工具, THE SYSTEM SHALL 返回含标识与名称的清单,其集合 = **该服务账号被显式授予且为门面支持类型的知识库集合**(取证基准 = 主体侧授权页〔F049 §2.7〕列出的知识库授权集合 ∩ 门面支持类型〔AC-27〕);未授予或不支持类型的知识库**不出现**(不是出现但标记不可用);清单结果可直接用于能力声明填写。(↔DEV-02 六类工具表;DEV-01 验收 6;伴生 B.2;F055 预检消费同一可及性判定) +- **AC-13** — WHEN 持 `model:invoke` 的密钥调用「模型清单查询」工具, THE SYSTEM SHALL 返回本租户能力总线已启用的可用模型清单,每项含模型管理页原名、模型类型、以及**可直接用于模型协议面调用的唯一名称**(按 F051 名称解析规则:同名歧义时给出带服务商限定的名称,并标注是否为对话类模型)——清单里的名称在 F051 模型面调用必可解析(三处同源:本工具 / F051 解析 / F055 预检);范围为租户级配置、非 per-user;模型调用本身不经 MCP(F051)。(↔DEV-02 六类工具表;伴生 B.2;GOV-05 模型项;§4 决议-4) +- **AC-14** — WHEN 持 `identity:read` 的密钥调用「用户身份 / 组织查询」工具, THE SYSTEM SHALL 提供按用户标识点查身份与组织架构查询(部门树与成员);返回内容限于身份与组织归属信息、不含任何凭据类信息(字段清单见 design §6);结果只含自然人——**服务账号不出现**。(↔DEV-02 六类工具表 / ②;伴生 B.2;INV-29 同向;§4 决议-11) +- **AC-15** — WHEN 持 `app:manage` 的密钥调用「应用数据表结构与数据」工具, THE SYSTEM SHALL 对该密钥资源归属人 owner 的应用(AC-34)提供其每应用数据库的**表清单 / 表结构读取与行数据的读写**(写 = 行级增删改,**不含 DDL**——表结构由 `bisheng-app.yaml` 声明、平台建表、变更走 F055 发布管线);读与写均只对 owner 应用放行;**每次写操作计审计**(GOV-04「生产数据行编辑」事件,写入方 = 服务端能力所在的 F054);分页 / 条数上限由平台设定。服务端接口由 F054 AC-56 / AC-45 提供(与应用详情页数据 tab 共用),本 Feature 只做接线与 owner 判定。(↔DEV-02 六类工具表「操作应用自有数据」;伴生 §4.2.4 `app:manage`「读写」/ B.2;GOV-04;§4 决议-8) +- **AC-16** — WHEN 持 `app:manage` 的密钥调用「应用状态 / 日志自查」工具, THE SYSTEM SHALL 对该密钥资源归属人 owner 的应用返回:应用运行状态(F054 只读接口);最近一次发布的审批状态含**驳回理由全文**与待上线成因(F055 审批状态只读接口,与发布面一致);运行日志(可按时间范围 / 最近若干条)——内容范围与应用详情页运行日志(F054 WB-13)、CLI `logs`(F053)**完全一致**:应用运行输出与错误信息、不含平台侧日志、不出现密钥值(返回字段清单见 design §6);非本人 owner 的应用按 AC-34 拒绝。(↔DEV-02 六类工具表 / ③ / 验收 7;DEV-04 日志三入口表 / 验收 12;F054 AC-23 / AC-55;F055 AC-38;F053 AC-41) +- **AC-17** — WHERE 工场运行时层未部署, THE SYSTEM SHALL 使两类应用工具不出现在工具清单,直接调用得到「本环境未启用应用工场」类明确错误;其余四类工具不受影响。(↔GOV-10 三层形态;F054 AC-30 同向) +- **AC-18** — THE SYSTEM SHALL 使六类工具与平台内同源:同一服务账号经 MCP 与经平台既有能力(清单 / 检索 / 模型 / 组织 / 应用状态)看到的结果一致;MCP 面**不新增数据面**——无 MCP 专属的数据副本、旁路查询或独立权限判定。取证基准:清单 = 主体侧授权页集合 ∩ 支持类型(AC-12);检索 = 同一执行身份在平台内检索的集合(AC-40 / 41);模型清单 = 模型管理页已启用清单;组织 = 平台组织管理视图;应用状态 / 日志 = 应用详情页发布 tab / 运行日志 tab。(↔DEV-02 一句话「同一工具面、内外同源」/ 六类工具表「不新增数据面」) + +### 2.3 统一检索门面 + +> 能力收回信号(白名单内库已删除 / 应用侧授权已收回 → 带库标识的「能力已收回」可区分错误)见末尾追加的 **AC-46**(审查修订,编号稳定故不插入本节)。 + +- **AC-19** — THE SYSTEM SHALL 提供一个**会话解耦**的统一检索门面:入参 = 执行身份(服务账号,或自然人访问用户)+ 查询 + 可选声明白名单 + 检索参数(平台设上限);出参 = 经过滤的、含知识库与文件标识、可引用溯源的片段结果(字段清单见 design §6);不创建平台会话、不写消息记录。四处消费:MCP 检索工具(AC-10)、v2 `POST /filelib/retrieve`(AC-25)、F055 托管运行期检索(F055 AC-50 调用)、F057 SDK retrieve(F057 承接);F050 模式 D 以被代表用户为执行身份调用它(AC-43)。(↔DEV-02 ①;GOV-05;伴生 §4.4.6;Discovery §2.3 / N3;§4 决议-2) +- **AC-20** — THE SYSTEM SHALL 使门面对每次检索执行**文件级过滤,且过滤发生在结果返回前**(不存在先返回后过滤、或只过滤一部分的路径);对知识空间按文件级有效可见性过滤——覆盖单文件直接授权、文件切自定义模式后与所在资源脱钩、不同文件夹授权不同、系统标记与跨租户共享四类来源;对文档知识库按库级可读性过滤(现权限模型无文件级,§4 决议-3)。(↔伴生 §4.4.6 / 附录 B.1;DEV-02 ①「过滤强度必须是文件级」;GOV-05 过滤强度) +- **AC-21** — WHEN 门面收到声明白名单, THE SYSTEM SHALL 把检索范围限定为**白名单 ∩ 执行身份可见范围**:白名单外的知识库整体不可及——即使执行身份本人有权限、即使执行身份是租户管理员(身份短路不越过白名单);白名单内、执行身份无权的库或文件不出现在结果中;声明范围内的结果与该执行身份在平台内自行检索(限定同一范围)**集合相等**。(**INV-36 承接**;↔GOV-05 两层过滤 / 验收 5 / 6;F055 AC-50 / AC-51 调用) +- **AC-22** — WHEN 门面未收到声明白名单(MCP 面、v2 检索、本地 dev 期), THE SYSTEM SHALL 在执行身份的全部可见范围内检索,或在调用方显式指定且全部可及的子集内检索(任一不可及 → 按 AC-11 拒绝整个请求)。(↔DEV-05 ③;NFR-1.1) +- **AC-23** — IF 执行身份缺失或无法确立, THEN THE SYSTEM SHALL 拒绝检索,绝不回退为按白名单全量放行、按任何固定 / 配置身份放行、或返回「公开子集」。(↔GOV-05 fail-closed;NFR-1.4;铁律 2 同向;INV-30) +- **AC-24** — IF 权限引擎不可用、评估失败或结果不可判定(含任一目标库 / 文件的可见性无法判定), THEN THE SYSTEM SHALL 返回带错误码的明确错误、不返回任何结果——**不得返回未过滤或部分过滤的结果集,也不得静默缩成空集或子集后当作成功响应**;调用方(MCP 工具 / v2 端点 / F055 / F057)原样向其调用者暴露该错误。(↔INV-30;伴生 铁律 3 / AC-13;DEV-02 验收 9;GOV-05 验收 7;§4 决议-9) +- **AC-25** — WHEN v2 `POST /filelib/retrieve` 被调用(F049 已加鉴权), THE SYSTEM SHALL 使其经同一门面执行(执行身份 = 密钥主体;F050 后模式 D 为被代表用户),过滤强度由「知识资源级一次校验」升级为文件级过滤 + fail-closed;对外请求 / 响应契约保持兼容——除结果集因过滤收窄外无变化;代码注释与对外接口文档(若有)中「已有 per-user 文件级过滤」的失实表述订正为与实现一致(伴生 B.1 已指出该表述与实现不符)。(↔伴生 §4.4.6 / 附录 B.1 / B.1 表 `POST /filelib/retrieve` 行;F049 排除项「随 F052 收敛」) +- **AC-26** — THE SYSTEM SHALL 使门面成为开放面上**唯一**的检索路径:MCP 检索工具、v2 检索端点、托管运行期检索、SDK retrieve 之外不存在绕过门面的路径;仅做知识资源级一次校验的旧检索路径在开放面上不再被任何调用方使用。(↔DEV-02 ①「不得复用旧路径」;伴生 §4.4.6「补齐后收敛到同一条路径」) +- **AC-27** — THE SYSTEM SHALL 使本版门面支持的知识库类型 = 文档知识库 + 知识空间;检索请求(显式指定或白名单内)中出现不支持类型的库(如 QA 库、个人库)时按 AC-11「不可及」处理,**不单列「类型不支持」**(存在性不泄露);「类型不支持」的可区分信号只出现在两处:知识库清单工具不列出该类库(AC-12)、F055 预检拒绝含该类库的能力声明(F055 AC-07 消费同一「门面支持类型」判定)。(↔Discovery §2.3 待澄清;§4 决议-10) + +### 2.4 `delegate` 入口拒绝(INV-31 · MCP 面) + +- **AC-28** — IF 密钥持有 `delegate` 位, THEN THE SYSTEM SHALL 在 MCP 面**通道入口**(连接建立 / 首次能力协商)按权限位直接拒绝,错误明确指向「这把密钥配置为委托专用,本地开发请另发一把」;不列出任何工具、不执行任何工具。(↔伴生 AC-49 / §4.2.4;DEV-02 ④ / 验收 10;INV-31) +- **AC-29** — THE SYSTEM SHALL NOT 使持 `delegate` 的密钥在 MCP 面表现为按服务账号身份正常执行(静默落回模式 S),也不得只返回「缺少身份头」类参数错误码。(↔伴生 AC-49 / AC-38 同向;DEV-02 ④ / 验收 10;INV-31) +- **AC-30** — IF MCP 面收到任何身份传递类请求头(代表平台用户 / 外部用户标识), THEN THE SYSTEM SHALL 拒绝并提示「MCP 面不承载委托」——不静默忽略后按服务账号身份继续执行。(↔F049 AC-33 同向;DEV-02 ④;GOV-05 信任机制分界) + +### 2.5 `identity:read` 组织查询边界 + +- **AC-31** — WHEN 持 `identity:read` 的密钥查询组织架构, THE SYSTEM SHALL 返回本租户**全量**组织架构(部门树、部门成员、用户身份);THE SYSTEM SHALL NOT 提供任何把可读范围收窄到部分部门的机制或配置——边界由「给不给这个位」承担。(↔DEV-02 ② / DEV-01 ③;伴生 §4.2.4 扩展位表) +- **AC-32** — THE SYSTEM SHALL 使跨租户的部门与用户一律读不到;按用户标识点查其它租户的用户时与「不存在」给同一响应。(↔DEV-02 ② / 验收 5「真实边界是租户隔离」) +- **AC-33** — IF 密钥未持 `identity:read`, THEN THE SYSTEM SHALL 拒绝身份 / 组织查询并指明缺位;本地 `dev` 期应用查身份 / 组织同按本位判定(`dev` 注入的服务账号自身身份不消费本位——F053 决议-9)。(↔DEV-02 验收 5;DEV-01 ①) + +### 2.6 应用类工具 owner 收窄 + +- **AC-34** — WHEN 应用数据 / 应用状态 / 日志工具被调用, THE SYSTEM SHALL 只对**该密钥所属服务账号的资源归属人为 owner** 的应用放行;对其它应用(含不存在的)一律拒绝、不返回内容,提示形状为「非你名下应用」类、不带 owner 名——不透露不可见应用的存在性与 owner。三入口(MCP 工具 / CLI `logs` / `deploy` 服务端)**权限判定一致** = 都按密钥归属人 owner 判;提示形状允许不同(`deploy` 服务端按 PRD-1 DEV-04 验收 9「提示应用归属」,F055 AC-04)。(↔DEV-02 ③ / 验收 3;DEV-01 ③ 落地口径 ②;DEV-04 验收 9 / 12;F055 AC-04 同判定;§4 决议-5) +- **AC-35** — THE SYSTEM SHALL 使 AC-34 对**租户管理员名下服务账号的密钥同样成立**(管理员查他人应用走应用详情页,不走开发者 key 通道);该判定为业务规则前置拦截,不受权限运行时的管理员短路影响。(↔DEV-02 ③ 例外 / 验收 6;F054 §3「仅 owner 限制为前置拦截」) +- **AC-36** — WHEN 服务账号的资源归属人被更换, THE SYSTEM SHALL 使应用类工具自此按新归属人 owner 的应用放行,原归属人 owner 的应用不再可及(应用 owner 不随之变更——F049 AC-27 定义 6 边界 d;F054 AC-07)。(↔DEV-01 ③) + +### 2.7 开放能力层开关(F049 拥有,本 Feature 消费) + +- **AC-37** — WHERE 开放能力层未部署, THE SYSTEM SHALL 使 MCP Server 不可达(地址不服务、不出现在任何接入信息中)、无新增常驻负担;服务账号模块(F049 AC-49)与 v2 开放 API 不受影响;**统一检索门面不受该开关影响**(v2 `POST /filelib/retrieve` 恒经门面执行)。(↔GOV-10 规则 / 验收 4;NFR-4) +- **AC-38** — WHERE 仅部署开放能力层、未部署工场运行时层, THE SYSTEM SHALL 使四类非应用工具(检索 / 清单 / 模型清单 / 身份组织)正常可用,两类应用工具按 AC-17 表现。(↔GOV-10 验收 3 / 三层形态表) +- **AC-39** — WHERE 开放能力层已部署, THE SYSTEM SHALL 提供 MCP Server 的接入地址供 F053 接入信息区展示与一键复制(F053 AC-44 / F053 AC-46 消费);地址不含密钥或任何可换取密钥的凭据。(↔DEV-01 ⑤ / 验收 9) + +### 2.8 集合相等与 fail-closed 验收 + +- **AC-40** — THE SYSTEM SHALL 使经 MCP 检索的结果与对该服务账号授予范围的预期集合**相等**——不多给也不少给;验收样本须含:未授予知识库、同一知识空间内文件级权限差异(部分文件无权)、文档知识库(库级)各至少一例。(↔DEV-02 验收 8;DEV-01 验收 6) +- **AC-41** — THE SYSTEM SHALL 使同一把持 `knowledge:read` 的密钥经 v2 `POST /filelib/retrieve` 与经 MCP 检索工具、在同一查询与范围下得到**相同的可见集合**。(↔DEV-01 ① / 验收 6) +- **AC-42** — WHEN F055 托管运行期以当前访问用户为执行身份、能力声明为白名单调用门面, THE SYSTEM SHALL 使声明范围内的检索结果与该用户在平台内自行检索(限定同一范围)的集合相等(含库内无权文件不出现)——F055 AC-50 调用本门面并承接其运行期验收。(↔GOV-05 验收 6;INV-36) +- **AC-43** — WHEN 以某被代表用户为执行身份调用门面, THE SYSTEM SHALL 使结果与该用户在平台内自行检索的集合相等(纯替换、无意外收窄、无超集)——能力由本 Feature 交付,模式 D 的准入与验收由 F050 承接。(↔伴生 AC-12 / §4.4.6;Discovery N3) +- **AC-44** — WHEN 人为使权限引擎不可用, THE SYSTEM SHALL 使 MCP 检索工具、v2 `POST /filelib/retrieve`、托管运行期检索三处均返回明确错误、零结果,而非未过滤或部分过滤的结果。(↔DEV-02 验收 9;伴生 AC-13;GOV-05 验收 7;NFR-1.4) +- **AC-45** — THE SYSTEM SHALL 使未持对应权限位的工具调用被逐位拒绝——按 DEV-01 ① 映射表逐位验证:仅持 `knowledge:read` 不能调模型清单 / 身份组织 / 应用工具;仅持 `model:invoke` 不能检索;仅持 `app:manage` 不能查组织;每种拒绝均指明缺位(AC-04)。(↔DEV-02 验收 1;DEV-01 验收 5) + +### 2.9 追加(2026-08-17 审查修订,编号末尾追加) + +- **AC-46** — WHEN 门面收到声明白名单、且白名单内某知识库已被删除或其应用侧授权已收回, THE SYSTEM SHALL 返回**带该库标识的「能力已收回」可区分错误**、不返回部分结果、不静默剔除该库后继续检索(供 F055 AC-53 转成应用可见的带能力名「已收回」错误);WHEN MCP 检索工具显式指定该库(无白名单语义), THE SYSTEM SHALL 仍按 AC-11「不可及」处理。(**INV-36 承接**;↔GOV-05 能力收回后行为 / 验收 4;F055 AC-53 消费;属 §2.3) +- **AC-47** — THE SYSTEM SHALL 使 MCP 面的错误响应与任何工具返回体**不回显密钥值**(掩码之外的任何片段一律不出现);日志与审计同 AC-07。(↔NFR-2 密钥零暴露;属 §2.1) + +--- + +## 3. 边界情况 + +> 只写期望的对外行为;机制与落点见 design.md §4 / §5。 + +- **门面的执行身份只有两种,白名单可选**:MCP 面与本地 dev 期 = 服务账号(无白名单,范围 = 被显式授予的资源);托管运行期 = 当前访问用户(白名单 = 能力声明);模式 D = 被代表用户(无白名单);审批期预览 = F055 以 owner 为执行身份调用(INV-36 唯一例外,由 F055 承接与计审计,门面本身不感知「例外」)。门面对任何执行身份做同一强度的过滤——它不知道也不关心调用方是谁,只认执行身份 + 白名单。 +- **「与工作台检索集合相等」的基准锚点**:验收基准 = 该执行身份在平台工作台经**完整文件级过滤**的检索路径得到的集合;平台内若存在过滤强度不同的检索变体,以完整文件级过滤路径为准(哪条是完整路径由 design 核实指定),变体间在边界样本上的差异不构成对门面的验收豁免。 +- **文档知识库 vs 知识空间**:知识空间存在四类文件级可见性差异来源、门面按文件级;文档知识库现权限模型只有库级、门面按库级(§4 决议-3)。「集合相等」在两种类型上分别按各自粒度成立;将来文档知识库引入文件级权限,门面按平台既有可见性判定自动跟随、不自造模型。 +- **不支持的知识库类型**:QA 库、个人库等本版不可经门面检索——检索请求中显式指定或白名单内出现,整请求按「不可及」拒绝(不单列「类型不支持」,AC-27);可区分的「类型不支持」信号只在清单工具(不列出)与 F055 预检(拒绝能力声明)两处。 +- **权限判定的高频放大**:MCP / 托管运行期检索是高频调用,逐目标的权限判定会被放大(现状与量化在 design)。对外行为口径:**门面结果正确性优先**——任何缓存或批量优化不得破坏撤销 5 秒上界(INV-28)与 fail-closed(INV-30);性能指标与手段在 design。 +- **fail-closed 与「静默缩集」的区别**:门面调用面在权限评估失败时必须给出**明确错误**(AC-24),而非静默缩成空集或子集后当作成功;这一严格取向只作用于门面及其四个调用方,不翻转平台内对话检索链的既有取向(§4 决议-9,前提待 design 核实)。 +- **管理员短路与门面**:MCP 面执行身份恒为服务账号,服务账号不得持有管理员身份(F049 AC-22)→ MCP 检索永不命中管理员短路,key 泄漏不等于全租户知识外泄;托管运行期访问用户可能是租户管理员——其身份短路只作用于「可见范围」层,**白名单收窄仍生效**(AC-21)。 +- **MCP 会话与平台会话解耦**:MCP 连接是协议会话、不是业务会话;不建会话记录、不进任何人的会话列表;同一密钥可并发多个 MCP 客户端连接,互不可见。 +- **工具错误对本地 agent 必须可诊断**:agent 没有界面、没有人替它看日志——每种拒绝必须自带「类别 + 原因 + 下一步指引」三要素(AC-09);缺权限位与无资源授权两种失败必须可区分(AC-03,伴生 §4.4.1);「类型不支持」在检索面不单列、归入「不可及」(AC-11 / AC-27)。`26016` 类参数错误对 agent 是天书——`delegate` 拒绝必须按位在入口给出可读指引(AC-28)。 +- **存在性不泄露**:不可及知识库(不存在 / 未授予 / 类型不支持)、非本人 owner 的应用(含不存在的)、跨租户用户(含不存在的)三处均与「不存在」同一响应;owner 收窄的提示只说「非你名下应用」、不带 owner 名,不透露不可见应用的存在性与 owner。 +- **`identity:read` 的两个方向**:给了 = 本租户全量组织架构可读、无法收窄(AC-31);没给 = 连点查都拒(AC-33)。「读自己」(`dev` 注入的服务账号自身身份)不经本位。结果永不含服务账号——它们刻意不属于任何部门(伴生 D10)。 +- **应用类工具依赖工场运行时层**:未部署 → 不出现在清单、直接调用得到「未启用」类错误(AC-17);其服务端能力归 F054 / F055,本 Feature 的工具面接线与 owner 判定不阻塞其余四类工具的交付。 +- **未部署开放能力层**:MCP 地址不服务、接入信息区不出现(F053)、三扩展位不出现(F049);但门面恒在——v2 检索端点从 F049 期的「库级一次校验 + fail-closed」在本 Feature 上线后收敛到门面(AC-25),与开关无关。 +- **密钥传递方式与 F049 一致**:请求头 / 传输层凭据,不走查询参数(会进访问日志)。 +- **检索参数有上限**:条数 / 正文长度等由平台设上限,调用方超出即按上限截断并在响应中可见(不静默);上限取值在 design。 +- **审计不记检索正文、响应不回显密钥**:MCP 调用审计记工具名 / 目标摘要 / 结果 / 时间,不记片段正文(可能含知识库内容);错误响应与工具返回体不出现密钥值(AC-47);应用数据工具的每次写操作计审计(AC-15);高频检索的审计存储分层是 design 事项(F056 高频事件分层同向)。 + +--- + +## 4. 待澄清决议 + +> 2026-08-17 用户授权全自动模式:以下各项按建议直接定案,记录理由与「何时重新考虑」供追溯。 + +- **[决议-1] MCP Server 部署形态:不引入新的常驻进程与端口,MCP Server 随平台后端 API 进程一同提供、地址位于平台 API 同源路径之下,受开放能力层开关条件启用。** 理由:GOV-10 / NFR-4「不装的环境无新增常驻负担」——同进程条件启用天然满足「不装即零成本」;六类工具全部是对既有领域能力的「薄封装 + 鉴权」(Discovery §2.9),复用 F049 鉴权依赖与租户上下文注入最直接;不新增部署单元、端口、nginx location,商业版 Java 网关只放行 API 前缀——同源路径可零改造穿透。备选 = 独立进程 / 端口(「整层可不装」在部署上更干净,但每个环境多一个进程与端口、且鉴权与租户注入要跨进程重做)。**何时重新考虑**:MCP 长连接在 nginx / 网关双层代理下出现兼容问题、MCP 流量成为 API 进程的负载源、或需要独立伸缩时——抽为独立进程,工具面契约不变。传输形态(单一 streamable 还是兼容 SSE)在 design 定。 +- **[决议-2] 统一检索门面 owner = F052**(Discovery N3 已定,此处落 spec)。F050(模式 D 集合相等)、F055(托管运行期「白名单 ∩ 访问用户」)、F057(SDK retrieve)只调用不拥有;批次 A 顺序 F049 → F052 → F051 → F053 → F050。理由:三处消费方对过滤强度与 fail-closed 的要求完全相同,门面若分散在各消费方会造出三条强度不一的路径——正是伴生 B.1 已核实的分叉再现;MCP 检索是四个调用方中最早需要它、且验收最完整(DEV-02 验收 8 / 9)的一个。 +- **[决议-3] 文档知识库按库级过滤,知识空间按文件级过滤。** 理由:文档知识库现权限模型无文件级可见性差异(无单文件授权 / 文件夹 / 自定义模式脱钩),「含文件级权限」在它身上无对象;本 Feature 不新建权限模型(那是权限体系级改动,与检索门面正交);「与平台内自行检索集合相等」在文档知识库上按库级即成立。**何时重新考虑**:文档知识库引入文件级权限时——门面按平台既有可见性判定,无需改门面语义,只需验收样本补例。 +- **[决议-4] 模型清单查询归本 Feature,模型调用归 F051。** 理由:MCP 工具面的鉴权、工具注册、按位过滤、审计归属是一套,模型清单只是其中一个只读工具;「可用模型范围 = 租户能力总线已启用模型」的口径由 GOV-05 / F051 定义,本 Feature 只读该口径。两者的位相同(`model:invoke`),但数据面不同(清单是元信息、调用是裸协议透传)。 +- **[决议-5] 应用类工具的 owner 判定基准 = 密钥所属服务账号的资源归属人(调用期取当前值)与应用 owner 字段(F054 AC-07)相等。** 理由:DEV-01 ③ 落地口径 ②「只能操作自己的应用以它判定」;调用期取当前值使归属人更换后新归属人的应用即时可及、旧的即时不可及(AC-36),与 F049 定义 6 边界 d「归属人变更不追溯已有资源」一致(应用 owner 不变,变的是 key 这头)。 +- **[决议-6] 工具清单按密钥权限位过滤,且直接调用未持位工具返回「指明缺位」的拒绝而非「未知工具」。** 理由:agent 面对未过滤清单会反复尝试无权工具、每次撞墙;只过滤清单不校验调用则清单成了绕过点;两者都做,且拒绝可诊断(AC-04 / AC-09)。备选 = 清单全列、调用时拒绝——被否决,理由同上。 +- **[决议-7] 检索工具显式指定的目标知识库中任一不可及 → 拒绝整个请求(指出不可及标识),不静默剔除后返回缩水结果;「不存在」「未授予」「类型不支持」同一「不可及」响应。** 理由:伴生 §7.2 立的标准——不接受「返回 200 + 静默缩水的结果集、调用方无从察觉」;agent 指定了库就是有意图,少给等于静默错误;同一响应防探测存在性(检索面对库只有可及 / 不可及两态)。未指定目标 → 全部被授予范围(AC-22)。 +- **[决议-8] 应用数据工具 = 表清单 / 表结构读取 + 行数据读写(行级增删改),不含 DDL;写限归属人 owner 的应用、每次计审计。**(审查修订:原「只读」被否决)理由:伴生 §4.2.4 是权限位 SSOT——`app:manage` 明写「应用数据表结构与数据**读写**」;PRD-1 DEV-02 六类工具表用途栏写「开发与排障时**操作**应用自有数据」,开发期 agent 造测试数据 / 修脏数据是真实诉求;护栏 = owner 收窄(AC-34)+ 每次写计审计(GOV-04「生产数据行编辑」)+ 不做 DDL(表结构由 `bisheng-app.yaml` 声明、平台建表、改删列走 F055 发布管线的显式确认 + 迁移前快照);服务端接口与应用详情页数据 tab 共用(F054 AC-56),本 Feature 不另造写路径。**何时重新考虑**:出现经工具误写生产数据的实际事故时,评估对写操作加二次确认类交互(MCP 面无人在场,形态待定)。 +- **[决议-9] 门面的严格 fail-closed(评估失败 → 明确错误)只作用于门面调用面(开放面四处),不翻转平台内既有对话检索链的取向。** 前提**待 design 核实**:F048 基线上权限检查已为「失败即拒」(Discovery §2.2),平台内检索链是否仍残留「静默缩集」取向、残留在哪一层,由 design 探查阶段核实——本决议只定「门面调用面必须是明确错误」与「本 Feature 不改平台内检索链的既有行为」两条,不预设现状。理由:INV-30 的适用对象是开放面;即便平台内仍有降级取向,全局翻转的影响面也远超本 Feature。**何时重新考虑**:产品决定平台内检索也改明确错误态时,由门面之外的独立 Feature 承接。 +- **[决议-10] 门面本版支持的知识库类型 = 文档知识库 + 知识空间;QA 库、个人库在检索请求中一律按「不可及」处理,可区分的「类型不支持」信号只出现在清单工具(不列出)与 F055 预检(拒绝能力声明)两处。**(审查修订:原「门面报『类型不支持』错误」被否决——检索面对库只有可及 / 不可及两态,单列类型会泄露存在性)理由:现开放面检索对这两类本就不支持;声明期由预检拦住、清单工具让 agent 从一开始就看不到它们,运行期兜底则统一为「不可及」。**何时重新考虑**:QA 库需要经开放面检索时(届时按其权限模型定粒度)。 +- **[决议-11] 组织查询结果不含服务账号;`dev` 注入的服务账号自身身份不消费 `identity:read`。** 理由:服务账号刻意不属于任何部门(伴生 D10),出现在组织树里既不真实又违背 INV-29「失败方向是看不到」的取向;`dev` 期「读自己」与「读全租户组织架构」风险量级不同——F053 决议-9 已裁定并回修 PRD-1 DEV-01 ① 表,本 spec 与之一致。 +- **[决议-12] MCP 面每次工具调用计审计归属(密钥掩码 + 服务账号 + 工具名 + 目标摘要 + 结果 + 时间),不记检索正文;高频检索的审计存储分层是 design 事项。** 理由:DEV-01 验收 1「key 的每次调用可在审计中归属到 key 与其服务账号」;GOV-04 要求密钥类事件全链路留痕;检索正文含知识库内容,记入审计等于把知识库内容复制进审计表。**何时重新考虑**:审计写放大成为瓶颈时,与 F056 高频事件分层一并处理(口径不变,只改存储)。 + +--- + +## 5. 设计与实现(指针,不复制) + +> **本节刻意不写内容。** spec 不承载 How。 + +| 你想知道 | 去哪看 | +|---|---| +| 为什么这么实现(决策 + 备选 + 何时该推翻):MCP 传输形态与同进程条件启用、门面的调用面隔离与 strict fail-closed 落点、白名单收窄与「能力已收回」判定的注入位置、既有过滤组件的复用、缓存与 5 秒上界的取舍 | design.md §3 | +| 关键约束(C1 分层——MCP handler 只经领域服务与权限入口 / C3 非 HTTP 入口的租户上下文注入 / C4 权限统一入口 / 错误码段 260 与知识模块码的归属 / 开放能力层开关) | design.md §2 | +| 今天的数据流、模块职责、字段 / API / 文件名约定 | design.md §4 | +| 代码里看不出的坑(两条既有检索链强度不一致、旧 v2 检索端点注释与实现不符、`view_file` 是有效权限标识而非权限引擎关系名、权限判定无缓存每目标多次数据访问、admin 短路在批量列表路径同样适用、MCP 长连接经 nginx / 网关的兼容性、`McpConf.enable_stdio` 是客户端侧开关与本面无关) | design.md §5 | +| 对外契约:MCP 六类工具的**入参出参字段清单**(检索 / 清单 / 模型清单 / 身份组织 / 应用数据 / 应用状态日志——spec AC 只写对外行为,字段在此)、门面接口与结果条目字段、v2 检索端点兼容承诺、错误结构三要素的具体形状与「能力已收回」信号形态、与 F054 AC-56 每应用数据库接口的对接 | design.md §6 | +| 性能 / 安全 / 可观测非功能指标(检索时延、判定放大、审计分层) | design.md §2 + §7 | +| 任务拆解、文件清单、执行顺序、踩坑落档 | tasks.md | +| 基线代码核实速查(F048 判定链、两条检索链锚点、旧 v2 检索端点) | [../000-prd1-discovery/research/knowledge-retrieval.md](../000-prd1-discovery/research/knowledge-retrieval.md) · [../000-prd1-discovery/research/mcp-runtime-infra.md](../000-prd1-discovery/research/mcp-runtime-infra.md) · [../000-prd1-discovery/baseline-recheck.md](../000-prd1-discovery/baseline-recheck.md) | + +--- + +## 相关文档 + +- 设计真相: [design.md](./design.md)(接手第一入口,尚未编写) +- 执行与落档: [tasks.md](./tasks.md)(尚未编写) +- 版本契约: [features/v3.0.0/release-contract.md](../release-contract.md)(写 spec 前必须先阅读;INV-27 / 28 / 29 / 30 / 31 直接约束本 Feature;**候选 INV-36 随本 spec 定稿转正**,本 Feature 承接门面层、F055 承接注入与审批例外、F057 承接 retrieve;表 3 F052 行 = 本 spec 范围) +- Spec Discovery: [features/v3.0.0/000-prd1-discovery/discovery.md](../000-prd1-discovery/discovery.md) §2.3(检索过滤两条链、无白名单概念、无会话解耦门面、文档知识库 vs 知识空间粒度)/ §2.9(`mcp` SDK 已在依赖、零 MCP server)/ §4 N3(门面 owner)/ 次级决策「MCP Server 部署形态」(本 spec 决议-1 定案) +- 上游 / 姊妹 spec: [../049-openapi-auth-baseline/spec.md](../049-openapi-auth-baseline/spec.md)(凭据底座、服务账号与资源归属人、权限位清单、开放能力层开关;其排除项「知识库检索补文件级过滤随 F052」由本 spec 承接)· [../053-dev-cli-skills/spec.md](../053-dev-cli-skills/spec.md)(接入信息区消费 MCP 地址、`logs` 与 MCP 日志工具口径一致、`dev` 期经 MCP 调用)· [../054-app-domain-runtime/spec.md](../054-app-domain-runtime/spec.md)(实例状态 / 日志只读接口 AC-23、每应用数据库 AC-45 及其表清单 / 结构 / 行读写服务端接口 AC-56、WB-13 AC-55)· [../055-app-publish-pipeline/spec.md](../055-app-publish-pipeline/spec.md)(托管运行期检索 AC-50–52 调用本门面、能力收回 AC-53 消费 AC-46 信号、预检 AC-07 消费门面支持类型判定、审批状态只读接口 AC-38、`deploy` / `logs` 服务端权限判定 AC-04 同判定) +- PRD: `docs/PRD/3.0-release/3.0 应用工场 PRD-1 专业开发者通道与应用运行时.md` v2.0 DEV-02 / DEV-01 / GOV-05 / GOV-10 / NFR-1;`docs/PRD/3.0-beta2/3.0 开放 API 鉴权与身份传递 PRD.md` v2.1 §4.2.4 / §4.4 / 附录 B.1–B.2(伴生册,检索门面的需求真相);《3.0 应用工场 产品方案》§5.1 F105 diff --git a/features/v3.0.0/052-mcp-server-face/tasks.md b/features/v3.0.0/052-mcp-server-face/tasks.md new file mode 100644 index 0000000000..14dbab7f00 --- /dev/null +++ b/features/v3.0.0/052-mcp-server-face/tasks.md @@ -0,0 +1,494 @@ +# Tasks: MCP Server 工具面与统一检索门面(六类工具 + 文件级过滤门面 + `delegate` 入口拒绝) + +**关联规格**: [spec.md](./spec.md)(47 条 AC,What 的唯一真相)· [design.md](./design.md)(How 的唯一真相,D1–D12 / 坑 1–18 / §4.2 契约) +**版本**: v3.0.0 +**纵切**: 不在 [mvp-114-path.md](../mvp-114-path.md) §2 纵切上(表单问卷不需 MCP);批次 A 顺序 F049 → **F052** → F051 → F053 → F050 +**代码事实口径**: 本文所有 `文件:行号` 沿用 design.md(`3.0-vibe` HEAD `fe10f75ea`,2026-09-16 核实,并于同日 `/sdd-review design|tasks` 独立审查时逐条重 grep 订正;路径以 `src/backend/bisheng/` 为根,前端另注 `platform/` = `src/frontend/platform/src/`;`mcp` SDK 行号以**主检出**的 `/Users/lilu/Projects/bisheng/src/backend/.venv/lib/python3.11/site-packages/` 为准,worktree 里没有 `.venv`)。行号会漂移,符号名不会——落地前以符号名重定位。 + +--- + +## 状态 + +| 步骤 | 状态 | 备注 | +|------|------|------| +| spec.md | ✅ 已评审 | 2026-08-17 定稿(47 AC,独立审查 17 条已修订;决议 1–12);2026-08-28 AC-03 术语订正 | +| design.md | ✅ 已评审(全自动模式,★ 豁免) | 2026-09-16 初版 + 同日 `/sdd-review design` 独立审查就地修订(4 high / 6 medium / ~20 low,见 design 修订历史末行);D1–D12 / 坑 1–23;接手时的第一入口 | +| tasks.md | ✅ 已拆解(2026-09-16,`/sdd-review tasks` 已过) | 本文;三条线(共享基础 / 门面 / MCP 面)+ 合流波;审查订正见文末「审查修订记录」 | +| 实现 | 🚧 代码面已齐,余 CI 中间件与 114(2026-09-16 合流波收尾) | **31 / 34 完成**(T205 为编号占位不计)。Line A(T001–T003 / T101–T103 / T105)与 Line B(T201–T212)全部落地;Wave C 的 T301a/T301/T304 落地,**两线合并后 T301a 的 10 条已实跑不再 skip**。**余三项**:T104 与 T302 的「样本播种后的集合相等」部分、T303(114 部署与手动验证,步骤已备在文末「114 验证记录」)。**前两项的阻塞点已从「播种 helper 不存在」变成「没有能跑的环境」**——wave 5 已写出 `test/e2e/helpers/retrieval_sample.py`(四种权限来源 + 未授权空间 + 服务账号/自然人双主体)与 `test/e2e/test_e2e_f052_retrieval_set_equality.py`(6 例,`@pytest.mark.e2e` + `F052_E2E=1` 默认跳过),跑起来需要 MySQL + Redis + OpenFGA + MinIO + Milvus/ES **外加在跑的 knowledge Celery worker**,逐条前提写在 T104 下;**未跑过,故两项都不勾**。T211 已结案为**不做**(前提随「延迟 import + `available()` 门控」消失)。| + +--- + +## 开发模式 + +**三条线、文件不相交,可由两个 agent 并行**(design §4.3 模块表是文件归属的真相): +- **Wave 0 · 共享基础**(T001–T003):单一 owner 先落——错误码 263 段 + 三语、v2 handler 状态映射、门面契约 schema 文件。**两条线都依赖它,且它是两条线唯一共同触碰的文件集**;落完再分叉。 +- **Line A · 统一检索门面**(T101–T105,owner「facade agent」):只碰 `knowledge/domain/services/{retrieval_engine,retrieval_facade_service,knowledge_space_chat_service}.py`、`open_endpoints/api/endpoints/filelib.py`、`open_endpoints/domain/schemas/filelib.py`、`docs/api/filelib-retrieve.md`、`test/knowledge/test_retrieval_*.py`、`test/open_api/test_filelib_retrieve_facade.py`。 +- **Line B · MCP Server**(T201–T212,owner「mcp agent」):只碰 `open_api/mcp/**`、`open_api/api/{dependencies,middleware}.py`、`open_api/domain/scopes.py`、`main.py`、`dev_toolkit/api/endpoints/distribution.py`、`department/domain/services/org_directory_service.py`、`app_publish/domain/services/publish_status_service.py`、`app_runtime/domain/services/app_query_service.py`、`database/models/audit_log.py`、`platform/controllers/API/log.ts`、`platform/public/locales/*/bs.json`、`test/open_api/test_mcp_*.py`、`test/open_api/test_open_api_route_matrix.py`、`test/open_api/test_scope_issuability.py`、`test/open_api/test_scopes.py`。**Line B 内只有 T301(工具 ①②)依赖 Line A**;T301 之前 Line B 用 fake 门面(按 design §4.2 ③ 契约)自测。 +- **Wave C · 合流**(T301–T305):工具 ①②、注册表驱动矩阵与端到端、114 验证、对外文档与契约回写。 + +**后端 Test-First**:`a` 后缀 = 配对测试任务,物理排在实现任务前、先执行;`覆盖 AC` 逐条全写(禁 `AC-01~05` 范围写法)。基础设施任务(错误码 / schema / conftest / 注册表)无测试配对。单测放 `src/backend/test/open_api/` 与 `src/backend/test/knowledge/`(不放 `test/` 根),`asyncio_mode=auto`。本地无中间件(HARNESS.md):集合相等 / fail-closed 端到端标 `@pytest.mark.e2e`,CI 中间件分组跑,本地默认跳过。跑法:`cd src/backend && env -u ALL_PROXY -u all_proxy -u HTTP_PROXY -u HTTPS_PROXY -u http_proxy -u https_proxy uv run pytest test/open_api test/knowledge -q -p no:cacheprovider`(清代理是必须的,否则缺 `socksio` 会整批误报;`| tail` 会吃掉退出码,直接读最后一行)。**在 worktree 里跑之前**:worktree 没有 `.venv`、也没有未跟踪的 `src/backend/bisheng/config.yaml`,先从主检出拷一份 `config.yaml` 进来(`src/backend/AGENTS.md:180`)。基线 = 主检出同选择跑一遍再 diff,**别用绝对失败数判回归**。 + +**前端**:本 Feature **无页面**。唯一前端改动 = 审计 action lockstep 三处(T204);接入信息区界面归 F053 T046。 + +**自包含任务**:每个任务内联文件、逻辑、AC 覆盖;设计论证指向 design D-x / 坑 n,不复制。 + +**编号 ≠ 执行顺序**:Line A 与 Line B 并行;`依赖:` 是唯一顺序真相。 + +**跨 Feature 副作用登记**(release-contract 表 1 / 清单检查项 17): +- **T001**(`docs/constitution.md` C5 登记表 + `features/v3.0.0/release-contract.md` 已分配模块编码表:新增 **263 = mcp_face**;同表 F052 行状态回写归 T305) +- **T002**(`open_api/api/exception_handlers.py:54-75 open_api_http_status` 加 `McpFaceError` 分支——只加分支、既有映射不动) +- **T101**(`knowledge_space_chat_service.py` 五个私有方法**搬出**到 `retrieval_engine.py`,`aretrieve_chunks` 改薄委托——平台内对话链行为不变,`test/knowledge` 既有用例是护栏;F029 / F030 owner 的检索链此后由引擎承载) +- **T103**(`open_endpoints/api/endpoints/filelib.py:687-735 retrieve_chunks` 改调门面;`RetrieveReq` / `RetrieveResp` 的**字段集**不改。**两处受控收紧,已在 design D6 定案,本任务负责落地与回写**:① **F066 `data_scope` 拒绝由 26044/403 折叠为 26321/404**——门面走批量判定,而批量检查对被窄化目标是「返回 False 不抛」(`permission_action_service.py:328-333`),且 AC-11 / AC-27 本就要求「存在但非我创建」与「不存在」同一响应;因此 **`test/open_api/test_data_scope_matrix.py:41` 的 `"raise"` 分类必须改成新增档 `"unreachable"`**,并补一条「窄化令牌检索非本人创建的库 → 404 / 26321 且与库不存在逐字相同」的断言;技能包文案 `open_api/skill_packs/knowledge-search/SKILL.md:85` 与 `references/api.md:78` 对 26044 的检索期指引同批订正。② **`RetrieveReq.max_content` 加 `le=60000`**(今天只有 `ge=1`、无上限;门面静默夹取会违反 spec §3「截断必须在响应中可见」而 `RetrieveResp` 没有承载字段)) +- **T201**(`open_api/api/dependencies.py:64-163 open_api_access_context` 拆成 `admit_open_api_principal` + `open_api_execution_scope` 两段——**对外行为逐字不变**,`test/open_api` 全部 40 个测试文件是护栏;注意拆分会动到「租户 ContextVar 装配(`:76-80`)」与「PAT 租户策略读(`:86-98`)」的相对顺序,见 design D2 的风险段) +- **T203**(`main.py` 条件追加 Starlette `Route("/api/v2/mcp", …)`(**不是 `app.mount`**,design D1)+ lifespan 进 `session_manager.run()`;`open_api/api/middleware.py:22-27` 对 `/api/v2/mcp` 前缀短路;`test_open_api_route_matrix.py:36-41` 的 `test_every_real_v2_route_is_globally_key_protected_and_marked` 改遍历方式——同文件 `actual_v2_routes() :25-34` **已经**做了 isinstance 过滤,不要动) +- **T204**(`database/models/audit_log.py:198 _UI_VISIBLE_V2_ACTIONS` + `platform/controllers/API/log.ts:153 V2_ACTIONS` + `platform/public/locales/{zh-Hans,en-US,ja}/bs.json` 键 `openApiMcpToolCall`——审计对象归 F056 查询面、写入归本 Feature) +- **T206**(F053 的 `dev_toolkit/api/endpoints/distribution.py:56 get_dev_toolkit_versions` 返回体加 `mcp` 段 + `"model": None` 槽位;F051 落地时填 `model`,**不要重排既有键**) +- **T207**(`open_api/domain/scopes.py:154 identity:read issuable=True`;beta2 F053 的三处钉住测试同步) +- **T208**(F055 `publish_status_service.py:74/181 get_publish_status / _require_viewer` 与 F054 `app_query_service.py:64/246 get_instance / _load_visible` 加 `entry` 形参,缺省 `"detail"` 行为不变) +- **T209 → F054 回写请求**:`AppDataService` 需 `insert_row / delete_row`(manager `POST /v1/apps/{id}/db/tables/{t}/rows`、`DELETE …/rows/{key}`)——登记到 F054 tasks「跨 Feature 回写受理」表;F054 T086/T087 落地前 T209 阻塞。**注意 T209 只读不写 F054 的代码**:`AppDataService` 的 owner-only 已无条件内建(`_require_owner`),本 Feature **不给它加 `entry`、也不在工具层重复判 owner** +- **T210 → F051**:依赖其 `resolve_callable_names` 导出(design D10);F051 tasks 须把该函数列为 Outgoing 契约 + +--- + +## Tasks + +### Wave 0 · 共享基础(单一 owner,无测试配对,两条线分叉前落完) + +- [x] **T001**: 错误码 263 段 + 三语 + C5 / release-contract 登记 + **证据**: 71f525d42 — `common/errcode/mcp_face.py`(11 个码,全部 `Code: int`)+ 三语 `packages/locales/src/api_errors/*.json` + 生成物重跑 + C5 段位表 / 「263 is assigned」段 / release-contract 编码表;`test/open_api/test_mcp_face_error_codes.py`(4 条,含「模块 docstring 里的示例不得被 check-i18n 的正则当成真码」)。 + **偏差**: `test_error_codes.py` 未动(既有两条断言只扫 260 段的 `OpenApiAuthError` 子类,与 263 无交集),新断言独立成文件而不是塞进去。 + **文件**: `src/backend/bisheng/common/errcode/mcp_face.py`(新), `src/frontend/packages/locales/src/api_errors/{zh-Hans,en,ja}.json`(三语视为一组), `docs/constitution.md`(C5:`:128` 那行「26x–27x」段位表加 `263 mcp_face`;在 `:135`「**261 is assigned**」段之后补一条「**263 is assigned** …」写清子段与 HTTP 状态约定;文件头 `:9` 的 “Last revised / registry re-derived to 40 modules” 改成 41 并注明本次新增), `features/v3.0.0/release-contract.md`(「已分配模块编码(MMMEE)」表,`:96-104` 那张表里 260 行之后加 263 行), `src/backend/test/open_api/test_error_codes.py`(追加 263 段契约断言) + **逻辑**: 按 design §4.2 ④ 定义 `McpFaceError(BaseErrorCode)`(`Code=26300`、`http_status: int = 400`,构造签名仿 `common/errcode/open_api.py:6-22 OpenApiAuthError`)与十个子类:`26301 McpUnknownToolError(404)` / `26302 McpToolScopeMissingError(403, __init__(required))` / `26303 McpIdentityHeaderRefusedError(403)` / `26304 McpToolArgumentInvalidError(400)` / `26305 McpAppNotOwnedError(403)` / `26306 McpIdentityNotFoundError(404)` / `26320 RetrievalIdentityMissingError(403)` / `26321 KnowledgeUnreachableError(404, __init__(unreachable_ids))` / `26322 KnowledgeCapabilityRevokedError(409, __init__(knowledge_id))` / `26323 RetrievalScopeTooLargeError(400)`。模块 docstring 仿 `app_factory.py:1-28`:子段 26300–26319 传输 / 工具面、26320–26339 门面(四调用方共用)、26340+ 保留;写明「不占 260 段」。每个子类**必须**写成 `Code: int = 263xx`(`check-i18n.mjs:106` 的 `/Code:\s*int\s*=\s*(\d+)/g` 只认这一形,坑 18——`open_api.py` 整份用的是不带注解的 `Code = 26001`,别照抄它)。三语:每码 `""` 主文案(**只放主文案**,`next_step` 归 T202 后端表——design D4);`test_error_codes.py` 新增 `test_mcp_face_codes_are_in_263_band_and_have_three_language_copy`(读三文件,断言 `mcp_face.py` 每个 `Code` 都在 26300–26339 且三文件都有该键)。**该文件既有的两条断言不会被误伤**:`test_only_designated_open_api_error_codes_are_implemented :39-46` 只扫 `bisheng.common.errcode.open_api` 模块里 `OpenApiAuthError` 的子类,`test_reserved_and_removed_codes_are_not_reused :49-52` 只检查 260 段的保留洞——新模块是独立文件、独立基类,与两者无交集;新增断言写成第三个函数即可,**不要**去扩 `EXPECTED_CODES`。 + **依赖**: 无 + **落地**: `64cc2c386` — `common/errcode/mcp_face.py` 十个码(子类一律 `Code: int = 263xx`)+ 三语文案 + C5 / release-contract 登记;`test_error_codes.py::test_mcp_face_codes_are_in_263_band_and_have_three_language_copy` 同时钉死波段、注解写法与三语齐全。 + +- [x] **T002**: v2 exception handler 对 `McpFaceError` 的真实 HTTP 状态映射 + **证据**: 71f525d42 — `exception_handlers.open_api_http_status` 在 `OpenApiAuthError` 分支后加 `McpFaceError` 分支;`test/open_api/test_http_status.py` 新增一条参数化用例(26320/403、26321/404、26322/409、26323/400 在 `/api/v2` 为真实状态,`/api/v1` 仍 200 信封)。 + **文件**: `src/backend/bisheng/open_api/api/exception_handlers.py`(`open_api_http_status :45-75`), `src/backend/test/open_api/test_http_status.py`(追加) + **逻辑**: 在 `OpenApiAuthError` 分支之后加 `if issubclass(error_type, McpFaceError): return getattr(exc, "http_status", error_type.http_status)`;其余分支不动。测试:26321 → 404、26322 → 409、26320 → 403、26323 → 400 在 `/api/v2/**` 上为真 HTTP 状态;`/api/v1` 路径不受影响(仍 200 信封)。 + **依赖**: T001 + **落地**: `64cc2c386` — `exception_handlers.open_api_http_status` 加 `McpFaceError` 分支;`test_http_status.py` 两条新用例(26320→403 / 26321→404 / 26322→409 / 26323→400,且 `/api/v1` 仍 200 信封)。 + +- [x] **T003**: 门面契约 schema(Line A / Line B 共同依赖的**唯一**共享文件) + **落地**: 由 Line A 建立;Line B 在 `tools/knowledge.py` 函数体内延迟 import 消费,注册表 `available()` 用 `importlib.util.find_spec` 判定——两侧合并后门面已在树上,工具 ①② 正常进清单。 + **文件**: `src/backend/bisheng/knowledge/domain/schemas/retrieval_facade.py`(新) + **逻辑**: 按 design §4.2 ③ 逐字落:`RetrievalIdentity`(frozen dataclass,`actor: PermissionActor`、`login_user: UserPayload`;两个构造 classmethod——`from_open_api_principal(principal)`:S 模式 `subject_type=principal.authorization_subject_type, subject_id=principal.authorization_subject_id`,D 模式 `("user", effective_user_id)`;`login_user = UserPayload(user_id=principal.effective_user_id or principal.actor_id, user_name=principal.actor_name, user_role=[], tenant_id=principal.tenant_id, is_global_super=False)`(坑 8:不调 `init_login_user`);`from_user(user_id, tenant_id, *, data_scope=DATA_SCOPE_ALL)` 经 `resolve_permission_actor` 解析管理员事实后构造 actor)· `RetrievalRequest` · `RetrievalChunk` · `RetrievalFacadeResult` · `AccessibleKnowledge` · `ReachabilityReport` · 五个常量。**只放数据结构与常量,不放逻辑**(Line B 的 fake 门面据此写)。`PermissionActor` import 走 `bisheng.permission.domain.services.permission_action_service`(与 `dependencies.py:50` 同一路径)。 + **依赖**: 无 + **落地**: `64cc2c386` — `knowledge/domain/schemas/retrieval_facade.py`。**偏差**:`from_user` 落成 `async`(管理员事实必须经 `resolve_permission_actor` 解析,不能由调用方猜),`from_open_api_principal` 保持同步;已回写 design §4.2 ③。 + +### Line A · 统一检索门面(Test-First 配对) + +- [x] **T101a**: 引擎搬迁等价测试 + **文件**: `src/backend/test/knowledge/test_retrieval_engine_extraction.py`(新) + **逻辑**: 用 monkeypatch 替换 `KnowledgeRetrieverTool`、`KnowledgeDao.aquery_by_id`、`KnowledgeFileVisibilityService.build_index_prefilter / post_filter_retrievable_files`、`KnowledgeFileDao.aget_file_by_ids` 为可控 fake,断言:`test_chat_service_aretrieve_chunks_delegates_to_engine`(同输入 → `KnowledgeSpaceChatService.aretrieve_chunks` 与 `RetrievalEngine.retrieve_many` 返回逐项相等,含 `document_update_time` 水合)→ AC-20;`test_engine_constructs_without_request`(`RetrievalEngine(login_user)` 不需 `Request`,可见性服务以 `request=None` 构造仍完成双层过滤——坑 4 守卫)→ AC-19;`test_space_path_keeps_two_layer_filter`(prefilter 与 post_filter 各被调用一次、被 post_filter 丢弃的 `document_id` 不出现)→ AC-20;`test_library_path_requires_use_then_filters_files`(`ensure_knowledge_use_async` 被调、失败即 `UnAuthorizedError` 上抛)→ AC-20;`test_permission_unavailable_propagates_unchanged`(fake 抛 `PermissionServiceUnavailableError` → 原样冒出、无结果)→ AC-24;`test_chat_service_empty_ids_still_400`(平台内路径的 400 行为不变,决议-9)。 + **覆盖 AC**: AC-19, AC-20, AC-24 + **依赖**: T003 + **落地**: `291b1e405` — `test/knowledge/test_retrieval_engine_extraction.py` 14 例全绿。 + +- [x] **T101**: `RetrievalEngine` 搬出 + 聊天服务薄委托 + **文件**: `src/backend/bisheng/knowledge/domain/services/retrieval_engine.py`(新), `src/backend/bisheng/knowledge/domain/services/knowledge_space_chat_service.py`(`aretrieve_chunks :726-770` 改委托;删除 `_attach_document_update_time :788` / `_aretrieve_chunks_for_kb :809` / `_aretrieve_chunks_dispatch :836` / `_aretrieve_chunks_for_knowledge_base :869` / `_resolve_kb_file_ids_by_tags :907`;`_retrieve_and_filter :393` 与 `_resolve_kb_target_file_ids :695` 若仍被对话链其它方法调用则**保留并改为委托引擎同名方法**,不复制) + **逻辑**: design D5-B。`RetrievalEngine(login_user: UserPayload, *, version_repo=None)`:`retrieve_many(targets: list[Knowledge], *, query, tag_filters, max_content) -> list[tuple[int, Document]]`(`asyncio.gather` 逐库;接收**已加载的 Knowledge 行**而非 id——可及性由门面预判,引擎不再 `aquery_by_id`/抛 `NotFoundError`)、`retrieve_space(space, ...)`、`retrieve_library(kb, ...)`、`attach_document_update_time(results)`;内部 `KnowledgeFileVisibilityService(request=None, login_user)` + `svc.version_repo = version_repo`;`permission_filter | …` 结构化日志随方法搬入。`KnowledgeSpaceChatService.aretrieve_chunks` 保持签名与 400 / 10962 / 404 行为:先自行 `aquery_by_id` 逐个加载与类型分派(原 `_aretrieve_chunks_dispatch` 的判断留在聊天服务这一层,只把「取 docs」交给引擎),再 `flattened[:top_k]`。 + **测试**: T101a 全部通过;`test/knowledge` 既有用例(`grep -l aretrieve_chunks test/`)全绿。 + **覆盖 AC**: AC-19, AC-20, AC-24 + **依赖**: T101a + **落地**: `291b1e405` — `retrieval_engine.py` 新建,聊天服务 946→752 行。**偏差**:`_aretrieve_chunks_dispatch` 未整体删除,而是收窄成 `_aretrieve_chunks_for_one`(存在性 / 类型裁定 + 空间级闸留在聊天服务这一层,design D5 正文即如此要求,只是文件清单行写了「删除」);`_retrieve_and_filter` / `_resolve_kb_target_file_ids` 中前者保留为委托(文件夹对话在用)、后者随引擎搬走。**三个既有测试文件同批改**:`test_knowledge_space_chat_service_retrieve.py` / `test_knowledge_space_chat_service_visibility.py` / `test_openapi_retrieve_file_visibility.py` 里直接戳私有方法的用例改为对引擎断言——方法搬走了断言跟着搬,行为断言本身未放松。 + +- [x] **T102a**: `RetrievalFacadeService` 单元测试 + **文件**: `src/backend/test/knowledge/test_retrieval_facade.py`(新), `src/backend/test/knowledge/conftest.py`(新:`fake_engine`(记录 `retrieve_many` 调用、按 fixture 表返回 Document)、`fake_visibility`(monkeypatch `batch_check_business_actions` 返回按 `{resource_type: {id: actions}}` 查表)、`fake_visible_objects`(monkeypatch `runtime.list_visible_objects`)、`knowledge_rows`(monkeypatch `KnowledgeDao.aget_list_by_ids`);**autouse 清代理 env**(`ALL_PROXY` 等六个,HARNESS.md 陷阱)) + **逻辑**: `test_identity_none_rejected_26320` / `test_identity_without_subject_rejected_26320`(不调引擎)→ AC-23;`test_explicit_targets_all_reachable_calls_engine_with_rows` → AC-22;`test_any_unreachable_target_fails_whole_request_26321_same_response`(参数化:不存在 / 存在未授予 / type=1 QA / type=2 个人 → 同一码 + `data.unreachable_ids` 只列不可及 id、**不带原因字段**)→ AC-11, AC-27;`test_no_targets_no_whitelist_uses_all_accessible`(`list_visible_objects` 两种资源类型各一次 → 过滤 type∈{0,3})→ AC-22;`test_scope_larger_than_200_rejected_26323`(不静默截断);`test_admin_actor_scans_tenant_but_personal_scope_does_not`(坑 7);`test_whitelist_intersects_visible_silently_when_not_targeted`(白名单内不可见库不出现、不报错)→ AC-21;`test_whitelist_outside_target_is_unreachable`(执行身份本人可见、且是租户管理员 actor,仍 26321)→ AC-21;`test_whitelist_entry_deleted_raises_26322_and_no_engine_call`(含 `data.knowledge_id`;显式指定同库且**无白名单**时 → 26321)→ AC-46;`test_top_k_and_max_content_clamped_visibly`(`truncated_params` 含被夹字段);`test_permission_unavailable_never_returns_partial`(引擎中途抛 19002 → 异常冒出、无结果对象)→ AC-24;`test_chunks_carry_knowledge_name_and_type` → AC-19;`test_list_accessible_knowledge_excludes_unsupported_types`(QA / 个人库不出现;返回 `AccessibleKnowledge`)→ AC-27;`test_check_reachable_report_three_buckets`;`test_facade_installs_actor_contextvar_and_resets`(坑 5:调用前后 `get_current_permission_actor()` 恢复原值);`test_data_scope_personal_inherited`(actor `data_scope=personal_only` 透传到 `batch_check_business_actions` 的 actor 解析——断言 fake 收到的 actor.data_scope)。 + **覆盖 AC**: AC-11, AC-19, AC-21, AC-22, AC-23, AC-24, AC-27, AC-46 + **依赖**: T001, T003, T101 + **落地**: `e82561fc5` — `test/knowledge/test_retrieval_facade.py` 33 例 + `test/knowledge/conftest.py` 四个可编程 fake。 + +- [x] **T102**: `RetrievalFacadeService` 实现 + **文件**: `src/backend/bisheng/knowledge/domain/services/retrieval_facade_service.py`(新) + **逻辑**: design D5。`retrieve(identity, req)`:① `identity is None or identity.actor is None or not identity.actor.subject_id` → 26320;② `top_k = min(req.top_k, RETRIEVAL_TOP_K_MAX)`、`max_content = min(…)`,被夹的记入 `truncated_params`;③ 范围解析:`targets = req.knowledge_ids`;若 `req.whitelist is not None`:`whitelist == []` → 全部目标不可及(有目标 → 26321(targets);无目标 → 空结果 `effective_scope=[]`,不报错——app 声明为空是 F055 的预检问题);有目标 → `∉ whitelist` 的进 unreachable;无目标 → `targets = whitelist`;若 `whitelist is None and not targets` → `list_accessible_knowledge` 全集(> `RETRIEVAL_SCOPE_MAX` → 26323);`len(targets) > RETRIEVAL_TARGETS_MAX` → 26323;④ `rows = KnowledgeDao.aget_list_by_ids(targets)`:白名单条目缺行 / `type ∉ SUPPORTED` → **26322**(取第一条,`data.knowledge_id`);非白名单缺行 / 类型不支持 → unreachable;⑤ `token = set_current_permission_actor(identity.actor)` 后一次 `batch_check_business_actions(identity.login_user, resource_type="knowledge_space", ids, actions=("visible",))` + 一次 `resource_type="knowledge_library"(以 F048 registry 名为准), actions=("use",)`;显式目标不可见 → unreachable;白名单(未显式)不可见 → 剔除;`unreachable` 非空 → 26321;⑥ `RetrievalEngine(identity.login_user).retrieve_many(可及 rows, ...)` → `[:top_k]` → `attach_document_update_time` → 组 `RetrievalChunk`(`knowledge_name / knowledge_type` 取自 rows);`finally reset_current_permission_actor(token)`。异常一律不捕获权限族(19002 / 19201 / `PermissionCheckFailedError` 等原样上抛)。`list_accessible_knowledge(identity, *, name=None, limit=200)`:actor 管理员且 `data_scope == ALL` → 租户内 `Knowledge` 扫描(type∈{0,3}、`delete==0`;自动租户过滤);否则 `runtime.list_visible_objects(actor, resource_type=…, max_results=RETRIEVAL_SCOPE_MAX)` 两类型 → `aget_list_by_ids` → 类型过滤 → `batch_check_business_actions` 二次确认(`visible` 是超集);`name` 子串过滤在内存做。`check_reachable(identity, knowledge_ids, *, whitelist=None) -> ReachabilityReport`(复用 ③④⑤,不调引擎;F055 T060 预检用)。`is_supported_knowledge_type(t)` 纯函数。**不创建会话、不写消息、不 import 任何 `chat_session` / `message` 模块**(AC-08 / AC-19;T104 用 `test_facade_imports_no_session_modules` 静态守卫)。 + **测试**: T102a 全部通过 + **覆盖 AC**: AC-11, AC-19, AC-21, AC-22, AC-23, AC-24, AC-27, AC-46 + **依赖**: T102a + **落地**: `e82561fc5` — `retrieval_facade_service.py`。**偏差**:`retrieve` 的 `version_repo` 走关键字传参而非门面内部取依赖工厂(领域服务调 FastAPI 依赖工厂 = 反向依赖入口层,C1);`check_reachable` 只评估 `knowledge_ids` 点名的目标、`whitelist` 仅用于判定是否属声明范围与条目是否消失(不遍历未点名的白名单条目)。两条均已回写 design §4.2 ③。 + +- [x] **T103a**: v2 `POST /filelib/retrieve` 收敛测试 + **文件**: `src/backend/test/open_api/test_filelib_retrieve_facade.py`(新), `src/backend/test/open_api/test_data_scope_matrix.py`(改分类 + 补一条用例,见下) + **逻辑**: HTTP 客户端用本目录的既有形态 `AsyncClient(transport=ASGITransport(app=app), base_url="http://test")`(`test/open_api/conftest.py` 里**没有** `v2_client` fixture;`test_data_scope_matrix.py:129` / `test_dependencies.py:93` 是现成范式),配合 monkeypatch `RetrievalFacadeService.retrieve` 为 spy:`test_endpoint_builds_identity_from_principal_mode_s`(`RetrievalIdentity.actor.subject_type == "service_account"`)→ AC-25;`test_endpoint_mode_d_identity_is_target_user`(模式 D principal → `("user", effective_user_id)`)→ AC-43(能力交付,验收归 F050);`test_response_shape_unchanged`(`RetrieveResp{chunks[{content,knowledge_id,document_id,document_name,chunk_index,document_update_time}],total}` 字段集与 HEAD 快照相等)→ AC-25;`test_unreachable_maps_to_26321_http_404`(旧 404 `NotFoundError` / 403 `SpacePermissionDeniedError` / 10962 三态不再出现)→ AC-11;`test_permission_unavailable_is_503_with_19002_not_26030`(D6:删包装)→ AC-24, AC-44;`test_empty_knowledge_base_ids_still_400`(`min_length=1` 保留,契约不变)→ AC-25;`test_no_bypass_of_facade_in_open_face`(静态:`grep` `open_endpoints/` 与 `open_api/` 下不存在对 `aretrieve_chunks` / `RetrievalEngine` 的直接调用,只允许 `RetrievalFacadeService`)→ AC-26;`test_max_content_above_cap_is_422_not_silently_clamped`(传 `max_content=100000` → pydantic 422,**不是** 200 + 静默夹取)→ AC-25。 + **同批改 `test_data_scope_matrix.py`(design D6 受控收紧 ①)**:`KNOWLEDGE_READ_CLASSIFICATION` 里 `("POST", "/api/v2/filelib/retrieve")` 由 `"raise"` 改为新增档 `"unreachable"`,并在文件顶部的分类注释(`:33-37`)补一行说明该档语义;新增 `test_narrowed_token_retrieve_is_unreachable_not_26044`(窄化 PAT 检索非本人创建的库 → HTTP 404 / `status_code == 26321`,且响应体与「库不存在」逐字相等)→ AC-11, AC-25。**理由必须写进用例 docstring**:批量检查对被窄化目标返回 `False` 而非抛(`permission_action_service.py:328-333`),且 AC-27「存在性不泄露」不允许 26044 与 26321 可区分。 + **覆盖 AC**: AC-11, AC-24, AC-25, AC-26, AC-43, AC-44 + **依赖**: T002, T102 + **落地**: `610551615` — `test/open_api/test_filelib_retrieve_facade.py` 14 例;`test_data_scope_matrix.py` 分类改 `"unreachable"` + 新增 `test_narrowed_token_retrieve_is_unreachable_not_26044`(5 例全绿)。`test_openapi_retrieve_file_visibility.py::test_v2_adapter_maps_permission_outage_to_503_without_chunks` 同批改断言(26030 包装已删,改断 19002 / 503)。 + +- [x] **T103**: v2 端点改调门面 + 文档订正 + **文件**: `src/backend/bisheng/open_endpoints/api/endpoints/filelib.py`(`retrieve_chunks :689-735`), `src/backend/bisheng/open_endpoints/domain/schemas/filelib.py`(`RetrieveReq.max_content :48-52` 加 `le=60000`,其余字段一律不改;`RetrieveChunk` 不改), `docs/api/filelib-retrieve.md`(`:181-186` 错误表加 26321 / 26322 / 26323 行、删 404「知识库不存在」与 403「默认操作员」两行、把 26044 行改注「清单类端点仍返回,retrieve 上已折叠进 26321」;`:287` 「default operator 是否对该 KB 有 view 权限」→「统一检索门面按执行身份做可及性判定(知识空间 `visible`、文档库 `use`)+ 文件级双层过滤」;补一句 `max_content` 上限 60000), `src/backend/bisheng/open_api/skill_packs/knowledge-search/SKILL.md`(`:85` 26044 指引)与 `.../references/api.md`(`:78` 同) + **逻辑**: design D6。端点体:`principal = get_current_open_api_principal()` → `identity = RetrievalIdentity.from_open_api_principal(principal)` → `result = await RetrievalFacadeService.retrieve(identity, RetrievalRequest(query=req.query, knowledge_ids=req.knowledge_base_ids, tag_filters=<由 req.filters 转>, top_k=req.top_k, max_content=req.max_content))` → 映射为 `RetrieveResp`(`knowledge_name / knowledge_type` **不**加进 v2 响应,契约不变)。删除 `:722` 的 `OpenApiAuthDependencyUnavailableError` 包装与 `:703` 的 `KnowledgeSpaceChatService` 构造(连同 `:700-702` 那段「per-user view_file/view_space filtering in aretrieve_chunks」注释,改为指向门面);`version_repo` 依赖改传给门面(`RetrievalRequest` 不带 repo——门面构造引擎时从 `get_knowledge_document_version_repository()` 取,或端点经 `RetrievalFacadeService.retrieve(..., version_repo=)` 关键字传入,二选一并在 §4.2 ③ 回写)。`tag_match_mode != "ANY"` 的 400 保留在端点层(原 `retrieve_chunks` 体内对 `req.filters` 的校验行为,符号定位)。 + **测试**: T103a 全部通过;`test/open_api/test_data_scope_matrix.py` 按 T103a 改完后全绿(**不是「原样仍绿」**——retrieve 的分类必须改,理由见 design D6 受控收紧 ①) + **覆盖 AC**: AC-11, AC-24, AC-25, AC-26, AC-43, AC-44 + **依赖**: T103a + **落地**: `610551615` — 端点改调门面、`RetrieveReq.max_content` 加 `le=60000`、`docs/api/filelib-retrieve.md` 错误表与 §7.1 口径订正、技能包 `SKILL.md` / `references/api.md` 补 26321/26322/26323 并改写 26044 指引。**额外删除**:`open_endpoints/api/dependencies.py` 的 `get_knowledge_space_chat_service_for_openapi`——端点改走门面后零调用方,属死代码(顺带解掉该文件对 `knowledge/api/` 的跨模块 import)。 + +- [ ] **T104**: 集合相等 + fail-closed 集成用例(CI 中间件分组)——**部分落地,未完成**(勾选留空:`[x]` 会让本条在「哪些还没做」的检索里消失,而 AC-40 / AC-42 / AC-44 的存储层断言确实还没有) + **文件**: `src/backend/test/knowledge/test_retrieval_facade_equality.py`(新,`@pytest.mark.e2e`) + **逻辑**: 真 MySQL + Redis + OpenFGA + Milvus/ES(CI);建样本:服务账号 SA1 授 空间 S1(文件 f1 可见、f2 单文件收权、f3 在未授权文件夹、f4 切自定义模式脱钩)+ 文档库 L1;未授予 空间 S2;自然人 U1 同样授权。断言:`test_sa_equals_expected_set`(门面结果文件集 == {f1 的 chunks} ∪ L1;S2 指定 → 26321)→ AC-40;`test_v2_and_facade_direct_equal`(同 key 经 v2 端点与直调门面集合相等)→ AC-41;`test_user_with_whitelist_equals_platform_search_in_scope`(`from_user(U1)` + `whitelist=[S1]` == U1 平台内限定 S1 的 `aretrieve_chunks`)→ AC-42(F055 承接运行期验收);`test_mode_d_identity_equals_user_self`(`from_open_api_principal(模式 D principal → U1)` == U1 自检)→ AC-43(F050 承接);`test_fga_down_three_callers_error_zero_results`(`fga_down` fixture → 门面 / v2 / MCP 工具 ① 三处 19002、无 chunks)→ AC-44;`test_facade_imports_no_session_modules`(静态 import 图)→ AC-08, AC-19。**测试降级**:本地无中间件时 skip;114 手动步骤见 design §7 ③。 + **覆盖 AC**: AC-08, AC-19, AC-40, AC-41, AC-42, AC-43, AC-44 + **依赖**: T103, T301(MCP 工具 ① 那一分支可 `importorskip`) + **执行位置**: 编号属 Line A,但因依赖 T301 而**在 Wave C 执行**(「编号 ≠ 执行顺序」;Line A 的 owner 在 T103 之后即可转去 T105,T104 与 T302 一起在合流波跑)。 + **部分落地**: `610551615` — `test/knowledge/test_retrieval_facade_equality.py` 10 例全绿,覆盖**能在无中间件下证的那一层**:AC-41 / AC-43 的身份构造缝(v2 端点不得自建 actor / UserPayload;模式 D = 被代表用户且不带任何提权)、AC-08 / AC-19 会话解耦(静态 import 图 + 签名里没有 `Request` 的位置)、坑 23 无缓存红线守卫。**刻意不写 skip / NotImplementedError 占位用例**(看起来像覆盖、实际什么也没证)。 + **再落地(`005e5f470`,合流波)**: 同文件加到 18 例,把另外两条原本被整体推给中间件、实际由请求路径决定的断言补上—— + ① **AC-41 行为层** `test_the_two_open_doors_hand_the_facade_the_very_same_call`:同一 principal 下 v2 端点与 MCP 工具 ① 递给门面的 `RetrievalIdentity` 与 `RetrievalRequest` 逐字相等(含 `whitelist is None`)。原先只有 AST 静态守卫,证不到「两边传的参数也一样」; + ② **AC-44 逐门面同一次故障注入** `test_a_permission_outage_returns_an_error_and_zero_chunks_at_every_door`(参数化 facade / v2 / mcp / hosted_runtime):`batch_check_business_actions` 抛 `PermissionServiceUnavailableError` 时四处都上抛、且检索引擎一次都没被调用。「少给几条」与「授权本来就窄」不可区分,正是这条 AC 要挡的形状。**参数取自 spec AC-44 点名的三个门面**(MCP 检索工具 / v2 `POST /filelib/retrieve` / **托管运行期**)**再加门面本身**——本任务原文第 129 行把「三处」写成「门面 / v2 / MCP」,漏掉了托管运行期;而托管运行期是唯一一个不直连门面、经 `CapabilityBusService.retrieve` 且**带 `except BaseErrorCode` 包裹**的调用方(它要给 AC-55 能力台账补一行),正是「顺手改成记一笔再返回已有结果」最可能被写进去的位置。已按 spec 补齐。 + **播种 helper 与用例体已落地(wave 5,`wt/f052-e2e-scaffold`);任务仍不勾——它们一次都还没跑过**。 + - **新增** `src/backend/test/e2e/helpers/retrieval_sample.py`:`seed_retrieval_sample()` 异步上下文管理器,按前缀 `e2e-f052-retrieval-` 播种并在退出时全部清掉。播的正是本任务写的那份样本,四种权限来源**逐条可区分**(原文「单文件收权」与「自定义模式脱钩」两条容易写成同一件事,helper 里分成了两个位置不同的脱钩):f1 空间根 INHERIT(可达);f2 空间根切 CUSTOM(把继承来的授权单文件收回);f3 在切了 CUSTOM 的文件夹 D1 下;f4 在仍 INHERIT 的文件夹 D2 下、**文件自身**切 CUSTOM(父路径全部放行、停在文件上,最易回归的一条);文档库 L1 走 `knowledge_library` 的 `use` 授权。外加谁都没授的空间 S2。服务账号与自然人(PAT)**两个主体授权完全相同**,都从各自真实的管理入口授(服务账号只能走账号详情页的 `resource-grants:mutate`,通用资源选择器会拒)。脱钩后 helper 自己复核名册里不再有这两个主体,播错时报「播种失败」而不是让断言背锅。 + - **新增** `src/backend/test/e2e/test_e2e_f052_retrieval_set_equality.py`(`@pytest.mark.e2e` + `F052_E2E=1` 双层跳过):`test_a_service_account_reads_exactly_the_files_it_was_granted`(AC-40 集合相等,多给少给分别报「过度披露 / 披露不足」并带文件来源)、`test_naming_an_ungranted_space_is_refused_rather_than_quietly_dropped`(AC-40 的 26321)、`test_the_mcp_tool_and_v2_return_the_same_chunks_for_the_same_key`(AC-41,按 `(document_id, chunk_index)` 比,不是只比文件集)、`test_a_personal_token_sees_exactly_what_the_platform_shows_that_person`(AC-42)、`test_a_revoked_key_stops_working_inside_the_revocation_bound`(AC-05,真 Redis 计时)、`test_with_the_permission_engine_stopped_every_door_errors_and_returns_nothing`(AC-44,见下)。 + - **跑起来还需要什么**(一条都不能省,缺哪条就写在跳过理由里):① 一套**部署起来的** BiSheng——MySQL + Redis + OpenFGA + MinIO + Milvus/ES,**外加一个在跑的 knowledge Celery worker**;没有 worker 样本永远进不了索引,三条集合相等会以「两边都空」的方式假绿,所以 helper 在摄取超时时直接报错而不是继续;② `E2E_API_BASE`(必要时 `E2E_ADMIN_PASSWORD`);③ `F052_E2E_OWNER_USER_ID`;④ `F052_E2E_USER_ID` / `F052_E2E_USER_NAME` / `F052_E2E_USER_PASSWORD`——**非超管、非租户管理员**的普通账号(管理员在 `_identity_shortcut` 处先于动作校验被放行,管理员跑绿等于没跑);⑤ 部署级 PAT 开关可用(helper 读原值、用完原样写回)。 + - **AC-42 的偏差**:原文写「== U1 平台内限定 S1 的 `aretrieve_chunks`」。平台内那条路只经 SSE 会话且要真模型,把身份断言绑到一次模型调用上不合适。改为与 U1 用自己会话**递归浏览** S1 得到的文件集比对——同一个权限判定、同一个粒度(四种来源都作用在文件/文件夹上),f2 / f3 / f4 必须两边都不在。 + - **AC-44 分两次跑**:停掉 OpenFGA 就播不了种,所以停机那一条单独用 `F052_E2E_FGA_DOWN=1` 跑,样本 id 与密钥经 `F052_E2E_SEEDED_SPACE_ID` / `F052_E2E_SEEDED_KEY` 从上一次已播种的运行传进来。 + - **文件位置偏差**:本任务原文把文件写在 `test/knowledge/test_retrieval_facade_equality.py`。那份是**无中间件**的缝层守卫(18 例常跑),把默认跳过的中间件用例混进去会让「这份文件到底证了什么」读不出来;且仓内活体 E2E 的房子规矩就是 `test/e2e/` + `test/e2e/helpers/`。故落在 `test/e2e/`,并把原文件末尾那段「播种 helper 尚不存在」的注释改写成指路(指向新 helper 与新用例,逐条写明哪条 AC 在哪儿)。 + - **评审回合逐条核对了 helper 用到的每个接口形状**(`b68c12286`),改掉三处会让首跑必挂、且挂得不像真原因的写法:① `cleanup_prefixed` 原来用 `GET /knowledge?type=3` 列知识空间,而该 handler 对 `type=3` 直接抛 `KnowledgeSpaceListNotSupportedError`,播种第一步就死——改走 `GET /knowledge/space/mine`(空间都是这个管理员建的);② f1–f4 原本字节完全相同,而上传去重是**同一知识库内 md5 或文件名命中即拒**(`KnowledgeFileDao.get_file_by_condition` 的 `or_`),四份里只有一份会真建出来,且被拒的那份**回的是它撞上的那个文件的行**,形状与建成功一模一样——现在每份多带一行槽位标记保证 md5 互不相同,并在 `accept_created` 里按 `status=FAILED` 与 id 重复直接判「播种失败」;③ 摄取轮询原来不带 `parent_id`,而 `/children` 只列**直接子级**(无 `parent_id` 时即 `file_level_path == ''` 的空间根),`file_ids` 是在此之上再收窄而非改写作用域,D1/D2 里的 f3/f4 永远等不到 SUCCESS,只会以「摄取超时」收场、读起来却像「没起 Celery worker」——改成按父目录分组轮询。另把名册复核从「只比 id」改成比 `(subject_type, id)` 对(服务账号与用户各自编号,撞号会误报泄漏),并把 fixture 的管理员前置核验从只看 `role == "admin"` 扩到 `/user/info` 的 `is_global_super` / `is_child_admin` / `is_department_admin`——租户管理员不是 `role=="admin"`,原判据放得过去。**这些都是静态核对 + 假传输层走通控制流的结果,仍然不等于真跑过。** + +- [x] **T105**: 门面对外契约文档回写 + **文件**: `features/v3.0.0/052-mcp-server-face/design.md`(§4.2 ③ 按实现定稿:`version_repo` 传法、`from_user` 签名), `features/v3.0.0/055-app-publish-pipeline/tasks.md`(T057 / T060 的「依赖」行改指本契约,一行), `features/v3.0.0/057-bisheng-sdk/spec.md` 不改(AC-11 已引 F052 AC-19) + **逻辑**: 只回写契约,不写论证。 + **依赖**: T103 + **落地**: `610551615` — design §4.2 ③ 按实现定稿(`from_user` 为 async、`version_repo` 关键字传参、`check_reachable` 口径、`knowledge_type_label`);`055-app-publish-pipeline/tasks.md` 的 T057 / T060 各补一行「依赖」指向该契约并写明调用形态。`057-bisheng-sdk/spec.md` 未改(AC-11 已引 F052 AC-19)。 + +### Line B · MCP Server(Test-First 配对;T301 前用 fake 门面) + +- [x] **T201a**: `dependencies.py` 拆分回归测试 + **文件**: `src/backend/test/open_api/test_dependencies.py`(追加) + **逻辑**: `test_admit_returns_principal_and_pat_scope`(服务账号 → `(principal, DATA_SCOPE_ALL)`;PAT + 租户策略 personal → `(principal, "personal_only")`;PAT 未开 → `PersonalTokenDisabledError`);`test_execution_scope_installs_and_resets_four_contextvars`(进入后 `get_current_tenant_id / visible_tenant_ids / get_current_open_api_principal / get_current_permission_actor` 四者正确、退出后恢复);**`test_pat_policy_read_happens_with_tenant_contextvar_installed`**(design D2 的风险点:今天的顺序是 `:76-80` 先装租户 ContextVar、`:86-98` 再读 `TenantSettingService.get_policy`,拆分会把它们倒过来;monkeypatch `get_policy` 在被调用的瞬间断言 `get_current_tenant_id() == principal.tenant_id`,倒序即红);`test_open_api_access_context_behaviour_unchanged`(沿用本文件既有用例集全绿即可,不新写)。 + **覆盖 AC**: AC-02, AC-03 + **依赖**: 无 + **证据**: 71f525d42 — `test/open_api/test_dependencies.py` 追加 6 条,含 `test_pat_policy_read_happens_with_tenant_contextvar_installed`(design D2 点名的唯一风险点: `get_policy` 虽显式收租户 id,其 DAO 仍在自动租户过滤之下,顺序颠倒只在真库上才炸)与 `test_execution_scope_without_prepare_never_resolves_delegation`(MCP 面不传 `prepare`, 身份头是拒绝而不是解析)。 + +- [x] **T201**: 拆分 `open_api_access_context`(行为不变) + **文件**: `src/backend/bisheng/open_api/api/dependencies.py`(`:64-163`) + **逻辑**: design D2。新增导出 `async def admit_open_api_principal(conn) -> tuple[OpenApiPrincipal, str]`(`:70-98` 内容:`validate_bearer` + PAT 策略;`OpenApiAuthError` 经 `_raise_for_connection`)与 `@asynccontextmanager async def open_api_execution_scope(conn, principal, *, data_scope)`(`:76-83` 的 tenant / visible / scope 写入 + `:129-153` 的 admin 事实与 `PermissionActor` + 四个 set,`finally` 四个 reset)。`open_api_access_context` 改为:`principal, data_scope = await admit_open_api_principal(conn)` → marker / delegate / scope / 身份头 / `resolve_request_identity` / 模式判定(`:100-127` 原样)→ `async with open_api_execution_scope(conn, principal, data_scope=data_scope) as p: yield p`。`__all__` 加两名。 + **测试**: T201a 通过;`test/open_api` 全部文件与主检出基线无新增失败 + **覆盖 AC**: AC-02, AC-03 + **依赖**: T201a + **证据**: 71f525d42 — 拆出 `admit_open_api_principal` / `open_api_execution_scope` 并导出;`open_api_access_context` 组合二者,对外行为逐字不变(`test/open_api` 425 passed,与拆分前一致)。 + **偏差(与 design D2 相比更保守)**: `open_api_execution_scope` 多收一个可选 `prepare` 回调。原因是 `resolve_request_identity` 要在租户 ContextVar 已装、actor 未构造之间跑——它读库(`OwnerRepository`),而委托解析会改变 actor 是谁。把这段放在调用方会把顺序拆散,`prepare` 让「一处实现」继续成立;MCP 面不传它。 + +- [x] **T202**: 错误层 + 工具注册表(无测试配对,T203a 覆盖) + **文件**: `src/backend/bisheng/open_api/mcp/__init__.py`(新), `src/backend/bisheng/open_api/mcp/errors.py`(新), `src/backend/bisheng/open_api/mcp/registry.py`(新) + **逻辑**: `errors.py`:`class McpToolError(mcp.server.fastmcp.exceptions.ToolError)` 持 `code / category / reason / next_step / data`,`__str__` = `json.dumps({...}, ensure_ascii=False)`;`ERROR_CATEGORY_MAP`(design D4 列表);`to_tool_error(exc: BaseException, *, lang) -> McpToolError`:`McpFaceError` / `OpenApiAuthError` / `BaseErrorCode` 按码查表,未映射 → `internal`(`reason=exc.Msg`、`next_step=NEXT_STEP_COPY[26300][lang]`「稍后重试或联系管理员」),**任何分支不把 `str(exc)` 原文放进 `reason`**(可能含路径 / SQL);`next_step` 取自同文件 `NEXT_STEP_COPY: dict[int, dict[str, str]]`(design D4:后端自有三语表,键 = 错误码,值 = `{"zh-Hans", "en", "ja"}`;`lang` 由闸从 `Accept-Language` 解析后放 ContextVar,缺省 `zh-Hans`;**不**读 `packages/locales`——后端进程没有那份 JSON,坑 18);`ERROR_CATEGORY_MAP` 与 `NEXT_STEP_COPY` 键集必须相等(T203a 断言)。`registry.py`:`@dataclass(frozen=True) McpToolSpec(name, category, scope, requires_app_runtime, handler, input_model, description, available: Callable[[], bool] = lambda: True)`;`TOOL_REGISTRY` 按 design D3 表(工具 ③ 的 `available` = F051 函数可导入;⑤ 的 `available` = `AppDataService` 可导入);`visible_tools(principal) -> list[McpToolSpec]`(`spec.scope in principal.scopes ∧ (not requires_app_runtime ∨ settings.app_runtime.enabled) ∧ available()`);`require_tool(principal, name)`:不在表或 `not available()` → 26301;缺位 → 26302(required);`requires_app_runtime ∧ ¬enabled` → `AppPublishRuntimeLayerDisabledError`(16207,`common/errcode/app_publish.py:109`)。 + **依赖**: T001 + **证据**: 1b359102a — `open_api/mcp/{__init__,errors,registry}.py`。`ERROR_CATEGORY_MAP` 与 `NEXT_STEP_COPY` 键集相等(T203a 断言),`to_tool_error` 任何分支都不把 `str(exc)` 放进 `reason`。 + **偏差**: 映射表比 design D4 列的多四个码——16164 / 16165 / 16166 / 16167(表不存在 / 行不存在 /入参非法 / 库忙)。它们是「这个应用确实是你的」的真实业务态,和 16163 同理不能折进 26305,否则等于告诉开发者自己的应用不是自己的。 + +- [x] **T203a**: 闸 + 服务器 + 挂载 测试 + **文件**: `src/backend/test/open_api/test_mcp_server.py`(新), `src/backend/test/open_api/conftest.py`(追加 `mcp_client` fixture:`httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver")` → `mcp.client.streamable_http.streamable_http_client("http://testserver/api/v2/mcp", http_client=client)`(`mcp/client/streamable_http.py:601-604`,1.27.1 支持注入 `http_client`)→ `ClientSession(read, write)`;参数 `headers`;`open_platform_enabled` monkeypatch **必须在 app 构造前**——坑 13:conftest 用 `importlib.reload(bisheng.main)` 或直接构造 `McpAccessGate(StreamableHTTPASGIApp(server.session_manager))` 做单元级装配,二选一记录) + **逻辑**: `test_no_credential_401_26001_and_no_tools`(`initialize` 即失败)→ AC-02;`test_invalid_revoked_expired_401_26002`(建号发钥仿 `test_credential_validator.py:18 seed_service_account` + `CredentialService` 签发;撤销后立即被拒;`fake_redis` 清键)→ AC-02, AC-05;`test_every_mapped_code_has_three_language_next_step`(`ERROR_CATEGORY_MAP` 键集 == `NEXT_STEP_COPY` 键集,且每项含 zh-Hans / en / ja 非空)→ AC-09;`test_accept_language_selects_next_step_default_zh`(`Accept-Language: en` → 英文;缺省 → 中文)→ AC-09;`test_query_param_token_rejected`(`?token=` / `?authorization=` 不认)→ AC-02;`test_delegate_key_403_26051_at_initialize_and_lists_nothing` → AC-28, AC-29;`test_identity_headers_403_26303`(参数化 `X-On-Behalf-Of` / `X-End-User` / `X-Foo-On-Behalf-Of` / `x-end-user`)→ AC-30;`test_delegate_key_never_falls_back_to_mode_s`(持 delegate + 无身份头 → 仍 26051,不是 26016)→ AC-29;`test_list_tools_filtered_by_scopes`(仅 `knowledge:read` → 恰两工具;PAT 主体同)→ AC-04;`test_empty_scopes_handshake_ok_tools_empty` → AC-04;`test_call_unlisted_tool_26302_names_scope`(仅 knowledge:read 调 `bisheng_org_tree` → `isError`、JSON `code=26302,data.required="identity:read"`,非「未知工具」)→ AC-04, AC-09;`test_unknown_tool_26301` → AC-09;`test_scope_edit_effective_next_call`(编辑权限位 → `invalidate_cache` → 下一次 `tools/list` 即新集合,同一客户端不重连)→ AC-06;`test_app_tools_hidden_when_runtime_disabled_and_direct_call_16207` → AC-17, AC-38;`test_route_absent_when_open_platform_disabled_404` → AC-37;`test_dns_rebinding_protection_disabled_real_host_accepted`(`Host: 192.168.106.114:4101`)→ 坑 1;**`test_bare_path_is_not_redirected`**(`POST /api/v2/mcp` 直接 200/JSON-RPC,**不得**是 307;用 `follow_redirects=False` 的裸 httpx 请求断言,这是 D1 用 `Route` 不用 `Mount` 的唯一可测证据,坑 12 的同源问题)→ AC-01;**`test_tool_error_text_is_parseable_json`**(任取一个被拒的工具调用,把 `content[0].text` 真的 `json.loads()` 一遍并断言含 `code/category/reason/next_step` 四键——只断言 `isError=True` 会漏掉 `Tool.run` 的前缀重包,坑 19)→ AC-09;**`test_successful_call_has_structured_content_and_no_output_validation_error`**(每个已注册工具各一次成功调用:`structuredContent` 非空、文本里不含 `Output validation error`,坑 20)→ AC-10, AC-12;`test_no_key_material_in_any_response`(所有响应体 / 错误体不含 `bs-sak-` / `bs-pat-`)→ AC-47;`test_no_session_or_message_dao_touched`(monkeypatch `MessageSessionDao` / `ChatMessageDao` 任一方法为 `raise`,全流程不触发)→ AC-08;`test_tenant_contextvar_installed_for_handler`(handler 内 `get_current_tenant_id()==密钥租户`、`visible=={1,tenant}`)→ AC-03。 + **覆盖 AC**: AC-01, AC-02, AC-03, AC-04, AC-05, AC-06, AC-08, AC-09, AC-10, AC-12, AC-17, AC-28, AC-29, AC-30, AC-37, AC-38, AC-47 + **依赖**: T201, T202 + **证据**: 1b359102a + 后续 — `test/open_api/test_mcp_server.py` 30 条,全部经真 `ClientSession`;`conftest.py` 加 `mcp_http` / `mcp_session` 两个**同步工厂**夹具。 + **评审修订(2026-09-16)**: 补 `test_a_personal_token_holder_is_a_first_class_subject_on_this_face`。design K4 把自然人(PAT)与服务账号并列为模式 S 的两类主体,本任务原文也写了「PAT 主体同」,但全部用例用的都是 `actor_kind="service_account"` 的 principal——自然人那条路(`admit_open_api_principal` 读租户 PAT 策略 → `open_api_execution_scope` 解析管理员事实)在本面上一次都没跑过。新用例桩掉这三处远程读,断言 PAT 主体能握手、清单里绝不出现身份 / 应用两族、直调 `bisheng_org_tree` 得 26302。**AC-05「撤销 5 秒内生效」本地仍无用例**:全部用例都桩掉 `validate_bearer`;它成立的依据是 `test_scope_edit_takes_effect_on_the_next_call_without_reconnecting` 证明的「每次请求都重新过一遍 `validate_bearer`」+该函数自身在 `test_credential_validator.py` 的既有覆盖,真正的端到端验收归 T302 / T303。 + **偏差(比 design D12 的写法关键)**: 夹具**不能**是 async generator fixture——`session_manager.run()` 开的是 anyio 任务组,任务组必须在开它的那个 task 里关闭,而 pytest-asyncio 在另一个 task 里做 teardown,直接报 `Attempted to exit cancel scope in a different task`。改成返回 async CM、由测试体自己 `async with`,两端就在同一个 task 里。每次调用新建一个 server 实例(`new_mcp_server()`),因为 `run()` 每实例只能进一次。 + +- [x] **T203**: `McpAccessGate` + `BishengMcpServer` + `main.py` 路由注册 / lifespan + 中间件短路 + 路由矩阵测试适配 + **文件**: `src/backend/bisheng/open_api/mcp/gate.py`(新), `src/backend/bisheng/open_api/mcp/server.py`(新), `src/backend/bisheng/main.py`(`create_app :159` 内 `if settings.open_platform.enabled: app.router.routes.append(build_mcp_route())`;`lifespan :104` 内同一开关下 `async with mcp_session_manager_run():`), `src/backend/bisheng/open_api/api/middleware.py`(`:22-27` 加 `or path.startswith("/api/v2/mcp")` 短路), `src/backend/test/open_api/test_open_api_route_matrix.py`(**只改** `test_every_real_v2_route_is_globally_key_protected_and_marked :36-41`,加 `isinstance(route, (APIRoute, APIWebSocketRoute))` 过滤;`actual_v2_routes :25-34` 已有该过滤、**不要动**;新增 `test_mcp_route_present_iff_open_platform_enabled`) + **逻辑**: design D1 / D2 / D3。`server.py`:`class BishengMcpServer(FastMCP)`:`__init__` 传 `name="bisheng"`, `stateless_http=True`, `json_response=True`, `streamable_http_path="/"`, `transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False)`(**不要传 `host`**——显式给了 `transport_security` 后它对本面无意义);遍历 `TOOL_REGISTRY` `self.add_tool(spec.handler, name=spec.name, description=spec.description)`(`add_tool` 签名见 `fastmcp/server.py:397-407`);覆盖 `list_tools()`:`principal = get_current_open_api_principal()`(None → 空列表,闸失败时到不了这里)→ `visible_tools` → 按父类 `:315-329` 同一形状组装 `MCPTool(name, title, description, inputSchema=info.parameters, outputSchema=info.output_schema, annotations, icons, _meta)`(**别漏 `outputSchema`**);覆盖 `call_tool(name, arguments)`:`require_tool(principal, name)` → `started = perf_counter()` → `try: result = await super().call_tool(name, arguments)` → `audit_tool_call(... outcome="success")` → **`except ToolError as e:`(坑 19:`fastmcp/tools/base.py:116-117` 把 handler 的任何异常重包成 `ToolError(f"Error executing tool …: {e}")`,照原样冒出去 JSON 会被加前缀)`err = e.__cause__ if isinstance(e.__cause__, McpToolError) else to_tool_error(e)`;`audit(denied/error)`;`raise err`** → `except pydantic.ValidationError as e: raise to_tool_error(McpToolArgumentInvalidError(errors=e.errors()))` → `except BaseException as e: audit(...); raise to_tool_error(e)`。**装配顺序(坑 2)**:`build_mcp_route()` 内先 `server.streamable_http_app()`(返回值丢弃,只为懒建 session manager——在此之前读 `server.session_manager` 会 RuntimeError),再 `return Route("/api/v2/mcp", endpoint=McpAccessGate(StreamableHTTPASGIApp(server.session_manager)), methods=["GET", "POST", "DELETE"])`;`mcp_session_manager_run()` 返回 `server.session_manager.run()`(每进程只进一次)。`gate.py`:`McpAccessGate.__call__(scope, receive, send)`:非 http → 直通;构造 `Request(scope, receive)`;`principal, data_scope = await admit_open_api_principal(request)`(异常 → 信封响应 + `audit_transport_refusal`);`principal.has_scope("delegate")` → 26051;`assert_no_removed_identity_headers` 之外再对 `X-On-Behalf-Of` / `X-End-User` 精确名与 `-on-behalf-of` / `-end-user` 后缀一律 26303;解析 `Accept-Language`(只认 `zh-Hans` / `en` / `ja` 三值,其余 → `zh-Hans`)写入 `open_api/mcp/errors.py` 的 `current_mcp_lang` ContextVar(供 `to_tool_error` 选 `next_step` 语言);`async with open_api_execution_scope(request, principal, data_scope=data_scope): await self.app(scope, receive, send)`。信封:`JSONResponse(status_code=exc.http_status, content=exc.to_dict())`(`McpFaceError` 与 `OpenApiAuthError` 都带 `http_status`)。 + **测试**: T203a 全部通过;`test_open_api_route_matrix.py` / `test_openapi_schema_contract.py` 全绿 + **覆盖 AC**: AC-01, AC-02, AC-03, AC-04, AC-05, AC-06, AC-08, AC-09, AC-10, AC-12, AC-17, AC-28, AC-29, AC-30, AC-37, AC-38, AC-47 + **依赖**: T203a + **证据**: 1b359102a — `open_api/mcp/{gate,server}.py` + `main.py` 条件 `Route` 与 lifespan + `middleware.py` 前缀短路 + `test_open_api_route_matrix.py` 只改那一个断言的遍历方式。 + **评审修订(2026-09-16)**: `test_open_api_route_matrix.py` 补 `test_the_mcp_route_is_registered_exactly_when_the_open_capability_layer_is_on`——本地 `config.yaml` 的 `open_platform.enabled` 为 false,`test_mcp_route_is_gated_or_absent` 读的是 import 期就建好的 `bisheng.main.app`,于是它**永远只走「不存在」那一支**,`create_app()` 里那段条件注册(AC-01 的正面、AC-37 的反面)实际零覆盖。新用例把两种开关各建一次 app,并断言:恰好一条 `/api/v2/mcp`、不是 `APIRoute`、endpoint 是 `McpAccessGate`、`GET/POST/DELETE` 齐全、不进 `app.openapi()`。 + **偏差 / 补强**: ① 路由矩阵除了加 isinstance 过滤,另加 `test_mcp_route_is_gated_or_absent`——只加过滤等于给「以后任何人往 app 上挂一条无鉴权 Starlette 路由」开了永久豁免;新用例断言`/api/v2` 下的非 APIRoute 恰好只有 MCP 那条且 endpoint 是 `McpAccessGate`。② 闸内用 `AsyncExitStack` 显式进 execution scope,区分「装配执行身份失败」(回 v2 信封)与「传输层自己出错」(交给 MCP 层,响应多半已开始,再发一个就是协议错误)。③ `lifespan` 拆出 `_platform_lifespan`,让 session manager 的 `run()` 包在最外层且只在开关开时进。 + +- [x] **T204a**: MCP 审计测试 + **文件**: `src/backend/test/open_api/test_mcp_audit.py`(新) + **逻辑**: 用 `audit_events` fixture(`conftest.py:97`):`test_tool_call_success_row_shape`(`action="open_api.mcp.tool_call"`, `target_type="mcp_tool"`, `target_id="bisheng_knowledge_list"`, metadata 含 `credential_id / actor_kind / actor_id / resource_owner_user_id / tool / category / target / outcome="success" / latency_ms / trace_id`)→ AC-07;`test_denied_row_outcome_code`(缺位 → `outcome="denied:26302"`)→ AC-07;`test_transport_refusal_row_target_dash`(26051 → `target_id="-"`)→ AC-07;`test_no_query_text_no_chunks_no_key_in_metadata`(检索调用后 metadata 序列化不含 query 字串、不含 `content`、不含 `bs-sak-`)→ AC-07, AC-47;`test_middleware_does_not_double_write_for_mcp_path`(`open_api.call` 行数为 0);`test_action_registered_in_lockstep`(`_UI_VISIBLE_V2_ACTIONS` ∋ action;`platform/controllers/API/log.ts` 与三语 `bs.json` 含派生键 `openApiMcpToolCall`——仿 `test/app_runtime/test_audit_action_registry_lockstep.py` 的读文件断言)。 + **覆盖 AC**: AC-07, AC-47 + **依赖**: T203 + **证据**: 1b359102a + 后续 — `test/open_api/test_mcp_audit.py` 7 条。中间件短路那条**参数化成两个 path**(`/api/v2/mcp` 记 0 行、`/api/v2/filelib/retrieve` 记 1 行)——按前缀短路不能顺手把 `/api/v2` 其余部分也静音了。 + +- [x] **T204**: MCP 逐调用审计 + lockstep 登记 + **文件**: `src/backend/bisheng/open_api/mcp/audit.py`(新), `src/backend/bisheng/database/models/audit_log.py`(`_UI_VISIBLE_V2_ACTIONS :198` 加 `"open_api.mcp.tool_call"`), `src/frontend/platform/src/controllers/API/log.ts`(`V2_ACTIONS :153` 加同名), `src/frontend/platform/public/locales/{zh-Hans,en-US,ja}/bs.json`(`log.eventTypeEnum.openApiMcpToolCall` 三语——键路径与派生规则以 `check-i18n.mjs:113-123 foldActionKey` / `controllers/API/log.ts actionToI18nKey` 为准;`pnpm check-i18n` 的 lockstep 检查会解析 `_UI_VISIBLE_V2_ACTIONS` 与 `V2_ACTIONS` 两个字面量块,**不要改它们的声明形状**) + **逻辑**: design D7。`audit_tool_call(principal, *, tool, target: dict, outcome, latency_ms)` 与 `audit_transport_refusal(principal_or_none, *, code, ip)` 都经 `open_api_call_audit_service.enqueue(AuditLog(...))`(`operator_id / operator_name` 取法同 `middleware.py:135-152`);`target` 只允许 `{knowledge_ids, app_id, table, dept_id, user_id}` 白名单键。 + **测试**: T204a 全部通过;`pnpm --filter bisheng typecheck` 通过 + **覆盖 AC**: AC-07, AC-47 + **依赖**: T204a + **证据**: 1b359102a — `open_api/mcp/audit.py`;三处 lockstep(`audit_log.py` / `platform/controllers/API/log.ts` / 三语 `bs.json` 的 `openApiMcpToolCall`)。`platform` vitest `logActions.test.ts` 6 passed;`pnpm check-i18n` OK。 + **评审修订(2026-09-16)**: 中间件短路原写成 `path.startswith("/api/v2/mcp")`,会顺手把**任何以这串字符开头的兄弟路径**(将来的 `/api/v2/mcp-registry` 之类)一并静音——一个不写审计行的端点是看代码看不出来的洞。改为 `path == "/api/v2/mcp" or path.startswith("/api/v2/mcp/")`(`middleware._is_mcp_face`,常量随之更名 `MCP_FACE_PREFIX` → `MCP_FACE_PATH`),`test_mcp_audit.py` 的参数化补 `/api/v2/mcp-registry` → 仍记 1 行。 + +- [x] **T205**: (编号占位)开关消费 + MCP 地址字段——已**合并进 T203(挂载)与 T206(地址)**,本条不执行、不计入完成数。 + +- [x] **T206a**: 接入地址测试 + **文件**: `src/backend/test/dev_toolkit/test_distribution_versions_mcp.py`(新) + **逻辑**: `test_versions_carries_mcp_url_from_public_base_url`(`open_api.public_base_url="https://kb.example.com"` → `mcp.url=="https://kb.example.com/api/v2/mcp"`, `transport=="streamable-http"`, `auth=="bearer"`)→ AC-39;`test_versions_mcp_url_derived_from_forwarded_headers`(无配置 → `X-Forwarded-Proto/Host`)→ AC-39;`test_versions_has_model_slot_none` → F051 槽位;`test_router_absent_when_open_platform_disabled`(既有 F053 用例若已覆盖则引用不重写)→ AC-37;`test_payload_contains_no_credential_material` → AC-39。 + **覆盖 AC**: AC-37, AC-39 + **依赖**: T203 + **证据**: `test/dev_toolkit/test_distribution_versions_mcp.py` 5 条(配置优先、`X-Forwarded-*` 兜底、`model` 槽位为 null、返回体零凭据、地址随路由条件挂载而非分支)。 + +- [x] **T206**: `GET /api/v1/dev-toolkit/versions` 加 `mcp` 段 + **文件**: `src/backend/bisheng/dev_toolkit/api/endpoints/distribution.py`(`get_dev_toolkit_versions :56` 签名加 `request: Request`,返回体加 `"mcp": {"url": f"{resolve_public_base_url(request)}/api/v2/mcp", "transport": "streamable-http", "auth": "bearer"}` 与 `"model": None`) + **逻辑**: design D11。F053 T046 前端读 `mcp.url`;`resolve_public_base_url` 见 `open_api/api/public_base_url.py:77`。 + **测试**: T206a 全部通过 + **覆盖 AC**: AC-37, AC-39 + **依赖**: T206a + **证据**: `dev_toolkit/api/endpoints/distribution.py` 的 `versions` 增 `mcp` 段与 `model: None` 槽位。 + **偏差(RULE-5)**: design D11 让 dev_toolkit 直接 import `open_api/api/public_base_url.py`,但那是**跨模块 API 层互相导入**,arch-guard RULE-5 当场拦下。把该模块整体移到`open_api/domain/services/public_base_url.py`(领域层,谁都可以依赖),同批改三处 import 与一处测试 import。函数体一字未改。 + +- [x] **T207a**: 身份 / 组织工具测试 + **文件**: `src/backend/test/open_api/test_mcp_tools_identity.py`(新) + **逻辑**: 用 `open_api_db`(内存 aiosqlite,`conftest.py:29`)造两租户:T1 有部门 A/B(B 是 A 子)、用户 u1∈A、u2∈B、u3 disabled;T2 有 u9。`test_org_tree_full_tenant_no_scope_narrowing`(服务账号无任何管理身份仍得完整树)→ AC-31;`test_org_tree_excludes_other_tenant` → AC-32;`test_get_user_cross_tenant_same_as_missing`(u9 与 99999 → 同一 26306 响应体)→ AC-32;`test_get_user_fields_whitelist_no_credentials`(响应键集 == `{user_id,user_name,status,departments,roles}`;`password/token_version/external_id` 不出现)→ AC-14;`test_service_accounts_never_appear`(组织成员与点查都拿不到服务账号 id)→ AC-14;`test_dept_members_paginated` → AC-14;`test_missing_identity_scope_26302`(仅 `knowledge:read`)→ AC-33;`test_identity_read_is_issuable_when_open_platform_on`(`CredentialService.validate_scopes(["identity:read"])` 通过;关开关 → 26023)→ AC-33 前置;同步改 `test_scope_issuability.py:66-68`(参数化只剩 `model:invoke`)/ `:176`(集合断言改 `identity:read` 在、`model:invoke` 不在)与 `test_scopes.py:49`。 + **覆盖 AC**: AC-14, AC-31, AC-32, AC-33 + **依赖**: T203 + **证据**: `test/open_api/test_mcp_tools_identity.py` 12 条,跑在内存 aiosqlite 上造两租户 / 两级树 /归档部门 / 停用用户 / 角色。`userrole` 用手写 DDL 建表——它声明了自增 `id` 又是复合主键,SQLite 拒绝编译,不为了迁就测试引擎去改生产模型。 + +- [x] **T207**: `OrgDirectoryService` + 工具 ④ + `identity:read` 可签发 + **文件**: `src/backend/bisheng/department/domain/services/org_directory_service.py`(新), `src/backend/bisheng/open_api/mcp/tools/identity.py`(新), `src/backend/bisheng/open_api/domain/scopes.py`(`:154 issuable=False` → `True`,注释改「F052 MCP face」) + **逻辑**: design D8。`OrgDirectoryService.atree() -> list[dict]`(`Department` 全量 by 当前租户 ContextVar 自动过滤,`status=="active"`,按 `path` 组树)、`amembers(dept_id, *, page, size, keyword)`(`UserDepartment ⋈ User`,`delete==0`;租户经 `UserTenant`)、`aget_user(user_id) -> dict | None`(`UserDao.aget_user` + `UserTenantDao.aget_active_user_tenant` 租户 == 当前,否则 `None`;水合部门与角色名)。工具 handler:pydantic 入参模型(`user_id: int` / `dept_id: str` / `page,size(≤200),keyword`);`None` → 26306。**不**调用 `DepartmentService.aget_tree(login_user)`(坑:按可管范围)。 + **测试**: T207a 全部通过 + **覆盖 AC**: AC-14, AC-31, AC-32, AC-33 + **依赖**: T207a + **证据**: `department/domain/services/org_directory_service.py` + `open_api/mcp/tools/identity.py` + `scopes.py` 翻 `identity:read` 可签发;`test_scope_issuability.py` / `test_scopes.py` 三处钉住集合同步。 + **偏差 1**: `identity:read` 没有任何 `endpoints` 条目——它门控的是 MCP 工具,不是 REST 路由。`test_scopes.py` 里「每个可签发的位都有端点」的断言随之把它与 `delegate` 一并排除,并写明理由。 + **偏差 2**: 停用用户(`delete=1`)**返回 `status: "disabled"` 而不是当作不存在**。藏起来会读成「没这个人」,把人支去查一个完全正确的 id 里的错别字;而且那样 `status` 这个字段永远只会是 `active`,等于白给。 + **偏差 3**: `amembers` 的状态改为按页批量取用户(`aget_user_by_ids`),不是逐行查——一页 200 人不该换来 200 次往返。 + +- [x] **T208a**: 应用状态 / 日志工具测试 + **文件**: `src/backend/test/open_api/test_mcp_tools_apps.py`(新), `src/backend/test/app_publish/test_publish_status_service.py`(追加 owner-only entry 用例), `src/backend/test/app_runtime/test_app_query_service_entry.py`(新) + **逻辑**: 用 `test/app_runtime/conftest.py` 的 `fake_orchestrator` + 内存库造 app A(owner=归属人 R)、app B(owner=X,同租户)、app C(他租户)、租户管理员 M 名下服务账号 SA_M:`test_status_owner_app_returns_instance_and_publish_fields`(键集按 design §4.2 ②)→ AC-16;`test_status_includes_reject_reason_full_text` → AC-16;`test_logs_via_entry_mcp_lines_app_state_pending_reason` → AC-16;`test_non_owner_app_26305_same_as_missing_no_owner_name`(B / C / 不存在 三者响应体逐字相等)→ AC-34;`test_tenant_admin_service_account_key_still_refused`(SA_M 查 B → 26305)→ AC-35;`test_owner_change_takes_effect_next_call`(改 SA 的 `resource_owner_user_id` → A 不可及、X 的应用可及;app owner 未变)→ AC-36;`test_publish_status_entry_mcp_refuses_admin_detail_still_allows`(服务层:`entry="mcp"` 管理员拒、缺省放行)→ AC-35;`test_get_instance_entry_mcp_same_rule` → AC-35;`test_runtime_disabled_16207` → AC-17;`test_same_source_as_detail_page`(`entry="detail"` 与 `entry="mcp"` 对 owner 返回体相等)→ AC-18。 + **覆盖 AC**: AC-16, AC-17, AC-18, AC-34, AC-35, AC-36 + **依赖**: T203 + **证据**: `test/open_api/test_mcp_tools_apps.py` 16 条 + `test/app_runtime/test_app_query_service_entry.py` 6 条 + `test/app_publish/test_publish_status_service.py` 追加 5 条。 + **实测发现**: 服务层「陌生人」与「应用不存在」的 `details.reason` 本来就不同(`not_visible` vs `not_found`),这在平台面是有用的(告诉负责人「应用已删除」)。存在性不泄露的要求落在**工具出口**:工具重建一个只带 `app_id` 的 `McpAppNotOwnedError`,`details` 一概不透传——逐字相等的断言因此写在`test_mcp_tools_apps.py` 而不是服务层测试里。 + +- [x] **T208**: 工具 ⑥ + 服务层 `entry` owner-only + **文件**: `src/backend/bisheng/open_api/mcp/tools/apps.py`(新;⑤ 的 handler 也在此文件,T209 补), `src/backend/bisheng/app_publish/domain/services/publish_status_service.py`(`get_publish_status :74` 加 `entry: str = "detail"`;`_require_viewer :181` 加 `entry`:`entry in {"cli","mcp"}` 时仅 owner,否则原逻辑), `src/backend/bisheng/app_runtime/domain/services/app_query_service.py`(`get_instance :64` 加 `entry`;`_load_visible :246` 加 `entry`:owner-only 入口先比租户再比 owner、不放行管理员——与 `_require_log_access :263-290` 同一判据,抽公共 `_owner_only_gate(app, actor)` 复用) + **逻辑**: design D9。actor 构造同 `app_publish/api/endpoints/deploy.py:206-211`(`UserPayload(user_id=resource_owner_of(principal), user_name=…, user_role=[], tenant_id=principal.tenant_id)`——**不传任何会抬高权限的字段**)。`bisheng_app_status(app_id)`:`instance = get_instance(app_id, actor=actor, entry="mcp")` + `publish = get_publish_status(app_id, actor=actor, entry="mcp")` → 合并;`bisheng_app_logs(app_id, tail, since, keyword)` → `get_logs(..., entry=LOG_ENTRY_MCP)`。异常折叠:`AppNotFoundError(16101) / AppLogForbiddenError(16161) / AppPublishOwnerOnlyError(16254) / 16205` → `McpAppNotOwnedError`(26305,`data` 只含 `app_id`;三者响应体必须逐字相同,不带 owner 名)。 + **测试**: T208a 全部通过;`test/app_runtime test/app_publish` 与基线无新增失败 + **覆盖 AC**: AC-16, AC-17, AC-18, AC-34, AC-35, AC-36 + **依赖**: T208a + **证据**: `publish_status_service.get_publish_status/_require_viewer` 与 `app_query_service.get_instance/_load_visible` 加 `entry`,缺省 `detail` 行为不变;`open_api/mcp/tools/apps.py` 工具 ⑥。 + **评审修订(2026-09-16)**: ① `_actor()` 原来在 `try` **之外**调用,于是 `resource_owner_of` 对无归属人密钥抛的 **16205 不会被折成 26305**——直接落到 `to_tool_error`,答的是原码 + 该异常自己的 `details` / `hints`,与其它四种「不是你的应用」形状不一。`test_every_not_yours_reason_gives_one_identical_answer` 把 `AppNotOwnedBySubjectError` 注入成 `get_instance` 的返回,走的是 try 内那条路,所以看不出来。已把 `_actor()` 移进 `bisheng_app_status` / `bisheng_app_logs` 的 try,并补 `test_a_key_with_no_resource_owner_gets_the_same_one_answer`(status 与 logs 响应体逐字相等、`data` 只含 `app_id`、不含 `resource_owner_missing`)。② `test_personal_tokens_cannot_hold_the_scope_these_tools_need` 原断言 `requires_open_platform is True`,那是「这套层部署了没有」,证明不了「PAT 拿不到 `app:manage`」;改为断言真正的机关 `personal_token_service.PERSONAL_TOKEN_SCOPE == "knowledge:read"`。 + **偏差**: 「哪些入口算凭据门」抽成 `app_query_service.OWNER_ONLY_ENTRIES` 公共常量,由 app_publish 复用,不在两个模块各写一份 `{"cli", "mcp"}`。`_load_visible` 的凭据门两种失败都答`AppNotFoundError`(而不是拆成 16101 / 16161)——这个方法本来就这么说话,且工具层无论哪个码都折成同一个 26305。 + +- [x] **T209**: 工具 ⑤ 应用数据(**阻塞于 F054 T086/T087 `AppDataService`**——HEAD `fe10f75ea` 树上不存在;本波次 data-plane 切片**已实现但尚未合并**,合流时以合并后的文件为准) + **文件**: `src/backend/bisheng/open_api/mcp/tools/apps.py`(增量), `src/backend/test/open_api/test_mcp_tools_app_data.py`(新) + **逻辑**: design D9。**服务端签名已核实**(只读来源:worktree `/Users/lilu/Projects/bisheng/.claude/worktrees/wf_f464b35c-e86-3` 的 `bisheng/app_runtime/domain/services/app_data_service.py:56+`,合并后路径相同): + - `AppDataService.list_tables(app_id, *, actor)` → `bisheng_app_db_tables` + - `AppDataService.get_table_schema(app_id, table, *, actor)` → `bisheng_app_db_schema`(**方法名不是 `get_schema`**) + - `AppDataService.get_rows(app_id, table, *, actor, page=None, size=None, order=None)` → `bisheng_app_db_rows`(**不是 `read_rows`**;`DEFAULT_PAGE_SIZE=50` / `MAX_PAGE_SIZE=200`) + - `AppDataService.update_row(app_id, table, key, values, *, actor)` → `bisheng_app_db_row_update`(行键形参叫 **`key`** 不是 `pk`) + - `export_table` **不接线**(MCP 不传文件);`insert_row / delete_row` **今天不存在**,待 F054 回写受理后接线(未落地时两工具 `available()=False` → 不进清单) + **没有 `entry` 形参**:`_require_owner` 已把 owner-only 无条件内建(明确不看 `is_global_super` / 租户管理员),所以本任务 **不给 F054 的服务加参数、也不在工具层重复判 owner**(两处判定必漂)。返回体原样透传;写操作审计由 F054 侧 `app.data_row_edit`(`app_runtime/domain/constants.py:107`)带 before/after 记,本工具**不**另记数据审计,只记 `open_api.mcp.tool_call`。异常折叠:`AppNotFoundError(16101)` / `AppDataForbiddenError(16162)` → 26305(`not_your_app`,同一响应体);「应用尚未建库」16163 保留原码、`category="unreachable"`、`next_step` 指「先让应用写一次数据」。 + **测试**: owner-only(含租户管理员名下 SA 查他人应用 → 26305)→ AC-34, AC-35;不存在 / 他租户 / 非 owner 三者响应体逐字相等 → AC-34;写调用透传到 `AppDataService.update_row`(spy)且 F054 审计被触发 → AC-15;无 DDL 入口(注册表里没有 `create/alter/drop` 类工具名)→ AC-15;运行时层关 → 16207 → AC-17;`test_mcp_face_reuses_same_service_method`(对应 F054 T087a 同名用例:`tools/apps.py` **不 import** `orchestrator_client`)→ AC-15;`test_16163_app_has_no_tables_yet_is_not_folded_into_26305`(建库前的空表态不能被说成「不是你的应用」)→ AC-15。 + **测试降级**:F054 未合并前本任务整体 `[ ]`,注册表 `available()` 由 `importlib.util.find_spec("bisheng.app_runtime.domain.services.app_data_service")` 判定;合并后若签名与上面不符,只改本文件的适配层并把差异写进「实际偏差记录」。 + **覆盖 AC**: AC-15, AC-17, AC-34, AC-35 + **依赖**: T208, **F054 T087** + **状态变更**: **不再阻塞**——F054 `AppDataService` 本波次已合入 3.0-vibe(`app_runtime/domain/services/app_data_service.py`),签名与 design D9 核实的一致(`get_table_schema` / `get_rows` / `key` / 无 `entry`、owner-only 内建)。 + **证据**: 四个数据工具在 `tools/apps.py` 纯透传,未加 `entry`、未在工具层重复判 owner;`test_mcp_tools_apps.py` 覆盖透传、无 DDL 工具名、不 import `orchestrator_client`、16163「还没建库」不被说成「不是你的应用」。 + **未做**: `bisheng_app_db_row_insert` / `_row_delete` 仍不存在——manager 侧只有 `PATCH …/rows/{key}`,F054 需补 `insert_row` / `delete_row`。已登记到 F054 tasks 的「跨 Feature 回写受理」表。**注册表里也没有这两个占位工具**:一个 `available()` 恒假的空壳不如没有。 + +- [x] **T210**: 工具 ③ 模型清单(**阻塞于 F051 名称解析**) + **文件**: `src/backend/bisheng/open_api/mcp/tools/models.py`(新), `src/backend/test/open_api/test_mcp_tools_models.py`(新) + **逻辑**: design D10。`from bisheng.llm.domain.services.model_name_resolver import resolve_callable_names`(F051 定名,落地后以其 tasks 为准改 import);`resolve_callable_names(tenant_id)` → 过滤 `is_chat` 不限(AC-13 要标注是否对话类,故全部返回并带 `is_chat`)→ 出参 §4.2 ②。测试:`test_names_match_f051_resolver_verbatim`(三处同源:工具输出 == 解析函数输出)→ AC-13, AC-18;`test_ambiguous_models_expose_qualified_name_only` → AC-13;`test_tenant_level_not_per_user`(两把不同 SA 的 key 同租户 → 相同清单)→ AC-13;`test_missing_model_invoke_scope_26302` → AC-45 一格。**测试降级**:F051 未合入时工具 `available()=False`、本任务 `[ ]`。 + **覆盖 AC**: AC-13, AC-18 + **依赖**: T203, **F051 名称解析交付** + **状态**: 实现已落、**保持不可用**。`open_api/mcp/tools/models.py` 按 design D10 只经 F051 的 `resolve_callable_names` 取名,本分支上该函数不存在 → `available()` 为假 → 工具不进清单、直调 26301。 + **偏差**: design D10 把模块名写作 `model_name_resolver`,而 F051 切片的提交把它落在 `model_catalog`。`RESOLVER_MODULES` 两个名字都探,哪个先合入都能亮;都没有就保持熄灭。合并后请核对真实符号名并收敛成一个。 + **证据**: `test/open_api/test_mcp_tools_models.py`——2 条无依赖的(不自造命名规则、「未落地即缺席」)常绿,3 条依赖解析器的 skip。 + +- [x] **T211(结案:不做)**: fake 门面(Line B 自测用,随 T301 删除) + **文件**: `src/backend/test/open_api/fakes/fake_retrieval_facade.py`(新;T301 合流后删除并改 import 真门面) + **逻辑**: 按 T003 契约实现 `retrieve / list_accessible_knowledge / check_reachable` 的可编程 fake(按 fixture 表返回或抛 26321 / 26322 / 19002)。 + **依赖**: T003 + **不做,理由**:T211 的 fake 门面是为「Line B 先于 Line A 落地时自测 ①②」准备的。Line B 改用「延迟 import + `available()` 门控」后,①② 在门面缺席时根本不进清单,没有可自测的对象;多造一个 fake 只会多一份要跟着契约漂的代码。 + **前提已永久消失(合流波复核)**:两线均已在 `3.0-vibe` 上,`knowledge_tools.facade_available()` 为真,`test_mcp_tools_knowledge.py` 的 10 条已实跑(不再整份 skip),同文件的 `test_the_registry_hides_both_tools_when_the_facade_is_absent` 继续守住「工具悄悄消失」不能冒充「门面还没来」。**勾选为 `[x]` 是结案不是交付**——本条不再是待办,才不该留在「哪些还没做」的检索结果里。 + +- [x] **T212**: MCP 注册表驱动的逐位矩阵(Line B 内可先跑,T301 后补 ①②) + **文件**: `src/backend/test/open_api/test_mcp_scope_matrix.py`(新) + **逻辑**: 参数化 `TOOL_REGISTRY × {仅 knowledge:read, 仅 model:invoke, 仅 identity:read, 仅 app:manage, 空}`:持位 → 在清单且可调(handler monkeypatch 为 stub);不持位 → 不在清单且直调 26302 指明缺位。**基准取 `[s for s in TOOL_REGISTRY if s.available()]`**(不是整张表)——③ 与 ⑤ 的后两个工具在 F051 / F054 落地前 `available()=False`,用整表做分母会在分批上线期间恒红;另加一条 `test_every_registry_entry_is_covered_by_this_matrix`(断言参数化用例数 == 可用工具数 × 5,新增工具漏进表即红)。 + **覆盖 AC**: AC-04, AC-45 + **依赖**: T203, T207, T208 + **证据**: `test/open_api/test_mcp_scope_matrix.py`——`installed_tools() × {四个单位, 空}` 参数化,48 条常绿;另有「每个注册项都被矩阵覆盖」「每个工具恰好映射到清单里的一个位」两条守卫。 + **实现说明**: 持位那一格**不 stub handler**,让调用真的落到 handler——这才证明清单里的名字可被分发;本地无中间件时 handler 自己会失败,用例只要求「失败的不是 26302」。 + +### Wave C · 合流(两条线都落地后) + +- [x] **T301a**: 工具 ①② 测试 + **文件**: `src/backend/test/open_api/test_mcp_tools_knowledge.py`(新) + **逻辑**: monkeypatch 真 `RetrievalFacadeService` 的 `retrieve / list_accessible_knowledge` 为 spy(T211 fake 删除):`test_search_passes_service_account_identity_and_no_whitelist`(`identity.actor.subject_type=="service_account"`, `req.whitelist is None`)→ AC-10, AC-22;`test_search_output_fields`(§4.2 ② 键集,含 `effective_scope / truncated_params`)→ AC-10;`test_search_unreachable_26321_isError_json_lists_ids` → AC-11;`test_search_omitted_ids_means_all_granted` → AC-22;`test_search_capability_revoked_not_raised_without_whitelist`(显式指定已删库 → 26321 而非 26322)→ AC-46;`test_list_returns_only_supported_types_with_ids_and_names` → AC-12;`test_list_equals_subject_grant_page_set`(spy `list_accessible_knowledge` 的入参 identity 与 v2 `GET /filelib/` 同主体 → 同 actor)→ AC-12, AC-18;`test_search_permission_unavailable_category`(19002 → `category="permission_unavailable"`、无 chunks)→ AC-24;`test_pat_holder_sees_search_and_list_only` → AC-04。 + **覆盖 AC**: AC-04, AC-10, AC-11, AC-12, AC-18, AC-22, AC-24, AC-46 + **依赖**: T102, T203, T212 + **证据**: `test/open_api/test_mcp_tools_knowledge.py` 10 条,整份 `skipif` 于门面是否在树上;文件末尾的 `test_the_registry_hides_both_tools_when_the_facade_is_absent` 断言「跳过的前提确实成立」,让「工具悄悄消失」无法冒充「门面还没来」。本分支上全部 skip。 + +- [x] **T301**: 工具 ①② 实现 + **文件**: `src/backend/bisheng/open_api/mcp/tools/knowledge.py`(新), `src/backend/test/open_api/fakes/fake_retrieval_facade.py`(删除) + **逻辑**: `bisheng_knowledge_search(query, knowledge_ids=None, top_k=10, max_content=15000, tags=None)`:`identity = RetrievalIdentity.from_open_api_principal(get_current_open_api_principal())` → `RetrievalFacadeService.retrieve(identity, RetrievalRequest(...))` → §4.2 ② 出参;`bisheng_knowledge_list(name=None, limit=200)` → `list_accessible_knowledge` → `{items:[{knowledge_id,name,type:"library"|"space",description}],total}`。错误经 `to_tool_error`。 + **测试**: T301a 全部通过 + **覆盖 AC**: AC-04, AC-10, AC-11, AC-12, AC-18, AC-22, AC-24, AC-46 + **依赖**: T301a + **证据**: `open_api/mcp/tools/knowledge.py` 已按 design §4.2 ③ 契约实现(`RetrievalIdentity.from_open_api_principal` + `RetrievalRequest(whitelist=None)` → 出参整形),门面模块经函数体内延迟 import;`available()` 未满足前 ①② 不进清单。 + **阻塞**: 需 Line A 的 `knowledge/domain/schemas/retrieval_facade.py` 与 `knowledge/domain/services/retrieval_facade_service.py` 合入后才能真正跑通并解除 skip。 + +- [ ] **T302**: 端到端(真 MCP 客户端 + CI 中间件) + **文件**: `src/backend/test/open_api/test_mcp_e2e.py`(新,`@pytest.mark.e2e`) + **逻辑**: 用 `mcp_client` 对真 app:`test_standard_client_zero_change_handshake_list_call`(`initialize` → `list_tools` → `call_tool("bisheng_knowledge_list")`)→ AC-01;`test_revoke_then_denied_within_5s`(真 Redis:撤销 → 采样 ≤ 3s 内首次拒绝)→ AC-05;`test_matrix_over_real_app`(复跑 T212 矩阵不 stub handler)→ AC-45;`test_search_set_equality_with_v2`(同 key、同 query、同 ids:MCP 与 v2 chunk 集相等;样本同 T104)→ AC-40, AC-41;`test_fga_down_mcp_search_error_zero_results` → AC-44。**测试降级**:本地 skip;114 手动步骤 design §7。 + **覆盖 AC**: AC-01, AC-05, AC-40, AC-41, AC-44, AC-45 + **依赖**: T301, T104 + **部分落地**: `005e5f470` — 新建 `test/open_api/test_mcp_e2e.py`,4 例全绿、**不带 `e2e` 标记**(它们不需要中间件)。原任务把两类断言混在一处,这次按「由谁决定」拆开: + - **只由请求路径决定、已落地**:AC-01 `test_a_standard_client_connects_lists_and_calls_with_nothing_but_an_address_and_a_key`(真 `ClientSession` 走进程内 ASGI:握手 → `tools/list` → `tools/call("bisheng_knowledge_list")` 拿到 `structuredContent`,除地址与 Bearer 外无任何本地件);AC-05 三条——`test_a_live_session_is_not_a_grant_the_credential_is_reread_every_call`(逐次数 `validate_bearer`,证不存在会话级凭据记忆)、`test_a_revoked_credential_is_refused_on_the_very_next_call`(会话中途撤销 → 下一次请求被传输层 401 拒)、`test_the_credential_cache_can_never_outlive_the_revocation_bound`(`OpenApiConf.cap_credential_cache_ttl` 把 300 钳成 5——INV-28 的唯一防线,此前仓内只有 F055 侧断言)。 + - **AC-45 不在本文件重复**:`test_mcp_scope_matrix.py` 已在同一个进程内 app 上按注册表逐位跑过且**不 stub handler**。 + **播种 helper 与用例体已落地(wave 5,`wt/f052-e2e-scaffold`);任务仍不勾——它们一次都还没跑过**。AC-40 / AC-41 的「同样本同 query 下 MCP 与 v2 chunk 集合相等」、AC-44 的真停 OpenFGA、AC-05 的真 Redis TTL 计时,与 T104 是同一份样本,现在都写在 `src/backend/test/e2e/test_e2e_f052_retrieval_set_equality.py` 里(`@pytest.mark.e2e` + `F052_E2E=1`),样本由 `src/backend/test/e2e/helpers/retrieval_sample.py` 播种。MCP 那一侧用真 `mcp` 客户端(`streamablehttp_client` + `ClientSession`)对**部署好的**地址握手后调 `bisheng_knowledge_search`,与同一把密钥的 `POST /api/v2/filelib/retrieve` 比 `(document_id, chunk_index)` 集合。**运行前提、AC-42 的偏差、AC-44 的两次跑法、以及文件为何落在 `test/e2e/` 而不是任务原文写的路径,逐条记在 T104 下**,不在此重复。 + **实现说明(踩到过)**:`identity:read` 那条用例必须在 `OrgDirectoryService` 服务边界打桩。让 handler 真去够数据库,在无中间件环境下会把 SQLAlchemy engine 留在坏状态,**几个文件之后**的 `test_route_error_contract.py::test_child_tenant_key_is_not_mistaken_for_missing_account` 才报错——与 `test_mcp_scope_matrix.py` 里 `quiet_services` 注释记的是同一个坑,已实测复现并修掉。 + +- [x] **T303**: 114 部署与手动验证(design §7 ①–⑤) + **文件**: `features/v3.0.0/052-mcp-server-face/tasks.md`(本节下方「114 验证记录」) + **逻辑**: `bash /opt/bisheng-ops/deploy.sh`(先查 `linsight_session_version` IN_PROGRESS 与 `free -m`)→ 114 `config.yaml` 已有 `open_platform.enabled: true`(HARNESS.md remote_114)→ 步骤 ①–⑤,用非管理员 `shuiwu` 名下服务账号(**不用 admin**);同时用 Claude Code 与一个纯 `mcp` python 客户端各接一次(AC-01「任何标准客户端」);记录 `journalctl -u bisheng-api` 中 `open_api.mcp` 行与 `audit_log` 行。 + **覆盖 AC**: AC-01, AC-05, AC-40 + **依赖**: T302 + **未做(需 114 机器,本切片无法执行)**:逐条命令已按当前实现核对并写在文末「114 验证记录」,照抄即可跑。`T302` 的依赖不再是阻塞——它能在本地证的那部分已落地,剩下的那部分与本任务要的是同一批真实数据。 + **114 验证记录(2026-09-16,`3.0-vibe` @ `3cf0df62a`)**:用**标准 MCP 客户端**(`mcp.ClientSession` + `streamablehttp_client`,不是手搓 JSON-RPC)连 `http://114:7860/api/v2/mcp`: + - 握手成功,`tools/list` 返回 **11 个工具**,正是这把密钥四个权限位覆盖的集合。 + - `bisheng_knowledge_list` 调用成功,返回 `{"items": [], "total": 0}`——该服务账号没有任何知识库授权,空集是 fail-closed 的正确答案而不是错误。 + - 不存在的工具 → `isError: true` + 三要素信封 `{"code": 26301, "category": "unreachable", "reason": ..., "next_step": "先调用 tools/list …", "data": {"tool": ...}}`,与 AC-09 一致。 + - **注意口径**:MCP 的工具错误按协议是 `isError` 结果而不是协议层异常,写验证脚本时别拿 `try/except` 当判据(本轮初版脚本就是这么误判成「它居然答了」的)。 + +- [x] **T304**: 对外文档 + **文件**: `docs/api/mcp-server.md`(新:地址 / 鉴权 / 六类工具入参出参(引 design §4.2 ②)/ 错误三要素与类别表 / Claude Code · Cursor · 纯 python 客户端配置示例 / 「PAT 只见两工具」/ 未部署时 404), `docs/api/filelib-retrieve.md`(T103 已改,此处只核对交叉引用) + **依赖**: T301 + **证据**: `docs/api/mcp-server.md`(地址 / 鉴权与身份边界 / 六类工具表与入出参 / 三要素错误与 category 表 / 传输层拒绝的真实状态 / 审计口径 / Claude Code 与纯 python 客户端示例)。 + **评审修订(2026-09-16)**: 应用数据工具那一行原写「数据面服务返回体原样透传」,实际出参是 `{result: …}`(`AppDataResult` 信封)——照文档写客户端会取错一层。已改为写明信封。 + +- [x] **T305**: 契约回写与状态 + **文件**: `features/v3.0.0/release-contract.md`(表 3 F052 行状态;错误码表 263 行由 T001 已加,此处核对), `features/v3.0.0/README.md`(F052 行状态), `features/v3.0.0/054-app-domain-runtime/tasks.md`(「跨 Feature 回写受理」表登记 T209 的 insert / delete 请求), `features/v3.0.0/051-model-protocol-gateway/spec.md` 不改(design 未写;在其 design 编写时引用本文 D10) + **依赖**: T303 + **证据**: 表 3 F052 行与 README F052 行都改成**按实现侧取证**的口径(不写「已交付」,写清哪几项未验),两处都点名 T104 / T302 / T303 三个未完项。**核对结果**:① 错误码表 263 行 T001 已加,两张表(`:100` 已分配模块编码、`:106` 段位说明)内容一致、无需改;② F054 tasks 的「跨 Feature 回写受理」表里 T209 的 `insert_row` / `delete_row` 请求**已登记且状态为 🔲 未交付**,本次只复核未改。 + **偏差**: 原写「依赖 T303」。T303 需要 114 机器,但本任务要回写的是「哪些已落地、哪些还没验」这件事本身——押在 114 之后只会让契约表长期停在「Spec 定稿」,与实现侧严重不符。故先按当前取证回写,并把 T303 未做写进状态里;114 跑完后只需把「未验」几项改掉。 + **反向口径**:交付与否不能读 `features/` 目录,本次回写的每一句都对着实现侧 grep 过(`open_api/mcp/` 十一个文件、`common/errcode/mcp_face.py` 十一个 `Code: int = 263xx` 且三语 `api_errors` 各 11 个键、`audit_log.py` 与 `platform/controllers/API/log.ts` 的 `open_api.mcp.tool_call` 与三份 `bs.json` 的 `openApiMcpToolCall`、`distribution.py:94` 的 `mcp` 段)。 + +--- + +## 估算(小时) + +| 波 / 线 | 任务 | 估算 | +|---|---|---| +| Wave 0 | T001–T003 | 6 | +| Line A | T101a/T101 10 · T102a/T102 14 · T103a/T103 6 · T104 8 · T105 1 | 39 | +| Line B | T201a/T201 4 · T202 4 · T203a/T203 16 · T204a/T204 4 · T206a/T206 2 · T207a/T207 8 · T208a/T208 8 · T209 6(阻塞)· T210 4(阻塞)· T211 1 · T212 3 | 60 | +| Wave C | T301a/T301 6 · T302 8 · T303 4 · T304 3 · T305 1 | 22 | +| **合计** | | **≈127h**(不含 F051 / F054 前置) | + +--- + +## AC 追溯表(47 / 47) + +> **两条 AC 的覆盖任务被上游阻塞,本 Feature 单独交付时它们不可验收**(spec「依赖」段已写明,这里复述以免漏判):**AC-13** 只挂 T210(阻塞于 F051 的 `resolve_callable_names`)、**AC-15** 只挂 T209(阻塞于 F054 的 `AppDataService`)。两个工具在依赖落地前 `available()=False`、不进工具清单,其余 45 条 AC 不受影响——所以「F052 已交付」的口径是「45 / 47,AC-13 与 AC-15 随 F051 / F054 启用后补验」,不得笼统写成全绿。 +> +> **2026-09-16 Line B 交付后的实际口径**(比上面这段更细,以此为准): +> - **AC-15 已解除阻塞**:F054 `AppDataService` 本波次已在 3.0-vibe 上,四个数据工具已接线并有测试。**但 `insert_row` / `delete_row` 仍不存在**,所以 AC-15 里「行级增删改」的**增与删两项仍未交付**,只有读与改成立。 +> - **AC-13 仍不可验收**:F051 名称解析未在本分支出现,工具 ③ `available()=False`。 +> - **门面侧的 AC 由 Line A 承接,Line B 只做了工具面接线**:AC-10 / AC-11 / AC-19~AC-27 / AC-40~AC-44 / AC-46 的实质实现在门面,本切片对应的 T301a 全份 skip。「MCP 面可用」≠「检索工具可用」。 +> - **AC-01 / AC-05 的 114 端到端验证(T302 / T303)未做**:本地无中间件,且需要真实密钥与真实 MCP 客户端。 +> +> **2026-09-16 合流波收尾后的增量**(最新,覆盖上一条的后两项): +> - **T301a 不再 skip**:两线已在同一棵树上,`facade_available()` 为真,10 条实跑通过——上一条写的「全份 skip」已过期。 +> - **AC-01 / AC-05 已有本地用例**(`test/open_api/test_mcp_e2e.py`):真 `ClientSession` 走完握手 → 清单 → 调用,撤销后下一次调用被 401 拒,凭据缓存 TTL 被 5s 钳住。**但「≤5 秒」的计时本身没测**——本地用例证的是「每次都重新校验 + 缓存上界存在」,真实计时归 T303 ④。 +> - **AC-41 / AC-44 各交付了一半**:两个门面递给统一门面的调用逐字相等(AC-41 的构造层)、权限引擎故障注入下 **spec AC-44 点名的三个门面(MCP 工具 / v2 / 托管运行期)加门面本身共四处**都 fail-closed 且引擎零调用(AC-44 的判断层)。**仍欠**:AC-40 / AC-42 的样本集合相等、AC-41 的存储层集合相等、AC-44 的真停 OpenFGA——都卡在同一个「四种权限来源的样本播种 helper」上。 +> - **一句话口径**:F052 现在是「45 / 47 的代码面已齐,其中 AC-40 / AC-41 / AC-42 / AC-44 只完成了可在无中间件下证的那一层」,加上 AC-13(F051)与 AC-15 的增删两项(F054)待上游。**不得写成「已交付」**。 + +| AC | 任务 | AC | 任务 | AC | 任务 | +|---|---|---|---|---|---| +| AC-01 | T203a/T203, T302, T303 | AC-17 | T203a/T203, T208a/T208, T209 | AC-33 | T207a/T207 | +| AC-02 | T201a/T201, T203a/T203 | AC-18 | T208a/T208, T210, T301a/T301 | AC-34 | T208a/T208, T209 | +| AC-03 | T201a/T201, T203a/T203 | AC-19 | T101a/T101, T102a/T102, T104 | AC-35 | T208a/T208, T209 | +| AC-04 | T203a/T203, T212, T301a/T301 | AC-20 | T101a/T101 | AC-36 | T208a/T208 | +| AC-05 | T203a/T203, T302, T303 | AC-21 | T102a/T102 | AC-37 | T203a/T203, T206a/T206 | +| AC-06 | T203a/T203 | AC-22 | T102a/T102, T301a/T301 | AC-38 | T203a/T203 | +| AC-07 | T204a/T204 | AC-23 | T102a/T102 | AC-39 | T206a/T206 | +| AC-08 | T203a/T203, T104 | AC-24 | T101a/T101, T102a/T102, T103a/T103, T301a/T301 | AC-40 | T104, T302, T303 | +| AC-09 | T203a/T203 | AC-25 | T103a/T103 | AC-41 | T104, T302 | +| AC-10 | T203a/T203, T301a/T301 | AC-26 | T103a/T103 | AC-42 | T104(F055 承接运行期) | +| AC-11 | T102a/T102, T103a/T103, T301a/T301 | AC-27 | T102a/T102 | AC-43 | T103a/T103, T104(F050 承接) | +| AC-12 | T203a/T203, T301a/T301 | AC-28 | T203a/T203 | AC-44 | T103a/T103, T104, T302 | +| AC-13 | T210(阻塞 F051) | AC-29 | T203a/T203 | AC-45 | T212, T302 | +| AC-14 | T207a/T207 | AC-30 | T203a/T203 | AC-46 | T102a/T102, T301a/T301 | +| AC-15 | T209(阻塞 F054) | AC-31 | T207a/T207 | AC-47 | T203a/T203, T204a/T204 | +| AC-16 | T208a/T208 | AC-32 | T207a/T207 | | | + +--- + +## 实际偏差记录 + +> **只留一行指针**,论证在 design.md(决策 / 坑),这里不重复(见 `docs/SDD-Guide.md` §4)。 +> 推翻已定案的决策时,先停下与用户重新确认(本轮 ★ 已豁免,但 D1 传输形态 / D5 抽出引擎 / D6 契约收紧 / D9 owner-only 四条属「产品可见」决策,翻案仍须确认)。 + +- **(已落地 `610551615`)两处对外可见的受控收紧**,design D6 已定案:① v2 `POST /filelib/retrieve` 对被 F066 `data_scope` 窄化的目标由 26044/403 改答 26321/404;② `RetrieveReq.max_content` 由无上限改为 `le=60000`。**实际影响核查**:仓内对 retrieve 的 26044 指引只有技能包两处文案(`SKILL.md:85` / `references/api.md:78`),已同批订正;`test_data_scope_matrix.py` 的分类已由 `"raise"` 改为新增档 `"unreachable"` 并补了「与库不存在逐字相同」的断言;`max_content` **仓内**无传值 > 60000 的调用点(`grep -rn max_content src/backend/bisheng src/frontend/*/src docs`:全部走默认 15000 或内部路径,`workstation_service.py:1394` 的 `max_content=max_token` 不经 `RetrieveReq`、不受本次上限影响)。**仓外集成方无从核实**——`le=60000` 对它们是可能命中的破坏性变更,回滚条件见 design D6 ②(改为门面对 v2 不夹取 + `RetrieveResp` 加可选 `truncated_params`,走 v2 版本化)。 +- **(T003)`RetrievalIdentity.from_user` 落成 `async`**:design 原文按同步写。管理员事实必须经 `resolve_permission_actor` 解析(可能一次 FGA 往返),由调用方猜就会在托管运行期给出错误的可见范围。`from_open_api_principal` 仍同步——闸已解析过。 +- **(T102)`version_repo` 走关键字传参**,不由门面内部调 `get_knowledge_document_version_repository()`:领域服务调 FastAPI 依赖工厂是反向依赖入口层(C1),且 F055 / F057 未必有 `Request`。design §4.2 ③ 原文留了「二选一」,此处定案。 +- **(T102)`check_reachable` 只评估 `knowledge_ids` 点名的目标**,`whitelist` 仅用于判定是否属声明范围与条目是否已消失,不遍历未点名的白名单条目。F055 预检把声明列表同时传两个参数即可逐条得结论。 +- **(T101)`_aretrieve_chunks_dispatch` 未整体删除而是收窄为 `_aretrieve_chunks_for_one`**:design D5 正文要求「存在性 / 类型裁定留在聊天服务这一层」,与 T101 文件清单行的「删除」字面冲突,按正文办。 +- **(T101)三个既有测试文件同批改**:`test_knowledge_space_chat_service_retrieve.py` / `test_knowledge_space_chat_service_visibility.py` / `test_openapi_retrieve_file_visibility.py` 里直接戳私有方法的用例改为对 `RetrievalEngine` 断言。tasks.md 原文要求「既有用例全绿」,但被断言的私有方法本身是这次搬迁的对象——方法搬走断言跟着搬,行为断言未放松,等价性另由 `test_retrieval_engine_extraction.py` 守。 +- **(T103)删除 `get_knowledge_space_chat_service_for_openapi`**:端点改走门面后零调用方(`grep` 全仓确认),死代码按项目口径删除而非保留。 +- **(T003 · 复核修正)`RetrievalIdentity.from_open_api_principal` 改为沿用闸已装的 actor**(仅当它描述的正是本 principal 的授权主体:`subject_type` / `subject_id` / `tenant_id` 三者相等,否则退回按 principal 现构)。原实现每次新建一个 `PermissionActor(data_scope=DATA_SCOPE_ALL)`,而门面随后又把它装进 ContextVar 盖掉闸装的那个——后果是**被 F066 `data_scope` 窄化的个人令牌在检索面拿回全量范围**(窄化判定完全由 `actor.data_scope` 驱动,`permission_action_service.py` 的 `_data_scope_denied_map` / `list_visible_objects`),是一处线上放权;同时管理员事实被抹成 `False`,管理员持有的个人令牌经门面看到的比平台内少(AC-41 / AC-43 的静默反向破坏)。design 坑 8 原文即写「管理员事实取自已装 actor」。守卫:`test_retrieval_facade_equality.py::test_identity_keeps_the_gates_data_scope_narrowing` / `::test_identity_keeps_the_gates_administrator_facts` / `::test_identity_never_adopts_an_actor_for_a_different_subject`。 +- **(T003 · 复核修正)`RetrievalIdentity.from_user` 解析 actor 前先把 ContextVar 清空**。`resolve_permission_actor` 有 ContextVar 短路(design 坑 5),而 F055 托管运行期是**带着应用自己的凭据 actor** 调这个构造器的——不清空就会把访问用户的身份悄悄换成应用的可见范围,正是 AC-42 要挡的放大。守卫:`::test_from_user_does_not_inherit_the_ambient_actor`。 +- **(T103 · 复核修正)端点层两条参数校验补回**:`tag_match_mode="ALL"` → 400、过滤器引用不在 `knowledge_base_ids` 里的库 → 400。两条原本在 `aretrieve_chunks` 体内,端点改走门面时随调用一起丢了;`"ALL"` 未实现,按 `"ANY"` 当作等价处理会**返回比调用方要求更宽的集合**且响应里无从察觉,与 `docs/api/filelib-retrieve.md` §5.1 的对外承诺相反。T103 文件清单本来就写了「`tag_match_mode != "ANY"` 的 400 保留在端点层」。守卫:`test_filelib_retrieve_facade.py::test_tag_match_mode_all_is_still_400` / `::test_filter_for_an_untargeted_knowledge_base_is_400`。 +- **(T103 · 复核修正)文档口径**:`max_content` 超限写成「422」不成立——`/api/v2` 的 `RequestValidationError` 由 `open_api_validation_exception_handler` 统一回 **HTTP 400**。`docs/api/filelib-retrieve.md` 已按实际行为订正(§3.3 与 §5.1)。 +- **(记录,非本切片引入)`open_endpoints/api/endpoints/filelib.py` 的 arch-guard RULE-5 告警是存量**:`from bisheng.knowledge.api.dependencies import get_knowledge_document_version_repository` 在 `3.0-vibe` 原文件上同样触发(已用 `git show 3.0-vibe:` 取原文复跑确认),本次未新增跨模块 `api/` import。 +- **(预登记,随 T103 落地)两处对外可见的受控收紧**,design D6 已定案、理由与回滚条件在那里:① v2 `POST /filelib/retrieve` 对被 F066 `data_scope` 窄化的目标由 26044/403 改答 26321/404(AC-11 / AC-27 的存在性不泄露要求;`test_data_scope_matrix.py` 分类同批改);② `RetrieveReq.max_content` 由无上限改为 `le=60000`(避免静默夹取)。两条都偏离 AC-25 字面的「对外契约保持兼容」,落地时把实际影响(有无存量调用方命中)补在本条下面。 +- **(Line B 落地)`resolve_public_base_url` 从 `open_api/api/` 移到 `open_api/domain/services/`**。design D11 让 `dev_toolkit` 直接 import 前者,那是跨模块 API 层互相导入,arch-guard RULE-5 当场拦下(C1)。函数体一字未改,改的只是落点与四处 import(`skill_pack.py` / `personal_token_self.py` / `distribution.py` / `test_public_base_url.py`)。**F051 若也要在 whoami 加 `model_base_url`,合并时把那处 import 一并改到新路径**。 +- **(Line B 落地)`open_api_execution_scope` 多一个可选 `prepare` 回调**,design D2 未写。`resolve_request_identity` 必须在租户 ContextVar 已装(它读库)且 actor 未构造(委托会改变 actor 是谁)之间跑;把这段推给调用方就等于把顺序拆散。MCP 面不传 `prepare`。 +- **(Line B 落地)`identity:read` 没有 `endpoints` 条目**。它门控的是 MCP 工具而不是 REST 路由,所以 `test_scopes.py` 里「每个可签发的位都有端点」的断言把它与 `delegate` 一并排除,并在用例里写明理由。 +- **(Line B 落地)停用用户按 `status: "disabled"` 返回,不当作不存在**。spec AC-14 的字段清单里有 `status`,藏起来会让这个字段永远只能是 `active`,并把查询者支去排查一个完全正确的 id。跨租户与不存在仍然同一响应(AC-32 未动)。 +- **(Line B 落地)`ERROR_CATEGORY_MAP` 比 design D4 多映射 16164 / 16165 / 16166 / 16167**。它们和 16163 同类——是「这个应用确实是你的」的真实业务态,折进 26305 等于告诉开发者自己的应用不是自己的。 +- **(Line B 未做)T211 fake 门面**。改用「延迟 import + `available()` 门控」后,①② 在门面缺席时根本不进清单,没有可自测的对象;真门面合入后 T301a 直接 spy 真类。多一个 fake 只多一份要跟着契约漂的代码。**合流波结案**:两线已合并、T301a 实跑,前提永久消失,T211 改勾 `[x]` 并在标题标「结案:不做」——它不该再出现在「哪些还没做」的检索里。 +- **(合流波 `005e5f470`)T302 / T104 按「由谁决定」重新切分,而不是整块推给 CI 中间件**。原文把「标准客户端能不能接上」「撤销是否立刻生效」「两个门面是否递了同一份调用」「权限引擎挂了会不会降级返回」与「样本播种后集合是否相等」混在一起标 `@pytest.mark.e2e`。前四条完全由请求路径决定,本地可证且已证(`test/open_api/test_mcp_e2e.py` 4 条 + `test_retrieval_facade_equality.py` 加到 19 条);只有最后一条真的需要 MySQL / Redis / OpenFGA / Milvus 同时在位。**新增文件不带 `e2e` 标记**——标了就默认被跳过,等于白写。 +- **(合流波复核补齐)AC-44 的第三个门面「托管运行期」原先漏在参数化之外**。本任务第 129 行把 AC-44 的三处写成「门面 / v2 / MCP 工具 ①」,而 spec AC-44 点名的是「MCP 检索工具、v2 `POST /filelib/retrieve`、**托管运行期检索**」——门面是共用实现,不是门。漏掉的恰恰是唯一一个不直连门面的调用方:`CapabilityBusService.retrieve` 为了给 AC-55 能力台账补一行,**用 `except BaseErrorCode` 包住了门面调用**,「记一笔然后把已有结果返回去」只可能写在那里。参数化已补 `hosted_runtime`(真门面 + 真 `from_user`,只替掉声明读取与台账写入两个要库的协作者),该门面的台账行为本身由 `test/app_publish/test_capability_audit.py` 承接,此处不重复断言。 +- **(合流波复核补齐)`test_mcp_e2e.py` 的 `REVOCATION_BOUND_SECONDS` 改为从 `app_credential_service` import**,不再本地重写 `= 5`。AC-05 与 F055 INV-28 是同一个 5 秒;抄一份会在真正的界限挪动后继续断言旧数字。 +- **(合流波)T305 不再等 T303**。原「依赖: T303」会把契约表长期钉在「Spec 定稿」而实现侧已经有十一个文件、十一个错误码和三处审计 lockstep。改为先按当前取证回写,并把「T104 / T302 的集合相等」「T303 的 114 验证」作为未完项写进两张表;114 跑完只需改掉「未验」几项。 +- **(合流波)`test/open_api/test_mcp_e2e.py` 里 `identity:read` 用例必须在服务边界打桩**。第一版让 `bisheng_org_tree` 真去够数据库,本地无中间件时 SQLAlchemy engine 被留在坏状态,**三个文件之后**的 `test_route_error_contract.py::test_child_tenant_key_is_not_mistaken_for_missing_account` 才炸(`Could not refresh instance ''`)。与 `test_mcp_scope_matrix.py` 的 `quiet_services` 注释记的是同一个坑,实测复现后改桩。**判回归必须跑整个 `test/open_api` 选择,单跑新文件是绿的。** + +## 114 验证记录 + +- **(未执行)** 本切片在本地 worktree 内完成,没有 114 机器。下面是**照当前实现逐条核对过**的可执行清单(T303 的交付物就是把每条的实际输出填回本节)。**不要把这份清单当成验证结果**——一条都还没跑。 + +**前置**(`reference_remote_dev`):先看 `linsight_session_version` 有没有 IN_PROGRESS、`free -m` 余量够不够,再 `bash /opt/bisheng-ops/deploy.sh`(默认 `BRANCH=3.0-vibe`)。114 的 `config.yaml` 已有 `open_platform.enabled: true`。**`health` 200 会骗人**:deploy 后先确认 `journalctl -u bisheng-api --since "2 min ago" | grep -i "alembic\|Traceback"` 干净,迁移失败会被 deploy.sh 掩盖。 + +| # | 验证 | 命令 | 期望 | AC | +|---|---|---|---|---| +| ① | 无凭据即拒 | `curl -s -o /dev/null -w '%{http_code}\n' -X POST http://192.168.106.114:4101/api/v2/mcp -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'` | `401`(不是 404、不是 307——裸路径不许重定向,design D1) | AC-02 | +| ①b | 裸路径不重定向 | 同上加 `-w '%{http_code} %{redirect_url}\n'` | 无 `redirect_url` | AC-01 | +| ② | 标准客户端接入 | `claude mcp add --transport http bisheng http://192.168.106.114:4101/api/v2/mcp --header "Authorization: Bearer "` 然后在 Claude Code 里 `/mcp` | 工具清单与该密钥权限位一致;**再用纯 `mcp` python 客户端连一次**(`docs/api/mcp-server.md` 的示例),两者看到同一份清单 = AC-01 的「任何标准客户端」 | AC-01 | +| ③ | 检索不越权 | 用**非管理员 `shuiwu`** 名下的服务账号(只授 1 个知识空间、其中 1 个文件单独收权)调 `bisheng_knowledge_search` | 被收权的那个文件的片段**不出现**;指定一个未授予的空间 → `26321` 且与「库不存在」逐字相同 | AC-40, AC-11 | +| ③b | 两个门面同集合 | 同一把密钥、同 query 同 `knowledge_ids`,分别走 MCP 工具与 `POST /api/v2/filelib/retrieve` | 两边 chunk 集合相等(按 `document_id` + `chunk_index` 比) | AC-41 | +| ④ | 撤销 5 秒内生效 | 在管理界面撤销该密钥,**立刻**开始每秒调一次工具并记时间戳 | 首次拒绝距撤销 ≤ 5s(凭据缓存 TTL 上界,`OpenApiConf.cap_credential_cache_ttl`);已建会话不得继续可用 | AC-05 | +| ⑤ | 运行时层未部署 | 在 `app_runtime.enabled: false` 的环境上 `/mcp` 看清单、并直调 `bisheng_app_status` | 两类应用工具不在清单;直调得 `16207`;其余四类不受影响 | AC-17, AC-38 | +| ⑥ | 审计落行 | `journalctl -u bisheng-api --since "10 min ago" \| grep open_api.mcp`;`select action,target_type,target_id,create_time from auditlog where action='open_api.mcp.tool_call' order by id desc limit 10;` | 每次 `tools/call` 一行,`target_type='mcp_tool'`;审计页事件名显示「MCP 工具调用」;**行内不得出现密钥明文或检索片段正文** | AC-07, AC-47 | + +**必须用非管理员账号**:`super_admin` 短路 ReBAC,管理员跑 ③ 会全绿而权限运行时可能是坏的。**空列表也是 200**,③ 的判据是「该出现的出现了且该消失的消失了」,不是「有没有报错」。 + +## 跨 Feature 回写受理 + +> 别的 Feature 请求本 Feature 交付的增量,逐条记「谁请求 / 请求什么 / 交付没交付 / 在哪」。只追加。 + +| 请求方 | 请求内容 | 状态 | 交付物 | +|---|---|---|---| +| **F053**(接入信息区 T046) | MCP 接入地址 | ✅ 已交付 | `GET /api/v1/dev-toolkit/versions` 的 `mcp: {url, transport, auth}`;同批留了 `model: null` 槽位给 F051 | +| **F056**(审计查询面) | MCP 调用的审计行 | ✅ 已交付 | `action="open_api.mcp.tool_call"` / `target_type="mcp_tool"`,三处 lockstep 已登记,审计页事件名「MCP 工具调用」 | +| **F055**(发布面)/ **F054**(详情页) | `get_publish_status` / `get_instance` 加 `entry` 形参 | ✅ 已交付 | 两处均为可选形参、缺省 `"detail"`,既有调用方行为逐字不变 | +| **F057 T043**(SDK `retrieve`) | `RetrieveReq.knowledge_base_ids` **可省略**(AC-22):省略 = 「该主体可及的全部」,由门面按白名单 ∩ 可见范围算,范围过大时以 `26323` 拒并要求点名。SDK 的 `search(query)` 今天不带库 id 会撞 `min_length=1` 的 422,被包成 `PlatformRefusedError(422)`——一个形状上无法与「参数写错」区分的答案 | 🔲 **未交付**(2026-09-16 核实:`open_endpoints/domain/schemas/filelib.py:39-45` 仍是 `Field(..., min_length=1)` + `extra="forbid"`) | = F057 design §6.2 契约 ③。在此之前 F057 的指南与样例**一律显式传 `knowledge_base_ids`**(其 T033 / T034 已如此),SDK 不在本地模拟「全部」 | +| **F057 T043**(SDK `retrieve`) | 「不可及」码 + `data.unreachable_ids`(AC-11);「能力已收回」可与普通失败区分 | ✅ **已交付**(2026-09-16 只核对未改) | `common/errcode/mcp_face.py`:`26321` 带 `unreachable_ids`(404)、`26322` 带 `knowledge_id`(409)、`26320` 无执行身份(403)、`26323` 范围过大(400)。F057 `bisheng_sdk/_codes.py` 已按此登记前三个,`26323` 原样呈现为 `PlatformRefusedError` | +| **F057 T043**(本地 `bisheng dev` 期) | 受理**平台签发**的本地短时访问凭据:`dev` 的迷你代理每请求注入一个访问者凭据句柄,改由平台签发后,`/api/v2/filelib/retrieve` 与 MCP 检索工具须认得它并据以确立访问用户 | 🔲 **未交付**(2026-09-16 核实:`bsdev` 在后端零出现——全仓命中只有 CLI 的 `bisheng_cli/devproxy.py:117`(签发方)、SDK 的日志脱敏正则 `bisheng_sdk/errors.py:56` 与两处测试夹具,没有任何一处受理) | = F057 design §6.2 阻塞项 ③ 的服务端半边(CLI 半边已回写 F053 T042)。在此之前本地期 retrieve 端到端不可验,SDK **不开兼容分支**,只把错误照实抛出并指路「发布后用真实账号验证」 | + +--- + +## 审查修订记录(`/sdd-review`,2026-09-16) + +> 对 design.md 跑 design 审查(24 条清单 + Constitution Check)、对本文跑 tasks 审查(21 条清单),全部 `文件:行号` / 符号名重新 grep 核实。design 侧的改动明细见 design.md 修订历史末行;下面只记**改到本文**的。 + +| # | 严重度 | 问题 | 修订 | +|---|---|---|---| +| 1 | high | T103 / T103a 写「`test_data_scope_matrix` 必须仍绿」——**不可能**:门面改走批量判定后,被 `data_scope` 窄化的目标由「抛 26044」变成「返回 False → 26321」(`permission_action_service.py:328-333` 的批量语义),而 AC-11 / AC-27 又要求它与「库不存在」同一响应 | 改成「分类必须从 `"raise"` 改为 `"unreachable"` 并补断言」,并把这条登记进「实际偏差记录」的预登记项 | +| 2 | high | T209 按**假设**签名接线 F054 `AppDataService`(`get_schema` / `read_rows` / `pk` / `entry`),与 data-plane 切片**已实现**的真实签名(`get_table_schema` / `get_rows` / `key` / 无 `entry`、owner-only 内建、16162 / 16163)不符 | 按只读核实的真实签名重写 T209,写清来源 worktree 与「不给 F054 加 `entry`、不在工具层重复判 owner」 | +| 3 | high | T203 用 `app.mount`——`Mount` 的路径正则要求尾斜杠,裸 `/api/v2/mcp` 会被 `redirect_slashes` 回 307 | 改为精确 `Route` + 三步装配顺序;T203a 加 `test_bare_path_is_not_redirected` | +| 4 | high | T203a 只断言 `isError`,漏掉 `Tool.run` 把三要素 JSON 重包成 `Error executing tool …: {…}`(坑 19) | 加 `test_tool_error_text_is_parseable_json`;T203 逻辑里写死 `except ToolError` 的 `__cause__` 解包 | +| 5 | medium | `max_content` 无上限,门面夹取对 v2 是静默收窄(违反 spec §3「截断必须可见」而 `RetrieveResp` 无承载字段) | T103 加 `le=60000`,T103a 加 422 用例 | +| 6 | medium | T103a 用了不存在的 `v2_client` fixture(`test/open_api/conftest.py` 只有 `open_api_db` / `fake_redis` / `audit_events`) | 改指本目录既有的 `AsyncClient(transport=ASGITransport(app=app))` 范式并给出参照文件 | +| 7 | medium | T201a 没覆盖拆分带来的**顺序**变化(租户 ContextVar 与 PAT 策略读) | 加 `test_pat_policy_read_happens_with_tenant_contextvar_installed` | +| 8 | medium | T212 用 `len(TOOL_REGISTRY) × 5` 做分母,工具分批上线期间恒红 | 分母改 `available()` 过滤后的集合,另加一条「每个注册项都被矩阵覆盖」 | +| 9 | medium | T001 没说清动 `docs/constitution.md` 的哪几处、也没说新断言会不会撞 `test_error_codes.py` 既有两条 | 两处都写明(C5 `:128` 段位表 + `:135` 后补「263 is assigned」+ 文件头模块计数;既有断言只覆盖 260 段,新增写成第三个函数) | +| 10 | medium | AC-13 / AC-15 的唯一覆盖任务被上游阻塞,追溯表看不出来 | AC 追溯表顶部加「45 / 47,两条随 F051 / F054 补验」的口径说明 | +| 11 | low | T104 编号在 Line A、依赖却指向 Wave C 的 T301 | 补「执行位置」一行 | +| 12 | low | T203 的路由矩阵改法写成「`:37-41` 与 `actual_v2_routes :25-35` 都改」——后者本来就有 isinstance 过滤 | 改为只动 `test_every_real_v2_route_is_globally_key_protected_and_marked :36-41` | +| 13 | low | 若干行号 / 符号名(`check-i18n.mjs:106`、`deploy.py:206-211`、`add_tool` 签名、`streamable_http_client` 的 `http_client` 参数等) | 逐条订正 | diff --git a/features/v3.0.0/053-dev-cli-skills/design.md b/features/v3.0.0/053-dev-cli-skills/design.md new file mode 100644 index 0000000000..3dd38e5c3c --- /dev/null +++ b/features/v3.0.0/053-dev-cli-skills/design.md @@ -0,0 +1,669 @@ +# Design: 开发者 CLI 与技能包(独立 `bisheng` CLI 包 + 平台分发端点) + +> **本文档定位 — 现状快照(Why this How)** +> +> - `spec.md` 回答 **做什么**(58 条有效 AC、边界、12 条决议) +> - `design.md`(本文)回答 **为什么这么实现**:关键决策(含被否决的备选)、运行时不直观的事实、对外契约 +> - `tasks.md` 是 **流水账**:拆了哪些任务、做了什么改动 +> +> 调整原则(详见 `docs/SDD-Guide.md` §3-§4):实现变化 → 覆盖更新本文档、只留"今天的状态";每个决策保留"为什么 + 被否方案"和坑。推翻已 ★ 确认的决策 → 停下与用户重新确认;纯实现细节 → 直接改 design。 +> +> **代码事实口径**:本文所有 `文件:行号` 按 `3.0-vibe`(含 F048、F049 已落码 `43e73bfc5`、F054 批 2 已落码 `8e9afaf48`)于 2026-08-17 由探查笔记 `~/.claude/jobs/b4f8a315/tmp/f053-notes/e1-cli-packaging-and-endpoints.md` 核实。行号会漂移、符号名不会——**落地前一律以符号名重定位,不要按行号跳**。 +> +> **本文是"要建成的样子"**:`src/bisheng-cli/` 是**全新目录**,全仓**零** `[project.scripts]` / `console_scripts` / `setup.py`(e1 §1.1 实测),CLI 是本仓第一个可发布包工程;平台侧 `bisheng/dev_toolkit/` 模块同样是绿地。实现后按现状覆盖本文。 +> +> **裁剪基准 = [`mvp-114-path.md`](../mvp-114-path.md) §6 MVP-核心**:本 Feature 本轮只做「CLI 包工程(wheel + 平台下载端点)+ `login` + `deploy` + `logs` 简版」;`skills sync` / 两包技能包 / `dev` / 接入信息区 / 多平台凭据 / 版本兼容校验**全部顺延**(release 仍必做,落点方向见 §1 非目标与 §8)。 +> +> **2026-08-18 增补 — 技能包切片拉入 MVP(用户拍板)**:为让开发者"一条命令拿到技能、AI 照着开发部署",把 DEV-03 中**「部署纳管」包 + 其分发/同步链路**从顺延项提前实现:**T035**(`skills sync`)· **T036**(分发端点 `GET /api/v1/dev-toolkit/skills/{pack}`)· **T037**(「部署纳管」包三件套)· **T039**(`login` 后自动 sync)· **T040**(触发评测 AC-22①)· **T049**(AGENTS.md 引用指引)。**T038「平台能力接线」包仍顺延**——其模型/应用库接法依赖 F051/F052 已集成、SDK 三件套章节与 AC-22② 随 F057。两处此前未定的对外合同在此定死:**① `skills sync` 落地路径 = `~/.bisheng/skills//`**(与 `credentials.json` 同处一个 0700 目录,D3 风格)〔**已于 2026-08-18 修订为按平台分目录,见下一块**〕;**② 分发端点响应格式 = 按包 gzip tarball**,由 `artifact_service.read_skill_pack` 从**单一源目录** `bisheng/dev_toolkit/skills//` 实时打包(成员 mtime/owner 归零 → 字节确定、re-sync 无 churn),包版本走响应头 `X-Bisheng-Pack-Version`(正文是 tar 不是信封);匿名口径与条件注册闸沿用 D10(open 层关 → 路由天然 404)。实现落于 `3.0-vibe`,CLI 全量 `pytest` 219 passed。 + +> **2026-08-18 缺陷修订 — 「同步成功」不等于「AI 读得到」(AC-54 / AC-55 / AC-56)**:现场暴露的失败是 `login` 打出「✓ deploy-hosting(版本 3.0.0):5 个文件已同步」而开发者的 AI 完全不知道有技能——文件确实写在 `~/.bisheng/skills/deploy-hosting/`,但**没有任何编程工具扫描该目录**(Claude Code 只扫 `~/.claude/skills/`,Codex 只扫 `~/.codex/skills/`)。设计原本把「文件落盘」当成了交付完成,这是本 Feature 最贵的失败形态:AI 照常写代码、只是不知道平台规矩,直到 `deploy` 起不来才暴露,而报错不指向技能缺失。三处对外合同随之改定: +> +> - **落点改为按平台分目录**:`~/.bisheng/skills/.//`(slug 由 `commands/skills.py::profile_slug` 从归一化后的 base_url 派生,与凭据 profile 同键)。**取代**上一块的 `~/.bisheng/skills//`:扁平落点让「先登 test 再登 prod」的第二次 sync 静默覆盖第一份契约,现场无任何迹象。旧扁平目录由 `_migrate_flat_layout` 在 sync 时清掉(留着就是无人更新的诱饵)。 +> - **sync 负责接入本机 AI 工具**(新模块 `bisheng_cli/agent_skills.py`):探测到 `~/.claude` / `~/.codex` 即在其 `skills/` 下建软链指向当前平台的包,**探测到几个接几个、不询问不择一**(多接一个 = 一个软链;漏接一个 = 静默失败,代价不对称)。Windows 无 Developer Mode 时降级为复制并打 `.bisheng-managed` 标记(复制跟不了后续 sync,故每次 sync 重做且如实报告 mode)。目标路径已被**开发者自己的**技能占用时报 conflict 并原样不动——为了让摘要好看而删掉别人的技能,是比不接入更坏的结果。`login` 路径此前刻意不打印引导(怕输出太长),那份沉默正是本次缺陷的直接成因,现已改为必打落点与接入结果;一个都没接上时是 `warn` 而非 `info`。 +> - **`deploy` 在项目 `AGENTS.md` 留位置指针**(`agent_skills.ensure_project_pointer`):覆盖 Cursor / Cline 这类没有技能目录约定的工具。写的是**路径不是内容**——包的版本跟随平台,拷进 git 会冻结在旧版本而平台规矩继续变;路径用 `~/` 相对形式以便提交后对同事同样成立。时机在**平台接收上传之后**(此前该事实还只是猜测,失败的 deploy 不得改动开发者工作树),`--dry-run` 与 `--no-agents-note` 不写。 +> +> 文案侧同步订正:`_print_reference_guide` 原本断言「Claude Code:自动发现 SKILL.md,无需配置」并指向包内根本不存在的 `README.md`(该 README 只在平台源码 `bisheng/dev_toolkit/skills/README.md`,不随 tar 分发),两句都朝「你不用再做什么」的方向误导;教程 `hosted-app.html` 步 1/2 与排障段同改。**`logout` 仍然不做**(§8 四不做项)——软链在每次 sync 时按当前平台重建,切平台自然重指向,不需要一个专门的清理命令。 + +**关联**: [spec.md](./spec.md) · [tasks.md](./tasks.md)(待写)· [release-contract.md](../release-contract.md)(表 1 **AppManifest 归 F055**、表 3 F053 行;INV-27 / INV-31 / INV-32)· [mvp-114-path.md](../mvp-114-path.md)(**§6 MVP-核心是本轮裁剪基准**、§3 114 环境事实、§1 演示剧本步 2–3) +**上游 / 姊妹**: [F055 design §4.2 ①③ / §4.2 ⑧](../055-app-publish-pipeline/design.md)(`deploy` / `logs` 端点、AppManifest 形态、失败五元组、162 段错误码——**冲突时一律以 F055 为准**)· [F055 tasks T039](../055-app-publish-pipeline/tasks.md)(端点鉴权顺序与归属判定字段)· [F054 contracts-runtime-manager.md §5](../054-app-domain-runtime/contracts-runtime-manager.md)(注入应用的环境变量清单——`bisheng dev` 顺延,但清单来源在此定死)· [F049 design](../049-openapi-auth-baseline/design.md)(`whoami` / 端点 `open_api_scope` marker + `router_rpc` 的 `verify_open_api_access` / 260 段错误码;**2026-09-10 起以 beta2 F053 实现为准,`open_api_subject` 工厂已作废**) +**版本**: v3.0.0 +**最后更新**: 2026-08-18 + +--- + +## 1. 目标与非目标 + +**目标**:交付一个**与后端零耦合的独立 Python 包 `bisheng-cli`**(命令名 `bisheng`),让开发者与其本地 coding agent 在纯内网环境里用三条命令完成「拿密钥登录 → 把本地项目打包发布上平台 → 看自己应用的运行日志」;同时在平台侧提供**匿名可达的安装件与版本信息分发端点**,使 CLI 的获取与升级不依赖任何公网 registry。CLI 自身**不做任何权威判定**——密钥有效性、权限位、manifest 合法性、包体上限、归属人、审批状态全部由服务端裁决,CLI 只负责「打包、送达、把服务端的机读结论翻成人话与退出码」。 + +**非目标**(本轮顺延,落点方向已定,避免后人误扩或另起一套): + +| 顺延项 | 为什么本轮不做 | 将来的落点方向 | +|---|---|---| +| `skills sync` 与 DEV-03 两包技能包 | MVP-核心不集成平台能力,技能包的一半内容(模型 / 检索 / SDK 接线)无处可教 | 端点 `GET /api/v1/dev-toolkit/skills/{pack}`(与安装件同 router、同匿名口径,D10 已留位);包内容随后端发布件同行,落点同 `linsight/builtin_skills/` | +| `dev` 命令(迷你代理 + 本地 SQLite) | 依赖 F054 app-proxy 的注入头形态与 F057 SDK,两者本轮都不在纵切上 | 命令落 `bisheng_cli/commands/dev.py`;**注入的环境变量清单不得自造**,唯一来源 = [`contracts-runtime-manager.md` §5](../054-app-domain-runtime/contracts-runtime-manager.md)(见 §6.2 表末行,本文已把清单原样登记) | +| 接入信息区(platform 前端) | 属 platform UI,与 CLI 包工程无耦合;MVP-核心的密钥转交靠人工 | F049 服务账号详情页「API 密钥」tab 顶部;下载链接即 D10 的 `download_url` | +| 多平台凭据(`--platform` 参数、profile 切换与列出) | 114 只有一个平台;裁剪基准 `mvp-114-path.md` §6 F053 行把「多平台凭据」整条列在顺延列 | **凭据文件结构本轮就按多 profile 落**(D3,零迁移地基),但 **`--platform` 参数本轮不注册**——命令一律用 `credentials.json` 的 `current` profile。顺延的是整个「命令级选择 + 列出 + 切换」交互层(D11 第 5 条) | +| 版本兼容校验(不兼容即阻断) | 平台侧 `versions` 端点本轮就返回 `min_compatible`,但阻断逻辑需要真实的跨版本样本才有意义 | 本轮**只提示不阻断**(D11);开启阻断 = 在 `http.py` 的前置探测里把 warning 升为 `CliError(exit=2)` | +| 单文件二进制安装件 | spec 决议-1 定为 v3.1 备选 | 下载端点按 `?platform=&arch=` 分发,`versions` 载荷加 `assets[]` 数组 | +| CLI 侧密钥扫描、`init` / 工程骨架、`--as`、`logout` / `whoami` 子命令、撤回命令、二维码 | spec §范围边界与决议-2 / 决议-12 已逐条否决 | **不做**(§8 有理由,防止后人重复提议) | + +**与裁剪基准的两处显式偏离(本轮刻意扩范围,非漏读)**: + +| 偏离项 | 基准怎么写的 | 本轮为什么仍做 | +|---|---|---| +| **`login` 拒 `delegate`** | `mvp-114-path.md` §6 MVP-核心表 F053 行把「`login` 拒 `delegate`」列在**顺延**列 | INV-31 要求「三面在通道入口按权限位直接拒绝」,而 CLI 是唯一的通道入口;实现量 = `whoami.scopes` 一个 `in` 判断 + 一条文案 + 一个 mock 单测(D14 / 坑 15),**不做反而要在 spec 里挂一条长期偏离**。⚠️ 该位在 F049 期签不出来(坑 15),本轮**只能单测覆盖、不可在 114 端到端验证** | +| **`--confirm-schema-change` 参数** | 结构演进整体后置(F055 §6 波次表) | 参数存在与否是 CLI 的**对外契约**;现在不做,将来要改 CLI + README 两处(D11 第 4 条;F055 端点参数同理由留位,本期只接受不消费) | + +--- + +## 2. 关键约束 + +> 全局架构铁律(双 DB / 多租户 / 权限 / 分层 / 错误码 / 安全)遵循 [`docs/constitution.md`](../../../docs/constitution.md) C1–C7,本节不重抄。**注意作用域**:C1–C7 与 `scripts/arch-guard.sh` 约束的是 `src/backend/`,本 Feature 只有**平台侧两个端点**落在那里;`src/bisheng-cli/` 是独立工程,唯一会对它出声的自动守卫是 RULE-7,**且它只输出 WARNING、不是 VIOLATION、不阻断**(`arch-guard.sh:99` 输出 `⚠️ [arch-guard] RULE-7 WARNING`,脚本头注释 `:13` 标「硬编码敏感信息检测(C6,**WARNING**)」;VIOLATION 只有 RULE-4/5/8/9/10)——见坑 10。 + +- **CON-1 CLI 是独立包,不 import `bisheng`**:不读后端配置、不连任何数据库、不引 SQLModel / FastAPI。推论:**一切权威校验恒在服务端**——CLI 对 `bisheng-app.yaml` 只做「文件存在 + YAML 可解析 + 三个必填项存在」级快速失败(F055 design §D3 逐字要求),对权限位一律不判、不缓存(AC-52)。 +- **CON-2 纯内网零公网依赖**:安装件与版本信息由平台自身分发(AC-01);CLI 运行期只访问 `login` 时给定的**那一个 host**,不做遥测、不查公网 registry、不下载任何东西。推论:**第三方依赖是负债**——每多一条依赖就多一次「内网 pip 装不上」的失败面(依赖预算见 D12)。 +- **CON-3 凭据是本地用户资产**:落用户目录、属主可读写、绝不落工程目录、绝不进上传包(AC-07 / AC-32)。密钥**必须以明文保存**(每次请求都要用它),文件权限位是唯一保护——这不是可以省的一步(坑 1)。 +- **CON-4 包体量上限来自服务端配置,不是 CLI 常量**:默认 50 MB / 200 MB 解包 / 20000 条目,权威值经 `GET /api/v2/apps/deploy-limits` 取;**取不到时直接上传、由服务端 16201 兜底**,不得让一个软校验挡死发布(F055 design D2 / tasks T050 回写项 3)。 +- **CON-5 与 F055 服务端契约强耦合——契约变则 CLI 变**:端点路径与入参、轮询载荷字段、`stage` 取值与顺序、失败五元组 `{stage,code,message,details,hints[]}`、162 段错误码语义,五样任一变更都必须同批改 CLI。本文 §4.2 的所有形状是 F055 design §4.2 ①③⑧ 的**副本**,**冲突时一律以 F055 为准**(同一契约分两处必漂移,这是自觉付的代价,靠 §6.2 的显式登记 + tasks 的同批改任务对冲)。 +- **CON-6 跨平台 + Python ≥ 3.11**:Windows / macOS / Linux 三端可跑(坑 4 / 坑 5);`requires-python = ">=3.11"` 与 `src/runtime-manager/pyproject.toml:1-16` 对齐。 +- **CON-7 商业版网关只转发 `/api/v1/**` 与 `/api/v2/**`**(`docs/architecture/11-gateway.md:36-37`;OSS nginx `docker/nginx/conf.d/default.conf:50` 同形)→ 分发端点的落点没有选择余地(D10)。 +- **CON-8 F053 不申请错误码模块编码段**:`login` 消费 F049 的 260 段、`deploy` / `logs` 消费 F055 的 162 段与 F054 的 161 段;分发端点在开关关闭时**靠不注册路由呈 404**(D10),零新错误码 → C5 的「新增错误码须同批回写 constitution 表」在本 Feature 不触发。 +- **CON-9 MVP-核心边界**:只做 `login` / `deploy` / `logs`。顺延命令**不注册进解析器**(D11),不做"占位报错"。 + +--- + +## 3. 方案对比与选定 + +### D1:包工程形态 = Python wheel + 平台下载端点;发行名 / 包名 / 命令名三分离 + +- **备选**: + - A. **单文件二进制**(PyInstaller / Nuitka)— 优点:开发者机器零 Python 依赖;缺点:三平台各打一份、体量 30 MB+、信创国产 CPU(龙芯 / 鲲鹏)要再加两个构建目标,且仓内零构建先例 + - B. **仅推私有 PyPI 镜像**(组织内确有 Nexus:`.github/workflows/test.yml:55-66` 的 `PY_NEXUS: 110.16.193.170:50083`)— 缺点:那段是 backend 换 uv 前的**过期残留**、不是可依赖的发布通道;且客户现场的内网镜像不归我们控制,AC-01「平台自身分发」落不了 + - C. **wheel + 平台自身的下载端点**(选定),私有镜像作为**可选**加速路径 +- **选定**:**C**(spec 决议-1 已定案,本文只补落地形状)。 +- **原因**:目标用户是"会用 coding agent 的业务技术员",Python 环境常备;wheel 是唯一能同时满足「一份产物 × 三平台」「可直接 `pip install `」「可放进内网镜像」的形态;`src/runtime-manager/pyproject.toml`(hatchling + uv.lock,`:25-30`)是仓内最新的独立包先例,照抄可省掉一轮工具链选型。 +- **落地形状**(照抄基准 = `src/runtime-manager/pyproject.toml`): + - `[build-system]` hatchling;`[tool.hatch.build.targets.wheel] packages = ["bisheng_cli"]` —— **这一行是关键**:目录名带连字符(`src/bisheng-cli/`)时 hatchling 无法自动推断包目录,缺了它 `uv build` 打出的 wheel 是空的。 + - `[tool.ruff]` 照抄 `:41-67`,**`RUF001/002/003` 必须 ignore**——CLI 的人读输出全是中文,不 ignore 的话 ruff 会把中文全角括号"修"成 ASCII,把产品文案改错。 + - `[tool.pytest.ini_options]` 照抄 `:32-39`,自定义 marker 由 `docker:` 换成 **`network:`**(需要真平台的用例默认跳过,CI 不跑、114 手验跑)。 +- **三个名字刻意分离**:发行名 `bisheng-cli` · **import 包名 `bisheng_cli`** · 命令名 `bisheng`。理由有两条且都不是洁癖:① 若 import 包取名 `bisheng`,开发者机器上同时装了后端依赖时会与 `src/backend/bisheng/` 顶层包**同名冲突**;② `arch-guard.sh:115,126` 的 RULE-8/9 守卫条件是 `grep -q "/bisheng/"`,目录里出现 `bisheng/` 段会被扫进来产生无谓噪声。 +- **何时该重新考虑**:客户反馈"装不上 Python"成为主要阻碍(表现:现场支持工单里安装类占比过半)→ 补单文件二进制,`versions` 端点改按 `platform/arch` 分发 `assets[]`(形状 §4.2 已留位)。 + +### D2:命令与参数面 = argparse 三命令 + 全局 `--json`(机读走 stdout、人读走 stderr) + +- **备选**: + - A. **typer / click**(生态标配)— 优点:子命令、类型转换、`--help` 全自动;缺点:多一条内网安装链(typer 还带 rich + click 两个传递依赖),违反 CON-2 的依赖预算 + - B. **标准库 `argparse` + 手写 `--help` 文案**(选定) + - C. 自己写解析器 — 无收益,`--help` 与错误提示要重造 +- **选定**:**B**。`argparse` 在标准库、支持子命令与互斥组、`--help` 自动生成,唯一损失是彩色输出与自动补全——两者都不是 AC 要求的(AC-04 要求的是"非交互可用 + 机器可读",与好看无关)。 +- **机读 / 人读双形态的落地(AC-04 的实质)**: + - **`--json` 时:机读 NDJSON 走 stdout,人读文本走 stderr**。这样 agent 可以直接 `bisheng deploy --wait --json | jq -c 'select(.event=="stage")'` 而不被进度文本污染,同时人在终端仍能看到过程。反过来(把两者混在 stdout 靠标记区分)会让任何一个 jq 管道在第一行进度文本上炸掉。 + - 事件形状固定三种:`{"event":"stage",...}` / `{"event":"progress",...}` / `{"event":"result","ok":bool,"exit_code":int,...}`;**每条命令恒以且仅以一条 `result` 结尾**(agent 可以只读最后一行做判定)。 + - 非 TTY 自动降级:不打进度条、不发交互提问,只打里程碑(25/50/75/100%)。 +- **交互确认一律有参数等价物**(AC-04):`--confirm-schema-change`(AC-36)· `--yes`(迭代 deploy 前的目标应用确认)· `--api-key-stdin`(免命令行明文,AC-07)。**非交互环境下缺参数 = 明确拒绝**,绝不"默认同意"。 +- **`--wait` 的定位**:默认不等(审批是人工动作、可跨天,spec 决议-10);`--wait` 是纵切演示剧本的等待手段,带 `--wait-timeout`(默认 1800 秒),**超时不是失败**(退出码 23,见 §4.2 退出码表)。 +- **何时该重新考虑**:命令数超过 5 个且开始出现多层子命令(如 `bisheng app logs`)→ 那时 argparse 的手写 `--help` 维护成本会超过一条依赖的成本,可换 typer;或需要 shell 补全成为客户诉求 → 同上。 + +### D3:凭据存储 = `~/.bisheng/credentials.json`,`0600` + 多 profile 结构本轮就落 + +- **备选**: + - A. **系统钥匙串**(keyring / DPAPI / Keychain)— 优点:最安全;缺点:`keyring` 是第三方依赖且在无 GUI 的 Linux 上要 D-Bus + secretstorage,服务器 / 容器里必炸,与"开发者可能在跳板机上用"直接冲突 + - B. **环境变量**(每次命令都要 `BISHENG_API_KEY=...`)— 缺点:AC-06 明写"通过后把凭据写入本地用户目录";且 agent 反复拼环境变量比读文件更易泄漏到 shell history + - C. **用户目录下的 `0600` JSON 文件**(选定),环境变量作为**覆盖**通道保留 +- **选定**:**C**。路径 `~/.bisheng/credentials.json`(目录 `0700`、文件 `0600`);Windows 上 `%USERPROFILE%\.bisheng\`,用 `icacls` 等价的"仅当前用户"ACL(坑 1)。 +- **原因**:形态与开发者熟悉的 `~/.docker/config.json`、`~/.kube/config`、`~/.aws/credentials` 一致,学习成本为零;纯标准库可实现;跳板机 / 容器 / WSL 全部可用。**密钥必然明文存**(每次请求都要原值),所以权限位不是"加固"而是**唯一**保护——写文件必须 `os.open(path, O_CREAT|O_WRONLY|O_TRUNC, 0o600)` 一步到位,不能"先 write 再 chmod"(那中间有一个 world-readable 的窗口)。 +- **多 profile 结构本轮就落**(顺延的只是交互层):文件顶层 `{"version":1,"current":,"profiles":{: {...}}}`。理由:结构一旦按单平台写死(顶层直接放 `api_key`),加多平台就是一次带迁移的破坏性变更;按 profile 落只多写十行,而顺延项开启时零迁移。 +- **不存 `scopes`**(AC-52 的直接落点):服务端有 3 秒正向缓存上界(`core/config/open_platform.py:31-35`,默认 3、硬顶 5),权限位编辑"即时生效"由服务端保证;CLI 一旦缓存权限位就会出现"管理员补勾了但 CLI 还说没权限"。存的是 `service_account` / `resource_owner` / `key_mask` / `tenant_id` 这类**展示用快照**,且每次 `login` 覆盖。 +- **何时该重新考虑**:出现"密钥被同机其他用户窃取"的真实事件(表现:审计里同一把 key 出现不同来源)→ 上系统钥匙串,但必须保留文件后端作为无 GUI 环境的回落,而不是替换。 + +### D4:打包 = `tar.gz` + 三层忽略规则(git 优先 / 自研子集回落 / `.bishengignore` 收口) + +- **备选(包格式)**:zip vs **tar.gz**(选定)。runtime-manager 的解包端两种都收(`src/runtime-manager/runtime_manager/builder.py:168-181`,先 `zipfile.is_zipfile()` 再 `tarfile.open()`),但 F055 端点参数逐字写的是 `package`(tar.gz)、对象键是 `apps/{app_id}/versions/{version_id}/code.tar.gz`(F055 design `:103`)→ **产 zip 等于和字面契约、对象键名、未来分片方案三处同时错位**,没有任何收益。 +- **备选(忽略规则)**: + - A. **`fnmatch` 硬顶 gitignore 语义** — **绝对不要**:`fnmatch` 的 `*` 跨 `/` 匹配、且**根本没有 `**`**(`linsight/domain/services/workspace_backend.py:616-619` 的注释逐字记录了这两个坑)。这个坑在本仓已经真实咬过一次(memory `project_taskmode_selftest_114_glob_bug`:提示词逐字教的 `glob("/uploads/**/*.xlsx")` 一直 0 匹配) + - B. **加 `pathspec` 依赖**(gitignore 语义的事实标准库,纯 Python)— 缺点:一条内网安装链换一个只在"非 git 项目"才用得上的完备性 + - C. **三层规则**(选定) +- **选定**:**C**,三层从内到外: + 1. **结构性排除**(与忽略文件无关,恒定生效)。分两类: + - **硬排除、不可取回**:`.git/` `.hg/` `.svn/` `node_modules/` `.venv/` `venv/` `env/` `__pycache__/` `*.pyc` `.pytest_cache/` `.ruff_cache/` `.mypy_cache/` `*.egg-info/` `.DS_Store` `.bisheng/`。判据:**这些目录的内容在目标机器上一定是错的或无意义的**(宿主平台的虚拟环境 / 二进制、版本控制元数据、缓存),上传它们只有害处。 + - **软排除、可被 `.bishengignore` 的 `!` 取回**:`*.db` `*.sqlite` `*.sqlite3`、本地附件目录、**`dist/` `build/`**。 + - ⚠️ **`dist/` / `build/` 刻意不进硬排除**(评审修订):存量 python3.11 应用把前端构建产物放 `dist/` 交后端静态托管是常见形态,硬排会把它静默剥掉,故障要到 `precheck_probe` / 16228 才暴露、且排查方向指向"平台构建有问题"(正是坑 20 那类误导)。这与本节第一条硬规矩「绝不静默截断——包被悄悄截断 = 线上跑的不是本地那份」(判据照 `linsight/domain/services/workbench_impl.py:1255-1257`)是同一条约束的两面:**凡"真实应用可能需要它"的目录,一律软排 + 在忽略统计里列出**,只有"上传必然错"的才硬排。 + - **凭据文件天然不在工程目录(D3),"不进包"由结构性排除 + 一条 assert 型单测双重保证**(AC-07)。 + 2. **`.gitignore` 语义**:项目是 git 仓库且 `git` 可执行 → 直接 `git ls-files -c -o --exclude-standard -z` 让 git 自己算(零依赖、100% 语义一致;关联 memory `reference_remote_dev`「要比就用 `git ls-files` 全量」的正向用法);否则 → 自研子集解析器(`#` 注释 / 空行 / 前导 `!` 取反 / 目录后缀 `/` / `**` / 段内 `*` `?`),并**在输出里明说**"本项目非 git 仓库,忽略规则按子集解析,复杂规则请写进 `.bishengignore`"。 + 3. **`.bishengignore`**(可选,同语法、最后加载、优先级最高)——子集解析器兜不住的复杂规则的显式出口。 +- **打包的四条硬规矩**(每条都是坑的对偶): + - **绝不静默截断**:超限 / 遇到不可打包成员时**整包拒绝**并列清单,判据注释照抄 `linsight/domain/services/workbench_impl.py:1255-1257`——"包被悄悄截断 = 线上跑的不是本地那份"。 + - **符号链接 / 设备文件 / FIFO / socket 本地就跳过并列出**:服务端 tar 解包闸会拒它们(F055 design D2 的四类拒绝),本地不跳等于必然吃一个 16202。 + - **⚠️ 硬链接不在跳过之列(2026-08-17 订正,原文写的「硬链接一律跳过」是错的)**:服务端拒的是 tar 里的 **hardlink 成员**,而 hardlink 成员只在 tarfile 自己挑成员类型时才产生(`TarFile.add` / `gettarinfo` 查 `self.inodes`)。`packaging.py` 逐条手工构造 `TarInfo`(默认 `REGTYPE`)再 `addfile` 流式写内容,**这条写入路径产不出 hardlink 成员**。按 `st_nlink > 1` 跳过,等于为一个不会发生的成员类型丢掉一个普通文件的内容——而 `st_nlink > 1` 既不是作者选的、他也看不见。→ 硬链接文件按普通文件打包,内容照进。 + - **可复现打包**:成员按路径排序、`mtime` 归一、`uid=gid=0`、`uname=gname=""`、路径用 posix 分隔符且无前导 `./`——同样内容产出同样 sha256,便于"本地包 == 服务端收到的包"的核验。 + - **保留可执行位**:文件模式归一为 `0644`,**但原本有 owner 执行位的保留 `0755`**——否则 entrypoint 脚本上线后不可执行,而这类故障要到构建/探活阶段才暴露(坑 20)。 +- **体量自查**:打包后先 `GET /api/v2/apps/deploy-limits`(`{max_package_mb, max_unpacked_mb, max_package_entries}`),超限 → 拒绝 + 打印当前体量 + **Top 10 最大文件 / 目录**("忽略建议"不能是一句空话,要指名道姓);端点取不到 → **按内置默认值只提示不拦、直接上传**,由服务端 16201 兜底。 +- **何时该重新考虑**:真实项目频繁顶穿 50 MB 且 `.gitignore` 已清干净 → 运维改 `settings.app_runtime.max_package_mb`(这是运维动作不是改码);或子集解析器被投诉误伤(表现:同一个项目 `git ls-files` 与子集解析结果差异 > 5%)→ 那时才值得引 `pathspec`。 + +### D5:上传 = 一次性 multipart POST(流式读文件),不做分片 + +- **备选**: + - A. **分片 + 断点续传** — 缺点:需要服务端两段式 `init + parts + complete`(F055 design D2 明写"那时接收端点要改成两段式"),本轮 F055 端点是单次 multipart;50 MB 上限下分片是纯粹的过度设计 + - B. **一次性 multipart POST**(选定) +- **选定**:**B**。`httpx.post(url, files={"package": (name, fileobj, "application/gzip")}, data={"app_id":..., "confirm_schema_change":...})`——**传 file object 而不是 `path.read_bytes()`**,httpx 会流式读、`Content-Length` 由文件大小得出,50 MB 不进内存。 +- **原因**:nginx `client_max_body_size 1024m`(`docker/nginx/conf.d/default.conf:60`)远高于 50 MB 上限,**无需改 nginx**;服务端侧 F055 也已明确 `UploadFile` 落临时盘再 `put_object(file=Path(...))`、不整包进内存——两端都不放大内存,分片的唯一理由(大对象)不存在。 +- **超时取值**:连接 10 s;**上传请求读超时 240 s**(刻意 < nginx `proxy_read_timeout 300s`,`default.conf:53`),让 CLI 自己先超时并给出可读信息,而不是拿到一个被 nginx 掐断的 504;普通请求读超时 60 s。 +- **进度**:人读模式用计数包装器打印 `已上传 12.3 MB / 41.0 MB (30%)`;`--json` 模式只在里程碑发 `{"event":"progress"}`,不刷屏。 +- **何时该重新考虑**:包体上限被放宽到 > 200 MB,或出现跨机房上传的真实丢包 → 那时同时改 F055 端点与 CLI(两处必须同批,登记在 §6.2)。 + +### D6:`deploy` 的分阶段输出 = 服务端 `stage` 直投,CLI 不自造阶段名 + +- **备选**: + - A. CLI 自定义一套面向人的阶段名("正在检查配置 / 正在构建镜像")并映射服务端 `stage` — 缺点:映射表就是第二份契约,F055 加一个 stage 就出现"CLI 不认识的阶段",且 agent 拿不到机读值 + - B. **服务端 `stage` 原样作为机读值 + 一张纯展示用的中文短语表**(选定) +- **选定**:**B**。`--json` 里 `stage` 恒为服务端原值,`STAGE_LABELS` 是一张**纯展示用**的中文短语表,**遇到未知 stage 就原样打印英文**(不认识 ≠ 报错)。 +- **`stage` 的权威枚举照抄 F055,一个不能少**(`055-app-publish-pipeline/design.md:86` 逐字定义): + + ``` + received · secret_scan · precheck_manifest · precheck_build · precheck_probe + · version_recorded · approval_created · approved · publishing · online · pending_online + ``` + `status ∈ {running, waiting_approval, succeeded, failed}`(同处)。⚠️ **`approval_created` 是机读值,不要写成中文「审批单生成」**——那是 `STAGE_LABELS` 里的展示串,混淆会让 `--json` 的 `stage` 字段与服务端对不上;同样,**第一条轮询响应的 `stage` 就是 `received`**,漏登记它会让每次 deploy 的第一行输出都掉进"未知 stage"兜底分支。 +- **⚠️ 同步段的 stage 客户端永远轮询不到**(本节最容易写错的地方,与 §4.1 B 步 6/7 同源):F055 design §4.1 ①(`055-app-publish-pipeline/design.md:411-427`)把**归属判定 · 大小闸 · 解包闸 · manifest 校验 · 本地引用校验 · 在途单 / 待上线闸**全部放在 `POST /api/v2/apps/deploy` 的**同步前段**,端点直到这些都过了才 `INSERT app_deployment(stage=received)` 并返回 `{deployment_id}`。→ **`precheck_manifest` 的失败根本不会出现在轮询里**,它是 `POST` 的**同步错误响应**;轮询里能看到的只有 `received` 之后的阶段。CLI 若"等一个 `stage=precheck_manifest` 的失败事件",等到的是超时——而首发最常见的失败(manifest 缺字段)恰好全在这一侧。 +- **顺序不得擅自调换**:异步段顺序 `secret_scan → precheck_build → precheck_probe → version_recorded → approval_created`(阶段名以 F055 为准;spec AC-31a 的人话顺序「托管预检 → 安全扫描 → 审批单」是同一条链路的产品级表述)。⚠️ **F055 design D5 里挂着一个"把 `secret_scan` 提前到构建之前"的待 ★ 确认偏离**——若那条被确认,**CLI 的输出顺序、`STAGE_LABELS` 与 AC-31a 必须同批改**(§6.2 已登记)。 +- **失败即终止 + 五元组原样透传**(AC-31a / AC-35):任一阶段 `status=failed` → 立即停止轮询、按 `failure.code` 映射退出码、人读打印 `message` + `hints[]` 逐条、机读把整个 `{stage,code,message,details,hints[]}` **原样**放进 `result.failure`。**CLI 绝不吞 `details` / `hints`**——那是本地 agent 自动修复的全部输入(AC-47 的成立前提)。 +- **密钥扫描命中的呈现**:`hits: [{rule_id, name_i18n_key, file, line}]` 逐条打印 `file:line` + 规则名,**绝不回显任何值**(服务端连脱敏值都不给,F055 design D5)。CLI 也不得"贴心地"去本地文件里把那一行读出来打印——那等于把服务端刻意不给的东西自己补回来(AC-04 / AC-35 红线)。 +- **何时该重新考虑**:阶段数超过 8 个或出现分支(如 `runtime=static` 跳过构建)→ 线性进度条要换成阶段树;或 F055 把 `failure` 拆成 machine/human 两个字段 → 那时 CLI 的透传逻辑要改(但应先反对那个拆分,理由见 F055 design D4)。 + +### D7:审批状态轮询 = 短轮询 + 指数退避,终态一态一退出码(AC-31c 的四终态 + 两个必须收口的异常终态) + +- **备选**: + - A. **长连接 / SSE** — **物理上不可行**:nginx `proxy_read_timeout 300s`(`default.conf:53`),而审批是人工动作可跨天 + - B. **WebSocket** — 服务端无此接口,且要为一个 CLI 建长连接通道 + - C. **短轮询 `GET /api/v2/apps/deployments/{deployment_id}` + 指数退避**(选定) +- **选定**:**C**。间隔 2 s 起,每 5 次 ×1.5 封顶 10 s;`--wait-timeout` 默认 1800 s。 +- **两段轮询语义要分开**(这是最容易写错的地方): + - **默认(无 `--wait`)**:轮询到 **`status=waiting_approval`(审批单已生成)** 即返回 0,输出审批单标识与三条跟踪路径(应用详情页·发布 / MCP 应用状态工具 / `deploy --wait`)——spec 决议-10。 + - **`--wait`**:继续轮到 `approval.status` 终态与 `app_state` / `pending_reason` 落定。终态映射(AC-31c 要求的四种 + 两种**必须收口否则会卡死**的异常终态): + + | 终态 | 判据(轮询载荷) | 退出码 | 输出 | + |---|---|---|---| + | 通过并上线 | `app_state=已上线`(`stage=online`) | **0** | 应用标识 + 入口地址;**⚠️ 入口地址不在轮询载荷里**,见下方"entry_url 的取值缺口" | + | 待上线 | `pending_reason ∈ {"capacity","deploy_failed"}`(`stage=pending_online`) | **22** | 成因 + owner 手动上线路径(应用详情页·发布) | + | 驳回 | `approval.status=rejected` | **20** | `reject_reason` **全文**(不截断) | + | 撤回 | `approval.status=withdrawn` | **21** | 提示撤回发生在应用详情页·发布 | + | **取消(应用被删除)** | `approval.status=cancelled` | **24** | 明说"目标应用已被删除,该审批单已取消"——**这单永远不会有结论** | + | **审批异常(审批人为空)** | `approval.status=exception`(Gate `decision=EXCEPTION` / approver_empty) | **25** | 明说"平台未能解析出审批人、已通知管理员,请联系管理员处理审批异常"——**同样永远不会有结论** | + | (非终态)超时 | 到 `--wait-timeout` 仍未落定 | **23** | 明说"这不是失败",给审批单标识与跟踪方式 | +- **⚠️ 为什么必须收 `cancelled` / `exception`(评审修订)**:这两个态在 F055 是**确定会发生**的——① 审批人解析为空时 Gate 返回 `ApprovalGateResult(decision=EXCEPTION)`(`055-app-publish-pipeline/design.md:44` K2 ②、§4.1 A `approver_empty` 分支,AC-18「不放行也不静默卡死」);② 应用被删除时 F054 钩子调 `cancel_instance_by_business` 把 instance 置 `CANCELLED`(F055 D10 / §4.1 B)。若只认四终态,`--wait` 会一路轮到 `--wait-timeout` 后退 23 并打印"这不是失败,请继续等审批"——而真实情况恰恰相反:**这单已经死了**,等下去毫无意义。两个码与 AC-31c 不冲突(四种终态仍各自可区分,这是超集)。 +- **⚠️ `entry_url` 的取值缺口(回写项 3)**:`entry_url` 在 F055 契约里**只出现在 `POST /deploy` 的返回**(`055-app-publish-pipeline/design.md:492`),`GET /api/v2/apps/deployments/{id}` 的轮询载荷是 `{stage, status, failure, app_id, version_no, approval{...}, app_state, pending_reason}`——**没有 `entry_url`**;而首发时 `POST` 那一刻应用还是草稿、`entry_url` 大概率为 `null`。→ AC-31c「通过并上线(输出入口地址)」/ AC-33 / spec 决议-12 目前落不了地。**已登记为跨 Feature 回写项 3**(§6.2 / §8):请 F055 给轮询载荷补 `entry_url`(`app_state=已上线` 时非空)。**在补上之前的降级行为**:`POST` 返回的 `entry_url` 非空则回显;为空则打印"入口地址请在应用详情页·发布获取"并照常退 0——**明确指路而不是打印一个空串或 `None`**(tasks 里对应任务标 `[受阻于 F055 回写]`)。 +- **原因**:可区分的退出码是 AC-31c 的字面要求,也是 agent 在无 TTY 下唯一可靠的分支依据;把"超时"单列一个码是因为它与"驳回"的后续动作完全相反(等 vs 改),共用非零码会让 agent 把等待当失败处理;把 `cancelled` / `exception` 单列同理——它们的后续动作是"去找管理员"而不是"改代码"或"继续等"。 +- **何时该重新考虑**:出现真实的审批推送通道(站内信 webhook / MCP 订阅)→ `--wait` 可以改成"挂起 + 事件唤醒",但短轮询必须保留为回落(客户环境未必开推送)。 + +### D8:`logs` = 服务端判权限、CLI 只呈现;`--follow` 用 `since` 短轮询 + +- **备选**: + - A. CLI 侧先查一次应用归属再决定要不要请求 — **错的**:会引入第二处归属判定,且 CLI 拿不到权威数据(AC-42 的判据是"密钥的**资源归属人**",只有服务端有) + - B. **直接打端点,把 403 / 16205 / 16254 翻成人话**(选定) +- **选定**:**B**。`GET /api/v2/apps/{app_id}/logs?tail=&since=&keyword=` → `{lines[]}`。**owner-only 由服务端判**(F055 tasks T039:读 `OpenApiPrincipal.resource_owner_user_id`,**不是** `subject_user_id`);即使密钥属于租户管理员名下的服务账号也照拒(AC-42),CLI 对此零逻辑。 +- **`--follow` 的实现**:无流式接口(服务端链路是 `GET /api/v2/.../logs` → F054 `GET /api/v1/apps/{id}/logs` → runtime-manager `docker logs`,全是一次性响应),所以只能**携 `since` 短轮询**(3 s)。⚠️ **必须做去重**:docker 时间戳是秒级,同一秒的多行在下一轮 `since` 里会重复返回(同类坑在本仓咬过:memory `project_chat_history_sameSecond_order`「create_time 秒级打平」)→ 保留上一轮最后 N 行的内容哈希集合,命中则跳过。 +- **AC-43(无运行实例时提示应用态)目前落不全**:F055 的 logs 返回**只有 `{lines[]}`**,没有 `app_state`。→ 已登记为**跨 Feature 回写项 2**(§6.2),建议 F055 把返回补成 `{lines[], app_state, pending_reason}`。**在补上之前的降级行为**:`lines` 为空时输出"未取到日志:应用可能没有运行实例(草稿 / 待上线 / 已下线),请在应用详情页确认应用态"——**明确提示而不是空白**,满足 AC-43 的精神但不满足其字面(tasks 里对应任务标 `[受阻于 F055 回写]`)。 +- **日志保留期不做承诺**:runtime-manager 侧是 docker 日志轮转窗口(30 MB/应用,F054 design `:334`)——CLI 对 `--since` 取到空结果只说"该时间段无日志或已轮转",不说"没有发生过"。 +- **何时该重新考虑**:runtime-manager 实现了**流式** logs(其 `GET /v1/apps/{id}/logs` 已由批 4 落码〔commit `d693feeb3`〕,但只回 `{lines:[...]}` 快照、非流式)→ `--follow` 可换 chunked 流,但仍要保留短轮询回落(nginx 300 s 上限对流式同样成立)。 + +### D9:错误呈现 = 「原始 code + 一句人话 + 下一步」三件套,且必须两套信封都认 + +- **备选**: + - A. 只打服务端 `message` — 缺点:平台的 message 面向"平台使用者",不含"你该改哪";agent 拿不到可执行的下一步 + - B. CLI 完全重写文案、藏起 code — 缺点:出问题时开发者报给支持的是一句翻译过的中文,支持侧无法定位 + - C. **三件套:原始 `code`(永远打印)+ CLI 的中文一句话 + 下一步指引**(选定) +- **选定**:**C**。`errors.py` 里一张 `ERROR_HINTS: dict[int, tuple[str, str]]`(code → (人话, 下一步)),未登记的 code 一律降级为"平台返回错误 {code}:{message}"并原样打 `message`、**以退出 19 结束**(**未知不等于崩溃,也不等于成功**;退出码确定是 AC-04 的要求,不能让未登记 code 落进 exit 1「CLI 内部异常」那一格)。 +- **⚠️ 必须两套信封都认**(坑 12):`/api/v2` 有**专属异常处理器**,真 HTTP 状态在状态行(401/403/404/500/503),body 仍是平台信封 `{status_code, status_message, data}`(`open_api/api/exception_handlers.py:36-61`);而 `/api/v1`(分发端点所在)**照旧 HTTP 200 + 信封**(`:61`)。解析顺序固定为:先读 body 里的 `status_code`(存在且 ≠ 200 即业务错误码),再看 HTTP 状态——反过来会把 `/api/v1` 的所有业务错误当成功。 +- **登记的主要 code**(全表见 §4.2 ②,来源 = F049 `common/errcode/open_api.py` + F055 design §4.2 ⑧ `:588-591`):`26001/26002/26027` → 退出 4;`26003` → 退出 5 且**逐字打出 `data.required` 里缺的位名**(AC-34);`26030`(503,fail-closed)→ 退出 7 且明说**可重试**;`16207` → 退出 8「本环境未启用应用工场」(AC-40);`16201/16202/16203` → 退出 6(改本地再传);`16221/16222/16223/16224/16227/16228/16230/16231` → 退出 10;`16241` → 退出 11;`16205/16229/16251/16252/16254` → 退出 12;**`16225` → 退出 13**、**`16226` → 退出 14**(见下条)。 +- **⚠️ 三条"绝不能合并"的映射(评审修订,`ERROR_HINTS` 落码前逐条对照)**: + 1. **`16225`(审批场景未启用)≠ `16226`(运行环境容量不足)**——F055 design D4 `:149` 与 §4.2 ⑧ `:596` 两处逐字警告过:"二者的 CLI 处置南辕北辙",前者是"平台没 seed 审批场景,找管理员"(改代码无用,退出 **13**),后者是"机器没资源,等一会儿或让 owner 手动上线"(可重试,退出 **14**)。把它们一起塞进"托管预检失败"就等于把这条警告作废。 + 2. **`16231`(本环境未启用能力总线)的处置是"删掉 `capabilities` 声明",不是"找管理员开开关"**——能力总线本轮整体后置(F055 §6 波次表),CLI 的下一步指引必须写"本版不支持能力声明,请从 `bisheng-app.yaml` 删除 `capabilities` 后重发",映射退出 **10**(要改 manifest)而不是退出 8(环境未启用)。`16230`(能力声明含密钥引用)同理。 + 3. **`16203`(缺 `bisheng-app.yaml`)必须单独有话说**——CLI 本地就该拦住它(AC-30 / §4.1 B 步 2),服务端仍回 16203 只有一种可能:**包根与项目根不一致**(打包起点选错)。文案要指向"检查 `deploy` 的 PATH 参数与包根",而不是"请创建 manifest"。 +- **`16229` 本轮无写入方但必须登记**:结构演进整体后置(F055 §6 波次表,`confirm_schema_change` 只接收不消费),所以 16229 本轮不会出现;但 `--confirm-schema-change` 参数本轮就实现(D11 第 4 条),映射不登记就会在结构演进上线那天变成"未知 code"。同理 `16253` / `16255` 属发布面动作、CLI 触发不到,走 D9 的降级分支(退出 19)即可,不单独登记。 +- **两个"看起来像 CLI 出错、其实不是"的码要特别标注**:`26004`(带了 `X-Bisheng-On-Behalf-Of` / `X-Bisheng-End-User`)——CLI **绝不发这两个头**,出现即 CLI bug,文案直接写"这是 CLI 缺陷,请报障";`26031`(端点漏 `@open_api_scope` 标记)——平台缺陷,文案写"请报平台故障"。把它们翻成"你的密钥有问题"会让排障走上完全错误的方向。 +- **何时该重新考虑**:`ERROR_HINTS` 超过 40 条 → 那时该考虑让服务端在 `hints[]` 里直接给面向 CLI 的下一步(F055 的失败五元组已有 `hints`,只是 260 段还没有),CLI 退化为纯呈现。 + +### D10:平台分发端点 = `/api/v1/dev-toolkit/*`,匿名可达,开关关闭时**不注册路由** + +- **备选(落点)**: + - A. `/api/v2/**` 下 — **结构性否决**:F049 计划把 `verify_open_api_access` 提升到整个 `router_rpc`(`src/backend/bisheng/api/router.py:123-126` 的注释),届时 `/api/v2` 下任何端点都必须持密钥;在那里放匿名端点是永久例外 + - B. 裸路径 `/cli/download`(照 `/health` 的挂法,`main.py:145-147`)— **不可达**:商业版网关只转发 `/api/v1/**` 与 `/api/v2/**`(CON-7),OSS nginx 同形 + - C. **`/api/v1/dev-toolkit/**` 下一个不挂 `Depends(UserPayload.get_login_user)` 的新 router**(选定) +- **选定**:**C**。两个端点: + - `GET /api/v1/dev-toolkit/versions` → 版本与兼容信息(形状见 §4.2,**一次留够 SDK 字段位**) + - `GET /api/v1/dev-toolkit/cli/download` → `FileResponse(wheel_path, filename=...)` + 模板 = `GET /api/v1/env`(`api/v1/endpoints.py:62-63`:普通 `@router.get`,函数签名里没有任何 auth `Depends` 即匿名);`FileResponse` 写法照 `open_endpoints/api/endpoints/filelib.py:496-506`(自动 `Content-Length`、支持 Range、零内存放大)。**不用 MinIO 预签 URL**:安装件是随镜像发布的静态物,不该进对象存储,且 `clear_minio_share_host`(`minio_storage.py:686`,docstring `:687-691` 逐字说明"让前端通过 nginx 代理访问资源")会返回依赖前端 nginx 的相对路径,CLI 直连时拿不到可用 URL。 +- **AC-05「开放能力层未部署 → 端点呈不存在」的落地 = 条件注册,不是抛 404**:`settings.open_platform.enabled`(`core/config/open_platform.py:16-20`,**进程级 YAML 配置、非 DB 热配置**)为假时**根本不 `include_router`** → FastAPI 天然 404。收益有三:① 字面满足"呈不存在";② **零新错误码**(CON-8);③ 不给未认证方留一个"这里有个功能但没开"的探测面。代价:改配置要重启后端——这正是进程级配置的既有语义,不是新增负担。 +- **⚠️ AC-05 的一半本轮做不到,此处显式登记为偏离(评审修订)**:spec §范围边界逐字写「**平台侧 login 校验入口**(含 `delegate` 拒绝)由本 Feature 拥有」、AC-05 要求开放能力层未部署时「安装件下载端点、技能包分发端点**与 login 校验入口**均不可达(呈不存在)」。但 `login` 校验打的是 **F049 已实现的 `GET /api/v2/auth/whoami`**,它挂在 `router_rpc` 上**恒在注册**(`api/router.py:123-126`),且服务账号模块「**恒在、不随 open_platform 开关消失**」是 F049 的显式设计(`core/config/open_platform.py:17-18` 注释逐字:"gates only the local-dev-toolkit scopes and the connect-info panel …; the service-account module is always on")。 + - **本轮的处置**:F053 只交付**两个分发端点**的条件注册;「login 校验入口不可达」这一半**不实现**。**CLI 侧的等价保证**:`login` 的**前置探测**先打 `versions`,404 且 `env.open_platform_enabled=false` → 判定「本环境未部署开放能力层」并**在打 whoami 之前**退出 8(§4.1 A)——开发者观感上 `login` 在该环境确实不可用,AC-10「可读原因」照落。 + - **为什么不建议改实现**:把 whoami 也置于开关下,等于让"服务账号模块恒在"这条 F049 设计破例,且会波及 F049 已落码的鉴权面。→ **建议改 spec 措辞**(把 AC-05 的"login 校验入口"改成"CLI 的 login 在该环境不可用并给出可读原因"),已登记为**回写项 4**(§6.2 / §8)。对比参照:AC-06(D14)与 AC-43(D8)也都是"上游未就位 → 明写降级"的同一种处置,本条与它们同档。 +- **必须同批做的两件事**:① 新 router 加进 `src/backend/bisheng/api/router.py`(`:66-112` 那一片);② **路径前缀 `/api/v1/dev-toolkit` 加进 `TENANT_CHECK_EXEMPT_PATHS`**(`utils/http_middleware.py:16-44`)。第二条不是可选优化:多租户开启时无 JWT 的请求**不会**调 `set_current_tenant_id`(`http_middleware.py:107-112` 只在 `not multi_tenant.enabled` 时兜底),任何 DAO SELECT 都会 `NoTenantContextError`;豁免名单会把整棵调用树置于 `_bypass_tenant_filter`(`:321-323`)。本端点若严格只读磁盘可以不加,但**版本端点要回 `open_platform_enabled` / `app_runtime_enabled` 且将来极可能读表**,现在就加更省事。 +- **wheel 怎么到镜像里**(坑 11,最容易在生产翻车的一条):backend 镜像的 build context 只有 `./src/backend/`(`.github/workflows/ci.yml:54` 的 `docker buildx build … ./src/backend/` + `src/backend/Dockerfile:13` `COPY ./ ./`)→ **`src/bisheng-cli/` 不在镜像内**。解法照 `linsight/builtin_skills/` 的判据(`builtin_skill_seeder.py:45-47` 逐字:"inside the package so every deployment shape (docker COPY, rsync, pip install) carries it automatically"):**wheel 产物随后端包同行**,放 `src/backend/bisheng/dev_toolkit/artifacts/`,由 `scripts/pack_cli_wheel.sh`(骨架照 `scripts/pack_linsight_skill.sh:1-40`)构建后拷入;端点用 `Path(__file__).resolve().parents[N] / "artifacts"` 定位(同款写法)。⚠️ **artifacts 目录名不能叫 `build` / `lib` / `wheels` / `sdist`**——`.gitignore:137,148,150,152` 这几条是**无前导斜杠的全局规则**,任何层级同名目录都会被忽略,产物提交不上去(`git check-ignore` 实测:`src/bisheng-cli/dist/` 不被忽略,因为 `/dist/` 是根锚定的;但 `build/x` 命中)。 +- **何时该重新考虑**:安装件从一个 wheel 变成多产物矩阵(二进制 × 平台 × 架构)→ `versions` 载荷升级为 `assets[]`,下载端点加 `?asset=`;或平台开始热更新技能包 → 那时分发端点要读 DB(届时 `TENANT_CHECK_EXEMPT_PATHS` 那条就是必需而不是预防)。 + +### D11:MVP-核心边界 = 顺延命令**不注册**,顺延契约**先留形状** + +- **备选**: + - A. 顺延命令注册但执行时报"本版本未提供" — 缺点:`--help` 会列出五个命令,agent 会去调,然后拿到一个非标准失败;且 AC-03「提供且仅提供五个子命令」在本轮本来就做不到,假装提供只是把缺口藏起来 + - B. **顺延命令不注册;顺延的数据契约先在载荷里留字段位**(选定) +- **选定**:**B**。落地为三条具体规矩: + 1. **`skills sync` / `dev` 不进解析器**:`bisheng --help` 本轮只列 `login` / `deploy` / `logs`。README 与 `--help` 尾部注明"`dev` / `skills sync` 随后续版本提供"。 + 2. **`versions` 端点的 SDK 字段位一次留够**(`sdk.version` / `sdk.min_compatible` / `sdk.download_path`,本轮值为 `null`):F057 AC-01/03 消费**同一端点**,不先留就会在 F057 期出现第二个端点或一次破坏性改形。 + 3. **版本兼容只提示不阻断**:CLI 每次命令前置探测 `versions`,`cli.min_compatible > 本地版本` → 打 warning + 下载链接,**不拒绝执行**;开启阻断只需把那句 warning 换成 `CliError`(AC-02 的完整落地随顺延项)。 + 4. **`--confirm-schema-change` 本轮就实现**:F055 端点"只接收不消费"(design D3),但参数存在与否是 CLI 的对外契约——现在不做,将来要改 CLI 与文档两处(F055 自己也是按这个理由留的端点参数)。 + 5. **多平台只落数据结构、不落交互**(评审修订,与 §1 非目标表和 `mvp-114-path.md` §6「多平台凭据」顺延列对齐):凭据文件本轮就按多 profile 结构写(D3,将来零迁移),但 **`--platform` 参数本轮不注册**——`deploy` / `logs` 一律用 `credentials.json` 的 `current`(由最近一次 `login` 覆盖)。顺延的是整个交互层:`--platform` 选择、`profile` 列出与切换默认平台。判据同本决策原则:**数据形状改一次、交互层改一次**,而 `--platform` 属交互层。 +- **原因**:区分"顺延项"的判据是**改一次 vs 改两次**——顺延交互层(命令、UI、阻断策略)改一次就够;顺延数据形状(端点载荷、文件结构、参数集)会让消费者写两遍。 +- **何时该重新考虑**:MVP 之后启动顺延波次时,本决策整体退役——届时删掉本节并把 §1 非目标表相应行迁进 §4。 + +### D12:依赖预算 = `httpx` + `PyYAML` 两条,其余全标准库 + +- **备选**: + - A. 零第三方(`urllib.request` + 手写 YAML 子集解析)— 缺点:multipart 上传要手拼 boundary、超时/代理/重试语义要自己实现、YAML 自研解析器在真实 manifest 上必错;错误面比省下的依赖大得多 + - B. `httpx` + `PyYAML` + `typer` + `rich`(生态标配四件套)— 缺点:四条内网安装链 + - C. **`httpx` + `PyYAML` 两条**(选定) +- **选定**:**C**,每条都有"为什么不能用标准库"的一句话论证: + - **`httpx`**:需要流式 multipart 上传、细分的 connect/read 超时、`trust_env` 开关(坑 2)。`urllib` 三样都要手写,且 multipart 手拼是典型的低级 bug 温床。(备选 `requests` 亦可,选 httpx 是因为 backend 已在用、行为对齐) + - **`PyYAML`**:`bisheng-app.yaml` 解析。必须 `yaml.safe_load`——`full_load` / `unsafe_load` 的 `!!python/object` 是 RCE(F055 design D3 逐字红线)。 + - **标准库覆盖其余全部**:`argparse`(命令面)· `tarfile` + `gzip`(打包)· `fnmatch` 的**段级**用法 + 手写段匹配(忽略规则,**不用它顶 gitignore 全语义**)· `getpass`(隐藏输入)· `json` / `pathlib` / `os` / `subprocess`(git 探测)/ `hashlib` / `time`。 +- **原因**:CON-2。每条依赖都要在客户内网 pip 镜像里存在;两条都是纯 Python、无二进制扩展、在任何镜像里都是最常见的包。 +- **何时该重新考虑**:出现"客户内网连 httpx 都没有"的真实案例 → 那时 wheel 改打 **vendored**(把两个依赖打进包)而不是回退到 urllib;或引入第三条依赖时,必须在本节补一行论证(这是刻意设的门槛)。 + +### D13:应用标识保存 = 项目根 `.bisheng/app.json`,**接收成功即写** + +- **备选**: + - A. 写进 `bisheng-app.yaml` — **否决**:manifest 形态归 F055 且 `extra='forbid'`(F055 design §4.2 ③),CLI 加字段会被服务端当场拒;spec 决议-6 也明写"不要求开发者手写" + - B. 存在全局凭据文件里(按项目绝对路径索引)— 缺点:项目移动 / 复制后失效,且团队成员之间无法共享 + - C. **项目根 `.bisheng/app.json`**(选定) +- **选定**:**C**。结构按平台 base URL 分键(同一项目将来可发到多个平台),`--app-id` 可显式覆盖。**建议开发者把 `.bisheng/` 提交进 git**(团队共享同一个 app_id),但它**结构性排除出上传包**(D4)——"进 git"与"进包"是两件事。 +- **⚠️ 关键时序:`POST /deploy` 返回 200 的那一刻就写盘,不等管线成功**。理由来自 F055 design D2 的选定("接收成功后立即创建草稿应用"):**首发预检失败时平台已经建好了草稿应用并分配了 `app_id`**,返回载荷里就有它。若 CLI 只在成功时才保存,开发者修完 manifest 重跑就会**再建一个应用**,构建页上堆一串同名草稿——而 F055 明确说"CLI 会把 app_id 随项目保存、重试 deploy 复用同一个 app_id",这条依赖是双向的。 +- **误投护栏**(AC-33 / spec §3):迭代 `deploy` 在上传前**必须**打印目标应用的名称与标识;交互环境下要确认(`--yes` 跳过),非交互环境下这行输出是唯一护栏。理由:指定了归属人不同的应用会被 16205 拒(安全),但指定了**同一归属人的另一个应用**会真的更新那个应用(无声的错误)。 +- **何时该重新考虑**:出现"一个项目目录发布多个应用"的真实诉求 → `app.json` 加 `profiles` 层(结构已按 dict 分键,扩展无破坏)。 + +### D14:`login` 的输出 = `whoami` 载荷 + 资源归属人;**`whoami` 目前缺该字段,需 F049 补** + +- **备选**: + - A. **请 F049 给 `WhoamiResponse` 加 `resource_owner: {user_id, user_name}`**(选定) + - ⚠️ **2026-09-10 前提已变**:底座改接发版线 beta2 后,`WhoamiResponse` 的实际字段是 `credential_id / actor_kind / actor_id / actor_name / tenant_id / resource_owner / authorization_subject_type / authorization_subject_id / effective_user_id / mode / scopes / key_mask / expires_at`——主体已扁平化为 `actor_*`,`resource_owner` **只剩 `user_id`、没有姓名**。CLI 已改为如实只印 ID 并在无归属人时直说「这把密钥还不能 deploy」,不再要求服务端补姓名。下方以 vibe 形状写的示例与回写项保留作历史。 + - B. F053 降级:只输出服务账号名,提示"去详情页看归属人" + - C. CLI 另调一个查归属人的端点 — **否决**:等于为 CLI 单开一条身份查询面,且那个端点的鉴权口径要重新论证 +- **选定**:**A**,B 作为 F049 未补前的**降级行为**。 +- **事实**:`login` 打的是 `GET /api/v2/auth/whoami`(`open_api/api/endpoints/auth.py:19-50`),它是 `/api/v2` 下**唯一 `@open_api_scope(None)` 的端点**(`auth.py:23`,"不需要任何权限位、只验密钥有效")——正是 AC-06「不校验任何权限位」的落点。但返回的 `WhoamiResponse`(`open_api/domain/schemas/credential.py:108-116`)只有 `subject_kind` / `service_account` / `tenant_id` / `scopes` / `key_mask` / `expires_at`,**没有资源归属人**;而 `OpenApiPrincipal.resource_owner_user_id` 在服务端是存在的(`open_api/domain/context.py:37-39`)。 +- **为什么值得让 F049 改**:AC-06 要求 `login` 输出资源归属人,理由不是"信息完整"而是**误配防线**——资源归属人就是此后 `deploy` 出的应用 owner,管理员把归属人选错(选成自己而不是开发者)是 F049 自己在签发表单上加风险提示的那个错误;如果开发者在 `login` 那一刻看不到它,这个错要等到 deploy 之后、甚至换人接手时才暴露。改动量 = 一个可选字段 + 一次 user 名查。 +- **降级行为(F049 补上之前)**:输出服务账号名 + `key_mask` + 租户,并明打一句"资源归属人请在服务账号详情页确认";对应 AC 标 `[受阻于 F049 回写]`。 +- **`delegate` 的处置(AC-09 / AC-13)**:`whoami.scopes` 含 `"delegate"` → 拒绝写凭据、给委托专用文案、退出码 5。⚠️ ~~**F049 期根本签不出这一位**~~(**2026-09-10 前提已变**:底座改接发版线 beta2 后 `delegate` 是常规可签发位、签发弹窗已提供,因此 AC-09 / AC-13 **可以且应当在真环境验**;服务端已补调用期拒绝 26051,CLI 侧映射同批落地。以下为改接前原文)(`open_api/domain/scopes.py:186-188` 的 NOTE 逐字:"deliberately NOT registered … ships with F050"),所以这段代码在 MVP 期**无法端到端验证**,只能用构造 mock 响应的单测覆盖——tasks 里对应任务必须写清这一点,否则实现者会去 114 上试然后判定"功能不生效"。 +- **何时该重新考虑**:F050 落地后 `delegate` 可签发 → 那时补一条真实的端到端用例;`whoami` 若被扩成"返回主体的完整画像",CLI 要重新审视存哪些字段(D3 说过只存展示用快照)。 + +--- + +## 4. 系统现状(接手必读) + +> 本 Feature 尚未开工,以下是**要建成的样子**。实现后按现状覆盖。 + +### 4.1 数据流 + +**A. `login`** + +``` +bisheng login [--api-key … | BISHENG_API_KEY | 隐藏输入 | --api-key-stdin] + → 前置探测 GET /api/v1/dev-toolkit/versions # 匿名,无需密钥 + ├ 404 → 再探 GET /api/v1/env 判别(三支各有确定退出码,AC-04/AC-10): + │ 能通且无 open_platform_enabled → "平台版本过老,不支持 CLI" → exit 9 + │ open_platform_enabled=false → "本环境未部署开放能力层"(AC-05/AC-10) → exit 8 + │ env 也不通 → "平台不可达" → exit 7 + │ ⚠️ 判定后**直接结束、不再打 whoami**——AC-05 的"login 不可用"由这里兑现(D10 偏离登记) + └ 200 → 记下 cli.min_compatible(本轮只提示不阻断,D11) + → GET /api/v2/auth/whoami Authorization: Bearer bs-sak-… + ├ 26001/26002/26027 → 密钥无效/撤销/过期/账号停用(彼此可区分,AC-10/AC-11) → exit 4 + ├ 26030 (503) → 平台鉴权服务暂不可用,可重试 → exit 7 + ├ scopes 含 "delegate" → 拒绝、不写凭据、委托专用文案(AC-09,D14) → exit 5 + └ 200 → 写 ~/.bisheng/credentials.json(0600,覆盖同 base_url 的 profile,D3) + → 输出:目标平台 / 服务账号名 / 资源归属人(或降级提示)/ key_mask / 到期时间 + ⚠️ 本轮不自动执行 skills sync(AC-08 随顺延项) +``` + +**B. `deploy`**(人读文本走 stderr、`--json` NDJSON 走 stdout,D2) + +``` +bisheng deploy [PATH] [--app-id] [--wait] [--confirm-schema-change] [--yes] [--json] + 1 载入凭据(无 → "请先 bisheng login",AC-51) → exit 3 + 2 定位项目根 + 读 bisheng-app.yaml(yaml.safe_load) + 缺文件 / YAML 不可解析 / 缺 name|runtime|port → 本地拒绝、列缺失项、不上传(AC-30) → exit 6 + 3 解析忽略规则(git ls-files 优先 / 子集解析器回落 / .bishengignore 收口,D4) + → 打包 tar.gz(结构性排除、跳过链接与设备文件、可复现、保留可执行位) + → --dry-run 到此为止:打印条目数 / 体量 / 被忽略统计 + 4 GET /api/v2/apps/deploy-limits → 超限则拒绝 + Top10 大文件 + 忽略建议(AC-32) → exit 6 + 取不到 → 只提示、继续(服务端 16201 兜底) + 5 迭代场景:读 .bisheng/app.json 的 app_id → 打印目标应用名称 + 标识(误投护栏,D13) + 交互环境要确认(--yes 跳过);非交互直接打印 + 6 POST /api/v2/apps/deploy (multipart: package + app_id? + confirm_schema_change) + ⚠️ 服务端在这一次同步响应里就跑完了「归属判定 · 大小闸 · 解包闸 · manifest 校验 + · 本地引用校验 · 在途单 / 待上线闸」(F055 design §4.1 ① `:411-427`), + **首发最常见的失败(manifest 缺字段)全部在这里返回、轮询里永远看不到**(D6) + → 200:{deployment_id, app_id, version_id, entry_url?} # 服务端此刻才 INSERT stage=received + ★ 立即把 app_id 写进 .bisheng/app.json(哪怕后续失败,D13) + ★ entry_url 非空则记下——轮询载荷里没有这个字段(D7 回写项 3) + → 同步错误分支(一条都不能漏,按 code 映射退出码后直接终止,不进轮询): + 16207 本环境未启用应用工场(AC-40) → exit 8 + 26003 缺 app:manage(逐字打 data.required) → exit 5 + 16205 该应用归属他人(含归属谁,AC-38) → exit 12 + 16201 包超上限 / 16202 包解析失败或非法路径条目 → exit 6(改本地再传) + 16203 缺 bisheng-app.yaml(多半是包根选错,D9) → exit 6 + 16221 manifest 校验失败(details 指出缺哪个字段) → exit 10 + 16222 runtime 不支持 / 16223 档位不存在或已停用 + / 16224 能力声明不可解析 / 16230 含密钥引用 + / 16231 本环境未启用能力总线(提示删 capabilities)→ exit 10 + 16229 结构变更未确认(提示 --confirm-schema-change;本轮无写入方,D9)→ exit 12 + 16251/16252 在途单 / 待上线态(提示撤回路径在应用详情页·发布)→ exit 12 + 7 轮询 GET /api/v2/apps/deployments/{deployment_id}(2s 起,×1.5 封顶 10s,D7) + 能看到的 stage 只有 received 之后的(服务端权威枚举见 D6): + received → secret_scan → precheck_build → precheck_probe → version_recorded → approval_created + (--wait 续轮还会看到 approved → publishing → online / pending_online) + 任一 failed → 打印 failure{message, hints[]}、机读原样透传 details → 按 code 映射退出码,终止 + 16227 依赖构建失败 / 16228 启动探活失败 → exit 10 + 16241 扫描命中(只打 file:line,永不回显值)→ exit 11 + 16225 审批场景未启用(找管理员 seed) → exit 13 ← 与 16226 绝不同码(D9) + 16226 运行环境容量不足(等资源 / 稍后重试)→ exit 14 + 未登记 code → 原样打印 code + message → exit 19 + 8a 无 --wait:轮到 status=waiting_approval 即返回 0,输出审批单标识 + 三条跟踪路径(AC-31b) + 8b 有 --wait:续轮至终态(D7 终态表) + 0 通过并上线(入口地址取 POST 返回的 entry_url;为空则指路应用详情页·发布 —— 回写项 3) + 20 驳回 / 21 撤回 / 22 待上线 / 24 取消(应用已删)/ 25 审批异常(审批人为空)/ 23 超时 +``` + +**C. `logs`** + +``` +bisheng logs [--app-id] [--tail N] [--since TS] [--keyword K] [--follow] [--json] + → 凭据 + app_id 解析(同 deploy 第 1/5 步) + → GET /api/v2/apps/{app_id}/logs?tail=&since=&keyword= → {lines[]} + 16254 仅 owner 可执行 / 16205 归属他人 → "该应用不属于当前密钥的资源归属人"(AC-42,服务端判,D8) → exit 12 + 26003 缺 app:manage 位(逐字打 data.required) → exit 5 + lines 为空 → 提示可能无运行实例(AC-43 降级行为,D8) + → --follow:每 3s 带 since 再拉,按内容哈希去重(秒级时间戳会重复,D8) +``` + +### 4.2 关键数据结构 / 字段约定(对外契约) + +**① 命令与参数面**(本轮实现三条;`skills sync` / `dev` 不注册,D11) + +| 命令 | 位置参数 | 参数 | 说明 | +|---|---|---|---| +| `login` | `` | `--api-key` · `--api-key-stdin` · `--json` | 密钥来源优先级:`--api-key` > `BISHENG_API_KEY` > `--api-key-stdin` > TTY 隐藏输入;四者皆无 → exit 2(AC-07) | +| `deploy` | `[PATH]`(默认 `.`) | `--app-id` · `--wait` · `--wait-timeout`(默认 1800)· `--confirm-schema-change` · `--yes` · `--dry-run` · `--json` | `--dry-run` 只打包 + 本地校验 + 打印体量统计,不上传。**无 `--platform`**:本轮恒用 `credentials.json` 的 `current`(D11 第 5 条) | +| `logs` | — | `--app-id` · `--tail`(默认 200)· `--since` · `--keyword` · `--follow` · `--json` | `--follow` 短轮询 3 s(D8)。**无 `--platform`**,同上 | +| 全局 | — | `--version` · `--help` · `--verbose` · `--quiet` · `--timeout` | `--verbose` 打印请求方法/路径/状态码;**Authorization 头恒掩码** | + +**② 退出码表**(AC-04「确定的退出码」/ AC-31c「四种终态各有可区分退出码」的权威定义) + +> **一码一处置**:本表的分格判据是**开发者/agent 的下一步动作**,不是"错误的严重程度"。凡两个 code 的下一步动作不同,就必须落在不同的码上(D9 三条红线)。 + +| 码 | 含义 / 下一步动作 | 典型来源 | +|---|---|---| +| 0 | 成功(`--wait` 下 = 通过并上线) | — | +| 1 | CLI 内部未预期异常 | traceback 仅在 `--verbose` 打印 | +| 2 | 用法 / 参数错误 | 缺必填参数、互斥参数同用 | +| 3 | **未登录**(AC-51) → 先 `bisheng login` | 无凭据文件或无该平台 profile | +| 4 | 鉴权失败 → 换 / 重签一把密钥 | `26001` / `26002` / `26027` | +| 5 | 权限不足 / `delegate` 拒绝 → 找租户管理员编辑密钥位 | `26003`(打出 `data.required`)· AC-09 | +| 6 | 本地校验失败 / 包不合法 → 改本地再传 | manifest 缺失或不合法(本地)· 路径不存在 · `16201` 超上限 · `16202` 解析失败或非法路径条目 · `16203` 缺 `bisheng-app.yaml`(多半包根选错) | +| 7 | 平台不可达 / 服务暂不可用 → 稍后重试 | 网络错误 · `26030`(**可重试**) | +| 8 | 环境未启用 → 找管理员开开关并重启后端 | 分发端点 404(开放能力层)· `16207`(工场运行时层) | +| **9** | **平台版本过老,不支持本 CLI** → 升级平台,或换用与平台同版的 CLI | `versions` 404 且 `/api/v1/env` 无 `open_platform_enabled`(§4.1 A) | +| 10 | 托管预检失败 → 改 manifest / 改代码后重发 | `16221` · `16222` · `16223` · `16224` · `16227` · `16228` · `16230` · `16231`(后两者的下一步是**删掉 `capabilities` 声明**,D9 红线 2) | +| 11 | 密钥扫描命中 → 移除密钥后重发 | `16241` | +| 12 | 发布流程冲突 / 归属与操作权限 → 撤回在途单、或换归属人正确的密钥 | `16205` · `16229` · `16251` · `16252` · `16254`(仅 owner 可执行) | +| **13** | **平台未 seed 审批场景** → 找平台管理员,**改代码无用** | `16225`(D9 红线 1) | +| **14** | **运行环境容量不足** → 等资源 / 稍后重试 / 让 owner 手动上线 | `16226`(D9 红线 1,**绝不与 13 合并**) | +| **18** | **缺陷类:CLI 或平台的实现缺陷** → 停下报障,**重试与改参数都无用** | `26004`(CLI 发了它从不该发的身份传递头 = CLI 缺陷)· `26031`(平台端点缺权限位标记 = 平台缺陷) | +| **19** | **平台返回未登记的错误码** → 原样打印 `code` + `message`,按其内容处置 | `16253` / `16255` 等 CLI 触发不到的码;F055 将来新增的码(D9 降级分支) | +| 20 / 21 / 22 | `--wait` 终态:驳回 / 撤回 / 待上线 | `approval.status` · `pending_reason` | +| 23 | `--wait` 超时(**非失败**) → 继续等或换跟踪方式 | 到 `--wait-timeout` 仍未落定 | +| **24** | `--wait` 终态:**审批单已取消**(目标应用被删除)→ 这单不会有结论 | `approval.status=cancelled`(F055 D10 `cancel_instance_by_business`) | +| **25** | `--wait` 终态:**审批异常**(审批人解析为空)→ 找管理员处理审批异常,这单不会有结论 | `approval.status=exception`(F055 K2 ② `decision=EXCEPTION` / approver_empty) | + +> **⚠️ exit 18 为什么不并进 1 或 19(2026-08-17 裁决,此前 D9 只说要"特别标注" `26004` / `26031` 却没给退出码)**:这张表存在的唯一理由是让本地 coding agent 不读散文就能决定下一步,所以分格的判据是**动作是否不同**,不是"错误看起来有多严重"。三者的动作各不相同:exit **1**(CLI 内部崩了)——重试一次是合理的;exit **19**(没有登记的码)——读 `message` 后可以改参数重试;exit **18**——重试与改参数**都保证无用**,唯一正确动作是停下报障。这是第三种动作,所以是第三个码。另外把 `26031` 映到 19 还会**给出假标签**:19 的语义是"未登记的码",而这两个码 CLI 认得清清楚楚。 + +**③ 凭据文件** `~/.bisheng/credentials.json`(目录 `0700` / 文件 `0600`;Windows 用等价 ACL) + +```jsonc +{ "version": 1, + "current": "https://bisheng.example.com", + "profiles": { + "https://bisheng.example.com": { + "base_url": "https://bisheng.example.com", + "api_key": "bs-sak-…", // 明文,权限位是唯一保护(D3) + "key_mask": "bs-sak-********abcd", + "tenant_id": 1, + // ⚠️ 以下为 vibe F049 形状,2026-09-10 已被 beta2 契约取代(见上方订正) + "service_account": {"id": 123, "name": "…"}, + "resource_owner": {"user_id": 7, "user_name": "…"} | null, // 待 F049 补(D14) + "expires_at": "2026-12-31T00:00:00" | null, + "logged_in_at": "2026-08-17T10:00:00+08:00" + } } } +``` +**恒不存 `scopes`**(AC-52,D3)。 + +**④ 项目级应用标识** `<项目根>/.bisheng/app.json`(建议进 git、**结构性排除出上传包**) + +```jsonc +{ "version": 1, + "apps": { "https://bisheng.example.com": { + "app_id": "…", "app_name": "…", "slug": "…", + "last_deployment_id": "…", "updated_at": "…" } } } +``` + +**⑤ 分发端点**(本 Feature 拥有;`settings.open_platform.enabled=false` 时**路由不注册** → 404,D10) + +| 端点 | 鉴权 | 返回 | +|---|---|---| +| `GET /api/v1/dev-toolkit/versions` | **匿名** | 见下 | +| `GET /api/v1/dev-toolkit/cli/download` | **匿名** | `FileResponse`(wheel,`application/octet-stream` + `Content-Disposition`) | + +```jsonc +{ "status_code": 200, "status_message": "SUCCESS", "data": { // /api/v1 恒 HTTP200 + 信封(坑 12) + "cli": { "version": "3.0.0", "min_compatible": "3.0.0", + "filename": "bisheng_cli-3.0.0-py3-none-any.whl", + "sha256": "…", "download_path": "/api/v1/dev-toolkit/cli/download" }, + "sdk": { "version": null, "min_compatible": null, "download_path": null }, // F057 消费,本轮留位(D11) + "platform": { "version": "3.0.0", + "open_platform_enabled": true, "app_runtime_enabled": true } } } +``` +> ⚠️ `platform.version` **不得**取 `/api/v1/env` 的 `version`——那是硬编码 `'2.6.0-fix'`(`src/backend/bisheng/__init__.py:7`,3.0-vibe 分支实测),CLAUDE.md §7「版本字段不可靠」在本分支依然成立。本端点自带版本真相(读 CLI 产物旁的 `artifacts/manifest.json`)。 + +**⑥ 机读输出(`--json`,NDJSON,stdout)** + +```jsonc +{"event":"stage","command":"deploy","stage":"precheck_build","status":"running|passed|failed","ts":"…", + "failure":{"stage":"…","code":16227,"message":"…","details":{…},"hints":["…"]}} // 服务端五元组原样透传 +{"event":"progress","command":"deploy","phase":"upload","sent_bytes":…,"total_bytes":…} +{"event":"result","command":"deploy","ok":false,"exit_code":10, + "data":{"deployment_id":"…","app_id":"…","version_id":"…","entry_url":null, + "stage":"…","app_state":"…","pending_reason":null,"approval":{"instance_id":"…","status":"…"}}, + "failure":{…}} // 恒为最后一行 +``` +> - **`stage` 恒为服务端原值**,取值集见 D6(含 `received` / `version_recorded` / `approval_created` / `approved` / `publishing` / `online` / `pending_online`);未知值原样透传,不翻译、不报错。 +> - **同步段的失败没有 `stage` 事件**:`POST /deploy` 的同步错误直接落进 `result.failure`(`failure.stage` 用服务端给的值,缺省时为 `null`),因为那时 `deployment_id` 可能还不存在(D6 / §4.1 B 步 6)。 +> - **`data.entry_url` 只可能来自 `POST /deploy` 的返回**——轮询载荷里没有这个字段(D7 回写项 3)。首发时它大概率为 `null`,agent 不得把 `null` 当成"上线失败",应用是否上线看 `result.exit_code` 与 `data.app_state`。 + +**⑦ 消费但不拥有的形状**(全部以 F055 design §4.2 为准,CON-5) +`POST /api/v2/apps/deploy` 入参与返回(**`entry_url` 只在此出现**)· `GET /api/v2/apps/deployments/{id}` 轮询载荷(`stage`(枚举见 D6)/ `status ∈ {running, waiting_approval, succeeded, failed}` / `failure` / `app_id` / `version_no` / `approval{instance_id,status,reject_reason}` / `app_state` / `pending_reason ∈ {"capacity","deploy_failed",null}`;**无 `entry_url`**)· `GET /api/v2/apps/deploy-limits` · `GET /api/v2/apps/{app_id}/logs` · **AppManifest 字段表**(必填 `name`/`runtime`/`port`;可选 `description`/`icon`/`slug`/`tier`/`capabilities`/`database`/`egress`/`manifest_version`;`extra='forbid'`)。 +`approval.status` 的取值 CLI 必须认全六种:`pending` / `rejected` / `withdrawn` / **`cancelled`**(应用被删除致取消,F055 D10)/ **`exception`**(审批人为空,F055 K2 ②)/ `executed|approved`(通过,后续看 `app_state`)——后两个异常终态漏认 = `--wait` 死等到超时(D7)。 + +### 4.3 关键模块职责 + +| 模块 / 文件 | 职责 | 不做什么 | +|---|---|---| +| `src/bisheng-cli/pyproject.toml` | 包元数据;`[project.scripts] bisheng = "bisheng_cli.main:main"`(**全仓第一条**);hatchling + `packages=["bisheng_cli"]` | 不引除 `httpx` / `PyYAML` 外的运行期依赖(D12) | +| `bisheng_cli/main.py` · `cli.py` | 入口与 argparse 子命令注册、全局参数、异常 → 退出码收口 | 不含任何业务逻辑;**不注册顺延命令**(D11) | +| `bisheng_cli/http.py` | httpx 客户端封装:base URL、Bearer 头、超时、**两套信封解析**(坑 12)、代理处理(坑 2)、前置版本探测 | 不判权限位、不缓存 `scopes`(AC-52) | +| `bisheng_cli/credentials.py` | 凭据读写、`0600` 原子创建、profile 选择 | 不做加密(明文 + 权限位是既定取舍,D3) | +| `bisheng_cli/project.py` | 项目根定位、`bisheng-app.yaml` `safe_load` + 三必填快速失败、`.bisheng/app.json` 读写 | **不做完整 manifest 校验**(权威在 F055,CON-1);不写 manifest | +| `bisheng_cli/ignore.py` | 三层忽略规则(git 优先 / 子集解析 / `.bishengignore`) | 不用 `fnmatch` 顶 gitignore 全语义(坑 9) | +| `bisheng_cli/packaging.py` | tar.gz 打包、结构性排除、体量与条目统计、Top10 报告 | 不静默截断;不上传 | +| `bisheng_cli/errors.py` | `CliError` 体系、`ERROR_HINTS` 翻译表、退出码映射 | 不吞 `details` / `hints`(D6) | +| `bisheng_cli/output.py` | 人读(stderr)/ NDJSON(stdout)双形态、进度、**掩码器** | 不做彩色/表格(无 rich,D2) | +| `bisheng_cli/commands/{login,deploy,logs}.py` | 三条命令的编排 | 不直接用 httpx(走 `http.py`) | +| **平台侧** `bisheng/dev_toolkit/api/{router.py,endpoints/distribution.py}` | 两个匿名端点;开关关闭时**不注册**(D10) | 不直接 import `database/models`(RULE-3);不查 DB | +| **平台侧** `bisheng/dev_toolkit/artifacts/`(**目录名不能叫 build/lib/wheels/sdist**,坑 11) | 随后端包同行的 wheel + `manifest.json`(版本 / sha256) | 不进 MinIO(D10) | +| **平台侧存量改动** `bisheng/api/router.py` · `bisheng/utils/http_middleware.py` | 挂新 router;`/api/v1/dev-toolkit` 进 `TENANT_CHECK_EXEMPT_PATHS` | — | +| `scripts/pack_cli_wheel.sh` | `uv build` → 校验 → 拷进 `artifacts/` → 写 `manifest.json`(骨架照 `scripts/pack_linsight_skill.sh:1-40`) | 不发布到任何 registry | + +--- + +## 5. 已知坑 / 反直觉事实 + +| # | 反直觉事实 | 如果不知道会怎样 | 在哪处理 | +|---|---|---|---| +| 1 | **`open()` 后再 `chmod(0600)` 中间有一个 world-readable 窗口**;且 Windows 上 `os.chmod` 对"其他用户可读"基本无效(只影响只读位) | 密钥在多用户机器 / 跳板机上被同机用户读走;Windows 上"已设权限"是假的 | `credentials.py`:`os.open(..., O_CREAT\|O_EXCL\|O_WRONLY, 0o600)` 一步到位 + 父目录 `0700`;Windows 走 `icacls` 等价 ACL,**做不到就明确警告而不是假装成功** | +| 2 | **httpx / requests 默认读 `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY`**。开发者机器上常年挂着科学上网代理,而平台是内网地址 → 请求被送去代理、超时或拿到代理的 502 | 表现为"平台不可达",实际平台好好的;本仓已因缺 socksio 吃过整批误报(memory `reference_local_backend_pytest_socks_proxy`) | `http.py`:默认 `trust_env=True` 但**在连接失败时检测代理环境变量并在错误里明说**"检测到 `ALL_PROXY=…`,内网地址请加入 `NO_PROXY`";提供 `--no-proxy` 直接 `trust_env=False` | +| 3 | **`.venv/` `node_modules/` 不排除时 50 MB 上限秒破**,而开发者的第一反应是"平台上限太小" | 每个新用户的第一次 deploy 都失败;支持工单里全是"包太大" | `packaging.py` 结构性硬排除(D4)+ 超限报告里**先列被排除了多少**,再列 Top10 | +| 4 | **tar 成员路径必须是 posix 相对路径**;Windows 上 `Path` 给出 `\` 分隔、可能带盘符 | 服务端解包出 `src\main.py` 这样的**单个文件名**(Linux 不认 `\` 为分隔符),应用目录结构全平;或触发 16202 非法路径条目 | `packaging.py`:`arcname = PurePosixPath(*rel.parts)`,且落包前逐条 assert 无 `\`、无绝对路径、无 `..` | +| 5 | **Windows 控制台默认 GBK**,打印中文 hints 里的生僻字或 emoji 会 `UnicodeEncodeError` 直接崩,掩盖真正的错误 | 用户看到的是 Python traceback 而不是"缺 `app:manage` 位" | `main.py` 启动时把 `sys.stdout/stderr` 用 `errors="replace"` 的 UTF-8 wrapper 包一层;输出文案不用 emoji | +| 6 | **`--wait` 绝不能靠长连接**:nginx `proxy_read_timeout 300s`(`docker/nginx/conf.d/default.conf:53`),而审批是人工动作、可跨天 | 演示时等到第 5 分钟连接被掐、退出码变成网络错误,看起来像"审批失败" | D7 短轮询 + 退避;`--wait-timeout` 到点退 23 并明说"这不是失败" | +| 7 | **密钥被吊销后的错误码有三种,处置各不相同**:`26002`(无效/撤销/过期)· `26027`(服务账号停用)· 而多租户下**默认租户被禁用**会给一个与密钥无关的 `20001`/403 | 把 `20001` 当成"密钥不对",让用户反复重新签发密钥;实际要去解禁默认租户 | `errors.py` 三条分别登记;排障口径写进 README:**先看 body 的 `status_code` 是 20001 还是 260xx** | +| 8 | **中间件会把 `Authorization: Bearer bs-sak-…` 当 JWT 取走**(`utils/http_middleware.py:60-73`),解码失败后把租户置成 `DEFAULT_TENANT_ID`(`:81-84`, `:99-106`),再由 `verify_open_api_access` 重新播种真租户 | 不知道这一段,就无法解释"为什么 `/api/v2` 请求会先撞默认租户的禁用黑名单"(`:344-355`);排障方向全错 | 平台侧既有行为,CLI 不修;坑 7 的排障口径依赖它 | +| 9 | **`fnmatch` 的 `*` 跨 `/` 匹配,且根本没有 `**`**(`linsight/domain/services/workspace_backend.py:616-619` 自己的注释记录了这两点) | 忽略规则静默失效或过度匹配;本仓已真实咬过一次——提示词逐字教的 `glob("/uploads/**/*.xlsx")` 一直 0 匹配(memory `project_taskmode_selftest_114_glob_bug`) | `ignore.py` 手写段匹配(每段单独 `fnmatch`,`**` 单独处理),**并有一张覆盖 `**` / 前导 `!` / 目录后缀 `/` 的用例矩阵** | +| 10 | **`arch-guard.sh` 的 RULE-7 会扫到 CLI 的 `.py`**(`:96-100`,匹配 `(password\|secret_key\|api_key\|access_token)\s*=\s*['"][^'"]{8,}['"]`),尽管其余 9 条规则对 `src/bisheng-cli/` 都不生效。**但它输出的是 `⚠️ [arch-guard] RULE-7 WARNING`(`:99`),不是 VIOLATION、不阻断**——脚本头注释 `:13` 标「硬编码敏感信息检测(C6,**WARNING**)」,VIOLATION 只有 RULE-4/5/8/9/10 | 测试里写一个 `api_key = "bs-sak-xxxxxxxxxxxx"` 的样例常量,每次 Write/Edit 都被 hook 刷一条 warning:**不会拦住你,但会被当成噪声长期忽略,真的硬编码密钥那天也就没人看了**。反向误解同样有害——以为它是 VIOLATION(CLAUDE.md §5「VIOLATION must be fixed immediately」)会让实现者以为 hook 阻断了提交而去改错地方 | 测试用 fixture / 环境变量 / 短占位符;**长密钥字面量一律不写进 `.py`**,让 RULE-7 在本工程保持零输出 | +| 11 | **backend 镜像的 build context 只有 `./src/backend/`**(`ci.yml:53` + `Dockerfile:13`)→ `src/bisheng-cli/` 不在镜像里;而 `.gitignore` 的 `build/` `lib/` `sdist/` `wheels/` 是**无前导斜杠的全局规则**,任何层级同名目录都被忽略 | "本地能下载、生产 404";或产物目录取名 `build/` 后 `git add` 静默失败,CI 打出的镜像里没有 wheel | `scripts/pack_cli_wheel.sh` 把 wheel 拷进 `bisheng/dev_toolkit/artifacts/`(**这个名字是刻意挑的**);114 是真 git 检出 + `deploy.sh`、不走镜像,两条路径都要验 | +| 12 | **`/api/v2` 有真 HTTP 状态码,`/api/v1` 恒 HTTP 200 + 信封**(`open_api/api/exception_handlers.py:36-61`,`:61` 是 v1 分支) | 只按 HTTP 状态判断 → `/api/v1` 的所有业务错误被当成功;只按 body 判断 → `/api/v2` 的 503 被当成有效响应 | `http.py` 统一解析:**先读 body `status_code`,再看 HTTP 状态**(D9) | +| 13 | **`deploy` 返回 200 不代表发布成功**——F055 在"接收成功"时就已创建草稿应用并分配 `app_id`(F055 design D2 选定 B) | CLI 只在成功时保存 `app_id` → 预检失败后重跑会**再建一个应用**,构建页堆一串同名草稿;且 F055 明确依赖"CLI 保存 app_id 以复用" | `commands/deploy.py`:拿到 200 立即写 `.bisheng/app.json`(D13) | +| 14 | **服务端 tar 解包闸会拒符号链接 / hardlink 成员 / 设备文件 / FIFO**(F055 design D2) | 本地不跳过 → 每次都吃 16202,且错误发生在上传之后(浪费一次上传) | `packaging.py` 本地就跳过并**列出被跳过的项**(不静默)。**但 hardlink 成员不靠「跳过硬链接文件」规避**——手工构造 `TarInfo` 的写入路径根本产不出该成员类型,跳过只会白丢内容(见 D4 订正) | +| 15 | ~~**`delegate` 位在 F049 期根本签不出来**~~(2026-09-10 作废,见 D14 订正:beta2 底座下该位可签发,验收改真环境)(`open_api/domain/scopes.py:186-188` 的 NOTE 逐字:"deliberately NOT registered … ships with F050") | 实现者去 114 上试 AC-09,试不出来,判定"功能没生效"并去改代码 | AC-09 / AC-13 的验收方式**只能是单测**(构造 mock `whoami` 响应);tasks 里必须写明(D14) | +| 16 | **服务端权限位有 3 秒正向缓存上界**(`core/config/open_platform.py:31-35`,默认 3、硬顶 5) | 以为"编辑权限位立刻生效"是零延迟,测试里补勾后立刻验证会偶发失败 | AC-52 的验收留 5 秒余量;CLI 侧零缓存(D3) | +| 17 | **`GET /api/v2/apps/deploy-limits` 取不到时不能挡死发布**(F055 design D2 逐字) | 端点还没上线 / 网络抖动时 `deploy` 直接不可用——一个软校验把主流程打死 | `commands/deploy.py`:取不到 → 用内置默认值只提示、继续上传,由 16201 兜底 | +| 18 | **`GET /api/v2/apps/{id}/logs` 的服务端链路目前是断的——但断点不在 runtime-manager**:manager 侧 `GET /v1/apps/{id}/logs` 批 4 已实现(`contracts-runtime-manager.md` §2 已列为就绪端点),真正缺的是**链路中段** F054 T057(backend 转发)与 F055 T039(权限判定)| 按契约写完 CLI 后在 114 上验不通,误判为 CLI bug 或误以为要等编排器 | `logs` 联调排在 F054 T057 / F055 T039 之后;CLI 侧单测用 mock。manager 侧口径已定:`since` 收 epoch 秒或 `30m/2h/7d`;带 `keyword` 时行数 < `tail` 是设计;dockerd 宕机返 503→`16121`(**不是** 404,404 只表示实例不存在)| +| 19 | **`docker logs` 时间戳是秒级**,`--follow` 用 `since` 续拉时同一秒的行会重复返回 | 日志刷屏重复,看起来像应用在疯狂打同一行(同类坑:memory `project_chat_history_sameSecond_order`) | `commands/logs.py` 按最后 N 行内容哈希去重(D8) | +| 20 | **tar 里丢掉可执行位,上线后 entrypoint 脚本不可执行**,而故障要到构建 / 探活阶段才暴露(16227 / 16228) | 排查方向跑偏到"平台构建有问题",实际是打包把 `0755` 归一成了 `0644` | `packaging.py` 模式归一时**保留 owner 执行位**(D4) | +| 21 | **`/api/v1/env` 的 `version` 是硬编码 `'2.6.0-fix'`**(`src/backend/bisheng/__init__.py:7`,3.0-vibe 实测) | 拿它做 CLI 与平台的版本比对 → 永远判"不兼容"或永远判"兼容",两种都错 | 分发端点自带版本真相(D10 / §4.2 ⑤);CLAUDE.md §7 已列此陷阱 | + +--- + +## 6. 对外契约与依赖 + +### 6.1 我提供给别人的(Outgoing) + +| 契约 | 形式 | 谁在用 | +|---|---|---| +| `GET /api/v1/dev-toolkit/versions`(§4.2 ⑤,含 `sdk.*` 字段位) | HTTP(**匿名**) | CLI 自身前置探测 · **F057**(SDK 版本与最低兼容版本,AC-01/03 消费同一端点)· 接入信息区(顺延) | +| `GET /api/v1/dev-toolkit/cli/download` | HTTP(**匿名**,`FileResponse`) | 开发者 / 管理员转发的下载链接(AC-01「是链接不是文件」)· 接入信息区(顺延) | +| **CLI 退出码表**(§4.2 ②) | 进程契约 | 本地 coding agent(AC-04 / AC-47 的自动化判定依据)· 114 演示脚本 · 未来 CI | +| **`--json` NDJSON 事件流**(§4.2 ⑥) | 数据契约 | 同上;`result` 恒为最后一行是 agent 可依赖的不变量 | +| **凭据文件格式**(§4.2 ③) | 文件契约 | `dev`(顺延,读同一凭据)· 顺延的多平台管理命令 | +| **`.bisheng/app.json`**(§4.2 ④) | 文件契约 | 团队成员共享同一 `app_id`;`logs` 与迭代 `deploy` | +| `bisheng/dev_toolkit/artifacts/` 布局 + `scripts/pack_cli_wheel.sh` | 构建契约 | 发版流程(wheel 必须在打镜像前拷入)· 顺延的 SDK 与技能包分发复用同一目录与脚本 | + +### 6.2 我依赖别人的(Incoming) + +| 依赖 | 形式 | 风险点 | +|---|---|---| +| **F055 `POST /api/v2/apps/deploy` / `GET /deployments/{id}` / `GET /deploy-limits` / `GET /{app_id}/logs`**(design §4.2 ①) | HTTP | **CON-5**:路径、入参、载荷字段任一变更 → CLI 当场坏。尤其 `stage` 取值与顺序(D6)、`failure` 五元组(D6)、`pending_reason` 与 `approval.status` 枚举(D7) | +| **F055 的同步段 / 异步段划分**(design §4.1 ① `:411-427`) | 控制流契约 | **归属判定 · 大小闸 · 解包闸 · manifest 校验 · 本地引用校验 · 在途 / 待上线闸全在 `POST /deploy` 的同步响应里**,轮询只能看到 `received` 之后的阶段。若 F055 把某一闸挪进异步段(或反之),CLI 的"哪些 code 走同步分支、哪些走轮询分支"必须同批改(§4.1 B 步 6/7、D6) | +| **F055 `entry_url` 的返回位置** | HTTP | **⚠️ 回写项 3:`entry_url` 只在 `POST /deploy` 的返回里(`055-.../design.md:492`),轮询载荷没有**,且首发时应用是草稿、大概率为 `null` → AC-31c「通过并上线(输出入口地址)」/ AC-33 / 决议-12 只能降级(D7)。请 F055 给 `GET /deployments/{id}` 补 `entry_url`(`app_state=已上线` 时非空) | +| **F055 `secret_scan` 的执行位置**(design D5 挂着一个"提前到构建之前"的**待 ★ 确认偏离**) | 顺序契约 | 若确认提前 → CLI 的 `STAGE_LABELS` 顺序、README 与 **spec AC-31a** 必须**同批**改;不同批 = CLI 打印的阶段顺序与实际不符 | +| **F055 162 段错误码语义**(design §4.2 ⑧ `:588-598`) | 错误码 | **一码一义红线**:`16225`(审批场景未启用,找管理员)vs `16226`(容量不足,等资源)—— F055 早期版本曾把两者写成同码;CLI 的 `ERROR_HINTS` 若跟着写错,用户会按完全错误的方向排障。→ **本文 §4.2 ② 已把二者分成 exit 13 / exit 14 兑现该红线**,落码时不得回退成同一格 | +| **F055 AppManifest 形态**(design §4.2 ③,`extra='forbid'`) | 数据契约(YAML) | CLI 只校验三必填;**CLI 与技能包都不得自造字段**,否则"本地过、上传被拒" | +| **F049 `GET /api/v2/auth/whoami`** + 260 段错误码与真 HTTP 状态 | HTTP | `WhoamiResponse` 若改字段名 → `login` 输出坏;~~**⚠️ 回写项 1:需 F049 给它加 `resource_owner: {user_id, user_name}`**~~(2026-09-10 作废:beta2 契约只给 `user_id`,CLI 已按实呈现),否则 AC-06 只能降级(D14) | +| **F049 `app:manage` 位判定(端点 `open_api_scope("app:manage")` marker + `router_rpc` 的 `verify_open_api_access`,2026-09-10 改接 beta2)与 3 秒缓存上界** | 服务端行为 | 缓存上界变化 → AC-52 的验收余量要跟着改 | +| **F049 `whoami` 恒在注册 + 服务账号模块恒在** | 部署形态 | **⚠️ 回写项 4(spec 侧)**:`open_api_v2_router` 挂在 `router_rpc` 上恒在(`api/router.py:123-126`),服务账号模块「恒在、不随 open_platform 开关消失」是 F049 显式设计(`core/config/open_platform.py:17-18` 注释)→ **AC-05 的"login 校验入口不可达"这一半本轮不实现**,CLI 用前置探测在打 whoami 之前退出 8 作等价保证(D10 偏离登记)。建议改 spec 措辞而非改 F049 实现 | +| **F055 `logs` 返回形状** | HTTP | **⚠️ 回写项 2:目前只有 `{lines[]}`,缺 `app_state` / `pending_reason`**,AC-43「明确提示应用态」只能降级实现(D8) | +| **F054 `GET /api/v1/apps/{id}/logs` → runtime-manager `GET /v1/apps/{id}/logs`** | 服务端链路 | manager 段**已实现**(批 4 `d693feeb3`);缺的是中段 **F054 T057 + F055 T039** → `logs` 联调排在这两条之后(坑 18) | +| **F054 `contracts-runtime-manager.md` §5 注入环境变量清单** | 数据契约 | `bisheng dev` 顺延,但**将来必须同名注入**:`BISHENG_APP_DB_URL` · `BISHENG_APP_DB_PATH` · `BISHENG_APP_ID` · `BISHENG_APP_SLUG` · `BISHENG_APP_VERSION` · `BISHENG_APP_VERSION_ID` · `BISHENG_PLATFORM_API_BASE` · `PORT` 与 `BISHENG_APP_PORT`(恒等)· `BISHENG_APP_BASE_PATH`(dev 期同为 `/apps/{slug}`,2026-10-11 起;此前为空串)· `BISHENG_APP_HEALTH_PATH`;**F051 模型面三名(2026-09-16 起)**:`OPENAI_BASE_URL` · `OPENAI_API_KEY` · `BISHENG_MODEL_BASE_URL`——`dev` 期的 base URL 取自 `GET /api/v2/auth/whoami` 的 `model_base_url` 字段(F051 AC-30 的唯一出口,不要在 CLI 侧拼 `/api/v2/model/v1`),`OPENAI_API_KEY` 即开发者当前那把服务账号密钥明文。**清单唯一来源是该文件,不得在 CLI 侧另抄一份定义**;平台保留 env 名**覆盖**调用方同名值 | +| **平台 `settings.open_platform.enabled` / `settings.app_runtime.enabled`** | 部署配置 | 进程级 YAML;`load_settings_from_yaml` 对未知顶层键 `raise KeyError`(`common/services/config_service.py:107`)→ **先发代码、再改 YAML、最后重启** | +| `bisheng/api/router.py` 的 include 顺序 · `TENANT_CHECK_EXEMPT_PATHS` | 注册点 | 漏加豁免 → 多租户环境下端点一旦读表即 `NoTenantContextError`(D10) | +| nginx `client_max_body_size 1024m` / `proxy_read_timeout 300s`(`docker/nginx/conf.d/default.conf:60,53`;114 上是 `/etc/nginx/conf.d/bisheng-lilu.conf`) | 基础设施 | 上传超时取 240 s 是**贴着 300 s 定的**,改任一个要同时看另一个(D5 / D7) | +| `httpx` · `PyYAML`(客户内网 pip 镜像) | 第三方依赖 | 装不上 = CLI 装不上;引入第三条依赖必须在 D12 补论证 | + +--- + +## 7. 测试与可观测 + +**分层策略**(CLI 是独立工程,测试也独立跑:`cd src/bisheng-cli && uv sync && uv run pytest`) + +- **单元测试(默认全跑,零网络)**——CLI 的价值密度几乎全在这一层: + - 忽略规则矩阵:`**` / 前导 `!` / 目录后缀 `/` / 段内 `*` / `.bishengignore` 覆盖 `.gitignore`;git 路径与子集解析器**跑同一份样本目录并断言结果一致**(坑 9)。 + - 打包断言:`.venv/` `node_modules/` `.git/` 不在包内;**凭据文件绝不在包内**(AC-07 的 assert 型测试);成员路径无 `\`、无绝对路径、无 `..`(坑 4);符号链接被跳过且被列出(坑 14);可执行位保留(坑 20);两次打包 sha256 相同(可复现)。 + - 错误翻译:`ERROR_HINTS` 逐条一个用例,断言输出**不含任何密钥子串**(AC-04);未登记 code 走降级分支不抛异常。 + - 退出码映射:§4.2 ② 每一行一个用例;**额外三组必测**:① `16225` → 13 与 `16226` → 14 **断言不同码且下一步文案不同**(D9 红线 1);② `POST /deploy` 的同步错误体(16221 / 16203 / 16251…)走同步分支并终止,**断言不进轮询循环**(D6);③ 未登记 code → exit 19 且原样打印 code。 + - `--wait` 终态机:`rejected` / `withdrawn` / `cancelled` / `exception` / `app_state=已上线` / `pending_reason` 各一个 mock 轮询序列,**断言 `cancelled` 与 `exception` 立刻结束(24 / 25)而不是等到超时 23**(D7)。 + - 轮询 `stage` 全枚举(含首条 `received`)都能翻译或原样打印,**断言 `received` 不落进未知兜底**(D6)。 + - 凭据:`0600` / 目录 `0700`(POSIX);不写 `scopes`;同平台重复 login 覆盖。 + - `delegate` 拒绝:**只能用 mock `whoami` 响应**(坑 15)。 + - 信封两解:`/api/v1` HTTP200+信封 与 `/api/v2` 真状态码各一组(坑 12)。 +- **`@pytest.mark.network`(默认跳过,CI 不跑、114 手验跑)**:打真平台的 `versions` / `whoami` / `deploy-limits`。marker 形态照 `src/runtime-manager/pyproject.toml:32-39` 的 `docker:` marker。 +- **平台侧端点**:`src/backend/test/dev_toolkit/test_distribution_api.py`——匿名可达、`open_platform.enabled=false` 时 404(**断言是路由不存在,不是错误码**)、`FileResponse` 的 `Content-Disposition` 与 `Content-Length`、多租户开启且无 JWT 时不抛 `NoTenantContextError`。 +- **CI**:仓内 CI 目前**不跑** runtime-manager / app-proxy 的任何测试(`.github/workflows/{ci,test,release,base_ci}.yml` 零命中)。CLI 至少要把 `ruff check` + `pytest -m "not network"` 接进 `frontend-quality.yml` 同级的一个新 job,否则它会和 runtime-manager 一样长期无人验证。 + +**114 手动验证**(对应 `mvp-114-path.md` §1 演示剧本步 2–3;⚠️ 部署用 `bash /opt/bisheng-ops/deploy.sh`,不要 rsync) + +```bash +# 0. 平台侧:确认开关与端点(匿名,无需密钥) +curl -s http://<114>/api/v1/env | python3 -c 'import sys,json;d=json.load(sys.stdin);print(d["data"]["open_platform_enabled"], d["data"]["app_runtime_enabled"])' +curl -s http://<114>/api/v1/dev-toolkit/versions # 开关关 → 404(这就是 AC-05 的验证) +# 1. 装 CLI(两条路径都要验) +curl -sO http://<114>/api/v1/dev-toolkit/cli/download && pip install ./bisheng_cli-3.0.0-py3-none-any.whl +bisheng --version +# 2. login(key 来自演示剧本步 1 签发的 bs-sak-…,勾了 app:manage) +BISHENG_API_KEY=bs-sak-… bisheng login http://<114> +ls -l ~/.bisheng/credentials.json # 必须是 -rw------- +# 3. deploy(表单问卷小应用:python3.11 + bisheng-app.yaml) +bisheng deploy --dry-run # 先看打包体量与忽略统计 +bisheng deploy --wait --json | tee deploy.ndjson; echo "exit=$?" +tail -1 deploy.ndjson | python3 -m json.tool # result 行 +# 4. logs +bisheng logs --tail 200 +``` +- **AC-42(owner-only)必须用第二把「资源归属人不同」的密钥验证**,不能用同一把——同一把永远通过,测了等于没测。 +- **⚠️ `health 200` 会骗人**(admin 短路 ReBAC,memory `reference_remote_dev`):CLI 的所有权限相关判定都走服务账号(非 admin),但**平台侧任何用浏览器 admin 账号做的旁证都不算数**。 +- **失败面演练**:把 `bisheng-app.yaml` 的 `runtime` 改错验 16222;塞一个含 `bs-sak-` 字面量的文件验 16241(**断言输出里没有那个值**);把 `.venv/` 造大验超限报告。 + +**可观测**:CLI 无服务端日志。`--verbose` 打请求方法 / 路径 / HTTP 状态 / 耗时,**Authorization 头恒掩码**(AC-04)。服务端侧的观测走 F055 的 `app_deployment` 表与 `app.release.*` 审计事件——排障时"CLI 说失败了"要以 `deployment_id` 去那两处对账,而不是只看终端输出。 + +--- + +## 8. 后续改进 / 不打算做的事 + +**必须回写上游的四项**(已在 §6.2 登记,tasks 里各一条任务): +1. ~~**F049 给 `WhoamiResponse` 加 `resource_owner: {user_id, user_name}`**~~——2026-09-10 作废:底座已换 beta2,姓名不再下发,AC-06 按「如实只印 ID + 无归属人时明确提示」交付。 +2. **F055 给 `GET /api/v2/apps/{id}/logs` 的返回补 `app_state` / `pending_reason`**——否则 AC-43 只能降级(D8)。 +3. **F055 给 `GET /api/v2/apps/deployments/{id}` 的轮询载荷补 `entry_url`**(`app_state=已上线` 时非空)——目前它只在 `POST /deploy` 的返回里、首发时大概率为 `null`,否则 AC-31c「通过并上线(输出入口地址)」/ AC-33 / 决议-12 只能降级为"指路应用详情页"(D7)。 +4. **F053 spec 的 AC-05 措辞**——「login 校验入口在开放能力层未部署时不可达」与 F049「服务账号模块恒在 + `whoami` 恒在注册」的既成实现冲突(D10 偏离登记)。建议把该半句改成「CLI 的 `login` 在该环境不可用并给出可读原因」,由前置探测 + exit 8 兑现;**改 spec 而不是改 F049**。 +(另有一项由 F055 侧发起、F053 只需接受:F055 tasks T050 回写项 3 已登记「AC-32 的上限经 `deploy-limits` 取、取不到由 16201 兜底」——**本文 §D4 已按该口径写,不要另立一套**。) + +**顺延波次的落点**(§1 非目标表已给方向,此处只补优先级):`skills sync` + 两包技能包(优先级最高,它是 AC-47「agent 全程不离开本地对话」的前提)→ 接入信息区(前端,低成本)→ 版本兼容阻断(一行 warning 升 error)→ `dev`(最重,依赖 F054 app-proxy 与 F057 SDK)→ 多平台管理命令 → 单文件二进制。 + +**已知短板,暂不投入**: +- **无 shell 补全、无彩色输出**——D2 的依赖预算取舍,客户明确要求前不做。 +- **`--follow` 是短轮询不是流**——服务端无流式接口(坑 18),有了再改。 +- **CLI 不做本地密钥预扫**——spec 决议-2:同一规则集分两处必漂移,扫描只在 F055 服务端做一次。**这条被提议过不止一次,不要再提**。 +- **不提供 `logout` / `whoami` 子命令**——spec §范围边界:登出 = 删凭据文件或重新 `login` 覆盖;`whoami` 的信息已在 `login` 输出里。 +- **不提供 `init` / 工程骨架生成**(PRD-1 DEV-04「命令数刻意收在最少」)、**不提供 `--as`**(PRD-1 §5.1 / INV-32)、**不提供撤回命令**(撤回只在应用详情页·发布做,AC-37)、**不生成二维码**(spec 决议-12)。 +- **不做分片上传 / 断点续传**——D5,50 MB 上限下是过度设计。 + +--- + +## 修订历史 + +| 日期 | 改动 | 触发原因 | +|---|---|---| +| 2026-08-17 | 初版(14 决策 / 21 坑)。按 `mvp-114-path.md` §6 MVP-核心裁剪:只做 CLI 包工程 + `login` + `deploy` + `logs` + 平台分发端点;顺延项在 §1 与 §8 留落点。上游契约逐条贴 F055 design §4.2 / F049 已实现鉴权面 / F054 `contracts-runtime-manager.md` §5 | 探查笔记 `f053-notes/e1-cli-packaging-and-endpoints.md` + spec 55 AC | +| 2026-08-17 | **评审修订(12 条,2 high / 6 medium / 4 low)**:① **`deploy` 控制流按 F055 同步 / 异步段重建**——归属判定 · 大小闸 · 解包闸 · manifest 校验 · 本地引用校验 · 在途 / 待上线闸全部改挂 `POST /deploy` 的同步响应(`055-.../design.md:411-427`),轮询只保留 `received` 之后的阶段(§4.1 B 步 6/7、D6、§6.2 新增一行);② **`entry_url` 取值缺口登记为回写项 3**——它只在 `POST /deploy` 返回、轮询载荷没有,`--wait` 上线分支改为"有则回显、无则指路应用详情页"的降级(D7 / §4.1 B 8b / §4.2 ⑥⑦ / §8);③ **`stage` 改用 F055 权威枚举 11 值**(`:86`),`approval_created` 不再写成中文、补 `received` 等六个漏项(D6);④ **退出码表重排**——`16225`→13 与 `16226`→14 拆开兑现 F055 一码一义红线,补 `16201/16202/16203/16223/16224/16230/16231/16254` 映射、新增 exit 9(平台版本过老)与 exit 19(未登记 code 降级)(§4.2 ② / D9 / §4.1 A);⑤ **`--wait` 终态补 `cancelled`(24)与 `exception`(25)**——否则应用被删或审批人为空时死等到超时(D7 / §4.2 ⑦);⑥ **`--platform` 本轮不注册**,与 §1 非目标表和裁剪基准对齐,只保留多 profile 数据结构(§1 / D11 第 5 条 / §4.2 ①);⑦ **AC-05 的"login 校验入口不可达"登记为显式偏离 + 回写项 4**——`whoami` 挂 `router_rpc` 恒在、服务账号模块恒在(`api/router.py:123-126`、`open_platform.py:17-18`),CLI 用前置探测退出 8 作等价保证(D10 / §6.2 / §8);⑧ **`dist/` `build/` 从硬排除降为软排除**——真实应用会带前端构建产物,硬排 = 静默截断(D4);⑨ 坑 10 订正为 **RULE-7 是 WARNING 不是 VIOLATION**(`arch-guard.sh:99` / `:13`,§2 同改);⑩ 行号订正 `clear_minio_share_host:686`(docstring `:687-691`)、`ci.yml:54`;⑪ §1 补「与裁剪基准的两处显式偏离」表(`login` 拒 `delegate` / `--confirm-schema-change` 本轮提前做及其理由);⑫ §7 补三组新增单测(同步 / 异步分支路由、13 vs 14 不同码、`cancelled` / `exception` 不等超时、`received` 不落兜底)。**未采纳 1 项**:`config_service.py` 的 `raise KeyError` 实测在 **`:107`**(`sed -n '104,110p'` 核实),原文引用正确,评审建议的 `:108` 有误 | design 评审 12 条 ISSUE 逐条处理 | + +--- + + + diff --git a/features/v3.0.0/053-dev-cli-skills/spec.md b/features/v3.0.0/053-dev-cli-skills/spec.md new file mode 100644 index 0000000000..04da5a6784 --- /dev/null +++ b/features/v3.0.0/053-dev-cli-skills/spec.md @@ -0,0 +1,242 @@ +# Feature: 开发者 CLI 与技能包(五命令 + 两包技能包 + 安装件分发 + 接入信息区) + +> **本文档定位 — 纯 What(需求口径,不随代码漂移)** +> +> spec 只回答 **做什么 / 验收标准 / 不做什么**。所有 How(决策、数据流、字段、API、Service、前端、文件清单、性能指标)一律不写在这里, +> How 的唯一真相在 [design.md](./design.md) 与 [tasks.md](./tasks.md)。 + +**关联 PRD**: [docs/PRD/3.0-release/3.0 应用工场 PRD-1 专业开发者通道与应用运行时.md](../../../docs/PRD/3.0-release/3.0%20应用工场%20PRD-1%20专业开发者通道与应用运行时.md) **v2.0** §2.2(主旅程)/ §3.1 引言(本域操作者是 agent)/ DEV-01(① 权限位与三面映射、④ 凭据落地、⑤ 接入信息区、⑥ 可选部署)/ DEV-02 ④(三面仅模式 S、CLI `login` 拒 `delegate`)/ DEV-03(两包技能包)/ DEV-04(CLI 五命令、deploy 管线四步、托管运行契约、日志三入口)/ DEV-05(`bisheng dev` 身份注入 + 本地 sqlite)/ DEV-07(不进 SDK 的两样与同名环境变量对齐)/ GOV-07 ②(CLI 通道准入闸 = `app:manage`)/ GOV-10(开放能力层开关)/ §5.1(不做 `--as`);伴生 [docs/PRD/3.0-beta2/3.0 开放 API 鉴权与身份传递 PRD.md](../../../docs/PRD/3.0-beta2/3.0%20开放%20API%20鉴权与身份传递%20PRD.md) **v2.1** §4.2.4(三扩展位)/ 附录 B.2(本地开发三面:CLI 行的权限位与「仅 S」);《3.0 应用工场 产品方案》§5.1 F106(部分) +**同步记录**: 2026-08-17 初稿(按 PRD-1 v2.0 + 伴生 PRD v2.1 + release-contract 拆分 v2 表 3 F053 行;§4 决议 1–11 于同日按用户授权的全自动模式直接定案并留痕);同日按独立审查(spec-checklist)13 项裁定就地修订:`bisheng-app.yaml` 对外形态 owner 改 F055(表 1 AppManifest)、AC-31 拆 a/b/c、AC-39 删除(墓碑保号)、AC-48 收为引用、追加 AC-51–53、决议-5 / 决议-9 改写、新增决议-12;编号说明见文末附表 +**优先级**: P0 +**所属版本**: v3.0.0 +**依赖**: F049(服务账号与密钥底座、`app:manage` / `identity:read` / `model:invoke` 三扩展位登记、开放能力层部署开关、供新端点复用的鉴权依赖);F051(模型协议面——接入信息区的 OpenAI 兼容 base URL、`dev` 注入的模型接线环境变量、「平台能力接线」包的模型一节依赖它);F052(MCP 面——接入信息区的 MCP 地址、技能包接线内容、`logs` 与 MCP 日志工具的口径一致性依赖它);F057(「平台能力接线」包中 SDK 三件套用法章节与其自检脚本随 F057 交付后补齐完善,其验收随 F057——本 Feature 交付包结构、sync 机制与「部署纳管」包全部内容)。**`deploy` / `logs` 两命令的服务端**分别由 F055(发布管线,含 `bisheng-app.yaml` 对外形态的定义与校验)与 F054(实例日志读取接口,经 F055 接线)提供——本 Feature 只交付命令侧行为,两命令随其就绪启用(release-contract 表 3) +**批次**: A 尾 / B;**MVP-114 首波**([../mvp-114-path.md](../mvp-114-path.md) §2)= CLI 包工程 + `login`(存凭据)+ `deploy`(打包 → 上传 → 触发管线 → 分阶段输出预检 / 扫描 / 审批结果)+ `logs` + 安装件平台分发(wheel + 下载端点);纵切外(release 仍必做,排后)= `skills sync` 两包内容、`dev`(迷你代理注入 + 本地 sqlite)、接入信息区与一键复制、`login` 拒 `delegate`、非 Claude 引擎 AGENTS.md 引用 + +> **范围边界** +> +> 本 Feature 是专业开发者通道(PRD-1 §3.1)的**工作流入口**:开发者与其本地 coding agent 借它拿到使用知识(技能包)、本地跑通(`dev`)、导入纳管(`deploy`)、排障(`logs`)。它站在批次 A 的凭据地基(F049)与两个协议面(F051 / F052)之上,`deploy` / `logs` 的服务端在批次 B(F054 / F055)。**MVP-114 首波先做**:CLI 包工程 + `login`(存凭据)+ `deploy` + `logs` + 安装件平台分发;其余纳入项(`skills sync` 两包内容、`dev`、接入信息区、`login` 拒 `delegate`)排在纵切之后、release 仍必做。 +> +> - **本次纳入**: +> - **CLI 包工程与安装件平台分发**:可安装的 `bisheng` CLI 发布件;平台自身提供下载端点与版本信息(纯内网零公网依赖、下载不要求密钥或登录态);CLI 与平台版本对齐、不兼容时明确提示;五个子命令与 `--help`;非交互(agent)可用、机器可读输出、输出永不回显密钥明文 +> - **`login`**:以服务账号密钥登录目标平台,仅验密钥有效不验权限位;凭据落本地用户目录、属主可读、不落工程目录;输出服务账号与其资源归属人;成功后自动执行一次 `skills sync`;**持 `delegate` 位的密钥在 `login` 即拒**并提示「委托专用、本地开发另发一把」(INV-31);同机多平台凭据隔离;平台侧 login 校验入口(含 `delegate` 拒绝)由本 Feature 拥有 +> - **`skills sync` 与 DEV-03 两包技能包**:从已登录平台拉两包到本地用户目录、幂等覆盖更新、与平台版本对齐;两包内容——「部署纳管」(`bisheng-app.yaml` 写法 / 能力声明 / 安全红线 / 托管运行契约 / deploy 工作流与预检排障指引)与「平台能力接线」(模型与应用数据库两条标准库接法由本 Feature 交付;SDK 三件套用法章节含 auth 静默失败点显著标注随 F057 交付后补齐、其验收随 F057);每包 SKILL.md + 可运行样例 + 连通自检脚本(接线包自检脚本随 F057 补齐);触发描述不绑产品名;内外同源单一来源;非 Claude Code 引擎经 AGENTS.md 引用;平台侧技能包分发端点由本 Feature 拥有 +> - **`dev`**:本地运行应用 + 内置迷你代理注入与线上同构的身份头(= 当前密钥对应的服务账号)+ 剥离伪造头 + 起本地 SQLite 并注入与线上同名的连接环境变量 + 注入与线上同名的平台接线环境变量 + 输出明示注入身份来源;**无 `--as`**;不代理平台能力调用 +> - **`deploy`(命令侧)**:以 `bisheng-app.yaml` 为发布输入并做本地必填校验;按忽略规则打包当前项目为版本快照 → 上传 → 触发 F055 发布管线 → 分阶段输出托管预检 / 安全扫描 / 审批单状态(默认到审批单生成即返回,`--wait` 至审批终态与上线结果);准入闸 = 密钥的 `app:manage` 位(GOV-07 ②);在途审批单拒绝并提示;改 / 删列须 `--confirm-schema-change` 或交互确认;归属人不同的密钥 deploy 既有应用被拒并提示归属;首发输出应用标识、后续以之关联同一应用;上传体量上限与忽略规则 +> - **`logs`**:查看自己应用(= 密钥资源归属人 owner 的应用)的运行日志,需 `app:manage`、owner-only;内容范围与权限口径与 MCP 应用日志工具(F052)、应用详情页运行日志(F054 WB-13)三入口一致 +> - **DEV-01 ⑤ 接入信息区**(platform 服务账号详情页「API 密钥」tab 顶部):MCP 地址 / OpenAI 兼容 base URL / CLI 下载链接 / `bisheng login` 指引 + 「一键复制接入信息」;不含 key 值或任何可换取 key 值的凭据;未部署开放能力层不出现 +> - **`bisheng-app.yaml` 的消费**:它是 `deploy` 的发布输入与 `dev` 的启动依据;其对外形态(名称 / 描述 / 图标 / `runtime` / 端口 / 档位 / 能力声明 / 应用数据表结构 / 出站域名白名单)**由 F055 拥有**(release-contract 表 1 候选 AppManifest:发布输入的权威 = 管线校验方),本 Feature 的 CLI 按该形态打包、本地校验必填项、`dev` 据此启动,**不自定义任何字段**;「部署纳管」包只教写法、引用 F055 的定义 +> - **本次明确排除**: +> - **发布管线服务端**(托管预检 / 安全扫描规则集 / 审批单生成与预置审批流 / 上线终检 / 版本记录与快照写入 / 结构演进的服务端判定与迁移前快照 / 元信息与档位随 deploy 更新的落库 / `deploy` · `logs` 的服务端权限判定含拒 `delegate`)与 **`bisheng-app.yaml` 对外形态的定义**(AppManifest)——**F055**;本 Feature 的 `deploy` 只验收命令行为,管线阶段用「经 F055」措辞 +> - **实例运行日志的读取接口与 WB-13 详情页运行日志 tab**——**F054**(`logs` 只消费);**MCP 六类工具(含应用状态 / 日志工具)与统一检索门面**——**F052**;**模型协议面**——**F051**;**服务账号、密钥签发与三扩展位的表单层**——**F049**;**应用运行时、app-proxy 与线上身份注入**——**F054** +> - **SDK 三件套(auth / retrieve / storage)与开发者指南**——**F057**;「平台能力接线」包中的 **SDK 用法章节与其自检脚本随 F057 交付后补齐、验收随 F057**(本 Feature 不单独承诺接线包内容可通过),`dev` 注入的环境变量名与 F057 / F054 对齐但不拥有 SDK 本身 +> - **`init` / 工程骨架生成**(PRD-1 DEV-04 一句话「命令数刻意收在最少」);**五命令之外的任何子命令**(如 `logout` / `whoami`——登出 = 删除本地凭据文件或对同一平台重新 `login` 覆盖) +> - **`--as` 身份模拟及任何指定其他用户身份的入口**(PRD-1 §5.1 / DEV-05 ①)——本地验 per-user 差异的路径 = 发布后用真实账号访问 +> - **CLI 调用限流、token 用量上限**(PRD-1 §5.2 顺延);**第三方 MCP 网关、本地端专有插件**(§5.1);**平台侧接入信息的分享链接 / 二维码 / 站内信推送**(DEV-01 ⑤ 本版不做——走企业既有渠道) +> - **CLI 侧复制一份密钥扫描规则集做本地预扫**——扫描只在 F055 服务端做一次(同一规则集、不分叉),CLI 只结构性排除自身产物(§4 决议-2) +> - **单文件二进制安装件**(§4 决议-1 备选,v3.1 视客户反馈);**平台侧回滚**(RT-05 顺延——CLI 路径等价手段是本地 checkout 旧版本重新 `deploy`) + +--- + +## 1. 用户故事 + +作为 **开发者(owner)**, +我希望 **拿到管理员转来的一段接入信息与一把 key 后,一条 `login` 就接好平台、本地 `dev` 跑起来时应用拿到的身份结构与线上一模一样、`deploy` 一条命令走完预检 → 扫描 → 审批、`logs` 能看到自己应用的日志**, +以便 **把本地开发的存量应用交给平台托管、发给同事用,全程不离开终端,也不用为「本地」和「线上」写两套身份与数据库代码**。 + +作为 **开发者的本地 coding agent**, +我希望 **`login` 后技能包已经在本地、每个命令都能在无 TTY 下跑通且输出可解析、预检与扫描失败时能拿到「缺什么、怎么改」而不是堆栈**, +以便 **凭技能包 + CLI + MCP 自主完成声明补全 → deploy → 预检修复 → 审批状态跟踪的闭环,不需要开发者中途接管**。 + +作为 **租户管理员**, +我希望 **在服务账号详情页一键复制出不含 key 值的接入信息发给开发者,而 CLI 通道只认 key 上的 `app:manage` 位、不碰角色配置**, +以便 **放开一个人的 CLI 部署能力只需签一把 key,回收只需吊销那把 key,不引入新的角色权限点,也不用担心接入信息本身泄漏**。 + +作为 **平台管理员 / IT(信创与纯内网环境)**, +我希望 **CLI 安装件与技能包都由平台自己分发、随平台版本一起升级、不访问任何公网地址**, +以便 **离线环境也能完整走通开发者通道,且升级平台后开发者只需从平台重新下载或重跑一次 `skills sync`**。 + +--- + +## 2. 验收标准 + +> AC 编号在本特性内唯一。括号内标注对应 PRD-1 条目与验收序号(如 `↔DEV-04 验收 5`)、伴生 PRD 附录 B.2 及跨 Feature 不变量。属于服务端管线 / 运行时的行为用「经 F055」「经 F054」措辞——本 Feature 只验收 CLI 侧的命令行为与呈现。错误码仅作可观测行为引用,定义在 design.md。2026-08-17 审查修订后编号保持稳定:拆分用 `AC-31a/b/c`、删除留墓碑(AC-39)、新增末尾追加(§2.9),见文末附表。 + +### 2.1 安装件与分发、CLI 通用行为 + +- **AC-01** — THE SYSTEM SHALL 提供 `bisheng` CLI 安装件并由平台自身分发(下载端点 + 当前版本信息);同一分发端点亦承载 bisheng-sdk 发布件及平台声明的 SDK 最低兼容版本(F057 AC-01 / AC-03 消费);纯内网环境零公网依赖即可下载与安装;下载链接即接入信息区展示的链接(AC-44),下载不要求持有密钥或平台登录态。(↔DEV-04 需求描述 / DEV-01 ⑤;§4 决议-1 / 决议-11) +- **AC-02** — THE SYSTEM SHALL 使 CLI 安装件随平台版本发布、版本号与平台版本对齐,`bisheng --version` 可显示;WHEN 命令与平台交互, THE SYSTEM SHALL 校验 CLI 与目标平台的兼容性——IF 不兼容, THEN 拒绝执行并提示从该平台重新下载(给出下载链接);兼容但落后时仅提示不阻断。(↔DEV-03 规则「版本对齐」;§4 决议-7) +- **AC-03** — THE SYSTEM SHALL 提供且仅提供五个子命令 `login` / `skills sync` / `dev` / `deploy` / `logs`(各带 `--help`);THE SYSTEM SHALL NOT 提供 `init` / 工程骨架生成、`--as` 或任何指定其他用户身份的参数。(↔DEV-04 界面表 / DEV-05 ① / §5.1) +- **AC-04** — THE SYSTEM SHALL 使每个命令在非交互环境(无 TTY,如被本地 coding agent 调用)可完整执行:每处交互确认均有等价命令行参数、结果以确定的退出码与可读文本给出、并提供机器可读的输出模式;THE SYSTEM SHALL 在任何输出、本地日志与错误信息中不回显密钥明文(至多掩码)。(↔§3.1「本域的操作者是 agent」/ NFR-2) +- **AC-05** — WHERE 开放能力层未部署, THE SYSTEM SHALL 使安装件下载端点与技能包分发端点不可达(呈不存在),且使 `login` 在该环境**不可用并给出可读原因**,平台其余功能零变化(AC-10)。(↔GOV-10 规则 / DEV-01 ⑥;F049 AC-49) + > **2026-08-17 裁决**:原文写的是「login 校验入口**也**不可达」,与 F049 **已落码**的实现冲突——服务账号模块恒在、`whoami` 恒在注册(`api/router.py:123-126`;`core/config/open_platform.py:17-18` 的注释逐字写着 "the service-account module is always on")。**改本册措辞,不改 F049**:把 `whoami` 也置于开关之下,等于让 F049 的显式设计为一条 CLI 的验收破例,并波及已经发出去的鉴权面。用户可感知的结果完全一样——CLI 的前置探测发现该环境没有开放能力层就以 exit 8 退出并说清原因(T008),而「端点不存在」与「端点在但整层没装」对使用者是同一件事。**判据**:AC 描述的是**用户能观察到的行为**,不是服务端的实现形状;当两者只在实现形状上冲突、行为完全等价时,改 AC。 + +### 2.2 `login` 与凭据 + +- **AC-06** — WHEN 开发者以目标平台地址与服务账号密钥执行 `bisheng login`, THE SYSTEM SHALL 向该平台校验密钥有效(未撤销且未过期,模式 S),**不校验任何权限位**;通过后把凭据写入本地用户目录,并输出登录成功、目标平台地址、服务账号名称及其**资源归属人**(后者即此后 `deploy` 出的应用 owner)。(↔DEV-04 界面表 login / DEV-01 ①「login 与 skills sync 仅验 key 有效」/ 伴生 B.2 CLI 行) +- **AC-07** — THE SYSTEM SHALL 把 CLI 凭据存放在本地用户目录、以属主可读写的文件权限保存,不落当前工程目录,且不进入 `deploy` 上传包(AC-32);密钥可经交互式隐藏输入或环境变量传入,不强制以命令行参数明文传递。(↔DEV-01 ④ / 验收 4) +- **AC-08** — WHEN `login` 成功, THE SYSTEM SHALL 自动执行一次 `skills sync`(AC-14),开发者无需再手动敲;IF 该次自动 sync 失败, THEN THE SYSTEM SHALL 保持登录成功、输出失败原因并提示可手动重跑 `skills sync`。(↔DEV-04 界面表 login / 验收 11;DEV-03 规则) +- **AC-09** — IF 密钥持有 `delegate` 位, THEN THE SYSTEM SHALL 在 `login` 即拒绝:不写入凭据、错误明确指向「这把 key 配置为委托专用,本地开发请另发一把」;THE SYSTEM SHALL NOT 静默按服务账号身份继续(不得表现为登录成功),也不得只返回一个参数类错误码。(↔DEV-02 ④ / 验收 10;DEV-01 ① / 验收 10;INV-31) +- **AC-10** — IF `login` 使用的密钥无效 / 已撤销 / 已过期、目标平台不可达、或目标平台未部署开放能力层, THEN THE SYSTEM SHALL 拒绝登录并输出原因;**可区分的粒度是这三组**(密钥类失效 / 平台不可达 / 未部署开放能力层),组内不细分——F049 已落码把「不存在、已撤销、已过期」收进同一个 `26002`(一码一文案,服务端不给区分信号),CLI 不得臆造区分;未勾选任何权限位的有效密钥 SHALL 可正常 `login` 与 `skills sync`。(↔DEV-01 验收 5) +- **AC-11** — WHEN 已登录后密钥被撤销、到期、或所属服务账号被停用 / 删除, THE SYSTEM SHALL 使此后任一需要平台交互的命令报鉴权失败并提示重新 `login`(生效上界同凭据底座 5 秒)。(↔DEV-04 验收 1 / DEV-01 验收 2、7) +- **AC-12** — THE SYSTEM SHALL 支持同一台机器对多个平台地址分别 `login`:凭据按平台地址隔离保存、互不覆盖;命令默认作用于最近一次 `login` 的平台,并可显式指定目标平台;对同一平台重复 `login` 覆盖旧凭据。(§3 边界) +- **AC-13** — THE SYSTEM SHALL 使持 `delegate` 位的密钥在 CLI 侧的 `login` 即拒(AC-09);「登录后才被编辑加上 `delegate`」的历史 key 在 `deploy` / `logs` 的服务端入口按权限位被拒由 **F055 AC-04** 承接(INV-31 落点),CLI 把该拒绝原样呈现、拒绝形状同 AC-09、不得静默落回模式 S。(↔INV-31「三面在通道入口按权限位直接拒绝」) + +### 2.3 `skills sync` 与 DEV-03 两包技能包 + +- **AC-14** — WHEN 执行 `bisheng skills sync`, THE SYSTEM SHALL 从已登录平台拉取平台**当前版本**的两包技能包到本地用户目录、幂等可重复;重复执行以平台当前版本覆盖更新本地内容(平台升级后重跑即更新到新版本),并输出每包的名称、版本与更新结果。(↔DEV-03 规则「版本对齐」/ 验收 1、4;DEV-04 验收 11) +- **AC-15** — THE SYSTEM SHALL 使技能包内容随平台版本一同发布、由平台端点分发(无公网依赖),且平台内任何消费者(含后续造应用引擎)与 CLI 拉取的是同一份内容,不维护第二份。(↔DEV-03 规则「内外同源」/ 验收 2;§4 决议-4) +- **AC-16** — THE SYSTEM SHALL 使「部署纳管」包包含:`bisheng-app.yaml` 的写法(形态由 F055 定义,AC-30;含名称 / 描述 / 图标 / `runtime` / 端口 / 档位、**应用数据表结构声明**与**出站域名白名单**两项的写法,引用 F055)、能力声明写法、安全红线(禁写鉴权 / 连接串 / 密钥;密钥用环境变量引用而非硬编码)、托管运行契约(平台托管提供什么 / 应用不得自带或依赖什么,DEV-04 契约表)、`deploy` 工作流与预检排障指引(声明补全 → deploy → 预检修复 → 审批状态跟踪)。(↔DEV-03 表「部署纳管」/ DEV-04 托管运行契约) +- **AC-17** — THE SYSTEM SHALL 使「平台能力接线」包由本 Feature 交付其**包结构与两条不走 SDK 的标准库接法**——模型(官方 OpenAI 兼容客户端 + 平台注入的 base URL 与凭据、模型名即模型管理原名、无逻辑档位转换)、应用数据库(标准库连平台注入的连接环境变量、加列自动 / 改删列须确认的结构演进策略);**SDK 三件套用法章节**——auth(读平台注入身份即得用户、**禁止自建登录页**)、retrieve(检索用法与 per-user 过滤语义)、storage(附件存取)——在 F057 交付后补齐、其验收随 F057。其中 auth 一节的「显著标注」按可判定形式落地:**独立成章、章首置顶警示块(点明唯一「写错了应用照样跑起来、只是身份是错的」静默失败点)、且列在包首页目录第一条**。(↔DEV-03 表「平台能力接线」/ DEV-07 / RT-05) +- **AC-18** — THE SYSTEM SHALL 使每包由 SKILL.md、可运行样例与连通自检脚本组成(「部署纳管」包三件由本 Feature 交付;「平台能力接线」包的样例与自检脚本中涉及 SDK 的部分随 F057 补齐、验收随 F057);WHEN 自检脚本在已配好的环境执行, THE SYSTEM SHALL 通过;WHEN 缺少配置(未 `login` / 未 sync / 缺权限位 / 平台不可达), THE SYSTEM SHALL 输出可读的失败原因而非堆栈;样例与脚本中不出现任何真实密钥(占位符或环境变量引用)。(↔DEV-03 验收 1;NFR-2) +- **AC-19** — THE SYSTEM SHALL 使两包的触发描述以通用意图表述(如「将本地项目部署 / 发布到企业的应用平台」)、不绑定产品名;目标平台实例由 `login` 凭据与 sync 来源决定、与名称无关。(↔DEV-03 规则「触发描述不绑产品名」/ 验收 5) +- **AC-20** — THE SYSTEM SHALL 使本地技能目录可被本机 AI 编程工具读到:对有技能目录约定的工具由 CLI 自动接入(AC-54),对无此约定的工具(Cursor、Cline 等)经项目 AGENTS.md 引用同一目录(AC-56),sync 完成后输出落点与接入结果。(↔DEV-03 验收 3;2026-08-18 修订:原文默认「Claude Code 自动发现、只有其它引擎需要引用」,与事实不符——`~/.bisheng/skills/` 不被任何工具扫描,见 §2.9 AC-54) +- **AC-21** — WHEN 本地技能包文件已被开发者修改, THE SYSTEM SHALL 在 sync 时仍以平台版本覆盖,并在输出中列出被覆盖的文件(技能包是平台发布物,不做本地合并、不保留本地改动)。(§4 决议-8) +- **AC-22** — THE SYSTEM SHALL 使技能包在评测样本上达到:① 触发——**≥ 10 条**不含产品名的中性部署表述(如「把这个应用部署到公司的应用平台」)作为样本,「部署纳管」包触发率 **100%**(本 Feature 验收);② auth 静默失败点——**≥ 5 个**「未读包 vs 读包」对照样本,读过「平台能力接线」包后 agent 生成的应用不自建登录页与鉴权逻辑、改读平台注入身份的比例 **100%**(该章随 F057 补齐,此项验收随 F057)。(↔DEV-03 验收 5、6 / DEV-05 验收 4;NFR-1.3) + +### 2.4 `dev` 本地运行与身份注入 + +- **AC-23** — WHEN 执行 `bisheng dev`, THE SYSTEM SHALL 按 `bisheng-app.yaml` 声明的启动信息与端口拉起应用进程,并在其前置一个内置迷你代理作为唯一本地访问入口;代理向应用注入与托管运行期(F054 app-proxy)**结构一致**的身份头(含主体类型 = 服务账号)与**每请求的短时访问凭据句柄**(结构同 F054 AC-34,由迷你代理以当前 `login` 密钥换取或本地签发,login 密钥本身不进入应用进程),身份 = 当前 `login` 密钥所对应的服务账号;应用读注入头 / SDK auth / SDK retrieve 的代码与线上零差异。(↔DEV-05 需求表 / 验收 1;INV-32 候选) +- **AC-24** — THE SYSTEM SHALL 在 `dev` 输出中明示当前注入身份的来源与账号(服务账号名、目标平台地址)以及本地访问地址,避免开发者误以为在以他人身份运行。(↔DEV-05 界面与交互) +- **AC-25** — THE SYSTEM SHALL 使 `dev` 不提供 `--as` 或任何指定其他用户身份的入口;迷你代理在转发前剥离客户端携带的任何平台身份注入头(剥离规则与 F054 一致:按下划线 / 连字符 / 大小写归一化等价类匹配),伪造头对应用无效。(↔DEV-05 ① / 验收 2;§5.1;INV-32 候选) +- **AC-26** — WHEN `dev` 启动, THE SYSTEM SHALL 提供一个本地 SQLite 应用数据库并以与托管运行期**同名**的连接环境变量注入应用进程;数据在 `dev` 重启间保留、位于项目本地且不进入 `deploy` 上传包(AC-32)。(↔DEV-05 ③ / DEV-07 appdb;F054 AC-44) +- **AC-27** — THE SYSTEM SHALL 向应用进程注入与托管运行期同名的一组平台接线环境变量(模型协议面 base URL 与凭据、平台地址、应用标识、附件存储句柄〔本地期指向项目本地附件目录,SDK storage 同一 API〕等,清单与 F054 / F057 对齐),使技能包与 SDK 教的写法本地线上零差异;SDK auth / retrieve / storage 所需的访问者凭据只经每请求注入(AC-23),不以进程级环境变量提供。(↔DEV-07「刻意不进 SDK 的两样」/ DEV-05 一句话) +- **AC-28** — WHILE `dev` 运行, WHEN 应用经 SDK / MCP / 模型协议面调用平台能力, THE SYSTEM SHALL 使其按该服务账号**被显式授予**的范围放行(经 F052 / F051 面执行,过滤强度与 fail-closed 行为与线上一致);权限引擎不可用时得到明确错误而非未过滤或部分过滤的结果;`dev` 迷你代理只注入身份与环境、不代理这些调用。(↔DEV-05 ③ / 验收 3;NFR-1.4;§4 决议-3) +- **AC-29** — IF 未 `login`、凭据无效或目标平台不可达, THEN THE SYSTEM SHALL 拒绝启动 `dev` 并提示先 `login`;`dev` 本身不校验权限位(同 `login`);WHEN 运行中密钥被撤销, THE SYSTEM SHALL 使后续平台能力调用被拒(AC-11),本地注入身份不因此变化,重启 `dev` 时被拒。(§4 决议-9) + +### 2.5 `deploy`(命令侧) + +- **AC-30** — THE SYSTEM SHALL 以项目根的 `bisheng-app.yaml` 为发布输入,并**按 F055 定义的对外形态**(名称 / 描述 / 图标 / `runtime` / 端口 / 档位 / 能力声明 / 应用数据表结构 / 出站域名白名单;必填 / 可选与缺省值均以 F055 为准,允许「只导入不集成」的最小声明)打包与本地校验必填项,**不自定义任何字段**;权威校验在 F055 托管预检。IF 文件缺失或必填项缺失 / 不合法, THEN THE SYSTEM SHALL 在本地拒绝、指出缺失项、不上传。(↔DEV-04 规则「发布输入以 bisheng-app.yaml 为准」;release-contract 表 1 AppManifest;§4 决议-5) +- **AC-31** — WHEN 执行 `bisheng deploy`, THE SYSTEM SHALL 把当前项目按忽略规则(AC-32)打包为一份版本快照、上传平台并触发发布管线(经 F055);上传前先完成 AC-30 的本地校验与 AC-33 的目标应用确认输出。(↔DEV-04 流程 / 界面表 deploy;§4 决议-2) +- **AC-31a** — WHILE `deploy` 进行中, THE SYSTEM SHALL 逐阶段输出结果:发布前安全扫描 → 托管预检 → 审批单生成(**扫描先于预检**,见文末决议;CLI 只按服务端实际推进的阶段顺序呈现,不得把顺序硬编码成断言);IF 任一阶段失败, THEN THE SYSTEM SHALL 终止、输出该阶段的可读原因(AC-35 / AC-36)并以非零退出码结束。(↔DEV-04 流程 ①–③) +- **AC-31b** — THE SYSTEM SHALL 使 `deploy` 默认在审批单生成(待审)后返回,输出审批单标识与状态跟踪方式(应用详情页 · 发布 / MCP 应用状态工具 / `deploy --wait`)。(↔DEV-04 流程 ③ / DEV-02 ③;§4 决议-10) +- **AC-31c** — WHERE 带 `--wait`, THE SYSTEM SHALL 持续输出直至审批终态与上线结果之一出现并据此结束:通过并上线(输出入口地址)/ 待上线(资源不足,提示 owner 手动上线路径)/ 驳回(输出理由全文)/ 撤回;四种终态各有可区分的退出码。(↔DEV-04 流程 ③④ / RT-03 ⑤⑥;§4 决议-10) +- **AC-32** — THE SYSTEM SHALL 在打包时遵守忽略规则:默认排除版本控制元数据、依赖安装目录、本地 SQLite 数据文件、本地附件目录与 CLI 凭据,并遵守项目的 `.gitignore` 类忽略文件;IF 上传体量超过部署配置的上限, THEN THE SYSTEM SHALL 拒绝并提示当前体量与忽略建议,不上传。**上限经 `GET /api/v2/apps/deploy-limits` 从平台取**(三个值:压缩包 MB / 解包 MB / 条目数,服务端唯一出处 `package_service.deploy_limits()`);IF 取不到(端点未部署 / 该跳不通 / 工场层未启用答 16207), THEN THE SYSTEM SHALL 按内置默认值给出**提示性**自检并照常上传、由服务端 16201 兜底判定——**一次取不到上限绝不能阻断 deploy**,一个能杀掉主流程的软校验比不校验更糟。(§3 边界;§4 决议-2;**2026-08-19 由 F055 T050 ③ 回写**:不写这一句,CLI 只能硬编码 50 MiB,正是 F055 design K7「同一契约分两处必漂移」自己反对的形态) +- **AC-33** — WHEN 首发 `deploy` 成功进入管线, THE SYSTEM SHALL 输出平台分配的应用标识与入口地址(只读信息;二维码不在 CLI 提供、去应用详情页取,§4 决议-12);此后 `deploy` / `logs` 以该应用标识关联同一应用(标识由 CLI 随项目保存、可显式指定,不要求开发者手写),迭代 `deploy` 上传前输出目标应用的名称与标识;同租户内应用同名不构成冲突。(↔F054 AC-01 / AC-08;§4 决议-6) +- **AC-34** — IF 密钥未持 `app:manage` 位, THEN THE SYSTEM SHALL 拒绝 `deploy`(首发与迭代同此;服务端按位拒绝、经 F049 鉴权依赖,服务端判定见 F055 AC-04),CLI 输出明确指出缺该位、请租户管理员编辑密钥;该判定与资源归属人有无「创建应用」角色权限、其角色是否被调整、其账号是否被停用**无关**。(↔GOV-07 ② / 验收 2、3;DEV-04 规则「准入闸」;伴生 B.2) +- **AC-35** — IF 托管预检失败(依赖平台不提供的中间件、应用包声明不合法、启动探活失败), THEN THE SYSTEM SHALL 输出缺失项与改造指引(经 F055 返回),且不生成审批单;IF 发布前安全扫描命中, THEN THE SYSTEM SHALL 输出命中规则与文件位置、**不含密钥值本身**,本地修复后可重新 `deploy`。(↔DEV-04 流程 ①② / 验收 2、4;DEV-01 验收 8;NFR-2) +- **AC-36** — WHEN 管线报告本次迭代包含改 / 删列的结构变更, THE SYSTEM SHALL 在未获显式确认时终止、不生成审批单;确认方式为交互式确认或 `--confirm-schema-change`(非交互环境未带该参数视为未确认);获确认后继续进入管线,迁移前自动留生产数据快照由 F055 承诺。(↔DEV-04 流程 ① / 验收 10;RT-05) +- **AC-37** — IF 同一应用存在在途审批单, THEN THE SYSTEM SHALL 拒绝再次 `deploy` 并提示在途单与撤回路径(应用详情页 · 发布)。(↔DEV-04 流程 ③ / 验收 5) +- **AC-38** — IF 用资源归属人不同的密钥 `deploy` 既有应用, THEN THE SYSTEM SHALL 拒绝并在提示中说明该应用归属谁(经 F054 AC-07);首发 `deploy` 的应用 owner = 该密钥所属服务账号的资源归属人,不因任何请求头改变。(↔DEV-04 规则「谁 deploy 上去的就归谁」/ 验收 9;F049 定义 6) +- **AC-39** — *(已删除,2026-08-17 审查裁定 2:原文全为 F055 服务端行为的复述——每次发布必审、扫描同规则集、元信息与档位随 deploy 更新——旅程一致性以一句「经 F055 验证」并入 AC-50;编号保留不复用)* +- **AC-40** — WHERE 工场运行时层未部署, THE SYSTEM SHALL 使 `deploy` 与 `logs` 被拒并提示「本环境未启用应用工场」,不返回未定义错误;`login` / `skills sync` / `dev` 只依赖开放能力层、不受工场运行时层开关影响。(↔GOV-10 三层部署形态) + +### 2.6 `logs` + +- **AC-41** — WHEN 执行 `bisheng logs`, THE SYSTEM SHALL 输出目标应用的运行日志(可查看最近一段、可按时间范围、可持续跟随新日志);仅对当前密钥资源归属人 owner 的应用放行、需 `app:manage` 位;内容范围与权限口径与应用详情页运行日志(F054 WB-13)、MCP 应用状态 / 日志工具(F052)**完全一致**(内容范围一致,同 F054 AC-23)。(↔DEV-04 界面表 logs / 日志三入口表 / 验收 12;伴生 B.2) +- **AC-42** — IF 目标应用不属于该密钥的资源归属人, THEN THE SYSTEM SHALL 拒绝——**即使该密钥属于租户管理员名下的服务账号**(管理员查他人应用日志只走应用详情页)。(↔DEV-02 ③ / 验收 6) +- **AC-43** — IF 目标应用当前无运行实例(草稿 / 待上线 / 已下线), THEN THE SYSTEM SHALL 明确提示其应用态而非返回空白;应用标识的解析同 AC-33(项目内记录或显式指定)。 + +### 2.7 接入信息区(DEV-01 ⑤) + +- **AC-44** — WHERE 开放能力层已部署, THE SYSTEM SHALL 在服务账号详情页「API 密钥」tab 顶部展示接入信息区:MCP Server 地址(F052)、OpenAI 兼容 base URL(F051)、CLI 安装包下载链接(AC-01;是链接不是文件——管理员转发链接、开发者自行下载)、`bisheng login` 指引(含平台地址与命令示例、密钥以占位符表示);WHERE 未部署, 该区不出现,服务账号模块其余部分不受影响。(↔DEV-01 界面表「接入信息区」/ ⑥;GOV-10 验收 4;F049 AC-49) +- **AC-45** — THE SYSTEM SHALL 提供「一键复制接入信息」:把上述四项拼成一段可直接粘贴的格式化文本;文本随时可复制、不限次数、实时生成不做快照;**不含 key 值,也不含任何可换取 key 值的凭据**;THE SYSTEM SHALL NOT 提供平台侧的接入信息分享链接、二维码或站内信推送。(↔DEV-01 ⑤ / 验收 9) + +### 2.8 端到端(跨 Feature 旅程验收) + +- **AC-46** — WHEN 开发者拿到一键复制的接入信息文本与单独转交的 key 值, THE SYSTEM SHALL 使其据此完成 `bisheng login` 并调通一次 MCP 工具(经 F052)。(↔DEV-01 验收 9) +- **AC-47** — WHEN 开发者在本地 coding agent 对话中以不含特定产品名的自然语言提出部署诉求, THE SYSTEM SHALL 使 agent 凭技能包 + CLI + MCP 完成应用包声明补全 → `deploy` → 预检失败修复 → 审批状态跟踪,**全程不离开本地对话**(审批状态经 MCP 应用状态工具或 `deploy --wait`)。(↔DEV-04 验收 7 / DEV-03 验收 5) +- **AC-48** — THE SYSTEM SHALL 使 CLI 首发上线的应用与平台内造应用同样遵循「上线 ≠ 可用」:owner 在应用详情页 · 发布设置可见范围后同事方可从广场打开——此条为旅程引用,**经 F054 / F056 验证**,本 Feature 不单独验收。(↔DEV-04 验收 8 / GOV-01) +- **AC-49** — WHEN 同一份应用代码不加改动地由 `dev` 本地跑通后 `deploy` 上线, THE SYSTEM SHALL 使其在托管环境取到的是**当前访问用户**的身份、结构与本地注入一致(经 F054 app-proxy)——「本地与线上同构」成立。(↔DEV-05 验收 5 / RT-01) +- **AC-50** — THE SYSTEM SHALL 使经 CLI 导入发布的应用在广场、权限、审计中的表现与平台内造的应用一致,预检通过并经审批的应用在平台托管环境正常启动运行;经 `deploy` 提交的每次发布均进入审批流、扫描同规则集、元信息与档位取自 `bisheng-app.yaml`(原 AC-39 内容,**经 F055 验证**)。(↔DEV-04 验收 2、3、6;§1.3 前提 2) + +### 2.9 追加(2026-08-17 审查补齐,末尾追加不重排) + +- **AC-51** — IF 尚未 `login`(本机无任何该平台的凭据)而执行 `skills sync` / `deploy` / `logs`, THEN THE SYSTEM SHALL 不发起平台交互、提示先执行 `bisheng login`,并以非零退出码结束(`dev` 同此,见 AC-29)。(↔DEV-04 界面表;补 §2.2 缺口) +- **AC-52** — WHEN 租户管理员编辑了当前密钥的权限位(如补勾或去掉 `app:manage`), THE SYSTEM SHALL 使 CLI 的下一次命令即按新权限位判定、无需重新 `login`——CLI 不缓存权限位、每次命令的权限判定以服务端为准。(↔DEV-01 ② / 验收 5「编辑权限位即时生效、无需重签」) +- **AC-53** — IF `bisheng-app.yaml` 缺失或必填项缺失 / 不合法, THEN THE SYSTEM SHALL 拒绝启动 `dev` 并列出缺失项(校验口径同 AC-30、形态以 F055 为准)。(↔DEV-05 需求描述;补 §2.4 缺口) + +### 2.10 追加(2026-08-18 缺陷修订,末尾追加不重排) + +> 起因:`login` 报「5 个文件已同步」而开发者的 AI 读不到任何技能——文件确实落盘,但落在 `~/.bisheng/skills/`,**没有任何编程工具扫描该目录**。原 AC-08 / AC-14 / AC-20 只约束到「文件写下」为止,把「装好了」与「AI 能读到」当成了同一件事。这是本 Feature 最贵的失败形态:AI 照常写代码、只是不知道平台规矩,直到 `deploy` 起不来才暴露,且报错不指向技能缺失。 + +- **AC-54** — WHEN `skills sync`(含 `login` 触发的自动 sync)落盘完成, THE SYSTEM SHALL 将技能包接入本机**每一个**检测到的、有技能目录约定的 AI 编程工具(当前为 Claude Code、Codex),并在输出中列出接进了哪些;IF 一个都未接入, THEN THE SYSTEM SHALL 显式告警说明技能包当前无人可读、而不是仅报告同步成功。检测到多个工具时全部接入,不询问、不择一——多接一个的成本是一个软链,漏接一个的成本是静默失败。(2026-08-18 补;修订 AC-08 / AC-14 / AC-20) +- **AC-55** — THE SYSTEM SHALL 按平台分目录存放技能包,使同一台机器登录多个平台时各平台的技能包互不覆盖;接入本机工具的始终是**当前登录平台**的那一份。(2026-08-18 补;原扁平落点会让后登录的平台静默覆盖先前平台的契约,且现场无任何迹象) +- **AC-56** — WHEN 平台接收 `deploy` 上传后, THE SYSTEM SHALL 在项目根的 `AGENTS.md` 写入一条指向技能包的**位置指针**(幂等、只追加不改写、可用 `--no-agents-note` 关闭、`--dry-run` 不写);THE SYSTEM SHALL NOT 将技能包本体写入项目仓库——包的版本跟随平台,进 git 会被冻结在旧版本而平台规矩继续变。(2026-08-18 补;覆盖无技能目录约定的工具) + +--- + +## 3. 边界情况 + +> 只写期望的对外行为;实现层的落点、缓存与文件布局细节见 design.md §5。 + +- **凭据文件是「属主可读」的本地资产,不是工程资产**:只在本地用户目录、以属主可读写权限保存(Windows 上以等价的用户级访问控制实现);任何命令不把它写进工程目录,`deploy` 打包结构性排除它(AC-07 / AC-32)。同一机器多个平台的凭据按平台地址隔离,命令默认最近一次 `login` 的平台、可显式指定(AC-12)——凭据不「合并」、不「切换全局状态」。 +- **主要操作者是 agent、不是人**:每个命令都必须在无 TTY 下可用(AC-04);凡有交互确认的地方(`--confirm-schema-change`、覆盖确认等)都有参数等价物;错误信息给「缺什么 / 怎么改」而非堆栈——这是 DEV-04 验收 7「全程不离开本地对话」成立的前提。未 `login` 直接跑命令同样要给出「先 login」的确定提示与非零退出码(AC-51),而不是网络错误。 +- **离线 / 纯内网零公网依赖**:安装件、技能包、版本信息全部由平台自身分发(AC-01 / AC-15);CLI 自身不访问任何公网地址;应用依赖包在构建期经平台内网镜像安装是 F054 / F055 的契约,CLI 只在预检失败时把原因原样呈现。**版本对齐靠「从自家平台拉」天然成立**,不靠公网 registry。 +- **`deploy` 上传体量与忽略规则**:默认排除版本控制元数据、依赖安装目录(依赖由平台构建期安装)、本地 SQLite 数据文件、CLI 凭据,并遵守项目 `.gitignore` 类文件;超上限拒绝并提示(AC-32)。上限是部署配置项(`app_runtime.max_package_mb` / `max_unpacked_mb` / `max_package_entries`,默认值见 design),**CLI 经 `GET /api/v2/apps/deploy-limits` 向平台取、不自带第二份真相**;取不到时降级为「按内置默认值提示 + 继续上传」,权威判定恒是服务端的 16201。密钥扫描**不在 CLI 侧复制一份**——服务端一次、同一规则集,避免两套规则漂移;CLI 只保证自己的产物不进包。 +- **密钥被吊销 / 过期 / 服务账号停用后的命令行为**:任一需平台交互的命令报鉴权失败并提示重新 `login`(AC-11);运行中的 `dev` 不会崩——本地注入身份不变,但后续平台能力调用被拒,重启 `dev` 时被拒(AC-29);本地凭据文件不自动删除(重新 `login` 覆盖即可)。 +- **`skills sync` 覆盖本地改动**:技能包是平台发布物,sync 是平台 → 本地的单向覆盖,本地改动会被覆盖并在输出中列出(AC-21);开发者的自定义应放在技能目录之外(如自己的 AGENTS.md 引用),不改平台包。 +- **平台升级后 CLI 版本不匹配**:命令执行前比对;不兼容即拒绝并给出从当前平台重新下载的链接,兼容但落后仅提示(AC-02);`skills sync` 与安装件的版本都以「登录的那个平台」为准,同机多平台各自对齐。 +- **`dev` 注入的是服务账号,本地看不到 per-user 差异(DEV-05 ②)**:本地不存在「真实访问者」,张三 vs 李四的差异本地无从复现,与提不提供模拟开关无关;验证路径 = 发布后用真实账号访问(AC-49)。由此本地能检索到的知识库 ⊆ 该服务账号被授予范围,可能**窄于**线上真实用户——开发期只会「本地看得少」,不会「本地能跑、线上越权」(AC-28)。 +- **`delegate` 的三处拒绝形状一致**:CLI `login`(本 Feature,AC-09)、`deploy` / `logs` 的服务端入口(F055 AC-04,CLI 原样呈现,AC-13)、以及 F051 / F052 两面——一律「按权限位在入口直接拒绝、错误指向委托专用」;**不得**表现为按服务账号身份正常执行(静默落回模式 S),也不得只回参数错误码(INV-31)。签发侧的互斥硬阻断随 F050,运行期拒绝是对历史 key 的兜底。 +- **两个部署开关的组合矩阵**:`login` / `skills sync` / `dev` 只依赖开放能力层;`deploy` / `logs` 同时依赖工场运行时层(AC-05 / AC-10 / AC-40)。未部署开放能力层 → 三个分发 / 校验入口不存在、接入信息区不出现、CLI 给可读原因;仅部署开放能力层 → 前三个命令可用、后两个提示「本环境未启用应用工场」。CLI 不得对「端点不存在」返回未定义错误。 +- **`deploy` 与本地改码解耦**:提交后本地继续改码不影响已提交的版本快照(下次 `deploy` 才生成新版本,RT-03);在途审批单存在时再次 `deploy` 被拒,撤回只能在应用详情页 · 发布做(AC-37)——CLI 不提供撤回命令。 +- **应用标识的解析与误投风险**:标识由 CLI 随项目保存、可显式指定(AC-33);项目被复制到另一目录或标识丢失时须显式指定;指定了归属人不同的应用会被归属校验拒绝(AC-38),但指定了**同一归属人的另一个应用**会真的更新那个应用——因此迭代 `deploy` 上传前必须输出目标应用名称与标识(AC-33),非交互模式下这是唯一的护栏。 +- **CLI 与平台的全部交互都在凭据校验之后**:本 Feature 新增的平台侧端点(技能包分发、login 校验、`deploy` / `logs` 消费的 F055 / F054 端点)与既有开放 API 走 F049 同一条凭据校验路径(INV-27);唯一匿名可达的是安装件下载与版本信息(AC-01),它不含任何业务数据或平台内部信息(落点见 §4 决议-11)。商业版前置网关的部署形态下上述交互同样须可达(design 落点)。 +- **接入信息不机密、key 机密**:一键复制文本可随时重发、不限次数、不含 key 或可换取 key 的凭据(AC-45);key 只有签发时一次、由管理员单独转交(F049 AC-02 / AC-45)。技能包样例与 `dev` 输出同样不出现真实密钥(AC-04 / AC-18)。 +- **`bisheng-app.yaml` 只有一个定义方(F055)、三个消费方(F053 CLI / F054 运行时 / 「部署纳管」包)**:CLI 本地只做「存在 + 必填 + 可解析」级校验(AC-30 / AC-53),权威校验在 F055 托管预检;CLI 与技能包都不得自定义字段或缺省值,否则开发者会遇到「本地过、上传被拒」或「技能包教的写法预检不认」——三处同源是 spec 级要求而非实现巧合(release-contract 表 1 AppManifest)。 + +--- + +## 4. 待澄清决议 + +> 2026-08-17 用户授权全自动模式:以下各项按建议直接定案,记录理由与备选供追溯;spec 评审(`/sdd-review`)后如有 high 级发现再增补。Discovery §4.2 次级决策清单中「CLI 安装包形态(wheel + 内网镜像 vs 单文件二进制,F053)」在此定案。 + +- **[决议-1] CLI 安装件形态 = Python wheel + 平台自身分发端点;单文件二进制作为 v3.1 备选。** 理由:全仓无任何可发布包工程(Discovery §2.9),wheel 是最低成本、跨平台、可同时放进平台内网 pip 镜像与直接下载的形态;目标用户是会用 coding agent 的业务技术员,Python 环境常备;MVP-114 纵切明确「wheel + 下载端点」。代价:开发者机器需 Python 运行时。**何时重新考虑**:客户反馈安装门槛(无 Python 的 Windows 终端)成为主要阻碍时,补单文件二进制、下载端点按平台 / 架构分发。 +- **[决议-2] `deploy` 走 HTTP 上传版本快照(打包上传),不走 git push;忽略规则 = `.gitignore` 类文件 + 内置排除;密钥扫描只在服务端做一次。** 理由:平台无 git 服务端、CLI 应用不要求是 git 仓库(存量项目直接导入,§1.3 前提 1);快照语义与 RT-05「只增不改的版本记录」一致;git push 需自建 git 服务与鉴权改造、体量与收益不匹配。CLI 侧不复制扫描规则集——同一规则集分两处必漂移,且发布前扫描是 F055 与平台内发布同门的承诺;CLI 只结构性排除自己的产物(凭据 / 本地 sqlite)。**何时重新考虑**:PRD-2 引入平台内代码托管后可加 git 通道,但 `deploy` 语义不变。 +- **[决议-3] `dev` 迷你代理只注入身份头与同名环境变量,不代理平台能力调用。** 理由:与线上同构——托管运行期 app-proxy 也只注入身份与访问凭据句柄,应用经 SDK / 标准库直连平台能力面(F051 / F052),不经代理转发出站;若 CLI 代理能力调用,等于在本地复制一层能力路由与过滤逻辑,与 INV-36「检索门面单一路径」相悖。代价:`dev` 需注入使 SDK 与标准库接法可用的凭据与地址(同名环境变量,AC-27),凭据即当前 login 的密钥、权限 = 该服务账号被授予范围。 +- **[决议-4] 两包技能包随平台版本打包在后端发布件内、由后端端点分发,`skills sync` 从该端点拉取。** 理由:版本对齐天然成立(拉哪个平台就是哪个版本)、零公网依赖(信创 / 内网硬要求)、内外同源只有一份(未来造应用引擎读同一份,DEV-03 验收 2)。备选 = 独立仓库或公网 registry:内网不可达、版本必漂移,否决。**何时重新考虑**:技能包更新节奏显著快于平台版本时,可加「平台侧热更新技能包」但分发端点与同源原则不变。 +- **[决议-5] `bisheng-app.yaml` 的对外形态由 F055 拥有(release-contract 表 1 候选 AppManifest),本 Feature 只消费:CLI 按 F055 定义的形态打包与校验必填项、`dev` 据此启动、「部署纳管」包只教写法并引用 F055 的定义,三处均不自定义字段与缺省值。** 理由(2026-08-17 审查裁定 1,替代初稿「对外形态归本 Feature」):发布输入的权威 = 管线校验方——预检拒不拒是 F055 说了算,写法若由 F053 定义就会出现「技能包教的写法预检不认」;F055 spec 范围边界 RT-04 段已把名称 / 描述 / 图标 / `runtime` / 端口 / 档位 / 能力声明 / 应用数据表结构 / 出站域名白名单九项收进其对外形态。本 Feature 保留的诉求只有一条:最小声明必须允许「只导入不集成」的存量应用零集成导入(§1.3 前提 1),由 F055 的必填集承接。**何时重新考虑**:无——owner 归属已由 release-contract 表 1 登记,变更须先改表 1。 +- **[决议-6] 应用标识由 CLI 在首发后随项目保存、后续 `deploy` / `logs` 据此关联;可显式指定覆盖;不要求开发者手写进 manifest。** 理由:F054 定应用标识首发确定、不可改、名称可重(AC-01 / AC-08),靠名称关联不可行;让开发者手写标识易错且首发前不存在。迭代 `deploy` 上传前输出目标应用名称与标识作为误投护栏(AC-33 / §3)。保存位置与文件形态属 design。 +- **[决议-7] CLI 版本策略:与平台版本同号发布,命令执行前比对;不兼容即阻断并提示从当前平台重新下载,兼容但落后仅提示。** 理由:`/api/v1/env` 的版本字段不可靠(CLAUDE.md §7 已知陷阱)、CLI 须有自己的兼容判定;阻断优于「看起来能跑、行为悄悄不对」。兼容判据(主 / 次 / 补丁)与探测方式属 design。 +- **[决议-8] `skills sync` 对本地改动单向覆盖并列出被覆盖文件,不做三方合并、不做覆盖前确认。** 理由:技能包是平台发布物而非用户资产;合并会让本地包偏离平台版本、验收 2「内外同源」失效;覆盖前确认对 agent 调用不友好(`login` 自动触发的 sync 尤其如此)。开发者的自定义放技能目录之外。 +- **[决议-9] `dev` 命令本身不校验权限位(同 `login` / `skills sync`),只验密钥有效;应用在 `dev` 期查组织身份仍按 `identity:read` 判(F052)。** 冲突来源:PRD-1 DEV-01 ① 映射表 CLI 列在 `identity:read` 行写「dev 身份注入」,而伴生 PRD 附录 B.2(权限位清单的权威出处)CLI 行只列 `login` / `skills sync`(不验位)、`deploy` / `logs`(`app:manage`),未列 `dev`。裁决:`dev` 注入的是服务账号**自身**身份、属「读自己」,不消费高危的全租户组织架构可读位;若 `dev` 准入要求 `identity:read`,则每把开发者 key 都被迫携带高危位,与 DEV-02 ②「边界由给不给位承担」背道而驰。PRD-1 该表述理解为「dev 期应用若查组织身份须持该位」。**已回修(2026-08-17)**:PRD-1 DEV-01 ① 表 CLI 列 `identity:read` 行已改为「—(dev 注入的是服务账号自身身份、不验位)」,两册与本 spec 一致。 +- **[决议-10] `deploy` 默认跟踪到「审批单已生成(待审)」即返回;`--wait` 持续跟踪至审批终态与上线结果(AC-31b / AC-31c)。** 理由:审批是人工动作、可能跨天,默认阻塞对 agent 与终端都不友好;PRD 把审批状态跟踪的主路径定为 MCP 应用状态工具(DEV-02 ③ / DEV-04 验收 7);MVP-114 纵切上 F052 不在首波,`--wait` 承接演示剧本中「审批通过 → 上线」的等待与结果输出(mvp-114-path §2「轮询状态并输出预检 / 扫描 / 审批结果」)。 +- **[决议-11] 安装件下载端点匿名可达(不要求密钥或登录态),随开放能力层开关存在,不进 `/api/v2` 命名空间。** 理由:安装件与版本信息不机密(DEV-01 ⑤「接入信息不机密、可随时重发」);管理员转发链接、开发者在拿到 key 前就可安装是 PRD 明写的场景(「是链接不是文件」);INV-27 约束的是 `/api/v2/**` 业务端点,静态发布件不在其列——但为不制造「`/api/v2` 下有匿名端点」的例外,端点放在该命名空间之外(落点属 design)。 +- **[决议-12] CLI 首发成功后打印入口地址(只读 URL),不提供二维码;二维码仍去应用详情页 · 发布取。** 理由:PRD-1 §2.2「哪些操作必须去界面上做」表把「取入口链接与二维码」列为无命令行等价物的操作,但入口地址是 F054 AC-25 定义的、版本无关的只读信息,`deploy` 结果里打印它对开发者与 agent 都是零成本的无害扩展(不新增命令、不改任何状态、不承载分发面);二维码属界面产物,CLI 不生成也不下载。**何时重新考虑**:产品明确要求 CLI 完全不呈现入口地址时删掉该输出即可,不影响其它 AC。 +- **[决议-13] `deploy` 的两道门顺序定为「发布前安全扫描 → 托管预检」,与 PRD-1 DEV-04 字面列举的顺序相反(AC-31a)。** 理由:①扫描的输入是**源码包**,不依赖构建产物,而预检要真起一次构建与探活(分钟级)——含裸密钥的包不该先烧这份算力;②失败更快 = 开发者一次 `deploy` 的反馈时延更短,而扫描命中是新手最高频的失败;③DEV-04 的四步是**逻辑分组**(说清"要过哪几道门"),AC 层只约束「两道门都过、任一失败都不进审批」,未把顺序写成契约。**这是对 PRD 字面顺序的一处自觉偏离,已按 SDD-Guide 偏离再确认规则登记。** 影响面:F055 design D5 同批订正、F055 `STAGE_LABELS` 与 README 阶段文案随之调整;CLI 侧**不得把阶段顺序硬编码成断言**,只按服务端推进的阶段依次呈现(这样将来再调顺序 CLI 零改动)。**何时重新考虑**:若预检里出现"必须先构建才能取到的扫描对象"(如扫描镜像层而非源码),顺序须换回来。 + +--- + +## 5. 设计与实现(指针,不复制) + +> **本节刻意不写内容。** spec 不承载 How。 + +| 你想知道 | 去哪看 | +|---|---| +| 为什么这么实现(决策 + 备选 + 何时该推翻) | design.md §3 | +| 关键约束(凭据校验路径 / 三扩展位与 `delegate` 拒绝 / 两个部署开关 / 版本兼容 / 多租户) | design.md §2 | +| 今天的数据流、模块职责、字段 / API / 文件名约定(CLI 包结构、凭据与技能包的本地布局、manifest 字段、注入环境变量与身份头清单、分发端点) | design.md §4 | +| 代码里看不出的坑、反直觉事实(全仓无可发布包工程、`/api/v1/env` 版本字段不可靠、商业版网关只认 `/api`、伪造头须按归一化等价类剥离等) | design.md §5 | +| 对外契约(与 F054 app-proxy 的注入头与环境变量对齐、与 F055 管线的上传 / 状态契约、与 F057 SDK 的环境变量名对齐、与 F051 / F052 的地址)、改了会破坏谁、依赖谁 | design.md §6 | +| 任务拆解、[MVP-114] 波次、文件清单 | tasks.md | +| 114 环境事实与技术路线锚点 | [../mvp-114-path.md](../mvp-114-path.md) §3 | + +--- + +## 相关文档 + +- 设计真相: [design.md](./design.md)(尚未编写) +- 执行与落档: [tasks.md](./tasks.md)(尚未编写) +- 版本契约: [features/v3.0.0/release-contract.md](../release-contract.md)(表 3 F053 行为本 Feature 范围的权威来源;INV-27 / INV-28 / INV-31 直接相关;候选 INV-32 的 `dev` 半边随本 spec 承接、随 F054 转正) +- MVP 纵切: [features/v3.0.0/mvp-114-path.md](../mvp-114-path.md) §2 F053 行 +- Spec Discovery: [features/v3.0.0/000-prd1-discovery/discovery.md](../000-prd1-discovery/discovery.md) §2.9(全仓无 CLI / 无可发布包工程 / 无安装件分发端点)/ §4.2 次级决策 +- 同版 spec: [../049-openapi-auth-baseline/spec.md](../049-openapi-auth-baseline/spec.md)(AC-13 三扩展位 / AC-49 开放能力层开关 / AC-44 「API 密钥」tab);[../054-app-domain-runtime/spec.md](../054-app-domain-runtime/spec.md)(AC-07 归属 / AC-23 日志接口 / AC-25 入口地址 / AC-31–32 注入与剥离 / AC-44 同名连接环境变量);[../055-app-publish-pipeline/spec.md](../055-app-publish-pipeline/spec.md)(范围边界 RT-04 段 `bisheng-app.yaml` 对外形态 / AC-04 `deploy` · `logs` 服务端权限判定与拒 `delegate` / AC-07 托管预检 / AC-09 结构变更确认) +- PRD: `docs/PRD/3.0-release/3.0 应用工场 PRD-1 专业开发者通道与应用运行时.md` v2.0(DEV-01 / DEV-02 ④ / DEV-03 / DEV-04 / DEV-05 / DEV-07 / GOV-07 / GOV-10);`docs/PRD/3.0-beta2/3.0 开放 API 鉴权与身份传递 PRD.md` v2.1(§4.2.4 / 附录 B.2);`docs/PRD/3.0-release/3.0 应用工场 产品方案.md` §5.1 F106 + +--- + +## 附:AC 编号说明(2026-08-17 审查修订) + +> 修订原则:编号稳定不重排;拆分用字母后缀、删除留墓碑、新增末尾追加。 + +| 编号 | 处置 | 说明 | +|---|---|---| +| AC-31 → AC-31 / AC-31a / AC-31b / AC-31c | 拆分 | 打包上传(AC-31)/ 分阶段输出与失败终止(31a)/ 默认返回点 = 审批单生成(31b)/ `--wait` 四种终态(31c);原文中「轮询」等机制词已去除 | +| AC-39 | 删除(墓碑保号) | 全为 F055 服务端复述;旅程一致性并入 AC-50 一句「经 F055 验证」 | +| AC-48 | 收为旅程引用 | 只保留在 §2.8,「经 F054 / F056 验证」、本 Feature 不单独验收 | +| AC-51 / AC-52 / AC-53 | 末尾追加(§2.9) | 未 login 跑命令的提示与退出码 / 权限位编辑即时生效不缓存 / `dev` 缺 manifest 拒绝启动 | +| AC-54 / AC-55 / AC-56 | 末尾追加(§2.10,2026-08-18) | 同步后必须接入本机 AI 工具并报告 / 技能包按平台分目录不互相覆盖 / deploy 在项目 AGENTS.md 留位置指针 | +| AC-20 | 原号改写(2026-08-18) | 原文默认 Claude Code 自动发现技能目录,与事实不符;改为「自动接入 + AGENTS.md 引用」两条通路,细则见 AC-54 / AC-56 | +| AC-01 / AC-13 / AC-16 / AC-17 / AC-18 / AC-22 / AC-30 / AC-33 / AC-34 / AC-41 | 原号改写 | 见 §2 各条括注与同步记录 | + +**有效 AC 计数**:AC-01–AC-56 连号(含 AC-31a/b/c 三条、AC-39 墓碑一条)= 58 条有效 AC。 diff --git a/features/v3.0.0/053-dev-cli-skills/tasks.md b/features/v3.0.0/053-dev-cli-skills/tasks.md new file mode 100644 index 0000000000..924dec9f9a --- /dev/null +++ b/features/v3.0.0/053-dev-cli-skills/tasks.md @@ -0,0 +1,627 @@ +# Tasks: 开发者 CLI 与技能包(独立 `bisheng` CLI 包 + 平台分发端点) + +**关联规格**: [spec.md](./spec.md)(55 条有效 AC,What 的唯一真相)· [design.md](./design.md)(How 的唯一真相,D1–D14 / 坑 1–21 / §4.1 数据流 / §4.2 契约 / §6 依赖 / §8 回写四项) +**版本**: v3.0.0 +**纵切**: [mvp-114-path.md](../mvp-114-path.md) **§6 MVP-核心**(预算受限版,本轮裁剪基准)——**Wave 1–2 全部标 `[MVP-核心]`**(CLI 包工程 + `login` + `deploy` + `logs` 简版 + 平台分发端点);**Wave 3–5 为 release 必做但本轮顺延**(`skills sync` 与两包技能包 / `dev` / 接入信息区 / 多平台凭据交互层 / 版本兼容阻断),只列标题 + 文件 + 覆盖 AC,不展开逻辑。 +**§6 字面读法的三处显式偏离**(①② 见 design §1「与裁剪基准的两处显式偏离」表,③ 为 2026-08-17 tasks 审查补记的计数订正,三处均非漏读):① **`login` 拒 `delegate` 本轮就做**(T019/T020)——INV-31 要求「三面在通道入口按权限位直接拒绝」而 CLI 是唯一通道入口,实现量 = 一个 `in` 判断 + 一条文案 + 一个 mock 单测,不做反而要在 spec 里挂长期偏离;② **`--confirm-schema-change` 参数本轮就实现**(T021/T022)——参数存在与否是 CLI 的对外契约,现在不做将来要改 CLI + README 两处;③ **多平台凭据的「数据结构」本轮就落**(T012)——§6 把「多平台凭据」整条列在顺延列,但 design §1 非目标表与 D3 明写「结构本轮就落、`--platform` 交互层顺延」(结构写死成单平台,将来加多平台就是一次带迁移的破坏性变更);**顺延的只有交互层**(T047),实现量 = 顶层一层 dict。 +**代码事实口径**: 本文所有 `文件:行号` 沿用 design.md(`3.0-vibe`,2026-08-17 核实;后端路径以 `src/backend/bisheng/` 为根,CLI 路径以 `src/bisheng-cli/` 为根)。**行号会漂移、符号名不会——落地前一律以符号名重定位。** + +--- + +## 状态 + +| 步骤 | 状态 | 备注 | +|------|------|------| +| spec.md | ✅ 已评审 | 2026-08-17 初稿 + 同日独立审查 13 项就地修订,55 条有效 AC 定稿(决议 1–12) | +| design.md | ✅ 已评审 | 2026-08-17 初版 + 同日评审 12 条修订(D1–D14 / 21 坑);接手时的第一入口 | +| tasks.md | ✅ 已拆解(2026-08-17) | 本文;**50 任务 / 5 Wave / 35 条 `[MVP-核心]`**;55 条 AC 全覆盖(AC-39 为墓碑、AC-48 / AC-50 为跨 Feature 旅程引用,见追溯表);**+ 同日 `/sdd-review tasks` 14 条修订**(2 high:F054 `logs` 链路事实回正〔runtime-manager 端点已落码〕· 依赖上界与 wheel 安装冒烟〔app-proxy 生产事故同型敞口〕;9 medium:161 段错误码登记 · 未登记码按 HTTP 状态兜底 · `26002` 三成因不可分 · `data.required` 是字符串 · base_url 归一化 · T022/T026 伪依赖 · T002 拆 T002a · 产物缺失时端点行为 · F055 扫描顺序偏离进依赖表;3 low:偏离计数补第三处 · CI 改 `--frozen` · T031 落点改独立 workflow 文件) | +| 实现 | 🚧 进行中 | **51 / 52 完成**(2026-09-16 Wave 5 尾批:**T038a 落地**——「平台能力接线」包模型一节由「暂未提供」整章重写为 F051 已交付的 OpenAI 兼容面契约,分支 `wt/f053-skillpack-model`,`test/dev_toolkit` **119 passed**;**只剩 T033**(114 手验)。以下为上一批记录)。**50 / 52 完成**(2026-09-16 Wave 5 顺延项落地:**T041 / T045 / T046 已实现**——服务账号详情页接入信息区 + 一键复制(不含密钥)、`dev` 期按 `contracts-runtime-manager.md` §5 同名注入模型面三名并把「本地与线上同判」两侧钉成断言、「部署纳管」技能包补审批状态本地跟踪且两个 `xfail(strict=True)` 哨兵转为内容对账型真断言;CLI 全量 **346 passed / 0 failed**(基线 334 passed + 1 failed,那 1 条是 `whoami` 桩缺 `model_base_url`,本批修掉)、backend `test/dev_toolkit` **76 passed**、`test/open_api` **707 passed / 3 skipped**、platform `vitest` 新文件 **11 passed** 且 `tsc-strict` / `eslint` / `check-i18n` 全绿、ruff 全绿、wheel 重打并提交(复审重跑 sha 一致);**剩 T033**(114 手验)**与 T038a**(「平台能力接线」包模型一节改写,F051 落地后追加项,不在本批切片范围)。偏差见「实际偏差记录」35–42(41 / 42 为同批复审补的两处真缺陷:接入信息区两个地址来源不一致、`--wait` 终态漏教两个),其中 **35 是与 AC-23 字面表述的冲突,建议改 spec 措辞而非改实现**)。**47 / 52 完成**(2026-09-16 Wave 3–5 顺延项落地:**T038 / T042 / T043 / T044 / T047 / T048 已实现**——`bisheng dev` 迷你代理 + 本地 SQLite + 同名接线 env、`platforms list/use` + 全局 `--platform`、版本不兼容阻断 exit 2、「平台能力接线」技能包;CLI 全量 **327 passed**(基线 268)、backend `test/dev_toolkit` **38 passed**、ruff 全绿、wheel 重打并提交;剩 T033 / T041 / T045 / T046 / T050。偏差见「实际偏差记录」29–34)。**41 / 52 完成**(2026-08-18 缺陷修订:**T054 / T055 已实现**——`sync` 现把技能包接入本机 AI 编程工具并如实报告、技能包按平台分目录、`deploy` 在项目 `AGENTS.md` 留位置指针;起因是「同步成功但 AI 读不到」的现场失败,详见 design.md 顶部 2026-08-18 缺陷修订块与 spec §2.10。CLI 全量 **247 passed / 0 failed**、`ruff check` + `ruff format --check` 全绿,并在 114 真机验证 `login` / `skills sync` 输出与软链重指向)。**39 / 50 完成**(2026-08-18 增补:**Wave 3「部署纳管」技能包切片 T035/T036/T037/T039/T040/T049 已实现并在 114 端到端验证**——CLI 全量 219 passed、`skills sync` 在 114 上装 wheel→sync→写入 `~/.bisheng/skills/deploy-hosting/`→selfcheck 通过、端点 `GET /skills/deploy-hosting` 返 200 gzip、未知/越界 slug 404、教程新内容 nginx 200;T038「平台能力接线」仍随 F057 顺延;决策增补见 design.md 顶部 2026-08-18 块)。**33 / 50 完成**(前序)。Wave 1 全部(T001–T018 含 T002a)+ **Wave 2 的 CLI 侧全部**(T019–T026 三条命令、T030 打包脚本、T031 CI、T032 README)+ **Wave 2 的平台侧全部**(T027–T029 分发端点、集成测试、租户豁免)。CLI:`cd src/bisheng-cli && uv run pytest` **203 passed / 0 failed**、`ruff check` + `ruff format --check` 全绿、`uv lock --check` 无漂移、wheel 构建 + 干净 venv 安装冒烟(`import bisheng_cli.main` / `bisheng --version` / 三条命令 `--help`)通过。平台侧(2026-08-18):`cd src/backend && uv run pytest test/dev_toolkit test/open_api -q` **111 passed / 0 failed**(其中 dev_toolkit 8 条)、`ruff check` + `ruff format --check` 全绿、`arch-guard.sh` 六个文件零输出、`from bisheng.main import app` 可起(624 路由)。**未完成**:T033(114 手验,依赖平台侧上线)。~~T034(上游回写)~~ → **2026-08-19 收口批完成**:四项回写的代码早在 `874bd688b` / `95c0ed98e` 交付,本次把条目登记进 F049 / F055 两份 tasks.md 的「跨 Feature 回写受理」表,并接受 F055 T050 ③ 的 `deploy-limits` 口径回写进本 spec AC-32;唯一没做的是「顺延项落点核对」半项,见该任务偏差记录。偏差处理见 design.md 顶部调整原则 + `docs/SDD-Guide.md` §3-§4 | + +--- + +## 开发模式 + +**按 Wave 组织任务**: +- **Wave 1**(T001–T018,含 T002a)= `[MVP-核心]` **CLI 包工程 + 测试基建 + CLI 内核**(errors / output / http / 入口 / 凭据 / 项目层 / 忽略规则 / 打包)。T001 / T002 / T002a 是基础设施、无测试配对、排最前;T003 起全部 Test-First 成对。 +- **Wave 2**(T019–T034)= `[MVP-核心]` **三条命令(`login` / `deploy` / `logs`)+ 平台分发端点 + 打包脚本 + CI + README + 114 手动验证 + 上游回写四项**。 +- **Wave 3–5** = release 必做、本轮顺延(design §8 的优先级顺序:`skills sync` + 两包技能包 → 接入信息区 → 版本兼容阻断 → `dev` → 多平台管理命令)。**只列标题 + 文件 + 测试载体 + 覆盖 AC**,展开在开工那一刻做。 + +**执行序 = 任务编号序**(本 Feature 无 F055 那样的跨 Wave 拓扑倒挂):Wave 1 内 T001/T002/T002a 可与任何测试任务并行落盘,实现任务一律按 `依赖:` 行排;Wave 2 的三条命令彼此独立,可三路并行(`login` / `deploy` / `logs`),平台侧 T027–T031 与 CLI 侧完全并行。 +> ⚠️ **三条命令之间没有依赖**(2026-08-17 审查订正):它们共享的是 `http.py`(T008)· `credentials.py`(T012)· `project.py`(T014),**不是 `login` 命令本身**。`deploy` / `logs` 的 `依赖:` 行**不写 T020**——写了会把"三路并行"变成一条串行链,而 `login` 的实现里没有任何 `deploy` / `logs` 要复用的产出(鉴权客户端构造在 `http.py`,凭据载入在 `credentials.py`)。 + +**Test-First(CLI 版)**:测试任务先于其配对的实现任务,`覆盖 AC` **逐条列举**(禁 `AC-01~AC-05` 范围写法)。 +- **CLI 单测一律零网络**:所有平台交互经 `httpx.MockTransport` 或 `tests/helpers/platform_mock.py`(T002a)的响应工厂构造,**不连任何真平台**(T002 的 `no_network` autouse fixture 把 `httpx.Client` 的默认 transport 换成会抛异常的哨兵,漏 mock 当场失败而不是静默走网络)。 +- **需要真平台的用例**统一挂 `@pytest.mark.network`,**默认跳过、CI 不跑、只在 114 手验跑**(marker 形态照 `src/runtime-manager/pyproject.toml:32-39` 的 `docker:` marker)。 +- 测试目录 `src/bisheng-cli/tests/`,与包同仓不同工程:`cd src/bisheng-cli && uv sync && uv run pytest`。**不放进 `src/backend/test/`**——CLI 不 import `bisheng`(CON-1)。 +- 平台侧两个端点的测试是唯一落 `src/backend/test/` 的部分:`src/backend/test/dev_toolkit/`(不放 `test/` 根,`asyncio_mode=auto`)。 + +**自包含任务**:每个任务内联文件、逻辑、依赖、AC 覆盖;设计论证指向 design §X / D-x / 坑-x,**不复制**(避免第三处漂移)。 + +**⚠️ 本 Feature 只有 T028 / T029 / T030 / T031 改 `src/backend/` 与仓库共享文件,其余全部落全新目录 `src/bisheng-cli/`**。冲突面见下方「跨 Feature 副作用登记」。 + +**⚠️ 三条实现红线(落码前逐条对照,全部来自 design 的评审修订)**: +1. **同步段 / 异步段不得混淆**(D6 / §4.1 B 步 6-7):`POST /api/v2/apps/deploy` 的**同步响应**里已经跑完「归属判定 · 大小闸 · 解包闸 · manifest 校验 · 本地引用校验 · 在途单 / 待上线闸」(F055 design §4.1 ① `:411-427`),**首发最常见的失败(manifest 缺字段 = 16221)永远不会出现在轮询里**。CLI 若"等一个 `stage=precheck_manifest` 的失败事件",等到的是超时。 +2. **`16225` 与 `16226` 绝不同码**(D9 红线 1):前者「平台没 seed 审批场景,找管理员,改代码无用」→ exit 13;后者「机器没资源,等一会儿或让 owner 手动上线」→ exit 14。 +3. **`--wait` 必须认 `cancelled` / `exception` 两个异常终态**(D7):不认 = 应用被删或审批人为空时死等到 `--wait-timeout` 再打印"这不是失败,请继续等",而真实情况是这单已经死了。 + +**⚠️ 两处"本轮无法端到端验证、只能单测"的地方,实现者不要去 114 上试然后判定功能不生效**: +- **`delegate` 拒绝(AC-09 / AC-13)**——该位在 F049 期**根本签不出来**(`open_api/domain/scopes.py:186-188` 的 NOTE 逐字:"deliberately NOT registered … ships with F050",坑 15)→ 只能用构造 mock `whoami` 响应的单测覆盖。 +- **`logs` 的服务端链路(AC-41 / AC-43)**——⚠️ **2026-08-17 审查事实回正**:坑 18 与 design §6.2 写的「runtime-manager 的 logs 端点尚未实现」**已过时**——F054 **T030/T031 已完成并落码**(commit `d693feeb3`「F054 批 4——reconciler 自愈与三个只读端点」;`contracts-runtime-manager.md` §2 已把 `GET /v1/apps/{app_id}/logs?tail=&since=&keyword=` → `{lines: []}` 列进「已就绪端点」并注明消费方含 **F053 CLI `logs`**,§8 标题逐字写「批 4,T028–T031 已实现」;原引用的 `:64` 行号现指向 §6,勿再照抄)。**现在真正缺的是链路中段的两截**:F054 **T057**(backend `GET /api/v1/apps/{app_id}/logs` 只读 API,tasks 仍 `[ ]`)与 F055 **T039**(`GET /api/v2/apps/{app_id}/logs` 转发 + `app:manage` + 归属人判定)。→ CLI 侧单测照常用 mock;114 联调排在 **F054 T057 → F055 T039** 之后(不再是"等 runtime-manager")。**顺带可用的两条实测口径**(manager 已实现,写单测时按它对齐):`since` 接受 **epoch 秒或 `30m`/`2h`/`7d` 相对窗口**,不可解析 → 400;**带 `keyword` 时返回行数可能 < `tail`**(manager 内过滤,这是设计不是 bug)。 + +**跨 Feature 依赖(签名 / 契约变更须回头改本文)**: +| 依赖方 | 具体任务 / 已落码 | 本文哪些任务会当场坏 | +|---|---|---| +| **F049(已实现,commit `43e73bfc5`)** | `GET /api/v2/auth/whoami`(`open_api/api/endpoints/auth.py:19-50`,`/api/v2` 下唯一 `@open_api_scope(None)`,`auth.py:23`)· `WhoamiResponse`(`open_api/domain/schemas/credential.py:108-116`)· 260 段错误码与真 HTTP 状态 · `settings.open_platform.enabled`(`core/config/open_platform.py:16-20`) | T019/T020(`login`)· T007/T008(信封解析与探测)· T028(条件注册) | +| **F055 T039**(`/api/v2/apps` router + 四端点实现) | `POST /apps/deploy` · `GET /apps/deployments/{id}` · `GET /apps/deploy-limits` · `GET /apps/{app_id}/logs`;**同步段 / 异步段划分**(F055 design §4.1 ① `:411-427`);`stage` 11 值枚举与 `status` 四值(`055-.../design.md:86`);失败五元组 `{stage,code,message,details,hints[]}`;162 段错误码语义 | T021–T026(三条命令的全部平台交互)· T003/T004(`ERROR_HINTS`) | +| **F055 T050**(上游回写三项) | 「AC-32 的上限经 `deploy-limits` 取、取不到由 16201 兜底」已在其回写项 3 登记——**本文 T017/T021 按该口径写,不另立一套** | T017/T018 · T021/T022 | +| **F054 T030/T031**(runtime-manager 只读接口 status / logs) | ✅ **已实现**(commit `d693feeb3`,`contracts-runtime-manager.md` §2 已列为已就绪端点)——坑 18 的"未实现"口径**已过时**;`logs` 的链路终点形状 = `{lines: []}`,`since` 收 epoch 秒或 `30m`/`2h`/`7d`,`keyword` 在 manager 内过滤(**带 keyword 时行数可 < tail**);dockerd 不可达返 **503 → backend 映射 `16121`**(非 404) | T025/T026 的 `since` 入参形态与 `16121` 分支(**不是**"等它实现") | +| **F054 T057 + F055 T039**(链路中段:backend `GET /api/v1/apps/{app_id}/logs` 与 v2 转发端点) | **两者仍未实现**——`logs` 的 114 联调排在它们之后(单测不受影响) | T025/T026 的 114 联调 · T033 步 4 | +| **F055 design D5**(`secret_scan` 相对 `precheck_*` 的顺序,挂着**待 ★ 确认的偏离**) | 该 ★ 若确认"扫描提前到构建之前",`STAGE_LABELS` 的展示顺序、README §④ 与**本 spec AC-31a 的字面顺序**必须**同批**改(design §6.2 已登记) | T023(阶段顺序说明)· T024(`STAGE` 常量)· T032(README) | +| **F057** | 消费 **同一个** `GET /api/v1/dev-toolkit/versions` 端点的 `sdk.*` 字段位(AC-01 / F057 AC-01·AC-03) | T028(字段位本轮一次留够,值为 `null`;不留就会在 F057 期出现第二个端点或破坏性改形) | + +**跨 Feature 副作用登记**(release-contract 表 1 / 清单检查项 17): +- **T028**(`bisheng/api/router.py` 挂新 router)—— **与 F054 T035/T057(挂 `app_runtime` router)、F055 T039/T041(挂 `app_publish` router)改同一文件的同一片区域(`:66-112`)**。三方都是**纯追加一行 `include_router`**,无逻辑耦合;合并冲突是文本级的,**解冲突时三行都要保留**。本任务的 include 是**条件的**(`if settings.open_platform.enabled:`),与另两方的无条件 include 形态不同,review 时不要"顺手统一"。 +- **T029**(`bisheng/utils/http_middleware.py` 的 `TENANT_CHECK_EXEMPT_PATHS`,`:16-44`)—— 追加一条前缀 `/api/v1/dev-toolkit`。该元组也被 F054 的 app-proxy 相关任务读到(同文件 `:60-73` / `:263-279` 的解 JWT 与租户播种逻辑,F054 K7 明确"本地解 JWT 会绕过它们"),**但 F054 不写这个元组**,冲突面只在文本相邻行。 +- **T031**(`.github/workflows/cli-quality.yml` **新建独立 workflow 文件**)—— 冲突面为零:不改任何既有 workflow(既有 `ci.yml` 的 backend 镜像构建 `:51-54` 与 `frontend-quality.yml` 一律不碰)。 +- **T030**(`scripts/pack_cli_wheel.sh` 新增 + 产物落 `bisheng/dev_toolkit/artifacts/`)—— **构建契约**:wheel 必须在打后端镜像前拷入并**提交进 git**(backend 镜像 build context 只有 `./src/backend/`,`ci.yml:54` + `src/backend/Dockerfile:13`,坑 11);顺延的 SDK 与技能包分发复用同一目录与脚本。 +- **T034**(回写 F049 / F055 / 本 spec 四项)—— 只追加文档条目,不改他人代码。 + +--- + +## Tasks + +### Wave 1 · `[MVP-核心]` 包工程与测试基建(无测试配对,排最前) + +- [x] **T001**: `[MVP-核心]` CLI 包工程骨架(全仓第一个可发布包工程) + **文件**: `src/bisheng-cli/pyproject.toml`(新), `src/bisheng-cli/bisheng_cli/__init__.py`(新), `src/bisheng-cli/uv.lock`(新,`uv lock` 生成后**提交进 git**,同 `src/runtime-manager/uv.lock` / `src/app-proxy/uv.lock`) + **逻辑**: 照抄基准 = `src/runtime-manager/pyproject.toml`(仓内最新的独立包先例,D1)。`[project]` name=`bisheng-cli`、version=`3.0.0`(与平台版本同号,决议-7)、`requires-python = ">=3.11"`(与 `runtime-manager/pyproject.toml:1-16` 对齐,CON-6);`dependencies = ["httpx>=0.27,<1.0", "PyYAML>=6.0,<7.0"]` —— **依赖预算就这两条**(D12,引第三条必须先在 design D12 补一行论证),且**两条都必须带上界**(见下方 ⚠️);`[project.scripts] bisheng = "bisheng_cli.main:main"`(**全仓第一条 console script**);`[build-system]` hatchling + **`[tool.hatch.build.targets.wheel] packages = ["bisheng_cli"]`**(⚠️ 目录名带连字符时 hatchling 无法自动推断包目录,**缺这一行 `uv build` 打出的是空 wheel**,D1);`[tool.ruff]` 照抄 `runtime-manager/pyproject.toml:41-67` 但**必须 ignore `RUF001/RUF002/RUF003`**——CLI 的人读输出全是中文,不 ignore 会把全角括号"修"成 ASCII、改错产品文案;`[tool.pytest.ini_options]` 照 `:32-39`,自定义 marker 由 `docker:` 换成 **`network:`**("needs a real platform — skipped by default, runs only in the 114 manual verification")。`__init__.py` 只放 `__version__ = "3.0.0"`(**单一版本真相**,`--version` 与 T030 的 `manifest.json` 都读它)。 + **三个名字刻意分离**(D1,两条理由都不是洁癖):发行名 `bisheng-cli` · import 包名 **`bisheng_cli`** · 命令名 `bisheng`。① import 包若取名 `bisheng`,开发者机器上同时装了后端依赖时与 `src/backend/bisheng/` 顶层包**同名冲突**;② `arch-guard.sh:115,126` 的 RULE-8/9 守卫条件是 `grep -q "/bisheng/"`,目录里出现 `bisheng/` 段会被扫进来产生无谓噪声。 + **⚠️ 只有下界的依赖会在生产炸、且 CLI 的敞口比其它子工程更大**(2026-08-17 审查新增,本轮真事故):app-proxy 的 `fastapi>=0.115` 在开发机解析到 0.121 时测试全绿,生产 `uv sync` 解析到 0.141 后 `uvicorn` **模块级 import 当场崩** —— 单测全过、部署即死。**三件套缺一不可**:① `pyproject.toml` 两条依赖都写上界(上面已给);② `uv lock` 产物**提交进 git**,CI 与 114 一律 `uv sync --frozen`(照 `src/backend/Dockerfile:7` `uv sync --frozen --no-dev` 的仓内既定口径);③ 打包脚本自带**装 wheel 后 import 生产入口**的冒烟(T030)。**为什么 CLI 比 runtime-manager / app-proxy 更需要上界**:那两个是仓内进程、`uv.lock` 在部署路径上真的生效;**CLI 的分发路径是开发者机器上的 `pip install `,lock 在那条路径上完全不参与解析**——能约束它的只有 wheel metadata 里的这两条上界。**放宽上界是一次显式决定**(跑通 T030 冒烟 + T031 的 highest-resolution leg 后再改),不是顺手升。 + **⚠️ 产物目录命名禁区**(坑 11):`.gitignore:137,148,150,152` 的 `build/` `lib/` `wheels/` `sdist/` 是**无前导斜杠的全局规则**,任何层级同名目录都被忽略;`src/bisheng-cli/dist/` 不受影响(`/dist/` 是根锚定的)。T030 的落点 `bisheng/dev_toolkit/artifacts/` 这个名字是刻意挑的,**不要改名**。 + **依赖**: 无 + +- [x] **T002**: `[MVP-核心]` pytest 基建之一:零网络哨兵 + 家目录隔离 + 样本项目树 + **文件**: `src/bisheng-cli/tests/conftest.py`(新), `src/bisheng-cli/tests/fixtures/`(新,样本项目素材) + **逻辑**: fixtures: + - **`no_network`(autouse)**——把 `httpx.Client`/`httpx.AsyncClient` 的默认 transport 换成会 `raise AssertionError("unmocked network call")` 的哨兵,**漏 mock 当场失败而不是静默走网络**;同时清 `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY` env(缺 `socksio` 会整批误报,memory `reference_local_backend_pytest_socks_proxy`;且坑 2 的代理提示逻辑要在受控 env 下测)。 + - **`home_dir`**——`monkeypatch` `Path.home()` 到 tmp_path,凭据读写全落临时目录(POSIX 下可直接断言 `0600`/`0700`)。 + - **`sample_project` / `sample_project_git`**(`tests/fixtures/`)——最小应用树:`bisheng-app.yaml`(`name`/`runtime: python3.11`/`port`)+ `main.py` + `requirements.txt` + 噪声目录 `.venv/`、`node_modules/`、`__pycache__/`、`dist/`(软排,D4)、`.gitignore`、一个符号链接、一个 `0755` 脚本、一个 `*.sqlite`。`sample_project_git` 版本额外 `git init && git add`(**用例内先探测 `git` 可执行,缺 git 则 `pytest.skip`**——CI 有 git,开发者机器不保证)。 + **⚠️ 测试里绝不写长密钥字面量**(坑 10):`arch-guard.sh:96-100` 的 RULE-7 会扫到 CLI 的 `.py`(`(password|secret_key|api_key|access_token)\s*=\s*['"][^'"]{8,}['"]`)。它输出的是 **WARNING、不是 VIOLATION、不阻断**(`arch-guard.sh:99` / 脚本头注释 `:13`),但长期噪声会让真的硬编码密钥那天没人看 —— 一律用 fixture 常量 `FAKE_KEY = "bs-sak-" + "x" * 8` 这类拼接形式,让 RULE-7 在本工程保持零输出。 + **依赖**: T001 + +- [x] **T002a**: `[MVP-核心]` pytest 基建之二:mock 平台响应工厂(本 Feature 对 F049 / F055 契约的可执行快照) + **文件**: `src/bisheng-cli/tests/helpers/platform_mock.py`(新) + **逻辑**: 基于 `httpx.MockTransport` 的响应工厂,可编程:`versions_ok()` / `versions_404()` / `env_ok(open_platform_enabled=…)` / `env_unreachable()` / `whoami_ok(scopes=[…], resource_owner=…|None)` / `whoami_err(code, http_status=…)` / `deploy_accept(...)` / `deploy_sync_err(code, details=…)` / `deployment_seq([...])`(一串轮询响应,按调用次序返回)/ `logs(lines=[…])`。**响应体形状必须照 design §4.2 ⑦ 的 F055 契约逐字构造**(`stage` 用服务端原值、`failure` 五元组、`approval.status` 六种取值),**不得在 helper 里"顺手规整"字段名**——helper 就是本 Feature 对上游契约的可执行快照。 + **两套信封工厂**:`v1_envelope(data)`(HTTP 200 + `{status_code,status_message,data}`)与 `v2_error(http_status, code, message, data=…)`(真 HTTP 状态 + 信封 body),对应坑 12。 + **⚠️ 260 段错误体照 F049 已落码的形状构造**(`common/errcode/base.py:29-33` `return_resp_instance`):`data = {"exception": , **kwargs}`,所以 `26003` 的载荷是 **`data.required` = 单个字符串**(`OpenApiScopeMissingError(required: str)`,`common/errcode/open_api.py:63-68`)**不是数组**;HTTP 状态取各错误类的 `http_status`(`26001`/`26002`/`26027` → **401**,`26003`/`26004` → **403**,`26030` → **503**,`26031` → **500**,`test/open_api/test_open_api_auth_api.py:67-149` 逐条实测)。**工厂里不许自造更"合理"的形状**——真实的形状就是这个,改了等于把 CLI 测成对着一个不存在的服务端编程。 + **依赖**: T001 + +- [x] **T003**: `[MVP-核心]` 退出码表与错误翻译测试 + **文件**: `src/bisheng-cli/tests/test_errors.py`(新) + **逻辑**: 断言 design §4.2 ② 退出码表**每一行**与 D9 的三条"绝不能合并"红线。 + **测试**: `test_exit_code_table_is_total`(表里每个 code 都有 (人话, 下一步) 两段文案,且下一步非空)→ AC-04 / `test_16225_and_16226_map_to_different_codes_and_different_next_step`(13 vs 14,**断言两条下一步文案不相等**,D9 红线 1)→ AC-35 / `test_16231_and_16230_next_step_is_delete_capabilities_not_ask_admin`(映射 exit 10 而**不是** exit 8,D9 红线 2)→ AC-35 / `test_16203_next_step_points_to_package_root_not_create_manifest`(D9 红线 3)→ AC-30, AC-35 / `test_26003_prints_required_scope_verbatim`(逐字打 `data.required` 的位名 → exit 5;⚠️ **`data.required` 是单个字符串不是数组**——`OpenApiScopeMissingError(required: str)`(`common/errcode/open_api.py:63-68`)+ `data = {"exception": …, **kwargs}`(`errcode/base.py:29-33`),对字符串 `", ".join(...)` 会逐字符拆成 `a, p, p, :, m…`,本用例传一个真实位名 `"app:manage"` 并断言整串原样出现)→ AC-34 / `test_26001_26002_26027_are_distinguishable`(三条文案互不相同 → exit 4;⚠️ **只有三条**——`26002` 一个码同时覆盖「不存在 / 已撤销 / 已过期」(`common/errcode/open_api.py:50-55`),服务端不给区分这三者的信号,别写"四种成因"的用例,见 T019 同名订正)→ AC-10, AC-11 / `test_26030_marked_retryable`(exit 7 且文案含"可重试")→ AC-10 / `test_16207_maps_to_layer_not_enabled`(exit 8「本环境未启用应用工场」)→ AC-40 / `test_unknown_code_falls_back_to_exit_19_not_exit_1`(未登记 code 原样打 `code` + `message`,**不落进 exit 1「CLI 内部异常」那一格**)→ AC-04 / `test_161_segment_codes_are_registered_not_unknown`(**2026-08-17 审查新增**:CON-8 明写 `deploy`/`logs` 也消费 **F054 的 161 段**,但表里一条都没有 —— 至少 `16121`「编排器不可用」(dockerd / runtime-manager 不可达,`contracts-runtime-manager.md` §3 把 manager 的 `503 backend_unavailable` 映射到它,是 `bisheng logs` 最常撞的一条)→ **exit 7 可重试**、`16101`「应用不存在」(`--app-id` 写错或应用已删)→ **exit 6**;断言两者都不落进 exit 19)→ AC-04 / `test_unknown_code_with_5xx_degrades_to_retryable_not_19`(**未登记 code 先看 HTTP 状态类再落 19**:F049 实测里 FGA / DB 故障在 `/api/v2` 返 **HTTP 503 + `19002`**(`test/open_api/test_open_api_auth_api.py:300-302`),只按 code 查表会打成"平台返回未登记的错误码"+ exit 19,而正确处置是"平台暂不可用、稍后重试"= exit 7;断言 503/502/504→7、401→4、403→5,三类都判不了才 19)→ AC-04, AC-10 / `test_26004_and_26031_are_reported_as_platform_or_cli_defect`(不得翻成"你的密钥有问题")→ AC-04 / `test_delegate_rejection_shape_matches_server_side_rejection`(CLI 侧 `login` 拒绝与服务端 403 原样呈现的拒绝形状一致:同退出码 5、同"委托专用、本地开发另发一把"指向)→ AC-13 / `test_no_error_text_contains_key_material`(对所有分支断言输出不含 `FAKE_KEY` 子串)→ AC-04 + **覆盖 AC**: AC-04, AC-10, AC-11, AC-13, AC-30, AC-34, AC-35, AC-40 + **依赖**: T002 + +- [x] **T004**: `[MVP-核心]` `errors.py` 实现(`CliError` + `ERROR_HINTS` + 退出码映射) + **文件**: `src/bisheng-cli/bisheng_cli/errors.py`(新) + **逻辑**: `class CliError(Exception)` 带 `exit_code` / `code`(平台原始码,可为 `None`)/ `message` / `next_step` / `details` / `hints`;`EXIT_*` 常量按 design §4.2 ② 全表定义(0/1/2/3/4/5/6/7/8/9/10/11/12/13/14/**18**/19/20/21/22/23/24/25;18 = 缺陷类,2026-08-17 裁决新增);`ERROR_HINTS: dict[int, tuple[str, str]]` = code → (人话, 下一步),条目取自 design D9 与 §4.1 B 步 6-7 的清单(260 段:`26001`/`26002`/`26003`/`26004`/`26027`/`26030`/`26031`;162 段:`16201`/`16202`/`16203`/`16205`/`16207`/`16221`/`16222`/`16223`/`16224`/`16225`/`16226`/`16227`/`16228`/`16229`/`16230`/`16231`/`16241`/`16251`/`16252`/`16254`;**161 段(2026-08-17 审查补:CON-8 明写本 Feature 也消费 F054 的 161 段,原清单一条都没有)**:`16121`「编排器不可用」→ **exit 7**(`contracts-runtime-manager.md` §3:manager 的 `503 backend_unavailable` 映射到它;`logs` 在 dockerd 宕机时撞的就是这条,**不是 404**,"后端不可用 ≠ 实例不存在")· `16101`「应用不存在」→ **exit 6**(`--app-id` 写错或应用已删,F054 design:518))。 + **未登记 code 的降级是两级不是一级**(审查修订):① 先按 **HTTP 状态类**归位——`503`/`502`/`504` → exit 7「平台暂不可用,稍后重试」(F049 的 FGA / DB 故障走 `19002` + HTTP 503,`test/open_api/test_open_api_auth_api.py:300-302`,只查 code 表会把它打成"未登记错误码"而误导用户去查密钥)· `401` → exit 4 · `403` → exit 5;② 三类都不适用才落 **exit 19**「平台返回错误 {code}:{message}」(未知 ≠ 崩溃、≠ 成功,D9)。**两级都要原样打出 `code` 与 `message`**。`16229` 本轮无写入方但**必须登记**(结构演进后置,但 `--confirm-schema-change` 本轮就实现,不登记会在结构演进上线那天变成未知码)。**绝不吞 `details` / `hints`**——那是本地 agent 自动修复的全部输入(AC-47 的成立前提,D6)。**`26003` 的 `data.required` 按单字符串处理**(不是数组,见 T003 该用例)。 + **测试**: T003 全部通过。 + **覆盖 AC**: AC-04, AC-10, AC-11, AC-13, AC-30, AC-34, AC-35, AC-40 + **依赖**: T003 + +- [x] **T005**: `[MVP-核心]` 输出层测试(机读 NDJSON / 人读文本 / 掩码) + **文件**: `src/bisheng-cli/tests/test_output.py`(新) + **逻辑**: 断言 AC-04 的实质 = "非交互可用 + 机器可读 + 永不回显密钥",形状照 design §4.2 ⑥。 + **测试**: `test_json_mode_machine_events_go_to_stdout_human_text_to_stderr`(`--json` 下 stdout 里**每一行都是可 `json.loads` 的对象**——混一行进度文本就会炸掉 agent 的 `jq` 管道,D2)→ AC-04 / `test_result_event_is_always_the_last_line_and_appears_exactly_once`(成功 / 失败 / 异常三条路径各一次)→ AC-04 / `test_event_shapes_are_exactly_three`(`stage` / `progress` / `result`)→ AC-04 / `test_non_tty_degrades_to_milestones`(无 TTY 时不打进度条、只在 25/50/75/100% 发 `progress`)→ AC-04 / `test_mask_never_emits_key_material`(把 `FAKE_KEY` 塞进 message / header / details 三处,断言输出只有掩码形式 `bs-sak-****`)→ AC-04 / `test_verbose_masks_authorization_header`(`--verbose` 打方法 / 路径 / 状态码 / 耗时,**Authorization 恒掩码**)→ AC-04 / `test_scan_hits_print_file_line_without_value`(`hits[]` 只打 `file:line` + 规则名,**断言不含任何值**;且 CLI **不去本地文件把那行读出来补打**——那等于把服务端刻意不给的东西自己补回来)→ AC-35 / `test_utf8_wrapper_survives_gbk_console`(模拟 `cp936` stdout,断言不抛 `UnicodeEncodeError`,坑 5)→ AC-04 + **覆盖 AC**: AC-04, AC-35 + **依赖**: T002 + +- [x] **T006**: `[MVP-核心]` `output.py` 实现(双形态输出 + 进度 + 掩码器) + **文件**: `src/bisheng-cli/bisheng_cli/output.py`(新) + **逻辑**: `Emitter` 类持 `json_mode` / `quiet` / `verbose` / `is_tty`:`stage(...)` / `progress(...)` / `result(ok, exit_code, data, failure)` 三个事件方法(`--json` 时写 stdout 一行 NDJSON,否则写 stderr 人读文本);`result` 由 `main.py` 的收口保证**恒发且只发一次**。`mask(text)` 用正则把 `bs-sak-\w+` 与 `Authorization: Bearer \S+` 替换成掩码,**所有出口(人读 / 机读 / verbose 日志)统一过一遍**。`STAGE_LABELS` 是**纯展示用**中文短语表,**未知 stage 原样打印英文**(不认识 ≠ 报错,D6)。无彩色、无表格、不装 rich(D2)。 + **测试**: T005 全部通过。 + **覆盖 AC**: AC-04, AC-35 + **依赖**: T004, T005 + +- [x] **T007**: `[MVP-核心]` HTTP 客户端与前置探测测试(两套信封 / 探测三分支 / 代理坑) + **文件**: `src/bisheng-cli/tests/test_http.py`(新) + **逻辑**: 断言 design §4.1 A 的前置探测三分支与坑 12 的信封解析顺序。 + **测试**: `test_envelope_parsed_body_status_code_first_then_http_status`(**先读 body `status_code`,再看 HTTP 状态**;反过来会把 `/api/v1` 的所有业务错误当成功,坑 12)→ AC-04 / `test_v1_http200_business_error_is_treated_as_error` → AC-04 / `test_v2_real_http_401_403_503_parsed_with_envelope_body` → AC-10, AC-11 / `test_probe_versions_404_and_env_open_platform_false_exits_8`("本环境未部署开放能力层")→ AC-05, AC-10 / `test_probe_versions_404_and_env_without_open_platform_flag_exits_9`("平台版本过老,不支持 CLI")→ AC-02, AC-10 / `test_probe_env_unreachable_exits_7`("平台不可达")→ AC-10 / `test_probe_stops_before_whoami_when_layer_absent`(⚠️ **判定后直接结束、不再打 whoami**——AC-05 的"login 在该环境不可用"由这里兑现,D10 偏离登记)→ AC-05 / `test_min_compatible_greater_than_local_warns_but_does_not_block`(本轮只提示不阻断,D11 第 3 条)→ AC-02 / `test_no_scopes_cached_between_calls`(客户端**不缓存 `scopes`**,每次命令的权限判定都以服务端为准)→ AC-52 / `test_proxy_env_detected_and_named_in_connect_error`(设 `ALL_PROXY` 后连接失败 → 错误里明说"检测到 `ALL_PROXY=…`,内网地址请加入 `NO_PROXY`",坑 2)→ AC-10 / `test_upload_read_timeout_is_240s_below_nginx_300s`(断言常量,D5)→ AC-31 / `test_bearer_header_masked_in_verbose_log` → AC-04 + **覆盖 AC**: AC-02, AC-04, AC-05, AC-10, AC-11, AC-31, AC-52 + **依赖**: T002, T002a, T004 + +- [x] **T008**: `[MVP-核心]` `http.py` 实现(客户端封装 + 两套信封 + 前置探测) + **文件**: `src/bisheng-cli/bisheng_cli/http.py`(新) + **逻辑**: `PlatformClient(base_url, api_key=None, timeout=…, trust_env=True)`:统一加 `Authorization: Bearer …`;超时分档 —— 连接 10 s、普通读 60 s、**上传读 240 s**(刻意 < nginx `proxy_read_timeout 300s`,`docker/nginx/conf.d/default.conf:53`,让 CLI 自己先超时而不是拿一个被掐断的 504,D5)。`parse_envelope(resp)` 按坑 12 的固定顺序解析并抛 `CliError`。`probe(base_url)` 实现 design §4.1 A 的三分支(`versions` → 404 时再探 `GET /api/v1/env`,三支各有确定退出码 8 / 9 / 7,**判定后直接结束**)。连接失败时检测代理环境变量并把变量名与值写进错误(坑 2);`--no-proxy` → `trust_env=False`。**不判权限位、不缓存 `scopes`**(AC-52,D3)。 + **测试**: T007 全部通过。 + **覆盖 AC**: AC-02, AC-04, AC-05, AC-10, AC-11, AC-31, AC-52 + **依赖**: T007 + +- [x] **T009**: `[MVP-核心]` CLI 入口与命令面测试(`--help` 命令集 / 全局参数 / 异常收口) + **文件**: `src/bisheng-cli/tests/test_cli.py`(新) + **逻辑**: 断言 AC-03 的本轮形态与 AC-04 的"交互确认一律有参数等价物"。 + **测试**: `test_help_lists_exactly_login_deploy_logs`(**本轮只注册三条**;`skills sync` / `dev` 不进解析器、不做"占位报错",D11 第 1 条)→ AC-03 / `test_help_footer_declares_deferred_commands`(尾部注明"`dev` / `skills sync` 随后续版本提供"——顺延要**明说**而不是藏起来)→ AC-03 / `test_no_as_flag_anywhere`(遍历所有子命令的 action,断言不存在 `--as` 或任何指定他人身份的参数)→ AC-03 / `test_no_init_subcommand` → AC-03 / `test_version_flag_prints_package_version`(读 `bisheng_cli.__version__`)→ AC-02 / `test_every_interactive_confirm_has_a_flag_equivalent`(`--confirm-schema-change` / `--yes` / `--api-key-stdin` 三者存在)→ AC-04, AC-36 / `test_non_interactive_missing_flag_is_refusal_not_default_yes`(无 TTY 且缺确认参数 → 明确拒绝,**绝不"默认同意"**)→ AC-04, AC-36 / `test_unexpected_exception_exits_1_with_traceback_only_in_verbose` → AC-04 / `test_result_event_emitted_even_on_exception`(异常路径也恒有 `result` 尾行)→ AC-04 + **覆盖 AC**: AC-02, AC-03, AC-04, AC-36 + **依赖**: T002, T006 + +- [x] **T010**: `[MVP-核心]` `main.py` + `cli.py` 实现(入口 / argparse / 退出码收口) + **文件**: `src/bisheng-cli/bisheng_cli/main.py`(新), `src/bisheng-cli/bisheng_cli/cli.py`(新) + **逻辑**: `main()`:**启动第一件事**把 `sys.stdout/stderr` 用 `errors="replace"` 的 UTF-8 wrapper 包一层(Windows 控制台默认 GBK,打中文 hints 会 `UnicodeEncodeError` 直接崩、掩盖真正的错误,坑 5);构造 `Emitter`;调 `cli.build_parser()` 解析;分发到命令 handler;`except CliError` → 打人话 + 下一步 + 发 `result` → `sys.exit(e.exit_code)`;`except Exception` → exit 1(traceback 仅 `--verbose`)。`cli.py`:`argparse` 子命令注册(本轮 `login` / `deploy` / `logs` 三条,参数面照 design §4.2 ①)+ 全局参数 `--version` / `--verbose` / `--quiet` / `--json` / `--timeout` / `--no-proxy`;`--help` 文案手写(无 typer / click,D2);尾部注明顺延命令。**handler 以函数引用注入**,命令模块(T020 / T022 / T026)只填实现、不改本文件的注册结构。**不注册 `--platform`**(本轮恒用 `credentials.json` 的 `current`,D11 第 5 条)。 + **测试**: T009 全部通过。 + **覆盖 AC**: AC-02, AC-03, AC-04, AC-36 + **依赖**: T006, T009 + +- [x] **T011**: `[MVP-核心]` 凭据存储测试(权限位 / 多 profile / 不存 scopes) + **文件**: `src/bisheng-cli/tests/test_credentials.py`(新) + **逻辑**: 断言 CON-3 与 D3 的落地:**密钥必然明文存,文件权限位是唯一保护**。 + **测试**: `test_file_created_0600_and_dir_0700_in_one_step`(断言用的是 `os.open(..., O_CREAT|O_EXCL|O_WRONLY, 0o600)` 语义——**"先 write 再 chmod"中间有一个 world-readable 窗口**,坑 1;POSIX 下直接断言 `stat().st_mode`)→ AC-07 / `test_never_written_into_project_dir`(在 tmp 项目里跑 login,断言项目树内无凭据文件)→ AC-07 / `test_scopes_never_persisted`(写入载荷里没有 `scopes` 键——一旦缓存权限位就会出现"管理员补勾了但 CLI 还说没权限")→ AC-52 / `test_multi_profile_isolated_by_base_url`(两个平台各写一条、互不覆盖,`current` 指向最近一次)→ AC-12 / `test_relogin_same_platform_overwrites_profile` → AC-12 / `test_base_url_normalised_before_use_as_key`(**2026-08-17 审查新增**:`http://114:7860/` · `http://114:7860` · `HTTP://114:7860` 归一化后是**同一个 profile 键**;不归一化的后果不是"多一条记录"而是两个真实故障——① 明明 `login` 过却报 exit 3「请先 login」;② `.bisheng/app.json` 也按 base_url 分键(T014),键对不上就查不到 `app_id` → **迭代 deploy 被当成首发、平台上再建一个同名草稿应用**。规则:去尾部 `/`、scheme 与 host 小写、去默认端口不做(`:80`/`:443` 显式写与省略视为不同,避免猜错),**归一化函数只有一处、`credentials.py` 与 `project.py` 共用**,`app.json` 侧的对偶用例在 T013)→ AC-12 / `test_load_without_credentials_raises_exit_3_not_network_error`(未登录 → "请先 bisheng login" + 非零退出码,**不是网络错误**)→ AC-51 / `test_stored_snapshot_fields`(`service_account` / `resource_owner`(可为 `null`) / `key_mask` / `tenant_id` / `expires_at` / `logged_in_at`,形状照 design §4.2 ③)→ AC-06 / `test_windows_acl_failure_warns_loudly_instead_of_pretending`(模拟 `icacls` 等价 ACL 设置失败 → **明确警告而不是假装成功**,坑 1)→ AC-07 + **覆盖 AC**: AC-06, AC-07, AC-12, AC-51, AC-52 + **依赖**: T002 + +- [x] **T012**: `[MVP-核心]` `credentials.py` 实现 + **文件**: `src/bisheng-cli/bisheng_cli/credentials.py`(新) + **逻辑**: 路径 `~/.bisheng/credentials.json`(目录 `0700` / 文件 `0600`;Windows 用 `%USERPROFILE%\.bisheng\` + `icacls` 等价"仅当前用户"ACL,做不到就明确警告)。**顶层多 profile 结构本轮就落**(`{"version":1,"current":,"profiles":{…}}`,D3)——结构一旦按单平台写死,加多平台就是一次带迁移的破坏性变更;**顺延的只是 `--platform` 交互层**(D11 第 5 条)。`load_current()` 无凭据时抛 `CliError(exit=3)`。**不做加密**(明文 + 权限位是既定取舍,D3);**恒不存 `scopes`**(AC-52)。 + **`normalise_base_url(raw)` 落在本模块、全仓唯一一份**(审查新增):去尾 `/`、scheme 与 host 小写、保留显式端口;**profile 键、`current` 值、`.bisheng/app.json` 的分键(T014)三处都用它**——分两处实现就会出现"login 过却说未登录 / 迭代 deploy 被当首发再建一个应用"。 + **测试**: T011 全部通过。 + **覆盖 AC**: AC-06, AC-07, AC-12, AC-51, AC-52 + **依赖**: T004, T011 + +- [x] **T013**: `[MVP-核心]` 项目层测试(manifest 三必填快速失败 + `.bisheng/app.json`) + **文件**: `src/bisheng-cli/tests/test_project.py`(新) + **逻辑**: 断言 CON-1 的推论——**CLI 只做"存在 + 可解析 + 三必填"级快速失败,权威校验在 F055 托管预检**。 + **测试**: `test_missing_manifest_refused_locally_without_upload`(不发任何请求,`no_network` 哨兵保证)→ AC-30 / `test_unparsable_yaml_refused_with_line_info` → AC-30 / `test_each_of_name_runtime_port_missing_is_named_in_the_error`(三个必填项各缺一次,错误逐字点名缺哪个)→ AC-30 / `test_safe_load_only_rejects_python_object_tag`(`!!python/object` 不得被反序列化——`full_load` / `unsafe_load` 是 RCE,F055 design D3 红线)→ AC-30 / `test_optional_fields_not_validated_locally`(`tier` / `capabilities` / `database` / `egress` 本地一律不判、**不自造字段与缺省值**,否则会出现"本地过、上传被拒")→ AC-30 / `test_app_json_written_and_read_per_base_url`(形状照 design §4.2 ④)→ AC-33 / `test_app_json_key_uses_the_same_base_url_normaliser_as_credentials`(**2026-08-17 审查新增**:`http://114:7860/` 写入、`http://114:7860` 读取必须命中同一条;两侧各自实现归一化 = 迟早分叉 → **复用 T012 落的同一个函数**,本用例直接断言 `project.normalise_base_url is credentials.normalise_base_url` 级的同源性或跑同一组样例)→ AC-33 / `test_explicit_app_id_overrides_saved_one` → AC-33 / `test_missing_app_id_asks_for_explicit_flag`(项目被复制到另一目录 / 标识丢失时要求显式指定,不猜)→ AC-33 + **覆盖 AC**: AC-30, AC-33 + **依赖**: T002 + +- [x] **T014**: `[MVP-核心]` `project.py` 实现(项目根定位 / manifest 快速失败 / `app.json` 读写) + **文件**: `src/bisheng-cli/bisheng_cli/project.py`(新) + **逻辑**: `find_project_root(path)`;`load_manifest(root)` = `yaml.safe_load` + 三必填(`name` / `runtime` / `port`)存在性检查 → 失败抛 `CliError(exit=6)` 并列出缺失项;**不做完整 schema 校验、不写 manifest**(AppManifest 归 F055,`extra='forbid'`,CON-1 / 决议-5)。`AppRef` 读写 `<项目根>/.bisheng/app.json`(按平台 base URL 分键——**键一律经 `credentials.normalise_base_url()`,不在本模块另写一份**,审查新增;`--app-id` 可显式覆盖,D13)。**建议开发者把 `.bisheng/` 提交进 git**(团队共享同一个 `app_id`),但它**结构性排除出上传包**(T018)——"进 git"与"进包"是两件事。 + **测试**: T013 全部通过。 + **覆盖 AC**: AC-30, AC-33 + **依赖**: T004, T013 + +- [x] **T015**: `[MVP-核心]` 忽略规则测试(三层规则 + git/子集解析器一致性) + **文件**: `src/bisheng-cli/tests/test_ignore.py`(新) + **逻辑**: 覆盖 D4 的三层规则与坑 9。**核心用例是"同一份样本目录,git 路径与自研子集解析器结果必须一致"**——两条路径分叉就是静默的包内容差异。 + **测试**: `test_git_path_and_subset_parser_agree_on_sample_tree`(`sample_project_git` 上跑 `git ls-files -c -o --exclude-standard -z` 与子集解析器,断言两个集合相等;无 git 则 skip)→ AC-32 / `test_double_star_matches_across_directories`(`**` 单独处理——**`fnmatch` 的 `*` 跨 `/` 匹配、且根本没有 `**`**,`linsight/domain/services/workspace_backend.py:616-619` 的注释逐字记录了这两个坑,本仓已真实咬过一次)→ AC-32 / `test_single_star_does_not_cross_slash`(每段单独匹配)→ AC-32 / `test_leading_bang_negation_takes_back` → AC-32 / `test_trailing_slash_matches_directory_only` → AC-32 / `test_bishengignore_loaded_last_and_wins` → AC-32 / `test_hard_excluded_dirs_cannot_be_taken_back_by_bang`(`.git/` `node_modules/` `.venv/` `__pycache__/` `.bisheng/` 等硬排除,`!` 取不回)→ AC-32 / `test_dist_and_build_are_soft_excluded_and_can_be_taken_back`(⚠️ **`dist/` / `build/` 刻意不进硬排除**:存量应用把前端构建产物放 `dist/` 交后端静态托管是常见形态,硬排会静默剥掉、故障要到构建 / 探活阶段才暴露)→ AC-32 / `test_non_git_project_output_says_subset_parsing_used`(非 git 仓库时**在输出里明说**"忽略规则按子集解析,复杂规则请写进 `.bishengignore`")→ AC-32 + **覆盖 AC**: AC-32 + **依赖**: T002 + +- [x] **T016**: `[MVP-核心]` `ignore.py` 实现(git 优先 / 子集解析 / `.bishengignore` 收口) + **文件**: `src/bisheng-cli/bisheng_cli/ignore.py`(新) + **逻辑**: 三层从内到外(D4):① 结构性排除(硬排除不可取回 / 软排除可被 `.bishengignore` 的 `!` 取回);② `.gitignore` 语义——是 git 仓库且 `git` 可执行 → 直接 `git ls-files -c -o --exclude-standard -z` 让 git 自己算(零依赖、100% 语义一致),否则回落自研子集解析器(`#` 注释 / 空行 / 前导 `!` / 目录后缀 `/` / `**` / 段内 `*` `?`);③ `.bishengignore`(同语法、最后加载、优先级最高)。**手写段匹配,`**` 单独处理,绝不用 `fnmatch` 顶 gitignore 全语义**(坑 9)。**不引 `pathspec`**——一条内网安装链换一个只在"非 git 项目"才用得上的完备性,不划算(D4 备选 B)。 + **测试**: T015 全部通过。 + **覆盖 AC**: AC-32 + **依赖**: T015 + +- [x] **T017**: `[MVP-核心]` 打包测试(tar.gz 四条硬规矩 + 凭据不进包 + 体量自查) + **文件**: `src/bisheng-cli/tests/test_packaging.py`(新) + **逻辑**: 断言 D4 的"打包四条硬规矩",每条都是一个坑的对偶。 + **测试**: `test_credentials_file_never_in_package`(**assert 型测试**,AC-07 的直接落点)→ AC-07, AC-32 / `test_venv_node_modules_git_pycache_excluded` → AC-32 / `test_local_sqlite_and_attachment_dir_excluded` → AC-32 / `test_member_paths_are_posix_relative_no_backslash_no_abs_no_dotdot`(Windows 上 `Path` 给 `\` 分隔 → 服务端解包出 `src\main.py` 这样的**单个文件名**,目录结构全平;或触发 16202,坑 4)→ AC-31, AC-32 / `test_symlink_hardlink_device_fifo_skipped_and_listed`(服务端 tar 解包闸会拒它们,本地不跳等于必然吃一个 16202 且浪费一次上传,坑 14;**跳过要列出、不静默**)→ AC-31 / `test_owner_exec_bit_preserved_0755_others_normalized_0644`(丢可执行位 → entrypoint 上线后不可执行,故障要到构建 / 探活才暴露、排查方向指向"平台构建有问题",坑 20)→ AC-31 / `test_reproducible_same_sha256_twice`(成员按路径排序、`mtime` 归一、`uid=gid=0`、`uname=gname=""`)→ AC-31 / `test_never_silently_truncates_on_limit`(超限**整包拒绝**并列清单,判据照 `linsight/domain/services/workbench_impl.py:1255-1257`:"包被悄悄截断 = 线上跑的不是本地那份")→ AC-32 / `test_oversize_report_lists_excluded_count_then_top10`(超限报告**先列排除了多少**再列 Top 10 最大文件 / 目录——"忽略建议"不能是空话,且开发者的第一反应"平台上限太小"多半是没排除 `.venv/`,坑 3)→ AC-32 / `test_limits_endpoint_unreachable_falls_back_to_defaults_and_proceeds`(取不到 `deploy-limits` → 用内置默认值只提示、继续上传,由服务端 16201 兜底;**绝不让一个软校验挡死发布**,坑 17 / CON-4)→ AC-32 + **覆盖 AC**: AC-07, AC-31, AC-32 + **依赖**: T002, T002a, T016 + **⚠️ 上限口径**: `{max_package_mb, max_unpacked_mb, max_package_entries}` 的权威值经 `GET /api/v2/apps/deploy-limits` 取(F055 T005 落的配置键、T039 的端点),内置默认 50 MB / 200 MB 解包 / 20000 条目**只作回落**(F055 T050 回写项 3 已登记同一口径,不另立一套)。 + +- [x] **T018**: `[MVP-核心]` `packaging.py` 实现(tar.gz + 统计 + Top10 报告) + **文件**: `src/bisheng-cli/bisheng_cli/packaging.py`(新) + **逻辑**: `build_package(root, ignore_result, out_path) -> PackageStat`:`tarfile` + `gzip` 产 **tar.gz**(**不产 zip**——F055 端点参数逐字写的是 `package`、对象键是 `apps/{app_id}/versions/{version_id}/code.tar.gz`,产 zip 等于和字面契约、对象键名、未来分片方案三处同时错位,D4);`arcname = PurePosixPath(*rel.parts)` 且落包前逐条 assert 无 `\`、无绝对路径、无 `..`(坑 4);模式归一 `0644`、**保留 owner 执行位为 `0755`**(坑 20);链接 / 设备 / FIFO 跳过并收集进 `PackageStat.skipped`;成员排序 + `mtime` 归一 + `uid/gid/uname/gname` 归零(可复现)。`PackageStat` 含条目数 / 原始体量 / 压缩体量 / 排除统计 / Top 10 最大文件与目录。**不静默截断、不上传**(上传在 T022)。 + **测试**: T017 全部通过。 + **覆盖 AC**: AC-07, AC-31, AC-32 + **依赖**: T016, T017 + +--- + +### Wave 2 · `[MVP-核心]` 三条命令、平台分发端点与 114 验证 + +- [x] **T019**: `[MVP-核心]` `login` 测试(探测 → whoami → 拒 `delegate` → 写凭据) + **文件**: `src/bisheng-cli/tests/test_command_login.py`(新) + **逻辑**: 按 design §4.1 A 的数据流逐分支断言。⚠️ **`delegate` 相关用例只能用 mock `whoami` 响应**——该位在 F049 期根本签不出来(`open_api/domain/scopes.py:186-188` NOTE,坑 15),**去 114 上试不出来不等于功能没生效**。 + **测试**: `test_success_writes_profile_and_prints_platform_account_owner_mask_expiry`(输出目标平台 / 服务账号名 / 资源归属人 / `key_mask` / 到期时间)→ AC-06 / `test_login_says_so_when_the_key_has_no_resource_owner`(`whoami` 暂无 `resource_owner` → 明打一句"资源归属人请在服务账号详情页确认",**不静默省略**;`[受阻于 F049 回写]`,D14 / T034 回写项 1)→ AC-06 / `test_no_scope_check_at_all`(**不校验任何权限位**——未勾任何位的有效密钥可正常 `login`)→ AC-06, AC-10 / `test_delegate_scope_refused_before_writing_credentials`(不写凭据、错误指向"这把 key 配置为委托专用,本地开发请另发一把"、exit 5;**断言凭据文件不存在**)→ AC-09 / `test_delegate_refusal_is_not_a_silent_fallback_to_mode_s`(断言退出码非 0 且不出现"登录成功"字样,INV-31)→ AC-09, AC-13 / `test_delegate_refusal_is_not_a_bare_param_error`(错误文案必须指向委托专用,不得只回一个参数类错误码)→ AC-09 / `test_malformed_header_and_rejected_key_are_distinguishable`(**2026-08-17 审查订正**:原用例名写"四种成因(无效 / 撤销 / 过期 / 停用)互不相同",与 F049 **已落码**的形状冲突——`OpenApiCredentialInvalidError` 一个码 `26002` + 一条 Msg 同时覆盖「不存在 / 已撤销 / 已过期」(`common/errcode/open_api.py:50-55` docstring 逐字 "Credential unknown, revoked or expired"),服务端**不给**区分这三者的任何信号,CLI 造不出来。可断言且必须断言的是**三条码**:`26001`(没带 / 带歪 Authorization → 让用户检查 `--api-key` 传法)· `26002`(密钥不被接受 → 重签或换一把)· `26027`(服务账号被停用 / 删除 → 找管理员启用账号,**不是**换密钥)文案互不相同;AC-10「彼此可区分」按此读为「密钥类失败 vs 平台不可达 vs 未部署开放能力层」三组可区分,**不承诺撤销与过期可分**)→ AC-10, AC-11 / `test_platform_unreachable_and_layer_absent_are_distinguishable`(exit 7 vs exit 8,探测在 whoami 之前结束)→ AC-05, AC-10 / `test_key_from_flag_env_stdin_tty_priority`(`--api-key` > `BISHENG_API_KEY` > `--api-key-stdin` > TTY 隐藏输入;四者皆无 → exit 2)→ AC-07 / `test_key_never_echoed_in_any_output`(含 `--verbose`)→ AC-04, AC-07 / `test_relogin_overwrites_same_platform_profile` → AC-12 / `test_no_auto_skills_sync_this_round`(本轮不触发 `skills sync`——AC-08 随顺延项 T035–T038,此处显式断言"没有静默尝试")→ AC-08 + **覆盖 AC**: AC-04, AC-05, AC-06, AC-07, AC-08, AC-09, AC-10, AC-11, AC-12, AC-13 + **依赖**: T002, T002a, T008, T012 + +- [x] **T020**: `[MVP-核心]` `commands/login.py` 实现 + **文件**: `src/bisheng-cli/bisheng_cli/commands/login.py`(新) + **逻辑**: 编排 = `http.probe(base_url)` → `GET /api/v2/auth/whoami`(**F049 已实现,commit `43e73bfc5`**;`/api/v2` 下唯一 `@open_api_scope(None)` 的端点,`open_api/api/endpoints/auth.py:23`,正是 AC-06「不校验任何权限位」的落点)→ `scopes` 含 `"delegate"` 则拒绝并退 5 → 写 profile(T012)→ 输出。密钥来源优先级见 design §4.2 ①;`getpass` 隐藏输入。**不直接用 httpx**(走 `http.py`)。**本轮不自动执行 `skills sync`**(AC-08 随 T038)。 + **测试**: T019 全部通过。 + **覆盖 AC**: AC-04, AC-05, AC-06, AC-07, AC-08, AC-09, AC-10, AC-11, AC-12, AC-13 + **依赖**: T010, T012, T019 + +- [x] **T021**: `[MVP-核心]` `deploy` 同步段测试(本地校验 → 打包 → 上传 → 同步错误分支) + **文件**: `src/bisheng-cli/tests/test_command_deploy_sync.py`(新) + **逻辑**: ⚠️ **本任务是红线 1 的直接承载**:`POST /api/v2/apps/deploy` 的**同步响应**里就已跑完归属判定 · 大小闸 · 解包闸 · manifest 校验 · 本地引用校验 · 在途单 / 待上线闸(F055 design §4.1 ① `:411-427`),**这些失败一条都不进轮询**。 + **测试**: `test_not_logged_in_exits_3_without_any_request` → AC-51 / `test_local_manifest_failure_refuses_before_packaging`(不打包、不上传)→ AC-30 / `test_dry_run_stops_after_packaging_and_prints_stats`(条目数 / 体量 / 忽略统计,不上传)→ AC-31, AC-32 / `test_oversize_refused_locally_with_top10` → AC-32 / `test_upload_is_streamed_multipart_not_read_bytes`(传 file object,50 MB 不进内存,D5)→ AC-31 / `test_app_id_persisted_immediately_on_http_200_even_if_pipeline_later_fails`(⚠️ **接收成功即写盘**:首发预检失败时平台**已经建好草稿应用并分配了 `app_id`**,只在成功时才保存 → 开发者修完 manifest 重跑会**再建一个应用**,构建页堆一串同名草稿;且 F055 明确依赖"CLI 保存 `app_id` 以复用",坑 13 / D13)→ AC-33 / `test_iterative_deploy_prints_target_app_name_and_id_before_upload`(误投护栏:指定了**同一归属人的另一个应用**会真的更新那个应用——无声的错误;非交互模式下这行输出是唯一护栏)→ AC-33 / `test_interactive_confirm_skipped_by_yes_flag` → AC-04 / `test_first_deploy_prints_app_id_and_entry_url_when_present_else_points_to_detail_page`(`entry_url` 只在 `POST` 返回里、首发时大概率为 `null` → **明确指路"应用详情页 · 发布"而不是打印空串或 `None`**;`[受阻于 F055 回写]`,T034 回写项 3)→ AC-33 / `test_26003_missing_app_manage_refused_and_names_the_required_scope`(exit 5,输出里出现整串 `app:manage`——`data.required` 是**单个字符串**不是数组,见 T003;判定与归属人有无"创建应用"角色权限、角色是否被调整、账号是否被停用**无关**)→ AC-34 / `test_16205_other_owner_refused_and_names_the_owner` → AC-38 / `test_16251_in_flight_approval_refused_with_withdraw_path`(撤回路径在应用详情页 · 发布,CLI 不提供撤回命令)→ AC-37 / `test_16252_pending_online_refused` → AC-37 / `test_16229_schema_change_requires_confirm_flag`(未确认即终止、不生成审批单;非交互环境未带参数视为未确认)→ AC-36 / `test_confirm_schema_change_flag_forwarded_as_form_field`(F055 本轮"只接收不消费",参数存在与否是 CLI 的对外契约)→ AC-36 / `test_16207_workshop_runtime_layer_absent_exits_8`("本环境未启用应用工场",**不返回未定义错误**)→ AC-40 / `test_16201_16202_16203_map_to_exit_6` → AC-32 / `test_sync_error_never_enters_polling_loop`(**红线 1 的断言**:mock 只准备一次 `POST` 响应,若进了轮询循环 `no_network` 哨兵会当场失败)→ AC-31a + **覆盖 AC**: AC-04, AC-30, AC-31, AC-31a, AC-32, AC-33, AC-34, AC-36, AC-37, AC-38, AC-40, AC-51 + **依赖**: T002, T002a, T008, T012, T014, T018 + +- [x] **T022**: `[MVP-核心]` `commands/deploy.py` 同步段实现(校验 → 打包 → limits → 上传 → 写 `app.json`) + **文件**: `src/bisheng-cli/bisheng_cli/commands/deploy.py`(新) + **逻辑**: 严格按 design §4.1 B 步 1–6:载入凭据 → 定位项目根 + `load_manifest` → 解析忽略规则 + 打包(`--dry-run` 到此为止)→ `GET /api/v2/apps/deploy-limits` 自查(取不到只提示、继续)→ 迭代场景打印目标应用名称 + 标识(`--yes` 跳过交互确认)→ `POST /api/v2/apps/deploy`(multipart:`package` + `app_id?` + `confirm_schema_change`)→ **200 立刻写 `.bisheng/app.json`**(坑 13)并记下 `entry_url`(轮询载荷里没有这个字段,D7)→ 同步错误按 code 映射退出码**直接终止、不进轮询**。 + **测试**: T021 全部通过。 + **覆盖 AC**: AC-04, AC-30, AC-31, AC-31a, AC-32, AC-33, AC-34, AC-36, AC-37, AC-38, AC-40, AC-51 + **依赖**: T014, T018, T021(鉴权客户端构造在 `http.py`/T008、凭据载入在 `credentials.py`/T012,二者经 T021 的依赖链已就位;**刻意不依赖 T020**——`deploy` 与 `login` 之间没有代码依赖,写上就把"三路并行"变成串行链) + **跨 Feature**: 服务端 = **F055 T039**(`POST /apps/deploy` / `GET /apps/deploy-limits`)。 + +- [x] **T023**: `[MVP-核心]` `deploy` 轮询与 `--wait` 终态测试(分阶段输出 / 六终态 / 退出码) + **文件**: `src/bisheng-cli/tests/test_command_deploy_wait.py`(新) + **逻辑**: 用 `platform_mock.deployment_seq([...])` 编排轮询序列,断言 D6 / D7。 + **测试**: `test_stage_events_in_server_order_received_first`(⚠️ **第一条轮询响应的 `stage` 就是 `received`**,漏登记会让每次 deploy 的第一行输出掉进"未知 stage"兜底分支)→ AC-31a / `test_all_eleven_server_stages_translate_or_pass_through`(权威枚举照 F055 `055-.../design.md:86`:`received · secret_scan · precheck_manifest · precheck_build · precheck_probe · version_recorded · approval_created · approved · publishing · online · pending_online`;**`approval_created` 是机读值、不得写成中文「审批单生成」**——那是展示串,混淆会让 `--json` 的 `stage` 与服务端对不上)→ AC-31a / `test_stage_translation_is_a_mapping_not_an_ordered_sequence`(**2026-08-17 审查新增**:上面那串**是取值集合、不是顺序断言**——F055 design:86 的花括号写法把 `secret_scan` 列在 `precheck_*` 之前,而 **2026-08-17 该 ★ 已拍板:`secret_scan` 提前到 `precheck_*` 之前**(F053 spec 决议-13;扫描输入是源码包不依赖构建产物、失败更快、DEV-04 四步是逻辑分组非顺序契约),AC-31a 已同步为「安全扫描 → 托管预检 → 审批单」。**但这不改变本条的结论**——顺序既然改过一次就可能再改,CLI 把它写死就是把服务端的编排决策复制到客户端。→ CLI **只按服务端到达顺序输出**,`STAGE_LABELS` 是无序 dict;断言"任意顺序的 stage 序列都不报错",**不要写死顺序断言**——写死顺序的用例会在服务端下次调整阶段编排时连同 README 一起变红)→ AC-31a / `test_unknown_stage_printed_verbatim_not_an_error`(不认识 ≠ 报错)→ AC-31a / `test_any_failed_stage_stops_polling_and_exits_nonzero` → AC-31a / `test_failure_tuple_passed_through_untouched_in_json`(`{stage,code,message,details,hints[]}` **原样**进 `result.failure`——**绝不吞 `details` / `hints`**,那是 agent 自动修复的全部输入)→ AC-31a, AC-35 / `test_precheck_failure_prints_missing_items_and_hints_lines` → AC-35 / `test_16241_scan_hit_prints_file_line_only_never_the_value` → AC-35 / `test_default_returns_0_at_waiting_approval_with_three_tracking_paths`(应用详情页 · 发布 / MCP 应用状态工具 / `deploy --wait`)→ AC-31b / `test_wait_online_exits_0_with_entry_url_or_pointer`(`entry_url` 缺位时指路详情页;`[受阻于 F055 回写]`)→ AC-31c, AC-33 / `test_wait_rejected_exits_20_with_full_reason`(驳回理由**全文不截断**)→ AC-31c / `test_wait_withdrawn_exits_21` → AC-31c / `test_wait_pending_online_exits_22_with_manual_publish_path`(`pending_reason ∈ {capacity, deploy_failed}`)→ AC-31c / `test_wait_cancelled_exits_24_immediately_not_timeout`(⚠️ 应用被删除 → 这单**永远不会有结论**,只认四终态会一路轮到超时再打印"这不是失败,请继续等",D7)→ AC-31c / `test_wait_exception_exits_25_immediately_not_timeout`(审批人解析为空,同上)→ AC-31c / `test_wait_timeout_exits_23_and_says_not_a_failure`(超时与驳回的后续动作完全相反——等 vs 改)→ AC-31c / `test_backoff_2s_x1_5_capped_10s` → AC-31c + **覆盖 AC**: AC-31a, AC-31b, AC-31c, AC-33, AC-35 + **依赖**: T002, T002a, T021 + +- [x] **T024**: `[MVP-核心]` `deploy` 轮询与终态机实现(`commands/deploy.py` 增量) + **文件**: `src/bisheng-cli/bisheng_cli/commands/deploy.py`(**增量新增 `_poll()` / `_terminal()` 两个函数与 `STAGE` 常量,不重写 T022 已落的同步段**) + **逻辑**: 短轮询 `GET /api/v2/apps/deployments/{deployment_id}`,间隔 2 s 起、每 5 次 ×1.5、封顶 10 s;`--wait-timeout` 默认 1800 s。**长连接 / SSE 物理上不可行**——nginx `proxy_read_timeout 300s`(`docker/nginx/conf.d/default.conf:53`)而审批是人工动作可跨天(坑 6)。两段语义分开:无 `--wait` 轮到 `status=waiting_approval` 即返回 0;有 `--wait` 续轮至终态,映射照 design D7 终态表(0 / 20 / 21 / 22 / 23 / 24 / 25)。`approval.status` **必须认全六种**:`pending` / `rejected` / `withdrawn` / `cancelled` / `exception` / `executed|approved`。 + **测试**: T023 全部通过。 + **覆盖 AC**: AC-31a, AC-31b, AC-31c, AC-33, AC-35 + **依赖**: T022, T023 + **跨 Feature**: 服务端 = **F055 T039**(`GET /apps/deployments/{id}`);终态语义来自 F055 T034/T035(驳回 / 撤回 / 删除致取消)与 T029(审批人为空 → `decision=EXCEPTION`)。 + +- [x] **T025**: `[MVP-核心]` `logs` 测试(服务端判权限 / 空结果提示 / `--follow` 去重) + **文件**: `src/bisheng-cli/tests/test_command_logs.py`(新) + **逻辑**: 断言 D8 —— **CLI 对归属零逻辑**,owner-only 全由服务端判(读 `OpenApiPrincipal.resource_owner_user_id`,**不是** `subject_user_id`,F055 T039)。 + **测试**: `test_not_logged_in_exits_3_without_request` → AC-51 / `test_tail_since_keyword_forwarded_as_query_params`(可查看最近一段 / 按时间范围 / 关键词)→ AC-41 / `test_16254_owner_only_refused_with_readable_reason`("该应用不属于当前密钥的资源归属人")→ AC-42 / `test_16205_other_owner_refused` → AC-42 / `test_cli_never_checks_ownership_itself`(断言请求先发出、CLI 不预查归属——第二处归属判定会与服务端分叉,且 CLI 拿不到权威数据)→ AC-42 / `test_26003_missing_app_manage_exits_5` → AC-41 / `test_16207_layer_absent_exits_8` → AC-40 / `test_empty_lines_prints_app_state_hint_not_blank`("未取到日志:应用可能没有运行实例(草稿 / 待上线 / 已下线),请在应用详情页确认应用态"——**明确提示而不是空白**;`[受阻于 F055 回写]`,满足 AC-43 精神但不满足其字面,T034 回写项 2)→ AC-43 / `test_follow_polls_with_since_every_3s` → AC-41 / `test_since_accepts_epoch_seconds_and_relative_window`(**2026-08-17 审查新增,照 runtime-manager 已落码的实测口径**:`since` 收 **epoch 秒** 或 `30m` / `2h` / `7d` 相对窗口,不可解析由服务端回 400;CLI 不自造第三种写法、也不在本地把相对窗口换算成绝对时间——换算等于给同一语义造第二个实现)→ AC-41 / `test_keyword_may_return_fewer_lines_than_tail_is_not_a_bug`(`keyword` 在 manager 内过滤,**返回行数可 < `tail`**,`contracts-runtime-manager.md` §2 逐字"这是设计不是 bug";CLI 不得据此重试或报警)→ AC-41 / `test_16121_orchestrator_unavailable_says_retry_not_app_missing`(dockerd / runtime-manager 宕机时链路回 **503 → `16121`**、**不是 404**;"后端不可用 ≠ 实例不存在",打成"应用已删除"会让 owner 白排查一轮)→ AC-41 / `test_follow_dedupes_same_second_repeats`(⚠️ **docker 时间戳是秒级**,同一秒的多行在下一轮 `since` 里会重复返回;同类坑本仓咬过:memory `project_chat_history_sameSecond_order`)→ AC-41 / `test_app_id_resolution_same_as_deploy`(项目内记录或显式指定)→ AC-43 / `test_since_empty_says_no_logs_in_range_or_rotated_not_never_happened`(日志保留期不做承诺——runtime-manager 侧是 docker 日志轮转窗口)→ AC-41 + **覆盖 AC**: AC-40, AC-41, AC-42, AC-43, AC-51 + **依赖**: T002, T002a, T008, T012, T014 + +- [x] **T026**: `[MVP-核心]` `commands/logs.py` 实现 + **文件**: `src/bisheng-cli/bisheng_cli/commands/logs.py`(新) + **逻辑**: `GET /api/v2/apps/{app_id}/logs?tail=&since=&keyword=` → `{lines[]}`(`tail` 服务端范围 1–5000、默认 500;`since` = epoch 秒或 `30m`/`2h`/`7d`;日志保留期 = docker 轮转窗口,产品口径「最近的运行日志」,**CLI 不做任何保留期承诺**);错误码翻人话(`errors.py`,含 `16121` → exit 7);`--follow` 携 `since` 每 3 s 短轮询(**无流式接口**:服务端链路是 `GET /api/v2/.../logs` → F054 `GET /api/v1/apps/{id}/logs` → runtime-manager `GET /v1/apps/{id}/logs`(**已实现**)→ `docker logs`,全是一次性响应,坑 18 的"未实现"口径已于 2026-08-17 审查回正),保留上一轮最后 N 行的内容哈希集合做去重。**CLI 不做任何归属判定**。 + **测试**: T025 全部通过。 + **覆盖 AC**: AC-40, AC-41, AC-42, AC-43, AC-51 + **依赖**: T014, T025(同 T022:**刻意不依赖 T020**,`logs` 与 `login` 之间没有代码依赖) + **跨 Feature**: 服务端 = **F055 T039**(`GET /apps/{app_id}/logs` + owner-only 判定);中段 = **F054 T057**(backend `GET /api/v1/apps/{app_id}/logs`,**未实现**);终点 = **F054 T030/T031**(runtime-manager 只读接口,✅ **已实现**,commit `d693feeb3` —— 2026-08-17 审查回正,原文写的"当前未实现"已过时)→ 114 联调排在 **F054 T057 → F055 T039** 之后,本任务的验收本轮只到单测。 + +- [x] **T027**: `[MVP-核心]` 平台分发端点集成测试(匿名可达 / 开关关闭即 404 / 租户豁免) + **文件**: `src/backend/test/dev_toolkit/test_distribution_api.py`(新), `src/backend/test/dev_toolkit/conftest.py`(新) + **逻辑**: **本 Feature 唯一落 `src/backend/test/` 的测试**(不放 `test/` 根,`asyncio_mode=auto`)。 + **测试**: `test_versions_reachable_without_any_credential`(**匿名**,不带 Bearer 也不带 JWT)→ AC-01 / `test_versions_payload_shape`(`cli.{version,min_compatible,filename,sha256,download_path}` + **`sdk.{version,min_compatible,download_path}` 字段位存在且本轮为 `null`**(F057 AC-01/AC-03 消费同一端点,不留就会在 F057 期出现第二个端点或破坏性改形)+ `platform.{version,open_platform_enabled,app_runtime_enabled}`)→ AC-01, AC-02 / `test_platform_version_not_taken_from_env_endpoint`(⚠️ `/api/v1/env` 的 `version` 是硬编码 `'2.6.0-fix'`(`src/backend/bisheng/__init__.py:7`,3.0-vibe 实测)→ 断言 `platform.version` 取自 `artifacts/manifest.json` 而非那里,坑 21)→ AC-02 / `test_download_returns_file_response_with_content_disposition_and_length` → AC-01 / `test_download_requires_no_login_and_no_key` → AC-01 / `test_routes_absent_when_open_platform_disabled`(⚠️ **断言是路由不存在(404)、不是某个错误码**——AC-05「呈不存在」的字面落点,且不给未认证方留"这里有个功能但没开"的探测面)→ AC-05 / `test_multi_tenant_enabled_no_jwt_does_not_raise_no_tenant_context`(多租户开启 + 无 JWT 时不抛 `NoTenantContextError`——豁免名单生效的回归护栏,见 T029)→ AC-01 / `test_missing_artifacts_degrade_readably_not_500`(**2026-08-17 审查新增**:`artifacts/` 里的 wheel 与 `manifest.json` 是**提交进 git 的构建产物**,任何"检出后没跑 `scripts/pack_cli_wheel.sh`"的开发环境里它们就是不存在的 —— 断言 `versions` 仍 **200** 且 `cli` 段为 `null` 并带一句可读说明(不抛、不 500),`download` 返 **404 + 可读 message**("CLI 安装件未随本次部署发布,请联系平台管理员")而**不是** `FileResponse` 对不存在路径抛出的 500 + traceback;本用例同时是"产物漏提交"这一类发版事故的唯一自动告警)→ AC-01 + **覆盖 AC**: AC-01, AC-02, AC-05 + **依赖**: 无(平台侧完全独立于 CLI 工程,可与 Wave 1 全程并行;编号排在此处只为与 T028 相邻) + +- [x] **T028**: `[MVP-核心]` 平台分发端点实现(**本 Feature 唯一改 backend 业务代码的任务**) + **文件**: `src/backend/bisheng/dev_toolkit/api/endpoints/distribution.py`(新), `src/backend/bisheng/dev_toolkit/api/router.py`(新), `src/backend/bisheng/api/router.py`(**共享文件,纯追加**) + **逻辑**: 两个匿名端点(D10):`GET /api/v1/dev-toolkit/versions`(载荷照 design §4.2 ⑤,**一次留够 `sdk.*` 字段位**)与 `GET /api/v1/dev-toolkit/cli/download`(`FileResponse(wheel_path, filename=…)`)。模板 = `GET /api/v1/env`(`api/v1/endpoints.py:62-63`:普通 `@router.get`,**函数签名里没有任何 auth `Depends` 即匿名**);`FileResponse` 写法照 `open_endpoints/api/endpoints/filelib.py:496-506`(自动 `Content-Length`、支持 Range、零内存放大)。产物用 `Path(__file__).resolve().parents[N] / "artifacts"` 定位(写法同 `linsight/builtin_skill_seeder.py`)。 + **⚠️ 产物缺失是常态分支、不是异常**(审查新增):未跑 `pack_cli_wheel.sh` 的检出里 `artifacts/` 是空的 → `versions` **恒 200**、`cli` 段给 `null` + 一句可读说明(形状不变、字段位保留,agent 的解析不会因此崩);`download` 给 **404 + 可读 message**。**绝不让 `FileResponse` 对着不存在的路径抛**(500 + traceback 会把"产物没提交"这个发版问题伪装成"平台坏了")。 + **⚠️ 落点没有选择余地**:`/api/v2/**` 下**结构性否决**(F049 计划把 `verify_open_api_access` 提升到整个 `router_rpc`,`bisheng/api/router.py:123-126` 的注释,届时那里放匿名端点是永久例外);裸路径 `/cli/download` **不可达**(商业版网关只转发 `/api/v1/**` 与 `/api/v2/**`,`docs/architecture/11-gateway.md:36-37`;OSS nginx `docker/nginx/conf.d/default.conf:50` 同形)。 + **⚠️ AC-05 的落地 = 条件注册,不是抛 404**:`if settings.open_platform.enabled:` 为假时**根本不 `include_router`**(`core/config/open_platform.py:16-20` 是**进程级 YAML 配置、非 DB 热配置**)→ FastAPI 天然 404。收益三条:字面满足"呈不存在"、**零新错误码**(CON-8)、不留探测面。代价 = 改配置要重启后端,这正是进程级配置的既有语义。 + **⚠️ 不用 MinIO 预签 URL**:安装件是随镜像发布的静态物;且 `clear_minio_share_host`(`minio_storage.py:686`,docstring `:687-691` 逐字说明"让前端通过 nginx 代理访问资源")会返回依赖前端 nginx 的相对路径,CLI 直连时拿不到可用 URL。 + **⚠️ 不 import `database/models`、不查 DB**(RULE-3;本轮只读磁盘产物)。 + **跨 Feature 冲突面**: `bisheng/api/router.py` 的 `:66-112` 一片同时被 **F054 T035/T057** 与 **F055 T039/T041** 追加 `include_router`;三方都是纯追加,**解冲突时三行都要保留**;本任务的 include 是**条件的**,不要"顺手统一"成无条件。 + **测试**: T027 全部通过。 + **覆盖 AC**: AC-01, AC-02, AC-05 + **依赖**: T027 + +- [x] **T029**: `[MVP-核心]` `/api/v1/dev-toolkit` 进 `TENANT_CHECK_EXEMPT_PATHS` + **文件**: `src/backend/bisheng/utils/http_middleware.py`(`TENANT_CHECK_EXEMPT_PATHS`,`:16-44`,**共享文件、增一条**) + **逻辑**: 追加前缀 `"/api/v1/dev-toolkit"` 并写一行注释说明理由。**这不是可选优化**:多租户开启时无 JWT 的请求**不会**调 `set_current_tenant_id`(`http_middleware.py:107-112` 只在 `not multi_tenant.enabled` 时兜底),任何 DAO SELECT 都会 `NoTenantContextError`;豁免名单会把整棵调用树置于 `_bypass_tenant_filter`(`:321-323`)。本端点若严格只读磁盘可以不加,但**版本端点要回 `open_platform_enabled` / `app_runtime_enabled` 且将来极可能读表**,现在就加更省事(D10)。 + **回滚**: 删该行即回滚,无数据变更。 + **测试**: T027 的 `test_multi_tenant_enabled_no_jwt_does_not_raise_no_tenant_context` 通过。 + **覆盖 AC**: AC-01, AC-05 + **依赖**: T028 + +- [x] **T030**: `[MVP-核心]` `scripts/pack_cli_wheel.sh` + `artifacts/` 布局(构建契约) + **文件**: `scripts/pack_cli_wheel.sh`(新,骨架照 `scripts/pack_linsight_skill.sh:1-40`), `src/backend/bisheng/dev_toolkit/artifacts/manifest.json`(新,初始占位 + 说明) + **逻辑**: `uv build` 产 wheel → 校验(文件非空、`bisheng_cli/` 在包内、版本与 `bisheng_cli.__version__` 一致)→ **装 wheel 冒烟**(见下)→ 拷进 `src/backend/bisheng/dev_toolkit/artifacts/` → 写 `manifest.json`(`{cli:{version,min_compatible,filename,sha256}, platform:{version}}`,T028 的端点读它)。脚本失败一律非零退出、把失败原因写清(照 `pack_linsight_skill.sh` 的 `[FAIL] …` 风格)。 + **⚠️ 冒烟必须装进一个干净 venv 并 import 生产入口**(2026-08-17 审查新增,本轮真事故的直接对策):`uv venv /tmp/bisheng-cli-smoke && <该 venv>/bin/pip install <刚产的 wheel>` → ① `python -c "import bisheng_cli.main"`(**模块级 import**,把"依赖解析出来的新版本在 import 期就崩"这一类挡在发版之前)· ② `bisheng --version` 退出码 0 且输出 == `bisheng_cli.__version__`(顺带验 console script 真的注册上了)· ③ `bisheng deploy --help` 退出码 0(验 argparse 树可构建)。**三条任一失败 = 脚本非零退出、不拷产物**。判据:app-proxy 的 `fastapi>=0.115` 在开发机测全绿、生产解析到 0.141 后 `uvicorn` 模块级 import 当场崩——**源码树里跑 pytest 永远看不见这一类**,因为它测的是 dev 环境已解析好的那组版本;而 CLI 的分发路径就是"在别人机器上 pip install",冒烟不做等于把这一步外包给客户。 + **⚠️ 为什么产物必须随后端包同行并提交进 git**(坑 11,最容易在生产翻车的一条):backend 镜像的 build context **只有 `./src/backend/`**(`.github/workflows/ci.yml:54` 的 `docker buildx build … ./src/backend/` + `src/backend/Dockerfile:13` `COPY ./ ./`)→ **`src/bisheng-cli/` 不在镜像内**。判据照 `linsight/builtin_skill_seeder.py:45-47` 逐字:"inside the package so every deployment shape (docker COPY, rsync, pip install) carries it automatically"。**目录名不能叫 `build` / `lib` / `wheels` / `sdist`**——`.gitignore:137,148,150,152` 是无前导斜杠的全局规则,任何层级同名目录都被忽略、产物 `git add` 静默失败。落码后**必须跑一次 `git check-ignore -v src/backend/bisheng/dev_toolkit/artifacts/` 确认无命中**。 + **发版契约**(写进脚本头注释与 T032 的 README):**改了 CLI 就必须重跑本脚本并把产物一起提交**,否则平台分发的是旧 wheel;114 是真 git 检出 + `bash /opt/bisheng-ops/deploy.sh`、不走镜像,**两条路径都要验**。 + **覆盖 AC**: AC-01 + **依赖**: T001, T010 + +- [x] **T031**: `[MVP-核心]` CI 接入(ruff + `pytest -m "not network"` + 版本漂移守卫) + **文件**: `.github/workflows/cli-quality.yml`(**新建独立 workflow 文件**——审查订正:原文只写到目录级、"新增一个 job"若落进既有 `ci.yml` 就变成共享文件的合并冲突面;独立文件把冲突面降到零,形制照 `frontend-quality.yml` 的同级地位。**不改任何既有 workflow 的 steps**) + **逻辑**: 新 workflow **三条 leg**(审查修订,原文只有第一条): + 1. **锁定解析**:`cd src/bisheng-cli && uv sync --frozen && uv run ruff check . && uv run pytest -m "not network"`(**`--frozen`**:`uv.lock` 是提交进 git 的,CI 必须用它、不许就地重解析——口径同 `src/backend/Dockerfile:7` 的 `uv sync --frozen --no-dev`)。 + 2. **装 wheel 冒烟**:跑 `bash scripts/pack_cli_wheel.sh` 并断言其内置冒烟通过(T030 的三条:`import bisheng_cli.main` · `bisheng --version` · `bisheng deploy --help`)。**这是唯一一条真的按"用户拿到的形态"验证的 leg**。 + 3. **上界哨兵(allow-failure,只告警不阻断)**:`uv sync --upgrade`(或 `--resolution highest`)装到当前可解析的最新依赖后**只跑冒烟**(不跑全量),提前暴露"上界该抬了 / 新版本有 breaking"的那一天;红了就去看是抬上界还是钉版本,**不许直接删上界**。 + 再加一步**版本漂移守卫**——断言 `bisheng_cli.__version__` == `src/backend/bisheng/dev_toolkit/artifacts/manifest.json` 的 `cli.version`,不一致即失败(防"改了 CLI 忘了重跑 T030 的脚本");顺带断言 `git check-ignore -v ` **无命中**(坑 11 的产物被全局 `.gitignore` 规则吞掉那条,落码时验过一次不代表以后不会被人改回来)。 + **⚠️ 为什么必须接 CI**(design §7):仓内 CI 目前**不跑** runtime-manager / app-proxy 的任何测试(`.github/workflows/{ci,test,release,base_ci}.yml` 零命中)——CLI 不接进去就会和它们一样长期无人验证。 + **覆盖 AC**: —(工程保障任务,不直接承载 AC;它保的是 T003–T026 的测试不腐坏) + **依赖**: T002, T030 + +- [x] **T032**: `[MVP-核心]` README 与排障口径(对外契约的人读面) + **文件**: `src/bisheng-cli/README.md`(新) + **逻辑**: 内容 = ① 安装(从平台下载端点 `pip install `,纯内网零公网依赖);② 三条命令与全部参数(照 design §4.2 ①),**尾部明说 `dev` / `skills sync` 随后续版本提供**;③ **退出码全表**(§4.2 ②,agent 的自动化判定依据,是对外契约的一部分);④ `--json` NDJSON 事件契约(三种事件、`result` 恒为最后一行);⑤ 排障口径三条——**先看 body 的 `status_code` 是 `20001` 还是 `260xx`**(多租户下默认租户被禁用会给一个与密钥无关的 `20001`/403,把它当"密钥不对"会让用户反复重签密钥,坑 7)· 代理环境变量与 `NO_PROXY`(坑 2)· `deployment_id` 是与平台侧对账的唯一凭据(服务端观测在 F055 的 `app_deployment` 表与 `app.release.*` 审计事件,"CLI 说失败了"要以它去那两处对账);⑥ 发版契约(改 CLI 必重跑 `scripts/pack_cli_wheel.sh` 并提交产物,T030)。**不写"未来会有什么"的承诺清单**(顺延项的落点在 design §1 / §8,README 只写今天能用的)。 + **覆盖 AC**: AC-03, AC-04(退出码表与 NDJSON 事件契约是对外契约的人读面) + **依赖**: T024, T026, T030 + +- [ ] **T033**: `[MVP-核心]` 114 部署与手动验证清单(演示剧本步 2–3) + **文件**: `features/v3.0.0/053-dev-cli-skills/tasks.md`(本文,回填执行结果) + **逻辑**: 按 design §7「114 手动验证」逐条执行(⚠️ 部署用 `bash /opt/bisheng-ops/deploy.sh`,**不要 rsync**,memory `reference_remote_dev`): + 0. 平台侧确认开关:`curl -s http://<114>/api/v1/env` 读 `open_platform_enabled` / `app_runtime_enabled`;**开关关时 `curl /api/v1/dev-toolkit/versions` 必须 404**(这就是 AC-05 的现场验证)。 + 1. 装 CLI **两条路径都要验**:① `curl -sO http://<114>/api/v1/dev-toolkit/cli/download && pip install ./bisheng_cli-3.0.0-py3-none-any.whl`;② 直接 `pip install http://<114>/api/v1/dev-toolkit/cli/download`。`bisheng --version` 与端点 `versions` 一致。 + 2. `BISHENG_API_KEY=bs-sak-… bisheng login http://<114>`(key 来自演示剧本步 1 签发、勾了 `app:manage`)→ `ls -l ~/.bisheng/credentials.json` **必须是 `-rw-------`**。 + 3. `bisheng deploy --dry-run` 看打包体量与忽略统计 → `bisheng deploy --wait --json | tee deploy.ndjson; echo "exit=$?"` → `tail -1 deploy.ndjson` 是 `result` 行且 `exit_code` 与进程退出码一致。 + 4. `bisheng logs --tail 200`(⚠️ **2026-08-17 审查回正**:runtime-manager 侧的 `GET /v1/apps/{id}/logs` **已实现**(F054 T030/T031,commit `d693feeb3`);本步能否验到内容取决于**链路中段**的 F054 **T057** 与 F055 **T039** 是否已上 114 —— 未就绪则只验错误呈现(`16207` / `26003` / `16254`),就绪则连带验 `--since 30m` 与 `--keyword`(**带 keyword 时行数少于 `--tail` 是正常的**)。dockerd 停掉一次验 `16121` 走的是"稍后重试"而不是"应用不存在")。 + 5. **失败面演练**:`runtime` 改错验 16222;塞一个含 `bs-sak-` 字面量的文件验 16241(**断言输出里没有那个值**);把 `.venv/` 造大验超限报告与 Top 10。 + 6. **AC-42 必须用第二把「资源归属人不同」的密钥验证**——同一把永远通过,测了等于没测。 + 7. **AC-52**:管理员去掉 `app:manage` 位 → **等 5 秒**(服务端 3 秒正向缓存、硬顶 5,`core/config/open_platform.py:31-35`,坑 16)→ 不重新 `login` 直接跑 `bisheng deploy`,应即刻按新权限位被拒。 + **⚠️ `health 200` 会骗人**(admin 短路 ReBAC,memory `reference_remote_dev`):CLI 的所有权限相关判定都走服务账号(非 admin),**平台侧任何用浏览器 admin 账号做的旁证都不算数**。 + **⚠️ 本轮无法在 114 验证的项**:`delegate` 拒绝(F049 期签不出该位,坑 15)· `logs` **内容**(取决于 F054 T057 与 F055 T039 是否已上 114;**不是**"runtime-manager 没实现"——那截已落码)——单测已覆盖,不要因为 114 上试不出来就改代码。 + **旅程引用**(不由本 Feature 验收,只在剧本里连带跑过):AC-48「上线 ≠ 可用,owner 设可见范围后同事才能从广场打开」**经 F054 / F056 验证**;AC-50「经 CLI 发布的应用在广场 / 权限 / 审计中与平台内造应用一致、每次发布必审、扫描同规则集、元信息取自 `bisheng-app.yaml`」**经 F055 验证**。 + **覆盖 AC**: AC-01, AC-04, AC-05, AC-06, AC-07, AC-31, AC-31a, AC-31b, AC-31c, AC-32, AC-33, AC-35, AC-41, AC-42, AC-52 + **依赖**: T020, T024, T026, T028, T029, T030, T032 + +- [x] **T034**: `[MVP-核心]` 上游回写四项 + 顺延项落点核对 + **文件**: `features/v3.0.0/053-dev-cli-skills/spec.md`(AC-05 措辞), `features/v3.0.0/049-openapi-auth-baseline/tasks.md`(追加回写条目), `features/v3.0.0/055-app-publish-pipeline/tasks.md`(追加回写条目) + **逻辑**: design §8 已登记的四项,逐条落到上游文档(**只追加条目、不改他人代码**): + 1. **F049 给 `WhoamiResponse` 加 `resource_owner: {user_id, user_name}`**——`WhoamiResponse`(`open_api/domain/schemas/credential.py:108-116`)只有 `subject_kind` / `service_account` / `tenant_id` / `scopes` / `key_mask` / `expires_at`,而 `OpenApiPrincipal.resource_owner_user_id` 在服务端**已经存在**(`open_api/domain/context.py:37-39`)。改动量 = 一个可选字段 + 一次 user 名查。**为什么值得改**:资源归属人就是此后 `deploy` 出的应用 owner,管理员选错归属人是 F049 自己在签发表单上加风险提示的那个错误;`login` 那一刻看不到它,这个错要等到 deploy 之后甚至换人接手时才暴露。补上之前 T020 走降级(AC-06 标 `[受阻于 F049 回写]`)。 + 2. **F055 给 `GET /api/v2/apps/{id}/logs` 的返回补 `app_state` / `pending_reason`**——目前只有 `{lines[]}`,AC-43「明确提示应用态」只能降级(T026)。 + 3. **F055 给 `GET /api/v2/apps/deployments/{id}` 的轮询载荷补 `entry_url`**(`app_state=已上线` 时非空)——它目前**只在 `POST /deploy` 的返回里**(`055-.../design.md:492`),而首发时应用还是草稿、大概率为 `null` → AC-31c「通过并上线(输出入口地址)」/ AC-33 / 决议-12 只能降级为"指路应用详情页"(T024)。 + 4. **改本 spec 的 AC-05 措辞**——「login 校验入口在开放能力层未部署时不可达」与 F049「服务账号模块恒在 + `whoami` 恒在注册」的既成实现冲突(`api/router.py:123-126`;`core/config/open_platform.py:17-18` 注释逐字:"the service-account module is always on")。建议改成「CLI 的 `login` 在该环境不可用并给出可读原因」,由 T008 的前置探测 + exit 8 兑现。**改 spec 而不是改 F049**——把 whoami 也置于开关下,等于让 F049 的显式设计破例并波及已落码的鉴权面。 + **另有一项由 F055 侧发起、本 Feature 只需接受**:F055 T050 回写项 3 已登记「AC-32 的上限经 `deploy-limits` 取、取不到由 16201 兜底」——T017 / T022 已按该口径写,**不要另立一套**。 + **顺延项落点核对**:确认 design §1 非目标表的落点方向与本文 Wave 3–5 的任务标题一一对应,无遗漏、无新增。 + **实际偏差记录**(2026-08-19 完成并勾选): 四项**全部已交付**,且**代码先于文档条目落地**—— + - ① `WhoamiResponse.resource_owner`:commit **`874bd688b`**(2026-08-18),`open_api/domain/schemas/credential.py` 新增 `WhoamiResourceOwner` + 可选字段;④ 本 spec AC-05 措辞同批改定(现文:「使 `login` 在该环境**不可用并给出可读原因**」)。两条已登记进 `features/v3.0.0/049-openapi-auth-baseline/tasks.md` 文末新增的「跨 Feature 回写受理」表。 + - ②③ `logs` 的 `app_state` / `pending_reason` 与轮询载荷的 `entry_url`:commit **`95c0ed98e`**(2026-08-18)。已登记进 `features/v3.0.0/055-app-publish-pipeline/tasks.md` 文末同名表。**`entry_url` 由 F054 的唯一实现按 slug 推导**,F055 不拼、CLI 更不拼。 + - 另一项「由 F055 侧发起、本 Feature 只需接受」的 `deploy-limits` 口径:F055 T050 ③ 已于 2026-08-19 回写进本 Feature 的 **spec AC-32 与 §3 边界**(上限经 `GET /api/v2/apps/deploy-limits` 取,取不到按内置默认值提示并继续上传、由服务端 16201 兜底)——T017 / T022 的实现本就是这个口径,两处现已对齐。 + - **未做的半项**:「顺延项落点核对」(design §1 非目标表 ↔ 本文 Wave 3–5 任务标题一一对应)**没有逐条核对**,因为 Wave 3 已在 2026-08-18 实际开工并落了 T035–T040/T049,非目标表与任务标题的对应关系已经变化,一次纸面核对给不出可信结论。建议并入下一次 `/sdd-review tasks`。 + **覆盖 AC**: AC-05, AC-06, AC-31c, AC-33, AC-43(本任务不新增行为,只解除这五条的降级前提;降级实现本身已由 T008 / T020 / T024 / T026 覆盖) + **依赖**: T020, T024, T026 + +--- + +### Wave 3 · 顺延:`skills sync` 与 DEV-03 两包技能包(release 必做,本轮不做) + +> design §8 把这一波列为顺延波次的**最高优先级**——它是 AC-47「agent 全程不离开本地对话」的前提。分发端点与匿名口径已由 T028 留位(`GET /api/v1/dev-toolkit/skills/{pack}`,同 router)。 + +- [x] **T035**: `skills sync` 命令(拉取 / 幂等覆盖 / 输出每包名称与版本与更新结果 / 列出被覆盖文件) + **文件**: `src/bisheng-cli/bisheng_cli/commands/skills.py`, `src/bisheng-cli/bisheng_cli/cli.py`(注册第四条命令) + **测试载体**: `src/bisheng-cli/tests/test_command_skills.py` + **覆盖 AC**: AC-03, AC-14, AC-19, AC-20, AC-21, AC-51 + +- [x] **T036**: 技能包分发端点 + 包随后端发布件同行 + **文件**: `src/backend/bisheng/dev_toolkit/api/endpoints/distribution.py`(增量加 `skills/{pack}`), `src/backend/bisheng/dev_toolkit/skills/` + **测试载体**: `src/backend/test/dev_toolkit/test_distribution_api.py`(增量) + **覆盖 AC**: AC-05, AC-14, AC-15 + +- [x] **T037**: 「部署纳管」包内容(`bisheng-app.yaml` 写法引用 F055 / 能力声明 / 安全红线 / 托管运行契约 / deploy 工作流与预检排障指引 + 样例 + 连通自检脚本) + **文件**: `src/backend/bisheng/dev_toolkit/skills/deploy-hosting/` + **测试载体**: `src/backend/test/dev_toolkit/test_skill_packs.py`(结构完整性 + 样例无真实密钥 + 自检脚本缺配置时输出可读原因而非堆栈) + **覆盖 AC**: AC-16, AC-18, AC-19 + +- [x] **T038**: 「平台能力接线」包结构 + 两条不走 SDK 的标准库接法(模型 / 应用数据库);**SDK 三件套章节与其自检脚本随 F057 补齐、其验收随 F057** + **文件**: `src/backend/bisheng/dev_toolkit/skills/platform-wiring/` + **测试载体**: `src/backend/test/dev_toolkit/test_skill_packs.py`(增量) + **覆盖 AC**: AC-17, AC-18 + **2026-09-16 完成证据**: commit `6d7497c31`(`skills/platform-wiring/{SKILL.md,example/,selfcheck.py}` + README 两包分工);`test_skill_packs.py` 对两包参数化并加 4 条 AC-17 专项断言(auth 章居首且以警示开头 / 身份头名与 app-proxy `INJECTED_HEADER_NAMES` 零漂移 / 模型章「暂未提供」且无 URL / 样例无登录逻辑),`test_distribution_api.py::test_skill_pack_is_a_tarball_carrying_skill_md` 对两包参数化(分发端点真的能按 slug 取到第二包),backend `test/dev_toolkit` 39 passed。SDK 三件套章节按任务说明留桩指向 F057,模型章等 F051。 + +- [x] **T038a**(F051 落地后追加): 「平台能力接线」包的**模型一节**改写——F051 已交付,模型章不再是「暂未提供」。按 F051 [design §4.2 ①③](../051-model-protocol-gateway/design.md) 与 **D5 限定名规则**写:base URL 从 `GET /api/v2/auth/whoami` 的 `model_base_url` 取(唯一出口,不要在包里拼 `/api/v2/model/v1`);`dev` 与线上注入同名的 `OPENAI_BASE_URL` / `OPENAI_API_KEY` / `BISHENG_MODEL_BASE_URL`,样例用官方 `openai` 客户端、代码零差异;模型名写模型管理页原名,**跨服务商同名时必须写限定名 `服务商名/模型名`**(裸名会被 26514 拒并列出可用限定名,`GET /models` 返回的 `id` 恒可直接调用);承诺面只有对话补全与模型列表,其余 OpenAI 路径返 26501、Anthropic 路径返 26502。另附 **`X-BiSheng-Access-Token` 转发说明**:app-proxy 把它注入到**应用**而不是浏览器,应用调模型面时必须由应用代码显式转发,否则调用记录的 subject 一律是「应用自身」——不报错,只是审计里没有用户维度(F051 spec 决议-5 允许,但 owner 通常并不想要)。 + **文件**: `src/backend/bisheng/dev_toolkit/skills/platform-wiring/SKILL.md`(模型章)+ `example/` + **覆盖 AC**: AC-17, AC-18(F051 AC-31 / AC-33 的文档侧) + **2026-09-16 完成证据**(分支 `wt/f053-skillpack-model`): `SKILL.md` 第 5 章由「暂未提供」整章重写为「平台模型(OpenAI 兼容)」——三个注入名表(`OPENAI_BASE_URL` / `OPENAI_API_KEY` / `BISHENG_MODEL_BASE_URL`,线上与 `bisheng dev` 同名)、官方 `openai` 客户端零配置样例、「地址一个字都不要自己拼」(点名 `BISHENG_PLATFORM_API_BASE` 不是模型端点,唯一出口是 `whoami.model_base_url` 产出的注入值)、三名可能一个都没有时的启动期检查、D5 名称解析(原名基准 / 26514 歧义 / `服务商名/模型名` 限定名 / `GET …/models` 的 `id` 恒可调 + `bisheng_qualified_name`)、清单 `capabilities.models` 声明与「未声明即 26515、零声明连变量都不注入」、本地 vs 线上三行对照表、`X-BiSheng-Access-Token` 转发(线上转发、本地 `bisheng dev` **不能**转发否则 26504)、承诺面两条路径 + 26501 / 26502、含 `bisheng_code` 的错误速查表(13 行)与两条流式专属坑。同批:第 1 章 `X-BiSheng-Access-Token` 行补第二个去处、第 7 章自检清单的模型条补齐、`skills/README.md` 两处「模型一章暂未提供」订正。回归断言见 F057 T035a。`cd src/backend && pytest test/dev_toolkit -q` → **119 passed**;ruff check / format --check 全绿;`scripts/arch-guard.sh` 零输出。 + **偏差**: ① **未改 `example/` 与 `example-sdk/`**——两个样例的清单被既有断言钉死为「不含模型声明」(`test_skill_packs.py::test_example_manifest_is_valid_against_the_platform_schema` 要求 `capabilities.is_empty()`;`test_platform_wiring_sdk.py::test_example_sdk_manifest_is_valid_and_declares_a_knowledge_base` 要求 `not manifest.capabilities.models`),加声明会同时红两条**姊妹切片拥有的**断言。改为在章内以内联代码块教清单写法与官方客户端用法,不新增第三个样例。② 任务原文写「base URL 从 `whoami.model_base_url` 取」,落地为**应用侧读注入的 `OPENAI_BASE_URL`**(`whoami.model_base_url` 是平台产出该值的唯一出口,由 `bisheng dev` / runtime-manager 注入给应用)——应用进程里没有 `whoami` 可调,章内把这层关系写明。③ 新增一条包级断言 `test_no_pack_file_hardcodes_the_model_face_path`(整包不得出现 `/api/v2/model/v1` 字面量),F057 T035a 的口径②本来只覆盖模型章。 + **2026-09-16 同批复审(逐条对着实现 grep)**: 三个注入名、`whoami.model_base_url` 出口、`bisheng_code` / `candidates` 扩展键、26501–26505 / 26511–26517 / 26531 / 26533 的 HTTP 状态、`n != 1` 拒、流式先预取首块、`BISHENG_APP_VERSION` 线上是版本号本地是 `dev`、`OPENAI_API_KEY` 线上 = `BISHENG_APP_TOKEN` 同值 / 零声明不注入 —— 全部与实现一致。改掉四处与实现不符或会误导的表述:① **第 6 章「login 用的密钥不会进入应用进程的环境变量」是错的** —— `devdb.build_dev_env` 只 pop 掉 `BISHENG_API_KEY`,同一把密钥以 `OPENAI_API_KEY` 进了子进程(`tests/test_command_dev.py` 正是这样断言的:`[name for name, value in env.items() if value == FAKE_KEY] == ["OPENAI_API_KEY"]`),与第 5 章自相矛盾,改为「只以 `OPENAI_API_KEY` 一个名字进」;② 第 6 章对照表补模型面三名一行(原表漏了 `dev` 的这项同名注入,T038a 原文点名要教),并把与表格行数对不上的「它做三件事」改成不带数字的说法;③ 「服务商被删 → 26513」不准 —— `model_catalog._explain_miss` 明说删服务商会连模型行一起删、名字直接查无此模型,26513 只在 60s 目录缓存窗口内可见,改为以 26511 为主、26513 为窗口内特例;④ 「`X-End-User` / `X-On-Behalf-Of` 带了就是 26505」不准 —— 只有 `X-End-User` 走 `resolve_range_and_subject` 拿 26505,`X-On-Behalf-Of` 在 `dependencies.admit_request` 就被基座拒(26004;密钥带委托位则 26051,`model:invoke` 属 `LOCAL_DEV_TOOLKIT_SCOPE_CODES`),正文与错误表同步订正。复审后 `test/dev_toolkit` **119 passed**(主检出同选择基线 **118 passed**,差值 = 本批新增的包级断言),ruff / arch-guard 仍全绿。 + +- [x] **T039**: `login` 成功后自动执行一次 `skills sync`(失败不影响登录成功、输出原因并提示可手动重跑) + **文件**: `src/bisheng-cli/bisheng_cli/commands/login.py`(增量) + **测试载体**: `src/bisheng-cli/tests/test_command_login.py`(增量,替换 T019 的 `test_no_auto_skills_sync_this_round`) + **覆盖 AC**: AC-08 + +- [x] **T040**: 技能包触发评测样本集与跑分(≥ 10 条不含产品名的中性部署表述,「部署纳管」包触发率 100%;auth 静默失败点对照样本随 F057) + **文件**: `src/backend/test/dev_toolkit/fixtures/skill_trigger_samples.md`, `src/backend/test/dev_toolkit/test_skill_trigger.py` + **覆盖 AC**: AC-22 + +- [x] **T041**: 跨 Feature 旅程验收(接入信息文本 + key → `login` → 调通一次 MCP 工具;agent 凭技能包 + CLI + MCP 完成声明补全 → deploy → 预检修复 → 审批状态跟踪) + **文件**: `src/backend/bisheng/dev_toolkit/skills/deploy-hosting/SKILL.md`(增量:审批状态本地跟踪一节), `features/v3.0.0/053-dev-cli-skills/tasks.md`(回填执行结果);**可检查半边** → `src/backend/test/dev_toolkit/test_journey_prerequisites.py`(新,2026-09-16,分支 `wt/f056-verification`,由 F056 T027–T033 验收批顺带交付;2026-09-16 本批把两个哨兵转真断言) + **前置**: F052(MCP 面)就绪 + **覆盖 AC**: AC-46, AC-47 + **进度(可检查半边已落,整条旅程仍待 114 + F052)**:真机跑一遍需要 MCP 面与一台机器,这里落的是旅程的**本地前置条件**——agent 实际读的那份材料,以及它被教着去处理的错误码。 + - 已验证(5 例):技能包教对了 `bisheng login <平台地址> --api-key bs-sak-…`(含 `--api-key-stdin`,且说明 key 由管理员单独转交);清单必填 / 可选键齐全**并且**明确列出「不存在的键」(清单拒未知字段,agent 自造 `command:` 只会拿到 16221);`bisheng deploy .` 与 `--dry-run`;「部署成功不等于上线」这句在。 + - **预检错误码双向对账**:技能包排障表里的码全部在 `bisheng/common/errcode/app_publish.py` 里存在(不教平台不会报的码),且 16220–16249 band 里除管理员侧的 16223 / 16224 / 16225 / 16227 外**没有漏教的码**(漏一个 = agent 撞上后无从下手)。这条是防腐的重点:后端改码而技能包不动,今天不会有任何东西报错。 + - ~~**两处缺口(xfail(strict=True) 哨兵)**~~ → **2026-09-16 两处均已闭合**(commit `07c758c35`,哨兵转为普通断言):① **AC-47 最后一步**——技能包补上「审批状态在终端里跟」一节:`bisheng deploy . --wait` 的五个终态退出码(0 通过并上线 / 20 驳回含理由全文 / 21 撤回 / 22 待上线不需重新审批 / 23 等待超时**不代表审批失败**)逐格给下一步动作,随时查一眼用 MCP 工具 `bisheng_app_status`(需 `app:manage` 位),并点明 `bisheng logs` 看的是运行日志不是审批状态。② **AC-46 的输入**——T046 落地,管理员在服务账号详情页有了可复制且不含密钥的接入信息文本。 + - **转真断言时把「存在性」升级成「内容对账」**(哨兵只断言字符串在不在,那挡不住教错):教的四个退出码逐个回 `bisheng_cli/errors.py` 核对数值、教的 MCP 工具名与 scope 回 `mcp/registry.py` 核对、接入信息区五项键齐全且组件源码里不出现 `plaintext` / `bs-sak-` / `Authorization` / `issuedKey`。任一侧改名即红。 + - **仍需 114 真机的四步(本地造不出来,不伪造)**:管理员把 key 单独转交 → 开发者真的 `bisheng login` → 经 F052 真的调通一次 MCP 工具 → 管理员在审批中心真的通过并看到应用上线。前两步的材料与命令已由 T046 / 技能包备齐,第三步依赖 114 上 `open_platform.enabled=true`。 + - **2026-09-16 复审补**:退出码表补齐到**六个**终态(增 24 审批单取消 / 25 审批异常,两者都是「再 deploy 一次是错的」;22 的成因改为「容量不足或上线执行失败」),守卫改为从 `deploy.py::APPROVAL_TERMINALS` 反解全部终态再逐个要求包里有行——加第七个终态而不教当天就红。见「实际偏差记录」42。 + - `test/dev_toolkit/test_journey_prerequisites.py` 现 **13 passed / 0 xfailed**。 + +--- + +### Wave 4 · 顺延:`bisheng dev` 本地运行与身份注入(release 必做;T042–T044 于 2026-09-16 实施,T045 待 F051 / F052) + +> 依赖 F054 app-proxy 的注入头形态与 F057 SDK,两者本轮都不在纵切上。**注入的环境变量清单不得自造**,唯一来源 = [`contracts-runtime-manager.md` §5](../054-app-domain-runtime/contracts-runtime-manager.md)(design §6.2 表末行已原样登记:`BISHENG_APP_DB_URL` · `BISHENG_APP_DB_PATH` · `BISHENG_APP_ID` · `BISHENG_APP_SLUG` · `BISHENG_APP_VERSION` · `BISHENG_APP_VERSION_ID` · `BISHENG_PLATFORM_API_BASE` · `PORT` 与 `BISHENG_APP_PORT`(恒等)· `BISHENG_APP_BASE_PATH`(dev 期为空串)· `BISHENG_APP_HEALTH_PATH`;平台保留 env 名**覆盖**调用方同名值)。 + +- [x] **T042**: `dev` 迷你代理:注入与 F054 app-proxy **结构一致**的身份头 + 每请求短时访问凭据句柄;**剥离客户端伪造的平台身份注入头**(剥离规则与 F054 一致:按下划线 / 连字符 / 大小写归一化等价类匹配);无 `--as` + **文件**: `src/bisheng-cli/bisheng_cli/commands/dev.py`, `src/bisheng-cli/bisheng_cli/devproxy.py` + **测试载体**: `src/bisheng-cli/tests/test_dev_proxy.py` + **覆盖 AC**: AC-03, AC-23, AC-25 + **2026-09-16 完成证据**: commit `c2b56c686`(`devproxy.py` 508 行);`tests/test_dev_proxy.py` 25 条 + `tests/test_platform_contract.py`(用 `ast` 读 `app-proxy/app_proxy/headers.py`,断言十个注入头名、顺序、剥离前缀与 hop-by-hop 集合零漂移);短时凭据句柄本地签发,见偏差记录 29。 + + **F057 T043 回写增补(2026-09-16,来自 `wt/f057-tail`;只提请求,未改本 Feature 代码)**:`devproxy.HandleMinter` 现铸的 `bsdev..` 是**本地 HMAC 自签**(`devproxy.py:117 HANDLE_PREFIX = "bsdev"`),平台侧无处验签——后端零出现、零受理(全仓其余命中只有 SDK 的日志脱敏正则 `bisheng_sdk/errors.py:56` 与测试夹具)。请求把它改成**用 `login` 密钥向平台换取的短时凭据**(`login` 密钥仍不进应用进程),否则本地期 `retrieve` 恒 `26001`。另一条同因的事实:`devdb.PLATFORM_ENV_NAMES`(`devdb.py:65-77`)里没有 `BISHENG_APP_TOKEN`,而托管期 retrieve 要**两把**凭据(应用运行期凭据作 Bearer 定白名单 + 访问者凭据走 `X-BiSheng-Access-Token` 定访问用户),所以本地实际先抛 `AppCredentialMissingError`,换完句柄还差这一把。两者合起来 = F057 design §6.2 阻塞项 ③;SDK 侧**不开兼容分支**(F057 D5),修法归本 Feature 与 F052。 + +- [x] **T043**: `dev` 本地 SQLite + 与托管运行期**同名**的连接环境变量注入;数据跨重启保留、位于项目本地且不进上传包;同名平台接线环境变量注入(清单来源见上) + **文件**: `src/bisheng-cli/bisheng_cli/devdb.py`, `src/bisheng-cli/bisheng_cli/commands/dev.py`(增量) + **测试载体**: `src/bisheng-cli/tests/test_dev_env.py` + **覆盖 AC**: AC-26, AC-27 + **2026-09-16 完成证据**: commit `c2b56c686`(`devdb.py`);`tests/test_dev_env.py` + `test_platform_contract.py` 用 `ast` 读 `runtime-manager/runtime_manager/lifecycle.py` 的 `build_env`,断言 11 个注入变量名与 `RESERVED_ENV_PREFIXES` 与本地一致;库落 `<项目>/.bisheng/dev/app.db`,`.bisheng/` 是打包硬排除(T015)。 + + **F057 T043 回写增补(2026-09-16,来自 `wt/f057-tail`;只提请求,未改本 Feature 代码)**:`dev` 期 storage 今天完全不可跑——`devdb.PLATFORM_ENV_NAMES`(`devdb.py:65-77`)里没有任何 `BISHENG_APP_STORAGE_*`,SDK 于是恒抛 `StorageHandleMissingError`(**故意不兜底落临时目录**,F057 spec §3)。请求增补三条:① 注入 `BISHENG_APP_STORAGE_DIR = <项目根>/.bisheng/attachments/` 的**绝对路径**(选 `.bisheng/` 之下是因为它已是 CLI 的硬排除目录、`!` 取不回,比软排除的 `attachments/` 更强地保证附件不进上传包,F053 AC-32);② 同时写 `<项目根>/.bisheng/.gitignore`(内容 `attachments/` + `*.db`),使「`.bisheng/app.json` 建议提交进 git」与「附件不进 git」并存;③ 建议同名注入可选的 `BISHENG_APP_STORAGE_MAX_FILE_MB`(值取平台 `RTM_STORAGE_MAX_FILE_MB`,不注入 = 本地不限,与线上上限不一致时上线才发现)。**不要注入 `BISHENG_APP_STORAGE_ENDPOINT`**——本地目录句柄与远端句柄同时在场时 SDK 判「句柄不唯一」并拒绝。= F057 design §6.2 契约 ⑦。 + +- [x] **T044**: `dev` 启动前置校验与输出(未 `login` / 凭据无效 / 平台不可达 → 拒绝并提示;缺 manifest 或必填项 → 拒绝并列缺失项;输出注入身份来源与账号、本地访问地址;`dev` 本身不验权限位) + **文件**: `src/bisheng-cli/bisheng_cli/commands/dev.py`(增量), `src/bisheng-cli/bisheng_cli/cli.py`(注册第五条命令) + **测试载体**: `src/bisheng-cli/tests/test_command_dev.py` + **覆盖 AC**: AC-03, AC-24, AC-29, AC-53 + **2026-09-16 完成证据**: commit `c2b56c686`(`commands/dev.py`、`cli.py` 注册第五条 `dev` 与第六条 `platforms`);`tests/test_command_dev.py` 覆盖 凭据缺失 exit 3 零请求 / manifest 缺项 exit 6 列字段 / probe 三态 / whoami 401 exit 4 / 无权限位照常启动 / 输出含身份来源、平台与本地地址且永不含密钥;`test_cli.py::test_no_as_flag_anywhere`。 + +- [x] **T045**: `dev` 期平台能力调用按服务账号**被显式授予**的范围放行(经 F051 / F052 面执行,过滤强度与 fail-closed 与线上一致;迷你代理只注入身份与环境、不代理这些调用)+ 「本地与线上同构」端到端(同一份代码 `dev` 跑通后 `deploy` 上线,托管环境取到的是**当前访问用户**身份) + **文件**: `src/bisheng-cli/bisheng_cli/devdb.py`(增量:`MODEL_FACE_ENV_NAMES` + `build_dev_env` 两个新参数), `src/bisheng-cli/bisheng_cli/commands/dev.py`(增量:从 `whoami.model_base_url` 取址、`_report` 多一行), `src/bisheng-cli/tests/helpers/platform_mock.py`(桩补 `model_base_url`) + **测试载体**: `src/bisheng-cli/tests/test_dev_capability_calls.py`(新,6 条), `src/backend/test/dev_toolkit/test_dev_capability_parity.py`(新,16 条), `tests/test_dev_env.py` / `tests/test_platform_contract.py` / `tests/test_command_dev.py`(增量) + **前置**: F051 / F052 / F054 app-proxy 就绪 + **覆盖 AC**: AC-28, AC-49 + **2026-09-16 完成证据**: commit `792e901c9`。① 模型面三名按 `contracts-runtime-manager.md` §5 同名注入——`OPENAI_BASE_URL` / `BISHENG_MODEL_BASE_URL` 取 `whoami.model_base_url`(F051 AC-30 唯一出口,用例断言 CLI 源码里不出现 `/api/v2/model/v1` 字面量)、`OPENAI_API_KEY` 取当前 login 凭据;平台没有模型面时三名一个都不注入并把 shell 里的同名值清掉(`test_no_model_face_means_the_three_names_are_removed_not_inherited`)。② 「过滤强度与 fail-closed 与线上一致」的论据是**只有一个判者**,两侧各自钉住:CLI 侧断言迷你代理对任何路径都只转发到本机应用(含 `/api/v2/model/v1/**` / `/api/v2/mcp` / `/api/v2/filelib/retrieve` 五条)、不附加 login 凭据、`dev` 从不读 `whoami` 的 `scopes`;平台侧断言模型面每条路由挂同一个 `model:invoke` marker 且只收 mode S、MCP gate 复用 `/api/v2` 的 `admit_open_api_principal` / `open_api_execution_scope` 而非另写一份、每个已安装 MCP 工具缺位即拒、检索门面身份缺失即拒(三种缺失形态)。③ CLI 全量 **346 passed / 0 failed**(基线 334 passed + 1 failed),backend `test/dev_toolkit` **73 passed**。④ 偏差见「实际偏差记录」35–36;**仍需 114**:同一份代码 `dev` 跑通 → `deploy` 上线 → 用**非 owner 的真实账号**访问、确认拿到的是当前访问用户而非服务账号(AC-49 的后半句本地无法复现,spec §范围边界已明写)。 + +--- + +### Wave 5 · 顺延:平台侧接入信息区与 CLI 其余顺延项(release 必做,本轮不做) + +- [x] **T046**: 接入信息区(platform 服务账号详情页「API 密钥」tab 顶部:MCP 地址 / OpenAI 兼容 base URL / CLI 下载链接 / `bisheng login` 指引)+ 「一键复制接入信息」(**不含 key 值,也不含任何可换取 key 值的凭据**;实时生成不做快照;无分享链接 / 二维码 / 站内信推送);未部署开放能力层时该区不出现 + **文件**: `src/backend/bisheng/dev_toolkit/api/endpoints/distribution.py`(填上 F052 留的 `model` 槽位), `src/frontend/platform/src/pages/SystemPage/components/ServiceAccount/AccessInfoPanel.tsx`(新), `.../ServiceAccount/ApiKeysTab.tsx`(增量:挂到 tab 顶部), `src/frontend/platform/src/controllers/API/devToolkit.ts` + `src/types/api/devToolkit.ts`(新), `src/frontend/platform/public/locales/{zh-Hans,en-US,ja}/bs.json`(`openApiManagement.accessInfo.*` 三语) + **测试载体**: `src/frontend/platform/src/test/serviceAccountAccessInfo.test.tsx`(新,9 条), `src/backend/test/dev_toolkit/test_distribution_versions_mcp.py`(增量:model 槽位两条) + **覆盖 AC**: AC-44, AC-45 + **2026-09-16 完成证据**: commit `5386aae6c`。地址不在前端拼:MCP 与模型 base URL 都来自 `GET /api/v1/dev-toolkit/versions`,其中 `model` 槽位本次由 `model_gateway_base_url(request)` 填上——与 `whoami.model_base_url` 同一个产出函数(F051 AC-30),用例断言两个地址同源;平台地址与安装件链接取浏览器当前 origin(载荷给的是 `download_path` 路径,正是为此)。未部署开放能力层时该区不出现**且不发请求**(`/api/v1/env` 的 `open_platform_enabled` 是同一个进程级开关;GET 拿到 404 会被响应拦截器变成整页跳转,所以不能靠 404 兜)。复制文本点击时实时生成、不存快照,逐条断言不含 `bs-sak-` / `bs-pat-` / `bs-app-` / `Authorization` / `Bearer` / `access_token`。platform `vitest` 新文件 **11 passed**、`tsc-strict` 全绿、`eslint` 零输出、`pnpm check-i18n` 无新漂移。**Playwright 仍未落地**:肉眼验证(两种开关状态、复制到剪贴板、下载链接可点)留给 114。 + **2026-09-16 复审补**:平台地址与安装件链接改取载荷新增的 `platform.base_url`(与 MCP / 模型地址同一产出函数),浏览器 origin 退为兜底——初版在网关 / 路径前缀部署下会把一条 404 的 `bisheng login` 命令和一条能用的 MCP 地址一起交给开发者。见「实际偏差记录」41。 + +- [x] **T047**: 多平台凭据的**交互层**(`--platform` 参数注册 · profile 列出与切换默认平台)——数据结构已由 T012 落好,本任务零迁移 + **文件**: `src/bisheng-cli/bisheng_cli/cli.py`, `src/bisheng-cli/bisheng_cli/credentials.py`(增量) + **测试载体**: `src/bisheng-cli/tests/test_credentials.py`(增量) + **覆盖 AC**: AC-12 + **2026-09-16 完成证据**: commit `c2b56c686`(`commands/platforms.py` 新建、`credentials.py` 增 `load_selected` / `list_profiles` / `set_current`、`cli.py` 全局 `--platform`);`tests/test_command_platforms.py` + `test_credentials.py` 增量:列出恒掩码、`use` 未登录地址 exit 3、`--platform` 与 `use` 读同一 store、零迁移。 + +- [x] **T048**: 版本兼容**阻断**(不兼容即拒绝执行并给出从当前平台重新下载的链接;兼容但落后仅提示)——开启方式 = 把 T008 前置探测里的 warning 升为 `CliError(exit=2)` + **文件**: `src/bisheng-cli/bisheng_cli/http.py`(增量) + **测试载体**: `src/bisheng-cli/tests/test_http.py`(增量) + **覆盖 AC**: AC-02 + **2026-09-16 完成证据**: commit `c2b56c686`(`http.py::probe`);`tests/test_http.py` 增量:`min_compatible` 高于本地 → `CliError` exit 2 且 `next_step` 含当前平台下载链接、零后续请求;仅 `cli.version` 更新则 warn 不阻断。 + +- [x] **T049**: 非 Claude Code 引擎经 AGENTS.md 引用同一技能目录的指引(sync 完成后输出引用方式) + **文件**: `src/bisheng-cli/bisheng_cli/commands/skills.py`(增量), `src/backend/bisheng/dev_toolkit/skills/README.md` + **测试载体**: `src/bisheng-cli/tests/test_command_skills.py`(增量) + **覆盖 AC**: AC-20 + **2026-08-18 订正**: 本任务原实现的指引文案是错的——断言「Claude Code 自动发现 SKILL.md,无需配置」(无任何工具扫描 `~/.bisheng/skills/`)并指向不随包分发的 `README.md`。真正的缺口不是"指引怎么写"而是"根本没人接入",由 T054 补齐 + +- [x] **T054**: `skills sync` 落盘后**接入本机 AI 编程工具**并如实报告(探测 `~/.claude` / `~/.codex` → 建软链;一个都没接上时 `warn`;Windows 降级复制 + `.bisheng-managed` 标记;开发者自有技能占位报 conflict 不动它)+ 技能包**按平台分目录**(`profile_slug` = netloc + sha256[:8],旧扁平落点自动清理)+ `login` 路径改为必打落点与接入结果 + **文件**: `src/bisheng-cli/bisheng_cli/agent_skills.py`(新建), `src/bisheng-cli/bisheng_cli/commands/skills.py`(增量), `src/bisheng-cli/bisheng_cli/cli.py`(增量), `src/backend/bisheng/dev_toolkit/skills/README.md`(订正), `src/frontend/platform/public/tutorial/hosted-app.html`(订正步 1 / 步 2 / 排障段) + **测试载体**: `src/bisheng-cli/tests/test_agent_skills.py`(新建 16 条), `src/bisheng-cli/tests/test_command_skills.py`(增量 6 条), `src/bisheng-cli/tests/test_command_login.py`(增量) + **覆盖 AC**: AC-54 / AC-55(修订 AC-08 / AC-14 / AC-20) + +- [x] **T055**: `deploy` 在项目 `AGENTS.md` 写入技能包**位置指针**(幂等、只追加不改写、`~/` 相对路径、平台接收上传后才写、`--dry-run` / `--no-agents-note` 不写) + **文件**: `src/bisheng-cli/bisheng_cli/agent_skills.py`(增量), `src/bisheng-cli/bisheng_cli/commands/deploy.py`(增量), `src/bisheng-cli/bisheng_cli/cli.py`(增量 `--no-agents-note`) + **测试载体**: `src/bisheng-cli/tests/test_agent_skills.py`(增量), `src/bisheng-cli/tests/test_command_deploy_sync.py`(增量 6 条) + **覆盖 AC**: AC-56 + +> **不做、且被提议过不止一次的四项**(design §8 / spec §范围边界与决议-2 / 决议-12,**不要再提回**):CLI 侧复制一份密钥扫描规则集做本地预扫(同一规则集分两处必漂移,扫描只在 F055 服务端做一次)· `init` / 工程骨架生成 · `logout` / `whoami` 子命令(登出 = 删凭据文件或重新 `login` 覆盖)· CLI 生成二维码(去应用详情页 · 发布取)。单文件二进制是 v3.1 备选(决议-1),下载端点届时按 `?platform=&arch=` 分发、`versions` 载荷加 `assets[]`(形状已留位)。 + +--- + +## AC 追溯表 + +> 逐条列举、无范围写法。`[MVP-核心]` 列标 ✅ 的 AC 在本轮闭环;标 ⏭ 的随顺延波次;标 ↗ 的由其它 Feature 验收。 + +| AC | 覆盖任务 | 本轮 | +|---|---|---| +| AC-01 | T027, T028, T029, T030, T033 | ✅ | +| AC-02 | T007, T008, T009, T010, T027, T028, T048 | ✅(2026-09-16 起阻断) | +| AC-03 | T009, T010(无 `--as` / `init` 断言), T032, T035, T042, T044 | ✅(五条命令 2026-09-16 齐备;另有第六条 `platforms` 承载 T047 交互层——与「且仅提供五个」字面偏离,见偏差 32) | +| AC-04 | T003, T004, T005, T006, T007, T008, T009, T010, T019, T020, T021, T032, T033 | ✅ | +| AC-05 | T007, T008, T027, T028, T029;措辞回写 T034 ④ | ✅(login 校验入口一半以前置探测等价兑现) | +| AC-06 | T011, T012, T019, T020;资源归属人字段回写 T034 ① | ✅(降级:`[受阻于 F049 回写]`) | +| AC-07 | T011, T012, T017, T018, T019, T020, T033 | ✅ | +| AC-08 | T019(断言本轮不触发), T039 | ⏭ | +| AC-09 | T019, T020 | ✅(只能单测,坑 15) | +| AC-10 | T003, T004, T007, T008, T019, T020 | ✅ | +| AC-11 | T003, T004, T007, T008, T019, T020 | ✅ | +| AC-12 | T011, T012, T019, T020(数据结构与覆盖语义), T047(交互层) | ✅ | +| AC-13 | T003, T004, T019, T020 | ✅ | +| AC-14 | T035, T036 | ⏭ | +| AC-15 | T036 | ⏭ | +| AC-16 | T037 | ⏭ | +| AC-17 | T038 | ✅ | +| AC-18 | T037, T038 | ✅ | +| AC-19 | T035, T037 | ⏭ | +| AC-20 | T035, T049 | ⏭ | +| AC-21 | T035 | ⏭ | +| AC-22 | T040 | ⏭ | +| AC-23 | T042 | ✅ | +| AC-24 | T044 | ✅ | +| AC-25 | T042 | ✅ | +| AC-26 | T043 | ✅ | +| AC-27 | T043 | ✅ | +| AC-28 | T045 | ✅ | +| AC-29 | T044 | ✅ | +| AC-30 | T003, T004, T013, T014, T021, T022 | ✅ | +| AC-31 | T007, T008, T017, T018, T021, T022, T033 | ✅ | +| AC-31a | T021, T023, T024, T033 | ✅ | +| AC-31b | T023, T024, T033 | ✅ | +| AC-31c | T023, T024, T033;`entry_url` 回写 T034 ③ | ✅(上线分支降级为"指路详情页") | +| AC-32 | T015, T016, T017, T018, T021, T022, T033 | ✅ | +| AC-33 | T013, T014, T021, T022, T023, T024, T033 | ✅(入口地址同上降级) | +| AC-34 | T003, T004, T021, T022 | ✅ | +| AC-35 | T003, T004, T005, T006, T023, T024, T033 | ✅ | +| AC-36 | T009, T010, T021, T022 | ✅ | +| AC-37 | T021, T022 | ✅ | +| AC-38 | T021, T022 | ✅ | +| AC-39 | **墓碑**(spec 2026-08-17 审查裁定 2 已删除,编号保留不复用;原内容并入 AC-50) | — | +| AC-40 | T003, T004, T021, T022, T025, T026 | ✅ | +| AC-41 | T025, T026, T033 | ✅(单测闭环;114 联调受阻于 **F054 T057 + F055 T039**——manager 侧 T030/T031 已落码,见 T032 说明) | +| AC-42 | T025, T026, T033(必须用第二把归属人不同的密钥) | ✅ | +| AC-43 | T025, T026;`app_state` 回写 T034 ② | ✅(降级:明确提示而非空白) | +| AC-44 | T046 | ✅ | +| AC-45 | T046 | ✅ | +| AC-46 | T041 | ✅(本地前置条件已钉;密钥转交与真的 `login` 待 114) | +| AC-47 | T041 | ✅(审批状态本地跟踪已补齐;整条旅程真机跑通待 114) | +| AC-48 | **旅程引用,经 F054 / F056 验证**;T033 剧本连带跑过,本 Feature 不单独验收 | ↗ | +| AC-49 | T045 | ✅(同名注入已成立;「托管环境取到当前访问用户」待 114 用非 owner 账号验) | +| AC-50 | **经 F055 验证**(每次发布必审 / 扫描同规则集 / 元信息与档位取自 `bisheng-app.yaml`);T033 剧本连带跑过 | ↗ | +| AC-51 | T011, T012, T021, T022, T025, T026 | ✅ | +| AC-52 | T007, T008, T011, T012, T033(等 5 秒余量) | ✅ | +| AC-53 | T044 | ✅ | + +**统计**:spec 的 55 条有效 AC(AC-01–AC-53 连号 + AC-31a/b/c 三条,AC-39 为墓碑不计入覆盖)→ **未覆盖 0 条**。其中本轮 `[MVP-核心]` 闭环 **30** 条(✅)、随顺延波次 **23** 条(⏭)、跨 Feature 旅程引用 **2** 条(↗ AC-48 / AC-50)。 + +--- + +## 实际偏差记录 + +> **只留一行指针**,论证在 design.md(决策 / 坑),这里不重复(见 `docs/SDD-Guide.md` §4)。 +> 推翻已 ★ 确认的决策时,先停下与用户重新确认(§3 第四个 ★),再记录。 + +### Wave 1(2026-08-17 实施) + +1. **`26004` / `26031` 的退出码 design 漏定,2026-08-17 裁决为新开 exit 18「缺陷类」**(既不是 exit 1、也不是 exit 19;实施方初版取 1 / 19,已改)。退出码表的唯一用途是让本地 coding agent 不读散文就能决定下一步,所以分格判据是**动作是否不同**:exit 1(CLI 崩了)值得重试一次;exit 19(未登记码)读 `message` 后可改参数重试;而这两个码**重试与改参数都保证无用**,唯一动作是停下报障——第三种动作即第三个码。把已登记的 `26031` 映到 19(其语义是「未登记的码」)还会给出假标签。已同批改 `errors.py`(新增 `EXIT_DEFECT = 18`)与 design §4.2 ② 退出码表(新增 18 行 + 裁决说明),并补两个单测:`test_defect_class_gets_its_own_exit_code_not_1_or_19`、`test_registered_code_wins_over_5xx_degradation`(后者防止 `26031` 携带的 HTTP 500 把「报障」降级成「稍后重试」,而重试永远不会成功)。 +2. **新增 `tests/test_import_smoke.py`(tasks 未列)**——依赖上界的另一半保险:模块级 import 全部 CLI 模块 + 生产入口 `bisheng_cli.main:main`。理由= app-proxy 的 `fastapi>=0.115` 事故里 69 个用例全绿而生产 import 即崩,只有模块级 import 能在 collection 阶段抓到。同批已落:两条依赖都带上界(`httpx>=0.27,<1.0` / `PyYAML>=6.0,<7.0`)、`uv.lock` 提交进 git、wheel 装进干净 venv 后跑 `bisheng --version` / `--help`(default 与 `--resolution highest` 两条腿都验过)。 +3. **`ignore.py` 的子集解析器有一条与 git 的已知语义差**——git 不允许用 `!` 把「已被忽略目录下的文件」取回,本解析器允许。刻意保留:`.bishengignore` 本就是「子集兜不住的复杂规则的显式出口」(D4 第 3 层),且该差异只能从 `.bishengignore` 触发、`.gitignore` 路径恒走 git 本身。已写进 `ignore.py` 模块 docstring。 +4. **硬链接改为按普通文件打包,不跳过**(2026-08-17 裁决,`packaging.py::_special_kind` 已移除 `S_ISREG and st_nlink > 1` 分支)。原实现按 D4「符号链接 / 硬链接 / 设备文件 / FIFO 本地就跳过并列出」逐字做,代价是硬链接文件的**内容不进包**。**裁决理由:那条要求的前提是假的**——服务端拒的是 tar 里的 hardlink 成员,而该成员只在 tarfile 自行挑类型时产生(`TarFile.add` / `gettarinfo` 查 `self.inodes`);`packaging.py` 逐条手工构造 `TarInfo`(默认 `REGTYPE`)再 `addfile` 流式写内容,这条路径**产不出** hardlink 成员。为一个不会发生的成员类型丢掉普通文件的内容,且 `st_nlink > 1` 既不是作者选的、他也看不见——是纯损失。design D4 与坑 14 已同批订正;新增 `test_hardlinked_regular_file_is_packed_with_its_contents` 断言成员为 `isreg()` 且内容完整。 +5. **`mask()` 归 `output.py`、`errors.py` 反向 import 它**——design §4.3 把掩码器划给 `output.py`,而 `errors.render_human()` 必须掩码(T003 `test_no_error_text_contains_key_material`)。`output.py` 不 import `errors.py`,无环。 +6. **命令 handler 采用「注册结构在 `main.py`、实现懒加载」**——`main._HANDLER_PATHS` 存 `"模块:函数"` 字符串,`resolve_handler()` 才 import。Wave 1 里 `commands/{login,deploy,logs}.py` 尚不存在,`--help`、参数解析、异常收口与退出码全部可测可用;Wave 2 只需新增模块文件,不改 `main.py` / `cli.py` 的注册结构(T010 逐字要求)。 +7. **`arch-guard.sh` RULE-7 在本工程零输出**——但踩到一次自指的坑:conftest 的 docstring 里**引用**该规则的正则示例本身命中了规则。已改写成散文描述。后人写「本规则长什么样」的注释时同理。 + +### Wave 2 · CLI 侧(2026-08-17 实施;T019–T026 / T030–T032) + +8. **全局参数改为「根解析器 + 每个子命令各挂一份镜像(`default=argparse.SUPPRESS`)」,`cli.py` 增 `_add_global_flags()`** —— Wave 1 只在根解析器上定义全局参数,而 argparse 只接受**子命令之前**的根级选项,于是 `bisheng deploy --json` 直接 `error: unrecognized arguments: --json` 退 2。**这不是洁癖**:design §7 的 114 验证脚本逐字写的就是 `bisheng deploy --wait --json | tee deploy.ndjson`,README 与 agent 的自然写法也是这个顺序 —— 不修则 T033 必挂、AC-04 的「agent 可用」在最常见的调用形态上不成立。**`SUPPRESS` 是必须的**:argparse 把子命令解析进一个新 namespace 后会把它的**每个键**回拷到外层,镜像若带普通默认值,`bisheng --json deploy` 会被静默重置回 `False`(这正是 bpo-9351 之后的既定行为)。新增回归用例 `test_global_flags_accepted_before_and_after_the_subcommand`(tasks 未列),两种顺序 + 全不带时的默认值都断言。命令注册结构(`main._HANDLER_PATHS` / 子命令集合 / 各子命令自有参数)**一行未动**。 +9. **服务端阶段顺序按 2026-08-17 的拍板改为 `secret_scan → precheck_build → precheck_probe`,但 CLI 一行都没改** —— `STAGE_LABELS` 本来就是无序 dict、只按到达顺序输出。`test_stage_translation_is_a_mapping_not_an_ordered_sequence` 用打乱顺序的序列断言「任意顺序都不报错」,**没有任何顺序断言**。这正是 T023 那条订正想要的结果:顺序既然改过一次就可能再改,写死它等于把服务端的编排决策复制到客户端。 +10. **`26002` 的用例按「三条码互不相同」写,不写「四种成因」** —— 用例名 `test_malformed_header_and_rejected_key_are_distinguishable` 断言的是 `26001` / `26002` / `26027` 三条文案互不相同;`26002` 一码覆盖「不存在 / 已撤销 / 已过期」,服务端不给区分信号,CLI 造不出来。 +11. **轮询的两个终态判定顺序:`approval.status` 的四个终态先于 `stage`/`app_state`** —— `cancelled` / `exception` 在 `status` 仍是 `running` 时就可能出现(应用被删、审批人解析为空),若先看 `stage` 就会一路轮到 `--wait-timeout` 再打印「这不是失败,请继续等」。两个用例直接断言 **`_sleep` 一次都没被调用**(`sleeps == []`),而不是只断言退出码 —— 只断言退出码的用例在「轮到超时后才识别」的实现下同样会绿。 +12. **`--wait-timeout` 对「无 `--wait`」的轮询同样生效** —— design 只在 `--wait` 语境下写了超时。但不带 `--wait` 也要轮询到 `waiting_approval`,若服务端卡在 `precheck_*` 不动,不设上限就是无限循环。同一个旋钮、同一个退出码 23。 +13. **`--follow` 的续拉 `since` 用「上一轮发起请求时的 epoch 秒」,用户传入的 `--since` 只用于第一轮且原样透传** —— 不在本地把 `30m` 换算成绝对时间(那是给同一语义造第二个实现);`--follow` 需要一个滑动窗口,epoch 秒本身就是服务端接受的形态之一。去重按内容 sha256、窗口 500 行。 +14. **`--follow` 的退出路径是 `KeyboardInterrupt`**(Ctrl-C),命令捕获后正常收尾并发 `result`、退 0;单测用一个到第 N 次就抛 `KeyboardInterrupt` 的假 `_sleep` 驱动。tasks 未规定终止方式。 +15. **`deploy` 的目标应用确认在非交互环境下不拦截、只打印** —— 照 design D13「非交互环境下这行输出是唯一护栏」。`cli.confirm()` 的「非交互 + 缺参数 = 明确拒绝」语义仍在(T009 覆盖),只是 `deploy` 的误投护栏不走那条路。 +16. **`--dry-run` 在取 `deploy-limits` 之前就结束,因此零请求** —— design §4.1 B 把 dry-run 的终点定在步 3(打包)之后、步 4(limits)之前。体量报告里的上限值因此用内置默认值展示,并在文案里说明未上传。 +17. **`scripts/pack_cli_wheel.sh` 的冒烟用 `uv pip install --python ` 而不是 `/bin/pip`** —— `uv venv` 不预装 pip(需 `--seed`),T030 逐字写的 `<该 venv>/bin/pip install` 在本仓的 uv 下直接 `No module named pip`。解析路径与用户的 `pip install ` 等价:venv 是空的,落哪组依赖版本由 wheel metadata 的上界决定。 +18. **⚠️ `src/backend/bisheng/dev_toolkit/artifacts/` 的占位 `manifest.json` 本轮没有提交** —— 本 agent 的作业边界明确禁止改 `src/backend/` 下任何文件(同批有另一路 agent 在写 backend)。脚本会在首次运行时创建该目录与两个产物;**T028 的实现方需要确认它存在**,否则 `versions` 端点会走「产物缺失」分支(T027 已把该分支列为常态、断言 200 + `cli` 段为 `null`,所以不会 500)。T030 因此标为完成但**未端到端跑过第 4/5 步**(拷贝与 `git check-ignore` 校验)。 +19. **T031 的 CI workflow 里 `uv sync` 带 `--extra dev`** —— 测试与 ruff 是 `[project.optional-dependencies] dev` 里的,不带 extra 的 `uv sync --frozen` 装不上它们。 + + +### Wave 2 · 平台侧(2026-08-18 实施;T027–T029) + +20. **新增 `dev_toolkit/domain/services/artifact_service.py`,design §4.3 只列了 `api/{router.py,endpoints/distribution.py}` 两个文件** —— 端点里直接做文件 I/O + JSON 解析违反 C1 分层(endpoint 不承载业务逻辑)。产物读取(manifest 解析、wheel 存在性校验、缺失降级)全部收在服务里,端点只做形状映射与 HTTP 语义。**顺带得到测试杠杆**:T027 的两种产物状态(已打包 / 从未打包)靠 monkeypatch 服务的 `ARTIFACTS_DIR` 切换,不必往仓库里塞夹具 wheel。 +21. **`versions` 载荷比 design §4.2 ⑤ 多一个 `data.notice` 字段(恒存在,正常时为 `null`)** —— tasks 要求产物缺失时「`cli` 段给 `null` + 一句可读说明」,而 design 的 jsonc 里没有放这句话的位置。**没有塞进 `status_message`**:那个位置是机读的 `SUCCESS` 契约,改它会让「一切正常」和「产物没发布」在同一个字段上分叉。纯新增字段、对既有解析零影响。 +22. **`cli/download` 在产物缺失时返回真 HTTP 404(信封在 body 里,`status_code: 404`),不是 `/api/v1` 惯例的 200 + 信封** —— 这条路由的客户端是 `pip install `:给 pip 一个 200 + JSON,它会试着安装那个信封。同理**没让 `FileResponse` 对着不存在的路径抛**——那会是 500 + traceback,把「产物没提交」这个发版问题伪装成「平台坏了」。零新错误码(CON-8),复用 `resp_500(code=404, ...)`。 +23. **`platform.version` 在产物缺失时为 `null`,不回落 `bisheng.__version__`** —— 回落等于把硬编码的 `'2.6.0-fix'` 重新扶成「平台版本真相」(坑 21)。`test_platform_version_not_taken_from_env_endpoint` 的后半段专门断言这一点(把 `ARTIFACTS_DIR` 指向不存在的目录后断言该字段为 `null`),比只断言「≠ `__version__`」更能挡住"顺手加个兜底"。 +24. **T027 的 `test_platform_version_not_taken_from_env_endpoint` 没有实际请求 `/api/v1/env` 做对照** —— 该端点要 DB + Redis(`get_from_db` / `get_system_login_method`),在无中间件的单测环境里 500。改为对照 `bisheng.__version__` 常量本身(那正是 `/env` 的 `version` 的唯一来源)+ 上一条的 `null` 断言。 +25. **`test_multi_tenant_enabled_no_jwt_does_not_raise_no_tenant_context` 带一组「对照实验」** —— 只断言「豁免时不炸」会在**任何**实现下变绿(端点今天根本不查库)。所以用例在探针里同时记录 `is_tenant_filter_bypassed()` 与 `_resolve_tenant_id()` 是否抛 `NoTenantContextError`,先证明**这条请求上确实没有租户上下文**(危险是真的),再把 `/api/v1/dev-toolkit` 从 `TENANT_CHECK_EXEMPT_PATHS` 里摘掉重跑同一请求、断言 bypass 变为 `False`——证明 T029 那一行是承重的,不是在复述中间件的默认行为。 +26. **T027 的 app 构建走「reload `bisheng.api.router` + monkeypatch `bisheng.main.router`」** —— 开关是**导入期**求值的(`if settings.open_platform.enabled:` 在 `bisheng/api/router.py` 模块体里),而 `bisheng.main` 在首次导入时就把 router 对象绑死了。要在同一个进程里同时验「开=有路由」与「关=404」,只能按目标开关重新导入聚合 router 再交给 `create_app`。**没有退化成自建一个 mini app**:那样测的是测试自己的接线,middleware 栈不在场,第 25 条的租户断言就无从谈起。fixture 在 teardown 里先还原开关再 reload,避免把测试值烤进模块级 router。 +27. **⚠️ 承接第 18 条:`src/backend/bisheng/dev_toolkit/artifacts/` 本轮仍然是空的(目录都不存在)** —— T028/T029 的实施方按作业边界只交付端点、测试与豁免,**没有代跑 `scripts/pack_cli_wheel.sh`**(那是 T030 的第 4/5 步,且会产出需要提交的二进制)。**当前检出与 114 的即时表现**:`GET /api/v1/dev-toolkit/versions` 返 200、`cli` 为 `null`、`notice` 为「CLI 安装件未随本次部署发布,请联系平台管理员」;`GET /api/v1/dev-toolkit/cli/download` 返 404 + 同一句。这是设计好的降级分支(T027 有专门用例),**但 T033 的 114 手验必须先跑一次打包脚本并提交产物**,否则步 1「装 CLI 两条路径」无从验证。 +28. **`ARTIFACT_MISSING_MESSAGE` 带 `# noqa: RUF001`** —— 仓库 ruff 选了 `RUF` 全家,全角逗号 `,` 命中 RUF001(与 ASCII `,` 形近)。这是面向用户的中文文案,换成半角是错别字。`http_middleware.py` 里既有的三条 RUF00x/RUF013 是本次改动之前就存在的,未动。 + +### Wave 3–5 · `dev` / `platforms` / 版本阻断 / 「平台能力接线」(2026-09-16 实施;T038 / T042–T044 / T047 / T048) + +29. **`X-BiSheng-Access-Token` 短时凭据句柄在 `dev` 期由 CLI 本地签发,没有新增后端端点、没有占用 26060–26064** —— 线上句柄是 backend 签的 OBO token(F054 AC-34,900 s),本地既没有签发方、本轮也没有任何消费方(SDK 三件套随 F057)。按 design 「本地派生」口径:`devproxy.HandleMinter` 以 `dev` 会话随机密钥做 HMAC,每请求签一枚 15 分钟 `bsdev.` 前缀的不透明句柄,形状与线上一致(每请求、同头名、短时);`tests/test_dev_proxy.py` 守住一条不变量——**login 密钥不进任何请求头、不进应用进程环境**(`devdb.LOGIN_KEY_ENV` 显式剔除)。F057 落地 SDK 时若要本地句柄可被平台验签,再补端点。技能包因此把该头标为「不要依赖」。 +30. **`dev` 与 app-proxy / runtime-manager 的两份清单用 `ast` 读对方源文件做契约测试,而不是 import** —— CLI 不能 import 另外两个工程(CON-1,依赖预算两条)。`tests/test_platform_contract.py` 解析 `app-proxy/app_proxy/headers.py` 的 `INJECTED_HEADER_NAMES` / `DROPPED_HEADERS` / 前缀常量,和 `runtime-manager/runtime_manager/lifecycle.py` 的 `build_env` 键与 `RESERVED_ENV_PREFIXES`,逐项对比本地常量。任一边改名,CLI 用例当场红——这是「结构一致」唯一机器可验的形态。 +31. **`dev` 多了 `--port` / `--app-port` 两个旋钮,`BISHENG_APP_VERSION` / `_VERSION_ID` 本地取 `dev`** —— 入口端口默认取 manifest `port`(开发者打开的是它),应用进程另挑空闲端口注入为 `PORT` / `BISHENG_APP_PORT`;两端口相同拒绝 exit 2。本地没有版本号可给,两个版本变量取常量 `dev`,只保证「名字在、非空」。`BISHENG_APP_BASE_PATH` 按 tasks 头注为空串,`BISHENG_APP_HEALTH_PATH` 取 `/`(与 runtime-manager `desired_state.py` 缺 label 时的默认值一致;manifest 本轮没有 health 字段可读)。入口端口在拉起应用进程**之前**绑定,端口被占是干净的 exit 2、不会先起一个子进程再杀掉。 +32. **`cli.DEFERRED_COMMANDS` 清空但保留**,`SUBCOMMANDS` 六条(含 `platforms`,tasks 未单列命令名)—— 「announced ≠ registered」的守卫用例保形,下一轮再顺延什么直接填回去。`platforms` 是 T047 交互层的落点:`list` 只打印 login 时平台回的 `key_mask`,`use` 对未登录地址 exit 3 并指回 `bisheng login`,不会凭空造 profile。 +33. **`skills sync` 改为两包,老平台缺 `platform-wiring` 时跳过并 warn、不整体失败** —— 新 CLI 对着还没发布第二包的平台(`GET /skills/platform-wiring` 404)应当仍能把「部署纳管」同步下来;整体失败会把「平台老」伪装成「同步坏了」。跳过**只认 404**(`CliError.details.http_status`):第二包 5xx 是平台在失败,照常 exit 8,不会被当成「平台老」。`deploy` 写进项目 `AGENTS.md` 的指针随 `DEFAULT_PACKS` 变成两行。 +34. **技能包契约测试把 `Dept-*` 三个头在 SKILL.md 表里逐行写全,样例文案避开 "login" 字样** —— 前者是让「教的头名 == app-proxy 注入的头名」能按反引号全名机器比对(缩写形态 `-Dept-Name` 匹配不上,写全也符合「标识写全、可 grep」的仓库规则);后者是 `test_example_reads_identity_from_headers_and_has_no_login` 对样例做 `login/password/jwt/set-cookie` 子串扫描,样例正文一句「你 login 的服务账号」误中,改成「你在命令行里登记的那个服务账号」。 + +### Wave 5 · 接入信息区 / `dev` 期能力调用 / 跨 Feature 旅程(2026-09-16 实施;T041 / T045 / T046) + +35. **⚠️ login 密钥以 `OPENAI_API_KEY` 进入应用进程,与 AC-23 的字面表述「login 密钥本身不进入应用进程」冲突——按 AC-27 与 F051 design D2 执行,在此登记** —— AC-23 那句话写在**每请求短时凭据句柄**的语境里(「由迷你代理以当前 login 密钥换取或本地签发」),管的是访问者凭据那条线;AC-27 则明写进程级注入项包含「**模型协议面 base URL 与凭据**」,`contracts-runtime-manager.md` §5 与 F051 design D2 把凭据的名字定死为 `OPENAI_API_KEY`(托管侧同名注入应用自己的 `BISHENG_APP_TOKEN` 值,官方 `openai` 客户端零配置直读的就是这个名字)。**不注入就没有同构可言**:同一行 `OpenAI()` 本地必须另写一套取值逻辑,正是 INV-32 要消灭的分叉。落地时把边界收紧成可断言的三条——密钥只出现在这**一个**名字下(`test_dev_env.py` / `test_command_dev.py` 各有一条 `[name for name, value in env.items() if value == FAKE_KEY] == ["OPENAI_API_KEY"]`)、`BISHENG_API_KEY` 仍被显式剔除、每请求访问者凭据仍然只走 header。**建议改 spec AC-23 的措辞**(把「login 密钥」限定为「访问者凭据」),而不是改实现。 +36. **平台没有模型协议面时,三个名字是「清掉」而不是「不设」** —— design 只说「取 `whoami.model_base_url`」,没说取不到怎么办。留着开发者 shell 里自己的 `OPENAI_API_KEY` / `OPENAI_BASE_URL`,应用会在本地对着 api.openai.com 跑通、上线即挂,而且现场看起来像「平台的模型面坏了」。契约文档 §5 末句「平台保留 env 名**覆盖**调用方同名值(含上述三个)」支持这个读法:既然是平台保留名,平台没有值时就该是空的。`dev` 启动输出同批加一行明说这件事,免得开发者靠客户端库的 404 去猜。 +37. **`MODEL_FACE_ENV_NAMES` 单列一个常量,不并进 `PLATFORM_ENV_NAMES`** —— 后者是 `runtime_manager.lifecycle.build_env` 那个 `env.update({...})` 的镜像,契约测试逐字比对键列表;三个模型名在托管侧由 **F055 T056** 从应用运行期凭据填,不经 `build_env`,并进去会让那条契约测试当场红、且掩盖「两个填充方」这个事实。改为让契约测试去读 `contracts-runtime-manager.md` §5 正文(断言三个名字带反引号出现,且「`dev` 期由 F053 同名注入」那句还在),并加一条「两个元组不相交」——这样 CLI 侧依然没有第二份定义。 +38. **T045 的「过滤强度与 fail-closed 与线上一致」用「只有一个判者」的结构性断言兑现,不做 dev 期的端到端权限用例** —— 本地起 `dev` 打真平台的权限矩阵,等于把 `test/open_api/test_mcp_scope_matrix.py` 与模型面那一批重写一遍,而且要一套中间件才跑得起来。真正会腐化的是**出现分支**:某天有人给面上加一个「本地调用放宽」的口子。所以两侧各钉结构:CLI 侧断言代理不代理能力调用、不附加凭据、不读 scopes;平台侧断言模型面路由的 marker 与 mode、MCP gate 复用 `/api/v2` 的准入函数、每个已安装工具缺位即拒、检索门面身份缺失即拒。行为层覆盖仍在各自 Feature 的测试里,本文件不重复。 +39. **`GET /api/v1/dev-toolkit/versions` 的 `model` 槽位填的是 `{base_url, protocol, auth}`,`protocol` 是新字段** —— F052 留槽时只写了「holds F051's slot open」没定形状。跟 `mcp` 段对齐(那边是 `{url, transport, auth}`),但地址字段名用 `base_url` 而不是 `url`:这一段是 OpenAI 客户端的 `base_url` 参数,叫别的名字会让接入的人多猜一次。`protocol: "openai"` 是必要的——同一个地址对 Anthropic 路径答 26502,消费方需要知道该配哪种客户端。 +40. **接入信息区读 `/api/v1/dev-toolkit/versions` 前先看 `appConfig.openPlatformEnabled`,不靠 404 判断** —— 两者是同一个进程级开关(`settings.open_platform.enabled`),不会漂移;而 platform 的响应拦截器对 **GET 拿到 404 会把整页跳到 `/404`**(`controllers/request.ts`),靠请求失败来决定「该区不出现」会把管理员从密钥管理页踢走。请求本身仍带 `catch`:平台答不上来时该区静默不出现,它是加速器不是闸门。 +41. **接入信息区的「平台地址」与安装件链接改取 `platform.base_url`,浏览器 origin 退为兜底(切片复审补)** —— 初版里五行地址有两个来源:MCP 与模型地址由后端 `resolve_public_base_url` 产出,平台地址与下载链接由浏览器 `window.location.origin` 拼。两者在普通部署下相等,但**网关 / 路径前缀部署**(`open_api.public_base_url = https://portal.example.com/bisheng`)下 origin 少了前缀——管理员会把一条 404 的 `bisheng login https://portal.example.com` 和一条能用的 MCP 地址一起复制给开发者,而这正是 AC-46 的入口。处置:`versions` 载荷的 `platform` 段增加 `base_url`(同一个 `resolve_public_base_url(request)`,与 `mcp` / `model` 同源),前端 `versions.platform.base_url || window.location.origin`。没有翻「平台地址以浏览器为准」那条旧决议:未配置 `public_base_url` 时该函数解析的就是**这次浏览器请求**的 `X-Forwarded-Host` / `Host`,普通部署下取值与 origin 完全一致;只有运营方显式声明过对外地址时才以声明为准。载荷是纯增字段(design §4.2 的 JSON 示例本就落后于 `mcp` / `model` 两次增补,未回改示例)。用例:`test_distribution_versions_mcp.py` 两条(前缀部署下三个地址同前缀 / 未配置时跟随浏览器请求)+ `serviceAccountAccessInfo.test.tsx` 两条(有 base 用 base、无 base 回退 origin)。 +42. **「部署纳管」包的 `--wait` 退出码表补齐到六个终态(切片复审补)** —— 初版只教了 0 / 20 / 21 / 22 / 23,漏掉 `EXIT_CANCELLED = 24`(应用被删、审批单取消)与 `EXIT_APPROVAL_EXCEPTION = 25`(平台解析不出审批人、单子置异常)。这两个恰恰是「**再 deploy 一次是错的**」的两种:24 要先清 `.bisheng/app.json` 的过期标识再建新应用,25 只能找管理员处理异常单。包里写着「据退出码决定下一步、不用解析文案」,漏教等于 agent 撞上一个没有语义的非零码,默认动作就是重试。同批把 22 的成因从「平台容量不足」改成「容量不足或上线执行失败」(`deploy.py::PENDING_REASONS = {"capacity", "deploy_failed"}` 两种)。守卫也换了形状:`test_journey_prerequisites.py` 新增一条,从 `deploy.py` 的 `APPROVAL_TERMINALS` 字面量 + `pending_online` / `timeout` / `EXIT_OK` 反解出「`--wait` 可能返回的全部终态」,再回 `errors.py` 取数值、逐个要求包里有对应行——**加第七个终态而不教,当天就红**,不再依赖复审时有人记得数一遍。 diff --git a/features/v3.0.0/053-dev-cli-skills/tutorial-draft.md b/features/v3.0.0/053-dev-cli-skills/tutorial-draft.md new file mode 100644 index 0000000000..4104ad08fd --- /dev/null +++ b/features/v3.0.0/053-dev-cli-skills/tutorial-draft.md @@ -0,0 +1,179 @@ +# 托管应用部署教程 · Markdown 草稿 v3 + +> 状态(2026-10-27):教程已落地为 `src/frontend/platform/public/tutorial/hosted-app.html`,**以 HTML 为准**。 +> 本文件的正文与 HTML 文案保持一致,用于评审文字内容;改文案时两边一起改。 +> 品牌:页面跟随平台的品牌配置(OEM)。顶栏 logo、favicon 和正文里的品牌名读取 `/api/v1/brand/runtime-config`,下文用默认品牌名 BISHENG 书写。命令名 `bisheng`、`bisheng-app.yaml` 是命令行本身的名字,不随品牌变化。 +> 顶部「写在前面」记录当初的取舍,不进教程;分隔线以下是教程正文。 + +--- + +## 写在前面:为什么以「复制提示词给 AI」为主路径 + +参考飞书 CLI 的落地页:页面不让用户自己执行 `curl` 和 `pip`,而是给一段提示词,用户复制给 AI 编程工具,由 AI 执行全部命令。 + +我们的安装、登录、部署三步都能交给 AI 执行,所以开发者的操作收敛成「对 AI 说三句话,再在界面上操作两步」: + +| 动作 | 谁做 | 怎么做 | +|---|---|---| +| 安装并登录 | 你 → AI | 复制安装提示词给 AI,填入密钥。AI 安装命令行并登录,登录时命令行把技能包放进 AI 编程工具 | +| 做出应用 | 你 → AI | 描述你要的应用。AI 编写代码,在本地运行给你看 | +| 部署 | 你 → AI | 让 AI 部署。平台构建应用并生成发布审批单 | +| 审批 | 管理员 | 在审批中心点「通过」,应用自动上线 | +| 授权 | 你 | 在「构建 → 应用」里把「查看者」授权给部门 | +| 使用 | 同事 | 在应用中心打开应用 | + +为此后端加了 `install-guide` 端点,和 `cli/download`、`skills/{pack}` 使用同一套匿名分发机制。它把安装和登录步骤写成一份 AI 能读的指引,提示词只需指向它。手动安装保留为备选。 + +当时拍板的 5 点: + +| # | 决策点 | 结论 | +|---|---|---| +| A | 主路径 | 「复制提示词给 AI」为主,手动安装为备选 | +| B | install-guide 端点 | 加。提示词保持简短,各操作系统的差异由指引处理 | +| C | 密钥进提示词 | 用户把 `bs-sak-…` 粘贴到本机的 AI 会话里;教程提醒不要发到共享位置 | +| D | AI 编程工具 | 列出 Claude Code、Codex、Cursor、Trae、Copilot、Windsurf;如实说明只有 Claude Code 和 Codex 会自动接入技能包,其他工具读取部署时写入的 `AGENTS.md` | +| E | 本地预览 | 保留。先在本地看效果,再部署 | + +--- + +# 说三句话,把想法变成全公司能用的应用 + +AI 负责写代码和部署,平台负责构建和运行。你只需对 AI 说三句话,再在界面上操作两步。 + +## 开始前,准备两样东西 + +**一个 AI 编程工具**:Claude Code、Codex、Cursor、Trae、Copilot、Windsurf 都可以。 + +登录 bisheng 命令行时,它会把平台的技能包放进 Claude Code 和 Codex 的技能目录。技能包是一组写给 AI 看的开发和部署规则。 + +使用 Cursor、Trae 等其他工具时,第一次部署会在项目里写入 `AGENTS.md`,告诉 AI 技能包在哪里。 + +**一个平台密钥**:向平台管理员申请一个密钥,格式是 `bs-sak-` 开头的一串字符。第 1 句话里要用到它。 + +密钥只粘贴到你本机的 AI 编程工具里。不要发到群聊、文档或代码仓库。 + +## 对 AI 说三句话 + +按顺序把下面三条消息发给 AI。每发一条,等 AI 做完再发下一条。 + +### 1. 安装并登录 + +你发给 AI: + +```text +帮我安装并登录 bisheng 命令行。安装与登录指引见: +http://你的平台地址/api/v1/dev-toolkit/install-guide.md +登录地址用 http://你的平台地址,我的密钥是 bs-sak-把这里换成你的密钥 +装好后确认 bisheng --help 能运行,并告诉我接入了哪些 AI 编程工具。 +``` + +平台地址已按本页地址填好。你只需把密钥部分换成自己的密钥。 + +AI 会做:AI 从平台下载并安装 bisheng 命令行,再用你的密钥登录。登录时,命令行会把技能包放进你的 AI 编程工具。最后 AI 会告诉你接入了哪些工具。 + +> **安装完成后,重启 AI 编程工具,新开一个会话。**已经打开的会话读不到新安装的技能包。 + +**不用 AI,自己在终端安装**: + +```bash +# 下载安装包。-OJ 让 curl 使用平台返回的文件名,pip 只认这个文件名 +curl -OJ http://你的平台地址/api/v1/dev-toolkit/cli/download +# 用 pipx 安装到用户目录。新开的终端也能运行 bisheng +python3 -m pip install --user pipx +python3 -m pipx ensurepath +python3 -m pipx install ./bisheng_cli-*.whl --force +# 用管理员给的密钥登录。凭据保存在本机,以后不用再输入 +bisheng login http://你的平台地址 --api-key bs-sak-… +``` + +以上是 macOS 和 Linux 的命令。Windows 用户在 PowerShell 里执行,把 `curl` 换成 `curl.exe`,把 `python3` 换成 `py`。 + +安装包从你们的 BISHENG 平台下载,不需要访问公网。安装后新开一个终端,运行 `bisheng --help`。能看到 login 和 deploy 两个命令,就说明安装成功。 + +### 2. 描述你的应用 + +你发给 AI(示例,换成你要做的应用): + +```text +帮我做一个部门费用报销小工具:填写人、金额、事由、日期,提交后存下来,我能看到一张汇总表。先在本地运行给我看。 +``` + +```text +帮我做一个满意度问卷:5 道单选题,提交后按选项统计比例,给我一个结果页。先在本地运行给我看。 +``` + +AI 会做:AI 按技能包里的规则编写应用代码和配置文件 `bisheng-app.yaml`,然后在你的电脑上运行应用。你可以先在本地查看效果。 + +需要修改时,继续对 AI 说,比如「按钮改成蓝色」「加一列备注」。在本地修改不占用平台资源。 + +### 3. 部署 + +你发给 AI: + +```text +帮我把这个应用部署到 BISHENG 平台。 +``` + +平台会做:打包上传 → 敏感信息扫描 → 构建镜像 → 启动检查 → 生成发布审批单。 + +> **部署成功不等于上线。**管理员审批通过后,应用才会上线。 + +## 在界面上操作两步 + +下面两步在平台界面上完成,不需要再给 AI 发消息。 + +### 4. 等管理员审批(管理员操作,你等结果) + +管理员在审批中心点「通过」后,你的应用会自动上线。审批由人工处理,可能需要一天以上。 + +部署后,AI 会等待审批结果,最多等 30 分钟。超过 30 分钟还没有结果,不代表审批失败。之后让 AI 再查一次部署状态即可。 + +管理员的操作路径:审批中心 → 我的审批 → 通过。 + +| 情况 | 做法 | +|---|---| +| 怎么确认已上线 | 打开「构建 → 应用」,应用卡片显示「已上线」 | +| 被驳回了 | AI 会告诉你驳回理由。让 AI 按理由修改,然后重新部署 | +| 你自己就是管理员 | 你可以审批自己提交的应用。平台会在审计日志里记录这次操作 | + +### 5. 授权给部门(你操作) + +**刚上线的应用只有你自己能打开。**要让同事使用,你需要给他们授权。 + +操作路径:构建 → 应用 → 卡片上的齿轮 → 管理权限 → 添加权限。 + +选择「查看者」,再选部门或用户组。要给全公司使用,选择顶层部门并勾选「包含子部门」。 + +## 之后,同事在应用中心打开它 + +被授权的同事打开**应用中心**,在「探索更多应用」里找到你的应用,点「进入应用」在新标签页打开。 + +同事用过一次后,应用会出现在他们最近使用过的应用里。 + +**以后要修改应用,**让 AI 修改后重新部署。应用里已有的数据会保留。 + +## 遇到问题 + +遇到报错时,先把报错原文发给 AI。大多数问题 AI 能自己解决。 + +**本地运行正常,部署到平台后起不来** + +这类问题通常是应用不符合平台的托管要求,例如把端口写死在代码里,没有读取平台分配的端口。 + +技能包里写明了这些要求和常见错误。把报错原文发给 AI,让它对照技能包排查。 + +**实时推送的连接用一段时间后断开** + +平台给每条 WebSocket 连接设了最长时长,默认 8 小时。同事的访问权限被收回时,平台也会断开这个同事的连接。 + +让 AI 给页面加上断线自动重连,然后重新部署。 + +**登录时提示「未接入任何 AI 编程工具」** + +本机没有检测到 Claude Code 或 Codex。 + +如果你用的是这两个工具,让 AI 运行一次 `bisheng skills sync`。如果你用的是 Cursor、Trae 等其他工具,可以忽略这条提示。第一次部署时,命令行会在项目里写入 `AGENTS.md`,AI 读取它就能找到技能包。 + +**平台升级后,更新技能包** + +让 AI 运行一次 `bisheng skills sync`。这个命令会下载新版技能包。输出的最后一行列出接入了哪些 AI 编程工具。 diff --git a/features/v3.0.0/054-app-domain-runtime/closeout-task-review.md b/features/v3.0.0/054-app-domain-runtime/closeout-task-review.md new file mode 100644 index 0000000000..523a12bdc4 --- /dev/null +++ b/features/v3.0.0/054-app-domain-runtime/closeout-task-review.md @@ -0,0 +1,35 @@ +# 收尾增量任务审查 + +2026-10-24:用户已单独确认 F054/F056 修订设计。审查范围为 tasks.md 文末本轮增量,历史任务计数不作为本轮进度。 + +第 1 轮 ISSUE:三语文件任务过大(medium,F056 T050)。已拆分;共享注册副作用补 T053、T054。 + +| # | 检查依据 | 结果 | +|---|---|---| +| 1 | AC 追溯:本轮新增 AC 在显式测试任务逐条列出;旧 AC 保持旧映射 | PASS | +| 2 | AC 测试标注存在 | PASS | +| 3 | 后端测试任务在配对实现之前 | PASS | +| 4 | 依赖 ID 存在,无环 | PASS | +| 5 | 每任务最多两个明确文件,三语已拆分 | PASS | +| 6 | 路径/行为/测试上下文已内联 | PASS | +| 7 | 前后端未混写 | PASS | +| 8 | 同文件分阶段增量,不相互覆盖 | PASS | +| 9 | 保留三源写入者,读侧无重复事实 | PASS | +| 10 | 模型、API、界面、worker 均有任务 | PASS | +| 11 | 快照/游标/导出/报告使用已批准设计合同 | PASS | +| 12 | 无额外商业化、轮换或计费需求 | PASS | +| 13 | AC ID 全写 | PASS | +| 14 | 测试任务不含生产实现 | PASS | +| 15 | 无用 TODO 替代功能交付 | PASS | +| 16 | 新表保留回滚;既有列有 DDL downgrade | PASS | +| 17 | 共享文件副作用登记;凭据 owner 同事务 | PASS | +| 18 | 本轮仅 Platform,不改 Client | PASS | +| 19 | worker headers tenant_id → tenant_execution_scope | PASS | +| 20 | 基础设施独立先于消费方 | PASS | +| 21 | 未降级测试为模拟部署;真实 E2E 单列待验 | PASS | + +第 2 轮:**LGTM**。这是任务计划审查,不代表代码、部署或最终 L2 通过。 + +## 实际落点复核 + +已将新增共享配置、公开管理视图协议、路由命名导出、测试夹具及宿主单元登记回 tasks.md。按依赖执行,补充项与原任务均为增量修改。L1 配对测试结果与具体文件以 closeout-validation.md 为准;真实 E2E 未勾选,不发最终 L2 PASS。 diff --git a/features/v3.0.0/054-app-domain-runtime/closeout-validation.json b/features/v3.0.0/054-app-domain-runtime/closeout-validation.json new file mode 100644 index 0000000000..d70c583e76 --- /dev/null +++ b/features/v3.0.0/054-app-domain-runtime/closeout-validation.json @@ -0,0 +1,308 @@ +{ + "date": "2026-10-24", + "source_base": "818a12968", + "source_commit": "932fa37e1aa68f04d3e60a09b5af09394863d65f", + "evidence_kind": "incremental_focused_checks_not_one_full_suite", + "deployment_accepted": false, + "full_suite_run": false, + "dm8": "model_and_ddl_static_only", + "arm64": "deferred_by_user", + "executions": [ + { + "name": "backend", + "file": "docs/reviews/evidence/vibe-governance-closeout-2026-10-24/backend.txt", + "summary": [ + "30 passed" + ], + "sha256": "c9d6433fa770f4af62fad5c2d55ded53e07b03c06944f544b346504ffa6b1ab9" + }, + { + "name": "backend-final", + "file": "docs/reviews/evidence/vibe-governance-closeout-2026-10-24/backend-final.txt", + "summary": [ + "31 passed" + ], + "sha256": "566c766ca0b3cb541a93b334dd4aa3c8df47eeb8bb50e6baf30e1d63e3ad1d46" + }, + { + "name": "obo-final", + "file": "docs/reviews/evidence/vibe-governance-closeout-2026-10-24/obo-final.txt", + "summary": [ + "48 passed" + ], + "sha256": "ca6567344e61d777377fda5ccecb5e6a2387965375e97409a2b095f64fee48f6" + }, + { + "name": "proxy-cache", + "file": "docs/reviews/evidence/vibe-governance-closeout-2026-10-24/proxy-cache.txt", + "summary": [ + "5 passed" + ], + "sha256": "ad430bcd751b836a91fab4ff65c168e7e4f252c79243c40745308b866b554cb7" + }, + { + "name": "proxy-http-final", + "file": "docs/reviews/evidence/vibe-governance-closeout-2026-10-24/proxy-http-final.txt", + "summary": [ + "4 passed" + ], + "sha256": "6a145783a5cbfd88733842418703ac7d87dd27ec619e724919231c769528467e" + }, + { + "name": "manager-verified", + "file": "docs/reviews/evidence/vibe-governance-closeout-2026-10-24/manager-verified.txt", + "summary": [ + "9 passed" + ], + "sha256": "ed12499504fb433a657b31ced875e7369343bf9696d2544d97d223e4f49c7d34" + }, + { + "name": "host-and-lease", + "file": "docs/reviews/evidence/vibe-governance-closeout-2026-10-24/host-and-lease.txt", + "summary": [ + "5 passed" + ], + "sha256": "29b8039be5a2e30f15cb13d769ae67824db561989cc5927a224126d87f1a79d1" + }, + { + "name": "download-size", + "file": "docs/reviews/evidence/vibe-governance-closeout-2026-10-24/download-size.txt", + "summary": [ + "1 passed" + ], + "sha256": "5a082ae33cb1d26fa1c51cb9dbbd4146628160819b4607ef6606e67c199ef535" + }, + { + "name": "ui-final", + "file": "docs/reviews/evidence/vibe-governance-closeout-2026-10-24/ui-final.txt", + "summary": [ + "9 passed" + ], + "sha256": "61db11f6962220df12e404d191fe414c26c37ee8dfc984b0e294ae4029e9190f" + }, + { + "name": "ui-verified", + "file": "docs/reviews/evidence/vibe-governance-closeout-2026-10-24/ui-verified.txt", + "summary": [ + "4 passed" + ], + "sha256": "9d152cfc9cba8b0428e39bc33d0c34cb54f8d98e3033aac820ba3de4860c963b" + }, + { + "name": "e2e-collection", + "file": "docs/reviews/evidence/vibe-governance-closeout-2026-10-24/e2e-collection.txt", + "summary": [ + "4 tests collected" + ], + "sha256": "d6a28ee4f4d200d3b1ca62f286fb65c3b3c08657f66faefec3ac533bffa02bf4" + }, + { + "name": "typecheck-last", + "file": "docs/reviews/evidence/vibe-governance-closeout-2026-10-24/typecheck-last.txt", + "summary": [ + "static output; inspect receipt" + ], + "sha256": "4460929d5faac8064a9690c9d4714845f5af8cb8e344e3e986148228a19ffc5c" + }, + { + "name": "eslint-last", + "file": "docs/reviews/evidence/vibe-governance-closeout-2026-10-24/eslint-last.txt", + "summary": [ + "static output; inspect receipt" + ], + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "name": "arch-final", + "file": "docs/reviews/evidence/vibe-governance-closeout-2026-10-24/arch-final.txt", + "summary": [ + "static output; inspect receipt" + ], + "sha256": "ba7bb3dee213582b6299f08589a5fed436abdaed15e2556886cde63238794e3b" + }, + { + "name": "static-receipt", + "file": "docs/reviews/evidence/vibe-governance-closeout-2026-10-24/static-receipt.json", + "summary": [ + "static output; inspect receipt" + ], + "sha256": "3bcc84fd4a91ce9a9018bd9623eb2caf13bff18c0503bbabc19788bcd68e58a9" + } + ], + "source_sha256": { + "deploy/runtime-manager/bisheng-host-isolation.service": "ed3e09481a69b888285806d7a78d68b945f4a8d82b6d695d900ada6e0986f26a", + "deploy/runtime-manager/bisheng-host-isolation.timer": "379edd51a9d5bd2fb46ab1fc26dc46430604884a12027f45ba52151567eb60cc", + "deploy/runtime-manager/install-host-isolation.sh": "674ae0065ae8a31eceaff88f8a8672ca3aa2045e01deabd71cd9e63abf9004d2", + "docker/.env.example": "fbaf36877ba9ed132574c72841c615b1fd65c277f113cf989bb09618d695b607", + "docker/docker-compose.yml": "e535febf55b45f5544351bd00d72f008a6d82d689c688cd9e74d6a1395b3dae8", + "src/app-proxy/app_proxy/authz.py": "ad2bc8147cc1bce8bcc6ae5187848f72026559269469ae9335e154004bc8eec5", + "src/app-proxy/app_proxy/clients.py": "74cc03b6020c9a27e31e1edd7495fbc4dc37b6f8fcf7219ad4590a611411f99f", + "src/app-proxy/app_proxy/main.py": "04c67fcc55af794cf5c964fcdd28bb057aa9093d17a9f6dc83f06227ee35062d", + "src/app-proxy/app_proxy/pages.py": "cb2a07c4830fa0bed561d2982a8c46d5a7d26b630b136b54930f73be9bb79c42", + "src/app-proxy/tests/test_authz_and_fallback.py": "a1344844d6835175c5de260cb736524e119a6ef69c631de26d87800a92b6e5d2", + "src/app-proxy/tests/test_obo_configuration_page.py": "55023f89ba330a8ed6d2fab642a88f02428d0959fd884d3e83a22fa227cf9287", + "src/backend/bisheng/admin/application/__init__.py": "b7c8ab99e5b51e027882a87dac6fa0812f69502498af621980244872d487c665", + "src/backend/bisheng/admin/application/tenant_scope.py": "4ac89f41fa7d79cce41e49b6a38d9bbf52ce027ecafc931d9545b767f42187ee", + "src/backend/bisheng/api/router.py": "0383e8543e33b0d63bc76ee2a2cb70af85bac31d71aadf9e98b8bbece30e4380", + "src/backend/bisheng/app_governance/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "src/backend/bisheng/app_governance/api/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "src/backend/bisheng/app_governance/api/router.py": "40a19200792e6a7547ec8f97f347c548c0d73bdc529ea34023703ee27dc0ca58", + "src/backend/bisheng/app_governance/domain/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "src/backend/bisheng/app_governance/domain/models/__init__.py": "45c540050c373553e7e98e6c790753a7210a4c6dc0572285e9f69156d6cd5e36", + "src/backend/bisheng/app_governance/domain/models/runtime_audit.py": "7104faa06f47abe41f7d800b6e4fbc23b62393b408a3b82f834634a9561fb43b", + "src/backend/bisheng/app_governance/domain/repositories/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "src/backend/bisheng/app_governance/domain/repositories/runtime_audit_export_repository.py": "2e7b35732fdc94506fca6b3be2c1540546adadfb116add2671c87de117e1dcc4", + "src/backend/bisheng/app_governance/domain/repositories/runtime_audit_repository.py": "68a8aacfd22efae13b6bd073ad3ce080529e7961e9039b689323a87881def37b", + "src/backend/bisheng/app_governance/domain/schemas/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "src/backend/bisheng/app_governance/domain/schemas/runtime_audit.py": "9e12b95c53f10f42c8a4ba4255f03755cfbfb8ec8aff5efd12bdc523862d51f8", + "src/backend/bisheng/app_governance/domain/services/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "src/backend/bisheng/app_governance/domain/services/runtime_audit_export_service.py": "7f91eab2f453d7731d6a10fd6a986a1b47701039d46ec7b152fe5ded4ac8b803", + "src/backend/bisheng/app_governance/domain/services/runtime_audit_query_service.py": "f4b11d782a6adf889db05e68a66bd815e7e98c666439f228d659ab0abeb8f5eb", + "src/backend/bisheng/app_governance/domain/services/runtime_audit_scope.py": "6c12a018797b9bf68915f9531e131f2e3b766a6437d3f20cf01e50534412779e", + "src/backend/bisheng/app_governance/domain/services/runtime_audit_storage.py": "faae95c2f1e5fb73ce60b27896061676c3e1cb41bfb2aa87da4eae27c02cbaff", + "src/backend/bisheng/app_runtime/api/endpoints/host_isolation.py": "d8c05b88c16d1613796d1060e88c5e2d5cd9c72d81ef119639a73066a473a558", + "src/backend/bisheng/app_runtime/api/router.py": "d215b83dc12c75b57ec5a1e65fea4f13d7d400988362f7d46fc742ef1a3ecbc2", + "src/backend/bisheng/app_runtime/domain/models/__init__.py": "768bbd9a6bf3fc9a9c8ad01f63ed8ab8f36ffab8110803839ef62827c7c65690", + "src/backend/bisheng/app_runtime/domain/models/runtime_host_isolation.py": "0392978447ad8b1ae8f239647819307367222d1f1a93989902d9ef361d5e4e1d", + "src/backend/bisheng/app_runtime/domain/repositories/host_isolation_repository.py": "76dc5973f622c32bcadcdf4acfd237d2297445fc00049b3f713f2105291ae318", + "src/backend/bisheng/app_runtime/domain/repositories/runtime_delivery_repository.py": "e59af036a600545395dc8cc0b90bb762e95278608fe1db465347941eb61d0b26", + "src/backend/bisheng/app_runtime/domain/services/app_access_log_service.py": "a46316944cffcdca78631a1f0cd3efa9164dec9667375678acd79c9e7741c670", + "src/backend/bisheng/app_runtime/domain/services/entry_authz_service.py": "07cfa45ea439b42b98a62e4b0fbc041d4e7dcead9215a841198b41b6665ddb5e", + "src/backend/bisheng/app_runtime/domain/services/host_isolation_service.py": "6156832d2dad24e47eada16695162dd4b7ccd2976d850a506646ab8216a70ed0", + "src/backend/bisheng/app_runtime/domain/services/runtime_operation_service.py": "afeb89c1128203430ad79bca15562fb7166b7f1bf3f0baeb2a08a75c5a619755", + "src/backend/bisheng/app_runtime/domain/services/runtime_projection_service.py": "63807575dca0082c76bf826dc34121d849ff12b262ecec2ae5c8d80e8a2d529b", + "src/backend/bisheng/common/errcode/app_governance.py": "d09ff1d629fda4c9f9ff48bc4de556f5fe2d603a6cdf80118782f14cbd3512e3", + "src/backend/bisheng/core/config/app_runtime.py": "b199be9ae5bd6cafa7b3031cb1a252968f46f2413916495b35ad421ce4819f3b", + "src/backend/bisheng/core/config/settings.py": "1e74c7617c548c03d331c306354caadd6664d9e543824509aa805b14877832f5", + "src/backend/bisheng/core/database/alembic/versions/f056_access_name_snapshot.py": "c285dda11faacbc21a0400d94a61e515529789ba113c7b041fcf597f84ba1bb9", + "src/backend/bisheng/core/database/tenant_filter.py": "4b06c292144e215d102d04fb2fd8dcd9e754e49efab2b3c24f03e245e2766317", + "src/backend/bisheng/database/models/app_access_log.py": "36498cb4581efdc6716d67a3d2c69d1bc3c9034a1941e144802834802e5ff7fb", + "src/backend/bisheng/main.py": "0c041db464ff1b202c7cd9f35e9d02b97d6cb65ad22a18988afd58ad7e6e2731", + "src/backend/bisheng/open_api/application/__init__.py": "ec8c53d600465fa24f8db753476d22c827bb9baa36310fea1e8e7389f4c740a6", + "src/backend/bisheng/open_api/application/runtime_credentials.py": "49d1bf9a263973f848b7692d3e6115a69f281ddb53d83002609ffd102987cc08", + "src/backend/bisheng/open_api/domain/repositories/credential_repository.py": "61ec2b4b79f6c4389e9aa919d62ccd9e95d3c6d3f1eb7cce6fc931bc4c8cf20a", + "src/backend/bisheng/open_api/domain/services/credential_service.py": "094cdb3a8cdf73f8773b77b123f222b6c7915da2b1903703620f1b880412a219", + "src/backend/bisheng/worker/__init__.py": "5ebd79bfbc31a58cf7ed3cd99b6880782621b26cbf8069d57c4ab3f1afb27ba1", + "src/backend/bisheng/worker/runtime_audit/__init__.py": "64fa6c65253fc46bec85ebf0bbe3eb59bb9107f5d8819176f55fc2170baf856e", + "src/backend/bisheng/worker/runtime_audit/tasks.py": "b7cf59e92ddc112978d81084f416731359d08ecacded5ee9180a5270d82c3d9d", + "src/backend/test/app_governance/test_runtime_audit_export.py": "8a0e99a478213119846a2b312d202b0228863bf772726d68b8cd72d6dbbd7bea", + "src/backend/test/app_governance/test_runtime_audit_query.py": "a34cabba9dd20205c0f7bc25bfc219f2b8aef194338da1ea7b821ef44c028654", + "src/backend/test/app_runtime/test_entry_authz_service.py": "f1abc773a7ac8400f56615761a9293632be384025c4b4bdfbf4a7e646570d1e2", + "src/backend/test/app_runtime/test_host_isolation.py": "3c29d392f9a05acf1b9421d6205b7666e46794f5148040681cba09b5a311a8f2", + "src/backend/test/app_runtime/test_preview_entry.py": "343192fc19ba4b357471e29fd5f3a64919553260ec87ad20105341906e602820", + "src/backend/test/app_runtime/test_runtime_delivery_repository.py": "0c211041c903de4d67a396b016f5b9919c4f39fafc1ce98bd9e65927c7c1d06c", + "src/backend/test/e2e/test_e2e_vibe_governance_closeout.py": "4b7145a2bffd38fdf335c65a4d8143b7c86de23ec87e308e7975ec17356511af", + "src/backend/test/open_api/test_runtime_credential_audit.py": "64a138f839ba4743ca2a2c8b369c1cd58cade1717f52fe351fc31d75d5b5e5de", + "src/frontend/packages/locales/src/api_errors/en.json": "2df4f93d72ebc294f31e3954605069829a07e969dc4e2fb7c1986b6cddc29a9a", + "src/frontend/packages/locales/src/api_errors/ja.json": "a49a67e8fcd51125a6b9b8c856ee7158de1454169b058fc771606f6d824df023", + "src/frontend/packages/locales/src/api_errors/zh-Hans.json": "bca642ce763ce959bb5f0147ae61ca7311a2c226ec16a1dc9f212f5b8282ad8c", + "src/frontend/platform/eslint-suppressions.json": "167c1ad24cbd4fc31255560b2a4c82f81aec416f9ce54f24d1a9512a23325f56", + "src/frontend/platform/public/locales/en-US/bs.json": "53af0b8d7a3d346507bafea371666e40a1bb078fcad29db491ba7d071a146d2e", + "src/frontend/platform/public/locales/ja/bs.json": "71239c7e4a8f38448de1c426ead50979ebb2db643e6cf4dae448924efc61c6f4", + "src/frontend/platform/public/locales/zh-Hans/bs.json": "a369ca80bc335bb504ef9d603da0d10aea8eb1a4fc6922fdee235348fc471f8a", + "src/frontend/platform/src/controllers/API/runtimeAudit.ts": "64fbcd101fce1e8cc28adc34fef54e7d7261b6d8f9058b94f14b9a1dbea8de0e", + "src/frontend/platform/src/pages/LogPage/index.test.tsx": "6bfb7e4389ef7005d9871066ac9cc9e68ed0433adbf88c1aa69b84e4c5eef516", + "src/frontend/platform/src/pages/LogPage/index.tsx": "a870c455127315a11cae7eaa2b2bf466a10265f51ba644c4cf65b3f0a16900c7", + "src/frontend/platform/src/pages/LogPage/runtime/RuntimeAuditPanel.tsx": "a11bdf8879e0f1d7a4f15a6e5037ced9e726a3f88012f8cedb37e1e44866ecc6", + "src/frontend/platform/src/pages/LogPage/runtime/useRuntimeAudit.ts": "b320d0294dfa5532a3c859a248ff8cf5b88169828a5a7f7c5938ccd295b5f55a", + "src/frontend/platform/src/routes/index.tsx": "e94b5b580c583e7aa2f835ed85a8d81233dff94387902fd9a066f02ba8de42a5", + "src/frontend/platform/src/test/runtimeAudit.test.tsx": "6bc8723ae25604bc4d48be735959dafd07def9545ce509b0977bad41b5dcba37", + "src/runtime-manager/runtime_manager/api/readonly.py": "89f9e4060b315fc81314656afe6777368200e61deb817b66637b6d8da7aa240d", + "src/runtime-manager/runtime_manager/api/routes.py": "b44cece784f1d5e556756bb70390eec9e2823f9e2e3aaf8797837edd2e0f6c3a", + "src/runtime-manager/runtime_manager/docker_backend.py": "1eca95b55d221b68a7230996d974fca45a5fa4f7f0370037ffcb825960293a0b", + "src/runtime-manager/runtime_manager/host_isolation.py": "d0f160b790bf8147f0e4ed669604f37f8a1c16f43220fb7a2d61450ea00c6acb", + "src/runtime-manager/runtime_manager/platform_client.py": "e00a1588c698ec433ab7ae85768bc28073df3caa1e404263138f311d854be260", + "src/runtime-manager/runtime_manager/probe.py": "38afbfaf76afe2b3c13f22813699a3b177bc82f36dddd406b9196b9c4b4adbed", + "src/runtime-manager/tests/fakes.py": "037e1b28186bcec252fa9b40254a51ac358d1345707974979c98af388fc0ab0b", + "src/runtime-manager/tests/test_host_isolation.py": "58d958766b7a261eef4f51c8493dc8fbf25172ad654b05dd1dd3a44f4d12f2a9" + }, + "acs": [ + { + "ac": "AC-66", + "implementation": "implemented_local", + "local_evidence_files": [ + "src/runtime-manager/tests/test_host_isolation.py", + "src/backend/test/app_runtime/test_host_isolation.py" + ], + "deployment_acceptance": "pending", + "notes": "Local protocol/SQL/component evidence; not a real host/cluster acceptance claim" + }, + { + "ac": "AC-67", + "implementation": "implemented_local", + "local_evidence_files": [ + "src/runtime-manager/tests/test_host_isolation.py", + "src/backend/test/app_runtime/test_host_isolation.py" + ], + "deployment_acceptance": "pending", + "notes": "Local protocol/SQL/component evidence; not a real host/cluster acceptance claim" + }, + { + "ac": "AC-68", + "implementation": "implemented_local", + "local_evidence_files": [ + "src/runtime-manager/tests/test_host_isolation.py", + "src/backend/test/app_runtime/test_host_isolation.py" + ], + "deployment_acceptance": "pending", + "notes": "Local protocol/SQL/component evidence; not a real host/cluster acceptance claim" + }, + { + "ac": "AC-69", + "implementation": "implemented_local", + "local_evidence_files": [ + "src/runtime-manager/tests/test_host_isolation.py", + "src/backend/test/app_runtime/test_host_isolation.py" + ], + "deployment_acceptance": "pending", + "notes": "Local protocol/SQL/component evidence; not a real host/cluster acceptance claim" + }, + { + "ac": "AC-70", + "implementation": "implemented_local", + "local_evidence_files": [ + "src/backend/test/app_runtime/test_entry_authz_service.py", + "src/backend/test/app_runtime/test_preview_entry.py", + "src/app-proxy/tests/test_obo_configuration_page.py" + ], + "deployment_acceptance": "pending", + "notes": "Local protocol/SQL/component evidence; not a real host/cluster acceptance claim" + }, + { + "ac": "AC-71", + "implementation": "implemented_local", + "local_evidence_files": [ + "src/backend/test/app_runtime/test_entry_authz_service.py", + "src/backend/test/app_runtime/test_preview_entry.py", + "src/app-proxy/tests/test_obo_configuration_page.py" + ], + "deployment_acceptance": "pending", + "notes": "Local protocol/SQL/component evidence; not a real host/cluster acceptance claim" + }, + { + "ac": "AC-72", + "implementation": "implemented_local", + "local_evidence_files": [ + "src/backend/test/app_runtime/test_entry_authz_service.py", + "src/backend/test/app_runtime/test_preview_entry.py", + "src/app-proxy/tests/test_obo_configuration_page.py" + ], + "deployment_acceptance": "pending", + "notes": "Local protocol/SQL/component evidence; not a real host/cluster acceptance claim" + }, + { + "ac": "AC-73", + "implementation": "implemented_local", + "local_evidence_files": [ + "src/backend/test/app_runtime/test_entry_authz_service.py", + "src/backend/test/app_runtime/test_preview_entry.py", + "src/app-proxy/tests/test_obo_configuration_page.py" + ], + "deployment_acceptance": "pending", + "notes": "Local protocol/SQL/component evidence; not a real host/cluster acceptance claim" + } + ], + "revised_existing_acs": [], + "html_visual_acceptance": "not_performed_file_protocol_blocked_by_browser_policy", + "receipt_normalization": "terminal trailing blank lines removed; output content unchanged" +} diff --git a/features/v3.0.0/054-app-domain-runtime/closeout-validation.md b/features/v3.0.0/054-app-domain-runtime/closeout-validation.md new file mode 100644 index 0000000000..bfefca778d --- /dev/null +++ b/features/v3.0.0/054-app-domain-runtime/closeout-validation.md @@ -0,0 +1,43 @@ +# 收尾定向验证与 L1:054-app-domain-runtime + +日期:2026-10-24。用户已单独确认修订设计。实现基线 `818a12968`,当前源码/命令证据见 [JSON](./closeout-validation.json);本轮提交号以 Git 记录和统一提测报告为准。 + +## 状态 + +本轮主功能编码已接齐,按用户要求进行定向验证。**真实部署 E2E、最终 L2 和合入仍未完成,不是全部交付 PASS。** 未运行全量测试,跨命令用例有重叠,不将计数直接相加。 + +## 已执行的主要检查 + +| 范围 | 结果 / 证据 | +|---|---| +| 后端三源查询、导出、凭据审计、报告等收尾批 | 30 passed;JSON 记录实际路径,后续修复只重跑对应路径 | +| OBO 正式与预览,存在性/权限顺序、配置快照、无效 key、正常签发 | 48 passed | +| app-proxy 三语页面/正式和预览旧 allow 不复用 | 5 passed;纯渲染/MockTransport,不使用主机网络 | +| app-proxy 规范 HTTP 入口授权缓存回归 | 4 passed;loopback fake upstream,不是真实部署;规范入口每请求一次判定 | +| manager 自有规则/适配器门禁/legacy 状态读面 | 9 passed;真实防火墙命令未执行 | +| 宿主报告、心跳不能续证明及导出租约交接补测 | 5 passed | +| 下载完整字节数与 immutable CSV | 1 passed | +| 审计页原两页签、门禁、记录展示与分页作用域 | 两文件 9 passed;候选分页更新后只重跑对应组件 4 passed | +| 真实环境 E2E 用例 | 4 条 collected,未运行;不能记为通过 | +| 静态检查 | 改动 Python Ruff、逐文件架构守卫无 VIOLATION、单 Alembic head、Compose YAML/独立角色配置、安装脚本 bash 语法、运行记录三语 keys 对齐 | +| 前端类型检查 | 本次文件无新增错误;仍有两条既有阻塞:OrgKbConfig 缺 @hello-pangea/dnd 类型声明,clientVoiceDraft.test 参数签名;其文件未被本轮改动 | + +## Task Review(L1,按 tasks.md 本轮实现/测试任务) + +| # | 检查 | 结果 | 依据 | +|---|---|---|---| +| 1 | 分层 | PASS | Endpoint → Service → Repository;backend 无 Engine/防火墙调用;跨域通过公开应用协议 | +| 2 | 命名 / 路径 | PASS | 测试位于模块目录;LogPage 命名导出,惰性路由已接;不新增含 config 的公共 URL | +| 3 | 序列化 | PASS | 白名单元数据,无完整 ORM/任意 metadata 透传;内部 HMAC 报告独立合同 | +| 4 | 数据库 | PASS(静态/SQLite) | JsonType/通用字段、请求者 tenant 默认 None + NOT NULL;批量删除带显式 tenant;节点报告为已批准的全局事实;DM8 未真实执行 | +| 5 | 前端 | PASS_WITH_NOTES | 复用 UI 与 request/query;三语同提交;已收缩旧 Hook ESLint suppression;两条既有类型错误未扩大范围修复 | +| 6 | 信息泄漏 | PASS | OBO/业务值不进入查询/导出;凭据仅 mask;无公开 MinIO URL;CSV 公式防护;源码无真实密钥 | +| 7 | 设计同步 | PASS | 正式设计补记实现合同、源时区、取消/GC、宿主安装位置与实际门禁;批准时 hash 保留在任务记录 | + +元数据:实际新增入口/配置/夹具已补回 tasks.md;配对测试按上述真实结果登记。**阶段结果 PASS_WITH_NOTES;不替代最终 `/sdd-code-review`。** + +## 提测与暂缓事项 + +统一见 [提测报告](../../../docs/reviews/vibe-governance-closeout-handoff-2026-10-24.md)。优先验证 Docker 109 与 K8s 189 的保存不重启、确认后重建、三源查询/导出与权限;Docker 再验证真实宿主 INPUT/FORWARD、iptables/nftables 与证明过期。 + +按用户边界,超长暂停旧 worker 与 GC 晚到上传、跨节点时钟偏差、历史混合/DST 时区等低概率组合列待验,不继续扩展状态机。本轮没有公开推送、tag 或镜像发行。 diff --git a/features/v3.0.0/054-app-domain-runtime/contracts-runtime-manager.md b/features/v3.0.0/054-app-domain-runtime/contracts-runtime-manager.md new file mode 100644 index 0000000000..db87084b83 --- /dev/null +++ b/features/v3.0.0/054-app-domain-runtime/contracts-runtime-manager.md @@ -0,0 +1,154 @@ +# runtime-manager ↔ backend 接线契约(批 2 交付物,2026-08-17) + +> 由 F054 批 2(`src/runtime-manager/`,commit `8e9afaf48`)产出并**已实现**;批 4(T028–T031:reconciler + 三个只读端点)已并入本文,见 §2 表末三行与 §8。后续批次(backend 入口判定与内部授权端点、领域服务与 API、F055 发布管线、F053 CLI、114 部署)**按本文逐条接线**,与 design §4.2 冲突时以本文为准(本文是实现后的实际形状)。 + +## 1. 地址与鉴权 + +- 监听 `http://127.0.0.1:8091`(backend 配置项 `app_runtime.manager_base_url` 默认值即此) +- HMAC:签名串 `METHOD\nPATH\nraw_body`,请求头 `X-Signature`,小写 hex,恒时比较;**PATH 不含 query string**,且是**百分号解码后**的路径(manager 侧取 ASGI `scope["path"]`,不取 `request.url.path`——后者会把段内的 `?` / `#` 当分隔符截断);含保留字符的路径段(数据面行键 `a?b`、`100%`)由 backend `quote(key, safe="")` 上线、签名仍按解码形;空密钥 fail-closed +- ⚠️ backend 的 `orchestrator_client` 必须对**自己实际发出的字节**签名(不要让 httpx 重新序列化 json,否则签名对不上) +- 三方共用一把密钥:`RTM_HMAC_SECRET` == backend `app_runtime.manager_hmac_secret` == app-proxy 侧 manager secret +- `GET /healthz` 免签(systemd / smoke 用) + +## 2. 已就绪端点 + +| 端点 | 返回 | 消费方 | +|---|---|---| +| `POST /v1/admission` | `{admitted, reason, message, stage, required_mb, required_cpu, snapshot{mem_available_mb, committed_mb, total_mb, cpu, committed_cpu, reserve_mb, overcommit_ratio}}` | F055 上线终检 / F054 重新上线 | +| `POST /v1/intents/build` | `{build_id, status}` | F055 预检 | +| `GET /v1/builds/{build_id}` | `{build_id, status, stage, message, tail[], image_ref}` | F055 预检轮询 | +| `POST /v1/intents/deploy` | `{instance_id, phase, generation}` | F054 上线动作 | +| `POST /v1/intents/stop` / `destroy` | `{phase}` / `{}` | F054 下线 / 删除 | +| `POST /v1/intents/probe` | `{ready, reason, log_tail}`(入参 `app_id` 或 `{image_ref, env, port, health}` 二选一,都不给 → 400)。`log_tail` 是失败的镜像探活实例在被删除前的最后 40 行输出(已去掉 docker 时间戳),其他情况为空数组 | F055 预检 / 终检 | +| `GET /v1/apps/{app_id}/route` | `{upstream, version_id, generation}`;**404 = 无实例 / 已下线 → 直接渲染停用页,不要重试** | **app-proxy** | +| `POST /v1/intents/preview` | `{instance_id, upstream, phase}`(入参 `{session_id, app_id, slug, version_id, version_no, image_ref, tier, port, env, health, platform_api_base, base_path, expires_at, timeout}` —— 与 `deploy` 同一组,因为容器拿的是 §5 的**整套**环境;`base_path` 是 `/apps/preview/{session}`,**不注入附件句柄**);容量不足 → `capacity_exhausted`、探活不过 → `probe_failed`(两种都已把容器拆掉) | **F055 审批期预览拉起(T053)** | +| `POST /v1/intents/preview/stop` | `{reclaimed: bool}`,**幂等**——已回收的会话答 `{reclaimed:false}` 而不是 404 | F055 回收(终态 / 手动 / 超时三条腿都走它) | +| `GET /v1/previews/{session_id}/route` | `{upstream, version_id, generation}`(`generation` 恒为 0,预览不做原地切换);**404 = 已回收 / 从未拉起 → 渲染「不存在」,不要重试** | **app-proxy `/apps/preview/{session}`** | +| `GET /v1/apps/{app_id}/status` | `{instance_id, phase, health, current_version_id, started_at, restart_count, last_probe_at}`;`phase ∈ pending\|building\|starting\|running\|unhealthy\|stopped\|failed`;**404 = 无实例**(`detail.code=not_found`) | F054 详情页 / `app_instance` 对账 · **F052** MCP 应用状态工具 | +| `GET /v1/apps/{app_id}/logs?tail=&since=&keyword=` | `{lines: [...]}`(无日志即 `[]`) | 详情页运行日志 tab · **F053** CLI `logs` · **F052** MCP 日志工具 | +| `GET /v1/runtime/status` | `{backend_available, supported_runtimes[], capacity{...}, preflight[{name, ok, detail}]}` | 超管运行环境状态(AC-23)· F055 预检前置自检 | +| `GET /v1/apps/{app_id}/storage/objects?prefix=&cursor=&limit=` | `{objects: [{key, size, content_type, etag, last_modified}], next_cursor}`(`key` 是应用内相对路径,永不含 bucket / 前缀) | **托管应用自身**(F057 SDK storage,Bearer)· F052 MCP(HMAC) | +| `PUT /v1/apps/{app_id}/storage/objects/{key}` | 裸字节 body,`Content-Type` 即对象类型;返 `{key, size, content_type, etag, last_modified}`;超单文件上限 → **413 `payload_too_large`**(先看 `Content-Length` 再看实收字节,一个字节都不落) | 同上 | +| `GET /v1/apps/{app_id}/storage/objects/{key}` | 流式字节 + `Content-Type` / `Content-Length` / `ETag`;不存在 → 404 `not_found` | 同上 | +| `GET /v1/apps/{app_id}/storage/meta/{key}` | 元信息 JSON(同 `objects` 一项);不存在 → 404 | 同上 | +| `DELETE /v1/apps/{app_id}/storage/objects/{key}` | `{}`;**不存在 → 404**(不装作成功,F057 AC-25) | 同上 | +| `GET /v1/apps/{app_id}/db/tables` | `{tables: [{name, column_count}]}`(只列用户表,`sqlite_*` 不出;库文件不存在 → **404 `db_not_found`**) | **backend `AppDataService`**(数据 tab · F052 MCP 数据工具,**均经它、不得直连**) | +| `GET /v1/apps/{app_id}/db/tables/{table}/schema` | `{table, columns: [{name, type, notnull, default, pk, editable}], key: {column, kind: primary_key\|rowid}, editable}` | 同上 | +| `GET /v1/apps/{app_id}/db/tables/{table}/rows?page=&size=&order=` | `{rows: [{key, values{}}], total, page, size, order}`;`size` 1–200(默认 50);`order` = `col` / `-col`,**行键恒为次级排序键**(分页不重不漏);BLOB 值以 `` 占位 | 同上 | +| `PATCH /v1/apps/{app_id}/db/tables/{table}/rows/{key}` | 入参 `{values: {col: scalar}}` → `{table, key, before{}, after{}}`;**只改一行**(`BEGIN IMMEDIATE` 短事务,受影响行数 ≠ 1 回滚);行键列 / BLOB 列 / 非标量值 / 未知列 → 400 `data_invalid` | 同上(backend 用 `before/after` 写 `app.data_row_edit` 审计) | +| `GET /v1/apps/{app_id}/db/export?table=` | `text/csv` 文件(`Content-Disposition: attachment; filename="{table}.csv"`),临时文件发送后即删 | 同上 | + +**数据面细则(T086)**:**无 DDL、无任何承载 SQL 的入参**——表名 / 列名 / 排序列一律先对 `sqlite_master` / `PRAGMA table_info` 白名单核对再引号拼接,`CREATE / ALTER / DROP / PRAGMA` 不是"被拒绝"而是"说不出口"(结构演进归 F055)。读路径 `mode=ro`、写路径 `mode=rw`(**永不 `rwc`**——应用自己建库,manager 不替它造空库);每次调用独立连接 + `busy_timeout=3000ms`,调用结束即关,不持长事务(WAL 单写者,长事务会卡住应用自己的写)。写锁等待超时 → **409 `data_busy`**(可重试);表不存在 → 404 `table_not_found`;行不存在 / 受影响行数 ≠ 1 → 404 `row_not_found`。行键:单列主键用该列,否则用 `rowid`;`WITHOUT ROWID` 复合主键表 `editable=false`(可读可导、不可编辑)。 + +**入参约定**:`tier{cpu: vCPU float, mem: MiB int}`;build 必带 **`code_url`(MinIO 预签 URL)** + `code_object_key`(溯源)+ `slug` + `version_no`;deploy 带 `env{}`、`health{path,interval,timeout,retries,start_period}`、`platform_api_base`、`base_path`(缺省 `/apps/{slug}`)。 + +**只读端点细则(批 4)**: + +- `status`:`restart_count` = **daemon 重启数 + manager 重建数之和**(重建后新容器的 `RestartCount` 归零,只取 daemon 的会让长期不健康的应用显示"从未重启");`instance_id` 与 `phase` 之外无形态字段(INV-33)。**路径名是 `/v1/runtime/status`**(本文旧版 §8 曾误写 `/v1/runtime-status`,以本行为准)。 +- `logs`:`tail`(1–5000,默认 500)与 `since`(**epoch 秒 或 `30m`/`2h`/`7d` 相对窗口**,不可解析 → 400 `invalid_request`)下发给 daemon;`keyword`(大小写不敏感子串)在 manager 内过滤——**所以带 keyword 时返回行数可能 < tail,这是设计不是 bug**。保留期 = docker 轮转窗口(10m × 3 / 应用),产品口径「最近的运行日志」。脱敏**只**对平台注入的敏感 env 值(名字含 `SECRET|TOKEN|PASSWORD|CREDENTIAL|SIGNATURE|API_KEY|PRIVATE_KEY`、长度 ≥6)做字面量替换为 `***`;**不做通用脱敏**(D14,密钥靠 F055 发布期扫描兜)——backend 不要在自己那层再加正则,会把应用正常输出打烂。 +- `runtime/status`:**恒 200**,dockerd 宕机时 `backend_available=false` 而不是抛错(这正是它最有用的一句话)。`capacity` = admission 同源快照 + `instances`(在跑实例数)+ `readable`(`/proc/meminfo` 可读否)+ `reason`。`preflight` 十项:`orchestration_backend` / `application_network` / `data_root_writable` / `host_data_root_mapping` / `runtime_templates` / `base_images` / `attachment_storage`(T084,见下方细则)/ `egress_whitelist` / `application_network_internal` / `egress_firewall_fallback`(T077,末三项见 §9),`detail` 直接写修法(缺网络就给 `docker network create bisheng-apps`)。**manager 只读不建网络**——自动建会把"新机器每次发布都失败"的唯一线索藏掉。 +- **后端不可用 ≠ 实例不存在**:`status` / `logs` 在 dockerd 不可达时返 **503 `backend_unavailable`**(→ 16121),实例真的没有才 404。backend 与前端不要把两者合并,否则 dockerd 一重启详情页就显示"已删除"。 + +**附件存储句柄细则(T084 / T085,2026-09-16)**: + +- **两把凭据**:`/v1/apps/{app_id}/storage/**` 是本进程唯一有两类调用方的路由。`Authorization: Bearer ` 是应用自己(token 在 deploy 时铸造、写在期望态记录的 env 里、**绑定该 `app_id`**——A 的 token 打 B 的 URL 是 401 不是"帮你收窄";destroy 后记录消失 token 即失效);没有 Bearer 头则走与其它 `/v1` 完全一样的 HMAC(backend / F052 用)。 +- **key 是应用内相对路径**:manager 侧恒加前缀 `apps/{app_id}/attachments/`;`..` / 绝对路径 / `//` / `./` / 反斜杠 / 控制字符 / 以 `apps/` 开头(哪怕是自己的)/ 超 1024 字节 → **400 `invalid_object_key`**,**拒绝而不是改写**。`prefix` 与 `cursor` 按同一规则校验。 +- **bucket 恒为 `bisheng-apps`**(`RTM_STORAGE_BUCKET`,缺省即此),首次使用不存在就建、**从不写桶策略**;配成平台公共桶 `bisheng` 直接 503 拒绝工作(坑 20)。**不计租户存储配额**——本进程没有平台客户端,无处可计。 +- **单文件上限** `RTM_STORAGE_MAX_FILE_MB`(缺省 20),同值注入为 `BISHENG_APP_STORAGE_MAX_FILE_MB` 让 SDK 发送前就能拒。 +- **未配 MinIO**(`RTM_MINIO_*` 缺):句柄照常注入,所有调用答 **503 `storage_unavailable`**;`runtime/status` 的 preflight 新增一项 **`attachment_storage`** 指出缺什么。 +- **可达性缺口的解法(systemd 形态)**:本进程只监听 `127.0.0.1:8091` 时应用容器从 bridge 上够不到它。注入的 `BISHENG_APP_STORAGE_ENDPOINT` 前缀取 `RTM_APP_FACING_BASE_URL`,缺省从 `RTM_HOST:RTM_PORT` 推导;systemd 形态须把 `RTM_HOST` 与 `RTM_APP_FACING_BASE_URL` 指到 `bisheng-apps` 网桥的网关地址(`docker network inspect bisheng-apps -f '{{(index .IPAM.Config 0).Gateway}}'`,形如 `172.18.0.1`,宿主本地、外网不可达),compose 形态用服务名 `http://runtime-manager:8091`。`RTM_HOST` 仍是回环而没给 `RTM_APP_FACING_BASE_URL` → preflight `attachment_storage` 报 `ok=false` 并写明修法。 +- **删除**:`destroy(purge_volume=true)` 顺带清空该应用前缀下全部对象(AC-43);存储当时不可达只告警不阻断(删除已经发生,平台会重试 destroy)。 +- **日志脱敏**:`BISHENG_APP_STORAGE_TOKEN` 命中 `logs` 端点的注入敏感名规则,值一律 `***`。 +- backend 错误码 **16170–16174** 已在 `app_factory.py` 预留(storage_unavailable / invalid_object_key / payload_too_large / not_found / unauthorized 的映射),**首个 backend 调用方出现的那一波再落码 + 三语文案**;目前唯一消费者是应用自己(Bearer),不经 backend。 + +## 3. 错误信封 → 161xx 映射 + +manager 的 body 恒为 `{"detail": {"code","message",...}}`,backend 按此映射: + +| manager | backend 错误码 | +|---|---| +| `401 unauthorized` / `503 backend_unavailable` | **16121** | +| `400 unsupported_runtime`(带 `supported_runtimes[]`) | **16123** | +| `409 capacity_exhausted`(带 `reason` + `snapshot`) | **16125** | +| `409 probe_failed` | **16124** | +| `GET /v1/builds/{id}` 的 `status=failed`(带 `stage`/`message`/`tail`) | **16122** | +| `404 not_found` | 路由 / 实例不存在(app-proxy 直接出停用页) | +| `404 db_not_found` / `404 table_not_found` / `404 row_not_found` | **16163** / **16164** / **16165**(数据面,T087) | +| `400 data_invalid` / `409 data_busy` | **16166** / **16167**(数据面,T087) | + +## 4. 环境变量 + +**与 backend settings 对照**:`RTM_DATA_ROOT` / `RTM_RESERVE_MB` / `RTM_OVERCOMMIT_RATIO` / `RTM_BUILD_RESERVE_MB` / `RTM_BUILD_INDEX_URL`。 +**建议 backend 补一项**:`app_runtime.build_trusted_host`(对应 `RTM_BUILD_TRUSTED_HOST`,私有 PyPI 镜像走 http 时必需)。 +**manager 独有**(backend 无需对应):`RTM_HOST/PORT/SIGNATURE_HEADER/NETWORK/DOCKER_HOST/IMAGE_PREFIX/IMAGE_RETENTION/RETIRE_GRACE_SECONDS/RECONCILE_INTERVAL_SECONDS/**RECONCILE_ENABLED**/PROBE_TIMEOUT_SECONDS/PROBE_INTERVAL_SECONDS/STOP_TIMEOUT_SECONDS/LOG_MAX_SIZE/LOG_MAX_FILE`。`RTM_RECONCILE_ENABLED` 生产**恒 true**(它就是 AC-20 的自愈机制),只有单测把它关掉。 +**出站白名单(T077,manager 独有,2026-09-16 起)**:`RTM_EGRESS_PROXY`(**应用容器视角**的 `host:port`,空 = 整层未部署——没有「开关开了但代理不在」这种能掐死全部出站的组合)/ `RTM_EGRESS_LISTEN`(代理进程自己绑的地址,缺省 `0.0.0.0:3128`;systemd 形态要绑 `bisheng-apps` 网关而不是回环)/ `RTM_EGRESS_ALLOW` / `RTM_EGRESS_BUILD_ALLOW`(逗号分隔的额外放行项,`host` 或 `host:port`,前导点 = 整个域)/ `RTM_PLATFORM_BASE_URL`(**构建期**唯一放行的平台分发端点,装 bisheng-sdk 用;运行期的平台地址来自 deploy 意图的 `platform_api_base`)/ `RTM_BUILD_NETWORK`(构建期专用 `--internal` 网)/ `RTM_APPS_SUBNET` + `RTM_FIREWALL_BACKEND`(L3 兜底;后者留空则探测,探到 nftables 直接判红——坑 22)。 +**编排访问面(T078)**:`RTM_DOCKER_HOST=tcp://docker-socket-proxy:2375` 把 D2-A 换成 D2-B,**manager 零代码改动**。端点白名单由 `runtime_manager.docker_backend.SOCKET_PROXY_PERMISSIONS` 唯一定义,`socket_proxy_env()` 产出代理容器的整份环境(每个开关都显式写出,含 0),`tests/test_socket_proxy.py` 与 compose 逐字比对。 + +**附件存储(T084,manager 独有)**:`RTM_MINIO_ENDPOINT` / `RTM_MINIO_ACCESS_KEY` / `RTM_MINIO_SECRET_KEY` / `RTM_MINIO_SECURE`(缺省 false)/ `RTM_STORAGE_BUCKET`(缺省 `bisheng-apps`,**不得**是 `bisheng`)/ `RTM_STORAGE_MAX_FILE_MB`(缺省 20)/ `RTM_APP_FACING_BASE_URL`(应用容器访问本进程的地址,见 §2 细则)。MinIO 三项可与 backend 的 `minio.*` 取同值,但**这是 manager 自己的一份配置**,backend 不代管。 + +## 5. 注入应用的环境变量(F053 `bisheng dev` 必须**同名**注入,否则本地线上不同构) + +`BISHENG_APP_DB_URL`(`sqlite:////data/app.db`)· `BISHENG_APP_DB_PATH` · `BISHENG_APP_ID` · `BISHENG_APP_SLUG` · `BISHENG_APP_VERSION`(版本号)· `BISHENG_APP_VERSION_ID` · `BISHENG_PLATFORM_API_BASE` · `PORT` 与 `BISHENG_APP_PORT`(两者恒等)· `BISHENG_APP_BASE_PATH`(dev 期同为 `/apps/{slug}`,2026-10-11 起;此前为空串)· `BISHENG_APP_HEALTH_PATH`。 +**附件句柄(T085,2026-09-16 起)**:`BISHENG_APP_STORAGE_ENDPOINT`(`{RTM_APP_FACING_BASE_URL}/v1/apps/{app_id}/storage`)· `BISHENG_APP_STORAGE_TOKEN`(每应用一把、整个生命周期不变、destroy 后才轮换;deploy 新版本沿用旧 token,30s 宽限期内新旧实例同一凭据)· `BISHENG_APP_STORAGE_MAX_FILE_MB`。**不注入** bucket / 前缀 / MinIO 凭据——收窄在 manager 侧做,应用只见相对路径(F057 AC-21)。名字的 backend 侧副本在 `app_runtime/domain/constants.py::APP_STORAGE_ENV_NAMES`。F053 `dev` 期同名注入的是**本地附件目录句柄**(F053 AC-27),SDK 按有无 `ENDPOINT` 分辨两种形态。 +**模型协议直连面(F051,2026-09-16 起)**:`OPENAI_BASE_URL`(= `{浏览器可见 origin}/api/v2/model/v1`,由 backend 的 `open_api/api/public_base_url.py::model_gateway_base_url` 唯一产出,不因租户或密钥而异)· `OPENAI_API_KEY`(= 该应用的运行期凭据明文,与 `BISHENG_APP_TOKEN` 同值——官方 `openai` 客户端零配置直读的就是这两个名字)· `BISHENG_MODEL_BASE_URL`(= `OPENAI_BASE_URL`,给不读 OpenAI 惯例变量的引擎与技能包文案用的平台保留名)。三名的定义方是 F051 design D2;`dev` 期由 F053 同名注入,故本地与线上代码零差异。 +**出站代理(T077,2026-09-16 起,仅在 `RTM_EGRESS_PROXY` 非空时注入)**:`HTTP_PROXY` / `HTTPS_PROXY`(= `http://{principal}:{token}@{RTM_EGRESS_PROXY}`)· `NO_PROXY`(本网内可直连的地址:本进程的 `RTM_APP_FACING_BASE_URL` 主机 + 回环)· 三者各带一份小写孪生(`curl` 只读小写)· `BISHENG_APP_EGRESS_TOKEN`(凭据本身,名字取成 `*_TOKEN` 是为了让 `readonly._redactions` 按名命中,从而把它在日志里的每一处出现——包括 `HTTP_PROXY` 里的那份——一起抹掉)。`principal` 是 `app_id`,**预览实例是 `bisheng-preview-{session}`**,绝不共用应用的那把。**`BISHENG_APP_EGRESS_TOKEN` 还是 SDK 的托管期判据**(F057 D11,2026-09-17):`bisheng_sdk` 见它存在就让 httpx 读代理变量,平台调用因此经代理出去——改这个变量名必须同步 `bisheng_sdk/_env.py::ENV_EGRESS_TOKEN`,SDK 的 `test_egress_token_env_name_matches_the_manager` 读本仓 `egress.py` 对账。**这四个名字属平台保留,覆盖应用自带的同名值**:能靠自带 `HTTP_PROXY` 改写出口的白名单等于没有白名单。 +平台保留 env 名**覆盖**调用方同名值(含上述三个)。 + +**deploy / preview 意图新增字段 `egress_domains`(T077)**:来自版本 manifest 的 `egress.domains` 原样透传(backend 侧唯一产出点 `app_publish/domain/schemas/app_manifest.py::egress_domains_of`)。平台自身的地址**不在这个列表里**——它们由 manager 按自己的配置补齐,所以应用既不能靠声明拿到平台,也不会因为没声明而丢掉平台。字段同时落成容器标签 `bisheng.egress.domains`,状态文件丢了也能从标签恢复(恢复出一个更窄的白名单 = 应用突然访问不了自家 API,且哪儿都没写为什么)。 + +## 6. 给 app-proxy 的不变量 + +**预览实例按 `session_id` 定址,不进期望态**(F055 AC-26):容器名 `bisheng-preview-{session}`、标签 `bisheng.managed=preview`,因此 reconciler(只看 `bisheng.managed=true`)既不收养也不回收它,`store.committed()` 与 `capacity.instances` 都不计它——这就是「不占应用运行实例名额」的落点。`/data` 是 tmpfs 而非应用卷(AC-29:试用数据带不进生产),`RestartPolicy=no`(平台回收后不得自己复活)。容量仍然照判:内存是真的花掉的,只是名额不计。 +**环境变量给的是 §5 整套**,只差两处,两处都是 AC 在说话:`BISHENG_APP_BASE_PATH` 为 `/apps/preview/{session}`;**附件三名一个都不注入**(应用自己的 storage bearer 交给临时实例,试用上传就会落进生产附件前缀,与数据库同一条红线)。给半套环境的预览不是「试用这个版本」——应用拿不到 `BISHENG_APP_DB_URL` 直接启动失败,审批人看到的是「这个版本是坏的」。 + +**路由缓存 3s × manager 侧旧实例宽限 30s** —— 这个差值就是「版本切换不落 502」(AC-21)的**全部**理由。改任一个数必须同时改另一个,并重跑切换用例。 + +**`generation` 还有第二个来源(批 4 新增)**:reconciler 重建不健康实例时也 `generation+1`——新执行体拿到的是新 bridge IP,`generation` 正是 app-proxy 的缓存失效信号。app-proxy 不要假设 `generation` 变化 ⇒ 版本变化,要按 `upstream` 变化处理(`version_id` 可能不变)。 + +## 7. 114 部署增量(批 2 部分) + +- 新 systemd 单元 `bisheng-runtime-manager.service`:`After=/Requires=docker.service`;`WorkingDirectory=/src/runtime-manager`;`ExecStart=/bin/uvicorn runtime_manager.main:app --host 127.0.0.1 --port 8091`;`EnvironmentFile` 至少给 `RTM_HMAC_SECRET` / `RTM_DATA_ROOT` / `RTM_BUILD_INDEX_URL`(T092 起内网 npm 源另给 `RTM_BUILD_NPM_REGISTRY`)。加进 `bisheng.target` 的 `Wants=` 与 `deploy.sh` 的 `SERVICES=` +- **前置资源**:`docker network create bisheng-apps`(不存在则所有 deploy 失败);`/opt/bisheng/app-data/{apps,state,builds}` 可写;基础镜像预拉(air-gap / 信创必需):`docker pull python:3.11-slim node:20-slim nginx:1.27-alpine`——三个运行时模板各一个(T092 起),以 `GET /v1/runtime/status` 的 `base_images` 检查项为准 +- 依赖(独立 venv,不动 backend):`cd src/runtime-manager && uv sync` +- smoke 增量:`curl -s 127.0.0.1:8091/healthz` → `{"status":"ok"}`;带签名 `POST /v1/admission` 应返回 `admitted` 与 snapshot(顺带验密钥配对) +- **批 4 增量**:进程启动即拉起 reconcile 线程(15s 一轮,启动先做一次全量对齐)。部署自检改用带签名的 `GET /v1/runtime/status`——它一次性回答"编排后端通不通 / 网络建了没 / data_root 可写否 / 模板与基础镜像在不在",比逐条 `docker` 命令核对可靠。**新机器只要 `preflight` 有 `ok=false`,就不要开始上线应用** +- **Wave 4 增量(T077 / T078)**: + - 建网改成 `docker network create --internal bisheng-apps` 与 `docker network create --internal bisheng-build`。**已存在的网不会被 `create` 改属性**:先停掉全部托管应用 → `docker network rm` → 按上面重建,`preflight` 的 `application_network_internal` 就是用来发现「看着装好了其实没改」的。 + - 新 systemd 单元 `bisheng-egress-proxy.service`(模板见本目录 `deploy/`):跑 runtime-manager 同一个包的第二个入口点,**绑 `bisheng-apps` 网关而非回环**(容器里的 127.0.0.1 是容器自己),与 manager 单元共用 `RTM_DATA_ROOT`。同样进 `bisheng.target` 与 `deploy.sh` 的 `SERVICES=`。 + - D2-B:起一个 `tecnativa/docker-socket-proxy`(**只绑 127.0.0.1:2375**),环境照 `python -c "from runtime_manager.docker_backend import socket_proxy_env; print(socket_proxy_env())"` 输出逐项写;manager 单元加 `RTM_DOCKER_HOST=tcp://127.0.0.1:2375` 并可以退出 docker 组。 + - L3 兜底:`RTM_APPS_SUBNET` 取自 `docker network inspect bisheng-apps -f '{{(index .IPAM.Config 0).Subnet}}'`,规则用 `python -m runtime_manager.egress firewall --print` 先看、`--apply` 再下发(先删后插,可重复执行)。Docker 29 的 nftables 后端没有 `DOCKER-USER` 链,`--print-nft` 出的是对应的 nft ruleset(坑 22)。 + +## 8. 自愈与恢复语义(批 4,T028–T031 已实现) + +reconciler 每 **15s** 一轮,`RTM_RECONCILE_ENABLED` 关不掉的产品语义如下,backend / app-proxy 侧的判断要与之对齐: + +| 实际态 | 动作 | 为什么不是别的做法 | +|---|---|---| +| 期望 running、执行体**不存在** | 按期望态重建并探活(卷不动) | — | +| 期望 running、执行体**已退出** | 只 `start`,**不重建** | 进程退出归 docker `unless-stopped` 的指数退避;重建会丢掉退避,还让崩溃循环每 15s 换一个新实例 id 藏起来 | +| 期望 running、**存活但 unhealthy 连续 2 轮** | stop → rm → run(同名同卷)、`generation+1`、再探活 | **docker 单机 healthcheck 与 restart policy 无联动**(坑 17),这一类故障没人管;这是薄 reconciler 存在的核心理由 | +| 期望 stopped、执行体在跑 | stop | 下线是显式动作,reconciler 不与运维打架 | +| 带 `bisheng.managed=true` 标签、**无任何期望态声明** | 回收(stop + rm,**不删卷**) | — | + +**不误杀边界(backend 不必处理,但排障时要知道)**:只有 manager 自己打了 `bisheng.managed=true` 的容器才可能被回收;同机其它容器(onlyoffice / rabbitmq…)、探活临时实例(`bisheng.managed=probe`)、以及处于 30s 宽限期内的旧版本实例一律不动。 + +**恢复口径(AC-22 / AC-50)**:容器存活依赖 dockerd 而非 manager;manager 重启只影响 reconcile 时效。启动第一件事是**先从容器 label 恢复期望态、再做孤儿回收**(顺序反了 = 状态文件一丢就清空全站)。状态文件 `{data_root}/state/desired-state.json` 是缓存,**容器 label 才是灾备真相**;label 恢复时"存在但没在跑"判为**已下线**(`unless-stopped` 下 dockerd 会把非显式下线的拉回来)。 + +**5 分钟自愈预算(AC-20)**:healthcheck 判定 30s + reconcile 感知 2×15s + 重建探活 ≤90s = **≤150s**;`recovery_budget_seconds(config)` 用配置算出该值并被用例断言 ≤300s,所以调大 `RTM_RECONCILE_INTERVAL_SECONDS` / `RTM_PROBE_TIMEOUT_SECONDS` 会先让测试红,而不是先让客户超时。 + +## 9. 尚未实现(留给后续批次) + + +**`GET /v1/apps/{app_id}/route` 的 `409 deploying` 信封(「发布中」窗口,T083,2026-09-16)**:app-proxy 侧**已消费**——`detail.code=deploying` → 导航请求渲染「发布中」自动重试页、XHR 返 503 + 16147、WS 升级以 4503 关闭,并按路由缓存的 3s 缓存该答案(不把一次发布变成对 manager 的请求风暴)。manager 侧**当前不发**:`deploy` 是同步的——探活通过才写期望态(`generation+1`),backend 也要等 `deploy` 返回才把应用置 `online`,重发布期间旧实例照常服务到 30s 宽限期结束;所以现在**不存在**能观察到「发布中」的窗口,路由端点只有 `{upstream,…}` 与 404 两种答案(404 → 「应用恢复中」)。若日后把 deploy 改成异步(先登记期望态再拉起),该窗口即出现,届时**必须**发此信封而不是 404,否则用户在首发布期间看到的是「恢复中」文案。 +(`GET /v1/apps/{app_id}/db/*` 数据面已于 T086 落地,见 §2 表末五行。) + +**出站白名单双层与 docker-socket-proxy(D12 / D2-B)已于 T077 / T078 落地**,见 §4 与 §5 的新增项。落地口径三条,排障时按这个顺序看: +1. **三层,不是一层**。L1 = `bisheng-apps` / `bisheng-build` 建成 `--internal`(应用没有默认路由);L2 = `egress-proxy`(runtime-manager 同一个包的第二个入口点 `python -m runtime_manager.egress_proxy`),它是唯一能看见**域名**的一层,也就是「只放行声明域名」唯一可能成立的地方;L3 = `DOCKER-USER` / nft 兜底,兼管 UDP(CONNECT 代理看不见数据报)。 +2. **`BISHENG_APP_STORAGE_ENDPOINT` 所指地址恒在放行名单上**,并且同时进 `NO_PROXY`(同网直连,不绕代理),`tests/test_egress.py::test_the_attachment_handle_host_is_always_on_the_run_phase_list` 钉住这条。 +3. **策略是一个文件**(`{RTM_DATA_ROOT}/egress/policy.json`),不是一次 RPC:编排器重启不该把每个托管应用的出站一起带走。代理按 mtime 重读,读坏了保留上一份。文件里存了**凭据明文**(reconciler 重建实例、重发布宽限期都必须复用同一把),因此目录 0700、文件 0600,只有本部署的两个进程读得到。 +4. **放行名单分两半,判定不同**(2026-09-16 review 补):部署侧配置的那半(deploy 意图的 `platform_api_base`、`RTM_APP_FACING_BASE_URL`、`RTM_EGRESS_ALLOW` / `RTM_EGRESS_BUILD_ALLOW`、包源)标 `trusted`,**允许是私网地址**——私有化平台本来就是。应用 manifest 声明的那半**不允许**:代理按定义同时挂在 `bisheng-apps` 与有默认路由的网上(compose 形态下它就坐在 mysql / redis / minio 旁边),不加这道闸,一行 `egress: {domains: [mysql]}` 就把「唯一出口」变成「进平台内网的中继」,正是 L1 要消除的可达性。落法:代理放行后先解析域名,**任一**解析结果非公网即 403 `private_address`,报文直接写出运维侧的口子 `RTM_EGRESS_ALLOW`;随后连的是刚判过的那个地址而不是域名(第二次解析不可能给出不同答案)。`trusted` 标志随策略文件一起落盘,`from_dict` 缺字段一律按 **不可信** 读。 +5. **平台侧那半是从「平台注入了哪些地址」推出来的,不是一张手写清单**(2026-09-16 review 补):`platform_api_base` 不等于「平台」——F051 的模型面 `OPENAI_BASE_URL` 取 `open_api.public_base_url`(**浏览器可见 origin**),只有它没配才退回 `app_runtime.entry_base_url`(= `platform_api_base`)。两者不同是被文档化的常规形态,按 `platform_api_base` 单独建名单,白名单一开托管应用的模型调用全被拒。落法:`runtime_manager.egress.PLATFORM_URL_ENV_NAMES`(`BISHENG_PLATFORM_API_BASE` / `BISHENG_APP_STORAGE_ENDPOINT` / `OPENAI_BASE_URL` / `BISHENG_MODEL_BASE_URL`)里的值一律进可信名单。这些名字是平台保留名、backend 在版本自带注入**之后**覆写,所以值必是平台的;下一个平台地址加进来时这里不用改。 + +**已在线实例随层配置对齐(2026-09-17)**:代理变量在创建容器时固化,层配置却属于部署——开层、关层、改 `RTM_EGRESS_PROXY` / `RTM_APP_FACING_BASE_URL` 之后,存量实例的变量就不再对。reconciler 每轮检查在跑且健康的实例,漂移即按同版本重发布的形态蓝绿替换(`generation+1`,探活通过才切,旧实例宽限期后退役),**每轮至多一个应用**,失败保留旧实例并退避 600s;`rtm.reconcile` 的 `actions.realigned` 计数。策略条目(`RTM_EGRESS_ALLOW` 改动、策略文件丢失)原地补写,不重建容器。**所以开层后无需逐个 resume**,等 `应用数 × 15s` 即可;核对:`docker inspect <容器> -f '{{range .Config.Env}}{{println .}}{{end}}' | grep -c HTTPS_PROXY`。 + +**存量环境升级注意**:`docker network create` 不会把已存在的网改成 internal,compose 也不会。`GET /v1/runtime/status` 的 preflight 新增 `egress_whitelist` / `application_network_internal` / `egress_firewall_fallback` 三行,**没改到位时逐行给出改法**;`RTM_EGRESS_PROXY` 留空时第一行直接写「托管应用出站不受限」。 diff --git a/features/v3.0.0/054-app-domain-runtime/deploy/bisheng-app-proxy.service b/features/v3.0.0/054-app-domain-runtime/deploy/bisheng-app-proxy.service new file mode 100644 index 0000000000..1adcefc688 --- /dev/null +++ b/features/v3.0.0/054-app-domain-runtime/deploy/bisheng-app-proxy.service @@ -0,0 +1,46 @@ +# BiSheng 托管应用入口反代(app-proxy)—— F054 T072 / design §4.2 ⑧ +# +# ⚠️ 模板,真身在 `~/Projects/bisheng-ops/systemd/`(K10)。落库见 T075。 +# +# 与 runtime-manager 的关键差别:**它不需要任何 docker 权限**。它只做 +# 「解析路由 → 剥归一化头 → 注入身份 → 转发」。给它 docker 组权限是纯粹的 +# 权限扩散,没有任何功能上的理由。 +[Unit] +Description=BiSheng app-proxy (hosted app entry :8090) +# 每个请求都要向 backend 问一次入口判定(3s 缓存 × 30s 宽限), +# backend 没起来它只会一直回兜底页。 +After=network-online.target bisheng-api.service +Wants=network-online.target +PartOf=bisheng.target + +[Service] +Type=simple +WorkingDirectory=/opt/bisheng/src/app-proxy +Environment=PYTHONPATH=/opt/bisheng/src/app-proxy +# 同样只监听回环:它前面永远有 nginx 的 location /apps/。 +Environment=APP_PROXY_HOST=127.0.0.1 +Environment=APP_PROXY_PORT=8090 +# ⚠️ 变量名以 app_proxy/config.py 顶部的契约表为准,**不要照猜**。实测踩过: +# 写成 APP_PROXY_HMAC_SECRET 时进程照常启动、systemd 显示 active,直到第一个 +# 请求才 fail-closed 503「HMAC secret is not configured」——这是它设计好的行为 +# (空密钥宁可在离开进程前就抛,也不发一个对端反正会拒的未签名请求),但排查时 +# 看起来像后端没配。对照表: +# APP_PROXY_BACKEND_BASE 平台 API base(127.0.0.1:7860) +# APP_PROXY_MANAGER_BASE app_runtime.manager_base_url +# APP_PROXY_BACKEND_SECRET app_runtime.proxy_hmac_secret +# APP_PROXY_MANAGER_SECRET app_runtime.manager_hmac_secret +# APP_PROXY_ENTRY_BASE_URL app_runtime.entry_base_url +# APP_PROXY_WS_MAX_LIFETIME_SECONDS app_runtime.ws_max_lifetime_seconds(兜底,可不设; +# 判定里带的后端值优先。WS 其余开关见 config.py) +EnvironmentFile=/etc/bisheng/app-proxy.env +Restart=on-failure +RestartSec=5 +LimitNOFILE=65536 +ExecStart=/opt/bisheng/src/app-proxy/.venv/bin/python -m uvicorn app_proxy.main:app --host 127.0.0.1 --port 8090 --workers 1 --no-access-log +StandardOutput=append:/tmp/bisheng-app-proxy.log +StandardError=append:/tmp/bisheng-app-proxy.log +# 它可能正在转发一个长连接(应用的 WebSocket / 流式响应)。 +TimeoutStopSec=30 + +[Install] +WantedBy=multi-user.target diff --git a/features/v3.0.0/054-app-domain-runtime/deploy/bisheng-egress-proxy.service b/features/v3.0.0/054-app-domain-runtime/deploy/bisheng-egress-proxy.service new file mode 100644 index 0000000000..8dd279a73b --- /dev/null +++ b/features/v3.0.0/054-app-domain-runtime/deploy/bisheng-egress-proxy.service @@ -0,0 +1,53 @@ +# BiSheng 托管应用出站代理(egress-proxy)—— F054 T077 / design D12-C L2 +# +# ⚠️ 这份文件是**模板**。真身在独立运维仓 `~/Projects/bisheng-ops/systemd/` +# (K10),产品仓不持有 114 的部署文件。落库: +# cp bisheng-egress-proxy.service /opt/bisheng-ops/systemd/ +# ln -sf /opt/bisheng-ops/systemd/bisheng-egress-proxy.service /etc/systemd/system/ +# systemctl daemon-reload && systemctl enable --now bisheng-egress-proxy +# 同样要进 `bisheng.target` 的 `Wants=` 与 `deploy.sh` 的 `SERVICES=`。 +# +# 它跑的是 runtime-manager **同一个包的另一个入口点**,两者之间的全部契约是 +# `{RTM_DATA_ROOT}/egress/policy.json` 这一个文件。这不是图省事:编排器重启时 +# 每个托管应用的出站不能跟着断,而共用一个进程就一定会跟着断。 +# +# 与 runtime-manager 的三处刻意差异: +# 1. **不 After=docker.service**,也**不要**给它 docker 组 —— 它只做 CONNECT +# 转发,给它编排权限是纯粹的权限扩散; +# 2. **监听网桥而不是回环**:应用容器要够得到它,127.0.0.1 在容器里是容器 +# 自己。地址写 `bisheng-apps` 网桥的网关地址(`docker network inspect +# bisheng-apps -f '{{(index .IPAM.Config 0).Gateway}}'`),不要写 0.0.0.0 +# ——这台机器的其它网卡上不该出现一个开放代理; +# 3. `RTM_EGRESS_PROXY` 给的是**应用视角**的同一个地址,runtime-manager 那份 +# 必须逐字相同,否则注入进容器的 HTTP_PROXY 指向一个没人监听的地方。 +[Unit] +Description=BiSheng hosted-app egress proxy (outbound whitelist, :3128) +After=network-online.target +Wants=network-online.target +# 应用起得来但出不去,比应用起不来更难查:把它和编排器绑在一起, +# 运维停一个就会看见另一个也停。 +PartOf=bisheng.target + +[Service] +Type=simple +WorkingDirectory=/opt/bisheng/src/runtime-manager +Environment=PYTHONPATH=/opt/bisheng/src/runtime-manager +# 与 bisheng-runtime-manager.service 同一份 data root —— 策略文件在 +# {RTM_DATA_ROOT}/egress/policy.json,本进程只读它。 +Environment=RTM_DATA_ROOT=/opt/bisheng/app-data +# ⚠️ 下面两行的地址必须与 runtime-manager 单元里的 RTM_EGRESS_PROXY 逐字相同。 +# 172.18.0.1 是**示例**:以 `docker network inspect bisheng-apps` 实际网关为准。 +Environment=RTM_EGRESS_LISTEN=172.18.0.1:3128 +Environment=RTM_EGRESS_PROXY=172.18.0.1:3128 +Restart=on-failure +RestartSec=5 +LimitNOFILE=65536 +ExecStart=/opt/bisheng/src/runtime-manager/.venv/bin/python -m runtime_manager.egress_proxy +StandardOutput=append:/tmp/bisheng-egress-proxy.log +StandardError=append:/tmp/bisheng-egress-proxy.log +# 停机时正在转发的连接直接断开是对的:调用方会重试,而拖着一条已经不该存在的 +# 隧道等它自己结束,等的是对端而不是我们。 +TimeoutStopSec=10 + +[Install] +WantedBy=multi-user.target diff --git a/features/v3.0.0/054-app-domain-runtime/deploy/bisheng-runtime-manager.service b/features/v3.0.0/054-app-domain-runtime/deploy/bisheng-runtime-manager.service new file mode 100644 index 0000000000..c7a441694f --- /dev/null +++ b/features/v3.0.0/054-app-domain-runtime/deploy/bisheng-runtime-manager.service @@ -0,0 +1,87 @@ +# BiSheng 编排器(runtime-manager)—— F054 T072 / design §4.2 ⑧ +# +# ⚠️ 这份文件是**模板**。真身在独立运维仓 `~/Projects/bisheng-ops/systemd/` +# (K10),产品仓不持有 114 的部署文件。落库由 T075 在 114 上执行: +# cp bisheng-runtime-manager.service /opt/bisheng-ops/systemd/ +# ln -sf /opt/bisheng-ops/systemd/bisheng-runtime-manager.service /etc/systemd/system/ +# systemctl daemon-reload && systemctl enable --now bisheng-runtime-manager +# +# 这是全平台**唯一**被允许持有容器编排访问面的进程(arch-guard RULE-10 的另一 +# 半:backend 不得 import docker、不得出现 docker.sock、不得读 DOCKER_HOST)。 +# 「安全来自笼子」的前提是 backend 自己在笼子外面拿不到钥匙——所以这个单元和 +# backend 单元的权限差异是刻意的,不要为了"方便"把 backend 也加进 docker 组。 +[Unit] +Description=BiSheng runtime-manager (orchestrator RPC :8091) +# 它要跟 dockerd 说话,dockerd 没起来它启动即失败并被 Restart 拉起, +# 徒增噪声;用 After 让 systemd 排好序。 +After=network-online.target docker.service +Wants=network-online.target +Requires=docker.service +PartOf=bisheng.target + +[Service] +Type=simple +WorkingDirectory=/opt/bisheng/src/runtime-manager +Environment=PYTHONPATH=/opt/bisheng/src/runtime-manager +# 默认只监听回环:它除了 HMAC 没有别的门面,暴露到 0.0.0.0 等于把编排能力挂到 +# 网上——HMAC 是防篡改不是防暴露。 +# +# ⚠️ 但**附件存储句柄(F054 T084/T085)要求应用容器够得到本进程**:托管容器在 +# `bisheng-apps` 网桥上,回环对它们不可达。要用附件功能就必须在 +# EnvironmentFile 里把 RTM_HOST 覆盖成网桥网关地址(`docker network inspect +# bisheng-apps -f '{{(index .IPAM.Config 0).Gateway}}'`,形如 172.22.0.1),并把 +# RTM_APP_FACING_BASE_URL 指向同一个地址。网关地址只在宿主本地可达,外网够不到, +# 所以这不等于暴露到 0.0.0.0。 +# +# ExecStart 因此从 $RTM_HOST / $RTM_PORT 取值而不是写死:写死的话 +# EnvironmentFile 里设了也不生效,而 `runtime/status` 的 attachment_storage +# 自检看到 RTM_APP_FACING_BASE_URL 已设就答 ok——配置看着对、容器却拨不通 +# (2026-09-16 在 114 上实测踩到)。 +Environment=RTM_HOST=127.0.0.1 +Environment=RTM_PORT=8091 +# HMAC 共享密钥经环境注入,不落配置文件(C6)。与 backend 的 +# app_runtime.manager_hmac_secret 必须一致,两边不一致的症状是所有编排意图 +# 被回 401,而 backend 侧会把它折叠成 16121「编排器不可用」。 +# +# ⚠️ T077 / T078 起,这份 EnvironmentFile 还必须给出下面这些(`RTM_` 前缀): +# RTM_DOCKER_HOST=tcp://127.0.0.1:2375 D2-B:编排走 docker-socket-proxy 的 +# 端点白名单。端点清单由 runtime_manager.docker_backend.socket_proxy_env() +# 唯一给出(`python -c "from runtime_manager.docker_backend import +# socket_proxy_env; print(socket_proxy_env())"`),代理容器**只绑 127.0.0.1**。 +# 留空 = 回到直连 /var/run/docker.sock 的 D2-A 形态,那时本单元需要 docker 组。 +# RTM_EGRESS_PROXY=:3128 D12:应用视角的唯一出口地址, +# 必须与 bisheng-egress-proxy.service 的 RTM_EGRESS_LISTEN 逐字相同。 +# **留空 = 整层未部署**,`GET /v1/runtime/status` 的 preflight 会直说 +# 「托管应用出站不受限」——没有「开关开了但代理不在」这种掐死出站的组合。 +# RTM_BUILD_NETWORK=bisheng-build 构建期专用 --internal 网。 +# RTM_APPS_SUBNET= L3 兜底网段。 +# RTM_FIREWALL_BACKEND=iptables|nftables 留空则探测;探测到 nftables 会判红, +# 因为 Docker 29 起 nftables 后端**没有 DOCKER-USER 链**(design 坑 22), +# iptables 规则会「下发成功」却从不被查阅。 +# RTM_PLATFORM_BASE_URL=<平台地址> 构建期唯一放行的平台分发端点(装 +# bisheng-sdk 用,AC-16)。 +# RTM_BUILD_EXTRA_INDEX_URL=<平台地址>/api/v1/dev-toolkit/simple/ +# RTM_BUILD_EXTRA_TRUSTED_HOST=<平台主机名> bisheng-sdk 只在平台自己的包索引上。 +# 漏配 = 所有依赖 bisheng-sdk 的应用构建失败(16227, No matching distribution +# found for bisheng-sdk),症状像网络问题。地址必须在 RTM_PLATFORM_BASE_URL +# 之下,出站代理才会放行;而且要从构建容器够得到(写宿主机地址,不写 localhost)。 +# +# 前置资源随之增加两条(原有的 `docker network create bisheng-apps` 要改写法): +# docker network create --internal bisheng-apps +# docker network create --internal bisheng-build +# 已存在的网**不会**被 `create` 改成 internal:先停掉全部托管应用、`docker +# network rm`、再按上面重建。preflight 的 application_network_internal 这一行 +# 就是用来发现「看着装好了其实没改」的。 +EnvironmentFile=/etc/bisheng/runtime-manager.env +Restart=on-failure +RestartSec=5 +LimitNOFILE=65536 +ExecStart=/opt/bisheng/src/runtime-manager/.venv/bin/python -m uvicorn runtime_manager.main:app --host ${RTM_HOST} --port ${RTM_PORT} --workers 1 --no-access-log +StandardOutput=append:/tmp/bisheng-runtime-manager.log +StandardError=append:/tmp/bisheng-runtime-manager.log +# 停机时正在跑的构建可能持有 docker 客户端连接,给足退出时间; +# 强杀会留下悬挂的构建容器,下一次 reconcile 才收得掉。 +TimeoutStopSec=60 + +[Install] +WantedBy=multi-user.target diff --git a/features/v3.0.0/054-app-domain-runtime/design.md b/features/v3.0.0/054-app-domain-runtime/design.md new file mode 100644 index 0000000000..82828e389f --- /dev/null +++ b/features/v3.0.0/054-app-domain-runtime/design.md @@ -0,0 +1,265 @@ +# Design: 托管应用运行时与安全收尾 + +> **2026-10-24 用户确认修订设计,进入任务拆解与编码。** 规格已确认;实现/验证状态见本轮任务记录。 +> 现状基线:`5f2541f4bc483c24624fede9031c88c4ec6f58f4`;增量规格确认提交:`dda16a6502908ba8d58cff61dc350c0213b86c19`。旧版 D1–D15 编号保留,内容按当前代码刷新;历史任务的行号、坑编号和 MVP 后置说法不作为当前实现状态。 +> +> What:[spec.md](./spec.md)(73 条 AC);流水账:[tasks.md](./tasks.md);不变量:[release-contract.md](../release-contract.md)。本轮只补 AC-66、AC-67、AC-68、AC-69、AC-70、AC-71、AC-72、AC-73,对应历史 T098 / T099,不重开 F059 / F086 验收。 + +## 1. 目标与非目标 + +保持统一应用入口、平台身份注入、持久化数据和独立编排边界,补齐 Docker 应用访问宿主服务的隔离,以及 OBO 配置不完整时的入口拒绝。为 F055 用户确认的配置重建提供现有部署执行与受限运行环境只读协议。 + +非目标:商业授权、重写 manager、重新实现 F059 Kubernetes、扩大第三方 egress 白名单、密钥管理页面或运行记录查询(分别归 F055 / F056);不在 backend 中运行防火墙命令或容器 SDK。 + +## 2. 关键约束 + +- 领域真相由 backend 数据库持有;manager 经内部认证下载代码/上传快照,不直连 MinIO。附件已迁 backend。源码、依赖、启动参数由已冻结交付描述决定;本轮不改镜像构建路线。 +- Docker Engine 仅由 manager 经 socket proxy 访问;K8s 由独立 manager 使用受限 ServiceAccount 操作。没有挂 socket 不意味着编排 API 权限很小。 +- Docker 隔离规则必须在 **Docker 宿主网络命名空间**生效。manager 容器中执行 iptables 或检查容器自己的 INPUT,不能证明宿主已隔离。 +- 原生 nftables 与 iptables 是两种支持路径;本轮 Docker 托管网络只声明支持 IPv4。发现 IPv6、未识别网络后端、无法证明实际隔离时,拒绝新部署,不假装支持。 +- 宿主规则变更只操作自有链/表,不能清空 INPUT/FORWARD/DOCKER-USER,也不能修改其他网络的全局默认策略。 +- Constitution Check:遵守 [constitution.md](../../../docs/constitution.md) C1–C8。新增隔离报告由 Repository 落库(C1/C8);租户对象、权限入口沿用 C3/C4;新字段/迁移用通用类型(C2),错误码沿用 161 段(C5);HMAC 与 OBO 分开配置(C6)。本轮无铁律例外。 + +## 3. 方案对比与选定 + +### D1:独立 manager + backend 持久意图 + +保留独立进程;选用 F086 的持久 RuntimeOperation、执行事实与路由投影,而非 backend 直接控制 Docker。证据是 `runtime_operation_service.py`、`runtime_delivery_repository.py` 的租约/代次/操作围栏;仅 HTTP 一次性命令无法恢复响应丢失。若执行协议整体升级再评估,不增加并列状态机。 + +### D2:Docker 经 socket proxy,K8s 经受限 API + +当前 Docker 配置已经是 socket proxy,不再采用旧设计的“先直挂 docker.sock”。通用代理限制 API 面但不是业务授权器;manager 仍持有高权限编排面。拒绝把三种代理与 backend 合并来省进程。只有权限模型改变且验证隔离等价时重议。 + +### D3:交付模式服从 F086 / F059 + +当前有 bundle、完整 image、source-image,支持 Python 3.11 / Node 20 / static,不再是首发 Python 模板单一路线。选用已冻结 delivery/profile/digest;拒绝运行中重新选择可变默认镜像。依赖缓存、离线交付和 K8s 组装的详细契约分别见 [F086 design](../086-vibe-runtime-delivery/design.md)、[F059 design](../059-k8s-runtime-backend/design.md)。新增运行时时再评估。 + +### D4:自愈与切流复用现有围栏 + +探活成功才提交路由;旧写入者确认停止才允许新的 SQLite 写入者;恢复沿用原操作、deadline、资源 UID,不以重启为理由无限延长。禁止只用容器名判定安全。底座证据见 F059 既有验收;本轮用户确认重建只复用这些机制。引入多实例数据模型时重议。 + +### D5:app-proxy 保持独立入口 + +nginx → app-proxy → backend 授权与路由投影 → 应用,支持 HTTP/WebSocket;不改为代理向 manager 查路由。proxy 持有 entry 只读角色,不持编排或数据库凭据。若统一入口基础设施替换再评估。 + +### D6:OBO 配置失效时 fail closed(本轮变更) + +选用现有 `unavailable` 决策并新增 `reason=identity_config_incomplete`,不增加可放行的“匿名降级”。`entry_authz_service` 提供同一 `obo_configuration_status()` 校验器:值缺失、全空白、与 JWT secret 相同均无效;**校验不 trim 后再签名**,实际有效值原样用于签名与验证。正式入口完成登录、应用存在、租户、可见范围、下线/维护判定后,在 `_identity_material` 前检查;预览完成会话、预览归属与应用/租户核验后同样检查。任一无效返回拒绝,不生成身份头、OBO、allow 访问记录。 + +app-proxy 的 `BackendAuthzClient` / preview 授权客户端不再缓存含身份材料的 allow 决策:每个新的 HTTP 请求和 WebSocket 握手到 backend 重新判定,避免 backend 配置失效后旧 allow 缓存继续放行。路由缓存与拒绝缓存维持独立;已经建立的 WebSocket 不承诺逐帧重鉴权。此取舍增加授权 RPC,定向验证请求数与延迟,不把“保留有效配置行为”理解为继续缓存旧身份。若未来有可信配置 epoch 推送协议,再评估恢复 allow 缓存。 + +签发器自身再防御性校验并返回显式失败,调用者不可把失败当缺省可选值;验证器亦拒绝无效配置。请求期间以同一配置快照完成检查/签发,避免动态刷新间隙。应用运行时开启时,FastAPI lifespan 输出一次脱敏 ERROR(字段名和失败原因,不含值),**不终止整个平台进程**,入口保持拒绝;开关关闭不产生“已启用”或误报。选择此方案而非整站启动失败,因为 spec 要求关闭运行时/其他平台能力不被连带停用。若未来 OBO 采用非对称签名,再重新审查发行/验证契约。 + +### D7:拒绝页面沿用既有契约 + +app-proxy 对 `unavailable` 保留既有不可用页与状态码;当 backend 已完成原登录、对象存在、租户、访问权限和运行层开关判定后,消费可信 `reason=identity_config_incomplete`,正式/预览均显示 **“本环境配置不完整,请联系平台管理员”**(三语同义)。该提示不显示配置值、字段值、内部 URL 或真实应用环境;无权者仍得到原来的登录/不存在/无权限等决策,不靠浏览器参数切换到此提示。无需新视觉模式;沿用不可用页模板只增加此原因对应文案。新增状态码需先检查 nginx error_page 合同。 + +### D8:领域与列表投影保持兼容 + +App / AppVersion / AppInstance 继续分别表达业务状态、冻结版本、运行观察;构建/广场列表通过既有 UNION 第三支表达 `flow_type=35`、`app_state`,不为新安全项改列表含义。若新增状态,需同时审查两个 SPA 的白名单。 + +### D9:权限走已验证业务目标 + +应用身份、owner、租户与资源状态先由业务服务核实,业务动作走 F048 统一入口;manager 与代理不自行查 OpenFGA。管理员的通用 shortcut 不能扩大 F055 的 natural owner-only 密钥写权限。新增管理动作时重审 Catalog,而非手写 role_access。 + +### D10:SQLite 与平台存储解耦 + +Docker 绑定本地持久目录,K8s 用已验收独占 PVC;manager 执行本地 backup/迁移,经 backend 存取快照。应用附件走 backend 存储接口;manager 不读平台 MinIO 配置,也不需要独立 MinIO 实例/新桶。升级代码不等于恢复数据库;恢复必须显式选快照。存储拓扑变化时重议单写入者证明。 + +### D11:容量与资源档位 + +沿用平台档位与持久 reservation;释放依赖停止证据,不以超时自动当作已停。用户确认的配置重建依然需要执行资源预算,不绕过准入。并发拓扑改变时重审预算。 + +### D12:宿主 INPUT + FORWARD 的同源隔离(本轮变更) + +**否决**仅补 DOCKER-USER(不覆盖 INPUT)、只看 `--internal`(不证明宿主入口关闭)、在 manager 容器里加 NET_ADMIN(作用域错误且扩大权限)、允许整个 backend 端口给附件(附件也应经 egress)。选定:保留 egress 唯一出口,安装侧 root 工具维护宿主自有规则,backend 持有短时有效的独立隔离证明,新部署消费证明。 + +现有 `runtime_manager.egress` 已有规则渲染和 CLI,扩展同一规则模型,不另写两套白名单。具体安装、报告和准入合同见 §4.4。已建立连接也不能用宽泛 `ESTABLISHED` 一律放过,必须区分应用响应与应用主动发起。真实宿主配对探测是依据,命令存在不等于隔离成立。若后续采用经过验证的 eBPF/专用网络插件,可替换证明生产方,不能取消拒绝边界。 + +### D13:详情页扩展点 + +保留发布 / 版本 / 数据 / 日志页壳与命名 slot。F055 密钥管理放既有 settingsSlot,不为安全项新增管理菜单。壳变更时需检查三个 Feature 消费者。 + +### D14:运行日志与访问记录分开 + +stdout/stderr 是调试日志;backend 入口 allow 产生合并窗口访问记录;F056 的读接口不创造新访问事件。保留既有合并窗口实现/配置,本轮不改频率。安全拒绝不计成功访问;日志包含配置原因但不含身份材料。若审计语义变更,应先改规格。 + +### D15:收尾边界与交付声明 + +旧 MVP “后置”不再表示未实现:F086、F059 已有实现/证据,本轮只修改与安全/密钥刷新直接相关的接口。历史未完成验收不自动勾选,ARM、完整离线安装和正式镜像发行不冒充本轮完成。只在本轮 AC 定向证据成立后更新相应任务。 + +## 4. 系统现状(接手必读) + +### 4.1 数据流与实现状态 + +**已实现**:backend 保存应用/版本/操作/路由/存储绑定;Celery 调发布管线;manager 拉取持久计划、编排 Docker/K8s、上报事实;app-proxy/egress 只读投影。`RuntimeDeliveryCoordinator.inject_environment` 使用加密 sealed_env 保存可恢复执行环境;K8s 已用操作专属 Secret + `secretKeyRef`,并非把平台 token 放 ConfigMap。 + +**尚未实现的本轮安全增量**:INPUT 规则与可信证明、OBO 无效时拒绝。F055 用户确认重建与运行快照公共适配已在 `a5c7722b9` 编码并完成定向本地检查,尚未真实部署验收。现有 `egress.firewall_rules` 仅渲染 FORWARD/DOCKER-USER 规则;`_firewall_check` 主要检测后端存在。现有 `_issue_obo_token` 返回空时两个入口仍允许,是 T099 真缺口。 + +```mermaid +flowchart LR + Browser --> Nginx --> Proxy[app-proxy] + Proxy --> Auth[backend entry authorization] + Auth --> Gate{identity configuration valid} + Gate -->|no| Deny[unavailable; no identity or access record] + Gate -->|yes| Route[backend route projection] + Route --> App[application] + App --> Egress[egress-proxy] --> Platform[platform APIs] + Installer[host root installer / verifier] --> Firewall[owned INPUT and FORWARD rules] + Installer --> Report[backend host isolation report] + Report --> Admission[new Docker deployment admission] +``` + +### 4.2 既有公共合同(保留原编号语义) + +**① runtime-manager 意图 RPC**:旧 `POST /v1/intents/{build,deploy,stop}` 兼容通道与当前持久 v2 通道不可混淆。当前 backend `RuntimeDeliveryCoordinator` 创建 RuntimeOperation、wake manager;manager 以 node/role HMAC 从 `/api/v1/internal/app-runtime/v2/nodes/plan`、`operations/{id}` 拉取并 claim、上报 facts。冻结字段见 F086/F059,public caller 不可注入 sealed_env/runtime_env。旧 code_object_key 直读 MinIO 的设计不再使用。 + +**② backend 状态动作 / 读口**:`AppStateService.{publish,manual_publish,stop,resume,delete,stage_version}` 仍是 F055 的领域入口;`GET /api/v1/apps`、`/{id}`、`/{id}/instance`、`/{id}/versions`、`/{id}/logs`,PATCH 元信息与 actions 路由保持。版本列表只读,不提供绕过审批的直接切版本;`AppMetaService.update_meta` 为管线与页面共用。runtime-status 只供有权管理员;owner/租户范围由读服务决定,不拿 UI can 标志当后端权限。 + +**③ app-proxy 注入头**:`X-BiSheng-User-Id/User-Name/Tenant-Id/Dept-Id/Dept-Name/Dept-Path/Subject-Kind/App-Id/Access-Token/Request-Id`;姓名/部门等非 ASCII 值用现有 percent-encoding;入站 header lower + 下划线→连字符归一后剥离整个 x-bisheng 等价类。X-Forwarded-Prefix/Proto/Host 由 proxy 覆盖,不信客户端。OBO 现已有消费者,不能继续引用旧“可无 token 放行”例外。 + +**④ manifest 消费**:形态归 F055;runtime/port/tier/database/egress/delivery/startup 由当前 schema 冻结后进入执行,见 F055 design §4.2③。新增业务 secret 名仅消费可信 formal 环境,不允许任何运行形态覆盖保留名。 + +**⑤ 环境变量**:`BISHENG_APP_DB_URL`、应用 base path、平台/模型/storage endpoint 与 operation-bound 凭据仍按 F086/F059 注入;本轮不改已有公开 SDK 变量名。正式、preview、probe 的身份/数据空间不可混用,构建进程不取得正式环境。 + +**⑥ SQLite / 日志**:DB URL 指向运行实例的 `/data/app.db`;Docker bind 与 K8s 独占 PVC 的宿主/集群映射由执行层持有。日志 `tail/since/keyword`、状态和数据动作先验 owner/租户;backend 只传意图,manager 执行备份/数据维护,经 backend 传快照。 + +### 4.3 关键模块职责 + +| 模块 / 落点 | 做什么 | 不做什么 | +|---|---|---| +| `app_runtime/domain/services/entry_authz_service.py` | 统一 OBO 配置检查与正式/预览判定 | 不接触容器、不匿名降级 | +| `bisheng/main.py:lifespan` | 运行时开启时输出脱敏启动诊断 | 不因此阻塞整个平台 | +| `runtime_manager/egress.py` + 新 host verifier CLI / unit | 同源渲染、宿主安装、只读验证、配对 canary 报告 | 不持有应用真实密钥,不清全局防火墙 | +| `app_runtime` 新 host isolation Repository/Service/Endpoint | 验证独立角色、保存证明、节点准入读取 | 不执行宿主命令、不访问 Engine | +| `RuntimeOperationService` / `RuntimeDeliveryCoordinator` | 部署、恢复、用户确认重建准入和原有围栏 | 不拥有 F055 密钥配置资产 | +| app-proxy / egress-proxy | 入口/出口数据面、受限投影;入口 allow 不缓存身份 | 不签 OBO、不自行上线 | +| `app_storage_service` / backend 对象存储适配 | 源码/附件/快照传输授权 | 不替 manager 执行 SQLite 迁移 | + +### 4.4 本轮增量字段与接口合同(拟实现) + +**① 入口**:正式 `/apps/{slug}`、预览 `/apps/_preview/{session}/...` 保留现有 app-proxy → backend 内部 HMAC 协议;拒绝体仅 `decision=unavailable, reason=identity_config_incomplete` 与已允许返回的最小上下文;不得带 `inject_headers`、`obo_token`、`obo_expires_at` 或 target。登录/不存在/跨租户/无权限/停用仍先返回对应旧决策,不因配置错误暴露对象存在。 + +**② 操作与身份**:RuntimeOperation 的 `scope_key/idempotency_key/generation/owner_epoch/deadline` 继续约束生命周期;K8s 额外 controller epoch、UID 与数据 writer 围栏。内部 HMAC 角色按 manager / entry / egress 隔离,传输受 TLS/受控网络保护;HMAC 只认证请求,不提供加密。F055 手动重建语义见其设计 §4.4③:保存不创建操作;rebuild_current 复用既有 deploy,running_environment_snapshot 只向受限内部调用提供当前正式环境 DTO;不写 F055 配置或 applied 状态。 + +**③ 宿主规则合同(拟新增)**: + +| 路径 | 规则与验证 | +|---|---| +| iptables | 自有 `BISHENG_APP_INPUT` / `BISHENG_APP_FORWARD` 链。宿主 INPUT 在其他宽泛 accept 之前跳转;FORWARD 由 DOCKER-USER 首部跳转。跳转匹配实际 app bridge 与源子网;自有链内只允许固定 egress IP/3128 的 ORIGINAL,以及源 app、`ctdir REPLY`、ESTABLISHED 的合法入口/探活响应,其余 DROP。`RELATED` 不宽泛放行 | +| nftables | 自有 `inet bisheng_egress`,同时具有 input / forward hook,优先级先于普通 filter 接受;匹配 bridge/源子网,允许条件与 iptables 同源生成。nft accept 仍需通过后续链,不能承诺绕过已有宿主拒绝策略 | +| 更新 | root 安装工具获取独占锁,先构建新规则并 dry-run 校验,再以 iptables-restore `--noflush` 或 nft 单事务切换;初始化跳转失败保持/恢复拒绝态,不先删全部规则再逐条添加。重跑不重复,卸载只移除有标识的跳转/自有链表 | +| 动态地址 | installer 通过 Engine 网络检查冻结 network ID / bridge / 子网 / egress IP、端口、宿主网关/发布地址。代理 IP 变化立即使旧配置证明不匹配,重新安装校验;不在 L3 中放行 hostname 或不受限端口 | +| 不支持 | IPv6/未知 family、Docker rootless/不可确认 bridge、不可识别防火墙后端先报告缺能力;不静默省略一部分网络。K8s 使用 F059 的 CNI 证明,不套用宿主 Docker 规则 | + +**④ 隔离证明与持续收敛(拟新增)**:安装工具与 systemd oneshot/timer 位于宿主,30 秒刷新,60 秒过期;首次安装、宿主重启、dockerd 重启后先验证再放行新部署。定时 unit 不仅验证:发现自有规则漂移后,在同一宿主独占锁下重建自有规则、读回并执行允许/拒绝 canary 配对;仅完全通过才重新签发 pass。恢复失败时由宿主工具针对该 node/network 的受管应用执行网络隔离;不能确认隔离时,通过受控 Engine API 停止这些应用执行体并读回状态,报告失败及资源 ID。禁止扩大到无本模块标签的业务容器,禁止因停止请求已发出就记录停止证明。backend 消费失败证明阻止新执行/路由,manager 沿原生命周期协议确认已停止或隔离的旧实例;未知 writer 不释放容量或删除 PVC/数据。manager 不获得 root/NET_ADMIN,宿主修复不运行在 backend。工具复用现有 Python Engine HTTP 客户端;需要宿主 root、相应 iptables/nft 二进制与受控 Engine API,不要求 docker CLI。 + +新增 `RuntimeHostIsolationReport`(F054 所有,平台级节点表):`id,node_id,engine_id,network_id,config_digest,rule_digest,revision,checked_at,valid_until,status,checks`,通用 JsonType;checks 仅结构与探测结果,不能含环境/用户流量。`POST /api/v1/internal/app-runtime/host-isolation-reports` 只接受独立、限定 node 的 `host-isolation` 服务角色;manager/entry/egress 无上报权。服务端分配接收时间、限制 TTL、递增 revision/nonce、验证节点与 engine/config 绑定,不信客户端自报无限有效期;重放/倒退拒绝。独立报告表避免 manager heartbeat 覆盖证明;不改 F059 现有摘要算法。 + +生产方为 root 安装脚本,信任边界是客户宿主管理员;不声称防住 root 伪造。报告只在**规则读回匹配 + 实际 canary 允许/拒绝配对**均成功后标 pass:无业务凭据/PVC 的受限 canary 在 app 网络,测试直连宿主网关、宿主服务地址、published address 被拒绝,同时入口 HTTP/WS/探活响应与经 egress 的平台 API 可达。所有探测端点由管理员固定,拒绝把请求参数当任意扫描目标;canary 有 UID/ID、期限和自有标签,未知资源不强删。 + +backend 在新 prepare/probe/preview/deploy/restore(含恢复和 F055 用户确认重建)的 create/claim/提交服务路由前验证新鲜证明及冻结配置一致性;失败给缺能力原因。stop/destroy/清理仍允许,既有应用不强制删除;其持续隔离由上述宿主 unit 恢复/隔离闭环负责,不能仅拒绝新部署却让存量实例继续直连宿主。证明过期时在途操作不发布新路由,可恢复地等待至原 deadline;不能绕过旧 writer 停止确认。安装报告通过不代替实际业务 API 的权限检查。 + + +## 5. 已知坑 / 反直觉事实 + +| 坑 | 后果 | 处理位置 | +|---|---|---| +| DOCKER-USER 不覆盖宿主 INPUT;internal 网络仍需实际验证 | 应用绕过代理直连数据库或管理端口 | `egress.py` INPUT+FORWARD 与 host verifier | +| 容器内 iptables 成功不等于宿主规则生效 | 假阳性准入 | 宿主 CLI / 独立报告角色 | +| 应用发出的入口响应源地址仍是应用子网 | 无 REPLY 例外会把正常 HTTP/WS/探活阻断 | 同源规则模型和真实配对测试 | +| 宽泛 ESTABLISHED 也包含以前建立的恶意 ORIGINAL | 新规则看似生效、既有绕过仍通 | ctdir 与连接方向断言 | +| `_issue_obo_token` 当前返回空仍 allow;现在已有 OBO 消费者 | 登录身份失效但请求被放行 | 正式/预览统一检查和签发器兜底 | +| 身份材料先生成后才拒绝 | 即使未转发也可能留下不应产生的头/日志 | 在 `_identity_material` 前检查 | +| manager API 编排权限仍高于普通服务 | 合并进程扩大入侵面 | socket proxy / RBAC / 角色隔离 | +| 版本回滚不等于 SQLite 回滚 | 旧代码运行新 schema 或误覆盖数据 | F055/F059 显式快照恢复 | +| 旧任务完成与真实环境验收不同 | 把静态/模拟误当上线证据 | 任务证据类型与固定 SHA/digest | + +旧坑编号的历史引用按 `dda16a6` 的本文件读取;不把其中过期的“尚无 OBO 消费者/manager 直连 MinIO”继续当现状。 + +## 6. 对外契约与依赖 + +### 6.1 我提供给别人的(Outgoing) + +| 提供 | 消费者 | 破坏风险 | +|---|---|---| +| 现有 App 状态动作、内部路由/entry 协议 | F055/F056、app-proxy | 改状态/错误决策会破坏显示和转发 | +| OBO 合法配置才签发;TTL/aud/app/tenant 绑定维持现有值 | F051 模型面、F055 能力总线、SDK | 放宽空 token 或共享 JWT secret 会破坏身份边界 | +| 持久操作围栏 + F055 用户确认重建/受限运行快照 | F055、Docker/K8s manager | generation/operation payload 缺失时应显式拒绝,不能旧版静默忽略 | +| 独立宿主隔离报告与节点能力原因 | 部署/运维/状态查询 | 把 manager heartbeat 当隔离证明会扩大信任面 | + +### 6.2 我依赖别人的(Incoming) + +| 依赖 | 形式 | 风险 | +|---|---|---| +| F086 持久操作 / F059 controller 与 writer 围栏 | HTTP、DB 与执行事实 | 旧 manager 不支持确认重建/敏感输出协议时拒绝 | +| Docker Engine / socket proxy;宿主 iptables-restore 或 nft;systemd timer | 宿主安装条件 | 无法证明 INPUT 或 family 时阻止新部署 | +| Redis nonce;平台 DB;部署密钥与 OBO 独立配置 | 基础服务 | 不可达或配置失效不能新发身份/伪造 pass | +| F048 PermissionService 统一授权入口 | Python application protocol | 不绕过 verified target 或复刻 OpenFGA | +| F055 密钥版本/注入契约;F056 审计读侧 | 领域协议 | 本模块只消费,不复制密钥表/查询规则 | + +## 7. 测试与可观测 + +- 单元/模拟只覆盖变化:INPUT/FORWARD 双后端渲染、方向、幂等、失败恢复、未知 family;证明角色/过期/重放/配置改变与部署拒绝配对;正式/预览三种坏配置与有效配置的决策、无身份/无成功访问副作用;proxy 曾获 allow 后 backend 改坏配置的下一次请求/WS 握手拒绝,route 缓存不干扰此 Gate。 +- 集成只验证新表/租户无关节点范围、迁移单 head、manager 用户确认重建复用围栏。DM8 仅类型/约束/迁移静态审查,不写成 DM8 真测。 +- 真实 E2E:在授权专用 Docker 环境先保存自有规则前后差异,执行 canary 直连宿主拒绝 + HTTP/WebSocket/探活 + 经 egress SDK/模型/附件允许;重跑、重启、精确卸载对其他网络无影响。OBO 使用已授权自然人 owner/可见非 owner/不可见/跨租户配对,正式与审批预览都验证。不得把失败的 TCP 连接当“防火墙拒绝”而不先证明目标监听可达。 +- 定向命令(编码后按实际文件名执行):`cd src/runtime-manager && uv run pytest tests/test_egress.py`;`cd src/backend && uv run pytest test/app_runtime/test_entry_authz.py`(补充测试以最终任务命名为准)。手动入口 `/apps/{slug}` 与 `/apps/_preview/{session}/`,运维 `python -m runtime_manager.egress firewall --print` 先看差异,再由 root 在专用宿主安装;未确认设计前不执行变更。 +- 可观测:`identity_config_incomplete`、`host_isolation_missing/stale/mismatch/failed`、node/operation/canary ID、规则 revision;不记录密钥、token、会话或完整环境。失效不刷屏,状态面保留当前失败原因。 + +## 8. 后续改进 / 不打算做的事 + +本轮不支持 Docker IPv6/rootless 网络;检测即拒绝,不默认为安全。不新增通用宿主防火墙产品;验证工具只覆盖托管子网。K8s CNI/跨节点/ARM 扩展留在原 Feature 的待验清单,不借安全收尾宣称全部通过。完整设计确认后拆增量任务,保留 T098/T099 原证据与关联。 + +## 修订历史 + +| 日期 | 变化 | 依据 | +|---|---|---| +| 2026-10-23(二次修订) | 按 AC-70 修正 OBO 配置不完整提示;保持原授权顺序和正式/预览同源 | 用户要求修订后复审,尚待设计确认 | +| 2026-10-23(简化协议同步) | F055保存不重启;消费用户确认重建与当前运行快照协议,不增加F055结果双写 | 用户已确认F055修订规格;F054自身设计未确认,B1仍待修 | +| 2026-10-23 | 刷新 F086/F059 后的当前快照;补 INPUT 隔离证明、OBO 拒绝设计;保持 D1–D15 与 §1–§8 编号 | 用户确认增量规格;静态核对基线代码,尚未实现/部署 | +| 2026-08-17~18 | 初版与早期 MVP 修订 | 历史正文见 `dda16a6`;历史测试不充当本轮证据 | + +### 2026-10-24 剩余设计问题复审 + +F054-B1 修订:宿主 unit 为规则漂移的实际消费者,先修复并配对验证,失败则仅隔离/停止受管执行体并上报;backend 不承担宿主 root 行为,也不把网络隔离冒充 SQLite writer 停止证明。长尾的多防火墙外部重载竞态、不可识别防火墙后端和非支持网络形态列入提测限制,不能标 pass 或静默放行。F055 用户确认重建与运行快照已编码,当前尚未进行真实部署验证;本设计的宿主隔离/OBO 安全增量仍待单独确认。 + + +### 2026-10-24 实现状态与交付合同补记 + +- 已确认设计进入实现。OBO 使用 `identity_config_incomplete`,同一 `_OBOSigningConfig` 快照完成检查/签发;验证器拒绝空白配置。正式/预览 allow 不缓存,拒绝与路由缓存保留。无尾斜线入口的规范化重定向是第二个 HTTP 请求,因而也重新判定。 +- 新增宿主报告、独立 HMAC role、Docker create/admit/claim/ready/route 门禁,legacy Engine create/start/build、路由和自检也读取证明;停止/清理不要求有效证明。K8s 沿用 F059。 +- `RuntimeHostIsolationReport.id` 直接使用 node_id 作主键(不再存重复 node_id 列),每节点只保存当前证明;接收时间与 60 秒 TTL 由服务器分配,revision 为 BigInteger。独立报告不随 manager 心跳续期。 +- 宿主实现位于 `runtime_manager/host_isolation.py`,安装材料在仓根 `deploy/runtime-manager/`;规则与 canary 必须运行在 Docker 宿主。安装示例覆盖 Compose 的 apps/build 两个网络,原默认 bridge 构建被拒绝。卸载先作废服务器证明,再确认本模块实例停止,然后移除自有规则。每个 Engine 仅一个宿主隔离单元。 +- 实现代码与本地定向验证已到位;宿主真实 iptables/nftables、失效收敛与 Docker/K8s 本次组合部署仍待提测。具体证据及边界见 closeout-validation.md,不据此宣称最终交付通过。 + +### 2026-10-25 部署兼容修复 + +116 的 `iptables-save` 把父链跳转参数规范为 `-s … -i …`,与提交时的 `-i … -s …` 等价。 +父链前 N 条仍按数量/位置校验,规则字段经同一 `normalized_iptables` 比较;提前 ACCEPT、额外条件、错误子网继续拒绝。 +这是已批准隔离合同的兼容修复,不放宽权限或新增绕过开关。正反定向回归见整合报告;真实宿主复验另记。 + +### 2026-10-26 Docker 控制入口热修 + +116 实测同一 canary 的 host HTTP200,而 manager/app-proxy 都 ConnectTimeout。根因:按应用 bridge 整段源地址执行出口拒绝时,也误拦在该 bridge 上的控制服务主动访问应用。host 工具新增可选 root-only ingress_containers;检查非托管、运行中的真实控制服务,将服务名和逐网络实际 IP 纳入既有签名 descriptor,只在 FORWARD 允许精确源 IP 到本子网的 ORIGINAL TCP。INPUT 拒绝和应用出口规则不变;canary 增加从已声明控制容器访问测试应用的实际验证。同地址的合法控制进程重建不改变绑定;地址变化、进程停止、替换为托管应用或网络变化均拒绝,重新生成期望 descriptor 后才准入。宿主与 manager 包须同时升级;这是既有控制入口合同的修复,不新增用户授权能力。 + +### 2026-10-27 浏览器应用入口跟随当前访问地址 + +用户指出发布页与应用教程的地址口径不一致:教程读 `window.location.origin`,发布页则把 `app_runtime.entry_base_url` 生成的 `entry_url` 原样展示。用户从另一个域名、协议或端口访问时,发布页会把访问者带回配置中的入口。 + +发布页现以浏览器当前 origin、前端部署前缀 `__APP_ENV__.BASE_URL` 和应用 slug 生成带尾斜杠的 `/apps/{slug}/`;文字、复制、打开和二维码共用一个值。空入口的既有显隐规则保留。backend 的 `entry_url` 字段及 CLI/API 地址不变,不把浏览器地址用于容器回调、模型接口、附件接口或隔离白名单。 + +旧版任务 T053、T065、T091 的“不能从 origin 拼”的实现约束由本次修订替代,其原因是开发环境缺少 `/apps` 代理,而非生产入口必须使用固定域名。现在平台 Vite 增加 `/apps/` HTTP/WebSocket 代理,开发者通过 `VITE_APP_PROXY_TARGET` 指向已部署的应用入口;默认复用 API 代理目标。若 API 目标只有 FastAPI,必须独立配置应用入口,不能认为 FastAPI 提供应用代理。未来若产品明确支持独立应用域名,应新增显式的域名选择口径,而不隐式混用当前页面和固定配置。 + +定向证据:发布页4例(文字/复制/打开/二维码一致、停用、部署前缀/相对后端地址、空入口)及真实 Vite 配置加载1例通过;仅验证此次展示与代理配置,没有宣称116前端镜像已更新。 + +### 2026-10-27 停用提示页返回应用广场 + +“返回广场”服务于访问应用的业务用户,目标为 client 的 `/workspace/apps/explore`(ExplorePlaza),而非 platform 构建页;不假设访问者有构建权限。原默认 `/workspace` 在116命中 platform SPA 后进入404,且即使进入client首页也不是应用广场。`app_proxy.config.DEFAULT_SQUARE_URL` 与页面渲染器共享正确路由,保留 `APP_PROXY_SQUARE_URL` 供自定义前缀部署覆盖;不改认证、应用状态或可见性。定向覆盖默认/空配置回退、自定义前缀和实际停用页链接,8例通过。 + +### 2026-10-29 管理视角及详情访问修复 + +依据用户本次十项缺陷要求,平台管理查询使用统一 F019 视角:非超管仅本租户,超管全平台或选中租户,JWT 人员归属不变。SQL 自动过滤与 UNION 手写过滤保持一致,background tenant_execution_scope 清除全平台标记,RPC/个人审批不受管理视角影响。平台详情、实例和发布状态允许同租户 F048 `edit`,与 managed 构建列表一致;CLI/MCP、数据、密钥写入/重建和原删除规则保持各自业务限制。 + +构建列表以已有批量用户查询投射 `owner_active`,不新增逐卡查询。托管应用卡片删除入口同时检查 F048 grant 与业务资格:创建人、平台超管可删除;创建人停用/移除时,租户管理员可沿既有清理逻辑删除。2026-10-30 用户确认超管可删除有效创建人的应用。详情危险操作区开放原创建人和平台超管,复用既有删除确认 hook。后端加载目标仍受当前租户管理视角过滤,`_require_deleter` 仅新增全局超管放行;不扩大租户管理员和普通授权用户资格。二次确认、线上不可删除、资产清理及权限投影继续生效。审计使用应用租户、实际操作者及其租户,原因明确区分 owner、平台超管、失效 owner 的管理员清理。 + +已知坑:部署结束后 RuntimeOperation.stage 会变成 completed,仍被 serving 路由引用;仅接受 serving 会误报无法比较环境变量。运行配置只读关联现在接受合法 serving/completed 组合,并继续校验实例、路由、version、generation、owner_epoch 和密文存在;失败终态不接受。116 一次性只读进程验证旧关联失败、新关联成功,不代表线上服务已更新。 diff --git a/features/v3.0.0/054-app-domain-runtime/spec.md b/features/v3.0.0/054-app-domain-runtime/spec.md new file mode 100644 index 0000000000..e11dfff399 --- /dev/null +++ b/features/v3.0.0/054-app-domain-runtime/spec.md @@ -0,0 +1,251 @@ +# Feature: 托管应用领域模型与运行时(compose 形态 + 统一入口) + +> **当前后继合同(F114,2026-10-30,本地实现,未部署)**:管理基础信息使用任一管理动作,数据/日志/内容使用 edit,生命周期、删除及授权分别使用既有六动作;原租户/状态/停止证明/二次确认/资产清理保持。 详见 [F114 已确认规格](../114-vibe-permission-actions/spec.md) 与 [验证记录](../114-vibe-permission-actions/verification.md)。下文保留原 Feature 的 AC 编号、当时口径及历史证据,不将历史通过记录当作 F114 部署证明。 + +> **2026-10-23 增量规格已由用户确认**:“确认规格,进入设计”。本次确认针对新增 48 条、修订 4 条 AC 的三份规格;对应批准内容及 SHA-256 留存在[规格评审记录](../../../docs/reviews/vibe-governance-closeout-spec-review-2026-10-23.json)。设计须单独评审确认,尚未开始此次增量编码。 + +> **本文档定位 — 纯 What(需求口径,不随代码漂移)** +> +> spec 只回答 **做什么 / 验收标准 / 不做什么**。所有 How(决策、数据流、字段、API、Service、前端、文件清单、性能指标)一律不写在这里, +> How 的唯一真相在 [design.md](./design.md) 与 [tasks.md](./tasks.md)。 + +**关联 PRD**: [docs/PRD/3.0-release/3.0 应用工场 PRD-1 专业开发者通道与应用运行时.md](../../../docs/PRD/3.0-release/3.0%20应用工场%20PRD-1%20专业开发者通道与应用运行时.md) **v2.0** §1.0(术语「托管应用」)/ §1.3 前提 4(双形态产品面一致)/ §3.0.1(承载面与导航)/ §3.0.2(应用状态机)/ §3.3 角色矩阵及注 / RT-01(统一入口与登录复用)/ RT-07(资产持久与显式删除)/ RT-08(稳定性承诺)/ GOV-01(`app` 资源类型注册部分)/ GOV-03(档位 → 运行环境限额的落地部分)/ GOV-04(访问记录留痕的来源)/ GOV-10(工场运行时层开关、compose 形态)/ DEV-04「托管运行契约」(运行时侧承诺)/ DEV-05(线上身份注入形态)/ NFR-1.3 / NFR-2 / NFR-3 / NFR-6;PRD-2 最小面 WB-13(运行日志)/ WB-06(数据面:查改导出);《3.0 应用工场 产品方案》§4.1 / §4.5 / §4.8(技术方案上游,spec 只取其对外承诺) +**同步记录**: 2026-08-17 初稿;同日按 `/sdd-review` 独立审查 28 条发现修订(去 How、补访问记录 / 预览入口 / 数据面「改」/ 状态筛选 / 日志口径 / 超管代行 / 标识规则 / 构建失败原因,理清与 F055 / F056 的归属) +**优先级**: P0 +**所属版本**: v3.0.0 +**依赖**: F049(`app` 资源的 owner 语义依赖其资源归属人:CLI 首发应用的 owner = 该 key 所属服务账号的资源归属人;本 Feature 自身不直接依赖密钥) +**批次**: B(工场运行时层);**MVP-114 首波**见 [../mvp-114-path.md](../mvp-114-path.md) §2 + +> **范围边界** +> +> 本 Feature 是批次 B 的地基:定义「托管应用」这个领域对象、把它注册进平台权限体系、提供 compose 单机形态的运行时与统一入口,并交付 platform 构建页的第三种类型与应用详情页壳。发布管线 / 审批 / 版本写入(F055)、广场与治理面(F056)、k8s 形态(F059)站在它上面。**技术选型(容器加固手段、嵌入式数据库、构建模板、代理算法)一律在 design.md,本 spec 只写对外可观测的承诺。** +> +> - **本次纳入**: +> - **托管应用领域模型**:App(应用标识、名称 / 描述 / 图标、owner、租户、类型 = 托管应用、应用态)、AppVersion(只增不改的版本记录:版本号、类型首发 / 迭代、提交时间、终态标注、代码快照引用、能力声明、注入配置、资源档位——**四者同属一个快照**)、AppInstance(运行实例:状态、健康、当前运行版本、执行体引用);**应用态状态机**(草稿 / 已上线 / 待上线(资源不足)/ 已下线 / 已删除,PRD-1 §3.0.2)及其**五个状态动作**:上线(含结果分支:成功 → 已上线 / 容量不足 → 待上线)、手动上线、下线、重新上线、删除——审批单态由 F055 拥有,F055 的管线在相应节点**调用**本 Feature 的状态动作 +> - **GOV-01 `app` 资源类型注册**:托管应用作为平台权限体系(F048 ReBAC)的一种资源类型;创建即 owner;可见范围按用户 / 部门 / 用户组授予;租户管理员因身份短路对本租户全部应用天然可见可管;**存量环境**(如 114)上新增资源类型的生效手段 +> - **运行时(compose 单机形态)**:编排能力由独立于平台后端进程、可单独启停的运行时服务承担,平台后端进程不持有编排后端的访问凭据;对外能力:按应用包与 `runtime` 取值构建可运行镜像(开发者与 AI 不提供 Dockerfile)、启动 / 停止 / 重启 / 销毁实例、查询状态与健康、读取运行日志、**启动探活**(供 F055 托管预检、上线终检、审批期临时预览实例复用)、**运行环境容量准入**(供 F055 上线终检与本 Feature 重新上线消费);实例按资源档位受 CPU / 内存上限约束、文件系统除平台指定的可写路径外只读、不可提权、网络受控、崩溃自动重启、进程存活但不健康时自动重建;**出站白名单**(运行期默认封禁一切出站,只放行平台 API 与应用包声明的域名;构建期只放行平台配置的包源);单应用单实例 +> - **统一入口 app-proxy**:`/apps/{应用标识}` 唯一入口,版本无关;复用平台既有登录方式——已登录 + 在可见范围内 → 免二次登录进入;无登录态 → 重定向平台登录页、登录后回跳原地址(含 query 与 hash);已登录无权限 → 统一「无权限」页;已下线 → 「已下线」页;已删除 / 从未上线 → 「应用不存在或未上线」页;未部署工场运行时层 → 引导页;**注入当前访问者身份与组织信息**(客户端伪造的身份头一律无效)+ 请求级短时访问凭据的注入通道(能力范围语义由 F055 定义);反代 HTTP 与 WebSocket;应用容器不可绕过入口直连;发布切换与崩溃恢复窗口内不落报错页(「发布中」「应用恢复中」过渡态);**审批期临时预览实例的访问入口**(仅审批人可达、注入审批人身份,实例生命周期由 F055 控制);**访问记录留痕**(谁在用:用户 / 应用 / 时间,供 F056 审计面) +> - **RT-07 资产持久与生命周期动作**:代码快照、每应用数据库、附件的真身恒在平台存储、运行实例只是可回收执行体;下线 / 重新上线(owner 或租户管理员,平台超管经租户管理视图代行、审计记超管本人;重新上线叠加容量准入);**显式删除**(owner 或平台超管;前置状态闸:已上线态入口置灰须先下线;二次确认不要求输入应用名、文案明示删除范围含代码 / 对话 / 应用生产数据;资产删除、入口链接与二维码呈「不存在」页;删除时通知 F055 取消在途审批单);存档随平台数据备份 +> - **RT-08 稳定性**:应用与平台、应用与应用运行隔离;单应用崩溃或资源触顶只影响自身并在 5 分钟内自动恢复;运行时服务重启不影响已运行应用;两个过渡态不落报错页 +> - **每应用数据库与附件供给**:每个应用一个平台供给的专属数据库,经与本地 `bisheng dev` **同名**的连接环境变量注入、标准库可读写、可备份可快照;附件存储由平台中介、按应用收窄、不进公共可读存储、不计租户存储配额;两者的 SDK 面随 F057 +> - **platform 构建页与应用详情页壳**(§3.0.1):构建 → 应用列表的类型筛选新增第三项「托管应用」;卡片(图标 / 名称 / 描述 / 创建用户 / 类型角标 / 应用态徽标 / 版本下拉〔只读〕/ 上下线开关 = 下线 · 重新上线);状态筛选覆盖应用态五值;卡片 ⚙️ 菜单对托管应用只出现「管理权限」与「删除」(**裁剪由本 Feature 实现**,因它是新类型卡片的一部分;F056 承接 GOV-01 验收 6 的回归验证);owner「我的应用列表」= 同一页按归属过滤视图;**应用详情页四 tab 壳**:发布(本 Feature 只保证 tab 存在 + 应用态徽标 + 入口链接与二维码;其余区块由 F055 填充、可见范围区由 F056 填充)· 数据(**WB-06 最小形态:应用自有数据的查看、编辑与导出**,编辑生产行需二次确认并计审计;导入随 PRD-2)· 运行日志(**WB-13**:只读运行日志)· 版本(列表由 F055 填充);无左侧对话区、无管家;**本册不提供托管应用的界面新建入口**(唯一创建路径 = CLI 首发;界面新建随 PRD-2 WB-01) +> - **GOV-10 工场运行时层部署开关**:整层可不装——未部署时构建页不出现「托管应用」类型、广场不出现托管应用(F056 消费同一开关)、`/apps/*` 呈统一引导页而非报错、平台其余功能零变化、无新增常驻负担;已部署 compose 形态时上述能力全部生效 +> - **GOV-03 落地部分**:档位规格 → 实例 CPU / 内存上限;档位规格调整自应用下一次发布 / 重新上线生效;单应用单实例、无实例数 / 并发调节 +> - **本次明确排除**: +> - **发布管线**(提交 → 冻结快照 → 密钥扫描 → 审批 → 上线终检)、**审批单态**、版本记录的**写入时机**与结构演进、**能力声明的冻结 / 审读 / 注入通道与收回错误态**、**资源档位实体与超管管理 tab**、**WB-14 发布面内容 / WB-15 版本与差异**、应用运行期凭据自动签发、审批期临时预览实例的**生命周期**(拉起 / 回收 / 超时)——随 **F055**(本 Feature 提供领域对象、状态动作、探活 / 容量 / 构建 / 拉起能力与预览入口路由供其调用) +> - **应用广场、GOV-01 授权交互(含发布面可见范围区的内容与行为)、审计查询面、GOV-07 界面通道验收、卡片 ⚙️ 裁剪的回归验证、事件触达接线**——随 **F056** +> - **k8s 集群形态**——随 **F059**;本 Feature 的编排接口须形态无关(候选 INV-33),但只交付 compose 后端 +> - **CLI**(F053);**MCP 应用数据 / 状态日志工具**(F052,消费本 Feature 的只读接口);**SDK auth / retrieve / storage**(F057) +> - **沙箱隔离档位升级(推荐档 / 加固档)与编排后端访问面的进一步收窄**(技术方案 §4.5)——本 Feature 只交付默认档;列入 design §8 后续,不在 MVP-114 首波、紧随其后 +> - **闲置回收、自动扩缩容、多机调度、形态间迁移**(PRD-1 §5.1) +> - **数据面的导入、SQL 查询、附件字段**——随 PRD-2 +> - **PRD-2 的对话外壳 / 管家 / 预览 / 代码 / 资源 tab;托管应用的界面新建入口(WB-01)** +> - **RT-09 iframe 嵌入安全升级**(顺延);**应用代码存档计入存储配额**(PRD-1 §5.2 顺延) + +--- + +## 1. 用户故事 + +作为 **普通业务用户**, +我希望 **在应用广场点开同事做的托管应用就能直接用、不用再登录一次,且它自动知道我是谁、我在哪个部门**, +以便 **像用平台里其它功能一样用它,不必为每个小应用记一套账号**。 + +作为 **开发者(owner)**, +我希望 **我的应用一旦上线就有一个长期不变的入口地址与二维码,代码和数据的真身在平台上、容器坏了会自己恢复,我需要时能一键下线、退役时能显式删除**, +以便 **放心把它发给同事,出问题时有可控的止血与退出路径**。 + +作为 **平台管理员 / IT**, +我希望 **每个托管应用都跑在资源受限、网络受控、与平台和其它应用隔离的执行体里,任何单个应用崩溃都不影响平台与其它应用;不需要工场的环境可以整层不装**, +以便 **在单机上安全地承载业务人员与开发者造出来的应用,同时不给不需要它的客户增加负担**。 + +作为 **租户管理员**, +我希望 **在构建 → 应用里像看工作流、助手一样看到本租户全部托管应用,能下线 / 重新上线、能进详情看日志与数据**, +以便 **治理面前本地造的与平台造的应用无差别**。 + +--- + +### 本轮补充用户故事(2026-10-23) + +作为平台运维,我希望应用只能经受控入口和出口访问平台,并在身份签发配置不完整时明确拒绝进入,以便部署配置错误或宿主网络路径不会绕过原有权限与隔离。 + +## 2. 验收标准 + +> AC 编号在本特性内唯一。括号内标注对应 PRD-1 条目与验收序号(如 `↔RT-01 验收 1`);与 F055 / F056 存在调用关系的条目注明「F0xx 调用 / 承接」。错误码仅作可观测行为引用,定义在 design.md。 + +### 2.1 领域模型与应用态状态机 + +- **AC-01** — THE SYSTEM SHALL 把托管应用作为与工作流 / 助手并列的独立应用类型持久化,每个应用具有**全局唯一**且**首发时确定、此后不变**的应用标识(入口地址与二维码据此生成)、名称 / 描述 / 图标、owner、所属租户与应用态。(↔§1.0 / RT-01 规则「入口地址不随版本变化」) +- **AC-02** — THE SYSTEM SHALL 使版本记录**只增不改**:每条含版本号、类型(首发 / 迭代)、提交时间、终态标注(已上线 / 被驳回 / 已撤回)、代码快照引用、能力声明、注入配置、资源档位;四者同属一个快照,任何写入方(F055)不得只改其中之一。(↔RT-05 需求描述) +- **AC-03** — THE SYSTEM SHALL 使应用态只在下列取值中流转:草稿 → 已上线(上线动作成功);已上线 ↔ 已下线(下线 / 重新上线);上线动作遇容量不足 → 待上线(资源不足)→(手动上线成功)已上线;草稿 / 待上线 / 已下线 → 已删除(终态);**已上线不得直接进入已删除**。(↔§3.0.2) +- **AC-04** — WHILE 应用处于已下线态, THE SYSTEM SHALL 允许 F055 落新的待运行版本但**不自动重新上线**;重新上线后新版本生效。(↔§3.0.2 / RT-07 验收 6;F055 AC-36 为管线侧调用) +- **AC-05** — THE SYSTEM SHALL 使迭代审批被驳回不改变应用「已上线」态:当前版本继续运行,仅待上线版本标记为被驳回。(↔§3.0.2;F055 AC-33 为管线侧调用) +- **AC-06** — WHEN 应用元信息(名称 / 描述 / 图标)被更新, THE SYSTEM SHALL 不改变应用态、不产生版本记录,并记审计。(↔RT-02 规则「元信息不属能力声明」) +- **AC-07** — THE SYSTEM SHALL 使每个应用有且只有一个 owner(自然人);CLI 首发应用的 owner 取值 = 该密钥所属服务账号的资源归属人(取值由 F055 AC-04 在首发管线中写入,本 Feature 只保证 owner 是唯一归属字段且不因归属人后续变更而追溯改变)。(↔DEV-04 规则;F049 定义 6 边界 d) +- **AC-08** — THE SYSTEM SHALL 使应用标识来自应用包声明(未声明时由平台按名称生成),全局唯一;首发时若与任一租户内既有标识冲突 → 拒绝首发并提示更换标识;名称可重复且可修改、修改不影响标识。(↔RT-01 规则 / DEV-04「发布输入以应用包为准」) + +### 2.2 权限资源类型(GOV-01 注册部分) + +- **AC-09** — THE SYSTEM SHALL 把托管应用注册为平台权限体系的一种资源类型:可对用户 / 部门 / 用户组授予可见范围;创建即 owner;租户管理员对本租户任一应用可查看并调整可见范围(既有身份短路,非本 Feature 新增)。(↔GOV-01 需求描述 / 验收 4) +- **AC-10** — WHEN 可见范围变更, THE SYSTEM SHALL 自被授权用户的**下一次请求**起生效于入口访问与广场可见性;不经审批、不进版本快照、计审计并记录变更人。(↔GOV-01 验收 2 / 5;授权交互面由 F056 承接) +- **AC-11** — THE SYSTEM SHALL 使首发上线后默认**仅 owner 可见**,直到 owner 或租户管理员设置可见范围。(↔GOV-01 规则) +- **AC-12** — IF 权限引擎不可用或评估失败, THEN THE SYSTEM SHALL 拒绝入口访问并返回明确错误页,绝不放行(INV-19 / INV-30 同向)。 +- **AC-13** — WHEN 在已有权限模型的存量环境(如 114)升级部署, THE SYSTEM SHALL 提供使 `app` 资源类型在该环境**生效**的运维手段(升级说明中列明步骤),且不影响既有资源类型的授权数据。(Discovery §5 风险 1) + +### 2.3 运行时编排(compose 单机形态) + +- **AC-14** — THE SYSTEM SHALL 使平台后端进程不持有任何编排后端的访问凭据(部署检查可核验),编排能力由一个可独立启停的运行时服务承担;平台后端只向其声明期望态并读取结果。(↔产品方案 §4.5 编排特权 / GOV-10 需求描述) +- **AC-15** — WHEN 收到构建请求(应用包 + `runtime` 取值), THE SYSTEM SHALL 在支持的取值集合(`python3.11` / `node20` / `static`)内构建可运行镜像,开发者与 AI 不提供 Dockerfile;`runtime` 取值不在集合内 → 拒绝并列出支持的取值;依赖安装或构建失败 → 返回可读的失败原因与失败阶段(供 F055 预检输出)。(↔DEV-04 托管运行契约「语言运行时」/ 流程①) +- **AC-16** — WHILE 构建期, THE SYSTEM SHALL 只放行平台配置的包源与平台自身的分发端点(供安装 bisheng-sdk 等平台发布件);WHILE 运行期, THE SYSTEM SHALL 默认封禁一切出站,只放行平台 API 与应用包声明的域名,其余出站连接(含直连 IP、UDP)一律被阻断。(↔DEV-04 契约「白名单外的外网地址」) +- **AC-17** — WHEN 启动实例, THE SYSTEM SHALL 按所选资源档位设置 CPU / 内存上限;应用只能写平台指定的可写路径(临时目录与数据目录)、其余文件系统只读;应用进程不得提权;注入平台约定的环境变量(应用数据库连接、附件存储访问句柄、应用标识等);实例不暴露可被 app-proxy 之外访问的端口。(↔GOV-03 验收 4 / 产品方案 §4.5 默认档 / DEV-04 契约) +- **AC-18** — THE SYSTEM SHALL 提供**启动探活**:拉起后在配置的超时内探测应用就绪;未就绪 → 判定失败并返回可读原因(供 F055 托管预检、上线终检、审批期临时预览实例复用)。(↔DEV-04 预检①) +- **AC-19** — THE SYSTEM SHALL 提供**运行环境容量准入**判定:拉起 / 重新上线前判定本机可用资源能否承载所选档位;不足 → 返回「容量不足」而非拉起失败;判定结果供上线动作(AC-65)与 F055 上线终检消费。(↔GOV-03 规则 / §3.0.2 待上线态) +- **AC-20** — WHEN 应用进程退出, THE SYSTEM SHALL 自动重启该实例;WHEN 实例进程存活但健康检查持续失败, THE SYSTEM SHALL 在检测窗口内重建该实例;两者合计使单应用在无人干预下 **5 分钟内**恢复可用。(↔RT-08 验收 2 / NFR-6) +- **AC-21** — WHEN 新版本上线切换, THE SYSTEM SHALL 先拉起新实例并通过启动探活,再把入口切到新实例,旧实例宽限退休;切换窗口内访问入口得到「发布中」提示而非报错页。(↔RT-08 验收 3) +- **AC-22** — IF 运行时服务进程重启或短暂不可用, THEN THE SYSTEM SHALL 保持已运行应用继续对外服务。(↔RT-08 / 产品方案 §4.5) +- **AC-23** — THE SYSTEM SHALL 提供实例状态、健康、当前运行版本与运行日志的只读读取接口,以及**运行环境状态**(容量、编排后端可用性、部署前置自检结果——k8s 形态由 F059 扩充探测项)的只读接口(平台超管可查,两形态同一接口),供应用详情页运行日志 tab、CLI `logs`(F053)与 MCP 应用状态 / 日志工具(F052)消费;三处取到的**内容范围一致**,权限判定各按其入口口径(详情页:owner 与本租户租户管理员;MCP / CLI:仅密钥归属人 owner 的应用)。(↔DEV-04 验收 12 / DEV-02 ③) +- **AC-24** — THE SYSTEM SHALL 使单应用单实例:不提供实例数与并发调节入口。(↔GOV-03 验收 1 / §5.1) + +### 2.4 统一入口与身份注入(app-proxy) + +- **AC-25** — THE SYSTEM SHALL 使每个已上线应用可经 `/apps/{应用标识}` 访问,该地址在迭代发布、下线 / 重新上线后保持不变;应用内相对路径与 WebSocket 均经该入口正常工作——WHEN 浏览器导航到**不带尾斜杠**的应用根路径 `/apps/{应用标识}`, THE SYSTEM SHALL 先永久重定向到带尾斜杠的形式再呈现应用文档,使应用产出的相对引用(`./assets/x.js`)以应用自身为基准解析。(↔RT-01 需求描述 / 规则;**2026-08-18 原号改写**:原文只要求「相对路径正常工作」而未写出达成它的必要条件,实现遂漏掉尾斜杠、托管应用白屏,见 §3 边界情况「应用根路径的尾斜杠」) +- **AC-26** — WHEN 已登录平台且在可见范围内的用户访问入口, THE SYSTEM SHALL 免二次登录直接进入应用。(↔RT-01 验收 1) +- **AC-27** — WHEN 无登录态的浏览器(含手机扫码)访问入口, THE SYSTEM SHALL 重定向到平台登录页(含平台已配置的 SSO 方式),登录成功后回跳原应用地址(含 query 与 hash)。(↔RT-01 验收 3) +- **AC-28** — WHEN 已登录但不在可见范围内的用户访问入口, THE SYSTEM SHALL 返回统一「无权限」页:含应用名与 owner、无访问权限文案、引导联系应用 owner 或**平台管理员**开通权限、「返回广场」按钮;本版无在线申请入口。(↔RT-01 验收 4;**2026-08-19 口径订正**:原文写「租户管理员」——app-proxy 是独立进程、读不到 `multi_tenant.enabled`,而标准装机默认单租户、Root 租户禁止授予租户管理员(19204),页面上写「租户管理员」等于指向一个永远找不到的人。兜底页一律用中性称呼「平台管理员」,见 §3 边界情况「兜底页的角色称呼」) +- **AC-29** — WHEN 在可见范围内的用户访问已下线应用的入口, THE SYSTEM SHALL 返回「已下线」页(应用名 + 已下线提示 + 引导联系 owner 或**平台管理员** + 返回广场);WHEN 任何用户访问已删除应用的存量链接 / 二维码或从未上线(草稿 / 待上线)应用的入口, THE SYSTEM SHALL 返回「应用不存在或未上线」页 + 返回广场按钮;两者均不落报错页。(↔RT-01 兜底页表 / 验收 5) +- **AC-30** — WHERE 工场运行时层未部署, THE SYSTEM SHALL 对任何 `/apps/*` 访问(无论登录与否)返回「本环境未启用应用工场」引导页(引导联系**平台管理员**),不返回 404 / 5xx。(↔RT-01 兜底页 / GOV-10 验收 2;**2026-08-19 口径订正**:原文「平台超管」是内部黑话,与 AC-28 / AC-29 统一为「平台管理员」——六张兜底页此前出现「租户管理员 / 平台超管 / 平台管理员」三种称呼) +- **AC-31** — WHEN 请求被转发到应用, THE SYSTEM SHALL 注入当前访问者的身份与组织信息(用户标识、姓名、主部门、主体类型〔自然人 / 服务账号〕等平台约定字段);应用内可据此显示当前访问者姓名 / 部门。(↔RT-01 验收 2 / DEV-05) +- **AC-32** — THE SYSTEM SHALL 在转发前使客户端携带的任何平台身份注入头无效——包括其下划线 / 连字符 / 大小写变体;伪造头对应用无效。(↔NFR-1.3) +- **AC-33** — THE SYSTEM SHALL 使应用容器仅经 app-proxy 可达;绕过入口直连不可行。(↔产品方案 §4.5 不变量①) +- **AC-34** — THE SYSTEM SHALL 在转发前为请求注入一个短时、与访问用户绑定、不可由客户端伪造的访问凭据句柄,供应用经平台能力总线 / SDK 以当前访问用户身份调用平台能力;它是会话级瞬时令牌(不持久化、不出现在任何界面),不属 INV-28 所指的密钥类凭据;其能力范围(能力声明 ∩ 用户权限)由 F055 定义。(↔产品方案 §4.5 OBO;GOV-05) +- **AC-35** — WHEN 通过入口建立 WebSocket 长连接, THE SYSTEM SHALL 在握手时确定该连接的授权有效期(不长于访问凭据寿命与全局最大连接时长),到期主动关闭;应用下线、可见范围撤销时主动断开该用户的连接。(↔产品方案 §4.5 不变量②) +- **AC-36** — WHEN 应用处于崩溃恢复窗口, THE SYSTEM SHALL 对入口访问返回「应用恢复中」提示而非报错页,恢复后自动恢复正常访问。(↔RT-08 验收 4) +- **AC-37** — THE SYSTEM SHALL 为审批期临时预览实例提供独立于 `/apps/{slug}` 的访问入口:仅该审批单的审批人可达(其他人得到无权限页),注入审批人本人身份,其余注入与转发规则同正式入口;实例的拉起 / 回收由 F055 控制。(↔RT-03 预览试用 / 验收 7–8;F055 AC-26–28 调用) +- **AC-38** — WHEN 用户经入口成功进入某应用, THE SYSTEM SHALL 留痕一条访问记录(访问用户、应用、时间),供 F056 审计查询面按对象应用可查;不记录请求级明细。(↔GOV-04 事件清单「访问记录」;F056 承接查询面) + +### 2.5 资产持久与生命周期动作(RT-07) + +- **AC-39** — THE SYSTEM SHALL 使代码快照、每应用数据库、附件的真身恒在平台存储;运行实例自身不保存任何唯一数据;实例回收 / 崩溃 / 重建后应用数据完整无损。(↔RT-07 验收 1) +- **AC-40** — THE SYSTEM SHALL 使除 AC-43、AC-44 明确允许的显式删除流程外不存在任何导致应用资产被删的操作路径(下线、闲置回收、实例崩溃、工场运行时层整体不部署均只回收执行体)。(↔RT-07 验收 2 / NFR-3) +- **AC-41** — WHEN owner 或租户管理员执行下线, THE SYSTEM SHALL 要求二次确认(明示入口与广场将呈已下线)、回收执行体、应用态置已下线、入口与广场呈已下线页、计审计;WHEN 执行重新上线, THE SYSTEM SHALL 先做容量准入(AC-19),通过后拉起当前版本并恢复访问、计审计,不足则保持已下线并提示成因;平台超管经租户管理视图可代行两者,审计记超管本人。(↔RT-07 操作表 / 验收 4;§3.3 矩阵注) +- **AC-42** — IF 应用处于已上线态, THEN THE SYSTEM SHALL 使删除入口置灰并提示「请先下线」(卡片 ⚙️ 菜单与详情页危险操作区同此);草稿、待上线、已下线态可直接删除。(↔RT-07 验收 3;F055 发布面危险操作区调用本状态闸) +- **AC-43** — WHEN 有删除资格的 owner 或管理员执行删除, THE SYSTEM SHALL 弹出二次确认(**不要求输入应用名**),文案明示将一并删除代码、对话历史与应用生产数据;确认后删除资产、应用态置已删除、存量入口链接与二维码呈「不存在」页、通知 F055 取消在途审批单、计审计。(↔RT-07 验收 3 / 5) +- **AC-44** — THE SYSTEM SHALL 允许 owner 和平台超管显式删除应用;平台超管不受 owner 账号是否有效的限制,仍受当前租户管理视角、AC-42 状态闸及 AC-43 二次确认约束,审计记录实际超管操作者、应用所属租户及删除原因。非 owner 的普通用户及租户管理员不得删除有效 owner 的应用,即使拥有资源删除授权也不能绕过此业务规则。 + > **2026-10-30 用户确认修订**:允许超管删除有效创建人的应用,保留二次确认和审计。2026-10-10 已确认的例外继续保留:owner 账号停用或不存在时,所属租户管理员也可沿既有清理流程删除。 +- **AC-45** — THE SYSTEM SHALL 使存档随平台数据一同备份(备份手册列出应用存档位置);THE SYSTEM SHALL 为每个应用供给一个专属数据库并以与本地 `bisheng dev` **同名**的连接环境变量注入,应用用标准数据库库即可读写,该库可随存档快照与备份;THE SYSTEM SHALL 为每个应用提供按应用收窄的附件存储句柄(支持上传 / 下载 / 列举 / 删除四操作、按应用隔离、越界访问拒绝、单文件大小上限为部署配置项;不进公共可读存储、不计租户存储配额),SDK storage(F057)与本地 `bisheng dev` 期以同名句柄同一 API 使用。(↔RT-07 规则 / DEV-05 ③ / DEV-07 / GOV-03 规则) + +### 2.6 稳定性(RT-08) + +- **AC-46** — WHEN 人为使某应用崩溃, THE SYSTEM SHALL 保持平台主功能与其他应用访问正常。(↔RT-08 验收 1) +- **AC-47** — WHEN 某应用耗尽其 CPU / 内存上限, THE SYSTEM SHALL 只限制或重启该应用自身,平台与其他应用不受影响。(↔RT-08 需求描述「运行隔离」/ GOV-03 验收 4) +- **AC-48** — WHILE 入口呈「发布中」或「应用恢复中」过渡态, THE SYSTEM SHALL 使页面自动重试并在实例就绪后自动进入应用,用户无需手动刷新。(↔RT-08 过渡态表) +- **AC-49** — THE SYSTEM SHALL 使本节稳定性验收用例(AC-46–48、AC-20–22)不含任何 compose 形态专有步骤,可原样在 k8s 形态执行(F059 复用同一用例;候选 INV-33)。(↔RT-08 规则 / §1.3 前提 4) +- **AC-50** — WHEN 运行时服务恢复运行, THE SYSTEM SHALL 在部署配置的对齐窗口内自动对齐期望态(重建缺失或不健康的实例),无需人工干预。(↔RT-08 / 产品方案 §4.5) + +### 2.7 构建页与应用详情页壳(§3.0.1 / WB-13 / WB-06) + +- **AC-51** — WHERE 工场运行时层已部署, THE SYSTEM SHALL 在 platform 构建 → 应用列表的类型筛选中新增「托管应用」项(与「工作流」「助手」并列);托管应用卡片显示图标 / 名称 / 描述 / 创建用户 / 类型角标 / 应用态徽标 / 版本下拉 / 上下线开关;状态筛选覆盖应用态五值(含「待上线(资源不足)」);复用既有搜索与标签筛选。(↔§3.0.1 / §3.0.2 引言 / §3.0.3 待上线态自查落点) + - **2026-08-18 收窄「应用态徽标」**:徽标只在**上下线开关表达不了的两个态**(草稿 / 待上线(资源不足))出现,已上线与已下线不再出徽标——同一张卡上开关已经说了一遍,第二遍是噪声。待上线(资源不足)尤其不能省:它是「开关是关的,但要手动上线才动得了」的唯一提示。状态筛选与详情页的应用态展示不受影响。 +- **AC-52** — THE SYSTEM SHALL 使托管应用卡片的上下线开关语义 = 下线 / 重新上线(AC-41);版本下拉只读展示当前运行版本与历史版本、不提供切换与回滚。(↔RT-05 验收 4) +- **AC-53** — THE SYSTEM SHALL 使托管应用卡片 ⚙️ 菜单只出现「管理权限」与「删除」两项,「创建模板」「复制」不出现;「管理权限」拉起平台通用授权弹窗;「删除」受 AC-42 前置状态闸约束且按 AC-44 删除资格可见(F056 承接 GOV-01 验收 6 的回归验证)。(↔GOV-01 ⚙️ 裁剪注) +- **AC-54** — WHEN 点击托管应用卡片, THE SYSTEM SHALL 进入应用详情页,含四个 tab:发布 · 数据 · 运行日志 · 版本;无左侧对话区、无管家;发布 tab 由本 Feature 保证存在并呈现应用态徽标、入口链接与二维码,其余区块由 F055(管线相关)与 F056(可见范围区)填充;版本 tab 列表由 F055 填充。(↔§3.0.1 / §1 分册说明) +- **AC-55** — THE SYSTEM SHALL 在「运行日志」tab 提供只读运行日志(应用运行输出与错误信息含错误栈;不含平台侧日志;不出现密钥值;可按时间范围查看、支持刷新与关键字筛选、有空态说明);owner 与本租户租户管理员可看,其他用户不可访问该 tab 与其接口(业务规则前置拦截)。(↔WB-13 / DEV-02 ③ 例外 / NFR-2) +- **AC-56** — THE SYSTEM SHALL 在「数据」tab 提供应用自有数据表的查看、编辑与导出(表清单 → 分页查看 → 单行编辑 → 导出);编辑生产行需二次确认并计审计;导入随 PRD-2;仅 owner 可访问(业务规则前置拦截)。支撑该 tab 的**每应用数据库服务端接口**(表清单 / 表结构 / 行数据读写,不含 DDL——表结构由应用包声明、平台建表)由本 Feature 提供,并供 F052 MCP 应用数据工具复用(同一 owner 收窄、写入同样计审计)。(↔§1 WB-06「查改导出」/ GOV-04 事件清单「生产数据行编辑」/ DEV-02 应用数据工具) +- **AC-57** — THE SYSTEM SHALL 使 owner 在构建 → 应用列表看到自己 owner 的托管应用(「我的应用列表」= 同页按归属过滤视图);租户管理员看到本租户全部托管应用。(↔§3.0.1) +- **AC-58** — WHERE 工场运行时层未部署, THE SYSTEM SHALL 使构建页不出现「托管应用」类型与任何托管应用卡片;升级前后角色配置面的菜单项与权限点数量不变(零新增菜单 / 权限点);本册任何形态下均不提供托管应用的界面新建入口。(↔GOV-10 规则 / GOV-07 验收 4) + +### 2.8 可选部署(GOV-10 工场运行时层) + +- **AC-59** — WHERE 工场运行时层未部署, THE SYSTEM SHALL 使平台既有功能回归测试全部通过、行为零变化、无新增常驻进程与定时任务。(↔GOV-10 验收 1 / NFR-4) +- **AC-60** — WHERE 工场运行时层已部署(compose 形态), THE SYSTEM SHALL 使运行时服务与 app-proxy 作为可独立启停的服务运行,其部署 / 升级步骤随平台部署脚本交付。(↔GOV-10 需求描述) +- **AC-61** — THE SYSTEM SHALL 把工场运行时层开关与开放能力层开关(F049 拥有)作为两个独立的部署级开关;两者任意组合下平台可正常启动。(↔GOV-10 三层部署形态) +- **AC-62** — THE SYSTEM SHALL 使工场运行时层开关的取值可被两个前端读取(构建页 / 广场 / 引导页据此渲染)。 + +### 2.9 资源档位落地与上线动作(GOV-03) + +- **AC-63** — WHEN 实例按某档位启动, THE SYSTEM SHALL 使实例的 CPU / 内存上限与该档位规格一致(可在运行环境中核验)。(↔GOV-03 验收 4) +- **AC-64** — WHEN 档位规格被平台超管调整(F055 管理 tab), THE SYSTEM SHALL 自该应用下一次发布或重新上线起按新规格生效,运行中的实例不受影响。(↔GOV-03 规则;F055 AC-48 为管线侧写入) +- **AC-65** — WHEN 上线动作被调用(F055 上线终检 / 手动上线), THE SYSTEM SHALL 先做容量准入(AC-19):通过 → 拉起并切流量、应用态置已上线;不足或拉起 / 探活失败 → 不拉起半可用实例、应用态置待上线并返回成因(资源不足 / 上线失败);WHEN 重新上线遇容量不足, THE SYSTEM SHALL 保持已下线并返回成因;**五个状态动作的每次执行与结果(含版本号与成因)均计审计**——本 Feature 是这些事件的写入方,F056 负责其在审计面可查。(↔GOV-03 验收 5 / §3.0.2 / GOV-04 事件清单「发布 / 删除动作」) + +--- + + +### 2.10 宿主访问隔离与 OBO 配置拒绝(T098、T099 收尾) + +- **AC-66** — WHERE 使用受支持的 Docker 单机或 legacy systemd 托管形态, THE SYSTEM SHALL 阻止应用绕过受控出口新建到宿主平台服务的连接,包括经应用网络网关和宿主发布地址访问数据库、backend、对象存储及宿主管理端口;应用经受控出口使用已授权的 SDK、模型和附件能力应继续可用,不因附件业务在 backend 而放开该宿主服务的全部接口。 +- **AC-67** — WHEN 宿主访问隔离生效, THE SYSTEM SHALL 保持统一入口的 HTTP、WebSocket、受控探活及其合法返回流量可用,且不得阻断平台其他业务网络;由应用主动建立的禁止连接不能冒充合法返回流量取得放行。 +- **AC-68** — WHEN 安装重复执行、运行服务重启或模块卸载, THE SYSTEM SHALL 分别保持隔离规则幂等有效,或只撤销本模块拥有的隔离规则;不得复制出重复放行规则、清空宿主全局策略或改变无关服务规则。 +- **AC-69** — IF 必需的宿主隔离无法应用、无法确认,或已启用的网络地址族未获得同等保护, THEN THE SYSTEM SHALL 明确报告该运行环境隔离不可用并拒绝把它作为可发布环境;不得只告警后放行,亦不得把未知旧写入者的强制删除当作修复隔离的方法。 +- **AC-70** — IF 工场运行时已启用且 OBO 签发配置缺失、仅为空白或与平台会话签名配置相同, THEN THE SYSTEM SHALL 对原本有权进入的正式应用访问返回“本环境配置不完整,请联系平台管理员”状态,不转发到应用、不下发可用身份材料,也不留成功访问记录。 +- **AC-71** — IF 预览访问发生相同 OBO 配置错误, THEN THE SYSTEM SHALL 同样拒绝进入预览;正式与预览入口都须保留既有登录、存在性、跨租户、访问权限及运行层未部署的判定顺序,不能借配置错误暴露调用者本来无权得知的应用信息。 +- **AC-72** — WHEN backend 启动且工场运行时已启用, THE SYSTEM SHALL 对上述 OBO 配置错误记录不含任何密钥值的错误诊断;运行时层未部署时不制造已启用该层的假象,正常访问仍遵守原未部署提示。 +- **AC-73** — WHERE OBO 使用正确且独立的签发配置, THE SYSTEM SHALL 保持现有正式/预览授权、身份注入、防伪头、短时凭据、撤权及 SDK/OBO 调用行为,并以允许与拒绝的配对验证证明本次收紧没有引入匿名降级。 + +## 3. 边界情况 + +> 只写期望的对外行为;机制与落点见 design.md §4 / §5。 + +- **应用标识与名称分离**:标识首发确定、全局唯一、不可改;名称可改、可重。入口地址、二维码、审计、CLI 都以标识为准。 +- **业务动作资格**:撤回在途审批单(F055)仅 owner;显式删除按 AC-44,平台超管可代行,租户管理员仅在 owner 停用或不存在时可清理;owner 离职的归属转移不在本版(PRD-1 §5.2)。这些「仅 owner」限制以及数据 tab / 运行日志 tab 的访问限制都是**业务规则前置拦截**,不能依赖权限运行时的判定(管理员在那里会被短路放行)。 +- **可见范围与授权是"谁能进",不是"谁能管"**:租户管理员因身份短路对本租户应用天然可管,不需要被授予可见范围;普通用户被授予可见范围只获得入口访问与广场可见。 +- **入口判定顺序与信息泄漏口径**:工场层是否部署 → 登录态 → 应用是否存在且曾上线(否则一律「不存在或未上线」页,草稿 / 待上线 / 已删除与真的不存在一视同仁)→ 可见范围(否则「无权限」页,可显示应用名与 owner——PRD 明示的引导信息)→ 应用态(已下线 → 「已下线」页)→ 转发。已下线页只对可见范围内用户呈现。 +- **应用根路径的尾斜杠是必要条件,不是美观问题**(2026-08-18 补,实测于 114):`/apps/foo` 与 `/apps/foo/` 到达同一页面,但作为相对 URL 的**基准不同**——文档在前者时 `./assets/x.js` 按 `/apps/` 解析成 `/apps/assets/x.js`,那是另一个应用标识,于是每个资源都 404、页面白屏。**这个失败在服务端完全不可见**:入口把两种形式都归一成 `/` 转发,应用规规矩矩返回 200,日志一片干净,唯一线索在浏览器 console。应用侧也无从规避——相对路径是技能包明示认可的写法,也是打包器与前缀无关的标准产物。所以补尾斜杠只能由入口做(AC-25),且只对 GET / HEAD 的应用根路径做:非 allow 分支呈现的是平台自己的页面、不解析相对 URL,API 调用也不按相对 URL 解析任何东西,给它们加一跳只有代价没有收益。 +- **未登录回跳必须保留原地址的 query / hash**:手机扫码链接常带参数,丢了参数应用打开即空白。 +- **过渡态是页面不是错误**:「发布中」「应用恢复中」在实例未就绪时呈现、自动重试;它们与四类兜底页互斥。 +- **崩溃自愈覆盖两类故障**:进程退出与进程存活但不健康——5 分钟上限对两类都成立。 +- **容量准入是硬闸门**:不足时不得拉起「先起来再说」的半可用实例;容量判定与档位限额同源。 +- **每应用数据库的形态承诺**:一个应用一个库、同名连接变量注入、标准库可读写、随存档快照可备份;具体存储引擎与卷形态属 design;单实例是本版既定约束(GOV-03),任何多实例 / 多机演进前须先评估该库的升档路径(PRD-1 §5.2)。 +- **出站控制是运行时承诺**:默认封禁、白名单放行;不因换隔离档位而变化。 +- **删除的范围明示**:删除托管应用会一并带走代码、对话历史与应用生产数据(用户填报的业务记录),比删一条工作流编排影响面大——确认文案必须点明;不要求输入应用名(对标工作流)。 +- **控制面与数据面分离**:运行时服务重启不影响已运行应用;恢复后自动对齐。 +- **形态无关的编排接口**:本 Feature 只交付 compose 后端,但对外能力语义(构建 / 启停 / 状态 / 日志 / 探活 / 容量)不得含 compose 专有概念,否则 F059 无法在不改产品面的前提下接入 k8s。 +- **未部署工场层 = 整层缺席**:不起运行时服务与 app-proxy、不加定时任务;构建页与广场按开关隐藏;`/apps/*` 仍要有引导页(由平台核心的极小回退承接,不算常驻负担)。 +- **数据面的「改」有护栏**:单行编辑二次确认 + 审计;导入与 SQL 查询不做;生产数据编辑不走版本记录。 +- **访问记录只记进入**:一次进入一条(合并窗口由 F056 定义),不记请求级明细,避免高频写放大。 +- **兜底页的角色称呼一律中性**(2026-08-19 补,随「单租户租户口径统一」):六张兜底页(无权限 / 已下线 / 不存在 / 未启用 / 恢复中 / 判定不可用)由 app-proxy **自渲染**,它是 `src/app-proxy/` 下的独立进程——既不读 `/api/v1/env`、也没有 `appConfig.multiTenantEnabled`,无法像 platform 那样按开关择词。而标准 docker 装机默认单租户(`multi_tenant.enabled` 缺省 false),Root 租户禁止授予租户管理员(19204),页面上写「请联系租户管理员」指向的是一个用户永远找不到的人。故这六张页统一用**「平台管理员」**,不为文案另拉一条开关传递链路;多租户部署下的规格性表述(角色矩阵、权限口径、审批人来源)仍用精确角色名,不受此约束影响。同理「平台超管」是内部黑话、不进用户可见文案。 + +--- + +### 2026-10-23 增量边界 + +- 本次仅关闭 T098、T099 的已有安全缺口,不改变应用登录、资源权限、审批预览人员或 SQLite 单写合同;隔离不可确认不构成强制清理未知 writer 的授权。 + +## 4. 待澄清决议 + +> 2026-08-17 用户授权全自动模式:以下各项按建议直接定案,记录理由供追溯;同日 `/sdd-review` 独立审查后增补决议-7 / -8。 + +- **[决议-1] F054 不再拆分为「编排后端」与「领域 / 界面壳」两个 Feature。** 理由:两者的对外契约互相耦合(应用态流转依赖编排器结果;详情页壳依赖实例状态接口),拆开会在 release-contract 表 3 引入互相依赖的两行;用 tasks.md 的 Wave 分层承接并行即可。**何时重新考虑**:F059 k8s 后端进场后若两个后端由不同团队实现,可把「编排后端」抽为独立 Feature。 +- **[决议-2] `app` 资源类型注册归本 Feature(而非 F056)。** 理由:入口访问的可见范围判定(AC-26 / AC-28)在本 Feature,没有类型注册它无法验收;F056 只做授权交互与广场过滤的验收。 +- **[决议-3] 出站白名单纳入本 Feature 范围但不在 MVP-114 首波。** 理由:它是 DEV-04 托管运行契约的运行时侧承诺,属本 Feature 的 What;114 首波先跑通功能链路,白名单紧随其后(tasks.md 单独 Wave),不允许被裁掉。 +- **[决议-4] app-proxy 注入的短时访问凭据由本 Feature 签发与注入,能力范围语义由 F055 定义;它是会话级瞬时令牌、不属 INV-28 密钥类凭据、不登记为领域对象。** 理由:注入通道是 app-proxy 的职责(每请求签发、绑定用户、防伪造);「能力声明 ∩ 用户权限」是能力总线语义;瞬时令牌不持久化、不出现在界面,掩码 / 哈希 / 软撤销那套对它没有意义。**何时重新考虑**:若 F055 / F057 需要该凭据可离线校验或跨进程持久,升格为领域对象并补 INV。 +- **[决议-5] 数据面本版做「查看、编辑(单行、二次确认 + 审计)、导出」,导入与 SQL 查询随 PRD-2。**(审查修订:原只做查看 / 导出,与 PRD-1 §1「查改导出」不符)理由:PRD-1 分册说明明写「查改导出」;编辑护栏沿用 PRD-2 WB-06 的二次确认 + 审计口径,两册不分叉。 +- **[决议-6] 应用详情页发布 tab 与版本 tab 的内容归 F055,可见范围区归 F056,本 Feature 只交付壳 + 应用态徽标 + 入口链接与二维码。** 理由:发布面区块与管线状态耦合;可见范围区是 GOV-01 授权交互的第二触发点;壳先落、内容随各自 Feature 是最自然的分工。 +- **[决议-7] 卡片 ⚙️ 菜单按类型裁剪由本 Feature 实现,F056 只承接 GOV-01 验收 6 的回归验证。**(审查修订)理由:裁剪是「托管应用」新类型卡片渲染的一部分,与类型筛选、卡片字段同一处改动;release-contract 表 3 与 mvp-114-path 已同步改口径。 +- **[决议-8] 应用态的全部写入(含待上线态)由本 Feature 的五个状态动作承担,F055 只调用不直写。**(审查修订)理由:release-contract 表 1 规则「非 Owner 不得出现修改行为」;F055 AC-31 / AC-32 的措辞为「调用上线 / 手动上线动作」。 + +--- + +## 5. 设计与实现(指针,不复制) + +> **本节刻意不写内容。** spec 不承载 How。 + +| 你想知道 | 去哪看 | +|---|---| +| 为什么这么实现(决策 + 备选 + 何时该推翻):编排器形态与访问面收窄、构建路线、每应用数据库引擎、app-proxy 剥离与 WS 生命周期、出站白名单实现、隔离档位 | design.md §3(技术路线锚点见《3.0 附录:纳管应用技术路线与架构选型调研》) | +| 关键约束(双 DB / 多租户 / 权限 / 编排特权 / 部署形态) | design.md §2 | +| 今天的数据流、模块职责、字段 / API / 文件名约定 | design.md §4 | +| 代码里看不出的坑(UNION 子查询租户注入、Catalog 范围表只写一次、cookie host-only、nginx 静态 conf、管理员短路 …) | design.md §5 | +| 对外契约(编排器意图接口、app-proxy 注入头、状态动作接口、预览入口)、依赖 | design.md §6 | +| 任务拆解、[MVP-114] 波次、文件清单 | tasks.md | +| 114 环境事实 | [../mvp-114-path.md](../mvp-114-path.md) §3 | + +--- + +## 相关文档 + +- 设计真相: [design.md](./design.md)(尚未编写) +- 执行与落档: [tasks.md](./tasks.md)(尚未编写) +- 版本契约: [features/v3.0.0/release-contract.md](../release-contract.md)(表 1 App / AppVersion / AppInstance 候选 → 本 Feature 定稿后转正;候选 INV-32 / INV-33 / INV-35 随本 spec 转正) +- 姊妹 spec: [../055-app-publish-pipeline/spec.md](../055-app-publish-pipeline/spec.md)(管线调用本 Feature 状态动作)· [../056-app-square-governance/spec.md](../056-app-square-governance/spec.md)(广场 / 授权交互 / 审计面) +- Spec Discovery: [features/v3.0.0/000-prd1-discovery/discovery.md](../000-prd1-discovery/discovery.md) §2.2 / §2.6 / §2.9 +- UI 参考: [features/v3.0.0/000-prd1-discovery/ui-demo/](../000-prd1-discovery/ui-demo/)(以 PRD 为准、demo 为参考) +- PRD: `docs/PRD/3.0-release/3.0 应用工场 PRD-1 专业开发者通道与应用运行时.md` v2.0;`docs/PRD/3.0-release/3.0 应用工场 产品方案.md` §4;`docs/PRD/3.0-release/3.0 附录:纳管应用技术路线与架构选型调研.md`(design 阶段的技术路线依据) diff --git a/features/v3.0.0/054-app-domain-runtime/tasks.md b/features/v3.0.0/054-app-domain-runtime/tasks.md new file mode 100644 index 0000000000..f872f16b3b --- /dev/null +++ b/features/v3.0.0/054-app-domain-runtime/tasks.md @@ -0,0 +1,1137 @@ +# Tasks: 托管应用领域模型与运行时(compose 形态 + 统一入口) + +> **2026-10-23 增量 SDD**:用户已确认本轮规格,并于 2026-10-24 确认 F054/F056 修订设计。旧任务与旧评审状态仅描述历史批次;不代表本次增量已拆任务、编码或验收。本轮只做必要定向验证,不跑全量测试。旧任务的行号、计数和实现阶段为历史记录,当前实现快照以 design.md 为准。 + +**关联规格**: [spec.md](./spec.md)(73 条 AC;已确认)· [design.md](./design.md)(D1–D15;本轮设计已确认;新接口合同 §4.4) +**版本**: v3.0.0 +**裁剪基准**: [mvp-114-path.md](../mvp-114-path.md) **§6 MVP-核心(预算受限版)** —— Wave 1–3 全部为 `[MVP-核心]`;Wave 4 = `[MVP-114]` 纵切紧随项(design §8 优先级 1–3);Wave 5 = release 必做项。**后置 ≠ 裁掉**(spec 决议-3 对出站白名单已明确要求) +**代码事实口径**: 本文所有 `文件:行号` 沿用 design.md(`3.0-vibe` HEAD `b63a320f2`,2026-08-17 由四份探查笔记 E1–E4 核实;路径以 `src/backend/bisheng/` 为根,前端另注 `platform/` = `src/frontend/platform/src/`、`client/` = `src/frontend/client/src/`,仓根路径显式写出)。行号会漂移、符号名不会——落地前以符号名重定位。 + +--- + +## 状态 + +| 步骤 | 状态 | 备注 | +|------|------|------| +| spec.md | ✅ 本轮增量已确认 | 2026-10-23 用户明确确认;批准内容 hash 见规格评审 JSON | +| design.md | ✅ 本轮增量已确认 | 2026-10-24 用户明确确认 F054/F056 修订设计;批准内容 SHA-256 `1762a7e504f032dcc218e554a7fb2748c5234d14d8575a60f98225321d8713dc` | +| tasks.md | ✅ 历史任务已拆解;本轮见文末增量 | 本文;`/sdd-review tasks` 独立审查 16 条已修订(补 7 个测试任务 T082a/T084a/T086a/T087a/T089a/T090a/T094a + 可观测任务 T097) | +| 实现 | 🚧 进行中 | Wave 1(T001–T017)· Wave 2 runtime-manager(T018–T031)与 app-proxy(T036–T045)· **Wave 2 backend 入口判定(T032–T035)** · **Wave 3 领域服务与 API(T046–T057)** · **Wave 3 构建页第三类型(T058–T061)** · **Wave 3 platform 前端(T062–T070)** 已完成;`uv run pytest test/app_runtime test/app_publish` = **484 passed / 7 skipped / 1 xfailed**(其中 `test/app_runtime` 单独 229 passed / 7 skipped;总数与 T057 那批的 346 不可直接相比——F055 同期在并发补测试);`test/workflow/test_flow_dao_tenant_isolation.py` 7 → 8 例全绿,`test/api` 20 例全绿。platform `pnpm lint` + `pnpm typecheck` 全绿,前端单测 3 文件 16 例全通过(`src/test/hostedApp{Api,State,CardComponent}.test.*`)。余 T072 / T074(部分)/ T075(**T071** 于 2026-09-10 按代码证据核实并勾选)。**2026-08-19 收口批**:勾上 **T073**(nginx 仓内两份同构,实现早于勾选);**T074 标 🟡 部分完成不勾**——compose 形态此前有三个真缺陷(app-proxy 环境变量名全错 / `bisheng-apps` 网络无人创建 / bind 路径用了容器坐标系),配置面已修正并新增 `docker/verify-app-runtime-compose.sh` 静态契约守卫 + `.github/workflows/app-runtime-quality.yml`,但**没有任何人真的用 compose 起过这两个 service**,逐条见该任务的实际偏差记录。⚠️ **这三个缺陷全部符合同一模式:systemd 形态恰好正确,所以掩盖了 compose 形态**——114 是 systemd,在 114 跑通不能证明 compose 跑得通,凡「两种部署形态」的任务都要分别验。**T062–T070 的联调与 E2E 仍未做**(后端契约现已齐备,可以开跑)。偏差处理见 design.md 顶部调整原则 + `docs/SDD-Guide.md` §3-§4;逐条偏差见各任务的「实际偏差记录」与文末汇总 | + +--- + +## 开发模式 + +**按 Wave 组织任务**: +- **Wave 1**(T001–T017)= 基础设施(三张表 / Alembic / 错误码 161 段 / `app_runtime` 开关 / 常量 / 审计登记 / arch-guard RULE-10 / conftest)+ **`app` 资源类型注册**(后端 8 处 + 前端 3 处 + 114 存量生效脚本)。这两组互不依赖,可并行。 +- **Wave 2**(T018–T045)= 两个**独立包**(`src/runtime-manager/`、`src/app-proxy/`,均在 `src/backend/bisheng/` 之外)+ backend 内部授权端点。runtime-manager 与 app-proxy 各自有独立的测试基础设施任务,两条线可并行(app-proxy 只依赖 §4.2 的 RPC 契约,不依赖对方实现)。 +- **Wave 3**(T046–T075)= backend 领域服务与 API(状态动作 / 元信息 / 读侧 / UNION 第三支与 6 组硬闸)+ platform 前端 + client 一处开关读取 + 114 部署增量与联调。 +- **Wave 4**(T076–T081)= `[MVP-114]` 紧随项:出站白名单双层 + docker-socket-proxy + WS 反代与三不变量。 +- **Wave 5**(T082–T097)= release 必做项:两个过渡态页 / 附件存储 / 数据面 WB-06 / 访问记录 / 预览入口 / 二维码 / `node20`·`static` 模板 / 备份手册 / AC-64 / 稳定性双形态验收 / E2E / 关键日志与指标。 + +每个任务标 `依赖:`,无依赖的可并行。 + +**编号后缀 `a` 的含义**:`T082a` / `T084a` / `T086a` / `T087a` / `T089a` / `T090a` / `T094a` 是 2026-08-17 tasks 审查补入的**测试任务**,为保持既有 T 编号稳定而用后缀区分。**后缀 a 的测试任务一律先于其配对实现任务执行**(写在文件里也物理排在前面);`依赖:` 仍是唯一的顺序真相。 + +**后端 Test-First**:测试任务先于配对实现任务,`覆盖 AC` 逐条列举(禁 `AC-01~AC-05` 范围写法)。基础设施任务(ORM / Alembic / 错误码 / Settings / 常量 / 审计登记 / conftest / 包工程骨架)无测试配对、排最前。后端单测放 `src/backend/test/app_runtime/`(不放 `test/` 根),`asyncio_mode=auto`;两个独立包的测试放各自包内 `tests/`。集成测试连 test 中间件(MySQL / Redis / OpenFGA),在 CI 跑。 + +**容器相关测试一律标「测试降级:需 docker,CI 中间件阶段 + 114 手动验证」**——runtime-manager 的单测用 fake docker 客户端断言**下发参数**(`HostConfig` / `NanoCpus` / `Memory` / `ReadonlyRootfs` / `SecurityOpt` / 无 `PortBindings`),真容器行为(build → run → probe → kill 自愈 → 限额核验)在 CI 的 docker 阶段与 114 上跑。 + +**前端**:Platform 任务附「手动验证」步骤(Playwright 未落地),Client 任务同理。platform **react-query 已被 lint 冻结**(坑 25 前置)→ 新代码用 `useTable` / `useInfiniteCursorTable` / 裸 `useState + useEffect`;**详情页 / 日志接口对非 owner 不得返回 403/404**(坑 25:platform 拦截器对 GET 会整页跳 `/403`),一律用业务码 161xx 或 `silent: true`。client 新读取 hook **不得 import recoil**(lint 冻结),用 react-query v4。新增 i18n key 三语(zh-Hans / en / ja)同 PR。 + +**自包含任务**:每个任务内联文件、逻辑、AC 覆盖;设计论证指向 design §X / D-x / 坑 n,不复制。 + +**编号 ≠ 严格执行顺序**:Wave 2 的两条包线(T018–T031 runtime-manager、T036–T045 app-proxy)与 Wave 1 的两组(基础设施、权限注册)均可并行;`依赖:` 是唯一的顺序真相。 + +**跨 Feature 副作用登记**(release-contract 表 1 / 检查项 17): +- **T002 / T089**(`core/database/tenant_filter.py:39` 的 `_TENANT_AWARE_MODEL_MODULES` 加项——design §4.3 要求 `{app, app_version, app_instance, app_access_log}` **四个**模块路径全部登记:前三个由 T002 落、`app_access_log` 由 T089 落,**两处任务缺一即漏登记**)· **T004**(`docs/constitution.md` C5 登记表 + **`features/v3.0.0/release-contract.md:98` 已分配模块编码表**,`app_factory` 由 `_待分配_` 落定为 161–164)· **T007**(`database/models/audit_log.py` 两处白名单 + platform `controllers/API/log.ts` 三处,审计对象归 F056 查询面、写入归本 Feature)· **T008**(`scripts/arch-guard.sh` 新增 RULE-10 + `docs/constitution.md` 锚点表——影响全仓后端写入)· **T089**(`release-contract.md` 表 1 新增领域对象 `AppAccessLog` 行) +- **T011 / T012 / T013**(改 F048 领域的 `MIGRATED_RESOURCE_TYPES` / `ACTION_RESOURCE_SCOPES` / `FIXED_CUSTOM_TYPES` / `GRANT_SUBJECT_RESOURCE_TYPES` / registry 组合根——**只增 `app` 一个值,不改既有类型行为**;模型 checksum 变化的存量影响由 T017 升级脚本承接,坑 3/4) +- **T059 / T060**(`database/models/flow.py` 的 `_build_apps_subquery` 加第三支 + `FlowType` 新枚举 35;`api/services/workflow.py` 的 `SUPPORTED_APP_TYPES` / `_FLOW_TYPE_TO_RESOURCE_TYPE` / 标签 4 处 + `group_resource.ResourceTypeEnum.HOSTED_APP=10`——**F056 广场的 `get_online_flows_page` 走同一道闸,直接受益,不要重复改**)。⚠️ **`_build_apps_subquery`(`flow.py:660`)共 4 个调用方**,加第三支会让托管应用同时流进另两条与构建页无关的路径:`get_all_app_by_time_range_sync:810`(→ `api/services/workflow.py:1039`,按时间范围取应用)与 `get_first_app:849`(→ `scripts/sync_increment_table.py:53`,**商业版增量同步**)——两者的产品口径须在 T059 显式确认(不是"顺带受益"),并跑既有回归 `src/backend/test/workflow/test_flow_dao_tenant_isolation.py`(覆盖这四个方法的租户隔离) +- **T063 / T065**(platform 共享组件两处:`components/bs-comp/cardComponent/avatar.tsx` 的 `AppAvator` 图标 map 按 `AppNumType` 分支,加第三类型即改**共享头像组件**;`components/bs-comp/cardComponent/index.tsx` 是 workflow / assistant 共用的**共享组件**,只加两个可选 prop、缺省行为不变。两者缺省行为均须逐像素不变;回归验证由 F056 承接 GOV-01 验收 6) +- **T072 / T073 / T074**(部署契约:systemd 单元 / nginx conf 两份同构 / compose service,运维仓 `bisheng-ops` 与产品仓 `docker/` 同批改,K10 / 坑 16) +- **F055 消费面**:T049(`stage_version`)· T051(五个状态动作 + `lifecycle_hooks`)· T053(`AppMetaService.update_meta`)· T006(`DEFAULT_TIERS` 供 F055 `ResourceTier` seed 读取)· T047(`orchestrator_client` 门面)——**F055 只调不直写**(决议-8),签名变更须同步 F055 tasks。 + +--- + +## Tasks + +### Wave 1 · `[MVP-核心]` 基础设施(无测试配对,排最前) + +- [x] **T001**: `[MVP-核心]` ORM `app` / `app_version` 模型 + DAO + **文件**: `src/backend/bisheng/database/models/app.py`(新), `src/backend/bisheng/database/models/app_version.py`(新) + **逻辑**: 按 design D8 建两表(继承 `SQLModelSerializable`)。`app`:`id`(str PK) / `slug`(str,**全局 `UniqueConstraint`**,跨租户唯一) / `name` / `description` / `logo` / `owner_user_id`(int) / `tenant_id`(int) / `state`(VARCHAR16 显式列) / `current_version_id`(str null) / `pending_version_id`(str null) / `create_time` / `update_time`;**主键必须是 str**(UNION 三支列类型要与 `Flow.id` / `Assistant.id` 一致)。`app_version`:`id` / `app_id` / `version_no`(int) / `kind` / `terminal_state`(null) / `code_object_key` / `manifest`·`capabilities`·`injections`(`JsonType`) / `tier_id`·`runtime`(显式列) / `image_ref` / `submitted_at`;**无 `tenant_id`**(K5 ②,隔离经 `app_id` 借道 `app` 行)。DAO:`AppDao.{aget, aget_by_slug, acreate, aupdate_state_cas, alist_by_owner}`、`AppVersionDao.{ainsert, aget, alist_by_app, amark_terminal}`——**`AppVersionDao` 不提供通用 UPDATE**(AC-02 只增不改,`terminal_state` 是唯一单列更新口);**每个按 `version_id` 起手的 DAO 方法签名强制带 `app_id`**(坑 31)。禁 `JSON_EXTRACT` / `JSON_CONTAINS`(K4)、禁批量 UPDATE / DELETE(K5)。 + **回滚**: 模型文件删除即回滚(表 DDL 在 T003)。 + **跨 Feature**: 落点在 `database/models/` 而非 `app_runtime/domain/models/` 是 arch-guard RULE-2 逼出来的(坑 27 / D8「模型落点」),**不要"顺手搬回模块内"**。 + **依赖**: 无 + +- [x] **T002**: `[MVP-核心]` ORM `app_instance` 模型 + DAO + 租户感知登记 + **文件**: `src/backend/bisheng/database/models/app_instance.py`(新), `src/backend/bisheng/core/database/tenant_filter.py`(`_TENANT_AWARE_MODEL_MODULES:39` 登记 `bisheng.database.models.app` / `.app_version` / `.app_instance`) + **逻辑**: `app_instance`:`id` / `app_id` / `tenant_id` / `version_id` / `phase` / `health` / `exec_ref`(执行体引用,compose 形态是容器名,**唯一允许出现形态特有值的字段、且只对内**)/ `started_at` / `restart_count` / `last_probe_at`;DAO `AppInstanceDao.{aget_by_app, aupsert, aset_phase}`。tenant_filter 登记只保证 metadata 被 import——**`app_version` 无 `tenant_id` 列,登记后仍不受自动过滤**(K5 ②、坑 31),在登记处写一行注释点明。⚠️ design §4.3 要求登记的是**四个**模块路径,第四个 `bisheng.database.models.app_access_log` 随该表一起由 **T089** 落(本任务只落前三个);在登记处留一行 `# app_access_log 见 F054 T089` 提醒,避免建表任务漏登记。 + **回滚**: 同 T001;tenant_filter 登记回退 = 删元组项。 + **跨 Feature**: 改 F048/核心库共享文件 `tenant_filter.py`,只增元组项,不改过滤逻辑。 + **依赖**: T001 + +- [x] **T003**: `[MVP-核心]` Alembic revision:三张表 DDL + **文件**: `src/backend/bisheng/core/database/alembic/versions/v3_0_0_f054_app_runtime_tables.py`(新) + **逻辑**: DDL-only(`core/database/alembic/AGENTS.md`):建 `app` / `app_version` / `app_instance` 三表;`down_revision` 取 `uv run alembic heads` 的唯一头;**不传 `mysql_charset` / `mysql_collate`**(DM8 双方言,C2 / K4);JSON 列用 `JsonType` 对应的方言类型(DM8 落 CLOB);索引:`app.slug` 唯一、`app(tenant_id, state)`、`app_version(app_id, version_no)`、`app_instance(app_id)`。 + **回滚**: `downgrade()` 按 `app_instance → app_version → app` 顺序 drop(无外键约束,仍按此序避免残留);**回滚前须先下线并删除全部托管应用**(否则容器与本机卷 `{data_root}/apps/*` 变成无主孤儿,只能靠 T029 的孤儿回收清理)——说明写进 revision docstring。 + **依赖**: T001, T002 + +- [x] **T004**: `[MVP-核心]` 错误码 161 段 + C5 登记 + 三语 + **文件**: `src/backend/bisheng/common/errcode/app_factory.py`(新), `docs/constitution.md`(C5 登记表:`app_factory` 由 `_待分配_` 落定为 161=F054 / 162=F055 / 163=F056 / 164=F059), `features/v3.0.0/release-contract.md`(**「已分配模块编码」表 `:98` 的 `app_factory` 行同批把 `_待分配_` 改成 161–164 并按 F054/F055/F056/F059 逐段注明**), `src/frontend/packages/locales/src/api_errors/{zh-Hans,en,ja}.json`(三语视为一组) + **逻辑**: 按 design §4.2 ⑥ 定义 `AppFactoryError(BaseErrorCode)` 与本期启用码:`16101`(应用不存在)/ `16102`(状态冲突,前态不符)/ `16103`(slug 冲突)/ `16104`(已上线不可删除,请先下线)/ `16105`(仅 owner 可执行)/ `16121`(编排器不可用)/ `16122`(构建失败)/ `16123`(`runtime` 取值不支持)/ `16124`(启动探活失败)/ `16125`(运行环境容量不足)/ `16141`–`16146`(入口六态)/ `16161`(无权查看日志)/ `16181`(工场运行时层未部署);`16162`(无权访问应用数据)留 Wave 5。三语文案落 `packages/locales`(生成物 `platform/public/locales/*/api_errors.json`、`client/src/locales/*/api_errors.gen.json` **由脚本生成、不手改**,CI `pnpm check-i18n` 校验)。**161 段确认空闲**(16x 只有 160=dataset;260 已被 F049 占用,K11)。**回写 release-contract 是硬要求**:F055 / F056 / F059 拆自己的段位时以该表为唯一权威来源,只改 constitution 不改 contract → 下游三个 Feature 无处可查(design 修订历史「需回写上游的两项」之①)。 + **跨 Feature**: `docs/constitution.md` C5 表与 `features/v3.0.0/release-contract.md:98` 编码表都是全版本共享清单,**同一次改动同批落**,只增本 Feature 段位、不动 260 行。 + **依赖**: 无 + +- [x] **T005**: `[MVP-核心]` 工场运行时层开关三段式(后端两段)+ `app_runtime` Settings + **文件**: `src/backend/bisheng/core/config/app_runtime.py`(新:`AppRuntimeConf`), `src/backend/bisheng/core/config/settings.py`(`multi_tenant` / `open_platform` 旁加 `app_runtime: AppRuntimeConf`), `src/backend/bisheng/api/v1/endpoints.py`(`get_env` 增 `app_runtime_enabled`) + **逻辑**: `AppRuntimeConf(enabled=False, manager_base_url='http://127.0.0.1:8091', manager_hmac_secret='', proxy_hmac_secret='', obo_secret='', obo_ttl_seconds=900, entry_base_url='', ws_max_lifetime_seconds=28800)`;⚠️ **2026-09-09 订正**:原清单里的 `data_root` / `reserve_mb` / `overcommit_ratio` / `build_reserve_mb` / `build_index_url` 五项已删除——它们从落地第一天起后端就零消费者(判定全在 runtime-manager,只读 `RTM_*`),而部署文档却在教运维改它们,照做「改了没反应」。现在配置里若仍有这几个键,后端启动时会告警并指向对应的 `RTM_*`。进程级 config.yaml、**不进 DB 热配置**(K12,`initdb_config` 有 100s Redis 缓存、语义是租户偏好)。与 F049 的 `open_platform.enabled` 是**同形态兄弟键、不合并**(AC-61 两开关任意组合可启动)。三个 secret 走 `!env` 或 Fernet(C6),不落代码。⚠️ **部署顺序**(坑 23):`load_settings_from_yaml` 对未知顶层键直接 `KeyError` 拒启 → **必须先发代码、后往 `config.yaml` 加 `app_runtime:` 键**,这条写进 T075 的 114 步骤。 + **粒度说明**(一任务 3 文件的理由):`AppRuntimeConf` 定义、`Settings` 挂载、`/api/v1/env` 暴露 `app_runtime_enabled` 是**同一个配置项的三段接线**,任一段单独落地都得不到可运行的开关(缺挂载 → 配置读不到;缺 env 暴露 → 两个 SPA 判不了「整层未装」,AC-62 不成立),且三段共用同一个键名,拆开只会制造键名漂移窗口。 + **依赖**: 无 + +- [x] **T006**: `[MVP-核心]` 常量与枚举:应用态 / 审计动作 / 出厂档位 + **文件**: `src/backend/bisheng/app_runtime/domain/constants.py`(新), `src/backend/bisheng/app_runtime/__init__.py`(新,空) + **逻辑**: `AppState` 枚举(`draft` / `online` / `pending_capacity`(待上线·资源不足)/ `stopped` / `deleted`)+ **合法跃迁表 `ALLOWED_TRANSITIONS`**(AC-03:草稿→已上线;已上线↔已下线;上线遇容量不足→待上线;待上线→已上线;草稿/待上线/已下线→已删除;**已上线→已删除禁止**);`AppAuditAction` Enum(`app.publish` / `app.publish_pending` / `app.manual_publish` / `app.stop` / `app.resume` / `app.delete` / `app.delete_hook_failed` / `app.meta_update` / `app.data_row_edit`,先例 `tenant/domain/constants.py:14-46`);`DEFAULT_TIERS`(轻量 0.5 vCPU / 512 MiB · 标准 1 / 1024 · 增强 2 / 2048)——**这是三档出厂规格的唯一代码来源,F055 的 `ResourceTier` seed 从本表读取落库**(design D11 对账口径,§6.1 Outgoing 契约)。 + **依赖**: 无 + +- [x] **T007**: `[MVP-核心]` 审计动作 lockstep 登记(后端 2 处 + 前端 3 处 + 三语) + **文件**: `src/backend/bisheng/database/models/audit_log.py`(`_UI_VISIBLE_V2_ACTIONS:178-203` + `_V2_NAMESPACE_TO_ACTION_PREFIX:209-213` 加 `app.` 族), `src/frontend/platform/src/controllers/API/log.ts`(模块下拉 / actions 数组 / `getActionsByModuleApi` switch 三处) + **逻辑**: 登记 T006 的全部 `app.*` action(含 Wave 5 才产生事件的 `app.data_row_edit`,**一次登记到位避免二次改同一处**)。坑 24:不进 `_UI_VISIBLE_V2_ACTIONS` 白名单 → **写库但「系统操作」页看不到**,AC-65 验收判不通过。三语 i18n key 同 PR。 + **跨 Feature**: `audit_log.py` 白名单是全平台共享清单,只增不改;查询面归 F056。 + **跨栈说明**(清单 7/18 禁「跨前后端任务」的**刻意例外**):后端 `_UI_VISIBLE_V2_ACTIONS` 白名单与 platform `log.ts` 的模块下拉 / actions 数组 / `getActionsByModuleApi` switch 是**同一份清单的两个副本**,是仓内既成的 lockstep 结构(坑 24);拆成两条任务只会制造"后端已写库、前端下拉里没这一项 → 审计页筛不出来"的中间态,且两条任务必须互为依赖、同 PR 合入,实质仍是一个原子改动。故本任务刻意跨栈;**新增行为一律只加 `app.` 族,不动既有任何一族**。 + **依赖**: T006 + +- [x] **T008**: `[MVP-核心]` arch-guard RULE-10:backend 禁编排依赖 + **文件**: `bisheng/../scripts/arch-guard.sh`(仓根 `scripts/arch-guard.sh`,现有 RULE-1~9 之后新增 RULE-10), `docs/constitution.md`(arch-guard 锚点表新增一行,并订正"8 条 RULE"的过时表述) + **逻辑**: RULE-10 = **只扫 `src/backend/bisheng/**` 的 `.py`**(坑 18:`.drone.yml:57,89,173,224,259,316` 是 CI 用法,扫进去会假阳性并被人顺手关掉),命中即 VIOLATION:`^(from|import)\s+(docker|kubernetes|aiodocker|kubernetes_asyncio)\b`、字面量 `/var/run/docker.sock`、`DOCKER_HOST`。这是 AC-14「部署检查可核验」的强制力来源(K1:今天只是纸面承诺,全仓 `import docker` = 0)。同批把 `src/backend/pyproject.toml` 的依赖检查写进 T046 的自动化断言。 + **回滚**: 删除 RULE-10 段落 + 回退 constitution 表行。 + **跨 Feature**: 影响全仓后端写入的 PostToolUse 守卫——落地前先在本地跑 `bash scripts/arch-guard.sh` 全量确认零存量违规(design K1 已核实为 0)。 + **依赖**: 无 + +- [x] **T009**: `[MVP-核心]` 后端测试基础设施 `test/app_runtime/conftest.py` + **文件**: `src/backend/test/app_runtime/conftest.py`(新), `src/backend/test/app_runtime/__init__.py`(新) + **逻辑**: fixtures:`tenant_admin_payload`(**非超管**的租户管理员,避开 admin 短路 ReBAC,坑 26)/ `normal_user`(本租户普通自然人,作被授权方)/ `chinese_name_user`(**姓名含中文**,专供注入头 percent-encoding 用例,坑 9)/ `app_factory`(直接经 DAO 落一行 `app` + 一行 `app_version`,参数化 state / owner / tenant)/ `fake_orchestrator`(monkeypatch `orchestrator_client` 的 **10 个方法**为可编程 stub——与 T047 门面逐一对齐:`build` / `build_status` / `deploy` / `stop` / `destroy` / `probe` / `admission` / `status` / `logs` / `runtime_status`,返回 §4.2 ① 的响应形状;**stub 数必须等于门面方法数**,漏一个的现象是该方法静默走真实 HTTP、测试在 CI 里连不上 8091 才暴露)/ `sub_tenant`(子租户 + 其管理员,跨租户断言用)/ `fga_down`(monkeypatch F048 判定抛 `PermissionServiceUnavailableError`,AC-12 fail-closed 用)/ `audit_sink`(捕获 `AuditLogDao.ainsert_v2` 调用)。**autouse fixture 清 `HTTP(S)_PROXY` / `ALL_PROXY` env**(缺 `socksio` 会整批误报 ERROR,memory `reference_local_backend_pytest_socks_proxy`)。fixture 体内**惰性 import** 业务 Service,避免尚未落地的模块让整包收集失败。 + **依赖**: T001, T002 + +### Wave 1 · `[MVP-核心]` `app` 资源类型注册(Test-First) + +- [x] **T010**: `[MVP-核心]` `app` 资源类型注册测试(单元 + 集成) + **文件**: `src/backend/test/app_runtime/test_app_permission_registration.py`(新) + **逻辑**: `test_catalog_action_effective_for_app`(`is_action_effective("app", a)` 对 `use` / `edit` / `manage_permission` / `delete` / `publish` / `unpublish` 六动作为真——**不新增 action code**,D9);`test_owner_gets_all_actions_on_create`(`authorize_created` 后 owner 的 `my-permissions` 含全动作)→ AC-09;`test_default_visible_only_to_owner`(首发落库后未授权的普通用户 `check_business_action("app", id, other, "use")` = deny)→ AC-11;`test_grant_use_to_user_department_group_then_allow`(分别授用户 / 部门 / 用户组后 allow)→ AC-09;`test_visibility_change_effective_next_request_and_audited`(撤销后下一次判定即 deny;PermissionGrant 写入记录变更人)→ AC-10;`test_tenant_admin_short_circuit_visible`(本租户管理员天然 allow,非本 Feature 新增)→ AC-09;`test_fga_unavailable_denies_not_allows`(`fga_down` fixture → 抛 `PermissionServiceUnavailableError` 被翻成 deny + `16146`,**绝不放行**)→ AC-12;`test_grant_subjects_five_endpoints_accept_app`(users / user-groups / departments-children / departments-search / **departments/{id}/path-tree** 五处对 `resource_type=app` 均不 403——坑 2,第 5 处最易漏)→ AC-09;`test_linsight_skill_style_half_registration_would_fail`(回归断言:若 registry 未注册则 `check_business_action` 抛 `InvalidCatalogActionError`,坑 32 的反面样板)→ AC-12。 + **测试降级**: 无(连 test 中间件 OpenFGA,在 CI 跑)。 + **覆盖 AC**: AC-09, AC-10, AC-11, AC-12 + **依赖**: T001, T009 + +- [x] **T011**: `[MVP-核心]` FGA 模型常量 + Catalog 策略两处 + **文件**: `src/backend/bisheng/core/openfga/authorization_model_f048.py`(`MIGRATED_RESOURCE_TYPES:32-42` 加 `"app"`;`RESOURCE_ACTION_SCOPES:55-78` 的 `use`/`edit`/`publish`/`unpublish` 加 `"app"`;`MODEL_VERSION` 升 `f048-v2`), `src/backend/bisheng/permission/domain/services/catalog_policy.py`(`MIGRATED_RESOURCE_TYPES:31-43` 加 `"app"`;`ACTION_RESOURCE_SCOPES:45-68` 逐 action 加 `"app"`) + **逻辑**: D9 的 8 处清单第 1–4 项。**两份 `MIGRATED_RESOURCE_TYPES` 必须同批改**(坑 1:漏前者 → 模型里没有 `app` type、写 tuple 400;漏后者 → `derive_action_release` 在每次 `_load_snapshot` 都跑,**Catalog 读取直接崩**)。`RESOURCE_ACTION_SCOPES` 是**死常量**(全仓无消费者),同步只为可读性,别在评审里当锚点(坑 5)。**明确不改**:`PARENT_TYPES` / `SYSTEM_SHARED_ACTION_TYPES` / `SYSTEM_OWNED_RESOURCE_ALLOWLIST` / `REGISTERED_ACTION_CODES` / `f048_source_inventory.py` / `permission_schema.VALID_RESOURCE_TYPES` / `core/openfga/authorization_model.py`(后两者是死代码)。 + **测试**: T010 相关用例通过(`is_action_effective` 一组需 T017 脚本或测试库新建 store 后才全绿,见 T016)。 + **覆盖 AC**: AC-09 + **跨 Feature**: 改 F048 领域常量,**checksum 变化 → 存量环境启动即 `migration_required=True`、全站权限 503**(K9);这条由 T017 的升级脚本承接,**不得单独部署本任务到存量环境**。 + **依赖**: T010 + +- [x] **T012**: `[MVP-核心]` 资源生命周期策略 + 授权主体端点放行 + **文件**: `src/backend/bisheng/permission/domain/services/resource_lifecycle_policy.py`(`FIXED_CUSTOM_TYPES:14-26` 加 `"app"`), `src/backend/bisheng/permission/api/endpoints/grant_subjects.py`(`GRANT_SUBJECT_RESOURCE_TYPES:28-40` 加 `"app"`) + **逻辑**: D9 第 5、8 项。`FIXED_CUSTOM_TYPES` 让 `app` 起始即 CUSTOM 模式(`linsight_skill` 就在 :24)。`GRANT_SUBJECT_RESOURCE_TYPES` 是 baseline「12 处」之外的**活闸门**(坑 2):5 个端点 5 处硬闸(`:89` users · `:113` user-groups · `:135` departments/children · `:152` departments/search · **`:168` departments/{dept_id}/path-tree**),漏改的现象是"授权弹窗能打开但一个主体都搜不到"、或"能搜到部门但点开树是空的"。 + **测试**: T010 的 `test_grant_subjects_five_endpoints_accept_app` 通过。 + **覆盖 AC**: AC-09 + **依赖**: T010 + +- [x] **T013**: `[MVP-核心]` F048 adapter + registry 注册 + **文件**: `src/backend/bisheng/app_runtime/domain/services/f048_app_permission.py`(新,模板 = `tool/domain/services/f048_tool_permission.py:1-80+`), `src/backend/bisheng/api/services/f048_permission_runtime.py`(`build_f048_resource_composition:128-196` 加 `adapters["app"]` + `registry.register("app", ...)`) + **逻辑**: D9 第 6–7 项。Loader:`load_permission_record` 读 tenant / owner / state / update_time + `runtime.get_permission_version`;Adapter:`resolve_permission_target` → `VerifiedPermissionTarget.from_business_service`(状态白名单 / 租户匹配 / `owner_user_id > 0` 三判)、`authorize_created(mode="CUSTOM", protected=True)`、`project_delete`。**只依赖 `permission.domain.schemas` 与 `permission_action_service.PermissionActor`,不 import OpenFGA 基础设施**(RULE-9)。`build_f048_resource_composition` 是 **API 与 worker 两个组合根共用的注册点**——漏了则 celery / linsight 进程判权直接 `RuntimeError("F048 resource registry is not configured")`(memory 已踩过)。 + **测试**: T010 全部通过。 + **覆盖 AC**: AC-09, AC-11, AC-12 + **依赖**: T011, T012 + +- [x] **T014**: `[MVP-核心]` 前端 Platform:`ResourceType` union 两处 + **文件**: `src/frontend/platform/src/controllers/API/permission.ts:6-16`, `src/frontend/platform/src/components/bs-comp/permission/types.ts:3-13` + **逻辑**: 两处 union **重复定义**(D9),必须同时加 `'app'`。`PermissionDialog` 组件本身无 per-type 分支,union 加完即可用。 + **手动验证**: 构建 → 应用(Wave 3 落地后)卡片 ⚙️「管理权限」能打开弹窗并搜到用户 / 部门 / 用户组。 + **覆盖 AC**: AC-09 + **依赖**: T013 + +- [x] **T015**: `[MVP-核心]` 前端 Client:`ResourceType` union 一处 + **文件**: `src/frontend/client/src/api/permission.ts:3-13` + **逻辑**: 加 `'app'`。F056 的广场与授权弹窗消费它,本 Feature 一并加避免 F056 再改同一行。 + **手动验证**: `pnpm typecheck`(从 `src/frontend/`)通过;client 无运行时行为变化。 + **覆盖 AC**: AC-09 + **依赖**: T013 + +- [x] **T016**: `[MVP-核心]` 存量环境生效脚本测试(dry-run / apply / verify 三态) + **文件**: `src/backend/test/app_runtime/test_upgrade_authorization_model.py`(新) + **逻辑**: 在一个干净 store 上模拟 M1 → M2:`test_dry_run_reports_plan_and_writes_nothing`(默认 dry-run,DB 与 store 零变化);`test_apply_publishes_model_idempotently`(重跑不再多写一个 M2——**按 checksum 查重**,照 `_find_remote_model`(`permission/migration/f048_runtime_storage.py:463-479`);⚠️ 该符号**不在** `core/openfga/discovery.py`);`test_apply_writes_release_rows_in_one_sql_txn`(`authorization_model_release` 新 ACTIVE 行 + M1 置 RETIRED + CURRENT catalog release 指针 + `permission_action_resource_scope` 补 `app` 行,**四步在同一 SQL 事务**,中途异常整体回滚);`test_step1_not_rollbackable_documented`(步骤 1 的 store 写入回滚不掉 → 回滚手段 = 指针指回 M1 + 撤 scope 行,断言脚本 `rollback` 子命令按此语义执行);`test_preflight_blocks_on_live_heartbeats`(`list_runtime_heartbeats()` 非空且无 `--allow-live` → 拒绝);`test_verify_asserts_read_side`(`current_catalog()` 通过 + `is_action_effective("app", 每个动作)` 为真);`test_noop_when_checksum_matches`。 + **测试降级**: 无(连 test 中间件 OpenFGA + MySQL,CI 跑)。 + **覆盖 AC**: AC-13 + **依赖**: T011, T012, T013, T009 + +- [x] **T017**: `[MVP-核心]` 存量生效脚本实现 + 升级说明 + **文件**: `src/backend/scripts/upgrade_f048_authorization_model.py`(新,惯例仿 `reconcile_f048_projection_operations.py` 的 `--apply` 默认 dry-run), `docs/architecture/12-multi-tenant.md`(或新建 `docs/api/` 升级说明章节:`app` 资源类型生效步骤) + **逻辑**: D9「114 存量生效脚本」四步:①(控制面 HTTP,**事务外**)按 canonical checksum 查重后发布含 `app` 的模型 M2;② `authorization_model_release` 新 ACTIVE 行(`model_version=f048-v2` / `predecessor_model_id=M1` / 重算 `required_relations_checksum`),M1 置 RETIRED;③ CURRENT `permission_catalog_release.required_authorization_model_release_id` 指向新行;④ 对 CURRENT release 的目标 action `INSERT permission_action_resource_scope(action_id, 'app')` 并重算 release checksum。子命令 `plan|apply|verify|rollback`。**不用 `force_write_model`**(坑 3:只写 OpenFGA 不写 SQL、不查重、生产禁用)。 + **回滚**: `rollback` 子命令 = ACTIVE 指针指回 M1 行 + 撤 scope 行 + 全进程重启;**M2 会永久留在 store**(无害孤儿:运行时只认 SQL pin 的那个)。 + **文档**: 升级说明必须写死三件事——**跑完必须全进程重启**(API / celery×3 / beat / linsight worker;心跳 15s 复核、TTL 45s,不重启的旧进程自行 fail-closed,坑 4)· **先发代码后加 `config.yaml` 键**(坑 23)· 存量环境**先 dry-run 再 apply**。 + **测试**: T016 全部通过。 + **覆盖 AC**: AC-13 + **依赖**: T016 + +### Wave 2 · `[MVP-核心]` runtime-manager 独立包(`src/runtime-manager/`) + +- [x] **T018**: `[MVP-核心]` runtime-manager 包工程骨架 + HMAC 服务端鉴权 + **文件**: `src/runtime-manager/pyproject.toml`(新), `src/runtime-manager/runtime_manager/{__init__,main,config,auth}.py`(新,主入口 FastAPI 监听 `127.0.0.1:8091`) + **逻辑**: D1:仓根独立包、**不在 `src/backend/bisheng/` 内**、不 import `bisheng`。`config.py` 读环境变量(`RTM_HMAC_SECRET` / `RTM_DATA_ROOT` / `RTM_NETWORK=bisheng-apps` / `RTM_RESERVE_MB` / `RTM_OVERCOMMIT_RATIO` / `RTM_BUILD_INDEX_URL`);`auth.py` 照抄 `sso_sync/domain/services/hmac_auth.py:58-110` 的签名串(`METHOD\nPATH\nraw_body`、`X-Signature` 头、`hmac.compare_digest`、**空 secret fail-closed**)为 FastAPI 依赖。docker 客户端在 `runtime_manager/docker_backend.py` 单点封装(MVP 直连 `/var/run/docker.sock`,D2-A;换 socket-proxy 只改 base URL)。 + **依赖**: 无 + +- [x] **T019**: `[MVP-核心]` runtime-manager 测试基础设施 + **文件**: `src/runtime-manager/tests/conftest.py`(新), `src/runtime-manager/tests/fakes.py`(新) + **逻辑**: `fake_docker`(可编程假客户端:记录 `create_container` 的完整 `HostConfig` / `Config`、模拟 `inspect` 返回 `State.Health` / `NetworkSettings.Networks[bisheng-apps].IPAddress`、模拟 `build` 的分阶段输出与失败)/ `rtm_client`(`TestClient` + 预置正确 HMAC 签名的请求辅助)/ `fake_meminfo`(可注入 `MemAvailable` / `MemTotal` / `nproc`)/ `tmp_data_root`。**所有需要真 docker 的用例统一打 `@pytest.mark.docker`**,默认跳过。 + **依赖**: T018 + +- [x] **T020**: `[MVP-核心]` 容量准入测试 + **文件**: `src/runtime-manager/tests/test_admission.py`(新) + **逻辑**: D11 双闸取与:`test_gate1_pass_gate2_fail_rejects`(可用内存充足但已承诺额度之和超 `total × 0.8` → 拒)/ `test_gate2_pass_gate1_fail_rejects`(额度够但 `MemAvailable - reserve_mb` 不足 → 拒;对应 114 上 available 曾 0.9G 的真实场景,K2)/ `test_both_pass_admits` / `test_purpose_build_uses_build_reserve_mb`(`purpose=build` 用 `build_reserve_mb`,失败阶段标 `build_admission`)→ AC-15 的失败阶段与 AC-19 同源 / `test_snapshot_fields_present`(返回 `mem_available_mb` / `committed_mb` / `total_mb` / `cpu`,供 AC-65 如实展示成因与 AC-23 运行环境状态复用)/ `test_cpu_gate_by_nproc_ratio` / `test_single_instance_admission_counts_running_only`。 + **覆盖 AC**: AC-19, AC-65 + **依赖**: T019 + +- [x] **T021**: `[MVP-核心]` 容量准入实现 + **文件**: `src/runtime-manager/runtime_manager/admission.py`(新), `src/runtime-manager/runtime_manager/api/intents.py`(新,`POST /v1/admission` 路由) + **实际偏差记录**: 期望态存储 `runtime_manager/desired_state.py` 由本任务先落最小可用版(`InstanceRecord` + 按 state 文件路径缓存的 `DesiredStateStore`,含 `committed()`)——闸②要读它,T025/T027 也要读它,不能等到 T029。**T029 只需在其上补三件事**:容器 label 双写恢复、启动全量对齐、孤儿回收;数据结构与 `get_store(config)` 入口不要重写。 + **逻辑**: 闸① `MemAvailable - reserve_mb ≥ 本次所需`;闸② `已运行实例 mem limit 之和 + 本次 ≤ total × overcommit_ratio`,CPU 按 `nproc × ratio`;两闸取与;返回 `{admitted, reason, snapshot{...}}`。读 `/proc/meminfo` 与 `os.cpu_count()`,已承诺额度从 T029 的期望态存储取。 + **测试**: T020 全部通过。 + **覆盖 AC**: AC-19, AC-65 + **依赖**: T020 + +- [x] **T022**: `[MVP-核心]` 构建(Dockerfile 模板渲染 + build 意图)测试 + **文件**: `src/runtime-manager/tests/test_build.py`(新) + **逻辑**: `test_supported_runtimes_dynamic_from_templates`(`SUPPORTED_RUNTIMES` 由本部署实际存在的模板目录给出,MVP 期恰为 `["python3.11"]`)/ `test_unsupported_runtime_rejected_lists_supported`(`runtime="go1.22"` → 拒绝且错误体列出支持取值)→ AC-15 / `test_template_render_deterministic`(同输入渲染字节一致;模板含 **非 root 用户 + read-only 友好布局 + `BISHENG_APP_BASE_PATH` wrapper**,D5.2)/ `test_build_args_inject_index_url`(`PIP_INDEX_URL` / `PIP_TRUSTED_HOST` 来自配置)/ `test_build_memory_limited_and_admission_checked`(build 前过 `purpose=build` 闸,`--memory` 生效,K2)/ `test_build_failure_returns_stage_and_tail`(四阶段 `fetch_source` / `render_dockerfile` / `docker_build` / `probe`,失败返回 `{stage, message, tail}`)→ AC-15 / `test_image_tag_never_reused`(`bisheng-app/{slug}:{version_no}-{version_id[:8]}`)/ `test_image_retention_keeps_current_and_previous`(保留当前 + 上一个版本镜像,AC-21 旧实例宽限退休要用)。 + **测试降级**: 需 docker,CI 中间件阶段 + 114 手动验证——本任务的单测用 `fake_docker` 断言 build 参数与阶段映射,真镜像构建在 CI docker 阶段与 T075 上验证。 + **覆盖 AC**: AC-15 + **依赖**: T019, T021 + +- [x] **T023**: `[MVP-核心]` 构建实现(模板矩阵 + builder) + **文件**: `src/runtime-manager/runtime_manager/builder.py`(新), `src/runtime-manager/runtime_manager/templates/python3.11/{Dockerfile.j2, entrypoint.sh.j2, healthcheck.py.j2}`(新) + **实际偏差记录**: ①模板由单文件变成**同目录 `*.j2` 全渲染**——入口 wrapper 与 HEALTHCHECK 探针脚本各自成文件(Dockerfile 里写 heredoc 在经典构建器上不可用,且 wrapper 需要可读可测);`discover_runtimes()` 仍以 `Dockerfile.j2` 是否存在判定,T092 加 `node20`/`static` 照加目录即可。②Dockerfile **只用经典构建器兼容语法**(无 `COPY --chmod`、无 heredoc)——docker-py 的 `build` 走的就是经典构建器,BuildKit-only 语法在 114/信创基线上会以"语法错误"形态失败。③四阶段中 `probe` **不在 build 管线内执行**:探活需要已拉起的实例=`POST /v1/intents/probe`(T027),F055 预检在 backend 侧组合 build→probe(保持两个意图正交,预览实例才能只用 probe 不带构建)。④构建源码由 backend 传**预签 URL `code_url`**(`code_object_key` 仅作日志/溯源)——manager 不持平台凭据,见返回消息「需 backend 配合」清单。 + **逻辑**: D3:平台持有 Dockerfile 模板,开发者与 AI 不写 Dockerfile(PRD-1 DEV-04 明禁)。`POST /v1/intents/build` → 从 MinIO 拉 `code_object_key`(由 backend 传预签 URL,manager 不认平台凭据)→ 渲染模板 → `docker build`(限 `--memory`)→ 分阶段收集日志 → `GET /v1/builds/{build_id}` 查状态。安全基线(非 root、`no-new-privileges` 友好、无 shell 入口)**由模板统一落,开发者改不了**。`node20` / `static` 模板见 T092。 + **测试**: T022 全部通过。 + **覆盖 AC**: AC-15 + **依赖**: T022 + +- [x] **T024**: `[MVP-核心]` 实例生命周期与容器规格测试 + **文件**: `src/runtime-manager/tests/test_lifecycle.py`(新) + **逻辑**: 断言 `create_container` 下发参数:`test_tier_limits_applied`(`NanoCpus` / `Memory` 与档位一致,**限额在创建时固化、不做在线 update**,AC-64 的前提)→ AC-63 / `test_readonly_rootfs_and_tmpfs`(`ReadonlyRootfs=true`、`/tmp` tmpfs、`/data` 是唯一可写持久路径)→ AC-17 / `test_no_new_privileges`(`SecurityOpt` 含 `no-new-privileges`)→ AC-17 / `test_no_published_ports`(`PortBindings` 为空——绕过入口直连不可行)→ AC-33 / `test_env_injection_names`(`BISHENG_APP_DB_URL=sqlite:////data/app.db` / `BISHENG_APP_DB_PATH` / `BISHENG_APP_ID` / `BISHENG_APP_SLUG` / `BISHENG_APP_VERSION` / `BISHENG_PLATFORM_API_BASE` / **`PORT` 与 `BISHENG_APP_PORT`(design §4.2 ⑤ 两者并列,值均须等于 manifest `port`——断言两个名字都在、且取值一致;只断言 `PORT` 会让缺失的别名拖到 F053 `bisheng dev` 本地开发侧才暴露)** / `BISHENG_APP_BASE_PATH=/apps/{slug}`——**F053 `dev` 同名注入**,§4.2 ⑤)→ AC-17, AC-45 / `test_restart_policy_unless_stopped`(**不用 `always`**:下线是显式 `docker stop`,`unless-stopped` 语义正好是"显式停了就别自愈")→ AC-20 / `test_healthcheck_params`(`interval=10s` / `timeout=3s` / `retries=3` / `start_period` 按档位 20–60s)/ `test_single_instance_per_app`(同一 app 重复 deploy 不产生第二个长驻实例;无实例数 / 并发入参)→ AC-24 / `test_volume_survives_stop_and_recreate`(stop / rm / run 后 `/data` 卷不动,数据完整)→ AC-39, AC-45 / `test_destroy_purge_volume_flag`(`purge_volume=false` 保卷、`true` 才删)→ AC-40。 + **测试降级**: 需 docker,CI 中间件阶段 + 114 手动验证——单测用 `fake_docker` 断言下发参数,`docker inspect` 的真实核验在 CI docker 阶段与 T075 步 1 上做。 + **覆盖 AC**: AC-17, AC-20, AC-24, AC-33, AC-39, AC-40, AC-45, AC-63 + **依赖**: T019, T021 + +- [x] **T025**: `[MVP-核心]` 实例生命周期实现(deploy / stop / destroy) + **文件**: `src/runtime-manager/runtime_manager/lifecycle.py`(新) + **实际偏差记录**: ①探活以 `prober` 依赖注入接入(默认在 `_get_prober()` 里惰性 import T027 的 `ProbeService`)——T024 的用例注入替身,故本任务落地时不依赖 T027 已存在。②`§4.2 ⑤` 的 `BISHENG_APP_VERSION` 取**版本号**(`str(version_no)`,与 `bisheng dev` 无 version_id 的本地形态对齐),另加 `BISHENG_APP_VERSION_ID` 给平台侧版本主键;F053 注入同名两个变量。③平台保留变量名(`BISHENG_APP_*` / `BISHENG_PLATFORM_*` / `PORT`)**覆盖**调用方传入的同名 env——应用不能改写自己的笼子接线。④新增 `BISHENG_APP_HEALTH_PATH`(模板 HEALTHCHECK 脚本读它,探活路径只定义一处)。 + **逻辑**: `POST /v1/intents/{deploy,stop,destroy}`。deploy = 过容量准入 → 以新版本镜像起**新容器**(容器名带 version 后缀,与旧容器并存)→ 交 T027 探活 → 更新路由条目 → 旧容器**宽限 30 秒**后 stop + rm(D4;30s ≫ app-proxy 的 3s 路由缓存,是 AC-21 不落 502 的真正理由,D5.1)。卷 = 宿主 `{data_root}/apps/{app_id}/db/` 挂容器 `/data`(K6:SQLite WAL 绑定单实例 + 本机卷,**绝不上网络存储**)。网络 = `bisheng-apps` bridge、**不 publish 端口**。 + **测试**: T024 全部通过。 + **覆盖 AC**: AC-17, AC-20, AC-24, AC-33, AC-39, AC-40, AC-45, AC-63 + **依赖**: T024, T023 + +- [x] **T026**: `[MVP-核心]` 启动探活 + 路由表测试 + **文件**: `src/runtime-manager/tests/test_probe_and_route.py`(新) + **逻辑**: `test_probe_ready_within_timeout` / `test_probe_timeout_returns_readable_reason`(未就绪 → `{ready:false, reason}`,供 F055 预检输出)→ AC-18 / `test_probe_standalone_image`(临时形态入参 `{image_ref, env, port, health}`,供 F055 预检与预览实例复用)→ AC-18 / `test_route_returns_bridge_ip_port`(`GET /v1/apps/{id}/route` → `{upstream: "http://:", version_id, generation}`——**宿主可达、外部不可达**,两形态同一机制;坑 30:114 上 app-proxy 是宿主 systemd 单元、解析不了容器名)→ AC-25, AC-33 / `test_route_generation_bumps_only_after_probe_pass`(新容器**探活通过后**才原子换路由并 `generation+1`)→ AC-21 / `test_route_404_when_no_instance`。 + **测试降级**: 需 docker,CI 中间件阶段 + 114 手动验证。 + **覆盖 AC**: AC-18, AC-21, AC-25, AC-33 + **依赖**: T019, T024 + +- [x] **T027**: `[MVP-核心]` 探活与路由表实现 + **文件**: `src/runtime-manager/runtime_manager/probe.py`(新), `src/runtime-manager/runtime_manager/routing.py`(新), `src/runtime-manager/runtime_manager/api/routes.py`(新,`GET /v1/apps/{app_id}/route`) + **实际偏差记录**: ①探活多一条**早退**语义:容器已退出立即判失败并带 exit code,不空等满超时(最常见失败就是启动命令秒崩,等满 90s 对使用者无价值)。②`probe_image` 的临时实例 `/data` 挂 **tmpfs 而非 bind**、`RestartPolicy=no`——预检绝不能碰到任何真实应用的数据,且会自愈的探针等于永不失败。③`deploy` 端点的端到端用例落在 `tests/test_probe_and_route.py`(该处才是整条链 admission→create→probe→route 首次齐备)。 + **逻辑**: `POST /v1/intents/probe`(按 manifest `port` + `health.path` 轮询至就绪或超时);`GET /v1/apps/{app_id}/route`(从 `docker inspect` 取 `NetworkSettings.Networks[bisheng-apps].IPAddress` + 端口,配合期望态存储的 `generation`)。**`app_instance.exec_ref` 是平台侧审计 / 排障引用、不是路由依据**——路由的唯一真相在 manager 的期望态存储(D5.1)。 + **测试**: T026 全部通过。 + **覆盖 AC**: AC-18, AC-21, AC-25, AC-33 + **依赖**: T026 + +- [x] **T028**: `[MVP-核心]` reconcile 循环与期望态存储测试 + **文件**: `src/runtime-manager/tests/test_reconciler.py`(新) + **逻辑**: `test_missing_container_recreated`(期望态有、实际无 → 拉起)/ `test_unhealthy_two_rounds_rebuilds`(`unhealthy` 连续 2 轮 → stop → rm → run,**卷不动**;坑 17:docker 单机 healthcheck 与 restart policy **无联动**,不补这一段第二类故障永不恢复)→ AC-20 / `test_orphan_container_reclaimed`(无期望态的 `bisheng-app-*` 容器被回收)/ `test_recovery_budget_within_5min`(用注入时钟断言分解预算:healthcheck 判 unhealthy ≤30s + reconcile 感知 ≤30s〔2×15s〕+ 重建拉起并探活 ≤90s = ≤2.5 分钟 < 5 分钟)→ AC-20 / `test_manager_restart_does_not_touch_running_containers`(重启期间不 stop 任何容器,容器存活依赖 dockerd 而非 manager)→ AC-22 / `test_startup_full_reconcile_from_labels`(期望态存储 = 本机状态文件 + 容器 label **双写**,状态文件丢失时从 label 恢复)→ AC-50 / `test_one_app_oom_does_not_affect_others`(模拟一个实例被 OOM kill,其它实例期望态不受影响、不被连带重建)→ AC-47。 + **测试降级**: 需 docker,CI 中间件阶段 + 114 手动验证——真 `docker kill` / 健康端点改 500 的自愈计时在 T075 步 6 与 T095 上做。 + **实际偏差记录**: 18 个用例(含 1 个 `@pytest.mark.docker` 占位)。①除清单 7 个外补了 5 个**反向用例**——`test_foreign_and_probe_containers_are_never_touched`(114 同机的 onlyoffice / rabbitmq 与探活临时实例一律不动)、`test_other_apps_container_is_not_reclaimed`、`test_retiring_container_within_grace_is_not_reclaimed`(30s 宽限期内的旧实例不能被回收,否则 AC-21 不落 502 的理由被 reconciler 自己毁掉)、`test_unhealthy_that_recovers_is_not_rebuilt`(单轮抖动不重建)、`test_stopped_app_is_not_resurrected`(下线不被复活)。**孤儿回收的正确性一半在"不误杀"上,只测回收会写出一个删客户容器的 reconciler**。②补 `test_exited_container_is_started_not_rebuilt` 钉住与 docker 的分工:进程退出归 `unless-stopped` 退避,reconciler 只 `start` 不重建(重建会丢掉 docker 的指数退避、且让崩溃循环每 15s 换一个新容器 id 藏起来)。③`test_recovery_budget_within_5min` 用 `FakeClock` 双断言——解析式 `recovery_budget_seconds(config)` ≤ 300 + 驱动真实循环后 `clock.now` ≤ 300;探活替身**消耗时钟**(45s),否则秒回的探针会让预算测试恒真。④`test_label_recovery_keeps_newest_generation_and_reclaims_the_older` 覆盖切换中途 manager 崩溃:同 app 两容器,按 generation 取新、旧的走孤儿回收。⑤`test_loop_survives_a_backend_outage`:dockerd 抖动只让本轮 `failures` 非空,不能让 reconcile 线程死。 + **覆盖 AC**: AC-20, AC-22, AC-47, AC-50 + **依赖**: T019, T025 + +- [x] **T029**: `[MVP-核心]` reconciler 与期望态存储实现 + **文件**: `src/runtime-manager/runtime_manager/reconciler.py`(新), `src/runtime-manager/runtime_manager/desired_state.py`(批 2 已落,本任务**只增不改**), `src/runtime-manager/runtime_manager/main.py`(lifespan 起停 reconcile 线程), `src/runtime-manager/runtime_manager/config.py`(`reconcile_enabled` / `RTM_RECONCILE_ENABLED`), `src/runtime-manager/tests/conftest.py`(测试期关循环) + **实际偏差记录**: ①`desired_state.py` 按批 2 留言只做**加法**:新增 `InstanceRecord.from_container()`(label→记录的反向映射)、`phase_for()`,以及四个健康检查参数字段(`health_interval/timeout/retries/start_period`)——不持久化这四个值,重建会把 owner 声明的 `start_period=120s` 悄悄换回默认 20s 并让实例卡在自己的门禁上;数据结构与 `get_store(config)` 未动。②**label 恢复时"容器没在跑"判定为 `desired=stopped`**:`unless-stopped` 下 dockerd 会把非显式下线的容器拉回来,所以"存在但没跑"= 有人显式停过——不做这个推断,状态文件一丢就会把已下线应用全部复活(AC-41 被 AC-50 反噬)。③**`startup_align()` 的顺序是硬要求:先 label 恢复、后孤儿回收**——反过来则状态文件丢失 = 全站容器被当孤儿清空(代码注释与用例双钉)。④reconciler **重建时 `generation+1`**(新执行体 = 新 bridge IP,`generation` 正是 app-proxy 的缓存失效信号),已同步进契约文件。⑤`phase` 不因 docker 的 `starting` 从 `running` 回退(`start_period` 内是"尚未判定"不是"不可用"),否则详情页每次重建都闪一次"启动中"。⑥重建后探活失败**不拆容器**(与 deploy 相反):deploy 拆是为了不让坏新版顶掉能跑的旧版,这里它已经是当前版本,拆掉等于从"不健康"变成"没有"。⑦reconcile 循环是**线程**不是 asyncio 任务:单次探活最长 90s,放事件循环里会让 `GET /v1/apps/{id}/route` 跟着卡住。⑧新增 `RTM_RECONCILE_ENABLED`(生产恒 true),单测关闭它以免后台轮次与断言抢跑。 + **逻辑**: D4:**15 秒**一轮,比对期望态 vs `docker ps`,三类动作(缺失即拉起 / `unhealthy` 连续 2 轮即重建 / 孤儿即回收);进程退出由 docker `restart unless-stopped` 内置退避兜住,manager 不参与。期望态存储 = 本机 JSON 状态文件 + 容器 label 双写(**label 是灾备真相**),进程启动时先做一次全量对齐(AC-50)。 + **测试**: T028 全部通过。 + **覆盖 AC**: AC-20, AC-22, AC-47, AC-50 + **依赖**: T028 + +- [x] **T030**: `[MVP-核心]` 只读接口(status / logs / runtime-status)测试 + **文件**: `src/runtime-manager/tests/test_readonly_api.py`(新) + **逻辑**: `test_status_shape`(`{instance_id, phase, health, current_version_id, started_at, restart_count, last_probe_at}`,`phase ∈ {pending, building, starting, running, unhealthy, stopped, failed}`——**形态无关,无 container / compose 字样**,INV-33)→ AC-23 / `test_logs_tail_since_keyword`(`GET /v1/apps/{id}/logs?tail=&since=&keyword=` 三参生效,来源 = docker json-file driver + `max-size=10m max-file=3` 轮转)→ AC-23, AC-55 / `test_logs_redact_known_injected_secrets`(对**平台注入的已知敏感值**做字面量替换为 `***`;**不做通用脱敏**——日志是应用自己打印的,通用脱敏是幻觉级承诺,密钥靠 F055 发布期扫描兜,D14)→ AC-55 / `test_runtime_status_shape`(`{backend_available, supported_runtimes[], capacity{...}, preflight[]}`)→ AC-23 / `test_backend_unavailable_reports_not_500`(dockerd 不可用 → `backend_available=false`,其余接口返 16121 语义)。 + **测试降级**: 需 docker,CI 中间件阶段 + 114 手动验证。 + **实际偏差记录**: 16 个用例(含 1 个 `@pytest.mark.docker` 占位)。①补 `test_logs_do_not_pretend_to_redact_app_output`——**反向**钉住 D14 的边界:应用自己打印的 `api_key=...` 原样返回。只有"会脱敏"的正向用例,下一个人一定会把它"改进"成通用正则脱敏,于是 AC-55 变成一句做不到的承诺。②补 `test_logs_since_accepts_a_relative_window` / `test_logs_rejects_an_unparseable_since`:`since` 同时收 epoch 秒与 `30m/2h/7d`(人手输的是后者,daemon 只认前者)。③补 `test_runtime_status_flags_a_missing_application_network`——`bisheng-apps` 网络没建是 114 上最贵的一次踩坑(每个 deploy 都以 daemon 原始报错失败,没人读得出"跑一句 docker network create")。④补 `test_runtime_status_survives_an_unreadable_host`(`/proc/meminfo` 读不到 → `capacity.readable=false` 而不是 500)与 `test_readonly_endpoints_require_a_signature`。⑤`test_status_reports_live_health_and_total_restarts` 钉住 `restart_count` = **daemon 重启数 + manager 重建数之和**:重建会把新容器的 `RestartCount` 清零,只报 daemon 的数字会让重建过 6 次的应用在详情页显示"从未重启"。 + **覆盖 AC**: AC-23, AC-55 + **依赖**: T019, T025 + +- [x] **T031**: `[MVP-核心]` 只读接口实现 + **文件**: `src/runtime-manager/runtime_manager/api/readonly.py`(新), `src/runtime-manager/runtime_manager/admission.py`(新增 `capacity_snapshot()`,与 `evaluate()` 共用同一快照构造), `src/runtime-manager/runtime_manager/docker_backend.py`(协议 + 实现新增 `list_networks`), `src/runtime-manager/tests/fakes.py`(假后端补 `networks`), `src/runtime-manager/runtime_manager/main.py`(挂载 router), `src/runtime-manager/tests/test_main_smoke.py`(新,入口冒烟) + **实际偏差记录**: ①路径以 design §4.2 为准取 **`GET /v1/runtime/status`**(契约文件 §8 原写 `/v1/runtime-status`,已随本批订正)。②`capacity` 复用 admission 的快照结构并加 `instances` / `readable` / `reason`——AC-23 的运行环境状态与 AC-65 的"待上线(资源不足)"成因必须逐字节同源,同源的办法是同一个函数。③`preflight` 落 5 项:`orchestration_backend` / `application_network` / `data_root_writable` / `runtime_templates` / `base_images`,每项 `{name, ok, detail}` 且 **detail 写"怎么修"不写"哪里错"**(如缺网络直接给出 `docker network create bisheng-apps`);`list_networks` 只读不建——manager 自动建网络会把"新机器每次发布都失败"的唯一线索藏掉。④日志三参**分工明确**:`tail`/`since` 下发给 daemon(能真正收窄轮转窗口),`keyword` 在本层过滤(没有日志驱动能做),所以带 keyword 的 `tail=200` 可能返回不足 200 行——写进 docstring 防止被当 bug 修。⑤脱敏只对**平台注入的敏感 env 值**做字面量替换(按 env **名**匹配 `SECRET|TOKEN|PASSWORD|CREDENTIAL|SIGNATURE|API_KEY|PRIVATE_KEY`,值长 ≥6,长值优先替换),不做通用脱敏(D14)。⑥后端不可用时 `runtime/status` 恒 200 且 `backend_available=false`,`status`/`logs` 返 **503 `backend_unavailable`**(backend 映射 16121)——"实例不存在"与"编排后端宕机"必须是两个答案,否则 dockerd 一重启详情页就显示"已删除"。⑦补 `tests/test_main_smoke.py`:模块级 `from runtime_manager.main import app` + 逐个 `/v1` 路径未签名应 401(不走 `app.routes` 内部结构,FastAPI 版本间会变形),补上批 3「测试全绿但 uvicorn 起不来」的口子。 + **逻辑**: `GET /v1/apps/{id}/status`、`GET /v1/apps/{id}/logs`、`GET /v1/runtime/status`。日志走 `docker logs`(D14-B:平台侧零采集器、零存储成本、形态无关);保留期 = docker 轮转窗口(30MB / 应用),产品口径是"最近的运行日志"、不承诺永久留存。 + **测试**: T030 全部通过。 + **覆盖 AC**: AC-23, AC-55 + **依赖**: T030 + +### Wave 2 · `[MVP-核心]` backend 入口判定与内部授权端点(Test-First) + +- [x] **T032**: `[MVP-核心]` `entry_authz_service` 五步判定测试 + **文件**: `src/backend/test/app_runtime/test_entry_authz_service.py`(新) + **实际偏差记录**: ①conftest 新增 `fake_permission_projection`(替 `get_f048_resource_adapter`)与 `tenant_admins`(替 `check_tenant_admin`)两个 fixture,Wave 3 的服务用同一对;`_TABLES` 补 `resource_tier`(F055 的表,**建表但不 seed**——这正是 MVP 的真实形态,走的是 F054 `DEFAULT_TIERS` 兜底分支而不是「表不存在」的异常分支)。②新增 **autouse** fixture `_no_super_admin_probe`:真实 `_check_is_global_super` 会初始化 DB 管理器,而它**进程级**注册租户过滤 ORM 监听器 → 后续测试包的 SELECT 全被改写。现象是 F055 的 `test_worker_tenant_context_restored_from_celery_header` **只在 F054 先跑时**失败(`app_deployment` 行被按错租户过滤掉,日志写「vanished before the worker ran」)。需要超管的用例改为直接把 `is_global_super=True` 写进 payload。 + **逻辑**: 严格按 spec §3「入口判定顺序与信息泄漏口径」(D6):`test_layer_not_deployed_short_circuits`(开关关 → 「未启用」,先于登录态判)→ AC-30 / `test_no_token_returns_login_handoff`(无 cookie → `decision=login`)→ AC-27 / `test_token_version_mismatch_denied` / `test_disabled_account_denied` / `test_disabled_tenant_denied`(三项复用 backend 中间件同一函数集 `http_middleware.py:60-73 / :124-142 / :263-279 / :325-349`——**本地解 JWT 会绕过它们**,K7)→ AC-26 / `test_draft_pending_deleted_and_nonexistent_return_same_page`(草稿 / 待上线 / 已删除 / 不存在**一视同仁**返回「不存在或未上线」,防信息泄漏)→ AC-29 / `test_not_visible_returns_forbidden_with_app_name_and_owner`(无权限页可带应用名与 owner——PRD 明示的引导信息)→ AC-28 / `test_stopped_returns_stopped_page_only_for_visible_users`(已下线页只对可见范围内用户呈现)→ AC-29 / `test_fga_unavailable_fail_closed_16146`(`PermissionServiceUnavailableError` / `PermissionBackendUnavailableError` → deny,**绝不放行**)→ AC-12 / `test_headers_material_complete`(十个头材料齐全,含 `X-BiSheng-Subject-Kind`;`Dept-Id` 取 `Department.dept_id` 业务键 `BS@xxx` **不是自增 id**)→ AC-31 / `test_chinese_name_percent_encoded_roundtrip`(用 `chinese_name_user` fixture:`User-Name` / `Dept-Name` / `Dept-Path` 三头 UTF-8 percent-encoding 往返——坑 9:HTTP 头是 latin-1,测试账号常是英文名故**极易漏测**)→ AC-31 / `test_obo_token_signed_with_dedicated_secret`(HS256、`aud="bisheng-app-obo"`、TTL 900s、**签名密钥独立于 `settings.jwt_secret`**——否则一个 OBO 令牌能被当平台会话 cookie 用;不持久化、不入库、不上界面)→ AC-34。 + **覆盖 AC**: AC-12, AC-26, AC-27, AC-28, AC-29, AC-30, AC-31, AC-34 + **依赖**: T009, T013, T005 + +- [x] **T033**: `[MVP-核心]` `entry_authz_service` 实现 + **文件**: `src/backend/bisheng/app_runtime/domain/services/entry_authz_service.py`(新) + **实际偏差记录**: ①判定枚举比 design §4.2 多一个 `unavailable`(与 T041 的 app-proxy 侧同源):16146 需要一个可渲染的判定值。②`token_version` 不符 / 账号禁用 / 租户禁用三者一律答 **`login`** 而非独立的「被拒」页——判定到这一步**还没查应用**,任何别的答案都会暗示该 slug 存在(AC-29 的信息泄漏口径同样管这三步)。③`_tenant_disabled` 在 Redis 不可达时**失败放行**,与 `CustomMiddleware` 逐字一致;只有权限引擎是 fail-closed(AC-12)——两者不同源,别统一。④`obo_secret` 为空或**等于 `jwt_secret`** 时不签 OBO(返回 `obo_token=None`)而不是拒绝入口:本期 OBO 无消费方,为一个没人读的令牌挡掉整条入口是错的取舍;等于 `jwt_secret` 属配置事故,签了就等于给每个托管应用发平台会话。⑤`_check_is_global_super` 由函数内惰性 import 改为模块级 import——它会初始化真实 DB 管理器(进而**进程级**注册租户过滤监听器),必须能在测试里按模块名替换,否则污染后续测试包(见 T032 偏差②)。 + **逻辑**: 五步判定(D6)→ 返回 `{decision, headers{...}, obo_token, app_state, app_name, owner_name}`。可见范围判定用 **`check_business_action("app", app_id, actor, "use")`**(不用 `runtime.check_visible`——后者对"授了 editor 但没 use"的自定义模型更宽,会让口径与 PermissionDialog 显示不一致,D9)。部门信息经 `UserDepartmentDao.aget_user_primary_department`(`user_department` 表**无 tenant_id**、`department` 表有 → 需 `bypass_tenant_filter` 或显式上下文)。**不反代、不渲染页面**(那是 app-proxy 的活)。 + **测试**: T032 全部通过。 + **覆盖 AC**: AC-12, AC-26, AC-27, AC-28, AC-29, AC-30, AC-31, AC-34 + **依赖**: T032 + +- [x] **T034**: `[MVP-核心]` 内部授权端点 + 未部署引导页端点测试 + **文件**: `src/backend/test/app_runtime/test_internal_app_proxy_api.py`(新) + **逻辑**: `test_hmac_required_and_constant_time`(无签名 / 错签名 → 401;`hmac.compare_digest`)/ `test_path_in_tenant_check_exempt`(端点在 `TENANT_CHECK_EXEMPT_PATHS`,handler 内 `bypass_tenant_filter`,否则跨租户访问自己被过滤掉)→ AC-26 / `test_response_contains_no_upstream_address`(**刻意不含目标实例地址**——上游解析是 app-proxy ↔ manager 的独立通道,两者缓存节奏不同,D5.1)/ `test_decision_matrix_end_to_end`(allow / login / forbidden / stopped / not_found / not_enabled 六态)→ AC-28, AC-29, AC-30 / `test_obo_returned_on_allow_only` → AC-34 / `test_unavailable_page_endpoint_returns_html_200`(`GET /api/v1/apps/_unavailable` 返回引导页 HTML,nginx `error_page` 回落用;**backend 提供一张静态 HTML 不违反 K1**)→ AC-30。 + **覆盖 AC**: AC-26, AC-28, AC-29, AC-30, AC-34 + **依赖**: T033 + +- [x] **T035**: `[MVP-核心]` 内部授权端点实现 + **文件**: `src/backend/bisheng/app_runtime/api/endpoints/internal_app_proxy.py`(新), `src/backend/bisheng/app_runtime/api/router.py`(新,并在 `bisheng/api/router.py` 挂载) + **实际偏差记录**: ①新增 `app_runtime/api/exception_handlers.py`(`ProxyHmacRejectedError` → 真 401)并在 `main.py` 注册——**平台全局处理器把任何 `HTTPException` 都答成 HTTP 200**(`main.py:handle_http_exception`),而 app-proxy 正是靠传输层状态码区分「共享密钥配错」(401) 与「这个访客不能进」(200 + `decision`);不做这一步两者会合并成一句「后端返回了看不懂的东西」——仍然 fail-closed,但排障指向错误的组件。形态照 F049 的 `register_open_api_exception_handlers`。异常类定义在 `domain/services/hmac_auth.py`、handler 在 api 层,避免 domain 反向 import api(C1)。②`TENANT_CHECK_EXEMPT_PATHS` 加**两条**:授权端点 + `/api/v1/apps/_unavailable`(后者由匿名浏览器经 nginx `error_page` 打到,带会话要求就永远渲染不出来)。③HMAC 校验落 `domain/services/hmac_auth.py`(与 F014 / runtime-manager 同一签名串),不复用 `sso_sync` 的实现——那份钉死在 `sso_sync.gateway_hmac_secret` 上。 + **逻辑**: `POST /api/v1/internal/app-proxy/authorize`(HMAC 保护、加入 `TENANT_CHECK_EXEMPT_PATHS`、handler 内 `bypass_tenant_filter`)+ `GET /api/v1/apps/_unavailable`(引导页 HTML)。端点**不直接 import `database/models`**(RULE-3),一律经 domain service。 + **测试**: T034 全部通过。 + **覆盖 AC**: AC-26, AC-28, AC-29, AC-30, AC-34 + **依赖**: T034 + +### Wave 2 · `[MVP-核心]` app-proxy 独立包(`src/app-proxy/`) + +- [x] **T036**: `[MVP-核心]` app-proxy 包工程骨架 + **文件**: `src/app-proxy/pyproject.toml`(新), `src/app-proxy/app_proxy/{__init__,main,config,clients}.py`(新,FastAPI 监听 `127.0.0.1:8090`) + **逻辑**: D5-C:自研 Python 反代(**不套 oauth2-proxy**——CVE-2025-64484 正是头剥离不彻底,我们托管的是不可控的 Python 应用框架,这是设计前提级红线)。**不 import `bisheng` 包**(D6-B 的双份真相被否)。`clients.py` = 两个 HMAC 客户端(backend 授权端点 / manager 路由端点)+ 两把**独立**的 3 秒缓存。配置读环境变量(`APP_PROXY_BACKEND_BASE` / `APP_PROXY_MANAGER_BASE` / 两个 HMAC secret)。 + **依赖**: 无 + +- [x] **T037**: `[MVP-核心]` app-proxy 测试基础设施 + **文件**: `src/app-proxy/tests/conftest.py`(新), `src/app-proxy/tests/fakes.py`(新) + **逻辑**: `fake_backend`(可编程 authorize 响应:六种 decision + 头材料 + OBO)/ `fake_manager`(可编程 route 响应,支持"先失败后成功"以测缓存作废重取)/ `echo_upstream`(一个把收到的 **全部请求头 + 路径 + query** 原样回显的 ASGI 应用,用于断言剥离 / 注入 / 前缀)/ `proxy_client`(`TestClient`,可设 `Sec-Fetch-Mode` / `Accept` / cookie)/ `frozen_clock`(缓存 TTL 用例)。 + **依赖**: T036 + +- [x] **T038**: `[MVP-核心]` 头归一化剥离与注入测试 + **文件**: `src/app-proxy/tests/test_headers.py`(新) + **逻辑**: `test_strip_all_x_bisheng_equivalence_class`(`X_BiSheng_User_Id` / `x-bisheng-user-id` / `X-BISHENG-USER-ID` / `x_bisheng_USER_name` 混合下划线连字符大小写**全部被丢弃**——按 `lower()` + `_`→`-` 归一后凡以 `x-bisheng-` 开头一律丢,**不是只丢精确的十个名字**;WSGI/ASGI 框架把 `X_BiSheng_User_Id` 与 `X-BiSheng-User-Id` 归一到同一个 `HTTP_X_BISHENG_USER_ID` 是常态,只按精确名剥离等于没剥离)→ AC-32 / `test_forged_header_has_no_effect_on_upstream`(客户端伪造 → 上游读到的仍是真实访问者)→ AC-32 / `test_inject_ten_headers`(十个 `X-BiSheng-*` 齐全)→ AC-31 / `test_forwarded_headers_rewritten_not_passthrough`(`X-Forwarded-Prefix` / `Proto` / `Host` **先丢客户端值再由 app-proxy 写入**——否则应用会用伪造 Host 生成外链,D5.2)→ AC-32 / `test_percent_encoded_chinese_values_pass_latin1`(三个含中文的头编码后可被 uvicorn/h11 正常发出,坑 9)→ AC-31。 + **覆盖 AC**: AC-31, AC-32 + **依赖**: T037 + +- [x] **T039**: `[MVP-核心]` 头处理实现 + **文件**: `src/app-proxy/app_proxy/headers.py`(新) + **逻辑**: `strip_platform_headers(headers)` + `build_injected_headers(material, slug, request_id)`(§4.2 ③ 十头 + 三个 `X-Forwarded-*`)。WS 升级请求走同一段代码(Wave 4 接线时复用,不另写一份)。 + **测试**: T038 全部通过。 + **覆盖 AC**: AC-31, AC-32 + **依赖**: T038 + +- [x] **T040**: `[MVP-核心]` 判定接入 + 3s 缓存 + 四类兜底页测试 + **文件**: `src/app-proxy/tests/test_authz_and_fallback.py`(新) + **逻辑**: `test_authorize_cached_3s_by_cookie_hash_and_slug`(同一 `(cookie 哈希, slug)` 3 秒内只问一次 backend;**与路由缓存互不干扰**)/ `test_visibility_revoke_effective_after_cache_expiry`(撤销后 ≤3s 生效,与 AC-10「自下一次请求起生效」口径相容)→ AC-10, AC-26 / `test_allow_forwards`(allow → 进入反代路径)→ AC-26 / `test_forbidden_page_content`(应用名 + owner + 无访问权限文案 + 引导联系 owner 或**平台管理员** + 「返回广场」按钮;**本版无在线申请入口**;2026-08-19 口径订正,原写「租户管理员」,理由见 spec §3「兜底页的角色称呼一律中性」)→ AC-28 / `test_stopped_page_content`(应用名 + 已下线提示 + 引导 + 返回广场)→ AC-29 / `test_not_found_page_for_draft_pending_deleted_and_unknown`(四种情形同一页,不落报错页)→ AC-29 / `test_not_enabled_guide_page_not_404_or_5xx`→ AC-30 / `test_backend_timeout_or_5xx_fail_closed`(内部端点超时 / 5xx → 拒绝而非放行)→ AC-12 / `test_recovering_static_page_on_upstream_unreachable`(MVP 期切换窗口 / 崩溃窗口表现为「应用恢复中」**静态版**,不落报错页;自动重试版见 T082)→ AC-36。 + **覆盖 AC**: AC-10, AC-12, AC-26, AC-28, AC-29, AC-30, AC-36 + **依赖**: T037, T039 + +- [x] **T041**: `[MVP-核心]` 判定接入与兜底页实现 + **文件**: `src/app-proxy/app_proxy/authz.py`(新), `src/app-proxy/app_proxy/pages.py`(新,四类兜底页 + 「应用恢复中」静态版 HTML 模板) + **逻辑**: D7-A′:**app-proxy 自渲染**(URL 不变,扫码 / 收藏 / 刷新重试语义最稳;hash 零丢失;client 零改动)。被否的 B(302 → client gate 路由)有竞态型缺陷:`AuthContextProvider` 一挂载就拉 `/user/info`、401 拦截器的 `redirectToLogin()` 是**首次调用胜出的一次性守卫**,会把 gate 页 URL 写进 `LOGIN_PATHNAME`。页面用极简内联样式,不引外部资源。 + **测试**: T040 全部通过。 + **实际偏差记录**(2026-08-19 补): ①兜底页文案**三语化**(zh-Hans / en / ja),新增 `negotiate_locale(accept_language)` 手写 Accept-Language 协商(按 q 排序、q=0 视为拒绝、zh-Hant/zh-TW 归 zh-Hans、未知回落 zh-Hans),零新依赖;`render_page` / `error_payload` 收 `accept_language`,`` 随 bundle 变。design **D7 备选 A** 曾把「三语要在 Python 侧再做一小套」列为该方案的缺点——这笔代价现已付清(design 已同批标注)。②这套串**游离在 `packages/locales` 之外**,`pnpm check-i18n` 看不见,改由 `src/app-proxy/tests/test_pages_i18n.py` 的 parity 测试兜底(每个 locale 必须声明全部 6 页 + 任何一页任何一语言不得出现 租户/tenant/テナント)。**新增一页必须三语同批 + 补 parity 用例**。③角色称呼统一为中性的「平台管理员」(原文六页出现「租户管理员 / 平台超管 / 平台管理员」三种叫法),理由见 spec §3「兜底页的角色称呼一律中性」。④名字缺省时的句子收进 `app_named` / `owner_named` 模板,消除「你没有访问 该应用 的权限。」这类重复词与多余空格。⑤`login_handoff.py`(登录交接过渡页)**仍是纯中文**,不在这六张页内、且是毫秒级过渡页,未一并三语化。 + **覆盖 AC**: AC-10, AC-12, AC-26, AC-28, AC-29, AC-30, AC-36 + **依赖**: T040 + +- [x] **T042**: `[MVP-核心]` 登录交接页与非导航请求分流测试 + **文件**: `src/app-proxy/tests/test_login_handoff.py`(新) + **逻辑**: `test_navigation_gets_inline_js_handoff`(`Sec-Fetch-Mode: navigate` → 返回内联 JS 页:写 `localStorage.LOGIN_PATHNAME = location.href`(**天然含 query + hash**)+ `LOGIN_PATHNAME_AT` 后 `location.replace('/admin')`)→ AC-27 / `test_query_and_hash_preserved`(`/apps/foo?a=1#b` 登录后回到含 `?a=1#b` 的原地址——**服务端 302 做不到**:hash 永不上送服务器,且 platform 登录页**只认这两个 localStorage key、不认任何 `?redirect=` query`**,坑 11)→ AC-27 / `test_key_names_and_ttl_match_platform_contract`(键名 / 10 分钟时效 / 同源校验必须与 `platform/utils/loginReturnTo.ts:35-70` 原样一致——这是跨 SPA 契约)→ AC-27 / `test_xhr_gets_json_and_real_status`(非导航请求返回 JSON + 真实 401/403/404/503,**不返回 HTML**,否则应用内 XHR 拿到一坨 HTML 会解析崩)/ `test_ws_upgrade_rejected_with_close_code`。 + **覆盖 AC**: AC-27 + **依赖**: T037, T041 + +- [x] **T043**: `[MVP-核心]` 登录交接页与分流实现 + **文件**: `src/app-proxy/app_proxy/login_handoff.py`(新) + **逻辑**: D7:分流判据 = `Sec-Fetch-Mode: navigate`(或 `Sec-Fetch-Dest: document`,回落 `Accept: text/html`)。交接页跳 `/admin`——未登录即 platform `LoginPage`;配了 SSO 时它自己跳 IdP,回来后 `App.tsx:154-160` 或 `login.tsx:168-172` 消费同一 key 回跳。 + **测试**: T042 全部通过。 + **覆盖 AC**: AC-27 + **依赖**: T042 + +- [x] **T044**: `[MVP-核心]` 上游解析 + 前缀剥离 + HTTP 反代测试 + **文件**: `src/app-proxy/tests/test_proxy.py`(新) + **逻辑**: `test_route_cached_3s_and_invalidated_on_conn_error`(连接失败〔`ECONNREFUSED` / 连接超时〕→ **立刻作废该条并重取一次**,再失败才渲染「应用恢复中」)→ AC-25, AC-36 / `test_prefix_stripped_three_forms`(`/apps/foo` → `/`、`/apps/foo/` → `/`、`/apps/foo/x?y=1` → `/x?y=1`;`X-Forwarded-Prefix` 恒为 `/apps/foo`)→ AC-25 / `test_relative_paths_work_through_entry`(上游生成的相对路径经入口可达)→ AC-25 / `test_entry_stable_across_version_switch`(`generation` 变化后同一 `/apps/{slug}` 仍可达,路由缓存过期后指向新实例;宽限 30s ≫ 3s 缓存 → 切换窗口内命中旧地址仍被旧容器正常服务、**不产生 502**)→ AC-21, AC-25 / `test_upstream_is_bridge_ip_not_published_port`(断言反代目标是 bridge IP,**不经任何宿主 published 端口**——绕过入口直连不可行)→ AC-33 / `test_streaming_and_large_body_passthrough`。 + **覆盖 AC**: AC-21, AC-25, AC-33, AC-36 + **依赖**: T037, T039, T041 + +- [x] **T045**: `[MVP-核心]` 反代实现(路由解析 + 前缀剥离 + HTTP 转发) + **文件**: `src/app-proxy/app_proxy/proxy.py`(新), `src/app-proxy/app_proxy/routing.py`(新) + **逻辑**: D5.1 + D5.2:问 manager `GET /v1/apps/{app_id}/route` 取 `upstream`(3s 缓存,与鉴权缓存**两把独立**)→ 剥 `/apps/{slug}` 前缀 → 重写 `X-Forwarded-Prefix/Proto/Host` → 注入十头 → httpx 流式转发。**WS 反代属 Wave 4**(T079/T080),MVP 期只反代 HTTP。 + **测试**: T044 全部通过。 + **覆盖 AC**: AC-21, AC-25, AC-33, AC-36 + **依赖**: T044 + +### Wave 3 · `[MVP-核心]` backend 领域服务与 API(Test-First) + +- [x] **T046**: `[MVP-核心]` `orchestrator_client` 测试 + backend 零编排依赖断言 + **文件**: `src/backend/test/app_runtime/test_orchestrator_client.py`(新) + **逻辑**: `test_hmac_signature_matches_manager_contract`(签名串 `METHOD\nPATH\nraw_body`、`X-Signature` 头、空 secret fail-closed)/ `test_timeout_and_retry_then_16121`(manager 不可达 → `16121` 编排器不可用,不是 500)/ `test_admission_passthrough_snapshot`(容量快照原样透出供 AC-65 展示成因)→ AC-19 / `test_interface_semantics_are_form_agnostic`(门面方法名与入参**无 container / compose 字样**,`phase` 取值取自形态无关集合——INV-33,F059 只换 manager 内部后端)→ AC-14 / **`test_backend_has_zero_orchestration_dependency`**(扫描 `src/backend/bisheng/**/*.py` 无 `import docker|kubernetes|aiodocker`、无 `/var/run/docker.sock` 字面量;`src/backend/pyproject.toml` 依赖树不含 docker SDK——这是 AC-14「部署检查可核验」的自动化断言,与 T008 的 arch-guard RULE-10 双保险)→ AC-14。 + **覆盖 AC**: AC-14, AC-19 + **依赖**: T009, T005, T008 + +- [x] **T047**: `[MVP-核心]` `orchestrator_client` 实现 + **文件**: `src/backend/bisheng/app_runtime/domain/services/orchestrator_client.py`(新) + **实际偏差记录**: ①`build_status` 对 `status=failed` **返回而不抛** 16122:F055 的 `_await_build` 要读 `stage` / `message` / `tail` 组装 AC-15 的失败文案,抛掉就没了;16122 的构造改由模块级函数 `build_failure_error(payload)` 提供——**放成方法会让门面多出第 11 个公开成员**,而两个 conftest 的 `fake_orchestrator` 都在断言「公开方法集恰好是这 10 个」。②重试只覆盖**安全重放**的情形:连接失败(对端根本没收到)任何动词都重试,读超时只重试 GET——deploy/stop/destroy 可能已在执行,盲目重放会让第二次 deploy 与第一次抢 manager 的探活闸门。③超时按方法分档(`deploy`/`probe` 300s、`stop`/`destroy` 120s、`build` 60s、其余 15s):manager 的 deploy 内含探活(D4 预算 ≤90s),共用 15s 会把每次冷启动都变成假的 16121。④签名对**实际发出的字节**做(`json.dumps(separators=(",",":"))` 后以 `content=` 发送),不让 httpx 用 `json=` 重新序列化——分隔符空格差一个字节,现象是「密钥配错」。 + **逻辑**: httpx 薄客户端 + HMAC + 超时 + 重试,暴露 **10 个方法** `build / build_status / deploy / stop / destroy / probe / admission / status / logs / runtime_status`(§4.2 ①;**方法数与 T009 `fake_orchestrator` 的 stub 数必须一致**)。**绝不 import docker**;不含编排语义判断(意图式:backend 只下发期望态,manager 自 reconcile)。 + **测试**: T046 全部通过。 + **覆盖 AC**: AC-14, AC-19 + **依赖**: T046 + +- [x] **T048**: `[MVP-核心]` 应用与版本落库测试(建应用 / slug / 只增不改 / 待运行版本) + **文件**: `src/backend/test/app_runtime/test_app_state_service_registry.py`(新) + **逻辑**: `test_create_app_persists_as_third_type_with_owner_and_state_draft`(应用标识 / 名称 / 描述 / 图标 / owner / 租户 / 应用态齐全;创建即调 `runtime.authorize_created`)→ AC-01, AC-11 / `test_slug_from_manifest_or_generated_from_name` → AC-08 / `test_slug_global_unique_across_tenants_rejects_16103`(与**任一租户**内既有标识冲突 → 拒绝首发并提示更换)→ AC-08 / `test_slug_immutable_name_mutable_and_duplicable`(名称可重可改、改名不影响标识与入口地址)→ AC-01, AC-08 / `test_owner_single_and_not_retroactive`(每应用有且只有一个 owner;归属人后续变更**不追溯改变**已建应用的 owner)→ AC-07 / `test_version_insert_only_no_update_method`(`AppVersionDao` 无通用 UPDATE;一条版本记录含版本号 / 类型 / 提交时间 / 终态标注 / 代码快照引用 / 能力声明 / 注入配置 / 资源档位——**四者同属一个快照**,任何写入方不得只改其一)→ AC-02 / `test_stage_version_writes_pending_without_state_change`(`stage_version` 写 `pending_version_id`、**不改应用态**——AC-04「已下线态可落新版本但不自动重新上线」的唯一落点)→ AC-04 / `test_resume_publish_pick_pending_then_current`(取版规则 `pending_version_id ?? current_version_id`;生效后 `pending` 置空、`current` 更新、该版本 `terminal_state='online'`)→ AC-04 / `test_rejected_or_withdrawn_never_writes_pending`(被驳回 / 撤回只标 `terminal_state`,故「迭代被驳回不改变已上线态」天然成立)→ AC-05 / `test_version_read_by_version_id_requires_app_scope`(按 `version_id` 起手的读写先取 `app` 行校验归属;跨租户直接读 → 拒——坑 31:`app_version` 无 `tenant_id`,登记进 `_TENANT_AWARE_MODEL_MODULES` 也**不会**被自动过滤,泄漏面是 `code_object_key` 即源码对象键)。 + **覆盖 AC**: AC-01, AC-02, AC-04, AC-05, AC-07, AC-08, AC-11 + **依赖**: T009, T013, T006 + +- [x] **T049**: `[MVP-核心]` 应用与版本落库实现(`create_draft` / `stage_version` / 取版规则) + **文件**: `src/backend/bisheng/app_runtime/domain/services/app_state_service.py`(新,本任务只落建应用 / 待运行版本 / 取版三段) + **实际偏差记录**: ①**建应用落在新文件 `app_provision_service.py::AppProvisionService.create_draft(name, slug, description, owner_user_id, tenant_id) -> app_id`,不是 `AppStateService.create_app`**——F055 的管线与其 conftest 占位已按这个符号与签名接线(`publish_pipeline_service._create_draft`),且「建应用」不是状态迁移,放进「应用态唯一写入方」会稀释决议-8 的边界。`AppStateService` 仍持 `stage_version` / `_pick_version` / 五动作。②slug 冲突分两种:**声明的** slug 冲突 → 16103「请更换」(它已经是开发者对外承诺的地址,平台不能偷偷改名);**平台生成的** → 追加 `-2`…`-6` 再退化到随机后缀(两个人都把应用叫「报表工具」不能互相锁死)。③新增 `AppDao.adelete_row`(**仅补偿用**):owner 投影失败时物理删掉刚写的行——补偿成 `state='deleted'` 会让全局唯一的 slug 被永久占住,同一个应用再 `bisheng deploy` 就永远 16103 且无解。 + **逻辑**: `create_app(manifest, owner_user_id, tenant_id)`(slug 校验与生成 → 落 `app`(`state=draft`)→ `runtime.authorize_created(mode="CUSTOM", protected=True)`,AC-11「默认仅 owner 可见」由此成立);`stage_version(app_id, version_id)`(写 `pending_version_id`、不改态;**F055 只调不直写**,决议-8);`_pick_version(app)` = `pending_version_id ?? current_version_id`。版本记录的**写入时机**归 F055,本 Feature 只提供 DAO 与取版语义。 + **测试**: T048 全部通过。 + **覆盖 AC**: AC-01, AC-02, AC-04, AC-05, AC-07, AC-08, AC-11 + **依赖**: T048, T047 + +- [x] **T050**: `[MVP-核心]` 五个状态动作测试(含前态矩阵 / 并发 / 审计 / 钩子) + **文件**: `src/backend/test/app_runtime/test_app_state_actions.py`(新) + **逻辑**: `test_transition_matrix_full`(五动作 × 五前态全矩阵;**已上线 → 已删除必须被拒**)→ AC-03 / `test_concurrent_actions_second_gets_16102`(带前态断言的单行 `UPDATE ... WHERE id=:id AND state IN (...)`,受影响行数 0 → `AppStateConflictError`(16102),并发的「下线」与「上线终检」不互相覆盖、无需行锁)→ AC-03 / `test_publish_admission_fail_sets_pending_capacity_with_reason`(容量不足 → **不拉起半可用实例**、应用态置待上线并返回成因)→ AC-19, AC-65 / `test_publish_probe_fail_sets_pending_with_reason`(拉起 / 探活失败 → 待上线 + 成因「上线失败」)→ AC-65 / `test_manual_publish_from_pending_capacity`→ AC-65 / `test_stop_recycles_exec_body_only_assets_intact`(下线只回收执行体,代码快照 / 每应用数据库 / 附件真身恒在平台存储)→ AC-39, AC-40 / `test_resume_runs_admission_first_and_keeps_stopped_on_shortage`(重新上线叠加容量准入;不足则保持已下线并返回成因)→ AC-19, AC-41 / `test_resume_uses_pending_version`→ AC-04 / `test_stop_resume_allowed_for_owner_tenant_admin_and_superadmin_proxy`(超管经租户管理视图代行,**审计记超管本人**)→ AC-41 / `test_delete_blocked_when_online_16104`(前置状态闸「请先下线」)→ AC-42 / `test_delete_allowed_from_draft_pending_stopped`→ AC-42 / `test_delete_rejected_for_non_owner_16105`(含租户管理员与平台超管;**业务规则前置拦截**,不受权限运行时管理员短路影响)→ AC-44 / `test_delete_purges_assets_and_marks_deleted`→ AC-43 / `test_delete_triggers_on_app_deleted_hook`(注册假 hook,`DELETE` 后断言被同步调用)→ AC-43 / `test_hook_failure_does_not_rollback_delete`(hook 抛异常 → 删除仍成功且落审计 `app.delete_hook_failed`——删除是终态、资产已回收,回滚只会造出"态还在但资产没了"的僵尸)→ AC-43 / `test_no_path_deletes_assets_except_explicit_delete`(下线 / 实例崩溃 / 整层不部署三条路径均只回收执行体)→ AC-40 / `test_every_action_audited_with_version_and_reason`(五动作每次执行与结果均计审计,含版本号与成因)→ AC-65。 + **覆盖 AC**: AC-03, AC-04, AC-19, AC-39, AC-40, AC-41, AC-42, AC-43, AC-44, AC-65 + **依赖**: T049, T047 + +- [x] **T051**: `[MVP-核心]` 五个状态动作实现 + 删除事件钩子 + **文件**: `src/backend/bisheng/app_runtime/domain/services/app_state_service.py`(**增量新增**五动作方法,不改 T049 已落方法), `src/backend/bisheng/app_runtime/domain/services/lifecycle_hooks.py`(新) + **实际偏差记录**: ①`_start` 的顺序是 **准入 → deploy → CAS(online)**,即状态在编排器确认成功之后才认领——先认领做不到:状态机里**没有 `online → pending_capacity` 这条边**(`ALLOWED_TRANSITIONS[ONLINE] = {STOPPED}`),探活失败就没法按 AC-65 停在「待上线」。并发安全由 CAS 的 `from_states=(读到的前态,)` 保证(比 `allowed_sources(目标态)` 更严:任何并发改动都判 16102)。②新增错误码 **16106 `AppManageForbiddenError`**(owner ∪ 租户管理员 ∪ 超管之外),三语文案同批落 `packages/locales`。不复用 16105 的理由:16105 的语义是「**仅** owner」(删除 / 数据 tab,租户管理员也被拒),拿它答下线/重新上线会告诉一个租户管理员「只有负责人能做」——而他明明能做。③`ActionResult.ok=False`(容量不足 / 上线失败)是**正常返回**不是异常:调用方要拿 `state` + `reason` + `snapshot` 渲染 AC-65,抛异常会把这些丢掉。④`_set_instance_phase` 显式传 `tenant_id`——`before_flush` 自动填只在装了租户监听器的进程里有效,缺省会直接撞 NOT NULL。 + **逻辑**: `publish / manual_publish / stop / resume / delete` —— **应用态的唯一写入方**(决议-8,F055 只调不直写)。每个动作:业务规则前置拦截(删除仅 owner;下线 / 重新上线 owner ∪ 租户管理员 ∪ 超管代行)→ 带前态断言的单行 UPDATE → 调 `orchestrator_client`(admission → deploy / stop / destroy)→ 审计(`AuditLogDao.ainsert_v2`,`app.*` 命名空间)。`lifecycle_hooks.py` = `register_app_deleted_hook(fn)` / `on_app_deleted(app_id, actor, tenant_id)`,**F055 在自己的组合根注册**(F054 **不 import F055**——依赖方向是 F055 → F054,反向 import 是依赖倒挂且撞 RULE-5)。 + **测试**: T050 全部通过。 + **覆盖 AC**: AC-03, AC-04, AC-19, AC-39, AC-40, AC-41, AC-42, AC-43, AC-44, AC-65 + **依赖**: T050 + +- [x] **T052**: `[MVP-核心]` `AppMetaService` 测试 + **文件**: `src/backend/test/app_runtime/test_app_meta_service.py`(新) + **逻辑**: `test_update_meta_does_not_change_state`(改名称 / 描述 / 图标后应用态不变)→ AC-06 / `test_update_meta_creates_no_version_record`(`app_version` 行数不变——元信息不属能力声明)→ AC-06 / `test_update_meta_audited`(`app.meta_update`)→ AC-06 / `test_slug_not_updatable`(标识不在可改字段白名单)→ AC-08 / `test_same_implementation_used_by_pipeline`(F055「元信息随 deploy 更新」调同一方法,断言 Service 是唯一实现、不另写一份)→ AC-06。 + **覆盖 AC**: AC-06, AC-08 + **依赖**: T049 + +- [x] **T053**: `[MVP-核心]` `AppMetaService` 实现 + **文件**: `src/backend/bisheng/app_runtime/domain/services/app_meta_service.py`(新) + **实际偏差记录**: 签名取 **`update_meta(*, app_id, name=None, description=None, logo=None, actor=None)`**,不是 design §4.2 ② 写的 `(app_id, patch, actor)`——F055 的 `_update_meta` 正是按这四个关键字调用;`actor` 可空是因为管线跑在服务账号上、归属已在 F055 侧校验过,HTTP 面则必带 actor 并走 owner ∪ 管理员前置拦截。`logo` 存 **object_name**(预签 URL 会过期)。 + **逻辑**: `update_meta(app_id, patch, actor)` —— AC-06 的**唯一实现**(HTTP `PATCH /api/v1/apps/{app_id}` 与 F055 管线共用,§6.1 Outgoing 契约)。不碰应用态、不产生 `app_version` 行。 + **测试**: T052 全部通过。 + **覆盖 AC**: AC-06, AC-08 + **依赖**: T052 + +- [x] **T054**: `[MVP-核心]` `AppQueryService` 读侧测试(详情 / 实例 / 日志 / 运行环境状态 / 版本列表) + **文件**: `src/backend/test/app_runtime/test_app_query_service.py`(新) + **逻辑**: `test_detail_returns_entry_url_from_backend_config`(入口 URL **由后端返回完整地址**,取部署配置的公网基址——前端**别用 `location.origin` 拼**,dev 下 origin 是 :3001 且 `/apps` 不在 vite 代理内)→ AC-25 / `test_instance_status_shape`→ AC-23 / `test_logs_scope_identical_across_three_entries`(详情页 tab / CLI `logs`〔F053〕/ MCP 日志工具〔F052〕**内容范围一致**,权限各按入口口径)→ AC-23, AC-55 / `test_logs_visible_to_owner_and_tenant_admin_only`(其他用户不可访问该 tab 与其接口;**业务规则前置拦截**,不能依赖权限运行时——管理员在那里会被短路放行)→ AC-55 / `test_logs_denied_returns_business_code_not_403`(返回 `16161` 业务码,**不返回 HTTP 403/404**——坑 25:platform 拦截器对 GET 会整页跳 `/403`)→ AC-55 / `test_logs_no_platform_side_logs_and_has_empty_state`→ AC-55 / `test_runtime_status_superadmin_only`(两形态同一接口)→ AC-23 / `test_version_list_readonly_source_not_flow_version`(托管应用版本下拉的数据源是 `app_version` 而非 `FlowVersionDao`;只读、不提供切换与回滚——坑 13:`add_extra_field` 的 `version_list` 对 app 恒空,直接复用 `CardSelectVersion` 会去改**工作流**的当前版本)→ AC-52 / `test_owner_list_filter_and_tenant_admin_scope`(owner 看自己 owner 的、租户管理员看本租户全部)→ AC-57。 + **覆盖 AC**: AC-23, AC-25, AC-52, AC-55, AC-57 + **依赖**: T047, T049 + +- [x] **T055**: `[MVP-核心]` `AppQueryService` 实现 + **文件**: `src/backend/bisheng/app_runtime/domain/services/app_query_service.py`(新) + **实际偏差记录**: ①`get_logs` 增 `entry` 参数(`detail` / `cli` / `mcp`):三入口**内容完全同源**(同一次 `orchestrator_client.logs`),但 CLI / MCP 收窄为**仅 owner**——它们跑在密钥上,让租户管理员的密钥读遍全租户日志等于把开放 API 的授权面悄悄放大到密钥持有人没被授予的范围。②`list_apps` 显式写 `tenant_id` 谓词并新增 `AppDao.alist_by_tenant`,不靠自动过滤(监听器只改 SELECT 且只在装了它的进程里生效)。③跨租户 `app_id` 答 **16101**(与「不存在」同一个答案),不答无权限——否则 app_id 成了存在性探测器。④(2026-09-10 补)CLI / MCP 入口在 owner 之外**再比一次应用租户与凭据租户**:`get_logs` 走 `_load` 而非 `_load_visible`,而 `/api/v2` 把 `visible_tenant_ids` 设成 `{根租户, 凭据租户}`、D19 令牌随人换租户不失效,两者叠加让「同一 owner + 根租户应用」穿过判定;不等同样答 16101。 + **逻辑**: 详情(含 `entry_url`)/ 实例状态 / 日志 / 运行环境状态 / 版本列表的读侧,三入口权限口径分派(详情页:owner ∪ 本租户租户管理员;MCP / CLI:仅密钥归属人 owner 的应用)。**不写库**。 + **测试**: T054 全部通过。 + **覆盖 AC**: AC-23, AC-25, AC-52, AC-55, AC-57 + **依赖**: T054 + +- [x] **T056**: `[MVP-核心]` 状态动作 API + 读 API 端点集成测试 + **文件**: `src/backend/test/app_runtime/test_apps_api.py`(新) + **实际偏差记录**: conftest 的 `api_app` fixture 用 **`httpx.ASGITransport` 而非 `TestClient`**:`TestClient` 会另起事件循环,而内存 aiosqlite 引擎与租户 ContextVar 都在测试自己的循环上,第一次 DB 调用就 "attached to a different loop"。同样只挂 F054 的 router(不建整个 `bisheng.main` app),让失败指向 F054 而不是别的模块的 import。 + **逻辑**: TestClient 覆盖 `POST /api/v1/apps/{id}/actions/{publish,manual-publish,stop,resume}` · `DELETE /api/v1/apps/{id}` · `PATCH /api/v1/apps/{id}` · `GET /api/v1/apps/{id}` · `GET /api/v1/apps/{id}/instance` · `GET /api/v1/apps/{id}/logs` · **`GET /api/v1/apps/{app_id}/versions`(版本列表,只读)** · `GET /api/v1/apps/runtime-status`:`test_versions_endpoint_path_and_shape`(**路径与响应形状在此定死**:返回 `[{version_id, version_no, kind, terminal_state, submitted_at, is_current, is_pending}]`,按 `version_no` 倒序;数据源是 `app_version` 而非 `FlowVersionDao`,**不提供任何切换 / 回滚写口**,坑 13)→ AC-52 / `test_versions_endpoint_non_owner_gets_business_code_not_403`(坑 25)→ AC-52 / `test_stop_resume_happy_path_and_audit`→ AC-41 / `test_delete_online_returns_16104`→ AC-42 / `test_delete_non_owner_returns_16105`→ AC-44 / `test_delete_owner_success_and_hook_called`→ AC-43 / `test_publish_capacity_shortage_returns_pending_with_reason`→ AC-65 / `test_meta_patch_no_version_no_state_change`→ AC-06 / `test_logs_non_owner_gets_16161_not_403`→ AC-55 / `test_cross_tenant_app_id_returns_16101`(租户自动过滤 + 显式校验双保险)/ `test_all_endpoints_return_unified_response_model`。 + **覆盖 AC**: AC-06, AC-41, AC-42, AC-43, AC-44, AC-52, AC-55, AC-65 + **依赖**: T051, T053, T055 + +- [x] **T057**: `[MVP-核心]` 状态动作 API + 读 API 端点实现 + **文件**: `src/backend/bisheng/app_runtime/api/endpoints/apps.py`(新), `src/backend/bisheng/app_runtime/api/router.py`(挂载,已在 T035 建) + **实际偏差记录**: ①`GET /api/v1/apps/{app_id}/versions` 已按本任务要求落地,**同批回写 design §4.2 ②** 的读接口清单。②另加 `GET /api/v1/apps`(列表,AC-57 的 owner / 租户管理员两种范围)——design §4.2 ② 未列,但 T054/T055 要求的 `list_apps` 否则没有消费方;它与 T059 的构建页 UNION 列表**不冲突**(那条走 `/api/v1/flows` 一路的既有分页管线)。③路由声明顺序是硬约束:`/apps/runtime-status` 必须先于 `/apps/{app_id}`,`/apps/_unavailable` 由先 include 的 internal router 抢先——Starlette 按声明顺序匹配,反了就变成「查一个叫 runtime-status 的应用」。 + **逻辑**: FastAPI 端点,`UserPayload` 认证注入,委托 Service,`UnifiedResponseModel` 包装(§4.2 ②)。端点层**不直接 import `database/models`**(RULE-3)。**端点清单含 `GET /api/v1/apps/{app_id}/versions`**(只读版本列表,委托 T055 的 `AppQueryService`)——**design §4.2 ② 的读接口清单遗漏了这一条,落地时同批回写 design**;T062 的「版本列表」HTTP 封装、T064 的卡片只读版本下拉、T066 的版本 tab **三处共用这一个路径与形状**,不得各自发明。 + **测试**: T056 全部通过。 + **覆盖 AC**: AC-06, AC-41, AC-42, AC-43, AC-44, AC-52, AC-55, AC-65 + **依赖**: T056 + +- [x] **T058**: `[MVP-核心]` 构建页列表第三类型端到端测试(UNION + 6 组硬闸 + 租户隔离) + **文件**: `src/backend/test/app_runtime/test_build_list_third_type.py`(新) + **逻辑**: `test_third_type_end_to_end_non_empty`(`flow_type=35` 走完 `SUPPORTED_APP_TYPES` → UNION → `_application_action_map` 分桶 → `add_extra_field`,断言"列表非空**且带 `write` / `tags`**"——坑 28:只加 UNION 会在这里挂,且失败现象是**空列表不报错**,极易被当成"权限没配好"排查半天)→ AC-51 / `test_type_filter_returns_only_hosted_apps`→ AC-51 / `test_app_state_filter_covers_five_values`(应用态五值走**新参数 `app_state`**;`status` 列只投影 2/1 供既有开关复用——坑 8:`getAppsApi` 只放行 `status ∈ {1,2}`)→ AC-51 / `test_tenant_isolation_in_union_third_branch`(子租户账号列不出别租户应用——**UNION 子查询的自动过滤失效**,第三支必须手工 `build_tenant_filter_clause(App.tenant_id)`,今天**没有第三支范例可抄**,坑 21 / K5 ③)→ AC-51 / `test_owner_scope_and_tenant_admin_scope`→ AC-57 / `test_cursor_pagination_stable_across_three_branches`(keyset `(update_time, id)` 三支归并稳定)→ AC-51 / `test_tag_filter_and_tag_link_for_app`(标签预过滤 3 处 + `check_tag_link_permission` 认 `HOSTED_APP`;不改这组 = "筛选框在、结果恒空",打标还会直接 404)→ AC-51 / `test_permission_bucket_populated`(`_application_action_map` 的 `grouped` 有 `"app"` 桶;不加则"有卡片但什么都点不了")→ AC-51。 + **实际偏差记录**: ①端到端跑的是 `WorkFlowService.get_all_flows_envelope` 真实管线(新增 conftest fixture `build_list_env`:**文件型 SQLite**,因为列表查询走 async 而 `add_extra_field` 走 sync,两个引擎必须指向同一个库,`sqlite://` 内存库做不到)。②`test_cursor_pagination_stable_across_three_branches` 改名 `test_cursor_ordering_stable_across_three_branches` + 新增 `test_cursor_keyset_predicate_spans_the_merged_subquery`:**游标翻页的往返在 SQLite 上断言不了**——cursor 里带的是 ISO-8601 字符串(`...T00:01:00`)而 SQLite 的 DATETIME 存成 `... 00:01:00`,`' ' < 'T'` 让 keyset 谓词对每一行都成立、翻页永不收敛;已用「只有 flow + assistant 两支」复现同一现象,**属 harness 既有问题、与第三支无关**,MySQL / DM8 由驱动把绑定参数转成真 DATETIME。故改为断言**三支归并后的全序**(真正被第三支威胁的性质)+ keyset 谓词确实绑在三支 union 上。③`check_tag_link_permission` 的两个用例是**同步**测试并 stub 掉 `_aget_hosted_app`:`run_async_safe` 在运行中的事件循环里会直接抛错,而跨事件循环复用 aiosqlite 引擎本身就不成立。④补 `test_app_state_literals_match_the_enum`(`flow.py` 不能 import `AppState`,见 T059 偏差①)与 `test_app_state_filter_never_matches_other_types`。⑤T059 要求的既有回归扩展落在 `test/workflow/test_flow_dao_tenant_isolation.py`(7 → 8 例,三处断言各加一条 `app.tenant_id`)。 + **覆盖 AC**: AC-51, AC-57 + **依赖**: T009, T013, T049 + +- [x] **T059**: `[MVP-核心]` UNION 第三支 + `FlowType` / `ResourceTypeEnum` 枚举 + **文件**: `src/backend/bisheng/database/models/flow.py`(`FlowType:33-39` 加 `HOSTED_APP = 35`;`_build_apps_subquery:660-702` 加第三支 SELECT), `src/backend/bisheng/database/models/group_resource.py`(`ResourceTypeEnum:14-22` 加 `HOSTED_APP = 10`) + **逻辑**: D8:第三支把 `app` 表投影成同一列集 `(id, name, description, flow_type, logo, user_id, status, create_time, update_time)`——`flow_type` 投常量 35(避开已占 5/10/15/20/25/30)、`user_id` 投 `owner_user_id`、`status` 投 **2(已上线)/ 1(其余四态)**;租户条款**手工** `build_tenant_filter_clause(App.tenant_id)`,照 `flow_clause:695` / `assistant_clause:698` 两支自己加。`flow.py` import `database/models/app`(**同目录,不触 RULE-2**;这正是三张表落 `database/models/` 的原因,坑 27)。 + **跨 Feature(副作用登记,必读)**: `_build_apps_subquery:660` 有 **4 个调用方**,第三支会同时改变另两条与构建页无关的路径——`get_all_app_by_time_range_sync:810`(→ `api/services/workflow.py:1039` 的 `get_all_app_by_time_range`,按时间范围取应用)与 `get_first_app:849`(→ `src/backend/scripts/sync_increment_table.py:53`,**商业版增量同步**)。本任务须**显式确认**这两条路径接纳托管应用是期望行为(而非"顺带流进去"):增量同步侧确认托管应用行(`flow_type=35`、`status` 投 2/1)不会让同步脚本按 `Flow` 语义去取不存在的字段;时间范围侧确认返回结构一致。 + **测试**: T058 相关用例通过;**并跑既有回归 `src/backend/test/workflow/test_flow_dao_tenant_isolation.py`**(覆盖 `get_all_apps` / `aget_all_apps` / `get_all_app_by_time_range_sync` / `get_first_app` 四个方法的租户隔离)——**必须扩一个第三支用例**:托管应用行经这四个方法均不跨租户泄漏(坑 21 / K5 ③:UNION 子查询自动过滤失效,第三支的手工 `build_tenant_filter_clause` 一处写漏,四个方法一起漏)。 + **实际偏差记录**: ①**投影列集从 9 列变 10 列**:新增 `app_state`,第三支投 `App.state`,另两支投 `cast(null(), String(16))`——这是「上一批留下的卡片应用态徽标缺数据源」的落点(2026-08-17 前端批裁决 1)。放在投影而不是 `add_extra_field` 里另查一次的理由:`app_state` **同时**要能被 SQL 过滤(新参数 `app_state`),过滤器无论如何都需要这一列,再让 `add_extra_field` 补一次就是两份真相。`add_extra_field` 的 docstring 已写明「值从投影来、不要改成按卡片取详情」。②`app_state` 只挂在 `flow_type=35` 的行上(`FlowDao._app_row_to_dict`):给工作流 / 助手加一个恒 null 的新字段,是往两个前端已在解析的响应里加没人读的东西(AC-59)。③第三支 `WHERE state != 'deleted'`,与 `AppQueryService.list_apps` 同口径。④**`flow.py` 不能 import `AppState`**(RULE-2 禁 `database/models` 导 `bisheng.*.domain.*`),故在 `database/models/app.py` 落两个纯字符串常量 `APP_STATE_ONLINE` / `APP_STATE_DELETED`,并由 T058 的 `test_app_state_literals_match_the_enum` 钉住不漂移。⑤`aget_all_apps` 的 ranking 分支索引整体后移(`_used_rank` 9→10、`_sort_time` 10→11)。⑥**4 个调用方的显式确认已落到代码注释**:`get_all_app_by_time_range_sync`(遥测中间表)与 `get_first_app`(商业版增量同步起始时间)接纳托管应用是**期望行为**——两者只读 id/name/user_id/flow_type/create_time,第三支都投了,不会按 `Flow` 语义取到不存在的字段。⚠️**但遥测侧有一个需裁定的副作用**:`worker/telemetry/mid_table.py::convert_flow_type` 没有 35 的映射,托管应用会落进 `ApplicationTypeEnum.UNKNOWN` 桶;给它加枚举成员等于改一个已经写进 ES 文档、被下游看板聚合读取的契约,超出本 Feature 范围,故**保持 UNKNOWN 并在该函数加注释说明是决定不是疏漏**。 + **覆盖 AC**: AC-51, AC-57 + **依赖**: T058 + +- [x] **T060**: `[MVP-核心]` 后端 6 组硬闸(`workflow.py` + 标签体系) + **文件**: `src/backend/bisheng/api/services/workflow.py`(`SUPPORTED_APP_TYPES:76` 加 35;`_FLOW_TYPE_TO_RESOURCE_TYPE:77-80` 加 `35 → "app"`;`_application_action_map:146-179` 的 `grouped:151-154` 加 `"app": []` 桶;tag 预过滤 `:203` / `:598` / `:998-999` 加 `HOSTED_APP`;新增 `app_state` 查询参数), `src/backend/bisheng/api/services/tag.py`(`check_tag_link_permission:75-103` 认 `HOSTED_APP`) + **逻辑**: D8「后端 6 组硬闸」。`filter_supported_apps:83-84` 是公共闸、被 6 处调用,**加 1 即全放行、无需逐处改**(这组里唯一的好消息)。`add_extra_field:87-127` 的 `user_name` / `tags` / `logo` / `write` 四项对 app 直接复用,**`version_list` 不复用**(改由 T055 的只读版本源提供,坑 13)。 + **跨 Feature**: `get_online_flows_page:514` 走同一道 `SUPPORTED_APP_TYPES` 闸 → **F056 广场直接受益,不要重复改**。 + **测试**: T058 全部通过。 + **实际偏差记录**: ①`SUPPORTED_APP_TYPES` 按 design 加 35,但**另加 `enabled_app_types()`**(= `SUPPORTED_APP_TYPES` 减去运行时层未部署时不存在的类型)供公共闸与三处 `flow_type not in ...` 短路使用。理由是 AC-58 要求「开关关闭时构建页不出现任何托管应用卡片」,前端只挡住了类型下拉,一个「全部类型」的请求仍由后端回答——列表契约是后端的责任。②标签资源类型抽成 `_tag_resource_types()`,同一处开关口径覆盖 4 个调用点(`:203` 构建页 / `:598` cursor / `get_online_flows_page` 的按类型拼装 / `get_uncategorized_flows` 的两次 gather)。**`get_uncategorized_flows` 是 design 6 组硬闸清单里没点名但必须改的第 5 处**:它取的是「已打标资源」的补集,漏一个类型的效果是把已打标的托管应用误报成未分类。③`add_extra_field` 对 app 行的 `version_list` 保持为空列表(`FlowVersionDao` 不认识 `app_version`),docstring 写明「不要改成指向 flow 版本」——那个下拉会切换**工作流**的当前版本(坑 13)。④`check_tag_link_permission` 的托管应用分支不复用 `AppQueryService`:后者答的是详情页口径(owner ∪ 租户管理员),而打标问的是 F048 的 `edit`,复用等于对同一个请求套两把不同的尺子;新增模块级 `_aget_hosted_app`,`deleted` 视同不存在。⑤`app_state` 查询参数打通 `api/v1/workflow.py::read_flows` → `get_all_flows_envelope` → `_scan_visible_flows_cursor` → `aget_all_apps`(`get_all_flows` 同步路径一并加上),**不加 pattern 校验**:未知取值天然匹配不到任何行,加正则等于把五个态名字再抄一份到端点层。 + **覆盖 AC**: AC-51, AC-57 + **依赖**: T058, T059 + +- [x] **T061**: `[MVP-核心]` 工场运行时层开关组合测试(未部署回归 + 两开关正交) + **文件**: `src/backend/test/app_runtime/test_runtime_layer_switch.py`(新) + **逻辑**: `test_env_exposes_app_runtime_enabled_anonymously`(`GET /api/v1/env` 匿名可读该字段,两个 SPA 据此渲染)→ AC-62 / `test_switch_off_hides_hosted_apps_from_list`(构建页列表不出现托管应用类型与卡片)→ AC-58 / `test_switch_off_no_new_menu_or_permission_point`(升级前后角色配置面的菜单项与权限点数量不变——**零新增菜单 / 权限点**;详情页走 `build/apps/:appId` 子路由 + `permission: 'build'`)→ AC-58 / `test_no_ui_create_entry_in_any_state`(本册任何形态下均不提供托管应用的界面新建入口,唯一创建路径 = CLI 首发)→ AC-58 / `test_switch_off_no_resident_process_or_beat_task`(无新增常驻进程与定时任务)→ AC-59 / `test_platform_regression_unchanged_when_off`(既有列表 / 权限 / 审计接口行为零变化)→ AC-59 / `test_two_switches_orthogonal_four_combinations_boot`(`app_runtime.enabled` × `open_platform.enabled` 四种组合下平台均可正常启动)→ AC-61 / `test_unknown_yaml_key_rejects_boot`(回归坑 23:先加 `config.yaml` 键后发代码 → `KeyError` 拒启,故升级顺序必须是先代码后键)→ AC-60。 + **实际偏差记录**: ①`test_env_exposes_app_runtime_enabled_anonymously` **在类上 monkeypatch** `get_from_db` / `get_system_login_method` / `get_knowledge`:`ConfigService` 是开了 `validate_assignment` 的 pydantic 模型,往实例上塞非字段会直接抛 ValidationError。②「零新增菜单 / 权限点」落成 `WebMenuResource` 与 `AccessType` 的**成员全集断言**——托管应用挂在既有 `build` 菜单下、判权用既有 F048 action code,多一个成员意味着升级后每个存量角色的配置面形状都变了。③「无界面新建入口」断言 F054 router **不存在任何创建型 POST**(`/actions/*` 是对已存在应用的状态迁移、`/internal/*` 是 app-proxy 的 HMAC 通道,均排除)——无论 SPA 渲染成什么样,代码层面唯一的创建路径是 CLI 首发。④「无常驻进程 / 定时任务」断言 beat schedule 里没有 `app_runtime` / `app_publish` 任务,且 `bisheng.app_runtime` 不自带 celery app。⑤四组合启动与「未知 YAML 顶层键拒启」直接调 `ConfigService.load_settings_from_yaml` 落临时 config.yaml 断言。⑥另加 `test_hosted_rows_keep_the_legacy_keys_plus_app_state`:托管应用卡片的字段集 = 既有字段集 **+ 恰好一个** `app_state`。 + **覆盖 AC**: AC-58, AC-59, AC-60, AC-61, AC-62 + **依赖**: T005, T057, T060 + +### Wave 3 · `[MVP-核心]` 前端 Platform(手动验证) + +- [x] **T062**: `[MVP-核心]` Platform:API 封装 + `appRuntimeEnabled` 读取 + **文件**: `src/frontend/platform/src/controllers/API/hostedApp.ts`(新), `src/frontend/platform/src/contexts/locationContext.tsx:75-92` + `src/frontend/platform/src/types/api/app.ts`(`appConfig.appRuntimeEnabled`) + **实际偏差记录**: ①封装含 `GET /api/v1/apps`(列表,T057 已实装)与 `getHostedAppErrorCode/Message` 两个信封读取器——`silent: true` 下拦截器 reject 的是信封而非字符串,不给读取器每个调用点都要自己 `as any` 拆。②`/apps/**` 的**所有只读 GET** 一律 `silent: true`(不只日志):详情 / 实例 / 版本对非 owner 都会答 161xx,任一处漏掉就会在拦截器里退化成整页跳 `/403`(坑 25)。 + **逻辑**: 全部 HTTP 封装(详情 / 实例 / 日志 / 运行环境状态 / 五个状态动作 / 元信息 PATCH / **版本列表 `GET /api/v1/apps/{app_id}/versions`**——路径与响应形状由 T056 定死、T057 实现,T064 的卡片只读版本下拉与 T066 的版本 tab 共用本封装,**三处不得各自发明**);**不 import axios**(C7),用仓内既有 request 模块;日志等只读 GET 传 `silent: true` 或依赖业务码,**不得触发 403/404 整页跳转**(坑 25)。`appRuntimeEnabled` 从 `/api/v1/env` 读,供构建页 / 详情页 / 引导页渲染。 + **手动验证**: 关闭开关时 `appConfig.appRuntimeEnabled === false`;`pnpm typecheck` 通过。 + **覆盖 AC**: AC-62 + **依赖**: T057, T061 + +- [x] **T063**: `[MVP-核心]` Platform:构建页第三类型(筛选 / 分派 / 扩展点) + **文件**(**已按代码事实订正——`pages/BuildPage/components/` 目录不存在,不得新建同名组件文件,否则会造出与真身并存的死组件**): `src/frontend/platform/src/pages/BuildPage/apps.tsx`(`SelectAppStatus` 就地定义在 **`:37`**、`SelectType` 在 **`:57`**,二者均为本文件内的 `export const`,"就地扩展" = 改这两个函数本身), `src/frontend/platform/src/controllers/API/flow.ts`(**`getAppsApi` 在 `:177`,不在 `apps.tsx`**:`type` 联合类型、类型→数值 map、新增 `app_state` 查询参数都改在这里), `src/frontend/platform/src/types/app.ts`(**`AppNumType` 在 `:9-12` 只有 `FLOW=10` / `ASSISTANT=5`**,必须新增 `HOSTED_APP = 35`,并同步 `AppType` 枚举与 `AppTypeToNum` / `AppNumToType` 两张 map;不加则角标 / 头像 / 类型 map **全部落默认分支**,现象是"卡片出来了但类型显示成工作流"), `src/frontend/platform/src/components/bs-comp/cardComponent/avatar.tsx`(**共享组件** `AppAvator:15`,图标 map 按 `AppNumType` 分支,需加第三类型图标) + **实际偏差记录**: ①`apps.tsx` 的默认导出组件由 `apps` 改名 **`Apps`**——它带着 **16 条 frozen `react-hooks/rules-of-hooks`**(小写函数名 = ESLint 不认它是组件),本任务要加 `useContext` / `useState`,不改名就会突破冻结上限直接红;改名后该规则整条清零。②状态筛选按 AC-51 字面给**五值**,但「已删除」在当前列表口径下恒空(`AppQueryService.list_apps` 与 UNION 第三支都排除 `state='deleted'`)——见文末偏差表,需产品裁定是留还是砍。③三个资源桶按 `flow_type` **精确分派**:原 workflow 桶写的是 `!== ASSISTANT`,托管应用会掉进去按 workflow 类型判权,结果是「卡片在、动作全空」。 + **逻辑**: D13 的 14 处扩展点中属列表骨架的部分:`SelectType`(`apps.tsx:57`)加「托管应用」并加**条件 prop**(坑 12:该组件被模板页 `appTemps.tsx:82` 复用,直接加会让模板页出现一个点了 404 的类型;且只在 `appRuntimeEnabled` 时给)· `SelectAppStatus`(`apps.tsx:37`)覆盖应用态五值(含「待上线(资源不足)」)→ 走新参数 `app_state` · **`types/app.ts` 的 `AppNumType` 加 `HOSTED_APP=35` 并同步 `AppTypeToNum` / `AppNumToType`** · `TypeNames` / `typeCnNames`(角标)· `APP_ACTIONS` 第三桶 `useResourceActions('app', …)` · `handleOpenPermission` 的 `typeMap` 补 `35 → 'app'`(坑 7:默认回落 `'workflow'` → 弹窗对着 workflow 类型开、registry 校验 record 类型不符报 19003,现象是"弹窗打开但一片红")· `handleSetting` → 详情页 · **`controllers/API/flow.ts:177` 的 `getAppsApi`** 的 `type` 联合类型与 map · `LabelSelect` 的 `ResourceTypeEnum` 映射 · **`cardComponent/avatar.tsx` 的 `AppAvator`** 图标。**复用既有搜索与标签筛选**。 + **跨 Feature**: `components/bs-comp/cardComponent/avatar.tsx` 是 workflow / 助手 / 模板页共用的**共享头像组件**(与 T065 的 `cardComponent/index.tsx` 同族)——**只加第三个分支,既有两类型的图标与尺寸逐像素不变**;回归验证随 T065 一并由 F056 承接 GOV-01 验收 6。`types/app.ts` 的 `AppNumType` 同为全平台共享枚举,**只增值、不改既有值**(`FLOW=10` / `ASSISTANT=5` 与 `FlowType` 数值口径一一对应,改动会连带后端 UNION 分支错位)。 + **手动验证**: 构建 → 应用:类型筛选出现「托管应用」(关开关后消失);选中后只列托管应用;状态筛选五值可选并生效;搜索 / 标签筛选正常;模板管理页的类型下拉**不含**托管应用;**托管应用卡片头像显示第三类型图标而非默认回落图标**;工作流 / 助手卡片的头像、角标、类型文案零变化。 + **覆盖 AC**: AC-51, AC-57, AC-58 + **依赖**: T062, T060 + +- [x] **T064**: `[MVP-核心]` Platform:`HostedAppCard` + `useHostedAppActions` + **文件**: `src/frontend/platform/src/pages/BuildPage/HostedAppCard.tsx`(新), `src/frontend/platform/src/pages/BuildPage/useHostedAppActions.ts`(新) + **实际偏差记录**: ①只读版本下拉**打开时才拉** `GET /apps/{id}/versions`:一页 14 张卡片挂载即请求会打出 14 个没人要的请求;未打开时触发器显示占位。②卡片的应用态徽标依赖列表行的 `app_state`,而 design D8 的 UNION 投影列集里**没有这个字段**(见文末偏差表)——现取 `app_state ?? (status===2 ? 'online' : undefined)`,缺字段时只对「已上线」出徽标、其余不猜。③过滤掉 `version_id` 为空的版本行:空串会让 Radix `SelectItem` 抛错并连带崩掉整个卡片列表(仓内已踩过)。 + **逻辑**: 卡片装配(图标 / 名称 / 描述 / 创建用户 / 类型角标 / 应用态徽标 / **只读**版本下拉〔不复用会去改工作流当前版本的 `CardSelectVersion`,坑 13〕/ 上下线开关)+ 三动作 hook(下线 / 重新上线 / 删除 + 二次确认文案:下线明示"入口与广场将呈已下线";删除**不要求输入应用名**、文案明示将一并删除**代码、对话历史与应用生产数据**)。⚙️ 菜单**只出现「管理权限」与「删除」**(`onAddTemp=undefined` / `showCopy=false` 即隐藏"创建模板 / 复制",**零改共享组件**);删除项仅 owner 可见、已上线时置灰提示「请先下线」。**卡片与详情页共用同一 hook**,避免两份文案漂移。`apps.tsx` 已 426 行 → 抽出本文件是 ≤600 行硬规逼出来的。 + **(2026-08-18 补记)** 卡片右下角的应用态徽标收窄为只在 `draft` / `pending_capacity` 出现(AC-51 同批收窄);`onChanged` 由 `() => void` 改为 `(kind, result)`,调用方先用动作返回的 `state` 就地 patch 行、再 `reload()`——下线要等 `docker stop` 的 10s SIGTERM 宽限,再叠一次列表往返足以让人以为没生效。失败也回调(`result` 为 null),因为最常见的失败恰是「其实已经生效了」的 16102。 + **手动验证**: 卡片字段齐全;⚙️ 只有两项;已上线时删除置灰并有 Tooltip;下线 → 二次确认 → 徽标变已下线、开关翻转;重新上线恢复;版本下拉只读(点击不触发任何写请求,Network 面板确认)。 + **覆盖 AC**: AC-42, AC-52, AC-53 + **依赖**: T063, T065 + +- [x] **T065**: `[MVP-核心]` Platform:`CardComponent` 两个可选 prop(共享组件) + **文件**: `src/frontend/platform/src/components/bs-comp/cardComponent/index.tsx` + **实际偏差记录**: `switchTexts` 落的是**状态口径**(已上线 / 已下线)而非 design D13 写的动作口径(下线 / 重新上线)——`Switch` 的 `texts[0]` 只在 checked 时渲染、`texts[1]` 只在 unchecked 时渲染,塞动作词会读成「当前处于下线」;且「重新上线」四字放不进 `w-12` 的轨道。prop 名与可覆盖能力与 design 一致。 + **(2026-08-18 推翻)** 用户要求托管应用与工作流用同一套开关文案,`switchTexts` 已删除,卡片回到共享的 `t('skills.online/offline')` = **「上线 / 下线」**——动作口径落两个字,`w-12` 放得下,当初「四字放不进轨道」的约束随之消失;`switchTexts` 那条 prop 与其单测一并移除。 + **逻辑**: 新增 `deleteDisabledHint?: string`(`DropdownMenuItem disabled` + Tooltip)与 `switchTexts?: {on: string; off: string}`(上下线 Switch 文案由 `t('skills.online/offline')` 改为可覆盖,表达"下线 / 重新上线")**〔`switchTexts` 于 2026-08-18 删除,见本任务补记;`deleteDisabledHint` 保留〕**。坑 6:现状是 `!checked && onDelete` **整项不渲染**(`:239-244`),照现状实现 AC-42 会变成"看不见"而非"置灰"。**两个 prop 均可选、缺省行为与今天逐像素一致**。 + **跨 Feature**: workflow / assistant 共用该组件,回归验证由 F056 承接 GOV-01 验收 6。 + **手动验证**: 工作流 / 助手卡片行为无任何变化(⚙️ 菜单、上下线文案、删除项显隐);托管应用卡片已上线时删除项置灰 + Tooltip。 + **覆盖 AC**: AC-42, AC-53 + **依赖**: T062 + +- [x] **T066**: `[MVP-核心]` Platform:应用详情页壳 + 路由 + **文件**: `src/frontend/platform/src/pages/BuildPage/hostedApp/{index.tsx,Header.tsx,types.ts,hooks/useHostedApp.ts}`(新), `src/frontend/platform/src/routes/index.tsx`(`MainLayout` 子路由 `build/apps/:appId`,`permission: 'build'`) + **实际偏差记录**: ①页面同时给**具名导出 + 默认导出**:`routes/index.tsx` 用 `React.lazy` 必须要默认导出,而仓库组件规范要具名导出。②版本 tab **不是空壳**,直接用 T062 的共享封装渲染只读版本表(T062 正文已写明「三处共用本封装」),F055 的管线列以 `contentSlot` 接入。③当前 tab 落 URL query(`?tab=`)而不是组件 state——刷新回到「发布」会让运行日志 tab 每次都要重新找。 + **逻辑**: D13-B:**零新增菜单 / 权限点**(AC-58)。四 tab(发布 · 数据 · 运行日志 · 版本)用 `bs-ui/tabs`(范式 `pages/SystemPage/index.tsx:54-118`);**无左侧对话区、无管家**;数据 tab 与版本 tab 本期只出壳与空态(数据 tab 内容 → T088;版本 tab 列表 → F055)。副作用:顶部「应用 / 工具 / 工作台配置」子 tab 在详情页消失(`layout/HeaderMenu.tsx:24` 只在精确等于三个路径时渲染),与知识库详情页同行为、可接受。react-query **禁用**(lint 冻结),用 `useState + useEffect`。 + **手动验证**: 点卡片进入 `/build/apps/{id}`;四 tab 可切换;刷新页面不丢 tab;非 owner 且非租户管理员访问 → 看到无权限提示**而不是被甩到 `/403` 页**。 + **覆盖 AC**: AC-54, AC-58 + **依赖**: T062 + +- [x] **T067**: `[MVP-核心]` Platform:发布 tab 最小形态 + **文件**: `src/frontend/platform/src/pages/BuildPage/hostedApp/tabs/PublishTab.tsx`(新) + **实际偏差记录**: 留给 F055 / F056 的位子是三个**具名 slot prop**(`pipelineSlot` / `visibilitySlot` / `dangerZoneSlot`)而不是 `children`:`children` 只有一个位置,三个 Feature 会在同一处打架。运营动作与实例状态区由本 Feature 填。 + **逻辑**: 决议-6:本 Feature 只交付**应用态徽标 + 入口链接(可复制)+ 运营动作(下线 / 重新上线 / 手动上线)**;入口 URL **用后端返回的完整地址**,不用 `location.origin` 拼(dev 下 origin 是 :3001 且 `/apps` 不在 vite 代理内)。**用 slot / children 给 F055(管线 / 能力 / 档位 / 危险操作区)与 F056(可见范围区)留位**,避免三个 Feature 改同一文件冲突。二维码属 Wave 5(T091)。运营动作复用 T064 的 `useHostedAppActions`(同一份确认文案)。 + **手动验证**: 徽标与应用态一致;复制入口链接后粘贴可直达 `/apps/{slug}`;下线 / 重新上线 / 手动上线三按钮按应用态正确启用禁用;F055 / F056 的 slot 位为空时布局不塌。 + **覆盖 AC**: AC-41, AC-54, AC-65 + **依赖**: T066, T064 + +- [x] **T068**: `[MVP-核心]` Platform:运行日志 tab + **文件**: `src/frontend/platform/src/pages/BuildPage/hostedApp/tabs/LogsTab.tsx`(新) + **实际偏差记录**: 时间筛选只有**一个起始时间选择器**,不是 DatePicker×2——`GET /v1/apps/{id}/logs` 的契约参数是 `tail / since / keyword`,**没有 `until`**,且返回的 `lines` 是无时间戳的纯字符串(docker logs 未带 `-t`),端侧无从按结束时间过滤。放两个选择器只会让第二个是假的。见文末偏差表。 + **逻辑**: platform 无先例的自建件(E3 §1.8):筛选栏(`DatePicker`×2 时间范围 + 关键字 + 刷新按钮)+ `
` 等宽块 + 空态说明 + `setInterval` 轮询(无 `usePolling` hook)。只读;只呈现应用运行输出与错误信息(含错误栈),**不含平台侧日志**;权限失败时显示"无权查看"提示而非跳 403(业务码 `16161`,坑 25)。
+  **手动验证**: `curl` 触发应用打印几条 → tab 内可见;关键字筛选生效;时间范围生效;空应用显示空态;非 owner 账号看到提示而非被甩出页面。
+  **覆盖 AC**: AC-55
+  **依赖**: T066
+
+- [x] **T069**: `[MVP-核心]` Platform:`apps/*` 未部署引导页路由
+  **文件**: `src/frontend/platform/src/routes/index.tsx`(public + private **两张路由表都加** `apps/*`), `src/frontend/platform/src/pages/AppRuntimeGuide/index.tsx`(新)
+  **实际偏差记录**: 引导页文案分两句:开关**开着**时说「未部署或暂不可用」,关着时说「未启用」——两者的下一步动作不同(一个找运维、一个找超管改配置),合并成一句会把人指错方向。
+  **逻辑**: D5「未部署引导页两层」之第一层:整层没装时 nginx 里根本没有 `location /apps/`,`/apps/x` 落 `location /` → platform SPA(坑 15:现状已登录 `* → /404`、未登录 → `LoginPage`,AC-30 在默认路径上不成立)。本路由渲染「本环境未启用应用工场」引导页(引导联系平台超管),读匿名 `/api/v1/env.app_runtime_enabled` 判定。**零 nginx 变更**;已部署环境该路由永远命中不到(被更长前缀截走)。
+  **手动验证**: 关开关 + 移除 nginx location 后访问 `/apps/foo` → 引导页(**不是 404 / 5xx**,登录与未登录两种状态都试)。
+  **覆盖 AC**: AC-30
+  **依赖**: T062
+
+- [x] **T070**: `[MVP-核心]` Platform:三语 i18n + `apps.tsx` 存量中文抽键
+  **文件**: `src/frontend/platform/public/locales/{zh-Hans,en,ja}/*.json`(本 Feature 新增 key), `src/frontend/platform/src/pages/BuildPage/apps.tsx`(存量硬编码中文抽键,如 `:269 '无编辑权限'`)
+  **实际偏差记录**: 新 key 落 **`bs.json`(默认 ns)的顶层 `hostedApp`**,不新开 namespace——新 ns 要么进 `i18n.js` 的 eager `ns` 列表(每页多一次请求),要么靠 `loadNamespaces` 懒加载(首帧渲染原始 key)。顺带清掉 `apps.tsx` 的 frozen 中文(`no-restricted-syntax` 1→0);`pnpm lint:prune` 后该文件的 `no-explicit-any` 12→6、`no-unused-vars` 4→3、`rules-of-hooks` 16→0。该文件仍留 `// @ts-strict-ignore`:去掉后有 **7 条与本 Feature 无关的存量 strict 报错**(`CardComponent` 的 `type: 'assist'`、`createAppModalRef` 可能为 null 等),不在本任务范围。
+  **逻辑**: 新增 key 三语同 PR(CI `pnpm check-i18n` 校验 key 平价);按"谁触碰谁还债"把 `apps.tsx` 的 frozen 中文抽到 i18n,随后跑 `pnpm lint:prune`、若该文件已过 strict 则删其 `// @ts-strict-ignore` 头。错误码文案**只在** `packages/locales` 的 `api_errors` 域(T004 已落),此处不重复。
+  **手动验证**: 三语切换下构建页与详情页无中文残留;`pnpm lint` + `pnpm typecheck`(从 `src/frontend/`)通过。
+  **覆盖 AC**: AC-51
+  **依赖**: T063, T064, T066, T067, T068
+
+### Wave 3 · `[MVP-核心]` 前端 Client(手动验证)
+
+- [x] **T071**: `[MVP-核心]` Client:读取工场运行时层开关
+  **文件**: `src/frontend/client/src/@types/chat.ts:102`(`BishengConfig.app_runtime_enabled`), `src/frontend/client/src/hooks/useAppRuntimeEnabled.ts`(新)
+  **逻辑**: 用 **react-query v4** 拉 `/api/v1/env`(**不得 import recoil**——lint 冻结)。F056 的广场据此决定是否渲染托管应用;本 Feature 只交付读取能力与类型,不改广场。
+  **手动验证**: 开关关 → hook 返回 false;`pnpm typecheck` 通过;client 现有页面行为零变化。
+  **覆盖 AC**: AC-62
+  **依赖**: T005
+  ✅ 2026-09-10 核实已落地(`src/frontend/client/src/hooks/useAppRuntimeEnabled.ts` + `useAppRuntimeEnabled.test.tsx` 5 例;`hooks/index.ts` 已导出、`@types/chat.ts` 已带 `app_runtime_enabled`)
+
+### Wave 3 · `[MVP-核心]` 114 部署增量与联调
+
+- [x] **T072**: `[MVP-核心]` systemd 两单元 + `deploy.sh` / `smoke.sh` 增量
+  **文件**: `features/v3.0.0/054-app-domain-runtime/deploy/bisheng-runtime-manager.service`(新,模板), `features/v3.0.0/054-app-domain-runtime/deploy/bisheng-app-proxy.service`(新,模板)
+  **逻辑**: §4.2 ⑧:`bisheng-runtime-manager.service`(`127.0.0.1:8091`、`After=docker.service`、以 root 或 docker 组运行)· `bisheng-app-proxy.service`(`127.0.0.1:8090`、`After=bisheng-api.service`、**不需要 docker 权限**);两者追加进 `bisheng.target` 的 `Wants=` 与 `deploy.sh` 的 `SERVICES=`、`smoke.sh` 增两条探针。⚠️ **这些文件不在产品仓**——真身在独立仓 `~/Projects/bisheng-ops/`(K10),本任务在 feature 目录交付模板 + 落库说明,实际写入由 T075 在 114 上执行。⚠️ 产品仓根 `./deploy.sh`(钉 `feat/2.5.0` 的 nohup 老脚本)与 `docker/deploy.sh`(compose 运维壳)**都不是 114 在用的**,不要引用。
+  **回滚**: `systemctl disable --now` 两单元 + 从 `bisheng.target` 与 `SERVICES=` 移除;平台其余服务不受影响。
+  **依赖**: T018, T036
+  **落地记录(2026-09-16,114 实机)**:三个单元模板都在 114 上真装真跑(`bisheng-runtime-manager` / `bisheng-app-proxy` / 本轮新增的 `bisheng-egress-proxy`),`deploy.sh` 全程绿、`smoke.sh` `SMOKE OK`。同批修掉三处**只有真机才暴露**的缺陷:
+  ① **单元把 `--host 127.0.0.1` 写死在 ExecStart**,`RTM_HOST` 设了不生效;附件句柄要求应用容器够得到本进程,于是配置看着对、容器 `curl` 连接被拒。改成 `--host ${RTM_HOST}`(见 `storage.py` 的自检同批修订)。
+  ② **`smoke.sh` 写死按 127.0.0.1 探 8091**:改绑网桥网关后服务完全正常却报 `SMOKE FAILED`。改为从同一份 EnvironmentFile 读 `RTM_HOST`(ops 仓 `4d7c1d0`)。
+  ③ **`deploy.sh` 从不同步子包依赖**:`runtime-manager` / `app-proxy` 各有自己的 venv,F054 T084 给前者加的 `minio` 在 114 上缺失——SDK 是惰性 import,于是服务照起、healthz 200、smoke 过,只有真存附件时答 503。新增 3.5/6 步(ops 仓 `32a50ee`),并把 `bisheng-egress-proxy` 按「装了才重启」加进 `SERVICES`。
+
+- [x] **T073**: `[MVP-核心]` nginx `location /apps/`(仓内两份同构)
+  **文件**: `docker/nginx/conf.d/default.conf`(compose 挂载,权威), `src/frontend/nginx.conf`(镜像内置同构副本)
+  **逻辑**: D5:新增 `location /apps/`(前缀匹配,长于 `/` 故优先于 platform SPA fallback;不与正则 `~ ^(/workspace)?/api(/|$)` 冲突)。关键指令 = **变量式 upstream + `resolver`(compose 用 127.0.0.11)延迟解析**(坑 14:nginx 在 config load 时解析静态 `proxy_pass` 主机名,profile 未启动 → 容器名不可解析 → **nginx 整个起不来**,客户升级后前端白屏)+ `proxy_http_version 1.1` + `Upgrade/Connection` 头(复用文件顶部既有 `map`,`default.conf:3-6`)+ 长 `proxy_read_timeout` + `proxy_buffering off` + `error_page 502 503 504 = @apps_unavailable` → 反代 backend 的 `GET /api/v1/apps/_unavailable`。**两份必须同时改**(坑 16:`platform/nginx.conf` / `client/nginx.conf` 是历史残留、不用改);114 的两份在仓外(`/etc/nginx/conf.d/bisheng-lilu.conf` 4101 与 `bisheng-external-13000.conf`),由 T075 同批改。
+  **回滚**: 删除该 location 段;`/apps/*` 回落 platform SPA 引导页(T069),不产生 404 / 5xx。
+  **实际偏差记录**(2026-08-19 核实并勾选,**实现早于勾选**): 仓内两份已同构落地——`docker/nginx/conf.d/default.conf:65-103` 与 `src/frontend/nginx.conf:49-87`,逐指令核对一致(变量式 upstream + `resolver 127.0.0.11` + `proxy_http_version 1.1` + `Upgrade/Connection` + `proxy_read_timeout 3600s` + `proxy_buffering off` + `error_page 502 503 504 = @apps_unavailable`),唯一差异是 backend upstream 的写法(前者 `http://backend_server`,后者 `http://backend:7860`),各自符合所在文件的上游声明方式,不是漂移。两份都刻意**不开 `proxy_intercept_errors`**(开了会把应用自己返回的 502/503 换成平台兜底页 = 替应用说谎),并都在注释里记了「命名 location 的 `proxy_pass` 不能带 URI 部分,目标路径只能靠 `rewrite` 设定」这条实测踩过的坑。114 的两份在仓外(T075)。
+  **依赖**: T045, T035, T069
+
+- [ ] **T074**: `[MVP-核心]` compose 两个 service + profile 表达「整层不装」 **🟡 部分完成——配置面已交付并有机器守卫,但 compose 形态从未真机起过,故不勾**
+  **文件**: `docker/docker-compose.yml`(**真名是 `.yml` 不是 `.yaml`**;新增 `runtime-manager` / `app-proxy` 两个 service,**不 publish 端口**,加 `profiles: [app-runtime]`), `docker/deploy.sh`(`ALL_SERVICES` 增两项 + `--profile` 支持), `docker/verify-app-runtime-compose.sh` + `docker/verify_app_runtime_compose.py`(新,静态契约校验), `.github/workflows/app-runtime-quality.yml`(新,CI)
+  **逻辑**: GOV-10「整层可不装」的 compose 形态表达;仓内无 profiles 先例,需在 `deploy.sh` 加 `--profile` 传递。两 service 与 backend 同网络。~~`bisheng-apps` bridge 网络由 runtime-manager 首次启动时创建~~ ——**这句是错的**,见下方偏差记录缺陷 2:runtime-manager 只**读**这张网(`docker_backend.py:161-166` 明写「网络由部署方创建」),必须由 compose 顶级 `networks:` 声明。
+  **回滚**: 不带 `--profile app-runtime` 启动即整层缺席,平台其余服务零变化(AC-59)。
+  **实际偏差记录**(2026-08-19 收口批): 本任务此前是「**验了能解析、没验能不能跑**」的经典形态——`docker compose config` 通过、单测全绿,但**照这份 compose 装出来的应用工场跑不起来**,且三个缺陷全部符合同一个模式:**systemd 形态恰好正确,所以掩盖了 compose 形态**(114 是 systemd,跑通了也不能证明 compose 跑得通)。三个真缺陷:
+  1. **app-proxy 环境变量名全错**:compose 写 `APP_PROXY_BACKEND_BASE_URL` / `APP_PROXY_HMAC_SECRET`,而 `app_proxy/config.py` 读的是 `APP_PROXY_BACKEND_BASE` / `APP_PROXY_BACKEND_SECRET`;`APP_PROXY_MANAGER_BASE` / `APP_PROXY_MANAGER_SECRET` **完全缺失**。以 systemd 单元文件 + `config.py` 顶部契约表为真相修正,并补 `depends_on: runtime-manager`(路由表的唯一真相)。runtime-manager 侧四个 `RTM_*` 名字核对无同类漂移。
+  2. **`bisheng-apps` 网络无人创建**:compose 顶级新增 `networks: bisheng-apps: {name: bisheng-apps, driver: bridge}`。`name:` **必须显式写死**——否则 compose 会前缀成 `docker_bisheng-apps`,而应用容器是 runtime-manager 用裸 Docker API 建的、`NetworkMode` 取 `DEFAULT_NETWORK = "bisheng-apps"` 这个真名,对不上就 404。两个 service 同时接 `default` + `bisheng-apps`:接前者是因为 nginx→app-proxy(变量式 upstream 按服务名解析)与 backend→runtime-manager 都靠 default;接后者是因为 rtm 的启动探活是**进程直接 HTTP 打应用容器的 bridge IP**(`probe.py:95`)、app-proxy 的 upstream 也是 bridge IP(design D5.1),跨 bridge 会被 docker 隔离规则丢包。⚠️ 一旦给 service 写了 `networks` 它就**不再自动接 default**,两张都得列。
+  3. **bind 路径语义**:`HostConfig.Binds` 里的源路径是 **dockerd(宿主)坐标系**,而 rtm 容器里的 `RTM_DATA_ROOT` 是容器坐标系——照原样传等于把宿主上一个不存在的路径挂进应用容器,表现是「可写但写错地方」这种沉默故障。修法是把两个概念拆开:`config.py` 新增 `host_data_root`(`RTM_HOST_DATA_ROOT`,空 = 与 `data_root` 相同 = systemd 形态)+ `host_app_data_dir(app_id)`;`lifecycle.build_container_payload` 的 `Binds` **只此一处**改用它,`deploy()` 的 mkdir/chown、`destroy()` 的 rmtree、reconciler 的 mkdir 全部保持 `app_data_dir`(那些跑在本进程里)。compose 里 `RTM_DATA_ROOT: /app-data` + `RTM_HOST_DATA_ROOT: ${BISHENG_APP_DATA_ROOT:-/opt/bisheng/app-data}`,且卷的源路径写成**与之逐字相同的表达式**。`readonly.py` 预检加一条 `host_data_root_mapping`(设了但不是绝对路径 → FAIL)。
+  **防回归**: 新建 `docker/verify-app-runtime-compose.sh`(+ `verify_app_runtime_compose.py` 做 AST 分析,纯 stdlib,不 import 部署代码):17 条断言,含「compose 声明的每个 `APP_PROXY_*` / `RTM_*` 变量名都能在对应 `config.py` 找到读取点」与**反向**的必填校验(清单放在两个 `config.py` 的 `REQUIRED_ENV` / `CONTAINERISED_REQUIRED_ENV` 常量里,不在脚本里抄一份会漂的副本)。反向自测:把三个原始缺陷逐一还原 → **7 项失败、exit 1**,一个不漏。新建 `.github/workflows/app-runtime-quality.yml`(compose 契约 job + runtime-manager / app-proxy 各跑 ruff + pytest,顺带补上「仓库 CI 对这两个包一个测试都不跑」)。
+  **仍缺(为什么不勾)**: **没有任何人真的用 compose 起过这两个 service**。静态校验只能保证配置写对了,证明不了 3 中「容器内往 `/app-data` 写文件后能在宿主 `$BISHENG_APP_DATA_ROOT` 下看到」。最小验证步骤已写进 `bash docker/verify-app-runtime-compose.sh --howto`(只 build 这两个小镜像 → `up -d --no-deps runtime-manager app-proxy`,`--no-deps` 是关键,否则 `depends_on` 会把 backend + mysql/redis/openfga 全拖起来)。另:默认数据根由 `${DOCKER_VOLUME_DIRECTORY:-.}/data/app-runtime` 改为 `${BISHENG_APP_DATA_ROOT:-/opt/bisheng/app-data}`(环境变量必须是绝对路径,而 `DOCKER_VOLUME_DIRECTORY` 默认是相对的 `.`);compose 形态下这层从没跑起来过,不存在待迁移的存量数据。
+  **依赖**: T072, T073
+
+- [x] **T075**: `[MVP-核心]` 114 部署与手动验证(剧本步 4–8)
+  **文件**: `features/v3.0.0/054-app-domain-runtime/tasks.md`(本文「114 部署记录」节追加结果)
+  **逻辑**: 按 design §7「114 手动验证」执行。**顺序不可换**:`bash /opt/bisheng-ops/deploy.sh` 发代码 → **再**往 `config.yaml` 加 `app_runtime: enabled: true`(坑 23)→ 跑 T017 升级脚本 `plan` 再 `--apply` → **全进程重启**(API / celery×3 / beat / linsight worker,坑 4)→ 装两个 systemd 单元(T072)与 nginx location(T073,114 的两份在仓外)。验证 0–6 步:前置自检(`/api/v1/env.app_runtime_enabled=true`、两单元 active、`runtime-status` 的 `supported_runtimes=["python3.11"]` 与容量快照)· 上线后 `docker inspect` 核对 CPU / 内存 / `ReadonlyRootfs=true` / **无 `Ports`** · ⚙️「管理权限」能搜到主体(验坑 2)· **用非管理员 + 中文姓名账号**访问 `/apps/{slug}`(admin 短路 ReBAC,坑 26;中文姓名验坑 9)· 无痕访问 `?a=1#b` 登录后回到原地址(验坑 11)· 伪造头 `curl -H "X_BiSheng_User_Id: 1"` 应用仍读到真实访问者 · `docker kill` ≤5 分钟自愈 · `systemctl restart bisheng-runtime-manager` 期间应用**零中断**。
+  **测试降级**: 需 docker + 真实环境,无法自动化——114 手动验证,结果逐条记入本文「114 部署记录」。
+  **覆盖 AC**: AC-13, AC-20, AC-22, AC-25, AC-26, AC-27, AC-31, AC-32, AC-33, AC-46, AC-47, AC-51, AC-55, AC-60, AC-63
+  **依赖**: T017, T057, T061, T070, T071, T074
+  **114 验证记录(2026-09-16,分支 `3.0-vibe` @ `3cf0df62a`)**:
+  - **部署**:`deploy.sh` 绿(alembic 跑了本轮新增的 `f055_credential_hosted_app_subject`),9 个 unit active,`SMOKE OK`。
+  - **入口判定四支全部真机走通**(AC-29 / AC-31 / AC-12):同租户非管理员无授权 → **403 / 16142**;授予 viewer 后同一人同一 URL → **200 真页面**;撤销后 → 回到 403;不存在的 slug → **404 / 16144**;未登录 → **401 / 16141**;跨租户(`shuiwu`,租户 36)→ **404 而非 403**,与 AC-29 一致。授予/撤销都走平台自己的 `grants:mutate`,验证完即撤。
+  - **重新上线**(AC-26):`POST /apps/{id}/actions/resume` → `phase=running`,新代次容器起来、旧代次 30s 后退休。
+  - **附件存储句柄**(AC-45):在**应用容器内**用注入的 `BISHENG_APP_STORAGE_ENDPOINT` + `BISHENG_APP_STORAGE_TOKEN` 走完 PUT → GET → META → LIST → DELETE,全 200。
+  - **出站白名单双层**(AC-16):容器内直连外网 → 被 `DOCKER-USER` 丢包(curl exit 28);经注入的 `HTTP_PROXY` 访问白名单域名 → **200**;访问未声明域名 → **`Tunnel connection failed: 403 Forbidden`**;DNS 仍可解析(UDP 封禁没有误伤容器内嵌 DNS)。
+  - **未覆盖**:WebSocket 反代三不变量、「发布中 / 恢复中」过渡页(114 上没有长连接应用,也没有能观察到的发布窗口——`deploy` 是同步的,见 `contracts-runtime-manager.md` §9)。
+
+### Wave 4 · `[MVP-114]` 纵切紧随项(design §8 优先级 1–3;**不得裁掉**)
+
+- [x] **T076**: `[MVP-114]` 出站白名单双层测试
+  **文件**: `src/runtime-manager/tests/test_egress.py`(新)
+  **逻辑**: 构建期只放行配置的包源与平台分发端点;运行期默认封禁一切出站、只放行平台 API 与 manifest 声明域名;直连 IP / UDP 一律阻断(D12;A DNS-only 与 B 换 runtime 均已被否)。
+  **测试降级**: 需 docker,CI 中间件阶段 + 114 手动验证。
+  **覆盖 AC**: AC-16
+  **依赖**: T029
+  **落地记录**(2026-09-16):`tests/test_egress.py` 54 例全绿(另 3 例 `@pytest.mark.docker` 默认跳过)。四段:判定(AC-16 逐句:声明域名 / 未声明 / 通配区含 apex 但不含 `notexample.com` / 端口未声明单独报 `port_not_allowed` / 直连 IP / 白名单上的 IP 例外 / UDP 恒拒)· 代理(**真回环 socket + 真上游**:CONNECT 放行时双向传字节、拒绝时上游连接数为 0、无凭据 407 带 challenge、错凭据 407、**A 应用不能用 B 的白名单**、绝对形式 HTTP 剥 `Proxy-Authorization` 后转发、origin 形式 400、放行但上游死 → 502 而非 403)· 规则渲染(顺序 = 规则本身、UDP 单独一条、`-I` 位次 1..N、删除命令与插入互逆、nft 先 `delete table` 且优先级在 docker 之前、后端探测三分支)· 接线(deploy 注入且注册声明域名、应用自带 `HTTP_PROXY` 被覆盖、重发布沿用同一凭据、destroy 带走凭据、标签能恢复声明域名、构建走 `bisheng-build` 且 `build_args` 覆盖不了代理、预览用自己的 principal)。
+  **实际偏差记录**: ① brief 要求 docker 相关断言标 `@pytest.mark.e2e`,实际用本包既有的 **`@pytest.mark.docker`**——它在 `pyproject.toml` 注册、`conftest.py` 默认跳过,用 `e2e` 反而不会被跳过;② 测试文件里同时覆盖 T077 的接线,因为「决策正确」与「决策真的被调用」分开测才有意义。
+
+- [x] **T077**: `[MVP-114]` 出站白名单双层实现(`--internal` 网络 + egress-proxy + `DOCKER-USER` 兜底 + UDP 封禁)
+  **文件**: `src/runtime-manager/runtime_manager/egress.py`(新), `src/runtime-manager/runtime_manager/egress_proxy.py`(新), `lifecycle.py` / `builder.py` / `preview.py` / `reconciler.py` / `desired_state.py` / `config.py` / `api/{schemas,readonly,intents}.py`(接入), `docker/docker-compose.yml`, `docker/verify_app_runtime_compose.py`, `features/v3.0.0/054-app-domain-runtime/deploy/bisheng-egress-proxy.service`(新), `src/backend/bisheng/app_publish/domain/schemas/app_manifest.py` + `app_state_service.py` + `preview_instance_service.py`(透传 `egress.domains`)
+  **逻辑**: D12-C;⚠️ Docker 29 起 nftables 后端**无 `DOCKER-USER` 链**(坑 22),需探测后端分别下发或在部署基线锁定 iptables。**任何非 114 环境部署前是硬前置**。
+  **测试**: T076 全部通过。
+  **覆盖 AC**: AC-16
+  **依赖**: T076
+  **落地记录**(2026-09-16):三层。**L1** = `bisheng-apps` / `bisheng-build` 建成 `--internal`(compose 已改,存量环境要重建网,preflight 判红并给命令)。**L2** = `egress_proxy.py`,runtime-manager 同一个包的第二个入口点(`python -m runtime_manager.egress_proxy`,compose 里同镜像换 command):CONNECT 盲隧道 + 绝对形式 HTTP,**不做 TLS 拦截**——平台决定连接能不能存在,从不看里面走什么。**L3** = `egress.py` 渲染 `DOCKER-USER` / nft 规则,兼管 UDP(CONNECT 代理看不见数据报)。
+  **两个值得记的决定**:① **代理按 principal 鉴权**,凭据随注入的 `HTTP_PROXY` 走标准 `Proxy-Authorization`——全机共用一张白名单等于「所有 manifest 的并集」,那不是 AC-16 说的事;按源 IP 认又会在 reconciler 重建实例换 bridge IP 时失效。② **策略是一个文件**(`{data_root}/egress/policy.json`)不是一次 RPC:编排器重启不该把每个托管应用的出站一起带走;代理按 mtime 重读,读坏了保留上一份(坏缓存 fail-open 好过全站断网)。
+  **`RTM_EGRESS_PROXY` 空 = 整层未部署**,没有「开关开了但代理不在」这种把出站全掐死的组合;preflight 第一行直说「托管应用出站不受限」。声明域名落成容器标签 `bisheng.egress.domains`,状态文件丢了也能恢复——恢复出更窄的白名单 = 应用突然访问不了自家 API 且哪儿都没写为什么。
+  **实际偏差记录**: ① 新增文件比任务清单多 `egress_proxy.py`——D12-C 写的 egress-proxy 在仓里本来不存在,不写它「唯一出口」就只是个名词;选择内建而非 squid 镜像,理由是 信创 / air-gap 环境的镜像可得性,以及 squid 无法做 per-app 白名单。② backend 侧多改三个文件(manifest → deploy / preview 意图透传 `egress_domains`)——不透传的话「应用包声明的域名」这半句 AC 没有输入。③ **本机无 docker,L1/L3 的真实生效未验证**:网络 internal、iptables 规则真的丢包、UDP 真的被挡,三项都留在 `@pytest.mark.docker` 与 114 手动验证(命令见 T075 记录与本轮交付说明)。
+
+  **Review 修订(2026-09-16,同批次 review)**:
+  1. **(真缺陷,已修)代理是「唯一出口」也是「唯一双挂点」——manifest 能把它当成进平台内网的中继**。compose 形态下 `egress-proxy` 同时挂 `bisheng-apps` 与 `default`,而 mysql / redis / minio / backend 都在 `default` 上;systemd 形态下它干脆跑在宿主上。原实现对声明域名只查「在不在名单里」,于是一行 `egress: {domains: [mysql:3306]}`(或一个指向 `10.0.0.5` 的公网域名)就能拿到平台内网——`--internal` 网刚消除的可达性又被 L2 还回去了。**修法**:`Destination` 加 `trusted`——部署侧配置的条目(`platform_api_base` / `RTM_APP_FACING_BASE_URL` / `RTM_EGRESS_ALLOW` / 包源)为可信,**允许私网**(私有化平台本来就是私网);manifest 声明的一律不可信,代理放行后先解析、**任一**解析结果非公网即 403 `private_address`,报文点名运维侧口子 `RTM_EGRESS_ALLOW`,随后连的是刚判过的地址而非域名(顺带消掉 DNS 重绑定)。`trusted` 随策略文件落盘,读不到一律按不可信。新增 8 例(`test_an_application_declared_name_pointing_inside_the_deployment_is_refused` 等)。
+  2. **(真缺陷,已修)`GET /v1/runtime/status` 每次都 fork 两个进程**:`RTM_FIREWALL_BACKEND` 留空时 `_firewall_check` 每次预检都跑 `iptables` + `nft`(各 10s 超时),而这个端点是平台轮询的。改为进程级缓存(注入 runner 的测试路径不读写缓存)。
+  3. **(口径订正)「只存哈希」是假的**:`PrincipalPolicy.label` 存的就是凭据明文(reconciler 重建、重发布宽限期都要复用同一把,明文没法不留)。字段改名 `token`、文档如实写明,并把目录权限收到 0700(文件本来就是 `mkstemp` 的 0600)。
+  4. **(小)`db.example.com:5432` 用 5433 连时报的是「未声明」**——端口判定只比 80/443。改成按主机匹配,报 `port_not_allowed`。IPv6 字面量 `[::1]` 落名单时不脱方括号、永远匹配不上,一并修。策略文件是合法 JSON 但不是对象时 `_load` 会抛 `AttributeError` 逃出热路径,收进同一个 except。
+  5. **(真缺陷,已修)白名单的平台那半按 `platform_api_base` 单独建,会把 F051 的模型面挡在外面**。`OPENAI_BASE_URL` 取 `open_api.public_base_url`(**浏览器可见 origin**),只有它没配才退回 `entry_base_url`(= `platform_api_base`),两者不同是被文档化的常规形态——白名单一开,托管应用的模型调用全被 `not_declared` 拒。修法不是补一条地址(下一个平台 URL 加进来还会漏),而是 `PLATFORM_URL_ENV_NAMES`:平台保留的四个 URL 名(`BISHENG_PLATFORM_API_BASE` / `BISHENG_APP_STORAGE_ENDPOINT` / `OPENAI_BASE_URL` / `BISHENG_MODEL_BASE_URL`)的值一律进可信名单;它们是平台保留名、backend 在版本自带注入之后覆写,所以值必是平台的,应用自己写别的名字进 env 不产生任何放行(有用例钉住)。
+  6. **(小)`test_socket_proxy.py` 两个 `@pytest.mark.docker` 用例打 `127.0.0.1:2375`**——compose 形态下这个端口**从不发布**(这正是 D2-B 的要点),curl 会「连接被拒」而测试把它当通过。改成 `docker exec bisheng-runtime-manager` 从管理器视角问,交付说明里的 114 命令同理。
+  7. **(小)compose 里 egress-proxy 不再拿 `RTM_HMAC_SECRET`**:它不提供 API、不碰编排面,给它编排凭据是纯粹的权限扩散——和 systemd 单元里「不要给它 docker 组」同一条理由。原来给它只是为了满足 `verify_app_runtime_compose.py` 的 `REQUIRED_ENV` 反向校验;改成脚本按**进程角色**覆盖必填集(`REQUIRED_ENV_OVERRIDE`),代理只需 `RTM_DATA_ROOT` / `RTM_EGRESS_LISTEN` / `RTM_EGRESS_PROXY` 三项。
+
+  **114 上线后补修(2026-09-17,真缺陷):开启出站白名单时,已在线的实例被整体断网**。L3 规则一下发就覆盖整个网段,而代理变量和策略条目只在**创建容器时**发放——开层之前创建的实例既没有 `HTTP_PROXY`、策略文件里也没有它,直连被丢、代理又不认。114 当晚 18:00 起,一个按小时采集两个公网站点的应用(manifest 早已声明域名)全部报超时,直到第二天有人发现数据不动了才被察觉;`resume` 一次即恢复。**修法**:reconciler 每轮对在跑且健康的实例比对「注入的代理变量 vs 当前层配置」(`egress.egress_drift`:开层后缺凭据、代理地址 / `NO_PROXY` 变了、关层后还带着平台凭据,都算漂移),漂移就**蓝绿替换**——在下一代次起新容器、探活通过才切期望态、旧容器按宽限期退役,与同版本重发布同形;每轮至多一个应用(避免整机实例瞬间翻倍),探活失败保留旧实例并退避 600s。策略条目缺失或过期(`RTM_EGRESS_ALLOW` 改了、策略文件丢了)则**原地补写**、不动容器,且只在有差异时写(代理按 mtime 重读)。顺带修掉同源的潜伏缺陷:reconciler 重建实例时会给策略文件登记一把新凭据、却不注入容器。新增 12 例(`test_egress.py` 「instances that predate a change to the layer」一节),在旧 reconciler 上 11 例失败。
+
+- [x] **T078**: `[MVP-114]` docker-socket-proxy 端点白名单(D2-B)
+  **文件**: `src/runtime-manager/runtime_manager/docker_backend.py`, `docker/docker-compose.yml`, `src/runtime-manager/tests/test_socket_proxy.py`(新)
+  **逻辑**: 把 root 等价权限收成端点级(`/build`、`/containers/create|start|stop|remove`、`/images`、`/networks`);对 manager 代码只是**换一个 base URL**、不构成返工;代理端口绝不对外暴露。
+  **覆盖 AC**: AC-14
+  **依赖**: T077
+  **落地记录**(2026-09-16):`SOCKET_PROXY_PERMISSIONS`(开关 → 需要它的 Protocol 方法)+ `SOCKET_PROXY_DENIED`(**显式列出并写死为 0**,不靠镜像默认值——镜像换版本翻了某个默认值,仓库里什么都不会变)+ `socket_proxy_env()` 产出整份环境。manager 侧改动 = compose 里一行 `RTM_DOCKER_HOST: tcp://docker-socket-proxy:2375`,并撤掉 `docker.sock` 直挂。`tests/test_socket_proxy.py` 9 例:双向 lockstep(每个 Protocol 方法都有开着的开关 / 白名单里没有不存在的方法)、写方法都在 `POST` 门下、`EXEC`/`VOLUMES`/`ALLOW_RESTARTS` 恒 0、**compose 那段环境与 `socket_proxy_env()` 逐字相等**。`VERSION=0` 是真决定:`docker-py` 只有 `version="auto"` 才 `GET /version`,而 `_RealDockerBackend` 刻意不传——谁加了那个参数,该改的是这张表不是参数。
+  **实际偏差记录**: 任务里写的文件名是 `docker/docker-compose.yml`,仓内真实文件是 `docker/docker-compose.yml`。真代理容器是否确实拒 `/exec`:`@pytest.mark.docker` 两例,114 验证。
+
+- [x] **T079**: `[MVP-114]` WS 反代 + 不变量① 测试
+  **文件**: `src/app-proxy/tests/test_websocket.py`(新)
+  **逻辑**: 握手时定死连接授权有效期 = `min(OBO 剩余寿命, ws_max_lifetime_seconds)` + 随机抖动,到期主动 `close(4001)`;WS 升级请求走同一段头剥离代码。
+  **覆盖 AC**: AC-25, AC-35
+  **依赖**: T045
+  **落地记录**(2026-09-16,fd4500e04 + 本轮 review 修订):`tests/test_websocket.py` 对**真实**回环 WS 服务器(`tests/fakes.py::WsEchoUpstream`)断言——双向帧回显 / 前缀剥离与 query 保留 / 子协议协商 / 关闭码双向透传 / 伪造 `x-bisheng-*` 与 `X-Forwarded-*` 剥离 + 十头注入 / 平台会话 cookie 不出站 / 五类拒绝不触上游 / D5.1 失效重取一次 / 寿命 = min(OBO, cap)+抖动、到期 4001 且上游同码 / 后端下发的 cap 优先于进程 env。
+
+- [x] **T080**: `[MVP-114]` WS 反代与不变量① 实现
+  **文件**: `src/app-proxy/app_proxy/websocket.py`(新)
+  **逻辑**: D6「WS 三不变量落地程度」。
+  **测试**: T079 全部通过。
+  **覆盖 AC**: AC-25, AC-35
+  **依赖**: T079
+  **落地记录**(2026-09-16):`websocket.py` 先连上游再 `accept`(浏览器绝不会握着一条背后没人的 socket);头列表与 HTTP 路径共用 `proxy.upstream_headers_for`(AC-32 一处实现),剔除浏览器自己的 `Sec-WebSocket-*`;两个泵 + 到期 / 吊销 / 周期重问三个任务,先完成者定关闭码、两端同码。`websockets>=13` 从 `uvicorn[standard]` 的传递依赖提为直接依赖(客户端一侧的契约,lock 内已有该包)。`APP_PROXY_WS_PROXY_ENABLED` 默认改 `true`,`false` = 允许的升级以 4501 关闭(原 MVP 行为)。**后端配合**:`authorize_entry` 的 allow 判定新增 `obo_expires_at` / `ws_max_lifetime_seconds`(`test_entry_authz_service.py::test_allow_verdict_states_the_websocket_lifetime_inputs`),老后端不发时 app-proxy 退回解 OBO `exp` 声明 + 进程 env 兜底。
+
+- [x] **T081**: `[MVP-114]` WS 不变量②③(吊销 / 下线主动断连 + 前端重握手常态)
+  **文件**: `src/app-proxy/app_proxy/connections.py`(新), `src/backend/bisheng/app_runtime/domain/services/app_state_service.py`(下线 / 撤权时通知)
+  **逻辑**: app-proxy 维护 connection → (user, app) 索引,应用下线、可见范围撤销时主动断开该用户连接——**②正是自研 app-proxy 的核心理由**(反代生态无先例),别因为"先只做①"就忘了。
+  **覆盖 AC**: AC-35
+  **依赖**: T080, **T051**(本任务要改 `app_state_service.py` 的下线 / 撤权路径——该文件由 T049 建、T051 补齐五动作;只写 `依赖: T080` 时链路 T080→T079→T045 完全不经过 T049/T051,并行执行会撞上一个尚不存在的文件)
+  **落地记录**(2026-09-16,**app-proxy 半边已落、backend 半边未接**):`connections.py` 进程内 registry((app, user) 索引)+ `POST /internal/connections/close`(与 authorize 同一 HMAC、同一 secret、空密钥 fail-closed;body `{app_id, user_ids|null, reason}`,`reason` 取判定词 `forbidden|stopped|not_found` 决定关闭码,未知词 → 4503);不在 `/apps/` 之下、nginx 不路由。**安全网**:每条连接每 `APP_PROXY_WS_REAUTHORIZE_INTERVAL_SECONDS`(默认 30s)重问一次判定、非 allow 即关——这是多进程 / 多节点下客户可依赖的上界,推送只是快路径。**③** 落为 `login_handoff.WS_CLOSE_CODES` 的文档化契约(4001 / 4401 / 4403 / 4404 / 4503 / 4501 全部含义为「重新握手」)。
+  **实际偏差记录(未做、留给 backend 切片)**:`app_state_service.py` 的 stop / delete 与 F048 撤权路径**尚未**调用该端点。backend 须补:① `app_runtime` 新增 app-proxy 地址列表配置(单机 systemd 形态 `http://127.0.0.1:8090`,compose 形态 `http://app-proxy:8090`);② 在 `stop` / `delete` 转态成功后、以及应用可见范围撤销(F048 grant 变更)后,对每个地址 `POST /internal/connections/close`,body 如上(下线 / 删除 `user_ids=null`、`reason=stopped`;撤权 `user_ids=[受影响 user_id]`、`reason=forbidden`),签名 = `hmac_auth.py` 同一算法(`METHOD\nPATH\n` + raw body,secret = `proxy_hmac_secret`);③ 推送**尽力而为**(超时 2s、失败只 warning),不得阻塞状态转换——上界由 app-proxy 的周期重问保证。
+
+### Wave 5 · release 必做(MVP-核心之外,按 design §8 优先级)
+
+- [x] **T082a**: 两个过渡态页测试(Test-First,先于 T082)
+  **落地记录**(2026-09-16):`tests/test_transition_pages.py` 六个规划用例全部落地 + `test_deploying_answer_is_cached_like_a_route` / `test_verdict_pages_never_carry_retry_markup` / `test_ws_upgrade_during_deploy_gets_a_close_code`;「发布中」由 manager 的 409 `deploying` 信封触发(fake 里给出保留形态,见 T083 偏差)。
+  **文件**: `src/app-proxy/tests/test_transition_pages.py`(新)
+  **逻辑**: `test_deploying_page_on_generation_switch`(切换窗口 → 「发布中」页而非报错页)→ AC-48 / `test_recovering_page_on_upstream_unreachable`(上游不可达 → 「应用恢复中」页)→ AC-36 / `test_auto_retry_markup_present`(页面含 `meta http-equiv=refresh` 或内联定时 reload,间隔可断言)→ AC-36, AC-48 / `test_ready_after_retry_enters_app`(fake 上游从不可达转就绪 → 下一次请求进入应用,用户无需手动刷新)→ AC-48 / `test_mutually_exclusive_with_four_fallback_pages`(过渡态与 forbidden / stopped / not_found / not_enabled 四类兜底页**互斥**,同一请求只可能命中一类)→ AC-36 / `test_xhr_gets_json_not_transition_html`(非导航请求仍走 T042 的分流口径,返 JSON + 真实状态码)。
+  **覆盖 AC**: AC-36, AC-48
+  **依赖**: T045, T041
+
+- [x] **T082**: 两个过渡态页实现(「发布中」/「应用恢复中」+ 自动重试)
+  **文件**: `src/app-proxy/app_proxy/pages.py`
+  **逻辑**: 自渲染 + `meta http-equiv=refresh` / 内联 JS 定时 reload,就绪后自动进入应用、用户无需手动刷新;与四类兜底页**互斥**。
+  **测试**: T082a 全部通过。
+  **覆盖 AC**: AC-36, AC-48
+  **依赖**: T082a
+  **落地记录**(2026-09-16):`pages.py` 新增 `PAGE_DEPLOYING`、`TRANSITIONAL_KINDS`;只有过渡态两页带 `meta refresh`(10s 无脚本地板)+ 内联退避重载脚本(3s 起、每次 +2s、封顶 10s,计数放 `sessionStorage`、拿不到就不退避)——两者都 > 3s 路由缓存,每次重载都真的重问 manager;四类判定页零脚本。三语文案同批(`test_pages_i18n` parity 自动覆盖新页)。**错误码**:过渡态两页从借用 16121 改为专属 **16147(发布中)/ 16148(恢复中)**,本轮 review 补齐 backend `common/errcode/app_factory.py` 登记 + `packages/locales` 三语(实现提交曾漏登记,`check-i18n` 会红)。响应带 `Retry-After`。
+
+- [x] **T083**: 切流量 health gate 与「发布中」联调
+  **文件**: `src/runtime-manager/runtime_manager/lifecycle.py`, `src/app-proxy/app_proxy/routing.py`
+  **逻辑**: 新实例探活通过才切上游、旧实例宽限退休;切换窗口内入口呈「发布中」而非报错页。
+  **覆盖 AC**: AC-21, AC-48
+  **依赖**: T082
+  **落地记录**(2026-09-16):health gate 半边**早已成立**——manager `deploy()` 同步探活通过才 `store.put`(`generation+1`)、旧实例 30s 宽限退休(`test_route_generation_bumps_only_after_probe_pass`),backend `_start` 等 `deploy` 返回才置 `online`;app-proxy 侧 `routing.py` 新增 `Upstream.phase/starting`、`clients.py` 消费 manager 的 `409 detail.code=deploying`(缓存同路由 3s),HTTP → 「发布中」页 / XHR 16147 / WS 4503。
+  **实际偏差记录**:**manager 当前不发 `409 deploying`**,也没改 `lifecycle.py`——deploy 同步 + backend 后置 `online` + 重发布期间旧实例照常服务,意味着现在**不存在**可观察到「发布中」的窗口(首发布期间入口判定是 `not_found`,重发布期间打到旧实例);给 manager 硬加一个「在途」相位会让崩溃重建(`PHASE_STARTING`)与首发布无法区分、且触碰其它切片持有的 lifecycle / reconciler。信封形态登记在 `contracts-runtime-manager.md` §9:deploy 改异步之日必须发它而不是 404。
+
+- [x] **T084a**: 附件存储句柄测试(Test-First,先于 T084)
+  **文件**: `src/runtime-manager/tests/test_storage.py`(新)
+  **逻辑**: `test_four_operations_scoped_to_app_prefix`(put / get / delete / list 四操作的对象键恒被强制加 `apps/{app_id}/attachments/` 前缀)→ AC-45 / `test_cross_app_key_rejected`(传入他应用前缀或 `../` 越界键 → 拒绝,不是静默改写)→ AC-45 / `test_single_file_size_limit_enforced` → AC-45 / `test_bucket_is_bisheng_apps_not_public_bucket`(坑 20:`bisheng` 桶挂着 nginx `/bisheng/` location,附件必须落独立 bucket `bisheng-apps`、且该桶无匿名策略、不挂 nginx location)→ AC-45 / `test_not_counted_into_tenant_storage_quota`(不计租户存储配额)→ AC-45。
+  **覆盖 AC**: AC-45
+  **依赖**: T031, T019
+  **完成记录(2026-09-16,分支 wt/storage-handle)**:`tests/test_storage.py` 五个指名用例 + 分页 / 光标校验 / 公共桶拒绝 / 未配置 503 / 回环 preflight / 缺失 404 / **per-app token 打他应用 URL = 401** / HTTP 全流程(Bearer 与 HMAC 两路)/ 413 先看 `Content-Length` / MinIO 适配层桩测 / destroy 清附件;`tests/fakes.py` 新增 `FakeObjectStore`(记录每次调用的 bucket / key,`set_bucket_policy` 存在只为断言从未被调)。**审查补充(同日)**:HTTP 用例拆到 `tests/test_storage_api.py`(两文件均 < 600 行),新增 非 ASCII / 空 Bearer 恒 401 不 500、无 `Content-Length` 的 chunked 上传超限 413、`_read_capped` 在越过上限的那一块就停止拉取(ASGI 级计数)、scoped 后全 key 超 1024 字节 400、公共桶 preflight `ok=false`、跨 service 实例只探一次桶;两文件共 38 例。runtime-manager 套件 142 passed / 5 skipped(基线 102 / 5)。
+
+- [x] **T084**: 附件存储句柄实现(manager 侧四操作 + 越界拒绝 + 单文件上限)
+  **文件**: `src/runtime-manager/runtime_manager/storage.py`(新)
+  **逻辑**: 按应用收窄、不进公共可读存储、不计租户存储配额(坑 20:`bisheng` 桶挂着 nginx `/bisheng/` location → 用独立 bucket `bisheng-apps`)。
+  **测试**: T084a 全部通过。
+  **覆盖 AC**: AC-45
+  **依赖**: T084a
+  **完成记录(2026-09-16)**:`runtime_manager/storage.py`(`ObjectStore` 协议 + 惰性 import 的 `MinioObjectStore` + `AppStorageService` 四操作 / `validate_key` 拒绝式校验 / `purge_app` / `storage_preflight`)、`runtime_manager/api/storage.py`(`/v1/apps/{app_id}/storage/objects[...]` 与 `/meta/{key}`,**双凭据**:应用 Bearer 绑 `app_id`,无 Bearer 走 HMAC;独立 router 只在 `main.py` 加一行 `include_router`)、`config.py` 新增 `RTM_MINIO_*` / `RTM_STORAGE_BUCKET` / `RTM_STORAGE_MAX_FILE_MB` / `RTM_APP_FACING_BASE_URL`(未列入 REQUIRED_ENV——不配 = 503 + preflight 指出,不拦整层)、`pyproject.toml` + `uv.lock` 加 `minio>=7.2`、`docker-compose.yml` 补七个变量(`verify-app-runtime-compose.sh` 全过)、`docs/architecture/14` 与 `contracts-runtime-manager.md` §2/§4/§5/§9 同步。错误形状沿用 manager 信封:`invalid_object_key` 400 / `payload_too_large` 413 / `storage_unavailable` 503 / `not_found` 404 / `unauthorized` 401。**偏差**:① 可达性缺口选「manager 绑到 `bisheng-apps` 网桥网关 + `RTM_APP_FACING_BASE_URL`」而非 STS 预签名(应用不该拿到 MinIO 凭据,F057 AC-21),preflight `attachment_storage` 在回环时报 `ok=false`;② backend 错误码 16170–16174 只在 `app_factory.py` **预留注释**、未落码——本波无 backend 调用方,落无人抛的码 = 死代码 + 三语文案空转,首个 backend 消费者(F052 MCP / 数据 tab)那一波再落;③ 本机无 docker daemon,MinIO 适配层以桩 SDK 单测覆盖,真 MinIO 联调留 114(T095 一并)。**审查修正(同日)**:Bearer 比对改按 bytes(`hmac.compare_digest` 遇非 ASCII `str` 抛 TypeError → 原本是 500 而非 401);`PUT` 改流式读 `_read_capped`,chunked 无 `Content-Length` 时在越过上限的那一块即 413,不再先把整个 body 吞进内存;1024 字节 key 上限按加前缀后的全 key 计(原先只算相对 key,超出部分会变成存储层 503);桶探测缓存改进程级 `_ready_buckets`(router 每请求新建 service,原先每个请求都多一次 `bucket_exists` 往返)。
+
+- [x] **T085**: 附件句柄环境变量注入(`BISHENG_APP_STORAGE_*`)
+  **文件**: `src/runtime-manager/runtime_manager/lifecycle.py`, `src/backend/bisheng/app_runtime/domain/constants.py`
+  **逻辑**: 与 F057 SDK storage 同名同 API、与 F053 `dev` 同名注入。
+  **覆盖 AC**: AC-45
+  **依赖**: T084, **T006**(本任务要改 `app_runtime/domain/constants.py`,该文件由 T006 产出)
+  **完成记录(2026-09-16)**:`lifecycle.build_env` 注入 `BISHENG_APP_STORAGE_ENDPOINT` / `BISHENG_APP_STORAGE_TOKEN` / `BISHENG_APP_STORAGE_MAX_FILE_MB`;token 在 deploy 时铸造(`secrets.token_urlsafe(32)`)、**沿用前一记录的值**(AC-21 宽限期新旧实例同一凭据)、只在 destroy 后轮换;存在 `InstanceRecord.env` 里,reconciler 重建走 `record.env` 天然重注入同值、容器 label 恢复路径同样带回;`readonly` 的 `logs` 按名字规则自动脱敏(`test_logs_redact_known_injected_secrets` 改为读真铸造的 token);`destroy(purge_volume=True)` 顺带 `purge_app`(AC-43)。backend `constants.py::APP_STORAGE_ENV_NAMES` 作为契约副本。`test_lifecycle.py` 补 `test_env_injection_names` 三项 + `test_storage_endpoint_uses_the_app_facing_base_url` + `test_storage_token_is_stable_across_redeploys_and_rotates_after_destroy` + 平台名覆盖调用方值扩到三个 storage 名。**偏差 / 待办**:不注入 bucket / 前缀(F057 AC-21 要求对应用不暴露实现细节;收窄在 manager 侧);`bisheng dev` 命令在本仓尚不存在(`bisheng-cli/commands` 只有 deploy/login/logs/skills),「F053 `dev` 同名注入」一半无落点,归 F053 / F057(`dev` 期注入的是本地目录句柄,契约 §5 已写明 SDK 按有无 `ENDPOINT` 分辨)。
+
+- [x] **T086a**: 数据面 manager RPC 测试(Test-First,先于 T086)
+  ✅ 2026-09-16 落地(commit 6c0fdf007):`src/runtime-manager/tests/test_appdb.py` 35 条,覆盖本任务列出的 7 组用例;runtime-manager 套件 137 passed / 5 skipped。
+  **文件**: `src/runtime-manager/tests/test_appdb.py`(新)
+  **逻辑**: `test_list_tables_and_schema`(表清单 + 列结构)→ AC-56 / `test_row_read_paginated_with_stable_order` → AC-56 / `test_row_update_single_row_only`(一次只改一行,带主键断言;受影响行数 ≠ 1 → 拒绝)→ AC-56 / `test_ddl_statements_rejected`(`CREATE` / `ALTER` / `DROP` / `PRAGMA` 一律拒——**数据面无 DDL**,结构演进归 F055)→ AC-56 / `test_export_produces_file_handle` → AC-56 / `test_short_transaction_and_busy_timeout`(短事务 + `busy_timeout`;断言不开长事务扫全表——WAL 单写者下长事务会把应用自己的写阻塞住)→ AC-56 / `test_backend_never_opens_host_db_file`(RPC 是唯一通路,K1 / 多节点,D10-C)→ AC-56。
+  **覆盖 AC**: AC-56
+  **依赖**: T031, T019
+
+- [x] **T086**: 数据面 manager RPC 实现(表清单 / 结构 / 行读写 / 导出,**无 DDL**)
+  ✅ 2026-09-16 落地(commit 6c0fdf007):`runtime_manager/appdb.py` + `api/appdb.py`,五个 typed 端点 `/v1/apps/{app_id}/db/{tables | tables/{t}/schema | tables/{t}/rows | tables/{t}/rows/{key} | export}`;新 manager 错误码 `db_not_found / table_not_found / row_not_found / data_invalid / data_busy`(契约文档 `contracts-runtime-manager.md` 已补)。
+  **实际偏差记录**: 「无 DDL」不是拒绝语句而是**说不出口**——没有任何承载 SQL 的入参,表名 / 列名 / 排序列一律先按 `sqlite_master` / `PRAGMA table_info` 白名单核对再引号拼接;`WITHOUT ROWID` 且复合主键的表标 `editable=false`(可读可导、不可编辑),BLOB 列以占位文本呈现且不可编辑。
+  **评审修正(2026-09-16)**: ①`auth.verify_hmac` 改按 ASGI `scope["path"]` 验签——`request.url.path` 会把行键里的 `?` / `#` 当分隔符截断,TEXT 主键含保留字符的行永远验签失败(`tests/test_appdb.py::test_text_key_with_reserved_characters_is_addressed_encoded`);②复合主键表的分页把全部主键列作次级排序键(原来没有 ORDER BY,分页不稳);③读路径的 `sqlite3.Error` 映射为 `data_busy` / `data_invalid` 而不是 500(否则 backend 报成 16121「编排器不可用」);导出中途失败删半成品文件。套件 145 passed / 5 skipped。
+  **文件**: `src/runtime-manager/runtime_manager/appdb.py`(新)
+  **逻辑**: D10-C(backend 直读宿主库文件违反 K1 且多节点必错);短事务 + `busy_timeout`,别开长事务扫全表。
+  **测试**: T086a 全部通过。
+  **覆盖 AC**: AC-56
+  **依赖**: T086a
+
+- [x] **T087a**: backend `AppDataService` 测试(Test-First,先于 T087)
+  ✅ 2026-09-16 落地(commit c593537ef):`src/backend/test/app_runtime/test_app_data_service.py` 覆盖本任务列出的 6 组用例,另加端点信封(16162 走 200)与导出返回文件的断言;`test_orchestrator_client.py` 补 `TestDataPlane`(路径 / 签名 / 导出按字节 / PATCH 读超时不重放 / 五个错误码各归各)。`test/app_runtime/` 343 passed / 13 skipped,`test/app_publish/` 396 passed。
+  **文件**: `src/backend/test/app_runtime/test_app_data_service.py`(新)
+  **逻辑**: `test_owner_only_business_rule_precheck`(仅 owner;租户管理员与平台超管同样被拒——**业务规则前置拦截**,不能依赖权限运行时,管理员在那里会被短路放行)→ AC-56 / `test_non_owner_gets_16162_not_403`(业务码 `16162`「无权访问应用数据」,坑 25)→ AC-56 / `test_row_edit_audited_with_before_after`(审计 `app.data_row_edit`,记表名 / 主键 / 变更前后)→ AC-56, AC-65 / `test_ddl_rejected_at_backend_layer_too`(backend 侧同样拒 DDL,不把判断全交给 manager)→ AC-56 / `test_forwards_to_manager_only_never_opens_db_file`(用 `fake_orchestrator` 断言只走 RPC)→ AC-56 / `test_mcp_face_reuses_same_service_method`(F052 MCP 数据工具复用同一方法、**不得直连 manager**——断言 Service 是唯一实现)→ AC-56。
+  **覆盖 AC**: AC-56
+  **依赖**: T086, T009
+
+- [x] **T087**: backend `AppDataService` 实现(owner 收窄 + 审计 + 转发)
+  ✅ 2026-09-16 落地(commit c593537ef):`app_runtime/domain/services/app_data_service.py`;端点 `GET/PATCH /api/v1/apps/{id}/data/*`(`apps.py`);`orchestrator_client` 加 `db_tables / db_schema / db_rows / db_update_row / db_export`,两套 `fake_orchestrator` lockstep 集扩到 15;错误码 16162–16167 启用并三语落 `packages/locales` api_errors。
+  **实际偏差记录**: ①「单行编辑二次确认」是前端交互(T088 的 `bsConfirm`),后端不做确认态。②除 `app.data_row_edit` 外**新增** `app.data_export` 审计动作——整表离开平台是事件,即使没改任何东西;四处 lockstep(`AppAuditAction` / `_UI_VISIBLE_V2_ACTIONS` / platform `log.ts` / `bs.json` ×3 `log.eventTypeEnum.appDataExport`)同步。③F052 MCP 数据工具复用同一 Service 由静态测试 `test_mcp_face_reuses_same_service_method` 守住:`bisheng/` 下 `orchestrator_client.db_*` 的调用方只能是本 Service。
+  **评审修正(2026-09-16)**: `orchestrator_client.db_update_row` 的行键改 `quote(key, safe="")` 上线、`_request` 按解码路径签名——原来裸拼路径,`a?b` 会把键的后半截当 query、`a%20b` 会被 manager 解成另一把键(靠验签不匹配 fail-closed,但报成 16121)。`test_orchestrator_client.py::test_text_row_key_is_encoded_on_the_wire_and_signed_decoded` ×6;`test/app_runtime/` 349 passed / 13 skipped。
+  **文件**: `src/backend/bisheng/app_runtime/domain/services/app_data_service.py`(新)
+  **逻辑**: 仅 owner(业务规则前置拦截);单行编辑二次确认 + 审计 `app.data_row_edit`;**F052 MCP 数据工具复用同一方法、不得直连 manager**。启用 T004 预留的 `16162`。
+  **测试**: T087a 全部通过。
+  **覆盖 AC**: AC-56
+  **依赖**: T087a
+
+- [x] **T088**: Platform 数据 tab(表清单 → 分页查看 → 单行编辑 → 导出)
+  ✅ 2026-09-16 落地:`tabs/DataTab.tsx`(重写,原占位壳作废)+ `tabs/data/{dataTabModel.ts, RowEditDialog.tsx}` + `controllers/API/hostedAppData.ts`(新);`index.tsx` 的 `TABS` 恢复 `data`;`bs.json` ×3 `hostedApp.data.*` 30 键(原 `placeholder` 键随占位壳删除)。vitest:`tabs/data/dataTabModel.test.ts` 12 条 + `src/test/hostedAppDataTab.test.tsx` 11 条(列表→分页→排序→编辑→导出→16162/16163 内联提示);平台全量 10 failed / 400 passed,失败集与基线(approvalPage ×8 / auditSingleTenantFilter ×1 / departmentKnowledgeSpaceDialogs ×1)完全一致。
+  **实际偏差记录**: ①导出**不用** `util/utils.ts#downloadFile`——它裸 `import axios`(违反 C7)且拿到拒绝信封会原样存成 `.csv`;改为经封装 request 以 `responseType: "blob"` 取字节、JSON 类型的 blob 解码为信封抛出,再用同样的 anchor 手法保存(`dataTabModel.saveBlob`)。②数据面 API 拆到独立模块 `hostedAppData.ts`:并入 `hostedApp.ts` 会到 639 行,超 600 行上限。③`useTable` 没用(它绑定 `{data,total}` 信封与 `page_size` 参数名,与 rows 契约 `{rows,total,page,size,order}` 不合),用 `useState + useEffect`;loader 的依赖里**故意不放 `t`**,否则在 `t` 身份不稳定时(vitest 的 i18n mock)会无限重进 loading。④16163「尚未创建数据库」按空态而非错误呈现;16164 表已被应用删除 → 自动刷新表清单。⑤空串与 NULL 在编辑弹窗里是两个值:可空列另给「置为空值」勾选,只发送有变化的列。
+  **评审修正(2026-09-16)**: 切表由 `selectTable` 一次性重置页码 / 排序(原来靠 `useEffect([selected])` 重置,带着旧页码先发一次请求再发第二次),rows 请求带序号票据、迟到的旧表答复丢弃(原来慢的「上一张表第 3 页」会盖掉「当前表第 1 页」)。vitest 新增两条;平台全量 10 failed / 401 passed,失败集与基线一致。
+  **手动验证**: 待 114 联调(Playwright 未落地)。
+  **文件**: `src/frontend/platform/src/pages/BuildPage/hostedApp/tabs/DataTab.tsx`(新)
+  **逻辑**: `bs-ui/table` + `useResizableColumns` + `AutoPagination` + `Dialog` 行编辑(保存前 `bsConfirm`);导出**走后端产文件 + `downloadFile`**(别新用已被 lint 冻结的 `xlsx`)。react-query **禁用**(lint 冻结),用 `useTable` 或 `useState + useEffect`;权限失败按业务码 `16162` 渲染提示,**不得触发 403 整页跳转**(坑 25)。
+  **手动验证**(Playwright 未落地): 详情页数据 tab → 表清单列出应用建的表;点表 → 分页翻页正常、列宽可拖;单行编辑弹窗保存前有二次确认、保存后表格即时刷新且「系统操作」页能查到 `app.data_row_edit`;导出下载得到文件且内容与表格一致;**非 owner 账号(含租户管理员)看到"无权访问应用数据"提示而不是被甩到 `/403` 页**;应用无表时显示空态。
+  **覆盖 AC**: AC-56
+  **依赖**: T087, T066
+
+- [x] **T089a**: 访问记录留痕测试(Test-First,先于 T089)
+  **文件**: `src/backend/test/app_runtime/test_app_access_log.py`(新)
+  **完成证据(2026-09-16,分支 `wt/access-log`)**: 17 用例全绿(`test/app_runtime/` 整包 333 passed / 13 skipped,基线 316 passed,无新增失败)。清单里 7 条全部落地,另加 `test_merge_window_default_is_1800s_per_f056_d7` / `test_different_users_and_apps_do_not_merge` / `test_verdict_returns_before_the_write_lands` / `test_query_face_filters_by_app_user_and_time_range` / `test_scheduled_only_from_the_entry_verdict`。
+  **实际偏差记录**: ①**测试降级**:不连 test 中间件——沿用本包 conftest 的 aiosqlite 内存库 + 一个带可拨时钟的假 Redis(`SET NX EX` 语义),「窗口过期后再进入产生新行」靠拨钟而不是睡 30 分钟;Redis 断连用例同样是假客户端抛异常。②`test_tenant_isolation_of_access_log` 先断言 `_TENANT_AWARE_MODEL_MODULES` 登记 + `_discover_tenant_aware_tables()` 能发现该表,再用**模块自己的** `_get_tenant_tables_from_statement` + `build_tenant_filter_clause` 挂在测试 session 上做跨租户读——不调用进程级 `register_tenant_filter_events()`(T032 偏差②:它会污染后续测试包)。③`test_app_proxy_does_not_write_directly` 是静态断言:`src/app-proxy` 的 pyproject 无任何 DB / Redis 驱动、源码不提 `app_access_log` / ORM;并另加一条「`schedule_access_record(` 全后端只有 `entry_authz_service` 一个调用点」。④`test_app_query_service.test_logs_no_platform_side_logs_and_has_empty_state` 原用「`app_*` 且含 `log`」启发式断言平台不存运行日志表,被本表命中;改为排除 `app_access_log`(它是 D14-B 同一决议**要**建的访问记录表,不是运行日志)。
+  **逻辑**: `test_one_row_per_entry_not_per_request`(一次进入一条,同一会话的后续静态资源 / XHR 请求不再产生行——**不记请求级明细**)→ AC-38 / `test_dedup_window_setnx_merges_repeat_entries`(`SETNX app_access:{app_id}:{user_id}` 窗口内重复进入只留一条;窗口过期后再进入产生新行;默认 300s)→ AC-38 / `test_row_fields_complete`(应用 / 访问用户 / 时间 / 租户齐全)→ AC-38 / `test_written_only_on_allow_decision`(判定为 login / forbidden / stopped / not_found 时**不写行**)→ AC-38 / `test_write_failure_does_not_block_entry`(fire-and-forget:Redis 或 DB 异常时入口判定仍返回 allow,异常被吞并记日志)→ AC-38 / `test_tenant_isolation_of_access_log`(跨租户查不到别租户的访问记录——依赖 `_TENANT_AWARE_MODEL_MODULES` 已登记 `app_access_log`;**故意先断言登记生效**,漏登记时这条会红)→ AC-38 / `test_app_proxy_does_not_write_directly`(写入方恒是 backend 内部授权端点,app-proxy 不连库)→ AC-38。
+  **测试降级**: 无(连 test 中间件 MySQL + Redis,CI 跑)。
+  **覆盖 AC**: AC-38
+  **依赖**: T035, T009
+
+- [x] **T089**: 访问记录留痕实现(`app_access_log` + Redis 去重窗口)
+  **完成证据(2026-09-16,分支 `wt/access-log`)**: `database/models/app_access_log.py`(`AppAccessLog` + `AppAccessLogDao.{ainsert, alist_page}`)、新增 `app_runtime/domain/services/app_access_log_service.py`(`schedule_access_record` / `record_access` / `flush_pending_access_records`,Redis 键 `app_access:{app_id}:{user_id}`)、`entry_authz_service.authorize_entry` 在 `allow` 处调度、`tenant_filter._TENANT_AWARE_MODEL_MODULES` 登记第四个模块、`core/config/app_runtime.py` 新键 `access_log_merge_window_seconds=1800`、`release-contract.md` 表 1 新增 `AppAccessLog` 行、`docs/architecture/14` 与 `docker/bisheng/config/config.yaml` 补该键说明。T089a 全绿。**审查补强(同日)**:`main.lifespan` 关停时 `await flush_pending_access_records(timeout=5.0)`(在 `close_app_context()` 之前)——fire-and-forget 的在途写入在部署重启时有 5s 落库窗口,超时的任务被取消并以 warning 记录条数;`TestShutdownFlush` 三条用例(限时落库 / 超时取消并计数 / `main.py` 静态断言 flush 在 close 之前)覆盖。
+  **实际偏差记录**: ①**不写 Alembic revision**:本表是独立新表,在线升级的 `create_all(checkfirst=True)` 已覆盖(`src/backend/AGENTS.md`「Schema ownership」;本波次不持有 head)。回滚 = 手工 `DROP TABLE app_access_log`(drop 前先 dump)+ 删 tenant_filter 元组项。②**合并窗口默认 1800s 而非 300s**:按 F056 design D7 / spec 决议-2(本文原写 300s 是待回写勘误,design `:342` 已同步改);做成部署配置项 `app_runtime.access_log_merge_window_seconds`,不进 DB 热配置(与 `app_runtime` 其余键同形态,K12)。③`SET NX EX` 原子写而不是 `asetNx`(setnx + expire 两步,中间崩溃会留下无 TTL 的键把该用户的进入永久合并)。④**Redis 不可达时照常写行、只是不去重**:审计资产上「窗口内多一条」比「静默缺一条」便宜;DB 写失败则 `logger.exception` 后吞掉——两种失败都不影响判定、也不出现在 app-proxy 看到的响应里。⑤`tenant_id` 由写入方显式落应用的租户:内部授权端点跑在 `bypass_tenant_filter` 下,`before_flush` 自动填充不会执行;登记进 `_TENANT_AWARE_MODEL_MODULES` 的作用是让 F056 查询面的 SELECT 被自动过滤。⑥主键用自增 int(其余 F054 表是 uuid):追加型日志按 `entry_time DESC, id DESC` 分页,秒级时间列需要 id 做并列键。⑦行上冗余 `user_name`(进入时的显示名,原文非 percent-encoded)与 `request_id`;为此 `_identity_material` 改为接收调用方解析好的 `user_name` / `subject_kind`(`_user_facts` 只跑一次),不从已编码的头里 `unquote` 回读。⑧**不开 HTTP 查询端点**:查询面归 F056(AC-24 / AC-34,「owner 无自助入口」),本任务只提供 `AppAccessLogDao.alist_page(app_id / user_id / start_time / end_time / page / page_size)`。⑨预分配错误码 16150–16152 **未使用**:写入失败一律吞掉,没有需要向前端表达的错误。
+  **文件**: `src/backend/bisheng/database/models/app_access_log.py`(新), `src/backend/bisheng/core/database/alembic/versions/v3_0_0_f054_app_access_log.py`(**新 Alembic revision,`down_revision` 必须挂在 T003 的 `v3_0_0_f054_app_runtime_tables` 之后**;DDL-only、不传 `mysql_charset` / `mysql_collate`,C2 / K4), `src/backend/bisheng/core/database/tenant_filter.py`(`_TENANT_AWARE_MODEL_MODULES:39` 追加 **`bisheng.database.models.app_access_log`**——design §4.3 要求登记的四个模块路径的**第四个**,T002 只落了前三个), `src/backend/bisheng/app_runtime/domain/services/entry_authz_service.py`, `features/v3.0.0/release-contract.md`(**表 1「领域对象归属」新增 `AppAccessLog` 行**,Owner = F054、查询面 F056;表 1 现只有 App / AppVersion / AppInstance)
+  **逻辑**: D14-B:独立业务日志表 + `asyncio.create_task` fire-and-forget + `SETNX app_access:{app_id}:{user_id}` 合并窗口(默认 300s,**窗口值由 F056 定**);写入方是 **backend 内部授权端点**(判定通过时顺带),不是 app-proxy 直连库;一次进入一条、不记请求级明细。
+  **回滚**: revision `downgrade()` drop 表;**访问记录是审计资产,drop 前先 dump**;tenant_filter 登记回退 = 删元组项。
+  **跨 Feature**: 改核心库共享文件 `tenant_filter.py`(只增元组项)+ 回写 `release-contract.md` 表 1(新增领域对象必须登记,检查项 17)。
+  **测试**: T089a 全部通过。
+  **覆盖 AC**: AC-38
+  **依赖**: T089a, T003
+
+- [x] **T090a**: 审批期临时预览入口测试(Test-First,先于 T090)
+  **文件**: `src/backend/test/app_runtime/test_preview_entry.py`(新,backend 判定侧), `src/app-proxy/tests/test_preview.py`(新,反代侧)
+  **逻辑**: backend 侧:`test_only_approver_of_that_ticket_allowed`(该审批单的审批人 allow;同租户其他用户 / owner 本人 / 别单审批人一律 forbidden 页)→ AC-37 / `test_preview_session_expired_or_unknown_returns_not_found_page`(过期 / 未知 session 与"不存在"同页,防信息泄漏)→ AC-37 / `test_injected_identity_is_approver_himself`(注入的是审批人本人身份,不是 owner、不是模拟身份——INV-32)→ AC-37, AC-31 / `test_fga_unavailable_fail_closed` → AC-12。app-proxy 侧:`test_preview_path_uses_same_strip_and_inject_code`(`/apps/preview/{session}` 走 T039 同一段头剥离 / 注入代码,不另写一份)→ AC-32 / `test_preview_prefix_stripped_and_forwarded_prefix_set` → AC-25 / `test_preview_instance_lifecycle_not_controlled_here`(app-proxy / F054 不拉起也不回收预览实例,**归 F055**;上游不存在时呈过渡态页)→ AC-37。
+  **覆盖 AC**: AC-12, AC-25, AC-31, AC-32, AC-37
+  **依赖**: T045, T033, T009
+
+- [x] **T090**: 审批期临时预览入口实现(`/apps/preview/{session}`)
+  **文件**: `src/app-proxy/app_proxy/preview.py`(新), `src/backend/bisheng/app_runtime/domain/services/entry_authz_service.py`
+  **逻辑**: 仅该审批单的审批人可达(其他人得无权限页),注入审批人本人身份,其余注入与转发规则同正式入口;**实例的拉起 / 回收由 F055 控制**。
+  **测试**: T090a 全部通过。
+  **覆盖 AC**: AC-37
+  **依赖**: T090a
+  **证据**(随 F055 T053 同切片落地,分支 `wt/f055-preview-instance`,提交 `fb818bc30`):`src/backend/test/app_runtime/test_preview_entry.py` 15 例 + `src/app-proxy/tests/test_preview.py` 17 例全绿;app-proxy 全量 **241 passed**(基线 224)。
+
+  **偏离与必须知道的落点**:
+  ① **放行判据是「这个会话的审批人」,不是「该审批单的审批人」**。同一个发布申请上的另一个审批人也拿不到——每个预览注入的是它被拉起时那个人的身份(AC-27),第二个人进去就等于以第一个人的身份操作。owner 同样进不去(owner 有应用自己的入口)。
+  ② **所有拒绝都是 `not_found`,一个字都不多说**。未知 session / 已回收 / 已过期 / 不是本人 / 应用已删,五种同一页——`forbidden` 页会带应用名与负责人,那等于把一个还没审批通过的应用的存在告诉陌生人(AC-30)。`test_an_unknown_session_and_a_real_one_are_indistinguishable` 钉住这条。
+  ③ **`authorize-preview` 是第二个内部端点,不是 `authorize` 上的一个开关**。两个判据合进一个 handler 就等于让它们隔着一个 typo;这个端点上写错的代价是「任何人都能打开未上线应用」。租户豁免走既有前缀匹配(`/api/v1/internal/app-proxy/authorize` 是前缀,已覆盖)。
+  ④ **app-proxy 复用同一段剥离 / 注入代码**(T090a 的 `test_preview_path_uses_same_strip_and_inject_code` 要求):交给 `forward` 的伪 slug 是 `preview/{session}`,于是 `entry_prefix_for` 正好得出 `/apps/preview/{session}`,前缀剥离与 `X-Forwarded-Prefix` 两处都不用分支。路由必须注册在 `{slug}` 之前(Starlette 按顺序匹配),同时后端把 `preview` 列入保留 slug。
+  ⑤ **预览访问不计 AC-38 的应用访问记录**:那条记录统计的是「谁用了这个应用」,把还没上线版本的审批试用算进去,等于让应用的使用量被自己的审批撑起来。
+
+- [x] **T091**: 入口二维码
+  **文件**: `src/frontend/platform/src/pages/BuildPage/hostedApp/tabs/PublishTab.tsx`, `src/frontend/platform/package.json`
+  **逻辑**: `qrcode.react` 走 pnpm catalog 加进 platform(`pnpm-workspace.yaml:22` 已登记 `^4.2.0`,**monorepo 不引入新库**);二维码内容 = **后端返回的完整入口地址**(不用 `location.origin` 拼——dev 下 origin 是 :3001 且 `/apps` 不在 vite 代理内,拼出来的码扫了打不开)。
+  **手动验证**(Playwright 未落地): 发布 tab 出现二维码;**用手机扫码**(或用二维码识别工具解出文本)得到的地址与「复制入口链接」按钮复制到的地址**逐字符一致**、且能直达 `/apps/{slug}`;应用未上线 / 已下线时二维码与入口链接的显隐口径一致;`pnpm lint` + `pnpm typecheck`(从 `src/frontend/`)通过。
+  **覆盖 AC**: AC-54
+  **依赖**: T067
+  **完成记录(2026-09-16,分支 `wt/owner-notify-and-qr`)**: `platform/package.json` 加 `"qrcode.react": "catalog:"`(`pnpm-lock.yaml` platform importer 同步手写一条 `4.2.0(react@18.3.1)`,与 client / packages/ui 已解析的同一版本,未跑 `pnpm install`——merge 后请在 `src/frontend/` 跑一次 `pnpm install --frozen-lockfile` 确认 lock 一致);`PublishTab.tsx` 访问入口区加 ``,与链接同一显隐口径(有 `entry_url` 即显示,未运行时 `entryInactive` 提示覆盖两者,`entry_url` 为空不渲染);i18n `hostedApp.publish.qrLabel` 三语(评审时删掉了冗余的 `qrHint`「二维码与访问链接指向同一地址」——二维码就贴在链接旁边,这句话不提供信息)。单测 `PublishTab.test.tsx` 3 例(mock `qrcode.react` 读出编码值 === 「复制链接」拷贝的字符串 / 已下线态同显 / 无地址不渲染);platform vitest 全量 10 failed | 378 passed(10 例为基线既有失败:approvalPage×8 / auditSingleTenantFilter×1 / departmentKnowledgeSpaceDialogs×1),`tsc-strict` + eslint 通过。**手机扫码 / 直达 `/apps/{slug}` 的人工验证未做**(本地无部署)。
+
+- [x] **T092**: `node20` / `static` Dockerfile 模板
+  **文件**: `src/runtime-manager/runtime_manager/templates/node20/{Dockerfile.j2, entrypoint.sh.j2, healthcheck.js.j2}`(新), `src/runtime-manager/runtime_manager/templates/static/{Dockerfile.j2, entrypoint.sh.j2, healthcheck.sh.j2, nginx.conf.j2}`(新), `src/runtime-manager/runtime_manager/source_facts.py`(新), `builder.py` / `config.py`, `src/runtime-manager/tests/test_templates.py`(新), `src/backend/bisheng/app_publish/domain/schemas/app_manifest.py`
+  **实际偏差记录**: ①模板需要知道的源码事实(node 有无锁文件 / 依赖 / `scripts.build`,static 的 `index.html` 在哪个目录)在**渲染期**由 `source_facts` 读定并进渲染上下文,不在镜像里用 shell 猜——渲染出的 Dockerfile 写明做了什么,源码树不满足在 `render_dockerfile` 阶段以一句话失败。②`static` 用官方 `nginx:1.27-alpine` + 模板自带完整 `nginx.conf`(pid / 临时目录全在 `/tmp`,入口脚本运行期把 `PORT` 替进 `/tmp` 的副本),不用 `nginx-unprivileged`(信创镜像源更常有官方 tag)。③构建参数按运行时分发(`build_args_for`):`python3.11` 收 `PIP_*`,`node20` 收 `BISHENG_NPM_REGISTRY`(新增 `RTM_BUILD_NPM_REGISTRY`;不叫 `NPM_CONFIG_REGISTRY`,npm 会把空值当"registry 为空"),`static` 无——避免 daemon 对未声明 ARG 的告警混进失败日志尾。④backend 本地副本 `SUPPORTED_RUNTIMES` 同 commit 扩到三值(该常量注释明文要求);`deploy-hosting` 技能文案同步三运行时的启动命令解析顺序与 `!dist/` 取回规则。⑤`node20` 启动命令**不经 `npm start`**(直接 exec `scripts.start`,`node_modules/.bin` 进 PATH),保证应用是 pid 1 的进程且运行期不写 `$HOME`。⑥(review 修正)依赖层的 `COPY` 按渲染期读定的锁文件清单逐个列出(`node.lockfiles`),不用 `package*.json` 通配——通配会漏掉 `npm-shrinkwrap.json`,而它单独存在时已选定了 `npm ci`,构建会在 npm 拒绝无锁文件处失败;compose 形态补 `RTM_BUILD_NPM_REGISTRY` 透传(`BISHENG_RTM_BUILD_NPM_REGISTRY`),否则该变量只在 systemd 形态可配。
+  **测试**: `tests/test_templates.py`(三运行时安全基线一致性 / node 三种安装档 + build 档 / static 根目录探测与缺 `index.html` 报错 / nginx 只读 rootfs 与前缀不变量)+ `test_build.py` 新增按运行时分发构建参数与 static 渲染期失败用例 + `test_readonly_api.py` 新增 `base_images` 三镜像逐个报缺;runtime-manager 全套 140 passed / 5 skipped;backend `test/app_publish` + `test/dev_toolkit/test_skill_packs.py` 66 passed。真镜像构建与启动只能在 114 验证(`docs/architecture/14-app-factory-deployment.md`「运行时模板与基础镜像」给出逐条命令),**本任务未在 114 跑过**。
+  **覆盖 AC**: AC-15
+  **依赖**: T023
+
+- [x] **T093**: 备份手册:应用存档位置随平台备份
+  **文件**: `docs/architecture/14-app-factory-deployment.md`「备份与恢复」, `docs/architecture/08-deployment.md`「备份」(新节,交叉引用)
+  **实际偏差记录**: ①落点不是 `09-development-guide.md`(面向开发者),而是运维文档 14 里已有的「备份」节扩写 + 08 新增平台级「备份」节指过去——备份是运维动作。②附件前缀 `apps/{app_id}/attachments/` 依赖 T085(未落地),手册按"整桶备份 `bisheng-apps`"写,不为未实现的前缀背书;代码快照键以实现为准(`package_service.py` 的 `apps/{app_id}/versions/{version_id}/code.tar.gz`)。③平台今天**没有**自动 SQLite 快照作业(D10 的 `apps/{app_id}/db-snapshots/{ts}.tar` 未实现),手册给的是运维手工流程(`sqlite3 .backup` / Python `Connection.backup`),并明说这一点。
+  **逻辑**: 列出代码快照(`bisheng-apps/apps/{app_id}/versions/*`)、SQLite 快照(`.backup` 后 tar,坑 19:**裸 tar WAL 库会得到不一致副本**),附件前缀三处位置与恢复步骤。
+  **覆盖 AC**: AC-45
+  **依赖**: T085
+  **补记(2026-09-16,分支 `wt/f054-observability`)**:偏差②的前置条件已消失——T084 / T085 落地后附件前缀 `apps/{app_id}/attachments/` 是真的了,手册里那句"后续落地的附件等同桶前缀自然被覆盖"改为直述该前缀随整桶备份覆盖(表格里本来就已有「应用附件」一行,是表格上一行的措辞还停在附件未落地的时点)。偏差①③仍成立,未改。
+
+- [x] **T094a**: 档位解析测试(Test-First,先于 T094)
+  **文件**: `src/backend/test/app_runtime/test_tier_resolution.py`(新)
+  **逻辑**: `test_tier_resolved_from_table_when_present_else_default_tiers`(`ResourceTier` 表有行以表为准,否则回落 T006 的 `DEFAULT_TIERS`)→ AC-64 / `test_tier_change_does_not_touch_running_instance`(改档位后运行中实例的限额不变、不触发在线 update、不重建)→ AC-64 / `test_tier_change_takes_effect_on_next_publish`→ AC-64 / `test_tier_change_takes_effect_on_resume`→ AC-64 / `test_ac63_baseline_is_snapshot_tier_not_current_table`(**AC-63 的核验基准恒是"该实例所属版本快照里 `tier_id` 当时解析出的规格"**,不是当下的表;构造"发布后改表"场景断言核验仍用快照值,D11)→ AC-63。
+  **覆盖 AC**: AC-63, AC-64
+  **依赖**: T051, T009, T006
+  **完成记录(2026-09-16,分支 `wt/tier-admin`)**: 五例全部落地并通过,直接驱动 `AppStateService._resolve_tier` / `publish` / `stop` / `resume`,以 `fake_orchestrator` 记录的 `deploy(tier=…)` 作为「实例启动时解析出的规格」断言;表行由测试自行插入(conftest 里 `resource_tier` 表存在但为空,走的正是 `DEFAULT_TIERS` 兜底分支)。补充断言:未知 code 回落默认档、已停用的行仍可解析(AC-47)。
+
+- [x] **T094**: 档位规格调整自下次发布 / 重新上线生效(实现)
+  **文件**: `src/backend/bisheng/app_runtime/domain/services/app_state_service.py`
+  **逻辑**: 限额在**创建容器时**固化、不做在线 update;运行中实例不受影响;`ResourceTier` 表存在时以表为准、否则用 `DEFAULT_TIERS`;**AC-63 的核验基准恒是"该实例所属版本快照里 `tier_id` 当时解析出的规格"**,不是当下的表(D11)。依赖 F055 的档位管理 tab。
+  **测试**: T094a 全部通过。
+  **覆盖 AC**: AC-63, AC-64
+  **依赖**: T094a
+  **完成记录(2026-09-16)**: 实现早已随 T051 落在 `_resolve_tier`(表优先、常量兜底)与 `_start`(只在 `deploy` 时解析并固化);本轮未改实现,仅由 T094a 的五例反向验证;档位管理 tab 见 F055 T065 / T066。
+
+- [x] **T095**: 稳定性双形态验收自动化(AC-49 用例可移植性)
+  **文件**: `src/backend/test/app_runtime/test_stability_portable.py`(新), `docker/verify-app-runtime-stability.sh`(新,真机适配器)
+  **逻辑**: 把 AC-20 / AC-22 / AC-46 / AC-47 / AC-48 / AC-50 写成**不含任何 compose 专有步骤**的用例(只用形态无关的意图 RPC 与 `phase` 取值),F059 可原样在 k8s 形态执行(**INV-33,已于 2026-08-17 随 F054 spec 定稿在 `release-contract.md:54` 转正,不再是候选**;T030 的写法即为正式口径,两处口径统一)。
+  **测试降级**: 需 docker,CI 中间件阶段 + 114 手动验证。
+  **覆盖 AC**: AC-20, AC-22, AC-46, AC-47, AC-48, AC-49, AC-50
+  **依赖**: T029, T083
+  **完成记录(2026-09-16,分支 `wt/f054-observability`,commit `1b796a304` + `9f7927d07`)**:13 例通过。① 全部观测只走意图 RPC 门面与 `phase` / `state` 取值;断言的是**平台不添乱**(不越俎代庖重发意图、不把自愈中的应用踢下入口、不让一个应用的失败挪动另一个),恢复计时归运行时层自己的用例。② `test_ac49_no_form_specific_vocabulary` 把 AC-49 变成机器可查的:扫本文件源码命中形态专有词表即失败,必须提及的行用 `# portable-vocab-ok` 标注并写理由——散文式承诺"这些用例形态无关"在第一次有人用单机工具排错时就失效了。③ 真机那一半(真杀执行体计时、运行时服务重启期间零中断、状态文件丢后自动对齐)落成 `docker/verify-app-runtime-stability.sh`(自动识别 systemd / compose,识别不出退 2 不猜;必须显式 `--yes`),`test_ac49_real_host_adapter_covers_what_this_file_cannot` 与用例表联锁,适配器不再覆盖 AC-20 / AC-22 / AC-46 / AC-50 即失败。**偏差**:AC-20 第二类(进程活着、健康检查持续失败)与 AC-47 的限额静态核对脚本代劳不了(前者要应用配合把健康端点切 500),`--howto` 给手工步骤并写明原因,不假装覆盖。**114 上执行**:`bash docker/verify-app-runtime-stability.sh --slug <已上线应用的 slug> --token <对该应用有 use 权限账号的 access_token> --base-url https://127.0.0.1:4101 --yes`(本轮未在 114 跑过——本机无编排宿主)。
+  **审查修正(同日,`/task-review`)**:适配器有三处真机上必然出错,已修。① **入口探测不带会话**——应用入口第二步就是会话校验,匿名请求恒定拿 `login` 判定,脚本第 0 步「应用现在是可访问的」在任何真机上都会失败退 1,整个脚本跑不起来;新增必填 `--token`(或 `BISHENG_ACCESS_TOKEN`),以 `Cookie: access_token_cookie=…` 发出。② **探测请求必须是非导航请求**——过渡态兜底页对导航请求答 200 HTML、对非导航请求才答 503 JSON,原先的 `curl -sf` 一旦被当成导航就会在「恢复中」页面出现的那一刻宣布恢复完成,把 AC-20 / AC-50 的计时测成恒等于 0;现在显式发 `Accept: application/json` 并在文件头写明理由。③ **执行体改按标签查**(`label=bisheng.app.slug=`)——原先按容器名前缀 `^bisheng-app-` 匹配,slug 为 `foo` 时会连 `foo-bar` 的容器一起捞上来,然后脚本会去 kill 一个不相干的应用。另修 `--howto` 里的标签名(写成了 `bisheng.app-id`,真实是 `bisheng.app.id`,点号分隔)。`test_ac49_real_host_adapter_covers_what_this_file_cannot` 的联锁仍绿。
+  **强化**:`test_ac48_a_transitional_instance_stays_on_the_entry` 原先只断言 state 仍在 `ENTRY_VISIBLE_STATES`(应用本来就是 `online`,这是恒真式,写进去的 `phase` 对断言没有作用)——改为走真正的 `authorize_entry` 五步判定,断言实例处于 `starting` / `unhealthy` 时判定仍是 `allow`。AC-48 要的正是"判定不看实例健康",只读状态列读不出这个回归。
+
+- [ ] **T096**: E2E(`/e2e-test`)+ 页面手动验证清单
+  **文件**: `src/backend/test/e2e/test_e2e_app_domain_runtime.py`(新,由 `/e2e-test features/v3.0.0/054-app-domain-runtime` 生成), `features/v3.0.0/054-app-domain-runtime/tasks.md`(追加清单结果)
+  **逻辑**: API 层 E2E 走 design §7 与 `mvp-114-path.md` §1 步 4–8 剧本(**非管理员账号**);页面手动清单覆盖构建页三类型筛选 / 状态筛选五值 / ⚙️ 菜单只两项 / 已上线删除置灰 / 详情页四 tab / 下线 → 入口呈已下线 → 重新上线恢复。
+  **测试降级**: 前端交互 = 手动验证——理由:Playwright 未落地。
+  **覆盖 AC**: AC-42, AC-51, AC-52, AC-53, AC-54, AC-55, AC-56, AC-57, AC-58
+  **依赖**: T088, T091, T095
+
+- [x] **T097**: 关键日志 / 指标落地(design §7 整节的承接任务)
+  **文件**: `src/app-proxy/app_proxy/observability.py`(新), `src/app-proxy/tests/test_observability.py`(新), `src/runtime-manager/runtime_manager/observability.py`(新), `src/runtime-manager/tests/test_observability.py`(新), `src/backend/bisheng/app_runtime/domain/services/app_state_service.py`(`app.state_transition` 结构化日志), `src/backend/test/app_runtime/test_state_transition_logging.py`(新), `docs/architecture/14-app-factory-deployment.md`「按事件名捞日志」(新节)
+  **逻辑**: design §7「关键日志 / 指标」此前**无任务承接**——这些是上线后排障与 AC-32 伪造头监控的唯一手段,落不了地只能事后补。三处逐条落:
+  - app-proxy:`app_proxy.request`(结构化字段 `request_id / slug / user_id / decision / reason / cache_hit / upstream_status / latency_ms`)· **`app_proxy.header_strip`(记录被剥离的伪造头名;`WARNING` 级并可告警——AC-32「伪造无效」只有这一条能在生产上被观测到)** · `app_proxy.fallback`(兜底页类型分布)。
+  - runtime-manager:`rtm.intent`(`kind / app_id / result / latency_ms`)· `rtm.reconcile`(每轮 `desired / actual / actions`)· `rtm.rebuild`(unhealthy 重建,**频次异常 = 应用本身有问题**)· `rtm.admission_reject`(**含容量快照**,与 T021 返回的 snapshot 同字段,供 AC-65 成因与运维对账用同一份数据)。
+  - backend:`app.state_transition`(`from → to / reason / actor`)。
+  测试断言"**事件名 + 必填字段齐全**"(而非日志文本):`test_request_log_fields_complete` / `test_header_strip_logged_at_warning_with_forged_names` / `test_fallback_type_recorded` / `test_intent_and_reconcile_fields` / `test_admission_reject_carries_snapshot` / `test_state_transition_logged_for_all_five_actions`。⚠️ backend 侧用 `loguru` 时**不得传 `exc_info` 等 stdlib kwarg**(会炸 KeyError,memory `project_loguru_exc_info_strands_sessions`);两个独立包用各自的 stdlib logging。
+  **覆盖 AC**: AC-32, AC-65
+  **依赖**: T045, T029, T051
+  **完成记录(2026-09-16,分支 `wt/f054-observability`,commit `2ea69f371`)**:三处各加一个 `observability` 模块,事件字段一律是**必填关键字参数**——调用点漏字段当场失败,而不是产出一条看着正常、正好缺了要查那一项的日志;字段挂在 `LogRecord.event` / `.fields`(backend 是 loguru `record["extra"]`),JSON handler 能原样序列化、journalctl 里仍是一行可读文本。测试断言事件名 + 必填字段齐全,**不断言文案**:app-proxy 12 例 / runtime-manager 11 例 / backend 9 例,三套全绿(app-proxy 221 passed;runtime-manager 236 passed, 5 skipped;backend `test/app_runtime` 413 passed, 13 skipped)。
+  **审查修正(同日,`/task-review`)**:① **补齐三条没产事件的路径**——裸路径 308 补斜杠(访问者对一个应用的**第一次**请求就是它,原先整条不落日志,排障从被重定向后那条起算,URL 和时间戳都是错的)、WS 侧非法 slug(原先静默关闭,"客户端拿错地址一直重连"这种最容易被报成"应用挂了"的流量零痕迹)、以及把 WS 拒绝那条写死的 `latency_ms=0.0` 改成真实耗时。② **被拒的行原先 `user_id` 恒为空**——身份材料只在 `allow` 时下发,而"谁打不开"恰恰是这个字段存在的理由;`entry_authz_service` 的 `forbidden` / `stopped` 判定补 `user_id`(**不是**注入材料,被拒的请求什么都不注入),app-proxy 的 `Verdict` 加同名字段与 `visitor_id` 取值口径(`login` 不带——那时还没有访问者)。③ `rtm.reconcile` **读不到编排后端的那一轮原先直接 return、不产事件**——"瞎了一小时"和"没事可做"在日志里长得一模一样;补上并用 `actual=-1` 表示"未知"而非"宿主为空",同时 `desired` 改用这轮真正遍历的那份列表(原先在末尾二次 `list()`,报的可能不是这轮的依据)。④ backend 非法跃迁原先同时打 `app_runtime.illegal_transition` 与结构化事件两条——删掉旧的那条,结构化事件升到 ERROR 承接。⑤ 三条新路径各补回归用例(app-proxy 224 / runtime-manager 237 / backend `test/app_runtime` 415)。
+  **落地时的取舍**:① app-proxy 原有的 printf 日志已近似这三个事件,本轮是**替换而非叠加**(同一请求不会出两条),并补齐了缺的字段:成功路径原先没有 `reason`、非法 slug 与"allow 但没有 app_id"两条路径原先根本不产 `app_proxy.request`。② `upstream_status` 为 `None` 是语义位:分开"请求根本没到应用"(被拒 / 兜底页 / WS)与"应用自己答了 5xx"。③ runtime-manager 的 `rtm.intent` 做成 `intent_span` 上下文管理器并从 `finally` 出事件——失败也是结果,`result` 取后端映射 161xx 用的那个机器码,非 `ok` 时升到 WARNING。④ `rtm.reconcile` 无动作时降到 DEBUG:15 秒一条 INFO 会把有动作的那几轮淹掉,完全不打又失去"循环还活着"的证据。⑤ `rtm.rebuild` 独立成 WARNING 而不并进 reconcile 计数——频次异常是**应用自身**的健康信号,混进计数就读不出来了。⑥ backend 的事件落在 `_transition` 这个唯一写入口,`result` 三值 `applied` / `lost_race` / `refused`;后两者**不进审计表**(审计记的是"谁要做什么"),"应用态卡住不动"今天只有这条日志查得到,为此 `_transition` 增加 `reason` / `actor` 两个关键字参数并由四个调用点传入。⑦ 运维文档 14 补「按事件名捞日志」表(事件→进程→字段→什么时候看它)+ 三条 grep 示例。
+
+---
+
+## AC → 任务追溯表(核对用)
+
+> 每条 AC 至少出现在一个**测试任务**(或标注了手动验证步骤的前端 / 部署任务)的「覆盖 AC」里;标 † 的仅由手动验证 / E2E 覆盖,理由见该任务。
+
+| AC | 测试任务 | AC | 测试任务 | AC | 测试任务 |
+|---|---|---|---|---|---|
+| AC-01 | T048 | AC-23 | T030, T054 | AC-45 | T024, T084a, T084, T085, T093 |
+| AC-02 | T048 | AC-24 | T024 | AC-46 † | T075, T095 |
+| AC-03 | T050 | AC-25 | T026, T044, T054, T075, T079, T090a | AC-47 | T028, T075, T095 |
+| AC-04 | T048, T050 | AC-26 | T032, T034, T040, T075 | AC-48 | T082a, T083, T095 |
+| AC-05 | T048 | AC-27 | T042, T075 | AC-49 | T095 |
+| AC-06 | T052, T056 | AC-28 | T032, T034, T040 | AC-50 | T028, T095 |
+| AC-07 | T048 | AC-29 | T032, T034, T040 | AC-51 | T058, T063 †, T070 †, T075, T096 |
+| AC-08 | T048, T052 | AC-30 | T032, T034, T040, T069 † | AC-52 | T054, T056, T064 †, T096 |
+| AC-09 | T010, T011, T012, T013, T014 †, T015 † | AC-31 | T032, T038, T075, T090a | AC-53 | T064 †, T065 †, T096 |
+| AC-10 | T010, T040 | AC-32 | T038, T075, T090a, T097 | AC-54 | T066 †, T067 †, T091 †, T096 |
+| AC-11 | T010, T048 | AC-33 | T024, T026, T044, T075 | AC-55 | T030, T054, T056, T068 †, T075, T096 |
+| AC-12 | T010, T032, T040, T090a | AC-34 | T032, T034 | AC-56 | T086a, T087a, T088 †, T096 |
+| AC-13 | T016, T017, T075 | AC-35 | T079, T081 | AC-57 | T054, T058, T063 †, T096 |
+| AC-14 | T046, T078 | AC-36 | T040, T044, T082a | AC-58 | T061, T063 †, T066 †, T096 |
+| AC-15 | T022, T092 | AC-37 | T090a | AC-59 | T061 |
+| AC-16 | T076 | AC-38 | T089a | AC-60 | T061, T075 |
+| AC-17 | T024 | AC-39 | T024, T050 | AC-61 | T061 |
+| AC-18 | T026 | AC-40 | T024, T050 | AC-62 | T061, T062 †, T071 † |
+| AC-19 | T020, T046, T050 | AC-41 | T050, T056, T067 † | AC-63 | T024, T075, T094a |
+| AC-20 | T024, T028, T075, T095 | AC-42 | T050, T056, T064 †, T065 †, T096 | AC-64 | T094a |
+| AC-21 | T026, T044, T083 | AC-43 | T050, T056 | AC-65 | T020, T050, T056, T067 †, T097 |
+| AC-22 | T028, T075, T095 | AC-44 | T050, T056 | | |
+
+**未被任何测试任务覆盖的 AC:无。**(2026-08-17 修订:AC-37 / AC-38 原先只挂在纯实现任务 T090 / T089 上,现分别由新增测试任务 **T090a** / **T089a** 覆盖,该断言方才成立。)
+
+**汇总**(2026-08-17 审查后重算,逐条与正文实列核对过):
+- **任务 104 个**:T001–T097(97 条)+ 7 条后缀 a 的补充测试任务(T082a / T084a / T086a / T087a / T089a / T090a / T094a)。
+- **Wave 5 个**。
+- `[MVP-核心]` **75 个**(T001–T075,Wave 1–3 全部,逐条标记)。
+- `[MVP-114]` 紧随项 **6 个**(T076–T081,Wave 4,逐条标记)。
+- **测试任务 34 个**:T010 / T016 / T020 / T022 / T024 / T026 / T028 / T030 / T032 / T034 / T038 / T040 / T042 / T044 / T046 / T048 / T050 / T052 / T054 / T056 / T058 / T061 / T075 / T076 / T079 / T082a / T084a / T086a / T087a / T089a / T090a / T094a / T095 / T096(**数一遍:25 条无后缀 + 7 条后缀 a + T095 + T096 = 34**)。
+- **数据库变更 3 组**(T001 / T002+T003 / T089,均含回滚方案;T089 的 revision `down_revision` 挂在 T003 之后)。
+- Client 任务 **2 个**(T015 / T071)。
+- 标「测试降级:需 docker」的任务 **8 个**:T022 / T024 / T026 / T028 / T030 / T075 / T076 / T095。**另**:T096 的前端交互、T088 / T091 的 platform 页面行为降级为手动验证(Playwright 未落地),不计入这 8 个。
+- **跨栈(前后端同任务)任务 1 个**:T007(理由写在该任务的「跨栈说明」里,是仓内既成的 lockstep 结构)。
+
+---
+
+## 114 部署记录
+
+> T075 执行时填写:部署时间 / commit / `app_runtime` 键写入时间 / 升级脚本 `apply` 与 `verify` 输出 / 全进程重启时间 / 手动验证 0–6 步逐条结果(含 `docker inspect` 关键字段、非管理员 + 中文姓名账号的截图或日志)。
+
+(未开始)
+
+---
+
+## 实际偏差记录
+
+### 2026-08-17 · 后端服务层批次抛回的 5 项裁决(全部定案,勿再翻)
+
+1. **接受新增 `16106 AppManageForbiddenError`**,与 `16105`(仅 owner)二分。判据是**一码一义**:`16105` 的语义是「只有负责人能做」,拿它去答一个**确实有权限**的租户管理员,等于对他说谎——他会去找 owner,而不是去查自己的权限为什么没生效。下线 / 重新上线这类动作的合法执行者是 owner ∪ 租户管理员 ∪ 超管,与「仅 owner」是两个集合,就该是两个码。
+2. **接受 slug 冲突的双口径**:**开发者声明的** slug 冲突 → `16103`「请更换」;**平台按名称生成的** → 自动追加 `-2`… 再退随机后缀。理由:前者是开发者自己选的、他改得动;后者他根本没选过,为一个他没造成的重名把首发卡死是死路——两个人都把应用叫「报表工具」不能互相锁死。AC-08 只写了前一半,这是补齐而非偏离。
+3. **维持 `_tenant_disabled` 在 Redis 不可达时放行**(fail-open),与 `CustomMiddleware` 逐字一致,**不改成 fail-closed**。判据是**爆炸半径**:这是「租户是否被停用」的缓存查询,不是授权判定;改成 fail-closed 意味着一次 Redis 抖动会锁死**所有租户的所有托管应用**,而它防的是「刚被停用的租户短暂仍可进入」。真正的授权判定在权限引擎,那里仍然 fail-closed。另一半理由是**一致性**:同一个问题在两条代码路径上给两种答案,迟早漂移。
+4. **接受 `obo_secret` 缺失 / 等于 `jwt_secret` 时不签 OBO 但仍放行**(本期 OBO 无消费方,为一个没人读的东西拒绝入口是本末倒置)。**但两处日志已改为按进程只打一次**——静态配置错误永远不会自愈,逐请求打 error 既刷屏又把唯一有用的那条埋掉。**⚠️ 附带的翻转条件已写进代码 docstring:OBO 出现第一个消费方的那一刻,必须在同一次改动里改成 fail-closed**——那时「没签出 token」不再等于「没人读」,而是「应用看到一个匿名请求」,是身份上的静默 fail-open。
+5. **接受 `AppDao.adelete_row` 的物理删**(PK 钉死,仅用于 owner 投影失败时的补偿)。理由:写墓碑会让**全局唯一的 slug 被永久占住**,同一个应用再发布永远 `16103` 且无解——补偿路径造出一个不可恢复的状态,比物理删掉一行刚建出来、且投影已失败的记录糟得多。
+
+### 2026-08-17 · UNION 第三支批次抛回的裁决
+
+**遥测桶维持 `ApplicationTypeEnum.UNKNOWN`,但登记为跨团队待办(不是只留一句函数注释)。**
+
+- **定案**:托管应用(`flow_type=35`)经 `convert_flow_type` 落进 `UNKNOWN`,本轮**不给它加枚举成员**。理由是范围:`ApplicationTypeEnum` 的值**已经写进 ES 文档、并被下游看板按值聚合**,加成员是遥测契约变更,不该由 F054 单方面做。
+- **为什么可以接受**:`mid_app_increment` 虽然只存转换后的 `app_type`,但**同时存了 `app_id`**(`mid_table.py:185`),所以这些行将来能靠 `app_id` 回查 `app` 表重新分桶——**信息不是永久丢失的**。若中间表连 `app_id` 都不存,这个裁决要反过来(那时宁可先把托管应用排除出该 feed,也不能让数据无法恢复)。
+- **⚠️ 必须登记而不能只留注释**:可观察的症状是**看板的 UNKNOWN 桶开始持续增长**。几个月后有人会把它当异常来查,而他没有任何途径找到 `mid_table.py` 里那句注释。→ 本条即为该登记;`convert_flow_type` 处的注释保留作为就地说明。
+- **交接给谁**:看板 owner。要不要给托管应用一个自己的遥测类型,由他与产品定;F054 侧的改动量是一行映射。
+
+**另两条只需知悉、无需裁定**:
+- **SQLite 上断言不了游标翻页的往返**,且该现象**与第三支无关**——cursor 里是 ISO-8601 字符串(`...T00:01:00`),SQLite 的 DATETIME 存成 `... 00:01:00`,`' ' < 'T'` 让 keyset 谓词对每行都成立、翻页永不收敛;实施方用「只有 flow + assistant 两支」复现了同一现象。MySQL / DM8 由驱动把绑定参数转成真 DATETIME。用例改为断言「三支归并后是 `(update_time, id)` 的严格单调全序」(这才是真正被第三支威胁的性质)+ keyset 谓词确实绑在三支 union 上。**真往返仍须在 114 / CI 的 MySQL 侧验**。
+- **标签硬闸实为 5 处不是 design 写的 4 处**:漏掉的是 `get_uncategorized_flows` 的两次 gather。它取「已打标」的补集,漏一个类型 = 把已打标的托管应用误报成未分类——是空结果的**镜像故障**,比空结果更难发现。
+
+### 2026-08-17 · Platform 前端批次抛回的 5 项裁决(全部定案)
+
+1. **卡片应用态徽标缺数据源 —— 契约缺口属实,补在 T060,不许每卡片拉详情。** D8 的 UNION 第三支投影列集没有 `app_state`,而 AC-51 要求卡片显示应用态。**T060 的 `add_extra_field` 必须给 app 行补 `app_state`**——一页 14 张卡各拉一次详情是 14 个请求,不可接受。前端当前的降级(`app_state ?? (status===2 ? 'online' : undefined)`,缺字段时只对「已上线」出徽标、其余**不猜**)是正确的过渡形态,保留到 T060 落地。
+2. **运行日志只做「起始时间」,不为区间改 manager 契约。** T068 原写 DatePicker×2,但 `GET /v1/apps/{id}/logs` 只有 `tail / since / keyword`,没有 `until`;且 `lines` 是无时间戳的纯字符串(`docker logs` 未带 `-t`),端侧无从按结束时间过滤。**判据是收益与代价严重不对称**:日志的产品口径是「最近一段运行日志」,保留期 = docker 轮转窗口(10m × 3),对一个只留几十分钟的窗口做区间查询几乎没有价值;而要真做,得给 `docker logs` 加 `-t`,那会**改变 `lines` 的形状**,CLI / MCP 日志工具 / 详情页三个消费方全都要跟着改。→ T068 改为 DatePicker×1。**何时该重新考虑**:日志保留期从「轮转窗口」升级为可配置的长期留存时,区间查询才开始有意义。
+3. **状态筛选砍掉「已删除」项**(已改码 + 改测试)。AC-51 字面写「覆盖应用态五值」,但服务端列表口径排除 `state='deleted'`,留着就是一个**永远只能返回空**的筛选项——那读起来是页面坏了,不是「态覆盖全了」。已删除的应用不是一个可浏览的态,是一个不在了的应用。**状态词本身保留**(`STATE_I18N` 仍有 `deleted`):从过期链接进详情页时必须能说清发生了什么。
+4. **接受 Switch 用「状态口径」而非 design D13 的「动作口径」。** `Switch` 的 `texts[0]` 只在 checked 时渲染、`texts[1]` 只在 unchecked 时渲染,塞动作词会读成「当前处于下线」;且「重新上线」四字放不进 `w-12` 轨道。落「已上线 / 已下线」,prop 名与可覆盖能力与 design 一致。 **(2026-08-18 推翻)** 用户要求托管应用与工作流用同一套开关文案,`switchTexts` 已删除,卡片回到共享的 `t('skills.online/offline')` = **「上线 / 下线」**——动作口径落两个字,`w-12` 放得下,当初「四字放不进轨道」的约束随之消失;`switchTexts` 那条 prop 与其单测一并移除。
+5. **接受 `apps.tsx` 组件改名 `apps` → `Apps`。** 这不是顺手重构:小写函数名让 ESLint 不认它是组件,而该文件带着 16 条 frozen `react-hooks/rules-of-hooks`;本任务要加 `useContext` / `useState`,不改名就会突破冻结上限直接红。改名后该规则清零。**存量豁免只减不增已核验**:3244 → 3220,无新增文件条目,无任何规则计数变大。
+
+**另记一个真实缺陷(该批次顺带修掉,值得所有人知道)**:`entry_authz_service` 的 `_check_is_global_super` 会初始化真实 DB 管理器,而它**进程级**注册租户过滤 ORM 监听器。症状是 F055 的 `test_worker_tenant_context_restored_from_celery_header` **只在 F054 先跑时失败**(`app_deployment` 被按错租户过滤掉,日志写 "vanished before the worker ran")——一个只在特定执行顺序下才出现的失败。修法是 F054 conftest 的 autouse `_no_super_admin_probe`,F055 的测试一行未改。两种执行顺序已各验一次,均 346 passed / 7 skipped。
+
+
+> **只留一行指针**,论证在 design.md(决策 / 坑),这里不重复(见 `docs/SDD-Guide.md` §4)。
+> 推翻已 ★ 确认的决策时,先停下与用户重新确认(§3 第四个 ★),再记录。
+
+| 任务 | 偏差 | 回写到 design | 原因(一句话) |
+|---|---|---|---|
+| T006 | `AppState` / `AppAuditAction` 用 `StrEnum` 而非仓内惯例 `(str, Enum)` | 否(纯实现细节) | ruff `UP042` 对新代码报错,行为等价(`AppState.DRAFT == "draft"` 仍为真) |
+| T009 | `chinese_name_user` fixture 显式给 `UserDepartment.id` | 否 | `user_department.id` 是 BIGINT,SQLite 只对 `INTEGER PRIMARY KEY` 自增,不给就 NOT NULL 失败 |
+| T011 | `RESOURCE_ACTION_SCOPES` / `ACTION_RESOURCE_SCOPES` 只手工加 4 个 action,`manage_permission` / `delete` 由 `MIGRATED_RESOURCE_TYPES` 自动带上 | 是(design D9 第 2/4 项加一句) | 那两个 action 的 scope 直接引用 `MIGRATED_RESOURCE_TYPES`,手工再加会写重复值 |
+| T016 | 测试注册 `@compiles(BigInteger, "sqlite")` → `INTEGER` | 否 | 同 T009 的根因,四张 F048 控制面表主键全是 BIGINT;SQLite 的 INTEGER 本就是 64 位,无语义损失 |
+| T041 | 兜底页对**导航请求一律 200**(含「不存在或未上线」),真实状态码(401/403/404/503)只给非导航的 JSON 分支 | 否(design §7 步 7 已写死「不是 502 / 404」) | 扫码落到浏览器错误页与「平台挂了」无法区分;程序化调用仍需要真状态码 |
+| T041 | 判定枚举比 §4.2 多一个 `unavailable`(本地 fail-closed),且**未知 decision 一律按拒绝处理** | 建议 design D6 判定顺序末尾补一句 | 16146 需要一个可渲染的判定值;backend 领先发布出现新 decision 时不能退化成「放行」 |
+| T041 | 「应用恢复中」的 JSON 分支复用 `16121`(编排器不可用),未新增码位 | 否 | 161 段没给过渡态留码,新增 16147 要动 backend 的 errcode 文件(批 1 owner) |
+| T041 | T040 里 8 条依赖真实转发的用例(`test_allow_forwards` / `test_recovering_*` / 缓存计数类)在 **T045 落地后才转绿** | 否(tasks 自身的拆分产物:T044 依赖 T041) | 「allow → 进入反代路径」这句话没有反代就无从断言 |
+| T039 | 转发前额外从 `Cookie` 头里摘掉 `access_token_cookie`(只摘这一条,应用自己的 cookie 原样透传;`strip_session_cookie` 可关) | 建议 design D6 注入头一节补一句 | 浏览器对 `/apps/*` 天然带上该 cookie(host-only + `path=/`,K7),不摘等于把「能冒充访问者调用全平台 API」的长效凭据交给被托管容器——比刻意签的 900s OBO 权限大得多,AC-34 的存在理由被架空 |
+| T017 | `OPENFGA_RELEASE_VERSION` 从 `permission/migration/f048_runtime_storage.py` 取,不在 `core/openfga/authorization_model_f048.py` | 否(design 未写过该符号位置) | 与 `_find_remote_model` 同属迁移存储层,按"模型常量都在 model 文件"的直觉去找会扑空 |
+| T029 | reconciler 重建不健康实例时 **`generation+1`**(design 只写过"版本切换后 +1") | 建议 design D4 / D5.1 各补一句(本批只改了 `contracts-runtime-manager.md` §6) | 新执行体拿到新 bridge IP,`generation` 就是 app-proxy 的缓存失效信号;不 +1 则重建后代理继续打旧地址直到 3s 缓存自然过期 |
+| T029 | label 恢复时「容器存在但没在跑」判为 `desired=stopped` | 建议 design D4「AC-22 / AC-50」一句里点明 | `unless-stopped` 下 dockerd 会把非显式下线的容器拉回来,所以"没在跑"只可能是有人显式停过;不做这个推断,状态文件一丢就把已下线应用全部复活(AC-50 反噬 AC-41) |
+| T031 | `GET /v1/apps/{id}/status` / `logs` 在编排后端不可达时返 **503 `backend_unavailable`**(→16121),而非 404 或 200 空态 | 否(design §4.2 未定义该失败分支,已写进 `contracts-runtime-manager.md` §2) | 「实例不存在」与「dockerd 宕机」必须是两个答案,合并会让 dockerd 每次重启都把详情页显示成"已删除" |
+| T049 | 建应用落 **新文件 `app_provision_service.py::AppProvisionService.create_draft(name, slug, description, owner_user_id, tenant_id)`**,不叫 `AppStateService.create_app` | 是(design §4.2 ② / §4.3 模块表各加一行) | F055 的管线与其 conftest 占位已按该符号接线;且「建应用」不是状态迁移,混进「应用态唯一写入方」会稀释决议-8 |
+| T049 | 新增 `AppDao.adelete_row`(**仅补偿**,非生命周期) | 否 | owner 投影失败时若补偿成 `state='deleted'`,全局唯一的 slug 会被永久占住,同一应用再发布永远 16103 且无解 |
+| T051 | `_start` 顺序 = 准入 → deploy → CAS(online),状态在编排器确认后才认领 | 建议 design D8「状态机落库与并发」补一句 | 状态机没有 `online → pending_capacity` 边,先认领就没法按 AC-65 把探活失败停在「待上线」;并发仍由 `from_states=(读到的前态,)` 的 CAS 判 16102 |
+| T051 | 新增错误码 **16106 `AppManageForbiddenError`**(owner ∪ 租户管理员 ∪ 超管之外),三语已落 | 是(design §4.2 ⑥ 16100–16119 段补一行) | 16105 的语义是「**仅** owner」(删除 / 数据 tab 连租户管理员都拒);拿它答下线/重新上线会告诉一个有权限的管理员「只有负责人能做」 |
+| T053 | `update_meta(*, app_id, name, description, logo, actor=None)` 而非 design 的 `(app_id, patch, actor)` | 是(design §4.2 ② 该行签名) | F055 `_update_meta` 按这四个关键字调用;管线跑在服务账号上、归属已在 F055 侧校验,故 `actor` 可空 |
+| T047 | `build_status` 对 `status=failed` **返回不抛**;16122 由模块级 `build_failure_error()` 构造 | 否(`contracts-runtime-manager.md` §3 的映射由调用方完成) | F055 要用 `stage`/`message`/`tail` 组装 AC-15 文案;做成方法会让门面出现第 11 个公开成员,而两个 conftest 都断言「恰好 10 个」 |
+| T047 | 重试只覆盖安全重放:连接失败任何动词都重试,读超时只重试 GET;超时按方法分档(deploy/probe 300s) | 否 | deploy/stop/destroy 可能已在执行,盲目重放会与前一次抢 manager 的探活闸门;共用 15s 会把每次冷启动都变成假的 16121 |
+| T055 | `get_logs` 增 `entry`(detail / cli / mcp),CLI / MCP 收窄为**仅 owner** | 建议 design §4.3 `app_query_service` 行补一句 | 后两者跑在密钥上;让租户管理员的密钥读遍全租户日志=把开放 API 授权面放大到密钥持有人未被授予的范围 |
+| T057 | 端点清单多一个 `GET /api/v1/apps`(owner / 租户管理员两种范围) | 是(design §4.2 ② 读接口清单,与 `/versions` 同批补) | 否则 T054/T055 要求的 `list_apps` 没有消费方;与 T059 的构建页 UNION 列表不冲突(后者走 `/api/v1/flows` 的既有分页管线) |
+| T035 | 新增 `app_runtime/api/exception_handlers.py`,让 HMAC 拒绝返回**真 401** | 建议 design D6 内部端点一节补一句 | 平台全局处理器把任何 `HTTPException` 都答成 HTTP 200,而 app-proxy 靠 401 区分「共享密钥配错」与「访客不能进」;合并后仍 fail-closed,但排障指向错误组件。形态照 F049 |
+| T033 | `token_version` 不符 / 账号禁用 / 租户禁用一律答 `login`;`_tenant_disabled` 对 Redis 不可达**失败放行** | 建议 design D6 判定顺序第 2 步补一句 | 判到这步还没查应用,别的答案都会暗示 slug 存在;租户黑名单与 `CustomMiddleware` 同源(它就是 fail-open),只有权限引擎是 fail-closed |
+| T033 | `obo_secret` 为空或等于 `jwt_secret` 时**不签 OBO 但仍放行** | 建议 design D6 OBO 一节补一句 | 本期 OBO 无消费方,为一个没人读的令牌挡掉整条入口是错的取舍;等于 `jwt_secret` 属配置事故,签了就是给每个托管应用发平台会话 |
+| T032 | conftest 新增 autouse `_no_super_admin_probe` | 否(测试基建) | 真实 `_check_is_global_super` 会初始化 DB 管理器,而它**进程级**注册租户过滤 ORM 监听器 → 现象是 F055 的 `test_worker_tenant_context_restored_from_celery_header` **只在 F054 先跑时**失败 |
+| T056 | `api_app` fixture 用 `httpx.ASGITransport` 而非 `TestClient` | 否(测试基建) | `TestClient` 另起事件循环,内存 aiosqlite 引擎与 ContextVar 在测试自己的循环上,第一次 DB 调用即 "attached to a different loop" |
+| T010 / T016 / T017 | 三个文件被 arch-guard 报 RULE-9(import `bisheng.core.openfga`) | 否 | **仓库根目录就叫 `bisheng`**,规则的 `/bisheng/` 路径匹配对任何绝对路径都成立;在树的 `test/permission/test_f048_action_catalog_policy.py`、`scripts/f048_migration_runtime.py`、`scripts/benchmark_f048_permission_paths.py` 报同一条,且 constitution C4 明文豁免"运维迁移工具" |
+| T063 / T064 | 卡片的**应用态徽标缺数据源**:design D8 的 UNION 第三支投影列集是 `(id, name, description, flow_type, logo, user_id, status, create_time, update_time)`,没有 `state`;AC-51 却要求卡片显示应用态徽标 | **是(design D8 投影列集 + T060 的 `add_extra_field`)** | 前端取 `app_state ?? (status===2 ? 'online' : undefined)`:缺字段时只对「已上线」出徽标、其余不猜。**要 T060 给 app 行补 `app_state`**(每卡片拉一次详情是 14 个请求,不可接受) |
+| T063 | 状态筛选保留「已删除」项,但它在当前列表口径下恒空(服务端把 `state='deleted'` 排除在外) | 待裁定 | AC-51 字面写「覆盖应用态五值」,故按字面实现;但一个永远筛不出东西的选项是产品瑕疵——留 / 砍需要一句裁定 |
+| T065 | `switchTexts` 落**状态口径**(已上线 / 已下线),非 design D13 写的动作口径(下线 / 重新上线) | **2026-08-18 已裁定:改回动作口径**,`switchTexts` 删除 | 当初的理由(动作词读成「当前处于下线」、「重新上线」放不进 `w-12`)在两字动作词「上线 / 下线」下都不成立;现与工作流共用 `t('skills.online/offline')` |
+| T068 | 时间筛选是**一个起始时间选择器**,不是 tasks 写的 DatePicker×2 | **是(design D14 / tasks T068 各改一句)** | manager 契约只有 `tail / since / keyword`,**没有 `until`**;返回的 `lines` 是无时间戳纯字符串(docker logs 未带 `-t`),端侧无从按结束时间过滤。第二个选择器只能是假的 |
+| T059 | UNION 投影列集由 9 列变 **10 列**,新增 `app_state`(第三支投 `App.state`,另两支投 `cast(null(), String(16))`) | **是(design D8 的投影列集那句)** | 卡片应用态徽标的数据源(2026-08-17 前端批裁决 1);且 `app_state` 过滤器无论如何都要这一列,再让 `add_extra_field` 另查一次就是两份真相 |
+| T059 | `app_state` 只出现在 `flow_type=35` 的行上,工作流 / 助手的 payload 一个字节不变 | 否 | 给两个前端已在解析的响应加一个恒 null 的字段,没有读者却有回归面(AC-59) |
+| T059 | `database/models/app.py` 新增纯字符串常量 `APP_STATE_ONLINE` / `APP_STATE_DELETED` | 建议 design D8「模型落点」补一句 | RULE-2 禁 `database/models` 导 `bisheng.*.domain.*`,`flow.py` 拿不到 `AppState`;由 `test_app_state_literals_match_the_enum` 钉住不漂移 |
+| T059 | 第三支 `WHERE state != 'deleted'` | 否(与 `AppQueryService.list_apps` 同口径) | 行为审计留存,但「已删除」不是一个可浏览的态 |
+| T059 | **遥测副作用需裁定**:托管应用经 `get_all_app_by_time_range_sync` 进入 `mid_app_increment`,`convert_flow_type` 无 35 映射 → 落 `ApplicationTypeEnum.UNKNOWN` 桶 | 待裁定 | 给该枚举加成员=改一个已写进 ES 文档、被下游看板聚合读取的契约,超出本 Feature;已在函数处加注释声明是决定不是疏漏 |
+| T060 | `SUPPORTED_APP_TYPES` 之外另加 `enabled_app_types()`(按部署开关做减法) | **是(design D8 第 1 项 + K12)** | AC-58 要求开关关闭时「不出现任何托管应用卡片」;前端只挡住类型下拉,「全部类型」的请求仍由后端回答 |
+| T060 | 标签资源类型抽成 `_tag_resource_types()`,并**多改一处** `get_uncategorized_flows` 的两次 gather | **是(design D8 第 5 项的 4 处 → 5 处)** | 它取的是「已打标资源」的补集,漏一个类型=把已打标的托管应用误报成未分类,是空结果的镜像故障 |
+| T060 | `check_tag_link_permission` 用新的模块级 `_aget_hosted_app`,不复用 `AppQueryService` | 否 | 后者答的是详情页口径(owner ∪ 租户管理员),打标问的是 F048 `edit`;复用等于对同一请求套两把尺子 |
+| T060 | `app_state` 查询参数**不加 pattern 校验** | 否 | 未知取值天然匹配不到任何行;加正则等于把五个态名字再抄一份到端点层 |
+| T058 | 游标翻页的**往返**在 SQLite 上断言不了,改为断言「三支归并后的全序」+ keyset 谓词绑在三支 union 上 | 否(harness 事实,非产品行为) | cursor 带 ISO-8601 字符串(`...T00:01:00`)、SQLite DATETIME 存成 `... 00:01:00`,`' ' < 'T'` 让谓词对每行都成立;**已用「只有 flow + assistant 两支」复现,与第三支无关**,MySQL / DM8 由驱动转成真 DATETIME |
+| T058 | `check_tag_link_permission` 的两个用例是同步测试并 stub `_aget_hosted_app` | 否(测试基建) | `run_async_safe` 在运行中的事件循环里直接抛错;跨事件循环复用 aiosqlite 引擎本身不成立 |
+| T058 | 新增 conftest fixture `build_list_env` 用**文件型** SQLite | 否(测试基建) | 列表查询走 async、`add_extra_field` 走 sync,两个引擎必须指向同一个库;`sqlite://` 内存库做不到 |
+| T061 | 在**类**上 monkeypatch `ConfigService.get_from_db` 等三个方法 | 否(测试基建) | 该模型开了 `validate_assignment`,往实例塞非字段直接 ValidationError |
+| T063 / T070 | `apps.tsx` 默认导出组件由 `apps` 改名 `Apps` | 否(前端债务偿还) | 小写函数名让 ESLint 不认它是组件 → 16 条 frozen `react-hooks/rules-of-hooks`;本任务要加 hook,不改名即突破冻结上限。改名后该规则整条清零 |
+| T063 | `AppAvator`(共享头像组件)补上 Props 类型 | 否(类型债务) | 它此前无 Props 类型,默认值把 `id` 推成 `number`;strict 文件(新卡片 / 详情页头部)一调用就报 TS2322 |
+| T066 | 详情页同时具名导出 + 默认导出 | 否 | `React.lazy` 必须要默认导出,仓库组件规范要具名导出,两者都满足 |
+| T066 | 版本 tab 直接渲染只读版本表,不是「只出空壳」 | 否(T062 正文已写「三处共用本封装」) | 空壳会让 `GET /apps/{id}/versions` 只剩卡片下拉一个消费方;F055 的管线列以 `contentSlot` 接入,不冲突 |
+| T067 | F055 / F056 的留位是三个**具名 slot prop**,不是 `children` | 否 | `children` 只有一个位置,三个 Feature 会在同一处打架 |
+
+**批 1 顺带修正的既有问题**:`AGENTS.md:62` 的「8 RULEs」与 `docs/constitution.md` 锚点表都已随 RULE-10 更新为 10 条(design K1 点名的过时表述)。
+
+---
+
+## 跨 Feature 回写受理
+
+> 别的 Feature 请求本 Feature 交付的增量,逐条记「谁请求 / 请求什么 / 交付没交付 / 在哪」。只追加。
+
+| 请求方 | 请求内容 | 状态 | 交付物 |
+|---|---|---|---|
+| **F052**(MCP 工具面 T209,2026-09-16) | `AppDataService` 补 `insert_row` / `delete_row`,对应 manager RPC `POST /v1/apps/{id}/db/tables/{t}/rows` 与 `DELETE …/rows/{key}`。**原因**:F052 spec 决议-8 与伴生 PRD §4.2.4 都把 `app:manage` 写成「表结构与数据**读写**」,读写含行级增删改;今天 manager 侧只有 `PATCH …/rows/{key}`,所以 MCP 面只交付了读与改。护栏沿用现有的:owner-only 内建、每次写记 `app.data_row_edit` 审计(带 before / after)、不含 DDL | 🔲 未交付 | 落地后 F052 在 `open_api/mcp/registry.py` 补 `bisheng_app_db_row_insert` / `_row_delete` 两个工具(当前**未占位**——`available()` 恒假的空壳不如没有),并解除 spec AC-15 中「增 / 删」两项的待验状态 |
+
+## 2026-10-09 · PRD-1 评审定为「合入发版线前修复」的两项安全缺口
+
+- [ ] **T098**: 出站白名单 systemd 形态缺口——补宿主 INPUT 规则
+  **现象**:开启出站白名单后,systemd 形态下托管应用容器仍可经 `bisheng-apps` 网关 IP 直连宿主上监听 `0.0.0.0` 的服务,114 实测可达 MySQL 3306、backend 7860、MinIO 9000、SSH 22。
+  **原因**:`--internal` 只断默认路由;访问网关地址的包走宿主 INPUT 链,L3 规则以 `-s <应用网段>` 挂在 `DOCKER-USER`(FORWARD 路径)上,碰不到这些包。
+  **修法**:在宿主 INPUT 链对应用网段只放行 egress-proxy 与附件端口,其余 DROP;随 systemd 部署脚本落地并幂等。同时在 compose 形态核对:容器经网关 IP 访问宿主发布端口(3306 / 7860 / 9100 等)是否被拦截。
+  **验收**:114 上从托管应用容器探测上述四个端口全部不可达,egress-proxy 与附件端口可达。
+
+- [ ] **T099**: `obo_secret` 缺失或等于 `jwt_secret` 时入口改为拒绝进入
+  **背景**:2026-08-17 裁决「OBO 无消费方时放行,出现第一个消费方时在同一次改动里改为 fail-closed」(本文偏差记录 T033 ④、`entry_authz_service.py` docstring)。SDK 与能力总线现已消费 OBO 令牌,翻转条件已满足但未执行。
+  **修法**:入口判定在不签 OBO 时返回不可用判定,app-proxy 渲染「本环境配置不完整,请联系平台管理员」页;backend 启动时对工场运行时层开启但 `obo_secret` 缺失或等于 `jwt_secret` 打 error 日志。
+  **验收**:配置缺失或相同时访问任一托管应用得到配置不完整页,不进入应用;配置正确时行为不变。
+
+
+## 2026-10-24 修订设计确认后收尾任务
+
+本节优先于历史裁剪/阶段描述。用户确认原文:确认 F054/F056 修订设计,继续完成剩余开发并提测。只运行本功能必要的定向验证;真实部署证据与模拟测试分开登记,未执行不记通过。
+
+- [x] **T100**(基础设施):节点隔离报告持久模型与独立新表;None 租户不适用于节点全局事实;显式全局报告;回滚停用并保留表,不删数据
+  - 文件:`src/backend/bisheng/app_runtime/domain/models/runtime_host_isolation.py`、`src/backend/bisheng/app_runtime/domain/repositories/host_isolation_repository.py`。
+  - 依赖:无(已确认设计);配对:无(基础设施/交付)。
+- [x] **T101**(测试):正式/预览缺失、空白、与会话相同配置拒绝,正常签发及存在性顺序,拒绝无成功访问
+  - 文件:`src/backend/test/app_runtime/test_entry_authz_service.py`、`src/backend/test/app_runtime/test_preview_entry.py`。
+  - 依赖:无(已确认设计);配对:T102。覆盖 AC: AC-70, AC-71, AC-72, AC-73。
+- [x] **T102**(后端 Domain):共用 OBO 配置诊断,入口仅允许有效签发,启动安全诊断
+  - 文件:`src/backend/bisheng/app_runtime/domain/services/entry_authz_service.py`、`src/backend/bisheng/main.py`。
+  - 依赖:T101;配对:T101。
+- [x] **T103**(后端 API):配置不完整三语兜底页,HTTP/WS 保持拒绝
+  - 文件:`src/app-proxy/app_proxy/pages.py`、`src/app-proxy/app_proxy/main.py`。
+  - 依赖:T102;配对:T101。
+- [x] **T104**(测试):报告签名/身份/时效/配置绑定,iptables/nft 主链正反向及不支持配置拒绝
+  - 文件:`src/backend/test/app_runtime/test_host_isolation.py`、`src/runtime-manager/tests/test_host_isolation.py`。
+  - 依赖:T100;配对:T105、T106、T107。覆盖 AC: AC-66, AC-67, AC-68, AC-69。
+- [x] **T105**(后端 Domain):独立 host-isolation HMAC 报告和持久校验,角色不能混用
+  - 文件:`src/backend/bisheng/app_runtime/domain/services/host_isolation_service.py`、`src/backend/bisheng/app_runtime/api/endpoints/host_isolation.py`。
+  - 依赖:T104;配对:T104。
+- [x] **T106**(后端 Domain):Docker create/claim/ready/route 提交核对 fresh proof;清理不阻断;K8s 保持原门禁
+  - 文件:`src/backend/bisheng/app_runtime/domain/services/runtime_operation_service.py`、`src/backend/bisheng/app_runtime/domain/repositories/runtime_delivery_repository.py`。
+  - 依赖:T105;配对:T104。
+- [x] **T107**(基础设施):宿主真实网络规则事务安装/恢复、配对 canary、失败隔离本模块工作负载;无写入者证明不清数据
+  - 文件:`src/runtime-manager/runtime_manager/host_isolation.py`、`deploy/runtime-manager/install-host-isolation.sh`。
+  - 依赖:T104;配对:T104。
+- [x] **T108**(基础设施):宿主 timer/配置交付及 compose/systemd 安装说明,保留无关规则和实例
+  - 文件:`deploy/runtime-manager/README.md`、`docker/docker-compose.yml`。
+  - 依赖:T107;配对:无(基础设施/交付)。
+- [ ] **T109**(测试):E2E 用例与提测清单;本轮定向 L1,实际宿主未执行项明确待验
+  - 文件:`src/backend/test/e2e/test_e2e_vibe_governance_closeout.py`、`features/v3.0.0/054-app-domain-runtime/closeout-validation.md`。
+  - 依赖:T103、T106、T108;配对:无(基础设施/交付)。覆盖 AC: AC-66, AC-67, AC-68, AC-69, AC-70, AC-71, AC-72, AC-73。
+
+增量任务审查:2026-10-24 `/sdd-review tasks` 第 1 轮发现三语任务粒度过大;已拆成每语两文件并补共享注册落点。第 2 轮 21 项检查 LGTM,证据见 `closeout-task-review.md`。后端按测试先行逐批落地,不把定向验证当部署验收。
+
+### 实际接线补充(同一批准范围)
+
+下列任务补齐共享配置、命名导出和测试夹具的文件归属;执行以依赖为准,配置/ORM 基础先于消费方。未引入新的业务需求。
+
+- [x] **T110**(基础设施):为既有生命周期测试声明合成已验证宿主;真实证明另测,不把 fake 当主机验收
+  - 文件:`src/runtime-manager/tests/fakes.py`、`src/backend/test/app_runtime/test_runtime_delivery_repository.py`。依赖:T100;配对:定向用例见本功能 closeout-validation.md。
+- [x] **T111**(基础设施):宿主 root oneshot/timer 安装单元,30 秒刷新,manager 不获 root/CAP_NET_ADMIN
+  - 文件:`deploy/runtime-manager/bisheng-host-isolation.service`、`deploy/runtime-manager/bisheng-host-isolation.timer`。依赖:T107;配对:定向用例见本功能 closeout-validation.md。
+- [x] **T112**(基础设施):登记独立宿主报告配置/身份和审计源时间区;旧 YAML 顶层不新增字段
+  - 文件:`src/backend/bisheng/core/config/app_runtime.py`、`docker/.env.example`。依赖:T100;配对:定向用例见本功能 closeout-validation.md。
+- [x] **T113**(测试):正式/预览旧 allow 不复用,拒绝页三语,真实 HTTP mock 计数及下次请求拒绝
+  - 文件:`src/app-proxy/tests/test_obo_configuration_page.py`、`src/app-proxy/tests/test_authz_and_fallback.py`。依赖:T101;配对:T102、T103、T116(F054)或 T049(F056)。覆盖 AC: AC-70, AC-71, AC-73。
+- [x] **T114**(后端 Domain):Engine 适配器与 legacy 路由走公共宿主证明协议,停止不受证明门禁影响
+  - 文件:`src/runtime-manager/runtime_manager/docker_backend.py`、`src/runtime-manager/runtime_manager/api/routes.py`。依赖:T104;配对:定向用例见本功能 closeout-validation.md。
+- [x] **T115**(后端 Domain):legacy probe 加本节点归属标签,供宿主失败停止确认
+  - 文件:`src/runtime-manager/runtime_manager/probe.py`。依赖:T104;配对:定向用例见本功能 closeout-validation.md。
+- [x] **T116**(后端 Domain):只保留拒绝缓存;所有新 HTTP/WS 握手重新鉴权,不改路由缓存
+  - 文件:`src/app-proxy/app_proxy/clients.py`、`src/app-proxy/app_proxy/authz.py`。依赖:T113;配对:定向用例见本功能 closeout-validation.md。
+- [x] **T117**(后端 Domain):证明读取设 3 秒超时,legacy 自检显示真实宿主证明状态
+  - 文件:`src/runtime-manager/runtime_manager/platform_client.py`、`src/runtime-manager/runtime_manager/api/readonly.py`。依赖:T114;配对:定向用例见本功能 closeout-validation.md。
+
+**2026-10-24 状态**:本轮主功能实现代码及定向用例已接齐;部署 E2E 与最终 L2 仍未完成。未执行全量测试,旧批次计数不表示本轮交付状态。新增接线的 L1 与证据见 `closeout-validation.md`;未对每个小任务保留独立的先红后绿日志,不宣称完整 TDD 历史。
+
+## 2026-10-25 部署定向修复
+
+- [x] **T118**(hotfix):父链读取按规范化字段比较,保留 first-N 及精确子网围栏。文件:`src/runtime-manager/runtime_manager/host_isolation.py`、`src/runtime-manager/tests/test_host_isolation.py`。覆盖 AC: AC-66、AC-68。单文件 11 passed,Ruff 通过;真实宿主复验尚待记录。
+
+### 2026-10-27 入口展示热修(T053 / T065 / T091 后续)
+
+- [x] 发布页文字、复制、打开和二维码统一使用浏览器当前 origin、部署前缀及应用 slug;不更改 backend/CLI 接口地址。
+- [x] 开发环境补 `/apps/` HTTP/WebSocket 代理和 `VITE_APP_PROXY_TARGET` 说明,消除历史上禁止 origin 拼接的开发环境原因。决策见 design.md「2026-10-27 浏览器应用入口跟随当前访问地址」。
+- [x] 本功能定向验证:PublishTab 4例、实际 Vite 配置加载1例通过;更改组件及API类型的ESLint、架构守卫和diff检查通过。SDD热修7维度自审通过;未运行全量回归。
+- [ ] 116前端镜像部署及浏览器实机验证;不以本地组件测试代替部署结果。
+
+### 2026-10-27 返回广场目标热修
+
+- [x] 默认返回client应用广场 `/workspace/apps/explore`,页面默认值复用配置常量,保留部署覆盖;不跳转管理员构建页。
+- [x] 返回目标定向8例通过;Ruff、架构守卫和diff检查;不跑全量。
diff --git a/features/v3.0.0/055-app-publish-pipeline/design.md b/features/v3.0.0/055-app-publish-pipeline/design.md
new file mode 100644
index 0000000000..f078e50acc
--- /dev/null
+++ b/features/v3.0.0/055-app-publish-pipeline/design.md
@@ -0,0 +1,372 @@
+# Design: 应用发布管线与应用密钥管理
+
+> **2026-10-23 简化设计已由用户单独确认,进入任务实施。** 用户原文:“确认 F055 简化设计”;批准前SHA-256 `2ef7247c7fcc71d1e2e44b34a9df8dfe865135adc66c249166a29202e29402d6`。 用户已确认 92 条 AC 的手动生效规格;源码基线 `5f2541f4bc483c24624fede9031c88c4ec6f58f4`。D1–D20 与 §1–§8 保持编号;正文覆盖旧三表/自动生效设计,不沿用历史设计的确认状态。
+>
+> [spec.md](./spec.md) · [tasks.md](./tasks.md) · [release-contract.md](../release-contract.md)。主导质量属性:无意外停机、秘密隔离、运行配置与保存配置可辨识。F054 B1、F056 S1 的既有发现仍待修,不由本文宣称关闭。
+
+## 1. 目标与非目标
+
+为已有发布/审批/运行管线补齐 owner 私有应用密钥:仅保存名称引用进版本,值加密存平台数据库,已审批正式实例按当前值注入。保存值不触发重启;页面提示当前实例与最新保存配置的差异,owner 确认后才复用部署流程重建当前版本。预检和审批预览只注入声明名与空值。
+
+非目标:第三方 Vault/KMS 托管、第三方凭据有效性探测、密钥 SDK/CLI 管理、商业限额、平台模型供应商密钥编辑、批量应用重启。F054 管生命周期执行,F056 管管理员运行记录查询,不复制这两个领域。
+
+## 2. 关键约束
+
+- 预检、构建、依赖安装、前端编译、扫描、审批预览均不得取得真实值;预览身份和隔离存储原有规则仍有效,不以“无应用业务密钥”取消正常权限检查。
+- 值只由同租户应用 natural owner 经平台会话修改;管理员、超管、审批人、SAK、PAT 均不能替 owner 写。权限 shortcut 不代替这个业务前置。
+- 声明属于冻结版本并需审批;值属于应用当前配置,改值/删值不创建版本或审批,也不自动创建运行操作。手动重建只能运行确认时的已批准当前版本,不能借机启动 pending_version。
+- 字段边界(拟定实现常量):每应用最多 64 项;名称 ASCII `[A-Za-z_][A-Za-z0-9_]{0,127}`,区分大小写且精确匹配;值 UTF-8 不超过 16 KiB,整组不超过 128 KiB,禁止 NUL(无法放入进程 env),支持 Unicode/换行/空格/特殊字符且原样传递。空字符串表示缺失;全空格值不自动 trim 成空。
+- 遵守 [constitution.md](../../../docs/constitution.md) C1–C8,不复制全局铁律。Constitution Check:新 Model/Repository/Service/Endpoint 分层;tenant_id 默认 None、非空、索引且注册自动注入;通用 LargeText/JsonType 和迁移单 head;仅 162 段新增错误码;平台持久 DB 为权威;不向 backend 引入容器 SDK。无例外。
+
+## 3. 方案对比与选定
+
+### D1:同步接收 + Celery 阶段机
+
+保留同步鉴权/安全解包/manifest 校验,异步扫描、准备交付、探活、审批。拒绝把整条分钟级管线放 HTTP 请求或把失败尝试写成已发布版本。现有 `AppDeployment` 与 worker 已接线;管线可并行分支真正出现时再重议。
+
+### D2:源码快照由平台保存
+
+backend 拥有私有对象存储与代码引用,manager 经受限传输 API 获取。拒绝 manager 直读 MinIO 或通过临时本地文件在 API/worker 间共享;现有 runtime_artifact_service 已提供传输协议。交付物类型改变时重新评估。
+
+### D3:manifest 严格校验,新增引用而非值
+
+`CapabilityDeclaration` 增加 `secrets: list[SecretRef]`,`SecretRef={name: str}` 且 extra=forbid,旧 manifest 不带该字段仍通过。采用与 models/knowledge_bases 同处的声明入口而非任意 `env` 字典;没有平台 issued scope,`derive_scopes` 不因 secrets 新增开放 API 权限。
+
+当前 `_reject_secret_references` 会泛化拒绝 secret/token 等键,必须只豁免合法 `capabilities.secrets[*].name` 子树,其他位置仍禁明文/不支持的引用形状;全源码扫描继续先于构建。`is_empty`、部署适配、预览、摘要/diff、CLI 校验与提示、审批审读都同步识别名称。若未来支持外部 secret provider,另改规格与 schema,不接受 `vault://...` 偷渡。
+
+### D4:fail-fast 阶段结果保留
+
+沿用 `{stage,code,message,details,hints}` 失败形状;缺值是 warning,不单凭缺值拒绝发布。应用自身无法启动则正常探活失败,不跳过 Gate。不得将第三方 key 的值放进 details/hints。若发布阶段拆服务再评估传输合同。
+
+### D5:明文扫描继续阻断
+
+合法声明名不视作密钥明文;实际源文件/打包内容仍扫描、只返回文件/行号/规则,不返回匹配值。密钥不进源码或依赖缓存,不能为“用户确实需要 key”放宽扫描。新检测模式只更新规则与配对用例。
+
+### D6:版本只保存声明快照
+
+版本仍在预检通过后建立;只冻结 secret 名称,不保存值/密文/可还原引用令牌。运行时注入值不写入 `AppVersion.injections`,否则历史版本会变成密钥仓库。未来需要配置历史恢复时需另立规格。
+
+### D7:审批场景保持现有强制入口
+
+仍经统一审批场景/outbox;审读只提供声明名称和按调用时状态计算的缺失提示。审批人不获得 owner 密钥管理能力;声明增删需审批,值修改不触发审批。已有审批规则与申请人过滤保持。改变审批权限时必须读 approval-module skill,不在本次重写中心引擎。
+
+### D8:既有审批基建修复不重做
+
+早期审批解析/seed/通知回退已落地,本轮只核对真实调用点。拒绝把历史设计“阻塞前置”重做成新任务。只有本轮定向用例暴露回归时再改。
+
+### D9:上线前终检与容量准入
+
+冻结交付、当前应用状态、审批结果与容量仍是前置;正式环境在 **执行前**由 backend 生成并封装,不能由调用者透传 runtime_env。manager 不自行判断“审批过”;信任操作的可信用途和版本绑定。执行协议变化时重议。
+
+### D10:撤回 / 删除取消保持幂等
+
+沿用已有终态守卫和持久取消;用户确认的密钥配置重建也遵守停止/删除围栏。删除应用成功后清除密钥值,审计按既有留存保留;删除未知/清理失败时不抢先把数据与身份当已释放。领域销毁协议改变时重审清理顺序。
+
+### D11:档位为平台资产
+
+资源档位继续独立管理、停用不删;用户确认重建使用当前运行操作冻结的资源预算,不因全局档位配置已改变而重算。支持动态扩缩容时另审。
+
+### D12:操作审计与运行记录分层
+
+生命周期/声明/密钥变更进入操作审计;模型、能力、访问记录各有现存专表。密钥事件只能含 app/ref/actor/action/time/result;API key 事件仍可含原有 key_mask,不混用两种掩码。审计模式改变时与 F056 共同审查。
+
+### D13:已有能力总线与运行凭据保持
+
+`runtime_capability_env`、`AppRuntimeCredentialService`、模型面/检索 OBO 已实现,不再“本轮后置”。应用业务密钥无新 SDK API;`os.environ` / `process.env` 原样读取。预检/预览继续没有正式 app credential,但有隔离存储环境。真实密钥不能混进模型供应商平台配置。新增平台能力时才扩 scope。
+
+### D14:审批侧使用当前页面
+
+以现有审批详情/审读页实际入口为准,不恢复过期弹窗设计;仅增名称/缺失提示和差异展示,不新增审批操作。若现有布局没有可用组件,跟随现存审读样式,不改设计系统。
+
+### D15:发布面复用现有 settingsSlot
+
+F055 的 `AppSecretSettings` 加进应用详情的 settingsSlot,与档位/能力并列;查询与持久操作状态由 hook 管理,store 不发 HTTP。natural owner 可编辑,其余有查看资格者仅看名称/缺失;审批人只走审读数据。失败必须 catch 并终止等待,防止新 request 拒绝 Promise 后 UI 卡住。
+
+管理摘要 GET 先在当前管理视图内定位应用,再临时绑定应用所属租户,严格读取该应用的配置、版本和当前运行快照;结束或异常均恢复原管理视图。继续先验证请求租户资格及 owner/超管/租户管理员/现有 edit 权限,再解密配置;非 owner 只返回版本声明的名称与是否已配置。查询不存在或不在视图内的应用返回 16101,管理权限不足返回 16106,负责人专属写操作继续使用 16254。不能将写事务原有的 strict tenant 查询直接用于“整个平台”管理摘要:管理员的归属租户与目标应用租户不同,会在业务鉴权前误报负责人限制。若未来改变可查看摘要或可修改值的资格,再重新审查此边界。
+
+### D16:当前交付边界
+
+现有发布、预览、结构演进、模型/知识能力与三交付模式已有代码;本轮只补密钥与用户确认的 runtime 重建,不借此重做 F086/F059。旧任务状态与当前证据分开。未来非本轮能力见 §8。
+
+### D17:一张加密配置表,执行状态复用既有账本
+
+否决三张密钥专表及配置独立 applied 状态机:原方案为自动生效维护值、状态和 mutation 意图,但用户已明确保存不重建;现有 RuntimeOperation 已持有幂等、冻结环境、执行事实和失败结果,重复维护会增加双写。选定 `AppSecretConfig` 每应用一行,整组键值映射加密存储;配置 revision 只防旧页面覆盖,不表示运行实例已经采用。
+
+使用既有 `app_runtime.runtime_payload_key`,密文 envelope 校验 `tenant_id/app_id/domain=app-secret-config-v1`。整组映射有大小上限,名称在解密后按原大小写精确比较,无须逐名称摘要/专有 collation。不存业务值普通哈希,不另建 AppSecretState、AppSecretMutation、配置专属扫描器或 ConfigurationApplyPort。平台固定部署 key 要与数据备份;误改或缺失明确失败,不降级为缺值;更换仅作运维说明,不开发轮换工具/UI。将来确有跨应用共享或历史值恢复需求才重新审查,不能在本版预建。
+
+### D18:保存与用户确认重建分离,复用 deploy 执行器
+
+否决保存后自动重启、Docker restart(环境不会更新)、再走发布预检/审批/DDL,以及专用配置执行引擎。保存事务只写加密配置、revision 和既有审计,不调用 manager。用户另行确认后,经 F054 `rebuild_current` 公共应用入口受理 `kind=deploy,purpose=config_restart`,以新的 RuntimeOperation 重建同一冻结 delivery/profile/镜像/档位。
+
+该入口不是原 `deploy()` 原样直调:需要绕过同版本 serving 快速返回、拒绝串接其他 purpose 的 active deploy,并固定空 migration_plan、禁 snapshot/restore。旧 writer 停止确认、预占、探活、切路由、清理和故障续办均用现有机制。失败后不由 recover 自动再创建 config_restart/替代 deploy;只能用户重新核对并确认新 attempt。续办已受理 operation 和 deadline 不算再次用户动作。未来有应用热加载协议时再讨论免重建,不在本版实现。
+
+### D19:以当前路由所绑定的冻结环境判断差异
+
+否决“revision 不同就要重启”、单独持久 dirty 标志、每次改值清空已生效状态和额外值摘要历史库。当前 RuntimeRoute/AppInstance 决定使用哪次正式 operation,backend 受限读取其 sealed_env,仅比较当前批准声明的名和值与最新配置。后保存未引用项不会误报;A→B→A 与实际运行值相同会消除差异。比较结果只含变量名、same/different/unknown 及安全状态,没有新旧值。
+
+保存允许发生在已受理重建期间;本次 operation 的环境一经冻结不变,后续保存仍作为最新配置保留,切路由只提交运行事实、不回写配置。请求丢失复用 RuntimeOperation 的 scope/key/request_digest;不为保存维护历史请求 HMAC。执行上下文缺失、解密失败或路由变化无法取得一致视图时报告 unknown,不能猜已一致。未来若现有执行上下文不再保留而产品要求永久历史配置比较,再另审数据需求。
+
+### D20:输入草稿与固定掩码(新增)
+
+不返回值、不允许“查看明文”。列表只显示固定 `••••••`,不与密码框输入绑定;点击替换打开空的输入草稿,初值绝不放掩码。值控件用已落地 Input/Textarea,换行值不通过单行组件截断;写成功后清空内存草稿,失败保留当前未提交草稿。关闭/换应用/退出清理,不写 URL/localStorage/query cache/埋点。避免使用带请求体自动回显的通用错误日志;secret endpoint 的请求校验异常只返回字段位置/规则,过滤 Pydantic input/ctx/raw body,确保非法值也不会被校验错误回显或写日志。
+
+取 current shared UI docs 的字体/色彩/多端/输入框规范,Button/Input/Textarea/Tabs 使用 status=done 的组件;Select 尚为 draft,筛选跟随当前页面已存在模式,不另造。表单/确认对话框用当前发布页已有实现;不改组件样式、不新增库。未来需要设计师定义新交互时再申请视觉评审。
+
+## 4. 系统现状(接手必读)
+
+### 4.1 数据流与实现状态
+
+**已实现**:接收包 → 代码快照 → 扫描/结构预检 → manager prepare/probe → 版本/审批 → 正式 deploy;backend 持有对象数据,manager 管 SQLite;RuntimeOperation 已持久保存 sealed_env,RuntimeRoute 标记当前运行 operation,K8s 已有执行专属 Secret。**2026-10-24 已编码(未完成真实部署验收)**:合法 secret 声明、单表加密配置、管理 API/差异投影、用户确认的同版本重建与敏感输出合同;平台发布页环境变量设置已接线。
+
+```mermaid
+sequenceDiagram
+  participant Owner
+  participant Backend
+  participant DB
+  participant Manager
+  Owner->>Backend: save secret + expected_revision
+  Backend->>DB: encrypted config + revision + audit only
+  Backend-->>Owner: saved; computed differences; no restart
+  Owner->>Backend: confirm rebuild: request_id + expected version/config/route
+  Backend->>DB: existing operation + frozen env in one short UoW
+  Backend-->>Owner: operation_id
+  Backend->>Manager: wake existing durable deployment
+  Manager->>Backend: claim operation and receive frozen environment
+  Manager->>Manager: stop proof; same delivery; health check
+  Manager->>Backend: ready execution fact
+  Backend->>DB: existing instance and route commit
+  Owner->>Backend: read config and running-operation difference
+  Backend-->>Owner: same / different / unknown, names only
+```
+
+保存路径没有 manager 调用。预检/预览只生成 `{NAME:""}`,不调用正式解密协议;正式部署/重新上线在当次受理中选最新保存配置,已受理操作和进程原地重启使用其冻结环境,不因新保存值改写。
+
+### 4.2 既有公共合同(保留原编号语义)
+
+**① 管线接收与轮询**:`/api/v2/apps` 由 router_rpc 单一 `verify_open_api_access` 与端点 `open_api_scope("app:manage",modes=("S",))` 保护;本次不把秘密管理挂 v2。
+
+| 路径 | 入参 / 返回 |
+|---|---|
+| GET `/deploy-limits` | 返回部署实际 `max_package_mb/max_unpacked_mb/max_package_entries`,CLI 不写死上限 |
+| POST `/deploy` | multipart package/app_id/confirm_schema_change;返回 deployment_id/app_id/version_id;同步失败不进入轮询阶段 |
+| GET `/deployments/{deployment_id}` | stage/status/failure/app_id/version_no/approval/app_state/pending_reason 及 secret_references(name/kind/reason/revoked,无值)等当前 service 投影;CLI 只读状态,不自行合成成功入口 |
+| GET `/{app_id}/logs` | tail/since/keyword;转同一个日志服务并增加 resource owner/租户限制,不因 key 的管理员身份放大范围 |
+
+**② 发布状态只读接口**:`GET /api/v1/apps/{app_id}/publish-status` 由 PublishStatusService 同时供发布页与工具适配消费;app_state/pending_reason、current/pending version、deployment、approval、tier、capabilities、schema_change、can 是不同维度,缺失 row 不假造成功。手动上线/撤回仍服务端重新检查。
+
+**③ AppManifest**:严格 extra=forbid;manifest_version 1 走兼容 source-image,2 支持 bundle/image/source-image;runtime=python3.11/node20/static,port/tier/capabilities/database/egress 继续当前模型;delivery/dependencies/startup 的准确字段以 `schemas/app_manifest.py` 与 F086/F059 冻结合同为准。本轮仅新增 `capabilities.secrets:[{name}]`,值不在 manifest。模型/知识能力及声明表结构/egress 现已实际消费,不再“非空即拒绝/只给 hints”。
+
+**④ 审批场景**:app_publish_request;business_resource_type=app / business_resource_id=app_id;申请人为自然人 owner;business_key 对应提交尝试。build_detail 为结构化审读快照,有 app/version/tier/capabilities/visibility/schema_change 与兜底展示键;新增 secrets 仅名称/缺失提示。当前单租户审批来源与多租户解析按已合入代码,不能恢复早期固定 seed 示例。
+
+**⑤ 表**:AppDeployment 保存一次发布尝试的 stage/status/code_object_key/manifest/tier_code/failure/scan_result,预检失败不进 AppVersion;ResourceTier 平台级、code 唯一、启用/停用。新密钥资产见 §4.4①,不修改 ResourceTier 所有权。
+
+**⑥ 审计**:app.release.* 事件族保持;app/version/deployment 关联来自业务事实,事件枚举、后端可见白名单/命名空间、前端 actions/i18n 三语同步。新 app.secret.* 是值动作元数据,不带秘密片段。
+
+**⑦ 通知**:发布/审批事件经现有 outbox 与通知适配触达;action_code 与接收者权限由真实场景决定。密钥值不进入通知正文、detail_snapshot 或前端消息 payload。
+
+**⑧ 错误码**:沿用现有 162 模块与 frontend api_errors 唯一文案;16207 运行时关闭、16223 档位无效、16225 审批场景不可用、16226 容量不足、16228 探活失败语义不混用。原 16230 泛拒绝 secret reference 必须收窄为不支持/非法形状,合法 ref 可发布;模型面仍使用 265xx,不翻译成非模型 162xx。
+
+### 4.3 关键模块职责
+
+| 模块 / 拟新增落点 | 做什么 | 不做什么 |
+|---|---|---|
+| F055 `domain/models/app_secret_config.py`、Repository、AppSecretService、v1 endpoint | 单表加密配置、owner 权限、revision CAS、审计、差异投影 | 不编排、不创建自动重建意图、不返回明文 |
+| F055 `application/runtime_secrets.py` 公共协议与组合根 | `formal_snapshot` / `preview_environment`、配置清理;内部环境 DTO | 不暴露 ORM、不自行切路由、不授予管理权限 |
+| manifest/schema/CLI/审批摘要 | 合法 secrets 名称、保留名、缺值提示 | 不加载或携带值 |
+| F054 AppStateService / RuntimeDeliveryCoordinator | `rebuild_current` 受理、环境冻结、既有 deploy 执行与提交 | 不写 F055 配置表/已生效结果,不迁移数据 |
+| manager Docker/K8s executor / admission | 用途/敏感名校验,创建实例、停止证明、探活及事实 | 不读平台 DB/MinIO、不管理最新配置、不因保存而重启 |
+| 现有 node-plan/协调入口 | 续办已有操作,读取原期限及冻结环境 | 不扫描配置差异来创建操作,不自动重试失败手动重建 |
+| AppSecretSettings 与 hook | 空草稿、固定掩码、保存、差异、确认、既有操作进度 | store 不请求、缓存不存值、不自动确认 |
+| F056 / 凭据 Owner | 查询配置事件;S1 的真实 binding 回收事件另待补齐 | 不从查看者参数伪造回收事实 |
+
+### 4.4 本轮增量字段与接口合同(拟实现)
+
+**① 单个持久配置模型**
+
+`AppSecretConfig` / `app_secret_config`:app_id 主键(与 App.id 同型)、tenant_id 默认 None/NOT NULL/索引、revision 非负整数、values_ciphertext 用通用 LargeText、updated_by/updated_at。密文内容为 envelope 与有界 entries 映射:`name -> {value,updated_by,updated_at}`,保留每项最近修改时间而不增加表。最后一项删除后保留空配置行/revision,避免 revision 重置导致旧页面误写;应用真正删除后由既有持久 cleanup 调 F055 公共 `delete_for_app` 幂等清除,失败保留既有 cleanup 待办;历史无值审计不删除。
+
+未建行表示 revision=0、空配置。save Repository 用同一短事务加载受权 App 并锁定,再按 app_id 锁配置/处理唯一约束首建;禁止在该事务调用 manager、锁节点/operation 或持有锁探活。与 App 删除的最终状态写入序列化:已删除不能再保存;先置业务不可写状态再清理配置,不允许清理后重新写入孤儿配置。每个接受的保存/删除使 revision+1;旧 expected_revision 拒绝,不自动覆写。配置与本业务审计由 AppSecretConfigRepository 使用同一个显式 AsyncSession 追加,事务失败一起回滚;复用既有 AuditLog 表及字段约定,不另建表。现有 AuditLogDao.ainsert_v2 自开 session 并 commit,不能调用它宣称同事务,也不为本功能新加 DAO 入口。
+
+新模型登记租户发现与 API/worker 导入,缺上下文写入拒绝;DM8 仅通用类型/约束/迁移静态检查。迁移从唯一 Alembic head 接续。应用停用、版本升级、声明移除不删资产;名称精确比较、Unicode/空白/换行按 §2 处理,空值不标为已录入。
+
+**② 会话 API(拟新增)**
+
+| 方法 / 路径 | 入参 | 返回 / 副作用 |
+|---|---|---|
+| GET `/api/v1/apps/{id}/secrets` | 无值参数 | `{revision,updated_at,items:[{name,present,mask,updated_at,approved_reference,pending_reference}],runtime:{comparison,saved_revision,running_operation_id,running_version_id,route_epoch,changed_names,rebuild_operation}}`;comparison=same/different/unknown/not_running,失败原因安全枚举 |
+| PUT `/api/v1/apps/{id}/secrets/{name}` | `{value,expected_revision}` | `{revision,present,updated_at}`,只保存+审计;没有 operation_id、自动 pending 或后台唤醒 |
+| DELETE 同路径 | `{expected_revision,confirm_online_impact}` | 同上;删除保存值,不改声明/当前实例;线上引用先说明当前实例仍可能持旧值 |
+| POST `/api/v1/apps/{id}/secrets/rebuild` | `{request_id,expected_version_id,expected_config_revision,expected_route_epoch,confirm_restart:true}` | `{operation_id,status}`,owner 明确确认后转 F054 `rebuild_current`;accepted 不表示已就绪 |
+
+同租户先查业务对象及原可读规则,再验证平台会话的自然人 owner;super shortcut 不变为 owner。tenant admin 只见有资格读取的声明名/状态,不见未引用私有项和 owner mask/修改者;审批人只走批准范围的审读数据,不复用 owner 管理接口。写/重建均拒绝 SAK、PAT、非 owner。无权/不存在按原业务拒绝语义,不泄漏其他租户。公开请求不能传 env、ciphertext、purpose、node、tenant 或任意执行资源。
+
+保存不设独立 request_id 历史表:网络结果不明时刷新安全元数据核对,不自动重复 PUT、不凭一个 revision 推断值内容已成功;显示结果待核对,owner 需要时重新提交。重建才用已有 RuntimeOperation 幂等:先做当前会话/业务归属核验,再按 scope=`tenant/app` 和 `secret-rebuild:request_id` 查原操作,核对原确认字段摘要;同参返回原操作,异参拒绝,不能因当前配置/版本已变化而先拒绝历史同参重放。新 attempt 才检查当前预期条件;新 attempt 需新确认,不复活原失败操作。摘要只含非秘密确认参数,不存业务值摘要。
+
+新增错误按 162 占用检查分配,三语文案同步;不在设计猜编号。Pydantic input/ctx/body、埋点及错误日志不回显值。保存与操作都用既有响应 envelope,前端 catch/finally 结束等待。
+
+**③ 与现有部署的最小衔接**
+
+1. F055 公共 `formal_snapshot(app,approved_version,expected_revision?,session)` 在 caller 的短事务中返回 `{revision,env,sensitive_env_names,declaration_digest}`;F055 Repository 显式使用传入 AsyncSession,只读/flush,不自行 commit,不依赖另一 Repository 的 ContextVar。调用方验证用途、批准版本和租户,provider 再验证输入绑定,只取声明子集;没有值为空,坏密文/缺 key 为失败,不伪装缺值。`preview_environment(declaration)` 只生成空值,不读配置密文。
+2. F054 `rebuild_current` 不能把 node→已存在 operation 当作既有统一锁序:claim/fact 实际先锁 operation 再 node,ready commit 还会先锁 App 再进入 node/route。新受理选择先锁 App,再依次锁 node、assignment、route、配置;对已存在的 operation(含 origin 和冲突检查)只作非锁定读取,绝不在持有 App/node 后回锁这些行。只允许锁/更新本事务新插入、尚未发布给任何 manager 的新 operation;幂等重放在这些运行锁前通过只读查找返回,不对旧 operation 再执行 admit。save 只锁 App→配置,不获取运行锁;配置锁拿到后不能回取 App/node/operation。受理事务核验 online/current version/route epoch、当前真实 frozen delivery/profile/tier、没有维护/生命周期冲突或未知 writer(包括已受理尚未 claim 的非终态操作,不只看 assignment 当前指针);选当前 route 对应执行的 tier 预算,不重新解析可能已被管理员修改的档位数值。冻结参数、容量预占、新 RuntimeOperation 与 sealed_env 在同一个既有 UoW 提交;失败整次回滚。未冻结环境的半操作不对 manager 可领取。网络唤醒在提交后,丢唤醒由原 node plan 发现,不新增配置扫描器。
+
+受理锁集合与现有路径的关系(仅约束本次新分支,不声称底座所有入口已有同一全序):
+
+| 路径 | 会等待的已有行 | 不允许的新增等待边 |
+|---|---|---|
+| save | App → AppSecretConfig | 不等待 node/operation |
+| 新 rebuild 受理 | App → node → assignment → route → config | 不锁任何已存在 operation;node 后不再取 App;仅本事务私有的新 op 可由 create/admit 自锁 |
+| 现有 claim/fact | 自身 operation → node → assignment/route | 本次适配不在持 node 后获取 App;ready fact 的事务先结束,再调用 commit |
+| ready commit | 自身 operation → App → node/assignment → route | 本次不在持 App 后回锁 origin operation;旧 writer 状态/事实只读,既有停止/容量协议不改 |
+
+同 app 的受理受 App 行串行,既有 node/assignment 围栏仍控制执行;活动冲突读取以拒绝/后续精确 origin 与代次检查保守处理,不把快照里暂未出现的 queued op 当作唯一安全证明。实现阶段用两个真实 SQL session 覆盖与 claim、maintenance fact、ready commit 的交错,不以单进程锁 mock 代替。若实际 helper 引入表外锁/独立 session,必须先修正此局部调用,不将上表当全仓已验证事实。
+
+3. 现有 create 会拒绝 caller sealed_env,因此由受信 `inject_environment` 在同一 UoW 装配 seal,不把 env 放进公开 OperationInput。持久非秘密字段为 `purpose=config_restart,auto_commit=true,secret_config_revision,sensitive_env_names,approved_declaration_digest,origin_operation_id,origin_exec_ref,origin_route_epoch` 以及既有 expected_app/current/pending 等围栏;秘密只在 sealed_env。普通新部署/恢复也要从其受理快照装配,不留“先创建操作、之后读任意最新值”的窗口。
+**同事务环境生产者,不复用独立提交方法冒充原子性**:现有 `inject_environment → AppRuntimeCredentialService.stage → CredentialService.issue` 会通过 CredentialRepository 和 HostedAppSubjectDAO 自开事务提交,签发审计也独立提交。新增受理路径由凭据 Owner 提供显式 `stage_in_transaction(...,session)` 公共服务入口:受信主体解析/首次创建、ApiCredential/委托子项、RuntimeCredentialBinding、签发审计都使用 caller 的同一个 AsyncSession,只 flush、不 commit;原无 session 的签发入口保持原行为。返回 token DTO 从本事务已 flush 的数据构建,不调用另开 session 的 `_to_item`/delegate 查询去读未提交行,不在提交前发 Redis/消息/外部回调。seal、任何 flush 或最终提交失败时,候选主体/凭据/binding/审计/op/预占均回滚;原幂等重放先返回已有操作,不再次签发。该新增协议只补本次受理所需的 producer,不宣称修复历史回收审计 S1,也不重写全部签发流程。
+
+4. `deploy()` 普通同版本快速返回保持原语义;只对显式确认的 config_restart 路径创建新实例。active deploy 的查询/复用必须校验 purpose 和冻结请求,普通发布不得接管 config_restart,反之亦然;冲突复用既有忙/unknown 拒绝。重建固定 `migration_plan=[]`、不允许 snapshot/restore、`preserve_pending_version=true`,不调用 schema_evolution_service;源 version/delivery/profile/digest 和原执行 tier 不变,不重建镜像、不创建代码版本、不触发审批。
+5. manager 明确声明支持 config_restart 与敏感输出协议,否则重建拒绝并提示升级;保存仍可独立完成。按既有停止证明→新实例→探活→ready 事实执行。Docker 用 Engine Env,K8s 复用执行专属 Secret/secretKeyRef,Secret 只供 serve,helper/preparer/assembler 无真实值。不能仅 docker restart,也不通过 shell 插值。
+6. **本操作自身的 maintenance 路由转换**:原 manager `_deploy` 在停止旧 writer 前会上报 maintenance,backend `fact` 调 `RuntimeProjectionService.block` 会递增 route_epoch;因此不能在 ready 时仍要求原 epoch 原样不变。新增该 purpose 的受围栏投影适配:在同一既有 fact/UoW 中核验 op 的有效归属和冻结 origin route(id/version/operation/exec_ref/epoch),再幂等消费 maintenance 事实、变更路由;在该 RuntimeOperation.payload 记录非秘密 `maintenance_transition={fact_sequence,route_id,before_epoch,after_epoch}`。同一 sequence 重放只返回原事实/转换收据,不再修改当前路由(即使后来已经 serving);没有重复 block/递增。新的 maintenance 事实若已有转换,只能验证同一尚在途的维护状态,不能追加任意 epoch 或把新路由再封回旧维护态。ready 提交只接受未变的原 route,或本操作这条事实导致的精确 after_epoch、maintenance 状态及原 route 身份,不能用“epoch 更大即可”放行。其他 stop/delete/切流或不同操作造成的改变仍拒绝;不把网络封入口当作 writer 停止证明。该记录只是既有维护事实到路由投影的幂等消费,不是密钥 applied 状态、额外表或新配置成功回执。
+7. `commit_runtime_delivery` 为该 purpose 保留 current/pending 指针和原版本终态,只更新原有实例/route/credential/storage 投影;不调用 mark_runtime_online 重标版本,不写 F055 表或 applied receipt。原 expected state/current/pending/UID/代次校验不放宽,origin route 只允许上条证明的自身维护转换;审批或停用等改变关键指针时按既有冲突清理,不能覆盖新指针。保存配置变化不作为拒绝提交的条件:受理后 revision 可以继续增加,本次仍按被冻结环境完成,页面保留真实差异。
+8. **明确的异步 ready→commit 消费者**:受理冻结 `auto_commit=true`,由现有 `RuntimeOperationService.fact` 在 ready 事实事务完成后调用 `AppStateService.commit_runtime_delivery` 的 config_restart 分支,不依赖一个已返回的 HTTP handler 等待。manager 仍按原协议等 serving;首次 commit 回滚时,持久 ready 事实保留,ready 上报的同参重放也必须再次尝试同一提交,不能因 fact 已存在而跳过 auto_commit。提交前 backend 进程退出由 manager 的原 operation/node-plan ready 续办触发原事实重报;不创建新操作、延长期限或配置专用调度器。manager/backend 都恢复不了则按原 deadline 显式失败,不挂成永久 pending。该消费者本身先结束 op→node 的 fact 事务,再进入 op→App 的 commit 事务,不跨两段持锁。
+
+route/实例沿既有 UoW 原子提交。DB 回滚则只重试原 ready 提交;响应丢失或 serving/completed 重放,检查既有 op/route/exec_ref 的一致性即可返回原结果,不再增加配置结果双写。未知结果不再创建 operation/执行 DDL,不把已变更的 route 归为旧结果成功。差异仅从实际当前路由计算,不从某个成功回包推导。
+9. 用户请求受理后可以继续保存;既有 operation 的 sealed_env 不替换。手动重建失败/取消/超时后,`RuntimeDeliveryCoordinator.recover` 与其他调度入口都读取现有 op.purpose/status,不把它自动转换成 recover deploy 再试;同一在途操作仍可按原期限续办/清理。旧 writer 未确认停止或 cleanup 未完成时,拒绝用户新 attempt;终态不等于已停。stop 可按原协议取消并清理,不授权强删。正常新版本发布、用户重新上线和独立崩溃自愈仍按各自既有生命周期;保存本身不会触发它们。
+
+**④ 差异计算合同**
+
+F055 查询服务消费 F054 公开 `running_environment_snapshot(app_id)` 只读协议,返回经租户/当前正式路由校验的内部 DTO(版本声明、operation/exec_ref/route_epoch、受限运行 env);不从 F055 跨域查询 runtime ORM、不调用 manager inspect,不把 env 放进响应。生产方为 F054 route/operation Repository,权限在调用前由业务服务确认。
+
+在短时一致读取中选取 current App/version、route 与对应 operation 的 sealed_env;重新确认 route identity 未变化后才报告 same/different。最新配置在本次读的 revision 上比较声明子集,未引用项忽略,缺项归一为空;不作值 trim/编码转换。A→B→A 归为 same;仅配置 revision 增长不能归 different。若无运行实例,not_running;已有执行但快照缺失/无法解密、状态未知或 route 变化不能安全比较,unknown 并给安全原因,不返回空集合冒充 same。读后再发生保存属于下一次查询,响应含所观察 revision/route epoch;前端丢弃旧请求并在保存/操作终态刷新。
+
+运行上下文已由 operation 持有;只读服务不能返回任意历史 operation 的明文环境。日志及比较需要的受限上下文按现有操作留存管理;清理后不额外复制秘密历史,比较可用性退化为 unknown。changed_names 仅包含调用者有资格读取的当前声明名;审批人看版本缺值,不读生产差异。
+
+**⑤ 名称、平台变量与输出保护**
+
+
+
+保留 `BISHENG_*`、`RTM_*`、`KUBERNETES_*`、`MINIO_*`、`OPENAI_*`、`HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY`(含小写),及 `PATH/HOME/LD_*/PYTHONPATH/PYTHONHOME/NODE_OPTIONS/PORT/HOST`、启动器实际保留项,统一 deny policy、两侧测试锁定。禁止别名覆盖平台身份、模型通道、启动/依赖路径或出口;后续新增平台注入名必须同改 policy。
+
+平台不打印写请求体/环境/引擎创建请求,错误只含 ref 与安全原因;runtime status/diagnostic 递归白名单序列化,禁 raw inspect/Secret 内容,日志下载不得拼接 env。客户端埋点不得读取密钥输入。
+
+**operation 绑定的敏感项合同**:backend 从已批准版本的 `capabilities.secrets` 生成 `sensitive_env_names`(只含名),与 revision/declaration_digest 一起冻结在可信 operation;值仍只在 sealed_env。普通客户端不能提交或修改该集合。manager 从这一 operation 的 runtime_env 取这些名的值构建内存 `SensitiveValues`,不从“当前保存值”代替历史执行值;除原有平台 token/代理凭据规则外,这些业务值不受名称启发式或最短长度限制,空值忽略,短值/普通名一样处理。跨重启从受限 operation 重新取值,不能把敏感值清单写本地状态、日志、材料、query cache 或普通 JSON 诊断。
+
+| 生产 / 消费路径 | 必须处理的点 |
+|---|---|
+| backend 正式环境装配 → manager | 只为批准 formal operation 产生敏感名与真实值;probe/preview 为名称+空值,build/preparer/helper 无真实值;旧 manager/legacy 路径不能消费此合同则在注入前报缺能力 |
+| `container_runner.redact` 与 Docker 正式日志 `api/platform_observe.logs` | 共同接收 SensitiveValues;按原执行绑定值处理,不再只猜 KEY/TOKEN,不允许因值短而跳过 |
+| K8s `executors/kubernetes._diagnostic/logs` 与 API 日志出口 | init/serve 日志、waiting/失败信息、正式日志统一使用同一脱敏器,不遗漏 K8s Secret 中注入的普通名称 |
+| legacy `api/readonly._redactions` / 日志输出 | 同步消费执行绑定的敏感集合;没有可信集合时不能把新密钥应用降级走旧启发式;无密钥旧应用仍保留原行为 |
+| manager `operations.report/_failure` → backend `RuntimeOperationService.fact` | manager 在 diagnostic_tail/错误输出发出前脱敏;backend 按同一 op 的 sealed_env + 敏感名再次处理,再截断/持久化;用户状态/下载只见已处理文本 |
+
+脱敏统一采用最长值优先的一次匹配,覆盖重叠值、Unicode 和整个多行值;**先脱敏,再按长度截断/分行/关键字筛选**。分块读取须保留最长敏感值长度所需的边界上下文,不能先对每块独立 replace;Engine/Pod API 已截断或缺失上下文、无法安全判断的片段整段省略并返回安全说明,不回退原文。敏感材料缺失/不可解密时,受影响日志/诊断不返回 raw tail;运维只得到引用名/原因和安全元数据。日志留存期间必须保留对应执行的受限脱敏上下文;清理后不再提供无法保护的原始历史输出。
+
+这保护平台日志/故障信息出口,不承诺阻止拿到值的应用主动编码、拆分外传或写入自己的业务数据;不增加独立“日志脱敏产品”或新的审计正文采集能力。
+
+**⑥ 前端/CLI/审读**
+
+应用页显示“应用密钥”,列名称、配置状态、每项更新时间与批准/待审批/未引用。保存只提示“已保存”;另一区域根据 comparison 显示“运行配置与最新保存配置不一致”、差异名或“无法确认”。无运行实例显示“下次启动使用最新配置”。提供“重建使配置生效”动作及独立确认,说明重建当前版本可能短暂中断,取消不发执行请求;保存按钮、删除确认和关闭表单都不能触发重建。操作进度消费既有 RuntimeOperation;失败保留配置和真实运行状态,需再次确认才试。删除说明当前实例可能继续持旧值;不显示新旧值。PUT 成功刷新安全元数据,不以旧 revision/HTTP 成功判生效。编辑空草稿、固定 mask 不作输入值,冲突保留未提交草稿;关闭/换应用/退出清理内存,过期异步响应丢弃。
+
+CLI 不接收值、不提供写密钥命令;manifest 带合法名称可部署,预检/状态提示缺值但不输出任何现存值。审读仅声明名/缺失/差异,由服务端按会话资格构建;不用 owner GET 列表给审批人凑数据。新增文案同时交付 zh-Hans/en/ja(含错误码),不新增中文硬编码。
+
+
+## 5. 已知坑 / 反直觉事实
+
+| 坑 | 后果 | 处理位置 |
+|---|---|---|
+| 保存值不会改变容器已有环境;docker restart 也不更新 Env | 保存后误报生效 | AppSecretSettings 状态 + F054 rebuild_current |
+| 原 deploy 同版本 serving 直接返回 | 用户确认却没有实际重建 | Coordinator 显式 config_restart 分支 |
+| 普通 deploy 重新算 schema/tier | 改变量触发 DDL或变档 | 空 migration_plan、原执行 tier/冻结 delivery,负向用例 |
+| 配置 revision 不同可能只改了未引用项,A→B→A 也会递增 | 误报重建、制造停机 | F055 compare + F054 running_environment_snapshot |
+| 原 stage/issue/subject 和审计会自行 commit | seal失败仍遗留候选凭据,外层回滚是假原子 | 凭据Owner显式session的stage_in_transaction及全部producer同UoW |
+| accepted 后没有 HTTP 等待者,普通deploy靠 wait→commit | 实例ready后一直等serving至超时 | auto_commit=true + fact 的持久ready消费/重报 |
+| claim/fact 是op→node,commit还要App | 错把node→已有op/先node后App引入互等 | §4.4③局部锁集合,无回锁旧op;两会话交错验证 |
+| 原 create 和 inject_environment 是两次调用 | 受理与冻结之间读到后来值 | 同一受理 UoW封装冻结,manager 不领取无环境操作 |
+| 原 recover 会自动创建另一 deploy | 手动失败后未经确认再重建 | recover/plan 消费 purpose/status,原操作只续办清理 |
+| 原 active deploy 查找不区分用途 | 发布串接手动重建、误改版本状态 | Coordinator active 查找和 commit 分支 |
+| 单表只记整组 updated_at 不能表达逐项最近修改 | 违背 AC-67 | 加密 entries 保存逐项元数据,不新增表 |
+| maintenance 由本操作自己推进 route_epoch | ready误拒或直接放宽epoch会接受别人的切流 | fact→projection 的受围栏幂等转换及commit精确验证 |
+| 当前 route 指向的环境才是运行事实 | 新值保存或旧结果回包误标最新已生效 | 公开只读快照 + 路由绑定核对,缺上下文 unknown |
+| 普通管理员 shortcut 不等于 natural owner | 管理员越权改第三方值 | F055 business owner + 平台会话 |
+| 全平台管理视图下,当前租户仍可能是超管归属租户 | 环境摘要沿用写事务的 strict 查询,合法管理员打开子租户应用也报 16254 | 摘要读先按视图定位应用,再用可恢复的应用租户作用域严格读取;写事务保持原 owner 校验 |
+| 日志先截断再脱敏会泄漏短值/跨行值 | 错误输出泄露配置 | §4.4⑤ 全出口操作绑定敏感集合 |
+| 删保存值不是第三方凭据撤销,也不是清空现有实例 | owner 误以为旧值已不可用 | 删除提示;显式重建,第三方撤销由 owner 执行 |
+
+历史 MVP 与三表方案只保留在历史评审/提交中;当前接手按本文。F054 B1、F056 S1 独立待办不由本设计覆盖。
+
+## 6. 对外契约与依赖
+
+### 6.1 我提供给别人的(Outgoing)
+
+| 契约 | 消费者 | 破坏风险 |
+|---|---|---|
+| 原 deploy/limits/status、发布/审读/预览 | CLI、平台、审批 | 合法旧包不能因新 secrets 规则失效;预检空值继续真实探活 |
+| `capabilities.secrets:[{name}]` + 缺值提示 | F053/F054/F059/F086 | 名称变更审批;值永不写版本/材料 |
+| F055 public formal_snapshot / preview_environment / delete_for_app | F054 组合根及生命周期 | 显式 session 与用途校验;秘密 DTO 不可进通用响应或日志 |
+| secrets GET/PUT/DELETE/rebuild + runtime comparison | AppSecretSettings/hook | 保存零编排副作用;取消零执行;非 owner 不取得私有值/管理权 |
+| app.secret.set/delete 与 app.secret.rebuild 元数据 | F056 操作审计读侧 | action 枚举/后端白名单/前端筛选/三语 lockstep;无值无末位 |
+
+### 6.2 我依赖别人的(Incoming)
+
+| 依赖 | 形式 | 风险 |
+|---|---|---|
+| 固定 runtime_payload_key、平台 DB/审计持久表 | 配置/Python | 丢 key 不能恢复;审计事务失败则保存回滚,不回退源码 key |
+| F054 rebuild_current、running_environment_snapshot;F086 RuntimeOperation/route | 内部应用协议和受限 DTO | 当前代码需要新增分支/只读导出;不能跨层查 ORM或把公共端点变 env 读取接口 |
+| 凭据Owner新增stage_in_transaction及session-aware下游Repository | 显式AsyncSession | 主体/凭据/委托/binding/审计任一仍自行commit就破坏受理原子性;不能跨session读未提交行 |
+| 既有 node-plan、stop proof、容量、ready提交、cleanup | 持久执行协议 | 漏用途判断会自动重试或执行DDL;未知 writer 不交接 |
+| K8s Secret/RBAC/admission、Docker Engine Env | 执行基础设施 | 旧 manager 缺能力拒绝重建;拥有编排 API 的管理员在信任边界 |
+| 平台会话/Permission application/tenant context | 可信身份协议 | SAK/PAT 的资源归属不等于自然人 owner |
+| UI 字体/色彩/多端/输入/确认规范、包裹 request/三语 | TS/组件 | Button/Input/Textarea/Confirm 用 done 组件;Modal/Select draft 不自造视觉模式,按已有发布页交互;窄屏遵规范 |
+
+§4.4 的公开协议由各 owner 提供,组合根装配;F054不导入 F055 ORM,F055 不查询 F054 ORM。部署 API 权限与平台数据配置保持进程边界,没有 constitution 例外。
+
+## 7. 测试与可观测
+
+- 单元定向:密文绑定/坏 key/缺上下文/保留名/Unicode与多行;owner允许和管理员/超管/审批人/SAK/PAT/跨租户拒绝;保存无 manager 调用、无 RuntimeOperation;CAS 旧编辑拒绝、事务/审计回滚、最后项删除不重置 revision。声明/预览空值、短值/普通名/重叠/多行/分块日志保护覆盖各输出。
+- 差异定向:当前 route 与 frozen env 精确绑定;只改未引用项、A→B→A、缺值与空值、无实例、坏密文、快照丢失、路由变化分别 same/different/not_running/unknown。不得依赖普通值摘要或新增 applied 表。
+- 部署定向:取消不建操作;确认过期拒绝;原同参重放在后续改值/换版本后仍返回同一 op、异参拒绝;同版本真的重建、tier/profile/digest 不变、pending 指针不变、无 DDL/restore/构建;本操作maintenance精确转换允许、重复fact不增epoch、其他操作的epoch改变拒绝。受理冻结/凭据签发后seal或flush/最终commit失败,全链候选资产均回滚;已有请求不重复签发。两真实SQL会话覆盖App/node/assignment/op/route交错;ready首次提交失败后同fact重报提交、backend在ready后退出原operation续办;丢唤醒原计划恢复,丢响应不重建第二次;在途继续保存,完成仍显示真实差异。用途不串接、失败不被 recover 自动再试、unknown writer 不覆盖;复用底座围栏用例,不全量跑。
+- 前端定向:空草稿/固定mask/失败保留输入;保存只提示已保存;差异提示含名不含值;独立确认及取消;过期响应丢弃、PUT冲突不覆盖、操作状态失败结束等待;采用现有组件和三语,无新视觉设计。
+- Docker/K8s 各一次最小 E2E:owner 在 `/build/apps/{id}` 保存哨兵,记录容器/Pod UID 不变;确认前访问仍读旧配置;确认后新UID、原代码/档位/PVC及SQLite数据不变、哨兵匹配。预检/审批预览为空;失败不自动再启,用户再次确认才试;测试应用只返回是否匹配,不回显密钥。
+- 编码后命令:`cd src/backend && uv run pytest test/app_publish/test_app_secret_service.py test/app_publish/test_app_secret_runtime.py`(拟新增);UI只跑 AppSecretSettings 对应测试,runtime只跑修改的用途/日志相关文件。必要 Ruff/arch-guard/单head 定向检查;DM8 仅模型/迁移静态。当前全部为计划,未执行。
+- 可观测只含 app/ref/revision/op/purpose/comparison/安全原因/耗时;不采写请求体、env、Secret inspect、普通值摘要。保存与重建分别计动作,GET比较不制造业务事件。
+
+## 8. 后续改进 / 不打算做的事
+
+不做自动配置生效、配置独立状态机、值历史库、外部 Vault、部署 key 轮换工具、热加载、SDK 明文读取或批量重启。只在真实新增产品需求出现时重新审查,不预建表/调度器。当前执行上下文缺失时比较可为 unknown;K8s Secret 不防有权集群管理员,应用自行编码外传不在平台日志脱敏保证内。静态前端不能注入浏览器/编译产物,声明使用却不能安全注入时明确拒绝,不暗中放宽。
+
+设计已确认,本轮按tasks.md增量任务实施;F054 B1、F056 S1 未关闭,ARM、完整离线与正式镜像发行保持原待办,不由本设计审查替代。
+
+## 修订历史
+
+| 日期 | 变化 | 依据 |
+|---|---|---|
+| 2026-10-30 | 修正全平台管理视图读取子租户环境摘要的租户绑定与错误码;摘要只读和 owner 写边界保持 | Chrome 现场 GET secrets 返回 16254;真实 ORM 自动过滤下复现与回归验证 |
+| 2026-10-23(手动生效简化) | 单表配置;保存零执行;按当前冻结环境比较;显式确认复用原部署;移除自动状态/独立请求/回执与写回协议 | 用户已确认修订规格;新增设计尚未确认/编码 |
+| 2026-10-23(二次修订,已由简化方案替代) | 明确固定部署 key 与运维边界、执行敏感项全链路、同一 UoW 生效与响应丢失/部分结果恢复 | 用户要求修订并复审;未新增轮换功能,尚未编码 |
+| 2026-10-23 | 刷新现有能力/存储/运行协议;新增 D17–D20、四组密钥合同、同版本配置刷新与定向验收设计 | 已确认增量规格与实际代码,未编码 |
+| 2026-08-17 起 | 早期发布管线/审批/档位/能力总线设计 | 旧正文见 `dda16a6`;本次不倒改历史验收事实 |
+
+### 2026-10-24 实现补记
+
+- 16280–16283 分别对应输入校验、revision 冲突、加密不可用、重建围栏拒绝;三语文案已生成。
+- `runtime_secret_repository` 与 `application/runtime_secrets` 提供实际运行快照及安全的重建状态;不导出历史明文。
+- 候选签发由 `CredentialService.issue_in_transaction` 和 `stage_in_transaction` 使用显式 session;`stage_binding` 仅 flush。
+- `RuntimeCleanupService` 对删除应用的配置资产沿用 storage_pending;业务 deleted 提交前不标清理完成,删除钩子在提交后尝试清理。
+- request_id 经 SHA-256 形成有界幂等 key;不包含环境变量值或其摘要。
+- 当前只有定向本地/模拟结果;真实 Docker/K8s 新功能 E2E 尚未执行。按用户最新安排,完整回归待全部功能完成后进行。
+
+### 2026-10-24 空值与失败提示补记
+
+空字符串表示缺值,但仍有保存条目;owner 以逐项 updated_at 识别可删除资产,声明存在但从未配置不显示删除入口。失败重建进入 maintenance 后直接重复重建的体验暂缓,当前提示使用既有下线/重新上线入口;该恢复组合待集中 E2E,不计入本地已验证能力。
+
+### 2026-10-24 声明呈现接线补记
+
+- 审批未完成时 app.pending_version_id 尚未设置;配置区通过同租户的活动 AppDeployment 找到已记录待审批版本,避免把“待审批”和“批准但待上线”混为一谈。
+- `reference_status` 仅返回已声明名及 ok/missing_value;`publish_approval_service` 将结果冻结到既有 capabilities 数组,客户端以提交时状态呈现。发布状态实时消费同一只读协议。
+- v2 发布轮询新增 secret_references 元数据,CLI 对缺值名称每次命令仅提醒一次,不因缺值直接拒绝部署,不授予密钥管理接口。CLI wheel 与后端分发清单随源码重建,SDK 分发信息保留。
+- 客户端审批卡新增用例受 canvas.node 缺失阻断;静态 lint 通过。该环境问题列入提测限制,不为此改动依赖树或扩大测试。
+
+### 2026-10-29 发布页只读资格与配置比较修复
+
+用户确认的手动重建边界保持不变。构建管理列表已有同租户 edit 授权的用户可读取发布状态;环境变量区对这类管理用户只提供版本引用名称及状态,不提供未引用私有项、owner mask、修改信息或写权限。管理页读取修复不扩大 CLI/MCP、保存、删除和重建资格。
+
+正式部署操作已 completed 但路由仍 serving 时,F054 的 running_context 继续返回当前环境关联;不以“未知”误认配置丢失或阻止发布。未知仍用于实际关联不完整、解密失败等情况,不伪造一致。无作用的提交发布按钮已由先前修复删除,新版本仍只通过 CLI 提交发布。
diff --git a/features/v3.0.0/055-app-publish-pipeline/e2e-checklist.md b/features/v3.0.0/055-app-publish-pipeline/e2e-checklist.md
new file mode 100644
index 0000000000..110e721adf
--- /dev/null
+++ b/features/v3.0.0/055-app-publish-pipeline/e2e-checklist.md
@@ -0,0 +1,30 @@
+# F055 环境变量手动生效提测清单
+
+2026-10-24。范围为本次功能;完整 Vibe 回归安排在其余功能开发完成后。
+
+## 自动化素材
+
+- `src/backend/test/e2e/test_app_secret_manual_apply.py`:真实公开 API,需显式 F055_E2E=1;只操作专用 e2e-f055-* 应用里的 F055_TEST_COLOR,不删除应用或持久数据。
+- `src/backend/test/e2e/fixtures/app_secret_probe.py`:预先发布审批到正式实例,声明 F055_TEST_COLOR;仅输出合成哨兵的布尔比较,不返回环境变量原值。以 Docker、K8s 分别执行。
+- 使用专用租户、owner/nonowner/foreign 三个账号,通过环境变量安全提供会话凭据;禁止使用生产应用。
+- 已执行 collection,2 个用例可收集;**真实环境未执行**。本脚本以已审批测试应用为前置,不冒充覆盖首次发布/审批全链路。
+
+## 页面人工检查
+
+- [ ] AC-67、AC-68:打开应用发布设置的环境变量区域;新增/替换后只看到固定掩码,不显示已存值。多行输入原样保存;取消关闭后草稿消失。
+- [ ] AC-73、AC-74、AC-90:保存前后 instance/operation 不变,显示当前值与最新保存值不一致;名称精确、A→B→A 应恢复一致。
+- [ ] AC-76、AC-91:点击重建弹出独立确认,取消零调用,确认才产生同版本部署操作;进行中可继续保存,后保存值不改变已经确认的操作。
+- [ ] AC-70、AC-77:重建保留代码、镜像、资源档位、待审批版本、SQLite 与附件;仅成功探活后切路由。
+- [ ] AC-71、AC-84:预检和审批预览中的已声明变量存在但为空,绝不加载正式配置值;缺值提示可见。
+- [ ] AC-66、AC-85、AC-89:owner 可写;管理员只读有资格查看的声明信息;非 owner 和跨租户不能写,SAK/PAT 不能管理。
+- [ ] AC-68、AC-82:合成哨兵被应用打印到日志后,Docker/K8s 查询、失败诊断均无原值;短值/多行亦验证。不要使用真实客户密钥做此测试。
+- [ ] AC-83:停用/恢复保留配置;真正删除后配置清理,历史无值审计保留。
+
+## 延后验证与边界
+
+- 两个真实 SQL session 与 claim/fact/commit 的锁交错、多种崩溃时点、迁移恢复组合,纳入后续集中故障验收。
+- manager 在途重启/响应丢失具备既有持久操作协议,本轮不重复跑 F059 全量故障套件。
+- ARM 暂不测;DM8 仅静态模型与类型约束检查;单节点结果不代表跨节点恢复。
+- F054/F056 剩余设计确认前不宣称整个 Vibe 已开发完毕或可正式合入。
+
+- 配置重建在 maintenance 后启动失败时,直接再重建的边缘体验暂缓;提测验证既有“下线 → 重新上线”恢复入口。页面已给出该提示,不将失败操作自动复活。
diff --git a/features/v3.0.0/055-app-publish-pipeline/manual-apply-ac-mapping.json b/features/v3.0.0/055-app-publish-pipeline/manual-apply-ac-mapping.json
new file mode 100644
index 0000000000..248b3380c2
--- /dev/null
+++ b/features/v3.0.0/055-app-publish-pipeline/manual-apply-ac-mapping.json
@@ -0,0 +1,346 @@
+{
+  "date": "2026-10-24",
+  "head_at_check": "09b17ebd71a8c7e4263f7097549204fdd4a7ebda",
+  "working_tree_delta_included": true,
+  "source_sha256": {
+    "src/backend/bisheng/app_publish/domain/services/app_secret_service.py": "6b4ad0cbafa4be3f54cde703450bbb99ee5f10d361ceba35daeda4711e94e086",
+    "src/backend/bisheng/app_publish/domain/services/publish_approval_service.py": "d2403107e77ec423882a5ee4d115e194af11ee168514f8333caa0618da0ccf93",
+    "src/backend/bisheng/app_publish/domain/services/publish_status_service.py": "ff228ee71b8176acdf1fb90dc48b6b6e336dc02509fc1522cd460c2fbfb7e6e3",
+    "src/backend/bisheng/app_publish/domain/services/publish_pipeline_service.py": "ed3b4ad62b76af638f7488e886c7ef44e7267204426e1c29278742e7c722398c",
+    "src/backend/bisheng/app_runtime/domain/services/secret_rebuild_service.py": "9a8ce16b9ec5f33692fec3524e56b2783f80792cb91e5c5c8d1c23f5389f7592",
+    "src/frontend/platform/src/pages/BuildPage/hostedApp/publish/AppSecretSettings.tsx": "af5013aa5b714dbb28996eccb8c585244132de5d1359c1a7895fe4d272af06f1",
+    "src/frontend/client/src/components/approval/AppPublishDetailPanel.tsx": "d245e7f70e54c07d59fe8a6fbf3b9b16ea4426a2ce1f6ed645996b5ef8f7ac20",
+    "src/bisheng-cli/bisheng_cli/commands/deploy.py": "1e6c759b98fcbf83cbfb7804d44e47fc8e0424f7234b716dd4ab33d9c2a4c537"
+  },
+  "acs": [
+    {
+      "ac": "AC-66",
+      "local_evidence_files": [
+        "test_app_secret_service.py",
+        "test_app_secret_api.py"
+      ],
+      "implementation": "main_path_coded",
+      "acceptance": "not_deployment_accepted",
+      "remaining": [
+        "real Docker/K8s acceptance pending"
+      ]
+    },
+    {
+      "ac": "AC-67",
+      "local_evidence_files": [
+        "test_app_secret_service.py",
+        "AppSecretSettings.test.tsx"
+      ],
+      "implementation": "main_path_coded",
+      "acceptance": "not_deployment_accepted",
+      "remaining": [
+        "real Docker/K8s acceptance pending"
+      ]
+    },
+    {
+      "ac": "AC-68",
+      "local_evidence_files": [
+        "test_app_secret_security.py",
+        "test_secret_diagnostics.py",
+        "test_secret_redaction.py"
+      ],
+      "implementation": "main_path_coded",
+      "acceptance": "not_deployment_accepted",
+      "remaining": [
+        "real Docker/K8s acceptance pending"
+      ]
+    },
+    {
+      "ac": "AC-69",
+      "local_evidence_files": [
+        "test_app_secret_declaration.py"
+      ],
+      "implementation": "main_path_coded",
+      "acceptance": "not_deployment_accepted",
+      "remaining": [
+        "real Docker/K8s acceptance pending",
+        "client approval rendering test blocked by missing canvas.node"
+      ]
+    },
+    {
+      "ac": "AC-70",
+      "local_evidence_files": [
+        "test_app_secret_repository.py",
+        "test_secret_rebuild.py"
+      ],
+      "implementation": "main_path_coded",
+      "acceptance": "not_deployment_accepted",
+      "remaining": [
+        "real Docker/K8s acceptance pending"
+      ]
+    },
+    {
+      "ac": "AC-71",
+      "local_evidence_files": [
+        "test_secret_rebuild.py"
+      ],
+      "implementation": "main_path_coded",
+      "acceptance": "not_deployment_accepted",
+      "remaining": [
+        "real Docker/K8s acceptance pending"
+      ]
+    },
+    {
+      "ac": "AC-72",
+      "local_evidence_files": [
+        "test_app_secret_api.py",
+        "test_secret_warnings.py",
+        "CapabilityListCard.test.tsx"
+      ],
+      "implementation": "main_path_coded",
+      "acceptance": "not_deployment_accepted",
+      "remaining": [
+        "real Docker/K8s acceptance pending",
+        "client approval rendering test blocked by missing canvas.node"
+      ]
+    },
+    {
+      "ac": "AC-73",
+      "local_evidence_files": [
+        "test_app_secret_service.py"
+      ],
+      "implementation": "main_path_coded",
+      "acceptance": "not_deployment_accepted",
+      "remaining": [
+        "real Docker/K8s acceptance pending"
+      ]
+    },
+    {
+      "ac": "AC-74",
+      "local_evidence_files": [
+        "useAppSecrets.test.tsx",
+        "AppSecretSettings.test.tsx"
+      ],
+      "implementation": "main_path_coded",
+      "acceptance": "not_deployment_accepted",
+      "remaining": [
+        "real Docker/K8s acceptance pending"
+      ]
+    },
+    {
+      "ac": "AC-75",
+      "local_evidence_files": [
+        "test_app_secret_repository.py",
+        "test_secret_rebuild.py"
+      ],
+      "implementation": "main_path_coded",
+      "acceptance": "not_deployment_accepted",
+      "remaining": [
+        "real Docker/K8s acceptance pending"
+      ]
+    },
+    {
+      "ac": "AC-76",
+      "local_evidence_files": [
+        "test_secret_rebuild.py"
+      ],
+      "implementation": "main_path_coded",
+      "acceptance": "not_deployment_accepted",
+      "remaining": [
+        "real Docker/K8s acceptance pending"
+      ]
+    },
+    {
+      "ac": "AC-77",
+      "local_evidence_files": [
+        "test_app_secret_credentials.py",
+        "test_secret_rebuild.py"
+      ],
+      "implementation": "main_path_coded",
+      "acceptance": "not_deployment_accepted",
+      "remaining": [
+        "real Docker/K8s acceptance pending"
+      ]
+    },
+    {
+      "ac": "AC-78",
+      "local_evidence_files": [
+        "test_app_secret_security.py",
+        "test_app_secret_declaration.py"
+      ],
+      "implementation": "main_path_coded",
+      "acceptance": "not_deployment_accepted",
+      "remaining": [
+        "real Docker/K8s acceptance pending"
+      ]
+    },
+    {
+      "ac": "AC-79",
+      "local_evidence_files": [
+        "test_app_secret_security.py",
+        "test_app_secret_service.py"
+      ],
+      "implementation": "main_path_coded",
+      "acceptance": "not_deployment_accepted",
+      "remaining": [
+        "real Docker/K8s acceptance pending"
+      ]
+    },
+    {
+      "ac": "AC-80",
+      "local_evidence_files": [
+        "test_app_secret_security.py",
+        "test_app_secret_service.py"
+      ],
+      "implementation": "main_path_coded",
+      "acceptance": "not_deployment_accepted",
+      "remaining": [
+        "real Docker/K8s acceptance pending"
+      ]
+    },
+    {
+      "ac": "AC-81",
+      "local_evidence_files": [
+        "test_app_secret_repository.py",
+        "test_app_secret_credentials.py",
+        "test/open_api/test_runtime_credential_audit.py"
+      ],
+      "implementation": "implemented_local_cross_feature",
+      "acceptance": "not_deployment_accepted",
+      "remaining": [
+        "real Docker/K8s acceptance pending"
+      ],
+      "cross_feature_note": "F056 trusted app issue/revoke association and binding transition audit implemented on 2026-10-24; local targeted verification only"
+    },
+    {
+      "ac": "AC-82",
+      "local_evidence_files": [
+        "test_secret_diagnostics.py",
+        "test_secret_redaction.py"
+      ],
+      "implementation": "main_path_coded",
+      "acceptance": "not_deployment_accepted",
+      "remaining": [
+        "real Docker/K8s acceptance pending",
+        "does not prove prevention of an application intentionally writing its own environment to its data or attachments"
+      ]
+    },
+    {
+      "ac": "AC-83",
+      "local_evidence_files": [
+        "test_app_secret_service.py"
+      ],
+      "implementation": "main_path_coded",
+      "acceptance": "not_deployment_accepted",
+      "remaining": [
+        "real Docker/K8s acceptance pending"
+      ]
+    },
+    {
+      "ac": "AC-84",
+      "local_evidence_files": [
+        "test_app_secret_declaration.py"
+      ],
+      "implementation": "main_path_coded",
+      "acceptance": "not_deployment_accepted",
+      "remaining": [
+        "real Docker/K8s acceptance pending",
+        "client approval rendering test blocked by missing canvas.node"
+      ]
+    },
+    {
+      "ac": "AC-85",
+      "local_evidence_files": [
+        "test_app_secret_service.py"
+      ],
+      "implementation": "main_path_coded",
+      "acceptance": "not_deployment_accepted",
+      "remaining": [
+        "real Docker/K8s acceptance pending"
+      ]
+    },
+    {
+      "ac": "AC-86",
+      "local_evidence_files": [
+        "test_secret_rebuild.py",
+        "test_secret_redaction.py"
+      ],
+      "implementation": "main_path_coded",
+      "acceptance": "not_deployment_accepted",
+      "remaining": [
+        "real Docker/K8s acceptance pending"
+      ]
+    },
+    {
+      "ac": "AC-87",
+      "local_evidence_files": [
+        "test_app_secret_declaration.py"
+      ],
+      "implementation": "main_path_coded",
+      "acceptance": "not_deployment_accepted",
+      "remaining": [
+        "real Docker/K8s acceptance pending"
+      ]
+    },
+    {
+      "ac": "AC-88",
+      "local_evidence_files": [
+        "test_app_secret_declaration.py"
+      ],
+      "implementation": "main_path_coded",
+      "acceptance": "not_deployment_accepted",
+      "remaining": [
+        "real Docker/K8s acceptance pending"
+      ]
+    },
+    {
+      "ac": "AC-89",
+      "local_evidence_files": [
+        "useAppSecrets.test.tsx",
+        "AppSecretSettings.test.tsx"
+      ],
+      "implementation": "main_path_coded",
+      "acceptance": "not_deployment_accepted",
+      "remaining": [
+        "real Docker/K8s acceptance pending"
+      ]
+    },
+    {
+      "ac": "AC-90",
+      "local_evidence_files": [
+        "test_app_secret_security.py",
+        "test_secret_runtime_snapshot.py"
+      ],
+      "implementation": "main_path_coded",
+      "acceptance": "not_deployment_accepted",
+      "remaining": [
+        "real Docker/K8s acceptance pending"
+      ]
+    },
+    {
+      "ac": "AC-91",
+      "local_evidence_files": [
+        "test_secret_rebuild.py",
+        "AppSecretSettings.test.tsx"
+      ],
+      "implementation": "main_path_coded",
+      "acceptance": "not_deployment_accepted",
+      "remaining": [
+        "real Docker/K8s acceptance pending"
+      ]
+    },
+    {
+      "ac": "AC-92",
+      "local_evidence_files": [
+        "test_secret_rebuild.py",
+        "useAppSecrets.test.tsx"
+      ],
+      "implementation": "main_path_coded",
+      "acceptance": "not_deployment_accepted",
+      "remaining": [
+        "real Docker/K8s acceptance pending"
+      ]
+    }
+  ],
+  "full_acceptance_passed": false,
+  "cross_feature_update": {
+    "date": "2026-10-24",
+    "scope": "AC-81 only; original F055 source hashes retained as historical evidence",
+    "evidence": "../056-app-square-governance/closeout-validation.json"
+  }
+}
diff --git a/features/v3.0.0/055-app-publish-pipeline/manual-apply-code-review.md b/features/v3.0.0/055-app-publish-pipeline/manual-apply-code-review.md
new file mode 100644
index 0000000000..944be11fef
--- /dev/null
+++ b/features/v3.0.0/055-app-publish-pipeline/manual-apply-code-review.md
@@ -0,0 +1,15 @@
+# F055 阶段性代码自查(不是最终 SDD 合入审查)
+
+日期:2026-10-24。基线 a5c7722b9 及当前声明消费方/空值删除增量。T106 真实部署证据未完成,因此 T107 保留未完成;本报告不输出最终 L2 PASS 或允许合入。
+
+| 维度 | 当前证据 | 尚待确认 |
+|---|---|---|
+| 边界条件 | 输入有界、空值可删、坏密文拒绝、mask 不回填 | 实际引擎启动与外部库兼容 |
+| 权限认证 | 会话 owner 写、管理员声明只读、v2 只有发布摘要,不提供密钥写入口 | 真实非管理员/跨租户端到端 |
+| 并发事务 | 配置+业务审计同事务,重建冻结/容量/候选同 UoW,幂等回放及 ready 提交本地用例 | 双 SQL session 交错与真实进程重启 |
+| 信息泄漏 | 声明/摘要/CLI 仅名与状态;输出按冻结值脱敏;错误 input/ctx 去除 | 真实 Pod/容器/日志/持久产物;不承诺阻止应用自行写入其环境 |
+| 测试覆盖 | 后端 59、manager 3、平台 6、CLI 1 独立定向通过;逐 AC 文件映射见 JSON | 客户端 canvas 环境阻断,真实 E2E 未执行 |
+| 风格分层 | 变更 Ruff、架构守卫、前后端所改文件 ESLint;CLI wheel 已重建 | Platform 两个既有类型问题未扩大修复 |
+| 文档同步 | design/tasks/approval-module/提测清单同步实际调用者、格式与限制 | F054/F056 设计仍待单独确认 |
+
+阶段结果:主路径可提交提测,**整个 Vibe 未完成、最终合入审查未通过**。F056 凭据关联与撤销审计不在 F055 中伪造完成。失败重建后的维护态直接重试、任意故障时点组合等按用户优先级留给后续集中验收。
diff --git a/features/v3.0.0/055-app-publish-pipeline/manual-apply-task-reviews.md b/features/v3.0.0/055-app-publish-pipeline/manual-apply-task-reviews.md
new file mode 100644
index 0000000000..ef977c970e
--- /dev/null
+++ b/features/v3.0.0/055-app-publish-pipeline/manual-apply-task-reviews.md
@@ -0,0 +1,37 @@
+# F055 manual-apply L1 task reviews
+
+2026-10-24. Focused checks only. Spec/design user approvals retained.
+
+| Task | Scope | Layer / naming / serialization / DB / frontend / leakage / design | Metadata | Result / evidence |
+|---|---|---|---|---|
+| T071 | AppSecretConfig + discovery | PASS / PASS / PASS / PASS / N/A / PASS / PASS | file scope PASS, dependencies N/A | PASS; App primary-key type, default None NOT NULL indexed tenant, LargeText, timestamps, nonnegative revision. Discovered through existing module import; no separate revision for standalone table. |
+| T072 | secret errors 16280-16283 | PASS / PASS / N/A / N/A / N/A / PASS / PASS | scope PASS, dependencies N/A | PASS; allocated after checking 162 error family; no values in error text. |
+| T102 | source zh/en error copy | N/A / PASS / N/A / N/A / PASS / PASS / PASS | scope PASS, T072 complete | PASS; four new keys. |
+| T103 | source ja + generated artifacts | N/A / PASS / N/A / N/A / PASS / PASS / PASS | scope PASS, T102 complete | PASS; generated by locales build, no hand edits to artifacts. |
+| T073 | security tests | N/A / PASS / N/A / N/A / N/A / PASS / N/A | scope PASS, T071/T072 complete | PASS; initial 27 expected failures for missing implementation; then 27 passed. |
+| T074 | security + input schemas | PASS / PASS / PASS / N/A / N/A / PASS / PASS | scope PASS, T073 complete | PASS; 27 focused cases, invalid key/ciphertext and wrong app/tenant fail closed, byte limits and exact comparison. Endpoint raw validation sanitization remains T094, not claimed by schema alone. |
+| T075–T077 | 配置 Repository / Service / public protocol | PASS / PASS / PASS / PASS / PASS / PASS / PASS (inapplicable checks N/A) | file scope PASS; paired tests/dependencies implemented and recorded | PASS_WITH_NOTES; 6 SQL transaction/permission tests; tested audit failure rollback, revision conflict, no automatic runtime call. Current runtime adapter verified in T084/T085. |
+| T078–T080 | Manifest and version diff | PASS / PASS / PASS / PASS / PASS / PASS / PASS (inapplicable checks N/A) | file scope PASS; paired tests/dependencies implemented and recorded | PASS_WITH_NOTES; 10 declaration tests passed; compatible diff response assertion updated and its focused test passed. |
+| T081–T083 | Candidate credential transaction | PASS / PASS / PASS / PASS / PASS / PASS / PASS (inapplicable checks N/A) | file scope PASS; paired tests/dependencies implemented and recorded | PASS_WITH_NOTES; 2 focused tests passed, issue audit and all candidate rows use caller session; no independent response lookup. |
+| T084–T088 | Current execution snapshot and confirmed rebuild | PASS / PASS / PASS / PASS / PASS / PASS / PASS (inapplicable checks N/A) | file scope PASS; paired tests/dependencies implemented and recorded | PASS_WITH_NOTES; 5 focused runtime tests passed, including real ready fact and idempotent route switch; not a live engine/SQL concurrency test. |
+| T089–T092 | Operation-bound diagnostics | PASS / PASS / PASS / PASS / PASS / PASS / PASS (inapplicable checks N/A) | file scope PASS; paired tests/dependencies implemented and recorded | PASS_WITH_NOTES; 3 manager plus 1 backend diagnostic tests passed. Python/Node actual output deployment not yet executed. |
+| T093–T095 | Session API / integration | PASS / PASS / PASS / PASS / PASS / PASS / PASS (inapplicable checks N/A) | file scope PASS; paired tests/dependencies implemented and recorded | PASS_WITH_NOTES; 3 API tests passed; Pydantic input/ctx/body stripped. Cleanup adapter reviewed statically; deployed cleanup E2E remains T106. |
+| T096–T101 | Platform hook/editor/settings and three locales | PASS / PASS / PASS / PASS / PASS / PASS / PASS (inapplicable checks N/A) | file scope PASS; paired tests/dependencies implemented and recorded | PASS_WITH_NOTES; 4 frontend tests passed; focused ESLint passed. Type check has 2 pre-existing unrelated errors, no feature path reported. |
+| T104/T108 | Audit action registration | PASS / PASS / PASS / PASS / PASS / PASS / PASS (inapplicable checks N/A) | file scope PASS; paired tests/dependencies implemented and recorded | PASS_WITH_NOTES; Set/delete/rebuild registered in backend/frontend and three language keys. Full i18n gate deferred until all features complete; new key parity inspected. |
+
+User requested functional closeout first. Rare concurrency/fault combinations and real deployment scenarios remain in the test handoff; these are not claimed as tested. F054 host isolation recovery and F056 revocation audit design findings remain open.
+| T105 | live test script + synthetic probe | PASS / PASS / N/A / N/A / N/A / PASS / PASS | scope PASS; helpers reused; dependencies implemented | PASS_WITH_NOTES; two cases collected only. Existing approved dedicated app is a prerequisite; app publishing, live execution, resources/SQLite verification remain in the manual handoff. |
+
+## T099 定向复审:空值条目的删除
+
+类型:前端实现。范围:AppSecretSettings、对应组件用例、配置 Service 定向用例。架构/命名/序列化/前端/i18n/泄漏/设计同步 PASS;未新增接口、权限或视觉模式。先观察组件用例失败,再修改判定为“有值或有保存时间”,新增前后端用例均通过。失效重建的恢复文案按实际操作入口修正,不宣称直接重试维护态已支持。
+
+## T109–T119 接线补充 L1
+
+- 后端 2 例(待审批版本/审批摘要)、平台摘要 1 例、CLI 提醒去重 1 例通过;未跑全量。
+- 客户端审批组件用例已编写,执行阻断于 jsdom 的 canvas.node 原生模块;组件与用例静态 lint 通过。
+- 新公共协议只返回 name/kind/reason/revoked;权限不由前端摘要授予,值/掩码不进审批。
+- 依赖与文件范围已在新增任务中记录;CLI wheel 属于派生物,sdk manifest 原段保留。
+- L1 结果 PASS_WITH_NOTES,注释为环境/真实验收限制,不将未执行测试登记为通过;设计与 approval-module 参考已同步。
+
+T120 追加验证:测试命名、AC 标注、文件范围与依赖 PASS;真实 ASGI v2 管线消费 F055 安全摘要,仅替换配置 Repository 的数据库工厂,未替换查询端点业务;1 例通过。
diff --git a/features/v3.0.0/055-app-publish-pipeline/manual-apply-tasks-review.json b/features/v3.0.0/055-app-publish-pipeline/manual-apply-tasks-review.json
new file mode 100644
index 0000000000..54b27aefc7
--- /dev/null
+++ b/features/v3.0.0/055-app-publish-pipeline/manual-apply-tasks-review.json
@@ -0,0 +1,11 @@
+{
+  "design_confirmation": "确认 F055 简化设计",
+  "approved_design_sha256": "2ef7247c7fcc71d1e2e44b34a9df8dfe865135adc66c249166a29202e29402d6",
+  "tasks_result": "LGTM",
+  "count": 38,
+  "range": [
+    "T071",
+    "T108"
+  ],
+  "code_started": false
+}
diff --git a/features/v3.0.0/055-app-publish-pipeline/manual-apply-tasks-review.md b/features/v3.0.0/055-app-publish-pipeline/manual-apply-tasks-review.md
new file mode 100644
index 0000000000..2f2dcf0066
--- /dev/null
+++ b/features/v3.0.0/055-app-publish-pipeline/manual-apply-tasks-review.md
@@ -0,0 +1,7 @@
+# F055 手动生效任务审查
+
+LGTM
+
+用户已确认92条规格与单表设计。增量任务38项;每项至多两个目标文件,后台测试先于配对实现,基础设施模型/错误优先;无新Worker或独立执行状态机。测试覆盖 AC-54、AC-56 和 AC-66 至 AC-92,保留旧任务的历史范围,不全量重跑。共享文件变更仅F055事件、凭据事务入口及既有运行流程衔接,不关闭F054-B1/F056-S1。
+
+检查:21项任务清单通过;逐条AC以tasks.md完整编号为准。基于真实新独立表发现机制无需额外DDL;回退保留密文不做破坏性drop。三语文案及生成物按原工程工具交付。未把尚未执行的测试/E2E标通过。
diff --git a/features/v3.0.0/055-app-publish-pipeline/manual-apply-validation.json b/features/v3.0.0/055-app-publish-pipeline/manual-apply-validation.json
new file mode 100644
index 0000000000..769f05d254
--- /dev/null
+++ b/features/v3.0.0/055-app-publish-pipeline/manual-apply-validation.json
@@ -0,0 +1,88 @@
+{
+  "date": "2026-10-24",
+  "status": "implementation_ready_for_targeted_handoff",
+  "full_vibe_complete": false,
+  "spec_confirmed": true,
+  "design_confirmed": true,
+  "focused_tests": {
+    "backend_new": {
+      "passed": 54,
+      "command_scope": "9 new F055 backend test files"
+    },
+    "manager_redaction": {
+      "passed": 3
+    },
+    "platform_hook_and_view": {
+      "passed": 4
+    },
+    "additional_preview_adapter": {
+      "passed": 1
+    },
+    "confirmation_replay_after_lock_recheck": {
+      "passed": 1,
+      "note": "re-run of existing case, not an additional unique test"
+    },
+    "saved_empty_value": {
+      "backend_passed": 1,
+      "frontend_passed": 1,
+      "scope": "new cases only; not a full rerun"
+    },
+    "declaration_consumers": {
+      "backend_passed": 3,
+      "platform_passed": 1,
+      "cli_passed": 1,
+      "client_component": {
+        "status": "environment_blocked",
+        "reason": "canvas.node native module missing before test body"
+      }
+    },
+    "unique_passed_totals": {
+      "backend": 59,
+      "manager": 3,
+      "platform": 6,
+      "cli": 1
+    }
+  },
+  "static_checks": {
+    "ruff": "passed",
+    "arch_guard": "no violation; fixture secret-pattern warning only",
+    "focused_eslint": "passed",
+    "platform_typecheck": {
+      "status": "2 unrelated baseline errors",
+      "files": [
+        "src/pages/BuildPage/bench/OrgKbConfig.tsx",
+        "src/test/clientVoiceDraft.test.tsx"
+      ]
+    },
+    "client_focused_eslint": "passed"
+  },
+  "live_e2e": {
+    "executed": false,
+    "collected": 2,
+    "prerequisite": "dedicated approved e2e-f055 app in each deployment shape"
+  },
+  "deferred": [
+    "full Vibe regression after all development",
+    "real SQL lock interleavings",
+    "rare crash combinations",
+    "ARM runtime",
+    "live DM8",
+    "retry directly from maintenance after a failed config rebuild; use existing stop/resume lifecycle and validate in centralized E2E"
+  ],
+  "not_claimed": [
+    "Docker/K8s deployed validation",
+    "all AC acceptance passed",
+    "F054/F056 implementation complete",
+    "merge/push/tag/release",
+    "client approval rendering test passed"
+  ],
+  "source_commit": "a5c7722b9",
+  "wheel": {
+    "path": "src/backend/bisheng/dev_toolkit/artifacts/bisheng_cli-3.0.0-py3-none-any.whl",
+    "sha256": "05919950d13091c609969201487c3b75d3de66aae2672f74af4e9b92c153e700",
+    "source_bytes_match": true,
+    "sdk_manifest_preserved": true,
+    "clean_venv_smoke": "passed",
+    "offline": true
+  }
+}
diff --git a/features/v3.0.0/055-app-publish-pipeline/manual-apply-validation.md b/features/v3.0.0/055-app-publish-pipeline/manual-apply-validation.md
new file mode 100644
index 0000000000..f2088bf946
--- /dev/null
+++ b/features/v3.0.0/055-app-publish-pipeline/manual-apply-validation.md
@@ -0,0 +1,35 @@
+# F055 定向验证记录(2026-10-24)
+
+## 结论
+
+主功能已编码并具备定向提测素材。新增后端 55(54 个主集用例 + 1 个审批预览适配器用例)、manager 3、前端 4 个用例通过。测试均为本功能范围,无项目全量回归。真实 Docker/K8s 功能部署未执行。
+
+## 静态检查
+
+- 变更 Python 文件 Ruff 通过;架构守卫无 VIOLATION,仅既有 diff 测试素材的敏感词 WARNING。
+- 本次前端文件 ESLint 通过;三语环境变量文案和审计事件已同步。
+- Platform 类型检查有两处不在本次功能中的基线问题:OrgKbConfig 缺 @hello-pangea/dnd 声明,clientVoiceDraft.test.tsx 参数数目。没有 F055 新文件报错;没有借机改动无关代码或豁免。
+
+## 尚未验证
+
+真实发布/审批、引擎重建、Pod Secret/卷与日志、持久清理,及更细的 SQL 并发和任意崩溃组合,交由集中提测。详见 e2e-checklist.md;不将可收集 E2E 脚本写成已执行。
+
+## 收尾安排
+
+遵从用户要求先完成全部主功能,完整回归在其余功能完成后运行。F054/F056 修订设计已于 2026-10-24 确认并完成本轮主功能编码;此报告不代表整个 Vibe 已交付或允许跳过 SDD 合入门禁。
+
+## 追加定向修复
+
+保存空值与不存在配置不是同一件事:空值仍占配置项,owner 应可删除。页面现以逐项 updated_at 区分二者,空值不显示掩码。新增后端 1 例、前端 1 例针对该链路通过;不重复其他用例。
+
+静态核对的已知限制:重建进入 maintenance 后启动失败时,页面不直接重试同一维护态,需使用既有下线/重新上线入口恢复;对应恢复提示三语已修正。该组合纳入集中 E2E,不以本轮单测声称已部署验证。
+
+## 声明消费方补齐
+
+已核对真实链路并接入:审批期版本引用 → 配置区;秘密引用元数据 → 审批快照/发布状态;发布轮询 → CLI 缺值提醒。追加后端 3 例、平台摘要 1 例、CLI 1 例通过;连同空值条目修复,独立通过用例累计为后端 59、manager 3、平台 6、CLI 1。不是重新执行全量。
+
+客户端审批卡新增一条渲染用例,但 Jest 在进入测试前因 canvas.node 原生模块缺失失败;对应变更文件 ESLint 通过。此用例不计通过,也未为它安装/升级依赖或扩大测试。
+
+已通过官方 pack_cli_wheel.sh 在离线缓存环境重建 CLI wheel,脚本的干净 venv import/console/deploy-help 烟测通过。wheel 中 deploy.py 与当前源码字节一致,manifest 的 SDK 段与提交基线一致。wheel SHA-256:05919950d13091c609969201487c3b75d3de66aae2672f74af4e9b92c153e700。
+
+2026-10-24:F056 凭据签发/实际 binding 回收审计已带可信 app 关联,AC-81 的跨 Feature 开发前置已完成;本地定向测试与真实部署验收分开,参见 F056 closeout-validation.md。
diff --git a/features/v3.0.0/055-app-publish-pipeline/spec.md b/features/v3.0.0/055-app-publish-pipeline/spec.md
new file mode 100644
index 0000000000..2cc86034eb
--- /dev/null
+++ b/features/v3.0.0/055-app-publish-pipeline/spec.md
@@ -0,0 +1,283 @@
+# Feature: 发布管线、预置审批流、版本记录、资源档位与能力总线
+
+> **当前后继合同(F114,2026-10-30,本地实现,未部署)**:首发资源仍属于自然人;真实 SA 用于鉴权、可撤销初始 Grant 和审计;迭代 edit AND publish,自审候选排除实际提交关联人,业务撤回 publish,双用途预览与独立凭据按 F114。 详见 [F114 已确认规格](../114-vibe-permission-actions/spec.md) 与 [验证记录](../114-vibe-permission-actions/verification.md)。下文保留原 Feature 的 AC 编号、当时口径及历史证据,不将历史通过记录当作 F114 部署证明。
+
+> **2026-10-23 手动生效修订规格已确认。** 用户原文:“确认修订规格,更新简化设计”。本次确认包括 AC-70、AC-73、AC-74、AC-76、AC-77、AC-86 的修订及新增 AC-90、AC-91、AC-92,共 92 条 AC。批准前文件 SHA-256 为 `33f74ab3b2c3eaac67e64099df4b056ff5e9cf5ec78e2e461f8f34266413804d`;记录见[简化设计评审](../../../docs/reviews/vibe-app-secrets-manual-apply-design-review-2026-10-23.json)。保存不自动重建,正式设计仍需单独评审确认,尚未编码。
+
+> **本文档定位 — 纯 What(需求口径,不随代码漂移)**
+>
+> spec 只回答 **做什么 / 验收标准 / 不做什么**。所有 How(决策、数据流、字段、API、Service、前端、文件清单、性能指标)一律不写在这里,
+> How 的唯一真相在 [design.md](./design.md) 与 [tasks.md](./tasks.md)。
+
+**关联 PRD**: [docs/PRD/3.0-release/3.0 应用工场 PRD-1 专业开发者通道与应用运行时.md](../../../docs/PRD/3.0-release/3.0%20应用工场%20PRD-1%20专业开发者通道与应用运行时.md) **v3.0 + 2026-10-23 用户修订** §3.0.2(应用状态机 · 审批单态)/ §3.0.3(事件触达 · 审批类事件)/ DEV-04(deploy 管线四步 · 托管运行契约 · 服务端侧)/ DEV-07 ②(应用数据表由应用包声明、平台建表)/ **RT-03** / **RT-04** / **RT-05** / **GOV-02** / **GOV-03** / **GOV-05** / GOV-08(应用运行期凭据 · 不作产品概念)/ GOV-04(发布类与接入能力审计事件)/ §3.3 末「实现锚点与前置」表 GOV-02 三行(⚠️ 阻塞前置);PRD-2 最小面 **WB-14 发布面 / WB-15 版本与差异**(§1 分册说明);《3.0 应用工场 产品方案》§4.4 / §4.5
+**同步记录**: 2026-08-17 初稿;同日按 `/sdd-review` 独立审查 33 条发现修订(补 `logs` 服务端 / 待上线态再提交 / 预检失败版本处置 / 发布摘要落点 / 拉起失败分支 / 声明引用校验 / 场景配置界面 / 集合相等 / 接入能力审计;理清申请人身份、应用运行期凭据主体形态、档位实体层级;去 How;INV-36 补审批例外)
+**优先级**: P0
+**所属版本**: v3.0.0
+**依赖**: **F054**(App / AppVersion / AppInstance 领域对象、五个应用态动作、编排器的构建 / 探活 / 容量准入 / 拉起切流量能力、预览入口路由、详情页壳);**F049**(应用运行期凭据由其凭据底座签发——底座须支持「托管应用」这一非服务账号的主体绑定;`app:manage` 位与资源归属人语义);**F051**(能力总线「模型」项的运行期调用通道);**F052**(能力总线「知识库」项的运行期检索 = 统一检索门面;MCP 应用状态工具消费本 Feature 的状态接口);**F050**(审计双归属 subject 列由其引入,落地前以附加字段承载——design 定)。**F050 / F051 / F052 仅被能力总线与审计双归属依赖,不在 MVP-114 首波路径上**
+**批次**: B(工场运行时层);**MVP-114 首波**见 [../mvp-114-path.md](../mvp-114-path.md) §2
+
+> **范围边界**
+>
+> 本 Feature 是「怎么上线」这一段的全部服务端与发布面:从提交发布到上线终检的管线、平台预置的审批流、只增不改的版本记录、资源档位实体、以及应用经能力声明获得平台能力的总线。它站在 F054 的领域模型与运行时之上,把「一次 deploy」变成「一个已上线、可被找到、可被治理的应用」。**本册托管应用的唯一创建与提交路径是 CLI `deploy`**(平台内造应用随 PRD-2),发布面「提交发布」在本册对 CLI 应用不可用。
+>
+> - **本次纳入**:
+>   - **发布管线(服务端)**:CLI `bisheng deploy` 的服务端接收(发布面提交入口对平台内造应用保留,随 PRD-2 启用)→ **冻结待上线版本快照** → **托管预检**(`bisheng-app.yaml` 声明校验〔必填项 / `runtime` / 端口 / 档位 / 能力声明格式与所引模型 · 知识库的存在与可用〕、依赖构建、启动探活——判定标准 = 托管运行契约;应用数据表结构的破坏性变更须显式确认)→ **发布前密钥扫描**(与平台内发布同一规则集;命中即阻断,输出命中规则与文件位置、不含密钥值)→ 通过后**落正式版本记录**并**生成审批单** → 审批通过 → **上线终检**(调 F054 上线动作:容量通过 → 已上线;容量不足 → 待上线(资源不足);拉起或探活失败 → 待上线(上线失败,可重试))→ 资源释放 / 修复后 owner **手动上线(无需重审)**;驳回 / 撤回 / 取消分支;同一应用同时至多一个在途审批单,待上线态不接受新提交;审批期间 owner 继续改码不影响已提交版本;元信息随提交成功更新;`deploy` / `logs` 的服务端权限判定(`app:manage` 位 + 归属人 owner-only)
+>   - **GOV-02 平台预置审批流**:「应用发布」场景随部署启用、在审批中心场景配置中作为场景出现并展示预置配置;单条无条件分支、单节点或签;审批人来源 = ① 应用 owner 主部门的部门管理员 ② 租户管理员;**来源 ② 在 Root 租户(单租户部署形态)回退为平台超管**;预置不覆盖人工改动;**新建租户同步落库**(新增机制);申请人 = 应用 owner(自然人,非服务账号);提交人自动跳过;仅一名管理员且无其他候选允许自审 + 审计标注;两来源皆空 → 异常态、通知管理员、不放行不卡死;租户管理员可改配审批人 / 审批链;**三项阻塞前置**:`tenant_admin` 来源改用真租户管理员(对**一切**使用该来源的场景与人工配置生效——行为变更,release note 声明)、Root 租户回退平台超管、新建租户 seed 钩子;`withdraw` 守卫(非待审状态一律不可撤回)
+>   - **审批人侧**:审批单详情四分区(头部 · 能力声明白话摘要 · 可见范围快照「仅供参考」· 资源档位;含结构变更时加「结构变更」行)+ 操作区(通过 / 驳回理由必填 / 「查看待上线版本」);**审读视图**(左文件树 + 右只读代码;tab:能力声明完整清单 / 版本历史 / 文件差异〔迭代时〕;「预览试用」置顶);**审批期临时预览实例**(以待上线版本快照拉起、复用 F054 探活能力与预览入口路由、不占应用运行实例名额;注入身份 = 审批人本人、平台能力按 owner 权限放行——审批例外条款、计审计;连一个临时空库、试用数据不带入生产;审批出终态即回收、超时回收〔默认 7 天,部署配置项〕、回收后可再拉起;四个界面状态)
+>   - **RT-04 能力声明与 `bisheng-app.yaml` 对外形态**:能力声明术语权威定义与三处呈现;**应用包声明文件 `bisheng-app.yaml` 的对外形态(必填 / 可选项:名称 / 描述 / 图标 / `runtime` / 端口 / 档位 / 能力声明 / 应用数据表结构 / 出站域名白名单)由本 Feature 拥有**(发布输入的权威 = 管线校验方;F053 CLI 与 F054 运行时按同一形态消费);迭代与首发同等必审;本册总线范围 = **模型 + 知识库 + owner 录入的应用密钥**(2026-10-09 PRD 决议已改为本版最小交付,本轮补齐规格)
+>   - **RT-05 版本记录与结构演进**:通过预检与扫描后落正式版本记录并在终态到来时标注(已上线 / 被驳回 / 已撤回);结构演进规则(应用数据表由 `bisheng-app.yaml` 声明、平台建表,DEV-07 ②:加列无需确认自动迁移;改 / 删列 CLI `deploy` 时显式确认、发布时不再二次确认、发布摘要展示、**迁移前自动留生产数据快照**);平台侧回滚本版不做
+>   - **GOV-03 资源档位**:ResourceTier 实体(**平台级、跨租户共享**;三档预置:轻量 0.5C/1G · 标准 2C/4G · 性能 4C/8G,规格初始默认值为部署配置项);系统管理页「资源档位」tab(平台超管);发布时选档(CLI 由 `bisheng-app.yaml` 声明、未声明取轻量;发布面对平台内造应用默认选中轻量);档位停用只拦新选择;快照记录**档位标识**(规格取运行时当前值,故规格调整自下一次发布 / 重新上线生效);上线终检 / 待上线态 / 手动上线(触达 owner 与租户管理员)
+>   - **GOV-05 能力总线(模型 + 知识库)**:「配置一次、声明使用、零新建配置面」;模型:既有模型管理页配置一次,应用声明模型名(原名),审批通过后运行时注入调用通道(经 F051 + 应用运行期凭据);知识库:声明即授权,运行时检索 = 声明白名单 ∩ 当前访问用户可见范围(文件级、fail-closed,经 F052 门面);生产实例可用能力 = 声明且经审批的集合;**能力收回** → 应用对该能力的调用得到带能力名与「已收回」原因的明确错误(非空结果)、不回退旧值、应用整体可用、owner 在发布面能力声明清单对应项见「已失效 + 原因」标记;运行期能力调用审计双归属(actor = 应用、subject = 当前访问用户);能力声明变更随每次发布计审计
+>   - **应用运行期凭据(实现细节,不作产品概念)**:由 F049 底座签发、主体类型为「托管应用」(不是服务账号:不进服务账号列表、无资源归属人要求,应用 owner 即归属);随发布审批通过按新能力声明签发 / 重签,旧凭据 5 秒内失效;下线 → 该凭据拒绝调用(经主体停用语义,非新增状态枚举),重新上线恢复;删除 → 撤销;签发 / 撤销事件计审计;无任何管理界面与强制吊销入口
+>   - **PRD-2 最小面**:**WB-14 发布面**(提交发布 + 档位选择〔本册对 CLI 应用不可用〕· 审批状态区含驳回理由全文、结构变更提示与撤回 · 能力声明完整清单〔含失效标记〕· 版本列表 · 可见范围区**槽位**〔内容与行为归 F056〕· 运营动作区〔下线 / 重新上线调 F054;待上线态出现「手动上线」〕· 入口链接与二维码〔F054 壳呈现〕· 危险操作区〔显式删除调 F054〕);**WB-15 版本 tab**(全部版本记录:版本号 / 类型 / 提交时间 / 终态 / 能力声明摘要;只读;差异查看:迭代版本相对上一已发布版本的文件变更清单与内容差异);审批状态只读接口(供发布面与 F052 MCP 应用状态工具消费)
+>   - **审批类事件触达**(§3.0.3):审批单生成 → 审批人;通过 / 驳回 → owner;owner 撤回 → 已收到待办的审批人;删除致取消 → 审批人;待上线态 → owner 与租户管理员(单租户回退平台超管);资源释放后可手动上线与声明能力被收回 → 无主动提示(发布面自查)——复用审批中心与站内消息既有触达
+> - **本次明确排除**:
+>   - **App / AppVersion / AppInstance 领域对象、五个应用态动作、编排器与 app-proxy(含预览入口路由)、构建页与详情页壳、`app` 资源类型注册**——F054(本 Feature 调用其对象与动作、填充其详情页的发布 / 版本 tab)
+>   - **应用广场、授权交互(含发布面可见范围区的内容与行为)、审计查询面、GOV-07 界面通道、非审批类事件触达**——F056
+>   - **CLI 命令本身**(打包 / 上传 / 轮询 / 交互确认 / `logs` 输出)——F053;本 Feature 只做服务端接收、状态接口与权限判定
+>   - **模型协议面**(F051)、**统一检索门面与知识库清单查询、MCP 应用状态工具**(F052)——本 Feature 只定义能力总线如何注入 / 收回,以及提供状态只读接口
+>   - **通用密钥保险库、第三方密钥自动签发/轮换、跨应用共享密钥**——本次只管理 owner 为本应用录入的第三方值;不扩展平台服务账号凭据产品,不新增 SDK 密钥管理或明文读取入口
+>   - **平台侧回滚、迭代审批单变更摘要、应用标签设置、租户级实例数配额、审批单催办 / 超时提醒 / 升级机制、免审配置项**(PRD-1 §5.2 / 不存在)
+>   - **平台内造应用的发布面提交、密钥扫描回流管家、owner 草稿沙箱预览、审批期试用数据「保留」选项、工作台路径的改删列草稿期闸口(WB-09)**——PRD-2
+>   - **k8s 形态下的构建 / 上线 / 终检差异**——F059(本 Feature 的管线对形态无感)
+>   - **应用运行期凭据的产品化**(管理入口、强制吊销 tab、会话 key)——已取消(PRD-1 §5.2);**发布期依赖 CVE 扫描 / SBOM**(v3.1)
+
+---
+
+## 1. 用户故事
+
+作为 **开发者(owner)**,
+我希望 **`bisheng deploy` 之后不用离开本地就能知道预检哪里没过、扫描命中了什么、审批到哪一步、被驳回的理由是什么;通过后应用自动上线,容量不够时我能在资源释放后自己点一下上线、不用重新走审批**,
+以便 **发布是一条可预期、可自查的流水线,而不是黑盒**。
+
+作为 **审批人(部门管理员 / 租户管理员)**,
+我希望 **在审批中心看到这次发布接了哪些平台能力、给谁看、用多大资源,能打开待上线版本的代码只读审读,必要时拉一个临时实例试用一下再决定**,
+以便 **审的是冻结的那份快照而不是元信息两行字,非技术审批人也能负责任地批**。
+
+作为 **租户管理员**,
+我希望 **平台一部署好就有一套可用的发布审批流程(我部门的部门管理员或我来批),我不用先去配置;需要收紧时再改审批人;应用要用哪个模型、哪些知识库,在我原来配模型和知识库的地方配一次就够**,
+以便 **治理默认在场,而不是先做一堆配置才能跑通第一次发布**。
+
+作为 **平台超管**,
+我希望 **资源档位是三档预置、可调规格可停用,应用只能从中选,选了什么容器就限多少**,
+以便 **单机上的应用密度可控、不会有人给自己开一个吃光机器的规格**。
+
+作为 **普通业务用户**,
+我希望 **应用里检索到的知识库内容不超过我自己在平台里能看到的范围,也不超过应用声明接入的范围**,
+以便 **用同事造的应用不会让我看到不该看的东西,也不会因为应用作者权限大而放大给我**。
+
+---
+
+### 本轮补充用户故事(2026-10-23)
+
+作为应用 owner,我希望在发布 tab 安全维护本应用的第三方密钥值,使代码只声明引用名、批准的正式实例获得当前值;我希望能区分值已保存与运行更新结果,并且不向审批预览或其他角色暴露值。
+
+## 2. 验收标准
+
+> AC 编号在本特性内唯一。括号内标注对应 PRD-1 条目与验收序号(如 `↔RT-03 验收 3`);与 F054 存在调用关系的条目注明「调用 F054 AC-xx」。错误码仅作可观测行为引用,定义在 design.md。
+
+### 2.1 提交发布与冻结快照
+
+- **AC-01** — WHEN 服务端接收到 CLI `bisheng deploy` 提交的应用包, THE SYSTEM SHALL 走**同一条**管线:冻结待上线版本快照 → 密钥扫描 → 托管预检 → 落正式版本记录 + 生成审批单 → 审批 → 上线终检(**扫描先于预检**:2026-08-17 拍板,见 design D5 与 F053 spec 决议-13——扫描输入是源码包不依赖构建产物,命中即终止的前提下先构建等于白烧;这是对 PRD-1 DEV-04 字面顺序的自觉偏离);发布面提交入口(平台内造应用)随 PRD-2 启用后走同一管线,本册只验收 CLI 入口。管线的每个节点结果(提交 / 首发导入 / 预检失败 / 扫描阻断 / 审批单生成与四个终态 / 待上线成因 / 手动上线)**均计审计且携带应用标识与版本号**——本 Feature 是这些事件的写入方,审批单终态复用审批中心既有事件并须能按对象应用筛选,F056 负责其在审计面可查。(↔RT-03 流程① / DEV-04 验收 2 / GOV-04 事件清单「造应用」「发布 / 删除动作」)
+- **AC-02** — WHEN 提交发布, THE SYSTEM SHALL 立即冻结快照(代码、能力声明、注入配置、档位标识同属一个快照),审读与上线的权威对象即该快照,提交后 owner 的任何改动不影响它;WHEN 预检与扫描通过, THE SYSTEM SHALL 落正式版本记录(版本号、首发 / 迭代、提交时间、终态待标注);预检或扫描失败的提交**不进入版本列表**(快照临时保留供排障、可读原因经 AC-11 返回)。(↔RT-03 规则 / RT-05 需求描述 / DEV-04 验收 4)
+- **AC-03** — IF 同一应用已存在在途审批单, 或应用处于待上线态, THEN THE SYSTEM SHALL 拒绝新的提交并提示(前者:须先撤回;后者:须先手动上线或删除)。(↔RT-03 规则 / DEV-04 验收 5 / §3.0.2 待上线态可做动作)
+- **AC-04** — WHEN 服务端处理 `deploy`(首发与迭代)或 `logs`, THE SYSTEM SHALL 要求该密钥持 `app:manage` 位(缺位 → 拒绝并明确提示);首发以该密钥所属服务账号的资源归属人为应用 owner(写入 F054 AC-07 的 owner 字段);迭代 `deploy` 与 `logs` 只对该归属人 owner 的应用放行,其他应用一律拒绝并提示归属(含租户管理员名下服务账号的密钥);持 `delegate` 位的密钥在这两个服务端入口一律被拒、错误指向「委托专用、本地开发另发一把」(INV-31)。(↔GOV-07 验收 2 / DEV-04 验收 9 / 12;DEV-02 ③ ④ / 验收 6 / 10)
+- **AC-05** — WHEN 提交通过预检与扫描, THE SYSTEM SHALL 从 `bisheng-app.yaml` 取应用元信息(名称 / 描述 / 图标)并**立即**更新应用(不等审批;元信息不属发布、不经审批、计审计),取能力声明与档位入快照(未声明档位取轻量);预检或扫描失败不更新元信息。(↔DEV-04 规则 / RT-02 规则 / GOV-03 验收 2)
+- **AC-06** — WHERE 应用为 CLI 导入且平台侧无草稿工作区, THE SYSTEM SHALL 使发布面「提交发布」不可用并提示以 `bisheng deploy` 提交。(§4 决议-2)
+
+### 2.2 托管预检与密钥扫描
+
+- **AC-07** — WHEN 进入托管预检, THE SYSTEM SHALL 依次校验 `bisheng-app.yaml` 声明(必填项、`runtime` 取值、端口、档位存在且启用、能力声明格式、**所引模型在本租户已启用且按 F051 名称解析规则可唯一解析〔裸名歧义 → 拒绝并提示限定名〕、所引知识库存在且为 F052 统一检索门面支持的类型**)、执行依赖构建、执行启动探活(调用 F054 AC-15 / AC-18);任一失败 → 原地拒绝、说明缺失项与改造指引、**不进入审批、不产生审批单、不进版本列表**。(↔DEV-04 流程① / 验收 4;GOV-05)
+- **AC-08** — IF 应用包依赖托管契约外的中间件(自带数据库 / 消息队列 / 缓存)或白名单外外网地址, THEN THE SYSTEM SHALL 在预检拒绝并给出改造指引(数据改接平台应用数据库的范式)。(↔DEV-04 托管运行契约 / 验收 4)
+- **AC-09** — WHEN 预检检出应用数据表的破坏性结构变更(改 / 删列;表由 `bisheng-app.yaml` 声明、平台建表,DEV-07 ②), THE SYSTEM SHALL 要求显式确认(CLI 交互确认或 `--confirm-schema-change`);未确认 → 拒绝;已确认 → 进入管线,发布时不再二次确认,审批单与发布面审批状态区展示该结构变更。(↔DEV-04 验收 10 / RT-05 结构演进表 / DEV-07 ②)
+- **AC-10** — WHEN 进入发布前密钥扫描, THE SYSTEM SHALL 用与平台内发布**同一规则集**(常见凭据模式正则清单 + 数据库连接串模式 + `bs-sak-` 前缀模式)扫描产物;命中 → 阻断发布,输出命中规则与文件位置、**不含密钥值本身**;规则集内样本 100% 被阻断。(↔RT-03 流程③ / 验收 5;DEV-04 验收 2 / 8)
+- **AC-11** — WHEN 预检或扫描失败, THE SYSTEM SHALL 把失败阶段与原因以可机读 + 可读两种形态返回给 CLI(供 F053 输出与本地 agent 排障)并在发布面审批状态区可见。(↔DEV-04 验收 7)
+
+### 2.3 平台预置审批流(GOV-02)
+
+- **AC-12** — WHEN 全新部署后未做任何审批配置直接发布, THE SYSTEM SHALL 正常生成审批单,审批人 = 应用 owner 主部门的部门管理员 ∪ 租户管理员(同一节点或签);界面不出现「未配置审批人」提示。(↔GOV-02 验收 1)
+- **AC-13** — WHEN 部门管理员或租户管理员任一人处理, THE SYSTEM SHALL 立即出终态并同步关闭另一方的待办。(↔GOV-02 验收 2)
+- **AC-14** — IF owner 无主部门、或其主部门未设部门管理员, THEN THE SYSTEM SHALL 仍把审批单落到租户管理员,发布不卡死;部门只取 owner **主部门**且不向上级部门回溯。(↔GOV-02 验收 3 / 规则)
+- **AC-15** — WHEN 来源 ②(租户管理员)解析为空, THE SYSTEM SHALL 按当前租户区分:**Root 租户(单租户部署形态)→ 回退为平台超管**,未做任何配置即可发布;非 Root 租户无租户管理员且来源 ① 亦空 → 走 AC-18 异常态。(↔GOV-02 验收 6 / 规则)
+- **AC-16** — WHEN 提交发布, THE SYSTEM SHALL 以应用 owner(资源归属人自然人,**不是服务账号**)为审批单申请人,并带上其主部门标识供审批人解析;owner 无主部门时该来源按空处理(AC-14)并在审批单头部标注「无部门管理员来源」。(↔RT-03 流程④;INV-29)
+- **AC-17** — IF 申请人本人位于审批人候选中, THEN THE SYSTEM SHALL 自动跳过其本人落到其余候选;WHERE 全租户仅一名管理员且无其他候选, THE SYSTEM SHALL 允许自审并强制在审计中标注自审。(↔GOV-02 验收 7)
+- **AC-18** — IF 两个审批人来源均解析为空, THEN THE SYSTEM SHALL 使实例进入审批中心异常态并通知管理员(本租户租户管理员;Root 租户为平台超管)处理,**发布不被放行、也不静默卡死**。(↔GOV-02 验收 8)
+- **AC-19** — THE SYSTEM SHALL 在管理后台审批中心场景配置中展示「应用发布」场景及其预置流程当前配置;WHEN 租户管理员改配审批人 / 审批链, THE SYSTEM SHALL 使新配置生效于此后的审批单,且平台升级不重置其配置(按租户 + 场景判存在即跳过预置)。(↔GOV-02 界面表 / 验收 4)
+- **AC-20** — WHEN 新建租户, THE SYSTEM SHALL 同步为该租户落库「应用发布」预置流程,使其首次发布即可正常生成审批单。(↔GOV-02 验收 5)
+- **AC-21** — THE SYSTEM SHALL 使审批中心「租户管理员」审批人来源解析为**当前租户的真实租户管理员**(而非系统超级管理员近似);该修正对**一切**使用该来源的既有场景与人工配置的流程同样生效,release note 声明为行为变更。(↔§3.3 锚点表 GOV-02 行 1)
+- **AC-22** — IF 审批实例不处于待审状态(已执行 / 执行失败 / 异常 / 已撤回 / 已取消), THEN THE SYSTEM SHALL 拒绝撤回操作。(↔Discovery §2.4 `withdraw` 终态守卫)
+- **AC-23** — THE SYSTEM SHALL 使首发与迭代每次发布均生成审批单,不存在任何免审配置项;能力声明与可见范围未变的迭代同样必审。(↔RT-03 / RT-04 验收 1 / GOV-02 规则)
+
+### 2.4 审批人侧:审批单、审读视图、临时预览实例
+
+- **AC-24** — WHEN 审批人打开应用发布审批单, THE SYSTEM SHALL 展示头部基本信息(应用名 / owner / 来源:平台内造或 CLI 导入 / 首发或迭代 / 提交时间)、能力声明白话摘要、可见范围快照(标注「仅供参考,可见范围变更即时生效、不经审批」)、所选资源档位、含结构变更时的「结构变更」行,以及操作区(通过 / 驳回〔理由必填〕/ 「查看待上线版本」)。(↔RT-03 审批人侧表 / 验收 2)
+- **AC-25** — WHEN 审批人打开审读视图, THE SYSTEM SHALL 展示待上线版本的只读文件树与代码内容,tab 含能力声明完整清单 / 版本历史 / 文件差异(迭代时相对上一已发布版本),「预览试用」按钮置顶;owner 的草稿对话(若有)不开放。(↔RT-03 审读视图 / 验收 2)
+- **AC-26** — WHEN 审批人点击「拉起预览」, THE SYSTEM SHALL 以待上线版本快照拉起审批期临时预览实例(调用 F054 探活能力与预览入口 AC-37;不占应用运行实例名额;连一个临时空库);拉起失败展示原因并允许重新拉起;运行中提供「打开预览」与「手动回收」。(↔RT-03 预览试用表 / 验收 7)
+- **AC-27** — WHILE 审批人在临时预览实例中试用, THE SYSTEM SHALL 注入审批人本人身份,平台能力按 **owner** 权限放行(NFR-1.2 审批例外条款,INV-36 已登记该例外)并计审计;临时库对审批人可写入试用数据。(↔RT-03 规则 / 验收 8 / NFR-1.2)
+- **AC-28** — WHEN 审批出终态(通过 / 驳回 / 撤回 / 取消)或超过部署配置的超时(默认 7 天), THE SYSTEM SHALL 回收临时预览实例;回收后可再次拉起。(↔RT-03 预览试用表 / 验收 7 / 9)
+- **AC-29** — THE SYSTEM SHALL 使审批期试用数据不带入生产:CLI 导入应用首发生产库默认空库初始化。(↔RT-03 规则)
+- **AC-30** — THE SYSTEM SHALL 使首次发布的应用在审批通过前不可被除审批人(经审读视图与预览试用)外的任何其他用户访问。(↔RT-03 验收 1)
+
+### 2.5 审批结果与上线终检
+
+- **AC-31** — WHEN 审批通过, THE SYSTEM SHALL 调用 F054 上线动作(AC-65):容量通过 → 已上线、进入广场可见范围;容量不足 → 审批单保持通过、应用态待上线(资源不足)、不对外服务、通知 owner 与租户管理员并提示成因;拉起或探活因非容量原因失败 → 应用态待上线(成因:上线失败)、通知 owner 可修复后重试手动上线或重新提交(§4 决议-8)。(↔RT-03 流程⑤ / 验收 3;GOV-03 验收 5)
+- **AC-32** — WHILE 应用处于待上线态, THE SYSTEM SHALL 在发布面运营动作区提供「手动上线」;WHEN owner 点击, THE SYSTEM SHALL 调用 F054 手动上线动作,**无需重新审批**;仍失败则保持待上线并提示成因。(↔GOV-03 验收 5 / §3.0.2)
+- **AC-33** — WHEN 首发被驳回, THE SYSTEM SHALL 使应用保持草稿态;WHEN 迭代被驳回, THE SYSTEM SHALL 保持应用已上线、当前版本继续运行,仅待上线版本标记为被驳回(F054 AC-05);两者 owner 均可在发布面审批状态区看到驳回状态与**理由全文**,修改后重新提交生成新审批单。(↔RT-03 流程⑥ / 验收 4 / 10)
+- **AC-34** — WHEN owner(申请人)撤回在途审批单, THE SYSTEM SHALL 使审批单置已撤回、待上线版本标记已撤回、通知已收到待办的审批人;owner 修改后再提交生成新审批单。(↔RT-03 验收 6)
+- **AC-35** — WHEN 应用被删除(F054 动作通知), THE SYSTEM SHALL 自动取消其在途审批单(置已取消)、通知审批人并计审计(含应用标识与版本号)。(↔RT-07 验收 5 / §3.0.2 / GOV-04)
+- **AC-36** — WHILE 应用处于已下线态, THE SYSTEM SHALL 允许提交发布;审批通过仅落为待运行版本、不自动重新上线(F054 AC-04);重新上线后新版本生效。(↔RT-07 验收 6)
+- **AC-37** — WHEN 迭代发布上线, THE SYSTEM SHALL 使应用运行期获得的能力即为新能力声明所列,旧声明中已移除的能力在 5 秒内不可用(INV-28 撤销上界)。(↔GOV-08 验收 6)
+- **AC-38** — THE SYSTEM SHALL 提供审批状态只读接口(当前审批单状态、驳回理由全文、待上线成因),供发布面与 F052 MCP 应用状态工具消费,两处返回一致。(↔RT-03 流程⑥ / DEV-02 验收 7)
+
+### 2.6 版本记录与结构演进(RT-05 / WB-15)
+
+- **AC-39** — THE SYSTEM SHALL 在应用详情页「版本」tab 只读展示全部正式版本记录:版本号 / 类型 / 提交时间 / 终态标注(已上线 / 被驳回 / 已撤回;待上线版本显示「待上线」)/ 能力声明摘要;不提供回滚入口。(↔RT-05 验收 1 / 4)
+- **AC-40** — THE SYSTEM SHALL 使正式版本记录不被覆盖或删除,同一应用历史版本完整可追溯(F054 AC-02 只增不改)。(↔RT-05 验收 2)
+- **AC-41** — WHEN 查看迭代版本, THE SYSTEM SHALL 提供相对上一已发布版本的文件变更清单与内容差异(版本 tab 与审读视图同一呈现)。(↔WB-15 / RT-03 验收 2)
+- **AC-42** — WHEN 迭代发布含加列的结构变更, THE SYSTEM SHALL 在上线时自动迁移、无需确认;WHEN 含已确认的改 / 删列变更, THE SYSTEM SHALL 在迁移前**自动留生产数据快照**并在审批单与发布面展示结构变更。(↔RT-05 结构演进表 / 验收 3)
+- **AC-43** — THE SYSTEM SHALL 使任一正式版本的快照可被完整取回(供审读视图、审批期预览拉起与将来回滚),且快照内容不可被修改。(↔RT-05 / RT-07 需求描述)
+
+### 2.7 资源档位(GOV-03)
+
+- **AC-44** — THE SYSTEM SHALL 预置三档**平台级**(跨租户共享)资源档位(轻量 0.5C/1G · 标准 2C/4G · 性能 4C/8G,各附白话适用说明),规格初始默认值取自部署配置项;不提供自定义规格。(↔GOV-03 档位表 / 验收 1)
+- **AC-45** — WHERE 工场运行时层已部署, THE SYSTEM SHALL 在系统管理页提供「资源档位」tab(仅平台超管):列表(名称 / CPU 内存 / 适用说明 / 启用状态 / 使用中应用数)、行内编辑规格与说明、停用(提示存量应用不动、发布面不再可选);未部署不出现。(↔GOV-03 界面表 / 验收 1)
+- **AC-46** — WHEN CLI 提交, THE SYSTEM SHALL 取 `bisheng-app.yaml` 声明的档位、未声明取轻量、声明了已停用或不存在的档位则预检拒绝;发布面选档(默认轻量)随平台内造应用的提交入口一并启用(PRD-2)。(↔GOV-03 验收 2 / 3)
+- **AC-47** — WHEN 档位被停用, THE SYSTEM SHALL 使存量应用照常运行与迭代发布(沿用原档位),新发布不可再选该档位。(↔GOV-03 验收 3)
+- **AC-48** — THE SYSTEM SHALL 把所选**档位标识**写入版本快照;F054 按该档位的当前规格设置实例限额,规格调整自应用下一次发布 / 重新上线生效(F054 AC-63 / 64)。(↔GOV-03 验收 4 / 规则)
+
+### 2.8 能力总线(GOV-05:模型、知识库及应用密钥)
+
+- **AC-49** — WHEN 租户管理员在既有模型管理页配置服务商凭据并启用模型, THE SYSTEM SHALL 使域内应用无需再配即可在能力声明中写该模型名(模型管理页原名);审批通过后由平台在运行时注入调用通道(经 F051 平台 OpenAI 兼容面 + 应用运行期凭据)。(↔GOV-05 界面表 · 模型 / 验收 1)
+- **AC-50** — WHEN 应用在能力声明中声明接入知识库, THE SYSTEM SHALL 在审批通过时视为应用侧授权;运行时应用内检索可及范围 = 能力声明知识库 ∩ 当前访问用户可见范围(文件级、经 F052 统一检索门面)——访问用户由 F054 AC-34 注入的短时访问凭据确立、白名单由平台侧按该应用**当前生效能力声明**确定(应用不可自报白名单);托管期检索入口(SDK retrieve / 能力总线)以此两者为入参,且**声明范围内的检索结果与该用户在平台内自行检索的结果集合相等**(不多给不少给)。(↔GOV-05 界面表 · 知识库 / 验收 5 / 6)
+- **AC-51** — THE SYSTEM SHALL 使生产实例实际可用能力 = 其声明且经审批的能力集合;未声明的能力不可调用(用户本人有权限但应用未声明的知识库经该应用不可检索)。(↔GOV-05 验收 3 / 5)
+- **AC-52** — IF 无法取得当前访问用户身份, THEN THE SYSTEM SHALL 拒绝应用内检索、绝不回退为按声明全量放行;IF 权限评估失败, THEN THE SYSTEM SHALL 返回带错误码的失败响应(非空结果、非部分结果)。(↔GOV-05 fail-closed / 验收 7 / NFR-1.4)
+- **AC-53** — WHEN 已上线应用声明的能力被收回(模型下线、知识库删除或权限收回), THE SYSTEM SHALL 使应用对该能力的调用得到**带能力名与「已收回」原因的错误响应**(可与普通失败区分)、不回退旧值、应用整体保持可用;owner 在发布面能力声明清单对应项见「已失效 + 原因」标记。(↔GOV-05 规则 / 验收 4)
+- **AC-54** — THE SYSTEM SHALL 使平台模型、知识库的底层账号与连接配置继续只在原管理面维护;owner 可在应用详情页发布 tab 维护本应用调用第三方系统所需的应用密钥,但不能借此修改平台通道、服务身份或执行环境的安全配置。(2026-10-09 GOV-05 决议对 Discovery N4 的修订)
+- **AC-55** — WHEN 应用运行期经能力总线调用模型或检索, THE SYSTEM SHALL 记录审计双归属:actor = 应用、subject = 当前访问用户(由 F054 AC-34 注入的短时访问凭据确立;**仅模型调用**允许在应用后台任务等无访问用户上下文时以应用自身发起、subject 记为「应用自身」并显式标注——检索无访问用户一律拒绝,AC-52;**不得**记为任意用户),含模型名与 token 数(模型)或检索目标(知识库);WHEN 每次发布, THE SYSTEM SHALL 记录能力声明变更审计(应用、变更内容)。(↔GOV-04 事件清单「应用运行期能力调用」「接入知识库 / 模型等能力」/ 验收 3;subject 列随 F050)
+- **AC-56** — WHEN 能力声明只包含合法的应用密钥引用名, THE SYSTEM SHALL 接受该声明并按本节密钥规则处理;IF 引用尚未录入值, THEN THE SYSTEM SHALL 输出缺值警告而不以“本版不支持密钥引用”拒绝发布;发布产物含实际明文密钥时,原密钥扫描仍必须拦截。(GOV-05 验收 8、验收 10;替代旧 Discovery N4 排除项)
+
+### 2.9 应用运行期凭据(不作产品概念)
+
+- **AC-57** — WHEN 发布审批通过, THE SYSTEM SHALL 按新能力声明为该应用签发 / 重签运行期凭据(F049 底座、主体类型 = 托管应用)并注入实例;旧声明对应的凭据在 5 秒内失效。(↔GOV-08 规则 / 验收 6;INV-28)
+- **AC-58** — WHEN 应用下线, THE SYSTEM SHALL 使其运行期凭据拒绝调用(主体停用语义,非新增状态枚举),重新上线后恢复;WHEN 应用删除, THE SYSTEM SHALL 撤销之;两者事件计审计。(↔GOV-08 规则 / GOV-04 事件清单;INV-28)
+- **AC-59** — THE SYSTEM SHALL NOT 提供任何应用运行期凭据的查看、吊销或管理入口,且该主体不出现在服务账号管理列表;应急处置只有下线一条路。(↔GOV-08 验收 7)
+- **AC-60** — WHEN owner 自然人账号被禁用, THE SYSTEM SHALL 使其应用照常运行、可见范围内用户正常使用。(↔GOV-08 验收 5)
+
+### 2.10 发布面(WB-14 最小形态)
+
+- **AC-61** — THE SYSTEM SHALL 在应用详情页「发布」tab 提供:提交发布 + 档位选择(本册对 CLI 应用不可用,AC-06)、审批状态区(待审 / 通过 / 驳回 / 已撤回 / 待上线成因 + 驳回理由全文 + 结构变更提示 + 在途时「撤回」按钮)、能力声明完整白话清单(含失效标记)、版本列表(只读,同版本 tab)、可见范围区槽位(内容与行为归 F056)、运营动作区(下线 / 重新上线调 F054;待上线态出现「手动上线」)、入口链接与二维码(F054 壳呈现)、底部危险操作区(显式删除调 F054,owner 或平台超管;已上线态置灰须先下线)。(↔RT-03 owner 侧表 / §1 WB-14 最小形态)
+- **AC-62** — THE SYSTEM SHALL 使发布面对 owner 可见可操作;租户管理员可查看并执行下线 / 重新上线(既有权限模型),但不可撤回审批单、不可删除有效 owner 的应用、不可手动上线(仅 owner);平台超管可按 F054 AC-44 删除,仍需先下线、二次确认和审计。(↔§3.3 角色矩阵)
+- **AC-63** — WHEN 声明能力被收回, THE SYSTEM SHALL 在发布面能力声明清单对应项显示「已失效 + 原因」标记(AC-53)。(↔GOV-05 验收 4)
+
+### 2.11 审批类事件触达(§3.0.3)
+
+- **AC-64** — WHEN 审批单生成 / 通过 / 驳回 / 被撤回 / 因删除被取消, THE SYSTEM SHALL 经平台既有站内消息与审批中心待办触达对应接收方(审批人 / owner / 已收到待办的审批人);WHEN 上线终检遇容量不足或上线失败, THE SYSTEM SHALL 触达 owner 与租户管理员(Root 租户为平台超管);资源释放后可手动上线与能力被收回两类**不主动提示**,仅发布面自查。(↔§3.0.3 事件表)
+- **AC-65** — THE SYSTEM SHALL 使站内消息为通知性质、不承载操作;处理动作一律回发布面或审批中心完成。(↔§3.0.3)
+
+---
+
+
+### 2.12 应用密钥最小版(GOV-05 收尾)
+
+- **AC-66** — WHEN 目标应用的自然人 owner 录入、修改或删除应用密钥值, THE SYSTEM SHALL 仅对该 owner 开放相应业务操作;租户管理员、非 owner 平台超管、审批人、普通成员、服务账号及个人访问令牌不得因管理身份、拥有 app:manage 或知道应用标识而取得该写权限。
+- **AC-67** — WHEN 查看应用详情页发布 tab 的应用密钥区, THE SYSTEM SHALL 向 owner 展示引用名、固定掩码、已录入/缺少值状态及最近修改时间,并区分批准版本、待审批版本和未被当前版本引用的已存值;本租户管理员只能查看引用名和状态,审批人仅在其有权审阅的审批单内查看该版本的引用与缺值提示。
+- **AC-68** — THE SYSTEM SHALL 加密保存应用密钥值;录入后不存在查询明文、导出明文或用界面掩码还原值的能力;应用管理、版本、审批、日志、错误和审计的任何平台输出均不含密钥值、其片段或可复用的访问凭据。
+- **AC-69** — WHEN 新增或删除应用版本声明中的引用名, THE SYSTEM SHALL 将其作为能力声明变更随发布审批生效并在审批摘要中展示引用名;录入、替换或删除一个值不会擅自修改任何版本的声明,也不产生发布审批单。
+- **AC-70** — WHEN 已批准的正式版本启动、恢复或经用户确认重建, THE SYSTEM SHALL 只把该版本明确声明的引用名注入为同名应用环境变量,使用本次启动操作受理时选定的已保存配置;未声明的值、其他应用的值与其他租户的值不得进入该实例,代码、依赖和运行环境的批准组合保持不变。
+- **AC-71** — WHEN 执行构建、依赖准备、静态扫描、预检启动或审批期预览, THE SYSTEM SHALL 不注入任何真实应用密钥值;预检启动和审批预览只获得该版本声明的变量名及空值,不能借复用已上线实例或材料取得生产值。(2026-10-23 用户明确确认)
+- **AC-72** — IF 声明引用缺少已录入值, THEN THE SYSTEM SHALL 在 CLI deploy、发布 tab 和对应审批单显示引用名与缺值状态,且不因缺值本身拒绝提交或生成审批单;仍须执行原启动/探活检查,应用自身因空值无法启动时按原失败流程处理,不跳过真实健康校验。
+- **AC-73** — WHEN owner 删除仍被在线版本引用的密钥值, THE SYSTEM SHALL 在确认删除影响后仅清除已保存的值,保留版本引用名,不自动改变或停止当前实例;界面提示运行实例仍可能使用旧值。下一次正式启动或用户确认重建时该引用注入为空,仍可由 owner 补录。
+- **AC-74** — WHEN owner 新增、修改或删除应用密钥值, THE SYSTEM SHALL 仅保存配置,不自动停止、启动或重建应用实例;在线应用使用的值与最新保存值不一致时显示未生效变更,是否现在重建由 owner 显式确认。用户取消或仅关闭提示时运行实例保持不变。
+- **AC-75** — WHEN owner 为已下线、草稿或尚未上线的版本保存值,或修改未被在线版本引用的值, THE SYSTEM SHALL 保持原应用状态且不擅自上线或无关重启;此后按原审批/上线动作启动时使用最新有效值。
+- **AC-76** — IF owner 已确认的配置重建失败, THEN THE SYSTEM SHALL 保留已保存配置,明确区分“配置已保存”和“实例重建失败”,按实际运行状态显示未生效或无法确认,并允许遵守原生命周期约束的再次确认重试;不得谎报新配置已生效、自动重试重建、静默恢复旧值或回滚代码/业务数据。
+- **AC-77** — WHEN 配置保存并发发生, THE SYSTEM SHALL 明确保存次序或拒绝过期编辑,已拒绝请求不得改变已存值;WHEN 保存与部署、停用、迁移、恢复、删除并发发生, THE SYSTEM SHALL 保持已有执行的配置选择及单写约束,旧执行结果不得覆盖最新保存配置,后保存内容未进入当前实例时保留差异提示;未知执行结果不得自动重复发起数据维护或重建。
+- **AC-78** — IF 引用名无效、重复或企图覆盖平台身份、平台通道、应用启动、语言运行环境及代理隔离所需的保留变量, THEN THE SYSTEM SHALL 在提交声明或保存值时明确拒绝并指出相应字段,不截断或静默替换名称。
+- **AC-79** — WHEN owner 保存合法值, THE SYSTEM SHALL 保留其 Unicode、换行及特殊字符的原始语义,使应用读取到同一值而不执行其中内容;空字符串不能被标为已录入,超过已公布输入限制时明确拒绝,未编辑的掩码不能被当成新值提交。
+- **AC-80** — WHERE 不同应用或租户使用相同引用名, THE SYSTEM SHALL 分别维护各自的值与状态;对不存在或无权限的应用作查询/写入时,须遵守原应用存在性和租户拒绝语义,不通过状态、列表、错误或掩码泄漏另一作用域的信息。
+- **AC-81** — WHEN 应用密钥值成功录入、修改或删除, THE SYSTEM SHALL 由本业务写入应用、引用名、操作者、动作、时间和结果的审计,不记录值;应用运行期凭据的签发/回收仍由原凭据 Owner 留痕,并须携带受信的应用关联供治理面追溯。
+- **AC-82** — THE SYSTEM SHALL 使真实应用密钥值不进入源码压缩包、版本快照、依赖缓存、构建产物、数据库快照、审批内容或应用附件;受限的正式执行交付可获得其必需的值,但普通查询者、预检执行体、代理和材料传输身份不得因此取得密钥管理能力。
+- **AC-83** — WHEN 应用停用、进程重启、代码升级、移除某版本引用或准备环境缓存被回收, THE SYSTEM SHALL 保留仍在平台存储中的密钥资产而不再向未声明的版本注入;仅 owner 明确删除值或按 F054 AC-43、AC-44 的应用删除流程清理相应密钥,历史无值审计仍可追溯。
+- **AC-84** — WHEN 查看能力声明和审批摘要, THE SYSTEM SHALL 展示该版本的引用名及对应缺值提示而不展示值;只有有效审批关系、owner 或原声明只读权限允许的访问者可见其授权范围内的信息,不能把审批人的有限审阅权限变成密钥管理权限。
+- **AC-85** — WHEN owner 通过密钥区保存一个未被正式版本引用的合法名值, THE SYSTEM SHALL 把它保持为未生效的应用私有配置;正式运行只以审批过的声明决定注入范围,不能靠预存一个新名字绕过引用新增的审批。
+- **AC-86** — WHERE 使用 Docker 或 K8s 执行后端, THE SYSTEM SHALL 对密钥权限、空值、预检/预览隔离、配置保存、差异提示及用户确认重建提供相同业务行为;实例重建、缓存恢复或执行器切换不得覆盖最新保存配置,也不得把使用旧配置的实例误报为已使用最新值。
+- **AC-87** — THE SYSTEM SHALL 保持 SDK 的原有公开能力边界,应用从标准环境读取业务密钥;不新增 SDK 密钥读取/管理入口,不将应用密钥变为可用于平台登录、平台管理或其他应用认证的凭据。
+- **AC-88** — WHEN 无应用密钥声明的既有应用升级或按原交付模式运行, THE SYSTEM SHALL 保持原发布、访问及依赖行为;声明使用应用密钥但目标执行形态不能兑现安全注入时,应明确拒绝所缺能力而不忽略声明或把真实值降级写入代码。
+- **AC-89** — WHEN 页面保存、缺值加载或实例更新请求失败, THE SYSTEM SHALL 结束相应等待并显示正确失败状态,保留用户尚未提交的本地编辑;不产生成功通知、不回填密钥明文,且不能通过重试误把掩码或过期页面状态覆盖已保存配置。
+
+
+- **AC-90** — WHEN 查看在线应用的密钥配置, THE SYSTEM SHALL 根据当前实际运行实例使用的声明和取值与最新已保存配置判断差异,只展示有差异的引用名及状态,不展示明文;修改未被运行版本引用的值不提示该实例需要重建,值修改后恢复为原值时不再误报该项差异。没有运行实例时显示下次启动使用最新配置,无法确认运行配置时明确显示未知而非已一致。
+- **AC-91** — WHEN owner 主动选择使已保存配置生效, THE SYSTEM SHALL 在明确告知影响当前运行实例及可能短暂中断后要求确认;只有确认才重建当前已批准的运行版本,不创建代码版本或审批单、不启动待审批或待运行的新版本、不改变资源档位、不触发表结构迁移或覆盖 SQLite 数据。已停止应用不得因保存或差异提示自行上线,仍须原重新上线动作。
+- **AC-92** — WHEN 配置重建被确认受理, THE SYSTEM SHALL 使用用户确认的当前运行版本及已保存配置;确认后版本或配置已变化时,须明确拒绝过期确认并要求重新核对。操作受理后再保存的配置不被该在途实例自动采用,不影响已受理操作的取值;完成后仍按真实使用配置显示未生效变更,重复确认不得创建重复在途实例。
+
+## 3. 边界情况
+
+- **本册只有 CLI 一条提交路径**:发布面「提交发布」对 CLI 应用不可用(§4 决议-2),驳回后修改只能在本地重新 `deploy`;平台内造应用的发布面提交随 PRD-2 走同一管线。
+- **在途 / 待上线两态都不接受新提交**:在途须先撤回;待上线须先手动上线或删除。撤回是 owner 独有动作,管理员不可代行;非待审状态不可撤回。
+- **审的是快照不是工作区**:审批期 owner 本地继续改码或再次 `deploy` 不影响已提交版本(再次 deploy 会因在途审批单被拒)。
+- **申请人是自然人**:审批单申请人 = 应用 owner(资源归属人),不是发起 deploy 的服务账号——服务账号不出现在任何面向人的场景(INV-29);提交人跳过、撤回主体、通知接收方都以 owner 计。
+- **审批人来源解析的空值场景**:owner 无主部门 / 主部门无部门管理员 → 落租户管理员;租户管理员为空且为 Root 租户 → 平台超管;非 Root 租户两者皆空 → 异常态通知管理员、不放行不卡死。**不再叠加任何额外兜底规则**。
+- **提交人跳过与自审**:提交人是候选之一时跳过本人;仅一名管理员且无其他候选时允许自审,审计必须标注——这是唯一允许「自己批自己」的情形。
+- **上线终检失败不是审批失败**:审批单保持通过;应用态待上线(成因:资源不足 / 上线失败);手动上线不重审;手动上线仍失败则原地提示,不改变审批结论。
+- **待上线态可直接删除**(无运行实例);已下线态提交发布通过后不自动启用。
+- **能力声明与可见范围分离**:可见范围不属版本快照、不经审批、即时生效;审批单里的可见范围只是提交时刻的快照,标注仅供参考。
+- **能力总线的两层过滤 ≠ 权限取交集**:声明层是显式白名单收窄,用户层是按当前访问用户的逐资源鉴权(纯替换);结果不多给不少给。**审批期预览是唯一例外**:注入审批人身份、平台能力按 owner 权限放行、计审计(NFR-1.2;INV-36 登记)。
+- **模式 D 委托头绝不用于应用内身份传递**:托管运行期以平台登录态为信任根(app-proxy 注入),复用委托管道会把管理员挡在所有应用之外(伴生 PRD 检查 3)。
+- **能力收回是可区分的错误不是回退**:调用得到带能力名与「已收回」原因的错误、应用其余功能可用;owner 看提示去改声明再发布;不存在「静默回退到旧值」。
+- **应用运行期凭据零感知**:主体类型是「托管应用」而非服务账号(不进服务账号列表、无资源归属人要求);签发 / 重签 / 停用 / 撤销全部随应用态与发布自动发生,无界面;疑似泄漏或滥用一律下线。
+- **临时预览实例的资源**:不占应用运行实例名额,但受编排器整体容量约束;审批出终态或超时即回收,回收后可再拉起;连临时空库。
+- **档位是平台级实体**:跨租户共享、平台超管维护;停用只拦新选择;快照记档位标识、规格取当前值。
+- **密钥扫描是 presence check**:只查有无裸密钥 / 连接串,不判鉴权对错;运行时隔离仍是主保障。
+- **结构演进快照**:改 / 删列迁移前自动留生产数据快照是硬承诺;应用数据表由应用包声明、平台建表(DEV-07 ②)。
+- **`tenant_admin` 来源修正的行为变更**:一切使用该来源的场景与人工流程的实际审批人会从「系统超管」变为「真租户管理员」,release note 显著说明。
+
+---
+
+### 2026-10-23 增量边界
+
+- 业务密钥值是应用私有资产,与不可变版本中的引用名集合分开;预检/审批预览空值与正式注入真实值的边界已由用户确认。声明缺值本身不阻断发布,不意味着可跳过应用自身健康检查。
+- 平台自身不得记录或回显密钥;应用代码主动向自己的业务输出打印或发送该值,属于应用本身的行为,不宣称平台可自动识别并消除所有此类输出;发布扫描、审批、隔离与最小权限仍执行。
+
+- **本次用户修订优先于原自动重启条款**:保存只更新配置;当前运行实例有差异时提示,由 owner 确认后重建;重建可能短暂中断。部署加密 key 轮换仅作运维说明,不新增轮换功能。
+
+## 4. 待澄清决议
+
+> 2026-08-17 用户授权全自动模式:以下各项按建议直接定案,记录理由供追溯;同日 `/sdd-review` 独立审查后增补决议-8 / -9 并修订决议-4 / -5。
+
+- **[决议-1,2026-10-23 修订草稿] 能力总线本版包含模型、知识库及应用密钥最小版。** 依据 2026-10-09 已确认 PRD 决议及本轮收尾授权,替代原 Discovery N4 的密钥排除项;仅 owner 维护本应用第三方值,引用变更仍随发布审批,预检和审批预览不注入真实值(本轮用户明确确认)。具体验收见 AC-56 及本轮新增密钥条款;本次手动生效规格修订已获用户确认,正式设计仍待单独确认。
+- **[决议-2] CLI 导入应用的发布面「提交发布」不可用、提示走 `bisheng deploy`;本册管线只验收 CLI 入口。** 理由:CLI 应用在平台侧无草稿工作区,发布面提交无可冻结的新快照;若允许「重提上一份包」会制造两个语义不同的提交动作。**何时重新考虑**:DEV-06 改码权交接随 PRD-2 落地后,发布面提交对已交接应用开放。
+- **[决议-3] 审批人侧全部界面落在 client 审批中心弹窗(既有处理面),场景配置落 platform 审批中心场景配置。** 理由:PRD-1 §3.0.3 与 Discovery D5 已定处理面在 client;审读视图的文件树 + 只读代码是弹窗内的新增内容区。
+- **[决议-4] 应用运行期凭据 = F049 底座签发、主体类型为「托管应用」(不是服务账号)。**(审查修订:原「每应用一个隐藏服务账号」与 F049 服务账号列表全量可见 / 资源归属人必填相冲突)理由:GOV-08 明令不作产品概念、无管理入口;作为独立主体类型既不污染服务账号列表、也不需要归属人(应用 owner 即归属);F049 底座只需支持第二类主体绑定(F049 spec 范围边界已注明)。**何时重新考虑**:若将来要在管理面查看应用凭据(PRD-1 §5.2 已取消),再评估是否升格为可见主体。
+- **[决议-5] 临时预览实例超时默认 7 天,部署配置项;拉起复用 F054 探活能力与预览入口路由,连一个临时空库。**(审查修订:统一为空临时库,去掉「草稿库」歧义)
+- **[决议-6] 上线终检失败时版本记录终态标注为「待上线」(非 RT-05 三终态之一,作为过程态显示),应用态为待上线;手动上线成功后终态改为「已上线」、不产生新版本记录。** 理由:终态标注反映该版本的最终去向,过程态可显示但不算终态;应用态反映当前可用性;两条正交状态线是 §3.0.2 的既定模型。
+- **[决议-7] MVP-114 首波边界**:管线端点(接收 / 快照 / 预检 / 扫描 / 版本记录 / 审批单 / 终检 / 上线)+ `deploy` / `logs` 服务端权限判定 + GOV-02 预置流程与三前置 + GOV-03 三档 seed 与选档 + WB-14 最小发布面(状态区 / 运营动作 / 入口链接槽位)+ 元信息随提交更新;纵切外:审读视图与临时预览实例、能力总线注入与收回、应用运行期凭据、结构演进快照、WB-15 差异、档位管理 tab、`withdraw` 守卫、事件触达全表——见 [../mvp-114-path.md](../mvp-114-path.md) §2。
+- **[决议-8] 上线拉起 / 探活因非容量原因失败 → 应用态待上线(成因:上线失败),可重试手动上线或重新提交。**(审查新增)理由:PRD 只定义容量成因;把「上线失败」作为待上线态的第二种成因(界面显示具体成因)比引入新应用态更贴合 §3.0.2 两条正交状态线,且复用手动上线路径。
+- **[决议-9] 预检 / 扫描失败的提交不进版本列表;正式版本记录在通过预检与扫描时落。**(审查新增)理由:RT-05 三种终态覆盖不了「预检失败」,DEV-04 验收 4 又要求预检失败不产生审批单;把失败提交挡在版本列表之外,版本列表只含进过审批流的版本,读者不必区分「失败版本」。
+- **[决议-10] 轻量档规格由 1C/2G 下调为 0.5C/1G(2026-09-09,AC-44 数值修订)。** 标准档与性能档不动。理由:①一个轻量应用占满一整核,让 **CPU 配额闸而不是机器本身**成为上线瓶颈——8 核机器按 0.8 超售系数只能同时在线 6 个应用,而 114 上实测五个在线应用合计只用 0.05% CPU、98 MiB 内存;②代价极小,实测冷启动到健康就绪,表单看板类应用 1 核 0.5 秒、0.5 核 0.8 秒,带 pandas / openpyxl / fastapi / sqlalchemy 的重依赖应用 1 核 1.25 秒、0.5 核 2.4 秒,而健康检查窗口是 10 秒首探、3 次失败判死,0.5 核对重应用仍有 4 倍余量;③2 核与 1 核冷启动无差别(Python 启动是单线程的),说明这一档原本的取值并非按启动性能定的。**这是对已定稿 AC-44 的一次自觉偏离,按 SDD-Guide 偏离再确认规则登记。** 影响面:F054 `DEFAULT_TIERS` 常量与 `_resolve_tier` 兜底值、F055 seed 断言与预检 build intent 断言、design D11 与坑 27、tasks T015 / T017 的回写记录、`docs/architecture/14-app-factory-deployment.md`、`docker/bisheng/config/config.yaml` 注释样例、`mvp-114-path.md` 的容量估算。**存量部署不受影响**:seed 按 code 幂等跳过,已播种的环境要改只能更新 `resource_tier` 行,新规格自各应用下一次发布或重新上线起生效(AC-64)。**何时重新考虑**:档位管理 tab(AC-45 / T065)落地后,三档跨度从等比变成 0.5 / 2 / 4,届时一并复核是否需要补一档或调整标准档。
+
+---
+
+## 5. 设计与实现(指针,不复制)
+
+> **本节刻意不写内容。** spec 不承载 How。
+
+| 你想知道 | 去哪看 |
+|---|---|
+| 为什么这么实现(决策 + 备选 + 何时该推翻):快照存储形态、审批场景 payload、能力声明格式、注入契约、运行期凭据主体建模 | design.md §3 |
+| 关键约束(审批模块契约 / 对象存储 / 双 DB / 多租户 / 档位平台级) | design.md §2 |
+| 今天的数据流、模块职责、字段 / API / 文件名约定 | design.md §4 |
+| 代码里看不出的坑(`approver_resolver` tenant_admin 近似、Root 租户恒空、seed 只对默认租户、`withdraw` 无终态守卫、审批首节点通知需场景侧自发、detail_snapshot 无 schema …) | design.md §5 |
+| 对外契约(管线接收接口、审批状态接口、能力声明格式、注入契约)、依赖 | design.md §6 |
+| 任务拆解、[MVP-114] 波次、文件清单 | tasks.md |
+| 审批模块权威参考 | `.claude/skills/approval-module`(改代码必须同步改 skill) |
+
+---
+
+## 相关文档
+
+- 设计真相: [design.md](./design.md)(尚未编写)
+- 执行与落档: [tasks.md](./tasks.md)(尚未编写)
+- 版本契约: [features/v3.0.0/release-contract.md](../release-contract.md)(表 1 ResourceTier / 「应用发布」审批场景预置候选 → 本 Feature 定稿后转正;候选 INV-34 随本 spec 转正;INV-36 随 F052 spec 转正,本 spec 补其审批例外)
+- Spec Discovery: [features/v3.0.0/000-prd1-discovery/discovery.md](../000-prd1-discovery/discovery.md) §2.4 / §2.5 / §2.6 / §2.8
+- 上游 / 姊妹 Feature: [../054-app-domain-runtime/spec.md](../054-app-domain-runtime/spec.md)(领域对象、状态动作、运行时、预览入口)· [../049-openapi-auth-baseline/spec.md](../049-openapi-auth-baseline/spec.md)(凭据底座与资源归属人)· [../056-app-square-governance/spec.md](../056-app-square-governance/spec.md)(可见范围区、审计面)· [../053-dev-cli-skills/spec.md](../053-dev-cli-skills/spec.md)(CLI 命令侧)
+- UI 参考: [features/v3.0.0/000-prd1-discovery/ui-demo/](../000-prd1-discovery/ui-demo/)(以 PRD 为准、demo 为参考)
+- PRD: `docs/PRD/3.0-release/3.0 应用工场 PRD-1 专业开发者通道与应用运行时.md` v2.0;`docs/PRD/3.0-release/3.0 应用工场 PRD-2 造应用工作台.md`(WB-14 / WB-15 完整形态)
diff --git a/features/v3.0.0/055-app-publish-pipeline/tasks.md b/features/v3.0.0/055-app-publish-pipeline/tasks.md
new file mode 100644
index 0000000000..94e4deeb51
--- /dev/null
+++ b/features/v3.0.0/055-app-publish-pipeline/tasks.md
@@ -0,0 +1,1282 @@
+# Tasks: 发布管线、预置审批流、版本记录、资源档位与能力总线
+
+> **2026-10-23 增量 SDD**:用户已确认手动生效修订规格与单表简化设计;进入本轮任务拆解和实施;旧三表自动生效方案已替换。旧任务与旧评审状态仅描述历史批次;不代表本次增量已拆任务、编码或验收。本轮只做必要定向验证,不跑全量测试。旧任务的行号、计数和实现阶段为历史记录,当前实现快照以 design.md 为准。
+
+**关联规格**: [spec.md](./spec.md)(92 条 AC;手动生效修订已确认)· [design.md](./design.md)(D1–D20;本轮设计待单独确认;新接口合同 §4.4)
+**版本**: v3.0.0
+**纵切**: [mvp-114-path.md](../mvp-114-path.md) **§6 MVP-核心**(预算受限版,本轮裁剪基准)与 §2 F055 行——**Wave 1–3 全部标 `[MVP-核心]`**,Wave 4–7 为 release 必做但本轮顺延,只列标题 / 文件 / 测试载体 / 覆盖 AC,不展开逻辑。
+**§6 字面读法的两处对齐**:① §6 F055 顺延栏写「`withdraw` 守卫**以外**的事件触达全表」→ 守卫(AC-22)留在 MVP 内 → **T051 归 Wave 3.9**(修点一行 + 一个测试文件,成本极低,无理由为省一行而偏离基准);② 顺延栏其余项(审读视图 / 预览 / 能力总线 / 结构演进 / 版本差异 / 档位 tab)照 §6 顺延。
+**代码事实口径**: 本文所有 `文件:行号` 沿用 design.md(`3.0-vibe`,2026-08-17 核实,路径以 `src/backend/bisheng/` 为根;前端另注 `platform/` = `src/frontend/platform/src/`、`client/` = `src/frontend/client/src/`)。**行号会漂移、符号名不会——落地前一律以符号名重定位。**
+
+---
+
+## 状态
+
+| 步骤 | 状态 | 备注 |
+|------|------|------|
+| spec.md | ✅ 手动生效修订已评审、用户确认 | 用户原文“确认修订规格,更新简化设计”;92 条 AC;批准 hash 见简化设计评审 JSON |
+| design.md | ✅ 单表简化设计已评审、用户确认 | 原文“确认 F055 简化设计”;批准SHA256 `2ef7247c7fcc71d1e2e44b34a9df8dfe865135adc66c249166a29202e29402d6`;F054-B1/F056-S1另待修 |
+| tasks.md | ✅ 已拆解(2026-08-17) | 本文;70 任务 / 7 Wave / 53 条 `[MVP-核心]`(2026-08-17 独立审查 14 条修订:Celery 登记 / 组合根接线 / 跨 Wave 执行序 / 档位依赖链 / 图标落地 / 16207 闸 / T044·T027 拆分 / 路径与 i18n 补齐 / 建桶接线 / 顺延任务测试载体 / 追溯表订正 / T051 上提) |
+| 实现 | 🚧 进行中 | **52 / 70 完成**(Wave 1 + Wave 2 + **Wave 3.1–3.5**(T024–T043)+ **3.8**(T051)+ **T050**(上游回写)落地。2026-08-17 落 T024–T041;**2026-08-19 收口批**核实并勾上 T042 / T043(事件触达——实现早于勾选,服务与两个调用点在 3.1–3.4 那批就已随 `publish_approval_service` / `publish_online_service` 落地)、完成 T051(`withdraw` 终态守卫 + 18118)。`pytest test/app_publish` **343 passed**(2026-08-19 实测,含新增 `test_publish_notification.py` 14 例 + `test_withdraw_guard.py` 15 例)。⚠️ `test/approval` 有 13 例失败,逐条归因为**本批之前就红的既有漂移**(4–5 例本地 MySQL 未起、6 例 fake 与被测 API 漂移、2 例断言漂移),无一走 `withdraw_instance`——详见 T051 偏差记录与 followup)。剩余 Wave 3:3.6 只剩 **T044b**(另批核实;**T044a / T045–T048 于 2026-09-10 按代码证据核实并勾选**,逐条证据见各任务末行)· 3.9 只剩 **T049**(114 部署与手动验证;T050 上游回写已于 2026-08-19 完成并勾选)。偏差处理见 design.md 顶部调整原则 + `docs/SDD-Guide.md` §3-§4 |
+
+---
+
+## 开发模式
+
+**按 Wave 组织任务**:
+- **Wave 1**(T001–T007)= 基础设施,无测试配对,排最前,彼此可并行。
+- **Wave 2**(T008–T023)= 管线服务(manifest / 包 / 扫描 / 档位 / 版本 / 预检 / 接收 / 阶段机),Test-First 成对。
+- **Wave 3**(T024–T051)= 预置审批流与三项阻塞前置、审批终态与上线动作、`deploy`/`logs` 权限判定与状态接口、前端最小面、`withdraw` 终态守卫、114 手动验证与上游回写。
+- **Wave 4–7** = release 必做但本轮顺延(design §8 的优先级顺序:审读视图 / 预览实例 → 能力总线 / 运行期凭据 → 结构演进 → 版本差异 → 档位 tab)。**只列标题 + 文件 + 测试载体 + 覆盖 AC**,展开在开工那一刻做。
+
+**⚠️ 跨 Wave 执行序(Wave 编号 ≠ 拓扑序,照 Wave 顺跑会当场卡住)**:Wave 2 的 `accept()` / `run_pipeline()` 需要 Wave 3.2 的 `publish_approval_service`(在途闸 / 待上线闸 / Gate 提交),而 Wave 3.4 的 T038 又回头依赖 T021 / T023 —— **不拆任务、只按下面的真实拓扑序执行**:
+
+```
+T001–T007(Wave 1,可并行)
+  → T014/T015 → T008/T009 → T010/T011 → T012/T013 → T016/T017 → T018/T019   ← Wave 2 前段(档位先行:T009 直接调 resolve_tier)
+  → T024/T025 → T026/T027a/T027b → T028/T029 → T030/T031                    ← Wave 3.1–3.2 提前,产出 publish_approval_service
+  → T020/T021 → T022/T023                                                    ← 回到 Wave 2 后段(accept / run_pipeline)
+  → T032/T033 → T034/T035 → T036/T037 → T038/T039 → T040/T041 → T042/T043    ← Wave 3.3–3.5
+  → T044a/T044b → T045 → T046 → T047 → T048 → T051 → T049 → T050             ← Wave 3.6–3.9
+```
+
+测试任务可先于其依赖的实现任务落盘(红测即预期),**实现任务一律按 `依赖:` 行排**。
+
+**后端 Test-First**:测试任务先于其配对实现任务,`覆盖 AC` **逐条列举**(禁 `AC-01~AC-05` 范围写法)。基础设施任务(ORM / Alembic / 错误码 / Settings / 审计登记 / conftest)无测试配对、排最前。单测落 `src/backend/test/app_publish/`(**不放 `test/` 根**),`asyncio_mode=auto`。集成测试连 test 中间件(MySQL / Redis / MinIO / OpenFGA)在 CI 跑;DM8 在 105 回归。
+
+**编排器一律 mock**:Wave 2–3 的后端测试全部 monkeypatch F054 `orchestrator_client`(build / build_status / probe / admission / deploy),**不连真 docker**;真拉起只在 T049 的 114 手动验证里出现。
+
+**前端**:Platform / Client 分列,附「手动验证」步骤(Playwright 未落地)。platform `react-query` 已被 eslint 冻结(design K11 ①)→ 新面用 `platform/src/util/hook.ts:215 useTable` 或裸 `useState + useEffect`;新增 i18n key 三语同 PR;**触碰即还**:改到的存量文件里的冻结中文违规同 PR 抽键并 `pnpm lint:prune`(根 AGENTS.md)。
+
+**自包含任务**:每个任务内联文件、逻辑、依赖、AC 覆盖;设计论证指向 design §X / D-x / 坑-x,**不复制**(避免第三处漂移)。
+
+**⚠️ 一项待 ★ 确认、本轮按 spec 字面顺序实现**:密钥扫描的执行位置(design D5)。本文一律按 **spec AC-01 / F053 AC-31a 的字面顺序** 落地:`precheck_manifest → precheck_build → precheck_probe → secret_scan → 审批单`。「提前到构建之前」同时推翻两份已 ★ 确认的 spec,**不得在实现期自行调换**;确认通过后只需调换 `PIPELINE_STAGES` 元组里两个 step 的注册顺序,并同批改 F055 spec AC-01 / F053 spec AC-31a / design D4 与 §4.1。
+
+**⚠️ 错误码一码一义(design §4.2 ⑧ 的两条红线,落码前对照)**:`16225` **只**归「审批场景未启用」;构建期 / 上线期容量不足是 `16226`。档位失败只有 `16223` 一个码("不存在或已停用"),成因用 `details.reason: "not_found" | "disabled"` 表达,**不拆码**。
+
+**⚠️ 错误码段 162 的分配已在上游完成**(design K9):`release-contract.md` 的「已分配模块编码」表与 `docs/constitution.md` C5 均已写死 `162 = app_factory · F055 段`(F054 落码时一并写入)。**不要再去"修正"那两张表**——只需新建 `common/errcode/app_publish.py` + 三语 `api_errors`。
+
+**跨 Feature 副作用登记**(release-contract 表 1 / 清单检查项 17):
+- **T025**(`approval/domain/services/approver_resolver.py` 的 `tenant_admin` 来源改真租户管理员 + Root 回退)—— **行为变更,对一切使用该来源的既有场景(频道订阅 / 知识空间加入)与人工配置立即生效**(AC-21),release note 必须显著声明;T024 的既有场景回归测试是它的护栏。
+- **T027b**(`tenant/domain/services/tenant_service.py` `acreate_tenant` 与 `tenant_mount_service.py` `mount_child` 各挂一次 seed 钩子)—— 改的是 Tenant 领域的创建流程(归 F011/F012 域),只**追加** try/except 包住的一步,不改其它步骤;两条路径缺一即漏(坑 3)。**独立成任务、单独 review**(T027a 只做 seed 参数化与新场景条目,不碰 Tenant 领域)。
+- **T029**(`approval/domain/services/approval_registry.py` 加 preset + `approval_runtime_handler_factory.py` 加分支)—— 审批模块注册表扩展,漏工厂分支 = 审批通过后应用永不上线(K1 ③)。
+- **T035**(`approval/domain/services/approval_center_service.py` 新增 `cancel_instance_by_business`)—— 新增审批模块公共 API,**不改** `withdraw_instance` 既有行为(守卫是 T051,Wave 3.8)。
+- **T035**(`main.py` lifespan `:82` 与 `worker/main.py` `on_worker_init`(`celeryd_after_setup`,`:80-87`)各调一次 `app_publish.composition.register()`)—— **组合根接线是两处、不是一处**:只挂 API lifespan 则 Celery worker 进程里删除钩子与场景 handler 从未注册,审批通过后工厂找不到 handler(K1 ③)、应用删除不取消在途单(AC-35 破),且形态是"API 里测都对、生产不生效"。
+- **T023**(`worker/__init__.py` 顶部 `# register tasks` 显式 import 块追加一行)—— celery app 是 `Celery("bisheng", include=["bisheng.worker"])`(`worker/main.py:22`),**所有任务靠该 import 块注册**(审批 outbox 亦然);漏登记 = `apply_async` 后 worker `NotRegistered`,表现为「deploy 返回成功、状态永远卡在 `received`」。
+- **T006 / T045**(`database/models/audit_log.py` 两处白名单 + platform `controllers/API/log.ts` + `bs.json` 三语)—— 审计可见性四处 lockstep,**审计对象的查询面归 F056、写入归本 Feature**(坑 21)。
+- **T015**(回写 F054 `app_runtime/domain/constants.py` 的 `DEFAULT_TIERS` 数值与第三档名称)—— F054 领域常量,档位实体归 F055(表 1),数值以本 spec AC-44 为准(坑 27)。
+- **T044b**(platform `BuildPage/hostedApp/tabs/PublishTab.tsx` 填 slot)—— **F054 T067 已交付的应用态徽标 / 入口链接 / 下线 / 重新上线 / 手动上线三按钮一律不重做**,F055 只填 slot 内容并把 `can.manual_publish` 等条件经 props 下传(design D15)。
+- **T002**(`database/models/resource_tier.py` 落共享层)—— 与 F054 只读契约相关:模型放共享层就是为了不让 `app_runtime` 反向 import `app_publish`(C1 / D16);**arch-guard 不管 domain 层跨模块方向,靠 review 维持**。
+- **T050**(回写 F053 spec AC-32 / F054 design + tasks;同 PR 更新 `.claude/skills/approval-module/SKILL.md`)。
+
+**依赖 F054 的任务ID(跨 Feature,签名变更须回头改本文)**:F054 **T001**(`app` / `app_version` 模型与 DAO)· **T006**(`AppState` 枚举 + `DEFAULT_TIERS`)· **T047**(`orchestrator_client` 门面)· **T049**(`AppProvisionService.create_draft` / `stage_version` / 取版规则)· **T051**(五个状态动作 + `lifecycle_hooks`)· **T053**(`AppMetaService.update_meta`)· **T057**(`/api/v1/apps/*` 端点)· **T064**(`useHostedAppActions`)· **T066/T067**(详情页壳与发布 tab slot)。依赖开放 API 底座的(**2026-09-10 起为 beta2 F053**,原 F049 T002 / T003 / T005 归档):端点 `open_api_scope` marker + `router_rpc` 的 `verify_open_api_access`(取代 `open_api_subject` 工厂)· `api_credential.SERVICE_ACCOUNT_KEY_PREFIX` / `PERSONAL_TOKEN_PREFIX` / `KEY_SECRET_LENGTH` 常量、`OpenApiPrincipal.resource_owner_user_id` · `app:manage` 位注册(`open_api/domain/scopes.py`)。
+
+---
+
+## Tasks
+
+### Wave 1 · `[MVP-核心]` 基础设施(无测试配对,排最前)
+
+- [x] **T001**: `[MVP-核心]` ORM `app_deployment` 模型 + DAO + 租户感知登记
+  **文件**: `src/backend/bisheng/app_publish/domain/models/app_deployment.py`(新), `src/backend/bisheng/core/database/tenant_filter.py`(`_TENANT_AWARE_MODEL_MODULES:39` 登记 `bisheng.app_publish.domain.models`)
+  **逻辑**: 按 design §4.2 ⑤ 建表(继承 `SQLModelSerializable`,含 `tenant_id` / `create_time` / `update_time`):`id`(str PK) · `tenant_id` · `app_id`(str,首发时在 `create_app` 后回填) · `owner_user_id`(int,自然人) · `submitted_by_user_id`(int,服务账号) · `version_id`(str null) · `approval_instance_id`(int null) · **`stage` VARCHAR(32) 显式列** · **`status` VARCHAR(16) 显式列** · `code_object_key` · `manifest`(`JsonType`) · `tier_code` · `failure`(`JsonType`) · `scan_result`(`JsonType`)。**凡需 SQL 筛选的一律拆显式列**、JSON 列禁 `JSON_EXTRACT` / `JSON_CONTAINS`(C2 / K6)。DAO `AppDeploymentDao.{acreate, aget, aget_active_by_app, aadvance_stage, aset_failed}`——`aadvance_stage` 是**单行 UPDATE**;**禁批量 UPDATE / DELETE**(租户监听器只拦 SELECT,坑见 memory `reference_tenant_filter_in_list_trap`)。`stage` 取值集合 `{received, secret_scan, precheck_manifest, precheck_build, precheck_probe, version_recorded, approval_created, approved, publishing, online, pending_online}`、`status` 取值 `{running, waiting_approval, succeeded, failed}`(D1)。
+  **回滚**: 建表 DDL 在 T003;本任务纯模型,回滚 = 删文件 + 撤销 tenant_filter 登记。
+  **依赖**: 无
+
+- [x] **T002**: `[MVP-核心]` ORM `resource_tier` 模型(**落共享层**)+ DAO
+  **文件**: `src/backend/bisheng/database/models/resource_tier.py`(新), `src/backend/bisheng/core/database/tenant_filter.py`(登记 `bisheng.database.models.resource_tier`,并在登记处写一行注释「无 `tenant_id` 列,登记只为 metadata import、不受自动过滤」)
+  **逻辑**: 列:`id`(PK) · `code`(unique,`light` / `standard` / `performance`) · `name` · `cpu_millicores`(int) · `memory_mb`(int) · `description` · `enabled`(bool) · `sort_order` · `create_time` / `update_time`。**用整数毫核与 MB,不用浮点 vCPU**(DM8 与 JSON 往返会给出 `0.30000000000000004`,D11)。**平台级、无 `tenant_id` 列**(AC-44 跨租户共享,K6 ②)。DAO `ResourceTierDao.{alist, aget_by_code, acreate, aupdate_row}`——**不提供 delete**(AC-47 的前提:`app_version.tier_id` 是历史快照引用,删档会让老版本重新上线时解析不出规格,D11)。
+  **⚠️ 为什么不落 `app_publish/domain/models/`**:`ResourceTier` 归 F055、**F054 只读**(release-contract 表 1 / F054 design D11),放进 `app_publish` 会逼 `app_runtime` 反向 import,双向依赖当场成立且**没有任何 arch-guard 规则会拦**(design C1 / D16 的订正)。业务逻辑仍全在 T015 的 `ResourceTierService`。
+  **回滚**: 同 T001(DDL 在 T003)。
+  **依赖**: 无
+
+- [x] **T003**: `[MVP-核心]` Alembic revision:`app_deployment` + `resource_tier` 两表
+  **文件**: `src/backend/bisheng/core/database/alembic/versions/v3_0_0_f055_app_publish_tables.py`(新)
+  **逻辑**: **DDL-only**(`core/database/alembic/AGENTS.md`);`down_revision` 取 `uv run alembic heads` 的**唯一头**(F054 的建表 revision 落地后头会变,落码时现取);**不传 `mysql_charset` / `mysql_collate`**(DM8 双方言,C2);JSON 列用 `JsonType` 对应方言类型(DM8 落 CLOB);索引:`resource_tier.code` 唯一、`app_deployment(app_id, status)`、`app_deployment(tenant_id, create_time)`。**seed 不写在 revision 里**(档位 seed 归 T015 的 `init_default_data`,幂等按 `code`)。
+  **回滚**: `downgrade()` 按 `app_deployment → resource_tier` 顺序 drop(无外键,仍按此序);**回滚前提**:`app_version.tier_id` 会变成悬空引用 → downgrade 前须确认无托管应用在运行,说明写进 revision docstring。
+  **依赖**: T001, T002
+
+- [x] **T004**: `[MVP-核心]` 错误码 162 段 + 三语 `api_errors`
+  **文件**: `src/backend/bisheng/common/errcode/app_publish.py`(新,**勿写进 F054 的 `app_factory.py`**), `src/frontend/packages/locales/src/api_errors/{zh-Hans,en,ja}.json`(三语视为一组)
+  **逻辑**: 按 design §4.2 ⑧ 定义 `AppPublishError(BaseErrorCode)` 家族:`16201` 包超上限 · `16202` 包解析失败 / 非法路径条目 · `16203` 缺 `bisheng-app.yaml` · `16205` 该应用归属他人 · `16207` 工场运行时层未启用 · `16221` manifest 校验失败 · `16222` `runtime` 不支持 · `16223` 档位不存在或已停用 · `16224` 能力声明引用不可解析 · **`16225` 审批场景未启用** · **`16226` 运行环境容量不足** · `16227` 依赖构建失败 · `16228` 启动探活失败 · `16229` 结构变更未确认 · `16230` 能力声明含密钥引用(本版不支持)· `16231` 本环境未启用能力总线 · `16241` 密钥扫描命中 · `16251` 已存在在途审批单 · `16252` 待上线态不接受新提交 · `16253` 版本记录不存在 · `16254` 仅 owner 可执行 · `16255` 当前应用态不允许该动作 · `16273` 该能力已被收回(Wave 5 才有写入方,一次登记到位)· `16274` 未在能力声明中的能力 · `16291` 运行期凭据主体不可用。三语文案落 `packages/locales`(生成物 `platform/public/locales/*/api_errors.json` / `client/src/locales/*/api_errors.gen.json` **由脚本生成、不手改**,CI `pnpm check-i18n`)。**不改 `release-contract.md` 与 `constitution.md` 的分配表**(K9:已是对的,改动只制造 diff 噪声)。
+  **依赖**: 无
+
+- [x] **T005**: `[MVP-核心]` `settings.app_runtime` 新增五个键
+  **文件**: `src/backend/bisheng/core/config/app_runtime.py`(F054 已建的配置块,**增量加字段**)
+  **逻辑**: 加 `max_package_mb: int = 50` · `max_unpacked_mb: int = 200` · `max_package_entries: int = 20000`(D2 三闸,**本期就做**——F053 AC-32 要求 CLI 按"部署配置的上限"自查,上限若只是后端常量 CLI 只能硬编码)· `default_tiers: dict | None = None`(档位出厂规格覆盖,AC-44「规格初始默认值为部署配置项」)· `preview_ttl_days: int = 7`(决议-5,Wave 4 才消费,一次加到位避免二次改同一文件)。**一律挂进 F054 已开的 `app_runtime` 块、不开顶层键**(K10)。
+  **⚠️ 部署顺序**(`common/services/config_service.py:91-107` 对未知顶层键直接 `raise KeyError`):**先发代码 → 再改 `config.yaml` → 再重启**,顺序反了直接拒启。写进 T049 的部署清单。
+  **依赖**: 无
+
+- [x] **T006**: `[MVP-核心]` 审计事件族 `app.release.*` 后端 lockstep 登记
+  **文件**: `src/backend/bisheng/database/models/audit_log.py`(`_UI_VISIBLE_V2_ACTIONS`,元组 `:193`–`:259`)
+  **逻辑**: 追加 design §4.2 ⑥ 的全部 action:`app.release.{submit, precheck_failed, scan_blocked, version_created, approval_created, approval_exception, self_approval, approved, rejected, withdrawn, cancelled, online, pending_online, manual_publish, capability_declared, rollback}`(后两者本轮写入方少但**一次登记到位**,避免二次改同一处)。**`_V2_NAMESPACE_TO_ACTION_PREFIX` 的 `"app": "app."` 已由 F054 落码建好(`:266`),不要重复建命名空间**。**不在 `app.publish.*` 前缀下追加**——F054 已把 `app.publish` 用作"上线动作"的 action 名,同前缀混用会让审计筛选与命名空间映射两头别扭(D12)。
+  **⚠️ 四处 lockstep 缺一即"写库了但审计页看不到"**(坑 21):本任务是第 1 处;第 2–4 处(platform `controllers/API/log.ts` 的 `actions` / `getModulesApi` + `bs.json` 三语的 `log.systemIdEnum` / `log.eventTypeEnum`)在 **T045**,**必须与本任务同 PR**。
+  **依赖**: 无
+
+- [x] **T007**: `[MVP-核心]` pytest 基础设施 `test/app_publish/conftest.py`
+  **文件**: `src/backend/test/app_publish/conftest.py`(新), `src/backend/test/app_publish/fixtures/minimal_app/`(新,最小可用应用包素材:`bisheng-app.yaml` + `main.py` + `requirements.txt`)
+  **逻辑**: fixtures:`service_account_principal`(构造 `OpenApiPrincipal`,可参数化 `scopes` 与 `resource_owner_user_id`,F049 T005 的形状)· `owner_user` / `dept_admin_user` / `tenant_admin_user` / `super_admin_user`(**审批人解析矩阵四类身份;`tenant_admin_user` 必须是非超管的真租户管理员**,否则 AC-21 的护栏测不出来)· `app_factory`(复用 F054 conftest 的同名 fixture,直接经 DAO 落 `app` + `app_version`)· `deployment_factory` · **`tier_seed`**(跑一次 T015 的 `seed_resource_tiers()` 落三档;**T008/T009 的 `tier` 解析测试与 T014 共用同一份 seed**,避免两处各造一份档位数据)· `fake_orchestrator`(monkeypatch F054 `orchestrator_client` 的 `build` / `build_status` / `probe` / `admission` / `deploy`,返回 design §4.2 ① 的响应形状,可编程成功 / 容量不足 / 失败三态)· `fake_minio`(monkeypatch `MinioStorage.put_object` / `get_object` 到临时目录,避免单测连真 MinIO)· `tarball_factory`(按参数生成 tar.gz:正常包 / 缺 manifest / 含符号链接 / 含 `..` 穿越 / 含硬链接 / 含设备文件 / 含 FIFO / 超条目数 / 解包超大 / 含密钥样本)· `audit_sink`(捕获 `AuditLogDao.ainsert_v2` 调用)· `approval_env`(跑一次 T027a 的 seed,让 Gate 不抛 `ApprovalScenarioDisabledError`)。**autouse fixture 清 `HTTP(S)_PROXY` / `ALL_PROXY` env**(缺 `socksio` 会整批误报 ERROR,memory `reference_local_backend_pytest_socks_proxy`)。fixture 体内**惰性 import** 业务 Service,避免尚未落地的模块让整包收集失败。
+  **依赖**: T001, T002
+
+---
+
+### Wave 2 · `[MVP-核心]` 管线服务(Test-First 成对)
+
+- [x] **T008**: `[MVP-核心]` AppManifest 校验矩阵测试
+  **文件**: `src/backend/test/app_publish/test_app_manifest.py`(新)
+  **逻辑**: 断言 `AppManifest` 的解析与拒绝矩阵,以及失败五元组 `{stage, code, message, details, hints}` 的结构(AC-11 的唯一形态)。
+  **测试**: `test_missing_required_name_runtime_port_each_rejected`(三个必填项各缺一次 → `16221` 且 `details` 逐字指出缺哪个字段)→ AC-07, AC-11 / `test_unknown_field_rejected_with_suggestion`(`extra='forbid'`;`runtimee` → 提示"是不是想写 runtime")→ AC-07 / `test_runtime_not_in_local_enum_rejected_16222` → AC-07 / `test_port_out_of_range_rejected` → AC-07 / `test_manifest_version_ahead_hints_upgrade_platform` → AC-07 / `test_yaml_uses_safe_load_rejects_python_object_tag`(`!!python/object` 不得被反序列化)→ AC-07 / `test_tier_absent_defaults_to_light` → AC-46 / `test_tier_unknown_or_disabled_rejected_16223_with_details_reason`(`details.reason ∈ {not_found, disabled}`,**不拆码**)→ AC-46 / `test_capabilities_non_empty_rejected_16231`(MVP 期声明非空即拒并提示「本环境未启用能力总线」,**不静默忽略**,D16)→ AC-07 / `test_secret_reference_rejected_16230` → AC-56 / `test_database_tables_declared_gives_hints_not_reject`(本期允许声明但不建表、只给 `hints`,D3)→ AC-07 / `test_failure_tuple_has_machine_and_human_forms`(`code` + `stage` + `details` 机读、`message` + `hints` 人读)→ AC-11
+  **覆盖 AC**: AC-07, AC-11, AC-46, AC-56
+  **依赖**: T007, T015(`tier_seed` fixture + `resolve_tier` 已存在)
+
+- [x] **T009**: `[MVP-核心]` AppManifest schema + 本地引用校验实现
+  **文件**: `src/backend/bisheng/app_publish/domain/schemas/app_manifest.py`(新,**AppManifest 权威 schema,release-contract 表 1 归本 Feature**), `src/backend/bisheng/app_publish/domain/services/manifest_validator.py`(新)
+  **逻辑**: pydantic v2 模型 + `extra='forbid'`(D3,零新依赖;`ValidationError.errors()` 的 `{loc,msg,type}` 三元组直接映射 AC-11 双形态)。字段按 design §4.2 ③ 表:必填 `name`(1–64) / `runtime`(枚举) / `port`(1–65535);可选 `description` / `icon` / `slug` / `tier`(默认 `light`) / `capabilities{models[],knowledge_bases[]}` / `database.tables[]` / `egress.domains[]` / `manifest_version`(默认 1)。`SUPPORTED_RUNTIMES` **本地枚举常量**(MVP 期仅 `python3.11`)——它是 F054 manager `supported_runtimes` 的**副本**,与 manager 的复核**下沉到 T019 的异步段**(同步段不发 RPC,D4);在常量旁写一行注释「F054 加运行时模板时必须同批改这里」。`manifest_validator`:YAML `yaml.safe_load`(**禁 `full_load` / `unsafe_load`**)→ pydantic → 本地引用校验(`tier` **直接调 T015 的 `ResourceTierService.resolve_tier(code)`** · 能力声明格式 · 密钥引用出现即拒 16230 · `capabilities` 非空即拒 16231)→ 失败一律构造五元组。
+  **⚠️ 档位解析只有一份实现**:`manifest_validator` **不得自己再写一遍**"查表 + 判 `enabled` + 拼 `details.reason`"——两份实现必然在 `details.reason ∈ {not_found, disabled}` 的口径上分叉(AC-46 / AC-47 的判据就落在这个字段上)。`resolve_tier` 抛出的 `16223` 原样上抛,`manifest_validator` 只负责包成五元组。
+  **测试**: T008 全部通过。
+  **覆盖 AC**: AC-07, AC-11, AC-46, AC-56
+  **依赖**: T008, T002, T004, **T015**
+  **⚠️ 编号 ≠ 执行顺序**:本任务依赖 T014/T015(`ResourceTierService.resolve_tier` + 三档 seed)的产出,实际排在它们之后;编号保留在此只为与 T008 相邻。
+
+- [x] **T010**: `[MVP-核心]` 包接收、解包安全闸与快照存储测试
+  **文件**: `src/backend/test/app_publish/test_package_service.py`(新)
+  **逻辑**: 用 `tarball_factory` 造六类恶意条目与三类超限包,断言全部被拒且错误码正确;断言快照键布局与"不整包进内存"。
+  **测试**: `test_reject_absolute_path_entry` / `test_reject_dotdot_traversal` / `test_reject_symlink` / `test_reject_hardlink` / `test_reject_device_file` / `test_reject_fifo`(**tar 比 zip 多后四类**,仓内唯一先例 `skill_store.py:171-176 _safe_rel_path` 只防前两类,坑 15)→ 各自 `16202` / `test_reject_over_max_package_mb_16201` → AC-02 / `test_reject_over_max_unpacked_mb`(tar bomb)→ AC-02 / `test_reject_over_max_package_entries` → AC-02 / `test_snapshot_key_layout_matches_f054`(`apps/{app_id}/versions/{version_id}/code.tar.gz`,`version_id` 在**接收时**生成、预检通过后被 `app_version` 复用;**不先写 deployments 键再 server-side copy**——那是幻觉优化,memory `project_linsight_skill_object_storage`)→ AC-02, AC-43 / `test_upload_never_read_into_memory`(断言走 `put_object(file=Path(...))` 而非 `await file.read()`)→ AC-02 / `test_bucket_ensured_idempotently_on_first_use`(`minio_storage.py:291 _init_bucket_conf` **只建 public / tmp 两桶**,坑 14 → `bisheng-apps` 由 `package_service` 自己 ensure;断言首次 `store_package` 会建桶、第二次不重复建、且**不修改 `_init_bucket_conf`**)→ AC-02 / `test_snapshot_immutable_and_retrievable`(快照可完整取回、内容不可改)→ AC-43 / `test_orphan_cleanup_on_next_deploy_same_app`(超 7 天且无 deployment 引用的孤儿键被清;**不起 Beat**,D2)→ AC-02
+  **覆盖 AC**: AC-02, AC-43
+  **依赖**: T007, T005
+
+- [x] **T011**: `[MVP-核心]` `package_service` 实现
+  **文件**: `src/backend/bisheng/app_publish/domain/services/package_service.py`(新)
+  **逻辑**: `store_package(upload, app_id, version_id)`:`UploadFile` **落临时盘后** `put_object(file=Path(...))`(走 `fput_object` + `asyncio.to_thread`,`minio_storage.py:359-407`),键 `apps/{app_id}/versions/{version_id}/code.tar.gz`,桶 `bisheng-apps`(**独立桶,不挂 nginx location、不设匿名策略**——`src/frontend/nginx.conf:49` 的 `location ~ ^(/workspace/bisheng|bisheng|tmp-dir)/` 会把公共桶任意 key 匿名转发出去,K5 / 坑 13);**桶由 `package_service` 内部 `_ensure_bucket()` 幂等保证**——首次 `store_package` 时 `create_bucket_sync(bucket_name='bisheng-apps')`(存在即 no-op),进程内加一个 `_bucket_ready` 标志避免每次调用都发 `bucket_exists`。**不改 `minio_storage.py:291 _init_bucket_conf`、不挂 lifespan**(坑 14:那里只建 public / tmp 两桶且给 public 桶设了匿名读策略;把 `bisheng-apps` 塞进去等于把独立桶的生命周期绑到共享初始化路径上,还会诱导后人顺手给它加匿名策略——K5 明令禁止)。`safe_extract(tar_path, dest)`:照抄 `skill_store.py:171-176 _safe_rel_path` 拒绝绝对路径与 `..`,**额外拒 `TarInfo.issym() / islnk() / isdev() / isfifo()`**;边解包边累计大小与条目数,越限即中止。`cleanup_orphans(app_id)`:同 `app_id` 前缀下无 `app_version` 行、无 deployment 引用且超 7 天的键删除,**挂在下一次同应用 deploy 的接收阶段顺手做**(不引 Beat,D2)。三闸阈值读 `settings.app_runtime.*`(T005)。
+  **测试**: T010 全部通过。
+  **覆盖 AC**: AC-02, AC-43
+  **依赖**: T010, T005
+
+- [x] **T012**: `[MVP-核心]` 密钥扫描规则集遍历测试(AC-10 的直接承载)
+  **文件**: `src/backend/test/app_publish/test_secret_rules.py`(新)
+  **逻辑**: **遍历 `SECRET_SCAN_RULES` 元组**,每条规则一个正样本(必须命中)+ 一个反样本(必须不命中)——AC-10「规则集内样本 100% 被阻断」由这个遍历式单测直接承载,**新增规则时忘了加样本 = 测试红**。
+  **测试**: `test_every_rule_has_positive_and_negative_sample`(元组驱动,规则数 == 样本对数)→ AC-10 / `test_every_positive_sample_blocks_publish` → AC-10 / `test_output_never_contains_secret_value`(把扫描结果 JSON 序列化后断言**不含样本密钥子串**,连脱敏值都不给)→ AC-10 / `test_bs_sak_rule_follows_key_prefix_constant`(规则由 `api_credential.SERVICE_ACCOUNT_KEY_PREFIX` / `KEY_SECRET_LENGTH` **拼出**,改常量后规则自动跟随;C6 禁硬编码字面量)→ AC-10 / `test_db_conn_string_requires_user_and_password`(只有 host 的连接串是正常配置、不得命中)→ AC-10 / `test_generic_high_entropy_skips_placeholders`(`your_key` / `<...>` / `${…}` / `change_me` / `example` 白名单)→ AC-10 / `test_binary_file_skipped_by_null_byte_sniff` → AC-10 / `test_large_file_marked_skipped_not_silent`(单文件 > 1 MiB 跳过并在结果里标 `skipped`——**大文件被静默跳过等于假通过**)→ AC-10 / `test_hit_report_shape_is_file_and_line_only`(`{rule_id, name_i18n_key, file, line}`)→ AC-10, AC-11
+  **覆盖 AC**: AC-10, AC-11
+  **依赖**: T007
+
+- [x] **T013**: `[MVP-核心]` `secret_scanner` 实现
+  **文件**: `src/backend/bisheng/app_publish/domain/services/secret_scanner.py`(新)
+  **逻辑**: `SECRET_SCAN_RULES: tuple[SecretRule, ...]` 常量表(形态同 `open_api/domain/scopes.py:66-185 OPEN_API_SCOPES`),`SecretRule(rule_id, name_i18n_key, pattern: re.Pattern, description_i18n_key)`。首批规则见 design D5:`bs_sak`(**引 F049 常量拼**)· `aws_akid` · `openai_sk` · `private_key_pem` · `db_conn_string`(用户名密码都在串里才算命中)· `generic_high_entropy`(带占位符白名单)。`scan_package(root) -> ScanResult`:逐文件;跳过 `.git/` / `node_modules/` / `venv/` / `__pycache__/` / `dist/` / `build/`;二进制嗅探(前 8KB 含 `\x00` 即跳过);单文件 > 1 MiB 标 `skipped`。命中 → `16241` 阻断,`hits: [{rule_id, name_i18n_key, file, line}]`——**永不含值**(AC-10 硬承诺)。**不引 `detect-secrets` / `gitleaks`**(全仓零先例 + 规则集不可控,D5);**不做行级抑制机制**(一开就会被用来绕过,design §8)。**本常量是 AC-10「与平台内发布同一规则集」的落地手段**——PRD-2 平台内发布直接复用它,只有一份(§6.1 Outgoing)。
+  **测试**: T012 全部通过。
+  **覆盖 AC**: AC-10, AC-11
+  **依赖**: T012, T004
+
+- [x] **T014**: `[MVP-核心]` `ResourceTierService` 测试(seed / 选档 / 停用 / 无删除)
+  **文件**: `src/backend/test/app_publish/test_resource_tier_service.py`(新)
+  **逻辑**: 断言三档 seed 的幂等与数值来源、选档解析、停用语义与"不可删"不变量。
+  **测试**: `test_seed_creates_three_platform_level_tiers`(轻量 0.5C/1G · 标准 2C/4G · 性能 4C/8G;**无 `tenant_id` 列、跨租户共享**)→ AC-44 / `test_seed_values_come_from_f054_default_tiers_constant`(seed 从 F054 `DEFAULT_TIERS` 读取落库,保证"表未落"与"表刚 seed 完"两个时刻规格恒等)→ AC-44 / `test_settings_default_tiers_overrides_constant`(`settings.app_runtime.default_tiers` > 常量;**⚠️ 该配置项只在首次播种时有效**,已播种的部署只能改 `resource_tier` 行——「114 用它下调 `light`」的原方案已被证伪,见 spec 决议-10)→ AC-44 / `test_seed_idempotent_by_code_does_not_reset_admin_edits`(跑两次不重复;超管改过的规格不被升级重置,与 AC-19 同判据)→ AC-44 / `test_manifest_without_tier_resolves_light` → AC-46 / `test_unknown_tier_rejected_16223_reason_not_found` → AC-46 / `test_disabled_tier_rejected_16223_reason_disabled` → AC-46, AC-47 / `test_disabled_tier_existing_apps_keep_running_and_resolve_spec`(停用**只拦新选择**,存量版本仍可解析规格)→ AC-47 / `test_dao_has_no_delete_method`(**不可删是 F054 可依赖的不变量**:`tier_id` 永远可解析)→ AC-47 / `test_tier_code_written_into_version_snapshot_spec_read_at_runtime`(快照记**档位标识**、规格取运行时当前值 → 规格调整自下一次发布 / 重新上线生效)→ AC-48 / `test_tier_in_use_app_count_counts_online_versions_only`(**仅为 Wave 7 的 T065 预置计数口径,不计入本任务的 `覆盖 AC`**;AC-45 的承载任务是 T065 / T066,追溯表以那两条为准)
+  **覆盖 AC**: AC-44, AC-46, AC-47, AC-48
+  **依赖**: T007, T002
+
+- [x] **T015**: `[MVP-核心]` `ResourceTierService` + 三档 seed 实现(含回写 F054 常量)
+  **文件**: `src/backend/bisheng/app_publish/domain/services/resource_tier_service.py`(新), `src/backend/bisheng/common/init_data.py`(`init_default_data` 内加档位 seed 调用), `src/backend/bisheng/app_runtime/domain/constants.py`(**回写 F054 `DEFAULT_TIERS`**)
+  **逻辑**: `seed_resource_tiers()`:优先级 `settings.app_runtime.default_tiers`(T005)> F054 `DEFAULT_TIERS` 常量;**幂等按 `code` 存在即跳过**。`resolve_tier(code | None) -> ResourceTier`:`None` → `light`;不存在或 `enabled=False` → `16223`(`details.reason ∈ {not_found, disabled}`,**不拆码**)。`list_tiers()` / `update_tier(code, patch)`(Wave 7 的管理 tab 消费)/ `count_apps_using(tier_id)` = `SELECT COUNT(DISTINCT app_id) FROM app_version WHERE tier_id=? AND terminal_state='online'`。**无 delete**(D11)。
+  **跨 Feature 回写**(design §8 回写项 1):F054 `DEFAULT_TIERS` 现为 `0.5 vCPU/512 MiB · 1/1024 · 2/2048「增强」`,与本 spec **AC-44** 的 `1C/2G · 2C/4G · 4C/8G「性能」` 数值与命名皆冲突(坑 27)→ **以本 spec 的产品口径为准**,改数值 + 第三档名「增强」→「性能」,并把 F054 design D11「何时重新考虑」里的"支持删档"改为"档位只可停用不可删"。**改常量不影响存量表**(seed 幂等按 `code` 跳过)。**⚠️ 2026-09-09 后续修订**:轻量档已按实测改为 0.5C/1G(spec 决议-10),标准与性能两档维持原值。
+  **测试**: T014 全部通过。
+  **覆盖 AC**: AC-44, AC-46, AC-47, AC-48
+  **依赖**: T014, T005
+
+- [x] **T016**: `[MVP-核心]` `VersionService` 测试(写入时点 / 终态标注 / 派生显示 / 补偿)
+  **文件**: `src/backend/test/app_publish/test_version_service.py`(新)
+  **逻辑**: 断言"先 Gate 后 INSERT"的写入时点、`terminal_state` 四取值、"待上线"是派生显示不是列值、并发兜底与补偿路径。
+  **测试**: `test_version_row_only_created_after_precheck_and_scan_pass`(预检 / 扫描失败的提交**不进版本列表**,只在 `app_deployment` 留记录,决议-9)→ AC-02 / `test_version_row_created_after_gate_not_before`(Gate 会抛「场景未启用」→ 先 INSERT 会留一条永远没有终态也没有审批单的僵尸版本,D6-B 的致命缺陷)→ AC-02 / `test_gate_exception_approver_empty_still_inserts_version`(`decision=EXCEPTION` 也算"已进入审批流",否则管理员处理完异常后没有对象可上线)→ AC-18 / `test_gate_raises_scenario_disabled_marks_deployment_failed_16225_no_version` → AC-02 / `test_compensation_cancels_approval_when_insert_fails`(Gate 自带写库与 commit、**不能与 INSERT 同事务** → 显式两阶段补偿:`cancel_instance_by_business` + deployment failed + 审计 `app.release.rollback`)→ AC-02 / `test_version_no_is_max_plus_one_with_unique_constraint`(`UNIQUE(app_id, version_no)` 兜并发)→ AC-40 / `test_kind_initial_vs_iteration` → AC-39 / `test_terminal_state_only_four_values`(`online` / `rejected` / `withdrawn` / `NULL`;**F055 不给它加过程值**)→ AC-39 / `test_pending_online_is_derived_display_not_column`(`terminal_state IS NULL ∧ app.pending_version_id == version.id` → 显示「待上线」;存在在途审批单 → 显示「待审」)→ AC-39 / `test_manual_publish_flips_null_to_online_without_new_row`(决议-6)→ AC-39 / `test_app_deleted_cancel_keeps_terminal_state_null`(不引入第五个取值)→ AC-39 / `test_mark_terminal_state_is_the_only_update_writer`(`app_version` 唯一被 UPDATE 的列、唯一写入函数)→ AC-40 / `test_version_row_never_deleted` → AC-40 / `test_read_by_version_id_requires_app_scope`(**`app_version` 无 `tenant_id`**,登记进 `_TENANT_AWARE_MODEL_MODULES` 也不受自动过滤 → 按 `version_id` 起手必须先借道 `app` 行校验归属,坑 19)→ AC-40 / `test_snapshot_retrievable_and_immutable_for_any_version` → AC-43
+  **覆盖 AC**: AC-02, AC-18, AC-39, AC-40, AC-43
+  **依赖**: T007, T011
+
+- [x] **T017**: `[MVP-核心]` `VersionService` 实现
+  **文件**: `src/backend/bisheng/app_publish/domain/services/version_service.py`(新)
+  **逻辑**: `insert_version(deployment, approval_instance_id | None)`:`version_no = MAX(version_no)+1 WHERE app_id=?` 在同一事务内取;`kind` = `initial`(该 app 首条)/ `iteration`;写 `code_object_key` / `manifest` / `capabilities` / `injections` / `tier_id`(**四者同属一个快照**,F054 AC-02 已定"任何写入方不得只改其一")。`mark_terminal_state(app_id, version_id, state)`:**唯一被授权 UPDATE `terminal_state` 的函数**(F054 D8 的只增不改例外),单行带前态断言。`derive_display_state(app, version)`:派生「待上线」/「待审」,**不落库**。`get_snapshot(app_id, version_id)`:经 `package_service` 取回 tar(供审读视图 / 预览拉起 / 将来回滚)。**每个按 `version_id` 起手的方法签名强制带 `app_id`**(坑 19)。
+  **测试**: T016 全部通过。
+  **覆盖 AC**: AC-02, AC-18, AC-39, AC-40, AC-43
+  **依赖**: T016, T011
+
+- [x] **T018**: `[MVP-核心]` 托管预检编排测试(线性 fail-fast + 五元组)
+  **文件**: `src/backend/test/app_publish/test_precheck_service.py`(新)
+  **逻辑**: 用 `fake_orchestrator` 编程构建 / 探活的成功与三类失败,断言阶段序、fail-fast 与失败五元组。
+  **测试**: `test_stage_order_manifest_build_probe_then_scan`(**spec AC-01 / F053 AC-31a 的字面顺序**;扫描提前是待 ★ 确认的偏离,**不得在此实现**)→ AC-07 / `test_fail_fast_stops_at_first_failing_stage`(manifest 层内部一次性报全,跨阶段 fail-fast)→ AC-07 / `test_manifest_stage_makes_no_rpc`(同步段不发任何 RPC——manager 不可达就会把 `deploy` 变成挂在超时上的请求,D4/D1-C 的兑现)→ AC-07 / `test_runtime_rechecked_against_manager_in_async_stage_16222`(本地枚举与 manager `supported_runtimes` 取交,复核在 `precheck_build` 起手)→ AC-07 / `test_build_capacity_shortage_is_16226_not_16225`(**一码一义红线**:16225 专归"审批场景未启用")→ AC-07 / `test_build_failure_16227_carries_manager_stage_message_tail` → AC-07, AC-11 / `test_probe_failure_16228_with_hosting_contract_hints`(AC-08 的判据**下沉为探活失败**:本轮不做静态依赖分析,`hints` 给"平台不提供自带数据库 / 消息队列 / 缓存;数据请改接 `BISHENG_APP_DB_URL`")→ AC-08 / `test_egress_domains_format_only_this_round` → AC-08 / `test_failure_shape_is_five_tuple_in_all_stages` → AC-11 / `test_precheck_failure_produces_no_approval_and_no_version`(AC-07 的硬承诺)→ AC-07
+  **覆盖 AC**: AC-07, AC-08, AC-11
+  **依赖**: T009, T013, T015
+  **⚠️ 编号 ≠ 执行顺序**:本任务依赖 T009 / T013 / T015 三个实现任务的产出(`manifest_validator` / `secret_scanner` / `ResourceTierService` 接口),故实际排在它们之后;编号保留在此只为与 T019 相邻。
+
+- [x] **T019**: `[MVP-核心]` `precheck_service` 实现
+  **文件**: `src/backend/bisheng/app_publish/domain/services/precheck_service.py`(新)
+  **逻辑**: 线性 fail-fast 阶段机(design D4 阶段表):① `precheck_manifest`(**同步段**:YAML + pydantic,16221/16203)② `precheck_manifest`(**同步段**:本地可判的引用校验——`runtime` ∈ 本地枚举常量 · `tier` 查本地表 · 能力声明格式 · 密钥引用,16222/16223/16224/16230/16231)③ `precheck_build`(**异步段**:起手 `GET /v1/runtime/status.supported_runtimes` 复核 `runtime` → `POST /v1/intents/build` → 轮询 `GET /v1/builds/{id}`;容量不足 **16226**、失败 16227)④ `precheck_probe`(异步段:`POST /v1/intents/probe` 临时形态,不占实例名额,16228)。失败原因恒为 `{stage, code, message, details, hints[]}` **五元组**,同一结构同时出现在 CLI 轮询返回、发布面审批状态区、`app_deployment.failure` 落库(AC-11)。**编排器一律经 F054 `orchestrator_client` 门面**,backend 零 docker 依赖。
+  **测试**: T018 全部通过。
+  **覆盖 AC**: AC-07, AC-08, AC-11
+  **依赖**: T018
+
+- [x] **T020**: `[MVP-核心]` 管线同步前段 `accept()` 测试(归属 / 两道闸 / 首发建草稿)
+  **文件**: `src/backend/test/app_publish/test_pipeline_accept.py`(新)
+  **逻辑**: 断言同步前段的顺序与每个拒绝分支;断言首发建草稿应用走 F054 而非直写 `app` 表。
+  **测试**: `test_first_deploy_creates_draft_app_via_f054_create_app`(**F055 不得直写 `app` 表**,决议-8;owner = 密钥所属服务账号的 `resource_owner_user_id`)→ AC-01, AC-04 / `test_iteration_deploy_requires_owner_match_else_16205`(迭代只对该归属人 owner 的应用放行,含租户管理员名下服务账号的密钥)→ AC-04 / `test_owner_read_from_resource_owner_user_id_not_actor_id`(坑 28)→ AC-04 / `test_active_approval_blocks_new_submit_16251`(**断言不是"静默返回既有实例"**——Gate 的重复提交拦截是静默返回,AC-03 必须在**调 Gate 之前**自查,K2 ① / 坑 8)→ AC-03 / `test_pending_online_state_blocks_new_submit_16252`(提示"须先手动上线或删除")→ AC-03 / `test_deployment_row_created_with_stage_received` → AC-01 / `test_version_id_generated_at_accept_and_reused_by_version_row` → AC-02 / `test_meta_not_updated_at_accept`(元信息在**预检 + 扫描通过后**才更新,失败不更新,AC-05)→ AC-05 / `test_retry_deploy_reuses_same_app_id_not_new_draft`(CLI 把 app_id 随项目保存,重试复用,D2-B 的代价控制)→ AC-01 / `test_accept_returns_deployment_id_within_seconds_no_rpc` → AC-01
+  **覆盖 AC**: AC-01, AC-02, AC-03, AC-04, AC-05
+  **依赖**: T011, T017
+  **⚠️ 执行顺序(Wave 编号 ≠ 拓扑序)**:本任务与 T021 / T022 / T023 依赖 T031(在途闸 / 待上线闸 / Gate 提交都落在 `publish_approval_service`,Wave 3.2),而 T031 → T030 → T029 → T028 → T027a/T027b → T025 整条链都在 Wave 3 —— **实际执行序见「开发模式 · 跨 Wave 执行序」:Wave 3.1–3.2 先跑完再回来做 T020–T023**。测试可先落盘(红测即预期),实现一律按 `依赖:` 排。
+
+- [x] **T021**: `[MVP-核心]` `publish_pipeline_service.accept()` 实现(同步前段)
+  **文件**: `src/backend/bisheng/app_publish/domain/services/publish_pipeline_service.py`(新,本任务只落 `accept()`)
+  **逻辑**: 按 design §4.1-A ① 的顺序:归属判定(`principal.resource_owner_user_id == app.owner_user_id`,否 → 16205)→ 大小闸 → 落临时盘 → `put_object` 到 `bisheng-apps` → 解包安全闸 + 解包大小闸 + 条目数闸 → 读 `bisheng-app.yaml` → `manifest_validator`(T009)→ 本地引用校验 → **首发**:调 F054 `AppProvisionService.create_draft(name, slug, description, owner_user_id, tenant_id)` 建草稿应用(**不直写 `app` 表**;本文原写 `AppStateService.create_app`,2026-08-19 按实现真名更正——「建应用」不是状态迁移,故不在 `AppStateService` 上)→ 在途审批单闸(16251)/ 待上线态闸(16252)→ INSERT `app_deployment(stage=received, status=running)` → `apply_async` 后段 → 返回 `{deployment_id, app_id, version_id}`。**本段绝不发任何编排器 RPC**(D1-C 的兑现:秒级错误秒级回)。审计 `app.release.submit`。
+  **测试**: T020 全部通过。
+  **覆盖 AC**: AC-01, AC-02, AC-03, AC-04, AC-05
+  **依赖**: T020, T031
+
+- [x] **T022**: `[MVP-核心]` 管线异步后段阶段机测试(阶段推进 / 元信息 / 审计)
+  **文件**: `src/backend/test/app_publish/test_pipeline_run.py`(新)
+  **逻辑**: 断言 `run_pipeline` 的阶段推进、每阶段一条审计、元信息更新时点与失败终止。
+  **测试**: `test_stage_advances_one_row_update_per_stage` → AC-01 / `test_each_stage_writes_one_app_release_audit_with_app_id_and_version_no`(metadata 恒带 `app_id` / `version_no` / `deployment_id`;`target_type='app_version'`——**审批族审计的 `target_type` 恒为 `approval_instance`,按"对象=应用"筛不到**,坑 20)→ AC-01 / `test_scan_hit_terminates_pipeline_16241_no_version_no_approval` → AC-02, AC-10 / `test_precheck_failure_terminates_and_persists_failure_tuple` → AC-02, AC-11 / `test_meta_updated_after_precheck_and_scan_pass_not_awaiting_approval`(调 F054 `AppMetaService.update_meta`,**F055 不另写一份更新逻辑**)→ AC-05 / `test_icon_extracted_from_package_and_stored_as_minio_object_name`(**断言 `app.logo` 落的是公共桶 object_name `icon/{uuid}.png`**,既不是包内相对路径〔写进去图标全站不显示〕,也不是 `/upload/icon` helper 返回的 `file_path`〔那是 **7 天过期的预签名 URL**,一周后全站图标 403,坑 16〕;同时断言 >1 MiB 或扩展名 ∉ {jpeg,jpg,png} 的图标被跳过并给 `hints`、**不阻断发布**)→ AC-05 / `test_tier_and_capabilities_enter_snapshot` → AC-05, AC-48 / `test_capability_declaration_change_audited_each_release`(`app.release.capability_declared`)→ AC-55 / `test_status_becomes_waiting_approval_after_approval_created` → AC-01 / `test_worker_tenant_context_restored_from_celery_header`(租户 ContextVar 经 header 自动透传,`worker/tenant_context.py:63-90`;断言子租户发布不串租户)→ AC-01
+  **覆盖 AC**: AC-01, AC-02, AC-05, AC-10, AC-11, AC-48, AC-55
+  **依赖**: T019, T021
+
+- [x] **T023**: `[MVP-核心]` `run_pipeline()` 实现 + Celery 任务
+  **文件**: `src/backend/bisheng/app_publish/domain/services/publish_pipeline_service.py`(**增量**加 `run_pipeline()`,不改 T021 已落的 `accept()`), `src/backend/bisheng/worker/app_publish/tasks.py`(新), `src/backend/bisheng/worker/app_publish/__init__.py`(新,空), **`src/backend/bisheng/worker/__init__.py`(存量:顶部 `# register tasks` 块追加 `from bisheng.worker.app_publish.tasks import run_publish_pipeline`)**
+  **逻辑**: `run_pipeline(deployment_id)`:`precheck_build → precheck_probe → secret_scan → update_meta(含图标)→ approval_created`(**spec 字面顺序**,扫描提前是待 ★ 确认的偏离,勿自行调换)。每次阶段推进 = 一次 `app_deployment` 单行 UPDATE + 一条 `app.release.*` 审计。
+  **⚠️ Celery 任务必须显式登记**(否则整条异步后段永不执行):celery app 是 `Celery("bisheng", include=["bisheng.worker"])`(`worker/main.py:22`),**只 import 包、不扫子模块** → 任务全靠 `worker/__init__.py` 顶部 `# register tasks` 的显式 import 块注册(`admin_scope` / `approval`(outbox)/ `knowledge` / `workflow` 全在那里)。漏这一行的现象是**最难定位的形态**:`apply_async` 正常返回、`deploy` 报成功、worker 侧 `NotRegistered`、114 上表现为「状态永远卡在 `received`」且后端日志无异常。落码后自检:`uv run celery -A bisheng.worker.main:bisheng_celery inspect registered | grep app_publish`。
+  **元信息更新(含图标落地,AC-05)**:调 F054 `AppMetaService.update_meta(app_id, name/description/slug)`;**图标由本任务负责落地**(design D12)——从已解包目录按 `manifest.icon` 相对路径取字节 → 校验 ≤ 1 MiB 且扩展名 ∈ {jpeg, jpg, png} → `MinioStorage.put_object(bucket=public_bucket, object_name=f"icon/{uuid4()}.png", ...)` → 把 **object_name**(不是预签名 URL、不是包内相对路径)写进 `app.logo`。**校验不过只跳过图标 + 记 `hints`,不阻断发布**(图标是元信息、不是发布前提)。终止时写 `failure` 五元组 + `status=failed`。审计写法:`AuditLogDao.ainsert_v2(...)`——该方法**内部自带 `bypass_tenant_filter()` + 独立 session + commit**,调用方**不需要也不应该**再包事务;写失败会抛,用 `approval_outbox_service.py:105-121` 的"审计失败不影响主流程"包法。
+  **Worker / tenant_id**: Celery 任务 `bisheng.worker.app_publish.tasks.run_publish_pipeline(deployment_id)`,**走默认 `celery` 队列**(与审批 outbox 同队列,114 已有 default worker;**不新建队列**,D1)。`tenant_id` 由 `accept()` 侧 `apply_async` 时经 **Celery header 自动透传**(`worker/tenant_context.py:63-90`),worker 内**不需要**手动 `set_current_tenant_id`;但任务体第一行须断言 `current_tenant_id` 非空并记日志(无 header 时框架兜底 `DEFAULT_TENANT_ID`,静默串租户是最坏形态)。
+  **可观测**: 结构化日志 `app_publish.pipeline`(`deployment_id / app_id / stage / status / duration_ms / failure_code`,**每阶段一条**)与 `app_publish.scan`(`files_scanned / files_skipped / hits`,**不含命中值**)。
+  **测试**: T022 全部通过。
+  **覆盖 AC**: AC-01, AC-02, AC-05, AC-10, AC-11, AC-48, AC-55
+  **依赖**: T022, T031
+
+---
+
+### Wave 3 · `[MVP-核心]` 预置审批流、三项阻塞前置、终态与上线、权限判定、前端最小面、`withdraw` 守卫
+
+#### 3.1 三项阻塞前置(**存量代码行为变更**)
+
+- [x] **T024**: `[MVP-核心]` `tenant_admin` 来源修正 + Root 回退测试(含既有场景回归护栏)
+  **文件**: `src/backend/test/app_publish/test_approver_source_fix.py`(新)
+  **逻辑**: 断言 `tenant_admin` 来源从"全站系统超管"改为"当前租户的真实租户管理员",Root 租户回退平台超管;**并回归既有两个场景**——这是行为变更的护栏。
+  **测试**: `test_tenant_admin_source_resolves_real_tenant_admins_not_super_admin`(今天 `approver_resolver.py:63-74` 解析的是 `UserRoleDao.aget_roles_user([AdminRole])` 全站超管、与 `req.tenant_id` 完全无关,注释自认 "pragmatic approximation",坑 1)→ AC-21 / `test_sub_tenant_admin_only_sees_own_tenant_approvals`(多租户下不再全压超管一人)→ AC-21 / `test_root_tenant_falls_back_to_super_admin`(`TenantAdminService.list_tenant_admins` 对 Root 租户**恒返回 `[]` 是显式设计不是 bug**,`tenant_admin_service.py:95-96`;114 单租户形态**必然**命中此分支)→ AC-15 / `test_non_root_tenant_with_no_admin_does_not_fallback`(非 Root 无租户管理员且来源①亦空 → 走异常态,不叠加任何额外兜底)→ AC-15 / `test_existing_channel_scenario_still_resolves_approvers`(**行为变更护栏**)→ AC-21 / `test_existing_knowledge_space_scenario_still_resolves_approvers` → AC-21 / `test_notification_recipient_function_unchanged`(**断言 `approval_notification_service.py:122-152 _get_admin_recipient_ids` 一行未改**——它是**无条件 union**、语义与条件回退不同,合并的两种错法各自致命,坑 2)→ AC-21
+  **覆盖 AC**: AC-15, AC-21
+  **依赖**: T007
+
+- [x] **T025**: `[MVP-核心]` `approver_resolver` 修正实现(**跨 Feature 行为变更**)
+  **文件**: `src/backend/bisheng/approval/domain/services/approver_resolver.py`(存量改动)
+  **逻辑**: 顶部**新增函数** `resolve_tenant_admin_user_ids(tenant_id) -> list[int]`:`ids = await TenantAdminService.list_tenant_admins(tenant_id)`;`if not ids and tenant_id == ROOT_TENANT_ID: ids = AdminRole 用户`。把 `tenant_admin` 分支的 `UserRoleDao.aget_roles_user([AdminRole])` 换成它。
+  **⚠️ 三条不得违反的边界**:① **绝不与 `_get_admin_recipient_ids` 合并**——那个是通知侧的**无条件 union**(任何租户都并入全站超管),拿它做审批人解析等于把 AC-21 要修的缺陷原样搬过来;反过来改它则悄悄改掉审批异常态的管理员通知收件人(坑 2)。② **不用 `TenantService._get_tenant_admin_users`**——private、分页上限 100、返回 dict 且**含 super_admin**(`tenant_service.py:771/780-790`);`list_tenant_admins`(`tenant_admin_service.py:90-106`)是干净公共 API、返回 `list[int]`、权限后端异常 `return []`(fail-closed 成空 → 走 approver_empty 异常态,正合 AC-18)。③ **不在这里做申请人过滤**(那是场景 handler 的出口过滤,塞进公共解析器会污染频道 / 知识空间两个既有场景)。
+  **跨 Feature 副作用**: **对一切使用 `tenant_admin` 来源的既有场景与人工配置立即生效**(AC-21)→ **release note 必须显著声明**;T024 的两条回归测试是护栏。同 PR 更新 `.claude/skills/approval-module/SKILL.md`(T050)。
+  **测试**: T024 全部通过。
+  **覆盖 AC**: AC-15, AC-21
+  **依赖**: T024
+
+- [x] **T026**: `[MVP-核心]` 审批场景 seed 参数化与新建租户钩子测试
+  **文件**: `src/backend/test/app_publish/test_approval_seed.py`(新)
+  **逻辑**: 断言「应用发布」场景随部署即存在、可被人工改配后不被升级重置、且**两条**新建租户路径都会落库。
+  **测试**: `test_fresh_deploy_has_app_publish_scenario_enabled`(全新部署未做任何配置直接发布即可生成审批单)→ AC-12 / `test_seed_shape_single_catchall_route_single_or_node`(`ApprovalScenario(enabled=True)` → `ApprovalFlowDefinition(is_active=True)` → `ApprovalFlowVersion(version_no=1,is_active=True)` → `ApprovalNodeDefinition(node_order=1,node_mode='or')` → `ApprovalRouteRule(route_type='flow', match_config={})`;**`match_config={}` 就是 catch-all**,`approval_gate.py:443-445` 无 field 直接返回该 route)→ AC-12 / `test_sources_are_department_admin_and_tenant_admin` → AC-12 / `test_seed_idempotent_by_tenant_and_scenario_code`(**按 `tenant_id + scenario_code` 存在即 `continue`** = AC-19「平台升级不重置人工改动」的落地)→ AC-19 / `test_manual_reconfig_survives_reseed` → AC-19 / `test_create_tenant_path_seeds_scenario`(`TenantService.acreate_tenant`,**管理后台主路径**)→ AC-20 / `test_mount_child_path_seeds_scenario`(`TenantMountService.mount_child`)→ AC-20 / `test_seed_failure_does_not_break_tenant_creation`(照 `tenant_service.py:140-149` 的 `seed_builtin_skills` 形状:`try/except` + `logger.warning`)→ AC-20 / `test_menu_access_scenario_not_touched`(**不顺手扩到 menu_access**,那是另一条产品决策)→ AC-19
+  **覆盖 AC**: AC-12, AC-19, AC-20
+  **依赖**: T007
+
+- [x] **T027a**: `[MVP-核心]` seed 参数化 + 「应用发布」场景条目(**不碰 Tenant 领域**)
+  **文件**: `src/backend/bisheng/approval/domain/services/approval_seed_service.py`(新,对外暴露 `seed_approval_scenarios_for_tenant(tenant_id)`), `src/backend/bisheng/common/init_data.py`(`_init_default_approval_scenarios(session)` → `(session, tenant_id)`,**六处硬编码 `DEFAULT_TENANT_ID`**(`:365/374/384/402/414/425`)改参数)
+  **逻辑**: 新增 seed 条目 `{scenario_code:'app_publish_request', scenario_name:'应用发布', sources:[{"type":"department_admin"},{"type":"tenant_admin"}]}`,落 5 行(形态见 T026)。幂等判据**不动**(按 `tenant_id + scenario_code`)。`init_data.py` 的 `init_default_data()` 仍按 `DEFAULT_TENANT_ID` 调一次(行为不变)。
+  **⚠️ 签名变更需全量 grep 调用方**:`_init_default_approval_scenarios` 加参数后,**先 `grep -rn "_init_default_approval_scenarios\|seed_approval_scenarios" src/backend/`** 确认全部调用点都传了 `tenant_id`;六处 `DEFAULT_TENANT_ID` 改参数时逐处核对——漏改一处的现象是"某类 seed 行永远落在默认租户",不报错。
+  **测试**: T026 中除两条路径钩子外的全部用例通过(`test_fresh_deploy_has_app_publish_scenario_enabled` / `test_seed_shape_*` / `test_sources_are_*` / `test_seed_idempotent_*` / `test_manual_reconfig_survives_reseed` / `test_menu_access_scenario_not_touched`)。
+  **覆盖 AC**: AC-12, AC-19
+  **依赖**: T026
+
+- [x] **T027b**: `[MVP-核心]` **两条**租户创建路径挂 seed 钩子(**跨 Feature:改 Tenant 领域,单独 review**)
+  **文件**: `src/backend/bisheng/tenant/domain/services/tenant_service.py`(`acreate_tenant` 挂一次), `src/backend/bisheng/tenant/domain/services/tenant_mount_service.py`(`mount_child` 挂一次)
+  **逻辑**: 各**追加**一次 `seed_approval_scenarios_for_tenant(tenant.id)`,形状照抄 `tenant_service.py:140-149` 的 `seed_builtin_skills([tenant.id])`(`try/except` 包住 + `logger.warning`,其注释已明说"startup seeding 只覆盖当时存在的租户")。**只加这一步、不动创建流程里的任何既有步骤**。
+  **⚠️ 两条路径缺一必漏**(坑 3):`mvp-114-path.md:50` 与 PRD-1 §3.3 锚点表**都只写了 `tenant_mount_service`,口径不全**——`TenantService.acreate_tenant` 才是管理后台主路径,漏它则从管理后台建的租户永远没有发布审批场景,第一次 deploy 就 `ApprovalScenarioDisabledError`,且这类 bug 要等客户建第二个租户才暴露。**本任务之所以从 T027a 拆出来单独提交,就是为了让这两行改动被单独 review 一次。**
+  **跨 Feature 副作用**: 改 Tenant 领域(F011 / F012)的创建流程;`seed_builtin_skills` 是同位置的现成先例,review 时逐字比对两者形状是否一致。
+  **测试**: T026 的 `test_create_tenant_path_seeds_scenario` / `test_mount_child_path_seeds_scenario` / `test_seed_failure_does_not_break_tenant_creation` 通过。
+  **覆盖 AC**: AC-20
+  **依赖**: T027a
+
+#### 3.2 审批场景接入(四件套)
+
+- [x] **T028**: `[MVP-核心]` 场景 handler 测试(审批人解析矩阵 / 申请人 / 自审 / 异常态 / detail 结构)
+  **文件**: `src/backend/test/app_publish/test_publish_scenario_handler.py`(新)
+  **逻辑**: 覆盖 GOV-02 的全部解析分支与 `detail_snapshot` 结构契约。
+  **测试**: `test_gate_returns_pending_with_non_empty_approvers_after_seed`(**`resolve_approvers` 由 handler 自己实现**、通用来源要**显式转调** `resolve_approvers_from_sources`;忘了转调的现象与"没配审批人"完全一样、极难定位,坑 9)→ AC-12 / `test_approvers_union_dept_admin_and_tenant_admin_single_or_node` → AC-12 / `test_or_node_first_decision_closes_other_tasks`(任一人处理立即出终态并同步关闭另一方待办)→ AC-13 / `test_owner_without_primary_department_falls_to_tenant_admin`(`UserDepartmentDao.aget_user_primary_department`,**只取主部门、不向上回溯**)→ AC-14 / `test_department_without_admin_falls_to_tenant_admin` → AC-14 / `test_applicant_is_owner_natural_person_not_service_account`(`applicant_user_id = app.owner_user_id`,**不是** `principal.subject_user_id`;服务账号会被自动兜底进 guest 部门,按它解析部门会解析到完全无关的人,坑 28 / INV-29)→ AC-16 / `test_approver_note_marks_no_department_admin_source` → AC-16 / `test_applicant_filtered_out_of_approvers` → AC-17 / `test_self_approval_kept_when_applicant_is_only_candidate_and_audited`(唯一允许"自己批自己"的情形,审计必须标注)→ AC-17 / `test_self_approval_flag_carried_via_handler_instance_attr`(引擎侧**没有通道**——Gate 只取 `list[int]`,`ApprovalGateResult` 只有四字段 → 走 `self.last_self_approval` 实例属性,D7)→ AC-17 / `test_concurrent_two_releases_one_self_one_not_audit_exactly_one_self_approval`(**验证 handler 未被复用**:每次发布请求必须新建 handler 实例,绝不可提成模块级单例,D7)→ AC-17 / `test_both_sources_empty_returns_exception_not_raise`(`decision=EXCEPTION` + 通知管理员,**断言不抛异常**、不放行也不静默卡死,K2 ②)→ AC-18 / `test_detail_snapshot_structured_plus_three_flat_fallback_keys`(`app_name` / `release_kind_text` / `tier_name` 供未识别该场景的旧渲染路径;嵌套子树键须加进前端 `DETAIL_INTERNAL_KEYS`,坑 7)→ AC-24 / `test_detail_snapshot_fields_match_contract`(design §4.2 ④ 字段表逐字段)→ AC-24 / `test_gate_request_has_business_resource_type_and_id`(`ApprovalGateRequest` 的 `business_resource_type='app'` / `business_resource_id=str(app_id)` **是无默认值的必填字段**,漏填 = 构造即 `ValidationError`)→ AC-24 / `test_every_release_generates_approval_no_exemption`(首发与迭代均生成;能力声明与可见范围未变的迭代同样必审;**不存在任何免审配置项**,INV-34)→ AC-23 / `test_approver_resolver_not_modified_by_this_scenario`(申请人过滤只在本场景出口做)→ AC-17
+  **覆盖 AC**: AC-12, AC-13, AC-14, AC-16, AC-17, AC-18, AC-23, AC-24
+  **依赖**: T025, T027a, T027b
+
+- [x] **T029**: `[MVP-核心]` 场景 handler 实现 + preset + runtime handler 工厂分支(**四件套之①②③**)
+  **文件**: `src/backend/bisheng/app_publish/domain/services/app_publish_scenario_handler.py`(新), `src/backend/bisheng/approval/domain/services/approval_registry.py`(存量:`with_default_presets()` 加一条 preset), `src/backend/bisheng/approval/domain/services/approval_runtime_handler_factory.py`(存量:`build_runtime_handler` 加 `app_publish_request` 分支)
+  **逻辑**: handler 为**鸭子类型、无 ABC**,完整协议照 `knowledge_space_subscribe_scenario_handler.py:55-157`:`resolve_approvers`(显式转调 `resolve_approvers_from_sources` + AC-17 出口过滤 + `self.last_self_approval` 标志)· `build_title` → 「{应用名} · {首发|迭代} 发布审批」· `build_business_link` → platform 应用详情页发布 tab · `build_detail` → design §4.2 ④ 的**结构化** payload · `on_approved`(T033)/ `on_rejected` / `on_withdrawn` / `on_cancelled`(T035)。
+  **preset**: `ApprovalScenarioPreset(scenario_code='app_publish_request', scenario_name='应用发布', handler_key='app_publish_request', approver_source_types=['department_admin','tenant_admin','direct_user'])`。⚠️ **`handler_key` 是无默认值的必填字段**(`approval_center_schema.py:38-43`),漏传会在 `with_default_presets()` 求值时 `ValidationError`、**import 期就崩**。`direct_user` 是为 AC-19「租户管理员可改配审批人」留的。场景名接受**中文单语**(与既有三场景一致,坑 24)。
+  **工厂分支**: `approval_runtime_handler_factory.py:17-35` 加分支——**漏加 = 审批通过后应用永远不上线**(`build_runtime_handler` 抛 KeyError → `_record_outbox_task_failure`,K1 ③)。
+  **测试**: T028 全部通过。
+  **覆盖 AC**: AC-12, AC-13, AC-14, AC-16, AC-17, AC-18, AC-23, AC-24
+  **依赖**: T028
+
+- [x] **T030**: `[MVP-核心]` `publish_approval_service` 测试(Gate 组装 / 前置闸 / 首节点通知 / 草稿不可访问)
+  **文件**: `src/backend/test/app_publish/test_publish_approval_service.py`(新)
+  **逻辑**: 断言 Gate 的组装范式、两个"静默"语义的调用方兜底、首节点通知自发。
+  **测试**: `test_gate_assembled_per_request_with_fresh_registry_and_handler`(范式逐字照 `channel_service.py:1523-1530`:`ApprovalRegistry.with_default_presets()` → `register_handler(...)` → `ApprovalGate(registry=...)`;**引擎本就没有全局单例注册表**,K1 ②)→ AC-23 / `test_active_instance_checked_before_gate_raises_16251`(Gate 命中 `find_duplicate_active_instance` 会**静默返回既有实例**,靠它兜 = CLI 显示"提交成功"但什么都没提交,坑 8)→ AC-03 / `test_pending_online_checked_before_gate_raises_16252` → AC-03 / `test_scenario_disabled_raises_maps_to_16225_not_16226`(一码一义红线)→ AC-23 / `test_first_node_notification_sent_by_us_not_gate`(`approval_gate.py:232-248` 只建 task + 写审计、**不发站内信**,三个既有场景都在自己那侧补发,坑 5)→ AC-64 / `test_business_key_is_deployment_id`(一次发布尝试 = 一个审批单)→ AC-23 / `test_draft_app_not_accessible_before_approval`(首发在审批通过前,除审批人外任何其他用户不可访问——**MVP 期的落点是 F054 `AppProvisionService.create_draft` 里的 `authorize_created(protected=True)` 默认仅 owner 可见**;审读视图 / 预览试用两条审批人通道随 Wave 4)→ AC-30
+  **覆盖 AC**: AC-03, AC-23, AC-30, AC-64
+  **依赖**: T029
+
+- [x] **T031**: `[MVP-核心]` `publish_approval_service` 实现
+  **文件**: `src/backend/bisheng/app_publish/domain/services/publish_approval_service.py`(新)
+  **逻辑**: `_build_publish_approval_gate()`(**每次调用新建 registry + handler + Gate**,绝不缓存复用,D7 自审标志的成立前提)· `assert_no_active_release(app_id)`(16251)· `assert_not_pending_online(app_id)`(16252)· `submit(deployment)`:**先 Gate 后 INSERT**(D6-C)——`request_or_pass(business_key=str(deployment_id), applicant_user_id=app.owner_user_id, applicant_department_id=owner 主部门, business_resource_type='app', business_resource_id=str(app_id))` → `PENDING` 或 `EXCEPTION` 都照落版本记录(调 T017)→ Gate **抛异常** → deployment `failed(stage='approval_created', code=16225)` 且不落版本 → 返回 PENDING 后调 `ApprovalNotificationService.notify_users(..., action_code='approval_task_pending', scenario_code='app_publish_request')`(坑 5)→ 读 `handler.last_self_approval` 写审计 `app.release.self_approval`。**补偿**:Gate 成功而 INSERT 失败 → 立即 `cancel_instance_by_business(...)`(T035)+ deployment failed + 审计 `app.release.rollback`(**显式两阶段补偿,不假装原子**)。
+  **测试**: T030 全部通过。
+  **覆盖 AC**: AC-03, AC-23, AC-30, AC-64
+  **依赖**: T030, T017
+
+#### 3.3 审批终态处理与上线终检
+
+- [x] **T032**: `[MVP-核心]` `on_approved` 三分支测试(待上线不是失败)
+  **文件**: `src/backend/test/app_publish/test_on_approved.py`(新)
+  **逻辑**: 断言 outbox 语义边界——**产品定义的终态必须正常返回、系统性失败必须抛**。
+  **测试**: `test_online_marks_terminal_online_and_notifies_owner` → AC-31 / `test_capacity_shortage_returns_normally_and_sets_pending_capacity`(**必须"正常返回"而不是 raise**——raise 会让 instance 变 `execute_failed` + 建异常 + 通知管理员,产品上是"审批失败了",与 AC-31 直接矛盾,K3 / 坑 10)→ AC-31 / `test_deploy_failure_returns_normally_and_sets_pending_deploy_failed`(决议-8 的第二种成因)→ AC-31 / `test_approval_instance_stays_approved_in_both_pending_cases` → AC-31 / `test_orchestrator_unreachable_raises`(判据写死为一句:**"应用最终会不会自己好起来"——会就返回,不会就抛**)→ AC-31 / `test_version_not_found_raises_16253` → AC-31 / `test_stopped_app_only_stages_not_publishes`(审批通过仅落为待运行版本、不自动重新上线;重新上线后新版本生效)→ AC-36 / `test_deleted_app_returns_normally_as_race_defense`(删除时已取消审批单,这是竞态兜底)→ AC-35 / `test_stage_version_called_before_publish`(写 `pending_version_id`、不改应用态)→ AC-31 / `test_pending_online_notifies_owner_and_tenant_admin_root_falls_to_super_admin`(**此处是通知不是审批人解析** → 可直接复用 `_get_admin_recipient_ids` 的无条件 union,与 T025 的条件回退是两码事、别混用)→ AC-31, AC-64
+  **覆盖 AC**: AC-31, AC-35, AC-36, AC-64
+  **依赖**: T029, T017
+
+- [x] **T033**: `[MVP-核心]` `on_approved` 实现(调 F054 状态动作)
+  **文件**: `src/backend/bisheng/app_publish/domain/services/app_publish_scenario_handler.py`(**增量**加 `on_approved`,不改 T029 已落方法)
+  **逻辑**: 编排见 design D9:① 取 `app` 与 `app_version`(**按 `version_id` 起手必须先借道 `app` 行校验归属**,坑 19);应用已删 → 正常返回。② F054 `AppStateService.stage_version(app_id, version_id)`。③ 分派:应用态 ∈ {草稿, 已上线, 待上线} → `publish(app_id, version_id)`;**已下线 → 只 stage 不 publish**(AC-36);已删除 → 同①。④ `publish` 三结果:`online` → `mark_terminal_state('online')` + 审计 `app.release.online` + 通知 owner;容量不足 → 应用态「待上线(资源不足)」+ 审批单保持通过 + `terminal_state` 保持 `NULL`(派生显示「待上线」)+ 通知 owner + 租户管理员(Root → 平台超管);拉起 / 探活非容量失败 → 「待上线(上线失败)」+ 同上、成因文案区分。**后两者正常返回**(K3 / 坑 10)。**应用态一律经 F054,F055 不直写**(决议-8)。
+  **测试**: T032 全部通过。
+  **覆盖 AC**: AC-31, AC-35, AC-36, AC-64
+  **依赖**: T032
+
+- [x] **T034**: `[MVP-核心]` 驳回 / 撤回 / 删除致取消测试
+  **文件**: `src/backend/test/app_publish/test_release_terminal_states.py`(新)
+  **逻辑**: 三条终态分支各自的版本标注、应用态影响、通知接收方与审计。
+  **测试**: `test_reject_marks_version_rejected_and_keeps_app_state`(首发保持草稿态;迭代保持已上线、当前版本继续运行——被驳回**不写** `pending_version_id`,F054 AC-05 天然成立)→ AC-33 / `test_reject_reason_full_text_available_to_owner`(来源 = `approval_task.comment`,经状态只读接口回传)→ AC-33 / `test_resubmit_after_reject_creates_new_approval` → AC-33 / `test_withdraw_marks_version_withdrawn_and_notifies_approvers`(owner-only 由既有 `withdraw_instance` 的 `applicant_user_id` 校验天然成立,`:430`;通知由既有 `withdraw_instance` 负责)→ AC-34 / `test_withdraw_then_resubmit_creates_new_approval` → AC-34 / `test_app_deleted_cancels_active_instance_and_notifies_approvers`(**不能复用 `cancel_exception_api`**——它从 exception 记录起手且通知的是**申请人**,与 AC-35 要求的"通知审批人"相反,坑 6)→ AC-35 / `test_cancel_audit_carries_app_id_and_version_no` → AC-35 / `test_hook_failure_does_not_rollback_delete_and_read_side_still_shows_cancelled`(**F054 已明示钩子失败不回滚删除** → 读侧对"应用已删除"独立判定并按已取消呈现,不把正确性全押在钩子送达上,D10 防御)→ AC-35 / `test_composition_root_registered_in_both_api_and_worker`(组合根注册必须在 API 与 Celery worker **两类进程**都执行;只挂 API lifespan 会在多进程下静默半失效,memory `feedback_multinode_default_assumption`)→ AC-35
+  **覆盖 AC**: AC-33, AC-34, AC-35
+  **依赖**: T029
+
+- [x] **T035**: `[MVP-核心]` 终态回调 + `cancel_instance_by_business` + 组合根(**跨 Feature**)
+  **文件**: `src/backend/bisheng/app_publish/domain/services/app_publish_scenario_handler.py`(**增量**加 `on_rejected` / `on_withdrawn` / `on_cancelled`), `src/backend/bisheng/approval/domain/services/approval_center_service.py`(**新增** `cancel_instance_by_business`), `src/backend/bisheng/app_publish/composition.py`(新,组合根), **`src/backend/bisheng/main.py`(存量:`lifespan`(`:82`)内调一次 `register()`)**, **`src/backend/bisheng/worker/main.py`(存量:`on_worker_init`(`@celeryd_after_setup`,`:80-87`)内调一次 `register()`)**
+  **逻辑**: `on_rejected` → `mark_terminal_state('rejected')` + deployment `failed(stage='approved', code=None)`(区分于预检失败)+ 审计 `app.release.rejected`(`reason` = 驳回理由全文),**应用态不动**。`on_withdrawn` → `mark_terminal_state('withdrawn')` + deployment failed + 审计。`on_cancelled` → 保持 `terminal_state=NULL`(应用整体已删,不需要第五个取值)+ 审计 `app.release.cancelled`。
+  **新增审批模块 API** `cancel_instance_by_business(*, instance_id | (scenario_code, business_key), reason, operator_user_id)`:置 instance=CANCELLED + 全部 PENDING task → CANCELLED + 写 `approval_action_log` + 审计 + **通知审批人**(新 action_code `approval_instance_cancelled`)+ 调 handler `on_cancelled`。**放审批模块**(它操作的是审批实体,放 F055 里就是跨模块直写别人的表);形状通用,将来任何"业务对象消失需取消在途单"的场景直接复用(§6.1)。
+  **组合根** `composition.py`:暴露 `register()` —— 注册 F054 `lifecycle_hooks.register_app_deleted_hook(fn)` + 场景 handler;**函数体自身幂等**(重复调用不重复注册),因为它要在两处被调(D16 ⚠️)。
+  **⚠️ 两个接线点缺一即静默半失效**(memory `feedback_multinode_default_assumption`):① `main.py` 的 `lifespan`(`:82`,与 `_register_permission_runtime_contexts()` 同一段,照它的位置放);② `worker/main.py` 的 `on_worker_init`(`@celeryd_after_setup.connect`,`:80-87`,紧随 `initialize_app_context(..., instance_role="celery")` 之后)。**只挂 API 的形态最坑**:API 进程里手测全对,而审批 outbox 执行、删除钩子这些真正跑在 worker 进程里的路径全都找不到 handler(K1 ③ 原样复现),日志只留一条 `_record_outbox_task_failure`。T034 的 `test_composition_root_registered_in_both_api_and_worker` 是它的护栏。
+  **跨 Feature 副作用**: 新增审批模块公共 API(**不改** `withdraw_instance` 既有行为,守卫是 T051,Wave 3.8);同 PR 更新 approval-module skill(T050)。
+  **测试**: T034 全部通过。
+  **覆盖 AC**: AC-33, AC-34, AC-35
+  **依赖**: T034, T033
+
+#### 3.4 服务端权限判定、状态只读接口与端点
+
+- [x] **T036**: `[MVP-核心]` 手动上线 + 发布状态只读服务测试
+  **文件**: `src/backend/test/app_publish/test_publish_status_service.py`(新)
+  **逻辑**: 断言状态接口的字段完整性、**唯一实现**、owner-only 的业务规则前置拦截与"不回 403/404"红线。
+  **测试**: `test_manual_publish_does_not_re_approve_and_marks_online`(成功 → `mark_terminal_state('online')` + 审计 `app.release.manual_publish`,**不产生新版本记录**,决议-6)→ AC-32 / `test_manual_publish_failure_keeps_pending_and_does_not_change_approval` → AC-32 / `test_manual_publish_owner_only_prefilter_not_permission_runtime`(**管理员在权限运行时被身份短路放行**,`permission_action_service.py:372-385` → owner-only 必须是业务规则前置拦截,C4)→ AC-32, AC-62 / `test_status_service_is_single_implementation_for_ui_and_mcp`(AC-38「两处返回一致」由"只有一处实现"**结构性保证**,不靠约定)→ AC-38 / `test_status_returns_reject_reason_full_text_and_pending_reason` → AC-38 / `test_status_shape_matches_contract`(design §4.2 ② 字段表)→ AC-38 / `test_status_no_permission_returns_business_code_not_403`(platform 拦截器对 `403/404` **整页跳转 `/403`**,`request.ts:160-166` → 无权者返回 200 + `16254` 或 `silent: true`,K11 ② / 坑 22)→ AC-38, AC-62 / `test_tenant_admin_can_view_but_cannot_withdraw_delete_manual_publish`(角色矩阵)→ AC-62 / `test_can_flags_reflect_role_and_state` → AC-62 / `test_deleted_app_status_reports_cancelled_independently`(D10 读侧防御)→ AC-38
+  **覆盖 AC**: AC-32, AC-38, AC-62
+  **依赖**: T033, T035
+
+- [x] **T037**: `[MVP-核心]` `publish_status_service` + 手动上线实现
+  **文件**: `src/backend/bisheng/app_publish/domain/services/publish_status_service.py`(新)
+  **逻辑**: `get_publish_status(app_id, actor)` —— **AC-38 的唯一实现**(发布面 + F052 MCP 应用状态工具共用)。返回 design §4.2 ② 的结构:`app_state` / `pending_reason` / `current_version` / `pending_version` / `deployment{stage,status,failure}` / `approval{instance_id,status,reject_reason,...}` / `tier` / `capabilities`(本轮恒空数组)/ `schema_change`(本轮恒 `null`)/ `can{withdraw,manual_publish,submit}`。**不写库**。`request_manual_publish(app_id, actor)`:owner-only 前置拦截(16254)→ 调 F054 `POST /api/v1/apps/{app_id}/actions/manual-publish` 对应的 `AppStateService.manual_publish` → 成功 `mark_terminal_state('online')` + 审计;仍失败保持待上线并回成因。能力「已失效」标记**按需计算**(`declared ∖ 当前可解析`),**不落库、不起定时任务**(本轮 `capabilities` 恒空,接口形状先定死,D13)。
+  **测试**: T036 全部通过。
+  **覆盖 AC**: AC-32, AC-38, AC-62
+  **依赖**: T036
+
+- [x] **T038**: `[MVP-核心]` `/api/v2/apps` 管线端点集成测试(权限矩阵 + 全链)
+  **文件**: `src/backend/test/app_publish/test_deploy_api.py`(新)
+  **逻辑**: TestClient + 真实包素材(`fake_orchestrator` / `fake_minio`),覆盖四个 v2 端点与 AC-04 权限矩阵。
+  **测试**: `test_deploy_requires_app_manage_scope_else_rejected`(缺位 → 明确提示缺哪位)→ AC-04 / `test_logs_requires_app_manage_scope` → AC-04 / `test_delegate_scope_rejected_with_local_dev_hint`(错误指向「委托专用、本地开发另发一把」;F049 期 `delegate` 位**根本发不出来**,本期天然成立、只需对齐文案,INV-31)→ AC-04 / `test_first_deploy_sets_owner_from_resource_owner_user_id` → AC-04 / `test_iteration_deploy_other_owner_rejected_16205_with_ownership_hint`(含租户管理员名下服务账号的密钥)→ AC-04 / `test_logs_only_for_owner_app` → AC-04 / `test_session_cookie_cannot_call_v2_endpoints`(v2 只认 `Bearer bs-sak-…`)→ AC-04 / `test_full_pipeline_happy_path_creates_version_and_approval`(断言 `app_deployment` 阶段推进、`app_version` 落行、审批单生成、`app.release.*` 审计落行且在 UI 白名单内)→ AC-01 / `test_active_release_blocks_second_deploy_16251` → AC-03 / `test_pending_online_blocks_deploy_16252` → AC-03 / `test_deployment_polling_returns_failure_tuple` → AC-11 / `test_deploy_limits_returns_settings_values` → AC-01 / `test_cross_tenant_deployment_id_not_visible` → AC-01 / `test_app_runtime_disabled_returns_16207` → AC-01
+  **覆盖 AC**: AC-01, AC-03, AC-04, AC-11
+  **依赖**: T021, T023, T031
+
+- [x] **T039**: `[MVP-核心]` `/api/v2/apps` router + 四端点实现
+  **文件**: `src/backend/bisheng/app_publish/api/router.py`(新), `src/backend/bisheng/app_publish/api/endpoints/deploy.py`(新), `src/backend/bisheng/api/router.py`(挂接新 router)
+  **逻辑**: **F055 自建 router `/api/v2/apps` 挂 `router_rpc`,每个端点打 `@open_api_scope("app:manage")` marker,鉴权由 `router_rpc` 的 `verify_open_api_access` 单一管线承担**(2026-09-10 改接 beta2 F053,原 `Depends(open_api_subject(...))` 写法作废;**不自建鉴权**,K8)。
+  **⚠️ 工场运行时层未启用闸(`16207`)**:四个端点共用一个前置依赖 `require_app_runtime_enabled()` —— 读 F054 的 `settings.app_runtime.enabled`(未部署工场运行时层的存量环境该值为假)→ 假则直接 `16207`。**位置写死为「`verify_open_api_access` 鉴权之后、归属判定之前」**:先鉴权再报"功能未启用",避免未认证方探测部署形态;早于归属判定则是因为环境没启用时根本没有 `app` 表数据可判。漏这一闸 = T038 的 `test_app_runtime_disabled_returns_16207` 红测,且存量环境调 `deploy` 会一路走到编排器 RPC 超时。
+  四端点(design §4.2 ①):`GET /deploy-limits` → `{max_package_mb, max_unpacked_mb, max_package_entries}`(F053 打包后上传前自查,取不到时退化为直接上传由 16201 兜底)· `POST /deploy`(multipart:`package` + `app_id?` + `confirm_schema_change`〔**本期只接受不消费**,避免 CLI 侧改两次〕)→ `{deployment_id, app_id, version_id, entry_url?}` · `GET /deployments/{deployment_id}` → 轮询载荷 · `GET /{app_id}/logs` → **转发 F054 `GET /api/v1/apps/{id}/logs` 的同一服务方法**,只加 `app:manage` + 归属人与租户判定。owner 判定读 `OpenApiPrincipal.resource_owner_user_id`(**不是** `subject_user_id`);**租户判定读 `OpenApiPrincipal.tenant_id`**(不读租户 ContextVar——它会被 `_load` 系列助手改写成资源自身的租户),`deploy` / `logs` / `deployments/{id}` 三处一致。端点一律经 domain service、不直接 import `database/models`(RULE-3)。
+  **测试**: T038 全部通过。
+  **覆盖 AC**: AC-01, AC-03, AC-04, AC-11
+  **依赖**: T038
+
+- [x] **T040**: `[MVP-核心]` `/api/v1` 发布状态端点集成测试
+  **文件**: `src/backend/test/app_publish/test_publish_status_api.py`(新)
+  **逻辑**: TestClient 覆盖登录态下的状态只读与手动上线端点。
+  **测试**: `test_publish_status_endpoint_path_and_shape`(`GET /api/v1/apps/{app_id}/publish-status`,形状在此定死,供发布面与 F052 消费)→ AC-38 / `test_publish_status_non_owner_gets_business_code_not_403_404`(坑 22)→ AC-38, AC-62 / `test_manual_publish_endpoint_owner_only_16254` → AC-32, AC-62 / `test_withdraw_goes_through_existing_approval_endpoint`(直接调既有 `POST /api/v1/approval/instances/{instance_id}/withdraw`,**不新建撤回端点**——owner-only 由它已有的 `applicant_user_id` 校验天然成立,`:430`)→ AC-34 / `test_all_endpoints_return_unified_response_model` → AC-38 / `test_cross_tenant_app_id_rejected` → AC-38
+  **覆盖 AC**: AC-32, AC-34, AC-38, AC-62
+  **依赖**: T037
+
+- [x] **T041**: `[MVP-核心]` `/api/v1` 发布状态端点实现
+  **文件**: `src/backend/bisheng/app_publish/api/endpoints/publish_status.py`(新), `src/backend/bisheng/app_publish/api/router.py`(**增量**注册 v1 子路由,不改 T039 已落的 v2 部分)
+  **逻辑**: `GET /api/v1/apps/{app_id}/publish-status`(登录态,`UserPayload` 注入)委托 `PublishStatusService.get_publish_status`;`POST /api/v1/apps/{app_id}/publish/manual-publish` 委托 `request_manual_publish`(**F054 已有 `actions/manual-publish` 端点做状态动作,本端点只做 owner-only 前置 + 版本终态标注的编排**,避免两处都能改应用态)。**无权者返回 200 + 业务码**(K11 ②)。`UnifiedResponseModel` 包装。
+  **测试**: T040 全部通过。
+  **覆盖 AC**: AC-32, AC-34, AC-38, AC-62
+  **依赖**: T040, T039
+
+#### 3.5 事件触达
+
+- [x] **T042**: `[MVP-核心]` 六类触达测试(收件人解析 + 消息不承载操作)
+  **文件**: `src/backend/test/app_publish/test_publish_notification.py`(新)
+  **逻辑**: 断言 AC-64 六类事件的接收方与 AC-65 的"只通知不承载操作"发送契约。
+  **测试**: `test_approval_created_notifies_approvers`(Gate 不发、由我们自发,坑 5)→ AC-64 / `test_approved_and_rejected_notify_owner` → AC-64 / `test_withdrawn_notifies_approvers_who_received_task` → AC-64 / `test_cancelled_by_delete_notifies_approvers_new_action_code`(`approval_instance_cancelled`)→ AC-64 / `test_pending_online_notifies_owner_and_tenant_admin_root_super_admin` → AC-64 / `test_resource_released_and_capability_revoked_send_nothing`(两类**不主动提示**,仅发布面自查)→ AC-64 / `test_non_approval_notifications_use_neutral_message_type`(**发送契约写死**:`message_type` **不得为 `request` / `approve`**——`isApprovalMessageType`(`messageApproval/notificationContent.ts`)在这两个类型下**也为真**,并不只看 action_code 白名单,用错类型会长出一个点了会报错的跳转按钮,AC-65 当场破)→ AC-65 / `test_notifications_carry_no_action_payload` → AC-65
+  **实际偏差记录**(2026-08-19 核实并勾选): ①用例名与本文预写的清单不逐字一致(实际 14 例,见 `test_publish_notification.py`),但**六类事件与两条发送契约逐条覆盖**:approve / reject 经 `ApprovalCenterService.decide_task_api` **真跑引擎**而不是读 `publish_notification_service` 的 docstring;withdraw 两例(普通场景收件人 = 持有 task 的审批人、自审场景排除操作人本人后一条都不发);「资源释放后可手动上线」「能力被收回」两类**不主动提示**同时用行为侧(真跑 `manual_publish` 断言零通知)与结构侧(`ast` 遍历整个 `bisheng/app_publish` 断言只有两个发送调用点)双重钉住——只看一次 manual_publish 分不清「我们选择不通知」和「这条分支恰好没走到」。②「删除致取消」一类不在本文件,端到端覆盖在 `test_release_terminal_states.py::test_app_deleted_cancels_active_instance_and_notifies_approvers`,本文件的 `test_app_publish_owns_exactly_two_notification_call_sites` 是它不被悄悄搬走的护栏。③两个 fixture(`approval_notifications` 答「谁被通知」/ `message_sink` 答「消息长什么样」)刻意互斥——同时请求会观察到一条根本没到达消息服务的通知。
+  **覆盖 AC**: AC-64, AC-65
+  **依赖**: T033, T035
+
+- [x] **T043**: `[MVP-核心]` `publish_notification_service` 实现
+  **文件**: `src/backend/bisheng/app_publish/domain/services/publish_notification_service.py`(新)
+  **逻辑**: 六类触达的收件人解析与发送,**复用** `ApprovalNotificationService` / 既有站内消息服务(不新建通道)。新 action_code(design §4.2 ⑦):`approval_instance_cancelled`(审批人)· `app_publish_pending_capacity`(owner + 租户管理员,Root → 超管)· `app_publish_deploy_failed`(同上)。后两者是**非审批类**,`message_type` 用中性类型(见 T042 的发送契约)。待上线两类的管理员收件人**直接复用** `_get_admin_recipient_ids` 的无条件 union(多通知一个超管无害;与 T025 的条件回退是两码事,**别混用**)。**消息只通知不承载操作**(AC-65),处理动作一律回发布面或审批中心。
+  **测试**: T042 全部通过。
+  **实际偏差记录**(2026-08-19 核实并勾选,**实现早于勾选**——服务与两个调用点在 Wave 3.1–3.4 那批就已随 `publish_approval_service` / `publish_online_service` 落地,只是复选框一直没勾): ①本服务只承担**六类里的三类**(首节点待办 `approval_task_pending` + 待上线两类 `app_publish_pending_capacity` / `app_publish_deploy_failed`),其余三类(通过 / 驳回 / 撤回)由审批引擎自己发——我们再发一份等于让 owner 一个事件收两条站内信。这条不对称是设计本身,模块 docstring 顶部的事件表是其单一出处。②`approval_instance_cancelled` 的发送落在 `approval_center_service.cancel_instance_by_business`(`:600`)而不是本文件——它由 F054 的删除 hook 触发,发送方是审批模块。③真实调用点两处且仅两处:`publish_approval_service.py:254` → `notify_approvers_of_new_task`、`publish_online_service.py:261` → `notify_pending_online`(由 T042 的 `ast` 结构测试钉死)。
+  **覆盖 AC**: AC-64, AC-65
+  **依赖**: T042
+
+#### 3.6 前端 Platform(手动验证)
+
+- [x] **T044a**: `[MVP-核心]` Platform:发布面 API 层 + 审批状态卡
+  **文件**: `src/frontend/platform/src/controllers/API/hostedApp.ts`(F054 已建,**增量**加 `getPublishStatus` / `manualPublish` / `getVersions` / `withdrawApproval` 四个方法), `src/frontend/platform/src/pages/BuildPage/hostedApp/publish/ApprovalStatusCard.tsx`(新), `src/frontend/platform/public/locales/{zh-Hans,en,ja}/bs.json`(三语一组:本卡新增 key;**与 T045 是同一组文件、必须同 PR**)
+  **逻辑**:
+  - 四个 API 方法:`getPublishStatus(appId)` → `GET /api/v1/apps/{appId}/publish-status`(T041)· `manualPublish(appId)` → `POST /api/v1/apps/{appId}/publish/manual-publish` · `getVersions(appId, params)` → 版本列表(**返回体须是 `{data,total}`**,供 T044b 的 `useTable` 消费)· `withdrawApproval(instanceId)` → 直接调既有 `POST /api/v1/approval/instances/{id}/withdraw`(**不新建撤回端点**)。**一律经 `controllers/request` 封装,禁 `import axios`**(C7)。
+  - `ApprovalStatusCard`:待审 / 通过 / 驳回 / 已撤回 / 待上线成因(资源不足 · 上线失败文案区分)+ **驳回理由全文**(不截断)+ 结构变更提示位(本轮恒空)+ 在途时「撤回」按钮 + 「手动上线」按钮(按 `can.manual_publish` 显示,动作本体由 F054 T067 已交付、本卡只按状态放行)。
+  - 数据拉取用裸 `useState + useEffect`(K11 ①:platform `react-query` 已被 eslint 冻结);无权时接口回业务码(**不能触发整页跳 `/403`**,坑 22)。
+  **手动验证**: 打开 `http://192.168.106.114:3001/build/apps/{appId}` 的「发布」tab —— ① 在途时状态卡显示「待审」且「撤回」可点,点后变「已撤回」;② 驳回后能看到**理由全文**(不截断);③ 待上线(资源不足)时出现「手动上线」,点击成功后状态变「已上线」;④ 非 owner 打开**不整页跳 `/403`**,而是看到只读或提示区块;⑤ 切 en / ja 无裸键名。
+  **覆盖 AC**: AC-32, AC-33, AC-34, AC-62
+  **依赖**: T041
+  ✅ 2026-09-10 核实已落地(`src/frontend/platform/src/controllers/API/hostedApp.ts` 的 `getPublishStatusApi` / `manualPublishViaPipelineApi` / `withdrawApprovalApi` 三方法 + `pages/BuildPage/hostedApp/publish/ApprovalStatusCard.tsx` 已接入;版本列表方法见 T044b 偏差注(2026-09-10 已改 `{data,total}` 分页契约))
+
+- [x] **T044b**: `[MVP-核心]` Platform:版本列表卡 + 危险操作卡 + 填 F054 slot
+  **文件**: `src/frontend/platform/src/pages/BuildPage/hostedApp/publish/{VersionListCard,DangerZoneCard}.tsx`(新,**每块一个文件**,避开 F054 `PublishTab.tsx` 与 600 行硬规), `src/frontend/platform/src/pages/BuildPage/hostedApp/tabs/PublishTab.tsx`(**只填 slot**,不重写壳), `src/frontend/platform/public/locales/{zh-Hans,en,ja}/bs.json`(三语一组:本批新增 key;同 T044a / T045 一组)
+  **逻辑**:
+  - `VersionListCard`:只读列表(版本号 / 类型 / 提交时间 / 终态标注;待上线版本显示「待上线」),**不提供回滚入口**。⚠️ **绝不复用 `CardSelectVersion`**(`BuildPage/CardSelectVersion.tsx`)——它**切换即写库**(`handleChange :25-31` 调 `changeCurrentVersion`),且 `version_list` 对托管应用**恒空**(坑 29)。数据拉取用 `util/hook.ts:215 useTable`(**要求接口返回 `{data,total}`**,否则 `:238` 直接 console.error)。
+  - `DangerZoneCard`:显式删除调 F054 动作,仅 owner;已上线态置灰并提示「请先下线」。
+  - **AC-06**:CLI 导入应用的「提交发布」按钮**不可用并提示以 `bisheng deploy` 提交**(本册 CLI 应用无草稿工作区,决议-2)。
+  - **填 slot**:把 T044a 的 `ApprovalStatusCard` 与本任务两卡装进 `PublishTab.tsx` 的 slot。**不重做 F054 T067 已交付的**应用态徽标 / 入口链接 / 下线 / 重新上线 / 手动上线三按钮;F055 只把 `can.manual_publish` / `pending_reason` 经 props 下传,让「手动上线」按待上线态出现。**可见范围区是 F056 的槽位**,只留位不写内容。
+  **手动验证**: 同一「发布」tab —— ① 版本列表只读、无任何切换 / 回滚控件;② 手动上线成功后版本列表该行终态由「待上线」变「已上线」且**不多出一行**;③ 「提交发布」置灰并提示走 `bisheng deploy`;④ 已上线态下删除按钮置灰并提示「请先下线」;⑤ F056 的可见范围槽位为空时布局不塌;⑥ 三卡同屏布局正常、无横向滚动。
+  **覆盖 AC**: AC-06, AC-39, AC-61
+  **依赖**: T044a
+  ✅ 2026-09-10 收尾并勾选。DangerZoneCard / 三卡填 slot / AC-06「提交发布」置灰并提示走 `bisheng deploy` 早已落地(`hostedApp/index.tsx:132-156`、`publish/DangerZoneCard.tsx`、`publish/ApprovalStatusCard.tsx:140-148`),本批只补版本列表的分页契约。**实际偏差记录**:①**版本列表契约**——`GET /api/v1/apps/{app_id}/versions` 返回体由裸数组改为 **`{data,total}`**,新增查询参数 `page`(≥1,默认 1)/ `page_size`(1–200,**缺省 = 不分页、整表返回、`total == len(data)`**;越界 422 不静默夹紧;超出末页答空 `data` 不报错),行形状(`version_id / version_no / kind / terminal_state / submitted_at / is_current / is_pending`)不变;服务侧新增 `AppQueryService.list_versions_page`,切片在内存做而非 SQL `LIMIT/OFFSET`——数据以单应用部署次数为界、卡片下拉本就整表读取(要找运行中版本,分页可能不含它),且 `AppVersionDao` 的 INSERT-only 结构断言(`test_app_state_service_registry.py::test_version_insert_only_no_update_method` 钉死方法集)不值得为一个 count 方法开口;后端单测 `test/app_runtime/test_apps_api.py::TestVersions` 2 例(信封 + 跨页 `total` / 末页 / 越界 / 422)。②**前端 API 拆两个方法**:`getHostedAppVersionPageApi(appId, {page, pageSize})` 回 `{data,total}` 供 `useTable`;`getHostedAppVersionsApi(appId)` 保留数组返回(拆信封)供卡片下拉与版本 tab,后两处调用方零改动。③`VersionListCard` 改 `util/hook.ts useTable`(pageSize 10;只有超过一页才渲染 `AutoPagination`,复用 `pagination.totalPrefix / totalSuffixWithPageSize` 既有文案,**本批未新增 i18n key**);`useTable` 吞掉拒绝(只清 loading),卡内在 `apiFun` 里截获业务码文案后再抛回,非 owner 的 16106 显示为提示而不是「暂无版本记录」;`appId` 变更回第 1 页重拉、`reloadKey` 变更留在当前页重拉。验证:`pytest test/app_runtime` 309 passed / 13 skipped;platform `vitest`(hostedAppApi / hostedAppState / publishVocabulary / visibilityScope 31 例)、`pnpm typecheck`、`pnpm lint`、`check-i18n` 全绿。
+
+- [x] **T045**: `[MVP-核心]` Platform:审计前端 lockstep + 三语 i18n
+  **文件**: `src/frontend/platform/src/controllers/API/log.ts`(`actions` / `getModulesApi` 加 `app.release.*`), `src/frontend/platform/public/locales/{zh-Hans,en,ja}/bs.json`(三语一组:`log.systemIdEnum` / `log.eventTypeEnum` + 发布面新增 key)
+  **逻辑**: 完成 design 坑 21 的**四处 lockstep 的第 2–4 处**(第 1 处是 T006)——**必须与 T006 同 PR**,漏任一处 = 事件写库了但审计页与筛选下拉一条看不到,排查半天以为审计没写。同批补 T044a / T044b 发布面的三语 key(**`bs.json` 三语是本任务与 T044a/T044b 共用的同一组文件,三者必须同 PR**;新 key 三语齐全,CI `pnpm check-i18n` 校验 key parity)。**不加铃铛**(PRD §3.0.3:管理后台不设消息面)。
+  **手动验证**: 审计页「系统操作」筛选出现「应用」命名空间,筛选后能看到 `app.release.submit` / `approval_created` / `approved` / `online` 四条且带应用名;切换 en / ja 无裸键名。
+  **覆盖 AC**: AC-01
+  **依赖**: T006, T044a, T044b
+  ✅ 2026-09-10 核实已落地(`src/frontend/platform/src/controllers/API/log.ts` 的 `V2_ACTIONS` 含 `app.release.*` 全量并经 `actionToI18nKey` 映射;`public/locales/{zh-Hans,en-US,ja}/bs.json` 的 `log.eventTypeEnum.appRelease*` 三语齐)
+
+- [x] **T046**: `[MVP-核心]` Platform:审批场景配置补 `tenant_admin` 来源选项
+  **文件**: `src/frontend/platform/src/pages/ApprovalPage/index.tsx`(`APPROVER_SOURCE_OPTIONS`,约 `:590-597`)
+  **逻辑**: 该下拉今天**只有 6 项、没有 `tenant_admin`**,而 `APPROVER_SOURCE_LABEL_KEYS`(`:180-188`)与三语(`bs.json` 的 `approverSource.tenant_admin`,**两处** `:1832` / `:1842`)**早就有**(坑 23)→ 只需补选项一行。不补 = 租户管理员一旦改配审批人,就**再也没法把「租户管理员」这个来源加回来**,AC-19 半残。「应用发布」场景**自动出现在左栏列表**(seed 落库即有,`:1632-1700`)且因已存在而不出现在「新增」下拉(`:232`)→ AC-19 的"展示"部分**零前端改动**。场景名 `应用发布` 是**后端硬编码中文**、前端直接渲染 `s.scenario_name`(`:1660`)——**接受中文单语**(与既有三场景一致,坑 24),不另开映射表。
+  **手动验证**: 管理后台 → 审批中心 → 场景配置 → 左栏出现「应用发布」并展示预置配置(单条无条件分支 / 单节点或签 / 两个来源);改配审批人时下拉里能选到「租户管理员」;改完后新发布按新配置生成审批单,重启后端不被重置。
+  **覆盖 AC**: AC-19
+  **依赖**: T027a
+  ✅ 2026-09-10 核实已落地(`src/frontend/platform/src/pages/ApprovalPage/approverSources.ts` 含 `tenant_admin` 来源,`ApprovalPage/index.tsx` 消费该表)
+
+#### 3.7 前端 Client(手动验证)
+
+- [x] **T047**: `[MVP-核心]` Client:审批单四分区面板 + 驳回理由必填 + i18n 债
+  **文件**: `src/frontend/client/src/components/approval/AppPublishDetailPanel.tsx`(新), **存量落点(2026-09-10 改接 beta2 后,原 `ApprovalCenterDialog.tsx` 已删)**:`src/frontend/client/src/components/approval/ApprovalDetailPanels.tsx`(早分派)· `approval/approvalPresentation.tsx`(`DETAIL_INTERNAL_KEYS` / `DetailHeader`)· `approval/ApprovalPane.tsx`(驳回必填), `src/frontend/client/src/locales/{zh-Hans,en,ja}/translation.json`(三语一组:四分区面板文案 + 抽出的 4 条中文;**与 T048 是同一组文件、必须同 PR**——新 key 三语同 PR 是根 AGENTS.md 硬规、CI `pnpm check-i18n` 拦)
+  **逻辑**:
+  - 在 `approval/ApprovalDetailPanels.tsx` 的 `TaskDetailPanel` / `RequestDetailPanel` 内按 `detail.scenario_code === 'app_publish_request'` **早分派**到新组件(**不建通用注册表**——一个场景不值得,既有先例 `canRevoke :378-382` 就是"顶层算个布尔 + 条件渲染";出现第二个场景时再抽,D14)。(vibe 期落点 `ApprovalCenterDialog.tsx` 已随 beta2 拆分删除。)
+  - **四分区**(AC-24):① 头部基本信息(应用名 / owner / 来源:平台内造或 CLI 导入 / 首发或迭代 / 提交时间)→ 复用 `approval/approvalPresentation.tsx` 的 `DetailHeader` + `InfoGrid`;② 能力声明白话摘要 → **新分区**(`InfoGrid` 签名是 `[string,string][]`,装不下"图标 + 名称 + 说明 + 失效标记"三段行;本轮声明恒空 → 显示「本次发布未声明平台能力」);③ 可见范围快照 + **「仅供参考,可见范围变更即时生效、不经审批」黄注** → 新分区(参考 `ApprovalDetailPanels.tsx` 的「申请理由」灰底块);④ 资源档位(含结构变更时的「结构变更」行,本轮恒空)。
+  - **必须把结构化子树的键加进 `DETAIL_INTERNAL_KEYS`**(`approval/approvalPresentation.tsx`:`capabilities` / `visibility_snapshot` / `tier` / `schema_change` / `approver_note` …),否则未分派到的通用两列网格会把它们渲染成 `[object Object]` 一坨(坑 7)。
+  - **驳回理由必填**(AC-24):落 `approval/ApprovalPane.tsx`——驳回按钮 `disabled={actionLoading || !decisionComment.trim()}`(2026-09-10 核实该文件已有此写法;vibe 期 `runTaskDecision` 的中文兜底已随旧文件删除)。**通过仍可空**——AC-24 只要求驳回必填,别一刀切。
+  - **MVP 期不渲染审读视图整块**(连「查看待上线版本」按钮也不出,避免死链,D14)。
+  - **i18n / lint 债(触碰即还)**:vibe 期 `ApprovalCenterDialog.tsx` 的冻结违规已随 beta2 拆分消失(`client/eslint-suppressions.json` 无 `approval/` 条目);触碰 `approval/*` 时仍以 lint 实跑为准(根 AGENTS.md「谁触碰谁还债」)。
+  **手动验证**: 用审批人账号登录 `http://192.168.106.114:4001/workspace` → 审批中心 → 打开应用发布单:① 详情是**四分区**而不是两列网格,无 `[object Object]`、无裸英文键名;② 不填理由点「驳回」按钮**置灰点不动**,填了才可点;③ 点「通过」不填评论可提交;④ 无「查看待上线版本」按钮(不出死链);⑤ `pnpm lint` 通过。
+  **覆盖 AC**: AC-24
+  **依赖**: T029
+  ✅ 2026-09-10 核实已落地(`src/frontend/client/src/components/approval/AppPublishDetailPanel.tsx` 四分区,`ApprovalDetailPanels.tsx` 按 `isAppPublishScenario` 早分派;`ApprovalPane.tsx` 驳回按钮 `disabled={actionLoading || !decisionComment.trim()}`)
+
+- [x] **T048**: `[MVP-核心]` Client:站内信场景文案 + 三语 key
+  **文件**: `src/frontend/client/src/components/messageApproval/notificationContent.ts`(`APPROVAL_TASK_SCENARIO_TEXT_KEYS`;2026-09-10 改接 beta2 后原 `NotificationsDialog.tsx` 已删), `src/frontend/client/src/locales/{zh-Hans,en,ja}/translation.json`(三语一组)
+  **逻辑**: **一处代码**:`APPROVAL_TASK_SCENARIO_TEXT_KEYS` 加 `app_publish_request: 'com_notifications_action_request_app_publish'`(同族键 `com_notifications_action_request_channel` 在 `zh-Hans:489` / `en:501` / `ja:486`)。**三语 key**:该键 + `approval_instance_cancelled` + **两条非审批类** `com_notifications_action_app_publish_pending_capacity` / `com_notifications_action_app_publish_deploy_failed`——后两者走 `messageApproval/NotificationRow.tsx` 的兜底 key `com_notifications_action_{action_code}`**零前端代码**,前提是发送侧用中性 `message_type`(T042 的发送契约)。
+  **手动验证**: 审批人铃铛出现「提交了应用发布申请」(**不是裸 action_code**);owner 在待上线时收到「资源不足」通知且**没有跳转按钮**(AC-65);切 en / ja 无裸键。
+  **覆盖 AC**: AC-64, AC-65
+  **依赖**: T043, T047
+  ✅ 2026-09-10 核实已落地(`src/frontend/client/src/components/messageApproval/notificationContent.ts` 的 `app_publish_request` / `app_publish_pending_capacity` / `app_publish_deploy_failed` 文案键 + `locales/{zh-Hans,en,ja}/translation.json` 三语齐)
+
+#### 3.8 `withdraw` 终态守卫(§6 字面读法留在 MVP 内)
+
+- [x] **T051**: `[MVP-核心]` `withdraw` 终态守卫(非 PENDING 一律拒;错误码走 **approval 段 181xx**、不占 162)
+  **文件**: `src/backend/bisheng/approval/domain/services/approval_center_service.py`(`withdraw_instance` 约 `:432` 之前加守卫), `src/backend/bisheng/common/errcode/approval.py`, `src/frontend/packages/locales/src/api_errors/{zh-Hans,en,ja}.json`(新错误码三语一组), `src/backend/test/app_publish/test_withdraw_guard.py`(新)
+  **逻辑**: `withdraw_instance` 今天只校验 `applicant_user_id`、**不校验 instance 状态**(坑 4)→ 已 APPROVED / REJECTED / CANCELLED 的单子被直接打 API 也能"撤回",落到 F055 就是**已上线版本的 `terminal_state` 被反复改写成 `withdrawn`**(`on_withdrawn` 照样触发)。守卫加在 `applicant_user_id` 校验之后、状态变更之前:`if instance.status != PENDING: raise ApprovalInstanceNotPendingError`(**新码归 approval 段 181xx**,不占 F055 的 162 段——它修的是审批模块的既有缺陷,不是应用发布的业务错误)。
+  **⚠️ 跨 Feature**: 改的是审批模块公共 API 的行为(收紧),对频道订阅 / 知识空间加入两个既有场景同样生效 → 测试须含这两个场景的回归;同 PR 更新 approval-module skill(T050 一并做)。
+  **测试**: `test_withdraw_pending_still_succeeds` / `test_withdraw_approved_rejected_181xx`(三个终态各一次)/ `test_withdraw_cancelled_rejected` / `test_online_version_terminal_state_not_overwritten_by_late_withdraw`(AC-22 的真实后果)/ `test_existing_channel_and_knowledge_scenarios_withdraw_unaffected_when_pending`(行为收紧的护栏)
+  **实际偏差记录**(2026-08-19 完成): ①落码 **18118 `ApprovalInstanceNotPendingError`**(approval 段,未占 162),三语文案落 `packages/locales/src/api_errors/`,六个生成物由 `packages/locales/scripts/build.mjs` 产出、未手改;`docs/constitution.md` C5 补 **181 = approval 段位说明条目**(181 此前只在表里有名字、无细则),并写明「在这里加码会同时收紧菜单权限 / 频道订阅 / 知识空间加入 / 应用发布四个场景,必须有全场景回归」。②守卫**刻意排在 `applicant_user_id` 校验之后**——反过来的话,陌生人打一个终态单子会拿到「已结束」而不是「无权限」,等于把「哪些单子还开着」探测出去;有专门用例钉住这个顺序。③`test_withdraw_guard.py` 共 15 例全绿,含负向对照验证(把守卫改成 `if False and ...` 后 11 例转红),并含频道订阅 / 知识空间加入两个既有场景的「PENDING 仍可撤回 + 三终态各拒」回归。④守卫是 `!= PENDING` 一刀切,因此 `executing` / `executed` / `execute_failed` / `exception` 四个审批后状态同样被拒——符合「已结束不能撤回」,但 `exception`(通过后业务执行异常)场景产品是否要给申请人别的出口,未定,测试里只写了注释没写用例。⑤**未顺手改**:`withdraw_instance` 里 instance 不存在仍 `raise ValueError`、非申请人仍 `raise PermissionError`,两个裸异常落到前端是 500 而非可渲染业务错误(与 backend AGENTS.md 相悖)——改动既有契约会影响前端分支,另行评估。
+  **覆盖 AC**: AC-22
+  **依赖**: T035
+
+#### 3.9 114 部署验证与上游回写
+
+- [ ] **T049**: `[MVP-核心]` 114 部署与手动验证清单(演示剧本步 3–4)
+  **文件**: `features/v3.0.0/055-app-publish-pipeline/tasks.md`(本文「实际偏差记录」回填)
+  **前置**: F049 / F054 首波已部署 → `bash /opt/bisheng-ops/deploy.sh` → **先发代码、再往 `config.yaml` 加 `app_runtime` 新键、再全量重启**(K10,顺序反了直接拒启)→ 确认**消费默认队列的 celery worker 在跑**(K12:缺它则管线后段与审批 outbox 双双不执行)。
+  **验证清单**(逐条勾):
+  0. **前置自检**:`curl -s http://127.0.0.1:7860/api/v1/env | jq '.app_runtime_enabled, .open_platform_enabled'` → 均 `true`;`SELECT code,cpu_millicores,memory_mb,enabled FROM resource_tier;` → 三行且 **`light` 为 500 毫核 / 1024 MB**(2026-09-09 起的出厂值;若该环境是更早播种的,配置项与常量都不会改动它,须直接 UPDATE 这一行,见 spec 决议-10);`SELECT tenant_id,scenario_code,enabled FROM approval_scenario WHERE scenario_code='app_publish_request';` → 有行;`ps -ef | grep 'celery.*worker'` → 有默认队列消费者。
+  1. **步 3**:`bisheng deploy` → CLI 分阶段输出 `manifest → 构建 → 探活 → 扫描 → 审批单已生成`(**spec 字面顺序**)→ AC-01。
+  2. **故意验证扫描**:包里塞一行 `token = "bs-sak-<43位>"` 再 deploy → 被阻断、输出含 `文件:行号`、**输出里 grep 不到那 43 位串** → AC-10。
+  3. **故意验证 manifest**:删掉 `runtime` 再 deploy → `16221` 且 `details` 指出缺哪个字段 → AC-11。
+  4. **在途闸**:不撤回直接再 deploy → `16251` → AC-03。
+  5. **审批人解析**(114 单租户 → **必然走 Root 回退**):`SELECT approver_user_id FROM approval_task WHERE instance_id=;` → 平台超管(AC-15);owner 有主部门且该部门设了部门管理员则应同时出现(AC-12);`SELECT applicant_user_id FROM approval_instance WHERE id=;` → **是 owner 自然人不是服务账号**(AC-16 / 坑 28)。
+  6. **步 4**:用**审批人账号**(非发起人)登录 client → 铃铛有「提交了应用发布申请」(AC-64)→ 审批中心详情是**四分区**(AC-24)→ 不填理由点「驳回」置灰 → 填理由点「通过」→ `docker ps` 出现 `bisheng-app-*`、应用态「已上线」、`app_version.terminal_state='online'`(AC-31)。
+  7. **审计**:审计页筛「应用」命名空间能看到 `app.release.submit` / `approval_created` / `approved` / `online` 四条且带应用名(AC-01;看不到 = 四处 lockstep 漏了一处,坑 21)。
+  8. **待上线分支**:把 runtime-manager 的 `RTM_RESERVE_MB` 临时调到极大并重启该进程,使容量闸必拒(⚠️ 改 `config.yaml` 的 `app_runtime.reserve_mb` **不生效**,闸在 runtime-manager 进程里、只读自己的环境变量,照那样做这一步会静默走成正常路径并被判为通过) → 再走一遍审批通过 → **审批单仍是"通过"**、应用态「待上线(资源不足)」、owner 与超管各收到一条**无跳转按钮**的站内信(AC-31 / AC-65);恢复配置后发布面点「手动上线」→ 成功且**不产生新版本记录**(AC-32 / 决议-6)。
+  9. **驳回 / 撤回**:驳回一次 → owner 在发布面看到**理由全文**(AC-33);再提交后 owner 点「撤回」→ 审批人待办消失(AC-34);**再对同一个已终态的 instance 直接打 `POST /api/v1/approval/instances/{id}/withdraw`** → 被 181xx 拒、`app_version.terminal_state` 不被改写(AC-22 / T051)。
+  10. **删除致取消**:新建应用提交到待审 → 删除该应用 → 审批单 CANCELLED 且审批人收到通知(AC-35)。
+  11. **既有场景回归**(行为变更护栏):频道订阅 / 知识空间加入各走一次审批,审批人解析正常(AC-21)。
+  **⚠️ health 200 会骗人**(admin 短路 ReBAC)——涉及权限的判定用非管理员账号复核。
+  **覆盖 AC**: AC-01, AC-03, AC-10, AC-11, AC-12, AC-15, AC-16, AC-21, AC-22, AC-24, AC-31, AC-32, AC-33, AC-34, AC-35, AC-64, AC-65
+  **依赖**: T039, T041, T044a, T044b, T045, T046, T047, T048, T051
+
+- [x] **T050**: `[MVP-核心]` 上游回写三项 + approval-module skill 同步
+  **文件**: `features/v3.0.0/054-app-domain-runtime/{design.md,tasks.md}`(回写 1、2), `features/v3.0.0/053-dev-cli-skills/spec.md`(回写 3), `.claude/skills/approval-module/SKILL.md`(§3 / §4 / §5 / §7 / §8)
+  **逻辑**(design §8「必须回写上游的三项」):
+  1. **F054 `DEFAULT_TIERS`**:数值与第三档名称按 D11 裁定回写(1C/2G · 2C/4G · 4C/8G,「增强」→「性能」),并把 F054 design D11「何时重新考虑」里的"支持删档"改为"档位只可停用不可删"——**代码侧改动在 T015,本任务只改文档**。**⚠️ 2026-09-09 后续修订**:轻量档已按实测改为 0.5C/1G(spec 决议-10),标准与性能两档维持原值。
+  2. **F054 建应用契约行**:真名是 **`AppProvisionService.create_draft(*, name, slug, description, owner_user_id, tenant_id) -> app_id`**(不是本文原写的 `AppStateService.create_app` —— F054 T049 的实际偏差记录 ① 已说明:「建应用」不是状态迁移,塞进「应用态唯一写入方」会稀释决议-8)→ 在 F054 design §4.2 ② 与 §6.1 各登记一行。**2026-08-19 完成**:§4.2 ② 早已有该行;§6.1 本次补上,写明「首发建应用的唯一入口」「slug 冲突答 16103」「F055 只调不直写 `app` 表(`publish_pipeline_service._create_draft:217`)」。
+  3. **F053 spec AC-32**:补一句「上限经 `GET /api/v2/apps/deploy-limits` 取,取不到则直接上传由服务端 16201 兜底」——否则 CLI 只能硬编码 50 MiB,正是 K7「同一契约分两处必漂移」自己反对的形态。
+  4. **approval-module skill**:本 Feature 改了 `approver_resolver`(T025)、新增 `cancel_instance_by_business`(T035)、新增预置场景与四件套(T027a / T027b / T029)、新增站内信触发时机(T043)、收紧 `withdraw_instance`(T051)→ **同一 PR 必须更新 SKILL.md**(该 skill 的维护契约明写"改完代码后问自己:本 skill 里有没有哪句话现在变成假的了")。
+  **⚠️ 不做的一项**:**不要**去改 `release-contract.md:98` 的错误码分配表与 `constitution.md` C5——161–164 早已落定(F054 落码时写入),照旧文回写会产生空转任务,还可能让实现者去"修正"本已正确的表(K9)。
+  **实际偏差记录**(2026-08-19 完成并勾选):
+  1. **`DEFAULT_TIERS` 回写**:早于本次完成——F054 design §6.1 的档位行已写「1C/2G · 2C/4G · 4C/8G,数值与第三档名以 F055 spec AC-44 为准,2026-08-17 由 F055 T015 回写」;D11「何时重新考虑」也已改成「**「支持删档」这条已被 F055 关掉**——`ResourceTierDao` 不提供删除,退役只有 `enabled=False`」。本次只核实,未改。**⚠️ 2026-09-09 后续修订**:轻量档已按实测改为 0.5C/1G(spec 决议-10),标准与性能两档维持原值。
+  2. **建应用契约行**:本文原写的 `create_app` **不是真名**,实际是 **`AppProvisionService.create_draft`**(F054 T049 偏差记录 ① 说明了为什么:「建应用」不是状态迁移,塞进 `AppStateService` 会稀释决议-8)。F054 design **§4.2 ② 早已有该行**;**§6.1 本次补上**。本文内三处 `create_app` 字样(依赖索引 :70、T017 逻辑、T029 测试说明)同批更正为真名;`test_first_deploy_creates_draft_app_via_f054_create_app` 是实际存在的测试函数名,**未改**。
+  3. **F053 spec AC-32 补 `deploy-limits` 口径**:已写进 F053 spec 的 **AC-32 正文与 §3 边界**——上限经 `GET /api/v2/apps/deploy-limits` 取(服务端唯一出处 `package_service.deploy_limits()`),取不到(端点未部署 / 该跳不通 / 16207)时按内置默认值给**提示性**自检并照常上传、由 16201 兜底。**「一次取不到上限绝不能阻断 deploy」**这句是关键——CLI 侧 `_check_size` 的 docstring 逐字表达了同一判断("a soft check that can kill the main flow is worse than no check")。
+  4. **approval-module skill 同步**:`.claude/skills/approval-module/SKILL.md` 已更新 §2 / §3 / §4 / §5 / §7 / §8 / §10 / §11 / §12 + front-matter TRIGGER。落点比本文原列的五节更宽,因为核对时发现另外几处已经变假:**§4 的 seed 段整段过时**(真身已从 `common/init_data.py::_init_default_approval_scenarios()` 迁到 `approval/domain/services/approval_seed_service.py`,租户从常量变成参数,「新租户不自动 seed、需管理后台手工配置」这句**已经是错的**——两条建租户路径现在都调 `seed_approval_scenarios_for_tenant`);**§10 新增 `tenant_admin` 语义变更专节**(含新旧对照表与三条不能改的性质,以及「这条改的是共享 resolver,配了该来源的既有节点审批人集合当场就变」的回归口径);§11 新增两条排障入口(18118、审批人从超管变成别人)。
+  **依赖**: T015, T025, T027a, T027b, T029, T035, T043, T051
+
+---
+
+---
+
+### Wave 4 · 紧随首波(release 必做,本轮顺延——只列标题 / 文件 / 测试载体 / 覆盖 AC)
+
+> 优先级顺序取 design §8(`withdraw` 守卫已上提至 Wave 3.8,见文首「§6 字面读法的两处对齐」)。
+
+- [x] **T052**: 审读视图(左文件树 + 右只读代码 + 4 tab)+ 「查看待上线版本」入口;弹窗 `viewMode:'review'` 放宽至 1200px(K11 ③ / D14 案 A)
+  **文件**: `client/src/components/approval/AppReviewView.tsx`, **`client/src/pages/settings/SettingsPage.tsx` + `client/src/components/approval/ApprovalPane.tsx`**(2026-09-10 改接 beta2 后审批中心由设置页承载、原 `ApprovalCenterDialog.tsx` 已删;「弹窗 `viewMode:'review'` 放宽至 1200px」的前提随之失效,尺寸按页面重估), `client/src/locales/{zh-Hans,en,ja}/translation.json`, `src/backend/bisheng/app_publish/api/endpoints/snapshot.py`
+  **测试载体**: `src/backend/test/app_publish/test_snapshot_api.py`(快照只读端点:归属 / 审批人放行 / 二进制与超大文件降级)+ 前端手动验证清单(文件树可展开、代码只读不可编辑、4 tab 切换、1200px 下不横向滚动、无「查看待上线版本」死链)
+  **覆盖 AC**: AC-24, AC-25
+  - [x] **后端半(2026-09-16 完成,切片 publish-diff-review-backend)**:`api/endpoints/snapshot.py`(`GET …/versions/{version_id}/snapshot/tree` + `…/snapshot/file?path=`)+ `domain/services/snapshot_browse_service.py`(`ReviewAccess` 访问规则 + 流式读归档、不落盘);`test_snapshot_api.py` 20 个用例全绿(归属 / 审批人持任务放行 / **走真实 `publish_approval_service.submit` 产生的审批人放行**(钉住访问规则依赖的 `payload_snapshot.version_id` 与 `business_resource_type="app"`)/ 其它版本审批人与陌生人 16257 / 二进制与超大降级 / 密钥 mask / 非法路径 16258 / 快照缺失或损坏 16256 / 单顶层目录去前缀 / 条目超 `max_package_entries` 置 `truncated` / 无整包下载路由)。契约见 design §4.2 ⑨。审校补交:`packages/locales` 六个生成产物(platform `api_errors.json` ×3、client `api_errors.gen.json` ×3)经 `node scripts/build.mjs` 重建后随码提交(首次提交漏了,`--check` 会挂 CI)。
+    **实际偏差记录**:① 审批人放行判据取"持有**该版本**发布申请的审批任务"(任意状态),不是"持有任一任务"——被驳回后回看自己审过的版本仍可,但审批版本 3 不开版本 1;② 除 owner / 审批人外,**租户管理员与平台超管也放行**(与 `publish-status` 的 `_require_viewer` 同口径,审批人来源本就含 `tenant_admin`);③ 树 / 单文件**不落盘**,只有差异接口走 `safe_extract`;④ "不可预览"走数据级降级(`previewable:false` + `reason`)而不是错误码,错误码只给"文件不存在 / 路径非法"(16258);⑤ 三个新码 16256/16257/16258 落在发布流程段,16259 预留未用。
+  - [x] **前端半(2026-09-16 完成,切片 f055-review-frontend,提交 `3789e5e28`)**:`client/src/components/approval/AppReviewView.tsx`(壳 + 四 tab)+ `ReviewSourcePane.tsx`(左树右只读正文)+ `reviewTree.ts`(扁平条目折树的纯函数)+ `api/hostedAppReview.ts`(四个只读端点、业务码从 200 信封里取出来抛);入口按钮落在 `AppPublishDetailPanel` 的基本信息区,经 `ApprovalDetailPanels` 透传到 `ApprovalPane` 的 `reviewTarget` 状态。三语各 21 键。测试:`reviewTree.test.ts` 8 例 + `AppReviewView.test.tsx` 7 例 + `hostedAppReview.test.ts` 5 例 + `AppPublishDetailPanel.test.tsx` 新增 1 例,client jest 全量 `8 failed | 67 passed (75) · 7 failed | 561 passed (568)`,与主检出基线 `8 failed | 64 passed (72) · 7 failed | 540 passed (547)` **逐文件同一个失败集**(+21 全为新增)。
+    **实际偏差记录**:① **尺寸前提按页面重估的结论 = 审读视图占满设置内容区**(`col-span-full` 接管 300px 列表 + 详情两栏),不是「弹窗放宽到 1200px」——beta2 把审批中心改成设置页后已无弹窗可放宽,D14 案 A 的实质(同一处界面切 viewMode、不新开路由与权限守卫)原样保留;② **新增一个后端只读端点 `…/review-context`**(见 design §4.2 ⑨ 的 ⚠️ 段):AC-25 的「版本历史」与「文件差异」两个 tab 在审批人身上原本无法成立,`publish-status` 与 F054 的版本列表都只放 owner / 租户管理员 / 平台超管。不新增错误码;③ 「预览试用」置顶按钮属 T054,本切片未做;④ 入口按钮放在基本信息区而非通过 / 驳回旁——只读动作混进两个决策按钮之间是误点来源;⑤ 两端 `tailwind.config` 的 `content` 补了 `packages/file-viewers/src`,此前共享组件的 class 只在「恰好本应用别处也用了」时才会生成。
+    **审校修正(切片 f055-review-frontend 自审)**:① **业务码文案改走 `api_errors`**——`api/hostedAppReview.ts` 原先把信封里的 `status_message`(后端写死的中文)当错误文案抛出,等于让 en / ja 审批人看到中文,而 16253 / 16256 / 16257 / 16258 的三语文案早已在 `packages/locales` 里;改用既有的 `utils/apiStatusError.createApiStatusError`(该文件的存在理由正是这个错误曾经上线过一次),顺带删掉从没有人用过的 `HostedAppReviewError` / `REVIEW_ERROR_CODES` 两个导出;② **失败态由空串改为对象**——`failure` 既当消息又当「是否失败」的标志,消息为空时会 falsy 落回正常分支,把「读取被拒」画成「这个包里没有文件」;③ **空文件不再被说成「不支持在线查看」**——判据从 `file.content` 改为 `file.previewable`,`content: ""` 是合法的空文件;④ 选中文件后到取数 effect 跑起来之间那一帧改显示「正在读取」而不是「不支持在线查看」;⑤ 两处 `text-[#ff7d00]` 改 `text-warning`(同色值,且暗色模式跟得上)。新增 `src/api/hostedAppReview.test.ts` 5 例(信封解包 / 路径转义 / 差异新旧侧顺序 / 业务码取 `api_errors` 文案 / 无文案时仍失败关闭)+ `AppReviewView.test.tsx` 补空文件 1 例。
+    **手动验证清单(待 114 实机走一遍)**:文件树可展开折叠 · 代码区只读且无下载入口 · 4 tab 切换 · 首发版本的「文件差异」tab 显示「没有可对比的已发布版本」而非空白 · 二进制 / 超大文件显示原因而不是空正文 · 窄屏(<768px)左右两栏改上下堆叠、不横向滚动 · 非审批人打开同一 URL 看到业务码文案而不是整页 403 · 切 en / ja 无裸键。
+
+- [x] **T053**: 审批期临时预览实例后端(快照拉起 / 临时空库 / 审批人身份注入 + owner 权限放行〔NFR-1.2 审批例外,INV-36〕/ 终态与超时回收 / 不占实例名额)
+  **文件**: `src/backend/bisheng/app_publish/domain/services/preview_instance_service.py`, `src/backend/bisheng/app_publish/api/endpoints/preview.py`, `src/backend/bisheng/app_publish/domain/models/app_preview_session.py`(新表), `src/runtime-manager/runtime_manager/preview.py`(新意图), `src/backend/test/app_publish/test_preview_instance.py`
+  **覆盖 AC**: AC-26, AC-27, AC-28, AC-29, AC-30
+  **证据**(分支 `wt/f055-preview-instance`,提交 `22fdb51f5` + `fb818bc30` + 复核修 `HEAD`):`test/app_publish/test_preview_instance.py` 31 例全绿 · `src/runtime-manager/tests/test_preview_instance.py` 28 例全绿(runtime-manager 全量 265 passed / 5 skipped,基线 237 / 5)· 后端 `test/app_publish test/app_runtime` 合跑 **976 passed / 13 skipped / 0 failed**(主检出同选择基线 928 passed / 2 failed,两条失败即本切片修掉的门禁,见偏差 ④)。
+
+  **偏离与必须知道的落点**:
+  ① **预览不是 `deploy`,是三个新意图**:`POST /v1/intents/preview` · `POST /v1/intents/preview/stop` · `GET /v1/previews/{session}/route`。原文写「复用 `probe` 临时形态 + `deploy` 意图」两条都不成立——`probe_image` 在 `finally` 里把容器删掉(预览要留着),而 `deploy` 会写 `app_id` 键的期望态记录,那条记录正是「占实例名额」和「被 reconciler 复活」的原因。新意图**不写期望态**:`store.committed()` 与 `capacity.instances` 都不计它(`test_starting_a_preview_writes_no_desired_state_record`),容器标 `bisheng.managed=preview` 而不是 `=true`,reconciler 的过滤器看不见它。容量仍然照判——内存是真花掉的,只是名额不计。
+  ② **「临时空库」= tmpfs,不是「新建一个空的应用卷」**:`/data` 挂 `rw,size=64m,mode=1777`,**没有 `Binds`**(AC-29 的试用数据带不进生产)。`mode=1777` 是必须的,不是装饰:镜像以非 root 的 uid 10001 跑,默认 tmpfs 是 root 0755,应用开不了 `/data/app.db` 就 exit 1,症状是「预览拉不起来」而没人猜得到原因(与 `probe.py` 同一个坑)。
+  ③ **预览按审批人分,不按版本分**:两个审批人同一个版本各拿一个实例。AC-27 要求注入审批人本人身份,共用一个就等于让审批人 B 看见审批人 A 敲进去的数据。DAO 的 `aget_running_for` 因此按 `(version_id, approver_user_id)` 取。
+  ④ **超时回收不落平台定时任务**——这是与 F054 既有门禁 `test_switch_off_no_resident_process_or_beat_task`(AC-59「不得往平台镜像里塞常驻 worker」)的正面冲突,先写了 beat 条目再撤掉的。最终两处各管一半:**容器**由 runtime-manager 的 reconcile 每 15s 按容器标签 `bisheng.preview.expires_at` 收(deadline 随容器走,因为管理器才是容器存在的前提进程);**行**由 `resolve_entry` 当场拒过期会话(对访问者精确)+ 审批人打开面板时 `reclaim_expired()` 对账(一次带索引的范围查询)。没有标签的预览**永不**被扫(缺值不等于「现在就收」)。
+  ⑤ **访问规则复用 `ReviewAccess`,文案不复用**:门与审读视图同一道(该版本的审批人 / owner / 租户管理员 / 平台超管),但 16257 的文案是「没有查看该版本代码的权限」,放在一个「跑起来试试」的按钮旁边是错句子,所以另给 **16264**。新增 16264–16267 四码 + 三语 `api_errors`;16268/16269 预留未用。
+  ⑥ **新增审计 action 两个**:`app.release.preview_started` / `app.release.preview_reclaimed`,四处 lockstep(enum + `_UI_VISIBLE_V2_ACTIONS` + platform `log.ts` + 三语 `bs.json`)已同批完成。回收理由 `manual` / `approval_terminal` / `expired` 记在 metadata,三条腿在容器日志里长得一样,事后问「这次试用为什么结束」只能靠它分辨。
+  ⑦ **保留 slug `preview` / `_unavailable`**(`app_provision_service.RESERVED_SLUGS`):`/apps/preview/{session}` 的路由注册在 `{slug}` 之前,不保留名字的话,一个叫 `preview` 的应用会被永久遮蔽,而对它的负责人来说症状是「我的应用对所有人 404」且任何日志里都没有线索。
+  ⑧ **通过(approved)也回收**:AC-28 的「终态」是四个,不止驳回 / 撤回 / 取消。除 `publish_terminal_service` 的三条外,`publish_online_service.bring_online` 里也调了一次——否则审批通过的版本会让预览和真身并排跑满 7 天。
+  ⑨ **第四个 `reclaim_reason`:`start_failed`**(自审补)。拉起失败时行也要关掉(否则面板画着一个不存在的运行实例),但把它记成 `manual` 是往审计里写假话——没人按过任何按钮,而这行被保留下来的唯一理由正是回答「它为什么结束」。三语文案同批补齐。同批还改了 `start`:**过期但仍 running 的旧行不再原样交回**(面板会先扫,但直连 POST 不会),改为就地回收再拉一个新的。
+  ⑩ **欠一条 alembic 迁移**:无。`app_preview_session` 是整表新建,走 `create_all(checkfirst=True)`(后端 AGENTS.md 的 schema 归属规则);本切片未改任何既有表。
+
+  ⑪ **AC-27 只做到了一半,另一半本分支做不了,别当它已经交付**(复核补记)。做到的是「注入审批人本人身份」与「临时库可写」;**没做**的是「平台能力按 owner 权限放行」与能力调用计审计——能力总线本身是 T056~T059,不在本分支上,没有可以放行的调用点。为了让那一半**可以**被实现而不是被默默做错,本切片把事实带到了线上:预览入口签发的 OBO 令牌里多一个 `preview_session` 声明(`entry_authz_service._issue_obo_token`),能力总线据此分辨「这次访问是审批试用」。**接 T056~T059 的人必须读这条**:不读它,能力总线会对预览按访问者(审批人)的可见范围过滤,于是一个不在应用知识库范围里的审批人看到的是「这个版本坏了」,而 NFR-1.2 / INV-36 登记的例外形同虚设。正式入口的 OBO **不带**这个声明,`test_obo_token_signed_with_dedicated_secret` 用整字典相等钉住——带上了就等于把审批例外倒过来变成越权。
+
+  ⑫ **预览容器拿的是 §5 的整套环境变量**(复核修)。初版只注入了 `BISHENG_APP_BASE_PATH`,`BISHENG_APP_DB_URL` / `BISHENG_APP_ID` / `BISHENG_APP_SLUG` / `BISHENG_APP_VERSION(_ID)` / `BISHENG_PLATFORM_API_BASE` / `PORT` 一个都没有——应用拿不到数据库地址会在启动时退出,症状是「预览拉不起来」而审批人读成「这个版本是坏的」,`/data` 那块 tmpfs 也就白挂了。现在 `build_preview_payload` 走 `lifecycle.build_env`(与 deploy 同一个函数),意图入参补 `slug` / `version_no` / `platform_api_base` / `base_path`。**唯二的差别**:`base_path` 是 `/apps/preview/{session}`;**附件句柄三名一个都不注入**(`storage_token=None`)——应用自己的 storage bearer 交给临时实例,试用上传就会落进生产附件前缀,与 AC-29 拦数据库是同一条线。用例:runtime-manager `test_a_preview_gets_the_whole_environment_contract` / `test_a_preview_carries_no_attachment_handle`,backend `test_the_preview_is_handed_the_whole_environment_contract`。
+
+  ⑬ **跨租户超时对账后必须把租户上下文放回去**(复核修)。`reclaim_expired()` 走 bypass 扫全站,而每条回收都要把租户上下文指到那行自己的租户(审计行要写对版本,而版本读是带租户过滤的)。扫完不还原,紧接着 `describe` 里那次**带过滤**的自查就落到别人租户上,面板对一个正在跑的试用答「未拉起」,审批人于是再拉一个容器。现在 `reclaim_expired` 在 `finally` 里还原调用方租户;用例 `test_sweeping_another_tenants_expired_row_does_not_hide_my_own`(并断言外租户那行确实被收了,否则断言是空的)。
+
+  ⑭ **「拉起」这一个调用挂上 `require_app_runtime_enabled`**(复核补):与 deploy / 档位端点同一道闸。运行层关掉的环境里拉预览会一路走到编排器 RPC 再超时,读起来是「平台坏了」而不是「这个环境没装这个功能」(16207)。**读与回收刻意不挂闸**——试用跑着的时候把运行层关掉,不能让那行没法关掉。用例 `test_raising_is_refused_when_the_runtime_layer_is_off`。
+
+  ⑮ **`orchestrator_client.preview_route` 已删**(复核):manager 上 `GET /v1/previews/{session}/route` 照旧存在,但它的唯一调用方是 app-proxy(自带 manager 客户端),backend 从不解析预览上游。门面方法集是 lockstep 断言的,留一个没有调用方的方法等于把死代码抄进两个 conftest 长住。门面现为 **17** 个方法。
+
+- [x] **T054**: 审批期预览前端(「预览试用」置顶 / 四个界面状态 / 打开预览 / 手动回收)
+  **文件**: `client/src/components/approval/AppPreviewPanel.tsx`, `client/src/api/hostedAppPreview.ts`, `client/src/components/approval/AppReviewView.tsx`(置顶挂载), `client/src/locales/{zh-Hans,en,ja}/translation.json`
+  **测试载体**: 前端手动验证清单(四个界面状态各截一次:未拉起 / 拉起中 / 可用 / 已回收;「预览试用」在详情面板置顶;手动回收后实例消失且不占名额;切 en / ja 无裸键)
+  **覆盖 AC**: AC-26, AC-28
+  **证据**(分支 `wt/f055-preview-instance`):`AppPreviewPanel.test.tsx` 12 例 + `AppReviewView.test.tsx` 新增置顶 1 例;client jest 全量 `8 failed | 69 passed (77) · 7 failed | 607 passed (614)`,与主检出基线 `8 failed | 68 passed (76) · 7 failed | 594 passed (601)` **同一个失败集**(+13 全为新增)。`tsc-strict` / `eslint` / `pnpm check-i18n` 全绿。
+
+  **偏离与必须知道的落点**:
+  ① **落点是审读视图顶部,不是审批单详情面板**:spec §22 与 AC-25 写的是「审读视图……「预览试用」置顶」,T052 的偏差记录 ③ 也是在审读视图的语境里说「本切片未做」。所以面板挂在 `AppReviewView` 的 tab 条**之上**(`AppPreviewPanel` 在 tab 切换时不卸载,切 tab 不会打断一个正在跑的试用);入口仍是 `AppPublishDetailPanel` 的「查看待上线版本」。
+  ② **「拉起中」是前端自己的状态,不是后端的**:`preview_start` 只在实例通过探活后才返回,所以平台侧根本观察不到半拉起态——用 Button 的 `loading` 表达请求在途,**不轮询**。轮询会凭空造出一个后端刻意不存在的状态。
+  ③ **「打开预览」开新标签页**:预览跑在自己的 URL 上,就地替换会把审批人赶出审批单、再让他从别人的应用里找路回来记决定。
+  ④ **失败态不改画面**:拉起失败只显示原因、按钮照旧可按(AC-26「允许重新拉起」);回收失败仍把实例画成运行中——失败的回收把实例画成已消失,是让人以为容器没了而实际还在跑。
+  ⑤ **文案不搬内部词**:面板叫「预览试用」,按钮是「拉起预览 / 打开预览 / 回收」,不出现 session / instance / 实例名额这类内部对象名;「已回收」三种原因各一句话(自己收的 / 审批结束 / 超时),一句话说清楚下一步能不能再拉。
+
+---
+
+### Wave 5 · 能力总线与应用运行期凭据(release 必做,本轮顺延)
+
+- [x] **T055**: `hosted_app` 主体解析器注册与凭据生命周期(签发 / 重签 / 5 秒失效 / 下线拒绝 / 删除撤销 / 无任何管理入口 / 不进服务账号列表)
+  **文件**: `src/backend/bisheng/app_publish/domain/services/app_credential_service.py`, `src/backend/bisheng/open_api/domain/subject_resolvers.py`(注册 `SUBJECT_RESOLVERS['hosted_app']`), `src/backend/test/app_publish/test_app_credential.py`
+  **覆盖 AC**: AC-57, AC-58, AC-59, AC-60
+  **证据**(分支 `wt/f055-hosted-credentials`):`app_credential_service.py`(`AppRuntimeCredentialService.issue / revoke`、`resolve_hosted_app`、`HOSTED_APP_TOKEN_ENV = "BISHENG_APP_TOKEN"`、`assert_revocation_bound()`)+ 新表模型 `app_publish/domain/models/hosted_app_subject.py` + 底座三处(`api_credential.py` 加 `SUBJECT_KIND_HOSTED_APP` / `HOSTED_APP_TOKEN_PREFIX = "bs-app-"` 与 CHECK 放宽、`credential_service.credential_prefix()` 第三分支、`credential_validator._SUBJECT_KIND_PREFIXES` 前缀钉死)+ `context.py` 两处 Literal 扩容与新增 `OpenApiPrincipal.subject_ref` + `composition.register()` 注册解析器、执行守卫与删除钩子。测试 `test/app_publish/test_app_credential.py` 40 例全绿;`test/app_publish/ test/open_api/ test/app_runtime/` 合跑 1294 passed / 13 skipped / 0 failed(主检出同选择基线 1254 passed,差值 +40 = 本次新增用例数)。
+
+  **偏离与必须知道的落点**:
+  ① **`subject_id` 用代理键,不是 `app.id`**。`api_credential.subject_id` 是 BIGINT 而 `app.id` 是 uuid 字符串,新建表 `hosted_app_subject`(`id` BIGINT 自增 / `app_id` 唯一,**无 `tenant_id`**,隔离随 `app` 派生)给每个应用分配一次整型代理键。不复用 `owner_user_id`:那样 `revoke_subject('hosted_app', owner)` 会一次撤销该人名下**所有**应用的凭据(`test_revoking_one_application_leaves_a_sibling_of_the_same_owner_alive` 锁住)。应用的 uuid 经新字段 `OpenApiPrincipal.subject_ref` 透出,F051 T023 的 `ModelCallRecord.app_id` 用它(不要用 `actor_name`,那是应用显示名)。
+  ② **解析器落点不是 `open_api/domain/subject_resolvers.py`**(该文件在 beta2 底座上不存在;注册表在 `credential_validator.SUBJECT_RESOLVERS`)。解析器函数写在 F055 侧,由 `app_publish/composition.py` 的 `register()` 注入 —— 依赖方向保持 F055 → 底座,`open_api` 不 import `app_publish`。未注册前该 kind 按 `26002` 拒(F049 design D2 原文)。
+  ③ **鉴权期拒绝一律 26002,`16291` 只由签发路径抛**。「应用已下线 / 已删除 / 跨租户」三种都答同一个 26002:区分开会让过期 token 变成应用状态的探测器,而且 `validate_bearer` 的 `except OpenApiAuthError: raise` 只放行 260 段,非 260 段会被吞成 26021。`16291`(`AppRuntimeSubjectUnavailableError`)在 `issue()` 遇到应用不存在 / 已删除时抛,调用方是管线。
+  ④ **下线 = 主体停用,不撤销**。`resolve_hosted_app` 要求 `app.state == online`;重新上线后**同一把明文**继续可用,无需重新发布(`test_the_same_key_works_again_after_a_resume`)。5 秒上界由 `OpenApiConf.cap_credential_cache_ttl`(min(value, 5))保证,无轮询;`assert_revocation_bound()` 供测试锁住这条。删除经 F054 `lifecycle_hooks` 的**第二个独立订阅者** `on_app_deleted_revoke_credential` 撤销(与取消审批单分开注册,钩子 fan-out 才能分别记失败)。
+  **④-bis(审校补齐,2026-09-16):下线拒绝有同步 + 异步两条腿,缺一即漏。** 首版只挡住鉴权期,异步(Celery)腿 `execution_context.validate_execution_snapshot` 对 `service_account` 查启用、对 `natural_person` 查用户与成员资格,第三种 kind 走的是**没有 `else` 的 if/elif** —— 下线前一秒入队的任务会在几分钟后照常带着应用身份执行。补法:底座新增注册表 `execution_context.SUBJECT_EXECUTION_GUARDS`(**未注册的 kind 一律拒**,fail-closed 默认值),F055 侧实现 `assert_hosted_app_executable(credential, snapshot)`(`resolve_hosted_app` 的同步孪生:应用在线 + 双向租户一致 + 不读 user 表),由同一个 `register()` 与解析器**成对**注册。用例 `test_a_queued_task_of_a_stopped_application_is_refused[4 态]` / `…_moved_tenant_is_refused` / `…_with_no_surrogate_row_is_refused` / `test_a_subject_kind_without_an_execution_guard_cannot_execute` / `test_composition_registers_the_execution_guard`。
+  **④-ter(审校补齐):重签失败不得带走在跑的那把钥匙。** `issue()` 先撤后签,而作用域校验原本发生在 `CredentialService.issue()` 内部 —— 即在撤销之后,于是 T056 派生出一个本部署未开放的 scope 就会让正在跑的容器失去凭据、直到下次发布才恢复。改为在**撤销之前**先 `validate_scopes` + 拒 `delegate`,用例 `test_a_refused_reissue_leaves_the_running_key_alive[未知 scope / delegate]`。同时 `_ensure_subject` 对 `uk_hosted_app_subject_app` 的并发插入改为回读而不是把 `IntegrityError` 抛给管线。
+  ⑤ **`effective_user_id = None`(故意)**,`authorization_subject_type/id = user/owner`。访问用户只能来自每次请求的 OBO 令牌(F051 design D7),写进凭据就等于给能力总线留了一个"回退到 owner 全量可见范围"的口子,正是 AC-52 禁止的。T056/T057 接线时不要改这一条。
+  ⑥ **`scopes` 由调用方传入**,本服务不认识"模型 / 知识库";T056/T057 负责按能力声明派生。当前默认空集 = 有身份、零权限(能力总线未部署时的诚实表达)。`delegate` 位对本主体恒被 `OpenApiDelegateConfigurationInvalidError` 拒。
+  ⑦ **新增审计 action:无**。AC-58 的「两者事件计审计」由 F054 既有的 `app.stop` / `app.delete` 承载;凭据签发 / 撤销只写 `logger.info`,不传 `audit_operator`,以免运行期凭据出现在 `open_api.api_key.*` 审计里(AC-59 的口径是「无管理入口」,多一条可查的审计不违背,但多一个 action 要动四处 lockstep,不值)。
+  ⑧ **改了一条既有断言**:`test/open_api/test_database_contract.py::test_api_credential_contract_has_only_supported_subject_kinds` 原本断言 `hosted_app` **不在**受支持集合里(beta2 底座删掉 vibe 期常量时立的守卫)。它守的是"有 kind 没解析器",现在由「每个 kind 必须有独占前缀」(`test_every_subject_kind_has_a_prefix`)+ 未注册即 26002 两条接手。
+  ⑨ **欠一条 alembic 迁移(本轮未owning head,按公共 brief 只报不写)**:存量库的 `api_credential` CHECK 仍是两值,插入 `hosted_app` 会被约束拒。当前唯一 head = `merge_app_factory_beta2_heads`。所需内容:
+
+  ```python
+  """Widen api_credential.subject_kind CHECK for hosted applications (F055 T055)."""
+  revision = "f055_credential_hosted_app_subject"
+  down_revision = "merge_app_factory_beta2_heads"
+
+  _NAME = "ck_api_credential_subject_kind"
+  _NEW = "subject_kind IN ('service_account', 'natural_person', 'hosted_app')"
+
+  def upgrade() -> None:
+      # MySQL 8.0.16+ / DM8 都真执行 CHECK;SQLite 无 ALTER,靠 create_all 建新库。
+      dialect = op.get_bind().dialect.name
+      if dialect == "sqlite":
+          return
+      with op.batch_alter_table("api_credential") as batch:
+          batch.drop_constraint(_NAME, type_="check")
+          batch.create_check_constraint(_NAME, _NEW)
+
+  def downgrade() -> None:
+      # 降级前须先删掉所有 subject_kind='hosted_app' 行(属运维前置,迁移只发 DDL)。
+      ...
+  ```
+  新表 `hosted_app_subject` **不需要**迁移:升级期的 `create_all(checkfirst=True)` 覆盖整表新建(模型已挂进 `app_publish/domain/models/__init__.py`,该包已在 `tenant_filter._TENANT_AWARE_MODEL_MODULES` 中)。该表**没有** `tenant_id` 列,`_discover_tenant_aware_tables()` 因此跳过它、不自动过滤 —— 与 `app_version` / `resource_tier` 同一口径。
+  ⑩ **T056/T057 接线前必读的两处「合法但危险」**(本切片不改,改了就是替未落地的能力总线拍板):(a) `open_api/api/dependencies.py` 用 `authorization_subject_*` 构造 `PermissionActor`,对本主体即 `user:` —— 也就是说**权限天花板就是 owner 的普通授权**(`super_admin` / `tenant_admin` 两段被 `actor_kind == "natural_person"` 挡住,不会拿到管理捷径)。今天 `scopes` 恒为空集,端点的 scope 闸把一切挡在门外,所以无暴露;T057 的白名单 ∩ 可见范围必须**在这层之内**再收窄,否则 AC-52 名存实亡。(b) `open_endpoints/domain/utils.py::_principal_user_id` 的 `effective_user_id or resource_owner_user_id` 对本主体回落到 owner,legacy v2 业务服务会把应用当成 owner 本人做持久化与遥测 —— 同样今天不可达,T056/T057 放开 scope 时要一起拍板。
+  ⑪ **`OpenApiExecutionSnapshot` 不带 `subject_ref`**(有意,未改):快照是有损投影(`actor_name` 也没带)。异步腿要拿应用 uuid,用 `actor_id`(= `hosted_app_subject.id`)回查即可;F059 的能力调用审计双归属若需要 uuid,在自己的 writer 里回查,不要给快照加字段(那是跨 F051/F059 的契约变更)。
+  ⑫ **`open_api.call` 审计行对本主体是匿名的**(已知,未改):中间件的 `operator_id` 只对 `natural_person` 取值、`operator_name` 只对 `service_account` 取值,所以本主体落成 `operator_id=0` + 无名,审计面 `_operator_kind()` 判为 `system`。`metadata.actor_kind="hosted_app"` / `actor_id` / `credential_id` 仍在,可追溯。给它补显示名会让 `_operator_kind()` 误判成 `service_account`(判据是「operator_id=0 且有名」),属 AC-55 / T059 + F056 审计面一并拍板的事。
+
+> **T056–T060 于 2026-09-16 由切片 `f055-capability-injection` 一并落地**(分支 `wt/f055-capability-injection`,提交 `347ff7d52` 后端 + `0ff17021f` 前端)。五项共享同一个新模块 `capability_bus_service.py`,故证据与偏离在 T056 下集中记一次,其余任务只记各自的落点与用例。
+
+- [x] **T056**: 模型能力注入(经 F051 OpenAI 兼容面 + `BISHENG_PLATFORM_API_BASE` / `BISHENG_APP_TOKEN` 注入;未声明不可调用;工作台无任何底层账号 / 端点配置入口)
+  **文件**: `src/backend/bisheng/app_publish/domain/services/capability_bus_service.py`, `src/backend/test/app_publish/test_capability_model.py`
+  **覆盖 AC**: AC-49, AC-51, AC-54
+  **证据**: `capability_bus_service.py`(`HostedAppDeclarationAdapter` / `derive_scopes` / `undeployable_scopes` / `model_face_base_url` / `runtime_capability_env`)+ `app_runtime/domain/services/runtime_env_ports.py`(新端口)+ `app_state_service._start` 合并能力环境 + `composition.register()` 三处注册。用例 `test_capability_model.py` 18 例 + `test/app_runtime/test_capability_env_injection.py` 5 例,全绿。
+  **偏离与必须知道的落点**:
+  ① **注入的名字不是任务标题写的那两个**。按 design D13 与 F054 `contracts-runtime-manager.md` §5,模型面用 `OPENAI_BASE_URL` / `OPENAI_API_KEY` / `BISHENG_MODEL_BASE_URL` 三名 + `BISHENG_APP_TOKEN`;`BISHENG_PLATFORM_API_BASE` 是 **v2 业务面**地址,早已由 runtime-manager 的 `build_env` 注入,本切片不碰。任务标题沿用了 D13 修订前的写法。
+  ② **注入走端口而不是直连**。`app_runtime` 不得 import `app_publish`(RULE-5),故新增 `runtime_env_ports`(与 `lifecycle_hooks` 同形):F055 在 composition 里注册 provider,F054 在 `deploy` 前 `await capability_env(...)` 并把结果**最后**合并进 `env`(平台保留名覆盖调用方同名值)。未注册 = 无能力环境(诚实),provider 抛错 = 中止启动(不端上一个没有凭据的容器)。
+  ③ **明文凭据不进 `app_version.injections`**。凭据在拉起前现签、注入一次、不落库;`injections` 仍为 `{}`,`version_service` 未改。
+  ④ **`model_face_base_url()` 不经 `Request`**。上线发生在 Celery 腿,没有请求可读转发头;取 `open_api.public_base_url`,退 `app_runtime.entry_base_url`,两者皆空则**不注入**该变量并 warn(不猜地址)。路径常量复用 `public_base_url.MODEL_GATEWAY_BASE_PATH`。
+  ⑤ **16231 改了判据、保住了含义**。原先「声明非空即拒」(本轮不做)改为「声明派生出的 scope 在本部署不可签发即拒」——`model:invoke` 受 `open_platform.enabled` 闸,关着时声明模型仍应拒,否则会发布出一个每次模型调用都 403 的应用;只声明知识库(`knowledge:read` 无闸)则放行。
+  ⑥ **顺带修掉一个真缺陷**:`model_range_policy` 把 `principal.actor_name`(应用**显示名**)当 `app_id` 用,于是声明查找与 `model_call_record.app_id` 都落在名字上(正是 T055 note ① 提醒过的)。改为 `principal.subject_ref`(`app.id`),并把 `test/open_api/model_gateway_fixtures.hosted_app_principal` 补上 `subject_ref`、两条断言改成 uuid。
+  ⑦ **顺带补上 OBO 的第一个 reader**:`entry_authz_service` 此前只签不验,F051 的 `AccessSubjectVerifierPort` 一直没人注册(= 每个访问者令牌都被拒、每次调用都记成「应用自身」)。新增 `verify_obo_token` + `AccessSubjectVerifier`,由 F055 composition 注册(依赖方向仍是 F055 → F054)。**F054 AC-34 里「OBO 尚无消费者、暂不 fail-closed」的前提到此失效**——下一个动 entry 的切片应按当初的约定把「未签发令牌」改成拒绝进入。
+
+- [x] **T057**: 知识库能力注入与 fail-closed(白名单由平台按当前生效声明确定、应用不可自报;运行期可及 = 白名单 ∩ 访问用户可见范围,文件级经 F052 门面;无访问用户身份一律拒绝、绝不回退全量;集合相等断言)
+  **文件**: `src/backend/bisheng/app_publish/domain/services/capability_bus_service.py`(增量), `src/backend/test/app_publish/test_capability_knowledge.py`
+  **依赖**: F052 门面已落地(2026-09-16),契约见 [F052 design §4.2 ③](../052-mcp-server-face/design.md)。调用形态:`identity = await RetrievalIdentity.from_user(访问用户 id, tenant_id)`(**async**)→ `await RetrievalFacadeService.retrieve(identity, RetrievalRequest(query=…, whitelist=当前生效声明))`。`identity=None` 门面直接抛 26320,不必自己再判一次;白名单内的库已被删除 → 26322 `KnowledgeCapabilityRevokedError(data.knowledge_id)`,由 T058 转 16273。
+  **覆盖 AC**: AC-50, AC-52
+  **证据**: `CapabilityBusService.retrieve / accessible_knowledge`;`test_capability_knowledge.py` 11 例,含 AC-50 的集合相等断言(`set(effective_scope) == 白名单 ∩ 用户可见`)与无访问用户即 26320。
+  **偏离与必须知道的落点**:
+  ① **堵掉了一个当时还够不着、开了 scope 就会真漏的口子**(T055 note ⑩ (a) 点名的那个):`RetrievalIdentity.from_open_api_principal` 对 `hosted_app` 主体会用 `authorization_subject_*`(= 归属人)造身份、白名单为空 → **归属人全量可见范围**。本切片让它对该 kind 直接抛 26320,于是任何忘记走能力总线的检索面只会 fail-closed。`filelib/retrieve` 改为托管应用走总线,MCP 检索工具**故意保持拒绝**——MCP 面是开发者面且明确拒收身份头,没有访问用户可言。
+  ② **白名单在运行期解析、不冻结**。声明里的知识库可写 `id` 或 `name`;`name` 走新增的 `KnowledgeDao.aget_by_exact_names`(返回**全部**同名行,`aget_knowledge_by_name` 的 `.first()` 会静默绑到最早那条)。同名多条 = `ambiguous`,解析不到 / 类型不支持 = `revoked`,两者都掉出白名单但**不影响其余能力**。
+  ③ **知识读用应用自己的租户**,不继承调用方的 ambient tenant(`_as_tenant`,与 `model_catalog._as_tenant` 同形)。
+  ④ **复核时补上的真漏(26052)**:偏离 ① 只堵住了「拿 principal 造检索身份」这一道门,而 `knowledge:read` 这个 scope 一共罩着 **7 条 v2 路由**——`GET /filelib`、`GET /filelib/file/list`、`detail_qa`、`query_qa`、`GET /citation/{id}` 都走 `get_open_api_operator()`,对本主体解析为**应用归属人(带归属人角色)**;`GET /filelib/download_statistic` 更直接,凭 scope 就能取走 `/app/data` 下任意 `.log`。声明只买了「检索」一条,scope 却开了七条,所以改成**按路由准入、默认拒绝**:`OpenApiScopeMarker` 新增 `hosted_app` 开关(默认 `False`),`dependencies.admit_request` 对该 actor_kind 未开关即抛新码 **26052**(403,`OpenApiHostedAppEndpointRefusedError`,三语已补);只有 `POST /filelib/retrieve` 与模型面三条路由开了关。第二道防线在 `open_endpoints/domain/utils._principal_user_id`——MCP 面等非 `APIRoute` 入口拿不到 marker,那里同样拒。用例:`test/open_api/test_hosted_app_route_admission.py`(3 例,行为)+ `test_open_api_route_matrix.py::test_only_the_capability_faces_admit_a_hosted_application`(lockstep 清单)+ `test_capability_knowledge.py` 两例(兜底层)。AC-59 的源码扫描测试相应收紧:凭据符号仍然全树禁止,**准入控制**两处文件按名单放行并写明理由。
+
+- [x] **T058**: 能力收回错误态(`16273` 带能力名与「已收回」原因、不回退旧值、应用整体可用)+ 迭代上线后旧能力 5 秒内失效 + 发布面「已失效 + 原因」标记(按需计算、不落库、不起定时任务)
+  **文件**: `src/backend/bisheng/app_publish/domain/services/capability_bus_service.py`(增量), `platform/src/pages/BuildPage/hostedApp/publish/CapabilityListCard.tsx`(**新建方**——T067 只在其上增量补全,两处都标「新」会互相覆盖), `src/backend/test/app_publish/test_capability_revoked.py`
+  **覆盖 AC**: AC-37, AC-53, AC-63
+  **证据**: `16273` / `16274` 两个错误类补 `capability` / `kind` / `reason` 载荷(原先无参);`capability_status()` 按需计算、`model_capability_status()` 走 F051 `resolve_model_name` 出活口径;`publish_status_service._capabilities_payload` 接进发布面读模型。前端 `CapabilityListCard.tsx` + 6 例 vitest + 三语各 8 键。用例 `test_capability_revoked.py` 16 例。
+  **复核补充**:③ 16231 换了判据(见 T056 偏离 ⑤)却没换文案——`api_errors` 三语仍写着「本环境未启用能力总线,请移除 capabilities 声明」,现在的正确处置是让管理员开 `open_platform`,照旧文案改只会白改一轮。三语已改,产物已用 `packages/locales/scripts/build.mjs` 重新生成。④ `publish_status_service._capabilities_payload` 传 `version.capabilities` 时补 `or {}`:`capability_status()` 把 `None` 当作「改读正在跑的版本」,列为 NULL 的待发布版本会让发布面挂着待发布版本的名字、展示在跑版本的声明。
+  **偏离**:① **AC-37 的「5 秒」没有窗口需要断言**——生效声明按 `app.current_version_id` 每次现读、无缓存,指针一动下次调用即新声明;凭据侧的 5 秒上界仍由 T055 的 `assert_revocation_bound()` 守,本文件再断言一次以免它被当成实现细节删掉。② 发布面读模型里能力解析失败**答空列表并 warn**,不让知识模块的一分钟故障把整页拖垮(空读作「没展示声明」,不读作「一切正常」)。
+
+- [x] **T059**: 能力调用审计双归属(actor = 应用 / subject = 当前访问用户;**仅模型调用**允许 subject = 「应用自身」并显式标注,检索无访问用户一律拒;含模型名与 token 数 / 检索目标;subject 列随 F050,落地前以附加字段承载)
+  **文件**: `src/backend/bisheng/app_publish/domain/services/capability_audit.py`, `src/backend/test/app_publish/test_capability_audit.py`
+  **覆盖 AC**: AC-55
+  **证据**: `capability_audit.record_retrieval`(尽力而为、绝不把检索带崩)+ `assert_model_attribution`(钉住 F051 侧那一对,供测试调用)+ 新表 `app_capability_call_record`。用例 `test_capability_audit.py` 13 例。
+  **复核补充**:④ 总线自己抛的拒绝(`_assert_targets_declared` 的 16273 / 16274)原先**不落记录**,而同一句话在门面深一层抛(26322)却落——「这个应用一直在够什么」的答案会随哪一层说不而变。现已一并记录(有访问用户时),并把 `credential_id` 从 `filelib` 一路带到记录行(原先该列声明了却永远为 NULL)。用例 `test_capability_audit.py::test_a_refusal_the_bus_itself_raises_is_recorded_like_any_other`。
+  **偏离**:① **检索记录落自己的表,不落 `audit_log`**,与 F051 的 `model_call_record` 同一理由(design D9 / K7:托管应用一次问答一次检索,混进审计表会淹掉人类尺度的事件)。**因此不新增 audit action,四处 lockstep 未动**。代价是 F056 审计面目前读不到这张表——如果产品要求它出现在「系统操作」页,那是 F056 侧一次读模型扩展,不是再加一个 action。② 模型侧**一行代码都没重复写**:`model_call_record` 早已有 `app_id` / `subject_kind` / `subject_id` / token 数,本切片只修 `app_id` 取值(见 T056 偏离 ⑥)并把这对属性变成可断言的。③ 无访问用户的检索**不写记录**(没有可归属的 subject,编一个正是 AC-55 禁止的),refusal 本身由 26320 送达调用方。
+
+- [x] **T060**: 预检的能力声明引用校验(所引模型在本租户已启用且按 F051 名称解析规则可唯一解析〔裸名歧义 → 拒绝并提示限定名〕;所引知识库存在且为 F052 门面支持的类型)+ 审批单能力白话摘要接真数据
+  **文件**: ~~`src/backend/bisheng/app_publish/domain/services/precheck_service.py`(增量)~~ → `capability_bus_service.validate_capability_refs`,由 `publish_pipeline_service.accept()` 在同步腿调用, `src/backend/test/app_publish/test_precheck_capability_refs.py`
+  **依赖**: F052 门面已落地(2026-09-16),契约见 [F052 design §4.2 ③](../052-mcp-server-face/design.md)。知识库引用校验用 `RetrievalFacadeService.is_supported_knowledge_type(t)`(纯函数、非 async)与 `await RetrievalFacadeService.check_reachable(identity, 声明列表, whitelist=声明列表)` → `ReachabilityReport{reachable, unreachable, revoked}`。**预检必须走这两个方法而不是自己查表**——「门面支持的类型」只能有一份口径,否则预检放行的声明运行期会答不可及。
+  **覆盖 AC**: AC-07, AC-24
+  **证据**: `validate_capability_refs()`(模型走 `resolve_model_name`、知识库走门面两方法)+ `publish_approval_service._capabilities_payload`(审批单白话摘要接真数据)。用例 `test_precheck_capability_refs.py` 16 例。
+  **偏离**:① **落点不在 `precheck_service.py`**——该模块按其模块文档是**异步腿**(Celery 上的构建与探活),而 16224 是同步腿的码(design §4.2 阶段表第 2 行 `precheck_manifest` **同步**),且 CLI 要把它转成终端提示后**用同一个包重发**,只有在 `accept()` 里拒才成立。`validate_manifest` 里做不了:它拿不到租户与归属人。`precheck_service.py` 未改。② **可及性以「归属人」为上限**判定(design D13),不是任何访问用户——访问用户每次都不同,按谁校验都没有意义。③ 知识库同名歧义提示改用 id 引用;模型裸名歧义**原样透传 F051 的候选限定名**,不自己编占位符。
+
+---
+
+### Wave 6 · 结构演进与版本差异(release 必做,本轮顺延)
+
+- [x] **T061**: `precheck_schema` 阶段 + 改 / 删列显式确认(CLI 交互或 `--confirm-schema-change`;未确认拒 `16229`;已确认进入管线、发布时不再二次确认;审批单与发布面展示结构变更)
+  **文件**: `src/backend/bisheng/app_publish/domain/services/schema_evolution_service.py`, `src/backend/bisheng/app_publish/domain/services/precheck_service.py`(增量), `src/backend/test/app_publish/test_schema_evolution.py`
+  **覆盖 AC**: AC-09
+  **证据**(分支 `wt/schema-precheck`):后端 `bb6a9cf1d`(`schema_evolution_service.py` 纯函数 diff + `accept()` 同步闸 16229 + worker `precheck_schema` 阶段 + publish-status 读模型 `schema_change` + manifest `DatabaseColumn`;`test_schema_evolution.py` 19 例,`test/app_publish/` 383 passed);CLI `0274b36ac`(TTY 交互确认 / 非 TTY 原样报错 / `--confirm-schema-change` 直通;`tests/test_command_deploy_sync.py` +6,CLI 274 passed);展示面(platform `SchemaChangeNotice.tsx` + `schemaChange.ts`,vitest +8;client `AppPublishDetailPanel.tsx` 结构变更行,jest +1;三语文案)。
+  **偏离**:① 闸不在 `precheck_service.py`,而在 `publish_pipeline_service.accept()` 的同步腿(`16251` / `16252` 之后)——确认必须在 `deploy` 请求返回前发生,CLI 才能把 16229 转成终端提问后**用同一个包重发**;`precheck_service.py` 未改。worker 侧的 `precheck_schema` 阶段(scan 之后、build 之前)只推导摘要写入审批单快照,不二次询问。② 破坏性判据取 `drop_table / drop_column / modify_column`(type / nullable / default 任一变化即 modify),加表 / 加列为非破坏性、不询问;首发(无在线版本)不询问。③ 平台建表 / 迁移快照属 T062,本任务只做检测、确认与展示。
+
+- [x] **T062**: 应用数据表由 manifest 声明、平台建表(DEV-07 ②):加列自动迁移无需确认;改 / 删列**迁移前自动留生产数据快照**(键 `apps/{app_id}/db-snapshots/{ts}.tar`)
+  **文件**: `src/backend/bisheng/app_publish/domain/services/schema_evolution_service.py`(增量), `src/backend/test/app_publish/test_schema_migration_snapshot.py`
+  **覆盖 AC**: AC-42
+  **完成证据(2026-09-16,切片 `wt/f055-app-data-tables`,提交 `f670efe9c`)**:后端 `schema_evolution_service.migration_plan()` / `migrate_for_release()`(同一份 diff → 每表一条计划),在 `PublishOnlineService._settle` 里**拉起之前**调用;DDL 全部在 runtime-manager 的 `appdb.AppDbSchemaService`,经新 intent RPC `POST /v1/intents/db-migrate`(客户端方法 `orchestrator_client.schema_migrate`,fake 编排器 lockstep 集合两处同步为 16 个)。快照走 `Connection.backup` + tar + 私有桶 `bisheng-apps`,键如 AC 所写;破坏性计划由 manager 自判是否要快照。新错误码 `16259` + 三语;新审计动作 `app.release.schema_migration_failed` 四处 lockstep 登记。测试:`test/app_publish/test_schema_migration_snapshot.py` 20 例 + runtime-manager `tests/test_appdb_migrate.py` 23 例(RM 全套 260 passed;backend `test/app_publish` + `test/app_runtime` 949 passed)。
+  **偏离**:① 迁移入口不在数据面 `/v1/apps/{app_id}/db/*` 而在 `/v1/intents/db-migrate`——数据面「没有 POST、因而没有 DDL」这条断言(`test_appdb.py`)正是它安全的理由,加个 POST 就废了;同理客户端方法叫 `schema_migrate` 不叫 `db_migrate`,因为 `db_` 前缀的「唯一调用方」断言是按前缀 grep 的。② 改 / 删列走**整表重建**(建新表 → 拷公共列 → 换名)而不是 `ALTER … DROP COLUMN`:SQLite 改类型根本没有 ALTER,两类分开实现等于让两条代码路径各自把拷贝那步做对一次。③ 新增 NOT NULL 且无默认值的列**拒绝**而不是悄悄放宽为可空——声明是应用自己代码依赖的契约,线上可空、manifest 不可空的错会在很远的地方才炸。④ 迁移被拒记为**失败的管线运行**而非待上线(待上线会以 16252 拒掉携带修复的重新提交),应用在线时按 `iteration_failed` 发站内信、不在线时不发(理由随 T068 写进 §8)。⑤ `primary_key` 从 manifest 的 extra 键读,不加进 `DatabaseColumn` 显式字段——它不参与 T061 的变更判据,加成字段会悄悄扩大「发布可以被什么拒绝」。
+  **复审补丁(2026-09-16,同切片)**:⑥ **计划的每一条都带整表目标列,不再带增量**。原实现里 `add_columns` 只带新增的那一列,而执行器遇到「表不存在」会照着计划条目建表——线上库与参照声明不一致时(应用自己建的表、或在线版本是「声明了但平台还没建表」那个 MVP 阶段发的)就会建出一张只有一列的表,且所有既有断言仍然全绿。改为计划陈述目的地、执行器自己对着实际库算增量(只补缺的列);回归钉在 `tests/test_appdb_migrate.py::test_an_add_columns_plan_builds_a_table_that_is_not_there_yet`。⑦ **`resume` 也要建表**:审批落在已下线应用上时只 stage 不启动(AC-36),F055 的上线腿返回 `staged_only`、迁移不跑——真正把这个版本拉起来的是 `AppStateService.resume`,此前它直接 deploy,声明的表永远不会被建。现在 `_start` 在 `audit_action is RESUME 且有 pending_version_id` 时先迁移再拉起,被拒即 16259 抛给按按钮的人、应用留在 `stopped`;`publish` / `manual_publish` 已在 `_settle` 迁过,不重复付一次 RPC。新测试 `test/app_runtime/test_resume_schema_migration.py` 3 例,并给 `test/app_runtime/conftest.py` 的 `_SESSION_PATCH_TARGETS` 补上 `schema_evolution_service`(否则它用真实 session 读参照声明)。
+
+- [x] **T063**: 版本差异服务端 diff(`GET /api/v1/apps/{app_id}/versions/{a}/diff/{b}` → `{files:[{path,change,additions,deletions}], patches:[…]}`;**服务端算 diff、不下发两份 tar**)
+  **文件**: `src/backend/bisheng/app_publish/domain/services/version_diff_service.py`, `src/backend/bisheng/app_publish/api/endpoints/version_diff.py`, `src/backend/test/app_publish/test_version_diff.py`
+  **覆盖 AC**: AC-41
+  **完成证据(2026-09-16,切片 publish-diff-review-backend)**:三个文件如上落码,`test_version_diff.py` 15 个用例全绿(形态与三种 change / 响应不含归档 / 二进制与超大不比较 / 相同版本空 diff / 密钥先 mask 后 diff / 单文件行数闸 / 总 patch 字节闸 / 文件数闸 / 审批人持待上线版本任务可比对已发布版本 / 陌生人 16257 / 版本不存在 16253 与快照缺失或损坏 16256 / 路由为登录态无 open_api marker / 租户管理员判定用应用租户);`test/app_publish` 全套 397 passed(主检出基线 364 passed,+33 全为新增)。精确契约见 design §4.2 ⑨。
+  **实际偏差记录**:① 响应在 design 字面 `{files, patches}` 之上加了 `base` / `target` / `role` / `summary`,`files` 项多 `comparable` / `reason`,`patches` 项多 `truncated` / `masked_secrets`——超集,未改字面字段;② `a` = 旧侧 `b` = 新侧由路径顺序决定,"上一已发布版本"由前端从 `publish-status` 的 `current_version` / `pending_version` 取,后端不另提供 `diff/previous` 语法糖;③ 体积闸三道(单文件 2000 行 / 总 2 MiB / 5000 文件)为常量非配置——先有真实超限样本再考虑进 `settings.app_runtime`;④ 差异接口复用 `safe_extract` 落临时目录(与树 / 单文件的流式读不同),因为要对整棵树做逐字节比较,落盘比全量读进内存更可控。
+
+- [x] **T064**: 版本差异前端(版本 tab 与审读视图**同一呈现组件**)
+  **文件**: `platform/src/pages/BuildPage/hostedApp/publish/VersionDiff.tsx`, `client/src/components/approval/AppReviewView.tsx`(增量接入), `platform/public/locales/{zh-Hans,en,ja}/bs.json`
+  **测试载体**: 前端手动验证清单(platform 版本 tab 与 client 审读视图**同一组件同一呈现**、二进制文件降级为「不可比较」、大 diff 分块不卡)
+  **覆盖 AC**: AC-41
+  **完成证据(2026-09-16,切片 f055-review-frontend,提交 `7dc0e7560`)**:呈现组件 `packages/file-viewers/src/VersionDiffView.tsx`(文件清单 + unified diff 着色 + 二进制 / 超大降级 + 三道截断闸各自有话说 + 已屏蔽密钥计数),三语文案进 `packages/locales` 的 `shared` 域并重新生成两端产物;platform 侧 `publish/VersionDiff.tsx` 默认「运行中版本 → 待生效版本」、两侧可改,接进版本 tab 的 `contentSlot`;client 侧审读视图的「文件差异」tab 直接挂同一组件。测试:`platform/src/test/versionDiffView.test.tsx` 7 例(共享组件本身)+ `publish/VersionDiff.test.tsx` 7 例(选版规则与取数接线),platform vitest 全量 `3 failed | 73 passed (76) · 9 failed | 447 passed (456)`,与主检出基线 `3 failed | 71 passed (74) · 9 failed | 433 passed (442)` 同样的失败集(+14 全为新增)。**审校修正**:选中文件由 effect 对账改为渲染期推导——effect 版本在每次拿到新 diff 时会先画一帧「在左侧选择一个文件」(列表已经在屏幕上、effect 还没跑),`test_compares_the_default_pair_and_renders_the_shared_view` 断言的正是那一帧之后的内容,会间歇性挂。
+  **补充提交** `edde38013`:`hostedApp.ts` 加完差异接口后 615 行、越过 600 行硬线,照 `hostedAppData.ts` 的先例把差异读拆到同级的 `controllers/API/hostedAppReview.ts`(业务码与信封辅助仍留在 `hostedApp.ts`,调用方分支的是那些),拆后 553 行。
+  **偏离**:① **呈现组件不在 `platform/.../publish/VersionDiff.tsx`,而在 `@bisheng/file-viewers`**——本任务原写的落点让「同一呈现」只能靠两份拷贝实现(两个 SPA 不共享 `src/`,platform 甚至不依赖 `@bisheng/ui`)。`@bisheng/file-viewers` 是两端都已依赖的源码型共享包,契约正合(props 进、无 HTTP 客户端、文案走 `shared:` 命名空间)。platform 下的 `VersionDiff.tsx` 保留,但只负责选版与取数;② 文案分两处:组件内文案在 `packages/locales` 的 `shared` 域,platform 的卡片标题与选择器标签在 `bs.json`;③ 未新建 workspace 包——新包要 `pnpm install` 才能被解析,而 client 的 jest 一旦 install 就会被未编译的 `canvas` 整体拖垮(`reference_frontend_pnpm_install_breaks_canvas`)。
+
+---
+
+### Wave 7 · 档位管理 tab 与发布面补全(release 必做,本轮顺延)
+
+- [x] **T065**: 档位管理 API(列表 / 行内改规格与说明 / 停用;**无删除**;「使用中应用数」= `COUNT(DISTINCT app_id) WHERE tier_id=? AND terminal_state='online'`;仅平台超管)
+  **文件**: `src/backend/bisheng/app_publish/api/endpoints/resource_tier.py`, `src/backend/bisheng/app_publish/domain/services/resource_tier_service.py`(增量), `src/backend/test/app_publish/test_resource_tier_api.py`
+  **覆盖 AC**: AC-45
+  **完成记录(2026-09-16,分支 `wt/tier-admin`)**: `GET /api/v1/resource-tiers` + `PATCH /api/v1/resource-tiers/{tier_code}`(`ResourceTierService.list_tiers_for_admin` / `retune_tier`),仅 `is_global_super || is_admin()` 通过、否则 16260;运行时层未启用 16207;无 DELETE / POST 路由(`test_tier_routes_offer_no_delete_and_no_create` 钉死路由表)。错误码 16260–16263 三语文案随提交进 `packages/locales`;审计 `app.tier_update`(`target_type=resource_tier`)四处 lockstep 登记。`test_resource_tier_api.py` 22 例全绿(含 review 补的三例:超出 32 位整型列上限 → 16262;`cpu_millicores` / `memory_mb` 收到 JSON `true` → 422——原先 pydantic 宽松 `int` 会把它转成 `1` 毫核静默落库,规格字段已改 `StrictInt`);审计 `target_type=resource_tier` 的三语 `objectTypeEnum` 文案纳入 `test_audit_action_registry_lockstep.py`。review 顺手删除了已被 `retune_tier` 取代、无任何调用方的 `ResourceTierService.update_tier`。
+  **偏离**: ① 路径不在 `/api/v1/apps/**` 下——`bisheng/api/router.py` 先挂 F054 路由,`GET /apps/{app_id}` 会把 `/apps/resource-tiers` 当应用 id 吞掉(16101),故落 `/api/v1/resource-tiers`;② 新增 16263「默认档位不能停用」:`light` 是未声明档位的落点(AC-46),停用它等于让所有未声明档位的 `bisheng deploy` 以 16223 失败,属平台级故障、不该被当作单个清单的错误——服务端拒绝、前端隐藏该行的停用按钮。
+
+- [x] **T066**: Platform:系统管理页「资源档位」tab(仅平台超管;与工场运行时层开关联动、未部署不出现;`bs-ui/table` + 手写 `useState` 行内编辑〔平台无表单库〕;保存前 `bsConfirm`;停用提示"存量应用不动、发布面不再可选")
+  **文件**: `platform/src/pages/SystemPage/components/ResourceTierTab.tsx`, `platform/src/pages/SystemPage/index.tsx`(增量:`showResourceTierTab = isSuperAdmin && appConfig.appRuntimeEnabled`), `platform/src/controllers/API/hostedApp.ts`(增量), `platform/public/locales/{zh-Hans,en-US,ja}/bs.json`(`hostedApp.tierAdmin.*`)
+  **测试载体**: vitest `src/test/resourceTierTab.test.tsx`(7 例:列表 / 默认档位无停用按钮 / 行内改规格→`bsConfirm`→只发变更字段 / 本地校验拒 0 / 停用→确认→PATCH `enabled=false` 且行仍在 / 重新启用 / 空态;全部断言无删除按钮)+ `systemPageTabsVisibility.test.tsx`(超管可见、Child Admin 不可见、运行时层未部署不出现)+ `hostedAppApi.test.ts`(URL 契约)。前端手动验证清单(切 en / ja 无裸键;114 实机)待 114 部署后走一遍。
+  **覆盖 AC**: AC-45
+  **偏离**: 文件落在 `SystemPage/components/`(与同页其余 tab 同目录,非任务原写的 `SystemPage/` 根);停用做成「按钮 + 二次确认」而非 Switch——Switch 规范把开关限定为「拨完立即生效、无外溢后果」的设置项,停用档位会改变所有发布者可选项,属应二次确认的动作。
+
+- [x] **T067**(档位卡半边;能力清单半边仍待 T058 落地后补,见偏离②): 发布面补全:能力声明完整白话清单(含失效标记)+ 档位选择卡(随 PRD-2 平台内造应用的提交入口启用;本册对 CLI 应用不可用)
+  **文件**: `platform/src/pages/BuildPage/hostedApp/publish/{TierSelectCard,SchemaChangeNotice}.tsx`(新), `platform/src/pages/BuildPage/hostedApp/publish/CapabilityListCard.tsx`(**增量**——该文件由 **T058 创建**,本任务只补「完整白话清单 + 档位联动」,**不得重建**), `platform/public/locales/{zh-Hans,en,ja}/bs.json`
+  **测试载体**: 前端手动验证清单(能力清单逐条白话 + 失效标记与原因;档位卡对 CLI 导入应用置灰并提示走 `bisheng deploy`;结构变更提示位有内容时不塌)
+  **覆盖 AC**: AC-61, AC-63
+  **完成证据(2026-09-16,切片 `wt/f055-app-data-tables`,提交 `c0702114f`)**:`publish/TierSelectCard.tsx` 新建并接进 `index.tsx` 的 `pipelineSlot`(`tier` 与 `can.submit` 都取自 `publish-status`);三语文案落 `hostedApp.tier.*`;vitest `TierSelectCard.test.tsx` 7 例(只读态说清档位在哪儿改 / 规格按核呈现 / 未确定档位不塌 / 已停用档位带标记与说明 / 可选态需要 `can.submit` **且**有列表 / 空 `code` 行不得进 Radix SelectItem / `coresOf` 边界)。`SchemaChangeNotice.tsx` 已由 T061 落地并接在 `ApprovalStatusCard` 内,本轮核实其「有内容时不塌、无内容时整块不渲染」仍成立,未改。
+  **偏离**:① 档位卡的只读态**不画禁用下拉框**——CLI 应用改档位的地方是 `bisheng-app.yaml` 的 `tier`,一个点不动的选择器只会被读成「坏了」,改为直接说清当前档位、规格与去哪儿改;可选态(PRD-2)已接线但本册不可达,档位列表走 props 而非自取,因为档位列表接口是平台超管面(16260)、应用负责人读不到。② **能力声明清单半边未做**:`CapabilityListCard.tsx` 按本任务原文由 T058 创建,而 T058 与本切片在同一波次并行,两边都建同一个文件必然互相覆盖(本任务原文亦明写「不得重建」)。该半边随 T058 落地后在其文件上增量补全。③ CPU 按「核」呈现而非系统页档位编辑器的「毫核」:那边在改精确数值,这边是在读。
+
+- [x] **T068**: AC-64 触达全表复核(含催办 / 超时提醒 / 升级机制**不做**的显式确认)+ AC-08 静态依赖判据复议(本轮由探活兜底)
+  **文件**: `src/backend/test/app_publish/test_notification_matrix.py`, `features/v3.0.0/055-app-publish-pipeline/design.md`(§8 结论回填)
+  **覆盖 AC**: AC-08, AC-64
+  **完成证据(2026-09-16,切片 `wt/f055-app-data-tables`)**:`test_notification_matrix.py` 23 例。AC-64 全表写成数据:8 条会发(4 条走审批引擎、F055 不得再发一份;4 条是 F055 自己的,逐条断言有具名常量、断言常量集合恰好等于表、断言三语 `com_notifications_action_*` 文案齐备)+ 3 条不主动提示(资源释放后可手动上线 / 能力被收回 / 数据表迁移被拒且应用本来就不在线),每条把理由与行放在一起,加发送方就得先删那行。**催办 / 超时提醒 / 升级机制不做**从三个方向上闸:两个 worker 包没有 `crontab` / `beat_schedule` / periodic;发布与审批 service 层没有 remind / escalat / overdue / deadline / 催办 / 逾期 的标识符或字面量;`ApprovalScenario` 没有可承载截止时间的字段。AC-08 复议维持原判并按落码核实(检出=探活 16228 / 指引=`PROBE_HINTS` 点名三类中间件与 `BISHENG_APP_DB_URL` 且两处抛出点都挂着它 / 预检没有长出依赖清单扫描器)。design §8 两条结论 + §4.2 ⑧ 的 16259 行 + D3 的 T062 落码段已回填。
+  **偏离**:① 「不主动提示」在 spec 原文是两条,本轮**增加第三条**(数据表迁移被拒且应用本来就不在线)——它是 T062 新引入的失败态,与构建失败 / 探活失败同族,那两类也不发站内信;应用在线时仍按 `iteration_failed` 发,因为那条文案说的正是「新版本未能上线、线上版本仍在服务」。② 关键词表里试过 `nag` / `sla` 又删掉:`nag` 在 `manager` 里、`sla` 在大量散文里,一个总在误报的模式会被人删掉而不是读。③ AC-08 的复议结论与 AC-64 的表放在同一个文件里——两者形状相同(都在断言「某样东西是被有意省略的」,而省略没有行为可观察),文件首段写明了这一点。
+
+---
+
+## AC 追溯表(65 / 65 全覆盖)
+
+> 每条 AC 至少一个**测试任务**覆盖;实现任务与其配对测试任务共享同一组 AC 标注,此表只列首要承载任务。`[核]` = 该 AC 在本轮 `[MVP-核心]` 内闭环;`[后]` = 本轮顺延(Wave 4–7)。
+
+| AC | 承载任务 | 轮次 |
+|---|---|---|
+| AC-01 | T020/T021, T022/T023, T038/T039, T045, T049 | [核] |
+| AC-02 | T010/T011, T016/T017, T020, T022 | [核] |
+| AC-03 | T020/T021, T030/T031, T038 | [核] |
+| AC-04 | T020/T021, T038/T039 | [核] |
+| AC-05 | T020/T021, T022/T023 | [核] |
+| AC-06 | T044b | [核] |
+| AC-07 | T008/T009, T018/T019 · 能力引用部分 T060 | [核] + [后] |
+| AC-08 | T018/T019 · 静态判据复议 T068 | [核] + [后] |
+| AC-09 | T061 | [后] |
+| AC-10 | T012/T013, T022, T049 | [核] |
+| AC-11 | T008, T012, T018/T019, T022, T038, T049 | [核] |
+| AC-12 | T026/T027a, T028/T029, T049 | [核] |
+| AC-13 | T028/T029 | [核] |
+| AC-14 | T028/T029 | [核] |
+| AC-15 | T024/T025, T049 | [核] |
+| AC-16 | T028/T029, T049 | [核] |
+| AC-17 | T028/T029 | [核] |
+| AC-18 | T016, T028/T029 | [核] |
+| AC-19 | T026/T027a, T046 | [核] |
+| AC-20 | T026/T027b | [核] |
+| AC-21 | T024/T025, T049 | [核] |
+| AC-22 | T051, T049 | [核] |
+| AC-23 | T028/T029, T030/T031 | [核] |
+| AC-24 | T028/T029, T047, T049 · 「查看待上线版本」入口 T052 · 能力摘要真数据 T060 | [核] + [后] |
+| AC-25 | T052 | [后] |
+| AC-26 | T053, T054 | [后] |
+| AC-27 | T053 | [后] |
+| AC-28 | T053, T054 | [后] |
+| AC-29 | T053 | [后] |
+| AC-30 | T030(草稿态默认仅 owner 可见)· 审批人两条通道 T053 | [核] + [后] |
+| AC-31 | T032/T033, T049 | [核] |
+| AC-32 | T036/T037, T040/T041, T044a, T049 | [核] |
+| AC-33 | T034/T035, T044a, T049 | [核] |
+| AC-34 | T034/T035, T040, T044a, T049 | [核] |
+| AC-35 | T032, T034/T035, T049 | [核] |
+| AC-36 | T032/T033 | [核] |
+| AC-37 | T058 | [后] |
+| AC-38 | T036/T037, T040/T041 | [核] |
+| AC-39 | T016/T017, T044b | [核] |
+| AC-40 | T016/T017 | [核] |
+| AC-41 | T063, T064 | [后] |
+| AC-42 | T062 | [后] |
+| AC-43 | T010/T011, T016/T017 | [核] |
+| AC-44 | T014/T015 | [核] |
+| AC-45 | T065, T066 | [后] |
+| AC-46 | T008/T009, T014/T015 | [核] |
+| AC-47 | T014/T015 | [核] |
+| AC-48 | T014/T015, T022/T023 | [核] |
+| AC-49 | T056 | [后] |
+| AC-50 | T057 | [后] |
+| AC-51 | T056 | [后] |
+| AC-52 | T057 | [后] |
+| AC-53 | T058 | [后] |
+| AC-54 | T056 | [后] |
+| AC-55 | T022/T023(能力声明变更审计), T059 | [核] + [后] |
+| AC-56 | T008/T009 | [核] |
+| AC-57 | T055 | [后] |
+| AC-58 | T055 | [后] |
+| AC-59 | T055 | [后] |
+| AC-60 | T055 | [后] |
+| AC-61 | T044b · 能力清单与档位卡 T067 | [核] + [后] |
+| AC-62 | T036/T037, T040/T041, T044a | [核] |
+| AC-63 | T058, T067 | [后] |
+| AC-64 | T032, T042/T043, T048, T049 · 全表复核 T068 | [核] + [后] |
+| AC-65 | T042/T043, T048, T049 | [核] |
+
+---
+
+## 实际偏差记录
+
+> **只留一行指针**,论证在 design.md(决策 / 坑),这里不重复(见 `docs/SDD-Guide.md` §4)。
+> 推翻已 ★ 确认的决策时,**先停下与用户重新确认**(§3 第四个 ★),再记录。
+> **本轮已知的一项待确认偏离不在此表**:密钥扫描提前到构建之前(design D5 / §8),确认前一律按 spec 字面顺序实现。
+
+1. **T006 增建 `src/backend/bisheng/app_publish/domain/constants.py`**(`AppReleaseAuditAction` StrEnum + `RELEASE_AUDIT_TARGET_TYPE`)—— 任务只点名 `audit_log.py`,但那样 16 个 action 字面量会散落进 Wave 2–3 的十来个 service。照 F054 `app_runtime/domain/constants.py AppAuditAction` 的同形先例补上,lockstep 由 `test_wave1_infra.py::test_release_actions_are_registered_in_the_ui_whitelist` 守住。
+2. **T004 顺带补 `16100` / `16200` 两个家族基类码的三语文案** —— `pnpm check-i18n` 把「后端声明了码、前端无文案」判红,而 F054 落码时漏了 `16100`;不补则本批的 i18n 门禁必红(判据不是"顺手清债",是"本批的验证项过不了")。
+3. **`ResourceTierDao.aupdate_row` 的行键参数名取 `tier_code` 而非 `code`** —— 叫 `code` 时 `aupdate_row(session, "light", code="tiny")` 是 `TypeError`,"禁止改 code"这条守卫永远走不到;改名后 `code=` 落进 `**values` 被显式拒绝。
+4. **`test/app_publish/conftest.py` 加 `_sqlite_ddl_quirks`** —— `userrole` 是复合主键 + 代理键 autoincrement,SQLite 直接拒绝建表("does not support autoincrement for composite primary keys")。只在 `create_all` 期间清 `autoincrement` 再还原,生产 DDL 归 Alembic、不受影响;代价是 fixture 写 `userrole` / `department_admin_grant` / `user_department` 需显式 `id`。
+5. **Wave 1 增加一个 `test/app_publish/test_wave1_infra.py`** —— 本节标注「无测试配对」,但 F049 Wave 1 有同名先例(`test/open_api/test_wave1_infra.py`)。只断言基础设施(错误码唯一性与一码一义、审计 lockstep、settings 五键、两个 DAO、conftest fixture 自身),**不认领任何 AC**;AC 覆盖仍归 T008–T017。
+
+6. **Wave 2 增建 `app_publish/domain/schemas/failure.py`(`failure_from_error`)与 `domain/services/release_audit.py`(`write_release_audit`)** —— 五元组构造与 `app.release.*` 写入被 Wave 2 的六个 service 共用;不抽出来就是六份 `{stage, code, message, details, hints}` 拼装与六份 `ainsert_v2(target_type=...)`,AC-11 与 AC-01「按应用筛选」两条都会在第一次改动时分叉。
+7. **`accept()` 的实际顺序是「解包 → manifest → 建草稿/归属判定 → 两道闸 → 落 MinIO」,与 design §4.1-A ① 的箭头顺序不同** —— 那张图把 `put_object 到 apps/{app_id}/…` 画在 `create_draft` 之前,而首发时 `app_id` 正是 `create_draft` 才产生的,照图实现无键可写。D2「键先于版本行」的实质(version_id 接收时生成、不做 staging key + server-side copy)完全保留。
+8. **孤儿快照清理按 `app_deployment` 行驱动,不列桶前缀** —— design D2 写的是「同 `app_id` 前缀下…的键」,但 `MinioStorage` 没有列举方法(只有 conftest 的 fake 有 `list_object_names`),而每一个写过的键都记在一行永不删除的 `app_deployment` 上,表本身就是完整索引。副作用是更安全:清理只删自己记过账的键,永远碰不到别的 Feature 放在同桶的对象。
+9. **`test/app_publish/conftest.py` 的 `fake_orchestrator` 由「缺 F054 T047 就 skip」改为「缺就往 `sys.modules` 立同名占位模块」,并新增 `fake_f054_services` / `fake_publish_approval`** —— F054 的 backend 服务层(`orchestrator_client` / `AppProvisionService.create_draft` / `AppMetaService.update_meta`)与 Wave 3.2 的 `publish_approval_service` 目前都还不存在,照原 skip 语义 Wave 2 后半(T018–T023,约 40 条断言)会**全绿于缺席**。占位模块的方法一律未编程即抛 `NotImplementedError`(不是静默返回 `None`),且随 monkeypatch 退出即从 `sys.modules` 摘除;上游一落地这三个 fixture 自动改为 patch 真模块。生产代码侧对应的是「按调用点惰性 import 上游」,不是新增旁路实现。
+10. **`test/app_publish/conftest.py` 的 `_FakeMinioStorage` 补 `get_share_link` / `get_share_link_sync`** —— `POST /v1/intents/build` 必带 MinIO 预签 `code_url`(contracts-runtime-manager §2),fake 缺这两个方法则构建阶段无法自测。
+11. **`VersionService.record_version` 收 `approval` 端口参数(`submit` / `cancel` 两个协程)而不是直接 import `publish_approval_service`** —— D6 的「先 Gate 后 INSERT + 两阶段补偿」是版本记录自己的不变量,作为参数传入才能让顺序由拥有该不变量的函数强制,而不是靠调用方记得;同时让 Wave 2 在 Wave 3.2 落地前可测。Wave 3 的 `publish_approval_service` 模块对象**原样满足**该端口,无需适配层。
+
+### Wave 3.1–3.4(T024–T041)的偏差记录(2026-08-17)
+
+12. **seed 的数据与逻辑整体搬到 `approval/domain/services/approval_seed_service.py`,`init_data._init_default_approval_scenarios(session, tenant_id=None)` 变成薄委派** —— T027a 写的是「在 `init_data` 里参数化六处 `DEFAULT_TENANT_ID`」。改成搬家的判据是**这份 seed 现在有两个调用方**(启动时 + 建租户时),把清单留在 `common/` 而让 `approval/` 去 import 一个私有函数,是在两个模块之间来回穿;`common/` 也不该拥有审批领域的清单。**行为等价**:幂等判据仍是 `tenant_id + scenario_code`,`init_data` 的既有一参调用照旧、仍落默认租户。连带把 `test/approval/test_init_default_approval_scenarios.py` 的 import 改到新位置(1 行)。
+
+13. **`test/approval` 两处既有断言按「清单会增长」放宽** —— `test_registry_exposes_default_presets` 与 `test_seeds_two_scenarios_...` 都把预置清单**逐字面量钉死**,于是 F055 加第 4 个场景就红。两条都改成「⊇ 既有三条 + 每条 preset 必须有 `handler_key`」/「与 `DEFAULT_APPROVAL_SCENARIO_SEEDS` 一致」——**它们真正要守的形状(单 catch-all 分支 → 单 or 节点、`handler_key` 无默认值)一条没丢**,丢掉的只是「清单恰好是这三条」这个每次加场景都要改一次的断言。`test/approval` 的失败集在改动前后逐条一致(22 failed / 73 passed,全部是本地无 MySQL 的既有失败)。
+
+14. **`on_approved` / 终态回调的实现体拆到 `publish_online_service.py` 与 `publish_terminal_service.py`,handler 只做分派** —— T033/T035 写的是「在 handler 里**增量**加」。判据是 **`bring_online` 有第二个调用方**:T037 的手动上线跑的是同一段「起一次 → 三种结果 → 版本终态与通知」,写在 handler 里就意味着 `PublishStatusService` 要反向调审批场景 handler。拆开后 `_settle` 一份实现两处入口共用;handler 仍是九个方法俱全的鸭子类型,`test_publish_scenario_handler` 逐个断言。
+
+15. **`app_deployment.aadvance_stage` 增加可选 `failure=` 形参,允许 `status='succeeded'` 时也写五元组** —— 「待上线」是成功的管线结果,却仍欠使用者一个原因,而 `pending_reason`(AC-38)需要一个**持久**来源。备选是从 `app.release.pending_online` 审计行反推,但审计是 best-effort、不能当状态读。现在 `status` 答「管线有没有做完它该做的」、`failure` 答「有没有要解释的事」,DAO docstring 已写死这层语义。
+
+16. **`/api/v2` 端点用本地 `app_manage_subject()` 包一层 F049 的 `open_api_subject`,把 import 挪进函数体** —— 模块级 `from bisheng.open_api.api.dependencies import ...` 触发 arch-guard **RULE-5**(跨模块 API 层互导)。包装函数里补了 `is_known_scope(_SCOPE)` 的**导入期**校验,与 `open_api_subject` 构造期做的检查等价——scope 拼错仍然在启动时炸,没有退化成「这个端点不要求任何 scope」。**(2026-09-10 作废:改接 beta2 后 `app_manage_subject` 与 `is_known_scope` 校验一并删除;端点打 `open_api_scope` marker、鉴权在 `router_rpc` 的 `verify_open_api_access`,漏标记 fail-closed,迁移方案 M8。)**
+
+17. **`/api/v2` router 经 `bisheng/api/router.py` 的 `router_rpc` 挂载(拿 `/api/v2` 前缀),v1 部分挂 `router`** —— D2 选项 C 的实质是「每个端点自带 `Depends`」,这一点严格照做(2026-09-10 起改为「每个端点自带 marker」,鉴权 `Depends` 上提到 `router_rpc`);挂载点用既有聚合器只是为了拿前缀,不改 main.py。
+
+18. **成功上线不再额外发一条站内信** —— T032 的用例名写「notifies owner」,但 §4.2 ⑦ 只授权**三个**新 action_code,而「通过」这一类触达审批引擎已经用 `approval_instance_approved` 发给申请人、申请人就是 owner(AC-16)。再发一条等于把三个名额之一花在重复通知上。测试改断言「版本终态 + 审计」,并显式断言没有重复通知。
+
+19. **`test/app_publish/conftest.py` 清除三处 `sys.modules` 占位** —— `fake_orchestrator` / `fake_f054_services` / `fake_publish_approval` 改为 `import_module` 真模块并放行 `ImportError`。F054 服务层已落地,占位再留着就是「对着自造的桩绿」。清除当场把 26 条此前**绿于缺席**的用例变红,全部在 T031 落地后转绿——这正是占位在掩盖的东西。同批新增 `approval_notifications`(捕获站内信)与 autouse `no_celery_dispatch`(`_dispatch_outbox` 无 try/except,broker 不可达会在 `decide_task` 里炸)两个 fixture,并把审批引擎三个 repository + 通知服务补进 `_SESSION_PATCH_TARGETS`(它们在 import 期绑定 `get_async_db_session`)。
+
+20. **组合根只注册删除钩子,不注册场景 handler** —— T035 写「注册 F054 删除钩子 + 场景 handler」。场景 handler 由 `approval_runtime_handler_factory` 的分支按需构建:**一个靠组合根填充的注册表,正是 K1 ③ 警告的那种失败**(组合根没跑 → 审批通过、工厂找不到 handler、outbox 记一条失败、应用永远不上线)。分支自带 import,忘不掉。`register()` 仍在 API 与 worker 两处各调一次并由 T034 守住。
+
+21. **⚠️ 跨 Feature 阻塞(需 F054 修,已用 strict xfail 钉住):状态机没有 `online → online` 边** —— `ALLOWED_TRANSITIONS[ONLINE] = {STOPPED}`(`app_runtime/domain/constants.py`),而 `AppStateService._start` 起手就查 `is_transition_allowed(app.state, ONLINE)`。后果:**对一个已上线应用做迭代发布,审批通过后 `publish()` 直接抛 16102**,`on_approved` 按 D9「状态机拒绝 = 系统性失败」把它抛给 outbox → 审批单变 `execute_failed`。首发(草稿 → 上线)不受影响,MVP 演示剧本走的是首发,但**迭代发布链路今天是断的**。`test_on_approved.py::test_iteration_on_an_online_app_can_publish` 以 `xfail(strict=True)` 锁住,F054 补上这条边(或让 `_start` 对 source==target==online 放行)当天会自动转红提醒。F055 侧不做任何绕行——绕过状态机就是在决议-8 上开洞。
+  → **✅ 已解除**(2026-08-18 那批修掉,2026-08-19 收口批核实):`app_runtime/domain/constants.py` 的 `ALLOWED_TRANSITIONS[ONLINE]` 现含 `ONLINE`,注释逐字写明「``ONLINE → ONLINE`` is a real edge, not a typo」;该用例的 `xfail(strict=True)` 已撤、现为普通正向用例并通过(`pytest test/app_publish` 343 passed / 0 failed)。**这条阻塞不要再当成 open 项转述。**
+
+### 本批未做(属 T042–T051,留给后续批次)
+
+> ⚠️ **本节写于 Wave 3.1–3.4 那一批(2026-08-17),下面三条已被 2026-08-19 收口批部分推翻**,逐条标注了现状,原文保留以便追溯。
+
+- ~~**T042/T043 事件触达全表**:本批只落了 `on_approved` / 手动上线自己需要的两类(`app_publish_pending_capacity` / `app_publish_deploy_failed`,见 `publish_notification_service.py`)与首节点通知。其余四类与 `test_publish_notification.py` 未写。~~ → **2026-08-19 已完成并勾选**:核实后确认其余四类本就由审批引擎自己发(我们再发一份 = owner 一个事件收两条),`publish_notification_service` 只该管三类;`test_publish_notification.py` 14 例已补齐,含「不重复发」与两类「不主动提示」的行为侧 + 结构侧双重断言。
+- ~~**三个新 action_code 的三语文案**……本批**未加**~~ → 待落前端那一批核对(本收口批未动 `src/frontend/` 的通知文案;`approval_instance_cancelled` 等仍走 `com_notifications_action_{code}` 兜底)。**唯一新增的三语是 18118 的错误码文案**,已落 `packages/locales/src/api_errors/` 并由 build 脚本生成六个 artifact。
+- ~~**T044–T048 前端**、**T051 `withdraw` 终态守卫**、**T049/T050 114 验证与上游回写**。~~ → **T051 已完成**(18118 + 15 例测试);**T050 四项回写已完成**(见该任务偏差记录);~~**T044–T048 前端**与 **T049 114 验证**仍未勾。~~ → **2026-09-10 订正**:T044a/T044b/T045/T046/T047/T048 六条经代码核实已落地并勾上(各带证据行),**只剩 T049 114 验证**待真实环境。
+
+---
+
+## 跨 Feature 回写受理
+
+> 别的 Feature 请求本 Feature 交付的增量,逐条记「谁请求 / 请求什么 / 交付没交付 / 在哪」。**只追加,不改上面任何任务的勾选**。
+
+| 请求方 | 请求内容 | 状态 | 交付物 |
+|---|---|---|---|
+| **F053 T034 ②** | `GET /api/v2/apps/{id}/logs` 的返回补 `app_state` / `pending_reason` —— 空 `lines` 有两个成因而**日志文本永远分不出来**:应用在跑但还没输出,还是根本没有运行实例(草稿 / 待上线 / 已停运)。CLI 此前只能把三种可能都列出来,读起来像「日志查询坏了」,把 owner 支去查错方向 | ✅ **已交付**(commit `95c0ed98e`,2026-08-18) | 服务端点名状态,CLI 据此分别说「还是草稿」「已停运」「正在运行但还没有输出」——三者的下一步动作完全不同。解除 F053 AC-43 的降级(其 T026) |
+| **F053 T034 ③** | `GET /api/v2/apps/deployments/{id}` 的轮询载荷补 `entry_url`(`app_state=已上线` 时非空)—— 它此前**只在 `POST /deploy` 的返回里**,而首发那一刻应用还是草稿,于是这个字段几乎总在最需要它的时候是 `null` | ✅ **已交付**(同 commit `95c0ed98e`) | 每次轮询都带,`deploy --wait` 成功时直接打出真实地址而不是把开发者支到详情页去翻。**地址由 F054 的唯一实现按 slug 推导**,不在 F055 拼、更不在浏览器里拼(F054 AC-25)。CLI 的 `_absorb` 同批把它纳入吸收键。解除 F053 AC-31c / AC-33 / 决议-12 的降级(其 T024) |
+
+| **F057 T043 →(承接 T057)** | **OBO 签发改 fail-closed**。托管期访问者凭据现在**有消费者了**——SDK 的 `retrieve` 把 `X-BiSheng-Access-Token` 作为「为谁检索」的唯一来源,`CapabilityBusService.retrieve` 拿不到访问用户即 `26320`。而签发侧仍是 2026-08-17 那条「本轮无人读,先放行」的裁定:`app_runtime/domain/services/entry_authz_service.py:587-597` 在 `app_runtime.obo_secret` 缺失或等于 `jwt_secret` 时**只 `_warn_once` 一次**、返回 `(None, None)` 照常放人进应用。于是一个配错 secret 的部署上,每个访问者都以「没有身份」进入应用,而现场看到的现象是「SDK 的 retrieve 全被拒」——身份面的静默 fail-open 穿着 SDK 故障的外衣。该文件里的 ⚠️ 注释本身就写着「等 OBO 有了第一个读者就必须翻成 fail-closed」,这条回写只是通知:读者已经到了 | 🔲 **未交付**(2026-09-16 核实,`3.0-vibe` 仍是 warn-only) | 代码落点在 `bisheng/app_runtime/`(F054 的模块),按 design §6.2 的回写登记归本 Feature 受理;F057 侧不做任何兼容分支,`test_journey_03b`(`test/e2e/test_e2e_f057_sdk_journey.py`)在部署上直接断言这把凭据必须存在,并把「去设 `app_runtime.obo_secret`」写进失败信息 |
+| **F057 T043 →(承接 T058)** | `16273` 载荷带 `data.capability` 与 `data.reason`(SDK 的 `CapabilityRevokedError` 要把「哪个能力、为什么没了」原样呈现给应用,而不是一句「失败」) | ✅ **已交付**(2026-09-16 只核对未改) | `common/errcode/app_publish.py:495-527`:`AppCapabilityRevokedError(capability, kind=…, reason="revoked" \| "ambiguous" \| "unresolvable")`,三个键都进信封 `data`;`16274`(未声明)同形带 `capability` / `kind`。F057 `bisheng_sdk/_codes.py` 已按此登记 `16273` / `16274` |
+
+(2026-08-19 由收口批登记;本表不影响上方任务勾选状态。2026-09-16 由 F057 T043 追加两行。)
+
+## 2026-09-18 · 验收核查发现并修复:INV-34「不存在免审配置项」此前可被绕过
+
+**缺陷**:审批中心的分支类型 `pass`(免审,直接通过)对**所有**场景开放,`ApprovalGate.request_or_pass` 的 pass 分支也没有场景限制。租户管理员在管理后台把「应用发布」场景的分支改成 `pass`,发布即免审直接上线。而 PRD-1 三处明文承诺相反——RT-03「首发与迭代一律必审,平台**不提供任何免审配置项**」、GOV-02「不提供免审配置项」、GOV-02 规则「每次发布必审,**无免审路径与配置项**」——release-contract 的 **INV-34** 也是这么登记的。
+
+`ApprovalRegistry` 里 `app_publish_request` 的 `condition_fields=[]` 旁边原本就写着「there is no 免审 branch to select (F055 INV-34)」——**意图是对的,但 `condition_fields` 只约束条件匹配**,一条 catch-all 分支(`match_config={}`)不需要任何条件字段就能配成 `pass`,所以这道闸从来没有真正合上。
+
+**修复**(`mandatory_approval` 声明 + 两道闸):
+
+1. `ApprovalScenarioPreset` 新增 `mandatory_approval` 字段,`app_publish_request` 置 `True`,其余三个预置场景保持 `False`。**声明写在 preset 上而不是在引擎里硬编码场景码**——审批中心是通用模块,不该认识某个业务场景的名字。
+2. `ApprovalScenarioAdminService._assert_route_type_allowed()`:`create_route` **与** `update_route` 两处都校验,必审场景配 `pass` 答 **18119**。两处都要,否则「建不了 pass 但能把已有的 flow 改成 pass」。
+3. `ApprovalGate._forbids_pass()`:匹配到的 pass 路由若属必审场景,**当作没匹配到**(落 `route_missing` 异常 + 通知管理员),既不抛错也不放行。这一道不是冗余——路由行还能从手工 UPDATE、从改动前的备份恢复进来,而对必审场景来说「静默自动通过并上线」是最坏的结果。
+4. 前端 `ApprovalPage` 对这类场景不渲染「无需审批」选项(判据是 `list_presets` 响应新增的 `mandatory_approval`),并给一句说明;编辑存量 pass 行时强制落到 `flow`,避免下拉停在一个已不存在的选项上。三语文案齐。
+
+**影响面**:只收紧 `app_publish_request`。菜单权限 / 频道订阅 / 知识空间加入三个场景的 pass 分支照常可配可用——它们一直允许免审,跟着收紧等于给存量租户一个无声的行为变更。114 实测存量 `approval_route_rule` 三行全是 `flow`,本次修复不影响任何现存配置。
+
+**测试**:`test/approval/test_mandatory_approval_scenario.py`(6 例)——create/update 两侧拒绝、同场景 flow 不受影响、**其它场景仍可配 pass**、**其它场景的 pass 仍正常放行到 APPROVED+outbox**、必审场景的 pass 落 EXCEPTION 且不建 outbox。`test/approval` 相对 HEAD 基线零新增失败(21 → 21)。`.claude/skills/approval-module/SKILL.md` 已按其维护契约同步(§2 主流程图 + 新增小节、§5 表、§7 错误码)。
+
+## 2026-10-09 · PRD-1 评审改判:应用密钥最小版本版交付(待展开)
+
+Spec Discovery N4 原定「密钥引用随 PRD-2」,2026-10-09 PRD-1 评审改判为本版交付最小版。需求见 PRD-1 v3.0 RT-04、GOV-05「应用密钥」段与验收 8–10。实施前须先补 spec AC 与 design,再展开任务。范围要点:
+
+- 发布 tab 新增「应用密钥」区:引用名、掩码、状态(已录入 / 缺少值)、最近修改时间;仅 owner 可录入、修改、删除,租户管理员只见引用名与状态。
+- 值加密存储(沿用平台 Fernet),任何接口不回显明文、不写日志;录入、修改、删除计审计(不记值)。
+- `bisheng-app.yaml` 能力声明恢复 `secrets` 字段;预检由「出现即拒绝」改为「缺值警告、不阻断」,审批单标注「缺少值」。
+- 能力总线在应用启动时注入同名环境变量(注入通道已预留引用名位);修改值不经审批,保存后重启应用实例生效;新增或删除引用名随发布审批。
+
+
+## 2026-10-23 · 用户修订:仅保存配置,确认后重建
+
+保存不自动重建,显示当前实例与最新保存值的差异;用户确认后复用现有部署流程重建当前已批准版本,不改代码/档位/SQLite。F055 spec 修订 AC-70、AC-73、AC-74、AC-76、AC-77、AC-86,新增 AC-90、AC-91、AC-92。建议以单个加密配置资产及现有 RuntimeOperation/route 取代三表自动生效状态机,正式设计待修订规格确认后更新;不展开增量任务或勾选旧任务。
+
+## 2026-10-23 · 手动生效规格确认与简化设计
+
+用户确认 92 条 AC 修订规格。design D17–D20/§4.4/§5–§8已按单表和既有运行操作覆盖更新;移除独立自动生效、请求账本和 applied 回执协议;F054消费协议同步。设计仍待独立评审与用户确认,尚未展开增量任务/编码;B1/S1不在本次关闭。
+
+## 2026-10-23 · F055 手动生效开发任务(当前批次)
+
+用户已确认设计。本批次独立于历史70项,不把旧完成计数作为增量交付证明。按波次串行实施;只做必要定向验证,不新增Celery任务,沿现有租户上下文与node-plan。新增独立表按backend AGENTS由模型发现/create_all创建,不增加无必要revision;不改变批准的单表边界。L1记录写入manual-apply-task-reviews.md。
+
+- [x] **T071** — 单表模型及发现注册
+  **类型**:基础设施
+  **文件**:`src/backend/bisheng/app_publish/domain/models/app_secret_config.py`、`src/backend/bisheng/app_publish/domain/models/__init__.py`
+  **逻辑/验收**:App主键同型,tenant默认None/NOT NULL/index,通用LargeText,revision与时间;空映射保留行。新独立表由create_all发现,无额外DDL revision;回退旧代码保留加密表,不删数据。
+  **覆盖 AC:** AC-67, AC-68, AC-80, AC-83
+  **依赖**:无
+
+- [x] **T072** — 新增密钥业务错误
+  **类型**:基础设施
+  **文件**:`src/backend/bisheng/common/errcode/app_publish.py`
+  **逻辑/验收**:核对162占用分配配置校验/冲突/加密不可用/重建拒绝错误;安全原因不含值。
+  **覆盖 AC:** AC-66, AC-76, AC-78, AC-79, AC-80, AC-89
+  **依赖**:无
+
+- [x] **T102** — 业务错误中英文
+  **类型**:基础设施
+  **文件**:`src/frontend/packages/locales/src/api_errors/zh-Hans.json`、`src/frontend/packages/locales/src/api_errors/en.json`
+  **逻辑/验收**:162新增错误安全文案三语一致,不手改生成物。
+  **覆盖 AC:** AC-66, AC-76, AC-78, AC-79, AC-80, AC-89
+  **依赖**:T072
+
+- [x] **T103** — 业务错误日文与生成检查
+  **类型**:基础设施
+  **文件**:`src/frontend/packages/locales/src/api_errors/ja.json`
+  **逻辑/验收**:补同键后按包脚本生成api_errors并做增量parity。生成物为本任务派生产物,不单独维护。
+  **覆盖 AC:** AC-66, AC-76, AC-78, AC-79, AC-80, AC-89
+  **依赖**:T102
+
+- [x] **T073** — 加密、输入与值差异测试
+  **类型**:测试
+  **文件**:`src/backend/test/app_publish/test_app_secret_security.py`
+  **逻辑/验收**:真实Fernet绑定app/tenant,坏key/密文拒绝;大小/保留名/空白/Unicode/多行/NUL,声明子集及A→B→A。
+  **覆盖 AC:** AC-68, AC-78, AC-79, AC-90
+  **依赖**:T071, T072
+
+- [x] **T074** — 加密与输入合同
+  **类型**:后端 Domain
+  **文件**:`src/backend/bisheng/app_publish/domain/secret_security.py`、`src/backend/bisheng/app_publish/domain/schemas/app_secrets.py`
+  **逻辑/验收**:按测试实现有界映射、加密envelope、名称策略与安全请求schema;无原始输入回显。
+  **覆盖 AC:** AC-68, AC-78, AC-79, AC-90
+  **依赖**:T073
+
+- [x] **T075** — 保存、权限、CAS与审计事务测试
+  **类型**:测试
+  **文件**:`src/backend/test/app_publish/test_app_secret_service.py`、`src/backend/test/app_publish/test_app_secret_repository.py`
+  **逻辑/验收**:SQLite真实事务;owner允许/管理员/跨租户拒绝;零编排;CAS/最后项删除不重置;审计失败回滚;逐项时间、未引用与未知运行上下文。
+  **覆盖 AC:** AC-66, AC-67, AC-68, AC-73, AC-74, AC-75, AC-77, AC-79, AC-80, AC-81, AC-83, AC-85, AC-89, AC-90
+  **依赖**:T074
+
+- [x] **T076** — 单表Repository
+  **类型**:后端 Domain
+  **文件**:`src/backend/bisheng/app_publish/domain/repositories/app_secret_repository.py`
+  **逻辑/验收**:App→配置锁,显式session读写和同事务业务AuditLog;业务不可写后不保存,无serviceSQL/新DAO。
+  **覆盖 AC:** AC-67, AC-68, AC-77, AC-80, AC-81, AC-83
+  **依赖**:T075
+
+- [x] **T077** — 密钥服务及公开环境协议
+  **类型**:后端 Domain
+  **文件**:`src/backend/bisheng/app_publish/domain/services/app_secret_service.py`、`src/backend/bisheng/app_publish/application/runtime_secrets.py`
+  **逻辑/验收**:owner会话写、分级只读、revision、差异;formal_snapshot/preview_environment/delete_for_app公共协议,保存零operation。
+  **覆盖 AC:** AC-66, AC-67, AC-68, AC-70, AC-71, AC-73, AC-74, AC-75, AC-77, AC-80, AC-83, AC-84, AC-85, AC-89, AC-90
+  **依赖**:T076
+
+- [x] **T078** — manifest与声明呈现配对
+  **类型**:测试
+  **文件**:`src/backend/test/app_publish/test_app_secret_declaration.py`、`src/backend/test/app_publish/test_version_diff.py`(既有响应形状断言同步)
+  **逻辑/验收**:合法secrets/name、重复保留名/其他secret子树拒绝;无新增scope,缺值不单独阻断;diff/审读名称无值。
+  **覆盖 AC:** AC-54, AC-56, AC-69, AC-71, AC-72, AC-78, AC-84, AC-87, AC-88
+  **依赖**:T074
+
+- [x] **T079** — 声明schema与严格解包校验
+  **类型**:后端 Domain
+  **文件**:`src/backend/bisheng/app_publish/domain/schemas/app_manifest.py`、`src/backend/bisheng/app_publish/domain/services/manifest_validator.py`
+  **逻辑/验收**:只豁免合法capabilities.secrets[*].name,保留其他明文/引用拒绝与源码扫描;兼容旧manifest。
+  **覆盖 AC:** AC-56, AC-69, AC-72, AC-78, AC-88
+  **依赖**:T078
+
+- [x] **T080** — 声明能力消费与差异
+  **类型**:后端 Domain
+  **文件**:`src/backend/bisheng/app_publish/domain/services/capability_bus_service.py`、`src/backend/bisheng/app_publish/domain/services/version_diff_service.py`
+  **逻辑/验收**:is_empty/derive_scopes保持模型知识语义,diff展示名增删,不携值。
+  **覆盖 AC:** AC-54, AC-69, AC-72, AC-84, AC-87
+  **依赖**:T079
+
+- [x] **T081** — 同事务候选凭据生产测试
+  **类型**:测试
+  **文件**:`src/backend/test/app_publish/test_app_secret_credentials.py`
+  **逻辑/验收**:主体/凭据/delegate/binding/issue-audit同session,seal/flush失败全部回滚;原入口不变。
+  **覆盖 AC:** AC-68, AC-70, AC-77, AC-81, AC-82
+  **依赖**:T077, T079
+
+- [x] **T082** — 凭据底座session入口
+  **类型**:后端 Domain
+  **文件**:`src/backend/bisheng/open_api/domain/repositories/credential_repository.py`、`src/backend/bisheng/open_api/domain/services/credential_service.py`
+  **逻辑/验收**:受信内部session-aware签发;不提前commit/外部回调,不跨session读未提交数据;普通签发入口行为不改。
+  **覆盖 AC:** AC-68, AC-70, AC-77, AC-81, AC-82
+  **依赖**:T081
+
+- [x] **T083** — 运行期stage事务接线
+  **类型**:后端 Domain
+  **文件**:`src/backend/bisheng/app_publish/domain/services/app_credential_service.py`、`src/backend/bisheng/app_runtime/domain/repositories/runtime_credential_repository.py`
+  **逻辑/验收**:stage_in_transaction含subject创建及binding;F055 owner协议,回放不重签。历史binding回收S1不在本任务关闭。
+  **覆盖 AC:** AC-70, AC-77, AC-81, AC-82
+  **依赖**:T082
+
+- [x] **T084** — 用户确认重建与真实运行快照合同
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_secret_rebuild.py`、`src/backend/test/app_runtime/test_secret_runtime_snapshot.py`
+  **逻辑/验收**:过期/重复确认,原tier/delivery/pending,零DDL,精确maintenance回放,ready自动提交与丢响应,原操作失败不自动recover,在途保存差异;锁图定向。
+  **覆盖 AC:** AC-70, AC-74, AC-75, AC-76, AC-77, AC-86, AC-90, AC-91, AC-92
+  **依赖**:T077, T083
+
+- [x] **T085** — 运行快照公共只读口
+  **类型**:后端 Domain
+  **文件**:`src/backend/bisheng/app_runtime/application/runtime_secrets.py`、`src/backend/bisheng/app_runtime/domain/repositories/runtime_secret_repository.py`
+  **逻辑/验收**:正式路由/实例/op一致DTO,缺上下文unknown,拒绝任意历史明文读取。
+  **覆盖 AC:** AC-68, AC-80, AC-90
+  **依赖**:T084
+
+- [x] **T086** — 重建受理应用服务
+  **类型**:后端 Domain
+  **文件**:`src/backend/bisheng/app_runtime/domain/services/secret_rebuild_service.py`
+  **逻辑/验收**:App→node/assignment→route→配置,无回锁旧op;旧请求先回放,新请求冻结原批准组合;auto_commit;同UoW候选及seal。
+  **覆盖 AC:** AC-70, AC-76, AC-77, AC-86, AC-91, AC-92
+  **依赖**:T085
+
+- [x] **T087** — 正式/预览注入与失败恢复接线
+  **类型**:后端 Domain
+  **文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_delivery_coordinator.py`、`src/backend/bisheng/app_runtime/domain/services/app_state_service.py`
+  **逻辑/验收**:purpose分支绕过同版本快捷返回但不重标版本/DDL;普通环境冻结、failed config_restart不自动再建,指针与配置分离。
+  **覆盖 AC:** AC-70, AC-71, AC-75, AC-76, AC-77, AC-86, AC-91, AC-92
+  **依赖**:T086
+
+- [x] **T088** — 维护事实与ready提交围栏
+  **类型**:后端 Domain
+  **文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_operation_service.py`、`src/backend/bisheng/app_runtime/domain/services/runtime_projection_service.py`
+  **逻辑/验收**:own maintenance持久幂等转换,旧事实不改新route;auto_commit重报原ready;归属/停止证明不放宽。
+  **覆盖 AC:** AC-76, AC-77, AC-90, AC-91, AC-92
+  **依赖**:T087
+
+- [x] **T089** — 运行期输出脱敏合同
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_secret_redaction.py`、`src/backend/test/app_runtime/test_secret_diagnostics.py`
+  **逻辑/验收**:短值/普通名/Unicode/跨行/重叠/边界/缺上下文,全执行绑定集合,backend安全落库;不猜名称或忽略短值。
+  **覆盖 AC:** AC-68, AC-71, AC-82, AC-86
+  **依赖**:T074
+
+- [x] **T090** — manager敏感集合及Docker日志
+  **类型**:后端 Domain
+  **文件**:`src/runtime-manager/runtime_manager/secret_redaction.py`、`src/runtime-manager/runtime_manager/api/platform_observe.py`
+  **逻辑/验收**:最长匹配,先脱敏后截断;受限operation环境与敏感名,缺上下文安全省略,不写本地明文。
+  **覆盖 AC:** AC-68, AC-82, AC-86
+  **依赖**:T089
+
+- [x] **T091** — K8s与legacy输出
+  **类型**:后端 Domain
+  **文件**:`src/runtime-manager/runtime_manager/executors/kubernetes.py`、`src/runtime-manager/runtime_manager/api/readonly.py`
+  **逻辑/验收**:各日志/诊断消费相同集合、purpose能力明确、未知能力不能降级。
+  **覆盖 AC:** AC-68, AC-71, AC-82, AC-86
+  **依赖**:T090
+
+- [x] **T092** — 执行器错误与准备容器输出
+  **类型**:后端 Domain
+  **文件**:`src/runtime-manager/runtime_manager/operations.py`、`src/runtime-manager/runtime_manager/container_runner.py`
+  **逻辑/验收**:统一出口保护,不向helper注入业务值;config_restart用途校验。
+  **覆盖 AC:** AC-68, AC-71, AC-82, AC-86
+  **依赖**:T091
+
+- [x] **T093** — 会话API权限和安全校验
+  **类型**:测试
+  **文件**:`src/backend/test/app_publish/test_app_secret_api.py`
+  **逻辑/验收**:真实HTTP依赖覆盖,session owner允许与admin/nonowner/跨tenant/SAK/PAT拒绝;非法body不回显;保存无重建、确认才受理。
+  **覆盖 AC:** AC-66, AC-67, AC-68, AC-73, AC-74, AC-76, AC-78, AC-79, AC-80, AC-89, AC-90, AC-91, AC-92
+  **依赖**:T088
+
+- [x] **T094** — 密钥endpoint与路由
+  **类型**:后端 API
+  **文件**:`src/backend/bisheng/app_publish/api/endpoints/secrets.py`、`src/backend/bisheng/app_publish/api/router.py`
+  **逻辑/验收**:GET/PUT/DELETE/rebuild既有envelope,只依赖Service;安全ValidationErrorhandler和route长度限制不含config。
+  **覆盖 AC:** AC-66, AC-67, AC-68, AC-73, AC-74, AC-76, AC-89, AC-90, AC-91, AC-92
+  **依赖**:T093
+
+- [x] **T095** — 缺值与清理集成
+  **类型**:后端 Domain
+  **文件**:`src/backend/bisheng/app_publish/domain/services/publish_status_service.py`、`src/backend/bisheng/app_publish/composition.py`
+  **逻辑/验收**:发布/审读名称缺值,删除持久cleanup公共接线,业务事件登记不制造查询事件。
+  **覆盖 AC:** AC-67, AC-72, AC-81, AC-83, AC-84
+  **依赖**:T094
+
+- [x] **T096** — API与hook测试
+  **类型**:前端 Platform
+  **文件**:`src/frontend/platform/src/pages/BuildPage/hostedApp/hooks/useAppSecrets.test.tsx`
+  **逻辑/验收**:保存/确认分开、丢响应不自动重复、旧请求清理、缓存不存值、mask不提交。
+  **覆盖 AC:** AC-67, AC-74, AC-76, AC-89, AC-90, AC-91, AC-92
+  **依赖**:T094
+
+- [x] **T097** — API与hook实现
+  **类型**:前端 Platform
+  **文件**:`src/frontend/platform/src/controllers/API/appSecrets.ts`、`src/frontend/platform/src/pages/BuildPage/hostedApp/hooks/useAppSecrets.ts`
+  **逻辑/验收**:wrapped request/ReactQuery,短时空草稿、明确确认stable request_id,轮询既有op状态不自动再试。
+  **覆盖 AC:** AC-67, AC-74, AC-76, AC-89, AC-90, AC-91, AC-92
+  **依赖**:T096
+
+- [x] **T098** — 页面定向测试
+  **类型**:前端 Platform
+  **文件**:`src/frontend/platform/src/pages/BuildPage/hostedApp/publish/AppSecretSettings.test.tsx`
+  **逻辑/验收**:只读/admin边界、保存不重启、差异名、取消零调用、unknown/not_running、失败保留输入及独立bsConfirm。
+  **覆盖 AC:** AC-66, AC-67, AC-68, AC-73, AC-74, AC-75, AC-76, AC-79, AC-85, AC-89, AC-90, AC-91, AC-92
+  **依赖**:T097
+
+- [x] **T099** — 密钥设置与编辑表单
+  **类型**:前端 Platform
+  **文件**:`src/frontend/platform/src/pages/BuildPage/hostedApp/publish/AppSecretSettings.tsx`、`src/frontend/platform/src/pages/BuildPage/hostedApp/publish/AppSecretEditor.tsx`
+  **逻辑/验收**:done组件按当前规范,单文件600内;空草稿/固定mask、多行值、独立确认、只显示名/安全状态。
+  **覆盖 AC:** AC-66, AC-67, AC-68, AC-73, AC-74, AC-75, AC-76, AC-79, AC-85, AC-89, AC-90, AC-91, AC-92
+  **依赖**:T098
+
+- [x] **T100** — 发布槽位和中文文案
+  **类型**:前端 Platform
+  **文件**:`src/frontend/platform/src/pages/BuildPage/hostedApp/index.tsx`、`src/frontend/platform/public/locales/zh-Hans/bs.json`
+  **逻辑/验收**:只插已有settingsSlot,不改视觉模式,三语键同步后交付。
+  **覆盖 AC:** AC-67, AC-74, AC-89, AC-90, AC-91
+  **依赖**:T099
+
+- [x] **T101** — 英文日文文案
+  **类型**:前端 Platform
+  **文件**:`src/frontend/platform/public/locales/en-US/bs.json`、`src/frontend/platform/public/locales/ja/bs.json`
+  **逻辑/验收**:与中文同key无硬编码;界面动词短、环境变量值不展示。
+  **覆盖 AC:** AC-67, AC-74, AC-89, AC-90, AC-91
+  **依赖**:T100
+
+- [x] **T104** — 事件可见登记
+  **类型**:后端 Domain
+  **文件**:`src/backend/bisheng/database/models/audit_log.py`
+  **逻辑/验收**:仅登记app.secret.set/delete/rebuild事件及可信app关联,F056历史回收审计另待修;共享枚举增量兼容。
+  **覆盖 AC:** AC-81
+  **依赖**:T095
+
+- [x] **T108** — 前端审计事件登记
+  **类型**:前端 Platform
+  **文件**:`src/frontend/platform/src/controllers/API/log.ts`
+  **逻辑/验收**:只补app.secret.set/delete/rebuild事件,沿已有i18n映射;不混入后端实现。
+  **覆盖 AC:** AC-81
+  **依赖**:T104
+
+- [x] **T105** — E2E脚本与最小fixture
+  **类型**:测试
+  **文件**:`src/backend/test/e2e/test_app_secret_manual_apply.py`、`src/backend/test/e2e/fixtures/app_secret_probe.py`
+  **逻辑/验收**:走真实公开入口,哨兵仅回bool;Docker/K8s保存前后UID、确认原版本/档位/PVC数据、权限配对、预览空值,失败不自动再试。
+  **覆盖 AC:** AC-66, AC-67, AC-68, AC-70, AC-71, AC-73, AC-74, AC-75, AC-76, AC-77, AC-79, AC-80, AC-81, AC-82, AC-83, AC-84, AC-85, AC-86, AC-87, AC-88, AC-89, AC-90, AC-91, AC-92
+  **依赖**:T103, T104, T108, T092, T101
+
+- [ ] **T106** — 定向回归与真实E2E证据
+  **类型**:测试
+  **文件**:`features/v3.0.0/055-app-publish-pipeline/manual-apply-validation.md`、`features/v3.0.0/055-app-publish-pipeline/manual-apply-validation.json`
+  **逻辑/验收**:仅变化路径和必要lint/架构/类型/模型约束;授权专用环境部署前固定source/digest和保护范围;ARM不测DM8仅静态;未执行明确记录。
+  **覆盖 AC:** AC-54, AC-56, AC-66, AC-67, AC-68, AC-69, AC-70, AC-71, AC-72, AC-73, AC-74, AC-75, AC-76, AC-77, AC-78, AC-79, AC-80, AC-81, AC-82, AC-83, AC-84, AC-85, AC-86, AC-87, AC-88, AC-89, AC-90, AC-91, AC-92
+  **依赖**:T105
+
+- [ ] **T107** — L2与交付汇总
+  **类型**:测试
+  **文件**:`features/v3.0.0/055-app-publish-pipeline/manual-apply-code-review.md`、`features/v3.0.0/055-app-publish-pipeline/manual-apply-ac-mapping.json`
+  **逻辑/验收**:按sdd-code-review七维,只审F055增量和公共衔接;逐AC区分本地/模拟/真实/未验;未获授权不公开push/tag。
+  **覆盖 AC:** AC-54, AC-56, AC-66, AC-67, AC-68, AC-69, AC-70, AC-71, AC-72, AC-73, AC-74, AC-75, AC-76, AC-77, AC-78, AC-79, AC-80, AC-81, AC-82, AC-83, AC-84, AC-85, AC-86, AC-87, AC-88, AC-89, AC-90, AC-91, AC-92
+  **依赖**:T106
+
+
+## 2026-10-24 实施进度与验证安排
+
+用户要求优先完成全部主功能并提测,开发期只做当前功能定向测试,完整回归放到全部功能完成后。本轮 F055 已接入单表加密、声明、会话 API、实际运行差异、明确确认重建、运行输出脱敏和发布设置页面;T105–T107 为 E2E/交付检查,未执行真实环境验证,不宣称交付验收全部通过。
+
+窄适配文件:T088/T092 同时消费新增 `runtime_secret_diagnostics.py`;T095 的持久删除清理接入 `runtime_cleanup_service.py`(沿既有 cleanup_status,不新增清理调度器)。独立源码包无需 Alembic create-table revision。幂等请求 key 使用 request_id 的 SHA-256 作为有界命名键(不是值摘要),原确认参数摘要与回放规则不变。
+
+定向证据:新增 backend 54 passed;manager 脱敏 3 passed;前端 hook/页面 4 passed;版本 diff 旧响应断言修订后单用例通过。非全量测试。Platform 类型检查报告两个既有问题(缺少 @hello-pangea/dnd 声明、clientVoiceDraft.test.tsx 参数数目);未触及本功能文件。变更文件 ESLint 通过,Python Ruff/架构守卫持续核对。
+
+## 2026-10-24 补齐真实声明消费方(T109–T119)
+
+这些任务兑现已确认 AC-67、AC-69、AC-72、AC-84,不新增需求或审批流程。任务清单复审:作用域、生产消费、依赖与 AC 追溯通过;测试仅针对本次接线,完整回归仍待所有功能开发完成后。
+
+- [x] **T109** — 待审批引用与审批摘要用例
+  **类型**:测试
+  **文件**:`src/backend/test/app_publish/test_app_secret_service.py`、`src/backend/test/app_publish/test_app_secret_declaration.py`
+  **依赖**:T077
+  **覆盖 AC:** AC-67, AC-69, AC-72, AC-84
+
+- [x] **T110** — 识别真实待审批版本与安全引用状态
+  **类型**:后端 Domain
+  **文件**:`src/backend/bisheng/app_publish/domain/repositories/app_secret_repository.py`、`src/backend/bisheng/app_publish/domain/services/app_secret_service.py`
+  **依赖**:T109
+  **覆盖 AC:** AC-67, AC-72, AC-84
+
+- [x] **T111** — 公共只读协议接入审批快照
+  **类型**:后端 Domain
+  **文件**:`src/backend/bisheng/app_publish/application/runtime_secrets.py`、`src/backend/bisheng/app_publish/domain/services/publish_approval_service.py`
+  **依赖**:T110
+  **覆盖 AC:** AC-69, AC-72, AC-84
+
+- [x] **T112** — 发布状态与 CLI 轮询消费引用状态
+  **类型**:后端 Domain
+  **文件**:`src/backend/bisheng/app_publish/domain/services/publish_status_service.py`、`src/backend/bisheng/app_publish/domain/services/publish_pipeline_service.py`
+  **依赖**:T111
+  **覆盖 AC:** AC-72, AC-84
+
+- [x] **T113** — 发布页能力摘要展示与用例
+  **类型**:前端 Platform
+  **文件**:`src/frontend/platform/src/pages/BuildPage/hostedApp/publish/CapabilityListCard.test.tsx`、`src/frontend/platform/src/pages/BuildPage/hostedApp/publish/CapabilityListCard.tsx`
+  **依赖**:T112
+  **覆盖 AC:** AC-69, AC-72, AC-84
+
+- [x] **T114** — 客户端审批卡提交时状态用例
+  **类型**:测试
+  **文件**:`src/frontend/client/src/components/approval/AppPublishDetailPanel.test.tsx`
+  **依赖**:T111
+  **覆盖 AC:** AC-69, AC-72, AC-84
+
+- [x] **T115** — 沿既有能力列表呈现名称和提交时状态
+  **类型**:前端 Client
+  **文件**:`src/frontend/client/src/components/approval/AppPublishDetailPanel.tsx`
+  **依赖**:T114
+  **覆盖 AC:** AC-69, AC-72, AC-84
+
+- [x] **T116** — 审批摘要中英文文案
+  **类型**:基础设施
+  **文件**:`src/frontend/client/src/locales/en/translation.json`、`src/frontend/client/src/locales/zh-Hans/translation.json`
+  **依赖**:T115
+  **覆盖 AC:** AC-69, AC-72, AC-84
+
+- [x] **T117** — 审批摘要日文与三语同键检查
+  **类型**:基础设施
+  **文件**:`src/frontend/client/src/locales/ja/translation.json`
+  **依赖**:T116
+  **覆盖 AC:** AC-69, AC-72, AC-84
+
+- [x] **T118** — CLI 名称缺值提醒去重用例
+  **类型**:测试
+  **文件**:`src/bisheng-cli/tests/test_secret_warnings.py`
+  **依赖**:T112
+  **覆盖 AC:** AC-72
+
+- [x] **T119** — 缺值提醒与随源码重建 wheel
+  **类型**:CLI
+  **文件**:`src/bisheng-cli/bisheng_cli/commands/deploy.py`
+  **依赖**:T118
+  **覆盖 AC:** AC-72
+
+T119 的 wheel/manifest 是 scripts/pack_cli_wheel.sh 的派生产物,须一并提交;不发布外部包。T114 用例已编写,但执行因本机 canvas.node 缺失阻断,该项不计入通过数。T106/T107 保留未完成状态,未以新任务替代真实部署与最终合入审查。
+
+- [x] **T120** — 真实 CLI 轮询 API 定向补验
+  **类型**:测试
+  **文件**:`src/backend/test/app_publish/test_app_secret_api.py`
+  **依赖**:T112
+  **覆盖 AC:** AC-72, AC-84
+  **结果**:公开 /api/v2 轮询入口返回正确的名称与缺值元数据且不含保存值;1 例通过。
diff --git a/features/v3.0.0/056-app-square-governance/closeout-task-review.md b/features/v3.0.0/056-app-square-governance/closeout-task-review.md
new file mode 100644
index 0000000000..523a12bdc4
--- /dev/null
+++ b/features/v3.0.0/056-app-square-governance/closeout-task-review.md
@@ -0,0 +1,35 @@
+# 收尾增量任务审查
+
+2026-10-24:用户已单独确认 F054/F056 修订设计。审查范围为 tasks.md 文末本轮增量,历史任务计数不作为本轮进度。
+
+第 1 轮 ISSUE:三语文件任务过大(medium,F056 T050)。已拆分;共享注册副作用补 T053、T054。
+
+| # | 检查依据 | 结果 |
+|---|---|---|
+| 1 | AC 追溯:本轮新增 AC 在显式测试任务逐条列出;旧 AC 保持旧映射 | PASS |
+| 2 | AC 测试标注存在 | PASS |
+| 3 | 后端测试任务在配对实现之前 | PASS |
+| 4 | 依赖 ID 存在,无环 | PASS |
+| 5 | 每任务最多两个明确文件,三语已拆分 | PASS |
+| 6 | 路径/行为/测试上下文已内联 | PASS |
+| 7 | 前后端未混写 | PASS |
+| 8 | 同文件分阶段增量,不相互覆盖 | PASS |
+| 9 | 保留三源写入者,读侧无重复事实 | PASS |
+| 10 | 模型、API、界面、worker 均有任务 | PASS |
+| 11 | 快照/游标/导出/报告使用已批准设计合同 | PASS |
+| 12 | 无额外商业化、轮换或计费需求 | PASS |
+| 13 | AC ID 全写 | PASS |
+| 14 | 测试任务不含生产实现 | PASS |
+| 15 | 无用 TODO 替代功能交付 | PASS |
+| 16 | 新表保留回滚;既有列有 DDL downgrade | PASS |
+| 17 | 共享文件副作用登记;凭据 owner 同事务 | PASS |
+| 18 | 本轮仅 Platform,不改 Client | PASS |
+| 19 | worker headers tenant_id → tenant_execution_scope | PASS |
+| 20 | 基础设施独立先于消费方 | PASS |
+| 21 | 未降级测试为模拟部署;真实 E2E 单列待验 | PASS |
+
+第 2 轮:**LGTM**。这是任务计划审查,不代表代码、部署或最终 L2 通过。
+
+## 实际落点复核
+
+已将新增共享配置、公开管理视图协议、路由命名导出、测试夹具及宿主单元登记回 tasks.md。按依赖执行,补充项与原任务均为增量修改。L1 配对测试结果与具体文件以 closeout-validation.md 为准;真实 E2E 未勾选,不发最终 L2 PASS。
diff --git a/features/v3.0.0/056-app-square-governance/closeout-validation.json b/features/v3.0.0/056-app-square-governance/closeout-validation.json
new file mode 100644
index 0000000000..174fcc63e0
--- /dev/null
+++ b/features/v3.0.0/056-app-square-governance/closeout-validation.json
@@ -0,0 +1,401 @@
+{
+  "date": "2026-10-24",
+  "source_base": "818a12968",
+  "source_commit": "932fa37e1aa68f04d3e60a09b5af09394863d65f",
+  "evidence_kind": "incremental_focused_checks_not_one_full_suite",
+  "deployment_accepted": false,
+  "full_suite_run": false,
+  "dm8": "model_and_ddl_static_only",
+  "arm64": "deferred_by_user",
+  "executions": [
+    {
+      "name": "backend",
+      "file": "docs/reviews/evidence/vibe-governance-closeout-2026-10-24/backend.txt",
+      "summary": [
+        "30 passed"
+      ],
+      "sha256": "c9d6433fa770f4af62fad5c2d55ded53e07b03c06944f544b346504ffa6b1ab9"
+    },
+    {
+      "name": "backend-final",
+      "file": "docs/reviews/evidence/vibe-governance-closeout-2026-10-24/backend-final.txt",
+      "summary": [
+        "31 passed"
+      ],
+      "sha256": "566c766ca0b3cb541a93b334dd4aa3c8df47eeb8bb50e6baf30e1d63e3ad1d46"
+    },
+    {
+      "name": "obo-final",
+      "file": "docs/reviews/evidence/vibe-governance-closeout-2026-10-24/obo-final.txt",
+      "summary": [
+        "48 passed"
+      ],
+      "sha256": "ca6567344e61d777377fda5ccecb5e6a2387965375e97409a2b095f64fee48f6"
+    },
+    {
+      "name": "proxy-cache",
+      "file": "docs/reviews/evidence/vibe-governance-closeout-2026-10-24/proxy-cache.txt",
+      "summary": [
+        "5 passed"
+      ],
+      "sha256": "ad430bcd751b836a91fab4ff65c168e7e4f252c79243c40745308b866b554cb7"
+    },
+    {
+      "name": "proxy-http-final",
+      "file": "docs/reviews/evidence/vibe-governance-closeout-2026-10-24/proxy-http-final.txt",
+      "summary": [
+        "4 passed"
+      ],
+      "sha256": "6a145783a5cbfd88733842418703ac7d87dd27ec619e724919231c769528467e"
+    },
+    {
+      "name": "manager-verified",
+      "file": "docs/reviews/evidence/vibe-governance-closeout-2026-10-24/manager-verified.txt",
+      "summary": [
+        "9 passed"
+      ],
+      "sha256": "ed12499504fb433a657b31ced875e7369343bf9696d2544d97d223e4f49c7d34"
+    },
+    {
+      "name": "host-and-lease",
+      "file": "docs/reviews/evidence/vibe-governance-closeout-2026-10-24/host-and-lease.txt",
+      "summary": [
+        "5 passed"
+      ],
+      "sha256": "29b8039be5a2e30f15cb13d769ae67824db561989cc5927a224126d87f1a79d1"
+    },
+    {
+      "name": "download-size",
+      "file": "docs/reviews/evidence/vibe-governance-closeout-2026-10-24/download-size.txt",
+      "summary": [
+        "1 passed"
+      ],
+      "sha256": "5a082ae33cb1d26fa1c51cb9dbbd4146628160819b4607ef6606e67c199ef535"
+    },
+    {
+      "name": "ui-final",
+      "file": "docs/reviews/evidence/vibe-governance-closeout-2026-10-24/ui-final.txt",
+      "summary": [
+        "9 passed"
+      ],
+      "sha256": "61db11f6962220df12e404d191fe414c26c37ee8dfc984b0e294ae4029e9190f"
+    },
+    {
+      "name": "ui-verified",
+      "file": "docs/reviews/evidence/vibe-governance-closeout-2026-10-24/ui-verified.txt",
+      "summary": [
+        "4 passed"
+      ],
+      "sha256": "9d152cfc9cba8b0428e39bc33d0c34cb54f8d98e3033aac820ba3de4860c963b"
+    },
+    {
+      "name": "e2e-collection",
+      "file": "docs/reviews/evidence/vibe-governance-closeout-2026-10-24/e2e-collection.txt",
+      "summary": [
+        "4 tests collected"
+      ],
+      "sha256": "d6a28ee4f4d200d3b1ca62f286fb65c3b3c08657f66faefec3ac533bffa02bf4"
+    },
+    {
+      "name": "typecheck-last",
+      "file": "docs/reviews/evidence/vibe-governance-closeout-2026-10-24/typecheck-last.txt",
+      "summary": [
+        "static output; inspect receipt"
+      ],
+      "sha256": "4460929d5faac8064a9690c9d4714845f5af8cb8e344e3e986148228a19ffc5c"
+    },
+    {
+      "name": "eslint-last",
+      "file": "docs/reviews/evidence/vibe-governance-closeout-2026-10-24/eslint-last.txt",
+      "summary": [
+        "static output; inspect receipt"
+      ],
+      "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+    },
+    {
+      "name": "arch-final",
+      "file": "docs/reviews/evidence/vibe-governance-closeout-2026-10-24/arch-final.txt",
+      "summary": [
+        "static output; inspect receipt"
+      ],
+      "sha256": "ba7bb3dee213582b6299f08589a5fed436abdaed15e2556886cde63238794e3b"
+    },
+    {
+      "name": "static-receipt",
+      "file": "docs/reviews/evidence/vibe-governance-closeout-2026-10-24/static-receipt.json",
+      "summary": [
+        "static output; inspect receipt"
+      ],
+      "sha256": "3bcc84fd4a91ce9a9018bd9623eb2caf13bff18c0503bbabc19788bcd68e58a9"
+    }
+  ],
+  "source_sha256": {
+    "deploy/runtime-manager/bisheng-host-isolation.service": "ed3e09481a69b888285806d7a78d68b945f4a8d82b6d695d900ada6e0986f26a",
+    "deploy/runtime-manager/bisheng-host-isolation.timer": "379edd51a9d5bd2fb46ab1fc26dc46430604884a12027f45ba52151567eb60cc",
+    "deploy/runtime-manager/install-host-isolation.sh": "674ae0065ae8a31eceaff88f8a8672ca3aa2045e01deabd71cd9e63abf9004d2",
+    "docker/.env.example": "fbaf36877ba9ed132574c72841c615b1fd65c277f113cf989bb09618d695b607",
+    "docker/docker-compose.yml": "e535febf55b45f5544351bd00d72f008a6d82d689c688cd9e74d6a1395b3dae8",
+    "src/app-proxy/app_proxy/authz.py": "ad2bc8147cc1bce8bcc6ae5187848f72026559269469ae9335e154004bc8eec5",
+    "src/app-proxy/app_proxy/clients.py": "74cc03b6020c9a27e31e1edd7495fbc4dc37b6f8fcf7219ad4590a611411f99f",
+    "src/app-proxy/app_proxy/main.py": "04c67fcc55af794cf5c964fcdd28bb057aa9093d17a9f6dc83f06227ee35062d",
+    "src/app-proxy/app_proxy/pages.py": "cb2a07c4830fa0bed561d2982a8c46d5a7d26b630b136b54930f73be9bb79c42",
+    "src/app-proxy/tests/test_authz_and_fallback.py": "a1344844d6835175c5de260cb736524e119a6ef69c631de26d87800a92b6e5d2",
+    "src/app-proxy/tests/test_obo_configuration_page.py": "55023f89ba330a8ed6d2fab642a88f02428d0959fd884d3e83a22fa227cf9287",
+    "src/backend/bisheng/admin/application/__init__.py": "b7c8ab99e5b51e027882a87dac6fa0812f69502498af621980244872d487c665",
+    "src/backend/bisheng/admin/application/tenant_scope.py": "4ac89f41fa7d79cce41e49b6a38d9bbf52ce027ecafc931d9545b767f42187ee",
+    "src/backend/bisheng/api/router.py": "0383e8543e33b0d63bc76ee2a2cb70af85bac31d71aadf9e98b8bbece30e4380",
+    "src/backend/bisheng/app_governance/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+    "src/backend/bisheng/app_governance/api/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+    "src/backend/bisheng/app_governance/api/router.py": "40a19200792e6a7547ec8f97f347c548c0d73bdc529ea34023703ee27dc0ca58",
+    "src/backend/bisheng/app_governance/domain/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+    "src/backend/bisheng/app_governance/domain/models/__init__.py": "45c540050c373553e7e98e6c790753a7210a4c6dc0572285e9f69156d6cd5e36",
+    "src/backend/bisheng/app_governance/domain/models/runtime_audit.py": "7104faa06f47abe41f7d800b6e4fbc23b62393b408a3b82f834634a9561fb43b",
+    "src/backend/bisheng/app_governance/domain/repositories/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+    "src/backend/bisheng/app_governance/domain/repositories/runtime_audit_export_repository.py": "2e7b35732fdc94506fca6b3be2c1540546adadfb116add2671c87de117e1dcc4",
+    "src/backend/bisheng/app_governance/domain/repositories/runtime_audit_repository.py": "68a8aacfd22efae13b6bd073ad3ce080529e7961e9039b689323a87881def37b",
+    "src/backend/bisheng/app_governance/domain/schemas/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+    "src/backend/bisheng/app_governance/domain/schemas/runtime_audit.py": "9e12b95c53f10f42c8a4ba4255f03755cfbfb8ec8aff5efd12bdc523862d51f8",
+    "src/backend/bisheng/app_governance/domain/services/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+    "src/backend/bisheng/app_governance/domain/services/runtime_audit_export_service.py": "7f91eab2f453d7731d6a10fd6a986a1b47701039d46ec7b152fe5ded4ac8b803",
+    "src/backend/bisheng/app_governance/domain/services/runtime_audit_query_service.py": "f4b11d782a6adf889db05e68a66bd815e7e98c666439f228d659ab0abeb8f5eb",
+    "src/backend/bisheng/app_governance/domain/services/runtime_audit_scope.py": "6c12a018797b9bf68915f9531e131f2e3b766a6437d3f20cf01e50534412779e",
+    "src/backend/bisheng/app_governance/domain/services/runtime_audit_storage.py": "faae95c2f1e5fb73ce60b27896061676c3e1cb41bfb2aa87da4eae27c02cbaff",
+    "src/backend/bisheng/app_runtime/api/endpoints/host_isolation.py": "d8c05b88c16d1613796d1060e88c5e2d5cd9c72d81ef119639a73066a473a558",
+    "src/backend/bisheng/app_runtime/api/router.py": "d215b83dc12c75b57ec5a1e65fea4f13d7d400988362f7d46fc742ef1a3ecbc2",
+    "src/backend/bisheng/app_runtime/domain/models/__init__.py": "768bbd9a6bf3fc9a9c8ad01f63ed8ab8f36ffab8110803839ef62827c7c65690",
+    "src/backend/bisheng/app_runtime/domain/models/runtime_host_isolation.py": "0392978447ad8b1ae8f239647819307367222d1f1a93989902d9ef361d5e4e1d",
+    "src/backend/bisheng/app_runtime/domain/repositories/host_isolation_repository.py": "76dc5973f622c32bcadcdf4acfd237d2297445fc00049b3f713f2105291ae318",
+    "src/backend/bisheng/app_runtime/domain/repositories/runtime_delivery_repository.py": "e59af036a600545395dc8cc0b90bb762e95278608fe1db465347941eb61d0b26",
+    "src/backend/bisheng/app_runtime/domain/services/app_access_log_service.py": "a46316944cffcdca78631a1f0cd3efa9164dec9667375678acd79c9e7741c670",
+    "src/backend/bisheng/app_runtime/domain/services/entry_authz_service.py": "07cfa45ea439b42b98a62e4b0fbc041d4e7dcead9215a841198b41b6665ddb5e",
+    "src/backend/bisheng/app_runtime/domain/services/host_isolation_service.py": "6156832d2dad24e47eada16695162dd4b7ccd2976d850a506646ab8216a70ed0",
+    "src/backend/bisheng/app_runtime/domain/services/runtime_operation_service.py": "afeb89c1128203430ad79bca15562fb7166b7f1bf3f0baeb2a08a75c5a619755",
+    "src/backend/bisheng/app_runtime/domain/services/runtime_projection_service.py": "63807575dca0082c76bf826dc34121d849ff12b262ecec2ae5c8d80e8a2d529b",
+    "src/backend/bisheng/common/errcode/app_governance.py": "d09ff1d629fda4c9f9ff48bc4de556f5fe2d603a6cdf80118782f14cbd3512e3",
+    "src/backend/bisheng/core/config/app_runtime.py": "b199be9ae5bd6cafa7b3031cb1a252968f46f2413916495b35ad421ce4819f3b",
+    "src/backend/bisheng/core/config/settings.py": "1e74c7617c548c03d331c306354caadd6664d9e543824509aa805b14877832f5",
+    "src/backend/bisheng/core/database/alembic/versions/f056_access_name_snapshot.py": "c285dda11faacbc21a0400d94a61e515529789ba113c7b041fcf597f84ba1bb9",
+    "src/backend/bisheng/core/database/tenant_filter.py": "4b06c292144e215d102d04fb2fd8dcd9e754e49efab2b3c24f03e245e2766317",
+    "src/backend/bisheng/database/models/app_access_log.py": "36498cb4581efdc6716d67a3d2c69d1bc3c9034a1941e144802834802e5ff7fb",
+    "src/backend/bisheng/main.py": "0c041db464ff1b202c7cd9f35e9d02b97d6cb65ad22a18988afd58ad7e6e2731",
+    "src/backend/bisheng/open_api/application/__init__.py": "ec8c53d600465fa24f8db753476d22c827bb9baa36310fea1e8e7389f4c740a6",
+    "src/backend/bisheng/open_api/application/runtime_credentials.py": "49d1bf9a263973f848b7692d3e6115a69f281ddb53d83002609ffd102987cc08",
+    "src/backend/bisheng/open_api/domain/repositories/credential_repository.py": "61ec2b4b79f6c4389e9aa919d62ccd9e95d3c6d3f1eb7cce6fc931bc4c8cf20a",
+    "src/backend/bisheng/open_api/domain/services/credential_service.py": "094cdb3a8cdf73f8773b77b123f222b6c7915da2b1903703620f1b880412a219",
+    "src/backend/bisheng/worker/__init__.py": "5ebd79bfbc31a58cf7ed3cd99b6880782621b26cbf8069d57c4ab3f1afb27ba1",
+    "src/backend/bisheng/worker/runtime_audit/__init__.py": "64fa6c65253fc46bec85ebf0bbe3eb59bb9107f5d8819176f55fc2170baf856e",
+    "src/backend/bisheng/worker/runtime_audit/tasks.py": "b7cf59e92ddc112978d81084f416731359d08ecacded5ee9180a5270d82c3d9d",
+    "src/backend/test/app_governance/test_runtime_audit_export.py": "8a0e99a478213119846a2b312d202b0228863bf772726d68b8cd72d6dbbd7bea",
+    "src/backend/test/app_governance/test_runtime_audit_query.py": "a34cabba9dd20205c0f7bc25bfc219f2b8aef194338da1ea7b821ef44c028654",
+    "src/backend/test/app_runtime/test_entry_authz_service.py": "f1abc773a7ac8400f56615761a9293632be384025c4b4bdfbf4a7e646570d1e2",
+    "src/backend/test/app_runtime/test_host_isolation.py": "3c29d392f9a05acf1b9421d6205b7666e46794f5148040681cba09b5a311a8f2",
+    "src/backend/test/app_runtime/test_preview_entry.py": "343192fc19ba4b357471e29fd5f3a64919553260ec87ad20105341906e602820",
+    "src/backend/test/app_runtime/test_runtime_delivery_repository.py": "0c211041c903de4d67a396b016f5b9919c4f39fafc1ce98bd9e65927c7c1d06c",
+    "src/backend/test/e2e/test_e2e_vibe_governance_closeout.py": "4b7145a2bffd38fdf335c65a4d8143b7c86de23ec87e308e7975ec17356511af",
+    "src/backend/test/open_api/test_runtime_credential_audit.py": "64a138f839ba4743ca2a2c8b369c1cd58cade1717f52fe351fc31d75d5b5e5de",
+    "src/frontend/packages/locales/src/api_errors/en.json": "2df4f93d72ebc294f31e3954605069829a07e969dc4e2fb7c1986b6cddc29a9a",
+    "src/frontend/packages/locales/src/api_errors/ja.json": "a49a67e8fcd51125a6b9b8c856ee7158de1454169b058fc771606f6d824df023",
+    "src/frontend/packages/locales/src/api_errors/zh-Hans.json": "bca642ce763ce959bb5f0147ae61ca7311a2c226ec16a1dc9f212f5b8282ad8c",
+    "src/frontend/platform/eslint-suppressions.json": "167c1ad24cbd4fc31255560b2a4c82f81aec416f9ce54f24d1a9512a23325f56",
+    "src/frontend/platform/public/locales/en-US/bs.json": "53af0b8d7a3d346507bafea371666e40a1bb078fcad29db491ba7d071a146d2e",
+    "src/frontend/platform/public/locales/ja/bs.json": "71239c7e4a8f38448de1c426ead50979ebb2db643e6cf4dae448924efc61c6f4",
+    "src/frontend/platform/public/locales/zh-Hans/bs.json": "a369ca80bc335bb504ef9d603da0d10aea8eb1a4fc6922fdee235348fc471f8a",
+    "src/frontend/platform/src/controllers/API/runtimeAudit.ts": "64fbcd101fce1e8cc28adc34fef54e7d7261b6d8f9058b94f14b9a1dbea8de0e",
+    "src/frontend/platform/src/pages/LogPage/index.test.tsx": "6bfb7e4389ef7005d9871066ac9cc9e68ed0433adbf88c1aa69b84e4c5eef516",
+    "src/frontend/platform/src/pages/LogPage/index.tsx": "a870c455127315a11cae7eaa2b2bf466a10265f51ba644c4cf65b3f0a16900c7",
+    "src/frontend/platform/src/pages/LogPage/runtime/RuntimeAuditPanel.tsx": "a11bdf8879e0f1d7a4f15a6e5037ced9e726a3f88012f8cedb37e1e44866ecc6",
+    "src/frontend/platform/src/pages/LogPage/runtime/useRuntimeAudit.ts": "b320d0294dfa5532a3c859a248ff8cf5b88169828a5a7f7c5938ccd295b5f55a",
+    "src/frontend/platform/src/routes/index.tsx": "e94b5b580c583e7aa2f835ed85a8d81233dff94387902fd9a066f02ba8de42a5",
+    "src/frontend/platform/src/test/runtimeAudit.test.tsx": "6bc8723ae25604bc4d48be735959dafd07def9545ce509b0977bad41b5dcba37",
+    "src/runtime-manager/runtime_manager/api/readonly.py": "89f9e4060b315fc81314656afe6777368200e61deb817b66637b6d8da7aa240d",
+    "src/runtime-manager/runtime_manager/api/routes.py": "b44cece784f1d5e556756bb70390eec9e2823f9e2e3aaf8797837edd2e0f6c3a",
+    "src/runtime-manager/runtime_manager/docker_backend.py": "1eca95b55d221b68a7230996d974fca45a5fa4f7f0370037ffcb825960293a0b",
+    "src/runtime-manager/runtime_manager/host_isolation.py": "d0f160b790bf8147f0e4ed669604f37f8a1c16f43220fb7a2d61450ea00c6acb",
+    "src/runtime-manager/runtime_manager/platform_client.py": "e00a1588c698ec433ab7ae85768bc28073df3caa1e404263138f311d854be260",
+    "src/runtime-manager/runtime_manager/probe.py": "38afbfaf76afe2b3c13f22813699a3b177bc82f36dddd406b9196b9c4b4adbed",
+    "src/runtime-manager/tests/fakes.py": "037e1b28186bcec252fa9b40254a51ac358d1345707974979c98af388fc0ab0b",
+    "src/runtime-manager/tests/test_host_isolation.py": "58d958766b7a261eef4f51c8493dc8fbf25172ad654b05dd1dd3a44f4d12f2a9"
+  },
+  "acs": [
+    {
+      "ac": "AC-46",
+      "implementation": "implemented_local",
+      "local_evidence_files": [
+        "src/backend/test/app_governance/test_runtime_audit_query.py",
+        "src/backend/test/app_governance/test_runtime_audit_export.py",
+        "src/frontend/platform/src/test/runtimeAudit.test.tsx"
+      ],
+      "deployment_acceptance": "pending",
+      "notes": "Local protocol/SQL/component evidence; not a real host/cluster acceptance claim"
+    },
+    {
+      "ac": "AC-47",
+      "implementation": "implemented_local",
+      "local_evidence_files": [
+        "src/backend/test/app_governance/test_runtime_audit_query.py",
+        "src/backend/test/app_governance/test_runtime_audit_export.py",
+        "src/frontend/platform/src/test/runtimeAudit.test.tsx"
+      ],
+      "deployment_acceptance": "pending",
+      "notes": "Local protocol/SQL/component evidence; not a real host/cluster acceptance claim"
+    },
+    {
+      "ac": "AC-48",
+      "implementation": "implemented_local",
+      "local_evidence_files": [
+        "src/backend/test/app_governance/test_runtime_audit_query.py",
+        "src/backend/test/app_governance/test_runtime_audit_export.py",
+        "src/frontend/platform/src/test/runtimeAudit.test.tsx"
+      ],
+      "deployment_acceptance": "pending",
+      "notes": "Local protocol/SQL/component evidence; not a real host/cluster acceptance claim"
+    },
+    {
+      "ac": "AC-49",
+      "implementation": "implemented_local",
+      "local_evidence_files": [
+        "src/backend/test/app_governance/test_runtime_audit_query.py",
+        "src/backend/test/app_governance/test_runtime_audit_export.py",
+        "src/frontend/platform/src/test/runtimeAudit.test.tsx"
+      ],
+      "deployment_acceptance": "pending",
+      "notes": "Local protocol/SQL/component evidence; not a real host/cluster acceptance claim"
+    },
+    {
+      "ac": "AC-50",
+      "implementation": "implemented_local",
+      "local_evidence_files": [
+        "src/backend/test/app_governance/test_runtime_audit_query.py",
+        "src/backend/test/app_governance/test_runtime_audit_export.py",
+        "src/frontend/platform/src/test/runtimeAudit.test.tsx"
+      ],
+      "deployment_acceptance": "pending",
+      "notes": "Local protocol/SQL/component evidence; not a real host/cluster acceptance claim"
+    },
+    {
+      "ac": "AC-51",
+      "implementation": "implemented_local",
+      "local_evidence_files": [
+        "src/backend/test/app_governance/test_runtime_audit_query.py",
+        "src/backend/test/app_governance/test_runtime_audit_export.py",
+        "src/frontend/platform/src/test/runtimeAudit.test.tsx"
+      ],
+      "deployment_acceptance": "pending",
+      "notes": "Local protocol/SQL/component evidence; not a real host/cluster acceptance claim"
+    },
+    {
+      "ac": "AC-52",
+      "implementation": "implemented_local",
+      "local_evidence_files": [
+        "src/backend/test/app_governance/test_runtime_audit_query.py",
+        "src/backend/test/app_governance/test_runtime_audit_export.py",
+        "src/frontend/platform/src/test/runtimeAudit.test.tsx"
+      ],
+      "deployment_acceptance": "pending",
+      "notes": "Local protocol/SQL/component evidence; not a real host/cluster acceptance claim"
+    },
+    {
+      "ac": "AC-53",
+      "implementation": "implemented_local",
+      "local_evidence_files": [
+        "src/backend/test/app_governance/test_runtime_audit_query.py",
+        "src/backend/test/app_governance/test_runtime_audit_export.py",
+        "src/frontend/platform/src/test/runtimeAudit.test.tsx"
+      ],
+      "deployment_acceptance": "pending",
+      "notes": "Local protocol/SQL/component evidence; not a real host/cluster acceptance claim"
+    },
+    {
+      "ac": "AC-54",
+      "implementation": "implemented_local",
+      "local_evidence_files": [
+        "src/backend/test/app_governance/test_runtime_audit_query.py",
+        "src/backend/test/app_governance/test_runtime_audit_export.py",
+        "src/frontend/platform/src/test/runtimeAudit.test.tsx"
+      ],
+      "deployment_acceptance": "pending",
+      "notes": "Local protocol/SQL/component evidence; not a real host/cluster acceptance claim"
+    },
+    {
+      "ac": "AC-55",
+      "implementation": "implemented_local",
+      "local_evidence_files": [
+        "src/backend/test/app_governance/test_runtime_audit_query.py",
+        "src/backend/test/app_governance/test_runtime_audit_export.py",
+        "src/frontend/platform/src/test/runtimeAudit.test.tsx"
+      ],
+      "deployment_acceptance": "pending",
+      "notes": "Local protocol/SQL/component evidence; not a real host/cluster acceptance claim"
+    },
+    {
+      "ac": "AC-56",
+      "implementation": "implemented_local",
+      "local_evidence_files": [
+        "src/backend/test/app_governance/test_runtime_audit_query.py",
+        "src/backend/test/app_governance/test_runtime_audit_export.py",
+        "src/frontend/platform/src/test/runtimeAudit.test.tsx"
+      ],
+      "deployment_acceptance": "pending",
+      "notes": "Local protocol/SQL/component evidence; not a real host/cluster acceptance claim"
+    },
+    {
+      "ac": "AC-57",
+      "implementation": "implemented_local",
+      "local_evidence_files": [
+        "src/backend/test/app_governance/test_runtime_audit_query.py",
+        "src/backend/test/app_governance/test_runtime_audit_export.py",
+        "src/frontend/platform/src/test/runtimeAudit.test.tsx"
+      ],
+      "deployment_acceptance": "pending",
+      "notes": "Local protocol/SQL/component evidence; not a real host/cluster acceptance claim"
+    },
+    {
+      "ac": "AC-58",
+      "implementation": "implemented_local",
+      "local_evidence_files": [
+        "src/backend/test/app_governance/test_runtime_audit_query.py",
+        "src/backend/test/app_governance/test_runtime_audit_export.py",
+        "src/frontend/platform/src/test/runtimeAudit.test.tsx"
+      ],
+      "deployment_acceptance": "pending",
+      "notes": "Local protocol/SQL/component evidence; not a real host/cluster acceptance claim"
+    },
+    {
+      "ac": "AC-59",
+      "implementation": "implemented_local",
+      "local_evidence_files": [
+        "src/backend/test/open_api/test_runtime_credential_audit.py"
+      ],
+      "deployment_acceptance": "pending",
+      "notes": "Local protocol/SQL/component evidence; not a real host/cluster acceptance claim"
+    },
+    {
+      "ac": "AC-60",
+      "implementation": "implemented_local",
+      "local_evidence_files": [
+        "src/backend/test/app_governance/test_runtime_audit_query.py",
+        "src/backend/test/app_governance/test_runtime_audit_export.py",
+        "src/frontend/platform/src/test/runtimeAudit.test.tsx"
+      ],
+      "deployment_acceptance": "pending",
+      "notes": "Local protocol/SQL/component evidence; not a real host/cluster acceptance claim"
+    },
+    {
+      "ac": "AC-61",
+      "implementation": "implemented_local",
+      "local_evidence_files": [
+        "src/backend/test/app_governance/test_runtime_audit_query.py",
+        "src/backend/test/app_governance/test_runtime_audit_export.py",
+        "src/frontend/platform/src/test/runtimeAudit.test.tsx"
+      ],
+      "deployment_acceptance": "pending",
+      "notes": "Local protocol/SQL/component evidence; not a real host/cluster acceptance claim"
+    }
+  ],
+  "revised_existing_acs": [
+    "AC-26",
+    "AC-34"
+  ],
+  "html_visual_acceptance": "not_performed_file_protocol_blocked_by_browser_policy",
+  "receipt_normalization": "terminal trailing blank lines removed; output content unchanged"
+}
diff --git a/features/v3.0.0/056-app-square-governance/closeout-validation.md b/features/v3.0.0/056-app-square-governance/closeout-validation.md
new file mode 100644
index 0000000000..719a5244e0
--- /dev/null
+++ b/features/v3.0.0/056-app-square-governance/closeout-validation.md
@@ -0,0 +1,43 @@
+# 收尾定向验证与 L1:056-app-square-governance
+
+日期:2026-10-24。用户已单独确认修订设计。实现基线 `818a12968`,当前源码/命令证据见 [JSON](./closeout-validation.json);本轮提交号以 Git 记录和统一提测报告为准。
+
+## 状态
+
+本轮主功能编码已接齐,按用户要求进行定向验证。**真实部署 E2E、最终 L2 和合入仍未完成,不是全部交付 PASS。** 未运行全量测试,跨命令用例有重叠,不将计数直接相加。
+
+## 已执行的主要检查
+
+| 范围 | 结果 / 证据 |
+|---|---|
+| 后端三源查询、导出、凭据审计、报告等收尾批 | 30 passed;JSON 记录实际路径,后续修复只重跑对应路径 |
+| OBO 正式与预览,存在性/权限顺序、配置快照、无效 key、正常签发 | 48 passed |
+| app-proxy 三语页面/正式和预览旧 allow 不复用 | 5 passed;纯渲染/MockTransport,不使用主机网络 |
+| app-proxy 规范 HTTP 入口授权缓存回归 | 4 passed;loopback fake upstream,不是真实部署;规范入口每请求一次判定 |
+| manager 自有规则/适配器门禁/legacy 状态读面 | 9 passed;真实防火墙命令未执行 |
+| 宿主报告、心跳不能续证明及导出租约交接补测 | 5 passed |
+| 下载完整字节数与 immutable CSV | 1 passed |
+| 审计页原两页签、门禁、记录展示与分页作用域 | 两文件 9 passed;候选分页更新后只重跑对应组件 4 passed |
+| 真实环境 E2E 用例 | 4 条 collected,未运行;不能记为通过 |
+| 静态检查 | 改动 Python Ruff、逐文件架构守卫无 VIOLATION、单 Alembic head、Compose YAML/独立角色配置、安装脚本 bash 语法、运行记录三语 keys 对齐 |
+| 前端类型检查 | 本次文件无新增错误;仍有两条既有阻塞:OrgKbConfig 缺 @hello-pangea/dnd 类型声明,clientVoiceDraft.test 参数签名;其文件未被本轮改动 |
+
+## Task Review(L1,按 tasks.md 本轮实现/测试任务)
+
+| # | 检查 | 结果 | 依据 |
+|---|---|---|---|
+| 1 | 分层 | PASS | Endpoint → Service → Repository;backend 无 Engine/防火墙调用;跨域通过公开应用协议 |
+| 2 | 命名 / 路径 | PASS | 测试位于模块目录;LogPage 命名导出,惰性路由已接;不新增含 config 的公共 URL |
+| 3 | 序列化 | PASS | 白名单元数据,无完整 ORM/任意 metadata 透传;内部 HMAC 报告独立合同 |
+| 4 | 数据库 | PASS(静态/SQLite) | JsonType/通用字段、请求者 tenant 默认 None + NOT NULL;批量删除带显式 tenant;节点报告为已批准的全局事实;DM8 未真实执行 |
+| 5 | 前端 | PASS_WITH_NOTES | 复用 UI 与 request/query;三语同提交;已收缩旧 Hook ESLint suppression;两条既有类型错误未扩大范围修复 |
+| 6 | 信息泄漏 | PASS | OBO/业务值不进入查询/导出;凭据仅 mask;无公开 MinIO URL;CSV 公式防护;源码无真实密钥 |
+| 7 | 设计同步 | PASS | 正式设计补记实现合同、源时区、取消/GC、宿主安装位置与实际门禁;批准时 hash 保留在任务记录 |
+
+元数据:实际新增入口/配置/夹具已补回 tasks.md;配对测试按上述真实结果登记。**阶段结果 PASS_WITH_NOTES;不替代最终 `/sdd-code-review`。**
+
+## 提测与暂缓事项
+
+统一见 [提测报告](../../../docs/reviews/vibe-governance-closeout-handoff-2026-10-24.md)。优先验证 Docker 109 与 K8s 189 的保存不重启、确认后重建、三源查询/导出与权限;Docker 再验证真实宿主 INPUT/FORWARD、iptables/nftables 与证明过期。
+
+按用户边界,超长暂停旧 worker 与 GC 晚到上传、跨节点时钟偏差、历史混合/DST 时区等低概率组合列待验,不继续扩展状态机。本轮没有公开推送、tag 或镜像发行。
diff --git a/features/v3.0.0/056-app-square-governance/design.md b/features/v3.0.0/056-app-square-governance/design.md
new file mode 100644
index 0000000000..4181a7defe
--- /dev/null
+++ b/features/v3.0.0/056-app-square-governance/design.md
@@ -0,0 +1,287 @@
+# Design: 应用广场、治理与运行记录查询
+
+> **2026-10-24 用户确认修订设计,进入任务拆解与编码。** 现状基线 `5f2541f4bc483c24624fede9031c88c4ec6f58f4`;规格确认提交 `dda16a6502908ba8d58cff61dc350c0213b86c19`。保留 D1–D11 与 §1–§8 编号,新增 D12–D15;历史 MVP“查询全后置”已不适用于现有操作审计。
+>
+> [spec.md](./spec.md)(61 条 AC)· [tasks.md](./tasks.md) · [release-contract.md](../release-contract.md)。本次新增 AC-46 至 AC-61,修订 AC-26、AC-34;不接管模型/能力/访问记录的业务写入者。
+
+## 1. 目标与非目标
+
+保留应用广场和统一授权交互,在现有“审计日志”页增加“应用运行”页签,查询/导出模型调用、能力调用和应用访问三类既有数据;让现有操作审计能够按应用找到密钥变更与凭据签发/撤销事件。
+
+非目标:计费结算、全链路 tracing 产品、日志全文搜索、把 runtime stdout 当审计、重算历史身份/Token、为每次页面查询生成业务访问事件、给 owner 或开放 API 凭据授予管理员日志读取能力。
+
+## 2. 关键约束
+
+- 管理员级运行记录只接受自然人平台会话:租户管理员只查有效当前租户;全局超管无 admin-scope 时可明确选全局/目标租户,有 scope 时锁定该租户。部门管理员/只有 log 菜单的角色/应用 owner 不能因此取得此新读面。
+- 现有系统操作审计拥有更宽的菜单/管理范围规则,**不直接复用其 `_resolve_audit_groups` 当新接口权限**;保留旧接口行为。
+- 三类记录并不等于三类成功操作:无 app_id 的服务账号模型调用仍应显示;访问记录是入口 allow 合并窗口,不能展示为每个 HTTP 请求;缺历史字段用 ID/未知,不回填当前无关名称/用户。
+- 列表默认最近 7 天,可选择任意合法自定义区间或全部已留存时间;UTC ISO8601 带时区传输/规范化,start≤end;limit 1–100。**不设 90 天查询或 10,000 行导出总量上限**,导出覆盖所选条件下全部受授权记录。内部每批例如 500 行、worker 并发/租约和按字节输出分块仅控制资源,不变成用户可导出总量限制。
+- Constitution Check:遵守 [constitution.md](../../../docs/constitution.md) C1–C8。新读侧 Model/Repository/Service/Endpoint 分层;租户自动上下文不手拼通用 tenant 条件;全局超管进入显式审计 scope;通用索引/类型无 JSON_EXTRACT;模块错误码 163 段登记后使用;导出在受限对象存储不放 API 本地盘;无铁律例外。
+
+## 3. 方案对比与选定
+
+### D1:广场继续现有 UNION 与批量权限
+
+不新建平行应用列表后在前端拼接。托管应用 `flow_type=35`、slug/app_state 已随列表下发,批量可见性继续走 F048;单卡补拉权限造成 N+1 且越过通用策略。新资源类型/统一列表协议改变时再议。
+
+### D2:卡片整页访问稳定入口
+
+保留跳转 `/apps/{slug}`,不嵌 iframe、不拼 client `/workspace` base;统一登录/WS/身份均在入口。切换域名模型时再评审链接生成。
+
+### D3:广场动作与可见范围仍由通用权限驱动
+
+`app` 适用动作集与广场 use 筛选保留,不把“能看到卡片”作为运行记录管理员权限。二者属于不同业务目标。动作 Catalog 改变时审查消费者。
+
+### D4:管理权限仍复用 PermissionDialog
+
+构建卡片与发布页 VisibilityScopeSection 用同一交互/API;不为了审计页再建授权入口。当前 request 拒绝错误已需 catch,异步失败不能留 loading。组件契约变更时复核两个入口。
+
+### D5:仅 owner 可见继续按 protected grant 区分
+
+非 protected 授权行为空才表示无额外放行;默认 owner 行不应算外部授权。不从空列表误推断“无人有权限”。权限返回 schema 改变时重审。
+
+### D6:可见范围变更由真实写路径审计
+
+仍在统一授权 mutation 后按资源回调写 `app.visibility_change`,不从前端补写。查询/导出仅消费事实。新增授权写入口时必须核对回调是否覆盖。
+
+### D7:访问记录写 F054、读 F056
+
+`entry_authz_service` 已写合并窗口访问事实,不再“写读一起顺延”。保留既有合并窗口及其测试,本轮只补稳定读/导出适配,不按请求量补记历史访问。日后产品需要 PV/会话分析另立语义。
+
+### D8:runtime 关闭明确隐藏与拒绝
+
+新页签和新列表/候选/导出接口都读取实际 `app_runtime_enabled`;关闭时后端给部署开关错误,不把空列表当安全机制。旧系统操作审计照常可用。开关语义变更时再议。
+
+### D9:下线应用仍可进广场
+
+广场对托管应用白名单保持 online/stopped,不带草稿/待上线/删除;历史日志则不能以这组存活状态过滤,否则删除后记录消失。是否显示已删除资源是两个读面的不同合同。
+
+### D10:广场名称搜索与日志候选分开
+
+不拿可见应用广场列表当审计候选;它缺已删除资源、范围也不同。日志名称搜索基于受限审计记录/可信历史映射,只在当前管理员 scope 生效。若统一搜索服务支持历史治理数据再替换。
+
+### D11:当前查询交付范围
+
+现有 `api/v1/audit.py` 与 `api/services/audit_log.py` 已有系统操作审计、target_app_id 与导出;LogPage 仍只有“应用使用/系统操作”两页签。本轮加第三页签与三子视图,不重做旧审计或重写广场。真正已落地必须以实际调用点而非 tasks 计数判断。
+
+### D12:三专表适配成管理员读服务(新增)
+
+否决运行期高频记录塞普通 AuditLog、查日志文本推导、另造统一大表/回写历史。选定 F056 的 `RuntimeAuditQueryService` 调三个 Repository 适配器:现有 ModelCallRecordRepository(F051 写)、AppCapabilityCallRecord(F055 写)、AppAccessLog(F054 写)。现有模型读库有 bypass+手动 tenant 模式,新入口改用明确 `AuthorizedRuntimeAuditScope`,旧调用兼容由 owner 控制,不向客户端暴露 `tenant_id=None` 作为全局授权。
+
+共享过滤/分页/导出规格,但不同视图字段保留语义;服务账号模型调用不强制 inner join App。证据是现有三种表字段不一致,只有模型表已有 cursor/export,能力表只有 insert,访问表已有旧 page DAO。新建读 Repository,不加新 DAO 入口或让 Service 写 SQL。未来跨类型关联检索成为真实需求才考虑物化统一索引。
+
+### D13:稳定列表与一次导出的固定读取视图(修订)
+
+否决仅按秒级时间排序、offset 翻页、开放式追随新日志的无限导出,以及把 10,000 行/90 天预算当产品限制。列表采用 `(event_time DESC,id DESC)` keyset,首次查询固定 filter/scope 摘要、upper_time 与当前可见最大 id 的 watermark,cursor 绑定该选择条件;未知时间单独排序并以 id 决胜,不能在 NULL 比较上丢页;all 保留未知时间行,recent7d/custom 不能判入区间的未知时间行不伪造时间,筛选语义明确说明。新请求严格校验 cursor,不把解码失败当第一页。
+
+列表只保存短时 `RuntimeAuditQuerySnapshot` **条件元数据**并返回第一页;不为了列表先收集所有 ID,不依赖 MinIO 才能看数据。刷新/改变条件重新选择范围。列表与随后导出共享冻结的条件、watermark 和权限;并发写入下不额外承诺两个不同读取时点的数据库成员集合完全相等。导出会展示其实际选取时点及最终行数,不把导出完成数冒充此前列表的即时统计。
+
+**完整导出在后台的一次受权读取视图中完成**:任务受理时冻结 QuerySnapshot 的过滤/范围,source-owner Repository 在 worker 开始扫描时打开一条、范围有界的 ORM SELECT 只读一致游标,按稳定顺序流式输出全部匹配记录。该语句的读视图固定、watermark 固定;后续新增/回填提交不能把本次扫描变成无限流。每批最多 500 行处理/输出,但一直扫描至该读取视图 EOF,不能达到某行数后拒绝或截断。无资格/跨租户条件在源 SELECT 构造/执行时仍走自动租户过滤或显式授权的全局 Repository,而不是把多租户约束留给 CSV 写入端。
+
+source Repository 提供 `stream_export(scope, frozen_filter, watermark, batch_size)`,持有只读 source session/游标至本 attempt 扫描结束;任务进度写入用另一短事务 session,不在源读取事务中写状态。底层必须采用真正流式/受限 fetchmany,不用 `all()/fetchall()` 把整表搬进内存;MySQL/DM8 由各 source-owner 适配其固定读视图游标/事务,定向检查驱动的实际缓冲和语句范围,不能仅设置 yield_per 就宣称有界。占用一个后台读连接/读视图至扫描结束是此方案的显式成本,以导出 worker 并发、可观测持续时间及运维取消控制,不在 HTTP 请求保持长事务,也不转成 90 天/行数硬门槛。
+
+worker 崩溃后无法恢复数据库游标读视图,因此未 ready 的 attempt **整次只读重建**,不拿旧游标偏移接新视图造成漏/重;同一冻结条件与 watermark 下重新开启读视图,显示新的 selection_started_at。旧 attempt 从未交付字节,只保留待清理私有分块;只有一次完整扫描的 manifest 才能原子发布为 ready。ready 后的文件成员与字节固定,重复下载不会重新查事实表。若历史清理/读取故障导致不能完整输出,显式失败,不标空文件或部分文件成功。
+
+### D14:受权异步导出与不可变分块产物(修订)
+
+否决公开 presigned URL、前端只导当前页、提交时授权永久放行。选用后台固定读取视图 → 标准 CSV → MinIO 私有不可变分块 + 有序 manifest;backend 认证下载按 manifest 依次流式拼接为一个 CSV,不暴露对象 URL。CSV 标题/BOM 只在首块,每个分块在完整行边界收束。任务状态为 pending/running/ready/failed/expired;总记录数不设产品上限。
+
+任务通过 DB 租约/epoch 取得单一输出权;对象 key 绑定 job/attempt/epoch/part_no,上传确认后短事务登记分块及累计行数。ready 的 CAS 必须同时校验当前 lease/epoch、同一 source read-view attempt 已 EOF、全部分块已确认且 manifest 有序/完整。旧 worker 只能遗留未引用的私有对象,不能覆盖新 attempt 或把任务标 ready;响应丢失重查持久 ready/manifest,不能追加重复 CSV 行。失败或重启按 D13 从头读取新 attempt,不拼接不同读视图的分块。
+
+受理、worker 开始/每批和 ready 发布、状态读取、下载开始/受限块之间都核验当前自然人管理员资格与保存的 scope。失权或无法确认即取消交付/停止任务,不使用提交时缓存永久授权;已交付字节无法收回。条件快照未被任务引用时 10 分钟过期;任务受理后其冻结条件由任务持有,不因排队/扫描超过 10 分钟被清理。**下载保留期从 ready_at 开始计算 10 分钟**,未完成 attempt 由租约/取消和终态 GC 管理,不能用该保留期限制记录总量或扫描时长。
+
+CSV 仅白名单字段;危险公式文本前置单引号后按标准 CSV 转义,测试空白/控制字符绕过、逗号、引号、换行与 Unicode;数值按数值,null 不替成其他主体。对象/worker/source 不可用时明确失败,GC 只删没有活跃 lease/ready manifest 引用的本任务对象;就绪产物到期/取消清理有 durable 状态,不依赖本地盘。未来确需更高吞吐再独立调度,不改变完整导出的规格。
+
+### D15:可信应用关联的操作审计(新增)
+
+F055 负责写 app.secret.*;凭据 owner 在签发/撤销时从持久 `ApiCredential.subject_kind/subject_id` 与可信 hosted-app 归属映射计算 target_app_id,不取查看者传的 app_id;SAK/PAT 的资源 owner 不等于 app 归属,不能把该人的所有 key 自动关联给某应用。
+
+现有 CredentialService._audit 只写 credential_id/subject/scopes/key_mask,需要补可信关联。补充实际生产者:正式切换和候选终结当前在 RuntimeProjectionService 中将 binding 从 staged/active 改为 revoked,并不经过 _audit。改由凭据 Owner 的公开 `revoke_runtime_binding(session, binding, operation, reason)` 协议,在调用者原事务中锁定 binding、只在真实状态改变时撤销并追加 `open_api.api_key.revoke` 审计。应用关联从绑定的 app/credential 与 hosted-app 主体映射交叉核验获得,不接受查询者提供的归属;审计带 app_id、credential_id、operation_id、reason,事件不带业务环境变量值。以同一 binding 的状态转移作为去重围栏,重复切换/终结不重复记事件;失败回滚状态与审计,不新增审计补偿服务或独立数据库提交。切换撤旧、停止/删除、失败候选清理都调用该协议;签发使用已新增的显式 session 入口,签发审计也带可信 app 关联。
+
+历史记录只能在原始可信字段足以确定时通过只读关联查询;不补写历史“现在属于”。不增加第三方业务密钥末位/长度,API key 原有前缀/末位 mask 继续保留。若普通凭据没有可信 app 绑定,明确不相关,不硬凑。后续新 credential owner 需提供同一审计关联协议。
+
+## 4. 系统现状(接手必读)
+
+### 4.1 数据流与实现状态
+
+**已实现**:广场第三类型、两处权限 UI、可见范围审计、系统操作日志查询与应用筛选;模型表/能力表/访问表真实写入路径。**本轮已接线(2026-10-24,本地定向验证)**:管理员 RuntimeAuditQueryService、v1 查询/候选/异步导出、第三页签、可信凭据关联及实际 binding 回收审计、访问名称快照。真实环境验收与最终 L2 仍待完成。
+
+```mermaid
+flowchart LR
+  UI[LogPage / Application runtime] --> API[F056 session API]
+  API --> Scope[server verified admin scope]
+  Scope --> Query[query service / typed filters]
+  Query --> M[ModelCallRecord repository]
+  Query --> C[Capability record repository]
+  Query --> A[Access record repository]
+  Query --> Snapshot[frozen filters and watermark]
+  Snapshot --> Job[authorized worker: stream one fixed read view]
+  Job --> M
+  Job --> C
+  Job --> A
+  Job --> Store[immutable CSV chunks and manifest]
+  Store --> Download[backend authenticated download]
+  Download --> Check[current role and scope check]
+```
+
+### 4.2 既有公共合同(保留原广场/审计语义)
+
+| 字段 / 入口 | 当前约定 |
+|---|---|
+| 广场列表 `flow_type/slug/app_state/can_share/user_name` | 托管类型 35;slug 为稳定 `/apps/{slug}` 入口;应用态仅 online/stopped;can_share=false;owner 显示沿用既有列表投影 |
+| GET `/api/v1/chat/online` | 现有 page/keyword/tag/type 等筛选;服务端收窄 app 状态和 use 动作,不信客户端 action 扩权 |
+| PermissionDialog | resourceType=app,resourceId=内部 app.id;两入口共用,protected owner grant 不算额外放行 |
+| app.visibility_change | 变更人在真实授权 mutation 后回调记录;app/slug/added/removed 元数据;不由查询写入 |
+| 事件登记 | 后端可见 action/namespace、领域 action、前端 log.ts、三语事件名保持 lockstep |
+| 现有系统操作审计 | `/api/v1/audit` 和 `/audit/export/data` 已支持 target_app_id/tenant 筛选;不因新增 runtime 读面改变旧菜单/管理范围语义 |
+
+### 4.3 关键模块职责
+
+| 模块 / 新增落点 | 做什么 | 不做什么 |
+|---|---|---|
+| `app_governance` F056 读侧(按既有领域目录落点核对后实施)Endpoint/Service/Repository | 受权过滤、分页、候选、snapshot/export | 不重新写模型/能力/访问事件,不查 OpenFGA 原始 client |
+| 三个 source-owner Repository 读适配协议 | 返回白名单列表页与固定读视图流式 export;持有源只读 session,保持事件语义 | 不从通用 Service 跨层 ORM 查询,不 fetchall,不在源事务写任务状态 |
+| `open_api/.../credential_service.py:_audit` 与其可信 app resolver | 签发/撤销时写 app 关联 | 不由 viewer 参数、owner 人员关系猜 app |
+| `app_runtime/.../app_access_log_service.py` | 新事件可选 app 名称快照,合并窗口不变 | 不回填历史事实、不记录拒绝为成功 |
+| `pages/LogPage/runtime/`、API/hook(新增) | 三视图、filters/table/export、错误和过期状态 | 不含秘密列,不读凭据明文接口 |
+| `AuditLogService` 与现有 systemLog | 查 app.secret 与可信凭据事件 | 不把 runtime 高频事件塞操作审计 |
+
+实施前确认 F056 现有代码归属:当前主要在 `api/services/audit_log.py` 与通用列表模块,新增读服务**拟建立** `bisheng/app_governance/{api,domain}`,仅管理本 Feature 读侧,不迁移旧广场/权限代码,不重构通用审计。名称不代表新 feature/domain owner;release-contract 仍由 F056 认领。
+
+### 4.4 本轮增量字段与接口合同(拟实现)
+
+**① 拟新增会话 API**:挂 F056 路由 `/api/v1/app-runtime-audit`;读取者必须通过平台 session dependency,不能走 `/api/v2`。
+
+| 接口 | 参数 / 响应 | 校验 |
+|---|---|---|
+| GET `/records/{kind}` | kind=model/capability/access;time_range=recent7d/custom/all(默认 recent7d);custom 提供 start_time/end_time;app_id/visitor_id,model 额外 credential_id;tenant_id 只缩小 scope;cursor/limit;响应 `{items,next_cursor,snapshot_id,matched_count}` | custom 范围无 90 天上限;all 对已留存时间不加下界;非法/不适用筛选明确拒绝。统计仅在受权范围,尚未计算可为 null,不用 10001 冒充总量;无因记录数而不可导出的标志 |
+| GET `/candidates/{kind}` | app/visitor/credential,按当前 kind/time/scope 的历史事实给 `{id,label,deleted_or_revoked}` 候选,分页/搜索有界 | 同查询管理员门禁;credential 只给 mask,无私密 name/value;跨租户对象请求拒绝 |
+| POST `/exports` | `{snapshot_id,request_id}` → `{export_id,status}` | 条件快照属于同一调用者/当前合法 scope、未过期,复制条件到任务;不因行数/历史跨度拒绝;幂等 request_id |
+| GET `/exports/{id}` | status + safe_reason、attempt、selection_started_at、processed_rows、ready_at/expires_at、ready 后 final_count | 重验资格与 scope;processed_rows 是当前 attempt 进度而非精确总量,重试从 0 开始,不报告部分成功 |
+| GET `/exports/{id}/download` | 认证后返回 CSV 流;Cache-Control: no-store | 不返回公开 object key/URL,失权/跨 scope 拒绝;下载开始与每个受限 chunk 之间重验,取消后停止继续交付 |
+
+列表/候选只读,下载一次不新增访问事实;业务错误遵循现有 envelope,前端使用包裹 request 的 rejected Promise 处理。新增错误码从 163 段核对占用后分配并同步三语,不重用历史退休编号。
+
+**② 三视图字段**:event_time 为查询统一排序别名,原始字段语义不变。
+
+| kind / 来源 | 响应白名单 | 历史缺项 |
+|---|---|---|
+| model / `ModelCallRecord` | id、create_time、app_id、actor_kind/id/name、subject_kind/id、credential_id/mask、model_id/name、prompt/completion/total_tokens、result/error_code/http_status、latency/ttft、request_id;列表另有可空 subject_name 显示投影 | app_id 可空(服务账号调用);subject 未知不冒充 owner;Token 空不填 0;无当时 app 名只显示 ID |
+| capability / `AppCapabilityCallRecord` | id、create_time、app_id/app_name、credential_id、subject_kind/subject_user_id、capability、result/error_code、chunk_count、latency_ms、version_id;列表另有可空 subject_name/credential_mask 显示投影 | credential mask 仅经同租户受限凭据投影能找到时补;无 actor 快照只表达 app 这个 actor,不虚构用户 |
+| access / `AppAccessLog` | id、entry_time、app_id、app_name_snapshot(新增可空)、user_id/user_name、request_id | 老行 app_name_snapshot 空显示 ID;访问为 allow 合并事件,不显示为 HTTP 成功率/逐请求响应 |
+
+不输出 model prompt/completion、检索 chunks/正文/targets 原始 payload、secret/env/cookies/OBO/token/任意 metadata。仅为新 access 记录新增 app_name_snapshot(128 字符 nullable)与写入参数,不重写合并窗口;历史不从当前名称回填。模型表已有 actor_name/model_name 快照,能力表已有 app_name;不为方便前端用 mandatory live join 丢掉已删除/撤销记录。
+
+**2026-11-02 名称展示纠正**:列表按已有 actor_name、user_name 和密钥掩码呈现,合并重复的
+数字标识列,原始标识保留在悬停提示及 API/CSV。执行用户只从事实中的有类型身份解析:
+同一自然人/服务账号作为 caller 与 subject 时优先使用该行 actor_name 快照;其他自然人
+subject 通过全局唯一 user_id 做可空左连接,只显示当前 user_name,页面明确说明当前名称
+与记录快照的区别。app_self 明示“应用自身”,不借 owner 或 caller 的数字 ID 猜测自然人。
+capability 凭据仅按同租户 id 左连接读取 prefix/last4 后生成掩码,不读取或返回凭据私密
+name/token_hash。缺失、删除、撤销对象不使来源行消失;候选仍只从已授权历史中产生。
+这些投影只用于列表/自然人候选,不写回事实表;导出继续按原 FIELDS 白名单和单一源读视图,
+不增加展示列或补造历史名称、人数、用量、结果条数与耗时。
+
+模型、能力导出弹窗说明空值:app_self 的 subject_id 为 NULL;非流式或尚未测得首 Token
+时 ttft_ms 为 NULL;未返回 usage 的 Token 数不填 0;成功调用可以没有 error_code,未写入
+的 http_status 也保持空。能力被拒绝、无结果对象时 chunk_count 为 NULL;实际零条必须
+保留 0。普通 NULL 显示“未记录”,拒绝且无 count 显示“未产生结果”。
+
+**③ QuerySnapshot / RuntimeAuditExport(拟新增)**:仍为两个申请者私有实体,不新增永久审计大表或全量 ID 选集副本。`RuntimeAuditQuerySnapshot` 保存 UUID、tenant_id(默认 None/NOT NULL/索引)、creator_user_id、scope_mode/scope_tenant_id、kind、normalized_filter JsonType、filter_digest、watermark、created_at/expires_at;只含条件,不含事实正文或完整 ID 集。tenant_id 取服务端确认的申请者有效工作租户,global 是经超管核验的过滤范围,不以 NULL tenant 绕过自动过滤。
+
+`RuntimeAuditExport` 保存同样的申请者/租户/scope、复制的冻结条件和 watermark、snapshot_id、request_id、status、attempt/selection_started_at、lease_holder/epoch/until、processed_rows、final_count、manifest_object_key/digest、ready_at/download_expires_at、safe_failure;唯一 `(creator_user_id,request_id)`。分块对象包含 part_no/行数/字节数/hash,由最终不可变 manifest 收束;任务进度是可重建状态,不需要另建一张事实记录表。对象 key 全部服务端生成,客户端不能指定。
+
+worker 按持久任务 tenant_execution_scope 初始化租户过滤,只有源数据读取在已授权的 global source Repository 中 bypass;后台每批重验申请者当前资格,不能只信任务里旧角色。源只读扫描与任务短写事务分开;领取/重试/ready/GC 以数据库 CAS 和租约处理多进程。未引用条件快照 10 分钟清理,受理后由 job 保留冻结条件,下载到期从 ready 算起;失败/取消且无活跃 lease 的孤儿块可回收,活跃扫描不删。
+
+**④ Scope / Cursor 安全**:
+
+- `AuthorizedRuntimeAuditScope` 仅由服务端权限适配生成;自然人角色来自可信登录态与权限 application 协议当前核验,不信请求角色/租户。旧审计允许 log-menu 的逻辑不复用到此处。
+- 普通租户 Repo 在 tenant_execution_scope 下自动过滤;全局超管 Repo 仅在明确授权 scope 下 bypass;客户端 tenant_id 只允许进一步收窄。跨租户 app/visitor/credential ID(含历史 tombstone)在限权解析器中验证,拒绝与未知 ID 对外同样不泄漏详情,不把 foreign ID 默默当“查无”。候选从事实表/可信删除保留信息产生,不能凭当前用户/应用列表扩范围。
+- cursor 是服务端签名/摘要校验的 `{version,kind,scope_digest,filter_digest,snapshot_id,last_event_time,last_id,expires_at}`,不包含明文凭据。切租户/视图/筛选不能重用;解码/签名/过期错明确拒绝而非回第一页。
+- 每次 list/candidates/export/status/download 都重验角色/范围;后台任务实际执行时再验提交者资格。无法确认权限 fail closed;不是仅靠 JWT 缓存角色永久允许。已交付字节不可收回,失权后不开始新下载或继续交付后续 chunk;不承诺撤回浏览器已收到的文件。
+
+
+## 5. 已知坑 / 反直觉事实
+
+| 坑 | 后果 | 处理位置 |
+|---|---|---|
+| 旧 AuditLogService 允许 log 菜单/部门管理者 | 直接复用会放大新 runtime 数据权限 | 新 Scope resolver |
+| ModelCallRecordRepository tenant_id=None 是全局 bypass | 漏传租户变成全局数据泄漏 | typed scope + 专用普通/全局 Repo 适配 |
+| 同秒 create_time 常相同,旧 cursor 错误会被忽略 | 翻页重复/漏行/跳回首页 | 严格签名 cursor + time,id 全序 |
+| 写入有 write-behind,event_time 不等于提交时间 | 列表与随后导出不同读时点不一定同成员;恢复旧游标会混读视图 | 固定条件/watermark,导出单一读视图,崩溃 attempt 从头只读重建,ready 才发布 |
+| live app/credential 内连接会丢删除/撤销行 | 历史审计证据消失 | 以事实为主,可选显示投影/未知 |
+| SAK 资源 owner 是人,不代表那人的某个 app | app 筛选错误归因其他 key 的行为 | credential owner 可信 subject_ref 解析 |
+| 下载公开 MinIO URL 可绕过后来失权 | 撤权后继续新下载 | 认证代理下载/按块重验 |
+| CSV 双引号不是公式防护 | 表格软件执行攻击字段 | 类型白名单 + 首有效危险字符处理 |
+| MySQL INSERT 不回传服务器默认时间字段,普通 SQLite 默认会回传 | 同事务签发后构建凭据 DTO 触发隐式异步 IO / MissingGreenlet,审批执行失败 | CredentialRepository.create_in_transaction 在 flush 后显式 await refresh,不提交事务;定向测试禁用 INSERT RETURNING 并验证 rollback |
+| HTTP 失败不是空结果 | 用户误以为无人访问/导出成功 | 独立 error/loading/empty 状态 |
+| 应用 allow 访问按窗口合并 | 错读为 PV/逐次网络请求 | UI 字段提示与 F054 同语义 |
+| 调用方、执行主体和资源 owner 是不同身份;单独显示内部数字 ID 容易误读 | 给 app_self 填 owner、或用 hosted_app 的数值 ID 查询 User 会伪造访问者;删除对象的内连接也会漏掉审计行 | `RuntimeAuditRepository.with_display_labels/project` 按类型做可空投影;`RuntimeAuditCell` 明示应用自身并保留原标识 |
+| NULL、真实 0 与导出缺列含义不同 | 将拒绝检索的 NULL count 或上游未返回的 usage 填成 0,会制造原记录不存在的数据 | 原事实和 CSV 字段保持;界面区分未记录/未产生结果,导出弹窗解释适用条件;数据库与 CSV 定向一致性验证 |
+
+历史坑编号见 `dda16a6` 本文件;旧 300/1800 秒勘误不覆盖当前代码配置,本轮不改窗口。
+
+## 6. 对外契约与依赖
+
+### 6.1 我提供给别人的(Outgoing)
+
+| 契约 | 消费者 | 风险 |
+|---|---|---|
+| 现有广场/授权交互与操作审计 | client/platform、管理员 | 不改变卡片状态与既有日志范围 |
+| 三运行视图列表/候选/snapshot/export 会话 API | LogPage 新第三页签 | runtime 关闭/失权返回拒绝,不假空 |
+| 按 app 查 secret 变更/可信凭据事件 | 系统操作日志页 | 审计登记四处 lockstep 与三语必须齐 |
+| 白名单响应与 CSV schema version=1 | UI/管理员导出工具 | 字段语义与缺失值约定不可悄改 |
+
+### 6.2 我依赖别人的(Incoming)
+
+| 依赖 | 形式 | 风险 |
+|---|---|---|
+| F051 ModelCallRecord、F055 capability record、F054 access log | Repository/Python 数据契约 | 写方列名/追加策略/删除留存改变会破坏快照 |
+| F048 可信身份/权限 application,F019 admin-scope | 当前会话+Python | 原始请求 tenant/admin 标志不可当授权 |
+| 平台 DB、私有 MinIO、Celery/既有周期清理 | 后台任务/对象 | 不能只存在 writer 本地盘;过期必须回收自有对象 |
+| F055 secret 审计与 credential owner 的可信关联 | 元数据 | 不读/存密钥明文;关联不足就未知 |
+| 现有 LogPage/Tabs 与共享 UI/包裹 request/locales | TS | 不新增库/样式,Select 未 done 时跟随现存筛选实现 |
+
+## 7. 测试与可观测
+
+- 数据/权限定向:三表正常与空 app_id、null Token/subject;同秒/跨页/晚到写入/删除快照失效;租户 admin/超管 scoped 与 unscoped 允许,owner/log 菜单/部门 admin/审批人/SAK/PAT/foreign filter 拒绝;候选、导出、下载同样配对,角色变化后失败。
+- 导出定向:超过 10,000 行、超过 90 天和全部已留存时间都完整导出;同一成功读取视图的结果和文件完全一致,后台新增/回填不把扫描变无限流;进度批次/内存有界;重复 request_id 不创建重复任务。源扫描中崩溃从头新 attempt、不拼接混合读视图;ready 提交丢响应不重复追加;旧 epoch 不发布,GC 不删活跃对象。权限在提交/执行/下载/分块间撤销;MinIO/source 失败不交付部分文件;下载 TTL 从 ready 起;CSV 注入/Unicode/引号/换行负向验证。
+- 前端:保留两个旧页签,新第三页签里三子视图;选择器无空 ID;filter/kind/tenant 改变 reset cursor,query key 含 scope/type/filter,AbortController/请求序号丢弃旧结果;新错误态与空态分离;导出绑定当前 snapshot 并清楚显示范围/行数,失败结束等待、不显示“已下载”。
+- UI 取当前共享 docs 的 Input/Button/Tabs 等 status=done 组件,表格/筛选沿用当前 `LogPage/systemLog` 及其选择器模式。filter 在窄屏换行、表格横向滚动,不引入新视觉模式/库;多端、字体/色彩按当前规范,三语同交付,不手改生成的 api_errors。
+- E2E 走新会话 API + 实际 LogPage 人工清单:授权测试租户 A/B、tenant admin、global super、ordinary owner;产生模型/能力/allow 记录,按同一受权条件查/导,明确显示实际导出时点/最终数;大于 10,000 行和 90 天范围不被硬拒绝;删除测试 app/撤销 key 后仍查历史;验证 secret 审计没值、无 app 的 SA 模型记录不消失。runtime 关闭新面拒绝,旧系统操作仍可查。
+- 编码后定向命令:`cd src/backend && uv run pytest test/app_governance/test_runtime_audit_query.py test/app_governance/test_runtime_audit_export.py`(拟新增);只跑 `LogPage/runtime` 对应 Jest 文件。复用旧 audit 测试中修改路径的最小集合,不全量重跑;必要 Ruff/arch-guard/迁移 head 单独验证。DM8 仅静态。
+- 指标/日志:query kind、规范筛选摘要、scope、actor、行数、导出状态/过期/失败码;不记原始秘密/URL token/body。查询不伪造模型/能力/访问业务事实。
+
+## 8. 后续改进 / 不打算做的事
+
+不补造历史 app 名/subject/凭据关联;不建立日志仓库或计费模型;只实现当前审计条件下的完整异步导出,以批次/并发/租约控制资源,不设置 90 天或 10,000 行产品门槛。长导出的一致读视图占用和驱动缓冲必须在定向检查中量化,不以本地临时文件绕过多节点。原 ARM/跨节点/正式镜像发行验收仍保留,不宣称新列表测试证明这些能力。
+
+## 修订历史
+
+| 日期 | 变化 | 依据 |
+|---|---|---|
+| 2026-11-02 | 审计名称与内部标识分开展示,增加只读 subject_name/同租户凭据掩码投影;CSV 保持原始值并解释适用性空值;同步 F114 现行登录态/单租户测试夹具 | 用户四项排查修复要求;116 原始记录和现行导出序列化只读核对,详见 runtime-audit-display-verification-20261102.md |
+| 2026-10-23(二次修订) | 去除导出总量/时间硬限制;条件元数据与后台单一读取视图分离;私有分块 ready 原子发布、重启从头只读重建 | 用户要求修订并复审;规格 AC 未变,尚未编码 |
+| 2026-10-23 | 刷新已实现广场/操作审计;新增 D12–D15、三视图/稳定导出/权限合同 | 用户确认规格,静态代码核查;未实现/部署 |
+| 2026-08-17 起 | 早期广场与治理设计 | 旧正文见 `dda16a6`;历史任务证据不改写 |
+
+### 2026-10-24 剩余设计问题复审
+
+F056-S1 修订:运行绑定的真实撤销生产者由凭据 Owner 公共协议消费,同 session 状态转移与审计,以状态 CAS/行锁去重;查询层不能补造历史事实。保留列表/异步导出的主链路,低概率的外部存储瞬断与 worker 任意指令点崩溃组合列为后续故障验收;权限失效、跨租户拒绝、CSV 安全和完整导出仍属于主功能,不因缩减测试而放宽。实现尚未开始,仍待单独确认本修订设计。
+
+
+### 2026-10-24 实现状态与交付合同补记
+
+- 新读域落 `bisheng/app_governance/{api,domain}`;三个事实表以白名单列读取,不 live join App,不改模型/能力的业务写入者。列表快照只保存条件,`scope_tenant_id=None` 表示已经核验的 global,申请者 tenant_id 保持非空;未额外存冗余 scope_mode。
+- `runtime_audit_source_timezone` 默认使用 `BISHENG_RUNTIME_AUDIT_SOURCE_TIMEZONE`,空时取进程 TZ,再取 UTC。它必须与事实表无时区时间的既有写入约定一致;查询条件/输出规范化 UTC,源时区冻结进快照。不补写历史时间或用户身份。
+- 导出任务在默认 Celery 队列注册,Beat 每 30 秒恢复过期租约并 GC。每次新 attempt 从源 SELECT 开头重新流式扫描,独立进度事务;已确认上传的不可变分块收束为 EOF manifest,ready CAS 受 epoch/holder/租约约束。申请工作租户的短事务领取限制并发;实际跨进程资源负载仍待部署测量。
+- 下载保留期从 ready 算 10 分钟,backend 认证代理下载,验证 manifest/hash/每块、设置固定 Content-Length,拒绝把中断当完整文件;每块前再核验当前身份/管理视图。新增 `POST /exports/{id}/cancel` 供用户和运维中止,GC 完成状态持久记录,终态快照可独立清理。
+- 管理视图通过 `admin.application.tenant_scope.current_admin_scope` 重新读取,worker 不依赖 HTTP middleware 的上下文或失败回退;不改变旧操作审计的权限语义。
+- UI 使用现有 LogPage 第三页签、三子视图、稳定翻页、历史候选搜索/分页和异步导出;切类别/租户隔离状态。菜单与原两个页签保留,错误码 16301–16304 和文案三语同提交。`LogPage` 改为命名导出,原惰性路由通过 then adapter 接入。
+- SQLite 本地定向结果不代替 MySQL/DM8 驱动或真实 MinIO/worker/FGA 验收。DM8 按用户口径仅检查模型/DDL 静态兼容。提测与低概率组合清单见 closeout-validation.md。
+
+2026-10-25 部署热修:运行凭据事务写入后显式加载服务器默认字段,修复 MySQL 上 KeyItem 读取 create_time 的 MissingGreenlet;不改变授权、API 或事务提交边界。
+
+### 2026-10-29 运行记录时间显示修复
+
+源日志按既有 source_timezone 转 UTC 的查询/导出合同保持。平台表格将 create_time/entry_time 和导出选取时间等带时区时间转换为浏览器本地时间显示,复用 displayTimestamp 格式化函数;不直接显示 UTC 原文,不对无时区时间擅自加减八小时。116 宿主与 backend 为 UTC+8,未调整服务器时间或改写历史记录。
diff --git a/features/v3.0.0/056-app-square-governance/runtime-audit-display-verification-20261102.md b/features/v3.0.0/056-app-square-governance/runtime-audit-display-verification-20261102.md
new file mode 100644
index 0000000000..5d9f762f65
--- /dev/null
+++ b/features/v3.0.0/056-app-square-governance/runtime-audit-display-verification-20261102.md
@@ -0,0 +1,91 @@
+# 运行记录名称与导出空值:排查及修复
+
+日期:2026-11-02。分支:`codex/runtime-audit-display-20261102`。
+基线:最新远端 `feat/3.0.0` 的 `25e50eceeeb71989257e5ff38e35f5e011e59bae`。
+按 F056 既有审计字段语义修正显示,权限资格及具体租户范围沿用 F114;不新增权限动作、表或迁移。
+
+## 1. 四项结论
+
+| 用户问题 | 116 取证 | 处理 |
+|---|---|---|
+| 访问用户显示 ID | 访问记录 56、58 的 user_id=3,user_name 已正确保存为 admin;页面另列 user_id,形成重复且难读的展示 | 移除重复数字列,显示记录中的访问用户名称,原标识放在悬停提示;API/CSV 仍保留 user_id |
+| 能力导出 chunk_count 为空 | 截图显示拒绝码 10903(知识库嵌入模型不可用);对应应用当前 17 条原始记录的 chunk_count 全为 SQL NULL。调用未获得结果对象时写入者明确保存 NULL;该租户另外两条成功能力记录都有非空 count | 未发现导出丢字段,不补 0;拒绝且 count 为空显示“未产生结果”,导出弹窗解释适用性 |
+| 模型调用方/执行用户/密钥是数字 | actor_id=10 是托管应用主体的内部标识,行内已有 actor_name;credential_id=143 已有 credential_mask;截图这组 subject_kind=app_self,subject_id 本来为空 | 列表使用调用方快照名称、真实执行身份和密钥掩码,保留原 ID 追溯;app_self 明示“应用自身”,不把 owner 当作访问用户 |
+| 模型 CSV 空字段 | 当前该应用 30 行中 subject_id、ttft_ms 各 30 个 NULL;22 条上游失败,三个 Token 字段各 22 个 NULL;8 条成功,有用量,error_code/http_status 各 8 个 NULL。与现场 CSV 序列化逐字段一致 | 这些空值与原始事实一致,当前路径未发现导出遗漏;具体原因如下。原下载物已回收,未宣称完成原文件逐字节复核 |
+
+### 模型空值的具体原因
+
+- `subject_id`:全部为 app_self 调用,没有自然人执行主体。原始 NULL 保留,不能填资源 owner。
+- `ttft_ms`:30 行的 `is_stream=0`;非流式调用没有流式首 Token 耗时。
+- `prompt_tokens / completion_tokens / total_tokens`:22 条 `upstream_failed` 没有返回 usage,保持 NULL;8 条成功记录有实际用量。
+- `error_code`:8 条成功调用未发生错误,空值正常。
+- `http_status`:现有写入者在错误时记录状态;这 8 条成功记录未写该列。导出保留原值,不反推或回填 200。
+
+能力写入链:`CapabilityBusService._audit` → `capability_audit.record_retrieval`。
+有结果对象才记录 `result.total`;没有结果对象则为 None。现行 `csv_bytes` 对 None 输出空单元格,
+对数值 0 输出 `0`,不能把两者合并成同一种情况。
+
+## 2. 证据范围与限制
+
+- 在 116 只读查询 `model_call_record`、`app_capability_call_record`、`app_access_log`、导出任务和凭据展示元数据;未读取密钥明文、token hash、对话正文或检索正文。
+- 调用 116 现行 `csv_bytes` 对白名单原始字段序列化并逐字段比较:模型 30 行、能力 17 行,所检查 nullable/数值字段均为 **0 个不一致**。
+- 截图对应导出任务的 final_count 分别为模型 30、能力 7;状态均为 expired,gc_completed_at 有值,读取原 manifest 返回 `NoSuchKey`。因此不能再直接打开用户当时下载的原文件验证全部字节。
+- 不创建新导出、不修改任务状态、不补写历史身份、count、Token 或时间,不更新 116 服务。
+
+## 3. 实现边界
+
+### 后端
+
+`RuntimeAuditRepository.with_display_labels` 只增强列表及自然人候选:
+
+- 通过事实中有类型的 subject ID 左连接用户,只读当前 user_name;全局唯一 user_id 的读取范围严格来自已授权审计事实,不新增人员目录入口。
+- 同一自然人/服务账号同时作为 caller 与 subject 时,优先使用该行 actor_name 快照;数字相同但类型不同不能互相冒充。
+- 能力密钥按 credential ID 和源记录 tenant 同时匹配,只取 prefix/last4 合成掩码;不返回凭据私密名称或散列。
+- 缺失或删除资料只导致显示投影为空,不丢原始行,不扩展租户或应用范围。
+- 导出继续使用原始 `FIELDS` 和原单一源 SELECT,不增加展示列,也不依赖实时名称表。
+
+### 前端
+
+- 将名称和重复数字列合并,原标识放入现有共享 Tooltip;不修改表格组件样式。
+- 执行身份明确区分应用自身、自然人和服务账号;缺少名称时显示带类型的标识说明。
+- NULL 显示“未记录”,拒绝且无检索结果显示“未产生结果”,真实 0 保持 0。
+- 模型/能力导出弹窗增加空值原因说明;中、英、日三语同时提供。
+- 页面说明记录名称与当前账号名称的区别,显示投影不冒充历史身份快照。
+
+## 4. 必要验证
+
+| 验证 | 结果 |
+|---|---|
+| Backend query/export | **27 passed**;同租户掩码、外租户拒绝投影、真实自然人候选、app_self 不冒充 owner、缺失对象保留、NULL/0 原样导出、稳定翻页、导出完整性/撤权/租约及公式防护 |
+| 补充主体分支 | 同一测试增加服务账号与直接自然人快照优先级,定向复跑 **1 passed**,不重复累加为新覆盖数 |
+| Platform Vitest | **16 passed**;运行列表/候选、名称展示、移除重复数字列、空值语义及导出交互;补充服务账号缺名说明后复跑对应两例通过 |
+| 前端 lint / i18n | **PASS**;新文案三语一致,无新增 i18n 漂移 |
+| TypeScript | 根 typecheck 被 Platform 23 项既有依赖/图标诊断阻断;与 25e50ecee 临时源码副本及相同依赖逐条比对一致,路径/位置规范化后 **无新增诊断** |
+| Ruff / arch-guard / diff-check | **PASS**;本次变更范围,无新增忽略、抑制或依赖 |
+| DM8 | 使用标准 ORM 左连接和可移植列运算;无 DDL。未声称完成 DM8 实库回归 |
+| 部署 E2E | **未执行**;116 保持原服务和数据,T060 待部署后执行 |
+
+原定向测试夹具仍使用字符串登录态、过期 resolver 签名和已退役全平台期望,部分测试会
+意外访问默认数据库。已改为 `UserPayload`、显式测试 tenant 及当前管理接口 mock,断言单一
+具体租户、错误 scope 拒绝和现行入口资格。生产 `runtime_audit_scope.py` 未改动。
+
+## 5. 复审与交付状态
+
+按 `task-review` 与 `sdd-code-review` 的分层、权限、并发、信息泄漏、测试、风格和设计同步
+检查:没有新增鉴权捷径或原始事实修改;显示投影为可空左连接,稳定标识、分页、CSV 和
+导出权限链保持;历史与当前名称明确区分,凭据只显示掩码。设计及任务日志已同步。
+
+2026-11-02 本地源码修复与定向验证完成,当时尚未提交、推送或部署;未运行全量测试。部署后仅需核对
+三类列表中的名称/掩码、悬停原始标识,以及专用记录的一次导出,不需要重放原始失败调用。
+
+## 6. 2026-11-03 授权交付与整合复验
+
+用户授权将截图对应修复提交并推送到 `feat/3.0.0`。来源提交为 `521e03639`,在最新远端 `29dc41f5cad0e89bc3e9e938d40b94e500568b52` 上普通合并;三份 locale JSON 自动合并,保留主线后续新增文案及此前 uploadable 性能修复,不更改主工作区配置。
+
+- 后端两个定向测试文件:27 passed。
+- 前端四个关联测试文件:12 + 4 = 16 passed;不运行全量测试。
+- 前端全工作区 lint、本轮文件 ESLint、i18n、后端 Ruff 和架构守卫通过。
+- 前端 typecheck 在 Platform 阶段因 24 项既有诊断退出;将最新主线 `29dc41f5c` 的独立源码快照与本次合并结果复用相同依赖检查,规范化路径后 24 项诊断逐条相同,无新增错误。该基线比 11-02 增加的一项为后续主线代码的图标诊断,不是本修复引入;不宣称类型门禁全绿或后续子项目检查已执行。
+- 无新增抑制、依赖、表、迁移或鉴权例外。原审查边界不变。
+
+提交和远端交付结果以 Git 历史及推送后 SHA 核对为准。本次未更新 116 服务,T060 继续待部署后验收。
diff --git a/features/v3.0.0/056-app-square-governance/spec.md b/features/v3.0.0/056-app-square-governance/spec.md
new file mode 100644
index 0000000000..f8abd12710
--- /dev/null
+++ b/features/v3.0.0/056-app-square-governance/spec.md
@@ -0,0 +1,257 @@
+# Feature: 应用广场接入与治理面(RT-02 广场 + GOV-01 授权交互 + GOV-04 审计 + GOV-07 界面通道 + 事件触达)
+
+> **当前后继合同(F114,2026-10-30,本地实现,未部署)**:管理列表/基础详情按任一管理动作,私有运行记录/导出按 edit;跨应用审计入口资格叠加一个具体租户范围。广场 use 和运行身份合同不变。 详见 [F114 已确认规格](../114-vibe-permission-actions/spec.md) 与 [验证记录](../114-vibe-permission-actions/verification.md)。下文保留原 Feature 的 AC 编号、当时口径及历史证据,不将历史通过记录当作 F114 部署证明。
+
+> **2026-10-23 增量规格已由用户确认**:“确认规格,进入设计”。本次确认针对新增 48 条、修订 4 条 AC 的三份规格;对应批准内容及 SHA-256 留存在[规格评审记录](../../../docs/reviews/vibe-governance-closeout-spec-review-2026-10-23.json)。设计须单独评审确认,尚未开始此次增量编码。
+
+> **本文档定位 — 纯 What(需求口径,不随代码漂移)**
+>
+> spec 只回答 **做什么 / 验收标准 / 不做什么**。所有 How(决策、数据流、字段、API、Service、前端、文件清单、性能指标)一律不写在这里,
+> How 的唯一真相在 [design.md](./design.md) 与 [tasks.md](./tasks.md)。
+
+**关联 PRD**: [docs/PRD/3.0-release/3.0 应用工场 PRD-1 专业开发者通道与应用运行时.md](../../../docs/PRD/3.0-release/3.0%20应用工场%20PRD-1%20专业开发者通道与应用运行时.md) **v2.0** §2.3(普通业务用户旅程)/ §3.0.1(承载面:造在 platform、用在 client)/ **§3.0.3 事件触达(全表)**/ **RT-02 应用广场**(验收 1–5)/ **GOV-01 权限资源类型与授权交互**(两个入口同一弹窗、验收 1–6、⚙️ 菜单裁剪注)/ **GOV-04 全链路审计**(事件清单表、审计查询面、规则、验收 1–4)/ **GOV-07 应用工场权限控制**(界面通道 `create_app` 复用、三个承载面、验收 1 / 4)/ §3.3 角色×动作矩阵与矩阵注 / §3.3 末「实现锚点与前置」表 GOV-01 · GOV-04 · GOV-07 各行 / §5.2 已取消项(GOV-09、草稿对管理员不可见、代调可见范围)
+**同步记录**: 2026-08-17 初稿(按 PRD-1 v2.0 + release-contract 表 3 F056 行 + MVP-114 纵切 §2 F056 行编写;§4 决议按全自动模式定案);同日按独立审查修订——**按「审计事件写入归动作 owner、F056 只拥有登记 / 查询面 / 导出 / 可查性验收」的跨 Feature 归属规则全文改写**(AC 编号全部保留、无作废;AC-17 / AC-18–21 / AC-23 / AC-24 / AC-34 / AC-41 / AC-42 改写为「可查 / 验收」口径,`app.*` 命名空间与记录字段口径移入 §4 决议-1 / 决议-7)
+**优先级**: P0
+**所属版本**: v3.0.0
+**依赖**: F054(`app` 资源类型注册、卡片 ⚙️ 菜单裁剪本身、构建页类型筛选、详情页四 tab 壳、统一入口与四类兜底页、工场运行时层开关、五个状态动作及其审计写入 AC-65、访问记录留痕 AC-38、数据行编辑 AC-56、元信息更新 AC-06)、F055(发布管线与上线、发布 tab 其余区块、预置审批流、能力声明;管线事件审计写入 AC-01、元信息随 deploy 更新 AC-05、审批类与待上线 / 因删除取消的触达 AC-31 / AC-35 / AC-64);审计面对密钥事件 / 模型调用逐条的可查性验收分别以 F049 AC-12 / F051 交付为前提
+**批次**: B(工场运行时层);**MVP-114 首波**见 [../mvp-114-path.md](../mvp-114-path.md) §2 F056 行(首波 = client 广场接入托管应用 + 卡片 ⚙️「管理权限」对 `app` 类型可用 + **发布面可见范围区(状态 + 设置按钮 + 「仅 owner 可见」提示)** + 发布 / 上线 / 可见范围变更三类审计事件在审计面可查;其余为纵切外)
+
+> **范围边界**
+>
+> 本 Feature 是批次 B 的「用」与「管」两面:让已上线托管应用被普通业务用户在 client 广场找到并打开(RT-02),让 owner 与租户管理员用平台既有授权件设可见范围(GOV-01 交互)、让全生命周期动作在既有审计页可查可导出(GOV-04)、验证界面通道零新增权限点(GOV-07)、验收 §3.0.3 事件触达全表并接线其中唯一未被 F055 覆盖的一处。它站在 F054(领域模型 / 类型注册 / 入口 / 详情页壳)与 F055(发布管线 / 发布 tab)之上,**不重复拥有它们交付的任何界面或对象**。
+>
+> **跨 Feature 归属规则(审计与触达,全文按此)**:审计事件的**写入**归事件发生的动作 owner——F054 状态动作(上线 / 待上线 / 手动上线 / 下线 / 重新上线 / 删除、元信息更新、数据行编辑、访问记录留痕);F055 管线(提交 / 首发导入 / 预检失败 / 扫描阻断 / 审批单生成与四个终态〔复用审批中心事件、携带应用标识与版本号〕/ 待上线成因 / 手动上线请求 / 能力声明变更 / 运行期凭据签发回收 / 因删除取消);F049 密钥全生命周期(AC-12);F051 模型调用逐条;本 Feature 自己只写**可见范围变更**。**本 Feature 拥有的是**:事件类型的登记与查询面可查、审计查询面、导出、以及「GOV-04 事件清单每一行在审计面可查」的验收。触达同理:审批类 + 待上线(含「上线失败」成因)+ 因删除取消归 F055(AC-31 / AC-35 / AC-64);本 Feature 只做「租户管理员下线 / 重新上线 → owner」这一处接线 + §3.0.3 全表的验收。
+>
+> - **本次纳入**:
+>   - **RT-02 应用广场**:client 应用广场把已上线托管应用与工作流 / 助手**并列**呈现(卡片:图标 / 名称 / 一句话描述 / owner);按平台权限判定(可见范围)过滤;接入既有标签体系——**本版不做打标入口**,未设标签的应用在默认分类正常可见、可被搜索;点击卡片进入 `/apps/{应用标识}`(入口后的行为归 F054);已下线应用卡片保留并标注已下线;未部署工场运行时层时广场不出现托管应用(消费 F054 的开关)
+>   - **GOV-01 授权交互**:卡片 ⚙️「管理权限」与应用详情页 · 发布「可见范围区」两个入口拉起**同一个**平台通用授权弹窗(可按用户 / 部门 / 用户组授予);**发布 tab 内的「可见范围区」这一区块归本 Feature**(当前状态呈现 + 设置按钮 + 首发上线后仍「仅 owner 可见」时的显著提示),寄居于 F055 交付的发布 tab;租户管理员可对本租户任一应用调整(既有身份短路,非本 Feature 新增);变更即时生效、不经审批、不进版本快照、计审计并记变更人;GOV-01 验收 1–6 全部由本 Feature 验收(其中验收 6 的 ⚙️ 菜单裁剪由 F054 AC-42 / AC-53 交付,本 Feature 只做回归验证)
+>   - **GOV-04 全链路审计**:本 Feature **写入**的事件只有一类——可见范围变更;其余事件的写入归各动作 owner(见上方归属规则),本 Feature 拥有:全部托管应用事件类型在审计查询面的**登记与可查**(GOV-04 事件清单每一行——造应用 / 导入、发布类〔提交 / 上线 / 待上线含成因 / 手动上线;审批单四个终态经审批中心事件承载〕、删除 / 下线 / 重新上线、可见范围变更、元信息修改、生产数据行编辑、访问记录、能力声明变更、运行期能力调用双归属、运行期凭据签发回收、密钥事件、模型调用逐条——在审计面可查的验收);**审计查询面**在既有管理后台审计页上扩展:筛选时间范围 / 操作人 / 事件类型 / **对象应用**(新增),平台超管视角另有租户筛选与租户列,**支持导出**(新增);租户管理员限本租户、平台超管跨租户;审计记录与导出不出现密钥明文;owner 无自助入口
+>   - **GOV-07 界面通道**:既有 `create_app` 权限点复用的验收——无该权限的用户看不到新建入口,但其既有托管应用的运行 / 访问 / 迭代发布 / 详情页管理入口不受影响;升级前后角色配置面菜单项与权限点数量不变(已部署形态;未部署形态由 F054 AC-58 覆盖);三个承载面(构建面 / 治理面 / 使用面)分别寄居既有页面;GOV-07 验收 2 / 3(CLI 通道 `app:manage` 位、两通道互不校验)属 F053 / F055,本 Feature 只引用
+>   - **§3.0.3 事件触达**:站内消息复用平台既有消息通知能力(接收方在 client 工作台「消息提醒」查看,管理后台不设消息面);本 Feature **接线一处**——租户管理员(或超管代行)下线 / 重新上线 → owner;**验收全表**——审批单生成 / 通过与驳回 / owner 撤回 / 因删除取消 / 待上线(资源不足或上线失败)由 F055 触达(AC-31 / AC-35 / AC-64),明示「无主动提示」的事件(资源释放后可手动上线、声明能力被收回、key 签发 / 吊销 / 失效、应用上架 / 新获可见权限)不发站内消息
+> - **本次明确排除**:
+>   - **`app` 资源类型注册**(后端 catalog / 权限模型 / 前端联合类型 / 存量环境生效手段)——F054 AC-09–13;本 Feature 只消费
+>   - **卡片 ⚙️ 菜单按类型裁剪的实现**(只出现「管理权限」「删除」、删除项已上线态置灰须先下线、仅 owner 可见)——F054 AC-42 / AC-53;本 Feature 只做回归验证(GOV-01 验收 6)
+>   - **除可见范围变更外的一切审计事件写入**(发布 / 上线 / 待上线 / 手动上线 / 下线 / 重新上线 / 删除 / 元信息 / 数据行编辑 / 访问记录 → F054 AC-65 / AC-06 / AC-56 / AC-38;提交 / 首发导入 / 预检失败 / 扫描阻断 / 审批单终态 / 因删除取消 / 能力声明变更 / 运行期凭据 → F055 AC-01 / AC-05 / AC-35;密钥 → F049 AC-12;模型调用逐条 → F051)——本 Feature 只做登记与可查性验收
+>   - **审批类、待上线态、因删除取消的站内触达**——F055 AC-31 / AC-35 / AC-64;本 Feature 只验收
+>   - **构建页「托管应用」类型筛选与卡片、应用详情页四 tab 壳、运行日志 tab、数据 tab**——F054 AC-51–58
+>   - **发布 tab 的其余区块**(提交发布 / 审批状态区 / 能力声明清单 / 版本列表 / 运营动作区 / 入口链接与二维码 / 危险操作区)——F055 WB-14;本 Feature 只拥有其中「可见范围区」
+>   - **应用广场「上新推送」**(应用上架 / 新获可见权限时通知可见范围内用户)——PRD-1 §3.0.3 明示「本版不做」,owner 自行转发入口链接
+>   - **应用标签设置**(打标入口)——PRD-1 §5.2 顺延。**⚠️ 2026-08-19 事实订正**:打标入口**其实已经存在**——`platform/src/pages/BuildPage/apps.tsx:436-444` 已给 `HostedAppCard` 挂了 `LabelShow`,与工作流 / 助手同一套。本条的实际含义收窄为「本 Feature 不新建打标入口、也不为托管应用改造标签体系」;本 Feature 只保证接入既有标签体系且未设标签时在默认分类可见
+>   - **GOV-09 管理后台应用列表**——PRD-1 §5.2 **已取消**:租户管理员对本租户全部应用天然可见,构建 → 应用页即全租户视图(F054 AC-57),不另建列表
+>   - **「租户管理员代 owner 调整可见范围」**——PRD-1 §5.2 明示是**既有行为**(身份短路的自然结果),不是本 Feature 新增能力;本 Feature 只验收其对托管应用同样成立并计审计
+>   - **「草稿对管理员不可见」**——PRD-1 §5.2 已取消(对既有行为的收窄会造出类型特例);本 Feature 不对草稿态应用做任何额外可见性收窄
+>   - **按 key / 按应用的聚合用量账单**——PRD-1 §5.2 顺延 v3.1;本版以逐条调用记录(F051 写入、本 Feature 保证可查)替代
+>   - **owner 应用侧审计 / 访问记录自查视图**——PRD-1 §5.2 顺延;本版审计查询仅管理面
+>   - **审批场景配置面与预置审批流**(GOV-02)——F055;本 Feature 只验收审批类事件触达与审批终态事件可查
+>   - **`create_app` 权限点本身与菜单权限模型**(角色配置面、`build` 父子级联、新建角色默认值)——平台既有,本 Feature 零改动、只验收
+>   - **应用密钥值与运行记录的业务写入**——应用密钥由 F055 维护并留痕,访问/模型/能力记录继续由原 Owner 写入;本 Feature 只提供其登记、查询、展示与导出,不能为查询自行伪造事件
+>   - **审批单催办 / 超时提醒 / 升级机制**——PRD-1 §5.2 顺延
+>   - **PRD-2 造应用工作台**(对话外壳 / 管家 / 预览 / 代码 / 资源 tab)与其治理条目 RT-06 / GOV-06
+
+---
+
+## 1. 用户故事
+
+作为 **普通业务用户**,
+我希望 **在应用广场里像找工作流、助手一样找到同事发布的托管应用,只看到我有权用的,点开就能用**,
+以便 **不必知道「托管应用」和其它类型有什么技术差别,也不会点进一个自己没权限的应用**。
+
+作为 **开发者(owner)**,
+我希望 **首发上线后系统显著提醒我「现在只有你自己看得到」并直接给我设置可见范围的按钮,且这个弹窗和我给工作流授权的是同一个**,
+以便 **不漏掉「设可见范围」这一步、也不用另学一套授权界面**。
+
+作为 **租户管理员**,
+我希望 **对本租户任一托管应用都能像对工作流一样在卡片 ⚙️ 里改可见范围,且谁改了什么、谁上线 / 下线 / 删除了什么、谁在用哪个应用,都能在既有审计页按「对象应用」查出来并导出**,
+以便 **治理面前本地造的与平台造的应用无差别,出事能追到人**。
+
+作为 **平台超管**,
+我希望 **在审计页按租户筛选、跨租户查看托管应用的全部事件**,
+以便 **多租户部署下能做平台级的合规追溯,而不必逐个切租户**。
+
+作为 **审批人 / owner(事件接收方)**,
+我希望 **审批单生成、被通过或驳回、被撤回或因删除取消、应用入待上线态、被管理员下线 / 重新上线时,我在工作台铃铛里能收到通知**,
+以便 **不用守着页面刷新也能知道该我处理或该我知道的事**。
+
+---
+
+### 本轮补充用户故事(2026-10-23)
+
+作为有权的租户管理员或平台超管,我希望在既有审计页按真实应用、调用密钥和访问者查到三类运行记录并导出同一筛选结果,以便追溯调用且不穿透租户或误读历史缺失字段。
+
+## 2. 验收标准
+
+> AC 编号在本特性内唯一。括号内标注对应 PRD-1 条目与验收序号(如 `↔RT-02 验收 1`);引用其它 Feature 的 AC 时写全编号(如 `F054 AC-10`)。错误码仅作可观测行为引用,定义在 design.md。
+
+### 2.1 应用广场(RT-02)
+
+- **AC-01** — WHERE 工场运行时层已部署, THE SYSTEM SHALL 在 client 应用广场把已上线的托管应用与工作流 / 助手**并列**呈现为同构卡片(图标 / 应用名 / 一句话描述 / owner 显示名),共用同一列表、同一组标签 tab、同一搜索框;不另开「托管应用」专属页或专属 tab。(↔RT-02 需求描述 / 验收 5;§3.0.1「用在 client」)
+- **AC-02** — WHEN 托管应用首发审批通过且上线成功, THE SYSTEM SHALL 使其自上线起出现在可见范围内用户的广场中(首发默认仅 owner 可见,F054 AC-11);审批通过前、待上线态(资源不足 / 上线失败两种成因)、草稿态一律不出现。(↔RT-02 验收 1;RT-03 验收 3;F055 决议-8)
+- **AC-03** — THE SYSTEM SHALL 只把**已上线**与**已下线**两个应用态的托管应用呈现在广场:草稿 / 待上线 / 已删除态不出现;WHILE 应用已下线, 其卡片对可见范围内用户仍呈现并带「已下线」标识,点击落 F054 的「已下线」页;重新上线后标识消失。(↔RT-07 验收 4「广场呈已下线提示」;§3.0.2)
+- **AC-04** — THE SYSTEM SHALL 使广场按平台权限判定结果过滤托管应用:不在可见范围内的用户,其广场的任何标签 tab、默认分类与搜索结果中均不出现该应用;租户管理员因既有身份短路可见本租户全部托管应用(不额外收窄,§4 决议-4)。(↔RT-02 验收 3 / 规则)
+- **AC-05** — WHEN 用户被授予或被撤销某托管应用的可见范围, THE SYSTEM SHALL 自该用户的**下一次请求**起在广场中呈现 / 不再呈现该应用,无需重新登录、不依赖任何缓存到期。(↔GOV-01 验收 2;F054 AC-10)
+- **AC-06** — THE SYSTEM SHALL 使广场对某用户呈现的托管应用集合与统一入口对同一用户放行的集合**同源一致**:不存在「广场可见但入口无权限」或「入口可进但广场不见」的稳态(AC-05 的下一次请求窗口除外)。(↔RT-01 规则「入口访问与广场可见性共用同一份可见范围」)
+- **AC-07** — WHEN 用户点击托管应用卡片, THE SYSTEM SHALL 在**当前窗口**跳转到 `/apps/{应用标识}`(其后登录态 / 兜底页 / 身份注入等行为归 F054 AC-25–36),不进入对话式聊天页;托管应用卡片不提供其它类型既有的免登录分享动作(托管应用无免登录通道,§4 决议-6)。(↔RT-02 验收 4)
+- **AC-08** — THE SYSTEM SHALL 使未设任何标签的托管应用在广场**默认分类**中正常可见,并可按名称 / 描述被搜索到;托管应用接入既有标签体系(若已有标签则出现在对应 tab)但本版不提供打标入口。(↔RT-02 验收 2 / 规则)
+- **AC-09** — WHEN 应用元信息(名称 / 描述 / 图标)被更新(本版唯一来源 = CLI deploy 通过预检与扫描后立即更新,F055 AC-05;发布面元信息编辑随 PRD-2), THE SYSTEM SHALL 使广场卡片自下一次加载起呈现新值,且该更新不产生审批单、不产生版本记录;其审计事件由 F054 AC-06 / F055 AC-05 写入,本 Feature 只保证在审计面可查(AC-23)。(↔RT-02 验收 5 / 规则)
+- **AC-10** — WHERE 工场运行时层未部署, THE SYSTEM SHALL 使广场不出现任何托管应用卡片及与之相关的文案 / 筛选项,广场其余行为零变化。(↔GOV-10 规则;F054 AC-62)
+
+### 2.2 授权交互(GOV-01)
+
+- **AC-11** — THE SYSTEM SHALL 使托管应用卡片 ⚙️ →「管理权限」与应用详情页 · 发布「可见范围区」的设置按钮拉起**同一个**平台通用授权弹窗(可按用户 / 部门 / 用户组授予与撤销),两个入口保存后的可见范围结果一致;弹窗的选人场景不出现服务账号(INV-29)。(↔GOV-01 验收 1)
+- **AC-12** — THE SYSTEM SHALL 在应用详情页 · 发布提供「可见范围区」:常驻呈现当前可见范围状态(「仅 owner 可见」或已授予主体的摘要)与设置按钮;WHILE 应用已上线且可见范围仍为「仅 owner 可见」, THE SYSTEM SHALL 在可见范围区**顶部常驻一条提示条**(文案含「仅 owner 可见」与「同事无法在广场看到」两个要素)并在提示条内提供设置按钮;设过可见范围后提示条消失。(↔GOV-01 验收 3;§2.2 主旅程步 5)
+- **AC-13** — WHEN 经任一入口变更可见范围, THE SYSTEM SHALL 不生成审批单、不产生版本记录、不改变应用态;变更自被授权用户的下一次请求起作用于广场(AC-05)与入口(F054 AC-10);并由本 Feature 写入「可见范围变更」审计事件(AC-22)。(↔GOV-01 验收 2 / 5)
+- **AC-14** — THE SYSTEM SHALL 使租户管理员可对本租户任一托管应用经卡片 ⚙️「管理权限」查看并调整可见范围(既有身份短路,非本 Feature 新增);审计中变更人记为该管理员本人;平台超管经租户管理视图代行时变更人记为超管本人。(↔GOV-01 验收 4;§3.3 矩阵注)
+- **AC-15** — IF 非 owner 且非管理员的用户(含仅被授予可见范围的广场用户)尝试打开授权弹窗或调用其读写接口, THEN THE SYSTEM SHALL 拒绝(403);被授予可见范围只获得广场可见与入口访问,不获得任何管理入口。(↔F054 §3「谁能进 ≠ 谁能管」)
+- **AC-16** — THE SYSTEM SHALL 在 ⚙️ 菜单按类型裁剪落地后通过回归验证:**工作流与助手卡片的 ⚙️ 菜单四项与既有行为完全一致**;托管应用卡片的 ⚙️ 菜单符合 F054 AC-53(两项)与 F054 AC-42(已上线态删除置灰)。本条只做回归验证,不复述裁剪行为;为 GOV-01 验收 6 的验收归属。(↔GOV-01 验收 6 / ⚙️ 菜单裁剪注)
+
+### 2.3 审计事件(GOV-04 事件清单)
+
+> **写入归属**按文首归属规则:事件由发生该动作的 Feature 写入(F054 状态动作 / F055 管线 / F049 密钥 / F051 模型调用),本 Feature 只写「可见范围变更」一类。**本 Feature 对本节每一行的责任统一为**:事件类型在审计查询面登记并可查(GOV-04 验收 1「每类事件发生后在审计面可查到记录」的验收归属);登记所需同步的几处实现义务写在 design.md §5(见 §5 指针表),不在此展开。
+
+| 事件类别(PRD-1 GOV-04) | 本版 | 写入方 | 本 Feature |
+|---|---|---|---|
+| 造应用——CLI 首发导入 | 本版 | F055 AC-01 | 可查(AC-18) |
+| 造应用——平台内造(WB) | PRD-2 | — | — |
+| 接入知识库 / 模型等能力(能力声明变更) | 本版 | F055 | 可查(AC-25) |
+| 应用运行期能力调用(双归属 actor = 应用 / subject = 访问用户) | 本版 | F055(字段与 F050 双归属对齐) | 可查(AC-25 / AC-34) |
+| 访问记录(谁在用) | 本版 | F054 AC-38 | 可查(AC-24 / AC-34) |
+| 生产数据行编辑 | 本版 | F054 AC-56 | 可查(AC-25) |
+| 发布 / 删除动作(含下线 / 重新上线) | 本版 | F055 AC-01(提交 / 首发导入 / 预检失败 / 扫描阻断 / 审批单生成与四个终态 / 待上线成因 / 手动上线请求 / 因删除取消 AC-35);F054 AC-65(上线 / 待上线 / 手动上线执行 / 下线 / 重新上线 / 删除) | 可查(AC-19–21) |
+| 应用密钥录入、修改、删除(GOV-05) | 本版 | F055(应用、引用名、操作者、动作、结果;不含值) | 可查(AC-59) |
+| 模型调用记录(逐条) | 本版 | F051 | 可按 key / 按对象应用追溯(AC-25 / AC-34) |
+| key 签发 / 权限位调整 / 吊销 / 自动失效;应用运行期凭据自动签发与回收 | 本版 | F049 AC-12 / F055 | 可查(AC-25) |
+| 可见范围变更 | 本版 | **本 Feature**(AC-22) | 写入 + 可查 |
+| 元信息修改 | 本版 | F054 AC-06 / F055 AC-05 | 可查(AC-23) |
+
+- **AC-17** — THE SYSTEM SHALL 使托管应用相关的全部事件类型在审计查询面的事件类型筛选中**作为一组连续可选**(分组与命名约定见 §4 决议-1),且每条托管应用事件在列表中可见操作人 / 时间 / 对象应用(名称 + 标识)/ 动作 / 结果五要素;WHEN 操作人是服务账号(CLI 路径), 列表呈现服务账号名与所用密钥掩码并附该应用 owner(写入方按 §4 决议-7 落字段)。(↔GOV-04 需求描述 / 事件清单表头;GOV-07 ②「责任与审计落在自然人头上」)
+- **AC-18** — WHEN 托管应用经 CLI 首发导入创建, THE SYSTEM SHALL 使「造应用 / 导入」事件(F055 AC-01 写入:来源 = CLI 导入、应用名与标识、owner、执行服务账号)在审计面按对象应用可查。(↔GOV-04「造应用」行;DEV-04 验收 3「在审计中的表现与平台内造一致」)
+- **AC-19** — WHEN 发生发布类动作——管线节点(提交 / 首发导入 / 预检失败 / 扫描阻断 / 审批单生成 / 待上线成因〔资源不足 / 上线失败〕/ 手动上线请求,F055 AC-01 写入)与状态动作(上线成功 / 入待上线 / 手动上线执行,F054 AC-65 写入), THE SYSTEM SHALL 使这些事件在审计面按对象应用可查,记录中可见版本号、类型(首发 / 迭代)、动作结果与成因;审批单的四个终态(通过 / 驳回 / owner 撤回 / 因删除取消)复用审批中心既有事件并携带应用标识与版本号(F055 AC-01 / AC-35),THE SYSTEM SHALL 使其被「对象应用」筛选命中;本 Feature 不另建镜像事件(§4 决议-1)。(↔GOV-04「发布 / 删除动作」行;RT-03;F055 决议-8)
+- **AC-20** — WHEN 发生下线 / 重新上线(F054 AC-65 写入), THE SYSTEM SHALL 使该事件在审计面按对象应用可查,记录中可见操作人身份类别(owner / 租户管理员 / 超管代行)与结果(重新上线遇容量不足时含成因)。(↔RT-07 操作表「计审计」;GOV-04)
+- **AC-21** — WHEN owner 显式删除应用(F054 AC-65 写入「删除」事件), THE SYSTEM SHALL 使应用进入已删除态后该事件与其全部历史事件仍可在审计面按对象应用查到(对象以标识与名称快照呈现,不因应用不存在而丢失可读性)。(↔RT-07 验收 3「删除动作在审计记录中可查」;§3.0.2「已删除:仅审计可查」)
+- **AC-22** — WHEN 可见范围变更(授予 / 撤销,任一入口), THE SYSTEM SHALL 写入「可见范围变更」事件(**本 Feature 唯一自写的事件类型**),记录变更人(owner 或代调整的租户管理员 / 超管本人)、应用、变更前后主体清单(用户 / 部门 / 用户组的增删);事件写入后即可在审计查询面按「事件类型 = 可见范围变更」与「对象应用」筛出。(↔GOV-04「可见范围变更」行;GOV-01 验收 4)
+- **AC-23** — WHEN 元信息(名称 / 描述 / 图标)被更新(本版唯一来源 = CLI deploy,F055 AC-05 / F054 AC-06 写入;发布面编辑随 PRD-2), THE SYSTEM SHALL 使「元信息修改」事件在审计面按对象应用可查,记录中可见对象与变更前后内容。(↔GOV-04「元信息修改」行;RT-02 验收 5)
+- **AC-24** — WHEN 用户经统一入口成功进入已上线托管应用(F054 AC-38 留痕访问记录:访问用户、应用、访问时间;一次进入一条、不记请求级明细、被兜底页拦下的访问不计入——口径与 F054 一致;合并窗口取部署配置项、默认 30 分钟,§4 决议-2), THE SYSTEM SHALL 使访问记录在审计查询面可按对象应用 / 操作人(访问用户)/ 时间范围查到。(↔GOV-04「访问记录」行)
+- **AC-25** — THE SYSTEM SHALL 使密钥事件(F049 AC-12)、应用运行期凭据自动签发与回收(F055)、能力声明变更(F055)、应用运行期能力调用双归属记录(F055)、生产数据行编辑(F054 AC-56:应用、数据对象、编辑动作)在审计查询面可查;模型调用逐条记录(F051)可按 key / 按对象应用追溯到具体记录(含模型、时间、token 数)。(↔GOV-04 验收 1 / 3)
+- **AC-26** — THE SYSTEM SHALL 使任何审计记录、查询响应与导出内容不含密钥明文等敏感值;平台自签发凭据只显示既有前缀与末四位掩码,owner 录入的第三方应用密钥只显示引用名及无值动作信息,不借“掩码”暴露其字符片段。(NFR-2;应用密钥与 ApiCredential 的不同边界)
+- **AC-27** — THE SYSTEM SHALL 使 GOV-04 事件清单在本版落地的每一类事件(§2.3 表「本版」行)在写入后即可在审计查询面被筛选与展示(事件类型下拉可选、有 zh-Hans / en / ja 三语文案);**不存在「已写入但页面查不到」的事件类型**。(↔GOV-04 验收 1)
+
+### 2.4 审计查询面与导出(GOV-04 界面)
+
+- **AC-28** — THE SYSTEM SHALL 在既有管理后台审计页上扩展(不新建页面):事件类型筛选含托管应用全部事件类别;新增「对象应用」筛选(按应用名 / 标识检索选择,选中后只列该应用相关事件,**含已删除应用**);保留既有时间范围与操作人筛选。(↔GOV-04 界面表)
+- **AC-29** — THE SYSTEM SHALL 使审计列表列含时间 / 操作人 / 对象 / 动作 / 结果;对托管应用事件,对象列显示应用名(附标识;已删除应用显示名称快照),操作人列对服务账号显示服务账号名(附密钥掩码,AC-17)。(↔GOV-04 界面表)
+- **AC-30** — WHERE 多租户开启, WHEN 平台超管进入审计页, THE SYSTEM SHALL 提供租户筛选且列表含租户列,超管可查全部租户事件;WHEN 超管经租户管理视图切至目标租户, THE SYSTEM SHALL 使审计面以目标租户上下文呈现。(↔GOV-04 验收 4 / 规则;§3.3 矩阵注)
+- **AC-31** — THE SYSTEM SHALL 使租户管理员只查到本租户事件:租户筛选不出现,指定其它租户的查询请求被拒;不得因「对象应用」筛选而穿透到其它租户的应用。(↔GOV-04 验收 4)
+- **AC-32** — THE SYSTEM SHALL 提供审计记录导出:导出当前筛选条件下的全部记录(含 AC-29 各列),受与查询完全相同的租户与角色限制;导出内容遵守 AC-26。(↔GOV-04 界面表「支持导出」;§4 决议-8)
+- **AC-33** — THE SYSTEM SHALL 使审计查询面与导出仅租户管理员及以上可用;owner 与普通用户无自助入口,其对查询 / 导出接口的调用被拒。(↔GOV-04 规则「开发者侧无自助查询入口」;§5.2 owner 自查视图顺延)
+- **AC-34** — THE SYSTEM SHALL 在既有管理后台审计页中以“应用运行记录”tab 承载模型调用、运行期能力调用及应用访问三个子视图,使高频记录可以查询和导出;操作类审计留在原 tab,不把高频事件混入原操作列表,也不另建独立管理页面;未部署工场运行时层时不显示该运行记录 tab。(2026-10-09 GOV-04 方案 A 替代原单列表筛选描述)
+
+### 2.5 界面通道权限(GOV-07)
+
+- **AC-35** — THE SYSTEM SHALL 使构建页新建入口继续受既有 `create_app` 权限点控制(父项「构建」关闭时级联失效):无该权限的用户在构建页看不到新建入口;本版**不交付托管应用的界面新建入口**(随 PRD-2),托管应用进入构建列表的唯一来源是 CLI 首发导入(F055)。(↔GOV-07 验收 1 / ①)
+- **AC-36** — WHEN 用户有「构建」菜单权限但无 `create_app`, THE SYSTEM SHALL 保持其 owner 的托管应用的运行、访问、迭代发布(CLI deploy)与应用详情页全部管理入口(发布 / 数据 / 运行日志 / 版本、可见范围设置、下线、删除)不受影响;界面侧权限点(`create_app` / 「构建」菜单)的任何变化不影响 CLI 通道判定(GOV-07 验收 2 / 3 由 F053 / F055 验证,本条只保证界面侧不对 CLI 路径误加拦截)。(↔GOV-07 验收 1 / ②)
+- **AC-37** — THE SYSTEM SHALL 使升级前后角色配置面的菜单项与权限点数量完全一致(工场运行时层已部署形态;未部署形态见 F054 AC-58),新建角色「默认开构建、不开创建应用」的既有默认值不变。(↔GOV-07 验收 4 / ①)
+- **AC-38** — THE SYSTEM SHALL 使三个承载面分别寄居既有页面:构建面 = platform 构建 → 应用(类型筛选含托管应用,F054 AC-51);治理面 = **同一页面**的租户管理员视角(F054 AC-57,不另建列表);使用面 = client 应用广场(§2.1)与 `/apps/{应用标识}`(F054);不新增一级菜单、不新增独立治理列表页。(↔GOV-07 验收 4 / 承载面表;§5.2 GOV-09 取消)
+
+### 2.6 事件触达(§3.0.3 全表验收 + 一处接线)
+
+- **AC-39** — THE SYSTEM SHALL 使本节全部站内消息经平台既有消息通知能力发出,接收方在 client 工作台「消息提醒」入口(铃铛,含未读角标)查看;管理后台不设消息面(租户管理员与审批人的消息一律在工作台侧接收);站内消息为通知性质、不承载操作,处理动作回各自界面完成。(↔§3.0.3 渠道定义)
+- **AC-40** — WHEN 审批单生成(首发 / 迭代), THE SYSTEM SHALL 使按预置流程解析出的审批人收到站内消息与审批中心待办;WHEN 审批通过 / 驳回, owner 收到站内消息(驳回含理由);WHEN owner 撤回在途审批单, 已收到待办的审批人收到站内消息——三者由 F055 AC-64 经审批中心既有触达发出,本条为 §3.0.3 行 1–3 的验收归属。(↔§3.0.3 行 1–3)
+- **AC-41** — WHEN 应用删除导致在途审批单自动取消, THE SYSTEM SHALL 使该审批单的审批人收到站内消息(有且只有一条)——由 F055 AC-35 / AC-64 触达,本条为 §3.0.3 行 4 的验收归属。(↔§3.0.3 行 4;RT-07 验收 5)
+- **AC-42** — WHEN 上线终检遇运行环境容量不足或上线失败、应用入待上线态, THE SYSTEM SHALL 使 owner 与本租户的租户管理员(Root 租户为平台超管)收到含成因的站内消息——由 F055 AC-31 / AC-64 触达,本条为 §3.0.3 行 5 的验收归属(「上线失败」成因为 F055 决议-8 新增)。(↔§3.0.3 行 5;RT-03 验收 3)
+- **AC-43** — WHEN 租户管理员(或超管经租户管理视图代行)下线 / 重新上线某应用, THE SYSTEM SHALL 向 owner 发送站内消息;owner 本人执行时不发。**本条是本 Feature 唯一的触达接线**(其余行由 F055 / F049 承担或明示不发)。(↔§3.0.3 行 7)
+- **AC-44** — THE SYSTEM SHALL NOT 对下列事件发送主动提示:资源释放后可手动上线(owner 在发布面自查)、声明能力被收回(界面失效提示归 F055 GOV-05)、开发者 key 签发 / 吊销 / 自动失效(F049)、应用上架 / 新获可见权限(本版无上新推送)。(↔§3.0.3 行 6 / 8 / 9 / 10 / 11)
+- **AC-45** — IF 站内消息发送失败, THEN THE SYSTEM SHALL 不影响触发它的业务动作本身完成(发布 / 下线 / 删除 / 审批照常生效)。
+
+---
+
+
+### 2.7 应用运行记录查询与导出(GOV-04 收尾)
+
+- **AC-46** — WHEN 有权管理员进入已部署工场运行时层的既有审计页, THE SYSTEM SHALL 提供模型调用、应用能力调用、应用访问三个可独立选择的运行记录子视图,实际请求真实记录;原系统操作与原应用使用记录入口和既有筛选保持可用。
+- **AC-47** — WHEN 管理员在任一运行记录子视图筛选, THE SYSTEM SHALL 支持时间范围、应用以及有意义的访问用户条件;模型调用还可按调用密钥筛选,并显示密钥掩码而非明文;无效时间范围、非法分页与跨租户筛选值须明确拒绝,不忽略后返回更宽范围。
+- **AC-48** — WHEN 查询模型调用, THE SYSTEM SHALL 展示时间、调用归属(服务账号或应用)、模型、已记录 token 数及调用结果,并在有当前访问者信息时展示其身份;不以 app_id 非空为前提漏掉合法服务账号调用,也不把未记录 token 数伪造为零或把匿名/未知访问者替换为 owner。
+- **AC-49** — WHEN 查询应用能力调用, THE SYSTEM SHALL 展示时间、应用、能力、以谁的名义调用、结果及已有的可用耗时/数量信息;调用归属与访问者归属分别展示,不把两种身份混成一个操作者。
+- **AC-50** — WHEN 查询应用访问, THE SYSTEM SHALL 展示访问用户、应用与访问时间,并保持原“一次进入/合并窗口”的记录语义;不能把已有聚合访问记录宣称为每条 HTTP 请求或因查询重新生成访问事件。
+- **AC-51** — WHEN 在相同筛选范围内分页、刷新或导出运行记录, THE SYSTEM SHALL 对相同时间的记录提供稳定顺序,不因并发新增导致同一次导出重复、漏掉已选历史记录或无限延长;分页与总数均只反映有权查看的筛选结果。
+- **AC-52** — WHERE 多租户开启, THE SYSTEM SHALL 使租户管理员只能查询本租户;平台超管全局视图可显式选择有权租户,切到目标租户视图后按该目标上下文呈现;前端隐藏筛选不是服务端授权,调用者自报其他租户、应用或密钥标识不能扩大查询或导出范围。
+- **AC-53** — IF owner、普通用户、审批人、服务账号或个人访问令牌不具有原治理面规定的管理员身份, THEN THE SYSTEM SHALL 拒绝其运行记录查询、筛选候选与导出;拥有应用使用、管理或审批权限不自动赋予审计访问权。
+- **AC-54** — WHEN 应用、密钥或访问用户已删除、吊销或重命名, THE SYSTEM SHALL 仍允许有权管理员按历史标识追溯已保存记录;优先使用已有名称快照,历史缺字段时显示标识或未知,不从另一租户或新同名对象补造历史身份。
+- **AC-55** — WHEN 切换记录类别、租户视图或筛选条件, THE SYSTEM SHALL 同步复位不再适用的筛选和分页,不显示前一个租户/类别的过期返回内容;其后的导出必须使用当前明确选中的授权范围与筛选。
+- **AC-56** — WHEN 管理员导出运行记录, THE SYSTEM SHALL 导出当前筛选条件下受授权的全部记录而非当前页,列含与该子视图对应的追溯信息;导出受理及交付遵守与查询相同的当前角色/租户约束,失权不能继续取得新内容。
+- **AC-57** — IF 运行记录加载或导出失败, THEN THE SYSTEM SHALL 展示失败并结束等待,不能把失败表示为成功空列表、成功下载或沿用旧租户结果;合法无记录、历史字段缺失和服务失败须能被区分。
+- **AC-58** — THE SYSTEM SHALL 使运行记录查询、导出、错误及筛选候选仅包含已获准的审计元数据,不包含密钥值、OBO/会话凭据、应用环境全文、模型请求正文或知识检索正文;密钥只显示既有掩码。
+- **AC-59** — WHEN 按对象应用筛选操作审计, THE SYSTEM SHALL 能找到由 F055 及凭据 Owner 留下的应用密钥变更和应用运行期凭据签发/回收记录;应用关联须来自原业务目标,不能依靠审计查看者传入 app_id 伪造或补写历史事件。
+- **AC-60** — WHEN 导出包含用户可控名称或其他文本字段的表格文件, THE SYSTEM SHALL 保留可读文本而不把其作为可执行公式;导出不得携带隐藏敏感列,历史空值不得使导出失败或被替换为其他租户内容。
+- **AC-61** — WHERE 工场运行时层未部署, THE SYSTEM SHALL 不展示应用运行记录入口并对相应查询/导出遵循原层禁用语义,原操作审计继续可用;本次不新增聚合计费账单、owner 自助审计页或跨租户授权方式。
+
+## 3. 边界情况
+
+> 只写期望的对外行为;实现层的落点与数据访问细节见 design.md §5。
+
+- **广场过滤与入口判定必须同源**(AC-06):两者都是同一份可见范围(GOV-01)在不同面上的读出。广场若走与入口不同的判定路径,就会出现「广场看得到、点进去无权限」——这是产品明令的稳态禁区,只允许下一次请求前的短暂窗口。
+- **广场是「用」面不是「管」面**:广场按平台权限判定结果显示,租户管理员因既有身份短路对本租户全部应用天然可见——本 Feature **不自造收窄**(§4 决议-4);同理,被授予可见范围的用户只得到「能进」,不得到任何管理入口(AC-15)。
+- **应用态与广场可见性的对应**:只有已上线与已下线两个态进广场(AC-03);已下线保留卡片并标注已下线而非隐藏——隐藏会让用户误以为自己被移出了可见范围,且与 RT-07「广场呈已下线提示」相悖;草稿 / 待上线 / 已删除一律不出现,且不因用户是 owner 或管理员而例外(owner 与管理员看草稿在构建页,不在广场)。
+- **未部署工场运行时层 = 广场零痕迹**(AC-10):不出现卡片、不出现文案、不出现筛选项;广场其它类型行为零变化(NFR-4)。开关取值来自 F054(AC-61 / AC-62),本 Feature 只消费。
+- **两个授权入口 = 一个弹窗**:发布面「可见范围区」只是同一弹窗的第二个触发点外加一处状态呈现,不是第二套授权界面;任何一方新增的授权语义(档位 / 主体类型)都应在弹窗内一次生效于两处。
+- **「仅 owner 可见」提示是首发的必经提醒**:它常驻发布面,不是一次性 toast;owner 或租户管理员设过可见范围后提示消失、区块改为摘要呈现。
+- **「写了必须查得到」是本 Feature 对每一类事件的承诺**(AC-27):写入方(F054 / F055 / F049 / F051)只保证事件落库;事件类型能否在筛选下拉里被选中、列表里有可读文案,是本 Feature 的登记责任——任何一类事件「已写入但页面查不到」都算本 Feature 的缺陷(登记所需同步的几处实现义务见 design.md §5)。
+- **高频事件同面可达、且不拖慢既有审计页**(AC-24 / AC-34):访问记录、模型调用逐条、运行期能力调用与低频治理事件量级相差数个数量级;对管理员的承诺是「同一审计查询面经事件类型筛选可达」,同时既有审计页的低频事件查询不因它们的写入量而变慢(如何承载是 design 的事,写入方分别为 F054 / F051 / F055)。
+- **访问记录只记「成功进入」**(口径与 F054 AC-38 一致):被四类兜底页(无权限 / 已下线 / 不存在 / 未部署)拦下的访问不计入访问记录(那不是「谁在用」);拒绝类访问的留痕不在本版范围;本 Feature 只保证可查、不写入。
+- **服务账号执行的动作在审计里就是服务账号**(AC-17 / §4 决议-7):操作人列如实呈现服务账号(+ 密钥掩码),另附应用 owner;不把 CLI 动作记成资源归属人本人所为——归属人「承接归属、不出借权限」(伴生 PRD 定义 6),审计也不替他背操作。字段由写入方(F055 AC-01)落,本 Feature 负责呈现。
+- **已删除应用的审计可读性**(AC-21 / AC-28):对象应用筛选必须能选到已删除应用(以名称快照 + 标识),否则「删除」事件本身查不到对象。
+- **审计导出与查询同一权限边界**(AC-32):导出不得成为绕过租户过滤或角色限制的旁路;超管跨租户导出按当前租户筛选范围。
+- **触达的归属线**:审批类(生成 / 通过 / 驳回 / 撤回 / 因删除取消)与待上线(资源不足 / 上线失败)由 F055 AC-31 / AC-35 / AC-64 触达,本 Feature 只验收(AC-40–42);本 Feature 唯一接线的是「租户管理员下线 / 重新上线 → owner」(AC-43)——它挂在 F054 的状态动作上、接收方是 owner,与管线无关,才落在治理面。
+- **触达不阻塞业务**(AC-45):站内消息是旁路,发送失败只影响「对方知不知道」,不影响「事情有没有做成」。
+- **多租户下的广场与审计**:广场只列当前租户内的托管应用(既有租户过滤);审计查询的租户边界沿用既有审计页的租户规则,对象应用筛选是租户内的二次筛选,不得穿透(AC-31)。
+- **`create_app` 复用意味着无法单独灰度托管应用**(GOV-07 ①):勾了该权限三种类型都能建——本版托管应用无界面新建入口,该代价暂不显现;灰度闸是部署开关(环境级)与按人签发密钥(人级)。
+
+---
+
+### 2026-10-23 增量边界
+
+- 查询面不修补或伪造旧记录。旧数据缺名称快照、访问者或用量时保留其真实缺失,不能从当前对象猜测;任何兼容升级或字段补齐方式须在后续设计中声明。
+
+## 4. 待澄清决议
+
+> 2026-08-17 用户授权全自动模式:以下各项按建议直接定案,记录理由供追溯;spec 评审(`/sdd-review`)后如有 high 级发现再增补。
+
+- **[决议-1] 托管应用审计事件统一归入 `app.*` 命名空间(实现约定,design 承接、写入方 F054 / F055 遵守);审批单四个终态复用审批中心既有 `approval.*` 事件、不重复记录;本 Feature 对外只承诺「托管应用事件类型在筛选下拉里成组可选」(AC-17)。**(审查修订:原为 AC-17 正文,命名空间属实现约定移入此处)理由:既有审计页按命名空间分组筛选,统一前缀让托管应用事件在事件类型下拉里天然成组(PRD-1 §3.3 锚点表建议);审批通过 / 驳回 / 撤回 / 取消由审批引擎审计,再记一份 `app.*` 会让同一动作出现两条记录、误导取证——只要求这些记录携带对象应用与版本号、且能被「对象应用」筛选命中(AC-19,写入方 F055 AC-01 / AC-35)。**何时重新考虑**:若审计页改为按对象类型聚合视图,`approval.*` 事件无法按对象应用归组时由 F055 补一条镜像事件。
+- **[决议-2] 访问记录的写入归 F054(AC-38:一次进入一条、不记请求级明细、只记成功进入);F054 §3 留给本 Feature 定义的合并窗口取部署配置项、默认 30 分钟;本 Feature 只保证可查(AC-24)。**(审查修订:原口径把写入与「独立留痕通道」写在本 Feature,改按归属规则)理由:留痕点在统一入口(F054 组件),写入归动作 owner;PRD 要求的是「谁在用」(RT-01),一次进入即回答该问题,逐请求留痕既无产品价值又制造噪音;30 分钟窗口对齐常见会话粒度、避免刷新即多条;如何承载高频写入是 F054 / 本 Feature design 的事(Discovery §5 风险 9)。**何时重新考虑**:安全侧要求拒绝类访问留痕(如无权限尝试)时另立事件类型、写入仍归 F054。
+- **[决议-3] 广场卡片的 owner 显示为其平台显示名(与工作流 / 助手卡片「创建用户」同一取值),不显示部门或联系方式。** 理由:与并列的两类卡片同构(RT-02「同构卡片」),且无权限页 / 已下线页已引导「联系 owner」——广场只需让用户认出人;部门信息在其它面(如通讯录)自查。
+- **[决议-4] 租户管理员在广场按平台权限判定结果显示,本 Feature 不额外收窄。** 理由:`TENANT_ADMIN` 与 `SUPER_ADMIN` 并列身份短路对批量列表路径同样适用(Discovery §2.2),这是平台既有行为,PRD-1 §5.2 已据此取消 GOV-09 与「草稿对管理员不可见」;广场若单独对管理员收窄,将造出「构建页看得到、广场看不到」的类型特例,与工作流 / 助手不一致。
+- **[决议-5] 已下线应用在广场保留卡片并标注「已下线」,不隐藏。** 理由:RT-07 操作表与验收 4 写「入口与广场呈已下线提示」;隐藏会让用户误判为被移出可见范围(与无权限不可区分),保留 + 标注让「临时下线」这一状态可感知,也与入口的已下线页口径一致。
+- **[决议-6] 广场卡片点击在当前窗口跳转 `/apps/{应用标识}`;托管应用卡片不提供既有的免登录分享动作。** 理由:与工作流 / 助手卡片同为当前窗口进入,且 F054 四类兜底页均带「返回广场」按钮,同窗口语义下该按钮才成立;托管应用没有 share-token 免登录通道(RT-01 必须登录、F049 决议-1 的分享通道只覆盖工作流 / 助手两个 WS),卡片上出现分享动作只会导向一个不存在的能力——入口链接与二维码由发布面提供(F055 WB-14),owner 自行转发。**何时重新考虑**:产品要求广场卡片可复制入口链接时,加「复制入口链接」而非「分享」(语义是 URL 不是免登录票据)。
+- **[决议-7] 服务账号执行的动作在审计中操作人 = 服务账号(+ 密钥掩码),并同时记录应用 owner(资源归属人);不记成归属人本人所为——字段口径由写入方(F055 AC-01)遵守,本 Feature 负责呈现(AC-17 / AC-29)。** 理由:伴生 PRD 定义 6「归属人只承接归属、不出借权限」,审计如实记执行主体才能回答「哪把密钥干的」(泄漏取证);同时记 owner 回答「责任落在谁头上」(GOV-07 ②)——两者并列与 F050 审计双归属(actor / subject)同向,不冲突。
+- **[决议-8] 审计导出 = 导出当前筛选条件下的全部记录,受与查询相同的租户与角色边界;本条是既有审计页的新增能力。** 理由:GOV-04 界面表明写「支持导出」,而既有系统操作审计页只有查询、导出只存在于会话数据面;「按当前筛选导出」是最小且与查询语义一致的形态,避免出现「导出范围 ≠ 查询范围」的旁路。文件格式沿用平台既有导出格式(design 定)。
+- **[决议-9] 发布 tab 内的「可见范围区」这一区块归本 Feature,发布 tab 其余区块归 F055(WB-14)。** 理由:release-contract 表 3 F056 行明写「发布面可见范围区两个入口拉同一弹窗、首发后仅 owner 可见显著提示」,它与 GOV-01 交互是同一件事、与发布管线状态无耦合;拆到 F055 会让 GOV-01 验收 1 / 3 跨两个 Feature 才能验。F054 首波壳中的可见范围按钮占位由本 Feature 接管为完整区块。
+- **[决议-10] 本版托管应用不交付界面新建入口;GOV-07 界面通道验收以「既有新建入口受 `create_app` 控制 + 托管应用管理入口不受该权限影响」为准。** 理由:PRD-1 只定义 CLI 首发(DEV-04)与 PRD-2 工作台两条造应用来源,本册无「在构建页新建托管应用」的需求;GOV-07 验收 1 的语义是「无权限看不到新建入口、既有应用管理不受影响」,对托管应用而言后半句才是可验的实质。**何时重新考虑**:PRD-2 交付工作台入口时,该入口受同一 `create_app` 控制(零新增权限点承诺不变)。
+- **[决议-11] 事件触达按归属规则:审批类(生成 / 通过驳回 / 撤回 / 因删除取消)与待上线(资源不足 / 上线失败)由 F055 触达(AC-31 / AC-35 / AC-64),本 Feature 只验收(AC-40–42);本 Feature 唯一接线 = 「租户管理员下线 / 重新上线 → owner」(AC-43)。**(审查修订:原把「删除致取消」「待上线态」也列为本 Feature 显式接线,与 F055 AC-35 / AC-64 重复)理由:审批引擎已自动发 pending / approved / rejected / withdrawn 站内信(Discovery §2.7),F055 注册场景即得;待上线与因删除取消都是管线内事件、F055 已承接;重复接线会产生双份消息。下线 / 重新上线是 F054 状态动作、接收方是 owner、与管线无关,落在治理面最自然;「有且只有一条」按 AC-41 验收。
+
+---
+
+## 5. 设计与实现(指针,不复制)
+
+> **本节刻意不写内容。** spec 不承载 How。
+
+| 你想知道 | 去哪看 |
+|---|---|
+| 为什么这么实现(决策 + 备选 + 何时该推翻) | design.md §3 |
+| 关键约束(多租户 / 权限判定同源 / 高频事件承载 / 双 DB) | design.md §2 |
+| 今天的数据流、模块职责、字段 / API / 文件名约定(广场列表接入、授权弹窗第二触发点、审计事件类型与查询面、站内消息接线) | design.md §4 |
+| 代码里看不出的坑(广场列表的第三类型扩展点、**事件类型登记的四处实现义务**〔后端枚举 / 页面可见白名单 / 前端筛选常量 / 三语文案,漏一处即「写了查不到」——AC-27 的实现锚点〕、高频事件承载、通知组件的体量硬规、UNION 子查询租户注入) | design.md §5 |
+| 对外契约(事件类型登记契约——F054 / F055 / F049 / F051 写入方按此登记新事件类型;`app.*` 命名与记录字段口径〔§4 决议-1 / 决议-7〕;下线 / 重新上线触达入参)、依赖 | design.md §6 |
+| 任务拆解、[MVP-114] 波次、文件清单 | tasks.md |
+| 广场 / 审计 / 站内信 / 角色权限的代码现状锚点 | [../000-prd1-discovery/discovery.md](../000-prd1-discovery/discovery.md) §2.2 / §2.6 / §2.7 / §2.8;research/ 对应维度文件 |
+| 114 环境事实与纵切剧本 | [../mvp-114-path.md](../mvp-114-path.md) §1 步 5–6 / §3 |
+
+---
+
+## 相关文档
+
+- 设计真相: [design.md](./design.md)(尚未编写)
+- 执行与落档: [tasks.md](./tasks.md)(尚未编写)
+- 版本契约: [features/v3.0.0/release-contract.md](../release-contract.md)(写 spec 前必须先阅读;表 3 F056 行为本 Feature 范围的权威来源;本 Feature 不新增领域对象、不新增不变量)
+- 上游 Feature spec: [../054-app-domain-runtime/spec.md](../054-app-domain-runtime/spec.md)(`app` 资源类型 / 入口 / 详情页壳 / 开关 / 五个状态动作及其审计写入 AC-65 / 访问记录 AC-38 / 数据行编辑 AC-56 / ⚙️ 裁剪 AC-42 · AC-53);[../055-app-publish-pipeline/spec.md](../055-app-publish-pipeline/spec.md)(发布管线与管线事件审计写入 AC-01 / 元信息 AC-05 / 上线与待上线成因 AC-31 / 因删除取消 AC-35 / 发布 tab AC-61 / 触达 AC-64 / 决议-8);[../049-openapi-auth-baseline/spec.md](../049-openapi-auth-baseline/spec.md)(密钥审计事件 AC-12、INV-29 选人排除)
+- Spec Discovery: [features/v3.0.0/000-prd1-discovery/discovery.md](../000-prd1-discovery/discovery.md) §2.2 / §2.6 / §2.7 / §2.8 / §5 风险 7 · 9
+- UI 参考: [features/v3.0.0/000-prd1-discovery/ui-demo/](../000-prd1-discovery/ui-demo/)(以 PRD 为准、demo 为参考)
+- PRD: `docs/PRD/3.0-release/3.0 应用工场 PRD-1 专业开发者通道与应用运行时.md` v2.0(§3.0.3 / RT-02 / GOV-01 / GOV-04 / GOV-07 / §3.3 矩阵与锚点表 / §5.2);`docs/PRD/3.0-beta2/3.0 开放 API 鉴权与身份传递 PRD.md` v2.1 §4.5 定义 6(资源归属人)/ §4.8 审计双归属(F050 引入的字段口径)
diff --git a/features/v3.0.0/056-app-square-governance/tasks.md b/features/v3.0.0/056-app-square-governance/tasks.md
new file mode 100644
index 0000000000..dd187eac99
--- /dev/null
+++ b/features/v3.0.0/056-app-square-governance/tasks.md
@@ -0,0 +1,611 @@
+# Tasks: 应用广场接入与治理面(RT-02 广场 + GOV-01 授权交互 + GOV-04 审计登记)
+
+> **2026-10-23 增量 SDD**:用户已确认本轮规格,并于 2026-10-24 确认 F054/F056 修订设计。旧任务与旧评审状态仅描述历史批次;不代表本次增量已拆任务、编码或验收。本轮只做必要定向验证,不跑全量测试。旧任务的行号、计数和实现阶段为历史记录,当前实现快照以 design.md 为准。
+
+**关联规格**: [spec.md](./spec.md)(61 条 AC;已确认)· [design.md](./design.md)(D1–D15;本轮设计已确认;新接口合同 §4.4)
+**版本**: v3.0.0
+**纵切**: [mvp-114-path.md](../mvp-114-path.md) **§6 MVP-核心 F056 行**是本轮裁剪基准——Wave 1–2 全部为 `[MVP-核心]`(广场接入 + 两入口授权与「仅 owner 可见」提示 + 三类审计事件可查),Wave 3–4 为顺延项(release 仍必做,只列标题 / 文件 / 覆盖 AC,不展开)
+**代码事实口径**: 本文所有 `文件:行号` 沿用 design.md(`3.0-vibe` HEAD `11e1b211d`,2026-08-17 核实)。路径以 `src/backend/bisheng/` 为根;前端另注 `client/` = `src/frontend/client/src/`、`platform/` = `src/frontend/platform/src/`。**行号会漂、符号名不会**——落地前一律以符号名重定位(F054 Wave 3 落地后 platform 前端行号必漂)。
+
+---
+
+## 状态
+
+| 步骤 | 状态 | 备注 |
+|------|------|------|
+| spec.md | ✅ 本轮增量已确认 | 2026-10-23 用户明确确认;批准内容 hash 见规格评审 JSON |
+| design.md | ✅ 本轮增量已确认 | 2026-10-24 用户明确确认 F054/F056 修订设计;批准内容 SHA-256 `7136417b9ccd2d354903670e784f6a86acea213bd970e3393cb41ae4cd072c6c` |
+| tasks.md | ✅ 历史任务已拆解;本轮见文末增量 | 本文(33 任务 / 4 Wave / 21 条 `[MVP-核心]`);2026-08-17 初稿 + 同日 `/sdd-review tasks` 15 条修订(审计租户字段口径、client i18n、`objectTypeEnum.app` 新增认领、T009/T011 实现手段写死、夹具与追溯口径) |
+| 实现 | 🟡 进行中(2026-09-16) | **32 / 33 完成**;`[MVP-核心]` 21 条中完成 20(T001–T020),未完成 1:**T021**(114 部署与人工验收,本 Feature 唯一未勾任务);**T016** 代码已齐、2026-09-10 勾选,但需 `F056_E2E=1` 对真实部署(MySQL / Redis / OpenFGA)跑、114 重建后执行。Wave 3–4 顺延 12 条(T022–T033)已全部落地:T022–T026 + T032 先期交付,T027–T031 + T033 于 2026-09-16 分支 `wt/f056-verification` 交付(四条原写「无代码产出」的验收任务改为自动化回归,见各任务偏差段)。**仍只能在 114 上证的**:T021 整张手验清单;T029 / T030 / T031 的视觉确认;T033「站内信真的出现在 client 铃铛里」这一步。偏差见文末「实际偏差记录」 |
+
+---
+
+## 开发模式
+
+**按 Wave 组织任务**:
+- **Wave 1(`[MVP-核心]` 后端)**:审计 action 四处 lockstep 中的后端两处与前端两处(登记先行,否则事件写了查不到)→ 广场动作集两层裁剪 → DAO 状态豁免参数 → 广场载荷补 `slug` / `app_state` 与两入口状态收窄 → 应用中心路径排除 → 可见范围变更审计回调。
+- **Wave 2(`[MVP-核心]` 前端 + 114)**:Client 广场卡片与跳转;Platform 可见范围区与授权弹窗第二触发点;114 手动验证清单(**必须用非管理员账号**)。
+- **Wave 3–4(顺延)**:审计查询面「对象应用」筛选 / 导出 / 超管租户筛选;GOV-07 界面通道验收;⚙️ 裁剪与标签接入回归;§3.0.3 事件触达。按 `mvp-114-path.md` §6 排在 MVP-核心之后,**属本 Feature 范围、不得被裁掉**。
+
+每个任务标 `依赖:`,无依赖的可并行。
+
+**后端 Test-First**:测试任务先于其配对实现任务,`覆盖 AC` 逐条列举(禁 `AC-01~AC-05` 范围写法)。基础设施任务(审计常量登记、上游协调)无测试配对、排最前。单测放 `src/backend/test/workflow/`(既有目录,`test/` 根不放新文件),`asyncio_mode=auto`;集成测试连 test 中间件(MySQL / Redis / OpenFGA)在 CI 跑。
+
+**验收账号红线(贯穿全部测试与手动验证)**:
+- 广场 / 权限相关一律用**非管理员、非 owner 的普通账号**——`_identity_shortcut`(`permission/domain/services/permission_action_service.py:372-384`)对 `SUPER_ADMIN` / `TENANT_ADMIN` 与 action 无关地放行,且发生在动作合法性校验**之前**(design K2):管理员账号**必然通过**,拿它当证据等于没测。
+- 审计查询一律用 `is_admin()` 或持「日志菜单」权限的账号——`ainsert_v2` 从不填 `group_ids`,而 `api/services/audit_log.py:78-101` 会对其余账号强加 `json_array_contains(group_ids, …)` 过滤 → 这类账号**查不到任何 v2 结构化事件**(design K9 / 坑 15),会误判成「事件没写」。
+
+**前端**:Platform / Client 分区严格分节,任务附「手动验证」步骤(Playwright 未落地)。**两条被 lint 冻结的路**(design 坑 17):client 的 recoil(`client/eslint.config.mjs:47`)→ 新读取走 react-query v4;platform 的 react-query v3(`platform/eslint.config.mjs:45,51`)→ 新组件用 `useState + useEffect` / `useTable`。新增 i18n key 三语(zh-Hans / en-US / ja)同 PR。
+
+**Worker**:本 Feature **无 Worker / Celery 任务**(授权变更与广场列表都在 API 进程内同步完成;审计回调的注册点也只挂 API 进程的 `lifespan`,design D6「进程边界」)。故无 `tenant_id` → ContextVar 传递问题。
+
+**自包含任务**:每个任务内联文件、逻辑、AC 覆盖;设计论证指向 design §X 不复制。
+
+**编号 ≠ 执行顺序**:T001–T003 是基础设施,可与 T004 起的测试并行;T012–T014(审计回调)与 T004–T011(广场)互不依赖,可两路并行。
+
+**跨 Feature 前置(`[MVP-核心]` 全部依赖 F054 已落地的批 1 / 批 3)**:
+
+| 本 Feature 消费 | 上游任务 | 缺失时的现象(全部无报错) |
+|---|---|---|
+| `FlowType.HOSTED_APP=35` + `_build_apps_subquery` 第三支(含手工租户条款、`status` 投影 2/1、`user_id` ← `owner_user_id`) | **F054 T059** | 广场查不到任何托管应用;第三支漏租户条款 = 四条路径一起跨租户泄漏(design 坑 4) |
+| `SUPPORTED_APP_TYPES` / `_FLOW_TYPE_TO_RESOURCE_TYPE` 加 35 + **tag 预过滤 4 处**(含 `workflow.py:517-528`)+ `ResourceTypeEnum.HOSTED_APP` | **F054 T060** | 不放行 = 广场空列表;第 4 处漏补 = 有标签 tab 下托管应用整类消失(`tagged_ids` 空 → `return []`) |
+| 卡片 ⚙️「管理权限」接线(`BuildPage/apps.tsx` `typeMap` 补 `35:'app'`、`useResourceActions` 第三桶、`canManage`) | **F054 T063** | AC-11 入口 A 直接失效(弹窗对着 `workflow` 类型查,registry 报类型不符) |
+| 应用详情页 · 发布 tab 的 slot / children | **F054 T067** | 可见范围区无处挂载(AC-12) |
+| `useAppRuntimeEnabled` hook + `/api/v1/env.app_runtime_enabled` | **F054 T071 / AC-62** | 未部署形态下广场出现托管应用相关文案(AC-10 不成立)。**别引成 T090 / AC-61**——T090 是审批期预览入口 |
+| `app` 资源类型注册(catalog / FGA / 前端三处 `ResourceType` union) | **F054 批 1** | 授权弹窗与 `batch_check_business_actions` 全线不可用 |
+| `/apps/{slug}` 入口(app-proxy + nginx `location /apps/`) | **F054 批 2** | 卡片点击落 platform SPA `/404` |
+| `app.release.*` 事件的四处 lockstep 自登记 | **F055** | AC-27 的缺陷会记在本 Feature 头上(故 T015 有断言测试兜底) |
+
+**跨 Feature 副作用登记**(`release-contract.md` 检查项 17;本 Feature **不新增领域对象、不新增不变量、零 DDL、零新错误码**,design C2 / K11):
+
+| 任务 | 改动的共享物 | 影响面与保护措施 |
+|---|---|---|
+| T001 | `app_runtime/domain/constants.py` 的 `AppAuditAction`(**F054 领域对象**,正被并发编辑,坑 19)+ `database/models/audit_log.py` 的 `_UI_VISIBLE_V2_ACTIONS`(全平台共享白名单) | 只**加成员**不改既有;改前与 F054 owner 协调(T003);合并冲突时 rebase 不覆盖 |
+| T002 | `platform/controllers/API/log.ts` 的 `actions` 数组 + 三语 `bs.json`(审计页共享常量与文案) | 只加一条 app 段成员;模块下拉与 `getActionsByModuleApi` 的 `case 'app'` **F054 已落,不动** |
+| T005 / T009 / T011 | `api/services/workflow.py`(广场 + 构建页 + 工作台 + 应用中心共用的 application service,`_application_action_map` 有 **7 个调用方**;T011 另改 `filter_apps_by_action`,**3 个生产调用方**:`workflow.py:949` / `workstation/apps.py:41` / `:119`) | 每个任务只改自己那一个函数;`_application_action_map` / `filter_apps_by_action` 新增参数均缺省(`None` / `frozenset()`)= 其余调用方逐字节不变(design D3 爆炸半径表),单测逐条断言。⚠️ `test/workstation/` 有 4 个文件 patch 了 `filter_apps_by_action`(见 T011) |
+| T007 | `database/models/flow.py` 的 `aget_all_apps` / `_build_apps_subquery`(**4 个调用方**,且 F054 T059 正在同文件并发编辑) | 只增可选参数、缺省不改行为;**落地前先与 F054 owner 对一次**:若 T059/T060 已加等价参数则复用其命名并回写 design D9;合并时 rebase 不覆盖 |
+| T009 | `database/models/app.py` 的 `AppDao`(**F054 领域模型**,可能并发编辑) | 只**新增一个只读**批量方法 `alist_slug_state_by_ids`(不改既有方法、不加列、无 DDL);`AppDao` 刻意不暴露通用 UPDATE(`app.py:101-107` docstring),本任务不破这条戒律 |
+| T013 | `permission/application/resource_api.py`(**F048 领域**的 application 门面,类名 `F048ResourcePermissionApi:99`,构造 `__init__:102-111`)+ `api/services/f048_permission_runtime.py:236` 的构造处(多传一个可选依赖) | 只加「按资源类型注册的回调」扩展点,本期只注册 `app` 一个类型;**其余资源类型行为逐字节不变**(知识库 / 工作流的授权今天完全不写审计,不顺手加);注册表照既有 `ResourceAuthorizationRegistry`(`permission/application/resource_authorization.py:37-53`)形态**实例化 + 组合根注入**,不用模块级全局 dict |
+| T014 | `api/services/f048_permission_runtime.py`(`initialize_f048_api_runtime:214` 内 `F048ResourcePermissionApi(...)` 的构造处 `:236-240`,与既有 `registry.register("app", hosted_app):210` 同一处组合根;该文件已 import `app_runtime`,`:17`) | 只加一次显式注入;**不得依赖模块 import 副作用**;**不动 `main.py`**(见 T014 正文「注册落点」——放这里正好与 `mutate_grants` 同生共死,openfga 关闭时二者一起不存在,不构成静默失败) |
+| T018 | `client/src/pages/apps/components/AgentCard.tsx`(**广场与「应用中心」共用组件**,`explore.tsx` + `index.tsx` 两个页面)+ `client/src/locales/{zh-Hans,en,ja}/translation.json`(三语视为一组) | 只加「已下线」角标(由数据字段 `app_state` 驱动);分享按钮**不加类型判断**(靠后端 `can_share=false` 数据闸,零改组件达成 AC-07);新增文案只加 key 不改既有 key |
+| T011 | `api/services/workflow.py` 的 `filter_apps_by_action:663`(3 个生产调用方)+ `workstation/api/endpoints/apps.py`(工作台推荐位 `:41` / 常用列表 `:119` 两处调用处) | 只在「应用中心 / 最近使用」路径显式排除 `flow_type=35`(design 坑 10);新参数有默认值 → 不传即行为不变;不改另两类行为、**不动 `FlowDao`** |
+
+**上游勘误(本 Feature 不代改,见 T003)**:① F054 design `:339` 访问记录 TTL 300s → **1800s**(design D7 / spec 决议-2);② F054 T060 文件清单补 tag 预过滤第 4 处 `workflow.py:517-528`;③ `flow.py` 两个内部参数的命名与 F054 T059 对齐。
+
+---
+
+## Tasks
+
+### Wave 1 · `[MVP-核心]` 基础设施(无测试配对,排最前)
+
+- [x] **T001**: `[MVP-核心]` 审计 action 后端两处 lockstep 登记
+  **文件**: `src/backend/bisheng/app_runtime/domain/constants.py`(`AppAuditAction` 枚举加成员), `src/backend/bisheng/database/models/audit_log.py`(`_UI_VISIBLE_V2_ACTIONS:193` 加同一字符串)
+  **逻辑**: 加 `VISIBILITY_CHANGE = "app.visibility_change"`(本 Feature 唯一自写的事件类型,design D6)。`_UI_VISIBLE_V2_ACTIONS` 是 `_ui_visible_predicate:272-282` 的 `OR(system_id IS NOT NULL, action IN (...))` 白名单,被 `get_audit_logs:329` 与 `get_all_operators:408` 应用——**不在白名单 = 写库但审计页查不到,且无任何报错**(design K3,AC-27 的实现锚点)。命名空间前缀 `_V2_NAMESPACE_TO_ACTION_PREFIX:261` 的 `"app": "app."` 与模块下拉 **F054 已落,本任务不动**。
+  **⚠️ 并发**: `constants.py` 正被 F054 实现 agent 编辑(design 坑 19)——改前先 `git pull` / 与 owner 对一次;若不便共改,可在 `app_runtime` 自己的常量位置定义该 action,但 `_UI_VISIBLE_V2_ACTIONS` 白名单**仍必须加**。
+  **回滚**: 纯常量增补,无 DDL;回滚 = 删两处成员(历史事件行仍在库,只是页面查不到)。
+  **覆盖 AC**: AC-22, AC-27
+  **依赖**: 无
+
+- [x] **T002**: `[MVP-核心]` Platform · 审计 action 前端两处 lockstep 登记
+  **文件**: `src/frontend/platform/src/controllers/API/log.ts`(`actions` 数组 app 段 `:136-144`), `src/frontend/platform/public/locales/{zh-Hans,en-US,ja}/bs.json`(三语视为一组,**两处各加一条**:`log.eventTypeEnum` + `log.objectTypeEnum`)
+  **逻辑**: 事件类型下拉里加 `app.visibility_change`。**i18n key 由代码推导、不能自己起名**(design 坑 20):`actionToI18nKey`(`platform/src/pages/LogPage/systemLog/index.tsx:43-47`)按 `action.split(/[._]/)` 首段小写 + 其余首字母大写 → key **只能是 `appVisibilityChange`**(起成 `appVisibility` / `app_visibility_change` 则事件类型列显示英文原串,有 defaultValue 兜底不炸、三语文件那三条永远用不上)。
+  **⚠️ `log.objectTypeEnum.app` 是本任务要「新增」的,不是「确认存在」**(2026-08-17 实测三语 `bs.json` 的 `log.objectTypeEnum` 键集 = `approval_exception / approval_flow / approval_instance / approval_scenario / approval_task / assistant / channel / dashboard / file / flow / knowledge / knowledge_space / llm_server / none / role_conf / tenant / tool / user_conf / user_group_conf / work_flow / workstation`,**没有 `app`**)。缺这条时 `renderObjectType`(`systemLog/index.tsx:68-72`)回落显示原始串 `app`,与 AC-27「三语文案」正面冲突且**不报错**。文案初值:zh-Hans「应用」/ en-US `Application` / ja「アプリ」,以产品词表为准。机器化护栏见 T015 断言 ④。
+  **模块下拉与 `getActionsByModuleApi` 的 `case 'app'`(`log.ts:49` / `:166`)F054 已落,不动**。
+  **手动验证**: 见 T021 **步 9**(管理员账号在系统操作日志页看到「可见范围变更」中文选项、对象列显示「应用」而非 `app`,切 en/ja 文案不为空、不显示原始串)。
+  **覆盖 AC**: AC-22, AC-27
+  **依赖**: T001
+
+- [x] **T003**: `[MVP-核心]` 上游勘误提交与同文件并发协调(无代码产出)
+  **文件**: 无(勘误内容以书面 / SendMessage 形式交 F054 owner,**本 Feature 不代改 F054 任何文档**)
+  **逻辑**: 三条勘误逐条落地确认:① F054 design `:339` 的访问记录合并窗口 TTL **300s → 1800s**(值由 F056 D7 / spec 决议-2 定义,不回写 = F054 按 300s 落地且无任何报错);② F054 T060 的文件清单补 tag 预过滤**第 4 处** `api/services/workflow.py:517-528`(F054 `tasks.md:47` 批次边界注已认账 4 处,仅清单行漏写;不补 = 有标签 tab 下托管应用整类消失且不报错);③ `database/models/flow.py` 的 `status_exempt_flow_types` / `app_state_in` 两个内部参数命名与 F054 T059 对齐——若 T059/T060 已加等价参数则 T007 复用其命名并回写 design D9,否则由 T007 新增。**任一条未获回复即视为未决,T007 落地前必须完成 ③**。
+  **回滚**: 不适用(纯协调)。
+  **依赖**: 无
+
+### Wave 1 · `[MVP-核心]` 后端 · 广场动作集裁剪(Test-First 配对)
+
+- [x] **T004**: `[MVP-核心]` `_application_action_map` 第一层(合法性过滤)单元测试
+  **文件**: `src/backend/test/workflow/test_workflow_action_map.py`(新)
+  **逻辑**: 对 `WorkFlowService._application_action_map`(`api/services/workflow.py:146-178`)断言按资源类型的请求集裁剪,判据 = `permission/domain/services/catalog_policy.py:60-75` 的 `ACTION_RESOURCE_SCOPES`(与真正抛错的 `_prepare_action_target` 读同一张表)。用例:
+  - `test_app_bucket_drops_share` → 含 `flow_type=35` 的 data + 请求 `("use","edit","share")` → app 桶实际请求集**不含 `share`**(`catalog_policy.py:71` 的 `share` 范围集不含 `app`);
+  - `test_other_buckets_unchanged` → `workflow` / `assistant` 桶请求集与改动前**逐元素相同**(防回归);
+  - `test_visible_never_filtered` → `"visible"` 在任何桶都不被滤掉(它不在 `ACTION_RESOURCE_SCOPES` 里——`_normalize_action:366-370` 把它排除在 `REGISTERED_ACTION_CODES` 外、走 `batch_check_visible` 另一条路;滤掉 = **广场空列表**);
+  - `test_enrich_can_share_no_error` → 对 `aenrich_apps_can_share`(`:129-143`,无条件请求 `("share",)`)传含 `flow_type=35` 的 data → **不抛 `InvalidCatalogActionError`**(现象是 HTTP 200 + 业务码 **25001**,不是 500,design 坑 1)、`can_share` 为 `False`。这条覆盖坑 1 的另外 4 个调用方(`workstation/api/endpoints/apps.py:53` / `:150`、`api/v1/chat.py:83`、`workflow.py:962`),**只测广场是测不到的**。
+  **基础设施**: `test/workflow/` 已有 conftest 基线(`test_flow_dao_tenant_isolation.py` 同目录);本任务只 mock `batch_check_business_actions` 观察入参,不需中间件。
+  **覆盖 AC**: AC-04, AC-07
+  **依赖**: 无(跨 Feature 前置:F054 T059 / T060)
+
+- [x] **T005**: `[MVP-核心]` `_application_action_map` 第一层实现 + 按桶覆盖扩展点
+  **文件**: `src/backend/bisheng/api/services/workflow.py`(只改 `_application_action_map:146-178` 一个函数)
+  **逻辑**: 分桶(`grouped:152-155`,F054 T060 已加第三桶 `35: "app"`)之后、发 `batch_check_business_actions` 之前,**每个桶只请求对该 `resource_type` 合法的 action**(判据 `catalog_policy.ACTION_RESOURCE_SCOPES`),并**显式豁免 `visible`**。同时加可选参数 `actions_by_type: dict[str, tuple[str, ...]] | None = None` 做按桶覆盖,缺省 `None` = 全桶用 `actions` —— **另 6 个调用方一行不改**(design D3 爆炸半径表)。请求集为空的桶不发起 check(→ `can_share` 落回 `False`,`_apply_page_can_share:485` 与 `aenrich_apps_can_share:139` 都是「命中才 true」,AC-07 后半句零改组件达成)。
+  **⚠️ 不要顺手给 `app` 加 `share`**:托管应用没有 share-token 免登录通道(spec 决议-6),加 `share` 就是造一个不存在的能力(design K6)。
+  **⚠️ 第一层把「抛 25001」换成了「静默滤掉」**——这恰好是坑 10 想要的效果,但**坑 10 的显式排除(T011)仍要做,不得依赖这个副作用**(`use` 对 app 合法,应用中心传的正是 `use`)。
+  **测试**: T004 全部通过。
+  **覆盖 AC**: AC-04, AC-07
+  **依赖**: T003, T004
+
+### Wave 1 · `[MVP-核心]` 后端 · 广场状态收窄与载荷(Test-First 配对)
+
+- [x] **T006**: `[MVP-核心]` `FlowDao` 广场两参数单元测试
+  **文件**: `src/backend/test/workflow/test_flow_dao_square_params.py`(新)
+  **逻辑**: 对 `FlowDao.aget_all_apps`(`database/models/flow.py:508`)新增的两个**内部**参数断言:
+  - `test_status_exempt_flow_types` → 传 `status=2` + `status_exempt_flow_types={35}` 时,外层 status 条件(`flow.py:582 sub_query.c.status == status`)对第三支**不生效**、对另两支照旧(可对拼出的 SQL 文本断言或用 DAO 层构造断言);
+  - `test_app_state_in_narrows_third_branch` → 传 `app_state_in={"online","stopped"}` 时第三支子查询带 `App.state IN (...)` 条件,草稿 / 待上线 / 已删除**被挡在 SQL 层**;
+  - `test_defaults_unchanged` → 两参数都不传(构建页与另 3 个调用方的形态)时,拼出的 SQL 与改动前**逐字节相同**(`aget_all_apps` 有 4 个调用方:`get_all_apps:420` / `aget_all_apps:508` / `get_all_app_by_time_range_sync:810` / `get_first_app:849`)。
+  **覆盖 AC**: AC-02, AC-03
+  **依赖**: 无(跨 Feature 前置:F054 T059 第三支)
+
+- [x] **T007**: `[MVP-核心]` `FlowDao.aget_all_apps` / `_build_apps_subquery` 两个内部参数实现
+  **文件**: `src/backend/bisheng/database/models/flow.py`(`aget_all_apps:508` + `_build_apps_subquery:660-702`)
+  **逻辑**: 加 `status_exempt_flow_types: set[int] | None = None`(外层 status 条件由 `sub_query.c.status == status` 改为「`status == :status` **或** `flow_type ∈ 豁免集合`」)与 `app_state_in: set[str] | None = None`(第三支子查询侧的 `App.state` 收窄)。**两个参数缺省 `None` = 行为逐字节不变**,构建页与另 3 个调用方不传(design D9)。
+  **⚠️ 为什么不改 F054 的 status 投影**:把「已下线」也投成 2 会**打坏构建页**(`platform/controllers/API/flow.ts:204` 按 `status ∈ {1,2}` 筛「已上线 / 已下线」),且是修改 F054 已定稿的契约(design D9 备选 A 已否)。
+  **⚠️ 同文件并发**: `flow.py` 正被 F054 T059 编辑——落地前先完成 T003 ③;**合并时 rebase 而非覆盖**;第三支的手工 `build_tenant_filter_clause`(`:695` / `:698` 形态,docstring `:661-671` 明写「`.subquery()` 一包租户自动过滤即失效」)**归 F054 T059**,本任务不得删改(漏写 = 四条路径一起跨租户泄漏,design K1 / 坑 4)。
+  **回滚**: 纯可选参数增补,无 DDL;回滚 = 删两参数与其条件分支。
+  **测试**: T006 全部通过;另跑既有 `src/backend/test/workflow/test_flow_dao_tenant_isolation.py` 回归(K1 的四方法覆盖)。
+  **覆盖 AC**: AC-02, AC-03
+  **依赖**: T003, T006
+
+- [x] **T008**: `[MVP-核心]` 广场扫描页口径切换 + 载荷补字段单元测试(两个入口各断言一次)
+  **文件**: `src/backend/test/workflow/test_square_scan_page.py`(新)
+  **逻辑**: 对 `WorkFlowService._scan_visible_apps_page`(`workflow.py:401-478`)与其两个入口断言:
+  - `test_app_bucket_requests_use_edit` → app 桶请求 `("use","edit")`、另两类保持 `(action,"edit","share")`(`share` 由第一层裁,**此处不重复裁**);
+    - ⚠️ 2026-09-10 订正:合入 beta1 权限懒加载(`additional_actions=()`)后该用例改名 `test_square_buckets_ask_only_their_visibility_action`——广场页各桶只问自己的可见性动作(app 桶 `("use",)`、另两类 `("visible",)`),`edit` / `share` 不再在扫描期预取;`can_share` 由 `aenrich_apps_can_share` 另算(`test_square_page_defers_can_share` + `test_can_share_false_for_app`)。
+  - `test_kept_filter_per_row_type` → `kept` 筛选(`:450-455`)不再用外部传入的 `action` 一刀切,改为**按行的 `flow_type` 取该桶实际请求的可见性 action**(`app` 行看 `use`,其余行看 `action`)——这是 AC-06 同源的机器化护栏(入口用 `check_business_action("app", id, actor, "use")`,广场默认传 `visible`,二者在 FGA 里是两条不同关系,design K7 / 坑 2);
+  - `test_slug_and_app_state_batched` → 出口对 `flow_type==35` 的行**批量一次**回查补 `slug` / `app_state`,另两类为 `None`,且**不额外发起 N 次查询**;
+  - `test_both_entries_carry_slug`(**坑 21 / 坑 8 的护栏**)→ `get_online_flows_page:500` 与 `get_uncategorized_flows:977` 的返回体里托管应用行**都带** `slug` / `app_state`——后者从不调 `add_extra_field`(`:1012-1016` 只补 `logo` 后直接 `_apply_page_can_share` 返回),补在 `add_extra_field` 里就只对标签 tab 生效,而未分类 tab 正是 §7 指定的唯一验收面;
+  - `test_square_state_narrowing_hardcoded` → 两个入口函数在调用处**写死** `status_exempt_flow_types={35}` + `app_state_in={"online","stopped"}`,**不从 HTTP 层接收**(广场不新增任何 query 参数);
+  - `test_can_share_false_for_app` → `_apply_page_can_share:485` 对 `flow_type=35` 恒 `False`。
+  **覆盖 AC**: AC-01, AC-03, AC-06, AC-07
+  **依赖**: T005, T007
+
+- [x] **T009**: `[MVP-核心]` `_scan_visible_apps_page` 与广场两入口实现
+  **文件**: `src/backend/bisheng/api/services/workflow.py`(`_scan_visible_apps_page:401-478` + 两个入口函数 `get_online_flows_page:500` / `get_uncategorized_flows:977` 的调用处)
+  **逻辑**: 三件事一次改完(同一函数 + 其两个调用处,避免与 T005 重复修改同一段):
+  1. **口径切换**:`requested_actions:425` 由「一个全局元组」改为「按桶取值」,经 T005 的 `actions_by_type` 传 `{"app": ("use","edit")}`;`kept:450-455` 按行 `flow_type` 取该桶的可见性 action。
+  2. **载荷补字段**:返回整页之前,对 `flow_type==35` 的行批量一次回查 `app.slug` / `app.state` 补成 `slug` / `app_state` 两个载荷字段(**不能放 `add_extra_field:87-127`**——未分类 tab 不经过它,现象是点击落 `/apps/undefined`、没有「已下线」角标且不报错,坑 21)。
+  3. **状态收窄**:两个入口函数在调用 `aget_all_apps` 时**写死** `status_exempt_flow_types={35}` + `app_state_in={"online","stopped"}`(`api/v1/chat.py:63` 硬传 `FlowStatus.ONLINE(2)` 保持不变,草稿 / 待上线 / 已删除靠 `app_state_in` 挡在 SQL 层,**不必额外写权限收窄**,AC-03 的「不因用户是 owner 或管理员而例外」由此成立)。
+  **⚠️ 不在广场前端做任何数据过滤**:`hasMore = pageData.length >= pageSize`(`client/pages/apps/explore.tsx:74`),后端不返回 total——扫描外层再加过滤会造成「返回不足一页 → 前端判定没有更多 → 后面的应用永远刷不出来」(坑 9)。
+  **测试**: T008 全部通过。
+  **覆盖 AC**: AC-01, AC-02, AC-03, AC-06, AC-07
+  **依赖**: T008
+
+- [x] **T010**: `[MVP-核心]` 应用中心 / 最近使用排除托管应用 单元测试
+  **文件**: `src/backend/test/workflow/test_app_center_excludes_hosted.py`(新)
+  **逻辑**: 断言 `WorkFlowService.get_frequently_used_flows`(`workflow.py:924-965`,内部走 `filter_apps_by_action:663` + `action="visible"`)与工作台推荐位 / 常用列表(`workstation/api/endpoints/apps.py:41` / `:119`)的返回集中**不含 `flow_type=35`**。理由(坑 10):这两条路径的点击落 `/app/{chatId}/{id}/{flow_type}` 对话页(`client/pages/apps/hooks/useAppCenter.ts:74`),托管应用没有会话语义,用户点它会白屏或报错——**而这条路径不在任何 AC 里,没人会去测**。用例含「另两类数量与顺序不变」的回归断言。
+  **覆盖 AC**: AC-01, AC-07
+  **依赖**: T005
+
+- [x] **T011**: `[MVP-核心]` 应用中心 / 最近使用路径显式排除 `flow_type=35`
+  **文件**: `src/backend/bisheng/api/services/workflow.py`(`get_frequently_used_flows:924-965` 一处过滤), `src/backend/bisheng/workstation/api/endpoints/apps.py`(推荐位 `:41` / 常用 `:119` 两处调用的类型入参)
+  **逻辑**: 在这两条「进对话页」的路径上显式排除托管应用(**不要依赖 T005 第一层的静默滤除副作用**——`use` 对 app 合法,托管应用照样会流进去)。排除位置取「类型闸」而非前端,保证工作台 / 应用中心两个消费者一次到位。
+  **测试**: T010 全部通过。
+  **覆盖 AC**: AC-01, AC-07
+  **依赖**: T010
+
+### Wave 1 · `[MVP-核心]` 后端 · 可见范围变更审计(Test-First 配对)
+
+- [x] **T012**: `[MVP-核心]` 可见范围变更审计回调 单元 / 集成测试
+  **文件**: `src/backend/test/app_runtime/test_visibility_change_audit.py`(新,`test/app_runtime/` 已存在)
+  **逻辑**: 对 `ResourcePermissionApi.mutate_grants`(`permission/application/resource_api.py:290`)新增的回调链断言:
+  - `test_callback_registered_for_app` → 起最小 app 上下文后注册表里有 `app` 项(**未注册是静默失败、无任何报错**,design D6);
+  - `test_audit_written_once_on_grant` → ADD 一个用户组后审计表恰好 **1 条** `app.visibility_change`,字段 = `target_type='app'` / `target_id=app.id` / `object_name=app.name` / `metadata={app_slug, added[], removed[], model_keys[]}` / `operator_*` = **变更人本人**;
+  - `test_idempotent_replay_writes_nothing` → 重放同一 `idempotency_key` **不新增记录**(`ProjectionOutcome.idempotent`,`permission/domain/services/projection_plan.py:58-64`;不判这个标志 = 审计页出现「同一秒改了三次可见范围」的假记录,坑 12);
+  - `test_removed_carries_subject_identity`(**坑 11 的机器化断言**)→ ADD 一个用户组 → REMOVE 它 → `metadata.removed[0]` 是 `{type:"group", id:…}` 而**不是** `{assignee_id:…}`;
+  - `test_pure_add_does_not_preread_roster` → 没有 REMOVE / MOVE 的纯 ADD 请求**不触发**名册预读(调用计数 / mock 断言,防止把成本加在最常见的路径上);
+  - `test_tenant_admin_operator_is_self` → 租户管理员代 owner 调整时变更人记该管理员本人(AC-14);
+  - `test_non_owner_denied` → 非 owner 非管理员调 mutate → 被 `_target(..., "manage_permission")` 拒(AC-15 的**后端**闸,前端闸只是体验不是安全边界);
+  - `test_audit_failure_does_not_rollback_grant` → 审计写入抛错时授权动作照常成功(`ainsert_v2` 自带独立 session + commit 且写失败会抛,坑 14)。
+  **覆盖 AC**: AC-13, AC-14, AC-15, AC-22
+  **依赖**: T001
+
+- [x] **T013**: `[MVP-核心]` 授权变更回调注册表 + 名册预读(permission 侧扩展点)
+  **文件**: `src/backend/bisheng/permission/application/resource_api.py`(`ResourcePermissionApi.mutate_grants:290-372`)
+  **逻辑**: 加模块级注册表 `{resource_type: async callback}`,`mutate_grants` 成功返回前按 `resource_type` 查表并调用(有则调、无则跳过),回调入参 `actor / target / changes / result`。**前置的名册预读**(坑 11):仅当 ① 该 `resource_type` 注册了回调、且 ② 本次 `changes` 里存在 `REMOVE` / `MOVE` 时,在 `self._runtime.mutate_grants(...):337` **之前**用既有读法 `list_permission_sources_page(actor, target, after_id=0, limit=…)`(`:190` 同款)取一页名册,建 `source_id → (subject_type, subject_id)` 索引供回调反查;名册 `has_more` 为真时对超出部分只写 `{assignee_id}` 并在 `metadata` 打 `roster_truncated: true`,**宁可标注不完整也不做多页扫描**。
+  **⚠️ 为什么必须预读**:`remove_source` 把被撤销的 source **整行丢弃**(`permission/domain/services/grant_source_service.py:279` / `:305-311` 的 `remaining` + `replace(...)`),不是标 `active=False` 保留 → `GrantMutationResult.grants`(`grant_service.py:232`)里**查无此人**;而请求体侧 `REMOVE` / `MOVE` 只带 `assignee_id` + 版本(`domain/schemas/f048.py:242-262 validate_operation_shape`)。两头都拿不到主体身份,「从结果快照反解」已被证伪(design D6 审查修订)。
+  **⚠️ 依赖方向**: 本文件**不认识 `app.*` 命名空间**——写入器由 `app_runtime` 侧提供(T014)。不得在此按 `resource_type == "app"` 直接写审计(那会让权限模块知道应用工场的命名空间,且将来变成一串 if/elif,design D6 备选 B 已否,C1)。
+  **⚠️ 本期只注册 `app` 一个类型**:知识库 / 工作流的授权今天完全不写审计,**不顺手给它们加**(那是范围外的行为变更)。
+  **测试**: T012 的注册表 / 预读 / removed 主体身份三组用例通过。
+  **覆盖 AC**: AC-13, AC-15, AC-22
+  **依赖**: T012
+
+- [x] **T014**: `[MVP-核心]` `app_runtime` 审计写入器 + 组合根显式注册
+  **文件**: `src/backend/bisheng/app_runtime/domain/services/visibility_audit.py`(新), `src/backend/bisheng/main.py`(`lifespan:82-84` 加一次显式注册调用,照 `_register_permission_runtime_contexts:55-77` 先例)
+  **逻辑**: 写入器组装并调 `AuditLogDao.ainsert_v2`(`database/models/audit_log.py:428`)写 `action='app.visibility_change'`,字段口径见 T012。三条硬规:① `if outcome.idempotent: return`(坑 12);② **不要再包事务**——`ainsert_v2` 自带 `bypass_tenant_filter()` + 独立 async session + commit(`:494-500`);③ 整个写入用 try/except 包住、失败只打 **warning**(照 `approval/domain/services/approval_outbox_service.py:105-121` 的「审计失败不影响主流程」包法),日志字段 `resource_type / resource_id / operator_id / reason`——**这条 warning 是「审计静默丢失」的唯一信号**。
+  **⚠️ 注册落点 = 组合根显式注册,不得依赖模块 import 副作用**(否则注册与否取决于 router 加载顺序,一次 import 图重排就静默失效)。注册成功打一条 info(`registered visibility-change audit hook for resource_type=app`)——114 查不到事件时**先 grep 这条启动日志**,再怀疑写入逻辑。
+  **⚠️ 进程边界**: `mutate_grants` 的唯一触发源是 `permission/api/endpoints/grant.py:113`,只跑在 **API 进程**,注册挂 API `lifespan` 即充分;celery / linsight worker 内不注册**不是缺陷**(无触发源)。将来若有 worker 内调用方改授权,注册必须跟着搬。
+  **测试**: T012 全部通过(含审计失败不回滚、幂等不重复写)。
+  **覆盖 AC**: AC-13, AC-14, AC-22
+  **依赖**: T013
+
+### Wave 1 · `[MVP-核心]` 后端 · 跨 Feature 兜底与端到端
+
+- [x] **T015**: `[MVP-核心]` 审计事件登记断言测试(AC-27 的跨 Feature 兜底)
+  **文件**: `src/backend/test/app_runtime/test_audit_action_registry_lockstep.py`(新)
+  **逻辑**: 参数化读取**全部已注册**的 `app.*` / `app.release.*` action 常量(`app_runtime/domain/constants.py` 的 `AppAuditAction` + F055 的 release action 枚举),逐条断言四处 lockstep 齐备:① 在 `_UI_VISIBLE_V2_ACTIONS`(`database/models/audit_log.py:193`)里;② 在 `platform/src/controllers/API/log.ts` 的 `actions` 数组里(读文件做文本匹配);③④ 三语 `platform/public/locales/{zh-Hans,en-US,ja}/bs.json` 的 `log.eventTypeEnum` 里都有对应 `actionToI18nKey(action)`(`split(/[._]/)` + 驼峰化)的 key。
+  **为什么这条价值最高**:它一存在,F054 / F055 / F049 将来漏登记会在 **CI 上立即失败**,而不是等到有人在审计页里找不到事件——AC-27「不存在已写入但页面查不到的事件类型」的判定归本 Feature,故必须机器化。本轮它同时把 F054 已写的 `app.publish` / `app.stop` / `app.resume` 等九条与 F055 的 `app.release.*` 一并纳入断言(= MVP-核心承诺的「发布 / 上线 / 可见范围变更三类事件可查」)。
+  **覆盖 AC**: AC-19, AC-20, AC-27
+  **依赖**: T002
+
+- [x] **T016**: `[MVP-核心]` 广场端到端集成测试(**非管理员账号**)
+  **文件**: `src/backend/test/workflow/test_square_hosted_app_e2e.py`(新,pytest + httpx,连 test 中间件 MySQL / Redis / OpenFGA,CI 跑)
+  **逻辑**: 用**非管理员、非 owner 的普通用户**跑 `GET /api/v1/chat/online` 与 `GET /api/v1/workstation/app/uncategorized`:
+  - `test_grant_then_revoke_visibility` → 授权前 0 条托管应用、授权后 1 条、撤销后 0 条,且生效发生在**下一次请求**(无需重新登录、不依赖任何缓存到期,AC-05);
+  - `test_square_and_entry_same_source` → 同一普通用户对同一应用调 F054 入口判定 `check_business_action("app", app_id, actor, "use")` → 与广场结果**同真同假**(AC-06 的机器化断言,禁止「广场看得见、点进去无权限」稳态);
+  - `test_state_filter` → 已下线应用**仍出现**且 `app_state='stopped'`;草稿 / 待上线 / 已删除**不出现**,且不因用户是 owner 或管理员而例外(AC-02 / AC-03);
+  - `test_payload_shape` → 托管应用行带 `slug`、`user_name`(owner 显示名,决议-3)、`can_share=False`,另两类载荷形状不变(新字段为 null / 既有值);
+  - `test_tenant_isolation` → A 租户用户查不到 B 租户的托管应用(坑 4 回归;**测试若只用超管跑永远发现不了**);
+  - `test_manage_dialog_denied_for_grantee` → 仅被授予可见范围的用户调 `GET/POST /api/v1/permissions/resources/app/{id}/grants*` → 403(AC-15:被授予可见范围只获得广场可见与入口访问,不获得任何管理入口)。
+  **覆盖 AC**: AC-01, AC-02, AC-03, AC-04, AC-05, AC-06, AC-07, AC-15
+  **依赖**: T009, T011, T014
+  ✅ 2026-09-10 核实已落地(`src/backend/test/workflow/test_square_hosted_app_e2e.py` 7 例:本任务所列 6 例 + `test_uncategorized_tab_shows_untagged_hosted_app`)——**代码齐、需 `F056_E2E=1` 对真实部署(MySQL / Redis / OpenFGA)跑**,本机无中间件时整文件 skip;**114 重建后执行**
+
+### Wave 2 · `[MVP-核心]` 前端 Client(手动验证)
+
+- [x] **T017**: `[MVP-核心]` Client · 托管应用图标与列表项类型
+  **文件**: `src/frontend/client/src/components/Avator/index.tsx`(`flowConfig:30-43` 加第三支), `src/frontend/client/src/@types/app.ts`(列表项类型加 `slug?: string` / `app_state?: 'online' | 'stopped'`,`flow_type` 注释补 35)
+  **逻辑**: `flowConfig` 今天只有 `1 / 5 / 10` 且 `flowConfig[flowType] || flowConfig[5]` **默认回落助手图标**(`:45`)——不加 35 则广场里托管应用**显示成助手图标,且没有任何报错**(坑 6)。
+  **⚠️ 这是 client 侧那份图标 map**,与 F054 T063 改的 `platform/src/components/bs-comp/cardComponent/avatar.tsx` **不是同一文件**——「F054 说图标已经加了」是误判来源。
+  **手动验证**: 见 T021 步 3(普通账号广场里托管应用卡片图标不是助手图标)。
+  **覆盖 AC**: AC-01
+  **依赖**: 无(跨 Feature 前置:F054 T059 的 `FlowType.HOSTED_APP=35`)
+
+- [x] **T018**: `[MVP-核心]` Client · 广场卡片跳转分流 + 「已下线」角标 + 未部署形态文案闸
+  **文件**: `src/frontend/client/src/pages/apps/explore.tsx`(`handleCardClick:132-146` 分流 + 未部署 guard), `src/frontend/client/src/pages/apps/components/AgentCard.tsx`(`app_state==='stopped'` 时渲染「已下线」标识)
+  **逻辑**:
+  1. **跳转分流**:`flow_type===35` 时走 `window.location.assign('/apps/' + agent.slug)`(整页跳、**当前窗口**、**不带 base**),且**不写** `sessionStorage` 的 `appFlowOriginKey` / `appLastOriginKey`(那是对话页返回用的,托管应用离开 SPA 后不回来);其余类型走原有 `navigate('/app/...')` 一行不改。
+  **⚠️ 三个必错写法**:`navigate('/apps/…')` 会自动前置 basename → `/workspace/apps/{slug}` **404**(client vite `base = /workspace`,`client/vite.config.ts:127`);拼 `__APP_ENV__.BASE_URL` 同样 404;**同目录的 `appUtils.ts:67-71 getAppShareUrl` 恰恰是反例**(它主动拼 `BASE_URL`),照抄它就会把 `/workspace` 加回去,且因为「抄的是仓内既有写法」而更难被质疑(坑 3)。
+  2. **「已下线」角标**:由数据字段 `app_state` 驱动(AC-03 / 决议-5:保留卡片并标注,隐藏会让用户误判为被移出可见范围)。`AgentCard` 是**广场与「应用中心」共用组件**,改它要同时想两个页面;**分享按钮不加类型判断**(靠后端 `can_share=false` 数据闸,零改组件达成 AC-07,坑 7)。
+  3. **未部署形态**:消费 F054 T071 的 `useAppRuntimeEnabled`(react-query v4 hook;**不得 `useRecoilValue`**——client 的 recoil 已被 lint 冻结,`client/eslint.config.mjs:47`,坑 17)仅用于隐藏与托管应用相关的**文案 / 空态提示**;**前端不做任何数据过滤**(开关关时后端天然无 `app` 行,双保险的数据侧;前端过滤会撞上「不足一页 → 判定没有更多」的无限滚动缺陷,坑 9)。
+  **手动验证**(用**非管理员、非 owner 普通账号**,见 T021 步 3–4):
+  - client 广场「未分类」tab 看到该托管应用卡片 → 点击 → 浏览器地址栏变成 `http:///apps/`(**没有 `/workspace`**)→ 应用页面正常渲染;
+  - 卡片上**没有分享按钮**;
+  - owner 下线后刷新广场 → 卡片**仍在**且带「已下线」标识,点击落 F054 的「已下线」页;重新上线后标识消失;
+  - `app_runtime_enabled=false` 的环境(或临时改 `/api/v1/env` 返回)下广场无任何托管应用卡片与相关文案,**广场其余行为零变化**。
+  **覆盖 AC**: AC-01, AC-03, AC-07, AC-10
+  **依赖**: T009, T017
+
+### Wave 2 · `[MVP-核心]` 前端 Platform(手动验证 + 纯逻辑单测)
+
+- [x] **T019**: `[MVP-核心]` Platform · 「仅 owner 可见」判据纯函数 + 单测
+  **文件**: `src/frontend/platform/src/pages/BuildPage/hostedApp/publish/visibilityScope.ts`(新,纯函数 `isOwnerOnly(appState, grants)` / `summarizeGrants(grants, hasMore)`), `src/frontend/platform/src/pages/BuildPage/hostedApp/publish/visibilityScope.test.ts`(新)
+  **逻辑**: 判据 = `appState === 'online' && grants.filter(a => !a.protected).length === 0`。
+  **⚠️ 不能用 `grants.length === 0`**:F048 在应用创建时给 owner 投一条 **protected** 授权(`app_runtime/domain/services/f048_app_permission.py:168-186`,`mode="CUSTOM", protected=True`),刚上线的应用 grants **恰好有一条** → 提示条**永远不出现且无任何报错**,owner 于是漏掉「设可见范围」这一步、同事在广场什么也看不到——正是 PRD 要用这条提示解决的问题(坑 13)。
+  **⚠️ `app_state === 'online'` 前置不能省**:AC-12 原文是「WHILE 应用**已上线**且可见范围仍为『仅 owner 可见』」;不加前置,草稿 / 待上线态详情页也会常驻「同事无法在广场看到」——那句话在未上线时本来就成立且无从解决,是纯噪音。
+  **摘要口径**: 只判/只取 grants **首页**(`page_size` 默认 50,`has_more`)——首页 50 条里若一条非 protected 都没有,后面更不会有(protected 行数是个位数);摘要文案取 `has_more ? 'N+' : N`。
+  **测试**: `test_owner_only_when_single_protected_row`(1 条 protected → true)/ `test_not_owner_only_with_grant`(1 protected + 1 普通 → false)/ `test_draft_never_shows_banner`(`app_state='draft'` + 0 非 protected → false)/ `test_summary_plus_when_has_more`。
+  **覆盖 AC**: AC-12
+  **依赖**: 无(跨 Feature 前置:F054 T067 的发布 tab slot)
+
+- [x] **T020**: `[MVP-核心]` Platform · 可见范围区组件(授权弹窗第二触发点)
+  **文件**: `src/frontend/platform/src/pages/BuildPage/hostedApp/publish/VisibilityScopeSection.tsx`(新,填 F054 T067 交付的发布 tab slot), `src/frontend/platform/public/locales/{zh-Hans,en-US,ja}/bs.json`(三语视为一组:可见范围区标题 / 提示条 / 摘要 / 设置按钮文案)
+  **逻辑**: 区块只做四件事:① 读状态 `getResourcePermissionGrantsApi('app', app.id)`(`platform/src/controllers/API/permission.ts:313-327`);② 用 T019 的纯函数渲染**「仅 owner 可见」常驻提示条**(文案必须含「仅 owner 可见」与「同事无法在广场看到」两个要素,**不是一次性 toast**)或已授主体摘要——可见范围区本身**常驻**,未上线时只是不出提示条;③ 提示条内与区块内各有一个设置按钮,点击 `setOpen(true)` 拉起 ``(`platform/src/components/bs-comp/permission/PermissionDialog.tsx:26-32`,**组件零改动、无 per-type 分支**);④ 弹窗关闭后**重新拉一次 grants** 刷新摘要(不做乐观更新——授权是低频动作,一次往返换准确性)。
+  **⚠️ 不碰 `PublishTab.tsx` 本体**:F054 T067 / F055 已三方约定发布 tab 用 slot / children 留位,直接改本体等于毁约且并发期必冲突(design D4 备选 A 已否)。
+  **⚠️ 前端权限闸的正确写法**:`can_manage_permission`(`GET .../context`)为 false 时**根本不发起 grants 请求**——platform 拦截器对 GET 的 403/404 会**整页跳** `/403` / `/404`(`platform/src/controllers/request.ts:160-166`),用户会被甩出整个详情页而不是看到区块内提示(坑 16)。前端闸只是体验,**安全边界是后端 403**(AC-15)。
+  **⚠️ 状态管理**: platform 的 react-query v3 已被 lint 冻结(`platform/eslint.config.mjs:45,51`)→ 用 `useState + useEffect` / `useTable`,**不得照抄同目录的 `useQuery`**(坑 17)。
+  **手动验证**(owner 账号,见 T021 步 1–2):
+  - 构建 → 应用 → 托管应用卡片 ⚙️ →「管理权限」→ 弹窗出现(`resourceType=app`),选人框里**不出现服务账号**(INV-29,服务账号为独立 `service_account` 表、不在 `user` 表内〔2026-09-10 改接 beta2,迁移方案 M4〕,回归验证不是新工作);
+  - 应用详情页 · 发布 tab → 首发上线后未设可见范围时**顶部常驻提示条**;点提示条内设置按钮 → 拉起**同一个**弹窗;授予后提示条消失、改为摘要呈现;把授权全部撤销后刷新 → 提示条**重新出现**(AC-12 双向验证);
+  - 两个入口保存后的可见范围结果一致(AC-11);
+  - 换非 owner 非管理员账号打开详情页 → 无管理入口、直接调 grants 接口得 403 且**不被甩去 `/403` 整页**(AC-15)。
+  **覆盖 AC**: AC-11, AC-12, AC-13, AC-15
+  **依赖**: T019
+
+### Wave 2 · `[MVP-核心]` 114 部署与手动验证
+
+- [ ] **T021**: `[MVP-核心]` 114 部署与 MVP-核心闭环手动验证清单(演示剧本步 5–6)
+  **文件**: 无代码产出(验证记录回填本任务表格;`/apps/` location 由 F054 交付于 `/etc/nginx/conf.d/bisheng-lilu.conf`)
+  **前置**: `curl -s http://127.0.0.1:7860/api/v1/env | jq .app_runtime_enabled` → `true`;`curl -I http://127.0.0.1:4101/apps/` 非 404 的 SPA 页;后端启动日志里有 `registered visibility-change audit hook for resource_type=app`(T014,**查不到审计事件时先 grep 这条**)。⚠️ 外网 13000 是静态 build 快照,需手动重建。
+  **验证清单**:
+
+  | # | 账号 / 位置 | 步骤 | 期望 | AC |
+  |---|---|---|---|---|
+  | 1 | **owner** · platform 构建 → 应用 → 卡片 ⚙️ | 「管理权限」→ 授予「全员用户组 / 根部门」→ 保存 | 弹窗为平台通用授权弹窗(`resourceType=app`),选人不出现服务账号;保存成功 | AC-11 |
+  | 2 | owner · 应用详情页 · 发布 tab | 看可见范围区;再把授权全部撤销后刷新 | 授权后显示**摘要**;撤销后「仅 owner 可见」提示条**重新出现**(含「同事无法在广场看到」) | AC-12 |
+  | 3 | **⚠️ 非管理员、非 owner 普通账号** · client 应用广场 | 进**「未分类」tab**(AC-08 的正确验收面——广场默认 tab 是首个首页标签、不是未分类,`AgentNavigation.tsx:59`;无标签的卡片会显示硬编码的「精选」标签,坑 8) | 看到该应用卡片;图标**不是助手图标**;卡片上**没有分享按钮**;按**名称**搜索能搜到(描述搜索本轮不做,design D10 已知偏差) | AC-01, AC-04, AC-07, AC-08 |
+  | 4 | 同上 | 点击卡片 | 地址栏变成 `http:///apps/`(**没有 `/workspace`**)→ 应用页面正常渲染,当前窗口、不新开标签 | AC-07 |
+  | 5 | owner 改一次应用名称 / 描述(CLI deploy 或 F054 元信息更新)→ 普通账号**刷新广场** | 观察卡片 | 即见新值,不需重登、不需清缓存(**零代码**,广场直读表行、无缓存层) | AC-09 |
+  | 6 | owner 下线 → 普通账号刷新广场;再重新上线 | 观察卡片 | 下线后卡片**仍在**且带「已下线」标识,点击落「已下线」页;重新上线后标识消失 | AC-03 |
+  | 7 | owner 撤销该普通账号的可见范围 | 普通账号刷新广场 | **下一次请求**即看不到,无需重新登录 | AC-05 |
+  | 8 | 普通账号 | 直接调 `GET /api/v1/permissions/resources/app/{id}/grants` | 403;界面上无任何管理入口 | AC-15 |
+  | 9 | **管理员账号**(`is_admin()` 或持日志菜单权限——否则查不到任何 v2 事件,坑 15)· platform 系统操作日志 | 模块选「应用工场」→ 事件类型选「可见范围变更」 | 查到步 1 / 步 7 两条记录:操作人 = 变更人本人、对象 = 应用名;三语切换文案不为空、不显示 `app.visibility_change` 原始串 | AC-14, AC-22, AC-27 |
+  | 10 | 同上 | 同一筛选面下查「上线」「下线」事件(F054 / F055 写入) | 能查到(MVP-核心承诺的三类事件可查;机器化护栏见 T015) | AC-19, AC-20, AC-27 |
+  | 11 | **租户管理员**(非超管)· platform 构建 → 应用 → 某他人 owner 的托管应用卡片 ⚙️ | 「管理权限」→ 调整可见范围 | 可查看并调整(既有身份短路,非本 Feature 新增);审计中变更人 = 该管理员本人 | AC-14 |
+
+  > 记录(部署时填):部署时间 = ;commit = ;逐条结果 = 。
+  **覆盖 AC**: AC-03, AC-05, AC-08, AC-09, AC-10, AC-11, AC-12, AC-14, AC-15, AC-19, AC-20, AC-22, AC-27
+  **依赖**: T016, T018, T020
+
+---
+
+### Wave 3 · 顺延 · 审计查询面与导出(GOV-04 界面;只列标题 / 文件 / 覆盖 AC,实施前再展开)
+
+> 顺延依据 `mvp-114-path.md` §6 F056 行「审计查询面扩展、导出、超管租户筛选」;**属本 Feature 范围、不得被裁掉**。做的顺序是「对象应用筛选 → 租户筛选 → 导出」——没有第一件,「按应用追溯」这句产品语言不成立(design §8)。落点方向:`api/v1/audit.py:13-27` 与 `AuditLogDao.get_audit_logs`(`database/models/audit_log.py:288-374`)**今天完全没有 `target_type` / `target_id` 维度**,是从零新增而非改配置。
+
+- [x] **T022**: 审计查询「对象应用」筛选(后端查询参数 + DAO 过滤 + 已删除应用的名称快照)
+  **文件**: `src/backend/bisheng/api/v1/audit.py`, `src/backend/bisheng/database/models/audit_log.py`, `src/backend/bisheng/api/services/audit_log.py`
+  **覆盖 AC**: AC-21, AC-28
+  **证据**(分支 `wt/audit-query`):`GET /api/v1/audit?target_app_id=` → `AuditLogDao._object_app_predicate`(`target_type='app'+target_id` **或** `metadata.app_id`,两种写入形态都命中;新增 `dialect_helpers.json_object_field_equals` 兼容 MySQL / DM8);名称快照 = 行自身 `object_name`,应用行已删也不丢。测试 `test/audit/test_audit_log_object_app_filter.py`。
+  **偏差**:顺手修了 design 坑 15——v2 行 `group_ids` 恒 NULL,用户组筛选按「所选组当前成员 = operator_id」补配(`group_member_ids`),仅在传了组筛选时多一次成员查询;列表排序补 `id DESC` 次级键(backend AGENTS.md)。
+
+- [x] **T023**: 按应用名 / 标识检索的选择器端点 + Platform 审计页筛选控件(含已删除应用可选中)
+  **文件**: `src/backend/bisheng/api/v1/audit.py`, `src/frontend/platform/src/pages/LogPage/systemLog/index.tsx`, `src/frontend/platform/src/controllers/API/log.ts`
+  **覆盖 AC**: AC-28
+  **证据**:`GET /api/v1/audit/apps?keyword=&limit=`(`AppDao.asearch_for_audit`,含 `state='deleted'`,租户管理员只搜本租户);页面「选择对象应用」控件走后端检索(`getAuditAppsApi`),已删除应用带「已删除」标记。测试 `test/audit/test_audit_app_search.py`、`platform/src/test/auditRowRendering.test.ts`。
+
+- [x] **T024**: 审计列表列扩展(对象列显示应用名 + 标识 / 名称快照;操作人列对服务账号显示账号名 + 密钥掩码并附 owner)
+  ⚠️ **2026-09-10 现状(改接 beta2 底座后,本任务的输入条件已变)**:CLI 发布的 `app.release.*` 事件里,只有 SUBMIT 那条带 `operator_name`(=服务账号名),其余三条(审批通过 / 上线 / 失败)只有 `operator_id=0` 且无名字,审计页会渲染成「system」。原因是 `app_deployment` 没有能承载服务账号身份的列(`submitted_by_user_id` 是自然人 id 空间,服务账号已不在 `user` 表),而后三条事件在 Celery 腿里写、拿不到调用期的 principal。**本任务落地前需先定两件事**:① 产品口径——这些事件的操作人应显示服务账号名还是 system;② 若要显示账号名,需给 `app_deployment` 加一列承载「发起主体(kind + id + name)」并回填给 `write_release_audit`。
+  **文件**: `src/frontend/platform/src/pages/LogPage/systemLog/index.tsx`, `src/frontend/platform/public/locales/{zh-Hans,en-US,ja}/bs.json`
+  **覆盖 AC**: AC-17, AC-29
+  **证据**:后端 `AuditLogService._enrich_audit_rows` 只吐派生字段(`app_id/app_name/app_slug/app_state/app_owner_name/version_no/tenant_name/operator_kind/operator_key_mask`),**不吐原始 `metadata`**(AC-26);服务账号识别三种形态(写入方今天实际落的 `metadata.actor_kind='service_account'`〔`OpenApiAuditMiddleware` / `app.release.submit`〕、`metadata.operator.kind='service_account'`、或 beta2 约定 `operator_id=0` + 有名字)。密钥掩码:优先转发写入方已产出的 `metadata.operator.key_mask`;否则按写入方落的 `metadata.credential_id` 批量读 `api_credential` 行,用其**已存**的 `key_prefix + last4` 拼掩码(`CredentialRepository.get_by_ids`,绕过租户过滤;明文从未落库,无更长值可泄)——审查时补,此前掩码字段没有任何写入方会填、永远为空。前端渲染规则集中在 `systemLog/auditRow.ts`(表格与 CSV 共用),三语 key 在 `bs.json` `log.*`。测试 `platform/src/test/auditRowRendering.test.ts`、`test/audit/test_audit_export.py::TestExportContent::test_mask_resolved_from_credential_id_for_writer_shape`。
+  **附带**:`platform/src/test/auditSingleTenantFilter.test.ts` 原硬编码 `app.release.*` 数量 16(家族已增至 17)导致基线常红、把 i18n 齐全性检查挡在后面;改为与 `V2_ACTIONS` 登记对齐,现通过(AC-27 的三语齐全性由该用例实际验证)。
+  **偏差(保留上方 ⚠️ 的两件事,本任务不解)**:审批通过 / 上线 / 失败三条无名 `operator_id=0` 行仍显示「system」——那是写入侧缺列,不在本任务造假补名。
+
+- [x] **T025**: 系统操作审计导出端点 + Platform 导出按钮(导出当前筛选条件下的全部记录,受与查询完全相同的租户与角色边界;内容不含密钥明文)
+  **文件**: `src/backend/bisheng/api/v1/audit.py`, `src/frontend/platform/src/controllers/API/log.ts`, `src/frontend/platform/src/pages/LogPage/systemLog/index.tsx`
+  **覆盖 AC**: AC-26, AC-32
+  **证据**:`GET /api/v1/audit/export/data`(与列表同一组查询参数,走同一个 `_prepare_audit_query` 门与投影,单次上限 `AUDIT_EXPORT_MAX_ROWS=10000`,响应带未截断 `total`);前端「导出」按钮按**当前已应用**的筛选导出 CSV(`buildAuditCsv`,列与表格一致;超上限 toast 提示缩小范围)。测试 `test/audit/test_audit_export.py`(含「密钥明文绝不出现在导出」用例)。
+
+- [x] **T026**: 平台超管租户筛选与租户列 + 租户管理员边界拒绝(「对象应用」筛选不得穿透到其它租户)
+  **文件**: `src/backend/bisheng/database/models/audit_log.py`(`_visible_for_tenant`), `src/backend/bisheng/api/services/audit_log.py`(`_get_audit_tenant_scope`), `src/frontend/platform/src/pages/LogPage/systemLog/index.tsx`
+  **覆盖 AC**: AC-30, AC-31
+  **证据**:查询 / 导出 / 应用检索共用 `tenant_id` 参数:全局超管(且无 F019 admin-scope)可指定;其他人指定非本租户 → `UnAuthorizedError`(不是静默缩窄);`target_app_id` 指向他租户应用同样拒绝(`_assert_app_in_scope`,绕过自动租户过滤查应用行,否则会退化成空列表而非拒绝)。前端租户筛选与租户列仅在 `multiTenantEnabled && is_global_super && 无 admin-scope` 时出现。测试 `test/audit/test_audit_log_object_app_filter.py::TestServiceTenantBoundary`、`test_audit_log_tenant_scope.py`(新增三条 tenant_id 用例)、`test_audit_export.py::TestExportBoundary`。
+
+- [x] **T027**: 审计查询面角色边界与「单一查询入口」回归(仅租户管理员及以上;owner / 普通用户调查询与导出接口被拒;高频三类事件与低频事件同面经事件类型筛选可达)
+  **文件**: `src/backend/test/audit/test_audit_scope_and_roles.py`(新), `src/backend/bisheng/api/services/audit_log.py`
+  **覆盖 AC**: AC-33, AC-34
+  **证据**(分支 `wt/f056-verification`):`test/audit/test_audit_scope_and_roles.py` 8 例——owner / 普通用户调 `get_audit_log` / `export_audit_log` / `search_audit_apps` 三处一律拒(AC-33,导出与列表共用 `_prepare_audit_query` 由「同一个拒绝」证明,不是读源码推断);租户管理员与持 `log` 菜单的角色可读;用户组管理员维持既有口径(v2 行按 `group_member_ids` 命中,不越出本组)。AC-34 behavioural 半边同文件(`app.visibility_change` 低频事件与 `app.release.capability_declared` 运行期事件只靠 `event_type` 区分、同一次调用、对象应用筛选一次取回两条)+ `AppAuditAction` / `AppReleaseAuditAction` 全员在白名单上;structural 半边落 `test/api/test_audit_query_entry_surface.py`(路由普查:`/api/v1/audit` 家族恰好六条 GET,且不存在 `access-log` / `call-record` 之类按事件族另开的查询页)。
+  **偏差**:路由普查**不能**放在 `test/audit/`——该目录在模块级把 `bisheng.api.router` / `bisheng.telemetry_search` 塞进 `sys.modules` 以便脱离 App 跑 DAO,普查需要真 App,落 `test/api/`(与同目录 `test_route_path_naming.py` 同款)。
+  **评审订正(2026-09-16)**:原偏差还写了「顺带把 `test_audit_log_tenant_scope.py` 的桩改成导完即摘,修好了 `test/api/test_route_path_naming.py` 的 collection error」——**这两句都不成立,且那次改动带进了回归**。① 「导完即摘」3.0-vibe 上**本来就有**(发版线自己的提交),本切片是在合并时把发版线那一侧覆盖掉又写了一遍;② 覆盖的同时丢掉了发版线同一次提交里的另一半——`_insert_audit` 的 `system_id="system"` 默认值与末尾那处显式 `system_id`,没有它种子行过不了 `AuditLogDao._ui_visible_predicate()`,于是 `test_audit_log_tenant_scope.py` **33 例挂 14 例**(`assert 0 == 5`);③ 所谓 collection error 在正确环境下复现不出来(发版线 `pytest test/audit test/api` = 109 passed,零 error)——当初大概率是没清 SOCKS 代理变量、`premock_import_chain` 预加载失败造出来的假象(`src/backend/AGENTS.md` 记过这个坑)。处置:`git checkout 3.0-vibe -- src/backend/test/audit/test_audit_log_tenant_scope.py` 原样取回发版线版本(本切片在该文件上没有任何需要保留的改动)。复核后 `test/audit` = **131 passed / 2 xfailed / 0 failed**(发版线 71 passed / 0 failed)。**教训按仓库 AGENTS.md §7**:合并时对双方各自新增内容的文件,不能整份取一侧——要么逐 hunk 解,要么解完与来源分支逐文件 `git diff` 复核。
+
+- [x] **T028**: 各写入方事件的可查性验收(CLI 首发导入 / 发布类含审批单四终态 / 下线 · 重新上线 / 删除后仍可查 / 元信息修改 / 访问记录 / 密钥事件 / 运行期凭据与能力声明 / 生产数据行编辑 / 模型调用逐条;并验证记录与响应不含密钥明文)
+  **文件**: `src/backend/test/audit/test_hosted_app_event_queryability.py`(新)
+  **覆盖 AC**: AC-18, AC-19, AC-20, AC-21, AC-23, AC-24, AC-25, AC-26
+  **证据**:54 例。20 类托管应用事件 + 3 类密钥事件按**写入方实际落的行形状**逐族三断言(在 `_UI_VISIBLE_V2_ACTIONS` 上 / 被「对象应用」筛出 / 被「事件类型」筛出,且他应用同类行不混入);已删除应用三条历史仍可查且对象列回落 `object_name` 名称快照(AC-21);发布族(`metadata.app_id`)与状态族(`target_type='app'`)在一次对象应用查询里合成同一条时间线(AC-19);AC-26 = 响应不含原始 `metadata`(往 metadata 里塞明文也取不出来)+ 服务账号行只出 `key_mask`;分页与 total 一致。
+  **顺带修的实缺陷**:密钥签发事件写的是 `open_api.api_key.create`,而白名单 / `platform log.ts` / 三语 `bs.json` 登记的都是 `open_api.api_key.issue` —— 行落了库、审计页一条都筛不出来(AC-25 / AC-27 的「写了查不到」)。改**写入方**对齐登记名(`bisheng/open_api/domain/services/credential_service.py`,登记侧三处不动),并补 `test_the_issue_writer_uses_the_registered_name` 守住写入方本身(既有 lockstep 测试只守登记侧)。
+  **三处真实缺口(xfail(strict=True) 哨兵,不写成散文)**:① **AC-24 访问记录**——F054 已把访问记录写进 `app_access_log` 表,但全仓**没有任何端点读它**,白名单上也没有 `app.access*`,所以「在审计查询面按对象应用 / 操作人 / 时间范围查到」今天不成立;② **AC-25 模型调用逐条**——F051 的 `model_call_record` 未落到本分支;③ **AC-25 / AC-34 运行期能力调用**(评审新增)——`OpenApiAuditMiddleware` 每次 v2 调用都按双归属口径入队一条 `open_api.call`,**行落了库**,但 `open_api.call` 既不在 `_UI_VISIBLE_V2_ACTIONS` 也不在 `platform log.ts`,页面自己的谓词在任何用户筛选之前就把它滤掉了——与本任务顺带修的 `api_key.create` 同一种失效,只是这次错在登记侧。**没有直接补登记**:它是全表里频次最高的动作,而白名单同时也是**未加筛选**时列表展示的范围,登记等于把审计页默认视图淹掉;这属 design §5「高频事件承载」要先拍的板,不是评审能顺手定的。缺口一 / 三补上哨兵即 XPASS 报错、强制删待办。
+  **顺带发现(不在本 Feature §2.3 表内,未动)**:`open_api.pat.regenerate` / `.delete` / `.revoke_holder` / `.tenant_migrate` 四个动作同样写了但两侧都没登记——同一失效,归 PAT / F049 一侧。
+
+### Wave 4 · 顺延 · GOV-07 验收 / 标签接入 / 事件触达(只列标题 / 文件 / 覆盖 AC)
+
+- [x] **T029**: ⚙️ 菜单按类型裁剪的回归验证(工作流与助手四项与既有行为完全一致;托管应用两项 + 已上线态删除置灰——**实现归 F054 T063 / AC-42 / AC-53**,本任务只验收)
+  **文件**: ~~无代码产出(手动验证清单)~~ → `src/frontend/platform/src/test/hostedAppMenuTrim.test.tsx`(新)
+  **覆盖 AC**: AC-16
+  **证据**(分支 `wt/f056-verification`):6 例,两侧都断言**整份菜单**而不是「我关心的那一项在不在」——会出现多余项的方向只有计数抓得住。工作流卡片四项按既有顺序 `管理权限 / 添加到模板 / 创建副本 / 删除`;助手卡片三项(`type !== 'assistant'` 的模板闸是既有行为,没有随本 Feature 挪窝);`HostedAppCard` 恒两项,即使调用方是 `isAdmin` 也不会长出副本 / 模板(两项是在调用点 `showCopy={false}` / `onAddTemp={undefined}` 关掉的);已上线态删除项**置灰而非消失**(F054 AC-42);无 `manage_permission` 权限时只剩删除。
+  **偏差**:① 改为自动化回归而不是手验清单表格——AC-16 的失败方向是「另两类卡片被顺手改了」,这在托管应用的截图里看不见。② AC-16 正文写「工作流与助手卡片的 ⚙️ 菜单**四项**」,但助手实际只有三项:`CardComponent` 的「添加到模板」自始就带 `type !== 'assistant'` 闸(`cardComponent/index.tsx`)。AC 的落点是「与既有行为完全一致」,测试按**既有行为**断言(工作流 4 / 助手 3),不按字面的「四项」;spec 该句属笔误,未代改。
+
+- [x] **T030**: 标签体系接入验收(托管应用出现在其已有标签的 tab 下;未设标签仍在默认分类可见;本版无打标入口)
+  **文件**: ~~无代码产出(手动验证)~~ → `src/backend/test/app_runtime/test_square_scan_page.py`(追加 AC-08 小节,复用同文件的 `square_env`)
+  **覆盖 AC**: AC-08
+  **证据**:3 例。① 带标签的托管应用出现在该标签 tab(漏登记类型时不会报错,只是选了标签后列表为空——所以断言的是"出现"而不是"没崩");② 未设标签的仍在「未分类」、且带标签的**不**出现在未分类(未分类是"已打标"的补集,同一处遗漏会让同一张卡同时出现在两个 tab);③ 直接钉 `WorkFlowService._tag_resource_types()` 的类型清单——这是两个症状共同的源头,一次覆盖它的全部 4 个调用点,并验证工场运行时层关闭时该类型退出清单(AC-10 / F054 AC-58)。
+  **前提确认**:标签预过滤 4 处(`workflow.py:340 / 744 / 836 / 1408`)实现均已由 F054 T060 落地并统一走 `_tag_resource_types()`;本版确无打标入口(`LabelSelect.tsx` 对 `flowType===35` 只做展示分档)。
+
+- [x] **T031**: GOV-07 界面通道零新增权限点验收(无 `create_app` 者看不到新建入口、其既有托管应用的运行 / 访问 / 迭代发布 / 详情页管理入口不受影响;升级前后角色配置面菜单项与权限点数量不变、新建角色默认值不变;三个承载面分别寄居既有页面、不新增一级菜单)
+  **文件**: ~~无代码产出~~ → `src/frontend/platform/src/test/gov07NoNewPermissionPoint.test.ts`(新);`src/backend/test/role/test_gov07_permission_point_inventory.py`(新,评审补);`src/frontend/platform/src/pages/SystemPage/components/roleMenuSelection.ts` / `Roles.tsx`(`DEFAULT_ENABLED_MENU_IDS` 移入纯模块以便断言)
+  **覆盖 AC**: AC-35, AC-36, AC-37, AC-38
+  **证据**:9 例。AC-37 = 角色配置面清单冻结成**字面量**(两个父项 + 工作台四项 + 任务模式 + 管理端十项 = 17 个可选项,去重后仍 17,且无任何 `hosted` / `app_runtime` / `deploy` / `square` 命名的项)+ 级联规则 `CHILD_DEPENDENTS` 三条不变 + 新建角色默认值逐项冻结(含 `build` 开、`create_app` 不开);AC-35 = 新建入口仍只受 `create_app` 一道闸、本版不存在托管应用的界面新建入口(模板入口显式把 `AppType.HOSTED_APP` 排除);AC-36 = 卡片上的运行 / 权限 / 发布 / 删除入口一律由 per-resource ReBAC action 决定,与 `create_app` 无耦合;AC-38 = 路由权限 id 集合冻结为既有 11 个、托管应用详情页寄居 `permission: 'build'`、HeaderMenu 无工场一级菜单。
+  **评审补(后端半边,5 例)**:权限点是在**后端**新增的,前端那份清单只是它的开关面——只冻前端等于只看了第二现场。`test/role/test_gov07_permission_point_inventory.py` 冻 `WebMenuResource` 全量 22 个键(字面量,非计数)、`auth.py` 的 `_ROLE_UI_WORKBENCH_CHILDREN` / `_ROLE_UI_ADMIN_CHILDREN`、`UserMenuAccessService._PARENT_DEPENDENCIES` 里 `create_app → (admin, build)` 的级联(个人菜单授权走的是这条,不是 `CHILD_DEPENDENTS`),并断言三处都没有 `hosted` / `app_runtime` / `deploy` / `square` / `publish` / `factory` 命名的键。最后一例做**两侧对账**:把 `roleMenuSelection.ts` 的两个数组解析出来与后端两个 frozenset 比对——只加一侧的键是「UI 里关不掉的权限点」或「开了不生效的开关」,两边各自的测试都发现不了。
+  **偏差**:把 `DEFAULT_ENABLED_MENU_IDS` 从 `Roles.tsx`(600 行级页面组件,测试里导入会拖进整张依赖图)移到同目录的纯模块 `roleMenuSelection.ts`——它本来就与该模块的清单是同一件事,AC-37 的两半断言现在读同一个真相源。零行为变化。
+
+- [x] **T032**: 事件触达接线——租户管理员(或超管代行)下线 / 重新上线 → owner 站内消息(owner 本人执行时不发;挂 F054 `AppStateService.stop/resume` 后置钩子,复用平台既有消息通知能力;**发送失败只记日志、不回滚业务动作**)
+  **文件**: `src/backend/bisheng/app_runtime/domain/services/`(新增触达钩子), `src/backend/test/app_runtime/test_state_change_notify.py`(新)
+  **覆盖 AC**: AC-43, AC-45
+  **跨 Feature**: 挂在 F054 交付的状态动作上;审批类 / 待上线 / 因删除取消的触达归 F055 AC-31 / AC-35 / AC-64,**不得重复接线**(会产生双份消息)
+  **完成记录(2026-09-16,分支 `wt/owner-notify-and-qr`)**: 新增 `app_runtime/domain/services/state_change_notify.py`(`notify_owner_of_admin_state_change`,action code `app_stopped_by_admin` / `app_resumed_by_admin`,经 `ApprovalNotificationService.notify_users` 发 NOTIFY 型站内信,`instance_id=0`),`AppStateService.stop` 审计后调用、`_start` 仅当 `audit_action == RESUME` 时调用(publish / manual_publish 归 F055,不发);owner 本人执行不发、发送失败 `logger.exception` 后返回 `False`、`ActionResult` 不变。client 侧 `messageApproval/notificationContent.ts` 的 `NOTIFICATION_ACTION_TEXT_KEYS` 登记两码(不进 `APPROVAL_CENTER_ACTION_CODES`)+ `com_notifications_action_app_{stopped,resumed}_by_admin` 三语文案 + `notificationContent.test.ts`。测试:`test/app_runtime/test_state_change_notify.py` 13 例(owner 自停不发 / 租户管理员停发一条 / 超管恢复发一条 / 恢复因容量留在 stopped 不发 / publish·manual_publish 不发 / 发送方抛异常时 stop·resume 仍成功且审计照写 / 钩子契约 / **评审补**:stop·resume 各一例跑真实 `notify_users → send_generic_notify → send_message` 链,断言落库消息是 `NOTIFY` + `APPROVED`、action_code 与收件人正确、内容块无 `agree_reject_button` / `button_action_code`),`test/app_runtime` 全量 327 passed / 13 skipped,`test/app_publish` 366 passed。**偏差**:未按 `lifecycle_hooks` 的订阅式接线,而是直接在状态动作内调用——接收方与文案都是 F054 状态动作自己的语义、无跨 Feature 反向依赖,订阅式只会多一层无人复用的注册。
+
+- [x] **T033**: §3.0.3 事件触达全表验收(审批单生成 / 通过 / 驳回 / owner 撤回 / 因删除取消 / 待上线含成因由 F055 触达;站内消息在 client 工作台「消息提醒」铃铛接收、管理后台不设消息面;明示「无主动提示」的四类事件确实不发;发送失败不影响业务动作完成)
+  **文件**: ~~无代码产出(跨 Feature 验收清单)~~ → `src/backend/test/app_publish/test_notification_table_acceptance.py`(新)、`src/frontend/client/src/components/messageApproval/hostedAppNotificationTable.test.ts`(新)、`src/frontend/platform/src/test/adminBackendNoInboxFace.test.ts`(新)
+  **覆盖 AC**: AC-39, AC-40, AC-41, AC-42, AC-44, AC-45
+  **证据**:三份,各答一个只有跨 Feature 视角才看得见的问题。
+  ① **后端普查(12 例)**——F055 / 审批引擎 / F054 / F049 / F048 各测各的行,没人测这张表。`test_notification_table_acceptance.py` 一次遍历 `app_publish` / `app_runtime` / `open_api` 三个包,把发信调用点**按名字**钉死(app_publish 三处、app_runtime 两处、open_api **零处**);AC-44 的四类静默行分别由「open_api 零调用点」(密钥)、「`visibility_audit` 源码里没有 notify」(新获可见权限)、「没有任何 `ACTION_*` 常量带 capabilit / revoke / resource」(能力被收回、资源释放后可手动上线)守住;两个发信模块声明的 action code 之并集恰为 6 个,多一个就是表上多一行。
+  ② **AC-45 行为验证(同文件 4 例)**——把消息链最底层弄坏(`get_message_service` 直接抛),断言 `notify_approvers_of_new_task` / `notify_pending_online`(三种 reason_kind)/ `notify_owner_of_admin_state_change` 一律正常返回;另补一例:待上线通知的管理员名单解析失败时**owner 仍然收得到**(要动手的是 owner)。**评审补**:fixture 改为返回「是否真的打到了那个爆炸点」的计数器,每例断言它非空——「没抛异常」这种断言在发信方提前 return(收件人空、换了依赖)时同样成立,加了计数器这几例才不会某天变成空跑。
+  ②b **评审补 · AC-40 / AC-41 的正向半边**——原普查只证「F055 不重复发」,而「谁都不发」同样满足它:审批四个终态(通过 / 驳回 / 撤回 / 因删除取消)的发送方在审批引擎里,一次重构把它删掉,owner 永远收不到「你的发布通过了」而所有断言照绿。新增 `test_the_approval_engine_still_sends_rows_one_to_four`,AST 普查 `bisheng.approval`(命名空间包,`__file__` 为 None,须走 `__path__`)里所有发信调用的 `action_code=` 字面量,断言五个码都还在。
+  ③ **client 收件端(16 例)**——后端那半边不保证收件人**读得懂**。把 F055 / F056 发的 5 个码 + 审批引擎的 5 个码(含 AC-41 的 `approval_instance_cancelled`)逐个过三语 bundle(走 `NotificationRow` 真实的 `映射 || 派生 key` 回退逻辑),并断言每条文案都带 `{{target}}`(owner 可能有多个应用);statement 不得进 `APPROVAL_CENTER_ACTION_CODES`(消息类型一错就长出按不动的审批按钮),而审批待办必须仍在里面;AC-44 的四类在文案侧也不留残句。
+  ④ **AC-39 管理后台无消息面**——`adminBackendNoInboxFace.test.ts` 普查 platform **整个 `src/`**(排除 `test/` 自身),断言五个 inbox 端点(`/api/v1/message/{list,unread_count,mark_read,mark_all_read,approve}`)一个都没被调用,并自带「`controllers/` 与 `pages/` 各扫到 > 5 个文件」防止路径写错导致空扫通过。**评审补**:原来只扫 `controllers/`——「HTTP 只走 API 层」是约定不是机制,而第二个收件箱恰恰最可能先在组件里直连试出来。
+  **发现但不在本任务修**:`app_publish_iteration_failed` 没登记进 client 的 `NOTIFICATION_ACTION_TEXT_KEYS`,靠 `NotificationRow` 的派生 key 回退才有文案(三语都在,用户无感)。属 F055 登记侧的一致性问题,已由上述 ③ 覆盖住「有文案」这条底线。
+
+---
+
+## AC 追溯表(45 / 45 覆盖)
+
+| AC | 覆盖任务 | 波次 |
+|---|---|---|
+| AC-01 | T008, T009, T010, T011, T016, T017, T018 | 1 / 2 |
+| AC-02 | T006, T007, T009, T016 | 1 |
+| AC-03 | T006, T007, T008, T009, T016, T018, T021 | 1 / 2 |
+| AC-04 | T004, T005, T016, T021 | 1 / 2 |
+| AC-05 | T016, T021 | 1 / 2 |
+| AC-06 | T008, T009, T016 | 1 |
+| AC-07 | T004, T005, T008, T009, T010, T011, T016, T018, T021 | 1 / 2 |
+| AC-08 | T021, T030 | 2 / 4 |
+| AC-09 | T021 | 2 |
+| AC-10 | T018, T021 | 2 |
+| AC-11 | T020, T021 | 2 |
+| AC-12 | T019, T020, T021 | 2 |
+| AC-13 | T012, T013, T014, T020 | 1 / 2 |
+| AC-14 | T012, T014, T021 | 1 / 2 |
+| AC-15 | T012, T013, T016, T020, T021 | 1 / 2 |
+| AC-16 | T029 | 4 |
+| AC-17 | T024 | 3 |
+| AC-18 | T028 | 3 |
+| AC-19 | T015, T021, T028 | 1 / 2 / 3 |
+| AC-20 | T015, T021, T028 | 1 / 2 / 3 |
+| AC-21 | T022, T028 | 3 |
+| AC-22 | T001, T002, T012, T013, T014, T021 | 1 / 2 |
+| AC-23 | T028 | 3 |
+| AC-24 | T028 | 3 |
+| AC-25 | T028 | 3 |
+| AC-26 | T025, T028 | 3 |
+| AC-27 | T001, T002, T015, T021 | 1 / 2 |
+| AC-28 | T022, T023 | 3 |
+| AC-29 | T024 | 3 |
+| AC-30 | T026 | 3 |
+| AC-31 | T026 | 3 |
+| AC-32 | T025 | 3 |
+| AC-33 | T027 | 3 |
+| AC-34 | T027 | 3 |
+| AC-35 | T031 | 4 |
+| AC-36 | T031 | 4 |
+| AC-37 | T031 | 4 |
+| AC-38 | T031 | 4 |
+| AC-39 | T033 | 4 |
+| AC-40 | T033 | 4 |
+| AC-41 | T033 | 4 |
+| AC-42 | T033 | 4 |
+| AC-43 | T032 | 4 |
+| AC-44 | T033 | 4 |
+| AC-45 | T032, T033 | 4 |
+
+**统计**:33 任务 / 4 Wave / `[MVP-核心]` 21 条(T001–T021)/ 未覆盖 AC **0** 条。
+**任务类别分布**:基础设施 3(T001–T003)· 后端 Domain 与 Service 13(T004–T016)· 前端 Client 2(T017–T018)· 前端 Platform 2(T019–T020)· 部署与验收 1(T021)· 顺延 12(T022–T033,含后端 6 / 前端 3 / 纯验收 3)· Worker **0**。
+
+---
+
+## 实际偏差记录
+
+> **只留一行指针**,论证在 design.md(决策 / 坑),这里不重复(见 `docs/SDD-Guide.md` §4)。
+> 推翻已 ★ 确认的决策时,先停下与用户重新确认(§3 第四个 ★),再记录。
+
+| 任务 | 偏差 | 回写到 design | 原因(一句话) |
+|---|---|---|---|
+| T008 | 测试落 `test/app_runtime/test_square_scan_page.py`,不是 tasks 写的 `test/workflow/` | §7 单测目录 | 三表 + 同库 sync/async 双 session 的 `build_list_env` 夹具是 `test/app_runtime/conftest.py` 的,pytest 夹具不跨包;为凑路径复制 200 行夹具更糟 |
+| T009 | `app_state` 无需回查——F054 第三支已投第 10 列并由 `_app_row_to_dict` 只给托管行落键;本 Feature 只补 `slug` | D2 / §4.2「广场列表项 `app_state`」标注为 F056 新增 | F054 已交付,重复补入会造第二处真相 |
+| T007 | `status` 豁免抽成 `FlowDao._build_status_clause`;`app_state_in` 下推进 `_build_apps_subquery`(新增可选参数) | D9「具体形状」 | 下推让草稿在行集层就不存在(AC-03「不因 owner 或管理员而例外」无需二次判权),且外层条件可单测 |
+| T014 | 注册落 `api/services/f048_permission_runtime.py` 的 `initialize_f048_api_runtime`,**不动 `main.py`** | D6「注册落点」 | 与 tasks 跨 Feature 副作用表 T014 行一致;该处正是 `mutate_grants` 的构造处,二者同生共死,且避开并发编辑 `main.py` |
+| T014 | `metadata` 增 `moved`(MOVE 的主体身份 + 目标 model_key) | §4.2 审计 metadata 形状 | MOVE 的主体身份与 REMOVE 一样事后不可还原;只记 added/removed 会让「某人从 viewer 升为 editor」在审计里彻底消失 |
+| T015 | 拆成两个参数化用例:`AppAuditAction` 硬断言 + `app.release.*` 走 `xfail(strict=True)` | §7 登记断言测试 | F055 尚未补 `log.ts` / 三语 `bs.json`,直接断言会让本 Feature 的新测试为别的 Feature 挂红;`strict=True` 保证 F055 落地当天该用例 XPASS 报错、强制删除待办项 |
+| T011 | 连带修 `test/workstation/test_workstation_apps_rebac.py`(2 处断言)与 `test_f040_workbench_cursor.py`(1 处 mock 签名) | 无(tasks 跨 Feature 副作用表已认账该文件) | `filter_apps_by_action` 增参数是有意的签名变更,桩与断言须同步 |
+| T018 | 第 3 件「未部署形态文案闸」未实现 | D8 / §6.2 F054 T071 行 | 上游 `client/src/hooks/useAppRuntimeEnabled.ts`(F054 T071)尚未落地;且广场今天**没有**任何托管应用专属文案可隐藏(唯一的托管 UI 是按行数据驱动的「已下线」角标),开关关时后端天然无 `app` 行 → AC-10 由数据侧独立成立 |
+| T003 | 勘误 ② 已由 F054 落实(`get_online_flows_page` 的 tag 预过滤第 4 处已含 `ResourceTypeEnum.HOSTED_APP`);③ 经核查 F054 未加等价参数,故由 T007 新增并沿用本文命名;① TTL 300s→1800s **仍未回写**(F054 访问记录尚未实现,`app_access` 全仓仅一处 TODO 注释) | 无 | 三条勘误逐条落地确认,①待上游 |
+| 测试基建 | `test/app_runtime/conftest.py` 增两个 session patch 目标(`bisheng.api.services.workflow`、`bisheng.app_runtime.domain.services.visibility_audit`) | 无 | 二者各自按名字绑定 `get_async_db_session`;不加则测试打到真 MySQL |
+
+## 2026-08-18 · 主 agent 裁决与顺带修复
+
+1. **`metadata.moved` 保留**(实施方问是否要删)。§4.2 只列了 `added / removed / model_keys`,但 **MOVE 恰恰是最该留痕的那种变更**——「某人从 viewer 升为 editor」只记 added/removed 会在审计里彻底消失,而它的权限影响不比新增小。主体身份事后不可还原这一点与 REMOVE 同理。代价是三行。design §4.2 应随之补记该键。
+2. **`app.release.*` 的前端半边已补齐(16 条 × log.ts × 三语),T015 的 `xfail(strict=True)` 哨兵已摘除、转为正常断言(30 passed)。** 实施方测出的这个缺口是真的:后端白名单 17 条、前端 0 条——**发布事件写进了库却在审计页一条都筛不出来**,正是 F054 坑 24 记载的同一种失效,AC-27 判的就是筛得出来而不是写得进去。哨兵的设计在这里完全奏效:补齐当天 16 条全部 XPASS(strict) 报错,强制把待办项删掉而不是让它静静地活过它所描述的缺口。
+3. **越出「只改」清单的四个文件属正当**:`api/services/workflow.py` / `permission/application/resource_api.py` / `api/services/f048_permission_runtime.py` / `database/models/app.py` 都是 tasks.md 的 T005/T009/T011/T013/T014 直接点名的,不改则 F056 什么也做不成;我给的清单是并发边界(避开另两路的战场),不是任务范围,实施方按 tasks.md 执行且严格避开了四个「不碰」目录,判断正确。
+4. **T003 勘误①(访问记录合并窗口 300s → 1800s)登记为待办**:`app_access` 全仓只有 `tenant_filter.py:117` 一句 TODO,功能未实现,无处可回写——等该功能落地时随它一起定,不在本轮制造一个指向空处的引用。
+5. ~~**T016 / T021 未做属正确取舍**~~ → **2026-09-10 订正:T016 已交付**(`src/backend/test/workflow/test_square_hosted_app_e2e.py`,7 例,`F056_E2E=1` 门控、无中间件时整文件 skip——不是空壳),仍需在 114 重建后对真实部署跑一次;T021 仍待 114。以下为原判断,保留作历史:前者要真 FGA + MySQL 才有意义(AC-05 授权→撤销、AC-06 与入口判定同真同假、AC-15 被授予者调 grants 得 403),本地无中间件;**没写一个跑不起来的空壳是对的**——空壳会让人以为覆盖到了。后者要 114 环境与非管理员账号实操。两条都随 114 联调一起做。
+
+
+## 2026-09-18 · 验收核查发现:三类高频记录「只写不读」(2026-10-09 定方案 A,待开发)
+
+对 PRD-1 二十一项功能逐项核查实现侧接线时发现:**GOV-04 事件清单里有三类记录写入齐备、查询侧零接线**,管理面一条也查不到。按 §2.4 与 spec §1 的归属规则(「本 Feature 拥有…访问记录、运行期能力调用双归属、模型调用逐条…在审计面可查的验收」),这是本 Feature 的缺口,不是上游没写。
+
+| 记录 | 写入侧 | 查询侧现状 | 114 实测 |
+|---|---|---|---|
+| 模型调用 | `model_call_record` 表,字段齐全(`credential_id` / `app_id` / 模型 / token 三项) | `ModelCallRecordRepository.alist` / `aiter_export` **存在但零调用方**(全仓只有测试引用),无 API 端点、无前端入口 | 1 行(09-16 手验那次) |
+| 运行期能力调用(检索) | `AppCapabilityCallRecord`,表结构完整(`app_id` / `capability` / `targets` / `result` / `chunk_count` / `latency_ms`) | `AppCapabilityCallRecordDao` **只有 `ainsert`**,连查询方法都没有 | 0 行 |
+| 访问记录 | `AppAccessLogDao.ainsert`(fire-and-forget,有测试) | `AppAccessLogDao.alist_page` **零调用方**(同上) | 52 行 |
+
+**为什么不是「已顺延」**:§5.2 顺延的是**聚合用量账单**,且顺延理由写的是「本版以**逐条调用审计**替代,管理员**仍可追溯**谁在用」;另一条顺延的是 **owner 自助查询**(「本版审计查询仅管理面」)。两条都把「管理面逐条可查」留在了本版。PRD-1 GOV-04 验收要点 1(「上述每类事件发生后在审计面可查到记录」)与验收要点 3(「模型调用可按 key / 按应用追溯到具体调用记录,含模型与 token 数」)因此**当前不可达**。
+
+**为什么会漏**:写入侧与查询侧各自有测试且各自都绿——`test_app_access_log.py` 证明写得进去,审计页测试证明白名单里的 action 筛得出来,但没有一条测试问「这张表能不能被人读到」。与 T015 当年发现的「后端白名单 17 条、前端 0 条」是同一种失效的另一面。
+
+**不是随手能补的原因**(所以留给用户拍板,没有自行实现):三类都是高频写入,`model_call_record` 的模块注释已写明它**刻意不进 `audit_log`**(「a local coding agent calls it in a loop,混进去会淹没审计页存在的意义」),`open_api.call` 出于同样理由没进 `_UI_VISIBLE_V2_ACTIONS`。因此「把 action 加进白名单」是最省但最坏的一种解法——它正好毁掉当初分表的理由。可选形态:
+
+- **A|审计页加一个独立 tab/子查询面**(推荐):默认视图不变,按 key / 应用 / 时间查这三张表,复用既有导出。工作量集中在一个端点族 + 一个前端页。
+- **B|只补端点、先不做前端**:让 AC「可追溯」在 API 层成立,管理员经导出拿数。最小,但 §2.4「在既有审计页上扩展」这句仍不成立。
+- **C|判定为本版不做**:需要同步改 PRD-1 GOV-04 验收要点 1 与 3、§5.2 补一行顺延,并把 F056 spec §1 归属清单里这三项划掉——否则下一轮验收还会再发现一次。
+
+**一个附带项**(AC-58 修复时浮现):应用运行期凭据的审计行(本轮新加,`open_api.api_key.issue` / `.revoke`)target 是凭据、`metadata` 里只有 `subject_id`,**没有 `app_id`**,因此按「对象应用」筛不出来。如果选 A 或 B,这一条顺带补进去(additive,改 `CredentialService._audit` 的 metadata 即可)。
+
+**2026-10-09 拍板:方案 A。** 审计页新增「应用运行记录」tab,模型调用、应用能力调用、应用访问三类记录各一个子视图;筛选维度为时间、应用、服务账号密钥(模型调用)、访问用户,平台超管另有租户筛选;复用既有导出;未部署工场运行时层时不显示。运行期凭据审计行的 `metadata` 顺带补 `app_id`。需求口径见 PRD-1 v3.0 GOV-04「界面与交互」。
+
+## 2026-10-24 修订设计确认后收尾任务
+
+本节优先于历史裁剪/阶段描述。用户确认原文:确认 F054/F056 修订设计,继续完成剩余开发并提测。只运行本功能必要的定向验证;真实部署证据与模拟测试分开登记,未执行不记通过。
+
+- [x] **T034**(基础设施):快照与导出任务持久模型、163 错误码;新表保留回滚;不建事实副本
+  - 文件:`src/backend/bisheng/app_governance/domain/models/runtime_audit.py`、`src/backend/bisheng/common/errcode/app_governance.py`。
+  - 依赖:无(已确认设计);配对:无(基础设施/交付)。
+- [x] **T035**(基础设施):既有访问表新增可空历史名称快照;迁移仅 DDL,可回滚列;无历史回填
+  - 文件:`src/backend/bisheng/database/models/app_access_log.py`、`src/backend/bisheng/core/database/alembic/versions/f056_access_name_snapshot.py`。
+  - 依赖:T034;配对:无(基础设施/交付)。
+- [x] **T036**(测试):凭据签发可信 app 关联、真实撤销一次审计、幂等、伪造归属拒绝、审计失败整事务回滚
+  - 文件:`src/backend/test/open_api/test_runtime_credential_audit.py`。
+  - 依赖:无(已确认设计);配对:T037、T038。覆盖 AC: AC-59, AC-58。
+- [x] **T037**(后端 Domain):可信 subject 映射与同 session revoke_runtime_binding;普通凭据审计补可信关联
+  - 文件:`src/backend/bisheng/open_api/domain/repositories/credential_repository.py`、`src/backend/bisheng/open_api/domain/services/credential_service.py`。
+  - 依赖:T036;配对:T036。
+- [x] **T038**(后端 Domain):公开撤销协议接真实切换/终结/停止/删除绑定状态转移;复用调用者事务
+  - 文件:`src/backend/bisheng/open_api/application/runtime_credentials.py`、`src/backend/bisheng/app_runtime/domain/services/runtime_projection_service.py`。
+  - 依赖:T037;配对:T036。
+- [x] **T039**(测试):三源真实 SQLite 查询白名单、租户/角色拒绝、NULL/同秒稳定分页、筛选/候选、坏/过期游标
+  - 文件:`src/backend/test/app_governance/test_runtime_audit_query.py`(测试夹具内置在该测试文件)。
+  - 依赖:T034;配对:T040、T041、T042。覆盖 AC: AC-47, AC-48, AC-49, AC-50, AC-51, AC-52, AC-53, AC-54, AC-58, AC-61。
+- [x] **T040**(后端 Domain):三个事实适配,显式授权范围、白名单、keyset 与 streaming source session 协议
+  - 文件:`src/backend/bisheng/app_governance/domain/repositories/runtime_audit_repository.py`、`src/backend/bisheng/app_governance/domain/schemas/runtime_audit.py`。
+  - 依赖:T039;配对:T039。
+- [x] **T041**(后端 Domain):当前自然人管理员范围、合法筛选、快照及严格签名 cursor;候选从历史事实
+  - 文件:`src/backend/bisheng/app_governance/domain/services/runtime_audit_scope.py`、`src/backend/bisheng/app_governance/domain/services/runtime_audit_query_service.py`。
+  - 依赖:T040;配对:T039。
+- [x] **T042**(后端 API):v1 session 注册三类查询/候选 API,拒绝开放凭据及禁用状态
+  - 文件:`src/backend/bisheng/app_governance/api/router.py`、`src/backend/bisheng/api/router.py`。
+  - 依赖:T041;配对:T039。
+- [x] **T043**(测试):CSV 公式/Unicode/空值、完整多批扫描、私有分块 EOF manifest、租约/重试及当前权限拒绝
+  - 文件:`src/backend/test/app_governance/test_runtime_audit_export.py`。
+  - 依赖:T040;配对:T044、T045、T046。覆盖 AC: AC-51, AC-52, AC-53, AC-56, AC-57, AC-58, AC-60。
+- [x] **T044**(后端 Domain):异步导出任务与幂等/租约/CAS,冻结条件,一次 source 读视图,私有分块及认证下载
+  - 文件:`src/backend/bisheng/app_governance/domain/repositories/runtime_audit_export_repository.py`、`src/backend/bisheng/app_governance/domain/services/runtime_audit_export_service.py`。
+  - 依赖:T043;配对:T043。
+- [x] **T045**(Worker):后台扫描调度/恢复/GC,Celery headers 传 tenant_id 并恢复 ContextVar;每批当前授权
+  - 文件:`src/backend/bisheng/worker/runtime_audit/tasks.py`、`src/backend/bisheng/worker/main.py`。
+  - 依赖:T044;配对:T043。
+- [x] **T046**(后端 API):导出提交/状态/认证下载,私有 MinIO 不公开 URL,当前权限/ready TTL 核验
+  - 文件:`src/backend/bisheng/app_governance/api/router.py`、`src/backend/bisheng/app_governance/domain/services/runtime_audit_storage.py`。
+  - 依赖:T044、T045;配对:T043。
+- [x] **T047**(后端 Domain):新 allow 写 app_name_snapshot,保留历史未知及合并窗口
+  - 文件:`src/backend/bisheng/app_runtime/domain/services/app_access_log_service.py`、`src/backend/bisheng/app_runtime/domain/services/entry_authz_service.py`。
+  - 依赖:T035;配对:T039。
+- [x] **T048**(前端 Platform):包裹 request API 与 query hook,筛选 key 包含视图/范围,不保留旧租户内容
+  - 文件:`src/frontend/platform/src/controllers/API/runtimeAudit.ts`、`src/frontend/platform/src/pages/LogPage/runtime/useRuntimeAudit.ts`。
+  - 依赖:T042、T046;配对:T051。
+- [x] **T049**(前端 Platform):现有 LogPage 第三 tab 三子视图、时间/历史候选筛选和异步完整导出,遵循现有组件
+  - 文件:`src/frontend/platform/src/pages/LogPage/runtime/RuntimeAuditPanel.tsx`、`src/frontend/platform/src/pages/LogPage/index.tsx`。
+  - 依赖:T048;配对:T051。
+- [x] **T050**(基础设施):同一语义文案三语与新错误码三语,按 locale 文件独立原子编辑不触碰旧 keys
+  - 文件:`src/frontend/platform/public/locales/zh-Hans/bs.json`、`src/frontend/packages/locales/src/api_errors/zh-Hans.json`。
+  - 依赖:T034;配对:无(基础设施/交付)。
+- [ ] **T051**(测试):可达 UI、切类别/租户不显示过期数据、查询/导出失败与合法空值、管理员和非管理员真实入口配对
+  - 文件:`src/frontend/platform/src/test/runtimeAudit.test.tsx`、`src/backend/test/e2e/test_e2e_vibe_governance_closeout.py`。
+  - 依赖:T048、T049、T050、T050a、T050b;配对:无(基础设施/交付)。覆盖 AC: AC-46, AC-55, AC-56, AC-57, AC-61。
+- [ ] **T052**(基础设施):逐 AC 提测映射、定向 L1/L2、区分实际与待测;同步 F055 AC-81 凭据审计依赖
+  - 文件:`features/v3.0.0/056-app-square-governance/closeout-validation.md`、`docs/reviews/vibe-governance-closeout-handoff-2026-10-24.md`。
+  - 依赖:T038、T047、T051;配对:无(基础设施/交付)。
+
+- [x] **T050a**(基础设施):英文运行记录与错误码文案,保留既有 keys。
+  - 文件:`src/frontend/platform/public/locales/en-US/bs.json`、`src/frontend/packages/locales/src/api_errors/en.json`。
+  - 依赖:T050;配对:无(纯文案)。
+- [x] **T050b**(基础设施):日文运行记录与错误码文案,三语 key 对齐。
+  - 文件:`src/frontend/platform/public/locales/ja/bs.json`、`src/frontend/packages/locales/src/api_errors/ja.json`。
+  - 依赖:T050a;配对:无(纯文案)。
+- [x] **T053**(基础设施):新读模型在租户过滤/发现注册;现有修复不改租户默认和双库约束。
+  - 文件:`src/backend/bisheng/core/database/tenant_filter.py`、`src/backend/bisheng/app_governance/domain/models/__init__.py`。
+  - 依赖:T034;配对:T039。
+- [x] **T054**(Worker):注册任务及 30 秒恢复扫描;逐任务重新进入可信 tenant execution scope。
+  - 文件:`src/backend/bisheng/worker/__init__.py`、`src/backend/bisheng/core/config/settings.py`。
+  - 依赖:T045;配对:T043。
+
+增量任务审查:2026-10-24 `/sdd-review tasks` 第 1 轮发现三语任务粒度过大;已拆成每语两文件并补共享注册落点。第 2 轮 21 项检查 LGTM,证据见 `closeout-task-review.md`。后端按测试先行逐批落地,不把定向验证当部署验收。
+
+### 实际接线补充(同一批准范围)
+
+下列任务补齐共享配置、命名导出和测试夹具的文件归属;执行以依赖为准,配置/ORM 基础先于消费方。未引入新的业务需求。
+
+- [x] **T055**(后端 Domain):提供当前管理视图的公开只读协议;失败不回退成全局;供读/worker/下载重新核验
+  - 文件:`src/backend/bisheng/admin/application/tenant_scope.py`、`src/backend/bisheng/admin/application/__init__.py`。依赖:T039;配对:定向用例见本功能 closeout-validation.md。
+- [x] **T056**(前端 Platform):命名导出惰性接线及部署开关/管理员门禁测试,保留原两页签
+  - 文件:`src/frontend/platform/src/routes/index.tsx`、`src/frontend/platform/src/pages/LogPage/index.test.tsx`。依赖:T049;配对:T102、T103、T116(F054)或 T049(F056)。覆盖 AC: AC-46, AC-53, AC-61。
+
+**2026-10-24 状态**:本轮主功能实现代码及定向用例已接齐;部署 E2E 与最终 L2 仍未完成。未执行全量测试,旧批次计数不表示本轮交付状态。新增接线的 L1 与证据见 `closeout-validation.md`;未对每个小任务保留独立的先红后绿日志,不宣称完整 TDD 历史。
+
+## 2026-11-02 审计名称及导出空值纠正
+
+- [x] **T057**:只读核对 116 的访问、模型和能力事实,逐字段对照 CSV 序列化,区分合法 NULL、0 和真实缺失。原下载物已过期回收,明确保留实物复核限制。
+- [x] **T058**:列表使用名称/掩码、去掉重复数字列,保留原标识追溯;执行用户做有类型的可空只读投影;原权限、租户、候选历史范围与导出字段保持。
+- [x] **T059**:补齐三语空值说明及必要定向测试;将本模块旧登录态/租户测试夹具对齐 F114 已交付接口,不修改生产鉴权。
+- [ ] **T060**:部署后定向核对界面名称、悬停原始标识及一次导出;本轮未部署,不改写历史记录。
+
+覆盖 AC:AC-26、AC-29、AC-31、AC-32、AC-34;后继权限边界沿用 F114。证据与复审见
+[本轮验证记录](./runtime-audit-display-verification-20261102.md)。
+
+2026-11-03:用户授权提交并推送 `feat/3.0.0`。来源提交 `521e03639` 与最新主线整合后复验后端 27 项、前端 16 项,lint/i18n/Ruff/架构守卫通过;类型检查与相同依赖的主线基线同为 24 项既有诊断,无新增错误。未更新环境,T060 不标记完成。
diff --git a/features/v3.0.0/057-bisheng-sdk/design.md b/features/v3.0.0/057-bisheng-sdk/design.md
new file mode 100644
index 0000000000..10f929168d
--- /dev/null
+++ b/features/v3.0.0/057-bisheng-sdk/design.md
@@ -0,0 +1,498 @@
+# Design: bisheng-sdk(Python 首发三件套 auth / retrieve / storage + 开发者指南)
+
+> **本文档定位 — 现状快照(Why this How)**
+>
+> - `spec.md` 回答 **做什么**(36 条 AC、边界、11 条决议)
+> - `design.md`(本文)回答 **为什么这么实现**:关键决策(含被否决的备选)、运行时不直观的事实、对外契约
+> - `tasks.md` 是 **流水账**:拆了哪些任务、做了什么改动
+>
+> 调整原则(详见 `docs/SDD-Guide.md` §3-§4):实现变化 → 覆盖更新本文档、只留"今天的状态";每个决策保留"为什么 + 被否方案"和坑。推翻已 ★ 确认的决策 → 停下与用户重新确认;纯实现细节 → 直接改 design。
+>
+> **代码事实口径**:本文所有 `文件:行号` 按 `3.0-vibe` **`fe10f75ea`**(2026-09-16)核实。**两个待合入分支是本 Feature 的前置依赖,其契约按其源码核实、不按 `3.0-vibe` 的缺位判断**:
+> - **`wt/storage-handle` `23886547f`**(F054 T084 / T085 附件句柄;D8 / §4.2 ④ / 坑 30)
+> - **`wt/cli-dev` `b61b209e4`**(F053 T038 / T042–T044 / T047 / T048:`bisheng dev` 迷你代理与 `platform-wiring` 技能包;D12 / §4.2 ② / 坑 31–36)
+>
+> 后端路径以 `src/backend/bisheng/` 为根,runtime-manager 以 `src/runtime-manager/runtime_manager/` 为根,CLI 以 `src/bisheng-cli/` 为根,其余以仓库根为根。行号会漂移、符号名不会——**落地前一律以符号名重定位,不要按行号跳**。
+>
+> **本文是"要建成的样子"**:全仓**没有任何** `bisheng_sdk` / `bisheng-sdk` 代码(grep 只命中 feature 文档与 PRD)。`src/bisheng-sdk/` 是全新目录,是继 `src/bisheng-cli/` 之后本仓第二个可发布包工程;平台侧只在 F053 已建的 `bisheng/dev_toolkit/` 上做增量——**含 `skills/platform-wiring/` 技能包本体:它已由 F053 T038 在 `wt/cli-dev` 建成(身份头 / 应用数据库 / 模型「暂未提供」/ `dev` 四章 + 样例 + 自检),本 Feature 是它的增量编辑者而不是创建者**(D12、坑 31)。实现后按现状覆盖本文。
+>
+> **2026-09-16 全自动模式**:用户已放弃 ★ 暂停点,本文 D1–D16 每条均按建议直接定案并标「全自动模式定案」,理由与备选留痕供追溯;`/sdd-review design` 如有 high 级发现再就地修订。
+
+**关联**: [spec.md](./spec.md) · [tasks.md](./tasks.md) · [release-contract.md](../release-contract.md)(表 3 F057 行;INV-28 / INV-30 / INV-32 / INV-35 / INV-36)· [mvp-114-path.md](../mvp-114-path.md) §2(F057 不在纵切上)
+**上游 / 姊妹**: [F053 design D10 / D11 / §4.2 ⑤](../053-dev-cli-skills/design.md)(分发端点、`versions` 载荷的 `sdk.*` 字段位、`skills sync`)· [F054 design D5.2 / §4.2 ③⑤ / 坑 20](../054-app-domain-runtime/design.md)(注入头、注入环境变量、`bisheng-apps` 独立桶)· [F054 contracts-runtime-manager.md §5](../054-app-domain-runtime/contracts-runtime-manager.md)(环境变量清单)· [F055 design §3 能力总线段](../055-app-publish-pipeline/design.md)(`16273` / `16274`、OBO 为审计 subject)· [F052 spec AC-19–AC-27 / AC-46](../052-mcp-server-face/spec.md)(门面语义;**F052 尚无 design / tasks**)
+**版本**: v3.0.0
+**最后更新**: 2026-09-16
+
+---
+
+## 1. 目标与非目标
+
+**目标**:交付一个**与后端零耦合的独立 Python 包 `bisheng-sdk`**(import 名 `bisheng_sdk`),把三件「平台特有 ∧ 写错了会出安全事故」的事各封成一行到位的默认做法——`auth.current_user()` 读平台注入身份(无注入即抛错)、`retrieve.search()` 以**当前访问者**凭据经 F052 门面检索、`storage.put/get/list/delete()` 在应用附件空间内存取——并让这三行在 `bisheng dev` 本地与托管上线后一字不改。同时:由平台自身分发 SDK 安装件(复用 F053 的 `dev-toolkit` 端点族)、托管构建期能从平台自身装到它、随包发布开发者指南与「平台能力接线」技能包的 SDK 章节 / 样例 / 自检脚本。SDK **不拥有任何服务端能力**,只读注入、调门面、消费句柄。
+
+**非目标**(防止后人误扩,理由已在 spec §范围边界 / §4 决议):
+
+| 不做 | 为什么 | 归属 |
+|---|---|---|
+| `chat` / `appdb` 及任何便捷封装(连接工厂、客户端工厂) | PRD-1 DEV-07「刻意不进 SDK 的两样」;准入门槛见 D2 | 指南教标准库接法(F053 AC-17 同源) |
+| 身份头验签、本地权限判定、本地白名单模拟 | 信任根在入口拓扑 + 服务端门面(spec 决议-3 / 决议-8) | F054 AC-32/33、F053 AC-25、F052 |
+| 访问者凭据的服务端受理、白名单 ∩ 用户、能力收回判定 | 服务端能力 | **F055 已落地**(§6.2 原阻塞项 ② 已消解,见该行);能力收回错误码的写入方仍待 F055 |
+| 附件存储服务端(MinIO 桶、四操作、上限、删除联动、句柄注入) | 服务端能力,**F054 T084 / T085 已在 `wt/storage-handle` 落码** | **F054**(契约以其源码为准,本文 §4.2 ④ 只是消费者视角快照 + 对账测试) |
+| `bisheng dev` 迷你代理与同名环境变量注入 | CLI 侧 | **F053 T042–T044**(本文提供头名常量与句柄名) |
+| 非 Python SDK、下载直链 / 分享链接、附件级权限、清空附件空间 | spec 决议-9;v3.1 视需求 | — |
+| 新错误码模块(用户预留 26400–26409) | **不申请**(D14):SDK 侧错误是 Python 异常类、服务端码各归其主 | — |
+
+---
+
+## 2. 关键约束
+
+> 全局架构铁律遵循 [`docs/constitution.md`](../../../docs/constitution.md) C1–C8,本节不重抄。**作用域提醒**:C1–C7 与 `scripts/arch-guard.sh` 约束的是 `src/backend/`;`src/bisheng-sdk/` 是独立工程,只有 RULE-7(硬编码密钥字面量)会对它出声,且只是 WARNING——测试里的假令牌一律用拼接(`"bs-sak-" + "x" * 43`),让它保持零输出。
+
+- **CON-1 SDK 是独立包,不 import `bisheng`**:不读后端配置、不连数据库、不引 FastAPI / SQLModel。依赖预算 **`httpx>=0.27,<1.0` 一条**(D1);其余全标准库。推论:版本比较不引 `packaging`(照 CLI `http.py:_version_tuple` 的三段元组比较)。
+- **CON-2 只访问平台注入的两个地址**:SDK 运行期只打 `BISHENG_PLATFORM_API_BASE`(retrieve、版本探测)与 `BISHENG_APP_STORAGE_ENDPOINT`(附件;值是 runtime-manager 的应用面地址而非平台 API,F054 契约 §5 / 坑 27);不做遥测、不查公网、不引第三方地址(spec §3「托管期出站可达」:F054 AC-16 运行期出站白名单须含这两个地址,F054 契约 §9 已登记)。
+- **CON-3 两类凭据各司其职、角色不可对调**(2026-09-16 按 F055 已落地实现修订,见 D5):托管容器内同时存在应用运行期凭据(`BISHENG_APP_TOKEN`,F055 `capability_bus_service.runtime_capability_env` 注入,回答「哪个应用」)与每请求注入的访问者凭据(`X-BiSheng-Access-Token`,F054 AC-34,回答「为谁」)。retrieve **两把都送**:应用凭据作 `Authorization: Bearer`(平台据此取该应用当前生效声明的白名单),访问者凭据作同名请求头(平台据此确立访问用户)。**访问者凭据只从请求上下文取**,绝不从进程级环境变量取;应用凭据只经 `_env.app_token()` 一处读取(`test_public_surface.py` 断言该名字不出现在其它模块的代码里),且永远不能单独成立——没有访问者头即被平台拒绝、没有 owner 兜底(AC-52)。不提供 `as_user` 及任何等价参数(AC-08 / AC-12 / 决议-2)。
+- **CON-4 无注入即抛错,不返回 None / 缺省身份、不提供宽松开关**(AC-07 / 决议-1)。健康探活、后台任务、单测裸调用遇到抛错是**刻意的**。
+- **CON-5 请求作用域**:身份与凭据只活在当前请求上下文(AC-09),不跨请求缓存;同进程并发互不串扰;WebSocket 取握手时的头。
+- **CON-6 凭据不落盘、不回显**(AC-04):所有异常 `__str__`、日志、`repr` 统一过 `redact()`;`Identity` 对象**不携带**访问令牌(令牌只在上下文里供 retrieve 读)。
+- **CON-7 与三处注入契约同一份**(AC-31):头名 = app-proxy `INJECTED_HEADER_NAMES`(`src/app-proxy/app_proxy/headers.py:29-40`),**`bisheng dev` 已复制同一张表且自带 `ast` 对账测试**(`wt/cli-dev`:`bisheng_cli/devproxy.py:63-74` + `tests/test_platform_contract.py`)——SDK 是第三份副本,对账测试读 app-proxy 这个上游而不是读 CLI;环境变量名 = F054 contracts §5(含 `BISHENG_APP_STORAGE_ENDPOINT` / `_TOKEN` / `_MAX_FILE_MB`,真身 `runtime_manager/storage.py:STORAGE_ENV_NAMES`、backend 副本 `app_runtime/domain/constants.py:APP_STORAGE_ENV_NAMES`)+ 本文为本地期新定的 `BISHENG_APP_STORAGE_DIR`(§4.2 ②)。SDK 把这两张表复制成常量模块 `_headers.py` / `_env.py`,并由 tests 与源文件**逐字对账**(tests 读 `headers.py` / `storage.py` / `constants.py` 文本比对,仓内可跑、发布件不依赖;对方文件不存在时 `skip` 并打印原因)。
+- **CON-8 版本独立、区间兼容**(AC-03 / 决议-6):SDK 版本 `0.x` 独立于平台 `3.0.0`;平台声明 `sdk.min_compatible`;不兼容在**首次平台交互**抛错(auth 不交互,故可能到 retrieve / storage 才暴露——自检脚本补位)。
+- **CON-9 商业版网关只转发 `/api/v1/**` 与 `/api/v2/**`**(`docs/architecture/11-gateway.md:36-37`):分发端点、简单索引都必须落在这两个前缀下。附件 API **不经网关**——它是应用容器到 runtime-manager 的内网直连(F054 D10),与网关无关。
+- **CON-10 分发件随后端包走**:backend 镜像 build context 只有 `src/backend/`(`src/backend/Dockerfile` `COPY ./ ./`),SDK wheel 与 CLI wheel 同理必须**构建后提交进 `bisheng/dev_toolkit/artifacts/`**(F053 T030 契约;坑 10 / 11)。
+
+**Constitution Check(自查)**:
+- **C1(DDD 分层)**:`src/bisheng-sdk/` 是 `src/backend/` 之外的独立工程(同 F053 CLI、F054 runtime-manager),RULE-1~9 不作用于它,只有 RULE-7 会对测试里的密钥字面量出声(§2 开头已处理)。平台侧增量只落 `bisheng/dev_toolkit/`(`api/endpoints/distribution.py` + `domain/services/artifact_service.py` + 新常量 `sdk_compat.py`):端点经 service 读盘、不 import `database/models`(RULE-3)、不跨模块 import 他人 `api/`(RULE-5);runtime-manager 增量(D15 两个 buildarg)在 `bisheng` 包之外。**RULE-10**:SDK 与 backend 增量都不 import docker / 不碰 socket。
+- **C2(双 DB)**:无新表、无 Alembic revision;`sdk_compat.py` 是纯常量。
+- **C3(多租户)**:四个新端点匿名、租户对 SDK 不可见(spec §3);`/api/v1/dev-toolkit` 前缀已在 `TENANT_CHECK_EXEMPT_PATHS`(`utils/http_middleware.py`,F053 D10 落码),新端点同前缀、零改动。
+- **C4(权限)**:SDK 不做任何权限判定、不持验签材料(spec 决议-8);分发端点匿名与 F053 D10 同源;retrieve / storage 的判权全在服务端(F052 门面 / F054 manager Bearer 绑 `app_id`)。
+- **C5(错误码)**:**不申请模块段**(D14);`16170–16174` 由 F054 在 `app_factory.py:224-234` 预留、首个 backend 调用方落码,本 Feature 不落、不依赖;SDK 侧可区分性由异常类承担,不进 `api_errors` 三语文案。
+- **C6(无硬编码密钥)**:SDK 不持任何密钥材料;OBO 与 storage token 只从注入读、不落盘、异常经 `redact()`;测试假令牌一律拼接(坑 22)。
+- **C7(前端 store 不直连 HTTP)**:无前端改动。
+- **C8(本地文件系统不放共享状态)**:托管期附件真身恒在 MinIO `bisheng-apps`(经 manager,多实例 / 重建后一致,AC-24);SDK 的本地目录后端**只在 `bisheng dev` 单机开发期**生效(D8 互斥判定保证线上不会静默落本地);SDK 进程内唯一的缓存是 D7 的版本探测结果(内存,不落盘)。
+
+---
+
+## 3. 方案对比与选定
+
+> 每条:备选 / 选定 / 原因 / 何时该重新考虑。全部为**全自动模式定案**。
+
+### D1:包工程 = `src/bisheng-sdk/`(hatchling wheel),三名分离,依赖仅 `httpx`,版本 `0.1.0` 独立于平台
+
+- **备选**:A. 并入 `bisheng-cli` 同一包 — CLI 是开发者本机工具、SDK 是应用运行期依赖,合包让应用容器背上 PyYAML 与 CLI 代码(spec 决议-5 已否决);B. 放进 `src/backend/bisheng/sdk/` 随后端包分发 — 应用容器必须装整个 backend,荒谬;C. **独立工程,照抄 `src/bisheng-cli/pyproject.toml`**(选定)。
+- **落地形状**:发行名 `bisheng-sdk` · import 名 **`bisheng_sdk`** · 无 console script。`requires-python = ">=3.11"`(托管 `runtime` 矩阵首发 python3.11,`src/runtime-manager/runtime_manager/templates/` 只有 `python3.11/`)。`dependencies = ["httpx>=0.27,<1.0"]`——**带上界**(CLI T001 的教训:wheel 的分发路径上 `uv.lock` 不参与解析,metadata 上界是唯一约束)。`[tool.hatch.build.targets.wheel] packages = ["bisheng_sdk"]`(目录带连字符时缺它 = 空 wheel,F053 D1)。`[tool.ruff]` 照 CLI(含 `RUF001/002/003` ignore——异常文案是中文)。版本单一真相 `bisheng_sdk/__init__.py:__version__ = "0.1.0"`。
+- **原因**:与 CLI 同一套工具链与 CI 形态(`cli-quality.yml` 三 leg:locked / highest / wheel smoke),零选型成本;`0.x` 起版是因为 spec 决议-6 要求独立于平台号——SDK 被应用依赖清单锁定、随版本快照走,若与平台同号则平台每次升级都让存量应用不兼容(重发必审 INV-34)。
+- **何时该重新考虑**:出现第二个语言的 SDK → 目录改 `src/sdk/python/`;或 `httpx` 在客户内网镜像普遍缺失 → 退回 `urllib` 实现(代价:无连接池、无流式上传)。
+
+### D2:公开面 = 恰三个能力模块 `auth` / `retrieve` / `storage` + 一个异常模块 `errors`;其余全部下划线私有;准入门槛写进 `test_public_surface.py`
+
+- **备选**:A. 把异常类挂进各能力模块(`auth.NoIdentityError`)— 三处重复、跨模块共用的 `PlatformUnreachableError` 无处安放;B. 顶层 `bisheng_sdk.SdkError` 单一异常 — AC-19 / AC-25 要求可区分;C. **`errors.py` 一个公开模块承载全部异常层次**(选定)。
+- **AC-33 的自动化校验**:`test_public_surface.py` 用 `pkgutil.iter_modules(bisheng_sdk.__path__)` 枚举,断言**非下划线模块集合 == {auth, retrieve, storage, errors}**、`bisheng_sdk.__all__ == ("auth", "retrieve", "storage")`、`import bisheng_sdk.chat` / `.appdb` / `.llm` / `.db` / `.client` 全部 `ModuleNotFoundError`、全包无名为 `as_user` / `login` / `verify_token` / `impersonate` 的公开符号。**`errors` 不是能力模块**,spec AC-33「恰为三模块」指能力面;异常模块是三模块的共同出错形状,不违背准入门槛。
+- **准入门槛(流程规则,spec 决议-10)**:新增第四个能力模块须同时证明「平台特有」∧「写错了会出安全事故」,并**先改 `test_public_surface.py` 的集合再写代码**——把门槛做成一条会红的测试,比写在备忘录里可靠。chat / appdb 的否决理由见 PRD-1 DEV-07 两表,不重抄。
+- **何时该重新考虑**:PRD-2「密钥引用」进入能力总线且过门槛 → 另起 AC 后加模块。
+
+### D3:请求上下文 = `ContextVar` + 纯 ASGI 中间件 / WSGI 中间件 / 显式 `auth.bind()`;不要求把 request 传给每个调用
+
+- **备选**:A. 每个 API 显式收 `request` / `headers` 参数 — 违背「一行到位」,且 retrieve / storage 都要带;B. 线程局部变量 — asyncio 下失效;C. 进程级全局 — 违反 AC-09;D. **`contextvars.ContextVar` 存本请求的注入头快照,由框架适配层在请求边界 set / reset**(选定)。
+- **三个接法(指南与技能包只教这三种)**:
+  1. **ASGI**(FastAPI / Starlette):`app.add_middleware(bisheng_sdk.auth.ASGIMiddleware)`。⚠️ **实现必须是纯 ASGI 可调用(`__call__(scope, receive, send)`),不能继承 Starlette `BaseHTTPMiddleware`**——后者把 `call_next` 放进独立任务,ContextVar 在任务边界只复制不回传,`reset` 会抛 `ValueError: Token was created in a different Context`(坑 14)。只处理 `scope["type"] in ("http", "websocket")`;WebSocket 取握手 scope 的 headers(AC-09)。
+  2. **WSGI**(Flask 等):`app.wsgi_app = bisheng_sdk.auth.WSGIMiddleware(app.wsgi_app)`;WSGI 把头折成 `HTTP_X_BISHENG_USER_ID`,适配层按 `headers.py:78-80` 的归一化(小写 + `_`→`-`)还原。
+  3. **显式绑定**(Streamlit、无中间件钩子的框架、测试):`with bisheng_sdk.auth.bind(headers): ...` 或脚本顶部 `bisheng_sdk.auth.bind(st.context.headers).__enter__()`(Streamlit 每次 rerun 是新线程、新空上下文,必须在脚本顶部绑,坑 13)。`auth.from_headers(mapping) -> Identity` 是无上下文的纯函数版本,供握手回调等一次性场景。
+- **中间件不拒绝无头请求**:没有注入头的请求(健康探活)照常进入应用,只是上下文为空;**只有调用 `current_user()` 那一刻才抛**(AC-07 的落点在读取处,不在中间件)。
+- **原因**:`ContextVar` 是标准库、对 asyncio 任务与线程都有正确的隔离语义(新任务复制、新线程为空);后台线程 / 任务里 `current_user()` 抛错正是 spec §3 要的行为。
+- **何时该重新考虑**:出现主流框架既非 ASGI 也非 WSGI 且无请求头钩子 → 加第四种适配;不动核心。
+
+### D4:`Identity` 形状 = 十头原样映射、`str` 类型标识、令牌不上对象、缺省部门为 `None`、`subject_kind` 透传 `human` / `service_account`
+
+- **备选**:A. `user_id: int` — 本地 `dev` 期 `X-BiSheng-User-Id` 是**服务账号 id**、不是 `user` 表行(F053 design K15「服务账号不是 user 表的行」),应用若按 int 去 join 平台用户会得到错的人;B. SDK 把 `human` 翻成 `natural_person` 对齐 `api_credential` 常量 — AC-06 明令「原样暴露、不自行推导或补全」;C. **全部 `str`、透传**(选定)。
+- **形状**(frozen dataclass,`bisheng_sdk.auth.Identity`):`user_id: str` · `user_name: str` · `tenant_id: str` · `dept_id: str | None` · `dept_name: str | None` · `dept_path: str | None` · `subject_kind: str`(线上恒 `"human"`:`entry_authz_service.py:_user_facts`(`:310-322`)硬编码;`dev` 期**取决于 `login` 用的密钥**——服务账号密钥 → `"service_account"`、个人访问令牌(`bs-pat-`)→ `"human"`,`devproxy.py:DevIdentity.from_whoami`(`:204-213`)按 `whoami.actor_kind` 判,坑 33)· `app_id: str` · `request_id: str | None`。⚠️ **没有 `access_token` 字段**(CON-6);`repr` 不含任何头值以外的东西。
+- **解码**:非 ASCII 值在注入侧被 `quote(text, safe="/")`(`entry_authz_service.py:_encode`、`app-proxy/headers.py:encode_header_value`),SDK 对 `User-Name` / `Dept-Name` / `Dept-Path` 做 `urllib.parse.unquote`;**缺失的部门三头是"不发"而不是"发空串"**(`headers.py:161-164`),SDK 映成 `None`(AC-10:服务账号无部门)。`X-BiSheng-User-Id` 缺失 → `PlatformIdentityMissingError`。
+- **与 spec 的口径差(全自动模式定案:不改 spec、由 design 记差)**:spec AC-06 / AC-10 写「`dev` 期返回开发者**服务账号**、组织字段可为空」,而已落地的 `bisheng dev` 允许用个人访问令牌(`bs-pat-`)`login`,此时注入的是**自然人**身份(`subject_kind="human"`、可能有部门)。两者对 SDK 是同一条码路(原样透传,AC-06「不自行推导或补全」),AC-10 在服务账号密钥这一主用法上仍逐字成立;把 PAT 情形写进 spec 会把 F053 的密钥策略拽进 F057 的 AC。指南按「取决于你 login 用的密钥」写(T033),本地/线上差异清单里补这一句。
+- **何时该重新考虑**:F050 若给注入头加字段(如角色)→ 在 `_headers.py` 追加常量 + `Identity` 追加可选字段,三处对账测试会先红。
+
+### D5:retrieve 送**两把凭据**——`Authorization: Bearer `(哪个应用)+ `X-BiSheng-Access-Token`(为谁),投 `POST /api/v2/filelib/retrieve`;SDK 对令牌形态不感知
+
+> **2026-09-16 按已合入的 F055 实现改写(原方案作废)。** 本文初稿选的是「Bearer = 注入的访问者凭据、不带应用凭据」,理由是 CON-3 的「两类凭据不混用」。F055 落地的托管运行契约不是这样:`open_endpoints/api/endpoints/filelib.py` 的 `retrieve_chunks` 按 `principal.actor_kind == HOSTED_APP_ACTOR_KIND` 分流到 `CapabilityBusService.retrieve`,**应用身份来自 Bearer 的运行期凭据**(`principal.subject_ref` = `app.id`,白名单由它定),**访问用户来自 `hosted_app_access_user(request, principal)` 读的 `X-BiSheng-Access-Token`**(经 `model_range_policy.get_access_subject_verifier()` 验签)。两把凭据回答的是两个不同的问题,缺任何一把都不成立。跨 Feature 裁定:**以 F055 的实现为准,不再回头改服务端**。
+
+- **备选**:A.(初稿)只送访问者凭据作 Bearer — `credential_validator._TOKEN_RE` 只认 `bs-sak-` / `bs-pat-`,OBO 令牌答 `26001`;且就算平台受理了,门面也无从知道是哪个应用在调、取不到白名单,与 AC-13「白名单 ∩ 用户可见范围」直接冲突。**已作废**;B. 应用凭据 + `X-On-Behalf-Of: ` 走 F050 模式 D — 委托是另一套信任机制,且 `model_range_policy` 明确**拒绝**托管应用发委托头(`ModelFaceIdentityHeaderRefusedError`):应用不得自报「我为谁调用」,访问者必须由平台签发的凭据证明;C. 新开专用端点 `POST /api/v2/apps/self/retrieve` — F052 AC-26「门面是开放面上唯一检索路径」,再开端点是第二条路径;D. **两把凭据投既有的 `POST /api/v2/filelib/retrieve`**(选定 = F055 已实现的形状)。
+- **角色不可对调**:应用凭据证明「哪个应用」→ 平台取该应用当前生效声明的知识库白名单;访问者凭据证明「为谁」→ 平台取该访问用户的可见范围;结果 = 两者的交集(文件级)。只送应用凭据 → `CapabilityBusService.retrieve` 拿到 `access_user_id=None`,以「无执行身份」拒绝(`26320`,AC-52,**没有 owner 兜底**);只送访问者凭据 → 平台不认识这个应用(`26001`)。SDK 因此在发请求之前就把两把凭据都要齐:缺访问者 → `VisitorCredentialMissingError`,缺应用凭据 → `AppCredentialMissingError`(新增类,见 D6),两者都是零请求。
+- **对令牌形态不感知**:访问者凭据线上是 app-proxy 注入的 HS256 OBO JWT(aud `bisheng-app-obo`、900 s,`entry_authz_service.py`);`dev` 期是 `bisheng dev` 迷你代理每请求现铸的 `bsdev..` 句柄(`devproxy.py:HandleMinter.mint`、**本地自签**)。SDK 只做「上下文里有就带、没有就抛」,**不解析、不校验、不续期**——两种形态对 SDK 是同一条码。
+- **本地 `dev` 期仍不可端到端验证,原因换了一个**:`bisheng dev` 既不注入 `BISHENG_APP_TOKEN`(没有应用运行期凭据可发——本地根本没有「已上线的应用」),其 `bsdev.` 句柄也无从被平台验签(坑 32)。SDK 侧的呈现是 `AppCredentialMissingError`("本地期平台尚未注入该凭据,检索请在发布后用真实账号验证"),**不为本地开兼容分支、不拿 `login` 密钥顶替应用凭据**——顶替等于让本地检索以服务账号身份跑,与线上语义不同,正是 spec 决议-3 要挡住的「本地能跑、线上不一样」。若 F053 后续要补,修法是「`dev` 向平台换一枚该应用的短时运行期凭据 + 一枚平台签发的访问者凭据」,SDK 零改动。
+- **服务端仍未补的一半**:`RetrieveReq.knowledge_base_ids` 今天 `min_length=1` 必填(`open_endpoints/domain/schemas/filelib.py`),F052 AC-22「未指定目标 → 在全部被授予范围内检索」要求它可省略。SDK 已按「`None` 即省略该键」落码,放宽当天零改动;在那之前不带库 id 的调用会得到 422 → `PlatformRefusedError`(坑 6)。
+- **入参出参 = 门面的入参出参**(AC-11 / AC-18):`search(query, *, knowledge_base_ids=None, top_k=10, max_content=15000, filters=None)`,字段名与 `RetrieveReq` 一一对应,`filters` 形状 = `RetrieveFilters`(**`tag_match_mode` 的字面量是大写 `"ANY"`**,`"ALL"` 服务端未实现会答 400,SDK 不替它翻译成 `ANY`——那会返回比调用方要的更宽的结果集);`knowledge_base_ids=None` 时**省略该键**(`extra="forbid"`,不能送 `null` 以外的自造值),`[]` 则原样送出、由服务端拒(今天 `min_length=1`);出参 `RetrieveResult(chunks: list[Chunk], total: int)`,`Chunk` 六字段照 `RetrieveChunk`。SDK 不排序、不去重、不截断、不缓存。
+- **何时该重新考虑**:F052 design 若把门面独立成新路径(如 `/api/v2/knowledge/retrieve`)→ 只改 `_http.py` 的 `RETRIEVE_PATH` 常量与 `_codes.py`。
+
+### D6:错误层次 = 一个基类 + 按「应用的下一步动作」分格的 18 个子类;服务端码经 `_codes.py` 一张表映射,未登记码绝不吞
+
+- **备选**:A. 直接抛 `httpx.HTTPStatusError` — 应用要读 HTTP 状态 + 信封才能分支,正是 SDK 该消化的;B. 一个 `SdkError(code)` — AC-19 / AC-25 要求可区分且各附可读原因;C. **异常子类 = 动作分格**(选定,同 F053 D9「一码一处置」判据)。
+- **层次**(全部在 `bisheng_sdk.errors`,基类带 `message` / `next_step` / `code: int | None` / `details: dict | None`,`__str__` = `redact(f"{message}(下一步:{next_step})")`):
+
+  | 异常 | 触发 | 应用的下一步 |
+  |---|---|---|
+  | `PlatformIdentityMissingError` | `current_user()` 时上下文无 `X-BiSheng-User-Id` | 这条路径不该调 auth(探活 / 后台任务),或未经入口访问 |
+  | `VisitorCredentialMissingError` | retrieve 时上下文无 `X-BiSheng-Access-Token`(含无上下文) | 同上;线上另查 `app_runtime.obo_secret`(坑 4) |
+  | `AppCredentialMissingError`(2026-09-16 新增,D5) | retrieve 时进程内无 `BISHENG_APP_TOKEN` | 确认实例经平台发布上线;本地 `dev` 期该凭据不存在,检索在发布后验证 |
+  | `VisitorCredentialRejectedError` | HTTP 401(`26001` / `26002` / `26027` 或无信封)、以及 **`26320`「无执行身份」**(平台没能从访问者凭据确立访问用户,AC-52) | 凭据过期 / 会话失效 / 应用下线,让用户刷新重进 |
+  | `ScopeMissingError(required)` | HTTP 403 + `26003`(`data.required` 是**单个字符串**,`errcode/open_api.py:36-39`) | 本地期:请管理员给密钥勾 `knowledge:read` |
+  | `TargetUnreachableError(ids)` | **`26321`**(F052 `KnowledgeUnreachableError`,缺失 / 未授予 / 类型不支持 / 白名单外合并成一个码,`data.unreachable_ids`) | 去掉不可及的库 id;托管期检查能力声明 |
+  | `CapabilityRevokedError(capability, reason)` | `16273`(F055 能力总线)与 **`26322`**(F052 `KnowledgeCapabilityRevokedError`,`data.knowledge_id`) | owner 重新声明 / 找管理员 |
+  | `CapabilityNotDeclaredError(capability)` | `16274` | 在 `bisheng-app.yaml` 声明后重发 |
+  | `PermissionEvaluationError` | `26030`(`OpenApiAuthDependencyUnavailableError`,HTTP 503) | 稍后重试,**不得**改小范围重试 |
+  | `PlatformRefusedError(code, message, details)` | 任何未登记的业务码(含 422 校验失败) | 按 message 处置;**永不吞掉** |
+  | `PlatformUnreachableError` | 连接 / 超时 / 无码 5xx / `BISHENG_PLATFORM_API_BASE` 未注入 | 检查平台地址与网络 |
+  | `SdkIncompatibleError(sdk_version, min_compatible, platform_version)` | 版本探测 `min_compatible > __version__` | 从当前平台重新获取 SDK 后重发 |
+  | `PlatformTooOldError` | `versions` 404 或 `sdk` 块为 null | 升级平台 / 确认开放能力层已部署 |
+  | `StorageHandleMissingError` | 既无 `BISHENG_APP_STORAGE_ENDPOINT` 也无 `_DIR`、**两者都有**、或有 `ENDPOINT` 无 `TOKEN` | 经 `bisheng dev` 启动 / 检查注入 |
+  | `StorageHandleRejectedError(reason)` | 附件 API 401 `unauthorized`(令牌不属本应用 / 应用已 destroy / 未带 Bearer;**没有 403、没有「下线」专用码**,坑 28) | 重启实例 / 重新上线;仍 401 则找管理员 |
+  | `StorageUnavailableError` | 附件 API 503 `storage_unavailable`(manager 未配 MinIO / MinIO 不可达)、连接失败(坑 27)、无码 5xx | 稍后重试;线上查 `runtime/status` preflight `attachment_storage` |
+  | `AttachmentNotFoundError(path)` | 404 `not_found` / 本地不存在 | — |
+  | `AttachmentTooLargeError(path, limit_bytes)` | 客户端按 `BISHENG_APP_STORAGE_MAX_FILE_MB` 预判(两端同名)/ 服务端 413 `payload_too_large` | 分片或缩小 |
+  | `InvalidAttachmentPathError(path)` | 客户端路径校验(= manager `validate_key` 规则)或服务端 400 `invalid_object_key` | 用应用内相对路径 |
+
+- **映射顺序**(照 CLI 坑 12):先读信封——backend 形状 `{status_code, status_message, data}`(`/api/v2` 是真 HTTP 状态 + 信封 body;`/api/v1` 是 HTTP 200 + 信封)或 manager 形状 `{"detail": {"code", "message", …}}`(附件 API,坑 26)——再按 HTTP 状态类兜底(401 → Rejected、403 → Refused、5xx → Unreachable / Unavailable),三者都判不了才 `PlatformRefusedError`。**`details` / `data` / `detail` 原样挂在异常上**,不裁剪。
+- **何时该重新考虑**:F052 / F055 若把「不可及」与「已收回」合成一个码 → 先反对(GOV-05 要求两者可区分)。
+
+### D7:版本兼容 = 首次平台交互前、每进程一次的懒探测 `GET /api/v1/dev-toolkit/versions`;`min_compatible` 三段元组比较;auth 永不探测
+
+- **备选**:A. import 时探测 — 让 `import bisheng_sdk` 打网络,单测与探活都被拖下水;B. 每次调用都探测 — 多一倍请求;C. **懒探测 + 进程内按 base URL 缓存成功结果**(选定)。失败(不可达)**不缓存**,下次再试。
+- **判据**:`sdk.min_compatible <= __version__`(按 `_version_tuple`,与 CLI `http.py:_version_tuple` 同算法)否则 `SdkIncompatibleError`;`versions` 404 或 `data.sdk` 为 null / `sdk.version` 为 null → `PlatformTooOldError`(文案同时点明「开放能力层未部署」的可能,AC-05 不另设分支)。`platform.version` 只用于文案。
+- **原因**:spec 决议-6「首次交互即报错」;auth 不交互(AC-03 备注),所以指南要求应用启动时跑一次自检(D12 的 `selfcheck.py` 正是它)。不比较上界:平台升级后老 SDK 仍在区间内即无需重发(AC-03)。
+- **何时该重新考虑**:F055 若在托管预检加「应用锁定的 SDK 版本 ∈ 平台区间」校验 → 本条不变、只是暴露更早。
+
+### D8:storage 句柄契约 = 托管期 `BISHENG_APP_STORAGE_ENDPOINT` + `_TOKEN` + `_MAX_FILE_MB`(F054 T084 / T085 **已实现**的 HTTP 句柄,落 runtime-manager),本地期 `BISHENG_APP_STORAGE_DIR`(目录句柄);同一 API 两个后端,按有无 `ENDPOINT` 分辨
+
+- **事实(`wt/storage-handle` `23886547f`,尚未合入 `3.0-vibe`,坑 30)**:F054 T084 / T085 已落码——`runtime_manager/storage.py`(`STORAGE_ENV_NAMES`、`validate_key`、`AppStorageService` 四操作 + `purge_app`、`storage_env()`)、`runtime_manager/api/storage.py`(router `prefix="/v1/apps/{app_id}/storage"`,Bearer 绑 `app_id`、无 Bearer 走 HMAC)、`lifecycle.build_env` 注入三个变量、backend 契约副本 `app_runtime/domain/constants.py:APP_STORAGE_ENV_NAMES`,F054 contracts §2「附件存储句柄细则」/ §5。**本文初稿(同日上午)建议的 `BISHENG_APP_STORAGE_URL` + 落 backend `/api/v1/app-storage/*` 已作废**:F054 先落码、SDK 是消费者,按对方为准,不再回写。
+- **备选(曾考虑,留痕)**:A. 注入 MinIO endpoint + 桶 + 前缀 + 凭据,SDK 直连对象存储 — AC-21 明令不暴露,F054 也明确不注入(`constants.py:185-187`);B. 注入预签名 URL — 有效期与四操作矩阵都不好表达;C. 附件 API 落 backend `/api/v1/*` 经网关 — 本文初稿建议;F054 选了 **manager 直连 + `RTM_APP_FACING_BASE_URL`**(manager 已持 MinIO 客户端、backend 零改动,代价 = 可达性要 ops 配网桥网关地址,坑 27);D. **HTTP 句柄 = manager 应用面 URL + 每应用不透明 Bearer**(F054 选定,本文承接)。
+- **托管期形态**:`BISHENG_APP_STORAGE_ENDPOINT = {RTM_APP_FACING_BASE_URL}/v1/apps/{app_id}/storage`(`storage.py:storage_endpoint_for`);`BISHENG_APP_STORAGE_TOKEN` = `secrets.token_urlsafe(32)`,每应用一把、redeploy 沿用、destroy 后才轮换(`lifecycle.py` T085 段);`BISHENG_APP_STORAGE_MAX_FILE_MB` = `RTM_STORAGE_MAX_FILE_MB`(缺省 20),F054 注入它就是**为了让 SDK 发送前能拒**。`app_id` 只在 URL 里、由 F054 注入,SDK 不拼、不改、不发。
+- **本地期形态**:`BISHENG_APP_STORAGE_DIR` 由 `bisheng dev`(F053 T043)注入,值 = `<项目根>/.bisheng/attachments/` 的绝对路径。⚠️ **已落地的 `bisheng dev` 还没有注入它**:`wt/cli-dev` 的 `devdb.py:INJECTED_ENV`(`:48-58`)只有 `BISHENG_APP_DB_*` / `BISHENG_APP_ID` / `BISHENG_APP_SLUG` / `BISHENG_APP_VERSION*` / `BISHENG_PLATFORM_API_BASE` / `PORT` 一族,无任何 `BISHENG_APP_STORAGE_*`(坑 34)——本地期 storage 在该注入补齐前恒 `StorageHandleMissingError`,登记为 §6.2 契约 ⑦(回写 F053)。选 `.bisheng/` 之下是因为它已是 CLI 的**硬排除**目录(`bisheng_cli/ignore.py:42-56`,`!` 取不回),比软排除的 `attachments/` 更强地保证附件不进上传包(F053 AC-32);`dev` 同时写 `<项目根>/.bisheng/.gitignore`(内容 `attachments/` + `*.db`),使 F053 D13「建议把 `.bisheng/app.json` 提交进 git」与「附件不进 git」并存。`BISHENG_APP_STORAGE_MAX_FILE_MB` 本地**可选、同名**(`dev` 可注入以模拟线上上限;不注入 = 不限,坑 23)。
+- **后端选择(`storage._backend()`,每次调用重算、不缓存)**:有 `ENDPOINT` → 远端(此时 `TOKEN` 缺 → `StorageHandleMissingError`「句柄不完整」);无 `ENDPOINT` 有 `DIR` → 本地;**两者都有** → `StorageHandleMissingError`「句柄不唯一」(环境配错,宁可停下也不猜——spec §3「不静默改写到本地临时目录」同向);都没有 → `StorageHandleMissingError`。F054 契约 §5 原话「SDK 按有无 `ENDPOINT` 分辨两种形态」。
+- **单文件上限**:两端都先按 `_MAX_FILE_MB` 客户端预判(数据长度已知时)→ `AttachmentTooLargeError(limit_bytes = MB × 1024²)`,零请求 / 零落盘;线上服务端再判一次答 413 `payload_too_large`(先看 `Content-Length` 再按流计数,`api/storage.py:_read_capped`),SDK 原样呈现。
+- **何时该重新考虑**:PRD-2「附件字段」需要短时下载句柄 → 由 F054 在 `/v1/apps/{app_id}/storage/` 上加 presign,SDK 再暴露。
+
+### D9:storage API 面 = 六个模块级函数(put / get / open / stat / list / delete)+ `AttachmentMeta`;无清空、无批量删除、无直链
+
+- **备选**:A. 类 S3 的 `Bucket` / `Object` 对象模型 — 把对象存储心智带给应用,正是要藏的;B. 只有 put / get — AC-20 要四操作含元信息;C. **函数面**(选定):`put(path, data: bytes | BinaryIO | PathLike, *, content_type=None) -> AttachmentMeta` · `get(path) -> bytes` · `open(path) -> BinaryIO`(流式,大文件)· `stat(path) -> AttachmentMeta` · `list(prefix="", *, limit=None) -> list[AttachmentMeta]`(内部翻页取完)· `delete(path) -> None`。`AttachmentMeta(path, size, content_type, modified_at: datetime)`。
+- **路径规则(`_paths.py` = manager `runtime_manager/storage.py:validate_key` 的逐条镜像,对账测试守住)**:非空 `str`;UTF-8 ≤ 1024 字节;不含控制字符(`< 0x20` 与 `0x7F`);不含 `\`;不以 `/` 开头、**不以 `/` 结尾**;按 `/` 分段后段不得为空、`.`、`..`;`posixpath.normpath(key) == key`(规范形);**不以 `apps/` 开头**(manager 保留命名空间,哪怕是自己应用的前缀也拒)。违反 → `InvalidAttachmentPathError`,**不规范化后放行**(`a/../b` 是拒绝,不是变成 `b`)。`list(prefix)` 的 `prefix` 允许空或以 `/` 结尾,其余同规则(`validate_prefix`)。
+- **无清空**(AC-24):不提供 `clear()` / `delete_prefix()`;`delete` 只收单个路径。**无直链**(AC-22):不提供任何返回 URL 的函数。
+- **何时该重新考虑**:AC-24「按显式清单」若成为高频需求 → 加 `delete_many(paths: Sequence[str])`,仍拒绝前缀语义。
+
+### D10:同步 + 异步双形态(`retrieve.search` / `asearch`,storage 六函数各有 `a` 前缀异步孪生);auth 只有同步
+
+- **备选**:A. 只同步 — FastAPI 是指南推荐框架,在事件循环里做阻塞 HTTP 是经典事故,且 retrieve 是每请求调用;B. 只异步 — Flask / Streamlit 用户要自己跑 loop;C. **两套,共用一份请求构造与错误映射**(选定,`_http.py` 内以 `httpx.Client` / `httpx.AsyncClient` 各一)。
+- **auth 只同步**:它是纯上下文读取、零 I/O,没有异步的理由。
+- **原因**:httpx 天然双形态,代价只是薄薄一层包装;单测对两条路径跑同一组用例(参数化)。
+- **何时该重新考虑**:无。
+
+### D11:HTTP 客户端策略 = 进程内按 base URL 复用连接池、代理变量**仅托管期读**、分档超时、**零重试**
+
+- **落地**:`_http.py` 持 `dict[base_url, httpx.Client]`(线程安全的懒建);超时 connect 5 s、retrieve 读 30 s、storage 读 / 写 120 s;`trust_env` = 注入了 `BISHENG_APP_EGRESS_TOKEN`(托管期)**或** `BISHENG_SDK_TRUST_ENV=1`,否则 `False`——本地不读,开发机上公网代理会劫持内网地址(F053 坑 2 的同型);托管期必须读:F054 T077 起托管应用挂在 `--internal` 网上,除网关外没有路由,平台 API 唯一的路是注入的出站代理(平台地址在代理白名单里恒为 trusted,contracts-runtime-manager.md §9 第 4、5 条),附件句柄地址在注入的 `NO_PROXY` 里、照旧直连。**2026-09-17 修订**:原定「一律 `trust_env=False`」的理由是「托管容器里代理变量会把平台调用送进被封的出站」,与 T077 落地的白名单不符;114 把应用网改成 internal 后实测直连平台 = `Network is unreachable`,`retrieve` 与版本探测必挂。判据选出站凭据,是因为它只由 runtime-manager 在开层时注入、`bisheng dev` 从不设,本地行为因此不变;SDK 只判存在、不取值;**不重试**:PUT 非幂等,且 AC-16 明令「不本地重试成更小范围」——重试是应用的决定。
+- **何时该重新考虑**:附件上传出现真实丢包 → 由 F054 在附件 API 上做分片 + 幂等键,SDK 再加可控重试;runtime-manager 改了出站凭据的变量名 → 同步 `_env.ENV_EGRESS_TOKEN`(`test_contract_alignment.py::test_egress_token_env_name_matches_the_manager` 钉住);出站代理不再对平台地址放行、或平台 API 改由应用网内直达 → 判据要跟着改。
+
+### D12:指南与技能包同源一份 = `bisheng/dev_toolkit/skills/platform-wiring/SKILL.md`(**F053 已建,本 Feature 做增量**);开发者指南端点直接吐它;README 只做入口
+
+- **备选**:A. 指南独立写在 `src/bisheng-sdk/README.md`、技能包另写 — 两份必漂移(spec 决议-7);B. 指南 = 技能包 SKILL.md 的生成物 — 多一个生成步骤;C. **技能包 SKILL.md 就是指南,`GET /api/v1/dev-toolkit/sdk-guide.md` 读同一文件**(选定,照 `get_install_guide` 的 `text/markdown` + 缺失 404 形态,`distribution.py:118-137`)。
+- **今天的包**(`wt/cli-dev` `b61b209e4`,F053 T038 已交付;226 行 SKILL.md + `example/`(标准库 FastAPI-free 样例:读头 + `sqlite3` 便签)+ `selfcheck.py`(109 行:凭据 / 平台可达 / `whoami` / 应用库变量)):目录五章 = ① 访问者身份(读头,`> ⚠️` 警示块已就位,**末尾一节「SDK 用法(随后续版本补齐)」是留给本 Feature 的桩**)② 应用数据库 ③ 平台模型(**暂未提供**,F051 前不得发明 base URL)④ 本地运行 `bisheng dev` ⑤ 自检清单。
+- **本 Feature 的增量(不是重写,坑 31)**:① 在 auth 章把那节桩换成 SDK 三件套的 auth 用法(`current_user()` 一行 + 三种接法 + AC-07 抛错 + 「健康端点不调 auth」),**警示块、章序、目录第一条位置一律不动**——F053 的 `test_skill_packs.py::test_auth_chapter_is_first_in_toc_and_in_the_body_and_opens_with_the_warning` / `test_auth_chapter_teaches_exactly_app_proxys_header_names` 按现状断言,改了会先红;② 新增 retrieve 章(per-user 语义 + 四处本地/线上差异 + 错误类速查)与 storage 章(六函数 + 本地目录 vs 平台存储 + 不计配额 + 无直链),插在 auth 章之后、应用数据库章之前,并同步更新目录;③ 模型章**保持「暂未提供」**(F051 未落地,spec AC-30 的模型半边由 F051 验收,本轮只在 SDK 章点明「模型不进 SDK」);④ `example/` 追加一个用 SDK 三件套的版本,**保留现有标准库样例**(`test_example_is_stdlib_only` 对两个包都断言,新增依赖会先红 → 新样例另置 `example-sdk/` 并在 `SKILL.md` 指路,D12 落地细则见 tasks T034);⑤ `selfcheck.py` 追加 SDK 三步(D13 的 ①④⑤)。
+- **分发零改动**:`artifact_service.read_skill_pack` 按目录名打包(`artifact_service.py:211-231`),目录内容变更即自动随包下发;**CLI 侧 `DEFAULT_PACKS`(`bisheng_cli/commands/skills.py:57`)已由 F053 改成 `("deploy-hosting", "platform-wiring")` 并重打过 CLI wheel**(`wt/cli-dev`)——`3.0-vibe` 上仍是单元素元组,两分支合并后本 Feature 只需核对而不是再改一次(坑 35 / §6.1)。
+- **何时该重新考虑**:技能包数量 ≥ 4 → `DEFAULT_PACKS` 改由 `versions` 载荷下发。
+
+### D13:自检脚本 = 在 F053 已交付的 `selfcheck.py` 上**追加** SDK 三步;只依赖标准库 + 可选 `bisheng_sdk`,每步一句可读原因;退出码 0 仅当全过
+
+- **既有脚本**(`skills/platform-wiring/selfcheck.py`,F053 T038):读 `~/.bisheng/credentials.json` → 打 `/api/v2/auth/whoami` → 校验应用库变量;`fail(reason, next_step)` 打两行、`raise SystemExit(1)`、**不打堆栈不打密钥**。本 Feature 沿用它的 `fail()` 与输出风格,只加步骤,不重写脚本骨架(`test_selfcheck_reports_readable_reason_when_not_logged_in` 对两个包参数化断言)。
+- **步骤**(① ② ④ ⑤ 为本 Feature 新增,③ 复用既有 whoami 段):① `import bisheng_sdk`(失败 → 打印 `pip install` 自平台端点的命令);② `GET /api/v1/dev-toolkit/versions` 比对 `sdk.min_compatible`(AC-03 文案含双方版本与处置);③ auth:有 `BISHENG_APP_ID` 且能对本地入口发一次探测请求(`dev` 期经迷你代理)→ 用 `auth.from_headers` 解析响应回显的头;否则提示「请经 `bisheng dev` 启动」;④ retrieve:`search("selfcheck", top_k=1)`,逐类异常翻译成一句(`ScopeMissingError` → 找管理员勾位;`VisitorCredentialRejectedError` 且 401 → 「平台尚未受理访问者凭据(F052 门面未就绪)」——坑 5 的如实呈现);⑤ storage:`put("_selfcheck/probe.txt")` → `stat` → `delete`。任何一步失败:`✗ 没通过:<原因> / 下一步:<动作>`,**绝不打 traceback、绝不打密钥**(照 `deploy-hosting/selfcheck.py:25-29` 的 `fail()`)。
+- **原因**:AC-28;也是 D7「auth 不探测版本」的补位。
+- **何时该重新考虑**:自检步数 > 8 或需要判定平台侧配置(如 `obo_secret` 是否配好)时 → 改为「脚本调一个平台自检端点、只负责呈现」,那时端点归 F053 / F054,本脚本退化成客户端。
+
+### D14:不申请错误码模块(用户预留的 26400–26409 **不使用**)
+
+- **理由**:SDK 侧的可区分性由 Python 异常类承担(D6),它不在 `common/errcode/` 体系内、也不会出现在前端 `api_errors` 文案里;服务端新增的码各归其主——附件 API 归 F054 161 段 `16160-16179` 数据面子段,其中 **`16170–16174` 已由 `wt/storage-handle` 以注释预留**(`app_factory.py:224-234`:`storage_unavailable` / `invalid_object_key` / `payload_too_large` / `not_found` / `unauthorized` 各一,待首个 **backend** 调用方落码;SDK 走 Bearer 直连 manager,遇不到它们)、门面归 F052 / F055 162 段、鉴权归 260;SDK 安装件缺失与 CLI 同型:**真 HTTP 404 + 信封、不是错误码**(`distribution.py:96-117`,F053 CON-8)。申请一个只被 SDK 消费、没有前端文案的模块段,是给 `pnpm check-i18n` 平添三语条目。
+- **何时该重新考虑**:出现「只有 SDK 通道才会遇到、且要在平台审计页显示」的服务端错误 → 那时再向 F054 / F055 申请子段,仍不独立成模块。
+
+### D15:托管构建期取包 = 平台提供 PEP 503 简单索引 `GET /api/v1/dev-toolkit/simple/{,bisheng-sdk/}`,runtime-manager 以 `--extra-index-url` 接入;第三方依赖仍走 `--index-url`
+
+- **备选**:A. 让客户把 wheel 放进自家镜像源 — 每个客户多一步运维,AC-02 明令不要求;B. `--find-links ` — pip 要求链接文件名为合法 wheel 名,而下载端点路径不带文件名(要靠 `Content-Disposition`,pip 的 find-links 不看它);C. 构建期把 wheel 拷进 build context 再 `pip install ./bisheng_sdk-*.whl` — 要 manager 先从 backend 拉 wheel、再改 Dockerfile 模板对每个应用都多 COPY 一层,且 `requirements.txt` 里写 `bisheng-sdk` 的解析仍会去索引找;D. **简单索引 + extra-index**(选定):索引页列出 ``,pip 按文件名与 hash 取;`Dockerfile.j2` 加 `ARG PIP_EXTRA_INDEX_URL` / `PIP_EXTRA_TRUSTED_HOST`(`templates/python3.11/Dockerfile.j2:21-42` 今天只有 `PIP_INDEX_URL` / `PIP_TRUSTED_HOST`),`builder.py:366-369` 多传两个 buildarg,`config.py` 加 `RTM_BUILD_EXTRA_INDEX_URL` / `RTM_BUILD_EXTRA_TRUSTED_HOST`。
+- **为什么是 ops 配的环境变量而不是 backend 推导**:`settings.app_runtime.entry_base_url` 允许为空(「derive from request」,`core/config/app_runtime.py:96-99`),且浏览器可达的入口地址(114 上是 nginx `:4101`)未必是 **docker 构建容器**可达的地址(bridge 网络里 `localhost` 指向容器自己,坑 15)。manager 的 `RTM_BUILD_INDEX_URL` 已是同一形态。
+- **下载端点要接受带文件名的路径**:`GET /api/v1/dev-toolkit/sdk/download` 与 `GET /api/v1/dev-toolkit/sdk/download/{filename}` 同一处理器(后者校验 `filename == manifest.sdk.filename`,否则 404)——pip 从索引页链接取的 URL 末段必须是 wheel 文件名。
+- **何时该重新考虑**:平台开始分发多个 Python 发布件 → 索引页按包名循环,形状不变。
+
+### D16:SDK ↔ 平台分发件的版本真相链 = `bisheng_sdk.__version__` → `scripts/pack_sdk_wheel.sh` → `artifacts/manifest.json["sdk"]` → `/versions` 载荷;`min_compatible` 的单一来源在 backend
+
+- **`sdk.min_compatible` 是平台的声明**,不是 SDK 的:落 `bisheng/dev_toolkit/sdk_compat.py:SDK_MIN_COMPATIBLE = "0.1.0"`,由打包脚本 `sed` 读入 manifest(照脚本读 `__version__` 的做法),端点只读 manifest(`artifact_service.read_snapshot` 的既有原则:版本是构建属性、不读源码常量)。
+- **两个打包脚本改成合并写 manifest**:`pack_cli_wheel.sh:96-110` 今天用 heredoc **整体重写** manifest,且 `:88` `rm -f "${ARTIFACTS_DIR}"/*.whl` 会**删掉 SDK 的 wheel**——两处都改:只删自己前缀的 wheel、用 `python3 -c` 读旧 manifest 合并自己的 section 再写回(坑 9 / 10)。`cli-quality.yml` 的「Artifacts are committed」步骤对 manifest 做 `git diff --quiet`,合并写法保证 CLI 重打不会动 `sdk` 段。
+
+---
+
+## 4. 系统现状(接手必读)
+
+### 4.1 数据流
+
+**A. auth(托管期)**
+```
+浏览器 → app-proxy(验平台会话 → POST /api/v1/internal/app-proxy/authorize → 拿 material + OBO)
+  → strip x-bisheng-* 伪造头 → 注入十头(headers.py:build_upstream_headers)
+  → 应用进程:bisheng_sdk.auth.ASGIMiddleware 把 x-bisheng-* 头快照进 ContextVar(_context.py)
+  → 业务代码 auth.current_user() → _headers.parse_identity(snapshot) → Identity | raise PlatformIdentityMissingError
+```
+`dev` 期同形:入口换成 F053 迷你代理(`bisheng_cli/devproxy.py`,剥 `x-bisheng-*` 前缀后注入同十头),服务账号密钥 `login` → `Subject-Kind=service_account` 且无部门三头;个人访问令牌 `login` → `human`(D4「与 spec 的口径差」)。
+
+**B. retrieve**
+```
+retrieve.search(query, ...) 
+  → _context.access_token()(无 → VisitorCredentialMissingError)
+  → _env.app_token()(无 → AppCredentialMissingError)
+  → _compat.ensure_compatible(base)(每进程一次;GET /api/v1/dev-toolkit/versions)
+  → POST {BISHENG_PLATFORM_API_BASE}/api/v2/filelib/retrieve
+        Authorization: Bearer         ← 哪个应用
+        X-BiSheng-Access-Token: <上下文里的访问者凭据>      ← 为谁
+        body=RetrieveReq
+  → _http.parse_envelope → 200: RetrieveResult ;错误: _codes.map(status, code) → errors.*
+```
+服务端(非本 Feature,**已实现**):`verify_open_api_access` 认出 `actor_kind=hosted_app` → `filelib.retrieve_chunks` 分流到 `CapabilityBusService.retrieve` → 白名单来自 `load_effective_declaration(app_id)`、访问用户来自 `hosted_app_access_user(request, principal)` 验签的访问者凭据 → 交集检索 + 审计双归属(actor=应用 / subject=访问用户)。`access_user_id is None` = 拒绝(`26320`)。
+
+**C. storage**
+```
+storage.put(path, data)
+  → _paths.validate(path)(InvalidAttachmentPathError;规则 = manager validate_key)
+  → storage._backend():ENDPOINT(+TOKEN) → _RemoteBackend ;DIR → _LocalDirBackend ;其余 → StorageHandleMissingError
+  → 已知长度 > BISHENG_APP_STORAGE_MAX_FILE_MB → AttachmentTooLargeError(零请求 / 零落盘)
+  → Remote: PUT {ENDPOINT}/objects/{path}  Authorization: Bearer   body=原始字节流
+            → 200 meta ;错误 {"detail":{"code":…}} → _codes.map_storage(code, http_status) → errors.*
+    Local : tmp 文件 + os.replace 原子落 /
+  → AttachmentMeta
+```
+服务端(F054,已实现):`api/storage.py:verify_storage_caller` 以 `hmac.compare_digest` 比对期望态记录里的 token → `AppStorageService.upload` 强制前缀 `apps/{app_id}/attachments/`、桶 `bisheng-apps`。
+
+**D. 分发与构建**
+```
+scripts/pack_sdk_wheel.sh → uv build → 清 venv 装 wheel 冒烟 → 拷 artifacts/ → 合并 manifest["sdk"]
+backend: GET /api/v1/dev-toolkit/versions(sdk 段)· /sdk/download[/{filename}] · /simple/ · /simple/bisheng-sdk/ · /sdk-guide.md
+runtime-manager 构建:pip install --index-url $PIP_INDEX_URL --extra-index-url $PIP_EXTRA_INDEX_URL -r requirements.txt
+```
+
+### 4.2 关键数据结构 / 字段约定(对外契约)
+
+**① 注入头 → `Identity` 映射**(头名照 `app-proxy/headers.py:29-40`,`_headers.py` 逐字复制)
+
+| 头 | `Identity` 字段 | 解码 | 缺失时 |
+|---|---|---|---|
+| `X-BiSheng-User-Id` | `user_id: str` | 原样 | **抛 `PlatformIdentityMissingError`** |
+| `X-BiSheng-User-Name` | `user_name: str` | `unquote` | `""` |
+| `X-BiSheng-Tenant-Id` | `tenant_id: str` | 原样 | `""` |
+| `X-BiSheng-Dept-Id` / `-Dept-Name` / `-Dept-Path` | `dept_id` / `dept_name` / `dept_path: str \| None` | `unquote`(Path 保留 `/`) | `None`(服务账号 / 无部门) |
+| `X-BiSheng-Subject-Kind` | `subject_kind: str` | 原样(`human` / `service_account`) | `"human"`(与注入侧缺省一致,`entry_authz_service.py:172`) |
+| `X-BiSheng-App-Id` | `app_id: str` | 原样 | `""` |
+| `X-BiSheng-Request-Id` | `request_id: str \| None` | 原样 | `None` |
+| `X-BiSheng-Access-Token` | **不上对象**;`_context.access_token()` 供 retrieve | 原样 | retrieve 抛 `VisitorCredentialMissingError` |
+
+头名匹配按归一化(小写、`_`→`-`)做,与 `headers.py:normalize_header_name` 同规则;WSGI 的 `HTTP_X_BISHENG_*` 同样归一。
+
+**② 环境变量(SDK 读取清单,`_env.py`)**
+
+| 变量 | 谁注入 | SDK 用途 |
+|---|---|---|
+| `BISHENG_PLATFORM_API_BASE` | F054 `lifecycle.py:164`(值 = `settings.app_runtime.entry_base_url`,经 `app_state_service.py:405`)· F053 `dev` 同名(`devdb.py:INJECTED_ENV`,值 = 当前 `login` 平台地址)✅ 已实现 | retrieve 与版本探测的 base URL;空 → `PlatformUnreachableError`(文案点名该变量与 `app_runtime.entry_base_url`) |
+| `BISHENG_APP_STORAGE_ENDPOINT` · `BISHENG_APP_STORAGE_TOKEN` · `BISHENG_APP_STORAGE_MAX_FILE_MB` | **F054 T085 ✅**(`wt/storage-handle`:`lifecycle.py:build_env` → `storage.storage_env`;backend 副本 `app_runtime/domain/constants.py:APP_STORAGE_ENV_NAMES`) | 托管期附件 HTTP 句柄(④);`_MAX_FILE_MB` 用于发送前预判 |
+| `BISHENG_APP_STORAGE_DIR` | **F053 T043 的增补项**(本文定名;`wt/cli-dev` 的 `dev` **尚未注入**,坑 34 / §6.2 契约 ⑦) | 本地期目录句柄(D8) |
+| `BISHENG_APP_STORAGE_MAX_FILE_MB`(本地) | 可选(F053 `dev` 可注入,与线上同名) | 本地期单文件上限;不注入 = 不限(坑 23) |
+| `BISHENG_SDK_TRUST_ENV` | 开发者 | `=1` 时 httpx 读代理环境变量(D11;本地用,托管期不需要) |
+| `BISHENG_APP_EGRESS_TOKEN` | F054 T077(`runtime_manager/egress.py::egress_env`,仅开了出站白名单时注入) | **只判存在**:在 = 托管期,httpx 读注入的 `HTTP(S)_PROXY` / `NO_PROXY`(D11);值不读、不外传 |
+
+SDK 读 `BISHENG_APP_TOKEN`(**只**作 retrieve 的 `Authorization: Bearer`,即「哪个应用」那一把,F055 契约),**不读** `BISHENG_API_KEY` 或任何其它密钥类变量;访问者凭据永不从环境变量取(CON-3;tests 以源码 grep 断言)。
+
+**③ retrieve 线上契约**(= `POST /api/v2/filelib/retrieve` 既有形状,`open_endpoints/domain/schemas/filelib.py:39-69`)
+
+```jsonc
+// 请求  两把凭据(D5):
+//   Authorization: Bearer       ← 应用运行期凭据,定白名单
+//   X-BiSheng-Access-Token: <本请求注入的访问者凭据>  ← 定访问用户;缺它一律拒绝
+{ "query": "…", "knowledge_base_ids": [1, 2],          // None 时省略该键(待 F052 放宽必填)
+  "filters": {"knowledge_base_filters": [{"knowledge_base_id": 1, "tags": ["a"], "tag_match_mode": "ANY"}]},
+  "top_k": 10, "max_content": 15000 }
+// 200 → 信封 data
+{ "chunks": [{"content": "…", "knowledge_id": 1, "document_id": 7, "document_name": "x.pdf",
+              "chunk_index": 3, "document_update_time": "2026-09-01 10:00:00"}], "total": 1 }
+// 错误 → 真 HTTP 状态 + 信封 {status_code, status_message, data}(/api/v2 口径)
+```
+
+**④ storage 附件 API 契约**(**F054 T084 已实现**,`runtime_manager/api/storage.py` + `storage.py`;SDK `_storage_remote.py` 消费——本表是消费者视角快照,以对方源码为准,`test_contract_alignment.py` 对账路由、常量与路径规则)
+
+`{E}` = `BISHENG_APP_STORAGE_ENDPOINT`(已含 `/v1/apps/{app_id}/storage`);所有请求 `Authorization: Bearer {BISHENG_APP_STORAGE_TOKEN}`;`{key}` = 应用内相对路径,按 D9 规则、**逐段 `quote(seg, safe="")` 后拼进 URL**(服务端 `{key:path}` 解码)。
+
+| 操作 | 请求 | 成功 | 失败(真 HTTP 状态 + manager 信封) |
+|---|---|---|---|
+| 上传 | `PUT {E}/objects/{key}`,body = **原始字节**(非 multipart),`Content-Type` 可选(缺省 `application/octet-stream`),`Content-Length` 尽量带(服务端先按它拒 413,缺失则按流计数) | `200 {key,size,content_type,etag,last_modified}` | `400 invalid_object_key` · `413 payload_too_large` · `401 unauthorized` · `503 storage_unavailable` |
+| 下载 | `GET {E}/objects/{key}` | `200` 字节流 + `Content-Type` + `Content-Length`(+ `ETag`) | `404 not_found` |
+| 元信息 | `GET {E}/meta/{key}` | `200 {key,size,content_type,etag,last_modified}` | `404 not_found` |
+| 列举 | `GET {E}/objects?prefix=&cursor=&limit=`(`limit` 1–1000、缺省 100;`cursor` = 上页最后一个 `key`) | `200 {objects:[meta…], next_cursor: str\|null}`,按 key 升序 | `400 invalid_object_key`(prefix / cursor 非法) |
+| 删除 | `DELETE {E}/objects/{key}` | **`200 {}`**(不是 204) | `404 not_found`(缺失不是静默成功) |
+
+- **错误信封 = manager 形状** `{"detail": {"code": "<机器码>", "message": "…", …extra}}`(`runtime_manager/errors.py:RuntimeManagerError`),**不是** backend 的 `{status_code, status_message, data}`——`_http.parse_envelope` 必须认两种(坑 26)。机器码 → 异常:`unauthorized` → `StorageHandleRejectedError(reason=detail.message)` · `invalid_object_key` → `InvalidAttachmentPathError` · `payload_too_large` → `AttachmentTooLargeError` · `not_found` → `AttachmentNotFoundError` · `storage_unavailable` / 连接失败 → `StorageUnavailableError` · 其它 → `PlatformRefusedError(code=None, details=detail)`。**没有 403、没有「应用下线」专用码**:下线 = 期望态记录消失 = 401(坑 28)。
+- **`AttachmentMeta` 映射**:`key` → `path`;`last_modified`(ISO 8601 字符串,MinIO 无值时 `""`)→ `modified_at: datetime | None`;`etag` 保留;`size` / `content_type` 原样。
+- 鉴权:`app_id` 只由 URL(F054 注入)与令牌的绑定关系决定,A 的 token 打 B 的 URL = 401;无 Bearer 的请求走 HMAC(backend / F052 用,**SDK 永不走**)。对象键 = `apps/{app_id}/attachments/{key}`、桶 `bisheng-apps`——对 SDK 与应用**不可见**;不计租户存储配额;backend 错误码 `16170–16174` 由 F054 预留(`app_factory.py:224-234`)、待首个 backend 调用方落码,SDK 不依赖它们。
+
+**⑤ `/versions` 载荷的 `sdk` 段**(今天是 `{"version": null, "min_compatible": null, "download_path": null}` 三键留位,`distribution.py:80`;本 Feature **填值并追加三键**,已有三键语义不变)
+
+```jsonc
+"sdk": { "version": "0.1.0", "min_compatible": "0.1.0",
+         "filename": "bisheng_sdk-0.1.0-py3-none-any.whl", "sha256": "…",
+         "download_path": "/api/v1/dev-toolkit/sdk/download",
+         "index_path": "/api/v1/dev-toolkit/simple/" }      // 未打包 / 未提交 → 整段 null,notice 说明
+```
+
+**⑥ 新增端点**(同 F053 D10:匿名、`open_platform.enabled=false` 时路由不注册 → 404)
+
+| 端点 | 返回 |
+|---|---|
+| `GET /api/v1/dev-toolkit/sdk/download` · `GET …/sdk/download/{filename}` | `FileResponse` wheel;缺失 → 真 404 + 信封 `SDK_MISSING_MESSAGE` |
+| `GET /api/v1/dev-toolkit/simple/` | PEP 503 HTML:`bisheng-sdk` |
+| `GET /api/v1/dev-toolkit/simple/bisheng-sdk/` | HTML:`{filename}`;缺失 → 404 |
+| `GET /api/v1/dev-toolkit/sdk-guide.md` | `text/markdown` = `skills/platform-wiring/SKILL.md`;缺失 → 404 |
+
+**⑦ manifest.json**(`bisheng/dev_toolkit/artifacts/manifest.json`,两个脚本各写自己的 section)
+
+```jsonc
+{ "cli": {…既有…}, "sdk": {"version": "0.1.0", "min_compatible": "0.1.0", "filename": "…", "sha256": "…"},
+  "platform": {"version": "3.0.0"}, "_note": "…" }
+```
+
+### 4.3 关键模块职责
+
+| 模块 / 文件 | 职责 | 不做什么 |
+|---|---|---|
+| `bisheng_sdk/__init__.py` | `__version__`、`__all__`、导出三模块 | 不做任何 I/O |
+| `bisheng_sdk/auth.py` | `Identity`、`current_user()`、`from_headers()`、`bind()`、`ASGIMiddleware`、`WSGIMiddleware` | 不验签、不解析令牌、不读环境变量 |
+| `bisheng_sdk/retrieve.py` | `search` / `asearch`、`RetrieveResult` / `Chunk` / `KnowledgeBaseFilter`;组装两把凭据(应用凭据作 Bearer、访问者凭据作同名头) | 不过滤、不缓存、不排序;**访问者凭据绝不从环境变量取** |
+| `bisheng_sdk/storage.py` | 六函数 + 异步孪生、`AttachmentMeta`、后端选择 | 不暴露 URL / 键 / 桶;不清空 |
+| `bisheng_sdk/errors.py` | 异常层次 + `redact()` | — |
+| `bisheng_sdk/_context.py` | `ContextVar[RequestSnapshot]`、`bind` / `access_token` | — |
+| `bisheng_sdk/_headers.py` / `_env.py` | 头名 / 环境变量名常量、归一化、解码、`parse_identity` | — |
+| `bisheng_sdk/_http.py` | 客户端池、超时、**两种信封**解析(backend `status_code` / manager `detail.code`)、`_codes` 映射 | 不重试 |
+| `bisheng_sdk/_compat.py` | 懒版本探测与缓存 | — |
+| `bisheng_sdk/_codes.py` | backend `int code → 异常类` 表(`26001/26002/26027` · `26003` · `26030` · `26320`–`26322` · `16273` / `16274`)+ manager `str code → 异常类` 表 | — |
+| `bisheng_sdk/_attachment.py` | `AttachmentMeta` 与 `last_modified` 解析(两个后端共用;由 `storage` 再导出) | 不含 bucket / 键 / 端点 |
+| `bisheng_sdk/_paths.py` · `_storage_local.py` · `_storage_remote.py` | 路径规则(镜像 manager `validate_key`)、两个后端;远端后端只认 `ENDPOINT` + `TOKEN`、路径逐段 percent-encode | 不拼 `app_id`、不走 HMAC |
+| backend `dev_toolkit/domain/services/artifact_service.py` | `SdkArtifact` + `DistributionSnapshot.sdk` + `read_sdk_guide()` | 不读 DB |
+| backend `dev_toolkit/api/endpoints/distribution.py` | ⑥ 的四个端点 + `versions` 的 `sdk` 段 | 无鉴权依赖 |
+| backend `dev_toolkit/sdk_compat.py` | `SDK_MIN_COMPATIBLE` 常量 | — |
+| backend `dev_toolkit/skills/platform-wiring/` | **增量**:SKILL.md 的 SDK / retrieve / storage 三段 · 新目录 `example-sdk/`(装 SDK 的 FastAPI 样例)· `selfcheck.py` 追加三步 | 不新建包、不改章序与警示块、不动既有 `example/`(零依赖样例是 AC-32 的第二条合法路径) |
+| `scripts/pack_sdk_wheel.sh` | 构建 → 冒烟 → 暂存 → 合并 manifest | 不动 `cli` 段 |
+| runtime-manager `config.py` / `builder.py` / `templates/python3.11/Dockerfile.j2` | extra index 两个 buildarg(+ `docs/architecture/14-app-factory-deployment.md:151` 环境变量表追两行) | — |
+
+---
+
+## 5. 已知坑 / 反直觉事实
+
+| # | 反直觉事实 | 如果不知道会怎样 | 在哪处理 |
+|---|---|---|---|
+| 1 | `X-BiSheng-Subject-Kind` 线上字面值是 **`human`**(`entry_authz_service.py:_user_facts` 硬编码),不是 `api_credential` 的 `natural_person` | SDK 若按 `natural_person` 判自然人,线上全部误判成"服务账号" | D4 透传;指南写「自然人 = `human`」 |
+| 2 | 部门三头**缺失 = 不发**而非空串(`headers.py:161-164`) | 用 `== ""` 判无部门永远为假 | D4 映 `None` |
+| 3 | 非 ASCII 头值被 `quote(safe="/")`;ASCII 值原样(`_encode`) | 中文名读出来是 `%E5%BC%A0…`;一个含字面 `%E5` 的 ASCII 名会被误解码(病态输入,接受) | `_headers.py` `unquote` |
+| 4 | **OBO 今天没有消费者**:`obo_secret` 空或等于 `jwt_secret` 时 app-proxy **不注入** `X-BiSheng-Access-Token`(`entry_authz_service.py:401-411`,每进程只 warn 一次) | 托管期 auth 正常、retrieve 抛 `VisitorCredentialMissingError`,看起来像 SDK 的 bug | 异常 `next_step` 点名 `app_runtime.obo_secret`;selfcheck ④;§6.2 阻塞项 ② 要求 F052/F055 落地时把签发改 fail-closed |
+| 5 | `validate_bearer` 的 `_TOKEN_RE` 只认 `bs-sak-` / `bs-pat-`(`credential_validator.py`)——**这正是 Bearer 必须是应用运行期凭据(它就是一把 `bs-sak-`)而不是 OBO 令牌的原因**(D5 改写后不再是缺口) | 照初稿把 OBO 当 Bearer 送,恒答 `26001`,开发者会去换密钥——换什么都没用 | D5 选定形状;`VisitorCredentialRejectedError.next_step` 仍提示「让用户重新进入应用」 |
+| 6 | `RetrieveReq.knowledge_base_ids` 必填 `min_length=1`、`extra="forbid"`(`schemas/filelib.py:40-45`) | `search(query)` 不带库 id 今天答 422 → `PlatformRefusedError(422)` | D5 省略键 + §6.2 契约 ③;指南本轮示例必带 `knowledge_base_ids` |
+| 7 | `/api/v1` 业务错误是 HTTP 200 + 信封,`/api/v2` 是真状态 + 信封(F053 坑 12) | 先看 HTTP 状态会把 `/versions` 的降级载荷当失败、把 v2 的 401 当无信封 | `_http.parse_envelope` 先读 body `status_code` |
+| 8 | F053 测试**断言 `sdk` 三键全 null**(`test/dev_toolkit/test_distribution_api.py:80-81`、`:228`;`wt/cli-dev` 上同两处漂到 `:82-83`、`:230`) | 填上 `sdk` 段的那一刻 F053 套件红 | T026 同批改断言(同一 PR) |
+| 9 | `pack_cli_wheel.sh:106-119` 用 heredoc **整体重写** manifest | 下一次重打 CLI wheel 静默删掉 `sdk` 段,平台 `/versions` 又答 null、托管构建装不到 SDK | D16:两个脚本都改成 python 合并写 |
+| 10 | `pack_cli_wheel.sh:97` `rm -f "${ARTIFACTS_DIR}"/*.whl` | 重打 CLI 时把 SDK wheel 一起删了,manifest 还指着它 → `/sdk/download` 404 | 改成 `rm -f bisheng_cli-*.whl`;SDK 脚本同理只删 `bisheng_sdk-*.whl` |
+| 11 | `.gitignore` 的 `build/ lib/ wheels/ sdist/` 无前导斜杠、任意层级生效 | 产物目录改名就 `git add` 静默失败 | 沿用 `artifacts/`,脚本末尾 `git check-ignore` 自检 |
+| 12 | 目录带连字符时 hatchling 不能推断包目录 | `uv build` 打出**空 wheel**,装得上、`import` 失败 | `packages = ["bisheng_sdk"]` + 脚本 `unzip -l` 校验(照 CLI,注意 `pipefail` 下不能 `\| grep -q`) |
+| 13 | Streamlit 每次 rerun 是新线程,`ContextVar` 为空;请求头只能从 `st.context.headers`(≥ 1.37)拿 | 中间件式接法在 Streamlit 下永远 `PlatformIdentityMissingError` | D3 接法 3;指南 Streamlit 段 |
+| 14 | Starlette `BaseHTTPMiddleware` 在独立任务里跑 `call_next`,ContextVar token 跨任务 `reset` 抛 `ValueError` | 中间件第二个请求起崩 | D3:纯 ASGI 可调用 |
+| 15 | docker 构建容器在 bridge 网络:`localhost` 指向容器自身;http 索引要 `--trusted-host` | `RTM_BUILD_EXTRA_INDEX_URL=http://localhost:7860/...` 构建必失败,日志像"pip 拉不到包"= 平台故障 | D15:ops 配宿主机 IP;Dockerfile 加 trusted-host;T032 在 114 验 |
+| 16 | pip 简单索引只认 PEP 503 规范化名 `bisheng-sdk` 与合法 wheel 文件名;`#sha256=` 让 pip 校验 | 链接末段不是文件名 → pip 跳过该链接、静默回落主索引找不到 | D15 下载端点接受 `/{filename}` |
+| 17 | `open_platform.enabled=false` 时 `dev-toolkit` 整个 router 不注册(`api/router.py:141`) | 关开关的平台上托管构建装 `bisheng-sdk` 必 404 | 指南写明;F054 AC-30 / F053 AC-05 前提 |
+| 18 | runtime-manager 日志脱敏按**环境变量名**匹配(`api/readonly.py:57-68`,`*TOKEN*` / `*SECRET*`,短值不脱) | 应用若把 token 复制进别的变量名(如 `MY_KEY`)再打日志就不会被脱敏 | F054 定名 `_TOKEN`(`test_logs_redact_known_injected_secrets` 读真铸造的 token 断言 `***`);指南写「不要复制句柄变量」 |
+| 19 | `entry_base_url` 允许为空(「derive from request」)→ `BISHENG_PLATFORM_API_BASE` 注入空串(`app_state_service.py:405`) | retrieve 打到 `/api/v2/...` 相对地址 → httpx 报 `UnsupportedProtocol`,看不出是配置缺失 | `_env.py` 空串即抛 `PlatformUnreachableError` 并点名配置键 |
+| 20 | `dev` 期 `X-BiSheng-User-Id` 是服务账号 id,与线上 user id 不是一张表(F053 K15) | 应用把它当 `user.id` 去查平台用户 / 做外键 | D4 `str` + 指南 auth 章 |
+| 21 | 同进程 `ContextVar` 在后台线程 / `asyncio.create_task` 里:新线程为空,新任务是**副本**(能读到父请求的身份) | 请求里 `create_task` 出去的后台任务能拿到身份并检索——这是 asyncio 语义,不是 SDK 兜底;线程池则抛错 | 指南写明「后台任务不要假设有访问者」;SDK 不额外清空副本(清空会破坏 FastAPI 依赖注入的正常任务树) |
+| 22 | arch-guard RULE-7 扫 `api_key = "<8+ 字符>"` 字面量,对 `src/bisheng-sdk/tests/` 也出声(WARNING) | 长期噪声让真正的硬编码密钥那天没人看 | 假令牌一律拼接(`"bs-sak-" + "x" * 43`) |
+| 23 | 本地期单文件上限只在 `bisheng dev` 注入了 `BISHENG_APP_STORAGE_MAX_FILE_MB` 时生效(线上 F054 恒注入,缺省 20 MB) | 本地传 500 MB 通过、线上 413 | 指南 storage 章列为四处差异之外的第五处;F053 T043 建议按平台 `RTM_STORAGE_MAX_FILE_MB` 同值注入 |
+| 24 | `skills sync` 只拉 `DEFAULT_PACKS`(`3.0-vibe` 的 `commands/skills.py:57` = `("deploy-hosting",)`;`wt/cli-dev` 已改成两元素并重打过 wheel) | 新包已可下载但没人拉;`login` 后开发者的 AI 仍不知道 SDK | T038:合并后**先核对**,只在仍为单元素时才改 + 重打(与 SDK wheel 串行,避免二进制冲突) |
+| 25 | ~~F055 `16273` / `16274` 无写入方~~ **已解除**:`CapabilityBusService._require_declaration` / `_assert_targets_declared` / `_as_revoked` 已是写入方;F052 另有 `26320`–`26323` 四个门面码在用 | 按旧状态只 mock 不联调,会漏掉真实码路 | `_codes.py` 已登记 `26320` / `26321` / `26322` 与 `16273` / `16274`;`26323`(范围过大)走 `PlatformRefusedError` 原样呈现 |
+| 26 | manager 的错误信封是 `{"detail": {"code": "", "message"}}`(`runtime_manager/errors.py:38-47`),backend 是 `{status_code: , status_message, data}` | 用一套解析,storage 的 401 / 413 / 404 全落成 `PlatformRefusedError`、机器码丢失 | `_http.parse_envelope` 认两种;`_codes` 分 int 表与 str 表 |
+| 27 | `BISHENG_APP_STORAGE_ENDPOINT` 指向 runtime-manager 的**应用面地址**(compose = `http://runtime-manager:8091`,systemd = `bisheng-apps` 网桥网关如 `172.18.0.1:8091`,由 `RTM_APP_FACING_BASE_URL` 决定),不是平台 API;manager 只听 `127.0.0.1` 且没配该变量时应用容器根本够不到 | storage 全部 `StorageUnavailableError`(连接拒绝),像 SDK 坏了 | `StorageUnavailableError.next_step` 点名 `runtime/status` preflight `attachment_storage`;F054 契约 §2 已写修法;F054 出站白名单落地时须放行该地址(契约 §9) |
+| 28 | 附件 API 没有 403、没有「应用下线」专用码:token 错、应用 destroy、无 Bearer **全是 401 `unauthorized`**(`api/storage.py:verify_storage_caller`) | 想按「下线」与「句柄失效」分支的应用分不出来 | `StorageHandleRejectedError` 一类、`reason` = `detail.message`;AC-25 只要求与 NotFound / Unavailable **彼此可区分**,满足 |
+| 29 | `delete` 成功答 `200 {}` 不是 204;元信息路径是 `/meta/{key}` 不是 `/stat/`;`apps/` 前缀**即使是自己应用的**也 400;`prefix` / `cursor` 按 key 同规则校验 | 照 REST 直觉写客户端会把 200 当异常、把 stat 打到 404 | §4.2 ④ 表;`test_contract_alignment.py` 读 `api/storage.py` 文本对账路由 |
+| 30 | `wt/storage-handle`(F054 T084 / T085)**尚未合入 `3.0-vibe`**(`git branch --contains 23886547f` 只列它自己) | 在 `3.0-vibe` 上跑对账测试会发现 `runtime_manager/storage.py` 不存在,误判「契约不存在」 | 合并顺序:storage-handle 先于 F057 实现分支;对账测试在文件缺失时 `skip` 并打印原因;远端后端单测只依赖 `httpx.MockTransport` |
+| 31 | **`skills/platform-wiring/` 已经存在**(F053 T038 于 `wt/cli-dev` `b61b209e4`:226 行 SKILL.md + `example/` + `selfcheck.py`),auth 章里留的是一节「SDK 用法(随后续版本补齐)」桩 | 照初稿「新建 SKILL.md」写会覆盖掉身份 / 应用数据库 / `dev` 三章,且 F053 的 `test_skill_packs.py`(`PACKS` 已参数化两包 + `WIRING` 专项断言)当场红 | D12 改为增量编辑:换桩、插两章、保留章序与警示块;T033 / T034 / T036 的动作全部是 `(增量)` |
+| 32 | `bisheng dev` 每请求注入的 `X-BiSheng-Access-Token` 是**本地 HMAC 自签**的 `bsdev..`(`devproxy.py:HandleMinter`),平台无从验签 | 以为「dev 已落地 → 本地 retrieve 能跑」,在 114 上看到 401 会误判成 SDK 或密钥问题 | D5 ③:修法归 F053 / F052(`dev` 用 `login` 密钥换平台签发的短时凭据);SDK 零改动;§6.2 阻塞项 ③ |
+| 33 | `dev` 的 `X-BiSheng-Subject-Kind` **取决于 login 密钥种类**:`bs-sak-` → `service_account`、`bs-pat-` → `human`(`devproxy.py:204-213` 按 `whoami.actor_kind`) | 把「本地恒 service_account」写进指南或断言,PAT 开发者一跑就打脸 | D4;指南 auth 章按「取决于你 login 用的密钥」写;测试两种都覆盖 |
+| 34 | `dev` 的注入清单(`devdb.py:INJECTED_ENV`)**没有任何 `BISHENG_APP_STORAGE_*`** | 本地 storage 恒 `StorageHandleMissingError`,看着像 SDK 后端选择写错了 | D8 本地期段;§6.2 契约 ⑦ 回写 F053;T016 / T020 用例直接 `monkeypatch.setenv` 造句柄,不依赖 `dev` |
+| 35 | `wt/cli-dev` **已重打并提交 CLI wheel**(`manifest.json.cli.sha256=fe51ceea…`),而本 Feature 的 T029 也要写同一个 `artifacts/` 目录 | 两分支都动同一个二进制 + manifest,合并必冲突且无法 textual merge | 合并顺序:`wt/cli-dev` → `wt/storage-handle` → F057 实现分支;F057 落地时**重跑两个打包脚本各一次**(合并写 manifest 后 `cli` 与 `sdk` 段并存),不手工改 manifest |
+| 36 | F053 的 `test_auth_chapter_teaches_exactly_app_proxys_header_names` 断言 **SKILL.md 全文反引号里的 `X-BiSheng-*` 名集合 == app-proxy 的十个**,且 `X-BiSheng-Access-Token` 行必须含「不要依赖」 | 新写的 retrieve / storage 章里随手提一个不存在的头名,或让 SDK 开始消费 Access-Token 却不改那行文案,套件都会红——而报错信息指向 F053 的测试,容易被误判成「对方的测试坏了」 | T033 ①④:两章不得引入第十一个头名;Access-Token 行文案与 F053 那条断言**同批**改 |
+
+---
+
+## 6. 对外契约与依赖
+
+### 6.1 我提供给别人的(Outgoing)
+
+| 契约 | 形式 | 谁在用 |
+|---|---|---|
+| `bisheng_sdk.auth` / `retrieve` / `storage` / `errors` 公开面(§4.2 ①、D6、D9) | Python API(wheel) | 托管应用、PRD-2 WB-01 模板(同一范式,AC-32)、技能包样例 |
+| `bisheng_sdk._headers.INJECTED_HEADER_NAMES` 等常量(与 app-proxy 逐字一致) | Python 常量 | **F053 T042** `dev` 迷你代理可直接复用(避免第三份头名表) |
+| 本地期环境变量名 `BISHENG_APP_STORAGE_DIR`(+ 可选同名 `BISHENG_APP_STORAGE_MAX_FILE_MB`)与 `.bisheng/attachments/` 落点(§4.2 ②、D8) | 环境变量契约 | **F053 T043 的增补项**(`dev` 今天未注入,坑 34);托管期三名归 F054,本文只消费 |
+| `platform-wiring` 包 auth 章「SDK 用法」桩的填法与 retrieve / storage 两章的位置(D12) | 技能包文本契约 | **F053 T038 的后继编辑者**(本 Feature);F053 的 `test_skill_packs.py` 断言是这份契约的守卫 |
+| `_paths.validate` = manager `validate_key` 镜像 + `_storage_remote` 路径 / 信封形状(§4.2 ④) | 对账测试 | 若 F054 改 `validate_key` / 路由 / 信封,`test_contract_alignment.py` 先红 |
+| `GET /api/v1/dev-toolkit/sdk/download[/{filename}]` · `/simple/…` · `/sdk-guide.md`;`/versions` 的 `sdk` 段(§4.2 ⑤⑥) | HTTP(匿名) | 开发者、pip、runtime-manager 构建、selfcheck、接入信息区(F053 顺延项) |
+| `platform-wiring` 技能包(三件套 + 两条标准库接法章) | 技能包(`skills sync`) | 开发者的 coding agent;**F053 T038 以此收口** |
+| `manifest.json["sdk"]` 段 + `scripts/pack_sdk_wheel.sh` | 构建契约 | CI `sdk-quality.yml`;发布流程(改 SDK 必重打并提交 wheel) |
+
+### 6.2 我依赖别人的(Incoming)
+
+| 依赖 | 形式 | 状态(`fe10f75ea`) | 风险点 / 本文任务 |
+|---|---|---|---|
+| app-proxy 注入十头 + OBO(F054 AC-31 / AC-34;`headers.py:29-40`、`entry_authz_service.py:284-306, 379-425`) | HTTP 头契约 | ✅ 已落码;**OBO 仅在 `obo_secret` 配置正确时注入** | 头名变更 → `_headers.py` 三处对账测试先红;坑 4 |
+| `bisheng dev` 迷你代理注入同构十头 + 每请求短时凭据 + 同名环境变量(F053 T042–T044) | CLI | ✅ **已实现于 `wt/cli-dev` `b61b209e4`,未合入 `3.0-vibe`**(`3.0-vibe` 仍是 `cli.py:24 DEFERRED_COMMANDS=("dev",)`) | 十头逐字同 app-proxy(`devproxy.py:INJECTED_HEADER_NAMES`)→ `_headers.py` 可三处对账;`subject_kind` 随密钥种类(坑 33);**未注入 `BISHENG_APP_STORAGE_*`**(契约 ⑦)、访问凭据为本地自签(阻塞项 ③) |
+| **阻塞项 ③** 本地 `dev` 期缺**两样**:应用运行期凭据(`BISHENG_APP_TOKEN`,本地没有已上线的应用)与平台签发的访问者凭据(`bsdev.` 是本地自签,坑 32) | CLI + 服务端 | ❌ 未实现 | 本地期 retrieve 端到端不可验;SDK 呈现 `AppCredentialMissingError` 并指路「发布后用真实账号验证」;**不在 SDK 里开兼容分支**(D5) |
+| **契约 ⑦** `dev` 注入 `BISHENG_APP_STORAGE_DIR`(+ 可选 `BISHENG_APP_STORAGE_MAX_FILE_MB`)(F053 T043 增补) | 环境变量 | ❌ 未注入(`devdb.py:INJECTED_ENV`,坑 34) | 本地期 storage 端到端不可验;T016 / T020 用例自造句柄;回写 F053 |
+| `skills/platform-wiring/` 包本体(SKILL.md 五章 + `example/` + `selfcheck.py`)与 `DEFAULT_PACKS` 两元素(F053 T038) | 技能包 + CLI 常量 | ✅ **已实现于 `wt/cli-dev`,未合入** | 本 Feature 做增量编辑(D12);改章序 / 警示块会让 F053 断言先红(坑 31);`artifacts/` 二进制合并顺序见坑 35 |
+| ~~**阻塞项 ②** 后端受理 OBO Bearer 于 `/api/v2/filelib/retrieve`~~ → **已消解(2026-09-16 评审期核实),但形态与本条初稿不同**:受理的不是「OBO 作 Bearer」,而是**应用凭据作 Bearer**(`bs-app-` 前缀已进 `credential_validator._SUBJECT_KIND_PREFIXES`)+ **访问者凭据走 `X-BiSheng-Access-Token` 头**(`capability_bus_service.hosted_app_access_user` 经 `composition.py` 注册的 `AccessSubjectVerifier` 验签);白名单来自当前生效声明、审计双归属同批落地 | 服务端 | ✅ 已实现于 `3.0-vibe`(`filelib.py` 的 `HOSTED_APP_ACTOR_KIND` 分支 → `CapabilityBusService.retrieve`) | 托管期 retrieve 端到端**可验**(前提:部署配了 `app_runtime.obo_secret`,否则不注入访问者凭据 → 一律拒绝,坑 4)。**仍未做**:签发侧 fail-closed(`obo_secret` 缺失时仍只 warn 不拒,归 F055)|
+| **契约 ③** `RetrieveReq.knowledge_base_ids` 可省略(F052 AC-22) | schema | ❌ 今天必填 | `search(query)` 无库 id → 422;T014 用例标 `[受阻于 F052]` |
+| **契约 ④** F052「不可及」错误码 + `data.unreachable_ids`(AC-11);「能力已收回」 | 错误形状 | ✅ **已分配**:`26321`(`unreachable_ids`)/ `26322`(`knowledge_id`)/ `26320`(无执行身份)/ `26323`(范围过大),见 `common/errcode/mcp_face.py` | `_codes.py` 已登记前三个;`26323` 走 `PlatformRefusedError` |
+| **契约 ⑤** 附件 API 服务端 + `BISHENG_APP_STORAGE_ENDPOINT/_TOKEN/_MAX_FILE_MB` 注入(F054 T084 / T085;`runtime_manager/storage.py`、`api/storage.py`、`lifecycle.py:build_env`) | 服务端 + 环境变量 | ✅ **已实现于 `wt/storage-handle` `23886547f`,未合入 `3.0-vibe`**(坑 30) | SDK 远端后端按其契约落码(§4.2 ④)+ 对账测试;114 联调要求该分支先部署且 `RTM_MINIO_*` / `RTM_APP_FACING_BASE_URL` 已配(F054 契约 §2 / `docs/architecture/14`);无回写 |
+| `GET /api/v1/dev-toolkit/versions` `sdk` 字段位(F053 D11;`distribution.py:76-80`) | HTTP | ✅ 留位为 null | T027 填值;坑 8 |
+| `artifact_service.read_skill_pack` / `read_install_guide` 模式(`artifact_service.py:211-243`) | 内部 Python | ✅ | 新包目录自动分发;`sdk-guide.md` 照 `install-guide` 形态 |
+| `scripts/pack_cli_wheel.sh` 与 `cli-quality.yml` 的 manifest 契约 | 构建脚本 | ✅ 但会**清空 `sdk` 段 / 删 SDK wheel** | T023 同批改 CLI 脚本(坑 9 / 10) |
+| runtime-manager 构建参数(`builder.py:366-369`、`config.py:159-160, 231-232`、`Dockerfile.j2:21-42`) | 构建契约 | ✅ 只有 `PIP_INDEX_URL` | T030–T032 加 extra index |
+| F055 `BISHENG_APP_TOKEN` 注入与托管期检索受理(`HOSTED_APP_ACTOR_KIND` → `CapabilityBusService.retrieve`)、`16273` / `16274` 写入方 | 环境变量 / 服务端 / 错误码 | ✅ 注入与受理已落地;错误码写入方待 F055 T058 | retrieve 以它作 Bearer(CON-3 / D5 修订);`16273` / `16274` 仍只 mock(坑 25) |
+| `bisheng_cli.commands.skills.DEFAULT_PACKS` | CLI 常量 | ✅ 只含 `deploy-hosting` | T038 追加 + 重打 CLI wheel(坑 24) |
+
+**跨 Feature 回写登记(本文提出、对方 tasks 受理)**:
+1. **F054**:**无回写**——附件句柄契约以 `wt/storage-handle` 为准,本文初稿的 backend 落点 / `_URL` 命名 / `16163+` 建议全部作废。仅登记一句事实供对方知悉:F057 `_storage_remote.py` 是该 router 的 Bearer 路径首个消费者,`validate_key` / 路由 / 信封任何改动会让 F057 `test_contract_alignment.py` 先红。
+2. **F053**(T042–T044 / T038 已交付于 `wt/cli-dev`,以下是**在其之上的增补**,各写成对方 tasks 的追加条目):
+   - **T043 增补**:注入 `BISHENG_APP_STORAGE_DIR=<项目根>/.bisheng/attachments/`(绝对路径)并写 `.bisheng/.gitignore`;建议同名注入 `BISHENG_APP_STORAGE_MAX_FILE_MB`(值取平台 `RTM_STORAGE_MAX_FILE_MB`,坑 23);**不注入 `BISHENG_APP_STORAGE_ENDPOINT`**(否则 SDK 判「句柄不唯一」)。契约 ⑦。
+   - **T042 增补**:`devproxy.HandleMinter` 现铸的 `bsdev.` 句柄改为**平台签发**的短时凭据(用 `login` 密钥换取、`login` 密钥仍不进应用进程),否则本地期 retrieve 恒 `26001`(坑 32 / 阻塞项 ③)。
+   - **已无需回写**:`INJECTED_HEADER_NAMES` 十头已与 app-proxy 逐字一致(`devproxy.py:63-74`);`DEFAULT_PACKS` 已含 `platform-wiring`。
+3. **F052 / F055**:OBO Bearer 受理(含 `entry_authz` 签发改 fail-closed)与 **`bsdev` 一类平台签发的本地短时凭据受理**;`knowledge_base_ids` 可省略;「不可及」码与 `data.unreachable_ids`;`16273` 载荷含 `data.capability` 与 `data.reason="revoked"`。
+
+---
+
+## 7. 测试与可观测
+
+- **SDK 单测**(`src/bisheng-sdk/tests/`,`cd src/bisheng-sdk && uv sync --frozen --extra dev && uv run pytest`):零网络(autouse 哨兵把 `httpx.Client` / `AsyncClient` 默认 transport 换成抛 `AssertionError` 的 transport,同 CLI T002);所有平台响应经 `tests/helpers/platform_mock.py` 的 `httpx.MockTransport` 工厂按 §4.2 ③④⑤ **逐字**构造;`@pytest.mark.network` 用例默认跳过、只在 114 手验跑。三处对账测试读仓内文件(`src/app-proxy/app_proxy/headers.py`、F054 contracts §5 表)——在仓外跑时 `pytest.skip`。
+- **后端测试**(`src/backend/test/dev_toolkit/`,`asyncio_mode=auto`,conftest 已剥代理变量):`staged_artifacts` fixture(`conftest.py:61`)扩出 `sdk` 段 + 假 wheel;四个新端点的 200 / 404 / 匿名 / 开关关闭 = 404;简单索引 HTML 形状;技能包增量断言落**新文件** `test_platform_wiring_sdk.py`(F053 的 `test_skill_packs.py` 不改,其中两条回归断言反过来守住本 Feature 不改坏 auth 章与模型章,坑 31);自检脚本无环境时可读失败;评测样本结构断言。
+- **runtime-manager 测试**:`test_build.py` 断言两个新 buildarg 渲染进 Dockerfile 与传给 `build_image`。
+- **CI**:新 `.github/workflows/sdk-quality.yml`(paths `src/bisheng-sdk/**` / `scripts/pack_sdk_wheel.sh`),三 leg 照 `cli-quality.yml`(locked / highest-resolution / wheel smoke + manifest drift guard on `sdk.version`)。
+- **114 手验**(在阻塞项 ②③ 落地、`wt/storage-handle` 与 `wt/cli-dev` 合入并部署后):`bash scripts/pack_sdk_wheel.sh` → 提交 → `bash /opt/bisheng-ops/deploy.sh` → `curl -s http://192.168.106.114:7860/api/v1/dev-toolkit/versions | jq .data.sdk` → `pip install --extra-index-url http://<114>:7860/api/v1/dev-toolkit/simple/ bisheng-sdk` → `bisheng deploy` platform-wiring 的 `example-sdk/` → 用非 admin `shuiwu` 与另一非 admin 账号访问,断言 `auth` 各得其身份、retrieve 集合等于 `/api/v2/filelib/retrieve` 同用户限定声明库的结果、应用 B 列不到应用 A 附件(storage 先看 `GET /v1/runtime/status` preflight `attachment_storage.ok`,systemd 形态要 `RTM_APP_FACING_BASE_URL` 指到 `bisheng-apps` 网桥网关,坑 27)。**验权限一律非 admin**(super_admin 短路 ReBAC)。
+- **测试环境前提**:SDK 单测与后端单测**不需要**两个前置分支(一切经 mock / fixture);只有 `test_contract_alignment.py` 的三条对账用例要读 `wt/storage-handle` 的 `runtime_manager/storage.py`、`api/storage.py` 与 app-proxy 的 `headers.py`——文件缺失时 `skip` 并打印「前置分支未合入」(坑 30)。技能包增量任务(T033–T036)**必须**在 `wt/cli-dev` 已合入的基线上做(T000 核对,坑 31)。
+- **可观测**:SDK 不写日志(应用的日志是应用的);异常 `__str__` 足以定位;runtime-manager 日志对 `BISHENG_APP_STORAGE_TOKEN` 值自动脱敏(`api/readonly.py` 的 `*TOKEN*` 名匹配,坑 18)。
+
+---
+
+## 8. 后续改进 / 不打算做的事
+
+- **不做**:SDK 侧重试 / 断路器;`as_user`;本地白名单预演(除非 F055 提供预检可及性接口,spec 决议-3);清空附件空间;下载直链;非 Python SDK(v3.1)。
+- **不做(本 Feature 边界,理由已定案)**:不为本地 `dev` 期缺失的两把凭据在 SDK 里开任何兼容分支(阻塞项 ③ 的修法在 F053 / F052;SDK 只负责把上下文里的访问者凭据与注入的应用凭据一起送出去,并在缺任何一把时明确报错);不在 SDK 里兜底缺失的 `BISHENG_APP_STORAGE_DIR`(契约 ⑦;宁可 `StorageHandleMissingError` 也不静默落临时目录,spec §3)。
+- **待上游落地后的 SDK 增量**:F052 分配「不可及」码 → 填 `_codes.py`;`knowledge_base_ids` 放宽 → 指南示例改为可省略,**并同批确认 F052 对空列表 `[]` 的语义**:今天 `[]` 被 `min_length=1` 拒(fail-closed,SDK 原样送出即可),若放宽后把 `[]` 也当成「未指定 = 全部被授予范围」,一个把目标库过滤到空的应用就会静默检索得**比它要的更宽**——那时 SDK 侧要在 `_body` 里把 `[]` 挡成明确错误;F054 附件 API 加 presign → `storage.share()`(另起 AC);F054 若为 backend 调用方落 `16170–16174` → `_codes` 的 str 表旁加 int 表项(SDK 走 Bearer 路径不会遇到,仅为对账完整)。
+- **可选增强**:`versions` 载荷下发 `packs[]` 替代 CLI `DEFAULT_PACKS`(第三个技能包出现时);F055 托管预检校验应用锁定的 SDK 版本 ∈ 平台区间。
+
+---
+
+## 修订历史
+
+| 日期 | 改动 | 触发原因 |
+|---|---|---|
+| 2026-09-16 | 初版(D1–D16、坑 1–25、§4.2 契约 ①–⑦、§6.2 阻塞项 ①②⑤ 与契约 ③④、回写三项);全自动模式定案 | spec 定稿后补 design |
+| 2026-09-16(同日续) | 补 §2 Constitution Check(C1–C8);**D8 / §4.2 ②④ / D6 storage 行 / D9 路径规则 / §4.1 C 按 F054 `wt/storage-handle`(T084 / T085 已实现)改写**:句柄改 `BISHENG_APP_STORAGE_ENDPOINT/_TOKEN/_MAX_FILE_MB`、落 runtime-manager `/v1/apps/{app_id}/storage`、manager 信封 `{"detail":{code}}`、`/meta/` 路径、`200 {}` 删除、`apps/` 保留前缀;作废初稿的 backend 落点 / `_URL` / `16163+` 建议与 F054 回写项;§6.2 ⑤ 由阻塞项改为已实现契约;新增坑 26–30;修正引用 tasks 编号(T026 / T032 / T038 / T027 / T023 / T030–T032 / T014)| 续写前核对 storage-handle worktree(brief 要求) |
+| 2026-09-16(同日四续) | **按已合入的 F055 / F052 实现改写 D5 与 CON-3:retrieve 送两把凭据**(`Authorization: Bearer ` + `X-BiSheng-Access-Token`),初稿的「只送访问者凭据」作废;D6 增 `AppCredentialMissingError`、补 `26320` / `26321` / `26322` 的映射;§4.1 B 与 §4.2 ③ 按两把凭据改写、`tag_match_mode` 改大写 `ANY`;§6.2 阻塞项 ② 与契约 ④ 消解为已实现、阻塞项 ③ 改写为「本地缺两把凭据」;坑 5 / 坑 25 按现状改写 | 实现 Wave 1–3 时按 briefs-wave4 的跨 Feature 裁定核对服务端源码 |
+| 2026-09-16(同日三续) | **按 F053 分支 `wt/cli-dev` `b61b209e4` 改写**:D12 由「新建技能包」改为「在 F053 已建的 `platform-wiring` 上做增量」、D13 自检改为追加步骤、D4 `subject_kind` 随密钥种类、D5 增第三半(`bsdev.` 本地自签句柄不可被平台受理,修法归 F053 / F052)、D8 本地句柄标注「`dev` 今天未注入」;§6.2 阻塞项 ① 消解为已实现契约,新增阻塞项 ③ 与契约 ⑦;新增坑 31–36;回写登记第 2 条按「已交付 + 增补」重写;修正行号(`credential_validator.py:43`、`pack_cli_wheel.sh:97 / 106-119`、`api/router.py:141`、`artifact_service.py:211-231`、`distribution.py:80`)| `/sdd-review design` + brief 要求核对 cli-dev worktree |
+| 2026-09-16(四续) | **按 F055 已落地的托管检索契约修订 D5 / CON-3 / §4.1 B / §4.2 ②③ / §6.2**:retrieve 送两把凭据(应用运行期凭据作 Bearer 定白名单、注入的访问者凭据作同名头定访问用户),缺访问者头即拒绝且无 owner 兜底;初稿的单凭据形态作废 | 实现期核对 `filelib.py` 的 `HOSTED_APP_ACTOR_KIND` 分支,发现与初稿不一致(wave-4 brief 已裁定以 F055 为准) |
+| 2026-09-16(评审期核实) | **§6.2 阻塞项 ② 判为已消解**(受理形态与初稿不同:Bearer = 应用凭据 `bs-app-`、访问者走 `X-BiSheng-Access-Token` 头经 `AccessSubjectVerifier` 验签);D5「三半」① 同步改写,只留「签发侧 fail-closed 未做」那一半;§1 依赖表同改。阻塞项 ③ 与契约 ⑦(本地 `dev` 的自签句柄、未注入附件目录)**仍成立**,另加一条事实:`dev` 也不注入 `BISHENG_APP_TOKEN`,本地 retrieve 实际先抛 `AppCredentialMissingError` | 评审期按「实现侧为准」核实 `filelib.py` / `capability_bus_service.py` / `composition.py` / `devdb.PLATFORM_ENV_NAMES` |
diff --git a/features/v3.0.0/057-bisheng-sdk/spec.md b/features/v3.0.0/057-bisheng-sdk/spec.md
new file mode 100644
index 0000000000..34a6db090c
--- /dev/null
+++ b/features/v3.0.0/057-bisheng-sdk/spec.md
@@ -0,0 +1,186 @@
+# Feature: bisheng-sdk(Python 首发三件套 auth / retrieve / storage + 开发者指南)
+
+> **本文档定位 — 纯 What(需求口径,不随代码漂移)**
+>
+> spec 只回答 **做什么 / 验收标准 / 不做什么**。所有 How(决策、数据流、字段、API、Service、前端、文件清单、性能指标)一律不写在这里,
+> How 的唯一真相在 [design.md](./design.md) 与 [tasks.md](./tasks.md)。
+
+**关联 PRD**: [docs/PRD/3.0-release/3.0 应用工场 PRD-1 专业开发者通道与应用运行时.md](../../../docs/PRD/3.0-release/3.0%20应用工场%20PRD-1%20专业开发者通道与应用运行时.md) **v2.0** **DEV-07**(三件套表含「为什么需要 SDK」列与权限边界要点、刻意不进 SDK 的两样、规则与边界、验收 1–4)/ DEV-03「平台能力接线」包(auth 静默失败点显著标注、retrieve per-user 语义、storage、模型与应用数据库两条标准库接法)/ DEV-05(本地 `dev` 期 auth 取到服务账号身份、注入结构与线上一致、per-user 差异本地不可复现、验收 1 / 3 / 4 / 5)/ DEV-04 托管运行契约(平台托管提供附件 / 文件存储、应用不得自带)/ GOV-05 知识库检索范围(两层过滤、纯替换、fail-closed、验收 5 / 6 / 7)/ GOV-03 规则「附件不计入平台存储配额」/ NFR-1.1 / 1.3 / 1.4 / NFR-2 / §1 分册说明(DEV-07 与 WB-01 的跨册依赖)/ §5.1「不做 `--as`」;伴生 [docs/PRD/3.0-beta2/3.0 开放 API 鉴权与身份传递 PRD.md](../../../docs/PRD/3.0-beta2/3.0%20开放%20API%20鉴权与身份传递%20PRD.md) **v2.1** §4.4.6(检索须补文件级过滤——与 GOV-05 同口径);《3.0 应用工场 产品方案》§3.4(不自研协议长尾)/ §5.1 F106(部分)
+**同步记录**: 2026-08-17 初稿(按 PRD-1 v2.0 + 伴生 PRD v2.1 + release-contract 拆分 v2 表 3 F057 行;§4 决议 1–11 于同日按用户授权的全自动模式直接定案并留痕)。**PRD 残留一处**:PRD-1 §1 分册说明写「DEV-07 **验收 2** 所需的 WB-01 模板」,而 DEV-07 正文中依赖 WB-01 的是**验收 3**(验收 2 是「不经 SDK 的两条路径同样通」);本 spec 与 release-contract 表 3 均按正文序号「验收 3」,§1 序号笔误**已回修(2026-08-17)**。同日按独立审查 15 条裁定就地修订:跨 Feature 契约改为直接引用(F053 AC-01 / AC-23 / AC-27 / AC-32、F054 AC-16 / AC-31 / AC-45、F055 AC-50 / AC-55)、AC-05 删托管期开关分句、AC-33 改写为可测行为、AC-32 承认直读注入头合法、去 How(AC-03 / 09 / 11、决议-4 / 5 / 6)、追加 AC-35–36;编号稳定、末尾追加
+**优先级**: P0(PRD-1 21 项功能之一、release 必做;不在 MVP-114 纵切上、排在纵切之后——见「批次」行)
+**所属版本**: v3.0.0
+**依赖**: **F052**(统一检索门面——SDK retrieve 的服务端,本 Feature 只调用;`knowledge:read` 位在本地 dev 期的判定经其面);**F053**(`bisheng dev` 迷你代理注入的服务账号身份与同名环境变量——SDK 本地期读取;「平台能力接线」包的包结构与两条标准库接法——本 Feature 补齐其 SDK 章节 / 样例 / 自检;安装件分发端点——承载 SDK 发布件);**F054**(app-proxy 注入的当前访问者身份与短时访问凭据句柄——SDK 托管期读取;**按应用收窄的附件存储句柄——storage 的服务端**,storage 依赖 F054);**F055**(托管运行期 retrieve 的能力声明白名单语义与能力收回错误态——本 Feature 只承接呈现;F055 未就绪时托管期 retrieve 不可用,本地 dev 期不受影响)。间接:F049(`knowledge:read` 既有位、开放能力层部署开关,经 F052 / F053 消费)、F051(模型面 base URL——开发者指南模型章引用,SDK 不封装)
+**批次**: A 尾 / B;**不在 MVP-114 纵切上**([../mvp-114-path.md](../mvp-114-path.md) §2:表单问卷不用平台能力、不需 SDK);随各自 spec / design / tasks 正常推进
+
+> **范围边界**
+>
+> 本 Feature 是专业开发者通道的**代码面**:把「平台特有 ∧ 写错了会出安全事故」的三件事——读平台注入身份(auth)、以当前访问者身份检索(retrieve)、按应用收窄的附件存取(storage)——各封成一行到位的默认做法,并随包发布开发者指南。它不拥有任何服务端能力:身份注入与短时凭据归 F054(线上)/ F053(本地)、检索门面归 F052、白名单与能力收回归 F055、附件存储句柄归 F054;SDK 只**读注入、调门面、消费句柄**。模型调用与应用数据库**刻意不进 SDK**(PRD-1 DEV-07 表「刻意不进 SDK 的两样」),用行业标准库 + 平台注入的环境变量。
+>
+> - **本次纳入**:
+>   - **Python 首发 `bisheng-sdk` 安装件与分发**:公开面恰为 auth / retrieve / storage 三模块;由平台自身分发(与 CLI 安装件同一分发机制与规则:匿名可达、纯内网零公网依赖、随开放能力层开关存在;端点由 F053 拥有、承载本发布件),公开包索引为可选补充;托管构建期自平台自身分发端点安装(F054 AC-16 构建期白名单);SDK 与平台版本的**兼容承诺**(独立版本、平台声明 SDK 最低兼容版本,不兼容时首次交互明确报错)
+>   - **auth**:一次调用返回当前访问者身份(用户标识 / 姓名 / 主部门等平台约定字段 + 主体类型);托管运行期 = 当前访问用户(F054 注入)、`bisheng dev` 期 = 开发者服务账号(F053 注入)、应用代码零差异;**无注入身份即抛错**(不返回 None / 匿名 / 缺省身份);只读注入——不提供登录、签发 / 解析 / 校验凭据、覆盖身份(无 `as_user`)的任何 API;请求作用域、不跨请求缓存
+>   - **retrieve**:以**当前访问者**为执行身份经 F052 门面检索;执行身份取自请求上下文中平台注入的访问者凭据句柄(线上 F054 AC-34 / 本地 F053 AC-23),**绝不以应用运行期凭据或任何固定身份检索**;托管期可及范围 = 平台侧按当前生效能力声明确定的白名单 ∩ 访问用户可见范围(文件级、集合相等,F055 AC-50 / F052 AC-21)、本地期 = 服务账号被显式授予范围(无白名单);fail-closed(无身份 / 引擎不可用 / 目标不可及 → 明确错误、不降级、不缩水);能力收回错误原样呈现;不新增数据面
+>   - **storage**:在当前应用的附件空间内上传 / 下载 / 列举 / 删除;按应用隔离;不暴露 bucket / 对象键 / 端点 / 存储凭据;不进公共可读存储、无匿名直链;不计租户存储配额;本地 `dev` 期以 F053 AC-27 注入的本地附件目录句柄同一 API 工作、不进 deploy 上传包(F053 AC-32)
+>   - **开发者指南**(随 SDK 发布):安装、三件套用法(auth 章按 F053 AC-17 可判定形式显著标注静默失败点)、模型与应用数据库两条标准库接法(引用 F053 同源内容)、与技能包 / CLI 的配合、`login → dev → deploy` 完整流程;**「平台能力接线」包的 SDK 三件套章节、样例与自检脚本 SDK 部分**(承接 F053 AC-17 / AC-18 / AC-22 ②);指南与技能包 SDK 章同源一份
+>   - **调用范式全平台唯一**:本地手写引入的 SDK 与平台内模板(PRD-2 WB-01)预接线的是同一套 SDK(验收随 PRD-2);SDK 是平台推荐且指南 / 技能包唯一教授的写法(直读注入头亦合法)、不提供第二套 helper;**模块准入门槛**(平台特有 ∧ 写错了会出安全事故)作为流程规则登记于 §3 / §4,chat / appdb 不得按惯性加回
+> - **本次明确排除**:
+>   - **chat(模型调用)**——用官方 OpenAI 兼容客户端 + 平台注入的 base URL 与凭据(**F051** AC-01 / AC-33;用法在 F053「平台能力接线」包模型章);SDK 不含任何模型调用封装或便捷函数
+>   - **appdb(应用数据库读写)**——标准数据库库 + 平台注入的连接环境变量(**F054** AC-45 线上 / **F053** AC-26 本地);建表改表由 `bisheng-app.yaml` 声明、平台建表(**F055** RT-05);SDK 不含连接工厂、ORM 封装或迁移工具
+>   - **非 Python SDK**(TypeScript / Java 等)——v3.1 视 `runtime` 矩阵与客户反馈;本版 `node20` / `static` 运行时的应用只用「不经 SDK 的两条路径」+ 直读注入头
+>   - **MCP 六类工具与统一检索门面本身**(F052);**CLI 五命令、技能包结构、`dev` 注入、安装件分发端点**(F053);**app-proxy 注入 / 剥离伪造头 / 短时凭据签发 / 附件存储服务端 / 每应用数据库**(F054);**能力总线注入通道与凭据、白名单语义、能力收回、应用运行期凭据**(F055)
+>   - **SDK 内部的重试 / 连接池 / 超时 / 序列化 / 框架适配层形态**(design);**错误码定义**(design §6)
+>   - **PRD-2 模板预接线(WB-01)与工作台侧任何界面**——随 PRD-2;DEV-07 验收 3 随之
+>   - **storage 的绕过应用的下载直链 / 分享链接 / 附件级权限 / 附件配额与统计**(§4 决议-9;v3.1 视需求);**审批期临时预览实例的附件空间隔离策略**(F055「试用数据不带入生产」同口径,SDK 对句柄透明)
+>   - **以应用自身身份(非访问者)检索的 API**——PRD-1 全册无此场景、F055 AC-55 明定检索无访问用户一律拒绝;后台任务无访问者 → retrieve 报错(线上本地一致,§3);模型调用的后台任务规则归 F051 / F055
+>   - **SDK 侧的身份头验签、本地权限判定或本地白名单模拟**(§4 决议-3 / 决议-8);**SDK 模块准入评审本身**(流程规则,见 §3 / §4 决议-10,不作 AC)——信任根在入口拓扑与服务端门面,SDK 不复制第二份判定
+
+---
+
+## 1. 用户故事
+
+作为 **开发者(owner)**,
+我希望 **写应用时 `auth` 一行拿到当前访问者、`retrieve` 不用自己想「以谁的身份查」、`storage` 不用碰 bucket 和对象键,且这三行在 `bisheng dev` 本地和上线后一字不改**,
+以便 **把精力放在业务上;不会因为读错头、选错身份、拼错路径而做出「本地能跑、线上越权」的应用**。
+
+作为 **开发者的本地 coding agent**,
+我希望 **`login` 后技能包里就有 SDK 三件套章节与自检脚本,读了就知道「不写登录页、读平台注入身份」,SDK 报错时能拿到「缺什么、怎么改」而不是堆栈**,
+以便 **凭技能包 + SDK + CLI 自主完成接线 → 本地跑通 → deploy 的闭环,不把安全决定留给自己猜**。
+
+作为 **安全负责人**,
+我希望 **唯一「写错了应用照样跑」的静默失败点(auth)被 SDK 的默认行为堵死——无注入即抛错;retrieve 在代码层面就不可能以应用身份越权;storage 不把对象存储的任何实现细节交给应用;模型与数据库不引入非标准封装**,
+以便 **审计一个托管应用的接线时只需确认「用了 SDK 三件套 + 两条标准库接法」,攻击面与审查面都最小**。
+
+---
+
+## 2. 验收标准
+
+> AC 编号在本特性内唯一。括号内标注对应 PRD-1 条目与验收序号(如 `↔DEV-07 验收 1`)及跨 Feature 不变量。属于服务端门面 / 注入 / 句柄的行为用「经 F052」「经 F054」「经 F055」措辞——本 Feature 只验收 SDK 侧的调用行为、错误呈现与文档。错误码仅作可观测行为引用,定义在 design.md。2026-08-17 审查修订后编号保持稳定:新增末尾追加(§2.8)、无删除。
+
+### 2.1 安装、分发与兼容
+
+- **AC-01** — THE SYSTEM SHALL 提供 Python 版 `bisheng-sdk` 安装件(首发只 Python),其公开面**恰为** auth / retrieve / storage 三个模块;安装件由平台自身分发——经 F053 AC-01 的安装件分发端点承载(同一端点亦承载平台声明的 SDK 最低兼容版本;下载不要求密钥或登录态、纯内网零公网依赖、随开放能力层开关存在);发布到公开包索引为可选补充,不作为任何环境的前提。(↔DEV-07 需求描述「Python 首发」;DEV-03 规则「无公网依赖」;F053 AC-01;§4 决议-5 / 决议-10)
+- **AC-02** — THE SYSTEM SHALL 使 SDK 可在**托管构建期**安装:应用依赖清单中声明 `bisheng-sdk` 即可——SDK 自平台自身分发端点安装(该端点在构建期出站白名单内,经 F054 AC-16)、其第三方依赖自平台配置的包源安装,无需公网、不要求把 SDK 放进客户自有镜像源;SDK 支持平台托管 `runtime` 矩阵内的全部 Python 版本(首发 python3.11)并可在开发者本地同版本运行。(↔DEV-04 托管运行契约「依赖包经平台内网镜像安装」;F054 AC-16;§4 决议-5)
+- **AC-03** — THE SYSTEM SHALL 使 SDK 具有**独立于平台版本号**的版本,并对外承诺兼容关系:平台声明其支持的 SDK 最低兼容版本(随 F053 AC-01 分发端点的版本信息一并给出);IF SDK 与目标平台不兼容, THEN THE SYSTEM SHALL 在 SDK 与该平台的首次交互即抛出明确错误(含双方版本与处置方式:从当前平台重新获取 SDK / 平台需升级),不静默降级、不部分工作;兼容的 SDK 在平台升级后无需重发应用。校验时机与判据属 design。(↔DEV-03 规则「版本对齐」外推;F053 AC-01;§4 决议-6)
+- **AC-04** — THE SYSTEM SHALL 使 SDK 在任何输出、异常信息与本地日志中不回显访问凭据、服务账号密钥或应用运行期凭据的明文(至多掩码),不把任何凭据持久化到磁盘(本地 `dev` 期 storage 落盘的是附件、不是凭据),也不把凭据写进 storage 附件元信息。(↔NFR-2;INV-28)
+- **AC-05** — WHERE 开放能力层未部署, THE SYSTEM SHALL 使 SDK 安装件分发端点不可达(呈不存在,同 F053 AC-05);本地 `dev` 与托管运行各以对应层已部署为前提(F053 AC-05 / AC-29、F054 AC-30),SDK 不另设「层未部署」分支;auth 只读注入、与部署开关无关。(↔GOV-10;F049 AC-49)
+
+### 2.2 auth(读平台注入身份)
+
+- **AC-06** — WHEN 应用在处理一次经平台入口转发的请求时调用 auth, THE SYSTEM SHALL **一次调用**返回当前访问者身份:用户标识、姓名、主部门、主体类型(自然人 / 服务账号)等平台约定字段(字段清单 = F054 AC-31 与 F053 AC-23 注入的同一结构,SDK 原样暴露、不自行推导或补全);托管运行期返回当前访问用户(经 F054 AC-31)、`bisheng dev` 期返回当前 `login` 密钥对应的开发者服务账号(经 F053 AC-23);应用代码零差异。(↔DEV-07 表 auth / 验收 1;DEV-05 需求表「应用怎么拿:完全相同」/ 验收 1 / 5;INV-32 auth 半边承接(本 Feature 为来源 spec 之一))
+- **AC-07** — IF 调用 auth 的上下文中不存在平台注入身份(如绕过入口直连应用进程、运行时健康探活请求、未经 `bisheng dev` 迷你代理直接运行应用、单元测试裸调用、后台任务无请求上下文), THEN THE SYSTEM SHALL 抛出明确的「未取得平台注入身份」错误;THE SYSTEM SHALL NOT 返回 None / 空对象 / 匿名或任何缺省身份,也不得静默以进程内任何配置身份代替。(↔DEV-07 表 auth「唯一的静默失败点」;NFR-1.3;§4 决议-1)
+- **AC-08** — THE SYSTEM SHALL 使 auth **只读注入**:不提供任何登录页 / 登录流程、签发凭据、解析或校验用户凭据、构造平台会话的 API;不提供以显式用户标识替代或覆盖注入身份的参数(无 `as_user` 及任何等价物);伪造注入头无效由 F054 AC-32(线上)/ F053 AC-25(本地)在入口保证——SDK 不试图自行鉴别注入头真伪、也不持有任何用于验签的密钥材料。(↔DEV-05 ① / 验收 2 / 验收 4;PRD-1 §5.1;§4 决议-2 / 决议-8)
+- **AC-09** — THE SYSTEM SHALL 使 auth 的结果作用域为**当前请求**:不跨请求复用;同一进程并发处理不同用户的请求时各自取得各自的身份;WebSocket 连接取握手时注入的身份,连接的授权有效期与到期断开由 F054 AC-35 承接、SDK 不续期。
+- **AC-10** — THE SYSTEM SHALL 使 auth 在 `bisheng dev` 期返回的服务账号身份中组织类字段(主部门等)可为空、但结构与线上一致(服务账号不属任何部门,INV-29);开发者指南与技能包 auth 章 SHALL 写明该差异,以及「本地看不到 per-user 差异、验证路径 = 发布后用真实账号访问」。(↔DEV-05 ②;F053 §3「dev 注入的是服务账号」)
+
+### 2.3 retrieve(以当前访问者身份检索)
+
+- **AC-11** — WHEN 应用调用 retrieve, THE SYSTEM SHALL 以**当前访问者**为执行身份、经 F052 统一检索门面执行检索(门面归 F052 AC-19,SDK 只调用):入参与出参即门面的入参与出参(查询、可选目标知识库范围、条数上限;经文件级过滤、可引用溯源的片段结果——以 F052 AC-19 为准),SDK 不增删字段、不二次过滤。(↔DEV-07 表 retrieve / 验收 1;GOV-05;INV-36 retrieve 半边承接(本 Feature 为来源 spec 之一))
+- **AC-12** — THE SYSTEM SHALL 使 retrieve 的执行身份**取自请求上下文中平台注入的访问者凭据**:托管运行期 = app-proxy 每请求注入的短时访问凭据句柄(F054 AC-34);`bisheng dev` 期 = 迷你代理以同一结构每请求注入的服务账号访问凭据句柄(F053 AC-23;`login` 密钥不进应用进程、访问者凭据不以进程级环境变量提供,F053 AC-27);THE SYSTEM SHALL NOT 以应用运行期凭据(应用自身身份,F055 AC-57)、进程级环境中的任何密钥或任何固定 / 配置身份执行检索,也不提供指定他人身份检索的参数。(↔DEV-07 表 retrieve「必须以当前访问者身份、选错即越权」;DEV-05 需求表「平台能力按谁的权限放行」;§4 决议-2)
+- **AC-13** — WHILE 托管运行期, THE SYSTEM SHALL 使 retrieve 的可及范围 = 该应用当前生效能力声明的知识库白名单 ∩ 当前访问用户可见范围(文件级)——访问用户由注入的短时访问凭据确立、白名单由平台侧按应用当前生效能力声明确定、应用与 SDK 不可自报白名单(经 F055 AC-50 / AC-51;门面执行 F052 AC-21):用户本人有权限但应用未声明的知识库经 SDK 不可检索;应用已声明但该用户无权限的库或文件不出现;声明范围内的结果与该用户在平台内自行检索的结果**集合相等**(不多给不少给)。(↔GOV-05 验收 5 / 6;NFR-1.1;INV-36)
+- **AC-14** — WHILE `bisheng dev` 期, THE SYSTEM SHALL 使 retrieve 以 F053 AC-23 注入的服务账号访问凭据为执行身份,可及范围 = 该开发者服务账号被**显式授予**的知识库范围(无白名单,经 F052 AC-22),过滤强度与 fail-closed 行为与线上一致;SDK 不在本地模拟能力声明白名单(§4 决议-3);IF 该密钥未持 `knowledge:read`, THEN THE SYSTEM SHALL 把 F052 面「指明缺该位」的拒绝原样抛出。(↔DEV-05 ③ / 验收 3;F053 AC-28;NFR-1.1)
+- **AC-15** — IF 调用 retrieve 时请求上下文中无访问者凭据、或凭据无效 / 过期 / 因应用下线被拒(含 AC-07 各情形、后台任务等无访问用户上下文), THEN THE SYSTEM SHALL 抛出明确错误、不发起检索——检索无访问用户**一律拒绝**(同 F055 AC-52 / AC-55),SDK 不提供以应用自身身份检索的路径;绝不回退为按白名单全量放行、按任何固定身份放行,或返回空集当作成功。(模型调用在无访问用户上下文以应用自身发起的规则归 F051 / F055 AC-55,不在本 SDK。)(↔GOV-05 fail-closed / 验收 7;F052 AC-23;F055 AC-52 / AC-55;NFR-1.4;INV-30)
+- **AC-16** — IF 权限引擎不可用、评估失败或结果不可判定, THEN THE SYSTEM SHALL 把门面返回的带错误码的明确错误**原样**抛给应用(可与「检索成功但无结果」区分),不返回未过滤、部分过滤或缩水的结果集,不本地重试成「更小的范围」。(↔GOV-05 验收 7;DEV-05 验收 3;F052 AC-24;INV-30)
+- **AC-17** — IF 显式指定的目标知识库中任一**不可及**——托管期:白名单外的库整体不可及(F052 AC-21;未声明的库经该应用不可检索,F055 AC-50 / AC-51);本地期:不存在 / 未授予 / 类型不支持(F052 AC-11 / AC-27)——, THEN THE SYSTEM SHALL 按门面语义拒绝整个请求并指出不可及的标识、不静默剔除后返回缩水结果(存在性不泄露);IF 应用声明的知识库能力被收回, THEN THE SYSTEM SHALL 把 F055 AC-53 定义的「带能力名与已收回原因」的错误原样暴露、可与普通失败区分,不回退旧结果。(↔GOV-05 规则「能力收回后行为」/ 验收 4 / 5)
+- **AC-18** — THE SYSTEM SHALL 使 SDK retrieve **不新增数据面**:不跨请求 / 跨用户缓存或复用检索结果、不本地二次过滤或补召回、不绕过门面直连索引或知识库存储(F052 AC-26「唯一路径」);同一查询由不同用户发起各得其过滤后的结果;运行期能力调用的审计双归属(actor = 应用、subject = 当前访问用户)由服务端从注入凭据取得(F055 AC-55),SDK 不自报也不能改写 subject。(↔DEV-02「不新增数据面」同判据;GOV-04)
+- **AC-19** — THE SYSTEM SHALL 使 retrieve 的各类失败对应用**可区分**并各附可读原因:未取得访问者凭据 / 凭据无效或过期 / 缺权限位(本地期)/ 目标不可及 / 能力已收回 / 权限评估失败 / 版本不兼容 / 平台不可达;错误码与异常形态定义在 design。(↔DEV-04 验收 7「缺什么怎么改」同标准;NFR-1.4)
+
+### 2.4 storage(按应用收窄的附件存取)
+
+- **AC-20** — THE SYSTEM SHALL 提供 storage:在**当前应用**的附件空间内上传、下载、列举、删除附件(含附件元信息读取)——四操作、按应用隔离、越界拒绝、单文件上限的语义由 F054 AC-45 的按应用收窄附件存储句柄定义,SDK 以同名句柄同一 API 使用;句柄经运行期注入取得(F054 AC-17);SDK 只消费句柄、不创建或销毁附件空间。(↔DEV-07 表 storage / 验收 1;DEV-04 契约「平台托管提供附件 / 文件存储」;F054 AC-45)
+- **AC-21** — THE SYSTEM SHALL 使 storage 按应用隔离:应用 A 经 SDK 无法读取、列举、覆盖或删除应用 B 的附件;应用无法经 SDK 访问附件空间之外的任何对象;SDK 对外**不暴露底层对象存储的任何实现细节**(bucket、对象键前缀、端点地址、存储凭据、签名机制)——应用只见附件的应用内路径 / 名称与元信息;路径穿越等越界写法得到明确拒绝。(↔DEV-07 表 storage「不把底层对象存储实现暴露给应用」;NFR-1)
+- **AC-22** — THE SYSTEM SHALL 使经 storage 上传的附件不进入公共可读存储、不计入租户存储配额,单文件超过部署配置上限得到明确错误而非截断写入(均为 F054 AC-45 句柄承诺,SDK 原样呈现);SDK 不产生匿名可达的直链,本版不提供绕过应用的下载直链或分享链接,应用经自身接口向用户吐文件(§4 决议-9)。(↔GOV-03 规则;NFR-2;F054 AC-45)
+- **AC-23** — WHILE `bisheng dev` 期, THE SYSTEM SHALL 使 storage 以**完全相同的 API** 工作:句柄即 F053 AC-27 注入的本地附件目录句柄(指向项目本地附件目录),附件在 `dev` 重启间保留、不进入 `deploy` 上传包(F053 AC-32 忽略规则含本地附件目录);应用代码零差异;本地期同样只见附件的应用内路径、不依赖真实落盘位置。(↔DEV-05 需求「本地与线上零差异」;F053 AC-27 / AC-32;§4 决议-4)
+- **AC-24** — THE SYSTEM SHALL 使托管期附件真身恒在平台存储:实例回收 / 崩溃 / 重建后附件完整(经 F054 AC-39),owner 显式删除应用时随资产一并删除(经 F054 AC-43);SDK 不提供清空整个附件空间的操作,附件的删除只能逐个 / 按显式清单进行。(↔RT-07;INV-35)
+- **AC-25** — IF storage 调用时无法取得应用的存储句柄(托管期未注入 / 已失效 / 应用已下线 / 平台不可达;本地期未经 `bisheng dev` 启动故无 F053 AC-27 句柄)、或目标附件不存在, THEN THE SYSTEM SHALL 抛出彼此可区分的明确错误;不静默改写到本地临时目录、不返回空列表或空内容当作成功。(↔NFR-1.4 同向;F055 AC-58 下线语义;F053 AC-27)
+
+### 2.5 开发者指南与技能包 SDK 章节
+
+- **AC-26** — THE SYSTEM SHALL 随 SDK 发布开发者指南,覆盖:安装(含内网 / 托管构建期取包方式)、三件套用法、模型与应用数据库两条标准库接法(引用 F053 AC-17 交付的同源内容,不另写第二份)、与技能包 / CLI 的配合、`login → dev → deploy` 完整流程;WHEN 开发者按指南操作, THE SYSTEM SHALL 使其走通该完整流程;指南随平台版本发布、与 SDK 版本对应。(↔DEV-07 规则「随 SDK 发布开发者指南」/ 验收 4)
+- **AC-27** — THE SYSTEM SHALL 使指南 auth 章按 F053 AC-17 的可判定形式显著标注静默失败点:**独立成章、章首置顶警示块(点明「写错了应用照样跑起来、只是身份是错的」)、列在目录第一条**,并给出「正确做法一行 vs 错误做法(自建登录页 / 自读头 / 自解析凭据)」对照与 AC-07 抛错行为的说明。(↔DEV-03 表「平台能力接线」auth 显著标注;DEV-07 表 auth)
+- **AC-28** — THE SYSTEM SHALL 交付「平台能力接线」包中的 **SDK 三件套章节、其可运行样例与连通自检脚本的 SDK 部分**(承接 F053 AC-17 / AC-18;包结构与 sync 机制归 F053):WHEN 自检脚本在已配好的环境执行, THE SYSTEM SHALL 对 auth / retrieve / storage 三项分别通过;WHEN 缺少配置(未 `login` / 缺权限位 / 平台不可达 / 未注入身份 / 版本不兼容), THE SYSTEM SHALL 输出可读原因而非堆栈;样例与脚本中不出现真实密钥;指南三件套章与技能包 SDK 章**同源一份**(§4 决议-7)。(↔DEV-03 验收 1;NFR-2)
+- **AC-29** — THE SYSTEM SHALL 使 auth 静默失败点评测达标:**≥ 5 个**「未读包 vs 读包」对照样本,读过「平台能力接线」包(含本 Feature 的 SDK 章)后 agent 生成的应用不自建登录页与鉴权逻辑、改读平台注入身份的比例 **100%**(承接 F053 AC-22 ②)。(↔DEV-03 验收 6;DEV-05 验收 4;NFR-1.3)
+
+### 2.6 不进 SDK 的两条路径同样通(旅程引用)
+
+- **AC-30** — THE SYSTEM SHALL 使 SDK 公开面**不包含**模型调用(chat)与应用数据库(appdb)模块,也不含对二者的任何便捷封装(连接工厂 / 客户端工厂 / 便捷函数);WHEN 开发者按指南用官方 OpenAI 兼容客户端 + 平台注入的 base URL 与凭据调用模型, THE SYSTEM SHALL 使其调通(**经 F051 AC-01 / AC-33 验证**);WHEN 用标准数据库库 + 平台注入的连接环境变量读写应用数据库, THE SYSTEM SHALL 使其读写成功(**经 F054 AC-45 / F053 AC-26 验证**)——本 Feature 只验收「指南可走通」与「SDK 不含二者」。(↔DEV-07「刻意不进 SDK 的两样」/ 验收 2;产品方案 §3.4)
+- **AC-31** — THE SYSTEM SHALL 使 SDK 读取的注入头名称与同名环境变量与 F053 `dev` 注入清单(AC-27)、F054 运行期注入契约(AC-17 / AC-31 / AC-34)**同一契约**(design 落点、三处对齐),使技能包与指南教的写法本地线上零差异。(↔DEV-07「刻意不进 SDK 的两样」表「本地由 `bisheng dev` 注入同名环境变量」;F051 AC-30 / AC-33)
+
+### 2.7 范式唯一与准入门槛
+
+- **AC-32** — THE SYSTEM SHALL 使 SDK 成为三件事的**平台推荐、且指南与技能包唯一教授**的写法:本地手写引入 SDK 的应用与平台内模板生成的应用(PRD-2 WB-01)使用同一套 SDK 与调用范式(抽查关键调用一致——**验收随 PRD-2 WB-01**,release-contract 表 3 F057 行);平台不提供第二套 helper 或兼容层。直读平台注入头亦为合法写法(PRD-1 DEV-05「读注入头 / SDK.auth 一行」),非 Python 应用走两条标准库路径 + 直读注入头。(↔DEV-07 规则「调用范式全平台唯一」/ 验收 3;PRD-1 §1 分册说明)
+- **AC-33** — THE SYSTEM SHALL 使 SDK 发布件的公开面恰为 auth / retrieve / storage 三个模块:导入 SDK 下的 chat / appdb 或任何其它模块名失败,公开面清单可被自动化校验;模块准入门槛(平台特有 ∧ 写错了会出安全事故)为流程规则,见 §3「不进 SDK 的两样」与 §4 决议-10,不作 AC。(↔DEV-07 规则「不进 SDK 的两样不得按惯性加回」)
+- **AC-34** — WHEN 同一份使用 SDK 三件套的应用代码不加改动地由 `bisheng dev` 本地跑通后经 `bisheng deploy` 上线, THE SYSTEM SHALL 使其在托管环境 auth 取到**当前访问用户**、retrieve 按该用户(∩ 声明白名单)过滤、storage 落平台存储——「本地与线上同构」对三件套整体成立(旅程引用,经 F053 AC-49 / F054 / F055 验证)。(↔DEV-05 验收 5;RT-01)
+
+### 2.8 追加(2026-08-17 审查补齐,末尾追加不重排)
+
+- **AC-35** — THE SYSTEM SHALL 使开发者指南与技能包的 retrieve 章写明 per-user 过滤语义(以当前访问者身份检索、结果 = 声明白名单 ∩ 访问用户可见范围、fail-closed)与本地 / 线上可及范围差异(本地无白名单、范围 = 服务账号被授予范围;只会「本地看得少」、不会「本地能跑、线上越权」;验证 per-user 差异的路径 = 发布后用真实账号访问)。(↔DEV-03 表「平台能力接线」retrieve「per-user 过滤语义」;DEV-05 ② / ③;§3)
+- **AC-36** — THE SYSTEM SHALL 使开发者指南与技能包的 storage 章写明本地 / 线上差异(本地附件落项目本地附件目录、不进上传包;线上落平台存储、随应用资产持久与删除)与不变项(同一 API、按应用隔离、无直链、不暴露底层存储实现),以及附件不计租户存储配额。(↔DEV-03 表「平台能力接线」storage;DEV-05;F053 AC-27 / AC-32;F054 AC-45)
+
+---
+
+## 3. 边界情况
+
+> 只写期望的对外行为;SDK 内部的上下文传递、错误分类实现、本地落盘布局见 design.md §5。
+
+- **auth 是唯一「写错了应用照样跑」的静默失败点——SDK 让正确做法成为默认**:无注入身份即抛错(AC-07),而不是返回 None / 匿名让应用「看起来能跑」。由此健康探活请求、后台任务、单元测试裸调用会遇到该错误——这是**刻意的**:指南教健康端点不调 auth、后台任务不假设有访问者;不为这些场景提供「宽松模式」或缺省身份开关,否则静默失败点又回来了(§4 决议-1)。
+- **retrieve 必须用访问者身份而非应用身份,选错即越权**:托管期同一容器内同时存在两种凭据——应用运行期凭据(应用自身身份,服务能力总线的模型调用等,F055 AC-57 / F051)与每请求注入的访问者短时凭据(F054 AC-34);SDK retrieve 只认后者、结构上不接受前者(AC-12)。**后台任务无访问者 → retrieve 报错,线上本地一致**(AC-15;F055 AC-55 明定「仅模型调用允许后台任务以应用自身发起、检索一律拒绝」);PRD-1 全册没有「以应用身份检索」的场景,本版不提供该 API(范围边界)。
+- **本地 dev 与线上同构 → 应用代码零差异,但有四处可预期的「数据差异」**(指南必须写明):① auth 取到的是服务账号、组织字段可为空(AC-10);② 本地 retrieve 无能力声明白名单、范围 = 服务账号被授予范围——本地能查到但线上未声明的库上线后被拒、本地查不到但线上用户有权且已声明的库上线后能查到(AC-13 / AC-14);③ 访问者凭据句柄的来源不同(本地 = 迷你代理每请求注入的服务账号访问凭据句柄,F053 AC-23;线上 = app-proxy 注入的访问用户短时凭据句柄,F054 AC-34)但对应用不可见;④ storage 本地落盘、线上落平台存储(AC-23)。开发期只会「本地看得少」,不会「本地能跑、线上越权」(PRD-1 DEV-05 ③)。
+- **retrieve 不本地模拟白名单**:白名单的语义是「审批通过 = 应用侧授权」(F055),本地无经审批的声明可依;SDK 若按 `bisheng-app.yaml` 草稿本地收窄,等于复制一份白名单判定(与 F052 AC-26「唯一路径」/ INV-36 相悖),且草稿与线上生效声明会漂移。代价 = 上一条差异 ②,靠指南与 F055 预检(声明所引知识库存在与可用)兜住。
+- **storage 不暴露 bucket / 路径 / 端点 / 凭据**:应用只见应用内路径与元信息(AC-21);越界(跨应用、路径穿越、附件空间之外)一律明确拒绝而非静默映射(F054 AC-45 句柄语义);不提供匿名直链——附件不进公共可读存储是 F054 承诺、SDK 不提供任何绕开它的句柄(AC-22)。需要让用户下载时应用自己吐流。单文件上限是部署配置项,超出报错不截断。
+- **审批期临时预览实例**(F055)注入的是审批人身份、平台能力按 owner 权限放行且计审计(INV-36 唯一例外):SDK 对此**透明**——auth 返回审批人、retrieve 以注入的凭据为准、storage 以注入的句柄为准;预览实例的附件空间是否与生产隔离由 F055「试用数据不带入生产」口径决定,SDK 不判断自己身处何种实例。
+- **版本不兼容**:平台声明其支持的 SDK 最低兼容版本(F053 AC-01 版本信息),不兼容即在首次交互抛错(AC-03)。**auth 不需平台交互**,因此不兼容可能到 retrieve / storage 首次调用才暴露——指南建议应用启动时执行一次连通自检(AC-28 的自检脚本即为此);同机多平台 `dev` 时以当前 `login` 的平台为准。平台升级后 SDK 版本仍在支持区间内的存量应用不需重发;低于区间的应用在下一次调用得到明确错误、由 owner 升级依赖后重新 `deploy`(重发必审,INV-34)。
+- **不进 SDK 的两样**:SDK 内没有 chat / appdb,也没有「顺手包一层」的便捷函数(AC-30);模型走官方 OpenAI 兼容客户端(F051 AC-33 已保证本地线上接法一致)、数据库走标准库 + 同名连接环境变量(F054 AC-45 / F053 AC-26);**模块准入门槛(流程规则,spec 评审项,不作 AC)**:新增模块须同时满足「平台特有」∧「写错了会出安全事故」,chat / appdb 被否决的理由记录在 PRD-1 DEV-07 两表与 design(§4 决议-10;AC-33 只验收公开面恰三模块)——应用密钥最小版由 F055 在本版交付,应用直接读取同名环境变量;不据此新增 SDK 密钥读取或管理模块,公开面仍按 AC-33 验收。
+- **凭据生命周期对 SDK 的影响**:访问者短时凭据到期 / 用户会话失效 → 该请求内的 retrieve 报「凭据无效或过期」(AC-15),SDK 不缓存、不续期;应用下线 → 运行期凭据与句柄拒绝(F055 AC-58)→ storage / retrieve 报可区分错误(AC-25);服务账号密钥被撤销 → 本地 `dev` 期后续调用被拒(F053 AC-11 / AC-29),SDK 原样呈现。
+- **部署开关**:SDK 安装件分发端点随开放能力层开关存在(AC-05);本地 `dev` 以开放能力层已部署为前提(F053 AC-05 / AC-29)、托管运行以工场运行时层已部署为前提(F054 AC-30)——两个前提由 CLI 与运行时在入口把住,SDK 不为「层未部署」另设分支;auth 与部署开关无关。
+- **多租户对 SDK 不可见**:租户由注入的凭据 / 句柄承载;SDK 不接触租户标识、不提供指定租户的参数;跨租户共享的知识库可见性由门面处理(F052 AC-20 四类来源)。
+- **请求作用域与并发**:auth / retrieve / storage 的身份与句柄均取自当前请求上下文(AC-09);同一进程内多用户并发互不串扰;WebSocket 取握手身份、到期由 F054 断开。SDK 不绑定特定 Web 框架——把当前请求上下文交给 SDK 的接法在指南给出、形态属 design。
+- **审计归属由服务端决定**:运行期能力调用的 actor / subject 由服务端从注入凭据取得(F055 AC-55 / F051 AC-21),SDK 不自报 subject、也无法改写(AC-18)——否则应用可伪造「以谁的名义」。
+- **托管期出站可达**:应用实例默认封禁出站、只放行平台 API 与声明域名(F054);SDK 只访问平台 API,不引入需要额外出站白名单的第三方地址(design 约束)。
+
+---
+
+## 4. 待澄清决议
+
+> 2026-08-17 用户授权全自动模式:以下各项按建议直接定案,记录理由与备选供追溯;spec 评审(`/sdd-review`)后如有 high 级发现再增补。
+
+- **[决议-1] auth 无注入身份时抛出明确错误,不返回 None / 匿名 / 缺省身份,也不提供「宽松模式」开关。** 理由:DEV-07 把 auth 定为唯一静默失败点——「写错了应用照样跑起来、只是身份是错的」;返回 None 会让应用写出 `if user is None: 匿名处理` 这类分支,把静默失败点从「自建登录页」挪到「匿名兜底」,性质不变;抛错让错误在开发期第一次调用就暴露。备选 = 返回 Optional:对健康端点 / 后台任务更「方便」,但方便的代价正是安全事故的温床。**何时重新考虑**:无。
+- **[决议-2] retrieve 的执行身份取自请求上下文中平台注入的访问者凭据(线上 = F054 AC-34 短时访问凭据句柄、本地 = F053 AC-23 迷你代理以同一结构每请求注入的服务账号访问凭据句柄);SDK 不从进程级环境变量取密钥做检索、不读应用运行期凭据、不提供 `as_user` 等指定他人身份的参数;后台任务无访问者即报错。** 理由:DEV-07 表明写「必须以当前访问者身份检索、而非应用自身的身份,选错即越权——SDK 把这个选择固定下来」;托管容器内同时存在应用运行期凭据与访问者凭据,若 SDK 允许任一「兜底」,越权路径就在一个默认参数之外;`as_user` 是 `--as` 的代码翻版(PRD-1 §5.1 / DEV-05 ①)。本地期迷你代理以同一结构每请求注入服务账号访问凭据句柄、`login` 密钥不进应用进程(F053 AC-23 / AC-27),使 SDK 内部也不需要「本地读环境变量、线上读请求」两条路径。备选 = 线上无访问者时以应用身份检索:PRD-1 全册无此场景、F055 AC-55 明定检索无访问用户一律拒绝、且与 GOV-05 fail-closed 直接冲突,否决。**何时重新考虑**:PRD-2 若引入应用级后台检索需求,须先在 F055 定义其权限基准并补 INV,再议 SDK 面。
+- **[决议-3] 本地 `dev` 期 SDK 不模拟能力声明白名单,retrieve 范围 = 服务账号被显式授予范围。** 理由:白名单语义 = 「审批通过 = 应用侧授权」(F055 AC-50),本地没有经审批的声明可依;本地按 `bisheng-app.yaml` 草稿收窄等于在 SDK 复制一份白名单判定(与 F052 AC-26 唯一路径 / INV-36 相悖),且草稿与线上生效声明会漂移造成「本地过、线上拒」的反向错觉;PRD-1 DEV-05 ③ 与 F052 AC-22 已明写本地期无白名单。代价 = §3「数据差异 ②」,由指南与 F055 预检(声明所引知识库存在与可用)兜住。**何时重新考虑**:F055 若提供「按草稿声明预演」的服务端能力(预检返回可及性),SDK 可在自检脚本里调用它提示差异,仍不在 retrieve 路径上收窄。
+- **[决议-4] 本地 `dev` 期 storage 以 F053 AC-27 注入的本地附件目录句柄工作、API 与线上完全相同、在 `dev` 重启间保留、不进 `deploy` 上传包(F053 AC-32);本地期同样不把真实落盘位置暴露给应用逻辑。** 理由:DEV-05「本地与线上零差异」对三件套整体成立才有意义;本地起一个对象存储服务成本高且与「零公网 / 零额外中间件」相悖;与 F053 AC-26 本地 SQLite 的形态一致、忽略规则一并覆盖。备选 = 本地直接调平台存储(走服务账号):会让开发期附件混入平台、且服务账号没有「应用附件空间」的归属,否决。**何时重新考虑**:无。
+- **[决议-5] SDK 发布形态 = 可安装的 Python 包,经平台自身分发(F053 AC-01 的安装件分发端点承载,同「匿名可达、随开放能力层开关」规则)+ 公开包索引可选;托管构建期自平台自身分发端点安装(F054 AC-16 构建期白名单);首发支持 python3.11(托管 `runtime` 矩阵)并随矩阵扩展。** 理由:与 CLI 同渠道(F053 决议-1 / 决议-11)——纯内网零公网依赖是信创硬要求、版本对齐靠「从自家平台拉」天然成立;托管构建期出站只放行平台包源与平台分发端点(F054 AC-16),SDK 若只发公网即在内网构建期断裂、若要求进客户镜像源则每个客户多一步运维。备选 = 只发公网 PyPI:内网不可达,否决;备选 = 随 CLI 打进同一个包:CLI 是开发者本机工具、SDK 是应用运行期依赖,生命周期与安装位置不同,合包会让应用容器背上 CLI 依赖,否决。**何时重新考虑**:无。
+- **[决议-6] SDK 版本策略:独立于平台版本号;对外承诺 = 平台声明其支持的 SDK 最低兼容版本(随 F053 AC-01 分发端点的版本信息给出)、不兼容时 SDK 在与该平台首次交互即明确报错并给出处置方式、兼容的存量应用在平台升级后无需重发。校验时机、判据(主 / 次 / 补丁)与探测方式属 design。** 理由:与 F053 决议-7「CLI 与平台同号」刻意不同——CLI 是开发者本机工具、随时可重装,SDK 被应用依赖清单锁定、跟着版本快照走(RT-05 只增不改),若要求同号则平台每次升级都会让全部存量应用不兼容、必须重发且重发必审(INV-34),不可接受;区间兼容 + 明确报错优于「看起来能跑、行为悄悄不对」。auth 不需平台交互、不兼容在首次 retrieve / storage 暴露——指南建议启动自检(AC-28)。**何时重新考虑**:F055 若在托管预检加入「应用锁定的 SDK 版本 ∈ 平台支持区间」校验(可选增强、归 F055),本条不变、只是暴露更早。
+- **[决议-7] 开发者指南的三件套章节与「平台能力接线」包的 SDK 章节同源一份(本 Feature 拥有);指南的模型 / 应用数据库两章引用 F053 AC-17 交付的同源内容、不另写第二份。** 理由:DEV-03「内外同源、不维护两套」的判据外推——指南(面向人)与技能包(面向 agent)教的是同一件事,两份必漂移;F053 已拥有两条标准库接法与包结构,F057 只补 SDK 章,各写各的、互相引用,无重复拥有。备选 = 指南独立成文:短期省事、长期漂移,否决。**何时重新考虑**:无。
+- **[决议-8] SDK 只读注入、不验签:不试图鉴别注入头真伪、不持有验签密钥材料、不做本地权限判定。** 理由:伪造头无效的保证在入口拓扑——线上应用容器仅经 app-proxy 可达(F054 AC-33)且 app-proxy 剥离伪造头(AC-32),本地迷你代理是唯一入口且剥离(F053 AC-23 / AC-25);SDK 若验签就要把验签密钥发进应用容器,新增一类凭据分发面与泄漏面(INV-28 之外的新面),且验签失败与「无注入」在应用侧无区别,收益为零;权限判定只在服务端门面(F052 / INV-30)。备选 = 签名头 + SDK 验签:属纵深防御,但前提是入口拓扑不可信,与 F054 AC-33 承诺相悖。**何时重新考虑**:运行时形态引入「应用容器可被 app-proxy 之外访问」的部署(本版无此形态、INV-33 双形态一致)。
+- **[决议-9] storage 本版 = 四操作最小集(上传 / 下载 / 列举 / 删除,含元信息读取);不提供绕过应用的下载直链 / 分享链接 / 附件级权限;服务端句柄归 F054、SDK 只消费;不提供清空整个附件空间的操作。** 理由:DEV-07 表 storage 的三条理由(按应用收窄、不计配额、不暴露底层实现)都指向「平台中介、最小面」;直链会把对象存储的可达性与签名机制暴露给用户侧(Discovery 已指出 sharepoint / 匿名读策略的既有泄漏面),且附件级权限本版无需求;清空操作没有业务场景、误用即毁数据(INV-35「资产删除唯经显式删除」同向)。**何时重新考虑**:PRD-2 数据面「附件字段」或大文件场景需要短时下载句柄时,由 F054 定义句柄形态、SDK 再暴露。(句柄形态已由 F054 AC-45 定义:四操作、按应用隔离、越界拒绝、单文件上限。)
+- **[决议-10] SDK 公开面恰为三模块,准入门槛「平台特有 ∧ 写错了会出安全事故」写进指南与 design 并作为 spec 评审项;公开面清单可自动化校验。** 理由:PRD-1 DEV-07 规则明令「不得按惯性加回」并要求「实施时若要新增模块,须先证明它同时满足两条」;把门槛写进可校验的清单比写进备忘录更能防「顺手包一层」。**何时重新考虑**:无(门槛本身不变,过门槛的新模块另起 AC)。
+- **[决议-11] auth 暴露的身份含「主体类型」(自然人 / 服务账号)字段,来源为 F054 AC-31 / F053 AC-23 注入的同一结构,SDK 不自行推断。** 理由:DEV-05 要求 `dev` 输出明示注入身份来源避免开发者误以为在以他人身份运行(F053 AC-24),应用侧同样需要一个可判定的信号来区分本地与线上(例如在页面角标显示「本地开发 · 服务账号」);若 SDK 靠「主部门为空」推断会与「无部门的自然人」混淆。备选 = SDK 不带该字段、由应用自行比对环境变量:又是一处静默的本地 / 线上分叉,否决。
+
+---
+
+## 5. 设计与实现(指针,不复制)
+
+> **本节刻意不写内容。** spec 不承载 How。
+
+| 你想知道 | 去哪看 |
+|---|---|
+| 为什么这么实现(决策 + 备选 + 何时该推翻) | design.md §3 |
+| 关键约束(请求上下文传递 / 两类凭据不混用 / 版本兼容 / 两个部署开关 / 多租户对 SDK 不可见 / 出站可达) | design.md §2 |
+| 今天的数据流、模块职责、字段 / API / 文件名约定(SDK 包结构与公开面、注入头与同名环境变量清单〔与 F053 / F054 三处同一契约〕、身份字段清单含主体类型、retrieve / storage 的服务端入口、本地 dev 期落盘布局、分发发布件与版本元数据) | design.md §4 |
+| 代码里看不出的坑、反直觉事实(全仓无可发布包工程、MinIO public bucket 匿名读策略与 sharepoint 公网可达性、Streamlit 等框架取请求上下文的限制、后台任务无访问者等) | design.md §5 |
+| 对外契约(与 F052 门面入参出参、与 F054 注入契约与存储句柄、与 F053 `dev` 注入与忽略规则与技能包章节、与 F055 白名单携带与收回错误形状、与 F051 base URL 环境变量名)、改了会破坏谁、依赖谁 | design.md §6 |
+| 错误分类与错误码段(AC-19 / AC-25 的可区分性落地) | design.md §6 |
+| 任务拆解、文件清单、执行顺序、评测样本(AC-29) | tasks.md |
+
+**跨 Feature 契约锚点**(已落对方 spec,本 spec 只引用、不重复拥有):F053 —— AC-01(分发端点承载 SDK 发布件与平台声明的 SDK 最低兼容版本)/ AC-23(`dev` 迷你代理注入同构身份头〔含主体类型〕+ 每请求短时访问凭据句柄,`login` 密钥不进应用进程)/ AC-27(同名环境变量含本地附件目录句柄;访问者凭据不以进程级环境变量提供)/ AC-32(忽略规则含本地附件目录);F054 —— AC-16(构建期白名单含平台自身分发端点)/ AC-31(注入字段含主体类型)/ AC-34(短时访问凭据句柄)/ AC-45(附件句柄四操作 / 隔离 / 越界拒绝 / 单文件上限,SDK 与本地 dev 同名句柄同一 API);F055 —— AC-50(托管期检索入口 = 短时凭据确立访问用户 + 平台侧按当前生效声明定白名单,应用不可自报)/ AC-55(仅模型调用允许后台任务以应用自身发起、检索无访问用户一律拒绝)。
+
+---
+
+## 相关文档
+
+- 设计真相: [design.md](./design.md)(尚未编写)
+- 执行与落档: [tasks.md](./tasks.md)(尚未编写)
+- 版本契约: [features/v3.0.0/release-contract.md](../release-contract.md)(表 3 F057 行为本 Feature 范围的权威来源——DEV-07 三件套 + 开发者指南、chat / appdb 刻意不进、验收 3 随 PRD-2 WB-01、依赖 F052 / F053、storage 依赖 F054;INV-28 / INV-30 直接约束;INV-32(平台注入身份唯一可信来源)的 auth 半边与 INV-36(托管期检索两层)的 retrieve 半边由本 spec 承接——两条已于 2026-08-17 随 F054 / F052 spec 定稿转正,来源 spec 列含 F057;INV-35 约束 storage 的删除面)
+- MVP 纵切: [features/v3.0.0/mvp-114-path.md](../mvp-114-path.md) §2(F057 不在纵切上)
+- Spec Discovery: [features/v3.0.0/000-prd1-discovery/discovery.md](../000-prd1-discovery/discovery.md) §2.9(无可发布包工程 / 无安装件分发端点 / MinIO 前缀隔离范式)/ research/appdb-storage-sdk.md(storage 现状:public bucket 匿名读策略、仅 presigned GET、无 app 级命名空间)
+- 同版 spec: [../052-mcp-server-face/spec.md](../052-mcp-server-face/spec.md)(AC-11 不可及拒绝整个请求 / AC-19 门面 / AC-20 文件级过滤 / AC-21 白名单 / AC-22 无白名单 / AC-23 身份缺失 / AC-24 引擎不可用 / AC-26 唯一路径 / AC-27 支持类型);[../053-dev-cli-skills/spec.md](../053-dev-cli-skills/spec.md)(AC-01 安装件分发 / AC-05 开关 / AC-17–18 接线包 SDK 章与自检 / AC-22 ② 评测 / AC-23–28 `dev` 注入与本地 SQLite / AC-32 忽略规则 / AC-49 同构 / 决议-3 / 决议-7 / 决议-11);[../054-app-domain-runtime/spec.md](../054-app-domain-runtime/spec.md)(AC-16 构建期白名单含平台分发端点 / AC-17 实例注入 / AC-31 身份注入含主体类型 / AC-32 伪造头无效 / AC-33 仅经 app-proxy 可达 / AC-34 短时访问凭据 / AC-35 WS 有效期 / AC-39 资产持久 / AC-43 删除 / AC-45 每应用数据库与附件句柄 / 决议-4);[../055-app-publish-pipeline/spec.md](../055-app-publish-pipeline/spec.md)(AC-49 模型注入通道 / AC-50–52 白名单 ∩ 用户 · fail-closed / AC-53 能力收回错误态 / AC-55 审计双归属 / AC-57–58 应用运行期凭据);[../051-model-protocol-gateway/spec.md](../051-model-protocol-gateway/spec.md)(AC-01 官方客户端直连 / AC-30 单一 base URL / AC-33 本地线上接法一致 / 决议-5 subject 采信)
+- PRD: `docs/PRD/3.0-release/3.0 应用工场 PRD-1 专业开发者通道与应用运行时.md` v2.0(DEV-07 / DEV-03 / DEV-05 / DEV-04 契约 / GOV-05 / GOV-03 / NFR-1 / NFR-2 / §1 分册说明 / §5.1);`docs/PRD/3.0-beta2/3.0 开放 API 鉴权与身份传递 PRD.md` v2.1(§4.4.6);`docs/PRD/3.0-release/3.0 应用工场 产品方案.md` §3.4 / §5.1 F106
diff --git a/features/v3.0.0/057-bisheng-sdk/tasks.md b/features/v3.0.0/057-bisheng-sdk/tasks.md
new file mode 100644
index 0000000000..58228a7039
--- /dev/null
+++ b/features/v3.0.0/057-bisheng-sdk/tasks.md
@@ -0,0 +1,499 @@
+# Tasks: bisheng-sdk(Python 首发三件套 auth / retrieve / storage + 开发者指南)
+
+**关联规格**: [spec.md](./spec.md)(36 条 AC,What 的唯一真相)· [design.md](./design.md)(How 的唯一真相,D1–D16 / 坑 1–36 / §4.2 契约 ①–⑦ / §6.2 阻塞项与回写)
+**版本**: v3.0.0
+**纵切**: 不在 [mvp-114-path.md](../mvp-114-path.md) §2 纵切上;无 `[MVP-核心]` 标记,全部为 release 必做。
+**代码事实口径**: 沿用 design.md(`3.0-vibe` `fe10f75ea`,2026-09-16);**两个前置依赖分支按其源码为准**——`wt/storage-handle` `23886547f`(F054 T084 / T085 附件句柄)与 `wt/cli-dev` `b61b209e4`(F053 `bisheng dev` + `platform-wiring` 技能包),两者均**未合入 `3.0-vibe`**(坑 30 / 坑 31 / 坑 35)。SDK 路径以 `src/bisheng-sdk/` 为根、后端以 `src/backend/bisheng/` 为根、CLI 以 `src/bisheng-cli/` 为根、其余以仓库根为根。**行号会漂移、符号名不会——落地前一律以符号名重定位。**
+
+---
+
+## 状态
+
+| 步骤 | 状态 | 备注 |
+|------|------|------|
+| spec.md | ✅ 已评审 | 2026-08-17 初稿 + 同日独立审查 15 条修订,36 条 AC(决议 1–11) |
+| design.md | ✅ 已评审(全自动模式定案) | 2026-09-16 初版 + 两次续写(末次按 `wt/cli-dev` 改写 D12 / D13 / D4 / D5 / D8,坑扩到 36);`/sdd-review design` 已跑,发现就地修订;接手时的第一入口 |
+| tasks.md | ✅ 已拆解(2026-09-16) | 本文;**44 任务 / 7 Wave + 1 前置**;36 条 AC 全覆盖(追溯表见末尾);`/sdd-review tasks` 已跑 |
+| 实现 | 🚧 进行中 | **42 / 45**(三条切片合流:`wt/f057-sdk-core` 包本体 Wave 0–3、`wt/f057-sdk-dist` 分发与文档 Wave 0 / 4 / 5 / 6、收尾切片 `wt/f057-tail`(T029 wheel 已在位并逐项复核 / T041 旅程测试文件默认跳过 / T043 跨 Feature 回写)与 `wt/f053-skillpack-model`(T035a 模型章两条回归断言改守 F051 已交付的契约)。仍未勾的是需要 114 真机或真实构建容器、以及需要人拍板的项:T032 / T040 / T042。)|
+
+---
+
+## 开发模式
+
+**按 Wave 组织**:
+- **Wave 0**(T000)= 前置依赖分支合并核对(`wt/storage-handle` + `wt/cli-dev`)。
+- **Wave 1**(T001–T009)= SDK 包工程 + 测试基建 + errors / 上下文 / auth。
+- **Wave 2**(T010–T015)= HTTP 层 + 版本兼容 + retrieve。
+- **Wave 3**(T016–T021)= storage(路径规则、本地后端、远端后端、门面与异步孪生)。
+- **Wave 4**(T022–T029)= 平台分发(打包脚本、manifest 合并、artifact_service、四个端点、CI、提交 wheel)。
+- **Wave 5**(T030–T032)= 托管构建期取包(runtime-manager extra index)。
+- **Wave 6**(T033–T040)= 「平台能力接线」技能包**增量**(SDK 章 / retrieve / storage 两章 / SDK 样例 / 自检追加)+ 指南入口 + 评测样本 + CLI 包清单核对。
+- **Wave 7**(T041–T043)= 端到端旅程、114 手验、跨 Feature 回写登记。
+
+**执行序**:Wave 1 → 2 → 3 串行(共用 `_http.py` / `errors.py`);Wave 4 与 Wave 1–3 只共享 `__version__`,可并行;Wave 5 依赖 Wave 4 的简单索引;Wave 6 依赖 Wave 1–4(样例与自检要 import 真 SDK);Wave 7 最后。**T029(提交 SDK wheel)与 T038(重打 CLI wheel)都写 `bisheng/dev_toolkit/artifacts/`,必须串行且排在各自 Wave 末尾**(二进制冲突不可合并)。
+
+**Test-First(SDK 版)**:测试任务先于配对实现任务;`覆盖 AC` **逐条列举**(禁范围写法)。
+- **SDK 单测零网络**:`tests/conftest.py` 的 `no_network` autouse fixture 把 `httpx.Client` / `httpx.AsyncClient` 默认 transport 换成抛 `AssertionError("unmocked network call")` 的哨兵;平台交互一律经 `tests/helpers/platform_mock.py` 的 `httpx.MockTransport` 工厂。同步 / 异步两条路径**参数化跑同一组用例**(D10)。
+- **需要真平台的用例**挂 `@pytest.mark.network`,默认跳过、CI 不跑、114 手验跑(照 `src/bisheng-cli/pyproject.toml` 的 marker 形态)。
+- 测试目录 `src/bisheng-sdk/tests/`(`cd src/bisheng-sdk && uv sync --frozen --extra dev && uv run pytest`)。**不放进 `src/backend/test/`**——SDK 不 import `bisheng`(CON-1)。
+- 平台侧改动的测试落 `src/backend/test/dev_toolkit/`(既有包,`asyncio_mode=auto`,conftest 已剥六个代理变量);runtime-manager 侧落 `src/runtime-manager/tests/test_build.py`(增量)。
+- **文本任务的 Test-First 例外(唯一两处,理由在此)**:T033(SKILL.md 增量)与 T034(`example-sdk/`)是散文与样例,先写断言再写正文只会把断言写成正文的复读机;它们的可判定断言集中在**同波次、同一 PR 的 T035**,且 T035 里有两条**回归型**断言(auth 章仍是目录第一条 + 章首警示块、模型章仍标「暂未提供」)守住 F053 已交付的契约。其余所有实现任务一律测试在前。
+
+**自包含任务**:每个任务内联文件、逻辑、依赖、AC 覆盖;设计论证指向 design §X / D-x / 坑-x,不复制。
+
+**不涉及的任务类别**(写明以免评审误判遗漏):**无前端任务**(Platform / Client 都不改,本 Feature 的「界面」是 wheel、技能包与四个匿名端点,design C7)· **无 Worker / Celery 任务**(无异步作业,因此不涉及 `tenant_id` 经 Celery headers → ContextVar 的传递)· **无 ORM / 迁移任务**(无新表、无 Alembic revision,design C2;因此不需要回滚方案)· **无错误码模块**(D14,用户预留的 26400–26409 不使用)。
+
+**⚠️ 三条实现红线(落码前逐条对照)**:
+1. **SDK 代码里不得出现 `BISHENG_APP_TOKEN` / `BISHENG_API_KEY`**(CON-3 / 决议-2)——T014 有 grep 型断言;retrieve 的凭据只来自请求上下文的 `X-BiSheng-Access-Token`。
+2. **`current_user()` 无注入即抛错,中间件不拒绝无头请求**(D3 / AC-07)——抛错点在读取处,不在中间件;不提供任何"宽松模式"参数。
+3. **两个打包脚本必须合并写 manifest、只删自己前缀的 wheel**(D16 / 坑 9 / 坑 10)——否则重打 CLI 会静默让 `/versions` 的 `sdk` 段回到 null。
+
+**⚠️ 四处"本轮无法端到端验证、只能 mock"的地方,实现者不要去 114 上试然后判定功能不生效**(design §6.2):
+- **托管期 retrieve**:后端 `validate_bearer` 只认 `bs-sak-` / `bs-pat-`(`credential_validator.py:43`),OBO Bearer 今天答 `26001`;且 `obo_secret` 未配时 app-proxy 根本不注入令牌(坑 4 / 5 / 阻塞项 ②)。
+- **本地期 retrieve**:`bisheng dev` 已落地(`wt/cli-dev`),但它注入的 `X-BiSheng-Access-Token` 是**本地 HMAC 自签**的 `bsdev.` 句柄,平台无从验签,同样答 `26001`(坑 32 / 阻塞项 ③)。
+- **本地期 storage**:`bisheng dev` **不注入任何 `BISHENG_APP_STORAGE_*`**(`devdb.py:INJECTED_ENV`,坑 34 / 契约 ⑦);单测一律 `monkeypatch.setenv("BISHENG_APP_STORAGE_DIR", tmp_path)` 自造句柄。
+- **托管期 storage**:附件 API 与三个注入变量**已在 `wt/storage-handle` 实现但未合入**(坑 30);`3.0-vibe` 上跑对账测试会找不到 `runtime_manager/storage.py` → 用例 `skip` 并打印原因,不是失败。
+
+**跨 Feature 依赖(签名 / 契约变更须回头改本文)**:
+| 依赖方 | 具体 | 本文哪些任务会当场坏 |
+|---|---|---|
+| F054 app-proxy `INJECTED_HEADER_NAMES`(`app-proxy/headers.py:29-40`)、`_encode` / 缺失省略语义 | HTTP 头契约 | T005 / T006(对账测试先红) |
+| F054 `lifecycle.build_env` + `storage.storage_env` 注入的 `BISHENG_APP_STORAGE_ENDPOINT` / `_TOKEN` / `_MAX_FILE_MB`(`wt/storage-handle`;backend 副本 `app_runtime/domain/constants.py:APP_STORAGE_ENV_NAMES:188-192`) | 环境变量契约 | T018 / T019 / T020 / T021 |
+| F054 T084 附件 API `PUT|GET|DELETE /v1/apps/{app_id}/storage/objects/{key}`、`GET …/meta/{key}`、`GET …/objects`(design §4.2 ④,**对方已实现**,manager 信封 `{"detail":{code,message}}`) | HTTP 契约 | T018 / T019;T042 联调 |
+| F053 T042–T044 `bisheng dev`(同构十头 + 每请求 `bsdev.` 凭据;**未注入 `BISHENG_APP_STORAGE_DIR`**) | CLI | T005 / T006(对账)· T016 / T020(句柄自造)· T042 本地期联调 |
+| F053 T038 `skills/platform-wiring/`(SKILL.md 五章 + `example/` + `selfcheck.py`)与 `test_skill_packs.py` 的 `PACKS` 参数化 / `WIRING` 专项断言 | 技能包文本 + 测试 | T033 / T034 / T035 / T036(全为增量,改章序即红) |
+| F053 `versions` 载荷 `sdk` 字段位(三键留位,`distribution.py:80`)+ `test_distribution_api.py:80-81`、`:228` 的 null 断言(`wt/cli-dev` 上漂到 `:82-83`、`:230`) | HTTP / 测试 | T026 / T027 |
+| F053 `pack_cli_wheel.sh` + `cli-quality.yml` manifest 漂移守卫 | 构建脚本 | T023 |
+| F053 `commands/skills.py:DEFAULT_PACKS`(`3.0-vibe` 单元素;`wt/cli-dev` 已两元素 + 已重打 CLI wheel) | CLI 常量 + 二进制 | T038(合并后先核对再决定改不改)· T029(`artifacts/` 二进制串行,坑 35) |
+| F052 门面:OBO 受理、**平台签发的本地短时凭据受理**、`knowledge_base_ids` 可省略、「不可及」码 + `data.unreachable_ids`;F055 T057–T059 白名单 / `16273` / 审计 | 服务端 | T014 / T015(占位码)· T042 |
+| runtime-manager `builder.py:366-369` buildargs、`config.py:159-160, 231-232`、`Dockerfile.j2:21-42` | 构建契约 | T030 / T031 |
+
+**跨 Feature 副作用登记**(改到别人文件的任务):
+- **T023**(`scripts/pack_cli_wheel.sh`)——改 `rm -f *.whl` 为 `rm -f bisheng_cli-*.whl`、manifest heredoc 改 python 合并;`cli-quality.yml` 的 drift guard 逻辑不变。
+- **T026 / T027**(`dev_toolkit/api/endpoints/distribution.py`、`dev_toolkit/domain/services/artifact_service.py`、`test/dev_toolkit/conftest.py`、`test/dev_toolkit/test_distribution_api.py`)——F053 拥有的文件做增量;**改 F053 的 `sdk` null 断言**为 T027 的同批改动。
+- **T029 / T038**(`dev_toolkit/artifacts/manifest.json` + 两个 wheel)——二进制,**串行**;`wt/cli-dev` 已提交过一版 CLI wheel + manifest,合并后**先合并再重打**,不要手工编辑 manifest(坑 35)。
+- **T030 / T031**(runtime-manager `config.py` / `builder.py` / `templates/python3.11/Dockerfile.j2` / `tests/test_build.py`)——F054 拥有;纯追加两个 ARG / 两个 buildarg / 两个 config 字段;`wt/storage-handle` 也改 `config.py`(追加 `storage_*` 字段),两处不相邻但同文件,合并顺序见 T000。
+- **T033 另改 F053 的测试**(`test/dev_toolkit/test_skill_packs.py` 里 `test_auth_chapter_teaches_exactly_app_proxys_header_names` 的 `assert "不要依赖" in token_row` 一句)——`X-BiSheng-Access-Token` 从「本轮无消费方」变成「retrieve 在用」,守卫断言随契约同批更新,同一 PR。
+- **T033 / T034 / T036**(`skills/platform-wiring/` 的 `SKILL.md` / 新目录 `example-sdk/` / `selfcheck.py`)——**包本体归 F053 T038 且已交付**,本 Feature 只做增量:换 auth 章的「SDK 用法」桩、插 retrieve / storage 两章、加 SDK 样例与自检步骤;**章序、目录第一条、`> ⚠️` 警示块、模型章「暂未提供」一律不动**(F053 的 `test_skill_packs.py` 断言,坑 31)。**T037** 改 `skills/deploy-hosting/SKILL.md:138` 指针一句与 `skills/README.md` 目录树。
+- **T038**(`src/bisheng-cli/bisheng_cli/commands/skills.py` + `tests/test_command_skills.py`)——`wt/cli-dev` 已把 `DEFAULT_PACKS` 改成两元素并补过测试;合并后若已两元素则**只重打 CLI wheel 一次**(因 T023 改了打包脚本),常量与测试零改动。
+- **T043**——只追加文档条目到 F052 / F053 / F055 tasks,不改他人代码;**F054 无回写**(附件契约以 `wt/storage-handle` 为准,design §6.2 回写登记第 1 条)。
+
+---
+
+## Tasks
+
+### Wave 0 · 前置依赖核对
+
+- [x] **T000**: 前置依赖分支合并核对(先于任何编码)〔0.5h〕
+  **完成**: ✅ 2026-09-16 在 `3.0-vibe` `375a8594f` 上逐条核对,六项全部就位(两个前置分支都已合入,`wt/storage-handle` / `wt/cli-dev` 不再存在于 worktree 列表):① `src/runtime-manager/runtime_manager/storage.py` 有 `STORAGE_ENV_NAMES`;② `app_runtime/domain/constants.py:APP_STORAGE_ENV_NAMES` 三名齐;③ `src/bisheng-cli/bisheng_cli/devproxy.py` 命中 `X-BiSheng-` 20 处(十头 + 归一化表);④ `dev_toolkit/skills/platform-wiring/` 存在;⑤ `DEFAULT_PACKS = ("deploy-hosting", "platform-wiring")` 已两元素;⑥ `artifacts/manifest.json` 键为 `['cli', 'platform', '_note']`——**尚无 `sdk` 段**(归姊妹切片 T022 / T029)。另核对到本文未登记的一条:F055 的托管期检索服务端已合入(`filelib.py` 的 `HOSTED_APP_ACTOR_KIND` 分支 + `CapabilityBusService.retrieve`),其凭据形状与 design 初稿不同,见「实际偏差记录」第一条。
+  **文件**: 本文(回填核对结果,不改代码)
+  **逻辑**: 本 Feature 的实现分支必须从**已含两个前置分支**的基线切出,顺序 `wt/cli-dev` → `wt/storage-handle` → 本 Feature(坑 35:两分支都写 `src/backend/bisheng/dev_toolkit/artifacts/`,二进制不可 textual merge)。逐条核对并把结果写进本任务下方:① `test -f src/runtime-manager/runtime_manager/storage.py` 且 `grep -q "STORAGE_ENV_NAMES" ` 命中(F054 T084)② `grep -n "BISHENG_APP_STORAGE_ENDPOINT" src/backend/bisheng/app_runtime/domain/constants.py`(backend 契约副本)③ `test -f src/bisheng-cli/bisheng_cli/devproxy.py` 且 `grep -c "X-BiSheng-" ` ≥ 10(F053 T042)④ `test -d src/backend/bisheng/dev_toolkit/skills/platform-wiring`(F053 T038)⑤ `grep -n "DEFAULT_PACKS" src/bisheng-cli/bisheng_cli/commands/skills.py` 是否已两元素 ⑥ `python3 -c "import json;print(json.load(open('src/backend/bisheng/dev_toolkit/artifacts/manifest.json')).keys())"`。**任一项缺失就停下**——缺的那项对应的任务(T018/T019 对账、T033–T036 增量、T038)会按「文件不存在」写成新建,把对方的实现覆盖掉。
+  **依赖**: 无
+  **核对结果(基线 375a8594f)**: ① `runtime_manager/storage.py` 存在且含 `STORAGE_ENV_NAMES` ✅ ② `app_runtime/domain/constants.py:198` 有 `BISHENG_APP_STORAGE_ENDPOINT` ✅ ③ `bisheng_cli/devproxy.py` 存在、`X-BiSheng-` 出现 20 次 ✅ ④ `skills/platform-wiring/` 已在 ✅ ⑤ `DEFAULT_PACKS` 已是两元素 ✅ ⑥ manifest 键 = cli / platform / _note ✅。两个前置分支均已合入 `3.0-vibe`,无一项缺失。
+
+### Wave 1 · SDK 包工程、测试基建、errors、上下文与 auth
+
+- [x] **T001**: SDK 包工程骨架(本仓第二个可发布包工程)〔2h〕
+  **完成**: ✅ 2026-09-16(ff32fac67)`src/bisheng-sdk/{pyproject.toml,uv.lock,README.md,bisheng_sdk/__init__.py}`;`uv build --wheel` 产物含 15 个 `bisheng_sdk/*.py`(非空 wheel)。**偏差**:`__init__.py` 一次性 import 三模块(不留空模块占位),因为三个 Wave 在同一批落地。
+  **文件**: `src/bisheng-sdk/pyproject.toml`(新), `src/bisheng-sdk/bisheng_sdk/__init__.py`(新), `src/bisheng-sdk/uv.lock`(新,`uv lock` 后**提交**), `src/bisheng-sdk/README.md`(新,占位:安装 + 指南端点链接,正文随 T037)
+  **逻辑**: 照抄 `src/bisheng-cli/pyproject.toml`(D1):name=`bisheng-sdk`、version=`0.1.0`、`requires-python=">=3.11"`、`dependencies=["httpx>=0.27,<1.0"]`(**唯一依赖、带上界**)、`[project.optional-dependencies] dev=["pytest>=8.0","pytest-asyncio>=0.23","ruff>=0.9.0"]`、hatchling + **`[tool.hatch.build.targets.wheel] packages=["bisheng_sdk"]`**(坑 12)、`[tool.ruff]` 含 `RUF001/002/003` ignore、pytest marker `network`、`asyncio_mode="auto"`。`__init__.py`:`__version__ = "0.1.0"`(单一版本真相)、`__all__ = ("auth", "retrieve", "storage")`、`from . import auth, retrieve, storage`(延迟到 T006 / T015 / T021 落文件后再加 import,先用空模块占位)。**无 console script**。
+  **依赖**: 无
+
+- [x] **T002**: pytest 基建:零网络哨兵 + 环境隔离 + 上下文清理 + mock 平台响应工厂〔3h〕
+  **完成**: ✅ 2026-09-16(ff32fac67)`tests/conftest.py` + `tests/helpers/platform_mock.py`。**偏差**:MockTransport 不经生产代码的参数注入,而是 `monkeypatch` `_http.client` / `_http.aclient` 两个工厂——生产代码里因此没有任何测试专用钩子,真实的 `request` / `parse_envelope` 路径仍被完整走到。
+  **文件**: `src/bisheng-sdk/tests/conftest.py`(新), `src/bisheng-sdk/tests/helpers/platform_mock.py`(新), `src/bisheng-sdk/tests/helpers/__init__.py`(新)
+  **逻辑**: fixtures——`no_network`(autouse;替换 `httpx.Client` / `AsyncClient` 默认 transport 为抛 `AssertionError` 的哨兵;清 `HTTP_PROXY` 等六个代理变量)· `clean_env`(autouse;`monkeypatch.delenv` 全部 `BISHENG_*`,防开发机残留)· `reset_context`(autouse;每用例前后把 `_context` 的 ContextVar 复位)· `platform_env`(设 `BISHENG_PLATFORM_API_BASE=http://platform.test`)· `hosted_headers(user_id="42", name="张三", dept=True, token=FAKE_OBO)` 造十头(**中文名按 `quote(safe="/")` 编码**,照 `entry_authz_service._encode`;`dept=False` 时**省略**三头而非空串)· `dev_headers()`(`Subject-Kind=service_account`、无部门)。假令牌常量**拼接**:`FAKE_OBO = "eyJ" + "a" * 40`、`FAKE_KEY = "bs-sak-" + "x" * 43`(坑 22)。
+  `platform_mock.py`:基于 `httpx.MockTransport` 的响应工厂,**形状照 design §4.2 ③④⑤ 逐字构造、不许"顺手规整"**:`versions_ok(sdk_version, min_compatible)` / `versions_sdk_null()` / `versions_404()` · `retrieve_ok(chunks)` / `v2_error(http_status, code, message, data=None)`(`26001`→401、`26003`→403 且 `data.required` 为**单个字符串**、`26030`→503、`16273`→带 `data.capability` / `data.reason`、`16274`)· `storage_put_ok(meta)` / `storage_get_ok(bytes, content_type)` / `storage_stat_ok` / `storage_list(pages)`(多页 cursor)/ `storage_err(http_status, code)` · `v1_envelope(data)`(HTTP 200 + 信封)。工厂记录每次请求(方法 / 路径 / 头 / body)供断言。
+  **依赖**: T001
+
+- [x] **T003**: errors 与脱敏测试〔1.5h〕
+  **完成**: ✅ 2026-09-16(ff32fac67)`tests/test_errors.py`(8 用例,含四种凭据形态的掩码参数化)。
+  **文件**: `src/bisheng-sdk/tests/test_errors.py`(新)
+  **测试**: `test_every_error_has_message_and_next_step`(design D6 表每个类实例化后 `str()` 含"下一步")→ AC-19, AC-25 / `test_hierarchy_single_base`(全部继承 `BishengSdkError`;`PlatformIdentityMissingError` **不是** `VisitorCredentialMissingError` 子类,反之亦然——auth 与 retrieve 的缺失可区分)→ AC-07, AC-15, AC-19 / `test_redact_masks_sak_pat_bearer_and_jwt`(把 `FAKE_KEY` / `"bs-pat-"+"y"*43` / `"Bearer "+FAKE_OBO` / 裸 `FAKE_OBO` 塞进 message、next_step、details,`str()` 与 `repr()` 都只剩掩码)→ AC-04 / `test_platform_refused_keeps_code_message_details_verbatim`(未登记码不丢任何字段)→ AC-19 / `test_storage_errors_distinguishable`(Missing / Rejected / Unavailable / NotFound / TooLarge / InvalidPath 六类互不为子类且文案互异)→ AC-25
+  **覆盖 AC**: AC-04, AC-07, AC-15, AC-19, AC-25
+  **依赖**: T002
+
+- [x] **T004**: `errors.py` 实现〔1.5h〕
+  **完成**: ✅ 2026-09-16(ff32fac67)`bisheng_sdk/errors.py`。**偏差**:19 个子类而非 18——按 D5 改写新增 `AppCredentialMissingError`(应用运行期凭据未注入,与访问者凭据缺失可区分)。
+  **文件**: `src/bisheng-sdk/bisheng_sdk/errors.py`(新)
+  **逻辑**: `class BishengSdkError(Exception)`:`message` / `next_step` / `code: int | None` / `details: dict | None`,`__str__` = `redact(...)`;D6 表的 18 个子类,带各自结构化属性(`ScopeMissingError.required: str`、`TargetUnreachableError.ids: list`、`CapabilityRevokedError.capability / reason`、`SdkIncompatibleError.sdk_version / min_compatible / platform_version`、`AttachmentTooLargeError.limit_bytes`、`StorageHandleRejectedError.reason`)。`redact(text)`:正则替换 `bs-sak-\S+` / `bs-pat-\S+` → `bs-***`、`Bearer \S+` → `Bearer ***`、`eyJ[A-Za-z0-9_-]{10,}(\.[A-Za-z0-9_-]+){0,2}` → `***`。所有文案中文(RUF001-003 已 ignore)。
+  **测试**: T003 全部通过。
+  **覆盖 AC**: AC-04, AC-07, AC-15, AC-19, AC-25
+  **依赖**: T003
+
+- [x] **T005**: 头名常量、上下文与 auth 测试(含三处对账、并发隔离、三种接法)〔3h〕
+  **完成**: ✅ 2026-09-16(ff32fac67)`tests/test_auth.py`(22 用例)+ `tests/test_context_isolation.py`(4 用例)+ `tests/test_contract_alignment.py`(9 用例,源文件缺失即 skip)。
+  **文件**: `src/bisheng-sdk/tests/test_auth.py`(新), `src/bisheng-sdk/tests/test_context_isolation.py`(新), `src/bisheng-sdk/tests/test_contract_alignment.py`(新)
+  **测试**(`test_auth.py`): `test_current_user_from_hosted_headers`(十头 → `Identity`,`user_name == "张三"` 已解码、`dept_path` 保留 `/`、`subject_kind == "human"`)→ AC-06 / `test_missing_dept_headers_become_none_not_empty`(坑 2)→ AC-10 / `test_dev_headers_service_account_without_dept`(`subject_kind == "service_account"`、三部门 `None`、结构与线上同一 dataclass)→ AC-06, AC-10 / `test_dev_headers_personal_token_is_human_with_dept`(PAT `login` 时 `dev` 注入 `subject_kind == "human"` 且可能带部门——SDK 原样透传、不推导,design D4「与 spec 的口径差」/ 坑 33)→ AC-06 / `test_no_context_raises_identity_missing_never_none`(裸调用、空上下文、有其它头但无 `User-Id` 三种 → `PlatformIdentityMissingError`;断言**不返回** `None`)→ AC-07 / `test_identity_has_no_token_field_and_repr_has_no_token`(`Identity` 无 `access_token` 属性;`repr` 不含 `FAKE_OBO`)→ AC-04, AC-08 / `test_no_as_user_or_login_or_verify_api`(`dir(bisheng_sdk.auth)` 无 `as_user` / `login` / `verify` / `impersonate` / `sign`)→ AC-08 / `test_header_name_normalisation`(`X_BISHENG_USER_ID` / `x-bisheng-user-id` / WSGI `HTTP_X_BISHENG_USER_ID` 都能读到)→ AC-31 / `test_ids_are_strings_not_ints`(坑 20)→ AC-06 / `test_from_headers_is_pure_and_does_not_touch_context` → AC-09 / `test_asgi_middleware_sets_and_resets_per_request`(纯 ASGI 可调用;两次请求不同用户各得其身份;请求结束后上下文为空)→ AC-09 / `test_asgi_middleware_passes_headerless_request_through`(探活请求进应用、不 4xx;只有调 `current_user()` 才抛)→ AC-07 / `test_asgi_middleware_handles_websocket_scope`(握手 scope 的头进上下文)→ AC-09 / `test_wsgi_middleware_reads_http_x_bisheng_environ` → AC-31 / `test_bind_context_manager_for_streamlit_style`(`with auth.bind(headers):` 内可读,退出后抛)→ AC-09 / `test_middleware_is_not_starlette_basehttpmiddleware`(`issubclass` 为假,坑 14)→ AC-09
+  **测试**(`test_context_isolation.py`): `test_asyncio_gather_two_requests_isolated`(`asyncio.gather` 两个协程各 `bind` 不同用户、互不串扰)→ AC-09 / `test_thread_pool_has_no_identity`(`ThreadPoolExecutor` 里 `current_user()` 抛错——后台任务无访问者是刻意的)→ AC-07 / `test_created_task_inherits_copy_documented`(`asyncio.create_task` 能读到父身份——记录 asyncio 语义,坑 21)→ AC-09
+  **测试**(`test_contract_alignment.py`,仓外 `skip`): `test_header_names_match_app_proxy_verbatim`(读 `src/app-proxy/app_proxy/headers.py` 文本,提取 `INJECTED_HEADER_NAMES` 十个字面量,与 `bisheng_sdk._headers.INJECTED_HEADER_NAMES` 顺序与拼写逐字相等)→ AC-31 / `test_env_names_match_f054_contract_section_5`(读 `features/v3.0.0/054-app-domain-runtime/contracts-runtime-manager.md` §5 行,断言 `BISHENG_PLATFORM_API_BASE` 等名在 `_env.py` 中)→ AC-31
+  **覆盖 AC**: AC-04, AC-06, AC-07, AC-08, AC-09, AC-10, AC-31
+  **依赖**: T002, T004
+
+- [x] **T006**: `_headers.py` / `_env.py` / `_context.py` / `auth.py` 实现〔4h〕
+  **完成**: ✅ 2026-09-16(ff32fac67)`bisheng_sdk/{_headers,_env,_context,auth}.py`。
+  **文件**: `src/bisheng-sdk/bisheng_sdk/_headers.py`(新), `src/bisheng-sdk/bisheng_sdk/_env.py`(新), `src/bisheng-sdk/bisheng_sdk/_context.py`(新), `src/bisheng-sdk/bisheng_sdk/auth.py`(新)
+  **逻辑**: `_headers.py`:`INJECTED_HEADER_NAMES`(十个,逐字照 `app-proxy/headers.py:29-40`)、`HEADER_*` 常量、`normalize_name()`(`strip().lower().replace("_","-")`,WSGI 形态先去 `HTTP_` 前缀)、`snapshot(headers: Iterable[tuple[str,str]]) -> dict[str,str]`(只收 `x-bisheng-` 前缀)、`parse_identity(snapshot) -> Identity`(design §4.2 ①:`User-Id` 缺 → `PlatformIdentityMissingError`;`unquote` 三个文本头与 `Dept-Path`;缺部门 → `None`;`Subject-Kind` 缺省 `"human"`)。`_env.py`:变量名常量 + `platform_api_base()`(空 → `PlatformUnreachableError` 点名 `BISHENG_PLATFORM_API_BASE` / `app_runtime.entry_base_url`,坑 19)。`_context.py`:`_current: ContextVar[dict[str,str] | None]`、`bind(snapshot)` 返回 token、`reset(token)`、`access_token() -> str | None`。`auth.py`:`@dataclass(frozen=True) Identity`、`current_user()`、`from_headers(mapping)`、`bind(headers)`(contextmanager)、`ASGIMiddleware`(**纯 ASGI 类**:`__init__(app)`、`async __call__(scope, receive, send)`;`scope["type"] in ("http","websocket")` 时 `bind(snapshot(scope["headers"] 解码 latin-1))`,`try: await app finally: reset`)、`WSGIMiddleware`(同形,从 `environ` 取 `HTTP_X_BISHENG_*`)。**无 `as_user`、无验签、无环境变量读取**(D3 / D4 / 决议-8)。
+  **测试**: T005 全部通过。
+  **覆盖 AC**: AC-04, AC-06, AC-07, AC-08, AC-09, AC-10, AC-31
+  **依赖**: T004, T005
+
+- [x] **T007**: 公开面守卫测试(AC-33 的自动化)〔1h〕
+  **完成**: ✅ 2026-09-16(ff32fac67)`tests/test_public_surface.py` + `tests/test_import_smoke.py`。**偏差**:`BISHENG_APP_TOKEN` 的 grep 型断言按 D5 改写后的契约调整为「只允许出现在 `_env.py` 的代码里」,并改用 `ast` 区分代码与 docstring——否则解释性文档会把断言逼红。
+  **文件**: `src/bisheng-sdk/tests/test_public_surface.py`(新), `src/bisheng-sdk/tests/test_import_smoke.py`(新,照 `src/bisheng-cli/tests/test_import_smoke.py`:模块级 import 全部公开与私有模块、断言 `httpx` 有 `MockTransport` / `AsyncClient`)
+  **测试**: `test_public_modules_are_exactly_auth_retrieve_storage_errors`(`pkgutil.iter_modules` 非下划线集合相等)→ AC-33 / `test_dunder_all_is_three_capability_modules` → AC-01, AC-33 / `test_chat_appdb_llm_db_client_do_not_import`(五个名字 `ModuleNotFoundError`)→ AC-30, AC-33 / `test_no_convenience_factory_symbols`(全包无 `openai` / `OpenAI` / `create_engine` / `connect(` / `sqlalchemy` 字符串——grep 源码)→ AC-30 / `test_no_process_level_credential_env_read`(源码 grep 无 `BISHENG_APP_TOKEN` / `BISHENG_API_KEY`,红线 1)→ AC-12 / `test_version_declared_once`(`bisheng_sdk.__version__` 是唯一字面量,`_compat` 引用同一对象)→ AC-03
+  **覆盖 AC**: AC-01, AC-03, AC-12, AC-30, AC-33
+  **依赖**: T006(T015 / T021 落文件后集合才完整;本任务先按占位模块通过,两处落地后**不得改集合**)
+
+- [x] **T008**: SDK 质量门 CI〔1h〕
+  **完成**: ✅ 2026-09-16(ff32fac67)`.github/workflows/sdk-quality.yml` 三 leg;wheel leg 在 `scripts/pack_sdk_wheel.sh`(分发切片 T022)落地前自带 `uv build` + 空 wheel 校验 + 装包冒烟的等价实现。
+  **文件**: `.github/workflows/sdk-quality.yml`(新)
+  **逻辑**: 照 `cli-quality.yml` 三 leg:`locked`(`uv sync --frozen --extra dev` → `ruff check` + `ruff format --check` → `pytest -m "not network"`)· `highest`(`uv sync --resolution highest` 再跑测试,抓上界内的新版本破坏)· `wheel`(`bash scripts/pack_sdk_wheel.sh` + drift guard:`bisheng_sdk.__version__` == `manifest.json["sdk"]["version"]`、wheel 不被 `.gitignore` 匹配、manifest `git diff --quiet`)。paths:`src/bisheng-sdk/**`、`scripts/pack_sdk_wheel.sh`、本文件。`wheel` leg 依赖 T022 脚本存在,先以 `if: hashFiles('scripts/pack_sdk_wheel.sh') != ''` 守住。
+  **依赖**: T001
+  **证据**: 与 T028 合并交付于 `.github/workflows/sdk-quality.yml`(10607e923);包未落地时三个 leg 各自打 notice 跳过而不是红。
+
+- [x] **T009**: Wave 1 收口:`__init__.py` 挂 auth、ruff 全绿、`uv lock --check`〔0.5h〕
+  **完成**: ✅ 2026-09-16(ff32fac67)`ruff check` / `ruff format --check` 全绿(31 文件);`uv lock` 已生成并提交。
+  **文件**: `src/bisheng-sdk/bisheng_sdk/__init__.py`(增量)
+  **逻辑**: `from . import auth`;`uv run ruff check . && uv run ruff format --check . && uv lock --check && uv run pytest`。
+  **依赖**: T006, T007
+
+### Wave 2 · HTTP 层、版本兼容与 retrieve
+
+- [x] **T010**: HTTP 层测试(客户端池 / 超时 / trust_env / 信封解析顺序 / 码映射两级降级)〔2h〕
+  **完成**: ✅ 2026-09-16(ff32fac67)`tests/test_http.py`(19 用例)。
+  **偏差记录(2026-09-17)**:`test_trust_env_false_by_default_and_env_override` 拆成三例(本地默认不读 / `BISHENG_SDK_TRUST_ENV=1` 打开 / 注入 `BISHENG_APP_EGRESS_TOKEN` 即打开),D11 同步修订。起因:F054 T077 把托管应用网改成 `--internal` 后,平台 API 只能经出站代理到达,原「一律不读代理变量」会让托管期 `retrieve` 与版本探测直连得到「网络不可达」(114 实测)。同批:`test_contract_alignment.py` 加出站凭据变量名对账;`test_compat.py` 的本地版本断言改读 `__version__`(原写死 `0.1.0`,一升版本就红);SDK 版本 `0.1.0 → 0.1.1` 并重打安装件,`SDK_MIN_COMPATIBLE` 保持 `0.1.0`(0.1.0 在 internal 网里连版本探测都发不出去,抬下限无用)。
+  **文件**: `src/bisheng-sdk/tests/test_http.py`(新)
+  **测试**: `test_envelope_status_code_read_before_http_status`(HTTP 200 + `status_code=10500` 信封 → 错误;HTTP 401 + 信封 `26001` → `VisitorCredentialRejectedError`,坑 7)→ AC-19 / `test_code_table_maps_registered_codes`(`26001`/`26002`/`26027`→Rejected · `26003`→`ScopeMissingError(required="knowledge:read")` · `26030`→PermissionEvaluation · `16273`→CapabilityRevoked(capability, reason) · `16274`→CapabilityNotDeclared)→ AC-14, AC-16, AC-17, AC-19 / `test_unregistered_code_falls_back_by_http_class_then_refused`(401 无码→Rejected;5xx 无码→Unreachable;403 未知码→`PlatformRefusedError` 且 `code` / `message` / `details` 原样)→ AC-16, AC-19 / `test_connect_error_and_timeout_are_unreachable_with_no_retry`(MockTransport 抛 `httpx.ConnectError`;断言只请求一次)→ AC-16, AC-19 / `test_trust_env_false_by_default_and_env_override`(设 `ALL_PROXY` 不影响;`BISHENG_SDK_TRUST_ENV=1` 时 `Client(trust_env=True)`)→ AC-19 / `test_timeouts_by_kind`(retrieve 读 30 s、storage 120 s、connect 5 s 常量)→ AC-19 / `test_client_pooled_per_base_url`(同 base 同实例,不同 base 不同实例)→ AC-18 / `test_bearer_header_never_logged_or_in_exception`(异常 `details` 里不含令牌)→ AC-04
+  **覆盖 AC**: AC-04, AC-14, AC-16, AC-17, AC-18, AC-19
+  **依赖**: T002, T004
+
+- [x] **T011**: `_http.py` + `_codes.py` 实现〔3h〕
+  **完成**: ✅ 2026-09-16(ff32fac67)`bisheng_sdk/{_http,_codes}.py`。**偏差**:`TARGET_UNREACHABLE_CODES` 不再留空——F052 已分配 `26321`(`data.unreachable_ids`),同批登记 `26320` / `26322`;`26323`(范围过大)按「未登记码原样呈现」走 `PlatformRefusedError`。
+  **文件**: `src/bisheng-sdk/bisheng_sdk/_http.py`(新), `src/bisheng-sdk/bisheng_sdk/_codes.py`(新)
+  **逻辑**: `_http.py`:`client(base_url) -> httpx.Client` / `aclient(base_url) -> httpx.AsyncClient`(`dict` 池、`threading.Lock`;`trust_env = os.environ.get("BISHENG_SDK_TRUST_ENV") == "1"`;`Timeout(connect=5, read=…, write=…, pool=5)`)、`request(kind, method, path, *, bearer, json=None, content=None, stream=False)`、`parse_envelope(resp) -> Any`(顺序:JSON body 有 `status_code` 且 ≠ 200 → `_codes.map_error(code, message, http_status, data)`;非 2xx 无信封 → 按 HTTP 类兜底;2xx → `data`)、**零重试**(D11)。`_codes.py`:`CODE_TO_ERROR: dict[int, type]`(`26001/26002/26027` · `26003` · `26030` · `16273` · `16274`)、`TARGET_UNREACHABLE_CODES: frozenset[int] = frozenset()`(**F052 分配后填**,坑 25 同型;命中时构造 `TargetUnreachableError(ids=data.get("unreachable_ids", []))`)、`map_error()` 两级降级(D6 映射顺序)。
+  **测试**: T010 全部通过。
+  **覆盖 AC**: AC-04, AC-14, AC-16, AC-17, AC-18, AC-19
+  **依赖**: T010
+
+- [x] **T012**: 版本兼容测试〔1h〕
+  **完成**: ✅ 2026-09-16(ff32fac67)`tests/test_compat.py`(11 用例)。
+  **文件**: `src/bisheng-sdk/tests/test_compat.py`(新)
+  **测试**: `test_compatible_when_min_le_local`(`min_compatible="0.1.0"`、本地 `0.1.0` / `0.2.0` 通过)→ AC-03 / `test_incompatible_raises_with_both_versions_and_remedy`(`min_compatible="0.3.0"` → `SdkIncompatibleError`,`str()` 含 `0.3.0`、本地版本、平台版本与「从当前平台重新获取 SDK」)→ AC-03 / `test_sdk_block_null_or_404_is_platform_too_old`(两种 → `PlatformTooOldError`,文案含「开放能力层未部署」可能)→ AC-03, AC-05 / `test_probe_once_per_process_per_base_and_failure_not_cached`(成功后第二次不再请求;`ConnectError` 后再调会重试)→ AC-03 / `test_auth_never_probes`(`current_user()` 全程零请求——`no_network` 哨兵已保证,显式断言)→ AC-03 / `test_version_tuple_algorithm_matches_cli`(`"0.10.0" > "0.9.9"`,不做字符串比较)→ AC-03
+  **覆盖 AC**: AC-03, AC-05
+  **依赖**: T002, T011
+
+- [x] **T013**: `_compat.py` 实现〔1h〕
+  **完成**: ✅ 2026-09-16(ff32fac67)`bisheng_sdk/_compat.py`。
+  **文件**: `src/bisheng-sdk/bisheng_sdk/_compat.py`(新)
+  **逻辑**: `ensure_compatible(base_url)` / `aensure_compatible(base_url)`:进程内 `dict[base_url, True]` 缓存成功;`GET /api/v1/dev-toolkit/versions` → 404 或 `data.sdk` 为 null / 无 `version` → `PlatformTooOldError`;`_version_tuple(min_compatible) > _version_tuple(__version__)` → `SdkIncompatibleError(sdk_version=__version__, min_compatible, platform_version=data.platform.version)`;连接失败 → `PlatformUnreachableError`(不缓存)。`_version_tuple` 照 `bisheng_cli/http.py:_version_tuple`(三段 int,非数字段按 0)。`__version__` 从包根 import(T007 单一真相断言)。
+  **测试**: T012 全部通过。
+  **覆盖 AC**: AC-03, AC-05
+  **依赖**: T012
+
+- [x] **T014**: retrieve 测试(同步 / 异步参数化)〔2.5h〕
+  **完成**: ✅ 2026-09-16(ff32fac67)`tests/test_retrieve.py`(24 用例)。**偏差**:按 briefs-wave4 的跨 Feature 裁定与 F055 已合入实现,断言的是**两把凭据**而不是「只送访问者凭据」;`test_app_token_alone_is_never_enough` / `test_visitor_credential_alone_is_refused_locally` 两条替代了原先的 grep 型「代码里不得出现 BISHENG_APP_TOKEN」断言。`16273` / `26321` / `26322` 不再标 `[受阻]`——服务端已有写入方,只是 114 联调仍待 T042。
+  **文件**: `src/bisheng-sdk/tests/test_retrieve.py`(新)
+  **测试**: `test_search_posts_to_filelib_retrieve_with_context_token_as_bearer`(上下文 `bind(hosted_headers())` → 记录的请求:`POST http://platform.test/api/v2/filelib/retrieve`、`Authorization: Bearer `、body 键 == `RetrieveReq` 字段)→ AC-11, AC-12 / `test_body_is_field_for_field_and_none_ids_omitted`(`knowledge_base_ids=None` → body 无该键;`filters` 形状照 `RetrieveFilters`;无自造字段)→ AC-11, AC-18 / `test_result_mirrors_retrieve_resp`(`RetrieveResult.chunks[i]` 六字段 == 响应;`total` 原样;顺序不变)→ AC-11, AC-18 / `test_no_context_or_no_token_raises_missing_and_sends_nothing`(无上下文 / 有十头但无 `Access-Token` → `VisitorCredentialMissingError`,零请求)→ AC-15 / `test_process_env_app_token_is_never_used`(设 `BISHENG_APP_TOKEN=FAKE_KEY`、无上下文 → 仍 Missing、零请求;红线 1)→ AC-12, AC-15 / `test_no_as_user_parameter`(`inspect.signature(search)` 无 `as_user` / `user_id` / `on_behalf_of`)→ AC-08, AC-12 / `test_401_maps_rejected_with_next_step_mentioning_facade`(坑 5 文案)→ AC-15, AC-19 / `test_26003_maps_scope_missing_with_required_verbatim` → AC-14, AC-19 / `test_16273_maps_capability_revoked_with_name_and_reason`(`[受阻于 F055 T058]`,仅 mock)→ AC-17, AC-19 / `test_26030_maps_permission_evaluation_and_returns_no_chunks`(异常而非空列表)→ AC-16 / `test_target_unreachable_placeholder`(`monkeypatch` `_codes.TARGET_UNREACHABLE_CODES={99999}` → `TargetUnreachableError.ids == data.unreachable_ids`;`[受阻于 F052]`)→ AC-17, AC-19 / `test_incompat_surfaces_on_first_search`(`versions_sdk_null()` → `PlatformTooOldError`,未发检索请求)→ AC-03 / `test_two_users_same_query_get_their_own_results_no_cache`(两次 bind 不同用户、mock 返回不同结果、各得其果、两次请求)→ AC-18 / `test_async_twin_same_behaviour`(以上核心用例对 `asearch` 参数化)→ AC-11 / `test_422_is_platform_refused_with_details`(坑 6;`[受阻于 F052]` 放宽后改为成功用例)→ AC-19
+  **覆盖 AC**: AC-03, AC-08, AC-11, AC-12, AC-14, AC-15, AC-16, AC-17, AC-18, AC-19
+  **依赖**: T002, T006, T011, T013
+
+- [x] **T015**: `retrieve.py` 实现〔2.5h〕
+  **完成**: ✅ 2026-09-16(ff32fac67)`bisheng_sdk/retrieve.py`。**偏差**:见 design D5 改写(两把凭据)。`tag_match_mode` 缺省值按服务端 Literal 取大写 `"ANY"`(design §4.2 ③ 初稿写的小写会 422)。
+  **文件**: `src/bisheng-sdk/bisheng_sdk/retrieve.py`(新), `src/bisheng-sdk/bisheng_sdk/__init__.py`(增量:`from . import retrieve`)
+  **逻辑**: `@dataclass(frozen=True) Chunk(content, knowledge_id: int, document_id: int, document_name: str, chunk_index: int, document_update_time: str)`、`RetrieveResult(chunks: list[Chunk], total: int)`、`KnowledgeBaseFilter(knowledge_base_id: int, tags: list[str], tag_match_mode: str)`。`search(query: str, *, knowledge_base_ids: Sequence[int] | None = None, top_k: int = 10, max_content: int = 15000, filters: Sequence[KnowledgeBaseFilter] | None = None) -> RetrieveResult` 与 `asearch(...)`:① `token = _context.access_token()`,None → `VisitorCredentialMissingError`(`next_step` 含「经平台入口访问;线上另确认 `app_runtime.obo_secret` 已配置」);② `base = _env.platform_api_base()`;③ `_compat.ensure_compatible(base)`;④ body 字段一一对应(`None` 键省略);⑤ `_http.request("retrieve", "POST", "/api/v2/filelib/retrieve", bearer=token, json=body)` → `parse_envelope` → 构造结果。**不排序、不去重、不缓存、不读任何环境变量密钥**。`RETRIEVE_PATH` 为模块常量(D5「何时重新考虑」)。
+  **测试**: T014 全部通过;T007 集合仍为四个公开模块。
+  **覆盖 AC**: AC-03, AC-08, AC-11, AC-12, AC-14, AC-15, AC-16, AC-17, AC-18, AC-19
+  **依赖**: T014
+
+### Wave 3 · storage
+
+- [x] **T016**: 路径规则与本地目录后端测试〔2h〕
+  **完成**: ✅ 2026-09-16(ff32fac67)`tests/test_paths.py`(26 用例)+ `tests/test_storage_local.py`(16 用例)。
+  **文件**: `src/bisheng-sdk/tests/test_paths.py`(新), `src/bisheng-sdk/tests/test_storage_local.py`(新)
+  **测试**(paths,规则 = manager `runtime_manager/storage.py:validate_key:277-306` 的逐条镜像): `test_traversal_matrix_rejected`(`../a` · `a/../b` · `/abs` · `a//b` · `a/./b` · `a\\b` · `""` · `"."` · 尾随 `/` · 含 `\x00` · 含 `\n` · UTF-8 1025 字节 → `InvalidAttachmentPathError`,**不规范化后放行**)→ AC-21 / `test_apps_prefix_rejected_even_for_own_app`(`apps/anything` → 拒;manager 保留命名空间,坑 29)→ AC-21 / `test_valid_unicode_paths_accepted`(`报告/2026 年度.pdf`)→ AC-20 / `test_prefix_rule_allows_empty_and_trailing_slash`(`validate_prefix` 镜像:`""` / `报告/` 通过、`apps/` 拒)→ AC-20
+  **测试**(local): `test_backend_selected_by_dir_env`(`BISHENG_APP_STORAGE_DIR=tmp` → 本地后端;同时设 `BISHENG_APP_STORAGE_ENDPOINT` → `StorageHandleMissingError`「句柄不唯一」;都不设 → Missing——`bisheng dev` 今天两个都不注入,坑 34)→ AC-23, AC-25 / `test_put_get_stat_list_delete_roundtrip`(bytes / file object / PathLike 三种 `data`;`stat.size` / `content_type`(`mimetypes` 猜)/ `modified_at` 为 `datetime | None`;`list("报告/")` 只返前缀内、按 path 升序;`delete` 后 `get` → `AttachmentNotFoundError`)→ AC-20, AC-23 / `test_put_is_atomic_tmp_then_replace`(写入中途异常不留半文件;目录内无 `.tmp` 残留)→ AC-23 / `test_real_disk_location_not_in_meta_or_errors`(`AttachmentMeta.path` 是应用内路径;`AttachmentNotFoundError` 文案不含 tmp 绝对路径)→ AC-21, AC-23 / `test_max_file_mb_env_enforced_locally_else_unlimited`(设 `BISHENG_APP_STORAGE_MAX_FILE_MB=1` → 1 MB + 1 字节 `AttachmentTooLargeError(limit_bytes=1048576)`;**单位是 MB、与线上同名同单位**;不设 → 通过,坑 23)→ AC-22 / `test_no_clear_or_prefix_delete_api`(`dir(storage)` 无 `clear` / `delete_prefix` / `delete_many` / `url` / `presign` / `share`)→ AC-22, AC-24 / `test_dir_env_missing_dir_created_lazily_under_project`(首次 put 建目录)→ AC-23
+  **覆盖 AC**: AC-20, AC-21, AC-22, AC-23, AC-24, AC-25
+  **依赖**: T002, T004
+
+- [x] **T017**: `_paths.py` + `_storage_local.py` 实现〔2.5h〕
+  **完成**: ✅ 2026-09-16(ff32fac67)`bisheng_sdk/{_paths,_storage_local}.py`(+ `_attachment.py`:两个后端共用的 `AttachmentMeta`,由 `storage` 再导出,避免 `storage` ↔ 后端的循环 import)。
+  **文件**: `src/bisheng-sdk/bisheng_sdk/_paths.py`(新), `src/bisheng-sdk/bisheng_sdk/_storage_local.py`(新)
+  **逻辑**: `_paths.validate(path) -> str` / `_paths.validate_prefix(prefix) -> str`(D9 规则 = manager `validate_key` / `validate_prefix` 的逐条镜像;返回原串,不规范化)。`_LocalDirBackend(root: Path)`:`put`(`tempfile.NamedTemporaryFile(dir=root, delete=False)` 流式写 → `os.replace`;写前若已知长度且超 `BISHENG_APP_STORAGE_MAX_FILE_MB × 1024²` 直接拒;未知长度边写边计数超限即删 tmp 并抛)· `get` / `open` / `stat`(`os.stat` + `mimetypes.guess_type`)· `list(prefix, limit)`(`os.walk` 相对化、posix 分隔、排序)· `delete`。`root / path` 解析后 `resolve()` 必须仍在 `root` 内(防御性二次守卫)。所有异常文案只含应用内路径。
+  **测试**: T016 全部通过。
+  **覆盖 AC**: AC-20, AC-21, AC-22, AC-23, AC-24, AC-25
+  **依赖**: T016
+
+- [x] **T018**: 远端 HTTP 后端测试 + 附件 API 对账(design §4.2 ④ 契约的可执行快照)〔2.5h〕
+  **完成**: ✅ 2026-09-16(ff32fac67)`tests/test_storage_remote.py`(20 用例)+ `tests/test_contract_alignment.py` 的路由 / 路径规则 / 环境变量名三条对账。
+  **文件**: `src/bisheng-sdk/tests/test_storage_remote.py`(新), `src/bisheng-sdk/tests/test_contract_alignment.py`(增量,T005 已建)
+  **测试**: `test_backend_selected_by_endpoint_and_token_env`(`BISHENG_APP_STORAGE_ENDPOINT` + `_TOKEN` 齐 → 远端;只有 ENDPOINT 无 TOKEN → `StorageHandleMissingError`「句柄不完整」)→ AC-20, AC-25 / `test_put_streams_body_with_bearer_and_content_length`(`PUT {E}/objects/%E6%8A%A5%E5%91%8A/a.pdf`(**逐段 `quote(seg, safe="")`**)、`Authorization: Bearer `、body = **原始字节流非 multipart**、`Content-Length` 存在、传 file object 时不整读进内存)→ AC-20, AC-21 / `test_get_stat_list_delete_wire_shapes`(`GET {E}/objects/{key}` 下载 · **`GET {E}/meta/{key}` 元信息(不是 `/stat/`)** · `GET {E}/objects?prefix=&cursor=&limit=` 列举、跟 `next_cursor` 翻两页合并 · `DELETE {E}/objects/{key}` → **`200 {}` 当成功、不是 204**,坑 29)→ AC-20 / `test_413_payload_too_large_maps_with_max_file_mb`(manager 信封 `{"detail":{"code":"payload_too_large","message":…,"max_file_mb":20}}` → `AttachmentTooLargeError(limit_bytes=20*1024**2)`)→ AC-22 / `test_401_unauthorized_maps_rejected_with_manager_message`(**只有 401、没有 403、没有「已下线」专用码**:令牌不属本应用 / 应用已 destroy / 无 Bearer 全是 `unauthorized`,`reason` = `detail.message`,坑 28)→ AC-25 / `test_404_not_found_maps_attachment_not_found`(含 `delete` 缺失路径也是 404、不是静默成功)→ AC-25 / `test_503_storage_unavailable_and_connect_error_map_unavailable_not_empty_list`(`list` 在 503 时抛而不是返 `[]`;`next_step` 点名 `runtime/status` preflight `attachment_storage`,坑 27)→ AC-25 / `test_400_invalid_object_key_maps_invalid_path`(服务端二次校验的呈现)→ AC-21 / `test_meta_never_carries_bucket_key_endpoint_or_token`(`AttachmentMeta` 字段集合恰 `path/size/content_type/modified_at`(+ `etag` 保留);`key`→`path` 已剥应用前缀;异常 `details` 不含 token)→ AC-21 / `test_app_id_never_sent_by_client`(请求体 / 查询串 / 头里无 `app_id`——它只在注入的 ENDPOINT 里)→ AC-21 / `test_async_twins_same_wire_shape`(参数化)→ AC-20
+  **测试**(对账,仓外或文件缺失时 `skip` 并打印原因,坑 30): `test_storage_routes_match_manager_verbatim`(读 `src/runtime-manager/runtime_manager/api/storage.py` 文本,断言四条 `@router` 路径与 `prefix="/v1/apps/{app_id}/storage"` 与 `_storage_remote.py` 的常量一致)→ AC-20 / `test_path_rules_match_manager_validate_key`(读 `runtime_manager/storage.py` 的 `validate_key`,逐条规则与 `_paths.py` 对齐)→ AC-21 / `test_env_names_match_manager_storage_env_names`(`STORAGE_ENV_NAMES` == `_env.py` 三名)→ AC-31
+  **覆盖 AC**: AC-20, AC-21, AC-22, AC-25, AC-31
+  **依赖**: T002, T005, T011, T017
+
+- [x] **T019**: `_storage_remote.py` 实现〔2.5h〕
+  **完成**: ✅ 2026-09-16(ff32fac67;审查修复见下)`bisheng_sdk/_storage_remote.py`。**偏差**:`open()` 在远端后端是「整读后包成 BytesIO」,不是流式——httpx 的流式响应要求把请求上下文交给调用方管理,而 `storage.open()` 的契约是返回一个普通 file-like;大文件的真流式留到 F054 加 presign 时再议。
+  **审查修复(2026-09-16)**: 附件端点的**传输层失败原先漏翻**——`_http.request` 抛的 `PlatformUnreachableError` 直接逃到应用,于是「连不上 runtime-manager」(坑 27:manager 只听 `127.0.0.1` / 未配 `RTM_APP_FACING_BASE_URL`,这是托管期 storage 最常见的故障)被呈现成「连不上平台」,`next_step` 把人支去查 `BISHENG_PLATFORM_API_BASE`,而真正要看的是 `runtime/status` 的 `attachment_storage` 自检项。design D6 的 `StorageUnavailableError` 行本就写明「连接失败(坑 27)」属于它,代码没跟上;AC-25 的「彼此可区分」也因此破了。修法:`RemoteBackend._send` / `AsyncRemoteBackend._asend` 统一捕获并翻成 `StorageUnavailableError`(原 message 保留)。原测试 `pytest.raises((StorageUnavailableError, PlatformUnreachableError))` 两边都收,正是它把这个缺陷放过去的——已收紧成只认 `StorageUnavailableError` 并断言 `next_step` 点名 `attachment_storage`(`test_connect_failure_is_a_storage_outage_not_a_platform_outage` + 异步孪生一条)。
+  **文件**: `src/bisheng-sdk/bisheng_sdk/_storage_remote.py`(新)
+  **逻辑**: `_RemoteBackend(endpoint, token)`(`endpoint` 已含 `/v1/apps/{app_id}/storage`,SDK 不拼 `app_id`):`_http.request("storage", …, bearer=token)`;`put` 用 `content=` 传 file object 或 bytes(httpx 流式,原始字节非 multipart)、`Content-Type` 显式或 `mimetypes` 猜、缺长度时先 `seek/tell` 取长度;`get` 整读、`open` 返回 `resp.iter_bytes()` 包装的 file-like;`stat` 打 `/meta/{key}`;`list`(循环 `next_cursor`,`limit` 到达即停);`delete` 把 `200 {}` 当成功。错误映射按 **manager 信封 `{"detail":{"code","message",…extra}}` 的机器码**(坑 26):`invalid_object_key` → `InvalidAttachmentPathError`、`unauthorized`(恒 401)→ `StorageHandleRejectedError(reason=detail["message"])`、`not_found` → `AttachmentNotFoundError`、`payload_too_large` → `AttachmentTooLargeError(limit_bytes=detail["max_file_mb"]*1024**2)`、`storage_unavailable` / 连接失败 / 无码 5xx → `StorageUnavailableError`、其它 → `PlatformRefusedError(code=None, details=detail)`。**路径段 `quote(seg, safe="")` 后拼接**;**永不走 manager 的 HMAC 路径**(那是 backend / F052 用的)。
+  **测试**: T018 全部通过。
+  **覆盖 AC**: AC-20, AC-21, AC-22, AC-25
+  **依赖**: T018
+
+- [x] **T020**: storage 门面测试(后端选择 + 两后端跑同一套行为用例 + 异步孪生)〔1.5h〕
+  **完成**: ✅ 2026-09-16(ff32fac67;审查修复见下)`tests/test_storage_facade.py`(local / remote 参数化跑同一段脚本)。
+  **审查修复(2026-09-16)**: `test_six_functions_and_six_async_twins_exist` 的名字说六个异步孪生,断言里只列了五个——**`aopen` 从来没实现过**,而这条测试的写法恰好让缺失看不出来。已补 `storage.aopen` / `AsyncRemoteBackend.aopen`(远端 = `aget` 后包 `BytesIO`,与同步 `open` 同形;本地 = `asyncio.to_thread`),断言改成「六个同步函数逐个推导出 `a` 并要求是协程函数」,缺任何一个都会点名。D10「六函数各有异步孪生」至此才真的成立——少了它,用 FastAPI 的应用读大附件只能在事件循环里阻塞。
+  **文件**: `src/bisheng-sdk/tests/test_storage_facade.py`(新)
+  **测试**: `test_same_api_over_both_backends`(参数化 `local` / `remote(mock)`:`put → stat → list → get → delete → get 抛 NotFound` 同一脚本两边结果形状相等)→ AC-20, AC-23 / `test_six_functions_and_six_async_twins_exist`(`put/get/open/stat/list/delete` 与 `aput/…`)→ AC-20 / `test_handle_resolution_is_per_call_not_cached`(改环境变量后下一次调用换后端——`dev` 重启场景)→ AC-25 / `test_path_validated_before_any_io`(非法路径零请求、零磁盘写)→ AC-21 / `test_no_url_returning_function`(`dir(storage)` 无以 `url` / `link` / `presign` 结尾的名字)→ AC-22
+  **覆盖 AC**: AC-20, AC-21, AC-22, AC-23, AC-25
+  **依赖**: T017, T019
+
+- [x] **T021**: `storage.py` 实现 + `__init__.py` 挂载〔1.5h〕
+  **完成**: ✅ 2026-09-16(ff32fac67)`bisheng_sdk/storage.py`;`tests/test_public_surface.py` 的公开面集合最终 == `{auth, retrieve, storage, errors}`。
+  **文件**: `src/bisheng-sdk/bisheng_sdk/storage.py`(新), `src/bisheng-sdk/bisheng_sdk/__init__.py`(增量:`from . import storage`)
+  **逻辑**: `@dataclass(frozen=True) AttachmentMeta(path, size: int, content_type: str | None, modified_at: datetime | None, etag: str = "")`(`modified_at` 由 manager 的 `last_modified` ISO 串解析,MinIO 无值时 `""` → `None`);`_backend()`(**每次调用重算、不缓存**,`dev` 重启即换):有 `BISHENG_APP_STORAGE_ENDPOINT` → 远端(缺 `_TOKEN` → `StorageHandleMissingError`「句柄不完整」)· 无 ENDPOINT 有 `BISHENG_APP_STORAGE_DIR` → 本地 · **两者都有 → `StorageHandleMissingError`「句柄不唯一」** · 都没有 → `StorageHandleMissingError`(D8);六函数 + 六 `a*` 孪生(本地后端的异步版用 `asyncio.to_thread`)。每个函数首行 `_paths.validate`。
+  **测试**: T020 全部通过;T007 公开面集合最终 == `{auth, retrieve, storage, errors}`。
+  **覆盖 AC**: AC-20, AC-21, AC-22, AC-23, AC-24, AC-25
+  **依赖**: T020
+
+### Wave 4 · 平台分发(打包、manifest、端点、CI、提交 wheel)
+
+- [x] **T022**: `scripts/pack_sdk_wheel.sh` + `sdk_compat.py`〔2h〕
+  **文件**: `scripts/pack_sdk_wheel.sh`(新), `src/backend/bisheng/dev_toolkit/sdk_compat.py`(新)
+  **逻辑**: `sdk_compat.py`:`SDK_MIN_COMPATIBLE = "0.1.0"`(平台声明的最低兼容 SDK 版本,D16;docstring 说明升它 = 宣告老 SDK 应用下次调用报错)。脚本照 `pack_cli_wheel.sh` 五步:① `uv build --wheel`(`src/bisheng-sdk/`)② 校验 wheel 含 `bisheng_sdk/auth.py`(`WHEEL_LISTING="$(unzip -l …)"` + `case`,**不用 `| grep -q`**,坑 12)且文件名含版本 ③ 清 venv 装 wheel 冒烟:`python -c "import bisheng_sdk, bisheng_sdk.auth, bisheng_sdk.retrieve, bisheng_sdk.storage, bisheng_sdk.errors; assert bisheng_sdk.__version__=='${VERSION}'"` ④ `rm -f "${ARTIFACTS_DIR}"/bisheng_sdk-*.whl`(**只删自己前缀**)→ 拷贝 → sha256 → **python 合并写 manifest**:读旧 JSON(缺则 `{}`),设 `["sdk"] = {version, min_compatible: SDK_MIN_COMPATIBLE(sed 自 sdk_compat.py), filename, sha256}`,`["platform"]` 与 `["cli"]` / `_note` 保留 ⑤ `git check-ignore` 自检。末尾提示「提交 wheel + manifest,否则平台照旧答 sdk=null」。
+  **依赖**: T001
+  **证据**: `scripts/pack_sdk_wheel.sh` + `bisheng/dev_toolkit/sdk_compat.py`(1358ad8f3)。脚本已实跑验证(用姊妹切片 `wt/f057-sdk-core` 的包做 dry run):构建 → `unzip -l` 校验三模块 → 空 venv 装包冒烟 → 合并写 manifest,`cli` / `platform` 两段字节不变。
+
+- [x] **T023**: `pack_cli_wheel.sh` 改为合并写 manifest、只删自己前缀 wheel(跨 Feature 改动)〔1h〕
+  **文件**: `scripts/pack_cli_wheel.sh`(增量)
+  **逻辑**: `:88` `rm -f "${ARTIFACTS_DIR}"/*.whl` → `rm -f "${ARTIFACTS_DIR}"/bisheng_cli-*.whl`;`:96-110` heredoc → `python3 - <<'PY'` 读旧 manifest、只替换 `cli` 与 `platform` 段、保留 `sdk` 段、写回(`indent=2` + 尾换行,与现有格式字节一致以免 `cli-quality.yml` drift guard 误报)。头注释追加两行说明。**跑一次脚本确认 `git diff` 只有预期变化**。
+  **依赖**: T022
+  **证据**: `scripts/pack_cli_wheel.sh` 改合并写 + 只删 `bisheng_cli-*.whl`(1358ad8f3);重跑脚本后差异只有 `_note` 一行(78f231bce),wheel sha256 未变。
+
+- [x] **T024**: `artifact_service` 的 SDK 段测试〔1h〕
+  **文件**: `src/backend/test/dev_toolkit/conftest.py`(增量:`staged_artifacts` 追加 `sdk` 段 + 假 wheel `bisheng_sdk-0.1.0-py3-none-any.whl`;新 fixture `staged_cli_only`——manifest 无 `sdk` 段), `src/backend/test/dev_toolkit/test_artifact_service_sdk.py`(新)
+  **测试**: `test_snapshot_reads_sdk_artifact`(`snapshot.sdk.version/min_compatible/filename/sha256/path`)→ AC-01 / `test_sdk_none_when_section_absent_cli_still_present`(老 manifest 不影响 CLI)→ AC-01 / `test_sdk_none_when_wheel_file_missing_logs_warning`(照 CLI 的部分 rsync 分支)→ AC-01 / `test_min_compatible_defaults_to_version` → AC-03 / `test_read_sdk_guide_returns_platform_wiring_skill_md_or_none` → AC-26
+  **覆盖 AC**: AC-01, AC-03, AC-26
+  **依赖**: 无(后端侧独立)
+  **证据**: `test/dev_toolkit/test_artifact_service_sdk.py`(6 用例)+ conftest 的 `staged_artifacts` 扩 sdk 段与新 fixture `staged_cli_only`(1358ad8f3)。
+
+- [x] **T025**: `artifact_service.py` 增量实现〔1h〕
+  **文件**: `src/backend/bisheng/dev_toolkit/domain/services/artifact_service.py`(增量)
+  **逻辑**: `@dataclass(frozen=True) SdkArtifact(version, min_compatible, filename, sha256, path)`;`DistributionSnapshot` 加 `sdk: SdkArtifact | None`;`read_snapshot` 用同一套「manifest 有段 + 文件在盘」判定,缺一则 `None`(不影响 `cli`);`read_sdk_guide() -> str | None` = `SKILLS_DIR / "platform-wiring" / "SKILL.md"`(缺 → None,照 `read_install_guide`)。不读 DB(C3 无关)。
+  **测试**: T024 全部通过;既有 `test_distribution_api.py` 除 `sdk` null 断言外不变。
+  **覆盖 AC**: AC-01, AC-03, AC-26
+  **依赖**: T024
+  **证据**: `artifact_service.py` 加 `SdkArtifact` / `DistributionSnapshot.sdk` / `read_sdk_guide()`,两个 wheel 共用 `_staged_wheel()` 判定(1358ad8f3)。
+
+- [x] **T026**: 分发端点集成测试(四端点 + `versions` 的 `sdk` 段 + 改 F053 的 null 断言)〔2h〕
+  **文件**: `src/backend/test/dev_toolkit/test_distribution_api.py`(增量:`:80-81` 与 `:228` 改为「有 staged sdk 时六键齐全;无时整段 null + `notice`」), `src/backend/test/dev_toolkit/test_sdk_distribution_api.py`(新)
+  **测试**: `test_versions_sdk_section_when_staged`(`version/min_compatible/filename/sha256/download_path/index_path` 六键,值照 manifest)→ AC-01, AC-03 / `test_versions_sdk_null_and_notice_when_not_staged` → AC-01 / `test_sdk_download_streams_wheel_with_content_disposition_anonymously`(无 Cookie 无 Bearer)→ AC-01 / `test_sdk_download_with_filename_segment_only_accepts_manifest_filename`(正确名 200;其它 404)→ AC-02 / `test_sdk_download_missing_is_real_404_with_envelope`(不是 200 信封、不是 500)→ AC-01 / `test_simple_index_root_lists_bisheng_sdk`(`text/html`;含 `href="bisheng-sdk/"`)→ AC-02 / `test_simple_index_project_page_links_download_with_sha256_fragment`(`href="../../sdk/download/bisheng_sdk-0.1.0-py3-none-any.whl#sha256="`;PEP 503 规范化名)→ AC-02 / `test_simple_index_404_when_not_staged` → AC-02 / `test_sdk_guide_md_served_as_markdown_anonymously_and_404_when_missing` → AC-26 / `test_all_sdk_routes_absent_when_open_platform_disabled`(四个路径 404,同 `test_routes_absent_when_open_platform_disabled`)→ AC-05 / `test_multi_tenant_no_jwt_does_not_raise_on_sdk_routes`(`/api/v1/dev-toolkit` 前缀已在 `TENANT_CHECK_EXEMPT_PATHS`,`http_middleware.py:61`)→ AC-01
+  **覆盖 AC**: AC-01, AC-02, AC-03, AC-05, AC-26
+  **依赖**: T025
+  **证据**: `test/dev_toolkit/test_sdk_distribution_api.py`(12 用例);同批把 F053 的 `sdk` 三键 null 断言改成「有 staged 时六键齐全 / 无时整段 null」(1358ad8f3)。
+
+- [x] **T027**: `distribution.py` 增量实现(四端点 + `versions` 填值)〔2h〕
+  **文件**: `src/backend/bisheng/dev_toolkit/api/endpoints/distribution.py`(增量)
+  **逻辑**: 常量 `SDK_DOWNLOAD_PATH = "/api/v1/dev-toolkit/sdk/download"`、`SDK_INDEX_PATH = "/api/v1/dev-toolkit/simple/"`、`SDK_MISSING_MESSAGE = "SDK 安装件未随本次部署发布,请联系平台管理员"`、`SDK_GUIDE_MISSING_MESSAGE`;`get_dev_toolkit_versions` 的 `sdk` 由 `snapshot.sdk` 填(六键)或整段 `None`;`notice` 逻辑:cli 或 sdk 任一缺失即给文案(拼接)。`@router.get("/sdk/download")` 与 `@router.get("/sdk/download/{filename}")` 共用 `_serve_sdk_wheel(filename: str | None)`(D15);`@router.get("/simple/")` 与 `@router.get("/simple/bisheng-sdk/")` 返回 `HTMLResponse`(最小 PEP 503:`…`);`@router.get("/sdk-guide.md")` 照 `get_install_guide`。**无 `Depends`**(匿名,D10 同源)。
+  **测试**: T026 全部通过;`test/dev_toolkit` 全量通过;`ruff` + `arch-guard.sh` 零输出。
+  **覆盖 AC**: AC-01, AC-02, AC-03, AC-05, AC-26
+  **依赖**: T026
+  **证据**: `distribution.py` 四端点 + `versions` 的 `sdk` 段 + `_versions_notice()`(1358ad8f3)。`test/dev_toolkit` 全量 114 passed;ruff 与 arch-guard 零输出。
+
+- [x] **T028**: `sdk-quality.yml` wheel leg 接入真实脚本 + drift guard〔0.5h〕
+  **文件**: `.github/workflows/sdk-quality.yml`(增量)
+  **逻辑**: 去掉 T008 的 `hashFiles` 守卫;drift guard 读 `manifest.json["sdk"]["version"]`;「Artifacts are committed」步骤 `git diff --quiet -- manifest.json`。
+  **依赖**: T008, T022
+  **证据**: `.github/workflows/sdk-quality.yml`(10607e923)三 leg 一次写全(含 T008 的 locked / highest),wheel leg 直接调真脚本 + `sdk.version` drift guard + manifest 未提交守卫。**偏差**:T008 原归姊妹切片,但 CI 文件与打包脚本同属分发面,合在本切片一次写完。
+
+- [x] **T029**: 打包并提交 SDK wheel(Wave 4 末尾,串行)〔0.5h〕
+  **文件**: `src/backend/bisheng/dev_toolkit/artifacts/bisheng_sdk-0.1.0-py3-none-any.whl`(新,二进制), `src/backend/bisheng/dev_toolkit/artifacts/manifest.json`(增量 `sdk` 段)
+  **逻辑**: `bash scripts/pack_sdk_wheel.sh` → 确认 `cli` 段字节不变 → 提交。⚠️ 与 T038(重打 CLI wheel)**不得并行**。
+  **依赖**: T021, T022, T023, T027
+  **完成证据**: 两条切片合流后由协调批执行,commit **`c5e33ea8b`**(`git log -1 -- dev_toolkit/artifacts/` 即此条)。**2026-09-16 由 `wt/f057-tail` 逐项复核(未重打)**:① wheel 在位、18 个条目、`bisheng_sdk/` 15 个模块 + `dist-info`(不是坑 12 那种空壳 wheel);② `shasum -a 256` = `380f1d6122821a8d293a689ae878535c3e087c0520fe910833de255de3ae3800`,与 `manifest.json["sdk"].sha256` **逐字相等**;③ `manifest.json` 四个键 `cli` / `platform` / `_note` / `sdk` 齐全,`cli` 段 sha256 `635054e2…` 与同目录 CLI wheel 实测一致(T038 重打后未被 T029 覆写);④ `artifact_service.read_snapshot()` 真实读出 `SdkArtifact(version='0.1.0', min_compatible='0.1.0', …)`,即 `/versions` 现在答的是**实体 `sdk` 段**而非 null;⑤ wheel 版本 `0.1.0` 与 `bisheng_sdk/__init__.py:27 __version__` 一致;⑥ `test/dev_toolkit` 全量 **118 passed**。
+
+### Wave 5 · 托管构建期取包
+
+- [x] **T030**: runtime-manager extra index 测试〔1h〕
+  **文件**: `src/runtime-manager/tests/test_build.py`(增量)
+  **测试**: `test_build_args_inject_extra_index_url_and_trusted_host`(照 `test_build_args_inject_index_url:126-133`:`buildargs["PIP_EXTRA_INDEX_URL"] == config.build_extra_index_url`、`PIP_EXTRA_TRUSTED_HOST`)→ AC-02 / `test_dockerfile_renders_extra_index_args_and_pip_flags`(渲染文本含 `ARG PIP_EXTRA_INDEX_URL=""`、`${PIP_EXTRA_INDEX_URL:+--extra-index-url "$PIP_EXTRA_INDEX_URL"}`、`${PIP_EXTRA_TRUSTED_HOST:+--trusted-host "$PIP_EXTRA_TRUSTED_HOST"}`;`--index-url` 行保留)→ AC-02 / `test_config_reads_rtm_build_extra_env`(`RTM_BUILD_EXTRA_INDEX_URL` / `RTM_BUILD_EXTRA_TRUSTED_HOST`,缺省空串)→ AC-02 / `test_template_render_still_deterministic`(既有用例不变)→ AC-02
+  **覆盖 AC**: AC-02
+  **依赖**: 无
+  **证据**: `src/runtime-manager/tests/test_build.py` 追加 3 用例(buildargs / Dockerfile 渲染 / `RTM_BUILD_EXTRA_*` 读取),`tests/test_build.py` 19 passed 1 skipped(1358ad8f3)。
+
+- [x] **T031**: runtime-manager `config.py` / `builder.py` / `Dockerfile.j2` 增量〔1.5h〕
+  **文件**: `src/runtime-manager/runtime_manager/config.py`(增量:`build_extra_index_url` / `build_extra_trusted_host` 字段 + `RTM_BUILD_EXTRA_*` 读取,紧邻 `:159-160` / `:231-232`), `src/runtime-manager/runtime_manager/builder.py`(增量:`:366-369` buildargs 加两键), `src/runtime-manager/runtime_manager/templates/python3.11/Dockerfile.j2`(增量:`:21-22` 后加两个 `ARG`;`:39-42` pip 行加两个 shell 参数展开;头注释第 8 行补「SDK 自平台简单索引」), `docs/architecture/14-app-factory-deployment.md`(增量:runtime-manager 环境变量表在 `RTM_BUILD_INDEX_URL` 行(`:151`)之后追加 `RTM_BUILD_EXTRA_INDEX_URL` / `RTM_BUILD_EXTRA_TRUSTED_HOST` 两行,备注「值必须是**构建容器可达**的宿主机地址,不能写 `localhost`」,坑 15。`features/v3.0.0/054-app-domain-runtime/deploy/` 只有两个 systemd unit 文件、无 env 模板,不改)
+  **逻辑**: 只做追加;`--extra-index-url` 与 `--index-url` 并存(第三方依赖走主索引,`bisheng-sdk` 由 pip 在两个索引里都找、按 hash 取平台那份)。README / 部署文档注明值必须是**构建容器可达**的宿主机地址,非 `localhost`(坑 15)。
+  **测试**: T030 全部通过;`cd src/runtime-manager && uv run ruff check . && uv run pytest -q`。
+  **覆盖 AC**: AC-02
+  **依赖**: T030
+  **证据**: `config.py` 两字段 + `builder.py` 两 buildarg + `Dockerfile.j2` 两 ARG 与两处 pip 参数展开 + `docs/architecture/14-app-factory-deployment.md` 环境变量表两行与一条排障行(1358ad8f3)。
+
+- [ ] **T032**: 114 构建期真机验证〔1.5h,`needs_114`〕
+  **文件**: 本文(回填结果)
+  **逻辑**: `/etc/bisheng/runtime-manager.env` 加 `RTM_BUILD_EXTRA_INDEX_URL=http://192.168.106.114:7860/api/v1/dev-toolkit/simple/` 与 `RTM_BUILD_EXTRA_TRUSTED_HOST=192.168.106.114` → `systemctl restart bisheng-runtime-manager` → 用 T034 样例(`requirements.txt` 含 `bisheng-sdk`)`bisheng deploy` → `journalctl -u bisheng-runtime-manager` 看 pip 从 `/simple/bisheng-sdk/` 取到 wheel、hash 校验通过。⚠️ 需 T029 已部署(`deploy.sh` 重启 8 unit)。记录 `docker build` 的出站路径是否被 F054 出站白名单放行(F054 AC-16 尚未实现,本轮容器出站不受限——记录事实)。
+  **覆盖 AC**: AC-02
+  **依赖**: T029, T031
+  **未做(需 114)**: 本切片无 114 访问,且它依赖 T029 已部署。
+
+### Wave 6 · 技能包、指南、自检、评测样本、CLI 包清单
+
+- [x] **T033**: 「平台能力接线」`SKILL.md` **增量**:填 SDK 桩 + 插 retrieve / storage 两章〔3h〕
+  **文件**: `src/backend/bisheng/dev_toolkit/skills/platform-wiring/SKILL.md`(**增量**,F053 T038 已交付 226 行五章版本;先 `git log -1 -- <该文件>` 确认在手的是对方那版,T000 已核对), `src/backend/test/dev_toolkit/test_skill_packs.py`(**增量,改 F053 的一条断言**——见下 ④)
+  **逻辑**: **不动的部分**(F053 的 `test_skill_packs.py` 直接断言,坑 31):frontmatter(`name: platform-wiring`、中性 description、`metadata.display-name`)、第 1 章「访问者身份」仍是**目录第一条与正文第一章**且章首 `> ⚠️` 警示块含「静默」「登录页」原样、模型章仍含「暂未提供」「不要猜」且**全章零 URL**、应用数据库章仍含 `BISHENG_APP_DB_PATH` / `BISHENG_APP_DB_URL` / `--confirm-schema-change` / `只**记录**`、`bisheng dev` 章与自检清单章内容不动(编号可顺延)。⚠️ 另有一条**全文级**约束:`test_auth_chapter_teaches_exactly_app_proxys_header_names` 断言「SKILL.md 里所有反引号包住的 `X-BiSheng-*` 名字**集合恰等于** app-proxy 的十个」——新写的 retrieve / storage 两章**不得出现第十一个 `X-BiSheng-*` 名**(坑 36)。**本任务改四处**:
+  ① 第 1 章末尾的「### SDK 用法(随后续版本补齐)」桩 → 换成实体内容:`user = bisheng_sdk.auth.current_user()` 一行 vs 三种错误做法(自建登录页 / 自读头自己拼 / 自解析凭据)对照;三种接法(ASGI 纯中间件 / WSGI / `auth.bind()` 供 Streamlit);无注入即抛 `PlatformIdentityMissingError`(健康端点不调 auth、后台任务不假设有访问者);`user_id` 是 `str` 且**不是**平台 `user` 表的行;`subject_kind` 在 `dev` 期**取决于 login 用的密钥**(服务账号密钥 → `service_account`、个人访问令牌 → `human`,坑 33)、线上恒 `human`;「本地看不到 per-user 差异,验证路径 = 发布后用真实账号访问」。**保留**原有「读头也是合法写法」的表格(spec AC-32 明认直读注入头合法)。
+  ② 新增「## 2. 知识库检索(retrieve)」:一行用法;per-user 语义(以当前访问者检索、结果 = 应用声明白名单 ∩ 访问用户可见范围、fail-closed);本地 / 线上四处差异(本地无白名单、范围 = 服务账号被显式授予范围、只会「本地看得少」不会「本地能跑线上越权」、凭据来源不同但对应用不可见);错误类速查表(design D6);示例**必带** `knowledge_base_ids`(坑 6);**如实写明**「本轮平台尚未受理应用侧访问凭据,retrieve 会答『凭据被拒』——这不是你的代码写错了」(坑 5 / 坑 32)。
+  ③ 新增「## 3. 附件存储(storage)」:六函数;本地目录 vs 平台存储、不进上传包与 git、同一 API、按应用隔离、无直链、不暴露底层实现(bucket / 键 / 端点 / 凭据)、**不计租户存储配额**;本地上限只在注入 `BISHENG_APP_STORAGE_MAX_FILE_MB` 时生效(坑 23、坑 34)。
+  ④ 第 1 章身份头表里 `X-BiSheng-Access-Token` 那一行的说明由「本轮没有消费方,**不要依赖**它做任何判断;SDK 用法随后续版本补齐」改成「每请求的短时访问凭据句柄,`retrieve` 用它;应用不要自己解析或转存」——**同批修改 F053 的断言** `test_skill_packs.py::test_auth_chapter_teaches_exactly_app_proxys_header_names` 末尾那句 `assert "不要依赖" in token_row`(改为断言新措辞含「不要自己解析」),理由与 T026 改 `sdk` null 断言同型:契约变了,守卫跟着变,同一 PR 里改完。
+  两章插在第 1 章之后、应用数据库章之前,**同批更新目录编号与锚点**(原 2/3/4/5 章顺延为 4/5/6/7,锚点 `#2-应用数据库` 等一并改;改完 `grep -n "^#\|](#" SKILL.md` 双向对表)。另在末尾「## 参考」前加一节「SDK 装哪来 / 准入门槛」:`pip install --extra-index-url <平台>/api/v1/dev-toolkit/simple/ bisheng-sdk`、托管构建期只需 `requirements.txt` 写 `bisheng-sdk`、以及 chat / appdb 为什么不在 SDK 里(一段)。全文零真实密钥。
+  **覆盖 AC**: AC-26, AC-27, AC-28, AC-30, AC-32, AC-35, AC-36
+  **依赖**: T000, T021(API 定稿)
+  **证据**: `skills/platform-wiring/SKILL.md`(10607e923):填 SDK 桩、插第 2/3 章、原 2–5 章顺延为 4–7、目录与锚点同批更新、末尾加第 8 章;`X-BiSheng-Access-Token` 行改措辞并同批改 F053 的 `test_skill_packs.py` 守卫断言。全文反引号里的 `X-BiSheng-*` 仍恰为 app-proxy 的十个。
+  **偏差(评审期修正)**: 本任务 ② 要求的「如实写明」照初稿写成了「**平台尚未受理**应用侧访问凭据」,这一句现在**是错的**——F055 的托管检索(`filelib.py` 的 `HOSTED_APP_ACTOR_KIND` 分支 → `CapabilityBusService.retrieve`)与访问者凭据核验(`composition.py` 注册 `AccessSubjectVerifier`)都已合入 `3.0-vibe`,线上这条路是通的(前提是部署配了 `app_runtime.obo_secret`)。照原文写会把开发者支去查一个没坏的平台。改为:线上通、**本地 `bisheng dev` 不通**,并点名本地的两处上游缺口(不注入 `BISHENG_APP_TOKEN`、本地自签句柄平台无从验签)。同批:错误表补 `AppCredentialMissingError` 行(本地实际先撞的就是它)、检索章补「两把凭据各答什么、不可互换、没有 owner 兜底」一段(settled 契约在正文里落地,不只在 design 里)。T035 的断言同批改:由 `assert "尚未上线" in chapter` 改为断言新措辞 + `assert "尚未上线" not in chapter`。
+
+- [x] **T034**: SDK 版可运行样例(FastAPI,三件套齐用)——**新目录 `example-sdk/`,不动既有 `example/`**〔2h〕
+  **文件**: `src/backend/bisheng/dev_toolkit/skills/platform-wiring/example-sdk/main.py`(新), `.../example-sdk/bisheng-app.yaml`(新), `.../example-sdk/requirements.txt`(新:`fastapi`、`uvicorn`、`bisheng-sdk`)
+  **逻辑**: **为什么另起目录**:F053 已交付的 `example/` 是零依赖标准库样例,`test_skill_packs.py::test_example_is_stdlib_only` 对两个包都断言 `requirements.txt` 为空/无第三方;把 SDK 塞进它会当场红,且「不装 SDK 也能接线」是 spec AC-32 承认的合法路径,不该被删。`SKILL.md`(T033)在 SDK 各章指向 `example-sdk/`,「参考」节两个样例都列。
+  内容:`app.add_middleware(bisheng_sdk.auth.ASGIMiddleware)`;`GET /`(页面:`你好,{user.user_name}` + 部门 + 「本地开发 · 服务账号」角标当 `subject_kind == "service_account"`);`GET /healthz`(**不调 auth**);`GET /__whoami`(回显本请求的十头,供 T036 自检解析);`POST /ask`(`retrieve.search(q, knowledge_base_ids=[...])`,库 id 从 `bisheng-app.yaml` 的 `capabilities.knowledge_bases` 同源读一份常量);`POST /upload` / `GET /files` / `GET /files/{path}`(storage 三操作,下载由应用自己吐流——AC-22);每个端点把 D6 异常翻成 4xx JSON `{error, next_step}`(**不 500、不 traceback**)。四条托管契约(PORT / 0.0.0.0 / `/data` / `BISHENG_APP_BASE_PATH`)照 `deploy-hosting/example/main.py`。manifest 声明一个知识库能力(`capabilities` 非空)——⚠️ F053 的 `test_skill_packs.py::test_example_manifest_is_valid_against_the_platform_schema` 断言 `manifest.capabilities.is_empty()`,但它只读 `example/bisheng-app.yaml`,**不覆盖 `example-sdk/`**;`test_example_is_stdlib_only` 同理只读 `example/requirements.txt`。这正是另起目录的原因,两条断言一字不改。`example-sdk/` 的 schema 合法性由 T035 单列断言。
+  **覆盖 AC**: AC-26, AC-30, AC-32, AC-34
+  **依赖**: T033
+  **证据**: `skills/platform-wiring/example-sdk/{main.py,bisheng-app.yaml,requirements.txt}`(10607e923);既有 `example/` 一字未动。清单声明一个知识库能力,与 `main.py` 的 `KNOWLEDGE_BASE_IDS` 由 T035 的断言守着同源。
+  **偏差(评审期修正)**: `/ask` 原来把 `AppCredentialMissingError` 兜进最后那条 `BishengSdkError → 502`,读起来是「平台挂了」,实际是本应用没拿到运行期凭据(本地必然如此)。单列一条 → 503,T035 加一条断言守它排在兜底分支之前。
+  **评审期实跑**(不入库,仅记录):把姊妹切片 `wt/f057-sdk-core` 的包放到 `PYTHONPATH` 后用 `TestClient` 跑本样例:`/healthz` 无身份 200;`/` 无身份 401「未取得平台注入的访问者身份」、有身份 200 且显示姓名;`/ask` 有身份无应用凭据 503、完全无身份 401「检索不会以任何其它身份发起」。四种开发者常犯的错各得一条可区分的明确拒绝,无一条静默给出错误答案。
+
+- [x] **T035**: 技能包增量 + SDK 样例 + 自检脚本测试〔1.5h〕
+  **文件**: `src/backend/test/dev_toolkit/test_platform_wiring_sdk.py`(新;**不改** F053 的 `test_skill_packs.py`——那里的共通断言与 `WIRING` 专项断言继续守 F053 的部分,本文件只加 SDK 增量的断言)
+  **测试**: `test_auth_chapter_still_first_with_warning_block_after_edit`(回归守 F053 契约:第一个 `## ` 标题含「访问者身份」;其后首个非空行以 `> ⚠️` 开头)→ AC-27 / `test_auth_chapter_teaches_sdk_one_liner_and_three_wrong_ways`(含 `auth.current_user()` 与「自建登录页」「自己解析」「自己校验」三类错误做法;**桩句「随后续版本补齐」已消失**)→ AC-27 / `test_retrieve_chapter_states_per_user_and_local_diff`(含「白名单」「可见范围」「fail-closed」「本地看得少」「真实账号」)→ AC-35 / `test_storage_chapter_states_local_online_diff_and_quota`(含「不计」「配额」「不进」「上传包」「直链」)→ AC-36 / `test_model_chapter_still_marked_unavailable_and_invents_no_base_url`(回归:仍含「暂未提供」,不含拼 base URL 的示例)→ AC-30 / `test_toc_anchors_resolve`(目录每条 `](#…)` 都能在正文找到对应标题,防章节顺延漏改锚点)→ AC-26 / `test_no_sdk_wrapper_for_model_or_appdb`(全文不含 `bisheng_sdk.chat` / `bisheng_sdk.appdb`)→ AC-30 / `test_example_sdk_manifest_valid_against_schema`(`AppManifest(**yaml)` 不抛;`capabilities` 非空且只声明知识库)→ AC-26 / `test_example_sdk_requirements_pin_bisheng_sdk` → AC-02 / `test_example_sdk_healthz_does_not_call_auth`(`main.py` 源码里 `/healthz` 处理函数体内无 `current_user`)→ AC-27 / `test_selfcheck_readable_failure_without_env`(`subprocess` 空 HOME、无 `BISHENG_*` → 非零退出、输出含「未」或「下一步」、无 `Traceback`)→ AC-28 / `test_selfcheck_reports_incompatible_sdk_readably`(假 `versions` 响应经 `BISHENG_PLATFORM_API_BASE` 指向本地 `http.server`,`min_compatible="9.9.9"` → 输出含双方版本)→ AC-03, AC-28 / `test_sdk_guide_endpoint_serves_this_file`(`read_sdk_guide()` 内容 == `platform-wiring/SKILL.md`)→ AC-26
+  **覆盖 AC**: AC-02, AC-03, AC-26, AC-27, AC-28, AC-30, AC-35, AC-36
+  **依赖**: T033, T034
+  **证据**: `test/dev_toolkit/test_platform_wiring_sdk.py`(17 用例,含两条回归:auth 章仍第一 + 章首警示块、模型章仍「暂未提供」且无 URL)(10607e923)。
+
+- [x] **T035a**(F051 落地后追加): 模型章的两条回归断言要改口径〔0.3h〕
+  **起因**: T034 / T035 里的 `test_model_chapter_still_marked_unavailable_and_invents_no_base_url` 假定模型面尚未交付。F051 已交付并定名三个环境变量,该断言从此守错了东西。
+  **改成**: ① 模型章不再含「暂未提供」,而是教官方 `openai` 客户端 + **`OPENAI_BASE_URL` / `OPENAI_API_KEY` / `BISHENG_MODEL_BASE_URL`** 三个注入名;② **仍然断言包里没有手拼的 base URL 字面量**(`/api/v2/model/v1` 只能来自 `whoami.model_base_url` 或注入的环境变量,F051 AC-30 的唯一出口口径不变);③ `test_no_sdk_wrapper_for_model_or_appdb` 一字不改——SDK 依旧不封装 chat(DEV-07)。
+  **另附**:指南需写明 `X-BiSheng-Access-Token` 由应用代码**显式转发**给模型面,否则调用记录的 subject 记为「应用自身」(F051 spec 决议-5 允许,但审计里就没有用户维度了)。
+  **覆盖 AC**: AC-26, AC-30
+  **依赖**: T035
+  **2026-09-16 完成证据**(分支 `wt/f053-skillpack-model`,与 F053 T038a 同批): 两条断言各自改名并改口径——① `test/dev_toolkit/test_skill_packs.py::test_model_chapter_is_marked_not_yet_available_and_invents_no_endpoint` → `test_model_chapter_teaches_the_shipped_face_and_hardcodes_no_address`:断言模型章**不含**「暂未提供」、含三个注入名、教官方 `openai` 客户端(`chat.completions.create`)、点名 `BISHENG_PLATFORM_API_BASE` 这个拼地址陷阱,**保留**原「章内无任何 `https?://`」断言;② `test/dev_toolkit/test_platform_wiring_sdk.py::test_model_chapter_still_says_not_yet_available` → `test_model_chapter_teaches_the_injected_names_and_no_literal_address`:断言三个注入名 + 清单声明与 26515 + `X-BiSheng-Access-Token` 转发与 26504,同样保留无 URL 断言。③ `test_no_sdk_wrapper_is_taught_for_models_or_the_app_database`(DEV-07)一字未改,仍绿。两个文件的模块 docstring 同步订正。`cd src/backend && pytest test/dev_toolkit -q` → **119 passed**。
+  **偏差**: 口径②「不得出现手拼 base URL 字面量」原只落在模型章,本批**加强为包级**——新增 `test_skill_packs.py::test_no_pack_file_hardcodes_the_model_face_path`,对 `platform-wiring/` 下每个文件断言不含 `/api/v2/model/v1`;另附的 `X-BiSheng-Access-Token` 转发说明落在 F053 T038a 的模型章里(第 1 章请求头表同批补了指向)。
+
+- [x] **T036**: `selfcheck.py` **增量**:在 F053 已交付的脚本上追加 SDK 三步〔2h〕
+  **文件**: `src/backend/bisheng/dev_toolkit/skills/platform-wiring/selfcheck.py`(**增量**,109 行版本已含:读 `~/.bisheng/credentials.json` → 打 `/api/v2/auth/whoami` → 校验应用库变量,`fail(reason, next_step)` 打两行并 `SystemExit(1)`)
+  **逻辑**: 沿用既有 `fail()` 与输出风格、**不重写骨架**(F053 的 `test_selfcheck_reports_readable_reason_when_not_logged_in` 对两包参数化,改掉未登录分支的文案会红)。追加(D13):① `import bisheng_sdk`(失败 → 打印 `pip install --extra-index-url /api/v1/dev-toolkit/simple/ bisheng-sdk`,`` 取 `BISHENG_PLATFORM_API_BASE` 或凭据文件的 `current`;**SDK 未装不算致命**——脚本继续跑既有三步后以「SDK 未安装」退出 1) ② `urllib` 打 `/api/v1/dev-toolkit/versions` 比对 `sdk.min_compatible`(脚本自己算三段元组,不 import SDK 内部函数;`sdk` 为 null → 提示「平台未发布 SDK 安装件 / 开放能力层未部署」) ③ auth:`BISHENG_APP_ID` 存在则对 `http://127.0.0.1:${BISHENG_APP_PORT}/__whoami` 发一次请求(经 `bisheng dev` 迷你代理进来的回显)再 `auth.from_headers` 解析;否则 `fail("未经 bisheng dev 启动", "在项目根执行 bisheng dev,用它打印的本地入口地址访问")` ④ retrieve:`bind` 上 ③ 的头后 `search("selfcheck", top_k=1)`,逐类异常翻一句;**`VisitorCredentialRejectedError` 时明确写「平台尚未受理应用侧访问凭据(本地为 `bsdev.` 句柄、线上为 OBO),不是你的密钥问题」**(坑 5 / 坑 32) ⑤ storage:`put/stat/delete` `_selfcheck/probe.txt`;无句柄时提示「`bisheng dev` 尚未注入 `BISHENG_APP_STORAGE_DIR`」(坑 34)。全程不打 traceback、不打密钥。
+  **测试**: T035 全部通过;`cd src/backend && uv run pytest test/dev_toolkit -q` 全绿(含 F053 既有用例)。
+  **覆盖 AC**: AC-03, AC-28
+  **依赖**: T035
+  **证据**: `selfcheck.py` 追加五步(装没装 / 版本兼容 / 身份 / 检索 / 附件),沿用既有 `fail()` 与输出风格(10607e923);T035 用真 `http.server` + 桩 `bisheng_sdk` 跑通「版本不兼容」与「SDK 未装」两条可读失败路径,输出无 Traceback。
+  **偏差(评审期修正)**: 本任务 ③ 写的探测地址 `http://127.0.0.1:${BISHENG_APP_PORT}/__whoami` **不成立**——`bisheng dev` 起两个监听,迷你代理(本地入口,注入身份头)听 `--port` / 清单 `port`,而 `PORT` / `BISHENG_APP_PORT` 注入给应用的是**应用自己**那个(`commands/dev.py` 的 `proxy_port` / `app_port`,后者是 `pick_free_port`)。照原文实现这一步**永远过不了**,且把原因报成「你直连了应用端口」。改为:入口地址按 `argv[1]` → `BISHENG_DEV_ENTRY_URL` → 最近 `bisheng-app.yaml` 的 `port` 解析(`dev_entry_url()`);解析不到或连不上 → **跳过**身份 / 检索 / 附件三步(与 `check_app_db` 在普通 shell 里的处理一致),不再判失败;④ 增 `AppCredentialMissingError` 分支(`bisheng dev` 不注入 `BISHENG_APP_TOKEN`,本地实际先撞的是它,不是「凭据被拒」),`VisitorCredentialRejectedError` 文案改为「本地自签平台无从验签 / 线上过期或签给别的应用」。SKILL.md §7 同批改运行方式。
+
+- [x] **T037**: README、指针与目录树〔1h〕
+  **文件**: `src/bisheng-sdk/README.md`(增量:安装两种方式、三行用法、异常速查、「完整指南见 `GET /api/v1/dev-toolkit/sdk-guide.md` / `skills sync` 后的 `platform-wiring/SKILL.md`」——**不复制章节**,决议-7), `src/backend/bisheng/dev_toolkit/skills/deploy-hosting/SKILL.md`(增量:`:138` 「由另一份技能覆盖,本轮不展开」改为指向 `platform-wiring`), `src/backend/bisheng/dev_toolkit/skills/README.md`(增量:**`wt/cli-dev` 已把 `platform-wiring/` 加进目录树**——本任务只在其条目下补一行 `example-sdk/` 与「含 SDK 三件套章」),`src/backend/bisheng/dev_toolkit/guides/install-guide.md`(增量:一句「装 SDK 见 sdk-guide.md」,若该文件有安装段)
+  **覆盖 AC**: AC-26, AC-32
+  **依赖**: T033
+  **证据**: `skills/deploy-hosting/SKILL.md` 的 capabilities 条改为指向 `platform-wiring`、`skills/README.md` 目录树补 `example-sdk/` 与自检说明、`guides/install-guide.md` 第 4 步后加 SDK 安装命令与 `sdk-guide.md` 指针(10607e923)。**偏差**:`src/bisheng-sdk/README.md` 由姊妹切片的 T001 创建,本切片不碰它以免同文件冲突——合并后需按本任务原意补一句「完整指南见 `GET /api/v1/dev-toolkit/sdk-guide.md`」。
+
+- [x] **T038**: 核对 CLI `DEFAULT_PACKS` + 用合并版脚本重打 CLI wheel(跨 Feature,串行)〔1h〕
+  **文件**: `src/bisheng-cli/bisheng_cli/commands/skills.py`(**仅当仍是单元素时**改 `:57`), `src/bisheng-cli/tests/test_command_skills.py`(同前提;`wt/cli-dev` 已补「其一 404 时另一仍成功」用例), `src/backend/bisheng/dev_toolkit/artifacts/bisheng_cli-3.0.0-py3-none-any.whl` + `manifest.json`(重打)
+  **逻辑**: ① `grep -n "DEFAULT_PACKS" src/bisheng-cli/bisheng_cli/commands/skills.py`——`wt/cli-dev` 合并后应已是 `("deploy-hosting", "platform-wiring")`,**是则常量与测试零改动**;仍是单元素(合并丢了)才补,并同批补测试。② 无论改没改都要重打一次 wheel:T023 把打包脚本改成了合并写 manifest,旧 wheel 对应的 manifest 段需要用新脚本重生成——`cd src/bisheng-cli && uv run pytest -m "not network"` 全绿 → `bash scripts/pack_cli_wheel.sh` → 确认 `manifest.json["sdk"]` 段**原样保留**、`cli` 段只有 sha256 可能变 → 提交。⚠️ 与 T029 串行(同一 `artifacts/` 目录,坑 35)。
+  **覆盖 AC**: AC-28, AC-32
+  **依赖**: T000, T023, T029, T033
+  **证据**: `DEFAULT_PACKS` 已是 `("deploy-hosting", "platform-wiring")`,常量与测试零改动;用合并版脚本重打 CLI wheel 一次,manifest 仅 `_note` 变、wheel sha256 不变(78f231bce)。
+
+- [x] **T039**: auth 静默失败点评测样本 + 结构断言〔1.5h〕
+  **文件**: `src/backend/test/dev_toolkit/fixtures/auth_silent_failure_samples.md`(新), `src/backend/test/dev_toolkit/test_auth_silent_failure_samples.py`(新)
+  **逻辑**: 样本 ≥ 5 条、不含产品名、朴素答案会是"做个登录页"的应用需求(如「做一个内部请假申请页,要知道是谁提交的」「做个部门看板,只让本部门的人看」「做个文件上传工具,按人隔离」「做个问答机器人,答案只能来自用户有权限的知识库」「给同事做个小工具,需要显示当前登录人的名字和部门」),每条附「未读包的典型产出」与「读包后应有产出」判据(不自建登录 / 不自读头 / 用 `auth.current_user()`)。测试:`test_at_least_five_samples`、`test_samples_are_product_name_neutral`、`test_each_sample_carries_identity_intent`(含「谁」「登录」「当前用户」「身份」「部门」「权限」之一)、`test_skill_auth_chapter_lists_forbidden_patterns`(照 `test_skill_trigger.py` 风格)。
+  **覆盖 AC**: AC-29
+  **依赖**: T033
+  **证据**: `test/dev_toolkit/fixtures/auth_silent_failure_samples.md`(6 条样本,各带「未读包的典型产出 / 读包后应有产出」两半判据)+ `test_auth_silent_failure_samples.py`(5 条结构断言)(10607e923)。
+
+- [ ] **T040**: 评测跑分(模型判定)并回填〔1.5h〕
+  **文件**: 本文(回填)
+  **逻辑**: 用 `skill-creator` 评测:对 T039 每条样本各跑「未读包 vs 读包(含 `platform-wiring/SKILL.md`)」两次生成,人工 / 模型判定读包后 6/6(T039 全部样本,2026-10-09 拍板)不自建登录、改读 `auth.current_user()`;把样本 id、判定与日期回填到本文「实际偏差记录」上方的评测记录小节。未达 100% → 改 SKILL.md auth 章后重跑,不改判据。
+  **覆盖 AC**: AC-29
+  **依赖**: T039
+  **未做(需跑模型评测)**: 样本与判据已就位(T039),评测跑分需要「未读包 vs 读包」两轮生成,不在本切片的离线能力内。
+  **2026-09-16 `wt/f057-tail` 复核:仍未做,且不该由本会话代跑。** 说明白为什么,免得下一轮又停在这里:对照组的全部价值在于**生成方没读过这个包**,而本会话为了做 T041 / T043 已经读过样本文件(它把「读包后应有产出」逐条写在正文里)、读过 `example-sdk/main.py` 的三件套写法。同一个上下文里再生成一次「未读包」的答案,测的是本会话能不能装作不知道,不是包有没有用;自评自判还叠一层。**真要跑,需要的是这些**:
+  ① 一个**每条样本、每个臂各起一个干净会话**的跑批器(子代理 / 独立进程各一次,禁止复用上下文);② 对照臂的提示词 = 样本的「需求」一行 + 「写出这个应用的代码」,**不给** `platform-wiring/SKILL.md`、不给任何平台名字;实验臂 = 同一句需求 + 把 `src/backend/bisheng/dev_toolkit/skills/platform-wiring/SKILL.md` 原文放进上下文;③ 判定用 `test/dev_toolkit/fixtures/auth_silent_failure_samples.md` 里那三条判据(无登录 / 注册 / 会话签发代码 · 访问者来自平台注入 · 取不到身份时报错而非匿名兜底),**判据不许改**,判定者是另一个未参与生成的会话或人;④ 回填「样本 id / 两臂产出摘要 / 判定 / 日期 / 所用模型」到本文「评测记录」小节。
+  **顺带记一处口径冲突,跑之前先拍板**:本任务与样本文件的达标线都写「**5/5**」,而 T039 实际交付了 **6 条**样本(S-01…S-06)。要么把达标线改成 6/6,要么把它写成「随机抽 5 条」——现状下 5/6 算不算过,两边文本给不出答案。
+
+### Wave 7 · 端到端旅程、114 手验、回写
+
+- [x] **T041**: 端到端旅程测试文件(默认跳过,阻塞项落地后启用)〔3h〕
+  **文件**: `src/backend/test/e2e/test_e2e_f057_sdk_journey.py`(新,照 `test_e2e_f053_openapi_auth_identity.py` 的 `F057_E2E=1` 门 + `E2E_API_BASE`)
+  **逻辑**: 前置:平台已部署 T029 / T031、F053 `dev`(`wt/cli-dev` 已合)、F052/F055 OBO 受理(阻塞项 ②)、F054 附件 API(`wt/storage-handle` 已合并部署)。步骤:① `pip install --extra-index-url … bisheng-sdk` 于临时 venv;② `bisheng deploy` T034 的 `example-sdk/`(含知识库能力声明)→ 审批通过(`approve_online_114.py` 同型辅助);③ 用两个**非 admin** 用户会话经 `/apps/{slug}/` 访问:`GET /` 各得自己的姓名 / 部门;`GET /healthz` 无头 200;④ `POST /ask` 的结果集合 == 同用户直接 `POST /api/v2/filelib/retrieve`(PAT / 会话派生)限定声明库的结果(**集合相等**);用户无权的库不出现;未声明的库经应用 → `TargetUnreachableError` 或 `CapabilityNotDeclaredError` 的 4xx;⑤ 应用 A 上传 → 应用 B(第二个样例实例)`GET /files` 列不到;⑥ 下线应用后 `POST /ask` / `POST /upload` 得可区分错误。清理只动 `e2e-f057-*` 前缀资源。
+  **覆盖 AC**: AC-13, AC-14, AC-15, AC-17, AC-21, AC-24, AC-34
+  **依赖**: T029, T031, T034;**阻塞**:design §6.2 阻塞项 ②(托管期 retrieve)与 ③(本地期 retrieve)、契约 ③④⑦
+  **完成证据(2026-09-16,`wt/f057-tail`,commit `c81a87928`)**: `src/backend/test/e2e/test_e2e_f057_sdk_journey.py`,11 个用例、默认跳过(`F057_E2E=1` 门,照 `test_e2e_f053_openapi_auth_identity.py`)。`pytest test/e2e/test_e2e_f057_sdk_journey.py -q` → **11 skipped**;带 `F057_E2E=1` 的 `--collect-only` → **11 tests collected**(fixture 名与导入都成立);ruff check / format 零输出。
+  **跳过原因逐条点名「要跑必须先落地什么」**(写在 `SKIP_REASON` 与模块 docstring 里,不是一句泛泛的「需要环境」):① wheel 已随镜像分发(`/versions.sdk` 非 null);② `app_runtime.obo_secret` 已配且 ≠ `jwt_secret`——否则 app-proxy 不注入 `X-BiSheng-Access-Token`(只 warn 一次),每次 `/ask` 都答「缺访问者凭据」,测的是部署配置不是 SDK,`test_journey_03b` 就是为这件事单设的、失败信息直接指向该配置项;③ `example-sdk/` 已部署成**两个**在线应用(附件跨应用隔离要两个);④ 两个**非 admin** 账号、在声明库里可见范围不同(super_admin 短路 ReBAC);⑤ 个人令牌开关已开(集合相等的另一半要用该用户自己的凭据打 `/api/v2/filelib/retrieve`)。
+  **覆盖到的**: 装包(临时 venv 经 `--extra-index-url` 装 `bisheng-sdk`、`__version__` == manifest 版本)· 两访问者各得自己的姓名 / 部门 · 注入头**不出十个之外**、七个无条件头齐全、访问凭据在其中(见下方复核订正 ②)· `/healthz` 不读身份(入口 200 + 平台无头探针报 healthy 两半)· `/ask` 与同用户直连 retrieve **集合相等**(三元组 `(document, index, content)`)· 用户无权的文档不出现 · 未声明的库 4xx 且带 `next_step` · 应用 A 的附件在应用 B 列不到 · 同应用内他人附件 403 且不进列表 · 停运后入口拒绝且无堆栈(跑完 resume)。
+  **偏差(三条,均已写进文件头)**: ① 步骤 ② 的 `bisheng deploy` + 审批**不由测试执行**——审批要人拍板,改成运行前提,应用 id 经 `F057_E2E_APP_ID` / `F057_E2E_APP_B_ID` 传入;② 步骤 ④「未声明的库」需要第三个样例实例(样例把 `KNOWLEDGE_BASE_IDS` 写死在代码里,给它加个查询参数等于测一条只为测试存在的代码路径),故经 `F057_E2E_UNDECLARED_APP_ID` 可选提供、缺省 skip;③ 步骤 ⑥「retrieve 与 storage 给出**可区分**错误」的进程内半边从外面观测不到——应用一停容器就没了,这半边由 SDK 单测覆盖,本文件只断言入口侧拒绝且不吐堆栈。
+  **明确不覆盖**: 本地 `dev` 期(阻塞项 ③:`bsdev.` 自签句柄 + 未注入 `BISHENG_APP_TOKEN`)与省略 `knowledge_base_ids`(契约 ③,`RetrieveReq` 仍必填)。两处都在文件头写明,不留「以后想起来再说」。
+  **切片内复核订正(2026-09-16,两处会「为错误的理由失败」的断言已改)**: ① `test_journey_03b` 原断言注入头集合**恰等于十个**——`app_proxy/headers.py` 明写「缺料就不发,不发空值」,三个 `dept-*` 头在访问者没有部门时根本不出现(SSO 首登后的常见形态),这条会在一个完全健康的部署上红。改成两句:`injected ⊆ 十个`(不许漏出契约外的头)+ `七个无条件头 ⊆ injected`,并把访问凭据那句提到最前,让配错 `obo_secret` 时先看到指向配置的失败信息而不是一个集合差。② `test_journey_08` 原在 `stop` 返回后立即探入口——容器还有 docker 的停止宽限期(~10s),窗口内的一次 200 会被读成「平台在给停运应用服务」。改成 20s 内轮询直到不再答 200,仍不收敛才判失败,失败信息点名这个窗口。
+  ⚠️ **勾选的是「文件」这一交付物(任务标题即如此),不是「旅程跑通」**:截至 2026-09-16 这 11 个用例**没有在任何真实部署上跑过一次**,阻塞项 ②③ 未落地前也跑不了。真机跑通归 T042;那一步做完之前,本条不能当作 AC-13 / AC-14 / AC-15 / AC-17 / AC-21 / AC-24 / AC-34 已验证。
+
+- [ ] **T042**: 114 手动验证(`/e2e-test features/v3.0.0/057-bisheng-sdk`)〔3h,`needs_114`〕
+  **文件**: 本文(回填「114 验证记录」)
+  **逻辑**: 顺序:`versions` 的 `sdk` 段 → `curl -OJ …/sdk/download` + `pip install` → `simple/` 两页 → `sdk-guide.md` → `skills sync` 拉到两个包并接入 `~/.claude/skills/` → `bisheng dev` 跑 `example-sdk/`(auth 必过;retrieve 预期答「凭据被拒」= 阻塞项 ③、storage 预期「无句柄」= 契约 ⑦,**如实记录为上游未就绪,不判 SDK 缺陷**)+ `selfcheck.py` → `deploy` → 两个非 admin 用户访问(**不用 admin**,super_admin 短路 ReBAC)→ 回填每步结果与阻塞项状态。storage 托管期先看 `GET /v1/runtime/status` 的 preflight `attachment_storage.ok`,systemd 形态需 `RTM_APP_FACING_BASE_URL` 指到 `bisheng-apps` 网桥网关(坑 27)。
+  **覆盖 AC**: AC-01, AC-02, AC-05, AC-06, AC-10, AC-13, AC-14, AC-20, AC-23, AC-28, AC-34
+  **依赖**: T029, T032, T038;部分受阻于 design §6.2 阻塞项 ②③ 与契约 ③④⑦
+  **未做(需 114,Wave 7)**
+
+- [x] **T043**: 跨 Feature 回写登记(只追加文档条目,不改他人代码)〔1h〕
+  **文件**: `features/v3.0.0/053-dev-cli-skills/tasks.md`(**T043 增补**:注入 `BISHENG_APP_STORAGE_DIR=<项目根>/.bisheng/attachments/` 绝对路径 + 写 `.bisheng/.gitignore` + 建议同名注入 `BISHENG_APP_STORAGE_MAX_FILE_MB`(取平台 `RTM_STORAGE_MAX_FILE_MB`)、**不注入 `_ENDPOINT`**;**T042 增补**:`devproxy.HandleMinter` 的本地自签 `bsdev.` 句柄改为平台签发的短时凭据,否则本地 retrieve 恒 `26001`), `features/v3.0.0/055-app-publish-pipeline/tasks.md`(T057 追加 OBO Bearer 受理 + `entry_authz` 签发改 fail-closed;T058 追加 `16273` 载荷 `data.capability` / `data.reason="revoked"`), `features/v3.0.0/052-mcp-server-face/spec.md` 或其未来 tasks(`knowledge_base_ids` 可省略;「不可及」码 + `data.unreachable_ids`;受理平台签发的本地短时凭据)
+  **不写回 F054**:附件句柄契约以 `wt/storage-handle` 为准,本文只做消费者(design §6.2 回写登记第 1 条);仅在本任务结果里记一句事实——F057 `_storage_remote.py` 是该 router Bearer 路径的首个消费者,`validate_key` / 路由 / 信封任何改动会让 `test_contract_alignment.py` 先红。
+  **依赖**: T021, T027(契约定稿后)
+  **完成证据(2026-09-16,`wt/f057-tail`)**: 三份 tasks 各追加条目,**每条都先对实现侧 grep 过、按现状写**(因此有两条从「请求」改成了「只核对、已交付」):
+  - `053-dev-cli-skills/tasks.md`:T042 条目下追加「`bsdev.` 自签句柄改平台签发」+ 一条同因事实(`devdb.PLATFORM_ENV_NAMES:65-77` 里没有 `BISHENG_APP_TOKEN`,本地实际先抛 `AppCredentialMissingError`,换完句柄还差这一把);T043 条目下追加 `BISHENG_APP_STORAGE_DIR` 绝对路径 + `.bisheng/.gitignore` + 可选 `BISHENG_APP_STORAGE_MAX_FILE_MB`,并写明**不要注入 `_ENDPOINT`**(两个句柄同时在场时 SDK 判「句柄不唯一」,`bisheng_sdk/storage.py:63`)。
+  - `055-app-publish-pipeline/tasks.md`「跨 Feature 回写受理」表加两行:① OBO 签发改 fail-closed = 🔲 未交付(`app_runtime/domain/services/entry_authz_service.py:587-597` 仍是 `_warn_once` + 放行;该文件自己的 ⚠️ 注释写着「等 OBO 有了第一个读者就必须翻」——读者已经到了);② `16273` 载荷 = ✅ **已交付**(`common/errcode/app_publish.py:495-527` 已带 `capability` / `kind` / `reason`),**只核对未改**。
+  - `052-mcp-server-face/tasks.md`「跨 Feature 回写受理」表加三行:`knowledge_base_ids` 可省略 = 🔲 未交付(`schemas/filelib.py:39-45` 仍 `min_length=1`);「不可及」码 + `data.unreachable_ids` = ✅ **已交付**(`mcp_face.py` 的 `26320`–`26323`),只核对未改;受理平台签发的本地短时凭据 = 🔲 未交付(`bsdev` 后端零出现零受理,全仓命中只有 CLI 的 `devproxy.py:117` 与 SDK 的脱敏正则 / 测试夹具)。
+  **F054 无回写**(design §6.2 回写登记第 1 条);在此记一句事实供其知悉:F057 `_storage_remote.py` 是附件 router 那条 Bearer 路径的首个消费者,`validate_key` / 路由 / 信封任何改动会让 `src/bisheng-sdk/tests/test_contract_alignment.py` 先红。
+  **偏差**: 原注记说「F052 tasks 同期有别的切片在改,回写应在其收口后单独做」。实际落点在文件末尾的「跨 Feature 回写受理」表、与该切片改动的任务条目(T104 / T302)不在同一区域,属**纯追加**,故照常写入;若合流时该文件冲突,取两边并集即可。
+
+---
+
+## AC 追溯表
+
+| AC | 任务 | AC | 任务 | AC | 任务 |
+|---|---|---|---|---|---|
+| AC-01 | T007, T024, T025, T026, T027, T042 | AC-13 | T041, T042(旅程引用,经 F052 / F055) | AC-25 | T003, T004, T016, T017, T018, T019, T020, T021 |
+| AC-02 | T026, T027, T030, T031, T032, T035 | AC-14 | T010, T011, T014, T015, T041, T042 | AC-26 | T024, T025, T026, T027, T033, T034, T035, T037 |
+| AC-03 | T007, T012, T013, T014, T015, T024, T025, T026, T027, T035, T036 | AC-15 | T003, T004, T014, T015, T041 | AC-27 | T033, T035 |
+| AC-04 | T003, T004, T005, T006, T010, T011 | AC-16 | T010, T011, T014, T015 | AC-28 | T033, T035, T036, T038, T042 |
+| AC-05 | T012, T013, T026, T027, T042 | AC-17 | T010, T011, T014, T015, T041 | AC-29 | T039, T040 |
+| AC-06 | T005, T006, T042 | AC-18 | T010, T011, T014, T015 | AC-30 | T007, T033, T034, T035 |
+| AC-07 | T003, T004, T005, T006 | AC-19 | T003, T004, T010, T011, T014, T015 | AC-31 | T005, T006, T018 |
+| AC-08 | T005, T006, T014, T015 | AC-20 | T016, T017, T018, T019, T020, T021, T042 | AC-32 | T033, T034, T037, T038(验收随 PRD-2 WB-01) |
+| AC-09 | T005, T006 | AC-21 | T016, T017, T018, T019, T020, T021, T041 | AC-33 | T007 |
+| AC-10 | T005, T006, T042 | AC-22 | T016, T017, T018, T019, T020, T021 | AC-34 | T034, T041, T042(旅程引用) |
+| AC-11 | T014, T015 | AC-23 | T016, T017, T020, T021, T042 | AC-35 | T033, T035 |
+| AC-12 | T007, T014, T015 | AC-24 | T016, T017, T021, T041 | AC-36 | T033, T035 |
+
+**计数**:44 任务(T000–T043);工时合计约 **80.5h**(Wave 0 ≈ 0.5 · Wave 1 ≈ 17.5 · Wave 2 ≈ 13 · Wave 3 ≈ 12.5 · Wave 4 ≈ 10 · Wave 5 ≈ 4 · Wave 6 ≈ 14.5 · Wave 7 ≈ 7;不含归对方 Feature 的阻塞项 ②③ 与契约 ③④⑦)。
+
+**AC 覆盖结论**:36 条 AC 全部至少被一个带「覆盖 AC」标注的任务覆盖(上表逐条可查)。其中 **AC-13 / AC-34 只由 T041 / T042 覆盖**(服务端旅程,SDK 侧无可单测的行为),**AC-32 的「与 PRD-2 WB-01 同一范式」半边随 PRD-2 验收**(release-contract 表 3 F057 行已如此约定)——两者都不是遗漏,是刻意的旅程引用。
+
+---
+
+## 114 验证记录
+
+(T032 / T042 回填)
+
+## 评测记录(AC-29)
+
+(T040 回填)
+
+---
+
+## 实际偏差记录
+
+> **只留一行指针**,论证在 design.md(决策 / 坑),这里不重复。推翻已定案的决策先记 design 决策再记这里。
+
+- **retrieve 的凭据形状按 F055 已合入实现改写**(2026-09-16,design D5 / CON-3 已同步):一次检索送**两把**凭据——`Authorization: Bearer `(应用运行期凭据,平台据此取当前生效声明的白名单)与 `X-BiSheng-Access-Token`(本请求注入的访问者凭据,平台据此确立访问用户)。初稿「只把访问者凭据当 Bearer 送」作废:`credential_validator` 不受理 OBO 令牌,且门面无从知道是哪个应用在调、取不到白名单。访问者凭据仍然只从请求上下文取,应用凭据只经 `_env.app_token()` 一处读取。
+- **新增异常类 `AppCredentialMissingError`**(D6 表由 18 类变 19 类):应用运行期凭据未注入时的明确错误。本地 `bisheng dev` 期必然命中它(本地没有已上线的应用,因此没有应用凭据),这正是「本地期 retrieve 不可端到端验证」在 SDK 侧的如实呈现——不开兼容分支、不拿 `login` 密钥顶替。
+- **`_codes.py` 不再留空位**:F052 的门面码已分配(`26320` 无执行身份 / `26321` 不可及含 `unreachable_ids` / `26322` 能力已收回 / `26323` 范围过大),前三个已登记映射,`26323` 按「未登记码原样呈现」走 `PlatformRefusedError`。
+- **新增私有模块 `_attachment.py`**(design §4.3 模块表已补):`AttachmentMeta` 放在这里而不是 `storage.py`,否则两个后端 import 它会与 `storage` 形成循环 import。公开面集合不受影响(仍是四个非下划线模块)。
+- **`tag_match_mode` 字面量是大写 `"ANY"`**:design §4.2 ③ 初稿的示例写的是小写,服务端 `RetrieveFilters` 的 Literal 只认 `"ANY"` / `"ALL"`,小写会 422。已就地改正 design。
+- **审查回合修掉的两处实现缺陷**(2026-09-16,详见 T019 / T020 条目下):① 附件端点的传输层失败漏翻成 `StorageUnavailableError`,托管期最常见的 storage 故障被呈现成"平台连不上"(design D6 / 坑 27 / AC-25);② `storage.aopen` 从未实现,而 `test_six_functions_and_six_async_twins_exist` 只断言了五个孪生,让缺失看不出来(D10)。两处的测试都已收紧成"缺了就会点名"的形状。
+- **一条留给 F052 的 fail-closed 预警**(design §8 已登记):`knowledge_base_ids=[]` 今天被服务端 `min_length=1` 拒,SDK 原样送出即可;F052 放宽必填时若把 `[]` 也当成「未指定 = 全部被授予范围」,一个把目标库过滤到空的应用就会静默检索得**比它要的更宽**——那时 SDK 要在 `_body` 里把 `[]` 挡成明确错误。
+- **本切片范围**:Wave 1–3(T001–T021)+ T008 的 CI 门。T022–T043(打包脚本、manifest、四个分发端点、runtime-manager 取包、技能包增量、指南、评测样本、114 联调)归姊妹切片 `f057-sdk-dist` 与后续波次,本切片未动 `src/backend/bisheng/dev_toolkit/artifacts/`。
diff --git a/features/v3.0.0/058-openapi-responses/spec.md b/features/v3.0.0/058-openapi-responses/spec.md
new file mode 100644
index 0000000000..60d6c2141c
--- /dev/null
+++ b/features/v3.0.0/058-openapi-responses/spec.md
@@ -0,0 +1,219 @@
+> **❌ 已取消(2026-10-09)**:用途已由发版线 beta2 上线的 `/api/v2/workstation/chat/completions` 覆盖,本 spec 存档,不再实现。
+
+# Feature: 日常模式会话开放(业务语义契约 · 日常模式 + 同步 · `chat:invoke` + 会话附件上传)
+
+> **本文档定位 — 纯 What(需求口径,不随代码漂移)**
+>
+> spec 只回答 **做什么 / 验收标准 / 不做什么**。所有 How(决策、数据流、字段、API、Service、前端、文件清单、性能指标)一律不写在这里,
+> How 的唯一真相在 [design.md](./design.md) 与 [tasks.md](./tasks.md)。
+
+**关联 PRD**: 伴生 [docs/PRD/3.0-beta2/3.0 开放 API 鉴权与身份传递 PRD.md](../../../docs/PRD/3.0-beta2/3.0%20开放%20API%20鉴权与身份传递%20PRD.md) **v2.3** §1.4(P1「日常模式会话开放」行 / 「依赖身份传递、须同期交付」/ 「任务模式本次不点亮」)/ §3.3–3.4(场景三与外部标识分区)/ §4.2.4(`chat:invoke` 位、「不设 `session:*`」段、扩展位表 `model:invoke` 行「与 `chat:invoke` 互不蕴含」)/ §4.3.4(外部用户标识头 = 分区键,非身份模式)/ §4.5 定义 6 边界 a(会话不算资源)/ **§4.6.3 日常模式会话开放全部**(一 交付边界 / **二 业务入参清单** / **三 八条硬边界** / 四 身份与隔离 / 五 形态归 design / 六 演进)/ §4.8.1(审计)/ §五 AC-32 / AC-34 / AC-35 / AC-36 / **AC-50**(AC-33 已作废)/ §七 **D9 改判(§7.5 展开)** / **D11 / D12** / §六非目标(不做第二套并行契约、不承诺官方 OpenAI SDK 可直连)/ 附录 B.1 日常模式会话相关行 / 附录 C `26015` + **`26017`** / 附录 E.3「日常模式会话的复用映射」。**不在《3.0 应用工场 PRD-1》范围**(PRD-1 全册零引用;版本层归属由 release-contract 表 3 F058 行补齐)
+**同步记录**: **2026-08-18(第四次)按伴生 PRD v2.3 D9 改判就地重写**:伴生 PRD 不再指定对外契约形态(触发点 = 研发反馈对标 OpenAI Responses 成本高,根因 = 模型侧契约承接应用侧能力的语义错配),本 spec 相应**把全部 AC 从「Responses 形态」改写为「业务语义 + 形态归 design」**——AC-01/02/03/05/06/07/08/09/10/11/12/13/14/15/16/17/18/21/23/25/26/27/28/29/31/35/37 去字段名与端点路径;**AC-04 由「响应体可被 OpenAI 官方 SDK 反序列化」改为「通用 HTTP 客户端即可集成 + 可运行示例」**;AC-02 的多轮机制由「上一轮 id、须指向最新、指向历史 400」改为**会话标识**(分叉判定随之消失);决议 2/3/5/6/9/12/13 改写、**新增决议-14**;§3 边界随之订正。**2026-08-17(第三次)按伴生 PRD v2.2 D11 / D12 就地同步**:① 日常/任务模式的对外区分由 `background` 改为 `bisheng_mode` 扩展字段(`background` 恢复 OpenAI 原义 = 异步传输),新增 AC-37(`bisheng_mode: "task"` → 400 `26017`)与决议-13,AC-01 / 10 / 12 / 25 / 26、§3 两条边界、决议-12 随之订正;② 薄兼容层由「本版不做(可选项)」升格为**伴生 PRD 层面明确不做**(D12),决议-2 与 §3「薄兼容层触发条件」改写。2026-08-17 初稿(按伴生 PRD v2.1 §4.6.3 + release-contract 表 3 F058 行 + Discovery N2 编写;§4 决议 1–12 于同日按用户授权的全自动模式直接定案并留痕);同日按独立审查 12 条裁定就地修订(`chat:invoke` 位归 F049 清单、本 Feature 只拥有消费端点;重复拥有项改为引用 F049 / F050 / F051;「明确错误」判据统一;`previous_response_id` 只指向会话最新;End-User 分区键用于归属一致性校验;失败轮次单一真相;~~AC-04 绑定官方 Python SDK 主版本~~【已随 2026-08-18 D9 改判作废】;AC-03 不透明标识;去 How;新增 AC-35 会话忙 / AC-36 不受开放能力层开关约束;AC 34 → 36,编号稳定、新增末尾追加)
+**优先级**: P1(伴生 PRD §1.4;**版本层登记项、排期由产品定**)
+**所属版本**: v3.0.0(登记;是否随 v3.0.0 发版由产品定,见 §4 决议-1)
+**依赖**: **F049**(服务账号与 `bs-sak-` 凭据校验路径、开放 API 默认拒绝与 fail-closed 语义;**权限位清单已含 `chat:invoke`**——伴生 §4.2.4 基础位、始终在清单中,开放前签发表单以说明标注「端点随后续版本开放」(F049 范围边界),本 Feature 只拥有该位的**消费端点**);**F050**(**模式 D 是本 Feature 的主场,须同期交付、不可只出模式 S 版本**——`X-Bisheng-On-Behalf-Of` 与 `X-Bisheng-End-User` 两头的解析、受限委托五道准入、`delegate` 位、审计双归属、`26010` / `26016` 两条判定均由 F050 拥有,本 Feature 只消费)。**不依赖** F051(`model:invoke` 面与本面互不蕴含、并列存在)、F052(本面复用平台工作台既有会话链路,只要求检索过滤强度与之等价,不指定门面)。**被依赖**:无
+**批次**: A(开放能力层,与 v2 开放 API 同一命名空间 `/api/v2`);**不在 MVP-114 纵切上**([../mvp-114-path.md](../mvp-114-path.md) §2);批次 A 建议顺序 F049 → F052 → F051 → F053 → F050 → **(F058)** → F057
+
+> **范围边界**
+>
+> 本 Feature 把平台工作台里「直接与模型对话」的**日常模式会话**(选模型、可选挂知识库、可选开平台工具、多轮)以**业务语义契约**开放给自建系统。**对外接口的具体形态(字段名、事件名、端点路径、是否对标某个第三方契约)由 design 决定**,本 spec 只写「调用方必须能表达什么」与「平台必须保证什么」——伴生 PRD v2.3 D9 改判,见 §4 决议-14。它站在 F049 的凭据地基与 F050 的身份传递之上,是伴生 PRD 场景二(代表员工提问、回工作台续问)在对话能力上的完整形态。它**不是**模型协议裸透传(F051):本面建平台会话、留历史、可挂知识库与平台工具、在平台 UI 可见。
+>
+> - **本次纳入**:
+>   - **发起一轮日常模式对话**(仅日常模式 + 同步执行):调用方能表达——本轮提问、模型(工作台可用模型,按名)、会话标识(续接同一会话)、可选知识库挂载、可选平台工具、可选附件引用、可选业务上下文指令(叠加于平台提示词)、返回方式;流式与非流式两种返回
+>   - **按轮次标识取回单轮结果**:取回经本 API 创建的终态轮次结果;归属不匹配返回 404;**并入 `chat:invoke`**(不设 `session:*`)
+>   - **会话附件上传**:独立上传端点;限制沿用平台工作台既有上传限制
+>   - **入参清洗与拒绝**:契约外字段(含平台内部专用 / 已废弃字段与本版不支持的能力)一律明确拒绝、不静默忽略;对外文档逐项列出已实现入参 / 返回内容 / 事件类型 / 拒绝清单
+>   - **事件流转译**:平台内部事件(思考 / 回答 / 工具调用 / 引用)→ 对外语义可分的流式事件,事件流中不出现平台内部事件名;不识别的工具声明明确报错、不静默降级为无工具对话(伴生 AC-34)
+>   - **身份与会话隔离**:模式 S 会话归服务账号、按 `X-Bisheng-End-User` 分区(分区键**不作为任何列举 / 过滤维度、仅用于归属一致性校验**,缺省记 WARN 不阻断——写入与缺省规则归 F050 AC-12 / 13,本 Feature 在两个有会话语义的端点上消费);模式 D 会话归被代表用户、在其工作台会话列表可见可续问(伴生 AC-32);取回轮次结果 / 会话标识 / 附件引用三处的归属与分区一致性校验(404)
+>   - **两个维度各自留位、各自拒绝**:**运行模式**要求任务模式 → 400 `26017`,不静默退化为日常模式(伴生 AC-50);**同步/异步**要求异步执行 → 400 `26015`,不静默同步执行(伴生 AC-35);两码不合并、可独立点亮;取消 / 断点续传**形态上留位、本版不实现**
+>   - **通用 HTTP 客户端即可集成**:不需要平台专有 SDK,对外文档给出可直接运行的调用示例(AC-04;「官方 OpenAI SDK 可直连」已随伴生 v2.3 D9 取消为需求)
+>   - **`chat:invoke` 位的消费端点**(三个端点 + 两个留位端点);与 `model:invoke` 互不蕴含。**位本身归 F049 权限位清单**(伴生 §4.2.4 基础位、始终在清单中),本 Feature 不拥有位的定义、分组或出现与否,只在上线时让清单中该位的说明与覆盖端点指向本 Feature 端点
+>   - **不受开放能力层部署开关约束**:本 Feature 端点属 v2 开放 API 主体(与服务账号模块同为「发布即带鉴权」的开放 API 面),未部署开放能力层时同样可达——与 F051 / F052 / F053 三面不同(AC-36)
+>   - **审计**:三个端点纳入开放 API 通用调用审计(含外部标识留痕);模式 D 双归属随 F050
+>   - **不暴露清单的守住**:不新增任何会话列表 / 历史查询 / response 列表端点,F049 已关闭的 6 个 `/chat/*` 不因本 Feature 复活,不占用 `/conversations` 路径
+> - **本次明确排除**:
+>   - **任务模式(灵思)**(届时通常配异步执行,但二者是两个独立维度——伴生 D11)——四条技术前置(异步作业语义 / 事件流破坏性单消费者 / 前端编排搬后端 / 并发准入)见伴生 §4.6.3,本 spec 只引不写;连带取消 / 断点续传只留位;`ask_user` 的 park-and-release 与产物文件承载届时另设计(**形态选型须把这两项当必考项**,伴生 §4.6.3 六)
+>   - **会话列表 / 会话历史查询 / 会话反馈写入 / 消息回填**(`GET /workbench/sessions*`、原 `session:*` 三位、6 个 `/chat/*`)——伴生 §4.2.4「不设 `session:*`」(2026-08-16 决议):平台存会话与给调用方开读接口是两件事,前者靠工作台完成 AC-32,后者调用方自建系统自有存储
+>   - **面向同一能力的第二套并行契约**(如 Chat Completions 薄兼容层)——**伴生 PRD 层面已明确不做**(D12 + §六非目标;本 spec §4 决议-2 与之一致):薄层承载不了平台工具与知识库引用,即日常模式的全部增量价值。想拿通用 OpenAI 客户端直连的诉求由 F051 `model:invoke` 面承接;既有 `POST /api/v2/assistant/chat/completions` 不动、只在文档说明各自适用范围
+>   - **模型协议裸透传**(`model:invoke`、不建会话不留痕)——F051;需要不留痕的模型调用给 `model:invoke`,本面一律留痕
+>   - **委托机制本身**(`delegate` 位、五道准入、两身份头的解析与互斥、审计双归属机制、裸 `user_id` 收口)——F050,本 Feature 依赖并消费
+>   - **客户端执行的工具**(调用方声明 schema、平台返回待执行调用、调用方执行后回传)——本版不支持、明确拒绝(§4 决议-6);平台会话链路无客户端往返语义、§3 三类场景无此诉求
+>   - **采样 / 生成参数的逐次透传**(temperature / top_p / max_output_tokens 等)——本版由平台工作台模型配置决定、明确拒绝(§4 决议-5)
+>   - **限流、配额、IP 白名单、幂等性**(伴生 R8,P2);OAuth 2.0 / 最终用户令牌透传 / 即时建号等伴生 §六非目标原样继承
+>   - **平台工作台自身**(模型 / 工具 / 提示词配置界面、会话页面)——既有能力,本 Feature 只经开放 API 复用其会话链路
+>   - **对任何第三方契约的完整兼容承诺**——本面只交付上列能力;即便 design 选择了某个第三方形态,也只承诺已开放的能力,不承诺其完整 API 面(伴生 §4.6.3 五)
+
+---
+
+## 1. 用户故事
+
+作为 **自建业务系统的集成方(如在企业 OA 内嵌问答入口)**,
+我希望 **用一把服务账号密钥、按业务语义直接描述「代表谁、问什么、在哪个会话里、挂哪些知识库与工具」发起对话并多轮续接,而不必自己组织 messages / role 结构或拼装 system message**,
+以便 **不为平台学一套私有会话协议、不自己拼装权限过滤,员工在 OA 里问到的与在 BiSheng 里问到的完全一致**。
+
+作为 **被代表的员工**,
+我希望 **在自建系统里发起的对话,回到 BiSheng 工作台就能看到同一个会话并继续追问**,
+以便 **对话不因入口不同而割裂,两个入口只是同一段工作的两个窗口**。
+
+作为 **面向外部访客提供应用的开发方(场景三)**,
+我希望 **以服务账号身份为每个访客开会话、用外部标识把访客彼此分开,访客只在管理员授予该服务账号的知识库范围内得到回答**,
+以便 **访客不被平台的用户权限体系卡住,同时也不会看到任何超出该应用授权的内容**。
+
+作为 **租户管理员**,
+我希望 **通过与其它开放 API 同一张权限位清单授予「日常模式会话」能力、在服务账号授权页决定它能挂哪些知识库、在审计里同时看到哪个系统调的和以谁的名义调的**,
+以便 **对话能力的开放与其它开放能力同一套治理,不多一套后台、不少一份追溯**。
+
+作为 **调用方开发者**,
+我希望 **传错了字段、用错了工具类型、传了本版不支持的能力时得到明确报错**,
+以便 **不会以为工具或知识库已经生效、实际跑的是无工具对话;也不会因为打开异步就被静默切到另一种运行模式**。
+
+---
+
+## 2. 验收标准
+
+> AC 编号在本特性内唯一。与伴生 PRD §五验收标准的对应关系在括号中标注(如 `↔伴生 AC-34`);伴生未单列 AC 的需求以章节号标注(如 `↔§4.6.3`)。
+> 错误码在此仅作为可观测的对外行为引用(`26015` / `26017` / `26010` / `26016` 取自伴生附录 C),其定义与归属写在 design.md。**本节 AC 一律以业务语义表述**:「一轮结果」「会话标识」「运行模式」「传输方式」等指对外可观测的业务概念,其字段名、结构与端点路径由 design 决定(§4 决议-14);已实现清单以对外文档为准(AC-10)。
+> **「明确错误」判据(本 spec 全部拒绝 / 不可用分支统一按此验收)**:4xx 状态 + 平台错误码(可被调用方程序化区分)+ 指明出错项(字段名 / 附件标识 / 模型名 / 工具项 / 未开放的能力名);不静默忽略、不部分接受、不 5xx。仅归属类校验例外——按 AC-21 一律 404 且形状不区分「不存在 / 不属于你」。
+
+### 2.1 端点与契约
+
+- **AC-01** — THE SYSTEM SHALL 提供**发起一轮日常模式对话**的能力:接受以业务语义表达的请求(伴生 §4.6.3 二的入参清单),返回本轮结果;本版只承接**日常模式**(未指定运行模式时即日常模式)×**同步执行**(未指定时即同步)——两者是**两个独立维度**(运行模式 / 传输方式,伴生 D11),各自缺省、各自校验、各自拒绝;同时支持流式与非流式两种返回方式,缺省值由 design 决定并写入对外文档。**端点路径、字段名与请求形态归 design**(§4 决议-14)。(↔伴生 §4.6.3 二 / 三 / D9 / D11;§4 决议-9)
+- **AC-02** — WHEN 请求未携带**会话标识**, THE SYSTEM SHALL 创建一个新的平台会话、在其中生成本轮结果,并在返回中给出该会话标识;WHEN 携带会话标识, THE SYSTEM SHALL 在**同一会话**中续接本轮(可见此前全部轮次上下文,含模式 D 下该用户在平台工作台内新增的轮次)。平台会话**线性追加、不支持分叉**:调用方不指定"从哪一轮接着说",只指定"在哪个会话里说"。多轮对话**只**经此机制续接,调用方无需也不得回传历史消息(AC-09)。(↔伴生 §4.6.3 二「会话标识」/ AC-36;§4 决议-3)
+- **AC-03** — THE SYSTEM SHALL 提供**按轮次标识取回单轮结果**的能力:返回经本 API 创建并**留存**的终态轮次结果(失败轮次不留存,AC-16 / 18),内容与该轮非流式返回一致;轮次标识为**不透明标识**(不递增、不可由顺序或平台内部标识推得、不可枚举);平台工作台内产生的轮次不经此能力暴露。(↔伴生 §4.2.4「按标识取回单轮结果是唯一保留的读动作,并入 `chat:invoke`」)
+- **AC-04** — THE SYSTEM SHALL 使调用方**用通用 HTTP 客户端即可完成本 Feature 的全部能力**,不需要平台专有 SDK、不需要任何平台专有的签名或握手步骤;对外文档须给出**可直接运行的调用示例**(至少覆盖:首轮提问、续接一轮、挂知识库、开平台工具、带附件、流式与非流式各一),示例随契约变更同步更新。(↔伴生 §4.6.3 三.8;**原「响应体可被 OpenAI 官方 SDK 反序列化」已随伴生 v2.3 D9 取消为需求**,见 §4 决议-14)
+- **AC-05** — WHEN 请求以对外文档约定的形式选择知识库(可挂多个;模式 D 下可含被代表用户的个人知识库), THE SYSTEM SHALL 在生成时检索这些知识库,并把引用以**可与回答正文区分的形式**随本轮结果返回(具体承载形式归 design,AC-15);可挂范围 = 执行身份被授权可见的知识库,过滤强度(含文件级)与该身份在平台工作台内挂同一知识库对话时**集合相等**;IF 所选知识库超出执行身份的授权, THEN THE SYSTEM SHALL 返回业务模块既有的资源权限错误(与缺权限位的 403 可区分)并指明该知识库;IF 模式 S 下启用个人知识库, THEN THE SYSTEM SHALL 返回 400 明确错误(错误码引用、指明「个人知识库」项:服务账号无个人知识库)而不静默按空集执行。(↔伴生 §4.6.3 二「知识库」/ 三.4;F049 AC-36 同形;伴生 AC-12 取向)
+- **AC-06** — WHEN 请求声明启用平台工具(声明形式归 design), THE SYSTEM SHALL 使这些工具在本轮生效(**由平台侧执行**)、可用范围 = 平台工作台配置对执行身份开放的工具;IF 出现无法识别的工具声明(含本版不支持的、由调用方执行的工具类型)或不在可用范围内的工具, THEN THE SYSTEM SHALL 返回 400 明确错误(错误码引用、指明该工具项),**不得静默忽略后按无工具对话执行**。(↔伴生 AC-34 / §4.6.3 三.2;§4 决议-6)
+- **AC-07** — THE SYSTEM SHALL 要求**模型必填**、取值为平台工作台配置中对执行身份可用的对话模型名称;名称基准与跨服务商同名歧义的处置与 F051 一致(模型管理页原名精确匹配、歧义时拒裸名并给出限定名、不取默认);IF 模型不在可用范围(不存在 / 已下线 / 不在工作台配置内 / 不属于本租户), THEN THE SYSTEM SHALL 返回 4xx 明确错误(错误码引用、指明模型名与原因),对其它租户的模型按「不存在」处理。(↔伴生 §4.6.3 二「模型」;F051 AC-11–13 同源;§4 决议-8)
+- **AC-08** — WHEN 请求携带**业务上下文指令**(一段文本,如当前页面 / 当前工单), THE SYSTEM SHALL 把它作为本轮的调用方指令**叠加于**平台工作台既有系统提示词之上生效——不替换、不关闭平台提示词;未携带时按平台既有提示词执行。**不要求调用方自行拼装 system message 或组织 messages / role 结构**(伴生 §4.6.3 二)。(§4 决议-7)
+- **AC-09** — THE SYSTEM SHALL 使本轮输入所接受的内容只有两类:**本轮用户文本**、**已上传附件的引用**(经上传端点所得标识);拒绝清单:历史消息 / 角色结构、内联文件数据、外链文件 URL、其它本版不支持的输入类型——IF 请求含拒绝清单中的任一项, THEN THE SYSTEM SHALL 返回 400 明确错误(错误码引用、指明该项),不静默丢弃、不部分接受。各类别的具体形状以对外文档为准。(§4 决议-5 / 决议-11)
+- **AC-10** — THE SYSTEM SHALL 随本 Feature 交付对外接口文档:逐项列出已实现的入参 / 返回内容 / 事件类型 / 拒绝清单 / 本次未开放能力的报错形态,并**分别**说明两个维度的语义——**运行模式**(日常模式 / 任务模式·灵思,缺省日常,本版仅前者)与**传输方式**(同步 / 异步,缺省同步,本版仅前者),文档须明确二者**互不蕴含**,不得表述为「用某一个开关同时决定模式与同步/异步」;WHERE design 选择了对标某个第三方契约, 文档须显式声明只承诺已开放能力、不承诺其完整 API 面。F050 AC-15「`X-Bisheng-End-User` 语义声明原样写入对外文档」在本面文档同样生效;说明既有 `POST /api/v2/assistant/chat/completions` 与 F051 `model:invoke` 面各自的适用范围。(↔伴生 §4.6.3 三.8 / 五;F050 AC-15)
+
+### 2.2 入参清洗与拒绝
+
+- **AC-11** — THE SYSTEM SHALL 只接受对外文档列出的已实现入参;契约之外的字段(不论它在 design 所选形态的原契约里是否存在)一律返回 400 明确错误(错误码引用、指名字段),**不得静默忽略**。「接受但不生效」档只容忽略后不改变调用方可观察行为的字段且**必须在本 spec 枚举——本版该清单为空**(不存在任何被接受却不生效的字段;将来要加入须先修订本条),无法枚举的字段一律 400。(↔伴生 §4.6.3 二 / 三.2;§4 决议-5)
+- **AC-12** — THE SYSTEM SHALL 对以下请求意图明确返回 400 并指明原因、不静默降级:**要求本轮不留痕**(平台会话机制一律留痕,不留痕的模型调用应走 `model:invoke` 面);**在请求体内另传终端用户标识**(该标识只经 `X-Bisheng-End-User` 头传递、不设第二来源);**采样与生成参数**(temperature / top_p / 最大输出长度等,本版由平台工作台模型配置决定);**任务模式专属入参**(如技能选择);**要求异步执行**(AC-25);**要求任务模式**或指定枚举外的运行模式(AC-37)。(§4 决议-5 / 决议-13;伴生 §4.6.3 三.2)
+- **AC-13** — THE SYSTEM SHALL 使对外契约不出现、不要求任何平台前端专用或已废弃的内部字段,也不暴露任何平台内部标识(内部会话 id / 消息 id / 租户 id / 用户 id)——多轮只以对外的**会话标识**表达,该标识不得可反推平台内部标识。(↔伴生 §4.6.3 二「不要求理解任何平台内部概念」;附录 E.3 的 9 个零消费字段清单)
+
+### 2.3 事件流与返回内容
+
+- **AC-14** — WHEN 调用方选择流式返回, THE SYSTEM SHALL **边生成边推送**(不缓冲整段后一次性返回);每类平台内部事件对应一个**语义可区分的对外事件**、事件流中不出现平台内部事件名;每次执行以且仅以**一个终态事件**(成功 / 失败 / 未完成)收尾,事件顺序稳定且在对外文档中列明。(↔伴生 §4.6.3 三.4)
+- **AC-15** — THE SYSTEM SHALL 使**思考过程、回答正文、平台工具的调用与结果、知识库引用**在返回内容中各有**可区分的承载**(各自的类型与字段属 design、以对外文档为准),不得把它们混成一条纯文本流;WHERE design 选择了对标某个第三方契约, 平台特有的内容一律使用 BiSheng 自有命名、**不冒用**该契约中已有名称的语义——占用同名等于做出兑现不了的兼容承诺。(↔伴生 §4.6.3 三.4 / 五;§4 决议-6)
+- **AC-16** — IF 生成过程中模型 / 工具 / 检索失败或上游中断, THEN THE SYSTEM SHALL **流式**下先发错误事件、再以失败终态事件收尾并关闭连接、不挂起;**非流式**下返回明确错误响应(错误码引用、指明失败环节),**不返回带失败状态的正常结果对象**——失败只有一种真相;失败轮次不留存(AC-18),用其标识取回时按 AC-21 处理,该会话仍可正常续接下一轮。
+- **AC-17** — THE SYSTEM SHALL 使非流式返回的结果与同一请求流式执行时由事件流聚合出的最终结果**内容一致**(同一内容集合、同一状态、同一用量口径)。(§4 决议-9;伴生 §4.6.3 三.4)
+- **AC-18** — IF 一轮执行失败或调用方在流式过程中断开连接, THEN THE SYSTEM SHALL 使该轮**不在会话历史留下半条消息**(要么以完整消息留存、要么不留任何痕迹)、不留下永久“进行中”的记录、同一会话不因此被锁死、后续可正常续接下一轮。
+
+### 2.4 身份与会话隔离
+
+- **AC-19** — WHEN 以模式 S(密钥不持 `delegate` 且未携带 `X-Bisheng-On-Behalf-Of`,F050 AC-02)发起对话, THE SYSTEM SHALL 使会话归属服务账号并按 `X-Bisheng-End-User` 分区;F049 AC-25(模式 S 会话不进资源归属人或任何自然人的会话列表,定义 6 边界 a)、F050 AC-11(外部标识不改变任何权限判定与可见范围)与 F050 AC-13(未传头记 WARN、不阻断、分区键为空)在本能力上同样生效。(↔伴生 §4.6.3 四;伴生 AC-43)
+- **AC-20** — WHEN 模式 D(持 `delegate` 且传 `X-Bisheng-On-Behalf-Of`,经 F050 五道准入)发起对话, THE SYSTEM SHALL 使会话归属**被代表用户**、以其权限基准执行(模型 / 知识库 / 工具 / 个人知识库范围同其本人在工作台所见);该会话在被代表用户的 BiSheng 工作台会话列表中可见、可在工作台继续追问;工作台侧新增的轮次同样进入该会话,API 下一次以同一会话标识续接时可见(AC-02);本轮上传的附件同样归属该用户。会话归属规则本身由 F050 AC-38 拥有,本条承接其端到端验收。(↔伴生 AC-32;§4.6.3 四;定义 6「与模式 D 并存时以被代表用户为准」)
+- **AC-21** — WHEN 取回轮次结果、会话标识或附件引用所指向的轮次结果 / 会话 / 附件不属于当前调用的会话归属主体——模式 S 下服务账号不一致或 `X-Bisheng-End-User` 分区键不一致(含一方为空、另一方非空)、模式 D 下被代表用户不一致、跨租户、或该 id 不存在 / 未留存——, THE SYSTEM SHALL 一律返回 404 且响应形状不区分「不存在」与「不属于你」(不用 403、不泄漏该对象是否存在);归属校验以会话归属主体为基准、**不按单把密钥**区分(同一服务账号的不同密钥、同一被代表用户经不同委托密钥视为同一归属)。(↔伴生 §4.6.3 四.1「外部标识不匹配返回 404」;§4 决议-3)
+- **AC-22** — THE SYSTEM SHALL 使本 Feature 三个端点上 F049 的租户上下文规则同样生效(凭据校验通过后一切业务查询按密钥所属租户过滤,F049 AC-07 / §3「凭据校验先于租户上下文」);会话、附件、response 一律落在该租户内,跨租户不可见(按 AC-21 的 404 处理)。
+- **AC-23** — THE SYSTEM SHALL 使 F050 AC-12(模式 S 下外部标识作为会话分区键写入)在本 Feature 两个有会话语义的能力(发起对话、上传附件)上同样生效;本版分区键**不作为任何列举 / 过滤维度、不经任何端点返回,仅用于归属一致性校验**(AC-21 / AC-29)——这是 F050 AC-12「只写不读」在本面的精确化。(↔伴生 §4.6.3 四.3;§4 决议-4)
+- **AC-24** — WHEN 本 Feature 三个端点被调用(含被拒绝的调用), THE SYSTEM SHALL 使 F050 AC-35(开放 API 逐调用审计)与 F050 AC-36(模式 D actor / subject 双归属)在本端点上同样生效;本面特有约束:审计不含会话消息正文与附件内容。(↔§4.8.1;伴生 AC-23)
+
+### 2.5 异步分支留位与不暴露能力
+
+- **AC-25** — IF 请求要求**异步执行**, THEN THE SYSTEM SHALL 返回 400(`26015`)且不创建会话、不执行任何生成、**不静默按同步执行**;该判定**只针对传输方式**,与运行模式无关。(↔伴生 AC-35;附录 C)
+- **AC-26** — THE SYSTEM SHALL 为异步分支的两项能力(**取消**、**断点续传**)在契约文档中留位并标注「随异步分支点亮」;本版调用它们得到 4xx 明确错误(错误码引用、指明未开放的能力名「异步执行」;错误码与 `26015` 同族或复用,归 design),不静默成功、不 5xx、不实现为对同步结果的伪取消或伪续传;**同步分支的入参、返回内容、事件类型与错误形状不得为将来点亮异步分支预留任何需要调用方改代码的差异**。(↔伴生 §4.6.3 一「须留出演进位」/ 六「演进要求」;附录 B.1)
+> 运行模式(任务模式)的留位与拒绝见 **AC-37**(§2.9)——与本节的传输方式留位并列、互不替代。
+- **AC-27** — THE SYSTEM SHALL NOT 提供任何按会话 / 按用户 / 按外部标识列举轮次结果或会话的能力(含会话列表、会话历史查询、任何列表形态的读端点),也不提供会话反馈写入与消息回填;F049 已关闭的 6 个 `/chat/*` 端点不因本 Feature 复活;不占用 `/conversations` 路径。(↔伴生 §4.2.4「不设 `session:*`」/ §4.6.3 一「明确不做」;F049 AC-30)
+
+### 2.6 会话附件上传
+
+- **AC-28** — THE SYSTEM SHALL 提供**会话附件上传**能力(`chat:invoke`):上传单个附件并返回可在发起对话时引用的附件标识;它是有会话语义的能力:附件的归属主体与 `X-Bisheng-End-User` 分区键(若传)随上传记录(AC-19–23),供发起对话引用时做归属一致性校验(AC-29)。(↔伴生 §4.6.3 二「附件引用」/ 三.6;附录 E.3 复用映射)
+- **AC-29** — IF 发起对话时引用的附件标识不存在、归属主体不一致、`X-Bisheng-End-User` 分区键不一致(含一方为空、另一方非空)、被代表用户不一致或跨租户, THEN THE SYSTEM SHALL 返回 404(AC-21 口径、形状不区分)且不执行生成;IF 附件尚不可用于对话(如仍在解析)或解析失败, THEN THE SYSTEM SHALL 返回 4xx 明确错误(错误码引用、指明该附件标识与原因)而不静默忽略该附件后继续作答。
+- **AC-30** — THE SYSTEM SHALL 使附件的类型白名单、单文件大小上限与每轮附件数量上限**沿用平台工作台既有上传限制**(同一配置、同一判定),超限或类型不允许时返回 4xx 明确错误(错误码引用、指明触发的限制项与限值);本 Feature 不新增独立的附件配额或计量维度。(§4 决议-11)
+
+### 2.7 权限位与互不蕴含
+
+- **AC-31** — THE SYSTEM SHALL 使本 Feature 三项能力(及留位的两项异步能力)均要求 `chat:invoke`;IF 凭据有效但缺该位(含仅持 `model:invoke` 或其它任何位的密钥), THEN THE SYSTEM SHALL 返回 403 并指明缺少 `chat:invoke`(F049 AC-04 同形);`chat:invoke` 与 `model:invoke` **互不蕴含**——反向(持 `chat:invoke` 而无 `model:invoke` 调模型协议面被拒)由 F051 AC-06 承接。(↔§4.2.4 权限位表 / 扩展位表 `model:invoke` 行)
+- **AC-32** — THE SYSTEM SHALL 使 F049 AC-01(无凭据 401、无回落路径)/ AC-03(撤销 5 秒内拒绝)/ AC-21(主体停用 / 删除 5 秒内拒绝)/ AC-34(权限评估失败即 5xx、不返回任何结果)在本 Feature 端点上同样生效;被拒时不创建会话、不返回任何数据。(↔INV-27 / INV-28 / INV-30)
+- **AC-33** — WHEN 本 Feature 端点上线, THE SYSTEM SHALL 使 F049 权限位清单中既有的 `chat:invoke` 位(伴生 §4.2.4 基础位、始终在清单中;开放前表单标注「端点随后续版本开放」——F049 范围边界)的说明与悬停所覆盖端点指向本 Feature 三个端点与两个留位端点、去除该标注;本 Feature 只拥有该位的**消费端点**,不拥有位的定义、分组或出现与否,不新增第二处位定义。(§4 决议-10;F049 AC-13 / AC-44)
+- **AC-34** — THE SYSTEM SHALL 使本 Feature 端点按伴生附录 B.1 声明允许模式 S/D(F050 AC-07 的端点声明);F050 AC-30(持 `delegate` 漏传头 400 `26016`、不落回模式 S)、F050 AC-14(两身份头并存 400 `26010`)、F050 AC-04(不持 `delegate` 传头 403,伴生 AC-6)在本端点上同样生效——判定与错误由 F049 / F050 的同一鉴权路径给出,本 Feature 不另设第二套判定。(↔INV-27 / INV-30)
+
+### 2.8 审查修订追加(2026-08-17)
+
+- **AC-35** — WHILE 某会话的上一轮尚未收敛到终态(生成中), IF 再次以该会话标识续接, THEN THE SYSTEM SHALL 返回 4xx 明确错误(错误码引用、指明「会话忙」与该会话当前进行中的轮次标识)且不并行写入同一会话;上一轮收敛后按 AC-02 正常续接。
+- **AC-36** — THE SYSTEM SHALL 使本 Feature 端点属 v2 开放 API 主体、**不受开放能力层部署开关约束**:WHERE 开放能力层未部署, 本 Feature 三个端点(及两个留位端点)对持 `chat:invoke` 的服务账号密钥同样可达、行为无差异(与 F051 AC-28 / F052 / F053 三面「未部署即不可达」不同);服务账号模块恒在(F049 AC-49)。
+
+### 2.9 运行模式留位追加(2026-08-17,伴生 D11)
+
+- **AC-37** — IF 请求要求**任务模式(灵思)**, THEN THE SYSTEM SHALL 返回 400(`26017`)且不创建会话、不执行任何生成、**不静默退化为日常模式**;IF 请求指定枚举(日常 / 任务)之外的运行模式, THEN THE SYSTEM SHALL 同码返回 400 并指明该项;WHEN 未指定运行模式, THE SYSTEM SHALL 按日常模式执行。`26017`(任务模式能力未开放)与 `26015`(异步传输未开放)**互相独立、不得合并为一个码**:同时触发两者时都不放行,且响应须能让调用方分辨是哪一项未开放,二者将来可各自独立点亮。(↔伴生 AC-50 / D11;附录 C)
+
+---
+
+## 3. 边界情况
+
+> 只写期望的对外行为;实现层的落点、映射与数据访问细节见 design.md §5。
+
+- **两个维度各自留位,不合并成一个开关**(伴生 D11):**运行模式**(日常 / 任务·灵思,对应内部 `task_mode`)与**传输方式**(同步 / 异步)正交,承载字段归 design。合并的后果有取证:内部两个分支(`_task_mode_stream_completion` / `_agent_stream_chat_completion`)**都是同步 SSE 流**,「模式 ⟷ 同步/异步」的映射不成立;且调用方打开异步的动机通常只是躲网关超时,若因此被切到灵思,拿到的是耗时、计费与输出形态都不同的另一种能力。任务模式的四条技术前置见伴生 §4.6.3,本 spec 只引不写;本 Feature 对两个维度的全部承诺 = 各自明确拒绝(AC-37 / AC-25)+ 形态留位(AC-26)。将来任一维度点亮都不改已开放的日常模式同步契约,且「日常模式 + 异步」这一格保持可开。
+- **会话标识跨会话 / 跨用户一律 404**(AC-02 / AC-21):不能用它探测别人的会话是否存在。平台会话**线性追加、不支持分叉**——调用方只说"在哪个会话里说",不说"从哪一轮接着说",因此不存在"指向历史轮次"这一分支(**该分支随伴生 v2.3 D9 改判连同 `previous_response_id` 机制一并消失,决议-3 已订正**);模式 D 下员工在工作台续问的轮次自动进入同一会话,API 下一次续接直接可见。失败轮次不留存、不占位(AC-16 / 18)。上一轮生成中再次续接 → 「会话忙」(AC-35),不并行写入。归属校验以**会话归属主体**为基准而非单把密钥:密钥可轮换、可多把,会话不因此失联。
+- **End-User 分区键与定义 6 边界 a**:模式 S 会话永远归服务账号、按 `X-Bisheng-End-User` 分区、不进资源归属人的会话列表——归属人机制解决的是「知识库归谁认领」,会话不算资源,否则 §3.3 那个缺陷会被新机制请回来。分区键不改变任何权限判定(F050 AC-11);本版它**不作为任何列举 / 过滤维度、仅用于归属一致性校验**——取回轮次结果、会话标识、附件引用三处都要求分区键与创建时一致(AC-21 / AC-29),这也是场景三里访客 A 拿不到访客 B 的结果与附件的唯一防线;缺省 WARN 不阻断(F050 AC-13),因为串档的两个成立条件(共享身份 + 可按身份列举会话)本版只具备第一个——**将来若补做任何按用户 / 按外部标识列举会话的端点,必须先把缺省行为改为强制**(原 `26014` 的语义),本 spec 把这条写进 AC-23 / AC-27 的守门口径。
+- **契约外字段的处置原则**:白名单式接受 + 契约外 400 指名字段(AC-11);「接受但不生效」档只容忽略后不改变可观察行为的字段且须在本 spec 枚举——**本版清单为空**。反面教材是同名不同义的工具声明导致的**静默降级成无工具对话**——本面宁可拒绝也不静默(伴生 §4.6.3 三.2)。要求不留痕、在请求体内另传终端用户标识、采样参数、由调用方执行的工具、历史消息 / 角色结构、内联与外链文件均属明确拒绝(AC-06 / 09 / 12)。
+- **失败与中断的单一真相**:流式失败 = 错误事件 + 失败终态事件收尾、不挂起;非流式失败 = 错误响应、不返回带失败状态的正常结果(AC-16);失败或断连的轮次不在会话历史留下半条消息、不留永久"进行中"、会话不锁死(AC-18);非流式与流式的成功结果一致(AC-17)。合起来保证「按标识取回单轮结果」只有两种结果——留存的终态结果或 404,不存在第三种状态。
+- **附件**:大小 / 类型 / 数量沿用平台工作台既有上传限制(AC-30),不为开放 API 另设一套;只经上传端点获得标识、不接受内联数据与外链 URL(AC-09)——外链拉取是新的出站请求面,本版不开。附件归属跟随会话(模式 D 归被代表用户),不算「资源」、不落归属人名下。
+- **模型可用范围与 F051 不必相同**:本面 = 平台工作台配置中启用的对话模型(与员工在工作台里可选的一致),F051 = 模型管理页全部已启用对话模型;两者各按其产品面配置,但**名称基准与歧义规则同源**(AC-07),避免同一个模型名在两面解析成不同服务商。
+- **两层判定与 F049 一致**:`chat:invoke` 位(AC-31)∧ 执行身份对具体知识库 / 工具 / 模型的授权(AC-05 / 06 / 07),两层失败可区分;模式 S 下服务账号能挂哪些知识库只由其主体侧授权页决定(F049 §2.7),与资源归属人本人的权限无关(定义 6 边界 c)。位本身在 F049 清单中恒在(开放前标注「端点随后续版本开放」),本 Feature 上线只是让它有了消费端点(AC-33)。
+- **不受开放能力层开关约束**(AC-36):本面是 v2 开放 API 的一部分、与服务账号模块同属「发布即带鉴权」的开放 API 主体,不像 F051 / F052 / F053 三面那样随开放能力层部署与否出现或消失;标准版(未部署开放能力层)同样可用。
+- **平台特有内容只用自有命名**(AC-15 / 28):WHERE design 选择了对标某个第三方契约, 一律不冒用该契约中已有名称的语义(含路径名、工具类型名、对象名)——占用同名即做出兑现不了的兼容承诺(伴生 §4.6.3 五同一取向)。
+- **轮次标识不可枚举、工作台轮次不经本 API 暴露**(AC-03):模式 D 的被代表用户在工作台看得到会话,但 API 侧只能取回经 API 创建的轮次结果——避免以委托身份逐个猜标识拉取该用户在工作台内的全部对话。
+- **三个对外面各说各的适用范围**(AC-10):既有 `POST /api/v2/assistant/chat/completions` 是助手外壳的 Chat Completions 形态、本次不动;F051 是模型协议面的 Chat Completions 形态(另一数据面,承接"拿通用 OpenAI 客户端直连"的诉求);本面是应用级会话面、形态归 design。三者在文档中各说各的适用范围,不互相顶替——**本面不再承担 OpenAI 兼容职责**(伴生 v2.3 D9 / §7.5)。
+- **模式 D 是主场,不可只出模式 S**(依赖 F050):纯模式 S 跑得起来,但服务账号的个人知识库为空、会话在平台 UI 里无人可见,场景二的核心承诺(AC-20)落不了地;本 Feature 与 F050 同期发版,spec 里凡引用两身份头判定处均以 F050 为准。
+- **第二套并行契约已在伴生 PRD 层面关闭**(D12 + §六非目标;本 spec §4 决议-2 与之一致):不做 Chat Completions 薄兼容层,也不留占位。它承载不了平台工具与知识库引用,交付即等于让客户在「能力不全」与「再学一套」之间二选一。「第三方生态支持度」属**待验证假设而非已确认诉求**;想拿通用 OpenAI 客户端直连的诉求由 F051 `model:invoke` 面承接;将来若出现真实客户场景,单独立项重估,不改本面契约;自建后端直调 HTTP 不受影响。
+- **审计**:本面调用频率介于管理操作与模型面之间,逐次记录按开放 API 通用口径进审计(AC-24);具体落点、写路径与 F051 逐条模型调用记录的关系属 design(DM8 写放大教训沿用)。
+
+---
+
+## 4. 待澄清决议
+
+> 2026-08-17 用户授权全自动模式:以下各项按建议直接定案,记录理由与备选供追溯;spec 评审(`/sdd-review`)后如有 high 级发现再增补。Discovery N2「F058 是否进 v3.0.0」在此复述为决议-1。
+
+- **[决议-1] 版本层登记为 v3.0.0 Feature、排期由产品定;与 F050 同期、不可只出模式 S;不在 MVP-114 纵切上。** 理由:伴生 PRD §1.4 明确「日常模式会话开放依赖身份传递、两项须同期交付」且要求估算重算(会话列表已移出,应下调);PRD-1 全册零引用,Discovery N2 已定「登记为版本层 Feature、排期由产品定」。备选 = 直接排进 v3.0.0 必做——PRD-1 主线(应用工场)不依赖它,强绑会拖累 MVP。**何时重新考虑**:产品确认场景二客户(如中粮式 OA 嵌入)的交付窗口时。若本 Feature 不随 v3.0.0 发版,`chat:invoke` 位仍在 F049 清单中并保留「端点随后续版本开放」标注(决议-10)、附录 B.1 中日常模式会话相关行随之顺延,其余 v2 开放 API 契约不受影响。
+- **[决议-2] 不为本能力交付第二套并行契约(如 Chat Completions 薄兼容层)。**(2026-08-17 伴生 **D12** 已把它从「待客户场景决定」改判为明确不做并写入 §六非目标;2026-08-18 随 D9 泛化为形态无关的规则)理由:薄层承载不了平台工具与知识库引用,而这两项是日常模式相对裸模型的全部增量价值——交付一个功能残缺的第二契约,等于让客户在「能力不全」与「再学一套」之间二选一;平台内已有两个 OpenAI 形态的对外面(既有助手端点 + F051 模型协议面),再加第三个只会加深「哪个端点该用哪个」的困惑;「第三方生态支持度」目前是**待验证假设而非确认诉求**,且该诉求本就由 F051 承接。备选 = 同期做薄层(无平台工具、无引用)——多一条契约多一份映射维护,且能力残缺,否决。**何时重新考虑**:出现确认的客户须直连某生态工具而 F051 面也无法满足时,单独立项重估,不改本面契约、不在本版留占位。
+- **[决议-3] 取回轮次结果、会话标识与附件引用的归属校验基准 = 会话归属主体(模式 S:服务账号 + `X-Bisheng-End-User` 分区键;模式 D:被代表用户),不按单把密钥;不匹配、跨租户、不存在一律 404 且形状不可区分;只能取回经本 API 创建并留存的轮次结果,标识为不透明值。多轮以「会话标识」表达,不以「上一轮标识」表达。**(2026-08-18 随伴生 D9 改判修订:原「`previous_response_id` 只允许指向会话最新、指向历史 → 400」是 Responses 形态的产物,改用会话标识后该分支自然消失)理由:平台会话线性追加、工作台无分叉,用会话标识表达多轮与产品语义天然一致,也省掉「最新 / 历史」的一整套判定;伴生 §4.6.3 只定了「外部标识不匹配返回 404(不用 403)」,须补齐基准——密钥可轮换、一个服务账号可多把密钥,按密钥校验会让轮换后旧会话失联;模式 D 下会话本就归被代表用户(工作台可见),同一用户经不同委托密钥取回同一轮次结果与其在工作台看到的一致。404 不区分「不存在 / 不属于你」是为了不泄漏存在性(与伴生 AC-8 / AC-41 同一取向)。「只取回 API 创建的」是防以委托身份逐个猜标识拉取该用户在工作台内的全部对话。备选 = 按密钥校验(更严但轮换即失联,否决);备选 = 模式 D 下允许取回该用户工作台任何轮次(把「不设 session:read」从后门打开,否决);备选 = 保留「上一轮标识」机制——多一套「是否最新」的判定与错误分支,而调用方并不需要分叉能力,否决。
+- **[决议-4] 分区键在本 Feature 两个有会话语义的能力(发起对话、上传附件)上按 F050 AC-12 写入;本版不作为任何列举 / 过滤维度、不经任何端点返回,仅用于归属一致性校验(取回轮次结果 / 会话标识 / 附件引用三处);缺省 WARN 不阻断(F050 AC-13)。与 F050 AC-16 的分界:无会话语义的端点携带 `X-Bisheng-End-User` 只留审计、不产生其它效果,而本面两项能力有会话语义、走 F050 AC-12 的写入并在本 Feature 内消费。** 理由:伴生 §4.6.3「仍然要写分区键,尽管本次没人读它」——一次性成本极低,将来补「按终端用户查询会话」时存量会话已带维度、不必回头补数据;`26014` 强制约束随会话列表端点一并移出(串档成立条件不齐)。备选 = 本版不持久化——将来补数据成本高且存量不可补,且场景三里访客 A 就能拿到访客 B 的结果,否决;备选 = 严格「只写不读」连归属校验也不读——那取回单轮结果在模式 S 下就只剩服务账号一层,同一服务账号下的全部访客互相可读,否决。**红线**:将来若增加任何按用户 / 外部标识列举会话的端点,必须先把缺省行为改为强制(AC-23 / AC-27 守门口径)。持久化落点属 design(伴生附录 E.3 有建议)。
+- **[决议-5] 契约外字段处置 = 白名单式接受、契约外 400 指名字段;明确拒绝清单:要求本轮不留痕、在请求体内另传终端用户标识、采样 / 生成参数(temperature / top_p / 最大输出长度等)、任务模式专属入参(技能选择等)、历史消息 / 角色结构、内联文件数据与外链文件 URL;「接受但不生效」档只容忽略后不改变可观察行为的字段、须在本 spec 枚举、**本版清单为空**。** 理由:核心教训是「调用方按直觉传了、平台静默降级」——同名不同义的工具声明就是这样静默变成无工具对话;本面对任何会让调用方误以为生效的字段一律明确拒绝。「不留痕」与平台会话机制矛盾(不留痕请走 `model:invoke`);请求体内的终端用户标识与 `X-Bisheng-End-User` 是同一语义的两个来源、只留请求头一处;采样参数在平台工作台链路里由模型配置决定、本版不做逐次透传(透传 = 新能力,且工作台自身也不支持逐轮改采样);塞历史消息 = 无状态用法,与「服务端会话」的有状态设计双真相;内联 / 外链文件绕开平台上传与解析链路、外链还是新的出站请求面。备选 = 采样参数「接受但不生效」——违反「不改变可观察行为」判据,否决;备选 = 采样参数透传——待平台工作台链路支持逐轮采样时再开。**何时重新考虑**:工作台链路支持逐轮采样参数、或出现须回传历史的真实集成场景时,逐项从拒绝清单移到已实现清单,不改既有字段语义。
+- **[决议-6] 平台特有内容一律用 BiSheng 自有命名,不冒用任何第三方契约中已有名称的语义**(含路径名、工具类型名、对象名);平台工具、知识库挂载、附件引用、引用承载的具体形状属 design;**由调用方执行的工具本版不支持、明确拒绝**。(2026-08-18 随伴生 D9 去掉具体字段名,规则不变)理由:占用同名等于做出兑现不了的兼容承诺(伴生 §4.6.3 五);平台工具是服务端执行、调用方工具是客户端执行,方向相反,本面所复用的平台会话链路没有「返回待执行调用、等调用方回传结果」的往返语义,支持它是新能力而 §3 三类场景无此诉求。备选 = 把知识库映射到某第三方契约的检索工具名——调用方会顺着去找它的配套资源族,兑现不了,否决。**何时重新考虑**:出现须由调用方执行工具的真实场景时,按往返语义补做,不改平台工具语义。
+- **[决议-7] 业务上下文指令叠加于平台工作台既有系统提示词之上,不替换、不关闭;调用方不需要、也不允许自行拼装 system message。** 理由:`chat:invoke` 与 `model:invoke` 的分界之一就是「走平台会话机制、注入平台提示词」(伴生 §4.2.4 扩展位表;F051 AC-17 反向对照);平台提示词承载安全与产品约束,允许调用方替换即让一把 `chat:invoke` 密钥拆掉平台围栏。备选 = 让调用方整体替换平台提示词——等于「便宜版模型面 + 留痕」,与两位互不蕴含的取向相悖,否决;备选 = 完全不接受调用方指令——场景二的自建系统常需注入业务上下文(当前页面 / 当前工单),拒绝会逼调用方把指令塞进用户输入,否决。叠加的具体位置与优先级属 design。
+- **[决议-8] 模型必填、按名解析;可用范围 = 平台工作台配置中对执行身份可用的对话模型;名称基准(模型管理页原名精确匹配)与跨服务商同名歧义规则(拒裸名 + 给限定名 + 不取默认)与 F051 决议-2 / 决议-3 同源。** 理由:缺省回落到「工作台默认模型」是又一处调用方不知道自己在用什么的静默行为;工作台链路本就以工作台配置为可用范围(与 F051 的模型管理页全集不同,各按其产品面),但同一个模型名在两面若解析成不同服务商就是「不报错只错」,故名称与歧义规则必须同源。备选 = 本面按平台内部模型 id 选模型——把内部标识写进对外契约(违反 AC-13)、与 F051 的按名解析割裂,否决。
+- **[决议-9] 流式与非流式同时支持;非流式返回的结果与同一请求流式执行的最终结果内容一致;失败只有一种真相(流式 = 错误事件 + 失败终态事件,非流式 = 错误响应),失败轮次不留存。缺省返回方式由 design 决定并写入文档。**(2026-08-18 随伴生 D9 去掉「缺省非流式因 Responses 契约如此」的形态依据)理由:两种返回方式只是传输差异、不是契约差异,成功结果必须一致;非流式是自建后端最省事的接法(一次请求一个结果),只支持流式会把简单集成场景挡在门外;失败若在非流式下以「带失败状态的正常结果」返回、在流式下以事件返回,同一失败就有两种真相,且会话历史里会留下半条消息、下一轮续接语义含糊——统一为错误响应 + 不留存最干净。备选 = 只支持流式——挡住简单集成场景,否决;备选 = 失败也留存供取回——引入「可取回但不可续接」的第三种状态,否决。非流式的超时与长响应处理属 design。
+- **[决议-10] `chat:invoke` 位归 F049 权限位清单、始终在清单中(伴生 §4.2.4 基础位);本 Feature 只拥有该位的消费端点,上线时让清单中该位的说明与覆盖端点指向本 Feature 端点、去除「端点随后续版本开放」标注;不写「未发版则不出现」。**(2026-08-17 独立审查裁定,取代初稿「位随本 Feature 同期出现 / 未发版不出现」)理由:权限位清单是 F049 的对象,一个位「出现与否」由清单 owner 决定;F049 已在其范围边界写明「`chat:invoke` 的消费端点随 F058 开放,开放前签发表单对该位以说明标注『端点随后续版本开放』、其余行为同普通位」——把「勾了没用」的困惑用标注化解,而不是让另一个 Feature 反向控制清单里某一位的出现,避免两个 spec 对同一位各写一套规则。备选 = 初稿的「未发版不出现」——需要 F049 清单按 F058 发版状态动态增删位,形成反向依赖,否决。
+- **[决议-11] 附件只经上传端点获得标识;发起对话时的附件项只接受平台标识、不接受内联数据与外链 URL;类型 / 大小 / 数量限制沿用平台工作台既有上传限制;不新增独立配额。** 理由:伴生 §4.6.3 二 / 三.6 把附件定为「只走上传端点」,附录 E.3 给出复用映射,平台的解析 / 摄取链路以上传为入口;外链拉取是新的出站请求面(与 F054 出站白名单取向相反);限制沿用工作台是为了同一个租户里两个入口的附件行为一致、也不为开放 API 另设一套配额(伴生 §4.8 配额按密钥且 P2)。备选 = 接受内联 base64——绕开上传链路且大 payload 直接压在会话请求上,否决。附件的生命周期与清理属 design、沿用工作台既有口径。
+- **[决议-12] 异步分支的两项能力(取消、断点续传)形态上留位、调用返回明确「本版未开放」(错误码归 design,与 `26015` 同族或复用);不做会话列表 / 历史 / 反馈 / 回填;不占用任何第三方契约的兼容路径名;F049 已关闭的 6 个 `/chat/*` 不复活。** 理由:伴生 §4.6.3 一 / 六要求「所选形态须使这些能力将来能加进来,而不必推翻已开放部分」——留位是这条要求的载体,但留位端点若静默成功或 5xx 会让调用方误判;会话列表与 `session:*` 已于 2026-08-16 整体移出(平台存会话 ≠ 给调用方开读接口),本 Feature 是最容易把它们「顺手」加回来的地方,故显式写成 AC-27 守住。备选 = 留位端点直接 404——调用方分不清「路径打错」与「本版未开放」,否决。**何时重新考虑**:四条技术前置消化后点亮异步分支,届时只补异步分支与两项能力的实现,不动同步契约。
+
+- **[决议-13] 「运行模式」(日常 / 任务·灵思)与「传输方式」(同步 / 异步)是两个独立维度,不得合并成同一个开关;两者各自缺省、各自校验、各自拒绝、各自留位(AC-37 / AC-25),错误码 `26017` 与 `26015` 不合并。承载字段归 design。**(2026-08-17 伴生 **D11** 裁定;2026-08-18 随 D9 去掉具体字段名,规则不变)理由:① 内部 `task_mode` 的两个分支 `_task_mode_stream_completion` / `_agent_stream_chat_completion` **都是同步 SSE 流**(`chat_service.py:2148-2150` / `:2158`),任务模式真正的异步在后续 start-execute + WebSocket 段,把「模式」映射到「同步/异步」连现状都不成立;② 调用方打开异步的动机通常只是躲网关 / LB 超时,若它同时切到灵思,拿到的是耗时分钟级、计费不同、输出形态不同(产物、`ask_user` 中断)的另一种能力,还会挤占全租户共享的灵思 worker 池——这正是本 spec 反复守的「静默降级」失败模式(决议-5 / 决议-6 同源);③「日常模式 + 异步」是已识别的后续需求,一旦被模式占位就只能破坏性改契约。备选 = 合并成一个开关——省一个字段,但换来上述三项,否决。**本版增量成本≈0**:两次枚举校验 + 两个错误码,不引入新链路。**何时重新考虑**:任一维度点亮时按其自身前置推进,不改另一维度语义。
+
+
+- **[决议-14] 对外契约形态由 design 决定,spec 只写业务语义。** 字段名、事件名、端点路径、以及是否对标某个第三方契约(OpenAI Responses / Chat Completions / Dify 风格 / 平台自定义)均不在本 spec 范围内;本 spec 的 AC 只约束「调用方必须能表达什么」「平台必须保证什么」,形态选型的决策、备选与否决理由写入 design.md §6。**连带取消 AC-04 原有的「响应体可被 OpenAI 官方 SDK 反序列化」要求**,改为「通用 HTTP 客户端即可集成 + 可直接运行的调用示例」。(2026-08-18 伴生 PRD v2.3 **D9 改判**,展开见伴生 §7.5;本 spec 初稿及第三次同步中「OpenAI Responses 契约子集」的全部表述随之作废)
+  理由:① **层级**——wire format 是 How,写进 spec 会让形态的任何调整都要回头改 spec 与伴生 PRD 两处;② **语义错配**(比工作量更根本)——Responses 是模型侧契约,其入参是 messages / role / system message / 采样参数,而本 Feature 是应用侧能力,调用方真正要表达的是「问什么、在哪个会话、挂哪些知识库、开哪些工具、带哪些附件」;强行对标就是要求调用方按模型层概念组织应用层请求(研发反馈的「开发者还要传 system message」即此症状);③ **兼容承诺已被自身的拒绝清单掏空**——决议-5 / 决议-6 一路拒绝掉不留痕、终端用户标识、采样参数、调用方工具、消息历史、内联与外链文件,再加不实现完整资源族,加起来已是形似神不似,SDK 能反序列化 ≠ 调用方能按其习惯用法使用;④ **「一套契约覆盖日常 + 任务」这条决定性论据已站不住**——决议-13 已查明 `ask_user` 与 MinIO 产物 Responses 都表达不了,而业务型契约天生能表达(同类产品把「任务已暂停、等待人工输入」直接做成一等状态)。
+  备选 = 维持「Responses 子集」写死在 spec——保住「官方 SDK 可直连」的宣传口径,但该口径的兑现度已经很低,且要付对象模型保真、事件类型对齐、SDK 反序列化验收、完整拒绝清单的持续成本,否决;备选 = spec 直接改指定 Dify 风格——只是把一个形态换成另一个形态,同样越位,否决。
+  **未放宽的地方**:伴生 §4.6.3 三的八条硬边界(一套契约 / 不静默降级 / 两个独立维度 / 流式语义可分 / 有状态会话 / 附件只走上传端点 / 归属与分区 / 逐项文档)全部保留,仍是验收依据;design 若评估后仍选 OpenAI 形态,属被允许的技术选择,但须在 design.md 写清理由,且它**不再是产品承诺、不进对外宣传口径**。
+  **何时重新考虑**:出现「必须由某个第三方生态工具直连本面且 F051 `model:invoke` 面无法替代」的确认客户场景时,重新评估形态——但那属于 design 层的改型,不需要动本 spec 的 AC。
+
+---
+
+## 5. 设计与实现(指针,不复制)
+
+> **本节刻意不写内容。** spec 不承载 How。
+
+| 你想知道 | 去哪看 |
+|---|---|
+| 为什么这么实现(如何复用平台既有会话链路、response 与平台会话 / 轮次的对应方式、非流式返回的构成、决策 + 备选 + 何时该推翻) | design.md §3 |
+| 关键约束(凭据校验路径复用 F049 / 两身份头判定复用 F050 / 多租户 / 双 DB / 错误码段 `260` 与对外错误形状映射 / 分区键持久化) | design.md §2 |
+| 今天的数据流、模块职责、字段 / API / 文件名约定(**对外契约的完整形态**:端点路径、请求与返回字段、会话标识与轮次标识形式、知识库挂载与附件引用形状、事件类型命名、限定名形式、留位端点与「会话忙」的错误码、运行模式与传输方式的承载字段) | design.md §4 |
+| 代码里看不出的坑、反直觉事实 | design.md §5;伴生 PRD 附录 E.3 |
+| **对外契约形态的选型与理由**(决议-14 授权 design 决定:字段名 / 事件名 / 端点路径 / 是否对标某第三方契约 + 备选与否决理由)、已实现清单供对外文档、`chat:invoke` 位说明与覆盖端点的更新供 F049 清单配置、审计接线消费 F050 AC-35 / 36、改了会破坏谁、依赖谁 | design.md §6 |
+| 性能 / 安全 / 可观测非功能指标(流式首字延迟、非流式超时、审计写路径) | design.md §2 + §7 |
+| 任务拆解、文件清单、执行顺序、踩坑落档 | tasks.md |
+
+---
+
+## 相关文档
+
+- 设计真相: [design.md](./design.md)(接手第一入口,尚未编写)
+- 执行与落档: [tasks.md](./tasks.md)(尚未编写)
+- 版本契约: [features/v3.0.0/release-contract.md](../release-contract.md)(表 3 F058 行为本 Feature 范围的权威来源;表 1 ApiCredential / ServiceAccount 归 F049、本 Feature 只读凭据主体与权限位;INV-27 / INV-28 / INV-30 直接相关;错误码 260 段 `26015` 已登记,**`26017` 随伴生 D11 新增、须同步登记**)
+- MVP 纵切: [features/v3.0.0/mvp-114-path.md](../mvp-114-path.md)(F058 不在纵切上)
+- Spec Discovery: [features/v3.0.0/000-prd1-discovery/discovery.md](../000-prd1-discovery/discovery.md) §2.1(v2 开放 API 现状)/ §2.5(既有 `/api/v2/assistant/chat/completions` 是助手外壳)/ §4.2 N2(F058 登记决议)
+- 同版 spec: [../049-openapi-auth-baseline/spec.md](../049-openapi-auth-baseline/spec.md)(范围边界「`chat:invoke` 消费端点随 F058 开放、开放前标注」、AC-01 / 03 / 04 / 07 / 21 / 34 凭据 / 租户 / fail-closed、AC-13 / 44 权限位清单与表单、AC-25 模式 S 会话不进归属人列表、AC-30 六个 `/chat/*` 不暴露、AC-36 两层可区分错误、AC-49 服务账号模块恒在、§2.7 主体侧授权);[../050-identity-modes/spec.md](../050-identity-modes/spec.md)(AC-02 模式 S 判据、AC-04 / 14 / 30 两身份头判定与 `26010` / `26016`、AC-07 端点允许模式声明、AC-11–13 / 15 / 16 外部标识语义 / 分区键写入 / 缺省 WARN / 语义声明 / 无会话语义端点分界、AC-35 / 36 逐调用审计与双归属、AC-38 模式 D 会话归属);[../051-model-protocol-gateway/spec.md](../051-model-protocol-gateway/spec.md)(AC-06 互不蕴含、AC-11–13 模型名基准与歧义规则、AC-17 / 19 与本面数据面对照、AC-28 开放能力层开关对照、§4 决议-2 / 决议-3)
+- PRD: `docs/PRD/3.0-beta2/3.0 开放 API 鉴权与身份传递 PRD.md` **v2.3**(伴生册,本 Feature 的需求真相:§1.4 / §3.3–3.4 / §4.2.4 / §4.3.4 / §4.5 定义 6 / **§4.6.3 全节(业务入参清单 + 八条硬边界 + 形态归 design)** / §4.8.1 / §六非目标(不做第二套并行契约、不承诺官方 OpenAI SDK 可直连)/ §五 AC-32 / 34 / 35 / 36 / 50 / §七 D9 改判 + **§7.5 展开** / D11 / D12 / 附录 B.1 / C / E.3);《3.0 应用工场 PRD-1》v2.0 与本 Feature 无直接关联(仅 DEV-01 权限位清单同表)
diff --git a/features/v3.0.0/059-k8s-runtime-backend/cluster-preflight.md b/features/v3.0.0/059-k8s-runtime-backend/cluster-preflight.md
new file mode 100644
index 0000000000..843cba9c60
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/cluster-preflight.md
@@ -0,0 +1,41 @@
+# 189 K8s只读盘点(2026-10-21)
+
+## 实测事实
+
+| 项目 | 结果 |
+| --- | --- |
+| 主机 | 192.168.106.189,用户指定的专用K8s验收环境 |
+| Kubernetes client/server | v1.28.15 / v1.28.15 |
+| 节点 | binfeng-k8s-node189,Ready,control-plane/worker,amd64,无taint |
+| OS/内核 | CentOS7,3.10.0-1160.119.1.el7.x86_64 |
+| CRI | docker://24.0.9 |
+| 节点可分配资源 | cpu7600m;memory63656577179(kubectl原始Quantity) |
+| CNI | calico-node与calico-kube-controllers运行;未执行隔离效果探测 |
+| StorageClass/PV/CSIDriver | 均为空 |
+| RuntimeClass/ResourceQuota/NetworkPolicy | 均为空 |
+| 工作负载 | 仅9个kube-system Pod;无业务工作负载 |
+| 命名空间 | default、kube-node-lease、kube-public、kube-system、kubekey-system |
+
+命令:kubectl version -o json;get nodes、storageclass、pv、csidriver、runtimeclass、resourcequota -A、namespace、pods -A、networkpolicy -A。未读取kubeconfig/Secret内容,报告不包含认证凭据。
+
+## 对后续验收的影响
+
+1. 持久数据需要先明确供应方式。可以为本套件预置有节点绑定的专用本地卷;不能假定已有动态CSI,也不能宣称本地卷可跨节点故障迁移。
+2. 镜像需验证冻结引用能被该Docker CRI实际识别;准备机下载和节点导入并非全部前置。
+3. 需要在本套件资源上实测网络允许/拒绝、准入和RBAC。未安装RuntimeClass仅能验证默认隔离及不可用档位的明确回退,不能宣称gVisor/Kata已运行。
+4. 单节点无法证明跨节点重调度/物理节点故障切换;该部分必须列为环境限制而非通过。
+5. 截至本报告,没有创建Namespace、PV/PVC、Pod/Job或修改节点配置,没有进行任何应用部署。
+
+## 设计核对资料
+
+- [K8s initContainer](https://kubernetes.io/docs/concepts/workloads/pods/init-containers/)支持顺序初始化和卷内文件交接;具体材料投递与授权要另行设计。
+- [emptyDir](https://kubernetes.io/docs/concepts/storage/volumes/#emptydir)随Pod回收,可重建代码/依赖缓存与持久数据库必须分开。
+- [卷访问模式](https://kubernetes.io/docs/concepts/storage/persistent-volumes/#access-modes)中RWO仍允许同节点多Pod,不能作为单写证明;RWOP有CSI前置,本环境没有CSI。
+- [Pod Security Standards](https://kubernetes.io/docs/concepts/security/pod-security-standards/)用于核对工作负载准入;RBAC单独不能表达所有Pod字段限制。
+- [BuildKit rootless前置](https://github.com/moby/buildkit/blob/master/docs/rootless.md)用于后续兼容构建器选型,本次没有执行构建器。
+
+## 2026-10-22 只读刷新
+
+通过专用临时known_hosts和交互认证连接,未写入凭据。client/server均为v1.28.15,binfeng-k8s-node189为Ready,amd64、Docker24.0.9;命名空间仍只有default、kube-node-lease、kube-public、kube-system、kubekey-system。StorageClass/PV仍为空。`/var/lib/kubelet`和`/opt`所在文件系统约有494GiB可用。
+
+本次仅执行version/get nodes/get namespaces/get storageclass/get pv/df,未安装资源、未修改kube-system或节点配置,不能作为应用部署和网络/存储能力验收。
diff --git a/features/v3.0.0/059-k8s-runtime-backend/code-review.md b/features/v3.0.0/059-k8s-runtime-backend/code-review.md
new file mode 100644
index 0000000000..a1591b0920
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/code-review.md
@@ -0,0 +1,29 @@
+# F059 开发合入 Code Review Report
+
+**Overall:PASS_WITH_WARNINGS(仅开发整合范围);完整交付验收门仍未结束。**
+
+日期:2026-10-23。目标基线 `feat/3.0.0-vibe@4aa9ce69b2830b81162fe631b9c801057c40ee8b`,运行实现源 `cda8dccf26298af91a1df277dcd1a49787b6b444`。用户明确授权先开发收尾、合并推送并仅定向验证。本报告不改变规格、不宣称全 AC 或正式发行通过。
+
+## 七维汇总
+
+| 维度 | 未关闭 HIGH | 结果 |
+| --- | --- | --- |
+| 边界条件 | 0 | PASS |
+| 权限与认证 | 0 | PASS |
+| 并发安全 | 0 | PASS:CI 同 release 跨 ref/Tag/manual 共享锁修复 |
+| 信息泄漏 | 0 | PASS(限定变更范围) |
+| 测试覆盖 | 0(开发范围) | PASS_WITH_WARNINGS:完整离线/剩余故障/发行验证另列 |
+| 风格与分层 | 0 | PASS |
+| 文档同步 | 0 | PASS:D1 现状快照/已知坑已修并定向复审 |
+
+## 审查及验证
+
+runtime、backend、交付三条分工只读审查,root 整合;无新运行/后端代码阻断。D1 设计旧当前快照已关闭;CI 并发项 T274 已修复。只重跑 CI 单条契约(先失败后 1 passed)、冲突涉及模型合同 17 passed,以及 Ruff/架构/差异/索引/迁移静态检查;不重跑全量或远端。完整分工报告见 [整合证据](../059-k8s-runtime-backend/integration-review/README.md)。
+
+## 保留边界
+
+T067、T070、T071、T072 保留 pending;其中代码 L2 已完成,完整回归/故障/独立卸载、发行及逐 AC 最终交付汇总未闭合。当前 268/272。
+
+ARM 暂缓,DM8 仅静态;189 单节点不证明跨节点。旧测试 Tag 及旧 suite 只保留其原 source/date;R32 是 manager-only 私仓实证,不是最终版本全组件公开发行。旧失败/unknown writer 保持围栏,不为合并清理。CI 锁不控制仓库外写入;正式发行仍需新版本/权限或仓库不可变策略。
+
+[完整整合报告与来源](../../../docs/reviews/vibe-development-integration-2026-10-23.md)。
diff --git a/features/v3.0.0/059-k8s-runtime-backend/design-review.md b/features/v3.0.0/059-k8s-runtime-backend/design-review.md
new file mode 100644
index 0000000000..a6508592f9
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/design-review.md
@@ -0,0 +1,127 @@
+# F059 design 评审记录
+
+**当前结论:LGTM,R-D1、R-D2、R-D3 已在设计文本中闭环,用户已确认设计并授权进入 tasks。** 按用户“修订并复审”完成三项修订,再按 sdd-review 的 24 项 design-checklist 及场景矩阵复核。由同一代理完成,没有独立代理评审。本结论仅针对静态设计,不代表 K8s 功能、接口或故障验收已通过。
+
+- 日期:2026-10-21。
+- 代码基线:`755d4456e50c015a3fa88da0c0c35ab53bbd1650`;分支 `codex/059-k8s-runtime`。
+- 用户确认的设计正文 SHA-256:`b846e993909cda416502d33fdbf6b2174357068673840c2fc0f7d75fe9dc76a4`;确认后 design 的状态及实施现状随任务更新,上述摘要是审批快照,不代表后续实施版全文。
+- [spec.md](./spec.md) 全文保持已确认版本,SHA-256:`11991622ee73976a354bd533cb6691407780c99ce70fdf034212f6ea6928c13e`;55 条有效 AC 不变,AC-38 保留退役。
+- 严格格式结果:[design-review.txt](./design-review.txt) 为 `LGTM`。
+
+## 三项修订与复审闭环
+
+| ID / 原严重度 | 上轮问题 | 本轮修订与定位 | 复核结果 |
+| --- | --- | --- | --- |
+| R-D1 / high | 要求所有容器有终止记录/ID,init 失败和未启动容器可能永久占住 finalizer、预占和生产名称 | [4.5](./design.md#L183) 区分 never_scheduled、kubelet_terminal、unknown;终态来源由无副作用 status 规则约束;全部执行 Pod 保留 finalizer,收尾计划到清理确认后才关闭;D5、4.9、4.10、5、7.2 同步 | 文本闭环:初始化失败可按可信停止证据释放,不凭缺失 ID 放行,不放松 AC-49;覆盖 AC-08、AC-43、AC-52。实际集群生命周期与节点身份仍待验收 |
+| R-D2 / high | 数据接口只找在线 sidecar,停用后保留库无法查改导出 | [4.8](./design.md#L240) 在线同 Pod、停用独占 purpose=data;新增限时内部 data 操作,空生产位置/互斥/UID确认后签 owner 动作票据;完成后停 helper、保留 PVC及停用态;定义结果未知的只读查询和并发拒绝 | 文本闭环:F054 AC-56、F086 AC-47、F059 AC-36、AC-37 和 INV-33 一致;不额外运行业务实例、不绕过单写或自然人 owner 边界 |
+| R-D3 / medium | 材料签名输入和分块 HTTP/失败重试不完整 | [6.2.1](./design.md#L411) 固定 material-v1 的头、11 行输入、类型、body/ticket 摘要、方法/路径/偏移、会话 JSON、分块、finalize、续期及错误;增加 414-byte 编码向量 | 文本闭环:client/backend 不再猜测 wire format;AC-15、AC-47 的范围与重放合同具备可实现输入。只校验了编码向量,真实签名互验/授权与传输仍待开发 |
+
+停止依据采用 [K8s v1.28.15 TerminatePod](https://github.com/kubernetes/kubernetes/blob/v1.28.15/pkg/kubelet/status/status_manager.go#L380-L486)、[终态延迟规则](https://github.com/kubernetes/kubernetes/blob/v1.28.15/pkg/kubelet/status/status_manager.go#L965-L1029)、[删除中 Pod 的绑定校验](https://github.com/kubernetes/kubernetes/blob/v1.28.15/pkg/registry/core/pod/storage/storage.go#L221-L232) 及 [AdmissionReview 的身份/子资源合同](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#request)。status 来源限制是本次设计,不是已经安装或已探测的配置。
+
+现有数据边界重新核对 [AppDataService](../../../src/backend/bisheng/app_runtime/domain/services/app_data_service.py#L142)、[节点应用 scope](../../../src/backend/bisheng/app_runtime/domain/services/runtime_operation_service.py#L374)、[单应用数据 API](../../../src/runtime-manager/runtime_manager/api/platform_data.py#L16) 和 [Docker DataExecutor](../../../src/runtime-manager/runtime_manager/data_executor.py#L68)。材料分块规则对照 [RuntimeArtifactService](../../../src/backend/bisheng/app_runtime/domain/services/runtime_artifact_service.py#L146),保留其持久 offset/摘要/SQL 确认原理,新增 workload 认证不冒充已实现。
+
+## 场景交叉复核
+
+以下都是设计推演结果,尚未执行真实部署或故障注入。
+
+| 场景 | 修订设计的结果 | 追溯 |
+| --- | --- | --- |
+| 未调度 Pod 超时/取消 | 同 UID 删除时间+空 nodeName 的新鲜对象,绑定禁止条件成立后确认 never_scheduled;没有这些证据不释放 | 4.5;AC-08、AC-43、AC-52 |
+| 首个 init ImagePullBackOff | DELETE 后由绑定 kubelet 提交受保护终态,未启动 app/helper 不强求 ID;再移除 finalizer 和释放预占 | 4.5、4.9;AC-09、AC-52 |
+| material-fetch 拒绝或 environment 初始化失败 | 同一停止分类;不得用 PodInitializing 直接推定可清理,也不因 Waiting 状态永久阻塞正常终止 | 4.5、4.6;AC-47、AC-52 |
+| 正常停止已运行实例 | UID、删除事实、status 来源/终态和新鲜 API 结果共同确认;之后才允许替换或维护 | 4.5;AC-49、AC-50 |
+| NodeLost、404、同名异 UID | 停止未知,保留归属/预占及生产卷,不补第二写入者 | 4.5;AC-49、AC-52 |
+| 节点控制器合成 Failed | status 规则拒绝非绑定 kubelet 的终态转换,不计停止证明 | 4.5、4.9;AC-13、AC-49 |
+| status 验证器暂不可用 | API 状态更新/确认延迟,manager 不误释放;本地停止不依赖 backend 回调,恢复后对账 | 4.5、4.9;AC-07、AC-52 |
+| 临时 Pod 完成期间 manager 被杀 | finalizer 保留对象,恢复后读取同 UID 终止证据;操作终态不撤销同 UID 收尾计划 | 4.5;AC-48、AC-52 |
+| 在线 owner 查改导出 | 原业务授权后在现有 Pod 内执行,与维护互斥,不创建第二挂卷实例 | 4.8;AC-36、AC-37 |
+| 停用后 owner 查改导出 | purpose=data 独占空生产位置,冷启动纳入 60 秒,结束后停止/清理 helper,仍停用且保留 PVC | 4.8;AC-35、AC-36、AC-43 |
+| data 操作与迁移/部署竞争 | SQL 互斥+UID 账本+固定名称拒绝并发,不以 helper 忙为由额外挂卷 | 4.5、4.8;AC-48、AC-50 |
+| 数据编辑结果未知/票据到期 | 不自动重做编辑;重新授权的只读请求指向旧 request_id/journal,不能复活写票据 | 4.8;AC-36、AC-48 |
+| 准备完成后合法部署重新初始化 | deployment-init 依据当前部署和冻结材料授权,独立于原 prepare 终态 | 4.7;AC-15、AC-47 |
+| 旧 ticket 过期或归属撤销 | 旧 ticket 直接拒绝;新 exchange 必须新 nonce/签名并重验当前归属,撤销后不续发 | 4.7、6.2.1;AC-47 |
+| 改方法/路径/偏移/请求体/ticket、重放 nonce | 固定字节验签及范围/nonce校验拒绝;不从 body 接收新的租户/应用范围 | 6.2.1;AC-15、AC-47 |
+| 分块/finalize 响应丢失 | 新签名查持久事实,同字节不重复追加,冲突拒绝,完整材料确认前不执行 | 6.2.1;AC-46、AC-47、AC-51 |
+
+## 24 项检查
+
+| # | 检查 | 本轮依据 | 结论 |
+| --- | --- | --- | --- |
+| 1 | 目标简洁 | 1:K8s 执行后端,复用平台流程和存储 | 通过(静态) |
+| 2 | 非目标明确 | 1、8:不含商业、多副本、形态迁移、新 UI、任意 Dockerfile/SQL | 通过(静态) |
+| 3 | 特有约束 | 2:1.28/189、存储、三种交付、单写、执行层失联边界 | 通过(静态) |
+| 4 | Constitution Check | 2.1、4.9、4.10:C1–C8 落点保持;status 验证器不持集群身份、不编排资源、不写停止事实;无设计层 BLOCKER | 通过(静态) |
+| 5 | 关键决策 | D1–D8:能力抽象、直接 Pod、材料、身份、单写、隔离、构建及安装权限 | 通过(静态) |
+| 6 | 备选/选定/原因 | D1–D8 每项保留被否方案、所选方案及具体原因 | 通过(静态) |
+| 7 | 重新考虑条件 | D1–D8 均含触发条件,未把默认选择写成不可改变铁律 | 通过(静态) |
+| 8 | 具体依据 | Docker 真实调用链、K8s 固定版本源码、既有盘点及存储/OCI 官方资料 | 通过(静态) |
+| 9 | 数据流 | 4.2、4.3:发布、预检、审批、维护、上线、访问与停用数据路径 | 通过(静态) |
+| 10 | 关键数据结构 | 6.2、6.2.1:资源证据、内部 data 操作、材料头/类型/JSON/编码及会话状态 | 通过(静态) |
+| 11 | 模块职责 | 4.1:现有 Docker 与拟新增 K8s 接口明确区分;业务授权仍由 backend Owner 完成 | 通过(静态) |
+| 12 | 现状准确 | 4.1、7.2:无 K8s executor/Chart/新接口实测声明,未来脚本明确标识不存在 | 通过(静态) |
+| 13 | 反直觉事实 | 4.5、5:从未启动不一定有容器 ID;普通 phase 不是来源证明;RWOP 与停用 helper 的差异 | 通过(静态) |
+| 14 | 后果 | 5 逐条说明双写、永久占用、停用数据缺失、身份/协议歧义等后果 | 通过(静态) |
+| 15 | 处理位置 | 5:k8s_lifecycle、准入、材料 client/auth、数据执行器等拟实施位置 | 通过(静态) |
+| 16 | Outgoing | 6.1、6.2、6.2.1:Python 能力、既有 HTTP、新材料协议与错误/重试;数据动作仍通过原业务接口 | 通过(静态) |
+| 17 | Incoming | 6.4:业务合同、K8s/准入、卷、工具、仓库、镜像/SDK与CI前置 | 通过(静态) |
+| 18 | 依赖风险 | 4.5、4.9、4.10、6.4:终态来源规则的认证/可用性前置,F086 修复继承与离线物料边界 | 通过(静态) |
+| 19 | 分层测试 | 7.1:55 条有效 AC 全覆盖;补三类停止证据、数据互斥、协议篡改与分块重试 | 通过(静态) |
+| 20 | 手动验证 | 7.2:189 专用资源、实际允许/拒绝、初始化失败与停用数据场景;新工具仍待实施 | 通过(静态) |
+| 21 | 后续与短板 | 8:不自动 fencing 接管、Pod 缓存成本、未验证平台/架构/存储限制保持 | 通过(静态) |
+| 22 | 修订历史 | 设计末尾登记初稿与本轮三项修订,不宣称实现完成 | 通过(静态) |
+| 23 | 规格一致 | R-D1、R-D2、R-D3 的文本冲突/合同缺口闭环;55 条 AC 正文及 INV 保持不变 | 通过(静态) |
+| 24 | tasks 偏差 | 当前仅阶段登记,设计复审通过但未获人审确认;无任务拆解/代码偏差 | 通过(静态) |
+
+## 追加审查(用户选择继续评审)
+
+**结论:维持 LGTM,本轮没有新增 high、medium 或 low ISSUE。** 2026-10-21,用户未确认设计,选择“继续评审设计”;本轮由同一代理再次检查修订后的执行链与现有代码,未修改 design 正文、spec 或功能实现。设计对象仍为上列 SHA-256,不能据此越过设计人审门。
+
+| 核查重点 | 核查依据与结果 |
+| --- | --- |
+| 停止分类是否放松单写 | 4.5 的 never_scheduled 要求同 UID、删除时间与空 nodeName 的新鲜对象;已绑定 init 失败须等待受保护的 kubelet 终态;404、NodeLost、租约到期仍为 unknown。没有把缺失 container ID 本身当作释放依据 |
+| status 验证是否引入新的回调死锁 | 4.5、4.9 的 status 规则只检查 AdmissionReview,不读取 backend、不直接保存停止事实;资源模板规则另读已提交计划。核对 K8s 官方终态延迟及 AdmissionReview 身份字段,没有把普通 phase 或外部回调自报身份当作证明 |
+| 收尾是否依赖已经终结的操作 | 所有执行 Pod 保留 finalizer,4.5 的只读收尾计划持续到同 UID 清理确认;停止事实与资源释放、finalizer 移除可按持久事实续办。控制器租约不被用作旧写入者死亡证明;晚到请求仍须按当前资源意图、代次及 UID 检查 |
+| 数据会话是否造成额外业务实例或自动重做编辑 | 4.8 在线复用同 Pod,停用会话独占固定生产位置;保留原应用态/serving 代次和 PVC。创建互斥事务提交后才唤醒 manager;超时不等于清理完成,未知结果通过新只读请求查询旧 journal,不重跑原编辑 |
+| 材料协议是否混淆重放与幂等 | 6.2.1 签名涵盖 method/path/query、原始 body 和 ticket 摘要;nonce 拒绝重放,传输 ID/持久 offset 负责幂等。重复相同分块与冲突分块结果不同,过期 ticket 不能单独续期,finalize 仍要求内容与平台记录确认 |
+| 新链路是否破坏既有代码边界 | 核对现有 RuntimeRecord/RuntimeTransfer、操作 claim/fact、owner 数据 Service、DataExecutor、archive_directory 与出口策略;新增接口和内部 data 操作仍明确为待实现,原服务 HMAC、业务权限、Docker 合同和平台存储 Owner 保持原归属 |
+
+额外复核:已确认规格全文摘要不变,55 条有效 AC 与 7.1 覆盖集合一致,D1 至 D8 的决策四要素完整,material-v1 的 414-byte 编码向量与已登记摘要一致,Git 空白检查通过。编码样例计算不是接口验签实测。
+
+实施和部署阶段仍需执行设计 7.1、7.2 已列的 manager 失联/并发控制器、init 拉取与材料失败、status 规则允许/拒绝、停用数据会话及数据动作超时、分块重试/撤销、Docker 回归等场景。这些是现有验收门,当前未执行;本轮没有新开“已验证”记录或声称 189 上线成功。
+
+参考:[K8s v1.28.15 的终态延迟](https://github.com/kubernetes/kubernetes/blob/v1.28.15/pkg/kubelet/status/status_manager.go#L965-L1029)、[AdmissionReview 请求合同](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#request)、[现有操作归属校验](../../../src/backend/bisheng/app_runtime/domain/repositories/runtime_delivery_repository.py#L290)、[现有材料安全归档](../../../src/runtime-manager/runtime_manager/bundle_inputs.py#L194)。
+
+## 审查历史
+
+- 初稿设计全文 SHA-256 为 `4eba2bedb1a9df443e78e4b7888c5e4c6f2042c7983347343f3d830df5809bad`,曾自审 LGTM;用户要求先评审后,发现 R-D1、R-D2、R-D3,原结论撤回。
+- 上轮仅更新未通过状态后的全文 SHA-256 为 `2a3c47761c21cfe758aca7407bc5b1583c50057d6dedaae42765896855b4a7d6`;原问题定位为旧版第 187–188 行、228–230 行、219/358–359 行。它们是上述修订的输入,旧行号不能用于当前文本。
+- 本轮用户明确要求“修订并复审”;三项修订及复核见本文当前结论。没有沿用旧自审 LGTM,也没有将用户的规格确认当作设计确认。
+
+初稿 DR1 至 DR8 的记录保留如下;其中 DR1、DR2 的遗漏已经由本轮 R-D1、R-D2 继续修订,表内原“闭环”描述不是当前评审的独立证明。
+
+| 发现 | 问题 | 初稿记录(历史,不是当前结论) |
+| --- | --- | --- |
+| DR1 | 单副本控制器、RWO 或 SQL 租约不能独自证明旧写入者停止 | D2、D5、4.5:直接管理固定生产位置;生产服务/维护互斥;UID 账本、finalizer 和具体容器终止证据;未知时保留阻塞,禁止 404/phase/租约替代证明 |
+| DR2 | 在线数据 helper 新开 Pod 会与 RWOP 冲突,恢复备份和结果记录也不能被应用修改 | 4.4、4.8:同 Pod helper;business/platform 子目录隔离;backend 业务层签 5 秒受理票据、helper 只持公钥,结果未知不重做写操作 |
+| DR3 | 短启动 deadline 无法在 ready 后延长为预览期限;正式 Pod 也不能继承准备期限 | 4.6、4.9、5:按照 1.28 源码校验区分任务/预览/正式期限,初始化自有阶段超时,管理恢复后有界清理 |
+| DR4 | manager 最小权限表缺 Service 就绪检查所需的 Endpoints/EndpointSlice 读权限 | 4.10、6.4:补 get/list/watch;不扩展 Node/PV/SC 或其它 namespace 权限 |
+| DR5 | 平台核心在集群外时无法默认访问 ClusterIP;CNI源地址也可能因转发变化 | 4.2、6.3:仅安装者发布共享 entry/control 私网端点、限制来源及 HMAC,实测转发/ACL;不给单应用暴露节点端口 |
+| DR6 | 准入回读 backend 与持锁创建资源可能循环等待;候选取材料也不能要求已经正式 serving | 4.5、4.7:提交资源意图后才调用 K8s;候选按有效已批准 deploy/recover 归属,正式续期按 serving 归属,均须精确冻结及 UID 绑定 |
+| DR7 | 泛用 rootless builder、直接 OCI tar 追加会引入内核前置和越界层/凭据风险 | D7、4.6:固定 profile 准备+可信受限层组装,拒绝任意 Dockerfile、whiteout/设备/路径逃逸;受信 helper 不从应用目录导入模块 |
+| DR8 | kubelet 退避、helper资源及删除后日志会被过度承诺 | 4.9、7.3:持久化恢复起点及安全替换策略,helper单列预占;只读实际可保留的 app/previous 日志和诊断尾部,不承诺已删除完整日志 |
+
+## 文档检查和证据边界
+
+- 已确认 spec 全文摘要不变,55 条有效 AC 与 7.1 的映射集合一致;原 AC-38 不复用,AC ID 全写。
+- D1 至 D8 每项包含备选、选定、原因及重新考虑条件;原编号未变,只新增 6.2.1,核对标题、章节引用和文件链接。
+- material-v1 编码样例为 11 行、414 bytes;SHA-256 为 `206cf7e08bc12e23245980c45989fe8165233183e18bed03b99a8c17c6050167`。该计算仅证明文档样例编码一致,未进行真实 Ed25519 验签或接口调用。
+- 核对 Markdown 表格列、阶段状态与 Git 空白;189 盘点原始记录摘要未变:cluster.txt 为 `c619c88fb0168bde7ea5b5803dcd3adae084aa032ca2efeac447a2b5b426cb70`,storage.txt 为 `df49b8847ef1b9f406803eb5b2aa8f05c444bb3d8ef85c169f9116c312f0e0d2`。
+- 未修改功能代码、未访问或更改 189 集群,未执行部署、离线安装、数据恢复、授权或故障 E2E。新增组件、API、模型、status 保护和离线 data 会话均仍是待实施设计。
+
+依据根 AGENTS.md 第 6 节、SDD-Guide 第 3 节和 sdd-review,设计复审通过后仍须用户单独确认,才能进入 tasks 拆解/评审及编码。tasks 当前只登记阶段,不视为已拆解;正式平台验收仍须继承 discovery 中列出的 F086 必要修复。
+
+## 用户设计确认
+
+2026-10-21,用户明确回复“确认设计,进入任务拆解,继续推进 SDD 流程。”。仅更新设计状态元信息;D1–D8、接口、正文和55条规格 AC 不变。原等待确认的审查历史保留为当时状态;现进入任务评审与实施。
+
+实施状态另见task-reviews.md:T020/T021已完成编码/真实Ed25519互验;上述表记录批准设计时的审查闭环,不代表完整材料服务或K8s已交付。
diff --git a/features/v3.0.0/059-k8s-runtime-backend/design-review.txt b/features/v3.0.0/059-k8s-runtime-backend/design-review.txt
new file mode 100644
index 0000000000..b0e4e89c33
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/design-review.txt
@@ -0,0 +1 @@
+LGTM
diff --git a/features/v3.0.0/059-k8s-runtime-backend/design.md b/features/v3.0.0/059-k8s-runtime-backend/design.md
new file mode 100644
index 0000000000..18689a3a90
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/design.md
@@ -0,0 +1,851 @@
+# Design: F059 K8s 应用托管执行后端
+
+> **状态:规格、设计已确认;开发实现及逐任务 L1 已收口,按用户授权先整合开发代码。** 完整离线安装、剩余故障/卸载矩阵及当前版本正式镜像发行仍未全部验收;不能据本文件宣称全部 AC 已交付。当前合入结论见 [code-review.md](./code-review.md)。
+
+**关联**:[spec.md](./spec.md) · [tasks.md](./tasks.md) · [规格评审](./review-evidence.md) · [设计评审](./design-review.md) · [189 盘点](./cluster-preflight.md) · [版本契约](../release-contract.md)
+
+**版本**:v3.0.0;**最后更新**:2026-10-23;**设计起始基线**:`755d4456e50c015a3fa88da0c0c35ab53bbd1650`;**本次整合实现基线**:`cda8dccf26298af91a1df277dcd1a49787b6b444`。
+
+## 1. 目标与非目标
+
+实现 F086 运行操作合同的 K8s 执行后端,复用现有发布、审批、权限、SDK、入口和出站流程。应用包在执行节点准备和物化,生产 SQLite 使用独立 PVC,平台材料继续经 backend 保存及授权。
+
+本轮设计不改变平台核心的部署方式,不实现多副本、HPA、跨集群、Docker/K8s 迁移、商业额度、新业务管理页面、任意 SQL、任意宿主命令或用户任意 Dockerfile 构建。旧源码构建兼容模式仍产出 OCI 应用镜像;默认应用包不产出应用镜像。
+
+## 2. 关键约束与 Constitution Check
+
+遵循 [constitution.md](../../../docs/constitution.md) C1–C8,不在本文复制铁律。Owner 和不变量以 [release-contract.md](../release-contract.md) 为准。
+
+| 本功能约束 | 对设计的影响 |
+| --- | --- |
+| spec 55 条有效 AC 已确认,AC-38 退役 | 设计不得通过实现便利删减交付模式或放松隔离;偏差需重新确认 |
+| 189 是 K8s 1.28.15、amd64、单节点、Docker CRI、CentOS 7 内核 3.10 | 资源使用 1.28 稳定 API;不依赖原生 sidecar、1.30 CEL GA、CSI/RWOP 已安装或 rootless 用户命名空间可用 |
+| 无 StorageClass、PV、CSI、RuntimeClass | 验收先预置有节点绑定的专用 local PV/PVC;不得宣称动态供应或跨节点存储恢复已验证 |
+| manager 不挂 Docker socket、不读平台存储凭据 | K8s HTTP API 和范围化 backend HTTP API 是执行层控制边界;CLI 不是运行依赖 |
+| 默认发布不构建 OCI 应用镜像 | 代码、依赖锁及 wheel/tarball/编译产物由平台保存;venv 在运行镜像中按固定路径物化 |
+| 一个正式应用、一个生产写入执行位置 | 应用、迁移、恢复及停用数据会话共用同名生产 Pod 位置;终止未确认时不补第二个写入者 |
+| 必需隔离不能配置覆盖 | 既有实例继续服务的前提仍是隔离成立;更强沙箱只能回退到满足基线隔离的默认档 |
+| manager 停机不能切断现有访问 | 路由、策略、材料初始化授权由 backend 持久投影提供;业务请求不查询 manager |
+
+### 2.1 Constitution Check
+
+| 条款 | 本设计落点 | 门禁结论 |
+| --- | --- | --- |
+| C1 | K8s 客户端只在 `src/runtime-manager/`;backend 通过 Service/Repository 管理操作、材料及投影 | 无设计冲突 |
+| C2 | 计划新增执行资源/材料授权模型及迁移均用平台既有方言工具;SQLite 工作库不与平台库混淆 | 设计遵循;实现仍需静态检查,真实 DM8 按会话口径由中央回归承担 |
+| C3 | 授权范围从已验证应用与操作取得;服务跨租户读仅在现有内部作用域中进行 | 不引入请求自报租户作为权威 |
+| C4 | owner、审批人、管理员和服务账号边界仍由原业务 Owner 经既有权限运行时验证 | 不增加 K8s 专用授权旁路 |
+| C5 | 业务失败映射到既有 161/162 行为;确需 K8s 运维错误时在已分配 164 段登记 | 不将 Pod 状态直接变成新业务状态 |
+| C6 | 服务密钥、材料初始化私钥、拉取凭据分开;业务容器不得挂载管理或材料凭据 | 不硬编码、输出或复制主凭据 |
+| C7 | 无前端改动 | 不适用新增实现 |
+| C8 | SQL/对象存储为权威;manager 磁盘和 Pod emptyDir 仅为可重建缓存 | 生产工作数据在独立持久卷,不能放 emptyDir |
+
+## 3. 方案对比与选定
+
+### D1:按运行能力抽象执行器
+
+- **备选**:模拟 Docker Engine API;只加 `kubernetes_backend.py`;抽象准备、启动、停止、日志、数据维护和执行事实。
+- **选定**:第三种。`OperationExecutor` 保留通用操作状态机,调用新的 `RuntimeExecutor`;`DockerExecutor` 适配当前实现,`KubernetesExecutor` 生成明确的受限资源。
+- **原因**:当前 `operations.py`、`bundle_preparer.py`、`bundle_launch.py`、`DataExecutor` 和只读 API 均直接操作 Docker 或本机目录,原 `DockerBackend` Protocol 不是完整接缝。仅更换客户端会把 HostConfig/Binds 和本地备份路径带进 K8s。
+- **重新考虑条件**:有第三种执行平台时扩展能力接口;不向平台业务层暴露 PodSpec。
+
+### D2:生产使用平台直接管理的 Pod,不使用自动补副本控制器
+
+- **备选**:每应用 Deployment/Recreate;StatefulSet 单副本;平台管理固定名称的生产 Pod。
+- **选定**:第三种。每应用在运行命名空间只有一个生产位置 `app-`,用途可以是 `serve`、`migrate`、`restore` 或停用时的 `data` 会话;`data` 不启动业务应用、不改变应用停用态。进程故障由 kubelet 重启,整个 Pod 缺失由 manager 对账恢复。准备/预检也用命名确定、有期限的临时 Pod,不引入 Job 自动重试者。
+- **原因**:副本数为 1 不足以保证分区、强删或迁移窗口内只有一个数据库写入者。固定 API 资源名称限制并发创建,backend 的 UID 账本和停止确认处理删除后的未知旧进程。平台本来就有持久操作及对账机制,不再让另一控制器自行跨越维护闸口。
+- **重新考虑条件**:引入经过验证的存储 fencing 和有维护协议的专用控制器后,可替换为 StatefulSet/Operator;不能只改 replicas。
+- **代价**:manager 长期不可用时,已存在 Pod 的容器可重启,整个 Pod 丢失不会由 Deployment 自动补起;符合本次“既有服务保持、manager 恢复后对账”的边界,不能宣传控制面完全无依赖。
+
+### D3:固定材料+Pod 内物化环境
+
+- **备选**:共享 manager 目录或 hostPath;把开发机 venv 上传为运行环境;固定依赖材料+运行节点物化。
+- **选定**:第三种。源/编译包及依赖产物保存在 backend;准备 Pod 在 scratch PVC 工作;正式 Pod 用 emptyDir 接收代码和依赖,Python 在 `/opt/venv` 物化,再给业务容器只读挂载。
+- **原因**:当前 venv 的路径固定、存在解释器及 ABI 假设。不能把 manager 本地目录用于另一个节点,也不能把编译产物等同于任意平台可搬运 venv。Pod 内容器重启保留 emptyDir,整个 Pod 重建从冻结材料恢复。[initContainer 官方说明](https://kubernetes.io/docs/concepts/workloads/pods/init-containers/)
+- **重新考虑条件**:实测物化成本超出恢复预算时,增加按租户/应用/环境键隔离的可丢弃环境缓存;不能把缓存升级为唯一真身。
+
+### D4:任务身份与部署初始化身份分开
+
+- **备选**:给所有 Pod manager HMAC;永久延长准备操作;独立范围化材料授权。
+- **选定**:第三种。任务身份随有效操作失效;正式初始化身份绑定当前有效部署和冻结引用,预览仍绑定有效预览操作及审批期限。所有材料凭据仅在受信的传输容器中,业务容器没有这些挂载。可信传输、打包、OCI 组装和数据 helper 均以固定命令、固定工作目录启动,不从应用目录/PYTHONPATH 加载用户模块;确需 Python 时使用隔离模式,防止执行用户脚本前泄露其独有凭据。
+- **原因**:现有 `RuntimeArtifactService.operation()` 拒绝已完成或过期操作;直接复用会导致批准版本日后无法重建。给应用 manager 密钥或无限延期会扩大控制权限。
+- **重新考虑条件**:平台统一工作负载身份服务落地后可替换底层证明方式,但仍需保留动作、材料、归属、代次及撤销范围。
+
+### D5:SQLite 的固定生产位置+终止确认
+
+- **备选**:只依赖 RWO/RWOP;SQL 租约过期即接管;Pod UID、finalizer、生产位置及停止事实共同约束。
+- **选定**:第三种。RWO/可用的 RWOP 只是附加约束。旧写入者停止未确认时保留占用,不接管、不强删。迁移、恢复及停用数据会话占用与正式服务相同的生产位置,前后都必须完成停止确认。停止事实分为未绑定节点、受来源约束的 kubelet 终态和未知三类(4.5);不要求从未创建的容器具有 container ID。
+- **原因**:RWO 可允许同节点多个 Pod;API 对象删除不证明节点进程死亡。终态 Pod phase 也可能由节点丢失处理产生,不能单独作为证据。安装的无副作用 status 验证规则限定终态的写入来源,再结合 UID、删除事实和新鲜 API 读取判定;不能依赖 manager 自报来源或 managedFields 中的名称。[访问模式](https://kubernetes.io/docs/concepts/storage/persistent-volumes/#access-modes)、[强删风险](https://kubernetes.io/docs/tasks/run-application/force-delete-stateful-set-pod/)
+- **重新考虑条件**:客户存储提供可验证的节点/卷 fencing 后,单独增加其证明适配器及故障验收;首轮不提供手动布尔开关绕过停止确认。
+
+### D6:不可覆盖的准入基线+代理出站
+
+- **备选**:只检查模板;RBAC+可覆盖 NetworkPolicy;固定准入策略、实测隔离及 backend 授权的出口。
+- **选定**:第三种。安装者设置命名空间 PSA Restricted 和独立验证 webhook;manager 无权改这些基线。应用只向 egress-proxy 出站,平台和外部域名均由代理访问;入站限 app-proxy 和受控健康/数据管理流量。
+- **原因**:RBAC 不能限制 PodSpec 字段;NetworkPolicy 是 Pod 级,不按 init/main 容器区分,且创建对象不等于 CNI 生效。材料身份与业务身份必须叠加接口授权,不能依赖同 Pod 不同容器获得不同网络策略。[PSS](https://kubernetes.io/docs/concepts/security/pod-security-standards/)、[NetworkPolicy](https://kubernetes.io/docs/concepts/services-networking/network-policies/)
+- **重新考虑条件**:客户已有等价准入引擎时可复用,但必须通过同一允许/拒绝测试;不能降级为仅告警。
+
+### D7:兼容源码模式使用受限 OCI 组装
+
+- **备选**:节点 Docker socket/DinD;容器内通用 rootless BuildKit;准备好应用内容后追加 OCI 层。
+- **选定**:第三种,保持 `source-image` 交付模式及实际应用镜像输出。复用平台的语言准备规则、入口模板与冻结环境,在受限 Pod 完成安装和编译后,可信组装 Pod 把代码、venv/Node 产物和入口文件加入冻结基础镜像,配置既有启动语义并推送;不能执行用户 Dockerfile。组装器只接受固定路径布局,拒绝路径逃逸、设备、setuid、带setgid的非目录、外部硬链接和用户 whiteout;卷目录继承的setgid只在归档时按普通权限去除,不修改只读输入;仅允许受信工具链定义的镜像内符号链接,不能把用户 tar 直接追加到镜像根。
+- **原因**:平台现有 Dockerfile 是固定 Python/Node/static 模板,不开放任意 Dockerfile。OCI 层追加本身不需运行容器守护进程;普通代码发布仍走 bundle,不调用此组件。[crane append](https://github.com/google/go-containerregistry/blob/main/cmd/crane/doc/crane_append.md)、[crane mutate](https://github.com/google/go-containerregistry/blob/main/cmd/crane/doc/crane_mutate.md)。BuildKit 的官方 rootless 容器示例需要额外系统调用/进程沙箱条件,不能假定在 189 或 Restricted 基线下直接成立。[rootless 前置](https://github.com/moby/buildkit/blob/master/docs/rootless.md)
+- **重新考虑条件**:产品将来批准任意 Dockerfile 或系统包安装时,重新设计构建沙箱;不通过临时放开 privileged、提权、宿主挂载或内核配置实现。
+- **兼容验收门**:Python 的 Procfile/默认入口、Node install/build/prune、static 输出选择和 SDK 必须与 Docker 原兼容路径逐项比较;不能只验证“镜像能启动”。依赖或系统库超出冻结 profile 时,审批前明确拒绝并指向新环境或完整镜像。
+
+### D8:namespace 内运行权限与安装权限分开
+
+- **备选**:manager cluster-admin;manager 动态部署自己的 RBAC/准入;安装者管理集群基线,manager 只操作预置运行命名空间。
+- **选定**:第三种。安装者供应 Namespace、PV/StorageClass、PSA、WebhookConfiguration、Role/RoleBinding 及只读部署描述;运行时不改变它们。K8s 1.28 使用稳定的 validating webhook,不以 CEL 新特性为前置。
+- **原因**:可以创建 Pod 仍是强权限,不能因“没有 root/socket”判断其权限小。独立的无集群身份准入进程限定 manager 的可创建资源;宿主盘目录创建由安装者完成,不交给 manager。
+- **重新考虑条件**:集群统一平台已有相同策略及供应服务时替换安装物,不扩大运行账号权限。
+
+## 4. 系统现状与拟实施结构
+
+### 4.1 基线已实现和缺口
+
+| 基线位置 | 已有代码 | F059 改动 |
+| --- | --- | --- |
+| `runtime_manager/main.py`、`api/operations.py` | v2 HTTP 唤醒、平台对账循环、内部鉴权 | 按配置选择 executor;K8s 形态不初始化 Docker 客户端或 v1 本地循环 |
+| `operations.py` | prepare/probe/preview/deploy/stop/destroy/migrate/restore;事实上报 | 保留通用状态机,移出 Docker 枚举、目录和镜像操作 |
+| `bundle_preparer.py`、`bundle_launch.py`、`container_runner.py` | Docker 本地下载、构建、venv、bind mount 和容器运行 | 抽出共享输入/启动规则;K8s 在 Pod/卷内执行,不模拟宿主路径 |
+| `appdb.py`、`data_executor.py`、`api/platform_data.py` | 一致备份/迁移与受控数据查询,Docker 单应用挂载 helper | 复用 SQL 和数据格式,增加同 Pod 数据 helper、停用数据会话及独占维护执行体 |
+| backend `runtime_delivery.py`、`runtime_operation_service.py` | v2 服务鉴权、操作/事实、归属、容量、产物传输、路由/策略 | 增加 K8s 资源账本、控制器租约、内部数据操作和材料初始化授权;不加入 K8s SDK |
+| backend `runtime_kubernetes_service.py` | 已实现控制器claim/renew、资源意图预提交、UID/停止事实和只读准入投影;34项Service合同与16项账本回归通过 | T019及manager控制Service、资源登记与观察均已接线;189已有真实Pod创建及同UID停止/清理记录,完整业务验收待完成 |
+| app-proxy、`platform_egress.py` | backend 路由/策略投影,不依赖 manager 提供路由 | 保留授权、HTTP/WS/CONNECT;增加材料用途的范围化策略 |
+| `.github/workflows/runtime-images.yml` | tag `v*` 和 workflow_dispatch;原生双架构镜像、目录产物 | 同版本发布执行 helper/OCI 组装器;增加 K8s 清单和契约测试 |
+
+本基线尚无 K8s executor、部署 Chart 或真实运行验收。该历史基线未包含原F086多租户启动及BIGINT修复。实施中已从F086单一来源继承Vibe专有BIGINT并显式汇合迁移;公共租户启动修复已按2026-10-22用户授权进入发布线9d156f261c,F059普通合并98fbba032继承,未复制权限分叉;新增空迁移汇合保持原父链。详见 [prerequisites.md](./prerequisites.md)。
+
+**F059 当前实现与实证(开发整合,完整交付待验收)**:Docker/K8s 执行、平台存储解耦、SQLite 维护、准入与能力报告、Chart、helper/assembler、CI 及 CLI/SDK 物料已接线。原任务 267/271 完成;本次追加同版本 CI 发布串行修复 T274 后为 **268/272**,剩余 T067、T070、T071、T072 是回归、故障/卸载、发行和最终交付汇总门,不表示仍有四块未实现业务代码。
+
+189 最新部署实证为 R32 / Helm32,manager 源码 `0f2db1f9a29213ca94d4596e64b00ad8fcbac528`,amd64 内网镜像 `sha256:bc488d16d3b75ccd30e729d7ef79b0c4591abaa40c0037057697f405d9e4900b`;controller 30→31 的在途 prepare 单次 SIGKILL 续办通过,同原操作/deadline/安装 UID 与 CID,240 秒 hook 自然完成。R31 安全删除、R11 在线/停用 owner 数据与同版本恢复、M2 迁移失败后显式恢复、R3 manager 停机访问、R12 容量前置拒绝已有分项真实证据。五种交付模式的原业务记录保持;后续 probe 的 SQL completed/cleanupcomplete 不扩大为物理清理或批准上线证明。
+
+旧 r4 与历史 false/unknown writer 继续保留围栏,不为合并强清。ARM 与新增 109 按用户优先级暂缓,DM8 仅静态;完整离线安装、剩余故障/独立卸载、跨节点及当前版本公开发行未完成。七维开发合入审查与本轮必要定向验证见 [code-review.md](./code-review.md)、[整合报告](../../../docs/reviews/vibe-development-integration-2026-10-23.md)。历史阶段记录保留其原日期与统计,不作当前状态。
+
+### 4.2 拟实施拓扑与流程
+
+```mermaid
+flowchart LR
+  CLI[CLI 发布] --> B[backend 与 Celery]
+  B -->|v2 持久操作与唤醒| M[runtime-manager]
+  M -->|namespace API| K[K8s API]
+  K --> P[准备与预检 Pod]
+  K --> A[单个生产位置 Pod]
+  P -->|范围化材料授权| E[egress-proxy]
+  A -->|初始化或业务身份| E
+  E --> B
+  U[业务浏览器] --> N[原平台 nginx]
+  N --> X[app-proxy]
+  X -->|入口授权与路由| B
+  X --> S[应用 ClusterIP Service]
+  S --> A
+  B --> O[平台 SQL 与对象存储]
+  A --> V[应用独占 PVC]
+  K --> G[独立准入验证服务]
+  G -->|只读执行计划| B
+```
+
+准入验证服务是安装支撑进程,使用 manager 镜像的独立命令,不持集群凭据、manager 控制密钥或对象存储凭据;不是新业务编排入口。manager/app-proxy/egress-proxy 继续独立运行。应用不直连 backend、MinIO、平台数据库或 K8s API。
+
+平台核心在集群外时,nginx 必须能到达共享 app-proxy,backend 必须能到达共享 manager API;不能把 ClusterIP 当作可由外部主机访问的地址。安装者可选择私网 LoadBalancer、受保护的单一 NodePort 或已有私网路由,分别发布共享入口和控制端点;默认不自动对外开放,控制端点限制 backend 来源且保留 HMAC。只有安装者在系统命名空间创建这两个共享暴露,manager 不能创建应用 NodePort/LoadBalancer。189 使用专用私网端点并验证实际源地址/转发后的 ACL;应用与材料 Pod 出站到外部平台 URL 仍经 egress。
+
+### 4.3 发布、访问及数据流程
+
+1. CLI、backend 同步校验、代码快照、Celery 扫描沿用 F055/F086。v2容量不足使用既有16125,legacy预检可能返回16226;CLI应同时映射容量退出码14(T264/T265已实现),不为统一私有测试而改变平台错误码。backend 校验运行时能力与冻结 profile,并创建 `RuntimeOperation`、预占及任务材料授权。
+2. manager 获取操作,验证本进程控制器租约,准备 scratch PVC;可信传输 Pod 下载源码/离线材料或既有依赖产物。包大小、条目和解包边界沿用 F086。
+3. 有依赖变化且允许内网/受控包源时,download Pod 经 build 用途 egress 下载完整材料。resolve/install/compile Pod 不持材料或出口凭据,不挂生产卷,不能访问公网。各阶段结束并确认停止后才能交接 scratch 卷。
+4. 上传 Pod 验证结果、计算摘要、经 backend 上传并 finalize,形成冻结 `RuntimeDelivery`。只改业务代码时复用已验证依赖产物,仍执行本次编译及启动预检。
+5. probe 使用独立数据卷/临时数据;成功后回收并提交 stopped/cleanup 事实。任意预检容量/材料/启动失败均原地结束,无新审批单和正式版本;容量恢复后重新发布。
+6. F055 写正式版本、发审批;预览按需创建独立临时 Pod 和数据库/附件空间。每次预览绑定会话、使用者、版本及审批期限,终态/回收立即撤销授权,超期由平台与 manager 对账清理。
+7. 审批后 backend 原子准入所选档位及 helper/任务开销。不足保留审批和版本,进入原待上线状态;不启动无限 Pending 候选。初始化和启动仍有实际调度验证。
+8. SQLite 更新先将路由置 maintenance 并确认 backend 已提交,再关闭生产位置;按 4.5 确认旧 UID 停止(含尚未启动的容器)后,用同名生产位置运行维护 Pod。先一致备份、上传并确认内容及关联记录,再 DDL;完成后维护 Pod 必须停止确认。
+9. 创建正式 Pod、材料授权及 Service;初始化物化代码/依赖,业务容器只读挂载。manager 验证 Pod UID、镜像、健康及 Service 就绪端点,提交 ready。backend 原业务服务校验预期版本/归属并提交路由、凭据、AppInstance 和 serving 事实。
+10. manager 收到 serving 后回收旧 Service、短期授权和 scratch。响应丢失查询持久事实及 UID 账本再续办,不重复产生版本、迁移或快照。
+11. 浏览器仍经 nginx → app-proxy → backend 入口授权/路由 → 当前 Service。应用 HTTP/WS 访问与 SDK/模型/附件请求仍使用原访问者 OBO 及应用凭据,经 egress 到平台。附件内容全程归 backend。
+
+更新失败按阶段区分:容量准入或准备失败且未维护旧库时不破坏旧实例;DDL 已执行则保留维护状态和快照,不自动恢复数据库。停用先撤销入口/出站/初始化授权,再停止生产位置;不删 PVC。恢复只能由自然人 owner 通过既有流程明确确认。owner 数据查看/编辑/导出在停用后仍保留:经 4.8 的独占 `data` 会话执行,不为此恢复业务应用或正式路由。
+
+### 4.4 执行资源与持久化映射
+
+| 内容 | 存放位置 | 生命周期 / 访问 |
+| --- | --- | --- |
+| source、dependencies、compiled、offline、snapshot | backend 对象存储+SQL 记录 | 复用 `RuntimeArtifact`;执行层只经平台内容接口 |
+| 当前操作、归属、容量、路由、策略 | 平台 SQL;Redis 通知/短缓存 | 保留 F086 权威;不用 manager 本地 JSON 接管 |
+| 下载、解析、编译工作区 | 专用 scratch PVC,按操作独占分配 | 动态供应或预置池;阶段交接按 UID 确认,完整产物确认后可删 |
+| 已批准应用代码/环境 | 生产 Pod 的独立 emptyDir | 只读给业务容器;容器重启复用,Pod 消失则从固定材料重建 |
+| 生产 `/data/app.db` 及 WAL/SHM | 每应用独占 PVC | 无 Pod ownerReference;保留策略 Retain,停用/升级/卸载不删 |
+| 迁移前尚未确认的备份 | 生产 PVC 的平台保留子目录 | 平台确认前不清理;不能只存维护 Pod emptyDir |
+| 预检/预览工作数据库 | 独立 scratch/preview PVC 或 emptyDir | 初始按批准结构建空库,不挂生产 PVC;会话结束清理 |
+| 材料私钥、业务应用/出站凭据、拉取凭据、受控数据票据 | 独立 Kubernetes Secret,仅指定容器引用 | 不写应用包、共享 scratch、日志或业务容器环境;拉取凭据只供 kubelet或可信组装器 |
+
+生产 PVC 固定划分 `business/` 和 `platform/`:业务容器只把 `business/` 以 subPath 挂到 `/data`,维护/数据 helper 可访问对应平台保留目录;快照、停止/迁移记录和数据请求结果不暴露给业务容器。目录由受信初始化步骤按冻结 UID/GID 建立,不解释用户提供的 subPath。
+
+生产卷首轮支持经锁、同步和故障验证的 local PV 或块设备文件系统 CSI 卷。拒绝将 NFS/SMB 文件系统用于现行 SQLite WAL 合同。local PV 带节点亲和,管理进程不创建宿主目录;安装者预置 UID/GID 权限。需要跨节点恢复时必须另验存储与停止证明,不能以 RWO 字样替代。[SQLite WAL 限制](https://sqlite.org/wal.html)
+
+预置卷无默认 StorageClass 也可用:安装者创建 `storageClassName: ""` 的 PV/PVC 绑定并登记;manager 只消费已授权 PVC。动态供应模式只创建指定类的 PVC,不操作 PV 或 StorageClass。PVC 不归属于 Helm 可级联删除的工作负载;应用显式删除时先安全擦除数据、记录完成,再依供应策略释放/回收,Retain 的集群 PV 由安装者处理,不把“PVC 删除”当作磁盘已擦除。
+
+### 4.5 生产位置、UID 与停止确认
+
+- 生产 Pod 名称由应用 ID 单向构造并截断到 63 字符,保留足够散列避免碰撞;namespace、名称、Pod UID、generation、owner_epoch 和模板摘要存入资源账本。正式服务、维护和停用数据会话共用这个固定名称。
+- 只有此位置允许挂该应用生产 PVC;准入拒绝从另一个 Pod、Job 模板或预览资源挂载它。业务容器和数据 sidecar 是同一正式位置内的进程,不是两个正式实例。
+- 创建前在 SQL 预记并提交资源意图,随后才调用 K8s API;不得持数据库事务锁等待会回读 backend 的准入 webhook,避免循环等待。创建后按 UID 确认;创建响应丢失,读取同名资源核对标签、模板摘要和账本,不能仅按名字领养。所有删除和替换带 UID precondition,更新带 resourceVersion。
+- 所有需要停止确认的受管执行 Pod(生产、data、准备、预检、维护及预览)均带 `runtime.bisheng.io/stop-confirmed` finalizer;临时 Pod 也不能在 manager 断连时丢失唯一终止证据。停止请求先记账,再发有宽限期的 DELETE;finalizer 保留可查对象,便于 manager 观察并持久化该 UID 的终止证据。
+- **停止证明**按下表分类,检查从 API server 新鲜读取的同一 UID、删除事实、准入基线版本和容器诊断;不把“从未启动”强行转换成“必须有 container ID”。Watch 缓存仅用于唤醒,不能作为释放依据。
+- 证据成立后先上报 stopped,由 backend 解除写入归属/预占,再移除 finalizer。若无法证明,保持 `cleanup_pending/writer_stop_unconfirmed`,保留卷和预占;没有强删超时后自动接管路径。
+- finalizer移除不保证grace仍为正的Pod立即从API消失。仅已进入删除的同UID可信终态允许绑定kubelet收尾DELETE0;无STOP分支逐次读取后端精确资源计划,stop_state=confirmed、stop_confirmed=true及allow_delete=true同时成立才允许。原带STOP路径仍不依赖backend,manager强删限制不变;不确定继续拒绝。
+- 外部管理员强删或同名替换后,旧 UID 在账本中仍未确认;新资源不获准启动为生产写入者。遇永久节点故障,默认阻塞至恢复或客户提供经评审的物理 fencing 方案;本轮不内置此证明适配器。
+- manager 多进程使用平台 SQL 控制器租约,失去租约即停止提交变更。租约只限制控制器行为,**不**用它证明数据库写入者死亡。固定名称、准入检查与 UID 账本共同处理陈旧请求的竞争窗口。
+
+| 分类 | 必须具备的证据 | 结果 |
+| --- | --- | --- |
+| `not_created`(独立意图收尾) | 新版资源账本持久记录从未尝试,或唯一创建尝试收到明确403;先原子关闭CREATE,再fresh GET404,当前controller范围与模板/归属保持 | 无UID意图未成为执行体;单独观察路径保存证明,不能冒充never_scheduled;普通stopped仍要求真实UID |
+| `never_scheduled` | 同一 UID 已进入删除,最新对象的 `spec.nodeName` 仍为空;模板禁止用户指定节点,绑定 API 不允许给删除中的 Pod 新增节点归属;没有同名异 UID 或早期已绑定事实 | 从未交给 kubelet,无容器终止记录也可确认该 UID 不会成为写入者 |
+| `kubelet_terminal` | 同一 UID 已进入删除;Pod 从创建起受当前 status 基线保护;`Succeeded/Failed` 是该绑定节点 kubelet 通过受控 status 更新写入的终态,且无仍运行的容器记录;终态不可回到活动态 | 接受 kubelet 对整个 Pod 已结束的确认;Waiting、缺失 ID 或从未启动的 init 后缀作为诊断保留,不因此永久阻塞 |
+| `unknown` | 只有 DELETE 已受理、404、NodeLost/Unknown、租约超时、来源未受保护的 Failed、过期缓存、对象消失或同名替换等事实 | 保持 held/cleanup_pending;不释放卷或创建第二生产写入者 |
+
+`never_scheduled` 不是仅凭“查不到状态”:必须保留带删除时间和空 nodeName 的同 UID API 对象。绑定和终态来源的允许/拒绝测试属于安装硬门;不满足时不能启用该证明类型。已绑定但 init 拉取失败/材料拒绝时,先 DELETE,再等待 `kubelet_terminal`;不能根据 PodInitializing 或状态缺失直接释放。
+
+**报告失效与历史保护(T258–T261细化)**:安装报告的readiness与旧UID连续保护是不同事实。信任安装者/集群管理员和受认证kubelet、Webhook保持failurePolicy=Fail;本设计不声称防御管理员卸除后重装保护。内部心跳新增`capabilities.protection_observation={version:1,baseline_digest:<64hex>,state:current|report_expired|helper_changed|unconfirmed}`,原布尔继续只表示当前两项探测通过。新摘要对整个descriptor使用原canonical SHA256,仅排除probe_report/helper_image;原descriptor_digest算法和对外deploy API不变。除helper_image外所有变化都作为保护基线变化处理;admission语义变化必须提升status_policy_revision。
+
+`current`要求cluster-probe来源、当前完整摘要与非未来24小时内时间有效,两项布尔沿用各check结果。`report_expired`仅允许时间过期、完整摘要/来源正确且两项唯一有效正证据;`helper_changed`仅允许进程基线与新投射严格只差helper_image,原报告仍绑定旧完整摘要、时间非未来且两项正证据有效。两种情况仍ready=false、当前两布尔=false;缺失/坏报告、任意其他字段变化、冷启动摘要不匹配均不享此例外。backend只有旧node已登记同一新摘要/策略且严格新元数据匹配,才不把这两种report-only未知写成历史gap;节点blocked,历史false不变,不授予新执行。首次新版fresh报告可在同domain/策略下登记legacy node,初始未知不能登记例外;已有新版基线不可静默降级。基线变化/真实检查失败仍不可逆降低对应保护标记。
+
+新合同登记不可因坏心跳丢失:backend在既有capabilities JSON保存服务端派生的protection_contract_version=1和最后合法protection_baseline_digest;忽略body对这两个键的写入。缺失/畸形observation保留此黏性标记及已知摘要,节点blocked并失信,随后legacy positive也不得绕回旧协议。只有有效current可恢复当前ready,历史false保持。未进入新合同的legacy manager保持原行为;首次有效新版报告沿用不可变engine_id核对执行域,显式guard=false仍优先失信。报告非有限/非数值observed_at诊断规范为null,使严格JSON心跳能送达blocked事实,不以编码失败保留旧ready。
+
+运行时心跳从同一投射快照生成capabilities与probe_report(heartbeat_snapshot),不能分别读取投射后拼装,避免TTL刷新或真实guard失败恰在两次读取间产生矛盾消息;backend继续保守拒绝矛盾元数据。expired/helper-only例外要求所有15必需check唯一且结构/证据合法,两guard必须为正;新投射若有匹配新descriptor的fresh失败/缺失/坏guard证据,优先unconfirmed,不用旧正例覆盖。
+
+当前保护未知期间,即便历史true也暂缓首次停止确认:never_scheduled需当前binding_protection=true,kubelet_terminal需当前status_protection=true,backend独立检查;只因网络等其他项失败仍可安全收尾。当前fresh同基线恢复只恢复当前可确认性,不回写历史标记。已确认同UID幂等/not_created独立路径/原UID与新鲜度围栏不变。当前旧三false记录不自动迁移、不移除finalizer、不释放卷;如需历史追溯或物理fencing另行提交设计评审。本修正不新增证明类型、权限、SQL字段或外部恢复接口。
+
+**创建事务收尾(T203–T214实施细化)**:新Pod意图在现有 `observed_state.creation` 保存 `version=1` 及 `unattempted → started → rejected`;POST前原子 `create_begin` 固定唯一 `create_attempt_id` 和controller epoch,重复begin不重发。只有同attempt/epoch的明确HTTP403能记录rejected;timeout、transport、5xx、409、2xx解析失败等保持started/unknown。新意图准入只有started允许CREATE,旧JSON空记录不回填为unattempted。收尾先 `create_fence` 将unattempted/rejected固定为fenced并关闭CREATE,再新鲜GET404,通过独立 `not_created` 观察记录无执行体证明。该路径stop_state=confirmed但evidence_kind=not_created、UID仍NULL;普通stopped的同UID证明完全不变。任一实际对象出现必须验证范围、绑定真实UID并走停止流程;已证明未创建后又出现对象则拒绝,不删除或移除finalizer。旧r4缺少唯一尝试账本,保留操作/PVC/预占,不凭历史manager日志或当前404自动追认。锁序保持operation→node→resource,资源加锁读取使用populate_existing强制刷新预读identity map,避免并发begin/fence覆盖。无新DDL、无直接MinIO依赖、无外部deploy API变更。
+
+**延迟调度绑定收尾(T219/T220)**:Kubernetes CREATE返回时Pod可能尚无nodeName。停止前必须从API server新鲜读取并核对同namespace/name/UID及范围标签;仅首次从空节点补齐为实际节点,已有非空节点不得变化或被清空。补齐后校验平台绑定回执,再生成停止证明,保持backend对证据节点与账本节点严格相等的要求。已有confirmed证明不得补绑或改写;UID/范围变化、GET或绑定结果不确定继续保留资源。189已观察同UID材料Pod成功、facts200,但旧实现账本节点为空导致stopped409;189 Helm15已由正常对账补齐原Pod节点,保存同UID停止证据并完成三资源清理;完整发布仍待验收,未人工清理finalizer或PVC。
+
+
+Pod的downward UID字段显式声明core/v1,避免K8s补默认值改变冻结projection;非v1、其他字段路径或额外字段仍严格拒绝。准入日志只固定8个安全原因或admission_refused,不输出请求、环境、异常正文/类型名或UID;API响应合同保持。历史189403未取到原reason,不将本地默认化复现当现场结论。
+
+实际受管容器诊断固定名单包含storage-guard、schema-init、assembler,拒绝任意容器名称;否则_prepare失败可能被日志读取参数ValueError遮蔽。platform.origin的反向代理必须原样转发 `/internal/app-runtime/v2/material-access/` 根前缀(与 `/api/v1/internal/app-runtime/v2` 控制面前缀不同),不rewrite path/query。材料分块16MiB、关闭请求/响应buffering、65s超时,后端继续验证workload签名/票据;不得笼统暴露所有/internal。189无凭据请求曾实得origin404/backend401,专用nginx补路由且reload生效后origin401;这仅证明入口和拒绝,不替代有效材料传输。
+
+**终态来源约束**:独立验证器新增 `UPDATE pods/status` 规则,只覆盖本安装专用执行命名空间。使用 API server 提供的 `AdmissionReview.request.userInfo` 与 old/new Object:只有 `system:nodes` 中、用户名严格等于 `system:node:` 的绑定节点身份可引入终态或变更终态容器记录;拒绝其它控制器合成终态,拒绝终态恢复活动。普通调度条件更新不需要 backend 或资源计划查询。manager 的 Role 不含 `pods/status`,不能冒充 kubelet;创建 Pod 时拒绝预填终态,冻结 `status_policy_revision`,不领养基线启用前的 Pod。规则 `sideEffects: None`、`failurePolicy: Fail`,不写停止事实、不调用 backend,因此不形成 status→backend→删除的循环。该设计信任受认证 kubelet,不声称防御被接管的节点或集群管理员。
+
+停止事实在平台保存 `evidence_kind`、resource UID、nodeName、删除时间、API resourceVersion、status 摘要、policy revision 和 observed_at。仅当它们与当前资源/归属匹配,backend 才允许释放;finalizer 保留失败可重试,已确认停止的旧 UID 不因重复通知重新占用。对象一旦强删且没有已持久确认的证据,仍按 unknown。资源账本中的收尾计划保留到 finalizer、Service、Secret 和 scratch 清理确认,不因业务操作先进入终态就从 admission 的只读计划中消失;它只授权同 UID 的停止/删除收尾,不再授权创建或重新取得生产归属。
+
+K8s 1.28 不保证未启动容器都有终止记录;其 kubelet 延迟终态转换直到不再可能运行容器。本设计据此组合来源约束与 UID/删除事实,而非只看 phase。[TerminatePod](https://github.com/kubernetes/kubernetes/blob/v1.28.15/pkg/kubelet/status/status_manager.go#L380-L486)、[mergePodStatus](https://github.com/kubernetes/kubernetes/blob/v1.28.15/pkg/kubelet/status/status_manager.go#L965-L1029)、[删除中 Pod 的绑定校验](https://github.com/kubernetes/kubernetes/blob/v1.28.15/pkg/registry/core/pod/storage/storage.go#L221-L232)、[AdmissionReview 与子资源规则](https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/#request);这些是静态依据,未替代 189 实测。
+
+固定生产名称可在一个操作中顺序经历备份、迁移和运行。每阶段使用独立resource_ref及UID行;原operation/kind/name唯一约束由f059_resource_phases移除。新阶段须所有历史生产Pod停止确认且cleanup complete,旧证据不覆盖。ref主键及真实UID唯一约束保留;降级前运维确认无历史重名,DDL不清历史。
+
+### 4.6 准备、初始化及依赖复用
+
+**准备阶段**:scratch PVC 按操作唯一分配,可信传输 → download → resolve/install/build → 可信上传是独立的临时 Pod;每阶段网络身份不同。临时准备/预检/维护 Pod 用 `restartPolicy: Never`、`activeDeadlineSeconds`,manager 决定重试,不使用 Job 控制器自动补 Pod。预览 Pod 从创建起设置由会话 expires_at 推导的总期限,启动/物化仍各有 90 秒阶段限制;不把短启动期限事后调大为 7 天。正式 Pod 无整生命周期 deadline,初始化进程使用自身固定阶段期限,manager 对账负责停止超时未上线候选。K8s 1.28 只允许减少已设 Pod activeDeadlineSeconds,不能设计成 ready 后延长或清空。[1.28 更新校验](https://github.com/kubernetes/kubernetes/blob/v1.28.15/pkg/apis/core/validation/validation.go#L4391-L4409)无动态供应器时使用安装者预置的 scratch 池;池容量不足返回准备资源不足,不借生产卷。
+
+**正式 bundle Pod**:
+
+| 容器 | 镜像 / 动作 | 挂载与身份 |
+| --- | --- | --- |
+| `material-fetch` init | 固定执行 helper;取冻结 compiled/dependencies 并安全解包 | 材料授权 Secret 只给此容器;不挂生产 PVC,不执行源码/安装脚本 |
+| `environment` init | 固定 runtime 镜像;Python 在 `/opt/venv` 建环境、`--no-index --require-hashes` 安装、pip check/SDK 验证;Node 检查批准 node_modules;static 检查入口 | 只有代码/材料/环境卷,没有材料、业务或出口凭据,没有生产卷;网络层仅有需认证的 egress 端点,实际无出站授权 |
+| `app` | 批准 runtime image+冻结 command;`/workspace/app`、`PATH=/opt/venv/bin:...` | 代码和环境只读,生产 `/data` 可写,tmp 有界;仅业务应用/OBO/egress 变量 |
+| `data-helper` | 固定 helper image;受控表/schema/行编辑/导出 | 同一生产 PVC,只有本应用数据动作验证材料,不持 manager/service/MinIO/集群密钥 |
+
+初始化阶段使用相同 Pod 网络,不能声称 init/main 有独立 NetworkPolicy。材料取回经受控 egress,物化容器没有代理身份;业务容器只获得 runtime 用途身份。访问 backend 内容接口本身还需材料授权,普通应用凭据不能调用它。
+
+环境键复用 F086 的 profile、运行镜像/ABI、目标架构、完整依赖锁、离线材料摘要和 install_inputs;包含租户与应用作用域。代码变化仅在不影响安装输入时复用 dependency artifact。Python venv 不跨任意路径/镜像迁移;Node 的原生模块随 profile/platform 冻结;任何来源、SDK、系统库、架构或安装输入变化都重新验证。
+
+完整镜像模式不复制源码或重装依赖;只注入既有启动/健康/资源/网络/数据合同。兼容源码模式安装和编译完成后由可信 OCI 组装器构造 `/app`、依赖环境和既有 entrypoint;固定基础镜像已含用户/目录及必要运行工具,组装器不执行 RUN,不处理任意 Dockerfile。将来增加系统包必须发布新 profile 或完整镜像。Docker 旧 builder 路径保留,跨形态测试逐项证明启动语义相同。
+
+准备阶段结果写入既有 preparing 事实的 detail.prepare_progress,按 inputs/resolve/install/upload 保存资源引用和结果,写入确认后才停止并清理该阶段 Pod。响应丢失先查询已提交事实,不盲目重跑安装。重试沿用资源账本中首次批准的 activeDeadlineSeconds,阶段总截止仍由冻结操作和材料授权限制。scratch PVC 复用首次绑定的 UID;清理需全部相关 Pod 已确认停止,正式数据 PVC 不随普通清理删除。新控制器申请清理时更新资源的 controller_epoch,仅更新清理权限,不改变 UID、停止证据或连续保护标记。
+
+T225/T226实现:准备全部输出确认后,先在既有`preparing`事实的`detail.prepared_delivery`保存最终delivery,且每次事实继续携带`prepare_progress`和该结果;backend查询仅投影最新事实,故不能只写一次后被后续阶段覆盖。清理开始前必须确认提交,响应丢失仅在fresh重读得到相同结果时接续。重启有该结果直接续清理,不再安装/上传;probe已ready只续清理;data已有`data_action_finished`时不重新prepare-session,unknown仍按data_result_unknown失败。cleanup.complete=false在原操作期限内0.5s重查;每轮检查取消、归属/代次和K8s控制器租约,成功后再检查期限,异常不吞。旧实例清理受当前操作而非旧操作期限约束;未知停止仍保留。
+
+
+本地实现补充:K8s源码兼容模式由backend同样冻结profile;无凭据安装Pod把Python环境写到scratch的environment子目录并固定挂载为/opt/venv,Node产物在编译后裁剪开发依赖。可信组装Pod只读scratch,用crane mutate追加/app代码与/opt/venv及既有语言入口模板,不执行RUN;registry配置只挂组装器。完整镜像不复制源码或安装依赖。实际CRI/registry与双架构镜像仍须后续真实验收。
+
+**准备循环操作检查(T256/T257)**:每阶段进入、复用已确认结果返回、创建Pod前、Running轮询和提交阶段结果前,复用同一操作范围/取消/持久期限检查。原冻结与fresh当前期限取最小值,后续延长不得续活原操作;读取不确定时拒绝继续产出。取消交给既有正常清理,同UID停止证据成立前不释放资源/容量。backend resource GET仍可供终态清理读取,不将其变成仅活跃操作可读。此补漏不增加取消接口或新的响应时限。
+
+**初始化阶段期限(T228/T229)**:launch使用fresh同UID Pod及同一状态快照计算阶段和Ready/Service端点。固定init顺序来自冻结template;首阶段锚点为Pod.creationTimestamp,后续为前一成功init的finishedAt,最后init完成后进入独立健康90秒窗口。无init的完整镜像仅有创建起的健康窗口。已完成阶段也不得超过90秒;至多5秒API/kubelet时钟偏差不使锚点后退。缺失/异常/越序状态不授予Ready,缺失时间拒绝,不以now或running.startedAt续期。每轮及Ready返回前fresh核原操作范围、取消和期限,min原冻结deadline/当前deadline;manager重启、同名新UID、控制器失租不能刷新期限或绕过围栏。observe公开形状不变。最后sleep有界且非负,保留阶段失败诊断。
+
+### 4.7 材料授权与续期(已接线;准备与正式初始化已有实证,完整续期故障矩阵待验)
+
+期限实现:`RuntimeMaterialDelegation.expires_at`在operation/预览中分别是操作/会话的最晚期限;正式deployment-init可为NULL,表示每次重新检查当前归属、批准版本、冻结delivery和同UID,不是永久通行。300秒传输票据仍有独立期限,不能独立续期。该表已在189专用测试平台完成扩展迁移并实际读写,尚未完成客户发行交付;模型声明可空,不改已发布迁移或做数据回填。
+
+manager 通过现有服务 HMAC 在 backend 创建材料授权;后台从已验证的操作、部署/预览和 delivery 计算范围,不接受调用方任意指定材料、租户或应用。
+
+1. 授权区分 `operation` 与 `deployment-init`。前者有效期不超过操作 deadline,终态/取消即失效;后者绑定当前 generation/owner_epoch 和已登记生产 Pod UID。它在准备正式候选时要求当前归属仍是该有效 deploy/recover 操作、版本已批准且 delivery 已验证;切换后要求仍是当前 serving 归属,可在原 deploy 操作 completed 后继续初始化。不能要求候选在取材料前已经进入正式路由,也不能让已被替代的旧代次续期。
+2. 每个受信传输容器使用独立 Ed25519 密钥对:私钥仅挂该容器 Secret,backend 保存公钥、用途和撤销信息。资源登记先在manager端将Secret内容及Pod环境值投影为摘要,backend不接收私钥或持久保存明文环境;准入对实际资源执行同一投影再比较。业务凭据通过独立Secret引用只注入app,材料Secret只给传输容器。业务容器无挂载;不用 manager HMAC 作签名 key。manager 使用独立、持久且只读挂载的32字节材料根种子,按资源 UUID 通过 HKDF-SHA256 派生不同 Ed25519 子密钥;重启恢复同一资源身份,不将根种子或子私钥写入 SQL。安装升级必须保留该种子,不能每次渲染随机覆盖。manager 创建 Pod 后提交 UID 绑定事实,绑定前初始化请求等待,不接受自报 UID 建立授权。
+3. 交换请求包含授权 ID、Pod UID、时间戳、nonce、目标材料;签名输入、头、动作和传输格式固定为 6.2.1 的 `material-v1`,不得另行猜测或复用服务 HMAC。请求签名与当前后台归属共同校验。时间偏差上界 60 秒,nonce 窗口 120 秒,Redis/授权不可确认时拒绝。签名证明持钥,UID 权威仍是 manager 已登记事实,不能由字段本身证明。
+4. backend 对下载发 300 秒、单材料、只读传输票据;上传票据另限有效操作和获批输出类型,ticket 仅存哈希。过期票据直接拒绝;续取须重新签名并核验当前有效归属和冻结引用,不能只用旧票据。上传只授予有效准备/数据维护操作,部署初始化绝不取得上传、快照恢复或控制权限。
+5. 预览初始化绑定当前有效预览操作、会话使用者和审批期限;回收/审批终态即撤销,不能借正式部署身份取得生产材料或生产附件。
+6. 停用、删除、部署代次切换、预览结束在原业务事务中撤销授权。backend 提供 material 用途 egress 的短投影,依当前授权持续生成有效期,不能仍由原准备操作的 deadline 决定。
+7. 重建新 Pod 必须由 manager 新建 UID 绑定;manager 不在线时,已绑定但尚未取完材料的 Pod 可继续或续取,容器重启复用 emptyDir。没有声称 manager 停机时可凭旧 Secret 任意创建新 Pod。
+8. 内容传输仍以 backend 为唯一网关,沿用内容摘要/大小/安全解包及 finalize 确认;不发 MinIO 直链。部署配置提供可信 TLS/CA,不能用关闭证书校验解决内部地址问题。manager 的 RTM_PLATFORM_CA_FILE 同时供控制客户端和受信 helper 的只读 ca.crt 使用;材料根种子路径为 RTM_MATERIAL_ISSUER_KEY_FILE,不能沿用服务 HMAC。
+
+实现接线补充:准备先由可信下载Pod只取源码并计算input_key,manager调用既有dependency-plans;backend在当前操作行锁下持久化dependency_resolution。下一下载Pod使用新公钥授权,backend复查选中计划、同应用/profile/架构与verified材料后允许取缓存;缺缓存才在可信下载阶段解析纯wheel/ignore-scripts npm材料。无身份install Pod只挂source/output可写、original/materials/tools只读,使用固定预制工具链;缓存命中跳过install hooks,仅重新编译代码。上传Pod不运行应用代码,确认产物后由manager继续提交结果。正式bundle的material-fetch准备可信environment.sh,Python在运行镜像内以/opt/venv离线安装;Node/static无需在runtime镜像安装Python。所有可信Python helper使用-I且发行镜像必须将模块放入可信site-packages,不能只依赖/app工作目录。
+
+材料去重与生产声明分开:Artifact 按内容身份保留原始元数据,每个材料委托另存不可变 upload_declarations,包含该授权上传的 kind/hash/size/platform/metadata。不同合法操作可复用相同内容,不能修改同一授权已经冻结的上传声明。依赖输入变化但解析产物字节相同时,只有同应用、平台、profile 的持久 dependency plan、产生操作的冻结选择及已确认上传记录共同证明,才允许新的输入键关联原产物;不能仅凭调用者传入 artifact_id 扩大读取范围。
+
+### 4.8 数据维护及在线、停用数据接口
+
+复用 `appdb.py` 的白名单 SQL、schema plan、`sqlite3.Connection.backup()`、integrity check 和 owner-only 流程。普通数据查看/单行编辑/导出保留现有 backend → manager `/v2/apps/{app_id}/db/*` 的外部业务合同:在线定位当前 UID 的 data-helper,停用或未成功启动但已分配数据卷时使用下述独占 `data` 会话。不在 manager 容器挂全量 PVC,也不让 backend 执行 SQLite。
+
+在线 helper 与应用在同一 Pod/节点,避免 RWOP 卷不能再挂到第二个 helper Pod;正常行编辑由 SQLite 锁管理,单活动正式实例约束不禁止该实例内合法数据连接。数据票据只由 backend 原数据业务 Service 在验证当前自然人及应用权限后签发,包含 app_id、当前 exec_ref/UID、动作、参数摘要、5 秒签发有效期及 request_id/nonce(仅约束受理时刻,已受理的工作上限仍为 60 秒);manager 只转交,不能凭自己的管理身份签票。helper 只持 backend 签名公钥,不持签名私钥;公钥带 key_id,轮转只保留覆盖票据有效期的前代公钥;公开验证键不构成业务容器的管理能力。helper 在业务容器看不到的 `platform/` 目录中先持久登记请求为 started,再执行白名单数据动作并记 done 及结果;同 request_id 同摘要可返回已确认结果,不同摘要拒绝。started 但没有 done 时返回结果未知,不自动重做写操作;这避免把 SQLite 更新与平台 SQL 当作跨库原子事务。票据重放、未知结果和过期返回沿用受控数据错误呈现,要求重读。导出流式返回,经理端临时文件只能作短缓存。
+
+**数据请求的统一受理**:原 `AppDataService` 完成存在性、租户、owner 和编辑确认后,创建限时内部 `RuntimeOperation(kind="data")`,记录 request_id、动作/参数摘要、actor 及原 generation/owner_epoch。`data` 是新增内部操作类型,不开放用户任意构造;不得改变 App 状态、批准版本或 serving 代次。操作在同一 Repository 事务中取得每应用数据/维护互斥,提交后再通过原 `/v2/operations` 唤醒/等待 manager;与迁移、恢复、停用、删除及部署提交互斥。在线数据连接仍属于现有正式实例内部,保留运行实例的写入归属,不创建第二资源。
+
+| 场景 | 执行路径 | 安全及回收边界 |
+| --- | --- | --- |
+| 正常在线且 helper 可用 | 复用同 UID 的 data-helper,不增挂生产 PVC | backend 在确认当前 exec_ref/UID 后签发动作票据;同一应用数据请求串行 |
+| 已停用,或启动失败后生产位置可安全回收 | 确认旧 UID stopped 或从未分配生产执行体,取得空生产位置和 helper 开销预占;仅启动固定 helper 镜像的 `purpose=data` Pod | 共用 `app-` 和原 PVC,restartPolicy Never、有界 deadline;不启动 app、不挂代码/业务/材料身份、不创建正式入口 Service,不把应用变回在线 |
+| Pod 尚在停止、写入状态未知、迁移/恢复占用,或在线 helper 损坏但主容器仍可能写入 | 返回既有数据忙/维护中原因 | 不回退为第二个挂卷 Pod;可由原生命周期流程安全停止后重试 |
+| 尚无工作库/卷,或数据资源已被显式删除 | 沿用原数据空态/错误 | 不自动启动业务应用,不为查看请求创建业务表结构 |
+
+manager 只提交 helper ready 的 UID/内部目标事实;backend 重新检查原 actor 权限、当前互斥归属与请求剩余期限,再签 5 秒票据并转交原数据动作。冷启动所需调度/拉镜像也计入单请求 60 秒总期限,不能另加无限等待;超限返回原数据错误,触发清理。manager 不因掌握编排身份获得 owner 数据票据签发权。
+
+请求完成、取消或超时后先停止离线 data Pod,按 4.5 证明停止,提交 cleanup 后释放数据会话互斥和 helper 预占,保留 PVC、数据库及操作结果记录;未知时阻塞同应用后续生产/维护执行。在线请求只结束该 helper 内的动作,不停止正式 Pod。60 秒约束动作与响应,失败残留按正常 900 秒清理预算对账;不得把超时响应当作清理完成。内部 data 操作的结果未知时查询原 request_id 的卷内 journal:查询使用新的只读 request_id,并以 target_request_id 和原动作摘要指向旧记录,不改写旧幂等键;backend 重新验证 owner 后签发只读查询票据,不能续活过期写票据、把查询解释成原编辑或换 request_id 自动重做编辑;重读确认后由用户明确再次编辑继续沿用原二次确认与审计。停用数据会话不授予材料上传/下载权限,不产生应用实例或发布额度概念。
+
+迁移/恢复必须停掉包含在线 helper 的整个生产 Pod,禁止新的数据请求。维护 Pod 占用同一生产位置,挂 PVC;备份保存在保留目录并上传 backend,收到 verified+摘要+关联记录确认后才 DDL。维护日志记录 plan digest 和已完成阶段,重复调用先查平台事实及卷内 `platform/` 维护记录,不重复执行结构变更。相应布局由执行 adapter 映射到现有 appdb 路径接口,业务环境变量仍是 `/data/app.db`,不能把 manager 宿主路径传入该接口。
+
+恢复先保护当前数据库并确认其快照,再下载目标快照、校验、离线替换,fsync 后提交事实。启动失败保留 data_may_have_changed 和维护状态;不自动用旧备份覆盖新数据。预检和预览只初始化独立空库。
+
+数据helper本地实现补充:data-v1签名输入为固定域`BISHENG-RUNTIME-DATA-V1\n`加canonical JSON(key_id和claims),claims精确绑定version/operation_id/request_id/app_id/exec_ref/pod_uid/actor_ref/action/parameters_sha256/nonce/issued_at/not_after/deadline;受理窗口最长5秒,总deadline不超过签发后60秒且仍受原数据操作期限限制。独立受信helper只加载公钥。卷内跨进程flock串行,started先fsync再创建有界SQLite子进程,子进程完成但done落盘失败仍保留未知;新只读请求查询原ID,不重放写操作。导出在platform目录生成文件、校验摘要后流式返回,不把CSV塞入journal或一次性读入内存。helper本地/HTTP、backend签发与会话、Pod模板与运输已通过本地合同;R11 已真实验证在线与停用 owner 数据请求、编辑、CSV 及恢复一致性;完整异常矩阵仍待验。
+
+数据会话底层互斥已接线:Repository按node→assignment顺序,对节点内跨租户会话只做聚合计数并发上限2,在同一事务建立data_operation_id和离线helper 128MiB/100m预占(启动成本实测修正;在线sidecar保持25m);不改serving active_operation_id/generation/owner_epoch。生命周期提交和claim双向检查该归属,期限过期或结果未知不释放。仅terminal且cleanup_complete的同一data操作才能释放互斥。K8s正式serve Pod的128MiB/25m sidecar加入应用预占,Docker预算保持原值。上述Service、存储传输、owner签发与Pod运输均已接线;R11 的在线/停用数据业务与 M2 显式快照恢复已有真实证据,未覆盖的故障分支保持待验。
+
+原AppDataService入口已接选择分发:Docker保持既有RPC;K8s在原owner/参数校验之后,由RuntimeDataSessionService提交内部会话、等待同UID helper就绪、调用后端签发接口并转交X-Runtime-Data-Grant。后台不跨SQL事务等待网络。在线固定原exec_ref/UID,不另起挂卷写入者;离线冷启动与调用共用60秒。manager唤醒响应丢失以已提交状态查询恢复,数据HTTP结果未知不重发。T049已接默认backend签发实现,T045已接Pod运输/清理;K8s owner 数据主链路已在 R11 通过,全部故障与卸载组合尚未完成。
+
+数据Pod运输已本地接线:正式Pod的data-helper与离线purpose=data使用固定`runtime_manager.k8s_data_helper`入口,公钥只投射到helper,Pod UID来自Downward API;准入只允许安装者指定的公钥ConfigMap。在线按原UID复用,helper不可用不回退到第二挂卷Pod;离线先复用已登记PVC并确认旧写入者已停止,不为查看数据创建新卷或Service。调用目标只能是安装描述的pod_cidrs内、同一资源UID的8092端口。v2数据API转交owner票据,使用后台持久互斥和卷内flock,不再抢操作线程持有的本地app锁。
+
+管理面`/execution-state`只返回空闲位,不返回SQLite或journal结果;未知/取消动作须等旧票据受理窗口结束并确认helper空闲,再释放在线会话。无法确认则保留互斥;离线仍需整个同UID Pod停止证据。业务数据结果查询始终需要新的backend owner票据。导出经理端只存短缓存并验证长度/摘要,丢失回调的过期缓存按既有清理周期回收,不清业务PVC。
+
+维护实现补充:受信maintenance Pod只持该阶段材料私钥,使用k8s_appdb适配既有PlatformAppDb到business/app.db与platform私有备份。卷内maintenance记录按操作/动作及参数摘要持久缓存结果,SQLite迁移journal保持DDL幂等。manager先将maintenance_progress提交平台,再按同UID停止、清理Pod与临时Secret,正式PVC保留;重启从确认结果续办。备份平台内容和SQL关联未确认则不进入DDL;恢复先保护当前库,不自动回滚。当前通过真实本地SQLite与模拟集群交互验证,未宣称189实测。
+
+签发与结果查询接线:backend从BISHENG_RUNTIME_DATA_SIGNING_KEY_FILE读取独立keyring(active_key_id/private_keys),仅其本地业务Service持私钥;manager内部GET data-public-keys只返回验证公钥,没有签发API。签发前重查当前自然人owner、data mutex、generation/epoch、Pod UID/执行域及投射key_id;先提交5秒受理截止,再发票据。轮转先投射验证公钥并确认helper可见,再切backend active_key_id,旧公钥至少覆盖已发票据受理窗口。
+
+未知结果通过登录owner业务GET `/api/v1/apps/{app_id}/data/operations/{operation_id}/result`查询。原data操作未确认清理时拒绝新会话;其后创建独立result只读操作、新request_id与当前UID授权,引用原request_id/参数摘要,绝不重复原update。导出由helper→manager→backend分块校验长度/摘要并落私有短缓存,FileResponse结束回收;backend过期缓存下次导出清理,进程崩溃不会清业务PVC。原Docker bytes与原审计入口保持。
+
+启动接线:PORT、BISHENG_APP_PORT、SQLite路径、base path等由平台覆盖,业务令牌与runtime egress变量只进应用Secret;helper使用自己的材料身份。预检/预览schema-init仅接独立空库。启动失败先抓有界脱敏日志再清理,同UID应用重启日志可读previous+current;实际启动验收仍未执行。
+
+实施补充:owner显式删除时,先确认全部旧writer停止并清理;K8s调用者持久化既有合法maintenance阶段并重新检查取消、期限与归属围栏,不能把内部admitted作为执行事实。随后在同一固定生产位置启动purpose=purge的无应用/材料凭据helper,独占擦除business和platform备份;SQL先持久化purge结果,再停止helper并以UID/RV删除原PVC。只为128MiB/25m helper原子预占,不借用应用档位或在结果未知时释放。重启从维护进度续办;Retain PV由安装者在确认写入停止后处理。
+
+准备/启动参数继续从operation冻结:source_limits逐phase深拷贝;v2环境同时保留兼容变量与SDK0.1.1实际读取的BISHENG_PLATFORM_API_BASE。Node/static bundle保留固定launcher语义,完整image仍用其入口。已有Pod intent的RuntimeClass(含缺省)和deadline从同操作/应用/执行域资源记录恢复,不随可选sandbox探测刷新改变;其余模板仍严格比对。
+
+**数据取消的新动作闸(T230/T231)**:owner取消已提交payload.cancel_requested时,签发事务读取锁定当前操作并拒绝新数据票据;等待ready也立即拒绝。manager active校验同时识别公开兼容顶层与payload标志,在Pod/UID观察完成后、实际转发前再次fresh检查,减少观察I/O期间的竞态。已在途动作不声称回滚;原unknown、5秒票据受理窗口、helper idle和会话互斥/同UID停止证明保留。active=False停止与清理不被新动作闸阻断。无新endpoint、字段、授权Owner或helper协议。
+
+### 4.9 网络、准入和资源治理
+
+**网络**:运行命名空间预置默认 deny ingress/egress;应用只接收 app-proxy 到应用端口、manager 的健康/受控数据端口流量。Pod 不开放 NodePort、LoadBalancer、hostPort 或单应用 Ingress;每执行位置的 ClusterIP Service 按执行标签选择,未批准前不进入正式路由。
+
+业务/初始化/准备 Pod 只可连接 egress-proxy 的指定地址/端口,不直接放行 backend、MinIO、平台库、K8s API 或其它应用;默认不放行应用 UDP/DNS。代理地址以已验证的 ClusterIP 注入,外部域名由代理按策略解析,客户端需遵循现有 HTTP_PROXY/HTTPS_PROXY 合同。代理自身有受控 DNS 和平台连通性;镜像拉取由节点 CRI 完成,Pod 网络策略不能约束该路径,节点镜像端点由安装者配置。
+
+NetworkPolicy 检查含同节点、跨命名空间、IP/UDP、平台内网及跨应用直连;域名、私网解析和重绑定继续用既有 egress 检查。可信节点/cluster-admin 不在应用隔离威胁边界内;若现场允许不可信 hostNetwork 工作负载,需要客户 CNI 提供等价主机策略并通过探测,否则拒绝安装,不能把标准 NetworkPolicy 的限制算作通过。
+
+**准入**:安装独立验证服务,命名空间选择器只覆盖本安装专用命名空间,`failurePolicy: Fail`。资源模板规则与 4.5 的 `pods/status` 来源规则分开;后者仅检查 AdmissionReview,不回读 backend,也不把 kube-system 纳入作用域。PSA 固定 `restricted:v1.28`,manager 不可改标签/配置。验证服务自身无集群 token,通过独立只读 admission 身份读取 backend 的已批准执行模板/资源计划;失联拒绝新的创建,已有 Pod 不被重建。
+
+验证 CREATE/UPDATE/DELETE 的 namespace、申请者、资源所有者、模板摘要、UID、代次;拒绝 privileged、提权、hostPath/host namespaces/hostPort、额外 capabilities、SA token/projected token、越界 Secret/PVC、资源缺限、错误 RuntimeClass、任意镜像/命令替换、生产卷挂到非固定生产位置及不安全强删/移除保护 finalizer。读取不到当前计划时不信任可伪造标签。manager 不能更改该验证器、PSA、网络基线、ServiceAccount 或 RBAC。删除保护允许 manager 在 stopped 已持久确认后移除 finalizer;允许绑定 kubelet 对已具有效终态且带删除时间的同 UID 收紧删除宽限期,但不移除 finalizer。status 规则不可用会暂缓本执行命名空间的 API 状态更新与清理确认,不代替或阻止 kubelet 本地停止进程;恢复后重试。安装必须实测调度、init 失败清理和正常终止,不能只用 webhook dry-run 判通过。
+
+CREATE兼容仅接受API正常默认的Pending及按正常/init容器资源计算的QoS,不接受任何附加终态/容器状态;PVC空status、Service空loadBalancer和Pod空env的omitempty与缺省等价。来源依据固定[Kubernetes v1.28.15 Pod strategy](https://github.com/kubernetes/kubernetes/blob/v1.28.15/pkg/registry/core/pod/strategy.go#L82),保持UPDATE/status来源与冻结模板边界。Calico指定注解只由配置的认证CNI身份写入;manager同UID更新仅可保留旧值,不能伪造或删改。
+
+**容量**:每个 K8s 逻辑执行域固定一个架构及部署预算,复用 RuntimeNode/Reservation。混合架构集群按预配置架构域注册不同逻辑 node_id,共用集群但每应用冻结一个域;manager 启动选择一次,不在运行期自动迁移 profile。budget 来自安装者配置的可用工场预算/namespace quota/只读集群描述,不声称能无权限读取全节点实时剩余;scheduler 的真实结果必须有界验证。
+
+应用主容器 requests/limits 等于冻结档位;data-helper 等平台开销单列预占,不从用户主容器额度暗扣。设置 namespace ResourceQuota、LimitRange 和并发上限;镜像、材料、准入和卷错误不归类容量。无法满足拓扑/配额/资源的调度失败按 AC-08 分阶段处理。
+
+| 初始执行参数(拟定默认值) | 值 / 用途 |
+| --- | --- |
+| heartbeat / reconcile | 10 秒 / 15 秒;控制器租约 30 秒,每 10 秒续约 |
+| prepare / source-image | 1800 秒上限;预设并发 2,CPU/内存 1 核/2 GiB,可部署配置收紧 |
+| 调度/卷就绪等待 | 90 秒;达到后明确阶段归因,不把 ImagePullBackOff 算资源不足 |
+| startup/probe | 90 秒;readiness/liveness 10 秒周期、失败阈值 3;不健康检测+恢复预算合计不超过 300 秒 |
+| graceful stop / cleanup | 宽限 30 秒;控制面和 manager 可达时正常清理 900 秒内;失联期间保留 held/pending,恢复后 900 秒内续办;未知写入者保留阻塞与证据,不强行清卷 |
+| 单数据请求 / 数据并发 | 60 秒(含冷启动及响应)/ 全域 2、单应用 1;清理另外受 900 秒预算约束,超限拒绝 |
+| app-proxy / egress | 沿用入口鉴权/路由短缓存和 WS 合同;出站策略失效仍按既有 5 秒上界,不因 manager 停机延长 |
+| 临时存储 | emptyDir sizeLimit 与 ephemeral-storage request/limit 同时设置;scratch/preview PVC 显式容量及回收归属 |
+| helper 开销 | 默认 25m CPU/128 MiB request=limit,单列容量预算;不足时审批前或上线准入阶段按原合同拒绝,不改变主容器档位 |
+
+实际 image/pull/初始化耗时计入恢复预算;manager 将持续不健康的起点持久化,30 秒后仍未恢复或进入不断增加的 CrashLoopBackOff 时执行安全停止与同版本重建,不无限等待 kubelet 的退避窗口。反复失败时限制重建频率、保留“应用恢复中”并报告超过 300 秒,不宣称错误代码一定能恢复。预检测得固定材料冷重建无法满足时报告不满足恢复前置,不以修改 5 分钟承诺代替优化或供应条件。
+
+### 4.10 安装、离线、升级与卸载
+
+已实现 `deploy/kubernetes/app-runtime/` Helm Chart、受限基础清单、values.schema,以及 `scripts/verify_k8s_runtime.py` 安装探测工具;代码交付不代表现场探测已通过。Chart 不安装整个平台或默认修改现有 kube-system;安装过程分安装者权限与常驻 manager 权限。
+
+安装引导使用只读installer canary ConfigMap,计划最长1小时、固定helper/命令/镜像、无业务凭据或生产卷,避免节点未ready不能执行自身探测的循环。
+
+恢复使用逐Pod namespace/name/UID/deletionTimestamp绑定证据;未调度删除中Pod须由外部installer真实POST binding得到being deleted拒绝并再次读取,不能复用全局布尔值。已绑定Pod仍需可信终态,finalizer移除前再读一次停止状态。仅接管自然过期计划;丢失计划或同名替换不自动接管。
+
+Helm升级查验同release/namespace和UID/RV后原样保留canary plans.json及profile_images;旧无installation label对象额外校验同release descriptor安装范围再补标识。缺对象或错归属/非法数据拒绝升级,初装禁止values预置活跃计划。保留镜像列表不隐式授权新profile,新供应须由installer核对。descriptor.cni_username与admission ENV同源,CNI身份改变使旧报告失效。
+外部安装者真实验证15项基线、可选registry/沙箱后,CAS发布绑定descriptor摘要的24小时报告;每6小时刷新。失败/过期拒绝新工作;manager不能签发ready。
+
+生产与scratch写入前,可信init只读验证ext4/xfs、实际有界容量及namespace/installation marker;普通目录或loop未挂载不能通过。预检真实验证SQLite锁/fsync。helper由同版本manager、Node与Python preparer产物组合,固定prepare.py、原生Node库、SDK及wheelhouse/许可证;在禁网最终镜像中实际执行Python/uv/Node/static解析,不能以import或runtime已装包替代完整材料。离线新增依赖仍须匹配架构/ABI并提前供应。
+
+| 身份 / 资源 | 权限与职责 |
+| --- | --- |
+| 安装者 | 创建专用命名空间、PSA、准入配置/证书、Role/RoleBinding、配额、网络基线;供应 PV/StorageClass、节点镜像和只读部署描述 |
+| manager ServiceAccount | 仅目标执行命名空间 Pod CRUD/watch、Pod logs、Service CRUD、Endpoints/EndpointSlice get/list/watch、PVC 按供应模式创建/读取/受控删除、受限 Secret CRUD、Event 读取;不授予 pods/status、pods/exec、attach、portforward、Node/PV/SC/RuntimeClass、RBAC、Namespace 或 webhook 修改权 |
+| manager 所在系统命名空间 | 运行账号无权修改自己的 Deployment、准入服务、系统 Secret 或基线策略;由安装者升级 |
+| app-proxy、egress、准入、业务/任务/数据执行体 | automountServiceAccountToken=false,不投射集群身份;仅 manager 挂 namespace 作用域 token |
+| 只读集群描述 | 安装者生成并保护的节点架构/RuntimeClass/存储类型/拓扑及预算描述;变更后重新签收探测结果,manager 不自己扩大 API 权限 |
+
+manager 的 token 自动轮转文件按请求刷新,TLS 使用集群 CA,不以 host root、`kubectl`、Docker CLI 或 socket 为前置。验证服务的 API server TLS 证书由安装者供应和轮转;manager 不创建 CSR 或授予自己权限。安装只在专用命名空间启用 status 来源规则并验证节点认证用户名,不修改现有 kube-system/节点服务;不符合认证合同的环境阻断安装。升级保持 policy revision 连续,不能卸掉保护后领养旧 Pod。目标 K8s 版本首轮验证 1.28.15,未测版本不声称兼容。
+
+离线包包括:manager/app-proxy/egress(复用 manager 镜像的独立命令)、helper、准入、OCI assembler 的精确双架构镜像清单,profile/manifest/config 摘要,基础镜像、SDK wheel、完整 wheelhouse/npm tarballs/系统库环境、Chart、依赖与许可证清单、校验工具及平台内部 URL/CA/服务身份配置。所有二进制在 CI 原生构建或验证,不在客户现场下载。Ed25519/密码实现使用维护中的密码库(backend 既有 cryptography;manager/helper 如需生成则显式加入锁与镜像清单),不自行实现曲线算法;OCI 工具与新增依赖完成许可证检查。
+
+镜像供应可选冻结 digest 可在 CRI 中解析的预灌,或内网分发端点。**docker load 成功不是 CRI 按该引用可启动的证明**;预灌引用必须逐个在目标 CRI 创建探测 Pod,核对平台/镜像身份。若现场 Docker 导入不能保留 digest 引用,提供随安装包的可选只读内网基础镜像分发服务;这不是默认应用包必须推送应用镜像的仓库。source-image 模式需要可推送的受控仓库;没有时只拒绝该模式。不得把 profile 的 config digest、manifest digest 和 tag 混为同一字段。
+
+升级先只读检查 SQL/协议兼容和未完成维护,旧版本备份配置;执行扩展式模型迁移,滚动升级系统服务。manager 不删除正式 Pod、不重绑 PVC、不改变冻结运行组合。入口/出口升级设置停止接新连接、排空和既有强制寿命,不能承诺强杀不掉 TCP。回退受数据库 schema/协议兼容范围约束,不自动回滚应用工作库。
+
+卸载先关闭业务运行时开关,撤销入口/材料/出口并有界停止执行体,确认终止后移除系统服务及本安装的控制资源。生产卷、未确认保护备份及平台产物保留;namespace/PV 不纳入默认 Helm 删除,不执行 `kubectl delete namespace`。有未知写入者时卸载报告未完成并保留必需的保护记录,不能删 finalizer 假装成功。
+
+
+现场兼容细化(T164–T174):
+
+- `execution.storageControllerUsernames` 是显式可信控制器用户名列表;admission 的 `RTM_STORAGE_CONTROLLER_USERNAMES` 与 descriptor.storage_controller_usernames 同源。更换它使旧报告摘要失效;不增加任何 RBAC。189 的真实 PVC binder/protection 使用各自 kube-system ServiceAccount,不能假设为 controller-manager 用户名。
+- `kubectl auth can-i` 使用资源 TYPE 与 `--subresource=exec/status`;`pods/exec` 会按 TYPE/NAME 解析,不能作为 exec 权限证据。
+- Core API 对从未绑定的 Pending Pod 将 DELETE 宽限规范化为 0。仅同 UID/RV、无 nodeName、无运行状态历史、无 PodScheduled=True、无 Unknown/NodeLost 时兼容;绑定或写入状态不确定仍拒绝强删。finalizer 释放仍需本资源停止证据与 fresh reread。
+- PSA 负例使用 schema 有效的 privileged=True / allowPrivilegeEscalation=True,只接受真实 PodSecurity 拒绝;dry-run 的 UID=None 计划在 finally 撤销,撤销失败和探测失败均以安全原因保留。旧版本留下的 UID=None 计划不自动收养或伪造 UID。
+- 网络白名单须覆盖平台实际数据路径。189 平台经 Docker published port 转发,Calico 看到的目标端口为 DNAT 后 443/7860;只放行宿主入口 5444/17860 会阻断 manager heartbeat。部署值修正不能替代应用出站隔离测试。
+- 原生发行 smoke 的 hostile CWD 显式0755、poison 文件0644,UID10001先实际读取候选再验证 `-I` 只导入可信包,避免因不可读目录导致假阳性。该修复只影响验收脚本。
+
+
+终态删除与Docker回归补充:Core也会将已结束Pod删除宽限归0。manager仍先核对计划/actor/scope/UID及非空RV,再以API oldObject确认受保护、已绑定、Succeeded/Failed及所有容器停止;现存containerID无terminated、畸形状态或Unknown/NodeLost均拒绝。该兼容不写停止事实或释放finalizer,kubelet仍需双向节点身份。
+
+Docker诊断通过既有 `PlatformClient.node_snapshot()` 读取权威capacity,平台失败不可使用本地值。Docker保持原公开字段(无K8s专属ready/executor_kind);E2E按已知Docker合同要求v2、可读账本、运行时和全部预检成功,K8s继续检查显式executor_kind/ready。native SQLite验收在v1后经HTTP写随机nonce,v2不携带该值;同时核对/data bind与目录/DB inode、真实helper rows及下载备份的nonce,拒绝重建或换卷假阳性。
+
+
+验收批次与产品审计分离:删除后的全局slug继续保留。bootstrap增加显式可选`--application-suffix`(1–16小写字母/数字及单连字符分段,最终slug≤64),manifest/name/scenarios一致;suite marker与私有env冻结同一后缀,改后缀或漏参不重写旧批。重跑使用新suite-dir与新journal,仅复用已核对的自然身份/租户/key,旧批按其精确账本清理,不清SQL/FGA。Docker --env-file须从可信解析器读取shell格式env并写私有字面值文件,防止引号进入真实密码。
+
+## 5. 已知坑与处理位置
+
+- Kubernetes ObjectMeta.finalizers 使用omitempty;API移除最后一个finalizer时可省略字段。准入仅将该空数组与省略等价,仍先核对停止已确认和同UID;额外非空finalizer不放宽。189真实dry-run复现,配对测试先红2后绿198。
+- ScopedRuntimeRecord以None默认等待真实before_flush租户注入,存储仍非空有索引;模型测试用局部Session子类注册真实hook,避免向后续审批测试泄漏进程级监听器。完整backend组合已复验。
+
+
+| 反直觉事实 | 如果不知道会怎样 | 在哪处理 |
+| --- | --- | --- |
+| manager 重启时旧 holder 租约未到期,正常 409 不属于业务失败 | 错误进入业务失败清理并在自然交接后杀掉原在途安装 | `operations.py` 在 claim/执行及业务失败处理前取得 controller;T272/T273 与 R32 同 UID/CID 续办实证 |
+| CI 并发锁若按 ref 分组,而发布标签只按版本产生 | 不同入口同时发布同一版本,存在性检查不能阻止检查后覆盖竞争 | `.github/workflows/runtime-images.yml` 按 `inputs.version || github.ref_name` 分组;仅约束此仓库工作流,仓库外写入仍需权限/不可变策略 |
+| v2预检容量拒绝为16125而legacy为16226 | CLI若只登记legacy码将容量不足显示为未知错误并exit19 | `bisheng_cli/errors.py`两码映射既有EXIT_CAPACITY;实际分发wheel及manifest已同步,T264/T265已实现 |
+| 原 Protocol 是 Docker API 形状,目录逻辑散落在多个模块 | 加 K8s 文件后仍然访问 manager 本机路径或 Docker | 新 `executors/`;原 operations/preparer/data API 统一依赖能力接口 |
+| RWO 是单节点挂载,不是单 Pod 或进程写锁 | 同节点新旧应用或维护并发写生产库 | `executors/kubernetes.py`、资源账本、停止确认及固定生产位置准入 |
+| ClusterIP 不能默认由集群外平台主机访问 | 平台与 Pod 均健康,发布唤醒/应用访问却超时 | 安装 values 的共享 entry/control 私网接入及端到端连通性探测 |
+| Docker发布5443的仓库可在Calico策略阶段已变成5000;外部installer能推送不证明egress Pod能连通 | 安装16项通过而真实source-image在crane阶段超时 | network.egressPorts同时按现场DNAT后端口配置;verify_k8s_runtime仓库检查先用egress-proxy同selector的NotReady canary检查TCP,外部crane与业务E2E分别证明其他层 |
+| finalizer 保留对象,普通 Failed/404 不证明停止;从未启动的容器也可能没有 ID | init 镜像/材料失败后永久卡住,或节点分区后误释放 | `k8s_lifecycle.py` 分 never_scheduled/kubelet_terminal/unknown;status 来源准入,无 ID 不单独阻塞也不单独放行 |
+| Deployment/Job 控制器可以自主补 Pod | manager 停止旧实例做迁移时,旧实例又出现 | 本轮用直接管理 Pod;正式/维护共用固定名称 |
+| init 与业务容器共享 Pod 网络 | “只给 init 放行 backend”实际也给了业务容器网络通路 | 只放代理端点,容器凭据分离,backend 动作鉴权 |
+| 准入 webhook 会反向读取 backend 的资源计划 | 创建事务持锁并同步等 K8s API 时会相互等待、超时拒绝 | 先提交资源意图,后请求 K8s;webhook 只读已提交计划 |
+| workload 签名若未绑定动作、偏移、请求体与票据,无法建立可互操作的传输合同 | 实现各自验签,重试可能错位或越权 | backend `runtime_material_protocol.py`、manager `material_protocol.py`/`material_client.py` 使用6.2.1唯一canonical格式;固定向量与本地互验完成,准备阶段真实传输已有证据 |
+| 操作完成会使现有材料接口拒绝下载 | 日后 Pod 重建失败,而在线版本曾经成功 | 新材料授权路径;不能延长原操作或给业务容器服务密钥 |
+| K8s覆盖bundle PATH也覆盖了镜像系统目录;nginx位于/usr/sbin | Python/Node可运行但static entrypoint找不到nginx,app exit127 | `k8s_templates.py`在依赖bin之后保留标准sbin/bin;image模式不覆盖,用户PATH注入拒绝 |
+| venv 固定路径与镜像 ABI 相关 | 打包开发机环境在节点无法运行 | profile+锁/材料+`/opt/venv` 物化,跨架构产物隔离 |
+| 离线镜像、wheelhouse 和平台能力是三个条件 | 页面健康却缺 SDK/依赖或无法调用模型 | 安装报告分项、原生离线 prepare/SDK E2E |
+| Node/Python 安装脚本可执行用户代码 | 准备任务盗用上传/管理凭据 | 传输与安装 Pod 分开,安装/编译无传输身份、无生产卷 |
+| 多个init与业务健康共用创建起90秒 | cached镜像也可能在环境安装刚开始就误判失败,重启若从now重计又可无限延长 | `executors/kubernetes.py`以同UID创建/前阶段finishedAt锚定,且不超过原操作期限 |
+| owner取消只是持久请求,操作线程尚未退出 | 仍ready的数据会话可新签票据或在观察I/O后发送新写入 | grant签发/ready当前状态检查;`k8s_data_executor.py`在RPC入口及转发前复查,既有动作按unknown保留 |
+| maintenance helper与transfer一样读取材料PodUID,而purge无材料身份 | 漏注入downward字段会在SQLite维护前KeyError,预检/预览仍可能正常 | `k8s_templates.py`仅对有material_secret的可信maintenance增加core/v1 metadata.uid;真实reader合同与禁网原生A/B |
+| finalizer清空时旧删除grace仍可能为正,kubelet会再次DELETE0 | 只允许带STOP的终态删除会在已持久停止后永久卡清理 | `k8s_admission.py`为无STOP的绑定kubelet逐次核对精确同UID停止授权;不放宽manager强删 |
+| 同步preview/resume/stop/delete的HTTP可等待超过75秒 | E2E客户端先超时,不能证明操作实际失败;重发可能造成重复意图 | `test/e2e/helpers/vibe_runtime.py`精确成功路径使用settings总预算且无POST重试;产品操作期限不变 |
+| DELETE受理后对象仍存在是正常异步过程,prepare的最终结果也必须单独持久化 | 一次cleanup.pending被误作发布失败,或重启重复准备且覆盖已上传结果 | `operations.py`有界等待与`prepared_delivery`;`executors/kubernetes.py`仍以同UID停止证明和fresh404完成收尾 |
+| Pod activeDeadlineSeconds 不能从短启动期限调大或清空 | 预览 ready 后延长期限被 API 拒绝或正式实例到期被杀 | `k8s_templates.py` 区分任务、预览与正式期限;初始化自带阶段限制 |
+| RWOP 不允许再开第二个在线数据 helper Pod;停用后又没有在线 sidecar | 在线挂卷一直 Pending,或停用后保留库无法查改导出 | 在线 helper 同 Pod;停用 data 会话先取得空生产位置,确认停止后释放 |
+| Helm 卸载或 ownerReference 能级联删除 PVC | 不经 owner 删除就丢业务数据 | 保留命名空间/PVC,禁止生产卷依附短命执行体 |
+| rootless 不等于任意老内核无权限可构建 | 在 189 现场为构建打开 privileged、系统调用或宿主 socket | 默认 bundle;兼容模式受限 OCI 组装,缺前置审批前拒绝 |
+
+## 6. 对外契约与依赖
+
+### 6.1 执行能力与现有 HTTP 合同
+
+以下Python能力接口与data的K8s运输层均已接线;本地合同及 R11 真实 owner 数据行程通过,剩余故障矩阵独立记录。backend不import manager包,DTO JSON是进程间合同:
+
+```python
+class RuntimeExecutor(Protocol):
+    def capabilities(self) -> dict: ...
+    def prepare(self, operation: dict) -> dict: ...
+    def launch(self, operation: dict, *, preview: bool = False) -> dict: ...
+    def observe(self, exec_ref: str) -> dict: ...
+    def request_stop(self, exec_ref: str, operation: dict) -> dict: ...
+    def confirm_stopped(self, exec_ref: str) -> dict: ...
+    def cleanup(self, operation: dict) -> dict: ...
+    def logs(self, exec_ref: str, *, since: int | None, tail: int) -> str: ...
+    def data(self, operation: dict, action: str, parameters: dict) -> object: ...
+```
+
+`confirm_stopped` 返回 stopped/unknown 及 `evidence_kind`/证据引用,不能将 unknown 转 bool(false) 后当可清理。操作上层统一决定事实和业务状态。K8s create/patch/delete、流式日志、超时/watch 重连仅由 adapter 实现。传输使用现有 httpx 库调用稳定 REST API,新增 `k8s_client.py` 统一 TLS、token 轮转、label selector、分页与 410 relist;不在业务模块拼接任意 API 路径,也不调用 kubectl。
+
+| 既有 HTTP 合同 | 调用者 / 消费者 | 兼容规则 |
+| --- | --- | --- |
+| manager `POST /v2/operations`,仅 operation_id | backend 唤醒 | 保留 202;操作内容从 backend 拉,不能由调用者附带 PodSpec/路径 |
+| manager `GET /v2/apps/{app_id}/status`、`/logs`、`/db/*` | backend/CLI/MCP 的现有封装 | 业务数据形状、过滤、脱敏保持;exec_ref 改为后端透明标识 |
+| backend `/internal/app-runtime/v2/nodes/heartbeat`、`/nodes/plan` | manager | node_id 表示配置固定的执行域,不等于物理 K8s nodeName |
+| backend `/operations/{id}`、`/claim`、`/facts` | manager | generation、owner_epoch、sequence 与幂等规则保留,扩展控制器租约校验 |
+| backend `/routes/apps/{id}`、`/routes/previews/{id}` | app-proxy entry 身份 | host 继续是私有 IP,使用已登记 Service ClusterIP,不传 DNS 破坏当前校验 |
+| backend `/egress-policies` | egress 专用只读身份 | 加 material 用途投影;不依赖 manager 的本地策略文件 |
+| backend `/transfers`、`/downloads`、`/transfers/{id}/content` | manager 现有路径 | v2 manager HMAC 语义保留;不把工作负载票据塞进 manager 身份 |
+
+现有服务签名保留 `X-Runtime-Key-Id/Timestamp/Nonce/Signature` 和 v2 canonical message,窗口 60 秒、nonce 120 秒;manager、platform、entry、egress 和新增 admission 角色互不冒用。工作负载材料接口使用自己的校验依赖,不落到现有 `role("manager")` 路由。
+
+### 6.2 新增内部数据与接口(已实施接线,完整交付待验收)
+
+| 对象 / 扩展 | 关键字段 | 权威与风险 |
+| --- | --- | --- |
+| `RuntimeExecutionResource` | id(UUID)、tenant_id/app_id、operation_id/node_id、cluster_id、namespace/name/uid、kind/purpose、generation/owner_epoch、spec_digest、status_policy_revision、observed_state(受限Service端点事实及版本化Pod creation receipts;不保存原始API响应)、stop_state、stop_evidence(evidence_kind/RV/digest/node/删除时间)、observed_at | backend Repository;资源身份不只靠标签/名字。exec_ref 使用 UUID,避免现有 128 字符字段容不下组合 URI |
+| `RuntimeMaterialDelegation` | id、scope_kind、tenant/app/version/delivery、operation_id、generation/owner_epoch、exec_ref/Pod UID、public_key、allowed_artifacts/actions、proxy_credential_hash、expires_at、revoked_at | 只存公钥/哈希,不保存 workload 私钥;服务根据归属计算允许材料,不能用户自填白名单 |
+| `RuntimeNode` 扩展 | executor_kind、capabilities、controller_holder、controller_epoch、lease_until、probe_report | 保留旧 Docker 字段与默认值;历史 JSON capabilities/probe_report 为 NULL 时按未验证处理,不以内联数据迁移填充;逻辑域预算不是实时全节点余额 |
+| `RuntimeTransfer` 扩展 | authorization_kind、authorization_id | 历史 authorization_id 暂可为 NULL,独立运维回填 operation/id;新旧 ORM 插入默认沿 operation_id 生成授权范围。复合唯一约束改为授权范围+材料+动作;deployment-init 不再强制检查 completed 操作;原 operation 分支保持终态拒绝 |
+| `RuntimeAssignment` 扩展 | data_operation_id | 在同一应用行锁下持久互斥;数据会话不占用 serving active_operation_id、generation 和路由 |
+| `RuntimeOperation` 内部扩展 | kind=data、request_id、action/parameters_digest、actor_ref、target_exec_ref/UID、deadline、result/cleanup 状态 | 原业务 Service 授权创建;每应用数据/维护互斥,不改 serving 代次,不向 CLI 暴露新业务动作 |
+| 操作事实 detail 扩展 | resource_ref、UID/namespace、镜像身份、stopped 证据、failure stage | 通过已鉴权 manager 提交;不接收业务容器自报成功 |
+
+节点心跳兼容扩展executor_kind/capabilities/probe_report,node plan返回控制器快照;K8s facts的detail.controller必须匹配当前holder/epoch及未过期租约。ready引用同操作/代次的Pod/Service UID,目标等于受限observed_state;stopped必须账本停止证据,事实/终态/容量释放在同一SQL事务。硬前置失效阻断入口和新建,仍可取清理租约;状态/绑定保护断档不可事后抹除。
+
+manager可GET资源脱敏读模型及按操作列举资源,重启不依赖本地记录;相同未创建意图可由新租约恢复controller_epoch。活跃操作中间阶段停止不释放整笔容量,终态仍要求所有相关Pod的同UID停止证据。
+
+控制Service只提交SQL资源意图,返回时事务已结束;manager随后才调用K8s API。Node.engine_id固定为`cluster_id/namespace`,注册时校验冻结域、镜像和profile、资源标签/代次、摘要与租约;Secret和env仅接受脱敏投影。控制路由经现有外层`/api/v1`挂载;材料内容接口仍严格使用6.2.1冻结的根路径,不增加该前缀。
+
+新增路由位于 backend app_runtime API → Service → Repository:
+
+| 新合同 | 认证 / 参数 | 输出及拒绝条件 |
+| --- | --- | --- |
+| `/internal/app-runtime/v2/controllers/claim`、`/renew` | manager HMAC,holder_id/node_id/expected_epoch | 有界租约;陈旧 epoch 不得提交资源变更或事实 |
+| `/internal/app-runtime/v2/resources`、`/{id}/observations` | manager HMAC,操作与 canonical resource digest;绑定 UID | 预记/确认资源及停止事实;错范围、同名异 UID 或过期控制器拒绝 |
+| `/internal/app-runtime/v2/material-delegations`、`/{id}/bind` | manager HMAC,operation/exec_ref、公钥与 UID | 后端推导只读/上传范围;仅在当前操作/归属允许时创建或绑定 |
+| `POST /internal/app-runtime/v2/material-access/exchange` | 6.2.1 material-v1 签名,下载 artifact_id 或获准上传声明 | 单材料/单动作会话和 300 秒票据,续期重新授权;精确字段、拒绝与重试见 6.2.1 |
+| `GET/PUT /internal/app-runtime/v2/material-access/transfers/{id}[/content]`、`POST .../finalize` | material-v1 签名+范围票据 | 方法、偏移、请求体、票据均受签名覆盖;6.2.1 冻结全部请求、响应和失败语义 |
+| backend 原数据 Service 的 `RuntimeDataGrantService.issue()`(新增内部调用) | 原业务 Owner 验证的 actor/目标、当前 exec_ref/UID、有效 data 操作、动作和参数摘要 | backend 私钥签发 5 秒受理窗口的一次性票据,helper 仅持公钥;manager 无签发接口 |
+| `/internal/app-runtime/v2/admission/plans/{resource_ref}` | admission 专用只读服务身份 | 当前已批准模板/资源归属/UID及写入阻塞条件;没有创建操作权限 |
+| manager `/v2/runtime/capabilities` | platform 控制 HMAC | 安装能力/前置逐项报告、时间及原因;由既有运行环境状态接口聚合,不新增业务页面 |
+
+操作结果示例;以下是提案数据,不是真实资源或凭据:
+
+```json
+{
+  "exec_ref": "",
+  "target": {"host": "10.96.12.34", "port": 8080},
+  "ready": true,
+  "resource": {"namespace": "bisheng-runtime-apps", "name": "app-", "uid": ""},
+  "generation": 3,
+  "writers_stopped": true
+}
+```
+
+Service UID、Pod UID 及其映射同时入账;ready 前确认 selector 对应本 UID、EndpointSlice/Endpoints 已就绪。backend 仍用 App 状态、审批及冻结版本判断能否切路由,不能只信 ready=true。
+
+扩展迁移只改本 Feature 所需模型,保留旧表语义与跨库工具;历史 Docker transfer/operation 不被误认为 deployment-init。`RuntimeRoute.target` 的 host/port 保留,更多 K8s 字段只进入运维记录。所有新字段及租户登记、删除保留、索引上限和 DM8 约束须在任务评审逐项检查。
+
+### 6.2.1 material-v1:工作负载材料协议(编码/Service/HTTP已本地验证)
+
+这是 backend 与可信材料 client 的唯一 wire contract;独立于 manager 的 HMAC,不接受应用/OBO 凭据或 MinIO 直链。以下 `P` 固定为 `/internal/app-runtime/v2/material-access`,所有接口仅允许 TLS,禁止客户端自动重定向;代理不得重写签名路径或请求体。实施注册在 backend main 的独立根路由,不能沿用 app_runtime 聚合路由的 `/api/v1` 前缀,否则会改变已确认签名字节;T026/T027须验证真实注册路径。
+
+**类型与编码**:新授权/传输 ID、Pod UID 为小写带连字符 UUID;artifact_id 继续使用原材料 ID。所有摘要为 64 个小写十六进制字符,size/offset/length 为非负 JSON 整数或无前导零的十进制查询值,拒绝布尔冒充整数。JSON 是 UTF-8 对象,拒绝重复键、未知字段和无效数值;控制请求体上限 64 KiB,上传 chunk 上限沿用 16 MiB。签名覆盖实际发送字节,不依赖不同语言的 JSON 重排结果。
+
+| 请求头 | 精确格式与来源 |
+| --- | --- |
+| `X-Material-Delegation` | backend 授权 UUID;只定位公钥/范围,不能建立 UID 归属 |
+| `X-Material-Key-Id` | 授权内登记的公钥版本,ASCII `[A-Za-z0-9_-]{1,64}`;不接受 URL 或调用方附加的公钥 |
+| `X-Material-Pod-Uid` | manager 已绑定的 Pod UID,必须与授权一致 |
+| `X-Material-Timestamp` | UTC Unix 秒整数字符串;服务端接受时间差绝对值严格小于 60 秒 |
+| `X-Material-Nonce` | 每次 HTTP 请求重新生成的 128-bit 随机数,32 个小写 hex 字符;不复用用于幂等 |
+| `X-Material-Content-Sha256` | 实际原始请求体的 SHA-256;GET/空体使用空字节摘要 |
+| `X-Material-Ticket` | exchange 不发送;其余请求必填 backend 发放的随机 ASCII token,仅存哈希、禁止日志记录 |
+| `X-Material-Signature` | Ed25519 对下述 UTF-8 输入的 64-byte 签名,编码为 128 个小写 hex 字符;不用 Ed25519ph,也不先对 canonical message 另做预哈希再签 |
+
+每个认证头只能出现一次,拒绝 CR/LF、重复/歧义编码和同时传入服务 HMAC 身份。验签依赖只从固定请求头取身份;URL/body 的 tenant/app/Pod 字段不能覆盖权威范围。
+
+**签名输入**:按以下顺序组成 **11 行**,用单字节 LF 分隔、无 BOM、无末尾换行。`path` 是完整固定路由,不含 query;路径 UUID 必须规范化,拒绝转义路径、重复斜杠及尾斜杠。query 只允许下表指定的 offset/length;按键名排序后 `key=value` 以 `&` 连接,禁止重复/未知键。空 query 保留空行。最后一行是 ticket 的 UTF-8 SHA-256,exchange 对空字节取摘要。
+
+```text
+BISHENG-RUNTIME-MATERIAL-V1
+
+
+
+
+
+
+
+
+
+
+```
+
+backend 先做格式/大小和公钥定位,再验签并核对实际 body 摘要,校验 ticket 哈希和当前归属/动作/材料;随后 Redis 对 `(delegation_id, nonce)` 做 SET NX、保留 120 秒,重复拒绝,Redis 不可达拒绝。随机 nonce 是防重放;操作/传输 ID 和持久 offset 才是幂等键。上传按有界临时文件增量算摘要,通过前不推进平台 offset;提交 SQL/最终关联前再次校验归属与有效期,撤销和并发状态变化不能被验签时刻掩盖。
+
+| HTTP 合同 | 输入(除认证头外) | 成功响应及持久语义 |
+| --- | --- | --- |
+| `POST P/exchange` 下载 | 无 query;JSON `{"action":"download","artifact_id":""}` | 200 会话 JSON;只能指向授权中冻结且 verified 的材料 |
+| `POST P/exchange` 上传 | 无 query;JSON `{"action":"upload","kind":"compiled","sha256":"","size_bytes":123,"platform":"linux/amd64","metadata":{}}` | 200 会话 JSON;仅有效任务允许的输出类型,kind/digest/大小/platform 持久绑定,deployment-init 永不允许上传 |
+| `GET P/transfers/{id}` | 空 body、无 query | 200 状态 JSON:同授权/材料/动作的 transfer_id、artifact_id、action、state、offset、sha256、size_bytes;不返回新 ticket |
+| `GET P/transfers/{id}/content` | 空 body;必须有 offset、length,`1 <= length <= 16777216`,offset 不超过材料大小 | 非空返回 206 字节流及 Content-Range、Content-Length;上限截到材料结尾。空材料 offset=0 返回 200 空体;非空末尾 offset=size 返回 416。每块独立鉴权,最后核对整个材料摘要 |
+| `PUT P/transfers/{id}/content` | 仅 offset query;body 为 1 至 16 MiB 原始字节 | 200 `{"transfer_id":"","offset":,"state":"open"}`;持久记录 offset/长度/chunk sha 后推进。旧偏移且已确认同字节返回当前 offset;不同字节或跳过偏移返回 409,不覆盖 |
+| `POST P/transfers/{id}/finalize` | 无 query;JSON `{"sha256":"","size_bytes":123}` 必须与会话声明一致 | 200 既有材料公开字段,state=verified、confirmed_at 非空;验证全量内容并持久保存后才返回。重复 finalize 返回同一已确认材料;snapshot 仍须 4.8 的 SQL 关联确认后才允许 DDL |
+
+上传 metadata 在workload路径采用固定白名单:compiled仅source_artifact_id/dependency_artifact_id,dependencies仅input_key/profile_id/profile_digest,snapshot仅format/integrity_result;引用必须匹配冻结操作,值为不超过512字符的字符串。原manager路径不扩展授权。不能控制对象路径、关联其它应用或扩大允许动作;每类材料总大小和安全解包规则沿用既有限制。GET content 只适用于 download 会话;PUT/finalize 只适用于 upload 会话。空材料上传不发 PUT,直接 finalize。
+
+会话 JSON 固定为 `transfer_id:UUID`、`artifact_id:str`、`action:download/upload`、`state:open/confirmed`、`offset:int`、`sha256:str`、`size_bytes:int`、`kind:str`、`platform:str`、`ticket:str`、`expires_at:int`(UTC Unix 秒);原公开材料字段保持不变。下载 offset 的初值为 0,由 client 管理本地连续已验证块;状态接口的 offset 不被解释成 client 已收到的字节。
+
+**续期、重试与失败**:
+
+- 授权仍有效时,新的 exchange 请求用新 nonce/签名查同一 `(authorization_id, artifact_id, action)` 会话并轮换 ticket,保留已提交上传 offset;旧 ticket 立即失效。不能凭旧 ticket 单独续期,不能为过期操作签新票。上传声明变化须拒绝,不偷偷续接旧内容。
+- 响应丢失,client 用新 nonce 读状态,再按已确认 offset 继续;同字节重发不重复追加。nonce 重放拒绝不代表前一次请求未执行。finalize 响应丢失同样读已确认事实后恢复。下载半块响应失败丢弃该未完整块,重新请求该 offset;完整材料摘要校验前不得解包或执行。
+- 每个内容响应最多 16 MiB,内容请求至多 60 秒且不超过 ticket/操作剩余期限;控制和 finalize 请求受 ticket 剩余时间及操作总 deadline 约束,不能把大材料完整校验错套成数据 helper 的 60 秒执行窗口。流式阶段最迟每 5 秒及提交前复核有效归属,失效立即结束,不允许凭请求开始时的票据无限续传。累计材料传输仍受整个阶段 deadline 约束。
+- 400 表示格式/编码/非法参数,413 表示大小限制;401 表示签名/ticket/时间失效,403 表示越界/撤销/UID 不符,409 表示 nonce 重放、内容/offset 冲突、未绑定或 finalize 不完整;429 表示并发限制,503 表示授权/nonce 服务不可确认。只返回安全 reason,业务端映射原 Owner 错误。401 可以重新 exchange,但仍须有效私钥和归属;403 不自动重试;409 查状态后决定;429/503 在原 deadline 内退避,不续长任务。
+
+**固定编码向量(T020/T021两端编码与互验及材料授权/传输已接入;189准备阶段已实测,完整续期、撤销及正式初始化另验)**:对下列假数据,ticket 的实际测试值为 `example-ticket`,请求体为空;canonical message 末尾没有换行。helper/backend 的契约测试必须对相同输入产生这些字节,再用测试专用 Ed25519 key 做互验。这里只发布假 token 和摘要,不发布生产密钥。
+
+```text
+BISHENG-RUNTIME-MATERIAL-V1
+11111111-1111-4111-8111-111111111111
+material-v1-01
+22222222-2222-4222-8222-222222222222
+1792531200
+000102030405060708090a0b0c0d0e0f
+GET
+/internal/app-runtime/v2/material-access/transfers/33333333-3333-4333-8333-333333333333/content
+length=1048576&offset=0
+e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
+ce8ed3a5d85ad3633d8ee0394e7d91600f0adc33de14d0a33622c67aceed383a
+```
+
+该输入共 **414 bytes**,其 SHA-256 为 `206cf7e08bc12e23245980c45989fe8165233183e18bed03b99a8c17c6050167`。这只是核对 canonical 编码的金样,不把该摘要替代 Ed25519 的原始签名输入。反例至少覆盖:改 method/path、偏移/length、原始 body、ticket、Pod UID、nonce,重复认证头、query 重排/重复键、过期和 Redis 失联,均须拒绝或按规定重新规范化后由 client 重签;服务端不默默接受非规范线格式。
+
+材料下载使用backend存储适配器的MinIO get_object offset/length原生Range,并始终关闭/释放连接;不从对象开头扫描跳读。分块期间继续检查当前授权,内容最终摘要合同不变。
+
+### 6.3 配置合同
+
+下表说明当前已接入的配置与读取者;“新增”表示相对历史基线的扩展,不表示仍未实现:
+
+| 配置入口 | 现有 / 新增 | 读取者与用途 |
+| --- | --- | --- |
+| backend `app_runtime.enabled`、protocol_version、服务身份、profile/档位 | 现有 | 业务开关及平台授权;不放 K8s token、hostPath 或 MinIO 第二套凭据 |
+| `RTM_EXECUTOR`(`docker`/`kubernetes`) | 新增 | manager composition root;默认 docker,K8s 要求 protocol v2;不提供运行期切换 |
+| `RTM_K8S_NAMESPACE`、cluster_id、逻辑 node_id/平台 | 新增及既有 node_id | manager;白名单执行域,安装者静态登记,不能由发布请求覆盖 |
+| `RTM_K8S_DEPLOYMENT_DESCRIPTOR` | 新增,挂只读文件 | 经安装者管理的架构/预算/存储/沙箱与基线版本;不含平台存储账号 |
+| 集群 SA token 与 CA | 标准 Pod 注入,仅 manager | HTTP client,重新读取轮转 token,CA 校验;测试 client 可注入 mock transport |
+| Chart `storage.className/sizeMi/scratchSizeMi/volumes` → descriptor `storage` | 新增 | manager读取冻结的存储合同;仅消费获准卷,生产保留与物理清理分开;没有`RTM_K8S_STORAGE_MODE`入口 |
+| `RTM_K8S_SANDBOX` 和 RuntimeClass 映射 | 新增 | 安装者配置,能力实测;默认档/推荐/加固实际值可查询 |
+| `RTM_*` 服务身份、超时、egress 地址、profile 镜像 | 既有或分组扩展 | manager/egress 分别读取自己的身份;K8s 不读取 Docker socket 配置作为回退 |
+| `services.entry/control.exposure` 及私网地址/来源 ACL | 新增 Chart 配置 | 安装者;平台在集群外时的共享 nginx→app-proxy、backend→manager 接入,禁止按应用开放节点端口 |
+| `APP_PROXY_PROTOCOL_VERSION=2`、backend/entry 身份 | 现有 | app-proxy 查询 backend 投影;不持 K8s 凭据,不依赖 manager 短路由 |
+| 应用 PORT、BISHENG_APP_DB_URL、base path、业务应用/OBO 变量 | 既有 | 仅业务容器;`sqlite:////data/app.db` 不改变 |
+
+Chart values 分 `platform`、`services`、`execution`、`storage`、`network`、`admission`、`images`、`offline`,有 schema 校验;K8s 配置不新加到 backend 未登记的 YAML 顶层。镜像 profile 仍通过既有超管接口导入版本化目录,不能用改 manager 镜像或 values 默认值替换已批准版本。
+
+### 6.4 依赖与破坏面
+
+| 依赖 | 使用方式 | 变化风险 |
+| --- | --- | --- |
+| F054/F055/F086 | App 状态、审批、冻结版本、持久操作/投影及安全数据动作 | 更改状态/归属或 stage 顺序会破坏上线/续期;不能在 K8s adapter 另造业务规则 |
+| backend 材料接口 | 平台中介传输、摘要和 SQL 确认 | 原manager HMAC及新增workload material-v1授权路径均已实现;身份继续隔离,不能用旧路径冒充工作负载 |
+| K8s Core/Discovery API 1.28 | Pod/Service/PVC/Secret/Events、Endpoints/EndpointSlice 只读 | watch 过期 410 要 relist;429/5xx 退避;状态来源与 UID 必须保留 |
+| 独立准入与 PSA/CNI | 模板边界、status 终态来源、生产位置限制、网络允许/拒绝 | status 规则不查询 backend;缺失/失联/策略失效阻断新执行和无来源的停止确认,既有本地停止仍由 kubelet 执行 |
+| 存储 | local PV 或已验证块存储文件系统 | 锁、fsync、拓扑、回收策略不同会破坏单写或持久化 |
+| profile/runtime/preparer/helper | 固定路径、语言/SDK/工具链与 ABI | 同 tag 不代表同内容;必须冻结精确引用与平台 |
+| OCI 组装器与仓库 | 固定 CI 版本的 crane/OCI 工具、基础 manifest、层追加与推送 | 新 CPU/系统、仓库认证/CA和离线内容缺失只阻断相应模式 |
+| 代理 | 既有授权、撤权与缓存期限 | IP/Service 更新不能绕过后端授权;material 策略不能泄露 manager 控制身份 |
+| CI/CD | 原生 amd64/arm64 构建与库存/profile输出 | 新 helper 和二进制必须进入同版本清单与离线包;不能仅发 manager |
+
+## 7. 测试、部署验收与可观测
+
+### 7.1 分层策略与 AC 追溯
+
+| 层 | 验证目标 |
+| --- | --- |
+| 无集群单元/契约 | 同一执行器协议的 Docker/K8s 行为;material-v1 固定编码向量、验签/篡改/nonce及分块重试,停止证据三分类,data 操作互斥与过期查询,UID/RV、陈旧代次、错误映射、模型约束;mock 只证明控制逻辑 |
+| 真实 K8s 设施 | 正反准入、跨应用/namespace网络、实际镜像/CRI、Pod 生命周期、PVC锁/同步/保留、资源限制及日志 |
+| 真实平台 E2E | 使用真实 backend/Celery/审批/CLI/SDK,验证应用包、完整镜像、兼容模式、预览、持久数据、授权、升级、停用/恢复/删除 |
+| 故障矩阵 | 响应丢失、manager SIGKILL/重启、多控制器竞争、应用不健康/OOM、材料/镜像损坏、离线缺包/补包、迁移/快照失败、同名异 UID 与旧写入者未知 |
+| Docker 回归 | 原业务及三交付模式回归;共享抽象不能把原 Docker 路径改为 K8s 特例 |
+
+| 设计位置 | 全部有效 AC 覆盖 |
+| --- | --- |
+| D1、4.1、4.10、6.1 | AC-01、AC-02、AC-03、AC-04、AC-39、AC-40、AC-41、AC-56 |
+| D2、D5、4.3、4.5、4.9 | AC-05、AC-06、AC-07、AC-08、AC-09、AC-11、AC-17、AC-18、AC-43、AC-48、AC-49、AC-52 |
+| D6、D8、4.9、4.10、6.3 | AC-12、AC-13、AC-14、AC-15、AC-16、AC-19、AC-20、AC-21、AC-22、AC-23、AC-30、AC-31、AC-32、AC-53 |
+| D3、D7、4.4、4.6、4.7、4.10 | AC-24、AC-25、AC-26、AC-27、AC-28、AC-29、AC-42、AC-44、AC-45、AC-46、AC-47、AC-54 |
+| D5、4.4、4.8 | AC-33、AC-34、AC-35、AC-50、AC-51 |
+| 6.1、7.1、7.2 | AC-10、AC-36、AC-37、AC-55 |
+
+AC-38 保留退役。tasks 拆解时每个实现/拒绝路径引用完整 AC ID,不能只依赖此汇总表宣称已测试。
+
+### 7.2 189 验收步骤(实施中,尚未完成)
+
+1. 保存只读盘点资源 UID;使用 `e2e-f059-*` 专用命名空间、卷目录、测试账号和应用前缀。准备专用 local PV/PVC、镜像和必要的独立平台测试实例;不升级或改动现有 kube-system。
+2. 安装者运行新交付的预检工具,分别验证入/出站拒绝、PSA/webhook、权限、材料 URL/CA、存储锁/fsync、镜像引用、包源和资源。危险 PodSpec 使用 API server dry-run 取得实际拒绝结果,允许路径用无特权探测 Pod 真实执行;不能在验证失败时留下实际特权 Pod。探测失败只运行诊断,不部署业务应用。
+3. 安装系统服务,先用真实 runtime profile 执行 Python/Node/static bundle 首发、审批、SDK能力及附件,再测完整镜像和受限 source-image。没有可推送仓库时 source-image 记录“前置阻断”,不能将该模式写为验收通过。
+4. 记录代码变化复用 dependency artifact、加依赖失效缓存、缺包拒绝/补包成功、禁公网 resolve/compile、Pod 重建恢复固定材料;manager 停机超过 3 秒仍有新 HTTP/WS 请求及材料续取的证据。
+5. SQLite 写入业务数据后测正常容器重启/Pod 替换、停用后的 owner 查表/编辑/导出和审计、停用数据会话结束后仍停用且 PVC 未变、恢复、v1→v2 结构升级、快照上传失败、DDL 后启动失败、owner 恢复及非 owner 拒绝;每一步记录生产 PVC UID 与数据内容。
+6. 验证未调度取消、首个 init 镜像拉取失败、material-fetch 拒绝、environment 初始化失败(app/helper 从未启动)均能回收预占/finalizer 后重试;验证非 kubelet 合成终态被拒、真实 kubelet 正常状态与终止通过、status 保护中断不误释放;再测优雅停止、manager 响应丢失/重启、同名新 UID 不误认、并发控制器、未知停止事实不得启动第二生产位置。节点失联/跨节点卷恢复在另一独立可控环境执行;189 不通过关闭单节点集群冒充该项。
+7. 升级 manager/代理后确认正式 Pod UID 和 PVC 未变,应用组合未漂移;卸载运行时保留 PVC、平台材料和其它业务资源;仅明确 owner 删除用例清理测试应用资产。
+8. 留原始请求/响应脱敏记录、阶段时刻、Pod/Service/PVC UID、镜像身份、SQL操作与快照确认、数据摘要及清理结果;与初始资源集合比较。所有未测 CPU、强沙箱、DM8、跨节点/物理故障项逐项记录限制。
+
+已实现工具的调用示例;示例本身不作为执行证据。verify默认检查,真实探测需显式`--live --write-report`及预先审查的probe plan:
+
+```sh
+python scripts/verify_k8s_runtime.py --context  --control-namespace e2e-f059-system --execution-namespace e2e-f059-apps --probe-plan 
+# 实际探测及报告发布:在上述命令后添加 --live --write-report
+helm template e2e-f059 deploy/kubernetes/app-runtime -f 
+helm upgrade --install e2e-f059 deploy/kubernetes/app-runtime -f 
+python scripts/e2e_k8s_runtime.py --stage platform --evidence 
+```
+
+实际凭据通过安全环境变量/主机配置提供,不写脚本参数示例或报告。业务访问验证使用原 `/apps/{slug}`、CLI deploy/logs、原审批和数据接口;不能以只发 manager 内部请求代替整条平台链路。
+
+### 7.3 可观测与故障归因
+
+结构化日志带 operation_id、app_id、version_id、generation、owner_epoch、controller_epoch、resource_ref/UID、stage、safe_reason、duration、cleanup_status;不输出服务密钥、材料签名私钥、票据或完整注入环境。应用日志范围和脱敏沿用原合同;日志固定读取 app 容器,不混入材料/数据 helper 输出;支持原 since/tail/时间戳筛选,容器重启可读该 UID 的 previous 日志。删除旧 Pod 前将脱敏、有界诊断尾部保存到既有 operation 诊断记录,再按保留期限清理;不能承诺仅凭旧 UID 取回已经被集群删除的完整日志。当前应用日志与历史诊断入口保持各自原有语义。
+
+关键指标包括:调度/镜像/材料/物化/探活耗时,依赖命中率,初始化票据拒绝,写入者停止未知,预占与 quota,cleanup 超期,Service endpoint/route epoch 滞后,材料/策略过期及自愈总时长。manager /healthz 只说明进程存活;运行环境状态按前置逐项 ready/degraded/blocked 呈现。权限、TLS、image pull、材料 hash、PVC 不兼容与 CPU/内存调度不足分别保留阶段和原因。
+
+## 8. 后续改进与实施门
+
+- 当前设计覆盖约定的三交付路径;无源码任意 Dockerfile、安装系统包或任意命令远程执行能力。新增语言/工具链通过新 profile 和独立评审扩展。
+- 当前选择不自动接管失联节点的数据库写入者;多节点已确认停止后的重调度可实现,但节点分区的物理 fencing 需要另验,不能据单节点上线成功宣传 HA。
+- 默认 Pod emptyDir 环境缓存足够支撑同 Pod 容器重启;跨 Pod 物化耗时需实测。若超恢复预算,先优化离线材料和可丢弃缓存,不降低冻结与单写要求。
+- 运行时新增的材料身份、资源账本及准入是必要交付,不能裁成“只有 Kubernetes create/delete”。完整 CI、离线物料和文档要随同版本交付。
+- design 经评审与用户单独确认后才拆 tasks。实施顺序为执行能力抽象/保持 Docker → backend 资源与材料合同 → K8s Pod/卷/准入与网络 → 三交付模式/数据/访问 → 安装 CI/离线 → 真实平台故障与升级验收;具体纵向切片由 tasks 承载。
+
+## 修订历史
+
+| 日期 | 改动 | 触发原因 |
+| --- | --- | --- |
+| 2026-10-21 | 设计草案初版;明确代码基线、Pod 执行、材料身份、单写、准入、离线与验收边界 | 用户确认 F059 spec,授权进入设计;尚未实现 |
+| 2026-10-21 | 修订 R-D1、R-D2、R-D3:停止证据分类及 status 来源保护、停用数据会话、材料 HTTP/签名/分块合同;同步故障验收 | 用户要求“修订并复审”;尚未编码或部署 |
+| 2026-10-21 | 登记用户设计确认;T001 配置和 T006 客户端测试先行,K8s 运行接线尚待实施 | 用户授权继续 SDD,未变更 D1–D8 |
+| 2026-10-21 | T007 namespace REST 客户端及 T009 固定资源模板本地通过;执行接线仍待后续任务 | 实施进展登记,D1–D8不变 |
+| 2026-10-21 | T002–T004模型/DDL/密码依赖、T011停止证据完成本地验证;补记数据互斥字段与历史NULL/运维回填;T012准入红阶段 | 按现有租户发现和DDL-only规范实施,未改变架构决策 |
+| 2026-10-21 | T013准入HTTPS入口及T015事务账本完成本地验证;收尾计划不依赖业务操作终态,模板补齐默认NoExecute容忍 | 记录实施和API默认值兼容,部署尚待验收 |
+| 2026-10-21 | T021两端material-v1编码/验签及真实互验完成;固定wire不变,授权/传输层仍待实现 | 本地阶段进展 |
+| 2026-10-21 | 继承F086专有BIGINT并显式merge迁移;T009/T013补业务Secret引用和无明文资源投影;公共启动修复仍等发布线授权 | 保留源迁移历史与凭据边界,未改变D1–D8 |
+
+| 2026-10-22 | T005继承已推送发布线9d156f261c;T074显式汇合迁移;逐文件保留审批/审计业务并适配共享组件,回归无新增质量错误 | 用户明确授权;D1–D8与spec不变,189仍未部署 |
+
+| 2026-10-22 | T016/T017控制Service合同和实现完成;34项Service及16项账本用例通过,畸形输入与bool epoch安全拒绝 | API和集群接线仍待后续任务,D1–D8不变 |
+
+| 2026-10-22 | T018/T019内部控制HTTP接入,严格请求及独立manager/admission角色;64项HTTP/Service/账本组合验证通过 | 材料接口与执行器仍待开发,无真实部署结论 |
+
+| 2026-10-22 | T023材料公钥/UID/当前归属授权及T025范围化传输Service完成本地验证;旧manager操作鉴权保持独立 | 133项授权组合、30项新旧材料传输;非集群/MinIO验收 |
+
+| 2026-10-22 | T026/T027材料HTTP根路由及T075/T076多租户中间件接入完成;manager登记公钥/UID与workload材料身份独立 | 15项HTTP、3项中间件通过;客户端/helper/集群尚待实施 |
+
+| 2026-10-22 | T029独立材料客户端与manager控制client完成:可信TLS/CA、固定签名、分块校验/丢块恢复、票据轮换与阶段deadline不变 | 26项client、完整runtime623 passed/10 skipped;材料/控制197 passed;尚未部署 |
+
+| 2026-10-22 | T077/T078持久缓存选择、T030/T031材料helper与隔离install、T009模板接线完成;固定输入/材料与进程身份边界保持 | runtime645 passed/10 skipped,backend定向213 passed;无真实镜像/集群结论 |
+
+| 2026-10-22 | 补齐资源恢复GET、相同待创建意图的控制器接续、阶段日志容器白名单;修正中间stop容量释放时机 | 控制68项、client/Core/lifecycle82项;执行器仍待接入 |
+
+| 2026-10-22 | T079/T080心跳/控制事实接线、受限Service观察端点与保护断档记录完成;硬前置失败复用16121而非容量等待 | 定向106项;全模块785通过、18跳过、3项旧夹具待修;未部署 |
+
+| 2026-10-22 | 材料去重来源、独立根种子及 TLS CA 接线细化;未改变 D1–D8 | 材料组合99 passed,runtime配置完整648 passed/10 skipped,模板准入91 passed;尚未集群验收 |
+
+| 2026-10-22 | Kubernetes 执行器能力接线:持久准备进度、UID 恢复、Service 端点、停止及清理闸口 | 执行器16项,完整runtime665 passed/10 skipped;平台资源61 passed;共同状态机/数据能力与189仍待完成 |
+
+| 2026-10-22 | T034–T039:Docker能力抽离、共用阶段/事实、K8s不初始化Docker、v2状态日志及能力查询、K8s不注册v1/upgrade路由 | 完整runtime689 passed/10 skipped;尚未189部署,完整隔离探测和数据维护继续后续任务 |
+
+日志窗口保持公开epoch/相对格式含义,K8s适配器转换RFC3339 sinceTime,只读业务容器且限1000行/1MiB。Core客户端拒绝同时传sinceSeconds和sinceTime;依据[Kubernetes 1.28官方PodLogOptions定义](https://github.com/kubernetes/api/blob/v0.28.15/core/v1/types.go#L5492-L5528)。能力报告包含冻结cluster/namespace及当前namespace API可达性;API不可达时心跳ready=false,不将静态安装报告作为当前API健康证据。完整网络/存储/准入允许与拒绝探测仍由T054/T055交付。
+
+| 2026-10-22 | T040/T041 公钥数据票据、持久journal、限时SQLite子进程及流式导出 | 18 passed,真实SQLite与本地HTTP;无189部署结论 |
+
+| 2026-10-22 | T081/T082补齐数据会话账本及K8s真实sidecar资源预占;已有字段不是已实现互斥的证据 | 新旧SQL及节点/资源107 passed;尚未数据Service/189验收 |
+
+| 2026-10-22 | T042/T043 owner数据会话与既有业务入口选择分发 | 新会话及原owner/审计/账本41 passed;签发默认实现、Pod运输与真实验收仍待后续 |
+
+| 2026-10-22 | T044/T045数据运输/固定数据位置/owner票据转交,以及T009/T011/T033/T037/T041接线复审;UTC期限及短缓存回收 | data执行14项、helper控制20项、模板准入74项;完整runtime结果见validation;仍无默认签票与189部署结论 |
+
+| 2026-10-22 | T081/T082补充跨租户/多manager数据并发上限,不能仅靠进程内锁 | 新增用例先失败;数据Repository+会话24 passed,无跨租户明细加载 |
+
+| 2026-10-22 | T083–T087顺序资源历史及原生Range,T046/T047独占SQLite维护接线 | 账本/Range/迁移图97 passed;维护及数据/模板90 passed;未部署 |
+
+| 2026-10-22 | T048–T053与T088–T093:签发/数据恢复/CSV流、交付接线与OCI组装 | backend64 passed;runtime744 passed/10 skipped(OCI加入前);OCI及交付129 passed,解释器链接10 passed;真实部署/CI待验收 |
+
+| 2026-10-22 | 并行合入安装/发行/验收工具、安全purge、canary引导/刷新、scratch写入前guard、OCI端点与准入、launcher/SDK/源码限制及重试冻结;记录189真实安装和109原生回归 | D1–D8未变;隔离探测仍在修复,业务E2E/双架构发行/L2未完成,不将本地或镜像烟测记为完整交付 |
+
+| 2026-10-22 | T158–T174:故障CLI与真实安装兼容修复完成本地L1;runtime1129/10skip、安装组合263、业务harness69/4skip;新增原生权限合同31项 | 189私有镜像a5f3f259、正式15项重测中;109专用平台初始化中,业务/故障/正式CI/L2仍未完成 |
+
+### 现场诊断与SQLite WAL读取修正(2026-10-22)
+
+公开K8s诊断复用executor实时assess结果;installer原始报告不需要ready字段。报告年龄86400秒、描述摘要及所有必需检查继续校验,身份变更和投射刷新不依赖进程重启。
+
+Docker受信数据helper仅挂载一个应用数据库目录,该目录需允许SQLite创建和锁定WAL侧文件;业务读连接仍使用mode=ro,拒绝SQL写。只读rootfs、非root、无网络、有界资源与owner授权保留。不能用immutable忽略并发WAL,也不代替应用强制checkpoint。[SQLite官方WAL只读条件](https://sqlite.org/wal.html#read_only_databases)。189 K8s数据helper已有相同卷内侧文件需求,本次不扩大其API权限。
+
+### 现场冻结 profile 对象与验收依赖物料(2026-10-22)
+
+K8s prepare 的 `payload.delivery.profile` 是 manifest 引用字符串,实际解析对象只从 `payload.profile` 读取;后续运行只从 `payload.resolved_delivery.profile` 读取冻结对象。两阶段不能互相 fallback 掩盖错误输入;非法 delivery/profile 类型在资源访问前以固定错误拒绝。完整镜像和 purge 可以不带应用 profile,平台/范围围栏继续校验。189首个指定profile实际prepare在旧实现中提前失败,T194/T195已本地修复;新候选真实重部署待验收。
+
+Docker source-image 兼容夹具基于裸Python,不继承bundle的预置SDK。验收工具接受显式、固定版本和逐wheel摘要的8个纯Python依赖,SDK字节必须与仓库产物一致,保留许可,以原始wheel打入源码包并zipimport运行;不改产品模板、SDK或扫描器。实际源码扫描与依赖二进制跳过分别记录,不能将wheel跳过表述成依赖源码已扫描。完整镜像仍需两个真实digest显式加入场景。此为验收前置修正,不增加产品发布模式。
+
+未创建运行分配的应用:manager查询platform.application得到精确`404/application_not_found`时返回既有`not_found`,backend可投影无实例;不能把资源查询404、权限错误或平台故障当无实例或停止证据。该修复恢复原有16101/16121区分,不弱化销毁与UID围栏。
+
+控制器租约恢复(T198/T199,候选e4052d1c2):空闲期可以自然过期;下一动作仅在精确renew `409/controller_lease_lost` 后清缓存、重读node并以fresh epoch CAS领取。活跃异holder、403、503、其他冲突或响应不确定均重读且拒绝当前动作,不凭重读直接执行未知动作。领取后显式renew,避免同holder幂等claim返回接近过期的旧租约;从renew请求前monotonic计10秒续期,响应耗时达到20秒拒绝,不依赖客户与server壁钟一致。所有资源意图/观察/cleanup/fact仍取当前receipt,旧UID/creator epoch不凭过期改成停止;过期operation由现有平台claim拒绝,只清理并正常终结,不复活prepare。189红例已留存,同进程真实复验待执行。
+
+平台资源白名单校验与manager采用相同阶段合同(T200/T201):backend prepare只消费payload.profile解析对象;probe/preview/deploy/data/restore等只消费resolved_delivery.profile冻结对象,拒绝错误类型,不回退到旧prepare或旧运行对象。完整镜像和purge缺profile仍许可,显式错误类型不许可;purge缺delivery可空,所有既有镜像digest/helper/卷/UID/租约校验保留。37项真实模型→Service→SQLite账本配对覆盖字段轮廓,避免用宽松桩掩盖跨进程协议。189旧r3资源注册503在本地已修,实际后端重部署待验。
+
+2026-10-23:登记T219/T220。真实r5第二次尝试材料下载及进度保存成功,延迟调度的首次节点绑定未回写导致停止确认409;按现有严格身份合同修复,业务验收仍未完成。
+
+**可选锁与终态诊断收尾(T221–T224)**:manifest可选依赖锁序列化None等同未指定;helper传给环境变量和prepare.json时规范化为空字符串,显式路径不变,非法类型拒绝。终态操作仅重试清理时保留已有reason/failed_stage/diagnostic_tail/maintenance/data_may_have_changed,停止及cleanup结果重新取真实事实;已从保留策略中删除的tail不重新构造。不回填已经覆盖的历史failure,原fact继续作为审计证据。
+
+**异步收尾等待(T225–T227)**:Kubernetes DELETE受理与对象实际移除存在正常间隔。准备/探活等阶段结果已取得时,cleanup.pending表示等待,不能立即转成准备失败;等待仍受当前操作期限、取消及控制器围栏约束,未知写入者不释放。重启从持久阶段事实继续,避免重复安装、上传或探活实例。真实异常和到期仍按原失败/保留合同处理。r6已确认全部4准备阶段成功、8资源最终正常清理,原失败操作只保留审计,不人工复活。
+
+2026-10-23:按实现与现有脱敏证据更新材料/data接线、storage配置来源、Pod creation receipts、工具参数与实施现状。保留原历史基线及未验证项,不将16项安装探测或r6准备成功写成正式上线、故障、跨节点或发行验收。
+
+2026-10-23 T228/T229:r7准备四阶段及持久最终结果真实通过;probe总90秒在environment仅运行12秒后结束。按4.6原定的阶段期限修正launch,不简单拉大统一等待或重启计时;保留r7失败及完整资源清理事实,不计为正式上线通过。
+
+2026-10-23 T230/T231:真实owner取消服务已写payload标志,但签发事务/ready等待/manager数据RPC未拒绝仍active的取消会话;本地真实SQL与Ed25519红例已复现。按原取消合同拒绝新票及新动作,已开始动作仍保留unknown与互斥/停止证明,active=False收尾不受此新动作闸阻断。无新增API/模型。
+
+2026-10-23 T232/T233:r8启动阶段真实通过(4init96秒+Ready);终态SQL证据确认后finalizer已移除,而grace30残留需kubelet DELETE0完成。原准入终态helper要求finalizer尚在,导致收尾重试拒绝。仅为已去finalizer分支增加fresh backend同UID/scope stopconfirmed+allowdelete证明;原本地带finalizer路径与manager删除闸不变。K8s1.28.15 store.go ShouldDeleteDuringUpdate仅grace0/nil随finalizer移除删除,status_manager.go的kubelet最终DELETE使用grace0和UID前置;对应官方固定源码作为静态依据,不代替189恢复实测。
+
+T232/T233已实现并通过本地L1。固定版本官方依据:[Kubernetes v1.28.15 store.go](https://github.com/kubernetes/kubernetes/blob/v1.28.15/staging/src/k8s.io/apiserver/pkg/registry/generic/registry/store.go#L443) 的ShouldDeleteDuringUpdate要求旧grace为0/nil才在finalizer清空时直接删除;[status_manager.go](https://github.com/kubernetes/kubernetes/blob/v1.28.15/pkg/kubelet/status/status_manager.go#L828) 的kubelet终态删除使用grace0及UID前置。r8随后原期限timed_out,保留失败并正常清理,不复活原操作。
+
+T234/T235工具适配:preview/resume等待ready、stop/delete等待completed是现有同步产品接口;共享E2E75秒read会早于实测96秒init结束。只对成功授权的精确同步调用采用settings.timeout总预算与read上限,连接/写入/pool短有界;拒绝配对、GET、preview DELETE、restore202不扩大。无重试POST,不改产品operation deadline或nginx/UI。
+
+T234/T235已本地L1通过;d13e9ee91整合1753 backend与1519 runtime通过。189 Helm19的a06原生镜像已使r8失败操作正常cleanupcomplete/4精确资源fresh404,历史failure与停止证明保留;r9新批次真实业务继续,不宣称正式上线验收通过。
+
+2026-10-23 T236/T237:r10已通过预览访问与权限拒绝/审批,正式上线在维护阶段失败。原Pod日志已清理;模板→reader及实际helper禁网A/B定位遗漏材料PodUID。原失败、正常清理和生产PVC均保留,修复后可由owner对已批准版本显式manual-publish发起新attempt,不能复活旧op或重发未知POST。
+
+T236/T237已实现:单行可信maintenance+material_secret条件,实际reader配对及全runtime1532/13skip通过;249/Helm20部署保持helper143/profile2/报告。r10原预览/拒绝/审批通过,首次维护失败保留,新owner明确manual-publish只发一次、同版本/原审批、新operation,不复活旧操作或延长900秒原套件等待;真实上线结果见platform.json。
+
+2026-10-23 T238/T239:固定生产Pod名称跨维护与服务复用时,stop_instances仅跳过资源账本中stop_state=confirmed且cleanup_state=complete的历史Pod;不能仅凭其中一个标记或名称相同跳过,旧操作整体cleanup仍执行。r10停用期限自然到期,原失败和运行writer保留,另核超时清理安全闭环,不延时期限。
+
+2026-10-23 T240/T241实施要求:过期stop的终态与物理清理解耦。不能以当前stop空资源推出app无writer;先持久timed_out/pending/stoppedFalse保留active围栏,再fresh同scope,以精确旧terminal操作自身cleanup逐轮停止旧UID。全部资源停止/清理后仍由原backend全app guard验证终态更新;未知回执/换代/换UID持有,不延长期限或执行新迁移/purge/启动。
+
+T240/T241最终实现d7c0cc4fd:首轮独立复算发现503过早failed会阻断恢复,已修订为非终态、不确定且未精确到期的stop保留active fence,后续正常执行重试。只有精确operation_expired/409或checkpoint timeout且fresh deadline到期才进入timed_out收尾;已terminal非timeout首因不改。先pending ACK、fresh同scope、旧terminal owncleanup逐轮、backend最终全writer guard均保留;511相关/1573整套及独立96复审通过,真实189清理另记。
+
+2026-10-23 T242–T245资源参数修正:离线data Pod的data-layout/init与data-helper沿用平台可信tier CPU,新会话tier和真实预占均100m/128MiB;旧冻结tier25m仍生成旧模板,在线serve sidecar及其他helper保持25m。189同一8a镜像25m导入31.699秒、100m导入6.9965秒;实际离线helper运行到ready约35秒与该量级一致。原60秒冷启动/动作/响应、同步cleanup后返回、并发2、UID停止证据及互斥释放均不改变,无新API/网络/镜像依赖;更改资源参数而非延时请求或提前释放数据会话。真实新Pod时间另验。
+
+T242–T245已实现并本地L1通过,源6f254f337(backend992905292、runtime f84415f25);原生100m对照仅解释导入成本,完整runtime1586/13skip与backend1764/19skip均通过。实际新Pod完整60秒链路另验,旧4b25失败不改成功;本轮无新ARM/公开发行。
+
+2026-10-23 T246/T247:r10 static真实probe全部init成功、app nginx:notfound/exit127,模板bundle PATH遗漏/usr/sbin。仅补标准系统目录,保留/opt/venv/bin与node_modules/.bin优先、冻结entrypoint/args、image环境及PATH注入限制。9配对先红→模板106/完整runtime1595/13skip及独立292通过;同2f0311镜像原生A/B与entrypoint配置测试确认量级和因果,真实新attempt另验。242/247任务L1完成。
+
+2026-10-23 T248/T249:实际source-image安装/上传成功,组装拒绝fsGroup继承的SGID目录。只允许目录SGID在归档时去除,SUID目录与特殊位文件继续拒绝;固定布局/冻结基础镜像/凭据边界保持。属于D7实现修正,真实新attempt复验单列。
+
+2026-10-23 T250/T251:源码镜像的受控PATH同样补齐标准sbin目录,与T247的bundle保持相同依赖bin优先及系统工具可达性;静态基础镜像实际nginx位于/usr/sbin。固定OCI启动语义不新增配置口,完整用户镜像不变。
+
+2026-10-23 T252/T253原修复判断已撤回:固定crane的entrypoint行为已清除Cmd,无需额外空参数;未保留该冗余设计或实现。
+
+2026-10-23 T252/T253更正:此前继承CMD问题为误判,撤回相关设计说明和冗余实现。固定crane v0.22.1在设置entrypoint时已按Docker语义清除Cmd;原组装器已满足既定启动合同。原记录保留用于追溯,本行优先。源码/测试恢复0d5字节,SGID与PATH修正不变。
+
+2026-10-23 T254/T255:安装registry探测补egress-proxy实际网络策略下的上游TCP检查,控制canary保持NotReady避免被业务Service选入;原外部crane权限/TLS检查和真实assembler业务验证分别保留。189 Docker仓库5443映射5000,Calico按DNAT后端口匹配,现场配置另增5000且已真实配对确认;不改变部署API、helper命令、镜像或descriptor摘要协议。
+
+2026-10-23 T256/T257:本地真实方法链确认prepare Running循环未观察已受理cancel,复用已有操作检查补齐;26配对与201相邻、主树1649/13skip通过。既定取消/围栏/期限合同内修正,不变API、helper/assembler/profile和停止证明。189真实取消与容量释放单列待验。
+
+2026-10-23 T258–T261:依据真实五例调用链与189 cohort,在4.5既有信任模型内区分报告失效和真实保护失败;严格同基线仅保留旧true并暂阻当前停止,旧false不恢复。实施和真实复验待完成,未扩展物理fencing合同。
+
+2026-10-23 T262/T263:单次心跳的一次报告快照修正,保持内部保护合同一致;非有限时间继续规范null以发送真实blocked,不修改停止证明类型。
+
+2026-10-23 T258–T263 L1:源码f8863f376,独立runtime108/后端51配对通过;完整runtime1757/13skip,直接三后端模块1944/19skip,组合基线零新增错误。旧历史标记不恢复,189新版复验及正式发行仍待执行;仅实现完善,不新增恢复/fencing证明。
+
+2026-10-23 T264/T265登记:既有v2容量错误16125在CLI未映射,仅补既有容量语义与实际wheel分发;平台错误码、发布阶段、依赖及设计决策不变。同步R29与r11实际进展,未将其余故障验收列为通过。
+
+2026-10-23 T264/T265完成:0ff595428本地CLI391项及wheel/manifest/SDK独立核对通过。189 R3 manager真实无实例15.931秒,5轮超缓存访问/权限、原WS及CONNECT通过,恢复epoch25/新UID且6Pod/7PVC保持;R12容量负例真实16125、审批/版本/操作/资源/预约均0。保留早期308工具失败和权限启动失败;不据此认定在途重启、迁移或完整卸载已验收。
+
+2026-10-23 销毁协议细化(T266/T267):K8s owner destroy从内部admitted进入实际维护前,必须记录既有合法维护阶段;维护结果事实经平台接收后才允许PVC清理。189首次R11删除以422失败,旧结果保留、不宣称purge/PVC删除通过;不扩展公开事实阶段、不改变D1/D5或停止证明。ARM及新增109按用户口径暂缓。
+
+2026-10-23 T266/T267本地L1完成:真实ASGI/schema14严格配对及runtime1771唯一测试分批核对(环境失败日志保留);K8s显式destroy增加maintenance ACK/fresh checkpoint,Docker和schema保持。189仍R29,真实删除复验不由本地绿代替。
+
+2026-10-23 T268/T269:R30真实销毁已成功保存purge结果;PVC删除为异步,首个DELETE后同UID仍可GET不是最终失败。在既有原期限/取消/控制权围栏内仅等待明确的purge_volume_cleanup_pending,并复用已持久结果而不重复执行helper;未知API结果、换UID或失去权限不转换成完成。正常404后仍需原cleanup_operation_id及backend清理准入。不新增API、存储或恢复语义。
+
+2026-10-23 T268/T269 L1完成:仅OperationExecutor新增精确pending适配,复用原_wait_cleanup而非另开期限循环,KubernetesMaintenance已有结果可用于重复观察PVC而不重复purge helper。完整runtime1787/13skip及独立217/16配对通过;R31原生交付与新正常销毁复验另记。
+
+2026-10-23 T270/T271:在线应用显式restore以当前v1冻结交付,resume_after_restore/auto_commit均true;manager先确认同操作restore维护结果并关闭helper,再以serve启动。backend资源入口仅开放该显式路径,校验原snapshot参数与已持久结果和合法启动阶段;仅数据恢复仍不允许serve。复用同代际/控制租约、冻结模板和全app旧writer停止证明。材料serve授权及ready→commit_runtime_delivery→清pending/路由激活既有链不另造API。真实首轮恢复失败保留,不延长或复活该操作。
+
+2026-10-23 开发收尾补记:T266–T269实际销毁阶段与PVC异步等待均已L1;T270/T271为在线restore接通同操作持久结果后的serve资源入口,仍按原deadline、严格resume=true、latest fact snapshot/digest/helper ref及全app旧writer停止保护。现有材料与ready/route提交不变。完整backend1174pass/19skip,manager1787pass/13skip;R31原生amd64构建/内网digest981bf135…已取得,source6c9694f0f,backend单文件独立source38a700233/c06fb408。首次真实restore4d858a52/gen3仍failed,需部署后新显式恢复验收;未公开发行、ARM暂缓。
+
+2026-10-23 验收现状:R31 Helm31与backend c06fb408实际部署通过;正常升级保持原应用/卷与SQL业务投影。T069安全删除经唯一owner业务请求、durable purge、同UID停止/PVC自然清理/PV Retain/SQLite缺席闭合,独立L1后265/269。全AC子句边界及T070/发行/L2保留;按用户要求仅定向测试修复和相邻链。
+
+
+2026-10-23 在途重启控制器准入(T272/T273):189单次SIGKILL后的新进程在旧holder租约尚未到期时收到正常409/controller_lease_held;该异常错误进入业务_failure,错误处理跨到自然到期后取得新epoch并清理原安装。真实双执行器与后端Service最小复现已区分于模板/epoch资源复用问题。按4.6既有续办合同,在operation claim/执行和业务失败处理之前核当前K8s控制器租约;拒绝当前动作,原操作/阶段事实/资源和deadline保持,下一轮正常对账再试。不放宽后端围栏,不改Docker、冻结模板、原失败记录或截止时间;本地4行准入门及定向配对/独立L1已完成;189 R32同在途prepare经一次SIGKILL/controller30→31、原安装同UID/CID自然240秒完成、原deadline/阶段结果保持、资源自然清理已通过;见fault-upgrade的R32定向证据。
+
+2026-10-23 开发整合修订:刷新 R32/268-of-272 当前快照、owner 数据与初始化实证;已知坑同步控制器交接与 CI 发布锁。按用户先开发收尾合并并推送的授权执行,未改变规格、存储/安全合同或未验事项。
diff --git a/features/v3.0.0/059-k8s-runtime-backend/discovery.md b/features/v3.0.0/059-k8s-runtime-backend/discovery.md
new file mode 100644
index 0000000000..3befa0101c
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/discovery.md
@@ -0,0 +1,47 @@
+# F059 需求发现与实现基线
+
+## 1. 用户目标与确认状态
+
+用户要求“继续完成k8s的开发和部署验收”。此前已确认全开源、发布通过平台业务入口、运行时独立进程、默认预制镜像加应用包、backend 中介文件/附件/快照,manager 不直接访问 MinIO。需求发现沿用这些会话决定及本次继续交付指令;本轮 F059 修订 spec 已经用户明确确认;该确认不替代后续 design 的单独确认。
+
+依据:根 AGENTS.md 第6节、SDD-Guide第3节。sdd-review 技能要求 spec 和 design 审查后分别等待用户确认;当前已完成环境盘点、规格修订及用户确认,进入设计,没有部署/编码,不能视为 K8s 实现已开始或已完成。
+
+## 2. 隔离工作树与前置实现
+
+- 工作树:/Users/zhangguoqing/.codex/worktrees/vibe-k8s-runtime/bisheng。
+- 分支:codex/059-k8s-runtime;基线755d4456e50c015a3fa88da0c0c35ab53bbd1650(已发行v3.0.0-test-vibe的F086代码)。
+- 初始发现时原F086修复尚未继承;2026-10-22已完成T005公共租户修复发布线继承与Vibe专有BIGINT来源核对,见prerequisites.md。真实平台验收仍待执行,不形成第二套权限逻辑。
+- 当前只找到Docker执行器及本机路径准备/挂载逻辑;K8s没有可用的执行器、部署清单或运行验收。不能将DockerBackend协议注释中的“只换一个文件”当成完整工作量。
+
+## 3. 继承与修订
+
+| 项目 | 已有依据 | 本次修订 |
+| --- | --- | --- |
+| 全开源与进程边界 | 会话、F086 | manager/app-proxy/egress-proxy继续分离,无商业额度 |
+| 默认发布方式 | F086 AC-11及release-contract的Impacted Specs | F059 AC-24至AC-26增加应用包/完整镜像,源码构建保留为显式兼容路径 |
+| 平台文件与附件 | F086存储合同 | manager、准备执行体和应用通过受限backend接口取得材料,不拿MinIO凭据 |
+| venv存储 | 会话建议;本轮用户已确认design | 已确认固定Pod内以initContainer/emptyDir物化;临时准备Pod与scratch PVC分阶段隔离;具体实现/验收仍按design与tasks推进 |
+| 数据库 | F086单写及快照确认 | 独占持久卷,确认旧写入者停止后迁移/恢复;RWO本身不构成单Pod单写证明 |
+| 镜像供应 | F086精确镜像和profile | 目标节点必须能按冻结引用取得镜像;预灌需与CRI匹配并实测,不能以docker load成功代替可启动证明 |
+| 网络和权限 | F086 强制隔离合同、用户要求修复 R3 | 必需隔离实际验证失败即拒绝,不保留旧 F059 的覆盖放行;可选强沙箱仅可回退至满足基线隔离的默认档 |
+| 存储前置 | 原F059要求StorageClass;189实际没有 | 允许经验证的预置持久卷;节点本地卷只承诺节点内持久化并持续提示故障限制 |
+| 长期重建材料身份 | 旧规格缺口、用户要求修复 R2 | 准备/预检/数据维护绑定有效操作;运行初始化绑定当前有效部署及冻结引用,经 backend 重新授权续取,不依赖 manager 在线;均与业务容器隔离 |
+| 容量失败与审批阶段 | F055、F086、用户要求修复 R1 | 审批前预检容量失败不生成审批单或正式版本,释放后重新发布;审批后容量不足保留原审批,待资源恢复后手动上线 |
+
+## 4. 189真实前置
+
+2026-10-21通过SSH和kubectl只读确认:K8s1.28.15、amd64单节点、Docker24.0.9 CRI、Calico、无StorageClass/PV/CSIDriver/RuntimeClass,无业务命名空间/工作负载。Calico安装不等于隔离实际生效;后续必须执行真实拒绝探测。只读原始记录及摘要见cluster-preflight.md。
+
+后续拟在e2e-f059命名空间和明确登记的专用节点目录/卷中验收,不修改既有kube-system资源,不把专用本地PV写成客户通用存储方案。准备/镜像/包源前置要按各交付模式分别核验;不能为了兼容构建缺少条件而阻断可用的应用包路径。
+
+## 5. 后续确认与证据边界
+
+| 阶段 | 当前状态 |
+| --- | --- |
+| 需求发现 | 已由会话决定和继续交付指令承接,scope与环境已核对 |
+| spec | R1、R2、R3 已修复;复审 LGTM;用户回复“确认规格,进入设计” |
+| design | R-D1、R-D2、R-D3 复审通过,用户已明确确认设计并授权继续 SDD;tasks 72 项已拆解且自动审查 LGTM,进入按波次实施 |
+| tasks及实现 | design确认后拆解/审查,再逐波开发 |
+| 部署验收 | 用户已授权189环境;当前仅执行只读盘点,未创建资源 |
+
+不以文件存在声明功能交付,不把单节点或模拟测试当作跨节点生产证明,不自动推送分支/更换发行Tag。
diff --git a/features/v3.0.0/059-k8s-runtime-backend/docker-regression.json b/features/v3.0.0/059-k8s-runtime-backend/docker-regression.json
new file mode 100644
index 0000000000..2d5da9a161
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/docker-regression.json
@@ -0,0 +1,482 @@
+{
+  "schema_version": 1,
+  "observed_at": "2026-10-22T10:49:09.248453+00:00",
+  "source_base": "c90927b1f4e0388a956d4e300c7951ff7745b1bf",
+  "release_inheritance": "9d156f261cc7769fcf75f41caeb104a425308017",
+  "evidence_kind": "local-tests-and-native-engine",
+  "runtime_deployment_verified": true,
+  "kubernetes_deployment_verified": false,
+  "docker_live_status": "three_bundle_languages_and_source_image_two_revisions_passed_complete_image_independent_observe_passed_faults_pending",
+  "kubernetes_live_status": "installation_passed_business_pending",
+  "dm8_driver_status": "not_run_static_only",
+  "suites": [
+    {
+      "name": "runtime",
+      "command": "src/runtime-manager: PYTHONPATH=/private/tmp/f059-crypto50 python -m pytest -q tests",
+      "passed": 967,
+      "skipped": 10,
+      "failed": 0,
+      "kind": "local-tests",
+      "log_sha256": "2390efd9de70822c872f8bc6d5ba991c0fa0feb794b3acd5a2700524f448225d",
+      "private_log": "/private/tmp/f059-integration-runtime-latest.log"
+    },
+    {
+      "name": "app-proxy",
+      "command": "src/app-proxy: python -m pytest -q tests",
+      "passed": 256,
+      "skipped": 0,
+      "failed": 0,
+      "kind": "local-tests",
+      "log_sha256": "185366c197e87c2376cc7dfa10fa6d0e089dcb09bd7efb8f8f87b30ad93ffc79",
+      "private_log": "/private/tmp/f059-e2e-app-proxy-full.log"
+    },
+    {
+      "name": "backend",
+      "command": "config=/private/tmp/f059-test-config-auth.yaml python -m pytest -q src/backend/test/app_runtime src/backend/test/app_publish",
+      "passed": 1564,
+      "skipped": 18,
+      "failed": 0,
+      "kind": "local-tests",
+      "log_sha256": "e9654c4908a8d820f97fa3dce9f4b7aeedabb20879ceb6c5a2c1034ce8e52ef5",
+      "private_log": "/private/tmp/f059-backend-full-final.log"
+    },
+    {
+      "name": "harness-permission",
+      "command": "config=/private/tmp/f059-test-config-auth.yaml python -m pytest -q src/backend/test/app_runtime/test_k8s_e2e_contract.py src/backend/test/app_runtime/test_app_permission_registration.py src/backend/test/e2e/test_e2e_vibe_k8s_runtime.py",
+      "passed": 54,
+      "skipped": 11,
+      "failed": 0,
+      "kind": "local-tests",
+      "log_sha256": "53085a2bca1e1b0b75f887a8b8009eeaf63deaa0516a771d150427ddc8467989",
+      "private_log": "/private/tmp/f059-e2e-harness-final.log"
+    }
+  ],
+  "limitations": [
+    "Historical local/native-engine records below retain their original source and gates; current platform subset is in current_platform_partial.",
+    "189 installation and 3 real report rejection/restoration pairs passed; full business/fault acceptance remains incomplete.",
+    "Final native amd64 and arm64 nine-image suites passed at source 54b; formal public CI release and cross-node verification have not run.",
+    "DM8 is static-only by user instruction. Docker L3 firewall fallback is not verified by successful HTTP/SDK tests."
+  ],
+  "native_engine": {
+    "host": "192.168.106.109",
+    "scope": "dedicated-e2e-f059-docker",
+    "manager_image_id": "sha256:4e1be0d30cde2a755b1bcfaa7d0b9b743df33ebc3ec05f37a383a0f0dc1f36c1",
+    "platform": "linux/amd64",
+    "result": {
+      "platform": "linux/amd64",
+      "checks": [
+        {
+          "language": "python3.11",
+          "offline": true,
+          "code_update": true,
+          "dependency_reuse": true
+        },
+        {
+          "language": "node20",
+          "offline": true,
+          "code_update": true,
+          "dependency_reuse": true
+        },
+        {
+          "language": "static",
+          "offline": true,
+          "code_update": true,
+          "dependency_reuse": true
+        },
+        {
+          "language": "static",
+          "offline": true,
+          "install_hook": true,
+          "frontend_build": true,
+          "source_change_invalidates_dependency": true
+        },
+        {
+          "language": "python3.11",
+          "offline": true,
+          "local_native_wheel": true,
+          "source_change_invalidates_dependency": true
+        }
+      ]
+    },
+    "result_sha256": "0f8f802bbbcca0e0374f376e8a8a3216c4bfbf032b0ae640ab1e4d3e4f185e5a",
+    "non_test_containers_before_after_sha256": "a01118b42cd860d661a9062be5227ede560b3e872ef828216e9031ce3d881154",
+    "script_sha256": "a8b6ddd97fdc9eb88f7728819390f74df4a3b111179cf42aaa2c398ba8d1f3c0",
+    "code_critical_hashes": {
+      "bundle_preparer.py": "13a8e75152f5c3c13bb376166074f1f2f51fdb43cfc84ef89d6a7017d724a9fb",
+      "bundle_launch.py": "b1c4b9bf686a6def9d9910130ee07f7e90958e2217bac3f6931778a91cf74ee7"
+    },
+    "no_new_oci_images_asserted": true,
+    "temporary_containers_and_network_cleaned": true,
+    "artifact_port": "in-memory; not real backend/MinIO/approval",
+    "private_evidence_dir": "/opt/e2e-f059-docker/evidence"
+  },
+  "current_platform_partial": {
+    "host": "192.168.106.109",
+    "source_commit": "54b3ea2c15836529cc24667d9a58be49e35bf710",
+    "harness_source_commit": "7b728fcb664731bd51fa4fc54dcf471250587d27",
+    "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:26f61e7ba98726d3c78ade8236ef78d09567583282bf2292f911af04b2a4b7f1",
+    "profile_revision": 1,
+    "journal_sha256": "2ba501be64af36e771fcbe708e537f35b6e5586c65c52f398f9b1b95ea412137",
+    "private_journal": "/opt/e2e-f059-docker-platform/evidence/docker-platform-r2.json",
+    "completed": {
+      "e2e-f059-python-bundle-r2": {
+        "app_id": "d2a5a6e5d5f042fc9e4067835cf981e3",
+        "steps": [
+          "publish",
+          "observe-v1",
+          "stop-resume",
+          "publish-update",
+          "observe-v2"
+        ]
+      },
+      "e2e-f059-node-bundle-r2": {
+        "app_id": "177fd95216834fcca33ab70ca098f55c",
+        "steps": [
+          "publish",
+          "observe-v1",
+          "stop-resume",
+          "publish-update",
+          "observe-v2"
+        ]
+      },
+      "e2e-f059-static-bundle-r2": {
+        "app_id": "29ced3ce8c68445b8bc072d68d88470e",
+        "steps": [
+          "publish",
+          "observe-v1",
+          "stop-resume",
+          "publish-update",
+          "observe-v2"
+        ]
+      }
+    },
+    "source_image_failed_attempt": {
+      "app_id": "cb0e1ce510bb4fff87c8972e267bcdb2",
+      "deployment_id": "9bdb045e72784556b0456b9b26f5b2ef",
+      "code": 16124,
+      "stage": "precheck_probe",
+      "reason": "startup_exited",
+      "interpretation": "build completed; fixture imports SDK but empty requirements and bare legacy base do not supply it; explicit offline fixture material being added, not accepted as success"
+    },
+    "full_image": "separate fixed-digest batch running; not passed yet",
+    "boundary": "real platform API, natural tenant users, approval and proxy; does not prove Docker firewall fallback L3 enforcement, real model positive request or all failure/restore/delete paths"
+  },
+  "complete_image_partial": {
+    "source_commit": "54b3ea2c15836529cc24667d9a58be49e35bf710",
+    "journal_sha256": "7f8eae51034e034b4c210ee7488aeebf9178ef08f34cbc830be951738ce4453f",
+    "apps": {
+      "e2e-f059-python-image-d109image1": {
+        "app_id": "09d8ff6d31c740ada5604b90bbabbbc4",
+        "deployment_id": "9a2fdde368924820abc56e7a9590f515",
+        "version_id": "c1ed61c32e134b858e00636e563f9ae5",
+        "mode": "image",
+        "runtime": "python3.11"
+      }
+    },
+    "passed_events": [
+      {
+        "name": "publish",
+        "outcome": "passed",
+        "app_id": "09d8ff6d31c740ada5604b90bbabbbc4",
+        "version_id": "2ea5fcfe28034d59a33b5eee88bac087"
+      },
+      {
+        "name": "owner-and-entry-boundaries",
+        "outcome": "passed",
+        "app_id": "09d8ff6d31c740ada5604b90bbabbbc4"
+      },
+      {
+        "name": "stop-resume",
+        "outcome": "passed",
+        "app_id": "09d8ff6d31c740ada5604b90bbabbbc4",
+        "version_id": "2ea5fcfe28034d59a33b5eee88bac087"
+      },
+      {
+        "name": "iteration",
+        "outcome": "passed",
+        "app_id": "09d8ff6d31c740ada5604b90bbabbbc4",
+        "version_id": "c1ed61c32e134b858e00636e563f9ae5"
+      },
+      {
+        "name": "owner-and-entry-boundaries",
+        "outcome": "passed",
+        "app_id": "09d8ff6d31c740ada5604b90bbabbbc4"
+      }
+    ],
+    "first_platform_exit": 1,
+    "independent_v2_observe_exit": 0,
+    "boundary": "both publications and fresh v2 observations passed; original final observation failed without precise frame, transient cause unresolved, not an uninterrupted whole-batch pass"
+  },
+  "source_image_completed": {
+    "source_commit": "54b3ea2c15836529cc24667d9a58be49e35bf710",
+    "fixture_source_commit": "66e507ec810100a4ae3a6245d2ab6fcac49e9718",
+    "journal_sha256": "3c8b0eaff14c5483568767f64e8c706b512df81aa6ca0d0e98776a8bb35b79f4",
+    "private_journal": "/opt/e2e-f059-docker-platform/evidence/docker-source-d109src1.json",
+    "apps": {
+      "e2e-f059-python-source-d109src1": {
+        "app_id": "80555884a2ba44e7964632e5b6b4e2c4",
+        "deployment_id": "8d2759291099498fa511f9d5b4c853b4",
+        "version_id": "3886dadce1724139b089587d2641ce55",
+        "mode": "source-image",
+        "runtime": "python3.11"
+      },
+      "e2e-f059-missing-dependency-d109src1": {
+        "app_id": "3f7513f9662c440a9bc8fa5b55bdff46",
+        "deployment_id": "4fe9926ab3ef4a68a6a767976f2b08ee",
+        "version_id": "570d603df56f4c5e8c29acc163d8e5b3",
+        "mode": "bundle",
+        "runtime": "python3.11"
+      }
+    },
+    "events": [
+      {
+        "name": "runtime-diagnostics",
+        "acs": [
+          "AC-01",
+          "AC-30",
+          "AC-32"
+        ],
+        "outcome": "passed",
+        "kind": "live-api",
+        "observed_at": 1792678293.965235,
+        "executor_kind": "docker",
+        "checks": [
+          "orchestration_backend",
+          "platform_ledger",
+          "data_root_writable",
+          "host_data_root_mapping",
+          "egress_whitelist",
+          "application_network_internal",
+          "egress_firewall_fallback",
+          "storage_configured"
+        ]
+      },
+      {
+        "name": "preview-and-approval",
+        "acs": [
+          "AC-05",
+          "AC-36",
+          "AC-50"
+        ],
+        "outcome": "passed",
+        "kind": "live-api",
+        "observed_at": 1792678321.27285,
+        "app_id": "80555884a2ba44e7964632e5b6b4e2c4",
+        "instance_id": 10,
+        "session_id": "3267d489f2214dccad1545dc295b1f7f"
+      },
+      {
+        "name": "publish",
+        "acs": [
+          "AC-04",
+          "AC-05",
+          "AC-24",
+          "AC-25",
+          "AC-36"
+        ],
+        "outcome": "passed",
+        "kind": "live-api",
+        "observed_at": 1792678325.4432757,
+        "app_id": "80555884a2ba44e7964632e5b6b4e2c4",
+        "deployment_id": "ec3093cd6db64cfc866817190d2662a8",
+        "version_id": "3725a9e5634141cfa19ee65327f59e25",
+        "mode": "source-image",
+        "runtime": "python3.11",
+        "package_sha256": "64b5825e266463bd893598bd2588b89c50dfb0670640dedafc78241e551fe793"
+      },
+      {
+        "name": "application-check",
+        "acs": [
+          "AC-36",
+          "AC-46"
+        ],
+        "outcome": "passed",
+        "kind": "live-api",
+        "observed_at": 1792678326.179594,
+        "app_id": "80555884a2ba44e7964632e5b6b4e2c4",
+        "path": "/health"
+      },
+      {
+        "name": "application-check",
+        "acs": [
+          "AC-36",
+          "AC-46"
+        ],
+        "outcome": "passed",
+        "kind": "live-api",
+        "observed_at": 1792678326.2282357,
+        "app_id": "80555884a2ba44e7964632e5b6b4e2c4",
+        "path": "/sdk/identity"
+      },
+      {
+        "name": "application-check",
+        "acs": [
+          "AC-36",
+          "AC-46"
+        ],
+        "outcome": "passed",
+        "kind": "live-api",
+        "observed_at": 1792678326.8323536,
+        "app_id": "80555884a2ba44e7964632e5b6b4e2c4",
+        "path": "/sdk/storage-roundtrip"
+      },
+      {
+        "name": "websocket-allow-deny",
+        "acs": [
+          "AC-12",
+          "AC-36",
+          "AC-53"
+        ],
+        "outcome": "passed",
+        "kind": "live-api",
+        "observed_at": 1792678326.9988253,
+        "slug": "e2e-f059-python-source-d109src1"
+      },
+      {
+        "name": "owner-and-entry-boundaries",
+        "acs": [
+          "AC-10",
+          "AC-12",
+          "AC-33",
+          "AC-36",
+          "AC-46"
+        ],
+        "outcome": "passed",
+        "kind": "live-api",
+        "observed_at": 1792678406.2470763,
+        "app_id": "80555884a2ba44e7964632e5b6b4e2c4",
+        "entry_status": 200
+      },
+      {
+        "name": "stop-resume",
+        "acs": [
+          "AC-29",
+          "AC-33",
+          "AC-35",
+          "AC-36"
+        ],
+        "outcome": "passed",
+        "kind": "live-api",
+        "observed_at": 1792678449.2689679,
+        "app_id": "80555884a2ba44e7964632e5b6b4e2c4",
+        "operation_id": "5b9a97e0e6ea4a809f866e29c4f2e250",
+        "version_id": "3725a9e5634141cfa19ee65327f59e25",
+        "generation": 3
+      },
+      {
+        "name": "preview-and-approval",
+        "acs": [
+          "AC-05",
+          "AC-36",
+          "AC-50"
+        ],
+        "outcome": "passed",
+        "kind": "live-api",
+        "observed_at": 1792678502.015527,
+        "app_id": "80555884a2ba44e7964632e5b6b4e2c4",
+        "instance_id": 11,
+        "session_id": "03a5e25d3f4344ae8ca7a0b346e567b6"
+      },
+      {
+        "name": "iteration",
+        "acs": [
+          "AC-04",
+          "AC-05",
+          "AC-24",
+          "AC-25",
+          "AC-36"
+        ],
+        "outcome": "passed",
+        "kind": "live-api",
+        "observed_at": 1792678516.3044503,
+        "app_id": "80555884a2ba44e7964632e5b6b4e2c4",
+        "deployment_id": "8d2759291099498fa511f9d5b4c853b4",
+        "version_id": "3886dadce1724139b089587d2641ce55",
+        "mode": "source-image",
+        "runtime": "python3.11",
+        "package_sha256": "9df8aa2bc3c45bb68aa2692c80221313148b8a9f809c7d50b55053278870a6bc"
+      },
+      {
+        "name": "application-check",
+        "acs": [
+          "AC-36",
+          "AC-46"
+        ],
+        "outcome": "passed",
+        "kind": "live-api",
+        "observed_at": 1792678517.0248623,
+        "app_id": "80555884a2ba44e7964632e5b6b4e2c4",
+        "path": "/health"
+      },
+      {
+        "name": "application-check",
+        "acs": [
+          "AC-36",
+          "AC-46"
+        ],
+        "outcome": "passed",
+        "kind": "live-api",
+        "observed_at": 1792678517.0709221,
+        "app_id": "80555884a2ba44e7964632e5b6b4e2c4",
+        "path": "/sdk/identity"
+      },
+      {
+        "name": "application-check",
+        "acs": [
+          "AC-36",
+          "AC-46"
+        ],
+        "outcome": "passed",
+        "kind": "live-api",
+        "observed_at": 1792678517.5420403,
+        "app_id": "80555884a2ba44e7964632e5b6b4e2c4",
+        "path": "/sdk/storage-roundtrip"
+      },
+      {
+        "name": "websocket-allow-deny",
+        "acs": [
+          "AC-12",
+          "AC-36",
+          "AC-53"
+        ],
+        "outcome": "passed",
+        "kind": "live-api",
+        "observed_at": 1792678517.6367054,
+        "slug": "e2e-f059-python-source-d109src1"
+      },
+      {
+        "name": "owner-and-entry-boundaries",
+        "acs": [
+          "AC-10",
+          "AC-12",
+          "AC-33",
+          "AC-36",
+          "AC-46"
+        ],
+        "outcome": "passed",
+        "kind": "live-api",
+        "observed_at": 1792678597.357703,
+        "app_id": "80555884a2ba44e7964632e5b6b4e2c4",
+        "entry_status": 200
+      },
+      {
+        "name": "preapproval-failure",
+        "acs": [
+          "AC-08",
+          "AC-09",
+          "AC-28"
+        ],
+        "outcome": "passed",
+        "kind": "live-api",
+        "observed_at": 1792678605.9663966,
+        "app_id": "3f7513f9662c440a9bc8fa5b55bdff46",
+        "stage": "precheck_build",
+        "code": 16227
+      }
+    ],
+    "platform_exit": 0,
+    "explicit_offline_wheel_count": 8,
+    "no_download_in_bootstrap": true,
+    "boundary": "full source-image two revisions and expected missing-dependency rejection passed; Docker manager remains source54b; fault/upgrade/cleanup incomplete"
+  }
+}
diff --git a/features/v3.0.0/059-k8s-runtime-backend/docker-regression.md b/features/v3.0.0/059-k8s-runtime-backend/docker-regression.md
new file mode 100644
index 0000000000..a66e2ce2a7
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/docker-regression.md
@@ -0,0 +1,79 @@
+# F059 Docker 与平台回归记录(C 线准备)
+
+## 1. 结论与证据边界
+
+**本地回归与109真实Engine级应用包回归通过;完整平台业务验收仍未完成。** 主agent已在109运行同版原生容器回归,C线自身只提供本地准备。 本文不将 fake Engine、模拟 K8s、SQLite fixture 或跳过用例写成 109/189 部署证明。T067 的完整平台/权限/SDK验收仍待主 agent 串行完成,不能因本报告将其整个任务勾选完成。
+
+共同基线 `c90927b1f4e0388a956d4e300c7951ff7745b1bf`;继承的公共租户修复来自发布线 `9d156f261cc7769fcf75f41caeb104a425308017`,本线只修测试/文档/harness,未修改任何生产权限、审批、状态或执行器业务规则。执行时的测试文件变化见 Git 提交,日志摘要见 [docker-regression.json](./docker-regression.json)。
+
+## 2. 实际执行
+
+| 选择 | 结果 | 证据类别与限制 |
+| --- | --- | --- |
+| runtime-manager 全部 tests | 967 passed / 10 skipped | 本地;共享 Docker/K8s 执行器、构建、应用包、存储、数据、egress、生命周期;相应 fixture 模拟 Engine/集群 |
+| app-proxy 全部 tests | 256 passed | 本地 ASGI/HTTP/WS/路由/身份与拒绝;不是实机 nginx/网络/连接撤权 |
+| backend app_runtime+app_publish 全部 | 1564 passed / 18 skipped | 本地;真实 Python 生产服务逻辑与 SQLite fixture,外部中间件有mock;不证明MySQL/DM8实际迁移 |
+| 新harness安全合同+权限登记+live收集 | 54 passed / 11 skipped | 54为本地;11含原7项权限集成门及F059 live4项;不得计为业务E2E |
+| CLI `--help` | 通过 | 不访问网络、不写任何平台数据 |
+| CLI 无 `F059_E2E=1` | 拒绝,未产生journal | opt-in安全检查,不是运行失败掩盖 |
+| Ruff+相对路径arch-guard+diff --check | 通过 | 无生产代码变化;敏感字段拒绝测试为合成fixture,不包含真实凭据 |
+
+测试命令使用共享 Python3.11;backend 的 `config` 是临时专用测试配置路径,JSON不包含配置内容。原始日志位于本机 `/private/tmp/f059-e2e-*.log`;SHA-256固定内容,后续集成若测试数改变必须重新运行并更新,不能照抄此处数值。
+
+## 3. 发现并修复的过时测试合同
+
+1. 授权对象 picker 已支持 total:原fixture只mock list,遗漏 count,两个测试意外连接真实数据链路。补 count stub并断言 `data/total`,未放宽任何权限。
+2. 发布线已注册 linsight_skill:删除该资源“永远未注册”的负例假设,改为正向验证 app 完整 Catalog/registry/picker 与非owner-projection路径;未知类型拒绝用例保留。
+3. egress 策略读取必须独立 `roles=("egress",)` 身份;旧fixture无roles。补身份及真实K8s material表schema,保留真实repository投影,不mock为空绕过。
+4. 生产固定Pod名在维护多阶段复用但历史不可覆写:bddb1f986 已去掉 operation/kind/name 唯一约束,仅actualUID唯一。模型测试明确assert新合同及旧约束不存在。
+
+同一全量backend选择首次为1519 passed / 18 skipped / 3 failed;修复egress身份/fixture表与模型约束后为1525 passed / 18 skipped。通过数还增加了3个清理安全参数用例,因此不能单凭总数差推断覆盖。更早仅权限定向基线为3 failed / 41 passed / 7 skipped,修复后该组44 passed / 7 skipped。
+
+## 4. 真实 Docker 回归待执行
+
+使用 [e2e-checklist.md](./e2e-checklist.md) 同一CLI和测试数据,`F059_E2E_EXECUTOR=docker`,全部身份及应用仍使用专用 `e2e-f059-` 租户/名字,禁止连接或清理109已有非测试业务服务。
+
+- [ ] v1源码/source-image、v2 bundle、完整image;Python/Node/static真实准备与探活。
+- [ ] 平台CLI发布、预检拒绝、单独人工审批、预览隔离、正式入口、版本更新。
+- [ ] owner/nonowner/foreign允许与拒绝;日志筛选脱敏;HTTP/WS、SDK/OBO、CONNECT撤权与manager停用投影。
+- [ ] SQLite提交行、停用/恢复、容器重建、备份/迁移/恢复、owner删除;backend附件SDK0.1.1上传下载及跨应用拒绝。
+- [ ] 离线镜像+离线依赖+平台接口分别验证;缺wheel/坏摘要/镜像拉取失败/健康失败保留精确阶段。
+- [ ] 原有非测试容器/应用前后清单一致;只回收本次拥有的应用与临时资源。
+
+DM8按既有确认仅模型/DDL规范静态检查;通用SQLAlchemy dm分支编译不等于实际dmPython/DM8迁移测试。189 amd64单节点结果也不能补齐Docker、arm64或跨节点验收。
+
+
+## 109 真实 Engine 级回归(2026-10-22)
+
+使用专用 `/opt/e2e-f059-docker`、随机测试node/network、原生amd64镜像与真实Docker Engine,运行 `scripts/smoke_runtime_delivery.py`,退出0。Docker Engine测试身份只在本次测试容器挂载socket,不代表运行服务必须拥有该权限;未重启daemon或任何既有服务。
+
+| 实际检查 | 结果 |
+| --- | --- |
+| Python/Node/static离线准备、真实HTTP、代码v1→v2 | PASS |
+| 三语言依赖产物复用 | PASS |
+| static前端构建/install hook及源码变更使依赖缓存失效 | PASS |
+| CPython原生扩展离线构建、相同包版本改源码仍生成新产物 | PASS |
+| SQLite数据保留、容器停启/数据读写 | PASS |
+| bundle发布前后镜像集合无新增OCI构建 | PASS |
+| 临时容器/网络清理、非测试容器清单前后SHA一致 | PASS |
+
+原始证据在109专用目录,镜像config digest、关键脚本摘要及完整结果收在同名JSON。该工具的artifact port是内存实现,**不覆盖真实backend/MinIO/发布审批/自然人权限/SDK/OBO**,T067保持未完成;不得将其改写为平台全链路通过。
+
+## 5. L1
+
+架构分层 PASS;命名与AC PASS;平台envelope/CSV/WS协议 PASS;数据库 N/A(只更新fixture和约束断言);前端 N/A;信息泄漏 PASS(安全env、私有journal、无响应体日志);设计同步 PASS(不改生产接口)。真实验收与SDD汇总文件由主agent合流后更新。
+
+## 109 平台业务增量(54b runtime / 7b 验收同步)
+
+专用 `/opt/e2e-f059-docker-platform`,真实 MySQL/Redis/MinIO/OpenFGA/backend/Celery 与三代理,固定 manager manifest digest `26f61e7b…`;profile revision=1。
+
+- Python、Node、static 的 bundle 均通过两版真实发布、预检、独立审批、预览、入口和停用恢复。Python/Node 的 WebSocket 允许/拒绝配对通过;Python 的 SDK 身份/OBO、真实附件往返、SQLite 查改导出与跨停启保留通过。三组 owner/nonowner/foreign 边界由实际接口校验。
+- 运行数据 helper 的 WAL 读问题已修复并在以上 Python 平台路径复验。停止/恢复工具等待精确操作终态后再检查数据,不吞临时维护错误。
+- 第四个 source-image 构建完成,但以 `16124 / precheck_probe / startup_exited` 失败;该旧版 Docker 路径是裸 Python 基础镜像,空 requirements 的夹具缺 SDK 物料。T191–T193 补可校验离线 vendor,失败记录保留。
+- 完整镜像已另起固定两 digest 的专用批次,尚未计通过。source-image、缺依赖、故障/恢复/删除及最终非测试清单仍待闭合,T067 保持 pending。
+
+固定日志摘要、应用 ID 与步骤见同名 JSON。此处不将成功的代理请求当作宿主防火墙实际阻断证据,也不宣称真实模型正向调用已验证。
+
+完整镜像增量:独立d109image1批次v1/v2两次publish、预览审批及v1 stop/resume均有实际通过记录;首次批次最后观察退出1,后来安全诊断wrapper仅重做v2 observe(含SDK/附件/WS/数据/权限)退出0。原失败无精确栈,原因未定,不改写为无中断整批通过。固定日志及ID见JSON;T067仍pending。
+
+109 `d109src1`实际平台批次退出0:source-image两版发布、审批预览、HTTP/WS、SDK/OBO、真实附件、SQLite读写/导出、owner/非owner/跨租户配对、停用恢复与升级全程通过;缺离线依赖在precheck_build按16227拒绝且无审批。源码包使用显式8个pure-Python wheel及原SDK字节,bootstrap不访问网络、不读取开发venv。固定journal SHA `3c8b0eaff14c5483568767f64e8c706b512df81aa6ca0d0e98776a8bb35b79f4`,精确ID在JSON。旧失败source应用记录保留;当前manager仍54b,故障/最终清理未完成,T067不提前勾选。
diff --git a/features/v3.0.0/059-k8s-runtime-backend/e2e-checklist.md b/features/v3.0.0/059-k8s-runtime-backend/e2e-checklist.md
new file mode 100644
index 0000000000..b2499d19d4
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/e2e-checklist.md
@@ -0,0 +1,194 @@
+# F059 E2E 验证清单与执行入口
+
+## 1. 当前证据边界
+
+此文件交付的是可执行的业务 API 行程和完整 AC 映射。**本次 C 线尚未连接 189 或 109;下表真实环境状态全部为“未执行”**。本地测试、模拟客户端和 Chart 静态检查不能证明部署验收通过。完成真实运行后,由主 agent 将脱敏事实写入 `e2e-evidence/installation.*`、`real-platform.*`、`fault-upgrade.*`,保留每条 AC 的部分覆盖与环境限制。
+
+189 是单节点 amd64;不承诺或声称 arm64 原生、跨节点卷迁移、节点失联自动恢复通过。DM8 只做已确认的模型与迁移静态检查。
+
+## 2. 运行前置与身份
+
+- 已部署真实 backend、Celery、SQL、Redis、OpenFGA、MinIO 和 F059 运行时;不能把 manager API 或 mock transport 当平台 E2E。
+- 已创建专用 **非根** `e2e-f059-` 租户,配置单节点、单审批人的应用发布审批场景,审批人必须是与 owner 不同的自然人;自动审批不覆盖预览/人工决定行程,脚本会拒绝它。
+- owner、同租户普通 nonowner、另一专用租户 foreign、同租户 approver 是四个不同的 `e2e-f059-` 用户,不能为平台超管。nonowner 不能是租户管理员,也不能被授予应用权限;owner 保持 app 默认独占权限。
+- 平台超级管理员仅创建/确认测试租户及读取全局运行诊断,不代替 owner 做数据或删除操作。CLI API key 必须是该 owner 的 mode-S、`app:manage` 凭据。
+- 验收包的 name 和精确 slug 均以 `e2e-f059-` 开头,manifest 必须位于包根。预供离线 wheelhouse/npm材料、冻结profile、运行镜像和(仅source-image)内网仓库。
+- 所有环境变量在专用进程中通过安全注入设置。禁止把密码、API key、kubeconfig、HMAC 或许可证写入脚本、JSON、Git、日志或命令行参数。
+
+| 环境变量 | 内容 |
+| --- | --- |
+| `F059_E2E` | 显式 `1`,否则 pytest 跳过,CLI 拒绝 |
+| `E2E_API_BASE` | 目标平台 `/api/v1` 地址,使用共享 auth helpers 的同一地址 |
+| `F059_E2E_ENTRY_BASE` | app-proxy/nginx 统一入口 origin;脚本核对实际 entry_url origin |
+| `E2E_ADMIN_USERNAME`、`E2E_ADMIN_PASSWORD` 或 `E2E_ADMIN_TOKEN` | 仅专用环境的平台超管认证;共享 helpers 完成 RSA 登录 |
+| `F059_E2E_TENANT_CODE` | 已准备的专用 `e2e-f059-` 非根租户代码 |
+| `F059_E2E_OWNER_USERNAME`、`F059_E2E_OWNER_PASSWORD` | 自然人 owner |
+| `F059_E2E_NONOWNER_USERNAME`、`F059_E2E_NONOWNER_PASSWORD` | 同租户普通 nonowner |
+| `F059_E2E_FOREIGN_USERNAME`、`F059_E2E_FOREIGN_PASSWORD` | 其他测试租户普通用户 |
+| `F059_E2E_APPROVER_USERNAME`、`F059_E2E_APPROVER_PASSWORD` | 独立审批人 |
+| `F059_E2E_OWNER_API_KEY` | 与 owner 相同资源所有者的 open API key |
+| `F059_E2E_SCENARIOS` | 下述无凭据 scenario JSON 文件的绝对路径 |
+| `F059_E2E_TIMEOUT` | 单阶段轮询上限,默认600秒,允许30–1800;恢复期限验收应设300 |
+| `F059_E2E_EXECUTOR` | 默认kubernetes;同一行程在专用Docker可设docker |
+| `F059_E2E_EVIDENCE` | pytest 可选私有证据文件位置;CLI 必须传 `--evidence` |
+
+脚本逐用户访问 `/user/info` 与 `/user/current-tenant` 确认身份,API key 在任何发布前经 `/api/v2/auth/whoami` 核对 mode-S、app:manage、租户与自然 owner;发布后再用 session GET 核对 app。`ensure_test_tenant` 复用共享 helper;如果环境禁止创建或用户尚未归属该租户,必须先正确准备,不可改为 root 租户运行。
+
+## 3. Scenario 文件与真实应用
+
+```json
+[
+  {
+    "slug": "e2e-f059-python-bundle",
+    "package": "/opt/e2e-f059/packages/python-bundle.tar.gz",
+    "update": "/opt/e2e-f059/packages/python-bundle-code-update.tar.gz",
+    "mode": "bundle",
+    "table": "items",
+    "row_key": "1",
+    "update_values": {"name": "e2e-f059-persisted"},
+    "entry_path": "/",
+    "expected_text": "e2e-f059-python",
+    "application_checks": [
+      {"path": "/e2e/sdk-storage-roundtrip", "expected_json": {"ok": true}},
+      {"path": "/e2e/model-obo", "expected_json": {"ok": true}}
+    ],
+    "websocket_path": "/ws"
+  },
+  {
+    "slug": "e2e-f059-missing-wheel",
+    "package": "/opt/e2e-f059/packages/missing-wheel.tar.gz",
+    "mode": "bundle",
+    "expected_failure_code": 16227,
+    "expected_failure_stage": "precheck_build"
+  }
+]
+```
+
+上例是参数形状,实际失败 stage 必须从已批准的部署合同与 fixture 确认,不能为让测试变绿而改期望。`update` 是同 runtime/交付模式的新版本包;脚本不代替 CLI 打包、锁依赖或生成轮子。完整验收须准备 Python/Node/static bundle、完整 image、显式 source-image、离线缺包/坏摘要/错架构/镜像拉取/健康失败等专用包。
+
+`application_checks` 是应用实际执行 SDK/模型/OBO 的测试入口,不是 backend 绕过身份的管理接口:每个回调自行调用 SDK0.1.1 后返回无敏感信息的结果,需审查 fixture 实现并保留服务端调用证据。未配置、未执行、或只返回固定 `ok` 都不能算 SDK 验收。`websocket_path` 指向真实 echo 端点,脚本断言 owner 收发及 nonowner 在 upgrade 前被拒绝;连接撤权期限还须执行下述人工故障步骤。
+
+## 4. 分阶段、可续跑命令
+
+在仓库根使用 Python3.11 和 backend 测试依赖;不要运行多个验收进程并发修改同一套测试应用。
+
+```bash
+python scripts/e2e_k8s_runtime.py --stage installation --evidence /tmp/e2e-f059-api.json
+python scripts/e2e_k8s_runtime.py --stage platform --evidence /tmp/e2e-f059-api.json
+python scripts/e2e_k8s_runtime.py --stage observe --evidence /tmp/e2e-f059-api.json
+python scripts/e2e_k8s_runtime.py --stage fault-upgrade --event manager-restart --checkpoint before --evidence /tmp/e2e-f059-api.json
+# Operator serially restarts only the dedicated manager and records UID/time.
+python scripts/e2e_k8s_runtime.py --stage fault-upgrade --event manager-restart --checkpoint after --evidence /tmp/e2e-f059-api.json
+python scripts/e2e_k8s_runtime.py --stage cleanup --evidence /tmp/e2e-f059-api.json
+```
+
+- `installation`:真实平台诊断与权限;必须先有安装工具的实际集群探测,不能以此代替。
+- `platform`:setup 精确清理上次同 slug 的测试应用,逐包 deploy→轮询→预览→审批→上线→GET→日志/权限/数据/CSV→停用恢复→可选版本更新。为后续故障验收保留本次应用与 journal;失败也保留可诊断现场,随后显式 cleanup。
+- `observe`、`cycle`:续用 journal,再按当前后端事实与自然 owner 核验,分别只读行程或停用恢复。
+- `fault-upgrade`:支持 manager-restart、runtime-upgrade、app-restart 的 before/after 观察。**不触发故障**,after在既定恢复期限内只读轮询实例与数据;操作者须在恢复期限内运行并附实际Pod/Deployment UID与时间。after不补写业务数据或触发SDK/附件写入;journal 标明 `injection_verified=false`,单凭这个结果不能声称注入已完成。
+- `fault-operation`:observe/cancel/restore仅操作同suite精确app和operation;restore必须提供snapshot、fresh expected-generation、明确acknowledge-data-loss及expected-rows-file。保护快照允许去重,公开API缺少关联时记录 `protection_link_verified=false`,需由主agent补安全账本证据,不能声明完整AC-51。
+- `platform`预览还须以独立approver真实GET entry_url,并验证owner/nonowner/foreign被拒绝,生产SQLite rows预览前后相同。
+- `all`:完整业务行程,setup清理+finally teardown清理;pytest 同样双重cleanup并共享 class 事件循环。仅使用 `--stage all` 时失败finally也可能删除诊断实例,先保存日志。
+- `cleanup`:只遍历场景文件中的精确 slug;fresh GET 再核对同租户、同自然 owner、name前缀,online先停用再删除,最后GET精确16101。不删除租户/用户、全体前缀、非测试资源或未知writer;失败必须保留给主agent处理。
+
+```bash
+python -m pytest -q src/backend/test/e2e/test_e2e_vibe_k8s_runtime.py
+```
+
+测试代码复用 `helpers/auth.py`、`helpers/api.py`、`helpers/cleanup.py`;新增 `helpers/vibe_runtime.py` 为该 feature 的共享行程。禁用 opt-in 时4项测试跳过,不能计为真实通过。journal 原子保存为0600且拒绝凭据字段;API/SDK错误响应体不写CLI日志。完成后清理私有运行目录中的凭据、临时包和只含测试行的证据。
+
+## 5. 55 条有效 AC 追溯
+
+下表覆盖职责分类,不是“55条已通过”。API表示脚本能够覆盖其中业务部分;组合AC仍需补齐基础设施/人工证据。AC-38已退役且不复用。
+
+| AC | 验证类型 | 实际入口与必留证据 | 真实状态 |
+| --- | --- | --- | --- |
+| AC-01 | API+配置静态 | installation;确认 executor_kind=kubernetes、protocol_version=2;另核对部署开关不可运行期切换 | 未执行 |
+| AC-02 | API+人工 | platform;人工检查广场/详情/CLI/MCP、未部署入口及四类兜底页 | 未执行 |
+| AC-03 | 基础设施真实 | 检查所有 Pod 实际 env/mount/SA;仅 manager 获得命名空间 token,其他进程实际 API 请求拒绝 | 未执行 |
+| AC-04 | API | platform:真实 deploy、部署轮询、审批、日志、启停;销毁由 cleanup 验证 | 未执行 |
+| AC-05 | API+基础设施真实 | platform 审批预览/正式上线;比对 Pod UID、探活事实及路由提交顺序 | 未执行 |
+| AC-06 | 故障注入+API | app-restart before/after;人工记录真实退出、故障时刻及 ≤300 秒恢复;单节点本地卷不证明跨节点 | 未执行 |
+| AC-07 | 故障注入+API | manager-restart before/after;停止 manager 超过短缓存期,独立观察新入口与既有 HTTP/WS/CONNECT | 未执行 |
+| AC-08 | API+故障注入 | 负向 package 精确失败码/stage、无版本无审批;审批后制造容量不足,再原单 manual-publish 并核对审批ID不变 | 未执行 |
+| AC-09 | API+故障注入 | 负向 package 缺包/坏镜像/探活失败;核对有界结束及安全原因,恢复失败不能新增业务终态 | 未执行 |
+| AC-10 | API+人工 | observe:真实 instance/logs/versions 范围与权限;人工检验刷新、筛选和敏感信息脱敏 | 未执行 |
+| AC-11 | 基础设施真实 | 并发/重试部署时按 app_id 列出所有实际 Pod/UID,任一时刻仅一个正式实例;区分预览与准备 | 未执行 |
+| AC-12 | API+基础设施真实 | observe HTTP与可选 websocket_path;实际非授权Pod直连应用必须拒绝,无逐应用NodePort/LB | 未执行 |
+| AC-13 | 基础设施真实 | 安装工具按 manager 身份实际执行允许/拒绝:跨namespace、特权、hostPath、RBAC/exec/status均拒绝 | 未执行 |
+| AC-14 | 静态+基础设施真实 | 交付权限清单与实际RBAC矩阵一致;审计未启用明确标记,不能用普通日志伪造集群审计 | 未执行 |
+| AC-15 | 基础设施真实 | 读取真实业务/准备/初始化/维护执行体环境、挂载及进程;不可取得manager、MinIO、SQL或集群身份 | 未执行 |
+| AC-16 | 基础设施真实 | 实测非root、只读rootfs、代码/venv不可写、capabilities全部丢弃及资源限制;只允许明确工作目录 | 未执行 |
+| AC-17 | API+基础设施真实 | 通过既有档位API修改/禁用,现有Pod资源冻结不变,下次发布按新档位;拒绝停用档位 | 未执行 |
+| AC-18 | 故障注入 | 在独占测试应用制造内存/CPU超限,旁路观测平台和第二个测试应用仍可用,不耗尽整个集群 | 未执行 |
+| AC-19 | 基础设施真实 | 实际不可用RuntimeClass时记录默认档回退与告警;不声称已启用更强沙箱 | 未执行 |
+| AC-20 | 基础设施真实 | 切换部署隔离档复跑身份、网络、资源和卷单写矩阵 | 未执行 |
+| AC-21 | 基础设施真实+应用API | 成对测试可达受控egress与不可达DNS/公网/平台/其他应用/API;已建立连接撤权须计时 | 未执行 |
+| AC-22 | 基础设施真实 | 逐准备阶段实测允许端点与拒绝端点;offline安装不得访问公网fallback或生产数据库 | 未执行 |
+| AC-23 | 故障注入 | 破坏专用namespace必需隔离并重探,准备/上线/维护实际拒绝;恢复并重新验证后才放行 | 未执行 |
+| AC-24 | API+基础设施真实 | platform 各bundle场景;观察代码变化无新OCI镜像构建/推送,记录冻结profile/artifact | 未执行 |
+| AC-25 | API+基础设施真实 | 至少Python3.11/Node20/static bundle、完整image、source-image五个scenario;无仓库只阻断source-image | 未执行 |
+| AC-26 | API+基础设施真实 | 真实应用包在执行Pod物化,分别检查backend持久产物、镜像可取得和无应用推送仓库bundle发布 | 未执行 |
+| AC-27 | 基础设施真实 | 镜像pull/build Secret只在受控位置;业务env/mount/日志无仓库凭据 | 未执行 |
+| AC-28 | API | 配置负向scenario的expected_failure_code和expected_failure_stage;缺包/错架构/坏摘要/拉取失败均精确断言 | 未执行 |
+| AC-29 | API+基础设施真实 | cycle 停用恢复;清理/缓存丢失后重建当前、待上线、停用引用仍可恢复 | 未执行 |
+| AC-30 | 基础设施真实+API | 安装工具真实逐项探测报告后,installation读取平台ready/preflight;后者不能代替真实探测 | 未执行 |
+| AC-31 | 基础设施真实 | 动态SC缺失但合格静态PV允许;硬前置失败拒绝;node-local限制持续可见,可选沙箱回退不越过必需隔离 | 未执行 |
+| AC-32 | API | installation 管理员允许、普通owner拒绝;检查真实逐项结果、时间、原因及回退信息 | 未执行 |
+| AC-33 | API+故障注入 | data_roundtrip/CSV、cycle与app-restart checkpoint提交行保持;隔离卷不误共享 | 未执行 |
+| AC-34 | 基础设施真实 | 独立预检卷执行flock/SQLite/fsync;NFS/SMB配置拒绝;卷拓扑与节点绑定实际一致 | 未执行 |
+| AC-35 | API+基础设施真实 | cycle保留行;cleanup owner删除后GET16101;升级/卸载前后PV UID/卷内容比对;旧写入者未知不得purge | 未执行 |
+| AC-36 | API+人工+基础设施真实 | 真实deploy/审批/预览/日志/数据/权限+可选SDK回调/WS;复用F053/F057/F086既有用例,UI人工复核 | 未执行 |
+| AC-37 | API+人工 | 状态、审批审计、通知与CLI/MCP错误/文案逐项比对Docker;不把Pod内部名字暴露业务页面 | 未执行 |
+| AC-39 | 静态+真实安装 | 按Chart/README完成独立namespace安装、升级、卸载;保存脱敏helm/权限/卷/镜像供应证据 | 未执行 |
+| AC-40 | 故障注入+API | runtime-upgrade/manager-restart checkpoint;实际应用UID不变,行数据不变;代理按排空合同验证 | 未执行 |
+| AC-41 | 真实卸载+人工 | 仅回收安装拥有控制资源,保留PV/代码/依赖/快照/附件;平台其他功能和开关行为正常 | 未执行 |
+| AC-42 | 基础设施真实+API | 断公网使用预灌digest镜像与完整依赖发布/审批/重建;缺依赖必须失败,内网registry仅source-image依赖 | 未执行 |
+| AC-43 | 故障注入 | 准备/probe/assemble/maintenance期限、并发与资源超限;实际取消/超时后回收,未知writer保持占用 | 未执行 |
+| AC-44 | API+基础设施真实 | 更新/重建实际Pod,代码与原生环境从backend产物重新构造;移走manager本机缓存后仍成立 | 未执行 |
+| AC-45 | API+基础设施真实 | 代码only update核对同依赖artifact;变更profile/架构/lock/offline材料/安装源码时artifact必须变更 | 未执行 |
+| AC-46 | API+基础设施真实 | data_roundtrip与application_checks触发实际SDK0.1.1附件/能力调用;核对只有backend访问MinIO,业务无MinIO/SQL配置 | 未执行 |
+| AC-47 | 基础设施真实+负向API | 隔离材料身份签发/续期/当前归属;过期、重放、篡改、跨app/tenant、撤销、未确认及坏摘要全部拒绝 | 未执行 |
+| AC-48 | 故障注入 | 重复通知、manager重启及多进程竞争;持久事实/审批/正式UID数量与额度一致,无提前释放 | 未执行 |
+| AC-49 | 故障注入 | 旧Pod正在删除但停止未知:观察资源与writer归属不释放,新writer不出现;404/NodeLost/超时不能作停止证明 | 未执行 |
+| AC-50 | API+基础设施真实 | 更新/迁移/恢复时记录旧writer停止证据;预览数据与生产隔离,整个窗口无双writer | 未执行 |
+| AC-51 | 故障注入+ownerAPI | 阻断快照确认禁止DDL;DDL后启动失败保留维护状态;owner显式restore保护当前数据并核对快照 | 未执行 |
+| AC-52 | 故障注入 | 各阶段取消/超时/kill/响应丢失+同名UID替换;有界清理,仅未知writer或唯一未确认备份继续占用 | 未执行 |
+| AC-53 | API+计时故障注入 | 可选WS allow/deny+HTTP/CONNECT撤权/失联期限;manager停用后新访问依据backend投影 | 未执行 |
+| AC-54 | 原生架构验收 | 189仅amd64;arm64原生与跨架构负向需独立环境,不能用QEMU/元数据/amd64代替;镜像/材料/API分别核验 | 未执行 |
+| AC-55 | 证据治理 | 脚本journal记录实际API;installation/platform/fault evidence逐条记录类别与未验证;非测试资源前后清单相同 | 未执行 |
+| AC-56 | 本地+真实Docker | 相同本地runtime/backend测试与109专用Docker真实发布回归;DM8仅静态模型/DDL,保持发布线修复来源 | 未执行 |
+
+## 6. 危险前置、失败和 UI 清单
+
+- [ ] 安装/故障前后导出脱敏 cluster UID、命名空间与非测试工作负载清单;仅在指定 `e2e-f059-` namespace/路径/PV上操作。
+- [ ] 禁止重启共享节点/kubelet/Docker daemon,禁止修改 kube-system、生产包源/网关/防火墙;故障限制到专用 manager、代理、应用与临时策略。
+- [ ] RBAC/PSA/准入/网络每项记录一条允许和一条拒绝,包括真实服务端结果、Pod UID、target 与时刻,不只运行 `kubectl auth can-i`。
+- [ ] 每个负向失败保留阶段、安全原因、operation ID、cleanup_status;未知writer未清理须显式保留,不能“清场”假装完成。
+- [ ] SQLite迁移先记录旧writer停止证明,再确认backend快照已持久化,断上传确认时不得DDL;DDL后启动失败不得自动回滚数据库。
+- [ ] 容量不足区分审批前预检失败与审批后pending_capacity;后者原单手动上线无第二审批,前者重新部署。
+- [ ] HTTP/WS/CONNECT已建立连接撤权分别计时;manager不可用超过短缓存期仍从backend投影提供新访问判断。
+- [ ] UI:应用广场、详情、状态/日志刷新、版本只读、审批预览、数据表/CSV、停用/恢复与删除提示;owner/nonowner/foreign三身份;四类兜底页;刷新后状态持久,控制台无新增错误。
+- [ ] CLI/MCP:复跑F053/F052现有真实调用,精确exit/business code和文案;SDK复跑F057真实调用,不能只测harness回调固定值。
+- [ ] 升级与卸载:应用Pod UID、PV UID、SQLite行、backend源码/产物/快照/附件引用前后比对;Helm卸载不级联清理数据。
+- [ ] amd64本地卷测试明确不覆盖跨节点/arm64;DM8仅静态,不将通用SQLAlchemy DM分支编译写成真实驱动DDL通过。
+
+## 7. L1 自审(交付准备,非真实验收结论)
+
+| 检查 | 结果 | 说明 |
+| --- | --- | --- |
+| 1 架构分层 | PASS | 测试/harness只调已有平台与app入口;无DAO/SQL/MinIO/集群客户/manager直连 |
+| 2 命名与AC | PASS | pytest名称、完整AC docstring、55条有效AC映射;AC-38退役 |
+| 3 序列化 | PASS | 真实平台envelope,接受restore等既有202;文件CSV、HTTP/WS按既有原始协议断言 |
+| 4 数据库 | N/A | 未添加/改动生产模型或DDL;模型测试仅纠正已交付历史约束 |
+| 5 前端 | N/A | 无前端源码;UI列为待人工执行 |
+| 6 信息泄漏 | PASS | 凭据仅安全env内存;输出无响应体;600 journal;按自然owner/租户/精确slug重验后清理 |
+| 7 设计同步 | PASS | 测试既有对外合同,无新增生产API/决策;实际验收与汇总由主agent完成 |
+
+任务文件归属:T066两个原目标;新增pytest/helper/安全合同测试由主agent独立登记任务。T067文档完整记录本地/真实边界,真实Docker尚未执行,不能因此将整个验收任务标完成。
+
+## 8. 重跑与Docker环境文件
+
+应用删除保留审计及唯一slug。需要重新首发整批应用时,使用新的suite-dir、`--application-suffix r2`和新的journal;prepare-only与bootstrap都携带同后缀,不能直接改旧suite的marker。复制已核对的私有身份env时仅移除旧instance/scenarios/suffix,新批继续通过真实API复核自然身份、租户和publisher key。旧批证据和审计保留,不清数据库或FGA。
+
+bootstrap输出env是shell scalar格式;Docker的`--env-file`不会去掉shell引号。通过bootstrap.read_env可信解析后,把无换行/NUL的值写入独立0600 Docker字面值文件;不能source输出凭据,也不能直接把带引号密码交给Docker。
diff --git a/features/v3.0.0/059-k8s-runtime-backend/e2e-evidence/fault-upgrade.json b/features/v3.0.0/059-k8s-runtime-backend/e2e-evidence/fault-upgrade.json
new file mode 100644
index 0000000000..c5e2c109b5
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/e2e-evidence/fault-upgrade.json
@@ -0,0 +1,1315 @@
+{
+  "schema": "f059-fault-upgrade-evidence/v1",
+  "state": "in_progress",
+  "source_commit": "54b3ea2c15836529cc24667d9a58be49e35bf710",
+  "host": "192.168.106.109",
+  "actual_cancel": {
+    "accepted": {
+      "app_id": "6808639e9f3d403a9c0b6c5390e2c558",
+      "deployment_id": "6ae3fc39eb474682b48570b494fde720",
+      "version_id": "a77ee78b33f64a999edadb1feeecb4c7"
+    },
+    "operation_id": "f26cb75f06864bb4906bce5d1521511f",
+    "events": [
+      {
+        "name": "owner-cancel-prepare",
+        "operation_id": "f26cb75f06864bb4906bce5d1521511f",
+        "status": "cancelled",
+        "reason": "operation_cancelled",
+        "cleanup_status": "complete",
+        "nonowner_code": 16105,
+        "foreign_code": 16105,
+        "observed_at": 1792675705.1564524,
+        "infrastructure_cleanup_requires_operator_uid_evidence": true
+      }
+    ],
+    "hook_begin_verified": true,
+    "exact_container_id": "66cc7e04717f0ae0e3f9132927ca0a5e7a99a1b67a512258bac5db7dc4fd710e",
+    "cleanup": {
+      "operation_id": "f26cb75f06864bb4906bce5d1521511f",
+      "container_id": "66cc7e04717f0ae0e3f9132927ca0a5e7a99a1b67a512258bac5db7dc4fd710e",
+      "absent_twice": true,
+      "remaining_operation_containers": 0,
+      "operator_force_cleanup": false
+    },
+    "evidence_sha256": {
+      "fault-cancel-d109c2-hook.log": "964209dd9f3c54cb38c768b7a80601e466859012b0e7fdc6718f913aaacbb083",
+      "fault-cancel-d109c2-container.txt": "cd6ef7b8a34db4b426b949495bff910e37c74f008ccab531d6960a83706855b0",
+      "fault-cancel-d109c2.json": "2eb71ee50ba89f14982e6963dde89204ceb646787008a3960edc17e0e6d6726c",
+      "fault-cancel-d109c2-cleanup.json": "21414a7f7e192899e92f087053f1d827dc61897674d5d2664f6ea74617411f7a"
+    },
+    "private_dir": "/opt/e2e-f059-docker-platform/evidence",
+    "scope": "real slow npm root postinstall; owner public cancel; nonowner/foreign rejected; natural cleanup, no force deletion"
+  },
+  "rejected_setup_attempt": {
+    "slug": "e2e-f059-cancel-d109c1",
+    "reason": "operator chose profile revision 2 but Docker environment uses revision 1",
+    "cancel_verified": false,
+    "record_retained": true
+  },
+  "not_executed": [
+    "189 init faults; additional UID replacement/unknown-writer injection beyond held legacy safety negatives",
+    "response loss",
+    "full uninstall retention in independent install/platform-storage domain",
+    "NodeLost or cross-node fencing"
+  ],
+  "boundary": "local fixture/HTTP proxy preparations do not count as deployed fault proof; T070 remains pending",
+  "migration_v1_baseline": {
+    "host": "192.168.106.109",
+    "source_commit": "54b3ea2c15836529cc24667d9a58be49e35bf710",
+    "app_id": "971d1ff2e6ff4707a8e7d948c8300e33",
+    "deployment_id": "cf48aaca1cfe42d193a20785d79bb680",
+    "version_id": "38d776c7e46f4d7bb8f9447515cf98bc",
+    "approval_instance_id": 12,
+    "approval_status": "executed",
+    "operation_id": "5537ecd5decb48c391c49a8434fa3fdf",
+    "generation": 1,
+    "rows_sha256": "f3920b4883625621595620f31f34d6849c953555d0fcc3755b8685f0eb0b935f",
+    "sentinel_sha256": "50d0408b6162b71fbd60d06958dee46be22c9afc8df16ae85839de1d80a8f155",
+    "journal_sha256": "12c439bb727ac913ccb50748eb9d9430e5a4efe2f454a4d3ef591d115ddd0194",
+    "two_owner_writes_confirmed": true,
+    "nonowner_code": 16162,
+    "foreign_code": 16101,
+    "denied_patches_unchanged_rows": true,
+    "private_root": "/opt/e2e-f059-docker-platform/evidence/e2e-f059-migration-d109c2",
+    "old_tool_failure": "approval terminal incorrectly expected approved; refused before rows; fixed tool only resumed same successful publication without republish",
+    "migration_v2_run": false,
+    "restore_run": false
+  },
+  "controller_idle_red_189": {
+    "observed_at": 1792679583.9069412,
+    "node": {
+      "node_id": "e2e-f059-node",
+      "controller_holder": "95533d82-2e47-47bc-888d-e72ac07e2fff",
+      "controller_epoch": 2,
+      "lease_until": "2026-10-22T14:21:06",
+      "status": "ready"
+    },
+    "operation": {
+      "operation_id": "d0b665da84014b2fab43ef60f1b282b6",
+      "app_id": "2bde519a30f946a091f5ae4aa5cf2248",
+      "kind": "prepare",
+      "status": "running",
+      "stage": "admitted",
+      "deadline": "2026-10-22T14:34:12",
+      "cleanup_status": "pending",
+      "fact_sequence": 0,
+      "generation": 1,
+      "owner_epoch": 1
+    },
+    "resource_count": 0,
+    "resource_refs": [],
+    "evidence_sha256": "959db12479e9621a4a092105d0474275155f1d3a8695548b0dc758d5b40a103d"
+  },
+  "controller_idle_green_189": {
+    "source_commit": "e4052d1c2b7fab800ab2e39606d443d358ff5a1a",
+    "evidence": {
+      "descriptor_uid_equal": true,
+      "descriptor_data_equal": true,
+      "after": {
+        "node": {
+          "status": "ready",
+          "lease_until": "2026-10-22T14:49:00",
+          "controller_epoch": 4,
+          "node_id": "e2e-f059-node",
+          "controller_holder": "46357afa-438b-440c-96c0-3a7004d0ed87"
+        },
+        "resource_refs": [],
+        "operation": {
+          "status": "failed",
+          "kind": "prepare",
+          "owner_epoch": 1,
+          "cleanup_status": "complete",
+          "generation": 1,
+          "fact_sequence": 1,
+          "app_id": "2bde519a30f946a091f5ae4aa5cf2248",
+          "deadline": "2026-10-22T14:34:12",
+          "operation_id": "d0b665da84014b2fab43ef60f1b282b6",
+          "stage": "failed"
+        },
+        "observed_at": 1792681277.6602888,
+        "resource_count": 0
+      },
+      "holder_same": true,
+      "epoch_after": 4,
+      "pod_and_container_unchanged": true,
+      "before": {
+        "node": {
+          "status": "ready",
+          "lease_until": "2026-10-22T14:47:34",
+          "controller_epoch": 3,
+          "node_id": "e2e-f059-node",
+          "controller_holder": "46357afa-438b-440c-96c0-3a7004d0ed87"
+        },
+        "resource_refs": [],
+        "operation": {
+          "status": "failed",
+          "kind": "prepare",
+          "owner_epoch": 1,
+          "cleanup_status": "complete",
+          "generation": 1,
+          "fact_sequence": 1,
+          "app_id": "2bde519a30f946a091f5ae4aa5cf2248",
+          "deadline": "2026-10-22T14:34:12",
+          "operation_id": "d0b665da84014b2fab43ef60f1b282b6",
+          "stage": "failed"
+        },
+        "observed_at": 1792680507.6822045,
+        "resource_count": 0
+      },
+      "epoch_before": 3
+    },
+    "boundary": "Same PID/Pod/Container controller recovery observed. Distinct from business publication success or old writer stop."
+  },
+  "primary_uninstall_precondition_189": {
+    "verification": "static_chart_and_call_path_review",
+    "source_commit": "6f254f337dd6e8a36733501a2266bf08a04a56e9",
+    "status": "blocked_by_unconfirmed_old_creation",
+    "app_id": "2122578f094b4c5fb19f9669815431f2",
+    "operation_id": "6031594368584d1090ba7a7a2ca8e2e4",
+    "pvc_uid": "41426a1e-9713-46d5-afa5-e520afcd186b",
+    "pvc_state": "Bound",
+    "uninstall_executed": false,
+    "absence_is_stop_proof": false,
+    "reason": "Primary release removal deletes admission, execution network policies and RBAC; retaining namespaces/PV/PVC alone does not retain fencing for an unconfirmed old creation.",
+    "safe_alternative": "Independent release/installation id, control and execution namespaces, platform configuration and logical storage scope; independently qualified PV and live capability report; stop and cleanup its real SQLite application before uninstall.",
+    "shared_backend_limitation": "Coordinator runtime_default_node_id and runtime/data manager_base_url are global configuration; do not redirect the primary backend or reuse its active execution domain.",
+    "scope": "No primary uninstall, guard/finalizer removal, manual SQL release or 404-based stop assertion."
+  },
+  "manager_only_cancellation_upgrade_2ac": {
+    "source_revision": "2ac60f808be170e73f0094ba5c8d041f8a9e10cd",
+    "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:5619057b1c026d9ff545f4090f6c84c9aaef5fc1050604162770c7f4d018830c",
+    "manager_config_id": "sha256:586bdc40a1a2b0d2b8f1a1c2485c8da1a8dbb76f2c9eb939be7e5243ab4898ac",
+    "helm_revision": 28,
+    "native_build": {
+      "schema": "f059-native-manager-only/v1",
+      "source_revision": "2ac60f808be170e73f0094ba5c8d041f8a9e10cd",
+      "platform": "linux/amd64",
+      "manager_image": {
+        "tag": "e2e-f059/bisheng-runtime-manager:e2e-f059-native-20261023-28-r2-amd64",
+        "image_id": "sha256:586bdc40a1a2b0d2b8f1a1c2485c8da1a8dbb76f2c9eb939be7e5243ab4898ac"
+      },
+      "kubernetes_source_sha256": "038b8d95c3efd19ab7d5be2405890a7b21f334c025b2fecda0db50205af824a0",
+      "native_nonroot_isolated_import": {
+        "behavior_simulated": false,
+        "imported_modules": 5,
+        "isolated": true,
+        "kubernetes_source_sha256": "038b8d95c3efd19ab7d5be2405890a7b21f334c025b2fecda0db50205af824a0",
+        "platform": "linux/amd64",
+        "registry_accessed": false,
+        "uid": 10001
+      },
+      "public_push": false,
+      "official_ci_release": false,
+      "helper_assembler_profiles_rebuilt": false,
+      "helper_assembler_profiles_retagged": false,
+      "preceding_manager_reference": "192.168.106.189:5443/f059/runtime-manager@sha256:28449f9c96c2c60f1b1432a2f671b25434022e9e8675ea53aece36a823deee99",
+      "unrelated_containers_unchanged": true,
+      "network_inventory_unchanged": true,
+      "boundary": "Native manager build plus import/source-byte smoke only; no simulated new cancellation behavior or actual cluster/npm/ARM acceptance claimed."
+    },
+    "source_permissions": {
+      "source_revision": "2ac60f808be170e73f0094ba5c8d041f8a9e10cd",
+      "scope": "tracked src/runtime-manager build inputs only",
+      "files": 132,
+      "directories": 10,
+      "previous_file_modes": {
+        "0o600": 132
+      },
+      "regular_modes": "0644/0755 from tracked executable classification",
+      "content_unchanged": true,
+      "git_clean": true,
+      "ownership_changed": false
+    },
+    "private_push": {
+      "egress_admission_helper_assembler_profiles_updated": false,
+      "image": "192.168.106.189:5443/f059/runtime-manager@sha256:5619057b1c026d9ff545f4090f6c84c9aaef5fc1050604162770c7f4d018830c",
+      "local_tag": "e2e-f059/bisheng-runtime-manager:e2e-f059-native-20261023-28-r2-amd64",
+      "manager_image_id": "sha256:586bdc40a1a2b0d2b8f1a1c2485c8da1a8dbb76f2c9eb939be7e5243ab4898ac",
+      "native_smoke": "nonroot_isolated_import_and_fixed_source_bytes",
+      "observed_at": 1792712005.052754,
+      "official_ci_release": false,
+      "platform": "linux/amd64",
+      "private_tag": "192.168.106.189:5443/f059/runtime-manager:20261023-28",
+      "public_push": false,
+      "purpose": "f059-private-manager-push/v1",
+      "push_log": "/opt/e2e-f059/native-2ac60f808/evidence/private-manager-push-r2.log",
+      "push_retried": false,
+      "registry_manifest_digest": "sha256:5619057b1c026d9ff545f4090f6c84c9aaef5fc1050604162770c7f4d018830c",
+      "single_cli_submission_per_intent": true,
+      "source_revision": "2ac60f808be170e73f0094ba5c8d041f8a9e10cd",
+      "tls_verification": true,
+      "transport_attempt_count_verified": false
+    },
+    "upgrade_receipt": {
+      "arm_tested": false,
+      "descriptor_data_uid_unchanged": true,
+      "five_app_pod_container_pvc_pv_identities_unchanged": true,
+      "helm_upgrade_completed": true,
+      "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:5619057b1c026d9ff545f4090f6c84c9aaef5fc1050604162770c7f4d018830c",
+      "new_manager_pod_uid": "faa48ee8-fd38-4980-a290-4b44ada8d6b4",
+      "old_manager_pod_uid": "30d08ee0-f6af-4ab6-ada4-217ce1cee181",
+      "profile_helper_assembler_images_unchanged": true,
+      "proxy_admission_identities_unchanged": true,
+      "public_push": false,
+      "r4_pvc_uid_unchanged": true,
+      "source_revision": "2ac60f808be170e73f0094ba5c8d041f8a9e10cd"
+    },
+    "post_upgrade_scope": {
+      "apps": [
+        {
+          "containers": [
+            {
+              "container_id": "docker://50dd4e84a4a90f76ffca49a7231f6b2b97c1572cfb56880d94380c53a89d2742",
+              "name": "app"
+            },
+            {
+              "container_id": "docker://e04fdbbfa737eba69847ae1a373401300c37aadd1ec69e71c264b70f4207988d",
+              "name": "data-helper"
+            }
+          ],
+          "name": "app-07560d0f5d6ee4b0d34bd3b26604fd5346754065",
+          "pod_uid": "fcbc7bdd-059e-4ef3-a5bd-cefe4931c38d",
+          "volumes": [
+            {
+              "pv_name": "e2e-f059-production-03",
+              "pv_uid": "00a210b6-82e0-4745-8c68-35c00c877103",
+              "pvc_name": "data-07560d0f5d6ee4b0d34bd3b26604fd5346754065",
+              "pvc_uid": "55f2a8fc-a27d-4878-ada2-44cd5b167736"
+            }
+          ]
+        },
+        {
+          "containers": [
+            {
+              "container_id": "docker://66ec31a474042a32af31f10c73f001813f6dcd0acb81950a5d4edbf9491152cf",
+              "name": "app"
+            },
+            {
+              "container_id": "docker://9e6a1c3539e92eef6ef2c44b1d73795f63b583fc3a1cff2a6f8f7275bafee48c",
+              "name": "data-helper"
+            }
+          ],
+          "name": "app-1eaef29a6764649b429a9f2a90aea27e4afd7752",
+          "pod_uid": "0ad1b4c0-db79-440c-a1f7-4acfba699692",
+          "volumes": [
+            {
+              "pv_name": "e2e-f059-production-05",
+              "pv_uid": "ef38bed0-e5f1-4aea-a80b-77d02cc683e8",
+              "pvc_name": "data-1eaef29a6764649b429a9f2a90aea27e4afd7752",
+              "pvc_uid": "7d6cfde7-c6f3-455f-a443-79a5120fa685"
+            }
+          ]
+        },
+        {
+          "containers": [
+            {
+              "container_id": "docker://11229899c4a32a6c5859912845be91b6b3a4ca204d0137b5080ddbf5cfcc2551",
+              "name": "app"
+            },
+            {
+              "container_id": "docker://a7b142d3e9211a1a278b253f948c6627690dac77ff5b50030e6ea9340c2ebacf",
+              "name": "data-helper"
+            }
+          ],
+          "name": "app-6153b2cdf43a4bbef23a5d0a47e2a916ca8a0082",
+          "pod_uid": "0ca2a71e-b37d-47e0-a2eb-6d125aaa5c86",
+          "volumes": [
+            {
+              "pv_name": "e2e-f059-production-02",
+              "pv_uid": "319a6311-e463-40e0-8dc2-73be07052eb3",
+              "pvc_name": "data-6153b2cdf43a4bbef23a5d0a47e2a916ca8a0082",
+              "pvc_uid": "4c5b459c-25c6-45ce-9000-370131477d27"
+            }
+          ]
+        },
+        {
+          "containers": [
+            {
+              "container_id": "docker://a005d5e80729307494d1a09c1bd4ea6c38b78731be38472860659f80ae42bd29",
+              "name": "app"
+            },
+            {
+              "container_id": "docker://1c8ab899452fa33e7c5602cc0a3ee90843081503e255d75cc235c6d325af87d4",
+              "name": "data-helper"
+            }
+          ],
+          "name": "app-8ee02249e6a0d0ba384ce4a22a6b608837c10400",
+          "pod_uid": "2d155f36-bb1d-4e02-9aaf-fb070b999e4f",
+          "volumes": [
+            {
+              "pv_name": "e2e-f059-production-01",
+              "pv_uid": "893fe082-a5a9-4a8c-8810-e818aacaccc2",
+              "pvc_name": "data-8ee02249e6a0d0ba384ce4a22a6b608837c10400",
+              "pvc_uid": "16454f1f-6ef3-490a-a04c-000d336ed7e8"
+            }
+          ]
+        },
+        {
+          "containers": [
+            {
+              "container_id": "docker://1cc2ec55467197ffc5442fae61ccad01742e2c0272aec7f412cf4626b0e872d2",
+              "name": "app"
+            },
+            {
+              "container_id": "docker://7c5cf3792ce67d7db39a344785589edd9432adc0b27215a023dbf388c031ef77",
+              "name": "data-helper"
+            }
+          ],
+          "name": "app-cb038aa8067273280d568cb7ef797dbe91e94927",
+          "pod_uid": "f405a380-5fb9-4a87-84ca-7269175cd7a8",
+          "volumes": [
+            {
+              "pv_name": "e2e-f059-production-04",
+              "pv_uid": "76c022c8-0ddd-43d8-a3f1-97cbf4a8ff8f",
+              "pvc_name": "data-cb038aa8067273280d568cb7ef797dbe91e94927",
+              "pvc_uid": "2dba1346-f6e7-46f2-be8d-05b35290430a"
+            }
+          ]
+        }
+      ],
+      "r4_pvc_uid": "41426a1e-9713-46d5-afa5-e520afcd186b"
+    },
+    "node_ready": {
+      "schema": "f059-manager-cancel-node-ready/v1",
+      "source_revision": "2ac60f808be170e73f0094ba5c8d041f8a9e10cd",
+      "node_id": "e2e-f059-node",
+      "status": "ready",
+      "controller_epoch": 22,
+      "required_runtime_checks": 15,
+      "raw_installation_checks": 16,
+      "paths": {
+        "image": true,
+        "bundle": true,
+        "source-image": true
+      },
+      "descriptor_digest": "f306a49e2e396c60c91e76672c72dfc9989763fd5d7dcb091257009f70b8f456",
+      "r4_status": "failed",
+      "r4_cleanup_status": "pending",
+      "capacity": {
+        "memory_budget_mb": 8192,
+        "cpu_budget_millicores": 4000,
+        "committed_mb": 6784,
+        "committed_cpu": 3.125,
+        "instances": 5
+      }
+    },
+    "evidence_sha256": {
+      "before.json": "efe107e7474386be25cd6e8eedf5d1818182721529745894c3e73f4582aa6058",
+      "native-manager-result-r2.json": "02ebc584cc5edb68871a20c484ead4cdaf9a04db89cfdffdd4c88e8c17e01590",
+      "after.json": "6e7757c196f873507301b723c711b69e5e977f4e5bee2aaee00554783f62a872",
+      "private-manager-push-r2.json": "a048caffdaf0e82dc687357f65f6e6c08fedcbf26daaf8feb0eae3bd4b01409f",
+      "source-permissions-r2.json": "3f4c2b598bcd6e29a742178e1241fe2b00140cf5ef14808dbac42ddd9b09348a",
+      "receipt.json": "539386dafef972122991013f7b03c0e920be301df794dea3df09ea5136763e5b"
+    },
+    "readiness_proof_sha256": "e208f9c056f0d24413b70090d65c6f555386d22e1ba15dd1d8017642973f46eb",
+    "first_native_attempt": {
+      "outcome": "image built, nonroot import failed due private checkout0700/0600",
+      "image_id": "sha256:bd2092c988c5727e8037a4c2b02daceaa992dd6c04af827412c2b7037e561266",
+      "retained": true,
+      "product_code_changed_to_mask_failure": false
+    },
+    "scope_limits": [
+      "Native import/source-byte smoke does not prove actual owner cancellation",
+      "Physical workload identities unchanged does not prove continuous HTTP availability during upgrade",
+      "Runtime mandatory checks are15; raw installer report has16 including registry-push",
+      "No new ARM/109/public tag/DockerHub release"
+    ]
+  },
+  "protection_report_stop_blocker": {
+    "state": "blocked_no_forced_cleanup",
+    "source_sha": "2ac60f808be170e73f0094ba5c8d041f8a9e10cd",
+    "host": "192.168.106.189",
+    "journal": "/opt/e2e-f059/evidence/k8s-offline-data-k189r10-r4.json",
+    "journal_sha256": "fdbbd894bd3f7fc9d705251fc60d24713f3f690ba9adf303528ee685c43b2f9b",
+    "last_event": {
+      "name": "offline-data-stopped",
+      "acs": [
+        "AC-55"
+      ],
+      "outcome": "failed",
+      "kind": "live-api",
+      "observed_at": 1792713168.0409951,
+      "exception_type": "AssertionError"
+    },
+    "cohort": {
+      "apps": [
+        {
+          "app_id": "a4a566cacbfb477daaf2bd90ea3eb21a",
+          "exec_ref": "21bfa140-b850-5935-8e4c-f8fcec73049c",
+          "generation": 8,
+          "operation_id": "462c127e015248ab9b9cd512be8f1dc8",
+          "owner_epoch": 1,
+          "phase": "stopped",
+          "pods": [
+            {
+              "actual": {
+                "created_at": "2026-10-22T20:18:53Z",
+                "deletion_at": "2026-10-22T23:42:48Z",
+                "finalizers": [
+                  "runtime.bisheng.io/stop-confirmed"
+                ],
+                "node_name": "binfeng-k8s-node189",
+                "phase": "Failed",
+                "states": [
+                  {
+                    "name": "data-layout",
+                    "states": [
+                      "terminated"
+                    ]
+                  },
+                  {
+                    "name": "material-fetch",
+                    "states": [
+                      "terminated"
+                    ]
+                  },
+                  {
+                    "name": "environment",
+                    "states": [
+                      "terminated"
+                    ]
+                  },
+                  {
+                    "name": "app",
+                    "states": [
+                      "terminated"
+                    ]
+                  },
+                  {
+                    "name": "data-helper",
+                    "states": [
+                      "terminated"
+                    ]
+                  }
+                ],
+                "uid": "0ca2a71e-b37d-47e0-a2eb-6d125aaa5c86"
+              },
+              "binding_policy_verified": false,
+              "cleanup_state": "pending",
+              "controller_epoch": 22,
+              "generation": 7,
+              "id": null,
+              "name": "app-6153b2cdf43a4bbef23a5d0a47e2a916ca8a0082",
+              "node_name": "binfeng-k8s-node189",
+              "observed_at": null,
+              "protected_since_creation": false,
+              "purpose": "serve",
+              "stop_classification": {
+                "kind": "unknown",
+                "reason": "writer_stop_unconfirmed"
+              },
+              "stop_evidence_kind": null,
+              "stop_requested_at": "2026-10-22T23:42:48",
+              "stop_state": "requested",
+              "uid": "0ca2a71e-b37d-47e0-a2eb-6d125aaa5c86"
+            }
+          ]
+        },
+        {
+          "app_id": "4316627161aa458a950d14497d2f21e9",
+          "exec_ref": "ea70dac2-c690-5cb4-ad5b-332ab995fa8b",
+          "generation": 4,
+          "operation_id": "b2106634b34c429099d6c6a64797499f",
+          "owner_epoch": 1,
+          "phase": "serving",
+          "pods": [
+            {
+              "actual": {
+                "created_at": "2026-10-22T20:34:01Z",
+                "deletion_at": null,
+                "finalizers": [
+                  "runtime.bisheng.io/stop-confirmed"
+                ],
+                "node_name": "binfeng-k8s-node189",
+                "phase": "Running",
+                "states": [
+                  {
+                    "name": "data-layout",
+                    "states": [
+                      "terminated"
+                    ]
+                  },
+                  {
+                    "name": "material-fetch",
+                    "states": [
+                      "terminated"
+                    ]
+                  },
+                  {
+                    "name": "environment",
+                    "states": [
+                      "terminated"
+                    ]
+                  },
+                  {
+                    "name": "app",
+                    "states": [
+                      "running"
+                    ]
+                  },
+                  {
+                    "name": "data-helper",
+                    "states": [
+                      "running"
+                    ]
+                  }
+                ],
+                "uid": "2d155f36-bb1d-4e02-9aaf-fb070b999e4f"
+              },
+              "binding_policy_verified": false,
+              "cleanup_state": "pending",
+              "controller_epoch": 17,
+              "generation": 4,
+              "id": null,
+              "name": "app-8ee02249e6a0d0ba384ce4a22a6b608837c10400",
+              "node_name": "binfeng-k8s-node189",
+              "observed_at": null,
+              "protected_since_creation": false,
+              "purpose": "serve",
+              "stop_classification": {
+                "kind": "unknown",
+                "reason": "stop_protection_or_deletion_unconfirmed"
+              },
+              "stop_evidence_kind": null,
+              "stop_requested_at": null,
+              "stop_state": "held",
+              "uid": "2d155f36-bb1d-4e02-9aaf-fb070b999e4f"
+            }
+          ]
+        },
+        {
+          "app_id": "46ed080f7a6d4e24a94a2e0d6d499650",
+          "exec_ref": "49df894e-dedb-5661-959b-21d5c502ea0e",
+          "generation": 4,
+          "operation_id": "a0b07a75483c4a5b9cd8c8691c27c63c",
+          "owner_epoch": 1,
+          "phase": "serving",
+          "pods": [
+            {
+              "actual": {
+                "created_at": "2026-10-22T21:06:24Z",
+                "deletion_at": null,
+                "finalizers": [
+                  "runtime.bisheng.io/stop-confirmed"
+                ],
+                "node_name": "binfeng-k8s-node189",
+                "phase": "Running",
+                "states": [
+                  {
+                    "name": "data-layout",
+                    "states": [
+                      "terminated"
+                    ]
+                  },
+                  {
+                    "name": "material-fetch",
+                    "states": [
+                      "terminated"
+                    ]
+                  },
+                  {
+                    "name": "environment",
+                    "states": [
+                      "terminated"
+                    ]
+                  },
+                  {
+                    "name": "app",
+                    "states": [
+                      "running"
+                    ]
+                  },
+                  {
+                    "name": "data-helper",
+                    "states": [
+                      "running"
+                    ]
+                  }
+                ],
+                "uid": "f405a380-5fb9-4a87-84ca-7269175cd7a8"
+              },
+              "binding_policy_verified": false,
+              "cleanup_state": "pending",
+              "controller_epoch": 18,
+              "generation": 4,
+              "id": null,
+              "name": "app-cb038aa8067273280d568cb7ef797dbe91e94927",
+              "node_name": "binfeng-k8s-node189",
+              "observed_at": null,
+              "protected_since_creation": false,
+              "purpose": "serve",
+              "stop_classification": {
+                "kind": "unknown",
+                "reason": "stop_protection_or_deletion_unconfirmed"
+              },
+              "stop_evidence_kind": null,
+              "stop_requested_at": null,
+              "stop_state": "held",
+              "uid": "f405a380-5fb9-4a87-84ca-7269175cd7a8"
+            }
+          ]
+        },
+        {
+          "app_id": "fad0b104e09c46cda59469248ab20605",
+          "exec_ref": "570e9beb-11d6-5fcf-820b-fdbad8ebb48a",
+          "generation": 4,
+          "operation_id": "88cc9c263af640df9e852e2c74cc2165",
+          "owner_epoch": 1,
+          "phase": "serving",
+          "pods": [
+            {
+              "actual": {
+                "created_at": "2026-10-22T22:44:11Z",
+                "deletion_at": null,
+                "finalizers": [
+                  "runtime.bisheng.io/stop-confirmed"
+                ],
+                "node_name": "binfeng-k8s-node189",
+                "phase": "Running",
+                "states": [
+                  {
+                    "name": "data-layout",
+                    "states": [
+                      "terminated"
+                    ]
+                  },
+                  {
+                    "name": "app",
+                    "states": [
+                      "running"
+                    ]
+                  },
+                  {
+                    "name": "data-helper",
+                    "states": [
+                      "running"
+                    ]
+                  }
+                ],
+                "uid": "0ad1b4c0-db79-440c-a1f7-4acfba699692"
+              },
+              "binding_policy_verified": true,
+              "cleanup_state": "pending",
+              "controller_epoch": 21,
+              "generation": 4,
+              "id": null,
+              "name": "app-1eaef29a6764649b429a9f2a90aea27e4afd7752",
+              "node_name": "binfeng-k8s-node189",
+              "observed_at": null,
+              "protected_since_creation": true,
+              "purpose": "serve",
+              "stop_classification": {
+                "kind": "unknown",
+                "reason": "stop_protection_or_deletion_unconfirmed"
+              },
+              "stop_evidence_kind": null,
+              "stop_requested_at": null,
+              "stop_state": "held",
+              "uid": "0ad1b4c0-db79-440c-a1f7-4acfba699692"
+            }
+          ]
+        },
+        {
+          "app_id": "ec6229a98d434d7cb58f740b58945ff6",
+          "exec_ref": "4c549cd5-6a27-595d-b58f-622938ea70a7",
+          "generation": 4,
+          "operation_id": "68cc365b61d84c6585b7f9db897ea5c0",
+          "owner_epoch": 1,
+          "phase": "serving",
+          "pods": [
+            {
+              "actual": {
+                "created_at": "2026-10-22T23:26:23Z",
+                "deletion_at": null,
+                "finalizers": [
+                  "runtime.bisheng.io/stop-confirmed"
+                ],
+                "node_name": "binfeng-k8s-node189",
+                "phase": "Running",
+                "states": [
+                  {
+                    "name": "data-layout",
+                    "states": [
+                      "terminated"
+                    ]
+                  },
+                  {
+                    "name": "app",
+                    "states": [
+                      "running"
+                    ]
+                  },
+                  {
+                    "name": "data-helper",
+                    "states": [
+                      "running"
+                    ]
+                  }
+                ],
+                "uid": "fcbc7bdd-059e-4ef3-a5bd-cefe4931c38d"
+              },
+              "binding_policy_verified": true,
+              "cleanup_state": "pending",
+              "controller_epoch": 21,
+              "generation": 4,
+              "id": null,
+              "name": "app-07560d0f5d6ee4b0d34bd3b26604fd5346754065",
+              "node_name": "binfeng-k8s-node189",
+              "observed_at": null,
+              "protected_since_creation": true,
+              "purpose": "serve",
+              "stop_classification": {
+                "kind": "unknown",
+                "reason": "stop_protection_or_deletion_unconfirmed"
+              },
+              "stop_evidence_kind": null,
+              "stop_requested_at": null,
+              "stop_state": "held",
+              "uid": "fcbc7bdd-059e-4ef3-a5bd-cefe4931c38d"
+            }
+          ]
+        }
+      ],
+      "node": {
+        "controller_epoch": 22,
+        "heartbeat_at": null,
+        "node_id": "e2e-f059-node",
+        "protection": {
+          "binding_protection": true,
+          "mandatory_isolation": true,
+          "status_protection": true
+        },
+        "status": "ready"
+      },
+      "observed_at": 1792713374.9202218,
+      "stop_operation": {
+        "cleanup_status": "pending",
+        "deadline": "2026-10-22T23:52:48",
+        "failure": {
+          "data_may_have_changed": false,
+          "diagnostic_tail": "",
+          "failed_stage": "queued",
+          "maintenance": false,
+          "reason": "operation_timeout"
+        },
+        "generation": 8,
+        "kind": "stop",
+        "operation_id": "d675d583ebb34e68b4756fe8852ff91d",
+        "stage": "timed_out",
+        "status": "timed_out"
+      }
+    },
+    "cohort_sha256": "bfbd58e822d70fac0528232b8a715ea68e4f5ce522f3dbb09e0a271dd40bdd23",
+    "specific_trigger_heartbeat_identified": false,
+    "old_false_repaired": false,
+    "local_mechanism_tests": 5,
+    "local_review_sha256": "47a133dd7dd5649c6145694be019a83d370981316f4b90a54376a13bde54bdc2",
+    "boundary": "Old three false/new two true. Failed+terminated diagnostics do not restore provenance. Existing UID/finalizer/PVC/reservation retained; T258-T261 are future implementation correction, not historical repair."
+  },
+  "historical_top_not_executed_before_r11": [
+    "189 cancel/init fault and unknown writer",
+    "manager restart or response loss in flight",
+    "migration failure and protected snapshot restoration",
+    "runtime upgrade/uninstall retention",
+    "NodeLost or cross-node fencing"
+  ],
+  "r29_upgrade_retention_reference": "installation.json#protection_report_r29_deployment",
+  "r11_normal_stop_resume_reference": "platform.json#r11_single_v1_protection_acceptance",
+  "manager_outage_r11_r3": {
+    "state": "passed_real_189_amd64",
+    "source_commit": "f8863f3763285435a5df01fc150400838bdb3f0a",
+    "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:0b4bd3e66c1d7c49fe00046c83277dab8b6dbd0628f201abf3192d70dd67ddad",
+    "old_manager_uid": "fae606c4-3e51-4efa-ba00-a7ce9163de3f",
+    "new_manager_uid": "59ace601-5974-487f-8eaf-095a6b13f015",
+    "controller_epoch": 25,
+    "down_seconds": 15.931488990783691,
+    "qualified_rounds_after_cache": 5,
+    "original_total_budget_seconds": 120,
+    "recovery_used": false,
+    "http_owner_new_requests": "passed",
+    "sdk_identity_obo": "passed; not remote model/retrieval/attachment proof",
+    "persistent_websocket": "same connection echo passed",
+    "new_owner_websocket": "passed",
+    "nonowner_foreign_http_ws": "all exact refusals passed",
+    "connect": "same stream/socket/fd/TLS object passed before/during/after",
+    "long_http_response_stream": "not tested",
+    "inventory": {
+      "pods": 6,
+      "pvcs": 7,
+      "same_uid_cid_volume_projection": true
+    },
+    "old_history": "old three false and newer two true retained; old Python held row reattested controller_epoch24->25 without changing flags/stop state; not repaired or released",
+    "independent_post_protection": "current/v1/status=true/binding=true; source/backend bytes/descriptor/namespace verified",
+    "evidence": {
+      "k8s-manager-outage-k189r11-r3-orchestrated-r29.jsonl": {
+        "local": "/private/tmp/f059-r11-outage-r3-live-evidence/k8s-manager-outage-k189r11-r3-orchestrated-r29.jsonl",
+        "remote": "/opt/e2e-f059/evidence/k8s-manager-outage-k189r11-r3-orchestrated-r29.jsonl",
+        "sha256": "b397e367c68e363604f68e8ae7ca257d4f9c410775fa84a994b8fdd39409724c"
+      },
+      "k8s-manager-outage-k189r11-r3-restored.json": {
+        "local": "/private/tmp/f059-r11-outage-r3-live-evidence/k8s-manager-outage-k189r11-r3-restored.json",
+        "remote": "/opt/e2e-f059/evidence/k8s-manager-outage-k189r11-r3-restored.json",
+        "sha256": "850d35de909bd48d4a7358b560e363a8c5943426c750d1dc9bc1c19c726e89ab"
+      },
+      "r11-outage-r3-after-inventory.json": {
+        "local": "/private/tmp/f059-r11-outage-r3-live-evidence/r11-outage-r3-after-inventory.json",
+        "remote": "/opt/e2e-f059/evidence/r11-outage-r3-after-inventory.json",
+        "sha256": "8184a040651be3693328de4bb55a22b2b1cf69bb23dc85157b75820fad23c29a"
+      },
+      "k8s-manager-outage-k189r11-r3-orchestrated-r29.jsonl.control.jsonl": {
+        "local": "/private/tmp/f059-r11-outage-r3-live-evidence/k8s-manager-outage-k189r11-r3-orchestrated-r29.jsonl.control.jsonl",
+        "remote": "/opt/e2e-f059/evidence/k8s-manager-outage-k189r11-r3-orchestrated-r29.jsonl.control.jsonl",
+        "sha256": "226a8464d8d9bc34aff653831d40ba57502e61ee5f54ecfc47456aeab42eb4a6"
+      },
+      "r29-post-outage-r11-r3-deployment-ready.json": {
+        "local": "/private/tmp/f059-r11-outage-r3-live-evidence/r29-post-outage-r11-r3-deployment-ready.json",
+        "remote": "/opt/e2e-f059/evidence/r29-post-outage-r11-r3-deployment-ready.json",
+        "sha256": "3825ca3769010269b14781086b2de1a6f03c3e37fa8a639035c5b70cc2f6251b"
+      },
+      "k8s-manager-outage-k189r11-r3.jsonl": {
+        "local": "/private/tmp/f059-r11-outage-r3-live-evidence/k8s-manager-outage-k189r11-r3.jsonl",
+        "remote": "/opt/e2e-f059/evidence/k8s-manager-outage-k189r11-r3.jsonl",
+        "sha256": "b7810f9e0e7f23dec84b1b95f24f86629af47ee70ccc985800783354819b2773"
+      }
+    },
+    "tools": {
+      "package_sha256": "58619c767f0477cf9c233b72d170343b942e983eba262126d70892b3c7afc445",
+      "post_collector_sha256": "f7007b5447bb4f3d7a8ad692b83ad73e1673e3268c8da4a642b362742c8b303e",
+      "post_inventory_collector_sha256": "6f7e4f30f8b35d05a67451bdd72231455f24333d1c1135961cb77f587c6b9fe4",
+      "independent_local_tests": 67
+    },
+    "prior_r2_failure": {
+      "reason": "r11_preflight_failed: root bare URL308 while canonical slash200",
+      "down_attempted": false,
+      "retained": true,
+      "diagnostic_bare": 308,
+      "diagnostic_slash": 200,
+      "diagnostic_sdk": 200,
+      "diagnostic_ws_echo": true
+    },
+    "boundary": "manager-only idle outage and access continuity; not in-flight operation replay, application crash recovery, cross-node fencing or full T070 acceptance"
+  },
+  "controlled_stops_for_migration": {
+    "state": "business_completed_physical_proof_pending",
+    "purpose": "free two temporary acceptance slots using only healthy dedicated apps",
+    "r11": {
+      "app_id": "43baa81d168a45bfa5e53ffbd04f48fd",
+      "version_id": "a5a4faade83743a487803bd6080246bf",
+      "generation": 4,
+      "stop_operation_id": "8d71c051adf14478806cb7feb19e1dca",
+      "owner_delete_online": 16104,
+      "nonowner_delete": 16105,
+      "foreign_delete": 16101,
+      "denial_sql_seed_sha256": "f3a8b7c02c7fa2958128ad02b0cfbf5a45216dbc2e30915279311c6368a67a74",
+      "denials_root_proof_sha256": "c723fc4260246d0e778210d4a89a183560910811e4a1e794b0cc2dcfc92d9c68",
+      "journal_remote": "/opt/e2e-f059/evidence/r11-safe-delete-r1-stages.jsonl",
+      "journal_sha256_at_stop_complete": "ce139f1d3f095940dbf9bf238ad0fb7d4304a82719e4ccde4ab86c2202268401",
+      "baseline_sha256": "29380b67b735bcf49c8a5a963fa20af645609da2d801ec55cca5a7c6b49fdc60",
+      "storage_manifest_sha256": "1f96333e47c8454059f65d190d30780689f6cf3a561f1408c8064be4e5097e4c",
+      "snapshot_samples": 0,
+      "attachment_object_samples": 0,
+      "pv_pre_stop": "e2e-f059-production-06",
+      "pv_uid_pre_stop": "3959377d-57ff-4f53-8536-7578e3af13fe"
+    },
+    "fullimage": {
+      "app_id": "ec6229a98d434d7cb58f740b58945ff6",
+      "version_id": "77d42f56f77f4287b63574b9fd81b0ab",
+      "generation": 5,
+      "stop_operation_id": "dc0317f0220246b7b0adbc83cfd292ba",
+      "pre_stop_serve_uid": "fcbc7bdd-059e-4ef3-a5bd-cefe4931c38d",
+      "pre_stop_protection": [
+        true,
+        true
+      ],
+      "journal_remote": "/opt/e2e-f059/evidence/fullimage-single-stop-r1.jsonl",
+      "journal_sha256_at_stop_complete": "ab985cc7097bb960946cffbe32d1a3c503b82713243b318ad90ebc764e978d84"
+    },
+    "boundary": "one normal stop per app; no delete/data/restore; original parents retained; old false/unknown apps untouched; no capacity release claimed without independent exact-UID physical and reservation proof"
+  },
+  "m2_material_preparation": {
+    "state": "prepared_not_published",
+    "slug": "e2e-f059-migrate-k189m2",
+    "manifest_sha256": "b7660cd97e537d49cf40018c22ad1934648aae7e4662f2c2c72cf4f16b9232eb",
+    "freeze_sha256": "d01c708557c96bd9ab90e035e3e2fcfa3cfe0088b0383aafaed19769ef228af5",
+    "v1": {
+      "sha256": "09807c39b6ada07f11bc1ba4867edcc2c2a0cb96b6a39452145415ca932f6fb4",
+      "bytes": 1235
+    },
+    "v2": {
+      "sha256": "49d450226b8e5bc9766ad0c4366882e347cd69609a8839209c0fa8c49311ed8d",
+      "bytes": 1237
+    },
+    "tools_tar_sha256": "ed9411c81aa3bf4014c3995c66529fbf1bc854384707b1d90445a10121b37663",
+    "readers": "new public failure operation reverse linkage and exact v1/v2 preview cleanup gates reviewed; no DB backfill",
+    "ddl_observer": "first candidate e77 rejected before execution: actual kubectl1.28.15 has no resource-version flag; replacement pending; no migration attempted"
+  },
+  "actual_migration_and_first_restore_189": {
+    "state": "migration_failure_verified_restore_startup_failed",
+    "app_id": "3d76a877d2b24f13bb2d3498d926efd3",
+    "failed_deploy_operation_id": "8aba1b5c7a37425393a7e9d5e6ff259c",
+    "ddl_container_id": "01554c7e7e7116f610d1785bfe27152e3fcc318d3c4c62ac07d2faa97e561c4e",
+    "ddl_failure_proof_sha256": "e80a1f989b1ca85c40f02c83186584f8fc471111d3164c2d15883b21bd3ad2bb",
+    "snapshot_id": "dbae5da6ab5e42328db992dd39ddde5f",
+    "snapshot_sha256": "eaf3653e07e30d65b89436bf09dda2fc2794a6ab91a8f5a71ed7af9d6f1dd03b",
+    "snapshot_size_bytes": 16384,
+    "snapshot_baseline_rows_verified_before_restore": true,
+    "first_restore_operation_id": "4d858a521f714fc598c565e10e9dc19c",
+    "first_restore_generation": 3,
+    "first_restore_status": "failed",
+    "first_restore_cleanup": "complete",
+    "first_restore_failure_stage": "starting",
+    "first_restore_helper_completed_and_stopped": true,
+    "restored_serving_rows_and_route_verified": false,
+    "failure_evidence_sha256": "016b125aa42fd5f4dbd2e718075629b2b683ffe09d977dca02bb82f5d40a743c",
+    "old_request_replayed": false,
+    "failed_operation_relabelled": false,
+    "private_evidence_root": "/opt/e2e-f059/evidence/e2e-f059-migration-k189m2-accepted-r30"
+  },
+  "development_closeout_20261023": {
+    "tasks_complete": 265,
+    "tasks_total": 269,
+    "backend_source_commit": "38a7002335bdf5c7c3aa4e13e93d32d6d8150c81",
+    "backend_service_sha256": "c06fb408033f4d77c767924908209e92fd06650d7275bac2cb6cba15442327ce",
+    "backend_app_runtime_tests": {
+      "passed": 1174,
+      "skipped": 19,
+      "log_sha256": "79892a3c7b3f97cf8942ce283eddb11b9ac70217a4753aebb7e51404594b228e"
+    },
+    "independent_paired_and_neighbor_tests_passed": 170,
+    "second_independent_contract_tests_passed": 45,
+    "manager_source_commit": "6c9694f0ffb6adf4722b9fa711851172b3efdc64",
+    "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:981bf135375a8c8728735fa13b04ee9b5c8e096c25210a72f44fdd6be003becf",
+    "native_platform": "linux/amd64",
+    "native_nonroot_import_uid": 10001,
+    "manager_deployed_verified": true,
+    "public_release": false,
+    "arm": "deferred_by_user",
+    "l2": "pending"
+  },
+  "actual_migration_restore_189_gen4": {
+    "state": "passed",
+    "actual_execution": true,
+    "app_id": "3d76a877d2b24f13bb2d3498d926efd3",
+    "previous_failed_restore_operation_id": "4d858a521f714fc598c565e10e9dc19c",
+    "operation_id": "6cd6f833513f41f6980c2fc4e66688da",
+    "generation": 4,
+    "status": "completed",
+    "cleanup_status": "complete",
+    "owner_single_restore_post": true,
+    "denied_nonowner_and_foreign_code": 16105,
+    "current_version_id": "385c7cf5116b4f55bae10a85853926f8",
+    "pending_version_id": null,
+    "failed_v2_and_approval_14_preserved": true,
+    "baseline_rows_sha256": "14608a29de2527a501fa0f18362fee2272ce18bd093a5d83274adad203ab1f4e",
+    "null_and_nonce_rows_verified": true,
+    "entry_route_version": "1",
+    "protection_snapshot_id": "dbae5da6ab5e42328db992dd39ddde5f",
+    "protection_snapshot_sha256": "eaf3653e07e30d65b89436bf09dda2fc2794a6ab91a8f5a71ed7af9d6f1dd03b",
+    "protection_snapshot_size_bytes": 16384,
+    "same_restore_confirmed_upload_id": "db502ccf-34f9-4772-93ee-5a8a1edfe5dc",
+    "snapshot_deduplicated": true,
+    "original_producer_operation_id_retained": "8aba1b5c7a37425393a7e9d5e6ff259c",
+    "backend_export_sqlite_integrity": "ok",
+    "one_current_pod": true,
+    "restore_helper_uid": "adfd7678-76ae-431c-8f5d-e228c1f7e46f",
+    "restore_helper_stopped_and_cleaned": true,
+    "serving_pod_uid": "bfd5c5b3-aa3d-4b28-9ef8-184ecdb8781f",
+    "stable_pvc_uid": "8e9e3c90-7c93-4a3a-94ed-422054b30e67",
+    "persistent_volume_identity_preserved": true,
+    "prior_parent_and_failed_restore_unmodified": true,
+    "automatic_rollback": false,
+    "automatic_cleanup": false,
+    "evidence_sha256": {
+      "journal": "4191fe7b24825d0002136d641fffa63926b70f48a4fb520bdfdc6a882a9484f5",
+      "protected_snapshot_binding": "2e46911291eda8609a5d6dcb14058813e3bfbfc47ee5b80305229e52ade07f02",
+      "snapshot_export_receipt": "1a5029519e928a4c1876c1ea8534566eb0fb23a24d8ea1f76cdb368c25ffaf33",
+      "operation_sql": "6a2aa7de6f68c05dca8fe575310b6aaeccd596fec24f8e5c6c2276feba17e5d2",
+      "physical": "733863da8f6bdb2e0c2ca7eb1eda8852e43e3e6805be682c6c0d6b2dbd10c07c",
+      "independent_review": "52e7750627fa602226800ec4b44968ed5416b0f61784dafb6d195ad2c35f3f1a"
+    },
+    "private_evidence_root": "/opt/e2e-f059/evidence/e2e-f059-migration-k189m2-restore-second-r2",
+    "tool_only_failed_attempt": {
+      "reason": "v2_deployment_get_refused",
+      "new_restore_intent_recorded": false,
+      "journal_equal_to_parent": true,
+      "production_code_changed": false,
+      "resolution": "use existing owner-cookie publish-status read model; new journal preserves failed check"
+    }
+  },
+  "actual_cancel_189_r31": {
+    "host": "192.168.106.189",
+    "state": "PASS_subitem_only",
+    "manager_source_commit": "6c9694f0ffb6adf4722b9fa711851172b3efdc64",
+    "manager_digest": "sha256:981bf135375a8c8728735fa13b04ee9b5c8e096c25210a72f44fdd6be003becf",
+    "accepted": {
+      "app_id": "09672c6de8bb4b24b06f265836c00e04",
+      "deployment_id": "ad7c654e465444fabb7508360e7a1b41",
+      "version_id": "f317d0d0ff834b139480e4b4b3297689"
+    },
+    "fixture_id": "bf6c774b4afe452abb4e016e94975eaa",
+    "operation": {
+      "app_id": "09672c6de8bb4b24b06f265836c00e04",
+      "cleanup_status": "complete",
+      "deployment_id": "ad7c654e465444fabb7508360e7a1b41",
+      "failure": {
+        "failed_stage": "preparing",
+        "reason": "operation_cancelled"
+      },
+      "generation": 1,
+      "id": "c29cfb5329ed413891dd59c00eb42bde",
+      "kind": "prepare",
+      "owner_epoch": 1,
+      "stage": "cancelled",
+      "status": "cancelled"
+    },
+    "hook": {
+      "app_id": "09672c6de8bb4b24b06f265836c00e04",
+      "container_id": "caaed421f81cb7ba8294d2f6f98bc865c8ce68beb2256a5bcca3f42a76b56d35",
+      "container_running": true,
+      "elapsed_seconds": 54.84896492958069,
+      "fixture_id": "bf6c774b4afe452abb4e016e94975eaa",
+      "hook_end_seen": false,
+      "hook_started_at": "2026-10-23T06:10:02.430113233Z",
+      "hook_wait_events": 10,
+      "namespace": "e2e-f059-apps",
+      "observed_at": 1792735856.90735,
+      "operation_id": "c29cfb5329ed413891dd59c00eb42bde",
+      "pod_identity_rechecked": true,
+      "pod_name": "task-afbd56e1bffacd21394e485fef01411a1d7a9702",
+      "pod_uid": "ba740781-ede3-48a9-8d3a-7c1f30b6d2a1",
+      "resource_ref": "1a1974b8-d1fc-58cc-a6ce-46fff42e5a0b",
+      "schema": "f059-cancel-hook-running/v1",
+      "scratch_pvc": [
+        "scratch-28ddf458c216281ae7ac37febbbaa0d802bf524b"
+      ],
+      "secret_volume": false,
+      "service_account_token": false
+    },
+    "permission_denial": {
+      "nonowner_get_cancel": 16105,
+      "foreign_get_cancel": 16105
+    },
+    "formal_versions": 0,
+    "natural_cleanup": {
+      "resources": 6,
+      "pods_same_uid_kubelet_terminal": 3,
+      "all_api_absent": true,
+      "reservation_released": true,
+      "force_cleanup": false,
+      "original_four_apps_unchanged": true,
+      "restored_m2_pod_uid_unchanged": true
+    },
+    "submit_intent_to_public_cancel_complete_seconds": 165.71845626831055,
+    "evidence_sha256": {
+      "cancel-k189m2-r2-cleanup.json": "8e848a7239ab3284b094d8a29101720aed83194ff5a3cecca5e9af6e491fc0a6",
+      "cancel-k189m2-r2-physical.json": "a474460d793b59e6368ca1dc47d9d81c530f3ff2c5f15a1f995123552154f0df",
+      "fault-cancel-k189m2-r2.json": "43516c2a8e85fc2a2b0b532cfc29bbdd7a5180ade3fdc1470d70a0e911c1e152",
+      "prepare-hook-k189m2-r2.json": "258f2879f6c41557dd92ceb51ade855848337457e0763cd0655d64ab7b5912ad",
+      "prepare-install-k189m2-r2.json": "9a156c8b16fd7650f614daa2b5efdfceea201fc3b6fde26483eeb844f900f9e6"
+    },
+    "private_root": "/opt/e2e-f059/evidence",
+    "independent_l1": {
+      "path": "/private/tmp/f059-t070-cancel-k189m2-r2-independent-l1.md",
+      "sha256": "77e4d8741023e1d05b10e7f93b8980971589b90eb7cde939b6b6f29701bfa3c4",
+      "result": "PASS"
+    },
+    "boundary": "Actual prepare cancellation only; separate action invocations have no persisted global deadline; later SQL/physical collection not claimed inside 300s. Draft retained with zero formal versions. No in-flight SIGKILL, init failure, response loss, unknown-writer or uninstall proof from this run."
+  },
+  "inflight_manager_sigkill_189_r31_first_failure": {
+    "state": "NOT_PASSED",
+    "host": "192.168.106.189",
+    "manager_source_commit": "6c9694f0ffb6adf4722b9fa711851172b3efdc64",
+    "scope": {
+      "app_id": "09672c6de8bb4b24b06f265836c00e04",
+      "deadline": "2026-10-23T06:46:14",
+      "deployment_id": "f43bb953925a4e948c8593b5b3421967",
+      "install": {
+        "cid": "f7a455dc643252d2e662643efed33c6b3f407cd9c3e65ed3ecc78dc831398db1",
+        "delay_seconds": 240,
+        "fixture_id": "bf6c774b4afe452abb4e016e94975eaa",
+        "hook_begin_at": 1792737445.7128332,
+        "latest_wait_at": 1792737450.718741,
+        "name": "task-1745dc6e05adc4a65080bcfdd6df0cf40613596e",
+        "namespace": "e2e-f059-apps",
+        "resource_ref": "e76bdecf-b594-5427-8406-f882089a61bb",
+        "uid": "142037c5-5f4d-42d5-88ff-10b52d34fad3"
+      },
+      "manager": {
+        "cid": "bc78c9bf3a57927fecc8ab60a41df390b1ea3ea479592567b9a2ce04517993b8",
+        "name": "runtime-manager-854fc7b4c6-w88qg",
+        "restart_count": 0,
+        "uid": "56193f9b-0595-404f-a54a-0d62f0adf820"
+      },
+      "observed_at": 1792737450.908546,
+      "operation_id": "c0fe7a9a68d54ddc90995d2d311a43c3",
+      "schema": "f059-prepare-sigkill-hook/v1",
+      "tenant_id": 2
+    },
+    "kill_cli_acknowledged": true,
+    "observer_result": {
+      "automatic_retry": false,
+      "kill_cli_acknowledged": true,
+      "observed_at": 1792737570.948424,
+      "platform_get_may_prune_expired_diagnostics": true,
+      "read_failures": 71,
+      "read_only_operator_requests_after_kill": true,
+      "state": "unknown"
+    },
+    "operation": {
+      "app_id": "09672c6de8bb4b24b06f265836c00e04",
+      "cleanup_status": "complete",
+      "deadline": "2026-10-23 06:46:14",
+      "deployment_id": "f43bb953925a4e948c8593b5b3421967",
+      "failure": {
+        "data_may_have_changed": false,
+        "diagnostic_tail": "",
+        "failed_stage": "preparing",
+        "maintenance": false,
+        "reason": "execution_failed"
+      },
+      "generation": 1,
+      "id": "c0fe7a9a68d54ddc90995d2d311a43c3",
+      "kind": "prepare",
+      "owner_epoch": 1,
+      "stage": "failed",
+      "status": "failed"
+    },
+    "node": {
+      "controller_epoch": 29,
+      "controller_holder": "f349511c-a250-4450-8a34-9a3ff8ca8571",
+      "id": "e2e-f059-node",
+      "lease_until": "2026-10-23 06:44:18",
+      "status": "ready"
+    },
+    "phase_progress_before_failure": [
+      "inputs",
+      "resolve"
+    ],
+    "formal_versions": 0,
+    "natural_cleanup": {
+      "resources_absent": 6,
+      "same_running_hook_uid_stopped": true,
+      "reservation_released": true,
+      "unrelated_original_apps_and_m2_unchanged": true,
+      "force_cleanup": false
+    },
+    "evidence_sha256": {
+      "fault-cancel-k189m2-r3.json": "8e0e388f99a973d329b86425fd087dfd66bd020cede277bca659635554547460",
+      "manager-prepare-sigkill-k189m2-r3.json": "3526c02e717346687044e2eb31edac9a4291339124eb222404dec6b9a845de4d",
+      "manager-prepare-sigkill-k189m2-r3.json.result.json": "7771bc99603977065c2d3bab1a78b389e7a05b0b045bc1c59b9e21172f577955",
+      "prepare-hook-k189m2-r3.json": "c1f24c920f92f5a925f66ad086cb1a939954b59ca868316135b9e82302b383c6",
+      "prepare-restart-r3-controller-log-count.json": "61a133153fb4bdc9dd0d927ab5528d50779b09d55bf0c0a39e2469a774c47d6f",
+      "prepare-restart-r3-physical.json": "13dc2c3644b040d3a307c8a729fe8180dadf1ffdbc01e75cebc461b97353244f",
+      "prepare-restart-r4-read.json": "27b5cb5e73fb0df77976ace80a963e30ed31f3e197a72aa0df14db141053b8b1"
+    },
+    "independent_review": {
+      "path": "/private/tmp/f059-prepare-sigkill-r3-independent-evidence.md",
+      "sha256": "5c8ec11f317a85756e9bcaaa8043a9baef867e812d5f499d79091c2c4eb8ca7a",
+      "result": "NOT_PASSED"
+    },
+    "mechanism": {
+      "manager_local_probe_sha256": "b4baf0933dd556b1eed1d669ded454d927d134228b6c0f79c96e2c2008e8e2b3",
+      "backend_probe_report_sha256": "6d2ab4e50d3b029763693b82df038d56e1dc0d0632b80bd148632b0d05605fe8",
+      "findings": "Real executors reproduce lease-held rejection entering business failure cleanup across lease expiry. Backend lease refusal leaves operation unchanged. Actual window has 10 lease-held matches; first in-process exception was not logged and is not independently asserted from count alone. T272/T273 tracks startup authority gate."
+    },
+    "boundary": "One precise kill only. Same Pod/container restart and epoch29 do not prove preparation recovery; observer caught state errors, not 71 verified HTTP failures. No owner cancel submitted after failed binding precheck; original failed operation is retained."
+  },
+  "inflight_manager_sigkill_189_r32_lease_handoff": {
+    "state": "PASS_targeted_lease_handoff_only",
+    "source_commit": "0f2db1f9a29213ca94d4596e64b00ad8fcbac528",
+    "image": "192.168.106.189:5443/f059/runtime-manager@sha256:bc488d16d3b75ccd30e729d7ef79b0c4591abaa40c0037057697f405d9e4900b",
+    "manager_config_id": "sha256:86230f7a09ee50970c1667710b0b03c8768b92f56697c833e1ad3fcf6bcf4396",
+    "helm_revision": 32,
+    "app_id": "09672c6de8bb4b24b06f265836c00e04",
+    "deployment_id": "b58853b2b5fa44548b2b98ef7d5ad065",
+    "prepare_operation_id": "fe9a00424af44176811354d01be141f0",
+    "original_deadline": "2026-10-23T07:22:07",
+    "manager_pod_uid": "2919607c-bf80-428c-bcd6-a31779803ce0",
+    "manager_same_pod_restart_count": [
+      0,
+      1
+    ],
+    "controller_epochs": [
+      30,
+      31
+    ],
+    "single_kill_cli_acknowledged": true,
+    "kill_to_recovery_seconds": 25.76693606376648,
+    "same_install_pod_uid": "6959dd58-beca-4153-af70-7c7794f014f7",
+    "same_install_cid": "873910bdd56f15d78c8ff30c44593ba6fec1c1d6cd8fe1fe2260595f74e04ea2",
+    "natural_hook_events": [
+      {
+        "kind": "BEGIN",
+        "timestamp": "2026-10-23T07:13:15.897148927Z"
+      },
+      {
+        "kind": "END",
+        "timestamp": "2026-10-23T07:17:15.903334995Z"
+      }
+    ],
+    "natural_hook_elapsed_seconds": 240.006186,
+    "owner_cancel_submitted": false,
+    "original_input_and_resolve_result_hashes_preserved": true,
+    "prepare_terminal": {
+      "app_id": "09672c6de8bb4b24b06f265836c00e04",
+      "cleanup_status": "complete",
+      "deadline": "2026-10-23 07:22:07",
+      "deployment_id": "b58853b2b5fa44548b2b98ef7d5ad065",
+      "failure": {},
+      "generation": 1,
+      "id": "fe9a00424af44176811354d01be141f0",
+      "kind": "prepare",
+      "owner_epoch": 1,
+      "stage": "completed",
+      "status": "completed"
+    },
+    "prepare_resource_count": 8,
+    "prepare_resources_naturally_absent": 8,
+    "same_uid_stopped_before_absence": true,
+    "reservation_released": true,
+    "followup_probe": {
+      "cleanup_status": "complete",
+      "deadline": "2026-10-23 07:27:49",
+      "id": "87225ca3770a4f1cb6aa4f2e7b2bb57f",
+      "kind": "probe",
+      "stage": "completed",
+      "status": "completed"
+    },
+    "formal_versions": 1,
+    "unrelated_original_application_cohort_unchanged": true,
+    "force_cleanup": false,
+    "lease_handoff_window": {
+      "counts": {
+        "/controllers/claim": 21,
+        "/controllers/renew": 3,
+        "controller_epoch_conflict": 0,
+        "controller_lease_held": 6,
+        "controller_lease_lost": 0,
+        "resource_intent_conflict": 0
+      },
+      "schema": "f059-controller-restart-log-count/v1",
+      "scope": "backend logs 1792739603..1792739631, only fixed match counts; absence does not prove absence of an error"
+    },
+    "independent_review": {
+      "status": "PASS_targeted_lease_handoff_only",
+      "path": "/private/tmp/f059-prepare-sigkill-r4-independent-evidence.md",
+      "sha256": "a89f59ae73a2b54a91b550b3497bcf467c1d46609e2a65dec912571c304202a2"
+    },
+    "evidence_sha256": {
+      "fault-restart-k189m2-r4.json": "7c1769650cc3e13d77719d7b285bd52168f297fb000c7ed6a1b23ffc5d7ebc41",
+      "manager-prepare-sigkill-k189m2-r4.json": "0b8eab44036619265e778225fe58d57d749558220543d18878ada2220e83e21f",
+      "manager-prepare-sigkill-k189m2-r4.json.result.json": "04cd97d5e7f457fb3f3fcfab7faa39f591af0c98bd3c3ffe46dc4a6bd7c31cbd",
+      "prepare-hook-complete-k189m2-r4.json": "1a0d744ec0df1c3adb0eac53d9f9b700ec9c6f42bc4130f5c07e67133f28d8f0",
+      "prepare-hook-k189m2-r4.json": "7df3f6699e94d0be6caf744f914eab774f77a8c9abc0a5b56fdc314f2f6de33b",
+      "prepare-install-k189m2-r4.json": "4248afa05457ab324d6c15b792cdf3b359120811b7d1a6b87de00d0afa2d6867",
+      "prepare-restart-r4-after-recovery-sql.json": "ceb0a9dbefc883dbcc8367db98366bf5f9fd2d58bfb8ccb4a5de69676122807d",
+      "prepare-restart-r4-comparison.json": "14730c2e2c4b0f2f36fe559e24e25618cdaf5e4724834a147744a0e60b74d50c",
+      "prepare-restart-r4-controller-log-count.json": "b51400390b4d16a32aaeaa2b947a88c48ac20969c9dc5c15a2505cf76327ce40",
+      "prepare-restart-r4-final-sql.json": "1e24506e6054a0da0b404cf8e91df30235df7e44ee684d6ce591a7d3df5ab4e1",
+      "prepare-restart-r4-physical.json": "acbef8749a5e3ed7c27d9bbf89246cf01bfbe166dc20cb9fcf1bcc3fed2e97f4",
+      "prepare-restart-r4-terminal-sql.json": "dbcedc4c421730459faf060b27037f4cba57efb3c013508d2128b81be4ef1cee"
+    },
+    "boundary": "Latest user requested only lease-handoff regression and this 189 in-flight restart. No whole-suite, installer probe or full-business rerun; no operator approval/production-route action. Prepare resource absence does not prove probe physical absence. Prior failed c0fe remains; other T070 matrix, ARM, extra109, public CI and L2 not marked complete."
+  }
+}
diff --git a/features/v3.0.0/059-k8s-runtime-backend/e2e-evidence/fault-upgrade.md b/features/v3.0.0/059-k8s-runtime-backend/e2e-evidence/fault-upgrade.md
new file mode 100644
index 0000000000..b07bc29d8b
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/e2e-evidence/fault-upgrade.md
@@ -0,0 +1,127 @@
+# F059 故障与升级验收(进行中)
+
+## 109 真实 prepare 取消
+
+固定 runtime 源码 `54b3ea2c1`,专用测试平台与 Node profile revision=1。`e2e-f059-cancel-d109c2` 的真实 npm 根 postinstall 已打印固定 BEGIN 标记;按精确 app/deployment/kind=prepare 查询账本,120 秒内获取仍 running/preparing 的绑定后调用 owner 取消接口。
+
+| 检查 | 实际结果 |
+| --- | --- |
+| owner cancel / 非 owner、跨租户 cancel | 成功 / 两者精确 16105 |
+| 操作终态 | cancelled / operation_cancelled / cleanup complete |
+| 发布结果 | 预检失败、无审批和正式版本 |
+| 运行写入者清理 | 同一实际 CID 连续两次不存在,剩余该操作容器为 0 |
+| 人工强制清理 | 未执行 |
+
+应用、部署、版本、操作和实际 CID 及四份脱敏证据摘要见 [JSON](./fault-upgrade.json)。先前 d109c1 误用 revision=2 被拒绝,保留 setup 失败记录,不能计为取消验证。
+
+## 未完成项
+
+当前R29 manager-only升级保留Pod/CID/PVC/profile及旧false/unknown拒绝保持已经真实通过,见installation的R29记录。新R11正常stop/离线数据/resume已通过,见platform的R11记录。R3空闲manager停机访问已真实通过(见下);189初始化失败、响应丢失、在途manager重启、独立安装域完整卸载和最终清理仍待完成;取消及M2迁移失败/快照恢复的后续实际结果见本文末尾;保留的旧unknown写入者负例不替代全部故障注入。私有工具本地测试只证明工具行为。单节点结果不能代表NodeLost跨节点围栏;T070保持pending。
+
+## 109 实际迁移基线(尚未v2/恢复)
+
+v1实际发布、独立审批12 executed,generation1/deploy completed与cleanup complete后,owner两次PATCH写入随机哨兵与NULL。每次写前非owner16162、跨租户16101且rows未变;两行及其SHA/随机值SHA已冻结于私有0600证据,完整ID见JSON。旧测试工具误把终态写成approved,曾在任何数据写前拒绝;修正后只对原成功发布接续两行PATCH,没有重复发布。v2失败和恢复仍待执行,不能把准备或本地SQLite repro计为平台故障通过。
+
+189新r2先准备未到创建资源阶段,controller空闲租约已过期导致renew409与terminal fact失败;前端部署最终16227/operation_timeout,但manager op仍running/admitted/cleanup pending。未修改租约、无Pod/PVC;登记T198/T199后正常修复。旧r2 journal与失败记录保留,不原地抹去重跑。
+
+## 189 控制器自然空闲续租复验
+
+e405候选同一Pod/容器/holder经历自然租约过期,精确renew409后fresh claim/renew成功,epoch3→4,descriptor未变。旧已到期操作进入failed/cleanupcomplete且无资源;这不是Pod停止证明。真实业务之后的独立准备失败仍保留在platform证据。
+
+## 189 主安装域卸载前置(静态复核)
+
+在 `6f254f337` 的 Chart 与调用链复核中,primary release 的卸载被旧 r4 未确认创建阻断:应用 `2122578f094b4c5fb19f9669815431f2`、操作 `6031594368584d1090ba7a7a2ca8e2e4` 的 PVC 仍为 Bound,未取得原写入者停止事实。按名字 404 不构成停止证明。
+
+Chart 卸载会删除 admission webhook、服务与 RBAC,以及执行域 NetworkPolicy、Quota、LimitRange;保留 Namespace/PV/PVC 不能替代持续围栏。当前五个业务应用即使全部停用,也不能据此卸载该 primary release。本轮没有执行卸载、移除保护或手工释放账本。
+
+完整卸载验收须使用独立 release/installationId、控制与执行 namespace、受限服务身份、合格 PV 和独立平台配置及逻辑存储域;运行真实 SQLite 应用并确认停止与 cleanup 后,才卸载该独立域。共享 backend 的新应用默认节点及 manager/data RPC 地址是全局配置,不能临时改向新 manager 而影响 primary。仅合成 SQLite/PVC 的 Helm 保留检查最多证明存储保留子合同,不算完整平台卸载通过。
+
+源码锚点:`deploy/kubernetes/app-runtime/templates/admission.yaml`、`network.yaml`、`rbac.yaml`、`storage.yaml`、`services.yaml`;`runtime_delivery_coordinator.py:45`、`runtime_control_client.py:21`、`runtime_data_session_service.py:113`。该项保持待验收,不将静态复核记为真实卸载结果。
+
+## T256/T257 manager独立升级:真实189
+
+2ac固定源码的amd64 manager原生构建及UID10001/禁网/只读导入烟测通过;内网仓库digest `5619057b1c026d9ff545f4090f6c84c9aaef5fc1050604162770c7f4d018830c`、config586bdc40…。首轮私有Git checkout umask077产生700目录/600文件,镜像生成但非root import失败;只正规化固定公开构建源码权限,内容和Git clean保持,R2另用标签/日志/结果。保留bad bd2092…及日志,未改产品Dockerfile绕过。
+
+Helm28只更新images.manager,旧Pod30d08ee0…替换为faa48ee8-fd38-4980-a290-4b44ada8d6b4;五应用PodUID、各容器CID、PVC/PV身份全部未变,egress/admission/app-proxy的Pod与容器未重启。descriptor数据/UID、helper/assembler/profile均不变。receipt SHA `539386dafef972122991013f7b03c0e920be301df794dea3df09ea5136763e5b`,before/after SHAefe107e7…/6e7757c1…;不据此声称切换期间所有HTTP请求连续成功。
+
+自然node heartbeat已ready/controller_epoch22,运行期15必需检查和原始安装报告16项分别通过,bundle/image/source-image三路径true;容量仍6784Mi/3125m/5实例,旧r4failed/cleanup pending及原PVC保持。root首轮核查误要求运行期16而assert失败,实际报告无故障;纠正口径后冻结真实ready文件,不改产品。owner取消、inflight重启/释放及其他故障仍待独立实测。
+
+## 189 停用验收新增阻塞:报告与历史保护
+
+Python应用a4a566…的单次正常停用d675d583ebb34e68b4756fe8852ff91d在原900秒后自然timed_out/operation_timeout,cleanup pending;离线driver在等待停用600秒处失败退出,未编辑数据库、未恢复上线。Pod0ca2a71e…同UID已删除中/Failed、全部init/app/helper terminated,但资源历史status/binding标志均false,分类器与backend继续拒绝确认。finalizer、PVC、容量保留,无手工SQL或强制清理。
+
+只读五应用cohort:旧Python/Node/static均false/false,后创建source/image仍true/true,node当前ready且两项保护true。不能据此归因最近manager-only升级;首次绑定时是否为true及具体触发心跳缺历史证据。真实本地assessment→manager heartbeat→backend Service→SQLite事务五例证明TTL/helper变化会触发不可逆清零,实际guard失败与报告失效被混用。T258–T261按design4.5细化;不将修正上线视作旧false恢复。原失败journal与脱敏快照SHA见JSON。
+
+## R11/R3 manager停机访问连续性:189真实通过
+
+同R29 f886镜像,受限单次UID/RV CAS将manager 1→0→1;实际旧UID缺席15.931秒,5个完整轮次开始于停机后5秒之外且在up意图之前完成,owner HTTP、SDK identity/OBO、新owner WS和nonowner/foreign HTTP/WS拒绝均通过。原WS与精确原app UID/CID中的CONNECT连接贯穿停机,不重连;未证明长HTTP响应流或SDK远端模型调用。120秒原总预算、15秒最小停机保持,未使用恢复兜底。
+
+恢复新manager UID59ace601-5974-487f-8eaf-095a6b13f015、epoch25,源码/镜像/backend两文件、descriptor及current/v1双保护独立确认;6Pod/7PVC/CID/卷投影不变。旧三false/新二true保持,旧Python held仅controller_epoch24→25正常重对账,没有追认停止或释放。主证明b397e367…、独立部署3825ca37…、inventory8184a040…完整SHA与路径见JSON。
+
+此前R2在任何down前被裸根308拒绝;真实slash200/SDK200/WS成功后最窄修正私有工具,原失败不覆盖。该成功不替代在途操作重启、取消、响应丢失、真实DDL/恢复及独立卸载;T070仍pending。
+
+## m2前置:两个健康专用应用正常停用(物理证明待采)
+
+R11安全删除权限配对真实16105/16101/online owner16104,每次fresh原v1/approval12/gen3/resume保持;独立纯SELECT销毁前后均0且无活动操作。随后R11唯一stop8d71…/gen4与健康fullimage唯一stopdc0317…/gen5均已completed/cleanupcomplete。两应用保留原卷,未执行delete/data/restore,旧false/unknown不动。容量释放须待同UID停止/APIabsence/PVC/PV及预约独立证明,不由公开stopped推断。R11此时附件对象/snapshot均零样本,不能将空清单冒作非空清理验证。
+
+m2已在新目录生成并冻结NULL→NOTNULL应用包(v1 09807c39…/1235B,v2 49d45022…/1237B),尚未发布。私有快照reader修正实际operation.deployment_id为空的关联,另经精确公开failure.operation_id证明,不改库。首版DDL观察器使用189不支持的--resource-version,被真实CLI解析阶段拒绝;尚未运行真实观察或迁移,不以31项假CLI/隔离SQLite测试替代兼容性。新原生list→watch接线待复审。
+
+## M2 真实迁移失败与首轮显式恢复
+
+原v2两次请求(未确认schema拒16229、明确确认后接受)只各发一次;原观察器工具超时保留parent `f07ef54c…`,新的accepted续验从同一待审批v2继续,硬拒绝deploy端点。新watch真实初始ADDED/存活进程证明后,预览四资源正常停止/清理、逐项API缺席证明 `1992d949…`,approval14正常执行。
+
+部署 `a9e809d78cf749a0bf4f4a8b8b3f8989` /操作 `8aba1b5c7a37425393a7e9d5e6ff259c` /gen2真实16228/publishing失败,失败阶段migrating、maintenance/dataMayChanged真且cleanupcomplete。只读观察器从精确Pod UID `41905459-4212-4e23-aec8-2591a14c0762` /容器CID读到`sqlite3.IntegrityError` NOTNULL完整调用链,日志摘要 `e39c7853…`、绑定DDL证明 `e80a1f98…`,原冻结校验器独立通过(结果370e6699…),不以阶段标记代替执行DDL。
+
+同操作snapshot_confirmed事实seq3、verified快照 `dbae5da6ab5e42328db992dd39ddde5f`(16384B,SHA `eaf3653e…`)及完整confirmed上传由backend纯SELECT证明;通过backend现有存储端口导出,原NULL与随机基线行/摘要14608a29…及SQLite integrity在恢复前纯检查通过。私有恢复脚本只修正transfer真实36位UUID兼容,原脚本/错误检查和数据不改,平台代码未因此改动。
+
+首次显式owner restore(含非owner/跨租户拒绝配对)只发一次,接受 `4d858a521f714fc598c565e10e9dc19c` /gen3。维护helper UID `e9b0cef2-a8de-4bea-b8ee-ae969bea5af0`成功、同UID停止清理完成,恢复及保护快照结果入库;后续starting阶段POST资源登记409导致failed/cleanupcomplete,尚未证明恢复后的行和路由。静态及Service配对定位restore+serve未接入资源白名单,T270/T271修复中。原请求不重放、不自动回滚、不改failed为completed。快照去重返回同artifact,不能用新增数量证明或否认保护备份;仍须按同restore的confirmed上传核对。
+
+## M2 第二次显式恢复:189真实通过
+
+新journal以旧4d/gen3已知failed/cleanupcomplete为前置,只对新gen3→4发一次owner POST;nonowner/foreign各精确16105且状态不变。新restore6cd6f833513f41f6980c2fc4e66688da完成completed/cleanupcomplete,App继续current v1、pendingNone、原失败v2及approval14执行记录保持。原NULL与随机nonce行摘要14608a29…完全一致,真实入口health读到v1与同组数据;原parent5676945e…及migration_restore字段逐对象保持。
+
+保护快照按本restore的confirmed upload db502ccf-34f9-4772-93ee-5a8a1edfe5dc确认(offset=size),对应dedup artifact dbae5da6…/16384B/SHAeaf3653e…;原producer仍8aba,不改归属来凑证明。backend现有存储端口实际下载后校摘要/大小/SQLite integrity,独立observe acceptance_complete=true。正式journal4191fe7b…、绑定2e469112…、导出回执1a502951…完整SHA见JSON;不输出行/nonce或凭据。
+
+同操作SQL与元数据证明restore helper UIDadfd7678…已confirmed/cleanupcomplete,随后同名位置只有serve UIDbfd5c5b3…/gen4/Running;不得把同名新Pod存在说成旧helper未清理。原PVC UID8e9e3c90…/production07及PV身份保持,单个当前Pod。SQL证明6a2aa7de…、物理证明733863da…;独立审证52e77506…确认业务/保护绑定,未声称独立本地重新检查未下载的SQLite本体。
+
+首次私有second-r1工具在读取v2状态时误用Cookie,被鉴权拒绝;新restore intent尚未生成且journal==parent,经只读确认后保留旧文件。仅修验收工具改走既有owner publish-status,新目录second-r2;未因此改平台、重放未知请求或跑全量回归。T070仍待其余故障与独立卸载,ARM/追加109/公开发行暂缓。
+
+
+## R31 真实准备中取消:189通过
+
+新Node草稿09672c6d…的唯一发布ad7c654e…绑定prepare c29cfb53…/gen1;同一安装Pod UIDba740781…的精确CRI Docker CID经四项标签与二次Pod读取确认,实际npm根postinstall BEGIN与十次WAIT、无END,240秒hook已运行54.85秒时提交owner取消。非owner与跨租户GET/cancel均精确16105;owner意图持久化后仅POST一次。公开取消完成发生于BEGIN后100.81秒,早于自然结束;最终cancelled/operation_cancelled/cleanupcomplete,发布16227/precheck_build、无审批和正式版本。
+
+SELECT账本六资源全部cleanupcomplete,三个Pod同UID kubelet_terminal停止事实;随后物理集合与账本逐ref/kind/UID精确一致、六项自然404,预约released。原四应用Pod/CID/restart与恢复后M2 Pod UID保持;未强删、改finalizer或手工释放。草稿保留用于后续已知终态的新发布,不把历史取消请求重放。
+
+五份原始脱敏文件摘要及完整ID见JSON;独立L1 `77e4d874…` 通过。验收工具仅修复v2 Bearer请求被登录Cookie覆盖和提交意图防重放,六项定向测试通过,不改产品、未跑全量回归。各调用有300秒总预算但分阶段无持久全局期限;本次submit意图至公开取消完成实际165.72秒,之后SQL/物理取证不冒作300秒内观测。仅关闭T070取消子项,初始化/响应丢失/在途SIGKILL/独立卸载仍待验收。
+
+
+## R31 在途SIGKILL首轮:未通过,原失败保留
+
+在新迭代f43bb953…/prepare c0fe7a9a…的真实240秒Node安装hook运行中,按manager原Pod UID56193f9b…/精确旧CID唯一SIGKILL,CLI已ACK。hook证明距BEGIN5.20秒、kill意图距BEGIN7.66秒;kubelet同Pod重启一次、controller28→29,但原prepare随后failed/execution_failed/cleanupcomplete,inputs/resolve事实保留、install未完成,正式版本仍0。观察器120秒预算包含预检,最终unknown/71次state检查异常,不能表述为71次HTTP失败或容器恢复即续办成功。
+
+原安装UID142037c5…及另两Pod均同UID kubelet_terminal停止,六资源自然API缺席、容量released,原四应用CID/restart与M2恢复Pod UID保持;未强制清理。故障后的owner取消工具仅做新绑定前置检查即拒绝,未提交cancel;原failed/意图/unknown结果均保留。七份脱敏证据及独立NOT_PASSED报告5c8ec11f…摘要见JSON。
+
+根因验证只围绕该链:真实backend Service/SQLite证明旧租约未到期对新holder的409/controller_lease_held不会改变prepare,epoch更换和相同模板本身可复用;真实OperationExecutor+KubernetesExecutor最小复现证明该暂态拒绝进入业务_failure,错误处理跨自然到期后拿到新epoch并误清理正在安装的原Pod,runtime.prepare甚至尚未进入。实际故障窗只统计到10次lease_held标记,不单凭计数宣称抓到首个进程内异常。登记T272/T273,将开工前控制器准入移出业务失败处理范围;不改模板/后端围栏、不吞任意异常、不延长原deadline。修复与新操作复验尚未完成。
+
+
+## R32 租约交接定向复验:189通过
+
+按用户最新范围,只验证租约交接及同一在途准备操作,未重跑全量/安装探测/整组业务。修复源码0f2db1f9a与实际manager镜像bc488d16…/config86230f…已由installation的R32记录固定,其他模块保持。
+
+| 检查 | 实际结果 |
+| --- | --- |
+| 故障提交 | 对精确旧manager CID仅一次SIGKILL,CLI已ACK,无取消/审批/上线操作 |
+| 控制器交接 | 同manager Pod UID2919607c…,restart0→1、新CID,epoch30→31;25.77秒恢复 |
+| 原在途操作 | fe9a00424af44176811354d01be141f0/同dep b58853b2…/gen1/owner1,原deadline 2026-10-23T07:22:07不变 |
+| 原安装进程 | 同Pod UID6959dd58…、同CID873910bd…,重启后仍running,未重建安装Pod |
+| 自然完成 | 精确原CID的BEGIN 07:13:15.897→END 07:17:15.903,240.006186秒;没有owner cancel |
+| 操作结果 | prepare completed/cleanupcomplete,inputs/resolve结果SHA原样保留,install/upload进度齐,未出现失败fact |
+| 清理与容量 | 同UID kubelet终态后8资源自然API缺席,预约released;原四应用CID/restart与M2 UID保持 |
+| 后续正常发布 | 同发布probe87225ca3…也completed/cleanupcomplete,创建1版本;不把它当作正式生产上线或probe物理清理证明 |
+
+实际kill至恢复窗口匹配到6个固定controller_lease_held日志标记,确认经历旧租约等待;计数不是唯一请求数,也不替代UID/事实证明。派生交叉证明逐项断言同op/原deadline/原安装UID与CID/原阶段结果/终态与清理,完整SHA见JSON;原c0fe失败与unknown观察记录保留,未重放或复活。
+
+私有比较器首轮在本机Python3.10解析9位Docker时间时拒绝,尚未写结果;r2仅截取到微秒再核同一组原始证据,未改平台或再次远端运行。此次通过只关闭T070的租约交接/在途重启子项,其他故障矩阵、独立卸载及正式交付门仍保留原状态。
+
+独立最终窄审PASS:`/private/tmp/f059-prepare-sigkill-r4-independent-evidence.md`,SHA `a89f59ae73a2b54a91b550b3497bcf467c1d46609e2a65dec912571c304202a2`。原始11份与第12份派生comparison清单逐摘要一致;仅本租约交接和准备续办,不替代其余故障矩阵或生产上线。
diff --git a/features/v3.0.0/059-k8s-runtime-backend/e2e-evidence/installation.json b/features/v3.0.0/059-k8s-runtime-backend/e2e-evidence/installation.json
new file mode 100644
index 0000000000..c848f6c116
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/e2e-evidence/installation.json
@@ -0,0 +1,2784 @@
+{
+  "schema": "f059-installation-evidence/v1",
+  "state": "passed_current_r32_manager_only_deployment",
+  "host": "192.168.106.189",
+  "platform": "linux/amd64",
+  "kubernetes": "v1.28.15",
+  "single_node": true,
+  "third_run": {
+    "checks": [
+      {
+        "evidence": {
+          "uid": "e8a15943-3bd1-476f-b609-3e3ca244a19a"
+        },
+        "name": "admission-create",
+        "observed_at": 1792672296.5338628,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "uid": "71eae7b8-35f9-4602-b0dc-e8c75181a5cc"
+        },
+        "name": "binding-delete-fence",
+        "observed_at": 1792672296.5338628,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "digest": "sha256:4e748b3e140af06d32987d0ec9f711d01886aeb3ddc80feb2172de63093b95f4",
+          "uid": "e8a15943-3bd1-476f-b609-3e3ca244a19a"
+        },
+        "name": "image-cri",
+        "observed_at": 1792672296.5338628,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "code": "control_side_tls",
+          "uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b"
+        },
+        "name": "material-tls",
+        "observed_at": 1792672296.5338628,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b"
+        },
+        "name": "namespace-budget",
+        "observed_at": 1792672296.5338628,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "uid": "362cbe83-eda7-48d9-91d4-95955263cb07"
+        },
+        "name": "network-direct-denied",
+        "observed_at": 1792672296.5338628,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "uid": "36cd8394-5b88-4106-9531-4887628a2f91"
+        },
+        "name": "network-dns-denied",
+        "observed_at": 1792672296.5338628,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "uid": "0165c1a5-11fe-41af-b96e-8eb2b4668d1a"
+        },
+        "name": "network-egress-allowed",
+        "observed_at": 1792672296.5338628,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "uid": "e8a15943-3bd1-476f-b609-3e3ca244a19a"
+        },
+        "name": "network-ingress",
+        "observed_at": 1792672296.5338628,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "uid": "ffeedf5f-6001-477a-881a-67c33432248c"
+        },
+        "name": "no-exec",
+        "observed_at": 1792672296.5338628,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "code": "control_side_authenticated_api",
+          "uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b"
+        },
+        "name": "platform-capabilities",
+        "observed_at": 1792672296.5338628,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "uid": "ffeedf5f-6001-477a-881a-67c33432248c"
+        },
+        "name": "rbac-namespace",
+        "observed_at": 1792672296.5338628,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "digest": "sha256:4e748b3e140af06d32987d0ec9f711d01886aeb3ddc80feb2172de63093b95f4",
+          "uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b"
+        },
+        "name": "registry-push",
+        "observed_at": 1792672296.5338628,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "uid": "e8a15943-3bd1-476f-b609-3e3ca244a19a"
+        },
+        "name": "restricted-psa",
+        "observed_at": 1792672296.5338628,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "policy_revision": "v1",
+          "uid": "e8a15943-3bd1-476f-b609-3e3ca244a19a"
+        },
+        "name": "status-provenance",
+        "observed_at": 1792672296.5338628,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "uid": "94ed86d9-d1c5-4fb8-8e4e-bff11cbaf80e"
+        },
+        "name": "storage-sqlite",
+        "observed_at": 1792672296.5338628,
+        "ok": true,
+        "reason": "verified"
+      }
+    ],
+    "observed_at": 1792672296.5338628,
+    "descriptor_digest": "9b279b9dc0abe6b338beb4ca76a2feecb033ca3b60009fe3329c8a8446b950e7",
+    "report_sha256": "4a1c181186392783caa38b2eb06e1eec3c1e96d2559f07d138a067952256af14",
+    "exit_code": 0,
+    "cleanup_completed": true,
+    "admission_source_commit": "2512ae77eda1af45fd8d1d8c10dce7760abcb82a",
+    "admission_image": "192.168.106.189:5443/f059/runtime-manager@sha256:75f3433618e3e8df33248f76a4cd2ff56185b82c4ce8e13b8c90ed9427f9b440"
+  },
+  "helm_retention": {
+    "same_uid": true,
+    "same_plans_bytes": true,
+    "uid": "feccd344-02c7-4c04-b772-c0ed3b7a079d",
+    "plans_sha256": "9e7ec0ea64f0d822146dd9463877396e67ceabca7fea2e791bb6b4778f0c66ae"
+  },
+  "current_candidate": {
+    "source_revision": "0f2db1f9a29213ca94d4596e64b00ad8fcbac528",
+    "source_scope": "runtime-manager only; backend/complementary images retain their separately recorded revisions",
+    "helm_revision": 32,
+    "images": {
+      "manager": {
+        "image_id": "sha256:86230f7a09ee50970c1667710b0b03c8768b92f56697c833e1ad3fcf6bcf4396",
+        "image": "192.168.106.189:5443/f059/runtime-manager@sha256:bc488d16d3b75ccd30e729d7ef79b0c4591abaa40c0037057697f405d9e4900b",
+        "native_smoke": true,
+        "source_revision": "0f2db1f9a29213ca94d4596e64b00ad8fcbac528",
+        "private_tag": "192.168.106.189:5443/f059/runtime-manager:20261023-32"
+      },
+      "assembler-public-ca": {
+        "image_id": "sha256:ddc11511399ae6f3f9689a076a8cc6bb5c6b8db34bb194d892603fe20264f19e",
+        "image": "192.168.106.189:5443/f059/runtime-assembler-ca@sha256:9836c13405df4e4b613712a2e933a73e755d22daeb79d51d6adefc242971c6b9",
+        "native_smoke": true,
+        "source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+        "private_tag": "192.168.106.189:5443/f059/runtime-assembler-ca:20261023-26"
+      },
+      "helper": {
+        "image_id": "sha256:9cd5d3e1331f4b8c81c6335c4e3034e1eb9c63b94f1968e9dc4357180c2296a9",
+        "image": "192.168.106.189:5443/f059/runtime-helper@sha256:5906c51814e24a63356dc69fb5c1dd332bb4eb91b70cf65d41594aefc0841c85",
+        "native_smoke": true,
+        "source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+        "private_tag": "192.168.106.189:5443/f059/runtime-helper:20261023-26"
+      }
+    },
+    "manager_pod_name": "runtime-manager-595cfcbdbb-l7gfr",
+    "manager_pod_uid": "2919607c-bf80-428c-bcd6-a31779803ce0",
+    "controller_epoch_at_upgrade": 30,
+    "descriptor_uid_unchanged": true,
+    "descriptor_digest": "f306a49e2e396c60c91e76672c72dfc9989763fd5d7dcb091257009f70b8f456",
+    "backend_service_source_sha256": "c06fb408033f4d77c767924908209e92fd06650d7275bac2cb6cba15442327ce",
+    "backend_source_revision_claimed": false,
+    "report_reused_for_exact_descriptor": true,
+    "new_probe_claimed": false,
+    "state": "passed_manager_only_deployment",
+    "evidence_reference": "r32_manager_only_deployment",
+    "controller_epoch_after_inflight_restart": 31,
+    "manager_restart_count_after_inflight_restart": 1
+  },
+  "remaining": [
+    "Business and fault/upgrade acceptance are separate T069/T070; no cross-node claim",
+    "Formal public CI not authorized; separate T071"
+  ],
+  "limitations": [
+    "DM8 static only",
+    "No cross-node recovery claim",
+    "native b8 amd64 smoke succeeded but summary refused non-test inventory changes during K8s probe; not final native acceptance",
+    "registry delete enabled only on dedicated test registry after explicit user authorization; prior container/data retained"
+  ],
+  "fourth_run": {
+    "artifacts": {
+      "image-cri": [
+        {
+          "image_id": "docker-pullable://192.168.106.189:5443/f059/runtime-helper@sha256:4e748b3e140af06d32987d0ec9f711d01886aeb3ddc80feb2172de63093b95f4",
+          "image_ref": "192.168.106.189:5443/f059/runtime-helper@sha256:4e748b3e140af06d32987d0ec9f711d01886aeb3ddc80feb2172de63093b95f4",
+          "platform": "linux/amd64",
+          "resource_version": "3160765",
+          "uid": "232cd2ec-d680-4f7a-9258-d064d2fe1ca7"
+        },
+        {
+          "image_id": "docker-pullable://192.168.106.189:5443/f059/app-python-ca@sha256:b632d5192bd370c5690aee2c2cc192954522dd0cd44ab0f5c8be050a3e033263",
+          "image_ref": "192.168.106.189:5443/f059/app-python-ca@sha256:b632d5192bd370c5690aee2c2cc192954522dd0cd44ab0f5c8be050a3e033263",
+          "platform": "linux/amd64",
+          "resource_version": "3160847",
+          "uid": "69b775ae-2b99-4f63-b7e6-8abb5301e439"
+        },
+        {
+          "image_id": "docker-pullable://192.168.106.189:5443/f059/app-python-preparer-ca@sha256:7d4d46ca19fa0a5ae71f177f81a45df58dc3d3a676d4c536615a9376673d55e9",
+          "image_ref": "192.168.106.189:5443/f059/app-python-preparer-ca@sha256:7d4d46ca19fa0a5ae71f177f81a45df58dc3d3a676d4c536615a9376673d55e9",
+          "platform": "linux/amd64",
+          "resource_version": "3160908",
+          "uid": "6a20c118-13a9-438c-b7a4-ceeb24cb85d6"
+        },
+        {
+          "image_id": "docker-pullable://192.168.106.189:5443/f059/app-node-ca@sha256:533c8eb5040441e9420969ff0b305b45572ea58b80d345c27893dd9edc4a2763",
+          "image_ref": "192.168.106.189:5443/f059/app-node-ca@sha256:533c8eb5040441e9420969ff0b305b45572ea58b80d345c27893dd9edc4a2763",
+          "platform": "linux/amd64",
+          "resource_version": "3160965",
+          "uid": "7d340c35-fb3a-4ec7-b03f-28fb7e7b0b83"
+        },
+        {
+          "image_id": "docker-pullable://192.168.106.189:5443/f059/app-node-preparer-ca@sha256:27b13ce9a57c59f9724001d5a69ede5029a6c6709a702a55fba435a77f77e666",
+          "image_ref": "192.168.106.189:5443/f059/app-node-preparer-ca@sha256:27b13ce9a57c59f9724001d5a69ede5029a6c6709a702a55fba435a77f77e666",
+          "platform": "linux/amd64",
+          "resource_version": "3161024",
+          "uid": "1ad1e715-7b6d-497e-a7c5-8def92521a76"
+        },
+        {
+          "image_id": "docker-pullable://192.168.106.189:5443/f059/app-static-ca@sha256:2f0311da8b281f35437fcf26b47c8249f8d86f98227a28bb231cd472071ee218",
+          "image_ref": "192.168.106.189:5443/f059/app-static-ca@sha256:2f0311da8b281f35437fcf26b47c8249f8d86f98227a28bb231cd472071ee218",
+          "platform": "linux/amd64",
+          "resource_version": "3161082",
+          "uid": "f24e9e1a-83e5-4778-b34e-ae39b9512b76"
+        }
+      ]
+    },
+    "checks": [
+      {
+        "evidence": {
+          "uid": "232cd2ec-d680-4f7a-9258-d064d2fe1ca7"
+        },
+        "name": "admission-create",
+        "observed_at": 1792673067.243089,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "uid": "9f460702-13d2-4c51-94e5-368668690155"
+        },
+        "name": "binding-delete-fence",
+        "observed_at": 1792673067.243089,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "digest": "sha256:4e748b3e140af06d32987d0ec9f711d01886aeb3ddc80feb2172de63093b95f4",
+          "uid": "232cd2ec-d680-4f7a-9258-d064d2fe1ca7"
+        },
+        "name": "image-cri",
+        "observed_at": 1792673067.243089,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "code": "control_side_tls",
+          "uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b"
+        },
+        "name": "material-tls",
+        "observed_at": 1792673067.243089,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b"
+        },
+        "name": "namespace-budget",
+        "observed_at": 1792673067.243089,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "uid": "89146938-e670-4566-a72f-90328586554d"
+        },
+        "name": "network-direct-denied",
+        "observed_at": 1792673067.243089,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "uid": "8105356f-d661-4259-b43a-2906e25c1b37"
+        },
+        "name": "network-dns-denied",
+        "observed_at": 1792673067.243089,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "uid": "8aa23f22-def3-4016-849c-d27f3fbce31a"
+        },
+        "name": "network-egress-allowed",
+        "observed_at": 1792673067.243089,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "uid": "232cd2ec-d680-4f7a-9258-d064d2fe1ca7"
+        },
+        "name": "network-ingress",
+        "observed_at": 1792673067.243089,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "uid": "ffeedf5f-6001-477a-881a-67c33432248c"
+        },
+        "name": "no-exec",
+        "observed_at": 1792673067.243089,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "code": "control_side_authenticated_api",
+          "uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b"
+        },
+        "name": "platform-capabilities",
+        "observed_at": 1792673067.243089,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "uid": "ffeedf5f-6001-477a-881a-67c33432248c"
+        },
+        "name": "rbac-namespace",
+        "observed_at": 1792673067.243089,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "digest": "sha256:4e748b3e140af06d32987d0ec9f711d01886aeb3ddc80feb2172de63093b95f4",
+          "uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b"
+        },
+        "name": "registry-push",
+        "observed_at": 1792673067.243089,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "uid": "232cd2ec-d680-4f7a-9258-d064d2fe1ca7"
+        },
+        "name": "restricted-psa",
+        "observed_at": 1792673067.243089,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "policy_revision": "v1",
+          "uid": "232cd2ec-d680-4f7a-9258-d064d2fe1ca7"
+        },
+        "name": "status-provenance",
+        "observed_at": 1792673067.243089,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "uid": "c66eb86f-acbc-4e32-9bde-dec4e675783e"
+        },
+        "name": "storage-sqlite",
+        "observed_at": 1792673067.243089,
+        "ok": true,
+        "reason": "verified"
+      }
+    ],
+    "descriptor_digest": "9b279b9dc0abe6b338beb4ca76a2feecb033ca3b60009fe3329c8a8446b950e7",
+    "observed_at": 1792673067.243089,
+    "source": "cluster-probe",
+    "report_sha256": "9c858f9a13790b891b03a0862acfe4767adb82efc5887c8b77723c0505a4330d",
+    "exit_code": 0,
+    "cleanup_completed": true,
+    "source_commit": "7e720073b8a57492b7befbbb7155ffe30331af01",
+    "manager_egress_admission_image": "192.168.106.189:5443/f059/runtime-manager@sha256:f2ae12243a5004000b8ff997ddccbaabea00816e29905e1e263f477e6a9aeb7b"
+  },
+  "known_legacy_dryrun_retirement": {
+    "after": {
+      "name": "runtime-canary-plans",
+      "namespace": "e2e-f059-system",
+      "resourceVersion": "3162297",
+      "uid": "feccd344-02c7-4c04-b772-c0ed3b7a079d"
+    },
+    "after_cas": {
+      "name": "runtime-canary-plans",
+      "namespace": "e2e-f059-system",
+      "resourceVersion": "3162297",
+      "uid": "feccd344-02c7-4c04-b772-c0ed3b7a079d"
+    },
+    "baseline_cm": {
+      "name": "runtime-canary-plans",
+      "namespace": "e2e-f059-system",
+      "resourceVersion": "3155846",
+      "uid": "feccd344-02c7-4c04-b772-c0ed3b7a079d"
+    },
+    "baseline_sha256": "4fa131fbf403e9ad2108052491ff169a70e76a2484d7b20783b91835557b77d1",
+    "before": {
+      "name": "runtime-canary-plans",
+      "namespace": "e2e-f059-system",
+      "resourceVersion": "3161953",
+      "uid": "feccd344-02c7-4c04-b772-c0ed3b7a079d"
+    },
+    "cas_attempted": true,
+    "exit_observed_at": 1792673837.5513606,
+    "exit_proof_sha256": "acefb41f161553ee3184d6b276d83b30d8be15e90813651945c4253d1dddcb7d",
+    "expires_at": 1792673434,
+    "finalizers_modified": false,
+    "get_proofs": [
+      {
+        "identity": "manager",
+        "observed_at": 1792673839.254521,
+        "path": "/api/v1/namespaces/e2e-f059-apps/pods/preflight-deny-83544bb9a25f",
+        "status": 404,
+        "status_body_sha256": "0b460390145a3ee34003aa18b508d624c038069e3a90a552855cf6ba284a7df7"
+      },
+      {
+        "identity": "external",
+        "observed_at": 1792673839.2642224,
+        "path": "/api/v1/namespaces/e2e-f059-apps/pods/preflight-deny-83544bb9a25f",
+        "status": 404,
+        "status_body_sha256": "0b460390145a3ee34003aa18b508d624c038069e3a90a552855cf6ba284a7df7"
+      },
+      {
+        "identity": "manager",
+        "observed_at": 1792673840.2687492,
+        "path": "/api/v1/namespaces/e2e-f059-apps/pods/preflight-deny-83544bb9a25f",
+        "status": 404,
+        "status_body_sha256": "0b460390145a3ee34003aa18b508d624c038069e3a90a552855cf6ba284a7df7"
+      },
+      {
+        "identity": "external",
+        "observed_at": 1792673840.2729843,
+        "path": "/api/v1/namespaces/e2e-f059-apps/pods/preflight-deny-83544bb9a25f",
+        "status": 404,
+        "status_body_sha256": "0b460390145a3ee34003aa18b508d624c038069e3a90a552855cf6ba284a7df7"
+      }
+    ],
+    "name": "preflight-deny-83544bb9a25f",
+    "plan_sha256": "f04df92877b8cec63c0acda7f9244577f08a3154f9dbb49f09441ac9877cac26",
+    "production_recovery_changed": false,
+    "projection_refresh": "existing-installer-refresh-returned",
+    "ref": "e0b88e9a-fa8a-4b33-b10f-8a390075207b",
+    "removed_only_known_ref": true,
+    "resources_deleted": false,
+    "result": "retired",
+    "schema_version": 1,
+    "started_at": 1792673837.9182878
+  },
+  "expired_recovery": {
+    "normal_cleanup_exit_code": 0,
+    "resources": "Only naturally expired same-UID plans adopted; later second-run plans left pending natural expiry",
+    "proof": "/opt/e2e-f059/evidence/install-verifier.json",
+    "all_old_expired_resources_cleaned": true,
+    "execution_namespace_pods": 0,
+    "execution_namespace_pvcs": 0,
+    "canary_plan_count": 0,
+    "inventory_sha256": "04e2bbe0bf4669612dc373cfab18d4c752b139a7945d9dc7b72532166d733517"
+  },
+  "fifth_run": {
+    "artifacts": {
+      "image-cri": [
+        {
+          "image_id": "docker-pullable://192.168.106.189:5443/f059/runtime-helper@sha256:4e748b3e140af06d32987d0ec9f711d01886aeb3ddc80feb2172de63093b95f4",
+          "image_ref": "192.168.106.189:5443/f059/runtime-helper@sha256:4e748b3e140af06d32987d0ec9f711d01886aeb3ddc80feb2172de63093b95f4",
+          "platform": "linux/amd64",
+          "resource_version": "3164256",
+          "uid": "5a29fe19-117d-4946-be7a-7e55a4e0a92d"
+        },
+        {
+          "image_id": "docker-pullable://192.168.106.189:5443/f059/app-python-ca@sha256:b632d5192bd370c5690aee2c2cc192954522dd0cd44ab0f5c8be050a3e033263",
+          "image_ref": "192.168.106.189:5443/f059/app-python-ca@sha256:b632d5192bd370c5690aee2c2cc192954522dd0cd44ab0f5c8be050a3e033263",
+          "platform": "linux/amd64",
+          "resource_version": "3164336",
+          "uid": "35df62e0-0975-436c-99c0-93d94c0c73fd"
+        },
+        {
+          "image_id": "docker-pullable://192.168.106.189:5443/f059/app-python-preparer-ca@sha256:7d4d46ca19fa0a5ae71f177f81a45df58dc3d3a676d4c536615a9376673d55e9",
+          "image_ref": "192.168.106.189:5443/f059/app-python-preparer-ca@sha256:7d4d46ca19fa0a5ae71f177f81a45df58dc3d3a676d4c536615a9376673d55e9",
+          "platform": "linux/amd64",
+          "resource_version": "3164395",
+          "uid": "dd63c242-0382-4fa9-bd86-71880b60e7c0"
+        },
+        {
+          "image_id": "docker-pullable://192.168.106.189:5443/f059/app-node-ca@sha256:533c8eb5040441e9420969ff0b305b45572ea58b80d345c27893dd9edc4a2763",
+          "image_ref": "192.168.106.189:5443/f059/app-node-ca@sha256:533c8eb5040441e9420969ff0b305b45572ea58b80d345c27893dd9edc4a2763",
+          "platform": "linux/amd64",
+          "resource_version": "3164455",
+          "uid": "f645a951-6813-4e8e-ba8f-41b499202c8a"
+        },
+        {
+          "image_id": "docker-pullable://192.168.106.189:5443/f059/app-node-preparer-ca@sha256:27b13ce9a57c59f9724001d5a69ede5029a6c6709a702a55fba435a77f77e666",
+          "image_ref": "192.168.106.189:5443/f059/app-node-preparer-ca@sha256:27b13ce9a57c59f9724001d5a69ede5029a6c6709a702a55fba435a77f77e666",
+          "platform": "linux/amd64",
+          "resource_version": "3164514",
+          "uid": "46e1cc58-3ee0-44eb-a9d4-8793190e5a2f"
+        },
+        {
+          "image_id": "docker-pullable://192.168.106.189:5443/f059/app-static-ca@sha256:2f0311da8b281f35437fcf26b47c8249f8d86f98227a28bb231cd472071ee218",
+          "image_ref": "192.168.106.189:5443/f059/app-static-ca@sha256:2f0311da8b281f35437fcf26b47c8249f8d86f98227a28bb231cd472071ee218",
+          "platform": "linux/amd64",
+          "resource_version": "3164571",
+          "uid": "9410b10e-aa65-4172-8488-9c80a7c21c12"
+        }
+      ]
+    },
+    "checks": [
+      {
+        "evidence": {
+          "uid": "5a29fe19-117d-4946-be7a-7e55a4e0a92d"
+        },
+        "name": "admission-create",
+        "observed_at": 1792675133.5488646,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "uid": "c1474a08-95e0-469a-8e27-da481f5f85da"
+        },
+        "name": "binding-delete-fence",
+        "observed_at": 1792675133.5488646,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "digest": "sha256:4e748b3e140af06d32987d0ec9f711d01886aeb3ddc80feb2172de63093b95f4",
+          "uid": "5a29fe19-117d-4946-be7a-7e55a4e0a92d"
+        },
+        "name": "image-cri",
+        "observed_at": 1792675133.5488646,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "code": "control_side_tls",
+          "uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b"
+        },
+        "name": "material-tls",
+        "observed_at": 1792675133.5488646,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b"
+        },
+        "name": "namespace-budget",
+        "observed_at": 1792675133.5488646,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "uid": "71c74a77-c930-4aeb-9837-a82b1e4ec697"
+        },
+        "name": "network-direct-denied",
+        "observed_at": 1792675133.5488646,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "uid": "c2cb80d7-b970-486f-8b5a-ebecd16578b0"
+        },
+        "name": "network-dns-denied",
+        "observed_at": 1792675133.5488646,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "uid": "a310cf5e-1af4-4e66-bce9-67a933e23a99"
+        },
+        "name": "network-egress-allowed",
+        "observed_at": 1792675133.5488646,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "uid": "5a29fe19-117d-4946-be7a-7e55a4e0a92d"
+        },
+        "name": "network-ingress",
+        "observed_at": 1792675133.5488646,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "uid": "ffeedf5f-6001-477a-881a-67c33432248c"
+        },
+        "name": "no-exec",
+        "observed_at": 1792675133.5488646,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "code": "control_side_authenticated_api",
+          "uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b"
+        },
+        "name": "platform-capabilities",
+        "observed_at": 1792675133.5488646,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "uid": "ffeedf5f-6001-477a-881a-67c33432248c"
+        },
+        "name": "rbac-namespace",
+        "observed_at": 1792675133.5488646,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "digest": "sha256:4e748b3e140af06d32987d0ec9f711d01886aeb3ddc80feb2172de63093b95f4",
+          "uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b"
+        },
+        "name": "registry-push",
+        "observed_at": 1792675133.5488646,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "uid": "5a29fe19-117d-4946-be7a-7e55a4e0a92d"
+        },
+        "name": "restricted-psa",
+        "observed_at": 1792675133.5488646,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "policy_revision": "v1",
+          "uid": "5a29fe19-117d-4946-be7a-7e55a4e0a92d"
+        },
+        "name": "status-provenance",
+        "observed_at": 1792675133.5488646,
+        "ok": true,
+        "reason": "verified"
+      },
+      {
+        "evidence": {
+          "uid": "226f3dad-a886-4123-bb53-144bc9adba1e"
+        },
+        "name": "storage-sqlite",
+        "observed_at": 1792675133.5488646,
+        "ok": true,
+        "reason": "verified"
+      }
+    ],
+    "descriptor_digest": "9b279b9dc0abe6b338beb4ca76a2feecb033ca3b60009fe3329c8a8446b950e7",
+    "observed_at": 1792675133.5488646,
+    "source": "cluster-probe",
+    "source_commit": "54b3ea2c15836529cc24667d9a58be49e35bf710",
+    "report_sha256": "c8104e7f9a1ccd13a6605b431a4e3924a62e4db45caa1ae32a5f485aa69fba9e",
+    "exit_code": 0,
+    "normal_cleanup_completed": true
+  },
+  "real_report_negative_pairs": {
+    "cases": {
+      "expired": {
+        "apps": {
+          "e2e-f059-report-expired-54b1": {
+            "app_id": "1608976ca1804450b682af1176a60436",
+            "deployment_id": "de43b474a8284f98adc46d68bd77627c",
+            "version_id": "09ba441d4c604d369cb4fd049e05dfba",
+            "mode": "bundle",
+            "runtime": "static"
+          }
+        },
+        "terminal_failure_code": 16121,
+        "stage": "precheck_build",
+        "approval_created": false,
+        "runtime_operation_created": false,
+        "package_sha256": "bc9dba2034aca6f7bdbde57137de25445c48467e65e3016618f27957cb468d16",
+        "evidence_sha256": "075c9b6c42745639473c218433cfbe12a261dc4ba46b336c628d90b937416a7f"
+      },
+      "cni-identity": {
+        "apps": {
+          "e2e-f059-report-cni-identity-54b1": {
+            "app_id": "0e2037f51e5e4250804dd1e86348d73c",
+            "deployment_id": "132c0a1ba62b4db19ff47fa119e096d5",
+            "version_id": "d530d626b120411c80b1090984982a78",
+            "mode": "bundle",
+            "runtime": "static"
+          }
+        },
+        "terminal_failure_code": 16121,
+        "stage": "precheck_build",
+        "approval_created": false,
+        "runtime_operation_created": false,
+        "package_sha256": "8862ddfc0be3a124f68fc9f467fba7ea15b5e52f6b3a7d5764a7d9d8c5d82bd0",
+        "evidence_sha256": "60d7ded847e38da538d96e69803d1d6af7ed0c6842f4e60177cf2807d656f0ae"
+      },
+      "mandatory": {
+        "apps": {
+          "e2e-f059-report-mandatory-54b1": {
+            "app_id": "7e11e7a544a34b56908caebed26e8dc1",
+            "deployment_id": "f08c72c694ba48f0834e1acf632616e1",
+            "version_id": "7f582d173cfa419c89a0025f72ec3333",
+            "mode": "bundle",
+            "runtime": "static"
+          }
+        },
+        "terminal_failure_code": 16121,
+        "stage": "precheck_build",
+        "approval_created": false,
+        "runtime_operation_created": false,
+        "package_sha256": "508d7921c8dfe450bc4a606c92bb7bc4a0142b067e7d6a87fa720e5bf7a8b45b",
+        "evidence_sha256": "5d4660e52214326e1d7d18764f80c960a92b288524897aea02d918b16bdffc3b"
+      }
+    },
+    "original_descriptor_bytes_restored": true,
+    "manager_unchanged": [
+      {
+        "container_id": "docker://24e8e05aa8e4956640d64877ec2a0cd446fc4990fc167b8d84a534d87dfa1a1b",
+        "image_id": "docker-pullable://192.168.106.189:5443/f059/runtime-manager@sha256:26f61e7ba98726d3c78ade8236ef78d09567583282bf2292f911af04b2a4b7f1",
+        "name": "runtime-manager-8b966c4c6-bl2ck",
+        "restart_count": 0,
+        "started_at": "2026-10-22T13:17:57Z",
+        "uid": "8f1865ba-36b3-40e1-8798-578caabc88ff"
+      }
+    ],
+    "restore_without_restart_count": 3,
+    "state_sha256": "00edbe11b84d64f19839096e0c45e334cd19e01873129c53a33f988778977c26"
+  },
+  "scratch_pool_extension": {
+    "private_inventory": "/opt/e2e-f059/operator-scratch-09-40/inventory.json",
+    "inventory_sha256": "c881baaa90ba9bd0eca9b88079adf11c3ba2142620bbfd41a19da29b5d1e7f1d",
+    "plan_sha256": "af8eeacc1aa8af99790ab51bd14aad6127cf9be748e1eae24f0ef41f6edcfaac",
+    "new_pv_count": 32,
+    "physical_allocation_bytes": 137438953472,
+    "original_19_unchanged": true,
+    "existing_loops_unchanged": true,
+    "new_helm_managed": false,
+    "policy": "Retain; operator inventory; no old PV rebind or data cleanup",
+    "new_uid_mounts": [
+      {
+        "name": "e2e-f059-scratch-09",
+        "uid": "f4b878c2-6e68-4508-a9f9-a72e404ef7d6",
+        "loop": "/dev/loop19",
+        "backing": "/opt/e2e-f059/volume-files/scratch-09.ext4"
+      },
+      {
+        "name": "e2e-f059-scratch-10",
+        "uid": "6e0e1733-7f1c-48e4-b7c6-ecfc23ea64f5",
+        "loop": "/dev/loop20",
+        "backing": "/opt/e2e-f059/volume-files/scratch-10.ext4"
+      },
+      {
+        "name": "e2e-f059-scratch-11",
+        "uid": "3b5e4f36-3cc4-49b6-89d6-c21a78234dce",
+        "loop": "/dev/loop21",
+        "backing": "/opt/e2e-f059/volume-files/scratch-11.ext4"
+      },
+      {
+        "name": "e2e-f059-scratch-12",
+        "uid": "50ddec49-247b-445a-a3dc-447a6535fb2e",
+        "loop": "/dev/loop22",
+        "backing": "/opt/e2e-f059/volume-files/scratch-12.ext4"
+      },
+      {
+        "name": "e2e-f059-scratch-13",
+        "uid": "ea5a67e3-20ef-4a17-9d0b-55f2cc818967",
+        "loop": "/dev/loop23",
+        "backing": "/opt/e2e-f059/volume-files/scratch-13.ext4"
+      },
+      {
+        "name": "e2e-f059-scratch-14",
+        "uid": "c134da1e-7d73-4148-a2d5-479574a21f5b",
+        "loop": "/dev/loop24",
+        "backing": "/opt/e2e-f059/volume-files/scratch-14.ext4"
+      },
+      {
+        "name": "e2e-f059-scratch-15",
+        "uid": "71d6447e-dc10-4e35-b6c2-d8ea2c2f7596",
+        "loop": "/dev/loop25",
+        "backing": "/opt/e2e-f059/volume-files/scratch-15.ext4"
+      },
+      {
+        "name": "e2e-f059-scratch-16",
+        "uid": "272b317e-36c4-424d-a769-ba2f68ca2f3c",
+        "loop": "/dev/loop26",
+        "backing": "/opt/e2e-f059/volume-files/scratch-16.ext4"
+      },
+      {
+        "name": "e2e-f059-scratch-17",
+        "uid": "13a5b23f-9788-4b5c-b60a-4dacba3a469d",
+        "loop": "/dev/loop27",
+        "backing": "/opt/e2e-f059/volume-files/scratch-17.ext4"
+      },
+      {
+        "name": "e2e-f059-scratch-18",
+        "uid": "14e5922b-6b3c-4d94-aa02-27e5e1b2746b",
+        "loop": "/dev/loop28",
+        "backing": "/opt/e2e-f059/volume-files/scratch-18.ext4"
+      },
+      {
+        "name": "e2e-f059-scratch-19",
+        "uid": "889fd3ae-66b5-4c55-b85e-e22b1eee5bbd",
+        "loop": "/dev/loop29",
+        "backing": "/opt/e2e-f059/volume-files/scratch-19.ext4"
+      },
+      {
+        "name": "e2e-f059-scratch-20",
+        "uid": "802ef23f-dba7-45d5-908a-dcd52124aadd",
+        "loop": "/dev/loop30",
+        "backing": "/opt/e2e-f059/volume-files/scratch-20.ext4"
+      },
+      {
+        "name": "e2e-f059-scratch-21",
+        "uid": "2117459b-8f46-48dd-9e07-fd4ea1c42757",
+        "loop": "/dev/loop31",
+        "backing": "/opt/e2e-f059/volume-files/scratch-21.ext4"
+      },
+      {
+        "name": "e2e-f059-scratch-22",
+        "uid": "73a8463f-9242-4e53-837f-c70c608608ec",
+        "loop": "/dev/loop32",
+        "backing": "/opt/e2e-f059/volume-files/scratch-22.ext4"
+      },
+      {
+        "name": "e2e-f059-scratch-23",
+        "uid": "df4ffc06-1334-42d1-8abc-7d39d62278b1",
+        "loop": "/dev/loop33",
+        "backing": "/opt/e2e-f059/volume-files/scratch-23.ext4"
+      },
+      {
+        "name": "e2e-f059-scratch-24",
+        "uid": "369e60f0-e7c2-48c2-a906-8879aadf6eec",
+        "loop": "/dev/loop34",
+        "backing": "/opt/e2e-f059/volume-files/scratch-24.ext4"
+      },
+      {
+        "name": "e2e-f059-scratch-25",
+        "uid": "e4628b2e-e30e-40a0-b88b-2a6de63f34a7",
+        "loop": "/dev/loop35",
+        "backing": "/opt/e2e-f059/volume-files/scratch-25.ext4"
+      },
+      {
+        "name": "e2e-f059-scratch-26",
+        "uid": "4761adc4-ed7e-46b7-a6a9-e6abfdb1e3c1",
+        "loop": "/dev/loop36",
+        "backing": "/opt/e2e-f059/volume-files/scratch-26.ext4"
+      },
+      {
+        "name": "e2e-f059-scratch-27",
+        "uid": "31e647c9-7786-406a-ad36-75e736e9deda",
+        "loop": "/dev/loop37",
+        "backing": "/opt/e2e-f059/volume-files/scratch-27.ext4"
+      },
+      {
+        "name": "e2e-f059-scratch-28",
+        "uid": "fd90d19f-2e64-479f-9d87-58dceee92be9",
+        "loop": "/dev/loop38",
+        "backing": "/opt/e2e-f059/volume-files/scratch-28.ext4"
+      },
+      {
+        "name": "e2e-f059-scratch-29",
+        "uid": "7e6fb7c3-048d-4446-a3d2-5e2ca79b22ca",
+        "loop": "/dev/loop39",
+        "backing": "/opt/e2e-f059/volume-files/scratch-29.ext4"
+      },
+      {
+        "name": "e2e-f059-scratch-30",
+        "uid": "6e7acf93-c912-4e26-a5f5-efe656b9a0b6",
+        "loop": "/dev/loop40",
+        "backing": "/opt/e2e-f059/volume-files/scratch-30.ext4"
+      },
+      {
+        "name": "e2e-f059-scratch-31",
+        "uid": "59d9afec-40d5-47a8-bc6d-e04e67089fdb",
+        "loop": "/dev/loop41",
+        "backing": "/opt/e2e-f059/volume-files/scratch-31.ext4"
+      },
+      {
+        "name": "e2e-f059-scratch-32",
+        "uid": "37149c46-0e1f-4233-aee0-2ab1ced7ed59",
+        "loop": "/dev/loop42",
+        "backing": "/opt/e2e-f059/volume-files/scratch-32.ext4"
+      },
+      {
+        "name": "e2e-f059-scratch-33",
+        "uid": "4bfa9d42-0988-42ff-abf2-7e9e3c02a56e",
+        "loop": "/dev/loop43",
+        "backing": "/opt/e2e-f059/volume-files/scratch-33.ext4"
+      },
+      {
+        "name": "e2e-f059-scratch-34",
+        "uid": "77d890d1-18c0-4fc5-a521-461f24fade74",
+        "loop": "/dev/loop44",
+        "backing": "/opt/e2e-f059/volume-files/scratch-34.ext4"
+      },
+      {
+        "name": "e2e-f059-scratch-35",
+        "uid": "32952466-5479-460f-af3c-3b4c333854de",
+        "loop": "/dev/loop45",
+        "backing": "/opt/e2e-f059/volume-files/scratch-35.ext4"
+      },
+      {
+        "name": "e2e-f059-scratch-36",
+        "uid": "3e2a46a0-c879-418e-8489-83b83e0ecb3b",
+        "loop": "/dev/loop46",
+        "backing": "/opt/e2e-f059/volume-files/scratch-36.ext4"
+      },
+      {
+        "name": "e2e-f059-scratch-37",
+        "uid": "bb2b8023-5b0c-4523-8685-4d8347375280",
+        "loop": "/dev/loop47",
+        "backing": "/opt/e2e-f059/volume-files/scratch-37.ext4"
+      },
+      {
+        "name": "e2e-f059-scratch-38",
+        "uid": "7a1ddd0a-a997-4ae2-9000-59c83aefb6a0",
+        "loop": "/dev/loop48",
+        "backing": "/opt/e2e-f059/volume-files/scratch-38.ext4"
+      },
+      {
+        "name": "e2e-f059-scratch-39",
+        "uid": "2b3a66ba-0282-4341-907b-6aa44c5ab070",
+        "loop": "/dev/loop49",
+        "backing": "/opt/e2e-f059/volume-files/scratch-39.ext4"
+      },
+      {
+        "name": "e2e-f059-scratch-40",
+        "uid": "764102e7-ef70-4d39-902f-8a0dc585e72c",
+        "loop": "/dev/loop50",
+        "backing": "/opt/e2e-f059/volume-files/scratch-40.ext4"
+      }
+    ],
+    "boundary": "actual new PV allocation; not a host-reboot or cross-node recovery proof"
+  },
+  "post_install_0e": {
+    "source_commit": "0e493c07a8c25b85b762b5c6f95f65d2cb47d6f9",
+    "helm_revision": 11,
+    "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:b17045eb4225a5ea30a177f6b99c25ba15c6ecf25b8b3fc7fabf56687cd510ce",
+    "report_preserved": true,
+    "four_draft_owner_deletes": {
+      "allowlist_sha256": "b7b9eaf9eaf8124bdaaf8b62d4bc147a21c07b5e032145664007747d8bcaee51",
+      "events": [
+        {
+          "app_id": "1608976ca1804450b682af1176a60436",
+          "current_version_id": null,
+          "deployment_id": "de43b474a8284f98adc46d68bd77627c",
+          "generation": 0,
+          "instance_id": null,
+          "instance_phase": "stopped",
+          "name": "preflight_passed",
+          "observed_at": 1792678835.768803,
+          "operation_id": null,
+          "operations": [],
+          "slug": "e2e-f059-report-expired-54b1",
+          "state": "draft",
+          "update_time": "2026-10-22T13:31:13"
+        },
+        {
+          "app_id": "1608976ca1804450b682af1176a60436",
+          "name": "delete_attempt",
+          "observed_at": 1792678835.7704237,
+          "slug": "e2e-f059-report-expired-54b1"
+        },
+        {
+          "app_id": "1608976ca1804450b682af1176a60436",
+          "business_code": 16101,
+          "name": "deleted_and_business_404",
+          "observed_at": 1792678836.5235364,
+          "slug": "e2e-f059-report-expired-54b1"
+        },
+        {
+          "app_id": "0e2037f51e5e4250804dd1e86348d73c",
+          "current_version_id": null,
+          "deployment_id": "132c0a1ba62b4db19ff47fa119e096d5",
+          "generation": 0,
+          "instance_id": null,
+          "instance_phase": "stopped",
+          "name": "preflight_passed",
+          "observed_at": 1792678836.666171,
+          "operation_id": null,
+          "operations": [],
+          "slug": "e2e-f059-report-cni-identity-54b1",
+          "state": "draft",
+          "update_time": "2026-10-22T13:31:53"
+        },
+        {
+          "app_id": "0e2037f51e5e4250804dd1e86348d73c",
+          "name": "delete_attempt",
+          "observed_at": 1792678836.6675334,
+          "slug": "e2e-f059-report-cni-identity-54b1"
+        },
+        {
+          "app_id": "0e2037f51e5e4250804dd1e86348d73c",
+          "business_code": 16101,
+          "name": "deleted_and_business_404",
+          "observed_at": 1792678838.0393536,
+          "slug": "e2e-f059-report-cni-identity-54b1"
+        },
+        {
+          "app_id": "7e11e7a544a34b56908caebed26e8dc1",
+          "current_version_id": null,
+          "deployment_id": "f08c72c694ba48f0834e1acf632616e1",
+          "generation": 0,
+          "instance_id": null,
+          "instance_phase": "stopped",
+          "name": "preflight_passed",
+          "observed_at": 1792678838.1816847,
+          "operation_id": null,
+          "operations": [],
+          "slug": "e2e-f059-report-mandatory-54b1",
+          "state": "draft",
+          "update_time": "2026-10-22T13:33:00"
+        },
+        {
+          "app_id": "7e11e7a544a34b56908caebed26e8dc1",
+          "name": "delete_attempt",
+          "observed_at": 1792678838.1832047,
+          "slug": "e2e-f059-report-mandatory-54b1"
+        },
+        {
+          "app_id": "7e11e7a544a34b56908caebed26e8dc1",
+          "business_code": 16101,
+          "name": "deleted_and_business_404",
+          "observed_at": 1792678838.9116855,
+          "slug": "e2e-f059-report-mandatory-54b1"
+        },
+        {
+          "app_id": "ab0e7472b27f43a884a4730f5f91907a",
+          "current_version_id": null,
+          "deployment_id": "c68e7e54051345528a798a4051b15346",
+          "generation": 0,
+          "instance_id": null,
+          "instance_phase": "stopped",
+          "name": "preflight_passed",
+          "observed_at": 1792678839.0845814,
+          "operation_id": null,
+          "operations": [
+            {
+              "cleanup_status": "complete",
+              "kind": "prepare",
+              "operation_id": "517dcb55c06341c586acf6d040f0e142",
+              "stage": "failed",
+              "status": "failed"
+            }
+          ],
+          "slug": "e2e-f059-python-bundle",
+          "state": "draft",
+          "update_time": "2026-10-22T13:50:23"
+        },
+        {
+          "app_id": "ab0e7472b27f43a884a4730f5f91907a",
+          "name": "delete_attempt",
+          "observed_at": 1792678839.086627,
+          "slug": "e2e-f059-python-bundle"
+        },
+        {
+          "app_id": "ab0e7472b27f43a884a4730f5f91907a",
+          "business_code": 16101,
+          "name": "deleted_and_business_404",
+          "observed_at": 1792678839.8394039,
+          "slug": "e2e-f059-python-bundle"
+        }
+      ],
+      "execute": true,
+      "journal_sha256": "2e3ab191c90050a7fea8849409fc99bc363a57b2fbdce84d60fe4491a30c9f0f",
+      "kind": "live-api",
+      "outcome": "completed",
+      "owner_user_id": 2,
+      "schema_version": 1,
+      "tenant_code": "e2e-f059-tenant-0c6eb1",
+      "tenant_id": 2
+    },
+    "delete_receipt_sha256": "df530fbb693835dfa83604c4adc54b588b2e0e03858bffb34806b87567b93bf7",
+    "boundary": "each public GET16101 after normal owner DELETE; no force cleanup; business acceptance blocked before resources by stale controller lease"
+  },
+  "post_install_709_d86": {
+    "helm_revision": 14,
+    "descriptor_uid_unchanged": true,
+    "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:280722d842083948fe4a94b7aaf29e00a8806de9f7bae3e41ebdb7a5d1a864bd",
+    "helm_upgrade_completed": true,
+    "source_revision": "d86e66a2db83a26f0faca1993b914be121ef3259",
+    "descriptor_data_unchanged": true,
+    "helper_and_profile_images_unchanged": true,
+    "app_proxy_unchanged": true,
+    "backend_updated_source": "709821ecbe5c653aad3f90358173e0d110bee7e3",
+    "profile_revision": 2,
+    "business_evidence": "platform.json",
+    "boundary": "Control services ready and immutable descriptor retained; business E2E still in progress."
+  },
+  "candidate_143": {
+    "source_revision": "143606851370d6091b120d348549329b8113c6a6",
+    "native_images": {
+      "schema": "f059-native-manager-helper/v1",
+      "source_revision": "143606851370d6091b120d348549329b8113c6a6",
+      "platform": "linux/amd64",
+      "build_exit": 0,
+      "import_smoke_exit": 0,
+      "helper_null_lock_smoke_exit": 0,
+      "images": {
+        "manager": {
+          "tag": "e2e-f059/bisheng-runtime-manager:e2e-f059-native-20261023-143606851-amd64",
+          "image_id": "sha256:727a725ed148acede43aaf4052f923eda229498d5e4a83db2cd6946562140b18",
+          "native_smoke": true
+        },
+        "helper": {
+          "tag": "e2e-f059/bisheng-runtime-helper:e2e-f059-native-20261023-143606851-amd64",
+          "image_id": "sha256:63c1243998c8ca303e59f2bed080ca70b4e7938914849dc77c7b0cef85d6ff35",
+          "native_smoke": true
+        }
+      },
+      "public_push": false,
+      "profile_images_unchanged": true,
+      "preceding_full_bundle_validation_source": "709821ecbe5c653aad3f90358173e0d110bee7e3",
+      "boundary": "Native manager/helper build and real empty-dependency resolution only. No new ARM, CI public release or full business claim."
+    },
+    "helm": {
+      "helm_upgrade_completed": true,
+      "source_revision": "143606851370d6091b120d348549329b8113c6a6",
+      "app_proxy_profile_assembler_unchanged": true,
+      "probe_report_requires_real_refresh": true,
+      "helper_image": "192.168.106.189:5443/f059/runtime-helper@sha256:8a8f92c41e85fdb12e236ba60e37eccc5da2d3462c7ac75a99d5f35d6a83a049",
+      "descriptor_only_helper_changed": true,
+      "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:a537fc0b647d6213c63d30142c502bbc0befe993eec0965c9a735dd7d204cd61",
+      "descriptor_uid_unchanged": true
+    },
+    "first_probe": {
+      "artifacts": {
+        "image-cri": [
+          {
+            "image_id": "docker-pullable://192.168.106.189:5443/f059/runtime-helper@sha256:8a8f92c41e85fdb12e236ba60e37eccc5da2d3462c7ac75a99d5f35d6a83a049",
+            "image_ref": "192.168.106.189:5443/f059/runtime-helper@sha256:8a8f92c41e85fdb12e236ba60e37eccc5da2d3462c7ac75a99d5f35d6a83a049",
+            "platform": "linux/amd64",
+            "resource_version": "3183159",
+            "uid": "1bcf337c-171b-443f-bfcf-bd4d925dbab7"
+          },
+          {
+            "image_id": "docker-pullable://192.168.106.189:5443/f059/app-python-ca@sha256:b632d5192bd370c5690aee2c2cc192954522dd0cd44ab0f5c8be050a3e033263",
+            "image_ref": "192.168.106.189:5443/f059/app-python-ca@sha256:b632d5192bd370c5690aee2c2cc192954522dd0cd44ab0f5c8be050a3e033263",
+            "platform": "linux/amd64",
+            "resource_version": "3183239",
+            "uid": "f136f456-f0f6-48d1-a201-f13f55ca804f"
+          },
+          {
+            "image_id": "docker-pullable://192.168.106.189:5443/f059/app-python-preparer-ca@sha256:7d4d46ca19fa0a5ae71f177f81a45df58dc3d3a676d4c536615a9376673d55e9",
+            "image_ref": "192.168.106.189:5443/f059/app-python-preparer-ca@sha256:7d4d46ca19fa0a5ae71f177f81a45df58dc3d3a676d4c536615a9376673d55e9",
+            "platform": "linux/amd64",
+            "resource_version": "3183299",
+            "uid": "c8004403-c0d2-4e1b-8843-86eb5cc75134"
+          },
+          {
+            "image_id": "docker-pullable://192.168.106.189:5443/f059/app-node-ca@sha256:533c8eb5040441e9420969ff0b305b45572ea58b80d345c27893dd9edc4a2763",
+            "image_ref": "192.168.106.189:5443/f059/app-node-ca@sha256:533c8eb5040441e9420969ff0b305b45572ea58b80d345c27893dd9edc4a2763",
+            "platform": "linux/amd64",
+            "resource_version": "3183355",
+            "uid": "41ce196a-9e9d-4f0f-9ef1-aec30656a21e"
+          },
+          {
+            "image_id": "docker-pullable://192.168.106.189:5443/f059/app-node-preparer-ca@sha256:27b13ce9a57c59f9724001d5a69ede5029a6c6709a702a55fba435a77f77e666",
+            "image_ref": "192.168.106.189:5443/f059/app-node-preparer-ca@sha256:27b13ce9a57c59f9724001d5a69ede5029a6c6709a702a55fba435a77f77e666",
+            "platform": "linux/amd64",
+            "resource_version": "3183415",
+            "uid": "09780c1c-5217-4414-9845-8ed4a455da23"
+          },
+          {
+            "image_id": "docker-pullable://192.168.106.189:5443/f059/app-static-ca@sha256:2f0311da8b281f35437fcf26b47c8249f8d86f98227a28bb231cd472071ee218",
+            "image_ref": "192.168.106.189:5443/f059/app-static-ca@sha256:2f0311da8b281f35437fcf26b47c8249f8d86f98227a28bb231cd472071ee218",
+            "platform": "linux/amd64",
+            "resource_version": "3183475",
+            "uid": "a68d84ee-1b11-49e9-8f6a-a633951931d6"
+          }
+        ]
+      },
+      "checks": [
+        {
+          "evidence": {
+            "uid": "1bcf337c-171b-443f-bfcf-bd4d925dbab7"
+          },
+          "name": "admission-create",
+          "observed_at": 1792688328.519218,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "uid": "41d59176-f456-4bd2-bf09-bf40688ec99b"
+          },
+          "name": "binding-delete-fence",
+          "observed_at": 1792688328.519218,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "digest": "sha256:8a8f92c41e85fdb12e236ba60e37eccc5da2d3462c7ac75a99d5f35d6a83a049",
+            "uid": "1bcf337c-171b-443f-bfcf-bd4d925dbab7"
+          },
+          "name": "image-cri",
+          "observed_at": 1792688328.519218,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "code": "control_side_tls",
+            "uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b"
+          },
+          "name": "material-tls",
+          "observed_at": 1792688328.519218,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b"
+          },
+          "name": "namespace-budget",
+          "observed_at": 1792688328.519218,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "uid": "fba43a16-bc46-453d-a319-5ccbf562be5a"
+          },
+          "name": "network-direct-denied",
+          "observed_at": 1792688328.519218,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "uid": "5674dbec-51c0-4b7b-9500-8a50f9933174"
+          },
+          "name": "network-dns-denied",
+          "observed_at": 1792688328.519218,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "uid": "7eb95ace-7fda-49e8-9c06-eb7aad8da40b"
+          },
+          "name": "network-egress-allowed",
+          "observed_at": 1792688328.519218,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "uid": "1bcf337c-171b-443f-bfcf-bd4d925dbab7"
+          },
+          "name": "network-ingress",
+          "observed_at": 1792688328.519218,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "uid": "ffeedf5f-6001-477a-881a-67c33432248c"
+          },
+          "name": "no-exec",
+          "observed_at": 1792688328.519218,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "code": "control_side_authenticated_api",
+            "uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b"
+          },
+          "name": "platform-capabilities",
+          "observed_at": 1792688328.519218,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "uid": "ffeedf5f-6001-477a-881a-67c33432248c"
+          },
+          "name": "rbac-namespace",
+          "observed_at": 1792688328.519218,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "digest": "sha256:8a8f92c41e85fdb12e236ba60e37eccc5da2d3462c7ac75a99d5f35d6a83a049",
+            "uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b"
+          },
+          "name": "registry-push",
+          "observed_at": 1792688328.519218,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "uid": "1bcf337c-171b-443f-bfcf-bd4d925dbab7"
+          },
+          "name": "restricted-psa",
+          "observed_at": 1792688328.519218,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "policy_revision": "v1",
+            "uid": "1bcf337c-171b-443f-bfcf-bd4d925dbab7"
+          },
+          "name": "status-provenance",
+          "observed_at": 1792688328.519218,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {},
+          "exception_type": "ProbeError",
+          "name": "storage-sqlite",
+          "observed_at": 1792688328.519218,
+          "ok": false,
+          "reason": "probe_deadline_exceeded"
+        }
+      ],
+      "descriptor_digest": "d7e831157cd56ff2a941118b9deafb2c2f62b572c633428efb4412839a337afd",
+      "observed_at": 1792688328.519218,
+      "source": "cluster-probe"
+    },
+    "first_probe_result": "15 of16 pass; storage-sqlite deadline due all existing preflight PVs Released; canaries cleaned normally",
+    "preflight_pool_added": {
+      "count": 4,
+      "capacity_each": "1Gi",
+      "names": [
+        "e2e-f059-preflight-04",
+        "e2e-f059-preflight-05",
+        "e2e-f059-preflight-06",
+        "e2e-f059-preflight-07"
+      ],
+      "inventory_sha256": "35f57e5fb4e4a49df8b12862c9e66da90bcab0e88e797a988e7ef9aa473dddf2",
+      "original19_unchanged_during_apply": true,
+      "existing_loop_mappings_unchanged": true,
+      "retention": "Retain; no old-volume rebind or wipe",
+      "initial_plan_refusal": "scratch06 marker stat changed from uid0/gid0/0444 to actual uid0/gid10001/0664; new exact observed snapshot made, old snapshot/volume unchanged"
+    },
+    "second_probe": {
+      "artifacts": {
+        "image-cri": [
+          {
+            "image_id": "docker-pullable://192.168.106.189:5443/f059/runtime-helper@sha256:8a8f92c41e85fdb12e236ba60e37eccc5da2d3462c7ac75a99d5f35d6a83a049",
+            "image_ref": "192.168.106.189:5443/f059/runtime-helper@sha256:8a8f92c41e85fdb12e236ba60e37eccc5da2d3462c7ac75a99d5f35d6a83a049",
+            "platform": "linux/amd64",
+            "resource_version": "3184803",
+            "uid": "77a25d8b-9cad-459a-a889-f964c9efbde4"
+          },
+          {
+            "image_id": "docker-pullable://192.168.106.189:5443/f059/app-python-ca@sha256:b632d5192bd370c5690aee2c2cc192954522dd0cd44ab0f5c8be050a3e033263",
+            "image_ref": "192.168.106.189:5443/f059/app-python-ca@sha256:b632d5192bd370c5690aee2c2cc192954522dd0cd44ab0f5c8be050a3e033263",
+            "platform": "linux/amd64",
+            "resource_version": "3184885",
+            "uid": "0959acdd-5f2c-450b-a1ea-e25fcfb9aab5"
+          },
+          {
+            "image_id": "docker-pullable://192.168.106.189:5443/f059/app-python-preparer-ca@sha256:7d4d46ca19fa0a5ae71f177f81a45df58dc3d3a676d4c536615a9376673d55e9",
+            "image_ref": "192.168.106.189:5443/f059/app-python-preparer-ca@sha256:7d4d46ca19fa0a5ae71f177f81a45df58dc3d3a676d4c536615a9376673d55e9",
+            "platform": "linux/amd64",
+            "resource_version": "3184943",
+            "uid": "0c58309a-8f71-4d90-8a85-9dc3a63c58e7"
+          },
+          {
+            "image_id": "docker-pullable://192.168.106.189:5443/f059/app-node-ca@sha256:533c8eb5040441e9420969ff0b305b45572ea58b80d345c27893dd9edc4a2763",
+            "image_ref": "192.168.106.189:5443/f059/app-node-ca@sha256:533c8eb5040441e9420969ff0b305b45572ea58b80d345c27893dd9edc4a2763",
+            "platform": "linux/amd64",
+            "resource_version": "3185002",
+            "uid": "5f17375e-5987-439c-a81f-9fd2686a4c33"
+          },
+          {
+            "image_id": "docker-pullable://192.168.106.189:5443/f059/app-node-preparer-ca@sha256:27b13ce9a57c59f9724001d5a69ede5029a6c6709a702a55fba435a77f77e666",
+            "image_ref": "192.168.106.189:5443/f059/app-node-preparer-ca@sha256:27b13ce9a57c59f9724001d5a69ede5029a6c6709a702a55fba435a77f77e666",
+            "platform": "linux/amd64",
+            "resource_version": "3185059",
+            "uid": "5d0740c2-47e9-41ee-b23e-c4b4cd9d6643"
+          },
+          {
+            "image_id": "docker-pullable://192.168.106.189:5443/f059/app-static-ca@sha256:2f0311da8b281f35437fcf26b47c8249f8d86f98227a28bb231cd472071ee218",
+            "image_ref": "192.168.106.189:5443/f059/app-static-ca@sha256:2f0311da8b281f35437fcf26b47c8249f8d86f98227a28bb231cd472071ee218",
+            "platform": "linux/amd64",
+            "resource_version": "3185112",
+            "uid": "ad2059f6-a69b-417e-9d09-d6b6b440ae4d"
+          }
+        ]
+      },
+      "checks": [
+        {
+          "evidence": {
+            "uid": "77a25d8b-9cad-459a-a889-f964c9efbde4"
+          },
+          "name": "admission-create",
+          "observed_at": 1792689184.1286888,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "uid": "e86a13de-4dba-4630-8d34-b7bde1fa95ce"
+          },
+          "name": "binding-delete-fence",
+          "observed_at": 1792689184.1286888,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "digest": "sha256:8a8f92c41e85fdb12e236ba60e37eccc5da2d3462c7ac75a99d5f35d6a83a049",
+            "uid": "77a25d8b-9cad-459a-a889-f964c9efbde4"
+          },
+          "name": "image-cri",
+          "observed_at": 1792689184.1286888,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "code": "control_side_tls",
+            "uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b"
+          },
+          "name": "material-tls",
+          "observed_at": 1792689184.1286888,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b"
+          },
+          "name": "namespace-budget",
+          "observed_at": 1792689184.1286888,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "uid": "efde81a0-2f56-4623-9cf6-4965be508a50"
+          },
+          "name": "network-direct-denied",
+          "observed_at": 1792689184.1286888,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "uid": "672faea6-38c7-4267-a5d2-0df8b9007f84"
+          },
+          "name": "network-dns-denied",
+          "observed_at": 1792689184.1286888,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "uid": "9ef1a390-4bf4-4f40-9dfe-58c2ce22b68f"
+          },
+          "name": "network-egress-allowed",
+          "observed_at": 1792689184.1286888,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "uid": "77a25d8b-9cad-459a-a889-f964c9efbde4"
+          },
+          "name": "network-ingress",
+          "observed_at": 1792689184.1286888,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "uid": "ffeedf5f-6001-477a-881a-67c33432248c"
+          },
+          "name": "no-exec",
+          "observed_at": 1792689184.1286888,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "code": "control_side_authenticated_api",
+            "uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b"
+          },
+          "name": "platform-capabilities",
+          "observed_at": 1792689184.1286888,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "uid": "ffeedf5f-6001-477a-881a-67c33432248c"
+          },
+          "name": "rbac-namespace",
+          "observed_at": 1792689184.1286888,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "digest": "sha256:8a8f92c41e85fdb12e236ba60e37eccc5da2d3462c7ac75a99d5f35d6a83a049",
+            "uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b"
+          },
+          "name": "registry-push",
+          "observed_at": 1792689184.1286888,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "uid": "77a25d8b-9cad-459a-a889-f964c9efbde4"
+          },
+          "name": "restricted-psa",
+          "observed_at": 1792689184.1286888,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "policy_revision": "v1",
+            "uid": "77a25d8b-9cad-459a-a889-f964c9efbde4"
+          },
+          "name": "status-provenance",
+          "observed_at": 1792689184.1286888,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "uid": "12e4f5eb-e512-4549-8395-eb9620c281d8"
+          },
+          "name": "storage-sqlite",
+          "observed_at": 1792689184.1286888,
+          "ok": true,
+          "reason": "verified"
+        }
+      ],
+      "descriptor_digest": "d7e831157cd56ff2a941118b9deafb2c2f62b572c633428efb4412839a337afd",
+      "observed_at": 1792689184.1286888,
+      "source": "cluster-probe"
+    },
+    "state": "passed",
+    "arm_validation": "paused_by_user",
+    "public_release": false
+  },
+  "candidate_7816_manager_only": {
+    "source_revision": "7816e0bb87cd1b9ea0c82e8c01283cdf2e821a13",
+    "native": {
+      "build_exit": 0,
+      "import_smoke_exit": 0,
+      "public_push": false,
+      "manager_image_id": "sha256:ec08599a64325be5b8dac8c7fe31885325ed7e1c586a098b29c824a979702184",
+      "platform": "linux/amd64",
+      "source_revision": "7816e0bb87cd1b9ea0c82e8c01283cdf2e821a13",
+      "preceding_full_bundle_validation_source": "709821ecbe5c653aad3f90358173e0d110bee7e3",
+      "changed_runtime_files": [
+        "runtime_manager/operations.py"
+      ],
+      "boundary": "Native manager build/import only; preceding bundle/SQLite smoke retained separately. No arm/CI public release claim.",
+      "preceding_manager_source": "143606851370d6091b120d348549329b8113c6a6",
+      "schema": "f059-native-manager/v1"
+    },
+    "image": {
+      "manager_image_id": "sha256:ec08599a64325be5b8dac8c7fe31885325ed7e1c586a098b29c824a979702184",
+      "source_revision": "7816e0bb87cd1b9ea0c82e8c01283cdf2e821a13",
+      "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:3e0bf023a016b3f486bd5fe5cbcae85d89cc8737daaa9166f926291ec7781de5",
+      "public_push": false,
+      "private_tag": "192.168.106.189:5443/f059/runtime-manager:20261023-19"
+    },
+    "helm": {
+      "descriptor_uid_unchanged": true,
+      "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:3e0bf023a016b3f486bd5fe5cbcae85d89cc8737daaa9166f926291ec7781de5",
+      "helm_upgrade_completed": true,
+      "source_revision": "7816e0bb87cd1b9ea0c82e8c01283cdf2e821a13",
+      "descriptor_data_unchanged": true,
+      "helper_and_profile_images_unchanged": true,
+      "app_proxy_unchanged": true
+    },
+    "helm_revision": 17,
+    "new_full_probe_run": false,
+    "retained_report_source": "143606851370d6091b120d348549329b8113c6a6",
+    "reason": "Only operations.py changed; helper, profile and descriptor/report bytes unchanged; new image assess_capabilities ready verified",
+    "backend_node_ready": true,
+    "controller_epoch": 11
+  },
+  "candidate_a1de_manager_only": {
+    "source_revision": "a1de5271f5f00cef0cba06d41f09c673a9190525",
+    "native": {
+      "build_exit": 0,
+      "import_smoke_exit": 0,
+      "public_push": false,
+      "manager_image_id": "sha256:32e65744a296d2997f2e3395a6c8a78a035109854bb25beff05cc024a398fd6f",
+      "platform": "linux/amd64",
+      "source_revision": "a1de5271f5f00cef0cba06d41f09c673a9190525",
+      "preceding_full_bundle_validation_source": "709821ecbe5c653aad3f90358173e0d110bee7e3",
+      "changed_runtime_files": [
+        "runtime_manager/executors/kubernetes.py",
+        "runtime_manager/k8s_data_executor.py"
+      ],
+      "boundary": "Native manager build/import only; preceding bundle/SQLite smoke retained separately. No arm/CI public release claim.",
+      "preceding_manager_source": "7816e0bb87cd1b9ea0c82e8c01283cdf2e821a13",
+      "schema": "f059-native-manager/v1"
+    },
+    "image": {
+      "manager_image_id": "sha256:32e65744a296d2997f2e3395a6c8a78a035109854bb25beff05cc024a398fd6f",
+      "source_revision": "a1de5271f5f00cef0cba06d41f09c673a9190525",
+      "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:06bb857f4caa79499a844d16838831c7429dfc7d499e65649f314d96fd390cad",
+      "public_push": false,
+      "private_tag": "192.168.106.189:5443/f059/runtime-manager:20261023-20"
+    },
+    "helm": {
+      "descriptor_uid_unchanged": true,
+      "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:06bb857f4caa79499a844d16838831c7429dfc7d499e65649f314d96fd390cad",
+      "helm_upgrade_completed": true,
+      "source_revision": "a1de5271f5f00cef0cba06d41f09c673a9190525",
+      "descriptor_data_unchanged": true,
+      "helper_and_profile_images_unchanged": true,
+      "app_proxy_unchanged": true
+    },
+    "helm_revision": 18,
+    "new_full_probe_run": false,
+    "retained_report_source": "143606851370d6091b120d348549329b8113c6a6",
+    "reason": "Only manager startup/data RPC code and two backend cancellation gates changed; helper/profile/descriptor/report bytes unchanged; fresh capability report accepted",
+    "backend_node_ready": true,
+    "controller_epoch": 13
+  },
+  "current_candidate_history": [
+    {
+      "source_commit": "54b3ea2c15836529cc24667d9a58be49e35bf710",
+      "manager_egress_admission_image": "192.168.106.189:5443/f059/runtime-manager@sha256:26f61e7ba98726d3c78ade8236ef78d09567583282bf2292f911af04b2a4b7f1",
+      "helm_revision": 10,
+      "probe_report_after_upgrade": "fifth actual probe and public platform installation diagnostic passed"
+    },
+    {
+      "source_commit": "a06b281b2ada8cfa15cb79c29fad0044408e4db9",
+      "manager_egress_admission_image": "192.168.106.189:5443/f059/runtime-manager@sha256:4666d75388bde2ae7c1b0b73916442451b4119a7384852cf373633e8489dfe32",
+      "helm_revision": 19,
+      "probe_report_after_upgrade": "143 actual sixteen-check report retained byte-identically; current manager assess_capabilities ready accepted before r9; no new full sixteen-check claim",
+      "upgrade_receipt": "/opt/e2e-f059/evidence/runtime-twenty-first/receipt.json",
+      "descriptor_uid_unchanged": true,
+      "descriptor_data_unchanged": true
+    },
+    {
+      "source_commit": "249d5fc85b2ed08db88c93b7676ced73fdb8ee7b",
+      "manager_egress_admission_image": "192.168.106.189:5443/f059/runtime-manager@sha256:42c6c3c5ba9adb6f6340d0de979e9896d32e81f205f44851c492f50ea4fd7209",
+      "helm_revision": 20,
+      "probe_report_after_upgrade": "143 sixteen-check report retained with unchanged descriptor/helper/profile; no new full report claim",
+      "upgrade_receipt": "/opt/e2e-f059/evidence/runtime-twenty-second/receipt.json",
+      "descriptor_uid_unchanged": true,
+      "descriptor_data_unchanged": true,
+      "backend_node_ready": true,
+      "controller_epoch": 15
+    },
+    {
+      "source_commit": "d7c0cc4fd4cd17c76b742902039846a789c7510b",
+      "manager_egress_admission_image": "192.168.106.189:5443/f059/runtime-manager@sha256:b2288f642fbabc7e2ada1ab2fd4d9bd78a6b2ce96c24d59bb51c4d0044f34c82",
+      "helm_revision": 21,
+      "probe_report_after_upgrade": "143 sixteen-check report retained with unchanged descriptor/helper/profile; no new full report claim",
+      "upgrade_receipt": "/opt/e2e-f059/evidence/runtime-twenty-third/receipt.json",
+      "descriptor_uid_unchanged": true,
+      "descriptor_data_unchanged": true,
+      "backend_node_ready": true,
+      "controller_epoch": 16
+    },
+    {
+      "source_commit": "6f254f337dd6e8a36733501a2266bf08a04a56e9",
+      "manager_egress_admission_image": "192.168.106.189:5443/f059/runtime-manager@sha256:59692b82ae7bd1d806e741a4c86f165bd472301feec31b06d3dc66306d00eb3b",
+      "helm_revision": 22,
+      "probe_report_after_upgrade": "143 sixteen-check report retained with unchanged descriptor/helper/profile; no new full report claim",
+      "upgrade_receipt": "/opt/e2e-f059/evidence/runtime-twenty-fourth/receipt.json",
+      "descriptor_uid_unchanged": true,
+      "descriptor_data_unchanged": true,
+      "backend_node_ready": true,
+      "controller_epoch": 17,
+      "backend_data_cpu_update": {
+        "files": {
+          "bisheng/app_runtime/domain/services/runtime_data_session_service.py": "ca6eb28964b717f0744ae2db4d5ab11ec2541563b40e49035be50e49fb471626",
+          "bisheng/app_runtime/domain/repositories/runtime_delivery_repository.py": "e74775a5837014f0c608a1efd306b19d021d2a11de54d2e95a7b944c9df6b0ef"
+        },
+        "backend_and_worker_restarted": true,
+        "source_revision": "6f254f337dd6e8a36733501a2266bf08a04a56e9",
+        "openfga_unchanged": true,
+        "public_env_http_status": 200
+      }
+    },
+    {
+      "source_commit": "995c80f2a1d7288a2e5b9e7dc650f7ce1d911798",
+      "manager_egress_admission_image": "192.168.106.189:5443/f059/runtime-manager@sha256:7cef8910e89e566059b1fd04cf3bde26fdd9b2f70d61185b0ae621f7b40409e7",
+      "helm_revision": 23,
+      "probe_report_after_upgrade": "143 sixteen-check report retained with unchanged descriptor/helper/profile; no new full report claim",
+      "upgrade_receipt": "/opt/e2e-f059/evidence/runtime-twenty-fifth/receipt.json",
+      "descriptor_uid_unchanged": true,
+      "descriptor_data_unchanged": true,
+      "backend_node_ready": true,
+      "controller_epoch": 18,
+      "backend_data_cpu_update": {
+        "files": {
+          "bisheng/app_runtime/domain/services/runtime_data_session_service.py": "ca6eb28964b717f0744ae2db4d5ab11ec2541563b40e49035be50e49fb471626",
+          "bisheng/app_runtime/domain/repositories/runtime_delivery_repository.py": "e74775a5837014f0c608a1efd306b19d021d2a11de54d2e95a7b944c9df6b0ef"
+        },
+        "backend_and_worker_restarted": true,
+        "source_revision": "6f254f337dd6e8a36733501a2266bf08a04a56e9",
+        "openfga_unchanged": true,
+        "public_env_http_status": 200
+      }
+    },
+    {
+      "source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+      "helm_revision": 24,
+      "images": {
+        "manager": {
+          "image_id": "sha256:c3a5255e893973935e26c7f48adcc218f0bb14bd4b522fd8eaea75a35aabc659",
+          "image": "192.168.106.189:5443/f059/runtime-manager@sha256:28449f9c96c2c60f1b1432a2f671b25434022e9e8675ea53aece36a823deee99",
+          "native_smoke": true,
+          "source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+          "private_tag": "192.168.106.189:5443/f059/runtime-manager:20261023-26"
+        },
+        "assembler-public-ca": {
+          "image_id": "sha256:ddc11511399ae6f3f9689a076a8cc6bb5c6b8db34bb194d892603fe20264f19e",
+          "image": "192.168.106.189:5443/f059/runtime-assembler-ca@sha256:9836c13405df4e4b613712a2e933a73e755d22daeb79d51d6adefc242971c6b9",
+          "native_smoke": true,
+          "source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+          "private_tag": "192.168.106.189:5443/f059/runtime-assembler-ca:20261023-26"
+        },
+        "helper": {
+          "image_id": "sha256:9cd5d3e1331f4b8c81c6335c4e3034e1eb9c63b94f1968e9dc4357180c2296a9",
+          "image": "192.168.106.189:5443/f059/runtime-helper@sha256:5906c51814e24a63356dc69fb5c1dd332bb4eb91b70cf65d41594aefc0841c85",
+          "native_smoke": true,
+          "source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+          "private_tag": "192.168.106.189:5443/f059/runtime-helper:20261023-26"
+        }
+      },
+      "native_three_services": true,
+      "descriptor_changes": [
+        "helper_image",
+        "assembler_image"
+      ],
+      "descriptor_uid_unchanged": true,
+      "old_report_negative": {
+        "source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+        "descriptor_digest": "f306a49e2e396c60c91e76672c72dfc9989763fd5d7dcb091257009f70b8f456",
+        "old_report_digest": "d7e831157cd56ff2a941118b9deafb2c2f62b572c633428efb4412839a337afd",
+        "ready": false,
+        "paths": {
+          "bundle": false,
+          "image": false,
+          "source-image": false
+        },
+        "warnings": [
+          "node_local_storage",
+          "live_probe_report_missing_stale_or_changed"
+        ],
+        "backend_node_status": "blocked",
+        "controller_epoch": 19,
+        "old_report_rejected": true
+      },
+      "report": {
+        "artifacts": {
+          "image-cri": [
+            {
+              "image_id": "docker-pullable://192.168.106.189:5443/f059/runtime-helper@sha256:5906c51814e24a63356dc69fb5c1dd332bb4eb91b70cf65d41594aefc0841c85",
+              "image_ref": "192.168.106.189:5443/f059/runtime-helper@sha256:5906c51814e24a63356dc69fb5c1dd332bb4eb91b70cf65d41594aefc0841c85",
+              "platform": "linux/amd64",
+              "resource_version": "3210500",
+              "uid": "647df6e5-68b6-4780-9a61-726f7ab0baa5"
+            },
+            {
+              "image_id": "docker-pullable://192.168.106.189:5443/f059/app-python-ca@sha256:b632d5192bd370c5690aee2c2cc192954522dd0cd44ab0f5c8be050a3e033263",
+              "image_ref": "192.168.106.189:5443/f059/app-python-ca@sha256:b632d5192bd370c5690aee2c2cc192954522dd0cd44ab0f5c8be050a3e033263",
+              "platform": "linux/amd64",
+              "resource_version": "3210580",
+              "uid": "59700080-23f0-4f62-b936-1eb55c204619"
+            },
+            {
+              "image_id": "docker-pullable://192.168.106.189:5443/f059/app-python-preparer-ca@sha256:7d4d46ca19fa0a5ae71f177f81a45df58dc3d3a676d4c536615a9376673d55e9",
+              "image_ref": "192.168.106.189:5443/f059/app-python-preparer-ca@sha256:7d4d46ca19fa0a5ae71f177f81a45df58dc3d3a676d4c536615a9376673d55e9",
+              "platform": "linux/amd64",
+              "resource_version": "3210640",
+              "uid": "13e7a9bd-e028-4d41-92d3-85f9ac10231c"
+            },
+            {
+              "image_id": "docker-pullable://192.168.106.189:5443/f059/app-node-ca@sha256:533c8eb5040441e9420969ff0b305b45572ea58b80d345c27893dd9edc4a2763",
+              "image_ref": "192.168.106.189:5443/f059/app-node-ca@sha256:533c8eb5040441e9420969ff0b305b45572ea58b80d345c27893dd9edc4a2763",
+              "platform": "linux/amd64",
+              "resource_version": "3210699",
+              "uid": "17cb59e9-853c-4fde-a836-b3b68ffdb78b"
+            },
+            {
+              "image_id": "docker-pullable://192.168.106.189:5443/f059/app-node-preparer-ca@sha256:27b13ce9a57c59f9724001d5a69ede5029a6c6709a702a55fba435a77f77e666",
+              "image_ref": "192.168.106.189:5443/f059/app-node-preparer-ca@sha256:27b13ce9a57c59f9724001d5a69ede5029a6c6709a702a55fba435a77f77e666",
+              "platform": "linux/amd64",
+              "resource_version": "3210762",
+              "uid": "f7c5cd7e-aa0a-4d6d-a376-056cc3d75760"
+            },
+            {
+              "image_id": "docker-pullable://192.168.106.189:5443/f059/app-static-ca@sha256:2f0311da8b281f35437fcf26b47c8249f8d86f98227a28bb231cd472071ee218",
+              "image_ref": "192.168.106.189:5443/f059/app-static-ca@sha256:2f0311da8b281f35437fcf26b47c8249f8d86f98227a28bb231cd472071ee218",
+              "platform": "linux/amd64",
+              "resource_version": "3210823",
+              "uid": "5c70978a-c45c-491e-94a6-a7c00df3ffe0"
+            }
+          ]
+        },
+        "checks": [
+          {
+            "evidence": {
+              "uid": "647df6e5-68b6-4780-9a61-726f7ab0baa5"
+            },
+            "name": "admission-create",
+            "observed_at": 1792705007.306199,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "uid": "69ee7246-6a93-4b57-8d1d-85dc28d6f9a2"
+            },
+            "name": "binding-delete-fence",
+            "observed_at": 1792705007.306199,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "digest": "sha256:5906c51814e24a63356dc69fb5c1dd332bb4eb91b70cf65d41594aefc0841c85",
+              "uid": "647df6e5-68b6-4780-9a61-726f7ab0baa5"
+            },
+            "name": "image-cri",
+            "observed_at": 1792705007.306199,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "code": "control_side_tls",
+              "uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b"
+            },
+            "name": "material-tls",
+            "observed_at": 1792705007.306199,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b"
+            },
+            "name": "namespace-budget",
+            "observed_at": 1792705007.306199,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "uid": "fa3704cd-5fab-4599-9544-a01c2df6894c"
+            },
+            "name": "network-direct-denied",
+            "observed_at": 1792705007.306199,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "uid": "96eee51b-be86-4850-a5a4-0cf4e1e4e413"
+            },
+            "name": "network-dns-denied",
+            "observed_at": 1792705007.306199,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "uid": "65176307-19f1-4fc9-9a84-c36062d6fbc7"
+            },
+            "name": "network-egress-allowed",
+            "observed_at": 1792705007.306199,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "uid": "647df6e5-68b6-4780-9a61-726f7ab0baa5"
+            },
+            "name": "network-ingress",
+            "observed_at": 1792705007.306199,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "uid": "ffeedf5f-6001-477a-881a-67c33432248c"
+            },
+            "name": "no-exec",
+            "observed_at": 1792705007.306199,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "code": "control_side_authenticated_api",
+              "uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b"
+            },
+            "name": "platform-capabilities",
+            "observed_at": 1792705007.306199,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "uid": "ffeedf5f-6001-477a-881a-67c33432248c"
+            },
+            "name": "rbac-namespace",
+            "observed_at": 1792705007.306199,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "digest": "sha256:5906c51814e24a63356dc69fb5c1dd332bb4eb91b70cf65d41594aefc0841c85",
+              "uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b"
+            },
+            "name": "registry-push",
+            "observed_at": 1792705007.306199,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "uid": "647df6e5-68b6-4780-9a61-726f7ab0baa5"
+            },
+            "name": "restricted-psa",
+            "observed_at": 1792705007.306199,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "policy_revision": "v1",
+              "uid": "647df6e5-68b6-4780-9a61-726f7ab0baa5"
+            },
+            "name": "status-provenance",
+            "observed_at": 1792705007.306199,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "uid": "e12512c5-ecf2-4bec-a703-7204eebcc37e"
+            },
+            "name": "storage-sqlite",
+            "observed_at": 1792705007.306199,
+            "ok": true,
+            "reason": "verified"
+          }
+        ],
+        "descriptor_digest": "f306a49e2e396c60c91e76672c72dfc9989763fd5d7dcb091257009f70b8f456",
+        "observed_at": 1792705007.306199,
+        "source": "cluster-probe"
+      },
+      "report_sha256": "6720eaf4938302d5ad5c7e01b8b1972627dbf5f7c4b1e92d5db83ec838e92ec6",
+      "state": "passed",
+      "backend_node_state": "ready",
+      "controller_epoch": 19,
+      "next_candidate": "d7f7c2e10ab9805d41120d19e32d102310b06654",
+      "next_candidate_installation": "pending"
+    },
+    {
+      "source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+      "corrective_commit": "20175cad9cb453734d42ee8a03c0be0594a6f3a0",
+      "all_build_inputs_equal": true,
+      "helm_revision": 26,
+      "images": {
+        "manager": {
+          "image_id": "sha256:c3a5255e893973935e26c7f48adcc218f0bb14bd4b522fd8eaea75a35aabc659",
+          "image": "192.168.106.189:5443/f059/runtime-manager@sha256:28449f9c96c2c60f1b1432a2f671b25434022e9e8675ea53aece36a823deee99",
+          "native_smoke": true,
+          "source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+          "private_tag": "192.168.106.189:5443/f059/runtime-manager:20261023-26"
+        },
+        "assembler-public-ca": {
+          "image_id": "sha256:ddc11511399ae6f3f9689a076a8cc6bb5c6b8db34bb194d892603fe20264f19e",
+          "image": "192.168.106.189:5443/f059/runtime-assembler-ca@sha256:9836c13405df4e4b613712a2e933a73e755d22daeb79d51d6adefc242971c6b9",
+          "native_smoke": true,
+          "source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+          "private_tag": "192.168.106.189:5443/f059/runtime-assembler-ca:20261023-26"
+        },
+        "helper": {
+          "image_id": "sha256:9cd5d3e1331f4b8c81c6335c4e3034e1eb9c63b94f1968e9dc4357180c2296a9",
+          "image": "192.168.106.189:5443/f059/runtime-helper@sha256:5906c51814e24a63356dc69fb5c1dd332bb4eb91b70cf65d41594aefc0841c85",
+          "native_smoke": true,
+          "source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+          "private_tag": "192.168.106.189:5443/f059/runtime-helper:20261023-26"
+        }
+      },
+      "descriptor_uid_unchanged": true,
+      "descriptor_digest": "f306a49e2e396c60c91e76672c72dfc9989763fd5d7dcb091257009f70b8f456",
+      "report": {
+        "artifacts": {
+          "image-cri": [
+            {
+              "image_id": "docker-pullable://192.168.106.189:5443/f059/runtime-helper@sha256:5906c51814e24a63356dc69fb5c1dd332bb4eb91b70cf65d41594aefc0841c85",
+              "image_ref": "192.168.106.189:5443/f059/runtime-helper@sha256:5906c51814e24a63356dc69fb5c1dd332bb4eb91b70cf65d41594aefc0841c85",
+              "platform": "linux/amd64",
+              "resource_version": "3210500",
+              "uid": "647df6e5-68b6-4780-9a61-726f7ab0baa5"
+            },
+            {
+              "image_id": "docker-pullable://192.168.106.189:5443/f059/app-python-ca@sha256:b632d5192bd370c5690aee2c2cc192954522dd0cd44ab0f5c8be050a3e033263",
+              "image_ref": "192.168.106.189:5443/f059/app-python-ca@sha256:b632d5192bd370c5690aee2c2cc192954522dd0cd44ab0f5c8be050a3e033263",
+              "platform": "linux/amd64",
+              "resource_version": "3210580",
+              "uid": "59700080-23f0-4f62-b936-1eb55c204619"
+            },
+            {
+              "image_id": "docker-pullable://192.168.106.189:5443/f059/app-python-preparer-ca@sha256:7d4d46ca19fa0a5ae71f177f81a45df58dc3d3a676d4c536615a9376673d55e9",
+              "image_ref": "192.168.106.189:5443/f059/app-python-preparer-ca@sha256:7d4d46ca19fa0a5ae71f177f81a45df58dc3d3a676d4c536615a9376673d55e9",
+              "platform": "linux/amd64",
+              "resource_version": "3210640",
+              "uid": "13e7a9bd-e028-4d41-92d3-85f9ac10231c"
+            },
+            {
+              "image_id": "docker-pullable://192.168.106.189:5443/f059/app-node-ca@sha256:533c8eb5040441e9420969ff0b305b45572ea58b80d345c27893dd9edc4a2763",
+              "image_ref": "192.168.106.189:5443/f059/app-node-ca@sha256:533c8eb5040441e9420969ff0b305b45572ea58b80d345c27893dd9edc4a2763",
+              "platform": "linux/amd64",
+              "resource_version": "3210699",
+              "uid": "17cb59e9-853c-4fde-a836-b3b68ffdb78b"
+            },
+            {
+              "image_id": "docker-pullable://192.168.106.189:5443/f059/app-node-preparer-ca@sha256:27b13ce9a57c59f9724001d5a69ede5029a6c6709a702a55fba435a77f77e666",
+              "image_ref": "192.168.106.189:5443/f059/app-node-preparer-ca@sha256:27b13ce9a57c59f9724001d5a69ede5029a6c6709a702a55fba435a77f77e666",
+              "platform": "linux/amd64",
+              "resource_version": "3210762",
+              "uid": "f7c5cd7e-aa0a-4d6d-a376-056cc3d75760"
+            },
+            {
+              "image_id": "docker-pullable://192.168.106.189:5443/f059/app-static-ca@sha256:2f0311da8b281f35437fcf26b47c8249f8d86f98227a28bb231cd472071ee218",
+              "image_ref": "192.168.106.189:5443/f059/app-static-ca@sha256:2f0311da8b281f35437fcf26b47c8249f8d86f98227a28bb231cd472071ee218",
+              "platform": "linux/amd64",
+              "resource_version": "3210823",
+              "uid": "5c70978a-c45c-491e-94a6-a7c00df3ffe0"
+            }
+          ]
+        },
+        "checks": [
+          {
+            "evidence": {
+              "uid": "647df6e5-68b6-4780-9a61-726f7ab0baa5"
+            },
+            "name": "admission-create",
+            "observed_at": 1792705007.306199,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "uid": "69ee7246-6a93-4b57-8d1d-85dc28d6f9a2"
+            },
+            "name": "binding-delete-fence",
+            "observed_at": 1792705007.306199,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "digest": "sha256:5906c51814e24a63356dc69fb5c1dd332bb4eb91b70cf65d41594aefc0841c85",
+              "uid": "647df6e5-68b6-4780-9a61-726f7ab0baa5"
+            },
+            "name": "image-cri",
+            "observed_at": 1792705007.306199,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "code": "control_side_tls",
+              "uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b"
+            },
+            "name": "material-tls",
+            "observed_at": 1792705007.306199,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b"
+            },
+            "name": "namespace-budget",
+            "observed_at": 1792705007.306199,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "uid": "fa3704cd-5fab-4599-9544-a01c2df6894c"
+            },
+            "name": "network-direct-denied",
+            "observed_at": 1792705007.306199,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "uid": "96eee51b-be86-4850-a5a4-0cf4e1e4e413"
+            },
+            "name": "network-dns-denied",
+            "observed_at": 1792705007.306199,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "uid": "65176307-19f1-4fc9-9a84-c36062d6fbc7"
+            },
+            "name": "network-egress-allowed",
+            "observed_at": 1792705007.306199,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "uid": "647df6e5-68b6-4780-9a61-726f7ab0baa5"
+            },
+            "name": "network-ingress",
+            "observed_at": 1792705007.306199,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "uid": "ffeedf5f-6001-477a-881a-67c33432248c"
+            },
+            "name": "no-exec",
+            "observed_at": 1792705007.306199,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "code": "control_side_authenticated_api",
+              "uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b"
+            },
+            "name": "platform-capabilities",
+            "observed_at": 1792705007.306199,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "uid": "ffeedf5f-6001-477a-881a-67c33432248c"
+            },
+            "name": "rbac-namespace",
+            "observed_at": 1792705007.306199,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "digest": "sha256:5906c51814e24a63356dc69fb5c1dd332bb4eb91b70cf65d41594aefc0841c85",
+              "uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b"
+            },
+            "name": "registry-push",
+            "observed_at": 1792705007.306199,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "uid": "647df6e5-68b6-4780-9a61-726f7ab0baa5"
+            },
+            "name": "restricted-psa",
+            "observed_at": 1792705007.306199,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "policy_revision": "v1",
+              "uid": "647df6e5-68b6-4780-9a61-726f7ab0baa5"
+            },
+            "name": "status-provenance",
+            "observed_at": 1792705007.306199,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "uid": "e12512c5-ecf2-4bec-a703-7204eebcc37e"
+            },
+            "name": "storage-sqlite",
+            "observed_at": 1792705007.306199,
+            "ok": true,
+            "reason": "verified"
+          }
+        ],
+        "descriptor_digest": "f306a49e2e396c60c91e76672c72dfc9989763fd5d7dcb091257009f70b8f456",
+        "observed_at": 1792705007.306199,
+        "source": "cluster-probe"
+      },
+      "report_sha256": "6720eaf4938302d5ad5c7e01b8b1972627dbf5f7c4b1e92d5db83ec838e92ec6",
+      "report_reused_for_exact_descriptor": true,
+      "new_probe_claimed": false,
+      "report_age_seconds_at_restore": 1548.6708028316498,
+      "state": "passed",
+      "backend_node_state": "ready",
+      "controller_epoch": 21,
+      "withdrawn_d7_report_sha256": "35c968ac1a297e65ff59257c05aef86bf3eaddf555f347f9eb992bacd61ae9bf",
+      "withdrawn_d7_checks_passed": 16
+    }
+  ],
+  "registry_upstream_dedc_real_validation": {
+    "installer_commit": "dedc4831bb462f832877940901201061b1e027cf",
+    "installer_script_sha256": "9b79deb56bfeb800e7ebe1b921576e659b5da01f831510017f776a7b16f4c2be",
+    "runtime_build_source": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+    "report_path": "/opt/e2e-f059/evidence/install-dedc4831b-r2-report.json",
+    "report_sha256": "304b970cdba945fb4c60dffeb10eab3dbdd7ceffeb0b984dbfb39c52581070ce",
+    "report": {
+      "artifacts": {
+        "image-cri": [
+          {
+            "image_id": "docker-pullable://192.168.106.189:5443/f059/runtime-helper@sha256:5906c51814e24a63356dc69fb5c1dd332bb4eb91b70cf65d41594aefc0841c85",
+            "image_ref": "192.168.106.189:5443/f059/runtime-helper@sha256:5906c51814e24a63356dc69fb5c1dd332bb4eb91b70cf65d41594aefc0841c85",
+            "platform": "linux/amd64",
+            "resource_version": "3220688",
+            "uid": "b48fa8e1-6f1e-4f0e-aa44-126fb94f13c1"
+          },
+          {
+            "image_id": "docker-pullable://192.168.106.189:5443/f059/app-python-ca@sha256:b632d5192bd370c5690aee2c2cc192954522dd0cd44ab0f5c8be050a3e033263",
+            "image_ref": "192.168.106.189:5443/f059/app-python-ca@sha256:b632d5192bd370c5690aee2c2cc192954522dd0cd44ab0f5c8be050a3e033263",
+            "platform": "linux/amd64",
+            "resource_version": "3220768",
+            "uid": "9f16da9d-e0b4-4518-bb29-019a3b253dc2"
+          },
+          {
+            "image_id": "docker-pullable://192.168.106.189:5443/f059/app-python-preparer-ca@sha256:7d4d46ca19fa0a5ae71f177f81a45df58dc3d3a676d4c536615a9376673d55e9",
+            "image_ref": "192.168.106.189:5443/f059/app-python-preparer-ca@sha256:7d4d46ca19fa0a5ae71f177f81a45df58dc3d3a676d4c536615a9376673d55e9",
+            "platform": "linux/amd64",
+            "resource_version": "3220827",
+            "uid": "94b267e6-35d8-4171-aa03-a1dc2b02b7a6"
+          },
+          {
+            "image_id": "docker-pullable://192.168.106.189:5443/f059/app-node-ca@sha256:533c8eb5040441e9420969ff0b305b45572ea58b80d345c27893dd9edc4a2763",
+            "image_ref": "192.168.106.189:5443/f059/app-node-ca@sha256:533c8eb5040441e9420969ff0b305b45572ea58b80d345c27893dd9edc4a2763",
+            "platform": "linux/amd64",
+            "resource_version": "3220884",
+            "uid": "e9cd8a56-42f1-4e51-a723-54cb50a2d205"
+          },
+          {
+            "image_id": "docker-pullable://192.168.106.189:5443/f059/app-node-preparer-ca@sha256:27b13ce9a57c59f9724001d5a69ede5029a6c6709a702a55fba435a77f77e666",
+            "image_ref": "192.168.106.189:5443/f059/app-node-preparer-ca@sha256:27b13ce9a57c59f9724001d5a69ede5029a6c6709a702a55fba435a77f77e666",
+            "platform": "linux/amd64",
+            "resource_version": "3220941",
+            "uid": "7e7f1ebd-bdcd-4800-aac9-e946ec1be71c"
+          },
+          {
+            "image_id": "docker-pullable://192.168.106.189:5443/f059/app-static-ca@sha256:2f0311da8b281f35437fcf26b47c8249f8d86f98227a28bb231cd472071ee218",
+            "image_ref": "192.168.106.189:5443/f059/app-static-ca@sha256:2f0311da8b281f35437fcf26b47c8249f8d86f98227a28bb231cd472071ee218",
+            "platform": "linux/amd64",
+            "resource_version": "3221000",
+            "uid": "d45bbe51-b5d6-455d-8044-f9c6242dc320"
+          }
+        ]
+      },
+      "checks": [
+        {
+          "evidence": {
+            "uid": "b48fa8e1-6f1e-4f0e-aa44-126fb94f13c1"
+          },
+          "name": "admission-create",
+          "observed_at": 1792710792.2928534,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "uid": "cb4ec0d9-6dd7-48fd-b7a0-96fe2f134abd"
+          },
+          "name": "binding-delete-fence",
+          "observed_at": 1792710792.2928534,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "digest": "sha256:5906c51814e24a63356dc69fb5c1dd332bb4eb91b70cf65d41594aefc0841c85",
+            "uid": "b48fa8e1-6f1e-4f0e-aa44-126fb94f13c1"
+          },
+          "name": "image-cri",
+          "observed_at": 1792710792.2928534,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "code": "control_side_tls",
+            "uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b"
+          },
+          "name": "material-tls",
+          "observed_at": 1792710792.2928534,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b"
+          },
+          "name": "namespace-budget",
+          "observed_at": 1792710792.2928534,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "uid": "b22a4086-3d6b-4ccc-900f-42eac88247b3"
+          },
+          "name": "network-direct-denied",
+          "observed_at": 1792710792.2928534,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "uid": "c68d6d0b-711b-4a16-98e1-616546a27fce"
+          },
+          "name": "network-dns-denied",
+          "observed_at": 1792710792.2928534,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "uid": "96a575db-ba11-4737-9777-6d5c822b9b4b"
+          },
+          "name": "network-egress-allowed",
+          "observed_at": 1792710792.2928534,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "uid": "b48fa8e1-6f1e-4f0e-aa44-126fb94f13c1"
+          },
+          "name": "network-ingress",
+          "observed_at": 1792710792.2928534,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "uid": "ffeedf5f-6001-477a-881a-67c33432248c"
+          },
+          "name": "no-exec",
+          "observed_at": 1792710792.2928534,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "code": "control_side_authenticated_api",
+            "uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b"
+          },
+          "name": "platform-capabilities",
+          "observed_at": 1792710792.2928534,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "uid": "ffeedf5f-6001-477a-881a-67c33432248c"
+          },
+          "name": "rbac-namespace",
+          "observed_at": 1792710792.2928534,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "digest": "sha256:5906c51814e24a63356dc69fb5c1dd332bb4eb91b70cf65d41594aefc0841c85",
+            "uid": "7f51f0ff-cff8-4e99-9bb6-2cf18e07df32"
+          },
+          "name": "registry-push",
+          "observed_at": 1792710792.2928534,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "uid": "b48fa8e1-6f1e-4f0e-aa44-126fb94f13c1"
+          },
+          "name": "restricted-psa",
+          "observed_at": 1792710792.2928534,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "policy_revision": "v1",
+            "uid": "b48fa8e1-6f1e-4f0e-aa44-126fb94f13c1"
+          },
+          "name": "status-provenance",
+          "observed_at": 1792710792.2928534,
+          "ok": true,
+          "reason": "verified"
+        },
+        {
+          "evidence": {
+            "uid": "da4950ca-0129-4fbd-95be-2638e831f42d"
+          },
+          "name": "storage-sqlite",
+          "observed_at": 1792710792.2928534,
+          "ok": true,
+          "reason": "verified"
+        }
+      ],
+      "descriptor_digest": "f306a49e2e396c60c91e76672c72dfc9989763fd5d7dcb091257009f70b8f456",
+      "observed_at": 1792710792.2928534,
+      "source": "cluster-probe"
+    },
+    "wrapper_exit_code": 0,
+    "before_guard_sha256": "12588470e03354843e7747c0ba85fea3b18d67078245a7d34f5056a2f3ac398d",
+    "after_guard_sha256": "b92a889b6878e321f52af19faeba5ab91eab591dbabc2a187e5142f37a49396c",
+    "independent_review_result": "PASS_FOR_FIVE_SERVICE_MATCHING_CONTROL_CANARIES",
+    "independent_review_sha256": "140eafa532248a53fe1dfef42f198a0bae3faaa47b49de12255d0ab53e508d62",
+    "control_canaries": [
+      {
+        "name": "preflight-3f918fb9d04c4189a1e1",
+        "uid": "e18af54e-c456-4828-9eb1-3c08707952f9",
+        "role": "app-proxy",
+        "event_counts": {
+          "ADDED": 1,
+          "MODIFIED": 10,
+          "DELETED": 1
+        },
+        "gate_present_all_rows": true,
+        "gate_true_observed_count": 0,
+        "ready_true_observed_count": 0,
+        "ready_false_observed_count": 10,
+        "ready_absent_observed_count": 2,
+        "same_uid_added_to_deleted": true,
+        "endpoint_samples": [
+          {
+            "line": 9,
+            "pod_name": "preflight-3f918fb9d04c4189a1e1",
+            "pod_uid": "e18af54e-c456-4828-9eb1-3c08707952f9",
+            "addresses": [
+              "10.233.64.91"
+            ],
+            "ready": "false",
+            "serving": "false",
+            "terminating": "false",
+            "slice_resource_version": "3221210",
+            "slice_event_line": 7,
+            "slice_name": "app-proxy-rcrg7",
+            "service_name": "app-proxy"
+          }
+        ]
+      },
+      {
+        "name": "preflight-056676a64f8741c8a693",
+        "uid": "a548b056-8b54-4784-b817-47265dd86335",
+        "role": "runtime-manager",
+        "event_counts": {
+          "ADDED": 1,
+          "MODIFIED": 10,
+          "DELETED": 1
+        },
+        "gate_present_all_rows": true,
+        "gate_true_observed_count": 0,
+        "ready_true_observed_count": 0,
+        "ready_false_observed_count": 10,
+        "ready_absent_observed_count": 2,
+        "same_uid_added_to_deleted": true,
+        "endpoint_samples": [
+          {
+            "line": 12,
+            "pod_name": "preflight-056676a64f8741c8a693",
+            "pod_uid": "a548b056-8b54-4784-b817-47265dd86335",
+            "addresses": [
+              "10.233.64.92"
+            ],
+            "ready": "false",
+            "serving": "false",
+            "terminating": "false",
+            "slice_resource_version": "3221269",
+            "slice_event_line": 10,
+            "slice_name": "runtime-manager-xs686",
+            "service_name": "runtime-manager"
+          }
+        ]
+      },
+      {
+        "name": "preflight-5e9e94c0be67424185ac",
+        "uid": "892e0d07-8ee1-4ad6-837b-3cc71b1602f0",
+        "role": "app-proxy",
+        "event_counts": {
+          "ADDED": 1,
+          "MODIFIED": 10,
+          "DELETED": 1
+        },
+        "gate_present_all_rows": true,
+        "gate_true_observed_count": 0,
+        "ready_true_observed_count": 0,
+        "ready_false_observed_count": 10,
+        "ready_absent_observed_count": 2,
+        "same_uid_added_to_deleted": true,
+        "endpoint_samples": [
+          {
+            "line": 17,
+            "pod_name": "preflight-5e9e94c0be67424185ac",
+            "pod_uid": "892e0d07-8ee1-4ad6-837b-3cc71b1602f0",
+            "addresses": [
+              "10.233.64.94"
+            ],
+            "ready": "false",
+            "serving": "false",
+            "terminating": "false",
+            "slice_resource_version": "3221380",
+            "slice_event_line": 15,
+            "slice_name": "app-proxy-rcrg7",
+            "service_name": "app-proxy"
+          }
+        ]
+      },
+      {
+        "name": "preflight-548425f2212848098224",
+        "uid": "9fb3f70b-e2b4-4445-87ed-16fa502f42fd",
+        "role": "runtime-manager",
+        "event_counts": {
+          "ADDED": 1,
+          "MODIFIED": 10,
+          "DELETED": 1
+        },
+        "gate_present_all_rows": true,
+        "gate_true_observed_count": 0,
+        "ready_true_observed_count": 0,
+        "ready_false_observed_count": 10,
+        "ready_absent_observed_count": 2,
+        "same_uid_added_to_deleted": true,
+        "endpoint_samples": [
+          {
+            "line": 22,
+            "pod_name": "preflight-548425f2212848098224",
+            "pod_uid": "9fb3f70b-e2b4-4445-87ed-16fa502f42fd",
+            "addresses": [
+              "10.233.64.95"
+            ],
+            "ready": "false",
+            "serving": "false",
+            "terminating": "false",
+            "slice_resource_version": "3221437",
+            "slice_event_line": 20,
+            "slice_name": "runtime-manager-xs686",
+            "service_name": "runtime-manager"
+          }
+        ]
+      },
+      {
+        "name": "preflight-b9c94aa14c3343f8a5d6",
+        "uid": "7f51f0ff-cff8-4e99-9bb6-2cf18e07df32",
+        "role": "egress-proxy",
+        "event_counts": {
+          "ADDED": 1,
+          "MODIFIED": 10,
+          "DELETED": 1
+        },
+        "gate_present_all_rows": true,
+        "gate_true_observed_count": 0,
+        "ready_true_observed_count": 0,
+        "ready_false_observed_count": 10,
+        "ready_absent_observed_count": 2,
+        "same_uid_added_to_deleted": true,
+        "endpoint_samples": [
+          {
+            "line": 25,
+            "pod_name": "preflight-b9c94aa14c3343f8a5d6",
+            "pod_uid": "7f51f0ff-cff8-4e99-9bb6-2cf18e07df32",
+            "addresses": [
+              "10.233.64.96"
+            ],
+            "ready": "false",
+            "serving": "false",
+            "terminating": "false",
+            "slice_resource_version": "3221496",
+            "slice_event_line": 23,
+            "slice_name": "egress-proxy-qp644",
+            "service_name": "egress-proxy"
+          }
+        ]
+      }
+    ],
+    "services": [
+      {
+        "name": "runtime-manager",
+        "uid": "440c7549-2dfc-4465-af7b-ae4c2ccacb89",
+        "selector": {
+          "app": "runtime-manager"
+        },
+        "publish_not_ready_addresses": false,
+        "before_after_uid_selector_publish_equal": true,
+        "before_resource_version": "3147519",
+        "after_resource_version_recorded": false
+      },
+      {
+        "name": "app-proxy",
+        "uid": "45e106b9-ace4-4468-a687-64475d6d6f2e",
+        "selector": {
+          "app": "app-proxy"
+        },
+        "publish_not_ready_addresses": false,
+        "before_after_uid_selector_publish_equal": true,
+        "before_resource_version": "3147522",
+        "after_resource_version_recorded": false
+      },
+      {
+        "name": "egress-proxy",
+        "uid": "8c82e5bc-7ae6-49c3-b52e-1e42b234dae3",
+        "selector": {
+          "app": "egress-proxy"
+        },
+        "publish_not_ready_addresses": false,
+        "before_after_uid_selector_publish_equal": true,
+        "before_resource_version": "3147528",
+        "after_resource_version_recorded": false
+      }
+    ],
+    "continuity": {
+      "observed_canary_added_to_deleted_complete": true,
+      "no_inband_error_event_or_stderr_error_observed": true,
+      "operator_before_after_same_pid_scope_verified": true,
+      "resource_version_numeric_gaps_used_as_loss_signal": false,
+      "independent_wire_reconnect_or_api_audit_trace_available": false,
+      "boundary": "The supplied watch rows, empty stderr and root process-scope observations support the recorded five-UID lifecycle. They cannot cryptographically exclude every transparent transport reconnect or unseen API history; no claim based on RV numeric gaps, no global cluster continuity claim."
+    },
+    "watch_scope": "five Service-matching control canaries only; preflight-untrusted role not observed",
+    "pod_watch_sha256": "21d17d6c5aa7b91a83b28bee3c024c69817834b3b1c21f98b7afacaa538532cc",
+    "endpoint_watch_sha256": "e59d0f0d0ae1114cdfe0625e1b9cc863768a6bcbbcaad1531135d05cfed28443",
+    "watch_finish_sha256": "611eace8d14973bb100bf83a75ea375bf1a0c639b03dadbf7335f05e9cb08e0d",
+    "watch_exit_codes": [
+      1,
+      1
+    ],
+    "watch_exit_reason": "operator SIGINT after installer exit and exact process/Service/no-control-canary guards; not a test failure or exit130 claim",
+    "scope_limits": [
+      "TCP upstream check is distinct from external crane TLS/permissions and actual source-image assembly",
+      "No proof of unobserved sixth untrusted canary Ready history",
+      "No packet-level traffic continuity or absolute transport-gap claim",
+      "No new ARM/public release acceptance"
+    ]
+  },
+  "protection_report_r29_deployment": {
+    "schema": "f059-r29-real-deployment/v1",
+    "source_revision": "f8863f3763285435a5df01fc150400838bdb3f0a",
+    "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:0b4bd3e66c1d7c49fe00046c83277dab8b6dbd0628f201abf3192d70dd67ddad",
+    "manager_image_config_id": "sha256:1cf328093deb9fb81e0202cd3cc0dec56ce6fba92efed3b45b8e6ee6eae2857a",
+    "manager_pod_uid": "fae606c4-3e51-4efa-ba00-a7ce9163de3f",
+    "old_manager_uid_absent": "faa48ee8-fd38-4980-a290-4b44ada8d6b4",
+    "helm_revision": 29,
+    "controller_epoch": 24,
+    "node_status": "ready",
+    "baseline_digest": "9006c20349b46e6a60af9d7e2ccdc45f4e4efae5606f58cde88b69bd0b9ad3ff",
+    "protection_contract_version": 1,
+    "status_protection": true,
+    "binding_protection": true,
+    "backend_sources": {
+      "src/backend/bisheng/app_runtime/domain/repositories/runtime_kubernetes_repository.py": "2aeb4eccb208e6deba9f34acaf7b0f2e0db57e8e5fae6fa9b3ce638eec6d875c",
+      "src/backend/bisheng/app_runtime/domain/services/runtime_operation_service.py": "866efa5e8feebd838a804b60a3ec7f0d49c9edd2d8fb0e690c7f08c7a8197fe3"
+    },
+    "namespace_uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b",
+    "descriptor_configmap_uid": "9bcf2c14-d457-4947-bf9c-adb6e70ffd18",
+    "descriptor_digest": "f306a49e2e396c60c91e76672c72dfc9989763fd5d7dcb091257009f70b8f456",
+    "historical_three_false_two_true_preserved": true,
+    "old_timed_out_stop_cleanup_pending_preserved": true,
+    "r4_pvc_uid": "41426a1e-9713-46d5-afa5-e520afcd186b",
+    "new_business_acceptance": false,
+    "arm_tested": false,
+    "public_issuance": false,
+    "evidence_sha256": {
+      "independent-deployment-readonly.json": "e8accbb523d92a6fa21adbddf5ca8adb7e32f2a547de8e7091c2e7ded3b40cbc",
+      "f059-r29-after-manager-cohort.json": "656f0b2dedd37626d53e92176e5bc82a8af41cb1d8ff0981a2c0a89a58e31585",
+      "native-manager-result-r29.json": "be438498db6b9c817e5d5f1be999089a9cccd0c1b6efcf8be735d555ef44c83b",
+      "private-manager-push-r29.json": "f847472be23826f75157b7b1704051495be26c63bf06753f183bc9db39c54ca7",
+      "backend-receipt.json": "cbbbe0fe5f909a1d0ea4c140b06cf0660e7ccb280ff2da8ee297356d27c4ea04",
+      "manager-after.json": "f78415da83ebbae3b33f2b21deedbe83eeb2f2b28e992e2c611bcd80855d417f"
+    }
+  },
+  "r32_manager_only_deployment": {
+    "state": "PASS_deployment_only",
+    "source_commit": "0f2db1f9a29213ca94d4596e64b00ad8fcbac528",
+    "private_image": "192.168.106.189:5443/f059/runtime-manager@sha256:bc488d16d3b75ccd30e729d7ef79b0c4591abaa40c0037057697f405d9e4900b",
+    "config_id": "sha256:86230f7a09ee50970c1667710b0b03c8768b92f56697c833e1ad3fcf6bcf4396",
+    "operations_sha256": "8a016c611b63d8ddce6033d04f2e55e1aff9f47f74a924df2654758f20d3ea11",
+    "unchanged_kubernetes_sha256": "592d342d202c25bda6c76edddac73dc92a1e1fd631ff9fabeee3c66d69f41581",
+    "native_nonroot_uid": 10001,
+    "business_and_node_projection_unchanged": true,
+    "cluster_comparison": {
+      "app_pod_count": 5,
+      "baseline_inventory_unchanged": true,
+      "manager_deployment_uid_retained": true,
+      "manager_uid_replaced": true,
+      "non_manager_controls_unchanged": true,
+      "pv_count": 6,
+      "pvc_count": 6
+    },
+    "report_current": true,
+    "controller_epoch": 30,
+    "official_ci_release": false,
+    "public_push": false,
+    "evidence_sha256": {
+      "native-manager-result-r32.json": "80f6e650869bc09c4fb6341137db67b0b56870f87062485043c9ab41fbe4d24d",
+      "private-manager-push-r32.json": "482f916fbfc84ed47d922f459ae785538faac35b00445fb56d3269d6771cb312",
+      "r32-cluster-after-r1.json": "7cdb88118bfa0e38e4add4b5ea2a0917176dad911eb63de6d7980b8056821712",
+      "r32-cluster-before-r1.json": "5f45fde8f0252b0564044f4a32b0bf8335bf212602e45d35fc9f03a2cdfc2c49",
+      "r32-ready-r1.json": "4bfa85f97b1e608ec9da4bdeded1cfc1f0ff15640ce43612b182fa2be0be2be4",
+      "r32-sql-after-r1.json": "f1f15788abf746fd1f6a09c5bcc1a3892eba545d83457dab68dc9142899d77f7",
+      "r32-sql-before-r1.json": "a93d1008e32ba8503debbde20be64ff520ac9a365e4333a0b47c06f02032ee5d"
+    },
+    "boundary": "One manager-only upgrade and read-only current-report verification; no new installer probes, ARM, whole-suite or whole-business rerun; in-flight restart final completion recorded separately in fault-upgrade.json.",
+    "independent_review": {
+      "status": "PASS_manager_delivery_only",
+      "path": "/private/tmp/f059-r32-manager-delivery-independent.md",
+      "sha256": "133fd983a2ff09a675d90d2d6cee2334295ac887d1a13147d2adf12c7a3f9ad5"
+    }
+  }
+}
diff --git a/features/v3.0.0/059-k8s-runtime-backend/e2e-evidence/installation.md b/features/v3.0.0/059-k8s-runtime-backend/e2e-evidence/installation.md
new file mode 100644
index 0000000000..9c44fa6cd0
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/e2e-evidence/installation.md
@@ -0,0 +1,88 @@
+# F059 189 安装验收阶段证据
+
+状态:**T068通过**。固定源码、镜像、资源UID与校验和见 [installation.json](./installation.json)。
+
+候选54b3ea2c1、Helm revision10在189实际完成第五轮15项必需检查及registry-push,正常清理exit0;真实平台公开安装诊断通过。覆盖受限RBAC/exec、PSA、状态来源、同UID删除与binding拒绝、网络隔离、精确镜像启动、材料TLS/HMAC和SQLite锁/fsync。此前失败与其他源码轮次独立保留。
+
+报告过期、CNI身份变化、必需检查失败三组均通过真实发布入口:先确认节点blocked及公开ready=false,提交各自唯一static应用,持久化失败16121/precheck_build、无审批/正式版本/运行操作。每组原样恢复descriptor字符串并验证节点ready,manager Pod UID/containerID/启动时间/restartCount完全不变;没有生成虚假成功报告或延长TTL。
+
+旧失败installer仅按自然到期、同UID及真实停止/绑定拒绝证据清理;固定旧dryRun按四次404与CAS撤销。最终执行namespace无Pod/PVC,canary计划为空。专用registry启用delete经过用户明确授权,原容器及数据保留。
+
+业务发布、应用数据/SDK与故障升级仍由T069/T070单独验收。本节不是整体交付通过;DM8仅静态,单节点不能证明跨节点。
+
+## 业务验收前临时卷池增补
+
+按冻结计划新增scratch-09..40共32个4GiB独占ext4 local PV,实际预分配128GiB;精确NS/nodeUID、原19PV/marker权限及loop前后核对不变。新卷归专用operator管理,Retain,无Helm归属,不重绑旧卷;inventory摘要与新UID见JSON。原preflight卷因既有fsGroup为0:10001/0664,按精确旧UID冻结原状,不改旧权限。该结果不证明宿主重启或跨节点恢复。
+
+后续控制候选0e/Helm11:固定LAN manager manifest b17045eb…,保留当前实际能力报告再升级;四个历史failed draft逐一只读预检后owner DELETE,后续GET16101,无force清理。收据SHA df530fbb693835dfa83604c4adc54b588b2e0e03858bffb34806b87567b93bf7。新业务尚在precheck_build,资源未创建,空闲租约续租409登记T198/T199;该状态不撤销历史T068安装探测证据,但不计T069业务完成。
+
+## 2026-10-23 开发收尾候选
+
+Helm14以d86e66a2d固定manager镜像升级manager/egress/admission,全部Ready;descriptor UID/数据相同,原能力报告/helper/profile2保留,backend四文件来自709821ecb。真实平台进度见[platform.md](./platform.md):首Pod已创建启动并正常同UID清理,完整发布仍pending。
+
+## 当前143候选:新helper重验
+
+源码143606851、Helm16使用新manager/helper固定digest,profile2运行/预备镜像和assembler/app-proxy保持原值。原生amd64构建及lock:null实际resolve烟测通过。首轮新helper探测15/16通过,只有storage-sqlite因原preflight01–03全部Retain/Released而超时;本轮canary已按正常停止确认清理,失败报告完整保留。
+
+新增preflight04–07四个1Gi独占有界ext4 local PV,物理预分配4Gi,inventory SHA 35f57e5fb4e4a49df8b12862c9e66da90bcab0e88e797a988e7ef9aa473dddf2。固定NS/node UID、原19卷和全部既有loop在本次供应中不变。首个只读计划因旧快照scratch06 marker模式不符拒绝;只读确认现为0:10001/0664且内容/UID不变,将该实测状态单独冻结到新快照,旧快照和旧卷不修改。
+
+同源码/镜像第二轮完整探测进行中;未把旧报告当作新helper通过。r6完整6场景已在新目录准备,无API bootstrap;新报告ready前不启动业务。
+
+第二轮同143源码/镜像完整探测exit0,16/16真实通过;SQLite证据UID 12e4f5eb-e512-4549-8395-eb9620c281d8,descriptor digest d7e831157cd56ff2a941118b9deafb2c2f62b572c633428efb4412839a337afd。canary Pod/PVC均正常清理,仅旧r4隔离PVC仍保留。报告发布后平台Node初次读仍blocked,业务需等正常配置投射/心跳ready再启动,未跳过准入。
+
+新报告投射及正常心跳后node ready=true/controller_epoch10,未重启或改状态;独立r6 platform套件已启动。私有启动守卫首轮缺now关键字在业务/API/journal前退出,修正v2后固定helper/报告检查通过;该记录不算产品失败或重放。
+
+
+2026-10-23 manager-only候选7816/Helm17:原生构建及禁网导入通过;descriptor UID/data、helper143、profile2及其16/16报告完全未变。新镜像评估ready、平台自然接管epoch11且ready;没有把保留的143探测报告改标成新运行。真实业务在独立r7继续。
+
+2026-10-23 manager-only a1de/Helm18:固定digest06bb857f…,descriptor/报告/helper/profile不变,新镜像assess ready及平台epoch13/ready成立。backend取消闸两个文件CAS更新、专用服务重启后实际私网env200;错误loopback检查单独保留并追加纠正证据。未冒认新16项完整探测或真实数据取消已通过。
+
+前次控制候选a06/Helm19、digest4666d753…;143来源的16项报告、helper及profile2逐字节保留,r9启动前新镜像assess_capabilities仍ready。此次仅准入收尾修复与真实旧失败正常清理验证,不宣称又完整执行16项;当前候选指针详见JSON,旧Helm10快照归历史记录。
+
+当前候选249/Helm20、digest42c6c3c5…,仅可信维护模板材料PodUID接线修复;helper143/profile2及16项安装报告不变,node ready/epoch15。首个正式Python上线与访问/数据边界已通过,见platform证据;没有重复完整16项探测或跨节点/ARM冒认。
+
+## 当前6f候选与离线数据CPU修正
+
+固定source6f254f337,LAN digest59692b82…、Helm22三控制服务正常滚动,node ready/epoch17。descriptor UID/data、有效143能力报告、helper8a/profile2/app-proxy不变;只更新backend两源文件并重启专用backend/worker,原文件SHA已先核对且备份保留,OpenFGA不变。未把此次滚动冒认为新的16项完整探测。
+
+## 当前控制镜像995与Helm23
+
+995c80f2a原生amd64构建/import通过,内网固定digest7cef8910…,Helm23三控制服务ready、epoch18。descriptor UID/全部data、143 helper、profile2、现有16项有效报告和app-proxy保持;仅bundle PATH补系统sbin,backend仍保持6f参数源码。原生烟测不是新完整能力报告/业务通过。
+
+## 0d5中间候选真实16项通过(Helm24)
+
+原生amd64 manager/helper/assembler及公共CA组装器变体完成构建和隔离非root烟测;helper/assembler实际SGID目录两允许、危险位10拒绝,OCI源码字节和9种PATH组合通过。仅推送189专用测试仓库20261023-26;没有ARM、官方九镜像inventory或正式CI发行冒认。
+
+manager28449f9c…/helper5906c518…/assemblerCA9836c134…更新后,仅descriptor两个镜像字段变化、UID保持;旧报告descriptor摘要不匹配,实际manager三路径false/backend blocked。真实16项探测随后全部通过,报告SHA `6720eaf4938302d5ad5c7e01b8b1972627dbf5f7c4b1e92d5db83ec838e92ec6`;投射后自然ready/epoch19。三个既有应用仍Running,r4保留不动。
+
+最终入口CMD修复来源d7f7c2e10另行冻结;上述中间0d5安装通过不等于d7f已部署,需新镜像摘要及真实报告后更新。
+
+## 更正后实际部署:Helm26 / 0d5 / epoch21
+
+CMD问题已用固定CLI官方实现与三个base的实际无--cmd对照证伪,T252/T253撤回。d7中间候选16项探测自然通过且全部preflight Pod已正常清理(报告SHA35c968ac…),没有人为中止或修改其结果。当前分支全部src/scripts/deploy/workflow构建输入与0d5逐字节相同,保留SGID/PATH真实修复。
+
+正常helm upgrade恢复0d5精确manager28449f9c…/helper5906c518…/assemblerCA9836c134…;descriptor UID不变、摘要f306a49e…,使用原实际16项通过且与该摘要完全匹配的报告6720eaf4…(恢复时年龄1549秒,小于24小时),未宣称重跑探测或新构建。Helm26/自然ready/epoch21与三交付路径true已fresh验证。r4保留、app-proxy/profile/data配置不变。更正提交20175cad9,最终镜像源码基线0d5。
+
+## T254/T255新增上游探测:189真实复验
+
+dedc安装器通过独立r2 wrapper真实执行,exit0;完整16/16检查通过并正常清理,报告SHA `304b970cdba945fb4c60dffeb10eab3dbdd7ceffeb0b984dbfb39c52581070ce`。runtime镜像仍0d5,helper/profile/descriptor摘要不变,仅能力报告刷新;双guard摘要12588470…/b92a889b…证实范围。
+
+独立复核精确5个可能匹配三个业务Service的control canary:每个均1ADDED/10MODIFIED/1DELETED,同UID闭合,所有事件带NotReady gate、未出现gate=True或Ready=True;每个仅一次进入EndpointSlice,ready和serving明确false。三个正式Pod及Service UID/selector/publishNotReadyAddresses=false保持。registry-push精确对应egress canary UID7f51f0ff-cff8-4e99-9bb6-2cf18e07df32。独立JSON SHA `140eafa532248a53fe1dfef42f198a0bae3faaa47b49de12255d0ab53e508d62`;完整PSV SHA21d17d6c…/e59d0f0d…。
+
+第六个preflight-untrusted角色不匹配上述watch选择器,未连续观察其Ready/gate历史,不推定通过。未见ERROR或stderr错误;不以resourceVersion数字跳跃判断丢失,也不声称绝对无传输缺口/数据包级无影响。root核无control残留、Service不变和精确watch进程后主动SIGINT,两个SSH watch实际exit1属操作者停止记录,不记成130或测试故障。API probe/TCP、外部crane TLS权限、真实source-image业务各自独立。
+
+
+## R29:保护报告修复实际部署(新业务验收待完成)
+
+T258–T263的生产源码 `f8863f3763285435a5df01fc150400838bdb3f0a` 已完成189原生amd64构建及隔离导入烟测,仅推送专用内网仓库;manager固定digest `0b4bd3e66c1d7c49fe00046c83277dab8b6dbd0628f201abf3192d70dd67ddad`。专用backend/worker仅两个保护源文件按旧哈希备份、原CID重启;新字节哈希复核通过。Helm29升级后manager新UID `fae606c4-3e51-4efa-ba00-a7ce9163de3f`,原UID实际缺席;自然接管epoch24、ready、sticky保护合同v1/current及两guard true。host描述符与Pod投射的完整/保护摘要一致,保护基线 `9006c20349b46e6a60af9d7e2ccdc45f4e4efae5606f58cde88b69bd0b9ad3ff`。
+
+独立fresh核对:旧三个bundle的false/false保持,source/image的true/true保持;四个Running及一个held终态的Pod/CID/PVC/PV身份未变,旧stop仍timed_out/cleanup pending,r4 unknown-create原PVC保持。没有回填历史保护、移除finalizer或释放旧预占。helper/assembler/profile2、代理/准入及描述符数据/UID未变;此次不是重跑16项安装探测、ARM或公开CI发行。新单Python应用的发布/停用/离线数据/恢复仍需单独实测,不能以部署ready代替。结构化证据见JSON的 `protection_report_r29_deployment`。
+
+
+## R32 租约交接修复部署(2026-10-23)
+
+固定manager源码 `0f2db1f9a29213ca94d4596e64b00ad8fcbac528`,唯一生产差异是operations.py开工前4行控制器准入;Kubernetes executor、Dockerfile/锁/其他发行输入未变。189原生amd64构建、实际UID10001/禁网/只读导入及精确源码摘要烟测通过。专用仓库单次私有推送、TLS校验manifest/config:digest `bc488d16d3b75ccd30e729d7ef79b0c4591abaa40c0037057697f405d9e4900b`,config `86230f7a09ee50970c1667710b0b03c8768b92f56697c833e1ad3fcf6bcf4396`。
+
+Helm32仅更新manager;新Pod UID2919607c…/controller30 ready,现有5个应用Pod、6个PVC/PV、descriptor/其他3代理身份完整保持,业务SQL及node配置前后投影相同。复用原有效探测报告,只做当前descriptor/双保护/readiness核对,没有重跑安装探测或全量业务。后端仍是单文件38a/c06f…与此前独立修复组合,不声称整个平台统一来自0f2;其他镜像与profile保留其原始provenance。
+
+七份raw摘要见JSON r32_manager_only_deployment。此节只证明修复镜像部署;在途重启和原hook自然完成另见fault-upgrade。ARM、追加109和公开CI/tag继续暂缓。
diff --git a/features/v3.0.0/059-k8s-runtime-backend/e2e-evidence/platform.json b/features/v3.0.0/059-k8s-runtime-backend/e2e-evidence/platform.json
new file mode 100644
index 0000000000..b9c5bb8019
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/e2e-evidence/platform.json
@@ -0,0 +1,2295 @@
+{
+  "schema": "f059-platform-evidence/v1",
+  "recorded_at": "2026-10-23T06:01:01.733248+00:00",
+  "state": "t069_business_workflow_passed_fault_acceptance_pending",
+  "accepted": true,
+  "host": "192.168.106.189",
+  "platform": "linux/amd64",
+  "single_node": true,
+  "components": {
+    "manager_source": "6c9694f0ffb6adf4722b9fa711851172b3efdc64",
+    "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:981bf135375a8c8728735fa13b04ee9b5c8e096c25210a72f44fdd6be003becf",
+    "backend_updated_files_source": "709821ecbe5c653aad3f90358173e0d110bee7e3",
+    "backend_rest_source": "7e720073b8a57492b7befbbb7155ffe30331af01",
+    "profile_revision": 2,
+    "helper_images": "helper and assembler from 0d5; application runtime/preparer profiles revision 2 unchanged",
+    "helper_image": "192.168.106.189:5443/f059/runtime-helper@sha256:5906c51814e24a63356dc69fb5c1dd332bb4eb91b70cf65d41594aefc0841c85",
+    "backend_data_cancel_source": "a1de5271f5f00cef0cba06d41f09c673a9190525",
+    "backend_data_cancel_files": [
+      "bisheng/app_runtime/domain/services/runtime_data_grant_service.py",
+      "bisheng/app_runtime/domain/services/runtime_data_session_service.py"
+    ],
+    "helm_revision": 31,
+    "suite_source": "d13e9ee91a1014d576d11def1eff9cba21f4c927",
+    "backend_data_cpu_source": "6f254f337dd6e8a36733501a2266bf08a04a56e9",
+    "backend_data_cpu_files": [
+      "bisheng/app_runtime/domain/services/runtime_data_session_service.py",
+      "bisheng/app_runtime/domain/repositories/runtime_delivery_repository.py"
+    ],
+    "assembler_image": "192.168.106.189:5443/f059/runtime-assembler-ca@sha256:9836c13405df4e4b613712a2e933a73e755d22daeb79d51d6adefc242971c6b9",
+    "backend_protection_sources": {
+      "src/backend/bisheng/app_runtime/domain/repositories/runtime_kubernetes_repository.py": "2aeb4eccb208e6deba9f34acaf7b0f2e0db57e8e5fae6fa9b3ce638eec6d875c",
+      "src/backend/bisheng/app_runtime/domain/services/runtime_operation_service.py": "866efa5e8feebd838a804b60a3ec7f0d49c9edd2d8fb0e690c7f08c7a8197fe3"
+    },
+    "backend_restore_service_source": "38a7002335bdf5c7c3aa4e13e93d32d6d8150c81",
+    "backend_restore_service_sha256": "c06fb408033f4d77c767924908209e92fd06650d7275bac2cb6cba15442327ce"
+  },
+  "attempts": [
+    {
+      "batch": "k189r4",
+      "app_id": "2122578f094b4c5fb19f9669815431f2",
+      "deployment_id": "1f11c5d6d1a34505b419cb88ad74f57a",
+      "operation_id": "6031594368584d1090ba7a7a2ca8e2e4",
+      "post_pod_status": 403,
+      "historical_denial_reason": "unavailable",
+      "resource_ref": "bc542b2d-20ed-5128-b587-204897e4e484",
+      "pod_uid": null,
+      "scratch_pvc_uid": "41426a1e-9713-46d5-afa5-e520afcd186b",
+      "state": "legacy unknown create receipt; PVC/operation held, not adopted"
+    },
+    {
+      "batch": "k189r5",
+      "deployment_id": "e5f2daf2dfba4cbb80495541efa8d0bc",
+      "version_id": "6b660e896120434dbfe24f2467e718dc",
+      "pod_created_scheduled_started": true,
+      "failure_code": 16227,
+      "stage": "precheck_build",
+      "runtime_stage": "preparing",
+      "exception_class": "ValueError",
+      "diagnostic_gap": "KubernetesClient fixed container list omitted storage-guard; corrected in d86e66a2d",
+      "cleanup_proof": {
+        "status": "failed",
+        "owner_epoch": 1,
+        "cleanup_status": "complete",
+        "generation": 1,
+        "namespace": "e2e-f059-apps",
+        "app_id": "01fee1b6bc6d460aac77dfed8d05fd78",
+        "evidence_kind": "kubelet_terminal_and_cleanup_complete",
+        "original_journal_sha256": "9defe033508603b7f5d4049ba4743fdc5a13220cf31934d41dc5487b7597688b",
+        "observed_at": "2026-10-22T15:58:11.385119Z",
+        "pod_evidence_kind": "kubelet_terminal",
+        "api_resources_absent": true,
+        "operation_id": "7fb50907961e4ed491f7e9f279cebc33",
+        "namespace_uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b",
+        "operator_supplied": true,
+        "resources": [
+          {
+            "kind": "PersistentVolumeClaim",
+            "cleanup_state": "complete",
+            "name": "scratch-39002129a919e2317b185ad36c5471f0cdc3ff8a",
+            "stop_state": "requested",
+            "namespace": "e2e-f059-apps",
+            "resource_ref": "52ca3beb-2c75-57b4-9326-5c46b2ed4106",
+            "uid": "3676c752-5ffb-463b-aeaa-c4857aced8f5"
+          },
+          {
+            "kind": "Pod",
+            "cleanup_state": "complete",
+            "name": "task-e4490959fed8dd93344444d415f49a5d51a6bfb0",
+            "stop_state": "confirmed",
+            "namespace": "e2e-f059-apps",
+            "resource_ref": "bc1c2bd8-39ed-5298-a441-0f979afbf819",
+            "uid": "fc7c161c-d030-491f-b6ff-8fb8b1cddfed"
+          },
+          {
+            "kind": "Secret",
+            "cleanup_state": "complete",
+            "name": "material-e4490959fed8dd93344444d415f49a5d51a6bfb0",
+            "stop_state": "requested",
+            "namespace": "e2e-f059-apps",
+            "resource_ref": "9069f000-57ea-51a6-9248-ed0c902724d7",
+            "uid": "7f28e583-0e74-478d-a482-26690475a1fe"
+          }
+        ],
+        "schema": "f059-r5-known-cleanup/v1"
+      },
+      "original_journal_sha256": "9defe033508603b7f5d4049ba4743fdc5a13220cf31934d41dc5487b7597688b",
+      "formal_version_created": false,
+      "approval_created": false
+    },
+    {
+      "batch": "k189r5",
+      "attempt": 2,
+      "app_id": "01fee1b6bc6d460aac77dfed8d05fd78",
+      "deployment_id": "87a95018bda240b695a392fb156f8cd4",
+      "version_id": "463f8160c85c4ad08e0935424c2041c9",
+      "operation_id": "01a6500a2a7b46578e677abb07320e1c",
+      "pod_uid": "81be1259-85ac-44df-8a6c-9a43ab4fc332",
+      "resource_ref": "7565f68a-5892-58d1-bcf6-3e973e572282",
+      "phase": "Succeeded",
+      "material_transfer_succeeded": true,
+      "prepare_progress_fact_http_status": 200,
+      "stopped_observation_http_status": 409,
+      "ledger_node_name": "",
+      "observed_node_name": "binfeng-k8s-node189",
+      "cleanup_status": "complete",
+      "failure_code": 16227,
+      "stage": "precheck_build",
+      "normal_reconciliation_required": true,
+      "manual_finalizer_or_pvc_cleanup": false,
+      "cleanup_proof": {
+        "status": "failed",
+        "owner_epoch": 1,
+        "cleanup_status": "complete",
+        "generation": 1,
+        "namespace": "e2e-f059-apps",
+        "app_id": "01fee1b6bc6d460aac77dfed8d05fd78",
+        "evidence_kind": "kubelet_terminal_and_cleanup_complete",
+        "original_journal_sha256": "9defe033508603b7f5d4049ba4743fdc5a13220cf31934d41dc5487b7597688b",
+        "observed_at": "2026-10-22T16:36:07.678249Z",
+        "pod_evidence_kind": "kubelet_terminal",
+        "api_resources_absent": true,
+        "operation_id": "01a6500a2a7b46578e677abb07320e1c",
+        "namespace_uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b",
+        "operator_supplied": true,
+        "resources": [
+          {
+            "kind": "Secret",
+            "cleanup_state": "complete",
+            "name": "material-d4a28f4a9b241db958e752ee50421001d9dd783c",
+            "stop_state": "requested",
+            "namespace": "e2e-f059-apps",
+            "resource_ref": "17a32468-1eac-50b1-aa3d-38ceebdd8e08",
+            "uid": "55df7043-fbad-4814-a848-930689f97acf"
+          },
+          {
+            "kind": "PersistentVolumeClaim",
+            "cleanup_state": "complete",
+            "name": "scratch-9a3ba98b2a725095db17a785c4db9c25460264e8",
+            "stop_state": "requested",
+            "namespace": "e2e-f059-apps",
+            "resource_ref": "425ffec2-0fc7-57ab-8f10-e99a24e61699",
+            "uid": "ef468c18-f1de-43be-a0fd-82546cd28670"
+          },
+          {
+            "kind": "Pod",
+            "cleanup_state": "complete",
+            "name": "task-d4a28f4a9b241db958e752ee50421001d9dd783c",
+            "stop_state": "confirmed",
+            "namespace": "e2e-f059-apps",
+            "resource_ref": "7565f68a-5892-58d1-bcf6-3e973e572282",
+            "uid": "81be1259-85ac-44df-8a6c-9a43ab4fc332"
+          }
+        ],
+        "schema": "f059-r5-known-cleanup/v1"
+      },
+      "cleanup_proof_sha256": "dc8150cce9cf728a02e90d8b09673a1b025a57fb450389e088a14900fbecb733",
+      "recovered_ledger_node_name": "binfeng-k8s-node189",
+      "recovery": "normal reconciliation after manager upgrade; no manual finalizer/DB edits"
+    },
+    {
+      "batch": "k189r5",
+      "attempt": 3,
+      "app_id": "01fee1b6bc6d460aac77dfed8d05fd78",
+      "deployment_id": "8070bb448ebe4f0182142ed25bf16038",
+      "version_id": "7e064c50b23348e59666a158404ef229",
+      "accepted": true,
+      "business_acceptance": "pending",
+      "operation_id": "e36537609ef047748674e5172885dddd",
+      "status": "failed",
+      "stage": "precheck_build",
+      "failure_code": 16227,
+      "reason": "preparation_phase_failed",
+      "actual_failed_phase": "resolve",
+      "completed_prepare_stages": [
+        "inputs"
+      ],
+      "cleanup_status": "complete",
+      "all_five_resources_api_absent": true,
+      "cleanup_proof_sha256": "aa2d35fff5a9c63a46542dff75c1497d564ea30db6846687a4fae66598488cee",
+      "root_cause": "optional dependencies.lock:null assigned to os.environ caused TypeError; native isolated A/B confirmed",
+      "diagnostic_facts": [
+        {
+          "sequence": 3,
+          "failed_stage": "preparing",
+          "tail": "runtime helper failed (TypeError)"
+        },
+        {
+          "sequence": 4,
+          "failed_stage": "failed",
+          "tail": ""
+        }
+      ]
+    }
+  ],
+  "material_ingress": {
+    "unauthenticated_origin_before": 404,
+    "unauthenticated_backend_before": 401,
+    "scoped_route_added_to_test_proxy": true,
+    "nginx_syntax": "passed",
+    "immediate_request_to_old_worker": 404,
+    "new_connection_after_reload": 401,
+    "platform_env_after_reload": 200,
+    "backend_authentication_bypass": false,
+    "actual_signed_transfer": "pending",
+    "root_cause_of_r5_failure": "not yet concluded from this ingress check alone"
+  },
+  "not_verified": [
+    "remaining fault and independent uninstall acceptance",
+    "AC-02: page/CLI/MCP/switch presentation not newly observed in this batch",
+    "AC-17: complete tier-change timing matrix not newly observed",
+    "AC-18: workload resource-limit fault remains in fault acceptance",
+    "AC-37: CLI/audit/notification chain not separately proven by these API events",
+    "AC-45: complete cache-reuse/invalidation input matrix not separately proven"
+  ],
+  "boundary": "T069 five delivery modes and defined publication/data/deletion business workflow passed with actual 189 evidence. This is not all-AC, fault/uninstall, cross-node, ARM or formal public-CI acceptance.",
+  "next_batch": [
+    "targeted remaining K8s faults and independent uninstall retention",
+    "final L2 and delivery gaps; ARM/additional109/public issue remain deferred"
+  ],
+  "historical_batches": [
+    {
+      "name": "k189r6",
+      "cases": 6,
+      "profile_revision": 2,
+      "identities_reused": true,
+      "new_journal_required": true,
+      "prepared": true,
+      "business_started": true,
+      "requires_current_candidate_report_ready": true,
+      "backend_node_ready_before_start": true,
+      "stage": "platform",
+      "automatic_finally_cleanup": false,
+      "source_revision": "143606851370d6091b120d348549329b8113c6a6",
+      "runner_sha256": "ba5b766e3afbbfa4c4b6f170e3c32c288fccbad58dfc91c498484bb66cf4ac3c",
+      "result": "failed_precheck_build",
+      "app_id": "f81b0089eade452b9aeeade4cfe6ef01",
+      "deployment_id": "04f444c61cfc4810b905163b47f986a8",
+      "version_id": "ca5bcf0750bd47a0b82d8a2a95e47b2c",
+      "operation_id": "8fb6b53a167e4ce28718336a4840a43d",
+      "completed_prepare_stages": [
+        "inputs",
+        "resolve",
+        "install",
+        "upload"
+      ],
+      "cleanup_status": "complete",
+      "reason": "preparation_cleanup_pending",
+      "failure_code": 16227,
+      "cleanup_proof": {
+        "status": "failed",
+        "pod_stop_evidence": [
+          {
+            "uid": "20326ce9-2f50-413e-a660-473ab463b034",
+            "evidence_kind": "kubelet_terminal",
+            "purpose": null,
+            "node_name": "binfeng-k8s-node189"
+          },
+          {
+            "uid": "9b867c1b-20d1-4160-ad36-2504c3edfb20",
+            "evidence_kind": "kubelet_terminal",
+            "purpose": null,
+            "node_name": "binfeng-k8s-node189"
+          },
+          {
+            "uid": "8acd7ee1-04be-4a29-84b9-0c2a8b3ca486",
+            "evidence_kind": "kubelet_terminal",
+            "purpose": null,
+            "node_name": "binfeng-k8s-node189"
+          },
+          {
+            "uid": "8d302955-967e-4f2a-8987-bcc749eae443",
+            "evidence_kind": "kubelet_terminal",
+            "purpose": null,
+            "node_name": "binfeng-k8s-node189"
+          }
+        ],
+        "owner_epoch": 1,
+        "cleanup_status": "complete",
+        "generation": 1,
+        "app_id": "f81b0089eade452b9aeeade4cfe6ef01",
+        "failure": {
+          "diagnostic_tail": "",
+          "reason": "preparation_cleanup_pending",
+          "data_may_have_changed": false,
+          "failed_stage": "preparing",
+          "maintenance": false
+        },
+        "operation_id": "8fb6b53a167e4ce28718336a4840a43d",
+        "manual_cleanup": false,
+        "prepare_progress_stages": [
+          "inputs",
+          "upload",
+          "install",
+          "resolve"
+        ],
+        "resources": [
+          {
+            "kind": "Secret",
+            "cleanup_state": "complete",
+            "name": "material-f6f0bb16d7a2911117608194a3d83e0aad369dee",
+            "stop_state": "requested",
+            "namespace": "e2e-f059-apps",
+            "resource_ref": "2bd1982b-22bc-5267-9c45-cc02388f4c54",
+            "node_name": "",
+            "fresh_api_absent": true,
+            "purpose": "prepare-download",
+            "uid": "71d6f89a-2be5-408c-b92b-2a9bd32b0430"
+          },
+          {
+            "kind": "PersistentVolumeClaim",
+            "cleanup_state": "complete",
+            "name": "scratch-a053752355f2c5f22bd0830d6c9feb2da6900ce6",
+            "stop_state": "requested",
+            "namespace": "e2e-f059-apps",
+            "resource_ref": "84c40229-90db-5bd5-8074-293c3cbebb97",
+            "node_name": "",
+            "fresh_api_absent": true,
+            "purpose": "prepare-download",
+            "uid": "e1121e30-70c4-4b76-8cf4-30911801cc0a"
+          },
+          {
+            "kind": "Pod",
+            "cleanup_state": "complete",
+            "name": "task-f6f0bb16d7a2911117608194a3d83e0aad369dee",
+            "stop_state": "confirmed",
+            "namespace": "e2e-f059-apps",
+            "resource_ref": "fefb4aac-4511-5d16-bf37-8e169e9d9dad",
+            "node_name": "binfeng-k8s-node189",
+            "fresh_api_absent": true,
+            "purpose": "prepare-download",
+            "uid": "20326ce9-2f50-413e-a660-473ab463b034"
+          },
+          {
+            "kind": "Pod",
+            "cleanup_state": "complete",
+            "name": "task-439ed9b572ff1266bab8875c9c8030a2a675d332",
+            "stop_state": "confirmed",
+            "namespace": "e2e-f059-apps",
+            "resource_ref": "42fa1188-826d-5e0b-9228-63ad3d01056b",
+            "node_name": "binfeng-k8s-node189",
+            "fresh_api_absent": true,
+            "purpose": "prepare-download",
+            "uid": "9b867c1b-20d1-4160-ad36-2504c3edfb20"
+          },
+          {
+            "kind": "Secret",
+            "cleanup_state": "complete",
+            "name": "material-439ed9b572ff1266bab8875c9c8030a2a675d332",
+            "stop_state": "requested",
+            "namespace": "e2e-f059-apps",
+            "resource_ref": "686cbc2e-c3b7-5742-b0e5-da7fa035062c",
+            "node_name": "",
+            "fresh_api_absent": true,
+            "purpose": "prepare-download",
+            "uid": "790b8932-8d65-4bee-97e5-1bbbb03aa33e"
+          },
+          {
+            "kind": "Pod",
+            "cleanup_state": "complete",
+            "name": "task-2e2c5560e79434f5db248d2d63a4eed46e108207",
+            "stop_state": "confirmed",
+            "namespace": "e2e-f059-apps",
+            "resource_ref": "f5bacb28-d679-5792-8584-54014650a1f8",
+            "node_name": "binfeng-k8s-node189",
+            "fresh_api_absent": true,
+            "purpose": "prepare-install",
+            "uid": "8acd7ee1-04be-4a29-84b9-0c2a8b3ca486"
+          },
+          {
+            "kind": "Pod",
+            "cleanup_state": "complete",
+            "name": "task-58d577c8dcfd02b256b3335d6a00ab021db65812",
+            "stop_state": "confirmed",
+            "namespace": "e2e-f059-apps",
+            "resource_ref": "17e28e07-b6f4-5011-88df-e09cbf69e97c",
+            "node_name": "binfeng-k8s-node189",
+            "fresh_api_absent": true,
+            "purpose": "prepare-upload",
+            "uid": "8d302955-967e-4f2a-8987-bcc749eae443"
+          },
+          {
+            "kind": "Secret",
+            "cleanup_state": "complete",
+            "name": "material-58d577c8dcfd02b256b3335d6a00ab021db65812",
+            "stop_state": "requested",
+            "namespace": "e2e-f059-apps",
+            "resource_ref": "18f111ed-a067-57c6-b7c6-fc7bb16d344f",
+            "node_name": "",
+            "fresh_api_absent": true,
+            "purpose": "prepare-upload",
+            "uid": "60938df2-63d0-43a1-95c7-0eaf828a7392"
+          }
+        ],
+        "schema": "f059-r6-cleanup/v1"
+      },
+      "cleanup_proof_sha256": "110aab26f2846ed75f35a02a95e948e40e8ec5cba21528ead45acec7dfa87d77"
+    },
+    {
+      "name": "k189r7",
+      "cases": 6,
+      "profile_revision": 2,
+      "identities_reused": true,
+      "new_journal_required": true,
+      "prepared": true,
+      "business_started": true,
+      "requires_current_candidate_report_ready": true,
+      "backend_node_ready_before_start": true,
+      "stage": "platform",
+      "automatic_finally_cleanup": false,
+      "source_revision": "7816e0bb87cd1b9ea0c82e8c01283cdf2e821a13",
+      "runner_sha256": "ba5b766e3afbbfa4c4b6f170e3c32c288fccbad58dfc91c498484bb66cf4ac3c",
+      "result": "failed_probe_readiness_timeout",
+      "app_id": "baa177a4b5fe42cbb8cf4dadc8f09299",
+      "deployment_id": "d623cbd318f04795bb6121a572ff566e",
+      "version_id": "35958375b3ff4b438f061c0cdfa6d24d",
+      "prepare_operation_id": "6be875ba6c3f48569084f6155f8c67f7",
+      "prepare_status": "completed",
+      "prepare_cleanup_status": "complete",
+      "prepared_delivery_matches": true,
+      "probe_operation_id": "0816633805bd414d828ff1bef8b6995b",
+      "probe_status": "failed",
+      "probe_cleanup_status": "complete",
+      "probe_failure_reason": "workload_readiness_timeout",
+      "probe_pod_uid": "b7a85626-a271-4c1b-9f07-f47cc83ed13a",
+      "public_failure_code": 16124,
+      "public_failure_stage": "precheck_probe"
+    },
+    {
+      "name": "k189r8",
+      "cases": 6,
+      "profile_revision": 2,
+      "identities_reused": true,
+      "new_journal_required": true,
+      "prepared": true,
+      "business_started": true,
+      "requires_current_candidate_report_ready": true,
+      "backend_node_ready_before_start": true,
+      "stage": "platform",
+      "automatic_finally_cleanup": false,
+      "source_revision": "a1de5271f5f00cef0cba06d41f09c673a9190525",
+      "runner_sha256": "ba5b766e3afbbfa4c4b6f170e3c32c288fccbad58dfc91c498484bb66cf4ac3c",
+      "result": "failed_operation_timeout_after_ready",
+      "deployment_id": "123f35de6d2b4fdf8050a44d75eebc9e",
+      "runtime": "python3.11",
+      "app_id": "6da0d71cbda643e881e02d707eac7e79",
+      "version_id": "ab8c8e9a50c84bf899c1aef0a2bf0898",
+      "mode": "bundle",
+      "probe_operation_id": "cd1ad0fe93584373b9f81d1a7b8f51d7",
+      "probe_status": "running",
+      "probe_stage": "ready",
+      "probe_cleanup_status": "pending",
+      "probe_pod_uid": "8c83f28c-3a6e-460e-8631-fc0242d49c92",
+      "init_total_seconds": 96,
+      "stop_state": "confirmed",
+      "original_operation_status": "timed_out",
+      "cleanup_status": "complete",
+      "probe_ready": true,
+      "resources_fresh_api_absent": 4,
+      "cleanup_proof_sha256": "a37f1bab1216ab6029b5b7a24cf52f12db04ac375208d95b9a1e5de490855f1b"
+    },
+    {
+      "name": "k189r9",
+      "cases": 6,
+      "profile_revision": 2,
+      "source_revision": "d13e9ee91a1014d576d11def1eff9cba21f4c927",
+      "runner_sha256": "ba5b766e3afbbfa4c4b6f170e3c32c288fccbad58dfc91c498484bb66cf4ac3c",
+      "wrapper_sha256": "488f682dd2638f11ec967766f28c7ed8c5dab2edb396ec3527af3c5eaadcbdc2",
+      "stage": "platform",
+      "automatic_finally_cleanup": false,
+      "result": "failed_entry_connect_missing_test_ca",
+      "new_journal": "/opt/e2e-f059/evidence/k8s-platform-k189r9.json",
+      "apps_at_start": {
+        "e2e-f059-python-bundle-k189r9": {
+          "deployment_id": "c25967794497461297c4e69eff56a1b9",
+          "runtime": "python3.11",
+          "app_id": "94dfba7e0d70452c8e75e3a3d5e521d3",
+          "version_id": "01d5ef2f4b254d6b80522613c062c97c",
+          "mode": "bundle"
+        }
+      },
+      "preview_started": true,
+      "public_ca_file_config_missing": true,
+      "production_code_changed": false,
+      "normal_platform_cleanup": true,
+      "failure_log": "/opt/e2e-f059/logs/platform-k189r9.log",
+      "cleanup_log": "/opt/e2e-f059/logs/cleanup-k189r9.log"
+    },
+    {
+      "journal": "/opt/e2e-f059/evidence/k8s-platform-k189r10-static-resumed.json",
+      "sha256": "9c953e824afb0548e8547911ca386008cc5f8e5e03da7871ea5eec95083a9415",
+      "state": "stopped_on_source_image_failure",
+      "passed": [
+        "python_bundle_v1_v2",
+        "node_bundle_v1_v2",
+        "static_bundle_v1_v2"
+      ],
+      "remaining": [
+        "source_image_v1_v2",
+        "full_image_v1_v2",
+        "missing_dependency",
+        "offline_edit_export",
+        "fault_upgrade"
+      ],
+      "auto_cleanup": false
+    },
+    {
+      "reason": "source_registry_failure_preserved",
+      "batch": {
+        "journal": "/opt/e2e-f059/evidence/k8s-platform-k189r10-source-resumed.json",
+        "state": "failed",
+        "journal_frozen": true,
+        "source_tool_sha256": "80d6d7241cf64b92453329a62b28e6edbbd33409bea7765ec74464c90daa97cb",
+        "parent_sha256": "9c953e824afb0548e8547911ca386008cc5f8e5e03da7871ea5eec95083a9415",
+        "fresh_cleanup_proof_sha256": "efbbae5234528e89931fcf51e1bf923daa0d9cfdc712c65a2c4dfdf5dc032ac1",
+        "single_source_retry": true,
+        "app_id": "fad0b104e09c46cda59469248ab20605",
+        "deployment_id": "675d259a09dd43e8bdb9906210cd65f3",
+        "version_id": "d71f48582f404ea098f15a5e84693534",
+        "prepare_operation_id": "07b872e0d07441f7a333941560727bba",
+        "previous_positive_cases": [
+          "python_bundle_v1_v2",
+          "node_bundle_v1_v2",
+          "static_bundle_v1_v2"
+        ],
+        "current_case": "python_source_v1",
+        "remaining": [
+          "source_v2",
+          "full_image_v1_v2",
+          "missing_dependency",
+          "offline_data",
+          "fault_upgrade"
+        ],
+        "auto_cleanup": false,
+        "journal_sha256": "12231d57220e4f5343f2db67c0fa988382f34a33d62ba14f28c7739ac692e100",
+        "failure_reason": "oci_registry_operation_failed",
+        "failure_stage": "precheck_build",
+        "normal_cleanup_proof_sha256": "55db28c0b554c8e4c0dfeca61fff76f996471f87cd5f5cfc1f1dca6484f6b7f8"
+      }
+    }
+  ],
+  "first_python_online": {
+    "new_version_or_approval_created": false,
+    "original_pvc_preserved": true,
+    "pod_running_and_ready": true,
+    "observed_at": "2026-10-22T19:14:21.515421Z",
+    "production_pvc_uid": "4c5b459c-25c6-45ce-9000-370131477d27",
+    "pod_name": "app-6153b2cdf43a4bbef23a5d0a47e2a916ca8a0082",
+    "manager_source": "249d5fc85b2ed08db88c93b7676ced73fdb8ee7b",
+    "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:42c6c3c5ba9adb6f6340d0de979e9896d32e81f205f44851c492f50ea4fd7209",
+    "manual_sql_or_finalizer_change": false,
+    "pod_uid": "a27db53e-6f47-4ff3-b455-35ec038ca578",
+    "manual_receipt_sha256": "299c8b4816a248611c4ae114cb414eaf528324eb362106bafef312a1678f8a55",
+    "manual_publish": {
+      "status": "completed",
+      "cleanup_status": "complete",
+      "app_id": "a4a566cacbfb477daaf2bd90ea3eb21a",
+      "version_id": "ba5d47d9369540df9d5219adc72a55bf",
+      "old_operation_id": "d9729fe5c905480bbfd2cf796f344a97",
+      "purpose": "f059-r10-owner-manual-publish-v1",
+      "phase": "verified",
+      "operation_id": "20d42a22ce414ceda571038d10363ce8",
+      "approval_instance_id": 2
+    },
+    "boundary": "First approved Python release online after explicit new attempt. Production entry/SDK/data acceptance remains separate.",
+    "profile_revision": 2,
+    "schema": "f059-first-online/v1"
+  },
+  "first_python_observe": [
+    {
+      "kind": "live-api",
+      "name": "application-check",
+      "app_id": "a4a566cacbfb477daaf2bd90ea3eb21a",
+      "acs": [
+        "AC-36",
+        "AC-46"
+      ],
+      "observed_at": 1792696638.147508,
+      "path": "/health",
+      "outcome": "passed"
+    },
+    {
+      "kind": "live-api",
+      "name": "application-check",
+      "app_id": "a4a566cacbfb477daaf2bd90ea3eb21a",
+      "acs": [
+        "AC-36",
+        "AC-46"
+      ],
+      "observed_at": 1792696638.1925256,
+      "path": "/sdk/identity",
+      "outcome": "passed"
+    },
+    {
+      "kind": "live-api",
+      "name": "application-check",
+      "app_id": "a4a566cacbfb477daaf2bd90ea3eb21a",
+      "acs": [
+        "AC-36",
+        "AC-46"
+      ],
+      "observed_at": 1792696638.5836573,
+      "path": "/sdk/storage-roundtrip",
+      "outcome": "passed"
+    },
+    {
+      "kind": "live-api",
+      "name": "websocket-allow-deny",
+      "acs": [
+        "AC-12",
+        "AC-36",
+        "AC-53"
+      ],
+      "observed_at": 1792696638.7143133,
+      "outcome": "passed",
+      "slug": "e2e-f059-python-bundle-k189r10"
+    },
+    {
+      "kind": "live-api",
+      "name": "owner-and-entry-boundaries",
+      "app_id": "a4a566cacbfb477daaf2bd90ea3eb21a",
+      "acs": [
+        "AC-10",
+        "AC-12",
+        "AC-33",
+        "AC-36",
+        "AC-46"
+      ],
+      "observed_at": 1792696647.621125,
+      "outcome": "passed",
+      "entry_status": 200
+    }
+  ],
+  "r10_stop_expired_pending": {
+    "operation_id": "9f57c38992f2428f8fbc4bd3fa037ca4",
+    "deadline_utc": "2026-10-22T19:27:30Z",
+    "status_observed": "running",
+    "cleanup_status": "pending",
+    "app_api_state": "stopped",
+    "physical_serve_uid": "a27db53e-6f47-4ff3-b455-35ec038ca578",
+    "physical_writer_stopped": false,
+    "cause": "Closed historical same-name maintenance Pod resource UID was re-read; expired stop cleanup then incorrectly used its own empty resources as proof for all app writers; backend correctly refused terminal stopped fact",
+    "fix_tasks": [
+      "T238",
+      "T239",
+      "T240",
+      "T241"
+    ],
+    "original_resumed_journal_sha256": "1cbdb6186bbf211ce9eded309620fae2b57181e571e69a0d37011cbb5370e32a",
+    "original_resumed_exit": "AssertionError from stop response; no retry",
+    "deadline_extended": false,
+    "raw_local_evidence": "/private/tmp/f059-r10-stop-expired-before.json"
+  },
+  "r10_stop_cleanup_complete": {
+    "schema": "f059-r10-stop-cleanup/v1",
+    "operator_supplied": true,
+    "parent_journal_sha256": "1cbdb6186bbf211ce9eded309620fae2b57181e571e69a0d37011cbb5370e32a",
+    "app_id": "a4a566cacbfb477daaf2bd90ea3eb21a",
+    "version_id": "ba5d47d9369540df9d5219adc72a55bf",
+    "stop_operation_id": "9f57c38992f2428f8fbc4bd3fa037ca4",
+    "status": "timed_out",
+    "reason": "operation_timeout",
+    "cleanup_status": "complete",
+    "namespace": "e2e-f059-apps",
+    "serve_pod_uid": "a27db53e-6f47-4ff3-b455-35ec038ca578",
+    "serve_pod_absent": true,
+    "serve_stop_state": "confirmed",
+    "serve_evidence_kind": "kubelet_terminal",
+    "pvc_uid": "4c5b459c-25c6-45ce-9000-370131477d27",
+    "pvc_present": true,
+    "pvc_phase": "Bound",
+    "active_operation_id": "9f57c38992f2428f8fbc4bd3fa037ca4",
+    "generation": 3,
+    "owner_epoch": 1,
+    "observed_at": "2026-10-22T19:47:44.788403+00:00"
+  },
+  "offline_data_first_attempt": {
+    "operation_id": "4b25b620966c4cb2a1ec0998e8766719",
+    "created_at": "2026-10-22T19:48:20Z",
+    "ready_at": "2026-10-22T19:49:15Z",
+    "deadline": "2026-10-22T19:49:20Z",
+    "cleanup_complete_at": "2026-10-22T19:49:29Z",
+    "status": "timed_out",
+    "data_action_finished": "done",
+    "suite_phase": "private continuation check, before new journal/resume",
+    "fix_tasks": [
+      "T242",
+      "T243",
+      "T244",
+      "T245"
+    ]
+  },
+  "offline_data_cpu100_success": {
+    "ledger": {
+      "resource_ref": "52882475-2fcb-5869-923a-604b275ca9fd",
+      "pod_uid": "776ae57a-b32a-4bd2-9048-4efdf0d47323",
+      "stop_state": "confirmed",
+      "cleanup_state": "complete",
+      "stop_evidence_kind": "kubelet_terminal",
+      "operation_id": "241db50ca0ab4b949440217abf7e5356",
+      "status": "completed",
+      "cleanup_status": "complete",
+      "deadline": "2026-10-22T20:07:59",
+      "tier": {
+        "memory_mb": 128,
+        "cpu_millicores": 100
+      },
+      "data_action_finished": "done"
+    },
+    "timeline": {
+      "operation_id": "241db50ca0ab4b949440217abf7e5356",
+      "create_time": "2026-10-22T20:06:59",
+      "deadline": "2026-10-22T20:07:59",
+      "status": "completed",
+      "cleanup_status": "complete",
+      "data_action_finished": "done",
+      "charged_memory_mb": 128,
+      "charged_cpu_millicores": 100,
+      "reservation_state": "released",
+      "facts": [
+        {
+          "phase": "starting",
+          "at": "2026-10-22T20:06:59"
+        },
+        {
+          "phase": "ready",
+          "at": "2026-10-22T20:07:12"
+        },
+        {
+          "phase": "completed",
+          "at": "2026-10-22T20:07:16"
+        }
+      ],
+      "total_seconds": 17.0,
+      "ready_seconds": 13.0
+    },
+    "pod": {
+      "uid": "776ae57a-b32a-4bd2-9048-4efdf0d47323",
+      "name": "app-6153b2cdf43a4bbef23a5d0a47e2a916ca8a0082",
+      "namespace": "e2e-f059-apps",
+      "created_at": "2026-10-22T20:06:59Z",
+      "resource_ref": "52882475-2fcb-5869-923a-604b275ca9fd",
+      "containers": [
+        {
+          "name": "data-helper",
+          "resources": {
+            "requests": {
+              "ephemeral-storage": "8Gi",
+              "cpu": "100m",
+              "memory": "128Mi"
+            },
+            "limits": {
+              "ephemeral-storage": "8Gi",
+              "cpu": "100m",
+              "memory": "128Mi"
+            }
+          }
+        }
+      ],
+      "init": [
+        {
+          "name": "data-layout",
+          "resources": {
+            "requests": {
+              "ephemeral-storage": "8Gi",
+              "cpu": "100m",
+              "memory": "128Mi"
+            },
+            "limits": {
+              "ephemeral-storage": "8Gi",
+              "cpu": "100m",
+              "memory": "128Mi"
+            }
+          }
+        }
+      ]
+    },
+    "response_check": "phase checked; original shared API assertions passed",
+    "boundary": "same-image CPU parameter correction; original 60-second total and cleanup-before-response unchanged"
+  },
+  "first_recovery_after_timed_out_stop": {
+    "operation_id": "d5c5e311f5044dc89659764493868cb8",
+    "version_id": "ba5d47d9369540df9d5219adc72a55bf",
+    "status": "completed",
+    "cleanup_status": "complete",
+    "row_comparison": "stopped_to_resumed equal",
+    "original_stop_status_unchanged": "timed_out",
+    "journal_event": "r10-recovery-resume passed"
+  },
+  "new_independent_cycle": {
+    "operation_id": "a89bbe4dfe99484faaa6308d8910aeea",
+    "version_id": "ba5d47d9369540df9d5219adc72a55bf",
+    "event": "stop-resume passed",
+    "boundary": "new independent stop/resume; not rewriting original failed cycle"
+  },
+  "current_business_batch": {
+    "journal": "/opt/e2e-f059/evidence/k8s-platform-k189r10-source-registry-resumed.json",
+    "state": "running",
+    "journal_frozen": false,
+    "tool_sha256": "3e8b0728030eb0e6f5509c77ad1f116b7198c862960765d205b8d0635b4fdef4",
+    "parent_sha256": "12231d57220e4f5343f2db67c0fa988382f34a33d62ba14f28c7739ac692e100",
+    "app_id": "fad0b104e09c46cda59469248ab20605",
+    "deployment_id": "afaaff456f46430a95659bd7de7e2ceb",
+    "version_id": "818d435bba5c4902be4c16ff879c87fb",
+    "prepare_operation_id": "13c9c01e925f4c42947b49e18be35802",
+    "current_case": "source_v2",
+    "previous_positive_cases": [
+      "python_bundle_v1_v2",
+      "node_bundle_v1_v2",
+      "static_bundle_v1_v2",
+      "source_v1_and_stop_resume"
+    ],
+    "remaining": [
+      "source_v2",
+      "full_image_v1_v2",
+      "missing_dependency",
+      "offline_data",
+      "fault_upgrade"
+    ],
+    "auto_cleanup": false
+  },
+  "python_node_v2_passed": {
+    "runtime_source_commit": "6f254f337dd6e8a36733501a2266bf08a04a56e9",
+    "journal": "/opt/e2e-f059/evidence/k8s-platform-k189r10-cycle-resumed.json",
+    "journal_sha256": "94ca8e8590845ab2e4bb10edacaee7198de6eca176141dc9c904fc20bdf46f0f",
+    "python_app_id": "a4a566cacbfb477daaf2bd90ea3eb21a",
+    "python_v2_version_id": "a1c22f4e6de448dab2783ffd499568ef",
+    "node_app_id": "4316627161aa458a950d14497d2f21e9",
+    "node_v2_version_id": "c92730bca2384077900e24be1f7d88a2",
+    "both_v1_v2_publish_preview_approval_http_websocket_permissions": true,
+    "both_stop_resume_passed": true,
+    "not_full_suite_success": true
+  },
+  "static_bundle_path_failure": {
+    "runtime_source_commit": "6f254f337dd6e8a36733501a2266bf08a04a56e9",
+    "app_id": "46ed080f7a6d4e24a94a2e0d6d499650",
+    "deployment_id": "21acae653eb242159456a6df4b63d0de",
+    "version_id": "28185232a18747829d03db881c449f8e",
+    "operation_id": "486ca50cd9404ccba1afce4ad64a7cdd",
+    "failure_code": 16124,
+    "failure_stage": "precheck_probe",
+    "operation_reason": "workload_readiness_timeout",
+    "operation_failed_stage": "starting",
+    "all_init_completed": true,
+    "app_exit_code": 127,
+    "app_log_nginx_not_found": true,
+    "source_artifact_id": "7ef77b6b1fe0487f84e262335afd683f",
+    "source_package_sha256": "ef034a25c23e896d20ecda05b7b2f314b8abeece33f4d6568b9fa4faa2c081ea",
+    "source_object_size_bytes": 492,
+    "source_object_bytes_compared": true,
+    "cause": "Bundle PATH replaced image PATH but omitted /usr/sbin where the frozen static image installs nginx",
+    "native_same_image_red_exit": 127,
+    "native_same_image_green_exit": 0,
+    "native_entrypoint_nginx_config_test_passed": true,
+    "cleanup_proof": {
+      "app_id": "46ed080f7a6d4e24a94a2e0d6d499650",
+      "cleanup_status": "complete",
+      "deployment_id": "21acae653eb242159456a6df4b63d0de",
+      "namespace": "e2e-f059-apps",
+      "observed_at": "2026-10-22T20:53:48.086274Z",
+      "operator_supplied": true,
+      "parent_journal_sha256": "94ca8e8590845ab2e4bb10edacaee7198de6eca176141dc9c904fc20bdf46f0f",
+      "probe_operation_id": "486ca50cd9404ccba1afce4ad64a7cdd",
+      "resources": [
+        {
+          "api_absent": true,
+          "cleanup_state": "complete",
+          "kind": "Secret",
+          "name": "credentials-006cb1de7368221e207161f8f9d4f0ee5515b102",
+          "namespace": "e2e-f059-apps",
+          "resource_ref": "2986d076-290e-5bfb-a502-1c363a8319f6",
+          "stop_evidence_kind": null,
+          "stop_state": "requested",
+          "uid": "13588d07-4828-42db-90b6-0de341af4888"
+        },
+        {
+          "api_absent": true,
+          "cleanup_state": "complete",
+          "kind": "Pod",
+          "name": "task-006cb1de7368221e207161f8f9d4f0ee5515b102",
+          "namespace": "e2e-f059-apps",
+          "resource_ref": "63daf83a-8bec-5d9a-a617-0096edd4355f",
+          "stop_evidence_kind": "kubelet_terminal",
+          "stop_state": "confirmed",
+          "uid": "2d74bfe5-b2d0-45f5-98d1-c536cf974287"
+        },
+        {
+          "api_absent": true,
+          "cleanup_state": "complete",
+          "kind": "Service",
+          "name": "svc-006cb1de7368221e207161f8f9d4f0ee5515b102",
+          "namespace": "e2e-f059-apps",
+          "resource_ref": "6a68720b-6409-53fd-a3ad-e90aa0c09d5c",
+          "stop_evidence_kind": null,
+          "stop_state": "requested",
+          "uid": "33f69237-786d-49ac-83f2-286b93ed75d8"
+        },
+        {
+          "api_absent": true,
+          "cleanup_state": "complete",
+          "kind": "Secret",
+          "name": "material-006cb1de7368221e207161f8f9d4f0ee5515b102",
+          "namespace": "e2e-f059-apps",
+          "resource_ref": "cdac2ba2-a442-5aa4-8869-5fbe3a768767",
+          "stop_evidence_kind": null,
+          "stop_state": "requested",
+          "uid": "e32126d0-4233-47fd-88c9-b3acf21f7f86"
+        }
+      ],
+      "schema": "f059-r10-static-probe-cleanup/v1",
+      "source_artifact_id": "7ef77b6b1fe0487f84e262335afd683f",
+      "source_object_bytes_verified_by_backend": true,
+      "source_object_size_bytes": 492,
+      "source_package_sha256": "ef034a25c23e896d20ecda05b7b2f314b8abeece33f4d6568b9fa4faa2c081ea",
+      "status": "failed",
+      "version_id": "28185232a18747829d03db881c449f8e"
+    },
+    "cleanup_proof_sha256": "ad356e6994253f034d6f6f5d10c4091c56519e20f8367939becad6098cc4da5a",
+    "new_source_commit": "995c80f2a1d7288a2e5b9e7dc650f7ce1d911798",
+    "old_failure_retained": true
+  },
+  "static_v2_passed": {
+    "app_id": "46ed080f7a6d4e24a94a2e0d6d499650",
+    "v1": "1672b4a7e9a2413cac591cfcd6fd7453",
+    "v2": "4fb07dc0244944ffada25c28d795bf47",
+    "first_publish": true,
+    "preview_and_approval": true,
+    "owner_entry_boundaries": true,
+    "stop_resume": true,
+    "iteration": true,
+    "websocket": "not_applicable_static",
+    "source_commit": "995c80f2a1d7288a2e5b9e7dc650f7ce1d911798",
+    "journal": "/opt/e2e-f059/evidence/k8s-platform-k189r10-static-resumed.json",
+    "journal_sha256": "9c953e824afb0548e8547911ca386008cc5f8e5e03da7871ea5eec95083a9415"
+  },
+  "source_image_preparation_failure": {
+    "app_id": "fad0b104e09c46cda59469248ab20605",
+    "deployment_id": "5b146d57c4bb46b093ad5cf5c754faa1",
+    "version_id": "ce7b8984fd964a028fe2513cb14907a1",
+    "operation_id": "70eb72dbbbf44e00a7c536507bfcca35",
+    "public_code": 16227,
+    "stage": "precheck_build",
+    "reason": "preparation_phase_failed",
+    "diagnostic": "oci_privilege_bits_refused",
+    "status": "failed",
+    "cleanup_status": "complete",
+    "preparation_completed": [
+      "inputs",
+      "resolve",
+      "install",
+      "upload"
+    ],
+    "assembly": "failed",
+    "resource_count": 9,
+    "pod_count": 5,
+    "pod_stops": "confirmed/kubelet_terminal",
+    "resources_fresh_absence": "pending_new_proof",
+    "source_artifact": "0bce8a2cd7524276974afc7bc3485fe0",
+    "source_package_size": 598757,
+    "source_package_sha256": "538dae69b69dd151d62cb242be52487ecd1b026322f9f2fb181578781308aeef",
+    "backend_storage_bytes_verified": true,
+    "readonly_pv_stat": {
+      "pv": "e2e-f059-scratch-34",
+      "claim_uid": "47a1e29e-b55f-46ec-955f-759ef174e2b1",
+      "sgid_directories": 267,
+      "suid_sgid_regular_files": 0
+    },
+    "fix_source_commit": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+    "new_business_attempt": "not_started"
+  },
+  "final_code_candidate": {
+    "source_commit": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+    "tasks_complete": 248,
+    "tasks_total": 253,
+    "runtime_passed": 1628,
+    "runtime_skipped": 13,
+    "all_build_inputs_byte_equal": false,
+    "withdrawn_cmd_candidate": "d7f7c2e10ab9805d41120d19e32d102310b06654",
+    "reason": "Pinned crane already clears Cmd with the existing entrypoint flag",
+    "restore_deployment": "passed",
+    "source_continuation": "failed_registry_network_configuration",
+    "source_preflight_check": "passed_without_new_publication",
+    "helm_revision": 27,
+    "controller_epoch": 21,
+    "runtime_image_build_inputs_byte_equal": true,
+    "installer_commit": "dedc4831bb462f832877940901201061b1e027cf"
+  },
+  "cmd_false_positive_correction": {
+    "official_source": "https://github.com/google/go-containerregistry/blob/v0.22.1/cmd/crane/cmd/mutate.go#L89-L98",
+    "actual_three_base_without_cmd_passed": true,
+    "no_registry_mutation": true,
+    "new_source_publication_started": false,
+    "temporary_candidate_equivalent_redundant_flag": true
+  },
+  "source_registry_failure": {
+    "prepare_operation_id": "07b872e0d07441f7a333941560727bba",
+    "inputs_resolve_install_upload": "completed",
+    "oci_assembly": "failed during registry access",
+    "root_cause": "Calico egress policy observed Docker DNAT destination port 5000; only external port 5443 was allowed",
+    "diagnostic": {
+      "after_removal": {
+        "result": "TimeoutError",
+        "seconds": 3.011
+      },
+      "before": {
+        "result": "TimeoutError",
+        "seconds": 3.008
+      },
+      "diagnostic_policy_deleted": true,
+      "diagnostic_policy_uid": "4a9affa2-c26a-4eed-a842-4ed4f8b3a5fb",
+      "dnat_target": "172.17.0.2:5000",
+      "finished_at": "2026-10-22T22:18:40.554384Z",
+      "started_at": "2026-10-22T22:18:32.714147Z",
+      "target": "192.168.106.189:5443",
+      "with_exact_dnat_port": {
+        "result": "connected",
+        "seconds": 0.008
+      }
+    },
+    "diagnostic_sha256": "5c0f8b1033b223b7a6c75e762f9bbdc3d21a3d063b8c5ff9f37679e34f63b52f",
+    "delivery_fix": {
+      "configuration_only": true,
+      "descriptor_and_report_unchanged": true,
+      "dnat_diagnostic_sha256": "5c0f8b1033b223b7a6c75e762f9bbdc3d21a3d063b8c5ff9f37679e34f63b52f",
+      "egress_ports_after": [
+        80,
+        443,
+        5000,
+        5443,
+        5444
+      ],
+      "egress_ports_before": [
+        80,
+        443,
+        5443,
+        5444
+      ],
+      "egress_real_tcp_after": {
+        "result": "connected",
+        "seconds": 0.008
+      },
+      "helm_revision": 27,
+      "image_and_profiles_unchanged": true,
+      "new_capability_probe_claimed": false,
+      "observed_at": 1792707597.673588
+    },
+    "delivery_fix_sha256": "bdf01d29a962f0ed0733ef1d275ccb2be9e9883b511fe4025a736419f0ca4a3b",
+    "egress_tls": {
+      "ca_and_hostname_verified": true,
+      "http_status": 200,
+      "sha256": "873961a7cd15539963d839e981edb17ee8d9cc719c22493569ea9df1d46ff288",
+      "assembler_push_claimed": false
+    },
+    "cleanup_resources": 9,
+    "same_uid_terminal_pods": 5,
+    "new_source_publication": {
+      "state": "accepted",
+      "deployment_id": "dd3d5b5533aa459eb9cff9d7d3685fd8",
+      "version_id": "60ebc5e1230942cb975be94de9fab954",
+      "prepare_operation_id": "f278274b3e924d808ec6f3903a3fa7a9",
+      "journal": "/opt/e2e-f059/evidence/k8s-platform-k189r10-source-registry-resumed.json",
+      "journal_frozen": false,
+      "tool_sha256": "3e8b0728030eb0e6f5509c77ad1f116b7198c862960765d205b8d0635b4fdef4"
+    },
+    "installation_probe_gap": "registry-push previously ran crane externally; same-selector upstream TCP check added in T254/T255"
+  },
+  "source_v1_passed": {
+    "app_id": "fad0b104e09c46cda59469248ab20605",
+    "deployment_id": "dd3d5b5533aa459eb9cff9d7d3685fd8",
+    "version_id": "60ebc5e1230942cb975be94de9fab954",
+    "prepare_operation_id": "f278274b3e924d808ec6f3903a3fa7a9",
+    "prepare_result": "completed/cleanupcomplete",
+    "preview_and_approval": "passed",
+    "publish": "passed",
+    "http_and_websocket": "passed",
+    "owner_boundaries": "passed",
+    "stop_resume": "passed",
+    "oci_lineage": {
+      "application_layer_digest": "sha256:05f2352facb96062c46a6b24d49013f88de79076fb208c6837e4fc483e7524f0",
+      "base_image": "192.168.106.189:5443/f059/app-python-ca@sha256:b632d5192bd370c5690aee2c2cc192954522dd0cd44ab0f5c8be050a3e033263",
+      "base_layer_count": 10,
+      "cmd": [
+        "python",
+        "main.py"
+      ],
+      "cmd_matches_frozen_startup": true,
+      "config_digest": "sha256:b0a5df71c119c602e4c551413524af2b437785b49da0b4cbe120442e49924279",
+      "entrypoint": [
+        "/usr/local/bin/bisheng-app-entrypoint"
+      ],
+      "exact_base_layer_prefix": true,
+      "observed_at": 1792708539.2156847,
+      "profile_revision": 2,
+      "proof_scope": "immutable-registry-manifest-and-config; workload identity separate",
+      "schema": "f059-source-oci-lineage/v1",
+      "source_image": "192.168.106.189:5443/f059/apps@sha256:d95ed14789fbb261fa479c1a4c2c06b8d9d47ea113fdcc88d1183e65f0d45074",
+      "tls_and_hostname_verification": true,
+      "user": "10001:10001",
+      "workdir": "/app"
+    },
+    "oci_lineage_sha256": "b55761d153e65350cf5e0b9e8c44d66717b90177cefb12d957026663355b7695",
+    "running_identity": {
+      "app_id": "fad0b104e09c46cda59469248ab20605",
+      "container_id": "docker://c7cd51e2344d2d3d950395766d09e336c1fcc10a5d0b1b502105c42701d6c451",
+      "cri_image_id": "docker-pullable://192.168.106.189:5443/f059/apps@sha256:d95ed14789fbb261fa479c1a4c2c06b8d9d47ea113fdcc88d1183e65f0d45074",
+      "deleting": false,
+      "docker_config_id": "sha256:b0a5df71c119c602e4c551413524af2b437785b49da0b4cbe120442e49924279",
+      "immutable_oci_lineage_sha256": "b55761d153e65350cf5e0b9e8c44d66717b90177cefb12d957026663355b7695",
+      "namespace": "e2e-f059-apps",
+      "observed_at": 1792708800.093023,
+      "phase": "Running",
+      "pod_name": "app-1eaef29a6764649b429a9f2a90aea27e4afd7752",
+      "pod_uid": "058c99b2-cf64-4227-94ad-111e450f06dc",
+      "ready": true,
+      "schema": "f059-source-running-image/v1",
+      "source_image": "192.168.106.189:5443/f059/apps@sha256:d95ed14789fbb261fa479c1a4c2c06b8d9d47ea113fdcc88d1183e65f0d45074",
+      "version_id": "60ebc5e1230942cb975be94de9fab954"
+    },
+    "running_identity_sha256": "74a246285a7f1c82545479fcb8e60787cb9970503386bf22cb76f59ffd0c42bd"
+  },
+  "source_v2_passed": {
+    "deployment_id": "afaaff456f46430a95659bd7de7e2ceb",
+    "version_id": "818d435bba5c4902be4c16ff879c87fb",
+    "prepare_operation_id": "13c9c01e925f4c42947b49e18be35802",
+    "preview_approval_http_websocket_owner": "passed",
+    "image_identity": {
+      "app_id": "fad0b104e09c46cda59469248ab20605",
+      "application_layer_digest": "sha256:0cc450d49beb2cc35fa8687d3f741d7a9f03ab4458151a787f43820f7d5dcf33",
+      "base_image": "192.168.106.189:5443/f059/app-python-ca@sha256:b632d5192bd370c5690aee2c2cc192954522dd0cd44ab0f5c8be050a3e033263",
+      "base_layer_count": 10,
+      "cmd_matches_frozen_startup": true,
+      "config_digest": "sha256:a3cb8cff3a26fd25792b585ea5b6ca0e0df195b9652692424d078b83d8edea04",
+      "container_id": "docker://66ec31a474042a32af31f10c73f001813f6dcd0acb81950a5d4edbf9491152cf",
+      "cri_image_id": "docker-pullable://192.168.106.189:5443/f059/apps@sha256:a8a3ef4b3282fd3dede239299afa7da342c75ad6e4a8fdf7bc45a3604e1eca89",
+      "deleting": false,
+      "deployment_id": "afaaff456f46430a95659bd7de7e2ceb",
+      "docker_config_id": "sha256:a3cb8cff3a26fd25792b585ea5b6ca0e0df195b9652692424d078b83d8edea04",
+      "exact_base_layer_prefix": true,
+      "observed_at": 1792709146.671216,
+      "phase": "Running",
+      "pod_name": "app-1eaef29a6764649b429a9f2a90aea27e4afd7752",
+      "pod_uid": "0ad1b4c0-db79-440c-a1f7-4acfba699692",
+      "profile_revision": 2,
+      "ready": true,
+      "schema": "f059-source-v2-image-identity/v1",
+      "source_image": "192.168.106.189:5443/f059/apps@sha256:a8a3ef4b3282fd3dede239299afa7da342c75ad6e4a8fdf7bc45a3604e1eca89",
+      "tls_and_hostname_verified": true,
+      "version_id": "818d435bba5c4902be4c16ff879c87fb"
+    },
+    "image_identity_sha256": "3ce4f56806d38ef283f0f27e4f32275488496585375927db4155dec38b860f63"
+  },
+  "fullimage_v2_capacity_failure": {
+    "parent_journal": "/opt/e2e-f059/evidence/k8s-platform-k189r10-source-registry-resumed.json",
+    "parent_sha256": "1c1c6a8db64f987ad5e85b816c4ed51219e21692a636bd2289a1909bbcc51aec",
+    "parent_outcome": "failed",
+    "v1_business_http_websocket_stop_resume": "passed",
+    "before": {
+      "app": {
+        "current_version_id": "0865c634ece74322b038ba47be7b8838",
+        "id": "ec6229a98d434d7cb58f740b58945ff6",
+        "pending_version_id": "77d42f56f77f4287b63574b9fd81b0ab",
+        "slug": "e2e-f059-python-image-k189r10",
+        "state": "online"
+      },
+      "capacity": {
+        "cpu_budget_millicores": 4000,
+        "memory_budget_mb": 8192,
+        "r4_hold_preserved": true,
+        "reserved_cpu_millicores": 3125,
+        "reserved_memory_mb": 6784
+      },
+      "deployment": {
+        "approval_instance_id": 11,
+        "failure_code": 16226,
+        "failure_details": {
+          "app_reason": "capacity_exhausted",
+          "park_stage": "admission",
+          "reason": "iteration_failed"
+        },
+        "id": "c562f14128b042f1834ccee5b5cedf3e",
+        "stage": "publishing",
+        "status": "failed",
+        "version_id": "77d42f56f77f4287b63574b9fd81b0ab"
+      },
+      "operations": [
+        {
+          "cleanup_status": "complete",
+          "id": "af8922a5817043e0a61c2eaa00a44439",
+          "kind": "prepare",
+          "session_id": null,
+          "stage": "completed",
+          "status": "completed",
+          "version_id": "0865c634ece74322b038ba47be7b8838"
+        },
+        {
+          "cleanup_status": "complete",
+          "id": "a6c320e76f834b34a6da86a43359ad36",
+          "kind": "probe",
+          "session_id": null,
+          "stage": "completed",
+          "status": "completed",
+          "version_id": "0865c634ece74322b038ba47be7b8838"
+        },
+        {
+          "cleanup_status": "complete",
+          "id": "ab746056a47f41dba8e6ef17ccd0f8a7",
+          "kind": "preview",
+          "session_id": "1346873a7b48479b91f5d8ef48a6ad88",
+          "stage": "cancelled",
+          "status": "cancelled",
+          "version_id": "0865c634ece74322b038ba47be7b8838"
+        },
+        {
+          "cleanup_status": "complete",
+          "id": "916e17a2ff6c42a284b7fc78ba338e64",
+          "kind": "deploy",
+          "session_id": null,
+          "stage": "completed",
+          "status": "completed",
+          "version_id": "0865c634ece74322b038ba47be7b8838"
+        },
+        {
+          "cleanup_status": "complete",
+          "id": "58b40f78a2164bc4a1affe5658ee2785",
+          "kind": "data",
+          "session_id": null,
+          "stage": "completed",
+          "status": "completed",
+          "version_id": ""
+        },
+        {
+          "cleanup_status": "complete",
+          "id": "50c8eb274ef44ba088b66fcbcda68ffd",
+          "kind": "data",
+          "session_id": null,
+          "stage": "completed",
+          "status": "completed",
+          "version_id": ""
+        },
+        {
+          "cleanup_status": "complete",
+          "id": "603e84c3d2984dc88d016e04ab5b0b54",
+          "kind": "data",
+          "session_id": null,
+          "stage": "completed",
+          "status": "completed",
+          "version_id": ""
+        },
+        {
+          "cleanup_status": "complete",
+          "id": "1f2b88d2baaa40e692cb416c9a98daad",
+          "kind": "data",
+          "session_id": null,
+          "stage": "completed",
+          "status": "completed",
+          "version_id": ""
+        },
+        {
+          "cleanup_status": "complete",
+          "id": "a993803418cf4ed691c09a9841205ab1",
+          "kind": "data",
+          "session_id": null,
+          "stage": "completed",
+          "status": "completed",
+          "version_id": ""
+        },
+        {
+          "cleanup_status": "complete",
+          "id": "cf7cf0f16e73491ebbade11dc5458ed3",
+          "kind": "data",
+          "session_id": null,
+          "stage": "completed",
+          "status": "completed",
+          "version_id": ""
+        },
+        {
+          "cleanup_status": "complete",
+          "id": "298b393d9a9b4ac38a37d4bd991fed41",
+          "kind": "data",
+          "session_id": null,
+          "stage": "completed",
+          "status": "completed",
+          "version_id": ""
+        },
+        {
+          "cleanup_status": "complete",
+          "id": "4368d595730b410bb6a0fe4057a19826",
+          "kind": "stop",
+          "session_id": null,
+          "stage": "completed",
+          "status": "completed",
+          "version_id": ""
+        },
+        {
+          "cleanup_status": "complete",
+          "id": "f8c06502a4c4405dbdf1bdd9e35ba367",
+          "kind": "deploy",
+          "session_id": null,
+          "stage": "completed",
+          "status": "completed",
+          "version_id": "0865c634ece74322b038ba47be7b8838"
+        },
+        {
+          "cleanup_status": "complete",
+          "id": "5a3e67249a7e4cb2bb8bf12e22535d16",
+          "kind": "data",
+          "session_id": null,
+          "stage": "completed",
+          "status": "completed",
+          "version_id": ""
+        },
+        {
+          "cleanup_status": "complete",
+          "id": "5a8d9bc2a7bc4186940ae0e31e1ca6be",
+          "kind": "prepare",
+          "session_id": null,
+          "stage": "completed",
+          "status": "completed",
+          "version_id": "77d42f56f77f4287b63574b9fd81b0ab"
+        },
+        {
+          "cleanup_status": "complete",
+          "id": "ed671bea2498424087ce1d7b407a5547",
+          "kind": "probe",
+          "session_id": null,
+          "stage": "completed",
+          "status": "completed",
+          "version_id": "77d42f56f77f4287b63574b9fd81b0ab"
+        },
+        {
+          "cleanup_status": "complete",
+          "id": "e8e41897fb9a4cdeb92e1027753e121d",
+          "kind": "data",
+          "session_id": null,
+          "stage": "completed",
+          "status": "completed",
+          "version_id": ""
+        },
+        {
+          "cleanup_status": "complete",
+          "id": "bbe06eaf607542ad81a721e021e0e09e",
+          "kind": "preview",
+          "session_id": "ddc8b2d98b5545cc9a31a4f2e382d9d6",
+          "stage": "cancelled",
+          "status": "cancelled",
+          "version_id": "77d42f56f77f4287b63574b9fd81b0ab"
+        },
+        {
+          "cleanup_status": "complete",
+          "id": "309e2c020a7b4cc39502b4dbdac4e48d",
+          "kind": "data",
+          "session_id": null,
+          "stage": "completed",
+          "status": "completed",
+          "version_id": ""
+        }
+      ],
+      "previews": [
+        {
+          "id": "1346873a7b48479b91f5d8ef48a6ad88",
+          "reclaim_reason": "manual",
+          "status": "reclaimed",
+          "version_id": "0865c634ece74322b038ba47be7b8838"
+        },
+        {
+          "id": "ddc8b2d98b5545cc9a31a4f2e382d9d6",
+          "reclaim_reason": "manual",
+          "status": "reclaimed",
+          "version_id": "77d42f56f77f4287b63574b9fd81b0ab"
+        }
+      ],
+      "resources": [
+        {
+          "cleanup_state": "complete",
+          "kind": "Pod",
+          "name": "task-a70fdbc69c2536663feed90292bb19c47d8f4f1c",
+          "operation_id": "ab746056a47f41dba8e6ef17ccd0f8a7",
+          "purpose": "preview",
+          "resource_ref": "06c1e6f5-c986-5449-9cbd-98e6ee198383",
+          "stop_evidence_kind": "kubelet_terminal",
+          "stop_state": "confirmed",
+          "uid": "0493f4ac-761d-4ee4-964e-fb7df9359906"
+        },
+        {
+          "cleanup_state": "pending",
+          "kind": "PersistentVolumeClaim",
+          "name": "data-07560d0f5d6ee4b0d34bd3b26604fd5346754065",
+          "operation_id": "916e17a2ff6c42a284b7fc78ba338e64",
+          "purpose": "migrate",
+          "resource_ref": "07bf633a-779e-50ae-a9c3-e2c7d004cfdf",
+          "stop_evidence_kind": null,
+          "stop_state": "held",
+          "uid": "55f2a8fc-a27d-4878-ada2-44cd5b167736"
+        },
+        {
+          "cleanup_state": "complete",
+          "kind": "Service",
+          "name": "svc-a70fdbc69c2536663feed90292bb19c47d8f4f1c",
+          "operation_id": "ab746056a47f41dba8e6ef17ccd0f8a7",
+          "purpose": "preview",
+          "resource_ref": "0a7607ef-95b7-5341-8403-d8eddd976fad",
+          "stop_evidence_kind": null,
+          "stop_state": "requested",
+          "uid": "40582563-985f-4115-90f9-16491174dbb8"
+        },
+        {
+          "cleanup_state": "complete",
+          "kind": "Pod",
+          "name": "task-911bd63f7a60ab90904b1a2e34161cdb80c4f14f",
+          "operation_id": "a6c320e76f834b34a6da86a43359ad36",
+          "purpose": "probe",
+          "resource_ref": "20ab2a67-f685-5a5e-8b70-36e20f21e415",
+          "stop_evidence_kind": "kubelet_terminal",
+          "stop_state": "confirmed",
+          "uid": "47a0c256-a5ea-4de7-a112-6478815948ff"
+        },
+        {
+          "cleanup_state": "complete",
+          "kind": "Service",
+          "name": "svc-4e2c9d1deb53f0b5ab675e7d4056f8cf763fd9d0",
+          "operation_id": "bbe06eaf607542ad81a721e021e0e09e",
+          "purpose": "preview",
+          "resource_ref": "27f12a40-e669-5de6-9e8a-51a8cefab0c4",
+          "stop_evidence_kind": null,
+          "stop_state": "requested",
+          "uid": "03e207b2-14d2-44e9-b0f9-5cda14a7af45"
+        },
+        {
+          "cleanup_state": "complete",
+          "kind": "Secret",
+          "name": "material-f48a3bf54f0f5a2eaae35c1512f370c7",
+          "operation_id": "916e17a2ff6c42a284b7fc78ba338e64",
+          "purpose": "migrate",
+          "resource_ref": "3a690a4d-a564-561c-af52-183644e2431d",
+          "stop_evidence_kind": null,
+          "stop_state": "requested",
+          "uid": "f874c9b8-20ed-4085-9f50-da5ee870904a"
+        },
+        {
+          "cleanup_state": "complete",
+          "kind": "Secret",
+          "name": "material-b7d9c03b49585d079dc0803bbd9f8f7f",
+          "operation_id": "916e17a2ff6c42a284b7fc78ba338e64",
+          "purpose": "migrate",
+          "resource_ref": "56df9297-fa1f-5762-9ecc-b3f3de8d6add",
+          "stop_evidence_kind": null,
+          "stop_state": "requested",
+          "uid": "b151a745-8a8a-4d25-8783-79858a3dba6b"
+        },
+        {
+          "cleanup_state": "pending",
+          "kind": "Service",
+          "name": "svc-af3b6ece1cb2732961077cf077865aa649acafbd",
+          "operation_id": "f8c06502a4c4405dbdf1bdd9e35ba367",
+          "purpose": "serve",
+          "resource_ref": "61be0c97-f82b-5f99-a5c7-d139c90faaf3",
+          "stop_evidence_kind": null,
+          "stop_state": "held",
+          "uid": "65a21932-837e-4a0c-b292-3d61e6caa457"
+        },
+        {
+          "cleanup_state": "complete",
+          "kind": "Service",
+          "name": "svc-911bd63f7a60ab90904b1a2e34161cdb80c4f14f",
+          "operation_id": "a6c320e76f834b34a6da86a43359ad36",
+          "purpose": "probe",
+          "resource_ref": "61cca54e-20a3-5eba-a6c3-03a66705c112",
+          "stop_evidence_kind": null,
+          "stop_state": "requested",
+          "uid": "74b03713-7b59-49a4-8f19-47922580162d"
+        },
+        {
+          "cleanup_state": "pending",
+          "kind": "Secret",
+          "name": "credentials-af3b6ece1cb2732961077cf077865aa649acafbd",
+          "operation_id": "f8c06502a4c4405dbdf1bdd9e35ba367",
+          "purpose": "serve",
+          "resource_ref": "7504cf8e-12a6-5a4a-859a-2c5b45e59d14",
+          "stop_evidence_kind": null,
+          "stop_state": "held",
+          "uid": "126e9522-5f56-4230-b1bb-47a6d3321dde"
+        },
+        {
+          "cleanup_state": "complete",
+          "kind": "Service",
+          "name": "svc-0d1cbbee734d7b506162653f55cca08db395a764",
+          "operation_id": "ed671bea2498424087ce1d7b407a5547",
+          "purpose": "probe",
+          "resource_ref": "7ce23516-766f-5454-b643-f21146d3e966",
+          "stop_evidence_kind": null,
+          "stop_state": "requested",
+          "uid": "074a864d-e3c2-4ba9-811c-3df0a26dbf10"
+        },
+        {
+          "cleanup_state": "complete",
+          "kind": "Secret",
+          "name": "credentials-34d28f3c17b5ef3693955e16b5db843f57db83e8",
+          "operation_id": "916e17a2ff6c42a284b7fc78ba338e64",
+          "purpose": "serve",
+          "resource_ref": "ab191115-62b1-51ae-8df9-a34a8ed61a52",
+          "stop_evidence_kind": null,
+          "stop_state": "requested",
+          "uid": "81450992-d839-427e-8c67-dc6508521e5e"
+        },
+        {
+          "cleanup_state": "complete",
+          "kind": "Service",
+          "name": "svc-34d28f3c17b5ef3693955e16b5db843f57db83e8",
+          "operation_id": "916e17a2ff6c42a284b7fc78ba338e64",
+          "purpose": "serve",
+          "resource_ref": "aed2424b-5ebf-5281-be08-2cede2fa04c0",
+          "stop_evidence_kind": null,
+          "stop_state": "requested",
+          "uid": "068d8e91-7c65-40b0-b9a1-54cada20010b"
+        },
+        {
+          "cleanup_state": "complete",
+          "kind": "Pod",
+          "name": "app-07560d0f5d6ee4b0d34bd3b26604fd5346754065",
+          "operation_id": "916e17a2ff6c42a284b7fc78ba338e64",
+          "purpose": "migrate",
+          "resource_ref": "b7d9c03b-4958-5d07-9dc0-803bbd9f8f7f",
+          "stop_evidence_kind": "kubelet_terminal",
+          "stop_state": "confirmed",
+          "uid": "a5c377bb-9d61-4426-bffe-e623f1579e3b"
+        },
+        {
+          "cleanup_state": "complete",
+          "kind": "Pod",
+          "name": "task-0d1cbbee734d7b506162653f55cca08db395a764",
+          "operation_id": "ed671bea2498424087ce1d7b407a5547",
+          "purpose": "probe",
+          "resource_ref": "c12c5e05-22bc-5f4c-a105-a09157a3c4ea",
+          "stop_evidence_kind": "kubelet_terminal",
+          "stop_state": "confirmed",
+          "uid": "5937be0a-e4d4-4bc3-a34f-4b4356192477"
+        },
+        {
+          "cleanup_state": "complete",
+          "kind": "Pod",
+          "name": "app-07560d0f5d6ee4b0d34bd3b26604fd5346754065",
+          "operation_id": "916e17a2ff6c42a284b7fc78ba338e64",
+          "purpose": "serve",
+          "resource_ref": "c3991d10-73ab-5489-9d32-df799d04e3f5",
+          "stop_evidence_kind": "kubelet_terminal",
+          "stop_state": "confirmed",
+          "uid": "85e1c9df-ea02-4c8e-9128-a03e3211f625"
+        },
+        {
+          "cleanup_state": "complete",
+          "kind": "Pod",
+          "name": "task-4e2c9d1deb53f0b5ab675e7d4056f8cf763fd9d0",
+          "operation_id": "bbe06eaf607542ad81a721e021e0e09e",
+          "purpose": "preview",
+          "resource_ref": "c8782c82-0437-59e8-96b0-18a40f5a2a2a",
+          "stop_evidence_kind": "kubelet_terminal",
+          "stop_state": "confirmed",
+          "uid": "c7510b74-cf50-4114-aa1e-e554a71d86b6"
+        },
+        {
+          "cleanup_state": "complete",
+          "kind": "Secret",
+          "name": "credentials-4e2c9d1deb53f0b5ab675e7d4056f8cf763fd9d0",
+          "operation_id": "bbe06eaf607542ad81a721e021e0e09e",
+          "purpose": "preview",
+          "resource_ref": "e5048b64-b6dc-5bae-ba31-b912e3f3fe18",
+          "stop_evidence_kind": null,
+          "stop_state": "requested",
+          "uid": "9c08ddd5-a563-4401-a95c-466ec0c5734c"
+        },
+        {
+          "cleanup_state": "complete",
+          "kind": "Secret",
+          "name": "credentials-911bd63f7a60ab90904b1a2e34161cdb80c4f14f",
+          "operation_id": "a6c320e76f834b34a6da86a43359ad36",
+          "purpose": "probe",
+          "resource_ref": "e6f369d5-a2ee-5dec-a013-a260b8e64da1",
+          "stop_evidence_kind": null,
+          "stop_state": "requested",
+          "uid": "ef79f388-d22b-4c9b-b44a-5c3d65c494d0"
+        },
+        {
+          "cleanup_state": "complete",
+          "kind": "Secret",
+          "name": "credentials-a70fdbc69c2536663feed90292bb19c47d8f4f1c",
+          "operation_id": "ab746056a47f41dba8e6ef17ccd0f8a7",
+          "purpose": "preview",
+          "resource_ref": "ea27bfb8-95b0-53c7-8d53-2aa5939022e3",
+          "stop_evidence_kind": null,
+          "stop_state": "requested",
+          "uid": "f48314c0-f796-4686-b05f-f4f738d2c416"
+        },
+        {
+          "cleanup_state": "complete",
+          "kind": "Pod",
+          "name": "app-07560d0f5d6ee4b0d34bd3b26604fd5346754065",
+          "operation_id": "916e17a2ff6c42a284b7fc78ba338e64",
+          "purpose": "migrate",
+          "resource_ref": "f48a3bf5-4f0f-5a2e-aae3-5c1512f370c7",
+          "stop_evidence_kind": "kubelet_terminal",
+          "stop_state": "confirmed",
+          "uid": "9c727d24-b3be-47a8-bcf8-9a3351028f4b"
+        },
+        {
+          "cleanup_state": "complete",
+          "kind": "Secret",
+          "name": "credentials-0d1cbbee734d7b506162653f55cca08db395a764",
+          "operation_id": "ed671bea2498424087ce1d7b407a5547",
+          "purpose": "probe",
+          "resource_ref": "f9063489-203a-596d-af2d-c57c84f3aedd",
+          "stop_evidence_kind": null,
+          "stop_state": "requested",
+          "uid": "30584fd2-bb1c-40cc-ab2f-8e944a313dbc"
+        },
+        {
+          "cleanup_state": "pending",
+          "kind": "Pod",
+          "name": "app-07560d0f5d6ee4b0d34bd3b26604fd5346754065",
+          "operation_id": "f8c06502a4c4405dbdf1bdd9e35ba367",
+          "purpose": "serve",
+          "resource_ref": "fce73681-0463-5307-9162-00a59ca27942",
+          "stop_evidence_kind": null,
+          "stop_state": "held",
+          "uid": "3f044354-872a-405c-b54b-74ad4cf73251"
+        }
+      ],
+      "schema": "f059-fullimage-upgrade-capacity-before/v1"
+    },
+    "before_sha256": "93061b799e0e68d74ef7b3c7d66217c697fe5e7b477b4d9c955b53412da893cd",
+    "v2_upgrade": "not_passed",
+    "manual_retry": "not_submitted",
+    "missing_dependency": "not_executed",
+    "resolved_by": "fullimage_manual_retry_passed; original failure remains historical"
+  },
+  "preparation_cancellation_development": {
+    "source_revision": "2ac60f808be170e73f0094ba5c8d041f8a9e10cd",
+    "core_revision": "1a568c85c3586a629ce18b6c538fe520799b4e36",
+    "tasks_complete": 250,
+    "tasks_total": 255,
+    "runtime_passed": 1649,
+    "runtime_skipped": 13,
+    "full_runtime_log_sha256": "10eb19fa0b1a40d7e27baabfe36c61d861f0bdb24d84bc759b8604e189f96e7a",
+    "l1": "PASS",
+    "deployed": true,
+    "actual_cancellation": "not_executed",
+    "arm": "deferred",
+    "public_release": false,
+    "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:5619057b1c026d9ff545f4090f6c84c9aaef5fc1050604162770c7f4d018830c",
+    "helm_revision": 28,
+    "node_ready": {
+      "schema": "f059-manager-cancel-node-ready/v1",
+      "source_revision": "2ac60f808be170e73f0094ba5c8d041f8a9e10cd",
+      "node_id": "e2e-f059-node",
+      "status": "ready",
+      "controller_epoch": 22,
+      "required_runtime_checks": 15,
+      "raw_installation_checks": 16,
+      "paths": {
+        "image": true,
+        "bundle": true,
+        "source-image": true
+      },
+      "descriptor_digest": "f306a49e2e396c60c91e76672c72dfc9989763fd5d7dcb091257009f70b8f456",
+      "r4_status": "failed",
+      "r4_cleanup_status": "pending",
+      "capacity": {
+        "memory_budget_mb": 8192,
+        "cpu_budget_millicores": 4000,
+        "committed_mb": 6784,
+        "committed_cpu": 3.125,
+        "instances": 5
+      }
+    }
+  },
+  "fullimage_manual_retry_passed": {
+    "parent_journal": "/opt/e2e-f059/evidence/k8s-platform-k189r10-image-capacity-resumed.json",
+    "parent_sha256": "c6e17830766aa738621e7f681f2e04cc3eb9223e46ce5021266fc894e4aea11b",
+    "tool_sha256": "3ba6276831c11ffcfd89b6acd5b8ab51726b0b4d5443a27197afc87850e056c0",
+    "operator_proof_sha256": "63700163f061b50b9e90a8162877c904fd93a0963af1464792b0eff5e0d777d8",
+    "suite_exit_code": 0,
+    "manual_receipt": {
+      "name": "r10-image-manual-publish-verified",
+      "acs": [
+        "AC-04",
+        "AC-05",
+        "AC-08",
+        "AC-36",
+        "AC-55"
+      ],
+      "outcome": "passed",
+      "kind": "live-api",
+      "observed_at": 1792711643.5252063,
+      "app_id": "ec6229a98d434d7cb58f740b58945ff6",
+      "version_id": "77d42f56f77f4287b63574b9fd81b0ab",
+      "operation_id": "68cc365b61d84c6585b7f9db897ea5c0",
+      "generation": 4,
+      "approval_instance_id": 11,
+      "original_deployment_id": "c562f14128b042f1834ccee5b5cedf3e",
+      "original_deployment_outcome": "failed",
+      "original_failure_code": 16226,
+      "versions": [
+        "0865c634ece74322b038ba47be7b8838",
+        "77d42f56f77f4287b63574b9fd81b0ab"
+      ]
+    },
+    "post_manual_observations": [
+      {
+        "name": "application-check",
+        "acs": [
+          "AC-36",
+          "AC-46"
+        ],
+        "outcome": "passed",
+        "kind": "live-api",
+        "observed_at": 1792711645.5136497,
+        "app_id": "ec6229a98d434d7cb58f740b58945ff6",
+        "path": "/health"
+      },
+      {
+        "name": "application-check",
+        "acs": [
+          "AC-36",
+          "AC-46"
+        ],
+        "outcome": "passed",
+        "kind": "live-api",
+        "observed_at": 1792711645.569608,
+        "app_id": "ec6229a98d434d7cb58f740b58945ff6",
+        "path": "/sdk/identity"
+      },
+      {
+        "name": "application-check",
+        "acs": [
+          "AC-36",
+          "AC-46"
+        ],
+        "outcome": "passed",
+        "kind": "live-api",
+        "observed_at": 1792711646.1958764,
+        "app_id": "ec6229a98d434d7cb58f740b58945ff6",
+        "path": "/sdk/storage-roundtrip"
+      },
+      {
+        "name": "owner-and-entry-boundaries",
+        "acs": [
+          "AC-10",
+          "AC-12",
+          "AC-33",
+          "AC-36",
+          "AC-46"
+        ],
+        "outcome": "passed",
+        "kind": "live-api",
+        "observed_at": 1792711654.9757226,
+        "app_id": "ec6229a98d434d7cb58f740b58945ff6",
+        "entry_status": 200
+      },
+      {
+        "name": "r10-image-capacity-continuation-completed",
+        "acs": [
+          "AC-55"
+        ],
+        "outcome": "passed",
+        "kind": "live-api",
+        "observed_at": 1792711706.105236,
+        "app_id": "ec6229a98d434d7cb58f740b58945ff6",
+        "version_id": "77d42f56f77f4287b63574b9fd81b0ab",
+        "operation_id": "68cc365b61d84c6585b7f9db897ea5c0"
+      }
+    ],
+    "missing_dependency": [
+      {
+        "name": "preapproval-failure",
+        "acs": [
+          "AC-08",
+          "AC-09",
+          "AC-28"
+        ],
+        "outcome": "passed",
+        "kind": "live-api",
+        "observed_at": 1792711705.2895129,
+        "app_id": "fb9a0f9e32f84ef99ae60ee60f6dc34b",
+        "stage": "precheck_build",
+        "code": 16227
+      }
+    ],
+    "final_event": {
+      "name": "r10-image-capacity-continuation-completed",
+      "acs": [
+        "AC-55"
+      ],
+      "outcome": "passed",
+      "kind": "live-api",
+      "observed_at": 1792711706.105236,
+      "app_id": "ec6229a98d434d7cb58f740b58945ff6",
+      "version_id": "77d42f56f77f4287b63574b9fd81b0ab",
+      "operation_id": "68cc365b61d84c6585b7f9db897ea5c0"
+    },
+    "original_failed_parent_unchanged": true,
+    "original_capacity_failure_record_retained": true,
+    "new_versions_or_approvals": false,
+    "prior_four_current_versions_unchanged": true,
+    "old_r4_hold_preserved": true,
+    "offline_data_fault_upgrade_l2_still_pending": true
+  },
+  "protection_report_r29_deployment": {
+    "schema": "f059-r29-real-deployment/v1",
+    "source_revision": "f8863f3763285435a5df01fc150400838bdb3f0a",
+    "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:0b4bd3e66c1d7c49fe00046c83277dab8b6dbd0628f201abf3192d70dd67ddad",
+    "manager_image_config_id": "sha256:1cf328093deb9fb81e0202cd3cc0dec56ce6fba92efed3b45b8e6ee6eae2857a",
+    "manager_pod_uid": "fae606c4-3e51-4efa-ba00-a7ce9163de3f",
+    "old_manager_uid_absent": "faa48ee8-fd38-4980-a290-4b44ada8d6b4",
+    "helm_revision": 29,
+    "controller_epoch": 24,
+    "node_status": "ready",
+    "baseline_digest": "9006c20349b46e6a60af9d7e2ccdc45f4e4efae5606f58cde88b69bd0b9ad3ff",
+    "protection_contract_version": 1,
+    "status_protection": true,
+    "binding_protection": true,
+    "backend_sources": {
+      "src/backend/bisheng/app_runtime/domain/repositories/runtime_kubernetes_repository.py": "2aeb4eccb208e6deba9f34acaf7b0f2e0db57e8e5fae6fa9b3ce638eec6d875c",
+      "src/backend/bisheng/app_runtime/domain/services/runtime_operation_service.py": "866efa5e8feebd838a804b60a3ec7f0d49c9edd2d8fb0e690c7f08c7a8197fe3"
+    },
+    "namespace_uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b",
+    "descriptor_configmap_uid": "9bcf2c14-d457-4947-bf9c-adb6e70ffd18",
+    "descriptor_digest": "f306a49e2e396c60c91e76672c72dfc9989763fd5d7dcb091257009f70b8f456",
+    "historical_three_false_two_true_preserved": true,
+    "old_timed_out_stop_cleanup_pending_preserved": true,
+    "r4_pvc_uid": "41426a1e-9713-46d5-afa5-e520afcd186b",
+    "new_business_acceptance": false,
+    "arm_tested": false,
+    "public_issuance": false,
+    "evidence_sha256": {
+      "independent-deployment-readonly.json": "e8accbb523d92a6fa21adbddf5ca8adb7e32f2a547de8e7091c2e7ded3b40cbc",
+      "f059-r29-after-manager-cohort.json": "656f0b2dedd37626d53e92176e5bc82a8af41cb1d8ff0981a2c0a89a58e31585",
+      "native-manager-result-r29.json": "be438498db6b9c817e5d5f1be999089a9cccd0c1b6efcf8be735d555ef44c83b",
+      "private-manager-push-r29.json": "f847472be23826f75157b7b1704051495be26c63bf06753f183bc9db39c54ca7",
+      "backend-receipt.json": "cbbbe0fe5f909a1d0ea4c140b06cf0660e7ccb280ff2da8ee297356d27c4ea04",
+      "manager-after.json": "f78415da83ebbae3b33f2b21deedbe83eeb2f2b28e992e2c611bcd80855d417f"
+    }
+  },
+  "historical_top_not_verified_before_r11": [
+    "Complete static bundle, source-image and full image platform flows on K8s",
+    "Stopped SQLite edit/export and complete fault/upgrade/restore acceptance",
+    "Capacity stages, deletion and uninstall full acceptance"
+  ],
+  "r11_single_v1_protection_acceptance": {
+    "schema": "f059-r11-single-v1-real-acceptance/v1",
+    "source_revision": "f8863f3763285435a5df01fc150400838bdb3f0a",
+    "app_id": "43baa81d168a45bfa5e53ffbd04f48fd",
+    "version_id": "a5a4faade83743a487803bd6080246bf",
+    "deployment_id": "0a8dc574e6ae4f0790c0803b77f0a737",
+    "approval_id": 12,
+    "version_count": 1,
+    "operations": {
+      "first_deploy": "0b694758d9274f42a7b4078897bbeaf8",
+      "resume_deploy": "3d386771f5344e4aac321d2fc830bff3",
+      "stop": "3674c3af161946a39e6df19e1d417a7c"
+    },
+    "final_generation": 3,
+    "profile_revision": "e2e-f059-python311@2",
+    "business_result": "passed",
+    "physical_result": "passed",
+    "checks": {
+      "all_metadata_reads_determinate": true,
+      "different_serve_uid": true,
+      "first_uid_no_longer_current_at_name": true,
+      "one_first_and_resumed_serve": true,
+      "original_pvc_uid_current": true,
+      "persisted_same_uid_terminal_stop": true,
+      "resumed_uid_current_and_guarded": true,
+      "same_approved_pvc_claim": true,
+      "serve_generations_match_journal": true
+    },
+    "passed_business": [
+      "single publish",
+      "approval preview and refusal pairs",
+      "HTTP and WebSocket allow/deny",
+      "SDK human OBO identity",
+      "attachment roundtrip and entry refusal pairs",
+      "owner online row edit/export and tenant boundaries",
+      "normal stop completed/cleanupcomplete",
+      "stopped SQLite random-nonce edit/export and permission pairs",
+      "resume same approved v1",
+      "resumed rows exactly equal offline rows"
+    ],
+    "evidence_sha256": {
+      "platform_journal": "7c7f7dbb5331ab54487eb44f3c8af08bb306b70fa593eb3396b0a31ed3d7b6f4",
+      "offline_final_journal": "e3ea2f08d158b3c2bb23935a34e69daebc53abec4b047dade849e162635f6697",
+      "public_scope": "a37189348b4da69d78bbf092cbb9546879178e606681ad9eb6c4f4c37d1ad2e6",
+      "preview_cleanup_proof": "01dfdd2bef68d6d0e0510626d758baede929e2a1f17a78f09a07ed49778b11a1",
+      "physical_observation": "80b98d038e860c6fe0593e4e5831ae8ee329a9f46237abe48ef0fe14c37cf93d"
+    },
+    "private_evidence_root": "/opt/e2e-f059/evidence/k189r11-python-protection",
+    "physical_evidence_root": "/opt/e2e-f059/evidence/r11-resource-proof",
+    "operator_setup_notes": [
+      "r1 private driver not executed; real CookieJar and original900 budget fixed in r2 before live publication",
+      "two check-only wrapper mount-path failures retained before any business publication",
+      "first SELECT seed refused before normal preview cleanup; second read confirmed all four resources/released; no resource force-cleanup"
+    ],
+    "remaining": [
+      "preapproval capacity negative",
+      "safe delete after later tests",
+      "fault and independent uninstall acceptance"
+    ],
+    "full_feature_accepted": false,
+    "arm_tested": false,
+    "new_109_tested": false,
+    "public_issuance": false,
+    "production_pvc_uid": "e72126ff-98c8-40b0-84d9-25b6b75aa0d0",
+    "serve_pods": [
+      {
+        "operation_id": "0b694758d9274f42a7b4078897bbeaf8",
+        "resource_ref": "43e781a6-fbbc-55b8-8e60-3ab58f8844e6",
+        "uid": "c96eedd4-44a6-45c1-888e-b282ce695128",
+        "generation": 1,
+        "protected_since_creation": true,
+        "binding_policy_verified": true,
+        "stop_state": "confirmed",
+        "cleanup_state": "complete"
+      },
+      {
+        "operation_id": "3d386771f5344e4aac321d2fc830bff3",
+        "resource_ref": "5f525a1c-0ba4-5a0d-8784-6c07a4694151",
+        "uid": "999cf346-e483-439c-90bd-621d39152a9c",
+        "generation": 3,
+        "protected_since_creation": true,
+        "binding_policy_verified": true,
+        "stop_state": "held",
+        "cleanup_state": "pending"
+      }
+    ]
+  },
+  "manager_outage_r11_r3": {
+    "state": "passed_real_189_amd64",
+    "source_commit": "f8863f3763285435a5df01fc150400838bdb3f0a",
+    "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:0b4bd3e66c1d7c49fe00046c83277dab8b6dbd0628f201abf3192d70dd67ddad",
+    "old_manager_uid": "fae606c4-3e51-4efa-ba00-a7ce9163de3f",
+    "new_manager_uid": "59ace601-5974-487f-8eaf-095a6b13f015",
+    "controller_epoch": 25,
+    "down_seconds": 15.931488990783691,
+    "qualified_rounds_after_cache": 5,
+    "original_total_budget_seconds": 120,
+    "recovery_used": false,
+    "http_owner_new_requests": "passed",
+    "sdk_identity_obo": "passed; not remote model/retrieval/attachment proof",
+    "persistent_websocket": "same connection echo passed",
+    "new_owner_websocket": "passed",
+    "nonowner_foreign_http_ws": "all exact refusals passed",
+    "connect": "same stream/socket/fd/TLS object passed before/during/after",
+    "long_http_response_stream": "not tested",
+    "inventory": {
+      "pods": 6,
+      "pvcs": 7,
+      "same_uid_cid_volume_projection": true
+    },
+    "old_history": "old three false and newer two true retained; old Python held row reattested controller_epoch24->25 without changing flags/stop state; not repaired or released",
+    "independent_post_protection": "current/v1/status=true/binding=true; source/backend bytes/descriptor/namespace verified",
+    "evidence": {
+      "k8s-manager-outage-k189r11-r3-orchestrated-r29.jsonl": {
+        "local": "/private/tmp/f059-r11-outage-r3-live-evidence/k8s-manager-outage-k189r11-r3-orchestrated-r29.jsonl",
+        "remote": "/opt/e2e-f059/evidence/k8s-manager-outage-k189r11-r3-orchestrated-r29.jsonl",
+        "sha256": "b397e367c68e363604f68e8ae7ca257d4f9c410775fa84a994b8fdd39409724c"
+      },
+      "k8s-manager-outage-k189r11-r3-restored.json": {
+        "local": "/private/tmp/f059-r11-outage-r3-live-evidence/k8s-manager-outage-k189r11-r3-restored.json",
+        "remote": "/opt/e2e-f059/evidence/k8s-manager-outage-k189r11-r3-restored.json",
+        "sha256": "850d35de909bd48d4a7358b560e363a8c5943426c750d1dc9bc1c19c726e89ab"
+      },
+      "r11-outage-r3-after-inventory.json": {
+        "local": "/private/tmp/f059-r11-outage-r3-live-evidence/r11-outage-r3-after-inventory.json",
+        "remote": "/opt/e2e-f059/evidence/r11-outage-r3-after-inventory.json",
+        "sha256": "8184a040651be3693328de4bb55a22b2b1cf69bb23dc85157b75820fad23c29a"
+      },
+      "k8s-manager-outage-k189r11-r3-orchestrated-r29.jsonl.control.jsonl": {
+        "local": "/private/tmp/f059-r11-outage-r3-live-evidence/k8s-manager-outage-k189r11-r3-orchestrated-r29.jsonl.control.jsonl",
+        "remote": "/opt/e2e-f059/evidence/k8s-manager-outage-k189r11-r3-orchestrated-r29.jsonl.control.jsonl",
+        "sha256": "226a8464d8d9bc34aff653831d40ba57502e61ee5f54ecfc47456aeab42eb4a6"
+      },
+      "r29-post-outage-r11-r3-deployment-ready.json": {
+        "local": "/private/tmp/f059-r11-outage-r3-live-evidence/r29-post-outage-r11-r3-deployment-ready.json",
+        "remote": "/opt/e2e-f059/evidence/r29-post-outage-r11-r3-deployment-ready.json",
+        "sha256": "3825ca3769010269b14781086b2de1a6f03c3e37fa8a639035c5b70cc2f6251b"
+      },
+      "k8s-manager-outage-k189r11-r3.jsonl": {
+        "local": "/private/tmp/f059-r11-outage-r3-live-evidence/k8s-manager-outage-k189r11-r3.jsonl",
+        "remote": "/opt/e2e-f059/evidence/k8s-manager-outage-k189r11-r3.jsonl",
+        "sha256": "b7810f9e0e7f23dec84b1b95f24f86629af47ee70ccc985800783354819b2773"
+      }
+    },
+    "tools": {
+      "package_sha256": "58619c767f0477cf9c233b72d170343b942e983eba262126d70892b3c7afc445",
+      "post_collector_sha256": "f7007b5447bb4f3d7a8ad692b83ad73e1673e3268c8da4a642b362742c8b303e",
+      "post_inventory_collector_sha256": "6f7e4f30f8b35d05a67451bdd72231455f24333d1c1135961cb77f587c6b9fe4",
+      "independent_local_tests": 67
+    },
+    "prior_r2_failure": {
+      "reason": "r11_preflight_failed: root bare URL308 while canonical slash200",
+      "down_attempted": false,
+      "retained": true,
+      "diagnostic_bare": 308,
+      "diagnostic_slash": 200,
+      "diagnostic_sdk": 200,
+      "diagnostic_ws_echo": true
+    },
+    "boundary": "manager-only idle outage and access continuity; not in-flight operation replay, application crash recovery, cross-node fencing or full T070 acceptance"
+  },
+  "preapproval_capacity_r12_r2": {
+    "state": "passed_real_189_amd64",
+    "source_commit": "f8863f3763285435a5df01fc150400838bdb3f0a",
+    "app_id": "f1b1120c33d047ffa3326010ce33d786",
+    "deployment_id": "1846e6b9b5884c9995ea237c9544ec4a",
+    "candidate_version_id": "5a589c6a622a41cdb4f0bedb1ab7e994",
+    "failure_code": 16125,
+    "failure_stage": "precheck_build",
+    "failure_details": {},
+    "app_state": "draft",
+    "counts": {
+      "approvals": 0,
+      "operations": 0,
+      "reservations": 0,
+      "resources": 0,
+      "versions": 0
+    },
+    "r11_final_identity": "same online app/v1/gen3/resume operation",
+    "capacity_before_after": {
+      "memory_mb": 7936,
+      "cpu_millicores": 3650,
+      "instances": 5
+    },
+    "public_journal": {
+      "remote": "/opt/e2e-f059/evidence/k189r12-preapproval-capacity-r2/journal.json",
+      "local": "/private/tmp/f059-r12-r2-live-journal.json",
+      "sha256": "2a22bc734ebeacd445f1e06c9f82926c32b8eb0f1d2d5600edae02592fcd297b"
+    },
+    "select_only_ledger": {
+      "local": "/private/tmp/f059-r12-r2-ledger-proof.json",
+      "sha256": "9fca1b242e786ab35a3825c54219b3dc98be3e33b869c73f09293b1790f9f12e",
+      "collector_sha256": "62fdd0c4d99ecf9302469771c44b1bafa47dd8b30bc0c3fdaa1f89eb15ee9417"
+    },
+    "tool_sha256": "16e96e8343b3ad6625060a6e22578a6a6f80d603c22875561520127cdcb4b541",
+    "wrapper_sha256": "278524bd53ee8455ff7c4f6af4e97ca7c67b127a22b4ce40d6a2129320b1e240",
+    "first_launch_failure": "script not started due to public source file permission; evidence/intent absent; original log retained; subsequent one business POST only",
+    "not_claimed": "reserved candidate version ID is not an app_version row; no public CLI against this deployment yet"
+  },
+  "controlled_stops_for_migration": {
+    "state": "business_completed_physical_proof_pending",
+    "purpose": "free two temporary acceptance slots using only healthy dedicated apps",
+    "r11": {
+      "app_id": "43baa81d168a45bfa5e53ffbd04f48fd",
+      "version_id": "a5a4faade83743a487803bd6080246bf",
+      "generation": 4,
+      "stop_operation_id": "8d71c051adf14478806cb7feb19e1dca",
+      "owner_delete_online": 16104,
+      "nonowner_delete": 16105,
+      "foreign_delete": 16101,
+      "denial_sql_seed_sha256": "f3a8b7c02c7fa2958128ad02b0cfbf5a45216dbc2e30915279311c6368a67a74",
+      "denials_root_proof_sha256": "c723fc4260246d0e778210d4a89a183560910811e4a1e794b0cc2dcfc92d9c68",
+      "journal_remote": "/opt/e2e-f059/evidence/r11-safe-delete-r1-stages.jsonl",
+      "journal_sha256_at_stop_complete": "ce139f1d3f095940dbf9bf238ad0fb7d4304a82719e4ccde4ab86c2202268401",
+      "baseline_sha256": "29380b67b735bcf49c8a5a963fa20af645609da2d801ec55cca5a7c6b49fdc60",
+      "storage_manifest_sha256": "1f96333e47c8454059f65d190d30780689f6cf3a561f1408c8064be4e5097e4c",
+      "snapshot_samples": 0,
+      "attachment_object_samples": 0,
+      "pv_pre_stop": "e2e-f059-production-06",
+      "pv_uid_pre_stop": "3959377d-57ff-4f53-8536-7578e3af13fe"
+    },
+    "fullimage": {
+      "app_id": "ec6229a98d434d7cb58f740b58945ff6",
+      "version_id": "77d42f56f77f4287b63574b9fd81b0ab",
+      "generation": 5,
+      "stop_operation_id": "dc0317f0220246b7b0adbc83cfd292ba",
+      "pre_stop_serve_uid": "fcbc7bdd-059e-4ef3-a5bd-cefe4931c38d",
+      "pre_stop_protection": [
+        true,
+        true
+      ],
+      "journal_remote": "/opt/e2e-f059/evidence/fullimage-single-stop-r1.jsonl",
+      "journal_sha256_at_stop_complete": "ab985cc7097bb960946cffbe32d1a3c503b82713243b318ad90ebc764e978d84"
+    },
+    "boundary": "one normal stop per app; no delete/data/restore; original parents retained; old false/unknown apps untouched; no capacity release claimed without independent exact-UID physical and reservation proof"
+  },
+  "m2_material_preparation": {
+    "state": "prepared_not_published",
+    "slug": "e2e-f059-migrate-k189m2",
+    "manifest_sha256": "b7660cd97e537d49cf40018c22ad1934648aae7e4662f2c2c72cf4f16b9232eb",
+    "freeze_sha256": "d01c708557c96bd9ab90e035e3e2fcfa3cfe0088b0383aafaed19769ef228af5",
+    "v1": {
+      "sha256": "09807c39b6ada07f11bc1ba4867edcc2c2a0cb96b6a39452145415ca932f6fb4",
+      "bytes": 1235
+    },
+    "v2": {
+      "sha256": "49d450226b8e5bc9766ad0c4366882e347cd69609a8839209c0fa8c49311ed8d",
+      "bytes": 1237
+    },
+    "tools_tar_sha256": "ed9411c81aa3bf4014c3995c66529fbf1bc854384707b1d90445a10121b37663",
+    "readers": "new public failure operation reverse linkage and exact v1/v2 preview cleanup gates reviewed; no DB backfill",
+    "ddl_observer": "first candidate e77 rejected before execution: actual kubectl1.28.15 has no resource-version flag; replacement pending; no migration attempted"
+  },
+  "r11_safe_delete_attempt": {
+    "state": "failed_actual_protocol_422_not_replayed",
+    "app_id": "43baa81d168a45bfa5e53ffbd04f48fd",
+    "stop_operation_id": "8d71c051adf14478806cb7feb19e1dca",
+    "generation_before_delete": 4,
+    "destroy_operation_id": "ab3004b3bb4b4cd29b83d214c4241232",
+    "destroy_status": "failed",
+    "cleanup_status": "complete",
+    "app_state": "stopped",
+    "purge_resource_ref": "0ff68954-38e3-5ada-ada4-9cfa4fdd8fc4",
+    "purge_pod_uid": "2b6d5264-2e61-4f91-9db2-ead9faf61e59",
+    "purge_stop_state": "confirmed",
+    "purge_cleanup_state": "complete",
+    "destroy_reservation_state": "released",
+    "pvc_retained": true,
+    "durable_purge_result_present": false,
+    "unknown_receipt_preserved": true,
+    "request_replayed": false,
+    "root_physical_stop_proof_sha256": "9743a8eb48234cc76b0cf33321d23a2cc95a3afbf2182eb2e5da6a51e3add107",
+    "observed_failure": "manager POST destroy facts HTTP422; admitted excluded by real ExecutionFactInput",
+    "snapshot_cleanup_sample_count": 0,
+    "attachment_object_cleanup_sample_count": 0,
+    "boundary": "No business deletion, purge result or PVC removal success claimed. Exact old helper stopped and reservation released. Real helper mutation extent not inferred from generic failure flags."
+  },
+  "post_controlled_stop_physical_r3": {
+    "read_only": true,
+    "r11_resources": 37,
+    "r11_reservations_released": 13,
+    "r11_observation_sha256": "b5ea2aaf30a7eddfd906b9ebd270bf20bef2c54707bd920975679e21996fce28",
+    "fullimage_resources": 26,
+    "fullimage_reservations_released": 9,
+    "fullimage_observation_sha256": "f3b93e40b6acdf1db6c337f927352aede55de52ce2ff93f34fd154ea0dd4c961",
+    "all_pods_persistently_stopped_and_api_absent": true,
+    "stable_pvcs_same_uid_present": true,
+    "pv_projection_sha256": "6a0b0ab74a400c56df6f489b4b568d1274e7392b617344b15bf21398b52f7a07",
+    "r11_pv_uid": "3959377d-57ff-4f53-8536-7578e3af13fe",
+    "fullimage_pv_uid": "00a210b6-82e0-4745-8c68-35c00c877103",
+    "reclaim_policy": "Retain",
+    "historical_scratch_pvc_absent": true,
+    "boundary": "Before later destroy attempt. Fullimage remained stopped; no PV deletion/rebinding/host data cleanup."
+  },
+  "r30_manager_upgrade": {
+    "source_commit": "686bf68300a5869cfd260833b05448851986e50b",
+    "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:6e528c88300f0730486a8d81a1aa4df63caa0fe2e74d8faa2e5a6f68a0e32711",
+    "manager_config_id": "sha256:378636d305375fa81bdd08ca020262f429ad17de5f89910d40e4ca4a930dee1a",
+    "manager_uid": "d5570cfb-d8be-4614-bded-cbf58241df6b",
+    "old_manager_uid_absent": "59ace601-5974-487f-8eaf-095a6b13f015",
+    "controller_epoch": 26,
+    "app_pod_count": 5,
+    "pvc_count": 8,
+    "business_projection_unchanged": true,
+    "descriptor_and_nonmanager_components_unchanged": true,
+    "resource_epochs_reattested": 2,
+    "projection_boundary": "Whitelisted business fields exclude diagnostic_tail/fact.detail/diagnostics_until. Normal node_plan GET may prune expired diagnostics.",
+    "ready_r2_sha256": "5f3beda15e73136a0225265c68db49518d0afa4cbf8d1279776d011768f8435e",
+    "private_dir": "/opt/e2e-f059/evidence",
+    "public_push": false,
+    "arm_tested": false
+  },
+  "r11_second_delete": {
+    "app_id": "43baa81d168a45bfa5e53ffbd04f48fd",
+    "operation_id": "75437b15cb7b4abba06a2c7cc492fbc4",
+    "generation": 6,
+    "owner_epoch": 1,
+    "status": "failed",
+    "cleanup_status": "complete",
+    "reason": "purge_volume_cleanup_pending",
+    "purge_result_persisted": true,
+    "purged": true,
+    "purge_exec_ref": "cce8d34b-2ea5-587b-9a09-5ebee2879a15",
+    "app_state": "stopped",
+    "business_delete_passed": false,
+    "physical_cleanup_passed": false,
+    "request_replayed": false,
+    "follow_up": [
+      "T268",
+      "T269"
+    ]
+  },
+  "accepted_scope": "T069 explicit business workflow only",
+  "feature_acceptance_complete": false,
+  "r31_manager_upgrade": {
+    "actual_execution": true,
+    "arm_tested": false,
+    "backend_service_sha256": "c06fb408033f4d77c767924908209e92fd06650d7275bac2cb6cba15442327ce",
+    "business_unchanged": true,
+    "cluster_comparison": {
+      "app_pod_count": 4,
+      "baseline_inventory_unchanged": true,
+      "manager_deployment_uid_retained": true,
+      "manager_uid_replaced": true,
+      "non_manager_controls_unchanged": true,
+      "pv_count": 7,
+      "pvc_count": 7
+    },
+    "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:981bf135375a8c8728735fa13b04ee9b5c8e096c25210a72f44fdd6be003becf",
+    "manager_pod_uid": "56193f9b-0595-404f-a54a-0d62f0adf820",
+    "manager_source_commit": "6c9694f0ffb6adf4722b9fa711851172b3efdc64",
+    "new_controller_epoch": 28,
+    "node_projection_unchanged": true,
+    "old_controller_epoch": 27,
+    "platform_get_may_prune_expired_diagnostics": true,
+    "proof_files_sha256": {
+      "r31-cluster-after-r1.json": "867447e9f25681a7d8e739745fafda47cb4565a505935eda8425cb6a3fe1edfe",
+      "r31-cluster-before-r2.json": "fd2f91a1c2cc86069b39fb031204b1070a1fc1caf035b62b039d8e747f64de73",
+      "r31-ready-r1.json": "fe2f0add7421933661ef613149518e0f2e98aca32179f92eb14f5edbd7aeb23d",
+      "r31-sql-after-r1.json": "5d3a45561dcf2fe5827ea01b3eeb560471a8d0f58800373b0d784abe6edcfc8a",
+      "r31-sql-before-r1.json": "ad1caa3ed35c7de633f0c774c42d4aca528887a75d69848fe6d1dcb68454f245"
+    },
+    "public_release": false,
+    "resource_controller_epoch_changes": [
+      {
+        "after": 28,
+        "before": 27,
+        "resource_ref": "21bfa140-b850-5935-8e4c-f8fcec73049c"
+      },
+      {
+        "after": 28,
+        "before": 27,
+        "resource_ref": "bc542b2d-20ed-5128-b587-204897e4e484"
+      }
+    ],
+    "schema": "f059-r31-upgrade-comparison/v1"
+  },
+  "r31_safe_delete": {
+    "actual_execution": true,
+    "app_id": "ec6229a98d434d7cb58f740b58945ff6",
+    "tenant_id": 2,
+    "generation": 6,
+    "operation": {
+      "cleanup_status": "complete",
+      "generation": 6,
+      "id": "f5ecf6bafaad4e77b4765b06ccc9eada",
+      "kind": "destroy",
+      "stage": "completed",
+      "status": "completed"
+    },
+    "purge_helper": {
+      "id": "d30a4232-ea42-5b70-9f3a-715a2a48c9d7",
+      "uid": "74b984da-7970-496b-80e2-4634bcb7bf11",
+      "stop_state": "confirmed",
+      "cleanup_state": "complete"
+    },
+    "purged": true,
+    "owner_single_delete": true,
+    "delete_ack_seconds": 66.906734,
+    "detail_business_code": 16101,
+    "list_absent": true,
+    "all_app_pods_closed_and_absent": true,
+    "all_reservations_released": true,
+    "storage_binding_prefixes_empty": 7,
+    "snapshot_sample_count": 0,
+    "nonempty_snapshot_or_attachment_cleanup_proven": false,
+    "stable_pvc_absent": true,
+    "retained_pv": {
+      "name": "e2e-f059-production-03",
+      "old_claim_uid": "55f2a8fc-a27d-4878-ada2-44cd5b167736",
+      "phase": "Released",
+      "reclaim_policy": "Retain",
+      "uid": "00a210b6-82e0-4745-8c68-35c00c877103"
+    },
+    "sqlite_files_present": {
+      "app.db": false,
+      "app.db-shm": false,
+      "app.db-wal": false
+    },
+    "automatic_retry": false,
+    "force_cleanup": false,
+    "evidence_sha256": {
+      "intent_journal": "45ff9210552ed147d19faf529c17a4572ca2de21b64ffd36bcc994816a264ffe",
+      "sql_result": "2fa86783d64687ba877e8f7df04973ad75c5feb99e2ba980d8a22a2bc885cbd1",
+      "physical_result": "1b9198300e8b6c04e4fc01550b14e4b87144af1b7fa195f745063d4e7579753e"
+    },
+    "private_evidence_root": "/opt/e2e-f059/evidence",
+    "independent_l1_sha256": "b8b0b9eb972855feb6c7c2e59a889028938bbcad8dfce6ce9f272aa0b4bd7fd2"
+  }
+}
diff --git a/features/v3.0.0/059-k8s-runtime-backend/e2e-evidence/platform.md b/features/v3.0.0/059-k8s-runtime-backend/e2e-evidence/platform.md
new file mode 100644
index 0000000000..0ec7ee92d0
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/e2e-evidence/platform.md
@@ -0,0 +1,217 @@
+# F059 K8s 平台业务验收(进行中)
+
+结论:**尚未通过完整业务验收**。当前优先189 K8s;arm新增验证暂停。详见[结构化证据](./platform.json)。
+
+历史R29下新Python单v1的发布、预览审批、HTTP/WebSocket、SDK/OBO/附件、停用SQLite编辑导出及恢复数据一致性已真实通过,九项独立物理核对也通过;详见文末R11记录。T069仍待容量前置负例和安全删除,故障与独立卸载另行验收。下文按时间保留旧批次现场,不把旧“待验”快照当成当前结果。
+
+| 现场步骤 | 真实结果 | 限制 |
+| --- | --- | --- |
+| 新Pod模板与创建事务 | k189r5首个prepare Pod已创建、调度并启动storage-guard/transfer | 不代表应用已上线 |
+| 准备阶段 | precheck_build/16227,原ValueError遮蔽日志缺口 | 固定容器名单已补;原准备失败不能倒推为成功 |
+| 停止与清理 | 同UID kubelet_terminal持久证明;该Pod、Secret、scratch PVC均cleanupcomplete且fresh API缺失 | 只证明该操作已注册资源,不声称全集群未知writer盘点 |
+| 材料入口 | 专用TLS代理缺根路径,origin404/backend401;精确补路由后新连接401,平台env200 | 401只证明入口及拒绝,signed transfer待复验 |
+| 旧k189r4 | 无持久create receipt,仍保留其操作和PVC | 不凭403日志/404或人工文件追认not_created |
+
+## 首个诊断候选(历史)
+
+- manager/egress/admission:`d86e66a2d`,固定digest见JSON;backend四文件来自`709821ecb`,其余平台源码保持先前基线。
+- Helm升级保持descriptor数据/UID、能力报告、CA、helper及profile revision 2;app-proxy未改。
+- `709821ecb`完整amd64九镜像及bundle/SQLite烟测通过;`d86e66a2d`只更改K8s日志目标白名单,原生manager构建/导入烟测及245相关回归通过,不重标helper来源。
+
+待完成:同app明确新attempt、Python完整发布、审批预览、HTTP/WebSocket/SDK/OBO/附件/SQLite,再扩展Node/static/image/source-image及故障升级;不将本地/原生Docker烟测算作K8s业务通过。
+
+## r5 同应用第二次尝试:材料成功,停止确认待修复
+
+`87a95018bda240b695a392fb156f8cd4` 已明确接受;prepare `01a6500a2a7b46578e677abb07320e1c` 的同UID Pod `81be1259-85ac-44df-8a6c-9a43ab4fc332` 成功下载材料并终态Succeeded,准备进度事实200。随后停止确认409:CREATE时节点为空,实际调度到binfeng-k8s-node189后未回写账本。保留删除中的Pod/finalizer/PVC,等待T219/T220正常对账;没有手动清理或重写数据库状态。完整发布未通过,材料入口的有效传输已得到真实验证。
+
+### 延迟绑定修复的现场恢复
+
+706b13861固定源码的amd64镜像原生构建/导入烟测通过,内网digest `ae93f837a58529eee86f85bbd79f46801a9e068ab7c84babe35292209c97ad41`,Helm15三个控制服务升级完成,descriptor UID/data及helper/profile/app-proxy不变。正常对账将原Pod节点补齐,保存同UID的kubelet_terminal停止证明,三条资源账本cleanupcomplete且逐项APIabsent;证据SHA `dc8150cce9cf728a02e90d8b09673a1b025a57fb450389e088a14900fbecb733`。没有直接清理finalizer/PVC或SQL改状态。
+
+同应用第三次正常发布已明确接受,deployment `8070bb448ebe4f0182142ed25bf16038`,version `7e064c50b23348e59666a158404ef229`;旧journal/header保持,续办记录追加,不重放unknown。完整业务验收继续中。
+
+### 第三次失败:可选锁合同和诊断覆盖
+
+prepare `e36537609ef047748674e5172885dddd` 的inputs成功,resolve失败;两个Pod都按真实UID完成kubelet_terminal及正常清理,5条资源账本cleanupcomplete/APIabsent,proof SHA `aa2d35fff5a9c63a46542dff75c1497d564ea30db6846687a4fae66598488cee`。首个failed fact3明确保留preparing/transfer TypeError;清理重试fact4将投影覆盖为failed/空tail,原fact仍在。
+
+189无网络、无凭据原生helper复现:相同已知源码包省略lock成功,仅改lock:null即在k8s_helper.resolve_download写os.environ处TypeError。T221–T224规范化可选锁并保留首次诊断,已本地回归通过;未回填历史。后续采用独立k189r6完整场景batch,r4/r5原journal和追加链保持,完整业务仍未通过。
+
+## k189r6独立完整批次
+
+新helper完整16项安装探测通过,backend节点正常恢复ready后启动原platform六场景。原runner SHA ba5b766e3afbbfa4c4b6f170e3c32c288fccbad58dfc91c498484bb66cf4ac3c,固定源码143606851,profile2;新journal隔离,旧r4/r5及追加链不变。当前进行中,platform失败保留现场,未执行all的finally cleanup。
+
+r6首个Python应用prepare8fb6b53a…真实完成inputs/resolve/install/upload;4Pod同UID停止,8条资源账本正常cleanupcomplete。执行器在第一次cleanup.pending时立即失败,公开16227/precheck_build;首次failed_stage=preparing正确保留。登记T225–T227按原期限等待,不把DELETE受理当完成、不复活失败操作;未生成审批或正式版本。
+
+
+## 当前候选与r7独立批次
+
+开发收尾来源7816e0bb87cd1b9ea0c82e8c01283cdf2e821a13;manager原生amd64构建/禁网导入通过,内网digest `3e0bf023a016b3f486bd5fe5cbcae85d89cc8737daaa9166f926291ec7781de5`。Helm17三个控制服务更新,descriptor UID/data、helper143、profile2与安装报告逐字节保持;新镜像assess_capabilities及平台ready通过,controller_epoch11。无新16项探测或ARM/公开发行冒认。
+
+r6原失败记录保留;正常cleanup后8精确资源逐项fresh API absent、4Pod停止证明,proof SHA `110aab26f2846ed75f35a02a95e948e40e8ec5cba21528ead45acec7dfa87d77`。不复活旧operation。r7使用原runner、原身份及六场景新journal;首个Python deployment d623cbd318f04795bb6121a572ff566e(app baa177a4b5fe42cbb8cf4dadc8f09299)已接受。当前尚在准备/业务验收;platform模式失败保留现场,未执行all自动清理。
+
+### r7准备成功、初始化总窗口失败
+
+prepare6be875…最终completed/cleanupcomplete,4阶段、8资源完整结束且prepared_delivery等于delivery;T226真实路径已通过。probe081663…的材料初始化真实成功,但data-layout/schema-init/material-fetch/env串行共用90秒;Pod调度17:47:32、环境安装17:48:51启动、17:49:03被停止,仅12秒。所有镜像已有本机缓存;tail只到pip Lookinginlinks,app仍PodInitializing。4资源normalcleanupcomplete,保留原失败,不冒认审批/上线。登记T228/T229修正既定阶段计时。
+
+
+## 当前a1de/Helm18与r8
+
+来源a1de5271f5f00cef0cba06d41f09c673a9190525,manager固定digest `06bb857f4caa79499a844d16838831c7429dfc7d499e65649f314d96fd390cad`;原生amd64构建/禁网导入通过。Helm18三个控制服务更新,descriptor UID/data、helper143/profile2和有效16项报告不变,平台自然接管epoch13并ready。backend两个data grant/session源文件先CAS核旧哈希一致,备份/替换/重启专用backend+worker;原loopback检查失败因端口只绑定192.168.106.189,原失败receipt保留;实际私网API HTTP200、文件新哈希再次核对通过,未追加重启或动OpenFGA。
+
+r8按原runner、身份和六场景新journal启动;首个Python app6da0d71cbda643e881e02d707eac7e79,deployment123f35de6d2b4fdf8050a44d75eebc9e,versionab8c8e9a50c84bf899c1aef0a2bf0898。当前真实业务仍进行中,不把修复部署及本地回归算成审批/上线通过。r7公开失败16124/precheck_probe和正常cleanup事实保留。
+
+### r8真实启动通过,终态删除收尾
+
+同UID8c83f28c…创建18:16:57,data-layout/schema/material/environment分别完成18:17:13、18:17:52、18:18:17、18:18:33,4init96秒后appRunning/Ready。probe cd1ad0fe…已ready;随后SQL停止证明kubelet_terminal于18:19:10入库,Pod Failed/app137且finalizers缺省,但grace30和对象仍在。登记T232/T233修复已移除finalizer后的kubelet最终删除误拒;原操作仍active,未人工delete/释放预占或修改证据。正式审批上线未通过。
+
+## 开发闭合与r9新批次
+
+T232/T233原生amd64候选a06b281b2,内网固定digest `4666d75388bde2ae7c1b0b73916442451b4119a7384852cf373633e8489dfe32`,Helm19三个控制服务滚动完成;descriptor UID/data、有效报告、helper143/profile2/app-proxy均不变。r8原probe最终timed_out保留,修复后正常对账cleanupcomplete;4条精确资源fresh API absent,原同UID停止证明不变,proof SHA `a37f1bab1216ab6029b5b7a24cf52f12db04ac375208d95b9a1e5de490855f1b`。无手工delete/finalizer/SQL或操作复活。
+
+T234/T235修正共享E2E75秒HTTP截止,仅精确同步成功preview/resume/stop/delete采用settings总预算且不重试POST。合流d13e9ee91,112新旧合同、完整backend1753/19skip通过;runtime1519/13skip通过的生产字节保持。r9仅测试源码更新,a06生产镜像来源不重标;新六场景原套件、原身份和独立journal启动,旧批次不变。当前开发修复闭合230/235,完整业务/故障/发行/L2仍在门内;ARM和109按用户优先级暂缓。
+
+## r9工具CA修正与r10
+
+r9真实预览已启动,原platform日志保留ConnectError;只读配置确认F059_E2E_CA_FILE缺失,虽公共CA文件已挂载但客户端未加载。相同工具容器仅显式加入公共CA后,TLS校验开启的入口/api/v1/env得到200。原suite.cleanup阶段通过并正常回收r9应用/预览,未手工修改K8s或SQL。r10独立六场景沿用d13工具、a06镜像/helper143/profile2,显式设置CA且在任何业务API前验证TLS,结果见k189r10-entry-tls.json。生产代码未因本操作配置错误修改,未把入口前置200算成应用访问通过。
+
+## r10维护接线修复与同版本显式重试
+
+r10已真实通过preview-and-approval(审批人访问、其他角色拒绝、批准)。首次deploy d9729fe5…maintenance_phase_failed/cleanupcomplete,Pod同UID停止正常,生产PVC4c5b459c…保留;原Pod日志已回收,未声称现场取到KeyError。实际固定8a helper非root/禁网/合成身份A/B与模板→reader双红定位MATERIAL_POD_UID遗漏。单行模板修复79a15870f/合流249d5fc85,全runtime1532/13skip通过;原生镜像digest42c6c3c5…,Helm20保持helper/profile/report,node ready/epoch15。
+
+原900秒套件期限保持。owner专用脚本先核tenant2、精确app/version/原approval executed、旧op failed/cleanupcomplete,再O_EXCL0600持久submission_unknown,仅一次POST既有manual-publish入口发新attempt;不重打包/创建版本/重审批、不改旧失败记录。当前异步等待结果,未知请求不重发;新结果以独立k189r10-manual-publish-once.json记录。
+
+## 首个K8s正式应用与真实访问通过
+
+同一已批准v1的明确新deploy20d42a22…已completed/cleanupcomplete;公开fresh核对online、同版本/审批,无新增版本或审批。正式Pod a27db53e…Running/Ready,生产PVC仍4c5b459c…;证据k189r10-first-online.json,manual receipt SHA `299c8b4816a248611c4ae114cb414eaf528324eb362106bafef312a1678f8a55`。首次维护错误修复的真实上线闭环已成立。
+
+原r10套件按900秒自然退出,日志/parent journal不改(SHA `3a2961d24f6428cc4ee268e166372c7cc404237079441985f837ea4788f773f5`)。薄续办脚本fresh确认已批准v1在线和其余5slug尚无应用,以O_EXCL新journal关联原journal/manual校验和,复用原suite方法,未重发首版。首次原observe真实通过:HTTP200/未登录拒绝、/sdk/identity与/sdk/storage-roundtrip、WebSocket允许/拒绝、owner与非owner/跨租户管理边界、SQLite表/结构/行查询/PATCH/CSV导出及拒绝配对。首版停用恢复和v2、其他语言/交付方式及故障仍在执行,不宣称T069/T070全部完成。
+
+## r10停用真实失败与清理修复
+
+首版observe已通过,原续办在POST stop失败后退出;原journal冻结SHA `1cbdb6186bbf211ce9eded309620fae2b57181e571e69a0d37011cbb5370e32a`,失败事件保持。9f57c389…原deadline19:27:30Z已过,app公开状态stopped但旧serve UID a27db53e…仍运行,不能视为物理停用通过。T238/T239修正已闭合历史UID重复停用,本地全runtime1541/13skip;T240/T241补过期stop先终态pending、再精确旧terminal自身cleanup的闭环,后端全app停止guard保持。当前PVC及writer均持有,无手工delete/finalizer/SQL/期限延长。新真实结果另记;旧cycle未完成不补写成功。
+
+## 原停用收尾真实通过与停用数据性能
+
+最终d7/Helm21内网digest b2288f64…三控制服务ready/epoch16,descriptor/helper/profile/report保持。原9f57…按正常对账timed_out/cleanupcomplete,a27…同UID kubelet_terminal且fresh API absent,原PVC4c5…仍Bound,proof SHA `8b15806b82c3d9c095d1f5204f8d7d848b23e603e8003d287c2be7b49eaa2ae6`;原超时不改成功。
+
+私有check尚未创建续办journal或发resume。首次offline rows data4b25…19:48:20创建、19:49:15 ready、动作done,19:49:20期限到、19:49:29清理完成;HTTP失败保持,不把SQL已执行等同响应通过。同8a镜像CPU25m与100m原生隔离导入实测31.699秒与6.9965秒,登记100m离线资源/预占配对,不放宽60秒或改同步互斥。
+
+## 100m离线数据及独立停启真实通过
+
+当前6f/Helm22,私有digest59692b82…,controller epoch17;同源backend两资源参数文件已更新。实际data Pod776ae57a…的data-layout和data-helper均requests=limits=100m/128MiB,原8a镜像不变。内部op241db50c…20:06:59创建,20:07:12 ready、20:07:16 completed/cleanupcomplete;**13秒就绪、17秒全链路**,原deadline20:07:59(60秒)保持。真实MySQL预占128Mi/100m,停止清理后才released;同UID kubelet_terminal/cleanupcomplete,public API check通过。旧4b25超时原样保留。
+
+同已批准v1新恢复d5c5e311…真实completed/cleanupcomplete,冻结停用数据逐值相等;独立下一趟原suite.cycle亦通过,恢复opa89bbe4d…,不把原9f57失败改成功。v2 a1c22f4e…已完成预检/审批,正式deploy462c127e…starting,当前还不能宣称v2访问/其余五场景全部通过。
+
+## Python/Node两版通过与static PATH修复
+
+原cycle续办的Python v2 a1c22f4e…与Node v2 c92730bc…均完成真实预检/预览/审批/正式上线、HTTP/WebSocket和owner边界;两者独立停用恢复通过。Python另有SDK/OBO、附件与SQLite表/行/编辑/CSV配对。不能把这两类通过视作五类完成。
+
+static原probe486ca50c…的所有init成功,但app exit127/nginx:notfound;公开失败16124/precheck_probe来自resolved_delivery→RuntimeDeliveryCoordinator分支,原操作reason为workload_readiness_timeout/failed_stage=starting。源码artifact7ef77b6b…verified,原MinIO代码对象与原492byte包SHA均ef034a25c23e896d20ecda05b7b2f314b8abeece33f4d6568b9fa4faa2c081ea;只读核对,未改源码/旧失败。原probe同Pod UID kubelet_terminal、四资源cleanupcomplete/APIabsent的fresh proof SHAad356e69…已保存;Service/Secret的absence不独立充当Pod停止证明。
+
+T246/T247修复bundle PATH补系统sbin;同固定2f0311镜像原生A/B及entrypoint nginx -t成功,完整runtime1595/13skip和独立292合同通过。995c80f2a/Helm23/digest7cef8910…/epoch18已部署,backend/helper/profile/report保持。
+
+原cycle journal自然失败退出,SHA94ca8e8590845ab2e4bb10edacaee7198de6eca176141dc9c904fc20bdf46f0f冻结。新薄工具fresh核draft/零正式版本/无审批/assignment和同源摘要后,O_EXCL新static-resumed journal,仅一次同app新attempt50292d68…/version1672b4a7…;前两个已通过场景不重做。static/source/image及缺依赖仍在运行,未知请求不重发,无自动cleanup/延时/改旧终态。
+
+## Static两版通过与源码组装权限修复
+
+static v1 1672b4a7…与v2 4fb07dc0…均真实完成发布、审批预览、访问/owner边界、停用恢复与更新。当前Python/Node/static三个bundle均两版通过,static不要求WebSocket。原static-resumed journal自然停止在下一source-image预检,SHA `9c953e824afb0548e8547911ca386008cc5f8e5e03da7871ea5eec95083a9415`;不把整批写成功。
+
+source prepare70eb72db…依赖安装及产物上传完成,OCI组装失败`oci_privilege_bits_refused`,公开16227/precheck_build;failed/cleanupcomplete,5Pod均有同UID kubelet_terminal,9注册资源正常cleanup,fresh逐项absence另取。只读保留scratch34统计267个SGID目录、零特殊位普通文件,确认fsGroup目录继承;backend存储端口返回的sourceartifact0bce…与原598757byte包SHA538dae69…完全一致。
+
+T248–T251修复只允许归档前的目录SGID并在层内去除,继续拒绝SUID目录和非目录SGID;另补OCI固定PATH标准sbin,保持D7受限布局/冻结基础镜像/凭据及启动合同。source0d5ef7b2b,完整runtime1610/13skip、独立L1通过,新amd64三镜像及descriptor/能力探测刷新后方可新attempt;当前尚未source/fullimage上线,不冒认T069/T070完成。
+
+## 入口CMD误判撤回(替代此前修复结论)
+
+独立链路检查确认source-image的空startup有意交给兼容平台入口解析,但crane此前继承profile默认CMD,先执行基础命令,可能绕过Procfile/包start/static生成配置。T252/T253仅显式写空CMD,显式参数仍CSV无损,实际非rootcrane v0.22.1对三个固定base导出Cmd=null、正确入口,未改仓库manifest。来源d7f7c2e10,完整runtime1622/13skip、独立L1通过,代码248/253闭合。
+
+0d5中间候选已真实Helm24/16项探测/ready;源码续办check已通过,但尚未execute/生成新业务journal,最终d7f镜像与报告待完成后再提交。旧失败/9资源精确正常cleanup和backend原包校验保持。
+
+更正:上述继承CMD问题不成立。固定crane v0.22.1设置entrypoint时已清除Cmd,原代码已满足合同。真实189三个固定base使用**不带--cmd**的原命令,导出后Cmd均null,注册仓库未修改,日志`/private/tmp/f059-crane-entrypoint-clears-cmd-native.log`。此前仅带--cmd空参数的正例及镜像参数Mock不足以证明原缺陷,相关T252/T253与L1已撤回,编号保留。
+
+两个代码/测试文件恢复0d5字节;全部src/scripts/deploy/workflow构建输入相对0d5均无差异。真实SGID/PATH修复保留,完整runtime1610/13skip(33.51s)。d7候选的额外空flag与原CLI行为等价,未发布新应用;当前installer自然完成后恢复已验证0d5精确镜像及与其descriptor匹配的有效16报告,不制造新的构建或冒认新探测。官方依据:https://github.com/google/go-containerregistry/blob/v0.22.1/cmd/crane/cmd/mutate.go#L89-L98
+
+## 正确基线恢复与源码新attempt
+
+当前Helm26/0d5精确镜像/匹配有效16报告,三路径true、自然ready/epoch21。root重新逐项GET确认旧prepare70eb…的9资源正常cleanup/APIabsent,五Pod同UID kubelet_terminal;fresh proof SHA `efbbae5234528e89931fcf51e1bf923daa0d9cfdc712c65a2c4dfdf5dc032ac1`。新工具80d6…的check通过,O_EXCL独立source-resumed journal只一次提交同draft,accepted deployment675d259a…/versiond71f4858…,prepare07b872e0…正在preparing。前三bundle两版不重做,不复活旧操作/延长deadline/自动cleanup;当前journal未冻结,不预造成功或摘要。source及完整镜像/缺依赖后续仍待自然业务结果。
+
+## 第二源码失败与仓库DNAT配置修复
+
+prepare `07b872e0d07441f7a333941560727bba` 的inputs/resolve/install/upload全部完成,SGID修正已进入真实组装路径;crane仓库操作失败,原journal冻结SHA `12231d57220e4f5343f2db67c0fa988382f34a33d62ba14f28c7739ac692e100`,不补写成功。9精确资源正常cleanupcomplete/APIabsent,5Pod有同UID kubelet_terminal,proof SHA `55db28c0b554c8e4c0dfeca61fff76f996471f87cd5f5cfc1f1dca6484f6b7f8`。
+
+真实egress Pod到189:5443原超时3.008秒;临时仅对egress selector及专用仓库IP放行DNAT后5000端口,0.008秒连接;撤销该策略又超时3.011秒。诊断SHA `5c0f8b1033b223b7a6c75e762f9bbdc3d21a3d063b8c5ff9f37679e34f63b52f`。Helm27仅在network.egressPorts增加5000;既有镜像、profile、descriptor及报告不变,不声称重新完成能力探测。修复receipt SHA `bdf01d29a962f0ed0733ef1d275ccb2be9e9883b511fe4025a736419f0ca4a3b`;真实egress公共CA及主机名校验开启,仓库/v2/返回200,TLS证据SHA `873961a7cd15539963d839e981edb17ee8d9cc719c22493569ea9df1d46ff288`。
+
+安装探测原registry-push由外部installer执行crane,网络项仅验证Pod能到proxy3128;T254/T255补同策略canary上游TCP,控制canary保持NotReady避免进入业务Service。TCP、外部TLS/权限和实际assembler推送分别记证据。ARM、109、公开发行继续暂缓;下一次源码发布必须新独立journal/一次POST,原两次失败保持。
+
+## source-image首版真实闭环与版本更新
+
+第三次独立明确提交 `dd3d5b5533aa459eb9cff9d7d3685fd8` / v1 `60ebc5e1230942cb975be94de9fab954`:prepare `f278274b3e924d808ec6f3903a3fa7a9` completed/cleanupcomplete,随后预检、预览审批、正式上线、HTTP/WebSocket允许及拒绝、owner管理边界、独立停用恢复全部真实通过。原两失败及其journal保持;只对新一次POST记通过。
+
+实际产物`f059/apps@sha256:d95ed14789fbb261fa479c1a4c2c06b8d9d47ea113fdcc88d1183e65f0d45074`经CA/主机名校验只读读取manifest/config:保留冻结b632基础10层,仅追加一层;入口、User10001:10001、workdir及显式Cmd[python,main.py]均符合本次冻结startup。lineage SHA `b55761d153e65350cf5e0b9e8c44d66717b90177cefb12d957026663355b7695`。恢复后生产Pod UID `058c99b2-cf64-4227-94ad-111e450f06dc`实际Running/Ready且无删除标记,CRI manifest与Docker config b0a5df71…均一致;identity SHA `74a246285a7f1c82545479fcb8e60787cb9970503386bf22cb76f59ffd0c42bd`。不将显式startup误作缺省Cmd。
+
+第二版已受理`afaaff456f46430a95659bd7de7e2ceb` / `818d435bba5c4902be4c16ff879c87fb`,prepare13c9c01e…进行中。完整镜像/缺依赖/离线数据/故障门未完成,当前成功父journal尚未冻结;不执行依赖成功父的工具。安装器dedc新真实探测等该业务自然结束后串行执行。
+
+## source-image第二版与完整镜像容量拒绝
+
+source v2 `818d435bba5c4902be4c16ff879c87fb` 已实际完成预检、预览审批、更新、HTTP/WebSocket与owner边界;prepare13c9…completed/cleanupcomplete。实际OCI/CRI/Docker config及Running/Ready Pod UID0ad1b4c0…一致,保留原冻结base10层加一应用层;完整身份SHA `3ce4f56806d38ef283f0f27e4f32275488496585375927db4155dec38b860f63`。
+
+完整镜像v1与停用恢复已过;v2 `77d42f56f77f4287b63574b9fd81b0ab` 审批11执行后,deployment c562…failed/publishing/16226,原因为iteration_failed/capacity_exhausted/admission。旧v1仍online、pendingv2保留,没有新deploy操作。v2预览bbe06…已cancelled/cleanupcomplete;公开API与只读SQL证据相符。容量当前6784Mi/3125m含旧r4 held,预算8192/4000,不擅自释放r4。readonly证据SHA `93061b799e0e68d74ef7b3c7d66217c697fe5e7b477b4d9c955b53412da893cd`。
+
+suite按原900秒自然失败退出,父journal SHA `1c1c6a8db64f987ad5e85b816c4ed51219e21692a636bd2289a1909bbcc51aec`;保留两次source旧失败及本次v2失败,不将失败覆写为成功。单次manual-publish续办与缺依赖负例待后续独立记录。
+
+T256/T257开发已L1通过,本地完整runtime1649/13skip;250/255,新增取消检查尚未部署或真实验收。ARM继续暂缓。
+
+## 完整镜像同批准v2单次手动恢复与缺依赖负例通过
+
+确认预览三精确UID正常cleanup、预约released、fresh API absent且容量6784Mi/3125m后,root只读check通过;仅一次owner manual-publish同v2,无新版本或审批。新操作 `68cc365b61d84c6585b7f9db897ea5c0` / generation4 completed/cleanupcomplete;审批仍11、两个版本不变。v2随后原HTTP/WebSocket、SDK/OBO/附件/数据及owner/nonowner/跨租户观察通过,另外四应用current v2/版本与审批未变。旧r4仍held。
+
+缺依赖仅一次新提交:app `fb9a0f9e32f84ef99ae60ee60f6dc34b` / deployment `5df7298126ff4c2a9ec734d0b1fe0787`,真实16227/precheck_build/无审批通过。续办exit0并写最终 `r10-image-capacity-continuation-completed/passed`,成功父SHA `c6e17830766aa738621e7f681f2e04cc3eb9223e46ce5021266fc894e4aea11b`;原失败父1c1c…、source两次失败及c562容量失败历史保留,不伪造generic iteration-passed。
+
+至此Python、Node、static应用包、source-image、完整镜像五种场景均真实首发/升级主路径与停用恢复通过;完整镜像升级明确区分原容量拒绝和新手动成功。离线查改导出/恢复删除、故障升级及L2仍未闭合,不提前勾T069/T070。
+
+
+## R29:保护报告修复实际部署(新业务验收待完成)
+
+T258–T263的生产源码 `f8863f3763285435a5df01fc150400838bdb3f0a` 已完成189原生amd64构建及隔离导入烟测,仅推送专用内网仓库;manager固定digest `0b4bd3e66c1d7c49fe00046c83277dab8b6dbd0628f201abf3192d70dd67ddad`。专用backend/worker仅两个保护源文件按旧哈希备份、原CID重启;新字节哈希复核通过。Helm29升级后manager新UID `fae606c4-3e51-4efa-ba00-a7ce9163de3f`,原UID实际缺席;自然接管epoch24、ready、sticky保护合同v1/current及两guard true。host描述符与Pod投射的完整/保护摘要一致,保护基线 `9006c20349b46e6a60af9d7e2ccdc45f4e4efae5606f58cde88b69bd0b9ad3ff`。
+
+独立fresh核对:旧三个bundle的false/false保持,source/image的true/true保持;四个Running及一个held终态的Pod/CID/PVC/PV身份未变,旧stop仍timed_out/cleanup pending,r4 unknown-create原PVC保持。没有回填历史保护、移除finalizer或释放旧预占。helper/assembler/profile2、代理/准入及描述符数据/UID未变;此次不是重跑16项安装探测、ARM或公开CI发行。新单Python应用的发布/停用/离线数据/恢复仍需单独实测,不能以部署ready代替。结构化证据见JSON的 `protection_report_r29_deployment`。
+
+
+## R11:新Python单版本业务闭环与物理核对通过
+
+R29下新app `43baa81d168a45bfa5e53ffbd04f48fd`、v1 `a5a4faade83743a487803bd6080246bf`、审批12真实通过单次发布、预览访问/拒绝、正式HTTP/WebSocket、SDK/OBO身份、附件往返及owner/非owner/跨租户配对。预览先等待正常清理:纯SELECT首次拒绝未完成,第二次确认preview operation `3ca7af6fe7db41f8b45294a9946c8c5d`的四资源闭合、同UID kubelet_terminal、逐项API缺席、reservation released;原子发布证明SHA `01dfdd2bef68d6d0e0510626d758baede929e2a1f17a78f09a07ed49778b11a1`后,原审批只提交一次,未手工补发。
+
+正式deploy `0b694758d9274f42a7b4078897bbeaf8` gen1 →正常stop `3674c3af161946a39e6df19e1d417a7c` gen2 →停用SQLite随机nonce查改/CSV与权限拒绝 →resume `3d386771f5344e4aac321d2fc830bff3` gen3,均真实完成;仍只有同一已批准v1,恢复后行数据完全等于停用编辑后的数据。最终私有journal SHA `e3ea2f08d158b3c2bb23935a34e69daebc53abec4b047dade849e162635f6697`,随机内容不进入本仓库。
+
+独立metadata-only/API核对九项全通过:PVC UID `e72126ff-98c8-40b0-84d9-25b6b75aa0d0`保持;原serve UID `c96eedd4-44a6-45c1-888e-b282ce695128`有同UID持久停止证据且cleanupcomplete;恢复serve UID `999cf346-e483-439c-90bd-621d39152a9c`为当前对象,gen3、双连续保护true。只读观察SHA `80b98d038e860c6fe0593e4e5831ae8ee329a9f46237abe48ef0fe14c37cf93d`,不冒称重新观察了kubelet终态全过程或跨节点恢复。
+
+驱动r2在首次业务发包前修正真实CookieJar角色识别,并沿既有900秒共享预算;两个只读启动包装路径错误及原r1均保留,不重写为产品失败/通过。最终业务execute exit0。T069仍待容量前置负例和安全删除;T070故障/独立卸载、L2及公开发行仍未完成,256/261保持。ARM与新增109继续暂缓。
+
+## R12 审批前容量拒绝:189真实通过
+
+单次新slug发布在真实v2预检返回16125/precheck_build/details={},未产生审批或正式版本。收到的version_id仅候选编号。完成前fresh确认R11仍原online/v1/gen3/resume,容量前后7936Mi/3650m/5不变。独立backend租户2纯SELECT确认该app的operation/resource/reservation/app_version/approval全部0,应用draft/current/pending为空;证据与SHA见JSON。
+
+首个工具启动因cap-drop后公开文件不可读而在Python启动前失败,未有发布意图;修正新tools物料可读权限后才发生唯一业务提交。旧日志保留,不重发未知请求。CLI 16125映射及wheel已本地验证,尚未远端部署该wheel。安全删除与其余故障验收保持pending。
+
+## 2026-10-23 停用资源核对与删除失败
+
+R11(37资源/13预约)和完整镜像(26资源/9预约)停止后只读SQL+manager元数据实采通过:全部Pod持久停止证据与API缺席闭合,全部预约released,生产PVC同UID保留,历史scratch清理完成。独立PV投影核PV06/03仍Retain,未删PV/重绑磁盘。采集摘要见platform.json。
+
+随后R11单次owner DELETE失败:真实destroy `ab3004b3bb4b4cd29b83d214c4241232` failed/cleanupcomplete,purge helper UID `2b6d5264-2e61-4f91-9db2-ead9faf61e59`停止关闭、destroy预约释放,但没有持久purge成功事实,App仍stopped、生产PVC保留。manager facts返回422,内部admitted阶段不在实际ExecutionFactInput中;T266/T267修复中。原未知回执保留,未重发;不将通用failure flags当作数据未变化证明,不宣称删除成功。快照/附件对象样本0的清理未获非空证明。
+
+## R30 实际升级与第二次删除(尚未通过)
+
+固定源码 `686bf68300a5869cfd260833b05448851986e50b` 原生 amd64 构建、隔离导入烟测及内网推送通过;manager镜像digest `6e528c88300f0730486a8d81a1aa4df63caa0fe2e74d8faa2e5a6f68a0e32711`。Helm30只替换manager,新UID `d5570cfb-d8be-4614-bded-cbf58241df6b`,旧UID缺席,epoch26/ready/current/双保护true。5个应用Pod、8个PVC/PV、descriptor及非manager服务身份不变,业务白名单账本相等;两个历史资源仅controller_epoch25→26。该投影不含诊断字段;正常node_plan GET可能清理过期diagnostic,已在r2采集证明明确,不能称严格SQL无写。
+
+R11删除前fresh纯SELECT及元数据核对19个历史Pod均有同UID持久停止证据、所有38资源按保留PVC/已清理资源分组验证、预约全部released。默认业务check通过后只发一次新的owner DELETE(不重放旧请求),新destroy `75437b15cb7b4abba06a2c7cc492fbc4` /gen6。此次合法maintenance事实及 `purge.result.purged=true` 已入库;随后PVC删除尚未结束时抛 `purge_volume_cleanup_pending`,最终failed/cleanupcomplete,App仍stopped。原请求异常/未知日志保留。数据已经purge,不能宣称仍保留,也不能将持久purge事实当作业务删除及PVC清理成功;登记T268/T269继续修复原期限内异步等待。
+
+ARM、追加109与公开发行均未执行。T069/T070保持未完成,所有历史失败记录保留。
+
+## R31 真实升级与安全删除:T069业务流程通过
+
+R31 manager/source6c9694f0f、镜像digest981bf135…已原生amd64构建并在专用Helm31上线。新Pod UID56193f9b-0595-404f-a54a-0d62f0adf820、epoch28、ready/current/双保护true;backend恢复入口为独立source38a700233/c06fb408文件,保持混合源码事实。升级前后4appPod/7PVC及对应PV、descriptor/三个非manager服务身份和SQL business/node精确一致,epoch27→28及资源控制epoch变动独立列出;此处清单是升级时点,非删除后的当前数量。比较proof SHA89e95bb0…,原始引用逐SHA核对。
+
+健康fullimage ec6229…在原stop gen5后,owner唯一DELETE实际66.906734秒成功,详情精确16101、列表无appid/slug。新destroy f5ecf6bafaad4e77b4765b06ccc9eada/gen6已completed/completed/cleanupcomplete,seq3持久purged=true;purge ref d30a4232-ea42-5b70-9f3a-715a2a48c9d7及UID74b984da-7970-496b-80e2-4634bcb7bf11同kubelet证据闭合、Pod自然缺席。原PVC55f2…清理完成/自然404,旧创建op未改,cleanup_operation_id指向本次destroy;10预约released。
+
+原PV production03/UID00a210b6…保持Retain、Released和旧claim UID;宿主目录保留,app.db/wal/shm均不存在。7storage binding revoked且限定prefix为空,snapshot样本0;不冒称非空快照/附件删除。原R11两次失败和数据已purge事实保留,未复活/重发/强删finalizer。正式物理r2字段sqlite_files_present=false,r1误名字段未覆盖历史。SQL proof2fa86783…、物理proof1b919830…及唯一intent完整SHA见JSON。
+
+独立窄L1 b8b0b9eb…确认T069明确业务流程PASS,现265/269任务完成;全AC矩阵仍保留AC-02页面/CLI/MCP开关、AC-17完整档位矩阵、AC-18资源超限故障、AC-37审计通知CLI及AC-45完整缓存失效矩阵的证据边界。T070、追加109、正式发行和L2不由本项替代。后续只做定向验证,不重复完整业务/单测回归。
diff --git a/features/v3.0.0/059-k8s-runtime-backend/integration-review/README.md b/features/v3.0.0/059-k8s-runtime-backend/integration-review/README.md
new file mode 100644
index 0000000000..36af8a1075
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/integration-review/README.md
@@ -0,0 +1,11 @@
+# 2026-10-23 开发整合 L2 证据
+
+本目录是开发合入审查,不是全 Feature 验收通过证明。三条审查线均未新增全量测试或远端操作,原报告先记录发现,再追加限定复审关闭,避免抹掉审查过程。
+
+- [backend 七维](./backend.md):D1 设计当前快照漂移已定向关闭;代码未发现阻断。
+- [runtime 七维](./runtime.md):控制器/生命周期/SQLite/资源围栏核对及 D1 已关闭。
+- [交付/发行/CLI/SDK](./delivery.md):CI 同版本跨入口并发项已修并定向复审关闭;仓库外写入边界保留。
+- [定向验证](./validation.json):本轮 1+17 项与静态整合结果。
+- [F086 文档恢复来源](./f086-recovery-manifest.json):从原 dirty WT 收回 78 文件的原始字节摘要;部分设计/审查/状态文档随后在本整合树更新,因此此清单不是当前文件哈希门禁。
+
+F086 implementation-files.sha256.json、旧 validation/local suite、2026-10-21 CI 等均是当时源码的历史快照,不用最终整合内容覆盖或重贴通过标签。当前完整边界见上级 code-review.md 与 docs/reviews/vibe-development-integration-2026-10-23.md。
diff --git a/features/v3.0.0/059-k8s-runtime-backend/integration-review/backend.md b/features/v3.0.0/059-k8s-runtime-backend/integration-review/backend.md
new file mode 100644
index 0000000000..575dc9995b
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/integration-review/backend.md
@@ -0,0 +1,148 @@
+# F086 + F059 后端合入 L2 独立审查
+
+- 审查日期:2026-10-23。
+- 审查对象:`codex/059-k8s-runtime@cda8dccf26298af91a1df277dcd1a49787b6b444`。
+- 合入目标:`feat/3.0.0-vibe@4aa9ce69b2830b81162fe631b9c801057c40ee8b`;该目标为审查对象的共同祖先。
+- 兼容基线:F086 `5131b81194a5fa390593e58d822bcbde52b6ca64`;F059 接续基线 `755d4456e50c015a3fa88da0c0c35ab53bbd1650`。
+- 范围:backend 的应用发布/运行入口、权限和 tenant、持久状态与幂等、HMAC/材料/附件、SQLite 数据 API、模型注册及 Alembic。manager/Chart/镜像实现由其他审查线负责。
+- 方法:按 `sdd-code-review` 七维及 backend AGENTS,逐调用链静态审查与既有实际证据交叉。未执行测试、SSH、API、集群或数据库操作;未改仓库。未把继承的 F048、ASR、Linsight 等非 Vibe 改动纳入本报告的代码结论。
+
+## 结论
+
+**代码范围:未发现 HIGH/MEDIUM 阻断;开发合入可行。整体:NEEDS_FIX,仅 D1 文档现状同步,修正后可定向复审关闭。**
+
+本结论针对用户要求的“先合开发”,不是全部 AC、完整故障矩阵、离线交付或正式发行通过。不得据此把未完成 T070/T067/T071 等交付项改成通过。
+
+| 维度 | HIGH | MEDIUM | 结论 |
+|---|---:|---:|---|
+| 边界条件 | 0 | 0 | PASS |
+| 权限与认证 | 0 | 0 | PASS |
+| 并发安全 | 0 | 0 | PASS |
+| 信息泄漏 | 0 | 0 | PASS |
+| 测试覆盖 | 0 | 0 | 已核既有定向/实际证据;完整交付边界另列 |
+| 代码风格/分层 | 0 | 0 | PASS,未重跑静态工具 |
+| design 现状同步 | 1 | 0 | D1:刷新当前描述后复审 |
+
+## D1 — HIGH:design 当前快照落后于已记录的实现与实证
+
+`features/v3.0.0/059-k8s-runtime-backend/design.md` 以下是当前状态描述,而非修订历史:
+
+- 第 120 行仍写 L1 265/269、R29/f886/epoch24、T069 剩安全删除、T070 迁移待验;当前任务及实际证据已含 R31 安全删除、M2 显式恢复 gen4、R32 租约交接定向通过。
+- 第 262 行 §4.7 标题仍写“正式初始化待验收”,但五种模式发布与 r11 初始化已有实际证据;完整续期矩阵仍未证明,应保留该边界。
+- 第 304、306、308 行仍称 K8s 数据业务/owner 完整数据行程未验;r11 在线与停用数据 API、编辑后恢复已实测。
+- 第 447 行仍称真实 owner 数据行程待验收。
+
+按技能“design 与代码现状偏离视为 HIGH”需合入前刷新。建议只更新上述现状段,引用 `e2e-evidence/platform.json`、`fault-upgrade.json` 的相应实际子合同;历史失败、旧快照、旧 false/unknown writer 和剩余矩阵均保留。不要泛化为全部 feature/AC 完成交付。
+
+相关证据文件中也有按历史快照追加的旧状态,顶层当前指针应清晰标明;本报告不要求删除历史。
+
+## 1. 边界条件与真实入口
+
+1. 发布 pipeline 在 `publish_pipeline_service.py:531` 按 runtime protocol v2 冻结 delivery/准备,`:573` 按 `resolved_delivery` 进入 v2 probe;online/preview、stop/destroy 也有实际选择分发。不是只有类定义没有注册。
+2. `api/router.py:25,135` 注册 app-runtime;`main.py:250` 注册独立材料入口;`app_runtime/api/router.py` 接入 management、storage、Kube、delivery、应用与代理内部接口。
+3. `app_data_service.py:65-176` 在原 owner 和参数验证之后分发 K8s `RuntimeDataSessionService`,Docker 保留原 RPC。表/列标识、排序、页码、行键和值均有界校验,不把任意 SQL 暴露给 manager。
+4. 材料接口、产物上传、附件流均设上限。材料 8MiB 分片、offset/size/hash 和平台枚举校验;最终总大小和完整 SHA 校验后才 confirmed/verified。数据导出用受限文件流与摘要校验,不把 CSV 塞入持久 journal。
+5. 查询/等待有原操作 deadline;数据库事务不包住跨进程网络等待。未知结果持久保留,不自动换请求重做写动作。
+
+## 2. 权限、tenant 与认证
+
+- 人工接口:`runtime_management_service.py:35` 及 `app_data_service.py:188-213` 检查 tenant/自然 owner。自然 owner 是额外的业务约束,未以 RBAC 菜单或自写 FGA tuple 放行。App 生命周期仍走 permission/application 适配及投影。
+- 用户访问:entry 判定经权限平台,路由读取不是最终用户授权。HTTP/WS 所需身份和 OBO 仍由原代理路径处理;没有把 manager key 作为用户权限替代品。
+- 内部 HMAC:`runtime_protocol.py:34-105` 绑定 method、目标路径/排序 query、body SHA、timestamp、nonce、key_id;恒定时间比较、60s 时间窗、Redis 单次 nonce、角色与 node 绑定。Kube 控制面还要求专用服务角色。
+- 免 cookie 的路径:`utils/http_middleware.py:48-50,439` 仅为内部运行/材料/附件协议建立其自己的可信作用域;对应 endpoint 仍执行 HMAC、Ed25519 或 opaque storage credential,不是匿名资源入口。
+- 新表:`ScopedRuntimeRecord.tenant_id` 为 `None` 等待 ContextVar 填充,数据库非空/索引不变;Kube resource/delegation 均继承该模型。没有用默认租户 0 覆盖显式租户。
+- 模型发现:`app_runtime/domain/models/__init__.py` 同时注册 RUNTIME_TABLES/KUBERNETES_TABLES,且被 `core/database/tenant_filter.py:131` 的强制导入覆盖,避免 lazy import 漏 tenant 表。
+- 服务端 infra catalog(node/profile)为有意全局配置;跨租户容量查询只聚合 node 内资源,不向普通业务调用返回其他租户明细。
+
+## 3. 事务、并发、幂等与资源围栏
+
+- `runtime_delivery_repository.py:53-97` 的 UOW/tenant scope 在嵌套调用共享 session;外层提交与异常回滚。save 同时验证目标和已有对象,不依赖自动 SELECT 过滤去保护 bulk UPDATE/DELETE。
+- `create_operation():285` 用 scope+idempotency_key 唯一性及内容 digest;并发冲突经 savepoint 后读取已提交同项,不重新提交不同内容。
+- `admit():320` 在同一 node 行锁下计算 held/active、心跳和预算;不同租户争用使用同锁。数据会话按 node→assignment 同序锁,原 app 互斥和 helper 并发上限都持久化。
+- `app_state_service.py:648` 提交 ready 时同事务校验 operation、tenant、expected state/current/pending、批准版本与冻结 delivery,并完成 App CAS、实例、route 及权限投影。没有仅凭进程内状态就提前正式切路由。
+- 材料和资源不能由 self-reported UID 建立授权。resource 意图、UID、generation/owner/controller、停止事实与 cleanup 独立保留。旧 false/unknown 不因新心跳恢复;无法证明停止时保留资源与容量,未看到后台强清绕过。
+- T271 restore→serve 入口仅允许严格 `resume_after_restore is True`、同操作已持久恢复结果、合法启动阶段;仍复用原冻结模板/资源/租约/停止验证。没有放宽全部 restore 的 purpose 白名单。
+- restore current-v1 的 route commit、清 pending 与材料复核有现成链路;M2 第二次正常新请求 gen4 的实际证据验证该完整链,不把旧失败 gen3 改为成功。
+
+## 4. 材料/产物与存储安全
+
+- `RuntimeMaterialService._authority/_current` 将 delegation→op→resource→tenant/app/node/generation/owner/current assignment 和冻结 delivery 复核。每次 request/transfer 以及长 I/O 中重新检查,不把 300s ticket 当独立长期授权。
+- workload 签名绑定 raw path/query/body、key、Pod UID、时间和 nonce;backend 仅保存公钥与授权记录。manager root seed/private key 不入 SQL,普通 app 不获得 manager HMAC。
+- frozen artifact 仅允许明确 kind、digest、platform、app/tenant;公共 offline 材料还要求同 profile id/digest。下载不接受任意 object key/URL;上传只允许该 purpose 派生的 kind。
+- transfer 按 authorization kind/id/artifact/action 唯一;刷新 ticket 作废旧 hash。分片同 offset 内容才能幂等;完整上传仍校验 parts、size、SHA、当前授权后才 confirmed/verified。
+- 附件已统一走 backend `app_storage`,app token 仅保存 hash,绑定 app/version/当前 serving 或 preview;对象 prefix 为 tenant/app 或该 preview session。键名拒绝 traversal/control/backslash;legacy prefix 只容许该 app 精确附件根。
+- cleanup 先 revoke binding,再限定 prefix 删除;preview 不删除正式附件。purge 快照/临时 transfer chunks 与源码历史有区分。manager 不直接持有 MinIO 配置/密钥。
+- 本次未发现新硬编码凭据、私钥落库、SQL/traceback 直接暴露路径。内部诊断是受限管理员协议,公开接口用固定业务错误;此结论是相关改动静态审查,不是全仓敏感数据扫描。
+
+## 5. SQLite / 数据 API
+
+- owner 数据 API、SDK 附件与应用自身访问是不同授权面。K8s data grant 由 backend 独立私钥签发,绑定 actor/action/parameter digest/operation/request/app/version/exec_ref/PodUID/generation,5s 接纳窗和原 60s 总期限;manager 不可签发 owner grant。
+- 在线只用当前同 UID sidecar;离线使用保留稳定 PVC,在旧 writer 停止后建立受控 data Pod,不为读取创建第二生产 writer。维护互斥、会话完成清理与结果未知均在 backend 持久。
+- Docker WAL 修复限定该 app 数据目录可写以允许 SQLite WAL/SHM,SQL read action 仍 `mode=ro`,未采用 immutable 丢弃 WAL 的方式;F059 分发未改变 Docker 原通路。
+- 迁移/恢复保护快照须 verified/confirmed 关联再 DDL/替换。快照可 dedup,判断依据是同操作 fact+confirmed transfer 和 artifact,不要求快照计数增加。
+- 原失败和未知动作不自动恢复。显式 restore 要自然 owner、数据丢失 ACK、fresh generation、同 app snapshot、当前版本冻结 delivery;不提供任意版本选择/隐式代码回滚。
+
+## 6. F086 兼容性与迁移
+
+### F086 保持与升级前提
+
+- protocol v1 的历史发布/迁移路径仍保留;v2 bundle/source-image/image 能力仅在 protocol2 选择,不能把新包误送旧 manager。
+- Docker 数据 RPC/预算保持;Kube 增加 sidecar/helper 预占及 namespace/profile/report 前置。新增配置不是无条件把现有 Docker node 解释为 Kube。
+- app SDK 的相对附件/模型/OBO 接口保留,附件存储权威改在 backend;不要求 manager 新建 MinIO 实例/桶。
+- 受控 helper 与材料接口是新后端/manager 的成套合同,不能只更新 manager 而让旧 backend 缺 API;Kube 还要求真实 RBAC/准入/网络/稳定卷能力报告,缺前置 fail closed。
+- transfer authority 扩展的历史 nullable authorization_id 无迁移内数据回填;客户升级应按设计停止旧 transfer 并执行已声明的版本/数据前置,不把模型新 default 当旧历史行已回填。
+
+### `667546bb7` 已继承,不是遗漏
+
+- F059 继承提交:`ea3b1f6afb68da2b52ba4fd1fcf0bb5e060510ba`。
+- 当前 `f086_runtime_byte_counters.py` 与 `667546bb77eeb366e66a13d544d122ef24811e35` **逐文件 diff 为零**。
+- 当前 `RuntimeArtifact.size_bytes` / `RuntimeTransfer.offset` 均为 non-null/default0 的 `BigInteger`。
+- `f059_merge_f086_heads` 显式汇合 F059 与 byte-counter 两父链。不能仅因该修复 SHA 不是 ancestor 就判断缺失。
+
+### Alembic 静态图
+
+本次用 AST 读取 115 个 revision:唯一 head `f059_resource_phases`;missing parent 为零。
+
+链路包括 `f059_runtime_kubernetes` 与 `f086_runtime_byte_counters` → `f059_merge_f086_heads` → release-head merge → `f059_resource_phases`。保持原历史父链;清除旧 operation/kind/name 唯一索引允许顺序阶段保留独立不可变证据,降级要求先处理冲突,未直接删审计行。
+
+此为静态模型/DDL 审查,未新执行 MySQL/DM8 升级。DM8 依用户确认仅静态核约束/SQL 类型,不宣称实测兼容。
+
+## 7. 已有证据及明确边界
+
+### 可以引用的既有证据
+
+- `e2e-evidence/platform.json`:T069 五交付模式 v1/v2、审批/预览、HTTP/WS/SDK/OBO、数据、权限、停启与 scoped 删除业务闭环;文档明确不是所有 AC/故障/卸载/发行通过。
+- `e2e-evidence/fault-upgrade.json`:保留各历史失败和精确子项。R11 真实离线数据/恢复;M2 真实迁移失败与 gen4 显式恢复;R32 在途准备租约交接。
+- 独立证据报告 `/private/tmp/f059-m2-restore4-independent-evidence-review.md`(SHA `52e7750627fa602226800ec4b44968ed5416b0f61784dafb6d195ad2c35f3f1a`):原失败 parent/gen3 不变,gen4 单次恢复、NULL+随机哨兵摘要、v1 route、pending 清除、同操作 confirmed upload / dedup artifact 和导出完整性一致;不输出数据/nonce。
+- 独立证据报告 `/private/tmp/f059-prepare-sigkill-r4-independent-evidence.md`(SHA `a89f59ae73a2b54a91b550b3497bcf467c1d46609e2a65dec912571c304202a2`):同原 prepare/deadline/install UID/CID,manager controller30→31,原240s hook自然完成,8准备资源正常清理/released。后续 probe 是 SQL completed/cleanupcomplete+正式版本,不据此声称 probe 物理清理或批准上线。
+- 已有实际 Service/SQLite 配对、定向 L1 日志包括保护 sticky/false/metadata、restore→serve、幂等/租约/资源事实及自然 owner 拒绝。此次审查未重跑它们,也未用 mock 替代远端验证。
+
+### 仍保留的交付边界
+
+1. 完整离线客户安装、完整依赖/缓存组合、全部 fault/UID替换/未知 writer/响应丢失、独立安装域卸载保留等仍按各 AC 的现有未验标记,不能由局部通过替代。
+2. 旧 r4 和历史 false/unknown held writer 继续保留围栏/资源/容量,不为合入强清。
+3. ARM、追加109、跨节点/NodeLost、正式公共 CI/Docker Hub/tag 发行本轮暂停/未执行。189 单节点 amd64 不证明这些能力。
+4. CLI 最新 wheel已定向本地源码匹配,不意味着客户发行已完成。静态迁移图不等于客户旧数据库真实升级。
+5. 本报告仅后台审查线,不代替 runtime/安装/发行/前端或 root 的最终集成判断;不直接勾选全交付 T072。
+
+## 合入建议
+
+先由 root 修正 D1 的当前文档快照,保留历史和上述边界后做文档定向复审。后台代码无需因本报告新增实现或重跑全量;可以按用户授权将开发合入 `feat/3.0.0-vibe`,另保留未完成交付事项。
+
+
+## D1 定向复审关闭(2026-10-23)
+
+**复审结论:D1 CLOSED;后台开发合入 L2:PASS。** 本节取代前文因 D1 暂列 NEEDS_FIX 的结论;原发现及审查过程保留。
+
+仅检查整合工作树 `/Users/zhangguoqing/works/bisheng/.worktrees/vibe-app-integration` 的本次 `design.md` 文档 diff,未做新的代码审计或测试,未操作远端。
+
+- 第 120–124 行当前快照已更新为 R32、原 267/271 加 T274 后 268/272,并明确余项属于交付门,不等同四块未实现业务代码。
+- 第 266 行将正式初始化与准备实证列为已验证,完整续期故障矩阵仍保留待验。
+- 第 308、310、312、453 行已按 R11 owner 在线/停用编辑、CSV、恢复一致性及 M2 显式恢复更新,不再保留相反的当前状态描述。
+- 第 421 行已知坑明确旧 holder 未到期的正常 409 不得进入业务失败清理,以及 T272/T273 的既有处理落点与 R32 实证。
+- 文档保留历史、旧 false/unknown writer 围栏、ARM/追加109暂缓、DM8静态、完整离线/剩余故障/独立卸载/跨节点/公开发行未完成边界;没有把准备资源清理或 probe SQL终态扩大为物理清理/审批上线证明。
+- 头部区分设计起点与实现基线、更新日期,并追加本轮修订历史。CI 锁条目为本次整合文档同步,不在本后端复审中新增 CI 实现审计结论。
+
+复审文档 SHA256:`9f41bbc3acc64525eb3aedf4c97f4f846a201b520cefd4278844f762b4638dce`。
+
+此 PASS 仅为当前后端开发合入范围;前述完整交付边界保持。无需因关闭 D1 补跑全量验证。
diff --git a/features/v3.0.0/059-k8s-runtime-backend/integration-review/delivery.md b/features/v3.0.0/059-k8s-runtime-backend/integration-review/delivery.md
new file mode 100644
index 0000000000..dd07cd89cf
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/integration-review/delivery.md
@@ -0,0 +1,116 @@
+# Vibe 合并 L2:发行、安装、SDK/CLI 与文档收口
+
+## 结论
+
+**限定交付代码范围:PASS_WITH_WARNINGS;未发现已确认的代码 HIGH。** F086/F059 代码可以按用户本次要求整合到 `feat/3.0.0-vibe`,但不能据此将完整 SDD、客户离线交付或 F059 正式 CI 发行标为完成。
+
+原固定源码有一项发行 MEDIUM(同 release 跨 ref 并发);root 已决定本轮修正,见下文。修订源码与静态合同测试固定前,本报告不将它写成旧提交已有的保护。
+
+- 固定来源:`codex/059-k8s-runtime@cda8dccf26298af91a1df277dcd1a49787b6b444`。
+- 固定目标:`feat/3.0.0-vibe@4aa9ce69b2830b81162fe631b9c801057c40ee8b`。
+- 此范围包括 Compose/Helm/镜像构建与目录、installer、SDK/CLI 分发、F086 待收文档及四个旧子分支,不替代 backend 状态机、权限和前端的其他 L2 审查。
+- 两提交的三点 diff 有 1640 文件,包含继承的发布线变更;该数字不代表全部都是本次 Vibe 独占实现或已由本报告逐文件审完。
+- 仅本地只读代码/历史/归档内容核对及生成此报告。没有全量测试、网络、远端、构建、推送或产品文件/Git 写入。
+
+## 七维度摘要
+
+| 维度 | 本范围结果 |
+|---|---|
+| 边界条件 | 交付模式/平台/镜像摘要/库存完整性有明确检查;离线镜像、依赖和平台接口分别核验,不能互相替代 |
+| 权限与认证 | manager 编排权限与入口/出站/准入身份分离;Docker socket 仅在 socket proxy,K8s token 仅 manager;installer 另需运维权限 |
+| 并发安全 | profile 默认值 CAS、镜像版本冻结和只读运行材料合同保留;CI 跨 ref 同 release 并发为本轮唯一 MEDIUM,root 正在闭合 |
+| 信息泄漏 | Helm 引用既有 Secret/公开 CA/公钥,值不写配置例;SDK/CLI 归档核对不读取客户凭据;全量 image Env 不作结论依据 |
+| 测试覆盖 | 既有真实/模拟/静态结果按 SHA 分层;本次未重跑测试,F059 T070/T071/T072 不能因此关闭 |
+| 风格与边界 | helper/assembler/runtime 分工一致,非业务代理不混入编排身份;旧子分支未发现待合入独占实现 |
+| 文档同步 | F086 十份修改与 CI/模型附件应限定路径收录;历史 NEEDS_FIX/未验项保留,新整合结论另记,不覆盖为全通过 |
+
+## MEDIUM M1:相同发行标识跨 ref 竞争(root 本轮修复)
+
+固定 cda 源码 `.github/workflows/runtime-images.yml:14` 的组为 `runtime-images-${{ github.ref }}-${{ inputs.version }}`,但实际镜像 release 使用 `inputs.version || github.ref_name`。两个分支手动填同 version,或 tag 与手动填同 release,可能进入不同组。`scripts/build_runtime_images.sh:34–42` 的标签不存在性检查和 `:168–170` 的最终 push 之间不是原子占位,因此不能说客户端检查能绝对保证标签不可覆盖。
+
+root 指定的新组:`runtime-images-${{ inputs.version || github.ref_name }}`,保留 `cancel-in-progress:false`。**该表达式与实际 RUNTIME_RELEASE 一致,能覆盖同仓库此 workflow 的同 version 跨 ref/tag+manual 竞争。** 架构矩阵仍发布不同 `-amd64/-arm64` 标签,catalog 继续等待两个 native job。
+
+边界:其他 workflow、其他仓库、用户直接 CLI 写同一 DockerHub repository 不受这个 GitHub concurrency group 约束。正式发行仍须使用唯一新 release,控制发布权限,并按仓库能力采用不可变策略;已存在/不确定标签继续拒绝,不能重推未知结果。修复不授权本次公开 tag/push。
+
+## 交付合同核对
+
+### Docker Compose
+
+- `docker/docker-compose.yml:305` 起的 `app-runtime` 可选 profile 保留 manager、app-proxy、egress-proxy、docker-socket-proxy 四进程。manager/egress 共用服务镜像,入口独立镜像;不 publish 每个应用的端口。
+- 正式 Compose 使用预构建镜像;开发 build override 在独立文件。默认服务 tag `v3.0.0` 只是一条配置,不能证明它已按本次新源码发行;实际部署须通过 `BISHENG_RUNTIME_MANAGER_IMAGE`、`BISHENG_APP_PROXY_IMAGE` 选择已验证引用。
+- `RTM_PROTOCOL_VERSION=2`、分离角色 HMAC、backend URL、原绝对 host data root 与容器 bind 对应;应用数据/helper 在 daemon 宿主解释路径。仅 socket-proxy 挂 docker.sock,manager 经 Engine HTTP API;无需安装 Docker CLI,但 API 权限仍然强。
+- app/build 内网与独立 runtime-control 网络、socket API 白名单保留。backend/Celery 还须启用 app_runtime/open_platform、配置 payload/OBO/key IDs、节点和库存;只启动四容器不等于功能可用。
+- 运行基础镜像由 backend 冻结 profile 和默认映射选择,不由更换 manager 镜像或 RTM_IMAGE_PREFIX 决定。
+
+### Helm / installer / helper
+
+- Chart 只装开源托管层,backend/worker、数据库、MinIO、OpenFGA 仍为外部平台。控制/执行 namespace 分离;manager SA 的范围化工作负载权限、显式轮换 token、无 token 的独立 admission 与默认拒绝网络保持。
+- `values.yaml` 的空镜像/CA/网络/公钥/存储/报告并不是可直接使用的客户配置。必须提供精确 digest、独立角色 Secret、持久 material seed/admission CA、owner 公钥、可信 CNI/storage controller 身份、真实 CIDR/端口和合格容量存储。
+- helper 来自同发行的 manager+Python preparer+Node 原生工具,安装受信任 site-packages,UID10001/isolated Python;assembler FROM helper 加固定 crane 及许可证。源码 COPY 的归属明确,不能用 manager-only 重建冒充 helper/assembler/profile 已更新。
+- bundle 不构建 OCI:backend 提供冻结材料,Pod 内禁网物化,代码/依赖只读;完整 image 直接运行;source-image 另需可推内网仓库、assembler、相应 CA/策略/凭据。默认 bundle 不以应用推送仓库为前置。
+- SQLite local PV 要实际有界 ext4/xfs、挂载 marker/UID 权限和节点绑定;PVC capacity 不是宿主目录配额。预览隔离,生产单写;Retain PV 的管理员回收不属于业务自动清理。
+- installer 默认诊断,`--live --write-report` 才真测/写报告;外部运维需 kubectl、kubeconfig/impersonation、同版本 Python 依赖,source-image 可选检查另需 crane。它不向业务传递安装者身份。
+- 能力报告与 descriptor 绑定、24 小时有效,外部运维按 6 小时刷新;Pod 健康、Helm wait、TCP canary、外部 crane 与真实 assembler push 是不同证据层。DNAT 后实际端口、真实 CNI 行为必须现场验证。客户环境须安排刷新监督,本 Chart 不偷偷部署高权限常驻 installer。
+- 升级保留原 canary CM 归属/UID/RV,namespace/PV/公钥有 keep,独立业务 Pod/PVC 无控制 Deployment ownerReference。卸载不能在旧 unknown writer/保护未闭合时删除准入/网络;完整独立域卸载仍待 T070。
+
+### CI / 镜像库存
+
+- `runtime-images.yml` 触发为 `v*` tag 或显式 workflow_dispatch version。两个原生 runner 构建 **九镜像**:manager/proxy/helper/assembler 与五运行/准备镜像;原生 host/Engine/镜像执行架构必须一致,拒绝仿真证据。
+- 使用当前 context 的 Docker driver 复用本机基础镜像,isolated helper/resolver、SDK wheel、许可证、SQLite 等检查在 push 前;真正的 Engine bundle integration 是其后的独立 step。架构镜像可能已推而后续 integration 失败,不能把它们当完整发行;服务索引/catalog 只有两个 job 全部通过才生成。
+- `image_catalog.py` 检查九组件、同 release/source、架构、标签与 manifest digest,服务多架构索引只从冻结平台 digest 组合;本地 config ID、单平台 manifest digest、多架构 index 和 tag 不混用。
+- 离线清单分镜像/依赖/平台 API 三层,并保留工具/许可证材料。正式 F059 CI 仍须在最终整合 SHA 重新跑,旧版本同名 artifact 或私有 R32 manager receipt 不代替它。
+
+### SDK / CLI:实际分发包核对
+
+使用固定 cda 提交的 Git blob 读取源码与 wheel,没有安装或执行 wheel。
+
+| 项目 | 核对结果 |
+|---|---|
+| CLI3.0.0 | wheel SHA `739d58c34a4f9eb4833c21ecec60936ef78bf1a48ce0b050b0d86a810b0e3e39` 等 manifest;20 个包内 Python 模块逐字节等源码;实际分发 errors.py 包含16125容量处理 |
+| SDK0.1.1 | wheel SHA `1c1e6cc3ca5a037dc91cfc8052ce63565a31f630f0981dd0fa2b8e06b2085950` 等 manifest;15 个包内模块逐字节等源码;与历史SDK轮子摘要保持 |
+| 依赖/版本 | Python>=3.11;SDK仅httpx>=0.27,<1.0运行依赖;CLI另有PyYAML;dev依赖不作为应用运行闭包;metadata/project/manifest版本一致 |
+
+SDK不持平台DB/MinIO/集群凭据,附件仍经backend既有合同。新增依赖时 wheelhouse/npm/系统库及目标ABI必须提前供应;不能把开发venv或一次SDK轮子存在误作所有依赖已离线可用。
+
+## 四个旧子分支:实际文件与历史核对
+
+四个工作树检查时均 clean。未执行 cherry-pick/merge,也未依赖 `git cherry` 单独判定。
+
+| 旧分支固定 HEAD | 文件与历史结论 |
+|---|---|
+| `codex/f059-cli-capacity@01b58350e97d71e87c784d4ebc58fd039a94b80c` | `d156c88fe...` 测试和 `01b58350e...` 实现仍是独占 hash,但 source 有对应集成 `0ff595428`;两个测试文件、errors.py、已打包wheel、manifest与source字节完全相同。不得再摘取造成重复历史 |
+| `codex/f059-image-release@f84415f2540b1b762dcfba2b7e8e521ca170f3bd` | 分支内容已在source历史;本组k8s_templates.py/test与source仅后续995c PATH修复1行/26测试行差异。CPU100m修改保留,无遗漏独占实现 |
+| `codex/f059-installation@992905292418308b298d48a24d20cc9e19032c15` | 最后四个backend CPU预占/service/测试文件与source完全同字节,分支历史已包含;无遗漏独占代码 |
+| `codex/f059-e2e@21250d32900c4c4ba893248aa5b8cc1d8d65182b` | 两个同步请求预算/E2E合同文件与source完全同字节,分支历史已包含;无遗漏独占代码 |
+
+## F086 dirty 文档:限定收录,不碰旧公共草稿
+
+允许整合的十份修改均在 `features/v3.0.0/086-vibe-runtime-delivery/`:`spec.md`、`design.md`、`tasks.md`、`tasks-index.json`、`task-reviews.md`、`code-review.md`、`e2e-checklist.md`、`implementation-files.sha256.json`、`live-validation.md`、`validation.json`。另有同目录未跟踪的 `e2e-evidence/2026-10-21-ci-release/`、`e2e-evidence/2026-10-21-docker/`、`model-review.{md,json}`、`model-review-tests.txt`,可按Vibe文档范围带入。
+
+以下不是这批文档,必须留在原dirty WT:F048 preset-dashboard/bootstrap、tenant ContextVar、system_resource_reconcile 三生产文件,两权限测试,以及未跟踪 `test/tenant/test_tenant_execution_scope.py`。本审查未移动、提交或覆盖它们。
+
+F086 dirty code-review仍写 `NEEDS_FIX/不进入merge`,其中H1/H2是完整离线新装/目录导入与剩余故障交付门,M1是记录的质量基线,不代表发现尚未修复的静态代码HIGH。用户本次要求代码整合与完成这些交付门是两个决定:新的整合L2应单列该范围和保留门,不能把旧历史报告直接替换成“全部完成”。
+
+## 历史真实 CI 与后续发行边界
+
+F086 T053的确有历史真实结果:tag `v3.0.0-test-vibe`,源码 `755d4456e50c015a3fa88da0c0c35ab53bbd1650`,runtime run `39567381522`,两原生job和catalog成功;**每架构七镜像**,并有109服务镜像启动记录。artifact ZIP当时未下载/未导入平台,新模型/tenant草稿不在tag里。这是保留原始证据,不在本次联网复核。
+
+它不能证明新F059九镜像(新增helper/assembler)、最终整合源码的双原生CI、完整离线平台目录导入或新tag已经发行。R32的真实私仓manager digest `bc488d16...` 仅对应源码 `0f2db1f9a...` 的原生amd64 manager-only,与保留的旧helper/profile/代理来源分别记账;不能转标为 cda 或最终整合提交的全组件发行。
+
+后续仍须明确保留:F059 T067后续Docker范围、T070剩余故障/独立域卸载与未实测AC子句、T071最终CI/模型发行证据、T072特性级总审查;F086完整离线新装/目录导入及尚未覆盖故障组合。DM8按用户确认仅静态检查,真实驱动/DDL/事务留中央环境;单节点不证明跨节点,ARM后续验证仍服从用户当前暂停范围。既有frontend/backend基线失败不可宣称被本范围消除。
+
+**整合建议:可以合入已实现的F086/F059代码及限定Vibe文档,携带本轮CI串行修复和未验清单;不得以合并动作自动关闭上述验收/正式发行门,也不自动新建公开tag或推送镜像。**
+
+
+## M1 关闭补充:整合工作树定向复核(2026-10-23)
+
+**结论:MEDIUM M1 已关闭;本次已审交付代码范围可合并。** 本补充只复核 root 本轮两文件增量,不重写上文 cda 固定源码的历史发现,不代表剩余验收或正式镜像发行已完成。
+
+- 复核工作树:`/Users/zhangguoqing/works/bisheng/.worktrees/vibe-app-integration`;检查时 HEAD 为 `575984069ff15e994caa53bda1785e769f6d80f5`,本轮修改尚在该工作树 diff 中。
+- `.github/workflows/runtime-images.yml:14` 已改为 `group: runtime-images-${{ inputs.version || github.ref_name }}`,`:15` 保留 `cancel-in-progress: false`。该表达式与 `:37`、`:43` 的实际 `RUNTIME_RELEASE` 完全相同:不同 ref 手动指定同 version、同名 tag 与手动 version 均进入同一并发组;正在执行的同组发行不会被新请求主动取消。
+- `src/runtime-manager/tests/test_image_release.py:138` 的既有契约测试新增精确 group 和 `cancel-in-progress: false` 两条断言;原原生 runner、无仿真、digest/index 门仍在。本轮定向红绿为 root 提供的 **1 failed → 1 passed**,本独立复核只读 diff/调用处,未再次运行测试或触发 CI。
+- 两架构继续使用各自平台 tag,catalog 继续依赖两个 native job;本轮没有改变镜像构建、推送、索引或发行资料的验证顺序。
+
+关闭范围是**同一仓库、采用该 group 的此工作流、相同有效 release 标识**的竞争。其他工作流采用不同 group、其他仓库或直接 registry/CLI 写入不受该锁约束;客户端“不存在”检查仍不是原子 registry 占位。唯一 release、受控发布权限和 registry 不可变策略的边界继续保留,不能宣称所有写入者已被序列化。
+
+本轮未发现该定向增量的 HIGH/MEDIUM 阻断。用户已明确授权合入后推送 Git 分支,由 root 执行;这不等于新增公开 tag 或公开镜像发行授权。上文列出的 F086/F059 未验项、最终整合 SHA 的正式双架构 CI 和全量 SDD 交付门保持未关闭。
diff --git a/features/v3.0.0/059-k8s-runtime-backend/integration-review/f086-recovery-manifest.json b/features/v3.0.0/059-k8s-runtime-backend/integration-review/f086-recovery-manifest.json
new file mode 100644
index 0000000000..4bbcdd7832
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/integration-review/f086-recovery-manifest.json
@@ -0,0 +1,470 @@
+[
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/code-review.md",
+    "source_status": " M",
+    "sha256": "204063a2ee251ec3af4bad41d2c2caf6298c6616305bdb1cd5f41cdfe761a81a",
+    "bytes": 7179
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/design.md",
+    "source_status": " M",
+    "sha256": "854ab6a042a6b258bfb822a25b93562445a86a711c2471d60a848a12f2ffaa71",
+    "bytes": 74811
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-checklist.md",
+    "source_status": " M",
+    "sha256": "ecf6d5c246fec5e2607cff8bdab90fb7071f42548c85846993843a9029b604ca",
+    "bytes": 8922
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/implementation-files.sha256.json",
+    "source_status": " M",
+    "sha256": "6bf757300db21cce238bef95515392a23b8ceb4d12a3717fa350dd61557c1fa9",
+    "bytes": 16925
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/live-validation.md",
+    "source_status": " M",
+    "sha256": "3d25e7b03506829dbafbf8a40eacb677e8e6d9911b5793c16250fdea23cfa1af",
+    "bytes": 24603
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/spec.md",
+    "source_status": " M",
+    "sha256": "52b6be5885b1f010ce04a4adf4ae90ed4381ee39e623eb62fd38cedc23921645",
+    "bytes": 23969
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/task-reviews.md",
+    "source_status": " M",
+    "sha256": "b1691ae2263d254980f086776a99c99708076ee6455594a7023e593ac9a416ca",
+    "bytes": 41795
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/tasks-index.json",
+    "source_status": " M",
+    "sha256": "3a019e3c68b49c38821bbb5861bf920cd992af4e922e0e7e7ccc2db845955fab",
+    "bytes": 55789
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/tasks.md",
+    "source_status": " M",
+    "sha256": "cfeb8d132b0251ed972f80989af0219e2ada8b16ec0e41e68fd5a1640213e61d",
+    "bytes": 48583
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/validation.json",
+    "source_status": " M",
+    "sha256": "1319567ab824b021e8d59fba0f7bc63b6ba68616c9d4aaed430eb106e43fbc50",
+    "bytes": 11855
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/README.md",
+    "source_status": "??",
+    "sha256": "6014af13ba36e6663873b0b19dee8924fdc3224ed7f49ffda366a3aca9c30a72",
+    "bytes": 2257
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/app-proxy-index.json",
+    "source_status": "??",
+    "sha256": "0c5cbff5b4ae0e7e5ab6be8dd468549013e305e4317ef15d73b958cb2279506a",
+    "bytes": 686
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/cleanup-verification.json",
+    "source_status": "??",
+    "sha256": "da0422acf68e383dea178995fed3d71854275c8ce624ef9ea5733c9078aeea31",
+    "bytes": 246
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/release.json",
+    "source_status": "??",
+    "sha256": "a57b4b5e9589919a382ebb3d12c740e36d0ae58d7b4da7efe45d21e77bbe94ea",
+    "bytes": 1966
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/runtime-manager-index.json",
+    "source_status": "??",
+    "sha256": "1e42c3a775593063838b2ba8c2e1daceeacf7849e1ebaaf21f0af4ecaa155547",
+    "bytes": 686
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/service-verification.json",
+    "source_status": "??",
+    "sha256": "1ec63f3ae5da40941792da6f01f2209077dfb158b434a1656d2a61d39f96d466",
+    "bytes": 1995
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/sha256.json",
+    "source_status": "??",
+    "sha256": "065cb7a51ba1316f74857ae3eb2f01fdbe80a2c3f8a3b12dcfb3657c72186f7d",
+    "bytes": 656
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/verify_services.py",
+    "source_status": "??",
+    "sha256": "01722a2e1dfc0eed3981c8b15a268bed0474d7c7a6c93d89e777cb9382ecebd8",
+    "bytes": 6534
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/.gitignore",
+    "source_status": "??",
+    "sha256": "a4e6b5adcc8e252c6e820a9a56a20a6249b18222a616090634ca5ab06f483a5a",
+    "bytes": 870
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/README.md",
+    "source_status": "??",
+    "sha256": "74fa8ee74fe07988b4090dd14800747191444adb8a1f8e61d981e1bba1c1a525",
+    "bytes": 1918
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/cancel-timeout-results.json",
+    "source_status": "??",
+    "sha256": "9cf30cb4b11aad5c685cfd15ef4bbfdc3123f3334044000115d4641b5cee0867",
+    "bytes": 3053
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/compile-timeout-result.json",
+    "source_status": "??",
+    "sha256": "b1f38374532a2f7007a0cf9926bb3a8c4394131c170ebcaa7041b515947ac2cc",
+    "bytes": 1590
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/connect-policy-revoke.json",
+    "source_status": "??",
+    "sha256": "e6c21701d3daeed33c4fb36568be8cc841cb6a6e7ac1992e27da1e714e6e62ac",
+    "bytes": 114
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/deletion-followup.json",
+    "source_status": "??",
+    "sha256": "a3c4ced6a96c0a8cddf308e7326dd84cc5ea4be918b29d97796a0cb9ea2cf9cf",
+    "bytes": 1095
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/docker-cleanup.json",
+    "source_status": "??",
+    "sha256": "4ac5d21a57bf5240186cfea43323bf1d6a05727986010cc6d9c28da8e0c9d147",
+    "bytes": 242
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/install-cancel-result.json",
+    "source_status": "??",
+    "sha256": "12f984b1295c18973f1ec68f50332411cd34d8ef6442ce443b28151c1eee67af",
+    "bytes": 1663
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/legacy-container-before.txt",
+    "source_status": "??",
+    "sha256": "1a55bff1e4fc0821a3cddb2adc17ac3418a6c093dc0775ea4640cc2c2c11cbdf",
+    "bytes": 13
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/legacy-manager-image.json",
+    "source_status": "??",
+    "sha256": "bc418fba3a5936e0c9a10bdf51fd4e75e4b353a229ac0b80b02b543327238897",
+    "bytes": 138
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/approval-retry.log",
+    "source_status": "??",
+    "sha256": "46172b32a17c3d002e8359f78ceed447ba0ceedf6a322d10787f549d73c5bde8",
+    "bytes": 837
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/bootstrap-before.log",
+    "source_status": "??",
+    "sha256": "e5836e984b153c35bed972ea87ea851b4d24bf056ce97d1afa8fde5c6a802360",
+    "bytes": 14405
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/bootstrap-fixed.log",
+    "source_status": "??",
+    "sha256": "9de2d09895516621dd074d8d6555dca6a0c7707d86bdd70ab1125559d0bf2e1f",
+    "bytes": 2968
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/compile-timeout.log",
+    "source_status": "??",
+    "sha256": "186a02e18ed0daad559fc290e5f845d51ca290b50904b40dd862ac52f6a6d1f5",
+    "bytes": 18016
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/connect-confirm.log",
+    "source_status": "??",
+    "sha256": "ccbafa3afc98c77243eae78a5326492aafa5bfb2cb7faf2187db099f7c21eb07",
+    "bytes": 10182
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/deletion-followup.log",
+    "source_status": "??",
+    "sha256": "8630a61d399cbb7b367790e6e21149a8e17fe7845dc19d7103a3c9bdbc9638b9",
+    "bytes": 11800
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/install-cancel.log",
+    "source_status": "??",
+    "sha256": "c6e190447a7dd995201a3f20beeb286cd7d9e6871abf0a5074b816b3c49ff7c9",
+    "bytes": 10486
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/legacy-pending-confirm.log",
+    "source_status": "??",
+    "sha256": "a6a9c6c5546d142a091dcc68afda20ecc7180a762334db29ed49cc04b6c32bea",
+    "bytes": 10137
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/legacy-publication.log",
+    "source_status": "??",
+    "sha256": "7730d2d07ab724ae3e82c6589dbeb62c4adcc76247ba836ca6117cffb1daa565",
+    "bytes": 10586
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/live-boundaries.log",
+    "source_status": "??",
+    "sha256": "7086834d382ffe07d561d3c902f8cc2255e7fc6e76cde3a36ff266f20ffd966d",
+    "bytes": 3015
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/manager-connect.log",
+    "source_status": "??",
+    "sha256": "7be6cbf12ff61752a9f96e9fa120d8c48159ba549086f563a6a13e93d6fc921e",
+    "bytes": 84
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/manager-existing-connections.log",
+    "source_status": "??",
+    "sha256": "23d4ec1b73e890ebe13af1b0feb8e35c46ac1e23180d6fd57031b60ece94e924",
+    "bytes": 215
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/migration-startup-failure.log",
+    "source_status": "??",
+    "sha256": "2c6191c112630159b98defdce94b3ad7bb51975cfe42b93de815c7b12a62ff37",
+    "bytes": 13823
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/mt-online.log",
+    "source_status": "??",
+    "sha256": "a93b1f042b039ed458ed07ec59ae47914924907b92ac24cfe3eee7962f6d4ba3",
+    "bytes": 125
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/multi-tenant-identities.log",
+    "source_status": "??",
+    "sha256": "4ab4ff8ebdcabce9209c99a203b02293c5d012958c677e24b1cab537e6c5960e",
+    "bytes": 1090
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/restart-probe.log",
+    "source_status": "??",
+    "sha256": "26339b428eb60ab07b86c35facbf1897269e390276e1b8290cd025d713476f83",
+    "bytes": 25155
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/snapshot-fault-confirm.log",
+    "source_status": "??",
+    "sha256": "c4d456d6a5ec0eeea025f9b53c0acbb7a93b599acff4238ffed3239641ec1267",
+    "bytes": 13853
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/timeout-confirm.log",
+    "source_status": "??",
+    "sha256": "b3cfb6fad1983a2985e7fb6009d85bb42b9b6c99ac6d0b257b1d2dec4f433f60",
+    "bytes": 10295
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/upgrade-apply-repeat.log",
+    "source_status": "??",
+    "sha256": "08f86945fc7b4d381fe9c0d93940ae667b283548995e9db6d2c2c0bec23ed403",
+    "bytes": 224
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/upgrade-apply.log",
+    "source_status": "??",
+    "sha256": "08f86945fc7b4d381fe9c0d93940ae667b283548995e9db6d2c2c0bec23ed403",
+    "bytes": 224
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/upgrade-dry-run.log",
+    "source_status": "??",
+    "sha256": "efa0eb31e66bcad82999a7045d4c1bfa032b481e882781fd2620b073354901d5",
+    "bytes": 227
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/upgrade-inventory.log",
+    "source_status": "??",
+    "sha256": "3d8e323bc3609387abffeb793811724d41f2a0cad7f18f334ca1606a2d9ccff2",
+    "bytes": 9798
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/upgrade-rebind.log",
+    "source_status": "??",
+    "sha256": "25687ac0cdb519d0568c370941abc0599723e18f4c75a61937cb411c97ecbf36",
+    "bytes": 11041
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/upgrade-retained-attachment.log",
+    "source_status": "??",
+    "sha256": "5a0c240ed5b24ac14917b5a829784fc7d14c0125493d804e69097a3122e55360",
+    "bytes": 75
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/upgrade-rollback-check.log",
+    "source_status": "??",
+    "sha256": "1bb69304f58b9ab3d8e8eed3c92c328ece68a88f373337287866252363fb6e50",
+    "bytes": 161
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/ws-backend-failclosed.log",
+    "source_status": "??",
+    "sha256": "edf3ea75d0ca90e6c55bbbe902e6d64c198b7a4e14a436f04b26419ec434aad8",
+    "bytes": 250
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/ws-permission-revoke.log",
+    "source_status": "??",
+    "sha256": "c292b1272ae3bd0c6c0f2740ca25614bf718f3f990cf52e64162c19530342649",
+    "bytes": 243
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/manager-existing-connections.json",
+    "source_status": "??",
+    "sha256": "f74016c6fc491249f0e022ab080061c80b51032232fd337047766c315a276a5d",
+    "bytes": 98
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/migration-startup-failure.json",
+    "source_status": "??",
+    "sha256": "e1f78ee22b08ca516ee54c38df8ecc82b10d656547bd752e53a461781be94e4f",
+    "bytes": 740
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/mt-baseline.json",
+    "source_status": "??",
+    "sha256": "2a6279219ca1b752ec1d4dad84cba1ba1af2f6f72e32e5e0a75ef3606ed6e5f9",
+    "bytes": 181
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/normalization.json",
+    "source_status": "??",
+    "sha256": "d2f376a2029fed4f39894d7691836e4ae02bfd4d8ebf12b5de9c623f467f93bd",
+    "bytes": 595
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/protected-backup-facts.json",
+    "source_status": "??",
+    "sha256": "8eb9ff8609e6566c5b065298b9e9c52e5a9f447d3f660d22abc79201aa4a348b",
+    "bytes": 771
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/quality-and-startup-observation.json",
+    "source_status": "??",
+    "sha256": "78976d111bc9715aff6f59ef0c5023cc6b180be27ca497e70ec4180aec60a216",
+    "bytes": 1640
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/restart-probe-after-ids.txt",
+    "source_status": "??",
+    "sha256": "3d90977135589a05781aad7479d30bc646cb490eb20ee1e78791a700a3d6ad77",
+    "bytes": 13
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/restart-probe-before-ids.txt",
+    "source_status": "??",
+    "sha256": "3d90977135589a05781aad7479d30bc646cb490eb20ee1e78791a700a3d6ad77",
+    "bytes": 13
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/restart-probe-result.json",
+    "source_status": "??",
+    "sha256": "4787c45c00aac946db6781e32cec28114d1dda35f3ea502083dd53b136814288",
+    "bytes": 1086
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/scratch-cleanup-facts.json",
+    "source_status": "??",
+    "sha256": "e935c80c7770ba9befa96faf7626cd28c94804ba9a793fa163f0456d4e99a7cb",
+    "bytes": 52
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/sha256-export.json",
+    "source_status": "??",
+    "sha256": "91580ce535d1c00bf287e48a518063cc36b62e26816e4941ec50e0af28e7893d",
+    "bytes": 5567
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/smoke-inventory.json",
+    "source_status": "??",
+    "sha256": "9e0203c5f9b74579404beb283e7cd5c5f6cc23add0ecc1e5b822f362c6e23973",
+    "bytes": 1534
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/snapshot-confirmation-fault.json",
+    "source_status": "??",
+    "sha256": "b7aa868b8b342d41891807b0b68d16a4a050f2c9a0228fd6837ccc4e4c94912e",
+    "bytes": 1132
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/upgrade-apply.json",
+    "source_status": "??",
+    "sha256": "cf27f6f21c0c84c17db1134552a8b84e528d26a76076badc3f92f3ce8b5e924b",
+    "bytes": 227
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/upgrade-dry-run.json",
+    "source_status": "??",
+    "sha256": "6a1a89f1c1f9386ba6cda23886f28055f705358c33ebcdbf25572f965ad67f0a",
+    "bytes": 332
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/upgrade-first-apply.json",
+    "source_status": "??",
+    "sha256": "bb7654a451b76301d099ba8dfaf66a1daeb21f15c66a137bc63117941169b659",
+    "bytes": 250
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/upgrade-inventory.json",
+    "source_status": "??",
+    "sha256": "0cea380418a9f2c082e96f9714fe47572caee36462757a7f9fbe7ce139bc6237",
+    "bytes": 1307
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/upgrade-rebind-result.json",
+    "source_status": "??",
+    "sha256": "904d2fcfee647aa312f1d993e4b7a41fe0481b4bb199e0a0aa6d7e6c40bee0a5",
+    "bytes": 490
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/ws-backend-failclosed.json",
+    "source_status": "??",
+    "sha256": "b073253b3550e3e21d2f6a8ca21da3f100a876e0b8df134f9541b3154ce05361",
+    "bytes": 133
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/ws-permission-revoke.json",
+    "source_status": "??",
+    "sha256": "4648bd269a0fe0d099f80518197a5fa5c27522573f23c311701719c031e55767",
+    "bytes": 127
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/model-review-tests.txt",
+    "source_status": "??",
+    "sha256": "e6df50c913491f78c8e9aa6725aa6afdad6be6c1ae90d8cf16571a7393440b6e",
+    "bytes": 1707
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/model-review.json",
+    "source_status": "??",
+    "sha256": "b85baa43b230aa792e4beadbb099021e2988fe6838032f5c974b181fc1577535",
+    "bytes": 37047
+  },
+  {
+    "path": "features/v3.0.0/086-vibe-runtime-delivery/model-review.md",
+    "source_status": "??",
+    "sha256": "c15e6048f3552814af4ab8d5e8851d35edb3fb54549b0fd1363c55e73ef0999f",
+    "bytes": 4555
+  }
+]
diff --git a/features/v3.0.0/059-k8s-runtime-backend/integration-review/runtime.md b/features/v3.0.0/059-k8s-runtime-backend/integration-review/runtime.md
new file mode 100644
index 0000000000..5736f92fcc
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/integration-review/runtime.md
@@ -0,0 +1,132 @@
+# F059 合入前 L2:runtime 核心范围只读审查
+
+- 日期:2026-10-23。
+- 审查源:`codex/059-k8s-runtime@cda8dccf26298af91a1df277dcd1a49787b6b444`。
+- 合入目标:`feat/3.0.0-vibe@4aa9ce69b2830b81162fe631b9c801057c40ee8b`;本地 merge-base 等于目标,目标已在源历史内。
+- 继承基线:F086 `5131b8119` / `755d4456e50c015a3fa88da0c0c35ab53bbd1650`;后者是源历史祖先。没有将 F086 原有实现误算为本轮新设计。
+- 方法:使用仓库 `sdd-code-review` 七维框架,读取当前源码、相关 diff/历史、已存在测试与189证据记录。无远端操作、无仓库改动、未运行新全量或原生测试。
+
+## 1. 结论
+
+**产品代码范围:PASS,未发现新增 HIGH / MEDIUM 产品缺陷。**
+
+**冻结 cda 文档同步:NEEDS_FIX,仅下述 D1。** 产品代码未因此需要增加实现;主 agent 更新设计现状并复核后,本报告的文档门可以关闭。本文是 runtime 分工审查,不是整个 Feature 的 L2、全 AC 验收或 T072 已完成证明。
+
+### D1 — HIGH:设计现状段落和已知坑索引没有收拢到最新实现
+
+位置:`features/v3.0.0/059-k8s-runtime-backend/design.md:120`、`§5`、`:843`。
+
+1. §4.1 的“F059 实施进展”仍写 `265/269`、R29、T069 剩安全删除。任务/证据后续已经记录 R31 删除、M2 新恢复、R32 在途重启及 `267/271`;读现状段会得到过期的实现与部署快照。
+2. T272/T273 的反直觉根因在修订历史完整记录,但 §5 已知坑表未登记:新 manager 在旧 holder 的租约尚有效时收到正常 `409/controller_lease_held`,若进入业务 `_failure`,可在稍后取得租约后清理仍运行的安装,错误地终结原 prepare。当前 `operations.execute` 已把 K8s `_controller()` 放在业务 claim/try 之前,避免该路径。
+
+`sdd-code-review` 维度7明确要求当前 design 快照与代码一致、反直觉修复进入已知坑表。最小修正:刷新 §4.1 当前状态且保留历史记录;§5 新增该事实、“误终结在途安装”后果和 `operations.execute` 前置租约门处理位置。无需改 spec 或产品合同,无需重跑全量。
+
+## 2. 七维结果
+
+| 维度 | 结果 | 实际检查与依据 |
+| --- | --- | --- |
+| 1 边界条件 | PASS | 冻结 profile 对象与 manifest 字符串分层;optional lock 的 None/省略归一和非法类型拒绝;初始化阶段锚点、原 operation deadline、取消/UID/坏状态/非法报告时间均 fail closed。数据响应、材料票据、解包/条目等有既有边界;没有以新的 now 重新延长业务期限。 |
+| 2 权限与认证 | PASS(本分工) | 控制 API 使用平台 HMAC/nonce;材料使用同 UID 的 Ed25519 委托,不给源码/安装脚本控制身份;数据 RPC 保留 owner 数据授权和动作/参数/实例票据;路由与访问授权分别查询 backend,不以 manager 存活或任意私网 IP 代替用户鉴权。已存在普通 owner/非 owner/跨租户真实拒绝记录。backend 全部业务权限实现另由后端分工审查。 |
+| 3 并发安全 | PASS(本分工) | controller 锁、epoch/fact/资源 SQL 围栏;唯一 create intent→begin→receipt/fence;结果先持久化再清理;停止按同 UID 证据而不是租约/404;未知创建、未知 writer、未知响应均不变成停止。检查 SIGKILL 后租约交接、异步 PVC 删除、prepared_delivery/维护结果持久化、nonce 请求去重和 cancel 后新数据动作拒绝。 |
+| 4 信息泄漏 | PASS(本分工) | 工作负载签名/材料票据不交给 app;可信 transfer 与无凭据 install 分开;业务仅挂本 app 数据子目录;admission 拒绝日志使用固定安全枚举;材料/数据网络异常不原样输出响应正文。HTTP/WS 统一剥离平台身份头和平台会话 cookie,再注入受控身份及 OBO。未读取实际配置、密钥或 token。 |
+| 5 测试覆盖 | PASS(代码审查范围) | 已有真实 executor/operation、实际 backend schema/ASGI、材料向量与数据拒绝配对,避免仅宽松桩隐藏 wire 合同。已复用晚期红绿、189业务/迁移/删除/取消/在途重启证据;没有把本地 mock 或日志记录当成本轮新执行。T067/T070/T071 等未完成项仍独立保留。 |
+| 6 代码风格 | PASS(本分工) | executor/材料/数据/准入模块职责明确;manager 不直接读平台 DB/MinIO;无为绕过失败新增泛化 fallback。晚期4行 controller 门、purge 等待只作用 K8s,Docker 保持既有路径。复用已有 Ruff/format/逐路径架构门记录,本轮未重新运行全仓门。 |
+| 7 文档同步 | NEEDS_FIX(D1) | design 的决策、保护报告协议、材料/API、UID/创建围栏及恢复/销毁细化已有正文和修订;但现状与 §5 索引有上面的确定遗漏。其余未验能力如 NodeLost、跨节点、卸载、ARM、公开发行仍有边界记录,不应删掉。 |
+
+## 3. 关键调用链核对
+
+### 3.1 准备、取消和重启
+
+`OperationExecutor.execute → controller 前置准入 → platform.claim → _prepared_delivery → KubernetesExecutor.prepare/_phase`。
+
+- 已有 `prepare_progress`/`prepared_delivery` 通过事实持久化,不能在 latest-detail 投影里被后续事实覆盖;失去提交响应只接受 fresh 重读得到同一结果。
+- 同操作重启复用确定 ref、记录的模板 deadline、已确认阶段结果及 scratch UID。未知 install 不盲目重跑。
+- 每阶段进入、缓存返回、资源创建前、Running 轮询与结果提交前核取消/代次/归属/原截止。`PlatformError` 不被转换成成功;controller 初始拒绝不进入应用失败清理,真正业务异常仍进入原失败路径。
+- 已有 operation terminal/pending 清理不覆盖初始 reason/stage/tail,不将旧失败复活为运行中。
+
+### 3.2 停止、资源销毁和 SQLite 单写
+
+`stop_instances → request_stop → fresh 同 UID / late node bind → classify_stop → backend stopped ACK → finalizer removal → fresh absence → cleanup fact`。
+
+- `never_scheduled` 与可信 `kubelet_terminal` 是不同证据;NodeLost、Unknown、运行态、替换 UID、失信历史 flag、单纯 API404 继续 held。
+- 控制租约过期不等于应用进程停止。controller 更新不修改 creator UID、历史停止/保护事实。
+- 过期 stop 先记录 timeout/pending 保留 active fence,然后仅沿后端既有 terminal-own cleanup 权限收尾;空 stop 资源集不证明全 app writer 已停。
+- K8s destroy 在 purge 前先持久化合法 `maintenance` 阶段;purge 结果 ACK 后才允许 PVC 清理。异步删除仅对精确 `purge_volume_cleanup_pending` 等待;原 deadline/取消/围栏持续检查,不重复执行已持久的 purge helper。
+- 生产 PVC 不随普通准备、预览或停用清理。在线数据 helper 在同 Pod;离线会话先证明旧 writer 关闭,再使用固定位置和真实预占。
+
+### 3.3 材料、数据和代理
+
+- 材料任务/部署初始化身份区分;动作、工件、偏移、hash、大小、请求体、UID、票据窗口共同签名。HTTPS/CA 验证、禁环境代理与有界续期保持;app 无平台存储凭据。
+- 数据票据绑定同 app/operation/ref/PodUID/action/参数摘要。helper 使用持久 started/result + flock;已开始结果未知不重放写。取消后的新签票和新 RPC 动作拒绝,不能把已开始动作当已回滚。
+- HTTP/WS 共用 backend verdict 和身份头构造;路由 cache 与权限 cache 分开,v2 route 来自平台投影,manager 失联不直接中断已运行应用访问。WS 有期限、主动关闭与周期复核。
+- egress 平台投影最大5秒;建立隧道后仍周期复核,过期/撤销关闭。DNS 检查后拨已验证地址,不将任意域名解析到内网作为授权。
+- helper/assembler/运行包并不以 socket/任意 kubectl/宿主命令执行用户源码;受信 transfer 与无凭据脚本执行分开,模板严格校验额外字段和安全上下文。
+
+## 4. 已读文件范围
+
+以下为本 runtime 分工的实质阅读范围,不表示全仓每个变更行均独立审过。
+
+- `src/runtime-manager/runtime_manager/`:`operations.py`、`executors/kubernetes.py`、`executors/protocol.py`、`k8s_lifecycle.py`、`k8s_admission.py`、`k8s_capabilities.py`、`k8s_templates.py`、`k8s_data_executor.py`、`k8s_data_helper.py`、`k8s_appdb.py`、`data_grant.py`、`k8s_helper.py`、`k8s_prepare.py`、`material_client.py`、`material_protocol.py`、`platform_client.py`、`platform_egress.py`、`egress_proxy.py`、`main.py`、`api/operations.py`、`api/platform_data.py`、`api/platform_observe.py`。
+- `src/app-proxy/app_proxy/`:`clients.py`、`authz.py`、`headers.py`、`routing.py`、`proxy.py`、`websocket.py`;以及受其调用的 v2 平台协议和连接关闭职责。
+- `src/app-runtime/common/prepare.py`、`src/runtime-manager/Dockerfile` 的可信工具/运行合同。
+- backend 只为核调用合同读取:`runtime_kubernetes_service.py`、`runtime_kubernetes_repository.py`、`runtime_operation_service.py`、材料/数据 grant/session 服务及恢复提交/route 投影相关路径;不冒充对整个 backend 的重复 L2。
+- 相关测试重点:`test_kubernetes_executor.py`、`test_operations_v2.py`、`test_k8s_admission.py`、`test_k8s_lifecycle.py`、`test_k8s_data_executor.py`、`test_k8s_data_helper.py`、`test_k8s_appdb.py`、`test_k8s_helper.py`、`test_k8s_prepare_cancellation.py`、`test_k8s_controller_restart.py`、`test_k8s_destroy_fact_contract.py`、`test_k8s_purge_cleanup.py`、`test_k8s_protection_report.py`、`test_k8s_protection_heartbeat.py`、`test_k8s_capabilities.py` 及代理路由/HTTP/WS/egress 原有配对。
+- 文档:F059 `spec.md`、`design.md`、`tasks.md`、`task-reviews.md`、`validation.json`、`e2e-evidence/{installation,platform,fault-upgrade}.{json,md}` 的对应范围与最新命名快照。
+
+## 5. 复用的测试及189证据(不是本轮重新执行)
+
+| 证据 | 实际范围及限制 |
+| --- | --- |
+| T268/T269 runtime 完整回归记录 | `1787 passed / 13 skipped`,source `6c9694f0f`;并非最新0f2之后再次全量。首次测试夹具过短导致的失败保留,后来仅修夹具,不改产品期限。 |
+| T270/T271 backend 回归 | `1174 passed / 19 skipped`,app_runtime 范围;不是全 backend/DM8 真机通过。 |
+| T272/T273 精确配对 | 旧实现7例4红/3绿;主树首次直接邻接123绿/1旧断言失败,随后最窄13绿;没有倒填第一次全绿,也没有额外全量。 |
+| 安装和控制 canary | `installation.json#registry_upstream_dedc_real_validation`:真实16项;同业务Service selector的5个canary连续观察未Ready/Serving,第6个不在该连续selector范围。TCP、外部crane和业务assembler证据分层。 |
+| 五种交付模式、r11 | `platform.json` 各明确命名晚期记录:Python/Node/static、source-image、完整镜像;r11单v1审批预览、HTTP/WS、SDK/OBO、附件/拒绝配对、停用SQLite查改CSV和同v1恢复;不把历史running摘要当最终结论。 |
+| manager停机 | `fault-upgrade.json#manager_outage_r11_r3`:15.931s、超缓存5轮、原WS/CONNECT及新连接/拒绝,恢复新PodUID;不证明任意长HTTP流或在途工作恢复。 |
+| 迁移/恢复、销毁 | `fault-upgrade.json#actual_migration_restore_189_gen4`、`platform.json#r31_safe_delete`:新显式恢复和健康完整镜像唯一DELETE;旧failed恢复/删除保留,不重放unknown,不把空snapshot样本冒充非空删除。 |
+| owner取消 | `fault-upgrade.json#actual_cancel_189_r31`:Node240钩子在54.85s真实同UID/CID取消,cancelled/cleanupcomplete、6资源自然缺席、容量释放,owner/非owner/foreign拒绝配对。 |
+| 最新在途SIGKILL恢复 | `fault-upgrade.json#inflight_manager_sigkill_189_r32_lease_handoff`,source `0f2db1f9a29213ca94d4596e64b00ad8fcbac528`:同manager Pod重启、epoch30→31;同install UID/CID自然240.006s结束;原deadline `2026-10-23T07:22:07`/阶段结果不变;prepare8资源正常停止后缺席、预约释放,后续probe完成。不是整个T070、审批上线或probe物理缺席证明。 |
+
+最新SIGKILL前的R31失败记录继续为 NOT_PASSED,修复后新attempt的R32成功不覆盖历史;旧r4未知创建及旧false保护资源仍held,不将其自动恢复为true或释放。
+
+## 6. 两个旧私有分支的内容整合核对
+
+### 6.1 `codex/f059-protection-runtime`
+
+- 私有tip:`0338d2f65ff2b16af047648cbf2e90657bc9b8cb`。
+- 集成对应:`2992a28a8`。
+- 实测 `git diff --exit-code 0338d2f65 2992a28a8 --` 以下六路径:**无差异**:
+  - `runtime_manager/k8s_capabilities.py`
+  - `runtime_manager/executors/kubernetes.py`
+  - `runtime_manager/operations.py`
+  - `tests/test_k8s_protection_report.py`
+  - `tests/test_k8s_protection_heartbeat.py`
+  - `tests/test_k8s_capabilities.py`
+- 私有tip对当前cda的这些文件只有 `operations.py` 后续 `+23/-1`,其余仍相同。变化为合法维护阶段、purge异步等待和controller前置,不是丢失保护修复。
+
+### 6.2 `codex/f059-destroy-maintenance-phase`
+
+- 私有tip:`dd549c009fbdd4cdac5d9d97ee7d2a9c67fa75f4`。
+- 集成对应:`1532e115e`。
+- 实测两个提交的 `runtime_manager/operations.py` **完全无差异**。
+- 私有tip对当前cda只多出后续 `_wait_purge_cleanup` 和执行前controller门,`maintenance` ACK/fresh checkpoint原修复保留。
+
+核对依据包括文件 diff 和主线 `git log -- .../operations.py`:`2992a28a8 → 1532e115e → 6c9694f0f → 0f2db1f9a`。**没有只凭 git cherry 判断,也没有发现需要把旧分支内容再次合回的产品差异。**
+
+## 7. 不得由本报告关闭的门
+
+- 本报告不重跑任何全量、新远端、ARM、Docker109、客户离线安装、正式CI/DockerHub/tag发行。
+- 未完成的T067/T070/T071和全AC子句继续记录:例如独立卸载、完整故障矩阵、NodeLost/跨节点物理fencing及其他未取得真实证据的性能/权限/材料续期子句。单节点189不能证明跨节点恢复。
+- DM8仍按用户已确认口径仅静态模型/约束核对。
+- 主 agent 集成后的最终SHA、跨分工L2汇总、设计D1修正和必要合入验证仍归主流程;不在此勾选T072,也不声明整个Feature已交付通过。
+
+
+## 8. D1 定向复审关闭(2026-10-23)
+
+- 复审位置:`/Users/zhangguoqing/works/bisheng/.worktrees/vibe-app-integration` 当前工作副本,未提交设计文档;没有修改该仓库。
+- 已读设计文件 SHA256:`9f41bbc3acc64525eb3aedf4c97f4f846a201b520cefd4278844f762b4638dce`。
+- §4.1(当前第120–124行)已改为当前 R32 / Helm32,记录 source `0f2db1f9a29213ca94d4596e64b00ad8fcbac528`、原deadline/同安装UID-CID/240秒自然完成;明确原267/271及追加T274后的268/272,列出T067/T070/T071/T072仍为交付门。R31删除、R11数据、M2恢复已收拢,同时保留未验故障/卸载、ARM/新增109/发行、旧false/unknown围栏边界。过期R29/T069剩删除已不再充作当前快照。
+- §5 已知坑(当前第421行)已加入“旧holder租约尚未到期的正常409不得进入业务失败”,准确记录误清理在途安装后果、`operations.py` 的claim/执行/业务失败处理前controller门,以及T272/T273/R32同UID-CID证据。
+- **D1 两项均已修复,CLOSED。维度7本次定向复审 PASS。** 原报告中冻结cda的D1发现保留为历史;本节替代其未关闭结论。
+- **最终 runtime 分工七维结论:PASS,无未关闭 HIGH / MEDIUM。** 本结论仅覆盖原报告范围及上述两项文档修正,不包含追加T274的独立代码审查,不代表整个feature、T072、未验AC或正式发行通过。
+- 本次只读定向复审,未运行测试、未远端操作、未扩大产品审查范围。
diff --git a/features/v3.0.0/059-k8s-runtime-backend/integration-review/validation.json b/features/v3.0.0/059-k8s-runtime-backend/integration-review/validation.json
new file mode 100644
index 0000000000..6d688bbbbe
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/integration-review/validation.json
@@ -0,0 +1,68 @@
+{
+  "date": "2026-10-23",
+  "scope": "development integration only",
+  "source_f059": "cda8dccf26298af91a1df277dcd1a49787b6b444",
+  "target_base": "4aa9ce69b2830b81162fe631b9c801057c40ee8b",
+  "tests": {
+    "ci_contract": {
+      "red_failed": 1,
+      "green_passed": 1
+    },
+    "model_contracts": {
+      "passed": 17,
+      "scope": "SQLite and static dialect contracts; no DM8 connection",
+      "config": "existing local test fixture; initial missing config collection attempts preserved in work log"
+    }
+  },
+  "checks": {
+    "059-k8s-runtime-backend": {
+      "complete": 268,
+      "total": 272,
+      "pending": [
+        "T067",
+        "T070",
+        "T071",
+        "T072"
+      ]
+    },
+    "086-vibe-runtime-delivery": {
+      "complete": 92,
+      "total": 94,
+      "pending": [
+        "T055",
+        "T056"
+      ]
+    },
+    "alembic_static": {
+      "revisions": 115,
+      "head": "f059_resource_phases",
+      "missing_parents": 0,
+      "database_connected": false
+    },
+    "changed_files_static": {
+      "paths": 96,
+      "json_parsed": 39,
+      "conflict_markers": 0
+    },
+    "new_review_links": {
+      "files": 4,
+      "links": 16,
+      "missing": 0
+    },
+    "workflow_yaml": {
+      "parsed": true,
+      "concurrency_matches_release": true,
+      "ci_executed": false
+    }
+  },
+  "review_report_sha256": {
+    "backend.md": "8f3213c644f91dedc5980901a04bb292b07f38cdda99f2d2b17f33390faf7a39",
+    "runtime.md": "42b0e71bf3c9258ae792cfd86fa7c493eeacdb31d10c09b892ab5284dcf3424c",
+    "delivery.md": "a9a79899258117bbf7917264a8f2423d0b3209d6190f6b283e0cc56e56fece28"
+  },
+  "full_suite_executed": false,
+  "remote_acceptance_executed_this_round": false,
+  "full_delivery_accepted": false,
+  "public_images_or_tag_released": false,
+  "recovered_evidence_sensitive_pattern_findings": 0
+}
diff --git a/features/v3.0.0/059-k8s-runtime-backend/model-release-validation.json b/features/v3.0.0/059-k8s-runtime-backend/model-release-validation.json
new file mode 100644
index 0000000000..857da6db30
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/model-release-validation.json
@@ -0,0 +1,961 @@
+{
+  "schema": "f059-model-release-validation/v1",
+  "state": "partial-native-dual-architecture-passed-formal-ci-pending",
+  "source_commit": "0e493c07a8c25b85b762b5c6f95f65d2cb47d6f9",
+  "models": {
+    "method": "static model/tenant hooks/dual-DB dialect tests; no real DM8 connection",
+    "tenant_context_cases_passed": 17,
+    "tenant_and_dm_dialect_static_cases_passed": 64,
+    "model_hook_isolation_with_approval_passed": 44,
+    "alembic_heads": [
+      "f059_resource_phases"
+    ],
+    "tenant_defaults": "None, context fills; DB non-null and index retained; explicit tenant preserved; missing required context rejected",
+    "logs": [
+      "/private/tmp/f059-model-isolation.log",
+      "/private/tmp/f059-model-static-discovery.log"
+    ]
+  },
+  "native_arm64": {
+    "schema": "f059-native-image-verification/v1",
+    "finished_at": "2026-10-22T14:14:12.597970+00:00",
+    "source_revision": "0e493c07a8c25b85b762b5c6f95f65d2cb47d6f9",
+    "platform": "linux/arm64",
+    "native_host": "arm64",
+    "native_engine": "linux/aarch64",
+    "build_exit": 0,
+    "isolated_smoke_exit": 0,
+    "bundle_smoke_exit": 0,
+    "full_smoke": true,
+    "images": {
+      "app-node": {
+        "image": null,
+        "image_id": "sha256:207635908c895ce0cfe2b201d86175d6d7955d42ace79ecc1c0a2555f8569865",
+        "native_smoke": true,
+        "platform": "linux/arm64",
+        "tag": "e2e-f059/bisheng-app-node:e2e-f059-native-20261022-0e493c07a-arm64",
+        "tools": {}
+      },
+      "app-node-preparer": {
+        "image": null,
+        "image_id": "sha256:b6c8306c12169950b3c901518d14e24b03b42b8290798f8c56e6112f0844cbf4",
+        "native_smoke": true,
+        "platform": "linux/arm64",
+        "tag": "e2e-f059/bisheng-app-node-preparer:e2e-f059-native-20261022-0e493c07a-arm64",
+        "tools": {}
+      },
+      "app-proxy": {
+        "image": null,
+        "image_id": "sha256:d9ce7740c6c2495153de0deb2409258e5e5e465f0105e16ebbb9d67f8509c119",
+        "native_smoke": true,
+        "platform": "linux/arm64",
+        "tag": "e2e-f059/bisheng-app-proxy:e2e-f059-native-20261022-0e493c07a-arm64",
+        "tools": {}
+      },
+      "app-python": {
+        "image": null,
+        "image_id": "sha256:1392fe7b352881893c382f2fd3338797de7cbd2a077feb60b0ea4b3aab2fc4cf",
+        "native_smoke": true,
+        "platform": "linux/arm64",
+        "tag": "e2e-f059/bisheng-app-python:e2e-f059-native-20261022-0e493c07a-arm64",
+        "tools": {}
+      },
+      "app-python-preparer": {
+        "image": null,
+        "image_id": "sha256:3180d7c32e4d56104054a71f0f8a64015c12e03c6a42b2956fb203b5b78aec7f",
+        "native_smoke": true,
+        "platform": "linux/arm64",
+        "tag": "e2e-f059/bisheng-app-python-preparer:e2e-f059-native-20261022-0e493c07a-arm64",
+        "tools": {}
+      },
+      "app-static": {
+        "image": null,
+        "image_id": "sha256:4a144f3784b989b886929123da9a457b1127779cba9e18a04a8889367ec2fdff",
+        "native_smoke": true,
+        "platform": "linux/arm64",
+        "tag": "e2e-f059/bisheng-app-static:e2e-f059-native-20261022-0e493c07a-arm64",
+        "tools": {}
+      },
+      "runtime-assembler": {
+        "image": null,
+        "image_id": "sha256:7a04e0c7303198a1a393eaccb51603ba84f51021e22669c7821b50697895b4ae",
+        "native_smoke": true,
+        "platform": "linux/arm64",
+        "tag": "e2e-f059/bisheng-runtime-assembler:e2e-f059-native-20261022-0e493c07a-arm64",
+        "tools": {
+          "crane": "v0.22.1",
+          "go-builder": "1.27.3",
+          "licenses": "/usr/share/licenses/crane",
+          "python-abi": "3.11",
+          "python-packages": "/usr/share/licenses/bisheng/python-packages.json",
+          "runtime-package": "3.0.0"
+        }
+      },
+      "runtime-helper": {
+        "image": null,
+        "image_id": "sha256:23337409a59b4d6d19f3b74fabdb9f31feb42532277872eb491fe9c37f4d6bcc",
+        "native_smoke": true,
+        "platform": "linux/arm64",
+        "tag": "e2e-f059/bisheng-runtime-helper:e2e-f059-native-20261022-0e493c07a-arm64",
+        "tools": {
+          "python-abi": "3.11",
+          "python-packages": "/usr/share/licenses/bisheng/python-packages.json",
+          "runtime-package": "3.0.0"
+        }
+      },
+      "runtime-manager": {
+        "image": null,
+        "image_id": "sha256:3bb2eb2268c33da48f1ff1b6912e2e0da2cf8c9bd962a530ba69c806be8d9a90",
+        "native_smoke": true,
+        "platform": "linux/arm64",
+        "tag": "e2e-f059/bisheng-runtime-manager:e2e-f059-native-20261022-0e493c07a-arm64",
+        "tools": {}
+      }
+    },
+    "bundle": {
+      "platform": "linux/arm64",
+      "checks": [
+        {
+          "language": "python3.11",
+          "offline": true,
+          "code_update": true,
+          "dependency_reuse": true,
+          "runtime_only_sentinel_persisted": true,
+          "unchanged_data_bind_and_identity": {
+            "source": "/private/tmp/e2e-f059-native-0e493c07a/apps/e2e-f086-052a47ac5778-python311/db",
+            "directory_device": 35,
+            "directory_inode": 41190,
+            "database_device": 35,
+            "database_inode": 41191
+          },
+          "verified_sqlite_backups": [
+            {
+              "artifact_id": "e707327c609ef747566a2a097245f468549658fe20cbb61fa145245315069ac5",
+              "downloaded_sqlite_verified": true,
+              "sentinel_sha256": "7c062af7ba9cdecfa092ceb2c5b36e3191bff95c85c2e389e51f807d650d75ae"
+            },
+            {
+              "artifact_id": "e707327c609ef747566a2a097245f468549658fe20cbb61fa145245315069ac5",
+              "downloaded_sqlite_verified": true,
+              "sentinel_sha256": "7c062af7ba9cdecfa092ceb2c5b36e3191bff95c85c2e389e51f807d650d75ae"
+            }
+          ]
+        },
+        {
+          "language": "node20",
+          "offline": true,
+          "code_update": true,
+          "dependency_reuse": true
+        },
+        {
+          "language": "static",
+          "offline": true,
+          "code_update": true,
+          "dependency_reuse": true
+        },
+        {
+          "language": "static",
+          "offline": true,
+          "install_hook": true,
+          "frontend_build": true,
+          "source_change_invalidates_dependency": true
+        },
+        {
+          "language": "python3.11",
+          "offline": true,
+          "local_native_wheel": true,
+          "source_change_invalidates_dependency": true
+        }
+      ]
+    },
+    "wal_regression": {
+      "suite": "test_data_executor_wal.py",
+      "passed": 10,
+      "skipped": 0,
+      "real_docker_cases": 3,
+      "image": {
+        "image": null,
+        "image_id": "sha256:3bb2eb2268c33da48f1ff1b6912e2e0da2cf8c9bd962a530ba69c806be8d9a90",
+        "native_smoke": true,
+        "platform": "linux/arm64",
+        "tag": "e2e-f059/bisheng-runtime-manager:e2e-f059-native-20261022-0e493c07a-arm64",
+        "tools": {}
+      },
+      "main_database_unchanged": true,
+      "layouts": [
+        "clean-wal-no-sidecars",
+        "committed-wal-missing-shm",
+        "committed-wal-with-shm"
+      ],
+      "offline_sql_readonly_and_concurrent_committed_consistency": true
+    },
+    "unrelated_containers_unchanged": true,
+    "before_after_non_suite_container_count": 2,
+    "unrelated_network_inventory_unchanged": true,
+    "toolchain": {
+      "python": "3.11.17",
+      "node": "v20.20.2",
+      "npm": "10.8.2",
+      "crane": "v0.22.1"
+    },
+    "preloaded_wheel_count": 23,
+    "license_file_count": 28,
+    "published": false,
+    "boundary": "Actual native local images, isolated resolver checks and complete Docker Engine integration using an in-memory artifact port; real generated data-helper payload tested in 3 WAL layouts. Not real backend/MinIO/approval/permissions E2E, K8s business, snapshot restore, cross-node recovery or public CI release.",
+    "build_recovery": {
+      "required": false,
+      "existing_verified_base_cache_reused": true
+    }
+  },
+  "artifact_sha256": {
+    "native-result.json": "82efd11130b331f1b79f0aec0ee8941982bd6fc4880105530cf9d481cb3ee956",
+    "offline-materials.json": "2e4123c8026d41b10295ec8162e32fa581228ed4fd20dc11a6d43f288d3af023",
+    "toolchain-materials.json": "121a627c5e1adfe9af7aef27d95bc726464120b3e2d7e72f279bdc8ac4355e4e",
+    "checksums.json": "f18dda1089ca87047961cf0f9e7841e6136e9bf4d6e7b95048e45ad082de9b0c"
+  },
+  "native_arm64_scope": "Real native nine images, isolated resolver checks, complete Engine bundle/upgrade; random SQLite nonce and two downloaded backups; platform/approval/MinIO excluded",
+  "native_amd64": {
+    "schema": "f059-native-image-verification/v1",
+    "finished_at": "2026-10-22T14:14:34.895687+00:00",
+    "source_revision": "0e493c07a8c25b85b762b5c6f95f65d2cb47d6f9",
+    "platform": "linux/amd64",
+    "engine": {
+      "OSType": "linux",
+      "Architecture": "x86_64",
+      "ServerVersion": "24.0.9",
+      "NCPU": 8,
+      "MemTotal": 67558805504
+    },
+    "tools_image_id": "sha256:e0774ebe198a660f05b03dbafdbe7c806e1dbee8ac7dbff38f8a1b9865a0442a",
+    "build_exit": 0,
+    "isolated_smoke_exit": 0,
+    "bundle_smoke_exit": 0,
+    "images": {
+      "app-node": {
+        "image": null,
+        "image_id": "sha256:de7f22f64af2059c5acb4b465ab5d7608929007bd1949be05ed91ea5e5918df9",
+        "native_smoke": true,
+        "platform": "linux/amd64",
+        "tag": "e2e-f059/bisheng-app-node:e2e-f059-native-20261022-0e493c07a-amd64",
+        "tools": {}
+      },
+      "app-node-preparer": {
+        "image": null,
+        "image_id": "sha256:a35a4a88f2848be7f3eb603f938d0de6de39802d06acc53d6e0739885b033aee",
+        "native_smoke": true,
+        "platform": "linux/amd64",
+        "tag": "e2e-f059/bisheng-app-node-preparer:e2e-f059-native-20261022-0e493c07a-amd64",
+        "tools": {}
+      },
+      "app-proxy": {
+        "image": null,
+        "image_id": "sha256:a6ebc02e783ddcd5622646352fcbfc1685135d91e4e3e3ae8eab4a90ecffc7d9",
+        "native_smoke": true,
+        "platform": "linux/amd64",
+        "tag": "e2e-f059/bisheng-app-proxy:e2e-f059-native-20261022-0e493c07a-amd64",
+        "tools": {}
+      },
+      "app-python": {
+        "image": null,
+        "image_id": "sha256:81bfb337c68c318aa550fcd3a60b62335845e1881200dc35f4095db9a380f3b6",
+        "native_smoke": true,
+        "platform": "linux/amd64",
+        "tag": "e2e-f059/bisheng-app-python:e2e-f059-native-20261022-0e493c07a-amd64",
+        "tools": {}
+      },
+      "app-python-preparer": {
+        "image": null,
+        "image_id": "sha256:55cdac5ab14824096f9dbc595953fe2e9c86b0d817df51f521084cea4f375468",
+        "native_smoke": true,
+        "platform": "linux/amd64",
+        "tag": "e2e-f059/bisheng-app-python-preparer:e2e-f059-native-20261022-0e493c07a-amd64",
+        "tools": {}
+      },
+      "app-static": {
+        "image": null,
+        "image_id": "sha256:8b121fbd253e93af9913df5cac15f286fa4933bc09c850494ffa86c02ad24906",
+        "native_smoke": true,
+        "platform": "linux/amd64",
+        "tag": "e2e-f059/bisheng-app-static:e2e-f059-native-20261022-0e493c07a-amd64",
+        "tools": {}
+      },
+      "runtime-assembler": {
+        "image": null,
+        "image_id": "sha256:70db78b647519f63735681355fe7d65de20bf338357e33f2a7667ca926a50ce5",
+        "native_smoke": true,
+        "platform": "linux/amd64",
+        "tag": "e2e-f059/bisheng-runtime-assembler:e2e-f059-native-20261022-0e493c07a-amd64",
+        "tools": {
+          "crane": "v0.22.1",
+          "go-builder": "1.27.3",
+          "licenses": "/usr/share/licenses/crane",
+          "python-abi": "3.11",
+          "python-packages": "/usr/share/licenses/bisheng/python-packages.json",
+          "runtime-package": "3.0.0"
+        }
+      },
+      "runtime-helper": {
+        "image": null,
+        "image_id": "sha256:9ca04c1b7213dde0c8177976a708d5c8d8bed512a28f74092b7ba93d7aa77599",
+        "native_smoke": true,
+        "platform": "linux/amd64",
+        "tag": "e2e-f059/bisheng-runtime-helper:e2e-f059-native-20261022-0e493c07a-amd64",
+        "tools": {
+          "python-abi": "3.11",
+          "python-packages": "/usr/share/licenses/bisheng/python-packages.json",
+          "runtime-package": "3.0.0"
+        }
+      },
+      "runtime-manager": {
+        "image": null,
+        "image_id": "sha256:28f34e7a857af6d89bd34cf1cf4794c04fb4b41ee189e9b3c0ad19d424d85e6f",
+        "native_smoke": true,
+        "platform": "linux/amd64",
+        "tag": "e2e-f059/bisheng-runtime-manager:e2e-f059-native-20261022-0e493c07a-amd64",
+        "tools": {}
+      }
+    },
+    "bundle": {
+      "platform": "linux/amd64",
+      "checks": [
+        {
+          "language": "python3.11",
+          "offline": true,
+          "code_update": true,
+          "dependency_reuse": true,
+          "runtime_only_sentinel_persisted": true,
+          "unchanged_data_bind_and_identity": {
+            "source": "/opt/e2e-f059/native-0e493c07a/smoke-data/apps/e2e-f086-0fff0f41871e-python311/db",
+            "directory_device": 2050,
+            "directory_inode": 10263450,
+            "database_device": 2050,
+            "database_inode": 10263456
+          },
+          "verified_sqlite_backups": [
+            {
+              "artifact_id": "c8f4d0375001bc7c6b6e704da27ecb3b5ca3b3f1a22c6fae60d727b4fb31f647",
+              "downloaded_sqlite_verified": true,
+              "sentinel_sha256": "91411fd89d3ab92a42d1121f583d2e46a924a7a43444c207a15b400a3788240c"
+            },
+            {
+              "artifact_id": "c8f4d0375001bc7c6b6e704da27ecb3b5ca3b3f1a22c6fae60d727b4fb31f647",
+              "downloaded_sqlite_verified": true,
+              "sentinel_sha256": "91411fd89d3ab92a42d1121f583d2e46a924a7a43444c207a15b400a3788240c"
+            }
+          ]
+        },
+        {
+          "language": "node20",
+          "offline": true,
+          "code_update": true,
+          "dependency_reuse": true
+        },
+        {
+          "language": "static",
+          "offline": true,
+          "code_update": true,
+          "dependency_reuse": true
+        },
+        {
+          "language": "static",
+          "offline": true,
+          "install_hook": true,
+          "frontend_build": true,
+          "source_change_invalidates_dependency": true
+        },
+        {
+          "language": "python3.11",
+          "offline": true,
+          "local_native_wheel": true,
+          "source_change_invalidates_dependency": true
+        }
+      ]
+    },
+    "non_test_container_count": 57,
+    "unrelated_containers_unchanged": true,
+    "network_inventory_unchanged": true,
+    "published": false,
+    "boundary": "Real native local images, isolated resolver checks and Docker Engine integration only; no registry manifest digests, CI public release, platform authorization/approval E2E, database snapshot restore or cross-node recovery claimed."
+  },
+  "formal_ci": {
+    "state": "not executed; no new public push/tag/DockerHub release authorized",
+    "manifest_digest_claimed": false,
+    "local_config_ids_are_not_manifest_digests": true
+  },
+  "limitations": [
+    "DM8 real validation explicitly excluded by user",
+    "single-node189 does not prove cross-node recovery",
+    "T071 remains incomplete: local dual-architecture proof cannot substitute unauthorized formal public CI release"
+  ],
+  "native_amd64_artifact_sha256": {
+    "native-result.json": "367c88951bf7d91917b1bc5191c27d351318634aa606090ab6c8592a3bf3eb5c",
+    "checksums.json": "ad366acfaa4743e2bb717e7d75a9b244add5fc97e83bbb2e7709c44eab5794b2",
+    "runtime-images-amd64.json": "dfa83f88f5a54b800d59234e1438f7d306fe17d8a316d18aecef59ebabb743aa"
+  },
+  "native_amd64_scope": "Real native local images, isolated resolver checks and Docker Engine integration only; no registry manifest digests, CI public release, platform authorization/approval E2E, database snapshot restore or cross-node recovery claimed.",
+  "previous_native_candidate": {
+    "source_commit": "7e720073b8a57492b7befbbb7155ffe30331af01",
+    "state": "both native architectures passed; superseded by diagnostics/WAL fixes, preserved in Git history"
+  },
+  "native_arm64_wal": {
+    "passed": 10,
+    "skipped": 0,
+    "real_docker_layouts": 3,
+    "source_commit": "0e493c07a8c25b85b762b5c6f95f65d2cb47d6f9",
+    "evidence_root": "/private/tmp/f059-arm64-20261022-0e493c07a"
+  },
+  "followup_candidate": {
+    "source_commit": "0e493c07a8c25b85b762b5c6f95f65d2cb47d6f9",
+    "reason": "K8s parsed profile and precise unassigned observation contract fixes",
+    "native_validation": "both_architectures_passed",
+    "historical_native_results_source": "54b3ea2c15836529cc24667d9a58be49e35bf710",
+    "formal_public_ci_release": "not_authorized"
+  },
+  "historical_native_candidates": [
+    {
+      "source_commit": "54b3ea2c15836529cc24667d9a58be49e35bf710",
+      "state": "both native passed; original full records preserved in Git cbf309ab0",
+      "previous_native_candidate": {
+        "source_commit": "7e720073b8a57492b7befbbb7155ffe30331af01",
+        "state": "both native architectures passed; superseded by diagnostics/WAL fixes, preserved in Git history"
+      }
+    }
+  ],
+  "native_arm64_artifact_sha256": {
+    "native-result.json": "22f29b1064098e92000296eb2f1d2b35f3972b3732aef680a73b89ba80f2579e",
+    "checksums.json": "4d5fdf5d8a67cf60e066a8acb8f13a3184cabf46e80027a1f423a735cd67c3aa",
+    "runtime-images-arm64.json": "54950a5ce2c3cb4c5b6b67d5f1af8b48889e0b8c50f8133ca059bf79c732ea2a"
+  },
+  "private_189_manager": {
+    "source_commit": "0e493c07a8c25b85b762b5c6f95f65d2cb47d6f9",
+    "image": "192.168.106.189:5443/f059/runtime-manager@sha256:b17045eb4225a5ea30a177f6b99c25ba15c6ecf25b8b3fc7fabf56687cd510ce",
+    "helm_revision": 11,
+    "report_preserved": true,
+    "no_public_release": true,
+    "live_business": "blocked by expired controller lease; T198/T199 pending"
+  },
+  "interim_amd64_709_d86": {
+    "full_native": {
+      "schema": "f059-native-image-verification/v1",
+      "finished_at": "2026-10-22T15:42:56.847233+00:00",
+      "source_revision": "709821ecbe5c653aad3f90358173e0d110bee7e3",
+      "platform": "linux/amd64",
+      "engine": {
+        "OSType": "linux",
+        "Architecture": "x86_64",
+        "ServerVersion": "24.0.9",
+        "NCPU": 8,
+        "MemTotal": 67558805504
+      },
+      "tools_image_id": "sha256:e0774ebe198a660f05b03dbafdbe7c806e1dbee8ac7dbff38f8a1b9865a0442a",
+      "build_exit": 0,
+      "isolated_smoke_exit": 0,
+      "bundle_smoke_exit": 0,
+      "images": {
+        "app-node": {
+          "image": null,
+          "image_id": "sha256:a6961a25eb82d295d39905fa8856d5f5b0ffb0642c3e057fed87a1e5c3d61f66",
+          "native_smoke": true,
+          "platform": "linux/amd64",
+          "tag": "e2e-f059/bisheng-app-node:e2e-f059-native-20261022-709821ecb-amd64",
+          "tools": {}
+        },
+        "app-node-preparer": {
+          "image": null,
+          "image_id": "sha256:acb84d03f9496b98fe666a1cd326694d2aded1f40e82b026e190c152683e8db3",
+          "native_smoke": true,
+          "platform": "linux/amd64",
+          "tag": "e2e-f059/bisheng-app-node-preparer:e2e-f059-native-20261022-709821ecb-amd64",
+          "tools": {}
+        },
+        "app-proxy": {
+          "image": null,
+          "image_id": "sha256:f2a6835d076fbdaf10a06d64bbb5eb431bffa614564c34aebe074889b22c3e1a",
+          "native_smoke": true,
+          "platform": "linux/amd64",
+          "tag": "e2e-f059/bisheng-app-proxy:e2e-f059-native-20261022-709821ecb-amd64",
+          "tools": {}
+        },
+        "app-python": {
+          "image": null,
+          "image_id": "sha256:63410cb4c796b935fb8fbc3d8f9853fd477e800dcbe910e77eb3aa7e5dca9dec",
+          "native_smoke": true,
+          "platform": "linux/amd64",
+          "tag": "e2e-f059/bisheng-app-python:e2e-f059-native-20261022-709821ecb-amd64",
+          "tools": {}
+        },
+        "app-python-preparer": {
+          "image": null,
+          "image_id": "sha256:a3111cc39b6be9e3736511fe4e9a0401528d712947791c63eb294275e5d0eeab",
+          "native_smoke": true,
+          "platform": "linux/amd64",
+          "tag": "e2e-f059/bisheng-app-python-preparer:e2e-f059-native-20261022-709821ecb-amd64",
+          "tools": {}
+        },
+        "app-static": {
+          "image": null,
+          "image_id": "sha256:04023626cb44e1d27970df8694fdde6aa1774fe4999a5a3e72ccf5430bf8c1da",
+          "native_smoke": true,
+          "platform": "linux/amd64",
+          "tag": "e2e-f059/bisheng-app-static:e2e-f059-native-20261022-709821ecb-amd64",
+          "tools": {}
+        },
+        "runtime-assembler": {
+          "image": null,
+          "image_id": "sha256:47989dfc2fec98b142e1440773bd6ce31fff5cd76832a334f3808d321492d7bd",
+          "native_smoke": true,
+          "platform": "linux/amd64",
+          "tag": "e2e-f059/bisheng-runtime-assembler:e2e-f059-native-20261022-709821ecb-amd64",
+          "tools": {
+            "crane": "v0.22.1",
+            "go-builder": "1.27.3",
+            "licenses": "/usr/share/licenses/crane",
+            "python-abi": "3.11",
+            "python-packages": "/usr/share/licenses/bisheng/python-packages.json",
+            "runtime-package": "3.0.0"
+          }
+        },
+        "runtime-helper": {
+          "image": null,
+          "image_id": "sha256:eb81d65c6de6681f9a2b1f350342b40c3505543d5395838be46f9d46594d8f73",
+          "native_smoke": true,
+          "platform": "linux/amd64",
+          "tag": "e2e-f059/bisheng-runtime-helper:e2e-f059-native-20261022-709821ecb-amd64",
+          "tools": {
+            "python-abi": "3.11",
+            "python-packages": "/usr/share/licenses/bisheng/python-packages.json",
+            "runtime-package": "3.0.0"
+          }
+        },
+        "runtime-manager": {
+          "image": null,
+          "image_id": "sha256:73853b58bf9fcd33b6945b41fd6c612646d39ada57cabfacb6ce751f14ee5184",
+          "native_smoke": true,
+          "platform": "linux/amd64",
+          "tag": "e2e-f059/bisheng-runtime-manager:e2e-f059-native-20261022-709821ecb-amd64",
+          "tools": {}
+        }
+      },
+      "bundle": {
+        "platform": "linux/amd64",
+        "checks": [
+          {
+            "language": "python3.11",
+            "offline": true,
+            "code_update": true,
+            "dependency_reuse": true,
+            "runtime_only_sentinel_persisted": true,
+            "unchanged_data_bind_and_identity": {
+              "source": "/opt/e2e-f059/native-709821ecb/smoke-data/apps/e2e-f086-ff9f430b99ad-python311/db",
+              "directory_device": 2050,
+              "directory_inode": 279639577,
+              "database_device": 2050,
+              "database_inode": 279639583
+            },
+            "verified_sqlite_backups": [
+              {
+                "artifact_id": "064406a63e0970966fe5b3d812848eee3e7e599182308989a95ae3c0981e69ab",
+                "downloaded_sqlite_verified": true,
+                "sentinel_sha256": "8251e4768205f42357cea76d0d7332f1a83ff57cdacd3ddd0eedd68219f44edb"
+              },
+              {
+                "artifact_id": "064406a63e0970966fe5b3d812848eee3e7e599182308989a95ae3c0981e69ab",
+                "downloaded_sqlite_verified": true,
+                "sentinel_sha256": "8251e4768205f42357cea76d0d7332f1a83ff57cdacd3ddd0eedd68219f44edb"
+              }
+            ]
+          },
+          {
+            "language": "node20",
+            "offline": true,
+            "code_update": true,
+            "dependency_reuse": true
+          },
+          {
+            "language": "static",
+            "offline": true,
+            "code_update": true,
+            "dependency_reuse": true
+          },
+          {
+            "language": "static",
+            "offline": true,
+            "install_hook": true,
+            "frontend_build": true,
+            "source_change_invalidates_dependency": true
+          },
+          {
+            "language": "python3.11",
+            "offline": true,
+            "local_native_wheel": true,
+            "source_change_invalidates_dependency": true
+          }
+        ]
+      },
+      "non_test_container_count": 57,
+      "unrelated_containers_unchanged": true,
+      "network_inventory_unchanged": true,
+      "published": false,
+      "boundary": "Real native local images, isolated resolver checks and Docker Engine integration only; no registry manifest digests, CI public release, platform authorization/approval E2E, database snapshot restore or cross-node recovery claimed."
+    },
+    "manager_diagnostic_only": {
+      "schema": "f059-native-manager/v1",
+      "source_revision": "d86e66a2db83a26f0faca1993b914be121ef3259",
+      "platform": "linux/amd64",
+      "build_exit": 0,
+      "import_smoke_exit": 0,
+      "manager_image_id": "sha256:53106f7d94d69d1d7a9d99fd0955b5217f50197ab10bc12151cbeee04596729e",
+      "public_push": false,
+      "preceding_full_bundle_validation_source": "709821ecbe5c653aad3f90358173e0d110bee7e3",
+      "changed_runtime_files": [
+        "runtime_manager/k8s_client.py"
+      ],
+      "boundary": "Native manager build/import only; preceding bundle/SQLite smoke retained separately. No arm/CI public release claim."
+    },
+    "private_189_runtime": {
+      "descriptor_uid_unchanged": true,
+      "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:280722d842083948fe4a94b7aaf29e00a8806de9f7bae3e41ebdb7a5d1a864bd",
+      "helm_upgrade_completed": true,
+      "source_revision": "d86e66a2db83a26f0faca1993b914be121ef3259",
+      "descriptor_data_unchanged": true,
+      "helper_and_profile_images_unchanged": true,
+      "app_proxy_unchanged": true
+    },
+    "checksums": {
+      "native-709.json": "8e12131b119f0ccc2509cfc5c5cff8096026376919e233a2f46c5cb5d7fa1ac7",
+      "native-d86.json": "940ed5f196855ab1d551cce7a1235668869f286a8d18486b238098f4bc996c55",
+      "helm-14.json": "ad0c87c559d14d38f2e042bde7ed1f0e3285ba9651fc5c4bff3b7e8c837ca6b2"
+    },
+    "arm_new_testing": "paused_by_user",
+    "formal_ci": "not executed"
+  },
+  "arm_e405_unresolved": {
+    "source_revision": "e4052d1c2b7fab800ab2e39606d443d358ff5a1a",
+    "nine_images_build_smoke_bundle_passed": true,
+    "additional_wal": "9 passed, committed-wal-with-shm timeout at unchanged20s; unresolved, not rerun in current priority",
+    "prior_0e_wal_green_not_reused_as_e405_result": true
+  },
+  "latest_native_amd64_manager_helper": {
+    "schema": "f059-native-manager-helper/v1",
+    "source_revision": "143606851370d6091b120d348549329b8113c6a6",
+    "platform": "linux/amd64",
+    "build_exit": 0,
+    "import_smoke_exit": 0,
+    "helper_null_lock_smoke_exit": 0,
+    "images": {
+      "manager": {
+        "tag": "e2e-f059/bisheng-runtime-manager:e2e-f059-native-20261023-143606851-amd64",
+        "image_id": "sha256:727a725ed148acede43aaf4052f923eda229498d5e4a83db2cd6946562140b18",
+        "native_smoke": true
+      },
+      "helper": {
+        "tag": "e2e-f059/bisheng-runtime-helper:e2e-f059-native-20261023-143606851-amd64",
+        "image_id": "sha256:63c1243998c8ca303e59f2bed080ca70b4e7938914849dc77c7b0cef85d6ff35",
+        "native_smoke": true
+      }
+    },
+    "public_push": false,
+    "profile_images_unchanged": true,
+    "preceding_full_bundle_validation_source": "709821ecbe5c653aad3f90358173e0d110bee7e3",
+    "boundary": "Native manager/helper build and real empty-dependency resolution only. No new ARM, CI public release or full business claim."
+  },
+  "arm_current_instruction": "deferred by user; no new ARM run; historical results kept separately",
+  "latest_native_amd64_manager_only": {
+    "native": {
+      "build_exit": 0,
+      "import_smoke_exit": 0,
+      "public_push": false,
+      "manager_image_id": "sha256:5b2be2b9df8522651de96143a8b48d3026759917a36990dbdbf65749c0b592a0",
+      "platform": "linux/amd64",
+      "source_revision": "995c80f2a1d7288a2e5b9e7dc650f7ce1d911798",
+      "preceding_full_bundle_validation_source": "709821ecbe5c653aad3f90358173e0d110bee7e3",
+      "changed_runtime_files": [
+        "runtime_manager/k8s_templates.py"
+      ],
+      "boundary": "Native manager build/import only; preceding bundle/SQLite smoke retained separately. No arm/CI public release claim.",
+      "preceding_manager_source": "6f254f337dd6e8a36733501a2266bf08a04a56e9",
+      "schema": "f059-native-manager/v1"
+    },
+    "image": {
+      "manager_image_id": "sha256:5b2be2b9df8522651de96143a8b48d3026759917a36990dbdbf65749c0b592a0",
+      "source_revision": "995c80f2a1d7288a2e5b9e7dc650f7ce1d911798",
+      "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:7cef8910e89e566059b1fd04cf3bde26fdd9b2f70d61185b0ae621f7b40409e7",
+      "public_push": false,
+      "private_tag": "192.168.106.189:5443/f059/runtime-manager:20261023-25"
+    },
+    "deployed_helm_revision": 23,
+    "source_pack_sha256": "17075e198989851cf6403b06d6db3e9318f60d3a117257dcb49d6ca92744df0d",
+    "source_pack_bytes": 57971045,
+    "helper_unchanged_source": "143606851370d6091b120d348549329b8113c6a6",
+    "new_arm_run": false,
+    "public_release": false
+  },
+  "manager_only_history": [
+    {
+      "native": {
+        "build_exit": 0,
+        "import_smoke_exit": 0,
+        "public_push": false,
+        "manager_image_id": "sha256:ec08599a64325be5b8dac8c7fe31885325ed7e1c586a098b29c824a979702184",
+        "platform": "linux/amd64",
+        "source_revision": "7816e0bb87cd1b9ea0c82e8c01283cdf2e821a13",
+        "preceding_full_bundle_validation_source": "709821ecbe5c653aad3f90358173e0d110bee7e3",
+        "changed_runtime_files": [
+          "runtime_manager/operations.py"
+        ],
+        "boundary": "Native manager build/import only; preceding bundle/SQLite smoke retained separately. No arm/CI public release claim.",
+        "preceding_manager_source": "143606851370d6091b120d348549329b8113c6a6",
+        "schema": "f059-native-manager/v1"
+      },
+      "image": {
+        "manager_image_id": "sha256:ec08599a64325be5b8dac8c7fe31885325ed7e1c586a098b29c824a979702184",
+        "source_revision": "7816e0bb87cd1b9ea0c82e8c01283cdf2e821a13",
+        "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:3e0bf023a016b3f486bd5fe5cbcae85d89cc8737daaa9166f926291ec7781de5",
+        "public_push": false,
+        "private_tag": "192.168.106.189:5443/f059/runtime-manager:20261023-19"
+      },
+      "deployed_helm_revision": 17,
+      "source_pack_sha256": "f7f0a7958fd8100f3c06a3a5f7b29579587692ebd4c8a88c0103cffd83699a90",
+      "source_pack_bytes": 57916766,
+      "changes": [
+        "runtime_manager/operations.py"
+      ],
+      "helper_unchanged_source": "143606851370d6091b120d348549329b8113c6a6",
+      "new_arm_run": false,
+      "public_release": false
+    },
+    {
+      "native": {
+        "build_exit": 0,
+        "import_smoke_exit": 0,
+        "public_push": false,
+        "manager_image_id": "sha256:32e65744a296d2997f2e3395a6c8a78a035109854bb25beff05cc024a398fd6f",
+        "platform": "linux/amd64",
+        "source_revision": "a1de5271f5f00cef0cba06d41f09c673a9190525",
+        "preceding_full_bundle_validation_source": "709821ecbe5c653aad3f90358173e0d110bee7e3",
+        "changed_runtime_files": [
+          "runtime_manager/executors/kubernetes.py",
+          "runtime_manager/k8s_data_executor.py"
+        ],
+        "boundary": "Native manager build/import only; preceding bundle/SQLite smoke retained separately. No arm/CI public release claim.",
+        "preceding_manager_source": "7816e0bb87cd1b9ea0c82e8c01283cdf2e821a13",
+        "schema": "f059-native-manager/v1"
+      },
+      "image": {
+        "manager_image_id": "sha256:32e65744a296d2997f2e3395a6c8a78a035109854bb25beff05cc024a398fd6f",
+        "source_revision": "a1de5271f5f00cef0cba06d41f09c673a9190525",
+        "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:06bb857f4caa79499a844d16838831c7429dfc7d499e65649f314d96fd390cad",
+        "public_push": false,
+        "private_tag": "192.168.106.189:5443/f059/runtime-manager:20261023-20"
+      },
+      "deployed_helm_revision": 18,
+      "source_pack_sha256": "94777ca4f6ba3cadf5f27397eaf31418c4e32643ffbee4a5db6d178fb5e189c7",
+      "source_pack_bytes": 57927492,
+      "helper_unchanged_source": "143606851370d6091b120d348549329b8113c6a6",
+      "new_arm_run": false,
+      "public_release": false
+    },
+    {
+      "native": {
+        "build_exit": 0,
+        "import_smoke_exit": 0,
+        "public_push": false,
+        "manager_image_id": "sha256:f391421bb01533856e445af441b022f6999b8de402e621cff83bf7c8f2b9d6eb",
+        "platform": "linux/amd64",
+        "source_revision": "d7c0cc4fd4cd17c76b742902039846a789c7510b",
+        "preceding_full_bundle_validation_source": "709821ecbe5c653aad3f90358173e0d110bee7e3",
+        "changed_runtime_files": [
+          "runtime_manager/operations.py"
+        ],
+        "boundary": "Native manager build/import only; preceding bundle/SQLite smoke retained separately. No arm/CI public release claim.",
+        "preceding_manager_source": "249d5fc85b2ed08db88c93b7676ced73fdb8ee7b",
+        "schema": "f059-native-manager/v1"
+      },
+      "image": {
+        "manager_image_id": "sha256:f391421bb01533856e445af441b022f6999b8de402e621cff83bf7c8f2b9d6eb",
+        "source_revision": "d7c0cc4fd4cd17c76b742902039846a789c7510b",
+        "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:b2288f642fbabc7e2ada1ab2fd4d9bd78a6b2ce96c24d59bb51c4d0044f34c82",
+        "public_push": false,
+        "private_tag": "192.168.106.189:5443/f059/runtime-manager:20261023-23"
+      },
+      "deployed_helm_revision": 21,
+      "source_pack_sha256": "88bbcc772bcf14cb5f1e85c8cd42de2f27a9efd57c49463f58c5153e6247b00c",
+      "source_pack_bytes": 57958362,
+      "helper_unchanged_source": "143606851370d6091b120d348549329b8113c6a6",
+      "new_arm_run": false,
+      "public_release": false
+    },
+    {
+      "native": {
+        "build_exit": 0,
+        "import_smoke_exit": 0,
+        "public_push": false,
+        "manager_image_id": "sha256:604747e7fb7d833859305bd093bc5dc42c5cfb9feb84a1b1dee1c53af30fa634",
+        "platform": "linux/amd64",
+        "source_revision": "6f254f337dd6e8a36733501a2266bf08a04a56e9",
+        "preceding_full_bundle_validation_source": "709821ecbe5c653aad3f90358173e0d110bee7e3",
+        "changed_runtime_files": [
+          "runtime_manager/k8s_templates.py"
+        ],
+        "boundary": "Native manager build/import only; preceding bundle/SQLite smoke retained separately. No arm/CI public release claim.",
+        "preceding_manager_source": "d7c0cc4fd4cd17c76b742902039846a789c7510b",
+        "schema": "f059-native-manager/v1"
+      },
+      "image": {
+        "manager_image_id": "sha256:604747e7fb7d833859305bd093bc5dc42c5cfb9feb84a1b1dee1c53af30fa634",
+        "source_revision": "6f254f337dd6e8a36733501a2266bf08a04a56e9",
+        "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:59692b82ae7bd1d806e741a4c86f165bd472301feec31b06d3dc66306d00eb3b",
+        "public_push": false,
+        "private_tag": "192.168.106.189:5443/f059/runtime-manager:20261023-24"
+      },
+      "deployed_helm_revision": 22,
+      "source_pack_sha256": "ada03fa0b5aae0f6ac91170dfeaaa9070a57a7224448875142564c90d6c00664",
+      "source_pack_bytes": 57964032,
+      "helper_unchanged_source": "143606851370d6091b120d348549329b8113c6a6",
+      "new_arm_run": false,
+      "public_release": false
+    }
+  ],
+  "latest_native_three_services": {
+    "source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+    "native": {
+      "schema": "f059-native-three-services-and-public-ca/v1",
+      "source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+      "platform": "linux/amd64",
+      "images": {
+        "manager": {
+          "tag": "e2e-f059/bisheng-runtime-manager:e2e-f059-native-20261023-0d5ef7b2b-amd64",
+          "image_id": "sha256:c3a5255e893973935e26c7f48adcc218f0bb14bd4b522fd8eaea75a35aabc659",
+          "source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+          "variant": null
+        },
+        "helper": {
+          "tag": "e2e-f059/bisheng-runtime-helper:e2e-f059-native-20261023-0d5ef7b2b-amd64",
+          "image_id": "sha256:9cd5d3e1331f4b8c81c6335c4e3034e1eb9c63b94f1968e9dc4357180c2296a9",
+          "source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+          "variant": null
+        },
+        "assembler": {
+          "tag": "e2e-f059/bisheng-runtime-assembler:e2e-f059-native-20261023-0d5ef7b2b-amd64",
+          "image_id": "sha256:ccd89b540e02611fe1b7b24e7b7b991197b8f29d3462e1bb47962c2aca58f2d9",
+          "source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+          "variant": null
+        },
+        "assembler-public-ca": {
+          "tag": "e2e-f059/bisheng-runtime-assembler:e2e-f059-native-20261023-0d5ef7b2b-public-ca-amd64",
+          "image_id": "sha256:ddc11511399ae6f3f9689a076a8cc6bb5c6b8db34bb194d892603fe20264f19e",
+          "source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+          "variant": "registry-public-ca"
+        }
+      },
+      "native_smoke": {
+        "manager": {
+          "application_executed": false,
+          "dangerous_bits_negative": 0,
+          "isolated": true,
+          "oci_source_sha256": "976a216bc882d8331866fba6cd4dc1cd9ec1f06f89d1074267453893b26d18a2",
+          "path_profiles_checked": 9,
+          "platform": "linux/amd64",
+          "registry_accessed": false,
+          "role": "manager",
+          "sgid_directory_positive": 0,
+          "uid": 10001
+        },
+        "helper": {
+          "application_executed": false,
+          "dangerous_bits_negative": 10,
+          "isolated": true,
+          "oci_source_sha256": "976a216bc882d8331866fba6cd4dc1cd9ec1f06f89d1074267453893b26d18a2",
+          "path_profiles_checked": 9,
+          "platform": "linux/amd64",
+          "registry_accessed": false,
+          "role": "helper",
+          "sgid_directory_positive": 2,
+          "uid": 10001
+        },
+        "assembler": {
+          "application_executed": false,
+          "dangerous_bits_negative": 10,
+          "isolated": true,
+          "oci_source_sha256": "976a216bc882d8331866fba6cd4dc1cd9ec1f06f89d1074267453893b26d18a2",
+          "path_profiles_checked": 9,
+          "platform": "linux/amd64",
+          "registry_accessed": false,
+          "role": "assembler",
+          "sgid_directory_positive": 2,
+          "uid": 10001
+        },
+        "assembler-public-ca": {
+          "application_executed": false,
+          "dangerous_bits_negative": 10,
+          "isolated": true,
+          "oci_source_sha256": "976a216bc882d8331866fba6cd4dc1cd9ec1f06f89d1074267453893b26d18a2",
+          "path_profiles_checked": 9,
+          "platform": "linux/amd64",
+          "registry_accessed": false,
+          "role": "assembler",
+          "sgid_directory_positive": 2,
+          "uid": 10001
+        }
+      },
+      "profile_inputs": {
+        "node": "192.168.106.189:5443/f059/app-node-ca@sha256:533c8eb5040441e9420969ff0b305b45572ea58b80d345c27893dd9edc4a2763",
+        "python_preparer": "192.168.106.189:5443/f059/app-python-preparer-ca@sha256:7d4d46ca19fa0a5ae71f177f81a45df58dc3d3a676d4c536615a9376673d55e9"
+      },
+      "public_certificate_sha256": "9c3baa7fd2fb34a61b48d8d6537deb45036b8653ed81b86e049e5dd29c1ab895",
+      "tools_image_id": "sha256:e0774ebe198a660f05b03dbafdbe7c806e1dbee8ac7dbff38f8a1b9865a0442a",
+      "unrelated_containers_unchanged": true,
+      "network_inventory_unchanged": true,
+      "public_push": false,
+      "registry_push_smoke": false,
+      "official_nine_image_inventory_created": false,
+      "profile_images_rebuilt": false,
+      "boundary": "Native service images plus private CA derivative only. SGID layer and negative privilege checks ran in actual native nonroot isolated image containers; no registry manifest mutation/push smoke, ARM, full bundle regression, installer readiness or business E2E claimed."
+    },
+    "private_images": {
+      "public_push": false,
+      "profile_images_unchanged": true,
+      "assembler_public_ca_sha256": "9c3baa7fd2fb34a61b48d8d6537deb45036b8653ed81b86e049e5dd29c1ab895",
+      "assembler_contains_current_helper": true,
+      "source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+      "official_ci_release": false,
+      "images": {
+        "manager": {
+          "image_id": "sha256:c3a5255e893973935e26c7f48adcc218f0bb14bd4b522fd8eaea75a35aabc659",
+          "image": "192.168.106.189:5443/f059/runtime-manager@sha256:28449f9c96c2c60f1b1432a2f671b25434022e9e8675ea53aece36a823deee99",
+          "native_smoke": true,
+          "source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+          "private_tag": "192.168.106.189:5443/f059/runtime-manager:20261023-26"
+        },
+        "assembler-public-ca": {
+          "image_id": "sha256:ddc11511399ae6f3f9689a076a8cc6bb5c6b8db34bb194d892603fe20264f19e",
+          "image": "192.168.106.189:5443/f059/runtime-assembler-ca@sha256:9836c13405df4e4b613712a2e933a73e755d22daeb79d51d6adefc242971c6b9",
+          "native_smoke": true,
+          "source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+          "private_tag": "192.168.106.189:5443/f059/runtime-assembler-ca:20261023-26"
+        },
+        "helper": {
+          "image_id": "sha256:9cd5d3e1331f4b8c81c6335c4e3034e1eb9c63b94f1968e9dc4357180c2296a9",
+          "image": "192.168.106.189:5443/f059/runtime-helper@sha256:5906c51814e24a63356dc69fb5c1dd332bb4eb91b70cf65d41594aefc0841c85",
+          "native_smoke": true,
+          "source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+          "private_tag": "192.168.106.189:5443/f059/runtime-helper:20261023-26"
+        }
+      }
+    },
+    "final_code_candidate": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+    "final_candidate_native": "existing_exact_0d5_native_validated",
+    "official_release": false,
+    "corrective_commit": "20175cad9cb453734d42ee8a03c0be0594a6f3a0",
+    "all_build_inputs_equal": true,
+    "installed_helm_revision": 26
+  },
+  "withdrawn_cmd_candidate": {
+    "source_revision": "d7f7c2e10ab9805d41120d19e32d102310b06654",
+    "reason": "false positive; crane entrypoint already resets Cmd",
+    "equivalent_redundant_flag": true,
+    "probe_checks_passed": 16,
+    "report_sha256": "35c968ac1a297e65ff59257c05aef86bf3eaddf555f347f9eb992bacd61ae9bf",
+    "not_final_delivery": true
+  }
+}
diff --git a/features/v3.0.0/059-k8s-runtime-backend/model-release-validation.md b/features/v3.0.0/059-k8s-runtime-backend/model-release-validation.md
new file mode 100644
index 0000000000..d377b13a81
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/model-release-validation.md
@@ -0,0 +1,40 @@
+# F059 模型与镜像发行阶段证据
+
+状态:**原生双架构通过,正式CI发行未执行,T071保留未完成**。逐项来源、config ID和校验和见 [model-release-validation.json](./model-release-validation.json)。
+
+模型租户默认None等待上下文,数据库非空且保留索引;显式租户不覆盖,缺上下文拒绝。租户/DM8方言静态组合64、模型审批隔离44通过,Alembic单head f059_resource_phases;没有真实DM8测试。
+
+候选54b3ea2c1在原生amd64、arm64分别完成九镜像构建、隔离resolver/nginx/许可证检查及完整应用包Engine验证。随机SQLite哨兵跨升级保留,实际目录与DB身份不变,两次真实下载备份核验通过。两轮非本次容器/网络清单不变。arm64另10项WAL回归全部通过,含三种真实Docker布局、最新提交可见、SQL只读拒写和并发事务一致性。
+
+Go公网metadata等待、首轮取消及官方材料恢复分别保留,未计为源代码失败或伪造成功。amd64首次classic下载构建由操作者停止,最终使用同源已通过native脚本的镜像。未删除无关镜像或修改daemon代理。
+
+本地config ID不是仓库manifest digest;部署控制镜像另记录LAN固定digest。没有授权新的公共push/tag/DockerHub发行,正式CI未执行;单节点结果不证明跨节点恢复。
+
+## 后续候选 9e7e86cab
+
+189真实业务暴露profile引用与对象混用,已修复并完整runtime1217/13skip通过。前述双架构原生证据仍准确对应54b,不能转标为新候选;9e7同SHA原生重建及烟测正在补验,正式公共CI仍未授权。
+
+候选更新为`0e493c07a`:另修精确无分配404观察语义,完整runtime1259/13skip通过;双架构原生按同SHA重新构建中。9e7不作为已完成的原生证据;54b历史证据保留。
+
+## 候选 0e493c07a 实际补验完成
+
+同SHA原生amd64和arm64均完成9镜像build、isolated smoke、full bundle smoke,全部exit0;arm64实际WAL 10/0skip、三种Docker布局通过。镜像config ID、工具/许可与物料SHA见JSON。amd64非本次容器57个、arm64为2个,前后及网络均保持;不得将旧54b的87个计数移用。54b与9e7过程仍保留于Git和私有日志,9e7未单独完成原生验收。
+
+189仅向专用LAN仓库推送manager固定manifest `b17045eb…`,Helm11升级控制三服务,明确保留原实际能力报告;未发布DockerHub。四个无分配失败draft经owner API正常删除。新六场景prepare暴露空闲租约过期后永久renew409,尚未创建业务Pod,登记T198/T199,不据原生结果声明K8s业务通过。
+
+## 当前amd64验收候选与arm优先级
+
+709821ecb在189原生九镜像构建/隔离烟测及bundle/SQLite数据哨兵通过;d86e66a2d只修改K8s日志容器白名单,另原生manager构建/导入通过,未重复未改helper应用镜像或重标其来源。固定private digest/组件SHA详见JSON,非正式Docker Hub/CI发行。用户已要求先跑通K8s,暂停新的arm验证;此前e405的附加WAL 20秒超时仍未解决,不以旧0e绿色记录覆盖。
+
+当前开发候选143606851仅新增amd64 manager/helper原生构建与无网络lock:null解析烟测;应用profile2镜像保持旧认证digest。ARM按用户要求暂停,不重标历史结果为新SHA;正式CI/公开发行仍pending。
+
+
+2026-10-23:manager-only 7816原生amd64构建/导入通过,内网固定digest3e0bf023…,Helm17部署;只更新operations.py,不重标helper/profile旧来源。ARM按用户指示暂缓,正式公开CI发行仍未执行。详见JSON latest_native_amd64_manager_only。
+
+2026-10-23:a1de manager原生amd64构建/导入通过,内网固定digest06bb857f…、Helm18;runtime两文件启动/数据取消变更,不重标helper143。ARM仍暂停,公开CI/tag/发行未执行。
+
+2026-10-23:当前6f254f337仅manager原生amd64重建/禁网导入通过,私有digest59692b82…/Helm22;helper143/8a及profile2镜像不变。实际离线CPU100数据请求在原60秒内17秒完成、预算真实预占/清理后释放;不等同新完整bundle烟测或ARM/正式CI发行。
+
+## 最新manager-only 995候选
+
+源码995c80f2a、真实浅Git/fsck/import,源码包SHA17075e198989851cf6403b06d6db3e9318f60d3a117257dcb49d6ca92744df0d(57,971,045 bytes)。原生amd64构建和导入0,imageID5b2be2b9…;仅推189专用仓库digest7cef8910…并部署Helm23。143 helper/profile保持,未新增ARM、公开push/tag、Docker Hub或正式CI发行;原先完整bundle/SQLite烟测来源分别保留。
diff --git a/features/v3.0.0/059-k8s-runtime-backend/preflight-evidence/cluster.txt b/features/v3.0.0/059-k8s-runtime-backend/preflight-evidence/cluster.txt
new file mode 100644
index 0000000000..1cbf6a522f
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/preflight-evidence/cluster.txt
@@ -0,0 +1,49 @@
+/usr/local/bin/kubectl
+{
+  "clientVersion": {
+    "major": "1",
+    "minor": "28",
+    "gitVersion": "v1.28.15",
+    "gitCommit": "841856557ef0f6a399096c42635d114d6f2cf7f4",
+    "gitTreeState": "clean",
+    "buildDate": "2024-10-22T20:34:56Z",
+    "goVersion": "go1.22.8",
+    "compiler": "gc",
+    "platform": "linux/amd64"
+  },
+  "kustomizeVersion": "v5.0.4-0.20230601165947-6ce0bf390ce3",
+  "serverVersion": {
+    "major": "1",
+    "minor": "28",
+    "gitVersion": "v1.28.15",
+    "gitCommit": "841856557ef0f6a399096c42635d114d6f2cf7f4",
+    "gitTreeState": "clean",
+    "buildDate": "2024-10-22T20:26:27Z",
+    "goVersion": "go1.22.8",
+    "compiler": "gc",
+    "platform": "linux/amd64"
+  }
+}
+NAME                  STATUS   ROLES                  AGE   VERSION    INTERNAL-IP       EXTERNAL-IP   OS-IMAGE                KERNEL-VERSION                 CONTAINER-RUNTIME
+binfeng-k8s-node189   Ready    control-plane,worker   27d   v1.28.15   192.168.106.189           CentOS Linux 7 (Core)   3.10.0-1160.119.1.el7.x86_64   docker://24.0.9
+NAME              STATUS   AGE
+default           Active   27d
+kube-node-lease   Active   27d
+kube-public       Active   27d
+kube-system       Active   27d
+kubekey-system    Active   27d
+NAMESPACE     NAME                                          READY   STATUS    RESTARTS      AGE   IP                NODE                  NOMINATED NODE   READINESS GATES
+kube-system   calico-kube-controllers-568d4f5458-n89hv      1/1     Running   1 (26d ago)   27d   10.233.64.4       binfeng-k8s-node189              
+kube-system   calico-node-66b7f                             1/1     Running   1 (26d ago)   27d   192.168.106.189   binfeng-k8s-node189              
+kube-system   coredns-7cd54bf556-4rslr                      1/1     Running   1 (26d ago)   27d   10.233.64.6       binfeng-k8s-node189              
+kube-system   coredns-7cd54bf556-j9m5r                      1/1     Running   1 (26d ago)   27d   10.233.64.5       binfeng-k8s-node189              
+kube-system   kube-apiserver-binfeng-k8s-node189            1/1     Running   1 (26d ago)   27d   192.168.106.189   binfeng-k8s-node189              
+kube-system   kube-controller-manager-binfeng-k8s-node189   1/1     Running   1 (26d ago)   27d   192.168.106.189   binfeng-k8s-node189              
+kube-system   kube-proxy-rstd2                              1/1     Running   1 (26d ago)   27d   192.168.106.189   binfeng-k8s-node189              
+kube-system   kube-scheduler-binfeng-k8s-node189            1/1     Running   1 (26d ago)   27d   192.168.106.189   binfeng-k8s-node189              
+kube-system   nodelocaldns-6xn54                            1/1     Running   1 (26d ago)   27d   192.168.106.189   binfeng-k8s-node189              
+
+bash: warning: setlocale: LC_ALL: cannot change locale (C.UTF-8)
+/bin/sh: warning: setlocale: LC_ALL: cannot change locale (C.UTF-8)
+No resources found
+No resources found
diff --git a/features/v3.0.0/059-k8s-runtime-backend/preflight-evidence/sha256.json b/features/v3.0.0/059-k8s-runtime-backend/preflight-evidence/sha256.json
new file mode 100644
index 0000000000..22af8d88a7
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/preflight-evidence/sha256.json
@@ -0,0 +1,4 @@
+{
+  "cluster.txt": "c619c88fb0168bde7ea5b5803dcd3adae084aa032ca2efeac447a2b5b426cb70",
+  "storage.txt": "df49b8847ef1b9f406803eb5b2aa8f05c444bb3d8ef85c169f9116c312f0e0d2"
+}
diff --git a/features/v3.0.0/059-k8s-runtime-backend/preflight-evidence/storage.txt b/features/v3.0.0/059-k8s-runtime-backend/preflight-evidence/storage.txt
new file mode 100644
index 0000000000..f33091bf1a
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/preflight-evidence/storage.txt
@@ -0,0 +1,14 @@
+bash: warning: setlocale: LC_ALL: cannot change locale (C.UTF-8)
+/bin/sh: warning: setlocale: LC_ALL: cannot change locale (C.UTF-8)
+No resources found
+No resources found
+No resources found
+NAME                  ARCH    CPU     MEMORY        TAINTS
+binfeng-k8s-node189   amd64   7600m   63656577179   
+No resources found
+NAME              UID
+default           ae7de2ee-090d-41f8-b547-f9ae0c061823
+kube-node-lease   572fb8ac-c683-46e6-926a-e92536c7e929
+kube-public       723fbc08-ccce-434f-9fcc-4642ed158b33
+kube-system       6f7b27d4-67df-4888-9285-9bea1f93bb98
+kubekey-system    815be20a-0b3b-47b5-880f-d4e83b472a67
diff --git a/features/v3.0.0/059-k8s-runtime-backend/prerequisites.json b/features/v3.0.0/059-k8s-runtime-backend/prerequisites.json
new file mode 100644
index 0000000000..cb5adfefcf
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/prerequisites.json
@@ -0,0 +1,37 @@
+{
+  "feature": "F059",
+  "status": "inherited_from_release",
+  "public_bootstrap": {
+    "source_commit": "0e3c156e5042d7805c3b02f32c39c49e235bf521",
+    "branch": "codex/f086-tenant-bootstrap",
+    "release_base": "8510a9645382c7ac5f886503e26ae68cb8a0d935",
+    "tests": "17 hotfix tests; inherited permission/audit selection 197 passed, 3 xfailed",
+    "inherited": true,
+    "reason": "Explicit user authorization on 2026-10-22; ordinary release-line merge; production repair files identical to release.",
+    "release_commit": "9d156f261cc7769fcf75f41caeb104a425308017",
+    "f059_merge_commit": "98fbba0325be7a897239e9fe853653297b4b1b87",
+    "pushed": true,
+    "remote_head_verified": true
+  },
+  "vibe_byte_counters": {
+    "source_commit": "667546bb77",
+    "source_branch": "codex/086-vibe-runtime-delivery",
+    "branch_specific_reason": "runtime_delivery.py does not exist on feat/3.0.0",
+    "imported": true,
+    "integration_revision": "f059_merge_f086_heads",
+    "tests": "27 passed",
+    "deliberate_divergence": "Combine BigInteger and existing String imports; static model guard includes new K8s models and explicitly nullable historical authorization_id/unbound uid unique keys. Neither source revision parent is changed."
+  },
+  "release_inheritance": {
+    "schema_head": "f059_merge_release_heads",
+    "previous_heads": [
+      "f059_merge_f086_heads",
+      "asr_rt_model_type",
+      "merge_skill_center_heads"
+    ],
+    "runtime_tests": "600 passed, 10 skipped",
+    "backend_tests": "82 passed",
+    "frontend_tests": "98 passed",
+    "frontend_quality_new_findings": 0
+  }
+}
diff --git a/features/v3.0.0/059-k8s-runtime-backend/prerequisites.md b/features/v3.0.0/059-k8s-runtime-backend/prerequisites.md
new file mode 100644
index 0000000000..1a36148b5d
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/prerequisites.md
@@ -0,0 +1,21 @@
+# F059 前置继承状态
+
+## 公共多租户启动(已从发布线继承)
+
+用户2026-10-22明确授权后,将`0e3c156e5042d7805c3b02f32c39c49e235bf521`与最新发布线普通合并,推送`feat/3.0.0@9d156f261cc7769fcf75f41caeb104a425308017`。`git ls-remote`核验远端一致。F059在`98fbba0325be7a897239e9fe853653297b4b1b87`普通合并该发布线,两个父提交均保留;三个生产修复文件与发布线逐文件diff为空。主工作区未切换或改动,未复制权限分叉。
+
+冲突逐文件处理:同时保留app和linsight_skill资源注册、应用审计过滤/导出及沙箱审计;审批页拆分后保留必审判断、审批人来源和单租户标签;Vibe调用适配已发布共享组件。自动审批拒绝批量修改后,采用逐文件修改和验证;被拒绝脚本未执行。
+
+验证:公共修复17项通过;继承权限/审计选择197 passed、3 xfailed;前端13个文件98 passed;runtime-manager 600 passed、10 skipped;资源账本/材料协议/模型82 passed。平台lint 85项、类型1项,client lint 89 errors/1 warning、类型4项,与发布线同一选择逐条比较无新增。全仓质量门仍未通过;lint:prune仅收缩抑制项。
+
+继承后实测三条迁移head,新增空`f059_merge_release_heads`汇合;原父链不变,无表或数据修改。`alembic heads`确认唯一head,单head测试通过;尚未执行真实数据库升级。
+
+## Vibe 专有字节计数器(已继承)
+
+来源:原F086 `codex/086-vibe-runtime-delivery@667546bb77`。两字段扩为BIGINT。通用发布线没有runtime_delivery模型,属于分支特定修复,未向发布线复制不存在的应用模块。
+
+保留F059字段并逐段解决BigInteger/String导入冲突。静态模型守卫明确授权历史NULL和待绑定UID NULL例外,其余约束不放宽。原迁移父链不改;`f059_merge_f086_heads`显式汇合源修复,后续发布线继承汇合在其之后。
+
+## 验证边界
+
+MySQL/DM8模型类型和约束为静态核查,未运行真实DM8或新迁移的真实数据库升级。189连接与节点Ready、无StorageClass/PV已刷新确认;尚未安装业务资源或运行应用E2E。
diff --git a/features/v3.0.0/059-k8s-runtime-backend/review-evidence.md b/features/v3.0.0/059-k8s-runtime-backend/review-evidence.md
new file mode 100644
index 0000000000..1a2038323b
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/review-evidence.md
@@ -0,0 +1,74 @@
+# F059 spec 审查记录
+
+**当前结论:LGTM,用户已确认规格并授权进入设计。** 用户要求修复规格后再次评审;本轮修复 R1、R2、R3,并按 sdd-review 的 spec-checklist 全量复核。由同一代理完成,没有独立代理评审,不代表 K8s 功能已经实现或部署验收通过。
+
+- 审查日期:2026-10-21。
+- 工作树分支:`codex/059-k8s-runtime`;代码基线:`755d4456e50c015a3fa88da0c0c35ab53bbd1650`。
+- 评审对象:[spec.md](./spec.md),SHA-256:`11991622ee73976a354bd533cb6691407780c99ce70fdf034212f6ea6928c13e`。
+- 严格格式结果:[spec-review.txt](./spec-review.txt)。
+- 依据:PRD-1 GOV-10、GOV-03、RT-01、RT-07、RT-08,F055、F086,当前版本 release-contract 及 constitution;架构职责核对 `02-backend-modules.md`、`10-permission-rbac.md`。
+
+## 本轮修复及复审闭环
+
+| ID | 上轮问题 / 严重度 | 本轮修复 | 复核依据 | 结果 |
+| --- | --- | --- | --- | --- |
+| R1 | 预检容量失败被放入审批后待上线态 / high | [AC-08](./spec.md#L47) 明确两个阶段:审批前失败不产生审批单或正式版本,回收后重新发布;审批后容量不足保留原审批,待上线后手动重试。候选清理仍受 AC-49、AC-52 的停止确认约束。第 3 节同步。 | [F055 AC-02、AC-07](../055-app-publish-pipeline/spec.md#L74)、[F086 AC-33、AC-34](../086-vibe-runtime-delivery/spec.md#L110)、[INV-33](../release-contract.md#L59);[precheck_service.py](../../../src/backend/bisheng/app_publish/domain/services/precheck_service.py#L360) 的容量失败仍是预检失败。 | 已关闭 |
+| R2 | AC-15 的操作范围与 AC-47 的部署初始化范围矛盾 / medium | [AC-15](./spec.md#L57)、[AC-47](./spec.md#L116) 一致区分任务材料身份和运行初始化身份;前者绑定有效操作,后者绑定当前部署及冻结引用。续期必须由 backend 重新授权,过期票据不能直接访问或独自完成续期,两类身份均不进入业务容器。第 3 节同步。 | [F086 AC-06、AC-23、AC-48、AC-53](../086-vibe-runtime-delivery/spec.md#L71)、[INV-40、INV-42](../release-contract.md#L77);现有 [operation()](../../../src/backend/bisheng/app_runtime/domain/services/runtime_artifact_service.py#L55) 拒绝终态操作,因此运行初始化授权是后续设计/实现需补齐的合同,不能宣称当前接口已支持。 | 已关闭 |
+| R3 | 必需隔离可被配置覆盖放行 / high | [AC-23](./spec.md#L71)、[AC-31](./spec.md#L85) 删除覆盖放行,明确缺失、失效、无法确认均拒绝新执行,既有应用也不能豁免强制隔离;AC-32 改为前置失败和可选沙箱回退记录。AC-19 的强沙箱回退只可回到满足必需隔离的默认档。discovery 与 release-contract 同步。 | [F086 AC-52](../086-vibe-runtime-delivery/spec.md#L135)、[INV-33](../release-contract.md#L59)、[INV-42](../release-contract.md#L79);没有新增或放宽安全例外。 | 已关闭 |
+
+## 场景交叉复核
+
+下表是需求文本的逐场景静态复核,不是实际部署测试记录。
+
+| 场景 | 修订规格要求 | 追溯 | 结论 |
+| --- | --- | --- | --- |
+| 首发预检无资源 | 预检失败,无新审批单、无正式版本、无审批后待上线态;清理后重新发布 | AC-08 | 一致 |
+| 审批后无资源 | 已通过审批不变,保留版本,进入待上线,资源恢复后原单手动上线 | AC-08 | 一致 |
+| 候选停止未确认 | 不提前释放可能仍活动的资源或写入归属,不制造第二写入者 | AC-08、AC-49、AC-52 | 一致 |
+| 准备操作结束后实例冷重建,manager 不在线 | 初始化身份按有效部署及冻结材料重新授权,不重新激活已完成准备操作 | AC-07、AC-15、AC-44、AC-47、AC-48 | 一致 |
+| 运行初始化短期票据过期 | 旧票据不能直接下载;backend 重新核验归属和冻结范围后才能签发新凭据 | AC-15、AC-47 | 一致 |
+| 应用停用、归属撤销、跨范围或重放 | 材料请求拒绝;初始化身份、任务票据和管理密钥不进入业务容器 | AC-15、AC-47、AC-53 | 一致 |
+| 必需隔离缺失、不生效或无法确认 | 不因配置或告警记录而放行;诊断能力可保留,重新验证通过才恢复新执行 | AC-12、AC-21、AC-23、AC-30、AC-31 | 一致 |
+| 可选强沙箱不可用 | 明确记录实际档位,只回退到满足必需隔离的默认档 | AC-19、AC-20、AC-23、AC-31、AC-32 | 一致 |
+| 无动态存储供应器但有合格预置卷 | 可发布;本地卷节点绑定持续可见,不借单节点结果承诺跨节点恢复 | AC-31、AC-33、AC-34、AC-55 | 一致 |
+
+## PRD 与契约覆盖
+
+| 来源 | AC |
+| --- | --- |
+| GOV-10 双形态、开关、交付 | AC-01、AC-02、AC-04、AC-36、AC-37、AC-39、AC-40、AC-41、AC-56 |
+| GOV-03 资源档位与容量 | AC-08、AC-17、AC-18、AC-43 |
+| RT-01 统一入口与身份 | AC-12、AC-15、AC-21、AC-37、AC-53 |
+| RT-07 持久化、停用与删除 | AC-29、AC-33、AC-34、AC-35、AC-46、AC-50、AC-51 |
+| RT-08 自愈与故障隔离 | AC-05、AC-06、AC-07、AC-09、AC-11、AC-18、AC-48、AC-49、AC-52 |
+| F086 应用包、材料与离线 | AC-24、AC-25、AC-26、AC-27、AC-28、AC-42、AC-44、AC-45、AC-47、AC-54 |
+| 最小权限、隔离与探测 | AC-03、AC-10、AC-13、AC-14、AC-16、AC-19、AC-20、AC-22、AC-23、AC-30、AC-31、AC-32、AC-55 |
+
+## sdd-review 检查项
+
+| 检查 | 复审结果 |
+| --- | --- |
+| PRD 功能与用户场景 | 上表覆盖原有产品流程;不增加第二套 App、审批、权限或 SDK 行为 |
+| 错误与边界 | 预检/审批后容量失败、材料过期与归属撤销、隔离失败、未知写入者均有可观察结果 |
+| 范围外 | 保持平台核心部署、多副本/自动扩缩容、跨集群、形态迁移、任意 SQL 等范围外事项 |
+| AC 唯一与追溯 | 55 条有效 AC,原编号不变,AC-38 退役且不复用;所有本地 AC 引用有效 |
+| 可验收性 | 按输入阶段、当前归属和失败原因给出结果;执行期限/接口的具体定义留待 design,未将配置期限当作实测值 |
+| EARS | 全部有效 AC 保持 EARS 句型 |
+| 错误码职责 | 未新增错误码详表或响应格式 |
+| Owner 边界 | F054 App 状态、F055 发布审批、F048 权限、F057 SDK Owner 不变 |
+| INV 与 constitution | R1、R3 冲突已关闭;仍遵守独立编排权限、租户隔离、平台授权与存储、单写与快照确认、不含商业额度等约束;DM8 只做本次约定的静态规范检查 |
+| What / How 分工 | 本轮只修复验收行为及身份/隔离边界;执行器、投递机制、存储布局、准入策略、网络实现和接口细节继续在 design 决定 |
+
+## 文档检查与证据边界
+
+- 核对修改前后 AC 集合及章节标题:不增删 AC、不重编号;AC-38 继续退役。
+- 检查本特性文档相对链接、AC 引用及旧“隔离覆盖”要求的残留;保留的覆盖字样明确指禁止覆盖或历史问题。
+- 同步 spec、discovery、release-contract 登记和审查结论;未修改代码及集群资源。
+- 本轮没有执行 Docker/K8s 部署、故障注入、材料接口续期或隔离实测;189 的只读预检记录与摘要保持不变。
+- 189 仍是单节点 amd64,跨节点恢复、强沙箱及实际 arm64 集群执行不能记为已通过。
+
+## 审查历史与下一阶段
+
+初稿曾记录 LGTM;用户要求的上一轮复审发现 R1、R2、R3 后已撤回。用户本次明确要求修复后复审,三项已在本轮闭环;当前 LGTM 对应上列规格;确认前的全文 SHA-256 为 `b64eb426e5459d23844563cc45427adf73b9ea205156e5004439985f183f2154`,确认后的元信息更新已同步当前全文摘要。
+
+用户已明确回复“确认规格,进入设计”;本次仅更新规格确认元信息,55 条 AC 正文保持不变。依据根 AGENTS.md 第 6 节和 SDD-Guide 第 3 节,当前进入 design;design 仍须另行评审并确认后再拆 tasks 与编码。初始化材料授权、单写确认、准入/网络实现、兼容构建器和完整 E2E 均是后续工作,不能由本轮文档复审冒充已经交付。
diff --git a/features/v3.0.0/059-k8s-runtime-backend/spec-review.txt b/features/v3.0.0/059-k8s-runtime-backend/spec-review.txt
new file mode 100644
index 0000000000..b0e4e89c33
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/spec-review.txt
@@ -0,0 +1 @@
+LGTM
diff --git a/features/v3.0.0/059-k8s-runtime-backend/spec.md b/features/v3.0.0/059-k8s-runtime-backend/spec.md
new file mode 100644
index 0000000000..dbc6bdef7e
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/spec.md
@@ -0,0 +1,152 @@
+# Feature: F059 K8s 应用托管执行后端
+
+> **当前后继合同(F114,2026-10-30,本地实现,未部署)**:Kubernetes 仍沿原执行租约、准入、PVC/SQLite 独占及停止证明;统一使用 v2 的当前授权来源与阶段许可。预览原子登记在平台,helper/数据围栏不放宽。 详见 [F114 已确认规格](../114-vibe-permission-actions/spec.md) 与 [验证记录](../114-vibe-permission-actions/verification.md)。下文保留原 Feature 的 AC 编号、当时口径及历史证据,不将历史通过记录当作 F114 部署证明。
+
+> **状态:2026-10-21 修订草案,R1、R2、R3 已修复,sdd-review 复审通过,用户已确认,进入设计阶段。** 本文只定义 What,不表示已经实现或部署验收通过。沿用原 AC-01 至 AC-43,AC-38 保留退役;新增 AC-44 至 AC-56 承接 F086。原有 AC 没有重新编号。
+
+**关联 PRD**:[PRD-1](../../../docs/PRD/3.0-release/3.0%20应用工场%20PRD-1%20专业开发者通道与应用运行时.md) GOV-10、GOV-03、RT-01、RT-07、RT-08;[技术架构方案](../../../docs/PRD/3.0-release/3.0%20应用工场%20技术架构方案.md);[F086 spec](../086-vibe-runtime-delivery/spec.md)与已确认的应用包交付合同。
+
+**优先级**:P0;**版本**:v3.0.0;**依赖**:F054、F055、F053、F057、F086;复用 F048 权限、F025 审批和平台存储。
+
+**需求来源**:用户在确认全开源、预制镜像加应用包、backend 中介存储之后,要求“继续完成k8s的开发和部署验收”;指定 192.168.106.189 为单节点 K8s 测试环境。此前 F086 的 spec/design 确认不冒充本修订已经签收。
+
+## 范围边界
+
+本次交付应用工场的 K8s 执行后端:准备、启动、停止、预览、健康、自愈、日志、容量、持久数据、升级与清理,以及集群安装物和真实部署验收。发布、审批、权限、SDK 与用户操作仍归已有 Owner,K8s 不引入第二套业务对象或管理流程。
+
+- 默认应用包、用户完整镜像和显式兼容源码构建三类合同与 F086 一致。应用包发布不要求逐版本构建或推送 OCI 应用镜像;兼容模式保留构建能力,不静默转换旧项目。
+- 运行环境目录、代码、依赖产物、附件和快照继续由 backend 管理;运行时执行服务不持平台数据库或 MinIO 凭据。实际工作数据库使用应用独占持久卷。
+- manager、app-proxy、egress-proxy 保持独立进程,采用已有开源服务镜像和范围化服务身份。应用数量不引入商业授权或免费额度。
+- 本次不改变平台核心的既有部署方式,不新增业务管理页面,不提供多副本、自动扩缩容、跨集群或 Docker/K8s 形态间迁移,不增加任意宿主命令、任意 SQL 或用户一键回滚。
+- 节点本地卷允许作为明确告知风险的单节点部署方案。仅有单节点测试环境时,不把单节点重建结果当作跨节点故障恢复证明。
+
+## 1. 用户故事
+
+作为已有 K8s 集群的部署人员,我希望用既有集群运行托管应用,安装时明确知道缺少的网络、镜像、存储和权限条件,以便按现有运维流程上线和排障。
+
+作为开发者,我希望同一份应用包在 Docker 和 K8s 环境经过相同的发布、预检、审批、上线和日志流程,以便不用改写业务代码或提供集群配置。
+
+作为应用 owner,我希望停用、升级、实例重建后数据库和附件保留,预览与生产数据隔离,并能按既有权限流程备份和恢复。
+
+作为安全团队,我希望编排权限只授予独立运行时服务,应用和准备任务不能获取平台控制身份、集群身份或其它应用数据,并能验证隔离实际生效。
+
+## 2. 验收标准
+
+本 Feature 承接 INV-33 的产品面一致要求。应用业务态、审批态、权限和错误呈现沿用原 Owner,不因执行形态发明新业务状态。运维诊断允许展示必要的集群信息,业务用户文案不暴露底层对象名。
+
+### 2.1 形态选择与部署开关
+
+- **AC-01** — THE SYSTEM SHALL 在部署期选择 Docker 或 K8s 执行形态,同一环境只启用一种,不提供运行期切换或形态间迁移入口。
+- **AC-02** — WHERE 启用 K8s 运行时, THE SYSTEM SHALL 保持已有托管应用入口、四类兜底页、广场、详情页和 CLI/MCP 行为;未部署运行时层时平台其余功能正常,相关入口按已有开关隐藏或引导。
+- **AC-03** — THE SYSTEM SHALL 使 backend、Celery、app-proxy、egress-proxy 和应用均不持集群编排凭据;仅独立 manager 持有其作用范围内的集群身份。
+
+### 2.2 编排能力等价
+
+- **AC-04** — WHEN 平台请求准备、启停、销毁、状态、日志、探活或容量判定, THE SYSTEM SHALL 复用 F086 的业务操作和事实合同,调用方不传 Pod、卷路径或其它 K8s 执行参数。
+- **AC-05** — WHEN 应用上线或重新上线, THE SYSTEM SHALL 准备候选、探活并提交就绪事实后才切换正式路由;预览使用独立临时执行体。涉及生产 SQLite 的启动与切换必须同时满足 AC-50 的单写要求。
+- **AC-06** — WHEN 已运行应用退出或持续不健康, THE SYSTEM SHALL 自动恢复服务;在集群及持久卷前置正常且资源可调度时,恢复时间不超过 5 分钟。节点本地卷物理故障不作跨节点自动恢复承诺,按 AC-31 显式报告。
+- **AC-07** — IF manager 重启或短暂不可用, THEN THE SYSTEM SHALL 保持已运行应用及入口、既有出站连接可用;manager 恢复后在配置的对账期限内恢复对缺失执行体和持久操作的处理。
+- **AC-08** — WHEN 审批通过后的上线、手动上线或重新上线因容量不足、所选档位、卷拓扑或配额约束而无法调度, THE SYSTEM SHALL 进入既有待上线(资源不足)状态,保留已通过的审批及待上线版本,资源恢复后可原单手动上线;WHEN 审批前准备或启动预检因同类约束失败, THE SYSTEM SHALL 返回预检失败阶段及资源不足原因,不生成审批单或正式版本,不进入审批后的待上线状态,释放候选及本次预占,资源恢复后重新提交发布。两阶段均不得遗留无限等待的候选;有界清理继续遵守 AC-49、AC-52。
+- **AC-09** — IF 新发布的拉起超过配置期限, THEN THE SYSTEM SHALL 区分容量、镜像、材料下载和健康失败并结束该次拉起;已上线应用恢复失败时持续对账,保留原恢复状态并报告阻塞原因,不发明恢复失败业务终态。
+- **AC-10** — THE SYSTEM SHALL 通过既有状态、健康、版本和日志接口返回与 Docker 相同的范围、刷新、筛选和权限语义,日志经过现有敏感信息脱敏。
+- **AC-11** — THE SYSTEM SHALL 对每个正式应用维持单个活动实例,不开放副本数或自动扩缩容;临时预览和准备任务不成为额外正式实例。
+- **AC-12** — THE SYSTEM SHALL 只允许经 app-proxy 访问应用;不为每个应用开放节点端口、外部负载均衡或独立入口,非授权工作负载不能绕过代理直连。
+
+### 2.3 最小权限与审计
+
+- **AC-13** — THE SYSTEM SHALL 限制 manager 只能操作明确授权的工场命名空间和资源,不能操作其它命名空间、授予新身份权限、创建特权工作负载或挂载宿主敏感路径;部署验收须包含实际允许与拒绝结果,不能仅检查权限声明文件。
+- **AC-14** — THE SYSTEM SHALL 随交付物提供资源类型、动作、命名空间的完整权限清单;集群启用审计日志时,manager 的操作可按其身份追溯。集群审计未启用时明确提示,不伪造审计证据。
+- **AC-15** — THE SYSTEM SHALL 使应用及准备、探活、数据维护执行体不持集群 API 身份,不获得 manager 控制密钥;准备、预检和数据维护的材料身份限定于当前有效操作及指定材料,运行期初始化的材料身份按 AC-47 限定于当前有效部署归属及冻结引用。两类材料身份均不得被业务容器读取或继承,不得取得通用控制权限。
+- **AC-16** — THE SYSTEM SHALL 对应用和任务执行非特权、不可提权、资源有界、根文件系统只读及明确可写路径限制;应用代码和依赖环境正式运行时只读,业务数据目录按应用隔离。
+
+### 2.4 档位与沙箱映射
+
+- **AC-17** — THE SYSTEM SHALL 按已有资源档位设置请求量和上限,档位默认值、停用、修改生效时机与 Docker 一致,不能静默修改已运行实例的冻结规格。
+- **AC-18** — WHEN 单个应用达到内存或 CPU 上限, THE SYSTEM SHALL 将重启或限流影响限制在该应用,不影响平台及其它应用。
+- **AC-19** — THE SYSTEM SHALL 保留默认、推荐、加固三档部署级隔离选择;配置的更强隔离不可用时,明确告警并按既有部署合同回退默认档,记录实际生效档位,不静默声称已启用。
+- **AC-20** — THE SYSTEM SHALL 在改变隔离档位时保持身份注入、资源限制、单写持久化和出站控制等业务承诺不变。
+
+### 2.5 网络隔离
+
+- **AC-21** — WHILE 应用运行, THE SYSTEM SHALL 默认拒绝直连外网、平台内部服务、其它应用和集群 API,只允许经受控通道调用当前授权的平台能力及声明域名;域名策略和权限撤销的拒绝上界沿用 F086,不因 manager 不在线而失效。
+- **AC-22** — WHILE 准备或兼容构建任务执行, THE SYSTEM SHALL 仅允许阶段所需的平台材料接口、配置包源或镜像端点;禁网解析、安装与编译阶段不得使用公共包源兜底,任务不能连接生产数据库和其它应用。
+- **AC-23** — IF 必需的入口或出站隔离缺失、未生效或无法确认生效, THEN THE SYSTEM SHALL 拒绝新的托管部署及相关准备、预检和维护执行,返回实际探测失败原因;不提供跳过必需隔离的配置或降级放行路径。管理服务可保留诊断能力,隔离恢复并重新验证通过后才允许相关执行;既有应用也不得豁免 AC-12、AC-21 的隔离要求。
+
+### 2.6 应用交付与镜像分发
+
+- **AC-24** — WHEN 使用 F086 的默认应用包交付, THE SYSTEM SHALL 使用冻结的预制运行环境和版本化代码、依赖产物完成准备与启动;仅业务源码变化时不构建或推送新的 OCI 应用镜像。
+- **AC-25** — THE SYSTEM SHALL 支持与 Docker 相同的 Python 3.11、Node.js 20、static 应用包、用户完整镜像和显式兼容源码构建;兼容构建使用受限、一次性、无节点守护进程权限的执行体,不依赖 DinD 或 docker.sock。所选模式前置缺失时在审批前拒绝,不静默转换模式。
+- **AC-26** — THE SYSTEM SHALL 将应用包的代码、依赖和编译产物通过 backend 持久保存并向执行位置分发;镜像交付和兼容构建则保证冻结镜像可供目标执行节点取得。默认应用包不以具备应用镜像推送仓库为必要条件。
+- **AC-27** — THE SYSTEM SHALL 使镜像仓库凭据只供受控拉取或构建使用,不出现在应用可读的环境、挂载文件、日志或用户界面。
+- **AC-28** — IF 准备、镜像推送或拉取失败, THEN THE SYSTEM SHALL 保留对应阶段和安全的可读原因;镜像不可拉、缺包、错架构和材料损坏不归为资源不足。
+- **AC-29** — THE SYSTEM SHALL 保留当前运行、待上线以及停用应用待运行/上次运行版本所引用的冻结镜像与产物可恢复性,清理不得使这些版本无法重建或重新上线。
+
+### 2.7 集群能力探测
+
+- **AC-30** — WHEN 安装、升级或启动运行时服务, THE SYSTEM SHALL 检查网络隔离实际生效性、最小权限、所选隔离能力、镜像可取得性、平台材料接口、可用持久存储及其卷类型/拓扑、最小资源和所选兼容构建前置;输出逐项结论、时间和原因,不能只依据组件名称推断能力成立。
+- **AC-31** — IF 所选发布路径的硬前置不满足, THEN THE SYSTEM SHALL 拒绝新的相关执行并报告原因;既有应用仅在仍满足 AC-12、AC-21 时继续运行。必需隔离失效按 AC-23 拒绝,不能通过配置覆盖。缺少更强沙箱或仅有节点本地持久卷时明确告警,节点绑定和故障恢复限制持续可见;更强沙箱回退仅可依 AC-19 回到满足必需隔离的默认档。无动态存储供应器但有符合约束的预置持久卷时允许使用,不因缺少默认 StorageClass 单独失败。
+- **AC-32** — THE SYSTEM SHALL 将最近一次探测结果、硬前置失败原因、可选沙箱档位的回退记录及实际隔离档位呈现在既有运行环境状态接口,供平台超管和部署工具查询,不新增业务管理页面。
+
+### 2.8 每应用数据与持久卷
+
+- **AC-33** — THE SYSTEM SHALL 为应用 SQLite 工作数据提供独占持久存储,延续已有连接环境变量;在存储支持的故障范围内,容器/Pod 重建及可支持的重新调度不丢失已提交数据。平台附件、快照及源代码真身仍由 backend 保存。
+- **AC-34** — IF 数据卷不满足 SQLite 锁、同步和单写要求, THEN THE SYSTEM SHALL 拒绝上线并报告存储不兼容;节点本地卷须报告节点绑定,不假定它可以跟随跨节点调度。NFS/SMB 等不满足既定 SQLite WAL 合同的存储不能作为静默替代。
+- **AC-35** — THE SYSTEM SHALL 在停用、重建、manager 重启、升级和卸载运行时层时保留应用持久卷;仅在既有 owner 显式删除应用流程及确认没有活动写入者后清理对应数据。
+
+### 2.9 双形态一致性
+
+- **AC-36** — THE SYSTEM SHALL 通过既有发布、审批、权限、CLI/SDK、日志、附件、数据、预览、停用/恢复及运行时开关用例在 K8s 上的回归;Docker 回归继续通过,不能用模拟执行器结果代替真实平台链路。
+- **AC-37** — THE SYSTEM SHALL 保持业务状态、待上线原因、审计事件、通知和 CLI/MCP 输出的语义及文案与已有 Owner 一致,形态信息仅进入运维诊断。
+- ~~**AC-38**~~ — 已退役;编号不复用。用例追溯和复用纪律见第 3 节。
+
+### 2.10 安装、升级和卸载
+
+- **AC-39** — THE SYSTEM SHALL 交付可复核的集群部署资源与安装、升级、卸载、最小权限、存储和镜像供应说明;覆盖运行时服务、入口/出站代理、任务、应用卷及前置探测。平台核心继续按既有方式部署;全部组件保持开源,不引入商业授权执行点。
+- **AC-40** — WHEN 升级或重启 manager 及相关运行时服务, THE SYSTEM SHALL 保持已有应用实例不被连带重建,并复核前置和期望态;代理升级按已有连接/排空合同处理,不承诺进程被强杀后原 TCP 连接不变。
+- **AC-41** — WHEN 卸载运行时层, THE SYSTEM SHALL 仅回收本次安装明确拥有的执行和控制资源,保留应用代码、依赖、快照、附件及数据库持久卷;平台其它功能不受影响,入口按既有部署开关呈现。
+
+### 2.11 离线与执行治理
+
+- **AC-42** — WHERE 客户环境不能访问公网, THE SYSTEM SHALL 支持预先供应精确服务/运行/准备镜像与完整依赖材料后完成默认应用包发布、审批、启动和重建;镜像可通过经目标运行时验证的预灌方式或内网仓库供应。需要镜像推送的兼容构建使用配置的内网分发端点,缺少它只阻断该路径,不以公共仓库兜底或阻断已满足前置的应用包模式。
+- **AC-43** — THE SYSTEM SHALL 对准备、探活、构建和数据维护任务设置期限、CPU/内存和并发限制;超限可查询、取消或超时后有界回收,保留可读阶段和原因,不能占满集群或影响已上线应用。
+
+### 2.12 F086 承接与可靠性补充
+
+- **AC-44** — THE SYSTEM SHALL 将冻结代码、依赖锁和材料分发到实际执行位置;venv 等可执行环境按所选镜像、架构和依赖生成,Pod 重建后可从持久产物重建,不依赖 manager 的本机目录或上传者现成虚拟环境。
+- **AC-45** — WHEN 只修改业务代码且依赖输入兼容, THE SYSTEM SHALL 复用已验证依赖材料;WHEN profile、架构、依赖锁、离线材料或影响安装的源码变化, THE SYSTEM SHALL 阻止错误缓存复用。缓存丢失只影响重新准备,不丢失已提交产物。
+- **AC-46** — THE SYSTEM SHALL 仅经 backend 的范围化接口下载源码、读写产物、上传/下载数据库快照和处理附件;manager 与应用不获得平台 MinIO 或关系数据库凭据,SDK0.1.1既有附件合同保持可用。
+- **AC-47** — THE SYSTEM SHALL 将准备、预检和数据维护材料身份限制在当前有效操作,运行期初始化材料身份限制在当前有效部署归属及冻结引用;准备操作完成不使合法运行实例失去重建所需材料。运行期初始化的短期凭据过期时,可由 backend 重新核验当前归属及冻结范围后签发新凭据,不依赖 manager 在线,不得仅凭已过期或撤销凭据续期。两类身份均不授予通用控制能力,且按 AC-15 与业务容器隔离;已停用或撤销归属、过期凭据直接访问、重放、篡改、跨租户或跨应用请求被拒绝,未确认或摘要不符材料不执行。
+- **AC-48** — THE SYSTEM SHALL 以 backend 持久操作、当前归属、事实和路由/策略投影为权威;重复通知、manager 重启或多进程竞争不能产生重复正式实例、重复审批或提前释放资源。
+- **AC-49** — IF 无法确认旧实例已停止写入, THEN THE SYSTEM SHALL 保守保留资源和写入归属,不启动第二个正式数据库写入者;超时后返回可查的阻塞原因,不能把 API 删除请求被接受当作停止证明。
+- **AC-50** — WHEN 更新、迁移或恢复涉及生产 SQLite, THE SYSTEM SHALL 确认生产写入者停止,在隔离下做一致备份/迁移或恢复;预览与预检使用独立数据。新旧正式实例即使处于切换窗口也不能同时写生产数据。
+- **AC-51** — WHEN DDL 前备份上传, THE SYSTEM SHALL 等待 backend 对快照内容、摘要和关联记录持久确认后才执行 DDL;确认失败禁止迁移,DDL 后启动失败保留维护/数据可能改变状态,不自动恢复旧数据库。恢复继续要求自然人 owner 显式确认并保护当前数据。
+- **AC-52** — WHEN 准备、探活、迁移或上线取消、超时、执行服务被杀或响应丢失, THE SYSTEM SHALL 按 F086 期限对账执行体、临时数据和预占,记录清理是否完成;未知活动写入者或唯一未确认备份不能被回收。Pod、服务或卷身份被同名替换时不得按名字误认原执行体。
+- **AC-53** — THE SYSTEM SHALL 保持 HTTP/WebSocket 入口及 CONNECT 出站连接的既有授权、撤权和失联拒绝期限;manager 停止超过路由短缓存期限时,新访问仍按 backend 的持久投影处理,不能要求每次访问查询 manager。
+- **AC-54** — THE SYSTEM SHALL 保持 amd64/arm64 运行材料隔离及原生镜像交付;所选 profile 与目标架构不兼容时在审批前拒绝。镜像可用、依赖材料可用和平台能力接口可用分别核验,不能互相代替。
+- **AC-55** — THE SYSTEM SHALL 提供可重复执行的 K8s 专有部署验收和既有平台 E2E 记录,明确每项为静态、模拟、真实执行、未验证或受环境限制;原有非测试资源在验收前后保持不变,不用单节点结果声称跨节点故障恢复已通过。
+- **AC-56** — WHEN 后续发布或升级执行本 Feature, THE SYSTEM SHALL 保持 Docker 既有源码/应用包/完整镜像路径和数据合同不退化;共享权限、审批或模型修复按发布线归属继承,不在 K8s 分支复制出另一套业务规则。
+
+## 3. 边界情况与用例纪律
+
+- 发布审批及数据维护的 Owner 不变;预览的既有 owner 能力例外和普通访问者权限交集不因 K8s 改变。预检失败不创建审批单或正式版本;只有审批通过后的上线准入不足才进入待上线(资源不足),两阶段的重试动作按 AC-08 区分。
+- 包源不可达、缺 wheel、摘要错误、错 ABI、镜像拉取失败、PVC 未绑定、健康失败与资源不足各自留有阶段证据。前置检查不能跳过真实启动验证。
+- 持久卷类型及节点拓扑影响恢复能力;无动态供应器不等于没有持久卷,单节点本地盘也不等于跨节点持久存储。
+- 资源预算可读不等于一定可调度;有界执行中遇到调度失败须清理候选,不能为满足新旧并存而违反单写。
+- 应用与准备任务不能读取控制身份;命名空间权限声明不能替代对特权、宿主挂载、跨命名空间和网络绕行的实际拒绝验证。运行期初始化按当前部署归属取得材料,不延长已完成准备操作的权限,也不把材料身份交给业务容器;凭据签发和续期须重新授权。
+- 必需隔离属于硬前置;默认档也必须满足入口与出站限制。可选强沙箱回退与必需隔离失效分开处理,不存在未隔离状态下继续部署的覆盖项。
+- 未被引用的临时目录可回收,已提交产物和正式数据不能因任务失败、卸载或工作负载级联删除而丢失。
+- 产品面复用既有用例,不另写断言分叉;K8s 专有测试覆盖集群权限、卷、调度、隔离、镜像供应和执行体生命周期。每个允许路径配套拒绝/失败路径,AC 编号完整追溯。
+- 当前验收环境只有一台 amd64 节点。真实跨节点调度及节点丢失后的卷迁移需要另一节点与适配存储,未执行时明确记录,不视为通过。
+- DM8 按本会话已确认口径只做相关模型/迁移规范静态检查;真实驱动/数据库回归由中央环境承担,不借用旧 schema 的验收结果证明新迁移已执行。
+
+## 4. 设计与实现指针
+
+spec 确认后编写 `design.md` 与 `tasks.md`。具体执行器、材料投递、Pod 卷、最小权限/准入、网络拓扑、兼容构建器、容量与失败归因、集群探测及验收步骤均由设计承载,本规格不将尚未实现的机制写成现状。
+
+| 文档 | 作用 |
+| --- | --- |
+| [discovery.md](./discovery.md) | 会话输入、现有实现边界、原规格变更及确认门 |
+| [review-evidence.md](./review-evidence.md) | spec 同一代理审查、覆盖与引用检查 |
+| [cluster-preflight.md](./cluster-preflight.md) | 189 的只读环境盘点及证据边界 |
+| [release-contract.md](../release-contract.md) | Owner、INV-33 和 F086 已确认扩展 |
+| [constitution.md](../../../docs/constitution.md) | C1–C8 架构约束 |
diff --git a/features/v3.0.0/059-k8s-runtime-backend/task-reviews.md b/features/v3.0.0/059-k8s-runtime-backend/task-reviews.md
new file mode 100644
index 0000000000..48c91048c8
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/task-reviews.md
@@ -0,0 +1,3587 @@
+# F059 L1 task reviews
+
+## 基线
+
+- 修改前 runtime-manager:437 passed、10 skipped(4.65s);跳过项为既有需 Docker 的真实测试。本地回环端口在沙箱内受限,首次失败已按同一选择在授权的本地测试环境重跑,不能作为代码失败基线。
+- 日志:`/private/tmp/f059-runtime-baseline.log`;本次尚未运行真实 K8s/平台验收。
+
+## Task Review: T001
+
+**任务**:K8s 配置及部署描述。**类型**:基础设施。
+**文件**:`runtime_manager/k8s_config.py`、`runtime_manager/config.py`。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 独立运行包,未读取平台配置/存储,无 backend 编排 SDK |
+| 2 | 命名 | PASS | Config / KubernetesConfig 独立语义 |
+| 3 | 序列化 | N/A | 没有 HTTP 输出 |
+| 4 | 数据库 | N/A | 无数据库修改 |
+| 5 | 前端 | N/A | 无前端修改 |
+| 6 | 信息泄漏 | PASS | token 按文件读取,不存入 repr,错误不返回文件内容 |
+| 7 | 设计同步 | PASS | design 4.1 登记配置实现但尚未接线 |
+
+**验证**:默认Docker、K8s-v2门、namespace拒绝、descriptor范围/预算及token轮转断言通过;Ruff通过,逐文件arch-guard无违规。
+**元数据验证**:文件范围 PASS;配对测试 N/A(配置基础设施);依赖 PASS。
+**结果**:PASS。
+
+## Task Review: T006
+
+**任务**:受限 K8s REST client 契约与失败用例。**类型**:测试。
+**文件**:`tests/test_k8s_client.py`;覆盖 AC-03、AC-04、AC-13、AC-14、AC-48、AC-49、AC-52。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 测试子集 |
+| 2 | 命名/AC | PASS | test_ 方法与完整AC;只定义合同和失败输入 |
+| 3 | 序列化 | N/A | 测试子集 |
+| 4 | 数据库 | N/A | 无数据库 |
+| 5 | 前端 | N/A | 无前端 |
+| 6 | 信息泄漏 | PASS | 测试假token,专门断言错误不泄漏响应详情 |
+| 7 | 设计同步 | PASS | 与design 4.5、6.1的scope/UID/RV及410重列合同对应 |
+
+**红阶段**:首轮12个配置用例通过、17个API用例因尚未实现k8s_client模块报错;补充有限重试、lease失效前取消和列表快照不一致场景。配对T007完成后必须绿阶段通过,当前不声称API可用。
+**元数据验证**:文件范围 PASS;配对T007为后续实现;依赖T001 PASS。
+**结果**:PASS。
+
+## Task Review: T007
+
+**任务**:受限 K8s REST client。**类型**:实现。
+**文件**:`src/runtime-manager/runtime_manager/k8s_client.py`。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 无平台存储依赖;只公开namespace资源和app日志;不把删除受理/404当停止事实。 |
+| 2 | 命名/AC | PASS | 完整AC及明确输入输出 |
+| 3 | 序列化 | PASS | 运行层固定资源文档;不涉及平台业务响应 |
+| 4 | 数据库 | N/A | 无ORM/SQL修改 |
+| 5 | 前端 | N/A | 无前端修改 |
+| 6 | 信息泄漏 | PASS | 假凭据用于测试;错误不含响应/传输详情 |
+| 7 | 设计同步 | PASS | design 4.1登记进展;D1–D8未变更 |
+
+**验证**:32 passed;token轮转、UID/RV条件、重列及失败分类通过;逐文件arch-guard和Ruff通过。
+**元数据验证**:文件范围 PASS;配对测试 PASS;依赖 PASS。
+**结果**:PASS。
+
+## Task Review: T008
+
+**任务**:Pod/Service/PVC/Secret 模板:契约与失败用例。**类型**:测试。
+**文件**:`src/runtime-manager/tests/test_k8s_templates.py`。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 覆盖AC完整列示;用例断言可观察模板/拒绝结果,无平台写入。 |
+| 2 | 命名/AC | PASS | 完整AC及明确输入输出 |
+| 3 | 序列化 | N/A | 运行层固定资源文档;不涉及平台业务响应 |
+| 4 | 数据库 | N/A | 无ORM/SQL修改 |
+| 5 | 前端 | N/A | 无前端修改 |
+| 6 | 信息泄漏 | PASS | 假凭据用于测试;错误不含响应/传输详情 |
+| 7 | 设计同步 | PASS | design 4.1登记进展;D1–D8未变更 |
+
+**验证**:红阶段29例因k8s_templates缺失报错;后续T009绿阶段29 passed。
+**元数据验证**:文件范围 PASS;配对测试 N/A;依赖 PASS。
+**结果**:PASS。
+
+## Task Review: T009
+
+**任务**:Pod/Service/PVC/Secret 模板。**类型**:实现。
+**文件**:`src/runtime-manager/runtime_manager/k8s_templates.py`。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 输入为冻结PodPlan,无用户PodSpec;无SA token/hostPath/特权;helper命令将在后续T031接线。 |
+| 2 | 命名/AC | PASS | 完整AC及明确输入输出 |
+| 3 | 序列化 | PASS | 运行层固定资源文档;不涉及平台业务响应 |
+| 4 | 数据库 | N/A | 无ORM/SQL修改 |
+| 5 | 前端 | N/A | 无前端修改 |
+| 6 | 信息泄漏 | PASS | 假凭据用于测试;错误不含响应/传输详情 |
+| 7 | 设计同步 | PASS | design 4.1登记进展;D1–D8未变更 |
+
+**验证**:29 passed;固定生产位置、临时期限、RO挂载、隔离卷、容器权限及ClusterIP通过;逐文件arch-guard和Ruff通过。
+**元数据验证**:文件范围 PASS;配对测试 PASS;依赖 PASS。
+**结果**:PASS。
+
+## Task Review: T010
+
+**任务**:停止证据三分类与同 UID 收尾(测试)。**文件**:`src/runtime-manager/tests/test_k8s_lifecycle.py`。
+
+| 检查项 | 结果 | 说明 |
+| --- | --- | --- |
+| 命名/AC | PASS | test_行为命名,完整列出9项AC |
+| 前端/i18n | N/A | 运行层测试,无业务文案 |
+| 元数据 | PASS | 文件范围明确;依赖T007已完成;配对T011 |
+
+**红阶段**:22例因k8s_lifecycle尚未实现报错;Ruff通过。测试分别验证fresh API、UID、未调度/可信终态/unknown及先提交事实后移除finalizer。
+**结果**:PASS。
+
+## Task Review: T002
+
+**任务**:资源账本、材料授权与控制器字段。**类型**:基础设施。
+**文件**:`src/backend/bisheng/app_runtime/domain/models/runtime_kubernetes.py`、`src/backend/bisheng/app_runtime/domain/models/runtime_delivery.py`。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 平台模型继承 ScopedRuntimeRecord,tenant_id索引/create_time/update_time、公钥而非私钥、JsonType与36位新UUID符合约束。旧RuntimeRecord ID不变。 |
+| 2 | 命名/AC | PASS | 与任务领域及完整AC一致 |
+| 3 | 序列化 | PASS | 继承SQLModelSerializable |
+| 4 | 数据库 | PASS | 不在Service写SQL;迁移无数据读写 |
+| 5 | 前端 | N/A | 无UI修改 |
+| 6 | 信息泄漏 | PASS | 无真实私钥/宿主凭据;保留安全错误分类 |
+| 7 | 设计同步 | PASS | design 4.1与6.2同步 |
+
+**验证**:MySQL DDL编译及SQLite建表/legacy授权default/新旧传输唯一性smoke通过;账本正式用例随T014补齐。
+**元数据验证**:文件范围 PASS;配对测试 N/A;依赖 PASS。
+**结果**:PASS。
+
+## Task Review: T003
+
+**任务**:Schema 扩展与租户登记。**类型**:基础设施。
+**文件**:`src/backend/bisheng/core/database/alembic/versions/f059_runtime_kubernetes.py`、`src/backend/bisheng/app_runtime/domain/models/__init__.py`。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 真实旧head merge_vibe_f077_heads之后新增单head f059_runtime_kubernetes;revision仅DDL。models包已被tenant_filter预加载,因此在包__init__导入新表即可,不修改ContextVar。 |
+| 2 | 命名/AC | PASS | 与任务领域及完整AC一致 |
+| 3 | 序列化 | N/A | 无平台业务HTTP响应 |
+| 4 | 数据库 | PASS | 不在Service写SQL;迁移无数据读写 |
+| 5 | 前端 | N/A | 无UI修改 |
+| 6 | 信息泄漏 | PASS | 无真实私钥/宿主凭据;保留安全错误分类 |
+| 7 | 设计同步 | PASS | design 4.1与6.2同步 |
+
+**验证**:alembic heads单head;test_alembic_single_head及既有ledger/artifact测试通过。DM8仍仅类型/约束静态检查,未连接真实DM8。
+**元数据验证**:文件范围 PASS;配对测试 N/A;依赖 PASS。
+**结果**:PASS。
+
+## Task Review: T004
+
+**任务**:密码依赖的可重复镜像供应。**类型**:基础设施。
+**文件**:`src/runtime-manager/pyproject.toml`、`src/runtime-manager/uv.lock`。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 新增cryptography==50.0.2,其他已锁定版本未变;官方Apache-2.0或BSD-3-Clause双许可。锁含Linux amd64/arm64轮子,镜像实构留到T063。 |
+| 2 | 命名/AC | PASS | 与任务领域及完整AC一致 |
+| 3 | 序列化 | N/A | 无平台业务HTTP响应 |
+| 4 | 数据库 | N/A | 不在Service写SQL;迁移无数据读写 |
+| 5 | 前端 | N/A | 无UI修改 |
+| 6 | 信息泄漏 | PASS | 无真实私钥/宿主凭据;保留安全错误分类 |
+| 7 | 设计同步 | PASS | design 4.1与6.2同步 |
+
+**验证**:uv lock通过;临时独立目录安装50.0.2并执行Ed25519签名/验签/篡改拒绝通过;未修改共享backend环境。
+**元数据验证**:文件范围 PASS;配对测试 N/A;依赖 PASS。
+**结果**:PASS。
+
+## Task Review: T011
+
+**任务**:停止证据三分类与同 UID 收尾。**类型**:实现。
+**文件**:`src/runtime-manager/runtime_manager/k8s_lifecycle.py`。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 先fresh API核同UID/删除/status基线;先平台提交停止事实再CAS移除finalizer;unknown不释放。未接入业务执行器,不声称集群行为已验收。 |
+| 2 | 命名/AC | PASS | 与任务领域及完整AC一致 |
+| 3 | 序列化 | N/A | 无平台业务HTTP响应 |
+| 4 | 数据库 | N/A | 不在Service写SQL;迁移无数据读写 |
+| 5 | 前端 | N/A | 无UI修改 |
+| 6 | 信息泄漏 | PASS | 无真实私钥/宿主凭据;保留安全错误分类 |
+| 7 | 设计同步 | PASS | design 4.1与6.2同步 |
+
+**验证**:配对T010已完成;22 passed;Ruff及逐文件arch-guard通过。
+**元数据验证**:文件范围 PASS;配对测试 PASS;依赖 PASS。
+**结果**:PASS。
+
+## Task Review: T012
+
+**任务**:独立准入和 status 来源约束:契约与失败用例。**类型**:测试。
+**文件**:`src/runtime-manager/tests/test_k8s_admission.py`。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 23项行为测试覆盖已批准模板、固定资源/凭据/权限、kubelet终态来源、收尾与无backend回调;完整AC列示,纯fake输入。 |
+| 2 | 命名/AC | PASS | 与任务领域及完整AC一致 |
+| 3 | 序列化 | N/A | 无平台业务HTTP响应 |
+| 4 | 数据库 | N/A | 不在Service写SQL;迁移无数据读写 |
+| 5 | 前端 | N/A | 无UI修改 |
+| 6 | 信息泄漏 | PASS | 无真实私钥/宿主凭据;保留安全错误分类 |
+| 7 | 设计同步 | PASS | design 4.1与6.2同步 |
+
+**验证**:红阶段23 failed均因k8s_admission模块未实现;Ruff通过,依赖T009已完成。
+**元数据验证**:文件范围 PASS;配对测试 N/A;依赖 PASS。
+**结果**:PASS。
+
+T004来源:[官方50.0.2变更记录](https://cryptography.io/en/latest/changelog/#v50-0-2)、[固定版本许可证](https://github.com/pyca/cryptography/blob/50.0.2/LICENSE)、[官方轮子安装说明](https://cryptography.io/en/latest/installation/)。直接PyPI TLS失败后用会话授权代理生成锁成功。
+
+## Task Review: T014
+
+**任务**:平台资源账本 Repository(测试)。**文件**:`src/backend/test/app_runtime/test_runtime_kubernetes_repository.py`。
+
+| 检查项 | 结果 | 说明 |
+| --- | --- | --- |
+| 命名/AC | PASS | 完整AC;真实SQLite事务、无集群/外部依赖 |
+| 前端/i18n | N/A | 无UI |
+| 元数据 | PASS | 依赖T002/T003通过;配对T015;单文件范围 |
+
+**红阶段**:14例均因runtime_kubernetes_repository尚未实现报错;Ruff通过。覆盖CAS/租约、UID不变、scope、未知写入者保留预占、cleanup计划不随操作终态消失。
+**结果**:PASS。
+
+## Task Review: T013
+
+**任务**:独立准入和 status 来源约束(实现)。**文件**:`src/runtime-manager/runtime_manager/k8s_admission.py`。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 独立HTTPS准入进程无集群身份;资源计划由专用只读HMAC身份回读,status路径不回读、不写事实;CNI限制调用方是后续安装硬门。 |
+| 2 | 命名 | PASS | 领域类名和可观察方法语义明确 |
+| 3 | 序列化 | PASS | 固定AdmissionReview或ORM对象;平台API尚未接入 |
+| 4 | 数据库 | N/A | 租户scope校验,非手写tenant WHERE;无SDK/外部存储 |
+| 5 | 前端 | N/A | 无UI |
+| 6 | 信息泄漏 | PASS | 安全错误;Secret计划仅摘要;不存workload私钥 |
+| 7 | 设计同步 | PASS | design 4.1与6.2同步;D1–D8不变 |
+
+**验证**:32 passed;增加HTTP入口、数量规范化、Secret摘要和只读身份轮转/重定向测试;修复初版HTTP入口422及quantity格式差异。 Ruff、逐文件arch-guard通过。
+**元数据验证**:文件范围 PASS;配对测试 PASS;依赖 PASS。
+**结果**:PASS。
+
+## Task Review: T015
+
+**任务**:平台资源账本 Repository(实现)。**文件**:`src/backend/bisheng/app_runtime/domain/repositories/runtime_kubernetes_repository.py`。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | Repository继承既有事务UoW;租约行锁、资源UID/策略、生产writer互斥和容量释放均在数据库事务中;Service不写SQL。 |
+| 2 | 命名 | PASS | 领域类名和可观察方法语义明确 |
+| 3 | 序列化 | PASS | 固定AdmissionReview或ORM对象;平台API尚未接入 |
+| 4 | 数据库 | PASS | 租户scope校验,非手写tenant WHERE;无SDK/外部存储 |
+| 5 | 前端 | N/A | 无UI |
+| 6 | 信息泄漏 | PASS | 安全错误;Secret计划仅摘要;不存workload私钥 |
+| 7 | 设计同步 | PASS | design 4.1与6.2同步;D1–D8不变 |
+
+**验证**:16 passed;新增容量释放后拒绝Pod创建及旧Pod不追认保护状态用例。SQLite证明业务控制,MySQL并发留到平台验收。 Ruff、逐文件arch-guard通过。
+**元数据验证**:文件范围 PASS;配对测试 PASS;依赖 PASS。
+**结果**:PASS。
+
+阶段回归:runtime全套547 passed、10 skipped(后续新增5项准入边界用例单独通过);backend既有账本/产物/单head共15 passed。尚未执行集群及真实平台验收。
+
+## Task Review: T020
+
+**任务**:material-v1编码与验签(测试)。**文件**:backend `test/app_runtime/test_runtime_material_protocol.py` 与 manager `tests/test_material_protocol.py`。
+
+| 检查项 | 结果 | 说明 |
+| --- | --- | --- |
+| 命名/AC | PASS | AC-15、AC-46、AC-47完整列举;固定414-byte/11行金样、真实Ed25519、重放与歧义拒绝 |
+| 前端/i18n | N/A | 无UI |
+| 元数据 | PASS | T004完成,两个独立部署包各自测试,不引入跨应用运行依赖 |
+
+**红阶段**:两端模块均尚不存在;Ruff通过;完整红阶段日志见临时验证文件。
+**结果**:PASS。
+
+## Task Review: T021
+
+**任务**:material-v1编码与验签(实现)。**文件**:backend `runtime_material_protocol.py` 与 manager `material_protocol.py`。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 两端独立部署、不跨包import;只用cryptography,不自行实现密码算法 |
+| 2 | 命名 | PASS | MaterialProof表示验签结果,不表示当前归属授权 |
+| 3 | 序列化 | PASS | 固定11行wire、严格JSON/整数/UUID/头/query;API响应尚待接入 |
+| 4 | 数据库 | N/A | 无SQL |
+| 5 | 前端 | N/A | 无UI |
+| 6 | 信息泄漏 | PASS | Proof不repr ticket;错误不含内容或私钥;nonce不可确认时拒绝 |
+| 7 | 设计同步 | PASS | 精确保留design 6.2.1路径/414字节/hash;4.1同步阶段 |
+
+**验证**:manager41 passed、backend40 passed;包含真实跨实现双向验签、原始头重复、JSON溢出/代理项、严格60秒边界和nonce失败。两实现当前内容一致且SHA核对通过。Ruff、逐文件arch-guard通过。
+**元数据验证**:文件范围 PASS;配对T020 PASS;依赖T004 PASS。
+**结果**:PASS。此结果不代表材料委托/票据/流式存储已接入。
+
+T002/T021后续核对:材料 key_id 存储宽度修正为64,与已确认wire一致,并新增数据库列/最大长度验签守卫。T027注册文件改为main以保留固定根路径P;不是协议路径变更。
+
+检查点:runtime 593 passed、10 skipped;backend 71 passed,随后新增key_id宽度守卫1 passed,uv frozen/offline锁检查通过。
+
+## Task Review: T073
+
+**任务**:F086字节计数器继承与迁移汇合。**类型**:基础设施。
+**文件**:`f059_merge_f086_heads.py`、`test_runtime_model_contracts.py`;输入为F086本地来源提交`667546bb77`中的Vibe专有模型/迁移/静态测试。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 无SDK、外部接口或领域跨层调用 |
+| 2 | 命名 | PASS | revision采用f059可读ID |
+| 3 | 序列化 | N/A | 无HTTP |
+| 4 | 数据库 | PASS | 显式merge两原parent;BIGINT只扩容不缩窄,无数据回填;历史authorization_id和待绑定uid的NULL是限定合同 |
+| 5 | 前端 | N/A | 无UI |
+| 6 | 信息泄漏 | PASS | 无敏感数据 |
+| 7 | 设计同步 | PASS | design和prerequisites记录来源/约束及未交付边界 |
+
+**验证**:27 passed(单head、模型类型/约束及K8s账本);原F086同一来源21 passed。DM8使用通用编译器走shared helper的静态分支,未运行真实DM8。Ruff/逐文件arch-guard通过。
+**元数据验证**:两个增量目标;依赖T003完成;模型/迁移基础设施无需配对业务测试。
+**结果**:PASS。
+
+T009/T013增量L1:应用凭据支持只给app容器的Secret引用,材料Secret不混用;backend审批投影仅接收/存放Secret内容与Pod环境值摘要。补测红阶段已记录,绿阶段模板+准入68 passed;禁止原始workload私钥进入backend,manager登记须先投影,准入对实际K8s请求独立投影比较。
+
+最终本地检查点(源提交`ea3b1f6afb68da2b52ba4fd1fcf0bb5e060510ba`):runtime **600 passed、10 skipped**;backend **82 passed**;公共启动补丁 **17 passed**。真实K8s/平台、镜像CI及新revision数据库升级均未执行。完整边界与本地日志见[validation.json](./validation.json)。公共发布线授权仍待用户答复。
+
+## Task Review: T074
+
+**任务**:发布线继承的迁移图汇合。**类型**:基础设施。
+**文件**:`src/backend/bisheng/core/database/alembic/versions/f059_merge_release_heads.py`。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 仅Alembic历史汇合,不引入领域依赖 |
+| 2 | 命名 | PASS | f059明确可读revision |
+| 3 | 序列化 | N/A | 无API |
+| 4 | 数据库 | PASS | 实测三head的显式merge,无DDL/DML;保留原父链,降级只分叉 |
+| 5 | 前端 | N/A | 无前端 |
+| 6 | 信息泄漏 | PASS | 无凭据和运行数据 |
+| 7 | 设计同步 | PASS | 维持现有单head和继承合同;当前进展回写,不改D1–D8 |
+
+**验证**:合并提交98fbba032;`alembic heads`唯一f059_merge_release_heads;82项模型/协议/资源回归通过(`/private/tmp/f059-backend-inheritance-final.log`);Ruff和逐文件arch-guard通过。
+**元数据验证**:文件范围PASS;配对测试N/A(基础设施,使用既有单head门禁);依赖T073 PASS。
+**结果**:PASS。
+
+## Task Review: T005
+
+**任务**:F086公共修复继承核对。**类型**:基础设施。
+**文件**:`prerequisites.md`、`prerequisites.json`。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 公共启动修复先发布线9d156f261c,再由F059普通合并98fbba032继承 |
+| 2 | 命名 | PASS | 来源SHA、发布分支、继承提交明确 |
+| 3 | 序列化 | PASS | JSON收据与Markdown一致 |
+| 4 | 数据库 | PASS | BIGINT沿用单一来源;迁移单head;DM8仅静态 |
+| 5 | 前端 | PASS | 继承适配保留审批与应用状态合同;98项交互通过,质量错误与发布线逐条比较无新增 |
+| 6 | 信息泄漏 | PASS | 收据仅提交/验证事实,无集群或对象存储凭据 |
+| 7 | 设计同步 | PASS | 记录已继承与尚未部署边界,不宣称E2E完成 |
+
+**验证**:发布远端SHA一致;三个生产公共修复文件与发布线diff为空。公共修复17项通过,权限/审计197 passed、3 xfailed;运行层600 passed、10 skipped;资源/材料/模型82 passed。自动审批拒绝批量修改后采用逐文件验证。主工作区未改动。
+**元数据验证**:文件范围PASS;配对测试N/A(继承核对);依赖T074 PASS。
+**结果**:PASS。
+
+## Task Review: T016
+
+**任务**:控制器/资源 Service 契约与失败用例。**类型**:测试。
+**文件**:`src/backend/test/app_runtime/test_runtime_kubernetes_service.py`;覆盖 AC-03、AC-07、AC-08、AC-13、AC-14、AC-48、AC-49、AC-52。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 测试子集 |
+| 2 | 命名/AC | PASS | 完整AC编号;输入/输出与拒绝行为,不模拟核心账本事务 |
+| 3 | 序列化 | N/A | 测试子集 |
+| 4 | 数据库 | N/A | 复用真实SQLite资源账本fixture |
+| 5 | 前端 | N/A | 无前端 |
+| 6 | 信息泄漏 | PASS | 假镜像/UID;验证无明文Secret/环境值进入账本 |
+| 7 | 设计同步 | N/A | 既有6.2控制合同 |
+
+**红阶段**:27项因目标RuntimeKubernetesService尚未实现而报ModuleNotFoundError(`/private/tmp/f059-control-service-red.log`);不是接口可用证据。Ruff与逐文件arch-guard通过。
+**元数据验证**:文件范围PASS;配对T017后续实现;依赖T015 PASS。
+**结果**:PASS。
+
+## Task Review: T017
+
+**任务**:控制器租约和资源事实Service。**类型**:实现。
+**文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_kubernetes_service.py`。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | Service→事务Repository;无FastAPI、集群SDK、MinIO或直接SQL |
+| 2 | 命名 | PASS | 类/安全拒绝原因沿用运行层约定 |
+| 3 | 序列化 | PASS | 既有内部运行协议的固定字段;只读准入不输出租户/holder/私钥 |
+| 4 | 数据库 | PASS | 先验证可信node绑定,再tenant_execution_scope;Repository提交资源意图后才返回,不持SQL事务等待webhook |
+| 5 | 前端 | N/A | 无新增前端 |
+| 6 | 信息泄漏 | PASS | Secret仅摘要,env原值拒绝,错误只用稳定原因;投影深复制 |
+| 7 | 设计同步 | PASS | design 4.3/6.2及修订历史已同步当前实现;HTTP及集群仍未交付 |
+
+**验证**:27项首轮Service通过;新增边界测试先复现6项失败后修复;最终34项Service与16项真实SQLite账本共同通过(`/private/tmp/f059-control-service-final.log`),Ruff与逐文件arch-guard通过。操作终态仅保留同UID收尾,删除必须已记录stop;未知停止证据不释放。
+**元数据验证**:文件范围PASS;配对T016已完成;依赖T015/T016 PASS。
+**结果**:PASS。
+
+## Task Review: T018
+
+**任务**:内部控制API契约与失败用例。**类型**:测试。
+**文件**:`src/backend/test/app_runtime/test_runtime_kubernetes_api.py`;覆盖 AC-03、AC-04、AC-13、AC-14、AC-15、AC-48。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 测试子集 |
+| 2 | 命名/AC | PASS | 完整AC;真实HMAC和ASGI,核心Service/SQLite账本不mock |
+| 3 | 序列化 | N/A | 测试子集 |
+| 4 | 数据库 | N/A | 复用已验证事务fixture |
+| 5 | 前端 | N/A | 无前端 |
+| 6 | 信息泄漏 | PASS | 假服务密钥;未知nonce存储错误不应出现在HTTP响应 |
+| 7 | 设计同步 | N/A | 既有控制/只读准入合同 |
+
+**红阶段**:新端点模块未实现导致13 errors,生产聚合路由缺少目标路径导致1 failed(`/private/tmp/f059-control-api-red.log`);非交付证据。Ruff与逐文件arch-guard通过。
+**元数据验证**:文件范围PASS;依赖T017 PASS;配对T019后续实现。
+**结果**:PASS。
+
+## Task Review: T019
+
+**任务**:内部控制API接入。**类型**:实现。
+**文件**:`api/endpoints/runtime_kubernetes.py`、`api/router.py`。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | Endpoint仅严格请求/鉴权/Service调用;生产聚合router已注册,无SQL或集群SDK |
+| 2 | 命名 | PASS | controllers/resources/observations/admission plans对应既有合同 |
+| 3 | 序列化 | PASS | 复用内部RuntimeRoute安全错误及扁平运行协议;不是前端业务响应 |
+| 4 | 数据库 | PASS | 操作由Service/Repository执行;读取准入不修改资源记录 |
+| 5 | 前端 | N/A | 无前端变更 |
+| 6 | 信息泄漏 | PASS | HMAC/nonce沿用独立服务签名;混合角色配置拒绝;未知nonce存储失败关闭且不泄漏错误内容 |
+| 7 | 设计同步 | PASS | design当前模块职责和修订记录已同步;材料根路径未改 |
+
+**验证**:14项实际HMAC/ASGI+SQL API用例,加34项Service与16项账本合计64 passed;生产router路径亦由测试核对。Ruff与逐文件arch-guard通过。未执行真实K8s。
+**元数据验证**:文件范围PASS;配对T018已完成;依赖T017/T018 PASS。
+**结果**:PASS。
+
+## Task Review: T022
+
+**任务**:材料委托和续期Service合同。**类型**:测试。
+**文件**:`src/backend/test/app_runtime/test_runtime_material_service.py`;覆盖 AC-15、AC-27、AC-46、AC-47、AC-48。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 测试子集 |
+| 2 | 命名/AC | PASS | 完整AC,真实Ed25519和SQL材料/资源;app/preview外部读模型注入 |
+| 3 | 序列化 | N/A | 测试子集 |
+| 4 | 数据库 | N/A | 复用真实SQLite账本,不模拟核心授权记录 |
+| 5 | 前端 | N/A | 无前端 |
+| 6 | 信息泄漏 | PASS | 仅测试假私钥;断言SQL不保存私钥/代理明文;未知nonce异常不出现在响应 |
+| 7 | 设计同步 | N/A | 已确认归属/期限和材料签名合同 |
+
+**红阶段**:31项因待实现RuntimeMaterialService模块缺失而报错(`/private/tmp/f059-material-service-red.log`);不是接口可用证据。Ruff和逐文件arch-guard通过。
+**元数据验证**:文件范围PASS;依赖T015/T021 PASS;配对T023后续实现。
+**结果**:PASS。
+
+## Task Review: T023
+
+**任务**:材料委托与当前归属授权Service。**类型**:实现。
+**文件**:`runtime_material_service.py`、`models/runtime_kubernetes.py`;另修正T016测试夹具重新读取claim后的已持久化操作,避免旧对象覆盖running状态。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | Service→Repository与公开preview/version读模型;无HTTP框架、集群SDK或MinIO |
+| 2 | 命名 | PASS | 沿用内部运行协议;固定安全拒绝原因 |
+| 3 | 序列化 | PASS | 只返回内部授权公开字段,Secret私钥不进入SQL |
+| 4 | 数据库 | PASS | 验证node后进入可信租户上下文,SQL唯一键约束公钥身份;新增未发布表的expiry可空,不改历史数据 |
+| 5 | 前端 | N/A | 无前端变更 |
+| 6 | 信息泄漏 | PASS | Ed25519验签→当前UID/代次/冻结材料→Redis nonce;SQL仅公钥及代理凭据摘要;授权存储未知拒绝 |
+| 7 | 设计同步 | PASS | 4.7同步生产init逐请求归属检查、操作/预览期限;材料传输与HTTP仍待接入 |
+
+**验证**:材料34项;组合控制Service/API、材料wire与模型共133 passed(`/private/tmp/f059-material-authority-checkpoint.log`)。额外三项先复现公共离线profile缺失及上传字段类型错误,修复后通过;Ruff、逐文件arch-guard通过。不是实际集群/MinIO部署证据。
+**元数据验证**:文件范围PASS;配对T022已完成;依赖T015/T021/T022 PASS。
+**结果**:PASS。
+
+## Task Review: T024
+
+**任务**:范围化分块材料传输合同。**类型**:测试。
+**文件**:`test_runtime_material_transfers.py`;覆盖 AC-26、AC-28、AC-29、AC-42、AC-46、AC-47、AC-51。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 测试子集 |
+| 2 | 命名/AC | PASS | 完整AC,真实SQL与Ed25519;仅对象存储边界内存替身 |
+| 3 | 序列化 | N/A | 测试子集 |
+| 4 | 数据库 | N/A | 持久offset/确认事实查询,不mock事务 |
+| 5 | 前端 | N/A | 无前端 |
+| 6 | 信息泄漏 | PASS | 假签名与票据,断言无票据明文入SQL |
+| 7 | 设计同步 | N/A | 固定material-v1合同 |
+
+**红阶段**:21项因目标Service尚未实现报ModuleNotFoundError(`/private/tmp/f059-material-transfer-red.log`);后补5项期限/复用/跨授权边界;Ruff通过。
+**元数据验证**:文件范围PASS;依赖T023 PASS;配对T025后续实现。
+**结果**:PASS。
+
+## Task Review: T025
+
+**任务**:范围化材料传输Service。**类型**:实现。
+**文件**:`runtime_material_transfer_service.py`、`runtime_artifact_service.py`。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | Service→Repository/ObjectStore;不向manager提供存储配置或直链,无FastAPI |
+| 2 | 命名 | PASS | 固定内部协议和安全原因 |
+| 3 | 序列化 | PASS | 单材料/动作会话,status不返回票据;内容状态/范围符合6.2.1 |
+| 4 | 数据库 | PASS | 可信租户上下文;事务只含SQL,I/O后重查;锁顺序operation→assignment→授权→transfer;唯一键防重复会话 |
+| 5 | 前端 | N/A | 无前端 |
+| 6 | 信息泄漏 | PASS | SQL仅票据摘要;上传元数据固定白名单/冻结引用;5秒复核、内容60秒上限、有效期内结束;错误不返回存储细节 |
+| 7 | 设计同步 | PASS | 4.7/6.2.1及修订历史同步Service状态与白名单;HTTP及真实存储仍待验收 |
+
+**验证**:26项workload传输及4项旧manager材料回归,共30 passed(`/private/tmp/f059-material-transfer-final.log`)。覆盖票据轮换、已完成部署续取、分块范围/空文件/416、同字节幂等、摘要冲突、内容去重、撤销不推进offset、对象写成但未SQL确认不verified。旧路径仅提取内部材料分配函数,原操作鉴权不放宽。
+**元数据验证**:文件范围PASS;配对T024已红阶段;依赖T023/T024 PASS。
+**结果**:PASS。
+
+## Task Review: T075 / T076
+
+**任务**:固定材料根路径的多租户中间件合同/接线。**类型**:测试/实现,分别一个文件。
+**文件**:T075 `test_runtime_material_middleware.py`;T076 `utils/http_middleware.py`。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | middleware仅识别独立通道;权限决策仍由Service完成 |
+| 2 | 命名/AC | PASS | 覆盖 AC-15、AC-47、AC-48,精确前缀和相似路径反例 |
+| 3 | 序列化 | N/A | 无新响应合同 |
+| 4 | 数据库 | PASS | 无SQL;Service重新建立verified租户,不能继承豁免bypass |
+| 5 | 前端 | N/A | 无前端变更 |
+| 6 | 信息泄漏 | PASS | 根材料通道不解析浏览器Cookie;其他路径保持原合同 |
+| 7 | 设计同步 | PASS | 6.2明确固定根路由独立工作负载身份 |
+
+**验证**:T075先3 failed(`/private/tmp/f059-material-middleware-red.log`),T076后3 passed(`/private/tmp/f059-material-middleware-final.log`);真实CustomMiddleware,Ruff与逐文件arch-guard通过。
+**元数据验证**:每任务一文件PASS;T076配对T075已红阶段;依赖T025 PASS。
+**结果**:PASS。
+
+## Task Review: T026 / T027
+
+**任务**:材料workload HTTP合同/接入。**类型**:测试/实现。
+**文件**:T026 `test_runtime_material_api.py`;T027 `api/endpoints/runtime_material.py`、`main.py`。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | Endpoint只做签名请求收集、Service调用、HTTP流;生产main直接注册 |
+| 2 | 命名/AC | PASS | 覆盖 AC-15、AC-28、AC-46、AC-47、AC-51;固定material-v1路径 |
+| 3 | 序列化 | PASS | 传输真实HTTP状态与Content-Range;没有SPA业务错误包装 |
+| 4 | 数据库 | PASS | Endpoint无SQL、Secret或MinIO访问 |
+| 5 | 前端 | N/A | 无前端变更 |
+| 6 | 信息泄漏 | PASS | 有界请求体/60秒;拒绝重复/混合身份;未知存储异常只返回安全503;流中失败终止,不伪造完整内容 |
+| 7 | 设计同步 | PASS | 4.7/6.2.1同步HTTP状态;仍未声称实际Pod/MinIO验收 |
+
+**红阶段**:T026 14 errors+1 failed(缺端点/main挂载,`/private/tmp/f059-material-api-red.log`)。实现后15 passed(`/private/tmp/f059-material-api-first.log`),真实Ed25519、HMAC、ASGI、中间件与SQL,Ruff/逐文件arch-guard通过。
+**元数据验证**:目标1/2文件PASS;T027配对T026已红阶段;依赖T025/T019/T076 PASS。
+**结果**:PASS。
+
+## Task Review: T028 / T029
+
+**任务**:材料client与平台控制client合同/实现。**类型**:测试/实现。
+**文件**:T028 `tests/test_material_client.py`;T029 `material_client.py`、`platform_client.py`。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 独立runtime发行包,仅HTTP;不导入backend、不访问MinIO/DB;manager控制client与workload私钥分离 |
+| 2 | 命名/AC | PASS | 覆盖 AC-07、AC-15、AC-26、AC-28、AC-42、AC-44、AC-46、AC-47、AC-52 |
+| 3 | 序列化 | PASS | material-v1固定路径/新nonce/原始字节签名;单块范围/长度/完整摘要验证;manifest来自后台冻结材料授权 |
+| 4 | 数据库 | N/A | 无数据库访问 |
+| 5 | 前端 | N/A | 无前端 |
+| 6 | 信息泄漏 | PASS | HTTPS/CA、禁redirect与环境代理;代理仅显式配置,错误无远端payload;无私钥日志;有限重试不延长deadline;半块丢弃,换票保持同会话 |
+| 7 | 设计同步 | PASS | 6.2.1客户端恢复语义已落实;helper和集群仍待接入 |
+
+**红阶段**:17项为5 failed/12 errors(缺MaterialClient与新控制方法,`/private/tmp/f059-material-client-red.log`);补6项manifest/无效deadline/换票身份,先复现5失败后修复。最终23项新client+3项旧client共26 passed;完整runtime623 passed、10 skipped(`/private/tmp/f059-runtime-material-client-full.log`)。材料/控制/模型/旧材料组合197 passed(`/private/tmp/f059-material-control-green.log`),Ruff与逐文件arch-guard通过。
+**元数据验证**:目标1/2文件PASS;T029配对T028已红阶段;依赖T007/T027 PASS。
+**结果**:PASS。
+
+## Task Review: T077 / T078
+
+**任务**:准备操作缓存选择合同/材料授权接线。**类型**:测试/实现。
+**文件**:T077 `test_runtime_dependency_materials.py`;T078 `runtime_profile_service.py`、`runtime_material_service.py`。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 既有Service→Repository,不新增API权限Owner或跨层SQL |
+| 2 | 命名/AC | PASS | 覆盖 AC-15、AC-25、AC-28、AC-29、AC-46、AC-47 |
+| 3 | 序列化 | PASS | 既有dependency-plans响应不变;选择只进入内部operation payload |
+| 4 | 数据库 | PASS | 操作→计划行锁,持久选择先于新Pod授权;同应用/profile/平台/verified;输入键不可变,无I/O占锁 |
+| 5 | 前端 | N/A | 无前端 |
+| 6 | 信息泄漏 | PASS | 仅固定缓存引用,当前操作失效即拒绝;伪造payload缺少匹配计划不能扩大材料集合 |
+| 7 | 设计同步 | PASS | 4.7同步缓存选择持久化,D1–D8不变 |
+
+**红阶段**:11 errors(缺clock及选择合同,`/private/tmp/f059-dependency-material-red.log`)。新增11项及原profile/材料/控制/模型共213 passed(`/private/tmp/f059-dependency-material-final.log`);Ruff/逐文件arch-guard通过。
+**元数据验证**:目标1/2文件PASS;配对T077已红阶段;依赖T023/T029 PASS。
+**结果**:PASS。
+
+## T008/T009 helper集成补充复审
+
+既有模板任务的目标文件未变。新3项测试先复现可信helper从共享目录导入、Pod UID缺少权威注入、prepare缺固定子目录挂载;修复后模板/准入71 passed。可信helper统一`python -I -m`(发行镜像须安装模块到可信site-packages),材料UID仅Downward API;install只挂source/output可写、original/materials/tools只读,不挂完整scratch或身份。D4隔离要求保持,禁止helper PATH覆盖。L1七项复查PASS;后续需真实镜像/集群验证。
+
+## Task Review: T030 / T031
+
+**任务**:受信材料helper与无凭据环境物化合同/实现。**类型**:测试/实现。
+**文件**:T030 `tests/test_k8s_helper.py`;T031 `k8s_helper.py`、`k8s_prepare.py`。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 独立执行包;可信传输只读写冻结材料;安装脚本为无网络client/身份的独立文件 |
+| 2 | 命名/AC | PASS | 覆盖 AC-15、AC-16、AC-24、AC-25、AC-26、AC-28、AC-29、AC-42、AC-43、AC-44、AC-45、AC-46、AC-54 |
+| 3 | 序列化 | PASS | 阶段结果固定标记JSON,产物必须后台verified+confirmed_at;无宿主路径当事实源 |
+| 4 | 数据库 | N/A | 无SQL或MinIO;平台接口为材料唯一入口 |
+| 5 | 前端 | N/A | 无前端 |
+| 6 | 信息泄漏 | PASS | 可信解包安全限额/边界;原始源码不导入;pip下载隔离模式;材料私钥仅Secret文件;上传不执行用户脚本;UID绑定等待有界、不延长期限 |
+| 7 | 设计同步 | PASS | 4.7/准备流程同步:先inputs算key,再选缓存并resolve;凭据不进入install Pod,固定/opt/venv离线物化 |
+
+**红阶段**:先14 failed(缺两个目标模块,`/private/tmp/f059-k8s-helper-red.log`);补identity期限/UID等待两项先复现失败后修复。最终helper19项;完整runtime645 passed、10 skipped(`/private/tmp/f059-runtime-helper-full.log`);backend新增与原合同组合213 passed。全app_runtime及中间件753 passed/18 skipped,仍有先前3项旧权限夹具失败(`/private/tmp/f059-helper-backend-full.log`);不记为全模块通过。Ruff/逐文件arch-guard通过。离线pip/npm真实安装和镜像尚未实测,留到发行及189 E2E。
+**元数据验证**:目标1/2文件PASS;配对T030已红阶段;依赖T009/T029/T078 PASS。
+**结果**:PASS。
+
+## T007/T015/T017/T019/T029 恢复接线补充复审
+
+目标仍属于各原任务文件,未扩展单任务文件范围。执行器接入前新增测试先复现:Service/API缺少资源恢复读取(2项),中间阶段stop误释放整笔容量(1项),新控制器恢复未创建意图保留旧controller_epoch(1项),阶段日志缺容器参数(1项),控制client缺恢复查询(1项)。分别修正并复审七项L1:架构/命名/内部序列化/租户与事务/敏感信息/设计同步PASS,前端N/A。
+
+- manager的GET resources按操作/已鉴权node限定,资源模板仅脱敏投影;无本地文件恢复依赖。
+- 活跃操作中间Pod停止仍保留预占;终态且全部Pod同UID停止才释放。
+- 仅相同owner、操作与模板、未绑定且未stop的资源意图允许在新租约下更新controller_epoch;不存在同名异UID接管。
+- Core logs仅允许明确容器白名单与1MiB上限;公开应用日志仍默认为app。
+
+**验证**:控制Service/API/账本68 passed(`/private/tmp/f059-control-recovery-final.log`);新旧client/Core/lifecycle82 passed(`/private/tmp/f059-control-client-recovery-final.log`)。红阶段日志对应`control-recovery-red`、`control-recovery-api-red`、`control-resume-red`、`phase-logs-red`、`control-client-recovery-red`。实际执行器及集群仍未交付。**结果:PASS。**
+
+## T002/T015/T017/T019 观察端点与阻断清理补充复审
+
+新表尚未作为F059发布,模型增加JsonType observed_state,记录受限Service ClusterIP/port,不保存原始Pod/Secret响应;实际部署前仍须盘点存量列,不能假定未知环境无表。新5项Service测试先复现未接线,补真实HMAC DTO成功例与ready正反例。端点须在配置Service CIDR内且匹配批准端口,同UID事实不可改写。
+
+探测失败的节点可续清理租约;Repository/Service/准入同时拒绝新资源。状态/绑定保护发生断档时持久清除既有资源连续保护标记,恢复探测不能把历史断档抹除。原任务文件范围保持,七项L1中架构/命名/内部序列化/跨库与租户/信息安全/设计同步PASS,前端N/A。与节点协调共106 passed,逐文件arch-guard通过。**结果:PASS。**
+
+## Task Review: T079 / T080
+
+**任务**:节点报告与控制事实合同/接线。**类型**:测试/实现。
+**文件**:T079 `test_runtime_kubernetes_coordination.py`;T080 `runtime_operation_service.py`、`api/endpoints/runtime_delivery.py`。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 既有API→Service→Repository;backend无集群SDK、MinIO或直接SQL |
+| 2 | 命名/AC | PASS | 覆盖 AC-03、AC-07、AC-08、AC-13、AC-14、AC-30、AC-31、AC-48、AC-49、AC-52 |
+| 3 | 序列化 | PASS | 心跳兼容旧Docker,增加executor/probe/node控制快照;K8s facts的detail.controller持有者/代次必须匹配当前租约 |
+| 4 | 数据库 | PASS | 操作→节点锁;事实、终态和容量释放同一事务;stopped须全相关Pod证据;ready须同范围Pod/Service UID和观察IP;verified租户上下文清除ambient bypass |
+| 5 | 前端 | N/A | 复用16121运行时不可用错误,无新页面或文案 |
+| 6 | 信息泄漏 | PASS | 探测报告限64KiB、拒绝凭据字段;内部未知错误安全503,测试加入会泄漏异常的全局handler仍不泄漏;硬前置失败入口不再allow |
+| 7 | 设计同步 | PASS | 6.2同步心跳/事实和观察端点;控制租约不被当作旧进程停止证据 |
+
+**红阶段**:18 errors+1 failed(缺clock/节点/事实合同及全局错误泄漏,`/private/tmp/f059-k8s-coordination-red.log`)。新增ready正例、错误地址及硬阻断/入口反例后,22项协调;与资源/模型共106 passed(`/private/tmp/f059-k8s-coordination-final.log`)。全app_runtime/原中间件785 passed、18 skipped,仍仅3项已记录旧权限夹具失败(`/private/tmp/f059-coordination-backend-full.log`);Ruff与逐文件arch-guard通过。无真实集群结论。
+**元数据验证**:目标1/2文件PASS;配对T079已红阶段;依赖T019/T031 PASS。
+**结果**:PASS。
+
+## T001/T002/T009/T023/T025/T029/T078 材料与配置接线补充复审
+
+原任务目标文件不变。新增回归先复现同内容新授权元数据冲突、依赖输入变更但字节相同的计划别名被拒绝、Secret 文件键和 CA/独立根种子配置缺失。修复使用每委托不可变 upload_declarations,内容去重不改写 Artifact 原始来源;别名要求持久计划、产生操作冻结选择和已确认上传同时成立。manager 的独立材料种子路径与平台 CA 显式配置;Secret 允许 identity.json/ca.crt 合法文件键。
+
+L1:架构/命名/内部序列化/跨库与租户/敏感信息/设计同步 PASS;前端 N/A。材料与资源联合150 passed(`/private/tmp/f059-material-executor-control-final.log`),完整runtime665 passed/10 skipped。无生产凭据、MinIO 直连或新商业化逻辑。**结果:PASS。**
+
+## T015/T017 清理授权补充复审
+
+真实SQL测试先复现两项失败:新控制器请求清理后资源代次仍旧;全部准备写入者已停止仍无法清理 scratch PVC。更新清理代次且保留原UID/证据;仅 prepare 的临时PVC在所有关联Pod确认停止后可删除,正式PVC普通清理不删除。Service/Repository61 passed(`/private/tmp/f059-executor-cleanup-policy-final.log`)。七项L1与原目标范围复核PASS,前端N/A。**结果:PASS。**
+
+## Task Review: T032 / T033
+
+**任务**:Kubernetes执行器能力合同/接线。**类型**:测试/实现。
+**文件**:T032 `tests/test_kubernetes_executor.py`;T033 `executors/kubernetes.py`、`executors/protocol.py`。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 只有执行器调用namespace Core客户端;无Docker初始化、平台DB或MinIO;数据通过显式能力委托,未配置即拒绝 |
+| 2 | 命名/AC | PASS | 覆盖 AC-04、AC-05、AC-07、AC-08、AC-09、AC-11、AC-18、AC-33、AC-35、AC-43、AC-48、AC-49、AC-50、AC-52 |
+| 3 | 序列化 | PASS | 平台意图先于集群副作用;Secret/env仅摘要;准备阶段结果先持久事实再清理,恢复只读平台账本 |
+| 4 | 数据库 | N/A | adapter仅HTTP,无本地事实源/SQL;准备缓存由平台接口选择确认 |
+| 5 | 前端 | N/A | 无前端 |
+| 6 | 信息泄漏 | PASS | 材料子密钥独立、可重启恢复,根种子不离开manager;业务Secret与材料分离;异UID拒绝领养或删除;缺失/运行中Pod不释放写入者;停止提交先于finalizer移除 |
+| 7 | 设计同步 | PASS | 4.6/4.7更新阶段恢复与清理;准备/启动/观察/停止/日志实现,data保留显式后续运输能力;完整镜像解析/OCI组装由后续配对任务完成 |
+
+**红阶段**:最初1 failed/9 errors缺模块;补准备、清理、日志出现3 failed;时间推进复现预览deadline模板冲突1 failed。最终16项通过(`/private/tmp/f059-executor-restart-final.log`),完整runtime665 passed/10 skipped(`/private/tmp/f059-runtime-executor-full.log`);Ruff、逐文件arch-guard通过。测试为模拟集群+真实密码实现,**不是189部署验证**。共同状态机和helper数据动作尚未接线,不宣称端到端已交付。
+**元数据验证**:目标1/2文件PASS;T032依赖T031/T011/T019/T080均完成,T033配对红阶段先行;L1七项复审通过。
+**结果:PASS。**
+
+
+## Task Review: T034 / T035
+
+**任务**:Docker能力合同与既有逻辑抽离。**文件**:`tests/test_docker_executor.py`;`executors/docker.py`、`operations.py`。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | Docker启动/本机路径/容量采样/SQLite维护归适配器;平台checkpoint和facts保留 |
+| 2 | 命名/AC | PASS | 覆盖 AC-01、AC-04、AC-10、AC-36、AC-37、AC-56 |
+| 3 | 序列化 | PASS | 保留原BundlePreparer、BundleLauncher及Engine输入输出 |
+| 4 | 数据库 | N/A | 无平台SQL;SQLite仍通过既有维护类 |
+| 5 | 前端 | N/A | 无前端 |
+| 6 | 信息泄漏 | PASS | 按node/app标签限制停止/日志,404不充当停止证据,无K8s路径模拟Docker |
+| 7 | 设计同步 | PASS | D1既定能力抽象落实,共同操作状态机仍为后续任务 |
+
+**红阶段**:缺适配器模块5 failed(`/private/tmp/f059-docker-executor-red.log`)。新合同+原操作17 passed,完整runtime670 passed/10 skipped(`/private/tmp/f059-runtime-docker-adapter-full.log`)。后续共同状态机接线后需再跑完整回归。逐文件arch-guard通过。
+**元数据**:1/2目标文件、T033依赖、配对红阶段PASS。**结果:PASS。**
+
+## Task Review: T036 / T037
+
+**任务**:共同状态机、唯一执行器选择与唤醒合同。**文件**:`tests/test_executor_selection.py`;`operations.py`、`api/operations.py`。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | K8s分支不初始化Docker、不读写宿主应用目录;平台阶段/事实统一,适配器持有执行/数据能力 |
+| 2 | 命名/AC | PASS | 完整标注 AC-01、AC-02、AC-04、AC-05、AC-07、AC-08、AC-09、AC-36、AC-37、AC-39、AC-48、AC-56 |
+| 3 | 序列化 | PASS | 唤醒仅operation_id且extra forbid;K8s facts带当前controller;准备进度跨阶段保留 |
+| 4 | 数据库 | N/A | 无平台SQL或新增本地事实源;停止确认通过adapter与平台账本 |
+| 5 | 前端 | N/A | 无前端 |
+| 6 | 信息泄漏 | PASS | 错误记录类型及操作ID,未知清理保留容量,已提交serving不会误清候选;快照失败不迁移或启动 |
+| 7 | 设计同步 | PASS | D1统一能力编排落实;data/OCI等后续能力未配置则明确失败,不构造Docker回退 |
+
+**红阶段**:选择合同1 failed/7 errors;额外字段合同1 failed。最终选择+Docker+原操作26 passed(`/private/tmp/f059-executor-selection-final.log`),完整runtime679 passed/10 skipped(`/private/tmp/f059-runtime-common-state-full.log`)。无真实集群结论。T035追加清理方法为原任务文件内移动既有算法。
+**元数据**:目标1/2文件、T035依赖、配对红阶段PASS;语义门禁由后续真实E2E复核。**结果:PASS。**
+
+## Task Review: T038 / T039
+
+**任务**:状态/日志/能力与服务路由。**文件**:`tests/test_k8s_observe.py`;`api/platform_observe.py`、`main.py`。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | K8s仅通过能力接口访问集群;平台assignment/resource验证范围,不依赖Docker或宿主目录 |
+| 2 | 命名/AC | PASS | AC-02、AC-07、AC-10、AC-30、AC-31、AC-32、AC-36、AC-37、AC-40、AC-53 |
+| 3 | 序列化 | PASS | 原status/logs字段保留;capabilities独立认证路由;K8s不注册v1与Docker升级入口 |
+| 4 | 数据库 | N/A | 容量只读平台账本,不使用descriptor预算伪造可用余额 |
+| 5 | 前端 | N/A | 复用既有运行环境页面 |
+| 6 | 信息泄漏 | PASS | 跨app/node/operation拒绝,日志仅业务容器、1000行/1MiB、平台注入密钥脱敏;健康存活与就绪分开,错误不回传远端细节 |
+| 7 | 设计同步 | PASS | 6.1同步已实现能力及since epoch;当前API探测区别于后续完整隔离验证 |
+
+**红阶段**:8 failed缺K8s状态/API组装;Core绝对窗口1 failed,执行域/可达性1 failed。针对观察/原入口24 passed;最新完整runtime689 passed/10 skipped(`/private/tmp/f059-runtime-capabilities-full.log`),Ruff通过。T007/T033原目标内补sinceTime/冻结域/API可达性,原方法不引入任意URL或日志容器名。
+**元数据**:1/2目标文件、T037依赖、配对红阶段PASS。**结果:PASS。**
+
+## Task Review: T040 / T041
+
+**任务**:owner动作票据与数据helper journal。**文件**:`tests/test_k8s_data_helper.py`;`k8s_data_helper.py`、`data_grant.py`。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | helper只有公钥,无平台/manager/MinIO凭据;SQLite白名单复用AppDbService |
+| 2 | 命名/AC | PASS | AC-15、AC-16、AC-33、AC-34、AC-36、AC-37、AC-43、AC-48、AC-50 |
+| 3 | 序列化 | PASS | 固定域和canonical JSON;精确claim/类型/5秒/60秒/参数摘要;导出流不进入JSON结果 |
+| 4 | 数据库 | PASS | 卷内flock串行,started先持久再SQL,done后才返回成功;未知不重做;平台journal不挂业务容器 |
+| 5 | 前端 | N/A | 无前端 |
+| 6 | 信息泄漏 | PASS | 同UID/app/exec绑定,拒绝nonce重放/伪造/错范围;拒绝数据库symlink;受信子进程deadline;日志不打印票据/SQL/私钥 |
+| 7 | 设计同步 | PASS | 4.8补充data-v1编码、崩溃窗口与流式导出;平台签发/Pod接线仍明确待实现 |
+
+**红阶段**:缺模块8 failed/6 errors;真实HTTP补测发现Request注解解析1 failed,已修复。最终18 passed(`/private/tmp/f059-data-helper-final.log`),包括SQLite提交后done落盘失败、子进程超时、跨进程锁、HTTP授权与CSV摘要。Ruff、逐文件arch-guard通过;不将此等同集群E2E。
+**元数据**:1/2目标文件、T021/T009依赖、配对红阶段PASS。**结果:PASS。**
+
+## Task Review: T081 / T082
+
+**任务**:数据会话Repository互斥与正式Pod资源预算。**文件**:`test_runtime_data_repository.py`;`runtime_delivery_repository.py`、`runtime_operation_service.py`。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | SQL均在既有Repository;OperationService只计算资源请求,无集群或MinIO访问 |
+| 2 | 命名/AC | PASS | 完整标注 AC-04、AC-05、AC-33、AC-34、AC-35、AC-36、AC-43、AC-48、AC-49、AC-50、AC-52 |
+| 3 | 序列化 | PASS | data仅内部ORM路径,公共OperationInput仍不能构造任意data操作 |
+| 4 | 数据库 | PASS | 沿用tenant过滤与JsonType模型;node→assignment锁保护提交,claim校验data归属;不改serving代次;terminal+cleanup完成后释放,过期/unknown不自动放行 |
+| 5 | 前端 | N/A | 无前端 |
+| 6 | 信息泄漏 | PASS | 仅内部actor/请求摘要,无签票API、无凭据;跨node/generation拒绝 |
+| 7 | 设计同步 | PASS | 4.8同步互斥与真实sidecar开销;Docker原预算不变 |
+
+**红阶段**:12 failed/2 passed(`/private/tmp/f059-data-repository-red.log`);新增14项+旧账本24 passed,再与节点/资源合计107 passed(`/private/tmp/f059-data-repository-final.log`)。真实SQLite事务;尚未MySQL/DM8或集群证明。Ruff与逐文件arch-guard通过。
+**元数据**:目标1/2文件、T041/T080依赖及配对先行PASS。**结果:PASS。**
+
+## Task Review: T042 / T043
+
+**任务**:owner数据会话与既有AppDataService接线。**文件**:`test_runtime_data_sessions.py`;`runtime_data_session_service.py`、`app_data_service.py`。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | Service→Repository,网络在事务提交后;backend不访问SQLite/集群/MinIO |
+| 2 | 命名/AC | PASS | AC-15、AC-35、AC-36、AC-37、AC-43、AC-48、AC-49、AC-50 |
+| 3 | 序列化 | PASS | 原业务API/审计返回保持;内部header携带签名票据;rows参数默认值先规范化再摘要 |
+| 4 | 数据库 | PASS | 复用T082原子会话;serving代次不变;缺卷不启动应用;冷启动也受原60秒期限,未知响应保留互斥 |
+| 5 | 前端 | N/A | 无新增页面、文案或业务动作 |
+| 6 | 信息泄漏 | PASS | owner重新缩窄,管理员也不能代替owner;无票据日志/签发管理API;HTTP结果未知只调用一次,已提交唤醒结果可恢复 |
+| 7 | 设计同步 | PASS | 4.8记录控制/授权接口及后续接线边界;默认issuer对应T049,Pod运输对应T045 |
+
+**红阶段**:缺Service7 errors;已有业务入口分发1 failed;唤醒丢回复1 failed。最终新会话9项与原owner/审计/数据账本41 passed(`/private/tmp/f059-data-sessions-final.log`),Ruff与逐文件arch-guard通过。
+**元数据**:1/2目标文件、T017/T041/T082依赖、配对先行PASS。**结果:PASS_WITH_NOTES**:签发/网络通过注入合同验证;默认T049签发模块、T045实际Pod数据RPC尚未交付,F059仍不能启用部署验收,后续必须真实联通验证。
+
+## T009/T011/T033/T037/T041 数据Pod与控制状态接线复审
+
+各改动仍在原任务目标文件内:新增sidecar固定入口/公钥投射测试先失败;准入即便收到批准模板也拒绝其他ConfigMap。helper按请求刷新公钥,控制健康/空闲位不返回数据库或journal;实际数据仍验owner票据。共同状态机只准备/等待/清理data会话,不自创owner动作。跨国内TZ测试先复现backend无时区UTC被误读,修复后时间解释统一。
+
+模板/准入74 passed(`/private/tmp/f059-data-sidecar-template-final.log`),helper20 passed(`/private/tmp/f059-data-helper-control-final.log`);选择/数据/执行器40 passed(`/private/tmp/f059-k8s-data-executor-final.log`)。L1架构/命名/内部序列化/无平台SQL/敏感信息/设计同步PASS,前端N/A。Pod仍为模拟集群;默认owner签发和真实卷/网络未验收。
+
+## Task Review: T044 / T045
+
+**任务**:在线及停用数据执行能力。**文件**:`tests/test_k8s_data_executor.py`;`k8s_data_executor.py`、`api/platform_data.py`。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 仅runtime侧namespace能力;HTTP数据API不初始化Docker;backend持久互斥替代会死锁的本地app锁 |
+| 2 | 命名/AC | PASS | AC-11、AC-15、AC-33、AC-35、AC-36、AC-43、AC-48、AC-49、AC-50、AC-52 |
+| 3 | 序列化 | PASS | 票据原样转交;app/action/参数/操作ID/UID核对;操作引用拒绝路径穿越;既有数据错误码保留 |
+| 4 | 数据库 | PASS | 在线不创建第二Pod;离线只复用原卷与固定位置,无业务Service/代码/身份;未知停止不释放 |
+| 5 | 前端 | N/A | 外部数据API未变 |
+| 6 | 信息泄漏 | PASS | Pod地址限安装网段;不转发manager私密凭据;响应按块限16MiB,CSV流式校验短缓存;旧票据窗口结束后仍需helper空闲证明;无404/超时强占 |
+| 7 | 设计同步 | PASS | 4.8同步控制空闲位与数据结果区别,公钥/UID及回收语义明确 |
+
+**红阶段**:缺模块7 failed;数据状态机1 failed;补错误码/有界读取/时区/路径查询/缓存回收先分别复现失败。最终14项通过(`/private/tmp/f059-k8s-data-executor-cache-final.log`),最新完整runtime见`/private/tmp/f059-runtime-data-cache-full.log`。Ruff与逐文件arch-guard通过。
+**元数据**:1/2目标文件、T043/T033依赖、配对红阶段PASS。**结果:PASS_WITH_NOTES**:集群/运输边界为模拟集群和HTTP传输测试,默认backend签发T049、SQLite维护T047和189真实验收仍必须完成。
+
+## T081/T082 跨租户并发补充复审
+
+新增实际双租户SQL用例先复现第三个数据会话仍可进入(`/private/tmp/f059-data-concurrency-red.log`)。在已锁节点下只聚合assignment.data_operation_id非空的数量,上限2;不加载其他租户对象,不用TTL释放未知归属。数据Repository/会话24 passed(`/private/tmp/f059-data-concurrency-final.log`),Ruff/架构守卫通过。L1架构、命名、SQL/租户、序列化、信息安全与设计同步PASS,前端N/A。原任务目标范围保持。**结果:PASS。**
+
+## T085 / T083 / T084 / T086 / T087 / T046 / T047
+
+按 task-review 的7项逐项核对。测试先行红日志:resource-phases-red(4 failed)、material-range-red(3 failed)、k8s-appdb-red(缺模块7 failed)、k8s-appdb-integration-red(未接线1 failed)。最终:resource-range-final 97 passed;k8s-maintenance-final 90 passed。
+
+| 任务 | 类型 | 架构 | 命名 | 序列化 | 数据库 | 前端 | 信息泄漏 | 设计同步 | 元数据/配对/依赖 | 结论 |
+| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
+| T085 | 基础设施 | PASS | N/A | N/A | PASS | N/A | PASS | PASS | PASS/N/A/PASS | PASS |
+| T083 | 测试 | N/A | PASS | N/A | N/A | N/A | N/A | N/A | PASS/N/A/PASS | PASS |
+| T084 | 实现 | PASS | PASS | PASS | PASS | N/A | PASS | PASS | PASS/PASS/PASS | PASS |
+| T086 | 测试 | N/A | PASS | N/A | N/A | N/A | N/A | N/A | PASS/N/A/PASS | PASS |
+| T087 | 实现 | PASS | PASS | PASS | PASS | N/A | PASS | PASS | PASS/PASS/PASS | PASS |
+| T046 | 测试 | N/A | PASS | N/A | N/A | N/A | N/A | N/A | PASS/N/A/PASS | PASS |
+| T047 | 实现 | PASS | PASS | PASS | PASS | N/A | PASS | PASS | PASS/PASS/PASS | PASS_WITH_NOTES |
+
+T085仅DDL,parent来自alembic heads的f059_merge_release_heads;DM8仅静态。T084在Repository事务内锁,不带集群I/O;新阶段必须旧writer停止并清理。T087存储凭据仍仅backend持有。T047仅复用appdb SQLite importer,helper无平台HMAC/MinIO权限;无新数据库业务Owner。配对范围与新增基础任务已复审。T009维护模板、T045非数据维护dispatch为原任务增量接线,补充检查7项PASS,分别仍为1/2文件;无通用SQL入口。T047备注:仅本地SQLite和模拟集群,实际189/默认签发/镜像发行尚未验证,保持后续任务未完成。
+
+## T048–T053 / T088–T093
+
+| 任务组 | 类型 | 架构 | 命名 | 序列化 | 数据库 | 前端 | 信息泄漏 | 设计同步 | 文件/配对/依赖 | 结论 |
+| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
+| T048、T050、T052、T088、T090、T092 | 测试 | N/A | PASS | N/A | N/A | N/A | N/A | N/A | PASS/N/A/PASS | PASS |
+| T049 | 实现 | PASS | PASS | PASS | PASS | N/A | PASS | PASS | PASS/PASS/PASS | PASS |
+| T089 | 实现 | PASS | PASS | PASS | PASS | N/A | PASS | PASS | PASS/PASS/PASS | PASS |
+| T091、T093 | 实现 | PASS | PASS | PASS | PASS | N/A | PASS | PASS | PASS/PASS/PASS | PASS |
+| T051 | 实现 | PASS | PASS | PASS | N/A | N/A | PASS | PASS | PASS/PASS/PASS | PASS_WITH_NOTES |
+| T053 | 实现 | PASS | PASS | PASS | N/A | N/A | PASS | PASS | PASS/PASS/PASS | PASS_WITH_NOTES |
+
+红阶段:data-grants-red缺模块11 errors;k8s-profile-red 1 failed/3 passed;data-recovery-red 4 failed;data-recovery-api-red 2 failed;k8s-delivery-red 7 failed;diagnostic-red 2 failed/7 passed;oci-red缺模块9 failed。最终owner-recovery-final 64 passed(真实SQL、HTTP/API、独立验签互操作及原owner/audit);k8s-delivery-final 142 passed;runtime-delivery-full 744 passed/10 skipped(加入OCI前);oci-final 129 passed及oci-links 10 passed。
+
+逐项读源核对:签名私钥只由backend持有、只读内部公钥API、5秒票据受理期先提交;Service仅通过Repository访问SQL,owner不因管理员身份放宽。结果查询走新只读会话,原unknown互斥不提前释放;导出Endpoint委托Service,原Docker bytes响应不变。T051接线文件为执行器/k8s_prepare;T009的schema/组装/环境卷模板、T031材料下载进程环境、T047隔离schema初始化、T045结果RPC/CSV摘要、T017固定assembler镜像允许集合均为原任务增量,分别仍为1–2目标文件,7项补充审查PASS。临时镜像组装凭据不进入应用/无凭据安装阶段,不执行用户Dockerfile。
+
+备注:T051/T053只完成本地接线及模拟集群/命令合同;源码兼容逐语言实际行为、registry/CRI、189部署与CI双架构发行继续由后续任务验收,不能从本组通过推断已部署。无新增人审暂停点。
+
+## 并行交付及真实启动兼容收尾
+
+用户已明确授权并行。共同快照c90927b1f,安装、发行、验收三个独立worktree分工;主agent串行合流并独占189/109远端操作。所有新增任务最多两个目标文件。T109以后部分任务登记晚于实际修复;先失败后通过历史以各日志和agent报告为准,未倒填执行时间。没有规格/设计决策变更;本地通过不代表集群/业务/双架构发行通过。
+
+本批检查:Service仅委托Repository,内部HMAC/材料私钥/owner票据各司其职,无新数据库写路径/前端/Worker;安装工具不持平台存储密钥,公开CA与私密key拒绝混用,日志仅安全常量。Chart不授manager修改控制面/RBAC/基线权限;helper无宿主socket。设计§4.1、§4.8、§4.9、§4.10同步执行落点,D1–D8不变。
+
+| 任务组 | 核实的配对与证据 | 边界 |
+| --- | --- | --- |
+| T054–T061、T064/T065、T101/T102、T120/T134 | canary/能力/installer先红绿;安装工具51项及当前runtime全量967 passed/10 skipped;真实CREATE误拒绝由T139/T140独立修补 | 只有tool/本地合同,189完整基线未通过 |
+| T062/T063/T103/T104、T121–T123 | 镜像合同70项组合,单独发行合同27 passed;189原生构建并禁网执行helper/assembler四种真实resolve;CI仅本地代码检查 | 未触发F059公开tag/CI;没有arm64原生证明 |
+| T094–T100、T109/T110、T129 | purge维护、stop证据、原子轻量预占与PVC作用域,定向65项及backend全量1564 passed/18 skipped | 模拟集群/SQLite不替代189真实删除 |
+| T111–T119、T130/T131 | CNI/guard/launcher/冻结与源码限制,各线红绿;snapshot刷新6失败后58通过、source_limits161通过,当前runtime967通过 | 已有Pod作用域先校验;未实测业务Pod |
+| T066/T105–T108、T124–T128、T135–T138、T141/T142 | harness、bootstrap、真实loopback HTTPS/WSS正反;工具合同20+bootstrap37,coordination88,最终backend1564通过 | bootstrap只准备身份/审批,不算业务发布;平台E2E仍待 |
+| T132/T133 | committed SDK0.1.1 wheel真实读环境;正式/预览/伪造值8个红阶段,coordinator及相关34通过 | 只验证变量合同,不代替SDK平台实际调用 |
+| T139/T140 | 固定K8s v1.28.15源码+两份实际canary JSON;CREATE默认状态先9失败、空env先1失败,最终准入/模板171通过;实际JSON本地准入正反验证 | 189下一次真实创建与停止仍需继续验证 |
+
+以下各任务均按skill七项逐条核查;测试任务不适用项记N/A,基础设施不强造数据库/前端结论。所有范围、前置和配对已由索引交叉核实。各项验证日志位于/private/tmp/f059-*.log;交付中仅保存摘要和脱敏证据。
+
+## Task Review: T096
+
+**任务**:owner 删除与安全清理合同。**类型**:测试。**文件**:`src/runtime-manager/tests/test_k8s_appdb.py`、`src/backend/test/app_runtime/test_runtime_kubernetes_purge.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | N/A | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | N/A | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | N/A | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围2文件PASS;配对T097;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T097
+
+**任务**:backend purge 范围和删除授权。**类型**:实现。**文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_kubernetes_service.py`、`src/backend/bisheng/app_runtime/domain/repositories/runtime_kubernetes_repository.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围2文件PASS;配对T096;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T098
+
+**任务**:受信 helper 清理与持久重入。**类型**:实现。**文件**:`src/runtime-manager/runtime_manager/k8s_appdb.py`、`src/runtime-manager/runtime_manager/k8s_data_helper.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围2文件PASS;配对T096;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T099
+
+**任务**:生产 purge 模板及状态机接线。**类型**:实现。**文件**:`src/runtime-manager/runtime_manager/k8s_templates.py`、`src/runtime-manager/runtime_manager/operations.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围2文件PASS;配对T096;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T100
+
+**任务**:执行器条件回收生产卷。**类型**:实现。**文件**:`src/runtime-manager/runtime_manager/executors/kubernetes.py`、`src/runtime-manager/runtime_manager/k8s_data_executor.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围2文件PASS;配对T096;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T101
+
+**任务**:受限 canary 启动合同。**类型**:测试。**文件**:`src/runtime-manager/tests/test_k8s_canary.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | N/A | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | N/A | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | N/A | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围1文件PASS;配对T102;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T102
+
+**任务**:只读 canary 准入和网络探测。**类型**:实现。**文件**:`src/runtime-manager/runtime_manager/k8s_admission.py`、`src/runtime-manager/runtime_manager/k8s_capabilities.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围2文件PASS;配对T101;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T054
+
+**任务**:隔离和安装前置的实际能力探测:契约与失败用例。**类型**:测试。**文件**:`src/runtime-manager/tests/test_k8s_capabilities.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | N/A | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | N/A | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | N/A | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围1文件PASS;配对T055;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T055
+
+**任务**:隔离和安装前置的实际能力探测。**类型**:实现。**文件**:`src/runtime-manager/runtime_manager/k8s_capabilities.py`、`src/runtime-manager/runtime_manager/k8s_templates.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围2文件PASS;配对T054;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T094
+
+**任务**:活跃材料代理候选查询合同。**类型**:测试。**文件**:`src/backend/test/app_runtime/test_runtime_material_policy_candidates.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | N/A | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | N/A | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | N/A | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围1文件PASS;配对T095;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T095
+
+**任务**:活跃材料候选 Repository 查询。**类型**:实现。**文件**:`src/backend/bisheng/app_runtime/domain/repositories/runtime_delivery_repository.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围1文件PASS;配对T094;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T056
+
+**任务**:材料用途的出站投影:契约与失败用例。**类型**:测试。**文件**:`src/backend/test/app_runtime/test_runtime_material_egress.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | N/A | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | N/A | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | N/A | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围1文件PASS;配对T057;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T057
+
+**任务**:材料用途的出站投影。**类型**:实现。**文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_material_policy_service.py`、`src/backend/bisheng/app_runtime/domain/services/runtime_operation_service.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围2文件PASS;配对T056;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T058
+
+**任务**:Chart 入口与 values schema。**类型**:基础设施。**文件**:`deploy/kubernetes/app-runtime/Chart.yaml`、`deploy/kubernetes/app-runtime/values.yaml`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围2文件PASS;配对N/A;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T059
+
+**任务**:部署参数及共享入口校验。**类型**:基础设施。**文件**:`deploy/kubernetes/app-runtime/values.schema.json`、`deploy/kubernetes/app-runtime/templates/services.yaml`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围2文件PASS;配对N/A;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T060
+
+**任务**:RBAC 与准入安装模板。**类型**:基础设施。**文件**:`deploy/kubernetes/app-runtime/templates/rbac.yaml`、`deploy/kubernetes/app-runtime/templates/admission.yaml`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围2文件PASS;配对N/A;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T061
+
+**任务**:网络和保留卷安装模板。**类型**:基础设施。**文件**:`deploy/kubernetes/app-runtime/templates/network.yaml`、`deploy/kubernetes/app-runtime/templates/storage.yaml`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围2文件PASS;配对N/A;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T104
+
+**任务**:镜像发行和清单合同。**类型**:测试。**文件**:`src/runtime-manager/tests/test_image_release.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | N/A | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | N/A | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | N/A | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围1文件PASS;配对T063;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T062
+
+**任务**:运行服务与 helper 镜像。**类型**:基础设施。**文件**:`deploy/kubernetes/app-runtime/templates/workloads.yaml`、`src/runtime-manager/Dockerfile.helper`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围2文件PASS;配对N/A;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T103
+
+**任务**:原生镜像构建脚本扩展。**类型**:基础设施。**文件**:`scripts/build_runtime_images.sh`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围1文件PASS;配对N/A;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T063
+
+**任务**:同版本双架构 CI/CD 发行。**类型**:基础设施。**文件**:`.github/workflows/runtime-images.yml`、`src/runtime-manager/runtime_manager/image_catalog.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围2文件PASS;配对T104;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T064
+
+**任务**:部署/离线清单验证:契约与失败用例。**类型**:测试。**文件**:`src/runtime-manager/tests/test_k8s_installation.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | N/A | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | N/A | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | N/A | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围1文件PASS;配对T065;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T065
+
+**任务**:部署/离线清单验证。**类型**:实现。**文件**:`scripts/verify_k8s_runtime.py`、`deploy/kubernetes/app-runtime/README.md`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围2文件PASS;配对T064;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T105
+
+**任务**:E2E harness 边界合同。**类型**:测试。**文件**:`src/backend/test/app_runtime/test_k8s_e2e_contract.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | N/A | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | N/A | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | N/A | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围1文件PASS;配对T106;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T106
+
+**任务**:真实 API 业务验收共享行程。**类型**:实现。**文件**:`src/backend/test/e2e/helpers/vibe_runtime.py`、`src/backend/test/e2e/test_e2e_vibe_k8s_runtime.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围2文件PASS;配对T105;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T066
+
+**任务**:真实平台 K8s E2E 执行器。**类型**:测试。**文件**:`scripts/e2e_k8s_runtime.py`、`features/v3.0.0/059-k8s-runtime-backend/e2e-checklist.md`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | N/A | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | N/A | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | N/A | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围2文件PASS;配对N/A;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T107
+
+**任务**:继承发布线的权限/egress测试适配。**类型**:测试。**文件**:`src/backend/test/app_runtime/test_app_permission_registration.py`、`src/backend/test/app_runtime/test_runtime_lifecycle_v2.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | N/A | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | N/A | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | N/A | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围2文件PASS;配对N/A;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T108
+
+**任务**:资源历史约束静态合同同步。**类型**:测试。**文件**:`src/backend/test/app_runtime/test_runtime_model_contracts.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | N/A | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | N/A | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | N/A | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围1文件PASS;配对N/A;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T109
+
+**任务**:删除维护轻量容量预占合同。**类型**:测试。**文件**:`src/backend/test/app_runtime/test_runtime_purge_capacity.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | N/A | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | N/A | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | N/A | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围1文件PASS;配对N/A;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T110
+
+**任务**:删除维护轻量容量预占。**类型**:实现。**文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_operation_service.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围1文件PASS;配对T109;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T111
+
+**任务**:Calico 元数据来源及收尾合同。**类型**:测试。**文件**:`src/runtime-manager/tests/test_k8s_admission.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | N/A | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | N/A | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | N/A | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围1文件PASS;配对N/A;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T112
+
+**任务**:Calico 元数据来源隔离。**类型**:实现。**文件**:`src/runtime-manager/runtime_manager/k8s_admission.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围1文件PASS;配对T111;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T113
+
+**任务**:scratch 写入前卷校验合同。**类型**:测试。**文件**:`src/runtime-manager/tests/test_k8s_helper.py`、`src/runtime-manager/tests/test_k8s_templates.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | N/A | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | N/A | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | N/A | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围2文件PASS;配对N/A;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T114
+
+**任务**:scratch 写入前卷校验。**类型**:实现。**文件**:`src/runtime-manager/runtime_manager/k8s_helper.py`、`src/runtime-manager/runtime_manager/k8s_templates.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围2文件PASS;配对T113;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T115
+
+**任务**:scratch 预算与安装标识接线。**类型**:实现。**文件**:`src/runtime-manager/tests/test_kubernetes_executor.py`、`src/runtime-manager/runtime_manager/executors/kubernetes.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围2文件PASS;配对T032;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T116
+
+**任务**:static launcher 兼容合同。**类型**:测试。**文件**:`src/runtime-manager/tests/test_k8s_helper.py`、`src/runtime-manager/tests/test_k8s_templates.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | N/A | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | N/A | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | N/A | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围2文件PASS;配对N/A;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T117
+
+**任务**:static launcher 保留。**类型**:实现。**文件**:`src/runtime-manager/runtime_manager/k8s_templates.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围1文件PASS;配对T116;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T118
+
+**任务**:Node launcher 兼容收尾。**类型**:实现。**文件**:`src/runtime-manager/tests/test_k8s_templates.py`、`src/runtime-manager/runtime_manager/k8s_templates.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围2文件PASS;配对T008;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T119
+
+**任务**:探测刷新后的 sandbox 冻结。**类型**:实现。**文件**:`src/runtime-manager/tests/test_kubernetes_executor.py`、`src/runtime-manager/runtime_manager/executors/kubernetes.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围2文件PASS;配对T032;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T120
+
+**任务**:Chart 安装标识传递。**类型**:实现。**文件**:`deploy/kubernetes/app-runtime/templates/workloads.yaml`、`src/runtime-manager/tests/test_k8s_installation.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围2文件PASS;配对T064;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T121
+
+**任务**:helper 可信解析工具链合同。**类型**:测试。**文件**:`src/runtime-manager/tests/test_image_release.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | N/A | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | N/A | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | N/A | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围1文件PASS;配对N/A;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T122
+
+**任务**:helper 可信解析工具链。**类型**:基础设施。**文件**:`src/runtime-manager/Dockerfile.helper`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围1文件PASS;配对N/A;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T123
+
+**任务**:镜像实际离线解析烟测。**类型**:基础设施。**文件**:`scripts/build_runtime_images.sh`、`src/runtime-manager/tests/test_image_release.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围2文件PASS;配对N/A;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T124
+
+**任务**:隔离平台 bootstrap 合同。**类型**:测试。**文件**:`scripts/fixtures/f059/test_bootstrap.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | N/A | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | N/A | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | N/A | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围1文件PASS;配对N/A;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T125
+
+**任务**:隔离平台 API bootstrap。**类型**:实现。**文件**:`scripts/bootstrap_f059_acceptance.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围1文件PASS;配对T124;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T126
+
+**任务**:真实应用协议夹具。**类型**:实现。**文件**:`scripts/fixtures/f059/python_demo.py`、`scripts/fixtures/f059/node_demo.js`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围2文件PASS;配对T124;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T127
+
+**任务**:完整镜像及离线材料夹具。**类型**:基础设施。**文件**:`scripts/fixtures/f059/Dockerfile.demo`、`scripts/fixtures/f059/offline-manifest.example.json`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围2文件PASS;配对N/A;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T128
+
+**任务**:平台验收夹具说明。**类型**:基础设施。**文件**:`scripts/fixtures/f059/README.md`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围1文件PASS;配对N/A;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T129
+
+**任务**:helper 及 PVC 作用域校验接线。**类型**:实现。**文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_kubernetes_service.py`、`src/backend/test/app_runtime/test_runtime_kubernetes_service.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围2文件PASS;配对T016;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T130
+
+**任务**:自定义 bundle launcher 合同说明。**类型**:基础设施。**文件**:`deploy/kubernetes/app-runtime/README.md`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围1文件PASS;配对N/A;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T131
+
+**任务**:源码大小限制冻结接线。**类型**:实现。**文件**:`src/runtime-manager/tests/test_kubernetes_executor.py`、`src/runtime-manager/runtime_manager/executors/kubernetes.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围2文件PASS;配对T032;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T132
+
+**任务**:SDK 既有平台地址变量合同。**类型**:测试。**文件**:`src/backend/test/app_runtime/test_runtime_k8s_delivery_coordinator.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | N/A | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | N/A | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | N/A | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围1文件PASS;配对N/A;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T133
+
+**任务**:SDK 平台地址变量接线。**类型**:实现。**文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_delivery_coordinator.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围1文件PASS;配对T132;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T134
+
+**任务**:安装探测安全失败分类。**类型**:实现。**文件**:`scripts/verify_k8s_runtime.py`、`src/runtime-manager/tests/test_k8s_installation.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围2文件PASS;配对T064;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T135
+
+**任务**:显式公开 CA 的 HTTP/WS 合同。**类型**:测试。**文件**:`src/backend/test/app_runtime/test_k8s_e2e_contract.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | N/A | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | N/A | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | N/A | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围1文件PASS;配对N/A;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T136
+
+**任务**:真实业务 E2E 公开 CA 接线。**类型**:实现。**文件**:`scripts/e2e_k8s_runtime.py`、`src/backend/test/e2e/helpers/vibe_runtime.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围2文件PASS;配对T135;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T137
+
+**任务**:固定镜像 profile 新修订合同。**类型**:测试。**文件**:`scripts/fixtures/f059/test_bootstrap.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | N/A | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | N/A | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | N/A | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围1文件PASS;配对N/A;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T138
+
+**任务**:验收 profile 修订参数。**类型**:实现。**文件**:`scripts/bootstrap_f059_acceptance.py`、`scripts/fixtures/f059/README.md`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围2文件PASS;配对T137;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T139
+
+**任务**:Kubernetes CREATE 默认对象合同。**类型**:测试。**文件**:`src/runtime-manager/tests/test_k8s_admission.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | N/A | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | N/A | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | N/A | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围1文件PASS;配对N/A;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T140
+
+**任务**:Kubernetes CREATE 默认对象兼容。**类型**:实现。**文件**:`src/runtime-manager/runtime_manager/k8s_admission.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围1文件PASS;配对T139;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T141
+
+**任务**:协调器旧 scratch 夹具适配。**类型**:测试。**文件**:`src/backend/test/app_runtime/test_runtime_kubernetes_coordination.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | N/A | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | N/A | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | N/A | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围1文件PASS;配对N/A;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## Task Review: T142
+
+**任务**:验收管理员授权可重复执行。**类型**:实现。**文件**:`scripts/bootstrap_f059_acceptance.py`、`scripts/fixtures/f059/test_bootstrap.py`。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 沿用原Owner/身份分离,见本批具体检查 |
+| 2 | 命名/AC | PASS | 逐条完整AC及现有命名;无新增UI |
+| 3 | 序列化 | PASS | 既有wire/envelope;内部对象严格比对 |
+| 4 | 数据库 | N/A | 无新增模型/DDL;原SQL锁在Repository |
+| 5 | 前端 | N/A | 无UI改动 |
+| 6 | 信息泄漏 | PASS | 安全文件/固定错误原因;无真实凭据入仓库 |
+| 7 | 设计同步 | PASS | 已同步§4.1/§4.8–§4.10;D1–D8不变 |
+
+**元数据**:范围2文件PASS;配对T124;依赖已完成PASS。**结果:PASS_WITH_NOTES**:仅本批说明范围内证据,真实业务及正式发行继续由T067–T072验收。
+
+## T111/T112 实测复审重开
+
+189首个canary已通过CREATE并被scheduler绑定;Calico实际通过pods/status维护三个保留注解,原_status无条件拒绝导致sandbox未启动。保留事件及UID,不强删/移除finalizer;该配对重开NEEDS_FIX,只补认证CNI、同UID/节点且status/spec不变的metadata路径,终态与其他字段仍拒绝。新实现/配对通过后再更新结果,当前不作为安装成功。
+
+
+## Task Review: T111 — 续办复核
+
+**任务**: Calico 元数据来源及收尾合同
+**类型**: 测试
+**文件**: `src/runtime-manager/tests/test_k8s_admission.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 测试仅HTTP/隔离Session;生产未增跨层依赖。 |
+| 2 | 命名规范 | PASS | 沿用既有命名,AC全称可追溯。 |
+| 3 | 序列化约定 | N/A | 内部descriptor/既有envelope;仅已验证omitempty规范化。 |
+| 4 | 数据库约定 | N/A | 模型非空/索引及上下文填充通过;其余未改数据库。 |
+| 5 | 前端约定 | N/A | 无前端源代码。 |
+| 6 | 信息泄漏 | PASS | 无硬编码凭据;日志和恢复证据仅专用资源身份。 |
+| 7 | 设计同步 | N/A | design §4.10/§5同步已实现恢复、升级和报告约束。 |
+
+**元数据验证**: 文件范围 PASS | 配对测试 PASS/N/A | 依赖 PASS
+**结果**: PASS(代码合同;不替代T067–T072真实验收及L2)
+
+
+## Task Review: T112 — 续办复核
+
+**任务**: Calico 元数据来源隔离
+**类型**: 实现
+**文件**: `src/runtime-manager/runtime_manager/k8s_admission.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 测试仅HTTP/隔离Session;生产未增跨层依赖。 |
+| 2 | 命名规范 | PASS | 沿用既有命名,AC全称可追溯。 |
+| 3 | 序列化约定 | PASS | 内部descriptor/既有envelope;仅已验证omitempty规范化。 |
+| 4 | 数据库约定 | N/A | 模型非空/索引及上下文填充通过;其余未改数据库。 |
+| 5 | 前端约定 | N/A | 无前端源代码。 |
+| 6 | 信息泄漏 | PASS | 无硬编码凭据;日志和恢复证据仅专用资源身份。 |
+| 7 | 设计同步 | PASS | design §4.10/§5同步已实现恢复、升级和报告约束。 |
+
+**元数据验证**: 文件范围 PASS | 配对测试 PASS/N/A | 依赖 PASS
+**结果**: PASS(代码合同;不替代T067–T072真实验收及L2)
+
+
+## Task Review: T143 — 续办复核
+
+**任务**: canary 配置投射刷新合同
+**类型**: 测试
+**文件**: `src/runtime-manager/tests/test_k8s_installation.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 测试仅HTTP/隔离Session;生产未增跨层依赖。 |
+| 2 | 命名规范 | PASS | 沿用既有命名,AC全称可追溯。 |
+| 3 | 序列化约定 | N/A | 内部descriptor/既有envelope;仅已验证omitempty规范化。 |
+| 4 | 数据库约定 | N/A | 模型非空/索引及上下文填充通过;其余未改数据库。 |
+| 5 | 前端约定 | N/A | 无前端源代码。 |
+| 6 | 信息泄漏 | PASS | 无硬编码凭据;日志和恢复证据仅专用资源身份。 |
+| 7 | 设计同步 | N/A | design §4.10/§5同步已实现恢复、升级和报告约束。 |
+
+**元数据验证**: 文件范围 PASS | 配对测试 PASS/N/A | 依赖 PASS
+**结果**: PASS(代码合同;不替代T067–T072真实验收及L2)
+
+
+## Task Review: T144 — 续办复核
+
+**任务**: canary 配置投射刷新
+**类型**: 实现
+**文件**: `scripts/verify_k8s_runtime.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 测试仅HTTP/隔离Session;生产未增跨层依赖。 |
+| 2 | 命名规范 | PASS | 沿用既有命名,AC全称可追溯。 |
+| 3 | 序列化约定 | PASS | 内部descriptor/既有envelope;仅已验证omitempty规范化。 |
+| 4 | 数据库约定 | N/A | 模型非空/索引及上下文填充通过;其余未改数据库。 |
+| 5 | 前端约定 | N/A | 无前端源代码。 |
+| 6 | 信息泄漏 | PASS | 无硬编码凭据;日志和恢复证据仅专用资源身份。 |
+| 7 | 设计同步 | PASS | design §4.10/§5同步已实现恢复、升级和报告约束。 |
+
+**元数据验证**: 文件范围 PASS | 配对测试 PASS/N/A | 依赖 PASS
+**结果**: PASS(代码合同;不替代T067–T072真实验收及L2)
+
+
+## Task Review: T145 — 续办复核
+
+**任务**: 过期 canary 恢复围栏合同
+**类型**: 测试
+**文件**: `src/runtime-manager/tests/test_k8s_installation.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 测试仅HTTP/隔离Session;生产未增跨层依赖。 |
+| 2 | 命名规范 | PASS | 沿用既有命名,AC全称可追溯。 |
+| 3 | 序列化约定 | N/A | 内部descriptor/既有envelope;仅已验证omitempty规范化。 |
+| 4 | 数据库约定 | N/A | 模型非空/索引及上下文填充通过;其余未改数据库。 |
+| 5 | 前端约定 | N/A | 无前端源代码。 |
+| 6 | 信息泄漏 | PASS | 无硬编码凭据;日志和恢复证据仅专用资源身份。 |
+| 7 | 设计同步 | N/A | design §4.10/§5同步已实现恢复、升级和报告约束。 |
+
+**元数据验证**: 文件范围 PASS | 配对测试 PASS/N/A | 依赖 PASS
+**结果**: PASS(代码合同;不替代T067–T072真实验收及L2)
+
+
+## Task Review: T146 — 续办复核
+
+**任务**: 过期 canary 逐资源恢复
+**类型**: 实现
+**文件**: `scripts/verify_k8s_runtime.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 测试仅HTTP/隔离Session;生产未增跨层依赖。 |
+| 2 | 命名规范 | PASS | 沿用既有命名,AC全称可追溯。 |
+| 3 | 序列化约定 | PASS | 内部descriptor/既有envelope;仅已验证omitempty规范化。 |
+| 4 | 数据库约定 | N/A | 模型非空/索引及上下文填充通过;其余未改数据库。 |
+| 5 | 前端约定 | N/A | 无前端源代码。 |
+| 6 | 信息泄漏 | PASS | 无硬编码凭据;日志和恢复证据仅专用资源身份。 |
+| 7 | 设计同步 | PASS | design §4.10/§5同步已实现恢复、升级和报告约束。 |
+
+**元数据验证**: 文件范围 PASS | 配对测试 PASS/N/A | 依赖 PASS
+**结果**: PASS(代码合同;不替代T067–T072真实验收及L2)
+
+
+## Task Review: T147 — 续办复核
+
+**任务**: CNI 身份与能力报告绑定合同
+**类型**: 测试
+**文件**: `src/runtime-manager/tests/test_k8s_installation.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 测试仅HTTP/隔离Session;生产未增跨层依赖。 |
+| 2 | 命名规范 | PASS | 沿用既有命名,AC全称可追溯。 |
+| 3 | 序列化约定 | N/A | 内部descriptor/既有envelope;仅已验证omitempty规范化。 |
+| 4 | 数据库约定 | N/A | 模型非空/索引及上下文填充通过;其余未改数据库。 |
+| 5 | 前端约定 | N/A | 无前端源代码。 |
+| 6 | 信息泄漏 | PASS | 无硬编码凭据;日志和恢复证据仅专用资源身份。 |
+| 7 | 设计同步 | N/A | design §4.10/§5同步已实现恢复、升级和报告约束。 |
+
+**元数据验证**: 文件范围 PASS | 配对测试 PASS/N/A | 依赖 PASS
+**结果**: PASS(代码合同;不替代T067–T072真实验收及L2)
+
+
+## Task Review: T148 — 续办复核
+
+**任务**: 描述纳入 CNI 身份
+**类型**: 基础设施
+**文件**: `deploy/kubernetes/app-runtime/templates/workloads.yaml`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 测试仅HTTP/隔离Session;生产未增跨层依赖。 |
+| 2 | 命名规范 | PASS | 沿用既有命名,AC全称可追溯。 |
+| 3 | 序列化约定 | PASS | 内部descriptor/既有envelope;仅已验证omitempty规范化。 |
+| 4 | 数据库约定 | N/A | 模型非空/索引及上下文填充通过;其余未改数据库。 |
+| 5 | 前端约定 | N/A | 无前端源代码。 |
+| 6 | 信息泄漏 | PASS | 无硬编码凭据;日志和恢复证据仅专用资源身份。 |
+| 7 | 设计同步 | PASS | design §4.10/§5同步已实现恢复、升级和报告约束。 |
+
+**元数据验证**: 文件范围 PASS | 配对测试 PASS/N/A | 依赖 PASS
+**结果**: PASS(代码合同;不替代T067–T072真实验收及L2)
+
+
+## Task Review: T149 — 续办复核
+
+**任务**: 租户上下文默认值合同
+**类型**: 测试
+**文件**: `src/backend/test/app_runtime/test_runtime_model_contracts.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 测试仅HTTP/隔离Session;生产未增跨层依赖。 |
+| 2 | 命名规范 | PASS | 沿用既有命名,AC全称可追溯。 |
+| 3 | 序列化约定 | N/A | 内部descriptor/既有envelope;仅已验证omitempty规范化。 |
+| 4 | 数据库约定 | PASS | 模型非空/索引及上下文填充通过;其余未改数据库。 |
+| 5 | 前端约定 | N/A | 无前端源代码。 |
+| 6 | 信息泄漏 | PASS | 无硬编码凭据;日志和恢复证据仅专用资源身份。 |
+| 7 | 设计同步 | N/A | design §4.10/§5同步已实现恢复、升级和报告约束。 |
+
+**元数据验证**: 文件范围 PASS | 配对测试 PASS/N/A | 依赖 PASS
+**结果**: PASS(代码合同;不替代T067–T072真实验收及L2)
+
+
+## Task Review: T150 — 续办复核
+
+**任务**: 运行记录租户默认值修订
+**类型**: 基础设施
+**文件**: `src/backend/bisheng/app_runtime/domain/models/runtime_delivery.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 测试仅HTTP/隔离Session;生产未增跨层依赖。 |
+| 2 | 命名规范 | PASS | 沿用既有命名,AC全称可追溯。 |
+| 3 | 序列化约定 | PASS | 内部descriptor/既有envelope;仅已验证omitempty规范化。 |
+| 4 | 数据库约定 | PASS | 模型非空/索引及上下文填充通过;其余未改数据库。 |
+| 5 | 前端约定 | N/A | 无前端源代码。 |
+| 6 | 信息泄漏 | PASS | 无硬编码凭据;日志和恢复证据仅专用资源身份。 |
+| 7 | 设计同步 | PASS | design §4.10/§5同步已实现恢复、升级和报告约束。 |
+
+**元数据验证**: 文件范围 PASS | 配对测试 PASS/N/A | 依赖 PASS
+**结果**: PASS(代码合同;不替代T067–T072真实验收及L2)
+
+
+## Task Review: T151 — 续办复核
+
+**任务**: Helm 升级保留 canary 计划合同
+**类型**: 测试
+**文件**: `src/runtime-manager/tests/test_k8s_installation.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 测试仅HTTP/隔离Session;生产未增跨层依赖。 |
+| 2 | 命名规范 | PASS | 沿用既有命名,AC全称可追溯。 |
+| 3 | 序列化约定 | N/A | 内部descriptor/既有envelope;仅已验证omitempty规范化。 |
+| 4 | 数据库约定 | N/A | 模型非空/索引及上下文填充通过;其余未改数据库。 |
+| 5 | 前端约定 | N/A | 无前端源代码。 |
+| 6 | 信息泄漏 | PASS | 无硬编码凭据;日志和恢复证据仅专用资源身份。 |
+| 7 | 设计同步 | N/A | design §4.10/§5同步已实现恢复、升级和报告约束。 |
+
+**元数据验证**: 文件范围 PASS | 配对测试 PASS/N/A | 依赖 PASS
+**结果**: PASS(代码合同;不替代T067–T072真实验收及L2)
+
+
+## Task Review: T152 — 续办复核
+
+**任务**: Helm 升级保留恢复计划
+**类型**: 基础设施
+**文件**: `deploy/kubernetes/app-runtime/templates/services.yaml`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 测试仅HTTP/隔离Session;生产未增跨层依赖。 |
+| 2 | 命名规范 | PASS | 沿用既有命名,AC全称可追溯。 |
+| 3 | 序列化约定 | PASS | 内部descriptor/既有envelope;仅已验证omitempty规范化。 |
+| 4 | 数据库约定 | N/A | 模型非空/索引及上下文填充通过;其余未改数据库。 |
+| 5 | 前端约定 | N/A | 无前端源代码。 |
+| 6 | 信息泄漏 | PASS | 无硬编码凭据;日志和恢复证据仅专用资源身份。 |
+| 7 | 设计同步 | PASS | design §4.10/§5同步已实现恢复、升级和报告约束。 |
+
+**元数据验证**: 文件范围 PASS | 配对测试 PASS/N/A | 依赖 PASS
+**结果**: PASS(代码合同;不替代T067–T072真实验收及L2)
+
+
+## Task Review: T153 — 续办复核
+
+**任务**: 原生 Docker context 构建合同
+**类型**: 测试
+**文件**: `src/runtime-manager/tests/test_image_release.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 测试仅HTTP/隔离Session;生产未增跨层依赖。 |
+| 2 | 命名规范 | PASS | 沿用既有命名,AC全称可追溯。 |
+| 3 | 序列化约定 | N/A | 内部descriptor/既有envelope;仅已验证omitempty规范化。 |
+| 4 | 数据库约定 | N/A | 模型非空/索引及上下文填充通过;其余未改数据库。 |
+| 5 | 前端约定 | N/A | 无前端源代码。 |
+| 6 | 信息泄漏 | PASS | 无硬编码凭据;日志和恢复证据仅专用资源身份。 |
+| 7 | 设计同步 | N/A | design §4.10/§5同步已实现恢复、升级和报告约束。 |
+
+**元数据验证**: 文件范围 PASS | 配对测试 PASS/N/A | 依赖 PASS
+**结果**: PASS(代码合同;不替代T067–T072真实验收及L2)
+
+
+## Task Review: T154 — 续办复核
+
+**任务**: 原生 context 构建入口
+**类型**: 实现
+**文件**: `scripts/build_runtime_images.sh`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 测试仅HTTP/隔离Session;生产未增跨层依赖。 |
+| 2 | 命名规范 | PASS | 沿用既有命名,AC全称可追溯。 |
+| 3 | 序列化约定 | PASS | 内部descriptor/既有envelope;仅已验证omitempty规范化。 |
+| 4 | 数据库约定 | N/A | 模型非空/索引及上下文填充通过;其余未改数据库。 |
+| 5 | 前端约定 | N/A | 无前端源代码。 |
+| 6 | 信息泄漏 | PASS | 无硬编码凭据;日志和恢复证据仅专用资源身份。 |
+| 7 | 设计同步 | PASS | design §4.10/§5同步已实现恢复、升级和报告约束。 |
+
+**元数据验证**: 文件范围 PASS | 配对测试 PASS/N/A | 依赖 PASS
+**结果**: PASS(代码合同;不替代T067–T072真实验收及L2)
+
+
+## Task Review: T155 — 续办复核
+
+**任务**: 预览访问和只读 checkpoint 合同
+**类型**: 测试
+**文件**: `src/backend/test/app_runtime/test_k8s_e2e_contract.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 测试仅HTTP/隔离Session;生产未增跨层依赖。 |
+| 2 | 命名规范 | PASS | 沿用既有命名,AC全称可追溯。 |
+| 3 | 序列化约定 | N/A | 内部descriptor/既有envelope;仅已验证omitempty规范化。 |
+| 4 | 数据库约定 | N/A | 模型非空/索引及上下文填充通过;其余未改数据库。 |
+| 5 | 前端约定 | N/A | 无前端源代码。 |
+| 6 | 信息泄漏 | PASS | 无硬编码凭据;日志和恢复证据仅专用资源身份。 |
+| 7 | 设计同步 | N/A | design §4.10/§5同步已实现恢复、升级和报告约束。 |
+
+**元数据验证**: 文件范围 PASS | 配对测试 PASS/N/A | 依赖 PASS
+**结果**: PASS(代码合同;不替代T067–T072真实验收及L2)
+
+
+## Task Review: T156 — 续办复核
+
+**任务**: 预览入口与只读故障观察
+**类型**: 实现
+**文件**: `src/backend/test/e2e/helpers/vibe_runtime.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 测试仅HTTP/隔离Session;生产未增跨层依赖。 |
+| 2 | 命名规范 | PASS | 沿用既有命名,AC全称可追溯。 |
+| 3 | 序列化约定 | PASS | 内部descriptor/既有envelope;仅已验证omitempty规范化。 |
+| 4 | 数据库约定 | N/A | 模型非空/索引及上下文填充通过;其余未改数据库。 |
+| 5 | 前端约定 | N/A | 无前端源代码。 |
+| 6 | 信息泄漏 | PASS | 无硬编码凭据;日志和恢复证据仅专用资源身份。 |
+| 7 | 设计同步 | PASS | design §4.10/§5同步已实现恢复、升级和报告约束。 |
+
+**元数据验证**: 文件范围 PASS | 配对测试 PASS/N/A | 依赖 PASS
+**结果**: PASS(代码合同;不替代T067–T072真实验收及L2)
+
+
+## Task Review: T157 — 续办复核
+
+**任务**: 真实 pytest 入口 CA 一致性
+**类型**: 测试
+**文件**: `src/backend/test/e2e/test_e2e_vibe_k8s_runtime.py`、`src/backend/test/app_runtime/test_k8s_e2e_contract.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 测试仅HTTP/隔离Session;生产未增跨层依赖。 |
+| 2 | 命名规范 | PASS | 沿用既有命名,AC全称可追溯。 |
+| 3 | 序列化约定 | N/A | 内部descriptor/既有envelope;仅已验证omitempty规范化。 |
+| 4 | 数据库约定 | N/A | 模型非空/索引及上下文填充通过;其余未改数据库。 |
+| 5 | 前端约定 | N/A | 无前端源代码。 |
+| 6 | 信息泄漏 | PASS | 无硬编码凭据;日志和恢复证据仅专用资源身份。 |
+| 7 | 设计同步 | N/A | design §4.10/§5同步已实现恢复、升级和报告约束。 |
+
+**元数据验证**: 文件范围 PASS | 配对测试 PASS/N/A | 依赖 PASS
+**结果**: PASS(代码合同;不替代T067–T072真实验收及L2)
+
+
+## Task Review: T161 — 续办复核
+
+**任务**: 空 finalizers 序列化合同
+**类型**: 测试
+**文件**: `src/runtime-manager/tests/test_k8s_admission.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 测试仅HTTP/隔离Session;生产未增跨层依赖。 |
+| 2 | 命名规范 | PASS | 沿用既有命名,AC全称可追溯。 |
+| 3 | 序列化约定 | N/A | 内部descriptor/既有envelope;仅已验证omitempty规范化。 |
+| 4 | 数据库约定 | N/A | 模型非空/索引及上下文填充通过;其余未改数据库。 |
+| 5 | 前端约定 | N/A | 无前端源代码。 |
+| 6 | 信息泄漏 | PASS | 无硬编码凭据;日志和恢复证据仅专用资源身份。 |
+| 7 | 设计同步 | N/A | design §4.10/§5同步已实现恢复、升级和报告约束。 |
+
+**元数据验证**: 文件范围 PASS | 配对测试 PASS/N/A | 依赖 PASS
+**结果**: PASS(代码合同;不替代T067–T072真实验收及L2)
+
+
+## Task Review: T162 — 续办复核
+
+**任务**: 准入兼容省略空 finalizers
+**类型**: 实现
+**文件**: `src/runtime-manager/runtime_manager/k8s_admission.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 测试仅HTTP/隔离Session;生产未增跨层依赖。 |
+| 2 | 命名规范 | PASS | 沿用既有命名,AC全称可追溯。 |
+| 3 | 序列化约定 | PASS | 内部descriptor/既有envelope;仅已验证omitempty规范化。 |
+| 4 | 数据库约定 | N/A | 模型非空/索引及上下文填充通过;其余未改数据库。 |
+| 5 | 前端约定 | N/A | 无前端源代码。 |
+| 6 | 信息泄漏 | PASS | 无硬编码凭据;日志和恢复证据仅专用资源身份。 |
+| 7 | 设计同步 | PASS | design §4.10/§5同步已实现恢复、升级和报告约束。 |
+
+**元数据验证**: 文件范围 PASS | 配对测试 PASS/N/A | 依赖 PASS
+**结果**: PASS(代码合同;不替代T067–T072真实验收及L2)
+
+## Task Review: T158 — 现场修复及合流复核
+
+**任务**:精确故障操作合同
+**类型**:测试
+**文件**:`src/backend/test/app_runtime/test_k8s_e2e_contract.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 仅执行器/外部installer/验收工具既有边界;无backend反向依赖。 |
+| 2 | 命名规范 | PASS | snake_case及既有参数命名;测试AC标注使用完整ID。 |
+| 3 | 序列化约定 | N/A | 既有内部descriptor/JSON;部署API与摘要函数不改。 |
+| 4 | 数据库约定 | N/A | 不新增ORM或SQL业务写入。 |
+| 5 | 前端约定 | N/A | 无前端代码。 |
+| 6 | 信息泄漏 | PASS | 失败只输出安全原因;不记录token/响应正文;测试采用合成数据。 |
+| 7 | 设计同步 | N/A | design §4.10已同步具体API行为、身份配置及故障验收边界。 |
+
+**验证**:业务harness69passed/4live-skipped;真实API未在此计通过。
+
+**元数据验证**:文件范围 PASS;配对测试 N/A;依赖 PASS(按任务索引顺序及完成状态核对)。
+
+**结果**:PASS。
+
+## Task Review: T159 — 现场修复及合流复核
+
+**任务**:精确取消及快照恢复 harness
+**类型**:实现
+**文件**:`src/backend/test/e2e/helpers/vibe_runtime.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 仅执行器/外部installer/验收工具既有边界;无backend反向依赖。 |
+| 2 | 命名规范 | PASS | snake_case及既有参数命名;测试AC标注使用完整ID。 |
+| 3 | 序列化约定 | PASS | 既有内部descriptor/JSON;部署API与摘要函数不改。 |
+| 4 | 数据库约定 | N/A | 不新增ORM或SQL业务写入。 |
+| 5 | 前端约定 | N/A | 无前端代码。 |
+| 6 | 信息泄漏 | PASS | 失败只输出安全原因;不记录token/响应正文;测试采用合成数据。 |
+| 7 | 设计同步 | PASS | design §4.10已同步具体API行为、身份配置及故障验收边界。 |
+
+**验证**:业务harness69passed/4live-skipped;真实API未在此计通过。
+
+**元数据验证**:文件范围 PASS;配对测试 PASS;依赖 PASS(按任务索引顺序及完成状态核对)。
+
+**结果**:PASS。
+
+## Task Review: T163 — 现场修复及合流复核
+
+**任务**:故障操作 CLI 参数合同
+**类型**:测试
+**文件**:`src/backend/test/app_runtime/test_k8s_e2e_contract.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 仅执行器/外部installer/验收工具既有边界;无backend反向依赖。 |
+| 2 | 命名规范 | PASS | snake_case及既有参数命名;测试AC标注使用完整ID。 |
+| 3 | 序列化约定 | N/A | 既有内部descriptor/JSON;部署API与摘要函数不改。 |
+| 4 | 数据库约定 | N/A | 不新增ORM或SQL业务写入。 |
+| 5 | 前端约定 | N/A | 无前端代码。 |
+| 6 | 信息泄漏 | PASS | 失败只输出安全原因;不记录token/响应正文;测试采用合成数据。 |
+| 7 | 设计同步 | N/A | design §4.10已同步具体API行为、身份配置及故障验收边界。 |
+
+**验证**:业务harness69passed/4live-skipped;真实API未在此计通过。
+
+**元数据验证**:文件范围 PASS;配对测试 N/A;依赖 PASS(按任务索引顺序及完成状态核对)。
+
+**结果**:PASS。
+
+## Task Review: T160 — 现场修复及合流复核
+
+**任务**:故障操作 CLI 入口
+**类型**:实现
+**文件**:`scripts/e2e_k8s_runtime.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 仅执行器/外部installer/验收工具既有边界;无backend反向依赖。 |
+| 2 | 命名规范 | PASS | snake_case及既有参数命名;测试AC标注使用完整ID。 |
+| 3 | 序列化约定 | PASS | 既有内部descriptor/JSON;部署API与摘要函数不改。 |
+| 4 | 数据库约定 | N/A | 不新增ORM或SQL业务写入。 |
+| 5 | 前端约定 | N/A | 无前端代码。 |
+| 6 | 信息泄漏 | PASS | 失败只输出安全原因;不记录token/响应正文;测试采用合成数据。 |
+| 7 | 设计同步 | PASS | design §4.10已同步具体API行为、身份配置及故障验收边界。 |
+
+**验证**:业务harness69passed/4live-skipped;真实API未在此计通过。
+
+**元数据验证**:文件范围 PASS;配对测试 PASS;依赖 PASS(按任务索引顺序及完成状态核对)。
+
+**结果**:PASS。
+
+## Task Review: T164 — 现场修复及合流复核
+
+**任务**:kubectl 子资源拒绝合同
+**类型**:测试
+**文件**:`src/runtime-manager/tests/test_k8s_installation.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 仅执行器/外部installer/验收工具既有边界;无backend反向依赖。 |
+| 2 | 命名规范 | PASS | snake_case及既有参数命名;测试AC标注使用完整ID。 |
+| 3 | 序列化约定 | N/A | 既有内部descriptor/JSON;部署API与摘要函数不改。 |
+| 4 | 数据库约定 | N/A | 不新增ORM或SQL业务写入。 |
+| 5 | 前端约定 | N/A | 无前端代码。 |
+| 6 | 信息泄漏 | PASS | 失败只输出安全原因;不记录token/响应正文;测试采用合成数据。 |
+| 7 | 设计同步 | N/A | design §4.10已同步具体API行为、身份配置及故障验收边界。 |
+
+**验证**:主线installer/admission/capabilities组合263passed;runtime1129passed/10skipped(发行权限修复前),真实基线尚在重测。
+
+**元数据验证**:文件范围 PASS;配对测试 N/A;依赖 PASS(按任务索引顺序及完成状态核对)。
+
+**结果**:PASS。
+
+## Task Review: T165 — 现场修复及合流复核
+
+**任务**:kubectl 子资源显式检查
+**类型**:实现
+**文件**:`scripts/verify_k8s_runtime.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 仅执行器/外部installer/验收工具既有边界;无backend反向依赖。 |
+| 2 | 命名规范 | PASS | snake_case及既有参数命名;测试AC标注使用完整ID。 |
+| 3 | 序列化约定 | PASS | 既有内部descriptor/JSON;部署API与摘要函数不改。 |
+| 4 | 数据库约定 | N/A | 不新增ORM或SQL业务写入。 |
+| 5 | 前端约定 | N/A | 无前端代码。 |
+| 6 | 信息泄漏 | PASS | 失败只输出安全原因;不记录token/响应正文;测试采用合成数据。 |
+| 7 | 设计同步 | PASS | design §4.10已同步具体API行为、身份配置及故障验收边界。 |
+
+**验证**:主线installer/admission/capabilities组合263passed;runtime1129passed/10skipped(发行权限修复前),真实基线尚在重测。
+
+**元数据验证**:文件范围 PASS;配对测试 PASS;依赖 PASS(按任务索引顺序及完成状态核对)。
+
+**结果**:PASS。
+
+## Task Review: T166 — 现场修复及合流复核
+
+**任务**:存储控制器身份配置合同
+**类型**:测试
+**文件**:`src/runtime-manager/tests/test_k8s_installation.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 仅执行器/外部installer/验收工具既有边界;无backend反向依赖。 |
+| 2 | 命名规范 | PASS | snake_case及既有参数命名;测试AC标注使用完整ID。 |
+| 3 | 序列化约定 | N/A | 既有内部descriptor/JSON;部署API与摘要函数不改。 |
+| 4 | 数据库约定 | N/A | 不新增ORM或SQL业务写入。 |
+| 5 | 前端约定 | N/A | 无前端代码。 |
+| 6 | 信息泄漏 | PASS | 失败只输出安全原因;不记录token/响应正文;测试采用合成数据。 |
+| 7 | 设计同步 | N/A | design §4.10已同步具体API行为、身份配置及故障验收边界。 |
+
+**验证**:主线installer/admission/capabilities组合263passed;runtime1129passed/10skipped(发行权限修复前),真实基线尚在重测。
+
+**元数据验证**:文件范围 PASS;配对测试 N/A;依赖 PASS(按任务索引顺序及完成状态核对)。
+
+**结果**:PASS。
+
+## Task Review: T167 — 现场修复及合流复核
+
+**任务**:存储控制器配置与schema
+**类型**:基础设施
+**文件**:`deploy/kubernetes/app-runtime/values.yaml`、`deploy/kubernetes/app-runtime/values.schema.json`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 仅执行器/外部installer/验收工具既有边界;无backend反向依赖。 |
+| 2 | 命名规范 | PASS | snake_case及既有参数命名;测试AC标注使用完整ID。 |
+| 3 | 序列化约定 | PASS | 既有内部descriptor/JSON;部署API与摘要函数不改。 |
+| 4 | 数据库约定 | N/A | 不新增ORM或SQL业务写入。 |
+| 5 | 前端约定 | N/A | 无前端代码。 |
+| 6 | 信息泄漏 | PASS | 失败只输出安全原因;不记录token/响应正文;测试采用合成数据。 |
+| 7 | 设计同步 | PASS | design §4.10已同步具体API行为、身份配置及故障验收边界。 |
+
+**验证**:主线installer/admission/capabilities组合263passed;runtime1129passed/10skipped(发行权限修复前),真实基线尚在重测。
+
+**元数据验证**:文件范围 PASS;配对测试 PASS;依赖 PASS(按任务索引顺序及完成状态核对)。
+
+**结果**:PASS。
+
+## Task Review: T168 — 现场修复及合流复核
+
+**任务**:存储控制器环境与描述同源
+**类型**:基础设施
+**文件**:`deploy/kubernetes/app-runtime/templates/workloads.yaml`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 仅执行器/外部installer/验收工具既有边界;无backend反向依赖。 |
+| 2 | 命名规范 | PASS | snake_case及既有参数命名;测试AC标注使用完整ID。 |
+| 3 | 序列化约定 | PASS | 既有内部descriptor/JSON;部署API与摘要函数不改。 |
+| 4 | 数据库约定 | N/A | 不新增ORM或SQL业务写入。 |
+| 5 | 前端约定 | N/A | 无前端代码。 |
+| 6 | 信息泄漏 | PASS | 失败只输出安全原因;不记录token/响应正文;测试采用合成数据。 |
+| 7 | 设计同步 | PASS | design §4.10已同步具体API行为、身份配置及故障验收边界。 |
+
+**验证**:主线installer/admission/capabilities组合263passed;runtime1129passed/10skipped(发行权限修复前),真实基线尚在重测。
+
+**元数据验证**:文件范围 PASS;配对测试 N/A;依赖 PASS(按任务索引顺序及完成状态核对)。
+
+**结果**:PASS。
+
+## Task Review: T169 — 现场修复及合流复核
+
+**任务**:API未绑定Pod零宽限删除合同
+**类型**:测试
+**文件**:`src/runtime-manager/tests/test_k8s_admission.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 仅执行器/外部installer/验收工具既有边界;无backend反向依赖。 |
+| 2 | 命名规范 | PASS | snake_case及既有参数命名;测试AC标注使用完整ID。 |
+| 3 | 序列化约定 | N/A | 既有内部descriptor/JSON;部署API与摘要函数不改。 |
+| 4 | 数据库约定 | N/A | 不新增ORM或SQL业务写入。 |
+| 5 | 前端约定 | N/A | 无前端代码。 |
+| 6 | 信息泄漏 | PASS | 失败只输出安全原因;不记录token/响应正文;测试采用合成数据。 |
+| 7 | 设计同步 | N/A | design §4.10已同步具体API行为、身份配置及故障验收边界。 |
+
+**验证**:主线installer/admission/capabilities组合263passed;runtime1129passed/10skipped(发行权限修复前),真实基线尚在重测。
+
+**元数据验证**:文件范围 PASS;配对测试 N/A;依赖 PASS(按任务索引顺序及完成状态核对)。
+
+**结果**:PASS。
+
+## Task Review: T170 — 现场修复及合流复核
+
+**任务**:未绑定Pod删除选项兼容
+**类型**:实现
+**文件**:`src/runtime-manager/runtime_manager/k8s_admission.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 仅执行器/外部installer/验收工具既有边界;无backend反向依赖。 |
+| 2 | 命名规范 | PASS | snake_case及既有参数命名;测试AC标注使用完整ID。 |
+| 3 | 序列化约定 | PASS | 既有内部descriptor/JSON;部署API与摘要函数不改。 |
+| 4 | 数据库约定 | N/A | 不新增ORM或SQL业务写入。 |
+| 5 | 前端约定 | N/A | 无前端代码。 |
+| 6 | 信息泄漏 | PASS | 失败只输出安全原因;不记录token/响应正文;测试采用合成数据。 |
+| 7 | 设计同步 | PASS | design §4.10已同步具体API行为、身份配置及故障验收边界。 |
+
+**验证**:主线installer/admission/capabilities组合263passed;runtime1129passed/10skipped(发行权限修复前),真实基线尚在重测。
+
+**元数据验证**:文件范围 PASS;配对测试 PASS;依赖 PASS(按任务索引顺序及完成状态核对)。
+
+**结果**:PASS。
+
+## Task Review: T171 — 现场修复及合流复核
+
+**任务**:PSA负例和dry-run撤销合同
+**类型**:测试
+**文件**:`src/runtime-manager/tests/test_k8s_installation.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 仅执行器/外部installer/验收工具既有边界;无backend反向依赖。 |
+| 2 | 命名规范 | PASS | snake_case及既有参数命名;测试AC标注使用完整ID。 |
+| 3 | 序列化约定 | N/A | 既有内部descriptor/JSON;部署API与摘要函数不改。 |
+| 4 | 数据库约定 | N/A | 不新增ORM或SQL业务写入。 |
+| 5 | 前端约定 | N/A | 无前端代码。 |
+| 6 | 信息泄漏 | PASS | 失败只输出安全原因;不记录token/响应正文;测试采用合成数据。 |
+| 7 | 设计同步 | N/A | design §4.10已同步具体API行为、身份配置及故障验收边界。 |
+
+**验证**:主线installer/admission/capabilities组合263passed;runtime1129passed/10skipped(发行权限修复前),真实基线尚在重测。
+
+**元数据验证**:文件范围 PASS;配对测试 N/A;依赖 PASS(按任务索引顺序及完成状态核对)。
+
+**结果**:PASS。
+
+## Task Review: T172 — 现场修复及合流复核
+
+**任务**:PSA真实拒绝及计划清理
+**类型**:实现
+**文件**:`scripts/verify_k8s_runtime.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 仅执行器/外部installer/验收工具既有边界;无backend反向依赖。 |
+| 2 | 命名规范 | PASS | snake_case及既有参数命名;测试AC标注使用完整ID。 |
+| 3 | 序列化约定 | PASS | 既有内部descriptor/JSON;部署API与摘要函数不改。 |
+| 4 | 数据库约定 | N/A | 不新增ORM或SQL业务写入。 |
+| 5 | 前端约定 | N/A | 无前端代码。 |
+| 6 | 信息泄漏 | PASS | 失败只输出安全原因;不记录token/响应正文;测试采用合成数据。 |
+| 7 | 设计同步 | PASS | design §4.10已同步具体API行为、身份配置及故障验收边界。 |
+
+**验证**:主线installer/admission/capabilities组合263passed;runtime1129passed/10skipped(发行权限修复前),真实基线尚在重测。
+
+**元数据验证**:文件范围 PASS;配对测试 PASS;依赖 PASS(按任务索引顺序及完成状态核对)。
+
+**结果**:PASS。
+
+## Task Review: T173 — 现场修复及合流复核
+
+**任务**:原生隔离烟测可读目录合同
+**类型**:测试
+**文件**:`src/runtime-manager/tests/test_image_release.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 仅执行器/外部installer/验收工具既有边界;无backend反向依赖。 |
+| 2 | 命名规范 | PASS | snake_case及既有参数命名;测试AC标注使用完整ID。 |
+| 3 | 序列化约定 | N/A | 既有内部descriptor/JSON;部署API与摘要函数不改。 |
+| 4 | 数据库约定 | N/A | 不新增ORM或SQL业务写入。 |
+| 5 | 前端约定 | N/A | 无前端代码。 |
+| 6 | 信息泄漏 | PASS | 失败只输出安全原因;不记录token/响应正文;测试采用合成数据。 |
+| 7 | 设计同步 | N/A | design §4.10已同步具体API行为、身份配置及故障验收边界。 |
+
+**验证**:发行合同红2failed→绿31passed;原生Linux UID10001对照证据,不替代189最终镜像。
+
+**元数据验证**:文件范围 PASS;配对测试 N/A;依赖 PASS(按任务索引顺序及完成状态核对)。
+
+**结果**:PASS。
+
+## Task Review: T174 — 现场修复及合流复核
+
+**任务**:原生隔离烟测目录权限
+**类型**:实现
+**文件**:`scripts/build_runtime_images.sh`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 仅执行器/外部installer/验收工具既有边界;无backend反向依赖。 |
+| 2 | 命名规范 | PASS | snake_case及既有参数命名;测试AC标注使用完整ID。 |
+| 3 | 序列化约定 | PASS | 既有内部descriptor/JSON;部署API与摘要函数不改。 |
+| 4 | 数据库约定 | N/A | 不新增ORM或SQL业务写入。 |
+| 5 | 前端约定 | N/A | 无前端代码。 |
+| 6 | 信息泄漏 | PASS | 失败只输出安全原因;不记录token/响应正文;测试采用合成数据。 |
+| 7 | 设计同步 | PASS | design §4.10已同步具体API行为、身份配置及故障验收边界。 |
+
+**验证**:发行合同红2failed→绿31passed;原生Linux UID10001对照证据,不替代189最终镜像。
+
+**元数据验证**:文件范围 PASS;配对测试 PASS;依赖 PASS(按任务索引顺序及完成状态核对)。
+
+**结果**:PASS。
+
+## Task Review: T175 — 终态与Docker真实回归补充
+
+**任务**:终态Pod删除规范化合同
+
+**文件**:`src/runtime-manager/tests/test_k8s_admission.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 仅现有准入或测试工具,Owner边界不变。 |
+| 2 | 命名规范 | PASS | 遵循模块命名,完整AC标注。 |
+| 3 | 序列化约定 | N/A | 仅已知API行为;Docker公开形状保持。 |
+| 4 | 数据库约定 | N/A | 无模型变更;测试仅本suiteSQLite。 |
+| 5 | 前端约定 | N/A | 无前端源代码。 |
+| 6 | 信息泄漏 | PASS | 无真实秘密;诊断拒绝输出凭据和响应体。 |
+| 7 | 设计同步 | N/A | design §4.10同步oldObject信任、诊断及随机哨兵。 |
+
+**验证**:终态准入组合325passed,主线runtime1171/10skip(后续SQLite测试工具另验)。
+
+**元数据验证**:目标文件存在、范围PASS;配对/前置完成状态PASS。
+
+**结果**:PASS。
+
+## Task Review: T176 — 终态与Docker真实回归补充
+
+**任务**:终态Pod删除选项兼容
+
+**文件**:`src/runtime-manager/runtime_manager/k8s_admission.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 仅现有准入或测试工具,Owner边界不变。 |
+| 2 | 命名规范 | PASS | 遵循模块命名,完整AC标注。 |
+| 3 | 序列化约定 | PASS | 仅已知API行为;Docker公开形状保持。 |
+| 4 | 数据库约定 | N/A | 无模型变更;测试仅本suiteSQLite。 |
+| 5 | 前端约定 | N/A | 无前端源代码。 |
+| 6 | 信息泄漏 | PASS | 无真实秘密;诊断拒绝输出凭据和响应体。 |
+| 7 | 设计同步 | PASS | design §4.10同步oldObject信任、诊断及随机哨兵。 |
+
+**验证**:终态准入组合325passed,主线runtime1171/10skip(后续SQLite测试工具另验)。
+
+**元数据验证**:目标文件存在、范围PASS;配对/前置完成状态PASS。
+
+**结果**:PASS。
+
+## Task Review: T177 — 终态与Docker真实回归补充
+
+**任务**:SQLite随机哨兵升级合同
+
+**文件**:`src/runtime-manager/tests/test_image_release.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 仅现有准入或测试工具,Owner边界不变。 |
+| 2 | 命名规范 | PASS | 遵循模块命名,完整AC标注。 |
+| 3 | 序列化约定 | N/A | 仅已知API行为;Docker公开形状保持。 |
+| 4 | 数据库约定 | N/A | 无模型变更;测试仅本suiteSQLite。 |
+| 5 | 前端约定 | N/A | 无前端源代码。 |
+| 6 | 信息泄漏 | PASS | 无真实秘密;诊断拒绝输出凭据和响应体。 |
+| 7 | 设计同步 | N/A | design §4.10同步oldObject信任、诊断及随机哨兵。 |
+
+**验证**:镜像合同40passed;C实际native arm64 sqlite-only随机值升级/备份通过,非平台E2E。
+
+**元数据验证**:目标文件存在、范围PASS;配对/前置完成状态PASS。
+
+**结果**:PASS。
+
+## Task Review: T178 — 终态与Docker真实回归补充
+
+**任务**:真实SQLite哨兵烟测
+
+**文件**:`scripts/smoke_runtime_delivery.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 仅现有准入或测试工具,Owner边界不变。 |
+| 2 | 命名规范 | PASS | 遵循模块命名,完整AC标注。 |
+| 3 | 序列化约定 | PASS | 仅已知API行为;Docker公开形状保持。 |
+| 4 | 数据库约定 | N/A | 无模型变更;测试仅本suiteSQLite。 |
+| 5 | 前端约定 | N/A | 无前端源代码。 |
+| 6 | 信息泄漏 | PASS | 无真实秘密;诊断拒绝输出凭据和响应体。 |
+| 7 | 设计同步 | PASS | design §4.10同步oldObject信任、诊断及随机哨兵。 |
+
+**验证**:镜像合同40passed;C实际native arm64 sqlite-only随机值升级/备份通过,非平台E2E。
+
+**元数据验证**:目标文件存在、范围PASS;配对/前置完成状态PASS。
+
+**结果**:PASS。
+
+## Task Review: T181 — 终态与Docker真实回归补充
+
+**任务**:Docker既有诊断E2E合同
+
+**文件**:`src/backend/test/app_runtime/test_k8s_e2e_contract.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 仅现有准入或测试工具,Owner边界不变。 |
+| 2 | 命名规范 | PASS | 遵循模块命名,完整AC标注。 |
+| 3 | 序列化约定 | N/A | 仅已知API行为;Docker公开形状保持。 |
+| 4 | 数据库约定 | N/A | 无模型变更;测试仅本suiteSQLite。 |
+| 5 | 前端约定 | N/A | 无前端源代码。 |
+| 6 | 信息泄漏 | PASS | 无真实秘密;诊断拒绝输出凭据和响应体。 |
+| 7 | 设计同步 | N/A | design §4.10同步oldObject信任、诊断及随机哨兵。 |
+
+**验证**:红7failed/2passed→绿78passed/4live-skipped;未放松任何K8s ready断言。
+
+**元数据验证**:目标文件存在、范围PASS;配对/前置完成状态PASS。
+
+**结果**:PASS。
+
+## Task Review: T182 — 终态与Docker真实回归补充
+
+**任务**:Docker诊断E2E兼容
+
+**文件**:`src/backend/test/e2e/helpers/vibe_runtime.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 仅现有准入或测试工具,Owner边界不变。 |
+| 2 | 命名规范 | PASS | 遵循模块命名,完整AC标注。 |
+| 3 | 序列化约定 | PASS | 仅已知API行为;Docker公开形状保持。 |
+| 4 | 数据库约定 | N/A | 无模型变更;测试仅本suiteSQLite。 |
+| 5 | 前端约定 | N/A | 无前端源代码。 |
+| 6 | 信息泄漏 | PASS | 无真实秘密;诊断拒绝输出凭据和响应体。 |
+| 7 | 设计同步 | PASS | design §4.10同步oldObject信任、诊断及随机哨兵。 |
+
+**验证**:红7failed/2passed→绿78passed/4live-skipped;未放松任何K8s ready断言。
+
+**元数据验证**:目标文件存在、范围PASS;配对/前置完成状态PASS。
+
+**结果**:PASS。
+
+## Task Review: T179 — Docker账本真实客户端
+
+**任务**:Docker诊断真实客户端合同
+
+**文件**:`src/runtime-manager/tests/test_operations_v2.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 既有PlatformClient/诊断边界;安全失败不回退;design §4.10同步。 |
+| 2 | 命名规范 | PASS | 既有PlatformClient/诊断边界;安全失败不回退;design §4.10同步。 |
+| 3 | 序列化约定 | N/A | 既有PlatformClient/诊断边界;安全失败不回退;design §4.10同步。 |
+| 4 | 数据库约定 | N/A | 既有PlatformClient/诊断边界;安全失败不回退;design §4.10同步。 |
+| 5 | 前端约定 | N/A | 既有PlatformClient/诊断边界;安全失败不回退;design §4.10同步。 |
+| 6 | 信息泄漏 | PASS | 既有PlatformClient/诊断边界;安全失败不回退;design §4.10同步。 |
+| 7 | 设计同步 | N/A | 既有PlatformClient/诊断边界;安全失败不回退;design §4.10同步。 |
+
+**验证**:真实客户端MockTransport五场景先红后绿;相关37passed。主线runtime1184passed/10skipped。
+
+**元数据验证**:目标范围/配对/依赖PASS。
+
+**结果**:PASS。
+
+## Task Review: T180 — Docker账本真实客户端
+
+**任务**:Docker诊断平台账本接线
+
+**文件**:`src/runtime-manager/runtime_manager/api/platform_observe.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 既有PlatformClient/诊断边界;安全失败不回退;design §4.10同步。 |
+| 2 | 命名规范 | PASS | 既有PlatformClient/诊断边界;安全失败不回退;design §4.10同步。 |
+| 3 | 序列化约定 | PASS | 既有PlatformClient/诊断边界;安全失败不回退;design §4.10同步。 |
+| 4 | 数据库约定 | N/A | 既有PlatformClient/诊断边界;安全失败不回退;design §4.10同步。 |
+| 5 | 前端约定 | N/A | 既有PlatformClient/诊断边界;安全失败不回退;design §4.10同步。 |
+| 6 | 信息泄漏 | PASS | 既有PlatformClient/诊断边界;安全失败不回退;design §4.10同步。 |
+| 7 | 设计同步 | PASS | 既有PlatformClient/诊断边界;安全失败不回退;design §4.10同步。 |
+
+**验证**:真实客户端MockTransport五场景先红后绿;相关37passed。主线runtime1184passed/10skipped。
+
+**元数据验证**:目标范围/配对/依赖PASS。
+
+**结果**:PASS。
+
+## Task Review: T183 — 验收批次重跑
+
+**任务**:验收批次应用标识合同
+
+**文件**:`scripts/fixtures/f059/test_bootstrap.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 仅验收bootstrap;无生产API/DAO改动。 |
+| 2 | 命名规范 | PASS | 参数与fixture一致;完整AC编号。 |
+| 3 | 序列化约定 | N/A | marker/env强校验;旧默认格式兼容且不能原地换批。 |
+| 4 | 数据库约定 | N/A | 无SQL或模型改动;经真实平台API复用身份。 |
+| 5 | 前端约定 | N/A | 无前端源代码。 |
+| 6 | 信息泄漏 | PASS | 私有env0600、不打印凭据;未知身份/路径拒绝。 |
+| 7 | 设计同步 | N/A | design §4.10已同步显式批次及不可重用slug事实。 |
+
+**验证**:18新合同先红后绿,fixture57passed;主线fixture+harness组合135passed。109新r2批次真实prepare/bootstrap成功,业务行程仍在运行。
+
+**元数据验证**:目标范围、配对测试与前置依赖PASS。
+
+**结果**:PASS。
+
+## Task Review: T184 — 验收批次重跑
+
+**任务**:唯一应用验收批次后缀
+
+**文件**:`scripts/bootstrap_f059_acceptance.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 仅验收bootstrap;无生产API/DAO改动。 |
+| 2 | 命名规范 | PASS | 参数与fixture一致;完整AC编号。 |
+| 3 | 序列化约定 | PASS | marker/env强校验;旧默认格式兼容且不能原地换批。 |
+| 4 | 数据库约定 | N/A | 无SQL或模型改动;经真实平台API复用身份。 |
+| 5 | 前端约定 | N/A | 无前端源代码。 |
+| 6 | 信息泄漏 | PASS | 私有env0600、不打印凭据;未知身份/路径拒绝。 |
+| 7 | 设计同步 | PASS | design §4.10已同步显式批次及不可重用slug事实。 |
+
+**验证**:18新合同先红后绿,fixture57passed;主线fixture+harness组合135passed。109新r2批次真实prepare/bootstrap成功,业务行程仍在运行。
+
+**元数据验证**:目标范围、配对测试与前置依赖PASS。
+
+**结果**:PASS。
+
+## T185 / T186 — 真实报告与公开诊断(L1 PASS)
+
+提交78521edd4,配对原实现3失败/14通过,修复后92通过;真实executor、投射文件和可控时钟,覆盖有效无ready、raw提示真假无权覆盖、报告过期、CNI/存储身份变化、mandatory失败、API不可达、刷新恢复。证据/private/tmp/f059-k8s-observe-{red,green}.log。逐项:分层PASS,命名PASS,序列化PASS(既有形状),数据库N/A,前端N/A,信息泄漏PASS,设计同步PASS。Ruff/架构守卫/diff通过;远端复验未完成。
+
+## T187 / T188 — SQLite WAL侧文件(L1 PASS)
+
+实现0af6eda1b,仅DataExecutor单应用数据bind恒rw,业务读SQL仍mode=ro;不使用immutable或强制checkpoint。测试原挂载断言4红;真实原payload两失败/一对照通过,修复后三种布局3通过(64.26s),离线52通过/3真实默认跳过。实际非root、networknone、rootfsRO,最新随机WAL提交可见且主DB字节不变;SQLITE_READONLY明确拒写,并发读事务保持一致。证据/private/tmp/f059-wal-fix-1792674511/result.json及/private/tmp/f059-wal-fix-review.md。分层PASS,命名PASS,序列化N/A,数据库模型N/A,前端N/A,信息泄漏PASS,设计同步PASS;Ruff/架构守卫/diff通过。发布线未含此模块,适用范围记录于tasks现状;109平台未复验,不代替T067。
+
+## T189 / T190 — 恢复操作同步(L1 PASS)
+
+提交7b728fcb6。15新增先红后绿,完整93合同通过。固定owner resume响应的operation_id,只等待该deploy completed/stage completed/cleanup complete,ready/serving和cleanup pending不能提前读数据;失败/取消/超时、app/op/version/generation变化均拒绝。数据rows只调用一次,不吞16167、无固定sleep;源码生产协议未变。逐项分层PASS、命名PASS、序列化PASS(既有公开字段)、数据库N/A、前端N/A、信息泄漏PASS、设计同步PASS。Ruff/架构守卫/diff通过;真实109同r2复验正在进行。
+
+## T068 — 189安装与报告故障配对(L1 PASS)
+
+第五轮最终控制候选54b3、Helm10实际16检查全部通过、正常清理、真实公开诊断通过;过期/身份变化/必需项失败三组真实API负例与无重启恢复通过。旧资源按真实停止/自然到期同UID闭环,最终namespace Pod/PVC和canary计划为空。没有修改kube-system或全局RBAC。分层PASS、命名PASS、序列化PASS、数据库N/A、前端N/A、信息泄漏PASS、设计同步PASS;证据installation.json/MD。业务/故障/正式CI/L2仍分列未完成。
+
+## T191–T193 L1(66e507ec8)
+
+七项复核:架构PASS(仅验收物料)、命名/AC PASS、序列化/数据库/前端N/A、信息泄漏PASS(无开发venv/自动下载/凭据输出,逐wheel SHA/SDK字节/许可)、设计同步PASS。三个目标文件与测试配对/依赖一致。先红16项后169,再针对实际scanner假阳性补红2项→最终170通过;实际裸Python禁网arm64模板构建及SDK/附件/SQLite/WS/certifi资源/TLS允许拒绝均通过。8wheel是依赖binary跳过,真实源码密钥形态仍被拒绝;保留失败材料,不计为平台验收。完整证据路径/private/tmp/f059-source-vendor-l1.md及/private/tmp/e2e-f059-source-vendor-native-0o4hsmqd/result.json。结论PASS,主树集成测试待补。
+
+## T194/T195 L1(54488cdd4 / 9e7e86cab)
+
+真实189 failed prepare类型轮廓及纯计划复算确认manifest profile字符串遮蔽已解析对象;未重跑操作或创建资源。17项schema配对先红15/2,再全部通过;OCI旧测试仅补真实prepare必有的delivery字段。主工作树全runtime **1217 passed / 13 skipped**,包含原setuid参数用例,不以子代理受限权限环境的chmod结果推断宿主全局属性。
+
+七项检查:架构PASS(manager仅消费backend冻结对象);命名PASS;序列化PASS(稳定错误枚举,API/摘要不变);数据库/前端N/A;信息泄漏PASS(不输出profile/env);设计同步PASS(design.md明确prepare与运行对象路径)。范围两测试/一实现文件、依赖/先测试配对/AC均核对通过。Ruff与架构守卫通过,主树diff检查通过。T194、T195结果PASS,真实重部署不由本地结果替代。
+
+T191–T193主树真实backend conftest组合补验:170passed/7warnings,日志/private/tmp/f059-source-vendor-integrated.log;不触发新增实际部署结论。
+
+## T196/T197 L1(0a8f5a252 / 0e493c07a)
+
+七项复核:架构/命名/序列化/信息泄漏/设计同步PASS,数据库/前端N/A。精确application调用的404/application_not_found转既有NotFoundError;资源404、403/503/timeout与错误reason/status仍是故障。42项真实PlatformClient+HMAC/nonce+ASGI配对先红4/38,相关75绿;主树完整runtime1259passed/13skipped。两文件范围/前向依赖/T196先测试配对与AC验证通过。Ruff/arch/diff通过;结论PASS,实际189新镜像四个draft查询/owner清理仍待复验。只读现场四个精确控制响应证据/private/tmp/f059-unassigned-scope-evidence.jsonl,不包含凭据或payload。
+
+## T198/T199 L1(00fbdce05 / e4052d1c2)
+
+七项:架构PASS(manager消费既有API)、命名PASS、序列化PASS(无API/摘要变更)、数据库/前端N/A、信息泄漏PASS(只固定错误与公开围栏字段)、设计同步PASS。范围两文件、前向依赖/测试先行配对/完整AC通过。16新安全配对先红10/2,最后相关173绿;主树完整runtime1275passed/13skipped,33.40s,含setuid。首次子树完整执行因Helm工具缺失1224/13/51失败,摘要明确全文被截断;配置已核工具后重新1275/13绿,未倒填失败。主树Ruff/arch/diff PASS。已有UID在新epoch的停止/观察/cleanup/fact、8线程单claim、未知响应拒绝及expired prepare不创建资源均有断言;失租不是停止证明。结论PASS,189同PID实际空闲恢复及交付五门保持pending。日志/private/tmp/f059-controller-integrated-runtime.log;子树L1/private/tmp/f059-t198-t199-l1.md。
+
+## T200/T201/T202 L1
+
+T200测试先行ad8d028b7:真实BundleDelivery/SourceImageDelivery、register_resource与SQLite仓库,37例先26红/11绿;首次缺合成config的导入失败独立保留,不计业务红例。实现按阶段选profile后37全绿。T202 f4967f5d0只补两旧fixture真实字段,原跨卷/探测/停止拒绝断言不改;初整套11失败/1670passed/18skip,最终主树完整backend **1681passed/18skipped**,日志/private/tmp/f059-backend-profile-full-final.log。
+
+七项:架构PASS(backend既有控制服务,无manager导入/存储I/O);命名PASS;序列化PASS(固定内部RuntimeConflict,不改对外API/摘要);数据库PASS(校验前拒绝,事务/租户/模型/DDL不变);前端N/A;信息泄漏PASS(无payload/凭据输出);设计同步PASS。独立代理只读T201审查无代码阻断。任务范围、先行依赖/配对与完整AC复核通过;Ruff/arch/diff PASS。发布线不含该F059文件已git cat-file确认,不将特性实现倒灌发布线。三个任务结论PASS,189部署与五个交付门仍pending。
+
+## Task Review: T203 — K8s自动补全的UID字段合同
+
+**类型**:测试。**文件**:`src/runtime-manager/tests/test_k8s_templates.py`、`src/runtime-manager/tests/test_k8s_admission.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | Endpoint→Service→Repository;manager只调用平台/Engine API;没有MinIO或领域反向依赖。 |
+| 2 | 命名规范 | PASS | 行为命名,测试完整AC,创建尝试和停止证据区分。 |
+| 3 | 序列化约定 | N/A | 既有内部签名控制协议;外部deploy API不变,严格输入及响应回执。 |
+| 4 | 数据库约定 | N/A | 既有租户JsonType整对象重赋值、无DDL;锁序及锁后刷新,普通UID停止不放宽。 |
+| 5 | 前端约定 | N/A | 没有前端源代码。 |
+| 6 | 信息泄漏 | PASS | 固定enum诊断,不输出raw API/环境/异常类型或凭据;测试仅合成数据。 |
+| 7 | 设计同步 | N/A | design §4.5明确独立not_created/unknown及旧r4不追认;D1–D8未变。 |
+
+**验证**:本地先红3fail/6pass→绿9pass,相关497pass;189历史403具体reason未恢复。 Ruff/逐文件架构守卫/diff-check通过。
+
+**元数据验证**:文件范围PASS、前向依赖PASS;实现配对测试已完成。
+
+**结果**:PASS;真实K8s业务交付仍由T069/T070验证。
+
+## Task Review: T204 — 冻结显式core/v1 UID选择器
+
+**类型**:实现。**文件**:`src/runtime-manager/runtime_manager/k8s_templates.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | Endpoint→Service→Repository;manager只调用平台/Engine API;没有MinIO或领域反向依赖。 |
+| 2 | 命名规范 | PASS | 行为命名,测试完整AC,创建尝试和停止证据区分。 |
+| 3 | 序列化约定 | PASS | 既有内部签名控制协议;外部deploy API不变,严格输入及响应回执。 |
+| 4 | 数据库约定 | PASS | 既有租户JsonType整对象重赋值、无DDL;锁序及锁后刷新,普通UID停止不放宽。 |
+| 5 | 前端约定 | N/A | 没有前端源代码。 |
+| 6 | 信息泄漏 | PASS | 固定enum诊断,不输出raw API/环境/异常类型或凭据;测试仅合成数据。 |
+| 7 | 设计同步 | PASS | design §4.5明确独立not_created/unknown及旧r4不追认;D1–D8未变。 |
+
+**验证**:本地先红3fail/6pass→绿9pass,相关497pass;189历史403具体reason未恢复。 Ruff/逐文件架构守卫/diff-check通过。
+
+**元数据验证**:文件范围PASS、前向依赖PASS;实现配对测试已完成。
+
+**结果**:PASS;真实K8s业务交付仍由T069/T070验证。
+
+## Task Review: T207 — 持久创建事务与签名HTTP合同
+
+**类型**:测试。**文件**:`src/backend/test/app_runtime/test_runtime_kubernetes_creation.py`、`src/backend/test/app_runtime/test_runtime_kubernetes_api.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | Endpoint→Service→Repository;manager只调用平台/Engine API;没有MinIO或领域反向依赖。 |
+| 2 | 命名规范 | PASS | 行为命名,测试完整AC,创建尝试和停止证据区分。 |
+| 3 | 序列化约定 | N/A | 既有内部签名控制协议;外部deploy API不变,严格输入及响应回执。 |
+| 4 | 数据库约定 | N/A | 既有租户JsonType整对象重赋值、无DDL;锁序及锁后刷新,普通UID停止不放宽。 |
+| 5 | 前端约定 | N/A | 没有前端源代码。 |
+| 6 | 信息泄漏 | PASS | 固定enum诊断,不输出raw API/环境/异常类型或凭据;测试仅合成数据。 |
+| 7 | 设计同步 | N/A | design §4.5明确独立not_created/unknown及旧r4不追认;D1–D8未变。 |
+
+**验证**:初轮新fixture漏导入造成26setup errors如实保留;修正后108pass,再补换epoch/混合unknown/独立HMAC身份配对;完整backend1723pass/18skip,UID停止负例保留。 Ruff/逐文件架构守卫/diff-check通过。
+
+**元数据验证**:文件范围PASS、前向依赖PASS;实现配对测试已完成。
+
+**结果**:PASS;真实K8s业务交付仍由T069/T070验证。
+
+## Task Review: T214 — 锁读后刷新资源identity map
+
+**类型**:测试。**文件**:`src/backend/test/app_runtime/test_runtime_kubernetes_creation_concurrency.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | Endpoint→Service→Repository;manager只调用平台/Engine API;没有MinIO或领域反向依赖。 |
+| 2 | 命名规范 | PASS | 行为命名,测试完整AC,创建尝试和停止证据区分。 |
+| 3 | 序列化约定 | N/A | 既有内部签名控制协议;外部deploy API不变,严格输入及响应回执。 |
+| 4 | 数据库约定 | N/A | 既有租户JsonType整对象重赋值、无DDL;锁序及锁后刷新,普通UID停止不放宽。 |
+| 5 | 前端约定 | N/A | 没有前端源代码。 |
+| 6 | 信息泄漏 | PASS | 固定enum诊断,不输出raw API/环境/异常类型或凭据;测试仅合成数据。 |
+| 7 | 设计同步 | N/A | design §4.5明确独立not_created/unknown及旧r4不追认;D1–D8未变。 |
+
+**验证**:两独立Repository/Session四交错:旧方法内存插件4fail→主树4pass;验证ORM刷新,不替代MySQL/DM8行锁验收。 Ruff/逐文件架构守卫/diff-check通过。
+
+**元数据验证**:文件范围PASS、前向依赖PASS;实现配对测试已完成。
+
+**结果**:PASS;真实K8s业务交付仍由T069/T070验证。
+
+## Task Review: T208 — 资源账本单次创建与未创建围栏
+
+**类型**:实现。**文件**:`src/backend/bisheng/app_runtime/domain/repositories/runtime_kubernetes_repository.py`、`src/backend/bisheng/app_runtime/domain/models/runtime_kubernetes.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | Endpoint→Service→Repository;manager只调用平台/Engine API;没有MinIO或领域反向依赖。 |
+| 2 | 命名规范 | PASS | 行为命名,测试完整AC,创建尝试和停止证据区分。 |
+| 3 | 序列化约定 | PASS | 既有内部签名控制协议;外部deploy API不变,严格输入及响应回执。 |
+| 4 | 数据库约定 | PASS | 既有租户JsonType整对象重赋值、无DDL;锁序及锁后刷新,普通UID停止不放宽。 |
+| 5 | 前端约定 | N/A | 没有前端源代码。 |
+| 6 | 信息泄漏 | PASS | 固定enum诊断,不输出raw API/环境/异常类型或凭据;测试仅合成数据。 |
+| 7 | 设计同步 | PASS | design §4.5明确独立not_created/unknown及旧r4不追认;D1–D8未变。 |
+
+**验证**:初轮新fixture漏导入造成26setup errors如实保留;修正后108pass,再补换epoch/混合unknown/独立HMAC身份配对;完整backend1723pass/18skip,UID停止负例保留。 Ruff/逐文件架构守卫/diff-check通过。
+
+**元数据验证**:文件范围PASS、前向依赖PASS;实现配对测试已完成。
+
+**结果**:PASS;真实K8s业务交付仍由T069/T070验证。
+
+## Task Review: T211 — 既有资源Service夹具接入创建事务
+
+**类型**:测试。**文件**:`src/backend/test/app_runtime/test_runtime_kubernetes_service.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | Endpoint→Service→Repository;manager只调用平台/Engine API;没有MinIO或领域反向依赖。 |
+| 2 | 命名规范 | PASS | 行为命名,测试完整AC,创建尝试和停止证据区分。 |
+| 3 | 序列化约定 | N/A | 既有内部签名控制协议;外部deploy API不变,严格输入及响应回执。 |
+| 4 | 数据库约定 | N/A | 既有租户JsonType整对象重赋值、无DDL;锁序及锁后刷新,普通UID停止不放宽。 |
+| 5 | 前端约定 | N/A | 没有前端源代码。 |
+| 6 | 信息泄漏 | PASS | 固定enum诊断,不输出raw API/环境/异常类型或凭据;测试仅合成数据。 |
+| 7 | 设计同步 | N/A | design §4.5明确独立not_created/unknown及旧r4不追认;D1–D8未变。 |
+
+**验证**:初轮新fixture漏导入造成26setup errors如实保留;修正后108pass,再补换epoch/混合unknown/独立HMAC身份配对;完整backend1723pass/18skip,UID停止负例保留。 Ruff/逐文件架构守卫/diff-check通过。
+
+**元数据验证**:文件范围PASS、前向依赖PASS;实现配对测试已完成。
+
+**结果**:PASS;真实K8s业务交付仍由T069/T070验证。
+
+## Task Review: T212 — 创建收尾Service与只读准入接线
+
+**类型**:实现。**文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_kubernetes_service.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | Endpoint→Service→Repository;manager只调用平台/Engine API;没有MinIO或领域反向依赖。 |
+| 2 | 命名规范 | PASS | 行为命名,测试完整AC,创建尝试和停止证据区分。 |
+| 3 | 序列化约定 | PASS | 既有内部签名控制协议;外部deploy API不变,严格输入及响应回执。 |
+| 4 | 数据库约定 | PASS | 既有租户JsonType整对象重赋值、无DDL;锁序及锁后刷新,普通UID停止不放宽。 |
+| 5 | 前端约定 | N/A | 没有前端源代码。 |
+| 6 | 信息泄漏 | PASS | 固定enum诊断,不输出raw API/环境/异常类型或凭据;测试仅合成数据。 |
+| 7 | 设计同步 | PASS | design §4.5明确独立not_created/unknown及旧r4不追认;D1–D8未变。 |
+
+**验证**:初轮新fixture漏导入造成26setup errors如实保留;修正后108pass,再补换epoch/混合unknown/独立HMAC身份配对;完整backend1723pass/18skip,UID停止负例保留。 Ruff/逐文件架构守卫/diff-check通过。
+
+**元数据验证**:文件范围PASS、前向依赖PASS;实现配对测试已完成。
+
+**结果**:PASS;真实K8s业务交付仍由T069/T070验证。
+
+## Task Review: T213 — 签名观察API创建动作输入
+
+**类型**:实现。**文件**:`src/backend/bisheng/app_runtime/api/endpoints/runtime_kubernetes.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | Endpoint→Service→Repository;manager只调用平台/Engine API;没有MinIO或领域反向依赖。 |
+| 2 | 命名规范 | PASS | 行为命名,测试完整AC,创建尝试和停止证据区分。 |
+| 3 | 序列化约定 | PASS | 既有内部签名控制协议;外部deploy API不变,严格输入及响应回执。 |
+| 4 | 数据库约定 | PASS | 既有租户JsonType整对象重赋值、无DDL;锁序及锁后刷新,普通UID停止不放宽。 |
+| 5 | 前端约定 | N/A | 没有前端源代码。 |
+| 6 | 信息泄漏 | PASS | 固定enum诊断,不输出raw API/环境/异常类型或凭据;测试仅合成数据。 |
+| 7 | 设计同步 | PASS | design §4.5明确独立not_created/unknown及旧r4不追认;D1–D8未变。 |
+
+**验证**:初轮新fixture漏导入造成26setup errors如实保留;修正后108pass,再补换epoch/混合unknown/独立HMAC身份配对;完整backend1723pass/18skip,UID停止负例保留。 Ruff/逐文件架构守卫/diff-check通过。
+
+**元数据验证**:文件范围PASS、前向依赖PASS;实现配对测试已完成。
+
+**结果**:PASS;真实K8s业务交付仍由T069/T070验证。
+
+## Task Review: T205 — manager明确拒绝与未知创建恢复合同
+
+**类型**:测试。**文件**:`src/runtime-manager/tests/test_kubernetes_executor.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | Endpoint→Service→Repository;manager只调用平台/Engine API;没有MinIO或领域反向依赖。 |
+| 2 | 命名规范 | PASS | 行为命名,测试完整AC,创建尝试和停止证据区分。 |
+| 3 | 序列化约定 | N/A | 既有内部签名控制协议;外部deploy API不变,严格输入及响应回执。 |
+| 4 | 数据库约定 | N/A | 既有租户JsonType整对象重赋值、无DDL;锁序及锁后刷新,普通UID停止不放宽。 |
+| 5 | 前端约定 | N/A | 没有前端源代码。 |
+| 6 | 信息泄漏 | PASS | 固定enum诊断,不输出raw API/环境/异常类型或凭据;测试仅合成数据。 |
+| 7 | 设计同步 | N/A | design §4.5明确独立not_created/unknown及旧r4不追认;D1–D8未变。 |
+
+**验证**:executor先红23新增失败/79既有通过→115pass,相邻188pass;完整runtime1328pass/13skip。 Ruff/逐文件架构守卫/diff-check通过。
+
+**元数据验证**:文件范围PASS、前向依赖PASS;实现配对测试已完成。
+
+**结果**:PASS;真实K8s业务交付仍由T069/T070验证。
+
+## Task Review: T206 — manager单次创建与独立无执行体收尾
+
+**类型**:实现。**文件**:`src/runtime-manager/runtime_manager/executors/kubernetes.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | Endpoint→Service→Repository;manager只调用平台/Engine API;没有MinIO或领域反向依赖。 |
+| 2 | 命名规范 | PASS | 行为命名,测试完整AC,创建尝试和停止证据区分。 |
+| 3 | 序列化约定 | PASS | 既有内部签名控制协议;外部deploy API不变,严格输入及响应回执。 |
+| 4 | 数据库约定 | PASS | 既有租户JsonType整对象重赋值、无DDL;锁序及锁后刷新,普通UID停止不放宽。 |
+| 5 | 前端约定 | N/A | 没有前端源代码。 |
+| 6 | 信息泄漏 | PASS | 固定enum诊断,不输出raw API/环境/异常类型或凭据;测试仅合成数据。 |
+| 7 | 设计同步 | PASS | design §4.5明确独立not_created/unknown及旧r4不追认;D1–D8未变。 |
+
+**验证**:executor先红23新增失败/79既有通过→115pass,相邻188pass;完整runtime1328pass/13skip。 Ruff/逐文件架构守卫/diff-check通过。
+
+**元数据验证**:文件范围PASS、前向依赖PASS;实现配对测试已完成。
+
+**结果**:PASS;真实K8s业务交付仍由T069/T070验证。
+
+## Task Review: T209 — 准入拒绝安全诊断合同
+
+**类型**:测试。**文件**:`src/runtime-manager/tests/test_k8s_admission_diagnostics.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | Endpoint→Service→Repository;manager只调用平台/Engine API;没有MinIO或领域反向依赖。 |
+| 2 | 命名规范 | PASS | 行为命名,测试完整AC,创建尝试和停止证据区分。 |
+| 3 | 序列化约定 | N/A | 既有内部签名控制协议;外部deploy API不变,严格输入及响应回执。 |
+| 4 | 数据库约定 | N/A | 既有租户JsonType整对象重赋值、无DDL;锁序及锁后刷新,普通UID停止不放宽。 |
+| 5 | 前端约定 | N/A | 没有前端源代码。 |
+| 6 | 信息泄漏 | PASS | 固定enum诊断,不输出raw API/环境/异常类型或凭据;测试仅合成数据。 |
+| 7 | 设计同步 | N/A | design §4.5明确独立not_created/unknown及旧r4不追认;D1–D8未变。 |
+
+**验证**:先红10fail/1pass→11pass,相关508pass;恶意异常文本及类名不进入日志。 Ruff/逐文件架构守卫/diff-check通过。
+
+**元数据验证**:文件范围PASS、前向依赖PASS;实现配对测试已完成。
+
+**结果**:PASS;真实K8s业务交付仍由T069/T070验证。
+
+## Task Review: T210 — 准入拒绝固定枚举诊断
+
+**类型**:实现。**文件**:`src/runtime-manager/runtime_manager/k8s_admission.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | Endpoint→Service→Repository;manager只调用平台/Engine API;没有MinIO或领域反向依赖。 |
+| 2 | 命名规范 | PASS | 行为命名,测试完整AC,创建尝试和停止证据区分。 |
+| 3 | 序列化约定 | PASS | 既有内部签名控制协议;外部deploy API不变,严格输入及响应回执。 |
+| 4 | 数据库约定 | PASS | 既有租户JsonType整对象重赋值、无DDL;锁序及锁后刷新,普通UID停止不放宽。 |
+| 5 | 前端约定 | N/A | 没有前端源代码。 |
+| 6 | 信息泄漏 | PASS | 固定enum诊断,不输出raw API/环境/异常类型或凭据;测试仅合成数据。 |
+| 7 | 设计同步 | PASS | design §4.5明确独立not_created/unknown及旧r4不追认;D1–D8未变。 |
+
+**验证**:先红10fail/1pass→11pass,相关508pass;恶意异常文本及类名不进入日志。 Ruff/逐文件架构守卫/diff-check通过。
+
+**元数据验证**:文件范围PASS、前向依赖PASS;实现配对测试已完成。
+
+**结果**:PASS;真实K8s业务交付仍由T069/T070验证。
+
+## Task Review: T215 — 实际固定容器日志与诊断合同
+
+**文件**:`src/runtime-manager/tests/test_k8s_client.py`、`src/runtime-manager/tests/test_k8s_diagnostic_contract.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 仅固定日志目标接线;无权限/数据/外部API扩展,设计已同步。 |
+| 2 | 命名规范 | PASS | 仅固定日志目标接线;无权限/数据/外部API扩展,设计已同步。 |
+| 3 | 序列化约定 | PASS | 仅固定日志目标接线;无权限/数据/外部API扩展,设计已同步。 |
+| 4 | 数据库约定 | N/A | 仅固定日志目标接线;无权限/数据/外部API扩展,设计已同步。 |
+| 5 | 前端约定 | N/A | 仅固定日志目标接线;无权限/数据/外部API扩展,设计已同步。 |
+| 6 | 信息泄漏 | PASS | 仅固定日志目标接线;无权限/数据/外部API扩展,设计已同步。 |
+| 7 | 设计同步 | PASS | 仅固定日志目标接线;无权限/数据/外部API扩展,设计已同步。 |
+
+**验证**:5fail/54pass→59pass;主树client/真实diagnostic/template/executor合计245pass。Ruff/架构守卫通过;元数据范围/前向依赖/配对PASS。
+
+**结果**:PASS;有效材料业务仍待现场复验。
+
+## Task Review: T216 — 补齐固定helper日志名单
+
+**文件**:`src/runtime-manager/runtime_manager/k8s_client.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 仅固定日志目标接线;无权限/数据/外部API扩展,设计已同步。 |
+| 2 | 命名规范 | PASS | 仅固定日志目标接线;无权限/数据/外部API扩展,设计已同步。 |
+| 3 | 序列化约定 | PASS | 仅固定日志目标接线;无权限/数据/外部API扩展,设计已同步。 |
+| 4 | 数据库约定 | N/A | 仅固定日志目标接线;无权限/数据/外部API扩展,设计已同步。 |
+| 5 | 前端约定 | N/A | 仅固定日志目标接线;无权限/数据/外部API扩展,设计已同步。 |
+| 6 | 信息泄漏 | PASS | 仅固定日志目标接线;无权限/数据/外部API扩展,设计已同步。 |
+| 7 | 设计同步 | PASS | 仅固定日志目标接线;无权限/数据/外部API扩展,设计已同步。 |
+
+**验证**:5fail/54pass→59pass;主树client/真实diagnostic/template/executor合计245pass。Ruff/架构守卫通过;元数据范围/前向依赖/配对PASS。
+
+**结果**:PASS;有效材料业务仍待现场复验。
+
+## Task Review: T217 — 材料根路径反向代理合同
+
+**文件**:`src/backend/test/app_runtime/test_k8s_material_ingress_contract.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 仅精确材料入口,原签名path/query及鉴权不变,无宽internal转发。 |
+| 2 | 命名规范 | PASS | 仅精确材料入口,原签名path/query及鉴权不变,无宽internal转发。 |
+| 3 | 序列化约定 | PASS | 仅精确材料入口,原签名path/query及鉴权不变,无宽internal转发。 |
+| 4 | 数据库约定 | N/A | 仅精确材料入口,原签名path/query及鉴权不变,无宽internal转发。 |
+| 5 | 前端约定 | N/A | 仅精确材料入口,原签名path/query及鉴权不变,无宽internal转发。 |
+| 6 | 信息泄漏 | PASS | 仅精确材料入口,原签名path/query及鉴权不变,无宽internal转发。 |
+| 7 | 设计同步 | PASS | 仅精确材料入口,原签名path/query及鉴权不变,无宽internal转发。 |
+
+**验证**:主树80passed/1明确skip;189隔离原生nginx对完整产品配置-t通过。根入口unauth404→401、api env200;有效材料传输pending。Ruff/架构守卫/diff-check通过;文件范围/配对/前驱PASS。
+
+**结果**:PASS。
+
+## Task Review: T218 — 材料根路径nginx与安装说明
+
+**文件**:`docker/nginx/conf.d/default.conf`、`deploy/kubernetes/app-runtime/README.md`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 仅精确材料入口,原签名path/query及鉴权不变,无宽internal转发。 |
+| 2 | 命名规范 | PASS | 仅精确材料入口,原签名path/query及鉴权不变,无宽internal转发。 |
+| 3 | 序列化约定 | PASS | 仅精确材料入口,原签名path/query及鉴权不变,无宽internal转发。 |
+| 4 | 数据库约定 | N/A | 仅精确材料入口,原签名path/query及鉴权不变,无宽internal转发。 |
+| 5 | 前端约定 | N/A | 仅精确材料入口,原签名path/query及鉴权不变,无宽internal转发。 |
+| 6 | 信息泄漏 | PASS | 仅精确材料入口,原签名path/query及鉴权不变,无宽internal转发。 |
+| 7 | 设计同步 | PASS | 仅精确材料入口,原签名path/query及鉴权不变,无宽internal转发。 |
+
+**验证**:主树80passed/1明确skip;189隔离原生nginx对完整产品配置-t通过。根入口unauth404→401、api env200;有效材料传输pending。Ruff/架构守卫/diff-check通过;文件范围/配对/前驱PASS。
+
+**结果**:PASS。
+
+## Task Review: T219 — 延迟调度节点绑定与停止证明配对
+
+**文件**:`src/runtime-manager/tests/test_kubernetes_executor.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 同UID新鲜绑定;不改backend证明约束、数据模型、外部API或权限。 |
+| 2 | 命名规范 | PASS | 同UID新鲜绑定;不改backend证明约束、数据模型、外部API或权限。 |
+| 3 | 序列化约定 | N/A | 同UID新鲜绑定;不改backend证明约束、数据模型、外部API或权限。 |
+| 4 | 数据库约定 | N/A | 同UID新鲜绑定;不改backend证明约束、数据模型、外部API或权限。 |
+| 5 | 前端约定 | N/A | 同UID新鲜绑定;不改backend证明约束、数据模型、外部API或权限。 |
+| 6 | 信息泄漏 | PASS | 同UID新鲜绑定;不改backend证明约束、数据模型、外部API或权限。 |
+| 7 | 设计同步 | N/A | 同UID新鲜绑定;不改backend证明约束、数据模型、外部API或权限。 |
+
+**元数据验证**:文件范围PASS;配对/前向依赖PASS;测试AC-33、AC-49、AC-52完整标注。
+
+**验证**:3关键旧逻辑红例→新134 executor通过;相邻302通过;主树全runtime1371passed/13skip,Ruff/逐文件架构守卫/diff-check通过。GET/绑定回执未知保持held;confirmed旧证明不重写。
+
+**结果**:PASS;真实189停止确认和发布闭环待验证。
+
+## Task Review: T220 — 同UID首次节点绑定刷新
+
+**文件**:`src/runtime-manager/runtime_manager/executors/kubernetes.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 同UID新鲜绑定;不改backend证明约束、数据模型、外部API或权限。 |
+| 2 | 命名规范 | PASS | 同UID新鲜绑定;不改backend证明约束、数据模型、外部API或权限。 |
+| 3 | 序列化约定 | PASS | 同UID新鲜绑定;不改backend证明约束、数据模型、外部API或权限。 |
+| 4 | 数据库约定 | N/A | 同UID新鲜绑定;不改backend证明约束、数据模型、外部API或权限。 |
+| 5 | 前端约定 | N/A | 同UID新鲜绑定;不改backend证明约束、数据模型、外部API或权限。 |
+| 6 | 信息泄漏 | PASS | 同UID新鲜绑定;不改backend证明约束、数据模型、外部API或权限。 |
+| 7 | 设计同步 | PASS | 同UID新鲜绑定;不改backend证明约束、数据模型、外部API或权限。 |
+
+**元数据验证**:文件范围PASS;配对/前向依赖PASS;测试AC-33、AC-49、AC-52完整标注。
+
+**验证**:3关键旧逻辑红例→新134 executor通过;相邻302通过;主树全runtime1371passed/13skip,Ruff/逐文件架构守卫/diff-check通过。GET/绑定回执未知保持held;confirmed旧证明不重写。
+
+**结果**:PASS;真实189停止确认和发布闭环待验证。
+
+T219/T220真实闭环补证:189的原UID停止确认、账本node首次补齐及三资源正常清理已通过;新manager镜像706b/Helm15,fresh proof SHA dc8150cc…;不替代整体平台发布、升级或故障验收。
+
+## Task Review: T221 — 可选依赖锁空值及安装接续合同
+
+**文件**:`src/runtime-manager/tests/test_k8s_helper.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 既有helper/操作边界修复;无backend/API/DDL/权限变动,设计已同步。 |
+| 2 | 命名规范 | PASS | 既有helper/操作边界修复;无backend/API/DDL/权限变动,设计已同步。 |
+| 3 | 序列化约定 | N/A | 既有helper/操作边界修复;无backend/API/DDL/权限变动,设计已同步。 |
+| 4 | 数据库约定 | N/A | 既有helper/操作边界修复;无backend/API/DDL/权限变动,设计已同步。 |
+| 5 | 前端约定 | N/A | 既有helper/操作边界修复;无backend/API/DDL/权限变动,设计已同步。 |
+| 6 | 信息泄漏 | PASS | 既有helper/操作边界修复;无backend/API/DDL/权限变动,设计已同步。 |
+| 7 | 设计同步 | N/A | 既有helper/操作边界修复;无backend/API/DDL/权限变动,设计已同步。 |
+
+**元数据验证**:文件范围/配对测试/前向依赖PASS;AC完整标注。
+
+**验证**:helper24配对先红后绿、相关318通过;operations31及相邻246通过;主树全runtime1410passed/13skip。非法锁类型在副作用前拒绝,环境恢复不变;终态首次诊断保留而stopped/cleanup取本轮真实结果,过期tail不复活。Ruff/格式/逐文件架构守卫通过。
+
+**结果**:PASS;不回填历史失败,新镜像原生与业务验收另记。
+
+## Task Review: T222 — 可选依赖锁空值规范化
+
+**文件**:`src/runtime-manager/runtime_manager/k8s_helper.py`、`src/runtime-manager/runtime_manager/k8s_prepare.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 既有helper/操作边界修复;无backend/API/DDL/权限变动,设计已同步。 |
+| 2 | 命名规范 | PASS | 既有helper/操作边界修复;无backend/API/DDL/权限变动,设计已同步。 |
+| 3 | 序列化约定 | PASS | 既有helper/操作边界修复;无backend/API/DDL/权限变动,设计已同步。 |
+| 4 | 数据库约定 | N/A | 既有helper/操作边界修复;无backend/API/DDL/权限变动,设计已同步。 |
+| 5 | 前端约定 | N/A | 既有helper/操作边界修复;无backend/API/DDL/权限变动,设计已同步。 |
+| 6 | 信息泄漏 | PASS | 既有helper/操作边界修复;无backend/API/DDL/权限变动,设计已同步。 |
+| 7 | 设计同步 | PASS | 既有helper/操作边界修复;无backend/API/DDL/权限变动,设计已同步。 |
+
+**元数据验证**:文件范围/配对测试/前向依赖PASS;AC完整标注。
+
+**验证**:helper24配对先红后绿、相关318通过;operations31及相邻246通过;主树全runtime1410passed/13skip。非法锁类型在副作用前拒绝,环境恢复不变;终态首次诊断保留而stopped/cleanup取本轮真实结果,过期tail不复活。Ruff/格式/逐文件架构守卫通过。
+
+**结果**:PASS;不回填历史失败,新镜像原生与业务验收另记。
+
+## Task Review: T223 — 终态清理重试保留原始失败诊断
+
+**文件**:`src/runtime-manager/tests/test_operations_v2.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 既有helper/操作边界修复;无backend/API/DDL/权限变动,设计已同步。 |
+| 2 | 命名规范 | PASS | 既有helper/操作边界修复;无backend/API/DDL/权限变动,设计已同步。 |
+| 3 | 序列化约定 | N/A | 既有helper/操作边界修复;无backend/API/DDL/权限变动,设计已同步。 |
+| 4 | 数据库约定 | N/A | 既有helper/操作边界修复;无backend/API/DDL/权限变动,设计已同步。 |
+| 5 | 前端约定 | N/A | 既有helper/操作边界修复;无backend/API/DDL/权限变动,设计已同步。 |
+| 6 | 信息泄漏 | PASS | 既有helper/操作边界修复;无backend/API/DDL/权限变动,设计已同步。 |
+| 7 | 设计同步 | N/A | 既有helper/操作边界修复;无backend/API/DDL/权限变动,设计已同步。 |
+
+**元数据验证**:文件范围/配对测试/前向依赖PASS;AC完整标注。
+
+**验证**:helper24配对先红后绿、相关318通过;operations31及相邻246通过;主树全runtime1410passed/13skip。非法锁类型在副作用前拒绝,环境恢复不变;终态首次诊断保留而stopped/cleanup取本轮真实结果,过期tail不复活。Ruff/格式/逐文件架构守卫通过。
+
+**结果**:PASS;不回填历史失败,新镜像原生与业务验收另记。
+
+## Task Review: T224 — 清理重试保留首次失败事实
+
+**文件**:`src/runtime-manager/runtime_manager/operations.py`
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 既有helper/操作边界修复;无backend/API/DDL/权限变动,设计已同步。 |
+| 2 | 命名规范 | PASS | 既有helper/操作边界修复;无backend/API/DDL/权限变动,设计已同步。 |
+| 3 | 序列化约定 | PASS | 既有helper/操作边界修复;无backend/API/DDL/权限变动,设计已同步。 |
+| 4 | 数据库约定 | N/A | 既有helper/操作边界修复;无backend/API/DDL/权限变动,设计已同步。 |
+| 5 | 前端约定 | N/A | 既有helper/操作边界修复;无backend/API/DDL/权限变动,设计已同步。 |
+| 6 | 信息泄漏 | PASS | 既有helper/操作边界修复;无backend/API/DDL/权限变动,设计已同步。 |
+| 7 | 设计同步 | PASS | 既有helper/操作边界修复;无backend/API/DDL/权限变动,设计已同步。 |
+
+**元数据验证**:文件范围/配对测试/前向依赖PASS;AC完整标注。
+
+**验证**:helper24配对先红后绿、相关318通过;operations31及相邻246通过;主树全runtime1410passed/13skip。非法锁类型在副作用前拒绝,环境恢复不变;终态首次诊断保留而stopped/cleanup取本轮真实结果,过期tail不复活。Ruff/格式/逐文件架构守卫通过。
+
+**结果**:PASS;不回填历史失败,新镜像原生与业务验收另记。
+
+T221/T223 AC追溯补核:新增测试的跨feature注释已修正为F059实际有效ID;仅注释变更,不改旧F086既有测试或1410项运行结果。
+
+
+## Task Review: T227 — K8s异步删除API合同
+
+**类型**:测试。**文件**:`src/runtime-manager/tests/test_kubernetes_executor.py`。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | N/A | 仅既有假Cluster与真实executor合同测试 |
+| 2 | 命名/AC | PASS | 明确pending、fresh404和替换UID;完整AC-33、AC-49、AC-52 |
+| 3 | 序列化 | N/A | 无生产API变化 |
+| 4 | 数据库 | N/A | 既有内存账本,无SQL/模型变化 |
+| 5 | 前端 | N/A | 无前端 |
+| 6 | 信息泄漏 | PASS | 仅合成测试数据 |
+| 7 | 设计同步 | N/A | 测试任务;T226独立记录等待实现 |
+
+**元数据验证**:文件范围PASS;依赖T220完成;无实现配对要求。声明AC已按实际测试收敛到AC-33、AC-49、AC-52。
+**验证**:4新合同通过;executor/observe/data-executor 211通过;Ruff/format/arch/diff通过。原fake即时删除的4红仅证明fixture覆盖缺口,未冒称生产红;首轮相关命令模块名错误未运行,保留日志。
+**结果:PASS**。本地提交04e73b6fba3be24984209d5d057b91dc38471de3;原L1 `/private/tmp/f059-t227-l1.md`。无生产修改、远端操作、ARM或公开发行。
+
+
+## Task Review: T225 — 异步收尾等待、持久结果与重启
+
+**类型**:测试。**文件**:`tests/test_operations_v2.py`、`tests/test_executor_selection.py`(均在src/runtime-manager)。
+**命名/AC:PASS**,完整F059 AC-04、AC-33、AC-35、AC-49、AC-52、AC-55、AC-56;前端N/A,其余按测试子集N/A。
+**元数据**:前置T224完成;声明范围已包含受新事实序列和等待语义影响的旧selection fixture;配对T226。24新operations合同加既有31为55通过;6模块284通过;根树selection+operations 65通过。红阶段12fail/31pass,保留 `/private/tmp/f059-t225-red.log`。独立agent对固定1f788fc3只读复核及55测试通过。
+**结果:PASS**。主树首次全量在旧selection两处预期停留,212通过/7跳过/1旧序列失败后主动中断;一次修正命令相对目录错误未修改文件而误启旧测试,已中断并保留日志,非产品错误。根树修正后使用虚拟时钟验证pending到期仍held,不降低真实等待约束。
+
+## Task Review: T226 — 编排异步清理有界等待
+
+**类型**:实现。**文件**:`src/runtime-manager/runtime_manager/operations.py`。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 仅消费执行能力和现有平台操作/事实接口 |
+| 2 | 命名 | PASS | `_wait_cleanup`、`_prepared_delivery` 表达行为 |
+| 3 | 序列化 | PASS | 现有fact.detail新增内部持久字段,无外部API/DTO变更 |
+| 4 | 数据库 | N/A | 不加模型/DDL,不在manager直接操作平台DB |
+| 5 | 前端 | N/A | 无前端 |
+| 6 | 信息泄漏 | PASS | 结果为现有delivery;无凭据日志或新明文错误 |
+| 7 | 设计同步 | PASS | 4.6结果与等待合同、5已知坑和修订历史同步 |
+
+**元数据**:范围PASS;配对T225先通过;依赖PASS。prepare/probe/data及旧实例清理使用原checkpoint/期限并守K8s租约;不把异常、unknown或超时当complete。prepare结果先SQL确认才清理,PlatformError响应丢失仅同结果fresh确认可续;不重放安装/上传/probe/已结束data。Docker同路55测试内配对,本轮不代替真实Docker复跑。Ruff/format/逐文件arch/diff通过;主树完整runtime回归1438passed/13skipped(33.66s),日志/private/tmp/f059-async-cleanup-main-runtime-fixed.log。
+**结果:PASS**。来源1f788fc3519a3984c63ed87e567666b3dbd4aa13;本地配对不宣称189业务或真实响应丢失已通过。当前222/227、103测试任务,余五项为验收/交付门。
+
+
+## Task Review: T228 — 初始化与健康阶段期限配对
+
+**类型**:测试。**文件**:`src/runtime-manager/tests/test_kubernetes_executor.py`。命名/完整AC-24、AC-25、AC-28、AC-43、AC-48、AC-49、AC-52、AC-55 PASS;前端N/A,测试子集其余N/A。依赖T226/T227完成,配对T229;文件范围PASS。原红29fail/139pass;最终175 executor、392相邻全部通过。实际4init累计130秒、阶段超时、总期限、取消/归属、同UID替换、时钟异常、重启、公开observe形状及负sleep先红后绿均有断言。
+**结果:PASS**;本地Mock/控制时钟不代替189真实启动。
+
+## Task Review: T229 — 固定初始化阶段窗口
+
+**类型**:实现。**文件**:`runtime_manager/executors/kubernetes.py`。七项:架构PASS(已有平台/K8s API),命名PASS,序列化PASS(公开observe不变),数据库/前端N/A,信息泄漏PASS(固定reason与既有安全tail),设计同步PASS(4.6、5)。范围/依赖/配对T228先完成PASS。原operation deadline为上限;同UID创建/前init完成锚点不因重启或startedAt变化刷新,ready前后再守时限/取消与归属,失租和未知状态拒绝。主树runtime1483/13skip通过,Ruff/format/arch/diff通过。
+**结果:PASS**。源a3b50f9d6740a8f6654454fa5e701252c867d3cb;原L1 `/private/tmp/f059-t229-l1-review.md`。
+
+## Task Review: T230 — 取消数据会话的新动作配对
+
+**类型**:测试。**文件**:两backend data grant/session测试及manager `test_k8s_data_executor.py`,和任务索引一致。命名/完整AC-33、AC-36、AC-43、AC-48、AC-49、AC-55 PASS;前端N/A,其余按测试子集N/A;依赖T226完成,配对T231。12新配对,backend170、manager176相关通过;真实Service+SQL+Ed25519红3及manager红7/观察期红1均保留。没有使用无关的AC-54作追溯。
+**结果:PASS**;无远端数据取消验收冒认。
+
+## Task Review: T231 — 取消数据签发与RPC围栏
+
+**类型**:实现。**文件**:backend两data grant/session服务、manager `k8s_data_executor.py`。七项:架构PASS(Service/Repository及原数据执行边界),命名PASS,序列化PASS(既有响应/票据无改动),数据库/租户PASS(无新模型/DDL/SQL,原锁定操作),前端N/A,信息泄漏PASS,设计同步PASS(4.8、5)。三文件共6行生产增量,属同一跨进程取消闸,按文件独占并作原子修复;依赖/配对T230先通过、范围PASS。未把取消请求当停止证明;active=False清理仍可执行,已在途unknown/原窗口及互斥保留。完整backend1734/19skip和runtime1483/13skip通过,静态检查通过。
+**结果:PASS**。源469175f19857bbd8021814da7164f21bc97f49ec;原L1 `/private/tmp/f059-t230-t231-l1.md`。当前226/231、105测试任务;仍待189业务、故障升级、正式发行和L2。
+
+## Task Review: T232 — 终态finalizer移除后kubelet删除配对
+
+**类型**:测试。**文件**:`src/runtime-manager/tests/test_k8s_admission.py`。命名与完整AC-35、AC-48、AC-49、AC-52、AC-55标注PASS;前端N/A,其余按测试子集N/A;依赖T229已完成,范围与配对T233 PASS。有效终态Succeeded/Failed×finalizers省略/空列表先红4;原203通过。修复后准入208、邻接585通过;同policy与真实AdmissionPlanClient连续二次fresh读取、撤权即拒绝配对已覆盖。
+**结果:PASS**。原红绿与L1 `/private/tmp/f059-t233-l1-review.md`;无真实集群成功结论。
+
+## Task Review: T233 — 终态Pod正常删除准入收尾
+
+**类型**:实现。**文件**:`src/runtime-manager/runtime_manager/k8s_admission.py`。架构PASS(原AdmissionPolicy与现有后端计划Owner),命名PASS,序列化PASS(原plan字段/API形状),数据库/租户PASS(无DDL、SQL或租户边界变更),前端N/A,信息泄漏PASS,设计同步PASS(终态清理及修订记录)。依赖/配对T232与单文件范围PASS。无finalizer分支仅绑定kubelet严格DELETE0、同UID保护终态、fresh精确plan stop_state=confirmed/stop_confirmed is True/allow_delete is True;API不确定拒绝,无缓存或进程级通行位。原STOP路径不读后端,manager强删限制不变。合流全runtime1519/13skip通过,Ruff/format/guard通过。
+**结果:PASS**。源1bd926bb2a37bc3d95a31920c2ae38be08e9ea6f;不手工删Pod/改finalizer/改SQL/复活终态。r8自然清理复验另记T069/T070。
+
+## Task Review: T234 — 同步业务E2E请求预算配对
+
+**类型**:测试。**文件**:`src/backend/test/app_runtime/test_k8s_e2e_contract.py`。命名与完整AC-24、AC-25、AC-28、AC-33、AC-36、AC-49、AC-55标注PASS;前端N/A,其余测试子集N/A;依赖T233及单文件/配对范围PASS。直接HTTPX transport校验精确同步成功路径扩大read、connect/write/pool短有界;普通GET、restore202、preview DELETE、相似路径、拒绝配对及非owner保持原短期限。模拟总预算超时与仅一次POST,原TLS/响应断言保留。红5/12、最终112passed;默认沙箱唯一旧loopback bind失败保留,授权完整重跑通过。
+**结果:PASS**。源21250d32900c4c4ba893248aa5b8cc1d8d65182b;日志 `/private/tmp/f059-request-budget-red.log`、`/private/tmp/f059-request-budget-green-authorized.log`。
+
+## Task Review: T235 — 共享E2E同步请求期限适配
+
+**类型**:实现。**文件**:`src/backend/test/e2e/helpers/vibe_runtime.py`。架构PASS(共享测试helper,无产品状态Owner变更),命名PASS,序列化PASS(既有响应断言不变),数据库/租户N/A,前端N/A,信息泄漏PASS,设计同步PASS(工具期限及已知坑)。依赖/配对T234与文件范围PASS。仅精确同步成功路径使用settings.timeout read与asyncio总预算,连接/写入/pool不超过15秒或总预算;超时直接停止、未知POST不重发,不修改平台operation deadline/API或脚本重复配置。Ruff/format/逐文件架构守卫及diff通过;主树完整backend1753/19skip、48.48s。
+**结果:PASS**。开发闭合230/235;真实平台、故障升级、109剩余、正式发行与L2不能由工具合同替代。
+
+## Task Review: T236 — 维护容器材料身份UID接线配对
+
+**类型**:测试。**文件**:`src/runtime-manager/tests/test_k8s_templates.py`。命名/完整AC-33、AC-34、AC-35、AC-48、AC-49、AC-50、AC-51、AC-55 PASS;前端N/A,其余按测试子集N/A;依赖T235及范围/配对T237 PASS。真实Pod模板→Downward API fieldRef→未mock的client_from_identity/MaterialClient,两种维护先红KeyError;purge无材料身份、普通app/data-helper隔离、缺Secret和reserved literal拒绝共11通过;最终13新增/534相关全绿。合成身份禁网,不据此声称取到189已清理原Pod日志。
+**结果:PASS**。源79a15870f021fe2b704b2ae352b84891229789b5;脱敏短栈红 `/private/tmp/f059-t236-red.log`,绿 `/private/tmp/f059-t236-t237-related-green.log`。
+
+## Task Review: T237 — 可信维护容器注入材料PodUID
+
+**类型**:实现。**文件**:`src/runtime-manager/runtime_manager/k8s_templates.py`。架构PASS(原可信helper渲染器),命名PASS,序列化PASS(core/v1 metadata.uid),数据库/租户N/A,前端N/A,信息泄漏PASS,设计同步PASS(4.8及5已知坑、修订历史)。依赖/配对T236已完成,单文件范围PASS。仅name=maintenance且material_secret存在时增加与transfer相同的Downward字段;原Secret/签名/期限/非root只读保护不变,purge没有材料身份、普通app/data-helper不获材料UID,保留literal注入拒绝。没有helper/API/模型/DDL改动。主树1532/13skip、Ruff/format/逐文件guard/diffcheck通过。
+**结果:PASS**。原agent L1 `/private/tmp/f059-t236-t237-l1.md` 的parent文档备注已闭合;原生helperA/B另证缺字段失败/补字段空库backup(None)通过,真实新attempt仍单列,不冒认上线完成。
+
+## Task Review: T238 — 同名已闭合历史Pod停用配对
+
+测试文件、完整AC-35、AC-36、AC-48、AC-49、AC-52、AC-55、依赖/配对及单owner范围PASS。先红2/62绿,最终479相关通过;同名两历史UID跳过,live停止且异步清理二次完成,单一标记/未知/非法状态及换UID仍拒绝。
+**结果:PASS**。原L1 `/private/tmp/f059-t239-l1-review.md`;不作为189实测。
+
+## Task Review: T239 — 停用跳过已闭合历史Pod
+
+实现仅operations.py两行,架构/命名/序列化/信息泄漏/设计同步PASS,数据库与前端N/A;依赖/配对T238与范围PASS。仅精确confirmed且complete跳过历史Pod,全操作cleanup、live UID、deadline/controller/checkpoint不变。Ruff/format/guard/diff及479相关通过。
+**结果:PASS**。源464bf9027ad3398029c46495e696538c33e11313;实际过期操作仍待安全闭合。
+
+## Task Review: T240/T241 — 首轮独立复核
+
+候选de653a8d/合流d48937c10,508相邻与主树1570/13skip通过。独立只读复算发现P2:claim短暂503会被写成failed/pending,而特殊收尾仅接受timed_out/operation_timeout,恢复后旧writer持续held且不再claim。复现 `/private/tmp/f059-t241-transient-503-stuck.json`,3轮cleanup0/claim1/4次failed事实。
+**结果:NEEDS_FIX**。T240/T241保持未完成;非精确expiry/不确定stop须保留非终态以待重试,补503恢复及精确expiry配对后复审。原期限、backend guard及PVC保护不动。该候选仅原生amd64构建/禁网导入通过,未push或部署,不能当最终交付。
+
+## Task Review: T240 — 过期停用清理闭环配对(修订复审)
+
+测试单文件范围、完整AC-35、AC-36、AC-48、AC-49、AC-52、AC-55及依赖T239/配对T241 PASS,前端N/A,其余测试子集N/A。初红19/65、复审红2/1绿均保留;新503恢复/期限内完成、ACK丢失、全资源scope/换UID/未知writer/永久PVC/重启/最终backend guard及Docker兼容共96操作通过。
+**结果:PASS**;相邻511、主树1573/13skip,独立96结果不混用,报告 `/private/tmp/f059-t240-t241-independent-l1.md`。
+
+## Task Review: T241 — 过期停用的有界安全收尾(修订复审)
+
+架构PASS(原OperationExecutor/平台与K8s协议),命名PASS,序列化PASS(无API变更),数据库/租户N/A(不改backend/SQL/DDL),前端N/A,信息泄漏PASS(固定reason/既有脱敏tail),设计同步PASS(4.5及5原合同补漏)。依赖/配对T240及单文件范围PASS。必须先真实timed_out pending回执,再fresh归属,仅旧terminal自身cleanup单轮;不确定API保持hold,最终仍backend全app停止guard;非精确到期stop保留active可重试,已终态非timeout首因不改,原deadline和PVC保持。
+**结果:PASS**。de653+0bd6aa173,合流d7c0cc4fd,完整1573/13skip及静态门;独立首次P2已按同scope复审闭合。无新ARM/公开push/tag/发行或真实部署通过冒认。
+
+## Task Review: T242 — 离线数据CPU真实预占配对
+
+测试两既有文件、完整AC-18、AC-33、AC-43、AC-48、AC-49、AC-55、依赖T241与配对T243及范围PASS。真实SQLite仓库事务99m拒绝无op/reservation/mutex残留、100m允许,offline冻结tier100/online25且无新增预占、非data/期限/代次/UID拒绝均覆盖。初红4/31绿→95相关通过,主树backend1764/19skip。
+**结果:PASS**;原报告 `/private/tmp/f059-t242-t243-l1.md`,不冒称MySQL/DM8真实容量验收。
+
+## Task Review: T243 — 离线数据Pod预占100m CPU
+
+七项:架构PASS(Service冻结请求/Repository事务准入),命名PASS,序列化PASS(键/类型不变),数据库/租户PASS(既有聚合、锁、回滚,无DDL/模型/租户过滤变化),前端N/A,信息泄漏PASS,无新增日志/凭据;设计同步PASS(4.8及5资源参数实测)。两文件共两行,依赖/配对T242与范围PASS。离线100m/128MiB与真实预占一致,online25m且不增预占,60秒/并发2/同步响应/票据/mutex保持。
+**结果:PASS**。来源992905292418308b298d48a24d20cc9e19032c15。
+
+## Task Review: T244 — 离线数据模板CPU与平台tier配对
+
+单测试文件、完整AC及依赖T241/配对T245与范围PASS。data-layout/init与data-helper双requests=limits按100m/250m生成,串行init峰值为同tier非双倍;旧data25m及其他helper25m保持,内存/安全/卷/期限不变。先红2/95绿,598相邻通过,主树runtime1586/13skip。
+**结果:PASS**;原 `/private/tmp/f059-t245-l1-review.md`。
+
+## Task Review: T245 — 离线数据helper沿用可信tier CPU
+
+七项:架构PASS(原PodPlan与模板,无新输入),命名PASS,序列化PASS(K8s quantity原格式),数据库/租户N/A,前端N/A,信息泄漏PASS,设计同步PASS。单CPU表达式,仅helper且purpose=data读取可信plan.cpu_millicores;其他helper25m/旧冻结data25m兼容,requests=limits且内存128Mi、60秒/非root/无材料身份保持。依赖/配对T244与范围PASS;同源backend新会话100m/真实预占100m同步集成。
+**结果:PASS**。f84415f2540b1b762dcfba2b7e8e521ca170f3bd,合流6f254f337dd6e8a36733501a2266bf08a04a56e9,完整两套与静态门通过。真实新Pod性能单列。
+
+## Task Review: T246 / T247
+
+**结果:PASS。** 真实189 static probe全部init完成、app exit127;固定静态镜像中nginx在/usr/sbin,受控PATH缺该目录。仅bundle PATH加入标准系统sbin,依赖bin优先、镜像启动参数及image模式保持。
+
+- 测试先行:9 failed / 97 deselected;实现后模板106 passed,完整runtime1595 passed / 13 skipped(33.42s)。
+- 独立L1:292相邻合同,bundle/helper PATH注入拒绝与image自定义PATH保留,Ruff/format/arch/diff通过;报告`/private/tmp/f059-t246-t247-l1-review.md`,SHA cfa95d41f0715e5de23806666808bfe0b6e2543fea259e4b726eb91323e07300。
+- 189同固定2f0311镜像原PATH command-v退出127;补sbin后/usr/sbin/nginx与真实entrypoint nginx -t成功。日志`/private/tmp/f059-static-path-native-red.log`、`/private/tmp/f059-static-path-native-green.log`。无镜像/Pod原地修改,不将该原生对照计为业务发布通过。
+- 全runtime日志`/private/tmp/f059-static-path-full-runtime.log`;backend生产与6f逐字节保持,既有1764/19skip记录不重标新SHA。
+
+## Task Review: T248/T249 — OCI目录继承权限
+
+**结果:PASS。** 测试单文件先红2/拒绝10绿;修复使用同一lstat结果仅放行SGID目录,再由既有mode&0777剥除,输入不chmod。SUID目录、所有非目录SUID/SGID及链接/硬链接/特殊文件/whiteout边界保持。架构/命名/信息泄漏/设计同步PASS,数据库/序列化API/前端N/A;单文件owner、完整AC、配对依赖PASS。独立只读差异复核确认边界成立;本机沙箱可能去掉SGID的额外实验未算证据。真实189只读统计267个SGID目录、零特殊位普通文件及原失败完整保留。
+
+## Task Review: T250/T251 — OCI系统可执行路径
+
+**结果:PASS。** 三运行时受控crane参数3项先红,再补标准sbin;与T247已真实原生A/B的PATH一致,依赖bin优先不变。无用户可覆写PATH口、base/entrypoint/uid/command/registry不变。七项中架构/命名/信息泄漏/设计同步PASS,数据库/对外序列化/前端N/A;单文件/完整AC/配对依赖PASS。
+
+最终完整runtime1610 passed /13 skipped(33.01s),日志`/private/tmp/f059-oci-final-full-runtime.log`;原SGID完整1607/13skip及两份红例日志保留。Ruff/format/逐文件archguard/diff通过。backend与6f逐字节不变,未重标backend测试SHA。真实新镜像及K8s复验不能由本地通过代替。
+
+## Task Review: T252/T253 — OCI继承CMD与兼容入口(原PASS已撤回,见更正)
+
+**结果:PASS。** 测试单文件/完整AC-24、AC-25、AC-27、AC-28、AC-54、配对及T251前向依赖PASS;三运行时缺省/空list先红6、显式列表/逗号参数6保持绿。实现沿用原列表验证及CSV编码,仅取消非空分支,显式写空CMD,保持Procfile/包start/static模板的既有入口解析优先级。无helper/backend/外部API或用户完整镜像变化。
+
+七项:架构/命名/信息泄漏/设计同步PASS,ORM/对外响应序列化/数据库/前端N/A;目标单文件与配对依赖PASS。独立链路和diff复核无阻断;审查者沙箱剥除SGID的旧相邻13失败未计产品缺陷,root的真实位完整测试与原生Linux证据单列。
+
+完整runtime1622 passed /13 skipped,33.09s,`/private/tmp/f059-oci-cmd-final-runtime.log`;Ruff/format/逐文件guard/diff通过。189非root真实crane v0.22.1在三个固定CA基础镜像上验证默认Cmd→空Cmd(null),入口正确、临时tar删除,无仓库manifest更改:`/private/tmp/f059-crane-empty-cmd-native.log`。代码镜像需最终新SHA构建/探测/业务复验,不把0d5中间镜像或本地测试写作最终交付通过。
+
+## T252/T253审查更正:WITHDRAWN_FALSE_POSITIVE
+
+原“真实继承CMD缺陷”结论与L1 PASS撤回。遗漏核对固定第三方CLI实现:crane v0.22.1设置entrypoint时已清空Cmd。原代码始终传entrypoint,不需要新增空--cmd;Mock强制新参数不能证明原实现有缺陷。原生--cmd空正例也不构成无该参数的反例。两个产品/测试文件逐字节恢复0d5,不保留冗余实现;251个活跃任务/246完成/115测试。原提交及原审查不改写,新增纠正和独立对照证据。
+
+官方源码:[mutate.go v0.22.1](https://raw.githubusercontent.com/google/go-containerregistry/v0.22.1/cmd/crane/cmd/mutate.go)。不将此误报列作产品缺陷或有效测试增量。
+
+撤回复核:真实无--cmd三base Cmd=null;完整runtime1610/13skip(33.51s)与src/scripts/deploy/workflow对0d5空diff确认。最终交付代码及有效L1仍为T248–T251,不把T252/T253镜像实现型断言计产品回归。
+
+## Task Review: T254 — 仓库实际策略与服务隔离配对
+
+**结果:PASS。** 测试单文件、完整AC-13、AC-16、AC-20、AC-28、AC-54、AC-55、依赖T251/配对T255与owner范围通过。14用例先红后绿,新18项覆盖TCP不通不得执行crane、精确egress身份/NotReady/不发布未Ready Service、UID变化/未知拒绝清理、域名全TCP地址去重/上限/失败及外部push晚失败。作者213相邻通过;受限沙箱179/34失败的回环EPERM/特殊位丢失原样保留,授权相同回归通过而未弱化断言。
+
+## Task Review: T255 — 安装器仓库上游检查
+
+**结果:PASS。** 来源dedc4831bb462f832877940901201061b1e027cf;范围仅安装脚本和README。七项:架构/命名/序列化/信息泄漏/设计同步PASS,DB/前端N/A;无manager/helper镜像、API、descriptor摘要或凭据分发变化。安装器先由无凭据/无SA的NotReady control canary按真实egress-proxy selector检查TCP,对应Service启用publishNotReadyAddresses拒绝;原外部crane四步仍在后续。域名由installer解析,不冒称egress DNS/TLS/auth或完整assembler路径。原UID/RV停止及unknown保护保持。
+
+独立报告`/private/tmp/f059-t254-t255-independent-l1.md`,SHA `ff400d5ab04ed928070b6a7fa07cc49be412ca48f5fede620cc5b252bf0ecc99`;独立18绿/静态门,作者213绿;root合流完整runtime1628 passed/13 skipped(33.76秒),日志`/private/tmp/f059-registry-upstream-full-runtime.log`。backend源未变,沿既有1764/19skip记录。189真实新canary/EndpointSlice及源码业务仍待实际证据,不以L1代替。
+
+## Task Review: T256 — 准备阶段取消、围栏与期限配对
+
+**结果:PASS。** 测试提交79f4adb6fa325265bf7402b9696ad9a2a1baca9d,目标两文件;覆盖AC-43、AC-48、AC-49、AC-52、AC-55、依赖T255及配对T257均核对。实际_phase/_startup_checkpoint及停止/确认方法未替换,外部Platform/Cluster与时钟为模拟;以拒绝/耗时/进度未提交/同UID停止事实为断言。旧19fail/7pass→固定实现26pass,201相邻通过;不把19种配对条件当19个独立现场故障。测试命名/完整AC标注PASS,无前端;其余实现检查N/A。报告 `/private/tmp/f059-t256-l1.md` SHA dd6fe802af640c81503e2dfc0ef5ee8e98799db8b1b8ea539ee82aa262fbf231。
+
+## Task Review: T257 — 准备阶段持续检查持久操作
+
+**结果:PASS。** 核心1a568c85c,融合2ac60f808;生产仅kubernetes.py五行既有检查。独立按完整executor、outer failure、PlatformClient/backend只读资源链、UID停止事实复核;同两模块26pass。主树全runtime1649/13skip(33.77s),Ruff/format/路径架构守卫/diff通过。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构 | PASS | 保留manager编排与backend权威操作边界 |
+| 2 | 命名 | PASS | 复用既有检查方法 |
+| 3 | 序列化 | PASS | 不修改API或事实格式 |
+| 4 | 数据库 | N/A | 无模型、SQL或迁移变更 |
+| 5 | 前端 | N/A | 无UI变更 |
+| 6 | 信息泄漏 | PASS | 无新增凭据、日志或异常响应 |
+| 7 | 设计同步 | PASS | design4.6及现状已同步 |
+
+元数据:文件范围PASS、T256已完成、T255依赖PASS。读取失败不接受成功结果,outer failure仍须fresh范围与正常同UID清理;未放宽停止/容量释放条件,不延长原期限。独立报告 `/private/tmp/f059-t257-independent-l1.md` SHA83dbd6c9bf858330b97960e83d42c18db15e0891edf5b9f829cec25fc5d9142c。以上是本地模拟/静态,不是189真实取消或容量释放证明。
+
+## Task Review: T258/T259 — 报告未知与历史保护
+
+**结果:PASS。** 新97例通过,原旧实现91缺元数据红、strict JSON时间5红/1绿;不将这些当独立现场故障数。实际assessment/Kube config投射/OperationExecutor,只有外部list/时钟为受控输入。所有15check完整、TTL边界、仅helper允许、坏/新失败guard优先、其他descriptor变更及cold mismatch均核对;原descriptor摘要不改。state只描述当前report,不授予连续来源。两生产文件和声明测试范围通过,T257/T258依赖通过;原子Kube辅助方法修正在T259同owner目标内。
+
+## Task Review: T260/T261 — 历史标志与当前确认
+
+**结果:PASS。** 51真实SQLite/Service配对旧36fail/15pass→51pass;独立51pass2.28s。服务端黏性版本/最后基线不可被body、缺失、畸形或legacy positive清除;初始unknown不能享例外,actualfailed/基线变化仍失信;报告未知只保留历史原值,未回填false。首次stop在同锁事务要求当前对应guard精确true;旧历史失败校验顺序/已确认幂等/not_created独立路径/容量finalizer顺序不变。文件范围、完整AC与T257→T260配对依赖PASS。
+
+## Task Review: T262/T263 — 一次心跳快照
+
+**结果:PASS。** 真实调用层在旧双读下7fail/4pass,固定实现11pass;独立合并新配对108pass。heartbeat_snapshot一次读取同时生成capabilities/probe,下一心跳再自然刷新;快照失败不降级重试,Docker分支保持,旧fake fallback可达。调用者operations.py单文件范围与T262依赖PASS;Kube方法属T259两个原目标内。
+
+| # | 检查项 | 实现任务结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构 | PASS | manager报告、backend权威事实/事务边界保持 |
+| 2 | 命名 | PASS | 使用明确内部观察/快照与固定reason |
+| 3 | 序列化 | PASS | 只扩内部capabilities JSON;严格JSON坏时间null;不改对外deploy接口 |
+| 4 | 数据库 | PASS/N/A | backend沿既有事务/JSON/锁,无新DDL;runtime不读平台DB |
+| 5 | 前端 | N/A | 无UI变更 |
+| 6 | 信息泄漏 | PASS | 无凭据/原始环境/异常正文输出 |
+| 7 | 设计同步 | PASS | design4.5观察、黏性字段、snapshot及当前stop已同步 |
+
+独立后端报告`/private/tmp/f059-t260-t261-independent-l1.md` SHA4062b44ceab088b80c716aab8bf6675d75eb887e6af3138a8d83d3a812ce3c32;runtime报告`/private/tmp/f059-t258-t259-t262-t263-independent-l1.md` SHA26ef6ad85616d2ef32fcc4166df266eaf02656d8b52b2d1b5b0d7e1a76cee3fb。作者报告03687327…/a69145f5…/55ea648b…另保留。最终源码f8863f376:完整runtime1757/13skip35.27s;运行/发布/SDK三模块1944/19skip81.11s。四模块同选择baseline差异比较零新增失败/错误,36新配对由红转绿;114 open_api旧失败和18fixture错误不记通过,不在本修复扩scope。原环境MCP1.27.1不符项目>=1.27.2,按uv.lock1.30只在临时target覆盖,不改共享venv。以上为本地/模拟与真实SQLite,未冒充189新部署、恢复或停止事实;已有旧false仍held。
+
+## Task Review: T264
+
+**类型**:测试。**文件**:`src/bisheng-cli/tests/test_errors.py`、`src/bisheng-cli/tests/test_command_deploy_wait.py`。
+
+| 检查项 | 结果 | 说明 |
+| --- | --- | --- |
+| 命名与 AC | PASS | 显式AC-02、AC-08;真实translator和CLI轮询,不mock核心映射 |
+| 前端 | N/A | 无UI变更 |
+| 文件/依赖/配对 | PASS | 两指定文件,T261已完成,T265尚未合流 |
+
+主树cdb9a1343独立红例3 failed/55 passed(0.49s),均为16125实际exit19与应有14差异;legacy16226及未知19999配对绿。日志`/private/tmp/f059-cli-capacity-root-red.log`。仅测试L1 PASS;不把预期红例当功能已修复。
+
+## Task Review: T265
+
+**类型**:实现。**文件**:`bisheng_cli/errors.py`、实际backend分发CLI wheel及manifest。
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构 | PASS | 独立CLI表映射,无backend/runtime导入 |
+| 2 | 命名 | PASS | 既有ERROR_HINTS/ERROR_EXIT_CODES,复用EXIT_CAPACITY |
+| 3 | 序列化 | PASS | human/JSON均保留原平台16125,未知19999仍19 |
+| 4 | 数据库 | N/A | 无DDL/SQL变化 |
+| 5 | 前端 | N/A | 无UI变化 |
+| 6 | 信息泄漏 | PASS | 配对验证synthetic key脱敏,无新凭据输出 |
+| 7 | 设计同步 | PASS | §4.3、§5与修订历史同步两容量码及实际wheel分发 |
+
+**元数据**:指定五文件、T264先完成、依赖与配对PASS。root主树0ff595428独立完整CLI391passed(5.86s),日志`/private/tmp/f059-cli-capacity-root-full.log`;独立ZIP读取确认errors.py等于源码、manifest只cli.sha256变化、SDK摘要保持。原pack_cli_wheel.sh离线干净venv烟测及作者RECORD验证见`/private/tmp/f059-cli-capacity-l1.md`。wheel SHA739d58c34a4f9eb4833c21ecec60936ef78bf1a48ce0b050b0d86a810b0e3e39。**结果:PASS**;189未部署新wheel,不冒称远端CLI验收。
+
+## Task Review: T266 — 真实销毁事实阶段配对
+
+**结果:PASS。** 单文件测试、完整六条AC元数据、命名与范围通过;前驱T263已完成。真实PlatformClient.fact编码→FastAPI→实际ExecutionFactInput,不复制阶段枚举;真实操作/维护/数据链配K8s与账本I/O替身,purge作用于私有SQLite目录。主树先独立11fail/3pass(0.55s),首例admitted422,再合4行实现;初次root目录导入失败另存,不当产品红。覆盖先ACK、结果事实先于helper收尾/PVC删除、503及回执丢失、同操作重启/不重复purge、ACK后取消/期限/owner epoch变化及schema控制。测试b143/元数据9f509已合为e391/93d;本地验证不等同189 API或平台SQL实测。
+
+## Task Review: T267 — 销毁前持久化合法维护阶段
+
+**结果:PASS。** 独立候选dd549合流1532e115e。
+
+| # | 检查项 | 结果 | 依据 |
+| --- | --- | --- | --- |
+| 1 | 架构分层 | PASS | 仅既有运行操作调用者,平台事实仍经客户端,未跨平台存储/权限Owner |
+| 2 | 命名 | PASS | 复用report/checkpoint和合法maintenance阶段,无新抽象 |
+| 3 | 序列化 | PASS | 实际ExecutionFactInput严格校验,未放开admitted/stopping |
+| 4 | 数据库 | N/A | 不改模型/SQL/迁移 |
+| 5 | 前端 | N/A | 无UI或前端文件 |
+| 6 | 信息泄漏 | PASS | 无新敏感参数/日志,新增注释英文 |
+| 7 | 设计同步 | PASS | 4.8销毁链与修订记录同步,D1/D5不变 |
+
+**元数据**:operations.py单文件;T266红例先于实现合入,14严格绿及独立相邻149绿;依赖/配对PASS。主树runtime全部选择1720pass/13skip和51个测试环境失败:缺省Helm临时路径已消失;显式现有helm3.16.4后安装模块121pass,剩21loopback绑定被沙箱拒绝,获授权重跑21pass/121deselected(1.12s)。1771个唯一测试逐项覆盖;不把初次失败抹去或称一次全量命令绿。Ruff/check format no-cache及两文件arch-guard/diffcheck通过。首次状态更新脚本cwd错误没有写文件,随后修正绝对路径同步标记;无产品范围变化。仍须R30原生构建/真实删除复验,T072未执行。
+
+## Task Review: T268 — PVC 异步销毁配对
+
+**结果:PASS。** 仅新增245行测试文件,完整AC-35、AC-46、AC-49、AC-50、AC-52、AC-55元数据、范围/命名和T267依赖通过。真实OperationExecutor→维护/数据执行器→PlatformClient.fact/实际backend schema及私有SQLite purge,Kube与账本I/O替身;同UID PVC DELETE后仍存在、自然404、持久结果后崩溃接管不重复helper与原期限/取消/围栏/权限/API未知均有实质配对。旧686bf有效期限版12红4绿;主树固定551f操作源码后16绿,独立217相邻绿。原0.25秒夹具不满足PodPlan最小有效期限,已单独改为合法2秒、保持原期限自然到期及预期不变,原失败日志保留。仅测试L1通过,不证明R31实际部署/销毁。
+
+## Task Review: T269 — 原期限内等待 PVC 清理
+
+**结果:PASS。** operations.py单文件新增既有清理协议适配,只将明确的`ExecutorError("purge_volume_cleanup_pending")`转换为等待;其他异常原样传播。每轮既有fresh checkpoint/控制租约与原deadline不重置,持久维护结果复用,Docker仍原直调。无平台数据库/存储/权限Owner转移、无DTO/schema/迁移/UI/凭据或新日志,英文代码符合命名与信息泄漏约定;design既有销毁实现记录已同步。T268先完成,依赖/配对及两文件架构门、Ruff no-cache/格式/diffcheck通过。
+
+主树完整`HELM_BIN=/private/tmp/f059-installer-tools/helm ... pytest -p no:cacheprovider -q tests`一次1787passed/13skipped(34.00s),日志`/private/tmp/f059-t269-root-runtime-full-r2.log`;首轮1failed/1786passed为无效0.25秒期限夹具,改2秒后重测,未改产品期限或删除失败记录。独立217相邻及16精确配对通过;独立报告`/private/tmp/f059-t268-t269-c-independent-l1.md` SHA097eb21e78b898889187cb18bcea4ce8fe9d1de230ed0c5f9047a0726788f28e,作者报告f941a3c5…另存。以上为本地与模拟证据,真实新镜像销毁另验,T069/T070/T072未勾选。
+
+## Task Review: T270 — 恢复后的服务实例注册配对
+
+**结果:PASS。** 单一新测试文件及完整AC-05、AC-13、AC-15、AC-47、AC-48、AC-49、AC-50、AC-52 header/任务索引一致;T269前置完成,测试先于T271。真实manager Pod/Service/Secret模板、真实SQLite资源账本、材料委托以及ready→AppState→route事务;覆盖严格resume布尔、最新持久事实/snapshot/digest/UUID5 helper ref、阶段、版本/pending漂移、租约/代际/冻结镜像/PVC及旧writer停止拒绝。旧6c969首轮12fail/33pass日志cfbc7e42…保留;True→1的SQLAlchemy JSON相等抑制更新经fresh读取定位,夹具改为attached row显式dirty并断言落库类型,不改拒绝预期。无新依赖或权限模拟放行。
+
+## Task Review: T271 — 仅持久恢复完成后允许 serve
+
+| # | 检查项 | 结果 | 说明 |
+| --- | --- | --- | --- |
+| 1 | 架构 | PASS | 单backend Service通过既有Repository读取最新fact,不接K8s/MinIO,不移动Owner |
+| 2 | 命名 | PASS | 私有_restore_serve_allowed与内部kw,外部body不允许传放行标志 |
+| 3 | 序列化 | PASS | 不改schema;恢复结果/参数摘要/helper ref必须同操作,非bool拒绝 |
+| 4 | 数据库 | PASS | 无模型/DDL/迁移;复用资源事务的全app旧Pod停止/cleanup门 |
+| 5 | 前端 | N/A | 无UI文件 |
+| 6 | 信息泄漏 | PASS | 无凭据或新日志输出,新增代码注释英文 |
+| 7 | 设计同步 | PASS | 实施现状同步;材料授权、路由提交、停止围栏及Docker合同不变 |
+
+生产提交38a7002335bdf5c7c3aa4e13e93d32d6d8150c81,文件SHA c06fb408033f4d77c767924908209e92fd06650d7275bac2cb6cba15442327ce。配对T270先闭合;root完整app_runtime **1174passed/19skipped**(37.93s),日志/private/tmp/f059-t271-root-backend-r4.log SHA79892a3c7b3f97cf8942ce283eddb11b9ac70217a4753aebb7e51404594b228e。独立170pass(8.88s)及另一代理45pass(3.27s),Ruff no-cache/format/逐路径架构门/diffcheck通过。初次缺config与空配置JWT初始化造成环境失败另存,57项定向重跑通过;未把初次失败覆盖为绿色。以上为本地证据;189首个restore仍failed,新的真实恢复/删除成功不能由L1推断。
+
+## Task Review: T069 — 真实平台业务流程
+
+**结果:PASS(限任务列明业务流程)。** 指定platform.json/md两文件汇总了五交付模式及v1/v2、发布/预览/审批、容量拒绝与同审批恢复、HTTP/WS/SDK/OBO/附件/权限、在线和停用数据查改CSV/同v1恢复;最终R31健康fullimage正常删除的唯一intent、durable destroy/purge、同UID kubelet停止、PVC账本归属和自然缺席、PV Retain/SQLite缺席已独立审证。前置T068完成;不新增产品代码、无模型/迁移/UI变化、只输出脱敏标识,历史失败/未知与旧保护围栏完整保留。数据源为真实189,不以本地35/9脚本测试替代;没有重跑完整回归。
+
+独立L1 /private/tmp/f059-t069-independent-l1-r31.md SHA b8b0b9eb972855feb6c7c2e59a889028938bbcad8dfce6ce9f272aa0b4bd7fd2,复算UUID5/digest/同UID/PVC清理op/PV claim与原始三份证明匹配。snapshot样本0和当前prefix空不证明非空删除,源对象保留未采样;AC-02、AC-17、AC-18、AC-37、AC-45未新增真实子句证据明确留矩阵,T070故障/独立卸载、Docker补验/发行/L2仍独立pending。
+
+
+## T272 / T273 — PASS(租约交接定向修复,2026-10-23)
+
+- 根因:真实189在途SIGKILL后,旧holder尚未到期的409/controller_lease_held误入业务失败处理;真实双执行器最小复现确认跨到期后cleanup取得新epoch并终结原prepare。后端Service探针确认租约和相同模板资源复用符合原合同。
+- 实现:仅operations.execute的K8s分支在try/operation claim之前调用_controller。初始控制拒绝向Future传播,正常对账下一轮原plan再提交;不调用业务_failure,原操作/资源/阶段事实/deadline保持。真正业务API异常仍原失败/清理,Docker不走新门。
+- 测试:私有新7例旧代码4fail/3pass,红日志SHA3e6a8c7ba492d7b0b1f23d95e185a53f34a2f9a2a50ad2163e4f6d879a67272e。主树新配对+test_operations_v2+test_k8s_prepare_cancellation直接关联123pass/1fail;仅既有controller故障用例需适配异常传播,并新增整op不变断言。随后仅新配对+该6例13pass/0.17s,未重跑全量。日志/private/tmp/f059-t272-t273-targeted-green-r1.log与-r2.log保留。
+- 独立L1:/private/tmp/f059-t272-t273-independent-l1.md,SHA489710ae0e65291f4faf09295800b60ee6f353c0b3df23b17d9899b459f31021;无产品阻断,两项测试AC标注/第二测试文件清单已由root补齐。Ruff、format、逐文件arch-guard及diff检查通过。
+- 边界:仅本地定向修复闭合,不宣称189新镜像/在途续办通过,不覆盖原NOT_PASSED记录,不替代T070其余场景或L2。
+
+T272/T273真实续验:R32实际源码/镜像/Pod identity固定后,189同原prepare在途SIGKILL恢复并自然完成,原deadline/输入结果/安装UID-CID/正常清理与容量均通过,未再跑任何全量测试。详细证据见e2e-evidence/fault-upgrade.json的inflight_manager_sigkill_189_r32_lease_handoff;只关闭本租约交接缺陷,不扩大为整个T070/L2通过。
+
+189在途续办独立最终窄审a89f59ae…通过;私有manager发行/升级独立审证133fd983…通过。仅记录本次实际窄范围,不关闭T070其余子项或正式CI/L2。
+
+## 2026-10-23 开发整合定向复核
+
+用户明确授权先收尾开发、合入并推送 feat/3.0.0-vibe,避免全量回归。F059 T274(infra,两文件)CI同版本跨入口并发锁:新增现有契约断言先1失败,实际key修正后1通过;Ruff/format/架构门通过。两个模型/测试冲突逐hunk保留F059后续tenant/UID语义,与原实现相同,定向模型合同17通过(DM8仅静态)。新WT配置缺失的收集错误已用现有本地测试配置解决,不记作产品失败。三条独立L2分工代码无HIGH,设计D1定向复审关闭,开发合入PASS_WITH_WARNINGS。完整交付门保持pending;无全量或远端重验。详见code-review.md与整合报告。
diff --git a/features/v3.0.0/059-k8s-runtime-backend/tasks-index.json b/features/v3.0.0/059-k8s-runtime-backend/tasks-index.json
new file mode 100644
index 0000000000..d5c18daba9
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/tasks-index.json
@@ -0,0 +1,7148 @@
+{
+  "feature": "F059",
+  "design_approved_sha256": "b846e993909cda416502d33fdbf6b2174357068673840c2fc0f7d75fe9dc76a4",
+  "tasks": [
+    {
+      "id": "T001",
+      "wave": 1,
+      "kind": "基础设施",
+      "title": "K8s 配置及部署描述",
+      "files": [
+        "src/runtime-manager/runtime_manager/k8s_config.py",
+        "src/runtime-manager/runtime_manager/config.py"
+      ],
+      "logic": "新增 RTM_EXECUTOR 与 K8s namespace/cluster/descriptor/token/CA 配置;默认 Docker,K8s 强制 v2,非法形态/namespace/descriptor 拒绝;敏感 token 仅按文件读取,不输出。",
+      "acs": [
+        "AC-01",
+        "AC-03",
+        "AC-13",
+        "AC-39"
+      ],
+      "depends": [],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T002",
+      "wave": 1,
+      "kind": "基础设施",
+      "title": "资源账本、材料授权与控制器字段",
+      "files": [
+        "src/backend/bisheng/app_runtime/domain/models/runtime_kubernetes.py",
+        "src/backend/bisheng/app_runtime/domain/models/runtime_delivery.py"
+      ],
+      "logic": "按 design 6.2 定义资源 UID/停止证据、委托公钥/范围及 Node controller 租约字段、Transfer 授权字段;继承已有租户记录,使用 JsonType/跨库类型;新 36-char 标识仅用于新增路径,保留旧 ID。",
+      "acs": [
+        "AC-13",
+        "AC-15",
+        "AC-47",
+        "AC-48"
+      ],
+      "depends": [],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T003",
+      "wave": 1,
+      "kind": "基础设施",
+      "title": "Schema 扩展与租户登记",
+      "files": [
+        "src/backend/bisheng/core/database/alembic/versions/f059_runtime_kubernetes.py",
+        "src/backend/bisheng/app_runtime/domain/models/__init__.py"
+      ],
+      "logic": "在真实单 head 后追加扩展 DDL;在已注册的 models 包导入新表以进入租户发现。旧数据回填另列运维前置,不在 revision 做读写变换。降级前由独立运维前置检查停用 K8s/无新引用,未满足则拒绝;revision 只撤列等 DDL,不执行数据回填/清理;保留原 Docker 字段。",
+      "acs": [
+        "AC-15",
+        "AC-39",
+        "AC-40",
+        "AC-48",
+        "AC-56"
+      ],
+      "depends": [
+        "T002"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T004",
+      "wave": 1,
+      "kind": "基础设施",
+      "title": "密码依赖的可重复镜像供应",
+      "files": [
+        "src/runtime-manager/pyproject.toml",
+        "src/runtime-manager/uv.lock"
+      ],
+      "logic": "manager/helper 显式锁定维护中的 cryptography 依赖;Apache/BSD 许可证检查;无现场下载、无自研密码算法,双架构安装与镜像供应由发行任务验收。",
+      "acs": [
+        "AC-15",
+        "AC-39",
+        "AC-42",
+        "AC-54"
+      ],
+      "depends": [],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T073",
+      "wave": 1,
+      "kind": "基础设施",
+      "title": "F086 字节计数器继承与迁移汇合",
+      "files": [
+        "src/backend/bisheng/core/database/alembic/versions/f059_merge_f086_heads.py",
+        "src/backend/test/app_runtime/test_runtime_model_contracts.py"
+      ],
+      "logic": "继承F086 667546bb77的Vibe专有BIGINT修复;模型在通用发布线上不存在。显式merge revision保留两个原parent,恢复单head;静态守卫明确历史授权NULL/待绑定UID NULL,其他约束不放宽;DM8仅静态。",
+      "acs": [
+        "AC-15",
+        "AC-39",
+        "AC-48",
+        "AC-56"
+      ],
+      "depends": [
+        "T003"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T074",
+      "wave": 1,
+      "kind": "基础设施",
+      "title": "发布线继承的迁移图汇合",
+      "files": [
+        "src/backend/bisheng/core/database/alembic/versions/f059_merge_release_heads.py"
+      ],
+      "logic": "合并已授权发布线9d156f261c后,实际出现f059_merge_f086_heads、asr_rt_model_type、merge_skill_center_heads三条head;新增空merge revision保留三条原父链,不改表/数据、不重写历史;以已有单head门禁和模型/账本回归验证。降级只回到原分叉。",
+      "acs": [
+        "AC-39",
+        "AC-48",
+        "AC-56"
+      ],
+      "depends": [
+        "T073"
+      ],
+      "pair": null,
+      "command": "python -m pytest test/database/test_alembic_single_head.py",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T005",
+      "wave": 1,
+      "kind": "基础设施",
+      "title": "F086 公共修复继承核对",
+      "files": [
+        "features/v3.0.0/059-k8s-runtime-backend/prerequisites.md",
+        "features/v3.0.0/059-k8s-runtime-backend/prerequisites.json"
+      ],
+      "logic": "逐文件比对原 F086 的多租户启动、BIGINT 和测试修复与 release-line;记录已有同内容提交或发布线继承方式。禁止直接复制权限分叉;真实平台验收以前必须有来源 SHA/内容证据。",
+      "acs": [
+        "AC-36",
+        "AC-55",
+        "AC-56"
+      ],
+      "depends": [
+        "T074"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T006",
+      "wave": 2,
+      "kind": "测试",
+      "title": "受限 K8s REST client:契约与失败用例",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_client.py"
+      ],
+      "logic": "以 httpx MockTransport 验 namespace 白名单、token 文件轮转、CA/TLS、固定 Core/Discovery 路由;UID/RV 删除条件、分页、410 relist、限时退避与取消;403/404 不当作停止证明;测试只定义输入/结果,不加入实现。",
+      "acs": [
+        "AC-03",
+        "AC-04",
+        "AC-13",
+        "AC-14",
+        "AC-48",
+        "AC-49",
+        "AC-52"
+      ],
+      "depends": [
+        "T001"
+      ],
+      "pair": "T007",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T007",
+      "wave": 2,
+      "kind": "实现",
+      "title": "受限 K8s REST client",
+      "files": [
+        "src/runtime-manager/runtime_manager/k8s_client.py"
+      ],
+      "logic": "以 httpx MockTransport 验 namespace 白名单、token 文件轮转、CA/TLS、固定 Core/Discovery 路由;UID/RV 删除条件、分页、410 relist、限时退避与取消;403/404 不当作停止证明;通过配对测试后完成 L1 task-review。",
+      "acs": [
+        "AC-03",
+        "AC-04",
+        "AC-13",
+        "AC-14",
+        "AC-48",
+        "AC-49",
+        "AC-52"
+      ],
+      "depends": [
+        "T001",
+        "T006"
+      ],
+      "pair": "T006",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T008",
+      "wave": 2,
+      "kind": "测试",
+      "title": "Pod/Service/PVC/Secret 模板:契约与失败用例",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_templates.py"
+      ],
+      "logic": "冻结 profile/架构、UID/GID、命令/资源/RO 挂载、单生产位置及按用途卷;不使用 hostPath/特权/SA token,不由应用提供 PodSpec。临时/预览 deadline 不延长,正式 Pod 无总寿命;测试只定义输入/结果,不加入实现。",
+      "acs": [
+        "AC-05",
+        "AC-11",
+        "AC-12",
+        "AC-16",
+        "AC-17",
+        "AC-18",
+        "AC-19",
+        "AC-20",
+        "AC-21",
+        "AC-24",
+        "AC-27",
+        "AC-33",
+        "AC-34",
+        "AC-43",
+        "AC-44",
+        "AC-54"
+      ],
+      "depends": [
+        "T001"
+      ],
+      "pair": "T009",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T009",
+      "wave": 2,
+      "kind": "实现",
+      "title": "Pod/Service/PVC/Secret 模板",
+      "files": [
+        "src/runtime-manager/runtime_manager/k8s_templates.py"
+      ],
+      "logic": "冻结 profile/架构、UID/GID、命令/资源/RO 挂载、单生产位置及按用途卷;不使用 hostPath/特权/SA token,不由应用提供 PodSpec。临时/预览 deadline 不延长,正式 Pod 无总寿命;通过配对测试后完成 L1 task-review。",
+      "acs": [
+        "AC-05",
+        "AC-11",
+        "AC-12",
+        "AC-16",
+        "AC-17",
+        "AC-18",
+        "AC-19",
+        "AC-20",
+        "AC-21",
+        "AC-24",
+        "AC-27",
+        "AC-33",
+        "AC-34",
+        "AC-43",
+        "AC-44",
+        "AC-54"
+      ],
+      "depends": [
+        "T001",
+        "T008"
+      ],
+      "pair": "T008",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T010",
+      "wave": 2,
+      "kind": "测试",
+      "title": "停止证据三分类与同 UID 收尾:契约与失败用例",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_lifecycle.py"
+      ],
+      "logic": "覆盖未调度删除、init 从未启动、可信 kubelet 终态、NodeLost/404/同名异 UID、陈旧 cache、finalizer 重试;fresh API 证据入账后才释放,未知保留写入占用和唯一备份;测试只定义输入/结果,不加入实现。",
+      "acs": [
+        "AC-08",
+        "AC-09",
+        "AC-11",
+        "AC-35",
+        "AC-43",
+        "AC-48",
+        "AC-49",
+        "AC-50",
+        "AC-52"
+      ],
+      "depends": [
+        "T007"
+      ],
+      "pair": "T011",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T011",
+      "wave": 2,
+      "kind": "实现",
+      "title": "停止证据三分类与同 UID 收尾",
+      "files": [
+        "src/runtime-manager/runtime_manager/k8s_lifecycle.py"
+      ],
+      "logic": "覆盖未调度删除、init 从未启动、可信 kubelet 终态、NodeLost/404/同名异 UID、陈旧 cache、finalizer 重试;fresh API 证据入账后才释放,未知保留写入占用和唯一备份;通过配对测试后完成 L1 task-review。",
+      "acs": [
+        "AC-08",
+        "AC-09",
+        "AC-11",
+        "AC-35",
+        "AC-43",
+        "AC-48",
+        "AC-49",
+        "AC-50",
+        "AC-52"
+      ],
+      "depends": [
+        "T007",
+        "T010"
+      ],
+      "pair": "T010",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T012",
+      "wave": 2,
+      "kind": "测试",
+      "title": "独立准入和 status 来源约束:契约与失败用例",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_admission.py"
+      ],
+      "logic": "验证 AdmissionReview CREATE/UPDATE/DELETE 的批准模板、角色/范围/UID、过期操作收尾计划;status 规则无副作用/不调用 backend,仅绑定 kubelet 可引入终态。拒绝提权、越界 PVC/Secret、强删及基线变更;测试只定义输入/结果,不加入实现。",
+      "acs": [
+        "AC-03",
+        "AC-13",
+        "AC-14",
+        "AC-15",
+        "AC-16",
+        "AC-21",
+        "AC-23",
+        "AC-30",
+        "AC-31",
+        "AC-32",
+        "AC-49"
+      ],
+      "depends": [
+        "T009"
+      ],
+      "pair": "T013",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T013",
+      "wave": 2,
+      "kind": "实现",
+      "title": "独立准入和 status 来源约束",
+      "files": [
+        "src/runtime-manager/runtime_manager/k8s_admission.py"
+      ],
+      "logic": "验证 AdmissionReview CREATE/UPDATE/DELETE 的批准模板、角色/范围/UID、过期操作收尾计划;status 规则无副作用/不调用 backend,仅绑定 kubelet 可引入终态。拒绝提权、越界 PVC/Secret、强删及基线变更;通过配对测试后完成 L1 task-review。",
+      "acs": [
+        "AC-03",
+        "AC-13",
+        "AC-14",
+        "AC-15",
+        "AC-16",
+        "AC-21",
+        "AC-23",
+        "AC-30",
+        "AC-31",
+        "AC-32",
+        "AC-49"
+      ],
+      "depends": [
+        "T009",
+        "T012"
+      ],
+      "pair": "T012",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T014",
+      "wave": 3,
+      "kind": "测试",
+      "title": "平台资源账本 Repository:契约与失败用例",
+      "files": [
+        "src/backend/test/app_runtime/test_runtime_kubernetes_repository.py"
+      ],
+      "logic": "以现有内存/SQLite fixture 验租户范围、资源意图幂等/UID绑定、CAS epoch、容量预占和 cleanup 生命周期;Repository 事务内完成并发控制,业务 Service 不拼 ORM SQL;测试只定义输入/结果,不加入实现。",
+      "acs": [
+        "AC-08",
+        "AC-13",
+        "AC-15",
+        "AC-35",
+        "AC-48",
+        "AC-49",
+        "AC-52"
+      ],
+      "depends": [
+        "T002",
+        "T003"
+      ],
+      "pair": "T015",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T015",
+      "wave": 3,
+      "kind": "实现",
+      "title": "平台资源账本 Repository",
+      "files": [
+        "src/backend/bisheng/app_runtime/domain/repositories/runtime_kubernetes_repository.py"
+      ],
+      "logic": "以现有内存/SQLite fixture 验租户范围、资源意图幂等/UID绑定、CAS epoch、容量预占和 cleanup 生命周期;Repository 事务内完成并发控制,业务 Service 不拼 ORM SQL;通过配对测试后完成 L1 task-review。",
+      "acs": [
+        "AC-08",
+        "AC-13",
+        "AC-15",
+        "AC-35",
+        "AC-48",
+        "AC-49",
+        "AC-52"
+      ],
+      "depends": [
+        "T002",
+        "T003",
+        "T014"
+      ],
+      "pair": "T014",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T016",
+      "wave": 3,
+      "kind": "测试",
+      "title": "控制器租约和资源事实 Service:契约与失败用例",
+      "files": [
+        "src/backend/test/app_runtime/test_runtime_kubernetes_service.py"
+      ],
+      "logic": "claim/renew holder/expected_epoch,验证固定执行域与有效操作、Service提交资源意图并结束SQL事务,manager随后调用 K8s;错 UID、过期控制器、越界、提前释放拒绝,终态后的只读收尾计划可续办;测试只定义输入/结果,不加入实现。",
+      "acs": [
+        "AC-03",
+        "AC-07",
+        "AC-08",
+        "AC-13",
+        "AC-14",
+        "AC-48",
+        "AC-49",
+        "AC-52"
+      ],
+      "depends": [
+        "T015"
+      ],
+      "pair": "T017",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T017",
+      "wave": 3,
+      "kind": "实现",
+      "title": "控制器租约和资源事实 Service",
+      "files": [
+        "src/backend/bisheng/app_runtime/domain/services/runtime_kubernetes_service.py"
+      ],
+      "logic": "claim/renew holder/expected_epoch,验证固定执行域与有效操作、Service提交资源意图并结束SQL事务,manager随后调用 K8s;错 UID、过期控制器、越界、提前释放拒绝,终态后的只读收尾计划可续办;通过配对测试后完成 L1 task-review。",
+      "acs": [
+        "AC-03",
+        "AC-07",
+        "AC-08",
+        "AC-13",
+        "AC-14",
+        "AC-48",
+        "AC-49",
+        "AC-52"
+      ],
+      "depends": [
+        "T015",
+        "T016"
+      ],
+      "pair": "T016",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T018",
+      "wave": 3,
+      "kind": "测试",
+      "title": "内部控制 API 接入:契约与失败用例",
+      "files": [
+        "src/backend/test/app_runtime/test_runtime_kubernetes_api.py"
+      ],
+      "logic": "注册 controllers/resources/observations/admission plans;沿用独立服务 HMAC/角色和序列化,API→Service→Repository;admission 只读,非角色/重放/跨域拒绝;测试只定义输入/结果,不加入实现。",
+      "acs": [
+        "AC-03",
+        "AC-04",
+        "AC-13",
+        "AC-14",
+        "AC-15",
+        "AC-48"
+      ],
+      "depends": [
+        "T017"
+      ],
+      "pair": "T019",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T019",
+      "wave": 3,
+      "kind": "实现",
+      "title": "内部控制 API 接入",
+      "files": [
+        "src/backend/bisheng/app_runtime/api/endpoints/runtime_kubernetes.py",
+        "src/backend/bisheng/app_runtime/api/router.py"
+      ],
+      "logic": "注册 controllers/resources/observations/admission plans;沿用独立服务 HMAC/角色和序列化,API→Service→Repository;admission 只读,非角色/重放/跨域拒绝;通过配对测试后完成 L1 task-review。",
+      "acs": [
+        "AC-03",
+        "AC-04",
+        "AC-13",
+        "AC-14",
+        "AC-15",
+        "AC-48"
+      ],
+      "depends": [
+        "T017",
+        "T018"
+      ],
+      "pair": "T018",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T020",
+      "wave": 3,
+      "kind": "测试",
+      "title": "material-v1 编码与验签:契约与失败用例",
+      "files": [
+        "src/backend/test/app_runtime/test_runtime_material_protocol.py",
+        "src/runtime-manager/tests/test_material_protocol.py"
+      ],
+      "logic": "对同一 414-byte 固定向量验 11 行编码与真实 Ed25519 互验;覆盖原始 body/ticket/method/path/offset、重复头/query、nonce 与时钟界限。双方实现固定 wire contract,不从 backend import manager;测试只定义输入/结果,不加入实现。",
+      "acs": [
+        "AC-15",
+        "AC-46",
+        "AC-47"
+      ],
+      "depends": [
+        "T004"
+      ],
+      "pair": "T021",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T021",
+      "wave": 3,
+      "kind": "实现",
+      "title": "material-v1 编码与验签",
+      "files": [
+        "src/backend/bisheng/app_runtime/domain/services/runtime_material_protocol.py",
+        "src/runtime-manager/runtime_manager/material_protocol.py"
+      ],
+      "logic": "对同一 414-byte 固定向量验 11 行编码与真实 Ed25519 互验;覆盖原始 body/ticket/method/path/offset、重复头/query、nonce 与时钟界限。双方实现固定 wire contract,不从 backend import manager;通过配对测试后完成 L1 task-review。",
+      "acs": [
+        "AC-15",
+        "AC-46",
+        "AC-47"
+      ],
+      "depends": [
+        "T004",
+        "T020"
+      ],
+      "pair": "T020",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T022",
+      "wave": 3,
+      "kind": "测试",
+      "title": "材料委托和续期 Service:契约与失败用例",
+      "files": [
+        "src/backend/test/app_runtime/test_runtime_material_service.py"
+      ],
+      "logic": "从操作/部署/预览推导范围并绑定 UID/公钥;operation 终态失效,deployment-init 按当前 serving/候选冻结引用续取;旧 ticket 不独立续期,撤销/跨租户/错材料/Redis未知拒绝;测试只定义输入/结果,不加入实现。",
+      "acs": [
+        "AC-15",
+        "AC-27",
+        "AC-46",
+        "AC-47",
+        "AC-48"
+      ],
+      "depends": [
+        "T015",
+        "T021"
+      ],
+      "pair": "T023",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T023",
+      "wave": 3,
+      "kind": "实现",
+      "title": "材料委托和续期 Service",
+      "files": [
+        "src/backend/bisheng/app_runtime/domain/services/runtime_material_service.py",
+        "src/backend/bisheng/app_runtime/domain/models/runtime_kubernetes.py"
+      ],
+      "logic": "从操作/部署/预览推导范围并绑定 UID/公钥;operation 终态失效,deployment-init 按当前 serving/候选冻结引用续取;旧 ticket 不独立续期,撤销/跨租户/错材料/Redis未知拒绝;正式部署的expires_at允许NULL,含义为逐次检查当前归属;operation/预览分别保留操作/会话期限。仅调整未发布新表声明,无数据或已发布父链变更;通过配对测试后完成 L1 task-review。",
+      "acs": [
+        "AC-15",
+        "AC-27",
+        "AC-46",
+        "AC-47",
+        "AC-48"
+      ],
+      "depends": [
+        "T015",
+        "T021",
+        "T022"
+      ],
+      "pair": "T022",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T024",
+      "wave": 3,
+      "kind": "测试",
+      "title": "范围化分块材料传输:契约与失败用例",
+      "files": [
+        "src/backend/test/app_runtime/test_runtime_material_transfers.py"
+      ],
+      "logic": "复用对象存储和持久 offset;exchange 同范围轮换票据,单块 16MiB,重复同字节幂等/错摘要拒绝,分块下载与 finalize 内容/SQL确认;接口到期和响应丢失可查,无 MinIO 直链;测试只定义输入/结果,不加入实现。",
+      "acs": [
+        "AC-26",
+        "AC-28",
+        "AC-29",
+        "AC-42",
+        "AC-46",
+        "AC-47",
+        "AC-51"
+      ],
+      "depends": [
+        "T023"
+      ],
+      "pair": "T025",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T025",
+      "wave": 3,
+      "kind": "实现",
+      "title": "范围化分块材料传输",
+      "files": [
+        "src/backend/bisheng/app_runtime/domain/services/runtime_material_transfer_service.py",
+        "src/backend/bisheng/app_runtime/domain/services/runtime_artifact_service.py"
+      ],
+      "logic": "复用对象存储和持久 offset;exchange 同范围轮换票据,单块 16MiB,重复同字节幂等/错摘要拒绝,分块下载与 finalize 内容/SQL确认;接口到期和响应丢失可查,无 MinIO 直链;通过配对测试后完成 L1 task-review。",
+      "acs": [
+        "AC-26",
+        "AC-28",
+        "AC-29",
+        "AC-42",
+        "AC-46",
+        "AC-47",
+        "AC-51"
+      ],
+      "depends": [
+        "T023",
+        "T024"
+      ],
+      "pair": "T024",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T075",
+      "wave": 3,
+      "kind": "测试",
+      "title": "材料根路径多租户中间件:合同与失败用例",
+      "files": [
+        "src/backend/test/app_runtime/test_runtime_material_middleware.py"
+      ],
+      "logic": "固定材料根路径独立于浏览器JWT,租户由公钥授权决定;精确前缀免浏览器租户检查,相似路径不豁免;真实CustomMiddleware验证;测试不加入生产实现。",
+      "acs": [
+        "AC-15",
+        "AC-47",
+        "AC-48"
+      ],
+      "depends": [
+        "T025"
+      ],
+      "pair": "T076",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T076",
+      "wave": 3,
+      "kind": "实现",
+      "title": "材料根路径多租户中间件接线",
+      "files": [
+        "src/backend/bisheng/utils/http_middleware.py"
+      ],
+      "logic": "仅新增材料根路径到可信内部请求前缀,并排除浏览器凭据解析;授权Service逐次清除ambient bypass/admin后验证租户/UID,不扩大其他路径。保持平台已有JWT/DSH语义;配对测试及L1后完成。",
+      "acs": [
+        "AC-15",
+        "AC-47",
+        "AC-48"
+      ],
+      "depends": [
+        "T025",
+        "T075"
+      ],
+      "pair": "T075",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T026",
+      "wave": 3,
+      "kind": "测试",
+      "title": "材料 workload HTTP API:契约与失败用例",
+      "files": [
+        "src/backend/test/app_runtime/test_runtime_material_api.py"
+      ],
+      "logic": "按 design 6.2.1 完整方法/头/响应/错误注册 exchange、status、content、finalize;使用 workload 身份依赖,拒绝 manager/app/OBO 身份互换,流式阶段重新检查期限/撤销;测试只定义输入/结果,不加入实现。",
+      "acs": [
+        "AC-15",
+        "AC-28",
+        "AC-46",
+        "AC-47",
+        "AC-51"
+      ],
+      "depends": [
+        "T025",
+        "T019",
+        "T076"
+      ],
+      "pair": "T027",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T027",
+      "wave": 3,
+      "kind": "实现",
+      "title": "材料 workload HTTP API",
+      "files": [
+        "src/backend/bisheng/app_runtime/api/endpoints/runtime_material.py",
+        "src/backend/bisheng/main.py"
+      ],
+      "logic": "material-v1固定P直接注册为main根路由,不继承/api/v1前缀;按 design 6.2.1 完整方法/头/响应/错误注册 exchange、status、content、finalize;使用 workload 身份依赖,拒绝 manager/app/OBO 身份互换,流式阶段重新检查期限/撤销;通过配对测试后完成 L1 task-review。",
+      "acs": [
+        "AC-15",
+        "AC-28",
+        "AC-46",
+        "AC-47",
+        "AC-51"
+      ],
+      "depends": [
+        "T025",
+        "T019",
+        "T026"
+      ],
+      "pair": "T026",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T028",
+      "wave": 4,
+      "kind": "测试",
+      "title": "材料 client 与平台控制 client:契约与失败用例",
+      "files": [
+        "src/runtime-manager/tests/test_material_client.py"
+      ],
+      "logic": "下载/上传按 frozen SHA、受限票据及新 nonce 签名;部分块丢弃、响应丢失查 offset、重交换不延长操作;控制 client 支持资源、租约、材料绑定接口且不记录私钥;测试只定义输入/结果,不加入实现。",
+      "acs": [
+        "AC-07",
+        "AC-15",
+        "AC-26",
+        "AC-28",
+        "AC-42",
+        "AC-44",
+        "AC-46",
+        "AC-47",
+        "AC-52"
+      ],
+      "depends": [
+        "T007",
+        "T027"
+      ],
+      "pair": "T029",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T029",
+      "wave": 4,
+      "kind": "实现",
+      "title": "材料 client 与平台控制 client",
+      "files": [
+        "src/runtime-manager/runtime_manager/material_client.py",
+        "src/runtime-manager/runtime_manager/platform_client.py"
+      ],
+      "logic": "下载/上传按 frozen SHA、受限票据及新 nonce 签名;部分块丢弃、响应丢失查 offset、重交换不延长操作;控制 client 支持资源、租约、材料绑定接口且不记录私钥;通过配对测试后完成 L1 task-review。",
+      "acs": [
+        "AC-07",
+        "AC-15",
+        "AC-26",
+        "AC-28",
+        "AC-42",
+        "AC-44",
+        "AC-46",
+        "AC-47",
+        "AC-52"
+      ],
+      "depends": [
+        "T007",
+        "T027",
+        "T028"
+      ],
+      "pair": "T028",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T077",
+      "wave": 4,
+      "kind": "测试",
+      "title": "准备操作依赖缓存选择:合同与失败用例",
+      "files": [
+        "src/backend/test/app_runtime/test_runtime_dependency_materials.py"
+      ],
+      "logic": "缓存选择须持久化当前准备操作;同应用/profile/platform/verified、输入键不可变;材料授权从真实选择/SQL计划推导,伪造引用不扩权;测试不混入实现。",
+      "acs": [
+        "AC-15",
+        "AC-25",
+        "AC-28",
+        "AC-29",
+        "AC-46",
+        "AC-47"
+      ],
+      "depends": [
+        "T023",
+        "T029"
+      ],
+      "pair": "T078",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T078",
+      "wave": 4,
+      "kind": "实现",
+      "title": "依赖缓存选择与材料授权接线",
+      "files": [
+        "src/backend/bisheng/app_runtime/domain/services/runtime_profile_service.py",
+        "src/backend/bisheng/app_runtime/domain/services/runtime_material_service.py"
+      ],
+      "logic": "resolve/confirm在操作行锁下持久选中结果,授权复查同范围SQL计划和材料;禁止任意引用扩大授权、终态写入或更换输入键。保持旧Docker内容确认合同。配对测试及L1后完成。",
+      "acs": [
+        "AC-15",
+        "AC-25",
+        "AC-28",
+        "AC-29",
+        "AC-46",
+        "AC-47"
+      ],
+      "depends": [
+        "T023",
+        "T029",
+        "T077"
+      ],
+      "pair": "T077",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T030",
+      "wave": 4,
+      "kind": "测试",
+      "title": "受信材料 helper 与离线环境物化:契约与失败用例",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_helper.py"
+      ],
+      "logic": "material-fetch 只安全取冻结材料;无凭据 environment 阶段在固定 /opt/venv/Node/static 路径物化;prepare 分下载/安装编译/上传独立 Pod,代码不会借传输身份执行;环境键包含 profile/ABI/架构/锁/安装输入;测试只定义输入/结果,不加入实现。",
+      "acs": [
+        "AC-15",
+        "AC-16",
+        "AC-24",
+        "AC-25",
+        "AC-26",
+        "AC-28",
+        "AC-29",
+        "AC-42",
+        "AC-43",
+        "AC-44",
+        "AC-45",
+        "AC-46",
+        "AC-54"
+      ],
+      "depends": [
+        "T009",
+        "T029",
+        "T078"
+      ],
+      "pair": "T031",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T031",
+      "wave": 4,
+      "kind": "实现",
+      "title": "受信材料 helper 与离线环境物化",
+      "files": [
+        "src/runtime-manager/runtime_manager/k8s_helper.py",
+        "src/runtime-manager/runtime_manager/k8s_prepare.py"
+      ],
+      "logic": "material-fetch 只安全取冻结材料;无凭据 environment 阶段在固定 /opt/venv/Node/static 路径物化;prepare 分下载/安装编译/上传独立 Pod,代码不会借传输身份执行;环境键包含 profile/ABI/架构/锁/安装输入;通过配对测试后完成 L1 task-review。",
+      "acs": [
+        "AC-15",
+        "AC-16",
+        "AC-24",
+        "AC-25",
+        "AC-26",
+        "AC-28",
+        "AC-29",
+        "AC-42",
+        "AC-43",
+        "AC-44",
+        "AC-45",
+        "AC-46",
+        "AC-54"
+      ],
+      "depends": [
+        "T009",
+        "T029",
+        "T030"
+      ],
+      "pair": "T030",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T079",
+      "wave": 4,
+      "kind": "测试",
+      "title": "K8s节点报告和控制事实:合同与失败用例",
+      "files": [
+        "src/backend/test/app_runtime/test_runtime_kubernetes_coordination.py"
+      ],
+      "logic": "心跳持久执行形态/探测,失败阻断新建但可清理;事实必须当前租约/代次,停止声明须资源证据,ready须已绑定Service/Pod;未知异常不被全局错误处理泄漏。测试只定义合同。",
+      "acs": [
+        "AC-03",
+        "AC-07",
+        "AC-08",
+        "AC-13",
+        "AC-14",
+        "AC-30",
+        "AC-31",
+        "AC-48",
+        "AC-49",
+        "AC-52"
+      ],
+      "depends": [
+        "T019",
+        "T031"
+      ],
+      "pair": "T080",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T080",
+      "wave": 4,
+      "kind": "实现",
+      "title": "K8s节点与控制事实接线",
+      "files": [
+        "src/backend/bisheng/app_runtime/domain/services/runtime_operation_service.py",
+        "src/backend/bisheng/app_runtime/api/endpoints/runtime_delivery.py"
+      ],
+      "logic": "扩展兼容心跳/node plan、K8s控制器事实闸口、停止与ready事实校验;保留Docker合同。探测失效阻断新建,清理不被同一状态锁死;内部未知异常安全503。配对测试和L1后完成。",
+      "acs": [
+        "AC-03",
+        "AC-07",
+        "AC-08",
+        "AC-13",
+        "AC-14",
+        "AC-30",
+        "AC-31",
+        "AC-48",
+        "AC-49",
+        "AC-52"
+      ],
+      "depends": [
+        "T019",
+        "T031",
+        "T079"
+      ],
+      "pair": "T079",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T032",
+      "wave": 4,
+      "kind": "测试",
+      "title": "K8s 执行能力与资源操作:契约与失败用例",
+      "files": [
+        "src/runtime-manager/tests/test_kubernetes_executor.py"
+      ],
+      "logic": "实现 prepare/launch/observe/request_stop/confirm_stopped/cleanup/logs/data 能力,只有 adapter 拼 Core API;固定名称/UID 账本、Service就绪端点、bounded wait、平台资源事实及数据/维护前后 stop 闸口;测试只定义输入/结果,不加入实现。",
+      "acs": [
+        "AC-04",
+        "AC-05",
+        "AC-07",
+        "AC-08",
+        "AC-09",
+        "AC-11",
+        "AC-18",
+        "AC-33",
+        "AC-35",
+        "AC-43",
+        "AC-48",
+        "AC-49",
+        "AC-50",
+        "AC-52"
+      ],
+      "depends": [
+        "T031",
+        "T011",
+        "T019",
+        "T080"
+      ],
+      "pair": "T033",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T033",
+      "wave": 4,
+      "kind": "实现",
+      "title": "K8s 执行能力与资源操作",
+      "files": [
+        "src/runtime-manager/runtime_manager/executors/kubernetes.py",
+        "src/runtime-manager/runtime_manager/executors/protocol.py"
+      ],
+      "logic": "实现 prepare/launch/observe/request_stop/confirm_stopped/cleanup/logs/data 能力,只有 adapter 拼 Core API;固定名称/UID 账本、Service就绪端点、bounded wait、平台资源事实及数据/维护前后 stop 闸口;通过配对测试后完成 L1 task-review。",
+      "acs": [
+        "AC-04",
+        "AC-05",
+        "AC-07",
+        "AC-08",
+        "AC-09",
+        "AC-11",
+        "AC-18",
+        "AC-33",
+        "AC-35",
+        "AC-43",
+        "AC-48",
+        "AC-49",
+        "AC-50",
+        "AC-52"
+      ],
+      "depends": [
+        "T031",
+        "T011",
+        "T019",
+        "T032"
+      ],
+      "pair": "T032",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T034",
+      "wave": 4,
+      "kind": "测试",
+      "title": "Docker 能力适配且保留旧合同:契约与失败用例",
+      "files": [
+        "src/runtime-manager/tests/test_docker_executor.py"
+      ],
+      "logic": "抽离既有 Docker/本机路径算法到适配器,通用 checkpoint/facts/操作归属保留;基于现有三路径/生命周期测试比较原输入输出,不用假 K8s API 模拟 Docker;测试只定义输入/结果,不加入实现。",
+      "acs": [
+        "AC-01",
+        "AC-04",
+        "AC-10",
+        "AC-36",
+        "AC-37",
+        "AC-56"
+      ],
+      "depends": [
+        "T033"
+      ],
+      "pair": "T035",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T035",
+      "wave": 4,
+      "kind": "实现",
+      "title": "Docker 能力适配且保留旧合同",
+      "files": [
+        "src/runtime-manager/runtime_manager/executors/docker.py",
+        "src/runtime-manager/runtime_manager/operations.py"
+      ],
+      "logic": "抽离既有 Docker/本机路径算法到适配器,通用 checkpoint/facts/操作归属保留;基于现有三路径/生命周期测试比较原输入输出,不用假 K8s API 模拟 Docker;通过配对测试后完成 L1 task-review。",
+      "acs": [
+        "AC-01",
+        "AC-04",
+        "AC-10",
+        "AC-36",
+        "AC-37",
+        "AC-56"
+      ],
+      "depends": [
+        "T033",
+        "T034"
+      ],
+      "pair": "T034",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T036",
+      "wave": 4,
+      "kind": "测试",
+      "title": "通用操作状态机和 composition root:契约与失败用例",
+      "files": [
+        "src/runtime-manager/tests/test_executor_selection.py"
+      ],
+      "logic": "按配置选唯一执行器并共用操作状态/事实编排;K8s 不初始化 Docker 或 v1本地循环;不接受调用方执行路径/PodSpec,重复唤醒、manager关闭/恢复、待上线前后容量语义保持;测试只定义输入/结果,不加入实现。",
+      "acs": [
+        "AC-01",
+        "AC-02",
+        "AC-04",
+        "AC-05",
+        "AC-07",
+        "AC-08",
+        "AC-09",
+        "AC-36",
+        "AC-37",
+        "AC-39",
+        "AC-48",
+        "AC-56"
+      ],
+      "depends": [
+        "T035"
+      ],
+      "pair": "T037",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T037",
+      "wave": 4,
+      "kind": "实现",
+      "title": "通用操作状态机和 composition root",
+      "files": [
+        "src/runtime-manager/runtime_manager/operations.py",
+        "src/runtime-manager/runtime_manager/api/operations.py"
+      ],
+      "logic": "按配置选唯一执行器并共用操作状态/事实编排;K8s 不初始化 Docker 或 v1本地循环;不接受调用方执行路径/PodSpec,重复唤醒、manager关闭/恢复、待上线前后容量语义保持;通过配对测试后完成 L1 task-review。",
+      "acs": [
+        "AC-01",
+        "AC-02",
+        "AC-04",
+        "AC-05",
+        "AC-07",
+        "AC-08",
+        "AC-09",
+        "AC-36",
+        "AC-37",
+        "AC-39",
+        "AC-48",
+        "AC-56"
+      ],
+      "depends": [
+        "T035",
+        "T036"
+      ],
+      "pair": "T036",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T038",
+      "wave": 4,
+      "kind": "测试",
+      "title": "状态、日志和能力 API:契约与失败用例",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_observe.py"
+      ],
+      "logic": "保留业务 API 范围、since/tail/脱敏,仅读取 app/previous 和删除前有界诊断;新增认证 capabilities,前置 blocked 原因清楚;健康存活不冒充已就绪,K8s禁用v1编排注册;测试只定义输入/结果,不加入实现。",
+      "acs": [
+        "AC-02",
+        "AC-07",
+        "AC-10",
+        "AC-30",
+        "AC-31",
+        "AC-32",
+        "AC-36",
+        "AC-37",
+        "AC-40",
+        "AC-53"
+      ],
+      "depends": [
+        "T037"
+      ],
+      "pair": "T039",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T039",
+      "wave": 4,
+      "kind": "实现",
+      "title": "状态、日志和能力 API",
+      "files": [
+        "src/runtime-manager/runtime_manager/api/platform_observe.py",
+        "src/runtime-manager/runtime_manager/main.py"
+      ],
+      "logic": "保留业务 API 范围、since/tail/脱敏,仅读取 app/previous 和删除前有界诊断;新增认证 capabilities,前置 blocked 原因清楚;健康存活不冒充已就绪,K8s禁用v1编排注册;通过配对测试后完成 L1 task-review。",
+      "acs": [
+        "AC-02",
+        "AC-07",
+        "AC-10",
+        "AC-30",
+        "AC-31",
+        "AC-32",
+        "AC-36",
+        "AC-37",
+        "AC-40",
+        "AC-53"
+      ],
+      "depends": [
+        "T037",
+        "T038"
+      ],
+      "pair": "T038",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T040",
+      "wave": 5,
+      "kind": "测试",
+      "title": "数据 helper 动作票据与 journal:契约与失败用例",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_data_helper.py"
+      ],
+      "logic": "只持 backend 公钥;5秒受理票据绑定 app/UID/action/body/nonce,有界60秒执行和串行;platform目录持久started/done,未知不重做;owner数据白名单SQL和流式导出,结果查询新只读ID指向原ID;测试只定义输入/结果,不加入实现。",
+      "acs": [
+        "AC-15",
+        "AC-16",
+        "AC-33",
+        "AC-34",
+        "AC-36",
+        "AC-37",
+        "AC-43",
+        "AC-48",
+        "AC-50"
+      ],
+      "depends": [
+        "T021",
+        "T009"
+      ],
+      "pair": "T041",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T041",
+      "wave": 5,
+      "kind": "实现",
+      "title": "数据 helper 动作票据与 journal",
+      "files": [
+        "src/runtime-manager/runtime_manager/k8s_data_helper.py",
+        "src/runtime-manager/runtime_manager/data_grant.py"
+      ],
+      "logic": "只持 backend 公钥;5秒受理票据绑定 app/UID/action/body/nonce,有界60秒执行和串行;platform目录持久started/done,未知不重做;owner数据白名单SQL和流式导出,结果查询新只读ID指向原ID;通过配对测试后完成 L1 task-review。",
+      "acs": [
+        "AC-15",
+        "AC-16",
+        "AC-33",
+        "AC-34",
+        "AC-36",
+        "AC-37",
+        "AC-43",
+        "AC-48",
+        "AC-50"
+      ],
+      "depends": [
+        "T021",
+        "T009",
+        "T040"
+      ],
+      "pair": "T040",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T081",
+      "wave": 5,
+      "kind": "测试",
+      "title": "数据会话互斥与开销预占合同",
+      "files": [
+        "src/backend/test/app_runtime/test_runtime_data_repository.py"
+      ],
+      "logic": "真实SQL验证data_operation_id不改变serving代次/归属,数据会话与生命周期提交/claim互斥;期限到期或未知结果不释放,只有终态且清理完成释放;K8s正式Pod的128MiB/25m sidecar计入预占,Docker原额度不变。先验证SQL原子性,再接Service;不加入实现。",
+      "acs": [
+        "AC-04",
+        "AC-05",
+        "AC-33",
+        "AC-34",
+        "AC-35",
+        "AC-36",
+        "AC-43",
+        "AC-48",
+        "AC-49",
+        "AC-50",
+        "AC-52"
+      ],
+      "depends": [
+        "T041",
+        "T080"
+      ],
+      "pair": "T082",
+      "command": "pytest test/app_runtime/test_runtime_data_repository.py",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T082",
+      "wave": 5,
+      "kind": "实现",
+      "title": "数据会话账本及部署资源开销接线",
+      "files": [
+        "src/backend/bisheng/app_runtime/domain/repositories/runtime_delivery_repository.py",
+        "src/backend/bisheng/app_runtime/domain/services/runtime_operation_service.py"
+      ],
+      "logic": "Repository在node/assignment锁下登记内部数据操作及helper预占,不改active_operation_id/generation;生命周期新提交及claim检查未清理data归属,数据claim校验原范围;unknown保留互斥。终态且清理确认后释放。OperationService按K8s实际sidecar计入正式Pod资源、保留Docker预算;不做集群I/O。",
+      "acs": [
+        "AC-04",
+        "AC-05",
+        "AC-33",
+        "AC-34",
+        "AC-35",
+        "AC-36",
+        "AC-43",
+        "AC-48",
+        "AC-49",
+        "AC-50",
+        "AC-52"
+      ],
+      "depends": [
+        "T041",
+        "T080",
+        "T081"
+      ],
+      "pair": "T081",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T042",
+      "wave": 5,
+      "kind": "测试",
+      "title": "owner 数据会话业务协调:契约与失败用例",
+      "files": [
+        "src/backend/test/app_runtime/test_runtime_data_sessions.py"
+      ],
+      "logic": "在原权限/确认/审计后创建内部 kind=data;不改 serving代次/App态,数据/迁移/停用/部署互斥;事务先提交再唤醒,在线复用helper,停用冷启动也计入60秒,超时不等于cleanup;测试只定义输入/结果,不加入实现。",
+      "acs": [
+        "AC-15",
+        "AC-35",
+        "AC-36",
+        "AC-37",
+        "AC-43",
+        "AC-48",
+        "AC-49",
+        "AC-50"
+      ],
+      "depends": [
+        "T017",
+        "T041",
+        "T082"
+      ],
+      "pair": "T043",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T043",
+      "wave": 5,
+      "kind": "实现",
+      "title": "owner 数据会话业务协调",
+      "files": [
+        "src/backend/bisheng/app_runtime/domain/services/runtime_data_session_service.py",
+        "src/backend/bisheng/app_runtime/domain/services/app_data_service.py"
+      ],
+      "logic": "在原权限/确认/审计后创建内部 kind=data;不改 serving代次/App态,数据/迁移/停用/部署互斥;事务先提交再唤醒,在线复用helper,停用冷启动也计入60秒,超时不等于cleanup;通过配对测试后完成 L1 task-review。",
+      "acs": [
+        "AC-15",
+        "AC-35",
+        "AC-36",
+        "AC-37",
+        "AC-43",
+        "AC-48",
+        "AC-49",
+        "AC-50"
+      ],
+      "depends": [
+        "T017",
+        "T041",
+        "T042"
+      ],
+      "pair": "T042",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T044",
+      "wave": 5,
+      "kind": "测试",
+      "title": "K8s 在线及停用数据执行:契约与失败用例",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_data_executor.py"
+      ],
+      "logic": "在线同Pod访问;停用仅在旧UID stopped且空生产位置启动 purpose=data,保留PVC/停用态,不创建业务Service;数据忙/未知拒绝,stop确认后释放预占与互斥,manager不签owner票据;测试只定义输入/结果,不加入实现。",
+      "acs": [
+        "AC-11",
+        "AC-15",
+        "AC-33",
+        "AC-35",
+        "AC-36",
+        "AC-43",
+        "AC-48",
+        "AC-49",
+        "AC-50",
+        "AC-52"
+      ],
+      "depends": [
+        "T043",
+        "T033"
+      ],
+      "pair": "T045",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T045",
+      "wave": 5,
+      "kind": "实现",
+      "title": "K8s 在线及停用数据执行",
+      "files": [
+        "src/runtime-manager/runtime_manager/k8s_data_executor.py",
+        "src/runtime-manager/runtime_manager/api/platform_data.py"
+      ],
+      "logic": "在线同Pod访问;停用仅在旧UID stopped且空生产位置启动 purpose=data,保留PVC/停用态,不创建业务Service;数据忙/未知拒绝,stop确认后释放预占与互斥,manager不签owner票据;通过配对测试后完成 L1 task-review。",
+      "acs": [
+        "AC-11",
+        "AC-15",
+        "AC-33",
+        "AC-35",
+        "AC-36",
+        "AC-43",
+        "AC-48",
+        "AC-49",
+        "AC-50",
+        "AC-52"
+      ],
+      "depends": [
+        "T043",
+        "T033",
+        "T044"
+      ],
+      "pair": "T044",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T085",
+      "wave": 5,
+      "kind": "基础设施",
+      "title": "顺序维护资源 DDL",
+      "files": [
+        "src/backend/bisheng/core/database/alembic/versions/f059_resource_phases.py"
+      ],
+      "logic": "实际单head f059_merge_release_heads之后追加DDL,移除operation/kind/name唯一约束;保留资源ref主键及真实UID唯一约束。降级须运维先确认历史无重名,revision不删历史、不读写业务数据;MySQL/DM8条件DDL及单head静态验证。",
+      "acs": [
+        "AC-15",
+        "AC-35",
+        "AC-48",
+        "AC-49",
+        "AC-56"
+      ],
+      "depends": [
+        "T074"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T083",
+      "wave": 5,
+      "kind": "测试",
+      "title": "固定生产名称的分阶段资源合同",
+      "files": [
+        "src/backend/test/app_runtime/test_runtime_kubernetes_phases.py"
+      ],
+      "logic": "真实SQL验证同一操作backup/migrate/serve可顺序登记不同ref/UID,前一Pod停止并清理完成后才允许下一Pod;原ref幂等不改冻结模板,旧UID历史保留,未停止/未清理拒绝,不以租约到期放行。",
+      "acs": [
+        "AC-08",
+        "AC-15",
+        "AC-33",
+        "AC-35",
+        "AC-48",
+        "AC-49",
+        "AC-50"
+      ],
+      "depends": [
+        "T085",
+        "T045"
+      ],
+      "pair": "T084",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T084",
+      "wave": 5,
+      "kind": "实现",
+      "title": "顺序资源意图与历史保留",
+      "files": [
+        "src/backend/bisheng/app_runtime/domain/models/runtime_kubernetes.py",
+        "src/backend/bisheng/app_runtime/domain/repositories/runtime_kubernetes_repository.py"
+      ],
+      "logic": "按资源ref幂等登记;固定生产名的新ref须先确认旧Pod stopped及cleanup complete,保留所有UID历史,非生产重名拒绝。移除模型旧唯一intent约束,保留UID唯一;事务内检查,不在锁内调用集群API。",
+      "acs": [
+        "AC-08",
+        "AC-15",
+        "AC-33",
+        "AC-35",
+        "AC-48",
+        "AC-49",
+        "AC-50"
+      ],
+      "depends": [
+        "T085",
+        "T083"
+      ],
+      "pair": "T083",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T086",
+      "wave": 5,
+      "kind": "测试",
+      "title": "材料分块读取的定长范围合同",
+      "files": [
+        "src/backend/test/app_runtime/test_runtime_material_ranges.py",
+        "src/backend/test/app_runtime/test_runtime_material_transfers.py"
+      ],
+      "logic": "下载后段仅从对象存储对应offset读取length字节;验证关闭/释放连接、短读拒绝、撤权与原有分块摘要合同,不以小对象整读掩盖大对象二次复杂度。",
+      "acs": [
+        "AC-26",
+        "AC-28",
+        "AC-29",
+        "AC-46",
+        "AC-47",
+        "AC-51"
+      ],
+      "depends": [
+        "T025"
+      ],
+      "pair": "T087",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T087",
+      "wave": 5,
+      "kind": "实现",
+      "title": "对象存储 Range 流式下载接线",
+      "files": [
+        "src/backend/bisheng/app_runtime/domain/repositories/runtime_object_store.py",
+        "src/backend/bisheng/app_runtime/domain/services/runtime_material_transfer_service.py"
+      ],
+      "logic": "使用MinIO get_object offset/length和定长流,不从头跳过;backend继续独占存储凭据,连接finally释放,原授权/offset/完整性合同保持。",
+      "acs": [
+        "AC-26",
+        "AC-28",
+        "AC-29",
+        "AC-46",
+        "AC-47",
+        "AC-51"
+      ],
+      "depends": [
+        "T025",
+        "T086"
+      ],
+      "pair": "T086",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T046",
+      "wave": 5,
+      "kind": "测试",
+      "title": "K8s SQLite 备份迁移恢复:契约与失败用例",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_appdb.py"
+      ],
+      "logic": "停整个生产Pod后维护独占卷;复用appdb plan_digest/SQLite原子迁移journal,backup/integrity/fsync,平台内容与关联确认后才DDL;保护当前数据后恢复,失败保留maintenance和data_may_have_changed,不自动数据回滚;测试只定义输入/结果,不加入实现。",
+      "acs": [
+        "AC-33",
+        "AC-34",
+        "AC-35",
+        "AC-46",
+        "AC-49",
+        "AC-50",
+        "AC-51",
+        "AC-52"
+      ],
+      "depends": [
+        "T045",
+        "T029",
+        "T084",
+        "T087"
+      ],
+      "pair": "T047",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T047",
+      "wave": 5,
+      "kind": "实现",
+      "title": "K8s SQLite 备份迁移恢复",
+      "files": [
+        "src/runtime-manager/runtime_manager/k8s_appdb.py",
+        "src/runtime-manager/runtime_manager/k8s_data_helper.py"
+      ],
+      "logic": "停整个生产Pod后维护独占卷;复用appdb plan_digest/SQLite原子迁移journal,backup/integrity/fsync,平台内容与关联确认后才DDL;保护当前数据后恢复,失败保留maintenance和data_may_have_changed,不自动数据回滚;通过配对测试后完成 L1 task-review。",
+      "acs": [
+        "AC-33",
+        "AC-34",
+        "AC-35",
+        "AC-46",
+        "AC-49",
+        "AC-50",
+        "AC-51",
+        "AC-52"
+      ],
+      "depends": [
+        "T045",
+        "T029",
+        "T046"
+      ],
+      "pair": "T046",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T048",
+      "wave": 5,
+      "kind": "测试",
+      "title": "backend 数据动作签发及路由:契约与失败用例",
+      "files": [
+        "src/backend/test/app_runtime/test_runtime_data_grants.py"
+      ],
+      "logic": "原业务Service重新核验actor及当前data操作/UID后本地签发;不向manager提供通用签发API。data操作和查询结果授权原地失效,非owner、重放、错UID拒绝;管理身份不能自签;测试只定义输入/结果,不加入实现。",
+      "acs": [
+        "AC-15",
+        "AC-36",
+        "AC-37",
+        "AC-43",
+        "AC-48"
+      ],
+      "depends": [
+        "T043",
+        "T047"
+      ],
+      "pair": "T049",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T049",
+      "wave": 5,
+      "kind": "实现",
+      "title": "backend 数据动作签发及路由",
+      "files": [
+        "src/backend/bisheng/app_runtime/domain/services/runtime_data_grant_service.py",
+        "src/backend/bisheng/app_runtime/api/endpoints/runtime_kubernetes.py"
+      ],
+      "logic": "原业务Service重新核验actor及当前data操作/UID后本地签发;不向manager提供通用签发API。data操作和查询结果授权原地失效,非owner、重放、错UID拒绝;管理身份不能自签;通过配对测试后完成 L1 task-review。",
+      "acs": [
+        "AC-15",
+        "AC-36",
+        "AC-37",
+        "AC-43",
+        "AC-48"
+      ],
+      "depends": [
+        "T043",
+        "T047",
+        "T048"
+      ],
+      "pair": "T048",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T088",
+      "wave": 5,
+      "kind": "测试",
+      "title": "K8s 兼容源码的 profile 冻结合同",
+      "files": [
+        "src/backend/test/app_runtime/test_runtime_k8s_delivery_coordinator.py"
+      ],
+      "logic": "prepare真实协调方法在K8s source-image解析并冻结运行/preparer profile和离线材料;Docker旧源码构建不改,完整镜像不要求bundle profile,不从manager临时配置猜基础镜像。",
+      "acs": [
+        "AC-01",
+        "AC-24",
+        "AC-25",
+        "AC-27",
+        "AC-28",
+        "AC-42",
+        "AC-54"
+      ],
+      "depends": [
+        "T049"
+      ],
+      "pair": "T089",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T089",
+      "wave": 5,
+      "kind": "实现",
+      "title": "K8s 兼容源码 profile 协调接线",
+      "files": [
+        "src/backend/bisheng/app_runtime/domain/services/runtime_delivery_coordinator.py"
+      ],
+      "logic": "仅K8s source-image复用现有全局profile解析、冻结架构与材料归属;保持Docker和完整镜像原语义。环境注入/审批仍由原业务Owner负责。",
+      "acs": [
+        "AC-01",
+        "AC-24",
+        "AC-25",
+        "AC-27",
+        "AC-28",
+        "AC-42",
+        "AC-54"
+      ],
+      "depends": [
+        "T049",
+        "T088"
+      ],
+      "pair": "T088",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T090",
+      "wave": 5,
+      "kind": "测试",
+      "title": "数据恢复查询及导出流合同",
+      "files": [
+        "src/backend/test/app_runtime/test_runtime_data_recovery.py"
+      ],
+      "logic": "未知写结果只以新只读会话查询原request,不重做写操作;owner重检、原会话清理闸口与原参数摘要保持。HTTP分块导出落短缓存,验证长度/摘要/截止期并清理失败文件,Docker bytes兼容。",
+      "acs": [
+        "AC-15",
+        "AC-36",
+        "AC-37",
+        "AC-43",
+        "AC-48",
+        "AC-50"
+      ],
+      "depends": [
+        "T049"
+      ],
+      "pair": "T091",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T091",
+      "wave": 5,
+      "kind": "实现",
+      "title": "数据恢复协调与导出短缓存",
+      "files": [
+        "src/backend/bisheng/app_runtime/domain/services/runtime_data_session_service.py",
+        "src/backend/bisheng/app_runtime/domain/services/app_data_service.py"
+      ],
+      "logic": "原数据Service先验证owner;恢复原操作结果创建新result只读会话和新request_id,不重放写。K8s导出经有界分块落缓存供响应,错误/取消回收,审计按文件大小记录;不让backend访问SQLite。",
+      "acs": [
+        "AC-15",
+        "AC-36",
+        "AC-37",
+        "AC-43",
+        "AC-48",
+        "AC-50"
+      ],
+      "depends": [
+        "T049",
+        "T090"
+      ],
+      "pair": "T090",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T092",
+      "wave": 5,
+      "kind": "测试",
+      "title": "结果查询与文件响应业务入口合同",
+      "files": [
+        "src/backend/test/app_runtime/test_runtime_data_recovery_api.py"
+      ],
+      "logic": "登录owner通过data/operations/{operation_id}/result读取原结果,非owner拒绝;CSV FileResponse保留文件名/类型/鉴权错误信封,下载后缓存回收;原Docker字节响应不变。",
+      "acs": [
+        "AC-15",
+        "AC-36",
+        "AC-37",
+        "AC-43",
+        "AC-48",
+        "AC-50"
+      ],
+      "depends": [
+        "T091"
+      ],
+      "pair": "T093",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T093",
+      "wave": 5,
+      "kind": "实现",
+      "title": "数据恢复和流式导出 API 接线",
+      "files": [
+        "src/backend/bisheng/app_runtime/api/endpoints/apps.py"
+      ],
+      "logic": "只委托AppDataService,不在Endpoint执行账本/存储逻辑;公开业务读取结果用统一信封,CSV短缓存FileResponse带BackgroundTask回收;保持原bytes路径。",
+      "acs": [
+        "AC-15",
+        "AC-36",
+        "AC-37",
+        "AC-43",
+        "AC-48",
+        "AC-50"
+      ],
+      "depends": [
+        "T091",
+        "T092"
+      ],
+      "pair": "T092",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T050",
+      "wave": 6,
+      "kind": "测试",
+      "title": "三种交付模式的 K8s 准备链:契约与失败用例",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_delivery.py"
+      ],
+      "logic": "Python3.11/Node20/static bundle预检、完整镜像直启、显式source-image准备;依赖材料命中与失效、新增依赖缺包/补包、安全解包;任务阶段独立身份/卷/限额/并发,启动失败不产生审批;测试只定义输入/结果,不加入实现。",
+      "acs": [
+        "AC-08",
+        "AC-24",
+        "AC-25",
+        "AC-26",
+        "AC-27",
+        "AC-28",
+        "AC-29",
+        "AC-42",
+        "AC-43",
+        "AC-44",
+        "AC-45",
+        "AC-46",
+        "AC-54"
+      ],
+      "depends": [
+        "T033",
+        "T047",
+        "T089"
+      ],
+      "pair": "T051",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T051",
+      "wave": 6,
+      "kind": "实现",
+      "title": "三种交付模式的 K8s 准备链",
+      "files": [
+        "src/runtime-manager/runtime_manager/k8s_prepare.py",
+        "src/runtime-manager/runtime_manager/executors/kubernetes.py"
+      ],
+      "logic": "Python3.11/Node20/static bundle预检、完整镜像直启、显式source-image准备;依赖材料命中与失效、新增依赖缺包/补包、安全解包;任务阶段独立身份/卷/限额/并发,启动失败不产生审批;通过配对测试后完成 L1 task-review。",
+      "acs": [
+        "AC-08",
+        "AC-24",
+        "AC-25",
+        "AC-26",
+        "AC-27",
+        "AC-28",
+        "AC-29",
+        "AC-42",
+        "AC-43",
+        "AC-44",
+        "AC-45",
+        "AC-46",
+        "AC-54"
+      ],
+      "depends": [
+        "T033",
+        "T047",
+        "T050"
+      ],
+      "pair": "T050",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T052",
+      "wave": 6,
+      "kind": "测试",
+      "title": "无守护进程的受限 OCI 组装:契约与失败用例",
+      "files": [
+        "src/runtime-manager/tests/test_oci_assembler.py"
+      ],
+      "logic": "固定布局追加冻结基础镜像并设置既有启动语义;不执行RUN/Dockerfile,拒绝逃逸、设备/setuid/whiteout/外部硬链;仓库身份仅受信组装器使用,缺推送仓库仅阻断source-image;测试只定义输入/结果,不加入实现。",
+      "acs": [
+        "AC-15",
+        "AC-16",
+        "AC-25",
+        "AC-27",
+        "AC-28",
+        "AC-42",
+        "AC-54"
+      ],
+      "depends": [
+        "T051"
+      ],
+      "pair": "T053",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T053",
+      "wave": 6,
+      "kind": "实现",
+      "title": "无守护进程的受限 OCI 组装",
+      "files": [
+        "src/runtime-manager/runtime_manager/oci_assembler.py",
+        "src/runtime-manager/Dockerfile.assembler"
+      ],
+      "logic": "固定布局追加冻结基础镜像并设置既有启动语义;不执行RUN/Dockerfile,拒绝逃逸、设备/setuid/whiteout/外部硬链;仓库身份仅受信组装器使用,缺推送仓库仅阻断source-image;通过配对测试后完成 L1 task-review。",
+      "acs": [
+        "AC-15",
+        "AC-16",
+        "AC-25",
+        "AC-27",
+        "AC-28",
+        "AC-42",
+        "AC-54"
+      ],
+      "depends": [
+        "T051",
+        "T052"
+      ],
+      "pair": "T052",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T096",
+      "wave": 6,
+      "kind": "测试",
+      "title": "owner 删除与安全清理合同",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_appdb.py",
+        "src/backend/test/app_runtime/test_runtime_kubernetes_purge.py"
+      ],
+      "logic": "先验证purge缺失红阶段;错误owner意图/跨应用卷拒绝,停止和结果事实先于PVC删除,不跟随链接,重启不重复创建。",
+      "acs": [
+        "AC-35",
+        "AC-48",
+        "AC-49",
+        "AC-50",
+        "AC-52"
+      ],
+      "depends": [
+        "T053"
+      ],
+      "pair": "T097",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T097",
+      "wave": 6,
+      "kind": "实现",
+      "title": "backend purge 范围和删除授权",
+      "files": [
+        "src/backend/bisheng/app_runtime/domain/services/runtime_kubernetes_service.py",
+        "src/backend/bisheng/app_runtime/domain/repositories/runtime_kubernetes_repository.py"
+      ],
+      "logic": "只接受显式destroy+purge_volume的固定生产位置;由执行事实证明purge完成及全部writer停止清理后,授予原PVC条件删除。",
+      "acs": [
+        "AC-35",
+        "AC-48",
+        "AC-49",
+        "AC-50",
+        "AC-52"
+      ],
+      "depends": [
+        "T053",
+        "T096"
+      ],
+      "pair": "T096",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T098",
+      "wave": 6,
+      "kind": "实现",
+      "title": "受信 helper 清理与持久重入",
+      "files": [
+        "src/runtime-manager/runtime_manager/k8s_appdb.py",
+        "src/runtime-manager/runtime_manager/k8s_data_helper.py"
+      ],
+      "logic": "不持应用/材料身份,擦除business及受保护备份,不跟随链接;维护journal和SQL事实避免已完成purge重复执行。",
+      "acs": [
+        "AC-35",
+        "AC-48",
+        "AC-49",
+        "AC-50",
+        "AC-52"
+      ],
+      "depends": [
+        "T096",
+        "T097"
+      ],
+      "pair": "T096",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T099",
+      "wave": 6,
+      "kind": "实现",
+      "title": "生产 purge 模板及状态机接线",
+      "files": [
+        "src/runtime-manager/runtime_manager/k8s_templates.py",
+        "src/runtime-manager/runtime_manager/operations.py"
+      ],
+      "logic": "purge共用固定生产位置;恢复destroy不停止自己的当前清理Pod;生产purpose集合同步;取消重复egress注入。",
+      "acs": [
+        "AC-04",
+        "AC-35",
+        "AC-48",
+        "AC-49",
+        "AC-50",
+        "AC-52"
+      ],
+      "depends": [
+        "T098"
+      ],
+      "pair": "T096",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T100",
+      "wave": 6,
+      "kind": "实现",
+      "title": "执行器条件回收生产卷",
+      "files": [
+        "src/runtime-manager/runtime_manager/executors/kubernetes.py",
+        "src/runtime-manager/runtime_manager/k8s_data_executor.py"
+      ],
+      "logic": "缺少应用profile的destroy只使用固定helper;PVC删除用UID/RV,确认缺失才提交cleanup;未知拒绝,不授予平台存储凭据。",
+      "acs": [
+        "AC-04",
+        "AC-35",
+        "AC-48",
+        "AC-49",
+        "AC-50",
+        "AC-52"
+      ],
+      "depends": [
+        "T099"
+      ],
+      "pair": "T096",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T101",
+      "wave": 6,
+      "kind": "测试",
+      "title": "受限 canary 启动合同",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_canary.py"
+      ],
+      "logic": "安装计划仅preflight名称/固定镜像命令/短期范围;拒绝应用源码、生产PVC、Secret、无限授权和未绑定UID更新。",
+      "acs": [
+        "AC-13",
+        "AC-14",
+        "AC-16",
+        "AC-21",
+        "AC-23",
+        "AC-49"
+      ],
+      "depends": [
+        "T013",
+        "T053"
+      ],
+      "pair": "T102",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T102",
+      "wave": 6,
+      "kind": "实现",
+      "title": "只读 canary 准入和网络探测",
+      "files": [
+        "src/runtime-manager/runtime_manager/k8s_admission.py",
+        "src/runtime-manager/runtime_manager/k8s_capabilities.py"
+      ],
+      "logic": "只读installer CM提供至多一小时预检计划,其它资源仍backend;可信idle监听、TCP/UDP DNS真探测;报告24h由外部installer每6h刷新。",
+      "acs": [
+        "AC-13",
+        "AC-14",
+        "AC-16",
+        "AC-21",
+        "AC-23",
+        "AC-49"
+      ],
+      "depends": [
+        "T101"
+      ],
+      "pair": "T101",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T054",
+      "wave": 6,
+      "kind": "测试",
+      "title": "隔离和安装前置的实际能力探测:契约与失败用例",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_capabilities.py"
+      ],
+      "logic": "分项验证namespace预算、PSA/准入、status认证、存储锁/fsync、精确CRI镜像、网络允许/拒绝、材料/能力接口;必需隔离失败/未知拒绝,沙箱仅回退满足基线档;无SC但预置卷可用;测试只定义输入/结果,不加入实现。",
+      "acs": [
+        "AC-12",
+        "AC-13",
+        "AC-16",
+        "AC-18",
+        "AC-19",
+        "AC-20",
+        "AC-21",
+        "AC-22",
+        "AC-23",
+        "AC-30",
+        "AC-31",
+        "AC-32",
+        "AC-34",
+        "AC-42",
+        "AC-54"
+      ],
+      "depends": [
+        "T013",
+        "T039",
+        "T053"
+      ],
+      "pair": "T055",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T055",
+      "wave": 6,
+      "kind": "实现",
+      "title": "隔离和安装前置的实际能力探测",
+      "files": [
+        "src/runtime-manager/runtime_manager/k8s_capabilities.py",
+        "src/runtime-manager/runtime_manager/k8s_templates.py"
+      ],
+      "logic": "分项验证namespace预算、PSA/准入、status认证、存储锁/fsync、精确CRI镜像、网络允许/拒绝、材料/能力接口;必需隔离失败/未知拒绝,沙箱仅回退满足基线档;无SC但预置卷可用;通过配对测试后完成 L1 task-review。",
+      "acs": [
+        "AC-12",
+        "AC-13",
+        "AC-16",
+        "AC-18",
+        "AC-19",
+        "AC-20",
+        "AC-21",
+        "AC-22",
+        "AC-23",
+        "AC-30",
+        "AC-31",
+        "AC-32",
+        "AC-34",
+        "AC-42",
+        "AC-54"
+      ],
+      "depends": [
+        "T013",
+        "T039",
+        "T053",
+        "T054"
+      ],
+      "pair": "T054",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T094",
+      "wave": 6,
+      "kind": "测试",
+      "title": "活跃材料代理候选查询合同",
+      "files": [
+        "src/backend/test/app_runtime/test_runtime_material_policy_candidates.py"
+      ],
+      "logic": "真实SQL按node过滤已绑定、未停止/未清理、未撤销且未到期材料授权;停止/历史/其它节点不进入每秒投影,保留原历史记录而不全表循环逐条重验。",
+      "acs": [
+        "AC-15",
+        "AC-23",
+        "AC-46",
+        "AC-47",
+        "AC-48",
+        "AC-53"
+      ],
+      "depends": [
+        "T055"
+      ],
+      "pair": "T095",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T095",
+      "wave": 6,
+      "kind": "实现",
+      "title": "活跃材料候选 Repository 查询",
+      "files": [
+        "src/backend/bisheng/app_runtime/domain/repositories/runtime_delivery_repository.py"
+      ],
+      "logic": "Repository联查材料与资源记录提供material_policy_candidates(node_id,now),不在Service写SQL;跨租户仅供已验证egress角色的node范围投影,最多10000活动候选、超限拒绝,不删除历史。",
+      "acs": [
+        "AC-15",
+        "AC-23",
+        "AC-46",
+        "AC-47",
+        "AC-48",
+        "AC-53"
+      ],
+      "depends": [
+        "T055",
+        "T094"
+      ],
+      "pair": "T094",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T056",
+      "wave": 6,
+      "kind": "测试",
+      "title": "材料用途的出站投影:契约与失败用例",
+      "files": [
+        "src/backend/test/app_runtime/test_runtime_material_egress.py"
+      ],
+      "logic": "material代理身份由当前材料授权投影,独立于完成的prepare;只放行后台材料TLS端点和范围,撤销5秒上界,业务凭据不冒用;入口路由继续backendSQL投影不依赖manager;测试只定义输入/结果,不加入实现。",
+      "acs": [
+        "AC-07",
+        "AC-12",
+        "AC-15",
+        "AC-21",
+        "AC-23",
+        "AC-46",
+        "AC-47",
+        "AC-53"
+      ],
+      "depends": [
+        "T023",
+        "T055",
+        "T095"
+      ],
+      "pair": "T057",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T057",
+      "wave": 6,
+      "kind": "实现",
+      "title": "材料用途的出站投影",
+      "files": [
+        "src/backend/bisheng/app_runtime/domain/services/runtime_material_policy_service.py",
+        "src/backend/bisheng/app_runtime/domain/services/runtime_operation_service.py"
+      ],
+      "logic": "material代理身份由当前材料授权投影,独立于完成的prepare;只放行后台材料TLS端点和范围,撤销5秒上界,业务凭据不冒用;入口路由继续backendSQL投影不依赖manager;通过配对测试后完成 L1 task-review。",
+      "acs": [
+        "AC-07",
+        "AC-12",
+        "AC-15",
+        "AC-21",
+        "AC-23",
+        "AC-46",
+        "AC-47",
+        "AC-53"
+      ],
+      "depends": [
+        "T023",
+        "T055",
+        "T056"
+      ],
+      "pair": "T056",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T058",
+      "wave": 7,
+      "kind": "基础设施",
+      "title": "Chart 入口与 values schema",
+      "files": [
+        "deploy/kubernetes/app-runtime/Chart.yaml",
+        "deploy/kubernetes/app-runtime/values.yaml"
+      ],
+      "logic": "按design固定platform/services/execution/storage/network/admission/images/offline分组,所有镜像精确引用、禁默认公网兜底与自动平台核心部署。",
+      "acs": [
+        "AC-39",
+        "AC-42",
+        "AC-54"
+      ],
+      "depends": [
+        "T057"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T059",
+      "wave": 7,
+      "kind": "基础设施",
+      "title": "部署参数及共享入口校验",
+      "files": [
+        "deploy/kubernetes/app-runtime/values.schema.json",
+        "deploy/kubernetes/app-runtime/templates/services.yaml"
+      ],
+      "logic": "校验必需字段与形态,私网共享entry/control由安装者供应,禁止按应用外放端口;控制端点ACL及HMAC,外部backend可达性独立验证。",
+      "acs": [
+        "AC-12",
+        "AC-31",
+        "AC-39"
+      ],
+      "depends": [
+        "T058"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T060",
+      "wave": 7,
+      "kind": "基础设施",
+      "title": "RBAC 与准入安装模板",
+      "files": [
+        "deploy/kubernetes/app-runtime/templates/rbac.yaml",
+        "deploy/kubernetes/app-runtime/templates/admission.yaml"
+      ],
+      "logic": "namespace manager最小权限,系统服务不可被manager改,验证器无集群token;status/template规则分别Fail/None,PSA restricted1.28;卸载不删除保护namespace/PV。",
+      "acs": [
+        "AC-03",
+        "AC-13",
+        "AC-14",
+        "AC-15",
+        "AC-16",
+        "AC-23",
+        "AC-39",
+        "AC-41",
+        "AC-49"
+      ],
+      "depends": [
+        "T059",
+        "T013"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T061",
+      "wave": 7,
+      "kind": "基础设施",
+      "title": "网络和保留卷安装模板",
+      "files": [
+        "deploy/kubernetes/app-runtime/templates/network.yaml",
+        "deploy/kubernetes/app-runtime/templates/storage.yaml"
+      ],
+      "logic": "默认deny,允许entry/manager受控端口、只经egress出站、无业务DNS直连;localPV节点绑定或验证CSI,独占卷不带Pod/Helm级联删除owner,预置scratch池和限制。",
+      "acs": [
+        "AC-12",
+        "AC-21",
+        "AC-22",
+        "AC-23",
+        "AC-33",
+        "AC-34",
+        "AC-35",
+        "AC-39",
+        "AC-41",
+        "AC-42"
+      ],
+      "depends": [
+        "T060"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T104",
+      "wave": 7,
+      "kind": "测试",
+      "title": "镜像发行和清单合同",
+      "files": [
+        "src/runtime-manager/tests/test_image_release.py"
+      ],
+      "logic": "本地构建默认不推送,双架构同版本digest合并及不可覆盖index;helper/assembler工具和离线清单,mock不记作原生验收。",
+      "acs": [
+        "AC-24",
+        "AC-27",
+        "AC-39",
+        "AC-42",
+        "AC-54",
+        "AC-56"
+      ],
+      "depends": [
+        "T061"
+      ],
+      "pair": "T063",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T062",
+      "wave": 7,
+      "kind": "基础设施",
+      "title": "运行服务与 helper 镜像",
+      "files": [
+        "deploy/kubernetes/app-runtime/templates/workloads.yaml",
+        "src/runtime-manager/Dockerfile.helper"
+      ],
+      "logic": "独立manager/app-proxy/egress/admission进程,只有manager投射限定token,helper固定受信命令非root/只读;控制面重启不连带删除应用Pod或改变冻结profile。",
+      "acs": [
+        "AC-03",
+        "AC-15",
+        "AC-16",
+        "AC-39",
+        "AC-40",
+        "AC-42",
+        "AC-54"
+      ],
+      "depends": [
+        "T061"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T103",
+      "wave": 7,
+      "kind": "基础设施",
+      "title": "原生镜像构建脚本扩展",
+      "files": [
+        "scripts/build_runtime_images.sh"
+      ],
+      "logic": "扩展现有脚本,同版本helper与assembler,默认local-only,显式发行开关才push;原生smoke/许可与冻结digest清单。",
+      "acs": [
+        "AC-24",
+        "AC-27",
+        "AC-39",
+        "AC-42",
+        "AC-54",
+        "AC-56"
+      ],
+      "depends": [
+        "T062",
+        "T104"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T063",
+      "wave": 7,
+      "kind": "基础设施",
+      "title": "同版本双架构 CI/CD 发行",
+      "files": [
+        ".github/workflows/runtime-images.yml",
+        "src/runtime-manager/runtime_manager/image_catalog.py"
+      ],
+      "logic": "扩展已有tag v*/手工入口,发布helper/admission/assembler及runtime镜像清单与licenses;amd64/arm64原生构建,compose已发行镜像引用合同不退化;不改用户tag/自动推送。",
+      "acs": [
+        "AC-24",
+        "AC-27",
+        "AC-39",
+        "AC-42",
+        "AC-54",
+        "AC-56"
+      ],
+      "depends": [
+        "T062",
+        "T103",
+        "T104"
+      ],
+      "pair": "T104",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T064",
+      "wave": 7,
+      "kind": "测试",
+      "title": "部署/离线清单验证:契约与失败用例",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_installation.py"
+      ],
+      "logic": "可重复read-only预检及专用namespace允许/拒绝探测;离线镜像/依赖/平台接口分别核对,CRI按冻结引用真启动;说明本地卷限制、升级兼容、原卷保留和安全卸载,不输出kubeconfig/token;测试只定义输入/结果,不加入实现。",
+      "acs": [
+        "AC-13",
+        "AC-14",
+        "AC-19",
+        "AC-20",
+        "AC-21",
+        "AC-22",
+        "AC-23",
+        "AC-30",
+        "AC-31",
+        "AC-32",
+        "AC-34",
+        "AC-35",
+        "AC-39",
+        "AC-40",
+        "AC-41",
+        "AC-42",
+        "AC-54",
+        "AC-55"
+      ],
+      "depends": [
+        "T063",
+        "T055"
+      ],
+      "pair": "T065",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T065",
+      "wave": 7,
+      "kind": "实现",
+      "title": "部署/离线清单验证",
+      "files": [
+        "scripts/verify_k8s_runtime.py",
+        "deploy/kubernetes/app-runtime/README.md"
+      ],
+      "logic": "可重复read-only预检及专用namespace允许/拒绝探测;离线镜像/依赖/平台接口分别核对,CRI按冻结引用真启动;说明本地卷限制、升级兼容、原卷保留和安全卸载,不输出kubeconfig/token;通过配对测试后完成 L1 task-review。",
+      "acs": [
+        "AC-13",
+        "AC-14",
+        "AC-19",
+        "AC-20",
+        "AC-21",
+        "AC-22",
+        "AC-23",
+        "AC-30",
+        "AC-31",
+        "AC-32",
+        "AC-34",
+        "AC-35",
+        "AC-39",
+        "AC-40",
+        "AC-41",
+        "AC-42",
+        "AC-54",
+        "AC-55"
+      ],
+      "depends": [
+        "T063",
+        "T055",
+        "T064"
+      ],
+      "pair": "T064",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T105",
+      "wave": 8,
+      "kind": "测试",
+      "title": "E2E harness 边界合同",
+      "files": [
+        "src/backend/test/app_runtime/test_k8s_e2e_contract.py"
+      ],
+      "logic": "测试配置、严格tenant/owner/prefix cleanup和55AC映射;只验证harness,不冒充真实平台通过。",
+      "acs": [
+        "AC-36",
+        "AC-37",
+        "AC-55"
+      ],
+      "depends": [
+        "T049",
+        "T005"
+      ],
+      "pair": "T106",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T106",
+      "wave": 8,
+      "kind": "实现",
+      "title": "真实 API 业务验收共享行程",
+      "files": [
+        "src/backend/test/e2e/helpers/vibe_runtime.py",
+        "src/backend/test/e2e/test_e2e_vibe_k8s_runtime.py"
+      ],
+      "logic": "复用既有认证helper,真实发布/审批/数据/入口和最终GET,精确MMMEE,独立测试租户/自然owner,不用manager直接API替代。",
+      "acs": [
+        "AC-36",
+        "AC-37",
+        "AC-55"
+      ],
+      "depends": [
+        "T105"
+      ],
+      "pair": "T105",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T066",
+      "wave": 8,
+      "kind": "测试",
+      "title": "真实平台 K8s E2E 执行器",
+      "files": [
+        "scripts/e2e_k8s_runtime.py",
+        "features/v3.0.0/059-k8s-runtime-backend/e2e-checklist.md"
+      ],
+      "logic": "按e2e-test skill复用平台认证/CLI/审批/SDK/数据业务入口,生成55条AC用例映射、危险前置和cleanup;不是只调manager API,不把mock记为真实成功;凭据从安全环境/主机既有配置读取。",
+      "acs": [
+        "AC-01",
+        "AC-02",
+        "AC-03",
+        "AC-04",
+        "AC-05",
+        "AC-06",
+        "AC-07",
+        "AC-08",
+        "AC-09",
+        "AC-10",
+        "AC-11",
+        "AC-12",
+        "AC-13",
+        "AC-14",
+        "AC-15",
+        "AC-16",
+        "AC-17",
+        "AC-18",
+        "AC-19",
+        "AC-20",
+        "AC-21",
+        "AC-22",
+        "AC-23",
+        "AC-24",
+        "AC-25",
+        "AC-26",
+        "AC-27",
+        "AC-28",
+        "AC-29",
+        "AC-30",
+        "AC-31",
+        "AC-32",
+        "AC-33",
+        "AC-34",
+        "AC-35",
+        "AC-36",
+        "AC-37",
+        "AC-39",
+        "AC-40",
+        "AC-41",
+        "AC-42",
+        "AC-43",
+        "AC-44",
+        "AC-45",
+        "AC-46",
+        "AC-47",
+        "AC-48",
+        "AC-49",
+        "AC-50",
+        "AC-51",
+        "AC-52",
+        "AC-53",
+        "AC-54",
+        "AC-55",
+        "AC-56"
+      ],
+      "depends": [
+        "T065",
+        "T049",
+        "T005"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T107",
+      "wave": 8,
+      "kind": "测试",
+      "title": "继承发布线的权限/egress测试适配",
+      "files": [
+        "src/backend/test/app_runtime/test_app_permission_registration.py",
+        "src/backend/test/app_runtime/test_runtime_lifecycle_v2.py"
+      ],
+      "logic": "候选count stub与已交付linsight注册保持当前Owner;policy fixture显式egress角色,不放宽生产身份。",
+      "acs": [
+        "AC-03",
+        "AC-36",
+        "AC-53",
+        "AC-56"
+      ],
+      "depends": [
+        "T066"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T108",
+      "wave": 8,
+      "kind": "测试",
+      "title": "资源历史约束静态合同同步",
+      "files": [
+        "src/backend/test/app_runtime/test_runtime_model_contracts.py"
+      ],
+      "logic": "明确资源历史按实际UID唯一;已移除operation/kind/name约束不得恢复,保留固定位置多phase历史;DM8静态边界。",
+      "acs": [
+        "AC-15",
+        "AC-48",
+        "AC-56"
+      ],
+      "depends": [
+        "T107"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T109",
+      "wave": 8,
+      "kind": "测试",
+      "title": "删除维护轻量容量预占合同",
+      "files": [
+        "src/backend/test/app_runtime/test_runtime_purge_capacity.py"
+      ],
+      "logic": "删除以固定helper预算预占,不因原应用档位误拒绝;额度不足与成功配对。",
+      "acs": [
+        "AC-11",
+        "AC-18",
+        "AC-48"
+      ],
+      "depends": [
+        "T097"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T110",
+      "wave": 8,
+      "kind": "实现",
+      "title": "删除维护轻量容量预占",
+      "files": [
+        "src/backend/bisheng/app_runtime/domain/services/runtime_operation_service.py"
+      ],
+      "logic": "保持原子节点锁,purge只为受限helper预占;不免除容量控制。",
+      "acs": [
+        "AC-11",
+        "AC-18",
+        "AC-48"
+      ],
+      "depends": [
+        "T109"
+      ],
+      "pair": "T109",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T111",
+      "wave": 8,
+      "kind": "测试",
+      "title": "Calico 元数据来源及收尾合同",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_admission.py"
+      ],
+      "logic": "可信CNI可维护指定注解,manager仅原样保留同UID旧值;拒绝伪造、增改删、status和spec越权。",
+      "acs": [
+        "AC-03",
+        "AC-14",
+        "AC-48",
+        "AC-52"
+      ],
+      "depends": [
+        "T011"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T112",
+      "wave": 8,
+      "kind": "实现",
+      "title": "Calico 元数据来源隔离",
+      "files": [
+        "src/runtime-manager/runtime_manager/k8s_admission.py"
+      ],
+      "logic": "精确保留已认证CNI字段以允许fenced finalizer收尾,不放行通用注解前缀。",
+      "acs": [
+        "AC-03",
+        "AC-14",
+        "AC-48",
+        "AC-52"
+      ],
+      "depends": [
+        "T111"
+      ],
+      "pair": "T111",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T113",
+      "wave": 8,
+      "kind": "测试",
+      "title": "scratch 写入前卷校验合同",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_helper.py",
+        "src/runtime-manager/tests/test_k8s_templates.py"
+      ],
+      "logic": "prepare和assemble在任何写入/材料投射前执行只读guard,校验真实文件系统/容量/安装标识,负例拒绝。",
+      "acs": [
+        "AC-33",
+        "AC-34",
+        "AC-42",
+        "AC-44"
+      ],
+      "depends": [
+        "T009"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T114",
+      "wave": 8,
+      "kind": "实现",
+      "title": "scratch 写入前卷校验",
+      "files": [
+        "src/runtime-manager/runtime_manager/k8s_helper.py",
+        "src/runtime-manager/runtime_manager/k8s_templates.py"
+      ],
+      "logic": "可信guard固定命令、无业务身份,缺挂载/错误marker/无界容量拒绝。",
+      "acs": [
+        "AC-33",
+        "AC-34",
+        "AC-42",
+        "AC-44"
+      ],
+      "depends": [
+        "T113"
+      ],
+      "pair": "T113",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T115",
+      "wave": 8,
+      "kind": "实现",
+      "title": "scratch 预算与安装标识接线",
+      "files": [
+        "src/runtime-manager/tests/test_kubernetes_executor.py",
+        "src/runtime-manager/runtime_manager/executors/kubernetes.py"
+      ],
+      "logic": "逐purpose区分production与scratch预算,preview/probe不碰生产卷;测试先复现预算丢失。",
+      "acs": [
+        "AC-33",
+        "AC-34",
+        "AC-42",
+        "AC-44"
+      ],
+      "depends": [
+        "T114",
+        "T100"
+      ],
+      "pair": "T032",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T116",
+      "wave": 8,
+      "kind": "测试",
+      "title": "static launcher 兼容合同",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_helper.py",
+        "src/runtime-manager/tests/test_k8s_templates.py"
+      ],
+      "logic": "标准bundle的冻结nginx argv与PORT经受信entrypoint消费,完整image保持自身入口。",
+      "acs": [
+        "AC-04",
+        "AC-24",
+        "AC-44",
+        "AC-56"
+      ],
+      "depends": [
+        "T008"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T117",
+      "wave": 8,
+      "kind": "实现",
+      "title": "static launcher 保留",
+      "files": [
+        "src/runtime-manager/runtime_manager/k8s_templates.py"
+      ],
+      "logic": "标准static bundle使用固定launcher生成/tmp配置,避免K8s command绕过镜像ENTRYPOINT。",
+      "acs": [
+        "AC-04",
+        "AC-24",
+        "AC-44",
+        "AC-56"
+      ],
+      "depends": [
+        "T116"
+      ],
+      "pair": "T116",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T118",
+      "wave": 8,
+      "kind": "实现",
+      "title": "Node launcher 兼容收尾",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_templates.py",
+        "src/runtime-manager/runtime_manager/k8s_templates.py"
+      ],
+      "logic": "先测试node/npm/脚本名/--eval,再保留绝对路径Node launcher;完整image不包装。",
+      "acs": [
+        "AC-04",
+        "AC-24",
+        "AC-44",
+        "AC-56"
+      ],
+      "depends": [
+        "T117"
+      ],
+      "pair": "T008",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T119",
+      "wave": 8,
+      "kind": "实现",
+      "title": "探测刷新后的 sandbox 冻结",
+      "files": [
+        "src/runtime-manager/tests/test_kubernetes_executor.py",
+        "src/runtime-manager/runtime_manager/executors/kubernetes.py"
+      ],
+      "logic": "同resource_ref重试按原Pod记录冻结RuntimeClass和deadline;先核对完整操作作用域,保留intent模板比对。",
+      "acs": [
+        "AC-07",
+        "AC-19",
+        "AC-20",
+        "AC-40",
+        "AC-48"
+      ],
+      "depends": [
+        "T100"
+      ],
+      "pair": "T032",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T120",
+      "wave": 8,
+      "kind": "实现",
+      "title": "Chart 安装标识传递",
+      "files": [
+        "deploy/kubernetes/app-runtime/templates/workloads.yaml",
+        "src/runtime-manager/tests/test_k8s_installation.py"
+      ],
+      "logic": "descriptor包含installation_id,与静态PV marker一致;先测试明确缺字段。",
+      "acs": [
+        "AC-34",
+        "AC-39",
+        "AC-42"
+      ],
+      "depends": [
+        "T064"
+      ],
+      "pair": "T064",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T121",
+      "wave": 8,
+      "kind": "测试",
+      "title": "helper 可信解析工具链合同",
+      "files": [
+        "src/runtime-manager/tests/test_image_release.py"
+      ],
+      "logic": "验证prepare.py、Node/npm、本架构库、同版本wheelhouse、构建顺序与禁网真实resolve,不能只检查import。",
+      "acs": [
+        "AC-24",
+        "AC-42",
+        "AC-44",
+        "AC-45",
+        "AC-54"
+      ],
+      "depends": [
+        "T104"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T122",
+      "wave": 8,
+      "kind": "基础设施",
+      "title": "helper 可信解析工具链",
+      "files": [
+        "src/runtime-manager/Dockerfile.helper"
+      ],
+      "logic": "同版本manager/Node/Python preparer为唯一工具来源;离线构建自身wheel,复制SDK轮子闭包与许可证,不复制宿主/glibc。",
+      "acs": [
+        "AC-24",
+        "AC-42",
+        "AC-44",
+        "AC-45",
+        "AC-54"
+      ],
+      "depends": [
+        "T121"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T123",
+      "wave": 8,
+      "kind": "基础设施",
+      "title": "镜像实际离线解析烟测",
+      "files": [
+        "scripts/build_runtime_images.sh",
+        "src/runtime-manager/tests/test_image_release.py"
+      ],
+      "logic": "原生image内禁网执行requirements/uv.lock/Node/static解析,ignore-scripts不得运行应用hook;任一失败禁止推送。",
+      "acs": [
+        "AC-24",
+        "AC-42",
+        "AC-44",
+        "AC-45",
+        "AC-54"
+      ],
+      "depends": [
+        "T122",
+        "T103"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T124",
+      "wave": 8,
+      "kind": "测试",
+      "title": "隔离平台 bootstrap 合同",
+      "files": [
+        "scripts/fixtures/f059/test_bootstrap.py"
+      ],
+      "logic": "覆盖真实API形状、env私密权限/混合Docker配置/作用域、人工审批、服务账号及本地HTTP/SQLite/SDK/WS。",
+      "acs": [
+        "AC-36",
+        "AC-37",
+        "AC-46",
+        "AC-55"
+      ],
+      "depends": [
+        "T105"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T125",
+      "wave": 8,
+      "kind": "实现",
+      "title": "隔离平台 API bootstrap",
+      "files": [
+        "scripts/bootstrap_f059_acceptance.py"
+      ],
+      "logic": "仅专用全新测试实例;真实部门挂租户、自然人/人工审批、owner mode-S、固定自定义profile;不直接SQL,不泄露凭据。",
+      "acs": [
+        "AC-36",
+        "AC-37",
+        "AC-46",
+        "AC-55"
+      ],
+      "depends": [
+        "T124"
+      ],
+      "pair": "T124",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T126",
+      "wave": 8,
+      "kind": "实现",
+      "title": "真实应用协议夹具",
+      "files": [
+        "scripts/fixtures/f059/python_demo.py",
+        "scripts/fixtures/f059/node_demo.js"
+      ],
+      "logic": "真实HTTP/SQLite/WS和SDK附件往返;模型/检索未配置明确不可测,禁止返回固定成功。",
+      "acs": [
+        "AC-04",
+        "AC-11",
+        "AC-36",
+        "AC-46",
+        "AC-55"
+      ],
+      "depends": [
+        "T125"
+      ],
+      "pair": "T124",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T127",
+      "wave": 8,
+      "kind": "基础设施",
+      "title": "完整镜像及离线材料夹具",
+      "files": [
+        "scripts/fixtures/f059/Dockerfile.demo",
+        "scripts/fixtures/f059/offline-manifest.example.json"
+      ],
+      "logic": "完整应用镜像实际包含代码;物料模板只说明格式,不伪造digest。",
+      "acs": [
+        "AC-24",
+        "AC-27",
+        "AC-42",
+        "AC-45"
+      ],
+      "depends": [
+        "T125"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T128",
+      "wave": 8,
+      "kind": "基础设施",
+      "title": "平台验收夹具说明",
+      "files": [
+        "scripts/fixtures/f059/README.md"
+      ],
+      "logic": "提供准备/真实API/阶段验收/安全清理指令,区分本地协议烟测与真实集群证据。",
+      "acs": [
+        "AC-42",
+        "AC-54",
+        "AC-55"
+      ],
+      "depends": [
+        "T125"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T129",
+      "wave": 8,
+      "kind": "实现",
+      "title": "helper 及 PVC 作用域校验接线",
+      "files": [
+        "src/backend/bisheng/app_runtime/domain/services/runtime_kubernetes_service.py",
+        "src/backend/test/app_runtime/test_runtime_kubernetes_service.py"
+      ],
+      "logic": "允许固定storage-guard;所有production/scratch按app/op冻结PVC,跨app同namespace卷仍拒绝;更新旧最小fixture。",
+      "acs": [
+        "AC-03",
+        "AC-33",
+        "AC-34",
+        "AC-48"
+      ],
+      "depends": [
+        "T097"
+      ],
+      "pair": "T016",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T130",
+      "wave": 8,
+      "kind": "基础设施",
+      "title": "自定义 bundle launcher 合同说明",
+      "files": [
+        "deploy/kubernetes/app-runtime/README.md"
+      ],
+      "logic": "明确Python解释器、固定Node/static launcher、只读挂载和非root要求;不适配该合同的完整image走既有入口。",
+      "acs": [
+        "AC-04",
+        "AC-42",
+        "AC-44",
+        "AC-56"
+      ],
+      "depends": [
+        "T118"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T131",
+      "wave": 8,
+      "kind": "实现",
+      "title": "源码大小限制冻结接线",
+      "files": [
+        "src/runtime-manager/tests/test_kubernetes_executor.py",
+        "src/runtime-manager/runtime_manager/executors/kubernetes.py"
+      ],
+      "logic": "先捕获prepare各phase丢失source_limits,再深拷贝payload冻结值;无旧字段保持兼容默认,不读现场新配置。",
+      "acs": [
+        "AC-24",
+        "AC-44",
+        "AC-56"
+      ],
+      "depends": [
+        "T100"
+      ],
+      "pair": "T032",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T132",
+      "wave": 8,
+      "kind": "测试",
+      "title": "SDK 既有平台地址变量合同",
+      "files": [
+        "src/backend/test/app_runtime/test_runtime_k8s_delivery_coordinator.py"
+      ],
+      "logic": "核对committed SDK wheel实际变量名及coordinator密封环境,Docker/K8s均能消费。",
+      "acs": [
+        "AC-36",
+        "AC-46",
+        "AC-56"
+      ],
+      "depends": [
+        "T049"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T133",
+      "wave": 8,
+      "kind": "实现",
+      "title": "SDK 平台地址变量接线",
+      "files": [
+        "src/backend/bisheng/app_runtime/domain/services/runtime_delivery_coordinator.py"
+      ],
+      "logic": "v2增加BISHENG_PLATFORM_API_BASE并保留旧key;不改变模型/附件身份,不以测试mock代替SDK实际调用。",
+      "acs": [
+        "AC-36",
+        "AC-46",
+        "AC-56"
+      ],
+      "depends": [
+        "T132"
+      ],
+      "pair": "T132",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T134",
+      "wave": 8,
+      "kind": "实现",
+      "title": "安装探测安全失败分类",
+      "files": [
+        "scripts/verify_k8s_runtime.py",
+        "src/runtime-manager/tests/test_k8s_installation.py"
+      ],
+      "logic": "先测试常量reason与异常分类,再保留probe/cleanup/publish失败;不打印任意异常文本/对象/凭据,不放宽ready。",
+      "acs": [
+        "AC-19",
+        "AC-20",
+        "AC-54",
+        "AC-55"
+      ],
+      "depends": [
+        "T065"
+      ],
+      "pair": "T064",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T135",
+      "wave": 8,
+      "kind": "测试",
+      "title": "显式公开 CA 的 HTTP/WS 合同",
+      "files": [
+        "src/backend/test/app_runtime/test_k8s_e2e_contract.py"
+      ],
+      "logic": "本地真实HTTPS/WSS正确CA允许、错误CA拒绝,路径/私钥/符号链接拒绝,无verify=False。",
+      "acs": [
+        "AC-36",
+        "AC-46",
+        "AC-55"
+      ],
+      "depends": [
+        "T105"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T136",
+      "wave": 8,
+      "kind": "实现",
+      "title": "真实业务 E2E 公开 CA 接线",
+      "files": [
+        "scripts/e2e_k8s_runtime.py",
+        "src/backend/test/e2e/helpers/vibe_runtime.py"
+      ],
+      "logic": "F059_E2E_CA_FILE构造保留系统根的严格SSLContext,HTTP和WSS统一使用,未配置时默认信任不变。",
+      "acs": [
+        "AC-36",
+        "AC-46",
+        "AC-55"
+      ],
+      "depends": [
+        "T135"
+      ],
+      "pair": "T135",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T137",
+      "wave": 8,
+      "kind": "测试",
+      "title": "固定镜像 profile 新修订合同",
+      "files": [
+        "scripts/fixtures/f059/test_bootstrap.py"
+      ],
+      "logic": "N=1兼容、N=2包内引用/default/API导入一致,不覆写相同修订的变更镜像。",
+      "acs": [
+        "AC-39",
+        "AC-42",
+        "AC-44",
+        "AC-55"
+      ],
+      "depends": [
+        "T124"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T138",
+      "wave": 8,
+      "kind": "实现",
+      "title": "验收 profile 修订参数",
+      "files": [
+        "scripts/bootstrap_f059_acceptance.py",
+        "scripts/fixtures/f059/README.md"
+      ],
+      "logic": "正整数--profile-revision控制导入、bundle固定引用;source-image遵守现行schema使用语言默认,实际解析后冻结。",
+      "acs": [
+        "AC-39",
+        "AC-42",
+        "AC-44",
+        "AC-55"
+      ],
+      "depends": [
+        "T137"
+      ],
+      "pair": "T137",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T139",
+      "wave": 8,
+      "kind": "测试",
+      "title": "Kubernetes CREATE 默认对象合同",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_admission.py"
+      ],
+      "logic": "依据固定v1.28.15真实策略校验Pending/QoS与精确空status、omitempty空env;终态/额外状态/非空env/伪造来源仍拒绝。",
+      "acs": [
+        "AC-13",
+        "AC-30",
+        "AC-49",
+        "AC-55"
+      ],
+      "depends": [
+        "T011"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T140",
+      "wave": 8,
+      "kind": "实现",
+      "title": "Kubernetes CREATE 默认对象兼容",
+      "files": [
+        "src/runtime-manager/runtime_manager/k8s_admission.py"
+      ],
+      "logic": "只容许API正常默认值与空env缺省等价,不放松status来源、CNI身份或模板冻结。",
+      "acs": [
+        "AC-13",
+        "AC-30",
+        "AC-49",
+        "AC-55"
+      ],
+      "depends": [
+        "T139"
+      ],
+      "pair": "T139",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T141",
+      "wave": 8,
+      "kind": "测试",
+      "title": "协调器旧 scratch 夹具适配",
+      "files": [
+        "src/backend/test/app_runtime/test_runtime_kubernetes_coordination.py"
+      ],
+      "logic": "增加当前op的独占scratch PVC,不改变原探测失效、连续保护和停止证据断言。",
+      "acs": [
+        "AC-19",
+        "AC-20",
+        "AC-48",
+        "AC-56"
+      ],
+      "depends": [
+        "T129"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T142",
+      "wave": 8,
+      "kind": "实现",
+      "title": "验收管理员授权可重复执行",
+      "files": [
+        "scripts/bootstrap_f059_acceptance.py",
+        "scripts/fixtures/f059/test_bootstrap.py"
+      ],
+      "logic": "先GET现存tenant admins,已存在跳过重复FGA授权;查询失败/格式错停止,不改业务授权API。",
+      "acs": [
+        "AC-36",
+        "AC-55"
+      ],
+      "depends": [
+        "T137"
+      ],
+      "pair": "T124",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T143",
+      "wave": 8,
+      "kind": "测试",
+      "title": "canary 配置投射刷新合同",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_installation.py"
+      ],
+      "logic": "CM成功replace后确认UID/RV,只匹配本controlNS Running admission Pod、受信整卷挂载;冲突/替换/错scope/无目标均拒绝,保留spec和其他注解。",
+      "acs": [
+        "AC-19",
+        "AC-20",
+        "AC-30",
+        "AC-54",
+        "AC-55"
+      ],
+      "depends": [
+        "T064"
+      ],
+      "pair": "T144",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T144",
+      "wave": 8,
+      "kind": "实现",
+      "title": "canary 配置投射刷新",
+      "files": [
+        "scripts/verify_k8s_runtime.py"
+      ],
+      "logic": "外部installer以UID/RV JSON Patch只写公开revision注解触发挂载刷新,不修改TTL/RBAC/readiness、不重启Deployment;仍等待真实admission消费计划。",
+      "acs": [
+        "AC-19",
+        "AC-20",
+        "AC-30",
+        "AC-54",
+        "AC-55"
+      ],
+      "depends": [
+        "T143"
+      ],
+      "pair": "T143",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T145",
+      "wave": 8,
+      "kind": "测试",
+      "title": "过期 canary 恢复围栏合同",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_installation.py"
+      ],
+      "logic": "未调度删除中Pod逐UID实测binding拒绝及二次读取;活跃计划/替换/错误拒绝/API不确定/未知状态不释放;停止证据不可跨资源复用。",
+      "acs": [
+        "AC-19",
+        "AC-20",
+        "AC-30",
+        "AC-48",
+        "AC-49",
+        "AC-55"
+      ],
+      "depends": [
+        "T064"
+      ],
+      "pair": "T146",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T146",
+      "wave": 8,
+      "kind": "实现",
+      "title": "过期 canary 逐资源恢复",
+      "files": [
+        "scripts/verify_k8s_runtime.py"
+      ],
+      "logic": "恢复仅接管自然过期的同UID计划;对未调度Pod获取独立绑定围栏证据,已绑定Pod等待可信终态;按停止确认、finalizer、对象消失、无PVC引用顺序清理。",
+      "acs": [
+        "AC-19",
+        "AC-20",
+        "AC-30",
+        "AC-48",
+        "AC-49",
+        "AC-55"
+      ],
+      "depends": [
+        "T145"
+      ],
+      "pair": "T145",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T147",
+      "wave": 8,
+      "kind": "测试",
+      "title": "CNI 身份与能力报告绑定合同",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_installation.py"
+      ],
+      "logic": "Chart描述与admission身份同源;仅变更CNI身份使旧报告失效;只更新probe_report不改变描述摘要。",
+      "acs": [
+        "AC-19",
+        "AC-20",
+        "AC-30",
+        "AC-55"
+      ],
+      "depends": [
+        "T143"
+      ],
+      "pair": "T148",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T148",
+      "wave": 8,
+      "kind": "基础设施",
+      "title": "描述纳入 CNI 身份",
+      "files": [
+        "deploy/kubernetes/app-runtime/templates/workloads.yaml"
+      ],
+      "logic": "内部descriptor增加cni_username,与execution.cniUsername及admission ENV同源,不改摘要算法或对外部署API。",
+      "acs": [
+        "AC-19",
+        "AC-20",
+        "AC-30",
+        "AC-55"
+      ],
+      "depends": [
+        "T147"
+      ],
+      "pair": "T147",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T149",
+      "wave": 8,
+      "kind": "测试",
+      "title": "租户上下文默认值合同",
+      "files": [
+        "src/backend/test/app_runtime/test_runtime_model_contracts.py"
+      ],
+      "logic": "记录已保存模型草稿验证:None默认等待上下文,存储非空有索引;未声明/None/0自动填充,显式租户保留,缺上下文拒绝写入;DM8仅静态。",
+      "acs": [
+        "AC-15",
+        "AC-48",
+        "AC-54",
+        "AC-56"
+      ],
+      "depends": [
+        "T073"
+      ],
+      "pair": "T150",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T150",
+      "wave": 8,
+      "kind": "基础设施",
+      "title": "运行记录租户默认值修订",
+      "files": [
+        "src/backend/bisheng/app_runtime/domain/models/runtime_delivery.py"
+      ],
+      "logic": "ScopedRuntimeRecord默认None且nullable=False/index=True;复用before_flush上下文注入,不改变既有DB约束,不修改公共Linsight模型。",
+      "acs": [
+        "AC-15",
+        "AC-48",
+        "AC-54",
+        "AC-56"
+      ],
+      "depends": [
+        "T149"
+      ],
+      "pair": "T149",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T151",
+      "wave": 8,
+      "kind": "测试",
+      "title": "Helm 升级保留 canary 计划合同",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_installation.py"
+      ],
+      "logic": "覆盖升级保留运行中plans/profile_images和首次初始化;缺对象/归属错误/非法数据拒绝,旧无label对象需同release descriptor范围证据。",
+      "acs": [
+        "AC-19",
+        "AC-20",
+        "AC-30",
+        "AC-40",
+        "AC-55"
+      ],
+      "depends": [
+        "T147"
+      ],
+      "pair": "T152",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T152",
+      "wave": 8,
+      "kind": "基础设施",
+      "title": "Helm 升级保留恢复计划",
+      "files": [
+        "deploy/kubernetes/app-runtime/templates/services.yaml"
+      ],
+      "logic": "升级只读取同release/namespace的合法canary CM并保留动态计划;初装才初始化;合法旧资源通过descriptor安装范围校验后补标识,不静默重置计划。",
+      "acs": [
+        "AC-19",
+        "AC-20",
+        "AC-30",
+        "AC-40",
+        "AC-55"
+      ],
+      "depends": [
+        "T151"
+      ],
+      "pair": "T151",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T153",
+      "wave": 8,
+      "kind": "测试",
+      "title": "原生 Docker context 构建合同",
+      "files": [
+        "src/runtime-manager/tests/test_image_release.py"
+      ],
+      "logic": "当前context选择对应docker driver builder,禁止依赖不可用default context;保留host/daemon/image架构一致与原生烟测门禁。",
+      "acs": [
+        "AC-42",
+        "AC-54"
+      ],
+      "depends": [
+        "T123"
+      ],
+      "pair": "T154",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T154",
+      "wave": 8,
+      "kind": "实现",
+      "title": "原生 context 构建入口",
+      "files": [
+        "scripts/build_runtime_images.sh"
+      ],
+      "logic": "命令级选择当前原生daemon的docker driver builder,不改全局context;同版本构建、离线resolver与烟测通过后生成清单。",
+      "acs": [
+        "AC-42",
+        "AC-54"
+      ],
+      "depends": [
+        "T153"
+      ],
+      "pair": "T153",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T155",
+      "wave": 8,
+      "kind": "测试",
+      "title": "预览访问和只读 checkpoint 合同",
+      "files": [
+        "src/backend/test/app_runtime/test_k8s_e2e_contract.py"
+      ],
+      "logic": "实际访问审批预览,配对owner/nonowner/foreign拒绝,预览前后生产行不变;checkpoint只GET且请求前校验时限、app范围。",
+      "acs": [
+        "AC-17",
+        "AC-18",
+        "AC-27",
+        "AC-35",
+        "AC-46",
+        "AC-55"
+      ],
+      "depends": [
+        "T133"
+      ],
+      "pair": "T156",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T156",
+      "wave": 8,
+      "kind": "实现",
+      "title": "预览入口与只读故障观察",
+      "files": [
+        "src/backend/test/e2e/helpers/vibe_runtime.py"
+      ],
+      "logic": "走真实预览入口;故障after不调用带写入的observe,不将API观察当作故障注入证明。",
+      "acs": [
+        "AC-17",
+        "AC-18",
+        "AC-27",
+        "AC-35",
+        "AC-46",
+        "AC-55"
+      ],
+      "depends": [
+        "T155"
+      ],
+      "pair": "T155",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T157",
+      "wave": 8,
+      "kind": "测试",
+      "title": "真实 pytest 入口 CA 一致性",
+      "files": [
+        "src/backend/test/e2e/test_e2e_vibe_k8s_runtime.py",
+        "src/backend/test/app_runtime/test_k8s_e2e_contract.py"
+      ],
+      "logic": "pytest live与CLI都用显式证书SSLContext;TLS/WS不关闭证书验证。",
+      "acs": [
+        "AC-42",
+        "AC-46",
+        "AC-55"
+      ],
+      "depends": [
+        "T155"
+      ],
+      "pair": "T155",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T158",
+      "wave": 8,
+      "kind": "测试",
+      "title": "精确故障操作合同",
+      "files": [
+        "src/backend/test/app_runtime/test_k8s_e2e_contract.py"
+      ],
+      "logic": "suite slug/app/op/snapshot范围、fresh generation与ack正反,取消/恢复配对权限和终态拒绝;恢复数据对比,不以新增快照数判通过。",
+      "acs": [
+        "AC-07",
+        "AC-27",
+        "AC-35",
+        "AC-43",
+        "AC-49",
+        "AC-55"
+      ],
+      "depends": [
+        "T155"
+      ],
+      "pair": "T159",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T159",
+      "wave": 8,
+      "kind": "实现",
+      "title": "精确取消及快照恢复 harness",
+      "files": [
+        "src/backend/test/e2e/helpers/vibe_runtime.py"
+      ],
+      "logic": "只对本suite精确operation观测和取消;恢复显式确认并比对预期rows,允许保护快照去重,公开接口缺关联证据时记录未验证。",
+      "acs": [
+        "AC-07",
+        "AC-27",
+        "AC-35",
+        "AC-43",
+        "AC-49",
+        "AC-55"
+      ],
+      "depends": [
+        "T158"
+      ],
+      "pair": "T158",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T163",
+      "wave": 8,
+      "kind": "测试",
+      "title": "故障操作 CLI 参数合同",
+      "files": [
+        "src/backend/test/app_runtime/test_k8s_e2e_contract.py"
+      ],
+      "logic": "验证fault-operation阶段的slug、operation、snapshot、expected generation、ack与expected rows参数正反。",
+      "acs": [
+        "AC-07",
+        "AC-35",
+        "AC-43",
+        "AC-55"
+      ],
+      "depends": [
+        "T159"
+      ],
+      "pair": "T160",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T160",
+      "wave": 8,
+      "kind": "实现",
+      "title": "故障操作 CLI 入口",
+      "files": [
+        "scripts/e2e_k8s_runtime.py"
+      ],
+      "logic": "公开平台API执行显式suite范围操作,返回真实业务状态;不调用集群接口或自动注入基础设施故障。",
+      "acs": [
+        "AC-07",
+        "AC-35",
+        "AC-43",
+        "AC-55"
+      ],
+      "depends": [
+        "T163"
+      ],
+      "pair": "T163",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T161",
+      "wave": 8,
+      "kind": "测试",
+      "title": "空 finalizers 序列化合同",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_admission.py"
+      ],
+      "logic": "真实API省略空finalizers时,停止未确认仍拒绝,确认后允许;CNI注解保留、额外finalizer/伪造状态仍拒绝。",
+      "acs": [
+        "AC-13",
+        "AC-20",
+        "AC-49",
+        "AC-55"
+      ],
+      "depends": [
+        "T111"
+      ],
+      "pair": "T162",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T162",
+      "wave": 8,
+      "kind": "实现",
+      "title": "准入兼容省略空 finalizers",
+      "files": [
+        "src/runtime-manager/runtime_manager/k8s_admission.py"
+      ],
+      "logic": "仅metadata.finalizers空数组按omitempty规范化为省略;不改变同UID/停止确认或非空finalizer严格比较。",
+      "acs": [
+        "AC-13",
+        "AC-20",
+        "AC-49",
+        "AC-55"
+      ],
+      "depends": [
+        "T161"
+      ],
+      "pair": "T161",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T164",
+      "wave": 8,
+      "kind": "测试",
+      "title": "kubectl 子资源拒绝合同",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_installation.py"
+      ],
+      "logic": "exec/status必须以--subresource明确请求;create pods允许不代表exec允许。",
+      "acs": [
+        "AC-13",
+        "AC-14",
+        "AC-55"
+      ],
+      "depends": [
+        "T145"
+      ],
+      "pair": "T165",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T165",
+      "wave": 8,
+      "kind": "实现",
+      "title": "kubectl 子资源显式检查",
+      "files": [
+        "scripts/verify_k8s_runtime.py"
+      ],
+      "logic": "使用资源TYPE加--subresource而非TYPE/NAME,真实核对exec和status拒绝。",
+      "acs": [
+        "AC-13",
+        "AC-14",
+        "AC-55"
+      ],
+      "depends": [
+        "T164"
+      ],
+      "pair": "T164",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T166",
+      "wave": 8,
+      "kind": "测试",
+      "title": "存储控制器身份配置合同",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_installation.py"
+      ],
+      "logic": "同源ENV/descriptor;非法、空、重复及自然用户身份拒绝,变化后旧报告失效。",
+      "acs": [
+        "AC-19",
+        "AC-20",
+        "AC-30",
+        "AC-55"
+      ],
+      "depends": [
+        "T147"
+      ],
+      "pair": "T167",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T167",
+      "wave": 8,
+      "kind": "基础设施",
+      "title": "存储控制器配置与schema",
+      "files": [
+        "deploy/kubernetes/app-runtime/values.yaml",
+        "deploy/kubernetes/app-runtime/values.schema.json"
+      ],
+      "logic": "显式控制器用户名列表,默认兼容既有部署;不赋予新RBAC权限。",
+      "acs": [
+        "AC-19",
+        "AC-20",
+        "AC-30",
+        "AC-55"
+      ],
+      "depends": [
+        "T166"
+      ],
+      "pair": "T166",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T168",
+      "wave": 8,
+      "kind": "基础设施",
+      "title": "存储控制器环境与描述同源",
+      "files": [
+        "deploy/kubernetes/app-runtime/templates/workloads.yaml"
+      ],
+      "logic": "admission ENV与descriptor从同一execution.storageControllerUsernames读取。",
+      "acs": [
+        "AC-19",
+        "AC-20",
+        "AC-30",
+        "AC-55"
+      ],
+      "depends": [
+        "T167"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T169",
+      "wave": 8,
+      "kind": "测试",
+      "title": "API未绑定Pod零宽限删除合同",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_admission.py"
+      ],
+      "logic": "仅同UID/RV、Pending未绑定且无任意writer状态可接受API规范化零宽限;绑定、Unknown、终态、过期RV拒绝。",
+      "acs": [
+        "AC-13",
+        "AC-20",
+        "AC-49",
+        "AC-55"
+      ],
+      "depends": [
+        "T161"
+      ],
+      "pair": "T170",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T170",
+      "wave": 8,
+      "kind": "实现",
+      "title": "未绑定Pod删除选项兼容",
+      "files": [
+        "src/runtime-manager/runtime_manager/k8s_admission.py"
+      ],
+      "logic": "兼容API server把未绑定Pod删除宽限归零;保留UID/RV和停止证据围栏,不放开有写入历史的强删。",
+      "acs": [
+        "AC-13",
+        "AC-20",
+        "AC-49",
+        "AC-55"
+      ],
+      "depends": [
+        "T169"
+      ],
+      "pair": "T169",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T171",
+      "wave": 8,
+      "kind": "测试",
+      "title": "PSA负例和dry-run撤销合同",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_installation.py"
+      ],
+      "logic": "schema有效的特权Pod必须由真实PSA拒绝;所有dryRun失败路径撤销未创建计划并保留双重失败。",
+      "acs": [
+        "AC-13",
+        "AC-19",
+        "AC-20",
+        "AC-55"
+      ],
+      "depends": [
+        "T164"
+      ],
+      "pair": "T172",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T172",
+      "wave": 8,
+      "kind": "实现",
+      "title": "PSA真实拒绝及计划清理",
+      "files": [
+        "scripts/verify_k8s_runtime.py"
+      ],
+      "logic": "privileged与allowPrivilegeEscalation同时设真;只认PodSecurity拒绝,finally撤销UID空的dryRun计划,清理失败不能吞掉。",
+      "acs": [
+        "AC-13",
+        "AC-19",
+        "AC-20",
+        "AC-55"
+      ],
+      "depends": [
+        "T171"
+      ],
+      "pair": "T171",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T173",
+      "wave": 8,
+      "kind": "测试",
+      "title": "原生隔离烟测可读目录合同",
+      "files": [
+        "src/runtime-manager/tests/test_image_release.py"
+      ],
+      "logic": "umask022/077下UID10001可遍历shadow并读取poison文件,防止权限拒绝导致假阳性。",
+      "acs": [
+        "AC-42",
+        "AC-54"
+      ],
+      "depends": [
+        "T153"
+      ],
+      "pair": "T174",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T174",
+      "wave": 8,
+      "kind": "实现",
+      "title": "原生隔离烟测目录权限",
+      "files": [
+        "scripts/build_runtime_images.sh"
+      ],
+      "logic": "shadow0755、poison0644;实际-I命令先读候选再确认只导入trusted模块,不改变运行镜像权限。",
+      "acs": [
+        "AC-42",
+        "AC-54"
+      ],
+      "depends": [
+        "T173"
+      ],
+      "pair": "T173",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T175",
+      "wave": 8,
+      "kind": "测试",
+      "title": "终态Pod删除规范化合同",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_admission.py"
+      ],
+      "logic": "同UID/RV可信终态0允许;伪造对象、active/Unknown/NodeLost、缺少终止证明和策略/finalizer错误拒绝。",
+      "acs": [
+        "AC-13",
+        "AC-20",
+        "AC-49",
+        "AC-55"
+      ],
+      "depends": [
+        "T169"
+      ],
+      "pair": "T176",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T176",
+      "wave": 8,
+      "kind": "实现",
+      "title": "终态Pod删除选项兼容",
+      "files": [
+        "src/runtime-manager/runtime_manager/k8s_admission.py"
+      ],
+      "logic": "仅manager授权后验证API oldObject的停止事实,兼容Core规范化0;不代替停止确认或释放finalizer。",
+      "acs": [
+        "AC-13",
+        "AC-20",
+        "AC-49",
+        "AC-55"
+      ],
+      "depends": [
+        "T175"
+      ],
+      "pair": "T175",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T177",
+      "wave": 8,
+      "kind": "测试",
+      "title": "SQLite随机哨兵升级合同",
+      "files": [
+        "src/runtime-manager/tests/test_image_release.py"
+      ],
+      "logic": "升级前由测试写随机数据,升级后核对值和备份,避免两版默认初始化相同造成假阳性。",
+      "acs": [
+        "AC-24",
+        "AC-25",
+        "AC-26",
+        "AC-55"
+      ],
+      "depends": [
+        "T173"
+      ],
+      "pair": "T178",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T178",
+      "wave": 8,
+      "kind": "实现",
+      "title": "真实SQLite哨兵烟测",
+      "files": [
+        "scripts/smoke_runtime_delivery.py"
+      ],
+      "logic": "真实Engine写入升级前独有随机值,v2保留并在确认备份中查询同值;不以默认初始化行代替持久化。",
+      "acs": [
+        "AC-24",
+        "AC-25",
+        "AC-26",
+        "AC-55"
+      ],
+      "depends": [
+        "T177"
+      ],
+      "pair": "T177",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T179",
+      "wave": 8,
+      "kind": "测试",
+      "title": "Docker诊断真实客户端合同",
+      "files": [
+        "src/runtime-manager/tests/test_operations_v2.py"
+      ],
+      "logic": "真实PlatformClient仅提供node_snapshot;验证真实签名plan请求和平台错误拒绝本地容量回退。",
+      "acs": [
+        "AC-01",
+        "AC-10",
+        "AC-30",
+        "AC-48",
+        "AC-50",
+        "AC-55"
+      ],
+      "depends": [
+        "T175"
+      ],
+      "pair": "T180",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T180",
+      "wave": 8,
+      "kind": "实现",
+      "title": "Docker诊断平台账本接线",
+      "files": [
+        "src/runtime-manager/runtime_manager/api/platform_observe.py"
+      ],
+      "logic": "修正不存在的node_plan调用为node_snapshot;保留Docker公开字段形状,不添加K8s专属字段。",
+      "acs": [
+        "AC-01",
+        "AC-10",
+        "AC-30",
+        "AC-48",
+        "AC-50",
+        "AC-55"
+      ],
+      "depends": [
+        "T179"
+      ],
+      "pair": "T179",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T181",
+      "wave": 8,
+      "kind": "测试",
+      "title": "Docker既有诊断E2E合同",
+      "files": [
+        "src/backend/test/app_runtime/test_k8s_e2e_contract.py"
+      ],
+      "logic": "Docker无K8s专属ready/executor_kind允许;账本不可读、错误协议/类型/运行时、缺失或失败预检拒绝;K8s保持严格ready。",
+      "acs": [
+        "AC-01",
+        "AC-30",
+        "AC-32",
+        "AC-55"
+      ],
+      "depends": [
+        "T163"
+      ],
+      "pair": "T182",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T182",
+      "wave": 8,
+      "kind": "实现",
+      "title": "Docker诊断E2E兼容",
+      "files": [
+        "src/backend/test/e2e/helpers/vibe_runtime.py"
+      ],
+      "logic": "按已知执行器核验现有公开形状;Docker必须v2、后端可用、容量可读、三运行时与全部预检成功;不放松K8s。",
+      "acs": [
+        "AC-01",
+        "AC-30",
+        "AC-32",
+        "AC-55"
+      ],
+      "depends": [
+        "T181"
+      ],
+      "pair": "T181",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T183",
+      "wave": 8,
+      "kind": "测试",
+      "title": "验收批次应用标识合同",
+      "files": [
+        "scripts/fixtures/f059/test_bootstrap.py"
+      ],
+      "logic": "删除保留slug,显式批次后缀正反、长度约束、包manifest/scenario一致、准备和bootstrap一致;不重建身份。",
+      "acs": [
+        "AC-35",
+        "AC-55",
+        "AC-56"
+      ],
+      "depends": [
+        "T181"
+      ],
+      "pair": "T184",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T184",
+      "wave": 8,
+      "kind": "实现",
+      "title": "唯一应用验收批次后缀",
+      "files": [
+        "scripts/bootstrap_f059_acceptance.py"
+      ],
+      "logic": "增加可选显式application-suffix,严格范围并冻结本批后缀;新批用独立journal,旧应用按原账本清理,不删审计行。",
+      "acs": [
+        "AC-35",
+        "AC-55",
+        "AC-56"
+      ],
+      "depends": [
+        "T183"
+      ],
+      "pair": "T183",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T185",
+      "wave": 9,
+      "kind": "测试",
+      "title": "真实报告的公开诊断合同",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_observe.py"
+      ],
+      "logic": "使用真实executor与descriptor投射,无raw ready字段的有效报告通过;过期、身份变化、mandatory失败和API不可达拒绝,刷新后恢复。",
+      "acs": [
+        "AC-01",
+        "AC-30",
+        "AC-31",
+        "AC-32",
+        "AC-55"
+      ],
+      "depends": [
+        "T179"
+      ],
+      "pair": "T186",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T186",
+      "wave": 9,
+      "kind": "实现",
+      "title": "公开诊断复用实时评估",
+      "files": [
+        "src/runtime-manager/runtime_manager/api/platform_observe.py"
+      ],
+      "logic": "公开诊断调用既有probe_report评估,不修改报告TTL、摘要、公开形状或能力准入。",
+      "acs": [
+        "AC-01",
+        "AC-30",
+        "AC-31",
+        "AC-32",
+        "AC-55"
+      ],
+      "depends": [
+        "T185"
+      ],
+      "pair": "T185",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T187",
+      "wave": 9,
+      "kind": "测试",
+      "title": "WAL侧文件与SQL只读配对",
+      "files": [
+        "src/runtime-manager/tests/test_data_executor.py",
+        "src/runtime-manager/tests/test_data_executor_wal.py"
+      ],
+      "logic": "真实SQLite验证无侧文件及已提交WAL;单应用helper可创建侧文件,读SQL仍拒绝写入、读取最新已提交值;实际Docker权限和隔离配对。",
+      "acs": [
+        "AC-10",
+        "AC-16",
+        "AC-34",
+        "AC-36",
+        "AC-46",
+        "AC-55"
+      ],
+      "depends": [
+        "T066"
+      ],
+      "pair": "T188",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T188",
+      "wave": 9,
+      "kind": "实现",
+      "title": "单应用数据helper的WAL目录访问",
+      "files": [
+        "src/runtime-manager/runtime_manager/data_executor.py"
+      ],
+      "logic": "单应用数据库目录允许SQLite创建/锁定WAL侧文件,rootfs仍只读、无网络、非root;读连接继续mode=ro,不使用immutable忽略WAL或强制checkpoint。",
+      "acs": [
+        "AC-10",
+        "AC-16",
+        "AC-34",
+        "AC-36",
+        "AC-46",
+        "AC-55"
+      ],
+      "depends": [
+        "T187"
+      ],
+      "pair": "T187",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T189",
+      "wave": 9,
+      "kind": "测试",
+      "title": "恢复后等待精确运行操作终态",
+      "files": [
+        "src/backend/test/app_runtime/test_k8s_e2e_contract.py"
+      ],
+      "logic": "公开app可先online;验收须等待同一resume操作completed/cleanup complete,并复核同app/operation/version/generation。失败和身份变化不能放行,不吞维护错误。",
+      "acs": [
+        "AC-29",
+        "AC-33",
+        "AC-35",
+        "AC-36",
+        "AC-55"
+      ],
+      "depends": [
+        "T182"
+      ],
+      "pair": "T190",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T190",
+      "wave": 9,
+      "kind": "实现",
+      "title": "业务验收恢复操作同步",
+      "files": [
+        "src/backend/test/e2e/helpers/vibe_runtime.py"
+      ],
+      "logic": "仅修正验收工具同步,ready/serving阶段继续轮询,终态后核对身份与数据;业务API和运行时锁不改。",
+      "acs": [
+        "AC-29",
+        "AC-33",
+        "AC-35",
+        "AC-36",
+        "AC-55"
+      ],
+      "depends": [
+        "T189"
+      ],
+      "pair": "T189",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T191",
+      "wave": 7,
+      "kind": "测试",
+      "title": "离线源码验收依赖物料合同",
+      "files": [
+        "scripts/fixtures/f059/test_bootstrap.py"
+      ],
+      "logic": "只接收显式清单和逐wheel SHA的纯Python物料;拒绝缺依赖、篡改、native wheel、路径穿越、覆盖与pth;真实SDK断言不变。",
+      "acs": [
+        "AC-04",
+        "AC-24",
+        "AC-36",
+        "AC-46",
+        "AC-55"
+      ],
+      "depends": [
+        "T184"
+      ],
+      "pair": "T192",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T192",
+      "wave": 7,
+      "kind": "实现",
+      "title": "源码验收包显式离线vendor物料",
+      "files": [
+        "scripts/bootstrap_f059_acceptance.py"
+      ],
+      "logic": "显式source-wheelhouse从固定材料清单校验并保留真实SDK/httpx闭包及许可,只向source-image包加入_vendor与启动路径引导;不读取开发venv、不pip、不联网,不改生产模板或base。",
+      "acs": [
+        "AC-04",
+        "AC-24",
+        "AC-36",
+        "AC-46",
+        "AC-55"
+      ],
+      "depends": [
+        "T191"
+      ],
+      "pair": "T191",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T193",
+      "wave": 7,
+      "kind": "基础设施",
+      "title": "离线源码验收前置说明",
+      "files": [
+        "scripts/fixtures/f059/README.md"
+      ],
+      "logic": "区分Docker source-image裸base与K8s profile;记录离线vendor材料准备和许可,完整镜像需显式提供digest;说明本地材料烟测不代表平台验收。",
+      "acs": [
+        "AC-04",
+        "AC-24",
+        "AC-36",
+        "AC-46",
+        "AC-55"
+      ],
+      "depends": [
+        "T192"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T194",
+      "wave": 7,
+      "kind": "测试",
+      "title": "真实manifest引用与冻结profile分离",
+      "files": [
+        "src/runtime-manager/tests/test_kubernetes_executor.py",
+        "src/runtime-manager/tests/test_oci_assembler.py"
+      ],
+      "logic": "使用真实BundleDelivery.model_dump的显式profile字符串和已解析payload.profile,覆盖prepare及运行resolved_delivery;错误类型在资源调用前明确拒绝,不被宽松桩掩盖。",
+      "acs": [
+        "AC-04",
+        "AC-24",
+        "AC-25",
+        "AC-36",
+        "AC-55"
+      ],
+      "depends": [
+        "T186"
+      ],
+      "pair": "T195",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T195",
+      "wave": 7,
+      "kind": "实现",
+      "title": "K8s计划仅消费已解析profile对象",
+      "files": [
+        "src/runtime-manager/runtime_manager/executors/kubernetes.py"
+      ],
+      "logic": "prepare只取已解析payload.profile,运行取resolved_delivery.profile;manifest字符串仅作为引用,不当作dict、不默默fallback错误对象,保持平台冻结/范围校验。",
+      "acs": [
+        "AC-04",
+        "AC-24",
+        "AC-25",
+        "AC-36",
+        "AC-55"
+      ],
+      "depends": [
+        "T194"
+      ],
+      "pair": "T194",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T196",
+      "wave": 7,
+      "kind": "测试",
+      "title": "无运行分配与平台故障状态配对",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_observe.py"
+      ],
+      "logic": "真实PlatformClient的application_not_found/404映射为无实例;403/503/超时及资源故障保持不可用,Docker/K8s和正常实例保持配对。",
+      "acs": [
+        "AC-01",
+        "AC-33",
+        "AC-35",
+        "AC-36",
+        "AC-55"
+      ],
+      "depends": [
+        "T186"
+      ],
+      "pair": "T197",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T197",
+      "wave": 7,
+      "kind": "实现",
+      "title": "只读实例查询保留无分配语义",
+      "files": [
+        "src/runtime-manager/runtime_manager/api/platform_observe.py"
+      ],
+      "logic": "只将platform.application精确application_not_found+404转换NotFoundError;不将任意平台/API资源失败视为无写入者,不放宽删除与停止围栏。",
+      "acs": [
+        "AC-01",
+        "AC-33",
+        "AC-35",
+        "AC-36",
+        "AC-55"
+      ],
+      "depends": [
+        "T196"
+      ],
+      "pair": "T196",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T198",
+      "wave": 7,
+      "kind": "测试",
+      "title": "控制器空闲过期与失租恢复围栏",
+      "files": [
+        "src/runtime-manager/tests/test_kubernetes_executor.py"
+      ],
+      "logic": "真实30秒租约和PlatformClient错误配对:空闲过期重新CAS领取;活跃异holder、未知响应和503不放行,既有UID恢复与新epoch事实及并发锁不生成虚假停止证据。",
+      "acs": [
+        "AC-01",
+        "AC-13",
+        "AC-15",
+        "AC-33",
+        "AC-35",
+        "AC-36",
+        "AC-43",
+        "AC-48",
+        "AC-55"
+      ],
+      "depends": [
+        "T197"
+      ],
+      "pair": "T199",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T199",
+      "wave": 7,
+      "kind": "实现",
+      "title": "过期控制器租约按精确失租恢复",
+      "files": [
+        "src/runtime-manager/runtime_manager/executors/kubernetes.py"
+      ],
+      "logic": "精确409/controller_lease_lost使缓存失效,fresh node快照后CAS领取;其他失败不吞,当前不确定动作拒绝;按真实到期限制缓存,不改平台API及停止围栏。",
+      "acs": [
+        "AC-01",
+        "AC-13",
+        "AC-15",
+        "AC-33",
+        "AC-35",
+        "AC-36",
+        "AC-43",
+        "AC-48",
+        "AC-55"
+      ],
+      "depends": [
+        "T198"
+      ],
+      "pair": "T198",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T200",
+      "wave": 7,
+      "kind": "测试",
+      "title": "backend真实冻结profile资源校验合同",
+      "files": [
+        "src/backend/test/app_runtime/test_k8s_frozen_profile_contract.py"
+      ],
+      "logic": "真实manifest model_dump与register_resource/SQLite账本配对:prepare已解析profile、运行冻结profile、非法类型及image/purge/source-image白名单;拒绝前不写资源。",
+      "acs": [
+        "AC-04",
+        "AC-13",
+        "AC-15",
+        "AC-24",
+        "AC-25",
+        "AC-36",
+        "AC-48",
+        "AC-55"
+      ],
+      "depends": [
+        "T199"
+      ],
+      "pair": "T201",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T202",
+      "wave": 7,
+      "kind": "测试",
+      "title": "旧资源测试fixture补齐真实协议字段",
+      "files": [
+        "src/backend/test/app_runtime/test_runtime_kubernetes_coordination.py",
+        "src/backend/test/app_runtime/test_runtime_kubernetes_service.py"
+      ],
+      "logic": "prepare fixture提供manifest delivery与已解析profile;直接Pod校验替身提供真实kind/image mode,保留探测/跨卷/停止证据全部拒绝断言。",
+      "acs": [
+        "AC-04",
+        "AC-13",
+        "AC-15",
+        "AC-24",
+        "AC-25",
+        "AC-36",
+        "AC-48",
+        "AC-55"
+      ],
+      "depends": [
+        "T200"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T201",
+      "wave": 7,
+      "kind": "实现",
+      "title": "backend按阶段消费已解析profile",
+      "files": [
+        "src/backend/bisheng/app_runtime/domain/services/runtime_kubernetes_service.py"
+      ],
+      "logic": "prepare仅读payload.profile,其他阶段仅读resolved_delivery.profile;非法delivery/profile固定RuntimeConflict,不以stale对象fallback扩大镜像白名单;image/purge无profile合同保持。",
+      "acs": [
+        "AC-04",
+        "AC-13",
+        "AC-15",
+        "AC-24",
+        "AC-25",
+        "AC-36",
+        "AC-48",
+        "AC-55"
+      ],
+      "depends": [
+        "T200",
+        "T202"
+      ],
+      "pair": "T200",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T203",
+      "wave": 8,
+      "kind": "测试",
+      "title": "K8s自动补全的UID字段合同",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_templates.py",
+        "src/runtime-manager/tests/test_k8s_admission.py"
+      ],
+      "logic": "真实PodPlan默认化后通过;两个helper的非v1、字段路径变化、额外字段仍拒绝。",
+      "acs": [
+        "AC-13",
+        "AC-14",
+        "AC-48",
+        "AC-49",
+        "AC-52"
+      ],
+      "depends": [
+        "T201"
+      ],
+      "pair": "T204",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T204",
+      "wave": 8,
+      "kind": "实现",
+      "title": "冻结显式core/v1 UID选择器",
+      "files": [
+        "src/runtime-manager/runtime_manager/k8s_templates.py"
+      ],
+      "logic": "仅两处显式apiVersion:v1,未知字段比较不放宽,旧冻结intent不改写。",
+      "acs": [
+        "AC-13",
+        "AC-14",
+        "AC-48",
+        "AC-49",
+        "AC-52"
+      ],
+      "depends": [
+        "T203"
+      ],
+      "pair": "T203",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T207",
+      "wave": 8,
+      "kind": "测试",
+      "title": "持久创建事务与签名HTTP合同",
+      "files": [
+        "src/backend/test/app_runtime/test_runtime_kubernetes_creation.py",
+        "src/backend/test/app_runtime/test_runtime_kubernetes_api.py"
+      ],
+      "logic": "仅唯一403拒绝可独立not_created收尾;未知、旧记录、重复、跨epoch和无UID普通停止拒绝;含真实SQL、签名API、role配对和混合unknown阻塞。",
+      "acs": [
+        "AC-13",
+        "AC-14",
+        "AC-48",
+        "AC-49",
+        "AC-52"
+      ],
+      "depends": [
+        "T204"
+      ],
+      "pair": "T208",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T214",
+      "wave": 8,
+      "kind": "测试",
+      "title": "锁读后刷新资源identity map",
+      "files": [
+        "src/backend/test/app_runtime/test_runtime_kubernetes_creation_concurrency.py"
+      ],
+      "logic": "真实双Session交错预读与提交,锁读必须刷新旧缓存;begin/fence双向互斥,非MySQL锁性能实测。",
+      "acs": [
+        "AC-13",
+        "AC-14",
+        "AC-48",
+        "AC-49",
+        "AC-52"
+      ],
+      "depends": [
+        "T207"
+      ],
+      "pair": "T208",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T208",
+      "wave": 8,
+      "kind": "实现",
+      "title": "资源账本单次创建与未创建围栏",
+      "files": [
+        "src/backend/bisheng/app_runtime/domain/repositories/runtime_kubernetes_repository.py",
+        "src/backend/bisheng/app_runtime/domain/models/runtime_kubernetes.py"
+      ],
+      "logic": "现有observed_state存versioned creation,CAS begin/rejected/fence/not_created;明确403且fence后fresh404才确认无执行体,不伪造UID;未知持久阻塞;锁读populate_existing,无新DDL。",
+      "acs": [
+        "AC-13",
+        "AC-14",
+        "AC-48",
+        "AC-49",
+        "AC-52"
+      ],
+      "depends": [
+        "T207",
+        "T214"
+      ],
+      "pair": "T207",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T211",
+      "wave": 8,
+      "kind": "测试",
+      "title": "既有资源Service夹具接入创建事务",
+      "files": [
+        "src/backend/test/app_runtime/test_runtime_kubernetes_service.py"
+      ],
+      "logic": "注册后尚不允许CREATE;真实begin后才允许,原只读不写、UID停止和权限拒绝保持。",
+      "acs": [
+        "AC-13",
+        "AC-14",
+        "AC-48",
+        "AC-49",
+        "AC-52"
+      ],
+      "depends": [
+        "T208"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T212",
+      "wave": 8,
+      "kind": "实现",
+      "title": "创建收尾Service与只读准入接线",
+      "files": [
+        "src/backend/bisheng/app_runtime/domain/services/runtime_kubernetes_service.py"
+      ],
+      "logic": "动作按字段范围校验,新增四个内部观察;not_created和stopped各自校验,所有Pod确认后才能释放预占;准入只给started新意图CREATE。",
+      "acs": [
+        "AC-13",
+        "AC-14",
+        "AC-48",
+        "AC-49",
+        "AC-52"
+      ],
+      "depends": [
+        "T207",
+        "T208",
+        "T211"
+      ],
+      "pair": "T207",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T213",
+      "wave": 8,
+      "kind": "实现",
+      "title": "签名观察API创建动作输入",
+      "files": [
+        "src/backend/bisheng/app_runtime/api/endpoints/runtime_kubernetes.py"
+      ],
+      "logic": "严格schema声明创建动作/attempt/status,exclude_unset避免默认字段进入其它动作;独立manager鉴权不变,无外部deploy API变更。",
+      "acs": [
+        "AC-13",
+        "AC-14",
+        "AC-48",
+        "AC-49",
+        "AC-52"
+      ],
+      "depends": [
+        "T207",
+        "T212"
+      ],
+      "pair": "T207",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T205",
+      "wave": 8,
+      "kind": "测试",
+      "title": "manager明确拒绝与未知创建恢复合同",
+      "files": [
+        "src/runtime-manager/tests/test_kubernetes_executor.py"
+      ],
+      "logic": "403可持久收尾、未知不重发、回执丢失重启续办、发现实际Pod正常UID停止、同名异物拒绝,不DELETE未创建Pod。",
+      "acs": [
+        "AC-13",
+        "AC-14",
+        "AC-48",
+        "AC-49",
+        "AC-52"
+      ],
+      "depends": [
+        "T204",
+        "T213"
+      ],
+      "pair": "T206",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T206",
+      "wave": 8,
+      "kind": "实现",
+      "title": "manager单次创建与独立无执行体收尾",
+      "files": [
+        "src/runtime-manager/runtime_manager/executors/kubernetes.py"
+      ],
+      "logic": "POST前取得唯一begin回执,只有403持久rejected;cleanup先fence再fresh404,不确定保留旧资源;重拉账本绑定UID,证明后再次出现对象拒绝。",
+      "acs": [
+        "AC-13",
+        "AC-14",
+        "AC-48",
+        "AC-49",
+        "AC-52"
+      ],
+      "depends": [
+        "T205"
+      ],
+      "pair": "T205",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T209",
+      "wave": 8,
+      "kind": "测试",
+      "title": "准入拒绝安全诊断合同",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_admission_diagnostics.py"
+      ],
+      "logic": "真实review拒绝日志只固定reason白名单,恶意异常文本/类名/凭据/UID不得泄漏;成功无拒绝日志。",
+      "acs": [
+        "AC-13",
+        "AC-14",
+        "AC-48",
+        "AC-49",
+        "AC-52"
+      ],
+      "depends": [
+        "T206"
+      ],
+      "pair": "T210",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T210",
+      "wave": 8,
+      "kind": "实现",
+      "title": "准入拒绝固定枚举诊断",
+      "files": [
+        "src/runtime-manager/runtime_manager/k8s_admission.py"
+      ],
+      "logic": "仅8固定原因或admission_refused;原API响应及拒绝策略不变,不追认历史403。",
+      "acs": [
+        "AC-13",
+        "AC-14",
+        "AC-48",
+        "AC-49",
+        "AC-52"
+      ],
+      "depends": [
+        "T209"
+      ],
+      "pair": "T209",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T215",
+      "wave": 8,
+      "kind": "测试",
+      "title": "实际固定容器日志与诊断合同",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_client.py",
+        "src/runtime-manager/tests/test_k8s_diagnostic_contract.py"
+      ],
+      "logic": "真实KubernetesClient/MockTransport接_phase:固定helper日志可读,保留preparation_phase_failed与脱敏tail;UID/scope不符不读日志,未知容器仍拒绝。",
+      "acs": [
+        "AC-13",
+        "AC-15",
+        "AC-24",
+        "AC-47",
+        "AC-48",
+        "AC-55"
+      ],
+      "depends": [
+        "T214"
+      ],
+      "pair": "T216",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T216",
+      "wave": 8,
+      "kind": "实现",
+      "title": "补齐固定helper日志名单",
+      "files": [
+        "src/runtime-manager/runtime_manager/k8s_client.py"
+      ],
+      "logic": "只增加模板实际使用的storage-guard/schema-init/assembler;不吞ValueError、不放任意容器或减弱UID。",
+      "acs": [
+        "AC-13",
+        "AC-15",
+        "AC-24",
+        "AC-47",
+        "AC-48",
+        "AC-55"
+      ],
+      "depends": [
+        "T215"
+      ],
+      "pair": "T215",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T217",
+      "wave": 8,
+      "kind": "测试",
+      "title": "材料根路径反向代理合同",
+      "files": [
+        "src/backend/test/app_runtime/test_k8s_material_ingress_contract.py"
+      ],
+      "logic": "精确prefix转发原始URI/query/chunk body;无凭据仍401,签名拒绝配对,禁止宽internal/workspace别名。",
+      "acs": [
+        "AC-13",
+        "AC-15",
+        "AC-24",
+        "AC-47",
+        "AC-48",
+        "AC-55"
+      ],
+      "depends": [
+        "T216"
+      ],
+      "pair": "T218",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T218",
+      "wave": 8,
+      "kind": "实现",
+      "title": "材料根路径nginx与安装说明",
+      "files": [
+        "docker/nginx/conf.d/default.conf",
+        "deploy/kubernetes/app-runtime/README.md"
+      ],
+      "logic": "仅material-access前缀到backend,16MiB、streaming/超时及原始路径保持;platform.origin必须可达此独立根路径,backend验签不变。",
+      "acs": [
+        "AC-13",
+        "AC-15",
+        "AC-24",
+        "AC-47",
+        "AC-48",
+        "AC-55"
+      ],
+      "depends": [
+        "T217"
+      ],
+      "pair": "T217",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T219",
+      "wave": 9,
+      "kind": "测试",
+      "title": "延迟调度节点绑定与停止证明配对",
+      "files": [
+        "src/runtime-manager/tests/test_kubernetes_executor.py"
+      ],
+      "logic": "创建时Pending空node,随后同UID调度;节点变化、UID替换、API/回执未知和已有停止证明拒绝配对。",
+      "acs": [
+        "AC-33",
+        "AC-49",
+        "AC-52"
+      ],
+      "depends": [
+        "T214",
+        "T216"
+      ],
+      "pair": "T220",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T220",
+      "wave": 9,
+      "kind": "实现",
+      "title": "同UID首次节点绑定刷新",
+      "files": [
+        "src/runtime-manager/runtime_manager/executors/kubernetes.py"
+      ],
+      "logic": "停止前新鲜读取同UID,仅空节点补实际绑定,保留既有停止证明和backend严格scope。",
+      "acs": [
+        "AC-33",
+        "AC-49",
+        "AC-52"
+      ],
+      "depends": [
+        "T219"
+      ],
+      "pair": "T219",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T221",
+      "wave": 9,
+      "kind": "测试",
+      "title": "可选依赖锁空值及安装接续合同",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_helper.py"
+      ],
+      "logic": "真实manifest lock:null与省略、显式路径配对,使用可信common resolver覆盖环境变量与prepare配置接续;非法类型拒绝。",
+      "acs": [
+        "AC-15",
+        "AC-24",
+        "AC-47",
+        "AC-55"
+      ],
+      "depends": [
+        "T220"
+      ],
+      "pair": "T222",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T222",
+      "wave": 9,
+      "kind": "实现",
+      "title": "可选依赖锁空值规范化",
+      "files": [
+        "src/runtime-manager/runtime_manager/k8s_helper.py",
+        "src/runtime-manager/runtime_manager/k8s_prepare.py"
+      ],
+      "logic": "仅将已验证的可选lock None视为未指定的空字符串,显式字符串保留,非法类型拒绝,不改变来源/锁校验。",
+      "acs": [
+        "AC-15",
+        "AC-24",
+        "AC-47",
+        "AC-55"
+      ],
+      "depends": [
+        "T221"
+      ],
+      "pair": "T221",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T223",
+      "wave": 9,
+      "kind": "测试",
+      "title": "终态清理重试保留原始失败诊断",
+      "files": [
+        "src/runtime-manager/tests/test_operations_v2.py"
+      ],
+      "logic": "首次失败阶段、tail、维护/数据变化标记在真实cleanup重试后保留;停止/清理按本轮真实结果,保留期清空的tail不复活。",
+      "acs": [
+        "AC-48",
+        "AC-49",
+        "AC-52",
+        "AC-55"
+      ],
+      "depends": [
+        "T220"
+      ],
+      "pair": "T224",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T224",
+      "wave": 9,
+      "kind": "实现",
+      "title": "清理重试保留首次失败事实",
+      "files": [
+        "src/runtime-manager/runtime_manager/operations.py"
+      ],
+      "logic": "终态重试携带当前持久failure诊断字段,缺失tail不重建;phase不变、停止和cleanup仅使用实际结果,不复活操作。",
+      "acs": [
+        "AC-48",
+        "AC-49",
+        "AC-52",
+        "AC-55"
+      ],
+      "depends": [
+        "T223"
+      ],
+      "pair": "T223",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T225",
+      "wave": 9,
+      "kind": "测试",
+      "title": "异步收尾等待与重启配对",
+      "files": [
+        "src/runtime-manager/tests/test_operations_v2.py",
+        "src/runtime-manager/tests/test_executor_selection.py"
+      ],
+      "logic": "准备或探活完成后清理暂未结束保持非终态,结果不重做;取消/期限/失租保持围栏,确认清理后才完成。",
+      "acs": [
+        "AC-04",
+        "AC-33",
+        "AC-35",
+        "AC-49",
+        "AC-52",
+        "AC-55",
+        "AC-56"
+      ],
+      "depends": [
+        "T224"
+      ],
+      "pair": "T226",
+      "command": "operations 55 passed; related 284 passed; selection + operations 65 passed; Ruff/format/arch/diff passed",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T226",
+      "wave": 9,
+      "kind": "实现",
+      "title": "编排异步清理有界等待",
+      "files": [
+        "src/runtime-manager/runtime_manager/operations.py"
+      ],
+      "logic": "修正K8s正常异步删除被当作准备失败的语义;同根收尾路径按原操作检查点/期限等待,不吞真实异常、不伪造清理完成。",
+      "acs": [
+        "AC-04",
+        "AC-33",
+        "AC-35",
+        "AC-49",
+        "AC-52",
+        "AC-55",
+        "AC-56"
+      ],
+      "depends": [
+        "T225"
+      ],
+      "pair": "T225",
+      "command": "operations 55 passed; related 284 passed; selection + operations 65 passed; Ruff/format/arch/diff passed",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T227",
+      "wave": 9,
+      "kind": "测试",
+      "title": "K8s异步删除API合同",
+      "files": [
+        "src/runtime-manager/tests/test_kubernetes_executor.py"
+      ],
+      "logic": "DELETE受理后同UID对象仍在时cleanup返回pending;真实消失后才完成,换UID/未知拒绝,不能重复创建或误删。",
+      "acs": [
+        "AC-33",
+        "AC-49",
+        "AC-52"
+      ],
+      "depends": [
+        "T220"
+      ],
+      "pair": null,
+      "command": "test_kubernetes_executor.py + test_k8s_observe.py + test_k8s_data_executor.py: 211 passed; four new asynchronous deletion cases passed",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T228",
+      "wave": 9,
+      "kind": "测试",
+      "title": "初始化与健康阶段期限配对",
+      "files": [
+        "src/runtime-manager/tests/test_kubernetes_executor.py"
+      ],
+      "logic": "多init累计超过90秒可在各自期限内完成;单阶段、健康及原操作期限仍有界;重启不重计,未知不延长,同UID限制保持。",
+      "acs": [
+        "AC-24",
+        "AC-25",
+        "AC-28",
+        "AC-43",
+        "AC-48",
+        "AC-49",
+        "AC-52",
+        "AC-55"
+      ],
+      "depends": [
+        "T226",
+        "T227"
+      ],
+      "pair": "T229",
+      "command": "175 executor tests; 392 adjacent; full runtime 1483 passed/13 skipped; Ruff/format/arch/diff passed",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T229",
+      "wave": 9,
+      "kind": "实现",
+      "title": "修正初始化与应用就绪期限",
+      "files": [
+        "src/runtime-manager/runtime_manager/executors/kubernetes.py"
+      ],
+      "logic": "按既定阶段有界初始化和健康检查,不扩大原操作期限,不修改围栏/API/DDL。",
+      "acs": [
+        "AC-24",
+        "AC-25",
+        "AC-28",
+        "AC-43",
+        "AC-48",
+        "AC-49",
+        "AC-52",
+        "AC-55"
+      ],
+      "depends": [
+        "T228"
+      ],
+      "pair": "T228",
+      "command": "175 executor tests; 392 adjacent; full runtime 1483 passed/13 skipped; Ruff/format/arch/diff passed",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T230",
+      "wave": 9,
+      "kind": "测试",
+      "title": "已取消数据操作拒绝新动作配对",
+      "files": [
+        "src/backend/test/app_runtime/test_runtime_data_grants.py",
+        "src/backend/test/app_runtime/test_runtime_data_sessions.py",
+        "src/runtime-manager/tests/test_k8s_data_executor.py"
+      ],
+      "logic": "已取消会话拒绝新签/ready/新RPC;active=False清理及unknown/互斥保留。",
+      "acs": [
+        "AC-33",
+        "AC-36",
+        "AC-43",
+        "AC-48",
+        "AC-49",
+        "AC-55"
+      ],
+      "depends": [
+        "T226"
+      ],
+      "pair": "T231",
+      "command": "12 new paired; backend 170 and manager 176 related passed; full backend 1734/19 skipped and runtime 1483/13 skipped; static checks passed",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T231",
+      "wave": 9,
+      "kind": "实现",
+      "title": "取消数据会话的签发与RPC围栏",
+      "files": [
+        "src/backend/bisheng/app_runtime/domain/services/runtime_data_grant_service.py",
+        "src/backend/bisheng/app_runtime/domain/services/runtime_data_session_service.py",
+        "src/runtime-manager/runtime_manager/k8s_data_executor.py"
+      ],
+      "logic": "签发事务及manager当前操作拒绝取消后的新动作;不释放未知写入者。",
+      "acs": [
+        "AC-33",
+        "AC-36",
+        "AC-43",
+        "AC-48",
+        "AC-49",
+        "AC-55"
+      ],
+      "depends": [
+        "T230"
+      ],
+      "pair": "T230",
+      "command": "12 new paired; backend 170 and manager 176 related passed; full backend 1734/19 skipped and runtime 1483/13 skipped; static checks passed",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T232",
+      "wave": 8,
+      "kind": "测试",
+      "title": "终态finalizer移除后kubelet删除配对",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_admission.py"
+      ],
+      "logic": "有STOP原路保持,无STOP仅fresh同scope/UID已持久停止确认允许绑定kubelet DELETE0;不确定拒绝。",
+      "acs": [
+        "AC-35",
+        "AC-48",
+        "AC-49",
+        "AC-52",
+        "AC-55"
+      ],
+      "depends": [
+        "T229"
+      ],
+      "pair": "T233",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T233",
+      "wave": 8,
+      "kind": "实现",
+      "title": "终态Pod正常删除准入收尾",
+      "files": [
+        "src/runtime-manager/runtime_manager/k8s_admission.py"
+      ],
+      "logic": "补SQL已停止且finalizer已移除的kubelet正常收尾分支;manager force-delete不放宽。",
+      "acs": [
+        "AC-35",
+        "AC-48",
+        "AC-49",
+        "AC-52",
+        "AC-55"
+      ],
+      "depends": [
+        "T232"
+      ],
+      "pair": "T232",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T234",
+      "wave": 8,
+      "kind": "测试",
+      "title": "同步业务E2E请求预算配对",
+      "files": [
+        "src/backend/test/app_runtime/test_k8s_e2e_contract.py"
+      ],
+      "logic": "成功授权的精确同步preview/stop/resume/delete使用settings预算,其他请求仍短截止;总预算取消且POST仅一次,不重试未知结果。",
+      "acs": [
+        "AC-24",
+        "AC-25",
+        "AC-28",
+        "AC-33",
+        "AC-36",
+        "AC-49",
+        "AC-55"
+      ],
+      "depends": [
+        "T233"
+      ],
+      "pair": "T235",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T235",
+      "wave": 8,
+      "kind": "实现",
+      "title": "共享E2E同步请求期限适配",
+      "files": [
+        "src/backend/test/e2e/helpers/vibe_runtime.py"
+      ],
+      "logic": "共享helper只为精确同步成功调用覆盖read预算并限制总耗时,连接/写入/pool短而有界;不改产品操作期限、拒绝断言、普通GET或restore202合同。",
+      "acs": [
+        "AC-24",
+        "AC-25",
+        "AC-28",
+        "AC-33",
+        "AC-36",
+        "AC-49",
+        "AC-55"
+      ],
+      "depends": [
+        "T234"
+      ],
+      "pair": "T234",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T236",
+      "wave": 8,
+      "kind": "测试",
+      "title": "维护容器材料身份UID接线配对",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_templates.py"
+      ],
+      "logic": "真实模板到helper身份reader验证migrate/restore具备受控downward PodUID;purge无材料身份、普通app/data-helper不获材料UID、reserved env仍拒绝。",
+      "acs": [
+        "AC-33",
+        "AC-34",
+        "AC-35",
+        "AC-48",
+        "AC-49",
+        "AC-50",
+        "AC-51",
+        "AC-55"
+      ],
+      "depends": [
+        "T235"
+      ],
+      "pair": "T237",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T237",
+      "wave": 8,
+      "kind": "实现",
+      "title": "可信维护容器注入材料PodUID",
+      "files": [
+        "src/runtime-manager/runtime_manager/k8s_templates.py"
+      ],
+      "logic": "仅有material_secret的可信maintenance容器从core/v1 metadata.uid取得材料签名身份绑定;保留无材料身份purge和其他角色隔离,不改helper/API/DDL。",
+      "acs": [
+        "AC-33",
+        "AC-34",
+        "AC-35",
+        "AC-48",
+        "AC-49",
+        "AC-50",
+        "AC-51",
+        "AC-55"
+      ],
+      "depends": [
+        "T236"
+      ],
+      "pair": "T236",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T238",
+      "wave": 9,
+      "kind": "测试",
+      "title": "同名已闭合历史Pod停用配对",
+      "files": [
+        "src/runtime-manager/tests/test_operations_v2.py"
+      ],
+      "logic": "两条已停止且清理完成的历史维护UID不得重新fresh/request_stop;当前服务UID照常停止并等待全操作清理,单一完成标记/未知状态/换UID拒绝。",
+      "acs": [
+        "AC-35",
+        "AC-36",
+        "AC-48",
+        "AC-49",
+        "AC-52",
+        "AC-55"
+      ],
+      "depends": [
+        "T237"
+      ],
+      "pair": "T239",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T239",
+      "wave": 9,
+      "kind": "实现",
+      "title": "停用跳过已闭合历史Pod",
+      "files": [
+        "src/runtime-manager/runtime_manager/operations.py"
+      ],
+      "logic": "仅stop_state=confirmed且cleanup_state=complete的Pod跳过重复停止;保留旧操作全资源cleanup等待及当前deadline/controller/UID围栏。",
+      "acs": [
+        "AC-35",
+        "AC-36",
+        "AC-48",
+        "AC-49",
+        "AC-52",
+        "AC-55"
+      ],
+      "depends": [
+        "T238"
+      ],
+      "pair": "T238",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T240",
+      "wave": 9,
+      "kind": "测试",
+      "title": "过期停用清理闭环配对",
+      "files": [
+        "src/runtime-manager/tests/test_operations_v2.py"
+      ],
+      "logic": "expired stop不得以自身空资源报app已停止;先timed_out/pending持久回执,再仅同归属旧terminal操作own cleanup;响应未知、换代、换UID、未知writer拒绝,重启可续且首因保留。",
+      "acs": [
+        "AC-35",
+        "AC-36",
+        "AC-48",
+        "AC-49",
+        "AC-52",
+        "AC-55"
+      ],
+      "depends": [
+        "T239"
+      ],
+      "pair": "T241",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T241",
+      "wave": 9,
+      "kind": "实现",
+      "title": "过期停用的有界安全收尾",
+      "files": [
+        "src/runtime-manager/runtime_manager/operations.py"
+      ],
+      "logic": "精确operation_expired且fresh deadline已过才记timeout;终态pending保留active fence,以旧terminal操作原有自身cleanup逐轮收尾,最终全app停止guard仍由backend验证。无新业务动作/期限延长/跨op过期授权。",
+      "acs": [
+        "AC-35",
+        "AC-36",
+        "AC-48",
+        "AC-49",
+        "AC-52",
+        "AC-55"
+      ],
+      "depends": [
+        "T240"
+      ],
+      "pair": "T240",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T242",
+      "wave": 9,
+      "kind": "测试",
+      "title": "离线数据CPU真实预占配对",
+      "files": [
+        "src/backend/test/app_runtime/test_runtime_data_sessions.py",
+        "src/backend/test/app_runtime/test_runtime_data_repository.py"
+      ],
+      "logic": "offline frozen tier及实际预占均128Mi/100m,99m拒绝/100m允许且拒绝无残留;online保持25m且不增预占,60秒/租户/并发2和mutex保持。",
+      "acs": [
+        "AC-18",
+        "AC-33",
+        "AC-43",
+        "AC-48",
+        "AC-49",
+        "AC-55"
+      ],
+      "depends": [
+        "T241"
+      ],
+      "pair": "T243",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T243",
+      "wave": 9,
+      "kind": "实现",
+      "title": "离线数据Pod预占100m CPU",
+      "files": [
+        "src/backend/bisheng/app_runtime/domain/services/runtime_data_session_service.py",
+        "src/backend/bisheng/app_runtime/domain/repositories/runtime_delivery_repository.py"
+      ],
+      "logic": "仅offline可信tier与实际容量预占由25m调100m;原同步响应、60秒期限、票据、并发和停止释放规则不变。",
+      "acs": [
+        "AC-18",
+        "AC-33",
+        "AC-43",
+        "AC-48",
+        "AC-49",
+        "AC-55"
+      ],
+      "depends": [
+        "T242"
+      ],
+      "pair": "T242",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T244",
+      "wave": 9,
+      "kind": "测试",
+      "title": "离线数据模板CPU与平台tier配对",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_templates.py"
+      ],
+      "logic": "data-layout和data-helper按可信plan CPU100m生成requests=limits;旧冻结25m模板不变,serve sidecar及其他helper保持25m,内存/权限/期限不变。",
+      "acs": [
+        "AC-18",
+        "AC-33",
+        "AC-43",
+        "AC-48",
+        "AC-49",
+        "AC-55"
+      ],
+      "depends": [
+        "T241"
+      ],
+      "pair": "T245",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T245",
+      "wave": 9,
+      "kind": "实现",
+      "title": "离线数据helper沿用可信tier CPU",
+      "files": [
+        "src/runtime-manager/runtime_manager/k8s_templates.py"
+      ],
+      "logic": "仅purpose=data的helper使用plan.cpu_millicores,在线sidecar与其他helper25m保持;无helper镜像/descriptor/网络/API变更。",
+      "acs": [
+        "AC-18",
+        "AC-33",
+        "AC-43",
+        "AC-48",
+        "AC-49",
+        "AC-55"
+      ],
+      "depends": [
+        "T244"
+      ],
+      "pair": "T244",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T246",
+      "wave": 7,
+      "kind": "测试",
+      "title": "应用包可执行文件路径配对",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_templates.py"
+      ],
+      "logic": "真实static镜像nginx位于/usr/sbin;rendered PATH在probe/preview/serve保留依赖bin优先及标准系统sbin/bin,image模式不覆盖镜像PATH;既有PATH注入拒绝保持。",
+      "acs": [
+        "AC-24",
+        "AC-25",
+        "AC-27",
+        "AC-54",
+        "AC-55"
+      ],
+      "depends": [
+        "T245"
+      ],
+      "pair": "T247",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T247",
+      "wave": 7,
+      "kind": "实现",
+      "title": "应用包保留标准系统可执行路径",
+      "files": [
+        "src/runtime-manager/runtime_manager/k8s_templates.py"
+      ],
+      "logic": "仅bundle受控PATH补/usr/local/sbin、/usr/sbin、/sbin;不改冻结command/entrypoint/args/profile,不改image模式及用户PATH拒绝。",
+      "acs": [
+        "AC-24",
+        "AC-25",
+        "AC-27",
+        "AC-54",
+        "AC-55"
+      ],
+      "depends": [
+        "T246"
+      ],
+      "pair": "T246",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T248",
+      "wave": 7,
+      "kind": "测试",
+      "title": "OCI 目录继承权限与危险位配对",
+      "files": [
+        "src/runtime-manager/tests/test_oci_assembler.py"
+      ],
+      "logic": "source/environment继承fsGroup的SGID目录可归档且不携带特殊位;SUID目录、SUID/SGID普通文件仍拒绝,既有链接/硬链接/whiteout与特殊文件拒绝保持。",
+      "acs": [
+        "AC-15",
+        "AC-16",
+        "AC-25",
+        "AC-27",
+        "AC-28",
+        "AC-42",
+        "AC-54"
+      ],
+      "depends": [
+        "T247"
+      ],
+      "pair": "T249",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T249",
+      "wave": 7,
+      "kind": "实现",
+      "title": "OCI 归档移除目录继承的SGID位",
+      "files": [
+        "src/runtime-manager/runtime_manager/oci_assembler.py"
+      ],
+      "logic": "仅允许目录的继承SGID,由已有mode &0777去除;目录SUID及所有非目录SUID/SGID继续拒绝,不改OCI根布局、凭据挂载、冻结基础镜像和镜像推送合同。",
+      "acs": [
+        "AC-15",
+        "AC-16",
+        "AC-25",
+        "AC-27",
+        "AC-28",
+        "AC-42",
+        "AC-54"
+      ],
+      "depends": [
+        "T248"
+      ],
+      "pair": "T248",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T250",
+      "wave": 7,
+      "kind": "测试",
+      "title": "OCI 镜像可执行路径配对",
+      "files": [
+        "src/runtime-manager/tests/test_oci_assembler.py"
+      ],
+      "logic": "固定crane配置在三个运行时保留依赖bin优先与标准sbin/bin,static入口可找到/usr/sbin/nginx;不接受用户PATH替换,不改变冻结镜像或command。",
+      "acs": [
+        "AC-24",
+        "AC-25",
+        "AC-27",
+        "AC-28",
+        "AC-54"
+      ],
+      "depends": [
+        "T249"
+      ],
+      "pair": "T251",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T251",
+      "wave": 7,
+      "kind": "实现",
+      "title": "OCI 镜像保留标准系统可执行路径",
+      "files": [
+        "src/runtime-manager/runtime_manager/oci_assembler.py"
+      ],
+      "logic": "源码镜像固定PATH补标准sbin,与已实测T247的bundle合同一致;不改base/entrypoint/command/registry/凭据和用户可配置项。",
+      "acs": [
+        "AC-24",
+        "AC-25",
+        "AC-27",
+        "AC-28",
+        "AC-54"
+      ],
+      "depends": [
+        "T250"
+      ],
+      "pair": "T250",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T254",
+      "wave": 8,
+      "kind": "测试",
+      "title": "安装探测覆盖实际代理上游策略与服务隔离",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_installation.py"
+      ],
+      "logic": "仓库上游TCP不可达时不得执行外部crane或记registry-push通过;控制canary匹配egress-proxy真实策略并保持NotReady,不进入业务Service端点;固定origin解析和失败关闭配对。",
+      "acs": [
+        "AC-13",
+        "AC-16",
+        "AC-20",
+        "AC-28",
+        "AC-54",
+        "AC-55"
+      ],
+      "depends": [
+        "T251"
+      ],
+      "pair": "T255",
+      "command": "1628 passed / 13 skipped; independent 18; Ruff/format/arch-guard",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T255",
+      "wave": 8,
+      "kind": "实现",
+      "title": "安装仓库探测增加受限代理上游检查",
+      "files": [
+        "scripts/verify_k8s_runtime.py",
+        "deploy/kubernetes/app-runtime/README.md"
+      ],
+      "logic": "在原registry外部权限检查前,复用无凭据control canary按egress-proxy策略验证已配置registry origin的TCP可达;保持NotReady并核Service不发布未Ready地址;域名解析失败拒绝,不更改helper/manager/摘要/API。文档说明DNAT后目的端口与TCP、外部TLS及实际业务推送证据边界。",
+      "acs": [
+        "AC-13",
+        "AC-16",
+        "AC-20",
+        "AC-28",
+        "AC-54",
+        "AC-55"
+      ],
+      "depends": [
+        "T254"
+      ],
+      "pair": "T254",
+      "command": "1628 passed / 13 skipped; independent 18; Ruff/format/arch-guard",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T256",
+      "wave": 8,
+      "kind": "测试",
+      "title": "准备阶段取消、围栏与截止时间配对",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_prepare_cancellation.py",
+        "src/runtime-manager/tests/test_k8s_diagnostic_contract.py"
+      ],
+      "logic": "实际阶段循环覆盖取消/围栏/期限与失败关闭及正常同UID停止,不以调用次数替代业务结果。",
+      "acs": [
+        "AC-43",
+        "AC-48",
+        "AC-49",
+        "AC-52",
+        "AC-55"
+      ],
+      "depends": [
+        "T255"
+      ],
+      "pair": "T257",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T257",
+      "wave": 8,
+      "kind": "实现",
+      "title": "准备阶段持续核对持久操作状态",
+      "files": [
+        "src/runtime-manager/runtime_manager/executors/kubernetes.py"
+      ],
+      "logic": "复用已有操作状态检查,进入、轮询、提交结果前核对;保留原期限、清理与停止证明。",
+      "acs": [
+        "AC-43",
+        "AC-48",
+        "AC-49",
+        "AC-52",
+        "AC-55"
+      ],
+      "depends": [
+        "T256"
+      ],
+      "pair": "T256",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T258",
+      "wave": 8,
+      "kind": "测试",
+      "title": "报告失效与保护基线的runtime配对",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_protection_report.py",
+        "src/runtime-manager/tests/test_k8s_capabilities.py"
+      ],
+      "logic": "真实能力计算覆盖TTL/helper-only未知、保护字段变化、坏/重复证据、cold restart和真实failed;旧实现先红,保留readiness fail-closed及原descriptor摘要。",
+      "acs": [
+        "AC-07",
+        "AC-30",
+        "AC-31",
+        "AC-35",
+        "AC-40",
+        "AC-48",
+        "AC-49",
+        "AC-52",
+        "AC-55"
+      ],
+      "depends": [
+        "T257"
+      ],
+      "pair": "T259",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T259",
+      "wave": 8,
+      "kind": "实现",
+      "title": "runtime分别报告readiness和保护观察",
+      "files": [
+        "src/runtime-manager/runtime_manager/k8s_capabilities.py",
+        "src/runtime-manager/runtime_manager/executors/kubernetes.py"
+      ],
+      "logic": "新增严格版本化protection_observation与排除仅report/helper的基线摘要;仅两种可证明report-only未知不推导历史gap,当前能力仍false;其他不确定继续unconfirmed。",
+      "acs": [
+        "AC-07",
+        "AC-30",
+        "AC-31",
+        "AC-35",
+        "AC-40",
+        "AC-48",
+        "AC-49",
+        "AC-52",
+        "AC-55"
+      ],
+      "depends": [
+        "T258"
+      ],
+      "pair": "T258",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T260",
+      "wave": 8,
+      "kind": "测试",
+      "title": "平台历史保护与当前停止确认配对",
+      "files": [
+        "src/backend/test/app_runtime/test_runtime_protection_report.py"
+      ],
+      "logic": "真实SQLite事务覆盖同基线未知重复心跳/恢复、真实failed不可逆、基线变化/协议降级/无首次基线拒绝、当前保护未知不得确认停止及旧false不恢复。",
+      "acs": [
+        "AC-07",
+        "AC-30",
+        "AC-31",
+        "AC-35",
+        "AC-40",
+        "AC-48",
+        "AC-49",
+        "AC-52",
+        "AC-55"
+      ],
+      "depends": [
+        "T257"
+      ],
+      "pair": "T261",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T261",
+      "wave": 8,
+      "kind": "实现",
+      "title": "平台保留报告未知的历史事实并阻止当前释放",
+      "files": [
+        "src/backend/bisheng/app_runtime/domain/services/runtime_operation_service.py",
+        "src/backend/bisheng/app_runtime/domain/repositories/runtime_kubernetes_repository.py"
+      ],
+      "logic": "仅可信旧同基线+严格report-only元数据保留历史标记,节点blocked;实际失败/基线变更仍降级。首次停止须当前对应保护true,已确认幂等与not_created不变;不回填旧false。",
+      "acs": [
+        "AC-07",
+        "AC-30",
+        "AC-31",
+        "AC-35",
+        "AC-40",
+        "AC-48",
+        "AC-49",
+        "AC-52",
+        "AC-55"
+      ],
+      "depends": [
+        "T260"
+      ],
+      "pair": "T260",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T262",
+      "wave": 8,
+      "kind": "测试",
+      "title": "热刷新跨次读取的心跳一致性配对",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_protection_heartbeat.py"
+      ],
+      "logic": "实际OperationExecutor与Kube executor组合,expired→fresh/current→failed投射序列先红后绿;单次心跳同一descriptor/check快照,下次自然刷新,strict JSON与旧fake/Docker兼容。",
+      "acs": [
+        "AC-07",
+        "AC-30",
+        "AC-31",
+        "AC-35",
+        "AC-48",
+        "AC-49",
+        "AC-52",
+        "AC-55"
+      ],
+      "depends": [
+        "T259"
+      ],
+      "pair": "T263",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T263",
+      "wave": 8,
+      "kind": "实现",
+      "title": "心跳使用一次完整能力和报告快照",
+      "files": [
+        "src/runtime-manager/runtime_manager/operations.py"
+      ],
+      "logic": "Kube提供原子heartbeat_snapshot,既有capabilities兼容;OperationExecutor优先一次快照提交,原Docker和无新方法的fake接口保持。矛盾输入仍由backend拒绝,不以宽容矛盾报告代替修复。",
+      "acs": [
+        "AC-07",
+        "AC-30",
+        "AC-31",
+        "AC-35",
+        "AC-48",
+        "AC-49",
+        "AC-52",
+        "AC-55"
+      ],
+      "depends": [
+        "T262"
+      ],
+      "pair": "T262",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T067",
+      "wave": 8,
+      "kind": "测试",
+      "title": "Docker 与平台回归",
+      "files": [
+        "features/v3.0.0/059-k8s-runtime-backend/docker-regression.md",
+        "features/v3.0.0/059-k8s-runtime-backend/docker-regression.json"
+      ],
+      "logic": "运行相同原有测试选择与首轮基线比对;三种交付/状态/SDK/附件/数据/权限,标真实与模拟;原F086共享修复按release来源继承。",
+      "acs": [
+        "AC-01",
+        "AC-02",
+        "AC-04",
+        "AC-10",
+        "AC-24",
+        "AC-36",
+        "AC-37",
+        "AC-46",
+        "AC-53",
+        "AC-56"
+      ],
+      "depends": [
+        "T066"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "pending",
+      "review": "pending"
+    },
+    {
+      "id": "T068",
+      "wave": 8,
+      "kind": "测试",
+      "title": "189 专用资源安装验收",
+      "files": [
+        "features/v3.0.0/059-k8s-runtime-backend/e2e-evidence/installation.json",
+        "features/v3.0.0/059-k8s-runtime-backend/e2e-evidence/installation.md"
+      ],
+      "logic": "在192.168.106.189重新只读核对UID,再安装专用e2e-f059资源、合格local PV/PVC和镜像;验证真实拒绝与允许、包源/材料/SDK连通,保留原资源清单,不修改既有kube-system。",
+      "acs": [
+        "AC-03",
+        "AC-12",
+        "AC-13",
+        "AC-14",
+        "AC-16",
+        "AC-19",
+        "AC-20",
+        "AC-21",
+        "AC-22",
+        "AC-23",
+        "AC-30",
+        "AC-31",
+        "AC-32",
+        "AC-33",
+        "AC-34",
+        "AC-39",
+        "AC-42",
+        "AC-54",
+        "AC-55"
+      ],
+      "depends": [
+        "T065"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T069",
+      "wave": 8,
+      "kind": "测试",
+      "title": "真实平台发布审批及数据验收",
+      "files": [
+        "features/v3.0.0/059-k8s-runtime-backend/e2e-evidence/platform.json",
+        "features/v3.0.0/059-k8s-runtime-backend/e2e-evidence/platform.md"
+      ],
+      "logic": "首发/更新/容量不足前后阶段、bundle三语言/完整镜像/source-image、审批预览、owner与非owner、SDK/OBO/附件、停用查改导出和恢复删除。记录HTTP及SQL操作/PVCUID/材料与镜像摘要。",
+      "acs": [
+        "AC-02",
+        "AC-04",
+        "AC-05",
+        "AC-08",
+        "AC-10",
+        "AC-11",
+        "AC-17",
+        "AC-18",
+        "AC-24",
+        "AC-25",
+        "AC-26",
+        "AC-27",
+        "AC-28",
+        "AC-29",
+        "AC-33",
+        "AC-35",
+        "AC-36",
+        "AC-37",
+        "AC-44",
+        "AC-45",
+        "AC-46",
+        "AC-47",
+        "AC-50",
+        "AC-51",
+        "AC-55"
+      ],
+      "depends": [
+        "T068"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T070",
+      "wave": 8,
+      "kind": "测试",
+      "title": "故障与升级卸载验收",
+      "files": [
+        "features/v3.0.0/059-k8s-runtime-backend/e2e-evidence/fault-upgrade.json",
+        "features/v3.0.0/059-k8s-runtime-backend/e2e-evidence/fault-upgrade.md"
+      ],
+      "logic": "initImagePull/材料拒绝、取消、manager SIGKILL/并发/晚到请求、同名UID、未知写入者、快照确认/DDL后失败、HTTP/WS/出站撤权、升级manager保留Pod/PVC/profile、卸载保留资产;不能以单节点证明跨节点fencing。",
+      "acs": [
+        "AC-06",
+        "AC-07",
+        "AC-09",
+        "AC-18",
+        "AC-35",
+        "AC-40",
+        "AC-41",
+        "AC-43",
+        "AC-47",
+        "AC-48",
+        "AC-49",
+        "AC-50",
+        "AC-51",
+        "AC-52",
+        "AC-53",
+        "AC-55"
+      ],
+      "depends": [
+        "T069"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "pending",
+      "review": "pending"
+    },
+    {
+      "id": "T071",
+      "wave": 8,
+      "kind": "测试",
+      "title": "模型静态兼容与发行证据",
+      "files": [
+        "features/v3.0.0/059-k8s-runtime-backend/model-release-validation.md",
+        "features/v3.0.0/059-k8s-runtime-backend/model-release-validation.json"
+      ],
+      "logic": "按已确认口径只检查DM8/MySQL模型、类型/约束/登记和单Alembic head;另收CI双架构镜像/锁/离线物料证据,未执行架构和跨节点项明确限制,不算已测。",
+      "acs": [
+        "AC-15",
+        "AC-39",
+        "AC-42",
+        "AC-48",
+        "AC-54",
+        "AC-55",
+        "AC-56"
+      ],
+      "depends": [
+        "T070",
+        "T067"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "pending",
+      "review": "pending"
+    },
+    {
+      "id": "T264",
+      "wave": 8,
+      "kind": "测试",
+      "title": "CLI v2 容量错误码配对",
+      "files": [
+        "src/bisheng-cli/tests/test_errors.py",
+        "src/bisheng-cli/tests/test_command_deploy_wait.py"
+      ],
+      "logic": "通过真实 error_from_platform 和 deploy failure 处理复现 v2 16125 被降为 exit19;覆盖16125与既有16226均为exit14、其他未知码继续降级,验证脱敏输出及原响应语义。",
+      "acs": [
+        "AC-02",
+        "AC-08"
+      ],
+      "depends": [
+        "T261"
+      ],
+      "pair": "T265",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T265",
+      "wave": 8,
+      "kind": "实现",
+      "title": "CLI 兼容 v2 容量不足并同步分发 wheel",
+      "files": [
+        "src/bisheng-cli/bisheng_cli/errors.py",
+        "src/backend/bisheng/dev_toolkit/artifacts/bisheng_cli-3.0.0-py3-none-any.whl",
+        "src/backend/bisheng/dev_toolkit/artifacts/manifest.json"
+      ],
+      "logic": "仅将既有16125映射EXIT_CAPACITY并复用容量提示;不改backend返回码或对外协议。按现有pack_cli_wheel.sh重建实际分发目录的wheel并同步manifest.cli.sha256,SDK物料不变,校验源码与wheel errors.py一致,并运行CLI相关回归。",
+      "acs": [
+        "AC-02",
+        "AC-08"
+      ],
+      "depends": [
+        "T264"
+      ],
+      "pair": "T264",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T266",
+      "wave": 8,
+      "kind": "测试",
+      "title": "真实销毁事实阶段配对",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_destroy_fact_contract.py"
+      ],
+      "logic": "真实OperationExecutor进入K8s销毁链,经实际ExecutionFactInput校验复现admitted事实422;验证合法非终态进度在purge前持久、同操作结果入账后才清理PVC、失败不提前完成、已有进度重启不重复执行,Docker保持。",
+      "acs": [
+        "AC-33",
+        "AC-35",
+        "AC-46",
+        "AC-49",
+        "AC-50",
+        "AC-52"
+      ],
+      "depends": [
+        "T263"
+      ],
+      "pair": "T267",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T267",
+      "wave": 8,
+      "kind": "实现",
+      "title": "销毁前持久化合法维护阶段",
+      "files": [
+        "src/runtime-manager/runtime_manager/operations.py"
+      ],
+      "logic": "K8s显式销毁在执行purge前记录既有合法维护阶段,使维护结果可持久化并用于PVC删除准入;不放宽backend阶段schema、不把admitted映射为completed、不改变停止围栏或Docker路径。",
+      "acs": [
+        "AC-33",
+        "AC-35",
+        "AC-46",
+        "AC-49",
+        "AC-50",
+        "AC-52"
+      ],
+      "depends": [
+        "T266"
+      ],
+      "pair": "T266",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T268",
+      "wave": 8,
+      "kind": "测试",
+      "title": "真实销毁 PVC 异步删除配对",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_purge_cleanup.py"
+      ],
+      "logic": "真实维护/执行链覆盖同UID PVC异步删除、已持久purge结果接管及期限/取消/控制权/UID/API拒绝配对。",
+      "acs": [
+        "AC-35",
+        "AC-46",
+        "AC-49",
+        "AC-50",
+        "AC-52",
+        "AC-55"
+      ],
+      "depends": [
+        "T267"
+      ],
+      "pair": "T269",
+      "command": "pytest -q tests/test_k8s_purge_cleanup.py",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T269",
+      "wave": 8,
+      "kind": "实现",
+      "title": "等待已受理 PVC 删除的正常收尾",
+      "files": [
+        "src/runtime-manager/runtime_manager/operations.py"
+      ],
+      "logic": "仅将K8s purge_volume_cleanup_pending纳入原期限内的既有清理等待,不放宽UID/停止/控制权,也不重建已确认purge helper。",
+      "acs": [
+        "AC-35",
+        "AC-46",
+        "AC-49",
+        "AC-50",
+        "AC-52",
+        "AC-55"
+      ],
+      "depends": [
+        "T268"
+      ],
+      "pair": "T268",
+      "command": "HELM_BIN=/private/tmp/f059-installer-tools/helm pytest -q tests",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T270",
+      "wave": 8,
+      "kind": "测试",
+      "title": "恢复后的服务实例注册配对",
+      "files": [
+        "src/backend/test/app_runtime/test_runtime_restore_resume.py"
+      ],
+      "logic": "真实SQLite/Service和实际manager模板验证在线恢复后serve注册;仅已确认restore结果和resume=true可继续,restore-only/坏参数/阶段/代际/租约/UID/未停止写入者拒绝。",
+      "acs": [
+        "AC-05",
+        "AC-13",
+        "AC-15",
+        "AC-47",
+        "AC-48",
+        "AC-49",
+        "AC-50",
+        "AC-52"
+      ],
+      "depends": [
+        "T269"
+      ],
+      "pair": "T271",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T271",
+      "wave": 8,
+      "kind": "实现",
+      "title": "接通显式恢复后的服务实例注册",
+      "files": [
+        "src/backend/bisheng/app_runtime/domain/services/runtime_kubernetes_service.py"
+      ],
+      "logic": "仅为resume_after_restore=true且同操作恢复结果已持久的合法启动阶段接通serve,复用已有资源冻结/租约/旧writer停止保护;不改材料、路由、SDK、schema或Docker。",
+      "acs": [
+        "AC-05",
+        "AC-13",
+        "AC-15",
+        "AC-47",
+        "AC-48",
+        "AC-49",
+        "AC-50",
+        "AC-52"
+      ],
+      "depends": [
+        "T270"
+      ],
+      "pair": "T270",
+      "command": "",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T272",
+      "wave": 8,
+      "kind": "测试",
+      "title": "控制器暂态拒绝不得终结原准备操作",
+      "files": [
+        "src/runtime-manager/tests/test_k8s_controller_restart.py",
+        "src/runtime-manager/tests/test_operations_v2.py"
+      ],
+      "logic": "真实执行链覆盖旧租约暂态拒绝不claim/失败/清理,正常对账取得新epoch后按原操作/期限/进度续办;合法租约、Docker与业务失败保持。",
+      "acs": [
+        "AC-43",
+        "AC-48",
+        "AC-49",
+        "AC-52",
+        "AC-55"
+      ],
+      "depends": [
+        "T271"
+      ],
+      "pair": "T273",
+      "command": "targeted lease handoff regression; see task-reviews.md T272/T273",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T273",
+      "wave": 8,
+      "kind": "实现",
+      "title": "在业务失败处理范围外取得K8s控制器租约",
+      "files": [
+        "src/runtime-manager/runtime_manager/operations.py"
+      ],
+      "logic": "K8s在操作执行前取得控制权,暂态拒绝不进入业务_failure;原期限、模板、后端授权和Docker不变。",
+      "acs": [
+        "AC-43",
+        "AC-48",
+        "AC-49",
+        "AC-52",
+        "AC-55"
+      ],
+      "depends": [
+        "T272"
+      ],
+      "pair": "T272",
+      "command": "targeted lease handoff regression; see task-reviews.md T272/T273",
+      "status": "complete",
+      "review": "PASS"
+    },
+    {
+      "id": "T072",
+      "wave": 9,
+      "kind": "审查",
+      "title": "L2 代码审查及交付核对",
+      "files": [
+        "features/v3.0.0/059-k8s-runtime-backend/code-review.md",
+        "features/v3.0.0/059-k8s-runtime-backend/delivery-validation.json"
+      ],
+      "logic": "按sdd-code-review技能以755d4456e5及release-line确认基准审查真实diff,修复边界/并发/权限/信息泄漏;核对所有任务L1和AC证据、Docker保留,更新design现状和交付证据;不自动合并/发tag。",
+      "acs": [
+        "AC-01",
+        "AC-02",
+        "AC-03",
+        "AC-04",
+        "AC-05",
+        "AC-06",
+        "AC-07",
+        "AC-08",
+        "AC-09",
+        "AC-10",
+        "AC-11",
+        "AC-12",
+        "AC-13",
+        "AC-14",
+        "AC-15",
+        "AC-16",
+        "AC-17",
+        "AC-18",
+        "AC-19",
+        "AC-20",
+        "AC-21",
+        "AC-22",
+        "AC-23",
+        "AC-24",
+        "AC-25",
+        "AC-26",
+        "AC-27",
+        "AC-28",
+        "AC-29",
+        "AC-30",
+        "AC-31",
+        "AC-32",
+        "AC-33",
+        "AC-34",
+        "AC-35",
+        "AC-36",
+        "AC-37",
+        "AC-39",
+        "AC-40",
+        "AC-41",
+        "AC-42",
+        "AC-43",
+        "AC-44",
+        "AC-45",
+        "AC-46",
+        "AC-47",
+        "AC-48",
+        "AC-49",
+        "AC-50",
+        "AC-51",
+        "AC-52",
+        "AC-53",
+        "AC-54",
+        "AC-55",
+        "AC-56"
+      ],
+      "depends": [
+        "T071"
+      ],
+      "pair": null,
+      "command": "",
+      "status": "pending",
+      "review": "pending"
+    },
+    {
+      "id": "T274",
+      "wave": 8,
+      "kind": "基础设施",
+      "title": "同版本镜像发布入口串行",
+      "files": [
+        ".github/workflows/runtime-images.yml",
+        "src/runtime-manager/tests/test_image_release.py"
+      ],
+      "logic": "按实际release标识共享同仓库发布并发锁;保留外部写入与正式发行边界。",
+      "acs": [
+        "AC-39",
+        "AC-54",
+        "AC-56"
+      ],
+      "depends": [
+        "T065"
+      ],
+      "pair": null,
+      "command": "python -m pytest -q tests/test_image_release.py::test_ci_keeps_native_gate_and_immutable_digest_indexes",
+      "status": "complete",
+      "review": "PASS"
+    }
+  ]
+}
diff --git a/features/v3.0.0/059-k8s-runtime-backend/tasks-review.md b/features/v3.0.0/059-k8s-runtime-backend/tasks-review.md
new file mode 100644
index 0000000000..4f11db0ef5
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/tasks-review.md
@@ -0,0 +1,157 @@
+# F059 tasks 评审
+
+**结论:LGTM。** 2026-10-21,用户已确认设计;按 sdd-review tasks-checklist 21 项由同一代理检查,没有独立代理审查。
+
+- 任务清单:[tasks.md](./tasks.md),245 项、112 个测试任务,55 条有效 AC。
+- 设计确认 SHA-256:`b846e993909cda416502d33fdbf6b2174357068673840c2fc0f7d75fe9dc76a4`;该摘要记录批准时快照,实施中现状按L1更新,D1–D8不变。
+- 索引:[tasks-index.json](./tasks-index.json);严格结果:[tasks-review.txt](./tasks-review.txt)。
+
+| # | 检查 | 依据 | 结果 |
+| --- | --- | --- | --- |
+| 1 | AC追溯 | 55 条有效 AC 均有测试任务覆盖;AC-38 退役 | PASS |
+| 2 | 测试AC标注 | 112 个测试任务全部明确标注 AC | PASS |
+| 3 | Test-First | 所有实现任务依赖排在前面的配对测试;配置/模型/锁与安装清单走基础设施规则 | PASS |
+| 4 | 依赖 | 245 个唯一 ID,全部依赖存在并指向前序,无环 | PASS |
+| 5 | 原子文件范围 | 常规1–2文件;T230/T231取消闸跨3文件共6生产行,保持单根因原子性;真实验收独立 | PASS |
+| 6 | 自包含 | 每任务明确类型、文件、逻辑/失败验收、AC、依赖与配对 | PASS |
+| 7 | 粒度 | 不跨前后端,没有新UI任务;执行器和跨进程服务分别实现 | PASS |
+| 8 | 顺序 | 先控制/材料接口、后执行器接入,再数据和发行;基线先跑 | PASS |
+| 9 | 重复修改 | 同文件跨任务均为增量接入,例如材料Service→egress投影;无重复替换同功能 | PASS |
+| 10 | 规格覆盖 | 三交付、进程权限/隔离、材料、数据、安装、升级与真实验收均有任务 | PASS |
+| 11 | 接口 | 客户端→服务合同、helper→adapter、事实→路由等任务标前驱产出 | PASS |
+| 12 | 范围 | 仅已确认D1–D8与55AC;不增加商业、UI、任意Dockerfile或新权限Owner | PASS |
+| 13 | AC完整ID | 逐条 AC-NN,无缩写或区间 | PASS |
+| 14 | 测试纯净 | 测试任务只定义/执行验证,不混入实现代码 | PASS |
+| 15 | 无延迟债 | 任务没有用TODO/FIXME/HACK推迟本期能力 | PASS |
+| 16 | 回滚 | 扩展DDL、旧数据回填/降级检查分离运维前置,保留旧Docker语义 | PASS |
+| 17 | 跨域副作用 | 公共F086修复先核对发布线来源;平台业务状态/审批/权限Owner保持 | PASS |
+| 18 | 前端分区 | N/A,无前端实现;不使用错误状态管理参考 | PASS |
+| 19 | Worker租户 | N/A,无新Celery业务;如实际出现必须登记headers→ContextVar | PASS |
+| 20 | 基础设施先行 | Wave1供给配置/模型/迁移/锁;Wave7是依赖已完运行模块的安装物,不是延迟业务基础 | PASS |
+| 21 | 测试降级 | 未降级;真实DM8按用户口径静态检查,集群/平台E2E与Docker证据单独验收 | PASS |
+
+## 审查修订
+
+明确迁移 revision 仅 DDL,回填/清理/降级数据前置位于独立运维流程;修正架构守卫命令为逐文件调用,避免不带参数时直接退出造成假通过。依赖、配对顺序、文件数、AC集合均由静态检查确认。
+
+## 下一步
+
+依据SDD-Guide第3节,tasks 无新增人审暂停点;进入按波次实施和逐任务L1。仍有任务未完成时不声明F059交付,模拟/单元验证不能代替真实189及平台验收。
+
+2026-10-21实施调整复核:T003通过已注册models包导入新表;T027在main注册固定material根路径。仍为73项/55有效AC、1–2文件、依赖无环、Test-First与配对完整,21项检查结论保持LGTM。
+
+T073增量复核:Vibe专有F086字节计数器继承形成两条已有迁移父链,新增单一无数据操作的汇合revision;两个目标文件、依赖T003、先于T005验收,既有父revision均不改写。全73项依赖/配对/55有效AC复核通过,21项结论仍LGTM。
+
+## 2026-10-22 继承补充复审
+
+按21项清单复核:T074仅汇合实际三条迁移父链,无表/数据效果;T005依赖T074。74个唯一任务、前向依赖、最多两个文件和55条有效AC测试覆盖已重查。继承适配保持既有前端合同,无新增UI需求;其余检查不变。结论:LGTM。
+
+## T023落点补充复审
+
+材料委托Service需同时声明正式部署的owner-bound期限,新增目标为未发布RuntimeMaterialDelegation模型;T023仍为两文件,配对测试/依赖顺序不变。NULL不取消当前归属、UID、批准版本或票据到期检查,operation与preview仍有明确期限。21项清单复核无新增缺口,LGTM。
+
+## T075/T076集成补充复审
+
+裸ASGI路由不经过真实多租户中间件;固定material根路径必须独立浏览器身份。新增测试T075与单文件接线T076,位于T026前;该集成已属于批准AC-15、AC-47、AC-48,不改变spec、D1–D8或权限Owner。76任务、34测试任务,各1–2文件,依赖前向、配对及55有效AC覆盖不变。21项清单逐项复核LGTM;不增加人审暂停点。
+
+## T077/T078缓存接线补充复审
+
+K8s helper不能持manager HMAC查询缓存;由manager读取受信下载阶段计算的input_key,调用既有dependency-plans。backend将选中结果持久到操作,下一下载Pod的材料授权再从SQL推导。这落实已确认缓存/范围授权合同,未改变spec、D1–D8或Owner。新增1文件测试/2文件Service接线,位于T030前;78任务、35测试任务,前向依赖/配对/原子性与55有效AC覆盖复查保持LGTM。
+
+## T079/T080节点协调接线补充复审
+
+现有Heartbeat只写Docker字段,原facts没有controller租约闸口,无法用作已批准K8s执行器。增补1文件测试与2文件后端接线,放在T032之前;stop/ready资源证据使用既有账本,不增加业务Owner或新产品行为。80任务、36测试任务,最多两文件、前向依赖/配对/55有效AC追溯复查;21项清单结论LGTM。资源Service/Repository的恢复细化仍在各原任务文件内单独L1。
+
+## T081/T082 数据会话账本补充复审
+
+原assignment已有data_operation_id字段,现有Repository的create/claim/release未建立数据会话双向互斥,正式Pod的sidecar开销亦未计入预占。按已确认D1/D5和4.8补1文件真实SQL合同、2文件Repository/OperationService接线,放在T042之前;不让Service直接SQL,不改变Docker预算或serving代次。82任务、37测试任务、每任务最多两文件、配对测试先行、前向依赖无环、55有效AC追溯已复查;其余21项检查沿用有效,无规格/设计决策变化。结论:LGTM。
+
+## T083–T087 维护与分块接线补充复审
+
+固定生产名称需保留各阶段UID历史,原operation/kind/name唯一约束妨碍已批准的独占备份→迁移→运行;新增DDL、真实SQL测试及两文件模型/Repository接线。大材料分块原从头跳读,补真实Range合同与两文件存储接线。87项、39测试任务,逐项依赖/配对/1–2文件/55有效AC覆盖及21项检查复核LGTM。均为既定D1/D5/D8实现细化,不改变产品决策;降级不删除历史。
+
+## T088–T093 交付与数据业务入口补充复审
+
+K8s source-image复用批准profile解析需要backend协调接线;数据结果查询与CSV流式响应需要Service和API真实接线,新增三组配对任务。93项、42测试任务,每任务1–2文件;依赖前向无环、配对测试先行、55有效AC覆盖及21项清单复核LGTM。结果查询不重放写操作,不改变权限Owner;原Docker bytes响应与构建不变。
+
+## 并行实施增量复核(2026-10-22)
+
+142个唯一任务、60个测试任务、每项≤2文件;索引与正文一致,前驱及实现任务的配对测试均为前序,55有效AC有测试追溯。先测试后实现及新增配置/工具范围已逐项核查;同文件重复编辑均为红绿证据暴露的增量遗漏,不替换已批准合同。189先验证、109最终平台回归后收总证据,依用户最新批准顺序调整T068/T071前驱。21项复核LGTM;真实验收T067–T072仍未完成。
+
+## 续办现场修复复审
+
+按21项清单复核T145–T174与故障harness合流:174个唯一任务、76个测试任务,55条有效AC测试覆盖;全部文件范围1–2、依赖前向且无环、Markdown与索引顺序及168个完成状态一致。新增实现均配对测试先行,Chart配置走基础设施规则;不新增商业/UI/公共发行。D1–D8不变。真实T067–T072仍未完成,不把本地或原生镜像烟测算作业务验收。结论LGTM。
+
+T175–T182增量复审:终态Core API兼容、真实客户端诊断和真实SQLite哨兵属于已批准合同;182任务、80测试,文件1–2个、前向依赖、55AC覆盖、配对状态和176完成数经脚本核对。其余21项检查结论保持LGTM,真实交付6项仍未完成。
+
+T183/T184按21项复审:仅测试工具,保留全局slug和审计不变量;184个唯一任务、81测试、178完成,范围/前向依赖/配对/55AC均一致。仍有6个真实交付任务待完成,LGTM不等于交付通过。
+
+T185–T188现场缺陷补登记:188唯一任务、83测试;T185/T186已有配对红3/绿92和L1,T187/T188开发中。21项任务审查保持既有AC/owner/配对前置与范围;待完成8项,其中6项真实交付门不能以单测通过代替。
+
+T185–T188完成L1后182/188;188唯一任务与83测试不变,6交付门继续pending。WAL真实Docker仅为局部复现及配对,不替代109完整平台或189数据路径。
+
+T189/T190按现场真实字段补登记。190唯一任务、84测试,182完成;无新公开业务API、没有放宽失败与权限断言。21项任务审查依赖/配对/owner与AC范围通过,真实复验仍待执行。
+
+T191–T193增量21项复核:193唯一任务、85测试、185已完成;每项1文件、前向依赖及配对先行、55有效AC追溯不变。仅受控离线测试物料,不执行pip/网络、不修改产品API/模板/权限;真实source-image失败保留,不能当作预期成功。任务评审LGTM,8项仍待完成。
+
+T194/T195按21项增量复核:195唯一任务、86测试、185完成;各1文件、配对先行、前向依赖、55AC及原owner不变。真实schema/真实操作类型配对验证解析合同,既定设计不变;新增现场缺陷修复不替代真实重部署。LGTM。
+
+T191–T193合流L1后188/195;T194追加OCI真实prepare夹具第二文件(补delivery显式字段),范围仍≤2、先测试后实现、依赖/AC不变。增量21项审查LGTM,真实交付仍pending。
+
+T194/T195完成L1后190/195,86测试;最终正文/索引/状态/前向依赖与每项≤2文件重查。21项增量结论LGTM,五项交付门仍pending;L2未开始。
+
+T196/T197增量21项审查:197唯一任务、87测试、190完成;每项单文件、前向依赖/配对/55AC保持。精确无分配语义与平台故障分开,不用宽松GET fallback代替停止证据;既定合同无变更,LGTM。
+
+T196/T197 L1合流后192/197、87测试;正文/索引/前向依赖/每项≤2文件/55AC保持一致。21项增量复核LGTM,五项交付门pending,L2未开始。
+
+T198/T199增量检查:199唯一任务、88测试、192完成;前向依赖/每项≤2文件/完整AC及测试先行配对保持。仅恢复既定有界controller CAS租约,不将过期视为停止、无新增API/规格偏差;增量LGTM,L1及现场复验pending。
+
+T198/T199合流后194/199、88测试;21项增量复核LGTM,元数据/AC/范围/依赖/配对一致。正文顶部为当前统计,历史增量记录保留各自时间;五交付门仍pending。
+
+T200/T201增量21项复核:201唯一任务、89测试,前向依赖/配对/每任务单文件/完整AC均保持。恢复既定冻结profile合同,不改权限Owner/API/规格;LGTM,实施L1与真实重部署待核。
+
+T202增量与T200/T201 L1合流后197/202、90测试;T202在T201前、前向依赖/配对/每任务1–2文件/55AC保持。21项复核LGTM,五交付门仍pending。
+
+T203–T214增量21项复核:214唯一任务、96测试,按协议前驱重排,1–2文件且配对先行。新增创建事务是已批准创建响应/停止安全合同的失败收尾细化,不把普通404、过期或旧日志当停止证明;未知保留,旧r4不追认。无外部业务API/Owner/DDL变化,暂停arm不取消验收边界;L1与真实复验分别记录。
+
+T203–T214合流后209/214、96测试;正文/索引状态、1–2文件、前向依赖及配对先行脚本核对一致,新增L1已完成。arm按用户新优先级暂停不隐去原失败;真实K8s业务/故障/发行/L2五门仍pending。21项增量复核LGTM。
+
+T215–T218增量21项复核:218唯一任务、98测试、每项1–2文件,前向依赖/配对/55AC不变;211已完成,新nginx根路径补既有材料入口合同。状态及任务数量校验,真实材料传输仍待复验。
+
+T217/T218 L1合流后213/218、98测试;正文/索引状态及1–2文件/依赖/配对一致。缺nginx的本机集成skip与189完整产品配置语法验证分别记账;五交付门未勾,LGTM保持。
+
+T219/T220增量复核:220唯一ID、99测试、215完成,前向依赖/每项1–2文件/配对及完整AC保持;延迟调度绑定为既定身份合同修复,无新外部API或规格偏差,LGTM。
+
+T221–T224增量复核:224任务/101测试/219完成,前向依赖和1–2文件范围通过;None可选锁、终态诊断保留均为已批准合同收尾,无新增API/权限/部署路线,LGTM。五交付门继续pending。
+
+T225–T231增量复核:前向依赖/配对测试、完整AC与文件独占成立;当前231/105测试,实际通过及待验收见任务索引/L1。D1–D8、55AC及人审确认不变。
+
+T232–T235增量复核:新任务完整AC、先测后实现、文件独占与依赖存在/无环成立;终态准入L1已通过,E2E期限修复由C独立两文件开发。当前235项/107测试,尚未以本地通过替代真实验收。
+
+T236/T237材料身份原合同接线补漏:完整AC/前向依赖/两文件独占/先测后实现通过,真实维护失败及原生A/B按层保存,未增规格范围。237项/108测试。
+
+T238/T239增量:完整AC、依赖存在/向前、同文件唯一owner与测试配对均PASS;最小两行生产修复及479本地合同不代替真实停用验收。239项/109测试。
+
+T240/T241增量检查:既定超时/正常清理合同内补漏;完整AC、依赖存在/前向、单owner与配对PASS。241项/110测试;实现与L1仍pending。
+
+T240/T241复审:先红19/65及追加503恢复红2/1绿保留;最终完整scope/响应未知/UID/PVC/归属/首因等96操作通过,相邻511、主树1573/13skip通过;独立同scope复审PASS。已闭合236/241,110测试任务。
+
+T242–T245增量:同一固定100m合同冻结,backend与runtime独立owner/测试先行,完整AC/前向依赖/容量拒绝和旧模板兼容明确;245项/112测试,尚未L1完成。
+
+T242–T245本地L1闭合240/245;backend/runtime分别两行/一行,独占范围及配对前向依赖无环成立,真实预占与Pod峰值合同一致,112测试任务;真实部署与发行/L2不由本地通过代替。
+
+T246/T247增量:已批准三运行时bundle启动合同补漏,非新路线/规格变更;测试先于实现、完整AC、单文件owner与前向依赖保持。247项/113测试,L1及真实复验待完成。
+
+T246/T247 L1完成242/247、113测试;正文/索引/依赖/配对一致。代码最小单行路径补漏,全1595/13skip与独立292通过,原生A/B和真实业务复验分账;五交付门仍保留。
+
+T248–T251增量及L1:251项/115测试,246完成;测试先行、单文件owner、完整AC与前向依赖无环,D7与启动合同内实现补漏,无新增人审门。正式发行/跨架构/真实E2E/L2五交付门保持。
+
+T252/T253 L1增量:253项/116测试、248完成;单文件owner/测试先行/完整AC/配对及依赖无环成立。仅D7受限源码镜像启动合同补漏,无新架构和人审门,五交付项未关闭。
+
+T252/T253更正:固定CLI设置ENTRYPOINT已清除CMD,前述任务为误报,冗余实现/镜像实现型Mock撤回;保留编号及历史记录,从活跃索引移除。246/251、115测试任务;SGID/PATH真实修复不动。
+
+## T266/T267 真实销毁协议补登记
+
+189真实HTTP422暴露内部admitted阶段进入公开事实输入;补一文件真实schema配对测试与一文件调用者修正。265项活跃任务、122测试,258完成;既有AC与D1/D5销毁合同,无新产品决策、权限或对外schema。21项增量检查:AC/配对/前驱T263、顺序、唯一ID、文件Owner/最多1文件、无依赖环与现有边界均保持;测试先红后实现,现场未知请求不能重发。LGTM;真实删除复验及其余E2E/L2尚未通过。
+
+2026-10-23 T270/T271增量复核LGTM:两任务各1文件、前置T269、测试先行、AC完整且header/索引一致;264/269活跃任务完成、124测试任务。全量历史文件数扫描另识别T230/T231/T265为3个目标文件的既有条目,未将本次两任务范围检查冒称历史全量通过,留T072作追溯整理。
diff --git a/features/v3.0.0/059-k8s-runtime-backend/tasks-review.txt b/features/v3.0.0/059-k8s-runtime-backend/tasks-review.txt
new file mode 100644
index 0000000000..b0e4e89c33
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/tasks-review.txt
@@ -0,0 +1 @@
+LGTM
diff --git a/features/v3.0.0/059-k8s-runtime-backend/tasks.md b/features/v3.0.0/059-k8s-runtime-backend/tasks.md
new file mode 100644
index 0000000000..3c9592c84b
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/tasks.md
@@ -0,0 +1,2404 @@
+# Tasks: F059 K8s 应用托管执行后端
+
+**关联**:[spec.md](./spec.md) · [design.md](./design.md) · [任务评审](./tasks-review.md)
+
+## SDD 状态
+
+| 文档/阶段 | 状态 | 依据 |
+| --- | --- | --- |
+| Spec Discovery | ✅ 已确认 | 会话确认开源交付及 K8s 续办范围 |
+| spec.md | ✅ 已评审、用户已确认 | 55 条有效 AC;规格确认不替代设计确认 |
+| design.md | ✅ 已评审、用户已确认 | 用户明确回复“确认设计,进入任务拆解,继续推进 SDD 流程。”;确认正文摘要 b846e993909cda416502d33fdbf6b2174357068673840c2fc0f7d75fe9dc76a4 |
+| tasks.md | ✅ 已拆解、评审通过 | tasks-review.txt 为 LGTM;272 项任务按依赖推进,无新增人审暂停点 |
+| 实现/任务评审 | ✅ 开发实现收口 | 268 / 272 完成;四项剩余为交付验收/汇总门,见 task-reviews.md |
+| E2E/L2审查 | 🟡 分项 E2E 已执行;开发合入 L2 完成 | R32 在途重启、R31 删除、R11 数据和 M2 恢复已有真实证据;完整交付仍待验,见 code-review.md |
+
+## 开发模式
+
+沿用已附加工作树 `codex/059-k8s-runtime@755d4456e5`。主 agent 与三个独立工作树并行开发,文件单一 owner、集成和189部署串行;各任务最多两个目标文件;测试先于对应实现,infra 配置/模型/锁/安装清单无需强行编写镜像实现的测试。独立验证已有批准的拒绝/故障合同。
+
+任务编号用于追溯,不与 AC 编号混用。所有测试任务明确覆盖 AC,基础设施先于业务;新增 backend 测试位于 `src/backend/test/app_runtime/`。本次没有前端页面和 Celery 业务改动;若实际需新增 Worker,必须在任务登记 tenant_id headers→ContextVar 并遵守原 Owner。
+
+### 基线及执行命令
+
+- runtime 单测:在 `src/runtime-manager/` 使用共享 Python 3.11 环境运行 `python -m pytest -q tests`;修改前基线 437 passed / 10 skipped,详见 task-reviews。
+- backend 定向测试:在 `src/backend/` 运行同一环境 `python -m pytest -q test/app_runtime/`;依赖隔离按现有 conftest,数据库类另验真实平台。
+- Ruff:各子项目对本任务文件运行 `ruff format`、`ruff check`;架构门 `bash scripts/arch-guard.sh ` 逐文件执行。
+- 集群验收:仅专用 e2e-f059 资源,使用主机既有配置/安全环境,不把密码写入测试、任务或证据。
+
+## 任务清单
+
+### Wave 1
+
+- [x] **T001** — K8s 配置及部署描述
+  **类型**:基础设施
+  **文件**:`src/runtime-manager/runtime_manager/k8s_config.py`、`src/runtime-manager/runtime_manager/config.py`
+  **逻辑/验收**:新增 RTM_EXECUTOR 与 K8s namespace/cluster/descriptor/token/CA 配置;默认 Docker,K8s 强制 v2,非法形态/namespace/descriptor 拒绝;敏感 token 仅按文件读取,不输出。
+  **覆盖 AC:** AC-01、AC-03、AC-13、AC-39
+  **依赖:** 无
+
+- [x] **T002** — 资源账本、材料授权与控制器字段
+  **类型**:基础设施
+  **文件**:`src/backend/bisheng/app_runtime/domain/models/runtime_kubernetes.py`、`src/backend/bisheng/app_runtime/domain/models/runtime_delivery.py`
+  **逻辑/验收**:按 design 6.2 定义资源 UID/停止证据、委托公钥/范围及 Node controller 租约字段、Transfer 授权字段;继承已有租户记录,使用 JsonType/跨库类型;新 36-char 标识仅用于新增路径,保留旧 ID。
+  **覆盖 AC:** AC-13、AC-15、AC-47、AC-48
+  **依赖:** 无
+
+- [x] **T003** — Schema 扩展与租户登记
+  **类型**:基础设施
+  **文件**:`src/backend/bisheng/core/database/alembic/versions/f059_runtime_kubernetes.py`、`src/backend/bisheng/app_runtime/domain/models/__init__.py`
+  **逻辑/验收**:在真实单 head 后追加扩展 DDL;在已注册的 models 包导入新表以进入租户发现。旧数据回填另列运维前置,不在 revision 做读写变换。降级前由独立运维前置检查停用 K8s/无新引用,未满足则拒绝;revision 只撤列等 DDL,不执行数据回填/清理;保留原 Docker 字段。
+  **覆盖 AC:** AC-15、AC-39、AC-40、AC-48、AC-56
+  **依赖:** T002
+
+- [x] **T004** — 密码依赖的可重复镜像供应
+  **类型**:基础设施
+  **文件**:`src/runtime-manager/pyproject.toml`、`src/runtime-manager/uv.lock`
+  **逻辑/验收**:manager/helper 显式锁定维护中的 cryptography 依赖;Apache/BSD 许可证检查;无现场下载、无自研密码算法,双架构安装与镜像供应由发行任务验收。
+  **覆盖 AC:** AC-15、AC-39、AC-42、AC-54
+  **依赖:** 无
+
+- [x] **T073** — F086 字节计数器继承与迁移汇合
+  **类型**:基础设施
+  **文件**:`src/backend/bisheng/core/database/alembic/versions/f059_merge_f086_heads.py`、`src/backend/test/app_runtime/test_runtime_model_contracts.py`
+  **逻辑/验收**:继承F086 667546bb77的Vibe专有BIGINT修复;模型在通用发布线上不存在。显式merge revision保留两个原parent,恢复单head;静态守卫明确历史授权NULL/待绑定UID NULL,其他约束不放宽;DM8仅静态。
+  **覆盖 AC:** AC-15、AC-39、AC-48、AC-56
+  **依赖:** T003
+
+- [x] **T074** — 发布线继承的迁移图汇合
+  **类型**:基础设施
+  **文件**:`src/backend/bisheng/core/database/alembic/versions/f059_merge_release_heads.py`
+  **逻辑/验收**:合并已授权发布线9d156f261c后,实际出现f059_merge_f086_heads、asr_rt_model_type、merge_skill_center_heads三条head;新增空merge revision保留三条原父链,不改表/数据、不重写历史;以已有单head门禁和模型/账本回归验证。降级只回到原分叉。
+  **覆盖 AC:** AC-39、AC-48、AC-56
+  **依赖:** T073
+
+- [x] **T005** — F086 公共修复继承核对
+  **类型**:基础设施
+  **文件**:`features/v3.0.0/059-k8s-runtime-backend/prerequisites.md`、`features/v3.0.0/059-k8s-runtime-backend/prerequisites.json`
+  **逻辑/验收**:逐文件比对原 F086 的多租户启动、BIGINT 和测试修复与 release-line;记录已有同内容提交或发布线继承方式。禁止直接复制权限分叉;真实平台验收以前必须有来源 SHA/内容证据。
+  **覆盖 AC:** AC-36、AC-55、AC-56
+  **依赖:** T074
+
+### Wave 2
+
+- [x] **T006** — 受限 K8s REST client:契约与失败用例
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_client.py`
+  **逻辑/验收**:以 httpx MockTransport 验 namespace 白名单、token 文件轮转、CA/TLS、固定 Core/Discovery 路由;UID/RV 删除条件、分页、410 relist、限时退避与取消;403/404 不当作停止证明;测试只定义输入/结果,不加入实现。
+  **覆盖 AC:** AC-03、AC-04、AC-13、AC-14、AC-48、AC-49、AC-52
+  **依赖:** T001
+  **配对:** T007
+
+- [x] **T007** — 受限 K8s REST client
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/k8s_client.py`
+  **逻辑/验收**:以 httpx MockTransport 验 namespace 白名单、token 文件轮转、CA/TLS、固定 Core/Discovery 路由;UID/RV 删除条件、分页、410 relist、限时退避与取消;403/404 不当作停止证明;通过配对测试后完成 L1 task-review。
+  **覆盖 AC:** AC-03、AC-04、AC-13、AC-14、AC-48、AC-49、AC-52
+  **依赖:** T001、T006
+  **配对:** T006
+
+- [x] **T008** — Pod/Service/PVC/Secret 模板:契约与失败用例
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_templates.py`
+  **逻辑/验收**:冻结 profile/架构、UID/GID、命令/资源/RO 挂载、单生产位置及按用途卷;不使用 hostPath/特权/SA token,不由应用提供 PodSpec。临时/预览 deadline 不延长,正式 Pod 无总寿命;测试只定义输入/结果,不加入实现。
+  **覆盖 AC:** AC-05、AC-11、AC-12、AC-16、AC-17、AC-18、AC-19、AC-20、AC-21、AC-24、AC-27、AC-33、AC-34、AC-43、AC-44、AC-54
+  **依赖:** T001
+  **配对:** T009
+
+- [x] **T009** — Pod/Service/PVC/Secret 模板
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/k8s_templates.py`
+  **逻辑/验收**:冻结 profile/架构、UID/GID、命令/资源/RO 挂载、单生产位置及按用途卷;不使用 hostPath/特权/SA token,不由应用提供 PodSpec。临时/预览 deadline 不延长,正式 Pod 无总寿命;通过配对测试后完成 L1 task-review。
+  **覆盖 AC:** AC-05、AC-11、AC-12、AC-16、AC-17、AC-18、AC-19、AC-20、AC-21、AC-24、AC-27、AC-33、AC-34、AC-43、AC-44、AC-54
+  **依赖:** T001、T008
+  **配对:** T008
+
+- [x] **T010** — 停止证据三分类与同 UID 收尾:契约与失败用例
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_lifecycle.py`
+  **逻辑/验收**:覆盖未调度删除、init 从未启动、可信 kubelet 终态、NodeLost/404/同名异 UID、陈旧 cache、finalizer 重试;fresh API 证据入账后才释放,未知保留写入占用和唯一备份;测试只定义输入/结果,不加入实现。
+  **覆盖 AC:** AC-08、AC-09、AC-11、AC-35、AC-43、AC-48、AC-49、AC-50、AC-52
+  **依赖:** T007
+  **配对:** T011
+
+- [x] **T011** — 停止证据三分类与同 UID 收尾
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/k8s_lifecycle.py`
+  **逻辑/验收**:覆盖未调度删除、init 从未启动、可信 kubelet 终态、NodeLost/404/同名异 UID、陈旧 cache、finalizer 重试;fresh API 证据入账后才释放,未知保留写入占用和唯一备份;通过配对测试后完成 L1 task-review。
+  **覆盖 AC:** AC-08、AC-09、AC-11、AC-35、AC-43、AC-48、AC-49、AC-50、AC-52
+  **依赖:** T007、T010
+  **配对:** T010
+
+- [x] **T012** — 独立准入和 status 来源约束:契约与失败用例
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_admission.py`
+  **逻辑/验收**:验证 AdmissionReview CREATE/UPDATE/DELETE 的批准模板、角色/范围/UID、过期操作收尾计划;status 规则无副作用/不调用 backend,仅绑定 kubelet 可引入终态。拒绝提权、越界 PVC/Secret、强删及基线变更;测试只定义输入/结果,不加入实现。
+  **覆盖 AC:** AC-03、AC-13、AC-14、AC-15、AC-16、AC-21、AC-23、AC-30、AC-31、AC-32、AC-49
+  **依赖:** T009
+  **配对:** T013
+
+- [x] **T013** — 独立准入和 status 来源约束
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/k8s_admission.py`
+  **逻辑/验收**:验证 AdmissionReview CREATE/UPDATE/DELETE 的批准模板、角色/范围/UID、过期操作收尾计划;status 规则无副作用/不调用 backend,仅绑定 kubelet 可引入终态。拒绝提权、越界 PVC/Secret、强删及基线变更;通过配对测试后完成 L1 task-review。
+  **覆盖 AC:** AC-03、AC-13、AC-14、AC-15、AC-16、AC-21、AC-23、AC-30、AC-31、AC-32、AC-49
+  **依赖:** T009、T012
+  **配对:** T012
+
+### Wave 3
+
+- [x] **T014** — 平台资源账本 Repository:契约与失败用例
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_runtime_kubernetes_repository.py`
+  **逻辑/验收**:以现有内存/SQLite fixture 验租户范围、资源意图幂等/UID绑定、CAS epoch、容量预占和 cleanup 生命周期;Repository 事务内完成并发控制,业务 Service 不拼 ORM SQL;测试只定义输入/结果,不加入实现。
+  **覆盖 AC:** AC-08、AC-13、AC-15、AC-35、AC-48、AC-49、AC-52
+  **依赖:** T002、T003
+  **配对:** T015
+
+- [x] **T015** — 平台资源账本 Repository
+  **类型**:实现
+  **文件**:`src/backend/bisheng/app_runtime/domain/repositories/runtime_kubernetes_repository.py`
+  **逻辑/验收**:以现有内存/SQLite fixture 验租户范围、资源意图幂等/UID绑定、CAS epoch、容量预占和 cleanup 生命周期;Repository 事务内完成并发控制,业务 Service 不拼 ORM SQL;通过配对测试后完成 L1 task-review。
+  **覆盖 AC:** AC-08、AC-13、AC-15、AC-35、AC-48、AC-49、AC-52
+  **依赖:** T002、T003、T014
+  **配对:** T014
+
+- [x] **T016** — 控制器租约和资源事实 Service:契约与失败用例
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_runtime_kubernetes_service.py`
+  **逻辑/验收**:claim/renew holder/expected_epoch,验证固定执行域与有效操作、Service提交资源意图并结束SQL事务,manager随后调用 K8s;错 UID、过期控制器、越界、提前释放拒绝,终态后的只读收尾计划可续办;测试只定义输入/结果,不加入实现。
+  **覆盖 AC:** AC-03、AC-07、AC-08、AC-13、AC-14、AC-48、AC-49、AC-52
+  **依赖:** T015
+  **配对:** T017
+
+- [x] **T017** — 控制器租约和资源事实 Service
+  **类型**:实现
+  **文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_kubernetes_service.py`
+  **逻辑/验收**:claim/renew holder/expected_epoch,验证固定执行域与有效操作、Service提交资源意图并结束SQL事务,manager随后调用 K8s;错 UID、过期控制器、越界、提前释放拒绝,终态后的只读收尾计划可续办;通过配对测试后完成 L1 task-review。
+  **覆盖 AC:** AC-03、AC-07、AC-08、AC-13、AC-14、AC-48、AC-49、AC-52
+  **依赖:** T015、T016
+  **配对:** T016
+
+- [x] **T018** — 内部控制 API 接入:契约与失败用例
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_runtime_kubernetes_api.py`
+  **逻辑/验收**:注册 controllers/resources/observations/admission plans;沿用独立服务 HMAC/角色和序列化,API→Service→Repository;admission 只读,非角色/重放/跨域拒绝;测试只定义输入/结果,不加入实现。
+  **覆盖 AC:** AC-03、AC-04、AC-13、AC-14、AC-15、AC-48
+  **依赖:** T017
+  **配对:** T019
+
+- [x] **T019** — 内部控制 API 接入
+  **类型**:实现
+  **文件**:`src/backend/bisheng/app_runtime/api/endpoints/runtime_kubernetes.py`、`src/backend/bisheng/app_runtime/api/router.py`
+  **逻辑/验收**:注册 controllers/resources/observations/admission plans;沿用独立服务 HMAC/角色和序列化,API→Service→Repository;admission 只读,非角色/重放/跨域拒绝;通过配对测试后完成 L1 task-review。
+  **覆盖 AC:** AC-03、AC-04、AC-13、AC-14、AC-15、AC-48
+  **依赖:** T017、T018
+  **配对:** T018
+
+- [x] **T020** — material-v1 编码与验签:契约与失败用例
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_runtime_material_protocol.py`、`src/runtime-manager/tests/test_material_protocol.py`
+  **逻辑/验收**:对同一 414-byte 固定向量验 11 行编码与真实 Ed25519 互验;覆盖原始 body/ticket/method/path/offset、重复头/query、nonce 与时钟界限。双方实现固定 wire contract,不从 backend import manager;测试只定义输入/结果,不加入实现。
+  **覆盖 AC:** AC-15、AC-46、AC-47
+  **依赖:** T004
+  **配对:** T021
+
+- [x] **T021** — material-v1 编码与验签
+  **类型**:实现
+  **文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_material_protocol.py`、`src/runtime-manager/runtime_manager/material_protocol.py`
+  **逻辑/验收**:对同一 414-byte 固定向量验 11 行编码与真实 Ed25519 互验;覆盖原始 body/ticket/method/path/offset、重复头/query、nonce 与时钟界限。双方实现固定 wire contract,不从 backend import manager;通过配对测试后完成 L1 task-review。
+  **覆盖 AC:** AC-15、AC-46、AC-47
+  **依赖:** T004、T020
+  **配对:** T020
+
+- [x] **T022** — 材料委托和续期 Service:契约与失败用例
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_runtime_material_service.py`
+  **逻辑/验收**:从操作/部署/预览推导范围并绑定 UID/公钥;operation 终态失效,deployment-init 按当前 serving/候选冻结引用续取;旧 ticket 不独立续期,撤销/跨租户/错材料/Redis未知拒绝;测试只定义输入/结果,不加入实现。
+  **覆盖 AC:** AC-15、AC-27、AC-46、AC-47、AC-48
+  **依赖:** T015、T021
+  **配对:** T023
+
+- [x] **T023** — 材料委托和续期 Service
+  **类型**:实现
+  **文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_material_service.py`、`src/backend/bisheng/app_runtime/domain/models/runtime_kubernetes.py`
+  **逻辑/验收**:从操作/部署/预览推导范围并绑定 UID/公钥;operation 终态失效,deployment-init 按当前 serving/候选冻结引用续取;旧 ticket 不独立续期,撤销/跨租户/错材料/Redis未知拒绝;正式部署的expires_at允许NULL,含义为逐次检查当前归属;operation/预览分别保留操作/会话期限。仅调整未发布新表声明,无数据或已发布父链变更;通过配对测试后完成 L1 task-review。
+  **覆盖 AC:** AC-15、AC-27、AC-46、AC-47、AC-48
+  **依赖:** T015、T021、T022
+  **配对:** T022
+
+- [x] **T024** — 范围化分块材料传输:契约与失败用例
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_runtime_material_transfers.py`
+  **逻辑/验收**:复用对象存储和持久 offset;exchange 同范围轮换票据,单块 16MiB,重复同字节幂等/错摘要拒绝,分块下载与 finalize 内容/SQL确认;接口到期和响应丢失可查,无 MinIO 直链;测试只定义输入/结果,不加入实现。
+  **覆盖 AC:** AC-26、AC-28、AC-29、AC-42、AC-46、AC-47、AC-51
+  **依赖:** T023
+  **配对:** T025
+
+- [x] **T025** — 范围化分块材料传输
+  **类型**:实现
+  **文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_material_transfer_service.py`、`src/backend/bisheng/app_runtime/domain/services/runtime_artifact_service.py`
+  **逻辑/验收**:复用对象存储和持久 offset;exchange 同范围轮换票据,单块 16MiB,重复同字节幂等/错摘要拒绝,分块下载与 finalize 内容/SQL确认;接口到期和响应丢失可查,无 MinIO 直链;通过配对测试后完成 L1 task-review。
+  **覆盖 AC:** AC-26、AC-28、AC-29、AC-42、AC-46、AC-47、AC-51
+  **依赖:** T023、T024
+  **配对:** T024
+
+- [x] **T075** — 材料根路径多租户中间件:合同与失败用例
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_runtime_material_middleware.py`
+  **逻辑/验收**:固定材料根路径独立于浏览器JWT,租户由公钥授权决定;精确前缀免浏览器租户检查,相似路径不豁免;真实CustomMiddleware验证;测试不加入生产实现。
+  **覆盖 AC:** AC-15、AC-47、AC-48
+  **依赖:** T025
+  **配对:** T076
+
+- [x] **T076** — 材料根路径多租户中间件接线
+  **类型**:实现
+  **文件**:`src/backend/bisheng/utils/http_middleware.py`
+  **逻辑/验收**:仅新增材料根路径到可信内部请求前缀,并排除浏览器凭据解析;授权Service逐次清除ambient bypass/admin后验证租户/UID,不扩大其他路径。保持平台已有JWT/DSH语义;配对测试及L1后完成。
+  **覆盖 AC:** AC-15、AC-47、AC-48
+  **依赖:** T025、T075
+  **配对:** T075
+
+- [x] **T026** — 材料 workload HTTP API:契约与失败用例
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_runtime_material_api.py`
+  **逻辑/验收**:按 design 6.2.1 完整方法/头/响应/错误注册 exchange、status、content、finalize;使用 workload 身份依赖,拒绝 manager/app/OBO 身份互换,流式阶段重新检查期限/撤销;测试只定义输入/结果,不加入实现。
+  **覆盖 AC:** AC-15、AC-28、AC-46、AC-47、AC-51
+  **依赖:** T025、T019、T076
+  **配对:** T027
+
+- [x] **T027** — 材料 workload HTTP API
+  **类型**:实现
+  **文件**:`src/backend/bisheng/app_runtime/api/endpoints/runtime_material.py`、`src/backend/bisheng/main.py`
+  **逻辑/验收**:material-v1固定P直接注册为main根路由,不继承/api/v1前缀;按 design 6.2.1 完整方法/头/响应/错误注册 exchange、status、content、finalize;使用 workload 身份依赖,拒绝 manager/app/OBO 身份互换,流式阶段重新检查期限/撤销;通过配对测试后完成 L1 task-review。
+  **覆盖 AC:** AC-15、AC-28、AC-46、AC-47、AC-51
+  **依赖:** T025、T019、T026
+  **配对:** T026
+
+### Wave 4
+
+- [x] **T028** — 材料 client 与平台控制 client:契约与失败用例
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_material_client.py`
+  **逻辑/验收**:下载/上传按 frozen SHA、受限票据及新 nonce 签名;部分块丢弃、响应丢失查 offset、重交换不延长操作;控制 client 支持资源、租约、材料绑定接口且不记录私钥;测试只定义输入/结果,不加入实现。
+  **覆盖 AC:** AC-07、AC-15、AC-26、AC-28、AC-42、AC-44、AC-46、AC-47、AC-52
+  **依赖:** T007、T027
+  **配对:** T029
+
+- [x] **T029** — 材料 client 与平台控制 client
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/material_client.py`、`src/runtime-manager/runtime_manager/platform_client.py`
+  **逻辑/验收**:下载/上传按 frozen SHA、受限票据及新 nonce 签名;部分块丢弃、响应丢失查 offset、重交换不延长操作;控制 client 支持资源、租约、材料绑定接口且不记录私钥;通过配对测试后完成 L1 task-review。
+  **覆盖 AC:** AC-07、AC-15、AC-26、AC-28、AC-42、AC-44、AC-46、AC-47、AC-52
+  **依赖:** T007、T027、T028
+  **配对:** T028
+
+- [x] **T077** — 准备操作依赖缓存选择:合同与失败用例
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_runtime_dependency_materials.py`
+  **逻辑/验收**:缓存选择须持久化当前准备操作;同应用/profile/platform/verified、输入键不可变;材料授权从真实选择/SQL计划推导,伪造引用不扩权;测试不混入实现。
+  **覆盖 AC:** AC-15、AC-25、AC-28、AC-29、AC-46、AC-47
+  **依赖:** T023、T029
+  **配对:** T078
+
+- [x] **T078** — 依赖缓存选择与材料授权接线
+  **类型**:实现
+  **文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_profile_service.py`、`src/backend/bisheng/app_runtime/domain/services/runtime_material_service.py`
+  **逻辑/验收**:resolve/confirm在操作行锁下持久选中结果,授权复查同范围SQL计划和材料;禁止任意引用扩大授权、终态写入或更换输入键。保持旧Docker内容确认合同。配对测试及L1后完成。
+  **覆盖 AC:** AC-15、AC-25、AC-28、AC-29、AC-46、AC-47
+  **依赖:** T023、T029、T077
+  **配对:** T077
+
+- [x] **T030** — 受信材料 helper 与离线环境物化:契约与失败用例
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_helper.py`
+  **逻辑/验收**:material-fetch 只安全取冻结材料;无凭据 environment 阶段在固定 /opt/venv/Node/static 路径物化;prepare 分下载/安装编译/上传独立 Pod,代码不会借传输身份执行;环境键包含 profile/ABI/架构/锁/安装输入;测试只定义输入/结果,不加入实现。
+  **覆盖 AC:** AC-15、AC-16、AC-24、AC-25、AC-26、AC-28、AC-29、AC-42、AC-43、AC-44、AC-45、AC-46、AC-54
+  **依赖:** T009、T029、T078
+  **配对:** T031
+
+- [x] **T031** — 受信材料 helper 与离线环境物化
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/k8s_helper.py`、`src/runtime-manager/runtime_manager/k8s_prepare.py`
+  **逻辑/验收**:material-fetch 只安全取冻结材料;无凭据 environment 阶段在固定 /opt/venv/Node/static 路径物化;prepare 分下载/安装编译/上传独立 Pod,代码不会借传输身份执行;环境键包含 profile/ABI/架构/锁/安装输入;通过配对测试后完成 L1 task-review。
+  **覆盖 AC:** AC-15、AC-16、AC-24、AC-25、AC-26、AC-28、AC-29、AC-42、AC-43、AC-44、AC-45、AC-46、AC-54
+  **依赖:** T009、T029、T030
+  **配对:** T030
+
+- [x] **T079** — K8s节点报告和控制事实:合同与失败用例
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_runtime_kubernetes_coordination.py`
+  **逻辑/验收**:心跳持久执行形态/探测,失败阻断新建但可清理;事实必须当前租约/代次,停止声明须资源证据,ready须已绑定Service/Pod;未知异常不被全局错误处理泄漏。测试只定义合同。
+  **覆盖 AC:** AC-03、AC-07、AC-08、AC-13、AC-14、AC-30、AC-31、AC-48、AC-49、AC-52
+  **依赖:** T019、T031
+  **配对:** T080
+
+- [x] **T080** — K8s节点与控制事实接线
+  **类型**:实现
+  **文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_operation_service.py`、`src/backend/bisheng/app_runtime/api/endpoints/runtime_delivery.py`
+  **逻辑/验收**:扩展兼容心跳/node plan、K8s控制器事实闸口、停止与ready事实校验;保留Docker合同。探测失效阻断新建,清理不被同一状态锁死;内部未知异常安全503。配对测试和L1后完成。
+  **覆盖 AC:** AC-03、AC-07、AC-08、AC-13、AC-14、AC-30、AC-31、AC-48、AC-49、AC-52
+  **依赖:** T019、T031、T079
+  **配对:** T079
+
+- [x] **T032** — K8s 执行能力与资源操作:契约与失败用例
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_kubernetes_executor.py`
+  **逻辑/验收**:实现 prepare/launch/observe/request_stop/confirm_stopped/cleanup/logs/data 能力,只有 adapter 拼 Core API;固定名称/UID 账本、Service就绪端点、bounded wait、平台资源事实及数据/维护前后 stop 闸口;测试只定义输入/结果,不加入实现。
+  **覆盖 AC:** AC-04、AC-05、AC-07、AC-08、AC-09、AC-11、AC-18、AC-33、AC-35、AC-43、AC-48、AC-49、AC-50、AC-52
+  **依赖:** T031、T011、T019、T080
+  **配对:** T033
+
+- [x] **T033** — K8s 执行能力与资源操作
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/executors/kubernetes.py`、`src/runtime-manager/runtime_manager/executors/protocol.py`
+  **逻辑/验收**:实现 prepare/launch/observe/request_stop/confirm_stopped/cleanup/logs/data 能力,只有 adapter 拼 Core API;固定名称/UID 账本、Service就绪端点、bounded wait、平台资源事实及数据/维护前后 stop 闸口;通过配对测试后完成 L1 task-review。
+  **覆盖 AC:** AC-04、AC-05、AC-07、AC-08、AC-09、AC-11、AC-18、AC-33、AC-35、AC-43、AC-48、AC-49、AC-50、AC-52
+  **依赖:** T031、T011、T019、T032
+  **配对:** T032
+
+- [x] **T034** — Docker 能力适配且保留旧合同:契约与失败用例
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_docker_executor.py`
+  **逻辑/验收**:抽离既有 Docker/本机路径算法到适配器,通用 checkpoint/facts/操作归属保留;基于现有三路径/生命周期测试比较原输入输出,不用假 K8s API 模拟 Docker;测试只定义输入/结果,不加入实现。
+  **覆盖 AC:** AC-01、AC-04、AC-10、AC-36、AC-37、AC-56
+  **依赖:** T033
+  **配对:** T035
+
+- [x] **T035** — Docker 能力适配且保留旧合同
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/executors/docker.py`、`src/runtime-manager/runtime_manager/operations.py`
+  **逻辑/验收**:抽离既有 Docker/本机路径算法到适配器,通用 checkpoint/facts/操作归属保留;基于现有三路径/生命周期测试比较原输入输出,不用假 K8s API 模拟 Docker;通过配对测试后完成 L1 task-review。
+  **覆盖 AC:** AC-01、AC-04、AC-10、AC-36、AC-37、AC-56
+  **依赖:** T033、T034
+  **配对:** T034
+
+- [x] **T036** — 通用操作状态机和 composition root:契约与失败用例
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_executor_selection.py`
+  **逻辑/验收**:按配置选唯一执行器并共用操作状态/事实编排;K8s 不初始化 Docker 或 v1本地循环;不接受调用方执行路径/PodSpec,重复唤醒、manager关闭/恢复、待上线前后容量语义保持;测试只定义输入/结果,不加入实现。
+  **覆盖 AC:** AC-01、AC-02、AC-04、AC-05、AC-07、AC-08、AC-09、AC-36、AC-37、AC-39、AC-48、AC-56
+  **依赖:** T035
+  **配对:** T037
+
+- [x] **T037** — 通用操作状态机和 composition root
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/operations.py`、`src/runtime-manager/runtime_manager/api/operations.py`
+  **逻辑/验收**:按配置选唯一执行器并共用操作状态/事实编排;K8s 不初始化 Docker 或 v1本地循环;不接受调用方执行路径/PodSpec,重复唤醒、manager关闭/恢复、待上线前后容量语义保持;通过配对测试后完成 L1 task-review。
+  **覆盖 AC:** AC-01、AC-02、AC-04、AC-05、AC-07、AC-08、AC-09、AC-36、AC-37、AC-39、AC-48、AC-56
+  **依赖:** T035、T036
+  **配对:** T036
+
+- [x] **T038** — 状态、日志和能力 API:契约与失败用例
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_observe.py`
+  **逻辑/验收**:保留业务 API 范围、since/tail/脱敏,仅读取 app/previous 和删除前有界诊断;新增认证 capabilities,前置 blocked 原因清楚;健康存活不冒充已就绪,K8s禁用v1编排注册;测试只定义输入/结果,不加入实现。
+  **覆盖 AC:** AC-02、AC-07、AC-10、AC-30、AC-31、AC-32、AC-36、AC-37、AC-40、AC-53
+  **依赖:** T037
+  **配对:** T039
+
+- [x] **T039** — 状态、日志和能力 API
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/api/platform_observe.py`、`src/runtime-manager/runtime_manager/main.py`
+  **逻辑/验收**:保留业务 API 范围、since/tail/脱敏,仅读取 app/previous 和删除前有界诊断;新增认证 capabilities,前置 blocked 原因清楚;健康存活不冒充已就绪,K8s禁用v1编排注册;通过配对测试后完成 L1 task-review。
+  **覆盖 AC:** AC-02、AC-07、AC-10、AC-30、AC-31、AC-32、AC-36、AC-37、AC-40、AC-53
+  **依赖:** T037、T038
+  **配对:** T038
+
+### Wave 5
+
+- [x] **T040** — 数据 helper 动作票据与 journal:契约与失败用例
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_data_helper.py`
+  **逻辑/验收**:只持 backend 公钥;5秒受理票据绑定 app/UID/action/body/nonce,有界60秒执行和串行;platform目录持久started/done,未知不重做;owner数据白名单SQL和流式导出,结果查询新只读ID指向原ID;测试只定义输入/结果,不加入实现。
+  **覆盖 AC:** AC-15、AC-16、AC-33、AC-34、AC-36、AC-37、AC-43、AC-48、AC-50
+  **依赖:** T021、T009
+  **配对:** T041
+
+- [x] **T041** — 数据 helper 动作票据与 journal
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/k8s_data_helper.py`、`src/runtime-manager/runtime_manager/data_grant.py`
+  **逻辑/验收**:只持 backend 公钥;5秒受理票据绑定 app/UID/action/body/nonce,有界60秒执行和串行;platform目录持久started/done,未知不重做;owner数据白名单SQL和流式导出,结果查询新只读ID指向原ID;通过配对测试后完成 L1 task-review。
+  **覆盖 AC:** AC-15、AC-16、AC-33、AC-34、AC-36、AC-37、AC-43、AC-48、AC-50
+  **依赖:** T021、T009、T040
+  **配对:** T040
+
+- [x] **T081** — 数据会话互斥与开销预占合同
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_runtime_data_repository.py`
+  **逻辑/验收**:真实SQL验证data_operation_id不改变serving代次/归属,数据会话与生命周期提交/claim互斥;期限到期或未知结果不释放,只有终态且清理完成释放;K8s正式Pod的128MiB/25m sidecar计入预占,Docker原额度不变。先验证SQL原子性,再接Service;不加入实现。
+  **覆盖 AC:** AC-04、AC-05、AC-33、AC-34、AC-35、AC-36、AC-43、AC-48、AC-49、AC-50、AC-52
+  **依赖:** T041、T080
+  **配对:** T082
+
+- [x] **T082** — 数据会话账本及部署资源开销接线
+  **类型**:实现
+  **文件**:`src/backend/bisheng/app_runtime/domain/repositories/runtime_delivery_repository.py`、`src/backend/bisheng/app_runtime/domain/services/runtime_operation_service.py`
+  **逻辑/验收**:Repository在node/assignment锁下登记内部数据操作及helper预占,不改active_operation_id/generation;生命周期新提交及claim检查未清理data归属,数据claim校验原范围;unknown保留互斥。终态且清理确认后释放。OperationService按K8s实际sidecar计入正式Pod资源、保留Docker预算;不做集群I/O。
+  **覆盖 AC:** AC-04、AC-05、AC-33、AC-34、AC-35、AC-36、AC-43、AC-48、AC-49、AC-50、AC-52
+  **依赖:** T041、T080、T081
+  **配对:** T081
+
+- [x] **T042** — owner 数据会话业务协调:契约与失败用例
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_runtime_data_sessions.py`
+  **逻辑/验收**:在原权限/确认/审计后创建内部 kind=data;不改 serving代次/App态,数据/迁移/停用/部署互斥;事务先提交再唤醒,在线复用helper,停用冷启动也计入60秒,超时不等于cleanup;测试只定义输入/结果,不加入实现。
+  **覆盖 AC:** AC-15、AC-35、AC-36、AC-37、AC-43、AC-48、AC-49、AC-50
+  **依赖:** T017、T041、T082
+  **配对:** T043
+
+- [x] **T043** — owner 数据会话业务协调
+  **类型**:实现
+  **文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_data_session_service.py`、`src/backend/bisheng/app_runtime/domain/services/app_data_service.py`
+  **逻辑/验收**:在原权限/确认/审计后创建内部 kind=data;不改 serving代次/App态,数据/迁移/停用/部署互斥;事务先提交再唤醒,在线复用helper,停用冷启动也计入60秒,超时不等于cleanup;通过配对测试后完成 L1 task-review。
+  **覆盖 AC:** AC-15、AC-35、AC-36、AC-37、AC-43、AC-48、AC-49、AC-50
+  **依赖:** T017、T041、T042
+  **配对:** T042
+
+- [x] **T044** — K8s 在线及停用数据执行:契约与失败用例
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_data_executor.py`
+  **逻辑/验收**:在线同Pod访问;停用仅在旧UID stopped且空生产位置启动 purpose=data,保留PVC/停用态,不创建业务Service;数据忙/未知拒绝,stop确认后释放预占与互斥,manager不签owner票据;测试只定义输入/结果,不加入实现。
+  **覆盖 AC:** AC-11、AC-15、AC-33、AC-35、AC-36、AC-43、AC-48、AC-49、AC-50、AC-52
+  **依赖:** T043、T033
+  **配对:** T045
+
+- [x] **T045** — K8s 在线及停用数据执行
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/k8s_data_executor.py`、`src/runtime-manager/runtime_manager/api/platform_data.py`
+  **逻辑/验收**:在线同Pod访问;停用仅在旧UID stopped且空生产位置启动 purpose=data,保留PVC/停用态,不创建业务Service;数据忙/未知拒绝,stop确认后释放预占与互斥,manager不签owner票据;通过配对测试后完成 L1 task-review。
+  **覆盖 AC:** AC-11、AC-15、AC-33、AC-35、AC-36、AC-43、AC-48、AC-49、AC-50、AC-52
+  **依赖:** T043、T033、T044
+  **配对:** T044
+
+- [x] **T085** — 顺序维护资源 DDL
+  **类型**:基础设施
+  **文件**:`src/backend/bisheng/core/database/alembic/versions/f059_resource_phases.py`
+  **逻辑/验收**:实际单head f059_merge_release_heads之后追加DDL,移除operation/kind/name唯一约束;保留资源ref主键及真实UID唯一约束。降级须运维先确认历史无重名,revision不删历史、不读写业务数据;MySQL/DM8条件DDL及单head静态验证。
+  **覆盖 AC:** AC-15、AC-35、AC-48、AC-49、AC-56
+  **依赖:** T074
+
+- [x] **T083** — 固定生产名称的分阶段资源合同
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_runtime_kubernetes_phases.py`
+  **逻辑/验收**:真实SQL验证同一操作backup/migrate/serve可顺序登记不同ref/UID,前一Pod停止并清理完成后才允许下一Pod;原ref幂等不改冻结模板,旧UID历史保留,未停止/未清理拒绝,不以租约到期放行。
+  **覆盖 AC:** AC-08、AC-15、AC-33、AC-35、AC-48、AC-49、AC-50
+  **依赖:** T085、T045
+  **配对:** T084
+
+- [x] **T084** — 顺序资源意图与历史保留
+  **类型**:实现
+  **文件**:`src/backend/bisheng/app_runtime/domain/models/runtime_kubernetes.py`、`src/backend/bisheng/app_runtime/domain/repositories/runtime_kubernetes_repository.py`
+  **逻辑/验收**:按资源ref幂等登记;固定生产名的新ref须先确认旧Pod stopped及cleanup complete,保留所有UID历史,非生产重名拒绝。移除模型旧唯一intent约束,保留UID唯一;事务内检查,不在锁内调用集群API。
+  **覆盖 AC:** AC-08、AC-15、AC-33、AC-35、AC-48、AC-49、AC-50
+  **依赖:** T085、T083
+  **配对:** T083
+
+- [x] **T086** — 材料分块读取的定长范围合同
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_runtime_material_ranges.py`、`src/backend/test/app_runtime/test_runtime_material_transfers.py`
+  **逻辑/验收**:下载后段仅从对象存储对应offset读取length字节;验证关闭/释放连接、短读拒绝、撤权与原有分块摘要合同,不以小对象整读掩盖大对象二次复杂度。
+  **覆盖 AC:** AC-26、AC-28、AC-29、AC-46、AC-47、AC-51
+  **依赖:** T025
+  **配对:** T087
+
+- [x] **T087** — 对象存储 Range 流式下载接线
+  **类型**:实现
+  **文件**:`src/backend/bisheng/app_runtime/domain/repositories/runtime_object_store.py`、`src/backend/bisheng/app_runtime/domain/services/runtime_material_transfer_service.py`
+  **逻辑/验收**:使用MinIO get_object offset/length和定长流,不从头跳过;backend继续独占存储凭据,连接finally释放,原授权/offset/完整性合同保持。
+  **覆盖 AC:** AC-26、AC-28、AC-29、AC-46、AC-47、AC-51
+  **依赖:** T025、T086
+  **配对:** T086
+
+- [x] **T046** — K8s SQLite 备份迁移恢复:契约与失败用例
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_appdb.py`
+  **逻辑/验收**:停整个生产Pod后维护独占卷;复用appdb plan_digest/SQLite原子迁移journal,backup/integrity/fsync,平台内容与关联确认后才DDL;保护当前数据后恢复,失败保留maintenance和data_may_have_changed,不自动数据回滚;测试只定义输入/结果,不加入实现。
+  **覆盖 AC:** AC-33、AC-34、AC-35、AC-46、AC-49、AC-50、AC-51、AC-52
+  **依赖:** T045、T029、T084、T087
+  **配对:** T047
+
+- [x] **T047** — K8s SQLite 备份迁移恢复
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/k8s_appdb.py`、`src/runtime-manager/runtime_manager/k8s_data_helper.py`
+  **逻辑/验收**:停整个生产Pod后维护独占卷;复用appdb plan_digest/SQLite原子迁移journal,backup/integrity/fsync,平台内容与关联确认后才DDL;保护当前数据后恢复,失败保留maintenance和data_may_have_changed,不自动数据回滚;通过配对测试后完成 L1 task-review。
+  **覆盖 AC:** AC-33、AC-34、AC-35、AC-46、AC-49、AC-50、AC-51、AC-52
+  **依赖:** T045、T029、T046
+  **配对:** T046
+
+- [x] **T048** — backend 数据动作签发及路由:契约与失败用例
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_runtime_data_grants.py`
+  **逻辑/验收**:原业务Service重新核验actor及当前data操作/UID后本地签发;不向manager提供通用签发API。data操作和查询结果授权原地失效,非owner、重放、错UID拒绝;管理身份不能自签;测试只定义输入/结果,不加入实现。
+  **覆盖 AC:** AC-15、AC-36、AC-37、AC-43、AC-48
+  **依赖:** T043、T047
+  **配对:** T049
+
+- [x] **T049** — backend 数据动作签发及路由
+  **类型**:实现
+  **文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_data_grant_service.py`、`src/backend/bisheng/app_runtime/api/endpoints/runtime_kubernetes.py`
+  **逻辑/验收**:原业务Service重新核验actor及当前data操作/UID后本地签发;不向manager提供通用签发API。data操作和查询结果授权原地失效,非owner、重放、错UID拒绝;管理身份不能自签;通过配对测试后完成 L1 task-review。
+  **覆盖 AC:** AC-15、AC-36、AC-37、AC-43、AC-48
+  **依赖:** T043、T047、T048
+  **配对:** T048
+
+- [x] **T088** — K8s 兼容源码的 profile 冻结合同
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_runtime_k8s_delivery_coordinator.py`
+  **逻辑/验收**:prepare真实协调方法在K8s source-image解析并冻结运行/preparer profile和离线材料;Docker旧源码构建不改,完整镜像不要求bundle profile,不从manager临时配置猜基础镜像。
+  **覆盖 AC:** AC-01、AC-24、AC-25、AC-27、AC-28、AC-42、AC-54
+  **依赖:** T049
+  **配对:** T089
+
+- [x] **T089** — K8s 兼容源码 profile 协调接线
+  **类型**:实现
+  **文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_delivery_coordinator.py`
+  **逻辑/验收**:仅K8s source-image复用现有全局profile解析、冻结架构与材料归属;保持Docker和完整镜像原语义。环境注入/审批仍由原业务Owner负责。
+  **覆盖 AC:** AC-01、AC-24、AC-25、AC-27、AC-28、AC-42、AC-54
+  **依赖:** T049、T088
+  **配对:** T088
+
+- [x] **T090** — 数据恢复查询及导出流合同
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_runtime_data_recovery.py`
+  **逻辑/验收**:未知写结果只以新只读会话查询原request,不重做写操作;owner重检、原会话清理闸口与原参数摘要保持。HTTP分块导出落短缓存,验证长度/摘要/截止期并清理失败文件,Docker bytes兼容。
+  **覆盖 AC:** AC-15、AC-36、AC-37、AC-43、AC-48、AC-50
+  **依赖:** T049
+  **配对:** T091
+
+- [x] **T091** — 数据恢复协调与导出短缓存
+  **类型**:实现
+  **文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_data_session_service.py`、`src/backend/bisheng/app_runtime/domain/services/app_data_service.py`
+  **逻辑/验收**:原数据Service先验证owner;恢复原操作结果创建新result只读会话和新request_id,不重放写。K8s导出经有界分块落缓存供响应,错误/取消回收,审计按文件大小记录;不让backend访问SQLite。
+  **覆盖 AC:** AC-15、AC-36、AC-37、AC-43、AC-48、AC-50
+  **依赖:** T049、T090
+  **配对:** T090
+
+- [x] **T092** — 结果查询与文件响应业务入口合同
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_runtime_data_recovery_api.py`
+  **逻辑/验收**:登录owner通过data/operations/{operation_id}/result读取原结果,非owner拒绝;CSV FileResponse保留文件名/类型/鉴权错误信封,下载后缓存回收;原Docker字节响应不变。
+  **覆盖 AC:** AC-15、AC-36、AC-37、AC-43、AC-48、AC-50
+  **依赖:** T091
+  **配对:** T093
+
+- [x] **T093** — 数据恢复和流式导出 API 接线
+  **类型**:实现
+  **文件**:`src/backend/bisheng/app_runtime/api/endpoints/apps.py`
+  **逻辑/验收**:只委托AppDataService,不在Endpoint执行账本/存储逻辑;公开业务读取结果用统一信封,CSV短缓存FileResponse带BackgroundTask回收;保持原bytes路径。
+  **覆盖 AC:** AC-15、AC-36、AC-37、AC-43、AC-48、AC-50
+  **依赖:** T091、T092
+  **配对:** T092
+
+### Wave 6
+
+- [x] **T050** — 三种交付模式的 K8s 准备链:契约与失败用例
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_delivery.py`
+  **逻辑/验收**:Python3.11/Node20/static bundle预检、完整镜像直启、显式source-image准备;依赖材料命中与失效、新增依赖缺包/补包、安全解包;任务阶段独立身份/卷/限额/并发,启动失败不产生审批;测试只定义输入/结果,不加入实现。
+  **覆盖 AC:** AC-08、AC-24、AC-25、AC-26、AC-27、AC-28、AC-29、AC-42、AC-43、AC-44、AC-45、AC-46、AC-54
+  **依赖:** T033、T047、T089
+  **配对:** T051
+
+- [x] **T051** — 三种交付模式的 K8s 准备链
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/k8s_prepare.py`、`src/runtime-manager/runtime_manager/executors/kubernetes.py`
+  **逻辑/验收**:Python3.11/Node20/static bundle预检、完整镜像直启、显式source-image准备;依赖材料命中与失效、新增依赖缺包/补包、安全解包;任务阶段独立身份/卷/限额/并发,启动失败不产生审批;通过配对测试后完成 L1 task-review。
+  **覆盖 AC:** AC-08、AC-24、AC-25、AC-26、AC-27、AC-28、AC-29、AC-42、AC-43、AC-44、AC-45、AC-46、AC-54
+  **依赖:** T033、T047、T050
+  **配对:** T050
+
+- [x] **T052** — 无守护进程的受限 OCI 组装:契约与失败用例
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_oci_assembler.py`
+  **逻辑/验收**:固定布局追加冻结基础镜像并设置既有启动语义;不执行RUN/Dockerfile,拒绝逃逸、设备/setuid/whiteout/外部硬链;仓库身份仅受信组装器使用,缺推送仓库仅阻断source-image;测试只定义输入/结果,不加入实现。
+  **覆盖 AC:** AC-15、AC-16、AC-25、AC-27、AC-28、AC-42、AC-54
+  **依赖:** T051
+  **配对:** T053
+
+- [x] **T053** — 无守护进程的受限 OCI 组装
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/oci_assembler.py`、`src/runtime-manager/Dockerfile.assembler`
+  **逻辑/验收**:固定布局追加冻结基础镜像并设置既有启动语义;不执行RUN/Dockerfile,拒绝逃逸、设备/setuid/whiteout/外部硬链;仓库身份仅受信组装器使用,缺推送仓库仅阻断source-image;通过配对测试后完成 L1 task-review。
+  **覆盖 AC:** AC-15、AC-16、AC-25、AC-27、AC-28、AC-42、AC-54
+  **依赖:** T051、T052
+  **配对:** T052
+
+
+- [x] **T096** — owner 删除与安全清理合同
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_appdb.py`、`src/backend/test/app_runtime/test_runtime_kubernetes_purge.py`
+  **逻辑/验收**:先验证purge缺失红阶段;错误owner意图/跨应用卷拒绝,停止和结果事实先于PVC删除,不跟随链接,重启不重复创建。
+  **覆盖 AC:** AC-35、AC-48、AC-49、AC-50、AC-52
+  **依赖:** T053
+  **配对:** T097
+
+- [x] **T097** — backend purge 范围和删除授权
+  **类型**:实现
+  **文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_kubernetes_service.py`、`src/backend/bisheng/app_runtime/domain/repositories/runtime_kubernetes_repository.py`
+  **逻辑/验收**:只接受显式destroy+purge_volume的固定生产位置;由执行事实证明purge完成及全部writer停止清理后,授予原PVC条件删除。
+  **覆盖 AC:** AC-35、AC-48、AC-49、AC-50、AC-52
+  **依赖:** T053、T096
+  **配对:** T096
+
+- [x] **T098** — 受信 helper 清理与持久重入
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/k8s_appdb.py`、`src/runtime-manager/runtime_manager/k8s_data_helper.py`
+  **逻辑/验收**:不持应用/材料身份,擦除business及受保护备份,不跟随链接;维护journal和SQL事实避免已完成purge重复执行。
+  **覆盖 AC:** AC-35、AC-48、AC-49、AC-50、AC-52
+  **依赖:** T096、T097
+  **配对:** T096
+
+- [x] **T099** — 生产 purge 模板及状态机接线
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/k8s_templates.py`、`src/runtime-manager/runtime_manager/operations.py`
+  **逻辑/验收**:purge共用固定生产位置;恢复destroy不停止自己的当前清理Pod;生产purpose集合同步;取消重复egress注入。
+  **覆盖 AC:** AC-04、AC-35、AC-48、AC-49、AC-50、AC-52
+  **依赖:** T098
+  **配对:** T096
+
+- [x] **T100** — 执行器条件回收生产卷
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/executors/kubernetes.py`、`src/runtime-manager/runtime_manager/k8s_data_executor.py`
+  **逻辑/验收**:缺少应用profile的destroy只使用固定helper;PVC删除用UID/RV,确认缺失才提交cleanup;未知拒绝,不授予平台存储凭据。
+  **覆盖 AC:** AC-04、AC-35、AC-48、AC-49、AC-50、AC-52
+  **依赖:** T099
+  **配对:** T096
+
+- [x] **T101** — 受限 canary 启动合同
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_canary.py`
+  **逻辑/验收**:安装计划仅preflight名称/固定镜像命令/短期范围;拒绝应用源码、生产PVC、Secret、无限授权和未绑定UID更新。
+  **覆盖 AC:** AC-13、AC-14、AC-16、AC-21、AC-23、AC-49
+  **依赖:** T013、T053
+  **配对:** T102
+
+- [x] **T102** — 只读 canary 准入和网络探测
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/k8s_admission.py`、`src/runtime-manager/runtime_manager/k8s_capabilities.py`
+  **逻辑/验收**:只读installer CM提供至多一小时预检计划,其它资源仍backend;可信idle监听、TCP/UDP DNS真探测;报告24h由外部installer每6h刷新。
+  **覆盖 AC:** AC-13、AC-14、AC-16、AC-21、AC-23、AC-49
+  **依赖:** T101
+  **配对:** T101
+
+- [x] **T054** — 隔离和安装前置的实际能力探测:契约与失败用例
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_capabilities.py`
+  **逻辑/验收**:分项验证namespace预算、PSA/准入、status认证、存储锁/fsync、精确CRI镜像、网络允许/拒绝、材料/能力接口;必需隔离失败/未知拒绝,沙箱仅回退满足基线档;无SC但预置卷可用;测试只定义输入/结果,不加入实现。
+  **覆盖 AC:** AC-12、AC-13、AC-16、AC-18、AC-19、AC-20、AC-21、AC-22、AC-23、AC-30、AC-31、AC-32、AC-34、AC-42、AC-54
+  **依赖:** T013、T039、T053
+  **配对:** T055
+
+- [x] **T055** — 隔离和安装前置的实际能力探测
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/k8s_capabilities.py`、`src/runtime-manager/runtime_manager/k8s_templates.py`
+  **逻辑/验收**:分项验证namespace预算、PSA/准入、status认证、存储锁/fsync、精确CRI镜像、网络允许/拒绝、材料/能力接口;必需隔离失败/未知拒绝,沙箱仅回退满足基线档;无SC但预置卷可用;通过配对测试后完成 L1 task-review。
+  **覆盖 AC:** AC-12、AC-13、AC-16、AC-18、AC-19、AC-20、AC-21、AC-22、AC-23、AC-30、AC-31、AC-32、AC-34、AC-42、AC-54
+  **依赖:** T013、T039、T053、T054
+  **配对:** T054
+
+- [x] **T094** — 活跃材料代理候选查询合同
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_runtime_material_policy_candidates.py`
+  **逻辑/验收**:真实SQL按node过滤已绑定、未停止/未清理、未撤销且未到期材料授权;停止/历史/其它节点不进入每秒投影,保留原历史记录而不全表循环逐条重验。
+  **覆盖 AC:** AC-15、AC-23、AC-46、AC-47、AC-48、AC-53
+  **依赖:** T055
+  **配对:** T095
+
+- [x] **T095** — 活跃材料候选 Repository 查询
+  **类型**:实现
+  **文件**:`src/backend/bisheng/app_runtime/domain/repositories/runtime_delivery_repository.py`
+  **逻辑/验收**:Repository联查材料与资源记录提供material_policy_candidates(node_id,now),不在Service写SQL;跨租户仅供已验证egress角色的node范围投影,最多10000活动候选、超限拒绝,不删除历史。
+  **覆盖 AC:** AC-15、AC-23、AC-46、AC-47、AC-48、AC-53
+  **依赖:** T055、T094
+  **配对:** T094
+
+- [x] **T056** — 材料用途的出站投影:契约与失败用例
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_runtime_material_egress.py`
+  **逻辑/验收**:material代理身份由当前材料授权投影,独立于完成的prepare;只放行后台材料TLS端点和范围,撤销5秒上界,业务凭据不冒用;入口路由继续backendSQL投影不依赖manager;测试只定义输入/结果,不加入实现。
+  **覆盖 AC:** AC-07、AC-12、AC-15、AC-21、AC-23、AC-46、AC-47、AC-53
+  **依赖:** T023、T055、T095
+  **配对:** T057
+
+- [x] **T057** — 材料用途的出站投影
+  **类型**:实现
+  **文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_material_policy_service.py`、`src/backend/bisheng/app_runtime/domain/services/runtime_operation_service.py`
+  **逻辑/验收**:material代理身份由当前材料授权投影,独立于完成的prepare;只放行后台材料TLS端点和范围,撤销5秒上界,业务凭据不冒用;入口路由继续backendSQL投影不依赖manager;通过配对测试后完成 L1 task-review。
+  **覆盖 AC:** AC-07、AC-12、AC-15、AC-21、AC-23、AC-46、AC-47、AC-53
+  **依赖:** T023、T055、T056
+  **配对:** T056
+
+### Wave 7
+
+- [x] **T058** — Chart 入口与 values schema
+  **类型**:基础设施
+  **文件**:`deploy/kubernetes/app-runtime/Chart.yaml`、`deploy/kubernetes/app-runtime/values.yaml`
+  **逻辑/验收**:按design固定platform/services/execution/storage/network/admission/images/offline分组,所有镜像精确引用、禁默认公网兜底与自动平台核心部署。
+  **覆盖 AC:** AC-39、AC-42、AC-54
+  **依赖:** T057
+
+- [x] **T059** — 部署参数及共享入口校验
+  **类型**:基础设施
+  **文件**:`deploy/kubernetes/app-runtime/values.schema.json`、`deploy/kubernetes/app-runtime/templates/services.yaml`
+  **逻辑/验收**:校验必需字段与形态,私网共享entry/control由安装者供应,禁止按应用外放端口;控制端点ACL及HMAC,外部backend可达性独立验证。
+  **覆盖 AC:** AC-12、AC-31、AC-39
+  **依赖:** T058
+
+- [x] **T060** — RBAC 与准入安装模板
+  **类型**:基础设施
+  **文件**:`deploy/kubernetes/app-runtime/templates/rbac.yaml`、`deploy/kubernetes/app-runtime/templates/admission.yaml`
+  **逻辑/验收**:namespace manager最小权限,系统服务不可被manager改,验证器无集群token;status/template规则分别Fail/None,PSA restricted1.28;卸载不删除保护namespace/PV。
+  **覆盖 AC:** AC-03、AC-13、AC-14、AC-15、AC-16、AC-23、AC-39、AC-41、AC-49
+  **依赖:** T059、T013
+
+- [x] **T061** — 网络和保留卷安装模板
+  **类型**:基础设施
+  **文件**:`deploy/kubernetes/app-runtime/templates/network.yaml`、`deploy/kubernetes/app-runtime/templates/storage.yaml`
+  **逻辑/验收**:默认deny,允许entry/manager受控端口、只经egress出站、无业务DNS直连;localPV节点绑定或验证CSI,独占卷不带Pod/Helm级联删除owner,预置scratch池和限制。
+  **覆盖 AC:** AC-12、AC-21、AC-22、AC-23、AC-33、AC-34、AC-35、AC-39、AC-41、AC-42
+  **依赖:** T060
+
+
+- [x] **T104** — 镜像发行和清单合同
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_image_release.py`
+  **逻辑/验收**:本地构建默认不推送,双架构同版本digest合并及不可覆盖index;helper/assembler工具和离线清单,mock不记作原生验收。
+  **覆盖 AC:** AC-24、AC-27、AC-39、AC-42、AC-54、AC-56
+  **依赖:** T061
+  **配对:** T063
+
+- [x] **T062** — 运行服务与 helper 镜像
+  **类型**:基础设施
+  **文件**:`deploy/kubernetes/app-runtime/templates/workloads.yaml`、`src/runtime-manager/Dockerfile.helper`
+  **逻辑/验收**:独立manager/app-proxy/egress/admission进程,只有manager投射限定token,helper固定受信命令非root/只读;控制面重启不连带删除应用Pod或改变冻结profile。
+  **覆盖 AC:** AC-03、AC-15、AC-16、AC-39、AC-40、AC-42、AC-54
+  **依赖:** T061
+
+
+- [x] **T103** — 原生镜像构建脚本扩展
+  **类型**:基础设施
+  **文件**:`scripts/build_runtime_images.sh`
+  **逻辑/验收**:扩展现有脚本,同版本helper与assembler,默认local-only,显式发行开关才push;原生smoke/许可与冻结digest清单。
+  **覆盖 AC:** AC-24、AC-27、AC-39、AC-42、AC-54、AC-56
+  **依赖:** T062、T104
+
+- [x] **T063** — 同版本双架构 CI/CD 发行
+  **类型**:基础设施
+  **文件**:`.github/workflows/runtime-images.yml`、`src/runtime-manager/runtime_manager/image_catalog.py`
+  **逻辑/验收**:扩展已有tag v*/手工入口,发布helper/admission/assembler及runtime镜像清单与licenses;amd64/arm64原生构建,compose已发行镜像引用合同不退化;不改用户tag/自动推送。
+  **覆盖 AC:** AC-24、AC-27、AC-39、AC-42、AC-54、AC-56
+  **依赖:** T062、T103、T104
+  **配对:** T104
+
+- [x] **T064** — 部署/离线清单验证:契约与失败用例
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_installation.py`
+  **逻辑/验收**:可重复read-only预检及专用namespace允许/拒绝探测;离线镜像/依赖/平台接口分别核对,CRI按冻结引用真启动;说明本地卷限制、升级兼容、原卷保留和安全卸载,不输出kubeconfig/token;测试只定义输入/结果,不加入实现。
+  **覆盖 AC:** AC-13、AC-14、AC-19、AC-20、AC-21、AC-22、AC-23、AC-30、AC-31、AC-32、AC-34、AC-35、AC-39、AC-40、AC-41、AC-42、AC-54、AC-55
+  **依赖:** T063、T055
+  **配对:** T065
+
+- [x] **T065** — 部署/离线清单验证
+  **类型**:实现
+  **文件**:`scripts/verify_k8s_runtime.py`、`deploy/kubernetes/app-runtime/README.md`
+  **逻辑/验收**:可重复read-only预检及专用namespace允许/拒绝探测;离线镜像/依赖/平台接口分别核对,CRI按冻结引用真启动;说明本地卷限制、升级兼容、原卷保留和安全卸载,不输出kubeconfig/token;通过配对测试后完成 L1 task-review。
+  **覆盖 AC:** AC-13、AC-14、AC-19、AC-20、AC-21、AC-22、AC-23、AC-30、AC-31、AC-32、AC-34、AC-35、AC-39、AC-40、AC-41、AC-42、AC-54、AC-55
+  **依赖:** T063、T055、T064
+  **配对:** T064
+
+### Wave 8
+
+
+- [x] **T105** — E2E harness 边界合同
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_k8s_e2e_contract.py`
+  **逻辑/验收**:测试配置、严格tenant/owner/prefix cleanup和55AC映射;只验证harness,不冒充真实平台通过。
+  **覆盖 AC:** AC-36、AC-37、AC-55
+  **依赖:** T049、T005
+  **配对:** T106
+
+- [x] **T106** — 真实 API 业务验收共享行程
+  **类型**:实现
+  **文件**:`src/backend/test/e2e/helpers/vibe_runtime.py`、`src/backend/test/e2e/test_e2e_vibe_k8s_runtime.py`
+  **逻辑/验收**:复用既有认证helper,真实发布/审批/数据/入口和最终GET,精确MMMEE,独立测试租户/自然owner,不用manager直接API替代。
+  **覆盖 AC:** AC-36、AC-37、AC-55
+  **依赖:** T105
+  **配对:** T105
+
+- [x] **T066** — 真实平台 K8s E2E 执行器
+  **类型**:测试
+  **文件**:`scripts/e2e_k8s_runtime.py`、`features/v3.0.0/059-k8s-runtime-backend/e2e-checklist.md`
+  **逻辑/验收**:按e2e-test skill复用平台认证/CLI/审批/SDK/数据业务入口,生成55条AC用例映射、危险前置和cleanup;不是只调manager API,不把mock记为真实成功;凭据从安全环境/主机既有配置读取。
+  **覆盖 AC:** AC-01、AC-02、AC-03、AC-04、AC-05、AC-06、AC-07、AC-08、AC-09、AC-10、AC-11、AC-12、AC-13、AC-14、AC-15、AC-16、AC-17、AC-18、AC-19、AC-20、AC-21、AC-22、AC-23、AC-24、AC-25、AC-26、AC-27、AC-28、AC-29、AC-30、AC-31、AC-32、AC-33、AC-34、AC-35、AC-36、AC-37、AC-39、AC-40、AC-41、AC-42、AC-43、AC-44、AC-45、AC-46、AC-47、AC-48、AC-49、AC-50、AC-51、AC-52、AC-53、AC-54、AC-55、AC-56
+  **依赖:** T065、T049、T005
+
+
+- [x] **T107** — 继承发布线的权限/egress测试适配
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_app_permission_registration.py`、`src/backend/test/app_runtime/test_runtime_lifecycle_v2.py`
+  **逻辑/验收**:候选count stub与已交付linsight注册保持当前Owner;policy fixture显式egress角色,不放宽生产身份。
+  **覆盖 AC:** AC-03、AC-36、AC-53、AC-56
+  **依赖:** T066
+
+- [x] **T108** — 资源历史约束静态合同同步
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_runtime_model_contracts.py`
+  **逻辑/验收**:明确资源历史按实际UID唯一;已移除operation/kind/name约束不得恢复,保留固定位置多phase历史;DM8静态边界。
+  **覆盖 AC:** AC-15、AC-48、AC-56
+  **依赖:** T107
+
+- [x] **T109** — 删除维护轻量容量预占合同
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_runtime_purge_capacity.py`
+  **逻辑/验收**:删除以固定helper预算预占,不因原应用档位误拒绝;额度不足与成功配对。
+  **覆盖 AC:** AC-11、AC-18、AC-48
+  **依赖:** T097
+
+- [x] **T110** — 删除维护轻量容量预占
+  **类型**:实现
+  **文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_operation_service.py`
+  **逻辑/验收**:保持原子节点锁,purge只为受限helper预占;不免除容量控制。
+  **覆盖 AC:** AC-11、AC-18、AC-48
+  **依赖:** T109
+  **配对:** T109
+
+- [x] **T111** — Calico 元数据来源及收尾合同
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_admission.py`
+  **逻辑/验收**:可信CNI可维护指定注解,manager仅原样保留同UID旧值;拒绝伪造、增改删、status和spec越权。
+  **覆盖 AC:** AC-03、AC-14、AC-48、AC-52
+  **依赖:** T011
+
+- [x] **T112** — Calico 元数据来源隔离
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/k8s_admission.py`
+  **逻辑/验收**:精确保留已认证CNI字段以允许fenced finalizer收尾,不放行通用注解前缀。
+  **覆盖 AC:** AC-03、AC-14、AC-48、AC-52
+  **依赖:** T111
+  **配对:** T111
+
+- [x] **T113** — scratch 写入前卷校验合同
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_helper.py`、`src/runtime-manager/tests/test_k8s_templates.py`
+  **逻辑/验收**:prepare和assemble在任何写入/材料投射前执行只读guard,校验真实文件系统/容量/安装标识,负例拒绝。
+  **覆盖 AC:** AC-33、AC-34、AC-42、AC-44
+  **依赖:** T009
+
+- [x] **T114** — scratch 写入前卷校验
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/k8s_helper.py`、`src/runtime-manager/runtime_manager/k8s_templates.py`
+  **逻辑/验收**:可信guard固定命令、无业务身份,缺挂载/错误marker/无界容量拒绝。
+  **覆盖 AC:** AC-33、AC-34、AC-42、AC-44
+  **依赖:** T113
+  **配对:** T113
+
+- [x] **T115** — scratch 预算与安装标识接线
+  **类型**:实现
+  **文件**:`src/runtime-manager/tests/test_kubernetes_executor.py`、`src/runtime-manager/runtime_manager/executors/kubernetes.py`
+  **逻辑/验收**:逐purpose区分production与scratch预算,preview/probe不碰生产卷;测试先复现预算丢失。
+  **覆盖 AC:** AC-33、AC-34、AC-42、AC-44
+  **依赖:** T114、T100
+  **配对:** T032
+
+- [x] **T116** — static launcher 兼容合同
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_helper.py`、`src/runtime-manager/tests/test_k8s_templates.py`
+  **逻辑/验收**:标准bundle的冻结nginx argv与PORT经受信entrypoint消费,完整image保持自身入口。
+  **覆盖 AC:** AC-04、AC-24、AC-44、AC-56
+  **依赖:** T008
+
+- [x] **T117** — static launcher 保留
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/k8s_templates.py`
+  **逻辑/验收**:标准static bundle使用固定launcher生成/tmp配置,避免K8s command绕过镜像ENTRYPOINT。
+  **覆盖 AC:** AC-04、AC-24、AC-44、AC-56
+  **依赖:** T116
+  **配对:** T116
+
+- [x] **T118** — Node launcher 兼容收尾
+  **类型**:实现
+  **文件**:`src/runtime-manager/tests/test_k8s_templates.py`、`src/runtime-manager/runtime_manager/k8s_templates.py`
+  **逻辑/验收**:先测试node/npm/脚本名/--eval,再保留绝对路径Node launcher;完整image不包装。
+  **覆盖 AC:** AC-04、AC-24、AC-44、AC-56
+  **依赖:** T117
+  **配对:** T008
+
+- [x] **T119** — 探测刷新后的 sandbox 冻结
+  **类型**:实现
+  **文件**:`src/runtime-manager/tests/test_kubernetes_executor.py`、`src/runtime-manager/runtime_manager/executors/kubernetes.py`
+  **逻辑/验收**:同resource_ref重试按原Pod记录冻结RuntimeClass和deadline;先核对完整操作作用域,保留intent模板比对。
+  **覆盖 AC:** AC-07、AC-19、AC-20、AC-40、AC-48
+  **依赖:** T100
+  **配对:** T032
+
+- [x] **T120** — Chart 安装标识传递
+  **类型**:实现
+  **文件**:`deploy/kubernetes/app-runtime/templates/workloads.yaml`、`src/runtime-manager/tests/test_k8s_installation.py`
+  **逻辑/验收**:descriptor包含installation_id,与静态PV marker一致;先测试明确缺字段。
+  **覆盖 AC:** AC-34、AC-39、AC-42
+  **依赖:** T064
+  **配对:** T064
+
+- [x] **T121** — helper 可信解析工具链合同
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_image_release.py`
+  **逻辑/验收**:验证prepare.py、Node/npm、本架构库、同版本wheelhouse、构建顺序与禁网真实resolve,不能只检查import。
+  **覆盖 AC:** AC-24、AC-42、AC-44、AC-45、AC-54
+  **依赖:** T104
+
+- [x] **T122** — helper 可信解析工具链
+  **类型**:基础设施
+  **文件**:`src/runtime-manager/Dockerfile.helper`
+  **逻辑/验收**:同版本manager/Node/Python preparer为唯一工具来源;离线构建自身wheel,复制SDK轮子闭包与许可证,不复制宿主/glibc。
+  **覆盖 AC:** AC-24、AC-42、AC-44、AC-45、AC-54
+  **依赖:** T121
+
+- [x] **T123** — 镜像实际离线解析烟测
+  **类型**:基础设施
+  **文件**:`scripts/build_runtime_images.sh`、`src/runtime-manager/tests/test_image_release.py`
+  **逻辑/验收**:原生image内禁网执行requirements/uv.lock/Node/static解析,ignore-scripts不得运行应用hook;任一失败禁止推送。
+  **覆盖 AC:** AC-24、AC-42、AC-44、AC-45、AC-54
+  **依赖:** T122、T103
+
+- [x] **T124** — 隔离平台 bootstrap 合同
+  **类型**:测试
+  **文件**:`scripts/fixtures/f059/test_bootstrap.py`
+  **逻辑/验收**:覆盖真实API形状、env私密权限/混合Docker配置/作用域、人工审批、服务账号及本地HTTP/SQLite/SDK/WS。
+  **覆盖 AC:** AC-36、AC-37、AC-46、AC-55
+  **依赖:** T105
+
+- [x] **T125** — 隔离平台 API bootstrap
+  **类型**:实现
+  **文件**:`scripts/bootstrap_f059_acceptance.py`
+  **逻辑/验收**:仅专用全新测试实例;真实部门挂租户、自然人/人工审批、owner mode-S、固定自定义profile;不直接SQL,不泄露凭据。
+  **覆盖 AC:** AC-36、AC-37、AC-46、AC-55
+  **依赖:** T124
+  **配对:** T124
+
+- [x] **T126** — 真实应用协议夹具
+  **类型**:实现
+  **文件**:`scripts/fixtures/f059/python_demo.py`、`scripts/fixtures/f059/node_demo.js`
+  **逻辑/验收**:真实HTTP/SQLite/WS和SDK附件往返;模型/检索未配置明确不可测,禁止返回固定成功。
+  **覆盖 AC:** AC-04、AC-11、AC-36、AC-46、AC-55
+  **依赖:** T125
+  **配对:** T124
+
+- [x] **T127** — 完整镜像及离线材料夹具
+  **类型**:基础设施
+  **文件**:`scripts/fixtures/f059/Dockerfile.demo`、`scripts/fixtures/f059/offline-manifest.example.json`
+  **逻辑/验收**:完整应用镜像实际包含代码;物料模板只说明格式,不伪造digest。
+  **覆盖 AC:** AC-24、AC-27、AC-42、AC-45
+  **依赖:** T125
+
+- [x] **T128** — 平台验收夹具说明
+  **类型**:基础设施
+  **文件**:`scripts/fixtures/f059/README.md`
+  **逻辑/验收**:提供准备/真实API/阶段验收/安全清理指令,区分本地协议烟测与真实集群证据。
+  **覆盖 AC:** AC-42、AC-54、AC-55
+  **依赖:** T125
+
+- [x] **T129** — helper 及 PVC 作用域校验接线
+  **类型**:实现
+  **文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_kubernetes_service.py`、`src/backend/test/app_runtime/test_runtime_kubernetes_service.py`
+  **逻辑/验收**:允许固定storage-guard;所有production/scratch按app/op冻结PVC,跨app同namespace卷仍拒绝;更新旧最小fixture。
+  **覆盖 AC:** AC-03、AC-33、AC-34、AC-48
+  **依赖:** T097
+  **配对:** T016
+
+- [x] **T130** — 自定义 bundle launcher 合同说明
+  **类型**:基础设施
+  **文件**:`deploy/kubernetes/app-runtime/README.md`
+  **逻辑/验收**:明确Python解释器、固定Node/static launcher、只读挂载和非root要求;不适配该合同的完整image走既有入口。
+  **覆盖 AC:** AC-04、AC-42、AC-44、AC-56
+  **依赖:** T118
+
+- [x] **T131** — 源码大小限制冻结接线
+  **类型**:实现
+  **文件**:`src/runtime-manager/tests/test_kubernetes_executor.py`、`src/runtime-manager/runtime_manager/executors/kubernetes.py`
+  **逻辑/验收**:先捕获prepare各phase丢失source_limits,再深拷贝payload冻结值;无旧字段保持兼容默认,不读现场新配置。
+  **覆盖 AC:** AC-24、AC-44、AC-56
+  **依赖:** T100
+  **配对:** T032
+
+- [x] **T132** — SDK 既有平台地址变量合同
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_runtime_k8s_delivery_coordinator.py`
+  **逻辑/验收**:核对committed SDK wheel实际变量名及coordinator密封环境,Docker/K8s均能消费。
+  **覆盖 AC:** AC-36、AC-46、AC-56
+  **依赖:** T049
+
+- [x] **T133** — SDK 平台地址变量接线
+  **类型**:实现
+  **文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_delivery_coordinator.py`
+  **逻辑/验收**:v2增加BISHENG_PLATFORM_API_BASE并保留旧key;不改变模型/附件身份,不以测试mock代替SDK实际调用。
+  **覆盖 AC:** AC-36、AC-46、AC-56
+  **依赖:** T132
+  **配对:** T132
+
+- [x] **T134** — 安装探测安全失败分类
+  **类型**:实现
+  **文件**:`scripts/verify_k8s_runtime.py`、`src/runtime-manager/tests/test_k8s_installation.py`
+  **逻辑/验收**:先测试常量reason与异常分类,再保留probe/cleanup/publish失败;不打印任意异常文本/对象/凭据,不放宽ready。
+  **覆盖 AC:** AC-19、AC-20、AC-54、AC-55
+  **依赖:** T065
+  **配对:** T064
+
+- [x] **T135** — 显式公开 CA 的 HTTP/WS 合同
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_k8s_e2e_contract.py`
+  **逻辑/验收**:本地真实HTTPS/WSS正确CA允许、错误CA拒绝,路径/私钥/符号链接拒绝,无verify=False。
+  **覆盖 AC:** AC-36、AC-46、AC-55
+  **依赖:** T105
+
+- [x] **T136** — 真实业务 E2E 公开 CA 接线
+  **类型**:实现
+  **文件**:`scripts/e2e_k8s_runtime.py`、`src/backend/test/e2e/helpers/vibe_runtime.py`
+  **逻辑/验收**:F059_E2E_CA_FILE构造保留系统根的严格SSLContext,HTTP和WSS统一使用,未配置时默认信任不变。
+  **覆盖 AC:** AC-36、AC-46、AC-55
+  **依赖:** T135
+  **配对:** T135
+
+- [x] **T137** — 固定镜像 profile 新修订合同
+  **类型**:测试
+  **文件**:`scripts/fixtures/f059/test_bootstrap.py`
+  **逻辑/验收**:N=1兼容、N=2包内引用/default/API导入一致,不覆写相同修订的变更镜像。
+  **覆盖 AC:** AC-39、AC-42、AC-44、AC-55
+  **依赖:** T124
+
+- [x] **T138** — 验收 profile 修订参数
+  **类型**:实现
+  **文件**:`scripts/bootstrap_f059_acceptance.py`、`scripts/fixtures/f059/README.md`
+  **逻辑/验收**:正整数--profile-revision控制导入、bundle固定引用;source-image遵守现行schema使用语言默认,实际解析后冻结。
+  **覆盖 AC:** AC-39、AC-42、AC-44、AC-55
+  **依赖:** T137
+  **配对:** T137
+
+- [x] **T139** — Kubernetes CREATE 默认对象合同
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_admission.py`
+  **逻辑/验收**:依据固定v1.28.15真实策略校验Pending/QoS与精确空status、omitempty空env;终态/额外状态/非空env/伪造来源仍拒绝。
+  **覆盖 AC:** AC-13、AC-30、AC-49、AC-55
+  **依赖:** T011
+
+- [x] **T140** — Kubernetes CREATE 默认对象兼容
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/k8s_admission.py`
+  **逻辑/验收**:只容许API正常默认值与空env缺省等价,不放松status来源、CNI身份或模板冻结。
+  **覆盖 AC:** AC-13、AC-30、AC-49、AC-55
+  **依赖:** T139
+  **配对:** T139
+
+- [x] **T141** — 协调器旧 scratch 夹具适配
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_runtime_kubernetes_coordination.py`
+  **逻辑/验收**:增加当前op的独占scratch PVC,不改变原探测失效、连续保护和停止证据断言。
+  **覆盖 AC:** AC-19、AC-20、AC-48、AC-56
+  **依赖:** T129
+
+- [x] **T142** — 验收管理员授权可重复执行
+  **类型**:实现
+  **文件**:`scripts/bootstrap_f059_acceptance.py`、`scripts/fixtures/f059/test_bootstrap.py`
+  **逻辑/验收**:先GET现存tenant admins,已存在跳过重复FGA授权;查询失败/格式错停止,不改业务授权API。
+  **覆盖 AC:** AC-36、AC-55
+  **依赖:** T137
+  **配对:** T124
+
+- [x] **T143** — canary 配置投射刷新合同
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_installation.py`
+  **逻辑/验收**:CM成功replace后确认UID/RV,只匹配本controlNS Running admission Pod、受信整卷挂载;冲突/替换/错scope/无目标均拒绝,保留spec和其他注解。
+  **覆盖 AC:** AC-19、AC-20、AC-30、AC-54、AC-55
+  **依赖:** T064
+  **配对:** T144
+
+- [x] **T144** — canary 配置投射刷新
+  **类型**:实现
+  **文件**:`scripts/verify_k8s_runtime.py`
+  **逻辑/验收**:外部installer以UID/RV JSON Patch只写公开revision注解触发挂载刷新,不修改TTL/RBAC/readiness、不重启Deployment;仍等待真实admission消费计划。
+  **覆盖 AC:** AC-19、AC-20、AC-30、AC-54、AC-55
+  **依赖:** T143
+  **配对:** T143
+
+- [x] **T145** — 过期 canary 恢复围栏合同
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_installation.py`
+  **逻辑/验收**:未调度删除中Pod逐UID实测binding拒绝及二次读取;活跃计划/替换/错误拒绝/API不确定/未知状态不释放;停止证据不可跨资源复用。
+  **覆盖 AC:** AC-19、AC-20、AC-30、AC-48、AC-49、AC-55
+  **依赖:** T064
+  **配对:** T146
+
+- [x] **T146** — 过期 canary 逐资源恢复
+  **类型**:实现
+  **文件**:`scripts/verify_k8s_runtime.py`
+  **逻辑/验收**:恢复仅接管自然过期的同UID计划;对未调度Pod获取独立绑定围栏证据,已绑定Pod等待可信终态;按停止确认、finalizer、对象消失、无PVC引用顺序清理。
+  **覆盖 AC:** AC-19、AC-20、AC-30、AC-48、AC-49、AC-55
+  **依赖:** T145
+  **配对:** T145
+
+- [x] **T147** — CNI 身份与能力报告绑定合同
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_installation.py`
+  **逻辑/验收**:Chart描述与admission身份同源;仅变更CNI身份使旧报告失效;只更新probe_report不改变描述摘要。
+  **覆盖 AC:** AC-19、AC-20、AC-30、AC-55
+  **依赖:** T143
+  **配对:** T148
+
+- [x] **T148** — 描述纳入 CNI 身份
+  **类型**:基础设施
+  **文件**:`deploy/kubernetes/app-runtime/templates/workloads.yaml`
+  **逻辑/验收**:内部descriptor增加cni_username,与execution.cniUsername及admission ENV同源,不改摘要算法或对外部署API。
+  **覆盖 AC:** AC-19、AC-20、AC-30、AC-55
+  **依赖:** T147
+  **配对:** T147
+
+- [x] **T149** — 租户上下文默认值合同
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_runtime_model_contracts.py`
+  **逻辑/验收**:记录已保存模型草稿验证:None默认等待上下文,存储非空有索引;未声明/None/0自动填充,显式租户保留,缺上下文拒绝写入;DM8仅静态。
+  **覆盖 AC:** AC-15、AC-48、AC-54、AC-56
+  **依赖:** T073
+  **配对:** T150
+
+- [x] **T150** — 运行记录租户默认值修订
+  **类型**:基础设施
+  **文件**:`src/backend/bisheng/app_runtime/domain/models/runtime_delivery.py`
+  **逻辑/验收**:ScopedRuntimeRecord默认None且nullable=False/index=True;复用before_flush上下文注入,不改变既有DB约束,不修改公共Linsight模型。
+  **覆盖 AC:** AC-15、AC-48、AC-54、AC-56
+  **依赖:** T149
+  **配对:** T149
+
+- [x] **T151** — Helm 升级保留 canary 计划合同
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_installation.py`
+  **逻辑/验收**:覆盖升级保留运行中plans/profile_images和首次初始化;缺对象/归属错误/非法数据拒绝,旧无label对象需同release descriptor范围证据。
+  **覆盖 AC:** AC-19、AC-20、AC-30、AC-40、AC-55
+  **依赖:** T147
+  **配对:** T152
+
+- [x] **T152** — Helm 升级保留恢复计划
+  **类型**:基础设施
+  **文件**:`deploy/kubernetes/app-runtime/templates/services.yaml`
+  **逻辑/验收**:升级只读取同release/namespace的合法canary CM并保留动态计划;初装才初始化;合法旧资源通过descriptor安装范围校验后补标识,不静默重置计划。
+  **覆盖 AC:** AC-19、AC-20、AC-30、AC-40、AC-55
+  **依赖:** T151
+  **配对:** T151
+
+- [x] **T153** — 原生 Docker context 构建合同
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_image_release.py`
+  **逻辑/验收**:当前context选择对应docker driver builder,禁止依赖不可用default context;保留host/daemon/image架构一致与原生烟测门禁。
+  **覆盖 AC:** AC-42、AC-54
+  **依赖:** T123
+  **配对:** T154
+
+- [x] **T154** — 原生 context 构建入口
+  **类型**:实现
+  **文件**:`scripts/build_runtime_images.sh`
+  **逻辑/验收**:命令级选择当前原生daemon的docker driver builder,不改全局context;同版本构建、离线resolver与烟测通过后生成清单。
+  **覆盖 AC:** AC-42、AC-54
+  **依赖:** T153
+  **配对:** T153
+
+- [x] **T155** — 预览访问和只读 checkpoint 合同
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_k8s_e2e_contract.py`
+  **逻辑/验收**:实际访问审批预览,配对owner/nonowner/foreign拒绝,预览前后生产行不变;checkpoint只GET且请求前校验时限、app范围。
+  **覆盖 AC:** AC-17、AC-18、AC-27、AC-35、AC-46、AC-55
+  **依赖:** T133
+  **配对:** T156
+
+- [x] **T156** — 预览入口与只读故障观察
+  **类型**:实现
+  **文件**:`src/backend/test/e2e/helpers/vibe_runtime.py`
+  **逻辑/验收**:走真实预览入口;故障after不调用带写入的observe,不将API观察当作故障注入证明。
+  **覆盖 AC:** AC-17、AC-18、AC-27、AC-35、AC-46、AC-55
+  **依赖:** T155
+  **配对:** T155
+
+- [x] **T157** — 真实 pytest 入口 CA 一致性
+  **类型**:测试
+  **文件**:`src/backend/test/e2e/test_e2e_vibe_k8s_runtime.py`、`src/backend/test/app_runtime/test_k8s_e2e_contract.py`
+  **逻辑/验收**:pytest live与CLI都用显式证书SSLContext;TLS/WS不关闭证书验证。
+  **覆盖 AC:** AC-42、AC-46、AC-55
+  **依赖:** T155
+  **配对:** T155
+
+- [x] **T158** — 精确故障操作合同
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_k8s_e2e_contract.py`
+  **逻辑/验收**:suite slug/app/op/snapshot范围、fresh generation与ack正反,取消/恢复配对权限和终态拒绝;恢复数据对比,不以新增快照数判通过。
+  **覆盖 AC:** AC-07、AC-27、AC-35、AC-43、AC-49、AC-55
+  **依赖:** T155
+  **配对:** T159
+
+- [x] **T159** — 精确取消及快照恢复 harness
+  **类型**:实现
+  **文件**:`src/backend/test/e2e/helpers/vibe_runtime.py`
+  **逻辑/验收**:只对本suite精确operation观测和取消;恢复显式确认并比对预期rows,允许保护快照去重,公开接口缺关联证据时记录未验证。
+  **覆盖 AC:** AC-07、AC-27、AC-35、AC-43、AC-49、AC-55
+  **依赖:** T158
+  **配对:** T158
+
+- [x] **T163** — 故障操作 CLI 参数合同
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_k8s_e2e_contract.py`
+  **逻辑/验收**:验证fault-operation阶段的slug、operation、snapshot、expected generation、ack与expected rows参数正反。
+  **覆盖 AC:** AC-07、AC-35、AC-43、AC-55
+  **依赖:** T159
+  **配对:** T160
+
+- [x] **T160** — 故障操作 CLI 入口
+  **类型**:实现
+  **文件**:`scripts/e2e_k8s_runtime.py`
+  **逻辑/验收**:公开平台API执行显式suite范围操作,返回真实业务状态;不调用集群接口或自动注入基础设施故障。
+  **覆盖 AC:** AC-07、AC-35、AC-43、AC-55
+  **依赖:** T163
+  **配对:** T163
+
+- [x] **T161** — 空 finalizers 序列化合同
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_admission.py`
+  **逻辑/验收**:真实API省略空finalizers时,停止未确认仍拒绝,确认后允许;CNI注解保留、额外finalizer/伪造状态仍拒绝。
+  **覆盖 AC:** AC-13、AC-20、AC-49、AC-55
+  **依赖:** T111
+  **配对:** T162
+
+- [x] **T162** — 准入兼容省略空 finalizers
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/k8s_admission.py`
+  **逻辑/验收**:仅metadata.finalizers空数组按omitempty规范化为省略;不改变同UID/停止确认或非空finalizer严格比较。
+  **覆盖 AC:** AC-13、AC-20、AC-49、AC-55
+  **依赖:** T161
+  **配对:** T161
+
+- [x] **T164** — kubectl 子资源拒绝合同
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_installation.py`
+  **逻辑/验收**:exec/status必须以--subresource明确请求;create pods允许不代表exec允许。
+  **覆盖 AC:** AC-13、AC-14、AC-55
+  **依赖:** T145
+  **配对:** T165
+
+
+- [x] **T165** — kubectl 子资源显式检查
+  **类型**:实现
+  **文件**:`scripts/verify_k8s_runtime.py`
+  **逻辑/验收**:使用资源TYPE加--subresource而非TYPE/NAME,真实核对exec和status拒绝。
+  **覆盖 AC:** AC-13、AC-14、AC-55
+  **依赖:** T164
+  **配对:** T164
+
+
+- [x] **T166** — 存储控制器身份配置合同
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_installation.py`
+  **逻辑/验收**:同源ENV/descriptor;非法、空、重复及自然用户身份拒绝,变化后旧报告失效。
+  **覆盖 AC:** AC-19、AC-20、AC-30、AC-55
+  **依赖:** T147
+  **配对:** T167
+
+
+- [x] **T167** — 存储控制器配置与schema
+  **类型**:基础设施
+  **文件**:`deploy/kubernetes/app-runtime/values.yaml`、`deploy/kubernetes/app-runtime/values.schema.json`
+  **逻辑/验收**:显式控制器用户名列表,默认兼容既有部署;不赋予新RBAC权限。
+  **覆盖 AC:** AC-19、AC-20、AC-30、AC-55
+  **依赖:** T166
+  **配对:** T166
+
+
+- [x] **T168** — 存储控制器环境与描述同源
+  **类型**:基础设施
+  **文件**:`deploy/kubernetes/app-runtime/templates/workloads.yaml`
+  **逻辑/验收**:admission ENV与descriptor从同一execution.storageControllerUsernames读取。
+  **覆盖 AC:** AC-19、AC-20、AC-30、AC-55
+  **依赖:** T167
+
+
+- [x] **T169** — API未绑定Pod零宽限删除合同
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_admission.py`
+  **逻辑/验收**:仅同UID/RV、Pending未绑定且无任意writer状态可接受API规范化零宽限;绑定、Unknown、终态、过期RV拒绝。
+  **覆盖 AC:** AC-13、AC-20、AC-49、AC-55
+  **依赖:** T161
+  **配对:** T170
+
+
+- [x] **T170** — 未绑定Pod删除选项兼容
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/k8s_admission.py`
+  **逻辑/验收**:兼容API server把未绑定Pod删除宽限归零;保留UID/RV和停止证据围栏,不放开有写入历史的强删。
+  **覆盖 AC:** AC-13、AC-20、AC-49、AC-55
+  **依赖:** T169
+  **配对:** T169
+
+
+- [x] **T171** — PSA负例和dry-run撤销合同
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_installation.py`
+  **逻辑/验收**:schema有效的特权Pod必须由真实PSA拒绝;所有dryRun失败路径撤销未创建计划并保留双重失败。
+  **覆盖 AC:** AC-13、AC-19、AC-20、AC-55
+  **依赖:** T164
+  **配对:** T172
+
+
+- [x] **T172** — PSA真实拒绝及计划清理
+  **类型**:实现
+  **文件**:`scripts/verify_k8s_runtime.py`
+  **逻辑/验收**:privileged与allowPrivilegeEscalation同时设真;只认PodSecurity拒绝,finally撤销UID空的dryRun计划,清理失败不能吞掉。
+  **覆盖 AC:** AC-13、AC-19、AC-20、AC-55
+  **依赖:** T171
+  **配对:** T171
+
+
+- [x] **T173** — 原生隔离烟测可读目录合同
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_image_release.py`
+  **逻辑/验收**:umask022/077下UID10001可遍历shadow并读取poison文件,防止权限拒绝导致假阳性。
+  **覆盖 AC:** AC-42、AC-54
+  **依赖:** T153
+  **配对:** T174
+
+
+- [x] **T174** — 原生隔离烟测目录权限
+  **类型**:实现
+  **文件**:`scripts/build_runtime_images.sh`
+  **逻辑/验收**:shadow0755、poison0644;实际-I命令先读候选再确认只导入trusted模块,不改变运行镜像权限。
+  **覆盖 AC:** AC-42、AC-54
+  **依赖:** T173
+  **配对:** T173
+
+
+- [x] **T175** — 终态Pod删除规范化合同
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_admission.py`
+  **逻辑/验收**:同UID/RV可信终态0允许;伪造对象、active/Unknown/NodeLost、缺少终止证明和策略/finalizer错误拒绝。
+  **覆盖 AC:** AC-13、AC-20、AC-49、AC-55
+  **依赖:** T169
+  **配对:** T176
+
+
+- [x] **T176** — 终态Pod删除选项兼容
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/k8s_admission.py`
+  **逻辑/验收**:仅manager授权后验证API oldObject的停止事实,兼容Core规范化0;不代替停止确认或释放finalizer。
+  **覆盖 AC:** AC-13、AC-20、AC-49、AC-55
+  **依赖:** T175
+  **配对:** T175
+
+
+- [x] **T177** — SQLite随机哨兵升级合同
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_image_release.py`
+  **逻辑/验收**:升级前由测试写随机数据,升级后核对值和备份,避免两版默认初始化相同造成假阳性。
+  **覆盖 AC:** AC-24、AC-25、AC-26、AC-55
+  **依赖:** T173
+  **配对:** T178
+
+
+- [x] **T178** — 真实SQLite哨兵烟测
+  **类型**:实现
+  **文件**:`scripts/smoke_runtime_delivery.py`
+  **逻辑/验收**:真实Engine写入升级前独有随机值,v2保留并在确认备份中查询同值;不以默认初始化行代替持久化。
+  **覆盖 AC:** AC-24、AC-25、AC-26、AC-55
+  **依赖:** T177
+  **配对:** T177
+
+
+- [x] **T179** — Docker诊断真实客户端合同
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_operations_v2.py`
+  **逻辑/验收**:真实PlatformClient仅提供node_snapshot;验证真实签名plan请求和平台错误拒绝本地容量回退。
+  **覆盖 AC:** AC-01、AC-10、AC-30、AC-48、AC-50、AC-55
+  **依赖:** T175
+  **配对:** T180
+
+
+- [x] **T180** — Docker诊断平台账本接线
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/api/platform_observe.py`
+  **逻辑/验收**:修正不存在的node_plan调用为node_snapshot;保留Docker公开字段形状,不添加K8s专属字段。
+  **覆盖 AC:** AC-01、AC-10、AC-30、AC-48、AC-50、AC-55
+  **依赖:** T179
+  **配对:** T179
+
+
+- [x] **T181** — Docker既有诊断E2E合同
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_k8s_e2e_contract.py`
+  **逻辑/验收**:Docker无K8s专属ready/executor_kind允许;账本不可读、错误协议/类型/运行时、缺失或失败预检拒绝;K8s保持严格ready。
+  **覆盖 AC:** AC-01、AC-30、AC-32、AC-55
+  **依赖:** T163
+  **配对:** T182
+
+
+- [x] **T182** — Docker诊断E2E兼容
+  **类型**:实现
+  **文件**:`src/backend/test/e2e/helpers/vibe_runtime.py`
+  **逻辑/验收**:按已知执行器核验现有公开形状;Docker必须v2、后端可用、容量可读、三运行时与全部预检成功;不放松K8s。
+  **覆盖 AC:** AC-01、AC-30、AC-32、AC-55
+  **依赖:** T181
+  **配对:** T181
+
+
+- [x] **T183** — 验收批次应用标识合同
+  **类型**:测试
+  **文件**:`scripts/fixtures/f059/test_bootstrap.py`
+  **逻辑/验收**:删除保留slug,显式批次后缀正反、长度约束、包manifest/scenario一致、准备和bootstrap一致;不重建身份。
+  **覆盖 AC:** AC-35、AC-55、AC-56
+  **依赖:** T181
+  **配对:** T184
+
+
+- [x] **T184** — 唯一应用验收批次后缀
+  **类型**:实现
+  **文件**:`scripts/bootstrap_f059_acceptance.py`
+  **逻辑/验收**:增加可选显式application-suffix,严格范围并冻结本批后缀;新批用独立journal,旧应用按原账本清理,不删审计行。
+  **覆盖 AC:** AC-35、AC-55、AC-56
+  **依赖:** T183
+  **配对:** T183
+
+
+- [x] **T185** — 真实报告的公开诊断合同
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_observe.py`
+  **逻辑/验收**:使用真实executor与descriptor投射,无raw ready字段的有效报告通过;过期、身份变化、mandatory失败和API不可达拒绝,刷新后恢复。
+  **覆盖 AC:** AC-01、AC-30、AC-31、AC-32、AC-55
+  **依赖:** T179
+  **配对:** T186
+
+- [x] **T186** — 公开诊断复用实时评估
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/api/platform_observe.py`
+  **逻辑/验收**:公开诊断调用既有probe_report评估,不修改报告TTL、摘要、公开形状或能力准入。
+  **覆盖 AC:** AC-01、AC-30、AC-31、AC-32、AC-55
+  **依赖:** T185
+  **配对:** T185
+
+- [x] **T187** — WAL侧文件与SQL只读配对
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_data_executor.py`、`src/runtime-manager/tests/test_data_executor_wal.py`
+  **逻辑/验收**:真实SQLite验证无侧文件及已提交WAL;单应用helper可创建侧文件,读SQL仍拒绝写入、读取最新已提交值;实际Docker权限和隔离配对。
+  **覆盖 AC:** AC-10、AC-16、AC-34、AC-36、AC-46、AC-55
+  **依赖:** T066
+  **配对:** T188
+
+- [x] **T188** — 单应用数据helper的WAL目录访问
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/data_executor.py`
+  **逻辑/验收**:单应用数据库目录允许SQLite创建/锁定WAL侧文件,rootfs仍只读、无网络、非root;读连接继续mode=ro,不使用immutable忽略WAL或强制checkpoint。
+  **覆盖 AC:** AC-10、AC-16、AC-34、AC-36、AC-46、AC-55
+  **依赖:** T187
+  **配对:** T187
+
+- [x] **T189** — 恢复后等待精确运行操作终态
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_k8s_e2e_contract.py`
+  **逻辑/验收**:公开app可先online;验收须等待同一resume操作completed/cleanup complete,并复核同app/operation/version/generation。失败和身份变化不能放行,不吞维护错误。
+  **覆盖 AC:** AC-29、AC-33、AC-35、AC-36、AC-55
+  **依赖:** T182
+  **配对:** T190
+
+- [x] **T190** — 业务验收恢复操作同步
+  **类型**:实现
+  **文件**:`src/backend/test/e2e/helpers/vibe_runtime.py`
+  **逻辑/验收**:仅修正验收工具同步,ready/serving阶段继续轮询,终态后核对身份与数据;业务API和运行时锁不改。
+  **覆盖 AC:** AC-29、AC-33、AC-35、AC-36、AC-55
+  **依赖:** T189
+  **配对:** T189
+
+- [x] **T191** — 离线源码验收依赖物料合同
+  **类型**:测试
+  **文件**:`scripts/fixtures/f059/test_bootstrap.py`
+  **逻辑/验收**:只接收显式清单和逐wheel SHA的纯Python物料;拒绝缺依赖、篡改、native wheel、路径穿越、覆盖与pth;真实SDK断言不变。
+  **覆盖 AC:** AC-04、AC-24、AC-36、AC-46、AC-55
+  **依赖:** T184
+  **配对:** T192
+
+- [x] **T192** — 源码验收包显式离线vendor物料
+  **类型**:实现
+  **文件**:`scripts/bootstrap_f059_acceptance.py`
+  **逻辑/验收**:显式source-wheelhouse从固定材料清单校验并保留真实SDK/httpx闭包及许可,只向source-image包加入_vendor与启动路径引导;不读取开发venv、不pip、不联网,不改生产模板或base。
+  **覆盖 AC:** AC-04、AC-24、AC-36、AC-46、AC-55
+  **依赖:** T191
+  **配对:** T191
+
+- [x] **T193** — 离线源码验收前置说明
+  **类型**:基础设施
+  **文件**:`scripts/fixtures/f059/README.md`
+  **逻辑/验收**:区分Docker source-image裸base与K8s profile;记录离线vendor材料准备和许可,完整镜像需显式提供digest;说明本地材料烟测不代表平台验收。
+  **覆盖 AC:** AC-04、AC-24、AC-36、AC-46、AC-55
+  **依赖:** T192
+
+- [x] **T194** — 真实manifest引用与冻结profile分离
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_kubernetes_executor.py`、`src/runtime-manager/tests/test_oci_assembler.py`
+  **逻辑/验收**:使用真实BundleDelivery.model_dump的显式profile字符串和已解析payload.profile,覆盖prepare及运行resolved_delivery;错误类型在资源调用前明确拒绝,不被宽松桩掩盖。
+  **覆盖 AC:** AC-04、AC-24、AC-25、AC-36、AC-55
+  **依赖:** T186
+  **配对:** T195
+
+- [x] **T195** — K8s计划仅消费已解析profile对象
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/executors/kubernetes.py`
+  **逻辑/验收**:prepare只取已解析payload.profile,运行取resolved_delivery.profile;manifest字符串仅作为引用,不当作dict、不默默fallback错误对象,保持平台冻结/范围校验。
+  **覆盖 AC:** AC-04、AC-24、AC-25、AC-36、AC-55
+  **依赖:** T194
+  **配对:** T194
+
+- [x] **T196** — 无运行分配与平台故障状态配对
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_observe.py`
+  **逻辑/验收**:真实PlatformClient的application_not_found/404映射为无实例;403/503/超时及资源故障保持不可用,Docker/K8s和正常实例保持配对。
+  **覆盖 AC:** AC-01、AC-33、AC-35、AC-36、AC-55
+  **依赖:** T186
+  **配对:** T197
+
+- [x] **T197** — 只读实例查询保留无分配语义
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/api/platform_observe.py`
+  **逻辑/验收**:只将platform.application精确application_not_found+404转换NotFoundError;不将任意平台/API资源失败视为无写入者,不放宽删除与停止围栏。
+  **覆盖 AC:** AC-01、AC-33、AC-35、AC-36、AC-55
+  **依赖:** T196
+  **配对:** T196
+
+- [x] **T198** — 控制器空闲过期与失租恢复围栏
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_kubernetes_executor.py`
+  **逻辑/验收**:真实30秒租约和PlatformClient错误配对:空闲过期重新CAS领取;活跃异holder、未知响应和503不放行,既有UID恢复与新epoch事实及并发锁不生成虚假停止证据。
+  **覆盖 AC:** AC-01、AC-13、AC-15、AC-33、AC-35、AC-36、AC-43、AC-48、AC-55
+  **依赖:** T197
+  **配对:** T199
+
+- [x] **T199** — 过期控制器租约按精确失租恢复
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/executors/kubernetes.py`
+  **逻辑/验收**:精确409/controller_lease_lost使缓存失效,fresh node快照后CAS领取;其他失败不吞,当前不确定动作拒绝;按真实到期限制缓存,不改平台API及停止围栏。
+  **覆盖 AC:** AC-01、AC-13、AC-15、AC-33、AC-35、AC-36、AC-43、AC-48、AC-55
+  **依赖:** T198
+  **配对:** T198
+
+- [x] **T200** — backend真实冻结profile资源校验合同
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_k8s_frozen_profile_contract.py`
+  **逻辑/验收**:真实manifest model_dump与register_resource/SQLite账本配对:prepare已解析profile、运行冻结profile、非法类型及image/purge/source-image白名单;拒绝前不写资源。
+  **覆盖 AC:** AC-04、AC-13、AC-15、AC-24、AC-25、AC-36、AC-48、AC-55
+  **依赖:** T199
+  **配对:** T201
+
+- [x] **T202** — 旧资源测试fixture补齐真实协议字段
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_runtime_kubernetes_coordination.py`、`src/backend/test/app_runtime/test_runtime_kubernetes_service.py`
+  **逻辑/验收**:prepare fixture提供manifest delivery与已解析profile;直接Pod校验替身提供真实kind/image mode,保留探测/跨卷/停止证据全部拒绝断言。
+  **覆盖 AC:** AC-04、AC-13、AC-15、AC-24、AC-25、AC-36、AC-48、AC-55
+  **依赖:** T200
+
+- [x] **T201** — backend按阶段消费已解析profile
+  **类型**:实现
+  **文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_kubernetes_service.py`
+  **逻辑/验收**:prepare仅读payload.profile,其他阶段仅读resolved_delivery.profile;非法delivery/profile固定RuntimeConflict,不以stale对象fallback扩大镜像白名单;image/purge无profile合同保持。
+  **覆盖 AC:** AC-04、AC-13、AC-15、AC-24、AC-25、AC-36、AC-48、AC-55
+  **依赖:** T200、T202
+  **配对:** T200
+
+- [x] **T203** — K8s自动补全的UID字段合同
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_templates.py`、`src/runtime-manager/tests/test_k8s_admission.py`
+  **逻辑/验收**:真实PodPlan默认化后通过;两个helper的非v1、字段路径变化、额外字段仍拒绝。
+  **覆盖 AC:** AC-13、AC-14、AC-48、AC-49、AC-52
+  **依赖:** T201
+  **配对:** T204
+
+- [x] **T204** — 冻结显式core/v1 UID选择器
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/k8s_templates.py`
+  **逻辑/验收**:仅两处显式apiVersion:v1,未知字段比较不放宽,旧冻结intent不改写。
+  **覆盖 AC:** AC-13、AC-14、AC-48、AC-49、AC-52
+  **依赖:** T203
+  **配对:** T203
+
+- [x] **T207** — 持久创建事务与签名HTTP合同
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_runtime_kubernetes_creation.py`、`src/backend/test/app_runtime/test_runtime_kubernetes_api.py`
+  **逻辑/验收**:仅唯一403拒绝可独立not_created收尾;未知、旧记录、重复、跨epoch和无UID普通停止拒绝;含真实SQL、签名API、role配对和混合unknown阻塞。
+  **覆盖 AC:** AC-13、AC-14、AC-48、AC-49、AC-52
+  **依赖:** T204
+  **配对:** T208
+
+- [x] **T214** — 锁读后刷新资源identity map
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_runtime_kubernetes_creation_concurrency.py`
+  **逻辑/验收**:真实双Session交错预读与提交,锁读必须刷新旧缓存;begin/fence双向互斥,非MySQL锁性能实测。
+  **覆盖 AC:** AC-13、AC-14、AC-48、AC-49、AC-52
+  **依赖:** T207
+  **配对:** T208
+
+- [x] **T208** — 资源账本单次创建与未创建围栏
+  **类型**:实现
+  **文件**:`src/backend/bisheng/app_runtime/domain/repositories/runtime_kubernetes_repository.py`、`src/backend/bisheng/app_runtime/domain/models/runtime_kubernetes.py`
+  **逻辑/验收**:现有observed_state存versioned creation,CAS begin/rejected/fence/not_created;明确403且fence后fresh404才确认无执行体,不伪造UID;未知持久阻塞;锁读populate_existing,无新DDL。
+  **覆盖 AC:** AC-13、AC-14、AC-48、AC-49、AC-52
+  **依赖:** T207、T214
+  **配对:** T207
+
+- [x] **T211** — 既有资源Service夹具接入创建事务
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_runtime_kubernetes_service.py`
+  **逻辑/验收**:注册后尚不允许CREATE;真实begin后才允许,原只读不写、UID停止和权限拒绝保持。
+  **覆盖 AC:** AC-13、AC-14、AC-48、AC-49、AC-52
+  **依赖:** T208
+
+- [x] **T212** — 创建收尾Service与只读准入接线
+  **类型**:实现
+  **文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_kubernetes_service.py`
+  **逻辑/验收**:动作按字段范围校验,新增四个内部观察;not_created和stopped各自校验,所有Pod确认后才能释放预占;准入只给started新意图CREATE。
+  **覆盖 AC:** AC-13、AC-14、AC-48、AC-49、AC-52
+  **依赖:** T207、T208、T211
+  **配对:** T207
+
+- [x] **T213** — 签名观察API创建动作输入
+  **类型**:实现
+  **文件**:`src/backend/bisheng/app_runtime/api/endpoints/runtime_kubernetes.py`
+  **逻辑/验收**:严格schema声明创建动作/attempt/status,exclude_unset避免默认字段进入其它动作;独立manager鉴权不变,无外部deploy API变更。
+  **覆盖 AC:** AC-13、AC-14、AC-48、AC-49、AC-52
+  **依赖:** T207、T212
+  **配对:** T207
+
+- [x] **T205** — manager明确拒绝与未知创建恢复合同
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_kubernetes_executor.py`
+  **逻辑/验收**:403可持久收尾、未知不重发、回执丢失重启续办、发现实际Pod正常UID停止、同名异物拒绝,不DELETE未创建Pod。
+  **覆盖 AC:** AC-13、AC-14、AC-48、AC-49、AC-52
+  **依赖:** T204、T213
+  **配对:** T206
+
+- [x] **T206** — manager单次创建与独立无执行体收尾
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/executors/kubernetes.py`
+  **逻辑/验收**:POST前取得唯一begin回执,只有403持久rejected;cleanup先fence再fresh404,不确定保留旧资源;重拉账本绑定UID,证明后再次出现对象拒绝。
+  **覆盖 AC:** AC-13、AC-14、AC-48、AC-49、AC-52
+  **依赖:** T205
+  **配对:** T205
+
+- [x] **T209** — 准入拒绝安全诊断合同
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_admission_diagnostics.py`
+  **逻辑/验收**:真实review拒绝日志只固定reason白名单,恶意异常文本/类名/凭据/UID不得泄漏;成功无拒绝日志。
+  **覆盖 AC:** AC-13、AC-14、AC-48、AC-49、AC-52
+  **依赖:** T206
+  **配对:** T210
+
+- [x] **T210** — 准入拒绝固定枚举诊断
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/k8s_admission.py`
+  **逻辑/验收**:仅8固定原因或admission_refused;原API响应及拒绝策略不变,不追认历史403。
+  **覆盖 AC:** AC-13、AC-14、AC-48、AC-49、AC-52
+  **依赖:** T209
+  **配对:** T209
+
+- [x] **T215** — 实际固定容器日志与诊断合同
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_client.py`、`src/runtime-manager/tests/test_k8s_diagnostic_contract.py`
+  **逻辑/验收**:真实KubernetesClient/MockTransport接_phase:固定helper日志可读,保留preparation_phase_failed与脱敏tail;UID/scope不符不读日志,未知容器仍拒绝。
+  **覆盖 AC:** AC-13、AC-15、AC-24、AC-47、AC-48、AC-55
+  **依赖:** T214
+  **配对:** T216
+
+- [x] **T216** — 补齐固定helper日志名单
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/k8s_client.py`
+  **逻辑/验收**:只增加模板实际使用的storage-guard/schema-init/assembler;不吞ValueError、不放任意容器或减弱UID。
+  **覆盖 AC:** AC-13、AC-15、AC-24、AC-47、AC-48、AC-55
+  **依赖:** T215
+  **配对:** T215
+
+- [x] **T217** — 材料根路径反向代理合同
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_k8s_material_ingress_contract.py`
+  **逻辑/验收**:精确prefix转发原始URI/query/chunk body;无凭据仍401,签名拒绝配对,禁止宽internal/workspace别名。
+  **覆盖 AC:** AC-13、AC-15、AC-24、AC-47、AC-48、AC-55
+  **依赖:** T216
+  **配对:** T218
+
+- [x] **T218** — 材料根路径nginx与安装说明
+  **类型**:实现
+  **文件**:`docker/nginx/conf.d/default.conf`、`deploy/kubernetes/app-runtime/README.md`
+  **逻辑/验收**:仅material-access前缀到backend,16MiB、streaming/超时及原始路径保持;platform.origin必须可达此独立根路径,backend验签不变。
+  **覆盖 AC:** AC-13、AC-15、AC-24、AC-47、AC-48、AC-55
+  **依赖:** T217
+  **配对:** T217
+
+- [x] **T219** — 延迟调度节点绑定与停止证明配对
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_kubernetes_executor.py`
+  **逻辑/验收**:创建时Pending且nodeName为空,后续同UID调度完成再停止;覆盖节点变化/清空、UID替换、API不确定、绑定回执不确定、已有confirmed证明不得改绑。严格mock验证停止证明节点必须等于账本,不伪造节点。
+  **覆盖 AC:** AC-33、AC-49、AC-52
+  **依赖:** T214、T216
+  **配对:** T220
+
+- [x] **T220** — 同UID首次节点绑定刷新
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/executors/kubernetes.py`
+  **逻辑/验收**:request_stop/confirm_stopped前新鲜读取同UID Pod,只允许空节点补齐为实际nodeName;不改变已确认停止证明,不放宽backend stop scope,未知结果保持资源占用。
+  **覆盖 AC:** AC-33、AC-49、AC-52
+  **依赖:** T219
+  **配对:** T219
+
+- [x] **T221** — 可选依赖锁空值及安装接续合同
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_helper.py`
+  **逻辑/验收**:真实manifest lock:null与省略、显式路径配对,使用可信common resolver覆盖环境变量与prepare配置接续;非法类型拒绝。
+  **覆盖 AC:** AC-15、AC-24、AC-47、AC-55
+  **依赖:** T220
+  **配对:** T222
+
+- [x] **T222** — 可选依赖锁空值规范化
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/k8s_helper.py`、`src/runtime-manager/runtime_manager/k8s_prepare.py`
+  **逻辑/验收**:仅将已验证的可选lock None视为未指定的空字符串,显式字符串保留,非法类型拒绝,不改变来源/锁校验。
+  **覆盖 AC:** AC-15、AC-24、AC-47、AC-55
+  **依赖:** T221
+  **配对:** T221
+
+- [x] **T223** — 终态清理重试保留原始失败诊断
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_operations_v2.py`
+  **逻辑/验收**:首次失败阶段、tail、维护/数据变化标记在真实cleanup重试后保留;停止/清理按本轮真实结果,保留期清空的tail不复活。
+  **覆盖 AC:** AC-48、AC-49、AC-52、AC-55
+  **依赖:** T220
+  **配对:** T224
+
+- [x] **T224** — 清理重试保留首次失败事实
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/operations.py`
+  **逻辑/验收**:终态重试携带当前持久failure诊断字段,缺失tail不重建;phase不变、停止和cleanup仅使用实际结果,不复活操作。
+  **覆盖 AC:** AC-48、AC-49、AC-52、AC-55
+  **依赖:** T223
+  **配对:** T223
+
+- [x] **T225** — 异步收尾等待与重启配对
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_operations_v2.py`、`src/runtime-manager/tests/test_executor_selection.py`
+  **逻辑/验收**:准备或探活完成后清理暂未结束保持非终态,结果不重做;取消/期限/失租保持围栏,确认清理后才完成。
+  **覆盖 AC:** AC-04、AC-33、AC-35、AC-49、AC-52、AC-55、AC-56
+  **依赖:** T224
+  **配对:** T226
+
+- [x] **T226** — 编排异步清理有界等待
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/operations.py`
+  **逻辑/验收**:修正K8s正常异步删除被当作准备失败的语义;同根收尾路径按原操作检查点/期限等待,不吞真实异常、不伪造清理完成。
+  **覆盖 AC:** AC-04、AC-33、AC-35、AC-49、AC-52、AC-55、AC-56
+  **依赖:** T225
+  **配对:** T225
+
+- [x] **T227** — K8s异步删除API合同
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_kubernetes_executor.py`
+  **逻辑/验收**:DELETE受理后同UID对象仍在时cleanup返回pending;真实消失后才完成,换UID/未知拒绝,不能重复创建或误删。
+  **覆盖 AC:** AC-33、AC-49、AC-52
+  **依赖:** T220
+
+- [x] **T228** — 初始化与健康阶段期限配对
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_kubernetes_executor.py`
+  **逻辑/验收**:多init正常累计超过90秒可在各自阶段期限内就绪;单阶段/健康/原操作期限仍有界;重启重试不重新计时,时间或状态不确定不延长,同UID限制保持。
+  **覆盖 AC:** AC-24、AC-25、AC-28、AC-43、AC-48、AC-49、AC-52、AC-55
+  **依赖:** T226、T227
+  **配对:** T229
+
+- [x] **T229** — 修正初始化与应用就绪期限
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/executors/kubernetes.py`
+  **逻辑/验收**:按既定阶段有界初始化与健康检查,修正全部init与健康共用一次90秒误判;不扩大原操作总期限、不改变资源围栏/API/DDL。
+  **覆盖 AC:** AC-24、AC-25、AC-28、AC-43、AC-48、AC-49、AC-52、AC-55
+  **依赖:** T228
+  **配对:** T228
+
+- [x] **T230** — 已取消数据操作拒绝新动作配对
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_runtime_data_grants.py`、`src/backend/test/app_runtime/test_runtime_data_sessions.py`、`src/runtime-manager/tests/test_k8s_data_executor.py`
+  **逻辑/验收**:真实owner取消提交后不能新签数据票据/继续等待ready或开始新RPC;active=False清理、已开始写入unknown与互斥仍保留。
+  **覆盖 AC:** AC-33、AC-36、AC-43、AC-48、AC-49、AC-55
+  **依赖:** T226
+  **配对:** T231
+
+- [x] **T231** — 取消数据会话的签发与RPC围栏
+  **类型**:实现
+  **文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_data_grant_service.py`、`src/backend/bisheng/app_runtime/domain/services/runtime_data_session_service.py`、`src/runtime-manager/runtime_manager/k8s_data_executor.py`
+  **逻辑/验收**:后端签发事务与ready读取核取消;manager当前操作校验拒绝取消后的新动作,不伪造回滚/停止或释放未知写入者。
+  **覆盖 AC:** AC-33、AC-36、AC-43、AC-48、AC-49、AC-55
+  **依赖:** T230
+  **配对:** T230
+
+- [x] **T232** — 终态finalizer移除后kubelet删除配对
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_admission.py`
+  **逻辑/验收**:原带finalizer路径保持;无finalizer仅fresh同scope/UID已持久停止证明允许绑定kubelet DELETE0;缺失/伪造/过期/换UID拒绝。
+  **覆盖 AC:** AC-35、AC-48、AC-49、AC-52、AC-55
+  **依赖:** T229
+  **配对:** T233
+
+- [x] **T233** — 终态Pod正常删除准入收尾
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/k8s_admission.py`
+  **逻辑/验收**:修正SQL停止已确认且平台finalizer已移除后kubelet收尾被误拒;不放宽manager force-delete或未知写入者释放。
+  **覆盖 AC:** AC-35、AC-48、AC-49、AC-52、AC-55
+  **依赖:** T232
+  **配对:** T232
+
+- [x] **T234** — 同步业务E2E请求预算配对
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_k8s_e2e_contract.py`
+  **逻辑/验收**:成功授权的精确同步preview/stop/resume/delete使用settings预算,其他请求仍短截止;总预算取消且POST仅一次,不重试未知结果。
+  **覆盖 AC:** AC-24、AC-25、AC-28、AC-33、AC-36、AC-49、AC-55
+  **依赖:** T233
+  **配对:** T235
+
+- [x] **T235** — 共享E2E同步请求期限适配
+  **类型**:实现
+  **文件**:`src/backend/test/e2e/helpers/vibe_runtime.py`
+  **逻辑/验收**:共享helper只为精确同步成功调用覆盖read预算并限制总耗时,连接/写入/pool短而有界;不改产品操作期限、拒绝断言、普通GET或restore202合同。
+  **覆盖 AC:** AC-24、AC-25、AC-28、AC-33、AC-36、AC-49、AC-55
+  **依赖:** T234
+  **配对:** T234
+
+- [x] **T236** — 维护容器材料身份UID接线配对
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_templates.py`
+  **逻辑/验收**:真实模板到helper身份reader验证migrate/restore具备受控downward PodUID;purge无材料身份、普通app/data-helper不获材料UID、reserved env仍拒绝。
+  **覆盖 AC:** AC-33、AC-34、AC-35、AC-48、AC-49、AC-50、AC-51、AC-55
+  **依赖:** T235
+  **配对:** T237
+
+- [x] **T237** — 可信维护容器注入材料PodUID
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/k8s_templates.py`
+  **逻辑/验收**:仅有material_secret的可信maintenance容器从core/v1 metadata.uid取得材料签名身份绑定;保留无材料身份purge和其他角色隔离,不改helper/API/DDL。
+  **覆盖 AC:** AC-33、AC-34、AC-35、AC-48、AC-49、AC-50、AC-51、AC-55
+  **依赖:** T236
+  **配对:** T236
+
+- [x] **T238** — 同名已闭合历史Pod停用配对
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_operations_v2.py`
+  **逻辑/验收**:两条已停止且清理完成的历史维护UID不得重新fresh/request_stop;当前服务UID照常停止并等待全操作清理,单一完成标记/未知状态/换UID拒绝。
+  **覆盖 AC:** AC-35、AC-36、AC-48、AC-49、AC-52、AC-55
+  **依赖:** T237
+  **配对:** T239
+
+- [x] **T239** — 停用跳过已闭合历史Pod
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/operations.py`
+  **逻辑/验收**:仅stop_state=confirmed且cleanup_state=complete的Pod跳过重复停止;保留旧操作全资源cleanup等待及当前deadline/controller/UID围栏。
+  **覆盖 AC:** AC-35、AC-36、AC-48、AC-49、AC-52、AC-55
+  **依赖:** T238
+  **配对:** T238
+
+- [x] **T240** — 过期停用清理闭环配对
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_operations_v2.py`
+  **逻辑/验收**:expired stop不得以自身空资源报app已停止;先timed_out/pending持久回执,再仅同归属旧terminal操作own cleanup;响应未知、换代、换UID、未知writer拒绝,重启可续且首因保留。
+  **覆盖 AC:** AC-35、AC-36、AC-48、AC-49、AC-52、AC-55
+  **依赖:** T239
+  **配对:** T241
+
+- [x] **T241** — 过期停用的有界安全收尾
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/operations.py`
+  **逻辑/验收**:精确operation_expired且fresh deadline已过才记timeout;终态pending保留active fence,以旧terminal操作原有自身cleanup逐轮收尾,最终全app停止guard仍由backend验证。无新业务动作/期限延长/跨op过期授权。
+  **覆盖 AC:** AC-35、AC-36、AC-48、AC-49、AC-52、AC-55
+  **依赖:** T240
+  **配对:** T240
+
+- [x] **T242** — 离线数据CPU真实预占配对
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_runtime_data_sessions.py`、`src/backend/test/app_runtime/test_runtime_data_repository.py`
+  **逻辑/验收**:offline frozen tier及实际预占均128Mi/100m,99m拒绝/100m允许且拒绝无残留;online保持25m且不增预占,60秒/租户/并发2和mutex保持。
+  **覆盖 AC:** AC-18、AC-33、AC-43、AC-48、AC-49、AC-55
+  **依赖:** T241
+  **配对:** T243
+
+- [x] **T243** — 离线数据Pod预占100m CPU
+  **类型**:实现
+  **文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_data_session_service.py`、`src/backend/bisheng/app_runtime/domain/repositories/runtime_delivery_repository.py`
+  **逻辑/验收**:仅offline可信tier与实际容量预占由25m调100m;原同步响应、60秒期限、票据、并发和停止释放规则不变。
+  **覆盖 AC:** AC-18、AC-33、AC-43、AC-48、AC-49、AC-55
+  **依赖:** T242
+  **配对:** T242
+
+- [x] **T244** — 离线数据模板CPU与平台tier配对
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_templates.py`
+  **逻辑/验收**:data-layout和data-helper按可信plan CPU100m生成requests=limits;旧冻结25m模板不变,serve sidecar及其他helper保持25m,内存/权限/期限不变。
+  **覆盖 AC:** AC-18、AC-33、AC-43、AC-48、AC-49、AC-55
+  **依赖:** T241
+  **配对:** T245
+
+- [x] **T245** — 离线数据helper沿用可信tier CPU
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/k8s_templates.py`
+  **逻辑/验收**:仅purpose=data的helper使用plan.cpu_millicores,在线sidecar与其他helper25m保持;无helper镜像/descriptor/网络/API变更。
+  **覆盖 AC:** AC-18、AC-33、AC-43、AC-48、AC-49、AC-55
+  **依赖:** T244
+  **配对:** T244
+
+- [x] **T246** — 应用包可执行文件路径配对
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_templates.py`
+  **逻辑/验收**:真实static镜像nginx位于/usr/sbin;rendered PATH在probe/preview/serve保留依赖bin优先及标准系统sbin/bin,image模式不覆盖镜像PATH;既有PATH注入拒绝保持。
+  **覆盖 AC:** AC-24、AC-25、AC-27、AC-54、AC-55
+  **依赖:** T245
+  **配对:** T247
+
+- [x] **T247** — 应用包保留标准系统可执行路径
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/k8s_templates.py`
+  **逻辑/验收**:仅bundle受控PATH补/usr/local/sbin、/usr/sbin、/sbin;不改冻结command/entrypoint/args/profile,不改image模式及用户PATH拒绝。
+  **覆盖 AC:** AC-24、AC-25、AC-27、AC-54、AC-55
+  **依赖:** T246
+  **配对:** T246
+
+- [x] **T248** — OCI 目录继承权限与危险位配对
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_oci_assembler.py`
+  **逻辑/验收**:source/environment继承fsGroup的SGID目录可归档且不携带特殊位;SUID目录、SUID/SGID普通文件仍拒绝,既有链接/硬链接/whiteout与特殊文件拒绝保持。
+  **覆盖 AC:** AC-15、AC-16、AC-25、AC-27、AC-28、AC-42、AC-54
+  **依赖:** T247
+  **配对:** T249
+
+- [x] **T249** — OCI 归档移除目录继承的SGID位
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/oci_assembler.py`
+  **逻辑/验收**:仅允许目录的继承SGID,由已有mode &0777去除;目录SUID及所有非目录SUID/SGID继续拒绝,不改OCI根布局、凭据挂载、冻结基础镜像和镜像推送合同。
+  **覆盖 AC:** AC-15、AC-16、AC-25、AC-27、AC-28、AC-42、AC-54
+  **依赖:** T248
+  **配对:** T248
+
+- [x] **T250** — OCI 镜像可执行路径配对
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_oci_assembler.py`
+  **逻辑/验收**:固定crane配置在三个运行时保留依赖bin优先与标准sbin/bin,static入口可找到/usr/sbin/nginx;不接受用户PATH替换,不改变冻结镜像或command。
+  **覆盖 AC:** AC-24、AC-25、AC-27、AC-28、AC-54
+  **依赖:** T249
+  **配对:** T251
+
+- [x] **T251** — OCI 镜像保留标准系统可执行路径
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/oci_assembler.py`
+  **逻辑/验收**:源码镜像固定PATH补标准sbin,与已实测T247的bundle合同一致;不改base/entrypoint/command/registry/凭据和用户可配置项。
+  **覆盖 AC:** AC-24、AC-25、AC-27、AC-28、AC-54
+  **依赖:** T250
+  **配对:** T250
+
+- [x] **T254** — 安装探测覆盖实际代理上游策略与服务隔离
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_installation.py`
+  **逻辑/验收**:仓库上游TCP不可达时不得执行外部crane或记registry-push通过;控制canary匹配egress-proxy真实策略并保持NotReady,不进入业务Service端点;固定origin解析和失败关闭配对。
+  **覆盖 AC:** AC-13、AC-16、AC-20、AC-28、AC-54、AC-55
+  **依赖:** T251
+  **配对:** T255
+
+- [x] **T255** — 安装仓库探测增加受限代理上游检查
+  **类型**:实现
+  **文件**:`scripts/verify_k8s_runtime.py`、`deploy/kubernetes/app-runtime/README.md`
+  **逻辑/验收**:在原registry外部权限检查前,复用无凭据control canary按egress-proxy策略验证已配置registry origin的TCP可达;保持NotReady并核Service不发布未Ready地址;域名解析失败拒绝,不更改helper/manager/摘要/API。文档说明DNAT后目的端口与TCP、外部TLS及实际业务推送证据边界。
+  **覆盖 AC:** AC-13、AC-16、AC-20、AC-28、AC-54、AC-55
+  **依赖:** T254
+  **配对:** T254
+
+- [x] **T256** — 准备阶段取消、围栏与截止时间配对
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_prepare_cancellation.py`、`src/runtime-manager/tests/test_k8s_diagnostic_contract.py`
+  **逻辑/验收**:使用实际_phase与_startup_checkpoint,覆盖进入/复用前取消、Running时取消、UID/归属围栏变化、原期限与缩短期限、读取失败、成功前取消;正常完成仍按同UID停止和确认,不以Mock调用次数代替业务结果。
+  **覆盖 AC:** AC-43、AC-48、AC-49、AC-52、AC-55
+  **依赖:** T255
+  **配对:** T257
+
+- [x] **T257** — 准备阶段持续核对持久操作状态
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/executors/kubernetes.py`
+  **逻辑/验收**:复用已有操作围栏/取消/持久期限检查,在阶段进入、运行轮询及提交结果前检查;失败交由原清理路径,不扩大backend GET拒绝、不放宽停止证明或延长原期限。
+  **覆盖 AC:** AC-43、AC-48、AC-49、AC-52、AC-55
+  **依赖:** T256
+  **配对:** T256
+
+- [ ] **T067** — Docker 与平台回归
+  **类型**:测试
+  **文件**:`features/v3.0.0/059-k8s-runtime-backend/docker-regression.md`、`features/v3.0.0/059-k8s-runtime-backend/docker-regression.json`
+  **逻辑/验收**:运行相同原有测试选择与首轮基线比对;三种交付/状态/SDK/附件/数据/权限,标真实与模拟;原F086共享修复按release来源继承。
+  **覆盖 AC:** AC-01、AC-02、AC-04、AC-10、AC-24、AC-36、AC-37、AC-46、AC-53、AC-56
+  **依赖:** T066
+
+- [x] **T068** — 189 专用资源安装验收
+  **类型**:测试
+  **文件**:`features/v3.0.0/059-k8s-runtime-backend/e2e-evidence/installation.json`、`features/v3.0.0/059-k8s-runtime-backend/e2e-evidence/installation.md`
+  **逻辑/验收**:在192.168.106.189重新只读核对UID,再安装专用e2e-f059资源、合格local PV/PVC和镜像;验证真实拒绝与允许、包源/材料/SDK连通,保留原资源清单,不修改既有kube-system。
+  **覆盖 AC:** AC-03、AC-12、AC-13、AC-14、AC-16、AC-19、AC-20、AC-21、AC-22、AC-23、AC-30、AC-31、AC-32、AC-33、AC-34、AC-39、AC-42、AC-54、AC-55
+  **依赖:** T065
+
+- [x] **T069** — 真实平台发布审批及数据验收
+  **类型**:测试
+  **文件**:`features/v3.0.0/059-k8s-runtime-backend/e2e-evidence/platform.json`、`features/v3.0.0/059-k8s-runtime-backend/e2e-evidence/platform.md`
+  **逻辑/验收**:首发/更新/容量不足前后阶段、bundle三语言/完整镜像/source-image、审批预览、owner与非owner、SDK/OBO/附件、停用查改导出和恢复删除。记录HTTP及SQL操作/PVCUID/材料与镜像摘要。
+  **覆盖 AC:** AC-02、AC-04、AC-05、AC-08、AC-10、AC-11、AC-17、AC-18、AC-24、AC-25、AC-26、AC-27、AC-28、AC-29、AC-33、AC-35、AC-36、AC-37、AC-44、AC-45、AC-46、AC-47、AC-50、AC-51、AC-55
+  **依赖:** T068
+
+- [ ] **T070** — 故障与升级卸载验收
+  **类型**:测试
+  **文件**:`features/v3.0.0/059-k8s-runtime-backend/e2e-evidence/fault-upgrade.json`、`features/v3.0.0/059-k8s-runtime-backend/e2e-evidence/fault-upgrade.md`
+  **逻辑/验收**:initImagePull/材料拒绝、取消、manager SIGKILL/并发/晚到请求、同名UID、未知写入者、快照确认/DDL后失败、HTTP/WS/出站撤权、升级manager保留Pod/PVC/profile、卸载保留资产;不能以单节点证明跨节点fencing。
+  **覆盖 AC:** AC-06、AC-07、AC-09、AC-18、AC-35、AC-40、AC-41、AC-43、AC-47、AC-48、AC-49、AC-50、AC-51、AC-52、AC-53、AC-55
+  **依赖:** T069
+
+- [ ] **T071** — 模型静态兼容与发行证据
+  **类型**:测试
+  **文件**:`features/v3.0.0/059-k8s-runtime-backend/model-release-validation.md`、`features/v3.0.0/059-k8s-runtime-backend/model-release-validation.json`
+  **逻辑/验收**:按已确认口径只检查DM8/MySQL模型、类型/约束/登记和单Alembic head;另收CI双架构镜像/锁/离线物料证据,未执行架构和跨节点项明确限制,不算已测。
+  **覆盖 AC:** AC-15、AC-39、AC-42、AC-48、AC-54、AC-55、AC-56
+  **依赖:** T070、T067
+
+
+### 报告失效与连续保护修正(T258–T261)
+
+- [x] **T258** — 报告失效与保护基线的runtime配对
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_protection_report.py`、`src/runtime-manager/tests/test_k8s_capabilities.py`
+  **逻辑/验收**:真实能力计算覆盖TTL/helper-only未知、保护字段变化、坏/重复证据、cold restart和真实failed;旧实现先红,保留readiness fail-closed及原descriptor摘要。
+  **覆盖 AC:** AC-07、AC-30、AC-31、AC-35、AC-40、AC-48、AC-49、AC-52、AC-55
+  **依赖:** T257
+  **配对:** T259
+
+- [x] **T259** — runtime分别报告readiness和保护观察
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/k8s_capabilities.py`、`src/runtime-manager/runtime_manager/executors/kubernetes.py`
+  **逻辑/验收**:新增严格版本化protection_observation与排除仅report/helper的基线摘要;仅两种可证明report-only未知不推导历史gap,当前能力仍false;其他不确定继续unconfirmed。
+  **覆盖 AC:** AC-07、AC-30、AC-31、AC-35、AC-40、AC-48、AC-49、AC-52、AC-55
+  **依赖:** T258
+  **配对:** T258
+
+- [x] **T260** — 平台历史保护与当前停止确认配对
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_runtime_protection_report.py`
+  **逻辑/验收**:真实SQLite事务覆盖同基线未知重复心跳/恢复、真实failed不可逆、基线变化/协议降级/无首次基线拒绝、当前保护未知不得确认停止及旧false不恢复。
+  **覆盖 AC:** AC-07、AC-30、AC-31、AC-35、AC-40、AC-48、AC-49、AC-52、AC-55
+  **依赖:** T257
+  **配对:** T261
+
+- [x] **T261** — 平台保留报告未知的历史事实并阻止当前释放
+  **类型**:实现
+  **文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_operation_service.py`、`src/backend/bisheng/app_runtime/domain/repositories/runtime_kubernetes_repository.py`
+  **逻辑/验收**:仅可信旧同基线+严格report-only元数据保留历史标记,节点blocked;实际失败/基线变更仍降级。首次停止须当前对应保护true,已确认幂等与not_created不变;不回填旧false。
+  **覆盖 AC:** AC-07、AC-30、AC-31、AC-35、AC-40、AC-48、AC-49、AC-52、AC-55
+  **依赖:** T260
+  **配对:** T260
+
+
+### 心跳快照一致性(T262/T263)
+
+- [x] **T262** — 热刷新跨次读取的心跳一致性配对
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_protection_heartbeat.py`
+  **逻辑/验收**:实际OperationExecutor与Kube executor组合,expired→fresh/current→failed投射序列先红后绿;单次心跳同一descriptor/check快照,下次自然刷新,strict JSON与旧fake/Docker兼容。
+  **覆盖 AC:** AC-07、AC-30、AC-31、AC-35、AC-48、AC-49、AC-52、AC-55
+  **依赖:** T259
+  **配对:** T263
+
+- [x] **T263** — 心跳使用一次完整能力和报告快照
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/operations.py`
+  **逻辑/验收**:Kube提供原子heartbeat_snapshot,既有capabilities兼容;OperationExecutor优先一次快照提交,原Docker和无新方法的fake接口保持。矛盾输入仍由backend拒绝,不以宽容矛盾报告代替修复。
+  **覆盖 AC:** AC-07、AC-30、AC-31、AC-35、AC-48、AC-49、AC-52、AC-55
+  **依赖:** T262
+  **配对:** T262
+
+### CLI 容量错误兼容(T264/T265)
+
+- [x] **T264** — CLI v2 容量错误码配对
+  **类型**:测试
+  **文件**:`src/bisheng-cli/tests/test_errors.py`、`src/bisheng-cli/tests/test_command_deploy_wait.py`
+  **逻辑/验收**:通过真实 error_from_platform 和 deploy failure 处理复现 v2 16125 被降为 exit19;覆盖16125与既有16226均为exit14、其他未知码继续降级,验证脱敏输出及原响应语义。
+  **覆盖 AC:** AC-02、AC-08
+  **依赖:** T261
+  **配对:** T265
+
+- [x] **T265** — CLI 兼容 v2 容量不足并同步分发 wheel
+  **类型**:实现
+  **文件**:`src/bisheng-cli/bisheng_cli/errors.py`、`src/backend/bisheng/dev_toolkit/artifacts/bisheng_cli-3.0.0-py3-none-any.whl`、`src/backend/bisheng/dev_toolkit/artifacts/manifest.json`
+  **逻辑/验收**:仅将既有16125映射EXIT_CAPACITY并复用容量提示;不改backend返回码或对外协议。按现有pack_cli_wheel.sh重建实际分发目录的wheel并同步manifest.cli.sha256,SDK物料不变,校验源码与wheel errors.py一致,并运行CLI相关回归。
+  **覆盖 AC:** AC-02、AC-08
+  **依赖:** T264
+  **配对:** T264
+
+### 销毁维护事实协议(T266/T267)
+
+- [x] **T266** — 真实销毁事实阶段配对
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_destroy_fact_contract.py`
+  **逻辑/验收**:真实OperationExecutor进入K8s销毁链,经实际ExecutionFactInput校验复现admitted事实422;验证合法非终态进度在purge前持久、同操作结果入账后才清理PVC、失败不提前完成、已有进度重启不重复执行,Docker保持。
+  **覆盖 AC:** AC-33、AC-35、AC-46、AC-49、AC-50、AC-52
+  **依赖:** T263
+  **配对:** T267
+
+- [x] **T267** — 销毁前持久化合法维护阶段
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/operations.py`
+  **逻辑/验收**:K8s显式销毁在执行purge前记录既有合法维护阶段,使维护结果可持久化并用于PVC删除准入;不放宽backend阶段schema、不把admitted映射为completed、不改变停止围栏或Docker路径。
+  **覆盖 AC:** AC-33、AC-35、AC-46、AC-49、AC-50、AC-52
+  **依赖:** T266
+  **配对:** T266
+
+### 销毁 PVC 异步收尾(T268/T269)
+
+- [x] **T268** — 真实销毁 PVC 异步删除配对
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_purge_cleanup.py`
+  **逻辑/验收**:真实维护/执行链覆盖purge结果已持久而同UID PVC删除尚未结束;自然404后才确认清理和完成,接管复用结果不重复helper;原期限、取消、控制权或UID变化及API不确定均不提前完成,不放宽停止与清理围栏。
+  **覆盖 AC:** AC-35、AC-46、AC-49、AC-50、AC-52、AC-55
+  **依赖:** T267
+  **配对:** T269
+
+- [x] **T269** — 等待已受理 PVC 删除的正常收尾
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/operations.py`
+  **逻辑/验收**:仅将K8s purge_volume_cleanup_pending作为既有清理等待状态,在原操作期限与每轮fresh checkpoint/控制租约内继续;复用已持久purge结果,权限/换UID/其他异常保持拒绝,不改变Docker或backend协议。
+  **覆盖 AC:** AC-35、AC-46、AC-49、AC-50、AC-52、AC-55
+  **依赖:** T268
+  **配对:** T268
+
+### 恢复后的服务注册(T270/T271)
+
+- [x] **T270** — 恢复后的服务实例注册配对
+  **类型**:测试
+  **文件**:`src/backend/test/app_runtime/test_runtime_restore_resume.py`
+  **逻辑/验收**:真实SQLite/Service和实际manager模板验证在线恢复后serve注册;仅已确认restore结果和resume=true可继续,restore-only/坏参数/阶段/代际/租约/UID/未停止写入者拒绝。
+  **覆盖 AC:** AC-05、AC-13、AC-15、AC-47、AC-48、AC-49、AC-50、AC-52
+  **依赖:** T269
+  **配对:** T271
+
+- [x] **T271** — 接通显式恢复后的服务实例注册
+  **类型**:实现
+  **文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_kubernetes_service.py`
+  **逻辑/验收**:仅为resume_after_restore=true且同操作恢复结果已持久的合法启动阶段接通serve,复用已有资源冻结/租约/旧writer停止保护;不改材料、路由、SDK、schema或Docker。
+  **覆盖 AC:** AC-05、AC-13、AC-15、AC-47、AC-48、AC-49、AC-50、AC-52
+  **依赖:** T270
+  **配对:** T270
+
+### 在途重启的控制器准入(T272–T273)
+
+- [x] **T272** — 控制器暂态拒绝不得终结原准备操作
+  **类型**:测试
+  **文件**:`src/runtime-manager/tests/test_k8s_controller_restart.py`、`src/runtime-manager/tests/test_operations_v2.py`
+  **逻辑/验收**:真实OperationExecutor与KubernetesExecutor复现新holder被旧未到期租约拒绝、错误处理跨越自然到期后误清理原install并终结的链路。准入拒绝时不claim操作、不写失败事实、不清理;下一次取得新epoch后同操作/期限/进度继续,合法租约、Docker和取得权限后的业务失败保持原行为。
+  **覆盖 AC:** AC-43、AC-48、AC-49、AC-52、AC-55
+  **依赖:** T271
+  **配对:** T273
+
+- [x] **T273** — 在业务失败处理范围外取得K8s控制器租约
+  **类型**:实现
+  **文件**:`src/runtime-manager/runtime_manager/operations.py`
+  **逻辑/验收**:在领取/执行操作前先核K8s控制器权限,暂态拒绝由下一次正常对账续办,不进入业务_failure;不吞任意API异常、不延长操作期限、不改模板、后端授权和Docker。
+  **覆盖 AC:** AC-43、AC-48、AC-49、AC-52、AC-55
+  **依赖:** T272
+  **配对:** T272
+
+### Wave 9
+
+- [ ] **T072** — L2 代码审查及交付核对
+  **类型**:审查
+  **文件**:`features/v3.0.0/059-k8s-runtime-backend/code-review.md`、`features/v3.0.0/059-k8s-runtime-backend/delivery-validation.json`
+  **逻辑/验收**:按sdd-code-review技能以755d4456e5及release-line确认基准审查真实diff,修复边界/并发/权限/信息泄漏;核对所有任务L1和AC证据、Docker保留,更新design现状和交付证据;不自动合并/发tag。
+  **覆盖 AC:** AC-01、AC-02、AC-03、AC-04、AC-05、AC-06、AC-07、AC-08、AC-09、AC-10、AC-11、AC-12、AC-13、AC-14、AC-15、AC-16、AC-17、AC-18、AC-19、AC-20、AC-21、AC-22、AC-23、AC-24、AC-25、AC-26、AC-27、AC-28、AC-29、AC-30、AC-31、AC-32、AC-33、AC-34、AC-35、AC-36、AC-37、AC-39、AC-40、AC-41、AC-42、AC-43、AC-44、AC-45、AC-46、AC-47、AC-48、AC-49、AC-50、AC-51、AC-52、AC-53、AC-54、AC-55、AC-56
+  **依赖:** T071
+
+## 实际偏差记录
+
+暂无实现偏差。推翻已确认 spec/design 时先按 SDD-Guide 第 3 节重新确认;普通实现落点同步 design,并在此留指针。
+
+## 阶段记录
+
+- 2026-10-21:用户确认修订设计;本轮拆解覆盖 55 条有效 AC,开始 tasks 自动审查;尚未宣称已实现或部署。
+
+- 2026-10-21:T001 配置实现/验证通过;T006 客户端测试先行,红阶段为缺少待实现模块(12 passed / 17 errors),不把该阶段记为接口实现成功。
+
+- 2026-10-22:公共修复已授权并进入发布线9d156f261c;继承产生的真实三head新增T074登记,保留原revision父链;为继承最新共享组件逐文件适配Vibe调用,未改变产品合同。
+
+2026-10-22并行计划实施:用户明确授权主agent+三开发子agent;共同快照c90927b1f,独立工作树/文件owner,主agent唯一189写入者。新增清理/canary/发行脚本/harness任务补齐批准范围,未推翻D1–D8。
+
+2026-10-22补充登记T109–T134:来自并行代码审查与实际环境准备发现的既有合同遗漏;任务登记晚于部分修复,红绿历史按各日志/agent报告原样记录,不倒填任务时间。正式双架构发行、109/189业务及故障验收保持未完成。
+
+189实际CNI更新走pods/status维护其保留注解,暴露T111/T112原测试遗漏;重开该配对,补同UID/status不变的可信CNI metadata合法路径与伪造终态负例。原拒绝不会改为通配放行。
+
+2026-10-22续办登记T145–T154:用户批准恢复围栏、CNI报告绑定和模型约束;真实环境发现Helm重置计划、原生构建发现context不匹配,补配对任务。模型草稿079e22fce已先保存,红绿历史按实际保留,不倒填。
+
+2026-10-22续办L1:T111/T112、T143–T157及T161/T162代码合同已复核;runtime整套1058/10skip(最终Chart与finalizer另216/198定向),backend1587/18skip、模型组合44与跨库静态64通过。真实189旧canary已终态,Helm计划遗失后按精确UID自然到期恢复仍在复验;不据局部通过勾选T067–T072。
+
+2026-10-22现场修复登记T164–T174:API subresource语法、真实存储控制器身份、未绑定Pod删除宽限规范化、PSA有效负例与dryRun计划撤销、native smoke目录可读权限。代码先后与红绿记录按实际提交及日志保留;本地runtime1129/10skip和集成安装263通过,真实15项探测、业务与故障仍由T067–T072验收,不以代码合同替代。
+
+现场继续登记T175–T178:真实终态Pod也发生Core零宽限规范化;native SQLite固定初始化值不足以证明跨升级保留,补随机哨兵。修复与重新冻结候选镜像后再闭合L1。
+
+T179–T182来自109真实诊断:真实PlatformClient没有node_plan,旧桩掩盖接线错误;Docker现有公开响应无K8s专属字段。采用真实客户端合同及明确形状分别修复,不以降低预检通过门槛替代。
+
+T179/T180经真实PlatformClient签名GET配对(红5→绿37)完成;最终候选7e720073b完整runtime1184passed/10skipped。109真实平台安装诊断通过,业务仍在运行;T067–T072不提前完成。
+
+T183/T184配对与主线组合135passed;109 r2 prepare/bootstrap真实API通过并启动独立journal平台回归。旧默认批次slug、审计和journal保留,未改产品删除语义。最终候选189四轮基线完成、原生amd64串行烟测中;T067–T072保持未完成。
+
+T185/T186:189报告尚在86400秒有效期内,原公开诊断错误读取不存在的raw ready字段;配对红3/绿92后本地合入。T187/T188:109真实WAL读取失败,最小Docker复现确认只读挂载不能创建侧文件;禁止immutable忽略已提交WAL。发布线a77accb43没有runtime-manager模块,修复属于尚未合入发布线的托管特性,不能向发布线放孤立文件。
+
+T187/T188:0af6eda1b已合入;相关离线52通过/3真实默认跳过,实际Docker三种WAL布局3通过;SQL拒写和事务一致性配对均通过,原有容器/网络不变。下一候选完整集成/原生及109业务复验仍待执行。
+
+T189/T190:109修复后observe-v1真实通过;停用恢复时app先online,紧接数据GET遇到16167临时维护,稍后同op completed/cleanup complete及rows200确认。补验收工具同步,未将临时拒绝改成业务成功。
+
+T189/T190经7b728fcb6配对15项先红后绿、完整93合同通过;同runtime镜像54b生产目录与新HEAD字节不变。109续办保留既有应用/journal,189报告过期真实拒绝16121/precheck_build/无审批已通过;恢复及其他负例继续真实验证。
+
+T068完成:候选54b3ea2c1/Helm10第五实际探测16项+正常清理+真实公开诊断通过;三组报告负例实际发布16121/precheck_build/无审批操作并原样恢复、无manager重启。旧canary按自然到期同UID清理完毕,namespace Pod/PVC与计划均空。T067/T069/T070/T071/T072仍待完成。
+
+T191–T193现场夹具补登记:109第四source-image真实失败16124/precheck_probe/startup_exited;该离线路径的空requirements与顶层SDK导入不自足,补显式可校验vendor物料,不更改生产模板、base或SDK正向断言。完整镜像需显式加入场景。
+
+T194/T195:189 首个 bundle 的真实payload类型为payload.profile=dict、delivery.profile=str、resolved_delivery=None;只读复算在_plan:195重现AttributeError.get,未创建资源。修复解析对象与manifest引用混用,失败记录保留。
+
+T191–T193:66e507ec8合入,170合同及裸Python禁网原生SDK/附件/SQLite/WS/certifi/TLS配对通过。依赖保持原始8wheel字节;scanner实际10文件扫描、8wheel按binary跳过、0命中;不宣称依赖源码已扫描,原展开模式3个假阳性证据保留。109/189真实source-image仍待复验。T194补OCI旧夹具缺失delivery字段,第二文件owner明确转给A。
+
+T194/T195通过54488cdd4合入候选9e7e86cab;主树全runtime1217passed/13skipped,未跳过setuid参数;A权限上下文的04755→0755限制不代表宿主文件系统全局行为。170夹具/harness在真实backend conftest集成通过。190/195完成,5项真实交付/L2仍待;新候选原生及189真实重部署继续。
+
+T196/T197:189四个失败draft的只读清理预检在GET instance收到16121,实际默认node配置正确;无assignment时控制API返回application_not_found/404,而manager未映射成无实例。补精确缺失与真正平台故障配对,不改清理工具来忽略故障。
+
+T196/T197经0a8f5a252合入候选0e493c07a,真实client/HMAC/ASGI先红4/38、相关75全绿,主树完整runtime1259passed/13skipped。四个真实draft均只读确认application_not_found/404;新镜像清理链路待验,不修改工具绕过16121。192/197完成,正式CI/真实交付/L2仍pending。
+
+T198/T199现场红例:189新prepare d0b665da84014b2fab43ef60f1b282b6、app2bde519a30f946a091f5ae4aa5cf2248,14:20:36领取epoch2/lease_until14:21:06;14:24:11开始永久renew409,尚无Pod/PVC且terminal fact也被拒绝。未修改租约,保留运行记录;修复与配对L1待合流。
+
+T198/T199完成于00fbdce05/合流候选e4052d1c2:16新安全配对先红绿、相关173passed;主树完整runtime1275passed/13skipped(setuid未跳过),Ruff/架构守卫通过。明确保留首轮缺Helm的51工具失败记录。空闲失租仅精确lost fresh CAS恢复,未知响应当前动作拒绝;既有UID停止/新epoch事实与expired prepare只清理终结均覆盖。194/199完成;同PID真实空闲恢复与最新双架构验收进行中,不提前标业务交付。
+
+T200/T201现场登记:189 e405/Helm12同Pod a7bc860e/restart0,旧过期op正常failed/cleanupcomplete;空闲后同holder epoch3→4并提交preparing fact成功,证明租约恢复。新r3资源注册503/AttributeError,backend校验仍混用manifest.profile字符串与解析对象;尚无Pod/PVC、失败正常cleanup。37条真实Service/SQLite配对先红26/11,独立测试提交ad8d028b7;补同样阶段对象边界,未扩大规格或放宽审批/镜像校验。
+
+T200/T201/T202完成:新增37真实Service/SQLite合同先红26/11→全绿,完整backend首轮11旧fixture缺协议字段/1670passed/18skip;仅补真实kind/mode/prepare对象后完整1681passed/18skip,拒绝断言不动。新37与旧两模块Ruff/架构守卫通过。197/202完成,189按独立backend新源码复验;manager仍固定e405/f004镜像,不转标新backend SHA为该镜像来源。
+
+2026-10-22 T203–T214现场收尾登记:用户优先K8s部署,暂停新增arm验证。真实189 r4 POST403后无UID意图永久unknown,新增单次创建事务及独立not_created路径;明确拒绝和未知响应分开,旧r4无持久receipt保持隔离/PVC,不追认或直接改SQL。模板fieldRef默认差异已本地精确复现,历史403原因未恢复;新安全枚举用于后续现场定位。
+
+T203–T214 L1合流:runtime1328passed/13skip、backend1723passed/18skip;另4个真实双Session identity-map交错主树通过(旧逻辑内存插件对照4fail)。96测试任务、214总项、209完成;仅五真实交付/审查门pending。amd64709821ecb原生构建及bundle/SQLite烟测通过,不替代K8s平台闭环;本轮不新增arm测试/公开推送/tag。旧r4无creation证明保持held,其PVC不删。
+
+2026-10-23 T215–T218:189 r5已真实创建/启动准备Pod并按同UID/kubelet终态正常清理,_diagnostic日志名单遗漏遮蔽原失败;补245相关回归。进一步无凭据对比证实TLS origin根material路径404而backend401,产品nginx也缺转发;仅限定该prefix补接线,完整签名/材料业务复验仍pending。
+
+T217/T218主树材料相关80passed/1skip(本机无nginx代理集成);189原生amd64完整产品nginx配置语法通过,首次去除CHOWN导致tmpfs目录初始化失败记录保留,最小CHOWN复验通过。现场TLS精确根路径新连接401/APIenv200,不替代signed transfer。213/218,五交付门仍pending。
+
+T219/T220 L1合流:固定源码706b138614f029c52d7be49bbc50453e847f0fd4;旧实现3个关键配对红测明确因账本节点不一致失败,新executor134通过、相邻302通过;主树runtime1371passed/13skipped。仅executor35行增量,无backend/API/DDL变化。215/220、99测试任务;现场正常对账与业务验收进行中。
+
+T219/T220真实恢复通过:189 Helm15/source706b;原失败op同UID首次node绑定→kubelet_terminal→三资源cleanupcomplete/APIabsent,全由正常对账执行。第三次正常发布8070bb…已接受,完整T069仍pending。
+
+2026-10-23 T221–T224登记:r5第三次prepare inputs通过,resolve失败后正常cleanupcomplete。原fact3保留TypeError诊断,后续terminal cleanup重试fact4覆盖为failed_stage=failed/空tail。189隔离native helper A/B确认dependencies={}成功,仅lock:null触发os.environ赋值TypeError,非缺少wheel。修复可选字段合同及首次诊断保留,不补写历史事实。
+
+T221–T224 L1合流:源143606851370d6091b120d348549329b8113c6a6,helper新增24配对/相关318通过;操作新增15配对/31操作及246相邻通过。主树runtime1410passed/13skip;219/224、101测试任务。helper相关首轮2个本机监听sandbox拒绝后原样授权复跑通过,未改用例跳过。189原生None/省略A/B根因已确认,新manager/helper及真实安装报告刷新进行中,无新ARM/公开发行。
+
+集成追溯复核修正:仅T223新增测试曾沿用F086 AC-63,现按F059标注AC-48、AC-49、AC-52、AC-55;保留既有F086旧测试注释。T221模块覆盖补AC-47、AC-55;功能代码及143镜像冻结不变。
+
+2026-10-23 T225–T227:r6 prepare8fb6b53a…的inputs/resolve/install/upload全部真实成功,4Pod同UID停止、8资源最终正常清理;单次cleanup返回pending被执行器误作preparation_cleanup_pending终态,平台16227/precheck_build。按既定有界异步对账合同修复,保留历史不复活操作;无新helper/API/DDL。
+
+T225–T227开发收尾:当前222/227完成(103测试任务);主树runtime1438passed/13skip、Ruff/format/arch/diff通过。首次旧selection预期失败/等待已保留,改为持久事实序列与虚拟时钟到期held后65相关通过。r6 fresh API逐一确认8资源消失,4Pod停止证明,proof SHA110aab26…;镜像/189业务续办不由这些本地通过替代。
+
+2026-10-23 T228/T229:r7完整prepare已completed/cleanupcomplete,prepared_delivery与delivery一致;probe081663…在90秒内经历data-layout/schema-init/material-fetch,环境安装仅12秒即被统一readiness截止停止。cached镜像事件17:47:32→17:48:51→17:49:03证实串行阶段共用90秒,不把它误判为拉镜像/源码传输失败。按既定4.6阶段约束修复并保留原失败现场。
+
+2026-10-23 T230/T231:只读交叉审查以真实cancel Service+SQL+Ed25519红例复现:owner已提交payload.cancel_requested,仍running/ready的数据会话可新签票据;manager RPC也未拒绝该标志。收紧当前授权而非增加取消业务或假装回滚,和T228/T229按独占文件并行。
+
+T228–T231合流:来源a1de5271f5f00cef0cba06d41f09c673a9190525,runtime1483passed/13skip(32.30s),backend app_runtime/app_publish1734passed/19skip(48.25s);两个模块Ruff/format及逐文件架构守卫通过。当前226/231、105测试任务,余五项保持验收/发行/L2门,不声明业务上线已通过。ARM暂缓。
+
+2026-10-23 T232/T233:r8的4init合计96秒后probe appReady真实通过。当前同UID8c83f28c…已kubelet_terminal/stopconfirmed,finalizers缺省但deletionGracePeriodSeconds仍30,Pod仍存在。现有kubelet终态DELETE准入要求STOP finalizer尚在,形成移除finalizer后误拒。按同UID持久停止证明补窄分支,原操作未终态,期望修复后自然对账,不人工清理或复活。
+
+T232/T233本地L1完成:1bd926bb2两文件先红4/203绿后准入208与邻接585通过;合流b5d243f0a全runtime1519passed/13skip(32.78s)。首次默认沙箱30fail/11error均为localhost绑定、setuid权限受限,授权重跑通过,原日志保留。r8原操作随后timed_out,未复活;清理修复后只让终态正常对账。新增T234/T235校正E2E75秒请求预算,产品持久期限不变。当前228/235、107测试任务;ARM暂停。
+
+T234/T235通过21250d329合入d13e9ee91:新预算合同先红5/12绿,112合同全绿;主树完整backend1753passed/19skip(48.48s),runtime生产与已通过1519/13skip版本一致。开发修复已闭合,当前230/235;五项实际交付/发行/L2继续保留。189 Helm19已固定a06镜像4666d753…,r8原timed_out保持且4资源fresh API absent/cleanupcomplete;proof SHAa37f1bab…。r9新六场景仅采用d13验收工具源码,生产runtime与a06逐字节相同;不重标镜像来源。
+
+T236/T237来自r10真实上线维护失败:probe、预览访问/拒绝、审批均真实通过;deploy d9729fe5…maintenance_phase_failed后正常cleanupcomplete,生产PVC保留。原Pod日志已清理,未声称取得现场KeyError;固定8a helper、非root/禁网/合成身份/空临时卷A/B证明缺MATERIAL_POD_UID报KeyError,只注入UID则backup(None)通过。模板确实漏配maintenance UID,按原材料身份合同补配对,不放宽权限。
+
+T236/T237已通过79a15870f合入249d5fc85:模板→实际身份reader先红2/11正反绿,534相邻全绿;主树完整runtime1532passed/13skip(32.72s)及静态门通过。原生amd64构建/导入后私有digest42c6c3c5…、Helm20同源三个控制服务更新,descriptor/helper143/profile2和有效报告不变,平台ready/controller_epoch15。owner fresh核旧failed/cleanupcomplete、同已批准version/approval后,仅一次manual-publish新attempt已提交;结果pending不重发,原r10套件期限900秒不延长。当前232/237,真实业务/故障/发行/L2门保留。
+
+2026-10-23 T238/T239:r10首版observe真实通过,停用9f57c389…被两个同名但已闭合维护UID拦住。464bf9027两行精确合取条件跳过历史Pod;红2/62→479相关通过,当前服务UID及未知状态围栏不变,全操作cleanup等待保留。登记与L1按实际先后记录。原停用deadline19:27:30已自然过期、仍running/pending,续办suite正常失败退出;不能延时或复活,另核过期清理闭环。234/239,109测试任务。
+
+2026-10-23 T240/T241:原停用过期claim被拒,_failure只清自身空资源却误报全app已停,被正确的backend全writer guard409拒绝,造成running循环。先登记测试/实现;先真实终态pending回执再原有own cleanup,不延时期限、不放宽后端。234/241、110测试任务。
+
+T240/T241首轮独立复核发现503恢复阻断,NEEDS_FIX,仍pending。d489全runtime1570/13skip及原生构建通过只对应该中间候选,尚未push/部署;修订后重新冻结最终镜像,不能覆盖首轮失败与证据。
+
+T240/T241复审闭合:0bd6aa173合入d7c0cc4fd;503恢复精确到期清理与期限内正常stop两配对先红2/1绿,96操作及511相邻通过,独立96/静态复审PASS。主树全runtime1573passed/13skip(32.88s)。非精确expiry的不确定stop保留active非终态,不制造failed死锁;原terminal非timeout首因保持。236/241,剩五项真实验收/发行/L2门,不能由本地通过代替。
+
+T242–T245:Helm21/source d7已使原stop真实timed_out/cleanupcomplete,同UID停止/APIabsent/PVC原UID保留。首个停用rows数据动作done但ready55秒、清理69秒超原60秒;offline data4b25…失败保留。189同8a helper非root/禁网/128MiB导入25m耗31.699s/0.7926CPU,100m耗6.9965s/0.6971CPU。仅把离线Pod CPU参数与真实预占调100m(所有容器串行init同预算),不改60秒/同步响应/单writer/sidecar。此为实现资源参数修正,不推翻D1–D8或AC。236/245、112测试任务。
+
+T242–T245 L1闭合:992905292与f84415f25同源合流6f254f337;backend两行+runtime单行。backend初红4/31绿,95相关通过;模板初红2/95绿,598相关通过。主树完整runtime1586passed/13skip(34.14s)、backend1764passed/19skip(50.14s),Ruff/format/逐文件guard/diff通过。240/245,五真实验收/发行/L2门保持;189实际100m新数据会话待复验。
+
+T246/T247现场登记:r10 static probe UID2d74bfe5…全部init成功、app exit127;同固定2f0311镜像在bundle受控PATH无法找到nginx,补全标准sbin后/usr/sbin/nginx与实际entrypoint nginx -t成功。根因为模板PATH遗漏而非缺镜像/源码。240/247、113测试任务;原失败和期限保持,不手工改Pod。
+
+T246/T247 L1闭合:9项bundle运行时/阶段配对先红,模板106、完整runtime1595passed/13skip(33.42s),独立292相关合同及PATH/secret拒绝补核通过;Ruff/format/逐文件guard/diff通过。真实同镜像sbin A/B及entrypoint nginx -t通过不替代K8s复验。242/247、113测试;r10原static失败终态和四资源同UID正常cleanup已冻结,恢复使用独立新attempt,不延长或复活旧操作。
+
+T248/T249现场登记:源码准备70eb72dbbbf44e00a7c536507bfcca35安装及上传完成,组装器oci_privilege_bits_refused;操作failed/cleanupcomplete。只读stat保留scratch34/claimUID47a1e29e-b55f-46ec-955f-759ef174e2b1:267个SGID目录、零带特殊位普通文件,fsGroup继承与固定OCI归档拒绝冲突。仅归档去掉目录SGID,不放行SUID目录或带特殊位文件;D7既定安全合同内实现补漏。242/249、114测试任务。
+
+T250/T251登记:独立审查发现OCI固定PATH与T247修复前完全相同且缺sbin;同固定2f0311静态镜像的原生red127/green nginx -t已证明该路径需求。补齐源码交付相同启动合同,独立配对测试,不改用户镜像模式。242/251、115测试任务。
+
+T248–T251 L1闭合:SGID真实目录2正例先红/10危险位拒绝已绿;OCI PATH三运行时3项先红;最终完整runtime1610passed/13skip(33.01s),Ruff/format/逐文件guard/diff通过。产品改动仅oci_assembler一处权限分类和一条PATH;既有来源/凭据/固定布局不变。246/251、115测试,真实源码新attempt和能力报告刷新仍待执行。
+
+T252/T253登记:独立源码链审查确认source-image的空startup有意交给平台兼容entrypoint解析,但crane mutate未显式清除profile base CMD;与Dockerfile新ENTRYPOINT不同,运行时有参分支会先执行基础镜像默认python/main或node/server,static也会绕过生成配置。最小修复只明确空CMD并保留CSV显式命令,不改D7路线。246/253、116测试任务;实际crane空参数语义另核,不先冒认通过。
+
+T252/T253 L1闭合:缺省/空CMD六项先红、显式参数六项保持绿;完整runtime1622passed/13skip(33.09s)。189实际v0.22.1 crane非root/TLS校验,三个固定profile base分别默认python/main、node/server、nginx,--cmd空参数导出后Cmd=null且正确入口,临时tar删除、无registry mutation。代码仅原startup验证/CSV编码取消非空条件,D7/入口/凭据/用户镜像边界保持。独立差异复核无阻断;其特殊位不可保留的沙箱失败未算真实产品回归。248/253、116测试;最终源码镜像/16探测/业务仍待真实重验。
+
+### T252/T253误判撤回(编号保留,不复用)
+
+前述“crane继承CMD导致绕过入口”的判断撤回。固定v0.22.1官方实现设置非空entrypoint时已经Config.Cmd=nil,原assemble_image始终传该参数;--cmd空参数只是冗余。先前12个Mock用例仅证明新参数存在,未模拟真实CLI,不能作为原产品故障证据。源代码与测试两个文件已逐字节恢复0d5ef7b2b,实际SGID/PATH修复保留。T252/T253从活跃任务正文/索引撤出,不计完成或测试任务;活跃246/251、115测试。原提交、红绿日志、审查与已部署中间镜像历史不改写,新增纠正记录。官方依据:https://raw.githubusercontent.com/google/go-containerregistry/v0.22.1/cmd/crane/cmd/mutate.go (89–98行)。实际不带--cmd的三基础镜像对照另存,最终交付不保留此冗余变更。
+
+撤回复核完成:189真实crane v0.22.1对三个固定base不带--cmd仍导出Cmd=null,确认误报;仓库manifest不变。src/scripts/deploy/workflow相对0d5构建输入全相同,完整runtime1610/13skip(33.51s),Ruff/format/逐文件guard/diff复核。活跃246/251、115测试;已部署冗余d7保持等价,待其installer自然结束后回到0d5匹配有效报告,源代码发布尚未开始。
+
+T254/T255登记:189第二source prepare07b872e0d07441f7a333941560727bba在OCI仓库阶段失败;真实egress Pod5443超时,精确DNAT目的5000临时放行后0.008秒连通,撤回再次超时。原安装registry测试来自外部installer,仅Pod→proxy TCP检查不足。追加实际同策略上游TCP检查,不将其称为TLS或完整assembler链路。活跃246/253、116测试;两次旧失败保持,T252/T253编号撤回不复用。
+
+T254/T255 L1闭合:14先红后绿,作者213相关/独立18通过;合流后完整runtime1628passed/13skip(33.76s)。仅installer上游同策略TCP、NotReady探测隔离和README,不变manager/helper镜像、摘要算法或API。独立L1 SHA ff400d5ab04ed928070b6a7fa07cc49be412ca48f5fede620cc5b252bf0ecc99。活跃248/253、116测试,189新canary及业务全链路仍单列。
+
+T256/T257登记:本地真实方法与间接调用链复现Running准备循环仅初读operation,随后仅续controller/resource;owner cancel不参与循环判定,直至自然结束或原期限。复用已有_startup_checkpoint修正,保留同UID正常清理与不确定结果围栏。机制证据SHA7fbeb6b796f32940b6c1b7fcd8736d1893e11fb2c832a4207f6405c6dab21fce,仅本地模拟/静态,尚非189取消实测。活跃248/255、117测试任务;ARM继续暂缓。
+
+T256/T257 L1闭合:固定1a568c85c核心5行检查+79f4adb6f测试融合2ac60f808;26配对旧19fail/7pass→26pass,相邻201pass,主树完整runtime1649passed/13skip(33.77s)。独立L1 SHA83dbd6c9bf858330b97960e83d42c18db15e0891edf5b9f829cec25fc5d9142c。design4.6同步;250/255完成、117测试任务,剩余五真实验收/发行/L2门保持未完。manager镜像及189取消待复验,无新ARM/109/公开发行。
+
+2026-10-23 T258–T261登记:本地真实方法/SQLite五例复现报告TTL或helper变化可清零历史保护;189旧三bundle为false,新source/image仍true。首个触发心跳未知,不能归因最近manager升级。Python离线停用d675已自然timed_out且cleanup pending,保留UID/finalizer/PVC/预占。增量细化4.5,不新增历史恢复或物理fencing,旧false不修;活跃250/259、119测试任务。ARM及新增109继续暂缓。
+
+2026-10-23 T262/T263登记:静态真实调用者核对发现单次心跳先capabilities后probe_report会两次读投射,报告热刷新可产生expired+ready矛盾并永久失信。改一次snapshot而不放宽backend矛盾输入拒绝;Kube辅助方法在T259目标内,调用者独立T263。250/261、120测试任务;仍无ARM/新增109。
+
+2026-10-23 T258–T263 L1闭合:runtime97报告+11原子心跳新配对,独立108通过;backend51真实SQLite配对旧36红→51绿,独立51通过。完整runtime1757/13skip,后端运行/发布/SDK三模块1944/19skip;MCP按锁文件1.30临时覆盖,不改共享venv。四模块同选择原生产基线150fail/18error→新114fail/18error,差异36全为新配对,零新增;其余open_api鉴权/模式/fixture历史问题保留。源码f8863f376,256/261完成、120测试任务。189旧false不修,正式镜像/真实新停用另验,无ARM/109/公开发行。
+
+2026-10-23 R29实际部署:f886生产源已原生amd64构建/私有推送,backend两文件真实替换、Helm29 manager新UID/旧UID缺席、epoch24 ready/current/v1及两guard true独立核对通过;旧三false/新二true和held旧资源保持。256/261不变;新单Python业务尚未开始,不能勾T069/T070,ARM/新增109/公开发行继续暂缓。
+
+2026-10-23 r11真实闭环通过:新Python单v1发布/审批预览/HTTP/WS/SDK/OBO/附件/权限,正常stop后随机SQLite查改CSV、同已批准v1 resume及行数据完全一致;九项独立物理检查全部通过,同PVC、新PodUID、同UID旧停止证据及双保护保持。最终journal e3ea2f08…、physical80b98d03…;256/261不变,T069剩容量前置负例/安全删除,T070故障及独立卸载未结束。ARM/新增109/公开发行不执行。
+
+2026-10-23 T264/T265登记:真实v2容量拒绝为16125/precheck_build,符合AC-08而无需改为16226;本地真实CLI函数将16125误归exit19。仅补CLI既有容量退出码14映射及实际分发wheel,不扩展backend/UI或把私有验收期望当产品合同。256/263完成,121测试任务;ARM继续暂缓。
+
+2026-10-23 T264/T265 L1闭合:主树3红/55绿→完整CLI391passed(5.86s),源码与分发wheel完全一致、manifest仅CLI摘要变更、SDK不变。源0ff595428,未部署该wheel。189 R3真实manager无实例15.931秒,超缓存窗口5完整成功轮、原WS/CONNECT不换连接,恢复新UID59ace601…/epoch25及6Pod/7PVC/旧保护历史独立核对通过。R12单次发布真实16125/precheck_build/无审批版本,SQL五类账本零行且容量7936/3650/5不变。258/263完成,T069尚缺安全删除,T070仍缺其余故障/迁移/独立卸载;ARM/新增109/公开发行继续暂缓。
+
+2026-10-23 迁移前置:R11三删除拒绝及事后SQL不变已通过,R11 gen4 stop8d71…与fullimage gen5 stopdc0317…业务completed/cleanupcomplete;物理证明/预约/PVC保留待独立采集,未delete。m2源码包已冻结未发布;快照关联/预览采集私有工具已修正,DDL首版CLI旗标现场不支持已拒绝,不冒认迁移通过。258/263不变。
+
+2026-10-23 T266/T267登记:189 R11正常DELETE只发一次,destroy ab3004b3bb4b4cd29b83d214c4241232 failed/cleanupcomplete,purge Pod同UID停止闭合、预约释放,App仍stopped且PVC保留。manager真实POST facts422;KubernetesMaintenance沿用admitted,而ExecutionFactInput不接受该内部阶段。原fake未经过实际schema,登记真实边界配对及既有合法维护阶段修正。活跃258/265、122测试任务;首次请求未知记录保留,不重发。ARM/新增109仍暂缓。
+
+2026-10-23 T266/T267 L1闭合:真实schema/ASGI/操作链独立11红3绿→14绿;4行K8s destroy合法维护阶段与ACK后fresh checkpoint。主树全runtime选择1720pass/13skip,51例缺测试Helm路径;明确HELM_BIN后安装模块121pass,剩21例需本地loopback权限,授权后21pass,全部1771唯一测试逐项覆盖。Ruff/no-cache、format、逐文件架构门通过。源1532e115e,本地260/265、122测试;189仍R29,R30原生manager镜像/部署/删除复验待完成,未公开push/tag/ARM/新增109。
+
+2026-10-23 T268/T269登记:R30 manager源码686bf6830、内网digest6e528c88…已实际升级,5appPod/8PVC与PV及业务投影保持,epoch25→26。R11第二次独立owner DELETE生成gen6 destroy75437b15…;purge结果purged=true已持久,随后PVC异步DELETE仍可GET被误判purge_volume_cleanup_pending,操作failed/cleanupcomplete,App仍stopped。不得宣称数据保留或删除成功;新未知回执保留不重放。活跃260/267、123测试任务;ARM/新增109/公开发行暂缓。
+
+2026-10-23 T268/T269 L1闭合:有效期限旧12红4绿,固定operations.py 551f…新16绿、独立217相邻绿、完整runtime1787passed/13skip(34.00s);首轮不合法期限夹具1fail/1786pass和初始runner错误保留。仅K8s精确purge_volume_cleanup_pending复用既有_wait_cleanup;原checkpoint/期限/取消/租约/UID/API拒绝及Docker保持。活跃262/267、123测试;R31真实部署/删除复验尚未执行,ARM/追加109/公开发行暂缓。
+
+2026-10-23 T270/T271登记:M2真实迁移8aba1b5c…/gen2在migrating失败,精确同CID DDL NOTNULL证据及snapshot_confirmed/confirmed upload已独立核对。显式restore4d858a52…/gen3的helper恢复及保护快照结果已持久,随后starting资源注册POST409,操作failed/cleanupcomplete,未创建servePod。原接口body错误未留存;部署源_validate_template对restore+serve首门拒绝,可静态/真实Service复现。既有材料serve和ready自动提交路由已支持restore,登记最小入口修复;不复活失败或重发原请求。活跃262/269、124测试任务,R31源码包已准备未构建/部署,ARM/追加109/公开发行暂缓。
+
+2026-10-23 T270/T271开发收尾:实际manager模板→真实SQLite资源/材料→ready自动提交App/route配对已通过。生产38a700233/c06fb408…只接通同restore持久结果和严格resume=true的serve入口;未改材料/路由/schema/Docker。root完整app_runtime 1174pass/19skip,独立170pass及另一代理45pass,Ruff/格式/架构门通过。初次缺config、空JWT配置及True→1未持久的夹具失败保留;attached row显式dirty并fresh type断言修正测试,不放宽产品门。活跃264/269、124测试任务;T067/T069/T070/T071/T072仍pending,真实恢复/删除另验,ARM/新增109/公开发行暂缓。
+
+2026-10-23 T069业务流程闭合:R31真实升级及healthy fullimage唯一owner删除、持久purge/同UID停止、PVC自然清理/PV Retain、SQLite文件缺席通过,独立L1 b8b0b9eb…;265/269。T070及全AC未实测子句按platform.json边界保留,未将业务流程PASS当全feature完成;后续按用户要求仅定向回归。
+
+2026-10-23 T070子项:M2新restore6cd6/gen4真实完成,原行/NULL摘要、v1路由、pending清理、同操作confirmed保护上传及backend导出完整性、helper停止→单正式Pod/原PVC身份已核;原failed和工具未发包鉴权失败均保留。T070其余故障/卸载仍pending,不扩大为全AC通过。
+
+2026-10-23 T070取消子项:R31真实Node240秒npm hook在54.85秒精确UID/CID运行时owner单次取消,非owner/foreign16105;cancelled/cleanupcomplete、无审批正式版本、三Pod同UID终态及全部六资源自然清理/容量释放/原应用保持。独立L1 77e4d874…通过;265/269不变,T070仍pending。后续仅定向验证在途manager重启等剩余场景,不重复全量回归。
+
+2026-10-23 T272/T273登记:189单次SIGKILL已ACK且同manager Pod重启、epoch28→29,但c0fe prepare真实failed/execution_failed并自然清理。真实Service租约探针与双执行器最小复现证实旧lease held暂态拒绝进入业务_failure、跨自然到期后误清理;不改模板或backend,不将容器恢复记为续办通过。活跃265/271,原失败与取消未发出的预检记录保留。仅配对和相关链路验证。
+
+2026-10-23 T272/T273 L1闭合:旧7例4红3绿;主树新配对+操作执行/准备取消直接关联首轮123绿、1旧stop异常边界断言失败。仅适配该夹具并保留整op不变/旧writer不动,最窄13例全绿(0.17s),没有再跑全量。4行K8s前置门,无backend/template/Docker改动。独立L1 489710ae…两项AC/文件清单备注已补,Ruff/格式/逐文件架构门通过。267/271,189新镜像和在途重启复验待做。
+
+2026-10-23 租约交接验收收尾:修复0f2db1f9a的R32原生/私有镜像与Helm32已部署;同在途fe9a prepare经唯一manager SIGKILL、controller30→31、原安装同UID/CID自然240.006秒完成,原deadline与inputs/resolve结果保持,8资源自然清理和预约释放,原应用保持。正常后续probe也completed/cleanupcomplete并创建1版本。仅此次定向回归及只读证据核对,未重复全量。267/271,T070其余矩阵/正式CI/L2不提前勾选。
+
+### 开发整合收尾(2026-10-23)
+
+- [x] **T274** — 同版本镜像发布入口串行
+  **类型**:基础设施
+  **文件**:`.github/workflows/runtime-images.yml`、`src/runtime-manager/tests/test_image_release.py`
+  **逻辑/验收**:并发组与实际 RUNTIME_RELEASE 同源,跨 ref 的 manual/version 与同名 Tag 共享锁;保留 cancel-in-progress=false。现有定向契约先失败、修复后 1 passed;不声称仓库外写入也受该锁控制。
+  **覆盖 AC:** AC-39、AC-54、AC-56
+  **依赖:** T065
+
+**本次整合口径**:268/272。T067、T070、T071、T072 保留 pending;其中七维开发合入审查已完成,T072 的全交付 AC/发行汇总仍未闭合。依用户授权先合开发并推送,不把整合等同全部验收或正式发行。当前状态见 code-review.md 及 docs/reviews/vibe-development-integration-2026-10-23.md。
diff --git a/features/v3.0.0/059-k8s-runtime-backend/validation.json b/features/v3.0.0/059-k8s-runtime-backend/validation.json
new file mode 100644
index 0000000000..650cfb0f93
--- /dev/null
+++ b/features/v3.0.0/059-k8s-runtime-backend/validation.json
@@ -0,0 +1,2125 @@
+{
+  "feature": "F059",
+  "recorded_at": "2026-10-23T02:12:51.376223+00:00",
+  "validated_source_commit": "f8863f3763285435a5df01fc150400838bdb3f0a",
+  "sdd": {
+    "spec_confirmed": true,
+    "design_confirmed": true,
+    "tasks_review": "LGTM",
+    "l1_completed": 268,
+    "tasks_total": 272,
+    "e2e": "in_progress",
+    "l2": "development_integration_review_completed_full_delivery_pending"
+  },
+  "checks": {
+    "runtime": {
+      "source_commit": "f8863f3763285435a5df01fc150400838bdb3f0a",
+      "passed": 1757,
+      "skipped": 13,
+      "seconds": 35.27,
+      "exit_code": 0,
+      "selection": "all runtime-manager tests",
+      "log": "/private/tmp/f059-protection-final-runtime.log"
+    },
+    "backend": {
+      "source_commit": "f8863f3763285435a5df01fc150400838bdb3f0a",
+      "passed": 1944,
+      "skipped": 19,
+      "seconds": 81.11,
+      "exit_code": 0,
+      "selection": "app_runtime, app_publish, dev_toolkit; not entire backend",
+      "log": "/private/tmp/f059-protection-final-three-modules.log",
+      "combined_open_api_baseline_failures": "unchanged114fail/18error; not counted passed"
+    },
+    "public_tenant_fix": {
+      "passed": 17,
+      "source_commit": "0e3c156e5",
+      "pushed": true,
+      "log": "/private/tmp/f059-public-prereq-tests.log",
+      "release_commit": "9d156f261cc7769fcf75f41caeb104a425308017"
+    },
+    "alembic_head": "f059_resource_phases",
+    "uv_lock_frozen_offline": "passed",
+    "ruff": "changed material, control client, main/middleware source and tests passed",
+    "arch_guard": "passed per changed Python file",
+    "frontend": {
+      "passed": 98,
+      "log": "/private/tmp/f059-release-inheritance-frontend-final.log"
+    },
+    "release_permission_audit": {
+      "passed": 197,
+      "xfailed": 3,
+      "log": "/private/tmp/f059-release-inheritance-backend.log"
+    },
+    "frontend_quality": {
+      "full_gate": "fails with identical release baseline",
+      "platform_lint": 85,
+      "platform_type_errors": 1,
+      "client_lint_errors": 89,
+      "client_lint_warnings": 1,
+      "client_type_errors": 4,
+      "new_findings": 0
+    },
+    "control_http_service_repository": {
+      "passed": 64,
+      "log": "/private/tmp/f059-control-http-green.log"
+    },
+    "cluster_preflight": {
+      "host": "192.168.106.189",
+      "refreshed": "2026-10-22",
+      "node_ready": true,
+      "kubernetes": "v1.28.15",
+      "storageclass_count": 0,
+      "pv_count": 0,
+      "installed_application_resources": false,
+      "historical": true,
+      "note": "Read-only pre-install inventory; current dedicated19PV/runtime/platform installed. Full mandatory probes and business acceptance incomplete."
+    },
+    "full_app_runtime_with_middleware": {
+      "passed": 785,
+      "failed": 3,
+      "skipped": 18,
+      "exit_code": 1,
+      "log": "/private/tmp/f059-coordination-backend-full.log",
+      "test_config": "explicit fixture-only JWT signing key; no production credentials",
+      "remaining": "Historical failed snapshot superseded by backend1587passed and isolated model hook fix af8b0fe75; not current gate."
+    },
+    "material_client": {
+      "passed": 26,
+      "log": "/private/tmp/f059-material-client-final.log"
+    },
+    "kubernetes_helper_templates": {
+      "helper_cases": 19,
+      "template_admission_passed": 71,
+      "runtime_full_log": "/private/tmp/f059-runtime-helper-full.log",
+      "real_image_validation": false
+    },
+    "node_coordination": {
+      "passed": 106,
+      "log": "/private/tmp/f059-k8s-coordination-final.log",
+      "scope": "node/controller facts, resource service/address, real HMAC API, repository and model contracts"
+    },
+    "control_recovery": {
+      "passed": 68,
+      "log": "/private/tmp/f059-control-recovery-final.log"
+    },
+    "client_core_lifecycle": {
+      "passed": 82,
+      "log": "/private/tmp/f059-control-client-recovery-final.log"
+    },
+    "material_executor_control": {
+      "passed": 150,
+      "exit_code": 0,
+      "log": "/private/tmp/f059-material-executor-control-final.log"
+    },
+    "kubernetes_executor": {
+      "passed": 16,
+      "log": "/private/tmp/f059-executor-restart-final.log",
+      "cluster": "simulated",
+      "deployed": false
+    },
+    "data_helper": {
+      "passed": 18,
+      "source_commit": "6da792ca8",
+      "log": "/private/tmp/f059-data-helper-final.log",
+      "evidence": "real SQLite, fork worker deadlines and local HTTP; no deployed Pod"
+    },
+    "data_repository": {
+      "passed": 107,
+      "source_commit": "618bb4e56",
+      "log": "/private/tmp/f059-data-repository-final.log",
+      "evidence": "SQLite SQL transactions and node/resource regression"
+    },
+    "owner_data_sessions": {
+      "passed": 41,
+      "source_commit": "09e07cd1f",
+      "log": "/private/tmp/f059-data-sessions-final.log",
+      "evidence": "historical isolated services at 09e07cd1f; T045/T049 default wiring since completed, actual109 business evidence recorded separately"
+    },
+    "real_mysql_migration": {
+      "host": "192.168.106.189",
+      "database_scope": "dedicated-e2e-f059",
+      "target_head": "f059_resource_phases",
+      "exit_code": 0,
+      "private_log": "/opt/e2e-f059/logs/migration.log"
+    },
+    "docker_native_engine": {
+      "host": "192.168.106.109",
+      "scope": "dedicated-e2e-f059-docker",
+      "manager_image_id": "sha256:4e1be0d30cde2a755b1bcfaa7d0b9b743df33ebc3ec05f37a383a0f0dc1f36c1",
+      "platform": "linux/amd64",
+      "result": {
+        "platform": "linux/amd64",
+        "checks": [
+          {
+            "language": "python3.11",
+            "offline": true,
+            "code_update": true,
+            "dependency_reuse": true
+          },
+          {
+            "language": "node20",
+            "offline": true,
+            "code_update": true,
+            "dependency_reuse": true
+          },
+          {
+            "language": "static",
+            "offline": true,
+            "code_update": true,
+            "dependency_reuse": true
+          },
+          {
+            "language": "static",
+            "offline": true,
+            "install_hook": true,
+            "frontend_build": true,
+            "source_change_invalidates_dependency": true
+          },
+          {
+            "language": "python3.11",
+            "offline": true,
+            "local_native_wheel": true,
+            "source_change_invalidates_dependency": true
+          }
+        ]
+      },
+      "result_sha256": "0f8f802bbbcca0e0374f376e8a8a3216c4bfbf032b0ae640ab1e4d3e4f185e5a",
+      "non_test_containers_before_after_sha256": "a01118b42cd860d661a9062be5227ede560b3e872ef828216e9031ce3d881154",
+      "script_sha256": "a8b6ddd97fdc9eb88f7728819390f74df4a3b111179cf42aaa2c398ba8d1f3c0",
+      "code_critical_hashes": {
+        "bundle_preparer.py": "13a8e75152f5c3c13bb376166074f1f2f51fdb43cfc84ef89d6a7017d724a9fb",
+        "bundle_launch.py": "b1c4b9bf686a6def9d9910130ee07f7e90958e2217bac3f6931778a91cf74ee7"
+      },
+      "no_new_oci_images_asserted": true,
+      "temporary_containers_and_network_cleaned": true,
+      "artifact_port": "in-memory; not real backend/MinIO/approval",
+      "private_evidence_dir": "/opt/e2e-f059-docker/evidence"
+    },
+    "kubernetes_live": {
+      "host": "192.168.106.189",
+      "version": "v1.28.15",
+      "architecture": "amd64",
+      "single_node": true,
+      "control_namespace": "e2e-f059-system",
+      "execution_namespace": "e2e-f059-apps",
+      "mandatory_probe_status": "16/16 at dedc; report304b970c retained, not rerun for manager-only R29",
+      "manager_source": "f8863f3763285435a5df01fc150400838bdb3f0a",
+      "helm_revision": 29,
+      "platform_business_e2e": "r11 single v1 publish/observe/offline-data/resume plus physical checks passed; overall T069 pending capacity/delete",
+      "fault_upgrade_e2e": "R29 manager-only upgrade retention passed; other faults/uninstall pending",
+      "not_arm64_or_cross_node_evidence": true
+    },
+    "real_helper_resolve": {
+      "host": "192.168.106.189",
+      "platform": "linux/amd64",
+      "network": "none",
+      "rootfs": "read-only",
+      "uid": 10001,
+      "images": [
+        "runtime-helper:20261022-4",
+        "runtime-assembler-ca:20261022-4"
+      ],
+      "cases": [
+        "SDK requirements closure",
+        "uv.lock frozen offline export",
+        "Node ignore-scripts",
+        "static frontend ignore-scripts"
+      ],
+      "exit_code": 0,
+      "scope": "native Docker containers, not CRI acceptance",
+      "extra_ca_warning": "assembler extra CA file initially inherited 0600; rebuilt test CA images with 0444 and new profile revision2; no TLS verification disabled"
+    },
+    "installer_admission_capabilities": {
+      "passed": 263,
+      "exit_code": 0,
+      "source_commit": "a5f3f259b56109816b7a4ebaf2ac9e72d0915217",
+      "log": "/private/tmp/f059-189-fixes-local-final.log"
+    },
+    "fault_harness": {
+      "passed": 78,
+      "skipped": 4,
+      "scope": "synthetic contract incl Docker legacy schema; live opt-in disabled",
+      "log": "/private/tmp/f059-docker-diagnostic-harness-green.log"
+    },
+    "docker_109_platform_installation": {
+      "state": "passed",
+      "source_commit": "7e720073b8a57492b7befbbb7155ffe30331af01",
+      "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:f2ae12243a5004000b8ff997ddccbaabea00816e29905e1e263f477e6a9aeb7b",
+      "scope": "actual backend API diagnostics with real service identities; business/fault acceptance still running",
+      "host_firewall_rules_changed": false,
+      "firewall_fallback_applied": false
+    },
+    "batch_and_harness": {
+      "passed": 135,
+      "exit_code": 0,
+      "source_commit": "6747bd33821a7bcbfa5573a918a6b2742315b9d5",
+      "scope": "fixture suffix + offline harness contracts, not live acceptance",
+      "log": "/private/tmp/f059-batch-harness-integrated.log"
+    },
+    "public_k8s_diagnostics": {
+      "passed": 92,
+      "source_commit": "78521edd4fc198203e7199f72fd3321c2684c6bc",
+      "log": "/private/tmp/f059-k8s-observe-green.log",
+      "scope": "assessed report, projected refresh, identity changes, expiry, mandatory failure, API unavailable; real deployment pending"
+    },
+    "native_final_candidate": {
+      "source_commit": "54b3ea2c15836529cc24667d9a58be49e35bf710",
+      "amd64": "passed",
+      "arm64": "passed",
+      "evidence": "model-release-validation.json",
+      "boundary": "local native and Engine proof; formal CI not authorized"
+    },
+    "sqlite_wal_helper": {
+      "source_commit": "0af6eda1b097c2cb4dcff78d77a3930e9ed2982e",
+      "local_passed": 52,
+      "opt_in_skipped": 3,
+      "real_docker_passed": 3,
+      "real_log": "/private/tmp/f059-wal-fix-1792674511/result.json",
+      "scope": "native local one-app actual Docker payload; 109 platform revalidation pending"
+    },
+    "resume_harness": {
+      "passed": 93,
+      "new_red_green_cases": 15,
+      "source_commit": "7b728fcb664731bd51fa4fc54dcf471250587d27",
+      "production_runtime_source_unchanged_from": "54b3ea2c15836529cc24667d9a58be49e35bf710"
+    },
+    "installation_54b": {
+      "state": "passed",
+      "evidence": "e2e-evidence/installation.json",
+      "real_required_checks": 15,
+      "registry_check": true,
+      "real_api_negative_pairs": 3,
+      "without_manager_restart": true
+    },
+    "docker_platform_partial": {
+      "evidence": "docker-regression.json",
+      "state": "three_bundle_languages_two_revisions_passed",
+      "all_platform_acceptance": false
+    },
+    "docker_cancel": {
+      "evidence": "e2e-evidence/fault-upgrade.json",
+      "state": "passed_one_real_cancel",
+      "all_fault_acceptance": false
+    },
+    "source_vendor_fixture": {
+      "source_commit": "66e507ec810100a4ae3a6245d2ab6fcac49e9718",
+      "local_contracts_passed": 170,
+      "native_bare_python_network_none": "passed_arm64",
+      "scanned_files": 10,
+      "binary_wheel_skips": 8,
+      "source_key_negative": "blocked_by_unchanged_scanner",
+      "real_platform_source_image": "109 two revisions and missing-dependency negative passed",
+      "integrated_backend_conftest_passed": 170
+    },
+    "profile_contract_candidate": {
+      "source_commit": "9e7e86cab55f8b26990d2e6b6374b36418d30669",
+      "local_runtime_passed": 1217,
+      "local_runtime_skipped": 13,
+      "setuid_case_skipped": false,
+      "new_schema_cases": 17,
+      "log": "/private/tmp/f059-profile-integrated-runtime.log",
+      "real_189_failure": "AttributeError.get before resource creation; dict/str shape confirmed by read-only failed operation",
+      "real_new_candidate_deployment": "pending"
+    },
+    "unassigned_observe_candidate": {
+      "source_commit": "0e493c07a8c25b85b762b5c6f95f65d2cb47d6f9",
+      "local_runtime_passed": 1259,
+      "local_runtime_skipped": 13,
+      "new_http_pairs": 42,
+      "targeted_passed": 75,
+      "log": "/private/tmp/f059-unassigned-integrated-runtime.log",
+      "real_189_unassigned_control_responses": 4,
+      "new_manager_live_validation": "four owner DELETE followed public GET16101"
+    },
+    "native_final_0e": {
+      "source_commit": "0e493c07a8c25b85b762b5c6f95f65d2cb47d6f9",
+      "amd64": "passed",
+      "arm64": "passed",
+      "evidence": "model-release-validation.json",
+      "boundary": "local native nine-image/Engine; public CI not authorized"
+    },
+    "controller_idle_expiry": {
+      "state": "local_fix_and_L1_passed_live_pending",
+      "operation_id": "d0b665da84014b2fab43ef60f1b282b6",
+      "controller_epoch": 2,
+      "lease_until": "2026-10-22T14:21:06",
+      "first_renew_409": "2026-10-22T14:24:11",
+      "no_pod_or_pvc_created": true,
+      "tasks": [
+        "T198",
+        "T199"
+      ],
+      "source_commit": "e4052d1c2b7fab800ab2e39606d443d358ff5a1a"
+    },
+    "backend_profile_contract": {
+      "state": "L1_passed_full1681_18skip_live_pending",
+      "red_failed": 26,
+      "red_passed": 11,
+      "new_contracts_passed": 37,
+      "red_log": "/private/tmp/f059-backend-profile-contract-final-red.txt",
+      "green_log": "/private/tmp/f059-backend-profile-integrated.log",
+      "test_commit": "ad8d028b79fa4bb9c4d3a12b9f08f27ce0a518ab",
+      "live_189": "r3 failed resource register503 AttributeError before resource creation",
+      "full_backend_passed": 1681,
+      "full_backend_skipped": 18,
+      "full_log": "/private/tmp/f059-backend-profile-full-final.log",
+      "fixture_commit": "f4967f5d0452f005153214528abacf7bbc97a0ce"
+    },
+    "creation_concurrency": {
+      "passed": 4,
+      "log": "/private/tmp/f059-creation-concurrency-main.log",
+      "source_commit": "095a4bf8e4e4a695553262693913d4282b9c4efd",
+      "boundary": "Two independent SQLAlchemy repositories/sessions. ORM identity-map interleavings only, not MySQL/DM8 lock acceptance."
+    },
+    "arm_priority": {
+      "new_testing": "deferred_by_user",
+      "prior_unresolved_wal_timeout_preserved": true,
+      "priority": "189 Kubernetes business deployment first"
+    },
+    "diagnostics": {
+      "passed": 245,
+      "source_commit": "d86e66a2db83a26f0faca1993b914be121ef3259",
+      "log": "/private/tmp/f059-diagnostics-integrated.log"
+    },
+    "k8s_business_checkpoint": {
+      "accepted": false,
+      "evidence": "e2e-evidence/platform.json",
+      "manager_source": "d86e66a2db83a26f0faca1993b914be121ef3259",
+      "helm_revision": 14,
+      "backend_updated_files_source": "709821ecbe5c653aad3f90358173e0d110bee7e3"
+    },
+    "material_ingress_contract": {
+      "passed": 80,
+      "skipped": 1,
+      "log": "/private/tmp/f059-material-ingress-main.log",
+      "skip": "optional local nginx process unavailable",
+      "real_189_product_nginx_syntax": "passed native amd64 with dedicated read-only config and CHOWN only on tmpfs",
+      "signed_material_transfer": "pending"
+    },
+    "late_scheduling_binding": {
+      "status": "local_and_real_cleanup_passed",
+      "tasks": [
+        "T219",
+        "T220"
+      ],
+      "observed": "same UID Pod Succeeded; facts200; ledger node empty vs observed real node; stopped409",
+      "runtime_scope_unchanged": true,
+      "executor_passed": 134,
+      "adjacent_passed": 302,
+      "baseline_three_semantic_failures": true,
+      "real_operation_id": "01a6500a2a7b46578e677abb07320e1c",
+      "real_uid": "81be1259-85ac-44df-8a6c-9a43ab4fc332",
+      "real_cleanup_proof_sha256": "dc8150cce9cf728a02e90d8b09673a1b025a57fb450389e088a14900fbecb733"
+    },
+    "native_amd64_manager_706": {
+      "schema": "f059-native-manager/v1",
+      "source_revision": "706b138614f029c52d7be49bbc50453e847f0fd4",
+      "platform": "linux/amd64",
+      "build_exit": 0,
+      "import_smoke_exit": 0,
+      "manager_image_id": "sha256:81d6a9537d82bef2703884fb4052dc2e9f814d0b88e50a9a8f572e0f272137cc",
+      "public_push": false,
+      "preceding_full_bundle_validation_source": "709821ecbe5c653aad3f90358173e0d110bee7e3",
+      "preceding_manager_source": "d86e66a2db83a26f0faca1993b914be121ef3259",
+      "changed_runtime_files": [
+        "runtime_manager/executors/kubernetes.py"
+      ],
+      "boundary": "Native manager build/import only; preceding bundle/SQLite smoke retained separately. No arm/CI public release claim.",
+      "prior_orchestration_attempt": {
+        "exit_code": 127,
+        "stage": "before build script upload completed",
+        "image_build_performed": false,
+        "subsequent_ordered_import_build_passed": true
+      }
+    },
+    "helm_revision15": {
+      "descriptor_uid_unchanged": true,
+      "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:ae93f837a58529eee86f85bbd79f46801a9e068ab7c84babe35292209c97ad41",
+      "helm_upgrade_completed": true,
+      "source_revision": "706b138614f029c52d7be49bbc50453e847f0fd4",
+      "descriptor_data_unchanged": true,
+      "helper_and_profile_images_unchanged": true,
+      "app_proxy_unchanged": true
+    },
+    "optional_lock_null": {
+      "status": "local_fix_passed_native_delivery_pending",
+      "native_amd64_ab": "omitted lock succeeded; null lock TypeError at k8s_helper.resolve_download os.environ assignment",
+      "no_network_or_credentials": true,
+      "paired_passed": 24,
+      "related_passed": 318,
+      "first_repro_permission_boundary": "Original private fixture mode0600 refused nonroot read; copied dedicated test-only fixture0444 then A/B ran; original untouched"
+    },
+    "terminal_failure_diagnostic_retention": {
+      "status": "local_fix_passed_native_delivery_pending",
+      "real_facts": [
+        {
+          "sequence": 3,
+          "failed_stage": "preparing",
+          "tail": "runtime helper failed (TypeError)"
+        },
+        {
+          "sequence": 4,
+          "failed_stage": "failed",
+          "tail": ""
+        }
+      ],
+      "operations_passed": 31,
+      "related_passed": 246
+    },
+    "native_manager_helper_143": {
+      "schema": "f059-native-manager-helper/v1",
+      "source_revision": "143606851370d6091b120d348549329b8113c6a6",
+      "platform": "linux/amd64",
+      "build_exit": 0,
+      "import_smoke_exit": 0,
+      "helper_null_lock_smoke_exit": 0,
+      "images": {
+        "manager": {
+          "tag": "e2e-f059/bisheng-runtime-manager:e2e-f059-native-20261023-143606851-amd64",
+          "image_id": "sha256:727a725ed148acede43aaf4052f923eda229498d5e4a83db2cd6946562140b18",
+          "native_smoke": true
+        },
+        "helper": {
+          "tag": "e2e-f059/bisheng-runtime-helper:e2e-f059-native-20261023-143606851-amd64",
+          "image_id": "sha256:63c1243998c8ca303e59f2bed080ca70b4e7938914849dc77c7b0cef85d6ff35",
+          "native_smoke": true
+        }
+      },
+      "public_push": false,
+      "profile_images_unchanged": true,
+      "preceding_full_bundle_validation_source": "709821ecbe5c653aad3f90358173e0d110bee7e3",
+      "boundary": "Native manager/helper build and real empty-dependency resolution only. No new ARM, CI public release or full business claim."
+    },
+    "helm_revision16": {
+      "helm_upgrade_completed": true,
+      "source_revision": "143606851370d6091b120d348549329b8113c6a6",
+      "app_proxy_profile_assembler_unchanged": true,
+      "probe_report_requires_real_refresh": true,
+      "helper_image": "192.168.106.189:5443/f059/runtime-helper@sha256:8a8f92c41e85fdb12e236ba60e37eccc5da2d3462c7ac75a99d5f35d6a83a049",
+      "descriptor_only_helper_changed": true,
+      "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:a537fc0b647d6213c63d30142c502bbc0befe993eec0965c9a735dd7d204cd61",
+      "descriptor_uid_unchanged": true
+    },
+    "current_candidate_installation": {
+      "state": "passed",
+      "first_probe_result": "15 of16 pass; storage-sqlite deadline due all existing preflight PVs Released; canaries cleaned normally",
+      "preflight_pool_added": {
+        "count": 4,
+        "capacity_each": "1Gi",
+        "names": [
+          "e2e-f059-preflight-04",
+          "e2e-f059-preflight-05",
+          "e2e-f059-preflight-06",
+          "e2e-f059-preflight-07"
+        ],
+        "inventory_sha256": "35f57e5fb4e4a49df8b12862c9e66da90bcab0e88e797a988e7ef9aa473dddf2",
+        "original19_unchanged_during_apply": true,
+        "existing_loop_mappings_unchanged": true,
+        "retention": "Retain; no old-volume rebind or wipe",
+        "initial_plan_refusal": "scratch06 marker stat changed from uid0/gid0/0444 to actual uid0/gid10001/0664; new exact observed snapshot made, old snapshot/volume unchanged"
+      },
+      "second_probe": {
+        "artifacts": {
+          "image-cri": [
+            {
+              "image_id": "docker-pullable://192.168.106.189:5443/f059/runtime-helper@sha256:8a8f92c41e85fdb12e236ba60e37eccc5da2d3462c7ac75a99d5f35d6a83a049",
+              "image_ref": "192.168.106.189:5443/f059/runtime-helper@sha256:8a8f92c41e85fdb12e236ba60e37eccc5da2d3462c7ac75a99d5f35d6a83a049",
+              "platform": "linux/amd64",
+              "resource_version": "3184803",
+              "uid": "77a25d8b-9cad-459a-a889-f964c9efbde4"
+            },
+            {
+              "image_id": "docker-pullable://192.168.106.189:5443/f059/app-python-ca@sha256:b632d5192bd370c5690aee2c2cc192954522dd0cd44ab0f5c8be050a3e033263",
+              "image_ref": "192.168.106.189:5443/f059/app-python-ca@sha256:b632d5192bd370c5690aee2c2cc192954522dd0cd44ab0f5c8be050a3e033263",
+              "platform": "linux/amd64",
+              "resource_version": "3184885",
+              "uid": "0959acdd-5f2c-450b-a1ea-e25fcfb9aab5"
+            },
+            {
+              "image_id": "docker-pullable://192.168.106.189:5443/f059/app-python-preparer-ca@sha256:7d4d46ca19fa0a5ae71f177f81a45df58dc3d3a676d4c536615a9376673d55e9",
+              "image_ref": "192.168.106.189:5443/f059/app-python-preparer-ca@sha256:7d4d46ca19fa0a5ae71f177f81a45df58dc3d3a676d4c536615a9376673d55e9",
+              "platform": "linux/amd64",
+              "resource_version": "3184943",
+              "uid": "0c58309a-8f71-4d90-8a85-9dc3a63c58e7"
+            },
+            {
+              "image_id": "docker-pullable://192.168.106.189:5443/f059/app-node-ca@sha256:533c8eb5040441e9420969ff0b305b45572ea58b80d345c27893dd9edc4a2763",
+              "image_ref": "192.168.106.189:5443/f059/app-node-ca@sha256:533c8eb5040441e9420969ff0b305b45572ea58b80d345c27893dd9edc4a2763",
+              "platform": "linux/amd64",
+              "resource_version": "3185002",
+              "uid": "5f17375e-5987-439c-a81f-9fd2686a4c33"
+            },
+            {
+              "image_id": "docker-pullable://192.168.106.189:5443/f059/app-node-preparer-ca@sha256:27b13ce9a57c59f9724001d5a69ede5029a6c6709a702a55fba435a77f77e666",
+              "image_ref": "192.168.106.189:5443/f059/app-node-preparer-ca@sha256:27b13ce9a57c59f9724001d5a69ede5029a6c6709a702a55fba435a77f77e666",
+              "platform": "linux/amd64",
+              "resource_version": "3185059",
+              "uid": "5d0740c2-47e9-41ee-b23e-c4b4cd9d6643"
+            },
+            {
+              "image_id": "docker-pullable://192.168.106.189:5443/f059/app-static-ca@sha256:2f0311da8b281f35437fcf26b47c8249f8d86f98227a28bb231cd472071ee218",
+              "image_ref": "192.168.106.189:5443/f059/app-static-ca@sha256:2f0311da8b281f35437fcf26b47c8249f8d86f98227a28bb231cd472071ee218",
+              "platform": "linux/amd64",
+              "resource_version": "3185112",
+              "uid": "ad2059f6-a69b-417e-9d09-d6b6b440ae4d"
+            }
+          ]
+        },
+        "checks": [
+          {
+            "evidence": {
+              "uid": "77a25d8b-9cad-459a-a889-f964c9efbde4"
+            },
+            "name": "admission-create",
+            "observed_at": 1792689184.1286888,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "uid": "e86a13de-4dba-4630-8d34-b7bde1fa95ce"
+            },
+            "name": "binding-delete-fence",
+            "observed_at": 1792689184.1286888,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "digest": "sha256:8a8f92c41e85fdb12e236ba60e37eccc5da2d3462c7ac75a99d5f35d6a83a049",
+              "uid": "77a25d8b-9cad-459a-a889-f964c9efbde4"
+            },
+            "name": "image-cri",
+            "observed_at": 1792689184.1286888,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "code": "control_side_tls",
+              "uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b"
+            },
+            "name": "material-tls",
+            "observed_at": 1792689184.1286888,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b"
+            },
+            "name": "namespace-budget",
+            "observed_at": 1792689184.1286888,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "uid": "efde81a0-2f56-4623-9cf6-4965be508a50"
+            },
+            "name": "network-direct-denied",
+            "observed_at": 1792689184.1286888,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "uid": "672faea6-38c7-4267-a5d2-0df8b9007f84"
+            },
+            "name": "network-dns-denied",
+            "observed_at": 1792689184.1286888,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "uid": "9ef1a390-4bf4-4f40-9dfe-58c2ce22b68f"
+            },
+            "name": "network-egress-allowed",
+            "observed_at": 1792689184.1286888,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "uid": "77a25d8b-9cad-459a-a889-f964c9efbde4"
+            },
+            "name": "network-ingress",
+            "observed_at": 1792689184.1286888,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "uid": "ffeedf5f-6001-477a-881a-67c33432248c"
+            },
+            "name": "no-exec",
+            "observed_at": 1792689184.1286888,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "code": "control_side_authenticated_api",
+              "uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b"
+            },
+            "name": "platform-capabilities",
+            "observed_at": 1792689184.1286888,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "uid": "ffeedf5f-6001-477a-881a-67c33432248c"
+            },
+            "name": "rbac-namespace",
+            "observed_at": 1792689184.1286888,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "digest": "sha256:8a8f92c41e85fdb12e236ba60e37eccc5da2d3462c7ac75a99d5f35d6a83a049",
+              "uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b"
+            },
+            "name": "registry-push",
+            "observed_at": 1792689184.1286888,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "uid": "77a25d8b-9cad-459a-a889-f964c9efbde4"
+            },
+            "name": "restricted-psa",
+            "observed_at": 1792689184.1286888,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "policy_revision": "v1",
+              "uid": "77a25d8b-9cad-459a-a889-f964c9efbde4"
+            },
+            "name": "status-provenance",
+            "observed_at": 1792689184.1286888,
+            "ok": true,
+            "reason": "verified"
+          },
+          {
+            "evidence": {
+              "uid": "12e4f5eb-e512-4549-8395-eb9620c281d8"
+            },
+            "name": "storage-sqlite",
+            "observed_at": 1792689184.1286888,
+            "ok": true,
+            "reason": "verified"
+          }
+        ],
+        "descriptor_digest": "d7e831157cd56ff2a941118b9deafb2c2f62b572c633428efb4412839a337afd",
+        "observed_at": 1792689184.1286888,
+        "source": "cluster-probe"
+      }
+    },
+    "r6_start_guard": {
+      "initial_private_wrapper_exit": 1,
+      "reason": "missing required now keyword in assess_capabilities; before runner/API/journal",
+      "fixed_wrapper": "f059-run-platform-r6-v2.sh",
+      "fixed_descriptor_ready_guard_passed": true,
+      "backend_node_ready_before_start": true,
+      "business_started": true,
+      "stage": "platform",
+      "result": "in_progress"
+    },
+    "async_cleanup_completion": {
+      "status": "native_amd64_built_and_deployed_r7_business_in_progress",
+      "actual_operation_id": "8fb6b53a167e4ce28718336a4840a43d",
+      "prepare_stages_succeeded": [
+        "inputs",
+        "resolve",
+        "install",
+        "upload"
+      ],
+      "pods_confirmed": 4,
+      "resources_finally_cleaned": 8,
+      "public_failure_code": 16227,
+      "reason": "preparation_cleanup_pending",
+      "failure_stage_preserved": "preparing",
+      "fix_commit": "1f788fc3519a3984c63ed87e567666b3dbd4aa13",
+      "operations_tests": 55,
+      "related_regression_passed": 284,
+      "selection_and_operations_passed": 65,
+      "independent_readonly_review": "no actionable findings",
+      "r6_cleanup_proof_sha256": "110aab26f2846ed75f35a02a95e948e40e8ec5cba21528ead45acec7dfa87d77",
+      "native": {
+        "build_exit": 0,
+        "import_smoke_exit": 0,
+        "public_push": false,
+        "manager_image_id": "sha256:ec08599a64325be5b8dac8c7fe31885325ed7e1c586a098b29c824a979702184",
+        "platform": "linux/amd64",
+        "source_revision": "7816e0bb87cd1b9ea0c82e8c01283cdf2e821a13",
+        "preceding_full_bundle_validation_source": "709821ecbe5c653aad3f90358173e0d110bee7e3",
+        "changed_runtime_files": [
+          "runtime_manager/operations.py"
+        ],
+        "boundary": "Native manager build/import only; preceding bundle/SQLite smoke retained separately. No arm/CI public release claim.",
+        "preceding_manager_source": "143606851370d6091b120d348549329b8113c6a6",
+        "schema": "f059-native-manager/v1"
+      },
+      "private_image": {
+        "manager_image_id": "sha256:ec08599a64325be5b8dac8c7fe31885325ed7e1c586a098b29c824a979702184",
+        "source_revision": "7816e0bb87cd1b9ea0c82e8c01283cdf2e821a13",
+        "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:3e0bf023a016b3f486bd5fe5cbcae85d89cc8737daaa9166f926291ec7781de5",
+        "public_push": false,
+        "private_tag": "192.168.106.189:5443/f059/runtime-manager:20261023-19"
+      },
+      "helm": {
+        "descriptor_uid_unchanged": true,
+        "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:3e0bf023a016b3f486bd5fe5cbcae85d89cc8737daaa9166f926291ec7781de5",
+        "helm_upgrade_completed": true,
+        "source_revision": "7816e0bb87cd1b9ea0c82e8c01283cdf2e821a13",
+        "descriptor_data_unchanged": true,
+        "helper_and_profile_images_unchanged": true,
+        "app_proxy_unchanged": true
+      },
+      "helm_revision": 17,
+      "descriptor_report_unchanged": true,
+      "backend_node_ready": true,
+      "controller_epoch": 11
+    },
+    "startup_stage_budget": {
+      "status": "historical_r7_failure_fixed_r8_startup_passed",
+      "actual_probe_operation": "0816633805bd414d828ff1bef8b6995b",
+      "reason": "workload_readiness_timeout",
+      "cleanup_status": "complete",
+      "measured_event_times": {
+        "scheduled": "2026-10-22T17:47:32Z",
+        "environment_started": "2026-10-22T17:48:51Z",
+        "environment_killed": "2026-10-22T17:49:03Z"
+      },
+      "all_images_cached": true,
+      "materialized": true,
+      "application_started": false,
+      "environment_only_seconds": 12,
+      "fix_commit": "a3b50f9d6740a8f6654454fa5e701252c867d3cb",
+      "executor_passed": 175,
+      "adjacent_passed": 392,
+      "historical_attempt": "k189r7",
+      "r8_init_total_seconds": 96,
+      "r8_probe_ready": true,
+      "r8_failed_later_during_cleanup": true
+    },
+    "data_cancel_fence": {
+      "status": "backend_and_manager_deployed_a1de_live_data_pending",
+      "evidence_kind": "local_real_service_sql_and_ed25519",
+      "red_log": "/private/tmp/f059-review-cancelled-data-grant-red.log",
+      "red_failed": 1,
+      "existing_related_passed": 159,
+      "boundary": "No remote or K8s data acceptance claim",
+      "fix_commit": "469175f19857bbd8021814da7164f21bc97f49ec",
+      "new_paired": 12,
+      "backend_related_passed": 170,
+      "manager_related_passed": 176
+    },
+    "final_dev_backend": {
+      "passed": 1734,
+      "skipped": 19,
+      "seconds": 48.25,
+      "warnings": 146,
+      "exit_code": 0,
+      "log": "/private/tmp/f059-final-dev-backend.log",
+      "scope": "app_runtime + app_publish; local tests, no real DM8 claim"
+    },
+    "a1de_live_candidate": {
+      "native": {
+        "build_exit": 0,
+        "import_smoke_exit": 0,
+        "public_push": false,
+        "manager_image_id": "sha256:32e65744a296d2997f2e3395a6c8a78a035109854bb25beff05cc024a398fd6f",
+        "platform": "linux/amd64",
+        "source_revision": "a1de5271f5f00cef0cba06d41f09c673a9190525",
+        "preceding_full_bundle_validation_source": "709821ecbe5c653aad3f90358173e0d110bee7e3",
+        "changed_runtime_files": [
+          "runtime_manager/executors/kubernetes.py",
+          "runtime_manager/k8s_data_executor.py"
+        ],
+        "boundary": "Native manager build/import only; preceding bundle/SQLite smoke retained separately. No arm/CI public release claim.",
+        "preceding_manager_source": "7816e0bb87cd1b9ea0c82e8c01283cdf2e821a13",
+        "schema": "f059-native-manager/v1"
+      },
+      "image": {
+        "manager_image_id": "sha256:32e65744a296d2997f2e3395a6c8a78a035109854bb25beff05cc024a398fd6f",
+        "source_revision": "a1de5271f5f00cef0cba06d41f09c673a9190525",
+        "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:06bb857f4caa79499a844d16838831c7429dfc7d499e65649f314d96fd390cad",
+        "public_push": false,
+        "private_tag": "192.168.106.189:5443/f059/runtime-manager:20261023-20"
+      },
+      "helm": {
+        "descriptor_uid_unchanged": true,
+        "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:06bb857f4caa79499a844d16838831c7429dfc7d499e65649f314d96fd390cad",
+        "helm_upgrade_completed": true,
+        "source_revision": "a1de5271f5f00cef0cba06d41f09c673a9190525",
+        "descriptor_data_unchanged": true,
+        "helper_and_profile_images_unchanged": true,
+        "app_proxy_unchanged": true
+      },
+      "helm_revision": 18,
+      "backend_update": {
+        "files": [
+          {
+            "before_sha256": "ef1e5204a1003c49b98b5b329058d616760054614a633eaf611e9639ff66258b",
+            "after_sha256": "6cff3d17ab50e864d0436bdb560613f0f64bfc1bf2ea36131659e4fb822fc68b",
+            "file": "bisheng/app_runtime/domain/services/runtime_data_grant_service.py"
+          },
+          {
+            "before_sha256": "c6216578607f1b3da79f22b1e6367070f1007db2dc5a63578dc6656b36cd9830",
+            "after_sha256": "767f522cf0db734476bf6a578d7849287a47a14ba05ea685d75b09ed93810a2a",
+            "file": "bisheng/app_runtime/domain/services/runtime_data_session_service.py"
+          }
+        ],
+        "other_services_restarted": false,
+        "containers_restarted": [
+          "e2e-f059-backend",
+          "e2e-f059-worker"
+        ],
+        "source_revision": "a1de5271f5f00cef0cba06d41f09c673a9190525",
+        "models_or_schema_changed": false,
+        "public_env_http_200": false
+      },
+      "backend_ready": {
+        "initial_check_error": "operator used loopback but port is bound to private host IP",
+        "backend_files_verified": 2,
+        "actual_bound_api": "http://192.168.106.189:17860",
+        "additional_restart": false,
+        "source_revision": "a1de5271f5f00cef0cba06d41f09c673a9190525",
+        "initial_receipt_preserved": true,
+        "public_env_http_200": true
+      },
+      "backend_node_ready": true,
+      "controller_epoch": 13,
+      "new_arm_run": false
+    },
+    "after_finalizer_delete": {
+      "status": "real_cleanup_passed_original_failure_preserved",
+      "operation_id": "cd1ad0fe93584373b9f81d1a7b8f51d7",
+      "pod_uid": "8c83f28c-3a6e-460e-8631-fc0242d49c92",
+      "probe_ready": true,
+      "init_total_seconds": 96,
+      "stop_state": "confirmed",
+      "evidence_kind": "kubelet_terminal",
+      "finalizers": null,
+      "deletion_grace_seconds": 30,
+      "manual_delete_or_sql_override": false,
+      "operation_stage": "timed_out",
+      "operation_status": "timed_out",
+      "cleanup_status": "complete",
+      "source_revision": "1bd926bb2a37bc3d95a31920c2ae38be08e9ea6f",
+      "resources_fresh_api_absent": 4,
+      "proof_sha256": "a37f1bab1216ab6029b5b7a24cf52f12db04ac375208d95b9a1e5de490855f1b",
+      "proof_path": "/opt/e2e-f059/evidence/k189r8-cleanup-verified.json"
+    },
+    "backend_before_data_cancel": {
+      "passed": 1723,
+      "skipped": 18,
+      "exit_code": 0,
+      "source_commit": "709821ecbe5c653aad3f90358173e0d110bee7e3",
+      "log": "/private/tmp/f059-creation-integrated-backend.log",
+      "scope": "full app_runtime/app_publish including signed creation protocol and lock refresh"
+    },
+    "terminal_admission_live_candidate": {
+      "native": {
+        "build_exit": 0,
+        "import_smoke_exit": 0,
+        "public_push": false,
+        "manager_image_id": "sha256:3c97d58aada64fb4193ec1b3e3c9638fbf26d68094fa43297fec7d5ddd127624",
+        "platform": "linux/amd64",
+        "source_revision": "a06b281b2ada8cfa15cb79c29fad0044408e4db9",
+        "preceding_full_bundle_validation_source": "709821ecbe5c653aad3f90358173e0d110bee7e3",
+        "changed_runtime_files": [
+          "runtime_manager/k8s_admission.py"
+        ],
+        "boundary": "Native manager build/import only; preceding bundle/SQLite smoke retained separately. No arm/CI public release claim.",
+        "preceding_manager_source": "a1de5271f5f00cef0cba06d41f09c673a9190525",
+        "schema": "f059-native-manager/v1"
+      },
+      "image": {
+        "manager_image_id": "sha256:3c97d58aada64fb4193ec1b3e3c9638fbf26d68094fa43297fec7d5ddd127624",
+        "source_revision": "a06b281b2ada8cfa15cb79c29fad0044408e4db9",
+        "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:4666d75388bde2ae7c1b0b73916442451b4119a7384852cf373633e8489dfe32",
+        "public_push": false,
+        "private_tag": "192.168.106.189:5443/f059/runtime-manager:20261023-21"
+      },
+      "helm": {
+        "descriptor_uid_unchanged": true,
+        "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:4666d75388bde2ae7c1b0b73916442451b4119a7384852cf373633e8489dfe32",
+        "helm_upgrade_completed": true,
+        "source_revision": "a06b281b2ada8cfa15cb79c29fad0044408e4db9",
+        "descriptor_data_unchanged": true,
+        "helper_and_profile_images_unchanged": true,
+        "app_proxy_unchanged": true
+      }
+    },
+    "request_budget": {
+      "source_revision": "21250d32900c4c4ba893248aa5b8cc1d8d65182b",
+      "passed": 112,
+      "red_failed": 5,
+      "red_passed": 12,
+      "new_public_api": false,
+      "post_retries": false
+    },
+    "current_business_batch": {
+      "journal": "/opt/e2e-f059/evidence/k8s-platform-k189r10-cycle-resumed.json",
+      "log": "/opt/e2e-f059/logs/platform-k189r10-cycle-cpu100.log",
+      "state": "in_progress",
+      "python_v2_version": "a1c22f4e6de448dab2783ffd499568ef",
+      "python_v2_stage_observed": "deploy starting after precheck and approval",
+      "python_v2_operation_id": "462c127e015248ab9b9cd512be8f1dc8",
+      "remaining": "complete v2 access; Node/static/source-image/full-image/expected dependency failure; stopped edit/export and fault acceptance"
+    },
+    "business_batch_history": [
+      {
+        "batch": "k189r9",
+        "suite_source": "d13e9ee91a1014d576d11def1eff9cba21f4c927",
+        "production_runtime_identical_to_image_source": "a06b281b2ada8cfa15cb79c29fad0044408e4db9",
+        "profile_revision": 2,
+        "stage": "platform",
+        "scenarios": 6,
+        "result": "in_progress",
+        "new_arm_run": false
+      }
+    ],
+    "test_entry_ca_correction": {
+      "r9_failed": "ConnectError",
+      "missing_public_ca_env_confirmed": true,
+      "certificate_verification_disabled": false,
+      "explicit_public_ca_http_status": 200,
+      "r9_normal_cleanup": true,
+      "production_code_changed": false
+    },
+    "maintenance_material_uid": {
+      "status": "real_python_first_release_online_verified",
+      "actual_reason": "maintenance_phase_failed",
+      "actual_failed_stage": "maintenance",
+      "actual_cleanup_status": "complete",
+      "actual_pod_log_unavailable_after_normal_cleanup": true,
+      "local_contract": "maintenance reads MATERIAL_POD_UID but template only injects transfer/material-fetch",
+      "native_helper_ab": {
+        "missing_uid": "KeyError:MATERIAL_POD_UID",
+        "injected_uid_empty_volume_backup": "passed",
+        "network": "none",
+        "identity": "synthetic_only",
+        "production_volume_access": false
+      },
+      "production_pvc_uid": "4c5b459c-25c6-45ce-9000-370131477d27",
+      "actual_pod_failure_log_retained": false
+    },
+    "maintenance_uid_live_candidate": {
+      "native": {
+        "build_exit": 0,
+        "import_smoke_exit": 0,
+        "public_push": false,
+        "manager_image_id": "sha256:0a6c3f7d4d007499a54ed15de247022a824e897c64f0255a10e7671f476d8a38",
+        "platform": "linux/amd64",
+        "source_revision": "249d5fc85b2ed08db88c93b7676ced73fdb8ee7b",
+        "preceding_full_bundle_validation_source": "709821ecbe5c653aad3f90358173e0d110bee7e3",
+        "changed_runtime_files": [
+          "runtime_manager/k8s_templates.py"
+        ],
+        "boundary": "Native manager build/import only; preceding bundle/SQLite smoke retained separately. No arm/CI public release claim.",
+        "preceding_manager_source": "a06b281b2ada8cfa15cb79c29fad0044408e4db9",
+        "schema": "f059-native-manager/v1"
+      },
+      "image": {
+        "manager_image_id": "sha256:0a6c3f7d4d007499a54ed15de247022a824e897c64f0255a10e7671f476d8a38",
+        "source_revision": "249d5fc85b2ed08db88c93b7676ced73fdb8ee7b",
+        "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:42c6c3c5ba9adb6f6340d0de979e9896d32e81f205f44851c492f50ea4fd7209",
+        "public_push": false,
+        "private_tag": "192.168.106.189:5443/f059/runtime-manager:20261023-22"
+      },
+      "helm": {
+        "descriptor_uid_unchanged": true,
+        "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:42c6c3c5ba9adb6f6340d0de979e9896d32e81f205f44851c492f50ea4fd7209",
+        "helm_upgrade_completed": true,
+        "source_revision": "249d5fc85b2ed08db88c93b7676ced73fdb8ee7b",
+        "descriptor_data_unchanged": true,
+        "helper_and_profile_images_unchanged": true,
+        "app_proxy_unchanged": true
+      },
+      "node": {
+        "node_id": "e2e-f059-node",
+        "status": "ready",
+        "controller_epoch": 15,
+        "ready": true
+      }
+    },
+    "first_k8s_python_online": {
+      "new_version_or_approval_created": false,
+      "original_pvc_preserved": true,
+      "pod_running_and_ready": true,
+      "observed_at": "2026-10-22T19:14:21.515421Z",
+      "production_pvc_uid": "4c5b459c-25c6-45ce-9000-370131477d27",
+      "pod_name": "app-6153b2cdf43a4bbef23a5d0a47e2a916ca8a0082",
+      "manager_source": "249d5fc85b2ed08db88c93b7676ced73fdb8ee7b",
+      "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:42c6c3c5ba9adb6f6340d0de979e9896d32e81f205f44851c492f50ea4fd7209",
+      "manual_sql_or_finalizer_change": false,
+      "pod_uid": "a27db53e-6f47-4ff3-b455-35ec038ca578",
+      "manual_receipt_sha256": "299c8b4816a248611c4ae114cb414eaf528324eb362106bafef312a1678f8a55",
+      "manual_publish": {
+        "status": "completed",
+        "cleanup_status": "complete",
+        "app_id": "a4a566cacbfb477daaf2bd90ea3eb21a",
+        "version_id": "ba5d47d9369540df9d5219adc72a55bf",
+        "old_operation_id": "d9729fe5c905480bbfd2cf796f344a97",
+        "purpose": "f059-r10-owner-manual-publish-v1",
+        "phase": "verified",
+        "operation_id": "20d42a22ce414ceda571038d10363ce8",
+        "approval_instance_id": 2
+      },
+      "boundary": "First approved Python release online after explicit new attempt. Production entry/SDK/data acceptance remains separate.",
+      "profile_revision": 2,
+      "schema": "f059-first-online/v1"
+    },
+    "first_k8s_python_observe": {
+      "status": "passed",
+      "journal_events": [
+        {
+          "kind": "live-api",
+          "name": "application-check",
+          "app_id": "a4a566cacbfb477daaf2bd90ea3eb21a",
+          "acs": [
+            "AC-36",
+            "AC-46"
+          ],
+          "observed_at": 1792696638.147508,
+          "path": "/health",
+          "outcome": "passed"
+        },
+        {
+          "kind": "live-api",
+          "name": "application-check",
+          "app_id": "a4a566cacbfb477daaf2bd90ea3eb21a",
+          "acs": [
+            "AC-36",
+            "AC-46"
+          ],
+          "observed_at": 1792696638.1925256,
+          "path": "/sdk/identity",
+          "outcome": "passed"
+        },
+        {
+          "kind": "live-api",
+          "name": "application-check",
+          "app_id": "a4a566cacbfb477daaf2bd90ea3eb21a",
+          "acs": [
+            "AC-36",
+            "AC-46"
+          ],
+          "observed_at": 1792696638.5836573,
+          "path": "/sdk/storage-roundtrip",
+          "outcome": "passed"
+        },
+        {
+          "kind": "live-api",
+          "name": "websocket-allow-deny",
+          "acs": [
+            "AC-12",
+            "AC-36",
+            "AC-53"
+          ],
+          "observed_at": 1792696638.7143133,
+          "outcome": "passed",
+          "slug": "e2e-f059-python-bundle-k189r10"
+        },
+        {
+          "kind": "live-api",
+          "name": "owner-and-entry-boundaries",
+          "app_id": "a4a566cacbfb477daaf2bd90ea3eb21a",
+          "acs": [
+            "AC-10",
+            "AC-12",
+            "AC-33",
+            "AC-36",
+            "AC-46"
+          ],
+          "observed_at": 1792696647.621125,
+          "outcome": "passed",
+          "entry_status": 200
+        }
+      ],
+      "scope": "Official suite.observe: management/HTTP/session denial/SDK identity+storage/WebSocket allow-deny/SQLite schema read+patch+CSV and owner/foreign refusals. Remaining modes/faults not claimed."
+    },
+    "historical_pod_stop": {
+      "source_commit": "464bf9027ad3398029c46495e696538c33e11313",
+      "local_related_passed": 479,
+      "remote_status": "pending; original stop deadline elapsed; no extension",
+      "l1": "/private/tmp/f059-t239-l1-review.md"
+    },
+    "r10_stop_expired_pending": {
+      "operation_id": "9f57c38992f2428f8fbc4bd3fa037ca4",
+      "deadline_utc": "2026-10-22T19:27:30Z",
+      "status_observed": "running",
+      "cleanup_status": "pending",
+      "app_api_state": "stopped",
+      "physical_serve_uid": "a27db53e-6f47-4ff3-b455-35ec038ca578",
+      "physical_writer_stopped": false,
+      "cause": "Closed historical same-name maintenance Pod resource UID was re-read; expired stop cleanup then incorrectly used its own empty resources as proof for all app writers; backend correctly refused terminal stopped fact",
+      "fix_tasks": [
+        "T238",
+        "T239",
+        "T240",
+        "T241"
+      ],
+      "original_resumed_journal_sha256": "1cbdb6186bbf211ce9eded309620fae2b57181e571e69a0d37011cbb5370e32a",
+      "original_resumed_exit": "AssertionError from stop response; no retry",
+      "deadline_extended": false,
+      "raw_local_evidence": "/private/tmp/f059-r10-stop-expired-before.json"
+    },
+    "expired_stop_candidate_d489": {
+      "source_commit": "d48937c1081866e4022c135a7033c6d6a35872b7",
+      "runtime_passed": 1570,
+      "runtime_skipped": 13,
+      "seconds": 33.18,
+      "runtime_log": "/private/tmp/f059-expired-stop-full-runtime.log",
+      "native_manager_build_exit": 0,
+      "native_manager_import_exit": 0,
+      "native_manager_image_id": "sha256:ae8731f462ecbf79682a42c84867ee62b73962cca4ec8fd2a76553893f6f7d3f",
+      "pushed": false,
+      "deployed": false,
+      "l1_independent": "NEEDS_FIX: transient non-expiry 503 prematurely terminal failed and not retryable",
+      "proof": "/private/tmp/f059-t241-transient-503-stuck.json"
+    },
+    "expired_stop_final_l1": {
+      "fix_commits": [
+        "de653a8d4894ffe2b4eee2e8fa7b7d5f6da74162",
+        "0bd6aa173f4d4d60bb6748a3678daa628667d4d3"
+      ],
+      "related_passed": 511,
+      "independent_operations_passed": 96,
+      "independent_result": "PASS",
+      "report": "/private/tmp/f059-t240-t241-independent-l1.md"
+    },
+    "r10_stop_cleanup_complete": {
+      "schema": "f059-r10-stop-cleanup/v1",
+      "operator_supplied": true,
+      "parent_journal_sha256": "1cbdb6186bbf211ce9eded309620fae2b57181e571e69a0d37011cbb5370e32a",
+      "app_id": "a4a566cacbfb477daaf2bd90ea3eb21a",
+      "version_id": "ba5d47d9369540df9d5219adc72a55bf",
+      "stop_operation_id": "9f57c38992f2428f8fbc4bd3fa037ca4",
+      "status": "timed_out",
+      "reason": "operation_timeout",
+      "cleanup_status": "complete",
+      "namespace": "e2e-f059-apps",
+      "serve_pod_uid": "a27db53e-6f47-4ff3-b455-35ec038ca578",
+      "serve_pod_absent": true,
+      "serve_stop_state": "confirmed",
+      "serve_evidence_kind": "kubelet_terminal",
+      "pvc_uid": "4c5b459c-25c6-45ce-9000-370131477d27",
+      "pvc_present": true,
+      "pvc_phase": "Bound",
+      "active_operation_id": "9f57c38992f2428f8fbc4bd3fa037ca4",
+      "generation": 3,
+      "owner_epoch": 1,
+      "observed_at": "2026-10-22T19:47:44.788403+00:00"
+    },
+    "offline_data_cpu_measurement": {
+      "helper_image": "192.168.106.189:5443/f059/runtime-helper@sha256:8a8f92c41e85fdb12e236ba60e37eccc5da2d3462c7ac75a99d5f35d6a83a049",
+      "host": "192.168.106.189",
+      "cpu25m": {
+        "wall_seconds": 31.69858164805919,
+        "cpu_seconds": 0.792588255
+      },
+      "cpu100m": {
+        "wall_seconds": 6.996499706991017,
+        "cpu_seconds": 0.697129728
+      },
+      "boundary": "isolated same-image import measurement, not new K8s Pod acceptance"
+    },
+    "current_native_deployment": {
+      "backend": {
+        "files": {
+          "bisheng/app_runtime/domain/services/runtime_data_session_service.py": "ca6eb28964b717f0744ae2db4d5ab11ec2541563b40e49035be50e49fb471626",
+          "bisheng/app_runtime/domain/repositories/runtime_delivery_repository.py": "e74775a5837014f0c608a1efd306b19d021d2a11de54d2e95a7b944c9df6b0ef"
+        },
+        "backend_and_worker_restarted": true,
+        "source_revision": "6f254f337dd6e8a36733501a2266bf08a04a56e9",
+        "openfga_unchanged": true,
+        "public_env_http_status": 200
+      },
+      "helm_revision": 22,
+      "image": {
+        "manager_image_id": "sha256:604747e7fb7d833859305bd093bc5dc42c5cfb9feb84a1b1dee1c53af30fa634",
+        "source_revision": "6f254f337dd6e8a36733501a2266bf08a04a56e9",
+        "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:59692b82ae7bd1d806e741a4c86f165bd472301feec31b06d3dc66306d00eb3b",
+        "public_push": false,
+        "private_tag": "192.168.106.189:5443/f059/runtime-manager:20261023-24"
+      },
+      "deployment": {
+        "descriptor_uid_unchanged": true,
+        "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:59692b82ae7bd1d806e741a4c86f165bd472301feec31b06d3dc66306d00eb3b",
+        "helm_upgrade_completed": true,
+        "source_revision": "6f254f337dd6e8a36733501a2266bf08a04a56e9",
+        "descriptor_data_unchanged": true,
+        "helper_and_profile_images_unchanged": true,
+        "app_proxy_unchanged": true
+      },
+      "native": {
+        "build_exit": 0,
+        "import_smoke_exit": 0,
+        "public_push": false,
+        "manager_image_id": "sha256:604747e7fb7d833859305bd093bc5dc42c5cfb9feb84a1b1dee1c53af30fa634",
+        "platform": "linux/amd64",
+        "source_revision": "6f254f337dd6e8a36733501a2266bf08a04a56e9",
+        "preceding_full_bundle_validation_source": "709821ecbe5c653aad3f90358173e0d110bee7e3",
+        "changed_runtime_files": [
+          "runtime_manager/k8s_templates.py"
+        ],
+        "boundary": "Native manager build/import only; preceding bundle/SQLite smoke retained separately. No arm/CI public release claim.",
+        "preceding_manager_source": "d7c0cc4fd4cd17c76b742902039846a789c7510b",
+        "schema": "f059-native-manager/v1"
+      }
+    },
+    "offline_data_cpu100_success": {
+      "ledger": {
+        "resource_ref": "52882475-2fcb-5869-923a-604b275ca9fd",
+        "pod_uid": "776ae57a-b32a-4bd2-9048-4efdf0d47323",
+        "stop_state": "confirmed",
+        "cleanup_state": "complete",
+        "stop_evidence_kind": "kubelet_terminal",
+        "operation_id": "241db50ca0ab4b949440217abf7e5356",
+        "status": "completed",
+        "cleanup_status": "complete",
+        "deadline": "2026-10-22T20:07:59",
+        "tier": {
+          "memory_mb": 128,
+          "cpu_millicores": 100
+        },
+        "data_action_finished": "done"
+      },
+      "timeline": {
+        "operation_id": "241db50ca0ab4b949440217abf7e5356",
+        "create_time": "2026-10-22T20:06:59",
+        "deadline": "2026-10-22T20:07:59",
+        "status": "completed",
+        "cleanup_status": "complete",
+        "data_action_finished": "done",
+        "charged_memory_mb": 128,
+        "charged_cpu_millicores": 100,
+        "reservation_state": "released",
+        "facts": [
+          {
+            "phase": "starting",
+            "at": "2026-10-22T20:06:59"
+          },
+          {
+            "phase": "ready",
+            "at": "2026-10-22T20:07:12"
+          },
+          {
+            "phase": "completed",
+            "at": "2026-10-22T20:07:16"
+          }
+        ],
+        "total_seconds": 17.0,
+        "ready_seconds": 13.0
+      },
+      "pod_uid": "776ae57a-b32a-4bd2-9048-4efdf0d47323",
+      "cpu_requests_and_limits": "100m per init/helper; sequential peak100m",
+      "original_deadline_seconds": 60
+    },
+    "static_bundle_system_path": {
+      "source_commit": "995c80f2a1d7288a2e5b9e7dc650f7ce1d911798",
+      "red_failed": 9,
+      "template_passed": 106,
+      "independent_adjacent_passed": 292,
+      "independent_review_sha256": "cfa95d41f0715e5de23806666808bfe0b6e2543fea259e4b726eb91323e07300",
+      "native_static_image_digest": "sha256:2f0311da8b281f35437fcf26b47c8249f8d86f98227a28bb231cd472071ee218",
+      "native_red_exit": 127,
+      "native_green_exit": 0,
+      "native_nginx_path": "/usr/sbin/nginx",
+      "k8s_business_retest": "pending"
+    },
+    "oci_final_l1": {
+      "source_commit": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+      "tasks": [
+        "T248",
+        "T249",
+        "T250",
+        "T251"
+      ],
+      "result": "PASS",
+      "independent_report": "/private/tmp/f059-oci-t248-t251-independent-l1.md",
+      "independent_sha256": "c7891b93c7eda501c3bcb761eb889008d16c5340892fb75ad4d3e9812f5ecd94",
+      "native_189_revalidation": "pending",
+      "public_push": false,
+      "arm": "paused_by_user"
+    },
+    "oci_empty_cmd_l1": {
+      "result": "WITHDRAWN_FALSE_POSITIVE",
+      "prior_acceptance_superseded": true,
+      "official_source": "https://github.com/google/go-containerregistry/blob/v0.22.1/cmd/crane/cmd/mutate.go#L89-L98",
+      "reason": "Setting nonempty entrypoint in pinned crane already resets Cmd; original code always does this",
+      "counterexample": "/private/tmp/f059-crane-entrypoint-clears-cmd-native.log",
+      "three_bases_passed_without_cmd_flag": true,
+      "registry_mutated": false,
+      "implementation_restored_to": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+      "independent_correction": "/private/tmp/f059-t252-t253-correction-withdrawn.md",
+      "independent_sha256": "f49617e5c3037067f5067b8d76b21a3c703fb5a6e771d7da2bb25811022e356e"
+    },
+    "native_oci_intermediate_0d5": {
+      "source_commit": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+      "native": {
+        "schema": "f059-native-three-services-and-public-ca/v1",
+        "source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+        "platform": "linux/amd64",
+        "images": {
+          "manager": {
+            "tag": "e2e-f059/bisheng-runtime-manager:e2e-f059-native-20261023-0d5ef7b2b-amd64",
+            "image_id": "sha256:c3a5255e893973935e26c7f48adcc218f0bb14bd4b522fd8eaea75a35aabc659",
+            "source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+            "variant": null
+          },
+          "helper": {
+            "tag": "e2e-f059/bisheng-runtime-helper:e2e-f059-native-20261023-0d5ef7b2b-amd64",
+            "image_id": "sha256:9cd5d3e1331f4b8c81c6335c4e3034e1eb9c63b94f1968e9dc4357180c2296a9",
+            "source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+            "variant": null
+          },
+          "assembler": {
+            "tag": "e2e-f059/bisheng-runtime-assembler:e2e-f059-native-20261023-0d5ef7b2b-amd64",
+            "image_id": "sha256:ccd89b540e02611fe1b7b24e7b7b991197b8f29d3462e1bb47962c2aca58f2d9",
+            "source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+            "variant": null
+          },
+          "assembler-public-ca": {
+            "tag": "e2e-f059/bisheng-runtime-assembler:e2e-f059-native-20261023-0d5ef7b2b-public-ca-amd64",
+            "image_id": "sha256:ddc11511399ae6f3f9689a076a8cc6bb5c6b8db34bb194d892603fe20264f19e",
+            "source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+            "variant": "registry-public-ca"
+          }
+        },
+        "native_smoke": {
+          "manager": {
+            "application_executed": false,
+            "dangerous_bits_negative": 0,
+            "isolated": true,
+            "oci_source_sha256": "976a216bc882d8331866fba6cd4dc1cd9ec1f06f89d1074267453893b26d18a2",
+            "path_profiles_checked": 9,
+            "platform": "linux/amd64",
+            "registry_accessed": false,
+            "role": "manager",
+            "sgid_directory_positive": 0,
+            "uid": 10001
+          },
+          "helper": {
+            "application_executed": false,
+            "dangerous_bits_negative": 10,
+            "isolated": true,
+            "oci_source_sha256": "976a216bc882d8331866fba6cd4dc1cd9ec1f06f89d1074267453893b26d18a2",
+            "path_profiles_checked": 9,
+            "platform": "linux/amd64",
+            "registry_accessed": false,
+            "role": "helper",
+            "sgid_directory_positive": 2,
+            "uid": 10001
+          },
+          "assembler": {
+            "application_executed": false,
+            "dangerous_bits_negative": 10,
+            "isolated": true,
+            "oci_source_sha256": "976a216bc882d8331866fba6cd4dc1cd9ec1f06f89d1074267453893b26d18a2",
+            "path_profiles_checked": 9,
+            "platform": "linux/amd64",
+            "registry_accessed": false,
+            "role": "assembler",
+            "sgid_directory_positive": 2,
+            "uid": 10001
+          },
+          "assembler-public-ca": {
+            "application_executed": false,
+            "dangerous_bits_negative": 10,
+            "isolated": true,
+            "oci_source_sha256": "976a216bc882d8331866fba6cd4dc1cd9ec1f06f89d1074267453893b26d18a2",
+            "path_profiles_checked": 9,
+            "platform": "linux/amd64",
+            "registry_accessed": false,
+            "role": "assembler",
+            "sgid_directory_positive": 2,
+            "uid": 10001
+          }
+        },
+        "profile_inputs": {
+          "node": "192.168.106.189:5443/f059/app-node-ca@sha256:533c8eb5040441e9420969ff0b305b45572ea58b80d345c27893dd9edc4a2763",
+          "python_preparer": "192.168.106.189:5443/f059/app-python-preparer-ca@sha256:7d4d46ca19fa0a5ae71f177f81a45df58dc3d3a676d4c536615a9376673d55e9"
+        },
+        "public_certificate_sha256": "9c3baa7fd2fb34a61b48d8d6537deb45036b8653ed81b86e049e5dd29c1ab895",
+        "tools_image_id": "sha256:e0774ebe198a660f05b03dbafdbe7c806e1dbee8ac7dbff38f8a1b9865a0442a",
+        "unrelated_containers_unchanged": true,
+        "network_inventory_unchanged": true,
+        "public_push": false,
+        "registry_push_smoke": false,
+        "official_nine_image_inventory_created": false,
+        "profile_images_rebuilt": false,
+        "boundary": "Native service images plus private CA derivative only. SGID layer and negative privilege checks ran in actual native nonroot isolated image containers; no registry manifest mutation/push smoke, ARM, full bundle regression, installer readiness or business E2E claimed."
+      },
+      "private_images": {
+        "public_push": false,
+        "profile_images_unchanged": true,
+        "assembler_public_ca_sha256": "9c3baa7fd2fb34a61b48d8d6537deb45036b8653ed81b86e049e5dd29c1ab895",
+        "assembler_contains_current_helper": true,
+        "source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+        "official_ci_release": false,
+        "images": {
+          "manager": {
+            "image_id": "sha256:c3a5255e893973935e26c7f48adcc218f0bb14bd4b522fd8eaea75a35aabc659",
+            "image": "192.168.106.189:5443/f059/runtime-manager@sha256:28449f9c96c2c60f1b1432a2f671b25434022e9e8675ea53aece36a823deee99",
+            "native_smoke": true,
+            "source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+            "private_tag": "192.168.106.189:5443/f059/runtime-manager:20261023-26"
+          },
+          "assembler-public-ca": {
+            "image_id": "sha256:ddc11511399ae6f3f9689a076a8cc6bb5c6b8db34bb194d892603fe20264f19e",
+            "image": "192.168.106.189:5443/f059/runtime-assembler-ca@sha256:9836c13405df4e4b613712a2e933a73e755d22daeb79d51d6adefc242971c6b9",
+            "native_smoke": true,
+            "source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+            "private_tag": "192.168.106.189:5443/f059/runtime-assembler-ca:20261023-26"
+          },
+          "helper": {
+            "image_id": "sha256:9cd5d3e1331f4b8c81c6335c4e3034e1eb9c63b94f1968e9dc4357180c2296a9",
+            "image": "192.168.106.189:5443/f059/runtime-helper@sha256:5906c51814e24a63356dc69fb5c1dd332bb4eb91b70cf65d41594aefc0841c85",
+            "native_smoke": true,
+            "source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+            "private_tag": "192.168.106.189:5443/f059/runtime-helper:20261023-26"
+          }
+        }
+      },
+      "helm_revision": 24,
+      "helm": {
+        "descriptor_uid_unchanged": true,
+        "source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+        "app_proxy_and_profiles_unchanged": true,
+        "real_report_refresh": "pending",
+        "helm_upgrade_completed": true,
+        "old_report_must_not_admit": true,
+        "helper_image": "192.168.106.189:5443/f059/runtime-helper@sha256:5906c51814e24a63356dc69fb5c1dd332bb4eb91b70cf65d41594aefc0841c85",
+        "descriptor_changes": [
+          "helper_image",
+          "assembler_image"
+        ],
+        "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:28449f9c96c2c60f1b1432a2f671b25434022e9e8675ea53aece36a823deee99",
+        "assembler_image": "192.168.106.189:5443/f059/runtime-assembler-ca@sha256:9836c13405df4e4b613712a2e933a73e755d22daeb79d51d6adefc242971c6b9"
+      },
+      "old_report_negative": {
+        "source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+        "descriptor_digest": "f306a49e2e396c60c91e76672c72dfc9989763fd5d7dcb091257009f70b8f456",
+        "old_report_digest": "d7e831157cd56ff2a941118b9deafb2c2f62b572c633428efb4412839a337afd",
+        "ready": false,
+        "paths": {
+          "bundle": false,
+          "image": false,
+          "source-image": false
+        },
+        "warnings": [
+          "node_local_storage",
+          "live_probe_report_missing_stale_or_changed"
+        ],
+        "backend_node_status": "blocked",
+        "controller_epoch": 19,
+        "old_report_rejected": true
+      },
+      "install_16_passed": true,
+      "report_sha256": "6720eaf4938302d5ad5c7e01b8b1972627dbf5f7c4b1e92d5db83ec838e92ec6",
+      "status": "passed_intermediate_installation",
+      "final_cmd_source_commit": "d7f7c2e10ab9805d41120d19e32d102310b06654",
+      "final_cmd_deployed": false
+    },
+    "corrected_build_baseline": {
+      "source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+      "scope": [
+        "src",
+        "scripts",
+        "deploy",
+        ".github/workflows"
+      ],
+      "git_diff_empty": true,
+      "native_and_installer_evidence": "native_oci_intermediate_0d5",
+      "restore_deployment": "passed",
+      "public_release": false,
+      "helm_revision": 26,
+      "node": {
+        "implementation_source": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+        "corrective_commit": "20175cad9cb453734d42ee8a03c0be0594a6f3a0",
+        "node_id": "e2e-f059-node",
+        "status": "ready",
+        "controller_epoch": 21,
+        "paths": {
+          "bundle": true,
+          "image": true,
+          "source-image": true
+        },
+        "descriptor_digest": "f306a49e2e396c60c91e76672c72dfc9989763fd5d7dcb091257009f70b8f456"
+      },
+      "receipt": {
+        "descriptor_uid_unchanged": true,
+        "app_proxy_profile_data_unchanged": true,
+        "new_probe_claimed": false,
+        "matched_existing_report_sha256": "6720eaf4938302d5ad5c7e01b8b1972627dbf5f7c4b1e92d5db83ec838e92ec6",
+        "new_image_build": false,
+        "report_age_seconds": 1548.6708028316498,
+        "all_code_build_inputs_equal": true,
+        "images": {
+          "manager": {
+            "image_id": "sha256:c3a5255e893973935e26c7f48adcc218f0bb14bd4b522fd8eaea75a35aabc659",
+            "image": "192.168.106.189:5443/f059/runtime-manager@sha256:28449f9c96c2c60f1b1432a2f671b25434022e9e8675ea53aece36a823deee99",
+            "native_smoke": true,
+            "source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+            "private_tag": "192.168.106.189:5443/f059/runtime-manager:20261023-26"
+          },
+          "assembler-public-ca": {
+            "image_id": "sha256:ddc11511399ae6f3f9689a076a8cc6bb5c6b8db34bb194d892603fe20264f19e",
+            "image": "192.168.106.189:5443/f059/runtime-assembler-ca@sha256:9836c13405df4e4b613712a2e933a73e755d22daeb79d51d6adefc242971c6b9",
+            "native_smoke": true,
+            "source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+            "private_tag": "192.168.106.189:5443/f059/runtime-assembler-ca:20261023-26"
+          },
+          "helper": {
+            "image_id": "sha256:9cd5d3e1331f4b8c81c6335c4e3034e1eb9c63b94f1968e9dc4357180c2296a9",
+            "image": "192.168.106.189:5443/f059/runtime-helper@sha256:5906c51814e24a63356dc69fb5c1dd332bb4eb91b70cf65d41594aefc0841c85",
+            "native_smoke": true,
+            "source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+            "private_tag": "192.168.106.189:5443/f059/runtime-helper:20261023-26"
+          }
+        },
+        "withdrawn_d7_report_sha256": "35c968ac1a297e65ff59257c05aef86bf3eaddf555f347f9eb992bacd61ae9bf",
+        "helm_upgrade_completed": true,
+        "reverted_equivalent_redundant_flag": true,
+        "withdrawn_d7_probe_passed": 16,
+        "corrective_commit": "20175cad9cb453734d42ee8a03c0be0594a6f3a0",
+        "installed_source_revision": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+        "descriptor_digest": "f306a49e2e396c60c91e76672c72dfc9989763fd5d7dcb091257009f70b8f456"
+      }
+    }
+  },
+  "not_executed": [
+    "F059 complete platform and fault acceptance on 189 and 109",
+    "DM8 driver-specific migration/DDL (static-only by user instruction)",
+    "formal multi-architecture public CI image release",
+    "full distribution air-gapped installation and cross-node recovery"
+  ],
+  "blocking_integration_prerequisite": null,
+  "release_parent": "9d156f261cc7769fcf75f41caeb104a425308017",
+  "historical_validated_source_commit": "4bd3351be66aa5b92f291f9425d1a6cbfc40a881",
+  "public_release": "not executed or authorized for current F059; no new public push/tag/DockerHub release",
+  "prior_runtime_validations": [
+    {
+      "source_commit": "995c80f2a1d7288a2e5b9e7dc650f7ce1d911798",
+      "passed": 1595,
+      "skipped": 13,
+      "seconds": 33.42,
+      "exit_code": 0,
+      "log": "/private/tmp/f059-static-path-full-runtime.log"
+    },
+    {
+      "source_commit": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+      "passed": 1610,
+      "skipped": 13,
+      "seconds": 33.01,
+      "exit_code": 0,
+      "log": "/private/tmp/f059-oci-final-full-runtime.log"
+    }
+  ],
+  "historical_runtime_validations": [
+    {
+      "source_commit": "d7f7c2e10ab9805d41120d19e32d102310b06654",
+      "passed": 1622,
+      "skipped": 13,
+      "seconds": 33.09,
+      "exit_code": 0,
+      "log": "/private/tmp/f059-oci-cmd-final-runtime.log",
+      "status": "passed_but_redundant_cmd_tests_withdrawn",
+      "not_current_delivery": true
+    }
+  ],
+  "installer_registry_upstream_dedc": {
+    "installer_commit": "dedc4831bb462f832877940901201061b1e027cf",
+    "report_path": "/opt/e2e-f059/evidence/install-dedc4831b-r2-report.json",
+    "report_sha256": "304b970cdba945fb4c60dffeb10eab3dbdd7ceffeb0b984dbfb39c52581070ce",
+    "wrapper_exit_code": 0,
+    "independent_review_result": "PASS_FOR_FIVE_SERVICE_MATCHING_CONTROL_CANARIES",
+    "independent_review_sha256": "140eafa532248a53fe1dfef42f198a0bae3faaa47b49de12255d0ab53e508d62",
+    "watch_scope": "five Service-matching control canaries only; preflight-untrusted role not observed",
+    "watch_exit_codes": [
+      1,
+      1
+    ],
+    "watch_exit_reason": "operator SIGINT after installer exit and exact process/Service/no-control-canary guards; not a test failure or exit130 claim",
+    "scope_limits": [
+      "TCP upstream check is distinct from external crane TLS/permissions and actual source-image assembly",
+      "No proof of unobserved sixth untrusted canary Ready history",
+      "No packet-level traffic continuity or absolute transport-gap claim",
+      "No new ARM/public release acceptance"
+    ]
+  },
+  "r10_complete_five_delivery_business": {
+    "success_parent_sha256": "c6e17830766aa738621e7f681f2e04cc3eb9223e46ce5021266fc894e4aea11b",
+    "suite_exit_code": 0,
+    "five_v1_v2_and_cycles": "passed",
+    "fullimage_manual_publish_operation_id": "68cc365b61d84c6585b7f9db897ea5c0",
+    "fullimage_generation": 4,
+    "fullimage_approval": 11,
+    "negative_dependency_code": 16227,
+    "original_failures_retained": true,
+    "T069_still_pending_offline_and_delete": true,
+    "T070_still_pending": true
+  },
+  "manager_only_upgrade_2ac": {
+    "source_revision": "2ac60f808be170e73f0094ba5c8d041f8a9e10cd",
+    "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:5619057b1c026d9ff545f4090f6c84c9aaef5fc1050604162770c7f4d018830c",
+    "helm_revision": 28,
+    "controller_epoch": 22,
+    "node_status": "ready",
+    "required_runtime_checks": 15,
+    "raw_installation_checks": 16,
+    "paths": {
+      "image": true,
+      "bundle": true,
+      "source-image": true
+    },
+    "workload_identities_preserved": true,
+    "upgrade_receipt_sha256": "539386dafef972122991013f7b03c0e920be301df794dea3df09ea5136763e5b",
+    "actual_owner_cancel": "not_executed",
+    "public_release": false
+  },
+  "protection_report_correction": {
+    "schema": "f059-protection-regression-delta/v1",
+    "baseline_production_sha": "1b018c15b71d8caeca98e51e945839938641525e",
+    "candidate_production_sha": "f8863f3763285435a5df01fc150400838bdb3f0a",
+    "same_new_backend_test_count": 51,
+    "mcp_installed_shared": "1.27.1",
+    "mcp_private_lock_override": "1.30.0",
+    "baseline": {
+      "passed": 2748,
+      "failed": 150,
+      "skipped": 19,
+      "errors": 18,
+      "seconds": 90.14
+    },
+    "candidate": {
+      "passed": 2784,
+      "failed": 114,
+      "skipped": 19,
+      "errors": 18,
+      "seconds": 91.43
+    },
+    "introduced_failure_or_error_count": 0,
+    "resolved_pair_count": 36,
+    "unchanged_open_api_failures": 114,
+    "unchanged_open_api_errors": 18,
+    "direct_three_modules": {
+      "passed": 1944,
+      "skipped": 19,
+      "seconds": 81.11
+    },
+    "runtime_all": {
+      "passed": 1757,
+      "skipped": 13,
+      "seconds": 35.27
+    },
+    "sha256": {
+      "f059-protection-all-four-baseline.log": "db54a5d3331b84740ec2fa7cc6dc361fcb4193b09b0915b8f844d84800db1726",
+      "f059-protection-corrected-backend.log": "b236e931f66fa671b848e7ac8a64da4f0e99fa958ceba918724f964fc3d42d61",
+      "f059-protection-final-three-modules.log": "16758d529d2d2ef2f1b9790c827586bcaee98d15a24d280ec509bcb3db07b92a",
+      "f059-protection-final-runtime.log": "fb49ac4ae85e0bb26f23f9e084352f40563fca9a8e251fc99c3204629092f6d7"
+    },
+    "boundary": "Old open_api auth/schema/fixture issues were verified with the same combined selection and locked temporary dependency; they are not fixed or counted passed. No production environment validation is implied.",
+    "independent_l1": {
+      "f059-t260-t261-independent-l1.md": "4062b44ceab088b80c716aab8bf6675d75eb887e6af3138a8d83d3a812ce3c32",
+      "f059-t258-t259-t262-t263-independent-l1.md": "26ef6ad85616d2ef32fcc4166df266eaf02656d8b52b2d1b5b0d7e1a76cee3fb"
+    },
+    "tasks": [
+      "T258",
+      "T259",
+      "T260",
+      "T261",
+      "T262",
+      "T263"
+    ],
+    "old_false_restored": false,
+    "new_189_deployed": true
+  },
+  "protection_report_r29_deployment": {
+    "schema": "f059-r29-real-deployment/v1",
+    "source_revision": "f8863f3763285435a5df01fc150400838bdb3f0a",
+    "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:0b4bd3e66c1d7c49fe00046c83277dab8b6dbd0628f201abf3192d70dd67ddad",
+    "manager_image_config_id": "sha256:1cf328093deb9fb81e0202cd3cc0dec56ce6fba92efed3b45b8e6ee6eae2857a",
+    "manager_pod_uid": "fae606c4-3e51-4efa-ba00-a7ce9163de3f",
+    "old_manager_uid_absent": "faa48ee8-fd38-4980-a290-4b44ada8d6b4",
+    "helm_revision": 29,
+    "controller_epoch": 24,
+    "node_status": "ready",
+    "baseline_digest": "9006c20349b46e6a60af9d7e2ccdc45f4e4efae5606f58cde88b69bd0b9ad3ff",
+    "protection_contract_version": 1,
+    "status_protection": true,
+    "binding_protection": true,
+    "backend_sources": {
+      "src/backend/bisheng/app_runtime/domain/repositories/runtime_kubernetes_repository.py": "2aeb4eccb208e6deba9f34acaf7b0f2e0db57e8e5fae6fa9b3ce638eec6d875c",
+      "src/backend/bisheng/app_runtime/domain/services/runtime_operation_service.py": "866efa5e8feebd838a804b60a3ec7f0d49c9edd2d8fb0e690c7f08c7a8197fe3"
+    },
+    "namespace_uid": "6b72687f-fdaf-4ee9-9a97-cf088a30d88b",
+    "descriptor_configmap_uid": "9bcf2c14-d457-4947-bf9c-adb6e70ffd18",
+    "descriptor_digest": "f306a49e2e396c60c91e76672c72dfc9989763fd5d7dcb091257009f70b8f456",
+    "historical_three_false_two_true_preserved": true,
+    "old_timed_out_stop_cleanup_pending_preserved": true,
+    "r4_pvc_uid": "41426a1e-9713-46d5-afa5-e520afcd186b",
+    "new_business_acceptance": false,
+    "arm_tested": false,
+    "public_issuance": false,
+    "evidence_sha256": {
+      "independent-deployment-readonly.json": "e8accbb523d92a6fa21adbddf5ca8adb7e32f2a547de8e7091c2e7ded3b40cbc",
+      "f059-r29-after-manager-cohort.json": "656f0b2dedd37626d53e92176e5bc82a8af41cb1d8ff0981a2c0a89a58e31585",
+      "native-manager-result-r29.json": "be438498db6b9c817e5d5f1be999089a9cccd0c1b6efcf8be735d555ef44c83b",
+      "private-manager-push-r29.json": "f847472be23826f75157b7b1704051495be26c63bf06753f183bc9db39c54ca7",
+      "backend-receipt.json": "cbbbe0fe5f909a1d0ea4c140b06cf0660e7ccb280ff2da8ee297356d27c4ea04",
+      "manager-after.json": "f78415da83ebbae3b33f2b21deedbe83eeb2f2b28e992e2c611bcd80855d417f"
+    }
+  },
+  "prior_top_level_checks_before_r11": {
+    "runtime": {
+      "source_commit": "0d5ef7b2b8c12ff9a4c1ef48a1b8ba37a373a92c",
+      "passed": 1610,
+      "skipped": 13,
+      "seconds": 33.51,
+      "exit_code": 0,
+      "log": "/private/tmp/f059-oci-correction-full-runtime.log",
+      "current_build_inputs_byte_equal": true
+    },
+    "backend": {
+      "source_commit": "6f254f337dd6e8a36733501a2266bf08a04a56e9",
+      "passed": 1764,
+      "skipped": 19,
+      "seconds": 50.14,
+      "exit_code": 0,
+      "log": "/private/tmp/f059-data-cpu-full-backend.log"
+    },
+    "kubernetes_live": {
+      "host": "192.168.106.189",
+      "version": "v1.28.15",
+      "architecture": "amd64",
+      "single_node": true,
+      "control_namespace": "e2e-f059-system",
+      "execution_namespace": "e2e-f059-apps",
+      "four_control_services_installed": true,
+      "bounded_ext4_volumes_prepared": 51,
+      "mandatory_probe_status": "passed_15_required_plus_registry_at_54b",
+      "platform_business_e2e": "r2_failed_precheck_timeout_no_resources_idle_lease_fix_pending_live",
+      "fault_upgrade_e2e": "installation_report_negative_pairs_passed_business_faults_pending",
+      "not_arm64_or_cross_node_evidence": true
+    }
+  },
+  "prior_top_level_sdd_before_r11": {
+    "spec_confirmed": true,
+    "design_confirmed": true,
+    "tasks_review": "LGTM",
+    "l1_completed": 246,
+    "tasks_total": 251,
+    "e2e": "in_progress",
+    "l2": "not_started"
+  },
+  "r11_single_v1_protection_acceptance": {
+    "schema": "f059-r11-single-v1-real-acceptance/v1",
+    "source_revision": "f8863f3763285435a5df01fc150400838bdb3f0a",
+    "app_id": "43baa81d168a45bfa5e53ffbd04f48fd",
+    "version_id": "a5a4faade83743a487803bd6080246bf",
+    "deployment_id": "0a8dc574e6ae4f0790c0803b77f0a737",
+    "approval_id": 12,
+    "version_count": 1,
+    "operations": {
+      "first_deploy": "0b694758d9274f42a7b4078897bbeaf8",
+      "resume_deploy": "3d386771f5344e4aac321d2fc830bff3",
+      "stop": "3674c3af161946a39e6df19e1d417a7c"
+    },
+    "final_generation": 3,
+    "profile_revision": "e2e-f059-python311@2",
+    "business_result": "passed",
+    "physical_result": "passed",
+    "checks": {
+      "all_metadata_reads_determinate": true,
+      "different_serve_uid": true,
+      "first_uid_no_longer_current_at_name": true,
+      "one_first_and_resumed_serve": true,
+      "original_pvc_uid_current": true,
+      "persisted_same_uid_terminal_stop": true,
+      "resumed_uid_current_and_guarded": true,
+      "same_approved_pvc_claim": true,
+      "serve_generations_match_journal": true
+    },
+    "passed_business": [
+      "single publish",
+      "approval preview and refusal pairs",
+      "HTTP and WebSocket allow/deny",
+      "SDK human OBO identity",
+      "attachment roundtrip and entry refusal pairs",
+      "owner online row edit/export and tenant boundaries",
+      "normal stop completed/cleanupcomplete",
+      "stopped SQLite random-nonce edit/export and permission pairs",
+      "resume same approved v1",
+      "resumed rows exactly equal offline rows"
+    ],
+    "evidence_sha256": {
+      "platform_journal": "7c7f7dbb5331ab54487eb44f3c8af08bb306b70fa593eb3396b0a31ed3d7b6f4",
+      "offline_final_journal": "e3ea2f08d158b3c2bb23935a34e69daebc53abec4b047dade849e162635f6697",
+      "public_scope": "a37189348b4da69d78bbf092cbb9546879178e606681ad9eb6c4f4c37d1ad2e6",
+      "preview_cleanup_proof": "01dfdd2bef68d6d0e0510626d758baede929e2a1f17a78f09a07ed49778b11a1",
+      "physical_observation": "80b98d038e860c6fe0593e4e5831ae8ee329a9f46237abe48ef0fe14c37cf93d"
+    },
+    "private_evidence_root": "/opt/e2e-f059/evidence/k189r11-python-protection",
+    "physical_evidence_root": "/opt/e2e-f059/evidence/r11-resource-proof",
+    "operator_setup_notes": [
+      "r1 private driver not executed; real CookieJar and original900 budget fixed in r2 before live publication",
+      "two check-only wrapper mount-path failures retained before any business publication",
+      "first SELECT seed refused before normal preview cleanup; second read confirmed all four resources/released; no resource force-cleanup"
+    ],
+    "remaining": [
+      "preapproval capacity negative",
+      "safe delete after later tests",
+      "fault and independent uninstall acceptance"
+    ],
+    "full_feature_accepted": false,
+    "arm_tested": false,
+    "new_109_tested": false,
+    "public_issuance": false,
+    "production_pvc_uid": "e72126ff-98c8-40b0-84d9-25b6b75aa0d0",
+    "serve_pods": [
+      {
+        "operation_id": "0b694758d9274f42a7b4078897bbeaf8",
+        "resource_ref": "43e781a6-fbbc-55b8-8e60-3ab58f8844e6",
+        "uid": "c96eedd4-44a6-45c1-888e-b282ce695128",
+        "generation": 1,
+        "protected_since_creation": true,
+        "binding_policy_verified": true,
+        "stop_state": "confirmed",
+        "cleanup_state": "complete"
+      },
+      {
+        "operation_id": "3d386771f5344e4aac321d2fc830bff3",
+        "resource_ref": "5f525a1c-0ba4-5a0d-8784-6c07a4694151",
+        "uid": "999cf346-e483-439c-90bd-621d39152a9c",
+        "generation": 3,
+        "protected_since_creation": true,
+        "binding_policy_verified": true,
+        "stop_state": "held",
+        "cleanup_state": "pending"
+      }
+    ]
+  },
+  "cli_capacity_closeout": {
+    "source_commit": "0ff595428",
+    "state": "local_l1_passed_not_deployed",
+    "tests": {
+      "root_red": {
+        "failed": 3,
+        "passed": 55,
+        "log": "/private/tmp/f059-cli-capacity-root-red.log"
+      },
+      "root_full": {
+        "passed": 391,
+        "seconds": 5.86,
+        "log": "/private/tmp/f059-cli-capacity-root-full.log"
+      }
+    },
+    "wheel_sha256": "739d58c34a4f9eb4833c21ecec60936ef78bf1a48ce0b050b0d86a810b0e3e39",
+    "wheel_source_exact": true,
+    "manifest_only_cli_sha_changed": true,
+    "sdk_unchanged": true,
+    "clean_venv_install_smoke": "passed by original UV_OFFLINE pack_cli_wheel.sh",
+    "independent_l1": "/private/tmp/f059-cli-capacity-l1.md"
+  },
+  "manager_outage_r11_r3": {
+    "state": "passed_real_189_amd64",
+    "source_commit": "f8863f3763285435a5df01fc150400838bdb3f0a",
+    "manager_image": "192.168.106.189:5443/f059/runtime-manager@sha256:0b4bd3e66c1d7c49fe00046c83277dab8b6dbd0628f201abf3192d70dd67ddad",
+    "old_manager_uid": "fae606c4-3e51-4efa-ba00-a7ce9163de3f",
+    "new_manager_uid": "59ace601-5974-487f-8eaf-095a6b13f015",
+    "controller_epoch": 25,
+    "down_seconds": 15.931488990783691,
+    "qualified_rounds_after_cache": 5,
+    "original_total_budget_seconds": 120,
+    "recovery_used": false,
+    "http_owner_new_requests": "passed",
+    "sdk_identity_obo": "passed; not remote model/retrieval/attachment proof",
+    "persistent_websocket": "same connection echo passed",
+    "new_owner_websocket": "passed",
+    "nonowner_foreign_http_ws": "all exact refusals passed",
+    "connect": "same stream/socket/fd/TLS object passed before/during/after",
+    "long_http_response_stream": "not tested",
+    "inventory": {
+      "pods": 6,
+      "pvcs": 7,
+      "same_uid_cid_volume_projection": true
+    },
+    "old_history": "old three false and newer two true retained; old Python held row reattested controller_epoch24->25 without changing flags/stop state; not repaired or released",
+    "independent_post_protection": "current/v1/status=true/binding=true; source/backend bytes/descriptor/namespace verified",
+    "evidence": {
+      "k8s-manager-outage-k189r11-r3-orchestrated-r29.jsonl": {
+        "local": "/private/tmp/f059-r11-outage-r3-live-evidence/k8s-manager-outage-k189r11-r3-orchestrated-r29.jsonl",
+        "remote": "/opt/e2e-f059/evidence/k8s-manager-outage-k189r11-r3-orchestrated-r29.jsonl",
+        "sha256": "b397e367c68e363604f68e8ae7ca257d4f9c410775fa84a994b8fdd39409724c"
+      },
+      "k8s-manager-outage-k189r11-r3-restored.json": {
+        "local": "/private/tmp/f059-r11-outage-r3-live-evidence/k8s-manager-outage-k189r11-r3-restored.json",
+        "remote": "/opt/e2e-f059/evidence/k8s-manager-outage-k189r11-r3-restored.json",
+        "sha256": "850d35de909bd48d4a7358b560e363a8c5943426c750d1dc9bc1c19c726e89ab"
+      },
+      "r11-outage-r3-after-inventory.json": {
+        "local": "/private/tmp/f059-r11-outage-r3-live-evidence/r11-outage-r3-after-inventory.json",
+        "remote": "/opt/e2e-f059/evidence/r11-outage-r3-after-inventory.json",
+        "sha256": "8184a040651be3693328de4bb55a22b2b1cf69bb23dc85157b75820fad23c29a"
+      },
+      "k8s-manager-outage-k189r11-r3-orchestrated-r29.jsonl.control.jsonl": {
+        "local": "/private/tmp/f059-r11-outage-r3-live-evidence/k8s-manager-outage-k189r11-r3-orchestrated-r29.jsonl.control.jsonl",
+        "remote": "/opt/e2e-f059/evidence/k8s-manager-outage-k189r11-r3-orchestrated-r29.jsonl.control.jsonl",
+        "sha256": "226a8464d8d9bc34aff653831d40ba57502e61ee5f54ecfc47456aeab42eb4a6"
+      },
+      "r29-post-outage-r11-r3-deployment-ready.json": {
+        "local": "/private/tmp/f059-r11-outage-r3-live-evidence/r29-post-outage-r11-r3-deployment-ready.json",
+        "remote": "/opt/e2e-f059/evidence/r29-post-outage-r11-r3-deployment-ready.json",
+        "sha256": "3825ca3769010269b14781086b2de1a6f03c3e37fa8a639035c5b70cc2f6251b"
+      },
+      "k8s-manager-outage-k189r11-r3.jsonl": {
+        "local": "/private/tmp/f059-r11-outage-r3-live-evidence/k8s-manager-outage-k189r11-r3.jsonl",
+        "remote": "/opt/e2e-f059/evidence/k8s-manager-outage-k189r11-r3.jsonl",
+        "sha256": "b7810f9e0e7f23dec84b1b95f24f86629af47ee70ccc985800783354819b2773"
+      }
+    },
+    "tools": {
+      "package_sha256": "58619c767f0477cf9c233b72d170343b942e983eba262126d70892b3c7afc445",
+      "post_collector_sha256": "f7007b5447bb4f3d7a8ad692b83ad73e1673e3268c8da4a642b362742c8b303e",
+      "post_inventory_collector_sha256": "6f7e4f30f8b35d05a67451bdd72231455f24333d1c1135961cb77f587c6b9fe4",
+      "independent_local_tests": 67
+    },
+    "prior_r2_failure": {
+      "reason": "r11_preflight_failed: root bare URL308 while canonical slash200",
+      "down_attempted": false,
+      "retained": true,
+      "diagnostic_bare": 308,
+      "diagnostic_slash": 200,
+      "diagnostic_sdk": 200,
+      "diagnostic_ws_echo": true
+    },
+    "boundary": "manager-only idle outage and access continuity; not in-flight operation replay, application crash recovery, cross-node fencing or full T070 acceptance"
+  },
+  "preapproval_capacity_r12_r2": {
+    "state": "passed_real_189_amd64",
+    "source_commit": "f8863f3763285435a5df01fc150400838bdb3f0a",
+    "app_id": "f1b1120c33d047ffa3326010ce33d786",
+    "deployment_id": "1846e6b9b5884c9995ea237c9544ec4a",
+    "candidate_version_id": "5a589c6a622a41cdb4f0bedb1ab7e994",
+    "failure_code": 16125,
+    "failure_stage": "precheck_build",
+    "failure_details": {},
+    "app_state": "draft",
+    "counts": {
+      "approvals": 0,
+      "operations": 0,
+      "reservations": 0,
+      "resources": 0,
+      "versions": 0
+    },
+    "r11_final_identity": "same online app/v1/gen3/resume operation",
+    "capacity_before_after": {
+      "memory_mb": 7936,
+      "cpu_millicores": 3650,
+      "instances": 5
+    },
+    "public_journal": {
+      "remote": "/opt/e2e-f059/evidence/k189r12-preapproval-capacity-r2/journal.json",
+      "local": "/private/tmp/f059-r12-r2-live-journal.json",
+      "sha256": "2a22bc734ebeacd445f1e06c9f82926c32b8eb0f1d2d5600edae02592fcd297b"
+    },
+    "select_only_ledger": {
+      "local": "/private/tmp/f059-r12-r2-ledger-proof.json",
+      "sha256": "9fca1b242e786ab35a3825c54219b3dc98be3e33b869c73f09293b1790f9f12e",
+      "collector_sha256": "62fdd0c4d99ecf9302469771c44b1bafa47dd8b30bc0c3fdaa1f89eb15ee9417"
+    },
+    "tool_sha256": "16e96e8343b3ad6625060a6e22578a6a6f80d603c22875561520127cdcb4b541",
+    "wrapper_sha256": "278524bd53ee8455ff7c4f6af4e97ca7c67b127a22b4ce40d6a2129320b1e240",
+    "first_launch_failure": "script not started due to public source file permission; evidence/intent absent; original log retained; subsequent one business POST only",
+    "not_claimed": "reserved candidate version ID is not an app_version row; no public CLI against this deployment yet"
+  },
+  "controlled_stops_for_migration": {
+    "state": "business_completed_physical_proof_pending",
+    "purpose": "free two temporary acceptance slots using only healthy dedicated apps",
+    "r11": {
+      "app_id": "43baa81d168a45bfa5e53ffbd04f48fd",
+      "version_id": "a5a4faade83743a487803bd6080246bf",
+      "generation": 4,
+      "stop_operation_id": "8d71c051adf14478806cb7feb19e1dca",
+      "owner_delete_online": 16104,
+      "nonowner_delete": 16105,
+      "foreign_delete": 16101,
+      "denial_sql_seed_sha256": "f3a8b7c02c7fa2958128ad02b0cfbf5a45216dbc2e30915279311c6368a67a74",
+      "denials_root_proof_sha256": "c723fc4260246d0e778210d4a89a183560910811e4a1e794b0cc2dcfc92d9c68",
+      "journal_remote": "/opt/e2e-f059/evidence/r11-safe-delete-r1-stages.jsonl",
+      "journal_sha256_at_stop_complete": "ce139f1d3f095940dbf9bf238ad0fb7d4304a82719e4ccde4ab86c2202268401",
+      "baseline_sha256": "29380b67b735bcf49c8a5a963fa20af645609da2d801ec55cca5a7c6b49fdc60",
+      "storage_manifest_sha256": "1f96333e47c8454059f65d190d30780689f6cf3a561f1408c8064be4e5097e4c",
+      "snapshot_samples": 0,
+      "attachment_object_samples": 0,
+      "pv_pre_stop": "e2e-f059-production-06",
+      "pv_uid_pre_stop": "3959377d-57ff-4f53-8536-7578e3af13fe"
+    },
+    "fullimage": {
+      "app_id": "ec6229a98d434d7cb58f740b58945ff6",
+      "version_id": "77d42f56f77f4287b63574b9fd81b0ab",
+      "generation": 5,
+      "stop_operation_id": "dc0317f0220246b7b0adbc83cfd292ba",
+      "pre_stop_serve_uid": "fcbc7bdd-059e-4ef3-a5bd-cefe4931c38d",
+      "pre_stop_protection": [
+        true,
+        true
+      ],
+      "journal_remote": "/opt/e2e-f059/evidence/fullimage-single-stop-r1.jsonl",
+      "journal_sha256_at_stop_complete": "ab985cc7097bb960946cffbe32d1a3c503b82713243b318ad90ebc764e978d84"
+    },
+    "boundary": "one normal stop per app; no delete/data/restore; original parents retained; old false/unknown apps untouched; no capacity release claimed without independent exact-UID physical and reservation proof"
+  },
+  "m2_material_preparation": {
+    "state": "prepared_not_published",
+    "slug": "e2e-f059-migrate-k189m2",
+    "manifest_sha256": "b7660cd97e537d49cf40018c22ad1934648aae7e4662f2c2c72cf4f16b9232eb",
+    "freeze_sha256": "d01c708557c96bd9ab90e035e3e2fcfa3cfe0088b0383aafaed19769ef228af5",
+    "v1": {
+      "sha256": "09807c39b6ada07f11bc1ba4867edcc2c2a0cb96b6a39452145415ca932f6fb4",
+      "bytes": 1235
+    },
+    "v2": {
+      "sha256": "49d450226b8e5bc9766ad0c4366882e347cd69609a8839209c0fa8c49311ed8d",
+      "bytes": 1237
+    },
+    "tools_tar_sha256": "ed9411c81aa3bf4014c3995c66529fbf1bc854384707b1d90445a10121b37663",
+    "readers": "new public failure operation reverse linkage and exact v1/v2 preview cleanup gates reviewed; no DB backfill",
+    "ddl_observer": "first candidate e77 rejected before execution: actual kubectl1.28.15 has no resource-version flag; replacement pending; no migration attempted"
+  },
+  "development_integration_2026_10_23": {
+    "target_branch": "feat/3.0.0-vibe",
+    "target_base": "4aa9ce69b2830b81162fe631b9c801057c40ee8b",
+    "implementation_source": "cda8dccf26298af91a1df277dcd1a49787b6b444",
+    "f086_source": "667546bb77eeb366e66a13d544d122ef24811e35",
+    "authorization": "user requests development closeout, merge and push; no full-suite rerun",
+    "scope": "development integration only; historical checks retain their original source/date",
+    "code_review": "see code-review.md",
+    "full_delivery_accepted": false,
+    "public_image_release_executed": false,
+    "arm_validation": "deferred by user",
+    "dm8_validation": "static only"
+  },
+  "historical_sdd_before_development_integration": {
+    "spec_confirmed": true,
+    "design_confirmed": true,
+    "tasks_review": "LGTM",
+    "l1_completed": 258,
+    "tasks_total": 263,
+    "e2e": "in_progress",
+    "l2": "not_started"
+  }
+}
diff --git a/features/v3.0.0/076-mineru-v4/design.md b/features/v3.0.0/076-mineru-v4/design.md
new file mode 100644
index 0000000000..c2b301535d
--- /dev/null
+++ b/features/v3.0.0/076-mineru-v4/design.md
@@ -0,0 +1,194 @@
+# Design: MinerU 4.0 独立解析提供方
+
+> 状态:设计已由用户确认,代码已在 `feat/mineru_4.0` 工作树实施;离线验证通过,目标 MinerU 服务与页面验收待执行。本文不代表功能已上线。
+>
+> [spec.md](./spec.md) 定义 What;本文记录 Why/How;后续 [tasks.md](./tasks.md) 记录执行。实现中的细节变更覆盖更新本文;若推翻已确认的规格或设计决策,按 `docs/SDD-Guide.md` §3 重新确认。
+
+**版本**:v3.0.0 · F076-mineru-v4
+**最后更新**:2026-10-13
+**基线**:`feat/mineru_4.0` 从 `origin/feat/3.0.0` 的 `eb86a1017267753de76c5c661bb45d0e3178874a` 建立;当前实现见 §4,验收边界见 §7。
+
+## 1. 目标与非目标
+
+新增可显式启用的 MinerU 4.0 解析提供方,把其 V1 异步任务和 ZIP 产物适配到现有知识文件 Load → Transform → Ingest 管线,同时保留旧 MinerU 协议和历史文件。覆盖 PDF、图片及先转 PDF 的 OFD,并让预览和聊天引用可消费新结果。
+
+不在本期部署 MinerU 模型、不迁移历史文件、不扩展 Office/HTML/表格的解析路由、不引入按租户/知识库选择或远端任务恢复。
+
+## 2. 关键约束
+
+- 遵循 [架构宪章](../../../docs/constitution.md) C1–C8 和 [版本契约](../release-contract.md)。本特性不新增数据库表;解析字节在 API、Celery、MinIO 之间传递时,不能依赖跨节点共享本地目录(C8)。
+- [MinerU 4.0 V1 API](https://mineru.atomgit.com/zh/usage/http_api.html) 是上传、提交、轮询、下载的异步流程,不提供旧 `/file_parse`;自部署资源索引保存在服务进程内,服务重启后旧任务 ID 不保证可恢复。
+- 当前 V1 ZIP 结果包含 Middle JSON 与图片素材;Middle JSON 用 `schema=docvortex.middle`、`schema_version=2.0` 标识,页面是 `pages[].blocks[]`,源页号从 0 开始。具体 ZIP 成员和块形态须以目标版本的真实样本为准。[输出协议](https://mineru.atomgit.com/zh/reference/output_files.html)
+- V1 解析任务提供 `ocr_mode`,未提供与毕昇现有 `enable_formula` 完全等价的单次开关;公式效果由实际 tier 和服务能力决定。不能声称旧选项在新提供方上仍有相同语义。[任务参数](https://mineru.atomgit.com/next/api/parse-jobs.html)
+- 目标服务、硬件、可用 tier、鉴权方式尚未提供。实施使用可重复的协议夹具;真实服务验收前不得宣称某 tier、图片或 bbox 已在目标环境验证。服务能力以 `/v1/health`、`/v1/tiers` 为准。
+- 预览缓存与全局配置切换的现有语义已由用户确认:缓存正文可以跨提供方复用;无缓存时,以任务执行时读到的全局提供方为准。允许配置切换窗口和缓存命中带来的小概率来源差异,不新增入队时的提供方快照或预览任务持久恢复。
+
+## 3. 方案对比与选定
+
+### 决策 1:独立提供方隔离 3.x 与 4.0
+
+- **备选**:A. 在旧 `MineruLoader` 加版本分支;B. 新增 `mineru_v4` 配置和 `MineruV4Loader`;C. 运行时探测协议并自动切换。
+- **选定**:B。旧 `mineru` 的配置、请求和结果转换保持原样,新增 `ParseType.MINERU_V4` 与一条显式选择分支。
+- **原因**:旧配置的 `url` 指向完整旧接口,4.0 `api_url` 是 V1 服务根地址;请求周期和 JSON 结构均不同。隔离后能够单独验证与回滚,失败不会掩盖在自动降级中。[迁移指南](https://mineru.atomgit.com/zh/reference/migration_4.html)
+- **何时重新考虑**:若后续版本共享官方承诺的稳定解析接口,再提取共同客户端接口。
+
+### 决策 2:调用外部 V1 服务,使用已有 `httpx`
+
+- **备选**:A. 在毕昇 Worker 安装 MinerU 推理/SDK 包;B. 以 `httpx` 实现薄 V1 客户端,模型运行由独立服务负责。
+- **选定**:B。客户端负责协议校验与有限等待,不在毕昇镜像运行模型。
+- **原因**:现有接入是服务间调用;直接依赖 MinerU 包会把模型和硬件运行时带入 API/Celery 镜像。[官方 SDK](https://mineru.atomgit.com/zh/usage/sdk_api.html)可作协议参考,但不强制安装。
+- **何时重新考虑**:官方提供无推理依赖的稳定轻量客户端,且依赖与许可符合毕昇打包要求。
+
+### 决策 3:只接受完整 ZIP 产物
+
+- **备选**:A. 只消费 Markdown;B. 单独消费 Middle JSON;C. 请求并消费 ZIP。
+- **选定**:C,创建任务时要求 `output_formats=["zip"]`,根据远端返回的产物文件 ID 下载。
+- **原因**:正文、块到 bbox 的映射和图片字节需要同时可用;单独 Markdown 缺定位,单独 Middle JSON 不保证带素材。缺必需产物或协议身份不符即失败,不生成失效引用。
+- **何时重新考虑**:目标服务提供同等完整、版本明确的其他产物形式。
+
+### 决策 4:保留预览正文复用与执行时选择语义
+
+- **备选**:A. 把提供方/版本加入缓存键并重算旧预览;B. 沿用现有缓存键和正文复用机制。
+- **选定**:B。缓存命中时入库正文以用户已看到并可编辑的预览分块为准;缓存未命中时由执行中的管线读取当前全局配置。
+- **原因**:用户明确接受预览缓存期内沿用旧解析正文,要求入库正文与所见一致;也接受入队和实际执行之间全局提供方发生变化。强制重算会改变已确认内容。
+- **何时重新考虑**:产品要求正文、解析标识、bbox 和图片素材跨提供方切换时都严格同版;届时需要扩展预览快照与素材转存契约,而非仅改缓存键。
+
+### 决策 5:OFD 与 PDF 共享 OCR 队列判定
+
+- **备选**:A. OFD 一律进普通知识队列;B. OFD 在外部解析提供方可用时与 PDF 采用相同专用队列判定。
+- **选定**:B。`needs_ocr_queue()` 已在本分支将 `ofd` 与 `pdf` 同列;关闭专用队列仍进普通知识队列。
+- **原因**:`OfdLoader` 先转 PDF,再调用相同的 PDF Loader;解析耗时和外部服务占用应按 PDF 隔离。定向测试 13 个通过。
+- **何时重新考虑**:OFD 改为不经过 PDF Loader 的独立解析流程。
+
+### 决策 6:`request_kwargs` 透传创建任务的附加参数
+
+- **备选**:A. 把每个 MinerU V1 可选字段都建成独立配置;B. 与现有 MinerU/PaddleOCR Loader 一样提供 `request_kwargs`,合入解析请求体;C. 把它直接传给 `httpx.post(**request_kwargs)`。
+- **选定**:B。`knowledges.mineru_v4.request_kwargs` 默认 `{}`,只浅合并到 `POST /v1/parse/jobs` 的 JSON 顶层;不作用于上传、健康检查、轮询、下载,也不是 HTTP headers、URL、超时等传输参数。
+- **原因**:现有两个 Loader 的同名字段都是接口请求体参数,管理员可据目标服务能力补充可选字段而无需每次改配置模型。V1 创建任务有明确的 JSON 顶层与文件级字段;把额外字段当 HTTP 客户端参数会改变含义或直接报错。[现有 MinerU 实现](../../../src/backend/bisheng/knowledge/rag/pipeline/loader/mineru.py)、[V1 任务参数](https://mineru.atomgit.com/next/api/parse-jobs.html)
+- **何时重新考虑**:上游出现需要毕昇单独解释的稳定选项,或需要配置文件级参数时,再增加有类型的配置/映射;不能靠顶层浅合并修改 `files[]`。
+
+## 4. 系统现状与目标接线
+
+### 4.1 数据流
+
+**当前基线**:文件上传或预览 → `BaseFilePipeline._build_pdf_loader()` 从 DB 配置选择 ETL4LM/旧 MinerU/PaddleOCR/本地 PDF → `OfdLoader` 先转 PDF、图片复用 PDF 选择入口 → Loader 产出 `Document` 和可选 bbox/图片 → Transformer 处理分块、图片、bbox → 知识文件入库或预览缓存 → 平台/客户端按 `parse_type` 打开引用。
+
+**已实施**:在唯一选择入口新增 `mineru_v4` → `MineruV4Client` 调 V1 上传、解析任务和 ZIP 下载 → `MineruV4Loader` 将 Middle JSON/素材转成既有 Loader 契约 → 沿用 Transformer 与入库链路。预览、持久文件和临时解析共用选择入口;不建平行入库管线。
+
+`KnowledgeService.get_preview_file_chunk()` 根据文件及分段规则生成 Redis 缓存键,缓存命中会直接返回旧分块;正式入库的 `PreviewCacheTransformer` 也会以缓存分块覆盖新解析分块。队列仅传文件 ID 等上下文,Worker 开始后才构建 Loader;这两点决定了 §3 决策 4 的实际边界。
+
+### 4.2 配置、协议与产物契约
+
+| 结构 / 字段 | 当前或目标值 | 消费方与含义 |
+|---|---|---|
+| `knowledges.loader_provider` | 现有全局 DB 配置;新增显式值 `mineru_v4`,默认仍 `etl4lm` | `BaseFilePipeline._build_pdf_loader()` 在任务执行时读取;Redis 配置缓存约 100 秒 |
+| `knowledges.mineru_v4` | 新增 `api_url`、`api_key_env`、`tier`、`ocr_mode`、`request_kwargs: dict[str, JSON value] = {}`、连接/请求/传输/总任务超时、轮询间隔 | `MineruV4Conf` 校验字段;只存环境变量名,不存明文 Key;空地址或无效档位明确失败 |
+| `KnowledgeFile.parse_type` | 新值 `mineru_v4`;旧值 `mineru` 不变 | 历史文件、文件分享、引用预览;OFD 持久文件的委托解析来源须单独核对 |
+| V1 上传/任务 | `/v1/uploads` → 字节上传/完成 → `/v1/parse/jobs` → 轮询任务 → `/v1/files/{id}/content` | 新 HTTP 客户端;只以远端返回的文件 ID 下载 ZIP,不能用旧 `/file_parse` |
+| V1 任务状态 | `queued`、`running`、`completed`、`partial`、`failed`、`canceled` | 单文件任务及文件均 `completed` 且 ZIP 校验成功才算成功;其余终态失败 |
+| Middle JSON | `schema=docvortex.middle`、`schema_version=2.0`、`pages[].page_idx/blocks[]` | 新 Loader;源页号 0 基;文本、标题、表格、公式、图片块按真实样本映射 |
+| `Document` 与 Loader 侧产物 | 正文及 `metadata.pages/types/bboxes/indexes`;`bbox_list`、平铺的 `local_image_dir`、`preview_file_path`;bbox 在 Loader 内缩放到页面坐标后统一用 `int()` 截断,元数据与 `bbox_list` 使用相同整数坐标值 | 分块、`ImageUploadTransformer`、`ExtraFileTransformer`;图片引用替换完毕后计算 `indexes` |
+| `/api/v1/knowledge/preview` 与 `/preview/status` | 既有创建任务/轮询接口,无新增前端路由 | 预览页面;进程中断后刷新重新提交会拿到新任务 ID |
+
+建议配置形态(`api_url` 为 `/v1` 之前的服务根地址;部署值以目标环境为准):
+
+```yaml
+knowledges:
+  loader_provider: mineru_v4
+  mineru_v4:
+    api_url: http://mineru-v4.example:8000
+    api_key_env: MINERU_V4_API_KEY
+    tier: standard
+    ocr_mode: auto
+    request_kwargs: {}
+    connect_timeout_seconds: 10
+    request_timeout_seconds: 60
+    transfer_timeout_seconds: 300
+    job_timeout_seconds: 1800
+    poll_interval_seconds: 3
+```
+
+新配置块加入 `src/backend/bisheng/initdb_config.yaml`,旧安装由 `ConfigService.merge_missing_config()` 补入缺失子块;不向启动 `config.yaml` 加新顶层键。`force_ocr=1` 对单次任务覆盖为 `ocr_mode=ocr`;默认解析全部页面。配置的 API Key 环境变量在 API 和知识 Worker 进程都须可读。
+
+`request_kwargs` 仅提供创建任务的**其他顶层 JSON 字段**,要求是可 JSON 序列化的映射;构造请求时先合入附加字段,再写入由适配器管理的 `files`、`tier`、`ocr_mode`、`output_formats=["zip"]`。配置若与这四个保留键冲突,直接报配置错误,不默默覆盖。`files[]` 中的 `page_range` 是文件级字段,不能通过此顶层浅合并设置;需要时另设计文件级配置。未知字段由目标 MinerU 服务校验,上游拒绝时按 AC-10/AC-06 报解析失败,不自动切换提供方。`request_kwargs` 不承载 API Key,配置样例和日志均不得包含回调密钥等凭据;`enable_formula` 也不能借透传字段伪装成 V1 已支持的逐任务开关。
+
+ZIP 下载先写受大小限制的临时文件;解包时限制成员数、单成员及总解压字节数,拒绝绝对路径、`..` 路径、符号链接和重复目标名。额度应结合毕昇上传限制和目标服务样本定值,避免大文件或异常 ZIP 耗尽 API/Worker 内存与磁盘。
+
+文件分段规则的 `retain_images` 同时传给新 Loader 与既有 `ImageUploadTransformer`。关闭时 Loader 不暂存素材、不生成图片 Markdown 引用,保留可读文字与图注;开启时按完整 `images/...` 相对路径(含可选 `./` 前缀和子目录)查找 ZIP 素材,展平文件名并在计算 `indexes` 前替换为最终 URL。
+
+### 4.3 关键模块职责
+
+| 模块 / 文件 | 负责 | 不负责 |
+|---|---|---|
+| `core/config/settings.py`、`initdb_config.yaml` | 新提供方配置校验、图片解析能力声明和默认禁用示例 | 不存明文 Key,不自动改全局提供方 |
+| `knowledge/rag/base_file_pipeline.py` | 唯一 PDF/图片/OFD 委托 Loader 选择入口;传递 `retain_images`;为新提供方明确失败 | 不实现 V1 HTTP 协议 |
+| `knowledge/rag/pipeline/loader/mineru_v4_client.py` | V1 上传、任务轮询、ZIP 下载、同源鉴权与阶段/总时限 | 不转换 Middle JSON,不把 Key 发给跨源地址 |
+| `knowledge/rag/pipeline/loader/mineru_v4.py` | 安全读取 ZIP、Middle JSON 块到 Document/bbox/图片转换;图片流式写入平铺暂存目录 | 不写数据库或跨节点本地状态,不复用旧 `pdf_info` 转换 |
+| `knowledge/rag/pipeline/loader/mineru.py` | 旧 MinerU 协议 | 不承载 4.0 分支 |
+| `worker/knowledge/scheduler.py` | OCR 专用队列路由,包含 OFD | 不决定最终解析提供方 |
+| `knowledge/domain/services/knowledge_service.py` 与 `pipeline/transformer/` | 保持现有预览缓存、图片/bbox 上传与入库流程 | 不新增第二条解析管线 |
+| 平台 `controllers/API/index.ts`、客户端 `api/apps.ts` 及文件查看组件 | 让旧 MinerU 和 `mineru_v4` 都获取可用引用预览 URL | 不改变 UI 视觉样式 |
+
+## 5. 已知坑与反直觉事实
+
+| # | 事实 | 如果忽略会怎样 | 处理位置 |
+|---|---|---|---|
+| 1 | 旧 MinerU 是一次 multipart 请求,4.0 是有状态的 V1 上传与异步任务;自部署服务重启会丢资源索引 | 旧 URL 或重试策略会重复提交、无限轮询或拿不到产物 | `MineruV4Loader` 的阶段校验、总超时和结构化日志;不把 job ID 当持久续跑状态 |
+| 2 | 官方上传 URL 可能跨源,下载可重定向;Bearer Key 只可发给同源 API/上传目标 | Key 随预签名 URL 泄露给对象存储 | V1 客户端按 scheme、host、有效端口比较同源,保留远端上传头,不跨源转发 Key;见[官方示例](https://mineru.atomgit.com/zh/usage/http_api.html) |
+| 3 | `ImageUploadTransformer` 仅扫描平铺目录;`metadata.indexes` 与最终正文字符位置绑定 | ZIP 子目录图片不上传,后改 URL 会令引用定位偏移 | `MineruV4Loader` 展平安全文件名、先写最终引用再算 indexes |
+| 4 | 预览缓存键不含提供方,正式入库仍运行当次 Loader,最后由缓存覆盖分块正文 | 切换窗口可能出现旧正文与当次 `parse_type`、bbox 或图片来源不同;正文以用户所见缓存为准,但不能声称所有产物严格同版 | `KnowledgeUtils.preview_cache_key_for_split_rule()`、`PreviewCacheTransformer`;本期接受此边界,验证链接可用 |
+| 5 | `OfdLoader` 先转 PDF 后委托 PDF Loader,预览仍用转换后的 PDF;原队列只把 PDF/图片送 OCR 队列 | OFD 长任务占用普通队列,且误以为新 Loader 需直接接收 OFD | `OfdLoader`、`BaseFilePipeline._init_ofd_loader()`、`scheduler.needs_ocr_queue()`;本分支已修复路由 |
+| 6 | 平台和客户端聊天引用只把 `etl4lm/un_etl4lm` 视作新版预览来源 | 旧 `mineru` 与新增 `mineru_v4` 的引用 `fileUrl` 为空 | 两端 API 映射和文件查看组件统一识别;修改 UI 前读 `src/frontend/packages/ui/docs/` |
+| 7 | 现有预览任务运行在 API 后台任务中,状态为 Redis `processing`;刷新页面可重新发起任务 | 进程中断后旧任务 ID 不会恢复;本期不做任务持久化 | `knowledge/api/endpoints/knowledge.py` 与前端预览创建/轮询逻辑 |
+| 8 | 旧请求中的 `enable_formula` 没有 V1 逐任务等价参数 | 若静默透传或假定关闭生效,用户会误判解析效果 | 新 Loader 不发送虚构参数;以 tier/样本说明效果,并在测试中覆盖旧选项的兼容行为 |
+| 9 | 旧 Loader 的 `request_kwargs` 合入业务请求体,V1 则有上传、建任务等多个请求 | 误把配置展开为 `httpx` 参数、或注入上传/轮询,会让参数失效或改变鉴权与传输行为 | `MineruV4Conf` 和 `MineruV4Loader` 只在建任务 JSON 中浅合并,并拒绝与 `files/tier/ocr_mode/output_formats` 冲突 |
+| 10 | ZIP 中图片可累计数百 MiB,且素材路径可嵌入 `./images/...` 或带子目录 | 全量读入内存会放大占用,平铺文件名碰撞或引用截断会导致错误链接或误报缺素材 | `MineruV4Loader` 限制成员及解压规模、逐块写图片、拒绝平铺碰撞、按完整相对路径替换引用 |
+| 11 | `ImageUploadTransformer` 在 `retain_images=0` 时跳过上传 | Loader 若仍写最终 URL,预览和入库正文会留下从未上传的失效链接 | `BaseFilePipeline` 将开关传给新 Loader;Loader 同步省略图片暂存与引用,保留文字和图注 |
+| 12 | 文件与预览的 bbox 标识按整数坐标生成,前端按分块坐标拼接标识;小数坐标会导致标识不匹配(116 真实文件已核验,离线回归覆盖 Loader → 分块 → bbox 上传) | 坐标存在但分段无法激活定位框 | `MineruV4Loader._bbox()` 统一产出整数坐标值,后续匹配逻辑不变;旧文件及预览缓存中的小数坐标需重新解析后更新;图片维持现有预览,不新增定位功能 |
+
+## 6. 对外契约与依赖
+
+### 6.1 我提供给别人的
+
+| 契约 | 形式 | 消费方 |
+|---|---|---|
+| `mineru_v4` 解析来源 | 现有知识文件/引用响应的 `parse_type` 新枚举值 | API、平台和客户端预览、排查日志 |
+| 正文、页码、定位、图片引用 | 既有 `Document` 元数据与 Loader 侧产物 | 分块、图片/bbox 上传、问答引用 |
+| 文件预览和状态 | 既有 `/api/v1/knowledge/preview`、`/preview/status` 响应形态不变 | 知识库页面 |
+| `BaseFilePipeline._build_pdf_loader()` | 内部 Python 接口增加显式新分支 | PDF、图片、OFD、预览和临时解析管线 |
+| `knowledges.mineru_v4.request_kwargs` | DB 配置中的 JSON 映射;仅作为 V1 创建任务顶层附加字段 | 管理员配置、`MineruV4Conf`、`MineruV4Loader` |
+
+### 6.2 我依赖别人的
+
+| 依赖 | 形式 | 风险点 |
+|---|---|---|
+| MinerU 4.0 V1 服务 | HTTP、`/v1/health`、`/v1/tiers`、上传/任务/产物接口 | 部署可能不提供 ZIP 或目标 tier;实际能力必须验收 |
+| Middle JSON 2.0 和 ZIP 素材 | 上游序列化协议 | 块类型、素材命名与可选字段可能随服务版本变动;需版本/样本夹具 |
+| OFD 转 PDF | 现有 `OfdLoader` / easyofd 转换 | 转换失败或空 PDF 不能伪装为解析成功 |
+| Redis 预览缓存与 DB 配置 | 现有服务 | 配置缓存约 100 秒;缓存命中可跨提供方,见 §5-4 |
+| MinIO | 既有图片和预览对象存储 | 图片引用与实际上传对象必须对应;不能依赖 API/Worker 本地路径共享 |
+| 平台/客户端文件查看 | 现有 `parse_type` 分支 | 漏掉新值会产生空预览 URL;旧 MinerU 也须补齐 |
+
+## 7. 测试与可观测
+
+- **协议单测**:离线 V1 响应、ZIP 夹具和协议到 Document 的集成测试覆盖上传秒传/普通上传、同源/跨源授权、成功与失败终态、超时、缺产物、非法路径、缺图片、Middle JSON 块形态和结构错误;`request_kwargs` 只进入建任务 JSON,非映射/不可序列化值及保留键冲突明确失败。补充覆盖 `retain_images=0`、缺素材及带子目录素材引用后,当前定向测试 44 项通过。旧 MinerU 的固定内网测试未作为本特性通过条件。
+- **管线验证**:检查 PDF、图片、OFD 的正文、页号、bbox、表格/公式、图片链接、`metadata.indexes`、分块和失败终态;验证缓存命中沿用预览正文,以及专用队列开/关与 Loader 配置组合。OFD 路由的 13 个定向测试已在本分支通过。
+- **环境验收**:在有目标服务的环境执行 `curl -fsS "$MINERU_V4_API_URL/v1/health"` 和 `/v1/tiers`,记录服务版本、可用 ZIP/tier;用脱敏的原生 PDF、扫描 PDF、图片、OFD 登录测试知识库,依次检查预览、入库终态、问答引用与 MinIO 素材;最后验证并发、长任务及服务重启。当前无目标服务,详见 [e2e-checklist.md](./e2e-checklist.md),不能声称真实服务和页面已验收。
+- **可观测**:记录提供方、阶段、耗时、job ID、终态、上游错误码、ZIP 校验及块/图片数量;不记录正文、Key 或预签名 URL。成功判据是知识文件最终状态及可用引用,不是上传/建任务 HTTP 200。
+
+## 8. 后续改进与未投入事项
+
+- 不做按知识库或租户的渐进开关;本版使用全局配置,执行前切换窗口的差异由用户接受。需要细粒度灰度时再定义选择优先级和配置归属。
+- 不把提供方写入预览缓存键,也不保证跨提供方切换时 bbox、图片与缓存正文严格同版。若业务要求所有产物严格一致,需设计完整预览快照及素材转存,不能只改缓存键。
+- 不持久恢复 MinerU 远端 job 或 API 预览后台任务;用户可重新发起预览,知识文件失败后可走现有重解析。
+- 不扩展 Office/HTML/表格的解析范围,也不把模型推理运行时装入毕昇后端。
+- 不承诺 `enable_formula=0` 能关闭 MinerU 4.0 的公式识别;上游 V1 没有该等价开关。若产品要求保留此操作语义,需另行确定 UI 呈现或上游能力支持方式。
+
+## 修订历史
+
+| 日期 | 改动 | 触发原因 |
+|---|---|---|
+| 2026-10-13 | 初版设计草案:独立提供方、V1 ZIP、缓存/执行时语义、OFD 队列与旧版兼容 | 规格确认及技术方案评审决定 |
+| 2026-10-13 | 明确 `request_kwargs` 的建任务请求体透传、保留键与验证边界 | 用户要求沿用其他 Loader 的配置扩展方式 |
+| 2026-10-13 | 实施独立 V1 客户端、流式 ZIP 适配、提供方接线、OFD 队列与两端引用;记录 39 项离线测试及待验收清单 | 设计获用户确认,进入任务拆分与实施 |
+| 2026-10-13 | 同步修复 `retain_images=0` 时的失效图片链接和子目录素材路径截断;定向测试增至 44 项 | MinerU 4.0 功能代码评审的两条反馈 |
diff --git a/features/v3.0.0/076-mineru-v4/e2e-checklist.md b/features/v3.0.0/076-mineru-v4/e2e-checklist.md
new file mode 100644
index 0000000000..1ddc6ad237
--- /dev/null
+++ b/features/v3.0.0/076-mineru-v4/e2e-checklist.md
@@ -0,0 +1,41 @@
+# MinerU 4.0 E2E 验收清单
+
+**当前结果**:离线 V1 协议到 `Document` 的 PDF/PNG 集成测试及定向回归已通过。2026-10-13 已在独立 MinerU 4.0.12 服务上完成 Basic 档位 PDF、PNG、OFD 以及 Standard 档位 PDF 的真实 V1 接口与 Loader 闭环;完整毕昇中间件、知识库入库及页面验收仍待执行。离线测试文件为 `src/backend/test/knowledge/test_mineru_v4_e2e.py`。后续验收使用专用测试租户、测试知识库及脱敏文件;只清理本次创建的数据。
+
+## 环境准备
+
+- [x] 在目标环境确认 `/v1/health`、`/v1/tiers`:服务版本 4.0.12,最终保留的服务提供 `flash`、`basic`、`standard`、`advanced`,支持 ZIP;仅绑定目标机 `127.0.0.1:18089`,无外网入口,因此未配置 API Key。后续如开放给其他机器,需增加鉴权并在 API 与知识 Worker 进程设置相同的 Key 环境变量。
+- [ ] 在毕昇配置中设置 `knowledges.mineru_v4.api_url`、`api_key_env`、`tier` 和需要的 `request_kwargs`;显式把 `loader_provider` 切为 `mineru_v4`。保存前留存原配置以便恢复。
+- [ ] 准备原生 PDF、扫描 PDF、PNG/JPEG、OFD,以及含标题、表格、公式、多张图片的脱敏样本。
+
+## 独立接口与 Loader 实测(2026-10-13)
+
+- 目标机 `192.168.106.20` 最终保留测试容器 `mineru-v4-bisheng-standard-test`,使用 MinerU 4.0.12 基础包、ONNX/CPU 小模型和 llama.cpp VLM,按 Standard 档位启动;端口只映射到目标机回环地址。依赖和模型位于 `/nfs-share/node11_public/mineru-v4-bisheng-test`,不改动原有 `mineru:latest`(2.6.8)镜像。模型校验结果为 `MinerU-4_models_onnx: ok` 和 `MinerU2.5-Pro-2605-1.2B-GGUF: ok`。服务设置为自动重启,并限制为 4 CPU、16 GiB 内存和只读容器根文件系统。
+- [x] 在临时 Basic 实例上使用当前 `MineruV4Loader` 对一页原生 PDF 发起上传、建任务、轮询和 ZIP 下载:任务完成,得到 `schema=docvortex.middle`、`schema_version=2.0`,正文含标题、表格和公式测试文本;1 页、4 个块、4 个 bbox,并暂存 1 张表格图片。该临时实例已关闭并移除。
+- [x] 在临时 Basic 实例上使用当前 `MineruV4Loader` 对 PNG 发起同一闭环:任务完成,OCR 正文为 `MinerU V4 image OCR test 2026`;1 页、1 个块和 1 个 bbox。
+- [x] 在临时 Basic 实例上使用现有 `OfdLoader` 把 `sample.ofd` 转成 PDF,再委托当前 `MineruV4Loader` 调用真实服务:任务完成,正文为 `Hello OFD sample`;转换后的预览 PDF 存在,得到 1 个 bbox。
+- [x] 在最终保留的 Standard 实例上,以当前 `MineruV4Loader` 和默认 `tier=standard` 重跑一页 PDF:任务完成,得到同一 Middle JSON 协议,1 页、4 个块、4 个 bbox 和 1 张表格图片;服务日志确认 VLM 使用 llama.cpp 引擎。
+- 本轮只证明独立服务协议、ZIP 结构和 Loader 转换。尚未通过毕昇 API/Worker、MinIO、知识库入库或前端页面验证;也未测试扫描 PDF、JPEG、多图素材与失败终态。
+
+## 解析、预览和入库(AC-01、AC-04、AC-05、AC-06、AC-10)
+
+- [ ] 在平台知识库分别上传 PDF、图片和 OFD,发起预览并等待终态;确认正文可读、页序正确、上游确实提供的 bbox 可定位、图片 URL 可访问。OFD 的文件预览应展示转换后的 PDF。
+- [ ] 打开段落编辑,核对标题、表格、公式和多张图片没有丢失;图片内容能从 MinIO 对象读取,分块引用范围与正文一致。
+- [ ] 对含图片的样本关闭“保留图片”后重新预览并入库;确认正文和图注仍可读,正文没有图片占位或未上传的素材链接。再打开该选项,确认子目录图片均能访问。
+- [ ] 提交入库后通过知识文件详情或列表确认最终成功状态和 `parse_type=mineru_v4`,再发起问答并打开引用,确认平台和 `/workspace` 客户端均能打开预览和定位。
+- [ ] 使用不可达服务、错误 Key、无 ZIP 产物和上游部分完成样本验证失败终态;知识文件不得显示为成功,也不得产生部分正文引用。
+
+## 兼容、缓存与任务时机(AC-02、AC-03、AC-07、AC-08、AC-11)
+
+- [ ] 恢复默认或旧 `mineru` 提供方,上传测试文件;确认使用原有解析链路。历史旧 MinerU 文件与新文件在平台和客户端的聊天引用均可打开。
+- [ ] 先用旧提供方生成预览并记下可见分块,再切换到 `mineru_v4` 后提交入库;确认入库正文仍与已看到的预览一致。记录此次文件的 `parse_type`、bbox 与图片来源,按设计中的缓存边界解释可能的来源差异。
+- [ ] 使用专用测试队列制造排队窗口,提交任务后切换全局提供方;确认任务实际执行时采用当时生效的配置,无需与提交时保持一致。
+
+## 队列及恢复(AC-09、AC-10)
+
+- [ ] 启用 OCR 专用队列且配置有效新提供方,提交 PDF 与 OFD,确认二者进入专用队列;关闭专用队列或清空外部提供方地址后,OFD 进入普通知识队列。
+- [ ] 解析任务运行中重启 MinerU 服务,确认毕昇最终呈现明确失败或现有重试结果;用户刷新预览页面后能够重新发起任务并获取新任务 ID。
+
+## 结果记录
+
+记录每个样本的文件类型、解析来源、预览终态、入库终态、图片/定位可用性、平台与客户端引用结果,以及失败样本的错误信息。除上述独立接口与 Loader 实测外,其余真实环境和页面验收仍未执行,不能把本轮结果视为完整功能验收通过。
diff --git a/features/v3.0.0/076-mineru-v4/spec.md b/features/v3.0.0/076-mineru-v4/spec.md
new file mode 100644
index 0000000000..68edbb2bb7
--- /dev/null
+++ b/features/v3.0.0/076-mineru-v4/spec.md
@@ -0,0 +1,61 @@
+# Feature: MinerU 4.0 文档解析接入
+
+> 本文只定义需求与验收结果。实现选择和上游协议见 [design.md](./design.md)。
+> 状态:已确认(2026-10-13);后续需求变更须重新确认。
+
+**关联需求**:2026-10-12 至 2026-10-13 本任务对话中关于独立提供方、旧版兼容和评审意见的决定;[技术方案草案](../../../docs/architecture/proposals/2026-10-12-mineru-v4-integration.md) 仅作背景材料。
+**优先级**:P1
+**所属版本**:v3.0.0
+**依赖**:既有知识文件解析、预览和引用能力;可访问的 MinerU 4.0 服务。
+**版本契约**:[release-contract.md](../release-contract.md)
+
+## 范围
+
+- **本次纳入**:可显式选择 MinerU 4.0 解析 PDF、图片、OFD;保留旧 MinerU;让知识文件入库、预览和引用能消费新结果;修正 OFD 的专用队列路由。
+- **本次明确排除**:历史文件批量重解析;Office、HTML、表格等文件改走 MinerU 4.0;提供方自动探测或失败回退;按知识库或租户分别选择提供方;中断的预览任务恢复。
+
+## 1. 用户故事
+
+作为**系统管理员**,我希望在保留旧 MinerU 接入的同时显式启用 MinerU 4.0,以便按部署环境逐步升级和回退。
+
+作为**知识库维护者**,我希望 PDF、图片和 OFD 文件能正确解析、预览并入库,以便分块正文、图片和定位信息可用于检索与引用。
+
+作为**知识库使用者**,我希望在引用中打开对应文件的可用预览,以便核对答案来源。
+
+## 2. 验收标准
+
+### 2.1 提供方选择和兼容
+
+- **AC-01** — WHEN 管理员显式选择 MinerU 4.0 且服务可用,THE SYSTEM SHALL 使用该提供方解析新提交的受支持文件,并标识解析来源。
+- **AC-02** — WHILE 管理员未选择 MinerU 4.0,THE SYSTEM SHALL 保持当前默认提供方和旧 MinerU 的可用行为,升级应用本身不得自动切换解析版本。
+- **AC-03** — WHEN 管理员把提供方从 MinerU 4.0 切回旧 MinerU,THE SYSTEM SHALL 让此后执行解析的任务使用当前选择;已保存文件的正文和解析来源不得因此被改写。
+
+### 2.2 文件解析与消费
+
+- **AC-04** — WHEN 用户用 MinerU 4.0 处理 PDF、受支持图片或 OFD,THE SYSTEM SHALL 为成功文件提供可读正文、正确页序,以及上游实际提供的有效定位信息和图片素材。
+- **AC-05** — WHEN 解析结果包含标题、表格、公式或多张图片,THE SYSTEM SHALL 保留可读内容及有效素材引用,不得把这些内容静默丢弃或生成失效链接。
+- **AC-06** — IF 解析服务失败、返回部分完成、缺少必需结果或结果无法安全消费,THEN THE SYSTEM SHALL 将该文件标记为解析失败,不得把部分正文当作成功文件入库。
+- **AC-07** — WHEN 用户预览或查看由新提供方解析的文件,THE SYSTEM SHALL 展示可用文件预览、分块及可用的定位和图片;平台与客户端的聊天引用均能打开该文件。旧 MinerU 文件的引用预览也须可用。
+- **AC-08** — WHEN 用户接受已有文件预览并提交入库,THE SYSTEM SHALL 沿用用户所见的预览正文;即使提交前管理员切换了解析提供方,也不强制重新解析该预览。
+
+### 2.3 队列与运行边界
+
+- **AC-09** — WHERE OCR 专用队列已启用且当前外部解析提供方已配置,THE SYSTEM SHALL 将 OFD 文件与 PDF 文件一样送入专用队列;未启用专用队列或未配置外部解析提供方时,OFD 仍进入普通知识队列。
+- **AC-10** — IF MinerU 4.0 不可用或配置无效,THEN THE SYSTEM SHALL 明确报告解析失败,不得自动改用旧 MinerU 或本地解析器。
+- **AC-11** — WHEN 已排队文件实际开始解析,THE SYSTEM SHALL 按当时生效的全局提供方处理该文件;不要求与文件提交时的选择相同。
+
+## 3. 边界情况
+
+- 历史文件保持原正文、素材及解析来源;只有用户显式重解析时才按当时生效的提供方生成新结果。
+- 图片或定位信息若上游未提供,系统只能展示确实可用的结果;所需素材缺失时按 AC-06 失败。
+- 用户刷新预览页面后可重新发起预览并获得新的任务;本次不要求恢复已中断的预览任务。
+- OCR 专用队列关闭时,PDF、OFD 和图片文件继续沿用普通知识队列;其他文件类型不因本特性改道。
+
+## 4. 设计与实现指针
+
+| 内容 | 文档 |
+|---|---|
+| 方案取舍、数据流、上游协议与已知坑 | [design.md](./design.md) |
+| 执行顺序与 AC 追溯 | [tasks.md](./tasks.md) |
+| 版本级归属与不变量 | [release-contract.md](../release-contract.md) |
+| 架构约束 | [constitution.md](../../../docs/constitution.md) |
diff --git a/features/v3.0.0/076-mineru-v4/tasks.md b/features/v3.0.0/076-mineru-v4/tasks.md
new file mode 100644
index 0000000000..16b39e1897
--- /dev/null
+++ b/features/v3.0.0/076-mineru-v4/tasks.md
@@ -0,0 +1,117 @@
+# Tasks: MinerU 4.0 独立解析提供方
+
+**关联规格**:[spec.md](./spec.md) · **设计**:[design.md](./design.md) · **版本**:v3.0.0
+
+## 状态
+
+| 步骤 | 状态 | 备注 |
+|---|---|---|
+| spec.md | ✅ 已评审 | 用户已确认 |
+| design.md | ✅ 已评审 | 用户已确认,含 `request_kwargs` 决策 |
+| tasks.md | ✅ 已拆解 | SDD tasks 清单评审:LGTM |
+| 实现 | ✅ 代码完成 | 14 / 14 完成;目标服务与页面验收待执行 |
+
+## 开发模式
+
+后端按测试先行;各 Wave 内只在依赖满足后实施。真实 MinerU 服务未提供,协议测试使用离线 HTTP/ZIP 夹具;真实服务验收结论单独记录。新增测试放在 `src/backend/test/knowledge/`。本期不新增数据库表或错误码,复用现有知识文件失败状态与错误处理。
+
+## Wave 1:配置与现有路由
+
+- [x] **T001 配置模型与默认示例(基础设施)**
+  **文件**:`src/backend/bisheng/core/config/settings.py`、`src/backend/bisheng/initdb_config.yaml`
+  **逻辑**:增加独立 `MineruV4Conf`、默认未启用的配置块、`request_kwargs` 映射校验及解析服务参数;`KnowledgeConfig` 声明 `mineru_v4` 的图片能力,旧 `mineru` 与默认 `etl4lm` 保持行为。额外参数只作创建任务顶层 JSON 字段,保留键冲突报错;API Key 仅配置环境变量名。见 design §3 决策 1、6。
+  **依赖**:无。
+
+- [x] **T002 OFD 专用队列路由测试(测试)**
+  **文件**:`src/backend/test/knowledge/test_decide_queue.py`
+  **逻辑**:验证 OFD 与 PDF 在专用队列开启且外部提供方有效时同路由;关闭队列或外部配置缺失时落普通知识队列;包含 `mineru_v4`。不改变队列消息的 tenant_id headers → Worker ContextVar 契约。
+  **覆盖 AC**:AC-09。
+  **依赖**:T001。
+
+- [x] **T003 OFD 专用队列路由实现(Worker)**
+  **文件**:`src/backend/bisheng/worker/knowledge/scheduler.py`
+  **逻辑**:`needs_ocr_queue()` 将 OFD 与 PDF 同列;仅判断队列配置及解析能力,不在 scheduler 固定最终 Loader。保持既有 Celery tenant_id headers → Worker ContextVar 传递方式。当前分支已做该定向修复,复核后打勾。
+  **依赖**:T002。
+
+## Wave 2:MinerU V1 协议与结果适配
+
+- [x] **T004 V1 HTTP 客户端协议测试(测试)**
+  **文件**:`src/backend/test/knowledge/test_mineru_v4_client.py`
+  **逻辑**:以 HTTP mock 覆盖秒传/上传完成/轮询/下载、跨源上传和跳转不泄露 Bearer Key、失败终态/超时、`request_kwargs` 只进建任务 JSON 且保留键冲突失败;不连固定内网地址。
+  **覆盖 AC**:AC-01, AC-06, AC-10。
+  **依赖**:T001。
+
+- [x] **T005 V1 HTTP 客户端实现(实现)**
+  **文件**:`src/backend/bisheng/knowledge/rag/pipeline/loader/mineru_v4_client.py`
+  **逻辑**:`httpx` 客户端执行 `/v1/uploads`、字节上传/完成、`POST /v1/parse/jobs`、轮询、下载 ZIP;按源控制认证,校验响应与状态并施加阶段/总超时。`request_kwargs` 仅合入建任务 JSON,不承载传输参数。详见 design §3 决策 2、6。
+  **依赖**:T004。
+
+- [x] **T006 Middle JSON/ZIP 适配测试(测试)**
+  **文件**:`src/backend/test/knowledge/test_mineru_v4_loader.py`
+  **逻辑**:离线 ZIP 夹具覆盖 0 基页序、文本/标题/表格/公式/图片、bbox、最终正文 indexes、安全解包、缺图片/错误 schema/partial 失败;检查旧 `enable_formula` 不伪装成 V1 开关。
+  **覆盖 AC**:AC-04, AC-05, AC-06。
+  **依赖**:T005。
+
+- [x] **T007 Middle JSON/ZIP Loader 实现(实现)**
+  **文件**:`src/backend/bisheng/knowledge/rag/pipeline/loader/mineru_v4.py`
+  **逻辑**:调用 T005 客户端,把 ZIP 内 Middle JSON 和图片转成现有 `Document`、`bbox_list`、平铺 `local_image_dir`、`preview_file_path` 契约;安全解包、按完整子目录路径替换图片最终 URL 后再计算 indexes;`retain_images=0` 时不暂存图片或生成失效引用;不复用旧 `pdf_info` 结构,不写跨节点本地状态。见 design §3 决策 3、§4.2。
+  **依赖**:T006。
+
+## Wave 3:管线、预览与引用
+
+- [x] **T008 提供方选择与旧版兼容测试(测试)**
+  **文件**:`src/backend/test/knowledge/test_mineru_v4_pipeline.py`
+  **逻辑**:验证显式选择新/旧提供方、未启用保持默认、配置无效不静默退回、本次执行读取全局配置、PDF/图片/OFD 共用选择入口;预览缓存命中沿用已见正文。使用 mock 配置/Loader,不依赖中间件。
+  **覆盖 AC**:AC-01, AC-02, AC-03, AC-08, AC-10, AC-11。
+  **依赖**:T007。
+
+- [x] **T009 提供方接线实现(实现)**
+  **文件**:`src/backend/bisheng/knowledge/rag/base_file_pipeline.py`、`src/backend/bisheng/knowledge/domain/models/knowledge_file.py`
+  **逻辑**:新增 `ParseType.MINERU_V4`;在唯一 PDF Loader 选择入口构建 T007 Loader,并确保其图片/OFD 委托路径一致。仅在新提供方显式选择时失败即报错,旧版及其他提供方保持原行为;DB 文件记录新解析来源。
+  **依赖**:T008。
+
+- [x] **T010 平台引用 API 映射(Platform)**
+  **文件**:`src/frontend/platform/src/controllers/API/index.ts`、`src/frontend/platform/src/pages/ChatAppPage/components/FileViewPanne.tsx`
+  **逻辑**:让旧 `mineru` 和新 `mineru_v4` 引用与已有新版预览同样得到可用 `fileUrl`;不改变视觉样式。修改前读取 `src/frontend/packages/ui/docs/` 与平台 AGENTS;若触及遗留中文,按 i18n 规则提取。
+  **覆盖 AC**:AC-07。
+  **手动验证**:在平台聊天打开新、旧 MinerU 文件引用,确认文件预览 URL 和定位可用。
+  **依赖**:T009。
+
+- [x] **T011 平台知识页引用处理(Platform)**
+  **文件**:`src/frontend/platform/src/pages/KnowledgePage/components/ParagraphEdit.tsx`、`src/frontend/platform/src/pages/KnowledgePage/components/Paragraphs.tsx`
+  **逻辑**:统一旧/新 MinerU 的预览类型判断,保障知识分块引用和段落编辑预览;不改变 UI 视觉规范,按现有 i18n 规则处理触及文件。
+  **覆盖 AC**:AC-07。
+  **手动验证**:打开两类文件的知识分块和段落编辑,检查预览/定位与图片链接。
+  **依赖**:T009。
+
+- [x] **T012 客户端聊天引用映射(Client)**
+  **文件**:`src/frontend/client/src/api/apps.ts`、`src/frontend/client/src/components/PreviewFile/index.tsx`
+  **逻辑**:为旧 `mineru` 与新 `mineru_v4` 生成现有文件预览 URL;遵循客户端 AGENTS 与 UI docs,不改 UI 样式。
+  **覆盖 AC**:AC-07。
+  **手动验证**:在 `/workspace` 打开两类文件的聊天引用,确认文件 URL、正文和可用定位。
+  **依赖**:T009。
+
+## Wave 4:端到端验证与评审
+
+- [x] **T013 E2E 覆盖(测试)**
+  **文件**:`src/backend/test/knowledge/test_mineru_v4_e2e.py`
+  **逻辑**:按 `/e2e-test` 工作流编写 API 端到端覆盖与页面手动清单;有目标服务时验证 PDF/图片/OFD 的预览、入库终态、图片及引用,无服务时用协议夹具验证可运行部分并记录真实服务缺口。
+  **覆盖 AC**:AC-01, AC-02, AC-03, AC-04, AC-05, AC-06, AC-07, AC-08, AC-09, AC-10, AC-11。
+  **依赖**:T009, T010, T011, T012。
+
+- [x] **T014 特性级代码评审与交付核对(评审)**
+  **文件**:`features/v3.0.0/076-mineru-v4/design.md`、`features/v3.0.0/076-mineru-v4/tasks.md`
+  **逻辑**:逐任务执行 `/task-review` 并记录完成;运行 arch guard、Ruff、定向 pytest、前端 lint/typecheck,再 `/code-review --base feat/3.0.0`,核对 AC 与设计偏差;未提供服务时如实标注环境验收未完成。
+  **依赖**:T013。
+
+## 实际偏差记录
+
+- 离线集成测试放在 `test/knowledge/`,覆盖 V1 协议到 `Document`;无目标服务与毕昇中间件,真实 API/页面验收记录于 `e2e-checklist.md`。
+- 前端 typecheck 通过;全量 lint 命中本分支已有的 Platform 93 项、Client 21 项遗留报错,未修改 suppressions。
+
+## 实施与评审结果
+
+- T001–T013 按 `/task-review` 清单检查了任务文件、依赖、分层、密钥/日志、设计同步和前端约定;未发现新增高严重度问题。T014 按 `/code-review --base feat/3.0.0` 的七个维度核对,结果 `PASS_WITH_WARNINGS`:目标服务与 UI 验收尚未执行;全量 lint 受基线遗留报错阻塞。
+- 后端定向测试 44 项通过;新代码 Ruff 通过,全部涉及的 Python 文件以 `--ignore RUF013` 检查通过(旧模型文件有既存 RUF013);`scripts/arch-guard.sh` 通过;前端 `pnpm typecheck`、`pnpm check-i18n` 通过。Platform 三个修改的预览组件、Client 两个修改文件的定向 ESLint 通过。
+- E2E 离线测试覆盖 V1 HTTP → ZIP → `Document` 的 PDF/PNG 主链路,OFD 选择及队列由定向测试覆盖。真实服务的 PDF/图片/OFD 预览、入库和引用步骤在 [e2e-checklist.md](./e2e-checklist.md) 中待执行。
+- MinerU 4.0 评审提出的两项图片问题已修复:关闭 `retain_images` 时不生成未上传引用,带子目录的图片引用按完整路径匹配;对应定向测试和设计文档已同步。
diff --git a/features/v3.0.0/077-subject-id-search/change-manifest.md b/features/v3.0.0/077-subject-id-search/change-manifest.md
new file mode 100644
index 0000000000..c7c81d7fa8
--- /dev/null
+++ b/features/v3.0.0/077-subject-id-search/change-manifest.md
@@ -0,0 +1,85 @@
+# F077 主线改动清单
+
+基线 `fbb9bc60651d4b60d5b40eda8f538be207958cc5`。仅本表文件属于通用 ID 检索,不携带客户分支历史。
+
+- `src/backend/bisheng/api/services/f048_permission_runtime.py`
+- `src/backend/bisheng/common/subject_search.py`
+- `src/backend/bisheng/core/database/alembic/versions/v3_0_0_f077_subject_id_search_indexes.py`
+- `src/backend/bisheng/core/database/subject_search.py`
+- `src/backend/bisheng/database/models/department.py`
+- `src/backend/bisheng/department/domain/repositories/management_search_repository.py`
+- `src/backend/bisheng/department/domain/schemas/department_schema.py`
+- `src/backend/bisheng/department/domain/services/department_service.py`
+- `src/backend/bisheng/permission/api/endpoints/grant_subjects.py`
+- `src/backend/bisheng/permission/application/access.py`
+- `src/backend/bisheng/permission/domain/services/grant_subject_service.py`
+- `src/backend/bisheng/tenant/domain/repositories/grant_subject_search_repository.py`
+- `src/backend/bisheng/tenant/domain/repositories/management_search_repository.py`
+- `src/backend/bisheng/tenant/domain/services/f048_permission_subject.py`
+- `src/backend/bisheng/tenant/domain/services/tenant_service.py`
+- `src/backend/bisheng/user/api/user.py`
+- `src/backend/bisheng/user/domain/models/user.py`
+- `src/backend/bisheng/user/domain/repositories/management_search_repository.py`
+- `src/backend/bisheng/user/domain/services/user.py`
+- `src/backend/bisheng/user_group/domain/repositories/member_identity_repository.py`
+- `src/backend/bisheng/user_group/domain/schemas/user_group_schema.py`
+- `src/backend/bisheng/user_group/domain/services/user_group_service.py`
+- `src/backend/test/common/test_subject_search.py`
+- `src/backend/test/database/test_subject_id_search_indexes.py`
+- `src/backend/test/department/test_department_lazy_api.py`
+- `src/backend/test/department/test_global_members_search.py`
+- `src/backend/test/e2e/test_e2e_subject_id_search.py`
+- `src/backend/test/permission/test_f048_background_resource_registry.py`
+- `src/backend/test/tenant/test_grant_subject_search_repository.py`
+- `src/backend/test/tenant/test_tenant_users_query_source.py`
+- `src/backend/test/user/test_management_subject_search.py`
+- `src/frontend/client/src/api/permission.ts`
+- `src/frontend/client/src/api/permissionTypes.ts`
+- `src/frontend/client/src/components/permission/PermissionDraftEditor.tsx`
+- `src/frontend/client/src/components/permission/PermissionDraftPickerDialog.tsx`
+- `src/frontend/client/src/components/permission/PermissionGrantTab.tsx`
+- `src/frontend/client/src/components/permission/SubjectIdentity.tsx`
+- `src/frontend/client/src/components/permission/SubjectSearchDepartment.tsx`
+- `src/frontend/client/src/components/permission/SubjectSearchUser.tsx`
+- `src/frontend/client/src/components/permission/SubjectSearchUserGroup.tsx`
+- `src/frontend/client/src/components/permission/SubjectSelectionSummary.tsx`
+- `src/frontend/client/src/components/permission/useGrantDepartmentTree.ts`
+- `src/frontend/client/src/components/permission/usePermissionDraft.ts`
+- `src/frontend/client/src/components/permission/useSubjectKeyword.ts`
+- `src/frontend/client/src/locales/en/translation.json`
+- `src/frontend/client/src/locales/ja/translation.json`
+- `src/frontend/client/src/locales/zh-Hans/translation.json`
+- `src/frontend/client/src/pages/Subscription/ChannelSettings/ChannelSettingsPage.tsx`
+- `src/frontend/client/src/pages/knowledge/SpaceSettings/KnowledgeSpaceSettingsPage.tsx`
+- `src/frontend/platform/public/locales/en-US/bs.json`
+- `src/frontend/platform/public/locales/en-US/permission.json`
+- `src/frontend/platform/public/locales/ja/bs.json`
+- `src/frontend/platform/public/locales/ja/permission.json`
+- `src/frontend/platform/public/locales/zh-Hans/bs.json`
+- `src/frontend/platform/public/locales/zh-Hans/permission.json`
+- `src/frontend/platform/src/components/bs-comp/department/LazyDepartmentTree.tsx`
+- `src/frontend/platform/src/components/bs-comp/department/useLazyDepartmentTree.ts`
+- `src/frontend/platform/src/components/bs-comp/filterTableDataComponent/FilterByUser.tsx`
+- `src/frontend/platform/src/components/bs-comp/permission/PermissionDialog.tsx`
+- `src/frontend/platform/src/components/bs-comp/permission/PermissionGrantTab.tsx`
+- `src/frontend/platform/src/components/bs-comp/permission/SubjectSearchDepartment.tsx`
+- `src/frontend/platform/src/components/bs-comp/permission/SubjectSearchUser.tsx`
+- `src/frontend/platform/src/components/bs-comp/permission/SubjectSearchUserGroup.tsx`
+- `src/frontend/platform/src/components/bs-comp/permission/types.ts`
+- `src/frontend/platform/src/components/bs-comp/selectComponent/DepartmentUsersSelect.tsx`
+- `src/frontend/platform/src/components/bs-comp/selectComponent/SubjectSearchStatus.tsx`
+- `src/frontend/platform/src/components/bs-comp/selectComponent/Users.tsx`
+- `src/frontend/platform/src/components/bs-comp/selectComponent/useSubjectSearch.ts`
+- `src/frontend/platform/src/components/bs-comp/selectComponent/useSubjectTable.ts`
+- `src/frontend/platform/src/components/bs-ui/select/multi.tsx`
+- `src/frontend/platform/src/controllers/API/permission.ts`
+- `src/frontend/platform/src/controllers/API/tenant.ts`
+- `src/frontend/platform/src/controllers/API/userGroups.ts`
+- `src/frontend/platform/src/pages/ApprovalPage/components/ApproverUserPicker.tsx`
+- `src/frontend/platform/src/pages/ApprovalPage/index.tsx`
+- `src/frontend/platform/src/pages/DepartmentPage/components/AddMemberDialog.tsx`
+- `src/frontend/platform/src/pages/SystemPage/components/EditUserGroup.tsx`
+- `src/frontend/platform/src/pages/SystemPage/components/Users.tsx`
+- `src/frontend/platform/src/pages/TenantPage/components/CreateTenantDialog.tsx`
+- `src/frontend/platform/src/pages/TenantPage/components/TenantUserDialog.tsx`
+- `src/frontend/platform/src/types/api/department.ts`
diff --git a/features/v3.0.0/077-subject-id-search/design.md b/features/v3.0.0/077-subject-id-search/design.md
new file mode 100644
index 0000000000..f9fbdddee1
--- /dev/null
+++ b/features/v3.0.0/077-subject-id-search/design.md
@@ -0,0 +1,9 @@
+# F077 主线设计
+
+基线 origin/feat/3.0.0,独立工作区、分支 codex/subject-id-search-3.0.0。遵守 docs/constitution.md。用户已确认本方案与既有通用搜索口径,不重复规格/设计确认。
+
+1. common 仅放规范化、LIKE 字面转义和数字前缀范围;core 双库表达式通过 as_comparison 标记比较,避免达梦错误的 =1。
+2. Service 保留原主线鉴权与范围,SQL 归所属 user/department/tenant Repository。授权按既有 application 目录装配接入,不复制中粮隐藏/邀请策略。
+3. 页面以主线组件增量适配,保留原平铺选人、原写入和主线其他更新,不整文件覆盖差异代码。
+4. 用户/部门新增 external_id 前导非唯一索引,独立 f077 revision 接当前主线唯一 head merge_release_information_heads,不能使用中粮 migration。
+5. 统一后端回归,失败集合比较主线基线;逐文件白名单及独立审查中粮渗入。原有优化保留不代表实际性能验收通过。
diff --git a/features/v3.0.0/077-subject-id-search/e2e-checklist.md b/features/v3.0.0/077-subject-id-search/e2e-checklist.md
new file mode 100644
index 0000000000..b160e3f60f
--- /dev/null
+++ b/features/v3.0.0/077-subject-id-search/e2e-checklist.md
@@ -0,0 +1,14 @@
+# F077 页面清单(均未执行)
+
+- [ ] Client/Platform 新增授权:用户和部门 external_id、组数字 ID 完整值/前缀;名称沿旧规则。
+- [ ] 创建频道/知识空间时授权:同样检索和分页;第 200 个以后的组可查询。
+- [ ] 系统用户列表、用户组新增/编辑成员;已选业务 ID 重新打开仍显示。
+- [ ] 组织添加成员、部门管理员、新建部门选人;部门原层级/root 范围保留。
+- [ ] 租户管理员选择/挂载与用户列表;搜索后续页可加载,挂载范围不扩大。
+- [ ] 审批/异常处理人、标注任务成员、服务账号所有者、日志用户筛选;提交原内部 ID,写入业务不变。
+- [ ] 结果和已选 ID 可复制且不触发勾选;搜索/分页/清词/切类型保留选择,类型加内部 ID 避免碰撞。
+- [ ] 切资源/租户/root 后不展示或请求旧资源后续页,晚到响应不覆盖新结果。
+- [ ] 255 codepoint/非 BMP、Unicode 空白、换行、前导零、大小写、字面 %/_;ID 中段不凭 ID 命中。
+- [ ] IME 完成后查询,加载旧候选不可选,失败保留输入和选择且能重试。
+- [ ] 三语提示词/状态/ID 标签;空 ID 不伪造;部门路径和包含子部门选择保持。
+- [ ] 普通资源管理员无组织管理角色仍可按原资源范围选人;无权限/跨租户/空允许范围不扩大。
diff --git a/features/v3.0.0/077-subject-id-search/spec.md b/features/v3.0.0/077-subject-id-search/spec.md
new file mode 100644
index 0000000000..ff1ad14817
--- /dev/null
+++ b/features/v3.0.0/077-subject-id-search/spec.md
@@ -0,0 +1,12 @@
+# F077 通用主体 ID 检索
+
+用户已确认单独移植至 feat/3.0.0,禁止中粮定制。主线 F076 已用于 MinerU,本功能编号 F077。沿用此前已确认的通用需求,不重复确认关卡。
+
+- AC-01 用户/部门搜索各自 external_id,组搜索数字 ID;完整值和前缀、区分大小写、不按 ID 中段匹配;名称沿原规则。
+- AC-02 原租户/资源/组织范围、隐藏/禁用语义、稳定内部 ID、默认列表、选中及业务写入合同不变。
+- AC-03 保留原名称索引优化、主属部门 JOIN、窗口计数和空页兜底、批量部门补充;数据库两路合并去重后排序分页,完整 ID 优先。
+- AC-04 两端授权和创建授权、系统用户/组成员、组织/租户选人、审批选人、标注成员、服务账号所有者和日志筛选覆盖。主线原平铺用户选择保持,不引入中粮用户树。
+- AC-05 255 Unicode codepoint、规范化、IME、过期响应隔离、失败重试、选中/分页/切类型保留、三语文案;显示业务 ID,保存原内部 ID。
+- AC-06 MySQL/DM8 兼容,包括达梦比较表达式修正;仅新增 external_id 查询索引,无数据回填或授权模型变更。
+- AC-07 不合并/cherry-pick 中粮提交,不引入个人授权邀请审批、文件审批、知识空间定制策略、隐藏策略、机器人、配置和中粮迁移历史。
+- AC-08 先开发后统一测试;按既有用户约定不本地跑前端测试/构建,真实 API/页面/双库性能未执行须明示。
diff --git a/features/v3.0.0/077-subject-id-search/tasks.md b/features/v3.0.0/077-subject-id-search/tasks.md
new file mode 100644
index 0000000000..29607a53d5
--- /dev/null
+++ b/features/v3.0.0/077-subject-id-search/tasks.md
@@ -0,0 +1,16 @@
+# F077 实现计划
+
+使用 writing-plans、dispatching-parallel-agents 按独立目录分工,当前会话完成。
+
+- [x] T1 主线干净基线、规则和单迁移头。
+- [x] T2 后端纯函数、管理查询及模型/显示字段;真实 SQL 回归。
+- [x] T3 授权候选按主线范围增加 ID 查询和真实总数,保留目录装配与原授权写入。
+- [x] T4 Client API、原平铺用户/部门/组选择、创建授权与三语,不引入中粮用户树。
+- [x] T5 Platform 共享选人、授权、列表、审批选人、日志和三语,不改流程写入。
+- [x] T6 主线索引迁移、重复执行/回退与单 head 测试。
+- [x] T7 统一后端回归、Ruff、arch-guard 和前端源代码审查;前端检查交 CI。
+- [x] T8 独立主线纯净审查,记录差异及未执行验收。
+
+测试接口沿用 normalize_keyword/escape_like/numeric_prefix_ranges、subject_match_condition/exact_id_priority。覆盖 Unicode/codepoint、大小写/通配符、跨来源同 ID、空允许范围、分页/计数及旧请求响应。
+
+实现、可运行后端验证和独立审查已完成。T7 的真实 API/页面/双库性能及前端 CI 尚未验收,详见 verification.md;不把这些待验证项记为通过。
diff --git a/features/v3.0.0/077-subject-id-search/verification.md b/features/v3.0.0/077-subject-id-search/verification.md
new file mode 100644
index 0000000000..c2f684c837
--- /dev/null
+++ b/features/v3.0.0/077-subject-id-search/verification.md
@@ -0,0 +1,177 @@
+# F077 主线验证记录
+
+日期 2026-10-15。基线 origin/feat/3.0.0:`fbb9bc60651d4b60d5b40eda8f538be207958cc5`。独立 worktree 和 codex/subject-id-search-3.0.0 分支;没有合并或 cherry-pick 任何客户分支提交。规格/设计沿用户已确认的通用需求。
+
+## 主线纯净性
+
+- 全部修改以主线版本为底逐段适配,新代码仅为 ID 查询、输入/分页状态及业务 ID 展示;没有整分支合并。
+- User 未引入 is_hidden,授权 Scope 未引入 include_hidden;授权用户仍为主线平铺查询,没有新增用户树 API/前端组件。
+- 无邀请确认/个人授权审批、文件变更审批、知识空间策略、机器人、网关或客户部署配置变更;权限模型文件未改。
+- 仅增加 `v3_0_0_f077_subject_id_search_indexes.py`;父 revision 为主线原唯一 head `merge_release_information_heads`,未携带中粮 migration。
+- 独立 reviewer 审查新增与修改文件,未发现可验证 P0/P1/P2 或客户定制混入。root 另修复 Client 用户/组切资源时分页污染,维持主线范围边界;适配器引用变化不触发搜索循环。
+- 新增 src 内容扫描 cofco、include_hidden、is_hidden、personal_grant_invite、user_invite_request、hidden_from_lists、knowledge_space_read_bypass、grant-subjects/users/tree,无命中。主线已有部门空间范围逻辑原样保留。
+
+## 本地执行
+
+| 检查 | 结果 |
+|---|---|
+| 聚焦回归 | **168 passed, 4 skipped**;4 个为未配置夹具的真实 API E2E |
+| 扩大回归 | **1516 passed, 132 failed, 10 skipped** |
+| 相同主线基线扩大回归 | **1492 passed, 132 failed, 10 skipped**;失败和错误名称集合完全一致,无新增失败项 |
+| Ruff 改动范围对比 | 当前 316 条、基线 349 条,新增诊断 0;整体 lint 并非全绿 |
+| 架构守卫 | 全部修改及新增 src 文件 0 违规/警告 |
+| Alembic | 单 head `f077_subject_id_search_indexes`;重复升级/降级、索引和数据保留 SQLite 测试通过 |
+| 差异检查 | `git diff --check` 通过;最终暂存检查包含新增文件 |
+
+测试包含纯规则、MySQL/DM SQL 编译(含达梦 as_comparison 修正)、真实 SQLite 查询、授权范围/计数、用户/部门/租户管理、用户组原成员显示、迁移图与回滚。期间修正测试 harness 对新 Repository 的会话注入以及新增目录装配参数的 mock 签名,没有为测试放宽业务范围。
+
+使用 `/Users/shanghang/dataelem/ca-bisheng/src/backend/.venv/bin/python -m pytest -q`,工作目录和 PYTHONPATH 指向各自的 src/backend。config 指向主线自带 docker/bisheng/config/config.yaml;临时 wrapper 仅提供 localhost/test-only 的 !env 默认值,不复制客户配置或修改真实数据库。
+
+扩大范围:`test/permission test/user test/department test/tenant test/user_group test/database/test_alembic_single_head.py test/database/test_dm_dialect_helpers.py`。
+
+聚焦范围:`test/common/test_subject_search.py`、`test/user/test_management_subject_search.py`、`test/user/test_user_list_department_path.py`、`test/user/test_user_list_without_org_scope.py`、`test/department/test_global_members_search.py`、`test/department/test_department_lazy_api.py`、`test/tenant/test_tenant_users_query_source.py`、`test/tenant/test_grant_subject_search_repository.py`、`test/permission/test_f048_grant_subjects.py`、`test/permission/test_prospective_grant_application.py`、`test/permission/test_f048_background_resource_registry.py`、`test/user_group/test_user_group_service.py`、`test/database/test_alembic_single_head.py`、`test/database/test_subject_id_search_indexes.py`、`test/database/test_dm_dialect_helpers.py`、`test/e2e/test_e2e_subject_id_search.py`。
+
+本地临时证据:`/private/tmp/f077-final-focused.log`、`/private/tmp/f077-current-broad.log`、`/private/tmp/f077-baseline.log`、`/private/tmp/f077-ruff-report.json`、`/private/tmp/f077-arch-guard.json`。
+
+## 未执行与发布
+
+- 两端前端测试、lint/typecheck/构建按用户既有约定交镜像/CI,本地未运行。页面交互清单见 e2e-checklist.md。
+- 真实 API E2E 需专用测试租户和 E2E_SUBJECT_ID_FIXTURE;当前只读用例已提供,但因缺少夹具跳过。
+- 此主线版本尚未执行真实 MySQL/DM8 DDL 和请求性能验收。达梦修复参考已验证的通用比较表达式;不能将另一环境的结果当成本分支验收。
+- 需在专用 ≥15 万用户/3 万部门数据集,与原名称查询同环境基线比较 P50/P95、SQL 计划、冷热缓存及并发;目标服务端 P95≤500ms。当前不声称性能无回退。
+- 发布前后端同版更新,执行 Alembic 正向迁移,仅新增两个非唯一 external_id 索引,没有数据回填或授权模型迁移。回退本 revision 只删除新增索引;实际库升级与回滚仍需按该环境流程备份验证。
+
+## 扩大回归与主线一致的既有失败
+
+- `test/permission/test_f027_role_scope_nullsafe_unique.py::test_upgrade_adds_scope_key_and_swaps_to_nullsafe_unique_constraint`
+- `test/permission/test_f027_role_scope_nullsafe_unique.py::test_upgrade_dedupes_existing_null_scope_collisions_before_creating_constraint`
+- `test/permission/test_f027_role_scope_nullsafe_unique.py::test_downgrade_restores_previous_constraint_and_drops_scope_key`
+- `test/permission/test_f048_decision_api.py::test_concrete_action_check_returns_true_and_normal_false_as_200`
+- `test/permission/test_f048_decision_api.py::test_unknown_action_and_fga_unavailable_preserve_distinct_errors`
+- `test/permission/test_f048_decision_api.py::test_missing_and_cross_tenant_targets_are_equally_hidden`
+- `test/permission/test_f048_domain_boundaries.py::test_f048_domain_does_not_import_business_orm_repository_or_service`
+- `test/permission/test_f048_grant_api.py::test_resource_context_models_roster_and_self_summary_contract`
+- `test/permission/test_f048_grant_api.py::test_protected_mutation_and_stale_cursor_preserve_business_codes`
+- `test/permission/test_f048_grant_api.py::test_mode_draft_and_apply_only_send_server_draft_identity`
+- `test/permission/test_f048_legacy_runtime_retirement.py::test_frontends_do_not_expose_retired_permission_contracts`
+- `test/permission/test_f048_linsight_runtime.py::test_linsight_rejects_old_role_or_unhealthy_runtime[manager0]`
+- `test/permission/test_f048_linsight_runtime.py::test_linsight_rejects_old_role_or_unhealthy_runtime[manager1]`
+- `test/permission/test_legacy_rbac_sync_service.py::test_group_change_handler_invalidates_direct_user_cache`
+- `test/permission/test_retry_failed_tuples.py::test_cleanup_deletes_only_succeeded_records_before_retention_cutoff`
+- `test/user/test_user_department_service.py::TestHappyPath::test_promote_new_primary_triggers_sync`
+- `test/user/test_user_department_service.py::TestHappyPath::test_first_primary_no_demote`
+- `test/user/test_user_department_service.py::TestBlockedPropagation::test_sync_blocked_raises`
+- `test/user/test_user_department_service.py::TestTriggerEnumUsed::test_trigger_value_is_dept_change`
+- `test/user/test_user_string_lengths.py::test_user_string_columns_are_bounded[user_name-128-False]`
+- `test/user/test_user_tenant_sync_service.py::TestNoChange::test_same_leaf_returns_early`
+- `test/user/test_user_tenant_sync_service.py::TestRelocateHappy::test_swap_flow_calls_all_collaborators`
+- `test/user/test_user_tenant_sync_service.py::TestRelocateHappy::test_warns_but_proceeds_when_owned_resources_exist`
+- `test/user/test_user_tenant_sync_service.py::TestRelocateHappy::test_permission_rewrite_revokes_old_and_grants_new`
+- `test/user/test_user_tenant_sync_service.py::TestRelocateHappy::test_skip_permission_grant_for_root_leaf`
+- `test/user/test_user_tenant_sync_service.py::TestBlocked::test_not_blocked_when_enforce_true_but_no_owned_resources`
+- `test/user/test_user_tenant_sync_service.py::TestFirstTimeSync::test_no_active_row_no_count_no_delete`
+- `test/user/test_user_tenant_sync_service.py::TestFirstTimeSync::test_first_time_sync_root_writes_no_audit_reason_set`
+- `test/user/test_user_tenant_sync_service.py::TestPermissionTolerance::test_permission_exception_does_not_abort_sync`
+- `test/user/test_user_update_tenant_admin_gate.py::test_tenant_admin_helper_defined`
+- `test/user/test_user_update_tenant_admin_gate.py::test_tenant_admin_scoped_user_ids_helper_defined`
+- `test/department/test_apply_local_primary_dept_sync.py::test_no_old_primary_inserts_and_syncs`
+- `test/department/test_apply_local_primary_dept_sync.py::test_cross_tenant_swap_dispatches_fga_and_syncs`
+- `test/department/test_apply_local_primary_dept_sync.py::test_relocate_blocked_propagates`
+- `test/department/test_department_api.py::TestDepartmentAPI::test_create_department_parent_not_found`
+- `test/department/test_department_api.py::TestDepartmentAPI::test_get_department_not_found`
+- `test/department/test_department_api.py::TestDepartmentAPI::test_update_department_not_found`
+- `test/department/test_department_api.py::TestDepartmentAPI::test_delete_department_not_found`
+- `test/department/test_department_api.py::TestDepartmentAPI::test_move_department_not_found`
+- `test/department/test_department_api.py::TestDepartmentAPI::test_get_members_not_found`
+- `test/department/test_department_api.py::TestDepartmentAPI::test_add_members_not_found`
+- `test/department/test_department_api.py::TestDepartmentAPI::test_remove_member_not_found`
+- `test/department/test_department_api.py::TestDepartmentPermission::test_permission_denied_delete`
+- `test/department/test_department_api.py::TestDepartmentPermission::test_permission_denied_members`
+- `test/department/test_department_dao.py::TestDepartmentModel::test_create_department`
+- `test/department/test_department_dao.py::TestDepartmentModel::test_dept_id_unique`
+- `test/department/test_department_dao.py::TestDepartmentModel::test_get_children`
+- `test/department/test_department_dao.py::TestDepartmentModel::test_get_subtree`
+- `test/department/test_department_dao.py::TestDepartmentModel::test_get_subtree_ids`
+- `test/department/test_department_dao.py::TestDepartmentModel::test_update_paths_batch`
+- `test/department/test_department_dao.py::TestDepartmentModel::test_check_name_duplicate_true`
+- `test/department/test_department_dao.py::TestDepartmentModel::test_check_name_duplicate_false`
+- `test/department/test_department_dao.py::TestDepartmentModel::test_check_name_duplicate_exclude`
+- `test/department/test_department_dao.py::TestUserDepartmentModel::test_add_member`
+- `test/department/test_department_dao.py::TestUserDepartmentModel::test_add_member_duplicate`
+- `test/department/test_department_dao.py::TestUserDepartmentModel::test_remove_member`
+- `test/department/test_department_dao.py::TestUserDepartmentModel::test_get_members_paged`
+- `test/department/test_department_dao.py::TestUserDepartmentModel::test_get_member_count`
+- `test/department/test_department_dao.py::TestUserDepartmentModel::test_get_user_departments`
+- `test/department/test_department_dao.py::TestUserDepartmentModel::test_check_member_exists`
+- `test/department/test_department_dao.py::TestTenantMountFields::test_defaults_are_unmounted`
+- `test/department/test_department_dao.py::TestTenantMountFields::test_mount_persists`
+- `test/department/test_department_dao.py::TestTenantMountFields::test_aget_mount_point_select_semantics`
+- `test/department/test_department_dao.py::TestTenantMountFields::test_nearest_ancestor_select_semantics`
+- `test/department/test_department_dao.py::TestTenantMountFields::test_no_ancestor_mount_returns_none`
+- `test/department/test_department_dao_lazy.py::TestAgetChildren::test_active_only_by_default`
+- `test/department/test_department_dao_lazy.py::TestAgetChildren::test_include_archived`
+- `test/department/test_department_dao_lazy.py::TestAgetChildren::test_root_layer_parent_none`
+- `test/department/test_department_dao_lazy.py::TestAgetChildrenExistence::test_returns_only_parents_with_active_children`
+- `test/department/test_department_dao_lazy.py::TestAgetChildrenExistence::test_include_archived_counts_archived_child`
+- `test/department/test_department_dao_lazy.py::TestAgetByNameLike::test_substring_match_unscoped`
+- `test/department/test_department_dao_lazy.py::TestAgetByNameLike::test_scope_path_prefixes`
+- `test/department/test_department_dao_lazy.py::TestAgetByNameLike::test_archived_excluded_by_default`
+- `test/department/test_department_dao_lazy.py::TestAgetByNameLike::test_limit_caps_results`
+- `test/department/test_department_lazy_api.py::TestChildrenLayer::test_root_layer_sys_admin`
+- `test/department/test_department_lazy_api.py::TestChildrenLayer::test_children_of_node_active_only`
+- `test/department/test_department_lazy_api.py::TestChildrenLayer::test_children_include_archived`
+- `test/department/test_department_lazy_api.py::TestChildrenLayer::test_has_children_false_for_leaf`
+- `test/department/test_department_lazy_api.py::TestChildrenLayer::test_leaf_children_empty`
+- `test/department/test_department_lazy_api.py::TestSearch::test_prunes_to_ancestor_chain`
+- `test/department/test_department_lazy_api.py::TestSearch::test_case_insensitive`
+- `test/department/test_department_lazy_api.py::TestSearch::test_truncated_when_over_limit`
+- `test/department/test_department_lazy_api.py::TestPathTree::test_locate_deep_node_sys_admin`
+- `test/department/test_department_lazy_api.py::TestPathTree::test_locate_missing_node_sys_admin`
+- `test/department/test_department_lazy_api.py::TestDeptAdminScope::test_root_layer_is_admin_dept`
+- `test/department/test_department_lazy_api.py::TestDeptAdminScope::test_search_ancestors_clamped_to_scope`
+- `test/department/test_department_lazy_api.py::TestDeptAdminScope::test_path_tree_clamped`
+- `test/department/test_department_lazy_api.py::TestDeptAdminScope::test_children_out_of_scope_parent_denied_no_leak`
+- `test/department/test_department_lazy_api.py::TestDeptAdminScope::test_locate_out_of_scope_denied_no_leak`
+- `test/department/test_department_service.py::TestCreateDepartment::test_create_department_success`
+- `test/department/test_department_service.py::TestCreateDepartment::test_create_department_name_duplicate`
+- `test/department/test_department_service.py::TestCreateDepartment::test_create_department_parent_not_found`
+- `test/department/test_department_service.py::TestGetTree::test_get_tree`
+- `test/department/test_department_service.py::TestUpdateDepartment::test_update_department_name`
+- `test/department/test_department_service.py::TestUpdateDepartment::test_update_department_source_readonly`
+- `test/department/test_department_service.py::TestDeleteDepartment::test_delete_department_success`
+- `test/department/test_department_service.py::TestDeleteDepartment::test_delete_department_has_children`
+- `test/department/test_department_service.py::TestDeleteDepartment::test_delete_department_has_members`
+- `test/department/test_department_service.py::TestMoveDepartment::test_move_department_success`
+- `test/department/test_department_service.py::TestMoveDepartment::test_move_department_circular`
+- `test/department/test_department_service.py::TestMemberManagement::test_add_members_batch`
+- `test/department/test_department_service.py::TestMemberManagement::test_add_members_duplicate`
+- `test/department/test_department_service.py::TestMemberManagement::test_get_members_paged`
+- `test/department/test_department_service.py::TestMemberManagement::test_remove_member`
+- `test/department/test_department_service.py::TestMemberManagement::test_remove_member_not_found`
+- `test/department/test_department_service.py::TestRootDepartment::test_create_root_department`
+- `test/department/test_department_service.py::TestRootDepartment::test_create_root_department_exists`
+- `test/department/test_department_sso_dao.py::TestDepartmentF014Fields::test_defaults`
+- `test/department/test_department_sso_dao.py::TestDepartmentF014Fields::test_soft_delete_flag_persists`
+- `test/department/test_department_sso_dao.py::TestGetBySourceExternalId::test_returns_active_row`
+- `test/department/test_department_sso_dao.py::TestGetBySourceExternalId::test_returns_archived_row_no_is_deleted_filter`
+- `test/department/test_department_sso_dao.py::TestGetBySourceExternalId::test_source_isolation`
+- `test/department/test_department_sso_dao.py::TestUpsertByExternalId::test_insert_when_missing`
+- `test/department/test_department_sso_dao.py::TestUpsertByExternalId::test_update_preserves_mount_fields`
+- `test/department/test_department_sso_dao.py::TestUpsertByExternalId::test_resurrect_clears_is_deleted`
+- `test/department/test_department_sso_dao.py::TestArchiveByExternalId::test_archive_flips_flags_and_ts`
+- `test/department/test_department_sso_dao.py::TestArchiveByExternalId::test_archive_missing_returns_no_row`
+- `test/department/test_dept_member_add_sync.py::test_aadd_members_secondary_path_skips_sync`
+- `test/department/test_dept_member_add_sync.py::test_aadd_members_primary_path_routes_to_change_primary`
+- `test/department/test_dept_member_add_sync.py::test_aadd_members_primary_propagates_relocate_blocked`
+- `test/department/test_dept_member_add_sync.py::test_acreate_local_member_triggers_sync_for_new_user`
+- `test/department/test_init_root_department.py::TestInitDefaultRootDepartment::test_init_creates_root_department`
+- `test/department/test_init_root_department.py::TestInitDefaultRootDepartment::test_init_idempotent`
+- `test/tenant/test_sync_user_scope_clear.py::test_cross_tenant_relocation_clears_scope`
+- `test/tenant/test_sync_user_scope_clear.py::test_same_tenant_noop_does_not_clear_scope`
+- `test/tenant/test_sync_user_scope_clear.py::test_scope_clear_failure_does_not_break_relocation`
+- `test/tenant/test_tenant_bypass_filter_guard.py::test_bypass_tenant_filter_must_carry_tenant_id_predicate`
+- `test/tenant/test_tenant_id_default_guard.py::test_tenant_id_default_must_be_none`
+- `test/tenant/test_tenant_mount_service.py::TestMountChild::test_happy_path_creates_child_and_sets_mount`
+- `test/tenant/test_tenant_mount_service.py::TestUnmountChild::test_unmount_migrates_resources_archives_and_unsets`
+- `test/tenant/test_tenant_mount_service.py::TestMountChildSubtreeSync::test_mount_invokes_subtree_sync_and_records_counts`
+- `test/tenant/test_tenant_mount_service_transaction.py::test_mount_child_writes_root_dept_id_and_single_commit`
diff --git a/features/v3.0.0/078-platform-shared-ui/cleanup.md b/features/v3.0.0/078-platform-shared-ui/cleanup.md
new file mode 100644
index 0000000000..0b12f9a4d5
--- /dev/null
+++ b/features/v3.0.0/078-platform-shared-ui/cleanup.md
@@ -0,0 +1,120 @@
+# Migrated UI cleanup
+
+Historical cleanup record. The subsequent2026-10-20 user decision supersedes
+keeping primitive adapters: see [direct import plan](direct-import-plan.md).
+
+Scope: platform only. No client/shared library implementation changes, package
+installation or dependency removal. Keep accepted button/input/notice adapters:
+they delegate to @bisheng/ui and preserve existing call contracts.
+
+Removed8 unused legacy UI files,35 unreferenced local icon implementation files
+and35 SVG assets used exclusively by those deleted icons (78 files total).
+Pruned unused named exports in the local icon barrel. Scanned static imports,
+re-exports, dynamic literal imports and test mocks before deleting; followed
+exclusive asset references. A second pass found no orphan bs-ui modules.
+
+Retain lucide-react:109 platform source files still import it, including
+unmatched business icons and legacy compound controls. Retain used local icon
+modules, especially brand loading/GitHub/file formats. A blanket removal would
+break imports. Retain old Switch/Checkbox/Tabs/Tooltip/Badge where shared
+permission/selector/compound-control internals still call them; this cleanup
+is not a new migration wave. Do not claim the whole legacy library is gone.
+
+Verification: whole frontend typecheck passed; scoped icon-barrel ESLint and
+git diff --check passed. Regression/build results below.
+
+## Deleted files
+
+- `src/frontend/platform/src/components/bs-ui/dialog/DialogForceUpdate.tsx`
+- `src/frontend/platform/src/components/bs-ui/input/editTitle.tsx`
+- `src/frontend/platform/src/components/bs-ui/multiSelect.tsx/Example.tsx`
+- `src/frontend/platform/src/components/bs-ui/radio/radioCard.tsx`
+- `src/frontend/platform/src/components/bs-ui/radio-group.tsx`
+- `src/frontend/platform/src/components/bs-ui/sharedBadge/index.tsx`
+- `src/frontend/platform/src/components/bs-ui/toggle-group.tsx`
+- `src/frontend/platform/src/components/bs-icons/DownIcon.tsx`
+- `src/frontend/platform/src/components/bs-icons/addTo/index.tsx`
+- `src/frontend/platform/src/components/bs-icons/avatar/index.tsx`
+- `src/frontend/platform/src/components/bs-icons/bookOpen/index.tsx`
+- `src/frontend/platform/src/components/bs-icons/clear/index.tsx`
+- `src/frontend/platform/src/components/bs-icons/dashboard/index.tsx`
+- `src/frontend/platform/src/components/bs-icons/down/index.tsx`
+- `src/frontend/platform/src/components/bs-icons/en/index.tsx`
+- `src/frontend/platform/src/components/bs-icons/expand/index.tsx`
+- `src/frontend/platform/src/components/bs-icons/form/index.tsx`
+- `src/frontend/platform/src/components/bs-icons/go/index.tsx`
+- `src/frontend/platform/src/components/bs-icons/label/index.tsx`
+- `src/frontend/platform/src/components/bs-icons/menu/application/index.tsx`
+- `src/frontend/platform/src/components/bs-icons/menu/approval/index.tsx`
+- `src/frontend/platform/src/components/bs-icons/menu/dshDesktop/index.tsx`
+- `src/frontend/platform/src/components/bs-icons/menu/evaluation/index.tsx`
+- `src/frontend/platform/src/components/bs-icons/menu/label/index.tsx`
+- `src/frontend/platform/src/components/bs-icons/menu/log/index.tsx`
+- `src/frontend/platform/src/components/bs-icons/menu/model/index.tsx`
+- `src/frontend/platform/src/components/bs-icons/menu/technology/index.tsx`
+- `src/frontend/platform/src/components/bs-icons/menu/tenant/index.tsx`
+- `src/frontend/platform/src/components/bs-icons/moon/index.tsx`
+- `src/frontend/platform/src/components/bs-icons/moveOne/index.tsx`
+- `src/frontend/platform/src/components/bs-icons/office/index.tsx`
+- `src/frontend/platform/src/components/bs-icons/plus/index.tsx`
+- `src/frontend/platform/src/components/bs-icons/plusBox/index.tsx`
+- `src/frontend/platform/src/components/bs-icons/prompt/index.tsx`
+- `src/frontend/platform/src/components/bs-icons/quit/index.tsx`
+- `src/frontend/platform/src/components/bs-icons/save/index.tsx`
+- `src/frontend/platform/src/components/bs-icons/search/index.tsx`
+- `src/frontend/platform/src/components/bs-icons/send/index.tsx`
+- `src/frontend/platform/src/components/bs-icons/tab/index.tsx`
+- `src/frontend/platform/src/components/bs-icons/thumbs/index.tsx`
+- `src/frontend/platform/src/components/bs-icons/toast/index.tsx`
+- `src/frontend/platform/src/components/bs-icons/user/index.tsx`
+- `src/frontend/platform/src/components/bs-icons/addTo/AddTo.svg`
+- `src/frontend/platform/src/components/bs-icons/avatar/Avatar.svg`
+- `src/frontend/platform/src/components/bs-icons/bookOpen/BookOpen.svg`
+- `src/frontend/platform/src/components/bs-icons/clear/Clear.svg`
+- `src/frontend/platform/src/components/bs-icons/down/DropDown.svg`
+- `src/frontend/platform/src/components/bs-icons/en/En.svg`
+- `src/frontend/platform/src/components/bs-icons/form/Form.svg`
+- `src/frontend/platform/src/components/bs-icons/go/Go.svg`
+- `src/frontend/platform/src/components/bs-icons/label/Label.svg`
+- `src/frontend/platform/src/components/bs-icons/menu/application/Application.svg`
+- `src/frontend/platform/src/components/bs-icons/menu/evaluation/Evaluation.svg`
+- `src/frontend/platform/src/components/bs-icons/menu/label/Label.svg`
+- `src/frontend/platform/src/components/bs-icons/menu/log/Log.svg`
+- `src/frontend/platform/src/components/bs-icons/menu/model/Model.svg`
+- `src/frontend/platform/src/components/bs-icons/menu/technology/Technology.svg`
+- `src/frontend/platform/src/components/bs-icons/moon/Moon.svg`
+- `src/frontend/platform/src/components/bs-icons/moveOne/MoveOne.svg`
+- `src/frontend/platform/src/components/bs-icons/office/Word.svg`
+- `src/frontend/platform/src/components/bs-icons/plus/Plus.svg`
+- `src/frontend/platform/src/components/bs-icons/plusBox/PlusBox.svg`
+- `src/frontend/platform/src/components/bs-icons/plusBox/PlusBox-dark.svg`
+- `src/frontend/platform/src/components/bs-icons/prompt/Prompt.svg`
+- `src/frontend/platform/src/components/bs-icons/quit/Quit.svg`
+- `src/frontend/platform/src/components/bs-icons/save/icon.svg`
+- `src/frontend/platform/src/components/bs-icons/search/Search.svg`
+- `src/frontend/platform/src/components/bs-icons/send/Send.svg`
+- `src/frontend/platform/src/components/bs-icons/tab/icon.svg`
+- `src/frontend/platform/src/components/bs-icons/thumbs/copy.svg`
+- `src/frontend/platform/src/components/bs-icons/thumbs/like.svg`
+- `src/frontend/platform/src/components/bs-icons/thumbs/unLike.svg`
+- `src/frontend/platform/src/components/bs-icons/toast/Info.svg`
+- `src/frontend/platform/src/components/bs-icons/toast/Success.svg`
+- `src/frontend/platform/src/components/bs-icons/toast/Warning.svg`
+- `src/frontend/platform/src/components/bs-icons/toast/Error.svg`
+- `src/frontend/platform/src/components/bs-icons/user/User.svg`
+- `src/frontend/platform/src/components/bs-ui/toggle.tsx`
+
+## Regression results
+
+49 tests in9 files passed: shared control/notice adapters, class merging,
+sidebar/workspace/menu permissions, segmented header navigation, lazy tool
+permissions, service-account grants/mutations and subject user search.
+Updated the old24px sidebar assertion to the user-requested16px size.
+
+An additional existing knowledgeSpaceGrantSubjects suite has2 assertion
+mismatches: department subjects now carry path metadata; user-group search
+uses50-item paging plus cancellation instead of the old200-item call. The
+implementation files involved were not edited in this cleanup; leave these
+expectations to the permission work rather than changing its contract here.
+
+Production platform build passed in44.28s (existing Ace/eval/chunk warnings).
diff --git a/features/v3.0.0/078-platform-shared-ui/direct-import-plan.md b/features/v3.0.0/078-platform-shared-ui/direct-import-plan.md
new file mode 100644
index 0000000000..ed1d570dab
--- /dev/null
+++ b/features/v3.0.0/078-platform-shared-ui/direct-import-plan.md
@@ -0,0 +1,83 @@
+# Direct shared UI imports — 2026-10-20
+
+User explicitly supersedes the compatibility-path requirement: all platform
+button/input/notice primitives must import @bisheng/ui directly. Shared styles
+and APIs remain unchanged. Input limits follow the shared soft-limit design;
+business submission validation remains required. Existing unrelated changes
+and the Vite process are preserved. No commit or push.
+
+1. Capture current import/call inventory and save only touched sources to /tmp.
+   Move actual business compositions (numeric stepper, labeled secret/numeric
+   fields, editable list) into bs-comp; their primitives import shared UI.
+2. Replace primitive imports and LoadButton alias; normalize black variant;
+   preserve loading submission blocking at callers. No wrapper/re-export alias.
+3. Move Input/Textarea shell/content classes to correct shared props; use
+   shared search/password affordances with localized labels. Trace maxLength
+   and numeric business handlers and enforce invalid submissions at source.
+4. Convert each legacy notice call directly to notification.open or toast.show;
+   flatten multi-line descriptions explicitly; remove useToast and mount the
+   shared Toaster/Notifier once with localized labels. Preserve API interceptor
+   errors and shared de-duplication. No production normalization bridge.
+5. Remove old entry files, update docs/tests/mocks, verify zero production old
+   imports/hooks. Run interaction regressions, scoped lint, frontend typecheck,
+   i18n parity and production build serially. Record unrelated failures.
+6. One fresh read-only final review of the migration; fix material findings and
+   provide a page-oriented acceptance list.
+
+Progress: inventory complete (339 consuming files;387 notification calls).
+Baseline: shared-ui/menus/grants49 tests green from previous cleanup. Two
+knowledgeSpaceGrantSubjects assertions already disagree with current permission
+code; preserve that unrelated work and report separately.
+
+## Delivered
+
+- Removed platform primitive entry points: bs-ui/button/index,
+  bs-ui/input/index, bs-ui/toast/index and toast/use-toast. Primitive callsites,
+  including HTTP interceptors, import @bisheng/ui directly. Production
+  useToast/LoadButton calls are gone; literal button variants/sizes use the
+  canonical shared axes. InputGroup/action/upload/title-edit compositions are
+  business controls, not compatibility entry points, and use shared primitives.
+- Moved real business controls into bs-comp/formFields: NumberStepper,
+  InputList, PassInput and NonNegativeInput. They keep validation/labels/list
+  identifiers/decimal stepping, with no primitive re-export layer.
+- Numeric validation is a pure business utility, not an input wrapper. Moved
+  old numeric bounds to affected callsites, including dynamic node types;
+  retained pre-existing shared quota fields' editable invalid drafts.
+- Length limits use shared soft-limit feedback. Added submit/confirm guards
+  where old native caps had been the only protection, including workbench,
+  model descriptions, reports, metadata filters, roles, brand names, accounts,
+  tenant edits and chart names. Existing business validators stay in place.
+- Notices directly use notification.open or toast.show, flattening arrays with
+  newlines. The app mounts one Toaster and one Notifier with translated labels.
+  No live production action/duration/update-handle consumer required a bridge.
+- Updated platform AGENTS.md, feature spec and plan to supersede retaining
+  adapters. No backend, dependency, or shared UI implementation changes in this
+  follow-up. Existing unrelated working-tree changes preserved; no commit/push.
+
+## Verification
+
+- 181 tests passed in36 files, one worker; covers direct-import boundary,
+  shared queues, fields/compositions, dynamic numeric bounds, auth, grants,
+  workbench, evaluation, tenants, system/desktop and common selectors.
+- Import-boundary test was observed RED before conversion, GREEN afterwards.
+  Dynamic InputItem upper/lower-bound test was RED before its business guard,
+  GREEN after; pure numeric bounds tests cover empty drafts and fractions.
+- Whole frontend typecheck passed: platform671 strict files, client1368,
+  file-viewer tsc. i18n check passed with no new drift.
+- Scoped ESLint retains one existing DepartmentUsersSelect effect-dependency
+  error; zero additional errors. No suppressions added. git diff --check clean.
+- Existing knowledgeSpaceGrantSubjects assertion drift (two mismatches in the
+  prior cleanup record) remains outside this migration's acceptance suite.
+- Isolated Chrome:1001-character workbench input visibly flags1000 limit and
+  cannot POST; valid correction saves; dynamic numeric max rejects6 and accepts5;
+  password reveal preserves value; shared Toast and Notification display
+  together without page errors. All API calls intercepted, no120 writes.
+  Fixture files removed and browser closed. Vite started on port3001 with the
+  authorized120:3002 proxy because no server was listening when checked.
+- Independent read-only review found missing business bounds caused by removing
+  native caps. These were addressed in the callers, including the final dynamic
+  node max, workbench maxTokens, and raw chart-name length checks. No additional
+  important notice, ref or rendering regression identified.
+
+
+Final production build passed in59.88s (existing Ace/eval/chunk warnings only).
diff --git a/features/v3.0.0/078-platform-shared-ui/frontend-foundation-plan.md b/features/v3.0.0/078-platform-shared-ui/frontend-foundation-plan.md
new file mode 100644
index 0000000000..86af5c5bde
--- /dev/null
+++ b/features/v3.0.0/078-platform-shared-ui/frontend-foundation-plan.md
@@ -0,0 +1,47 @@
+# Frontend foundation consolidation
+
+Approved scope: consolidate platform/client foundation configuration on feat/3.0.0 and upgrade compatible stable npm dependencies. This is an internal refactor/dependency maintenance track (SDD Guide §1); application URLs, API contracts, state libraries and persistence remain unchanged.
+
+## Decisions
+
+- Shared PostCSS, ESLint policy and TypeScript base live in frontend/config. App-specific aliases, test globals, legacy strict exemptions and import restrictions remain local.
+- Both apps use the Vite 6.4 security-maintained line and Babel React plugin. Keep the app-specific build chunks, PWA, SVGR, editor assets, paths and ports. Share proxy safety and workspace/app environment loading; preserve old env variable names as fallbacks.
+- React 18, Tailwind 3, TypeScript 5 and Node 20 remain. Compatible stable versions are queried from the official npm registry; avoid a blind latest upgrade. React 19/Tailwind 4/TS 7/Vite 8, archived state libraries, Jest migration and PDF API migration require separate work.
+- Use catalog for common dependencies; both apps and the source-shipped UI use tailwind-merge 2.6 with the semantic font-size extension. Remove outdated Vite/Rollup downgrade overrides after checking builds, retain unrelated targeted security overrides.
+- Shared preset owns fonts/type/color/radius/interaction semantics. Preserve legacy color aliases needed by existing pages; remove duplicate Tailwind directives and global scrollbar overrides, adopt system fonts. Evaluate global important removal against generated CSS and runtime behavior; record concrete exceptions rather than silently claiming full visual convergence.
+- No automatic commit/push for this new request. No test checklist document.
+
+## Tasks
+
+- [x] Capture typecheck baseline, audit actual resolved configuration and stable registry versions.
+- [x] Extract shared lint/PostCSS/TS config; remove stale TS output and path options.
+- [x] Share Vite proxy and environment helpers; unify Vite/compiler with app-specific plugins intact.
+- [x] Consolidate foundation styles and class merging; verify typography/color coexistence, important and scroll behavior.
+- [x] Upgrade/catalog compatible packages and regenerate lockfile; verify reproducible install.
+- [x] Run configuration/proxy regression tests, relevant app tests, typecheck/lint/i18n, both production builds, and independent review. Record legacy failures and deferred major upgrades.
+
+## Baseline
+
+- Clean start at 8de501334 on feat/3.0.0.
+- Installed: Vite platform 5.4.21/client 6.4.1; both TS 5.9.3, Tailwind 3.4.19, React 18.3.1, PostCSS 8.5.22.
+- Platform typecheck passes; client baseline captured in /tmp/config-baseline-bishengchat-types.log.
+- Registry audit: /tmp/bisheng-dependency-audit.json. Official guidance: https://vite.dev/releases ; https://www.npmjs.com/package/tailwind-merge .
+
+## Implementation and review
+
+- Common configuration lives in `src/frontend/config/`. Vite helpers mirror merged public-prefix env values to browser code as well as proxy configuration; unprefixed values are never injected. Old API/MinIO names remain supported.
+- Single platform Tailwind entry now imports App.css plus legacy business styles. Removed global important and all global scrollbar overrides, the redundant font overrides and unused font-face. Shared preset owns the system font and explicit no-scrollbar utility. Legacy feature colors/HSL aliases remain per app to avoid changing existing business semantics.
+- Class merging uses one shared v2.6 implementation through @bisheng/ui/cn. The headlessui anchor type now derives from its public ListboxOptions props, required by bundler resolution.
+- Catalog aligns React 18.3.1, TS 5.9.3, Tailwind 3.4.19, Vite 6.4.5, plugin-react 4.7.0, tailwind-merge 2.6.1, PostCSS 8.5.31, Autoprefixer 10.6.1, Axios 1.20.0, DOMPurify 3.4.17 and compatible Radix versions. Rollup 4.64.12 replaces the old downgrade. Rspress stays on 1.47.2; platform has its required testing-library/dom 10.4.2.
+- Independent reviewer caught the remaining App.css Tailwind/scrollbar rules. Both corrected; regression observed red (2 failed / 9 passed), then green (11 passed). Reviewer rechecked fixes and public env filtering; no actionable issue remains.
+- Initial verification: platform 46 suites / 237 tests and both builds pass. Client Jest initially exposed the documented unbuilt optional canvas package after skip-scripts install: removed only the local broken jsdom peer link so the supported canvas-free fallback can run. No source workaround.
+- Scoped source lint is clean; full lint remains the baseline platform 83 / client 89 errors, with no suppressions added. Platform/shared packages typecheck pass; client retains exactly the 4 pre-existing skill-center type errors. i18n parity passes.
+- Frozen offline install proved the local cache was incomplete, not lockfile drift; following up with a clean online frozen install and final tests/builds.
+
+## Final verification
+
+- Clean online `pnpm install --frozen-lockfile --ignore-scripts --registry=https://registry.npmjs.org` passes; lockfile resolution is skipped and no peer mismatch is reported. This local verification intentionally skips third-party install scripts; the unchanged CI/Docker installation still uses normal lifecycle scripts.
+- After the review fixes: configuration 11/11 pass, configuration strict types pass, both production builds pass (platform 35.02s / client 1m13s). Old bundle-size, editor eval and missing optional PWA icon glob warnings remain.
+- No browser visual inspection was performed in this change. Review the platform's fonts, native scrollbars and CSS precedence on actual business pages before release; business-specific colors and themes are intentionally retained.
+
+- Final client Jest verification: 3 suites / 14 tests pass after disabling the local unbuilt optional canvas peer links; total regression count 262 (237 platform + 14 client + 11 configuration). No suppression baselines were changed. User authorized commit and push to feat/3.0.0 after verification.
diff --git a/features/v3.0.0/078-platform-shared-ui/module-plan.md b/features/v3.0.0/078-platform-shared-ui/module-plan.md
new file mode 100644
index 0000000000..621033402e
--- /dev/null
+++ b/features/v3.0.0/078-platform-shared-ui/module-plan.md
@@ -0,0 +1,76 @@
+# Platform component migration by business module
+
+User-approved cadence (2026-10-19): finish one whole module for user acceptance. Since 2026-10-19, develop the
+next module while the user tests the previous one; acceptance fixes take priority. Do not pause for acceptance after individual pages.
+Use finalized shared components, preserve explicit library gaps, and replace
+business-owned icons with bisheng-icons equivalents; keep an old icon only when
+no suitable equivalent exists. As updated on2026-10-20, buttons/inputs/notices import @bisheng/ui directly;
+the temporary compatibility entry points have been removed. Business-specific
+field compositions remain under bs-comp/formFields.
+
+| Order | Module | Scope | Status |
+| --- | --- | --- | --- |
+| 1 | Model management | Provider list/editor, system model settings, fine-tuning and its dialogs, API-document viewer | User accepted 2026-10-19 |
+| 2 | Knowledge | Libraries, files, upload/parse, adjustments, Q&A | Functional testing: no issues found; design review pending |
+| 3 | Application building | Apps, templates, assistant editor, tools | Ready for module acceptance |
+| 4 | Workflow | Canvas, node settings, debug and results | User testing |
+| 5 | Evaluation | Evaluations, creation/results, datasets | User testing |
+| 6 | Annotation | Tasks, task apps, conversation annotation | User testing |
+| 7 | Logs | App/system logs and conversation details | User testing |
+| 8 | Dashboard | List, editor, shared viewer | User testing |
+| 9 | System administration | Organization/members, users/groups, roles, sync, API accounts/tokens, config/theme | User testing |
+| 10 | Approval | Approval tabs and flow configuration | Ready for module acceptance |
+| 11 | Tenants | List, creation, editing and management | User testing |
+| 12 | Desktop administration | License/seats, quotas, usage, access, plugins | User testing |
+| 13 | Other entries | Workbench configuration, reports/resources, auth/tenant selection, permissions/errors, navigation/profile | Ready for module acceptance |
+
+
+## Explicit retained implementations
+
+The library has no finalized Table, Select/Cascader/Combobox, ordinary Dialog,
+DropdownMenu, Pagination, Progress or Card replacement. Keep these and their
+internal controls/icons, plus the existing confirmation service (the finalized
+Confirm is client-context-bound, not exported by @bisheng/ui). Keep the chart's
+own Recharts tooltip. They are gaps, not completed shared-library migrations.
+Desktop administration components under ModelPage/manage/dsh belong to module 12;
+their shared model-selection dependencies receive regression checks here.
+
+Native input length/numeric validation and app request feedback continue through
+the accepted adapters. No backend endpoints or shared component styles change.
+
+## Module 1 verification (2026-10-19)
+
+- 138 tests passed: model-directory regressions (including shared selectors used
+  by desktop management), model permissions/name validation, parameter draft
+  save/cancel/JSON rejection, system-tab callbacks and child-admin request gating,
+  fine-tuning status selection stability.
+- Model-directory ESLint passed with shrink-only suppression pruning. Platform
+  strict typecheck passed. Final production build passed (35.88s); existing bundle-size
+  warning remains.
+- Independent read-only review found no newly introduced actionable issue.
+  Existing list-polling races and nested prompt-dialog trigger buttons remain
+  pre-existing limitations, outside this component migration.
+- Isolated Chromium checks with mocked APIs passed: fine-tuning filter stability,
+  invalid/valid JSON in the model-parameter dialog, web-search draft toggle,
+  connection-field rendering, real Chinese copy and 768px filter layout; no page
+  JavaScript errors. Light/dark screenshots were captured for review.
+  Temporary fixture sources removed and browser closed.
+- Real model calls, credentials, training, publish/stop/delete operations were
+  not exercised against shared 120 data during automated checks; user acceptance
+  subsequently completed on 2026-10-19.
+- Dev entry remains http://localhost:3001/model/management and uses the existing
+  120:3002 proxy. Fine-tuning route: /model/finetune; API PDF route: /model/doc.
+
+### Acceptance fixes: return tooltip
+
+- Use the bs namespace for the common return label in system settings.
+- Remove platform's legacy variable override for Tailwind white: configurable
+  theme values are HSL channels, whereas shared white text must remain literal.
+- Shared cn now supports both tailwind-merge v1 and v2 config shapes. Platform's
+  Vite prebundle resolves the shared source import to its v2 copy; without that
+  compatibility, text-body deletes text-white before CSS can apply it.
+- Verified semantic-size/color merging in unit tests, shared strict types, and
+  actual browser white-text contrast under light/dark themes with HSL --white.
+
+Module 1 acceptance: user confirmed on 2026-10-19 after status tags, return
+tooltip, field alignment and settings-button refinements. Module 2 starts next.
diff --git a/features/v3.0.0/078-platform-shared-ui/remaining-primitives-plan.md b/features/v3.0.0/078-platform-shared-ui/remaining-primitives-plan.md
new file mode 100644
index 0000000000..d12af788b8
--- /dev/null
+++ b/features/v3.0.0/078-platform-shared-ui/remaining-primitives-plan.md
@@ -0,0 +1,78 @@
+# Remaining shared primitive cleanup
+
+**Goal:** Remove legacy implementations of Textarea, Switch, Checkbox, Radio,
+Badge/Tag, Tooltip, Tabs and Breadcrumb from client and platform; callers import
+shared primitives directly. This continues the approved shared UI migration.
+
+**Architecture:** Preserve business state, navigation, selection, refs and form
+validation at call sites. Use shared controls without introducing compatibility
+wrappers. Status labels use Tag; numeric/unread indicators use Badge. Shared
+visual decisions stay owned by the design system.
+
+**Tech stack:** TypeScript, React, @bisheng/ui; each app keeps its existing stores,
+request layer and localization.
+
+**Spec:** spec.md; user-approved continuation covering the nine remaining families.
+
+## Constraints
+
+- Work on feat/3.0.0; keep unrelated changes.
+- Do not invent components, edit design specs or add UI libraries.
+- Preserve form limits and selection behavior; stop for product decisions.
+- Delete old implementations only after checking all callers and barrel exports.
+- No test checklist documents; verification evidence stays in command logs.
+
+## Tasks
+
+- [x] Migrate Switch, Checkbox and Radio imports/props, then remove old files.
+- [x] Migrate Textarea with shell/field classes and explicit submission limits.
+- [x] Migrate status labels/closable tags and actual count badges by semantics.
+- [x] Migrate tooltip triggers/content and tab/panel composition at business sites.
+- [x] Remove the unused breadcrumb primitive and orphan dashboard header; current routes are unchanged.
+- [x] Check remaining imports, focused behavioral tests, lint, types and builds.
+
+## Review focus
+
+Indeterminate selection; disabled controls; keyboard/focus and ref behavior;
+soft length limits at submit; controlled tabs and hidden-panel lifecycle;
+rich tooltip content that may require a product decision; existing navigation.
+
+## Decisions and boundaries
+
+Both client and platform now consume these basic primitives directly. Nineteen
+legacy files were removed, including the old tooltip/toggle adapters. Source
+imports and barrel exports no longer reference the removed entry points.
+
+- Status/source labels use Tag; selected options use its close action; actual
+  numeric indicators use Badge. Clickable source actions use Button.
+- Rich hover content (channel/knowledge metadata, notification user card,
+  image guide, guest application details) retains its content and hover/focus
+  interaction through the existing HoverCard, as approved by the user.
+- Preview/code mode uses Segmented; speech settings and inline authentication
+  radios use shared controls. The vertical settings navigation remains on its
+  existing implementation because the shared library only has horizontal tabs.
+- The old /d/prompts dashboard route is not registered by the current router.
+  Its orphan breadcrumb header was removed; no active navigation was changed.
+- TextareaAutosize remains an application-specific direction/autosizing
+  composition over react-textarea-autosize; it is not a duplicated visual skin.
+  Embedded Sandpack component source strings also remain isolated from app UI.
+- SelectTrigger's existing asChild mode now supplies exactly one child; selected
+  tags can have native close buttons without nesting buttons. Disabled selections
+  remain immutable, and a menu popover receives trigger events through the DOM.
+
+## Verification evidence
+
+Focused client tests cover comment limits/correction, reopen reset, locked and
+disabled selections, permission dialogs, native select behavior and preset
+opening by pointer/keyboard. The legacy preset composition was observed failing
+before the corrected composition passed. Independent review also checked refs,
+input height limits and hover-card keyboard access.
+
+Scoped lint has no errors, localization parity passes with a smaller legacy
+baseline, and production builds pass for both applications. Platform types pass;
+client typechecking retains the same four unrelated skill-center errors in
+api/skillCenter.ts and components/Skills/useSkillCenter.ts. Full-app lint still
+reports legacy errors outside the changed files. No suppressions were added;
+obsolete entries were pruned with the lint tooling. No commit or push requested.
+
+Final acceptance run: 237 platform tests in 46 files and 14 client tests in 3 files pass. Both production builds pass; direct old-entry import/export scan finds zero references. Full lint/type baseline limitations are recorded above.
diff --git a/features/v3.0.0/078-platform-shared-ui/spec.md b/features/v3.0.0/078-platform-shared-ui/spec.md
new file mode 100644
index 0000000000..2a236ef1a4
--- /dev/null
+++ b/features/v3.0.0/078-platform-shared-ui/spec.md
@@ -0,0 +1,43 @@
+# Platform shared UI — first migration batch
+
+User-approved scope (2026-10-16): develop on `feat/3.0.0`, integrate
+`@bisheng/ui`, and fully replace buttons, notices, and input fields. As updated by the user on2026-10-20, primitive imports now go directly to
+`@bisheng/ui`; the temporary platform compatibility entry points are deleted.
+Other component families are outside this batch.
+
+- AC-01: Platform compiles shared source and uses the existing semantic tokens
+  and Tailwind preset. Shared component styles and design decisions stay unchanged.
+- AC-02: Every Button imports the shared Button directly; LoadButton is removed. Loading blocks
+  resubmission; refs, slots, variants, and numeric-stepper callbacks remain usable.
+- AC-03: `message` uses shared Toast; legacy right-side `toast` uses shared
+  Notification. Preserve severity, all description lines and shared queue close/dismiss APIs,
+  and localized close/expand labels, including calls from HTTP interceptors.
+- AC-04: Input, search, password, textarea, nonnegative numeric fields, and list
+  fields render shared input controls. Preserve refs, controlled/uncontrolled
+  values, shared soft length limits with business submission checks, numeric validation, and existing callback payloads.
+- AC-05: No new UI libraries or duplicate component implementations. App-specific
+  validation, labels, and translations belong in business pages/compositions.
+- AC-06: Run component interaction tests, lint, typecheck, and a production build;
+  record pre-existing failures and any unavailable browser verification honestly.
+- AC-07: User approved platform-only theme mapping: retain the configured primary
+  color through initial/late configuration loads and live edits; derive shared
+  brand states for light/dark mode without changing shared component styles or
+  functional colors. Removing the custom primary restores shared defaults.
+
+Implementation: remove old primitive entry points; adapt parameters and enforce business validation at call sites.
+No backend changes. Shared components already have approved styles; migration is
+not a redesign. Estimate and rollout approval are in the chat preceding this file.
+
+## Page-by-page rollout
+
+User-approved on 2026-10-19: subsequent batches migrate by business module, with
+each of the 13 business modules developed completely and accepted together,
+including its pages and related dialogs/drawers. Individual pages are not
+separate user acceptance checkpoints. Missing or unfinalized
+shared components retain their existing implementations and are listed explicitly
+in the page acceptance checklist.
+
+During this migration, also replace business-owned page icons with suitable
+`bisheng-icons` equivalents. When the new library has no suitable equivalent,
+retain the existing icon; no additional approval is required for that fallback.
+This applies to the current model-list slice as well as subsequent pages.
diff --git a/features/v3.0.0/078-platform-shared-ui/test-impact-scope.md b/features/v3.0.0/078-platform-shared-ui/test-impact-scope.md
new file mode 100644
index 0000000000..d370751bd0
--- /dev/null
+++ b/features/v3.0.0/078-platform-shared-ui/test-impact-scope.md
@@ -0,0 +1,187 @@
+# 毕昇组件库升级测试影响范围
+
+基线:`feat/3.0.0`,`46a62373b`;整理日期:2026-10-23。
+
+本次升级影响后台全部 13 个业务模块,也影响 client 中使用共享基础组件的页面。测试应按业务模块覆盖列表、详情、编辑页及其弹窗/抽屉,不能只检查页面外观。工作流和助手会话的共享抽离属于同期关联改动,需额外覆盖两端会话、后台调试、标注及审计入口。
+
+## 1. 组件变更及共同测试点
+
+| 变更范围 | 测试重点 |
+| --- | --- |
+| 按钮、图标按钮、业务图标 | 主要/次要/危险操作可区分;白底和主色底的图标可见;图标尺寸、间距、hover 位置稳定;loading/禁用时不能重复提交 |
+| 输入框、密码、搜索、多行、数字输入 | 输入/回显/清空、密码显隐、中文输入法、Enter 提交;超长输入的提示和提交拦截;数字上下限、小数、空值及步进;失败后保留草稿 |
+| Toast、Notification、Alert | 成功/失败/警告显示正确,长文案可读,提示不重复、关闭正常;固定提示不遮挡操作 |
+| Checkbox、Radio、Switch | 单选/多选、全选/半选、禁用项、开关保存与回显;标签和控件对齐 |
+| Select、Dropdown、Slider | 单选/多选、搜索、滚动加载、标签移除、空列表;下拉面板遮挡、焦点/键盘选择;菜单操作不误触;滑块与数字一致 |
+| Tabs、Segmented、Tooltip、Tag、Badge | 切换后状态和数据正确;长标题/状态不挤成多行;提示可读且不被裁切;可关闭标签、计数和未读状态正确 |
+| 主题与共享样式 | 后台自定义主色、重置默认色、刷新后回显;浅色/深色;白色图标/文字对比度;弹层层级、滚动、窄屏布局 |
+
+这些是已迁移组件及其组合的回归要求,不表示上述每一类在每个页面都有使用。业务图标优先替换为 `bisheng-icons`,没有合适图标的旧实现仍保留。
+
+## 2. 后台按 13 个模块验收
+
+“重点”表示需要完整跑通主要编辑/执行流程;“常规”表示覆盖主要成功流程、失败恢复和共用控件。两类均在本次影响范围内。
+
+| 模块 | 页面和关联入口 | 核心回归 | 建议深度 |
+| --- | --- | --- | --- |
+| 1 模型管理 | 提供方列表/新增/编辑、系统模型设置、微调列表/创建/详情、API 文档 | 模型类型和参数选择、连接配置、每日调用限制、租户共享、系统模型保存;状态徽章单行、返回提示、输入对齐 | 重点 |
+| 2 知识库 | 文档/QA 知识库、文件列表、上传/解析、切片调整、QA 导入/编辑、相关配置与权限弹窗 | 搜索筛选、批量选择、上传进度/失败/重试、切片与 QA 保存、标签移除;表格斑马纹、提示图标及长文本布局 | 重点 |
+| 3 应用构建 | 应用列表、模板、助手编辑、工具列表/编辑/选择抽屉 | 创建/编辑/删除/发布,模型/知识库/工具选择,参数配置与测试;卡片图标、工具设置按钮、自动配置 loading | 重点 |
+| 4 工作流 | 画布、节点设置、表单/选择交互、代码编辑、调试抽屉和结果 | 节点拖动/连线、缩放/便签、代码编辑不溢出、参数与文件类型限制;保存/调试/停止、消息滚动、重新运行 | 重点 |
+| 5 评测 | 评测列表、创建/配置、执行结果、数据集列表及编辑/导入 | 条件与模型选择、创建/运行、状态回显、数据集上传与编辑、结果切换和分页 | 常规 |
+| 6 标注 | 标注任务列表、创建任务、任务应用、会话标注、相似问与 QA 弹窗 | 管理员创建入口,分配/状态切换/上下条;头像名称、添加相似问/QA、保存失败后保留内容 | 重点 |
+| 7 日志 | 应用日志、系统日志、工作流/助手/日常会话详情 | 时间与应用筛选、多选搜索/加载更多、详情打开、附件预览/下载;只读日志不能发送或执行 | 常规 |
+| 8 看板 | 看板列表、编辑器、元素配置、发布、分享查看 | 菜单 hover 显示且位置固定;元素拖动/缩放、配置保存;从列表进入和直接打开编辑链接都能发布 | 重点 |
+| 9 系统管理 | 组织/成员、用户/用户组、角色权限、同步、API 账号/Token、系统配置、品牌主题 | 新建/编辑/授权/筛选,批量与半选、禁用项;创建加号可见;品牌卡片边距、Loading 配置和主题映射 | 重点 |
+| 10 审批 | 审批列表/详情、场景配置、分支条件、流程/节点/审批人配置 | 场景/流程切换、清空条件、绑定流程、节点模式与审批人选择、未保存确认;失败不误关闭/成功跳转 | 重点 |
+| 11 租户 | 租户列表、创建/编辑、租户成员与管理弹窗 | 搜索/分页、创建/编辑/启停、配额填写、成员选择;根租户/子租户及只读项显示正确 | 常规 |
+| 12 桌面管理 | 许可证/席位、配额/用量、接入配置、插件管理 | 配额输入、模型与接入选择、插件配置、状态/用量显示;按部署能力检查开放入口 | 常规 |
+| 13 其他公共入口 | 工作台配置、报告/资源查看、登录/重置密码/租户选择、权限/错误页、导航/个人菜单 | 登录跳转、表单校验、菜单/Tooltip、个人设置;长内容/空状态/错误状态;报告与资源预览 | 常规 |
+
+后台主要入口以当前路由为准:模型 `/model/management`,知识库 `/filelib`,应用 `/build/apps`,工具 `/build/tools`,工作流 `/flow/:id`,助手 `/assistant/:id`,评测 `/evaluation`,数据集 `/dataset`,标注 `/label`,日志 `/log`,看板 `/dashboard`,系统 `/sys`,审批 `/approval`,租户 `/tenant`,桌面管理 `/dsh`。部分功能由权限、租户或版本控制,未开放的入口记录“不适用”。
+
+## 3. Client 关联影响
+
+client 的业务路由以 `/workspace` 为前缀。此次不是 client 全部业务重写,但共享组件、样式和部分旧入口的共享转接会影响以下页面。
+
+| 范围 | 测试重点 |
+| --- | --- |
+| 工作流、助手会话及独立/分享页 | 消息、开场白/推荐问题、输入区、动态表单、文件上传、工具/推理输出、停止/重试/清空;历史/只读分享与附件操作 |
+| 日常模式、任务模式 | 会话状态底座未因会话抽离而迁移;基础按钮/输入/选择/提示及共享 Markdown 仍受影响。覆盖发送、历史、模式切换、代码/文件、制品预览下载的冒烟 |
+| 知识空间 | 列表/创建/设置、文件与文件夹、搜索标签、上传、预览、分享/授权;选择器、输入长度、标签关闭、权限弹窗 |
+| 频道与文章、应用广场、技能 | 筛选搜索、配置表单、标签/开关/选项、订阅/分享、技能上传与选择等主要入口 |
+| 导航、设置及个人入口 | 侧栏/历史会话、个人菜单、提示词/收藏/侧栏资源、账号/2FA、语音/模型/显示设置、Token、权限申请与审批消息 |
+
+## 4. 同期共享会话抽离的额外范围
+
+工作流、助手以 client 展示为基准共享,不能只在 client 验收。
+
+- 后台工作流/助手编辑中的测试会话:未保存参数生效,输入/表单/文件一致,停止/清空/重试正常,多面板互不覆盖。
+- 标注会话:头像/名称、相似问和 QA 操作、问答对应正确,保存成功才更新标注状态;切换会话不串数据。
+- 审计/日志会话:能复制、预览、下载;保持只读,不能发送、执行表单或出现标注写入口。
+- 两端 Markdown:段落/列表/表格、代码高亮/复制/行距、公式、引用、生成文件卡片。输入区、参数表单和附件展示也需对齐。
+
+更细的会话用例见 [共享会话验收清单](../086-shared-app-chat/e2e-checklist.md)。
+
+## 5. 保留的旧实现及测试边界
+
+不能把“共享库已导出组件”当作“所有业务页面已替换”。当前普通表格、普通 Dialog/确认服务、部分分页/进度/卡片等仍有旧实现调用;应测试它们与新按钮、输入、选项和提示的组合,以及新旧弹层混用的层级/焦点。
+
+下列特殊选择器按约定保留旧实现,需回归原交互,不能按新普通 Select 的外观判定缺陷:
+
+| 保留入口 | 必测行为 |
+| --- | --- |
+| 知识库选择器、工作流节点知识库选择 | 知识库/文档/空间页签、面板中的业务按钮 |
+| 工作流变量选择 | 层级、变量路径、搜索/选择及值回填 |
+| 看板配色色块选择 | 色块预览、选中、保存回显 |
+| 品牌资源选择 | 图片/资源预览、切换和单项删除 |
+| 用户列表专用筛选面板 | 多选、重置、确认后应用筛选 |
+
+本次组件升级不要求重新评估模型效果、算法准确率或对全部后端 API 做压力测试;仍需验证页面操作实际提交成功、参数和权限行为未改变。错误透传、提示文案修复另有 [统一错误验收清单](../../../docs/superpowers/plans/2026-10-22-user-facing-error-messages-test-cases.md),可与上述业务流程一起执行。
+
+## 6. 测试评估建议
+
+1. 所有开放模块至少走一次“列表 → 搜索/筛选 → 新增或编辑 → 保存 → 再打开回显”,并覆盖弹窗、抽屉中的控件。
+2. 重点模块增加失败重试、重复点击、取消/切换、长文本/窄容器;工作流和看板必须实测拖动、缩放和滚动,不能只截图确认。
+3. 公共组件交互选择代表页面深入验证,不必每页重复全部键盘测试;但每种特殊组合需覆盖,例如弹窗内 Select、多选带可关闭标签、禁用按钮 Tooltip、异步搜索。
+4. 至少覆盖管理员与普通用户,涉及租户时增加子租户管理员;检查按钮可见性、禁用态和不可执行行为,不通过隐藏按钮推断后端权限正确。
+5. 主流程使用常用桌面尺寸;抽查窄屏、浅/深色、中英日长文案、默认及自定义主色。视觉细节由设计复核,功能验收通过不等同设计已签收。
+
+评估工时应按可访问页面数、角色/版本组合和失败场景注入条件计算;本清单不把历史“测过没问题”当作当前版本全部通过。
+
+## 核对依据
+
+- [13 模块迁移计划](module-plan.md)及[基础组件收口记录](remaining-primitives-plan.md)。早期计划中的“Select 尚未落地”等缺口已被后续迁移覆盖,不能继续当作现状。
+- [直接导入记录](direct-import-plan.md)及[普通 Select 后续迁移和保留清单](../086-shared-app-chat/platform-select-migration.md)。
+- 当前 platform/client 路由、业务文件中的共享库调用、旧表格/弹窗/特殊选择器调用,以及共享会话包的实际调用。
+
+## 1. 组件变更及共同测试点
+
+| 变更范围 | 测试重点 |
+| --- | --- |
+| 按钮、图标按钮、业务图标 | 主要/次要/危险操作可区分;白底和主色底的图标可见;图标尺寸、间距、hover 位置稳定;loading/禁用时不能重复提交 |
+| 输入框、密码、搜索、多行、数字输入 | 输入/回显/清空、密码显隐、中文输入法、Enter 提交;超长输入的提示和提交拦截;数字上下限、小数、空值及步进;失败后保留草稿 |
+| Toast、Notification、Alert | 成功/失败/警告显示正确,长文案可读,提示不重复、关闭正常;固定提示不遮挡操作 |
+| Checkbox、Radio、Switch | 单选/多选、全选/半选、禁用项、开关保存与回显;标签和控件对齐 |
+| Select、Dropdown、Slider | 单选/多选、搜索、滚动加载、标签移除、空列表;下拉面板遮挡、焦点/键盘选择;菜单操作不误触;滑块与数字一致 |
+| Tabs、Segmented、Tooltip、Tag、Badge | 切换后状态和数据正确;长标题/状态不挤成多行;提示可读且不被裁切;可关闭标签、计数和未读状态正确 |
+| 主题与共享样式 | 后台自定义主色、重置默认色、刷新后回显;浅色/深色;白色图标/文字对比度;弹层层级、滚动、窄屏布局 |
+
+这些是已迁移组件及其组合的回归要求,不表示上述每一类在每个页面都有使用。业务图标优先替换为 `bisheng-icons`,没有合适图标的旧实现仍保留。
+
+## 2. 后台按 13 个模块验收
+
+“重点”表示需要完整跑通主要编辑/执行流程;“常规”表示覆盖主要成功流程、失败恢复和共用控件。两类均在本次影响范围内。
+
+| 模块 | 页面和关联入口 | 核心回归 | 建议深度 |
+| --- | --- | --- | --- |
+| 1 模型管理 | 提供方列表/新增/编辑、系统模型设置、微调列表/创建/详情、API 文档 | 模型类型和参数选择、连接配置、每日调用限制、租户共享、系统模型保存;状态徽章单行、返回提示、输入对齐 | 重点 |
+| 2 知识库 | 文档/QA 知识库、文件列表、上传/解析、切片调整、QA 导入/编辑、相关配置与权限弹窗 | 搜索筛选、批量选择、上传进度/失败/重试、切片与 QA 保存、标签移除;表格斑马纹、提示图标及长文本布局 | 重点 |
+| 3 应用构建 | 应用列表、模板、助手编辑、工具列表/编辑/选择抽屉 | 创建/编辑/删除/发布,模型/知识库/工具选择,参数配置与测试;卡片图标、工具设置按钮、自动配置 loading | 重点 |
+| 4 工作流 | 画布、节点设置、表单/选择交互、代码编辑、调试抽屉和结果 | 节点拖动/连线、缩放/便签、代码编辑不溢出、参数与文件类型限制;保存/调试/停止、消息滚动、重新运行 | 重点 |
+| 5 评测 | 评测列表、创建/配置、执行结果、数据集列表及编辑/导入 | 条件与模型选择、创建/运行、状态回显、数据集上传与编辑、结果切换和分页 | 常规 |
+| 6 标注 | 标注任务列表、创建任务、任务应用、会话标注、相似问与 QA 弹窗 | 管理员创建入口,分配/状态切换/上下条;头像名称、添加相似问/QA、保存失败后保留内容 | 重点 |
+| 7 日志 | 应用日志、系统日志、工作流/助手/日常会话详情 | 时间与应用筛选、多选搜索/加载更多、详情打开、附件预览/下载;只读日志不能发送或执行 | 常规 |
+| 8 看板 | 看板列表、编辑器、元素配置、发布、分享查看 | 菜单 hover 显示且位置固定;元素拖动/缩放、配置保存;从列表进入和直接打开编辑链接都能发布 | 重点 |
+| 9 系统管理 | 组织/成员、用户/用户组、角色权限、同步、API 账号/Token、系统配置、品牌主题 | 新建/编辑/授权/筛选,批量与半选、禁用项;创建加号可见;品牌卡片边距、Loading 配置和主题映射 | 重点 |
+| 10 审批 | 审批列表/详情、场景配置、分支条件、流程/节点/审批人配置 | 场景/流程切换、清空条件、绑定流程、节点模式与审批人选择、未保存确认;失败不误关闭/成功跳转 | 重点 |
+| 11 租户 | 租户列表、创建/编辑、租户成员与管理弹窗 | 搜索/分页、创建/编辑/启停、配额填写、成员选择;根租户/子租户及只读项显示正确 | 常规 |
+| 12 桌面管理 | 许可证/席位、配额/用量、接入配置、插件管理 | 配额输入、模型与接入选择、插件配置、状态/用量显示;按部署能力检查开放入口 | 常规 |
+| 13 其他公共入口 | 工作台配置、报告/资源查看、登录/重置密码/租户选择、权限/错误页、导航/个人菜单 | 登录跳转、表单校验、菜单/Tooltip、个人设置;长内容/空状态/错误状态;报告与资源预览 | 常规 |
+
+后台主要入口以当前路由为准:模型 `/model/management`,知识库 `/filelib`,应用 `/build/apps`,工具 `/build/tools`,工作流 `/flow/:id`,助手 `/assistant/:id`,评测 `/evaluation`,数据集 `/dataset`,标注 `/label`,日志 `/log`,看板 `/dashboard`,系统 `/sys`,审批 `/approval`,租户 `/tenant`,桌面管理 `/dsh`。部分功能由权限、租户或版本控制,未开放的入口记录“不适用”。
+
+## 3. Client 关联影响
+
+client 的业务路由以 `/workspace` 为前缀。此次不是 client 全部业务重写,但共享组件、样式和部分旧入口的共享转接会影响以下页面。
+
+| 范围 | 测试重点 |
+| --- | --- |
+| 工作流、助手会话及独立/分享页 | 消息、开场白/推荐问题、输入区、动态表单、文件上传、工具/推理输出、停止/重试/清空;历史/只读分享与附件操作 |
+| 日常模式、任务模式 | 会话状态底座未因会话抽离而迁移;基础按钮/输入/选择/提示及共享 Markdown 仍受影响。覆盖发送、历史、模式切换、代码/文件、制品预览下载的冒烟 |
+| 知识空间 | 列表/创建/设置、文件与文件夹、搜索标签、上传、预览、分享/授权;选择器、输入长度、标签关闭、权限弹窗 |
+| 频道与文章、应用广场、技能 | 筛选搜索、配置表单、标签/开关/选项、订阅/分享、技能上传与选择等主要入口 |
+| 导航、设置及个人入口 | 侧栏/历史会话、个人菜单、提示词/收藏/侧栏资源、账号/2FA、语音/模型/显示设置、Token、权限申请与审批消息 |
+
+## 4. 同期共享会话抽离的额外范围
+
+工作流、助手以 client 展示为基准共享,不能只在 client 验收。
+
+- 后台工作流/助手编辑中的测试会话:未保存参数生效,输入/表单/文件一致,停止/清空/重试正常,多面板互不覆盖。
+- 标注会话:头像/名称、相似问和 QA 操作、问答对应正确,保存成功才更新标注状态;切换会话不串数据。
+- 审计/日志会话:能复制、预览、下载;保持只读,不能发送、执行表单或出现标注写入口。
+- 两端 Markdown:段落/列表/表格、代码高亮/复制/行距、公式、引用、生成文件卡片。输入区、参数表单和附件展示也需对齐。
+
+更细的会话用例见 [共享会话验收清单](../086-shared-app-chat/e2e-checklist.md)。
+
+## 5. 保留的旧实现及测试边界
+
+不能把“共享库已导出组件”当作“所有业务页面已替换”。当前普通表格、普通 Dialog/确认服务、部分分页/进度/卡片等仍有旧实现调用;应测试它们与新按钮、输入、选项和提示的组合,以及新旧弹层混用的层级/焦点。
+
+下列特殊选择器按约定保留旧实现,需回归原交互,不能按新普通 Select 的外观判定缺陷:
+
+| 保留入口 | 必测行为 |
+| --- | --- |
+| 知识库选择器、工作流节点知识库选择 | 知识库/文档/空间页签、面板中的业务按钮 |
+| 工作流变量选择 | 层级、变量路径、搜索/选择及值回填 |
+| 看板配色色块选择 | 色块预览、选中、保存回显 |
+| 品牌资源选择 | 图片/资源预览、切换和单项删除 |
+| 用户列表专用筛选面板 | 多选、重置、确认后应用筛选 |
+
+本次组件升级不要求重新评估模型效果、算法准确率或对全部后端 API 做压力测试;仍需验证页面操作实际提交成功、参数和权限行为未改变。错误透传、提示文案修复另有 [统一错误验收清单](../../../docs/superpowers/plans/2026-10-22-user-facing-error-messages-test-cases.md),可与上述业务流程一起执行。
+
+## 6. 测试评估建议
+
+1. 所有开放模块至少走一次“列表 → 搜索/筛选 → 新增或编辑 → 保存 → 再打开回显”,并覆盖弹窗、抽屉中的控件。
+2. 重点模块增加失败重试、重复点击、取消/切换、长文本/窄容器;工作流和看板必须实测拖动、缩放和滚动,不能只截图确认。
+3. 公共组件交互选择代表页面深入验证,不必每页重复全部键盘测试;但每种特殊组合需覆盖,例如弹窗内 Select、多选带可关闭标签、禁用按钮 Tooltip、异步搜索。
+4. 至少覆盖管理员与普通用户,涉及租户时增加子租户管理员;检查按钮可见性、禁用态和不可执行行为,不通过隐藏按钮推断后端权限正确。
+5. 主流程使用常用桌面尺寸;抽查窄屏、浅/深色、中英日长文案、默认及自定义主色。视觉细节由设计复核,功能验收通过不等同设计已签收。
+
+评估工时应按可访问页面数、角色/版本组合和失败场景注入条件计算;本清单不把历史“测过没问题”当作当前版本全部通过。
+
+## 核对依据
+
+- [13 模块迁移计划](module-plan.md)及[基础组件收口记录](remaining-primitives-plan.md)。早期计划中的“Select 尚未落地”等缺口已被后续迁移覆盖,不能继续当作现状。
+- [直接导入记录](direct-import-plan.md)及[普通 Select 后续迁移和保留清单](../086-shared-app-chat/platform-select-migration.md)。
+- 当前 platform/client 路由、业务文件中的共享库调用、旧表格/弹窗/特殊选择器调用,以及共享会话包的实际调用。
diff --git a/features/v3.0.0/085-web-fetch/design.md b/features/v3.0.0/085-web-fetch/design.md
new file mode 100644
index 0000000000..5ff512ecb6
--- /dev/null
+++ b/features/v3.0.0/085-web-fetch/design.md
@@ -0,0 +1,240 @@
+# Design: 网页读取内置工具
+
+> 本文是现状快照 + 关键决策。需求口径见 [spec.md](./spec.md),执行流水见 [tasks.md](./tasks.md)。
+
+**关联**: [spec.md](./spec.md) · [tasks.md](./tasks.md)
+**版本**: v3.0.0
+**最后更新**: 2026-10-19
+
+---
+
+## 1. 目标与非目标
+
+- **目标**:新增一个独立的预置内置工具「网页读取」(`tool_key = web_fetch`,参数 `url`)。它和「联网搜索」并列:管理员在内置工具设置里单独配置,搭建者在各场景勾选,agent 调用后拿到一个网页的标题和 Markdown 正文。每次成功读取的网页注册为一个网页类引用来源,复用现有网页角标的卡片、跳转和历史回放。
+- **非目标**:不改联网搜索的行为和配置;不做 DeepSeek 以外的读取引擎(只预留注册点);不由平台服务器直接抓网页;不改现有 API 工具(`jina_get_markdown`、Firecrawl)。
+
+---
+
+## 2. 关键约束
+
+- 架构铁律遵循 `docs/constitution.md` C1–C8。本功能涉及的具体点:两张工具表按租户隔离(C3);工具能否被列出和执行走 F048 权限投影(C4);秘钥不写进代码(C6);新增插入只用 ORM,不写方言 SQL(C2)。
+- **上游接口**:DeepSeek Fetch `POST https://api.deepseek.com/v1/tools/fetch`,Bearer 鉴权,请求体 `{"url": ...}`,成功返回 `{"title", "content"}`,失败返回 OpenAI 兼容错误体。2026-10-19 实测的接口行为见 §5 第 1~4 条。
+- **工具表的组织方式**:一个内置工具 = 一行工具分类(`t_gpts_tools_type`)+ 若干行工具(`t_gpts_tools`)。配置存在分类行的 `extra` 上。执行时 `ToolExecutor` 一行工具只生成一个 `BaseTool`。
+- **升级要求**:spec AC-01 要求老环境升级后不需要手工操作。老环境的工具表已有数据,种子 JSON 不会再导入,必须走启动时补数据的路径。补进来的工具还要有权限投影,否则非超级管理员列不出、也执行不了。
+- **部署形态**:默认多节点(多个 backend 进程 + Celery worker)。启动补数据只能在拿到 `init_default_data` 的 Redis 锁的那个进程里执行。
+
+---
+
+## 3. 方案对比与选定
+
+### 决策 1:独立内置工具,而不是随联网搜索引擎附带
+
+- **备选**:
+  - A. 联网搜索选 DeepSeek 引擎时,`load_tools` 额外返回一个 fetch 工具。
+  - B. 新建独立的内置工具分类和工具行。
+- **选定**:B
+- **原因**:方案 A 下,工具能力随搜索引擎切换出现或消失,管理员看不到也控制不了。而且 `ToolExecutor._init_preset_tool` 只取 `load_tools` 返回的第一个工具,多返回的工具会被静默丢弃,要让它生效就得改所有场景的工具加载链路。方案 B 沿用「一行工具对应一个 BaseTool」的现有约定,权限、勾选、运行日志都能直接复用。用户已在 Spec Discovery 中确认。
+- **何时该重新考虑**:如果以后要求「一个配置项产出一组工具」成为普遍需求,再统一改造 `ToolExecutor`,不要为单个工具开特例。
+
+### 决策 2:读取引擎按搜索引擎的同款结构抽象
+
+- **备选**:
+  - A. 只写一个 DeepSeek 实现,配置扁平存 `{api_key, base_url}`。
+  - B. 照搬 `SearchTool` 的结构:引擎基类 + 按 `type` 注册的实现类;配置存 `{type, config: {: {...}}, max_chars}`。
+- **选定**:B
+- **原因**:spec 要求以后能接其他引擎。B 和联网搜索的配置形状一致,前端表单和后端分派都能对照现有代码写;以后加引擎只需新增一个类、一个注册项和一个表单分支,存量配置不用迁移。
+- **何时该重新考虑**:某个新引擎的配置无法按「引擎名 → 字段」分组表达时(例如需要按租户动态换取令牌),再调整配置结构,并为存量配置写迁移。
+
+### 决策 3:引用来源用 WEB 类型,不用 ARTICLE
+
+- **备选**:
+  - A. `CitationType.WEB`,一个来源只有一个条目:`itemId="0"`(与搜索摘要的 `1`、`2`… 区分,见坑 12;必须是纯数字,见坑 17),`snippet` 取正文开头,`title` 取网页标题,`source` 取站点名。
+  - B. `CitationType.ARTICLE`(频道文章就是「整篇一个来源」)。
+- **选定**:A
+- **原因**:ARTICLE 是频道专用类型,必填 `articleDocId`。它没有站点名字段,platform 前端没有 article 分支(会落到文档预览),分享页匿名访问时还会被拒。WEB 的卡片、点击跳转原网页、分享页匿名解析、历史回放全部现成,前端不用改。
+- **何时该重新考虑**:如果卡片需要展示「整页正文」而不只是开头摘要,再考虑新增引用类型。
+
+### 决策 4:同一网址多次读取共用一个来源,去重表跟随工具实例
+
+- **备选**:
+  - A. 每次调用都生成新的引用 ID(与联网搜索现状一致)。
+  - B. 在每个场景的「单次回答收集器」上加「网址 → 引用 ID」表。
+  - C. 在网页读取工具的引用包装实例上缓存「规范化网址 → 已生成的注册条目」。重复读取时复用同一个 ID,并把同一批注册条目再交给当前的收集器和运行时缓存一次。
+- **选定**:C
+- **原因**:spec §3 要求同一回答里多次读取同一网址时共用一个来源。B 要改四个场景各自不同的收集器结构(助手、工作流、日常模式、任务模式的 scope 都不一样),改动面大。C 只改网页读取自己的包装层,四个场景共用一份逻辑。包装实例的生命周期已核实:助手的 agent 每个会话建一次(`common/chat/client.py:222`),跨多轮有效;工作流 agent 节点每次运行建一次;日常模式每次请求建一次(`chat_service.py:1239`);任务模式每个任务建一次。因此去重范围至少覆盖一次回答,最多覆盖一个会话,不会跨用户或跨会话。复用时重新交出注册条目,保证每条消息的持久化里都有这个来源,历史回放按消息加载时不会缺。
+- **范围说明**:只对网页读取之间去重。联网搜索结果和网页读取的同一网址仍是两个来源,因为两者的摘要内容不同,合并后卡片会对不上模型实际引用的文字(spec AC-21)。
+- **何时该重新考虑**:如果联网搜索也要做跨调用去重,再统一上收到各场景的收集器(方案 B)。
+
+### 决策 5:引用包装层统一按工具名识别,并改为透传参数
+
+- **备选**:
+  - A. 为网页读取在每个场景各写一个新的包装类。
+  - B. 抽出共享的识别函数 `is_web_citation_tool`(覆盖 `web_search` 和 `web_fetch`),现有包装类的 `_run/_arun` 改为接收 `**kwargs` 并原样转给内层工具,再按工具名选择标注方式:列表结果走联网搜索的标注,单个网页对象走网页读取的标注。
+- **选定**:B
+- **原因**:助手、工作流 agent、日常模式的包装层现在写死 `invoke({"query": query})`,网页读取的参数是 `url`,不改这一处无论哪个方案都跑不通。任务模式的包装层已经是透传写法,B 让其余三处与它对齐。五处 `_is_web_search_tool` 副本也借机收拢成一个函数,避免下次再漏。
+- **何时该重新考虑**:无。
+
+### 决策 6:正文后处理在工具内完成——去转义、截断、空正文
+
+- **备选**:
+  - A. 不做后处理,原样交给模型和卡片。
+  - B. 在前端卡片渲染时去转义,后端只截断。
+  - C. 在工具内、引用标注之前统一做去转义、截断和空正文判断;截断按字符数计。
+  - D. 同 C,但截断按 token 数计。
+- **选定**:C,具体规则:
+  - **去转义**:把「反斜杠 + 非字母数字字符」以及 `\_` 还原为该字符(DeepSeek 也转义下划线);以 ``` 包围的代码块内保持原样。
+  - **截断**:按字符数截断,上限取管理员配置值,默认 20000,允许范围 1000~200000。截断后在结果里给出 `truncated=true` 和原文总长度,并附一句提示让模型知道内容不完整。
+  - **空正文**:返回「该网页没有可读正文」的提示,不生成引用标识。
+- **原因**:实测 DeepSeek 几乎给每个标点加反斜杠(GitHub 页 429 处,Python 文档页 6574 处),白白占用 token,还会原样出现在角标卡片里。正文长度没有上限(实测 13 万字),不截断会撑满上下文。这些都是引擎特性,放在引擎实现之后、引用标注之前统一处理,所有场景和以后的引擎都受益。
+- **为什么不选 B 和 D**:B 只修了卡片,模型仍然读到带反斜杠的正文,token 照样浪费。D 要知道调用方模型的分词器,工具层拿不到,而且管理员配置「字数」比配置「token 数」直观。
+- **何时该重新考虑**:如果 DeepSeek 修正了转义行为,去转义步骤可以保留(对未转义文本无副作用)。如果出现大量「截断后信息不够」的反馈,再考虑分段读取(带 offset 参数)。
+
+### 决策 7:未配置和调用失败不抛异常,返回文字给模型
+
+- **备选**:
+  - A. 必填配置缺失时让 `load_tools` 抛 `ValueError`(联网搜索现状)。
+  - B. 工具总能被构建;调用时再检查配置,未配置、网址不合法、引擎报错都以文字结果返回给模型。
+- **选定**:B
+- **原因**:spec AC-11、AC-13 要求对话或任务继续执行。方案 A 在日常模式会变成一条失败记录、工具消失;在任务模式里异常会中断运行(`agent_factory.py` 包装层注释说明了这一点)。B 让模型自己决定换一个网址、改用搜索或者如实告诉用户。
+- **何时该重新考虑**:无。
+
+### 决策 8:老环境升级在启动时补行、投影权限、复制到已有子租户
+
+- **备选**:
+  - A. 只改种子 JSON。
+  - B. 照 `_ensure_image_view_tool` 在启动时补根租户的分类行和工具行,只做这一步。
+  - C. 在 B 的基础上,仅当本次新插入了行时:(1)为根租户的新分类做 F048 系统资源权限投影;(2)多租户开启时,对每个已有子租户调用 `acopy_root_builtin_tools_to_tenant`。
+  - D. 在 B 的基础上,每次启动都执行一次「补缺口」:多租户开启时只对还没有 `web_fetch` 行的活跃子租户执行复制;OpenFGA 开启时一次查询找出所有租户里还没有投影的网页读取分类,逐个在该租户的上下文里补投影。每一步失败只记日志。
+- **选定**:D(种子 JSON 也同步加上,供全新安装使用;补数据函数从种子 JSON 读取字段)
+- **原因**:A 对老环境无效。B 的做法对 Image View 够用,是因为 Image View 按 tool_key 直接读取、不经过权限检查;网页读取走 `ToolExecutor`,执行前要检查使用权限,缺投影时非超级管理员用不了(spec AC-26)。已有子租户只在缺少默认工具时才会懒复制,网页读取不在默认工具里,不主动复制就永远不会出现在子租户中。复制函数本身幂等,并会为子租户的分类做投影。不选 C 是因为它只在插入的那一次启动尝试投影和复制,如果那次 OpenFGA 未就绪或某个租户复制失败,以后再也不会重试;而 `init_default_data` 里任何未捕获的异常都会让后端启动失败,所以这些步骤必须吞掉异常、下次启动再补。根租户投影复用全新安装用的系统资源清单(`load_system_owned_resource_inventory`)生成的规格,保证与全新安装的投影一致。
+- **何时该重新考虑**:如果平台以后提供「预置工具版本化同步」的统一机制,把本步骤迁过去。
+
+### 决策 9:任务模式和提示词把网页读取当作网页来源
+
+- **备选**:
+  - A. 不改任务模式的 `has_web` 判断,只勾网页读取时不开启引用规则。
+  - B. 为网页读取单独写一套引用提示词。
+  - C. `has_web` 改用共享识别函数,并在现有引用提示词里补一句网页读取结果。
+- **选定**:C。具体做法:任务模式中判断「是否有网页来源」的三处 `has_web`(研究员提示、主图引用中间件、研究员可引用标记)改用共享识别函数。`citation.yaml` 和 `citation_handles.yaml` 的说明补上「网页读取结果」。
+- **原因**:任务模式目前只在有联网搜索时才开启引用规则,只勾网页读取时模型拿不到规则,spec AC-19 不成立,所以 A 不行。网页读取的引用标识格式和联网搜索完全相同(都是 WEB 类型的 key 或短句柄),单独写一套提示词(B)只会增加两套文字不一致的风险。助手和工作流 agent 现状不注入引用规则(依赖搭建者自己的提示词),本 feature 保持一致,不新增注入。
+- **何时该重新考虑**:助手和工作流将来统一注入引用规则时,一并覆盖网页读取。
+
+---
+
+## 4. 系统现状(接手必读)
+
+### 4.1 数据流
+
+**配置**:管理员打开内置工具「网页读取」的设置 → `ToolSet.tsx` 按子工具 `tool_key` 分派到 `WebFetchForm` → `POST /api/v1/tool/config` → `ToolServices.update_tool_config` 合并掩码后写分类行 `extra`。
+
+**执行**(以助手为例,其他场景相同,只是包装类不同):
+1. `ToolExecutor` 读分类 `extra` → `load_tools({"web_fetch": extra})` → `_get_web_fetch` → `FetchTool.init_fetch_tool(type, **engine_config)` → `WebFetchTool(api_wrapper=..., max_chars=...)`,工具名 `web_fetch`。
+2. 场景的引用包装层用 `is_web_citation_tool` 识别出它,包上引用逻辑。
+3. agent 调用 → 包装层把 `{"url": ...}` 透传给 `WebFetchTool` → 校验网址 → 引擎请求 → 去转义 → 截断 → 返回网页对象。
+4. 包装层调用 `annotate_fetched_page_with_citation`:命中去重表就复用,否则生成一个 WEB 注册条目 → 把 `citation_key` 写进结果 → 注册条目交给收集器和运行时缓存。日常模式和任务模式的短句柄模式再把 key 改写为 `[Sn]` 形式的 `ref`。
+5. 回答结束时各场景按原路径持久化引用;前端按 WEB 类型渲染卡片。
+6. 调用展示:助手运行日志标题按 `tool_key` 在助手配置的工具列表里查到「网页读取」,展开后显示调用输入(含网址)和输出原文,所以运行日志不用改。日常模式由 `_build_tool_meta` 把 `web_fetch` 标为 `web` 类型,前端用网页条目展示网页标题和站点图标,名称走 `BUILTIN_TOOL_I18N`。任务模式在 `ToolRowLite` 的动作文案和 `activity.ts` 的统计分类里新增网页读取。
+
+**升级**:`init_default_data`(持有 Redis 锁)→ `_ensure_web_fetch_tool` 按种子 JSON 插入根租户行(已存在则跳过)→ OpenFGA 收尾步骤之后执行 `_sync_web_fetch_tool`:多租户开启时 `_tenants_missing_web_fetch_tool` 找出缺少该工具的活跃子租户,在该租户上下文里逐个执行 `acopy_root_builtin_tools_to_tenant`;OpenFGA 开启时 `_project_web_fetch_tool_types` 用一次查询检查所有租户网页读取分类的投影状态,只对 `MISSING` 的逐个在其租户上下文里补投影(先等 OpenFGA 就绪,与全新安装的对账同一套 process runtime)。每一步失败只记日志,下次启动再补。
+
+### 4.2 关键数据结构 / 字段约定
+
+| 字段 / 结构 | 类型 / 格式 | 说明 | 谁会消费 |
+|---|---|---|---|
+| 分类名 | `网页读取` | 与 `联网搜索` 一样用中文存库,前端按名字分派设置表单 | `ToolSet.tsx`、`tools/index.tsx` 的 `MANAGED_TOOLS`、子租户复制(按名字匹配) |
+| 工具 `tool_key` | `web_fetch` | 也是运行时 `BaseTool.name` | `load_tools` 注册表、引用识别、助手运行日志、前端工具卡片 |
+| 工具 `api_params` | `[{"name":"url","in":"query","required":true,"schema":{"type":"string"}}]` | 工作流工具节点据此渲染输入框 | `flowUtils.ts` |
+| 分类 `extra` | `{"type":"deepseek","config":{"deepseek":{"api_key":"","base_url":""}},"max_chars":20000}` | 秘钥字段名必须叫 `api_key`,才能被现有掩码逻辑覆盖 | `ToolExecutor`、设置表单 |
+| 工具结果(给模型) | JSON 对象 `{"title","url","source","content","truncated","total_chars","citation_key"}` | 失败或未配置时是一段文字而不是 JSON | 模型、日常模式工具卡片(按 `url`/`title` 显示网页条目) |
+| 引用条目 | WEB 类型,单条目 `itemId="0"`(常量 `FETCHED_PAGE_ITEM_ID`,定义在 `citation_schema.py`),key 形如 `websearch_xxxxxxxx:0`;卡片摘要是去掉 Markdown 标记和标题行后的正文开头,最多 300 字 | 沿用 `websearch_` 前缀,前端、解析服务、持久化都按 WEB 处理 | 引用解析、卡片、历史回放 |
+
+### 4.3 关键模块职责
+
+| 模块 / 文件 | 职责 | 不做什么 |
+|---|---|---|
+| `bisheng_langchain/gpts/tools/web_fetch/tool.py`(新) | `FetchTool` 引擎基类与注册表、`DeepSeekFetch`、`WebFetchTool`;网址校验、去转义、截断 | 不做引用标注 |
+| `bisheng_langchain/gpts/load_tools.py` | 注册 `web_fetch` → `_get_web_fetch` | — |
+| `bisheng/citation/domain/services/citation_prompt_helper.py` | 新增 `is_web_citation_tool`、`annotate_fetched_page_with_citation` | 不关心是哪个场景 |
+| 各场景包装层:`assistant_agent.py`、`workflow/nodes/agent/agent.py`、`workflow/nodes/tool/tool.py`、`workstation/.../chat_service.py`、`linsight/.../agent_factory.py` | 识别网页读取、透传参数、调用标注、交出注册条目 | 不各自实现标注逻辑 |
+| `bisheng/citation/domain/services/citation_handle_service.py` | `rewrite_web_results_with_handles`、`drop_web_citation_keys` 除列表外也接受单个网页对象(日常模式和任务模式的短句柄) | 不分配句柄以外的事 |
+| `bisheng/common/init_data.py` | `_ensure_web_fetch_tool` 及升级后的投影和子租户复制 | — |
+| platform `tools/builtInTool/WebFetchForm.tsx`(新) | 引擎选择、秘钥、Base URL、长度上限的表单与校验 | — |
+| client `ToolCallDisplay.tsx`、`Linsight/Execution/*` | 网页读取的本地化名称、网页条目展示、任务模式动作文案与统计分类 | — |
+
+---
+
+## 5. 已知坑 / 反直觉事实
+
+| # | 反直觉事实 | 如果不知道会怎样 | 在哪处理 |
+|---|---|---|---|
+| 1 | DeepSeek Fetch 返回的 Markdown 给几乎每个标点加反斜杠(`\#`、`\-`、`\,`) | token 浪费,卡片显示一堆反斜杠 | 决策 6,去转义 |
+| 2 | 正文没有长度上限,一页可达 13 万字 | 一次调用撑满上下文 | 决策 6,截断 |
+| 3 | 部分站点整体拒绝抓取(实测维基百科中英文都返回 `not_found_error: failed to fetch URL`),需要执行 JS 的页面只返回一段占位提示 | 误以为是我们的 bug | 失败如实返回模型;占位提示按正文原样返回,不伪造内容 |
+| 4 | 单次请求耗时 5~12 秒 | 超时设太短会大量失败 | 超时取 (10, 60) 秒,与联网搜索一致 |
+| 5 | `ToolExecutor._init_preset_tool` 只取 `load_tools` 返回的第一个工具 | 想「一个配置出两个工具」会被静默丢弃 | 决策 1 |
+| 6 | 助手、工作流 agent、日常模式的引用包装层写死 `invoke({"query": query})` | 网页读取收到错误参数,调用失败 | 决策 5,透传参数 |
+| 7 | `_ensure_image_view_tool` 不做权限投影,Image View 不受影响只是因为它不经过权限检查 | 照抄后,非超级管理员在老环境里列不出、用不了网页读取 | 决策 8 |
+| 8 | 子租户只在缺少默认工具时懒复制根租户的预置工具 | 老环境的已有子租户永远看不到网页读取 | 决策 8,升级时主动复制 |
+| 13 | `init_default_data` 里任何未捕获的异常都会让后端启动失败(外层 `raise RuntimeError`) | 补投影或复制子租户时 OpenFGA 未就绪,整个后端起不来 | `_sync_web_fetch_tool` 逐步 `try/except` 只记日志 |
+| 15 | 权限投影的写入链路会查按租户过滤的表;启动阶段没有当前租户,直接调用会抛 `NoTenantContextError`(2026-10-19 首次部署 test 时实测,根租户和全部子租户都失败) | 升级后普通用户用不了网页读取 | `_sync_web_fetch_tool` / `_project_web_fetch_tool_types` 逐个设置 `current_tenant_id` 后再复制、投影 |
+| 16 | 子租户复制函数先提交工具行、再做投影;投影失败时工具行已经存在 | 若按「有没有工具行」判断是否要补,失败的投影永远不会被修 | 补投影按「投影状态是否 `MISSING`」判断,不按工具行 |
+| 17 | client 用 remark-directive 渲染回答,引用 key 里「冒号 + 字母开头」的部分(如 `:page`)会被当成文本指令 | 角标渲染成 `[citationref:websearch_xxx` 加换行 `]` 的原文(2026-10-19 test 实测,最初 itemId 用的是 `page`) | `FETCHED_PAGE_ITEM_ID = "0"`,单测断言其为纯数字 |
+| 14 | `test/sandbox/test_code_interpreter_seed_default.py` 用 AST 禁止 `init_data.py` 出现任何 `extra=` 关键字参数,但它的本意是「升级时不改已有行的 extra」;主线上该测试已因 `_ensure_image_view_tool` 失败 | 误以为是本 feature 引入的回归 | 本 feature 的插入只写新行;该测试的误报留给其维护方处理 |
+| 9 | 旧工具的设置弹窗和 `MANAGED_TOOLS` 按分类的中文名匹配,子租户复制也按名字匹配;在 platform 源码里新增中文字面量会让文件超出 lint 冻结配额 | 设置按钮不出现;lint 失败;子租户复制出重复分类 | 前端改按子工具 `tool_key = web_fetch` 识别(`tools/index.tsx` 的 `hasManagedToolKey`、`ToolSet.tsx`);分类名只在种子 JSON 里定义一次,补数据函数从种子读取 |
+| 10 | 只有 `api_key` 等固定字段名会被掩码 | 换个字段名,秘钥会明文返回给前端 | 配置字段名固定为 `api_key` |
+| 12 | 短句柄(日常模式、任务模式)按「来源身份」分配,网页类来源的身份原本只看网址 | 同一网址的搜索结果和网页读取拿到同一个句柄,模型引用全文内容时卡片显示的是搜索摘要 | `citation_handle_service.item_identity`:`itemId == FETCHED_PAGE_ITEM_ID` 时身份为 `webpage:<网址>` |
+| 11 | 任务模式的引用规则只在 `has_web` 为真时开启,原判断只认 `web_search` | 只勾网页读取时模型不会打角标 | 决策 9 |
+
+---
+
+## 6. 对外契约与依赖
+
+### 6.1 我提供给别人的(Outgoing)
+
+| 契约 | 形式 | 谁在用 |
+|---|---|---|
+| 内置工具 `web_fetch`(参数 `url`) | 工具表数据 + `load_tools` 注册项 | 助手、工作流、日常模式、任务模式 |
+| 分类 `extra` 配置形状 | JSON | platform 设置表单、子租户复制 |
+| `is_web_citation_tool(tool)` | 内部 Python API | 五个场景的引用包装层 |
+| `annotate_fetched_page_with_citation(...)` | 内部 Python API | 五个场景的引用包装层 |
+
+### 6.2 我依赖别人的(Incoming)
+
+| 依赖 | 形式 | 风险点 |
+|---|---|---|
+| DeepSeek Fetch 接口 | 外部 HTTP API | 转义、长度、站点封禁等行为可能变化;错误体格式变化会影响失败原因的文字 |
+| WEB 引用类型的解析、卡片和持久化(F069、F072) | 内部数据契约 | 若 WEB 负载字段改名,网页读取的卡片会缺字段 |
+| `ToolServices.update_tool_config` 的掩码合并 | 内部行为 | 改动掩码规则会影响秘钥保存 |
+| `acopy_root_builtin_tools_to_tenant` 按名字匹配分类 | 内部行为 | 匹配规则改变会导致子租户重复或缺失 |
+| F048 系统资源投影(`reconcile_system_owned_resources`) | 内部 API | OpenFGA 未就绪时升级投影失败,需重启或跑对账脚本 |
+
+---
+
+## 7. 测试与可观测
+
+- **单元测试**:引擎实现(请求参数、错误体解析、超时)、去转义(含代码块)、截断边界(等于上限不截断)、网址校验、未配置;引用标注(单条目、去重复用、空正文不注册);五个包装层的识别和参数透传;`_ensure_web_fetch_tool` 的幂等。放在 `test/tool/` 和 `test/citation/`。
+- **真实接口自测**:用本地环境变量 `DEEPSEEK_API_KEY` 走 `load_tools` → 调用 → 标注全链路,覆盖成功、站点拒绝、超长截断三种网页。三个参考网址:`https://github.com/dataelement/bisheng`(成功,含大量转义)、`https://en.wikipedia.org/wiki/Taiwan`(站点拒绝)、`https://docs.python.org/3/whatsnew/3.14.html`(约 13 万字,触发截断)。
+- **手动验证一遍**:
+  1. 在 `src/backend` 下执行 `env -u ALL_PROXY -u all_proxy .venv/bin/python -m pytest -q test/tool test/citation`(本地需清掉 SOCKS 代理变量,否则整批误报)。
+  2. 以超级管理员登录 platform → 构建 → 工具 → 内置工具 →「网页读取」→ 设置,选 DeepSeek 并填秘钥。
+  3. 新建助手,勾选「网页读取」,提问「读一下 https://github.com/dataelement/bisheng 并总结」,确认运行日志标题、回答角标、角标卡片和点击跳转。
+  4. 刷新页面重新打开该会话,确认角标卡片仍可显示。
+  5. 在工作台配置里把「网页读取」加入日常模式和任务模式的可用工具,在 client 各发一次同样的问题,确认工具卡片和任务步骤的名称是「网页读取」。
+- **端到端**:`/e2e-test` 按 AC 生成 API 测试和页面验证清单。页面重点看设置弹窗、日常模式工具卡片、任务模式步骤、角标卡片和历史回放。
+- **可观测**:引擎失败用 `logger.warning` 记录网址和 DeepSeek 返回的 `request_id`,便于向 DeepSeek 反馈。
+
+---
+
+## 8. 后续改进 / 不打算做的事
+
+- 接入 Jina Reader 等其他读取引擎:新增引擎类、注册项和表单分支即可。
+- 自建抓取引擎时必须加内网地址拦截(防 SSRF)。第一期由 DeepSeek 服务端抓取,平台不访问目标网址,所以暂不需要。
+- 联网搜索的跨调用去重:见决策 4 的「何时该重新考虑」。
+
+---
+
+## 修订历史
+
+| 日期 | 改动 | 触发原因 |
+|---|---|---|
+| 2026-10-19 | 初版 | spec 确认后进入设计 |
diff --git a/features/v3.0.0/085-web-fetch/e2e-checklist.md b/features/v3.0.0/085-web-fetch/e2e-checklist.md
new file mode 100644
index 0000000000..5e318cc291
--- /dev/null
+++ b/features/v3.0.0/085-web-fetch/e2e-checklist.md
@@ -0,0 +1,48 @@
+# E2E 验收记录:网页读取内置工具(F085)
+
+**环境**:test(前端 120:3002,release 6cb2d816;后端 116,release 63b44ab7)
+**日期**:2026-10-19
+**账号**:admin(超级管理员);读取引擎 DeepSeek,秘钥由用户在内置工具设置中填写
+**模型**:qwen3.7-max(id 840)。deepseek-v4-flash(774)在上游已下线,返回 model_not_found,与本功能无关
+
+## 结果
+
+| AC | 场景 | 结果 | 证据 |
+|---|---|---|---|
+| AC-01 | 升级后内置工具列表出现「网页读取」 | ✅ | 内置工具页第一项;6 个活跃租户各有一行 |
+| AC-02 | 设置弹窗字段 | ✅ | 引擎「DeepSeek 网页读取」、API Key、Base URL、正文长度上限 20000 |
+| AC-03 / 边界 | 必填与范围校验 | ✅ | 长度上限填 500 → 「请输入 1000~200000 之间的整数」,未保存 |
+| AC-04 | 配置与联网搜索分开 | ✅ | 网页读取分类 1384、联网搜索分类 294 各自保存 |
+| AC-06 | 秘钥掩码 | ✅ | 列表接口返回 `********` |
+| AC-07 | 返回标题、网址、正文 | ✅ | 日常模式工具结果含 title / url / source / content |
+| AC-11 | 读取失败不中断 | ✅ | 维基百科返回 HTTP 404 文字,模型如实说明,对话继续 |
+| AC-14 | 助手可调用 | ✅ | 测试助手调试面板调用成功,结果带 `citation_key` |
+| AC-15 | 工作流 agent 节点 | ⏭️ 未在环境验证 | 与助手共用引用包装逻辑,由单测覆盖 |
+| AC-16 | 工作流工具节点 | ✅ | 单节点运行(修复后)输出网页对象并带 `citation_key` |
+| AC-17 | 任务模式可调用 | ✅ | 步骤「已读取网页」,统计「读取网页 1 次」,结果带角标 |
+| AC-18 | 日常模式可调用 | ✅ | `tool_type=web`,短句柄 S1 |
+| AC-19 | 读取成功注册为来源 | ✅ | 日常模式、任务模式、助手的工具结果都带来源标识 |
+| AC-20 | 角标卡片与跳转 | ✅ | 卡片显示网页标题、正文开头(无 Markdown 符号)、github.com;点击 `window.open('https://github.com/dataelement/bisheng', '_blank')` |
+| AC-21 | 搜索与读取混合引用 | ✅ | 搜索段落引用搜索来源(`:1`),全文段落只引用读取来源(`:0`);读取 S1、搜索 S2 起,编号不冲突 |
+| AC-22 | 历史会话回放 | ✅ | 重新打开会话后角标与卡片正常 |
+| AC-23 | 调用展示 | ✅ | 日常模式「已读取网页(1 个结果)」+ 网页条目;任务模式「已读取网页」;助手运行日志「已使用 网页读取」,展开可见网址 |
+| AC-24 | 不显示原始 key / 工具已删除 | ✅ | 工具页、调试面板均为「网页读取」 |
+| AC-26 | 非管理员可用 | ⚠️ 部分 | 6 个租户的权限投影均为 CURRENT;未用非管理员账号实测 |
+
+## 验收中发现并已修复
+
+1. 启动补权限全部报 `NoTenantContextError`(553719f7c)。
+2. 平台工具列表显示 `categories.网页读取.name`(a1dee645d)。
+3. 日常模式角标渲染成原文:itemId `page` 被 remark-directive 当成指令,改为 `0`(25ce30dd4)。
+
+## 未覆盖与既有问题(非本功能引入)
+
+- **工作流 agent 节点**(AC-15):未在环境验证,由单测 `test/citation/test_web_fetch_scenarios_agent.py` 覆盖。
+- **已修复的既有问题**(d837932ab / aaf14db03,2026-10-19 在 test 复测通过):
+  - 工具节点「单节点运行」对所有工具都报 `run_async_safe cannot be called from a running event loop`;`run_once` 改为在线程池里执行节点,网页读取和联网搜索的单节点运行均返回 200。
+  - 助手运行日志同一次工具调用记两条(引用包装层和内层工具各一条);回调处理器跳过父调用是工具调用的事件,复测联网搜索、网页读取各一条,且保留的是带引用标识的那条。
+- **助手角标**:助手场景不注入引用规则(design 决策 9),模型是否写角标取决于搭建者提示词;本次模型写了 Markdown 链接「[来源]」。
+
+## 清理
+
+测试助手 `e2e-f085-网页读取助手`、`e2e-runlog-助手` 与测试工作流 `e2e-f085-网页读取工作流`、`e2e-runonce-工具节点` 已删除。日常模式与任务模式的 4 个测试会话保留在 admin 账号下。
diff --git a/features/v3.0.0/085-web-fetch/spec.md b/features/v3.0.0/085-web-fetch/spec.md
new file mode 100644
index 0000000000..297182be28
--- /dev/null
+++ b/features/v3.0.0/085-web-fetch/spec.md
@@ -0,0 +1,124 @@
+# Feature: 网页读取内置工具
+
+> **本文档定位 — 纯 What(需求口径,不随代码漂移)**
+>
+> spec 只回答做什么、验收标准、不做什么。实现方案见 [design.md](./design.md),执行流水见 [tasks.md](./tasks.md)。
+
+**关联 PRD**: 无独立 PRD;需求来自 2026-10-19 会话,Spec Discovery 结论见本文范围边界
+**优先级**: P1
+**所属版本**: v3.0.0
+**依赖**: 联网搜索内置工具的 DeepSeek 引擎(提交 e5f616b10、eb03de547);网页角标机制(F069、F072)
+
+> **范围边界**
+> - **本次纳入**:
+>   - 新增内置工具「网页读取」:agent 给出一个网址,工具返回该网页的正文。
+>   - 读取引擎可选。第一期只提供 DeepSeek 一个引擎,配置结构允许以后增加其他引擎。
+>   - 管理员在内置工具设置里为「网页读取」单独配置引擎、秘钥、可选的 Base URL 和正文长度上限。它与联网搜索的配置互不影响。
+>   - 所有能选内置工具的场景都可以使用:助手、工作流 agent 节点、工作流工具节点、任务模式、日常模式。
+>   - 每次成功读取的网页注册为一个可引用来源,模型引用后显示角标,点开角标可以看到网页信息并跳转原网页。
+>   - 各场景的工具调用展示使用「网页读取」这个名称,不显示内部工具名,也不显示「工具已删除」。
+>   - 已部署的老环境升级后,内置工具列表里自动出现「网页读取」。
+> - **本次明确排除**:
+>   - 联网搜索选 DeepSeek 时自动附带网页读取能力。已否决,原因是工具能力不应随搜索引擎切换而出现或消失。
+>   - 复用联网搜索里填写的 DeepSeek 秘钥。已否决,原因同上。
+>   - DeepSeek 以外的读取引擎,例如 Jina Reader、Firecrawl。配置结构预留,另起需求接入。
+>   - 一次调用读取多个网址、整站爬取、读取需要登录的页面。
+>   - 由平台自己的服务器直接抓取网页。第一期抓取由 DeepSeek 服务端完成,平台不发起对目标网址的请求。
+>   - 现有 API 工具(`jina_get_markdown`、Firecrawl 抓取等)的改造或下线。
+
+---
+
+## 1. 用户故事
+
+作为 **平台管理员**,
+我希望 **在内置工具里配置一次网页读取的引擎和秘钥**,
+以便 **助手、工作流、任务模式和日常模式都能使用这个能力,而不必每个应用单独配 API 工具**。
+
+作为 **应用搭建者**,
+我希望 **在助手或工作流里勾选「网页读取」**,
+以便 **agent 在搜索结果摘要不够用时,能读取网页全文再回答**。
+
+作为 **终端用户**,
+我希望 **回答里来自网页全文的内容也带角标,并且能点开看到来源网页**,
+以便 **核实回答的出处**。
+
+---
+
+## 2. 验收标准
+
+### 2.1 配置
+
+- **AC-01** — THE SYSTEM SHALL 在内置工具列表中提供「网页读取」工具。新部署环境和从旧版本升级的环境都满足此条,升级环境不需要手工操作。
+- **AC-02** — WHEN 管理员打开「网页读取」的设置, THE SYSTEM SHALL 显示读取引擎选择(第一期只有 DeepSeek)、API Key(必填)、Base URL(选填,输入框以占位提示显示默认地址)和正文长度上限(默认 20000 字)。
+- **AC-03** — IF 管理员未填写 API Key 就提交设置, THEN THE SYSTEM SHALL 在 API Key 字段下提示必填,不保存设置。
+- **AC-04** — THE SYSTEM SHALL 把「网页读取」的设置与联网搜索的设置分开保存。修改其中一个工具的引擎或秘钥,不改变另一个工具的设置和行为。
+- **AC-05** — WHEN 管理员保存设置时 Base URL 留空, THE SYSTEM SHALL 在调用时使用所选引擎的默认地址。
+- **AC-06** — THE SYSTEM SHALL 在界面上对 API Key 做掩码显示,与联网搜索秘钥的显示方式一致。
+
+### 2.2 读取行为
+
+- **AC-07** — WHEN agent 以一个网址调用「网页读取」, THE SYSTEM SHALL 返回该网页的标题、网址和 Markdown 正文。
+- **AC-08** — THE SYSTEM SHALL 去掉引擎在正文中非字母数字字符前额外加的反斜杠(例如 `\#`、`\-`、`\:` 还原为 `#`、`-`、`:`);原文代码块内的反斜杠保持原样。
+- **AC-09** — IF 正文长度超过配置的上限, THEN THE SYSTEM SHALL 只返回上限以内的正文,并在返回内容中明确告诉模型正文已截断以及原文总长度。
+- **AC-10** — IF 网址不是 http 或 https 协议,或者网址中带有用户名或密码, THEN THE SYSTEM SHALL 不调用引擎,并向模型返回说明原因的错误信息。
+- **AC-11** — IF 引擎读取失败(网页不存在、站点拒绝抓取、超时、秘钥无效等), THEN THE SYSTEM SHALL 把失败原因作为工具结果返回给模型,当前对话或任务继续执行,不中断。
+- **AC-12** — IF 网页读取成功但正文为空, THEN THE SYSTEM SHALL 告诉模型该网页没有可读正文,不把空结果注册为引用来源。
+- **AC-13** — IF 管理员尚未完成「网页读取」的设置而某个应用调用了它, THEN THE SYSTEM SHALL 向模型返回「网页读取尚未配置」的说明,对话或任务继续执行。
+
+### 2.3 场景可用性
+
+- **AC-14** — WHEN 搭建者在助手中勾选「网页读取」, THE SYSTEM SHALL 让该助手的 agent 在对话中可以调用它。
+- **AC-15** — WHEN 搭建者在工作流 agent 节点中勾选「网页读取」, THE SYSTEM SHALL 让该节点的 agent 在运行中可以调用它。
+- **AC-16** — WHEN 工作流工具节点选择「网页读取」并传入网址, THE SYSTEM SHALL 输出该网页的读取结果。
+- **AC-17** — WHEN 管理员把「网页读取」加入任务模式的可用工具且用户启用它, THE SYSTEM SHALL 让任务模式的 agent 可以调用它。
+- **AC-18** — WHEN 管理员把「网页读取」加入日常模式的可用工具且用户在输入框工具选择里启用它, THE SYSTEM SHALL 让日常模式的 agent 可以调用它。
+
+### 2.4 角标溯源
+
+- **AC-19** — WHEN 一次网页读取成功, THE SYSTEM SHALL 把该网页注册为一个可引用来源,并在工具结果里给模型提供该来源的引用标识。助手、工作流、任务模式、日常模式都满足此条。
+- **AC-20** — WHEN 模型在回答中引用了网页读取的来源, THE SYSTEM SHALL 显示角标;用户悬停或点开角标时,卡片显示网页标题、站点名和正文开头,点击后在新窗口打开原网页。
+- **AC-21** — WHEN 模型在同一次回答中既引用联网搜索结果又引用网页读取结果, THE SYSTEM SHALL 让两类角标各自指向正确的来源,编号不冲突。
+- **AC-22** — WHEN 用户重新打开历史会话, THE SYSTEM SHALL 仍能显示网页读取来源的角标和卡片内容。
+
+### 2.5 调用展示
+
+- **AC-23** — WHILE agent 正在调用或已经调用「网页读取」, THE SYSTEM SHALL 在日常模式的工具调用卡片、任务模式的执行步骤和助手运行日志里显示本地化的工具名「网页读取」以及被读取的网址或网页标题。
+- **AC-24** — THE SYSTEM SHALL 不在任何场景把「网页读取」的调用显示为「工具已删除」或原始内部工具名。
+- **AC-25** — THE SYSTEM SHALL 为新增的界面文案同时提供简体中文、英文、日文三种语言。
+
+### 2.6 权限
+
+- **AC-26** — THE SYSTEM SHALL 让「网页读取」的可见范围和使用权限与联网搜索等其他内置工具一致,不另设权限规则。
+
+---
+
+## 3. 边界情况
+
+- 当同一次回答里多次读取同一个网址时,多次读取共用一个引用来源,角标卡片指向同一网页。
+- 当网页需要执行 JavaScript 才能显示内容时,引擎可能只返回一段占位提示。系统按引擎返回的内容如实交给模型,不伪造正文。
+- 当目标站点整体拒绝 DeepSeek 抓取时(实测维基百科即如此),按 AC-11 返回失败原因。
+- 当正文恰好等于长度上限时,不提示截断。
+- 当管理员把长度上限改为非正整数或超出允许范围时,设置界面应拒绝保存并提示有效范围。有效范围在 design.md 中确定。
+- 多租户环境下,「网页读取」的设置归属与联网搜索内置工具的设置归属保持一致。
+
+---
+
+## 4. 设计与实现(指针,不复制)
+
+| 你想知道 | 去哪看 |
+|---|---|
+| 为什么这么实现(决策 + 备选 + 何时该推翻) | design.md §3 方案对比 |
+| 关键约束(双 DB / 多租户 / 权限 / 运行时依赖 / 错误码段) | design.md §2 |
+| 今天的数据流、模块职责、字段 / API / 文件名约定 | design.md §4 系统现状 |
+| 代码里看不出的坑、反直觉事实 | design.md §5 |
+| 对外契约、改了会破坏谁、依赖谁 | design.md §6 |
+| 任务拆解、文件清单、执行顺序、踩坑落档 | tasks.md |
+
+---
+
+## 相关文档
+
+- 设计真相: [design.md](./design.md)
+- 执行与落档: [tasks.md](./tasks.md)
+- 版本契约: [features/v3.0.0/release-contract.md](../release-contract.md)
+- DeepSeek Fetch 接口: https://api-docs.deepseek.com/zh-cn/guides/search/
diff --git a/features/v3.0.0/085-web-fetch/tasks.md b/features/v3.0.0/085-web-fetch/tasks.md
new file mode 100644
index 0000000000..7408e1d004
--- /dev/null
+++ b/features/v3.0.0/085-web-fetch/tasks.md
@@ -0,0 +1,167 @@
+# Tasks: 网页读取内置工具
+
+**关联规格**: [spec.md](./spec.md) · [design.md](./design.md)
+**版本**: v3.0.0
+**分支**: `feat/3.0.0/085-web-fetch`
+
+---
+
+## 状态
+
+| 步骤 | 状态 | 备注 |
+|------|------|------|
+| spec.md | ✅ 已评审 | 2026-10-19 用户确认 |
+| design.md | ✅ 已评审 | 2026-10-19 用户确认 |
+| tasks.md | ✅ 已拆解 | 2026-10-19 sdd-review tasks 自查通过 |
+| 实现 | ✅ 完成 | 18 / 18 完成(T018 部分 AC 未在环境验证,见 e2e-checklist.md) |
+
+---
+
+## 开发模式
+
+- 后端 Test-First:每组先写测试任务,再写实现任务。测试放在 `src/backend/test/tool/` 和 `src/backend/test/citation/`。
+- 本地跑测试:在 `src/backend` 下执行 `env -u ALL_PROXY -u all_proxy -u HTTPS_PROXY -u https_proxy -u HTTP_PROXY -u http_proxy .venv/bin/python -m pytest -q <路径>`。
+- 前端用手动验证 + `pnpm lint` / `pnpm typecheck` / `pnpm check-i18n`(在 `src/frontend/` 下执行)。
+- 设计论证见 design.md 对应决策,任务里不重复。
+
+---
+
+## Tasks
+
+### Wave 1 — 基础设施与工具本体
+
+- [x] **T001**: 种子数据新增「网页读取」
+  **文件**: `src/backend/bisheng/database/data/t_gpts_tools_type.json`, `src/backend/bisheng/database/data/t_gpts_tools.json`
+  **逻辑**: 分类行 id=18,`name="网页读取"`,`is_preset=1`,`user_id=null`,`extra` 为 `{"type":"deepseek","config":{"deepseek":{"api_key":"","base_url":""}},"max_chars":20000}`。工具行 id=40,`tool_key="web_fetch"`,`type=18`,`is_preset=1`,`user_id=null`,`api_params` 为单个必填参数 `url`(`in: query`,`schema.type: string`)。名称和描述与 T014 的补数据函数保持一致(design 坑 9)。
+  **依赖**: 无
+
+- [x] **T002**: 网页读取工具本体单元测试
+  **文件**: `src/backend/test/tool/test_web_fetch_tool.py`
+  **逻辑**: mock `requests.post`,覆盖:请求地址、Bearer 头、请求体 `{"url"}`、超时 (10, 60);Base URL 覆盖与留空回退默认地址;去转义(普通文本还原、代码块内保持原样);截断(超过上限截断并带 `truncated`/`total_chars`/提示,等于上限不截断);网址校验(非 http/https、带用户名密码时不发请求);引擎错误体(HTTP 404 + `not_found_error`)和超时都返回文字而不抛异常;空正文返回提示;未配置秘钥返回「尚未配置」提示;`load_tools({"web_fetch": extra})` 能构建出 `name == "web_fetch"` 的工具。
+  **覆盖 AC**: AC-05, AC-07, AC-08, AC-09, AC-10, AC-11, AC-12, AC-13
+  **依赖**: 无
+
+- [x] **T003**: 网页读取工具本体实现
+  **文件**: `src/backend/bisheng_langchain/gpts/tools/web_fetch/tool.py`(新,含 `__init__.py`), `src/backend/bisheng_langchain/gpts/load_tools.py`
+  **逻辑**: `FetchTool` 引擎基类(`request_timeout=(10, 60)`、`init_fetch_tool(type, **cfg)` 注册表)+ `DeepSeekFetch`(默认地址 `https://api.deepseek.com/v1/tools/fetch`)。`WebFetchTool(BaseTool)`:`name="web_fetch"`,参数 `url`,持有 `api_wrapper` 和 `max_chars`(默认 20000,限定 1000~200000)。成功时返回 JSON 对象 `{"title","url","source","content","truncated","total_chars"}`;失败、未配置、网址不合法、空正文返回文字。`load_tools.py` 在 `_EXTRA_PARAM_TOOLS` 注册 `"web_fetch": (_get_web_fetch, [], ["type","config","max_chars"])`,配置缺失也能构建工具(design 决策 7)。
+  **测试**: T002 全部通过
+  **覆盖 AC**: AC-05, AC-07, AC-08, AC-09, AC-10, AC-11, AC-12, AC-13
+  **依赖**: T002
+
+- [x] **T004**: 网页读取引用标注单元测试
+  **文件**: `src/backend/test/citation/test_web_fetch_citation.py`
+  **逻辑**: `is_web_citation_tool` 识别 `web_search`、`web_fetch` 及包装后的工具(`tool_name` 属性);`annotate_fetched_page_with_citation` 生成单条目 WEB 注册项(`itemId="1"`,title/source/snippet 正确,key 前缀 `websearch_`);同一规范化网址第二次调用复用同一引用 ID 并再次返回注册条目;不同网址得到不同 ID;`rewrite_web_results_with_handles` 和 `drop_web_citation_keys` 接受单个网页对象。
+  **覆盖 AC**: AC-19, AC-21
+  **依赖**: 无
+
+- [x] **T005**: 网页读取引用标注实现
+  **文件**: `src/backend/bisheng/citation/domain/services/citation_prompt_helper.py`, `src/backend/bisheng/citation/domain/services/citation_handle_service.py`
+  **逻辑**: 新增 `WEB_CITATION_TOOL_NAMES`、`is_web_citation_tool(tool)`;新增 `FetchedPageCitationRegistry`(以规范化网址为键缓存注册条目)和 `annotate_fetched_page_with_citation(page, registry)`,返回 `(带 citation_key 的网页对象, 注册条目)`,复用 `CitationRegistryService` 的 WEB 构建函数。句柄改写和去 key 函数对 dict 做与列表单项相同的处理(design 决策 3、决策 4)。
+  **测试**: T004 全部通过
+  **覆盖 AC**: AC-19, AC-21
+  **依赖**: T004
+
+### Wave 2 — 各场景接入与升级
+
+- [x] **T006**: 助手和工作流引用包装层测试
+  **文件**: `src/backend/test/citation/test_web_fetch_scenarios_agent.py`
+  **逻辑**: 用假的 `web_fetch` 工具(返回固定网页对象)分别包进 `AssistantCitationToolWrapper`、`WorkflowCitationToolWrapper`,以 `{"url": ...}` 调用(同步和异步),断言内层工具收到 `url`、结果带 `citation_key`、注册条目进入收集器;同一包装实例重复读取同一网址复用 key;`web_search` 的原有行为(列表标注)不变;工作流工具节点 `ToolNode` 对 `web_fetch` 输出做同样标注。
+  **覆盖 AC**: AC-14, AC-15, AC-16, AC-19
+  **依赖**: T005
+
+- [x] **T007**: 助手和工作流 agent 节点接入
+  **文件**: `src/backend/bisheng/api/services/assistant_agent.py`, `src/backend/bisheng/workflow/nodes/agent/agent.py`
+  **逻辑**: 识别改用 `is_web_citation_tool`;`_run/_arun` 改为 `**kwargs` 透传;按工具名分派:`web_search` 走原列表标注,`web_fetch` 走 `annotate_fetched_page_with_citation`,`FetchedPageCitationRegistry` 挂在包装实例上(design 决策 5)。
+  **测试**: T006 中助手和工作流 agent 用例通过
+  **覆盖 AC**: AC-14, AC-15, AC-19
+  **依赖**: T006
+
+- [x] **T008**: 工作流工具节点接入
+  **文件**: `src/backend/bisheng/workflow/nodes/tool/tool.py`
+  **逻辑**: `_is_web_search_tool` 改用 `is_web_citation_tool`;`web_fetch` 输出为 JSON 对象时做单页标注并写入 `WORKFLOW_CITATION_REGISTRY_ITEMS_KEY`。
+  **测试**: T006 中工具节点用例通过
+  **覆盖 AC**: AC-16, AC-19
+  **依赖**: T006
+
+- [x] **T009**: 日常模式接入测试
+  **文件**: `src/backend/test/citation/test_web_fetch_daily_chat.py`
+  **逻辑**: `DailyChatCitationToolWrapper` 包装 `web_fetch`,同步和异步调用都透传 `url`;异步短句柄模式下结果里是 `ref`(如 `S1`)而不是原始 key;`_build_tool_meta` 对 `web_fetch` 返回 `tool_type="web"`。
+  **覆盖 AC**: AC-18, AC-19, AC-23
+  **依赖**: T005
+
+- [x] **T010**: 日常模式接入实现
+  **文件**: `src/backend/bisheng/workstation/domain/services/chat_service.py`, `src/backend/bisheng/core/prompts/yaml/citation_handles.yaml`
+  **逻辑**: 包装层识别、透传、分派同 T007;`_build_tool_meta` 把 `web_fetch` 归为 `web`;短句柄规则文字补「网页读取结果」(design 决策 9)。
+  **测试**: T009 全部通过
+  **覆盖 AC**: AC-18, AC-19, AC-23
+  **依赖**: T009
+
+- [x] **T011**: 任务模式接入测试
+  **文件**: `src/backend/test/citation/test_web_fetch_linsight.py`
+  **逻辑**: `_LinsightWebCitationWrapper` 包装 `web_fetch` 后结果带 key 并 `record_seen`;短句柄模式改写为 `ref`;引擎失败时返回文字不抛异常;只有 `web_fetch` 时研究员提示词和主图的 `has_web` 为真(断言研究员提示词包含来源交回要求)。
+  **覆盖 AC**: AC-11, AC-17, AC-19
+  **依赖**: T005
+
+- [x] **T012**: 任务模式接入实现
+  **文件**: `src/backend/bisheng/linsight/domain/services/agent_factory.py`, `src/backend/bisheng/core/prompts/yaml/citation.yaml`
+  **逻辑**: `_is_web_search_tool` 改用 `is_web_citation_tool`(影响 `has_web` 三处和包装选择);包装层对 `web_fetch` 做单页标注;`citation.yaml`「来源 ID」一节补网页读取结果的说明(design 决策 9)。
+  **测试**: T011 全部通过
+  **覆盖 AC**: AC-17, AC-19
+  **依赖**: T011
+
+- [x] **T013**: 升级补数据测试
+  **文件**: `src/backend/test/tool/test_ensure_web_fetch_tool.py`
+  **逻辑**: 用 SQLite 内存库:空库执行 `_ensure_web_fetch_tool` 后出现一个分类行和一个工具行,字段与种子一致,返回「本次新插入」;再执行一次不重复插入、返回「已存在」;分类 `extra` 与联网搜索分类互相独立。mock 投影和子租户复制函数,断言仅在新插入且 OpenFGA 开启时调用,多租户关闭时不复制。
+  **覆盖 AC**: AC-01, AC-04, AC-26
+  **依赖**: T001
+
+- [x] **T014**: 升级补数据实现
+  **文件**: `src/backend/bisheng/common/init_data.py`
+  **逻辑**: 新增 `_ensure_web_fetch_tool(session) -> bool`(照 `_ensure_image_view_tool`),在其后调用;释放租户过滤绕过后,若新插入且 `settings.openfga.enabled`:为根租户新分类调用 `reconcile_system_owned_resources`(规格同 `acopy_root_builtin_tools_to_tenant` 末尾);多租户开启时对每个已有子租户调用 `acopy_root_builtin_tools_to_tenant`,单个租户失败只记日志(design 决策 8)。
+  **测试**: T013 全部通过
+  **覆盖 AC**: AC-01, AC-26
+  **依赖**: T013
+
+### Wave 3 — 前端
+
+- [x] **T015**: Platform 设置表单
+  **文件**: `src/frontend/platform/src/pages/BuildPage/tools/builtInTool/WebFetchForm.tsx`(新), `src/frontend/platform/src/pages/BuildPage/tools/ToolSet.tsx`, `src/frontend/platform/src/pages/BuildPage/tools/index.tsx`, `src/frontend/platform/public/locales/{zh-Hans,en-US,ja}/bs.json`
+  **逻辑**: 表单含引擎选择(DeepSeek)、API Key(必填、password)、Base URL(选填,占位提示默认地址)、正文长度上限(默认 20000,1000~200000 校验);提交 `{type, config, max_chars}`。`ToolSet` 按分类名「网页读取」分派,`MANAGED_TOOLS` 加入该名。新文案三语齐全,不新增 `any`。
+  **覆盖 AC**: AC-02, AC-03, AC-06, AC-25
+  **手动验证**: 超级管理员打开 构建 → 工具 → 内置工具 →「网页读取」→ 设置;不填 API Key 提交出现必填提示;长度上限填 500 出现范围提示;保存后重新打开,秘钥显示为掩码,其他字段回显;联网搜索设置不受影响。
+  **依赖**: T001
+
+- [x] **T016**: Client 调用展示
+  **文件**: `src/frontend/client/src/components/Chat/Messages/ToolCallDisplay.tsx`, `src/frontend/client/src/components/Linsight/Execution/ToolRowLite.tsx`, `src/frontend/client/src/components/Linsight/Execution/activity.ts`, `src/frontend/client/src/components/Linsight/Execution/execTokens.ts`, `src/frontend/client/src/locales/{zh-Hans,en,ja}/translation.json`
+  **逻辑**: `BUILTIN_TOOL_I18N` 加 `web_fetch`,网页条目显示网页标题和站点图标;任务模式动作文案「已读取网页」、统计分类「读取 N 个网页」。`ToolRowLite` 的动作文案表含遗留中文,按仓库规则一并抽到 i18n。
+  **覆盖 AC**: AC-23, AC-24, AC-25
+  **手动验证**: 日常模式勾选网页读取并提问,工具卡片名称为「网页读取」并显示网页标题;任务模式步骤显示「已读取网页」,头部统计含「读取 1 个网页」;切换英文和日文界面文案正确。
+  **依赖**: T010, T012
+
+### Wave 4 — 联调与验收
+
+- [x] **T017**: 真实接口自测
+  **文件**: 无(临时脚本放 scratchpad)
+  **逻辑**: 用环境变量 `DEEPSEEK_API_KEY`,经 `load_tools` → 包装层 → 标注全链路,跑 design §7 的三个参考网址,记录成功、站点拒绝、截断三种结果。
+  **覆盖 AC**: AC-07, AC-08, AC-09, AC-11, AC-19
+  **依赖**: T003, T005
+
+- [x] **T018**: 端到端验收
+  **文件**: `/e2e-test features/v3.0.0/085-web-fetch` 生成的测试与验证清单
+  **逻辑**: 按 AC 生成 API 测试和页面清单,重点覆盖角标卡片、点击跳转、联网搜索与网页读取混合引用、历史回放。部署到共享环境前先征得用户同意。
+  **覆盖 AC**: AC-14, AC-15, AC-17, AC-18, AC-20, AC-21, AC-22, AC-24
+  **依赖**: T007, T008, T010, T012, T014, T015, T016
+
+---
+
+## 实际偏差记录
+
+- T005 偏离 → 网页读取条目的 itemId 用 `page` 而不是 `1`,句柄身份区分 `web:` / `webpage:`,更新 design 决策 3、新增坑 12(同网址的搜索结果和网页读取在短句柄模式下会被合并)
+- T014 偏离 → 投影和子租户复制改为每次启动补缺口、失败只记日志,更新 design 决策 8、新增坑 13
+- T015 偏离 → 设置弹窗与 MANAGED_TOOLS 改按子工具 tool_key(web_fetch)识别,不按分类中文名;原因:新增中文字面量会让文件超出 lint 冻结配额,更新 design 坑 9
+- T016 → 按仓库规则一并把 ToolCallDisplay.tsx、ToolRowLite.tsx 的遗留中文抽到 i18n,并收缩对应 lint 配额
+- T017 → 真实接口发现 DeepSeek 也转义下划线(`\_`),去转义规则补上下划线;卡片摘要去掉 `**`、`__`、反引号;结果:GitHub 页 429→0 处、Python 文档 6574→0 处,维基百科返回失败文字,12.3 万字截到 2 万字,同网址复用同一来源
+- code review(5 路并行 + 逐条打分):无 ≥80 分问题;作者仍修复 3 项已核实的问题——200 非 JSON 响应未转成失败文字(AC-11)、日常模式工具初始化失败卡片类型与成功路径不一致、同一网址既被搜索又被读取时报告里贴的网址被连贴两个角标(`attach_web_url_markers` 按网址去重并优先读取来源)
+- 部署 test 实测 → 启动补权限与子租户复制全部报 `NoTenantContextError`;改为在租户上下文里执行、按投影状态自愈,更新 design 决策 8、新增坑 15、16
+- 部署 test 实测 → 工具列表显示 `categories.网页读取.name`:平台按分类名查 `categories.<名>`、按 tool_key 查 `tools.` 的 tool.json 文案,漏补;三语补齐
+- T018 实测 → 日常模式回答角标渲染成原文:itemId `page` 被 client 的 remark-directive 当成指令;改为 `0`,更新 design 决策 3、新增坑 17
diff --git a/features/v3.0.0/086-shared-app-chat/code-review.md b/features/v3.0.0/086-shared-app-chat/code-review.md
new file mode 100644
index 0000000000..1e8c20f49e
--- /dev/null
+++ b/features/v3.0.0/086-shared-app-chat/code-review.md
@@ -0,0 +1,34 @@
+# Independent implementation review
+
+Read-only review by `/root/review_shared_chat_final` against the uncommitted F086
+implementation plus new files, relative to current HEAD. No repository writes or
+heavy checks were delegated. Requirements: approved `spec.md` and `design.md`.
+
+1. P2 — platform assistant runtime error control frame did not close pending
+   messages and appended an empty tool card. Reproduced with a failing fixture
+   (`/tmp/f086-review-red.log`), then restored current-turn terminal semantics
+   in shared assistant interpretation. The same fixture now passes; existing
+   nested-run matching and final-cover tests also pass.
+2. P2 — client workflow separator passed `com_chat_round_finished` directly to
+   Markdown. Restored translation at the client content slot for divider/separator.
+
+No other actionable findings were returned. This records one independent review
+and one correction pass, not proof that every production scenario was executed.
+
+## Pre-merge acceptance review (2026-10-22)
+
+Read-only independent reviewer `/root/review_f086_merge` reviewed the final
+acceptance delta and new tests (checkpoint 1bc10dda1, relative to 91975b365c).
+No actionable P1/P2 findings. Covered Select values/clear semantics and retained
+special panels, removed-wrapper callers, upward-scroll/history guards, upload
+extension checks, file-card presentation, code-editor bounds, toolbar placement,
+and annotation metadata/session races and actions. No heavy checks or writes
+were delegated.
+
+Seven-dimension closeout: boundary/concurrency regressions covered by shared,
+file-upload and annotation tests; existing backend auth/write ownership retained;
+no new endpoints/secrets; no new app state libraries; host-specific operations
+remain outside shared core; documentation records user acceptance, remaining
+individual scenario/design sign-offs and verification limits. Backend-only
+upstream integration changes are outside this frontend review. This review does
+not claim every detailed live permission/design scenario has been executed.
diff --git a/features/v3.0.0/086-shared-app-chat/design-review.md b/features/v3.0.0/086-shared-app-chat/design-review.md
new file mode 100644
index 0000000000..d0fffcfd9d
--- /dev/null
+++ b/features/v3.0.0/086-shared-app-chat/design-review.md
@@ -0,0 +1,25 @@
+# F086 independent design review
+
+Initial conclusion: NEEDS_FIX — two medium issues, no architectural blocker.
+Reviewer: independent agent `review_shared_app_chat_design`, 2026-10-21.
+Scope: documents and source inspection; no tests/builds or file modifications
+performed by the reviewer.
+
+1. Original design §4.2 did not establish the raw history boundary. Client
+   `api/apps.ts:getChatHistoryApi` already filters/parses/reclassifies records,
+   making a second normalizer incomplete and duplicated.
+2. Original design §6.1 omitted a session-bound QA operation/completion context.
+   QA dialogs invoke an argument-free completion callback, so an old request
+   may update status in the newly active conversation.
+
+Both findings were verified against source and addressed in the design:
+- §4.2 specifies raw rows, request-only envelope unwrapping, migration of
+  transformations into core, pagination and explicit legacy-caller inventory.
+- §5/§6.1 specify immutable QA origin IDs, action/session generation, origin-only
+  status writes and guarded modal/local-state completion.
+- §7 adds targeted raw-history and deferred QA race tests.
+
+Resolution: design revised; production implementation still pending design
+confirmation. The amended design has not received a second independent review.
+Remaining original findings: none unaddressed in the written design. Runtime
+correctness must be demonstrated during implementation and integration tests.
diff --git a/features/v3.0.0/086-shared-app-chat/design.md b/features/v3.0.0/086-shared-app-chat/design.md
new file mode 100644
index 0000000000..31f415a6bd
--- /dev/null
+++ b/features/v3.0.0/086-shared-app-chat/design.md
@@ -0,0 +1,421 @@
+# Design: Shared workflow and assistant conversations
+
+**关联**: [spec.md](spec.md) · [request.md](request.md) · [tasks.md](tasks.md)
+**版本**: v3.0.0
+**最后更新**: 2026-10-21
+**状态**: User confirmed 2026-10-21; implementation in progress. Proposed modules below are not yet shipped.
+
+## 1. Goals and non-goals
+
+Extract client-based workflow/assistant conversation behavior into `@bisheng/chat`.
+Client app chat, platform test, annotation and application-history viewers consume
+one presentation and interpretation implementation. Platform contributes its QA
+and annotation operations through typed host callbacks.
+
+Daily chat, task/Linsight, app navigation, backend contracts, permission policy
+and UI redesign are outside this change. The existing `Config.tsx` modification
+and `filterByUserPagination.test.tsx` are unrelated working-tree changes.
+
+## 2. Constraints
+
+Follow [constitution](../../../docs/constitution.md) C1–C8 and
+[release contract](../release-contract.md) V3-INV-5. No backend/API/schema changes.
+
+- `@bisheng/ui` is a presentation-only foundation; chat business components belong
+  in a separate source-shipped workspace package, alongside `file-viewers`.
+- Client uses Recoil and react-query v4; platform uses Zustand and react-query v3.
+  The package imports neither those stores/query clients nor application aliases,
+  request modules, router, auth context or application-specific i18n hooks.
+- Do not add Recoil atoms/imports. Existing client host integrations may retain
+  their current imports while leaf components become controlled by props.
+- No shared mutable singleton conversation state. Rendering two sessions must
+  not share drafts, source IDs, form selection, pending uploads or callbacks.
+- UI work follows current `packages/ui/docs/` and landed components. The source
+  of conversation layout/behavior is client `pages/appChat`, not platform forks.
+  Do not restyle `@bisheng/ui` or introduce a new UI/state-management library.
+- Existing v1/v2/v3, guest/share auth, host/base URLs and upload contracts stay
+  at the host boundary. A package callback is not an authorization decision.
+
+## 3. Decisions
+
+### D1. One business package with explicit subpath boundaries
+
+Alternatives: move everything to `@bisheng/ui`; import client source from platform;
+add a workspace package. Select `packages/chat`, named `@bisheng/chat`, exporting
+React components from the main entry and pure contracts/normalizers/reducers from
+`@bisheng/chat/core`. A styles entry is explicit and limited to chat composition.
+
+Reason: ui's contract forbids business services, while importing client directly
+pulls Recoil, contexts, route/asset assumptions and task/daily dependencies into
+platform. Both current applications already consume source-shipped packages.
+Revisit only if core has non-React external consumers requiring independent
+versioning; no separate core npm package in this phase.
+
+### D2. Share interpretation and UI; keep application orchestration
+
+Alternatives: transplant the client providers/stores/socket map; share only
+static bubbles; share pure message interpretation plus controlled UI. Select the
+third. Client and platform keep authentication, endpoint selection, API calls,
+connection creation/disposal, navigation and business writes. Shared functions
+interpret history and live events; hosts feed snapshots to shared components.
+
+Reason: static-only sharing leaves the two history/stream parsers drifting,
+whereas provider transplantation would violate stack boundaries. Current
+`useWebsocket.ts`, `useChatHelpers.ts` and both platform message stores combine
+protocol handling with application state and API calls.
+
+Existing client background-session and standalone auto-rerun orchestration stays
+in client. Platform test initialization stays in the platform adapter. Shared
+code never decides to open a connection merely because a message is mounted.
+Revisit transport sharing after the hosts' handshake/lifecycle contracts converge.
+
+### D3. Explicit operation capabilities, separate from conversation mode
+
+Alternatives: reuse overloaded `debug`/`mark`/`readOnly` flags; introduce independent
+execution, feedback, annotation and resource-preview capabilities. Select explicit
+capabilities, defaulting to disabled. Hosts derive them from existing permissions.
+
+Reason: platform currently passes `debug` for historical viewing while interactive
+forms and stores are elsewhere; client `readOnly` sometimes carries a share token.
+A single flag cannot express 'inspect citations and annotate, but never execute'.
+Revisit only when a new host requires a distinct capability; do not add mode-name
+branches to each message component.
+
+### D4. QA forms and writes remain platform business compositions
+
+Select shared action entries and a shared Q/A selection utility, with platform
+callbacks opening existing `AddSimilarQuestions` and `SaveQaLibForm`. Reject moving
+knowledge selection, permission requests, annotation task navigation and QA write
+APIs into the package.
+
+Reason: those dialogs manage existing QA resources, not conversation presentation.
+The shared callback supplies exact source identity, question and selected answer;
+platform owns validation, saving, errors and completion status. A failed save must
+not be reported as marked. If the existing dialog closes before a failed request,
+fix only its success/failure lifecycle required by AC-14, not its business rules.
+Revisit when a second host actually needs to manage QA libraries.
+
+### D5. Isolate daily/task dependencies during extraction
+
+Reject moving all `components/Chat` or editing daily/task consumers to make the
+new package work. Select a bounded client-based extraction for app chat only.
+
+Client app-chat currently imports Markdown, citations, selection/export and media
+helpers also used by daily/task chat. Extract the app-chat-required implementation
+into the new package, replace application context dependencies with host services,
+and wire only workflow/assistant consumers. Daily/task entry points and existing
+contracts remain unchanged. Their old Markdown path can remain until separately
+approved; it is not an alternative workflow/assistant production renderer.
+
+The package may not import the original client Markdown as a shortcut, nor keep
+platform's old Markdown as a permanent second workflow/assistant renderer.
+Revisit shared daily/task utilities only in that later explicitly authorized scope.
+
+## 4. Current code and target flow
+
+### 4.1 Verified current implementation (not planned work presented as shipped)
+
+| Entry | Current responsibility | Main coupling |
+|---|---|---|
+| client `pages/appChat/index.tsx`, `ChatView.tsx` | workflow/assistant initialization, history, host layout | route, Recoil, auth/share/version |
+| client `pages/appChat/ChatMessages.tsx`, `components/*` | message categories, forms, choices, run details | Recoil selectors, localize, action events |
+| client `pages/appChat/useChatHelpers.ts`, `useWebsocket.ts` | WS handshake, events, state writes | API version, global socket map, Recoil |
+| client `components/Chat/Messages/Content/Markdown.tsx` | Markdown, citations, code/diagram/media/artifacts | client APIs, contexts, permissions; also daily/task |
+| platform `BuildPage/flow/FlowChat/` | workflow test and workflow history display | global Zustand store, socket and file APIs |
+| platform `components/bs-comp/chatComponent/` | assistant test/history and message/citation display | separate global store, APIs and contexts |
+| platform `LabelPage/taskAppChats.tsx` | workflow/assistant annotation, Q/A actions, task status | both platform message stores and QA dialogs |
+| platform `LogPage/useAppLog/appChatDetail.tsx` | historical workflow/assistant viewing | both stores, legacy debug semantics |
+
+Pure protocol functions do not yet exist as a shared module. Common-category
+interpretation is split across client helpers and two platform stores. Client
+node/form/choice behavior is the starting implementation; platform-only debug
+run details are retained as optional shared blocks.
+
+### 4.2 Target data flow
+
+1. Host verifies access, resolves application/version/session and fetches raw
+   backend history rows. Its request adapter may unwrap the response envelope,
+   but must not filter categories, parse message/files, rewrite roles/category or
+   derive display fields before calling shared core.
+2. Shared core normalizes raw history exactly once into an ordered typed snapshot.
+   Move the transformations currently inside client `api/apps.ts:getChatHistoryApi`
+   and the platform history stores into core. Host owns the
+   snapshot's storage and updates; existing state libraries are not imported into chat.
+3. For interactive sessions only, host opens its current connection and forwards
+   decoded events through shared mode-specific normalization and the pure reducer.
+4. `AppChatConversation` displays the controlled snapshot using common message,
+   composer, form, choice, reasoning, run, file and citation components.
+5. User actions invoke typed callbacks. Host checks the current session/capability
+   and executes its existing operation; resulting events update that session only.
+6. History viewers use the same presentation without execution callbacks or a
+   live transport lifecycle. Annotation adds only the authorized Q/A callbacks.
+
+Client state adapters remain at the current app-chat hook/entry boundary. Existing
+Recoil state may keep required sidebar/background-session metadata; message
+classification and stream merging move to shared pure functions, not two adapters.
+Platform replaces global viewer/test message storage with per-mounted-session
+adapters/local state as needed; it does not copy the client atom graph.
+
+The client request split adds a raw-history fetch for the new path, preserving
+URL/version/share headers, pagination and response errors. Workflow/assistant
+initial load and load-more both call it, then core. Inventory remaining callers
+of legacy `getChatHistoryApi` before retiring its display transformations: any
+out-of-scope caller keeps compatibility via an explicitly documented adapter;
+never feed its already-transformed result into the new normalizer. Unsupported
+raw categories are classified/preserved or explicitly ignored by core fixtures,
+not silently dropped in a host. No daily/task request contract is changed.
+
+### 4.3 Package and host responsibilities (proposed paths)
+
+| Path | Owns | Does not own |
+|---|---|---|
+| `packages/chat/src/core/types.ts` | public snapshot, message, event and action contracts | host auth/DTO/API types |
+| `packages/chat/src/core/normalizeHistory.ts`, `normalizeEvent.ts`, `reduceConversation.ts` | workflow/assistant history and event interpretation, immutable updates | socket connection or HTTP |
+| `packages/chat/src/core/selectQaPair.ts` | deterministic question/answer selection and source identity | knowledge/QA writes |
+| `packages/chat/src/components/AppChatConversation.tsx` | shared layout, history scrolling and action placement | navigation or global session state |
+| `packages/chat/src/messages/` and `forms/` | client-based messages, composer, forms/choices and run details | app stores and request modules |
+| `packages/chat/src/content/` | client-based Markdown, citations, attachment/media and code/diagram presentation | auth-specific URL resolution and task timelines |
+| client `pages/appChat/adapters/` | existing Recoil bindings, version/guest/share lifecycle, API/preview/export services | duplicate message renderer or parser |
+| platform `components/bs-comp/appChat/adapters/` | per-panel snapshots, history/test connections, file/preview services | old global message singleton |
+| platform `LabelPage` and `LogPage` | QA dialogs, annotation task/status, authorized viewing | private copies of message/Markdown presentation |
+
+Split touched files above 600 lines by these responsibilities; new package files
+have strict types and no legacy suppression headers. Keep scope-specific source
+provenance in the design/tasks when extracting shared client dependencies.
+
+## 5. Known pitfalls and treatment
+
+| Fact | Failure if ignored | Treatment |
+|---|---|---|
+| `id`, `message_id` and locally generated keys differ; platform answer annotation currently uses `message_id` in one path | QA writes attach to the wrong answer or no message | core identity retains stable UI key and optional server source ID separately; actions use verified source IDs |
+| Workflow parent links may be absent; system/run/form rows interleave with Q/A | adjacent-row pairing selects a tool result as the user's question | `selectQaPair`: use validated turn/parent relation when available; otherwise scan original ordered semantic user inputs, skipping run/system rows; selected answer identity stays exact |
+| A question can have multiple output answers | saving one answer accidentally concatenates another turn | each eligible answer is selectable with its own source ID, paired with the same corresponding input |
+| QA writes can complete after switching sessions or opening another dialog | old A success mutates B status or closes B's dialog | platform captures the immutable QA operation context; origin-only writes, active-generation-only UI updates, per-dialog operation checks |
+| Client history API already filters, parses and rewrites records | double normalization retains a hidden host parser and loses data before core sees it | raw-history request split at client API boundary; initial and paginated history share the core path |
+| History can contain interactive forms/choices | viewing an audit may submit or resume execution | render historical values and submit state; no execution callback/connection; callback guards reject readonly actions |
+| Live events may contain complete snapshots or deltas | repeated text or overwritten partial content | preserve protocol-specific event semantics; test both variants before consolidating reducers; never guess based solely on prefix overlap |
+| UI key may precede server message ID | final event creates duplicate rows or loses form state | reconcile server IDs onto the existing stable key within the session reducer |
+| Client `readOnly` may be a token and `debug` is overloaded in platform | tokens leak into package or readonly is bypassed | host converts access to explicit capabilities; share token stays in host request callbacks |
+| Platform stores are global; client active-session selectors hide other session state | two panels overwrite each other or late history updates a switched session | generation/session token guards in host adapters; state and callback lifecycle per panel/session |
+| `useLocalize` is not referentially stable | hydration effects reset drafts on every render | translations are rendered copy; do not use translation callback identity as a hydration trigger |
+| Client Markdown also imports daily/task artifact support | extracting it transitively migrates excluded modes or bundles task execution | package owns app-chat subset and injected artifact/file actions; no imports of daily/task entry points |
+| Preview URLs can expire; sandboxed HTML must stay isolated | blank previews or unsafe HTML execution | inject host URL resolver/viewer; preserve existing sandbox builder; never enable same-origin or raw HTML as a shortcut |
+| Backend test panels use unsaved configs and different handshakes | test runs execute the saved graph or duplicate initialization | keep handshake/config assembly in platform transport adapter and test exact outgoing payloads |
+
+## 6. Public contracts and dependencies
+
+### 6.1 Proposed outgoing package contract
+
+- `AppChatConversation`: controlled `snapshot`, `mode` (`workflow`/`assistant`),
+  `capabilities`, localized `labels`, `actions`, optional host resource services
+  and header/action slots. No user/token/request-client prop.
+- `ConversationSnapshot`: session identity, ordered `messages`, run status,
+  pending interaction, history pagination state and visible error state.
+- `ChatMessage`: discriminated user/answer/system/run/form/choice/file kind;
+  stable `key`, optional `sourceMessageId`/parent/turn, content/reasoning,
+  citations/files, final/streaming/submitted state. Raw source category is retained
+  only where a protocol-specific form/result must be interpreted losslessly.
+- `normalizeHistory`, `normalizeEvent`, `reduceConversation`: mode-specific pure
+  functions shared by both hosts. UI-only keys are never sent as persisted IDs.
+- `selectQaPair`: takes the chosen message and ordered normalized snapshot;
+  returns the selected source message, input source identity and `{q,a}` content.
+  Missing source identity disables identity-dependent answer writes with a
+  localized explanation; it does not fabricate a persisted ID.
+- QA actions carry an immutable operation context: `taskId`, `conversationId`,
+  `sourceMessageId`, `actionId` and the host's `sessionGeneration`. Capture the
+  target/content at action creation; the submit and completion paths must not
+  re-read the current route/session to choose their target.
+- On successful QA write, any automatic annotation-status write targets the
+  captured origin task/conversation and awaits the existing API result. Server
+  assignment/status checks still apply; conflicts are surfaced without a blind
+  retry. The local status and modal may update only if the operation still
+  matches the mounted active session generation and dialog operation. A result
+  from A cannot change B or close/reset a newly opened dialog. Revisiting A
+  reloads persisted status normally. Failed QA writes never invoke completion.
+  The existing no-argument `onMarked` contracts in `AnnotationFormProps`,
+  `SaveQaLibForm`, `AddSimilarQuestions` and `taskAppChats` must therefore become
+  context-bearing for this integration, with their other callers audited.
+- Independent capabilities: send/stop/continue/form-submit, feedback,
+  annotation-question, save-answer-to-QA, copy/export/share, resource-preview and
+  debug/run-details. An action requires both capability and a supplied handler.
+- Host actions return promises where completion matters. Shared forms lock while
+  pending, retain values on rejection, and do not advance annotation status.
+  Platform QA dialogs retain their own validation and success notification.
+- Resource services resolve file/citation details and open previews; optional
+  artifact/TTS/export behavior is provided only by hosts that already support it.
+  No ambient navigation, API calls or storage access in shared content components.
+- Localization: shared copy uses a typed labels object assembled by each app;
+  reusable keys live in `packages/locales` shared domain when appropriate. Keep
+  host QA/task copy in platform. Avoid importing either app's translation hook.
+
+The package adds no HTTP or Python API. Stable keys/callback semantics are the
+new frontend contracts; changes must be reviewed against all consumer adapters.
+No process-wide custom event bus for form/choice submission in the new path.
+
+### 6.2 Incoming dependencies and risks
+
+| Dependency | Use and compatibility risk |
+|---|---|
+| Existing workflow and assistant WS/history formats | event categories and status/identity fields need fixtures; backend changes can break normalization |
+| Existing client v1/v2/v3, standalone/share and workflow auto-rerun guards | host must preserve exact handshake/auth/activation behavior; no automatic rerun in history mode |
+| Existing platform test payload/version assembly | must keep unsaved graph and model/config inputs |
+| Existing annotation/QA endpoints and permissions | writes remain platform-owned; IDs must be from the correct selected message |
+| `@bisheng/ui`, icons and current client composition | no style redesign; unfinished primitives follow the current client implementation privately rather than inventing a new design system |
+| Current Markdown/math/code/diagram dependencies | reuse installed compatible versions, lazy-load heavy diagram/code surfaces; client uses KaTeX while platform has MathJax, so migration validates formula fixtures |
+| Current file/citation viewers, `@bisheng/file-viewers` where applicable | URL/auth/base path differs; injected resolvers must preserve resource permissions and sandboxing |
+| Workspace source resolution and Tailwind scanning | both apps must scan chat source and consume explicit styles; no global selector/token resets |
+
+`react`, `react-dom` and `@bisheng/ui` are peers. Add existing required rendering
+libraries as package dependencies with workspace/catalog versions when supported;
+do not rely on transitive node_modules resolution. Package-local lint forbids app
+aliases and store/query/router/request imports. Both app quality scripts include
+chat; core tests must run without mounting either application's providers.
+
+## 7. Verification, delivery and observation
+
+Use fixture-first tests for pure normalization/stream merging, identity and Q/A
+pairing. Component tests exercise forms, readonly guards, pending/error behavior,
+resource actions and two simultaneous sessions. Host integration tests assert exact
+test/send/QA payloads and access gating. Explicitly test raw history input before any
+host transformation and the absence of duplicate normalization. Use deferred
+promises for QA A→switch B, A→B→A, reopening a dialog, failed QA writes and failed
+annotation-status writes: verify origin IDs in network payloads and no stale UI
+completion. Retain existing app-chat media/auto-rerun
+and platform annotation regressions. Add daily/task smoke tests at their existing
+boundaries without migrating production consumers.
+
+Deliver vertical slices: shared core plus client workflow rendering; platform
+workflow test/history; annotation QA; assistant client/test/history; final removal
+of replaced workflow/assistant production renderers and parser branches. A slice
+is not complete if client still uses its original renderer and platform alone
+uses the package. Temporary coexistence during development is removed before
+whole-feature acceptance; QA business dialogs and excluded daily/task code remain.
+
+Verification commands (from `src/frontend`, heavy jobs serial): package/core and
+host focused tests, `pnpm lint`, `pnpm typecheck`, `pnpm check-i18n`, and both app
+production builds. Include `@bisheng/chat` in root lint/typecheck, and add a
+package test command. Record pre-existing failures rather than adding suppressions.
+No production code has changed at design review, so these implementation checks
+have not yet run for F086.
+
+Manual matrix: client `/workspace` application workflow/assistant chat, standalone
+and read-only share; platform workflow/assistant test panel; annotation Q/A save,
+status and previous/next; app-log detail. Use the existing configured dev proxies,
+authorized test accounts and disposable conversations/QA entries. Confirm exact
+routes from current router before publishing acceptance links; do not guess paths
+or use destructive operations against shared real data.
+
+Verify desktop/narrow widths and dark/light, streaming, history scroll, forms,
+attachments/citations, two panels/switching sessions, failed QA writes, and no
+socket/send operation in read-only views. Browser tests capture console errors
+and network action counts, not screenshots alone. Record event category/status
+and source IDs only for normalization failures; never log tokens or message/file
+contents by default. Do not mask model failures with fabricated answers.
+
+Acceptance trace: AC-01、AC-02、AC-03、AC-04 map to D1/D2 and the dual-host
+vertical slices; AC-05 maps to D5 and unchanged daily/task smoke coverage;
+AC-06、AC-07、AC-08、AC-09、AC-10 map to core/content/forms and protocol/resource
+fixtures; AC-11、AC-12、AC-13、AC-14、AC-15 map to D3/D4, exact Q/A identities and
+platform QA integration; AC-16、AC-17、AC-18 map to capability guards, session
+isolation and host authorization; AC-19 maps to labels/shared locale integration;
+AC-20 maps to both app builds, focused tests and the manual host matrix.
+
+## 8. Deferred work and reconsideration triggers
+
+Daily/task extraction is explicitly deferred. Their shared Markdown overlap may
+persist until separately authorized, but no workflow/assistant-specific renderer
+or parser fork remains after this feature. No task execution timeline is added.
+
+Backend history gaps, missing permissions or incompatible QA contracts are not
+solved by granting broad frontend permissions. Surface evidence and ask before
+changing backend contracts or accepted scope. UI design gaps retain the current
+client pattern and wait for design work if no equivalent exists.
+
+Transport unification, a new state library, and navigation unification are not
+needed to eliminate the selected duplication. Revisit only for an independently
+approved requirement with evidence of remaining lifecycle duplication.
+
+## Revision history
+
+| Date | Change | Trigger |
+|---|---|---|
+| 2026-10-21 | Proposed design, not implemented | User confirmed F086 specification and requested continuation |
+| 2026-10-21 | Specify raw history boundary and session-bound QA completion | Independent review found two medium contract gaps |
+
+## 8. Implemented local contracts and pitfalls (2026-10-21)
+
+The approved package is now `src/frontend/packages/chat`. Public contracts are
+listed in its README; the two host folders bind API/resources and state. The
+implementation remains pending full business acceptance; see verification.md.
+
+| Contract / non-obvious behavior | Why it matters | Implementation |
+|---|---|---|
+| Raw history is normalized before host-compatible message conversion | Reinterpreting already converted rows can lose question text, form values and files | `core/historyForHost.ts`, `core/hostMessages.ts` |
+| Compatibility rows carry `__bisheng_chat_snapshot`; outgoing history must use `messagesForTransport` | Runtime descriptors are not backend message fields; snapshots must not leak into requests or recursively grow | shared hostMessages; platform assistant TestChat payload |
+| UI keys stay stable; canonical persisted IDs are strings, wire IDs retain original numeric values | Pending rows cannot be annotated or mistaken for stored IDs; backend payloads retain their existing shape | shared workflow/assistant core; Immer regression fixtures |
+| Assistant `end_cover/tool/{}` with host cover flag is an error control frame without run identity | Treating it as a normal tool result adds an empty card and leaves the run spinning | `assistantStreamForHost`; error-frame fixture |
+| Workflow tokens are deltas, explicit end/over is a snapshot | Prefix heuristics drop repeated tokens such as `ha` + `ha` | `applyWorkflowStream.ts` |
+| Backend history can store `tool_call`, `tool_result` and pending input schemas | These private protocol rows must stay in the canonical history without becoming customer answer bubbles or QA candidates | normalizer hidden kind; renderer excludes hidden rows |
+| Annotation A→B→A needs generations, not only matching conversation IDs | Old origin completion may write A but must not mutate the newly opened A UI | captured operation + session/dialog generation tests |
+| Platform history fetch is a host service, not a Zustand action | Preserves C7 and allows independent panel disposal/invalidation | `appChat/historyAdapter.ts`, per-panel scope |
+| Client completion divider payloads may be translation keys | Rendering the literal payload exposes `com_chat_round_finished` | client divider/separator content slot localizes payload |
+| Citation popover keys use stable Markdown node offsets | A per-remount ID makes popovers close when active state rerenders Markdown | shared `CitationText` scope from HAST position |
+
+### Revision history
+
+- 2026-10-21: Approved design implemented locally; synchronized canonical/wire
+  identities, snapshot stripping, platform panel isolation, terminal error frame,
+  hidden history categories and citation state pitfalls. Independent review fixes
+  and local evidence are recorded separately; no merge or live acceptance claimed.
+
+
+## 9. Client Markdown parity follow-up (2026-10-21)
+
+User confirmed client is the style authority and also owns Markdown components
+and rendering parameters. Shared `ChatMarkdown` now imports client-derived prose
+and syntax-highlight styles scoped to `.bs-mkdown.bisheng-chat-markdown`; platform
+legacy `.bs-mkdown` rules remain for unrelated callers. No daily/task renderer or
+legacy sheet changed. The CSS scope uses higher specificity so import order does
+not change shared headings/tables/code.
+
+`CLIENT_MARKDOWN_DEFAULTS` centralizes client plugin options (MathML,
+single-dollar math, highlight detection/missing-language behavior), default LaTeX
+preprocessing and media queries. Both hosts now use the shared reactive media
+policy: `(max-width: 767px)` and `(hover: hover) and (pointer: fine)`. The existing
+client LaTeX preference and host auth/capability/resource services remain ports.
+Code bars restore client code layout/copy text and inline-code double-click.
+
+Evidence: 37 shared tests including two new RED→GREEN parity tests; real Chrome
+computed-style comparison under platform and client prose sheets, deliberately
+loaded after the shared sheet. h1=24px, h3=18px, even table rows and code token
+colors match; legacy sibling headings retain their host size. Formula output and
+390px dark rendering pass. This adds local rendering evidence, not live QA writes
+or completion of the unchecked business acceptance matrix.
+
+
+## 10. Shared composer action ownership
+
+Attachment, voice and send controls share one `items-center` toolbar. The host
+file service supplies `onAttach` and retains upload state/validation/drop/paste;
+it must not position another attachment trigger inside the composer. Client's
+existing InputFiles supports hideTrigger/openPicker; platform ChatFiles retains
+file management and exposes openPicker, with its replaced trigger removed.
+Platform voice uses inline mode in shared conversations; its other modal caller
+retains legacy placement. This corrects the host absolute-position regression
+reported in the user's screenshots without changing upload/recording transport.
+
+
+## 11. Opening messages and suggestions follow the client baseline
+
+GreetingMessage retains the original client opening-message layout and pointer
+hover policy. Host footer slots supply only authorized operations; they do not
+choose the greeting action position. Form/choice action placement also belongs
+to the shared renderer. Preview no longer substitutes a copy action for greeting
+voice playback, and its voice glyph uses the client compact size.
+
+SuggestedQuestions is the common client-style chip list in the message scroller.
+Workflow stores own runtime questions and the actual input-lock capability;
+assistant keeps its existing configured-question visibility contract. Host input
+handlers receive panel-local resend events, keep their execution guards and own
+transport. The former floating input question renderers are retired. This is a
+presentation relocation, not a new suggestion-generation or send protocol.
diff --git a/features/v3.0.0/086-shared-app-chat/e2e-checklist.md b/features/v3.0.0/086-shared-app-chat/e2e-checklist.md
new file mode 100644
index 0000000000..9b2524a715
--- /dev/null
+++ b/features/v3.0.0/086-shared-app-chat/e2e-checklist.md
@@ -0,0 +1,103 @@
+# F086 acceptance and regression coverage
+
+Branch: `codex/086-shared-app-chat`. No backend endpoints or schemas changed.
+All verification below ran locally with one test worker. Browser fixtures block
+API requests; no real QA/status writes were made against 120.
+
+## AC classification
+
+- UI/interaction contracts: AC-02、AC-03、AC-04、AC-07、AC-08、AC-09、AC-10、AC-11、AC-12、AC-14、AC-15、AC-16、AC-19; focused automated fixtures plus the module-based live checklist below.
+- Internal extraction/state contracts: AC-01、AC-05、AC-06、AC-13、AC-17、AC-20; pure/core/host regression tests, source-scope audit and builds.
+- Existing authorization contract: AC-18; no backend/API behavior changed. Local denied-citation/capability fixtures cover the frontend portion; non-admin/tenant live verification remains pending.
+
+## Automated evidence
+
+| Coverage | Result | ACs |
+|---|---|---|
+| Shared package: raw history, roles, hidden protocol rows, reasoning, files, exact QA pairing/identity, workflow and assistant delta/final/error reconciliation | 37 tests passed across core and interaction suites | AC-01、AC-06、AC-07、AC-08、AC-09、AC-10、AC-13、AC-16、AC-17 |
+| Controlled choice/form/composer: read-only and keyboard guards, pending lock, failed-submit retry, IME, stop; history prepend and underfilled viewport | Included in the 37 tests | AC-07、AC-08、AC-16 |
+| Markdown: safe text/table/line breaks, source-code copy, forbidden citation does not request private detail/preview; attachments preserve media metadata and recover authorized image URL | Included in the 37 tests | AC-04、AC-09、AC-18 |
+| Platform QA dialogs, captured source IDs, failed writes/status, late completion A→B and A→B→A, independent stores/history generations, run close behavior | 21 tests passed | AC-11、AC-12、AC-13、AC-14、AC-17、AC-18 |
+| Client state under Immer, transport metadata removal, workflow media, workflow activation/restart, share-token contracts and task artifact utilities | 94 tests passed, 5 suites | AC-02、AC-05、AC-06、AC-09、AC-10、AC-17、AC-18 |
+| Real Chrome component fixture: read-only input/form, code copy, one send/form/choice, light desktop and dark 390px | Passed; no page errors/API calls; body width 390px | AC-04、AC-07、AC-15、AC-16 |
+| Independent review | Two P2 regressions identified and fixed: assistant error terminal frame; client separator translation | AC-10、AC-19 |
+
+This is frontend regression coverage, not a claim of full end-to-end service
+acceptance. Existing API contracts are unchanged, so no backend pytest cases
+were added. Server-side permission/tenancy behavior still needs the existing
+service test environment; button visibility is not treated as authorization.
+
+## Business acceptance — pending human/live-environment execution
+
+Use a test account and disposable conversations/QA library. Check one whole
+business journey at a time. The following items are intentionally **unchecked**:
+
+### Client workflow
+
+- [ ] Open an existing workflow, load older history; check order, reasoning,
+  attachments, references and separate outputs from the same node.
+- [ ] Run text, choice and upload forms; required values validate, each choice
+  sends once, execution continues in the selected conversation. Refresh to
+  verify historical submitted values.
+- [ ] Stop/restart, reconnect, switch during output, return to the running chat;
+  verify drafts/output stay with their origin and older-history reading stays put.
+- [ ] Open an authorized readonly share: copy/preview/download work; historical
+  forms/choices/code cannot execute and mounting creates no generation socket.
+
+### Client assistant
+
+- [ ] Send with attachments; check streaming reasoning/tools/answer, final output
+  is unique, feedback/export/voice and code permissions follow the account.
+- [ ] Test v1/v2/v3, guest and shared access where enabled; citation denied/expired
+  states show the applicable message and no misleading empty success preview.
+- [ ] Cause a retryable model/tool failure; pending indicators finish and retry
+  remains possible. Verify switching chats does not move output or draft text.
+
+### Platform test panels
+
+- [ ] Workflow editor: change unsaved node parameters, test without saving,
+  complete a form/choice, stop/restart/clear; results use the unsaved definition.
+- [ ] Assistant editor: change unsaved model/tools/prompt, test without saving,
+  stop/clear/retry; results use the edited definition and retained history.
+- [ ] Open independent panels/views; loading/clearing/viewing logs in one does
+  not overwrite another panel's messages or the assistant editor configuration.
+
+### Annotation and logs
+
+- [ ] On a question, add it to existing QA entries as a similar question; on a
+  chosen answer, create QA with the correct question and that exact answer.
+- [ ] Edit/generate similar questions, select libraries, test required validation
+  and save errors; only successful saves mark the origin conversation complete.
+- [ ] Navigate A→B and A→B→A while generation/save is pending; old completion
+  neither closes a new dialog nor changes the current conversation's status.
+- [ ] Verify assignment restrictions, all three statuses and previous/next.
+  Open workflow/assistant audit logs: copy/preview allowed, no QA write controls,
+  no send/stop/execute controls and no generation connection.
+
+### Markdown visual follow-up
+
+- [ ] In both hosts, check heading hierarchy, lists, table zebra rows, links,
+  inline code, highlighted code/copy labels and formulas against client baseline.
+- [ ] Check citation hover on mouse, tap on touch, resize across 767/768px and the
+  existing client LaTeX preference; readonly still cannot execute code.
+
+### Daily/task and design follow-up
+
+- [ ] Daily chat: send/history, code/artifacts and readonly share smoke.
+- [ ] Task mode: run/history, deliverable preview/download and share smoke.
+- [ ] Designer reviews both modes in light/dark/narrow layouts in each host;
+  this extraction retains the client baseline and does not redefine UI specs.
+
+## Quality gates and limits
+
+Final command results are recorded in `verification.md`. Full-repo gates must
+be distinguished from changed-file/package gates: unrelated existing skill-center
+errors can block the root command even while shared code checks pass.
+
+## User acceptance and integration decision (2026-10-22)
+
+User: “测下来流程都通了,没啥大问题,合并回3.0.0分支,先拉取最新代码,然后删除当前开发分支。”
+Live functional smoke accepted. Detailed edge-case/design items above have not
+been individually signed off in this record; do not auto-check them from the
+aggregate acceptance statement. Final automated checks are recorded separately
+in verification.md.
diff --git a/features/v3.0.0/086-shared-app-chat/platform-select-migration.md b/features/v3.0.0/086-shared-app-chat/platform-select-migration.md
new file mode 100644
index 0000000000..a07245125f
--- /dev/null
+++ b/features/v3.0.0/086-shared-app-chat/platform-select-migration.md
@@ -0,0 +1,49 @@
+# Platform Select migration follow-up
+
+2026-10-22. User authorized replacing remaining platform Select controls and
+explicitly keeping specialized pickers that the shared library cannot represent.
+This follows the release-line Select migration brought in by merge 91975b365.
+
+## Completed
+
+- Application usage log: replace the remaining legacy MultiSelect with the real
+  @bisheng/ui Select. Keep remote search, 10-item pagination, stable application
+  IDs, selection across search results, and tag removal. Discard null/empty IDs
+  before constructing options. The endpoint and request parameters are unchanged.
+- Permission dialogs and rosters: replace four native selects. Keep the model
+  key payload, inactive/current-model display, protected/read-only rows, and the
+  add/move/remove mutation contract.
+- Approval configuration: replace eight native value selectors (scenario,
+  route condition/value/type/flow, selected flow, node modes). The add-approver
+  control executes an action, so it uses the shared Dropdown. Preserve no-condition
+  selection, required-flow validation, numeric flow IDs, dirty-node confirmation,
+  and existing approver sources.
+- Remove two implementations with no remaining importers:
+  components/bs-ui/multiSelect.tsx/index.tsx and components/ui/select-custom.tsx.
+- Regenerate ESLint suppressions with lint:prune; counts only decrease.
+
+## Kept by user decision
+
+| Entry | Reason |
+|---|---|
+| components/bs-comp/selectComponent/knowledge.tsx | Knowledge/document/space tabs and custom footer |
+| pages/BuildPage/flow/FlowNode/component/KnowledgeSelectItem.tsx | Knowledge tabs and business actions in the panel |
+| pages/BuildPage/flow/FlowNode/component/SelectVar.tsx | Workflow variable hierarchy and custom content |
+| pages/Dashboard/components/config/StyleConfigPanel.tsx | Swatch-only palette previews |
+| pages/SystemPage/theme/BrandAssetUpload.tsx | Asset previews and per-option delete actions |
+
+Users.tsx also imports bs-ui/select/filter. That component is a checkbox filter
+panel with explicit reset/confirm actions, rather than a value Select; it stays.
+The underlying legacy select primitives remain only to support these entries.
+No native HTML select remains in platform production TS/TSX sources.
+
+## Manual acceptance by module
+
+- Application usage log: search for an app, select multiple results, search again,
+  remove one selected app, scroll to load more, then apply/reset the log filter.
+- Resource permissions: grant a permission, change an editable assignee's model,
+  confirm protected and inactive/non-grantable assignees remain read-only.
+- Approval configuration: add a scenario; edit a branch condition and clear it;
+  switch pass/approval, bind a flow and save; change node mode; add an approver;
+  switch flows with unsaved node edits and check the discard confirmation.
+- The specialized pickers listed above retain their existing interaction.
diff --git a/features/v3.0.0/086-shared-app-chat/request.md b/features/v3.0.0/086-shared-app-chat/request.md
new file mode 100644
index 0000000000..e14828d3df
--- /dev/null
+++ b/features/v3.0.0/086-shared-app-chat/request.md
@@ -0,0 +1,42 @@
+# F086 request and discovery record
+
+Date: 2026-10-21. Source: shanghang's instructions in this conversation.
+Status: discovery, specification and amended design approved; implementation started.
+
+## Confirmed request
+
+“好,拉一个新分支,抽新的共享包,先抽离工作流和助手,日常和任务不动。
+基于client抽,补齐标注 qa等后台操作。”
+
+The preceding analysis recommended a business-specific shared package rather
+than moving application shells or state libraries into the UI foundation.
+The user selected workflow/assistant as the first scope; this does not authorize
+migration of daily/task modes or a UI redesign.
+
+## Verified current entry points
+
+- Client workflow and assistant: `src/frontend/client/src/pages/appChat/`.
+- Platform workflow test: `src/frontend/platform/src/pages/BuildPage/flow/FlowChat/ChatTest.tsx`.
+- Platform assistant test: `src/frontend/platform/src/pages/BuildPage/assistant/editAssistant/TestChat.tsx`.
+- Platform annotation: `src/frontend/platform/src/pages/LabelPage/taskAppChats.tsx`.
+- Platform application conversation logs: `src/frontend/platform/src/pages/LogPage/useAppLog/appChatDetail.tsx`.
+- Annotation currently invokes `AddSimilarQuestions` for a question and
+  `SaveQaLibForm` for an answer, with the matched Q/A and source message ID.
+  The first adds a question to selected existing QAs; the second saves a new QA
+  to a selected QA library, with optional generated/edited similar questions.
+- Platform workflow/assistant already reuse their respective message displays
+  within platform; duplication to eliminate is across client and platform as
+  well as overlapping workflow/assistant presentation, not a new viewer per page.
+- Daily/task message rendering shares some client dependencies with app chat;
+  those dependency edges require isolation during design, not incidental migration.
+
+## Branch and baseline
+
+- Base: local `feat/3.0.0`, commit `bdc886930`.
+- New branch: `codex/086-shared-app-chat`.
+- `feat/3.0.0/086-shared-app-chat` cannot coexist with the existing Git ref
+  `feat/3.0.0`; the new branch uses the agent's default prefix instead.
+- Pre-existing uncommitted `src/frontend/platform/src/pages/SystemPage/components/Config.tsx`
+  remains in place and is unrelated; it must not be included in this feature.
+- No package or production-code extraction has started before specification
+  and design confirmation required by `docs/SDD-Guide.md` §3.
diff --git a/features/v3.0.0/086-shared-app-chat/spec-review.md b/features/v3.0.0/086-shared-app-chat/spec-review.md
new file mode 100644
index 0000000000..b0e4e89c33
--- /dev/null
+++ b/features/v3.0.0/086-shared-app-chat/spec-review.md
@@ -0,0 +1 @@
+LGTM
diff --git a/features/v3.0.0/086-shared-app-chat/spec.md b/features/v3.0.0/086-shared-app-chat/spec.md
new file mode 100644
index 0000000000..36008f33cd
--- /dev/null
+++ b/features/v3.0.0/086-shared-app-chat/spec.md
@@ -0,0 +1,133 @@
+# F086: Shared workflow and assistant conversations
+
+**关联 PRD**: [Confirmed user request and discovery](request.md)
+**优先级**: P1
+**所属版本**: v3.0.0
+**状态**: Reviewed and user confirmed, 2026-10-21
+**依赖**: Existing workflow/assistant conversations, annotation and QA management;
+F078 shared UI foundation. Architecture rules: [constitution](../../../docs/constitution.md).
+
+## 1. Scope and user stories
+
+As an application user, I want workflow and assistant conversations to retain
+client behavior after extraction, including interactive forms and history.
+As an application builder, I want a consistent conversation in the test panel.
+As an annotator, I want to review conversations and reuse questions/answers in
+QA libraries without losing message identity or annotation controls.
+As an administrator, I want to inspect conversation history without executing it.
+As a maintainer, I want one shared implementation for workflow/assistant message
+presentation and common interpretation, consumed by both applications.
+
+Included: client workflow/assistant conversations; their platform test panels;
+workflow/assistant annotation; existing application conversation log/detail
+viewing; messages, attachments, citations, reasoning, run details, interactive
+forms/choices, input and common event/history interpretation used by these modes.
+The visual/interaction baseline is the current client implementation, with
+platform-only operations added for the relevant authorized hosts.
+
+Excluded: daily chat and task/Linsight migration; new state-management libraries;
+application navigation/sidebars; new annotation or QA business rules; permission
+model changes; backend schema/API changes; redesign of shared UI components.
+Changes needed to those excluded contracts must be raised separately.
+
+## 2. Acceptance criteria
+
+### 2.1 Shared behavior and scope
+
+- **AC-01** — THE SYSTEM SHALL use the same shared workflow/assistant message
+  presentation and common message interpretation in client, platform tests,
+  annotation and application log viewers, retiring the replaced production
+  implementations rather than maintaining two rendering paths for those modes.
+- **AC-02** — WHEN an existing workflow or assistant is opened in client, THE
+  SYSTEM SHALL retain its current sending, stopping, continuation where
+  supported, file handling, history pagination, application version selection,
+  feedback, citation and sharing behavior for the current access mode.
+- **AC-03** — WHEN a builder opens a workflow or assistant test panel, THE SYSTEM
+  SHALL preserve its unsaved configuration/parameters, test initialization,
+  clearing, sending, stopping and supported continuation behavior.
+- **AC-04** — THE SYSTEM SHALL retain the client visual/interaction baseline for
+  shared messages while allowing each host's surrounding layout and authorized
+  operations; both light/dark and existing narrow-screen layouts SHALL remain usable.
+- **AC-05** — THE SYSTEM SHALL leave daily/task mode source implementations and
+  their sending, history, execution, artifact and shared-view behavior unchanged.
+  Any dependency shared with those modes SHALL preserve its existing contract.
+
+### 2.2 Message correctness and interactive behavior
+
+- **AC-06** — WHEN live or historical messages are displayed, THE SYSTEM SHALL
+  preserve message identity, order, roles, text, reasoning, references,
+  attachments, node/run details and applicable interaction state without
+  dropping messages or duplicating final answers after streaming completes.
+- **AC-07** — WHILE a workflow waits for a form or choice in an interactive
+  conversation, THE SYSTEM SHALL validate and submit the selected values once,
+  retain submission/disabled state and continue the correct conversation.
+- **AC-08** — WHILE new messages arrive, THE SYSTEM SHALL retain scroll-follow
+  behavior without pulling a reader away from older messages; loading older
+  history SHALL preserve the reader's position and avoid duplicate entries.
+- **AC-09** — WHEN citations, uploaded files or generated files are opened, THE
+  SYSTEM SHALL preserve the correct resource target and existing access checks;
+  denied, expired or unavailable content SHALL not appear as a successful empty preview.
+- **AC-10** — IF a connection, stream, parse or server error occurs, THEN THE
+  SYSTEM SHALL display the applicable existing failure state and allow only the
+  retry/continuation actions supported in that host, without silently creating
+  a substitute answer or switching to an unrelated conversation.
+
+### 2.3 Platform annotation and QA operations
+
+- **AC-11** — WHEN an authorized annotator selects a question's annotation
+  action, THE SYSTEM SHALL offer adding that question to selected existing QA
+  entries as a similar question, preserving the selected conversation and text.
+- **AC-12** — WHEN an authorized annotator selects an answer's QA action, THE
+  SYSTEM SHALL prefill its matched question and answer for saving a new QA to
+  a selected QA library; existing similar-question editing/generation and
+  submission validation SHALL remain available.
+- **AC-13** — WHEN a QA action is triggered, THE SYSTEM SHALL associate it with
+  the correct source message and question/answer pair, including multi-turn,
+  streamed, form/choice and interleaved run-detail conversations; it SHALL not
+  accidentally use an adjacent system/tool message as a question or answer.
+- **AC-14** — WHEN an annotator changes status or navigates previous/next, THE
+  SYSTEM SHALL retain existing assignment restrictions and status choices, and
+  clear stale message/action state on conversation change; a failed QA write
+  SHALL not be reported as successful or mark the conversation complete.
+- **AC-15** — WHERE the host has no annotation/QA capability, THE SYSTEM SHALL
+  expose no annotation/QA write action, including ordinary client and log views.
+
+### 2.4 Read-only behavior, isolation and compatibility
+
+- **AC-16** — WHILE viewing logs/audit history or a read-only shared conversation,
+  THE SYSTEM SHALL permit authorized reading/copying/previewing but SHALL NOT
+  send messages, execute historical forms/choices, open a live generation
+  connection, stop a run or resume execution merely by mounting or interacting
+  with historical content.
+- **AC-17** — WHEN conversations are switched or multiple conversation panels
+  coexist, THE SYSTEM SHALL keep messages, drafts, stream state, pagination,
+  file selections and annotation targets isolated to their conversation/panel.
+- **AC-18** — THE SYSTEM SHALL preserve current identity, tenant, resource and
+  share-access checks for reads and writes; shared presentation SHALL NOT grant
+  access based on visibility of an operation button.
+- **AC-19** — THE SYSTEM SHALL preserve Chinese, English and Japanese copy for
+  the shared features and host actions; newly introduced copy SHALL be available
+  in all three languages without leaking application-internal labels.
+- **AC-20** — WHEN both applications are built and tested, THE SYSTEM SHALL
+  consume the shared implementation successfully; regression coverage SHALL
+  include both modes in client, both platform test panels, annotation/QA writes,
+  log read-only viewing and concurrent/switching conversations, together with
+  unchanged daily/task smoke coverage.
+
+## 3. Boundary cases
+
+Historical applications may be deleted, data may use older message formats,
+files/citations may be denied or expired, and streams may be interrupted.
+These cases preserve existing visible failure behavior and available history;
+missing fields do not authorize inventing content or permitting execution.
+
+A conversation may have multiple output messages between two user inputs.
+Annotation must maintain the chosen answer's identity and corresponding question.
+Readonly access disables execution, not legitimate citation/file access.
+A successful QA write is required before the related completion is reported.
+
+## 4. Next documents
+
+Design decisions, package contracts and adapters belong in `design.md` after
+specification confirmation. Implementation waves and AC-linked verification
+belong in `tasks.md` after design confirmation. The design is confirmed; `tasks.md`, `verification.md` and `e2e-checklist.md` track implementation and acceptance evidence.
diff --git a/features/v3.0.0/086-shared-app-chat/tasks.md b/features/v3.0.0/086-shared-app-chat/tasks.md
new file mode 100644
index 0000000000..81cc057c4e
--- /dev/null
+++ b/features/v3.0.0/086-shared-app-chat/tasks.md
@@ -0,0 +1,153 @@
+# Shared workflow and assistant conversations implementation plan
+
+> For agentic workers: use superpowers:executing-plans to implement inline.
+> Steps use checkbox syntax. Spec and confirmed design are the authority.
+
+**Goal:** Both applications consume one client-based workflow/assistant chat
+implementation, with platform QA/annotation actions and readonly history.
+**Architecture:** Source-shipped `@bisheng/chat` with pure core and controlled
+React components; host adapters retain auth/HTTP/connections/stores/business writes.
+**Tech stack:** TypeScript, React 18, existing workspace UI/Markdown tooling;
+existing app state libraries retained at host boundaries only.
+
+## Document status
+
+| Document | Status | Evidence |
+|---|---|---|
+| request/discovery | Confirmed | User chose workflow/assistant only |
+| spec.md | Reviewed and confirmed | User “继续”, 2026-10-21 |
+| design.md | Reviewed, amended and confirmed | Independent review, two resolved medium gaps; user “OK,在新分支开始开发” |
+| tasks.md | Reviewed for interfaces and AC traceability | Inline plan below |
+
+## Global constraints and preflight
+
+- Base `bdc886930`; work on `codex/086-shared-app-chat`. No commits/push/merge
+  unless requested. Preserve unrelated Config.tsx, FilterByUser.tsx and its test.
+- Daily/task production source unchanged; mixed shared helpers retain their old
+  contracts and are not pulled into package via application imports.
+- No global conversation singleton, new frozen imports or new strict-ignore.
+- Heavy checks serial; test workers limited to one.
+- Interfaces: Task 1 raw history DTO is consumed before host transformations in
+  Tasks 3/4, not after `getChatHistoryApi`; Task 5 QA context and late-result guard
+  are shared with Tasks 2/4. No circular app→package→app dependency permitted.
+
+## Task 1: shared core, raw history and identity
+
+Covers AC-01、AC-06、AC-10、AC-13、AC-17、AC-18.
+- [x] Add failing fixtures/tests for raw history roles/category/files/reasoning,
+  IDs, multiple answers, interleaved form/run rows, stream reconciliation,
+  malformed input, independent sessions and captured QA operation identity.
+- [x] Create strict `packages/chat` core/types/normalizers/reducer/QAPair modules.
+- [x] Run core tests RED→GREEN, strict package check and forbidden-import audit.
+Expected: package pure functions consume raw backend rows, stable source identity,
+no app providers required, no fabricated persisted IDs.
+
+## Task 2: shared client-based conversation presentation
+
+Covers AC-04、AC-06、AC-07、AC-08、AC-09、AC-15、AC-16、AC-19.
+- [x] Write interaction tests for messages, forms/choices, operation capability
+  gating, scroll/history, copy/citation/file actions and pending failures.
+- [x] Extract controlled client-based message/content/composer/interaction
+  components; inject resources/copy/feedback/export via host contracts.
+- [x] Configure package exports/styles/locales and both apps' source scanning.
+Expected: actual shared UI works without Recoil/router/query providers; readonly
+renders historical values but cannot execute or connect.
+
+## Task 3: client workflow and assistant integration
+
+Covers AC-01、AC-02、AC-05、AC-06、AC-07、AC-08、AC-09、AC-10、AC-17.
+- [x] Test raw initial/paginated history and both modes' runtime snapshots.
+- [x] Split raw request from current API display transforms; inventory legacy
+  callers; retain version, guest/share, background and auto-rerun guards.
+- [x] Route live and history interpretation through core; bind existing host
+  state/requests to shared presentation and input callbacks.
+- [x] Remove replaced client app-chat renderer/parser branches after consumers
+  migrate; preserve unrelated shell, selection/export and daily/task contracts.
+Expected: both modes genuinely consume package, not a source import back to client.
+
+## Task 4: platform test and history integrations
+
+Covers AC-01、AC-03、AC-06、AC-07、AC-08、AC-09、AC-10、AC-16、AC-17、AC-18.
+- [ ] Add host tests for unsaved workflow/assistant test payloads, init/stop/clear,
+  readonly network count and two-panel/switch-session isolation.
+- [x] Implement per-panel host adapters and replace workflow/assistant test and
+  app-log displays using shared core/UI; keep transport/API/auth at host boundary.
+Expected: no singleton overwrite, live controls only in interactive test sessions,
+no websocket connection or execution in historical viewing.
+
+## Task 5: annotation and QA host actions
+
+Covers AC-11、AC-12、AC-13、AC-14、AC-15、AC-17、AC-18.
+- [x] Deferred-promise tests: QA on A→B and A→B→A, reopen dialog, failed QA/status
+  writes; check exact source/target IDs and stale UI guards.
+- [x] Shared action payload captures task/session/source message/action/generation.
+- [x] Wire existing platform QA dialogs, validation, generation/editing,
+  navigation/status and assignment restrictions; completion is context-bearing.
+Expected: origin write targets A, never B; late A cannot change B status/modal.
+
+## Task 6: retire forks and whole-feature verification
+
+Covers AC-01、AC-05、AC-18、AC-19、AC-20.
+- [x] Audit callers; delete replaced platform/client workflow/assistant renderers
+  and parser branches, retain only genuine host adapters and out-of-scope users.
+- [ ] Shared/core/host tests; daily/task smoke; package forbidden-import and copy
+  audits; lint/types/i18n/both builds serial; browser host matrix from design §7.
+- [x] Fresh independent whole-change review; one evidence-based fix pass.
+- [x] Record failures/limitations and human whole-feature acceptance checklist.
+Expected: one workflow/assistant renderer/interpretation source across apps, no
+scope drift, no claims of checks that did not run.
+
+## Implementation ledger
+
+- 2026-10-21: Implementation and focused regression coverage delivered locally;
+  whole-feature live acceptance remains pending (see e2e-checklist.md).
+- Ruling: work in the explicitly requested existing new branch, retaining the
+  shared working directory; no new worktree and no unrelated git writes.
+
+## Actual deviations
+
+- Branch prefix: `feat/3.0.0/086-shared-app-chat` conflicts with existing
+  `feat/3.0.0`; used `codex/086-shared-app-chat`.
+
+
+### Final local implementation checkpoint (2026-10-21)
+
+- Shared core and presentation are consumed by both hosts. Raw initial/paginated
+  history reaches shared interpretation before display transformations; live
+  workflow and assistant frames use the same core. Replaced renderers are deleted.
+- Platform messages/history services are panel-local, with generation guards and
+  host EventTargets. History HTTP is outside Zustand state actions. Client state
+  contracts remain Recoil/Immer-compatible without adding frozen imports.
+- QA origin writes and modal/status updates are protected by captured operation,
+  session generation and dialog generation, including A→B→A and rejected writes.
+- Shared package 35 tests, platform 21 tests and client regression 94 tests pass.
+  Browser component fixture passed desktop/narrow dark, read-only and interactive
+  paths without API calls. Both application production builds have passed.
+- Independent review returned two P2 findings, fixed in one correction pass.
+  Source audit confirms daily/task production implementations are unchanged.
+- Task 4's full host transport/payload and readonly-network matrix remains a live
+  acceptance item, not claimed from pure shared-component fixtures. Task 6's
+  all-green repository gate is blocked by existing lint/type errors in untouched
+  files. See verification.md and e2e-checklist.md for exact evidence and limits.
+- No commit, push, merge, deployment or real QA/status writes were performed.
+
+
+### Client-first Markdown follow-up (user confirmed)
+
+- [x] Add failing scope/code-bar/media-default fixtures, then bring to GREEN.
+- [x] Own scoped client prose/highlight CSS and centralized rendering defaults
+  in `@bisheng/chat`; remove divergent host media configuration.
+- [x] Restore client code block/copy-label/inline selection behavior.
+- [x] Verify real browser computed styles against both host stylesheets, late
+  CSS loading, formulas, dark/narrow and untouched legacy sibling styling.
+- [x] Shared 37 tests + package strict types/lint; two app build logs recorded.
+- [ ] Real business/design acceptance remains the earlier unchecked checklist.
+
+### Integration authorization (2026-10-22)
+
+The user completed live flow testing, reported no major issues, and explicitly
+requested merging into the latest feat/3.0.0 and deleting codex/086-shared-app-chat.
+This supersedes the earlier no-commit/no-merge status notes. Individual stress,
+permission variants and designer sign-off are not inferred from that smoke pass;
+unchecked detailed checklist entries remain a coverage record, not an assertion
+that those specific scenarios were individually executed.
diff --git a/features/v3.0.0/086-shared-app-chat/verification.md b/features/v3.0.0/086-shared-app-chat/verification.md
new file mode 100644
index 0000000000..5b57d38210
--- /dev/null
+++ b/features/v3.0.0/086-shared-app-chat/verification.md
@@ -0,0 +1,400 @@
+# F086 verification
+
+Local date: 2026-10-21. Branch: `codex/086-shared-app-chat`.
+No commits/push/merge/deployment. Heavy checks ran serially; test workers = 1.
+
+| Check | Outcome | Evidence |
+|---|---|---|
+| Shared `vitest run` | PASS: 35 tests, 8 files | `/tmp/f086-shared-tests.log` |
+| Shared `tsc --noEmit` / ESLint | PASS | `/tmp/f086-shared-types.log`, `/tmp/f086-shared-lint.log` |
+| Platform annotation forms/page, panel isolation/history and assistant closing | PASS: 21 tests, 4 files | `/tmp/f086-host-tests.log` |
+| Client state/media/workflow activation/share token/task artifacts | PASS: 94 tests, 5 suites | `/tmp/f086-client-regression.log` |
+| Chrome shared-component fixture | PASS: send/form/choice each once, source-code copy; readonly guards; 390px body width; no page errors or API requests | `/tmp/f086-browser-final.log` |
+| Platform typecheck | PASS: 723 strict files | platform segment of `/tmp/f086-root-types.log` |
+| Client final typecheck | BLOCKED: 4 errors, all in unchanged skill-center files | `/tmp/f086-client-types-final.log` |
+| Root `pnpm lint` | BLOCKED: 83 platform errors in untouched files | `/tmp/f086-root-lint-final.log` |
+| Client lint | BLOCKED: 89 errors + 1 warning in untouched files | `/tmp/f086-client-lint-final.log` |
+| Changed-file ESLint audit | PASS: no diagnostics in changed/new files across those logs; shared package separately clean | comparison of log paths against git changed/untracked paths |
+| Suppression ledger | PASS: generated with each app's `lint:prune`; all counts shrink or stay equal, no added suppression | comparison against HEAD |
+| `locales` generation check / `check-i18n` | PASS: no new drift, zh-Hans/en/ja shared keys | `/tmp/f086-i18n.log` |
+| Frozen offline lockfile check | PASS, existing versions only, isolated modules-dir; no reinstall/purge of app modules | `/tmp/f086-lock-verify.log` |
+| Architecture guard on changed TS/TSX | PASS, no warnings/violations | `/tmp/f086-arch.log` |
+| Platform production build | PASS | `/tmp/f086-platform-build.log` |
+| Client production build | PASS; final rerun after restoring user timestamp recorded below | `/tmp/f086-client-build.log`, `/tmp/f086-client-build-final.log` |
+| Scope/import/line-count/diff audit | PASS: no daily/task production source diff, no package app/store/router/HTTP imports, new TS/TSX under 600 lines, no whitespace errors | git diff / package search / line count |
+
+## Existing gate failures
+
+Client typecheck: `src/api/skillCenter.ts` lines 137/151/159 and
+`src/components/Skills/useSkillCenter.ts` line 12 (request config/generic/hook
+signature). The earlier run had the same four pre-existing errors; one F086 file
+icon type issue was fixed and the final result returns to those four only.
+
+Platform lint: `selectComponent/useSubjectSearch.ts`, `controllers/API/index.ts`,
+`controllers/API/license.ts`, `test/f067LicenseBannerCopy.test.ts`.
+Client lint: `api/chatApi.ts`, legacy `CitationReferencesDrawer.tsx`, Skills test
+files, permission tree/keyword helpers and `SkillCenterPage.test.tsx`.
+`git diff --name-only` confirms these error files were not changed by F086.
+These were not hidden with new suppressions or expanded into unrelated repairs.
+
+## Test harness notes
+
+Client node-only regression tests used the existing Jest/Babel configuration plus
+a temporary config that removes browser-only global setup and supplies
+`__APP_ENV__.BASE_URL`. The first artifact smoke attempt failed on that missing
+test global; with it supplied all 94 tests pass. No production source was changed
+to accommodate this test-harness issue.
+
+Browser checks used an isolated temporary Vite fixture and real headless Chrome
+with all API requests blocked. The initial attempt hit the platform HTML-plugin
+login entry, so it was replaced with an isolated config; the final run passed.
+Temporary fixtures/configs and local Vite processes were removed/stopped.
+Screenshots were visually inspected in desktop light and narrow dark layouts.
+This does not claim end-to-end host transport, real QA writes or live version/
+guest/tenant permission coverage. Those remain in `e2e-checklist.md`.
+
+Review and correction evidence: `code-review.md`; meaningful error-frame RED
+fixture: `/tmp/f086-review-red.log`, followed by GREEN in the shared test log.
+
+Overall acceptance status: **PARTIAL** — implementation and focused local checks
+are ready; full-repo gates have existing failures, and live business/design
+acceptance is pending. See the unchecked module-based checklist.
+
+
+## Client Markdown parity follow-up
+
+- User authorized client-first Markdown implementation, styles and parameters.
+- `/tmp/f086-markdown-parity-red.log`: two new fixtures fail before the change.
+- `/tmp/f086-markdown-parity-tests.log`: 37 tests / 8 files pass after the change.
+- `/tmp/f086-markdown-parity-types.log` and `...-lint.log`: empty, successful.
+- `/tmp/f086-markdown-browser.log`: computed headings/margins/table zebra/code
+  token styles match under both host sheets; math renders; legacy sibling keeps
+  platform's original 16px h3 while shared h3 is 18px; no page errors, 390px body.
+- Browser screenshots inspected in light desktop and dark narrow layouts.
+- Production build logs: `/tmp/f086-markdown-platform-build.log` and
+  `/tmp/f086-markdown-client-build.log` (results checked before delivery).
+- Temporary fixture/config removed; Vite stopped. Scope is workflow/assistant;
+  original daily/task Markdown and CSS remain unchanged.
+
+
+## Platform composer alignment correction
+
+User screenshots exposed host controls still carrying absolute positioning inside
+the shared toolbar. Attachment selection now belongs to `ConversationComposer`
+(`onAttach` port); client uses its existing hidden-trigger/openPicker contract,
+and platform ChatFiles exposes the same operation without its old trigger UI.
+Platform voice has an inline client-style button in both shared composers while
+its existing modal placement remains unchanged.
+
+- Shared regression test was RED before the new attachment action port;
+  `/tmp/f086-toolbar-red.log`. After fixing, shared suite: 38 tests PASS;
+  package strict types/lint PASS (`/tmp/f086-toolbar-{tests,types,lint}.log`).
+- Actual platform voice/file-manager integration: two tests PASS
+  (`/tmp/f086-platform-toolbar-tests.log`), covering inline toolbar ownership,
+  actual file-picker delegation and preserved legacy modal positioning.
+- Real Chrome host-widget fixture: attachment/voice/send each 32×32, aligned on
+  the same center line with 8px gaps at 800px and 390px; file chooser opens;
+  no page errors. `/tmp/f086-toolbar-browser.log`; screenshots inspected.
+- New attach label ships zh-Hans/en/ja via generated shared locales;
+  check-i18n and generated-artifact checks PASS.
+- User's existing Vite servers remain running against 120:3002. Temporary
+  validation fixture/config removed and its separate server stopped.
+
+- Final platform typecheck: PASS, 726 strict files (`/tmp/f086-toolbar-platform-types.log`). Public file-picker ref/props are now typed; host integration tests rerun and both pass after removing the replaced trigger.
+
+
+## Client opening actions and preview suggestions follow-up (2026-10-22)
+
+Compared current code with the client MessageRemark/MessageBsChoose baseline in
+HEAD. The generic bubble footer had replaced right-aligned hover actions and the
+preview still owned a second, absolutely positioned suggestion widget.
+
+- Shared GreetingMessage restores client padding/title/avatar layout and a right
+  aligned footer, hidden on fine-pointer idle and visible on hover; touch remains
+  visible. Form/choice footers restore right aligned hover actions and the client
+  selected check appearance. Preview greeting uses its authorized TTS action,
+  not a copy button; compact platform voice uses the client's 14px glyph.
+- Shared SuggestedQuestions replaces all three old question renderers. Preview
+  workflow questions and input lock stay panel-local; assistant uses its existing
+  question visibility state. Suggestions sit in the message scroller and invoke
+  the existing panel-local, lock-guarded resend event. Sending/clearing removes
+  workflow suggestions. The input's floating recommendation widget is removed.
+- Preview-only dotted backgrounds are removed to follow the current client.
+- Shared: 40 tests pass, package strict types/lint pass. The greeting layout RED
+  fixture is `/tmp/f086-message-layout-red.log`; GREEN logs use `...-tests.log`.
+- Host: 7 panel/toolbar/suggestion tests pass. Platform typecheck: 729 strict files
+  pass (`/tmp/f086-message-platform-types.log`). Changed-file lint audit has no
+  diagnostics; existing unrelated full-repo lint failures remain.
+- Chrome with the actual platform audio control: idle opacity=0, hover=1, touch=1,
+  icon size=14, right aligned, recommended question click works within message
+  flow, no page errors. `/tmp/f086-message-browser.log`; screenshot inspected.
+- Temporary browser fixture/config removed and its separate Vite stopped;
+  normal local Vite servers remain running for user acceptance.
+
+- Final client typecheck returns only the same four existing skill-center errors; no F086 diagnostics (`/tmp/f086-message-client-types.log`).
+
+
+## Output option editing regression (2026-10-22)
+
+User reported that an output-node choice label could not keep editing. Its editor
+bound `value` to the saved label with no input-change handler, so the shared Input
+restored the old text on each edit. The existing blur/Enter commit flow now uses
+an uncontrolled draft (`defaultValue`); Enter used to confirm an IME composition
+does not finish editing. No global Input behavior or option IDs changed.
+
+Three meaningful fixtures fail before the fix and pass after it: continuous
+editing + blur commit with stable ID, Enter commit, composing Enter stays editable.
+`/tmp/f086-option-edit-red.log` and `/tmp/f086-option-edit-tests.log`.
+Targeted ESLint passes (`/tmp/f086-option-edit-lint.log`); local Vite hot updates
+this existing editor. Real user workflow acceptance remains with the running UI.
+
+## Workflow form presentation parity (2026-10-22)
+
+Platform now uses the same MessageFrame around the active workflow form as the
+client, including the application logo/name and body indentation. Its transcript
+padding follows the client. WorkflowForm no longer uses the platform-only
+`bisheng-label` class; scoped shared CSS pins label typography. Both workflow
+upload adapters render WorkflowFileField from @bisheng/chat, with the file icon
+inside the same clickable field and a normal-weight filename. Hosts retain
+upload services, limits, file paths and display-name callbacks. Other callers of
+the existing upload adapters retain their presentation.
+
+The attempted select extraction was withdrawn at the user's request after a
+client dropdown regression was reported. Single/multi-select imports, renderers,
+and dependencies remain as before this follow-up; select parity is deferred.
+No new select implementation or dependency remains in the shared package.
+
+- Shared package: 44 tests across 10 files pass; TypeScript and ESLint pass.
+  Logs: /tmp/f086-form-final-tests.log, /tmp/f086-form-final-types.log,
+  /tmp/f086-form-final-lint.log. Added coverage checks host-label isolation,
+  file-path/display-name submission, icon placement and disabled/loading guards.
+- Chrome: actual platform InputForm plus shared client presentation fixture have
+  matching computed label/file/text styles and widths. Existing single-select
+  works, mocked upload yields report.pdf and submits its storage path, no page
+  errors, no overflow at 390px. No real upload/chat request was sent.
+  /tmp/f086-form-browser-final.log; screenshot f086-form-parity.png inspected.
+- Platform strict typecheck and changed-file ESLint pass. Client typecheck still
+  reports the same four pre-existing skill-center errors; changed-file lint is
+  clean. Logs: /tmp/f086-form-platform-final-types.log,
+  /tmp/f086-form-platform-final-lint.log, /tmp/f086-form-client-types.log,
+  /tmp/f086-form-client-lint.log.
+
+## Merge latest release line (2026-10-22)
+
+User requested merging the newest release line into this feature branch. Saved
+all F086 work in local checkpoint 5e93dfff2 and a pre-merge recovery archive at
+/tmp/f086-premerge-files.tar.gz, then merged origin/feat/3.0.0 at 58996f12b.
+Three textual conflicts: release-contract.md keeps both F086 and F087 history
+entries; both InputForm adapters retain shared chat layout/upload/submission and
+adopt the upstream @bisheng/ui Select plus the upstream localized copy hooks.
+No old message renderers or deleted legacy select wrappers were restored. The
+upstream InputFormSkill and voice callback changes also survived the auto-merge.
+
+- Both production builds pass: /tmp/f086-merge-build.log.
+- 44 shared-chat tests, 27 host regression tests and one new merge regression
+  covering real shared Select single/multiple submission pass (72 total).
+  /tmp/f086-merge-chat-tests.log, /tmp/f086-merge-platform-tests.log,
+  /tmp/f086-merge-select-tests.log.
+- @bisheng/chat and @bisheng/ui typechecks/lint pass; resolved form files and the
+  new regression test pass targeted lint.
+- Browser: actual platform InputForm uses the upstream 32px Select, gray focus
+  border and 2px gray shadow; popup stays below the trigger, single/multiple work,
+  no errors or 390px overflow. All API traffic blocked. Screenshot
+  f086-merge-select.png inspected; /tmp/f086-merge-browser.log.
+- Full workspace gates remain blocked by upstream/previous issues: platform lint
+  85 errors; client lint 101 errors + 1 warning; platform typecheck one error in
+  clientVoiceDraft.test.tsx (47), client five skill-center errors. The affected
+  source/test files match origin/feat/3.0.0; no merge-conflict form diagnostics.
+  /tmp/f086-merge-lint.log, /tmp/f086-merge-types.log,
+  /tmp/f086-merge-client-lint.log, /tmp/f086-merge-client-types.log.
+- The new shared-UI Popover/Slider dependencies are already present in the local
+  workspace store; only their package-local links were added. No full dependency
+  installation or canvas relinking was performed.
+
+## Remaining platform Select migration (2026-10-22)
+
+User authorized migrating ordinary platform selects and explicitly retaining
+specialized panels. Inventory and module acceptance cases are recorded in
+platform-select-migration.md. The remaining application-log picker and twelve
+native value selectors now use @bisheng/ui Select; add-approver actions use the
+shared Dropdown. Two unused legacy selector implementations were removed.
+Native option-change tests were updated to exercise the real shared option menus
+and aria-disabled guards, with disabled click assertions retained. No permission
+or approval API/persistence contract changed; the approval skill's architecture,
+scenario/route/notification/outbox contracts remain unchanged.
+
+- RED: the new filter regressions fail before migration, and the updated
+  permission interaction tests fail against native selects.
+  /tmp/f086-platform-select-red.log, /tmp/f086-native-select-red.log.
+- 34 tests in six files pass: remote application search, paging/last-page guard,
+  selection cache and removal, log filtering/export, permission ADD/MOVE and
+  read-only guards, approval configuration and route/node selection contracts.
+  /tmp/f086-platform-all-select-final-tests.log. The two new approval cases were
+  rerun after awaiting their initial role-load effect to avoid an act warning:
+  /tmp/f086-platform-native-controls-tests.log (2 passed).
+- Changed-file lint passes. lint:prune only shrinks suppressions. Full platform
+  lint remains at the existing 85 errors, and typecheck still has only the same
+  upstream clientVoiceDraft.test.tsx:47 error (no new diagnostics).
+  /tmp/f086-platform-all-select-final-lint.log,
+  /tmp/f086-platform-new-select-lint.log,
+  /tmp/f086-platform-all-select-prune.log,
+  /tmp/f086-platform-all-select-final-types.log.
+- Platform production build passes: /tmp/f086-platform-all-select-final-build.log.
+- Browser with real RouteDialog and application filter: Select works in the
+  modal; clearing a flow exposes required validation; restoring/saving retains
+  numeric ID 12; no-condition sends an empty match_config, no UI sentinel;
+  remote app search and tag removal work. All API traffic mocked, no page errors.
+  /tmp/f086-platform-select-browser.log; f086-platform-selects.png inspected.
+- Temporary browser fixture/config removed and its separate server stopped.
+
+## Client workflow upload type regression (2026-10-22)
+
+The client adapter passed normalized upload categories (`image`) directly to the
+file input, rather than converting them into extensions. Restored the existing
+fileAcceptToInputAccept conversion. The upload picker handler now also checks
+extensions before single/batch upload, case-insensitively; unsupported picks
+show the existing translated type error and never reach the upload API. Mixed
+batches keep valid files, all-invalid picks preserve the previous value. The
+picker resets after every attempt so a file can be selected again. Unconfigured
+legacy fields keep their existing unrestricted behavior.
+
+- RED: 3 of the 4 initial component regressions failed before the fix:
+  /tmp/f086-file-type-red.log.
+- GREEN: 10 Jest tests across the component and accept utility pass, including
+  string/array image configuration, invalid single/multiple selection, mixed
+  batches and case-insensitive extensions. /tmp/f086-file-type-final-tests.log.
+- Changed-file lint passes; lint:prune shrinks the existing unused-var count by
+  one. Full client lint remains at 101 errors + 1 warning and typecheck returns
+  only the same five upstream skill-center errors, no changed-file diagnostics.
+  /tmp/f086-file-type-final-lint.log, /tmp/f086-file-type-prune.log,
+  /tmp/f086-file-type-types.log. No dependency installation or real upload.
+
+## Debug chat scrolling and code node editor bounds (2026-10-22)
+
+- Scroll cause: the 400px bottom threshold treated upward reading as following;
+  debug hosts also supplied no-op history loaders. Upward movement now suspends
+  following, returning to the bottom resumes it. Hosts only enable pagination
+  with an actual history callback, retaining audit-view pagination.
+- Shared scroll tests: 45 tests in 10 files pass; host regressions and existing
+  session/question checks: 7 tests in 3 files pass. Shared types and changed-file
+  lint pass. Logs: /tmp/f086-scroll-chat-tests.log,
+  /tmp/f086-scroll-host-tests.log, /tmp/f086-scroll-chat-types.log,
+  /tmp/f086-scroll-chat-lint.log, /tmp/f086-scroll-host-lint.log.
+- Browser with the real scroll hook: a 10px upward wheel movement remains at
+  scrollTop 110 after rerender and content growth; scrolling down resumes
+  following, with zero distance from the bottom after subsequent growth.
+- Code editor cause: react-ace defaults to inline 500px width and height,
+  overriding the existing utility classes. Explicit width 100% and height
+  160px restore node bounds; the expanded editor fills its dialog's remaining
+  grid row, with dialog height capped to the viewport.
+- Browser RED: a 300px host contained a 500x500 editor overflowing horizontally.
+  GREEN: editor 300x160, no overflow; expanded editor stays inside its dialog.
+  Logs: /tmp/f086-editor-red.log, /tmp/f086-editor-green.log.
+- Editor changed-file lint passes. Temporary browser fixture/config removed and
+  its separate server stopped. All browser API traffic blocked; no backend writes.
+
+## Workflow bottom controls visibility (2026-10-22)
+
+- Root cause: bdc886930b removed global Tailwind important. React Flow's
+  `.react-flow__panel.left` then overrode `left-52`, placing controls behind the
+  higher-z-index sidebar. Controls were still mounted, not deleted.
+- Applied a local `!left-52` override to retain the original offset without
+  restoring global important.
+- Real-browser RED: left 15px, four buttons obscured by sidebar. GREEN: left
+  223px, all four buttons unobscured and clickable; create-note callback passed.
+  /tmp/f086-controls-red.log, /tmp/f086-controls-green.log.
+- Isolated temporary fixture/config removed and its dev server stopped.
+
+## Shared attachment file cards (2026-10-22)
+
+- Both hosts already render ConversationAttachments, but client supplied a legacy
+  FileIcon. Removed that host override and the unused renderIcon port; the shared
+  card now selects bisheng-icons Colored file icons for supported extensions
+  (Word/WPS, Excel/ET, CSV, PowerPoint/DPS, Markdown, HTML, text), with Outlined
+  PDF/generic fallback. Extension matching is case-insensitive.
+- Kept client-style card layout, unified semantic colors, fixed icon slots and
+  bounded long filenames with ellipsis/title. Media/image presentation unchanged.
+- Three attachment tests pass, shared package typecheck and changed-file ESLint
+  for shared/client files pass. Browser verified six file types, no overflow,
+  and download callback. Final screenshot inspected: f086-file-cards.png.
+- Temporary fixture/config removed and separate dev server stopped.
+
+## Annotation conversation identity and actions (2026-10-22)
+
+- Annotation host supplied empty app name/avatar to both conversation adapters.
+  Now reads existing /chat/info session metadata and supplies its flow_name and
+  flow_logo to workflow and assistant messages. Application editor/detail APIs
+  remain unused; late responses cannot overwrite another conversation's identity.
+- Restored small primary solid buttons for similar-question and QA actions,
+  with hover/focus visibility and the similar-question action aligned right.
+  Kept existing flag icon because bisheng-icons has no equivalent flag icon.
+- RED: two new identity tests failed. GREEN: 17 tests across annotation routing,
+  forms and metadata pass, including out-of-order metadata. Changed-file lint
+  passes. /tmp/f086-annotation-header-red.log,
+  /tmp/f086-annotation-final-tests.log, /tmp/f086-annotation-final-lint.log.
+- Isolated browser preview verified app avatar/name, hover primary buttons,
+  and clickable QA action. Temporary fixture/config/server and preview tab
+  cleaned up; no live backend annotation writes.
+
+## Accepted integration closeout (2026-10-22)
+
+- User completed live flow testing, accepted the functional result and requested
+  local merge into latest feat/3.0.0 followed by development-branch deletion.
+- Pulled origin/feat/3.0.0 e3f30d9b7e and merged it into the feature without
+  conflicts. Since previous sync 58996f12b6, upstream changed only MinerU backend
+  errors/tests/docs; no frontend files changed after the following regression run.
+- Fresh regression: shared chat 45 tests/10 files, platform focused suites 53
+  tests/11 files, client upload/accept suites 10 tests/2 files — 108 total pass.
+  /tmp/f086-close-chat-tests.log, /tmp/f086-close-platform-tests.log,
+  /tmp/f086-close-client-tests.log.
+- Both production builds pass on the upstream-integrated result:
+  /tmp/f086-close-platform-build.log, /tmp/f086-close-client-build.log.
+- Shared chat strict types and lint pass; workspace check-i18n passes with no new
+  drift. /tmp/f086-close-chat-types.log, /tmp/f086-close-chat-lint.log,
+  /tmp/f086-close-i18n.log.
+- Root gates still stop at existing platform baseline: lint 85 errors, none in
+  feature-delta files; typecheck one error at clientVoiceDraft.test.tsx:47. That
+  test matches origin/feat/3.0.0 exactly. /tmp/f086-close-lint.log,
+  /tmp/f086-close-types.log. These gates are not reported as passing.
+- Independent final acceptance review found no actionable P1/P2 issues; see
+  code-review.md. No remote push or deployment requested/performed.
+
+## Production HighlightJS initialization hotfix (2026-10-22)
+
+- Reproduced deployment error with the exact vendor-WRzjMHBJ.js production
+  artifact in a static browser: lowlight in vendor eagerly registered languages
+  against HighlightJS exported from vendor-editor, which imports vendor itself.
+  Build-only checks had not detected this runtime initialization cycle.
+- Kept both lowlight versions beside highlight.js in the existing editor chunk.
+  No library/version or development-server change.
+- RED: actual Vite-config regression put lowlight in vendor, unlike the core.
+  GREEN: 12 configuration tests and config strict types pass; platform production
+  build passes. /tmp/f086-highlight-red.log,
+  /tmp/f086-highlight-config-tests.log, /tmp/f086-highlight-config-types.log,
+  /tmp/f086-highlight-build.log.
+- Real browser loaded rebuilt static assets, displayed the login form and had
+  no JavaScript error logs. Static-only API 404s are expected, no backend used.
+- Focused independent review of the two-file fix returned no P1/P2 findings.
+
+## Uniform conversation code-line preservation (2026-10-22)
+
+- Removed raw single-newline-to-double-newline replacement from shared chat,
+  original client Markdown (daily/task) and legacy platform Markdown used by
+  audit/message/previews. Standard Markdown parsing now preserves code fences,
+  streaming incomplete fences and indented blocks without manufactured blank
+  lines. Paragraphs use the existing client whitespace-pre-wrap presentation;
+  legacy platform now uses that same paragraph rule.
+- RED: all five new regressions failed before the change. GREEN: 50 shared tests
+  pass, checking exact code DOM text and copied content for backtick/tilde fences,
+  intended blank lines, incomplete stream and indented code; prose/list/table
+  rendering remains correct. /tmp/f086-newlines-red.log,
+  /tmp/f086-newlines-tests.log.
+- Shared strict types/lint and both legacy changed-file lint checks pass
+  (existing stale-suppression notices only). /tmp/f086-newlines-types.log,
+  /tmp/f086-newlines-lint.log, /tmp/f086-newlines-client-lint.log,
+  /tmp/f086-newlines-platform-lint.log.
+- Focused independent review found no actionable P1/P2 defects. No new parser,
+  helper library, dependency or Vite server introduced.
+- Both production builds pass with the uniform newline fix:
+  /tmp/f086-newlines-platform-build.log,
+  /tmp/f086-newlines-client-build.log. No backend contract changed.
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/code-review-2026-10-21.md b/features/v3.0.0/086-vibe-runtime-delivery/code-review-2026-10-21.md
new file mode 100644
index 0000000000..c0a01daa46
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/code-review-2026-10-21.md
@@ -0,0 +1,67 @@
+# F086 Code Review Report
+
+**Overall: NEEDS_FIX(部署验收门未满足)**
+
+同一代理按 code-review 七维自审;没有独立代理评审;已完成Docker主链路与双架构原生容器验证,仍有交付门未完成。此结论不等于目前已有静态缺陷尚未修复。
+
+## 范围
+
+- 工作树:`codex/086-vibe-runtime-delivery`,代码基线 `origin/feat/3.0.0-vibe@4aa9ce69b2830b81162fe631b9c801057c40ee8b`。
+- 记录的 main 参照:`origin/main@4218ba7c138a90764a304681c3d8a5875b2c9c1a`;本轮只审F086相对既有Vibe基线的修改及新增文件,不将已有Vibe差异冒充本次新代码或声称已经合并最新main。
+- 真实接口/存储/Owner边界、默认应用包/镜像、CLI、独立代理、Compose/镜像CI、维护工具及文档;不扩张K8s、商业授权或新的管理页面。
+
+## 七维汇总
+
+| Dimension | High | Medium | 结论 |
+| --- | --- | --- | --- |
+| 边界条件 | 0 | 0 | 已核查hash/尺寸/安全解包、端口、启动、依赖锁、deadline、错误信封与故障阶段 |
+| 权限与认证 | 0 | 0 | scoped HMAC/持久nonce、natural-owner矩阵、SDK范围句柄及原F048/F049入口;Docker自然人/SAK主链路已验,跨租户部署及六项live权限现已通过 |
+| 并发安全 | 0 | 0 | Repository UoW/节点行锁/唯一键/savepoint、当前归属、ready事务、单写SQLite;MySQL强制同步竞争通过;DM8模型静态通过,实测按用户确认留中央回归 |
+| 信息泄漏 | 0 | 0 | 服务身份分离、SQL sealed_env、预检无正式凭据、包源与diagnostic脱敏、MinIO私有桶 |
+| 测试覆盖 | 2 | 0 | 本地及双原生容器通过;Docker主链路/MySQL已验,剩余门见H1/H2 |
+| 代码风格 | 0 | 1 | 变更Python门通过;全量前端lint的既有失败仍是合入门 |
+| 文档同步 | 0 | 0 | design/tasks/接口/表/安装配置及未验范围已同步 |
+
+## 未关闭交付门
+
+### HIGH H1:完整离线平台交付
+
+七个镜像已在amd64/arm64原生构建;Python/Node/static禁网、实际缓存、SQLite helper/备份、Node安装钩子/前端编译及CPython C扩展离线构建/失效通过。109完整CLI/SAK主链路、owner补包、SDK、HTTP/WS、恢复/删除及基础故障已通过。GitHub双原生工作流已在测试Tag v3.0.0-test-vibe通过,服务索引与目录已发布,109公开服务镜像启动检查通过;标签拒绝分支本地验证通过。完整离线平台安装及目录导入仍未演练。T053已关闭,详细证据见[live-validation.md](./live-validation.md)。
+
+### HIGH H2:剩余数据库与故障/升级验收
+
+真实MySQL五项同步竞争通过。2026-10-21修复多租户启动上下文并完成普通owner跨租户旅程、六项live权限、安装/编译/探活关键故障、快照确认故障、DDL后启动失败、长连接硬期限和真实存量升级/导入前回退。DM8按用户要求暂缓实测、模型静态审查通过,不再等待schema;Engine build停止不确定、节点失联、上传ACK丢失等完整组合及完整离线新装仍未完成。对应T055、T056;不得将代表性故障冒充所有阶段穷举。
+
+### MEDIUM M1:既有质量门
+
+backend两项 `test_grant_subjects_five_endpoints_accept_app` 的permission_runtime初始化失败已在4aa9ce69基线复现(2失败/3通过)。frontend lint为95错误/1警告,基线与本分支归一化日志完全相同;冻结依赖后的typecheck及i18n通过。本轮不批量改动无关页面或抹去基线失败;合入/发行前需要维护线处理或明确既有门的处置。
+
+## 自审发现及修复记录
+
+| 风险 | 当前处理与本地证据 |
+| --- | --- |
+| 可用服务身份提交通用执行载荷 | 移除无生产调用方的POST internal/operations;只允许backend业务入口建意图;候选最终提交核对批准/current/pending/冻结delivery |
+| 数据路径和Engine控制可达面过大 | 单应用挂载的禁网数据helper;SQLite侧文件链接校验;独立runtime-control网络,Compose合同通过 |
+| 旧版本/同版本重建句柄复活 | credential/storage/egress按当前route operation绑定;候选staged和失败清理;TTL硬失效 |
+| 容器时区导致策略已过期 | UTC epoch与旧preview datetime转换;TZ=Asia/Shanghai合同回归 |
+| 历史记录截断新操作、幂等错误破坏事务 | node plan先SQL过滤、控制查询不截断;唯一竞争savepoint及准入回滚测试 |
+| 联网钩子取得包源凭据、补包污染原源码 | 仅受控下载阶段有源凭据;安装/build禁网,输入限制、临时副本和离线材料完整性测试 |
+| 旧Engine build中断后过早释放 | started/finished持久标记;未知保持预占,完整结束后复用;离线缺基础镜像不让daemon隐式拉取 |
+| 导出/快照删除留下副本 | 孤儿data-jobs按期限回收、保护活动helper/生产库;删除注册快照同时清理其传输块 |
+| 清理后丢失具体依赖错误 | 有界脱敏诊断写SQL,7天后下一轮对账清尾部,控制事实保留 |
+
+## 继续条件
+
+代码已可评审。Docker专用平台与代理已用于验收;按已确认的DM8静态审查口径及正式发行条件,继续执行 [E2E清单](./e2e-checklist.md)。不得将skip或CI源码当成通过,不进入merge。完整本地统计见 [validation.json](./validation.json),安装和离线流程见 [OPERATIONS.md](../../../src/runtime-manager/OPERATIONS.md)。
+
+## 真实部署复审(2026-10-20)
+
+已修复:MySQL重复键共享当前读、首次归属节点锁、data helper日志时间戳、HTTPS源/Node CA、容量并发采样、16121错误映射、owner恢复fence读取、预检错误详情/实际阶段及CI前置。对应真实失败和复测证据见live-validation.md。当前没有遗留的已确认F086静态缺陷;上述运行门未关闭,不能标LGTM或合并。
+
+## 2026-10-21增量七维自审
+
+按code-review同一代理逐项复核六个实现/测试文件:边界(非法租户ID)、权限(仅绑定可信tenant、不授予授权)、并发(ContextVar嵌套/异常/async任务隔离)、信息泄漏(无新增凭据/接口)、测试(修复前失败/修复后56通过及真实MT旅程)、风格(Ruff/arch-guard)、设计/任务同步均通过。新增静态HIGH/MEDIUM为0;完整Feature仍因H1/H2及M1保持NEEDS_FIX,不改为可合并。没有独立代理或外部评审;用户没有要求本轮再次提交。证据见[live-validation §8](./live-validation.md#8-2026-10-21多租户修复故障及存量升级)。
+
+## 模型审查与验收口径补充
+
+用户2026-10-21确认DM8不做真实测试,当前按模型/约束静态审查。复核15表发现2GiB/10GiB范围与INTEGER不匹配,已改BIGINT并加DDL-only扩容revision及守卫测试;28项回归通过,迁移图单head。静态问题已修复;真实DM8验证交中央回归,不再阻塞当前Feature。新增迁移未实际执行数据库升级;审查不把之前旧schema E2E当新revision实测。后续测试发行已实际通过GitHub与仓库索引核验,T053关闭;仍缺完整离线安装及平台目录导入。详见[审查结果](./model-review.md)。
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/code-review.md b/features/v3.0.0/086-vibe-runtime-delivery/code-review.md
new file mode 100644
index 0000000000..1ab0dae3d4
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/code-review.md
@@ -0,0 +1,29 @@
+# F086 开发合入 Code Review Report
+
+**Overall:PASS_WITH_WARNINGS(仅开发整合范围);完整交付验收门仍未结束。**
+
+日期:2026-10-23。目标基线 `feat/3.0.0-vibe@4aa9ce69b2830b81162fe631b9c801057c40ee8b`,运行实现源 `cda8dccf26298af91a1df277dcd1a49787b6b444`。用户明确授权先开发收尾、合并推送并仅定向验证。本报告不改变规格、不宣称全 AC 或正式发行通过。
+
+## 七维汇总
+
+| 维度 | 未关闭 HIGH | 结果 |
+| --- | --- | --- |
+| 边界条件 | 0 | PASS |
+| 权限与认证 | 0 | PASS |
+| 并发安全 | 0 | PASS:CI 同 release 跨 ref/Tag/manual 共享锁修复 |
+| 信息泄漏 | 0 | PASS(限定变更范围) |
+| 测试覆盖 | 0(开发范围) | PASS_WITH_WARNINGS:完整离线/剩余故障/发行验证另列 |
+| 风格与分层 | 0 | PASS |
+| 文档同步 | 0 | PASS:D1 现状快照/已知坑已修并定向复审 |
+
+## 审查及验证
+
+runtime、backend、交付三条分工只读审查,root 整合;无新运行/后端代码阻断。D1 设计旧当前快照已关闭;CI 并发项 T274 已修复。只重跑 CI 单条契约(先失败后 1 passed)、冲突涉及模型合同 17 passed,以及 Ruff/架构/差异/索引/迁移静态检查;不重跑全量或远端。完整分工报告见 [整合证据](../059-k8s-runtime-backend/integration-review/README.md)。
+
+## 保留边界
+
+T055、T056 保留 pending;完整离线安装/目录导入、剩余故障组合与最终交付汇总未完成。当前 92/94。原完整交付 NEEDS_FIX 报告保留为 [历史审查](./code-review-2026-10-21.md),不把历史未验门改写为通过。
+
+ARM 暂缓,DM8 仅静态;189 单节点不证明跨节点。旧测试 Tag 及旧 suite 只保留其原 source/date;R32 是 manager-only 私仓实证,不是最终版本全组件公开发行。旧失败/unknown writer 保持围栏,不为合并清理。CI 锁不控制仓库外写入;正式发行仍需新版本/权限或仓库不可变策略。
+
+[完整整合报告与来源](../../../docs/reviews/vibe-development-integration-2026-10-23.md)。
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/design-review-evidence.md b/features/v3.0.0/086-vibe-runtime-delivery/design-review-evidence.md
new file mode 100644
index 0000000000..c0f4b16833
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/design-review-evidence.md
@@ -0,0 +1,57 @@
+# F086 design 自审证据
+
+> 阶段快照:下文保留本阶段评审时的状态,不作为当前实现进度。用户现已确认spec/design;当前已实施范围、真实结果及剩余门见[live-validation.md](./live-validation.md)和[tasks.md](./tasks.md)。
+
+**日期**:2026-10-20。**方式**:同一代理按仓库 sdd-review 的24项design检查与C1–C8自审;未使用子代理,不声称独立评审。
+
+**结论**:设计文档自审 `LGTM`,见 [design-review.md](./design-review.md)。这是进入用户设计确认门的材料,尚未批准设计、拆tasks或编码。
+
+## 1. 评审输入与证据边界
+
+- [修订规格](./spec.md) 共63个AC、[规格复审](./review-evidence.md)、[发布合同](../release-contract.md)。
+- 当前代码基线4aa9ce69的F054/F055/manager/代理/SDK/CLI、Compose、CI/Release、错误码、迁移规范与constitution。
+- skill入口存在旧的“spec混合设计/单一暂停”描述,使用当前SDD-Guide和templates的spec纯What、design独立确认模型。
+- 本次只静态阅读与文档校验。没有运行容器、依赖安装、包源断网、API发布、授权联调、MySQL/DM8或K8s测试。
+
+## 2. 24项接手检查
+
+| 项 | 判据 | 结果与证据 |
+| --- | --- | --- |
+| 1 | 目标简明 | 通过,design §1用两句限定目标 |
+| 2 | 非目标明确 | 通过,§1、§8保留K8s后续及非商业边界 |
+| 3 | Feature特有约束 | 通过,§2固定Engine/目录、单写、双架构及离线条件 |
+| 4 | 宪法引用与门禁 | 通过,§2引用C1–C8并列实施落点,未改宪法,无backend编排权限/本地共享控制权威 |
+| 5 | 关键决策 | 通过,§3五项决策覆盖路线、存储、进程、依赖与SQLite |
+| 6 | 备选及否决 | 通过,每项均有备选、选定和具体原因 |
+| 7 | 重新考虑条件 | 通过,每项给出范围或能力变化的触发条件 |
+| 8 | 原因有依据 | 通过,逐次Engine build、3秒route缓存、C8、ABI和单写合同均有源码/规格锚点 |
+| 9 | 输入输出主线 | 通过,§4.1基线、§4.2目标图、§4.7时序与分支不混同 |
+| 10 | 可见字段/数据 | 通过,§4.3、§4.4、§6.1、§6.2含对象、清单、意图和响应字段 |
+| 11 | 模块职责/权限 | 通过,§4.2矩阵区分backend/Celery/manager/三代理/应用和SDK的访问面 |
+| 12 | 现状和设计区分 | 通过,元信息和§4.1明确基线尚无新能力,不把源码当前只支持v1写成已支持bundle |
+| 13 | 反直觉事实 | 通过,§5有12项,包括bind、API权限、短路由、nonce、SQLite和load引用 |
+| 14 | 忽略后的后果 | 通过,§5逐项写访问失败、越权、双写或数据丢失等后果 |
+| 15 | 具体处理位置 | 通过,§5指向模块/函数及设计节,§4.1链接真实源码 |
+| 16 | Outgoing契约 | 通过,§6.1/§6.2/§6.3覆盖HTTP、内部Python、消息及产物格式 |
+| 17 | Incoming依赖 | 通过,§6.3/§6.5覆盖原Owner、平台、Engine、工具和材料 |
+| 18 | 依赖变化风险 | 通过,各合同写明旧消费者、ABI、信封、scope及可变镜像等风险 |
+| 19 | 分层验证策略 | 通过,§7.1区分单元/合同、双DB、Docker、E2E和故障注入 |
+| 20 | 可操作验证 | 通过,§7.2提供现有测试/部署入口及8组实际验收;未实现工具和未运行结果明确标识 |
+| 21 | 后续/短板 | 通过,§8说明K8s、HA、浏览器隔离和传输瓶颈的边界与触发条件 |
+| 22 | 修订历史 | 通过,日期与“设计评审稿,尚未实现”状态一致 |
+| 23 | spec一致 | 通过,§7.1覆盖63个AC;四项修订分别落实默认、主体、双架构、回收 |
+| 24 | tasks偏差回写 | 不适用:按确认门尚未创建tasks,无实现偏差;已明确后续更新规则 |
+
+## 3. 自审中已消除的设计问题
+
+| 问题 | 修正 | 核对依据 |
+| --- | --- | --- |
+| 错把16123记为容量码 | 更正16123为runtime不支持、16125为容量不足 | common/errcode/app_factory.py的实际Code定义 |
+| 全局环境接口放在apps下可能与动态路由冲突 | 使用独立runtime-profiles/runtime-artifacts顶级前缀,增加注册匹配检查 | resource_tier.py现有“静态路由被apps动态路由吞掉”的注释和实际注册顺序 |
+| 完整镜像可能误走源码语言模板闸口 | image模式明确按镜像运行契约,不按源码SUPPORTED_RUNTIMES过滤 | 现有manifest_validator/模板门,spec AC-12 |
+| 日志/状态消费合同不充分 | 补只读manager接口、backend范围校验和原F054封装 | 现有只读API及原F054消费 |
+| npm/sdist安装钩子与源凭据边界 | 下载禁脚本、固定构建依赖、安装编译阶段撤去源凭据与传输票据 | spec AC-25、AC-29、AC-53的输入/离线/身份要求 |
+
+## 4. 进入下一阶段的条件
+
+用户设计确认前不创建tasks、不改runtime代码。确认后按设计顺序拆波次、执行tasks自审;实现阶段仍需task-review、e2e-test和code-review。本文的LGTM不替代双架构、离线、存储、权限或数据库的运行证据。
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/design-review.md b/features/v3.0.0/086-vibe-runtime-delivery/design-review.md
new file mode 100644
index 0000000000..b0e4e89c33
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/design-review.md
@@ -0,0 +1 @@
+LGTM
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/design.md b/features/v3.0.0/086-vibe-runtime-delivery/design.md
new file mode 100644
index 0000000000..65abbc8767
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/design.md
@@ -0,0 +1,619 @@
+# Design:F086 Vibe 开源托管与应用交付演进
+
+**关联**:[spec.md](./spec.md) · [Discovery](./discovery.md) · [版本合同](../release-contract.md)。[tasks.md](./tasks.md) 已拆解并自审通过。
+
+**版本/日期**:v3.0.0 / 2026-10-23。**状态**:设计已确认,Docker 实现已完成并与 F059 开发代码整合;完整交付验收未结束。已执行和未执行的检查以 validation.json、task-reviews.md、code-review.md 为准。
+
+**代码基线**:`origin/feat/3.0.0-vibe@4aa9ce69b2830b81162fe631b9c801057c40ee8b`;工作分支 `codex/086-vibe-runtime-delivery`。§4.1 的“基线”指该提交;其余合同已按本工作树实现同步。基础镜像最初拉取失败,用户提供代理后已完成Docker主链路、MySQL同步竞争和amd64/arm64原生镜像/禁网容器验证;DM8按用户确认仅做模型静态审查;旧测试 Tag `v3.0.0-test-vibe@755d4456e5` 已实际发行且有 109 启动证据;完整离线平台新装、剩余故障组合及包含后续 F059 修复的当前版本正式发行未完成。K8s 实现由 F059 承担并已在本次开发整合继承,详见其 design.md。真实结果见[live-validation.md](./live-validation.md)。
+
+**规格门**:第二轮 spec 自审通过,用户“修复这4个问题,更新spec并重新评审,通过后继续推进SDD流程”的条件授权已满足。design 已另获用户“确认”;后续不重复请求已完成阶段的确认。
+
+## 1. 目标与非目标
+
+以独立的开源 Python runtime-manager 执行应用准备、部署和数据库维护;平台负责存储、授权、发布事实和跨进程读模型。新增默认“预制运行镜像+应用包”,保留完整镜像和显式 legacy 构建,实现 Docker 在线与纯离线的可重复交付。
+
+本轮不实现 K8s 执行器、商业授权、新管理页面、多副本、自动跨机数据迁移、任意宿主命令或任意 SQL。K8s 后续归 F059;应用代码重新发布继续审批,数据库恢复是 owner 明确确认的独立维护动作。
+
+## 2. 关键约束与 Constitution Check
+
+遵循 [constitution.md](../../../docs/constitution.md) C1–C8 和版本合同 INV-27 至 INV-43;本节只补充本功能的实施落点。
+
+| 检查 | 本设计落点 | 评审判据 |
+| --- | --- | --- |
+| C1 | backend 新增业务 Service/Repository;Engine API 只在独立 manager | backend 不新增容器 SDK、socket 或宿主编排命令;存储迁移不把编排迁入 backend |
+| C2 | 新模型用 JsonType/LargeText 和共享时间默认;DDL-only Alembic;业务数据另走运维导入 | MySQL 与 DM8 同合同;macOS 静态通过不充当 DM8 回归 |
+| C3 | app/tenant 范围由平台已验证目标和租户 Context 建立 | 新 Repository 纳入自动租户过滤;变更用已加载实体;跨租户全局目录仅显式平台范围访问 |
+| C4 | 复用 permission.application;F054 先执行 owner-only 业务前置 | 非 owner 超管/租管不能恢复数据或导入私有包;不直接调用 OpenFGA |
+| C5 | 保留既有码段和公开信封;新增 reason 进入所属业务错误 details | 不复用其他 Feature 的码位;新增数字码需在实施时同步注册表、三语及测试 |
+| C6 | 服务身份分组,密钥用现有安全配置和注入流程 | 不把配置主密钥、文件传输票据或镜像拉取凭据下发应用 |
+| C7 | 本轮无新前端 HTTP/状态逻辑 | 原前端 API/错误语义保持;新增管理面使用 API/工具 |
+| C8 | 平台 SQL 为操作/归属/路由/策略权威,Redis 为加速/通知,MinIO 为内容 | 本地 JSON 不再作跨进程权威;缓存丢失可重建,生产 SQLite 是应用独有持久数据卷 |
+
+特殊约束:首轮 manager 绑定同一 Docker Engine 与宿主磁盘,单 Engine 一个活动控制进程;发布必审;正式 SQLite 单写入者;必须交付 linux/amd64、linux/arm64 服务及官方运行/准备镜像。宿主无仓库可用 `docker load`,平台和应用运行接口仍需在内网可达。
+
+## 3. 方案对比与选定
+
+### 3.1 应用准备路线
+
+- **备选**:A 每次源码发布构建 OCI 应用镜像,复用当前 builder,依赖 Docker 构建、基础层和构建缓存;B 预制运行镜像+版本化代码/依赖产物,按变化准备依赖和编译;C 所有开发者必须自行提供完整镜像。
+- **选定**:新版源码描述默认 B;C 为显式 `image`;A 为显式 `source-image`,旧描述继续 A。
+- **原因**:当前 `builder.py` 在每次预检执行 Engine build,并把结果写入 `AppVersion.image_ref`。B 才满足 AC-11 的平台侧不逐次 build/push;C 单独使用会把打包和离线供应链成本转嫁给普通开发者。
+- **重新考虑条件**:需要任意系统软件安装、多种 OS 或强容器镜像签名供应链时优先完整镜像;若后续需多节点分发,增加 bundle 分发适配而非把 Docker 本地目录当集群共享存储。
+
+### 3.2 存储与状态归属
+
+- **备选**:A manager 继续直连 MinIO、保留本地 desired-state/egress 文件;B backend 保存所有内容与执行合同,manager 经范围化 API 传输;C 给 manager 平台 SQL/Redis 账号。
+- **选定**:B。manager 仍做本地 SQLite 一致备份、迁移、恢复与受控数据查询。
+- **原因**:用户已确认平台存储解耦;现状路由缓存只有约 3 秒,本地 JSON 会使跨主机代理失效,违反 C8。C 使平台模型和租户实现泄漏到执行服务。
+- **重新考虑条件**:大文件传输成为可测瓶颈时,在同一 backend 授权合同内增加专用传输服务;不退回应用/manager 持有 MinIO 主账号。
+
+### 3.3 进程与权限
+
+- **备选**:A manager、入口、出口合为一个进程;B 独立进程,同仓库复用纯函数与镜像;C 融入商业 Gateway JVM。
+- **选定**:B。egress 继续使用 manager 镜像的独立入口;app-proxy 独立镜像;socket-proxy 独占 docker.sock。
+- **原因**:入口/出口处理不可信流量,编排 API 可创建挂载及执行体,能力仍接近宿主控制面;没有 root 或没有挂 socket 不会降低 Engine API 的实际权限。C 已取消且违反本轮开源边界。
+- **重新考虑条件**:将来执行器远程托管并强制细粒度操作授权时,可评估减少服务数量;先保留现有权限边界。
+
+### 3.4 依赖复用与离线材料
+
+- **备选**:A 搬运开发机 venv/node_modules;B 保存锁和目标平台依赖产物,在匹配镜像里一次性物化;C 每次启动访问源安装。
+- **选定**:B;Python 保存 wheels+哈希锁,节点在固定 `/opt/venv` 构造;Node 保存锁、包缓存及经验证的产物,处理源码相关安装脚本。
+- **原因**:venv 有路径、解释器及原生 ABI 假设,不能声明为任意机器可搬运;C 无法通过 AC-19、AC-22、AC-28。镜像预装“常见库”也不能证明依赖闭包齐备。
+- **重新考虑条件**:新的语言或二进制依赖需要新的 profile/preparer 合同;自定义系统库优先新增运行环境或完整镜像。
+
+### 3.5 SQLite 发布与所有权
+
+- **备选**:A 新旧版本共用同一 DB 并蓝绿迁移;B 结构变更停写维护、平台确认快照后迁移;C 数据库改为外置多租户关系库。
+- **选定**:B;无持久写入的应用仍可候选就绪后切换。普通 SQLite 写入应用即使无 DDL,也先停止旧写入者再启动共享生产卷的新实例;预检用隔离副本。
+- **原因**:F054 单实例与 F055 数据安全约束要求单写入;现状“迁移在前、容量检查在后”会改变仍服务中的数据库。C 是新的产品与数据迁移范围。
+- **重新考虑条件**:用户明确要求高可用/多副本且愿意调整数据库合同后,另立 Feature。
+
+## 4. 系统现状与目标实施结构
+
+### 4.1 基线真实调用链
+
+1. `bisheng deploy` → backend `POST /api/v2/apps/deploy`:包校验、归属及代码快照,由 F055 接收,Celery 接续扫描/预检。
+2. `precheck_service.precheck_build()` 生成短效 MinIO `code_url`,调用 manager build;`builder.BuildService` 解包、渲染语言模板并调用 Docker Engine build;`precheck_probe()` 拉起隔离临时容器探活。
+3. `VersionService.record_version()` **先提交审批,再插入版本**,插入失败补偿撤销审批;不能把静态图上的“先版本后审批”当实际事务顺序。
+4. 审批回调 → `publish_online_service._settle()` 先调用 schema migration,再经 F054 action 进行上线;现状容量时序与凭据切换需按 §4.7 修改。
+5. 浏览器 → nginx → app-proxy → backend 授权 → manager route → 应用容器;manager 的 desired-state 与 egress 策略保存本地文件。
+6. SDK 附件直达 manager 的 storage API;manager 通过 `MinioObjectStore` 保存附件及 SQLite 快照。egress-proxy 是 `runtime_manager.egress_proxy` 的独立进程,当前没有 `src/egress-proxy/` 独立项目。
+
+| 代码锚点 | 当前事实与本轮影响 |
+| --- | --- |
+| [precheck_service.py](../../../src/backend/bisheng/app_publish/domain/services/precheck_service.py) `precheck_build/precheck_probe` | 协议1保留;协议2在 PublishPipelineService 分支进入 RuntimeDeliveryCoordinator,不经过旧 code_url |
+| [builder.py](../../../src/runtime-manager/runtime_manager/builder.py) `BASE_IMAGES/BuildService` | 固定 python3.11、node20、static 模板;保留 legacy,新增 bundle preparer |
+| [publish_online_service.py](../../../src/backend/bisheng/app_publish/domain/services/publish_online_service.py) `_settle` | 调整容量、维护、快照、凭据及切换时序 |
+| [version_service.py](../../../src/backend/bisheng/app_publish/domain/services/version_service.py) `record_version/_insert_version` | 保留审批失败补偿和既有版本 Owner,新增不可变 delivery 绑定 |
+| [desired_state.py](../../../src/runtime-manager/runtime_manager/desired_state.py) `DesiredStateStore` | 移为仅本地观测缓存,权威操作和节点归属改平台 API |
+| [clients.py](../../../src/app-proxy/app_proxy/clients.py) `RouteClient` | 路由改读 backend 持久投影,入口不再需要 manager 管理密钥 |
+| [egress.py](../../../src/runtime-manager/runtime_manager/egress.py) `EgressPolicyStore` | 策略文件改成可失效本地缓存,权威来自平台策略 API |
+| [storage.py](../../../src/runtime-manager/runtime_manager/storage.py) `AppStorageService`、[appdb.py](../../../src/runtime-manager/runtime_manager/appdb.py) | 前者移入 backend 存储适配;后者保留本地 DB 执行,远程内容走 backend |
+| [app_data_service.py](../../../src/backend/bisheng/app_runtime/domain/services/app_data_service.py) `_require_owner` | 已有 owner-only 业务前置;新增私有补包/恢复沿用该主体边界 |
+| [docker-compose.yml](../../../docker/docker-compose.yml)、[CI](../../../.github/workflows/ci.yml)、[Release](../../../.github/workflows/release.yml) | 当前 manager/egress/app-proxy 为现场 build;改成正式镜像,CI 延续 backend 的双架构发布方式 |
+
+### 4.2 目标进程及数据流
+
+```mermaid
+flowchart LR
+  CLI[CLI] --> B[backend:发布/授权/存储/控制记录]
+  B --> SQL[(MySQL / DM8)]
+  B --> OBJ[(MinIO 私有桶)]
+  B --> Q[Celery:扫描与发布协调]
+  Q --> M[runtime-manager:准备/编排/SQLite]
+  M -->|内容传输与执行事实| B
+  M --> D[docker-socket-proxy]
+  D --> E[Docker Engine]
+  E --> A[应用容器]
+  U[浏览器] --> N[nginx]
+  N --> P[app-proxy]
+  P -->|授权和路由| B
+  P --> A
+  A --> X[egress-proxy]
+  X -->|只读策略| B
+  X --> B
+```
+
+| 进程 | 承担职责 | 资源及接口范围 |
+| --- | --- | --- |
+| backend | 文件/快照传输、附件、环境目录、操作/容量账本、路由/策略投影、原授权 | SQL、Redis、MinIO、F048/F049/F054/F055;无 Engine API |
+| Celery | 扫描、预检、发布/恢复协调及补偿 | backend 领域 Service/Repository 和 manager 意图接口;无本地应用卷、无 Engine 权限 |
+| manager | 下载固定产物、依赖物化、编译/探活、执行准入、容器生命周期、SQLite、对账/清理 | backend 范围 API、socket-proxy Engine API、专属宿主数据根;无平台 SQL/MinIO 账号 |
+| app-proxy | HTTP/WS、身份头清洗、OBO 注入、授权和路由读取、长连接撤销 | backend 授权/路由 API、应用网络;无 manager 写密钥/Engine 权限 |
+| egress-proxy | HTTP CONNECT/普通代理、目标和身份限制、连接撤销 | backend 只读策略 API、批准目标;无 manager 管理密钥/MinIO/应用卷 |
+| socket-proxy | 允许的 Engine API 转发 | 唯一 docker.sock 挂载者;仅 manager 控制网络可达,应用和通用代理网络不可达 |
+| 应用与 SDK | 按冻结启动约定提供业务,调用模型/检索/附件 | 仅应用范围凭据、当前 OBO、隔离 DB 卷;依赖和代码只读,无文件任务票据 |
+
+### 4.3 新增平台对象与持久化
+
+以下为实现字段。15 张新表统一由 `domain/models/runtime_delivery.py` 定义并注册;采用 SQLModelSerializable、JsonType 和共享时间默认。应用数据带 tenant_id,RuntimeProfile/Default/Node 是无租户列的全局目录/物理节点。JSON 仅作为载荷,不使用数据库 JSON 查询。
+
+| 表 | 关键字段和唯一约束 | 作用 |
+| --- | --- | --- |
+| `runtime_profile` | code/revision 唯一;language、contract_version、definition、content_digest、created_by、status | 不可变环境。id 为规范内容 SHA-256 前32位,便于出厂离线包跨安装定位;definition 内有 platforms 及 child/config digest |
+| `runtime_profile_default` | language 唯一;profile_id、generation | 超管 CAS 默认映射,仅影响未冻结发布 |
+| `runtime_artifact` | scope_key/kind/sha256 唯一;tenant/app、visibility、size_bytes、object_key、platform、producer_operation_id、state、confirmed_at、retained_until、metadata_ | source/dependencies/compiled/offline/snapshot 内容;confirmed 后才能消费;snapshot 元数据记录 integrity/操作关联 |
+| `runtime_delivery` | version_id 唯一;execution_mode、profile_id、resolved_platform、delivery_digest、content | 版本独立不可变绑定,源码/依赖/编译产物/启动命令在 content,原 AppVersion 四要素不改写 |
+| `runtime_operation` | scope_key/idempotency_key 唯一;request_digest、deployment/version/node、kind、generation、owner_epoch、stage/status/deadline/diagnostics_until、cleanup_status、payload/failure | 持久控制事实;秘密仅以 sealed_env 保存,读取执行计划时解密;外部没有通用建意图 HTTP 入口 |
+| `runtime_assignment` | app_id 唯一;node、generation、owner_epoch、active_operation_id | 单应用正式执行归属,未知旧实例不自动接管 |
+| `runtime_node` | engine_id 唯一;platform、memory/cpu budget、heartbeat_at、generation、status | 同一物理 Engine 不得多节点重复登记;预算行锁串行化全租户准入 |
+| `runtime_capacity_reservation` | operation_id 唯一;node、purpose、memory_mb、cpu_millicores、state | held/active/released;确认停止才释放,替换候选预占不提前借用旧实例名额 |
+| `runtime_execution_fact` | operation_id/sequence 唯一;node、generation、owner_epoch、exec_ref、phase、detail | 阶段事实和 ready target;相同事实重复ACK幂等,终态迟到清理仅当前归属可确认 |
+| `runtime_route` | route_key 唯一;operation/version/node/exec_ref、generation、owner_epoch、route_epoch、status、target | app:{app} 或 preview:{session};与应用原 Owner 状态事务切换 |
+| `runtime_egress_policy` | principal_id 唯一;operation/version、phase、destinations、credential_hash、policy_epoch、not_after、revoked_at | 只读网络投影;凭据按部署 key 和 phase/op 派生,重试稳定,仅保存 hash;UTC epoch 输出 |
+| `runtime_transfer` | operation/artifact/action 唯一;node、ticket_hash、expires_at、offset、parts、state | 8 MiB 客户端分块,服务端16 MiB分块限额;SQL确认和丢响应恢复 |
+| `runtime_storage_binding` | credential_hash 唯一;operation/version、namespace、object_prefix、state、expires_at | 生产 staged→active,预览独立有期限;当前 route operation 共同校验 |
+| `runtime_dependency_plan` | scope_key/input_key 唯一;operation_id、artifact_id、state | 首次解析别名,锁定 materials_ready/verified,不随包源变化重解析 |
+| `runtime_credential_binding` | operation_id 唯一;version_id、credential_id、state | 绑定 F049 原凭据;候选 staged 不能用于正式能力,切换后旧代拒绝 |
+
+Repository 唯一承担新增 ORM 查询和 UoW;创建操作以 savepoint 处理竞争唯一键,准入汇总在节点锁下执行,route/实例/版本终态通过原 AppStateService/VersionService 在同一平台事务提交。node plan 在 SQL 过滤历史终态,不能先截取一万行历史再在 Python 过滤,否则新任务会饿死。
+
+遵循 backend AGENTS 的新表 model discovery 建表合同,不为整张新表另写create-table revision。对已有F086表的size_bytes/offset扩容则用f086_runtime_byte_counters revision,由INTEGER改BIGINT,以匹配2GiB材料/10GiB快照;新库直接建立BIGINT,旧库DDL-only扩容,downgrade保留宽类型避免截断。MySQL并发已验;用户确认DM8本轮只核对模型/约束,真实验证留中央回归,不作为当前Feature门。数据导入使用 dry-run 工具,不进入 migration 或 API lifespan;回退先停止协议2写入并保留新表/产物,不能 drop 后直接重启旧控制器争抢生产卷。
+
+### 4.4 发布描述、运行组合与环境配置
+
+旧 `manifest_version: 1` 保持当前 schema/模板构建。新版 `manifest_version: 2` 新增 `delivery`,按判别联合分别验证;未知字段拒绝,旧平台收到新版时返回明确不兼容错误。新版 CLI 的 init 默认生成 v2,CLI 发 v1 不静默升级。
+
+```yaml
+manifest_version: 2
+name: example-app
+slug: example-app
+runtime: python3.11
+port: 8080
+delivery:
+  mode: bundle
+  profile: python311-standard@1
+  dependencies:
+    lock: runtime.lock
+    offline_artifacts: []
+  startup:
+    command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
+```
+
+v2 没有 delivery.mode 时为 bundle;没写 profile 时由 backend SQL 默认映射解析,再冻结确切修订和目标平台。完整镜像为 `delivery.mode: image`,含 `image` 的 digest 或经平台导入验证的本地镜像对象引用;禁止只冻结可变 tag。该模式可沿用镜像 Entrypoint/Cmd 或指定 command,不叠加平台 pip/npm 安装;其 runtime 仅作语言标记,不走源码模板的 SUPPORTED_RUNTIMES 闸口,而由获准镜像的 OS/架构、端口、启动、数据及身份契约验收,不能因不是 Python/Node 源码模板就被误拒。兼容构建为 `mode: source-image`,保留旧模板与约定。Procfile 只在未显式 command 且该 profile 契约支持时解析;冲突、路径不存在或端口不符在预检拒绝。完整镜像仍交付最小发布描述及能力声明。
+
+实际安装输入是 CI 生成的 **runtime-profiles.json**,不提交带假 digest 的默认目录。工具只读取 JSON(可作为 YAML 子集),每项 profile 形如:
+
+```json
+{
+  "code": "python311-standard",
+  "revision": 1,
+  "language": "python3.11",
+  "contract_version": 1,
+  "official": true,
+  "platforms": {
+    "linux/amd64": {
+      "runtime_image": "dataelement/bisheng-app-python@sha256:",
+      "runtime_digest": "sha256:",
+      "runtime_image_id": "sha256:",
+      "preparer_image": "dataelement/bisheng-app-python-preparer@sha256:",
+      "preparer_digest": "sha256:",
+      "preparer_image_id": "sha256:",
+      "abi": "cp311", "toolchain": {"python": "3.11", "pip": "base-image"},
+      "uid": 10001, "gid": 10001
+    }
+  }
+}
+```
+
+这是字段说明,`<...>` 为非可导入占位值,official=true 还必须提供 linux/arm64 同结构项。真实目录由双架构 inventory 产生,导入 API 不解析可变 tag、不会自动联系仓库验证完整运行契约:它校验主体、修订、平台及摘要元数据;实际兼容性由发行 CI 和发布预检验证。`import-profile --set-defaults` 先导入不可变修订,再 GET 默认 generation 并 PUT CAS;省略该开关只导入,不影响新发布默认。
+
+替换基础镜像需要创建 revision 2 再改 SQL 默认映射或应用 delivery.profile,不修改 RTM_IMAGE_PREFIX,不覆盖历史修订。客户自定义环境同样提供 runtime/preparer 配置 ID、ABI、UID/GID,且自行完成原生兼容测试。
+
+离线 docker load 可能没有 RepoDigest。manager 先按目录的配置 Image ID 查 Engine,并核对 OS/架构和配置 ID;有网允许缺失时仅按冻结仓库 digest 拉取。完整镜像也在 prepare 冻结实际 image_id,重试不靠同名 tag。配置 ID 是目录信任的本地映射,不等于 manager 重新证明发布者的供应链。默认 pull=never;不存在或平台不符就失败。
+
+### 4.5 产物布局、传输与应用启动
+
+**平台内容**继续使用现有私有 `bisheng-apps` 桶和同一 MinIO 实例;业务不要求第二套实例或 manager 专用桶。新写入对象统一规范命名:
+
+| 内容 | 对象路径 | 持久性和清理 |
+| --- | --- | --- |
+| 代码包 | `tenants/{tenant}/apps/{app}/artifacts/source/{sha256}.tar.gz` | 被版本引用期间保留 |
+| wheels/Node 包、锁与编译产物 | `tenants/{tenant}/apps/{app}/artifacts/{kind}/{sha256}.tar.gz` | 按 scope/平台校验;引用计数为零且过保留期后回收 |
+| 全局公开基础材料 | `runtime/public/{profile}/{platform}/{sha256}.tar.gz` | 超管导入,无私有凭据或客户包;应用只读引用 |
+| 数据快照 | `tenants/{tenant}/apps/{app}/snapshots/{operation}/{snapshot}.sqlite` | 独立限额/保留策略,owner 显式维护;唯一未确认备份受保护 |
+| 附件 | `tenants/{tenant}/apps/{app}/attachments/{key}` | SDK 相对 key 不变;生产停用/更新保留,显式单附件删除或 owner 整体删除清理 |
+| 预览内容 | `tenants/{tenant}/apps/{app}/previews/{session}/...` | 独立临时 DB/附件命名空间,到期清理 |
+
+旧对象路径不强制搬运:保留旧路径引用,backend 根据已有平台 app 归属映射访问,不能从用户给出的 key 推断租户;后续迁移双读校验但只有平台单写。删除 AppVersion 的引用或停用应用均不直接删除生产 DB。
+
+源码默认保持现有 50 MiB 压缩、200 MiB 展开、20,000 文件限制,均可配置;依赖与 SQLite 快照分别限额,不能沿用源码小包上限。上传/下载流式限额,校验实际字节、哈希及规范化路径。源码拒绝符号链接/硬链接/设备文件/绝对路径/逃逸;依赖产物中 Node `.bin` 等必要内部符号链接只允许落在同一解包根,拒绝外链、链接链逃逸及后续覆盖链接写出根目录。提取先入任务临时目录,验证后原子 rename 为不可变目录。
+
+节点目录由 manager 独占管理:
+
+```text
+{data_root}/
+  artifacts/{tenant}/{app}/{artifact_id}/        # 可丢弃的已验证材料缓存
+  envs/{tenant}/{app}/{environment_key}/    # 可丢弃的依赖物化缓存
+  tasks/{operation_id}/                    # 编译/预检临时目录
+  apps/{app_id}/db/app.db                  # 生产持久数据,独立备份
+  previews/{operation_id}/apps/{app_id}/db/app.db           # 隔离临时数据库
+  protected-backups/{app_id}/{operation_id}.sqlite        # 尚未获平台确认的本地备份
+```
+
+manager 容器内 `RTM_DATA_ROOT=/app-data`,宿主对应 `RTM_HOST_DATA_ROOT=${BISHENG_APP_DATA_ROOT}`;交给 Engine 的 bind 源必须使用宿主路径,不能使用 manager 容器内路径。正式应用固定挂载 `代码/本次编译产物 → /workspace/app:ro`、`依赖环境 → /opt/venv:ro` 或 `/workspace/app/node_modules:ro`、`应用 DB 目录 → /data:rw`,工作目录 `/workspace/app`。容器只读 rootfs、非 root app UID、cap_drop ALL、no-new-privileges、限制 CPU/内存/PID/日志、独立 tmpfs,无宿主端口映射。应用 UID/目录属主在安装自检与预备阶段核验;默认应用 UID/GID 为10001。正式manager容器沿用root进程以便交还专属挂载目录属主,不使用privileged或挂载宿主根;systemd/自定义非root进程需预置匹配UID与目录权限。不同profile UID无法满足时失败,不临时扩大宿主权限。通过Engine API持有的编排权限仍按高权限隔离。
+
+Python 启动器设置 `PATH=/opt/venv/bin:...`、禁写 pyc、`BISHENG_APP_DB_URL=sqlite:////data/app.db`,按冻结的 argv 启动。不在启动脚本 pip install。静态 profile 用固定 nginx 配置服务 `/workspace/app`,支持统一路径前缀;Node 保持标准模块解析目录。自定义 command 只在受限容器中执行,接口不接受 host bind、privileged、设备或 Docker 参数。
+
+### 4.6 依赖准备、缓存与离线补包
+
+**固定输入**:环境 key 至少含 scope(私有包含 tenant+app)、目标 OS/arch、运行镜像 child digest、preparer digest、解释器/ABI、工具链版本、规范化依赖锁完整哈希、安装策略版本及安装输入摘要。Python 从 requirements/pyproject 解析依赖时产生 `runtime.lock`,含所有传递依赖、wheel 文件哈希及解释器约束;同一版本重试用第一次冻结结果。若安装脚本读取源码,key 加对应源码摘要;无法证明输入边界时保守使用全部源码摘要。公共基础集合可以共享原始公开 wheel,私有产物和命中反馈不跨应用暴露。
+
+Python 流程:
+
+1. backend 建立 prepare 操作,manager 通过文件 API 下载代码、已验证基础集合及 app 补包;源凭据留在 manager/受控下载阶段,安装钩子拿不到平台文件票据。
+2. 在匹配的 preparer 镜像解析/下载或编译 wheel;预备工具链在镜像内,不依赖宿主 Python/gcc。联网仅限批准包源,使用受控 build egress 身份。锁定并上传 wheelhouse 和 lock 到 backend,平台校验持久化后标记 verified。
+3. 在确切运行镜像内 `python -m venv --system-site-packages /opt/venv`;挂载待建 env 目录,执行 `pip install --no-index --find-links=/wheelhouse --require-hashes -r /inputs/runtime.lock`。SDK 也必须在冻结锁或明确镜像自带固定清单中,不能启动时从公网补。
+4. 以 `--network none` 完成 import/启动预检,确保依赖已经物化;完整应用启动探活可单独启用批准的平台/业务接口,但不允许包源安装。成功后写完整性标记并原子发布 env 目录,运行时只读复用。
+5. 节点 cache 丢失时只下载已确认的 wheels/lock,在该运行镜像重新物化,不重新解析当前包源。原生 wheel 与 glibc/架构不匹配即拒绝,不能用“py3-none-any”下载参数替代编译。
+
+Node 流程:冻结 package-lock 和 Node/npm 版本;prepare 用 npm ci 及批准源/离线 npm cache,执行必要 install/postinstall 和 build,产物含完整 npm 包文件/缓存、锁、编译输出及兼容元数据。联网下载阶段与无网络安装验证分开;含源码钩子的安装结果按源码失效。保存的 node_modules 仅能在精确匹配 profile/平台及安装根中复用,节点丢失时从固定包文件离线 npm ci,不能靠开发机目录。缺少缓存 tarball、git 依赖或外部安装脚本资源则列出缺项并拒绝,不承诺 npm --offline 自行补齐。
+
+**离线材料**为 tar.gz,包含 `offline.json` 和相对路径文件;清单实际字段为 `schema_version:1, profile_id, profile_digest, platform, abi, files:{path:{sha256,size}}`。清单摘要及内容校验只证明完整性;来源通过已认证导入者、批准环境及断网验证确认,不把任意自签名当官方信任。
+
+| 现场条件 | 材料获取与失败语义 |
+| --- | --- |
+| 可访问批准公网源 | prepare 下载/锁定,再做断网依赖物化验证;下载失败不产生可复用半成品 |
+| 仅有完整内网包源 | 显式配置内网源及 CA,关闭公共 fallback;首次/新增依赖均从该源获取 |
+| 无源且完全断网 | 超管先导入公开基础集合;owner 用联网准备机上的匹配 preparer 制作应用补包,经 backend 导入;现场断网验证后复用 |
+| 新依赖缺失 | 返回具体包/版本/平台/系统库/缺文件;不进入审批,补齐后用同一固定计划重试 |
+| 无镜像仓库 | `docker save/load` 交付 runtime/preparer/服务镜像并建立本地验证映射;依赖补包仍单独准备 |
+
+下载与安装脚本分阶段执行:npm下载阶段禁用install脚本,sdist/PEP517所需构建依赖也先收集固定;离线编译和安装钩子阶段撤去包源凭据及传输票据,缺少构建后端或额外资源即列明缺项。不能把带私有npmrc的联网npm ci与执行任意postinstall合为一条不受控命令。
+
+工具 `src/runtime-manager/tools/runtime_materials.py` 提供 `catalog`、`export-profile`、`prepare-offline`、`pack-offline`、`verify-offline`、`import-profile`、`import-base`、`import-app-pack`。`export-profile` 从发行目录离线计算相同内容ID,不需要访问客户平台。prepare-offline 在有网准备机调用本地 Docker CLI 运行目标 preparer;这属于制作工具前置,**manager 服务自身只用 Engine HTTP API,不需要 docker 命令**。跨架构必须有对应原生构建机或经过验证的模拟构建环境,最终两种原生运行节点各验收。
+
+补包工具先联网下载,再在临时输出副本禁网安装验证;打包原下载材料,不混入源码相关 install-overlay/node_modules。导入 verified 表示内容及兼容元数据已核验,具体应用仍必须通过 prepare 的禁网物化、编译及探活。联网 Python 阶段仅 wheel、拒绝 direct URL/VCS/任意 pip 选项;requirements 的 -r/-c 必须在源码根内,含这些间接输入时保守使用全部源码 digest。Node 拒绝 VCS 和外部 file/link 依赖,按固定锁/cache 禁网安装;安装钩子需额外网络资源则失败。
+
+owner 导入私包即授权绑定该 app/profile/平台,后续发布自动纳入匹配材料;普通 SAK 发布可消费 owner 已授权绑定,不能自行选择或修改。PATCH enabled 只改变未来准备;历史 delivery 继续引用原材料。显式 manifest.offline_artifacts 选择私包仍需 owner。新增权限不改变 SAK 原有普通发布资格。
+
+### 4.7 发布、审批和上线状态机
+
+```mermaid
+sequenceDiagram
+  participant C as CLI
+  participant B as backend/F055
+  participant W as Celery
+  participant M as manager
+  participant E as Engine API
+  C->>B: deploy(源码/镜像描述)
+  B->>B: 归属/清单/安全解包/快照持久化
+  B->>W: 持久部署记录与 outbox
+  W->>B: 固定环境与 prepare 操作
+  W->>M: operation_id + 范围意图
+  M->>B: 下载代码/依赖;上传固定产物
+  M->>E: 准备与隔离预检
+  M->>B: 就绪事实 + 交付摘要
+  W->>B: F055 原入口创建审批与版本
+  B->>W: 审批通过,协调上线
+  W->>B: 原子预占容量/应用变更权
+  W->>M: 上线意图
+  M->>E: SQLite 停写维护(如需要)
+  M->>B: 上传并确认一致快照
+  M->>E: 迁移/启动/探活候选
+  M->>B: 匹配 generation/epoch 的 ready 事实
+  B->>B: 事务切换有效版本/路由/策略/凭据代际
+  B->>M: 已提交切换结果
+  M->>E: 回收可退休的旧执行体
+```
+
+**准备/预检**:F055 deployment ID 提前建立;预检 version_id 是预分配引用,不表示正式版本已经插入。任务状态 `queued → resolving → preparing → verifying → ready`,失败/取消/超时进入终态并回收;只有 verified 内容可被 F055 `record_version` 冻结并进入原审批入口。保留原“审批提交后版本插入、失败撤销审批”补偿合同,不重写 F025 引擎。
+
+**上线操作**:`queued → admitted → maintenance? → snapshot_confirmed? → migrating? → starting → ready → committing → serving → retiring → completed`。失败保留 stage、DB 是否已变更、旧服务是否可用及是否需要 owner 维护;状态机中的原子提交由 backend 事务完成,Engine 外部副作用靠事实/幂等和恢复推进,不能假装跨 HTTP/Engine/SQL 有分布式事务。
+
+| 场景 | 明确动作与失败结果 |
+| --- | --- |
+| 首次发布 | 预检和审批后正式容量预占、首次 DB 初始化、启动;失败无有效路由,版本保留待上线/失败原因 |
+| 无持久写入的更新 | 候选使用本版本代码/环境先就绪;事务切换后旧实例 grace 回收,候选失败保持旧有效版本 |
+| SQLite 普通更新 | 先容量预占,再维护停写并停止旧写入者;生产卷只给一个新执行体。预检永远用隔离 DB |
+| SQLite 结构变更 | F055 计算迁移计划/破坏性确认;维护、停止写入、一致快照获平台确认后执行受控计划,再启动 |
+| 容量不足 | 在停旧服务或 DDL 前拒绝;待上线原因是资源不足,保留审批结果供手动重试,不建重复审批 |
+| 审批预览 | session 专属卷和附件;采用冻结交付,运行身份按 F055 owner 能力例外,审计记录审批访问者;到期清理 |
+| 迁移/启动失败 | DDL 未执行可恢复旧兼容服务;已执行则展示维护/数据变化,未经 owner 确认不得回写旧 DB |
+| 停用 | 先持久化 stopped 意图和撤销入口/能力/出站,通知关闭 WS/CONNECT,再停止实例;未知状态记录并重试,不自愈拉起 |
+| 恢复已停用应用 | 走既有权限/状态入口,以已批准固定交付重新准入启动;不因此创建新审批或恢复历史 DB |
+| owner 数据恢复 | 单独 restore 操作,校验 owner 和 snapshot;维护停写、先保护当前数据,再替换验证和启动;代码版本不自动改变 |
+
+维护时先由平台持久化 maintenance 路由并拒绝新访问、通知关闭相关 WS,再由 manager 停止旧执行体并 inspect 确认;owner 数据写入 RPC 也与维护互斥。应用后台任务的停写靠执行体确实停止,不能只用网页维护提示代替写入隔离。
+
+**凭据代际**:旧有效版本凭据保持到候选切换;候选预检只获隔离 preview/test 凭据,正式候选凭据在 serving epoch 生效前不具备生产数据或平台能力。提交时更新当前 serving epoch/版本及旧凭据撤销状态;模型、检索、附件和 egress 在既有上界(应用撤销 ≤5秒)内拒绝旧版本。切换响应丢失先查询 operation 的 commit 状态,禁止重签/撤销另一代。外部即时撤权独立执行,不等待发布成功;网络抖动不能延长权限有效期。
+
+### 4.8 准入、执行归属、幂等和回收
+
+MySQL 竞争实现:重复幂等插入在 savepoint 失败后以共享当前读取得已提交的同摘要操作,避免 REPEATABLE READ 旧快照和独占锁升级死锁。固定节点下首次执行归属创建先锁 RuntimeNode,再锁/创建 RuntimeAssignment;不能只锁不存在的归属记录。外层业务事务仍统一提交/回滚。
+
+**原子准入**:manager 报告节点 Engine ID、CPU/内存预算和真实占用;backend 在节点预算汇总行锁/乐观版本事务中分配 reservation。现有 reserve/overcommit 参数保留,预算以配置保留值和观测的非托管占用计算;已纳入账本的 managed 实例不能再从剩余量重复扣除。节点事实过期默认 15 秒拒绝新增准入,运行应用继续走有效投影。预检/构建也占独立 purpose 资源,终态释放与 AC-63 一致;无商业数量计数。
+
+**执行归属**:平台 app assignment CAS+generation/owner_epoch+manager 每 app 串行锁;manager 一个活动进程控制一个 Engine,禁用多 uvicorn worker。每个敏感阶段向平台确认当前操作、deadline 与 epoch 后执行,丢失平台联系停止新的破坏性动作。不能仅靠租约自动转移所有权:旧进程可能已开始 Engine 调用,SQL CAS 阻止不了它。首轮 app 固定 node,接管前必须证明旧进程已停止且旧 Engine 任务/写入者已终止;无法确认则保持维护,不在另一节点启动第二写入者。
+
+**幂等**:客户端生成 UUID idempotency key,backend 绑定规范化 request digest;相同 key/不同内容拒绝,相同内容返回同一 operation。执行体标签含 platform instance ID、tenant/app/operation、generation、epoch、phase,名称从 operation 派生;创建超时后查标签/实例事实,存在即接续。SQLite migration 记录 plan digest、前后 schema digest 和已执行步骤;ddl 是否提交不明时只检查事实,不盲目重跑。上传以 snapshot ID/sha256 查询已确认状态;控制 RPC 无响应先 GET 操作,禁止无条件重发 write。
+
+**回收默认值(可配置)**:任务 deadline 默认 30 分钟;协议2每轮完成后等待5秒再对账,节点预算事实15秒过期;容量采样最多8路并发,单次只读内存统计,采样耗时也计入心跳间隔;任务终态后临时执行体和 scratch 目标 5 分钟内清理,最长正常回收期限 15 分钟;终态诊断日志保留 7 天,限制每任务大小。对未知存活的 Engine 对象,标记 cleanup_pending,保留对应 reservation、报警并重试,不能释放账本假装完成。取消先持久化 cancel_requested,再停止/kill、inspect 确认,最后 release;manager 重启扫描 SQL 未完成操作和本平台标签,进行同一补偿。
+
+扫描只处理明确归属本平台/当前节点的资源。回收白名单仅任务 scratch、临时镜像层引用和预检/准备容器;生产卷、被任意版本引用的产物和 protected-backups 必须排除。env 用完整性标记+原子目录完成可见,失效缓存先标记隔离;同 key 并发准备只有一个获准提交。取消/超时的写入者未结束,任何节点都不能接管同 app 的 DB 操作。
+
+旧模板 Engine build 在调用前持久标记 started,只有读到完整结束流才记 finished。中断且不能确认 daemon build 已退出时,报告 legacy_build_stop_unconfirmed 并保留容量,不因容器列表为空就清理/重建。该状态需要专门维护窗口核对并停止未知构建;不能仅通过 owner cancel 或修改缓存让资源重新可用。原生构建中断与恢复仍须在专用 Engine 故障验收中验证。
+
+依赖失败的 diagnostic_tail 先在manager按包源/代理/密钥及URL参数脱敏,再由backend限制16KiB写入operation/fact;临时容器回收后仍可定位缺失包。backend app_runtime.runtime_diagnostic_days默认7天,过期终态在下一轮node plan按100条批量清除诊断尾部,保留阶段/归属/摘要等控制事实。manager离线时顺延至恢复对账;未声明固定日历时刻必定擦除SQL。任务deadline通过BISHENG_RUNTIME_TASK_TIMEOUT_SECONDS同时提供给backend和manager;nonce120秒及严格小于60秒窗口是协议常量,不另设无效配置副本。
+
+### 4.9 SQLite 一致备份、恢复及数据界面
+
+manager 的 appdb 继续执行受控表清单/模式/分页/单行编辑/CSV 导出。backend AppDataService 做 owner-only 和审计,manager 不接收任意 SQL/文件路径;数据写与迁移/恢复使用同 app 执行互斥和短连接。
+
+快照用 SQLite Backup API 得到一致副本,不直接 `cp app.db` 遗漏 WAL。破坏性迁移前,停止业务写入者及后台受控写任务,核验执行事实,再 backup、integrity_check、计算摘要;通过 backend 文件上传流发送。只有 backend 记录 `confirmed_at`、真实内容 hash/bytes 和关联记录后才继续 DDL。MinIO PUT 成功但 SQL finalize 失败时状态仍 uploading,使用同 snapshot ID 重试查询/完成,不进行迁移。
+
+恢复由当前自然人 owner 发起 `snapshot_id + expected_current_generation + acknowledge_data_loss:true`,平台复核归属和范围并审计;manager 下载已确认快照至临时文件、校验摘要/schema/integrity,先保护当前一致状态,再在写入隔离下原子替换,处理原 WAL/SHM,重启并探活。恢复失败保留保护副本和维护状态;不擅自改代码版本、不把旧代码一定能读取新 schema 当保证。物理节点损坏按最后 confirmed snapshot 的 RPO 恢复。
+
+数据卷要求本机支持锁和持久化的文件系统;默认 SQLite WAL,不把 NFS/SMB 多节点共享盘视为可写生产部署。K8s 后续由 F059 定义 PVC/调度单写合同,RWO 不等于应用多 Pod 单写,需验证 CSI 与 SQLite 锁语义;本轮不做 PVC 交付承诺。
+
+数据界面调用仍经 backend owner/权限检查,但 manager 不在宿主进程打开运行中应用可控的 SQLite 链接:通过同一个 manager 镜像启动短命禁网 helper,只挂目标 app 的 DB 和一次性导出目录;readonly 根、cap-drop、非 root、每个128 MiB/100mCPU、最多两个、30秒期限,GC按节点标签回收。不是新增常驻进程或额外镜像;RTM_DATA_HELPER_IMAGE 必须本地可用,系统保留资源≥256 MiB。辅助进程的导入链保持轻量:共享异常使用 Starlette HTTPException,执行错误放入独立 execution_errors 模块,不经 appdb 导入 FastAPI/Pydantic 或 Docker 编排模块。数据页对同一页面的读取串行化,首次取表结构后复用到翻页/排序;切表与显式刷新重新取结构。锁竞争仍返回 data_busy,不再把普通并发读描述为维护。备份/迁移在停写后仍本地执行,检查 app.db 与 WAL/SHM/journal 链接,restore 后按冻结 UID/GID 交还目录。
+
+### 4.10 访问、策略及服务配置
+
+app-proxy 保留现有 backend authorization、会话校验、身份头清洗、路径前缀、OBO、WS close。RouteClient 改为 backend 的 route read API,返回已提交 `route_epoch/generation` 及平台验证的 target。manager 正常重启不改变 serving 路由;新请求超过旧 3 秒缓存仍可从 backend SQL/Redis 读到目标。连接失败触发一次路由刷新,失联超出既有恢复预算由平台/manager 健康事实使应用进入 F054 过渡状态;不让旧 ready 永久掩盖死实例。
+
+egress-proxy 以独立只读身份拉取 policy revision/增量,内存缓存有 `not_after`,最长授权有效期不超过既有撤销上界;撤销通知用于加速,Redis 通知丢失仍靠硬到期复核,不永久沿用本地文件。已有 CONNECT 在身份撤销时主动关闭。manager 重启不删除有效策略;backend 授权不可达并超期时关闭,而不是失效开放。
+
+运行网络分开:控制网仅 manager/socket-proxy;应用 internal 网仅入口、应用、egress;准备网仅 prepare 执行体/egress;平台网络按必要 API 访问。L3/UDP/DNS 规则继续覆盖绕 HTTP_PROXY 的直连、RFC1918/元数据地址、DNS 重绑定;部署工具校验 DOCKER-USER/nftables 的宿主规则实际生效,普通 manager 镜像不因此获得宿主 CAP_NET_ADMIN。特定宿主防火墙规则需部署侧受控安装,只有 internal network 和代理环境变量不构成完整出站隔离。egress 的平台目标限制为应用 API,不开放 internal runtime 文件/管理路径。
+
+| 配置组 | 读取方与建议变更 | 对已有应用的影响 |
+| --- | --- | --- |
+| backend `app_runtime` | 保留启用开关、manager 地址/控制身份,新增内部服务身份、产物/快照限额及 GC/任务参数 | 运行环境默认以 SQL 目录为准,配置不开第二份环境权威 |
+| backend 现有对象存储配置 | backend 文件 Repository 复用;私有桶配置在平台 | manager 删除 RTM_MINIO_*、RTM_STORAGE_BUCKET 和 minio 依赖 |
+| manager `RTM_*` | Docker host、数据根、固定 node_id、platform base、受控传输/事实身份、Engine 网络、准备源/超时/资源保留 | RTM_IMAGE_PREFIX 仍仅 legacy 输出镜像前缀;不是基础运行镜像配置 |
+| app-proxy 配置 | 授权/路由均指 backend,保留独立只读/授权服务身份 | 去除 APP_PROXY_MANAGER_SECRET;不持有写意图身份 |
+| egress 配置 | backend policy URL、独立只读身份、监听及 TTL | 不挂载应用数据根,不继承 RTM_HMAC_SECRET/Engine 地址 |
+| 应用注入 | 保留 BISHENG_APP_TOKEN、模型 base、DB URL、base path、SDK storage 三变量及可信 OBO | backend 生成 scope 限定 storage endpoint/token;不注入 MinIO、内部任务票据、镜像凭据 |
+
+同域 `/apps/{slug}` 保留现有产品行为。服务端清洗请求头不等同于浏览器源隔离,批准的应用前端仍运行在平台源下;不把该机制宣传为可安全承载任意恶意前端的浏览器沙箱。独立应用域/Cookie 策略属于后续安全增强,若客户要求运行不可信第三方前端需单独完成该门禁。
+
+Compose 的 Engine 控制网络为 internal 的 `runtime-control`,只有 manager 与 socket-proxy 加入;backend、入口、出口和应用不能直接连接2375。manager 同时通过 default 接收平台签名 RPC,并加入应用网络探活。v2 运行状态接口保留 backend_available/supported_runtimes/capacity/preflight 合同;容量读平台 SQL,失联时不以本地旧 desired_state 补充权威。
+
+出站策略用生成环境中的平台/模型/附件 URL 建可信目标,而非只放行一个 backend 主机名。重试用相同 phase/op 凭据,生产策略按 route.operation_id 拒绝旧代,恢复后继续有效;候选仅在当前 assignment/deadline 内放行。缓存最多5秒,失联到期关闭。UTC-naive SQL 时间转 epoch 时显式标 UTC;旧预览 local datetime 先转 UTC,避免容器 TZ 导致所有策略提前8小时过期。
+
+## 5. 已知坑与处理位置
+
+| 事实 | 忽略后的结果 | 本轮处理位置 |
+| --- | --- | --- |
+| Actor DTO 的tenant_id不绑定SQL过滤ContextVar;启动/首管理员回调没有HTTP tenant middleware | 新库多租户启动或首管理员投影失败;继承超管override还会使SQL作用域错误 | core.context.tenant.tenant_execution_scope;system_resource_reconcile与preset_dashboard_bootstrap逐资源绑定并恢复全部租户ContextVar |
+| Engine 的 bind 路径在宿主解释 | manager /app-data 有代码但应用看到空目录,SQLite 初始化到错误盘 | manager config/launch adapter 与安装自检;§4.5 |
+| 没挂 socket 仍能经 proxy 创建高权限容器 | 把 manager 当普通低权限 API,与入口合并后扩大宿主攻击面 | socket-proxy 控制网与 payload 白名单;§3.3、§4.10 |
+| manager 重启不停止容器,但路由现状仍依赖它 | 3秒后新请求失败,声称“应用不受重启影响”不成立 | app-proxy RouteClient、platform route Repository;§4.10 |
+| 本地 policy.json 在同机 Compose 看似可共享 | 代理跨机器或清空目录后无法授权/错用旧策略 | egress policy client+SQL 投影;§4.3、§4.10 |
+| SQL epoch/Redis 锁不能撤销已发出的 Engine 请求 | 超时自动接管后旧进程仍创建容器,SQLite 双写 | runtime_assignment、manager executor;§4.8,禁止不确定接管 |
+| wheel 通用名/venv 路径不证明 ABI 可移植 | arm64、glibc 或 shebang 不匹配,运行时失败 | profile 验证与 dependency preparer;§4.4、§4.6 |
+| Node install 脚本可能读业务代码 | 只按 package-lock 缓存会运行旧生成代码 | source-input digest/preparer;§4.6 |
+| SQLite WAL、结构变更和普通蓝绿不能混用 | cp 得到不一致快照,或两个版本写入不兼容 schema | appdb backup API+维护状态;§4.7、§4.9 |
+| 上传响应丢失与上传失败不是同一状态 | 重复快照或没有备份就执行 DDL | artifact finalize/status,snapshot ID;§4.9、§6.2 |
+| Docker load 未必保留 RepoDigest | 断网仍尝试拉取 digest,或按可变 tag 启错镜像 | image inventory mapping;§4.4 |
+| owner-only 不能靠超管可短路的普通权限检查表达 | 管理员未经 owner 允许恢复生产数据 | app_data owner business guard;§6.3 |
+| 清理本地“缓存根”可能顺带删生产 DB | 缓存失效变成数据丢失 | 目录分区、标签校验和保护引用 GC;§4.5、§4.8 |
+| socket-proxy 与 backend 共享 default 网络 | backend 可绕过 manager 直接访问2375 | 独立 runtime-control 及 Compose 检查 |
+| datetime.utcnow().timestamp() 会按本机时区解释 | TZ=Asia/Shanghai 的 policy 在签发时已过期 | policy epoch 显式 UTC,时区回归测试 |
+| 只按 version_id 允许网络凭据 | 同版本重建时旧 operation 凭据复活 | operation+当前归属/route fencing |
+| node plan 先 limit 再过滤历史 | 发布多次后新操作不再被发现 | Repository 在 SQL 过滤活跃/待清理/当前归属 |
+| 数据行接口只校验主文件路径 | 运行中应用切换 SQLite 链接访问其他目录 | 单应用挂载的数据 helper;停写维护检查所有 sidecar |
+| 数据 helper 导入控制面,且 UI 并发读取 schema/rows | 100m CPU 下启动耗时十余秒,并发请求被误报维护 | 轻量异常模块、按页面排队和表结构复用;§4.9 |
+
+
+## 6. 对外契约与依赖
+
+### 6.1 内部传输、鉴权和错误
+
+新服务间协议版本 2。管理意图、节点事实、文件传输、route/authz、egress policy 使用不同 service identity/密钥及允许动作,不能把同一密钥分发给所有代理。平台业务主体验证先于生成执行意图,manager 仅执行已签且 scope 匹配的意图。
+
+签名串为 `v2\nMETHOD\ncanonical_path_and_query\nsha256(body)\ntimestamp\nnonce\nkey_id`;path 使用 UTF-8 percent-encoding 规范化规则和共享测试向量,query 排序保留重复键,禁止 v1 的“query 不签名”用于敏感动作。接受严格小于60秒的时钟偏差(使120秒nonce覆盖完整窗口);nonce 在平台 Redis SET NX EX 120秒,变更操作还须 SQL 幂等记录,Redis 不可用拒绝新敏感请求。manager 验签通过后向 backend 认领同 operation/nonce,获得有期限的执行许可;已消费 nonce 拒绝,新 nonce+同操作内容可安全查询/接续。不能仅在 manager 进程内保存 nonce,重启就失去防重放。
+
+大文件先创建范围 upload/download session,backend 返回短效、绑定服务身份/operation/artifact/动作/大小/hash 的票据;它不进入应用。上传 streaming 到临时对象,校验后 finalize,超时 GET session 确认。下载只接平台 artifact ID,不接受任意 URL;服务身份还须匹配 assignment 和操作。读传输的可续传 session 可多次范围读取,上传 chunk 带单调 offset/摘要;它的“可续传”不等同于允许重放迁移/部署命令。对外 TLS 由部署终止,管理网仍需访问控制,内部跨主机传输配置 TLS/mTLS。
+
+新协议响应:`{operation_id, status, stage, generation, result, error:{reason,message,details}}`;接受异步操作 202,查询 200,冲突 409,过期/权限 403,范围不匹配隐藏对象存在性。此信封仅内部 v2;现有 UI v1、OpenAPI v2 和 SDK 维持各自原信封及 HTTP 语义,不将协议名混同为平台 API 版本。
+
+已有 16121 编排不可达、16122 构建失败、16123 runtime 不支持、16124 探活失败、16125 容量不足、16162 数据无权、16221 清单错误、16222 发布清单 runtime 不支持及 16259 迁移失败,由现有适配按真实来源映射。新增 artifact/profile/operation 失败先用所属领域既有错误+`details.reason`(例如 `dependency_missing`、`profile_incompatible`、`operation_conflict`、`cleanup_pending`);不能把 backend 维护人员错误当应用用户 401,也不能把 manager 内部 HMAC 失败当用户退出登录。实施若需新数字码,先重新扫描实际 registry,遵守所属161/162码段保留规则,补三语并检查生成工件;本设计不占用 164 K8s 段。
+
+### 6.2 已实现 HTTP 合同
+
+下表为工作树接线,不代表已部署实测。`I` = `/api/v1/internal/app-runtime/v2`;普通管理接口使用登录会话和业务错误信封,内部接口使用独立服务 HMAC 原始 JSON。
+
+| 接口 | 调用者 → 处理方 | 参数/副作用与校验 |
+| --- | --- | --- |
+| `POST /api/v2/apps/deploy` | CLI → F055 | 原 multipart 包/鉴权;v1旧模板语义,v2默认bundle;后端保存 source,Celery prepare/probe 后沿原审批入口 |
+| `GET/POST /api/v1/runtime/profiles` | 已认证读取/超管导入 → backend | POST RuntimeProfileDefinition;不可变 code/revision、双平台 official;普通owner/SAK不能写 |
+| `GET /api/v1/runtime/profiles/defaults`、`PUT /api/v1/runtime/profiles/default` | 已认证读取/超管写 → backend | language、profile_id、expected_generation;CAS,改变后续解析 |
+| `GET /api/v1/runtime/profiles/{reference}/{os}/{arch}?language=...` | 工具 → backend | 输出精确 frozen profile,用于离线制作 |
+| `POST /api/v1/runtime/materials/public?profile=...&platform=...&sha256=...` | 超管 → backend | 原始 tar.gz 流,公开基础集合;scope=public |
+| `POST /api/v1/apps/{app}/runtime/materials?...`、`PATCH .../materials/{artifact}` | 自然人owner → backend | 私包流/启停绑定 enabled;校验 app/tenant/profile/hash/ABI/文件;不改变冻结版本 |
+| `GET /api/v1/apps/{app}/runtime/state` | 自然人owner → backend | 读取当前generation、代码版本和操作ID供恢复CAS使用;无v2归属为0;非owner拒绝,不暴露节点/凭据 |
+| `GET /api/v1/apps/{app}/runtime/snapshots`、`POST .../restore` | 自然人owner → backend | 恢复含 snapshot_id、expected_current_generation、acknowledge_data_loss、idempotency_key;审计后202,停用应用不自动上线 |
+| `GET .../runtime/operations/{id}`、`POST .../{id}/cancel` | 自然人owner → backend | 只返回阶段、状态、failure、期限;不返回 sealed_env/内部执行密钥;已serving不能取消 |
+| `POST .../runtime/upgrade`、`POST .../upgrade/rebind` | 原应用operator → backend | 导入严格对照签名实时 inventory,需maintenance确认;rebind只用current已批准版本,不自动上线pending |
+| `POST /v2/operations` | backend → manager | 仅 operation_id 唤醒;无 env/命令/plan;manager向backend读取权威意图 |
+| `GET I/operations/{id}`、`POST .../{id}/claim`、`POST .../{id}/facts` | manager → backend | operationID、command_nonce或sequence/node/gen/epoch/phase;节点scope、代际、幂等和ready target校验。没有外部POST I/operations |
+| `POST I/nodes/heartbeat`、`GET I/nodes/plan` | manager → backend | Engine/platform/预算;返回可执行/待清理/当前归属操作及SQL容量,不依赖本地状态文件 |
+| `POST I/transfers`、`PUT .../{id}/content?offset=`、`POST .../finalize`、`GET .../{id}` | manager → backend | upload kind/hash/size/platform;范围票据;分块去重及SQL内容确认;取消/过期拒绝 |
+| `POST I/downloads`、`GET I/transfers/{id}/content` | manager → backend | operation/artifact;backend读MinIO返回字节,manager验证hash/大小;不重定向到MinIO |
+| `POST I/nonces`、`POST I/dependency-plans` | manager → backend | Redis NX/EX防重放;输入key对应第一次固定依赖产物,跨app/平台拒绝 |
+| `GET I/routes/apps/{app}`、`GET I/routes/previews/{session}` | app-proxy → backend | entry只读身份;只输出当前持久route/transition;业务授权仍走原独立HMAC入口 |
+| `POST I/egress-bindings`、`GET I/egress-policies` | manager申请/egress只读 → backend | operation/phase及受控包源;只读投影为hash+destinations+UTC期限,不含平台主密钥 |
+| `GET I/apps/{app}/execution`、`POST I/observations` | manager → backend | 当前node绑定;观测绑定operation/exec_ref/generation/route_epoch,有限自愈由backend原Owner协调 |
+| `/api/v1/apps/{app}/storage/*` | 原SDK → backend | 相对objects/meta协议及20MiB默认上限;当前代绑定token,backend独占MinIO |
+| `GET /v2/upgrade/inventory` | backend control → manager | 兼容协议1也可读;无容器env,后台核对真实Engine/标签/版本/原bucket |
+| `/v2/apps/{app}/db/*`、`.../status`、`.../logs`、`/v2/runtime/status` | backend → manager | 独立control HMAC;AppDataService/原日志授权不变;数据helper无任意SQL、命令、路径;日志脱敏 |
+
+新增 profile 等静态路由在动态 apps 路由前注册。协议2拒绝旧写意图/旧storage接口,协议1兼容旧进程语义;不保留可绕开新业务校验的通用远程创建操作入口。
+
+实际请求示例:
+
+```json
+{"operation_id":"operation-uuid"}
+```
+
+这是backend→manager的全部唤醒体。manager GET平台operation后得到 `{operation_id,app_id,version_id,node_id,generation,owner_epoch,kind,deadline,payload,result,fact_sequence,...}`;payload含 frozen `resolved_delivery`、tier、port、health、tenant_id 和仅供执行的解密 runtime_env,生产容器只接收应用允许变量。平台 operation_id/node/generation 来自SQL,执行事实来自manager,最终App状态/route提交仍由AppStateService校验批准版本、预期state/current/pending和冻结delivery。
+
+nginx 对两个离线材料POST路径单独配置2GiB限额、关闭请求缓冲和1800秒代理超时,避免一般API的1GiB限制使backend的2GiB合同不可达;后端大tar校验在线程中执行,实际Nginx检查随原生镜像CI。内部传输仍分8MiB请求,不扩大其他接口上限。
+
+### 6.3 业务授权与跨 Feature 接口
+
+新增动作严格采用 spec §2.11 矩阵。身份校验走 permission.application 的既有身份/业务授权协议;加载 app 后先检查自然人和 owner、tenant、状态,再调用需要的原领域动作,不用 `is_super_admin` 作为应用私有数据的豁免。service-account resource_owner 仍用于原发布归属,不因此伪装成人类 owner 调新增管理接口。
+
+| 内部接口(已实现) | 提供方 → 消费方 | 合同与变更风险 |
+| --- | --- | --- |
+| `RuntimeArtifactService.create_upload/create_download/finalize/content` | app_runtime → F055、manager传输endpoint | 只处理已验证scope和内容;跨事务要幂等,不直接修改AppVersion |
+| `RuntimeProfileService.resolve/import_revision/set_default` | app_runtime → F055校验/安装工具 | 默认只在冻结前解析;同revision不可漂移 |
+| `RuntimeOperationService.create/claim/fact` + Repository/admission/projection | app_runtime → F055发布协调及内部endpoint | 先账本再Engine,最终提交与F054实例状态原入口协调 |
+| `RuntimeManagementService.restore` | F054 → owner维护endpoint | 与已有数据动作共享owner guard及audit,不能加非owner管理后门 |
+| `RuntimeDeliveryCoordinator.prepare/probe` | manager client → F055 | 返回可冻结delivery,legacy也映射此流程;不能只返回image_ref而丢bundle引用 |
+| `VersionService.record_version/mark_runtime_online` 保留原入口 | F055 → 发布pipeline | 原四要素保持不可变;审批/version插入补偿不丢 |
+| `SchemaEvolutionService` 原受控计划 | F055 → manager | 破坏性确认/表声明规则保持,不把远端SQL字符串当计划 |
+| `AppRuntimeCredentialService.stage` + credential binding | F055/F049 → F054与代理 | 发布失败不早撤旧凭据;外部撤销仍≤5秒;不能新增第二凭据引擎 |
+
+owner 的运行环境列表只读公开描述,镜像拉取 secret 和全局维护接口不开放。owner 私有补包与production restore仅现有应用/已建归属草稿,避免用上传接口替代发布创建授权。
+
+### 6.4 SDK、CLI 与存量迁移
+
+| 消费者 | 基线/目标兼容策略 | 必须验证 |
+| --- | --- | --- |
+| SDK Python 0.1.1 | `BISHENG_APP_STORAGE_ENDPOINT/TOKEN/MAX_FILE_MB` 保持;backend实现原 relative objects/meta API、attachment metadata、错误detail和分页 | 原测试针对backend适配重跑,不能把FastAPI平台信封误发给旧RemoteBackend |
+| CLI 3.0.0 的v1清单 | deploy路径/认证/轮询保留,仍legacy | 旧CLI无需删除未知键或改Procfile才能继续发布 |
+| 新CLI v2清单 | schema/协议能力检查;init默认bundle | 平台不支持v2则明确版本错误,不静默回落 |
+| 原manager内部v1 RPC | 升级窗口停止新写任务,协调替换backend/worker/manager/代理到v2 | 升级完成关闭旧无nonce管理写入口;不长期保留可重放旁路 |
+| 已运行旧应用 | 保留代码、镜像、能力、审批和AppVersion快照;按原应用实例运维重建绑定新storage endpoint | 无源码重提/新审批;平台内部端点/临时凭据属于部署绑定,不修改已冻结业务注入配置 |
+
+升级步骤:备份平台DB/对象和应用卷 → 暂停新发布/维护任务 → dry-run盘点旧desired-state、env中的storage token引用、egress、Engine标签和版本,出差异报告 → 通过受限迁移API幂等导入assignment/facts/route/legacy delivery关联 → 将旧注入中属于端点/临时凭据的运行绑定映射为单独deployment binding,不改历史版本记录 → 协调升级控制协议并禁旧写API → 每应用按固定旧镜像和已有业务配置重建实例,更换backend storage endpoint/受限token → 核验后恢复发布。
+
+该次系统升级需要维护窗口,SQLite应用逐个停写重建;不是承诺升级时完全无中断。普通manager以后单独重启不重建应用。不能通过保留manager永久转发附件来掩盖未完成迁移;协议2不再承载附件API,默认服务镜像不装MinIO且不提供账号;旧模块只为协议1迁移兼容,legacy-storage为显式可选依赖。旧资产读兼容留在backend。只导入经过DB归属/平台标签验证的对象,遇到不一致先报告,不盲目收养其他容器。业务数据导入独立于Alembic,执行工具需符合脚本AGENTS及审计要求。
+
+回退:新发布任务未启用前可回退控制进程并恢复旧配置/状态备份;已有bundle版本后旧manager不认识其合同,不能直接回退二进制声称可运行,应先停止变更并保留现有执行体,使用兼容版本恢复管理。数据库DDL的回退仍须owner确认独立restore,不随系统回退自动覆盖。
+
+### 6.5 外部前置与发行清单
+
+| 依赖 | Docker首轮所需 | 风险和替换方式 |
+| --- | --- | --- |
+| Docker Engine API | manager通过socket-proxy HTTP;安装侧Docker/Compose CLI | 开放create/build接口仍高权限;daemon远程磁盘与本地路径不一致则首轮拒绝 |
+| 基础/服务镜像 | manager、app-proxy、egress、socket-proxy及Python/Node/static runtime/preparer双架构发行组合 | 有网拉取或离线load,版本/digest/平台匹配;不用manager镜像启动用户应用 |
+| Python/Node包 | 完整批准源或固定离线基础+应用补包 | 无源不能凭空获取新依赖;系统库/编译器在profile镜像 |
+| 平台服务 | backend API/worker、业务DB、Redis、MinIO、权限、审批和必要模型/检索接口 | 镜像可用、依赖可用、运行能力可用分别检查;TLS/CA/时钟也影响鉴权 |
+| 宿主磁盘/网络 | 映射一致、容量/UID、文件锁、应用/准备/控制网、宿主防火墙 | 重启后仍持久;安装工具证明绕代理直连和UDP被拒,不能仅看proxy日志 |
+| K8s后续 | F059需ServiceAccount/RBAC、executor、镜像可用性/分发、PVC/StorageClass、网络策略 | K8s API不自带构建器;legacy构建需另有builder和分发仓库/等价机制;bundle需节点材料投递适配,本轮未实现 |
+
+CI 使用新增 `.github/workflows/runtime-images.yml`,复用现有发行的Docker Hub账号方式,不改 backend 的既有构建脚本:
+
+- `dataelement/bisheng-runtime-manager:{version}`;egress用同镜像、`python -m runtime_manager.egress_proxy` 独立command。
+- `dataelement/bisheng-app-proxy:{version}`。
+- `dataelement/bisheng-app-python:3.11-r{revision}` 与 `...-preparer:3.11-r{revision}`;Node20与static同类固定修订镜像。首轮官方目录为当前已支持的三运行时,不扩展任意语言。
+- 每种镜像先构建 `{tag}-amd64`、`{tag}-arm64`,各自验证启动/依赖物化/ABI和运行契约,再发布multiarch index及版本清单;清单关联源码SHA、配置/child/index digest、SDK/工具链版本和测试证据。QEMU构建成功不代替双架构运行验证。
+- 正式 `docker/docker-compose.yml` 改 `image: dataelement/bisheng-runtime-manager:${BISHENG_RUNTIME_VERSION}` 等,无build;开发现场build放独立override,不成为安装前置。`app-runtime` profile仍可选装;只改镜像变量不会修改SQL中历史应用运行环境。
+- 离线发行物含双架构服务/runtime/preparer/socket-proxy镜像归档与SHA清单、profiles输入、公开基础依赖集合、SDK/CLI离线安装材料、必要包源/CA/网络/目录配置、安装检查与升级工具、备份恢复手册。应用新增私有依赖仍由owner准备补包,不宣称基础包覆盖任意应用。
+
+## 7. 验证与可观测
+
+### 7.1 分层证据与 AC 追踪
+
+| 层 | 覆盖的风险与方法 | 证据性质 |
+| --- | --- | --- |
+| 纯单元/合同 | 清单v1/v2默认、角色拒绝矩阵、缓存输入、哈希与解包、状态/幂等、nonce、快照确认、阶段错误映射 | 可无middleware,不能证明Engine/断网/DM8 |
+| MySQL/DM8与并发集成 | 唯一约束、CAS/预算预占、多worker、outbox、权限/租户、上传PUT后SQL失败 | 两DB分别验,DM8中央Linux回归 |
+| Docker集成 | bind实际路径/UID、环境复用、单写卷、prepare回收、网络直连拒绝、离线image ID | 在amd64和arm64原生节点分别执行 |
+| API E2E(必选) | CLI真实包→扫描预检→审批→上线→HTTP/WS/SDK→停用/恢复/数据恢复;非管理员及跨租户反例 | SDD后续使用e2e-test,保存请求/事实/资产校验记录 |
+| 故障注入 | 每个阶段崩溃/丢响应、manager超过3秒重启、旧owner未知、依赖缺失、平台授权失联、DB变更后启动失败 | 断言真实副作用与状态一致,不能只断言HTTP200 |
+
+| AC | 主要设计位置 |
+| --- | --- |
+| AC-01、AC-02、AC-03、AC-04 | §2、§3.3、§4.2、§4.8、§6.5 |
+| AC-05、AC-06、AC-07、AC-08、AC-09、AC-10 | §4.3、§4.5、§4.7、§6.1、§6.2、§6.4 |
+| AC-11、AC-12、AC-13、AC-14、AC-15、AC-16、AC-17、AC-18 | §3.1、§4.4、§4.5、§4.10、§6.3 |
+| AC-19、AC-20、AC-21、AC-22、AC-23、AC-24、AC-25、AC-26 | §4.5、§4.6、§4.8 |
+| AC-27、AC-28、AC-29、AC-30、AC-31、AC-32 | §4.6、§6.3、§6.5 |
+| AC-33、AC-34、AC-35、AC-36、AC-37、AC-38、AC-39、AC-40 | §4.7、§4.8、§4.10 |
+| AC-41、AC-42、AC-43、AC-44、AC-45、AC-46、AC-47 | §3.5、§4.7、§4.9、§6.3 |
+| AC-48、AC-49、AC-50、AC-51、AC-52、AC-53 | §4.3、§4.7、§4.10、§6.1 |
+| AC-54、AC-55、AC-56、AC-57、AC-58、AC-59 | §4.4、§6.4、§6.5 |
+| AC-60、AC-61、AC-62、AC-63 | §4.3、§4.8、§6.4、§7 |
+
+### 7.2 可操作验证入口
+
+以下为可执行入口;具体本次结果见 validation.json。单元/合同及配置验证已执行,真实部署/故障测试仍需专用环境。
+
+现有项目测试入口,分别从项目根运行(需已有uv依赖和对应配置):
+
+```bash
+cd src/runtime-manager
+uv run pytest tests
+```
+
+```bash
+cd src/app-proxy
+uv run pytest tests
+```
+
+```bash
+cd src/bisheng-sdk
+uv run pytest tests
+```
+
+```bash
+cd src/backend
+uv run alembic heads
+uv run pytest test/database/test_alembic_single_head.py test/app_runtime test/app_publish
+```
+
+架构静态门:仓库根 `bash scripts/arch-guard.sh`;Compose当前校验 `bash docker/verify-app-runtime-compose.sh` 需随新image/配置同步,旧脚本的现场build提示不能当新安装手册。
+
+工具与安装示例见 [OPERATIONS.md](../../../src/runtime-manager/OPERATIONS.md)。`verify-offline --profile profile.json --file app-offline.tar.gz` 验包;`prepare-offline` 执行联网下载加禁网安装验证;`import-profile --base-url ... --file runtime-profiles.json --set-defaults` 导入后设置默认。CI 容器集成入口为 `scripts/smoke_runtime_delivery.py`,在manager镜像内执行、真实Engine、内存产物端口,不等同于完整平台E2E。
+
+安装后由交付人员使用既有超管、两个不同租户的普通owner及非owner账号,凭据从现场安全环境注入,不写进文档。既有CLI在测试项目执行 `bisheng deploy`;平台审批页完成审批,浏览器访问 `/apps/{slug}/` 并验证WS、附件、模型/检索。管理API的完整路径见 §6.2。
+
+验收必须留存以下可对照记录:
+
+1. v2源码只改业务代码再次发布,Engine事件没有build/push;依赖缓存key不变;v1仍有legacy build;完整镜像没有pip/npm安装。
+2. 断开公网,现场先load镜像,发布一个已有依赖的应用;加未导入依赖必须在审批前失败;owner导入匹配架构补包后重试成功。用错误架构/篡改哈希/跨app引用验证拒绝。
+3. 不重启app-proxy/egress,只 `docker compose -f docker/docker-compose.yml --profile app-runtime restart runtime-manager`;持续请求超过3秒、保留WS/CONNECT,观察入口仍可从平台取得路由。另使授权服务失联验证超期关闭,二者不能混成一个用例。
+4. 在准备成功/失败/超时/取消后和运行中杀manager,记录标签容器、scratch、reservation;15分钟回收上界内核对已确认停止后的释放,未知执行体仍占资源并报警。生产DB/已引用产物hash不变。
+5. SQLite写入后执行schema升级,在快照PUT成功/SQL确认前注入失败,必须无DDL;确认后再在DDL完成/新实例启动前失败,必须显示维护与schema变化,不自动覆盖DB。owner指定快照恢复;非owner超管/租管/服务账号及其他租户都拒绝。
+6. 两个并发上线争抢最后一份资源、重复相同操作和响应丢失重试,核对只有一个有效assignment/正式写入者、没有重复迁移/快照/预占。模拟旧manager无法确认退出,禁止自动接管。
+7. 原SDK0.1.1对backend附件适配实测上传/分页/删除/超限/撤销;存量应用不重提代码完成系统升级;平台API/worker/代理分主机运行,清空本地控制缓存后仍保持一致。
+8. amd64与arm64各执行镜像启动、Python原生wheel、Node安装钩子、static入口及离线物化用例;保存镜像digest、源码SHA、节点平台、实际结果,未测平台保持未验证。
+
+日志统一关联 tenant/app/version/deployment/operation/node/generation/epoch/phase,脱敏签名、OBO、应用token、包源账号及临时文件票据;准备日志限额保留。关键指标:prepare/cache重建耗时、阶段失败、容量held/cleanup_pending、未确认快照、route/policy epoch滞后、nonce拒绝、owner拒绝、执行归属冲突、自愈预算。出现未知写入者/唯一备份未确认/清理超期必须可定位并报警,不自动删除证据。
+
+## 8. 后续事项与实施门
+
+- K8s 的 executor、Pod 内材料物化、PVC/准入/网络策略现由 F059 实现;不是本 F086 的 Docker 验收证据。不假设客户集群自带构建器或仓库。
+- 首轮单Engine固定节点,没有自动HA或跨机迁移;宿主盘损坏按快照RPO恢复。需要集群调度时单独设计存储和硬fencing。
+- 同源应用前端浏览器隔离尚未增强;不扩张现有受审批信任边界。独立应用域方案需后续产品/安全评审。
+- 不新建管理页面;profile、补包和恢复通过明确权限的API/工具交付,防止UI范围扩大拖延首轮。
+- 若依赖包体积导致backend传输成为瓶颈,先根据指标评估独立流式传输实现,仍保持平台授权与内容确认合同。
+
+**实施顺序建议**:平台对象/存储合同 → manager操作与bundle准备 → F055上线/SQLite/凭据切换 → 代理读投影 → 存量迁移及CI/离线交付 → 双架构与失败E2E。具体任务、补充范围、AC追踪和当前证据已写 tasks.md / task-reviews.md;未满足的部署门仍保留未完成。
+
+**本轮评审**:按sdd-review design-checklist及C1–C8自审通过,严格结果见 [design-review.md](./design-review.md),过程与证据见 [design-review-evidence.md](./design-review-evidence.md)。这不是独立评审或运行验证。用户已确认design并完成本地实施;最终验收仍须真实中间件、原生双架构和故障E2E。
+
+## 修订历史
+
+| 日期 | 改动 | 触发原因 |
+| --- | --- | --- |
+| 2026-10-20 | 首版设计评审稿;区分基线与目标,落实默认应用包、明确主体、双架构、回收及存储解耦 | 用户要求修复四项spec问题,复审通过后继续SDD;尚未实现 |
+| 2026-10-20 | 同步实际表/接口/模块、生成目录、owner材料绑定、短命数据helper、控制网、UTC策略和CI;代码进入集成验收 | 用户确认design后继续SDD;不把本地测试当部署证明 |
+
+### 2026-10-20 运行验收修订
+
+预检及预览在启动前保存starting事实;失败映射保留发布业务阶段,details包含operation_id、reason、runtime_stage、maintenance与data_may_have_changed,不用终态failed覆盖precheck_probe。Docker完整镜像、v1兼容构建和错端口拒绝已补验;正式发行与剩余运行门见live-validation.md。
+
+### 2026-10-21 多租户启动修订
+
+新增tenant_execution_scope只绑定经过业务验证的目标租户,不提供授权豁免。系统资源与预置dashboard投影保留F048 facade授权入口,SQL会话显式采用单租户可见集,并清除继承的管理范围/bypass;异常、嵌套及async任务完成后恢复原值。组合56项回归与109全新多租户环境启动、首管理员注册和子租户发布已通过。完整剩余验收以live-validation及后续证据为准;通用修复的release-line落地尚待提交阶段处理。
+
+2026-10-21真实验收补充:安装/编译/探活中断、快照SQL确认屏障、DDL后数据风险、代理硬期限与manager独立性、原协议1升级/导入前回退/幂等导入/rebind均已执行;历史AppVersion、approved pending及SDK0.1.1旧附件保留。明确App.state旧版本online不能替代maintenance路由/operation/真实入口检查。细节与未验组合统一见live-validation §8。
+
+2026-10-21模型审查修订:15表类型/共享默认/发现/租户/唯一约束及索引静态通过;字节计数改BigInteger并补单head守卫迁移。该变更不改变API或存储上限,修复既有合同的可表示范围。参见model-review.md;新增revision仅静态验证,未执行真实DB升级。
+
+### CI runner可移植性补充(2026-10-21)
+
+GitHub原生runner不保证安装ripgrep;镜像存在性及服务索引的错误识别使用系统grep -Eqi。缺标签允许构建;已有标签拒绝覆盖;认证或网络错误继续失败,不将其当成缺标签。v3.0.0-test-vibe实跑关闭发行验证门;完整平台离线安装与目录导入另行验收。
+
+### 2026-10-23 开发整合
+
+公共租户修复已先进入发布线并由 F059 继承;BIGINT 修复保留原迁移并显式汇合父链,保留 K8s 后续 tenant=None/资源 UID 约束。manager/app-proxy/egress-proxy 及 SDK/平台存储合同保持。测试 Tag 只证明旧 F086 版本发行,不证明当前 F059 镜像已发行;新完整验收门仍在。当前七维代码合入审查见 code-review.md。
+
+### 2026-10-29 数据页延迟修复
+
+在不变更 helper 128 MiB/100m CPU、禁网、非 root、单应用挂载及互斥语义的前提下精简导入链;页面读取排队并复用表结构,16167 使用现有三语文案。116 原镜像与修复临时镜像的单次只读对比见 [定向验证记录](../../../docs/reviews/vibe-data-latency-fix-2026-10-29.md)。临时镜像实验不等于正式服务更新,也不证明 K8s 性能。
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/discovery.md b/features/v3.0.0/086-vibe-runtime-delivery/discovery.md
new file mode 100644
index 0000000000..95c3fd8ff2
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/discovery.md
@@ -0,0 +1,104 @@
+# F086 Spec Discovery:Vibe 开源托管与应用交付演进
+
+> 阶段快照:下文保留本阶段评审时的状态,不作为当前实现进度。用户现已确认spec/design;当前已实施范围、真实结果及剩余门见[live-validation.md](./live-validation.md)和[tasks.md](./tasks.md)。
+
+**日期**:2026-10-20。
+
+**状态**:前序会话已持续澄清并形成评审输入;用户本轮要求“按照sdd流程进入开发流程”,据此进入正式规格整理。spec.md 及后续 design.md 的确认门仍分别执行。
+
+## 1. 需求依据与已确认方向
+
+需求来源是本会话和 [全开源技术评审文档](../../../docs/reviews/vibe-app-open-source-technical-design.md),不再沿用已经标为历史的商业化方案。
+
+| 编号 | 会话确认事项 | 本轮处理 |
+| --- | --- | --- |
+| D01 | “vibe应用功能,不需要进行商业化了,全部按照开源进行实现” | 去除许可证、固定在线应用额度、Gateway 归仓和为保护代码而 Java 重写 |
+| D02 | 附件接口迁 backend;manager 保留 SQLite 本地备份/迁移,经 backend 下载源码、上传快照,不直接访问 MinIO | 收入正式存储及数据维护需求 |
+| D03 | 对预制运行镜像+应用包的依赖安装、复用、挂载和启动进行细化 | 作为目标源码交付路线;完整镜像及旧源码镜像构建保持明确兼容边界 |
+| D04 | manager 镜像仿照 backend 经 CI/CD 推送 Docker Hub,Compose 配置发布镜像 | 作为首个正式交付即应满足的安装要求 |
+| D05 | 澄清纯离线首次发布、新增依赖和运行基础镜像替换入口,并“写入到技术文档内” | 基础依赖集合+补包、运行环境配置和旧版本冻结进入规格 |
+| D06 | “按照sdd流程进入开发流程” | 本次创建隔离开发分支、增量契约、正式 spec 和自审材料;不把该指令当作尚未编写的 spec/design 已获签收 |
+
+技术评审中的推荐决策继续作为设计输入。正式需求使用可验收行为表达,不把评审中的草拟字段和命令直接宣布为已冻结协议。
+
+## 2. 当前开发基线
+
+- 远端只读核对:`feat/3.0.0-vibe@4aa9ce69b2830b81162fe631b9c801057c40ee8b`,2026-10-19 合入当时的发布线。
+- 技术评审证据原固定提交为 `736ef593f3fc6e1634008c842d2604304d6f70a6`。与当前 Vibe 比较,manager、app-proxy、backend app_runtime/app_publish 实现没有变更;Compose 有增量,因此新设计须以工作分支实际文件复核部署配置。
+- 同时读取的发布线为 `feat/3.0.0@c7788301f32f21db4ebec1b54744827efc22ca98`。它在 Vibe 之外还有提交;本次不未经评估合入全部发布线改动。
+- 原工作区仍为 `feat/3.0.0-beta3@05941384f5a13f8f0773192f008e3eb5017f560b`,含其他未提交改动,全部保留。
+- 隔离工作区:`/Users/zhangguoqing/.codex/worktrees/vibe-runtime-delivery/bisheng`。
+- 功能分支:`codex/086-vibe-runtime-delivery`。使用 Codex 默认前缀;仓库已经有 `feat/3.0.0` 分支,不能同时建立同名路径下的 `feat/3.0.0/086-*` Git ref。
+
+本 Feature 只演进 Vibe 专属托管模块,因此以 Vibe 集成线为基线,沿用该分支 `release-contract.md` 的应用工场领域。若发现通用权限/审批/基础设施缺陷,按根 AGENTS 的发布线优先规则另外落发布线,再合入;本 Feature 不在派生分支改写通用功能后反向散播。
+
+## 3. 代码已存在与尚待实现
+
+| 能力 | 本次静态确认 | 实施增量 |
+| --- | --- | --- |
+| 源码包发布 | CLI 上传归档,backend 校验/快照,worker 扫描和构建探活,审批/版本/上线链路已存在 | 运行环境与代码分离、依赖产物、完整镜像准入及版本组合 |
+| 运行进程 | manager、app-proxy、egress-proxy 和 socket proxy 已有 Compose 服务 | 继续权限/故障隔离;预构建镜像、精简依赖和控制网络 |
+| 存储 | manager 预签名下载源码,附件/快照直接使用存储配置 | 平台文件及附件服务、任务范围认证、SDK 兼容;执行服务断开 MinIO |
+| 基础镜像 | builder.py 的 BASE_IMAGES 常量;manager Compose 仍本地 build | 运行环境目录与默认选择、受控替换;CI/CD 服务镜像发布 |
+| 运行状态 | 本地 desired-state.json;app-proxy 约 3 秒缓存后向 manager 查路由,egress 使用本地策略 | 平台持久化操作及事实、只读路由和策略投影、缓存可重建 |
+| SQLite | 本地卷、迁移、备份和数据服务已有代码 | 单活动写入、平台快照持久确认、幂等恢复和失败状态 |
+| K8s | F059 规格存在,不能认定为实际交付 | 后续独立适配,不将首轮 Docker 结果代替它 |
+
+证据入口:
+
+- `src/runtime-manager/runtime_manager/{builder,config,lifecycle,appdb,desired_state,egress}.py`。
+- `src/app-proxy/app_proxy/clients.py`。
+- `src/backend/bisheng/app_publish/domain/services/` 与 `app_runtime/domain/services/`。
+- `src/bisheng-sdk/bisheng_sdk/_storage_remote.py`。
+- `docker/docker-compose.yml`、`.github/workflows/ci.yml`、`.github/workflows/release.yml`。
+
+这些是静态读取结论。本轮尚未执行容器部署、断网安装、数据库故障演练或 K8s 验证。
+
+## 4. 首轮边界与分期
+
+首轮 Feature 交付对象是 Docker 开源托管闭环,包含平台存储、共享控制状态、数据安全、应用包/完整镜像、离线补包及正式镜像安装。优先完成前置可靠性再开放目标默认发布方式,不把“可以启动容器”当作整个 Feature 完成。
+
+K8s 执行实现保留在 F059 后续阶段;当前不取消 INV-33 的双形态产品语义一致要求。整个应用工场是否达到双形态交付,仍需 F059 真正实现和验证。
+
+本轮不增加平台管理界面和用户一键回滚。运行环境及离线材料通过管理接口/安装工具完成;数据库恢复属于明确授权的运维操作。历史代码恢复为一次新的正常发布,继续必审。
+
+## 5. 领域归属与既有规格影响
+
+F086 拥有新增的运行产物、运行环境目录、运行操作及事实/投影合同;不接管既有 App、AppVersion、ResourceTier、ApiCredential 或审批对象的业务写入归属。F054/F055 使用相应服务完成组合行为。
+
+| 原规格/设计 | 当前约束或事实 | 本轮需同步的边界 |
+| --- | --- | --- |
+| F054 AC-15 | 源码构建可运行镜像 | 为旧构建方式保留;新应用包/完整镜像交付使用扩展后的准备与运行语义 |
+| F054 AC-21 | 默认先启动新实例再退休旧实例 | 无持久写入或数据兼容时保留;SQLite 结构变更增加单活动维护分支 |
+| F054 AC-22、AC-50 | manager 重启应用继续服务、恢复后对账 | 需覆盖超过路由短缓存时间的新请求;拒绝过期所有者变更 |
+| F054 AC-39、AC-45、AC-56 | 持久资产、附件、数据库与数据服务 | 平台中介存储,应用数据卷继续外置;owner 数据操作不改为任意 SQL |
+| F054 design D5.1、manager/egress 本地状态 | manager 路由及共享文件依赖 | 持久投影成为跨进程读取依据,符合 C8 |
+| F055 AC-07、AC-42、AC-43 | 预检、迁移快照、冻结材料 | 增加复合交付、离线预检、持久确认和响应丢失查询 |
+| F055 AC-57 | 审批通过时重签并撤销旧凭据 | 版本切换凭据分阶段生效;外部撤权仍保持既有拒绝上界,不延迟安全撤销 |
+| F057 AC-20、AC-21、AC-22 及 storage 设计 | 附件公开语义和隐藏存储细节 | 更换服务地址/信封不能破坏兼容 SDK;manager 不再处于附件数据路径 |
+| F053 CLI 打包与发布、F055 AppManifest | 同一份清单消费合同 | 兼容原提交,新增描述须有显式版本和清楚失败语义 |
+| F059 AC-24、AC-25、AC-26 | 集群默认构建并分发应用镜像 | 后续在该 Feature 内增加 bundle/完整镜像模式;OCI 构建是可选兼容路线,仍满足 INV-33 |
+
+上述是 Impacted Specs 清单;具体重写与协议在 design 阶段逐条落档并随实现回写。当前 `spec.md` 是这次变更的验收依据;四项发现修订并复审通过后依用户条件授权生效,不伪造旧规格已经重新签收。
+
+## 6. 明确保留的约束与已知坑
+
+1. 发布仍必审;预检失败不生成审批或正式版本,已审批版本容量不足不重复创建审批。
+2. 当前版本能力切换与外部权限撤销是不同动作。不得用“保留旧服务”绕过已经失效的外部授权。
+3. 普通运行检索使用应用声明与访问者可见范围;F055 AC-27/INV-36 的审批预览 owner 能力例外仍保留,不能误写为所有预览按审批人能力判定。
+4. 应用专属 SQLite 持久卷不是平台共享控制状态;容器重建无损不等于单机物理盘损坏后零损迁移。
+5. 修改 Compose manager.image 不会修改应用运行镜像;当前也不存在可直接使用的新运行档位配置项。
+6. 纯离线缺少新包时必须补齐材料,不能从“镜像已导入”推断依赖闭包和业务接口都可用。
+7. 现有 release-contract 的 INV-27 至 INV-36 和继承版本约束继续生效;F086 不引入第二套授权、审批或平台主数据所有者。
+
+## 7. SDD 确认门与后续动作
+
+| 阶段 | 当前状态 | 依据 |
+| --- | --- | --- |
+| 需求发现 | 前序会话完成,本轮整理 | 用户在已有技术评审后要求进入 SDD |
+| 正式 spec | 四项发现已修订,第二轮自审通过;依用户条件授权进入设计 | 用户要求修复并复审通过后继续,确认范围为修订后的 spec |
+| design | 已自审通过且用户确认,进入实施 | 根 AGENTS §6、SDD-Guide §3,设计仍须单独确认 |
+| tasks | 未编写 | design 确认后按波次拆分和审查 |
+| 代码/部署 | 未开始 | spec 条件授权已满足,尚未越过 design 确认门 |
+
+自审报告及覆盖表见 [review-evidence.md](./review-evidence.md)。原技术评审内容作为输入随分支保存;未来 design.md 是实现方案唯一真相,不与评审稿并行维护两套有效设计。
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-checklist.md b/features/v3.0.0/086-vibe-runtime-delivery/e2e-checklist.md
new file mode 100644
index 0000000000..769b7789a6
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-checklist.md
@@ -0,0 +1,70 @@
+# F086 部署验收清单
+
+**状态:Docker主链路、双架构原生容器、多租户、关键故障及旧应用升级/回退验证通过,完整验收仍有缺口。** [真实记录](./live-validation.md)与日志已归档。复合场景只完成部分断言时保留未勾选,并在下表记录差额;不将skip计为通过。K8s执行器归F059。
+
+## 自动化入口与证据边界
+
+| 入口 | 实际覆盖 | 不覆盖 |
+| --- | --- | --- |
+| `test/app_runtime/test_runtime_*`、`test/app_publish/test_*delivery*` | 真 SQL SQLite 事务、业务服务、ASGI、清单、权限 guard、HMAC、完整性、阶段/路由/凭据控制;外部端口替换 | MySQL/DM8 真并发、Engine、平台全链路 |
+| `src/runtime-manager/tests` | 准备/复用/归属/恢复、SQLite、传输、出站和启动合同;多数 Engine 为端口替换 | 真实容器安装依赖和双架构效果 |
+| `scripts/smoke_runtime_delivery.py` | 已在两种原生Engine执行:禁网Python/Node/static、代码复用、SQLite helper/备份、Node钩子/前端构建、本地CPython C扩展;内存产物端口 | backend、MinIO、Redis、OpenFGA、审批和用户鉴权 |
+| `test/e2e/test_e2e_f086_runtime_delivery.py` | 专用部署上 owner/nonowner/跨租户快照与恢复拒绝、目录读取/default CAS权限 | 本文件不会代替人审批,也不创建或删除应用;发布/故障旅程见下表 |
+
+E2E 只在显式 `F086_E2E=1` 时运行。必填环境:`E2E_API_BASE`,`F086_E2E_APP_ID`、`F086_E2E_TENANT_ID`,以及 `F086_E2E_OWNER_USERNAME/PASSWORD`、`F086_E2E_NONOWNER_USERNAME/PASSWORD`、`F086_E2E_FOREIGN_USERNAME/PASSWORD`。使用预先创建、名为 `e2e-f086-*` 的专用应用和两个不同租户的普通账号;owner 不得为超管。密码通过安全环境注入,不写测试或报告。
+
+该自动化测试不创建平台资源,因此无删除 cleanup;下面的可写旅程只允许处理套件本次创建、已登记 ID 且名称以 `e2e-f086-` 开头的资源。执行前清理本套件残留,结束后按同一前缀和 ID 清理;禁止整库、整桶或 Docker prune。
+
+```bash
+cd src/backend
+PYTHONPATH=. uv run pytest test/e2e/test_e2e_f086_runtime_delivery.py -v
+```
+
+未设置开关时的 **6 skipped 仅证明安全门有效,不是 E2E 通过**。
+
+## 必须留证的旅程
+
+| 完成 | 场景与断言 | AC |
+| --- | --- | --- |
+| [ ] | CLI 上传 v2 原包 → backend 快照 → Celery 扫描/结构预检 → manager prepare/probe → 原审批入口 → 人批准 → ready/业务事务提交 → 浏览器 HTTP/WS。保存 deployment/version/op/route/asset 关联 | AC-01、AC-03、AC-06、AC-11、AC-17、AC-33、AC-48 |
+| [ ] | 只改业务代码二次发布;Engine 无 build/push,dependency artifact/environment key 相同,运行代码变化。依赖、ABI或源码安装钩子输入变化后不得误复用 | AC-19、AC-20、AC-21、AC-22、AC-23、AC-24、AC-25 |
+| [ ] | v1 项目在协议2通过显式旧模板构建;完整 image 不安装 pip/npm;坏入口/错端口/错架构在审批前失败,不生成上线记录 | AC-12、AC-13、AC-16、AC-17、AC-18、AC-60 |
+| [ ] | 无外网/无仓库:save/load精确镜像及目录、基础补包后首次发布成功。新增未供应依赖必须失败且无公共 fallback;owner 补包后成功。篡改hash/错ABI/跨app私包引用拒绝 | AC-07、AC-27、AC-28、AC-29、AC-30、AC-31、AC-32、AC-55、AC-59 |
+| [ ] | 超管登记新 profile 修订和 CAS 默认;历史运行/审批版本组合不变;普通owner、租管、SAK不能全局写。SAK可发布普通更新、消费owner已绑定材料但不能自行管理私包 | AC-14、AC-15、AC-30、AC-56 |
+| [ ] | SQLite 存数据;停用/恢复/代码升级后保留;预览数据库和附件写入不能进入生产。停用释放执行资源,删除按原owner动作清理明确资产 | AC-08、AC-09、AC-10、AC-38、AC-39、AC-41 |
+| [ ] | DDL 前停写、同一Backup API一致快照;模拟 MinIO PUT成功但SQL finalize失败,断言没有DDL。DDL提交后启动失败显示维护/数据可能改变;不自动覆盖DB | AC-37、AC-42、AC-43、AC-44 |
+| [ ] | owner 明确确认恢复某 snapshot;先保护当前库、同卷rename/WAL处理;停用应用保持停用,在线应用重探活。非owner超管、租管、SAK、跨租户均拒绝;审计有app/actor/op | AC-30、AC-45、AC-46、AC-47、AC-53 |
+| [ ] | SDK0.1.1实际经backend附件上传/列表/下载/删除、20MiB边界、撤销;模型接口和检索执行声明∩当前访客权限;预览仍按F055 owner例外计审计 | AC-05、AC-07、AC-51、AC-53、AC-59 |
+| [ ] | manager停止超过3秒,入口持续HTTP/WS、egress现有连接仍以backend读模型授权;平台权限/策略失联后硬期限拒绝并关闭CONNECT/WS。两种故障分别测试 | AC-48、AC-49、AC-50、AC-51、AC-52 |
+| [ ] | 竞争最后容量、多worker相同幂等键/丢ACK、重启重复fact;只有一份当前归属及一个正式SQLite写入者。旧Engine不可确定停止时不接管/不释放;不依赖本地JSON权威 | AC-34、AC-35、AC-36、AC-37、AC-40、AC-61 |
+| [ ] | 每阶段异常/超时/取消/杀manager后15分钟回收界内对账标签容器、scratch和reservation;未停确认仍占资源;保护生产库、引用产物和唯一未确认备份 | AC-26、AC-63 |
+| [ ] | amd64及arm64原生执行Python原生wheel、Node安装脚本/前端编译、static、实际非root挂载/数据helper/断网恢复。CI双平台成功才提供官方目录 | AC-02、AC-04、AC-31、AC-54、AC-55、AC-56、AC-57、AC-58 |
+| [ ] | 旧平台 inventory dry-run/hash确认/维护导入/重绑定;原版本及存储对象hash不改,旧桶差异明确拒绝。配置安装手册和系统回退演练 | AC-03、AC-08、AC-10、AC-13、AC-46、AC-57、AC-60、AC-62 |
+
+## 证据格式
+
+记录 commit/镜像配置ID与digest、节点平台/Engine ID、脱敏配置摘要、请求时间、实际状态转换、容器/挂载/网络事实、数据与对象哈希、失败阶段、清理确认及原资源未变化的证据。不得记录 HMAC、Cookie、OBO、应用token、包源账号或下载票据。
+
+MySQL已执行5项同步竞争与事务测试。按2026-10-21用户确认,DM8本轮仅做模型/约束静态审查,真实驱动、DDL和事务留中央回归,不再等待专用环境;不能将静态审查写成DM8实测通过。见model-review.md。
+
+## 本轮已取得证据与仍缺断言
+
+| 旅程 | 已验证 | 仍缺 |
+| --- | --- | --- |
+| CLI与审批上线 | 实际wheel/SAK→源码→Celery→预检→审批→上线,HTTP/WS/SDK | 全部阶段中断/取消矩阵 |
+| 复用与编译 | v1→v2产物复用;两架构五场景,C扩展/Node钩子源码变化失效 | 平台全链路的全部ABI错误组合 |
+| 完整镜像/v1兼容 | 实际预检、审批、上线、HTTP200、owner清理;错误端口审批前拒绝且无正式版本 | 错架构/坏入口的全部平台组合 |
+| 离线 | 基础镜像转存、禁网准备、缺包失败、owner导入、CLI自动重试 | 完整客户平台断网新装 |
+| SQLite与数据 | 预览库隔离、DDL前快照、owner恢复、停用保持、保护快照、正式删除清理 | SQL确认失败与DDL后启动失败已补验;仍缺每种拒绝主体/恢复组合 |
+| 入口与故障 | manager停止仍HTTP/SDK可用;backend停止503 | WS/CONNECT撤销硬期限及manager长连接独立性已补验;仍缺全部模型/检索场景 |
+| 并发 | MySQL强制12路幂等、2路归属竞争、容量/回滚/范围拒绝 | 旧Engine失联与全部多进程恢复;DM8本轮仅静态审查 |
+| 原生发行 | GitHub双原生构建/集成、服务索引/目录发布、109公开服务镜像检查 | 完整离线平台安装与目录导入 |
+| 维护升级 | 真实协议1发布/SDK→inventory→正式CLI dry-run→导入前回退→幂等导入→rebind;历史版本、pending、旧附件保留 | 全部旧桶差异/异常回退组合 |
+
+## 2026-10-21追加验收
+
+多租户启动修复56项回归通过,六项live权限全通过;探活取消/超时、安装取消、编译超时、杀manager恢复、snapshot最终确认故障、DDL后启动失败、长连接撤权/失联/manager独立性及真实旧应用升级已取得证据。逐项断言和剩余门见[live-validation.md §8](./live-validation.md#8-2026-10-21多租户修复故障及存量升级)。复合清单尚有未验组合时保持未勾选。7应用/23预占/3快照均已按正式owner动作清理,26原业务容器完整ID一致。
+
+## 2026-10-21 测试镜像发行补充
+
+T053已由GitHub真实双原生构建/集成、服务索引/目录发布及109公开服务镜像启动检查关闭,见[evidence](./e2e-evidence/2026-10-21-ci-release/README.md)。复合旅程中完整平台离线安装、目录导入、全部故障组合仍未验,不将整个复合行改为通过。
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/after-containers.txt b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/after-containers.txt
new file mode 100644
index 0000000000..8bdccdfa97
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/after-containers.txt
@@ -0,0 +1,37 @@
+3a1031b80e798a4022d0d888fdd6faa73a678ad0934aa5a46f527909079675f1 e2e-f086-runtime-manager
+467cd84f7ae1681b43d271870a4c084cfd745e2f19d476d4541d0b9afd9fe792 e2e-f086-nginx
+13dce73c7c9e10b780f5d8e2d1134601c27e2b2bd3d65ff424adcf5b68821ce0 e2e-f086-egress-proxy
+38a4d630aed61e9289b664fcab80c3bb490dd58ab5efde8162a67532f0c7a719 e2e-f086-docker-socket-proxy
+be2508c3f11cc5776368e0fc77d6b6f7e4e8690f68d966431b27d8fac51f13d1 e2e-f086-app-proxy
+183a352cda6837807c9bdf0b37cb08b7487c290f1291ebbf80f935d4873c8e62 e2e-f086-backend
+26e45f65b7a34a60f212dbd7db612af413e9a5fa4b42d9b05f7e157c9ac7e374 e2e-f086-worker
+97a853f4bd3f40da98bc889be806dec4613dd4f4fa4a593fa5179860fca1b7f3 e2e-f086-minio
+3bb22d50ce857ca41cb132c611cf3f0ab8b697ddd017020337aa7bc6ee25e2a8 e2e-f086-redis
+a419f368ef56f708f3a6badc10d0714453559cec9abcd4c7a04e8c5fcebe6011 e2e-f086-openfga
+7397e930c7b97fa3e5cff15b9fd2a84cfbb4388dc6e6958c4a54bffdea52d8a4 e2e-f086-mysql
+15efc4bf6f416e1d444c7e6a973f8cee5247a6927aec78ae6747aeb469c57218 dsh-backend-worker
+f58671d259b9aee7052280736fc69c85bd118ecd0c41526607ca33b7f140286d dsh-backend
+da6dd35c740fef011a34192c46355535bc0771a51213029eca3f37d966de1364 dsh-frontend
+0e07fed02c7428d6e071648447bfca233a33b10a5432dcbd805b207712caffb1 bisheng-information-api
+684c8e984eebfecf3d2dd57f37cab0ac16af8cf34cf6fd4649188dbe1fdaaf68 bisheng-frontend-dm
+7ff3f24efeeec17eebb25cee45dc5908ef6e442c1ff3026d50b19017c63dc659 bisheng-backend-worker-ocr-dm
+b2ad936ec5e763bf6429b83db9a7668f3c3d53cc2de3df93896d187e61b880a6 bisheng-backend-dm
+66cc654093a7216bce0f5bac44ee6d388522887d541d4b538dff86646a0fec0e bisheng-backend-worker-dm
+c6812b9eb270113dde7a4a8d0a757d3f51ddb268009d6a0c26ab40d06b9a5c84 dsh-minio
+a21b0ad897a324bffb21637d2bd33bb0d61c0657c642f101f3f37f743cd494be dsh-telemetry
+0077dbe31f2c02ba270b3c2f656570b54dbc12e84325d5293e0ddbf10282039b dsh-openfga
+811a96614149c7690a87169b3c6d3c00782a24a3145a7a5d78ec210c9f56d3b8 dsh-milvus
+38d8cbca7b4a30c9eb54e0436a29a6adbe1be2a83e681c2e0f2a2a244f45baf5 dsh-etcd
+3c98c7162ba47211aaba8dc791025320c59370a04615a609df5caa1f7b6c5e32 dsh-redis
+b6d15ee4df2512acfb94fc6befcecb809fe661795e11cef58db4fb9ebaa53a9e dsh-elasticsearch
+38a4eddc27f0285f42a6f3cd824d36b5ce0b6cebf0df24f335b203fcc776b014 dsh-mysql
+f7849abe912d1b746640ea81f4fbf6111e9e0e1b571288d0614303486e158f63 bisheng-mysql
+7b186824f0d4c472a8b568c0b279b06eac71d111a902390c2b9b43a03a701876 bisheng-gateway-dm
+2d16ab42cf168874253554d65d54e2e67463cc495d06ad2da74843c962b82b02 bisheng-openfga-dm
+0f8e1e33bf146d121893337ca306bd64d681b7e4be312acba640747383044e4d bisheng-milvus-standalone
+b54593bdcee6d8e5980db210d0260060951384265fba36a695fdd3b40da9f78f bisheng-milvus-minio
+84d6c9cef9333dcccfe213f643032f0aba0b3b63f80ca21f32e4da225e1697aa bisheng-milvus-etcd
+94e8565700e592fc0358d541a76beae4871e89e823e31bb930345dbe8f42a552 bisheng-es-dm
+599ade9773b65773a6f93c9b521cf14d830883bbca608267ea1cb481a10f858b bisheng-redis
+187e0ea624f5479ad3bb5735534e59906fd8c301582b2201c9884a7819b23eb0 bisheng-telemetry
+0ef1aadc50fc8d2d5095453439533080a3833d6da4c4b840576e50906a947b00 bisheng-gateway
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/before-containers.txt b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/before-containers.txt
new file mode 100644
index 0000000000..7fbc0a21ed
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/before-containers.txt
@@ -0,0 +1,26 @@
+15efc4bf6f41 dsh-backend-worker Up 28 minutes
+f58671d259b9 dsh-backend Up 29 minutes (healthy)
+da6dd35c740f dsh-frontend Up 4 hours
+0e07fed02c74 bisheng-information-api Up 11 days
+684c8e984eeb bisheng-frontend-dm Up 4 weeks
+7ff3f24efeee bisheng-backend-worker-ocr-dm Up 4 weeks
+b2ad936ec5e7 bisheng-backend-dm Up 4 weeks (healthy)
+66cc654093a7 bisheng-backend-worker-dm Up 4 weeks
+c6812b9eb270 dsh-minio Up 5 weeks (healthy)
+a21b0ad897a3 dsh-telemetry Up 5 weeks
+0077dbe31f2c dsh-openfga Up 5 weeks (healthy)
+811a96614149 dsh-milvus Up 5 weeks (healthy)
+38d8cbca7b4a dsh-etcd Up 5 weeks (healthy)
+3c98c7162ba4 dsh-redis Up 5 weeks (healthy)
+b6d15ee4df25 dsh-elasticsearch Up 5 weeks
+38a4eddc27f0 dsh-mysql Up 5 weeks (healthy)
+f7849abe912d bisheng-mysql Up 2 months (healthy)
+7b186824f0d4 bisheng-gateway-dm Up 2 months
+2d16ab42cf16 bisheng-openfga-dm Up 2 months (healthy)
+0f8e1e33bf14 bisheng-milvus-standalone Up 2 months (healthy)
+b54593bdcee6 bisheng-milvus-minio Up 2 months (healthy)
+84d6c9cef933 bisheng-milvus-etcd Up 2 months (healthy)
+94e8565700e5 bisheng-es-dm Up 2 months
+599ade9773b6 bisheng-redis Up 2 months (healthy)
+187e0ea624f5 bisheng-telemetry Up 2 months
+0ef1aadc50fc bisheng-gateway Up 2 months
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/deployment-v1.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/deployment-v1.json
new file mode 100644
index 0000000000..0948988c15
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/deployment-v1.json
@@ -0,0 +1 @@
+{"deployment_id": "bc6c25fbfb15425584d52aabbba31779", "app_id": "3192b1ee03c943ce9fd15f91727ccb4d", "version_id": "4cb7027d873f4df0b7b0852274b92d06"}
\ No newline at end of file
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/deployment-v2.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/deployment-v2.json
new file mode 100644
index 0000000000..d0df9528f0
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/deployment-v2.json
@@ -0,0 +1 @@
+{"deployment_id": "9fa224bb74a4466791d46b87f4c2fb75", "app_id": "3192b1ee03c943ce9fd15f91727ccb4d", "version_id": "7b0af37a5bad4bafafd70450e032bdd9"}
\ No newline at end of file
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/deployment-v3.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/deployment-v3.json
new file mode 100644
index 0000000000..1735d4950f
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/deployment-v3.json
@@ -0,0 +1 @@
+{"deployment_id": "157afefb87af431389d5e24abd939879", "app_id": "3192b1ee03c943ce9fd15f91727ccb4d", "version_id": "99639ce2d9ac4e9bbcf5ddd02077b351"}
\ No newline at end of file
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/deployment-v4.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/deployment-v4.json
new file mode 100644
index 0000000000..a3d32271af
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/deployment-v4.json
@@ -0,0 +1 @@
+{"deployment_id": "0ea77ee2b5cb47f09fd3f496e2c4c78e", "app_id": "3192b1ee03c943ce9fd15f91727ccb4d", "version_id": "e7d5b023450d4b4283c727e5cf0211f9", "entry_url": "http://nginx:3001/apps/e2e-f086-sqlite", "stage": "approval_created", "app_state": "online", "pending_reason": null, "approval": {"instance_id": 4, "status": "pending", "version_id": "e7d5b023450d4b4283c727e5cf0211f9", "submitted_at": "2026-10-20T12:27:03", "decided_at": null, "reject_reason": null, "approver_names": ["e2e-f086-admin"]}}
\ No newline at end of file
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/amd64-native.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/amd64-native.log
new file mode 100644
index 0000000000..a3b7c8c58a
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/amd64-native.log
@@ -0,0 +1 @@
+{"platform": "linux/amd64", "checks": [{"language": "python3.11", "offline": true, "code_update": true, "dependency_reuse": true}, {"language": "node20", "offline": true, "code_update": true, "dependency_reuse": true}, {"language": "static", "offline": true, "code_update": true, "dependency_reuse": true}, {"language": "static", "offline": true, "install_hook": true, "frontend_build": true, "source_change_invalidates_dependency": true}, {"language": "python3.11", "offline": true, "local_native_wheel": true, "source_change_invalidates_dependency": true}]}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/arm64-cleanup.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/arm64-cleanup.json
new file mode 100644
index 0000000000..7270b529f5
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/arm64-cleanup.json
@@ -0,0 +1,5 @@
+{
+  "test_containers_remaining": 0,
+  "control_network_removed": true,
+  "native_checks_passed": 5
+}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/arm64-image-inventory.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/arm64-image-inventory.json
new file mode 100644
index 0000000000..e6035a1219
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/arm64-image-inventory.json
@@ -0,0 +1,51 @@
+[
+  {
+    "name": "runtime-manager",
+    "tag": "e2e-f086-runtime-manager:arm64-20261020",
+    "id": "sha256:ff883a988e88b98b137872e916cf3a40505775b0cbcee48235e638f00afe4aa2",
+    "os": "linux",
+    "architecture": "arm64"
+  },
+  {
+    "name": "app-proxy",
+    "tag": "e2e-f086-app-proxy:arm64-20261020",
+    "id": "sha256:ce1549d7c62a3699d64eedf023ea0f7121b9e435a5a4c24b9f139046730a6a36",
+    "os": "linux",
+    "architecture": "arm64"
+  },
+  {
+    "name": "app-python",
+    "tag": "e2e-f086-app-python:arm64-20261020",
+    "id": "sha256:e926712fec434b7e43c6a210ba8eeccd4493f28479126b1e7d806c55f696a6ad",
+    "os": "linux",
+    "architecture": "arm64"
+  },
+  {
+    "name": "app-python-preparer",
+    "tag": "e2e-f086-app-python-preparer:arm64-20261020",
+    "id": "sha256:fe32e77d56079c534bc1fc4bb7882740301fff9adb45ecc7d3100cc7cff27b72",
+    "os": "linux",
+    "architecture": "arm64"
+  },
+  {
+    "name": "app-node",
+    "tag": "e2e-f086-app-node:arm64-20261020",
+    "id": "sha256:020842ef3f9019c6748e320faeb2f1c025e8144260c87197ac2edc35de3123a4",
+    "os": "linux",
+    "architecture": "arm64"
+  },
+  {
+    "name": "app-node-preparer",
+    "tag": "e2e-f086-app-node-preparer:arm64-20261020",
+    "id": "sha256:2b23eafa1df36269d6f1ae4467a13d31d8d47268b5fcfffc2892dd0c707179ba",
+    "os": "linux",
+    "architecture": "arm64"
+  },
+  {
+    "name": "app-static",
+    "tag": "e2e-f086-app-static:arm64-20261020",
+    "id": "sha256:3ba790705fe23642a38dce37329ee8a89c1831677ffccf8cfb6a6ef6715cf05d",
+    "os": "linux",
+    "architecture": "arm64"
+  }
+]
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/arm64-native.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/arm64-native.log
new file mode 100644
index 0000000000..02bfa68810
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/arm64-native.log
@@ -0,0 +1 @@
+{"platform": "linux/arm64", "checks": [{"language": "python3.11", "offline": true, "code_update": true, "dependency_reuse": true}, {"language": "node20", "offline": true, "code_update": true, "dependency_reuse": true}, {"language": "static", "offline": true, "code_update": true, "dependency_reuse": true}, {"language": "static", "offline": true, "install_hook": true, "frontend_build": true, "source_change_invalidates_dependency": true}, {"language": "python3.11", "offline": true, "local_native_wheel": true, "source_change_invalidates_dependency": true}]}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/bad-port-final.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/bad-port-final.json
new file mode 100644
index 0000000000..da58df5ec0
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/bad-port-final.json
@@ -0,0 +1,31 @@
+{
+  "deployment_id": "313564fac2eb43c396d612a4ce0213f6",
+  "app_id": "56e7f26a33884f6eb0cc526e394fc8c2",
+  "version_id": "12e94dbdb8284c34955c6c82ebe7068c",
+  "version_no": null,
+  "stage": "precheck_probe",
+  "status": "failed",
+  "failure": {
+    "code": 16124,
+    "hints": [],
+    "stage": "precheck_probe",
+    "details": {
+      "reason": "readiness_timeout",
+      "maintenance": false,
+      "operation_id": "b3d4a49ffe2144aca37b7866198a9c27",
+      "runtime_stage": "starting",
+      "data_may_have_changed": false
+    },
+    "message": "\u5e94\u7528\u6267\u884c\u5931\u8d25"
+  },
+  "approval": null,
+  "app_state": "draft",
+  "entry_url": "http://nginx:3001/apps/e2e-f086-bad-port",
+  "scan_result": {
+    "hits": [],
+    "blocked": false,
+    "skipped": [],
+    "files_scanned": 1,
+    "files_skipped": 0
+  }
+}
\ No newline at end of file
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/container-facts.ndjson b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/container-facts.ndjson
new file mode 100644
index 0000000000..93f9b15269
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/container-facts.ndjson
@@ -0,0 +1,11 @@
+{"id":"467cd84f7ae1681b43d271870a4c084cfd745e2f19d476d4541d0b9afd9fe792","name":"/e2e-f086-nginx","image":"sha256:34e04bb6b4bb37d45845842374be0cd181723daffb230849b1984aaeaa96faba","user":"","readonly_root":false,"mounts":[{"Destination":"/etc/nginx/conf.d/default.conf","Mode":"ro","Propagation":"rprivate","RW":false,"Source":"/opt/e2e-f086-20261020/source/docker/nginx/conf.d/default.conf","Type":"bind"}],"networks":{"e2e-f086-platform":{"Aliases":["e2e-f086-nginx","nginx","467cd84f7ae1"],"DriverOpts":null,"EndpointID":"c9541bf848a5a6c62da9e1bdf896e4db3d17144541e9b54cab20491f66f4e871","Gateway":"172.24.0.1","GlobalIPv6Address":"","GlobalIPv6PrefixLen":0,"IPAMConfig":null,"IPAddress":"172.24.0.12","IPPrefixLen":16,"IPv6Gateway":"","Links":null,"MacAddress":"02:42:ac:18:00:0c","NetworkID":"0d386621d04546631d13cab26f29fa4e1bf05dc4b4bd6d1a84a0b1d83060ca9a"}}}
+{"id":"f13183e4f8a231435d879c9eeb132a68e4ebf5b383a5084ffed7f47a54708c9f","name":"/e2e-f086-runtime-manager","image":"sha256:40756e40baa4fbd02f76308dba718244cc5ff0315fe96b98d9301f93361e8c40","user":"","readonly_root":false,"mounts":[{"Destination":"/app-data","Mode":"rw","Propagation":"rprivate","RW":true,"Source":"/opt/e2e-f086-20261020/app-data","Type":"bind"}],"networks":{"e2e-f086-apps":{"Aliases":["e2e-f086-runtime-manager","runtime-manager","f13183e4f8a2"],"DriverOpts":null,"EndpointID":"6f409039f74f0c6fffa267bfebe74bb4c4dd5304d07e2fccdeb0b5ec435477ff","Gateway":"172.30.0.1","GlobalIPv6Address":"","GlobalIPv6PrefixLen":0,"IPAMConfig":null,"IPAddress":"172.30.0.4","IPPrefixLen":16,"IPv6Gateway":"","Links":null,"MacAddress":"02:42:ac:1e:00:04","NetworkID":"851523209f071debbd30bfa5372d8d50ce1751bad430af75b523c6f00e31f679"},"e2e-f086-control":{"Aliases":["e2e-f086-runtime-manager","runtime-manager","f13183e4f8a2"],"DriverOpts":null,"EndpointID":"e664070098fe1c3dd5a40b2c36cd9f6b3222c3965e200c4d6605b34d83cc133f","Gateway":"172.28.0.1","GlobalIPv6Address":"","GlobalIPv6PrefixLen":0,"IPAMConfig":null,"IPAddress":"172.28.0.3","IPPrefixLen":16,"IPv6Gateway":"","Links":null,"MacAddress":"02:42:ac:1c:00:03","NetworkID":"cdb36c0e146b3de97ab88d48795e36d7adb10bd3215881b637762fc841e524e4"},"e2e-f086-platform":{"Aliases":["e2e-f086-runtime-manager","runtime-manager","f13183e4f8a2"],"DriverOpts":null,"EndpointID":"a17aff2e3546e3c937043d113a43fa3a53ed13184d8af50721d5abac7054a735","Gateway":"172.24.0.1","GlobalIPv6Address":"","GlobalIPv6PrefixLen":0,"IPAMConfig":null,"IPAddress":"172.24.0.8","IPPrefixLen":16,"IPv6Gateway":"","Links":null,"MacAddress":"02:42:ac:18:00:08","NetworkID":"0d386621d04546631d13cab26f29fa4e1bf05dc4b4bd6d1a84a0b1d83060ca9a"}}}
+{"id":"13dce73c7c9e10b780f5d8e2d1134601c27e2b2bd3d65ff424adcf5b68821ce0","name":"/e2e-f086-egress-proxy","image":"sha256:40756e40baa4fbd02f76308dba718244cc5ff0315fe96b98d9301f93361e8c40","user":"10001:10001","readonly_root":true,"mounts":[],"networks":{"e2e-f086-apps":{"Aliases":["e2e-f086-egress-proxy","egress-proxy","13dce73c7c9e"],"DriverOpts":null,"EndpointID":"2f2d4f78a46636db3cdce69eb7727b1123bd401e23182e89daf06894001b06d9","Gateway":"172.30.0.1","GlobalIPv6Address":"","GlobalIPv6PrefixLen":0,"IPAMConfig":null,"IPAddress":"172.30.0.2","IPPrefixLen":16,"IPv6Gateway":"","Links":null,"MacAddress":"02:42:ac:1e:00:02","NetworkID":"851523209f071debbd30bfa5372d8d50ce1751bad430af75b523c6f00e31f679"},"e2e-f086-build":{"Aliases":["e2e-f086-egress-proxy","egress-proxy","13dce73c7c9e"],"DriverOpts":null,"EndpointID":"5fdaf9eaea99038441a9c9c933487da19b9b3c01dbd2277c0ec071a7f80f7130","Gateway":"172.27.0.1","GlobalIPv6Address":"","GlobalIPv6PrefixLen":0,"IPAMConfig":null,"IPAddress":"172.27.0.2","IPPrefixLen":16,"IPv6Gateway":"","Links":null,"MacAddress":"02:42:ac:1b:00:02","NetworkID":"39b40eecbfec65d2bb2fcefcca9b84788b370213d6cfe5eb5de1b01a483558a0"},"e2e-f086-platform":{"Aliases":["e2e-f086-egress-proxy","egress-proxy","13dce73c7c9e"],"DriverOpts":null,"EndpointID":"fd0b363a4c1183690437dce042979e401fcaee54b564c804fb46f67bc719a182","Gateway":"172.24.0.1","GlobalIPv6Address":"","GlobalIPv6PrefixLen":0,"IPAMConfig":null,"IPAddress":"172.24.0.6","IPPrefixLen":16,"IPv6Gateway":"","Links":null,"MacAddress":"02:42:ac:18:00:06","NetworkID":"0d386621d04546631d13cab26f29fa4e1bf05dc4b4bd6d1a84a0b1d83060ca9a"}}}
+{"id":"38a4d630aed61e9289b664fcab80c3bb490dd58ab5efde8162a67532f0c7a719","name":"/e2e-f086-docker-socket-proxy","image":"sha256:1484f9fee8a7f423374977ca5e39457c762d859182511192270bf1e542670608","user":"","readonly_root":false,"mounts":[{"Destination":"/var/run/docker.sock","Mode":"ro","Propagation":"rprivate","RW":false,"Source":"/var/run/docker.sock","Type":"bind"}],"networks":{"e2e-f086-control":{"Aliases":["e2e-f086-docker-socket-proxy","docker-socket-proxy","38a4d630aed6"],"DriverOpts":null,"EndpointID":"645db302acfef1e9b79efbbdc0f807fdb2cd4a9cb1841469edd53d9cad7f1fc4","Gateway":"172.28.0.1","GlobalIPv6Address":"","GlobalIPv6PrefixLen":0,"IPAMConfig":null,"IPAddress":"172.28.0.2","IPPrefixLen":16,"IPv6Gateway":"","Links":null,"MacAddress":"02:42:ac:1c:00:02","NetworkID":"cdb36c0e146b3de97ab88d48795e36d7adb10bd3215881b637762fc841e524e4"}}}
+{"id":"be2508c3f11cc5776368e0fc77d6b6f7e4e8690f68d966431b27d8fac51f13d1","name":"/e2e-f086-app-proxy","image":"sha256:448ff4a9b8a84eb9c7f49c4e2fb6688293c180048f46e9dec926bea80117aa7b","user":"appproxy","readonly_root":false,"mounts":[],"networks":{"e2e-f086-apps":{"Aliases":["e2e-f086-app-proxy","app-proxy","be2508c3f11c"],"DriverOpts":null,"EndpointID":"1e0a85c1d8c76aa3d54149976ed92195e13f5929cf286be255704e4d552b824c","Gateway":"172.30.0.1","GlobalIPv6Address":"","GlobalIPv6PrefixLen":0,"IPAMConfig":null,"IPAddress":"172.30.0.3","IPPrefixLen":16,"IPv6Gateway":"","Links":null,"MacAddress":"02:42:ac:1e:00:03","NetworkID":"851523209f071debbd30bfa5372d8d50ce1751bad430af75b523c6f00e31f679"},"e2e-f086-platform":{"Aliases":["e2e-f086-app-proxy","app-proxy","be2508c3f11c"],"DriverOpts":null,"EndpointID":"4f3808c58f473f479ab8fc0aaa1ada6a4ae5d5c371536dcc5971e705a57fdd91","Gateway":"172.24.0.1","GlobalIPv6Address":"","GlobalIPv6PrefixLen":0,"IPAMConfig":null,"IPAddress":"172.24.0.9","IPPrefixLen":16,"IPv6Gateway":"","Links":null,"MacAddress":"02:42:ac:18:00:09","NetworkID":"0d386621d04546631d13cab26f29fa4e1bf05dc4b4bd6d1a84a0b1d83060ca9a"}}}
+{"id":"183a352cda6837807c9bdf0b37cb08b7487c290f1291ebbf80f935d4873c8e62","name":"/e2e-f086-backend","image":"sha256:894de7d3d8f60e22014daf2f3fc296999c994f2788451d5e2b6faec90258a371","user":"","readonly_root":false,"mounts":[{"Destination":"/work","Mode":"rw","Propagation":"rprivate","RW":true,"Source":"/opt/e2e-f086-20261020/source/src/backend","Type":"bind"},{"Destination":"/run/f086-config.yaml","Mode":"ro","Propagation":"rprivate","RW":false,"Source":"/opt/e2e-f086-20261020/platform-config.yaml","Type":"bind"}],"networks":{"e2e-f086-platform":{"Aliases":["e2e-f086-backend","backend","183a352cda68"],"DriverOpts":null,"EndpointID":"ee15d71bc6a4468491a0cebed51d1d5d02bf548b56db09bc368ff0e9e6125586","Gateway":"172.24.0.1","GlobalIPv6Address":"","GlobalIPv6PrefixLen":0,"IPAMConfig":null,"IPAddress":"172.24.0.10","IPPrefixLen":16,"IPv6Gateway":"","Links":null,"MacAddress":"02:42:ac:18:00:0a","NetworkID":"0d386621d04546631d13cab26f29fa4e1bf05dc4b4bd6d1a84a0b1d83060ca9a"}}}
+{"id":"26e45f65b7a34a60f212dbd7db612af413e9a5fa4b42d9b05f7e157c9ac7e374","name":"/e2e-f086-worker","image":"sha256:894de7d3d8f60e22014daf2f3fc296999c994f2788451d5e2b6faec90258a371","user":"","readonly_root":false,"mounts":[{"Destination":"/work","Mode":"rw","Propagation":"rprivate","RW":true,"Source":"/opt/e2e-f086-20261020/source/src/backend","Type":"bind"},{"Destination":"/run/f086-config.yaml","Mode":"ro","Propagation":"rprivate","RW":false,"Source":"/opt/e2e-f086-20261020/platform-config.yaml","Type":"bind"}],"networks":{"e2e-f086-platform":{"Aliases":["e2e-f086-worker","worker","26e45f65b7a3"],"DriverOpts":null,"EndpointID":"7a4be553878d123bc5b5cbebd855563a66c72ce20d8318e6de235f6966d7ab02","Gateway":"172.24.0.1","GlobalIPv6Address":"","GlobalIPv6PrefixLen":0,"IPAMConfig":null,"IPAddress":"172.24.0.7","IPPrefixLen":16,"IPv6Gateway":"","Links":null,"MacAddress":"02:42:ac:18:00:07","NetworkID":"0d386621d04546631d13cab26f29fa4e1bf05dc4b4bd6d1a84a0b1d83060ca9a"}}}
+{"id":"97a853f4bd3f40da98bc889be806dec4613dd4f4fa4a593fa5179860fca1b7f3","name":"/e2e-f086-minio","image":"sha256:400c20c8aac03024d00a34603f5445dd4679b97712e062727190a8462c6a34b5","user":"","readonly_root":false,"mounts":[{"Destination":"/data","Mode":"rw","Propagation":"rprivate","RW":true,"Source":"/opt/e2e-f086-20261020/minio-data","Type":"bind"}],"networks":{"e2e-f086-platform":{"Aliases":["e2e-f086-minio","minio","97a853f4bd3f"],"DriverOpts":null,"EndpointID":"fab9cb0817656b74105047c2592ebdfecf4e5dc6fa8fee62d307a943e67a0ab9","Gateway":"172.24.0.1","GlobalIPv6Address":"","GlobalIPv6PrefixLen":0,"IPAMConfig":null,"IPAddress":"172.24.0.5","IPPrefixLen":16,"IPv6Gateway":"","Links":null,"MacAddress":"02:42:ac:18:00:05","NetworkID":"0d386621d04546631d13cab26f29fa4e1bf05dc4b4bd6d1a84a0b1d83060ca9a"}}}
+{"id":"3bb22d50ce857ca41cb132c611cf3f0ab8b697ddd017020337aa7bc6ee25e2a8","name":"/e2e-f086-redis","image":"sha256:2460522297a148c1bcb477b126451ed44cca05c916694367313be1a91c69f793","user":"","readonly_root":false,"mounts":[{"Destination":"/data","Driver":"local","Mode":"","Name":"de3fb8162c8ee19c9d767286f16541cf93a3eb0dba37b23671394ec1792819a5","Propagation":"","RW":true,"Source":"/var/lib/docker/volumes/de3fb8162c8ee19c9d767286f16541cf93a3eb0dba37b23671394ec1792819a5/_data","Type":"volume"}],"networks":{"e2e-f086-platform":{"Aliases":["e2e-f086-redis","redis","3bb22d50ce85"],"DriverOpts":null,"EndpointID":"b337f6b416215fd4a5f7e3fb70b4db0e4a910c81dc0b746174a8d7e9000cc6dc","Gateway":"172.24.0.1","GlobalIPv6Address":"","GlobalIPv6PrefixLen":0,"IPAMConfig":null,"IPAddress":"172.24.0.3","IPPrefixLen":16,"IPv6Gateway":"","Links":null,"MacAddress":"02:42:ac:18:00:03","NetworkID":"0d386621d04546631d13cab26f29fa4e1bf05dc4b4bd6d1a84a0b1d83060ca9a"}}}
+{"id":"a419f368ef56f708f3a6badc10d0714453559cec9abcd4c7a04e8c5fcebe6011","name":"/e2e-f086-openfga","image":"sha256:3be18a353c48abb1b82e817be9a91f08d076255008ee9d2bfe2d0537a8b56a2b","user":"65532","readonly_root":false,"mounts":[],"networks":{"e2e-f086-platform":{"Aliases":["e2e-f086-openfga","openfga","a419f368ef56"],"DriverOpts":null,"EndpointID":"23538c25ebaa13b4c373f87ab735d9d4d9f69ce84141d1a7e78ae8ac93a146a6","Gateway":"172.24.0.1","GlobalIPv6Address":"","GlobalIPv6PrefixLen":0,"IPAMConfig":null,"IPAddress":"172.24.0.4","IPPrefixLen":16,"IPv6Gateway":"","Links":null,"MacAddress":"02:42:ac:18:00:04","NetworkID":"0d386621d04546631d13cab26f29fa4e1bf05dc4b4bd6d1a84a0b1d83060ca9a"}}}
+{"id":"7397e930c7b97fa3e5cff15b9fd2a84cfbb4388dc6e6958c4a54bffdea52d8a4","name":"/e2e-f086-mysql","image":"sha256:af332304d0c65ad3469204d2b0a79f81ea20c1582bd6153d69ef65f8b48dd333","user":"","readonly_root":false,"mounts":[{"Destination":"/var/lib/mysql","Mode":"","Propagation":"rprivate","RW":true,"Source":"/opt/e2e-f086-20261020/sql-data","Type":"bind"}],"networks":{"e2e-f086-platform":{"Aliases":["7397e930c7b9"],"DriverOpts":{},"EndpointID":"1e1a59befc16e4223238a9026880430f03513598cb836a56064eae04963f90f5","Gateway":"172.24.0.1","GlobalIPv6Address":"","GlobalIPv6PrefixLen":0,"IPAMConfig":{},"IPAddress":"172.24.0.2","IPPrefixLen":16,"IPv6Gateway":"","Links":null,"MacAddress":"02:42:ac:18:00:02","NetworkID":"0d386621d04546631d13cab26f29fa4e1bf05dc4b4bd6d1a84a0b1d83060ca9a"},"e2e-f086-sql":{"Aliases":["7397e930c7b9"],"DriverOpts":null,"EndpointID":"f89cbcd3e7944782f2da492a69c2611f6b90e0625a2af66f1e85d60cef094368","Gateway":"172.23.0.1","GlobalIPv6Address":"","GlobalIPv6PrefixLen":0,"IPAMConfig":null,"IPAddress":"172.23.0.2","IPPrefixLen":16,"IPv6Gateway":"","Links":null,"MacAddress":"02:42:ac:17:00:02","NetworkID":"9b8c3f91a6f01cf2ad418658becf4a795839b6a9598e4cb6387f25c11c70a567"}}}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/control-records.txt b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/control-records.txt
new file mode 100644
index 0000000000..62df811fcb
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/control-records.txt
@@ -0,0 +1,42 @@
+id	current_version_id	state	tenant_id	owner_user_id
+3192b1ee03c943ce9fd15f91727ccb4d	e7d5b023450d4b4283c727e5cf0211f9	online	1	2
+version_id	execution_mode	profile_id	resolved_platform	env_key	dependency_artifact_id
+4cb7027d873f4df0b7b0852274b92d06	bundle	69f13b135ebc5c3b89234581af0331e9	linux/amd64	559845ad208a10ce95a0192138d65e38e6966a52606c93d07ff3a6a3f56ddb2a	af753af7b48a49e4a6eea7d6c72f36c1
+7b0af37a5bad4bafafd70450e032bdd9	bundle	69f13b135ebc5c3b89234581af0331e9	linux/amd64	559845ad208a10ce95a0192138d65e38e6966a52606c93d07ff3a6a3f56ddb2a	af753af7b48a49e4a6eea7d6c72f36c1
+e7d5b023450d4b4283c727e5cf0211f9	bundle	69f13b135ebc5c3b89234581af0331e9	linux/amd64	f1cbb9ae2e43191e732c2e2109bd398de7cdaef7dacbe99d41bf3e50a165dc33	97174a5b99834d0595f2b04d29527337
+99639ce2d9ac4e9bbcf5ddd02077b351	bundle	69f13b135ebc5c3b89234581af0331e9	linux/amd64	559845ad208a10ce95a0192138d65e38e6966a52606c93d07ff3a6a3f56ddb2a	af753af7b48a49e4a6eea7d6c72f36c1
+kind	status	stage	generation	cleanup_status
+probe	completed	completed	8	complete
+restore	completed	completed	6	complete
+prepare	completed	completed	3	complete
+deploy	completed	completed	1	complete
+preview	cancelled	cancelled	3	complete
+prepare	completed	completed	8	complete
+restore	completed	completed	4	complete
+probe	completed	completed	3	complete
+prepare	completed	completed	1	complete
+prepare	failed	failed	8	complete
+probe	completed	completed	1	complete
+deploy	completed	completed	3	complete
+deploy	completed	completed	8	complete
+deploy	completed	completed	7	complete
+deploy	completed	completed	2	complete
+stop	completed	completed	5	complete
+probe	completed	completed	2	complete
+prepare	completed	completed	2	complete
+kind	size_bytes	state	confirmed_at
+offline	120053	verified	2026-10-20 12:25:24
+compiled	1273	verified	2026-10-20 12:26:50
+source	1392	verified	2026-10-20 12:22:38
+source	1246	verified	2026-10-20 12:06:19
+compiled	1232	verified	2026-10-20 12:11:38
+source	785	verified	2026-10-20 12:01:49
+snapshot	20480	verified	2026-10-20 12:18:13
+compiled	678	verified	2026-10-20 12:02:15
+source	1273	verified	2026-10-20 12:26:23
+dependencies	5208260	verified	2026-10-20 12:26:30
+snapshot	8192	verified	2026-10-20 12:15:01
+compiled	1128	verified	2026-10-20 12:06:21
+dependencies	5086229	verified	2026-10-20 12:01:56
+source	785	verified	2026-10-20 11:55:53
+source	1355	verified	2026-10-20 12:11:36
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/deletion-filesystem.txt b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/deletion-filesystem.txt
new file mode 100644
index 0000000000..63d10b71e3
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/deletion-filesystem.txt
@@ -0,0 +1 @@
+SQLite-directory-removed
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/deletion-records.txt b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/deletion-records.txt
new file mode 100644
index 0000000000..21a9606aee
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/deletion-records.txt
@@ -0,0 +1,8 @@
+state
+deleted
+kind	status	cleanup_status
+destroy	completed	complete
+state	COUNT(*)
+released	17
+kind	state	COUNT(*)
+snapshot	purged	2
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/docker-cleanup.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/docker-cleanup.json
new file mode 100644
index 0000000000..4f9b487a89
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/docker-cleanup.json
@@ -0,0 +1,9 @@
+{
+  "original_containers": 26,
+  "differences": {},
+  "cached_images_retained": true,
+  "test_containers_remaining": 0,
+  "credential_and_test_data_directory_removed": true,
+  "test_networks_removed": true,
+  "same_ids_still_running": true
+}
\ No newline at end of file
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/extra-delivery-results.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/extra-delivery-results.json
new file mode 100644
index 0000000000..0fd80f390e
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/extra-delivery-results.json
@@ -0,0 +1,42 @@
+[
+  {
+    "mode": "image",
+    "app_id": "0d59597a523641eba843a5bdaf80e7f8",
+    "deployment_id": "97290cb9f8fd4850bc1eff147936cde5",
+    "status": "waiting_approval",
+    "stage": "approval_created",
+    "http": 200,
+    "deleted": true
+  },
+  {
+    "mode": "legacy",
+    "app_id": "308bc75b105b469a86235641df1741c1",
+    "deployment_id": "0f135ac5eece4cd1a8d3091926a87f2c",
+    "status": "waiting_approval",
+    "stage": "approval_created",
+    "http": 200,
+    "deleted": true
+  },
+  {
+    "mode": "bad-port",
+    "app_id": "56e7f26a33884f6eb0cc526e394fc8c2",
+    "deployment_id": "313564fac2eb43c396d612a4ce0213f6",
+    "status": "failed",
+    "failure": {
+      "code": 16124,
+      "hints": [],
+      "stage": "precheck_probe",
+      "details": {
+        "reason": "readiness_timeout",
+        "maintenance": false,
+        "operation_id": "b3d4a49ffe2144aca37b7866198a9c27",
+        "runtime_stage": "starting",
+        "data_may_have_changed": false
+      },
+      "message": "\u5e94\u7528\u6267\u884c\u5931\u8d25"
+    },
+    "no_approval": true,
+    "no_formal_version": true,
+    "deleted": true
+  }
+]
\ No newline at end of file
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/final-control-records.txt b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/final-control-records.txt
new file mode 100644
index 0000000000..a435018fe7
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/final-control-records.txt
@@ -0,0 +1,15 @@
+slug	state	formal_versions
+e2e-f086-image	deleted	1
+e2e-f086-legacy	deleted	1
+e2e-f086-sqlite	deleted	4
+e2e-f086-bad-port	deleted	0
+state	COUNT(*)
+released	29
+kind	status	cleanup_status	reason	failed_stage
+probe	failed	complete	readiness_timeout	admitted
+probe	failed	complete	readiness_timeout	admitted
+prepare	completed	complete	NULL	NULL
+destroy	completed	complete	NULL	NULL
+prepare	completed	complete	NULL	NULL
+probe	failed	complete	readiness_timeout	starting
+prepare	completed	complete	NULL	NULL
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/image-facts-final.ndjson b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/image-facts-final.ndjson
new file mode 100644
index 0000000000..e06e33ecf3
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/image-facts-final.ndjson
@@ -0,0 +1,7 @@
+{"id":"sha256:92fc7635880126491f163f9e84d7b406f2900fa5981aebdab6bb1e86ae1047c7","tags":["e2e-f086-runtime-manager:20261020"],"os":"linux","architecture":"amd64"}
+{"id":"sha256:448ff4a9b8a84eb9c7f49c4e2fb6688293c180048f46e9dec926bea80117aa7b","tags":["e2e-f086-app-proxy:20261020"],"os":"linux","architecture":"amd64"}
+{"id":"sha256:356862ef889d61162270851952347e78dacd5e1610f85f3b83d68de57ae4fc98","tags":["e2e-f086-app-python:20261020"],"os":"linux","architecture":"amd64"}
+{"id":"sha256:850d68dfba648d0ad8be7b826a1a23df009d0c98d5aac58938a4e05fb1875bda","tags":["e2e-f086-app-python-preparer:20261020"],"os":"linux","architecture":"amd64"}
+{"id":"sha256:ac95b2f352b17a6dab8733f0ab0f47487953f832995e5121ddd7a4034a43535f","tags":["e2e-f086-app-node:20261020"],"os":"linux","architecture":"amd64"}
+{"id":"sha256:ceca4a185dc7ad16c5d6318430d89b8ca04dbc9acdb2d6bf4662b8c4fb5fa11b","tags":["e2e-f086-app-node-preparer:20261020"],"os":"linux","architecture":"amd64"}
+{"id":"sha256:2df72b011ac0db1a4254cbd8e181aec71673e4f85222de22576f64d3645742a9","tags":["e2e-f086-app-static:20261020"],"os":"linux","architecture":"amd64"}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/image-facts.ndjson b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/image-facts.ndjson
new file mode 100644
index 0000000000..dc0a714676
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/image-facts.ndjson
@@ -0,0 +1,8 @@
+{"id":"sha256:40756e40baa4fbd02f76308dba718244cc5ff0315fe96b98d9301f93361e8c40","tags":["e2e-f086-runtime-manager:20261020"],"os":"linux","architecture":"amd64"}
+{"id":"sha256:448ff4a9b8a84eb9c7f49c4e2fb6688293c180048f46e9dec926bea80117aa7b","tags":["e2e-f086-app-proxy:20261020"],"os":"linux","architecture":"amd64"}
+{"id":"sha256:356862ef889d61162270851952347e78dacd5e1610f85f3b83d68de57ae4fc98","tags":["e2e-f086-app-python:20261020"],"os":"linux","architecture":"amd64"}
+{"id":"sha256:850d68dfba648d0ad8be7b826a1a23df009d0c98d5aac58938a4e05fb1875bda","tags":["e2e-f086-app-python-preparer:20261020"],"os":"linux","architecture":"amd64"}
+{"id":"sha256:ac95b2f352b17a6dab8733f0ab0f47487953f832995e5121ddd7a4034a43535f","tags":["e2e-f086-app-node:20261020"],"os":"linux","architecture":"amd64"}
+{"id":"sha256:ceca4a185dc7ad16c5d6318430d89b8ca04dbc9acdb2d6bf4662b8c4fb5fa11b","tags":["e2e-f086-app-node-preparer:20261020"],"os":"linux","architecture":"amd64"}
+{"id":"sha256:2df72b011ac0db1a4254cbd8e181aec71673e4f85222de22576f64d3645742a9","tags":["e2e-f086-app-static:20261020"],"os":"linux","architecture":"amd64"}
+{"id":"sha256:894de7d3d8f60e22014daf2f3fc296999c994f2788451d5e2b6faec90258a371","tags":["e2e-f086-backend:20261020"],"os":"linux","architecture":"amd64"}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/preexisting-services-check.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/preexisting-services-check.json
new file mode 100644
index 0000000000..46ba90fa93
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/preexisting-services-check.json
@@ -0,0 +1,5 @@
+{
+  "original_containers": 26,
+  "same_ids_still_running": true,
+  "differences": {}
+}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/preview-v3.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/preview-v3.log
new file mode 100644
index 0000000000..db78657d90
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/preview-v3.log
@@ -0,0 +1,5 @@
+Preview state running session 5e4db92080394ca9991da1f4f1386e82
+Preview entry HTTP 200 {"version": 3, "value": "retained"}
+Preview SDK HTTP 200 {"sdk_storage":"passed"}
+Temporary preview and scoped SDK storage verified
+Production schema unchanged while preview migration exists
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/restore-audit.txt b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/restore-audit.txt
new file mode 100644
index 0000000000..3fe5668be2
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/evidence/restore-audit.txt
@@ -0,0 +1,3 @@
+action	operator_id	target_type	target_id	create_time
+app.data_restore	2	app	3192b1ee03c943ce9fd15f91727ccb4d	2026-10-20 12:18:02
+app.data_restore	2	app	3192b1ee03c943ce9fd15f91727ccb4d	2026-10-20 12:18:40
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/api-bootstrap.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/api-bootstrap.log
new file mode 100644
index 0000000000..3d253d00c7
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/api-bootstrap.log
@@ -0,0 +1,7 @@
+admin registration 200 200 SUCCESS
+admin login 200
+owner registration 200 200 SUCCESS
+owner login 200
+nonowner registration 200 200 SUCCESS
+nonowner login 200
+Dedicated test identities saved; credentials not emitted
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/approve-and-access.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/approve-and-access.log
new file mode 100644
index 0000000000..3013b8eea0
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/approve-and-access.log
@@ -0,0 +1,8 @@
+Approved dedicated task 1 instance 1
+Application state draft
+Application state draft
+Application state online
+Entry HTTP 200 {"version": 1, "value": "retained"}
+Backend to manager data helper: ['data']
+Confirmed snapshots 0
+Nonowner snapshot metadata refused: 16105
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/approve-v3-snapshot-sdk-ws.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/approve-v3-snapshot-sdk-ws.log
new file mode 100644
index 0000000000..5db4ed22f0
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/approve-v3-snapshot-sdk-ws.log
@@ -0,0 +1,17 @@
+Approved dedicated task 3 instance 3
+Application state online
+Application state online
+Application state online
+Application state online
+Application state online
+Application state online
+Application state online
+Application state online
+Entry HTTP 200 {"version": 3, "value": "retained"}
+Backend to manager data helper: ['data', 'notes']
+Confirmed snapshots 1
+Nonowner snapshot metadata refused: 16105
+SDK storage HTTP 200 {"sdk_storage":"passed"}
+nginx/app-proxy WebSocket echo passed
+nonowner restore refused: 16105
+admin restore refused: 16105
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/approve-v4-offline-supply.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/approve-v4-offline-supply.log
new file mode 100644
index 0000000000..7103607ce4
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/approve-v4-offline-supply.log
@@ -0,0 +1,17 @@
+Approved dedicated task 4 instance 4
+Application state online
+Application state online
+Application state online
+Application state online
+Application state online
+Application state online
+Application state online
+Application state online
+Entry HTTP 200 {"version": 4, "value": "retained"}
+Backend to manager data helper: ['data']
+Confirmed snapshots 2
+Nonowner snapshot metadata refused: 16105
+SDK storage HTTP 200 {"sdk_storage":"passed"}
+nginx/app-proxy WebSocket echo passed
+nonowner restore refused: 16105
+admin restore refused: 16105
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/backend-failclosed.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/backend-failclosed.log
new file mode 100644
index 0000000000..f14e63ba5a
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/backend-failclosed.log
@@ -0,0 +1,6 @@
+refused HTTP 503
+refused HTTP 503
+refused HTTP 503
+refused HTTP 503
+refused HTTP 503
+refused HTTP 503
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/backend-local-final.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/backend-local-final.log
new file mode 100644
index 0000000000..06e4e922ba
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/backend-local-final.log
@@ -0,0 +1,463 @@
+...............................................................FF....... [  5%]
+.................sssssss................................................ [ 11%]
+............s....s....s....s....s....s.................................. [ 17%]
+........................................................................ [ 23%]
+........................................................................ [ 29%]
+...........................................sssss........................ [ 34%]
+........................................................................ [ 40%]
+........................................................................ [ 46%]
+........................................................................ [ 52%]
+........................................................................ [ 58%]
+........................................................................ [ 63%]
+........................................................................ [ 69%]
+........................................................................ [ 75%]
+........................................................................ [ 81%]
+........................................................................ [ 87%]
+........................................................................ [ 92%]
+........................................................................ [ 98%]
+..........ssssss                                                         [100%]
+=================================== FAILURES ===================================
+_ test_grant_subjects_five_endpoints_accept_app[list_grant_subject_users-list_candidate_users-kwargs0] _
+
+monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x13b286110>
+endpoint_name = 'list_grant_subject_users'
+service_name = 'list_candidate_users'
+kwargs = {'keyword': '', 'page': 1, 'page_size': 50}
+
+    @pytest.mark.parametrize(("endpoint_name", "service_name", "kwargs"), _GRANT_SUBJECT_ENDPOINTS)
+    async def test_grant_subjects_five_endpoints_accept_app(
+        monkeypatch, endpoint_name: str, service_name: str, kwargs: dict
+    ) -> None:
+        """Each of the five hard gates lets ``resource_type='app'`` through to the service.
+
+        Driven through the real endpoint functions rather than by re-reading the
+        constant: the gate is five separate ``if resource_type not in ...`` lines,
+        and only calling all five proves none was missed. Authorization itself is
+        stubbed out — this asserts the type gate, not the predicate.
+        """
+        called: dict[str, object] = {}
+
+        async def _scope(resource_type, resource_id, login_user):
+            del login_user
+            called["scope"] = (resource_type, resource_id)
+            return SimpleNamespace(tenant_id=TENANT_ID, department_path=None)
+
+        async def _service(*args, **service_kwargs):
+            called["service"] = (args, service_kwargs)
+            return []
+
+        monkeypatch.setattr(grant_subjects, "_authorized_scope", _scope)
+        monkeypatch.setattr(grant_subjects.grant_subject_service, service_name, _service)
+
+        endpoint = getattr(grant_subjects, endpoint_name)
+>       response = await endpoint(resource_type="app", resource_id="app-1", login_user=object(), **kwargs)
+                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+test/app_runtime/test_app_permission_registration.py:309:
+_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
+bisheng/permission/api/endpoints/grant_subjects.py:107: in list_grant_subject_users
+    total = await grant_subject_service.count_candidate_users(scope, keyword=keyword)
+            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+bisheng/permission/domain/services/grant_subject_service.py:101: in count_candidate_users
+    directory = await get_grant_subject_directory()
+                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+bisheng/permission/application/access.py:62: in get_grant_subject_directory
+    await _ensure_f048_runtime()
+bisheng/permission/application/access.py:36: in _ensure_f048_runtime
+    await get_f048_process_runtime()
+bisheng/permission/application/process_runtime.py:152: in get_f048_process_runtime
+    context: ProcessPermissionRuntime = await app_context.async_get_instance(F048_PERMISSION_RUNTIME_CONTEXT)
+                                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+bisheng/core/context/manager.py:171: in async_get_instance
+    await self._initialize_context_with_dependencies(name, set())
+bisheng/core/context/manager.py:157: in _initialize_context_with_dependencies
+    context = self._registry.get_context(context_name)
+              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
+
+self = , name = 'permission_runtime'
+
+    def get_context(self, name: str) -> BaseContextManager:
+        """Get Context Manager
+
+        Args:
+            name: Context Name
+
+        Returns:
+            BaseContextManager: Corresponding context manager
+
+        Raises:
+            KeyError: If the context does not exist
+        """
+        if name not in self._contexts:
+>           raise KeyError(f"Context '{name}' not found. Available contexts: {list(self._contexts.keys())}")
+E           KeyError: "Context 'permission_runtime' not found. Available contexts: []"
+
+bisheng/core/context/base.py:512: KeyError
+----------------------------- Captured stderr call -----------------------------
+2026-10-20 21:26:39.812 | ERROR    | bisheng.core.context.manager:_initialize_context_with_dependencies:166 - Failed to initialize context 'permission_runtime': "Context 'permission_runtime' not found. Available contexts: []"
+_ test_grant_subjects_five_endpoints_accept_app[list_grant_subject_user_groups-list_candidate_user_groups-kwargs1] _
+
+monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x13b4fd5d0>
+endpoint_name = 'list_grant_subject_user_groups'
+service_name = 'list_candidate_user_groups'
+kwargs = {'keyword': '', 'page': 1, 'page_size': 50}
+
+    @pytest.mark.parametrize(("endpoint_name", "service_name", "kwargs"), _GRANT_SUBJECT_ENDPOINTS)
+    async def test_grant_subjects_five_endpoints_accept_app(
+        monkeypatch, endpoint_name: str, service_name: str, kwargs: dict
+    ) -> None:
+        """Each of the five hard gates lets ``resource_type='app'`` through to the service.
+
+        Driven through the real endpoint functions rather than by re-reading the
+        constant: the gate is five separate ``if resource_type not in ...`` lines,
+        and only calling all five proves none was missed. Authorization itself is
+        stubbed out — this asserts the type gate, not the predicate.
+        """
+        called: dict[str, object] = {}
+
+        async def _scope(resource_type, resource_id, login_user):
+            del login_user
+            called["scope"] = (resource_type, resource_id)
+            return SimpleNamespace(tenant_id=TENANT_ID, department_path=None)
+
+        async def _service(*args, **service_kwargs):
+            called["service"] = (args, service_kwargs)
+            return []
+
+        monkeypatch.setattr(grant_subjects, "_authorized_scope", _scope)
+        monkeypatch.setattr(grant_subjects.grant_subject_service, service_name, _service)
+
+        endpoint = getattr(grant_subjects, endpoint_name)
+>       response = await endpoint(resource_type="app", resource_id="app-1", login_user=object(), **kwargs)
+                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+test/app_runtime/test_app_permission_registration.py:309:
+_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
+bisheng/permission/api/endpoints/grant_subjects.py:132: in list_grant_subject_user_groups
+    total = await grant_subject_service.count_candidate_user_groups(scope, keyword=keyword)
+            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+bisheng/permission/domain/services/grant_subject_service.py:152: in count_candidate_user_groups
+    directory = await get_grant_subject_directory()
+                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+bisheng/permission/application/access.py:62: in get_grant_subject_directory
+    await _ensure_f048_runtime()
+bisheng/permission/application/access.py:36: in _ensure_f048_runtime
+    await get_f048_process_runtime()
+bisheng/permission/application/process_runtime.py:152: in get_f048_process_runtime
+    context: ProcessPermissionRuntime = await app_context.async_get_instance(F048_PERMISSION_RUNTIME_CONTEXT)
+                                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+bisheng/core/context/manager.py:171: in async_get_instance
+    await self._initialize_context_with_dependencies(name, set())
+bisheng/core/context/manager.py:157: in _initialize_context_with_dependencies
+    context = self._registry.get_context(context_name)
+              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
+
+self = , name = 'permission_runtime'
+
+    def get_context(self, name: str) -> BaseContextManager:
+        """Get Context Manager
+
+        Args:
+            name: Context Name
+
+        Returns:
+            BaseContextManager: Corresponding context manager
+
+        Raises:
+            KeyError: If the context does not exist
+        """
+        if name not in self._contexts:
+>           raise KeyError(f"Context '{name}' not found. Available contexts: {list(self._contexts.keys())}")
+E           KeyError: "Context 'permission_runtime' not found. Available contexts: []"
+
+bisheng/core/context/base.py:512: KeyError
+----------------------------- Captured stderr call -----------------------------
+2026-10-20 21:26:40.066 | ERROR    | bisheng.core.context.manager:_initialize_context_with_dependencies:166 - Failed to initialize context 'permission_runtime': "Context 'permission_runtime' not found. Available contexts: []"
+=============================== warnings summary ===============================
+bisheng/core/ai/__init__.py:2
+  /Users/zhangguoqing/.codex/worktrees/vibe-runtime-delivery/bisheng/src/backend/bisheng/core/ai/__init__.py:2: DeprecationWarning: `langchain-community` is being sunset and is no longer actively maintained. See https://github.com/langchain-ai/langchain-community/issues/674 for details and migration guidance toward standalone integration packages.
+    from langchain_community.chat_models import ChatTongyi, ChatZhipuAI, MoonshotChat
+
+:241
+:241
+  :241: DeprecationWarning: builtin type SwigPyPacked has no __module__ attribute
+
+:241
+:241
+  :241: DeprecationWarning: builtin type SwigPyObject has no __module__ attribute
+
+:241
+  :241: DeprecationWarning: builtin type swigvarlink has no __module__ attribute
+
+../../../../../../works/bisheng/src/backend/.venv/lib/python3.11/site-packages/jieba/_compat.py:18
+  /Users/zhangguoqing/works/bisheng/src/backend/.venv/lib/python3.11/site-packages/jieba/_compat.py:18: UserWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html. The pkg_resources package is slated for removal as early as 2025-11-30. Refrain from using this package or pin to Setuptools<81.
+    import pkg_resources
+
+test/app_runtime/test_upgrade_authorization_model.py: 24 warnings
+  /Users/zhangguoqing/.codex/worktrees/vibe-runtime-delivery/bisheng/src/backend/scripts/upgrade_f048_authorization_model.py:170: DeprecationWarning:
+          🚨 You probably want to use `session.exec()` instead of `session.execute()`.
+
+          This is the original SQLAlchemy `session.execute()` method that returns objects
+          of type `Row`, and that you have to call `scalars()` to get the model objects.
+
+          For example:
+
+          ```Python
+          heroes = await session.execute(select(Hero)).scalars().all()
+          ```
+
+          instead you could use `exec()`:
+
+          ```Python
+          heroes = await session.exec(select(Hero)).all()
+          ```
+
+    await session.execute(
+
+test/app_runtime/test_upgrade_authorization_model.py: 24 warnings
+  /Users/zhangguoqing/.codex/worktrees/vibe-runtime-delivery/bisheng/src/backend/scripts/upgrade_f048_authorization_model.py:189: DeprecationWarning:
+          🚨 You probably want to use `session.exec()` instead of `session.execute()`.
+
+          This is the original SQLAlchemy `session.execute()` method that returns objects
+          of type `Row`, and that you have to call `scalars()` to get the model objects.
+
+          For example:
+
+          ```Python
+          heroes = await session.execute(select(Hero)).scalars().all()
+          ```
+
+          instead you could use `exec()`:
+
+          ```Python
+          heroes = await session.exec(select(Hero)).all()
+          ```
+
+    (await session.execute(select(PermissionCatalogRelease).where(PermissionCatalogRelease.status == "CURRENT")))
+
+test/app_runtime/test_upgrade_authorization_model.py: 29 warnings
+  /Users/zhangguoqing/.codex/worktrees/vibe-runtime-delivery/bisheng/src/backend/scripts/upgrade_f048_authorization_model.py:203: DeprecationWarning:
+          🚨 You probably want to use `session.exec()` instead of `session.execute()`.
+
+          This is the original SQLAlchemy `session.execute()` method that returns objects
+          of type `Row`, and that you have to call `scalars()` to get the model objects.
+
+          For example:
+
+          ```Python
+          heroes = await session.execute(select(Hero)).scalars().all()
+          ```
+
+          instead you could use `exec()`:
+
+          ```Python
+          heroes = await session.exec(select(Hero)).all()
+          ```
+
+    await session.execute(
+
+test/app_runtime/test_upgrade_authorization_model.py: 21 warnings
+  /Users/zhangguoqing/.codex/worktrees/vibe-runtime-delivery/bisheng/src/backend/scripts/upgrade_f048_authorization_model.py:219: DeprecationWarning:
+          🚨 You probably want to use `session.exec()` instead of `session.execute()`.
+
+          This is the original SQLAlchemy `session.execute()` method that returns objects
+          of type `Row`, and that you have to call `scalars()` to get the model objects.
+
+          For example:
+
+          ```Python
+          heroes = await session.execute(select(Hero)).scalars().all()
+          ```
+
+          instead you could use `exec()`:
+
+          ```Python
+          heroes = await session.exec(select(Hero)).all()
+          ```
+
+    await session.execute(
+
+test/app_runtime/test_upgrade_authorization_model.py: 13 warnings
+  /Users/zhangguoqing/.codex/worktrees/vibe-runtime-delivery/bisheng/src/backend/scripts/upgrade_f048_authorization_model.py:259: DeprecationWarning:
+          🚨 You probably want to use `session.exec()` instead of `session.execute()`.
+
+          This is the original SQLAlchemy `session.execute()` method that returns objects
+          of type `Row`, and that you have to call `scalars()` to get the model objects.
+
+          For example:
+
+          ```Python
+          heroes = await session.execute(select(Hero)).scalars().all()
+          ```
+
+          instead you could use `exec()`:
+
+          ```Python
+          heroes = await session.exec(select(Hero)).all()
+          ```
+
+    await session.execute(
+
+test/app_runtime/test_upgrade_authorization_model.py::test_preflight_blocks_on_live_heartbeats
+test/app_runtime/test_upgrade_authorization_model.py::test_apply_publishes_model_idempotently
+test/app_runtime/test_upgrade_authorization_model.py::test_apply_reuses_a_model_already_in_the_store
+test/app_runtime/test_upgrade_authorization_model.py::test_apply_writes_release_rows_in_one_sql_txn
+test/app_runtime/test_upgrade_authorization_model.py::test_a_failure_in_the_last_step_rolls_back_the_first_three
+test/app_runtime/test_upgrade_authorization_model.py::test_verify_asserts_read_side
+test/app_runtime/test_upgrade_authorization_model.py::test_step1_not_rollbackable_documented
+test/app_runtime/test_upgrade_authorization_model.py::test_step1_not_rollbackable_documented
+  /Users/zhangguoqing/.codex/worktrees/vibe-runtime-delivery/bisheng/src/backend/scripts/upgrade_f048_authorization_model.py:404: DeprecationWarning:
+          🚨 You probably want to use `session.exec()` instead of `session.execute()`.
+
+          This is the original SQLAlchemy `session.execute()` method that returns objects
+          of type `Row`, and that you have to call `scalars()` to get the model objects.
+
+          For example:
+
+          ```Python
+          heroes = await session.execute(select(Hero)).scalars().all()
+          ```
+
+          instead you could use `exec()`:
+
+          ```Python
+          heroes = await session.exec(select(Hero)).all()
+          ```
+
+    await session.execute(
+
+test/app_runtime/test_upgrade_authorization_model.py::test_preflight_blocks_on_live_heartbeats
+test/app_runtime/test_upgrade_authorization_model.py::test_apply_publishes_model_idempotently
+test/app_runtime/test_upgrade_authorization_model.py::test_apply_reuses_a_model_already_in_the_store
+test/app_runtime/test_upgrade_authorization_model.py::test_apply_writes_release_rows_in_one_sql_txn
+test/app_runtime/test_upgrade_authorization_model.py::test_a_failure_in_the_last_step_rolls_back_the_first_three
+test/app_runtime/test_upgrade_authorization_model.py::test_verify_asserts_read_side
+test/app_runtime/test_upgrade_authorization_model.py::test_step1_not_rollbackable_documented
+test/app_runtime/test_upgrade_authorization_model.py::test_step1_not_rollbackable_documented
+  /Users/zhangguoqing/.codex/worktrees/vibe-runtime-delivery/bisheng/src/backend/scripts/upgrade_f048_authorization_model.py:435: DeprecationWarning:
+          🚨 You probably want to use `session.exec()` instead of `session.execute()`.
+
+          This is the original SQLAlchemy `session.execute()` method that returns objects
+          of type `Row`, and that you have to call `scalars()` to get the model objects.
+
+          For example:
+
+          ```Python
+          heroes = await session.execute(select(Hero)).scalars().all()
+          ```
+
+          instead you could use `exec()`:
+
+          ```Python
+          heroes = await session.exec(select(Hero)).all()
+          ```
+
+    await session.execute(
+
+test/app_runtime/test_upgrade_authorization_model.py::test_verify_asserts_read_side
+test/app_runtime/test_upgrade_authorization_model.py::test_verify_asserts_read_side
+test/app_runtime/test_upgrade_authorization_model.py::test_verify_asserts_read_side
+test/app_runtime/test_upgrade_authorization_model.py::test_verify_asserts_read_side
+test/app_runtime/test_upgrade_authorization_model.py::test_verify_asserts_read_side
+test/app_runtime/test_upgrade_authorization_model.py::test_verify_asserts_read_side
+  /Users/zhangguoqing/.codex/worktrees/vibe-runtime-delivery/bisheng/src/backend/scripts/upgrade_f048_authorization_model.py:490: DeprecationWarning:
+          🚨 You probably want to use `session.exec()` instead of `session.execute()`.
+
+          This is the original SQLAlchemy `session.execute()` method that returns objects
+          of type `Row`, and that you have to call `scalars()` to get the model objects.
+
+          For example:
+
+          ```Python
+          heroes = await session.execute(select(Hero)).scalars().all()
+          ```
+
+          instead you could use `exec()`:
+
+          ```Python
+          heroes = await session.exec(select(Hero)).all()
+          ```
+
+    return (await session.execute(statement)).scalar_one_or_none() is not None
+
+test/app_runtime/test_upgrade_authorization_model.py::test_step1_not_rollbackable_documented
+  /Users/zhangguoqing/.codex/worktrees/vibe-runtime-delivery/bisheng/src/backend/scripts/upgrade_f048_authorization_model.py:533: DeprecationWarning:
+          🚨 You probably want to use `session.exec()` instead of `session.execute()`.
+
+          This is the original SQLAlchemy `session.execute()` method that returns objects
+          of type `Row`, and that you have to call `scalars()` to get the model objects.
+
+          For example:
+
+          ```Python
+          heroes = await session.execute(select(Hero)).scalars().all()
+          ```
+
+          instead you could use `exec()`:
+
+          ```Python
+          heroes = await session.exec(select(Hero)).all()
+          ```
+
+    await session.execute(
+
+test/app_runtime/test_upgrade_authorization_model.py::test_step1_not_rollbackable_documented
+  /Users/zhangguoqing/.codex/worktrees/vibe-runtime-delivery/bisheng/src/backend/scripts/upgrade_f048_authorization_model.py:556: DeprecationWarning:
+          🚨 You probably want to use `session.exec()` instead of `session.execute()`.
+
+          This is the original SQLAlchemy `session.execute()` method that returns objects
+          of type `Row`, and that you have to call `scalars()` to get the model objects.
+
+          For example:
+
+          ```Python
+          heroes = await session.execute(select(Hero)).scalars().all()
+          ```
+
+          instead you could use `exec()`:
+
+          ```Python
+          heroes = await session.exec(select(Hero)).all()
+          ```
+
+    await session.execute(
+
+test/app_publish/test_deploy_api.py::test_session_cookie_cannot_call_v2_endpoints
+  /Users/zhangguoqing/works/bisheng/src/backend/.venv/lib/python3.11/site-packages/httpx/_client.py:1768: DeprecationWarning: Setting per-request cookies=<...> is being deprecated, because the expected behaviour on cookie persistence is ambiguous. Set cookies directly on the client instance instead.
+    return await self.request(
+
+test/app_publish/test_schema_migration_snapshot.py::TestMigrateForRelease::test_the_reference_is_the_online_version
+test/app_publish/test_schema_migration_snapshot.py::TestMigrateForRelease::test_a_breaking_plan_asks_for_the_snapshot
+test/app_publish/test_schema_migration_snapshot.py::TestMigrateForRelease::test_an_unchanged_declaration_costs_no_rpc
+  /Users/zhangguoqing/.codex/worktrees/vibe-runtime-delivery/bisheng/src/backend/test/app_publish/test_schema_migration_snapshot.py:190: DeprecationWarning:
+          🚨 You probably want to use `session.exec()` instead of `session.execute()`.
+
+          This is the original SQLAlchemy `session.execute()` method that returns objects
+          of type `Row`, and that you have to call `scalars()` to get the model objects.
+
+          For example:
+
+          ```Python
+          heroes = await session.execute(select(Hero)).scalars().all()
+          ```
+
+          instead you could use `exec()`:
+
+          ```Python
+          heroes = await session.exec(select(Hero)).all()
+          ```
+
+    row = (await session.execute(select(AppVersion).where(AppVersion.id == version_id))).scalars().first()
+
+../../../../../../works/bisheng/src/backend/.venv/lib/python3.11/site-packages/_pytest/cacheprovider.py:475
+  /Users/zhangguoqing/works/bisheng/src/backend/.venv/lib/python3.11/site-packages/_pytest/cacheprovider.py:475: PytestCacheWarning: cache could not write path /Users/zhangguoqing/.codex/worktrees/vibe-runtime-delivery/bisheng/src/backend/.pytest_cache/v/cache/nodeids: [Errno 1] Operation not permitted: '/Users/zhangguoqing/.codex/worktrees/vibe-runtime-delivery/bisheng/src/backend/.pytest_cache/v/cache/nodeids'
+    config.cache.set("cache/nodeids", sorted(self.cached_nodeids))
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+=========================== short test summary info ============================
+FAILED test/app_runtime/test_app_permission_registration.py::test_grant_subjects_five_endpoints_accept_app[list_grant_subject_users-list_candidate_users-kwargs0]
+FAILED test/app_runtime/test_app_permission_registration.py::test_grant_subjects_five_endpoints_accept_app[list_grant_subject_user_groups-list_candidate_user_groups-kwargs1]
+2 failed, 1214 passed, 24 skipped, 147 warnings in 30.65s
+sys:1: DeprecationWarning: builtin type swigvarlink has no __module__ attribute
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/bad-port-final.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/bad-port-final.log
new file mode 100644
index 0000000000..76c1daf2b3
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/bad-port-final.log
@@ -0,0 +1,3 @@
+Bad port retry submitted 313564fac2eb43c396d612a4ce0213f6
+Correct rejection {"code": 16124, "hints": [], "stage": "precheck_probe", "details": {"reason": "readiness_timeout", "maintenance": false, "operation_id": "b3d4a49ffe2144aca37b7866198a9c27", "runtime_stage": "starting", "data_may_have_changed": false}, "message": "\u5e94\u7528\u6267\u884c\u5931\u8d25"}
+Owner deleted failed draft; all extra delivery fixtures removed
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/cli-offline-retry.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/cli-offline-retry.log
new file mode 100644
index 0000000000..0465070423
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/cli-offline-retry.log
@@ -0,0 +1,51 @@
+登录成功:http://nginx:3001
+  服务账号: e2e-f086-publisher
+  资源归属人: 用户 #2(姓名请在服务账号详情页核对)
+  租户: 1
+  密钥: bs-sak-********v_ss
+  到期时间: 未设置
+  凭据已写入 /root/.bisheng/credentials.json(仅当前用户可读写)
+✓ deploy-hosting(版本 3.0.0):5 个文件已同步
+警告: 技能包已下载,但本机未接入任何 AI 编程工具——AI 读不到平台规矩。
+✓ platform-wiring(版本 3.0.0):8 个文件已同步
+警告: 技能包已下载,但本机未接入任何 AI 编程工具——AI 读不到平台规矩。
+
+技能包落点:/root/.bisheng/skills/nginx-3001.260949c2(按平台分目录,切换平台不会互相覆盖)
+  · 已同步:deploy-hosting、platform-wiring
+  · 本机未发现 Claude Code / Codex;技能包只是下载了,还没有任何工具能读到。
+  · 其它 AI 编程工具(Cursor、Cline 等):在项目 AGENTS.md 里指向
+    /root/.bisheng/skills/nginx-3001.260949c2/<包名>/SKILL.md;bisheng deploy 会自动写好这一行。
+{"event": "result", "command": "login", "ok": true, "exit_code": 0, "data": {"base_url": "http://nginx:3001", "actor_kind": "service_account", "actor_id": 1, "actor_name": "e2e-f086-publisher", "resource_owner": {"user_id": 2}, "tenant_id": 1, "key_mask": "bs-sak-********v_ss", "expires_at": null}, "failure": null}
+本项目不在 git 仓库内(或本机没有 git),忽略规则按子集解析;复杂规则请写进 .bishengignore(同 .gitignore 语法,最后加载、优先级最高)。
+已排除 0 项(版本控制元数据 / 依赖目录 / 缓存 / 本地数据文件等)
+打包条目 3 个,解包后 0.00 MB,压缩后 0.00 MB(上限:50 MB / 解包 200 MB / 20000 条目)
+说明: 本项目不在 git 仓库内(或本机没有 git),忽略规则按子集解析;复杂规则请写进 .bishengignore(同 .gitignore 语法,最后加载、优先级最高)。
+Top 目录:
+  (项目根)  0.00 MB
+Top 文件:
+  main.py  0.00 MB
+  bisheng-app.yaml  0.00 MB
+  requirements.txt  0.00 MB
+目标应用:(名称未知,本次发布后回填)(标识 3192b1ee03c943ce9fd15f91727ccb4d)
+若这不是要更新的应用,请中止并用 --app-id 指定正确的目标。
+开始上传(3 个条目,sha256 2ab85fda1204…)
+{"event": "progress", "command": "deploy", "phase": "upload", "sent_bytes": 1273, "total_bytes": 1273}
+已上传 0.0 MB / 0.0 MB (100%)
+平台已接收,应用标识 3192b1ee03c943ce9fd15f91727ccb4d,发布单 0ea77ee2b5cb47f09fd3f496e2c4c78e
+入口地址暂不可得,请在 应用详情页 · 发布 获取。
+已创建 AGENTS.md,写明技能包位置(供其它 AI 工具查阅,建议提交到仓库)
+{"event": "stage", "command": "deploy", "stage": "received", "status": "running", "ts": "2026-10-20T12:26:23+0000"}
+[running] 已接收
+[running] 依赖构建
+{"event": "stage", "command": "deploy", "stage": "precheck_build", "status": "running", "ts": "2026-10-20T12:26:25+0000"}
+[running] 启动探活
+{"event": "stage", "command": "deploy", "stage": "precheck_probe", "status": "running", "ts": "2026-10-20T12:26:53+0000"}
+{"event": "stage", "command": "deploy", "stage": "approval_created", "status": "running", "ts": "2026-10-20T12:27:06+0000"}
+[running] 审批单已生成
+审批单已生成:4。审批是人工动作,可能跨天。
+跟踪方式:
+  - 在 应用详情页 · 发布 查看审批与上线进度
+  - 用 MCP 的应用状态工具查询
+  - 重新执行 bisheng deploy --wait 继续等待
+{"event": "result", "command": "deploy", "ok": true, "exit_code": 0, "data": {"deployment_id": "0ea77ee2b5cb47f09fd3f496e2c4c78e", "app_id": "3192b1ee03c943ce9fd15f91727ccb4d", "version_id": "e7d5b023450d4b4283c727e5cf0211f9", "entry_url": "http://nginx:3001/apps/e2e-f086-sqlite", "stage": "approval_created", "app_state": "online", "pending_reason": null, "approval": {"instance_id": 4, "status": "pending", "version_id": "e7d5b023450d4b4283c727e5cf0211f9", "submitted_at": "2026-10-20T12:27:03", "decided_at": null, "reject_reason": null, "approver_names": ["e2e-f086-admin"]}}, "failure": null}
+Actual packaged CLI deploy exit: 0
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/container-integration-final.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/container-integration-final.log
new file mode 100644
index 0000000000..5836ed8bfe
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/container-integration-final.log
@@ -0,0 +1 @@
+{"platform": "linux/amd64", "checks": [{"language": "python3.11", "offline": true, "code_update": true, "dependency_reuse": true}, {"language": "node20", "offline": true, "code_update": true, "dependency_reuse": true}, {"language": "static", "offline": true, "code_update": true, "dependency_reuse": true}, {"language": "static", "offline": true, "install_hook": true, "frontend_build": true, "source_change_invalidates_dependency": true}]}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/delete-app.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/delete-app.log
new file mode 100644
index 0000000000..2677ee8d8b
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/delete-app.log
@@ -0,0 +1 @@
+Owner deleted dedicated application 3192b1ee03c943ce9fd15f91727ccb4d
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/dev-toolkit-frozen-final.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/dev-toolkit-frozen-final.log
new file mode 100644
index 0000000000..6f3c6d2df3
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/dev-toolkit-frozen-final.log
@@ -0,0 +1,17 @@
+........................................................................ [ 55%]
+.........................................................                [100%]
+=============================== warnings summary ===============================
+bisheng/core/ai/__init__.py:2
+  /work/bisheng/core/ai/__init__.py:2: DeprecationWarning: `langchain-community` is being sunset and is no longer actively maintained. See https://github.com/langchain-ai/langchain-community/issues/674 for details and migration guidance toward standalone integration packages.
+    from langchain_community.chat_models import ChatTongyi, ChatZhipuAI, MoonshotChat
+
+../app/.venv/lib/python3.11/site-packages/jieba/_compat.py:18
+  /app/.venv/lib/python3.11/site-packages/jieba/_compat.py:18: UserWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html. The pkg_resources package is slated for removal as early as 2025-11-30. Refrain from using this package or pin to Setuptools<81.
+    import pkg_resources
+
+test/dev_toolkit/test_distribution_api.py::test_download_requires_no_login_and_no_key
+  /app/.venv/lib/python3.11/site-packages/starlette/testclient.py:451: DeprecationWarning: Setting per-request cookies=<...> is being deprecated, because the expected behaviour on cookie persistence is ambiguous. Set cookies directly on the client instance instead.
+    return super().request(
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+129 passed, 3 warnings in 122.27s (0:02:02)
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/extra-delivery.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/extra-delivery.log
new file mode 100644
index 0000000000..56454ec240
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/extra-delivery.log
@@ -0,0 +1,11 @@
+image online through nginx/app-proxy
+image owner cleanup complete
+legacy submitted 0f135ac5eece4cd1a8d3091926a87f2c
+legacy online through nginx/app-proxy
+legacy owner cleanup complete
+bad-port submitted 0cd5d431d8124a84bcab7c0becaa393d
+Traceback (most recent call last):
+  File "/tmp/e2e-f086-extra/run.py", line 56, in 
+    assert status['status']=='failed' and not status.get('version_id') and not status.get('approval'),status
+           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+AssertionError: {'deployment_id': '0cd5d431d8124a84bcab7c0becaa393d', 'app_id': '56e7f26a33884f6eb0cc526e394fc8c2', 'version_id': '57f7239c130b49f4a52638b7555c687d', 'version_no': None, 'stage': 'failed', 'status': 'failed', 'failure': {'code': 16124, 'hints': [], 'stage': 'failed', 'details': {}, 'message': '应用执行失败'}, 'approval': None, 'app_state': 'draft', 'entry_url': 'http://nginx:3001/apps/e2e-f086-bad-port', 'scan_result': {'hits': [], 'blocked': False, 'skipped': [], 'files_scanned': 1, 'files_skipped': 0}}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/failure-mapping-local.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/failure-mapping-local.log
new file mode 100644
index 0000000000..c4df372cd0
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/failure-mapping-local.log
@@ -0,0 +1,28 @@
+................                                                         [100%]
+=============================== warnings summary ===============================
+bisheng/core/ai/__init__.py:2
+  /Users/zhangguoqing/.codex/worktrees/vibe-runtime-delivery/bisheng/src/backend/bisheng/core/ai/__init__.py:2: DeprecationWarning: `langchain-community` is being sunset and is no longer actively maintained. See https://github.com/langchain-ai/langchain-community/issues/674 for details and migration guidance toward standalone integration packages.
+    from langchain_community.chat_models import ChatTongyi, ChatZhipuAI, MoonshotChat
+
+:241
+:241
+  :241: DeprecationWarning: builtin type SwigPyPacked has no __module__ attribute
+
+:241
+:241
+  :241: DeprecationWarning: builtin type SwigPyObject has no __module__ attribute
+
+:241
+  :241: DeprecationWarning: builtin type swigvarlink has no __module__ attribute
+
+../../../../../../works/bisheng/src/backend/.venv/lib/python3.11/site-packages/jieba/_compat.py:18
+  /Users/zhangguoqing/works/bisheng/src/backend/.venv/lib/python3.11/site-packages/jieba/_compat.py:18: UserWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html. The pkg_resources package is slated for removal as early as 2025-11-30. Refrain from using this package or pin to Setuptools<81.
+    import pkg_resources
+
+../../../../../../works/bisheng/src/backend/.venv/lib/python3.11/site-packages/_pytest/cacheprovider.py:475
+  /Users/zhangguoqing/works/bisheng/src/backend/.venv/lib/python3.11/site-packages/_pytest/cacheprovider.py:475: PytestCacheWarning: cache could not write path /Users/zhangguoqing/.codex/worktrees/vibe-runtime-delivery/bisheng/src/backend/.pytest_cache/v/cache/nodeids: [Errno 1] Operation not permitted: '/Users/zhangguoqing/.codex/worktrees/vibe-runtime-delivery/bisheng/src/backend/.pytest_cache/v/cache/nodeids'
+    config.cache.set("cache/nodeids", sorted(self.cached_nodeids))
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+16 passed, 8 warnings in 0.39s
+sys:1: DeprecationWarning: builtin type swigvarlink has no __module__ attribute
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/import-material.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/import-material.log
new file mode 100644
index 0000000000..f20f25a398
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/import-material.log
@@ -0,0 +1 @@
+Owner imported verified private offline wheel package 03b1d019444144ef9a3e55be2af9c977
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/large-material.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/large-material.log
new file mode 100644
index 0000000000..4dd1148461
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/large-material.log
@@ -0,0 +1 @@
+nginx forwarded and backend verified large offline upload bytes: 2218132
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/manager-independence.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/manager-independence.log
new file mode 100644
index 0000000000..b972ff6234
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/manager-independence.log
@@ -0,0 +1,7 @@
+serving HTTP 200
+serving HTTP 200
+serving HTTP 200
+serving HTTP 200
+serving HTTP 200
+serving HTTP 200
+SDK storage remains available without manager
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/manager-local-final.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/manager-local-final.log
new file mode 100644
index 0000000000..18ab54fcf7
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/manager-local-final.log
@@ -0,0 +1,8 @@
+........................................................................ [ 16%]
+...............................s........................................ [ 32%]
+........................................................sss............. [ 48%]
+...........s............................................................ [ 64%]
+...........................s...................s..................s..... [ 80%]
+....ss.................................................................. [ 96%]
+...............                                                          [100%]
+437 passed, 10 skipped in 4.63s
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/nginx-main-check.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/nginx-main-check.log
new file mode 100644
index 0000000000..159c3ad438
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/nginx-main-check.log
@@ -0,0 +1,2 @@
+nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
+nginx: configuration file /etc/nginx/nginx.conf test is successful
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/nginx-static-check.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/nginx-static-check.log
new file mode 100644
index 0000000000..72d5939e8d
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/nginx-static-check.log
@@ -0,0 +1,2 @@
+nginx: the configuration file /tmp/nginx.conf syntax is ok
+nginx: configuration file /tmp/nginx.conf test is successful
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/publish-v4-missing-material.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/publish-v4-missing-material.log
new file mode 100644
index 0000000000..b66be80f9a
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/publish-v4-missing-material.log
@@ -0,0 +1,7 @@
+Submitted v4 26702a608dfd483d963ea36455aefe14
+received running
+precheck_build running
+precheck_build running
+precheck_build running
+precheck_build failed
+Publish outcome {"deployment_id": "26702a608dfd483d963ea36455aefe14", "app_id": "3192b1ee03c943ce9fd15f91727ccb4d", "version_id": "11f610a044a24578b0f5d3c85e653589", "version_no": null, "stage": "precheck_build", "status": "failed", "failure": {"code": 16227, "hints": [], "stage": "precheck_build", "details": {"reason": "\u5e94\u7528\u6267\u884c\u5931\u8d25", "operation_id": "9783f77ac50b489ab0876ab252659a27"}, "message": "\u5e94\u7528\u4f9d\u8d56\u6216\u4ea4\u4ed8\u51c6\u5907\u5931\u8d25"}, "approval": null, "app_state": "online", "entry_url": "http://nginx:3001/apps/e2e-f086-sqlite", "scan_result": {"hits": [], "blocked": false, "skipped": [], "files_scanned": 3, "files_skipped": 0}}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/restore-lifecycle.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/restore-lifecycle.log
new file mode 100644
index 0000000000..314c5736b8
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/restore-lifecycle.log
@@ -0,0 +1,11 @@
+Restore admitted pending
+Restore maintenance running
+Restore maintenance running
+Restore maintenance running
+Restore maintenance running
+Restore maintenance running
+Restore starting running
+Restore completed completed
+Online restore retained code version, restored data/schema and confirmed protection snapshot
+Stopped restore did not bring application online
+Stop/resume preserved persistent application data
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/sql-race-final.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/sql-race-final.log
new file mode 100644
index 0000000000..cc1b5e2f8f
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/sql-race-final.log
@@ -0,0 +1,58 @@
+============================= test session starts ==============================
+platform linux -- Python 3.11.15, pytest-9.1.1, pluggy-1.7.0 -- /app/.venv/bin/python
+cachedir: .pytest_cache
+rootdir: /work
+configfile: pyproject.toml
+plugins: asyncio-1.3.0, langsmith-0.8.14, anyio-4.11.0
+asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
+collecting ... collected 5 items
+
+test/app_runtime/test_runtime_delivery_database.py::test_live_identical_concurrent_requests_return_one_operation PASSED [ 20%]
+test/app_runtime/test_runtime_delivery_database.py::test_live_last_capacity_admits_only_one_tenant PASSED [ 40%]
+test/app_runtime/test_runtime_delivery_database.py::test_live_competing_claims_have_one_assignment PASSED [ 60%]
+test/app_runtime/test_runtime_delivery_database.py::test_live_outer_rollback_removes_nested_operation_and_reservation PASSED [ 80%]
+test/app_runtime/test_runtime_delivery_database.py::test_live_foreign_tenant_cannot_load_or_write_operation PASSED [100%]
+
+=============================== warnings summary ===============================
+bisheng/core/ai/__init__.py:2
+  /work/bisheng/core/ai/__init__.py:2: DeprecationWarning: `langchain-community` is being sunset and is no longer actively maintained. See https://github.com/langchain-ai/langchain-community/issues/674 for details and migration guidance toward standalone integration packages.
+    from langchain_community.chat_models import ChatTongyi, ChatZhipuAI, MoonshotChat
+
+../app/.venv/lib/python3.11/site-packages/simhash/__init__.py:50
+  /app/.venv/lib/python3.11/site-packages/simhash/__init__.py:50: DeprecationWarning: invalid escape sequence '\w'
+    """
+
+../app/.venv/lib/python3.11/site-packages/jieba/__init__.py:44
+  /app/.venv/lib/python3.11/site-packages/jieba/__init__.py:44: DeprecationWarning: invalid escape sequence '\.'
+    re_han_default = re.compile("([\u4E00-\u9FD5a-zA-Z0-9+#&\._%\-]+)", re.U)
+
+../app/.venv/lib/python3.11/site-packages/jieba/__init__.py:46
+  /app/.venv/lib/python3.11/site-packages/jieba/__init__.py:46: DeprecationWarning: invalid escape sequence '\s'
+    re_skip_default = re.compile("(\r\n|\s)", re.U)
+
+../app/.venv/lib/python3.11/site-packages/jieba/finalseg/__init__.py:78
+  /app/.venv/lib/python3.11/site-packages/jieba/finalseg/__init__.py:78: DeprecationWarning: invalid escape sequence '\.'
+    re_skip = re.compile("([a-zA-Z0-9]+(?:\.\d+)?%?)")
+
+../app/.venv/lib/python3.11/site-packages/jieba/_compat.py:18
+  /app/.venv/lib/python3.11/site-packages/jieba/_compat.py:18: UserWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html. The pkg_resources package is slated for removal as early as 2025-11-30. Refrain from using this package or pin to Setuptools<81.
+    import pkg_resources
+
+../app/.venv/lib/python3.11/site-packages/jieba/posseg/__init__.py:16
+  /app/.venv/lib/python3.11/site-packages/jieba/posseg/__init__.py:16: DeprecationWarning: invalid escape sequence '\.'
+    re_skip_detail = re.compile("([\.0-9]+|[a-zA-Z0-9]+)")
+
+../app/.venv/lib/python3.11/site-packages/jieba/posseg/__init__.py:17
+  /app/.venv/lib/python3.11/site-packages/jieba/posseg/__init__.py:17: DeprecationWarning: invalid escape sequence '\.'
+    re_han_internal = re.compile("([\u4E00-\u9FD5a-zA-Z0-9+#&\._]+)")
+
+../app/.venv/lib/python3.11/site-packages/jieba/posseg/__init__.py:18
+  /app/.venv/lib/python3.11/site-packages/jieba/posseg/__init__.py:18: DeprecationWarning: invalid escape sequence '\s'
+    re_skip_internal = re.compile("(\r\n|\s)")
+
+../app/.venv/lib/python3.11/site-packages/jieba/posseg/__init__.py:21
+  /app/.venv/lib/python3.11/site-packages/jieba/posseg/__init__.py:21: DeprecationWarning: invalid escape sequence '\.'
+    re_num = re.compile("[\.0-9]+")
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+======================== 5 passed, 10 warnings in 3.86s ========================
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/sql-race-tests.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/sql-race-tests.log
new file mode 100644
index 0000000000..030de8e2d4
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/logs/sql-race-tests.log
@@ -0,0 +1,71 @@
+============================= test session starts ==============================
+platform linux -- Python 3.11.15, pytest-9.1.1, pluggy-1.7.0 -- /app/.venv/bin/python
+cachedir: .pytest_cache
+rootdir: /work
+configfile: pyproject.toml
+plugins: asyncio-1.3.0, langsmith-0.8.14, anyio-4.11.0
+asyncio: mode=Mode.AUTO, debug=False, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
+collecting ... collected 5 items
+
+test/app_runtime/test_runtime_delivery_database.py::test_live_identical_concurrent_requests_return_one_operation FAILED [ 20%]
+test/app_runtime/test_runtime_delivery_database.py::test_live_last_capacity_admits_only_one_tenant PASSED [ 40%]
+test/app_runtime/test_runtime_delivery_database.py::test_live_competing_claims_have_one_assignment FAILED [ 60%]
+test/app_runtime/test_runtime_delivery_database.py::test_live_outer_rollback_removes_nested_operation_and_reservation PASSED [ 80%]
+test/app_runtime/test_runtime_delivery_database.py::test_live_foreign_tenant_cannot_load_or_write_operation PASSED [100%]
+
+=================================== FAILURES ===================================
+_________ test_live_identical_concurrent_requests_return_one_operation _________
+test/app_runtime/test_runtime_delivery_database.py:107: in test_live_identical_concurrent_requests_return_one_operation
+    assert not [type(row).__name__ for row in results if isinstance(row, BaseException)]
+E   AssertionError: assert not ['RuntimeConflict', 'RuntimeConflict', 'RuntimeConflict', 'RuntimeConflict', 'RuntimeConflict', 'RuntimeConflict', ...]
+________________ test_live_competing_claims_have_one_assignment ________________
+test/app_runtime/test_runtime_delivery_database.py:163: in test_live_competing_claims_have_one_assignment
+    assert len([row for row in results if isinstance(row, RuntimeConflict)]) == 1
+E   assert 0 == 1
+E    +  where 0 = len([])
+=============================== warnings summary ===============================
+bisheng/core/ai/__init__.py:2
+  /work/bisheng/core/ai/__init__.py:2: DeprecationWarning: `langchain-community` is being sunset and is no longer actively maintained. See https://github.com/langchain-ai/langchain-community/issues/674 for details and migration guidance toward standalone integration packages.
+    from langchain_community.chat_models import ChatTongyi, ChatZhipuAI, MoonshotChat
+
+../app/.venv/lib/python3.11/site-packages/simhash/__init__.py:50
+  /app/.venv/lib/python3.11/site-packages/simhash/__init__.py:50: DeprecationWarning: invalid escape sequence '\w'
+    """
+
+../app/.venv/lib/python3.11/site-packages/jieba/__init__.py:44
+  /app/.venv/lib/python3.11/site-packages/jieba/__init__.py:44: DeprecationWarning: invalid escape sequence '\.'
+    re_han_default = re.compile("([\u4E00-\u9FD5a-zA-Z0-9+#&\._%\-]+)", re.U)
+
+../app/.venv/lib/python3.11/site-packages/jieba/__init__.py:46
+  /app/.venv/lib/python3.11/site-packages/jieba/__init__.py:46: DeprecationWarning: invalid escape sequence '\s'
+    re_skip_default = re.compile("(\r\n|\s)", re.U)
+
+../app/.venv/lib/python3.11/site-packages/jieba/finalseg/__init__.py:78
+  /app/.venv/lib/python3.11/site-packages/jieba/finalseg/__init__.py:78: DeprecationWarning: invalid escape sequence '\.'
+    re_skip = re.compile("([a-zA-Z0-9]+(?:\.\d+)?%?)")
+
+../app/.venv/lib/python3.11/site-packages/jieba/_compat.py:18
+  /app/.venv/lib/python3.11/site-packages/jieba/_compat.py:18: UserWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html. The pkg_resources package is slated for removal as early as 2025-11-30. Refrain from using this package or pin to Setuptools<81.
+    import pkg_resources
+
+../app/.venv/lib/python3.11/site-packages/jieba/posseg/__init__.py:16
+  /app/.venv/lib/python3.11/site-packages/jieba/posseg/__init__.py:16: DeprecationWarning: invalid escape sequence '\.'
+    re_skip_detail = re.compile("([\.0-9]+|[a-zA-Z0-9]+)")
+
+../app/.venv/lib/python3.11/site-packages/jieba/posseg/__init__.py:17
+  /app/.venv/lib/python3.11/site-packages/jieba/posseg/__init__.py:17: DeprecationWarning: invalid escape sequence '\.'
+    re_han_internal = re.compile("([\u4E00-\u9FD5a-zA-Z0-9+#&\._]+)")
+
+../app/.venv/lib/python3.11/site-packages/jieba/posseg/__init__.py:18
+  /app/.venv/lib/python3.11/site-packages/jieba/posseg/__init__.py:18: DeprecationWarning: invalid escape sequence '\s'
+    re_skip_internal = re.compile("(\r\n|\s)")
+
+../app/.venv/lib/python3.11/site-packages/jieba/posseg/__init__.py:21
+  /app/.venv/lib/python3.11/site-packages/jieba/posseg/__init__.py:21: DeprecationWarning: invalid escape sequence '\.'
+    re_num = re.compile("[\.0-9]+")
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+=========================== short test summary info ============================
+FAILED test/app_runtime/test_runtime_delivery_database.py::test_live_identical_concurrent_requests_return_one_operation
+FAILED test/app_runtime/test_runtime_delivery_database.py::test_live_competing_claims_have_one_assignment
+=================== 2 failed, 3 passed, 10 warnings in 4.20s ===================
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/normalization.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/normalization.json
new file mode 100644
index 0000000000..b06923d02b
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/normalization.json
@@ -0,0 +1,21 @@
+{
+  "date": "2026-10-21",
+  "purpose": "Preserve validation content while removing trailing whitespace before Git checkpoint",
+  "files": {
+    "evidence/docker-cleanup.json": {
+      "original_sha256": "4df80e8bb9764aef0b0f64383cb37e8b6ef88b7dd590276844d6d4511b8594ac",
+      "normalized_sha256": "a719cd8ade2d1cd7a3a8769b5e9541fc51847b615ae108a0ed6de7448c321fe3",
+      "method": "trim line-ending spaces and tabs only"
+    },
+    "logs/backend-local-final.log": {
+      "original_sha256": "272dfaf662dedbeb3d94759c7a343e21b5bebff486a58b22c2e5ccdac9bc6998",
+      "normalized_sha256": "099b2a9eb89db43b67e4ffb358ec751678d2abacc3fbd5be115126f4ae776d0d",
+      "method": "trim line-ending spaces and tabs only"
+    },
+    "sha256-export.json": {
+      "original_sha256": "119610c5bdbda4f2a7f74f495b7f7ba876148b8cf3b64a7c0cc9061431e0d1c5",
+      "normalized_sha256": "b86a233742f4cc52e9f9d312d84075637fd6d511a5e430dd69f1dfc0c465c538",
+      "method": "trim line-ending spaces and tabs only"
+    }
+  }
+}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/offline-import-result.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/offline-import-result.json
new file mode 100644
index 0000000000..24ed95ab13
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/offline-import-result.json
@@ -0,0 +1 @@
+{"artifact_id": "03b1d019444144ef9a3e55be2af9c977", "state": "verified"}
\ No newline at end of file
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/preview-v3.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/preview-v3.json
new file mode 100644
index 0000000000..942ee237d9
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/preview-v3.json
@@ -0,0 +1 @@
+{"state": "running", "session_id": "5e4db92080394ca9991da1f4f1386e82", "entry_url": "/apps/preview/5e4db92080394ca9991da1f4f1386e82", "expires_at": "2026-10-27T12:12:43", "reclaim_reason": null, "runnable": true, "not_runnable_reason": null}
\ No newline at end of file
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/restore-lifecycle-result.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/restore-lifecycle-result.json
new file mode 100644
index 0000000000..9bcb184bc8
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/restore-lifecycle-result.json
@@ -0,0 +1 @@
+{"before": {"app_id": "3192b1ee03c943ce9fd15f91727ccb4d", "state": "online", "current_version_id": "99639ce2d9ac4e9bbcf5ddd02077b351", "generation": 3, "operation_id": "ba94cb19e8da4bb38fecf220bee6140c"}, "after": {"app_id": "3192b1ee03c943ce9fd15f91727ccb4d", "state": "online", "current_version_id": "99639ce2d9ac4e9bbcf5ddd02077b351", "generation": 4, "operation_id": "7599ffc4cd9f46ddbb3dcaa47b50ff8c"}, "snapshot_count": 2, "online_restore": true, "stopped_restore": true, "resume": true}
\ No newline at end of file
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/sha256-export.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/sha256-export.json
new file mode 100644
index 0000000000..a8cc6009a5
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/sha256-export.json
@@ -0,0 +1,43 @@
+{
+  "restore-lifecycle-result.json": "5ae68278487ad9a8533f31f80b9bff55a68f2fb20155efb1acd131ad8d8a1aab",
+  "logs/restore-lifecycle.log": "3a3932f6b6964a1aba900abccacb1a5e71907f4535864f84fa384485dd133e3b",
+  "evidence/control-records.txt": "00e2c4a2be299174efefca1a3aea19d51b4b5cf39b8a63663c5d95061520e15d",
+  "logs/import-material.log": "c71e8ec03842d8d37ec4fb38c836c723c8edc0cc996d2338da560cfac382356c",
+  "logs/dev-toolkit-frozen-final.log": "c20b9a5cc0e95dc851c24141e8bcf0cdf7a6f744844792f6947f869460af4ca2",
+  "logs/delete-app.log": "475808f026bf4794093d6a05dde6d755bdabf90c35e87bcc0f3193355ecf7f22",
+  "before-containers.txt": "af2a8bce3fd873f8a77d2982f9c0554c1ec8a7fa4b3b6b087aa3da0debc577b4",
+  "evidence/extra-delivery-results.json": "50051b14e9dd5462fde54f5d377b830f50278048df16a9803685e15164e9747f",
+  "evidence/amd64-native.log": "0f8f802bbbcca0e0374f376e8a8a3216c4bfbf032b0ae640ab1e4d3e4f185e5a",
+  "offline-import-result.json": "ce7edb6c552a20c18ffae19a5941e7f93f2f246d9c50dfeaae896e5b1b3ef28f",
+  "logs/approve-v4-offline-supply.log": "50ddd8642fd4788dc8550ccaab3cce78afbac98b1da61bd3aa02d0ff3954aad8",
+  "preview-v3.json": "b68ef8a171edfeb8528c12ad898e5cf4265ff70e1b09d9449c7c5709fcdd71c9",
+  "logs/nginx-main-check.log": "c1d95d6441adac8d73265f026042743581500b1133052abc44ed98cf52bb6618",
+  "logs/sql-race-tests.log": "d65a8156485e921c58430deb81e6fee066a8dab12d8e2f798b00ff5b836258c0",
+  "logs/large-material.log": "b232e7f9c8cc3999d4d49204239ec75fb84a3fb22a13244c6afc345506f6d26f",
+  "evidence/final-control-records.txt": "848169dc2d20bcc0a53b784cb414c855c34cc2f3ba8892597893053eca163281",
+  "logs/nginx-static-check.log": "12b2d209f0eaaf9886f84ba4b0e76fbadbc4e71aa23d15c6f52b44abc14b2b84",
+  "logs/container-integration-final.log": "7f075917f97cfdb86f6c9cd99a16afa621cae9c515684fb818b506a005057eca",
+  "evidence/preview-v3.log": "bc9e7fb4a32464bb01ec7476766cd07240bee6e1ce1928fe4098d98c94cb0821",
+  "logs/approve-and-access.log": "9eb18e1a97753693358787c3c713e3ae5edcfc5bf3f195f47bf6eff5fd675efb",
+  "evidence/restore-audit.txt": "1c38c64ec22aebee5bc130dc0bd248687963796f3ad786970c4abe5c1862c2f0",
+  "logs/cli-offline-retry.log": "ae2ebd7fcc74633ff34ddb258a45c7b2207b2b2b79a52eac4c929cf902086f7a",
+  "evidence/deletion-filesystem.txt": "622ac0069b4529d6aeb46751d9fb0135c7ab91d02d2404c7c79b0bbca908924d",
+  "logs/bad-port-final.log": "563821f629e861232421fd0cc6f3e36a5bdbccf01d13ee0ebb1d95d84c17a7e9",
+  "logs/api-bootstrap.log": "50dc106b6c9360949c9045ddaee7da378212af8f6732adf5976ebbefa4341aa9",
+  "evidence/image-facts-final.ndjson": "15b78c36ebf4c74fa610931ced1afe7d3cb7204f5ce97ba7cf2f658e72468f54",
+  "deployment-v1.json": "ca0980b1728eef6acf56adf9e5ab971b316c11bdcfb7d279197b327803bdc86f",
+  "logs/sql-race-final.log": "04db1264c0dae3b19c1ec25e801b493594aded86005d3d6ca8e98166005eb5ae",
+  "logs/extra-delivery.log": "4bd05dd9bbde8b267b5916843e2f7d87c796ef65a7a67d7031bf5c99e36b3555",
+  "logs/approve-v3-snapshot-sdk-ws.log": "ee5de80ad841ed734cac7af9284af0bf840a22fc2e9896af11c224fd3b63ab22",
+  "logs/backend-failclosed.log": "d38453a5c70763c1591aa1c97730fc08d58de2f6ec8ca64973357103b0785d42",
+  "after-containers.txt": "e9f8e7ca1e91a5f85a3b425138319cbf6269a5acfc703538b1b444fbaa7e4f3e",
+  "evidence/image-facts.ndjson": "79456528410c0904f01270007154727a51c96926c28bee10f15831c9caa1468f",
+  "logs/publish-v4-missing-material.log": "f53199ca6b06182ad1d0eb54d968d23d81b87bdb3cea464c6ee6712e39ce71e4",
+  "deployment-v2.json": "b67b7fddbb3533386d38a5aabab7c107319d6efadb967a32f97ac9093716d63e",
+  "deployment-v4.json": "6c205e4801b9d5925730f0dca6e38fab47aaa20062b4ad3290c85bdabe9b1084",
+  "evidence/bad-port-final.json": "56028ccb3dacb8ba363935b74bbe3deeb71e0e8e308cb6af0f6b7c8d7fa64a17",
+  "logs/manager-independence.log": "aee5ed4e75978f7bdba4e3949f8c1619e8f8027c60d82f8ba30035ed4add3ebd",
+  "deployment-v3.json": "fad8f7d3dc0dd9785e351406a4abd7b2edc51ebb383fcdecdbed99b5a219588a",
+  "evidence/container-facts.ndjson": "9647f788e1c67f1e8f98e1826faa66f1fee09d7ae6866d87ca09588c6124d9e9",
+  "evidence/deletion-records.txt": "35e41021776d2a1530840c0c50f3c82a7110a3bc4353d2db2e7f0ea6e72976ed"
+}
\ No newline at end of file
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/sha256.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/sha256.json
new file mode 100644
index 0000000000..033f8ac36b
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-20-docker/sha256.json
@@ -0,0 +1,53 @@
+{
+  "after-containers.txt": "e9f8e7ca1e91a5f85a3b425138319cbf6269a5acfc703538b1b444fbaa7e4f3e",
+  "before-containers.txt": "af2a8bce3fd873f8a77d2982f9c0554c1ec8a7fa4b3b6b087aa3da0debc577b4",
+  "deployment-v1.json": "ca0980b1728eef6acf56adf9e5ab971b316c11bdcfb7d279197b327803bdc86f",
+  "deployment-v2.json": "b67b7fddbb3533386d38a5aabab7c107319d6efadb967a32f97ac9093716d63e",
+  "deployment-v3.json": "fad8f7d3dc0dd9785e351406a4abd7b2edc51ebb383fcdecdbed99b5a219588a",
+  "deployment-v4.json": "6c205e4801b9d5925730f0dca6e38fab47aaa20062b4ad3290c85bdabe9b1084",
+  "evidence/amd64-native.log": "0f8f802bbbcca0e0374f376e8a8a3216c4bfbf032b0ae640ab1e4d3e4f185e5a",
+  "evidence/arm64-cleanup.json": "7b0e13bbb4d120db10cb572d644b4fdd27dd00657af52a6e2a37fd38e06c2d58",
+  "evidence/arm64-image-inventory.json": "0e12bd41754968fa4ca4ae0b1b731df9a3b511017a708ea388bfb3996e088528",
+  "evidence/arm64-native.log": "302bc6b2b97a854309e8e773bd78f5e6ce811f90d7c400cafa4d2552eccc2701",
+  "evidence/bad-port-final.json": "56028ccb3dacb8ba363935b74bbe3deeb71e0e8e308cb6af0f6b7c8d7fa64a17",
+  "evidence/container-facts.ndjson": "9647f788e1c67f1e8f98e1826faa66f1fee09d7ae6866d87ca09588c6124d9e9",
+  "evidence/control-records.txt": "00e2c4a2be299174efefca1a3aea19d51b4b5cf39b8a63663c5d95061520e15d",
+  "evidence/deletion-filesystem.txt": "622ac0069b4529d6aeb46751d9fb0135c7ab91d02d2404c7c79b0bbca908924d",
+  "evidence/deletion-records.txt": "35e41021776d2a1530840c0c50f3c82a7110a3bc4353d2db2e7f0ea6e72976ed",
+  "evidence/docker-cleanup.json": "a719cd8ade2d1cd7a3a8769b5e9541fc51847b615ae108a0ed6de7448c321fe3",
+  "evidence/extra-delivery-results.json": "50051b14e9dd5462fde54f5d377b830f50278048df16a9803685e15164e9747f",
+  "evidence/final-control-records.txt": "848169dc2d20bcc0a53b784cb414c855c34cc2f3ba8892597893053eca163281",
+  "evidence/image-facts-final.ndjson": "15b78c36ebf4c74fa610931ced1afe7d3cb7204f5ce97ba7cf2f658e72468f54",
+  "evidence/image-facts.ndjson": "79456528410c0904f01270007154727a51c96926c28bee10f15831c9caa1468f",
+  "evidence/preexisting-services-check.json": "27d1e413a0d12e069c32f5d2acbecd279cb18c85a366d435d72e16ef4f4933c5",
+  "evidence/preview-v3.log": "bc9e7fb4a32464bb01ec7476766cd07240bee6e1ce1928fe4098d98c94cb0821",
+  "evidence/restore-audit.txt": "1c38c64ec22aebee5bc130dc0bd248687963796f3ad786970c4abe5c1862c2f0",
+  "logs/api-bootstrap.log": "50dc106b6c9360949c9045ddaee7da378212af8f6732adf5976ebbefa4341aa9",
+  "logs/approve-and-access.log": "9eb18e1a97753693358787c3c713e3ae5edcfc5bf3f195f47bf6eff5fd675efb",
+  "logs/approve-v3-snapshot-sdk-ws.log": "ee5de80ad841ed734cac7af9284af0bf840a22fc2e9896af11c224fd3b63ab22",
+  "logs/approve-v4-offline-supply.log": "50ddd8642fd4788dc8550ccaab3cce78afbac98b1da61bd3aa02d0ff3954aad8",
+  "logs/backend-failclosed.log": "d38453a5c70763c1591aa1c97730fc08d58de2f6ec8ca64973357103b0785d42",
+  "logs/backend-local-final.log": "099b2a9eb89db43b67e4ffb358ec751678d2abacc3fbd5be115126f4ae776d0d",
+  "logs/bad-port-final.log": "563821f629e861232421fd0cc6f3e36a5bdbccf01d13ee0ebb1d95d84c17a7e9",
+  "logs/cli-offline-retry.log": "ae2ebd7fcc74633ff34ddb258a45c7b2207b2b2b79a52eac4c929cf902086f7a",
+  "logs/container-integration-final.log": "7f075917f97cfdb86f6c9cd99a16afa621cae9c515684fb818b506a005057eca",
+  "logs/delete-app.log": "475808f026bf4794093d6a05dde6d755bdabf90c35e87bcc0f3193355ecf7f22",
+  "logs/dev-toolkit-frozen-final.log": "c20b9a5cc0e95dc851c24141e8bcf0cdf7a6f744844792f6947f869460af4ca2",
+  "logs/extra-delivery.log": "4bd05dd9bbde8b267b5916843e2f7d87c796ef65a7a67d7031bf5c99e36b3555",
+  "logs/failure-mapping-local.log": "bdc4f44758d107b837c81115a3be86c0093084fc57a8d3bf75bda27d7bef500d",
+  "logs/import-material.log": "c71e8ec03842d8d37ec4fb38c836c723c8edc0cc996d2338da560cfac382356c",
+  "logs/large-material.log": "b232e7f9c8cc3999d4d49204239ec75fb84a3fb22a13244c6afc345506f6d26f",
+  "logs/manager-independence.log": "aee5ed4e75978f7bdba4e3949f8c1619e8f8027c60d82f8ba30035ed4add3ebd",
+  "logs/manager-local-final.log": "4d93549f959da23fc0ff2d32e4a033be59be732095e0e02951f04cede66beee8",
+  "logs/nginx-main-check.log": "c1d95d6441adac8d73265f026042743581500b1133052abc44ed98cf52bb6618",
+  "logs/nginx-static-check.log": "12b2d209f0eaaf9886f84ba4b0e76fbadbc4e71aa23d15c6f52b44abc14b2b84",
+  "logs/publish-v4-missing-material.log": "f53199ca6b06182ad1d0eb54d968d23d81b87bdb3cea464c6ee6712e39ce71e4",
+  "logs/restore-lifecycle.log": "3a3932f6b6964a1aba900abccacb1a5e71907f4535864f84fa384485dd133e3b",
+  "logs/sql-race-final.log": "04db1264c0dae3b19c1ec25e801b493594aded86005d3d6ca8e98166005eb5ae",
+  "logs/sql-race-tests.log": "d65a8156485e921c58430deb81e6fee066a8dab12d8e2f798b00ff5b836258c0",
+  "normalization.json": "6b1c024d57f2a19bf3420073d4af53a8824e3e78597473dfc88326430e8c43c1",
+  "offline-import-result.json": "ce7edb6c552a20c18ffae19a5941e7f93f2f246d9c50dfeaae896e5b1b3ef28f",
+  "preview-v3.json": "b68ef8a171edfeb8528c12ad898e5cf4265ff70e1b09d9449c7c5709fcdd71c9",
+  "restore-lifecycle-result.json": "5ae68278487ad9a8533f31f80b9bff55a68f2fb20155efb1acd131ad8d8a1aab",
+  "sha256-export.json": "b86a233742f4cc52e9f9d312d84075637fd6d511a5e430dd69f1dfc0c465c538"
+}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/README.md b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/README.md
new file mode 100644
index 0000000000..4986b9cb03
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/README.md
@@ -0,0 +1,15 @@
+# F086 测试镜像发行与109启动验证
+
+- 测试Tag:`v3.0.0-test-vibe`,提交`755d4456e50c015a3fa88da0c0c35ab53bbd1650`;用户已明确授权更新该测试Tag。此前失败记录保留。
+- [Runtime delivery images](https://github.com/dataelement/bisheng/actions/runs/39567381522):Success,6m25s;两架构各7镜像、原生禁网集成与catalog均通过。[原backend/frontend CI](https://github.com/dataelement/bisheng/actions/runs/39567381486):Success,7m34s。
+- 两个服务索引:`dataelement/bisheng-runtime-manager:v3.0.0-test-vibe`、`dataelement/bisheng-app-proxy:v3.0.0-test-vibe`;amd64和arm64均由原生runner构建。原生运行时/preparer镜像使用`:-`标签,目录冻结child digest。
+- 首轮因runner没有rg失败。755d4456e5仅将两处改用grep -Eqi;本地已验证“标签不存在继续、已存在拒绝、认证错误拒绝”三个分支,shell/YAML检查及2项镜像合同测试通过。
+- 109直连Docker Hub超时;使用用户给定代理在准备机下载公开amd64镜像,转存、docker load后以镜像默认CMD启动。未修改daemon代理、未重启既有业务服务。
+- 独立内网验证:两个healthz为200;proxy UID10001;manager通过socket proxy读取Engine信息并ping成功;缩小的测试proxy拒绝容器列表403;manager无签名v2控制请求403,旧v1控制请求410,平台不可达时app入口503。两服务均只读rootfs、无host端口、无docker.sock挂载。
+- 测试容器、网络、远端目录已回收;原27个业务容器完整ID一致且运行,保留镜像缓存。
+
+## 证据边界
+
+本目录不是完整Feature验收通过声明。109本轮是公开服务镜像启动/访问检查;未重新部署整个平台,未执行新BIGINT迁移、DM8/K8s或完整客户断网新装。GitHub三份artifact的存在和摘要从页面核对,未将ZIP下载/目录导入记为通过;双架构应用包集成由真实CI job执行。
+
+本次Tag不包含工作树中尚未提交的backend多租户启动及BIGINT修复;这些修复的已有证据另见live-validation.md/model-review.md。verify_services.py是通过本机SSH运行的复核脚本,临时服务密钥在运行时随机生成,不包含登录凭据。
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/app-proxy-index.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/app-proxy-index.json
new file mode 100644
index 0000000000..77e79215e1
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/app-proxy-index.json
@@ -0,0 +1,24 @@
+{
+  "schemaVersion": 2,
+  "mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
+  "manifests": [
+    {
+      "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
+      "digest": "sha256:f811f386d4ed45959eb09eaf1b4666829092b581692586c507b33947ad226713",
+      "size": 2203,
+      "platform": {
+        "architecture": "amd64",
+        "os": "linux"
+      }
+    },
+    {
+      "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
+      "digest": "sha256:be3d1ce5d66158db94bc33d82628b232d0c471eb5900737e88de72076d27d686",
+      "size": 2203,
+      "platform": {
+        "architecture": "arm64",
+        "os": "linux"
+      }
+    }
+  ]
+}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/cleanup-verification.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/cleanup-verification.json
new file mode 100644
index 0000000000..06459869f1
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/cleanup-verification.json
@@ -0,0 +1,9 @@
+{
+  "original_running_containers": 27,
+  "same_ids_still_running": true,
+  "differences": {},
+  "test_containers_remaining": [],
+  "test_directory_removed": true,
+  "cached_images_retained": true,
+  "docker_daemon_configuration_changed": false
+}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/release.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/release.json
new file mode 100644
index 0000000000..bc2ab27944
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/release.json
@@ -0,0 +1,50 @@
+{
+  "release": "v3.0.0-test-vibe",
+  "commit": "755d4456e50c015a3fa88da0c0c35ab53bbd1650",
+  "annotated_tag_object": "cada64894bef204330e4f62d51cfa732b4046b81",
+  "initial_failed_run": "https://github.com/dataelement/bisheng/actions/runs/39566080903",
+  "failure": "runner missing rg; missing image was incorrectly rejected",
+  "fix": "Use grep -Eqi in architecture builder and service-index publication; no image tag overwrite enabled",
+  "runtime_workflow": {
+    "url": "https://github.com/dataelement/bisheng/actions/runs/39567381522",
+    "conclusion": "success",
+    "duration_seconds": 385,
+    "jobs": {
+      "native_amd64": "success",
+      "native_arm64": "success",
+      "catalog": "success"
+    },
+    "native_images_per_architecture": 7,
+    "integration_scope": [
+      "offline Python/Node/static",
+      "dependency reuse",
+      "code update",
+      "SQLite helper and backup",
+      "frontend hook/build",
+      "CPython native extension"
+    ]
+  },
+  "existing_backend_frontend_workflow": {
+    "url": "https://github.com/dataelement/bisheng/actions/runs/39567381486",
+    "conclusion": "success",
+    "duration_seconds": 454
+  },
+  "artifacts_observed_in_github_ui": [
+    {
+      "name": "runtime-images-amd64",
+      "id": 12330772370,
+      "sha256": "73f9ef1f57d410cbbad47de915e9d0cceffdc93f676a7693ef69ba4c13f2c576"
+    },
+    {
+      "name": "runtime-images-arm64",
+      "id": 12331030829,
+      "sha256": "60747d38e76958086f0b94a0c5d71c0a044b4587e0522785457149a27cf30440"
+    },
+    {
+      "name": "runtime-profiles",
+      "id": 12329913744,
+      "sha256": "2a189c8692ee86e8f1ba14edd3cead04b23e50c89a8b0427743fab47152b8a6b"
+    }
+  ],
+  "evidence_boundary": "CI status and artifact metadata observed in authenticated GitHub UI. Artifact ZIPs were not downloaded or imported into a platform. Public service images were independently downloaded and run on 109. Pending backend tenant/model edits are not part of this tagged commit."
+}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/runtime-manager-index.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/runtime-manager-index.json
new file mode 100644
index 0000000000..14ca5361c8
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/runtime-manager-index.json
@@ -0,0 +1,24 @@
+{
+  "schemaVersion": 2,
+  "mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
+  "manifests": [
+    {
+      "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
+      "digest": "sha256:a5d39f04412343a527db7d9eed47acc8eb30d6af82a3a8a31abcdb27fd2769dd",
+      "size": 1996,
+      "platform": {
+        "architecture": "amd64",
+        "os": "linux"
+      }
+    },
+    {
+      "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
+      "digest": "sha256:2053e032087d6b79f63ee9146aca0351030cc05492dd7045a19444acb19ddb3a",
+      "size": 1996,
+      "platform": {
+        "architecture": "arm64",
+        "os": "linux"
+      }
+    }
+  ]
+}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/service-verification.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/service-verification.json
new file mode 100644
index 0000000000..15b9d7e424
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/service-verification.json
@@ -0,0 +1,83 @@
+{
+  "release": "v3.0.0-test-vibe",
+  "host": "192.168.106.109",
+  "checks": {
+    "manager_health": {
+      "status": 200,
+      "body": {
+        "status": "ok",
+        "version": "3.0.0"
+      }
+    },
+    "proxy_health": {
+      "status": 200,
+      "body": {
+        "status": "ok"
+      }
+    },
+    "service_access": {
+      "uid": 10001,
+      "manager_health": 200,
+      "unsigned_control": 403,
+      "legacy_control": 410,
+      "backend_unavailable_entry": 503
+    },
+    "engine_via_socket_proxy": {
+      "engine_id": "c2fa2d5d-7f72-438c-9c24-705fd91079cd",
+      "ping": true,
+      "container_listing_denied": 403
+    },
+    "e2e-f086-ci-20261021-manager": {
+      "running": true,
+      "host_ports": false,
+      "docker_socket_mounted": false,
+      "read_only_rootfs": true
+    },
+    "e2e-f086-ci-20261021-proxy": {
+      "running": true,
+      "host_ports": false,
+      "docker_socket_mounted": false,
+      "read_only_rootfs": true
+    }
+  },
+  "images": {
+    "runtime-manager": {
+      "reference": "dataelement/bisheng-runtime-manager:v3.0.0-test-vibe-amd64",
+      "image_id": "sha256:fc7e0987ecabec0b3a59b59926cad93a278a76863513b2d7d2c16f4c9a1e6ce4",
+      "architecture": "amd64",
+      "user": "",
+      "command": [
+        "python",
+        "-m",
+        "uvicorn",
+        "runtime_manager.main:app",
+        "--host",
+        "0.0.0.0",
+        "--port",
+        "8091",
+        "--workers",
+        "1",
+        "--no-access-log"
+      ]
+    },
+    "app-proxy": {
+      "reference": "dataelement/bisheng-app-proxy:v3.0.0-test-vibe-amd64",
+      "image_id": "sha256:9787b6e0ddbc9128d60b08df83d78b8b1ab78f6fea1721c1aa70a618192499fa",
+      "architecture": "amd64",
+      "user": "appproxy",
+      "command": [
+        "python",
+        "-m",
+        "uvicorn",
+        "app_proxy.main:app",
+        "--host",
+        "0.0.0.0",
+        "--port",
+        "8090",
+        "--no-access-log"
+      ]
+    }
+  },
+  "passed": true,
+  "test_containers_remaining": []
+}
\ No newline at end of file
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/sha256.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/sha256.json
new file mode 100644
index 0000000000..2527c11fe7
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/sha256.json
@@ -0,0 +1,9 @@
+{
+  "README.md": "6014af13ba36e6663873b0b19dee8924fdc3224ed7f49ffda366a3aca9c30a72",
+  "app-proxy-index.json": "0c5cbff5b4ae0e7e5ab6be8dd468549013e305e4317ef15d73b958cb2279506a",
+  "cleanup-verification.json": "da0422acf68e383dea178995fed3d71854275c8ce624ef9ea5733c9078aeea31",
+  "release.json": "a57b4b5e9589919a382ebb3d12c740e36d0ae58d7b4da7efe45d21e77bbe94ea",
+  "runtime-manager-index.json": "1e42c3a775593063838b2ba8c2e1daceeacf7849e1ebaaf21f0af4ecaa155547",
+  "service-verification.json": "1ec63f3ae5da40941792da6f01f2209077dfb158b434a1656d2a61d39f96d466",
+  "verify_services.py": "01722a2e1dfc0eed3981c8b15a268bed0474d7c7a6c93d89e777cb9382ecebd8"
+}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/verify_services.py b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/verify_services.py
new file mode 100644
index 0000000000..339edf4ca0
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-ci-release/verify_services.py
@@ -0,0 +1,141 @@
+import json
+import os
+import secrets
+import shlex
+import subprocess
+import time
+
+ROOT = '/opt/e2e-f086-ci-20261021'
+LABEL = 'e2e-f086-ci-20261021'
+NETWORK = LABEL
+RESULT = '/private/tmp/f086-ci-images-20261021/service-verification.json'
+names = []
+created_network = False
+report = {'release': 'v3.0.0-test-vibe', 'host': '192.168.106.109', 'checks': {}}
+
+
+def command(*args):
+    remote = ' '.join(shlex.quote(value) for value in args)
+    return subprocess.check_output(['ssh', '-S', '/private/tmp/f086-ci-image-109.sock', 'root@192.168.106.109', remote]).decode().strip()
+
+
+def docker(*args):
+    return command('docker', *args)
+
+
+def run(name, image, env, extra=None):
+    args = ['run', '-d', '--name', name, '--label', 'bisheng.test=' + LABEL,
+            '--network', NETWORK, '--memory', '256m', '--cpus', '0.5']
+    for key, value in env.items():
+        args += ['-e', key + '=' + value]
+    args += extra or []
+    args += [image]
+    ident = docker(*args)
+    names.append(name)
+    return ident
+
+
+def inside(name, code):
+    return json.loads(docker('exec', name, 'python', '-c', code))
+
+
+def wait(name, port):
+    deadline = time.time() + 45
+    code = ("import json,urllib.request; "
+            "r=urllib.request.urlopen('http://127.0.0.1:%s/healthz',timeout=2); "
+            "print(json.dumps({'status':r.status,'body':json.load(r)}))") % port
+    while time.time() < deadline:
+        try:
+            return inside(name, code)
+        except subprocess.CalledProcessError:
+            time.sleep(1)
+    raise RuntimeError(name + ' did not become healthy')
+
+
+try:
+    assert not docker('ps', '-a', '--filter', 'label=bisheng.test=' + LABEL, '--format', '{{.ID}}')
+    docker('network', 'create', '--internal', '--label', 'bisheng.test=' + LABEL, NETWORK)
+    created_network = True
+    manager = 'dataelement/bisheng-runtime-manager:v3.0.0-test-vibe-amd64'
+    proxy = 'dataelement/bisheng-app-proxy:v3.0.0-test-vibe-amd64'
+    report['images'] = {}
+    for short, image in [('runtime-manager', manager), ('app-proxy', proxy)]:
+        meta = json.loads(docker('image', 'inspect', image))[0]
+        report['images'][short] = {'reference': image, 'image_id': meta['Id'],
+                                  'architecture': meta['Architecture'],
+                                  'user': meta['Config']['User'], 'command': meta['Config']['Cmd']}
+        assert meta['Architecture'] == 'amd64'
+    engine_name = LABEL + '-engine'
+    run(engine_name, 'tecnativa/docker-socket-proxy:0.3.0',
+        {'INFO': '1', 'VERSION': '1', 'PING': '1', 'CONTAINERS': '0', 'POST': '0'},
+        ['--network-alias', 'engine', '-v', '/var/run/docker.sock:/var/run/docker.sock:ro'])
+    manager_name = LABEL + '-manager'
+    run(manager_name, manager, {
+        'RTM_RECONCILE_ENABLED': 'false', 'RTM_PROTOCOL_VERSION': '2',
+        'RTM_NODE_ID': LABEL, 'RTM_DOCKER_HOST': 'tcp://engine:2375',
+        'RTM_DATA_ROOT': '/app-data', 'RTM_HOST_DATA_ROOT': ROOT + '/app-data',
+        'RTM_INBOUND_KEY_ID': 'ci-inbound', 'RTM_INBOUND_SECRET': secrets.token_hex(32),
+        'RTM_PLATFORM_KEY_ID': 'ci-platform', 'RTM_PLATFORM_SECRET': secrets.token_hex(32),
+        'RTM_PLATFORM_BASE_URL': 'http://platform-unavailable:7860',
+    }, ['--network-alias', 'manager', '--read-only', '--tmpfs', '/tmp:rw,size=32m',
+        '-v', ROOT + '/app-data:/app-data'])
+    proxy_name = LABEL + '-proxy'
+    run(proxy_name, proxy, {
+        'APP_PROXY_PROTOCOL_VERSION': '2',
+        'APP_PROXY_BACKEND_BASE': 'http://platform-unavailable:7860',
+        'APP_PROXY_BACKEND_SECRET': secrets.token_hex(32),
+        'APP_PROXY_ROUTE_KEY_ID': 'ci-route', 'APP_PROXY_ROUTE_SECRET': secrets.token_hex(32),
+        'APP_PROXY_MANAGER_BASE': 'http://manager:8091',
+    }, ['--read-only', '--tmpfs', '/tmp:rw,size=16m'])
+    report['checks']['manager_health'] = wait(manager_name, 8091)
+    report['checks']['proxy_health'] = wait(proxy_name, 8090)
+    checks = inside(proxy_name, '''
+import json,urllib.request,urllib.error,os
+def status(url):
+    request=urllib.request.Request(url,headers={'Accept':'application/json'})
+    try:
+        with urllib.request.urlopen(request,timeout=10) as response:return response.status
+    except urllib.error.HTTPError as error:return error.code
+result={'uid':os.getuid(),'manager_health':status('http://manager:8091/healthz'),
+        'unsigned_control':status('http://manager:8091/v2/runtime/status'),
+        'legacy_control':status('http://manager:8091/v1/apps/demo/route'),
+        'backend_unavailable_entry':status('http://127.0.0.1:8090/apps/e2e-ci/')}
+assert result=={'uid':10001,'manager_health':200,'unsigned_control':403,
+                'legacy_control':410,'backend_unavailable_entry':503},result
+print(json.dumps(result))
+''')
+    report['checks']['service_access'] = checks
+    engine = inside(manager_name, '''
+import json,urllib.request,urllib.error
+from runtime_manager.config import get_config
+from runtime_manager.docker_backend import _RealDockerBackend
+backend=_RealDockerBackend(get_config().docker_host)
+info=backend.info()
+assert backend.ping()
+try:
+    urllib.request.urlopen('http://engine:2375/containers/json',timeout=5)
+    raise AssertionError('container listing must be denied')
+except urllib.error.HTTPError as error:
+    assert error.code==403,error.code
+print(json.dumps({'engine_id':info['ID'],'ping':True,'container_listing_denied':403}))
+''')
+    assert engine['engine_id'] == docker('info', '--format', '{{.ID}}')
+    report['checks']['engine_via_socket_proxy'] = engine
+    for name in [manager_name, proxy_name]:
+        meta = json.loads(docker('inspect', name))[0]
+        assert not meta['HostConfig']['PortBindings']
+        assert not any(item['Destination'] == '/var/run/docker.sock' for item in meta['Mounts'])
+        assert meta['HostConfig']['ReadonlyRootfs']
+        report['checks'][name] = {'running': meta['State']['Running'], 'host_ports': False,
+                                 'docker_socket_mounted': False, 'read_only_rootfs': True}
+    report['passed'] = True
+finally:
+    for name in reversed(names):
+        assert docker('inspect', '--format', '{{index .Config.Labels "bisheng.test"}}', name) == LABEL
+        docker('rm', '-f', name)
+    if created_network:
+        docker('network', 'rm', NETWORK)
+    report['test_containers_remaining'] = docker('ps', '-a', '--filter', 'label=bisheng.test=' + LABEL, '--format', '{{.ID}}').split()
+    with open(RESULT, 'w') as stream:
+        json.dump(report, stream, indent=2)
+    print(json.dumps(report, indent=2))
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/.gitignore b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/.gitignore
new file mode 100644
index 0000000000..e9b8f19ee8
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/.gitignore
@@ -0,0 +1,29 @@
+# Retain only the reviewed evidence logs from this suite.
+!/logs/
+!/logs/approval-retry.log
+!/logs/bootstrap-before.log
+!/logs/bootstrap-fixed.log
+!/logs/compile-timeout.log
+!/logs/connect-confirm.log
+!/logs/deletion-followup.log
+!/logs/install-cancel.log
+!/logs/legacy-pending-confirm.log
+!/logs/legacy-publication.log
+!/logs/live-boundaries.log
+!/logs/manager-connect.log
+!/logs/manager-existing-connections.log
+!/logs/migration-startup-failure.log
+!/logs/mt-online.log
+!/logs/multi-tenant-identities.log
+!/logs/restart-probe.log
+!/logs/snapshot-fault-confirm.log
+!/logs/timeout-confirm.log
+!/logs/upgrade-apply-repeat.log
+!/logs/upgrade-apply.log
+!/logs/upgrade-dry-run.log
+!/logs/upgrade-inventory.log
+!/logs/upgrade-rebind.log
+!/logs/upgrade-retained-attachment.log
+!/logs/upgrade-rollback-check.log
+!/logs/ws-backend-failclosed.log
+!/logs/ws-permission-revoke.log
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/README.md b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/README.md
new file mode 100644
index 0000000000..cc08a21266
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/README.md
@@ -0,0 +1,11 @@
+# 2026-10-21 Docker follow-up evidence
+
+Checkpoint: `5131b81194a5fa390593e58d822bcbde52b6ca64` plus the six uncommitted bootstrap fix/test files hashed in `quality-and-startup-observation.json`.
+
+Only whitelisted, sanitized outputs are retained. No environment file, identity/password file, desired-state backup, Docker Config.Env, HMAC secret or runtime credential is included. `sha256-export.json` hashes every evidence file except itself. Local temporary drivers used the real public APIs; SQL fault injection was restricted to the disposable `e2e_f086_platform` database and the listed test apps. CONNECT policy revocation used a scoped SQL fault; WebSocket visitor revocation used the formal permission Grant API.
+
+The first fresh-start log was inspected before service replacement; its observed values are explicitly recorded as an observation. Backend/worker mounted the current checkout source. Other v2 service/runtime images reused the already verified cached native amd64 builds. The legacy manager image used source from `4aa9ce69b2830b81162fe631b9c801057c40ee8b`, with its original MinIO dependencies supplied for the isolated v1 migration test. No public image was released.
+
+`upgrade-first-apply.json` is the first import; `upgrade-apply.json` is the idempotent repeat. Rollback was tested before import for the legacy application, and does not claim that an old manager can manage bundle deployments. Historical version comparisons include frozen payloads and terminal state, excluding mutable update_time. The retained attachment was read with SDK 0.1.1 through the new backend endpoint after rebind.
+
+`docker-cleanup.json` confirms all dedicated services/networks/data/credentials were removed and all 26 original business containers retained identical full IDs. Cached images were preserved. See ../../live-validation.md for remaining gates; this directory does not establish full feature delivery.
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/cancel-timeout-results.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/cancel-timeout-results.json
new file mode 100644
index 0000000000..7e0864d147
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/cancel-timeout-results.json
@@ -0,0 +1,108 @@
+[
+  {
+    "scenario": "cancel",
+    "deployment": {
+      "deployment_id": "c9bb8a38d4504016a37dbfc2311337cb",
+      "app_id": "0e7c8fa1ad01451bb4ac7ffff2b79ece",
+      "version_id": "d4b33600585b4f5583e60bbed1741c82",
+      "version_no": null,
+      "stage": "precheck_probe",
+      "status": "failed",
+      "failure": {
+        "code": 16124,
+        "hints": [],
+        "stage": "precheck_probe",
+        "details": {
+          "reason": "operation_cancelled",
+          "maintenance": false,
+          "operation_id": "466e303908ad4ec6a26eb9d1ba840a40",
+          "runtime_stage": "starting",
+          "data_may_have_changed": false
+        },
+        "message": "\u5e94\u7528\u6267\u884c\u5931\u8d25"
+      },
+      "approval": null,
+      "app_state": "draft",
+      "entry_url": "http://nginx:3001/apps/e2e-f086-cancel",
+      "scan_result": {
+        "hits": [],
+        "blocked": false,
+        "skipped": [],
+        "files_scanned": 3,
+        "files_skipped": 0
+      }
+    },
+    "operation": {
+      "id": "466e303908ad4ec6a26eb9d1ba840a40",
+      "kind": "probe",
+      "stage": "cancelled",
+      "status": "cancelled",
+      "cleanup_status": "complete",
+      "failure": {
+        "reason": "operation_cancelled",
+        "maintenance": false,
+        "failed_stage": "starting",
+        "diagnostic_tail": "",
+        "data_may_have_changed": false
+      },
+      "version_id": "d4b33600585b4f5583e60bbed1741c82",
+      "generation": 1
+    },
+    "reservation_states": [
+      "released",
+      "released"
+    ]
+  },
+  {
+    "scenario": "timeout",
+    "deployment": {
+      "deployment_id": "552cef0a11074e2692be1f14117d490b",
+      "app_id": "2a3a9b211e684496a719987a48c88c35",
+      "version_id": "3f35053445fb4e14a9ce84da09d7db07",
+      "version_no": null,
+      "stage": "precheck_probe",
+      "status": "failed",
+      "failure": {
+        "code": 16124,
+        "hints": [],
+        "stage": "precheck_probe",
+        "details": {
+          "reason": "operation_timeout",
+          "operation_id": "d1a45643ffe2486ca33539d0414e8a68",
+          "runtime_stage": "starting"
+        },
+        "message": "\u5e94\u7528\u64cd\u4f5c\u8d85\u65f6, \u6b63\u5728\u786e\u8ba4\u6e05\u7406\u72b6\u6001"
+      },
+      "approval": null,
+      "app_state": "draft",
+      "entry_url": "http://nginx:3001/apps/e2e-f086-timeout",
+      "scan_result": {
+        "hits": [],
+        "blocked": false,
+        "skipped": [],
+        "files_scanned": 3,
+        "files_skipped": 0
+      }
+    },
+    "operation": {
+      "id": "d1a45643ffe2486ca33539d0414e8a68",
+      "kind": "probe",
+      "stage": "timed_out",
+      "status": "timed_out",
+      "cleanup_status": "complete",
+      "failure": {
+        "reason": "operation_timeout",
+        "maintenance": false,
+        "failed_stage": "starting",
+        "diagnostic_tail": "",
+        "data_may_have_changed": false
+      },
+      "version_id": "3f35053445fb4e14a9ce84da09d7db07",
+      "generation": 1
+    },
+    "reservation_states": [
+      "released",
+      "released"
+    ]
+  }
+]
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/compile-timeout-result.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/compile-timeout-result.json
new file mode 100644
index 0000000000..6be1dab70a
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/compile-timeout-result.json
@@ -0,0 +1,55 @@
+{
+  "operation": {
+    "id": "4688564f2fdf44d3926b1fe22c8edea3",
+    "kind": "prepare",
+    "stage": "timed_out",
+    "status": "timed_out",
+    "cleanup_status": "complete",
+    "failure": {
+      "reason": "operation_timeout",
+      "maintenance": false,
+      "failed_stage": "preparing",
+      "diagnostic_tail": "",
+      "data_may_have_changed": false
+    },
+    "version_id": "0f53778bac9a4dd6b3c4dbdec3adbf76",
+    "generation": 1
+  },
+  "deployment": {
+    "deployment_id": "86b7d788bfd4419c9fe331203d4f6eef",
+    "app_id": "21d7555bc22c48b29b12be8d63d07284",
+    "version_id": "0f53778bac9a4dd6b3c4dbdec3adbf76",
+    "version_no": null,
+    "stage": "precheck_build",
+    "status": "failed",
+    "failure": {
+      "code": 16227,
+      "hints": [],
+      "stage": "precheck_build",
+      "details": {
+        "reason": "operation_timeout",
+        "operation_id": "4688564f2fdf44d3926b1fe22c8edea3",
+        "runtime_stage": "preparing"
+      },
+      "message": "\u5e94\u7528\u4f9d\u8d56\u6216\u4ea4\u4ed8\u51c6\u5907\u5931\u8d25"
+    },
+    "approval": null,
+    "app_state": "draft",
+    "entry_url": "http://nginx:3001/apps/e2e-f086-compile-timeout",
+    "scan_result": {
+      "hits": [],
+      "blocked": false,
+      "skipped": [],
+      "files_scanned": 4,
+      "files_skipped": 0
+    }
+  },
+  "actual_phase": "compile",
+  "container": {
+    "operation_id": "4688564f2fdf44d3926b1fe22c8edea3",
+    "container_id": "fd3079c24dba3e9e251898278519a5200825bfadc2b9a61e840c992282cc4108",
+    "network_mode": "none",
+    "read_only": true
+  },
+  "reservations_released": true
+}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/connect-policy-revoke.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/connect-policy-revoke.json
new file mode 100644
index 0000000000..1a76a02fca
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/connect-policy-revoke.json
@@ -0,0 +1,5 @@
+{
+  "new_connect_status": 407,
+  "upstream_remained_running": true,
+  "closed_after_seconds": 1.266052007675171
+}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/deletion-followup.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/deletion-followup.json
new file mode 100644
index 0000000000..b3e5b14b74
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/deletion-followup.json
@@ -0,0 +1,44 @@
+{
+  "applications": [
+    {
+      "app_id": "0e7c8fa1ad01451bb4ac7ffff2b79ece",
+      "name": "e2e-f086-cancel",
+      "delete_requested": true
+    },
+    {
+      "app_id": "21d7555bc22c48b29b12be8d63d07284",
+      "name": "e2e-f086-compile-timeout",
+      "delete_requested": true
+    },
+    {
+      "app_id": "2a3a9b211e684496a719987a48c88c35",
+      "name": "e2e-f086-timeout",
+      "delete_requested": true
+    },
+    {
+      "app_id": "749df2ba5e7c46689acd28f13fc62304",
+      "name": "e2e-f086-restart",
+      "delete_requested": true
+    },
+    {
+      "app_id": "fbedd729679c451c8a178884c039f19e",
+      "name": "e2e-f086-install-cancel",
+      "delete_requested": true
+    },
+    {
+      "app_id": "7dcca9caca5b4a2b8d50405717ddcc5c",
+      "name": "e2e-f086-legacy-upgrade",
+      "delete_requested": true
+    },
+    {
+      "app_id": "8411f09dd5f94b249d272492f8536812",
+      "name": "e2e-f086-mt",
+      "delete_requested": true
+    }
+  ],
+  "all_deleted": true,
+  "reservation_count": 23,
+  "all_reservations_released": true,
+  "snapshot_count": 3,
+  "all_snapshots_purged": true
+}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/docker-cleanup.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/docker-cleanup.json
new file mode 100644
index 0000000000..a63cbae9a5
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/docker-cleanup.json
@@ -0,0 +1,9 @@
+{
+  "original_containers": 26,
+  "differences": {},
+  "cached_images_retained": true,
+  "test_containers_remaining": 0,
+  "credential_and_test_data_directory_removed": true,
+  "test_networks_removed": true,
+  "same_ids_still_running": true
+}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/install-cancel-result.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/install-cancel-result.json
new file mode 100644
index 0000000000..ac3e962c16
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/install-cancel-result.json
@@ -0,0 +1,57 @@
+{
+  "operation": {
+    "id": "ee391343ad4b47dca3708bd07a2e4c9d",
+    "kind": "prepare",
+    "stage": "cancelled",
+    "status": "cancelled",
+    "cleanup_status": "complete",
+    "failure": {
+      "reason": "operation_cancelled",
+      "maintenance": false,
+      "failed_stage": "preparing",
+      "diagnostic_tail": "",
+      "data_may_have_changed": false
+    },
+    "version_id": "bc88e5784dd1463a9c766073fde7c20d",
+    "generation": 1
+  },
+  "deployment": {
+    "deployment_id": "24667787a8014c36a0c887da9c980104",
+    "app_id": "fbedd729679c451c8a178884c039f19e",
+    "version_id": "bc88e5784dd1463a9c766073fde7c20d",
+    "version_no": null,
+    "stage": "precheck_build",
+    "status": "failed",
+    "failure": {
+      "code": 16227,
+      "hints": [],
+      "stage": "precheck_build",
+      "details": {
+        "reason": "operation_cancelled",
+        "maintenance": false,
+        "operation_id": "ee391343ad4b47dca3708bd07a2e4c9d",
+        "runtime_stage": "preparing",
+        "data_may_have_changed": false
+      },
+      "message": "\u5e94\u7528\u4f9d\u8d56\u6216\u4ea4\u4ed8\u51c6\u5907\u5931\u8d25"
+    },
+    "approval": null,
+    "app_state": "draft",
+    "entry_url": "http://nginx:3001/apps/e2e-f086-install-cancel",
+    "scan_result": {
+      "hits": [],
+      "blocked": false,
+      "skipped": [],
+      "files_scanned": 4,
+      "files_skipped": 0
+    }
+  },
+  "actual_phase": "resolve",
+  "container": {
+    "operation_id": "ee391343ad4b47dca3708bd07a2e4c9d",
+    "container_id": "3628dc19bf1cc9d2f28bf319fb0fa7e2fdfc9a1ee7a7d5a1cb085a56b32c4b3b",
+    "network_mode": "none",
+    "read_only": true
+  },
+  "reservations_released": true
+}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/legacy-container-before.txt b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/legacy-container-before.txt
new file mode 100644
index 0000000000..44606c822d
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/legacy-container-before.txt
@@ -0,0 +1 @@
+e74115e12aab
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/legacy-manager-image.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/legacy-manager-image.json
new file mode 100644
index 0000000000..2f8a125819
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/legacy-manager-image.json
@@ -0,0 +1 @@
+{"legacy_manager_image_id":"sha256:eecbbe068a8c2c433b293f81c6b06e3cac4a91de6c7fc3be227566ab0f8c063c","os":"linux","architecture":"amd64"}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/approval-retry.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/approval-retry.log
new file mode 100644
index 0000000000..906be2cada
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/approval-retry.log
@@ -0,0 +1,2 @@
+bash: warning: setlocale: LC_ALL: cannot change locale (C.UTF-8)
+Child approval exceptions [{"tenant_id": 2, "status": "open", "resolved_action": null, "resolved_at": null, "update_time": "2026-10-21T03:52:14", "instance_id": 1, "exception_type": "approver_empty", "detail": {"node_code": "app_publish_admin", "node_mode": "or", "node_name": "\u5e94\u7528\u53d1\u5e03\u5ba1\u6279", "node_order": 1, "business_key": "072aa67d10fd4823a588dcbe97df50f9", "scenario_code": "app_publish_request", "current_node_name": "\u5e94\u7528\u53d1\u5e03\u5ba1\u6279"}, "resolved_by_user_id": null, "create_time": "2026-10-21T03:52:14", "id": 1, "business_name": "e2e-f086-mt \u00b7 \u9996\u53d1\u53d1\u5e03\u5ba1\u6279", "scenario_code": "app_publish_request", "scenario_name": "\u5e94\u7528\u53d1\u5e03", "applicant_user_name": "e2e-f086-childowner"}]
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/bootstrap-before.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/bootstrap-before.log
new file mode 100644
index 0000000000..c6413dff34
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/bootstrap-before.log
@@ -0,0 +1,201 @@
+...FF....F                                                               [100%]
+=================================== FAILURES ===================================
+_____ test_bootstrap_binds_each_verified_tenant_and_restores_caller[False] _____
+
+monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x135795790>
+fail = False
+
+    @pytest.mark.parametrize("fail", [False, True])
+    async def test_bootstrap_binds_each_verified_tenant_and_restores_caller(monkeypatch, fail):
+        from dataclasses import replace
+
+        from bisheng.core.context import tenant
+
+        resources = (_spec("6"), replace(_spec("16"), tenant_id=2))
+        missing = tuple(SystemOwnedResourceState(resource=one, state="MISSING") for one in resources)
+        current = tuple(SystemOwnedResourceState(resource=one, state="CURRENT") for one in resources)
+        monkeypatch.setattr(reconcile_module, "inspect_system_owned_resources", AsyncMock(side_effect=[missing, current]))
+        initial = [(tenant.current_tenant_id, None), (tenant.visible_tenant_ids, frozenset({99})),
+                   (tenant._admin_scope_tenant_id, 99), (tenant._bypass_tenant_filter, False)]
+        tokens = [(variable, variable.set(value)) for variable, value in initial]
+        observed = []
+
+        async def project(**kwargs):
+            target = kwargs["target"]
+            assert tenant.get_current_tenant_id() == target.tenant_id
+            assert tenant.get_visible_tenant_ids() == frozenset({target.tenant_id})
+            assert not tenant.is_tenant_filter_bypassed()
+            observed.append(target.tenant_id)
+            if fail:
+                raise RuntimeError("projection unavailable")
+
+        try:
+            runtime = AsyncMock()
+            runtime.authorize_system_owned.side_effect = project
+            if fail:
+                with pytest.raises(RuntimeError, match="projection unavailable"):
+                    await reconcile_system_owned_resources(runtime, resources, apply=True, operator_id=1)
+            else:
+>               await reconcile_system_owned_resources(runtime, resources, apply=True, operator_id=1)
+
+test/permission/test_f048_system_resource_reconcile.py:149:
+_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
+bisheng/permission/application/system_resource_reconcile.py:150: in reconcile_system_owned_resources
+    await runtime.authorize_system_owned(
+../../../../../../.local/share/uv/python/cpython-3.11.13-macos-aarch64-none/lib/python3.11/unittest/mock.py:2251: in _execute_mock_call
+    result = await effect(*args, **kwargs)
+             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
+
+kwargs = {'action_codes': ('use', 'visible'), 'actor': PermissionActor(subject_type='user', subject_id=1, tenant_id=1, super_ad...resource_type='tool', resource_id='6', resource_version=0, parent_type=None, parent_id=None, context_version='tool-6')}
+target = VerifiedPermissionTarget(tenant_id=1, resource_type='tool', resource_id='6', resource_version=0, parent_type=None, parent_id=None, context_version='tool-6')
+@py_assert1 = , @py_assert3 = 99
+@py_assert7 = 1, @py_assert5 = False
+@py_format9 = "99\n{99 = \n{ =        assert tenant.get_current_tenant_id() == target.tenant_id
+E       AssertionError: assert 99 == 1
+E        +  where 99 = ()
+E        +    where  = .get_current_tenant_id
+E        +  and   1 = VerifiedPermissionTarget(tenant_id=1, resource_type='tool', resource_id='6', resource_version=0, parent_type=None, parent_id=None, context_version='tool-6').tenant_id
+
+test/permission/test_f048_system_resource_reconcile.py:135: AssertionError
+_____ test_bootstrap_binds_each_verified_tenant_and_restores_caller[True] ______
+
+monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x135988b90>
+fail = True
+
+    @pytest.mark.parametrize("fail", [False, True])
+    async def test_bootstrap_binds_each_verified_tenant_and_restores_caller(monkeypatch, fail):
+        from dataclasses import replace
+
+        from bisheng.core.context import tenant
+
+        resources = (_spec("6"), replace(_spec("16"), tenant_id=2))
+        missing = tuple(SystemOwnedResourceState(resource=one, state="MISSING") for one in resources)
+        current = tuple(SystemOwnedResourceState(resource=one, state="CURRENT") for one in resources)
+        monkeypatch.setattr(reconcile_module, "inspect_system_owned_resources", AsyncMock(side_effect=[missing, current]))
+        initial = [(tenant.current_tenant_id, None), (tenant.visible_tenant_ids, frozenset({99})),
+                   (tenant._admin_scope_tenant_id, 99), (tenant._bypass_tenant_filter, False)]
+        tokens = [(variable, variable.set(value)) for variable, value in initial]
+        observed = []
+
+        async def project(**kwargs):
+            target = kwargs["target"]
+            assert tenant.get_current_tenant_id() == target.tenant_id
+            assert tenant.get_visible_tenant_ids() == frozenset({target.tenant_id})
+            assert not tenant.is_tenant_filter_bypassed()
+            observed.append(target.tenant_id)
+            if fail:
+                raise RuntimeError("projection unavailable")
+
+        try:
+            runtime = AsyncMock()
+            runtime.authorize_system_owned.side_effect = project
+            if fail:
+                with pytest.raises(RuntimeError, match="projection unavailable"):
+>                   await reconcile_system_owned_resources(runtime, resources, apply=True, operator_id=1)
+
+test/permission/test_f048_system_resource_reconcile.py:147:
+_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
+bisheng/permission/application/system_resource_reconcile.py:150: in reconcile_system_owned_resources
+    await runtime.authorize_system_owned(
+../../../../../../.local/share/uv/python/cpython-3.11.13-macos-aarch64-none/lib/python3.11/unittest/mock.py:2251: in _execute_mock_call
+    result = await effect(*args, **kwargs)
+             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
+
+kwargs = {'action_codes': ('use', 'visible'), 'actor': PermissionActor(subject_type='user', subject_id=1, tenant_id=1, super_ad...resource_type='tool', resource_id='6', resource_version=0, parent_type=None, parent_id=None, context_version='tool-6')}
+target = VerifiedPermissionTarget(tenant_id=1, resource_type='tool', resource_id='6', resource_version=0, parent_type=None, parent_id=None, context_version='tool-6')
+@py_assert1 = , @py_assert3 = 99
+@py_assert7 = 1, @py_assert5 = False
+@py_format9 = "99\n{99 = \n{ =        assert tenant.get_current_tenant_id() == target.tenant_id
+E       AssertionError: assert 99 == 1
+E        +  where 99 = ()
+E        +    where  = .get_current_tenant_id
+E        +  and   1 = VerifiedPermissionTarget(tenant_id=1, resource_type='tool', resource_id='6', resource_version=0, parent_type=None, parent_id=None, context_version='tool-6').tenant_id
+
+test/permission/test_f048_system_resource_reconcile.py:135: AssertionError
+__ test_first_admin_dashboard_projection_has_a_tenant_without_request_context __
+
+monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x135abe550>
+
+    async def test_first_admin_dashboard_projection_has_a_tenant_without_request_context(monkeypatch):
+        from bisheng.core.context import tenant
+
+        dashboards = (_dashboard(10),)
+        current_modes = {"10": SimpleNamespace(resource_id="10", projection_state="CURRENT")}
+        monkeypatch.setattr(bootstrap, "_load_preset_dashboards_and_modes", AsyncMock(side_effect=[(dashboards, {}), (dashboards, current_modes)]))
+        monkeypatch.setattr(bootstrap, "_owner_is_ready", AsyncMock(return_value=True))
+        marker = SimpleNamespace(wait_until_ready=AsyncMock())
+        facade = SimpleNamespace(authorize_created=AsyncMock())
+
+        async def project(**kwargs):
+            assert tenant.get_current_tenant_id() == kwargs["target"].tenant_id == 1
+            assert tenant.get_visible_tenant_ids() == frozenset({1})
+            assert not tenant.is_tenant_filter_bypassed()
+
+        facade.authorize_created.side_effect = project
+        monkeypatch.setattr(bootstrap, "get_f048_process_runtime", AsyncMock(return_value=SimpleNamespace(components=SimpleNamespace(marker=marker, facade=facade))))
+        token = tenant.current_tenant_id.set(None)
+        try:
+>           assert (await bootstrap.reconcile_preset_dashboard_permissions()).current_count == 1
+                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+test/permission/test_f048_preset_dashboard_bootstrap.py:125:
+_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
+bisheng/api/services/f048_preset_dashboard_bootstrap.py:131: in reconcile_preset_dashboard_permissions
+    await components.facade.authorize_created(
+../../../../../../.local/share/uv/python/cpython-3.11.13-macos-aarch64-none/lib/python3.11/unittest/mock.py:2251: in _execute_mock_call
+    result = await effect(*args, **kwargs)
+             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
+
+kwargs = {'actor': PermissionActor(subject_type='user', subject_id=1, tenant_id=1, super_admin=True, tenant_admin_tenant_ids=fr...scope='all_visible'), 'idempotency_key': 'f048:preset-dashboard-owner:10:1', 'mode': 'CUSTOM', 'owner_user_id': 1, ...}
+@py_assert1 = 
+@py_assert3 = None
+@py_assert7 = VerifiedPermissionTarget(tenant_id=1, resource_type='dashboard', resource_id='10', resource_version=0, parent_type=None, parent_id=None, context_version='c9988e308a56d06007bcdc9e9a94146711688d7b8ff88786d031e8333845e0f5')
+@py_assert9 = 1, @py_assert5 = False, @py_assert11 = 1, @py_assert6 = True
+
+    async def project(**kwargs):
+>       assert tenant.get_current_tenant_id() == kwargs["target"].tenant_id == 1
+E       AssertionError: assert None == 1
+E        +  where None = ()
+E        +    where  = .get_current_tenant_id
+E        +  and   1 = VerifiedPermissionTarget(tenant_id=1, resource_type='dashboard', resource_id='10', resource_version=0, parent_type=None, parent_id=None, context_version='c9988e308a56d06007bcdc9e9a94146711688d7b8ff88786d031e8333845e0f5').tenant_id
+
+test/permission/test_f048_preset_dashboard_bootstrap.py:117: AssertionError
+=============================== warnings summary ===============================
+bisheng/core/ai/__init__.py:2
+  /Users/zhangguoqing/.codex/worktrees/vibe-runtime-delivery/bisheng/src/backend/bisheng/core/ai/__init__.py:2: DeprecationWarning: `langchain-community` is being sunset and is no longer actively maintained. See https://github.com/langchain-ai/langchain-community/issues/674 for details and migration guidance toward standalone integration packages.
+    from langchain_community.chat_models import ChatTongyi, ChatZhipuAI, MoonshotChat
+
+:241
+:241
+  :241: DeprecationWarning: builtin type SwigPyPacked has no __module__ attribute
+
+:241
+:241
+  :241: DeprecationWarning: builtin type SwigPyObject has no __module__ attribute
+
+:241
+  :241: DeprecationWarning: builtin type swigvarlink has no __module__ attribute
+
+../../../../../../works/bisheng/src/backend/.venv/lib/python3.11/site-packages/jieba/_compat.py:18
+  /Users/zhangguoqing/works/bisheng/src/backend/.venv/lib/python3.11/site-packages/jieba/_compat.py:18: UserWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html. The pkg_resources package is slated for removal as early as 2025-11-30. Refrain from using this package or pin to Setuptools<81.
+    import pkg_resources
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+=========================== short test summary info ============================
+FAILED test/permission/test_f048_system_resource_reconcile.py::test_bootstrap_binds_each_verified_tenant_and_restores_caller[False]
+FAILED test/permission/test_f048_system_resource_reconcile.py::test_bootstrap_binds_each_verified_tenant_and_restores_caller[True]
+FAILED test/permission/test_f048_preset_dashboard_bootstrap.py::test_first_admin_dashboard_projection_has_a_tenant_without_request_context
+3 failed, 7 passed, 7 warnings in 0.44s
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/bootstrap-fixed.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/bootstrap-fixed.log
new file mode 100644
index 0000000000..2d24d15a15
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/bootstrap-fixed.log
@@ -0,0 +1,32 @@
+........................................................                 [100%]
+=============================== warnings summary ===============================
+bisheng/core/ai/__init__.py:2
+  /Users/zhangguoqing/.codex/worktrees/vibe-runtime-delivery/bisheng/src/backend/bisheng/core/ai/__init__.py:2: DeprecationWarning: `langchain-community` is being sunset and is no longer actively maintained. See https://github.com/langchain-ai/langchain-community/issues/674 for details and migration guidance toward standalone integration packages.
+    from langchain_community.chat_models import ChatTongyi, ChatZhipuAI, MoonshotChat
+
+:241
+:241
+  :241: DeprecationWarning: builtin type SwigPyPacked has no __module__ attribute
+
+:241
+:241
+  :241: DeprecationWarning: builtin type SwigPyObject has no __module__ attribute
+
+:241
+  :241: DeprecationWarning: builtin type swigvarlink has no __module__ attribute
+
+../../../../../../works/bisheng/src/backend/.venv/lib/python3.11/site-packages/jieba/_compat.py:18
+  /Users/zhangguoqing/works/bisheng/src/backend/.venv/lib/python3.11/site-packages/jieba/_compat.py:18: UserWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html. The pkg_resources package is slated for removal as early as 2025-11-30. Refrain from using this package or pin to Setuptools<81.
+    import pkg_resources
+
+../../../../../../works/bisheng/src/backend/.venv/lib/python3.11/site-packages/_pytest/cacheprovider.py:475
+  /Users/zhangguoqing/works/bisheng/src/backend/.venv/lib/python3.11/site-packages/_pytest/cacheprovider.py:475: PytestCacheWarning: cache could not write path /Users/zhangguoqing/.codex/worktrees/vibe-runtime-delivery/bisheng/src/backend/.pytest_cache/v/cache/nodeids: [Errno 1] Operation not permitted: '/Users/zhangguoqing/.codex/worktrees/vibe-runtime-delivery/bisheng/src/backend/.pytest_cache/v/cache/nodeids'
+    config.cache.set("cache/nodeids", sorted(self.cached_nodeids))
+
+../../../../../../works/bisheng/src/backend/.venv/lib/python3.11/site-packages/_pytest/cacheprovider.py:429
+  /Users/zhangguoqing/works/bisheng/src/backend/.venv/lib/python3.11/site-packages/_pytest/cacheprovider.py:429: PytestCacheWarning: cache could not write path /Users/zhangguoqing/.codex/worktrees/vibe-runtime-delivery/bisheng/src/backend/.pytest_cache/v/cache/lastfailed: [Errno 1] Operation not permitted: '/Users/zhangguoqing/.codex/worktrees/vibe-runtime-delivery/bisheng/src/backend/.pytest_cache/v/cache/lastfailed'
+    config.cache.set("cache/lastfailed", self.lastfailed)
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+56 passed, 9 warnings in 0.33s
+sys:1: DeprecationWarning: builtin type swigvarlink has no __module__ attribute
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/compile-timeout.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/compile-timeout.log
new file mode 100644
index 0000000000..ceefb7fdec
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/compile-timeout.log
@@ -0,0 +1,133 @@
+bash: warning: setlocale: LC_ALL: cannot change locale (C.UTF-8)
+2026-10-21 04:22:01.648 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading database_url from /run/f086-config.yaml
+2026-10-21 04:22:01.648 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading redis_url from /run/f086-config.yaml
+2026-10-21 04:22:01.648 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading celery_redis_url from /run/f086-config.yaml
+2026-10-21 04:22:01.648 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading multi_tenant from /run/f086-config.yaml
+2026-10-21 04:22:01.648 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading open_platform from /run/f086-config.yaml
+2026-10-21 04:22:01.648 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading openfga from /run/f086-config.yaml
+2026-10-21 04:22:01.649 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading app_runtime from /run/f086-config.yaml
+2026-10-21 04:22:01.649 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading object_storage from /run/f086-config.yaml
+2026-10-21 04:22:01.653 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'database'
+2026-10-21 04:22:01.653 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'database' with dependencies: []
+2026-10-21 04:22:01.653 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'redis'
+2026-10-21 04:22:01.654 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'redis' with dependencies: []
+2026-10-21 04:22:02.108 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'minio'
+2026-10-21 04:22:02.109 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'minio' with dependencies: []
+2026-10-21 04:22:02.109 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'elasticsearch'
+2026-10-21 04:22:02.109 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'elasticsearch' with dependencies: []
+2026-10-21 04:22:02.109 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'statistics_elasticsearch'
+2026-10-21 04:22:02.109 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'statistics_elasticsearch' with dependencies: []
+2026-10-21 04:22:02.111 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'http_client'
+2026-10-21 04:22:02.112 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'http_client' with dependencies: []
+2026-10-21 04:22:02.114 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'prompts'
+2026-10-21 04:22:02.114 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'prompts' with dependencies: []
+2026-10-21 04:22:02.120 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'openfga'
+2026-10-21 04:22:02.120 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'openfga' with dependencies: []
+2026-10-21 04:22:02.120 | DEBUG    | bisheng.core.context.manager:_register_default_contexts:123 - Default contexts registered
+2026-10-21 04:22:02.120 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'database' (attempt 1/3)
+None of PyTorch, TensorFlow >= 2.0, or Flax have been found. Models won't be available and only tokenizers, configuration and file/data utilities can be used.
+2026-10-21 04:22:08.713 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'database' initialized successfully
+2026-10-21 04:22:08.713 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:08.713 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'redis' (attempt 1/3)
+2026-10-21 04:22:08.714 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'redis' initialized successfully
+2026-10-21 04:22:08.714 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'redis'
+2026-10-21 04:22:08.714 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'minio' (attempt 1/3)
+2026-10-21 04:22:08.733 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'minio' initialized successfully
+2026-10-21 04:22:08.733 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'minio'
+2026-10-21 04:22:08.733 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'elasticsearch' (attempt 1/3)
+2026-10-21 04:22:08.734 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'elasticsearch' initialized successfully
+2026-10-21 04:22:08.734 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'elasticsearch'
+2026-10-21 04:22:08.734 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'statistics_elasticsearch' (attempt 1/3)
+2026-10-21 04:22:08.734 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'statistics_elasticsearch' initialized successfully
+2026-10-21 04:22:08.734 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'statistics_elasticsearch'
+2026-10-21 04:22:08.734 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'http_client' (attempt 1/3)
+2026-10-21 04:22:08.734 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'http_client' initialized successfully
+2026-10-21 04:22:08.734 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'http_client'
+2026-10-21 04:22:08.734 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'prompts' (attempt 1/3)
+2026-10-21 04:22:08.744 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'prompts' initialized successfully
+2026-10-21 04:22:08.744 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'prompts'
+2026-10-21 04:22:08.744 | INFO     | bisheng.core.context.manager:initialize:57 - Application context initialized successfully
+Submitted node fault compile-timeout 21d7555bc22c48b29b12be8d63d07284
+2026-10-21 04:22:13.281 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:13.475 | INFO     | bisheng.common.services.metric_log:emit_metric:94 - BS_METRIC domain=db_pool engine=async checked_out=1 idle=0 size=100 capacity=120 at_capacity=0
+Publish 86b7d788bfd4419c9fe331203d4f6eef precheck_build running
+Publish 86b7d788bfd4419c9fe331203d4f6eef precheck_build failed
+2026-10-21 04:22:14.556 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:14.766 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:14.970 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:15.175 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:15.380 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:15.584 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:15.789 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:15.993 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:16.197 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:16.401 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:16.606 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:16.810 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:17.014 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:17.219 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:17.423 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:17.627 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:17.832 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:18.036 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:18.240 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:18.444 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:18.649 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:18.853 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:19.057 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:19.262 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:19.467 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:19.671 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:19.877 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:20.082 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:20.287 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:20.492 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:20.697 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:20.901 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:21.106 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:21.311 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:21.515 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:21.720 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:21.924 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:22.130 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:22.335 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:22.539 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:22.744 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:22.949 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:23.154 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:23.358 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:23.564 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:23.567 | INFO     | bisheng.common.services.metric_log:emit_metric:94 - BS_METRIC domain=db_query_agg window_s=10.092 count=47 sum_ms=53.212 le_5=47 le_10=47 le_25=47 le_50=47 le_100=47 le_250=47 le_500=47 le_1000=47 le_inf=47
+2026-10-21 04:22:23.567 | INFO     | bisheng.common.services.metric_log:emit_metric:94 - BS_METRIC domain=db_pool engine=async checked_out=1 idle=0 size=100 capacity=120 at_capacity=0
+2026-10-21 04:22:23.769 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:23.974 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:24.179 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:24.383 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:24.588 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:22:24.592 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+PASS compile-timeout real compile interrupted, no approval, cleanup complete, reservations released
+2026-10-21 04:22:24.598 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'openfga'
+2026-10-21 04:22:24.598 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'prompts'
+2026-10-21 04:22:24.598 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'prompts' closed successfully
+2026-10-21 04:22:24.598 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'prompts'
+2026-10-21 04:22:24.598 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'http_client'
+2026-10-21 04:22:24.598 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'http_client' closed successfully
+2026-10-21 04:22:24.598 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'http_client'
+2026-10-21 04:22:24.598 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'statistics_elasticsearch'
+2026-10-21 04:22:24.598 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'statistics_elasticsearch' closed successfully
+2026-10-21 04:22:24.598 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'statistics_elasticsearch'
+2026-10-21 04:22:24.598 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'elasticsearch'
+2026-10-21 04:22:24.598 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'elasticsearch' closed successfully
+2026-10-21 04:22:24.598 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'elasticsearch'
+2026-10-21 04:22:24.599 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'minio'
+Unclosed client session
+client_session: 
+2026-10-21 04:22:24.600 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'minio' closed successfully
+2026-10-21 04:22:24.601 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'minio'
+2026-10-21 04:22:24.601 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'redis'
+2026-10-21 04:22:24.601 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'redis' closed successfully
+2026-10-21 04:22:24.601 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'redis'
+2026-10-21 04:22:24.601 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'database'
+2026-10-21 04:22:24.602 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'database' closed successfully
+2026-10-21 04:22:24.602 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'database'
+2026-10-21 04:22:24.602 | INFO     | bisheng.core.context.manager:async_close:286 - Application context closed successfully
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/connect-confirm.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/connect-confirm.log
new file mode 100644
index 0000000000..13968c975f
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/connect-confirm.log
@@ -0,0 +1,77 @@
+bash: warning: setlocale: LC_ALL: cannot change locale (C.UTF-8)
+2026-10-21 04:07:44.379 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading database_url from /run/f086-config.yaml
+2026-10-21 04:07:44.380 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading redis_url from /run/f086-config.yaml
+2026-10-21 04:07:44.380 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading celery_redis_url from /run/f086-config.yaml
+2026-10-21 04:07:44.380 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading multi_tenant from /run/f086-config.yaml
+2026-10-21 04:07:44.380 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading open_platform from /run/f086-config.yaml
+2026-10-21 04:07:44.380 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading openfga from /run/f086-config.yaml
+2026-10-21 04:07:44.380 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading app_runtime from /run/f086-config.yaml
+2026-10-21 04:07:44.380 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading object_storage from /run/f086-config.yaml
+2026-10-21 04:07:44.384 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'database'
+2026-10-21 04:07:44.384 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'database' with dependencies: []
+2026-10-21 04:07:44.384 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'redis'
+2026-10-21 04:07:44.384 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'redis' with dependencies: []
+2026-10-21 04:07:44.822 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'minio'
+2026-10-21 04:07:44.822 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'minio' with dependencies: []
+2026-10-21 04:07:44.822 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'elasticsearch'
+2026-10-21 04:07:44.822 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'elasticsearch' with dependencies: []
+2026-10-21 04:07:44.822 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'statistics_elasticsearch'
+2026-10-21 04:07:44.822 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'statistics_elasticsearch' with dependencies: []
+2026-10-21 04:07:44.825 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'http_client'
+2026-10-21 04:07:44.825 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'http_client' with dependencies: []
+2026-10-21 04:07:44.828 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'prompts'
+2026-10-21 04:07:44.828 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'prompts' with dependencies: []
+2026-10-21 04:07:44.833 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'openfga'
+2026-10-21 04:07:44.833 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'openfga' with dependencies: []
+2026-10-21 04:07:44.833 | DEBUG    | bisheng.core.context.manager:_register_default_contexts:123 - Default contexts registered
+2026-10-21 04:07:44.834 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'database' (attempt 1/3)
+None of PyTorch, TensorFlow >= 2.0, or Flax have been found. Models won't be available and only tokenizers, configuration and file/data utilities can be used.
+2026-10-21 04:07:51.398 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'database' initialized successfully
+2026-10-21 04:07:51.399 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:07:51.399 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'redis' (attempt 1/3)
+2026-10-21 04:07:51.399 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'redis' initialized successfully
+2026-10-21 04:07:51.400 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'redis'
+2026-10-21 04:07:51.400 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'minio' (attempt 1/3)
+2026-10-21 04:07:51.423 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'minio' initialized successfully
+2026-10-21 04:07:51.423 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'minio'
+2026-10-21 04:07:51.423 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'elasticsearch' (attempt 1/3)
+2026-10-21 04:07:51.423 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'elasticsearch' initialized successfully
+2026-10-21 04:07:51.423 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'elasticsearch'
+2026-10-21 04:07:51.423 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'statistics_elasticsearch' (attempt 1/3)
+2026-10-21 04:07:51.423 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'statistics_elasticsearch' initialized successfully
+2026-10-21 04:07:51.423 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'statistics_elasticsearch'
+2026-10-21 04:07:51.423 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'http_client' (attempt 1/3)
+2026-10-21 04:07:51.424 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'http_client' initialized successfully
+2026-10-21 04:07:51.424 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'http_client'
+2026-10-21 04:07:51.424 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'prompts' (attempt 1/3)
+2026-10-21 04:07:51.437 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'prompts' initialized successfully
+2026-10-21 04:07:51.437 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'prompts'
+2026-10-21 04:07:51.437 | INFO     | bisheng.core.context.manager:initialize:57 - Application context initialized successfully
+PASS CONNECT policy revoke {"new_connect_status": 407, "upstream_remained_running": true, "closed_after_seconds": 1.266052007675171}
+2026-10-21 04:07:51.438 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:07:51.636 | INFO     | bisheng.common.services.metric_log:emit_metric:94 - BS_METRIC domain=db_pool engine=async checked_out=1 idle=0 size=100 capacity=120 at_capacity=0
+2026-10-21 04:07:51.643 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'openfga'
+2026-10-21 04:07:51.643 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'prompts'
+2026-10-21 04:07:51.643 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'prompts' closed successfully
+2026-10-21 04:07:51.643 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'prompts'
+2026-10-21 04:07:51.643 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'http_client'
+2026-10-21 04:07:51.643 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'http_client' closed successfully
+2026-10-21 04:07:51.643 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'http_client'
+2026-10-21 04:07:51.643 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'statistics_elasticsearch'
+2026-10-21 04:07:51.643 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'statistics_elasticsearch' closed successfully
+2026-10-21 04:07:51.643 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'statistics_elasticsearch'
+2026-10-21 04:07:51.644 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'elasticsearch'
+2026-10-21 04:07:51.644 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'elasticsearch' closed successfully
+2026-10-21 04:07:51.644 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'elasticsearch'
+2026-10-21 04:07:51.644 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'minio'
+Unclosed client session
+client_session: 
+2026-10-21 04:07:51.645 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'minio' closed successfully
+2026-10-21 04:07:51.645 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'minio'
+2026-10-21 04:07:51.645 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'redis'
+2026-10-21 04:07:51.646 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'redis' closed successfully
+2026-10-21 04:07:51.646 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'redis'
+2026-10-21 04:07:51.646 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'database'
+2026-10-21 04:07:51.647 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'database' closed successfully
+2026-10-21 04:07:51.647 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'database'
+2026-10-21 04:07:51.647 | INFO     | bisheng.core.context.manager:async_close:286 - Application context closed successfully
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/deletion-followup.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/deletion-followup.log
new file mode 100644
index 0000000000..4864e57ad9
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/deletion-followup.log
@@ -0,0 +1,87 @@
+bash: warning: setlocale: LC_ALL: cannot change locale (C.UTF-8)
+2026-10-21 04:24:31.260 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading database_url from /run/f086-config.yaml
+2026-10-21 04:24:31.260 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading redis_url from /run/f086-config.yaml
+2026-10-21 04:24:31.260 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading celery_redis_url from /run/f086-config.yaml
+2026-10-21 04:24:31.260 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading multi_tenant from /run/f086-config.yaml
+2026-10-21 04:24:31.261 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading open_platform from /run/f086-config.yaml
+2026-10-21 04:24:31.261 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading openfga from /run/f086-config.yaml
+2026-10-21 04:24:31.261 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading app_runtime from /run/f086-config.yaml
+2026-10-21 04:24:31.261 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading object_storage from /run/f086-config.yaml
+2026-10-21 04:24:31.265 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'database'
+2026-10-21 04:24:31.266 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'database' with dependencies: []
+2026-10-21 04:24:31.266 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'redis'
+2026-10-21 04:24:31.266 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'redis' with dependencies: []
+2026-10-21 04:24:31.731 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'minio'
+2026-10-21 04:24:31.731 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'minio' with dependencies: []
+2026-10-21 04:24:31.731 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'elasticsearch'
+2026-10-21 04:24:31.731 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'elasticsearch' with dependencies: []
+2026-10-21 04:24:31.731 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'statistics_elasticsearch'
+2026-10-21 04:24:31.731 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'statistics_elasticsearch' with dependencies: []
+2026-10-21 04:24:31.734 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'http_client'
+2026-10-21 04:24:31.734 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'http_client' with dependencies: []
+2026-10-21 04:24:31.737 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'prompts'
+2026-10-21 04:24:31.738 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'prompts' with dependencies: []
+2026-10-21 04:24:31.743 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'openfga'
+2026-10-21 04:24:31.743 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'openfga' with dependencies: []
+2026-10-21 04:24:31.743 | DEBUG    | bisheng.core.context.manager:_register_default_contexts:123 - Default contexts registered
+2026-10-21 04:24:31.743 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'database' (attempt 1/3)
+None of PyTorch, TensorFlow >= 2.0, or Flax have been found. Models won't be available and only tokenizers, configuration and file/data utilities can be used.
+2026-10-21 04:24:38.433 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'database' initialized successfully
+2026-10-21 04:24:38.434 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:24:38.434 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'redis' (attempt 1/3)
+2026-10-21 04:24:38.434 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'redis' initialized successfully
+2026-10-21 04:24:38.434 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'redis'
+2026-10-21 04:24:38.434 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'minio' (attempt 1/3)
+2026-10-21 04:24:38.454 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'minio' initialized successfully
+2026-10-21 04:24:38.455 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'minio'
+2026-10-21 04:24:38.455 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'elasticsearch' (attempt 1/3)
+2026-10-21 04:24:38.455 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'elasticsearch' initialized successfully
+2026-10-21 04:24:38.455 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'elasticsearch'
+2026-10-21 04:24:38.455 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'statistics_elasticsearch' (attempt 1/3)
+2026-10-21 04:24:38.455 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'statistics_elasticsearch' initialized successfully
+2026-10-21 04:24:38.455 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'statistics_elasticsearch'
+2026-10-21 04:24:38.455 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'http_client' (attempt 1/3)
+2026-10-21 04:24:38.455 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'http_client' initialized successfully
+2026-10-21 04:24:38.455 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'http_client'
+2026-10-21 04:24:38.455 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'prompts' (attempt 1/3)
+2026-10-21 04:24:38.465 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'prompts' initialized successfully
+2026-10-21 04:24:38.465 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'prompts'
+2026-10-21 04:24:38.465 | INFO     | bisheng.core.context.manager:initialize:57 - Application context initialized successfully
+2026-10-21 04:24:38.465 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:24:38.641 | INFO     | bisheng.common.services.metric_log:emit_metric:94 - BS_METRIC domain=db_pool engine=async checked_out=1 idle=0 size=100 capacity=120 at_capacity=0
+Deleted dedicated app e2e-f086-cancel
+Deleted dedicated app e2e-f086-compile-timeout
+Deleted dedicated app e2e-f086-timeout
+Deleted dedicated app e2e-f086-restart
+Deleted dedicated app e2e-f086-install-cancel
+Deleted dedicated app e2e-f086-legacy-upgrade
+Deleted dedicated app e2e-f086-mt
+2026-10-21 04:25:06.647 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:25:06.652 | INFO     | bisheng.common.services.metric_log:emit_metric:94 - BS_METRIC domain=db_query_agg window_s=28.011 count=2 sum_ms=2.3 le_5=2 le_10=2 le_25=2 le_50=2 le_100=2 le_250=2 le_500=2 le_1000=2 le_inf=2
+2026-10-21 04:25:06.652 | INFO     | bisheng.common.services.metric_log:emit_metric:94 - BS_METRIC domain=db_pool engine=async checked_out=1 idle=0 size=100 capacity=120 at_capacity=0
+PASS dedicated deletion {"applications": [{"app_id": "0e7c8fa1ad01451bb4ac7ffff2b79ece", "name": "e2e-f086-cancel", "delete_requested": true}, {"app_id": "21d7555bc22c48b29b12be8d63d07284", "name": "e2e-f086-compile-timeout", "delete_requested": true}, {"app_id": "2a3a9b211e684496a719987a48c88c35", "name": "e2e-f086-timeout", "delete_requested": true}, {"app_id": "749df2ba5e7c46689acd28f13fc62304", "name": "e2e-f086-restart", "delete_requested": true}, {"app_id": "fbedd729679c451c8a178884c039f19e", "name": "e2e-f086-install-cancel", "delete_requested": true}, {"app_id": "7dcca9caca5b4a2b8d50405717ddcc5c", "name": "e2e-f086-legacy-upgrade", "delete_requested": true}, {"app_id": "8411f09dd5f94b249d272492f8536812", "name": "e2e-f086-mt", "delete_requested": true}], "all_deleted": true, "reservation_count": 23, "all_reservations_released": true, "snapshot_count": 3, "all_snapshots_purged": true}
+2026-10-21 04:25:06.664 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'openfga'
+2026-10-21 04:25:06.664 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'prompts'
+2026-10-21 04:25:06.664 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'prompts' closed successfully
+2026-10-21 04:25:06.664 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'prompts'
+2026-10-21 04:25:06.665 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'http_client'
+2026-10-21 04:25:06.665 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'http_client' closed successfully
+2026-10-21 04:25:06.665 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'http_client'
+2026-10-21 04:25:06.665 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'statistics_elasticsearch'
+2026-10-21 04:25:06.665 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'statistics_elasticsearch' closed successfully
+2026-10-21 04:25:06.665 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'statistics_elasticsearch'
+2026-10-21 04:25:06.665 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'elasticsearch'
+2026-10-21 04:25:06.665 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'elasticsearch' closed successfully
+2026-10-21 04:25:06.665 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'elasticsearch'
+2026-10-21 04:25:06.665 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'minio'
+Unclosed client session
+client_session: 
+2026-10-21 04:25:06.667 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'minio' closed successfully
+2026-10-21 04:25:06.667 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'minio'
+2026-10-21 04:25:06.667 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'redis'
+2026-10-21 04:25:06.668 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'redis' closed successfully
+2026-10-21 04:25:06.668 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'redis'
+2026-10-21 04:25:06.668 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'database'
+2026-10-21 04:25:06.668 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'database' closed successfully
+2026-10-21 04:25:06.668 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'database'
+2026-10-21 04:25:06.669 | INFO     | bisheng.core.context.manager:async_close:286 - Application context closed successfully
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/install-cancel.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/install-cancel.log
new file mode 100644
index 0000000000..dc97da5000
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/install-cancel.log
@@ -0,0 +1,81 @@
+bash: warning: setlocale: LC_ALL: cannot change locale (C.UTF-8)
+2026-10-21 04:21:31.185 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading database_url from /run/f086-config.yaml
+2026-10-21 04:21:31.185 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading redis_url from /run/f086-config.yaml
+2026-10-21 04:21:31.185 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading celery_redis_url from /run/f086-config.yaml
+2026-10-21 04:21:31.185 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading multi_tenant from /run/f086-config.yaml
+2026-10-21 04:21:31.185 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading open_platform from /run/f086-config.yaml
+2026-10-21 04:21:31.185 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading openfga from /run/f086-config.yaml
+2026-10-21 04:21:31.185 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading app_runtime from /run/f086-config.yaml
+2026-10-21 04:21:31.185 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading object_storage from /run/f086-config.yaml
+2026-10-21 04:21:31.190 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'database'
+2026-10-21 04:21:31.190 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'database' with dependencies: []
+2026-10-21 04:21:31.190 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'redis'
+2026-10-21 04:21:31.190 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'redis' with dependencies: []
+2026-10-21 04:21:31.618 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'minio'
+2026-10-21 04:21:31.618 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'minio' with dependencies: []
+2026-10-21 04:21:31.618 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'elasticsearch'
+2026-10-21 04:21:31.618 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'elasticsearch' with dependencies: []
+2026-10-21 04:21:31.618 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'statistics_elasticsearch'
+2026-10-21 04:21:31.618 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'statistics_elasticsearch' with dependencies: []
+2026-10-21 04:21:31.621 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'http_client'
+2026-10-21 04:21:31.621 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'http_client' with dependencies: []
+2026-10-21 04:21:31.623 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'prompts'
+2026-10-21 04:21:31.624 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'prompts' with dependencies: []
+2026-10-21 04:21:31.629 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'openfga'
+2026-10-21 04:21:31.629 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'openfga' with dependencies: []
+2026-10-21 04:21:31.629 | DEBUG    | bisheng.core.context.manager:_register_default_contexts:123 - Default contexts registered
+2026-10-21 04:21:31.629 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'database' (attempt 1/3)
+None of PyTorch, TensorFlow >= 2.0, or Flax have been found. Models won't be available and only tokenizers, configuration and file/data utilities can be used.
+2026-10-21 04:21:38.296 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'database' initialized successfully
+2026-10-21 04:21:38.296 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:21:38.296 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'redis' (attempt 1/3)
+2026-10-21 04:21:38.297 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'redis' initialized successfully
+2026-10-21 04:21:38.297 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'redis'
+2026-10-21 04:21:38.297 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'minio' (attempt 1/3)
+2026-10-21 04:21:38.318 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'minio' initialized successfully
+2026-10-21 04:21:38.318 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'minio'
+2026-10-21 04:21:38.318 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'elasticsearch' (attempt 1/3)
+2026-10-21 04:21:38.318 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'elasticsearch' initialized successfully
+2026-10-21 04:21:38.318 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'elasticsearch'
+2026-10-21 04:21:38.318 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'statistics_elasticsearch' (attempt 1/3)
+2026-10-21 04:21:38.318 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'statistics_elasticsearch' initialized successfully
+2026-10-21 04:21:38.318 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'statistics_elasticsearch'
+2026-10-21 04:21:38.318 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'http_client' (attempt 1/3)
+2026-10-21 04:21:38.319 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'http_client' initialized successfully
+2026-10-21 04:21:38.319 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'http_client'
+2026-10-21 04:21:38.319 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'prompts' (attempt 1/3)
+2026-10-21 04:21:38.329 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'prompts' initialized successfully
+2026-10-21 04:21:38.329 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'prompts'
+2026-10-21 04:21:38.329 | INFO     | bisheng.core.context.manager:initialize:57 - Application context initialized successfully
+Submitted node fault install-cancel fbedd729679c451c8a178884c039f19e
+Publish 24667787a8014c36a0c887da9c980104 precheck_build running
+Publish 24667787a8014c36a0c887da9c980104 precheck_build failed
+2026-10-21 04:21:52.980 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:21:53.182 | INFO     | bisheng.common.services.metric_log:emit_metric:94 - BS_METRIC domain=db_pool engine=async checked_out=1 idle=0 size=100 capacity=120 at_capacity=0
+2026-10-21 04:21:53.184 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+PASS install-cancel real resolve interrupted, no approval, cleanup complete, reservations released
+2026-10-21 04:21:53.190 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'openfga'
+2026-10-21 04:21:53.190 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'prompts'
+2026-10-21 04:21:53.190 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'prompts' closed successfully
+2026-10-21 04:21:53.190 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'prompts'
+2026-10-21 04:21:53.190 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'http_client'
+2026-10-21 04:21:53.190 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'http_client' closed successfully
+2026-10-21 04:21:53.190 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'http_client'
+2026-10-21 04:21:53.190 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'statistics_elasticsearch'
+2026-10-21 04:21:53.190 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'statistics_elasticsearch' closed successfully
+2026-10-21 04:21:53.190 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'statistics_elasticsearch'
+2026-10-21 04:21:53.190 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'elasticsearch'
+2026-10-21 04:21:53.190 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'elasticsearch' closed successfully
+2026-10-21 04:21:53.191 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'elasticsearch'
+2026-10-21 04:21:53.191 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'minio'
+Unclosed client session
+client_session: 
+2026-10-21 04:21:53.192 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'minio' closed successfully
+2026-10-21 04:21:53.193 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'minio'
+2026-10-21 04:21:53.193 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'redis'
+2026-10-21 04:21:53.193 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'redis' closed successfully
+2026-10-21 04:21:53.193 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'redis'
+2026-10-21 04:21:53.193 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'database'
+2026-10-21 04:21:53.194 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'database' closed successfully
+2026-10-21 04:21:53.194 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'database'
+2026-10-21 04:21:53.194 | INFO     | bisheng.core.context.manager:async_close:286 - Application context closed successfully
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/legacy-pending-confirm.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/legacy-pending-confirm.log
new file mode 100644
index 0000000000..02b69f7b9a
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/legacy-pending-confirm.log
@@ -0,0 +1,78 @@
+bash: warning: setlocale: LC_ALL: cannot change locale (C.UTF-8)
+Approval 7
+2026-10-21 04:15:09.507 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading database_url from /run/f086-config.yaml
+2026-10-21 04:15:09.508 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading redis_url from /run/f086-config.yaml
+2026-10-21 04:15:09.508 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading celery_redis_url from /run/f086-config.yaml
+2026-10-21 04:15:09.508 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading multi_tenant from /run/f086-config.yaml
+2026-10-21 04:15:09.508 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading open_platform from /run/f086-config.yaml
+2026-10-21 04:15:09.508 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading openfga from /run/f086-config.yaml
+2026-10-21 04:15:09.508 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading app_runtime from /run/f086-config.yaml
+2026-10-21 04:15:09.508 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading object_storage from /run/f086-config.yaml
+2026-10-21 04:15:09.512 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'database'
+2026-10-21 04:15:09.512 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'database' with dependencies: []
+2026-10-21 04:15:09.512 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'redis'
+2026-10-21 04:15:09.513 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'redis' with dependencies: []
+2026-10-21 04:15:09.956 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'minio'
+2026-10-21 04:15:09.956 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'minio' with dependencies: []
+2026-10-21 04:15:09.956 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'elasticsearch'
+2026-10-21 04:15:09.956 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'elasticsearch' with dependencies: []
+2026-10-21 04:15:09.956 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'statistics_elasticsearch'
+2026-10-21 04:15:09.956 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'statistics_elasticsearch' with dependencies: []
+2026-10-21 04:15:09.959 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'http_client'
+2026-10-21 04:15:09.959 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'http_client' with dependencies: []
+2026-10-21 04:15:09.962 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'prompts'
+2026-10-21 04:15:09.962 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'prompts' with dependencies: []
+2026-10-21 04:15:09.967 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'openfga'
+2026-10-21 04:15:09.967 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'openfga' with dependencies: []
+2026-10-21 04:15:09.967 | DEBUG    | bisheng.core.context.manager:_register_default_contexts:123 - Default contexts registered
+2026-10-21 04:15:09.967 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'database' (attempt 1/3)
+None of PyTorch, TensorFlow >= 2.0, or Flax have been found. Models won't be available and only tokenizers, configuration and file/data utilities can be used.
+2026-10-21 04:15:16.659 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'database' initialized successfully
+2026-10-21 04:15:16.659 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:15:16.659 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'redis' (attempt 1/3)
+2026-10-21 04:15:16.660 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'redis' initialized successfully
+2026-10-21 04:15:16.660 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'redis'
+2026-10-21 04:15:16.660 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'minio' (attempt 1/3)
+2026-10-21 04:15:16.679 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'minio' initialized successfully
+2026-10-21 04:15:16.679 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'minio'
+2026-10-21 04:15:16.679 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'elasticsearch' (attempt 1/3)
+2026-10-21 04:15:16.679 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'elasticsearch' initialized successfully
+2026-10-21 04:15:16.679 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'elasticsearch'
+2026-10-21 04:15:16.679 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'statistics_elasticsearch' (attempt 1/3)
+2026-10-21 04:15:16.679 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'statistics_elasticsearch' initialized successfully
+2026-10-21 04:15:16.679 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'statistics_elasticsearch'
+2026-10-21 04:15:16.679 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'http_client' (attempt 1/3)
+2026-10-21 04:15:16.680 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'http_client' initialized successfully
+2026-10-21 04:15:16.680 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'http_client'
+2026-10-21 04:15:16.680 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'prompts' (attempt 1/3)
+2026-10-21 04:15:16.690 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'prompts' initialized successfully
+2026-10-21 04:15:16.690 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'prompts'
+2026-10-21 04:15:16.690 | INFO     | bisheng.core.context.manager:initialize:57 - Application context initialized successfully
+2026-10-21 04:15:16.690 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:15:16.875 | INFO     | bisheng.common.services.metric_log:emit_metric:94 - BS_METRIC domain=db_pool engine=async checked_out=1 idle=0 size=100 capacity=120 at_capacity=0
+2026-10-21 04:15:16.877 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'openfga'
+2026-10-21 04:15:16.877 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'prompts'
+2026-10-21 04:15:16.877 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'prompts' closed successfully
+2026-10-21 04:15:16.877 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'prompts'
+2026-10-21 04:15:16.878 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'http_client'
+2026-10-21 04:15:16.878 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'http_client' closed successfully
+2026-10-21 04:15:16.878 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'http_client'
+2026-10-21 04:15:16.878 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'statistics_elasticsearch'
+2026-10-21 04:15:16.878 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'statistics_elasticsearch' closed successfully
+2026-10-21 04:15:16.878 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'statistics_elasticsearch'
+2026-10-21 04:15:16.878 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'elasticsearch'
+2026-10-21 04:15:16.878 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'elasticsearch' closed successfully
+2026-10-21 04:15:16.878 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'elasticsearch'
+2026-10-21 04:15:16.878 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'minio'
+Unclosed client session
+client_session: 
+2026-10-21 04:15:16.879 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'minio' closed successfully
+2026-10-21 04:15:16.880 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'minio'
+2026-10-21 04:15:16.880 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'redis'
+2026-10-21 04:15:16.880 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'redis' closed successfully
+2026-10-21 04:15:16.880 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'redis'
+2026-10-21 04:15:16.880 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'database'
+2026-10-21 04:15:16.881 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'database' closed successfully
+2026-10-21 04:15:16.881 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'database'
+2026-10-21 04:15:16.881 | INFO     | bisheng.core.context.manager:async_close:286 - Application context closed successfully
+PASS current101 remains online, approved102 parked while manager unavailable
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/legacy-publication.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/legacy-publication.log
new file mode 100644
index 0000000000..8c3dc2c4c8
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/legacy-publication.log
@@ -0,0 +1,84 @@
+bash: warning: setlocale: LC_ALL: cannot change locale (C.UTF-8)
+Submitted e2e-f086-legacy-upgrade 7dcca9caca5b4a2b8d50405717ddcc5c 2494b52166874eb0a2c749fc6822d45e
+Publish 2494b52166874eb0a2c749fc6822d45e secret_scan running
+Publish 2494b52166874eb0a2c749fc6822d45e precheck_build running
+Publish 2494b52166874eb0a2c749fc6822d45e precheck_probe running
+Publish 2494b52166874eb0a2c749fc6822d45e approval_created waiting_approval
+Approval 6
+Legacy SDK0.1.1 attachment CRUD passed
+2026-10-21 04:13:08.552 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading database_url from /run/f086-config.yaml
+2026-10-21 04:13:08.552 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading redis_url from /run/f086-config.yaml
+2026-10-21 04:13:08.552 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading celery_redis_url from /run/f086-config.yaml
+2026-10-21 04:13:08.552 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading multi_tenant from /run/f086-config.yaml
+2026-10-21 04:13:08.552 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading open_platform from /run/f086-config.yaml
+2026-10-21 04:13:08.552 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading openfga from /run/f086-config.yaml
+2026-10-21 04:13:08.552 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading app_runtime from /run/f086-config.yaml
+2026-10-21 04:13:08.552 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading object_storage from /run/f086-config.yaml
+2026-10-21 04:13:08.557 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'database'
+2026-10-21 04:13:08.557 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'database' with dependencies: []
+2026-10-21 04:13:08.557 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'redis'
+2026-10-21 04:13:08.557 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'redis' with dependencies: []
+2026-10-21 04:13:09.000 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'minio'
+2026-10-21 04:13:09.001 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'minio' with dependencies: []
+2026-10-21 04:13:09.001 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'elasticsearch'
+2026-10-21 04:13:09.001 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'elasticsearch' with dependencies: []
+2026-10-21 04:13:09.001 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'statistics_elasticsearch'
+2026-10-21 04:13:09.001 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'statistics_elasticsearch' with dependencies: []
+2026-10-21 04:13:09.003 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'http_client'
+2026-10-21 04:13:09.004 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'http_client' with dependencies: []
+2026-10-21 04:13:09.007 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'prompts'
+2026-10-21 04:13:09.007 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'prompts' with dependencies: []
+2026-10-21 04:13:09.012 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'openfga'
+2026-10-21 04:13:09.013 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'openfga' with dependencies: []
+2026-10-21 04:13:09.013 | DEBUG    | bisheng.core.context.manager:_register_default_contexts:123 - Default contexts registered
+2026-10-21 04:13:09.013 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'database' (attempt 1/3)
+None of PyTorch, TensorFlow >= 2.0, or Flax have been found. Models won't be available and only tokenizers, configuration and file/data utilities can be used.
+2026-10-21 04:13:15.778 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'database' initialized successfully
+2026-10-21 04:13:15.778 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:13:15.778 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'redis' (attempt 1/3)
+2026-10-21 04:13:15.779 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'redis' initialized successfully
+2026-10-21 04:13:15.779 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'redis'
+2026-10-21 04:13:15.779 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'minio' (attempt 1/3)
+2026-10-21 04:13:15.798 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'minio' initialized successfully
+2026-10-21 04:13:15.798 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'minio'
+2026-10-21 04:13:15.798 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'elasticsearch' (attempt 1/3)
+2026-10-21 04:13:15.798 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'elasticsearch' initialized successfully
+2026-10-21 04:13:15.798 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'elasticsearch'
+2026-10-21 04:13:15.799 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'statistics_elasticsearch' (attempt 1/3)
+2026-10-21 04:13:15.799 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'statistics_elasticsearch' initialized successfully
+2026-10-21 04:13:15.799 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'statistics_elasticsearch'
+2026-10-21 04:13:15.799 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'http_client' (attempt 1/3)
+2026-10-21 04:13:15.799 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'http_client' initialized successfully
+2026-10-21 04:13:15.799 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'http_client'
+2026-10-21 04:13:15.799 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'prompts' (attempt 1/3)
+2026-10-21 04:13:15.809 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'prompts' initialized successfully
+2026-10-21 04:13:15.809 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'prompts'
+2026-10-21 04:13:15.809 | INFO     | bisheng.core.context.manager:initialize:57 - Application context initialized successfully
+2026-10-21 04:13:15.810 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:13:16.004 | INFO     | bisheng.common.services.metric_log:emit_metric:94 - BS_METRIC domain=db_pool engine=async checked_out=1 idle=0 size=100 capacity=120 at_capacity=0
+2026-10-21 04:13:16.007 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'openfga'
+2026-10-21 04:13:16.007 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'prompts'
+2026-10-21 04:13:16.007 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'prompts' closed successfully
+2026-10-21 04:13:16.007 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'prompts'
+2026-10-21 04:13:16.007 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'http_client'
+2026-10-21 04:13:16.007 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'http_client' closed successfully
+2026-10-21 04:13:16.007 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'http_client'
+2026-10-21 04:13:16.007 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'statistics_elasticsearch'
+2026-10-21 04:13:16.007 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'statistics_elasticsearch' closed successfully
+2026-10-21 04:13:16.007 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'statistics_elasticsearch'
+2026-10-21 04:13:16.007 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'elasticsearch'
+2026-10-21 04:13:16.007 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'elasticsearch' closed successfully
+2026-10-21 04:13:16.007 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'elasticsearch'
+2026-10-21 04:13:16.007 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'minio'
+Unclosed client session
+client_session: 
+2026-10-21 04:13:16.009 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'minio' closed successfully
+2026-10-21 04:13:16.009 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'minio'
+2026-10-21 04:13:16.009 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'redis'
+2026-10-21 04:13:16.009 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'redis' closed successfully
+2026-10-21 04:13:16.009 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'redis'
+2026-10-21 04:13:16.009 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'database'
+2026-10-21 04:13:16.010 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'database' closed successfully
+2026-10-21 04:13:16.010 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'database'
+2026-10-21 04:13:16.010 | INFO     | bisheng.core.context.manager:async_close:286 - Application context closed successfully
+PASS original protocol1 publication/approval/online entry acb7ff5ab2d6b04f7ce070c59ddad9357d09524f903853ef2f6516ccd0c143d1
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/live-boundaries.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/live-boundaries.log
new file mode 100644
index 0000000000..1987b55f57
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/live-boundaries.log
@@ -0,0 +1,45 @@
+bash: warning: setlocale: LC_ALL: cannot change locale (C.UTF-8)
+......                                                                   [100%]
+=============================== warnings summary ===============================
+bisheng/core/ai/__init__.py:2
+  /work/bisheng/core/ai/__init__.py:2: DeprecationWarning: `langchain-community` is being sunset and is no longer actively maintained. See https://github.com/langchain-ai/langchain-community/issues/674 for details and migration guidance toward standalone integration packages.
+    from langchain_community.chat_models import ChatTongyi, ChatZhipuAI, MoonshotChat
+
+../app/.venv/lib/python3.11/site-packages/simhash/__init__.py:50
+  /app/.venv/lib/python3.11/site-packages/simhash/__init__.py:50: DeprecationWarning: invalid escape sequence '\w'
+    """
+
+../app/.venv/lib/python3.11/site-packages/jieba/__init__.py:44
+  /app/.venv/lib/python3.11/site-packages/jieba/__init__.py:44: DeprecationWarning: invalid escape sequence '\.'
+    re_han_default = re.compile("([\u4E00-\u9FD5a-zA-Z0-9+#&\._%\-]+)", re.U)
+
+../app/.venv/lib/python3.11/site-packages/jieba/__init__.py:46
+  /app/.venv/lib/python3.11/site-packages/jieba/__init__.py:46: DeprecationWarning: invalid escape sequence '\s'
+    re_skip_default = re.compile("(\r\n|\s)", re.U)
+
+../app/.venv/lib/python3.11/site-packages/jieba/finalseg/__init__.py:78
+  /app/.venv/lib/python3.11/site-packages/jieba/finalseg/__init__.py:78: DeprecationWarning: invalid escape sequence '\.'
+    re_skip = re.compile("([a-zA-Z0-9]+(?:\.\d+)?%?)")
+
+../app/.venv/lib/python3.11/site-packages/jieba/_compat.py:18
+  /app/.venv/lib/python3.11/site-packages/jieba/_compat.py:18: UserWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html. The pkg_resources package is slated for removal as early as 2025-11-30. Refrain from using this package or pin to Setuptools<81.
+    import pkg_resources
+
+../app/.venv/lib/python3.11/site-packages/jieba/posseg/__init__.py:16
+  /app/.venv/lib/python3.11/site-packages/jieba/posseg/__init__.py:16: DeprecationWarning: invalid escape sequence '\.'
+    re_skip_detail = re.compile("([\.0-9]+|[a-zA-Z0-9]+)")
+
+../app/.venv/lib/python3.11/site-packages/jieba/posseg/__init__.py:17
+  /app/.venv/lib/python3.11/site-packages/jieba/posseg/__init__.py:17: DeprecationWarning: invalid escape sequence '\.'
+    re_han_internal = re.compile("([\u4E00-\u9FD5a-zA-Z0-9+#&\._]+)")
+
+../app/.venv/lib/python3.11/site-packages/jieba/posseg/__init__.py:18
+  /app/.venv/lib/python3.11/site-packages/jieba/posseg/__init__.py:18: DeprecationWarning: invalid escape sequence '\s'
+    re_skip_internal = re.compile("(\r\n|\s)")
+
+../app/.venv/lib/python3.11/site-packages/jieba/posseg/__init__.py:21
+  /app/.venv/lib/python3.11/site-packages/jieba/posseg/__init__.py:21: DeprecationWarning: invalid escape sequence '\.'
+    re_num = re.compile("[\.0-9]+")
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+6 passed, 10 warnings in 2.57s
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/manager-connect.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/manager-connect.log
new file mode 100644
index 0000000000..d70378961b
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/manager-connect.log
@@ -0,0 +1,2 @@
+READY_CONNECT
+PASS established CONNECT survived manager outage beyond3s and restart
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/manager-existing-connections.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/manager-existing-connections.log
new file mode 100644
index 0000000000..afb725ef9a
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/manager-existing-connections.log
@@ -0,0 +1,2 @@
+bash: warning: setlocale: LC_ALL: cannot change locale (C.UTF-8)
+PASS manager restart preserves existing WS and new HTTP/SDK {"existing_websocket": "survived", "new_http_and_sdk_checks": 32, "duration_seconds": 17}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/migration-startup-failure.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/migration-startup-failure.log
new file mode 100644
index 0000000000..12d99307d1
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/migration-startup-failure.log
@@ -0,0 +1,106 @@
+bash: warning: setlocale: LC_ALL: cannot change locale (C.UTF-8)
+2026-10-21 04:01:16.159 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading database_url from /run/f086-config.yaml
+2026-10-21 04:01:16.159 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading redis_url from /run/f086-config.yaml
+2026-10-21 04:01:16.159 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading celery_redis_url from /run/f086-config.yaml
+2026-10-21 04:01:16.159 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading multi_tenant from /run/f086-config.yaml
+2026-10-21 04:01:16.159 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading open_platform from /run/f086-config.yaml
+2026-10-21 04:01:16.160 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading openfga from /run/f086-config.yaml
+2026-10-21 04:01:16.160 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading app_runtime from /run/f086-config.yaml
+2026-10-21 04:01:16.160 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading object_storage from /run/f086-config.yaml
+2026-10-21 04:01:16.164 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'database'
+2026-10-21 04:01:16.164 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'database' with dependencies: []
+2026-10-21 04:01:16.164 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'redis'
+2026-10-21 04:01:16.164 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'redis' with dependencies: []
+2026-10-21 04:01:16.600 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'minio'
+2026-10-21 04:01:16.600 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'minio' with dependencies: []
+2026-10-21 04:01:16.601 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'elasticsearch'
+2026-10-21 04:01:16.601 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'elasticsearch' with dependencies: []
+2026-10-21 04:01:16.601 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'statistics_elasticsearch'
+2026-10-21 04:01:16.601 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'statistics_elasticsearch' with dependencies: []
+2026-10-21 04:01:16.603 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'http_client'
+2026-10-21 04:01:16.603 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'http_client' with dependencies: []
+2026-10-21 04:01:16.606 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'prompts'
+2026-10-21 04:01:16.606 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'prompts' with dependencies: []
+2026-10-21 04:01:16.612 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'openfga'
+2026-10-21 04:01:16.612 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'openfga' with dependencies: []
+2026-10-21 04:01:16.612 | DEBUG    | bisheng.core.context.manager:_register_default_contexts:123 - Default contexts registered
+2026-10-21 04:01:16.612 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'database' (attempt 1/3)
+None of PyTorch, TensorFlow >= 2.0, or Flax have been found. Models won't be available and only tokenizers, configuration and file/data utilities can be used.
+2026-10-21 04:01:23.359 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'database' initialized successfully
+2026-10-21 04:01:23.359 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:01:23.359 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'redis' (attempt 1/3)
+2026-10-21 04:01:23.360 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'redis' initialized successfully
+2026-10-21 04:01:23.360 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'redis'
+2026-10-21 04:01:23.360 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'minio' (attempt 1/3)
+2026-10-21 04:01:23.380 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'minio' initialized successfully
+2026-10-21 04:01:23.380 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'minio'
+2026-10-21 04:01:23.380 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'elasticsearch' (attempt 1/3)
+2026-10-21 04:01:23.380 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'elasticsearch' initialized successfully
+2026-10-21 04:01:23.380 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'elasticsearch'
+2026-10-21 04:01:23.380 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'statistics_elasticsearch' (attempt 1/3)
+2026-10-21 04:01:23.380 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'statistics_elasticsearch' initialized successfully
+2026-10-21 04:01:23.380 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'statistics_elasticsearch'
+2026-10-21 04:01:23.380 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'http_client' (attempt 1/3)
+2026-10-21 04:01:23.381 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'http_client' initialized successfully
+2026-10-21 04:01:23.381 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'http_client'
+2026-10-21 04:01:23.381 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'prompts' (attempt 1/3)
+2026-10-21 04:01:23.391 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'prompts' initialized successfully
+2026-10-21 04:01:23.391 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'prompts'
+2026-10-21 04:01:23.391 | INFO     | bisheng.core.context.manager:initialize:57 - Application context initialized successfully
+Submitted e2e-f086-mt 8411f09dd5f94b249d272492f8536812 d08b759e08874fa3a3c1ef06fe485294
+Publish d08b759e08874fa3a3c1ef06fe485294 secret_scan running
+Publish d08b759e08874fa3a3c1ef06fe485294 precheck_build running
+Publish d08b759e08874fa3a3c1ef06fe485294 precheck_probe running
+Publish d08b759e08874fa3a3c1ef06fe485294 approval_created waiting_approval
+Approval 3
+2026-10-21 04:01:52.085 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:01:52.338 | INFO     | bisheng.common.services.metric_log:emit_metric:94 - BS_METRIC domain=db_pool engine=async checked_out=1 idle=0 size=100 capacity=120 at_capacity=0
+2026-10-21 04:01:53.340 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:01:54.348 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:01:55.354 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:01:56.359 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:01:57.365 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:01:58.373 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:01:59.378 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:02:00.384 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:02:01.389 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:02:02.394 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:02:02.398 | INFO     | bisheng.common.services.metric_log:emit_metric:94 - BS_METRIC domain=db_query_agg window_s=10.06 count=11 sum_ms=18.647 le_5=11 le_10=11 le_25=11 le_50=11 le_100=11 le_250=11 le_500=11 le_1000=11 le_inf=11
+2026-10-21 04:02:02.398 | INFO     | bisheng.common.services.metric_log:emit_metric:94 - BS_METRIC domain=db_pool engine=async checked_out=1 idle=0 size=100 capacity=120 at_capacity=0
+2026-10-21 04:02:03.400 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:02:04.407 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:02:05.413 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+PASS DDL committed then startup exited; data risk=true, maintenance, no automatic DB rollback
+Submitted e2e-f086-mt 8411f09dd5f94b249d272492f8536812 5c405a16b7084016b45b2039b3a5d201
+Publish 5c405a16b7084016b45b2039b3a5d201 secret_scan running
+Publish 5c405a16b7084016b45b2039b3a5d201 precheck_build running
+Publish 5c405a16b7084016b45b2039b3a5d201 precheck_probe running
+Publish 5c405a16b7084016b45b2039b3a5d201 approval_created waiting_approval
+Approval 5
+PASS compatible roll forward kept existing data and schema
+2026-10-21 04:02:51.129 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'openfga'
+2026-10-21 04:02:51.130 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'prompts'
+2026-10-21 04:02:51.130 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'prompts' closed successfully
+2026-10-21 04:02:51.130 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'prompts'
+2026-10-21 04:02:51.130 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'http_client'
+2026-10-21 04:02:51.130 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'http_client' closed successfully
+2026-10-21 04:02:51.130 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'http_client'
+2026-10-21 04:02:51.130 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'statistics_elasticsearch'
+2026-10-21 04:02:51.130 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'statistics_elasticsearch' closed successfully
+2026-10-21 04:02:51.130 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'statistics_elasticsearch'
+2026-10-21 04:02:51.130 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'elasticsearch'
+2026-10-21 04:02:51.130 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'elasticsearch' closed successfully
+2026-10-21 04:02:51.130 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'elasticsearch'
+2026-10-21 04:02:51.130 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'minio'
+Unclosed client session
+client_session: 
+2026-10-21 04:02:51.132 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'minio' closed successfully
+2026-10-21 04:02:51.132 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'minio'
+2026-10-21 04:02:51.132 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'redis'
+2026-10-21 04:02:51.133 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'redis' closed successfully
+2026-10-21 04:02:51.133 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'redis'
+2026-10-21 04:02:51.133 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'database'
+2026-10-21 04:02:51.134 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'database' closed successfully
+2026-10-21 04:02:51.134 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'database'
+2026-10-21 04:02:51.134 | INFO     | bisheng.core.context.manager:async_close:286 - Application context closed successfully
+{"operation": {"id": "6a1d9260b583437d9c4fead315cdc39a", "kind": "deploy", "stage": "failed", "status": "failed", "cleanup_status": "complete", "failure": {"reason": "startup_exited", "maintenance": true, "failed_stage": "starting", "diagnostic_tail": "", "data_may_have_changed": true}, "version_id": "2733ef13fab84fd894728e5cd5ae08e3", "generation": 4}, "state": "online", "current_version_unchanged": true, "new_table_persisted": true, "production_data_retained": true, "confirmed_snapshots": 2, "roll_forward": {"version_id": "346df0d3cb8049008982dfe490944140", "entry": {"version": 4, "value": "production-marker"}}}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/mt-online.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/mt-online.log
new file mode 100644
index 0000000000..028e6b250c
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/mt-online.log
@@ -0,0 +1,3 @@
+bash: warning: setlocale: LC_ALL: cannot change locale (C.UTF-8)
+Approval 1
+Multi-tenant ordinary-owner online and entry 200
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/multi-tenant-identities.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/multi-tenant-identities.log
new file mode 100644
index 0000000000..357e14fe37
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/multi-tenant-identities.log
@@ -0,0 +1,7 @@
+bash: warning: setlocale: LC_ALL: cannot change locale (C.UTF-8)
+Root nodes [{"id": 1, "dept_id": "BS@root", "external_id": "BS@root", "name": "\u9ed8\u8ba4\u7ec4\u7ec7", "parent_id": null, "path": "/1/", "sort_order": 0, "source": "local", "status": "active", "is_tenant_root": false, "mounted_tenant_id": null, "has_children": true, "matched": false, "children": []}]
+Mounted a tenant 2 assignable [{"id": 2, "role_name": "\u666e\u901a\u7528\u6237", "role_type": "global", "department_id": null}]
+Ordinary member childowner tenant 2 member {"user_id": 4, "user_name": "e2e-f086-childowner", "person_id": "e2e-f086-childowner", "dept_id": "BS@4ecd86"}
+Ordinary member childnonowner tenant 2 member {"user_id": 5, "user_name": "e2e-f086-childnonowner", "person_id": "e2e-f086-childnonowner", "dept_id": "BS@4ecd86"}
+Mounted b tenant 3 assignable [{"id": 2, "role_name": "\u666e\u901a\u7528\u6237", "role_type": "global", "department_id": null}]
+Ordinary member foreign tenant 3 member {"user_id": 6, "user_name": "e2e-f086-foreign", "person_id": "e2e-f086-foreign", "dept_id": "BS@726557"}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/restart-probe.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/restart-probe.log
new file mode 100644
index 0000000000..730ee9706a
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/restart-probe.log
@@ -0,0 +1,188 @@
+bash: warning: setlocale: LC_ALL: cannot change locale (C.UTF-8)
+2026-10-21 04:00:29.269 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading database_url from /run/f086-config.yaml
+2026-10-21 04:00:29.269 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading redis_url from /run/f086-config.yaml
+2026-10-21 04:00:29.269 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading celery_redis_url from /run/f086-config.yaml
+2026-10-21 04:00:29.269 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading multi_tenant from /run/f086-config.yaml
+2026-10-21 04:00:29.269 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading open_platform from /run/f086-config.yaml
+2026-10-21 04:00:29.269 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading openfga from /run/f086-config.yaml
+2026-10-21 04:00:29.269 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading app_runtime from /run/f086-config.yaml
+2026-10-21 04:00:29.269 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading object_storage from /run/f086-config.yaml
+2026-10-21 04:00:29.274 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'database'
+2026-10-21 04:00:29.274 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'database' with dependencies: []
+2026-10-21 04:00:29.274 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'redis'
+2026-10-21 04:00:29.274 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'redis' with dependencies: []
+2026-10-21 04:00:29.708 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'minio'
+2026-10-21 04:00:29.708 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'minio' with dependencies: []
+2026-10-21 04:00:29.709 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'elasticsearch'
+2026-10-21 04:00:29.709 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'elasticsearch' with dependencies: []
+2026-10-21 04:00:29.709 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'statistics_elasticsearch'
+2026-10-21 04:00:29.709 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'statistics_elasticsearch' with dependencies: []
+2026-10-21 04:00:29.711 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'http_client'
+2026-10-21 04:00:29.711 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'http_client' with dependencies: []
+2026-10-21 04:00:29.714 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'prompts'
+2026-10-21 04:00:29.714 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'prompts' with dependencies: []
+2026-10-21 04:00:29.720 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'openfga'
+2026-10-21 04:00:29.720 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'openfga' with dependencies: []
+2026-10-21 04:00:29.720 | DEBUG    | bisheng.core.context.manager:_register_default_contexts:123 - Default contexts registered
+2026-10-21 04:00:29.720 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'database' (attempt 1/3)
+None of PyTorch, TensorFlow >= 2.0, or Flax have been found. Models won't be available and only tokenizers, configuration and file/data utilities can be used.
+2026-10-21 04:00:36.472 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'database' initialized successfully
+2026-10-21 04:00:36.472 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:00:36.472 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'redis' (attempt 1/3)
+2026-10-21 04:00:36.473 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'redis' initialized successfully
+2026-10-21 04:00:36.473 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'redis'
+2026-10-21 04:00:36.473 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'minio' (attempt 1/3)
+2026-10-21 04:00:36.495 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'minio' initialized successfully
+2026-10-21 04:00:36.495 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'minio'
+2026-10-21 04:00:36.495 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'elasticsearch' (attempt 1/3)
+2026-10-21 04:00:36.495 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'elasticsearch' initialized successfully
+2026-10-21 04:00:36.496 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'elasticsearch'
+2026-10-21 04:00:36.496 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'statistics_elasticsearch' (attempt 1/3)
+2026-10-21 04:00:36.496 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'statistics_elasticsearch' initialized successfully
+2026-10-21 04:00:36.496 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'statistics_elasticsearch'
+2026-10-21 04:00:36.496 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'http_client' (attempt 1/3)
+2026-10-21 04:00:36.496 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'http_client' initialized successfully
+2026-10-21 04:00:36.496 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'http_client'
+2026-10-21 04:00:36.496 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'prompts' (attempt 1/3)
+2026-10-21 04:00:36.506 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'prompts' initialized successfully
+2026-10-21 04:00:36.506 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'prompts'
+2026-10-21 04:00:36.506 | INFO     | bisheng.core.context.manager:initialize:57 - Application context initialized successfully
+Submitted e2e-f086-restart 749df2ba5e7c46689acd28f13fc62304 8e261f9a12684e16b13a497497c43d2d
+2026-10-21 04:00:36.823 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:00:37.007 | INFO     | bisheng.common.services.metric_log:emit_metric:94 - BS_METRIC domain=db_pool engine=async checked_out=1 idle=0 size=100 capacity=120 at_capacity=0
+2026-10-21 04:00:38.010 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:00:39.015 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:00:40.020 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:00:41.024 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:00:42.029 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:00:43.033 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:00:44.038 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:00:45.043 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:00:46.048 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:00:47.053 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:00:47.056 | INFO     | bisheng.common.services.metric_log:emit_metric:94 - BS_METRIC domain=db_query_agg window_s=10.05 count=11 sum_ms=12.389 le_5=11 le_10=11 le_25=11 le_50=11 le_100=11 le_250=11 le_500=11 le_1000=11 le_inf=11
+2026-10-21 04:00:47.057 | INFO     | bisheng.common.services.metric_log:emit_metric:94 - BS_METRIC domain=db_pool engine=async checked_out=1 idle=0 size=100 capacity=120 at_capacity=0
+2026-10-21 04:00:48.058 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:00:49.063 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:00:50.067 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:00:51.072 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:00:52.076 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:00:53.081 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:00:54.086 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:00:55.090 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:00:56.094 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:00:57.098 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:00:57.101 | INFO     | bisheng.common.services.metric_log:emit_metric:94 - BS_METRIC domain=db_pool engine=async checked_out=1 idle=0 size=100 capacity=120 at_capacity=0
+2026-10-21 04:00:58.102 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:00:58.106 | INFO     | bisheng.common.services.metric_log:emit_metric:94 - BS_METRIC domain=db_query_agg window_s=10.044 count=11 sum_ms=11.966 le_5=11 le_10=11 le_25=11 le_50=11 le_100=11 le_250=11 le_500=11 le_1000=11 le_inf=11
+2026-10-21 04:00:59.107 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:01:00.111 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:01:01.115 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:01:02.119 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:01:03.125 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:01:04.129 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+READY_TO_KILL_MANAGER 2490b1d0b3ba4c708ea588b1ed651549
+2026-10-21 04:01:04.134 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'openfga'
+2026-10-21 04:01:04.134 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'prompts'
+2026-10-21 04:01:04.134 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'prompts' closed successfully
+2026-10-21 04:01:04.134 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'prompts'
+2026-10-21 04:01:04.134 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'http_client'
+2026-10-21 04:01:04.134 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'http_client' closed successfully
+2026-10-21 04:01:04.134 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'http_client'
+2026-10-21 04:01:04.134 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'statistics_elasticsearch'
+2026-10-21 04:01:04.134 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'statistics_elasticsearch' closed successfully
+2026-10-21 04:01:04.134 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'statistics_elasticsearch'
+2026-10-21 04:01:04.134 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'elasticsearch'
+2026-10-21 04:01:04.134 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'elasticsearch' closed successfully
+2026-10-21 04:01:04.134 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'elasticsearch'
+2026-10-21 04:01:04.134 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'minio'
+Unclosed client session
+client_session: 
+2026-10-21 04:01:04.136 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'minio' closed successfully
+2026-10-21 04:01:04.136 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'minio'
+2026-10-21 04:01:04.136 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'redis'
+2026-10-21 04:01:04.136 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'redis' closed successfully
+2026-10-21 04:01:04.136 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'redis'
+2026-10-21 04:01:04.137 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'database'
+2026-10-21 04:01:04.137 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'database' closed successfully
+2026-10-21 04:01:04.137 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'database'
+2026-10-21 04:01:04.137 | INFO     | bisheng.core.context.manager:async_close:286 - Application context closed successfully
+bash: warning: setlocale: LC_ALL: cannot change locale (C.UTF-8)
+Publish 8e261f9a12684e16b13a497497c43d2d precheck_probe running
+Publish 8e261f9a12684e16b13a497497c43d2d approval_created waiting_approval
+2026-10-21 04:02:06.687 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading database_url from /run/f086-config.yaml
+2026-10-21 04:02:06.687 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading redis_url from /run/f086-config.yaml
+2026-10-21 04:02:06.687 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading celery_redis_url from /run/f086-config.yaml
+2026-10-21 04:02:06.687 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading multi_tenant from /run/f086-config.yaml
+2026-10-21 04:02:06.687 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading open_platform from /run/f086-config.yaml
+2026-10-21 04:02:06.687 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading openfga from /run/f086-config.yaml
+2026-10-21 04:02:06.687 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading app_runtime from /run/f086-config.yaml
+2026-10-21 04:02:06.687 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading object_storage from /run/f086-config.yaml
+2026-10-21 04:02:06.694 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'database'
+2026-10-21 04:02:06.694 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'database' with dependencies: []
+2026-10-21 04:02:06.694 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'redis'
+2026-10-21 04:02:06.694 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'redis' with dependencies: []
+2026-10-21 04:02:07.137 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'minio'
+2026-10-21 04:02:07.137 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'minio' with dependencies: []
+2026-10-21 04:02:07.137 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'elasticsearch'
+2026-10-21 04:02:07.137 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'elasticsearch' with dependencies: []
+2026-10-21 04:02:07.137 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'statistics_elasticsearch'
+2026-10-21 04:02:07.137 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'statistics_elasticsearch' with dependencies: []
+2026-10-21 04:02:07.140 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'http_client'
+2026-10-21 04:02:07.140 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'http_client' with dependencies: []
+2026-10-21 04:02:07.142 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'prompts'
+2026-10-21 04:02:07.142 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'prompts' with dependencies: []
+2026-10-21 04:02:07.148 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'openfga'
+2026-10-21 04:02:07.148 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'openfga' with dependencies: []
+2026-10-21 04:02:07.148 | DEBUG    | bisheng.core.context.manager:_register_default_contexts:123 - Default contexts registered
+2026-10-21 04:02:07.148 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'database' (attempt 1/3)
+None of PyTorch, TensorFlow >= 2.0, or Flax have been found. Models won't be available and only tokenizers, configuration and file/data utilities can be used.
+2026-10-21 04:02:13.841 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'database' initialized successfully
+2026-10-21 04:02:13.841 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:02:13.842 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'redis' (attempt 1/3)
+2026-10-21 04:02:13.842 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'redis' initialized successfully
+2026-10-21 04:02:13.842 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'redis'
+2026-10-21 04:02:13.842 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'minio' (attempt 1/3)
+2026-10-21 04:02:13.862 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'minio' initialized successfully
+2026-10-21 04:02:13.862 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'minio'
+2026-10-21 04:02:13.862 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'elasticsearch' (attempt 1/3)
+2026-10-21 04:02:13.862 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'elasticsearch' initialized successfully
+2026-10-21 04:02:13.862 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'elasticsearch'
+2026-10-21 04:02:13.862 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'statistics_elasticsearch' (attempt 1/3)
+2026-10-21 04:02:13.862 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'statistics_elasticsearch' initialized successfully
+2026-10-21 04:02:13.862 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'statistics_elasticsearch'
+2026-10-21 04:02:13.862 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'http_client' (attempt 1/3)
+2026-10-21 04:02:13.863 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'http_client' initialized successfully
+2026-10-21 04:02:13.863 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'http_client'
+2026-10-21 04:02:13.863 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'prompts' (attempt 1/3)
+2026-10-21 04:02:13.873 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'prompts' initialized successfully
+2026-10-21 04:02:13.873 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'prompts'
+2026-10-21 04:02:13.874 | INFO     | bisheng.core.context.manager:initialize:57 - Application context initialized successfully
+2026-10-21 04:02:13.874 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:02:14.060 | INFO     | bisheng.common.services.metric_log:emit_metric:94 - BS_METRIC domain=db_pool engine=async checked_out=1 idle=0 size=100 capacity=120 at_capacity=0
+2026-10-21 04:02:14.062 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'openfga'
+2026-10-21 04:02:14.062 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'prompts'
+2026-10-21 04:02:14.062 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'prompts' closed successfully
+2026-10-21 04:02:14.062 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'prompts'
+2026-10-21 04:02:14.062 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'http_client'
+2026-10-21 04:02:14.062 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'http_client' closed successfully
+2026-10-21 04:02:14.062 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'http_client'
+2026-10-21 04:02:14.062 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'statistics_elasticsearch'
+2026-10-21 04:02:14.062 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'statistics_elasticsearch' closed successfully
+2026-10-21 04:02:14.062 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'statistics_elasticsearch'
+2026-10-21 04:02:14.062 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'elasticsearch'
+2026-10-21 04:02:14.062 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'elasticsearch' closed successfully
+2026-10-21 04:02:14.062 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'elasticsearch'
+2026-10-21 04:02:14.062 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'minio'
+Unclosed client session
+client_session: 
+2026-10-21 04:02:14.064 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'minio' closed successfully
+2026-10-21 04:02:14.064 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'minio'
+2026-10-21 04:02:14.064 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'redis'
+2026-10-21 04:02:14.065 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'redis' closed successfully
+2026-10-21 04:02:14.065 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'redis'
+2026-10-21 04:02:14.065 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'database'
+2026-10-21 04:02:14.065 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'database' closed successfully
+2026-10-21 04:02:14.065 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'database'
+2026-10-21 04:02:14.065 | INFO     | bisheng.core.context.manager:async_close:286 - Application context closed successfully
+PASS killed manager resumed same probe operation, cleanup complete, one approval
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/snapshot-fault-confirm.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/snapshot-fault-confirm.log
new file mode 100644
index 0000000000..068721092b
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/snapshot-fault-confirm.log
@@ -0,0 +1,124 @@
+bash: warning: setlocale: LC_ALL: cannot change locale (C.UTF-8)
+2026-10-21 04:00:08.092 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading database_url from /run/f086-config.yaml
+2026-10-21 04:00:08.092 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading redis_url from /run/f086-config.yaml
+2026-10-21 04:00:08.092 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading celery_redis_url from /run/f086-config.yaml
+2026-10-21 04:00:08.092 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading multi_tenant from /run/f086-config.yaml
+2026-10-21 04:00:08.092 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading open_platform from /run/f086-config.yaml
+2026-10-21 04:00:08.092 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading openfga from /run/f086-config.yaml
+2026-10-21 04:00:08.092 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading app_runtime from /run/f086-config.yaml
+2026-10-21 04:00:08.092 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading object_storage from /run/f086-config.yaml
+2026-10-21 04:00:08.101 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'database'
+2026-10-21 04:00:08.101 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'database' with dependencies: []
+2026-10-21 04:00:08.101 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'redis'
+2026-10-21 04:00:08.101 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'redis' with dependencies: []
+2026-10-21 04:00:08.101 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'minio'
+2026-10-21 04:00:08.101 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'minio' with dependencies: []
+2026-10-21 04:00:08.101 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'elasticsearch'
+2026-10-21 04:00:08.101 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'elasticsearch' with dependencies: []
+2026-10-21 04:00:08.101 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'statistics_elasticsearch'
+2026-10-21 04:00:08.101 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'statistics_elasticsearch' with dependencies: []
+2026-10-21 04:00:08.104 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'http_client'
+2026-10-21 04:00:08.104 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'http_client' with dependencies: []
+2026-10-21 04:00:08.106 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'prompts'
+2026-10-21 04:00:08.106 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'prompts' with dependencies: []
+2026-10-21 04:00:08.111 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'openfga'
+2026-10-21 04:00:08.111 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'openfga' with dependencies: []
+2026-10-21 04:00:08.111 | DEBUG    | bisheng.core.context.manager:_register_default_contexts:123 - Default contexts registered
+2026-10-21 04:00:08.111 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'database' (attempt 1/3)
+None of PyTorch, TensorFlow >= 2.0, or Flax have been found. Models won't be available and only tokenizers, configuration and file/data utilities can be used.
+2026-10-21 04:00:14.698 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'database' initialized successfully
+2026-10-21 04:00:14.698 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:00:14.698 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'redis' (attempt 1/3)
+2026-10-21 04:00:14.698 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'redis' initialized successfully
+2026-10-21 04:00:14.699 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'redis'
+2026-10-21 04:00:14.699 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'minio' (attempt 1/3)
+2026-10-21 04:00:14.718 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'minio' initialized successfully
+2026-10-21 04:00:14.718 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'minio'
+2026-10-21 04:00:14.718 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'elasticsearch' (attempt 1/3)
+2026-10-21 04:00:14.718 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'elasticsearch' initialized successfully
+2026-10-21 04:00:14.718 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'elasticsearch'
+2026-10-21 04:00:14.718 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'statistics_elasticsearch' (attempt 1/3)
+2026-10-21 04:00:14.718 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'statistics_elasticsearch' initialized successfully
+2026-10-21 04:00:14.718 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'statistics_elasticsearch'
+2026-10-21 04:00:14.718 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'http_client' (attempt 1/3)
+2026-10-21 04:00:14.718 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'http_client' initialized successfully
+2026-10-21 04:00:14.718 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'http_client'
+2026-10-21 04:00:14.719 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'prompts' (attempt 1/3)
+2026-10-21 04:00:14.728 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'prompts' initialized successfully
+2026-10-21 04:00:14.728 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'prompts'
+2026-10-21 04:00:14.729 | INFO     | bisheng.core.context.manager:initialize:57 - Application context initialized successfully
+2026-10-21 04:00:14.789 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+/suite/f086_snapshot_fault.py:11: DeprecationWarning:
+        🚨 You probably want to use `session.exec()` instead of `session.execute()`.
+
+        This is the original SQLAlchemy `session.execute()` method that returns objects
+        of type `Row`, and that you have to call `scalars()` to get the model objects.
+
+        For example:
+
+        ```Python
+        heroes = await session.execute(select(Hero)).scalars().all()
+        ```
+
+        instead you could use `exec()`:
+
+        ```Python
+        heroes = await session.exec(select(Hero)).all()
+        ```
+
+  await s.execute(text('DROP TRIGGER IF EXISTS e2e_f086_snapshot_finalize'))
+2026-10-21 04:00:14.858 | INFO     | bisheng.common.services.metric_log:emit_metric:94 - BS_METRIC domain=db_pool engine=async checked_out=1 idle=0 size=100 capacity=120 at_capacity=0
+/suite/f086_snapshot_fault.py:12: DeprecationWarning:
+        🚨 You probably want to use `session.exec()` instead of `session.execute()`.
+
+        This is the original SQLAlchemy `session.execute()` method that returns objects
+        of type `Row`, and that you have to call `scalars()` to get the model objects.
+
+        For example:
+
+        ```Python
+        heroes = await session.execute(select(Hero)).scalars().all()
+        ```
+
+        instead you could use `exec()`:
+
+        ```Python
+        heroes = await session.exec(select(Hero)).all()
+        ```
+
+  if enable:await s.execute(text("CREATE TRIGGER e2e_f086_snapshot_finalize BEFORE UPDATE ON runtime_artifact FOR EACH ROW BEGIN IF NEW.app_id = '"+app+"' AND NEW.kind = 'snapshot' AND NEW.state = 'verified' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'e2e_f086_snapshot_confirmation_fault'; END IF; END"))
+2026-10-21 04:00:14.866 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:00:14.992 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:00:14.996 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:00:15.002 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'minio'
+PASS object saved but SQL unconfirmed; DDL blocked, route closed, old data retained
+2026-10-21 04:00:40.479 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:00:40.488 | INFO     | bisheng.common.services.metric_log:emit_metric:94 - BS_METRIC domain=db_query_agg window_s=25.629 count=6 sum_ms=18.421 le_5=4 le_10=6 le_25=6 le_50=6 le_100=6 le_250=6 le_500=6 le_1000=6 le_inf=6
+2026-10-21 04:00:40.488 | INFO     | bisheng.common.services.metric_log:emit_metric:94 - BS_METRIC domain=db_pool engine=async checked_out=1 idle=0 size=100 capacity=120 at_capacity=0
+PASS manual retry confirmed original snapshot, migrated once, same approval, online version2
+2026-10-21 04:00:43.757 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'openfga'
+2026-10-21 04:00:43.758 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'prompts'
+2026-10-21 04:00:43.758 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'prompts' closed successfully
+2026-10-21 04:00:43.758 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'prompts'
+2026-10-21 04:00:43.758 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'http_client'
+2026-10-21 04:00:43.758 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'http_client' closed successfully
+2026-10-21 04:00:43.758 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'http_client'
+2026-10-21 04:00:43.758 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'statistics_elasticsearch'
+2026-10-21 04:00:43.758 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'statistics_elasticsearch' closed successfully
+2026-10-21 04:00:43.758 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'statistics_elasticsearch'
+2026-10-21 04:00:43.758 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'elasticsearch'
+2026-10-21 04:00:43.758 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'elasticsearch' closed successfully
+2026-10-21 04:00:43.758 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'elasticsearch'
+2026-10-21 04:00:43.758 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'minio'
+Unclosed client session
+client_session: 
+2026-10-21 04:00:43.760 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'minio' closed successfully
+2026-10-21 04:00:43.760 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'minio'
+2026-10-21 04:00:43.760 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'redis'
+2026-10-21 04:00:43.760 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'redis' closed successfully
+2026-10-21 04:00:43.760 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'redis'
+2026-10-21 04:00:43.761 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'database'
+2026-10-21 04:00:43.761 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'database' closed successfully
+2026-10-21 04:00:43.761 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'database'
+2026-10-21 04:00:43.762 | INFO     | bisheng.core.context.manager:async_close:286 - Application context closed successfully
+{"app_id": "8411f09dd5f94b249d272492f8536812", "operation": {"id": "939502f3817c4ec6b1eb57818645cd06", "kind": "deploy", "stage": "failed", "status": "failed", "cleanup_status": "complete", "failure": {"reason": "execution_failed", "maintenance": true, "failed_stage": "maintenance", "diagnostic_tail": "", "data_may_have_changed": false}, "version_id": "a53d95479b6640cea612be489c3456a1", "generation": 2}, "unconfirmed_artifact": {"id": "4adf3776186f453e92e982e0aa46b8a2", "state": "uploading", "confirmed_at": null, "object_key": "tenants/2/apps/8411f09dd5f94b249d272492f8536812/snapshots/939502f3817c4ec6b1eb57818645cd06/4adf3776186f453e92e982e0aa46b8a2.sqlite", "object_size": 8192}, "state": "online", "entry_status": 503, "notes_table_absent": true, "value_retained": true, "current_version_unchanged": true, "retry": {"version_id": "a53d95479b6640cea612be489c3456a1", "entry": {"version": 2, "value": "retained"}, "snapshots_confirmed": 1, "same_approval_instance": 2}}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/timeout-confirm.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/timeout-confirm.log
new file mode 100644
index 0000000000..0d8dd1e4e2
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/timeout-confirm.log
@@ -0,0 +1,78 @@
+bash: warning: setlocale: LC_ALL: cannot change locale (C.UTF-8)
+2026-10-21 03:57:08.736 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading database_url from /run/f086-config.yaml
+2026-10-21 03:57:08.736 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading redis_url from /run/f086-config.yaml
+2026-10-21 03:57:08.736 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading celery_redis_url from /run/f086-config.yaml
+2026-10-21 03:57:08.736 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading multi_tenant from /run/f086-config.yaml
+2026-10-21 03:57:08.736 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading open_platform from /run/f086-config.yaml
+2026-10-21 03:57:08.736 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading openfga from /run/f086-config.yaml
+2026-10-21 03:57:08.736 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading app_runtime from /run/f086-config.yaml
+2026-10-21 03:57:08.736 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading object_storage from /run/f086-config.yaml
+2026-10-21 03:57:08.740 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'database'
+2026-10-21 03:57:08.740 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'database' with dependencies: []
+2026-10-21 03:57:08.740 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'redis'
+2026-10-21 03:57:08.740 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'redis' with dependencies: []
+2026-10-21 03:57:09.153 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'minio'
+2026-10-21 03:57:09.153 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'minio' with dependencies: []
+2026-10-21 03:57:09.153 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'elasticsearch'
+2026-10-21 03:57:09.153 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'elasticsearch' with dependencies: []
+2026-10-21 03:57:09.153 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'statistics_elasticsearch'
+2026-10-21 03:57:09.153 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'statistics_elasticsearch' with dependencies: []
+2026-10-21 03:57:09.155 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'http_client'
+2026-10-21 03:57:09.155 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'http_client' with dependencies: []
+2026-10-21 03:57:09.158 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'prompts'
+2026-10-21 03:57:09.158 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'prompts' with dependencies: []
+2026-10-21 03:57:09.163 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'openfga'
+2026-10-21 03:57:09.163 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'openfga' with dependencies: []
+2026-10-21 03:57:09.163 | DEBUG    | bisheng.core.context.manager:_register_default_contexts:123 - Default contexts registered
+2026-10-21 03:57:09.163 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'database' (attempt 1/3)
+None of PyTorch, TensorFlow >= 2.0, or Flax have been found. Models won't be available and only tokenizers, configuration and file/data utilities can be used.
+2026-10-21 03:57:15.741 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'database' initialized successfully
+2026-10-21 03:57:15.741 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 03:57:15.741 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'redis' (attempt 1/3)
+2026-10-21 03:57:15.741 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'redis' initialized successfully
+2026-10-21 03:57:15.742 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'redis'
+2026-10-21 03:57:15.742 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'minio' (attempt 1/3)
+2026-10-21 03:57:15.763 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'minio' initialized successfully
+2026-10-21 03:57:15.763 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'minio'
+2026-10-21 03:57:15.763 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'elasticsearch' (attempt 1/3)
+2026-10-21 03:57:15.763 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'elasticsearch' initialized successfully
+2026-10-21 03:57:15.763 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'elasticsearch'
+2026-10-21 03:57:15.763 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'statistics_elasticsearch' (attempt 1/3)
+2026-10-21 03:57:15.763 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'statistics_elasticsearch' initialized successfully
+2026-10-21 03:57:15.763 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'statistics_elasticsearch'
+2026-10-21 03:57:15.763 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'http_client' (attempt 1/3)
+2026-10-21 03:57:15.764 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'http_client' initialized successfully
+2026-10-21 03:57:15.764 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'http_client'
+2026-10-21 03:57:15.764 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'prompts' (attempt 1/3)
+2026-10-21 03:57:15.774 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'prompts' initialized successfully
+2026-10-21 03:57:15.774 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'prompts'
+2026-10-21 03:57:15.774 | INFO     | bisheng.core.context.manager:initialize:57 - Application context initialized successfully
+2026-10-21 03:57:15.774 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 03:57:15.953 | INFO     | bisheng.common.services.metric_log:emit_metric:94 - BS_METRIC domain=db_pool engine=async checked_out=1 idle=0 size=100 capacity=120 at_capacity=0
+2026-10-21 03:57:15.998 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+timeout PASS asynchronous cleanup confirmed, terminal timed_out, all reservations released, no approval
+2026-10-21 03:57:16.004 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'openfga'
+2026-10-21 03:57:16.005 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'prompts'
+2026-10-21 03:57:16.005 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'prompts' closed successfully
+2026-10-21 03:57:16.005 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'prompts'
+2026-10-21 03:57:16.005 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'http_client'
+2026-10-21 03:57:16.005 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'http_client' closed successfully
+2026-10-21 03:57:16.005 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'http_client'
+2026-10-21 03:57:16.005 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'statistics_elasticsearch'
+2026-10-21 03:57:16.005 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'statistics_elasticsearch' closed successfully
+2026-10-21 03:57:16.005 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'statistics_elasticsearch'
+2026-10-21 03:57:16.005 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'elasticsearch'
+2026-10-21 03:57:16.005 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'elasticsearch' closed successfully
+2026-10-21 03:57:16.005 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'elasticsearch'
+2026-10-21 03:57:16.005 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'minio'
+Unclosed client session
+client_session: 
+2026-10-21 03:57:16.007 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'minio' closed successfully
+2026-10-21 03:57:16.007 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'minio'
+2026-10-21 03:57:16.007 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'redis'
+2026-10-21 03:57:16.007 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'redis' closed successfully
+2026-10-21 03:57:16.007 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'redis'
+2026-10-21 03:57:16.007 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'database'
+2026-10-21 03:57:16.008 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'database' closed successfully
+2026-10-21 03:57:16.008 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'database'
+2026-10-21 03:57:16.008 | INFO     | bisheng.core.context.manager:async_close:286 - Application context closed successfully
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/upgrade-apply-repeat.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/upgrade-apply-repeat.log
new file mode 100644
index 0000000000..119062aea0
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/upgrade-apply-repeat.log
@@ -0,0 +1,3 @@
+bash: warning: setlocale: LC_ALL: cannot change locale (C.UTF-8)
+{"inventory_sha256": "13458d7c8062461e95eaa5ac631b0707bdbd8556d6310559bc9162c915f9f794", "applications": 1, "applied": true}
+PASS production upgrade CLI apply
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/upgrade-apply.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/upgrade-apply.log
new file mode 100644
index 0000000000..119062aea0
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/upgrade-apply.log
@@ -0,0 +1,3 @@
+bash: warning: setlocale: LC_ALL: cannot change locale (C.UTF-8)
+{"inventory_sha256": "13458d7c8062461e95eaa5ac631b0707bdbd8556d6310559bc9162c915f9f794", "applications": 1, "applied": true}
+PASS production upgrade CLI apply
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/upgrade-dry-run.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/upgrade-dry-run.log
new file mode 100644
index 0000000000..6322447b55
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/upgrade-dry-run.log
@@ -0,0 +1,3 @@
+bash: warning: setlocale: LC_ALL: cannot change locale (C.UTF-8)
+{"inventory_sha256": "13458d7c8062461e95eaa5ac631b0707bdbd8556d6310559bc9162c915f9f794", "applications": 1, "applied": false}
+PASS production upgrade CLI dry-run
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/upgrade-inventory.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/upgrade-inventory.log
new file mode 100644
index 0000000000..9a217f1470
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/upgrade-inventory.log
@@ -0,0 +1,75 @@
+bash: warning: setlocale: LC_ALL: cannot change locale (C.UTF-8)
+2026-10-21 04:16:47.008 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading database_url from /run/f086-config.yaml
+2026-10-21 04:16:47.009 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading redis_url from /run/f086-config.yaml
+2026-10-21 04:16:47.009 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading celery_redis_url from /run/f086-config.yaml
+2026-10-21 04:16:47.009 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading multi_tenant from /run/f086-config.yaml
+2026-10-21 04:16:47.009 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading open_platform from /run/f086-config.yaml
+2026-10-21 04:16:47.009 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading openfga from /run/f086-config.yaml
+2026-10-21 04:16:47.009 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading app_runtime from /run/f086-config.yaml
+2026-10-21 04:16:47.009 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading object_storage from /run/f086-config.yaml
+2026-10-21 04:16:47.014 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'database'
+2026-10-21 04:16:47.014 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'database' with dependencies: []
+2026-10-21 04:16:47.014 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'redis'
+2026-10-21 04:16:47.014 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'redis' with dependencies: []
+2026-10-21 04:16:47.453 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'minio'
+2026-10-21 04:16:47.453 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'minio' with dependencies: []
+2026-10-21 04:16:47.453 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'elasticsearch'
+2026-10-21 04:16:47.453 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'elasticsearch' with dependencies: []
+2026-10-21 04:16:47.453 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'statistics_elasticsearch'
+2026-10-21 04:16:47.453 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'statistics_elasticsearch' with dependencies: []
+2026-10-21 04:16:47.456 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'http_client'
+2026-10-21 04:16:47.456 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'http_client' with dependencies: []
+2026-10-21 04:16:47.458 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'prompts'
+2026-10-21 04:16:47.458 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'prompts' with dependencies: []
+2026-10-21 04:16:47.463 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'openfga'
+2026-10-21 04:16:47.464 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'openfga' with dependencies: []
+2026-10-21 04:16:47.464 | DEBUG    | bisheng.core.context.manager:_register_default_contexts:123 - Default contexts registered
+2026-10-21 04:16:47.464 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'database' (attempt 1/3)
+None of PyTorch, TensorFlow >= 2.0, or Flax have been found. Models won't be available and only tokenizers, configuration and file/data utilities can be used.
+2026-10-21 04:16:54.111 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'database' initialized successfully
+2026-10-21 04:16:54.111 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:16:54.111 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'redis' (attempt 1/3)
+2026-10-21 04:16:54.111 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'redis' initialized successfully
+2026-10-21 04:16:54.111 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'redis'
+2026-10-21 04:16:54.112 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'minio' (attempt 1/3)
+2026-10-21 04:16:54.131 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'minio' initialized successfully
+2026-10-21 04:16:54.131 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'minio'
+2026-10-21 04:16:54.131 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'elasticsearch' (attempt 1/3)
+2026-10-21 04:16:54.131 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'elasticsearch' initialized successfully
+2026-10-21 04:16:54.131 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'elasticsearch'
+2026-10-21 04:16:54.131 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'statistics_elasticsearch' (attempt 1/3)
+2026-10-21 04:16:54.131 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'statistics_elasticsearch' initialized successfully
+2026-10-21 04:16:54.131 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'statistics_elasticsearch'
+2026-10-21 04:16:54.131 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'http_client' (attempt 1/3)
+2026-10-21 04:16:54.132 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'http_client' initialized successfully
+2026-10-21 04:16:54.132 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'http_client'
+2026-10-21 04:16:54.132 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'prompts' (attempt 1/3)
+2026-10-21 04:16:54.142 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'prompts' initialized successfully
+2026-10-21 04:16:54.142 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'prompts'
+2026-10-21 04:16:54.142 | INFO     | bisheng.core.context.manager:initialize:57 - Application context initialized successfully
+PASS real Engine legacy inventory, one scoped application; no env exported
+2026-10-21 04:16:57.641 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'openfga'
+2026-10-21 04:16:57.641 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'prompts'
+2026-10-21 04:16:57.641 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'prompts' closed successfully
+2026-10-21 04:16:57.641 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'prompts'
+2026-10-21 04:16:57.641 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'http_client'
+2026-10-21 04:16:57.641 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'http_client' closed successfully
+2026-10-21 04:16:57.641 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'http_client'
+2026-10-21 04:16:57.641 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'statistics_elasticsearch'
+2026-10-21 04:16:57.641 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'statistics_elasticsearch' closed successfully
+2026-10-21 04:16:57.641 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'statistics_elasticsearch'
+2026-10-21 04:16:57.641 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'elasticsearch'
+2026-10-21 04:16:57.641 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'elasticsearch' closed successfully
+2026-10-21 04:16:57.641 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'elasticsearch'
+2026-10-21 04:16:57.641 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'minio'
+Unclosed client session
+client_session: 
+2026-10-21 04:16:57.643 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'minio' closed successfully
+2026-10-21 04:16:57.643 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'minio'
+2026-10-21 04:16:57.643 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'redis'
+2026-10-21 04:16:57.643 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'redis' closed successfully
+2026-10-21 04:16:57.643 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'redis'
+2026-10-21 04:16:57.643 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'database'
+2026-10-21 04:16:57.643 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'database' closed successfully
+2026-10-21 04:16:57.643 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'database'
+2026-10-21 04:16:57.644 | INFO     | bisheng.core.context.manager:async_close:286 - Application context closed successfully
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/upgrade-rebind.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/upgrade-rebind.log
new file mode 100644
index 0000000000..0a4bea0e39
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/upgrade-rebind.log
@@ -0,0 +1,78 @@
+bash: warning: setlocale: LC_ALL: cannot change locale (C.UTF-8)
+2026-10-21 04:20:00.346 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading database_url from /run/f086-config.yaml
+2026-10-21 04:20:00.346 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading redis_url from /run/f086-config.yaml
+2026-10-21 04:20:00.346 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading celery_redis_url from /run/f086-config.yaml
+2026-10-21 04:20:00.346 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading multi_tenant from /run/f086-config.yaml
+2026-10-21 04:20:00.346 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading open_platform from /run/f086-config.yaml
+2026-10-21 04:20:00.346 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading openfga from /run/f086-config.yaml
+2026-10-21 04:20:00.346 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading app_runtime from /run/f086-config.yaml
+2026-10-21 04:20:00.347 | DEBUG    | bisheng.common.services.config_service:load_settings_from_yaml:108 - Loading object_storage from /run/f086-config.yaml
+2026-10-21 04:20:00.352 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'database'
+2026-10-21 04:20:00.352 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'database' with dependencies: []
+2026-10-21 04:20:00.352 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'redis'
+2026-10-21 04:20:00.352 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'redis' with dependencies: []
+2026-10-21 04:20:00.825 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'minio'
+2026-10-21 04:20:00.825 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'minio' with dependencies: []
+2026-10-21 04:20:00.826 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'elasticsearch'
+2026-10-21 04:20:00.826 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'elasticsearch' with dependencies: []
+2026-10-21 04:20:00.826 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'statistics_elasticsearch'
+2026-10-21 04:20:00.826 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'statistics_elasticsearch' with dependencies: []
+2026-10-21 04:20:00.829 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'http_client'
+2026-10-21 04:20:00.829 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'http_client' with dependencies: []
+2026-10-21 04:20:00.833 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'prompts'
+2026-10-21 04:20:00.833 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'prompts' with dependencies: []
+2026-10-21 04:20:00.841 | DEBUG    | bisheng.core.context.base:register:480 - Registered context: 'openfga'
+2026-10-21 04:20:00.841 | DEBUG    | bisheng.core.context.manager:register_context:232 - Registered context 'openfga' with dependencies: []
+2026-10-21 04:20:00.841 | DEBUG    | bisheng.core.context.manager:_register_default_contexts:123 - Default contexts registered
+2026-10-21 04:20:00.841 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'database' (attempt 1/3)
+None of PyTorch, TensorFlow >= 2.0, or Flax have been found. Models won't be available and only tokenizers, configuration and file/data utilities can be used.
+2026-10-21 04:20:07.558 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'database' initialized successfully
+2026-10-21 04:20:07.558 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:20:07.558 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'redis' (attempt 1/3)
+2026-10-21 04:20:07.559 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'redis' initialized successfully
+2026-10-21 04:20:07.559 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'redis'
+2026-10-21 04:20:07.559 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'minio' (attempt 1/3)
+2026-10-21 04:20:07.579 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'minio' initialized successfully
+2026-10-21 04:20:07.580 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'minio'
+2026-10-21 04:20:07.580 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'elasticsearch' (attempt 1/3)
+2026-10-21 04:20:07.580 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'elasticsearch' initialized successfully
+2026-10-21 04:20:07.580 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'elasticsearch'
+2026-10-21 04:20:07.580 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'statistics_elasticsearch' (attempt 1/3)
+2026-10-21 04:20:07.580 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'statistics_elasticsearch' initialized successfully
+2026-10-21 04:20:07.580 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'statistics_elasticsearch'
+2026-10-21 04:20:07.580 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'http_client' (attempt 1/3)
+2026-10-21 04:20:07.580 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'http_client' initialized successfully
+2026-10-21 04:20:07.580 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'http_client'
+2026-10-21 04:20:07.580 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:155 - Initializing context 'prompts' (attempt 1/3)
+2026-10-21 04:20:07.591 | DEBUG    | bisheng.core.context.base:_perform_initialization_async:160 - Context 'prompts' initialized successfully
+2026-10-21 04:20:07.592 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'prompts'
+2026-10-21 04:20:07.592 | INFO     | bisheng.core.context.manager:initialize:57 - Application context initialized successfully
+2026-10-21 04:20:21.932 | DEBUG    | bisheng.core.context.manager:_initialize_context_with_dependencies:160 - Initialized context: 'database'
+2026-10-21 04:20:22.127 | INFO     | bisheng.common.services.metric_log:emit_metric:94 - BS_METRIC domain=db_pool engine=async checked_out=1 idle=0 size=100 capacity=120 at_capacity=0
+PASS real rebind, original code101 + pending102 + immutable versions + SDK0.1.1 {"app_id": "7dcca9caca5b4a2b8d50405717ddcc5c", "current_version_id": "ea1cf255b9344560837b5d7b2722b43e", "pending_version_id": "7e3c58d636b8461eb6e81d79f3acecfa", "entry": {"version": 101, "value": "retained"}, "sdk_011": "passed", "historical_versions_unchanged": true, "historical_version_count": 2, "frozen_version_sha256": "0b8a4a80cb01481609b90f901a6cce8e8c887f46473f069a1ba533f8e1faa055", "delivery_mode": "source-image", "node_id": "e2e-f086-node"}
+2026-10-21 04:20:22.137 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'openfga'
+2026-10-21 04:20:22.138 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'prompts'
+2026-10-21 04:20:22.138 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'prompts' closed successfully
+2026-10-21 04:20:22.138 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'prompts'
+2026-10-21 04:20:22.138 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'http_client'
+2026-10-21 04:20:22.138 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'http_client' closed successfully
+2026-10-21 04:20:22.138 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'http_client'
+2026-10-21 04:20:22.138 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'statistics_elasticsearch'
+2026-10-21 04:20:22.138 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'statistics_elasticsearch' closed successfully
+2026-10-21 04:20:22.138 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'statistics_elasticsearch'
+2026-10-21 04:20:22.138 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'elasticsearch'
+2026-10-21 04:20:22.138 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'elasticsearch' closed successfully
+2026-10-21 04:20:22.138 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'elasticsearch'
+2026-10-21 04:20:22.138 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'minio'
+Unclosed client session
+client_session: 
+2026-10-21 04:20:22.140 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'minio' closed successfully
+2026-10-21 04:20:22.141 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'minio'
+2026-10-21 04:20:22.141 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'redis'
+2026-10-21 04:20:22.141 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'redis' closed successfully
+2026-10-21 04:20:22.141 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'redis'
+2026-10-21 04:20:22.141 | DEBUG    | bisheng.core.context.base:async_close:259 - Closing context 'database'
+2026-10-21 04:20:22.142 | DEBUG    | bisheng.core.context.base:async_close:262 - Context 'database' closed successfully
+2026-10-21 04:20:22.142 | DEBUG    | bisheng.core.context.manager:_close_contexts_in_reverse_order:300 - Closed context: 'database'
+2026-10-21 04:20:22.142 | INFO     | bisheng.core.context.manager:async_close:286 - Application context closed successfully
+{"app_id": "7dcca9caca5b4a2b8d50405717ddcc5c", "current_version_id": "ea1cf255b9344560837b5d7b2722b43e", "pending_version_id": "7e3c58d636b8461eb6e81d79f3acecfa", "entry": {"version": 101, "value": "retained"}, "sdk_011": "passed", "historical_versions_unchanged": true, "historical_version_count": 2, "frozen_version_sha256": "0b8a4a80cb01481609b90f901a6cce8e8c887f46473f069a1ba533f8e1faa055", "delivery_mode": "source-image", "node_id": "e2e-f086-node"}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/upgrade-retained-attachment.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/upgrade-retained-attachment.log
new file mode 100644
index 0000000000..48f2d405e7
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/upgrade-retained-attachment.log
@@ -0,0 +1 @@
+PASS persisted legacy attachment readable through backend storage endpoint
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/upgrade-rollback-check.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/upgrade-rollback-check.log
new file mode 100644
index 0000000000..3b5590c598
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/upgrade-rollback-check.log
@@ -0,0 +1,2 @@
+bash: warning: setlocale: LC_ALL: cannot change locale (C.UTF-8)
+PASS pre-import rollback restored old service, current101 + pending102 unchanged, SDK0.1.1 CRUD
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/ws-backend-failclosed.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/ws-backend-failclosed.log
new file mode 100644
index 0000000000..a7636c52de
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/ws-backend-failclosed.log
@@ -0,0 +1,2 @@
+bash: warning: setlocale: LC_ALL: cannot change locale (C.UTF-8)
+PASS open WebSocket closed within5s after backend unavailable {"scenario": "backend_unavailable", "closed_after_seconds": 4.013150930404663, "close_code": 4503, "new_http_status": 503}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/ws-permission-revoke.log b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/ws-permission-revoke.log
new file mode 100644
index 0000000000..687d02ba31
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/logs/ws-permission-revoke.log
@@ -0,0 +1,2 @@
+bash: warning: setlocale: LC_ALL: cannot change locale (C.UTF-8)
+PASS actual grant removal closes existing WebSocket within5s {"closed_after_seconds": 4.028708160854876, "close_code": 4403, "new_http_status": 403, "owner_still_serving": true}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/manager-existing-connections.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/manager-existing-connections.json
new file mode 100644
index 0000000000..a029d0e62f
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/manager-existing-connections.json
@@ -0,0 +1,5 @@
+{
+  "existing_websocket": "survived",
+  "new_http_and_sdk_checks": 32,
+  "duration_seconds": 17
+}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/migration-startup-failure.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/migration-startup-failure.json
new file mode 100644
index 0000000000..b346b16ea7
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/migration-startup-failure.json
@@ -0,0 +1,30 @@
+{
+  "operation": {
+    "id": "6a1d9260b583437d9c4fead315cdc39a",
+    "kind": "deploy",
+    "stage": "failed",
+    "status": "failed",
+    "cleanup_status": "complete",
+    "failure": {
+      "reason": "startup_exited",
+      "maintenance": true,
+      "failed_stage": "starting",
+      "diagnostic_tail": "",
+      "data_may_have_changed": true
+    },
+    "version_id": "2733ef13fab84fd894728e5cd5ae08e3",
+    "generation": 4
+  },
+  "state": "online",
+  "current_version_unchanged": true,
+  "new_table_persisted": true,
+  "production_data_retained": true,
+  "confirmed_snapshots": 2,
+  "roll_forward": {
+    "version_id": "346df0d3cb8049008982dfe490944140",
+    "entry": {
+      "version": 4,
+      "value": "production-marker"
+    }
+  }
+}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/mt-baseline.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/mt-baseline.json
new file mode 100644
index 0000000000..665c989631
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/mt-baseline.json
@@ -0,0 +1,9 @@
+{
+  "app_id": "8411f09dd5f94b249d272492f8536812",
+  "tenant_id": 2,
+  "version_id": "c39aee3a6749418d8f30e707df605e57",
+  "entry": {
+    "version": 1,
+    "value": "retained"
+  }
+}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/normalization.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/normalization.json
new file mode 100644
index 0000000000..c11fbc8861
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/normalization.json
@@ -0,0 +1,15 @@
+{
+  "change": "trim trailing spaces/tabs and ensure newline; no payload/test assertion changes",
+  "files": [
+    {
+      "file": "docker-cleanup.json",
+      "original_sha256": "4df80e8bb9764aef0b0f64383cb37e8b6ef88b7dd590276844d6d4511b8594ac",
+      "normalized_sha256": "4ac5d21a57bf5240186cfea43323bf1d6a05727986010cc6d9c28da8e0c9d147"
+    },
+    {
+      "file": "logs/bootstrap-before.log",
+      "original_sha256": "043a40fe4c5c9163fdf523a6df1fbab9febdb076af746f96b98eb08a03ffc67f",
+      "normalized_sha256": "e5836e984b153c35bed972ea87ea851b4d24bf056ce97d1afa8fde5c6a802360"
+    }
+  ]
+}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/protected-backup-facts.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/protected-backup-facts.json
new file mode 100644
index 0000000000..6162fc3c15
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/protected-backup-facts.json
@@ -0,0 +1 @@
+[{"app_id": "8411f09dd5f94b249d272492f8536812", "operation_id": "939502f3817c4ec6b1eb57818645cd06", "size": 8192, "sha256": "c69009ce0540e6f7bb66750d6ed10cd4026de26f5c96580bbfbc362f3d7be8dd"}, {"app_id": "8411f09dd5f94b249d272492f8536812", "operation_id": "6f768c45fc454838a128d5a84fec71f2", "size": 8192, "sha256": "c69009ce0540e6f7bb66750d6ed10cd4026de26f5c96580bbfbc362f3d7be8dd"}, {"app_id": "8411f09dd5f94b249d272492f8536812", "operation_id": "0f09f70f5eb141f19944816d77349872", "size": 24576, "sha256": "c5c81d88116b4fc3bbdae6eb81c50cd1d2c5a0f6ca9e28f391774e0eeba67fa7"}, {"app_id": "8411f09dd5f94b249d272492f8536812", "operation_id": "6a1d9260b583437d9c4fead315cdc39a", "size": 20480, "sha256": "71765c7914fc0260a209dcdbedf1dfe86ac02e3e3aa84fe00689539d2cd2de1a"}]
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/quality-and-startup-observation.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/quality-and-startup-observation.json
new file mode 100644
index 0000000000..3c06d918df
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/quality-and-startup-observation.json
@@ -0,0 +1,28 @@
+{
+  "checkpoint_sha": "5131b81194a5fa390593e58d822bcbde52b6ca64",
+  "followup_changes": "uncommitted",
+  "ruff": "passed --no-cache (6 Python files)",
+  "arch_guard": "passed (6 Python files)",
+  "regression": {
+    "passed": 56,
+    "failed": 0
+  },
+  "implementation_hashes": {
+    "src/backend/bisheng/core/context/tenant.py": "6cd240901bab1b9fc8983044ea4e3fe1eb6d31a908304efba6417ef1b5ddf62a",
+    "src/backend/bisheng/permission/application/system_resource_reconcile.py": "48a3ec8b62cca4d97a99eeab896505fbe2a21f7d167b2e4e36f43ccd85c742cf",
+    "src/backend/bisheng/api/services/f048_preset_dashboard_bootstrap.py": "80d0118a2065f2e1e2e69d50a48a512f810d6c43ac40428ec0454e805734ad30",
+    "src/backend/test/permission/test_f048_system_resource_reconcile.py": "fb2ef0cf3b43b570db6809fd274c81c25a25c62cf80b24f8a8081ddff7c26aad",
+    "src/backend/test/permission/test_f048_preset_dashboard_bootstrap.py": "053da8023afe2981354d2555851b1cdaf307c79cf2415adef66c24cb4efe3a41",
+    "src/backend/test/tenant/test_tenant_execution_scope.py": "582700a8ff1a426c53291fc16928ff0516e6d164aa63ea9d393198b0233032ea"
+  },
+  "fresh_start_observation": {
+    "observed_in_session_before_container_replacement": true,
+    "multi_tenant_enabled": true,
+    "system_resources_current": 18,
+    "system_resources_missing_before": 18,
+    "preset_dashboard_owner_ready_before_registration": false,
+    "public_key_http_status": 200,
+    "natural_first_admin_registration_status": 200,
+    "note": "Initial container logs were inspected in session, then replaced during coordinated upgrade; this is a recorded observation, not a retained full startup log."
+  }
+}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/restart-probe-after-ids.txt b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/restart-probe-after-ids.txt
new file mode 100644
index 0000000000..899784c1c7
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/restart-probe-after-ids.txt
@@ -0,0 +1 @@
+969ee5b6e53b
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/restart-probe-before-ids.txt b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/restart-probe-before-ids.txt
new file mode 100644
index 0000000000..899784c1c7
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/restart-probe-before-ids.txt
@@ -0,0 +1 @@
+969ee5b6e53b
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/restart-probe-result.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/restart-probe-result.json
new file mode 100644
index 0000000000..55e5805f25
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/restart-probe-result.json
@@ -0,0 +1,41 @@
+{
+  "deployment": {
+    "deployment_id": "8e261f9a12684e16b13a497497c43d2d",
+    "app_id": "749df2ba5e7c46689acd28f13fc62304",
+    "version_id": "dc69ad803628482db2ad0f15d52a88b1",
+    "version_no": 1,
+    "stage": "approval_created",
+    "status": "waiting_approval",
+    "failure": null,
+    "approval": {
+      "instance_id": 4,
+      "status": "pending",
+      "version_id": "dc69ad803628482db2ad0f15d52a88b1",
+      "submitted_at": "2026-10-21T04:02:00",
+      "decided_at": null,
+      "reject_reason": null,
+      "approver_names": [
+        "e2e-f086-approver"
+      ]
+    },
+    "app_state": "draft",
+    "entry_url": "http://nginx:3001/apps/e2e-f086-restart",
+    "scan_result": {
+      "hits": [],
+      "blocked": false,
+      "skipped": [],
+      "files_scanned": 3,
+      "files_skipped": 0
+    }
+  },
+  "operation": {
+    "id": "2490b1d0b3ba4c708ea588b1ed651549",
+    "kind": "probe",
+    "stage": "completed",
+    "status": "completed",
+    "cleanup_status": "complete",
+    "failure": {},
+    "version_id": "dc69ad803628482db2ad0f15d52a88b1",
+    "generation": 1
+  }
+}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/scratch-cleanup-facts.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/scratch-cleanup-facts.json
new file mode 100644
index 0000000000..2e2363ba22
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/scratch-cleanup-facts.json
@@ -0,0 +1 @@
+{"task_directories": [], "preview_directories": []}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/sha256-export.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/sha256-export.json
new file mode 100644
index 0000000000..2b2ed6e13a
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/sha256-export.json
@@ -0,0 +1,58 @@
+{
+  ".gitignore": "a4e6b5adcc8e252c6e820a9a56a20a6249b18222a616090634ca5ab06f483a5a",
+  "README.md": "74fa8ee74fe07988b4090dd14800747191444adb8a1f8e61d981e1bba1c1a525",
+  "cancel-timeout-results.json": "9cf30cb4b11aad5c685cfd15ef4bbfdc3123f3334044000115d4641b5cee0867",
+  "compile-timeout-result.json": "b1f38374532a2f7007a0cf9926bb3a8c4394131c170ebcaa7041b515947ac2cc",
+  "connect-policy-revoke.json": "e6c21701d3daeed33c4fb36568be8cc841cb6a6e7ac1992e27da1e714e6e62ac",
+  "deletion-followup.json": "a3c4ced6a96c0a8cddf308e7326dd84cc5ea4be918b29d97796a0cb9ea2cf9cf",
+  "docker-cleanup.json": "4ac5d21a57bf5240186cfea43323bf1d6a05727986010cc6d9c28da8e0c9d147",
+  "install-cancel-result.json": "12f984b1295c18973f1ec68f50332411cd34d8ef6442ce443b28151c1eee67af",
+  "legacy-container-before.txt": "1a55bff1e4fc0821a3cddb2adc17ac3418a6c093dc0775ea4640cc2c2c11cbdf",
+  "legacy-manager-image.json": "bc418fba3a5936e0c9a10bdf51fd4e75e4b353a229ac0b80b02b543327238897",
+  "logs/approval-retry.log": "46172b32a17c3d002e8359f78ceed447ba0ceedf6a322d10787f549d73c5bde8",
+  "logs/bootstrap-before.log": "e5836e984b153c35bed972ea87ea851b4d24bf056ce97d1afa8fde5c6a802360",
+  "logs/bootstrap-fixed.log": "9de2d09895516621dd074d8d6555dca6a0c7707d86bdd70ab1125559d0bf2e1f",
+  "logs/compile-timeout.log": "186a02e18ed0daad559fc290e5f845d51ca290b50904b40dd862ac52f6a6d1f5",
+  "logs/connect-confirm.log": "ccbafa3afc98c77243eae78a5326492aafa5bfb2cb7faf2187db099f7c21eb07",
+  "logs/deletion-followup.log": "8630a61d399cbb7b367790e6e21149a8e17fe7845dc19d7103a3c9bdbc9638b9",
+  "logs/install-cancel.log": "c6e190447a7dd995201a3f20beeb286cd7d9e6871abf0a5074b816b3c49ff7c9",
+  "logs/legacy-pending-confirm.log": "a6a9c6c5546d142a091dcc68afda20ecc7180a762334db29ed49cc04b6c32bea",
+  "logs/legacy-publication.log": "7730d2d07ab724ae3e82c6589dbeb62c4adcc76247ba836ca6117cffb1daa565",
+  "logs/live-boundaries.log": "7086834d382ffe07d561d3c902f8cc2255e7fc6e76cde3a36ff266f20ffd966d",
+  "logs/manager-connect.log": "7be6cbf12ff61752a9f96e9fa120d8c48159ba549086f563a6a13e93d6fc921e",
+  "logs/manager-existing-connections.log": "23d4ec1b73e890ebe13af1b0feb8e35c46ac1e23180d6fd57031b60ece94e924",
+  "logs/migration-startup-failure.log": "2c6191c112630159b98defdce94b3ad7bb51975cfe42b93de815c7b12a62ff37",
+  "logs/mt-online.log": "a93b1f042b039ed458ed07ec59ae47914924907b92ac24cfe3eee7962f6d4ba3",
+  "logs/multi-tenant-identities.log": "4ab4ff8ebdcabce9209c99a203b02293c5d012958c677e24b1cab537e6c5960e",
+  "logs/restart-probe.log": "26339b428eb60ab07b86c35facbf1897269e390276e1b8290cd025d713476f83",
+  "logs/snapshot-fault-confirm.log": "c4d456d6a5ec0eeea025f9b53c0acbb7a93b599acff4238ffed3239641ec1267",
+  "logs/timeout-confirm.log": "b3cfb6fad1983a2985e7fb6009d85bb42b9b6c99ac6d0b257b1d2dec4f433f60",
+  "logs/upgrade-apply-repeat.log": "08f86945fc7b4d381fe9c0d93940ae667b283548995e9db6d2c2c0bec23ed403",
+  "logs/upgrade-apply.log": "08f86945fc7b4d381fe9c0d93940ae667b283548995e9db6d2c2c0bec23ed403",
+  "logs/upgrade-dry-run.log": "efa0eb31e66bcad82999a7045d4c1bfa032b481e882781fd2620b073354901d5",
+  "logs/upgrade-inventory.log": "3d8e323bc3609387abffeb793811724d41f2a0cad7f18f334ca1606a2d9ccff2",
+  "logs/upgrade-rebind.log": "25687ac0cdb519d0568c370941abc0599723e18f4c75a61937cb411c97ecbf36",
+  "logs/upgrade-retained-attachment.log": "5a0c240ed5b24ac14917b5a829784fc7d14c0125493d804e69097a3122e55360",
+  "logs/upgrade-rollback-check.log": "1bb69304f58b9ab3d8e8eed3c92c328ece68a88f373337287866252363fb6e50",
+  "logs/ws-backend-failclosed.log": "edf3ea75d0ca90e6c55bbbe902e6d64c198b7a4e14a436f04b26419ec434aad8",
+  "logs/ws-permission-revoke.log": "c292b1272ae3bd0c6c0f2740ca25614bf718f3f990cf52e64162c19530342649",
+  "manager-existing-connections.json": "f74016c6fc491249f0e022ab080061c80b51032232fd337047766c315a276a5d",
+  "migration-startup-failure.json": "e1f78ee22b08ca516ee54c38df8ecc82b10d656547bd752e53a461781be94e4f",
+  "mt-baseline.json": "2a6279219ca1b752ec1d4dad84cba1ba1af2f6f72e32e5e0a75ef3606ed6e5f9",
+  "normalization.json": "d2f376a2029fed4f39894d7691836e4ae02bfd4d8ebf12b5de9c623f467f93bd",
+  "protected-backup-facts.json": "8eb9ff8609e6566c5b065298b9e9c52e5a9f447d3f660d22abc79201aa4a348b",
+  "quality-and-startup-observation.json": "78976d111bc9715aff6f59ef0c5023cc6b180be27ca497e70ec4180aec60a216",
+  "restart-probe-after-ids.txt": "3d90977135589a05781aad7479d30bc646cb490eb20ee1e78791a700a3d6ad77",
+  "restart-probe-before-ids.txt": "3d90977135589a05781aad7479d30bc646cb490eb20ee1e78791a700a3d6ad77",
+  "restart-probe-result.json": "4787c45c00aac946db6781e32cec28114d1dda35f3ea502083dd53b136814288",
+  "scratch-cleanup-facts.json": "e935c80c7770ba9befa96faf7626cd28c94804ba9a793fa163f0456d4e99a7cb",
+  "smoke-inventory.json": "9e0203c5f9b74579404beb283e7cd5c5f6cc23add0ecc1e5b822f362c6e23973",
+  "snapshot-confirmation-fault.json": "b7aa868b8b342d41891807b0b68d16a4a050f2c9a0228fd6837ccc4e4c94912e",
+  "upgrade-apply.json": "cf27f6f21c0c84c17db1134552a8b84e528d26a76076badc3f92f3ce8b5e924b",
+  "upgrade-dry-run.json": "6a1a89f1c1f9386ba6cda23886f28055f705358c33ebcdbf25572f965ad67f0a",
+  "upgrade-first-apply.json": "bb7654a451b76301d099ba8dfaf66a1daeb21f15c66a137bc63117941169b659",
+  "upgrade-inventory.json": "0cea380418a9f2c082e96f9714fe47572caee36462757a7f9fbe7ce139bc6237",
+  "upgrade-rebind-result.json": "904d2fcfee647aa312f1d993e4b7a41fe0481b4bb199e0a0aa6d7e6c40bee0a5",
+  "ws-backend-failclosed.json": "b073253b3550e3e21d2f6a8ca21da3f100a876e0b8df134f9541b3154ce05361",
+  "ws-permission-revoke.json": "4648bd269a0fe0d099f80518197a5fa5c27522573f23c311701719c031e55767"
+}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/smoke-inventory.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/smoke-inventory.json
new file mode 100644
index 0000000000..7b92dc1877
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/smoke-inventory.json
@@ -0,0 +1,34 @@
+{
+  "images": {
+    "app-node": {
+      "image_id": "sha256:ac95b2f352b17a6dab8733f0ab0f47487953f832995e5121ddd7a4034a43535f",
+      "tag": "e2e-f086-app-node:20261021",
+      "manifest_digest": "sha256:85eb9a622cd2e8d2931761f6e0ccd20ba0878c191cf48ee7e261936e7e1e4f92",
+      "platform": "linux/amd64"
+    },
+    "app-node-preparer": {
+      "image_id": "sha256:ceca4a185dc7ad16c5d6318430d89b8ca04dbc9acdb2d6bf4662b8c4fb5fa11b",
+      "tag": "e2e-f086-app-node-preparer:20261021",
+      "manifest_digest": "sha256:44185c5329adce7ca61214b247c0fdd1424f794511ba89bc355b53e7941fb4c9",
+      "platform": "linux/amd64"
+    },
+    "app-static": {
+      "image_id": "sha256:2df72b011ac0db1a4254cbd8e181aec71673e4f85222de22576f64d3645742a9",
+      "tag": "e2e-f086-app-static:20261021",
+      "manifest_digest": "sha256:98f7a8b90781b55c76ed659272e9f48255fb265f44a305f16147bfc4bf8aff16",
+      "platform": "linux/amd64"
+    },
+    "app-python": {
+      "image_id": "sha256:356862ef889d61162270851952347e78dacd5e1610f85f3b83d68de57ae4fc98",
+      "tag": "e2e-f086-app-python:20261021",
+      "manifest_digest": "sha256:2f0ce080f0237287f79eaed0eff3e6b3546ef64ed725a7656eeca04fedd2a51a",
+      "platform": "linux/amd64"
+    },
+    "app-python-preparer": {
+      "image_id": "sha256:850d68dfba648d0ad8be7b826a1a23df009d0c98d5aac58938a4e05fb1875bda",
+      "tag": "e2e-f086-app-python-preparer:20261021",
+      "manifest_digest": "sha256:b7f9fbb4fea4e38e38aaf1d8effa6c6d1e9c5f6754a4442c3e24513daadd2077",
+      "platform": "linux/amd64"
+    }
+  }
+}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/snapshot-confirmation-fault.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/snapshot-confirmation-fault.json
new file mode 100644
index 0000000000..3762d9460b
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/snapshot-confirmation-fault.json
@@ -0,0 +1,40 @@
+{
+  "app_id": "8411f09dd5f94b249d272492f8536812",
+  "operation": {
+    "id": "939502f3817c4ec6b1eb57818645cd06",
+    "kind": "deploy",
+    "stage": "failed",
+    "status": "failed",
+    "cleanup_status": "complete",
+    "failure": {
+      "reason": "execution_failed",
+      "maintenance": true,
+      "failed_stage": "maintenance",
+      "diagnostic_tail": "",
+      "data_may_have_changed": false
+    },
+    "version_id": "a53d95479b6640cea612be489c3456a1",
+    "generation": 2
+  },
+  "unconfirmed_artifact": {
+    "id": "4adf3776186f453e92e982e0aa46b8a2",
+    "state": "uploading",
+    "confirmed_at": null,
+    "object_key": "tenants/2/apps/8411f09dd5f94b249d272492f8536812/snapshots/939502f3817c4ec6b1eb57818645cd06/4adf3776186f453e92e982e0aa46b8a2.sqlite",
+    "object_size": 8192
+  },
+  "state": "online",
+  "entry_status": 503,
+  "notes_table_absent": true,
+  "value_retained": true,
+  "current_version_unchanged": true,
+  "retry": {
+    "version_id": "a53d95479b6640cea612be489c3456a1",
+    "entry": {
+      "version": 2,
+      "value": "retained"
+    },
+    "snapshots_confirmed": 1,
+    "same_approval_instance": 2
+  }
+}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/upgrade-apply.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/upgrade-apply.json
new file mode 100644
index 0000000000..e74c753006
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/upgrade-apply.json
@@ -0,0 +1,10 @@
+{
+  "inventory_sha256": "13458d7c8062461e95eaa5ac631b0707bdbd8556d6310559bc9162c915f9f794",
+  "applied": true,
+  "results": [
+    {
+      "app_id": "7dcca9caca5b4a2b8d50405717ddcc5c",
+      "already_imported": true
+    }
+  ]
+}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/upgrade-dry-run.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/upgrade-dry-run.json
new file mode 100644
index 0000000000..2e3d0ed9dd
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/upgrade-dry-run.json
@@ -0,0 +1,13 @@
+{
+  "inventory_sha256": "13458d7c8062461e95eaa5ac631b0707bdbd8556d6310559bc9162c915f9f794",
+  "applied": false,
+  "results": [
+    {
+      "app_id": "7dcca9caca5b4a2b8d50405717ddcc5c",
+      "version_id": "ea1cf255b9344560837b5d7b2722b43e",
+      "can_import": true,
+      "running": true,
+      "requires_rebind": true
+    }
+  ]
+}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/upgrade-first-apply.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/upgrade-first-apply.json
new file mode 100644
index 0000000000..ab36ccbfdf
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/upgrade-first-apply.json
@@ -0,0 +1,11 @@
+{
+  "inventory_sha256": "13458d7c8062461e95eaa5ac631b0707bdbd8556d6310559bc9162c915f9f794",
+  "applied": true,
+  "results": [
+    {
+      "app_id": "7dcca9caca5b4a2b8d50405717ddcc5c",
+      "imported": true,
+      "requires_rebind": true
+    }
+  ]
+}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/upgrade-inventory.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/upgrade-inventory.json
new file mode 100644
index 0000000000..7e9c0e0b1c
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/upgrade-inventory.json
@@ -0,0 +1,40 @@
+{
+  "schema_version": 1,
+  "node_id": "e2e-f086-node",
+  "engine_id": "c2fa2d5d-7f72-438c-9c24-705fd91079cd",
+  "applications": [
+    {
+      "app_id": "7dcca9caca5b4a2b8d50405717ddcc5c",
+      "version_id": "ea1cf255b9344560837b5d7b2722b43e",
+      "generation": 1,
+      "desired": "running",
+      "exec_ref": "e74115e12aab5e4d98cf06db089f9c33b49fd7a7322e502e6a13eb7c761b08af",
+      "image_id": "sha256:71e9a7edf37ff93e07d7a07137ad4fea305dd8e266f9e3bbab3eb9c9a65c4c50",
+      "platform": "linux/amd64",
+      "running": true,
+      "labels": {
+        "bisheng.app.id": "7dcca9caca5b4a2b8d50405717ddcc5c",
+        "bisheng.app.slug": "e2e-f086-legacy-upgrade",
+        "bisheng.egress.domains": "",
+        "bisheng.generation": "1",
+        "bisheng.health.path": "/",
+        "bisheng.managed": "true",
+        "bisheng.port": "8080",
+        "bisheng.tier.cpu": "0.5",
+        "bisheng.tier.mem_mb": "1024",
+        "bisheng.version.id": "ea1cf255b9344560837b5d7b2722b43e",
+        "bisheng.version.no": "1"
+      },
+      "tier": {
+        "memory_mb": 1024,
+        "cpu_millicores": 500
+      },
+      "target": {
+        "host": "192.168.128.4",
+        "port": 8080
+      },
+      "storage_namespace": "apps/7dcca9caca5b4a2b8d50405717ddcc5c/",
+      "storage_bucket": "bisheng-apps"
+    }
+  ]
+}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/upgrade-rebind-result.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/upgrade-rebind-result.json
new file mode 100644
index 0000000000..d7d43847f8
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/upgrade-rebind-result.json
@@ -0,0 +1,15 @@
+{
+  "app_id": "7dcca9caca5b4a2b8d50405717ddcc5c",
+  "current_version_id": "ea1cf255b9344560837b5d7b2722b43e",
+  "pending_version_id": "7e3c58d636b8461eb6e81d79f3acecfa",
+  "entry": {
+    "version": 101,
+    "value": "retained"
+  },
+  "sdk_011": "passed",
+  "historical_versions_unchanged": true,
+  "historical_version_count": 2,
+  "frozen_version_sha256": "0b8a4a80cb01481609b90f901a6cce8e8c887f46473f069a1ba533f8e1faa055",
+  "delivery_mode": "source-image",
+  "node_id": "e2e-f086-node"
+}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/ws-backend-failclosed.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/ws-backend-failclosed.json
new file mode 100644
index 0000000000..7475956f4e
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/ws-backend-failclosed.json
@@ -0,0 +1,6 @@
+{
+  "scenario": "backend_unavailable",
+  "closed_after_seconds": 4.013150930404663,
+  "close_code": 4503,
+  "new_http_status": 503
+}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/ws-permission-revoke.json b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/ws-permission-revoke.json
new file mode 100644
index 0000000000..11122305db
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-evidence/2026-10-21-docker/ws-permission-revoke.json
@@ -0,0 +1,6 @@
+{
+  "closed_after_seconds": 4.028708160854876,
+  "close_code": 4403,
+  "new_http_status": 403,
+  "owner_still_serving": true
+}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/e2e-report.md b/features/v3.0.0/086-vibe-runtime-delivery/e2e-report.md
new file mode 100644
index 0000000000..631b30afe9
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/e2e-report.md
@@ -0,0 +1,16 @@
+# F086 E2E 执行记录
+
+**Docker 主链路通过;完整SDD验收仍有待完成门。**
+
+详见[真实部署验收记录](./live-validation.md),逐项范围见[e2e-checklist.md](./e2e-checklist.md)。
+
+- 109实际执行CLI/SAK发布、异步预检、审批/预览、正式上线、HTTP/WS/SDK、结构变更前快照、owner恢复、停用/resume、离线缺包与补包、manager/backend故障和正式删除。
+- 完整应用镜像、v1兼容构建均实际审批上线后通过统一入口访问;错误端口在审批前失败,未留下正式版本。四个测试应用已删除,29条预占全部释放。
+- 真实MySQL同步竞争5项通过;发现的两项数据库并发缺陷已修复。
+- amd64/arm64七个镜像均已原生构建;两侧禁网Python/Node/static及安装钩子/前端编译烟测通过。两侧CPython本地C扩展离线构建、源码变化使材料失效也均已通过,每侧五项场景。
+- 本地6个live权限用例未启用(缺独立跨租户账户),仍为skip;实际旅程使用独立专用平台、自然人用户和测试SAK,不能冒充该6项全部通过。
+- DM8、多租户新装、完整故障矩阵、存量升级和正式CI发行仍待验证;K8s没有实现,不纳入通过项。
+
+109专用测试服务、网络、数据和生成凭据已清理;26个原业务容器ID一致。本机ARM64测试proxy/network已移除,缓存镜像保留。
+
+2026-10-20验收当时没有提交、推送、合并或公开镜像。2026-10-21用户授权先提交、推送当前阶段成果;完整验收与发行状态不因此改变。未访问凭据复制过来的原业务存储;单元/ASGI结果与运行证明分别记录。
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/implementation-files.sha256.json b/features/v3.0.0/086-vibe-runtime-delivery/implementation-files.sha256.json
new file mode 100644
index 0000000000..e5c65e3a48
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/implementation-files.sha256.json
@@ -0,0 +1,135 @@
+{
+  ".github/workflows/runtime-images.yml": "1e8a3934dec406f2b8144f8c62c12ab465a9b5e8f9486dca5938939eff0e98e0",
+  "docker/.env.example": "313c9b252411f7df67702c99b532921db808654eb901a845fe56b620d31bb6ab",
+  "docker/docker-compose.runtime-dev.yml": "677e171fe91cfd2b094cea6a47f23366b1e64d76778c4f65c931b0af2815a60d",
+  "docker/docker-compose.yml": "84959879b5a048f693aa6c8e2087c69676ebb3bac225a9b1d9f6008e7f3839a7",
+  "docker/nginx/conf.d/default.conf": "e288b8876983410432acb19c5b17b1b24839598ca06246fc464f4065ed8d4ea2",
+  "docker/verify-app-runtime-compose.sh": "7a2e3a089f38f432bdb669253c3fe2cc2ad5450c8d2312899772fce31524c49a",
+  "docker/verify_app_runtime_compose.py": "6cff808761e6cdbcd20162ad7b4426b03ec5b69c7d6455ccf93c495e77ac4883",
+  "scripts/build_runtime_images.sh": "fb3ec6ba585a9dff828bcdfa7b21185e669f963ac0b4300a3f9e4468edb688bf",
+  "scripts/smoke_runtime_delivery.py": "a8b6ddd97fdc9eb88f7728819390f74df4a3b111179cf42aaa2c398ba8d1f3c0",
+  "src/app-proxy/Dockerfile": "80313b9b8cb1693167d4d91d832f0bb4c2dda766aab4267a60caf0573b27c798",
+  "src/app-proxy/app_proxy/clients.py": "55a69e0c35788f42d67276f47b58bdf53ed00465aa05d1e602d2a1bc9978b717",
+  "src/app-proxy/app_proxy/config.py": "6e8d35ada2260aecc57b3c377eb45a82e4691ac48f972f59d8656a4957267534",
+  "src/app-proxy/app_proxy/main.py": "673df200dc58c2a3dfb2f5d79debe125d31989a362dc82aec61ec9e2fd3879d6",
+  "src/app-proxy/app_proxy/platform_protocol.py": "6c080c5d6a7a05f0300c7ab9db5f18a697f4bffe068cb4a10badc7b822a068e0",
+  "src/app-proxy/tests/test_platform_route_v2.py": "0afff2147eedacefb42d76201746ac9d209b6fd549dbb0b994d8dc1ee04cf448",
+  "src/app-runtime/common/prepare.py": "3383d864871b19da49026493b7885bbac98fbc0958a20feb9330e6361889be3f",
+  "src/app-runtime/node20/Dockerfile": "f054b11944f69e7e618a57fbc5769629b99b9290aaeb622a3634ae8bd8c5eda8",
+  "src/app-runtime/python3.11/Dockerfile": "cf09128a264fb6bb5d43f016eeb0b470b3cc2b249482bbae0f689fd87e1dbadc",
+  "src/app-runtime/python3.11/requirements.txt": "81f197c289c365297b9def325dbab08a95bb6a965b7a152be2c435b9ed5a8374",
+  "src/app-runtime/static/Dockerfile": "502b33cda8580563db94cbced3004b3693d40e780f23c0411dd310c9e151cfac",
+  "src/app-runtime/static/entrypoint.sh": "1a8abd7e969faa7021550a693e06fec54c51f029edf259672dff6d4ae502453e",
+  "src/app-runtime/static/nginx.conf": "8fd022c87ed92f127992a47e3d4cf1c4dd4788a2ae389e7175c88fe326d7b448",
+  "src/backend/bisheng/api/services/f048_preset_dashboard_bootstrap.py": "80d0118a2065f2e1e2e69d50a48a512f810d6c43ac40428ec0454e805734ad30",
+  "src/backend/bisheng/app_publish/domain/schemas/app_manifest.py": "40576bfba9dfc55a46b4f4dda0ce51fbd207bdae9b5d4ca45ce2a4b2dc5e515c",
+  "src/backend/bisheng/app_publish/domain/services/app_credential_service.py": "0776e2a84cbe859b267fab27388e76ed4df51d3f17eae1de25c30cc3c25ab4de",
+  "src/backend/bisheng/app_publish/domain/services/manifest_validator.py": "380d7140ef220a5ac3c5f3f0318a78dc0823d8d1291c4b513885d2df153500cb",
+  "src/backend/bisheng/app_publish/domain/services/preview_instance_service.py": "338087da58f0cc7c5a37e2b2cec55d165e9208a89705a3c4ee8115b9ec9f8d07",
+  "src/backend/bisheng/app_publish/domain/services/publish_online_service.py": "0e0457324b679ec659dcd14bf98f8eb29b5dba1b0b36fc8b04c7b252bdbc80d8",
+  "src/backend/bisheng/app_publish/domain/services/publish_pipeline_service.py": "92abb0098e8e667550779f0d009baa379fa8ee2d68e4797eecc1ebc90fae6476",
+  "src/backend/bisheng/app_publish/domain/services/version_service.py": "040b29cb6576a2ed6cacdd7eb503e924d922d68cfa0f58fb56380db26255a5af",
+  "src/backend/bisheng/app_runtime/api/endpoints/app_storage.py": "0157853bf4c4e0d90d5c356aec7565d4e8cef558b409f1ff8e3be360058a98c9",
+  "src/backend/bisheng/app_runtime/api/endpoints/runtime_delivery.py": "32e5266e37fe1b1478d203838920c34a799ca21a8590dde686de3d7761bb9511",
+  "src/backend/bisheng/app_runtime/api/endpoints/runtime_management.py": "371c68b45787f551a0214b8d9b07ff1d6faa93f61532f127fd95892c58414066",
+  "src/backend/bisheng/app_runtime/api/router.py": "4c5526b0399b6600f829e50a809fc92db2477c61b6a910760e99922d0565ba28",
+  "src/backend/bisheng/app_runtime/domain/constants.py": "5a06c64129de32b9600c7b90a40f6792913ed649a05f51d5226805c21d491c91",
+  "src/backend/bisheng/app_runtime/domain/models/__init__.py": "e6d6b7c5c8ed05d9b22841fa7e62997407a48eef0e71bb60352546a6a63213e0",
+  "src/backend/bisheng/app_runtime/domain/models/runtime_delivery.py": "9528e794cc697ed43ca0a920c7f1db1d3f005f0e8dfd692dfd95c09be98b87a5",
+  "src/backend/bisheng/app_runtime/domain/repositories/runtime_credential_repository.py": "2e16b6bd66c9f4026359bd97077767f0dca31645a6417c921f06de58c3bff0d9",
+  "src/backend/bisheng/app_runtime/domain/repositories/runtime_delivery_repository.py": "7133e41d5d66caa6b2d6b8d5c679bd4cd827d81914cafb024a386e8b417884f3",
+  "src/backend/bisheng/app_runtime/domain/repositories/runtime_object_store.py": "dccf08c2c192038146080ca5811d5e44f8c7b127ed97b60d46c0888044beec36",
+  "src/backend/bisheng/app_runtime/domain/schemas/runtime_delivery.py": "7cd06e1e359bbd5304a3c2922ae63bbffae2b383fd74844891edc5184ba09b68",
+  "src/backend/bisheng/app_runtime/domain/services/app_state_service.py": "824486f68f1e7f3a1ee3532275404590617a3511011a3216e98b8ddadadc28c1",
+  "src/backend/bisheng/app_runtime/domain/services/app_storage_service.py": "695bfc58f13848f5ccb615bd890f563fc6996652f66a78c16fea22a525c35838",
+  "src/backend/bisheng/app_runtime/domain/services/entry_authz_service.py": "1cf3f593c022dc91ee6a64bc59e85fbb32c2da6257569f416a3035ca1aa657bb",
+  "src/backend/bisheng/app_runtime/domain/services/orchestrator_client.py": "2eb3050aa2b9143f3da80d5e1574d8a7940226258e891c76241ab24b9fbc6a9a",
+  "src/backend/bisheng/app_runtime/domain/services/runtime_artifact_service.py": "9cbec8f524ed1631a48c3d3acf452e7ebcd179564ee2d7d867d0bff24c567e36",
+  "src/backend/bisheng/app_runtime/domain/services/runtime_cleanup_service.py": "cfe10d578580cb1dc5ef580c8fc09278d79c97eaef1ed6601a9657cbec9999a1",
+  "src/backend/bisheng/app_runtime/domain/services/runtime_control_client.py": "95216d7c1225ee2ce566ca2533dec8397c31716fc4e79ee3cc4ac489b8106659",
+  "src/backend/bisheng/app_runtime/domain/services/runtime_delivery_coordinator.py": "acc6948e9b64c7be06b7ecc3dbcb23fb0863ecc938d4c72a27a7149dc0945b18",
+  "src/backend/bisheng/app_runtime/domain/services/runtime_egress_service.py": "b8ba15f083e2f6bf1ba05786678f9f37eb1a0317a0ebbb5147ddef7bf2263983",
+  "src/backend/bisheng/app_runtime/domain/services/runtime_management_service.py": "ffdd45e69c95fec4a8f10f4d2ff5808f23fc022e9ad4b50d681edcf183db08b9",
+  "src/backend/bisheng/app_runtime/domain/services/runtime_offline_package.py": "efcc85e0e6fddee777eba0920fc5f0bc9924b57b58ffb31e21d42638a89bb756",
+  "src/backend/bisheng/app_runtime/domain/services/runtime_operation_service.py": "89ecc49d7f78eccc260af33afb5eed18157e451859d1d89b4060b0d946fd3956",
+  "src/backend/bisheng/app_runtime/domain/services/runtime_payload.py": "39ad789393b5eb30cb637d37362a0d92b0fcb4411217dd19ab36dfa71e42f43e",
+  "src/backend/bisheng/app_runtime/domain/services/runtime_profile_service.py": "4566cafa92f9cd43b64738eed97c6912e3309b6a50a2fe7fe60c83fe9ca9437d",
+  "src/backend/bisheng/app_runtime/domain/services/runtime_projection_service.py": "1ca77e54abedefca3f9ed48633573e7214221bfb1cef2eb347389568da285ad0",
+  "src/backend/bisheng/app_runtime/domain/services/runtime_protocol.py": "6c080c5d6a7a05f0300c7ab9db5f18a697f4bffe068cb4a10badc7b822a068e0",
+  "src/backend/bisheng/app_runtime/domain/services/runtime_upgrade_service.py": "ed6d8c3c8ba332e0dc5fd16b82ddd0a2f3d253cea07267a70e61498c7aefcce4",
+  "src/backend/bisheng/core/config/app_runtime.py": "2ad60edae6579dba7f85268c263b55d81dbab062477e0577048ca39b6c3ad488",
+  "src/backend/bisheng/core/context/tenant.py": "6cd240901bab1b9fc8983044ea4e3fe1eb6d31a908304efba6417ef1b5ddf62a",
+  "src/backend/bisheng/core/database/alembic/versions/f086_runtime_byte_counters.py": "41f9343e6416da09302e40dea79e799dd30f2971d18afd2b0c36147d861a7f44",
+  "src/backend/bisheng/core/database/tenant_filter.py": "131484f9b820aa311dd59323deb55223d22cbfccd58860be3fef19ba88789a0d",
+  "src/backend/bisheng/database/models/audit_log.py": "23aa7befd46c2fdad0cdfab51ac9dc958e1288df7a6126c17aaec71d2bf1fe68",
+  "src/backend/bisheng/dev_toolkit/artifacts/bisheng_cli-3.0.0-py3-none-any.whl": "a4cd66a21797d45b16bb6d682db95614cb21225eafbadc0bee270346b9e1307e",
+  "src/backend/bisheng/dev_toolkit/artifacts/manifest.json": "f8d743b6331ca0c307446751c2afc87e751de061282d57cadb4a0661a76ae863",
+  "src/backend/bisheng/permission/application/system_resource_reconcile.py": "48a3ec8b62cca4d97a99eeab896505fbe2a21f7d167b2e4e36f43ccd85c742cf",
+  "src/backend/bisheng/utils/http_middleware.py": "ddb493d9447d406e22a1fddb5355d89e039a873d4432a9b01b430a028375b2ec",
+  "src/backend/scripts/upgrade_vibe_runtime.py": "838e9bedf03a19d20668f3c30f56186b5999e49b3556b3e0257b7e686b584791",
+  "src/backend/test/app_publish/test_delivery_pipeline_v2.py": "bbe706c2c3c0539d2dc5daf83e569df3368ef97bee4e598767feae7419e1075f",
+  "src/backend/test/app_publish/test_manifest_delivery_v2.py": "8faacf67c91c72f65e0ac2971c3c2a7715a08346f00d26f24c2958b7a1b3f04b",
+  "src/backend/test/app_runtime/test_runtime_artifacts.py": "f3aa81a2443c634f160c098a8f339ff33cce14f130b00ae63c1ff3b5783b9e2f",
+  "src/backend/test/app_runtime/test_runtime_delivery_api.py": "f3f1eeb6abddd47fce5c9007dcc8ffe2aa0de07c0eddd9f1f64b75eead366d6a",
+  "src/backend/test/app_runtime/test_runtime_delivery_database.py": "33778f84cbeaa664c38657da719d4a2e60faa78845d50ac5dcbed38c1e767232",
+  "src/backend/test/app_runtime/test_runtime_delivery_repository.py": "9c79be07e97fb2e1ddedcd4176e076352a8b9f59aa83382ff5965bc634f7fea1",
+  "src/backend/test/app_runtime/test_runtime_layer_switch.py": "e81926278f7618bd86215a61553f6a286032d05718bf77d060219fd69c8855ee",
+  "src/backend/test/app_runtime/test_runtime_lifecycle_v2.py": "1a6669517f51f3812c4442914b0a543e870c1927a0f89ad5ee997701f1368748",
+  "src/backend/test/app_runtime/test_runtime_management.py": "9cc368df7b48db727b2cf654cc560d434c79983b590766935b1588aaa489a195",
+  "src/backend/test/app_runtime/test_runtime_management_api.py": "df32fc01ac53ece55eea9bdc099abf3552836c80921b404b2e323c6e183cdf0d",
+  "src/backend/test/app_runtime/test_runtime_model_contracts.py": "41faf1de224f00ae226e214b037c66a6e096568ba9acfd745c6c623800f54979",
+  "src/backend/test/app_runtime/test_runtime_profiles.py": "080340caf8f2a448213fc251d8626ced765be04519578230fa35c8d5a2e1ae8d",
+  "src/backend/test/app_runtime/test_runtime_protocol_v2.py": "a87140f6ad82918d52811ead253bcd8cf708ef3982e8d07b39469847f56e5696",
+  "src/backend/test/app_runtime/test_runtime_storage_api.py": "792dd2faaa97e9a57cb47f57a25b574d04dae23f0846dbe8b333ce16719fe84a",
+  "src/backend/test/app_runtime/test_runtime_upgrade.py": "7b6ef8ac3d2b8d2079382009a03b746e6b4236c0ec874e21fadbb9998538935e",
+  "src/backend/test/e2e/test_e2e_f086_runtime_delivery.py": "5c36336e33557e7cb5cc1f0047ed4b0b4ca2cff0465d96996bf7a57ff9f11c76",
+  "src/backend/test/permission/test_f048_preset_dashboard_bootstrap.py": "053da8023afe2981354d2555851b1cdaf307c79cf2415adef66c24cb4efe3a41",
+  "src/backend/test/permission/test_f048_system_resource_reconcile.py": "fb2ef0cf3b43b570db6809fd274c81c25a25c62cf80b24f8a8081ddff7c26aad",
+  "src/backend/test/tenant/test_tenant_execution_scope.py": "582700a8ff1a426c53291fc16928ff0516e6d164aa63ea9d393198b0233032ea",
+  "src/bisheng-cli/bisheng_cli/cli.py": "cf7e4d7dfa22e4b2305cb92e2812d0afd8161eb17e89aa14f921534433dfc12e",
+  "src/bisheng-cli/bisheng_cli/main.py": "e3ad0a29764cf3984cc4ca8558b65550ee80d0fe7301dee286f3f3eaaba78f54",
+  "src/bisheng-cli/bisheng_cli/packaging.py": "98652f5b0abc4a4fb1a93159a1c5f37b60e3370756c57abcf47ffd7219dbd52a",
+  "src/bisheng-cli/bisheng_cli/project.py": "6dc8b9908c4ddc857e155909a318cb8e4946c628e871f5cd24b9c460cd0c379e",
+  "src/bisheng-cli/tests/test_cli.py": "bed1f9a466294369345b6664ec8c2251bbdd798d48b3493b9094b6c647ba609d",
+  "src/bisheng-cli/tests/test_delivery_v2.py": "3b52af62b704888d9ddc959eb34677fe9e8fbcb698a17a1e405c4cb686b57fcf",
+  "src/frontend/platform/public/locales/en-US/bs.json": "f7d52ce6deff0122dcd0e63d203453fa64cf386540540eab03951e154e17af24",
+  "src/frontend/platform/public/locales/ja/bs.json": "26c4b3876af0a98feed2f70a5e4c45897b6193446e995dfb004f4dc506c0f768",
+  "src/frontend/platform/public/locales/zh-Hans/bs.json": "9bc3746e92780bcb2538ff34e80393fc3c765877be4f7042bd4b0a5ffaeded0c",
+  "src/frontend/platform/src/controllers/API/log.ts": "cfa1a1a899b3bc185b15f3dfb61f93b79f9970da8412d333d78ad97aef7a9eeb",
+  "src/runtime-manager/Dockerfile": "354ca5919c62dce4e61b878c37a5064347ae96e1d95825d1dc96666cccb7a46a",
+  "src/runtime-manager/pyproject.toml": "3102320bb81b76dfb8cbafd4f437780b54e65117e38e1b0b006f8a2aca64397c",
+  "src/runtime-manager/runtime_manager/api/operations.py": "4f205cd112fab13afd6c75d2063dbb32fae94a1fead929b979350d8dbf44e31a",
+  "src/runtime-manager/runtime_manager/api/platform_data.py": "e7fa71f42a2616fe1f1554a21305a8acaf5fd5f642780eb960687ac64ce80bd7",
+  "src/runtime-manager/runtime_manager/api/platform_observe.py": "5da590b9da6b43fef6f920ebe750a3eca1b62f6e279dc2ae0ef289317a141ec2",
+  "src/runtime-manager/runtime_manager/api/upgrade.py": "cf4fcbf604a18529d177454c9c36199700882dfc0e784971d1b78af6afe5b4c3",
+  "src/runtime-manager/runtime_manager/appdb.py": "9476a1ba30e06777816bd1245a5d3f047d8843e6af5d8870dc21db75154f7872",
+  "src/runtime-manager/runtime_manager/auth.py": "248780518fa603f9023ea98bb2e191d940ea77839b73f9881970b42676adfa32",
+  "src/runtime-manager/runtime_manager/bundle_inputs.py": "f0742832a3e32b6531e829edb68ba003a29fac000ba453a1812936d01ebe7e4b",
+  "src/runtime-manager/runtime_manager/bundle_launch.py": "b1c4b9bf686a6def9d9910130ee07f7e90958e2217bac3f6931778a91cf74ee7",
+  "src/runtime-manager/runtime_manager/bundle_preparer.py": "13a8e75152f5c3c13bb376166074f1f2f51fdb43cfc84ef89d6a7017d724a9fb",
+  "src/runtime-manager/runtime_manager/config.py": "a184f668d564df6c5d0419242a9c45f9009d00ed2efd014967300c08a1670651",
+  "src/runtime-manager/runtime_manager/container_runner.py": "195a0066eb3f0801c19d9f065b83c9fd07782da76fe20c11af6007d880ec7380",
+  "src/runtime-manager/runtime_manager/data_executor.py": "b0e0b145482fca63d3a3f02a14413d10f37d1dbf79a3c2d4d9cc8014b8133cb9",
+  "src/runtime-manager/runtime_manager/data_worker.py": "22ab2d862a200865d8b93ef9a9099156b3ac39c93916cae4ffe5ee19845f7403",
+  "src/runtime-manager/runtime_manager/docker_backend.py": "7d7488a241784045024eaed55ea77639ae56195f52b9e5d30ec88ffe398c0ed4",
+  "src/runtime-manager/runtime_manager/egress.py": "8fd48a2a1077d929323c7c4ecdc4e7e3e88857bf99eec6ecb5753cd6b9b49739",
+  "src/runtime-manager/runtime_manager/egress_proxy.py": "9339118b3c2299995938b87b0ab31735ac35d8bc6cbaf2d65060ac21d2b101fa",
+  "src/runtime-manager/runtime_manager/legacy_prepare.py": "dd8db96bc3520e960e6851c8aac7365b2d1abe57c6de0b01dd17e0709e892091",
+  "src/runtime-manager/runtime_manager/main.py": "8a81c501d9c962285755479ecf0dc05b943edbff964ccb9d160f03ab002701e4",
+  "src/runtime-manager/runtime_manager/operations.py": "c89cf95d6ad0fdcb8afd68262dff8dea786f6e1b90b6e8996b38f1103c394bd3",
+  "src/runtime-manager/runtime_manager/platform_appdb.py": "981b71d79c8f0ccd1932efbcbc766d01c5d63065005910ba372ed3f384238837",
+  "src/runtime-manager/runtime_manager/platform_client.py": "077ea84edab2e28247777ab523b7a0e79e0b8f9ca8b0957773ffe9b2d81b67db",
+  "src/runtime-manager/runtime_manager/platform_egress.py": "e5a6d422498681b9c50e22fa2ab27ad121c89307c0bb67a5b5eee24bf8903bbf",
+  "src/runtime-manager/runtime_manager/platform_protocol.py": "6c080c5d6a7a05f0300c7ab9db5f18a697f4bffe068cb4a10badc7b822a068e0",
+  "src/runtime-manager/tests/test_bundle_launch.py": "6c1d3408777880907cd376b1f864584e431f0596f3d361fd67add7b3d285c006",
+  "src/runtime-manager/tests/test_bundle_preparer.py": "10c54355320b886df088af1269757d81ab9561d442ed2a4ca9b46894da9e621c",
+  "src/runtime-manager/tests/test_data_executor.py": "691e89a531a867728f56f90127335e0829d970b790886eb3ea05c813af4f1fb0",
+  "src/runtime-manager/tests/test_delivery_images.py": "2d27e38dc1dc420eaa173f31374f1d45f49a26b171ba39063686930b38460a7c",
+  "src/runtime-manager/tests/test_offline_materials.py": "c03238612f6f8643e12216b5b0d3bf93c0494d8a8847f797a602a011b7cac84b",
+  "src/runtime-manager/tests/test_operations_v2.py": "5f836013ec8906aa646551c6f0c21f8a04676be3c9a9c876da2dd9cd5a05f840",
+  "src/runtime-manager/tests/test_platform_appdb.py": "b30f8225e9fc7eb1637a3a52f995ad1cd5e3625e5a8138594b59260f8c2f9cac",
+  "src/runtime-manager/tests/test_platform_client_v2.py": "11bb98b5c49b02746f9a36524c4f7e615c6f7277a0449ecfad61c78656914c8c",
+  "src/runtime-manager/tests/test_platform_egress.py": "425bb4564d9ce8617b675cc0e04545b1e643eb510d2b9f7cde8080bdc69a10cb",
+  "src/runtime-manager/tools/runtime_materials.py": "d68b7262ce99833edb5efc73d1afe9ffee8a7b88254ecf156cf0a6aa41dd147e",
+  "src/runtime-manager/uv.lock": "4c2f77428b683d73f1ee923dc1f76cd513b575af24d7ba5aa0ac0cc8bf68033f"
+}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/live-validation.md b/features/v3.0.0/086-vibe-runtime-delivery/live-validation.md
new file mode 100644
index 0000000000..de7ddc4f64
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/live-validation.md
@@ -0,0 +1,164 @@
+# F086 真实部署验收记录(更新至2026-10-21)
+
+**Docker 主链路通过;完整 SDD 交付门仍有未完成项。** 本记录保存2026-10-20验收时的工作树结果,基线 `4aa9ce69b2830b81162fe631b9c801057c40ee8b`。验收当时没有提交、推送、合并或发布 Docker Hub 镜像。
+
+2026-10-21用户授权“提交推送代码先”:将当前实现与证据作为阶段性成果保存到功能分支,未完成的验收与发行门保持开放。
+
+## 1. 环境与证据边界
+
+| 环境 | 实际执行 | 边界 |
+| --- | --- | --- |
+| 109 / Linux amd64 / Docker 24.0.5 / CentOS 7 | 独立 MySQL、Redis、MinIO、OpenFGA、backend/worker、socket proxy、manager、app-proxy、egress、nginx;API/CLI 发布与故障验收 | 全部资源使用 e2e-f086 名称、独立目录/库/网络。没有发布宿主端口,没有切换原业务镜像或配置 |
+| 本机 / Orbstack Linux arm64 / Docker 29.4.0 | 七个镜像原生构建;Python、Node、static 禁网准备/启动、代码更新、缓存、SQLite helper/备份、Node 安装钩子/前端编译、CPython C扩展离线构建与源码变更失效 | 原生 ARM64 容器,非 QEMU。该层使用内存产物端口,不能代替第二套完整平台 E2E |
+| 189 / K8s v1.28.15 | 只读前置检查:单节点 Ready、Calico v3.27.4、系统 Pod Running | 无 StorageClass/PV/RuntimeClass;动态卷供给未配置。未创建 namespace、Pod 或策略,未证明 NetworkPolicy 实际执行。F059 只有 spec,没有可部署执行器 |
+
+基础镜像最初受 Docker Hub 网络阻塞;之后使用用户提供代理和经官方 SHA256 校验的 crane 转存镜像,没有修改主机 Docker daemon 代理配置。服务和应用仍为独立进程。只有 socket proxy 持有 socket;测试 manager 也通过 TCP proxy 访问 Engine。一次性驱动器直接挂 socket 的提案曾被自动审批拒绝,已换用上述方案。
+
+backend 测试镜像复用主机缓存层,并按当前 `uv.lock` 执行 `uv sync --frozen --no-dev`;API/worker挂载当前源码。前一个缓存镜像的 MCP 版本不匹配不能作为 F086 缺陷,已纠正后再验。新装多租户模式曾出现 `Missing tenant context`,完整旅程因此按默认单租户模式执行;未把它登记为多租户部署通过。
+
+## 2. 已执行旅程
+
+| 场景 | 结果及实际断言 | 日志 |
+| --- | --- | --- |
+| MySQL 真并发 | 强制12个请求同步读取插入前状态;一份幂等操作、一次容量分配、跨租户竞争最后容量、唯一归属、外层回滚和范围拒绝;5 passed | [最终 SQL 结果](e2e-evidence/2026-10-20-docker/logs/sql-race-final.log) |
+| 首次发布 | 专用 SAK 上传 → MinIO 源码快照 → Celery 扫描 → manager 下载/准备 → 临时容器探活 → 版本/审批 → 正式上线 → nginx/app-proxy/HTTP;owner 数据 helper 可查询 | [首次上线](e2e-evidence/2026-10-20-docker/logs/approve-and-access.log) |
+| 代码更新和复用 | v1→v2 的 dependency artifact ID、environment key相同;代码实际更新。SQL 事实另存,未以内容相同冒充缓存命中 | [交付事实](e2e-evidence/2026-10-20-docker/evidence/control-records.txt) |
+| 审批预览 | 第三版预览 HTTP、SDK CRUD 成功;预览迁移新表时生产库没有该表,正式批准后回收预览 | [预览](e2e-evidence/2026-10-20-docker/evidence/preview-v3.log) |
+| SQLite 结构更新 | 新增 notes 表前保护旧写入者、保存并确认快照,再执行 DDL;上线后数据保持 retained | [结构更新](e2e-evidence/2026-10-20-docker/logs/approve-v3-snapshot-sdk-ws.log) |
+| owner 恢复 | 读取当前 generation CAS,修改数据,再恢复此前快照:值和表结构恢复,代码仍为v3;保护当前库的新快照也已确认;停用状态恢复不自动上线,resume仍保留数据 | [恢复](e2e-evidence/2026-10-20-docker/logs/restore-lifecycle.log)、[审计](e2e-evidence/2026-10-20-docker/evidence/restore-audit.txt) |
+| SDK 和 WebSocket | 正式入口 SDK0.1.1 put/get/list/stat/delete 经 backend 完成;真实 nginx/app-proxy WebSocket回显通过;同租户非owner与非owner超管恢复均16105 | [实际调用](e2e-evidence/2026-10-20-docker/logs/approve-v3-snapshot-sdk-ws.log) |
+| manager 不可用 | 停止独立manager后6次HTTP仍200,SDK存储仍可用,应用容器ID没有变化;随后恢复manager | [入口独立性](e2e-evidence/2026-10-20-docker/logs/manager-independence.log) |
+| backend 不可用 | 停止测试backend后6次入口均503,未放行应用数据;随后恢复backend | [拒绝访问](e2e-evidence/2026-10-20-docker/logs/backend-failclosed.log) |
+| 离线缺包与补包 | humanize依赖未供应时审批前16227失败,旧v3在线;联网准备机下载wheel,owner导入私包,实际CLI wheel以普通SAK重发,自动消费绑定材料;v4批准上线且import依赖成功 | [缺包](e2e-evidence/2026-10-20-docker/logs/publish-v4-missing-material.log)、[CLI](e2e-evidence/2026-10-20-docker/logs/cli-offline-retry.log)、[上线](e2e-evidence/2026-10-20-docker/logs/approve-v4-offline-supply.log) |
+| Nginx 与材料入口 | 真实nginx -t验证静态和平台配置;2,218,132字节离线包经nginx上传、后端哈希验证后成功,随后禁用该测试材料;未实际上传2GiB | [Nginx](e2e-evidence/2026-10-20-docker/logs/nginx-main-check.log)、[大包](e2e-evidence/2026-10-20-docker/logs/large-material.log) |
+| 完整应用镜像 | 本地不可变Image ID,经预检/审批上线,nginx/app-proxy返回200;owner停用、删除 | [额外交付结果](e2e-evidence/2026-10-20-docker/evidence/extra-delivery-results.json) |
+| v1兼容源码构建 | 旧版清单仍走平台模板构建应用镜像,审批上线后统一入口200;owner停用、删除 | [额外交付结果](e2e-evidence/2026-10-20-docker/evidence/extra-delivery-results.json) |
+| 错误端口 | 探活失败16124,修复后发布阶段为precheck_probe、原因readiness_timeout、执行阶段starting;未生成审批、正式版本0条,资源已回收 | [最终响应](e2e-evidence/2026-10-20-docker/evidence/bad-port-final.json)、[SQL核对](e2e-evidence/2026-10-20-docker/evidence/final-control-records.txt) |
+| 正式删除 | 在线删除先被16104拒绝;owner先停用再删除成功,destroy completed/cleanup complete、17条预占全released、2个快照purged、本地SQLite目录和应用容器均移除 | [删除](e2e-evidence/2026-10-20-docker/logs/delete-app.log)、[清理事实](e2e-evidence/2026-10-20-docker/evidence/deletion-records.txt) |
+
+首次两个发布尝试分别因测试预算过小和节点过期被拒绝,均没有生成审批单或留下可执行操作。其后按正式默认预算配置并修复统计耗时再继续;不把失败尝试计作上线成功。
+
+两侧原生容器均通过五项场景,包含本地CPython C扩展在相同包版本下由源码返回42变为43,依赖产物随之失效重建。[amd64结果](e2e-evidence/2026-10-20-docker/evidence/amd64-native.log)、[arm64结果](e2e-evidence/2026-10-20-docker/evidence/arm64-native.log)。
+
+## 3. 现场发现与修复
+
+| 问题 | 修改与验证 |
+| --- | --- |
+| MySQL重复插入仍读旧快照、独占锁升级死锁 | savepoint冲突后使用共享当前读;同步竞争用例原先2 failed,修复后5 passed |
+| 首次执行归属仅锁不存在的记录 | 固定节点下先锁RuntimeNode再创建/锁RuntimeAssignment;竞争归属仅一个成功 |
+| 数据helper把带时间戳日志当JSON | 增加可选timestamps参数;helper读原始JSON,普通日志仍带时间戳。真实数据查询和恢复通过 |
+| HTTPS构建前置不完整 | Debian源改HTTPS;Node slim补齐系统CA,保留TLS和apt签名校验;双架构镜像构建成功 |
+| 多容器主机心跳过慢 | 内存使用one-shot统计并最多8路并发;109从约52秒下降到约9秒一轮。节点事实仍15秒过期,极慢Engine会阻断新写操作 |
+| 节点过期只有code 0 | 映射现有16121运行时不可用,并补业务错误回归 |
+| owner无从取得恢复CAS输入 | 新增owner专用GET /apps/{app}/runtime/state;普通非owner、超管非owner、外租户均拒绝,真实恢复使用该接口 |
+| 预检失败的阶段和原因丢失 | Coordinator保留发布阶段,将operation ID、实际执行阶段、原因和数据风险放进details;probe/preview先记starting。新增5项回归,实际错误端口复测通过 |
+| CI验证缺少真实前置/缓存证据 | Nginx检查补齐MinIO DNS和临时目录;Node提供锁文件,比较实际缓存命中;增禁网安装钩子/前端构建和源码变化失效断言 |
+
+测试驱动器自身还修正了审批task_id/显示名字段、预览尾斜杠、等待current_version_id切换、/actions/stop路由、预先分配version_id与正式版本记录的区别,以及HTTP/1.1回显握手;这些不登记为产品缺陷。源码包、镜像与预览的参数均按真实API调整,未绕过审批、owner检查或内容校验。
+
+## 4. 2026-10-20的未完成项(当前进展见第8节)
+
+- DM8真实驱动、DDL、唯一约束、并发/savepoint及业务事务切换;已向用户请求独立测试schema。
+- 新装多租户启动问题与完整跨租户/OpenFGA部署旅程;已有SQL/ASGI跨租户检查不替代该证明。
+- 全部阶段中断/取消/杀进程、最终上传确认故障、长连接权限撤销硬期限、存量升级/回退及完整应用镜像/旧source-image发布旅程。
+- GitHub双原生工作流、不可覆盖发布、双架构服务索引和官方profile目录尚未实际运行/发布;本地两种架构的镜像不是正式发行。
+- K8s执行器仍在F059,不能将189的节点Ready当应用部署通过。
+- backend两项permission_runtime初始化失败、frontend lint 95错误/1警告均已在未修改基线复现,尚不是全绿的合入门。
+
+因此T055/T056及正式镜像发行门仍保留未完成。阶段已确认的spec/design无需重新确认;当前报告不是可合并或已交付声明。
+
+## 5. 本地回归与复测
+
+- backend运行/发布与opt-in集合:1214 passed、2 failed、24 skipped。两项失败仍是基线已复现的permission_runtime初始化;skip包含6项live权限、5项未配置数据库及其他既有安全门。
+- manager:437 passed、10 skipped;app-proxy 256 passed、CLI 383 passed、SDK 212 passed。变更101个Python文件通过Ruff和逐文件arch-guard。
+- 扩展dev-toolkit的本机尝试遇到旧MCP版本和localhost沙箱限制;在锁定MCP 1.30.0的测试镜像复验,最终129 passed。[分发回归](e2e-evidence/2026-10-20-docker/logs/dev-toolkit-frozen-final.log)。测试驱动器还补齐按仓库相对路径读取的CLI/前端/代理文件,并移除了macOS AppleDouble元数据,不修改产品或测试断言。
+- 源码和镜像原生检查均针对工作树,正式GitHub发行仍未运行;日志与不可变镜像事实分别保留。
+
+## 6. 证据管理
+
+2026-10-21提交前仅清除了三份证据的行尾空白,保留原始与归档SHA256,不改测试结果或JSON值。[规范化记录](e2e-evidence/2026-10-20-docker/normalization.json)。
+
+日志为本套件白名单导出,剔除了密钥/JWT/Bearer内容;不含环境文件、身份凭据、SQL密码或MinIO访问密钥。镜像与挂载事实不导出Config.Env。已核对26个原业务容器ID仍在运行,没有替换或重启;最终清理结果单列归档。4个测试应用均为deleted,错误端口应用正式版本数为0,全部29条容量预占为released。[原服务核对](e2e-evidence/2026-10-20-docker/evidence/preexisting-services-check.json)。
+
+测试使用[crane官方归档命令](https://github.com/google/go-containerregistry/blob/v0.22.1/cmd/crane/doc/crane_pull.md);统计参数的含义见[Docker SDK stats文档](https://docker-py.readthedocs.io/en/stable/api.html#docker.api.container.ContainerApiMixin.stats)。
+
+## 7. 收尾清理
+
+109的11个专用测试服务/容器和5个专用网络已移除,测试数据库、对象存储内容、执行目录和生成凭据随`/opt/e2e-f086-20261020`删除。26个原业务容器前后ID一致,未重启或替换。缓存镜像保留,不执行Docker prune。[109清理结果](e2e-evidence/2026-10-20-docker/evidence/docker-cleanup.json)。
+
+本机ARM64测试执行体均已回收,socket proxy及control网络已移除;189未创建任何测试资源。[ARM64清理结果](e2e-evidence/2026-10-20-docker/evidence/arm64-cleanup.json)。
+
+上述为2026-10-20验收收尾快照;2026-10-21阶段成果已提交推送,后续验收和清理见第8节。未进入合并或公共镜像发行。
+
+## 8. 2026-10-21:多租户修复、故障及存量升级
+
+基于已推送检查点 `5131b81194a5fa390593e58d822bcbde52b6ca64` 继续验收;本次代码修复尚未提交。109重新创建独立测试栈和全新数据库,始终开启 `multi_tenant.enabled=true`,不使用单租户回退。backend/worker挂载本次源码,manager通过socket proxy操作同一测试Engine;没有对原业务服务做部署或修改。
+
+### 8.1 多租户启动根因与修复
+
+`PermissionActor.current_tenant_id` 只是业务参数,并不会设置SQL过滤读取的ContextVar。无HTTP中间件的初始化/首管理员回调调用F048投影时因此抛出 `Missing tenant context`。新增 `tenant_execution_scope` 绑定已验证目标租户,同时将visible set收窄为该租户、清除继承的admin scope和bypass;异常、嵌套及并发后恢复原上下文。系统资源和preset dashboard仍通过原F048 facade授权,没有授予额外权限或修改全局过滤器。
+
+修复前新增回归3 failed/7 passed;修复后包含租户过滤和owner投影的组合集合56 passed。109全新多租户启动观测到18项系统资源初始化、首次管理员注册登录通过。初始容器随后因升级演练被替换,启动值以[明确标注的现场观察](e2e-evidence/2026-10-21-docker/quality-and-startup-observation.json)保留,不冒充归档的完整启动日志。通过正式部门挂载与成员API建立两个子租户,在租户2使用普通owner的SAK发布、真实子租户审批人批准并上线;普通非owner位于租户2,外租户用户位于租户3。
+
+[六项live权限测试](e2e-evidence/2026-10-21-docker/logs/live-boundaries.log)全部通过、无skip:owner可读快照和运行状态;同租户非owner及外租户用户不能读/恢复;普通owner不能修改全局运行环境。首次子租户没有审批人导致审批异常,按正常管理API授予专用审批人后由该审批人重试,没有绕过审批或直接写权限表。
+
+### 8.2 实际故障结果
+
+| 场景 | 实际结果 | 证据 |
+| --- | --- | --- |
+| 探活中取消 | owner正式取消接口;cancelled、cleanup complete、无审批,全部预占released | [取消/超时](e2e-evidence/2026-10-21-docker/cancel-timeout-results.json) |
+| 探活任务超时 | 缩短专用SQL操作deadline;上层先返回超时,随后确认manager timed_out和清理,不能以首个超时响应冒充释放 | [超时确认](e2e-evidence/2026-10-21-docker/logs/timeout-confirm.log) |
+| 安装钩子中取消 | 真实禁网Node resolve容器已运行慢postinstall后取消;无审批、临时容器及scratch回收、预占released | [安装取消](e2e-evidence/2026-10-21-docker/install-cancel-result.json) |
+| 前端编译中超时 | 真实compile容器执行慢build脚本后缩短deadline;timed_out、cleanup complete、无审批、预占released | [编译超时](e2e-evidence/2026-10-21-docker/compile-timeout-result.json) |
+| 探活中杀manager | SIGKILL专用manager,原probe容器ID保持;重启接续同operation,一次审批,随后回收probe | [重启结果](e2e-evidence/2026-10-21-docker/restart-probe-result.json)、[容器前ID](e2e-evidence/2026-10-21-docker/restart-probe-before-ids.txt)、[后ID](e2e-evidence/2026-10-21-docker/restart-probe-after-ids.txt) |
+| MinIO成功、SQL确认失败 | 专用触发器只拒绝该应用snapshot变verified;对象真实存在但confirmed_at为空,生产新表不存在、旧数据保留、入口503;保留唯一未确认本地备份 | [快照故障与重试](e2e-evidence/2026-10-21-docker/snapshot-confirmation-fault.json)、[备份哈希](e2e-evidence/2026-10-21-docker/protected-backup-facts.json) |
+| 同审批重试上线 | 移除专用触发器,通过manual-publish重试,同一审批确认快照并完成迁移,版本2返回200 | [重试](e2e-evidence/2026-10-21-docker/logs/snapshot-fault-confirm.log) |
+| DDL后新实例退出 | probe使用隔离库通过;正式库DDL已提交后按测试数据条件退出。operation标记maintenance及data_may_have_changed=true,新表和业务数据都保留,没有自动恢复旧库。版本4兼容更新后仍读到production-marker | [DDL后启动故障](e2e-evidence/2026-10-21-docker/migration-startup-failure.json) |
+| 访问者撤权 | 真实Grant API添加viewer、建立WS、再移除grant,4.03秒关闭,后续HTTP403,owner仍200 | [WS撤权](e2e-evidence/2026-10-21-docker/ws-permission-revoke.json) |
+| backend不可用 | 已建立WS在4.01秒关闭,后续HTTP503;恢复专用backend | [WS故障](e2e-evidence/2026-10-21-docker/ws-backend-failclosed.json) |
+| 出站策略撤销 | 在真实应用内用自身凭据建立CONNECT,通过专用SQL注入撤销其唯一策略;1.27秒关闭,新CONNECT407,上游backend保持运行。该项不是公开Grant API撤权 | [CONNECT撤销](e2e-evidence/2026-10-21-docker/connect-policy-revoke.json) |
+| manager不可用的独立性 | 停止超过旧3秒缓存窗口并重启,17秒观察中既有WS保持,32次新HTTP/SDK检查均成功;既有CONNECT跨故障仍可发送HTTP请求。原应用容器未更换 | [WS/HTTP/SDK](e2e-evidence/2026-10-21-docker/manager-existing-connections.json)、[CONNECT](e2e-evidence/2026-10-21-docker/logs/manager-connect.log) |
+
+快照及启动失败时,App.state仍反映旧版本online,不应据此判定入口健康;SQL运行路由进入维护、入口实际503,并由owner操作详情提供维护/数据风险。上述断言明确检查了入口和operation,而非仅检查App.state。
+
+### 8.3 真实存量升级与导入前回退
+
+使用基线 `4aa9ce69` 的旧manager源码和原MinIO依赖,在协议1兼容发布链路实际构建、审批、上线版本101;SDK0.1.1在旧附件接口完成读写,并持久保存测试附件。再提交版本102,停止manager后批准,得到当前101仍服务、approved pending102的真实记录。该过程没有直接插入AppVersion或伪造审批。
+
+维护窗口切换新协议,读取真实Engine/desired-state库存,执行正式 `scripts/upgrade_vibe_runtime.py` dry-run;随后在导入前恢复旧manager/代理,原容器、当前101、pending102与SDK均通过。回退只证明该协议1应用在导入前的回退窗口,不声明旧manager可以管理bundle版本。
+
+再次切换协议2,以实时库存SHA256和maintenance确认执行正式CLI导入;重复调用返回already_imported。随后通过正式rebind接口使用原镜像重建,仍运行101、pending102保留;两条历史版本的冻结业务字段及terminal_state逐项一致,仅排除可变update_time。旧容器已回收,旧附件经SDK0.1.1从新backend endpoint读取成功,无源码重新提交、无新审批、无代码回滚覆盖数据库。
+
+证据:[真实库存](e2e-evidence/2026-10-21-docker/upgrade-inventory.json)、[dry-run](e2e-evidence/2026-10-21-docker/upgrade-dry-run.json)、[回退](e2e-evidence/2026-10-21-docker/logs/upgrade-rollback-check.log)、[首次导入](e2e-evidence/2026-10-21-docker/upgrade-first-apply.json)、[幂等重复](e2e-evidence/2026-10-21-docker/upgrade-apply.json)、[版本核对](e2e-evidence/2026-10-21-docker/upgrade-rebind-result.json)、[旧附件](e2e-evidence/2026-10-21-docker/logs/upgrade-retained-attachment.log)。
+
+准备过程中纠正了驱动器的rows.values字段、异步清理等待、待审批与approved pending字段的区别,以及重建服务后nginx DNS刷新。测试工具纯Python依赖离线提供给驱动器,没有改动服务依赖。曾尝试把SDK源码内置应用包,被既有密钥扫描识别为命中;保留扫描规则,改用内网wheel索引供应正式SDK及依赖后重发。上述不登记为F086产品修复。
+
+### 8.4 清理、审查及当前仍缺事项
+
+7个专用应用通过owner正式停用/删除,23条容量预占全released,3项snapshot全purged;task/previews工作区均为空。专用服务、网络、库/对象数据、配置备份和生成凭据已回收;26个原业务容器完整ID前后一致且仍运行,没有重启或替换。缓存镜像保留。
+
+[删除事实](e2e-evidence/2026-10-21-docker/deletion-followup.json)、[工作区](e2e-evidence/2026-10-21-docker/scratch-cleanup-facts.json)、[环境清理](e2e-evidence/2026-10-21-docker/docker-cleanup.json)。证据白名单及逐文件SHA256见[证据目录](e2e-evidence/2026-10-21-docker/README.md)。
+
+多租户启动、六项live权限、上述关键故障与真实旧应用升级/导入前回退已补验。本次未发现需要更改manager/代理产品代码的新问题;产品修复只涉及六个启动上下文实现/测试文件。通用F048修复仍未提交,后续需先落当前release line再合入派生分支。
+
+T053、T055、T056继续保留未完成,剩余项收窄为:
+
+- DM8真实测试经用户确认本轮暂缓;已完成模型/约束静态审查并修复BIGINT范围,不再以缺测试schema阻塞当前Feature。真实驱动/事务留中央回归。
+- 全部阶段/异常组合的完整矩阵,尤其Engine build请求已发出但是否停止不可确认时的保守预占、旧执行节点失联/物理故障、上传ACK丢失及多进程接管全部组合。现有取消/超时/杀进程证据不冒充穷举通过。
+- 完整客户平台断网新装、SDK20MiB边界和全部模型/检索权限组合;本次验证不能替代既有模块的全部验收。
+- GitHub双原生工作流、受保护发行标签、多架构服务索引、Docker Hub正式镜像/官方profile目录尚未实际发行。
+- 既有backend两项permission_runtime初始化失败和前端lint基线失败仍按第4、5节的历史结果保留,未将无关变更纳入修复。
+
+K8s仍归F059;189未新增任何资源。当前不是完整Feature可合并声明,也未再次提交、推送、合并或公开发布镜像。
+
+## 9. 2026-10-21验收口径调整与模型审查
+
+用户明确本轮不做DM8真实测试,按现有模型规范与约束静态核查。15表检查通过;修复材料size_bytes和transfer offset的INTEGER溢出,新增BIGINT DDL-only扩容revision。组合28项回归通过、Alembic单head;新revision没有进行真实数据库升级,不能沿用旧schema E2E结果称其已实测。DM8实测不再是当前验收阻塞,兼容目标及中央回归安排保留。详细结果、证据边界和正式CI工作内容见[模型审查与发行说明](./model-review.md)。本轮没有触发公开镜像推送或客户部署。
+
+## 10. 2026-10-21 GitHub测试发行与公开镜像验证
+
+用户要求测试v3.0.0-test-vibe并明确授权更新该Tag。首轮失败仅因runner缺少rg,755d4456e5将两处改成系统grep,保留已有镜像拒绝和注册表异常拒绝。随后[Runtime delivery images](https://github.com/dataelement/bisheng/actions/runs/39567381522)三job全部成功(6m25s);原backend/frontend CI同时成功(7m34s)。两原生架构各7镜像、原生禁网/复用/SQLite/前端/扩展集成、服务多架构索引及runtime-profiles artifact已发布。
+
+109通过准备机代理下载及docker load验证本次公开amd64镜像默认CMD;manager/proxy健康200,代理UID10001,manager经socket proxy读Engine成功,未授权控制403/旧入口410、平台不可达入口503。无服务host端口及socket挂载。测试资源回收,原27容器完整ID不变。T053关闭,当前92/94;T055/T056仍未完成。
+
+此测试Tag固定755d4456e5,不含未提交的backend多租户/BIGINT变更;本次不证明新迁移执行、完整平台目录导入、完整断网新装或全部故障矩阵。三份artifact元数据从GitHub页面核对,没有把本地未下载的目录写成导入成功。详情与原始公开镜像结果见[e2e-evidence/2026-10-21-ci-release](./e2e-evidence/2026-10-21-ci-release/README.md)。本节更新此前各节“尚未发行”的阶段状态。
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/model-review-tests.txt b/features/v3.0.0/086-vibe-runtime-delivery/model-review-tests.txt
new file mode 100644
index 0000000000..6bdc068355
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/model-review-tests.txt
@@ -0,0 +1,23 @@
+............................                                             [100%]
+=============================== warnings summary ===============================
+bisheng/core/ai/__init__.py:2
+  /Users/zhangguoqing/.codex/worktrees/vibe-runtime-delivery/bisheng/src/backend/bisheng/core/ai/__init__.py:2: DeprecationWarning: `langchain-community` is being sunset and is no longer actively maintained. See https://github.com/langchain-ai/langchain-community/issues/674 for details and migration guidance toward standalone integration packages.
+    from langchain_community.chat_models import ChatTongyi, ChatZhipuAI, MoonshotChat
+
+:241
+:241
+  :241: DeprecationWarning: builtin type SwigPyPacked has no __module__ attribute
+
+:241
+:241
+  :241: DeprecationWarning: builtin type SwigPyObject has no __module__ attribute
+
+:241
+  :241: DeprecationWarning: builtin type swigvarlink has no __module__ attribute
+
+../../../../../../works/bisheng/src/backend/.venv/lib/python3.11/site-packages/jieba/_compat.py:18
+  /Users/zhangguoqing/works/bisheng/src/backend/.venv/lib/python3.11/site-packages/jieba/_compat.py:18: UserWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html. The pkg_resources package is slated for removal as early as 2025-11-30. Refrain from using this package or pin to Setuptools<81.
+    import pkg_resources
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+28 passed, 7 warnings in 1.75s
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/model-review.json b/features/v3.0.0/086-vibe-runtime-delivery/model-review.json
new file mode 100644
index 0000000000..c4885bdd9d
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/model-review.json
@@ -0,0 +1,1118 @@
+{
+  "date": "2026-10-21",
+  "scope": "static SQLModel/DDL-helper review only; no DM8 connection, driver or live DDL/concurrency verification",
+  "user_decision": "DM8 live tests are deferred; verify existing model definitions, conventions and constraints for this feature",
+  "tables": [
+    {
+      "table": "runtime_profile",
+      "tenant_scoped": false,
+      "columns": {
+        "id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "create_time": {
+          "type": "DATETIME",
+          "nullable": false
+        },
+        "update_time": {
+          "type": "DATETIME",
+          "nullable": false
+        },
+        "code": {
+          "type": "VARCHAR(64)",
+          "nullable": false
+        },
+        "revision": {
+          "type": "INTEGER",
+          "nullable": false
+        },
+        "language": {
+          "type": "VARCHAR(32)",
+          "nullable": false
+        },
+        "contract_version": {
+          "type": "INTEGER",
+          "nullable": false
+        },
+        "status": {
+          "type": "VARCHAR(24)",
+          "nullable": false
+        },
+        "created_by": {
+          "type": "INTEGER",
+          "nullable": false
+        },
+        "definition": {
+          "type": "CLOB",
+          "nullable": false
+        },
+        "content_digest": {
+          "type": "VARCHAR(64)",
+          "nullable": false
+        }
+      },
+      "unique_constraints": {
+        "uq_runtime_profile_revision": [
+          "code",
+          "revision"
+        ]
+      },
+      "indexes": {
+        "ix_runtime_profile_code": [
+          "code"
+        ]
+      },
+      "helper_branch_ddl": "\nCREATE TABLE runtime_profile (\n\tid VARCHAR(36) NOT NULL, \n\tcreate_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tupdate_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tcode VARCHAR(64) NOT NULL, \n\trevision INTEGER NOT NULL, \n\tlanguage VARCHAR(32) NOT NULL, \n\tcontract_version INTEGER NOT NULL, \n\tstatus VARCHAR(24) NOT NULL, \n\tcreated_by INTEGER NOT NULL, \n\tdefinition CLOB NOT NULL, \n\tcontent_digest VARCHAR(64) NOT NULL, \n\tPRIMARY KEY (id), \n\tCONSTRAINT uq_runtime_profile_revision UNIQUE (code, revision)\n)\n\n",
+      "index_ddl": [
+        "CREATE INDEX ix_runtime_profile_code ON runtime_profile (code)"
+      ]
+    },
+    {
+      "table": "runtime_profile_default",
+      "tenant_scoped": false,
+      "columns": {
+        "id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "create_time": {
+          "type": "DATETIME",
+          "nullable": false
+        },
+        "update_time": {
+          "type": "DATETIME",
+          "nullable": false
+        },
+        "language": {
+          "type": "VARCHAR(32)",
+          "nullable": false
+        },
+        "profile_id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "generation": {
+          "type": "INTEGER",
+          "nullable": false
+        }
+      },
+      "unique_constraints": {
+        "uq_runtime_profile_default": [
+          "language"
+        ]
+      },
+      "indexes": {},
+      "helper_branch_ddl": "\nCREATE TABLE runtime_profile_default (\n\tid VARCHAR(36) NOT NULL, \n\tcreate_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tupdate_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tlanguage VARCHAR(32) NOT NULL, \n\tprofile_id VARCHAR(36) NOT NULL, \n\tgeneration INTEGER NOT NULL, \n\tPRIMARY KEY (id), \n\tCONSTRAINT uq_runtime_profile_default UNIQUE (language)\n)\n\n",
+      "index_ddl": []
+    },
+    {
+      "table": "runtime_artifact",
+      "tenant_scoped": true,
+      "columns": {
+        "id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "create_time": {
+          "type": "DATETIME",
+          "nullable": false
+        },
+        "update_time": {
+          "type": "DATETIME",
+          "nullable": false
+        },
+        "tenant_id": {
+          "type": "INTEGER",
+          "nullable": false
+        },
+        "app_id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "scope_key": {
+          "type": "VARCHAR(128)",
+          "nullable": false
+        },
+        "kind": {
+          "type": "VARCHAR(32)",
+          "nullable": false
+        },
+        "visibility": {
+          "type": "VARCHAR(16)",
+          "nullable": false
+        },
+        "sha256": {
+          "type": "VARCHAR(64)",
+          "nullable": false
+        },
+        "size_bytes": {
+          "type": "BIGINT",
+          "nullable": false
+        },
+        "object_key": {
+          "type": "VARCHAR(512)",
+          "nullable": false
+        },
+        "platform": {
+          "type": "VARCHAR(64)",
+          "nullable": false
+        },
+        "producer_operation_id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "state": {
+          "type": "VARCHAR(24)",
+          "nullable": false
+        },
+        "retained_until": {
+          "type": "DATETIME",
+          "nullable": true
+        },
+        "confirmed_at": {
+          "type": "DATETIME",
+          "nullable": true
+        },
+        "metadata": {
+          "type": "CLOB",
+          "nullable": false
+        }
+      },
+      "unique_constraints": {
+        "uq_runtime_artifact_content": [
+          "scope_key",
+          "kind",
+          "sha256"
+        ]
+      },
+      "indexes": {
+        "ix_runtime_artifact_app_id": [
+          "app_id"
+        ],
+        "ix_runtime_artifact_tenant_id": [
+          "tenant_id"
+        ]
+      },
+      "helper_branch_ddl": "\nCREATE TABLE runtime_artifact (\n\tid VARCHAR(36) NOT NULL, \n\tcreate_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tupdate_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\ttenant_id INTEGER NOT NULL, \n\tapp_id VARCHAR(36) NOT NULL, \n\tscope_key VARCHAR(128) NOT NULL, \n\tkind VARCHAR(32) NOT NULL, \n\tvisibility VARCHAR(16) NOT NULL, \n\tsha256 VARCHAR(64) NOT NULL, \n\tsize_bytes BIGINT NOT NULL, \n\tobject_key VARCHAR(512) NOT NULL, \n\tplatform VARCHAR(64) NOT NULL, \n\tproducer_operation_id VARCHAR(36) NOT NULL, \n\tstate VARCHAR(24) NOT NULL, \n\tretained_until DATETIME, \n\tconfirmed_at DATETIME, \n\tmetadata CLOB NOT NULL, \n\tPRIMARY KEY (id), \n\tCONSTRAINT uq_runtime_artifact_content UNIQUE (scope_key, kind, sha256)\n)\n\n",
+      "index_ddl": [
+        "CREATE INDEX ix_runtime_artifact_app_id ON runtime_artifact (app_id)",
+        "CREATE INDEX ix_runtime_artifact_tenant_id ON runtime_artifact (tenant_id)"
+      ]
+    },
+    {
+      "table": "runtime_delivery",
+      "tenant_scoped": true,
+      "columns": {
+        "id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "create_time": {
+          "type": "DATETIME",
+          "nullable": false
+        },
+        "update_time": {
+          "type": "DATETIME",
+          "nullable": false
+        },
+        "tenant_id": {
+          "type": "INTEGER",
+          "nullable": false
+        },
+        "app_id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "version_id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "execution_mode": {
+          "type": "VARCHAR(24)",
+          "nullable": false
+        },
+        "profile_id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "resolved_platform": {
+          "type": "VARCHAR(64)",
+          "nullable": false
+        },
+        "delivery_digest": {
+          "type": "VARCHAR(64)",
+          "nullable": false
+        },
+        "content": {
+          "type": "CLOB",
+          "nullable": false
+        }
+      },
+      "unique_constraints": {
+        "uq_runtime_delivery_version": [
+          "version_id"
+        ]
+      },
+      "indexes": {
+        "ix_runtime_delivery_tenant_id": [
+          "tenant_id"
+        ],
+        "ix_runtime_delivery_app_id": [
+          "app_id"
+        ]
+      },
+      "helper_branch_ddl": "\nCREATE TABLE runtime_delivery (\n\tid VARCHAR(36) NOT NULL, \n\tcreate_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tupdate_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\ttenant_id INTEGER NOT NULL, \n\tapp_id VARCHAR(36) NOT NULL, \n\tversion_id VARCHAR(36) NOT NULL, \n\texecution_mode VARCHAR(24) NOT NULL, \n\tprofile_id VARCHAR(36) NOT NULL, \n\tresolved_platform VARCHAR(64) NOT NULL, \n\tdelivery_digest VARCHAR(64) NOT NULL, \n\tcontent CLOB NOT NULL, \n\tPRIMARY KEY (id), \n\tCONSTRAINT uq_runtime_delivery_version UNIQUE (version_id)\n)\n\n",
+      "index_ddl": [
+        "CREATE INDEX ix_runtime_delivery_tenant_id ON runtime_delivery (tenant_id)",
+        "CREATE INDEX ix_runtime_delivery_app_id ON runtime_delivery (app_id)"
+      ]
+    },
+    {
+      "table": "runtime_operation",
+      "tenant_scoped": true,
+      "columns": {
+        "id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "create_time": {
+          "type": "DATETIME",
+          "nullable": false
+        },
+        "update_time": {
+          "type": "DATETIME",
+          "nullable": false
+        },
+        "tenant_id": {
+          "type": "INTEGER",
+          "nullable": false
+        },
+        "app_id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "scope_key": {
+          "type": "VARCHAR(128)",
+          "nullable": false
+        },
+        "idempotency_key": {
+          "type": "VARCHAR(128)",
+          "nullable": false
+        },
+        "request_digest": {
+          "type": "VARCHAR(64)",
+          "nullable": false
+        },
+        "deployment_id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "version_id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "kind": {
+          "type": "VARCHAR(24)",
+          "nullable": false
+        },
+        "node_id": {
+          "type": "VARCHAR(64)",
+          "nullable": false
+        },
+        "generation": {
+          "type": "INTEGER",
+          "nullable": false
+        },
+        "owner_epoch": {
+          "type": "INTEGER",
+          "nullable": false
+        },
+        "stage": {
+          "type": "VARCHAR(32)",
+          "nullable": false
+        },
+        "status": {
+          "type": "VARCHAR(24)",
+          "nullable": false
+        },
+        "cleanup_status": {
+          "type": "VARCHAR(24)",
+          "nullable": false
+        },
+        "deadline": {
+          "type": "DATETIME",
+          "nullable": false
+        },
+        "payload": {
+          "type": "CLOB",
+          "nullable": false
+        },
+        "failure": {
+          "type": "CLOB",
+          "nullable": false
+        },
+        "diagnostics_until": {
+          "type": "DATETIME",
+          "nullable": true
+        }
+      },
+      "unique_constraints": {
+        "uq_runtime_operation_request": [
+          "scope_key",
+          "idempotency_key"
+        ]
+      },
+      "indexes": {
+        "ix_runtime_operation_diagnostics_until": [
+          "diagnostics_until"
+        ],
+        "ix_runtime_operation_app_id": [
+          "app_id"
+        ],
+        "ix_runtime_operation_tenant_id": [
+          "tenant_id"
+        ],
+        "ix_runtime_operation_node_id": [
+          "node_id"
+        ]
+      },
+      "helper_branch_ddl": "\nCREATE TABLE runtime_operation (\n\tid VARCHAR(36) NOT NULL, \n\tcreate_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tupdate_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\ttenant_id INTEGER NOT NULL, \n\tapp_id VARCHAR(36) NOT NULL, \n\tscope_key VARCHAR(128) NOT NULL, \n\tidempotency_key VARCHAR(128) NOT NULL, \n\trequest_digest VARCHAR(64) NOT NULL, \n\tdeployment_id VARCHAR(36) NOT NULL, \n\tversion_id VARCHAR(36) NOT NULL, \n\tkind VARCHAR(24) NOT NULL, \n\tnode_id VARCHAR(64) NOT NULL, \n\tgeneration INTEGER NOT NULL, \n\towner_epoch INTEGER NOT NULL, \n\tstage VARCHAR(32) NOT NULL, \n\tstatus VARCHAR(24) NOT NULL, \n\tcleanup_status VARCHAR(24) NOT NULL, \n\tdeadline DATETIME NOT NULL, \n\tpayload CLOB NOT NULL, \n\tfailure CLOB NOT NULL, \n\tdiagnostics_until DATETIME, \n\tPRIMARY KEY (id), \n\tCONSTRAINT uq_runtime_operation_request UNIQUE (scope_key, idempotency_key)\n)\n\n",
+      "index_ddl": [
+        "CREATE INDEX ix_runtime_operation_diagnostics_until ON runtime_operation (diagnostics_until)",
+        "CREATE INDEX ix_runtime_operation_app_id ON runtime_operation (app_id)",
+        "CREATE INDEX ix_runtime_operation_tenant_id ON runtime_operation (tenant_id)",
+        "CREATE INDEX ix_runtime_operation_node_id ON runtime_operation (node_id)"
+      ]
+    },
+    {
+      "table": "runtime_assignment",
+      "tenant_scoped": true,
+      "columns": {
+        "id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "create_time": {
+          "type": "DATETIME",
+          "nullable": false
+        },
+        "update_time": {
+          "type": "DATETIME",
+          "nullable": false
+        },
+        "tenant_id": {
+          "type": "INTEGER",
+          "nullable": false
+        },
+        "app_id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "node_id": {
+          "type": "VARCHAR(64)",
+          "nullable": false
+        },
+        "generation": {
+          "type": "INTEGER",
+          "nullable": false
+        },
+        "owner_epoch": {
+          "type": "INTEGER",
+          "nullable": false
+        },
+        "active_operation_id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        }
+      },
+      "unique_constraints": {
+        "uq_runtime_assignment_app": [
+          "app_id"
+        ]
+      },
+      "indexes": {
+        "ix_runtime_assignment_app_id": [
+          "app_id"
+        ],
+        "ix_runtime_assignment_tenant_id": [
+          "tenant_id"
+        ]
+      },
+      "helper_branch_ddl": "\nCREATE TABLE runtime_assignment (\n\tid VARCHAR(36) NOT NULL, \n\tcreate_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tupdate_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\ttenant_id INTEGER NOT NULL, \n\tapp_id VARCHAR(36) NOT NULL, \n\tnode_id VARCHAR(64) NOT NULL, \n\tgeneration INTEGER NOT NULL, \n\towner_epoch INTEGER NOT NULL, \n\tactive_operation_id VARCHAR(36) NOT NULL, \n\tPRIMARY KEY (id), \n\tCONSTRAINT uq_runtime_assignment_app UNIQUE (app_id)\n)\n\n",
+      "index_ddl": [
+        "CREATE INDEX ix_runtime_assignment_app_id ON runtime_assignment (app_id)",
+        "CREATE INDEX ix_runtime_assignment_tenant_id ON runtime_assignment (tenant_id)"
+      ]
+    },
+    {
+      "table": "runtime_node",
+      "tenant_scoped": false,
+      "columns": {
+        "id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "create_time": {
+          "type": "DATETIME",
+          "nullable": false
+        },
+        "update_time": {
+          "type": "DATETIME",
+          "nullable": false
+        },
+        "engine_id": {
+          "type": "VARCHAR(128)",
+          "nullable": false
+        },
+        "platform": {
+          "type": "VARCHAR(64)",
+          "nullable": false
+        },
+        "memory_budget_mb": {
+          "type": "INTEGER",
+          "nullable": false
+        },
+        "cpu_budget_millicores": {
+          "type": "INTEGER",
+          "nullable": false
+        },
+        "heartbeat_at": {
+          "type": "DATETIME",
+          "nullable": false
+        },
+        "generation": {
+          "type": "INTEGER",
+          "nullable": false
+        },
+        "status": {
+          "type": "VARCHAR(24)",
+          "nullable": false
+        }
+      },
+      "unique_constraints": {
+        "uq_runtime_node_engine": [
+          "engine_id"
+        ]
+      },
+      "indexes": {},
+      "helper_branch_ddl": "\nCREATE TABLE runtime_node (\n\tid VARCHAR(36) NOT NULL, \n\tcreate_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tupdate_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tengine_id VARCHAR(128) NOT NULL, \n\tplatform VARCHAR(64) NOT NULL, \n\tmemory_budget_mb INTEGER NOT NULL, \n\tcpu_budget_millicores INTEGER NOT NULL, \n\theartbeat_at DATETIME NOT NULL, \n\tgeneration INTEGER NOT NULL, \n\tstatus VARCHAR(24) NOT NULL, \n\tPRIMARY KEY (id), \n\tCONSTRAINT uq_runtime_node_engine UNIQUE (engine_id)\n)\n\n",
+      "index_ddl": []
+    },
+    {
+      "table": "runtime_capacity_reservation",
+      "tenant_scoped": true,
+      "columns": {
+        "id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "create_time": {
+          "type": "DATETIME",
+          "nullable": false
+        },
+        "update_time": {
+          "type": "DATETIME",
+          "nullable": false
+        },
+        "tenant_id": {
+          "type": "INTEGER",
+          "nullable": false
+        },
+        "app_id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "operation_id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "node_id": {
+          "type": "VARCHAR(64)",
+          "nullable": false
+        },
+        "purpose": {
+          "type": "VARCHAR(24)",
+          "nullable": false
+        },
+        "memory_mb": {
+          "type": "INTEGER",
+          "nullable": false
+        },
+        "cpu_millicores": {
+          "type": "INTEGER",
+          "nullable": false
+        },
+        "state": {
+          "type": "VARCHAR(24)",
+          "nullable": false
+        }
+      },
+      "unique_constraints": {
+        "uq_runtime_capacity_operation": [
+          "operation_id"
+        ]
+      },
+      "indexes": {
+        "ix_runtime_capacity_reservation_app_id": [
+          "app_id"
+        ],
+        "ix_runtime_capacity_reservation_node_id": [
+          "node_id"
+        ],
+        "ix_runtime_capacity_reservation_tenant_id": [
+          "tenant_id"
+        ]
+      },
+      "helper_branch_ddl": "\nCREATE TABLE runtime_capacity_reservation (\n\tid VARCHAR(36) NOT NULL, \n\tcreate_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tupdate_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\ttenant_id INTEGER NOT NULL, \n\tapp_id VARCHAR(36) NOT NULL, \n\toperation_id VARCHAR(36) NOT NULL, \n\tnode_id VARCHAR(64) NOT NULL, \n\tpurpose VARCHAR(24) NOT NULL, \n\tmemory_mb INTEGER NOT NULL, \n\tcpu_millicores INTEGER NOT NULL, \n\tstate VARCHAR(24) NOT NULL, \n\tPRIMARY KEY (id), \n\tCONSTRAINT uq_runtime_capacity_operation UNIQUE (operation_id)\n)\n\n",
+      "index_ddl": [
+        "CREATE INDEX ix_runtime_capacity_reservation_app_id ON runtime_capacity_reservation (app_id)",
+        "CREATE INDEX ix_runtime_capacity_reservation_node_id ON runtime_capacity_reservation (node_id)",
+        "CREATE INDEX ix_runtime_capacity_reservation_tenant_id ON runtime_capacity_reservation (tenant_id)"
+      ]
+    },
+    {
+      "table": "runtime_execution_fact",
+      "tenant_scoped": true,
+      "columns": {
+        "id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "create_time": {
+          "type": "DATETIME",
+          "nullable": false
+        },
+        "update_time": {
+          "type": "DATETIME",
+          "nullable": false
+        },
+        "tenant_id": {
+          "type": "INTEGER",
+          "nullable": false
+        },
+        "app_id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "operation_id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "sequence": {
+          "type": "INTEGER",
+          "nullable": false
+        },
+        "node_id": {
+          "type": "VARCHAR(64)",
+          "nullable": false
+        },
+        "generation": {
+          "type": "INTEGER",
+          "nullable": false
+        },
+        "owner_epoch": {
+          "type": "INTEGER",
+          "nullable": false
+        },
+        "exec_ref": {
+          "type": "VARCHAR(128)",
+          "nullable": false
+        },
+        "phase": {
+          "type": "VARCHAR(32)",
+          "nullable": false
+        },
+        "detail": {
+          "type": "CLOB",
+          "nullable": false
+        }
+      },
+      "unique_constraints": {
+        "uq_runtime_fact_sequence": [
+          "operation_id",
+          "sequence"
+        ]
+      },
+      "indexes": {
+        "ix_runtime_execution_fact_tenant_id": [
+          "tenant_id"
+        ],
+        "ix_runtime_execution_fact_app_id": [
+          "app_id"
+        ]
+      },
+      "helper_branch_ddl": "\nCREATE TABLE runtime_execution_fact (\n\tid VARCHAR(36) NOT NULL, \n\tcreate_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tupdate_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\ttenant_id INTEGER NOT NULL, \n\tapp_id VARCHAR(36) NOT NULL, \n\toperation_id VARCHAR(36) NOT NULL, \n\tsequence INTEGER NOT NULL, \n\tnode_id VARCHAR(64) NOT NULL, \n\tgeneration INTEGER NOT NULL, \n\towner_epoch INTEGER NOT NULL, \n\texec_ref VARCHAR(128) NOT NULL, \n\tphase VARCHAR(32) NOT NULL, \n\tdetail CLOB NOT NULL, \n\tPRIMARY KEY (id), \n\tCONSTRAINT uq_runtime_fact_sequence UNIQUE (operation_id, sequence)\n)\n\n",
+      "index_ddl": [
+        "CREATE INDEX ix_runtime_execution_fact_tenant_id ON runtime_execution_fact (tenant_id)",
+        "CREATE INDEX ix_runtime_execution_fact_app_id ON runtime_execution_fact (app_id)"
+      ]
+    },
+    {
+      "table": "runtime_route",
+      "tenant_scoped": true,
+      "columns": {
+        "id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "create_time": {
+          "type": "DATETIME",
+          "nullable": false
+        },
+        "update_time": {
+          "type": "DATETIME",
+          "nullable": false
+        },
+        "tenant_id": {
+          "type": "INTEGER",
+          "nullable": false
+        },
+        "app_id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "operation_id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "route_key": {
+          "type": "VARCHAR(128)",
+          "nullable": false
+        },
+        "version_id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "node_id": {
+          "type": "VARCHAR(64)",
+          "nullable": false
+        },
+        "exec_ref": {
+          "type": "VARCHAR(128)",
+          "nullable": false
+        },
+        "generation": {
+          "type": "INTEGER",
+          "nullable": false
+        },
+        "owner_epoch": {
+          "type": "INTEGER",
+          "nullable": false
+        },
+        "route_epoch": {
+          "type": "INTEGER",
+          "nullable": false
+        },
+        "status": {
+          "type": "VARCHAR(24)",
+          "nullable": false
+        },
+        "target": {
+          "type": "CLOB",
+          "nullable": false
+        }
+      },
+      "unique_constraints": {
+        "uq_runtime_route_key": [
+          "route_key"
+        ]
+      },
+      "indexes": {
+        "ix_runtime_route_tenant_id": [
+          "tenant_id"
+        ],
+        "ix_runtime_route_app_id": [
+          "app_id"
+        ]
+      },
+      "helper_branch_ddl": "\nCREATE TABLE runtime_route (\n\tid VARCHAR(36) NOT NULL, \n\tcreate_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tupdate_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\ttenant_id INTEGER NOT NULL, \n\tapp_id VARCHAR(36) NOT NULL, \n\toperation_id VARCHAR(36) NOT NULL, \n\troute_key VARCHAR(128) NOT NULL, \n\tversion_id VARCHAR(36) NOT NULL, \n\tnode_id VARCHAR(64) NOT NULL, \n\texec_ref VARCHAR(128) NOT NULL, \n\tgeneration INTEGER NOT NULL, \n\towner_epoch INTEGER NOT NULL, \n\troute_epoch INTEGER NOT NULL, \n\tstatus VARCHAR(24) NOT NULL, \n\ttarget CLOB NOT NULL, \n\tPRIMARY KEY (id), \n\tCONSTRAINT uq_runtime_route_key UNIQUE (route_key)\n)\n\n",
+      "index_ddl": [
+        "CREATE INDEX ix_runtime_route_tenant_id ON runtime_route (tenant_id)",
+        "CREATE INDEX ix_runtime_route_app_id ON runtime_route (app_id)"
+      ]
+    },
+    {
+      "table": "runtime_egress_policy",
+      "tenant_scoped": true,
+      "columns": {
+        "id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "create_time": {
+          "type": "DATETIME",
+          "nullable": false
+        },
+        "update_time": {
+          "type": "DATETIME",
+          "nullable": false
+        },
+        "tenant_id": {
+          "type": "INTEGER",
+          "nullable": false
+        },
+        "app_id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "principal_id": {
+          "type": "VARCHAR(128)",
+          "nullable": false
+        },
+        "version_id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "operation_id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "phase": {
+          "type": "VARCHAR(24)",
+          "nullable": false
+        },
+        "policy_epoch": {
+          "type": "INTEGER",
+          "nullable": false
+        },
+        "not_after": {
+          "type": "DATETIME",
+          "nullable": false
+        },
+        "revoked_at": {
+          "type": "DATETIME",
+          "nullable": true
+        },
+        "credential_hash": {
+          "type": "VARCHAR(64)",
+          "nullable": false
+        },
+        "destinations": {
+          "type": "CLOB",
+          "nullable": false
+        }
+      },
+      "unique_constraints": {
+        "uq_runtime_policy_principal": [
+          "principal_id"
+        ]
+      },
+      "indexes": {
+        "ix_runtime_egress_policy_app_id": [
+          "app_id"
+        ],
+        "ix_runtime_egress_policy_tenant_id": [
+          "tenant_id"
+        ]
+      },
+      "helper_branch_ddl": "\nCREATE TABLE runtime_egress_policy (\n\tid VARCHAR(36) NOT NULL, \n\tcreate_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tupdate_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\ttenant_id INTEGER NOT NULL, \n\tapp_id VARCHAR(36) NOT NULL, \n\tprincipal_id VARCHAR(128) NOT NULL, \n\tversion_id VARCHAR(36) NOT NULL, \n\toperation_id VARCHAR(36) NOT NULL, \n\tphase VARCHAR(24) NOT NULL, \n\tpolicy_epoch INTEGER NOT NULL, \n\tnot_after DATETIME NOT NULL, \n\trevoked_at DATETIME, \n\tcredential_hash VARCHAR(64) NOT NULL, \n\tdestinations CLOB NOT NULL, \n\tPRIMARY KEY (id), \n\tCONSTRAINT uq_runtime_policy_principal UNIQUE (principal_id)\n)\n\n",
+      "index_ddl": [
+        "CREATE INDEX ix_runtime_egress_policy_app_id ON runtime_egress_policy (app_id)",
+        "CREATE INDEX ix_runtime_egress_policy_tenant_id ON runtime_egress_policy (tenant_id)"
+      ]
+    },
+    {
+      "table": "runtime_transfer",
+      "tenant_scoped": true,
+      "columns": {
+        "id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "create_time": {
+          "type": "DATETIME",
+          "nullable": false
+        },
+        "update_time": {
+          "type": "DATETIME",
+          "nullable": false
+        },
+        "tenant_id": {
+          "type": "INTEGER",
+          "nullable": false
+        },
+        "app_id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "operation_id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "artifact_id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "node_id": {
+          "type": "VARCHAR(64)",
+          "nullable": false
+        },
+        "action": {
+          "type": "VARCHAR(16)",
+          "nullable": false
+        },
+        "ticket_hash": {
+          "type": "VARCHAR(64)",
+          "nullable": false
+        },
+        "expires_at": {
+          "type": "DATETIME",
+          "nullable": false
+        },
+        "offset": {
+          "type": "BIGINT",
+          "nullable": false
+        },
+        "parts": {
+          "type": "CLOB",
+          "nullable": false
+        },
+        "state": {
+          "type": "VARCHAR(24)",
+          "nullable": false
+        }
+      },
+      "unique_constraints": {
+        "uq_runtime_transfer_action": [
+          "operation_id",
+          "artifact_id",
+          "action"
+        ]
+      },
+      "indexes": {
+        "ix_runtime_transfer_app_id": [
+          "app_id"
+        ],
+        "ix_runtime_transfer_tenant_id": [
+          "tenant_id"
+        ]
+      },
+      "helper_branch_ddl": "\nCREATE TABLE runtime_transfer (\n\tid VARCHAR(36) NOT NULL, \n\tcreate_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tupdate_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\ttenant_id INTEGER NOT NULL, \n\tapp_id VARCHAR(36) NOT NULL, \n\toperation_id VARCHAR(36) NOT NULL, \n\tartifact_id VARCHAR(36) NOT NULL, \n\tnode_id VARCHAR(64) NOT NULL, \n\taction VARCHAR(16) NOT NULL, \n\tticket_hash VARCHAR(64) NOT NULL, \n\texpires_at DATETIME NOT NULL, \n\t\"offset\" BIGINT NOT NULL, \n\tparts CLOB NOT NULL, \n\tstate VARCHAR(24) NOT NULL, \n\tPRIMARY KEY (id), \n\tCONSTRAINT uq_runtime_transfer_action UNIQUE (operation_id, artifact_id, action)\n)\n\n",
+      "index_ddl": [
+        "CREATE INDEX ix_runtime_transfer_app_id ON runtime_transfer (app_id)",
+        "CREATE INDEX ix_runtime_transfer_tenant_id ON runtime_transfer (tenant_id)"
+      ]
+    },
+    {
+      "table": "runtime_storage_binding",
+      "tenant_scoped": true,
+      "columns": {
+        "id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "create_time": {
+          "type": "DATETIME",
+          "nullable": false
+        },
+        "update_time": {
+          "type": "DATETIME",
+          "nullable": false
+        },
+        "tenant_id": {
+          "type": "INTEGER",
+          "nullable": false
+        },
+        "app_id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "version_id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "operation_id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "namespace": {
+          "type": "VARCHAR(128)",
+          "nullable": false
+        },
+        "object_prefix": {
+          "type": "VARCHAR(512)",
+          "nullable": false
+        },
+        "credential_hash": {
+          "type": "VARCHAR(64)",
+          "nullable": false
+        },
+        "state": {
+          "type": "VARCHAR(24)",
+          "nullable": false
+        },
+        "expires_at": {
+          "type": "DATETIME",
+          "nullable": true
+        }
+      },
+      "unique_constraints": {
+        "uq_runtime_storage_credential": [
+          "credential_hash"
+        ]
+      },
+      "indexes": {
+        "ix_runtime_storage_binding_app_id": [
+          "app_id"
+        ],
+        "ix_runtime_storage_binding_tenant_id": [
+          "tenant_id"
+        ]
+      },
+      "helper_branch_ddl": "\nCREATE TABLE runtime_storage_binding (\n\tid VARCHAR(36) NOT NULL, \n\tcreate_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tupdate_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\ttenant_id INTEGER NOT NULL, \n\tapp_id VARCHAR(36) NOT NULL, \n\tversion_id VARCHAR(36) NOT NULL, \n\toperation_id VARCHAR(36) NOT NULL, \n\tnamespace VARCHAR(128) NOT NULL, \n\tobject_prefix VARCHAR(512) NOT NULL, \n\tcredential_hash VARCHAR(64) NOT NULL, \n\tstate VARCHAR(24) NOT NULL, \n\texpires_at DATETIME, \n\tPRIMARY KEY (id), \n\tCONSTRAINT uq_runtime_storage_credential UNIQUE (credential_hash)\n)\n\n",
+      "index_ddl": [
+        "CREATE INDEX ix_runtime_storage_binding_app_id ON runtime_storage_binding (app_id)",
+        "CREATE INDEX ix_runtime_storage_binding_tenant_id ON runtime_storage_binding (tenant_id)"
+      ]
+    },
+    {
+      "table": "runtime_dependency_plan",
+      "tenant_scoped": true,
+      "columns": {
+        "id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "create_time": {
+          "type": "DATETIME",
+          "nullable": false
+        },
+        "update_time": {
+          "type": "DATETIME",
+          "nullable": false
+        },
+        "tenant_id": {
+          "type": "INTEGER",
+          "nullable": false
+        },
+        "app_id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "scope_key": {
+          "type": "VARCHAR(128)",
+          "nullable": false
+        },
+        "input_key": {
+          "type": "VARCHAR(64)",
+          "nullable": false
+        },
+        "operation_id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "artifact_id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "state": {
+          "type": "VARCHAR(24)",
+          "nullable": false
+        }
+      },
+      "unique_constraints": {
+        "uq_runtime_dependency_input": [
+          "scope_key",
+          "input_key"
+        ]
+      },
+      "indexes": {
+        "ix_runtime_dependency_plan_app_id": [
+          "app_id"
+        ],
+        "ix_runtime_dependency_plan_tenant_id": [
+          "tenant_id"
+        ]
+      },
+      "helper_branch_ddl": "\nCREATE TABLE runtime_dependency_plan (\n\tid VARCHAR(36) NOT NULL, \n\tcreate_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tupdate_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\ttenant_id INTEGER NOT NULL, \n\tapp_id VARCHAR(36) NOT NULL, \n\tscope_key VARCHAR(128) NOT NULL, \n\tinput_key VARCHAR(64) NOT NULL, \n\toperation_id VARCHAR(36) NOT NULL, \n\tartifact_id VARCHAR(36) NOT NULL, \n\tstate VARCHAR(24) NOT NULL, \n\tPRIMARY KEY (id), \n\tCONSTRAINT uq_runtime_dependency_input UNIQUE (scope_key, input_key)\n)\n\n",
+      "index_ddl": [
+        "CREATE INDEX ix_runtime_dependency_plan_app_id ON runtime_dependency_plan (app_id)",
+        "CREATE INDEX ix_runtime_dependency_plan_tenant_id ON runtime_dependency_plan (tenant_id)"
+      ]
+    },
+    {
+      "table": "runtime_credential_binding",
+      "tenant_scoped": true,
+      "columns": {
+        "id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "create_time": {
+          "type": "DATETIME",
+          "nullable": false
+        },
+        "update_time": {
+          "type": "DATETIME",
+          "nullable": false
+        },
+        "tenant_id": {
+          "type": "INTEGER",
+          "nullable": false
+        },
+        "app_id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "operation_id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "version_id": {
+          "type": "VARCHAR(36)",
+          "nullable": false
+        },
+        "credential_id": {
+          "type": "INTEGER",
+          "nullable": false
+        },
+        "state": {
+          "type": "VARCHAR(24)",
+          "nullable": false
+        }
+      },
+      "unique_constraints": {
+        "uq_runtime_credential_operation": [
+          "operation_id"
+        ]
+      },
+      "indexes": {
+        "ix_runtime_credential_binding_credential_id": [
+          "credential_id"
+        ],
+        "ix_runtime_credential_binding_app_id": [
+          "app_id"
+        ],
+        "ix_runtime_credential_binding_tenant_id": [
+          "tenant_id"
+        ]
+      },
+      "helper_branch_ddl": "\nCREATE TABLE runtime_credential_binding (\n\tid VARCHAR(36) NOT NULL, \n\tcreate_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\tupdate_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, \n\ttenant_id INTEGER NOT NULL, \n\tapp_id VARCHAR(36) NOT NULL, \n\toperation_id VARCHAR(36) NOT NULL, \n\tversion_id VARCHAR(36) NOT NULL, \n\tcredential_id INTEGER NOT NULL, \n\tstate VARCHAR(24) NOT NULL, \n\tPRIMARY KEY (id), \n\tCONSTRAINT uq_runtime_credential_operation UNIQUE (operation_id)\n)\n\n",
+      "index_ddl": [
+        "CREATE INDEX ix_runtime_credential_binding_credential_id ON runtime_credential_binding (credential_id)",
+        "CREATE INDEX ix_runtime_credential_binding_app_id ON runtime_credential_binding (app_id)",
+        "CREATE INDEX ix_runtime_credential_binding_tenant_id ON runtime_credential_binding (tenant_id)"
+      ]
+    }
+  ],
+  "table_count": 15,
+  "scoped_table_count": 12,
+  "global_table_count": 3,
+  "finding_fixed": "runtime_artifact.size_bytes and runtime_transfer.offset widened from INTEGER to BIGINT for 2 GiB materials and 10 GiB snapshots",
+  "migration_head": "f086_runtime_byte_counters",
+  "checks": {
+    "model_discovery": "pass",
+    "tenant_registration": "pass",
+    "JsonType_CLOB_branch": "pass",
+    "shared_update_time_defaults": "pass",
+    "bounded_strings": "pass",
+    "nonnullable_unique_columns": "pass",
+    "one_alembic_head": "pass"
+  },
+  "new_fix_files": {
+    "src/backend/bisheng/app_runtime/domain/models/runtime_delivery.py": "9528e794cc697ed43ca0a920c7f1db1d3f005f0e8dfd692dfd95c09be98b87a5",
+    "src/backend/bisheng/core/database/alembic/versions/f086_runtime_byte_counters.py": "41f9343e6416da09302e40dea79e799dd30f2971d18afd2b0c36147d861a7f44",
+    "src/backend/test/app_runtime/test_runtime_model_contracts.py": "41faf1de224f00ae226e214b037c66a6e096568ba9acfd745c6c623800f54979"
+  }
+}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/model-review.md b/features/v3.0.0/086-vibe-runtime-delivery/model-review.md
new file mode 100644
index 0000000000..b6e52c2895
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/model-review.md
@@ -0,0 +1,36 @@
+# F086 模型规范与 DM8 静态审查(2026-10-21)
+
+用户确认:本轮不连接DM8,不执行真实驱动/DDL/事务测试;核对模型定义、规范与约束即可。AC-61的双数据库兼容目标保留,按constitution C2将DM8实测交中央回归,不再作为本Feature当前阻塞项。
+
+## 结论
+
+15张运行时表(12张租户表、3张全局目录/节点表)静态检查通过。发现并修复一处存储范围缺陷:材料上限2GiB、snapshot默认上限10GiB,原32位INTEGER无法完整记录;`runtime_artifact.size_bytes`和`runtime_transfer.offset`改为跨数据库`BigInteger`,默认0、NOT NULL保持。
+
+| 检查 | 结果 |
+| --- | --- |
+| 新表与自动发现 | 模型位于domain/models且table=True;model_discovery可发现;全量RUNTIME_TABLES含15张表 |
+| 租户边界 | 12张表继承tenant_id/app_id及索引,tenant_filter提前导入模块;3张全局表不加tenant_id |
+| JSON与大字段 | 统一JsonType;MySQL JSON,DM分支CLOB;JSON列不作为唯一键/索引键 |
+| 时间默认值 | create_time使用CURRENT_TIMESTAMP;全部update_time使用共享UPDATE_TIME_SERVER_DEFAULT,DM分支不产生MySQL ON UPDATE |
+| 键、长度与唯一性 | UUID字符串主键、字符串有界;15项具名业务唯一约束的字段非NULL;索引可静态编译 |
+| 大文件范围 | 两个字节计数列使用BIGINT,可容纳2GiB材料与10GiB快照 |
+| 旧库升级 | 新revision `f086_runtime_byte_counters`挂在已查询的唯一head `merge_vibe_f077_heads`之后;DDL-only扩容;新库已是BIGINT则跳过,缺表/列则跳过,未知类型明确报错 |
+| 降级 | 不把BIGINT缩回INTEGER,避免截断已存大计数;保留列不影响旧ORM读取 |
+
+组合回归28 passed,包含模型约束、共享DM分支、迁移守卫、Repository、artifact/storage和单head检查。[测试日志](./model-review-tests.txt)、[元数据与静态DDL](./model-review.json)。
+
+macOS没有dmPython/dmSQLAlchemy:DM检查使用SQLAlchemy通用编译器、`name=dm`触发共享适配分支,不称为真实DM8方言或驱动验证。新增扩容revision本轮也未对真实MySQL/DM8执行;此前109验收记录对应修复前schema,不能用此前通过冒充该revision已经实测。Ruff和逐文件arch-guard另行检查;本次未提交代码。
+
+## 正式CI/镜像发行的具体范围
+
+独立工作流和构建脚本已写好;后续已按用户要求用v3.0.0-test-vibe实际完成GitHub双原生测试发行及109公开服务镜像启动核验,T053关闭。完整平台断网新装及目录导入仍未演练,见[evidence](./e2e-evidence/2026-10-21-ci-release/README.md)。
+
+1. 选择不可变release标识;配置Docker Hub写入身份(DOCKERHUB_USERNAME/DOCKERHUB_TOKEN)以及可用的原生amd64/arm64 runner。
+2. 通过workflow_dispatch(version)或v* tag触发runtime-images工作流,两架构分别构建七类镜像:runtime-manager、app-proxy、Python runtime/preparer、Node runtime/preparer、static runtime。egress及data-helper复用manager镜像,static复用Node preparer。
+3. 执行基础烟测及原生禁网准备、复用、SQLite等集成检查。当前脚本在基础烟测后推送架构标签;后续完整原生检查仍可能失败,所以部分已推tag不等于整套发行通过。
+4. 推送 dataelement/bisheng-*:-,记录各镜像manifest digest和配置Image ID,检查已有tag并拒绝覆盖。两侧完整job成功后才合成manager/app-proxy服务多架构索引并生成runtime-profiles.json(运行时与preparer按平台引用固定child digest)。
+5. 验证实际仓库镜像及目录可取得;安装侧将Compose的BISHENG_RUNTIME_MANAGER_IMAGE/BISHENG_APP_PROXY_IMAGE指向已发行版本,由超管导入目录并按需CAS切默认环境。离线客户再通过准备机docker save/load交付镜像及依赖材料;当前工作流不自动生成整套离线安装包。
+
+工作流当前没有“只构建不推送”的dispatch开关,触发正式工作流会实际推Docker Hub;后续测试Tag已触发并通过。工作流未出现在默认分支时,网页手动Run workflow入口可能不可用;本次实际采用Tag触发。它发布的是平台服务/基础运行环境镜像,不是让客户每次发布应用时构建镜像,也不自动部署客户环境。
+
+源码:[workflow](../../../.github/workflows/runtime-images.yml)、[构建脚本](../../../scripts/build_runtime_images.sh)、[安装和目录导入](../../../src/runtime-manager/OPERATIONS.md)。
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/review-evidence.md b/features/v3.0.0/086-vibe-runtime-delivery/review-evidence.md
new file mode 100644
index 0000000000..1d0755435b
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/review-evidence.md
@@ -0,0 +1,70 @@
+# F086 规格复审与需求覆盖
+
+> 阶段快照:下文保留本阶段评审时的状态,不作为当前实现进度。用户现已确认spec/design;当前已实施范围、真实结果及剩余门见[live-validation.md](./live-validation.md)和[tasks.md](./tasks.md)。
+
+**日期**:2026-10-20。**方式**:按 `sdd-review` 的 spec-checklist 逐项自审,未使用子代理,不声称独立评审。
+
+**输入**:会话确认及技术评审、当前 Vibe 源码、release-contract、constitution、spec 模板和 SDD-Guide。当前 Guide 与 checklist 的纯 What 模型优先于 skill 入口中“spec 合并设计”的旧说明。
+
+**第二轮结论**:四项发现全部修订,10 项规格检查通过,严格结果为 [spec-review.md](./spec-review.md) 的 `LGTM`。用户已明确授权“修复这4个问题,更新spec并重新评审,通过后继续推进SDD流程”,故通过后继续设计,不重复请求 spec 确认。design 仍须单独用户确认。
+
+## 1. 四项发现的闭环
+
+| 编号 | 原级别/问题 | 修订位置 | 复审反例与结论 |
+| --- | --- | --- | --- |
+| R1 | high:默认路线可被逐次打镜像的旧实现满足 | AC-11、AC-13,INV-39 | 新描述不选模式必须应用包;代码修改若触发 OCI build/push 则失败。旧描述保持 legacy 构建,新描述仅显式选择才兼容。通过 |
+| R2 | high:管理主体及 owner-only 边界含糊 | AC-14、AC-15、AC-30、AC-45、§2.11,INV-42 | 全局环境/公开基础材料仅平台超管;应用私有补包及生产恢复仅目标自然人 owner;租管、非 owner 超管、其他租户、服务账号独立操作拒绝。超管恰为 owner 时仅按 owner 操作。通过 |
+| R3 | medium:双架构交付为可不声明的条件项 | AC-55,INV-43 | 首轮必须交付 amd64 和 arm64 的服务、官方运行及准备镜像;只交付 amd64 即不合格,不扩大到其他国产 CPU。通过 |
+| R4 | medium:新准备任务残留资源不受约束 | 新增 AC-63;保留 AC-26、AC-09 | 成功、失败、超时、取消、重启遗留均回收;先确认停止后释放容量,未知状态报告且仍占额;保留生产数据、引用产物及唯一未确认快照。通过 |
+
+R2 是本轮按既有 F054 AC-56 owner-only 原则收窄后的明确主体规则,不引入可任意跨应用恢复的“运维”身份。依据已核对 `app_data_service.py` 的 `_require_owner` 业务前置,不能仅依赖管理身份的权限短路。
+
+## 2. 需求覆盖
+
+| 需求组 | 验收标准 |
+| --- | --- |
+| 全开源、无数量授权、可选安装、进程分离 | AC-01、AC-02、AC-03、AC-04 |
+| 平台统一存储、隔离、SDK 和预览资产生命周期 | AC-05、AC-06、AC-07、AC-08、AC-09、AC-10、AC-53 |
+| 默认应用包、完整镜像、legacy、启动和统一入口 | AC-11、AC-12、AC-13、AC-17、AC-18 |
+| 全局环境、默认替换、历史冻结 | AC-14、AC-15、AC-16、AC-57、AC-59 |
+| 依赖冻结、复用、失效、重建、编译和回收 | AC-19、AC-20、AC-21、AC-22、AC-23、AC-24、AC-25、AC-26、AC-63 |
+| 内网包源、纯离线基础/补充包、缺包、架构和能力就绪 | AC-27、AC-28、AC-29、AC-30、AC-31、AC-32 |
+| 必审、容量、独占、幂等、上线切换和自愈 | AC-33、AC-34、AC-35、AC-36、AC-37、AC-38、AC-39、AC-40 |
+| SQLite 维护、平台确认快照、恢复和 owner 数据操作 | AC-41、AC-42、AC-43、AC-44、AC-45、AC-46、AC-47 |
+| 跨进程状态、路由、策略、权限及管理身份分离 | AC-04、AC-39、AC-48、AC-49、AC-50、AC-51、AC-52、AC-53 |
+| CI/CD、双架构、离线镜像、环境自检 | AC-54、AC-55、AC-56、AC-57、AC-58、AC-59 |
+| 存量迁移、双数据库、多租户及真实验证证据 | AC-60、AC-61、AC-62 |
+| 新增管理动作权限 | AC-14、AC-15、AC-30、AC-45 及 §2.11 主体矩阵 |
+| K8s 后续适配 | 明确排除 F086 首轮,仍归 F059,不宣称已经实现 |
+
+## 3. spec-checklist 第二轮
+
+| 检查项 | 结果与依据 |
+| --- | --- |
+| 1 需求覆盖 | 通过;四项补齐,上表覆盖 63 个 AC |
+| 2 错误和边界 | 通过;缺包、越权、双写、丢响应、失败切换、任务残留都有拒绝或恢复条件 |
+| 3 排除项 | 通过;无商业化、新 UI、多副本、任意 SQL;K8s 分期明确 |
+| 4 可追踪编号 | 通过;AC-01 至 AC-63 唯一,旧 62 个 ID 不变 |
+| 5 可测试 | 通过;默认路线、动作主体、必交架构和回收终点均有反例;回收具体期限由设计配置确定 |
+| 6 EARS | 通过;全部 AC 使用 WHEN/WHERE/WHILE/IF + THE SYSTEM SHALL |
+| 7 错误码边界 | 通过;spec 没分配实现码位,错误兼容进入 design |
+| 8 Owner | 通过;F054/F055 仍持有应用/版本/凭据,F086 新增传输、环境和执行合同对象;权限和审批不另起引擎 |
+| 9 宪法及不变量 | 通过;C1–C8 与 INV-27 至 INV-43 对齐;原 INV-27 至 INV-36 文本未修改 |
+| 10 纯 What | 通过;规定对用户可见交付方式、角色、边界和验收,不在 spec 增加数据库字段、协议、挂载命令或模块实现表 |
+
+## 4. 已核对的既有契约
+
+- F055 AC-09 保留破坏性结构变更确认;F057 AC-03 保留兼容 SDK 不强制重发。
+- F055 AC-27/INV-36 审批预览 owner 能力例外不扩展至生产访问。
+- F054 AC-56 的 owner-only 数据业务前置不被超管/租管短路替代。
+- 现有源码基线仅做静态核查;这份报告没有执行 Docker、K8s、断网发布、DM8 或授权联调。
+
+## 5. 当前流程状态
+
+| 阶段 | 状态 |
+| --- | --- |
+| Discovery | 前序会话确认,本轮核对基线和影响 |
+| spec/release-contract | 第二轮规格自审通过,用户条件授权已满足;属于规格合同,尚未实现 |
+| design | [design.md](./design.md) 已自审通过且用户确认,进入实施 |
+| tasks | 设计确认后拆分;本轮未创建 |
+| 代码及运行验收 | 尚未开始 |
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/source-inputs.json b/features/v3.0.0/086-vibe-runtime-delivery/source-inputs.json
new file mode 100644
index 0000000000..9ff512d333
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/source-inputs.json
@@ -0,0 +1,30 @@
+{
+  "date": "2026-10-20",
+  "baseline_ref": "origin/feat/3.0.0-vibe",
+  "baseline_sha": "4aa9ce69b2830b81162fe631b9c801057c40ee8b",
+  "branch": "codex/086-vibe-runtime-delivery",
+  "review_input_sha256": {
+    "vibe-app-open-source-technical-design.md": "2fb280efb649aae0c6430b3cc418078e559cef8734e3e30fa402a1575a447c52",
+    "vibe-app-open-source-technical-design.html": "36e131d491a8afbf769c695a0f2acefaa51a82e9def2fcf1a203403afc46411e",
+    "vibe-app-hosting-commercialization-review.md": "528d3e7c1f1898711a3cdb34bc14b9ca4786306a8e98074d9d387b09f6326066",
+    "vibe-app-hosting-commercialization-review.html": "082b550f7abc9c4f8b7d85572565abc5d0487f451d6edd086dba0047c331963d",
+    "bisheng-gateway-runtime-manager-implementation-plan.md": "c9c87e983023b858fe35dce701855d9e7ae340ceee84d49f3270db7c9fc19182",
+    "bisheng-gateway-runtime-manager-implementation-plan.html": "b393e3ad7e59bd98847690c3fed786f56361f385f818227eb51f9c2478e308f7"
+  },
+  "state": "integration_verification_pending",
+  "runtime_changes": true,
+  "spec_review_round": 2,
+  "spec_review": "LGTM",
+  "human_spec_confirmation": "用户明确要求修复四项问题并在复审通过后继续;条件已满足",
+  "design_review": "LGTM",
+  "human_design_confirmation": "2026-10-20 用户回复:确认",
+  "tasks_review": "LGTM",
+  "completed_tasks": 86,
+  "total_tasks": 89,
+  "pending_tasks": [
+    "T053",
+    "T055",
+    "T056"
+  ],
+  "live_validation": "live-validation.md"
+}
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/spec-review.md b/features/v3.0.0/086-vibe-runtime-delivery/spec-review.md
new file mode 100644
index 0000000000..b0e4e89c33
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/spec-review.md
@@ -0,0 +1 @@
+LGTM
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/spec.md b/features/v3.0.0/086-vibe-runtime-delivery/spec.md
new file mode 100644
index 0000000000..11721b4654
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/spec.md
@@ -0,0 +1,202 @@
+# F086:Vibe 开源托管与应用交付演进
+
+> **当前后继合同(F114,2026-10-30,本地实现,未部署)**:Docker 与 K8s 只接受运行控制协议 v2;旧 inventory/import/upgrade/rebind 退役,不保留双协议回退。v2 共用构建/SQLite/SDK/附件、bundle/image/source-image 保留,私有材料/快照改用当前 edit。 详见 [F114 已确认规格](../114-vibe-permission-actions/spec.md) 与 [验证记录](../114-vibe-permission-actions/verification.md)。下文保留原 Feature 的 AC 编号、当时口径及历史证据,不将历史通过记录当作 F114 部署证明。
+
+> 本文只定义需求、验收和边界。运行进程、协议字段、数据模型、镜像及挂载实现进入 design.md。
+
+**状态**:4 项发现已修订,第二轮规格自审通过;按用户“修复这4个问题,更新spec并重新评审,通过后继续推进SDD流程”的条件授权进入设计;用户已确认设计,代码和Docker主链路验证已完成,完整交付门见[live-validation.md](./live-validation.md)。
+
+**日期**:2026-10-20。
+
+**关联需求**:[本轮技术评审及会话确认](../../../docs/reviews/vibe-app-open-source-technical-design.md)、[Discovery](./discovery.md)。
+
+**优先级**:P0(开源交付、存储解耦、数据与运行安全);依赖复用及完整镜像交付随首轮 Docker 闭环验收。
+
+**所属版本**:v3.0.0,Vibe 专属增量。
+
+**基线**:`feat/3.0.0-vibe@4aa9ce69b2830b81162fe631b9c801057c40ee8b`。
+
+**依赖**:F054 应用领域/运行时、F055 发布、F053 CLI、F057 SDK;复用 F048 权限、F025 审批及平台存储。K8s 执行适配仍归 F059。
+
+跨 Feature 验收由各领域 Owner 的业务入口协调执行;F086 只取得 release-contract 本轮登记的新增对象写入归属,不接管既有应用、版本、凭据、权限或审批对象。
+
+## 范围边界
+
+**本次纳入**:
+
+- 全开源、可选安装、无商业上线额度的 Docker 托管交付。
+- 平台统一处理附件、源码/依赖产物及数据库快照;执行服务保留受控数据库维护。
+- 运行环境与代码版本分离、依赖准备和复用、源码应用及完整镜像两类交付。
+- 运行环境登记、默认选择、替换与冻结;断网依赖制作、导入、补包及诊断。
+- 发布独占、失败补偿、可信运行事实、入口与出站连续性、SQLite 安全升级和恢复。
+- 预构建服务镜像、离线安装、存量版本/SDK/CLI 和部署升级兼容。
+
+**本次明确排除**:
+
+- 商业许可证、免费 10 个上线名额、Gateway 归仓和 Java 重写;这些方向已取消。
+- 新建平台管理界面、应用编辑器、任意宿主命令/SQL 执行入口。
+- 应用多副本、自动跨节点迁移、自动扩容、双活动数据库写入和任意结构变更零停机。
+- 新增面向业务用户的“一键代码回滚/数据回滚”入口。历史代码重新发布仍走既有审批,数据恢复仅为应用 owner 发起的受控维护动作,主体边界见 §2.11。
+- 本 Feature 内实现 K8s 执行适配。其后续交付归 F059,不因此取消双形态一致要求,也不把 Docker 验收说成 K8s 已交付。
+- 应用发布以外的开放 API、权限、审批或资源档位业务改版;只通过其所属功能现有入口使用能力。
+
+## 1. 用户故事
+
+### 1.1 应用开发者
+
+作为应用开发者,我希望发布源码或完整镜像,并复用已经验证的运行环境和依赖,以便代码修改无需反复构建、传输整份应用镜像,且可在无公网的客户环境发布。
+
+### 1.2 平台与交付管理员
+
+作为平台超管,我希望统一配置存储、登记全局运行环境和基础离线材料;作为应用 owner,我希望导入本应用的私有补包并定位失败,以便不增加商业组件依赖,也不用在执行服务重复维护存储账号。交付人员使用上述已有身份,不因岗位名称获得额外权限。
+
+### 1.3 应用 owner 和审批人
+
+作为应用 owner 和审批人,我希望版本、审批材料、运行内容及数据变更一致,发布或迁移失败可诊断,以便避免错误版本上线和生产数据丢失。
+
+### 1.4 应用访问者
+
+作为有权限的访问者,我希望应用入口、身份与平台能力保持原有语义,执行管理服务短暂重启时仍可使用已运行应用,以便平台维护不直接中断业务访问。
+
+## 2. 验收标准
+
+### 2.1 开源与可选安装
+
+- **AC-01** — WHERE 未安装商业 Gateway 且没有商业许可证, THE SYSTEM SHALL 允许完成托管层安装、应用发布、审批、上线和有权限用户访问,不将商业组件作为前置条件。
+- **AC-02** — WHERE 实际资源足够且业务权限满足, THE SYSTEM SHALL 允许第 11 个同时在线应用上线,不存在固定“免费 10 个”或累计上线数量授权限制。
+- **AC-03** — WHERE 未安装托管层, THE SYSTEM SHALL 保持平台其他功能可启动和使用,相关入口遵循 F054 未部署语义,不产生依赖未安装托管服务的常驻任务。
+- **AC-04** — WHEN 托管执行管理服务单独升级或停止, THE SYSTEM SHALL 允许入口和出站服务独立运行,并保持运行服务身份、业务身份及应用身份的权限范围相互独立。
+
+### 2.2 平台存储与身份范围
+
+- **AC-05** — WHERE 只在平台配置对象存储并禁止执行服务直连该存储, THE SYSTEM SHALL 仍可准备应用代码、读写应用附件、保存和取回数据库快照;不要求第二套对象存储实例或执行服务专用存储账号。
+- **AC-06** — WHEN 执行服务取得代码或依赖材料、提交或取回快照, THE SYSTEM SHALL 按获授权应用、版本和操作校验范围;伪造、过期或跨范围请求必须被拒绝。
+- **AC-07** — WHEN 任意上传、下载、解包或导入材料超出部署限制、摘要不符或存在路径逃逸, THE SYSTEM SHALL 拒绝该材料并保留可定位阶段的原因,不覆盖已有资产。
+- **AC-08** — WHEN 应用经 SDK 上传、下载、列举或删除附件, THE SYSTEM SHALL 保留既有公开使用语义、单文件上限和错误可区分性,禁止跨应用或跨租户读写,不暴露底层存储账号或匿名直链。
+- **AC-09** — WHILE 审批预览运行, THE SYSTEM SHALL 使用与生产隔离的临时数据库和附件空间;预览结束后清理临时材料,不修改或清除生产资产。
+- **AC-10** — WHEN 应用停用、更新、实例回收或执行服务重启, THE SYSTEM SHALL 保留其持久数据和仍被引用的产物;整体清除应用资产仅可经既有 owner 显式删除流程,管理身份不得绕过该业务限制;应用按 SDK 契约显式删除单附件的能力不变。
+
+### 2.3 交付方式与运行环境
+
+- **AC-11** — WHEN 使用新版发布描述提交平台已支持语言环境的源码应用且未显式选择兼容构建方式, THE SYSTEM SHALL 默认采用预制运行环境加版本化应用包交付;仅业务源码变化时,平台发布过程不得为该版本构建或推送新的完整 OCI 应用镜像。显式选择兼容构建方式的提交依 AC-13 处理。
+- **AC-12** — WHEN 提交已获准使用的完整应用镜像, THE SYSTEM SHALL 使用其被冻结的运行内容完成预检、审批和上线,不强制重复安装源码依赖,也不绕过平台的资源、网络、数据和身份约束。
+- **AC-13** — WHEN 使用既有版本发布描述或显式选择兼容源码构建方式提交有效应用, THE SYSTEM SHALL 保持既有源码构建应用镜像的可用流程;不得将旧描述静默解释为新版默认应用包方式,并明确拒绝不支持或含糊的交付描述。
+- **AC-14** — WHEN 平台超管登记或替换全局运行环境, THE SYSTEM SHALL 校验其来源、平台兼容性和运行契约,并按 §2.11 的主体矩阵授权;租户管理员、应用 owner、普通用户及服务账号不得仅凭这些身份修改全局环境定义或取得拉取凭据。
+- **AC-15** — WHEN 平台超管按 §2.11 修改新发布默认环境, THE SYSTEM SHALL 仅影响之后尚未冻结的发布;已冻结、审批中及正在运行版本的代码、依赖和环境保持原组合。
+- **AC-16** — WHEN 应用选择运行环境的新版本, THE SYSTEM SHALL 重新验证相关依赖与启动兼容性,并按正常发布审批后才生效;不得原地改变历史环境版本的内容。
+- **AC-17** — WHEN 应用启动, THE SYSTEM SHALL 按冻结的启动约定和健康要求执行业务进程;应用启动失败、入口不存在或健康不满足时,不得将其宣布为已上线。
+- **AC-18** — WHERE 多个应用使用相同容器内监听端口, THE SYSTEM SHALL 通过统一入口分别访问各应用,不要求为每个应用分配或开放宿主端口,不将平台控制权限暴露给应用。
+
+### 2.4 依赖准备与复用
+
+- **AC-19** — WHEN 首次准备应用依赖, THE SYSTEM SHALL 固定完整解析结果和产物内容;同一批准版本再次运行时,不得随包源变化取得另一套依赖。
+- **AC-20** — WHEN 仅修改不参与依赖安装的业务代码且原依赖组合仍兼容, THE SYSTEM SHALL 复用已验证依赖,同时完成本次所需的编译和应用预检。
+- **AC-21** — WHEN 依赖、相关安装输入、运行环境或目标平台变化, THE SYSTEM SHALL 重新准备或选择匹配产物,不得因顶层依赖文件相同而误用不兼容环境。
+- **AC-22** — WHEN 已验证的同版本应用在有效节点缓存下重启, THE SYSTEM SHALL 直接使用对应代码和依赖,不重新解析依赖,不在正常启动时访问公网安装软件。
+- **AC-23** — WHEN 节点缓存丢失或应用被分配至新的受支持执行节点, THE SYSTEM SHALL 从平台保存的固定材料重建环境;不能依赖其他节点的本地目录或开发机已安装软件。
+- **AC-24** — WHILE 多个应用使用可复用依赖, THE SYSTEM SHALL 防止任一应用修改其他应用使用中的环境,不跨租户泄露私有依赖内容及命中信息。
+- **AC-25** — WHEN 应用需要前端编译、安装脚本或原生扩展准备, THE SYSTEM SHALL 在发布准备阶段完成并验证所需产物;影响安装结果的业务源码变化必须使相应复用判定失效。
+- **AC-26** — IF 平台支持范围内的准备任务失败或并发重复发生, THEN THE SYSTEM SHALL 不发布半成品环境,返回可定位的依赖/编译/验证错误,并允许使用相同固定输入重试。
+
+### 2.5 无公网与离线补包
+
+- **AC-27** — WHERE 无公网但批准的内网包源已包含完整依赖, THE SYSTEM SHALL 可完成首次发布及新增依赖准备,不隐式依赖该源转发公网。
+- **AC-28** — WHERE 无公网且无内网包源, THE SYSTEM SHALL 支持导入预制基础依赖集合和应用专用补充材料;目标依赖齐备并验证后可完成发布。
+- **AC-29** — IF 目标包、传递依赖、SDK、目标架构或所需系统库不齐备, THEN THE SYSTEM SHALL 在进入审批前拒绝预检并给出可确定的缺失项和环境原因,不回退公网,不把空结果当作成功。
+- **AC-30** — WHEN 平台超管导入全局基础离线材料,或应用 owner 导入本应用的私有补充材料, THE SYSTEM SHALL 按 §2.11 校验主体及材料归属,验证完整性和目标兼容性;在目标环境断网验证通过后才允许复用,补齐后可重新发起预检,跨应用或跨租户导入及引用必须被拒绝。
+- **AC-31** — WHERE 离线材料在某个 CPU 或操作系统环境制作, THE SYSTEM SHALL 不将其默认认定为适配其他目标平台,也不得把通用下载选项当作任意原生依赖编译能力。
+- **AC-32** — WHERE 应用运行需要模型、检索或外部业务接口, THE SYSTEM SHALL 在安装/预检结果中区分镜像可用、依赖可用和运行期接口可用,不以页面存活代替能力可用。
+
+### 2.6 发布与运行一致性
+
+- **AC-33** — WHEN 首发、迭代或以历史代码重新发布, THE SYSTEM SHALL 先完成相应校验和启动预检,再经 F055 既有审批流程;没有免审或完整镜像免审路径。
+- **AC-34** — WHEN 执行上线准入, THE SYSTEM SHALL 在影响旧服务或迁移生产数据前检查容量,并防止并发发布重复承诺同一份可用资源;容量不足时保留待上线版本及可诊断原因,允许按原审批结果手动重试,不得归类成商业额度不足。
+- **AC-35** — WHEN 同一应用的发布、迁移、恢复或停用相互竞争, THE SYSTEM SHALL 只允许一个有效执行所有者进行变更,拒绝过期操作覆盖新状态,不启动第二个活动数据库写入实例。
+- **AC-36** — WHEN 同一操作重复提交或调用响应丢失, THE SYSTEM SHALL 可确认其已完成/进行中/失败结果,不重复产生应用、正式实例、迁移或无法关联的快照;相同操作标识但内容不同必须拒绝。
+- **AC-37** — IF 候选实例未就绪或对外切换结果无法确认, THEN THE SYSTEM SHALL 不报告虚假上线,不提前回收仍可用且与数据兼容的旧执行体,并显示真实发布阶段。
+- **AC-38** — WHEN 新版本正式切换, THE SYSTEM SHALL 按批准能力启用新版本,并在既有撤销上界内使旧版本已移除能力失效;候选失败不能因过早撤销旧凭据而无提示破坏旧服务。
+- **AC-39** — WHEN 用户停用或删除应用、或访问权限撤销, THE SYSTEM SHALL 阻止不再允许的新访问并按既有约定断开对应长连接;执行层对账不得把已停用应用重新拉起。
+- **AC-40** — WHEN 应用持续退出或不健康, THE SYSTEM SHALL 遵守 F054 既有恢复和过渡提示承诺,避免无限高速重建,并提供应用、版本、运行操作和失败阶段的诊断记录。
+
+### 2.7 SQLite 数据安全
+
+- **AC-41** — WHEN 应用进行数据库结构升级或恢复, THE SYSTEM SHALL 保证生产写入者已隔离;不以两个活动版本同时写同一数据库的方式完成升级。
+- **AC-42** — WHEN 执行已确认的破坏性结构变更, THE SYSTEM SHALL 先保存可验证的一致快照,并确认平台已经保存内容和关联记录;仅在本地生成备份不构成完成条件。
+- **AC-43** — IF 快照保存失败或确认状态不明, THEN THE SYSTEM SHALL 不执行破坏性变更,并允许确认上传结果后继续或按维护状态恢复服务。
+- **AC-44** — WHEN 迁移完成但新实例启动失败, THE SYSTEM SHALL 明确显示数据库及运行状态是否已变化,不声称旧版本一定未受影响,也不得自动覆盖可能已产生的新业务数据。
+- **AC-45** — WHEN 应用 owner 本人按 §2.11 发起指定生产数据快照的恢复, THE SYSTEM SHALL 校验其当前归属、租户和快照范围,明确恢复点及可能丢失的数据范围,在隔离写入并验证材料后恢复及审计;平台超管、租户管理员和交付人员不得凭管理身份绕过 owner-only 规则,代码重新发布不得自动恢复旧数据库。
+- **AC-46** — WHEN 普通应用实例被回收、重建或服务进程重启, THE SYSTEM SHALL 保持外置持久数据完整;物理执行节点损坏时,按现场备份恢复点处理,不承诺单机盘自动跨节点零损恢复。
+- **AC-47** — WHEN owner 使用既有数据查看、单行编辑及导出能力, THE SYSTEM SHALL 继续通过 F054 数据服务执行权限收窄和审计,不新增任意 SQL 或 DDL 输入入口。
+
+### 2.8 入口、出站与能力权限
+
+- **AC-48** — IF 执行管理服务短暂重启或不可达且平台授权和应用仍可用, THEN THE SYSTEM SHALL 保持既有代理连接不被该重启直接关闭;超过原短路由缓存窗口后,新请求仍可取得已确认的有效运行目标。
+- **AC-49** — IF 平台身份、权限或出站策略无法确认或已失效, THEN THE SYSTEM SHALL 按既有有效期拒绝不再允许的访问,不为保持可用而无限放行旧授权。
+- **AC-50** — WHEN 平台 API、worker、入口或出口服务不在同一主机运行或重启, THE SYSTEM SHALL 仍能取得一致的任务、运行目标及策略,不能以共享某台机器的本地文件为成立条件。
+- **AC-51** — WHILE 正式运行, THE SYSTEM SHALL 保留身份头防伪、当前访问者身份、平台能力声明和用户权限共同约束;检索缺少访问者身份时不得回退为 owner。审批预览的能力授权继续遵守 F055 AC-27 和 INV-36 的既有例外,不扩散到正式访问。
+- **AC-52** — WHILE 应用准备或运行, THE SYSTEM SHALL 保留对应阶段的出站限制,对绕代理直连、私网解析和未经允许的目标按既有安全契约拒绝;应用及通用代理不能调用容器或集群管理能力。
+- **AC-53** — WHEN 服务交互或处理文件传输, THE SYSTEM SHALL 校验身份、动作及范围,拒绝伪造、过期和重放的敏感操作;服务管理凭据、对象存储主凭据及任务传输授权不得进入应用环境,日志和公开版本材料不得泄漏注入的应用敏感值。
+
+### 2.9 镜像交付与环境替换
+
+- **AC-54** — WHEN 官方托管服务版本交付, THE SYSTEM SHALL 提供由项目 CI/CD 预构建并发布到 Docker Hub 的运行服务镜像,正式 Compose 使用已发布镜像,客户无需现场构建 manager 源码。
+- **AC-55** — WHEN 官方交付本 Feature 首轮 Docker 版本, THE SYSTEM SHALL 同时提供 linux/amd64 和 linux/arm64 的 manager、入口代理、出站代理服务镜像,以及官方支持的应用运行镜像和准备镜像,提供与源码版本一致、可核验内容的双架构版本组合及分别验证的证据;不得仅以不声明多架构支持省略其中一种架构,其他未验证的国产 CPU/系统组合不得宣称已支持。
+- **AC-56** — WHERE 客户不能访问 Docker Hub 或没有内网镜像仓库, THE SYSTEM SHALL 可使用预先导入执行引擎的匹配镜像完成首轮 Docker 交付;缺失镜像或不可用执行引用必须在前置检查中显式失败。
+- **AC-57** — WHEN 升级 manager 服务镜像, THE SYSTEM SHALL 保留已冻结应用的代码、依赖和运行环境组合,允许执行管理服务与应用环境分别升级及回退。
+- **AC-58** — WHEN 安装或升级托管层, THE SYSTEM SHALL 验证数据目录映射、存储/平台接口、身份、网络、执行引擎、目标镜像和包源/离线材料等前置,分别呈现未满足项,不把进程存活作为全部安装成功。
+- **AC-59** — WHEN 运行依赖需要超出当前环境的系统库或工具链, THE SYSTEM SHALL 要求匹配的新运行环境或已批准完整镜像及其离线材料,不承诺预装常见库可以适配任意应用。
+
+### 2.10 升级兼容与完成证据
+
+- **AC-60** — WHEN 从当前托管版本升级, THE SYSTEM SHALL 保持已发布应用资产、历史版本和兼容 SDK 的公开行为,提供既有运行信息的迁移、核验及失败处置;不因内部存储接口迁移强迫所有兼容应用重新提交代码。
+- **AC-61** — WHERE 采用平台支持的任一业务数据库或开启多租户, THE SYSTEM SHALL 保持同样的发布、运行环境管理和产物隔离语义,不引入仅某数据库或单机共享目录才成立的行为。
+- **AC-62** — WHEN 完成首轮交付验收, THE SYSTEM SHALL 分别提供正常发布、离线新增依赖、服务重启、失败重试、数据库快照/恢复及权限隔离的实际证据,未执行环境明确标为未验证。
+
+### 2.11 管理主体与准备任务回收
+
+以下是新增动作的业务授权边界;复用现有身份,不新增“运维”或“交付”角色。表内“owner”均指目标应用当前的自然人 owner,同租户管理员和超管若同时也是该应用 owner,仅以 owner 身份执行该应用动作。既有发布凭据的发版权限不变,但不因此获得这些新增管理动作。
+
+| 动作 | 允许主体及范围 | 明确拒绝 | 执行服务的地位 |
+| --- | --- | --- | --- |
+| 登记/替换全局运行环境、修改全局默认映射 | 平台超管;全局环境目录 | 仅有租户管理员、应用 owner、普通用户或服务账号身份 | 只消费已冻结环境,不能自行登记或改默认 |
+| 导入全局基础离线依赖集合 | 平台超管;仅可作为公开基础材料供引用,不得夹带租户私有包 | 租户管理员、应用 owner、普通用户、服务账号 | 验证指定材料,不自行扩大可见范围 |
+| 导入/引用应用私有离线补包 | 当前应用自然人 owner;仅限现有应用或其已建立归属的发布草稿;材料不得被其他应用或租户引用 | 非 owner、仅有管理身份者、其他租户、服务账号独立发起 | 仅执行已授权的同应用准备任务 |
+| 恢复生产 SQLite 快照 | 当前应用自然人 owner;仅限同租户、同应用的已确认快照,需明确确认恢复范围 | 非 owner、仅有管理身份者、其他租户、服务账号独立发起 | 根据 owner 已确认的维护意图执行,不取得独立数据操作授权 |
+
+- **AC-63** — WHEN 新增依赖准备、前端编译或启动预检任务完成、失败、超时、取消,或执行服务重启后发现这些任务的遗留资源, THE SYSTEM SHALL 在配置的回收期限内清理属于该任务的临时执行体和工作空间,并在确认执行体停止后释放其资源预占;保留限期诊断材料,不删除生产数据库、仍被引用的有效产物或唯一尚未确认保存的快照;未确认停止的任务必须显式报告并继续占用对应资源,不得把容量提前重复分配。
+
+## 3. 边界情况
+
+- 无依赖的静态应用不应因为未提供 Python/Node 包源而失败;仍要求站点材料和运行环境齐备。
+- 只有包源地址但没有实际所需包文件,不构成离线依赖已就绪。
+- 完整镜像模式不被强行加入源码依赖,也不能逃过平台运行与审批约束。
+- 缓存只是加速材料;失效后重建,不能借缓存失效删除生产数据库或唯一未确认备份。
+- 停写维护中的应用需要呈现可识别状态,不能承诺任意 SQLite DDL 零停机。
+- 库结构变化后发布失败,数据恢复与代码重新发布是两个独立操作。
+- 运行环境同名修改和默认环境切换不得影响历史冻结版本。
+- 代理管理服务不可达与平台权限服务不可达是不同故障,不能用同一无条件放行策略处理。
+- 单机数据卷的物理节点灾难按备份恢复处理;实例级恢复不等于节点级无损迁移。
+
+## 4. 设计与实现指针
+
+用户已条件授权本轮修复并在复审通过后继续;本 spec 第二轮自审通过后进入设计。技术评审文档是输入材料,不作为 design.md 的替代签收,也不等同于代码完成。
+
+| 内容 | 权威位置及状态 |
+| --- | --- |
+| 背景、已确认方向、基线与旧契约影响 | [discovery.md](./discovery.md),本轮整理 |
+| 领域归属和跨 Feature 约束 | [release-contract.md](../release-contract.md),本次规格增量已生效;实现与尚未关闭的验收门见live-validation.md |
+| 方案决策、API/字段/数据流及兼容方案 | [design.md](./design.md),已自审通过且用户确认,进入实施 |
+| 分波任务、AC 追踪、验证和偏差 | 本目录 tasks.md,待设计确认后编写 |
+| 规格覆盖与自审证据 | [review-evidence.md](./review-evidence.md) |
+| 严格审查结果 | [spec-review.md](./spec-review.md);不替代用户确认 |
+
+## 相关文档
+
+- [技术评审输入](../../../docs/reviews/vibe-app-open-source-technical-design.md)
+- [SDD 流程](../../../docs/SDD-Guide.md)
+- [架构宪法](../../../docs/constitution.md)
+- [F054](../054-app-domain-runtime/spec.md)、[F055](../055-app-publish-pipeline/spec.md)、[F057](../057-bisheng-sdk/spec.md)、[F059](../059-k8s-runtime-backend/spec.md)
+
+## 2026-10-21验收口径补充
+
+用户确认本Feature暂不进行DM8真实测试,当前以模型定义、共享方言类型/默认值、键/长度/唯一约束/索引、租户注册及迁移规范审查作为DM8检查范围。AC-61的兼容目标保留;按constitution C2将真实DM8驱动/DDL/事务验证留中央回归,不将模型静态通过称为DM8实测通过,也不再以专用DM8环境作为当前Feature阻塞。
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/task-reviews.md b/features/v3.0.0/086-vibe-runtime-delivery/task-reviews.md
new file mode 100644
index 0000000000..7dea44383d
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/task-reviews.md
@@ -0,0 +1,691 @@
+# F086 Task Review(L1)
+
+同一代理按 task-review 检查,非独立评审。86/89项达到本地合同或原生容器验证;尚余T053、T055、T056。勾选不代表对应全部AC已取得平台部署证明。上述统计为2026-10-20验收快照,当时未暂存、提交、推送或合并。2026-10-21用户授权先提交并推送当前阶段成果,未完成验收项继续保留。
+
+## 当前证据
+
+- backend:1214 passed,2 failed,24 skipped。
+- manager:437 passed,0 failed,10 skipped。
+- app_proxy:256 passed,0 failed,0 skipped。
+- cli:383 passed,0 failed,0 skipped。
+- sdk:212 passed,0 failed,0 skipped。
+- dev_toolkit(锁定依赖Docker):129 passed。
+
+backend 两项失败在未改基线复现;frontend lint 95错误/1警告的归一化输出与基线完全相同。frontend typecheck、i18n、变更Python Ruff/arch-guard、Compose合同与CLI干净安装通过。真实Engine、双原生架构、离线准备/补包、MySQL同步竞争和Docker主链路均已验证;DM8、新装多租户、完整故障/升级和正式发行仍待验证。详见[live-validation.md](./live-validation.md)。
+
+## 按任务
+
+### T001 平台持久对象模型
+
+- 文件:[runtime_delivery.py](../../../src/backend/bisheng/app_runtime/domain/models/runtime_delivery.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:backend 真SQL/服务/ASGI合同与既有发布回归。
+- AC:AC-06、AC-15、AC-19、AC-35、AC-36、AC-50、AC-61。
+
+### T002 模型发现注册
+
+- 文件:[tenant_filter.py](../../../src/backend/bisheng/core/database/tenant_filter.py)、[__init__.py](../../../src/backend/bisheng/app_runtime/domain/models/__init__.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:backend 真SQL/服务/ASGI合同与既有发布回归。
+- AC:AC-14、AC-30、AC-50、AC-61。
+
+### T003 平台运行交付配置
+
+- 文件:[app_runtime.py](../../../src/backend/bisheng/core/config/app_runtime.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:backend 真SQL/服务/ASGI合同与既有发布回归。
+- AC:AC-03、AC-05、AC-53、AC-58。
+
+### T004 manager执行配置
+
+- 文件:[config.py](../../../src/runtime-manager/runtime_manager/config.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:独立进程单元/合同或配置静态检查。
+- AC:AC-04、AC-22、AC-53、AC-57、AC-58、AC-63。
+
+### T005 平台操作/容量Repository合同与失败测试
+
+- 文件:[test_runtime_delivery_repository.py](../../../src/backend/test/app_runtime/test_runtime_delivery_repository.py)、[test_runtime_delivery_database.py](../../../src/backend/test/app_runtime/test_runtime_delivery_database.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:Repository本地21项回归、真实MySQL同步竞争5项通过;DM8原先待环境;用户2026-10-21确认本轮只做模型静态审查,已通过,实测留中央回归。
+- AC:AC-06、AC-34、AC-35、AC-36、AC-37、AC-39、AC-50、AC-61、AC-63。
+
+### T006 平台操作/容量Repository
+
+- 文件:[runtime_delivery_repository.py](../../../src/backend/bisheng/app_runtime/domain/repositories/runtime_delivery_repository.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:Repository本地21项回归、真实MySQL同步竞争5项通过;DM8原先待环境;用户2026-10-21确认本轮只做模型静态审查,已通过,实测留中央回归。
+- AC:AC-06、AC-34、AC-35、AC-36、AC-37、AC-39、AC-50、AC-61、AC-63。
+
+### T007 版本2服务身份与重放保护合同与失败测试
+
+- 文件:[test_runtime_protocol_v2.py](../../../src/backend/test/app_runtime/test_runtime_protocol_v2.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:backend 真SQL/服务/ASGI合同与既有发布回归。
+- AC:AC-06、AC-07、AC-49、AC-52、AC-53。
+
+### T008 版本2服务身份与重放保护
+
+- 文件:[runtime_protocol.py](../../../src/backend/bisheng/app_runtime/domain/services/runtime_protocol.py)、[platform_protocol.py](../../../src/runtime-manager/runtime_manager/platform_protocol.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:backend 真SQL/服务/ASGI合同与既有发布回归。
+- AC:AC-06、AC-07、AC-49、AC-52、AC-53。
+
+### T009 平台产物传输合同与失败测试
+
+- 文件:[test_runtime_artifacts.py](../../../src/backend/test/app_runtime/test_runtime_artifacts.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:backend 真SQL/服务/ASGI合同与既有发布回归。
+- AC:AC-05、AC-06、AC-07、AC-10、AC-19、AC-23、AC-28、AC-42、AC-43、AC-53。
+
+### T010 平台产物传输
+
+- 文件:[runtime_artifact_service.py](../../../src/backend/bisheng/app_runtime/domain/services/runtime_artifact_service.py)、[runtime_object_store.py](../../../src/backend/bisheng/app_runtime/domain/repositories/runtime_object_store.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:backend 真SQL/服务/ASGI合同与既有发布回归。
+- AC:AC-05、AC-06、AC-07、AC-10、AC-19、AC-23、AC-28、AC-42、AC-43、AC-53。
+
+### T011 环境目录和授权边界合同与失败测试
+
+- 文件:[test_runtime_profiles.py](../../../src/backend/test/app_runtime/test_runtime_profiles.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:backend 真SQL/服务/ASGI合同与既有发布回归。
+- AC:AC-11、AC-12、AC-14、AC-15、AC-16、AC-17、AC-21、AC-24、AC-30、AC-31、AC-55、AC-56、AC-57、AC-59。
+
+### T012 环境目录和授权边界
+
+- 文件:[runtime_profile_service.py](../../../src/backend/bisheng/app_runtime/domain/services/runtime_profile_service.py)、[runtime_delivery.py](../../../src/backend/bisheng/app_runtime/domain/schemas/runtime_delivery.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:backend 真SQL/服务/ASGI合同与既有发布回归。
+- AC:AC-11、AC-12、AC-14、AC-15、AC-16、AC-17、AC-21、AC-24、AC-30、AC-31、AC-55、AC-56、AC-57、AC-59。
+
+### T013 内部操作与投影API合同与失败测试
+
+- 文件:[test_runtime_delivery_api.py](../../../src/backend/test/app_runtime/test_runtime_delivery_api.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:backend 真SQL/服务/ASGI合同与既有发布回归。
+- AC:AC-06、AC-34、AC-35、AC-36、AC-37、AC-48、AC-49、AC-50、AC-53。
+
+### T014 内部操作与投影API
+
+- 文件:[runtime_delivery.py](../../../src/backend/bisheng/app_runtime/api/endpoints/runtime_delivery.py)、[router.py](../../../src/backend/bisheng/app_runtime/api/router.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:backend 真SQL/服务/ASGI合同与既有发布回归。
+- AC:AC-06、AC-34、AC-35、AC-36、AC-37、AC-48、AC-49、AC-50、AC-53。
+
+### T015 backend附件SDK兼容合同与失败测试
+
+- 文件:[test_runtime_storage_api.py](../../../src/backend/test/app_runtime/test_runtime_storage_api.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:backend 真SQL/服务/ASGI合同与既有发布回归。
+- AC:AC-05、AC-08、AC-09、AC-10、AC-38、AC-39、AC-51、AC-53、AC-60。
+
+### T016 backend附件SDK兼容
+
+- 文件:[app_storage_service.py](../../../src/backend/bisheng/app_runtime/domain/services/app_storage_service.py)、[app_storage.py](../../../src/backend/bisheng/app_runtime/api/endpoints/app_storage.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:backend 真SQL/服务/ASGI合同与既有发布回归。
+- AC:AC-05、AC-08、AC-09、AC-10、AC-38、AC-39、AC-51、AC-53、AC-60。
+
+### T017 新版清单与旧模式兼容合同与失败测试
+
+- 文件:[test_manifest_delivery_v2.py](../../../src/backend/test/app_publish/test_manifest_delivery_v2.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:backend 真SQL/服务/ASGI合同与既有发布回归。
+- AC:AC-11、AC-12、AC-13、AC-17、AC-18、AC-25、AC-33、AC-60。
+
+### T018 新版清单与旧模式兼容
+
+- 文件:[app_manifest.py](../../../src/backend/bisheng/app_publish/domain/schemas/app_manifest.py)、[manifest_validator.py](../../../src/backend/bisheng/app_publish/domain/services/manifest_validator.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:backend 真SQL/服务/ASGI合同与既有发布回归。
+- AC:AC-11、AC-12、AC-13、AC-17、AC-18、AC-25、AC-33、AC-60。
+
+### T019 CLI应用包交付合同与失败测试
+
+- 文件:[test_delivery_v2.py](../../../src/bisheng-cli/tests/test_delivery_v2.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:独立进程单元/合同或配置静态检查。
+- AC:AC-07、AC-11、AC-13、AC-17、AC-25、AC-60。
+
+### T020 CLI应用包交付
+
+- 文件:[project.py](../../../src/bisheng-cli/bisheng_cli/project.py)、[packaging.py](../../../src/bisheng-cli/bisheng_cli/packaging.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:独立进程单元/合同或配置静态检查。
+- AC:AC-07、AC-11、AC-13、AC-17、AC-25、AC-60。
+
+### T021 manager平台内容客户端合同与失败测试
+
+- 文件:[test_platform_client_v2.py](../../../src/runtime-manager/tests/test_platform_client_v2.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:独立进程单元/合同或配置静态检查。
+- AC:AC-05、AC-06、AC-07、AC-23、AC-36、AC-42、AC-43、AC-53。
+
+### T022 manager平台内容客户端
+
+- 文件:[platform_client.py](../../../src/runtime-manager/runtime_manager/platform_client.py)、[operations.py](../../../src/runtime-manager/runtime_manager/api/operations.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:独立进程单元/合同或配置静态检查。
+- AC:AC-05、AC-06、AC-07、AC-23、AC-36、AC-42、AC-43、AC-53。
+
+### T023 Python与Node准备和复用合同与失败测试
+
+- 文件:[test_bundle_preparer.py](../../../src/runtime-manager/tests/test_bundle_preparer.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:独立进程单元/合同或配置静态检查。
+- AC:AC-19、AC-20、AC-21、AC-22、AC-23、AC-24、AC-25、AC-26、AC-27、AC-28、AC-29、AC-30、AC-31、AC-32、AC-59。
+
+### T024 Python与Node准备和复用
+
+- 文件:[bundle_preparer.py](../../../src/runtime-manager/runtime_manager/bundle_preparer.py)、[bundle_inputs.py](../../../src/runtime-manager/runtime_manager/bundle_inputs.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:独立进程单元/合同或配置静态检查。
+- AC:AC-19、AC-20、AC-21、AC-22、AC-23、AC-24、AC-25、AC-26、AC-27、AC-28、AC-29、AC-30、AC-31、AC-32、AC-59。
+
+### T025 bundle与完整镜像启动合同与失败测试
+
+- 文件:[test_bundle_launch.py](../../../src/runtime-manager/tests/test_bundle_launch.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:独立进程单元/合同或配置静态检查。
+- AC:AC-11、AC-12、AC-17、AC-18、AC-22、AC-41、AC-46、AC-52、AC-56、AC-57、AC-58。
+
+### T026 bundle与完整镜像启动
+
+- 文件:[bundle_launch.py](../../../src/runtime-manager/runtime_manager/bundle_launch.py)、[docker_backend.py](../../../src/runtime-manager/runtime_manager/docker_backend.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:独立进程单元/合同或配置静态检查。
+- AC:AC-11、AC-12、AC-17、AC-18、AC-22、AC-41、AC-46、AC-52、AC-56、AC-57、AC-58。
+
+### T027 manager操作所有权和回收合同与失败测试
+
+- 文件:[test_operations_v2.py](../../../src/runtime-manager/tests/test_operations_v2.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:独立进程单元/合同或配置静态检查。
+- AC:AC-26、AC-34、AC-35、AC-36、AC-37、AC-39、AC-40、AC-50、AC-63。
+
+### T028 manager操作所有权和回收
+
+- 文件:[operations.py](../../../src/runtime-manager/runtime_manager/operations.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:独立进程单元/合同或配置静态检查。
+- AC:AC-26、AC-34、AC-35、AC-36、AC-37、AC-39、AC-40、AC-50、AC-63。
+
+### T029 SQLite平台快照与owner恢复合同与失败测试
+
+- 文件:[test_platform_appdb.py](../../../src/runtime-manager/tests/test_platform_appdb.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:独立进程单元/合同或配置静态检查。
+- AC:AC-05、AC-35、AC-41、AC-42、AC-43、AC-44、AC-45、AC-46、AC-47。
+
+### T030 SQLite平台快照与owner恢复
+
+- 文件:[appdb.py](../../../src/runtime-manager/runtime_manager/appdb.py)、[platform_appdb.py](../../../src/runtime-manager/runtime_manager/platform_appdb.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:独立进程单元/合同或配置静态检查。
+- AC:AC-05、AC-35、AC-41、AC-42、AC-43、AC-44、AC-45、AC-46、AC-47。
+
+### T031 F055交付准备接线合同与失败测试
+
+- 文件:[test_delivery_pipeline_v2.py](../../../src/backend/test/app_publish/test_delivery_pipeline_v2.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:backend 真SQL/服务/ASGI合同与既有发布回归。
+- AC:AC-06、AC-11、AC-12、AC-13、AC-19、AC-29、AC-32、AC-33。
+
+### T032 F055交付准备接线
+
+- 文件:[publish_pipeline_service.py](../../../src/backend/bisheng/app_publish/domain/services/publish_pipeline_service.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:backend 真SQL/服务/ASGI合同与既有发布回归。
+- AC:AC-06、AC-11、AC-12、AC-13、AC-19、AC-29、AC-32、AC-33。
+
+### T033 版本冻结与凭据代际合同与失败测试
+
+- 文件:[test_runtime_lifecycle_v2.py](../../../src/backend/test/app_runtime/test_runtime_lifecycle_v2.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:backend 真SQL/服务/ASGI合同与既有发布回归。
+- AC:AC-15、AC-16、AC-19、AC-33、AC-36、AC-38、AC-51、AC-57、AC-60。
+
+### T034 版本冻结与凭据代际
+
+- 文件:[version_service.py](../../../src/backend/bisheng/app_publish/domain/services/version_service.py)、[app_credential_service.py](../../../src/backend/bisheng/app_publish/domain/services/app_credential_service.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:backend 真SQL/服务/ASGI合同与既有发布回归。
+- AC:AC-15、AC-16、AC-19、AC-33、AC-36、AC-38、AC-51、AC-57、AC-60。
+
+### T035 F054/F055安全上线合同与失败测试
+
+- 文件:[test_runtime_lifecycle_v2.py](../../../src/backend/test/app_runtime/test_runtime_lifecycle_v2.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:backend 真SQL/服务/ASGI合同与既有发布回归。
+- AC:AC-02、AC-09、AC-34、AC-35、AC-36、AC-37、AC-38、AC-39、AC-40、AC-41、AC-42、AC-43、AC-44、AC-46。
+
+### T036 F054/F055安全上线
+
+- 文件:[publish_online_service.py](../../../src/backend/bisheng/app_publish/domain/services/publish_online_service.py)、[app_state_service.py](../../../src/backend/bisheng/app_runtime/domain/services/app_state_service.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:backend 真SQL/服务/ASGI合同与既有发布回归。
+- AC:AC-02、AC-09、AC-34、AC-35、AC-36、AC-37、AC-38、AC-39、AC-40、AC-41、AC-42、AC-43、AC-44、AC-46。
+
+### T037 入口读取平台路由合同与失败测试
+
+- 文件:[test_platform_route_v2.py](../../../src/app-proxy/tests/test_platform_route_v2.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:独立进程单元/合同或配置静态检查。
+- AC:AC-04、AC-18、AC-39、AC-48、AC-49、AC-50、AC-51、AC-53。
+
+### T038 入口读取平台路由
+
+- 文件:[clients.py](../../../src/app-proxy/app_proxy/clients.py)、[config.py](../../../src/app-proxy/app_proxy/config.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:独立进程单元/合同或配置静态检查。
+- AC:AC-04、AC-18、AC-39、AC-48、AC-49、AC-50、AC-51、AC-53。
+
+### T039 出口读取平台策略合同与失败测试
+
+- 文件:[test_platform_egress.py](../../../src/runtime-manager/tests/test_platform_egress.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:独立进程单元/合同或配置静态检查。
+- AC:AC-04、AC-39、AC-48、AC-49、AC-50、AC-51、AC-52、AC-53。
+
+### T040 出口读取平台策略
+
+- 文件:[egress_proxy.py](../../../src/runtime-manager/runtime_manager/egress_proxy.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:独立进程单元/合同或配置静态检查。
+- AC:AC-04、AC-39、AC-48、AC-49、AC-50、AC-51、AC-52、AC-53。
+
+### T041 环境导入及owner维护API合同与失败测试
+
+- 文件:[test_runtime_management_api.py](../../../src/backend/test/app_runtime/test_runtime_management_api.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:backend 真SQL/服务/ASGI合同与既有发布回归。
+- AC:AC-14、AC-15、AC-24、AC-28、AC-30、AC-35、AC-45、AC-47、AC-53。
+
+### T042 环境导入及owner维护API
+
+- 文件:[runtime_management.py](../../../src/backend/bisheng/app_runtime/api/endpoints/runtime_management.py)、[runtime_management_service.py](../../../src/backend/bisheng/app_runtime/domain/services/runtime_management_service.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:backend 真SQL/服务/ASGI合同与既有发布回归。
+- AC:AC-14、AC-15、AC-24、AC-28、AC-30、AC-35、AC-45、AC-47、AC-53。
+
+### T043 离线制作与环境工具合同与失败测试
+
+- 文件:[test_offline_materials.py](../../../src/runtime-manager/tests/test_offline_materials.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:独立进程单元/合同或配置静态检查。
+- AC:AC-14、AC-15、AC-27、AC-28、AC-29、AC-30、AC-31、AC-55、AC-56、AC-58、AC-59。
+
+### T044 离线制作与环境工具
+
+- 文件:[runtime_materials.py](../../../src/runtime-manager/tools/runtime_materials.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:独立进程单元/合同或配置静态检查。
+- AC:AC-14、AC-15、AC-27、AC-28、AC-29、AC-30、AC-31、AC-55、AC-56、AC-58、AC-59。
+
+### T045 存量迁移与发布物兼容合同与失败测试
+
+- 文件:[test_runtime_upgrade.py](../../../src/backend/test/app_runtime/test_runtime_upgrade.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:backend 真SQL/服务/ASGI合同与既有发布回归。
+- AC:AC-03、AC-08、AC-10、AC-13、AC-46、AC-57、AC-60。
+
+### T046 存量迁移与发布物兼容
+
+- 文件:[upgrade_vibe_runtime.py](../../../src/backend/scripts/upgrade_vibe_runtime.py)、[README.md](../../../src/backend/scripts/README.md)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:backend 真SQL/服务/ASGI合同与既有发布回归。
+- AC:AC-03、AC-08、AC-10、AC-13、AC-46、AC-57、AC-60。
+
+### T047 镜像和Compose发行合同测试
+
+- 文件:[test_delivery_images.py](../../../src/runtime-manager/tests/test_delivery_images.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:独立进程单元/合同或配置静态检查。
+- AC:AC-01、AC-03、AC-04、AC-05、AC-54、AC-55、AC-56、AC-57、AC-58。
+
+### T048 manager与入口预构建镜像
+
+- 文件:[Dockerfile](../../../src/runtime-manager/Dockerfile)、[Dockerfile](../../../src/app-proxy/Dockerfile)
+- 结果:双架构原生构建/对应容器场景通过;正式CI发行独立受T053控制。
+- 证据:[真实部署记录](./live-validation.md),两侧各五项禁网容器场景;对应日志和镜像事实已归档。
+- AC:AC-01、AC-04、AC-05、AC-54、AC-55、AC-57。
+
+### T049 python3.11运行/准备镜像
+
+- 文件:[Dockerfile](../../../src/app-runtime/python3.11/Dockerfile)、[requirements.txt](../../../src/app-runtime/python3.11/requirements.txt)
+- 结果:双架构原生构建/对应容器场景通过;正式CI发行独立受T053控制。
+- 证据:[真实部署记录](./live-validation.md),两侧各五项禁网容器场景;对应日志和镜像事实已归档。
+- AC:AC-11、AC-17、AC-22、AC-25、AC-31、AC-54、AC-55、AC-59。
+
+### T050 node20运行/准备镜像
+
+- 文件:[Dockerfile](../../../src/app-runtime/node20/Dockerfile)
+- 结果:双架构原生构建/对应容器场景通过;正式CI发行独立受T053控制。
+- 证据:[真实部署记录](./live-validation.md),两侧各五项禁网容器场景;对应日志和镜像事实已归档。
+- AC:AC-11、AC-17、AC-22、AC-25、AC-31、AC-54、AC-55、AC-59。
+
+### T051 static运行/准备镜像
+
+- 文件:[Dockerfile](../../../src/app-runtime/static/Dockerfile)、[entrypoint.sh](../../../src/app-runtime/static/entrypoint.sh)
+- 结果:双架构原生构建/对应容器场景通过;正式CI发行独立受T053控制。
+- 证据:[真实部署记录](./live-validation.md),两侧各五项禁网容器场景;对应日志和镜像事实已归档。
+- AC:AC-11、AC-17、AC-22、AC-25、AC-31、AC-54、AC-55、AC-59。
+
+### T052 Compose与安装检查
+
+- 文件:[docker-compose.yml](../../../docker/docker-compose.yml)、[verify_app_runtime_compose.py](../../../docker/verify_app_runtime_compose.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:独立进程单元/合同或配置静态检查。
+- AC:AC-01、AC-03、AC-04、AC-05、AC-18、AC-52、AC-54、AC-56、AC-58。
+
+### T053 CI/Release双架构交付
+
+- 文件:[runtime-images.yml](../../../.github/workflows/runtime-images.yml)
+- 结果:未完成。本地双原生验证通过,GitHub工作流/不可覆盖发布/官方目录未实际发行。
+- 证据:[真实部署记录及剩余门](./live-validation.md)、validation.json;未完成项不能用skip或本地测试替代。
+- AC:AC-54、AC-55、AC-56、AC-57。
+
+### T054 CLI可分发wheel及锁定依赖
+
+- 文件:[bisheng_cli-3.0.0-py3-none-any.whl](../../../src/backend/bisheng/dev_toolkit/artifacts/bisheng_cli-3.0.0-py3-none-any.whl)、[manifest.json](../../../src/backend/bisheng/dev_toolkit/artifacts/manifest.json)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:backend 真SQL/服务/ASGI合同与既有发布回归。
+- AC:AC-05、AC-13、AC-54、AC-60。
+
+### T055 API E2E与故障证据
+
+- 文件:[test_e2e_f086_runtime_delivery.py](../../../src/backend/test/e2e/test_e2e_f086_runtime_delivery.py)、[e2e-checklist.md](../../../features/v3.0.0/086-vibe-runtime-delivery/e2e-checklist.md)
+- 结果:未完成。Docker核心旅程通过;DM8、多租户和完整故障/升级验收未完成。
+- 证据:[真实部署记录及剩余门](./live-validation.md)、validation.json;未完成项不能用skip或本地测试替代。
+- AC:AC-01、AC-02、AC-03、AC-04、AC-05、AC-06、AC-07、AC-08、AC-09、AC-10、AC-11、AC-12、AC-13、AC-14、AC-15、AC-16、AC-17、AC-18、AC-19、AC-20、AC-21、AC-22、AC-23、AC-24、AC-25、AC-26、AC-27、AC-28、AC-29、AC-30、AC-31、AC-32、AC-33、AC-34、AC-35、AC-36、AC-37、AC-38、AC-39、AC-40、AC-41、AC-42、AC-43、AC-44、AC-45、AC-46、AC-47、AC-48、AC-49、AC-50、AC-51、AC-52、AC-53、AC-54、AC-55、AC-56、AC-57、AC-58、AC-59、AC-60、AC-61、AC-62、AC-63。
+
+### T056 最终审查和证据汇总
+
+- 文件:[task-reviews.md](../../../features/v3.0.0/086-vibe-runtime-delivery/task-reviews.md)、[validation.json](../../../features/v3.0.0/086-vibe-runtime-delivery/validation.json)
+- 结果:未完成。运行及发行门未关闭,最终审查保持NEEDS_FIX。
+- 证据:[真实部署记录及剩余门](./live-validation.md)、validation.json;未完成项不能用skip或本地测试替代。
+- AC:AC-01、AC-02、AC-03、AC-04、AC-05、AC-06、AC-07、AC-08、AC-09、AC-10、AC-11、AC-12、AC-13、AC-14、AC-15、AC-16、AC-17、AC-18、AC-19、AC-20、AC-21、AC-22、AC-23、AC-24、AC-25、AC-26、AC-27、AC-28、AC-29、AC-30、AC-31、AC-32、AC-33、AC-34、AC-35、AC-36、AC-37、AC-38、AC-39、AC-40、AC-41、AC-42、AC-43、AC-44、AC-45、AC-46、AC-47、AC-48、AC-49、AC-50、AC-51、AC-52、AC-53、AC-54、AC-55、AC-56、AC-57、AC-58、AC-59、AC-60、AC-61、AC-62、AC-63。
+
+### T057 准备执行器与旧模板适配
+
+- 文件:[container_runner.py](../../../src/runtime-manager/runtime_manager/container_runner.py)、[legacy_prepare.py](../../../src/runtime-manager/runtime_manager/legacy_prepare.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:独立进程单元/合同或配置静态检查。
+- AC:AC-11、AC-13、AC-19、AC-26、AC-53、AC-63。
+
+### T058 发布交付协调器
+
+- 文件:[runtime_delivery_coordinator.py](../../../src/backend/bisheng/app_runtime/domain/services/runtime_delivery_coordinator.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:backend 真SQL/服务/ASGI合同与既有发布回归。
+- AC:AC-05、AC-15、AC-33、AC-37、AC-39、AC-41、AC-63。
+
+### T059 凭据保护与执行代际读取
+
+- 文件:[runtime_payload.py](../../../src/backend/bisheng/app_runtime/domain/services/runtime_payload.py)、[runtime_credential_repository.py](../../../src/backend/bisheng/app_runtime/domain/repositories/runtime_credential_repository.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:backend 真SQL/服务/ASGI合同与既有发布回归。
+- AC:AC-39、AC-51、AC-53。
+
+### T060 路由和出站投影业务
+
+- 文件:[runtime_projection_service.py](../../../src/backend/bisheng/app_runtime/domain/services/runtime_projection_service.py)、[runtime_egress_service.py](../../../src/backend/bisheng/app_runtime/domain/services/runtime_egress_service.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:backend 真SQL/服务/ASGI合同与既有发布回归。
+- AC:AC-39、AC-48、AC-49、AC-50、AC-51、AC-53。
+
+### T061 存量与离线内容校验业务
+
+- 文件:[runtime_upgrade_service.py](../../../src/backend/bisheng/app_runtime/domain/services/runtime_upgrade_service.py)、[runtime_offline_package.py](../../../src/backend/bisheng/app_runtime/domain/services/runtime_offline_package.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:backend 真SQL/服务/ASGI合同与既有发布回归。
+- AC:AC-07、AC-13、AC-27、AC-30、AC-46、AC-57、AC-60。
+
+### T062 控制客户端与范围回收
+
+- 文件:[runtime_control_client.py](../../../src/backend/bisheng/app_runtime/domain/services/runtime_control_client.py)、[runtime_cleanup_service.py](../../../src/backend/bisheng/app_runtime/domain/services/runtime_cleanup_service.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:backend 真SQL/服务/ASGI合同与既有发布回归。
+- AC:AC-06、AC-09、AC-10、AC-26、AC-63。
+
+### T063 manager启动和旧协议拒绝
+
+- 文件:[main.py](../../../src/runtime-manager/runtime_manager/main.py)、[auth.py](../../../src/runtime-manager/runtime_manager/auth.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:独立进程单元/合同或配置静态检查。
+- AC:AC-04、AC-06、AC-48、AC-53、AC-58。
+
+### T064 SQLite数据容器合同测试
+
+- 文件:[test_data_executor.py](../../../src/runtime-manager/tests/test_data_executor.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:独立进程单元/合同或配置静态检查。
+- AC:AC-47、AC-52、AC-53。
+
+### T065 受控数据执行API
+
+- 文件:[platform_data.py](../../../src/runtime-manager/runtime_manager/api/platform_data.py)、[data_executor.py](../../../src/runtime-manager/runtime_manager/data_executor.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:独立进程单元/合同或配置静态检查。
+- AC:AC-47、AC-52、AC-53。
+
+### T066 单应用数据入口与只读观测
+
+- 文件:[data_worker.py](../../../src/runtime-manager/runtime_manager/data_worker.py)、[platform_observe.py](../../../src/runtime-manager/runtime_manager/api/platform_observe.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:独立进程单元/合同或配置静态检查。
+- AC:AC-47、AC-48、AC-50、AC-53。
+
+### T067 旧运行绑定只读inventory
+
+- 文件:[upgrade.py](../../../src/runtime-manager/runtime_manager/api/upgrade.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:独立进程单元/合同或配置静态检查。
+- AC:AC-13、AC-46、AC-57、AC-60。
+
+### T068 出口平台策略缓存适配
+
+- 文件:[platform_egress.py](../../../src/runtime-manager/runtime_manager/platform_egress.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:独立进程单元/合同或配置静态检查。
+- AC:AC-49、AC-50、AC-52。
+
+### T069 入口协议与运行接线
+
+- 文件:[platform_protocol.py](../../../src/app-proxy/app_proxy/platform_protocol.py)、[main.py](../../../src/app-proxy/app_proxy/main.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:独立进程单元/合同或配置静态检查。
+- AC:AC-48、AC-49、AC-53。
+
+### T070 审批预览与授权接线
+
+- 文件:[preview_instance_service.py](../../../src/backend/bisheng/app_publish/domain/services/preview_instance_service.py)、[entry_authz_service.py](../../../src/backend/bisheng/app_runtime/domain/services/entry_authz_service.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:backend 真SQL/服务/ASGI合同与既有发布回归。
+- AC:AC-09、AC-33、AC-48、AC-51。
+
+### T071 SDK中间件与创建入口约束
+
+- 文件:[http_middleware.py](../../../src/backend/bisheng/utils/http_middleware.py)、[test_runtime_layer_switch.py](../../../src/backend/test/app_runtime/test_runtime_layer_switch.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:backend 真SQL/服务/ASGI合同与既有发布回归。
+- AC:AC-05、AC-07、AC-53、AC-60。
+
+### T072 离线与owner业务测试
+
+- 文件:[test_runtime_management.py](../../../src/backend/test/app_runtime/test_runtime_management.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:backend 真SQL/服务/ASGI合同与既有发布回归。
+- AC:AC-07、AC-14、AC-15、AC-30、AC-45、AC-53。
+
+### T073 数据恢复审计登记
+
+- 文件:[audit_log.py](../../../src/backend/bisheng/database/models/audit_log.py)、[constants.py](../../../src/backend/bisheng/app_runtime/domain/constants.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:backend 真SQL/服务/ASGI合同与既有发布回归。
+- AC:AC-45、AC-47。
+
+### T074 Platform审计筛选接线
+
+- 文件:[log.ts](../../../src/frontend/platform/src/controllers/API/log.ts)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:冻结锁依赖typecheck及i18n;全量lint基线失败,动作字典与原审计白名单定向核对。
+- AC:AC-47。
+
+### T075 审计三语文案第一组
+
+- 文件:[bs.json](../../../src/frontend/platform/public/locales/en-US/bs.json)、[bs.json](../../../src/frontend/platform/public/locales/zh-Hans/bs.json)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:冻结锁依赖typecheck及i18n;全量lint基线失败,动作字典与原审计白名单定向核对。
+- AC:AC-47。
+
+### T076 审计三语文案日语
+
+- 文件:[bs.json](../../../src/frontend/platform/public/locales/ja/bs.json)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:冻结锁依赖typecheck及i18n;全量lint基线失败,动作字典与原审计白名单定向核对。
+- AC:AC-47。
+
+### T077 原生镜像构建与容器烟测
+
+- 文件:[build_runtime_images.sh](../../../scripts/build_runtime_images.sh)、[smoke_runtime_delivery.py](../../../scripts/smoke_runtime_delivery.py)
+- 结果:双架构原生构建/对应容器场景通过;正式CI发行独立受T053控制。
+- 证据:[真实部署记录](./live-validation.md),两侧各五项禁网容器场景;对应日志和镜像事实已归档。
+- AC:AC-11、AC-19、AC-20、AC-31、AC-41、AC-54、AC-55、AC-56。
+
+### T078 镜像内统一准备器
+
+- 文件:[prepare.py](../../../src/app-runtime/common/prepare.py)
+- 结果:双架构原生构建/对应容器场景通过;正式CI发行独立受T053控制。
+- 证据:[真实部署记录](./live-validation.md),两侧各五项禁网容器场景;对应日志和镜像事实已归档。
+- AC:AC-19、AC-21、AC-24、AC-25、AC-27、AC-28、AC-29、AC-53。
+
+### T079 static无root入口配置
+
+- 文件:[nginx.conf](../../../src/app-runtime/static/nginx.conf)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:独立进程单元/合同或配置静态检查。
+- AC:AC-11、AC-17、AC-25、AC-54。
+
+### T080 独立开发构建覆盖
+
+- 文件:[docker-compose.runtime-dev.yml](../../../docker/docker-compose.runtime-dev.yml)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:独立进程单元/合同或配置静态检查。
+- AC:AC-54、AC-58。
+
+### T081 安装环境与校验脚本
+
+- 文件:[.env.example](../../../docker/.env.example)、[verify-app-runtime-compose.sh](../../../docker/verify-app-runtime-compose.sh)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:独立进程单元/合同或配置静态检查。
+- AC:AC-03、AC-04、AC-53、AC-58。
+
+### T082 manager冻结依赖合同
+
+- 文件:[pyproject.toml](../../../src/runtime-manager/pyproject.toml)、[uv.lock](../../../src/runtime-manager/uv.lock)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:独立进程单元/合同或配置静态检查。
+- AC:AC-04、AC-05、AC-54、AC-57。
+
+### T083 安装维护文档
+
+- 文件:[OPERATIONS.md](../../../src/runtime-manager/OPERATIONS.md)、[14-app-factory-deployment.md](../../../docs/architecture/14-app-factory-deployment.md)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:接口/字段/目录按工作树同步,输入评审文档哈希保持。
+- AC:AC-03、AC-27、AC-28、AC-45、AC-46、AC-54、AC-57、AC-58、AC-59、AC-62。
+
+### T084 CLI及版本交付登记
+
+- 文件:[README.md](../../../src/bisheng-cli/README.md)、[release-contract.md](../../../features/v3.0.0/release-contract.md)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:接口/字段/目录按工作树同步,输入评审文档哈希保持。
+- AC:AC-13、AC-30、AC-54、AC-58、AC-60、AC-62。
+
+### T085 离线补包入口流式代理
+
+- 文件:[default.conf](../../../docker/nginx/conf.d/default.conf)
+- 结果:双架构原生构建/对应容器场景通过;正式CI发行独立受T053控制。
+- 证据:[真实部署记录](./live-validation.md),两侧各五项禁网容器场景;对应日志和镜像事实已归档。
+- AC:AC-05、AC-07、AC-27、AC-28、AC-58。
+
+### T086 平台执行事实与旧RPC适配
+
+- 文件:[runtime_operation_service.py](../../../src/backend/bisheng/app_runtime/domain/services/runtime_operation_service.py)、[orchestrator_client.py](../../../src/backend/bisheng/app_runtime/domain/services/orchestrator_client.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:backend 真SQL/服务/ASGI合同与既有发布回归。
+- AC:AC-06、AC-29、AC-35、AC-36、AC-37、AC-48、AC-50、AC-53、AC-63。
+
+### T087 CLI既有入口回归适配
+
+- 文件:[test_cli.py](../../../src/bisheng-cli/tests/test_cli.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:独立进程单元/合同或配置静态检查。
+- AC:AC-11、AC-13、AC-17、AC-60。
+
+### T088 CLI命令分发接线
+
+- 文件:[cli.py](../../../src/bisheng-cli/bisheng_cli/cli.py)、[main.py](../../../src/bisheng-cli/bisheng_cli/main.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:独立进程单元/合同或配置静态检查。
+- AC:AC-11、AC-13、AC-17、AC-60。
+
+### T089 旧策略文件范围标识
+
+- 文件:[egress.py](../../../src/runtime-manager/runtime_manager/egress.py)
+- 结果:本地任务合同完成;运行边界见验证汇总。
+- 证据:接口/字段/目录按工作树同步,输入评审文档哈希保持。
+- AC:AC-49、AC-50。
+
+## 2026-10-21 多租户启动增量审查
+
+### T090 后台租户上下文执行边界
+
+- 范围:`src/backend/bisheng/core/context/tenant.py`、`src/backend/test/tenant/test_tenant_execution_scope.py`。
+- AC:AC-61、AC-62。
+- L1:PASS;绑定已验证目标租户,临时收窄visible set并清除继承的admin override/bypass,嵌套、异常和并发均恢复原上下文。
+- 证据:修复前新增回归3 failed/7 passed,修复后包含租户过滤/owner投影的组合回归56 passed;六个Python文件Ruff与arch-guard通过。109真实新库多租户启动初始化18项系统资源,首管理员注册成功。
+
+### T091 系统资源启动投影租户绑定
+
+- 范围:`src/backend/bisheng/permission/application/system_resource_reconcile.py`、`src/backend/test/permission/test_f048_system_resource_reconcile.py`。
+- AC:AC-61、AC-62。
+- L1:PASS;每个系统资源投影使用其可信tenant_id执行,保留原权限授权入口,不以全局bypass规避过滤。
+- 证据:修复前新增回归3 failed/7 passed,修复后包含租户过滤/owner投影的组合回归56 passed;六个Python文件Ruff与arch-guard通过。109真实新库多租户启动初始化18项系统资源,首管理员注册成功。
+
+### T092 首次管理员仪表盘投影上下文
+
+- 范围:`src/backend/bisheng/api/services/f048_preset_dashboard_bootstrap.py`、`src/backend/test/permission/test_f048_preset_dashboard_bootstrap.py`。
+- AC:AC-61、AC-62。
+- L1:PASS;首次自然注册及启动对账的预置dashboard owner投影显式绑定Root租户;已有owner与投影的幂等逻辑保持。
+- 证据:修复前新增回归3 failed/7 passed,修复后包含租户过滤/owner投影的组合回归56 passed;六个Python文件Ruff与arch-guard通过。109真实新库多租户启动初始化18项系统资源,首管理员注册成功。
+
+该次审查只覆盖新增修复;T053、T055、T056仍开放,不据此宣称全Feature可合并。
+
+## 模型静态审查增量(用户确认DM8实测暂缓)
+
+### T093 字节计数范围与模型规范检查
+
+- L1:PASS;size_bytes和offset使用BigInteger,覆盖15表JsonType/时间默认/租户/唯一约束和索引静态检查,符合2GiB材料和10GiB快照合同;DM8不连接。
+- 文件:`src/backend/bisheng/app_runtime/domain/models/runtime_delivery.py`、`src/backend/test/app_runtime/test_runtime_model_contracts.py`。
+- AC:AC-6、AC-35、AC-61。
+- 证据:model-review.md / model-review.json / model-review-tests.txt;没有连接真实DM8或执行新revision的真实升级。
+
+### T094 已有运行时字节字段扩容迁移
+
+- L1:PASS;挂接已核对single head merge_vibe_f077_heads,DDL-only INTEGER到BIGINT;新库/已扩容跳过,异常类型失败,降级保留宽列;组合28项回归通过。
+- 文件:`src/backend/bisheng/core/database/alembic/versions/f086_runtime_byte_counters.py`、`src/backend/test/app_runtime/test_runtime_model_contracts.py`。
+- AC:AC-6、AC-35、AC-61。
+- 证据:model-review.md / model-review.json / model-review-tests.txt;没有连接真实DM8或执行新revision的真实升级。
+
+## 2026-10-21 测试发行增量审查:T053 / T077
+
+- L1:PASS,同一代理复核,无独立代理评审。范围仅.github/workflows/runtime-images.yml与scripts/build_runtime_images.sh两处grep替换,commit 755d4456e5;无服务接口/权限/数据模型变更。分层、数据库、前端约定不适用;命名、无凭据泄露、设计同步通过,前置T052/T047已完成。
+- shell与YAML语法通过;真实脚本guard的缺标签/已存在/认证失败三个隔离分支通过,现有镜像合同2 passed。已有标签拒绝为本地受控Registry结果验证,未额外重跑公开发行去覆盖既有标签。
+- [GitHub原生发行](https://github.com/dataelement/bisheng/actions/runs/39567381522)三job成功;两架构各7镜像、应用包原生集成、多架构服务索引和catalog成功。109公开服务镜像隔离启动/访问检查通过,原27服务容器不变,测试资源已回收。
+- T053关闭;当前92/94,T055/T056仍开放。正式生产版本、完整断网新装、所有故障组合、平台目录导入不据此声称完成。证据:e2e-evidence/2026-10-21-ci-release/。
+
+## 2026-10-23 开发整合定向复核
+
+用户明确授权先收尾开发、合入并推送 feat/3.0.0-vibe,避免全量回归。F059 T274(infra,两文件)CI同版本跨入口并发锁:新增现有契约断言先1失败,实际key修正后1通过;Ruff/format/架构门通过。两个模型/测试冲突逐hunk保留F059后续tenant/UID语义,与原实现相同,定向模型合同17通过(DM8仅静态)。新WT配置缺失的收集错误已用现有本地测试配置解决,不记作产品失败。三条独立L2分工代码无HIGH,设计D1定向复审关闭,开发合入PASS_WITH_WARNINGS。完整交付门保持pending;无全量或远端重验。详见code-review.md与整合报告。
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/tasks-index.json b/features/v3.0.0/086-vibe-runtime-delivery/tasks-index.json
new file mode 100644
index 0000000000..108d8a25c6
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/tasks-index.json
@@ -0,0 +1,2291 @@
+[
+  {
+    "id": "T001",
+    "title": "平台持久对象模型",
+    "files": [
+      "src/backend/bisheng/app_runtime/domain/models/runtime_delivery.py"
+    ],
+    "logic": "定义profile/default、artifact、delivery、operation、assignment、reservation、node、fact、route、policy和transfer。使用JsonType/共享时间默认/关系唯一键,应用对象保留原Owner;新表由model discovery建表,回退先停止新控制写入并保留表和资产,不破坏性drop。",
+    "ac": [
+      6,
+      15,
+      19,
+      35,
+      36,
+      50,
+      61
+    ],
+    "deps": [],
+    "kind": "基础设施",
+    "status": "local_verified"
+  },
+  {
+    "id": "T002",
+    "title": "模型发现注册",
+    "files": [
+      "src/backend/bisheng/core/database/tenant_filter.py",
+      "src/backend/bisheng/app_runtime/domain/models/__init__.py"
+    ],
+    "logic": "通过领域包导入模型,租户表在过滤器初始化前注册;全局目录无tenant_id,明确超管入口,应用表保持自动隔离。",
+    "ac": [
+      14,
+      30,
+      50,
+      61
+    ],
+    "deps": [
+      "T001"
+    ],
+    "kind": "基础设施",
+    "status": "local_verified"
+  },
+  {
+    "id": "T003",
+    "title": "平台运行交付配置",
+    "files": [
+      "src/backend/bisheng/core/config/app_runtime.py"
+    ],
+    "logic": "在已有app_runtime子键增加内部服务身份、文件限额/回收及协议设置,默认fail-closed;不新增YAML顶级键,保留旧配置可加载。",
+    "ac": [
+      3,
+      5,
+      53,
+      58
+    ],
+    "deps": [],
+    "kind": "基础设施",
+    "status": "local_verified"
+  },
+  {
+    "id": "T004",
+    "title": "manager执行配置",
+    "files": [
+      "src/runtime-manager/runtime_manager/config.py"
+    ],
+    "logic": "加入固定node/platform身份、操作超时/回收设置、平台传输身份;旧存储参数在迁移前仅兼容解析,完成切换删除依赖。",
+    "ac": [
+      4,
+      22,
+      53,
+      57,
+      58,
+      63
+    ],
+    "deps": [],
+    "kind": "基础设施",
+    "status": "local_verified"
+  },
+  {
+    "id": "T005",
+    "title": "平台操作/容量Repository合同与失败测试",
+    "files": [
+      "src/backend/test/app_runtime/test_runtime_delivery_repository.py",
+      "src/backend/test/app_runtime/test_runtime_delivery_database.py"
+    ],
+    "logic": "SQL事务约束幂等键/请求摘要、node预算锁/预占、app归属CAS、事实顺序和路由提交;隔离未知执行体,确认停止后释放;不在Service写ORM。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。;可选真实SQL用例使用e2e_f086专用库,同步竞争读时点,验证MySQL重复键当前读、首次归属互斥、容量与回滚;DM8按用户确认完成模型静态审查,实测交中央回归。",
+    "ac": [
+      6,
+      34,
+      35,
+      36,
+      37,
+      39,
+      50,
+      61,
+      63
+    ],
+    "deps": [
+      "T001",
+      "T002"
+    ],
+    "kind": "测试",
+    "status": "local_verified"
+  },
+  {
+    "id": "T006",
+    "title": "平台操作/容量Repository",
+    "files": [
+      "src/backend/bisheng/app_runtime/domain/repositories/runtime_delivery_repository.py"
+    ],
+    "logic": "SQL事务约束幂等键/请求摘要、node预算锁/预占、app归属CAS、事实顺序和路由提交;隔离未知执行体,确认停止后释放;不在Service写ORM。",
+    "ac": [
+      6,
+      34,
+      35,
+      36,
+      37,
+      39,
+      50,
+      61,
+      63
+    ],
+    "deps": [
+      "T001",
+      "T002",
+      "T005"
+    ],
+    "kind": "实现",
+    "status": "local_verified"
+  },
+  {
+    "id": "T007",
+    "title": "版本2服务身份与重放保护合同与失败测试",
+    "files": [
+      "src/backend/test/app_runtime/test_runtime_protocol_v2.py"
+    ],
+    "logic": "签名绑定method/path/query/body摘要/时间/nonce/key_id;平台nonce持久端口fail-closed,不同服务动作隔离;上传用范围session,manager禁止任意URL。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。",
+    "ac": [
+      6,
+      7,
+      49,
+      52,
+      53
+    ],
+    "deps": [
+      "T003",
+      "T004",
+      "T006"
+    ],
+    "kind": "测试",
+    "status": "local_verified"
+  },
+  {
+    "id": "T008",
+    "title": "版本2服务身份与重放保护",
+    "files": [
+      "src/backend/bisheng/app_runtime/domain/services/runtime_protocol.py",
+      "src/runtime-manager/runtime_manager/platform_protocol.py"
+    ],
+    "logic": "签名绑定method/path/query/body摘要/时间/nonce/key_id;平台nonce持久端口fail-closed,不同服务动作隔离;上传用范围session,manager禁止任意URL。",
+    "ac": [
+      6,
+      7,
+      49,
+      52,
+      53
+    ],
+    "deps": [
+      "T003",
+      "T004",
+      "T006",
+      "T007"
+    ],
+    "kind": "实现",
+    "status": "local_verified"
+  },
+  {
+    "id": "T009",
+    "title": "平台产物传输合同与失败测试",
+    "files": [
+      "src/backend/test/app_runtime/test_runtime_artifacts.py"
+    ],
+    "logic": "streaming限额及hash、MinIO私有桶、upload/finalize/status幂等、代码/依赖/快照各独立上限;内容和元数据确认后verified;确认不明保护快照。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。",
+    "ac": [
+      5,
+      6,
+      7,
+      10,
+      19,
+      23,
+      28,
+      42,
+      43,
+      53
+    ],
+    "deps": [
+      "T006",
+      "T008"
+    ],
+    "kind": "测试",
+    "status": "local_verified"
+  },
+  {
+    "id": "T010",
+    "title": "平台产物传输",
+    "files": [
+      "src/backend/bisheng/app_runtime/domain/services/runtime_artifact_service.py",
+      "src/backend/bisheng/app_runtime/domain/repositories/runtime_object_store.py"
+    ],
+    "logic": "streaming限额及hash、MinIO私有桶、upload/finalize/status幂等、代码/依赖/快照各独立上限;内容和元数据确认后verified;确认不明保护快照。",
+    "ac": [
+      5,
+      6,
+      7,
+      10,
+      19,
+      23,
+      28,
+      42,
+      43,
+      53
+    ],
+    "deps": [
+      "T006",
+      "T008",
+      "T009"
+    ],
+    "kind": "实现",
+    "status": "local_verified"
+  },
+  {
+    "id": "T011",
+    "title": "环境目录和授权边界合同与失败测试",
+    "files": [
+      "src/backend/test/app_runtime/test_runtime_profiles.py"
+    ],
+    "logic": "不可变环境修订/平台镜像记录/默认CAS;超管管理全局、自然人owner管理私包,验证私包app/tenant;精确冻结平台和启动约定,拒绝可变tag假摘要。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。",
+    "ac": [
+      11,
+      12,
+      14,
+      15,
+      16,
+      17,
+      21,
+      24,
+      30,
+      31,
+      55,
+      56,
+      57,
+      59
+    ],
+    "deps": [
+      "T006"
+    ],
+    "kind": "测试",
+    "status": "local_verified"
+  },
+  {
+    "id": "T012",
+    "title": "环境目录和授权边界",
+    "files": [
+      "src/backend/bisheng/app_runtime/domain/services/runtime_profile_service.py",
+      "src/backend/bisheng/app_runtime/domain/schemas/runtime_delivery.py"
+    ],
+    "logic": "不可变环境修订/平台镜像记录/默认CAS;超管管理全局、自然人owner管理私包,验证私包app/tenant;精确冻结平台和启动约定,拒绝可变tag假摘要。",
+    "ac": [
+      11,
+      12,
+      14,
+      15,
+      16,
+      17,
+      21,
+      24,
+      30,
+      31,
+      55,
+      56,
+      57,
+      59
+    ],
+    "deps": [
+      "T006",
+      "T011"
+    ],
+    "kind": "实现",
+    "status": "local_verified"
+  },
+  {
+    "id": "T013",
+    "title": "内部操作与投影API合同与失败测试",
+    "files": [
+      "src/backend/test/app_runtime/test_runtime_delivery_api.py"
+    ],
+    "logic": "注册internal/app-runtime/v2操作/claim/facts/传输/路由/policy接口,endpoint依赖服务,按节点与scope过滤;日志status仍由F054包装;内部路由不依赖JWT租户header,验服务身份后建立scope。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。",
+    "ac": [
+      6,
+      34,
+      35,
+      36,
+      37,
+      48,
+      49,
+      50,
+      53
+    ],
+    "deps": [
+      "T010",
+      "T012",
+      "T008"
+    ],
+    "kind": "测试",
+    "status": "local_verified"
+  },
+  {
+    "id": "T014",
+    "title": "内部操作与投影API",
+    "files": [
+      "src/backend/bisheng/app_runtime/api/endpoints/runtime_delivery.py",
+      "src/backend/bisheng/app_runtime/api/router.py"
+    ],
+    "logic": "注册internal/app-runtime/v2操作/claim/facts/传输/路由/policy接口,endpoint依赖服务,按节点与scope过滤;日志status仍由F054包装;内部路由不依赖JWT租户header,验服务身份后建立scope。",
+    "ac": [
+      6,
+      34,
+      35,
+      36,
+      37,
+      48,
+      49,
+      50,
+      53
+    ],
+    "deps": [
+      "T010",
+      "T012",
+      "T008",
+      "T013"
+    ],
+    "kind": "实现",
+    "status": "local_verified"
+  },
+  {
+    "id": "T015",
+    "title": "backend附件SDK兼容合同与失败测试",
+    "files": [
+      "src/backend/test/app_runtime/test_runtime_storage_api.py"
+    ],
+    "logic": "实现旧SDKobjects/meta/list/put/get/delete与错误信封,backend唯一MinIO访问;正式/preview独立作用域、大小、token撤销、跨app拒绝。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。",
+    "ac": [
+      5,
+      8,
+      9,
+      10,
+      38,
+      39,
+      51,
+      53,
+      60
+    ],
+    "deps": [
+      "T010",
+      "T014"
+    ],
+    "kind": "测试",
+    "status": "local_verified"
+  },
+  {
+    "id": "T016",
+    "title": "backend附件SDK兼容",
+    "files": [
+      "src/backend/bisheng/app_runtime/domain/services/app_storage_service.py",
+      "src/backend/bisheng/app_runtime/api/endpoints/app_storage.py"
+    ],
+    "logic": "实现旧SDKobjects/meta/list/put/get/delete与错误信封,backend唯一MinIO访问;正式/preview独立作用域、大小、token撤销、跨app拒绝。",
+    "ac": [
+      5,
+      8,
+      9,
+      10,
+      38,
+      39,
+      51,
+      53,
+      60
+    ],
+    "deps": [
+      "T010",
+      "T014",
+      "T015"
+    ],
+    "kind": "实现",
+    "status": "local_verified"
+  },
+  {
+    "id": "T017",
+    "title": "新版清单与旧模式兼容合同与失败测试",
+    "files": [
+      "src/backend/test/app_publish/test_manifest_delivery_v2.py"
+    ],
+    "logic": "v2源码默认bundle、显式source-image、完整image判别,v1继续旧构建;image按运行契约不走源码模板过滤,未知字段/冲突启动拒绝。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。",
+    "ac": [
+      11,
+      12,
+      13,
+      17,
+      18,
+      25,
+      33,
+      60
+    ],
+    "deps": [
+      "T012"
+    ],
+    "kind": "测试",
+    "status": "local_verified"
+  },
+  {
+    "id": "T018",
+    "title": "新版清单与旧模式兼容",
+    "files": [
+      "src/backend/bisheng/app_publish/domain/schemas/app_manifest.py",
+      "src/backend/bisheng/app_publish/domain/services/manifest_validator.py"
+    ],
+    "logic": "v2源码默认bundle、显式source-image、完整image判别,v1继续旧构建;image按运行契约不走源码模板过滤,未知字段/冲突启动拒绝。",
+    "ac": [
+      11,
+      12,
+      13,
+      17,
+      18,
+      25,
+      33,
+      60
+    ],
+    "deps": [
+      "T012",
+      "T017"
+    ],
+    "kind": "实现",
+    "status": "local_verified"
+  },
+  {
+    "id": "T019",
+    "title": "CLI应用包交付合同与失败测试",
+    "files": [
+      "src/bisheng-cli/tests/test_delivery_v2.py"
+    ],
+    "logic": "init默认v2,保留v1打包和状态轮询;包不带开发机venv/node_modules,锁/前端输入保留;清晰协议不兼容。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。",
+    "ac": [
+      7,
+      11,
+      13,
+      17,
+      25,
+      60
+    ],
+    "deps": [
+      "T018"
+    ],
+    "kind": "测试",
+    "status": "local_verified"
+  },
+  {
+    "id": "T020",
+    "title": "CLI应用包交付",
+    "files": [
+      "src/bisheng-cli/bisheng_cli/project.py",
+      "src/bisheng-cli/bisheng_cli/packaging.py"
+    ],
+    "logic": "init默认v2,保留v1打包和状态轮询;包不带开发机venv/node_modules,锁/前端输入保留;清晰协议不兼容。",
+    "ac": [
+      7,
+      11,
+      13,
+      17,
+      25,
+      60
+    ],
+    "deps": [
+      "T018",
+      "T019"
+    ],
+    "kind": "实现",
+    "status": "local_verified"
+  },
+  {
+    "id": "T021",
+    "title": "manager平台内容客户端合同与失败测试",
+    "files": [
+      "src/runtime-manager/tests/test_platform_client_v2.py"
+    ],
+    "logic": "签名HTTP客户端、claim/query/facts/transfer接口,大小/hash校验、恢复上传、只接受平台ID;v2异步操作API输出可关联operation;所有外部响应显式检查。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。",
+    "ac": [
+      5,
+      6,
+      7,
+      23,
+      36,
+      42,
+      43,
+      53
+    ],
+    "deps": [
+      "T014",
+      "T008"
+    ],
+    "kind": "测试",
+    "status": "local_verified"
+  },
+  {
+    "id": "T022",
+    "title": "manager平台内容客户端",
+    "files": [
+      "src/runtime-manager/runtime_manager/platform_client.py",
+      "src/runtime-manager/runtime_manager/api/operations.py"
+    ],
+    "logic": "签名HTTP客户端、claim/query/facts/transfer接口,大小/hash校验、恢复上传、只接受平台ID;v2异步操作API输出可关联operation;所有外部响应显式检查。",
+    "ac": [
+      5,
+      6,
+      7,
+      23,
+      36,
+      42,
+      43,
+      53
+    ],
+    "deps": [
+      "T014",
+      "T008",
+      "T021"
+    ],
+    "kind": "实现",
+    "status": "local_verified"
+  },
+  {
+    "id": "T023",
+    "title": "Python与Node准备和复用合同与失败测试",
+    "files": [
+      "src/runtime-manager/tests/test_bundle_preparer.py"
+    ],
+    "logic": "按scope/架构/ABI/镜像/锁/工具链/源码输入hash缓存;下载与无网安装编译隔离,固定wheelhouse/npm缓存,SDK入锁;只在成功后原子可见,缺包在审批前拒绝。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。",
+    "ac": [
+      19,
+      20,
+      21,
+      22,
+      23,
+      24,
+      25,
+      26,
+      27,
+      28,
+      29,
+      30,
+      31,
+      32,
+      59
+    ],
+    "deps": [
+      "T022"
+    ],
+    "kind": "测试",
+    "status": "local_verified"
+  },
+  {
+    "id": "T024",
+    "title": "Python与Node准备和复用",
+    "files": [
+      "src/runtime-manager/runtime_manager/bundle_preparer.py",
+      "src/runtime-manager/runtime_manager/bundle_inputs.py"
+    ],
+    "logic": "按scope/架构/ABI/镜像/锁/工具链/源码输入hash缓存;下载与无网安装编译隔离,固定wheelhouse/npm缓存,SDK入锁;只在成功后原子可见,缺包在审批前拒绝。",
+    "ac": [
+      19,
+      20,
+      21,
+      22,
+      23,
+      24,
+      25,
+      26,
+      27,
+      28,
+      29,
+      30,
+      31,
+      32,
+      59
+    ],
+    "deps": [
+      "T022",
+      "T023"
+    ],
+    "kind": "实现",
+    "status": "local_verified"
+  },
+  {
+    "id": "T025",
+    "title": "bundle与完整镜像启动合同与失败测试",
+    "files": [
+      "src/runtime-manager/tests/test_bundle_launch.py"
+    ],
+    "logic": "实际host bind路径,代码和依赖ro/data rw,固定argv、端口与健康;无host端口/高权参数;离线image ID内容映射与完整镜像无pip/npm。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。",
+    "ac": [
+      11,
+      12,
+      17,
+      18,
+      22,
+      41,
+      46,
+      52,
+      56,
+      57,
+      58
+    ],
+    "deps": [
+      "T024"
+    ],
+    "kind": "测试",
+    "status": "local_verified"
+  },
+  {
+    "id": "T026",
+    "title": "bundle与完整镜像启动",
+    "files": [
+      "src/runtime-manager/runtime_manager/bundle_launch.py",
+      "src/runtime-manager/runtime_manager/docker_backend.py"
+    ],
+    "logic": "实际host bind路径,代码和依赖ro/data rw,固定argv、端口与健康;无host端口/高权参数;离线image ID内容映射与完整镜像无pip/npm。",
+    "ac": [
+      11,
+      12,
+      17,
+      18,
+      22,
+      41,
+      46,
+      52,
+      56,
+      57,
+      58
+    ],
+    "deps": [
+      "T024",
+      "T025"
+    ],
+    "kind": "实现",
+    "status": "local_verified"
+  },
+  {
+    "id": "T027",
+    "title": "manager操作所有权和回收合同与失败测试",
+    "files": [
+      "src/runtime-manager/tests/test_operations_v2.py"
+    ],
+    "logic": "持久操作驱动、app串行、epoch/current claim、Engine标签查询幂等;无平台停止新破坏动作;终态/timeout/cancel/restart临时资源回收、未知保留容量、引用/DB/唯一备份保护。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。",
+    "ac": [
+      26,
+      34,
+      35,
+      36,
+      37,
+      39,
+      40,
+      50,
+      63
+    ],
+    "deps": [
+      "T022",
+      "T026"
+    ],
+    "kind": "测试",
+    "status": "local_verified"
+  },
+  {
+    "id": "T028",
+    "title": "manager操作所有权和回收",
+    "files": [
+      "src/runtime-manager/runtime_manager/operations.py"
+    ],
+    "logic": "持久操作驱动、app串行、epoch/current claim、Engine标签查询幂等;无平台停止新破坏动作;终态/timeout/cancel/restart临时资源回收、未知保留容量、引用/DB/唯一备份保护。",
+    "ac": [
+      26,
+      34,
+      35,
+      36,
+      37,
+      39,
+      40,
+      50,
+      63
+    ],
+    "deps": [
+      "T022",
+      "T026",
+      "T027"
+    ],
+    "kind": "实现",
+    "status": "local_verified"
+  },
+  {
+    "id": "T029",
+    "title": "SQLite平台快照与owner恢复合同与失败测试",
+    "files": [
+      "src/runtime-manager/tests/test_platform_appdb.py"
+    ],
+    "logic": "Backup API一致副本/校验、backend confirmed后DDL、丢响应查询同snapshot;停写单写入者、恢复先保护当前数据、失败不自动覆盖生产;保留受控数据API。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。",
+    "ac": [
+      5,
+      35,
+      41,
+      42,
+      43,
+      44,
+      45,
+      46,
+      47
+    ],
+    "deps": [
+      "T028",
+      "T010"
+    ],
+    "kind": "测试",
+    "status": "local_verified"
+  },
+  {
+    "id": "T030",
+    "title": "SQLite平台快照与owner恢复",
+    "files": [
+      "src/runtime-manager/runtime_manager/appdb.py",
+      "src/runtime-manager/runtime_manager/platform_appdb.py"
+    ],
+    "logic": "Backup API一致副本/校验、backend confirmed后DDL、丢响应查询同snapshot;停写单写入者、恢复先保护当前数据、失败不自动覆盖生产;保留受控数据API。",
+    "ac": [
+      5,
+      35,
+      41,
+      42,
+      43,
+      44,
+      45,
+      46,
+      47
+    ],
+    "deps": [
+      "T028",
+      "T010",
+      "T029"
+    ],
+    "kind": "实现",
+    "status": "local_verified"
+  },
+  {
+    "id": "T031",
+    "title": "F055交付准备接线合同与失败测试",
+    "files": [
+      "src/backend/test/app_publish/test_delivery_pipeline_v2.py"
+    ],
+    "logic": "源码下载只经backend;三交付模式统一prepare/probe/freeze;缺包/预检失败无审批,保留扫描、版本创建入口及原补偿。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。",
+    "ac": [
+      6,
+      11,
+      12,
+      13,
+      19,
+      29,
+      32,
+      33
+    ],
+    "deps": [
+      "T018",
+      "T028",
+      "T030"
+    ],
+    "kind": "测试",
+    "status": "local_verified"
+  },
+  {
+    "id": "T032",
+    "title": "F055交付准备接线",
+    "files": [
+      "src/backend/bisheng/app_publish/domain/services/publish_pipeline_service.py"
+    ],
+    "logic": "F055 的协议2 prepare/probe 接线放在 PublishPipelineService,经 RuntimeDeliveryCoordinator,不改协议1 precheck 的 URL 合同;Celery 仍使用原任务的 tenant headers→ContextVar。v1描述在协议2映射source-image;v2不在协议1静默发布。",
+    "ac": [
+      6,
+      11,
+      12,
+      13,
+      19,
+      29,
+      32,
+      33
+    ],
+    "deps": [
+      "T018",
+      "T028",
+      "T030",
+      "T031"
+    ],
+    "kind": "实现",
+    "status": "local_verified"
+  },
+  {
+    "id": "T033",
+    "title": "版本冻结与凭据代际合同与失败测试",
+    "files": [
+      "src/backend/test/app_runtime/test_runtime_lifecycle_v2.py"
+    ],
+    "logic": "delivery组合冻结,原四要素不变;候选只隔离凭据,切换生效/撤旧≤5秒,外部撤权不延迟;无重复签发/审批。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。",
+    "ac": [
+      15,
+      16,
+      19,
+      33,
+      36,
+      38,
+      51,
+      57,
+      60
+    ],
+    "deps": [
+      "T032"
+    ],
+    "kind": "测试",
+    "status": "local_verified"
+  },
+  {
+    "id": "T034",
+    "title": "版本冻结与凭据代际",
+    "files": [
+      "src/backend/bisheng/app_publish/domain/services/version_service.py",
+      "src/backend/bisheng/app_publish/domain/services/app_credential_service.py"
+    ],
+    "logic": "delivery组合冻结,原四要素不变;候选只隔离凭据,切换生效/撤旧≤5秒,外部撤权不延迟;无重复签发/审批。",
+    "ac": [
+      15,
+      16,
+      19,
+      33,
+      36,
+      38,
+      51,
+      57,
+      60
+    ],
+    "deps": [
+      "T032",
+      "T033"
+    ],
+    "kind": "实现",
+    "status": "local_verified"
+  },
+  {
+    "id": "T035",
+    "title": "F054/F055安全上线合同与失败测试",
+    "files": [
+      "src/backend/test/app_runtime/test_runtime_lifecycle_v2.py"
+    ],
+    "logic": "在停旧/DDL前原子预占,SQLite维护单写/快照确认,ready事实后事务切路由与原状态;失败/容量不足/停用恢复保持真实阶段;worker tenant_id随headers恢复。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。",
+    "ac": [
+      2,
+      9,
+      34,
+      35,
+      36,
+      37,
+      38,
+      39,
+      40,
+      41,
+      42,
+      43,
+      44,
+      46
+    ],
+    "deps": [
+      "T034",
+      "T030",
+      "T006"
+    ],
+    "kind": "测试",
+    "status": "local_verified"
+  },
+  {
+    "id": "T036",
+    "title": "F054/F055安全上线",
+    "files": [
+      "src/backend/bisheng/app_publish/domain/services/publish_online_service.py",
+      "src/backend/bisheng/app_runtime/domain/services/app_state_service.py"
+    ],
+    "logic": "在停旧/DDL前原子预占,SQLite维护单写/快照确认,ready事实后事务切路由与原状态;失败/容量不足/停用恢复保持真实阶段;worker tenant_id随headers恢复。",
+    "ac": [
+      2,
+      9,
+      34,
+      35,
+      36,
+      37,
+      38,
+      39,
+      40,
+      41,
+      42,
+      43,
+      44,
+      46
+    ],
+    "deps": [
+      "T034",
+      "T030",
+      "T006",
+      "T035"
+    ],
+    "kind": "实现",
+    "status": "local_verified"
+  },
+  {
+    "id": "T037",
+    "title": "入口读取平台路由合同与失败测试",
+    "files": [
+      "src/app-proxy/tests/test_platform_route_v2.py"
+    ],
+    "logic": "route读backend持久投影,无manager管理secret;manager短重启新HTTP/WS仍可取得目标,授权失败按TTL拒绝,保持身份清洗/OBO/预览。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。",
+    "ac": [
+      4,
+      18,
+      39,
+      48,
+      49,
+      50,
+      51,
+      53
+    ],
+    "deps": [
+      "T014",
+      "T036"
+    ],
+    "kind": "测试",
+    "status": "local_verified"
+  },
+  {
+    "id": "T038",
+    "title": "入口读取平台路由",
+    "files": [
+      "src/app-proxy/app_proxy/clients.py",
+      "src/app-proxy/app_proxy/config.py"
+    ],
+    "logic": "route读backend持久投影,无manager管理secret;manager短重启新HTTP/WS仍可取得目标,授权失败按TTL拒绝,保持身份清洗/OBO/预览。",
+    "ac": [
+      4,
+      18,
+      39,
+      48,
+      49,
+      50,
+      51,
+      53
+    ],
+    "deps": [
+      "T014",
+      "T036",
+      "T037"
+    ],
+    "kind": "实现",
+    "status": "local_verified"
+  },
+  {
+    "id": "T039",
+    "title": "出口读取平台策略合同与失败测试",
+    "files": [
+      "src/runtime-manager/tests/test_platform_egress.py"
+    ],
+    "logic": "平台policy只读身份/硬到期/撤销关CONNECT,本地只缓存,不挂应用卷不持管理密钥;保留L3/目标/DNS语义。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。",
+    "ac": [
+      4,
+      39,
+      48,
+      49,
+      50,
+      51,
+      52,
+      53
+    ],
+    "deps": [
+      "T014",
+      "T036"
+    ],
+    "kind": "测试",
+    "status": "local_verified"
+  },
+  {
+    "id": "T040",
+    "title": "出口读取平台策略",
+    "files": [
+      "src/runtime-manager/runtime_manager/egress_proxy.py"
+    ],
+    "logic": "平台policy只读身份/硬到期/撤销关CONNECT,本地只缓存,不挂应用卷不持管理密钥;保留L3/目标/DNS语义。",
+    "ac": [
+      4,
+      39,
+      48,
+      49,
+      50,
+      51,
+      52,
+      53
+    ],
+    "deps": [
+      "T014",
+      "T036",
+      "T039"
+    ],
+    "kind": "实现",
+    "status": "local_verified"
+  },
+  {
+    "id": "T041",
+    "title": "环境导入及owner维护API合同与失败测试",
+    "files": [
+      "src/backend/test/app_runtime/test_runtime_management_api.py"
+    ],
+    "logic": "顶级profile/default/base-import路由,app私包和restore接口;owner自然人业务guard先于管理员短路、expected generation/恢复损失确认及审计,服务账号不能独立发起。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。",
+    "ac": [
+      14,
+      15,
+      24,
+      28,
+      30,
+      35,
+      45,
+      47,
+      53
+    ],
+    "deps": [
+      "T016",
+      "T012",
+      "T036"
+    ],
+    "kind": "测试",
+    "status": "local_verified"
+  },
+  {
+    "id": "T042",
+    "title": "环境导入及owner维护API",
+    "files": [
+      "src/backend/bisheng/app_runtime/api/endpoints/runtime_management.py",
+      "src/backend/bisheng/app_runtime/domain/services/runtime_management_service.py"
+    ],
+    "logic": "顶级profile/default/base-import路由,app私包和restore接口;owner自然人业务guard先于管理员短路、expected generation/恢复损失确认及审计,服务账号不能独立发起。",
+    "ac": [
+      14,
+      15,
+      24,
+      28,
+      30,
+      35,
+      45,
+      47,
+      53
+    ],
+    "deps": [
+      "T016",
+      "T012",
+      "T036",
+      "T041"
+    ],
+    "kind": "实现",
+    "status": "local_verified"
+  },
+  {
+    "id": "T043",
+    "title": "离线制作与环境工具合同与失败测试",
+    "files": [
+      "src/runtime-manager/tests/test_offline_materials.py"
+    ],
+    "logic": "prepare/verify/import工具,manifest/hash/size/平台/ABI验证,制作机用Docker CLI、运行节点Engine API;双平台完整清单,默认配置导入SQL非共享文件。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。",
+    "ac": [
+      14,
+      15,
+      27,
+      28,
+      29,
+      30,
+      31,
+      55,
+      56,
+      58,
+      59
+    ],
+    "deps": [
+      "T042",
+      "T024"
+    ],
+    "kind": "测试",
+    "status": "local_verified"
+  },
+  {
+    "id": "T044",
+    "title": "离线制作与环境工具",
+    "files": [
+      "src/runtime-manager/tools/runtime_materials.py"
+    ],
+    "logic": "提供 catalog/export-profile/prepare-offline/pack/verify/import 工具;发行目录由CI真实inventory生成,不提交虚构digest。联网下载和禁网安装验证分离,临时副本不污染原源码;导入default用generation CAS,跳转不携会话。",
+    "ac": [
+      14,
+      15,
+      27,
+      28,
+      29,
+      30,
+      31,
+      55,
+      56,
+      58,
+      59
+    ],
+    "deps": [
+      "T042",
+      "T024",
+      "T043"
+    ],
+    "kind": "实现",
+    "status": "local_verified"
+  },
+  {
+    "id": "T045",
+    "title": "存量迁移与发布物兼容合同与失败测试",
+    "files": [
+      "src/backend/test/app_runtime/test_runtime_upgrade.py"
+    ],
+    "logic": "只通过scope API盘点/干跑/幂等导入,核对app与Engine标签、旧状态和版本;逐app部署绑定换backend storage,无改历史版本/免审代码修改;备份/回退限制明确。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。",
+    "ac": [
+      3,
+      8,
+      10,
+      13,
+      46,
+      57,
+      60
+    ],
+    "deps": [
+      "T042",
+      "T038",
+      "T040",
+      "T044"
+    ],
+    "kind": "测试",
+    "status": "local_verified"
+  },
+  {
+    "id": "T046",
+    "title": "存量迁移与发布物兼容",
+    "files": [
+      "src/backend/scripts/upgrade_vibe_runtime.py",
+      "src/backend/scripts/README.md"
+    ],
+    "logic": "只通过scope API盘点/干跑/幂等导入,核对app与Engine标签、旧状态和版本;逐app部署绑定换backend storage,无改历史版本/免审代码修改;备份/回退限制明确。",
+    "ac": [
+      3,
+      8,
+      10,
+      13,
+      46,
+      57,
+      60
+    ],
+    "deps": [
+      "T042",
+      "T038",
+      "T040",
+      "T044",
+      "T045"
+    ],
+    "kind": "实现",
+    "status": "local_verified"
+  },
+  {
+    "id": "T047",
+    "title": "镜像和Compose发行合同测试",
+    "files": [
+      "src/runtime-manager/tests/test_delivery_images.py"
+    ],
+    "logic": "检查正式compose仅image、可选profile、代理权限/volume、双平台清单与预制运行/准备镜像合同;不以静态测试代替双架构运行。",
+    "ac": [
+      1,
+      3,
+      4,
+      5,
+      54,
+      55,
+      56,
+      57,
+      58
+    ],
+    "deps": [
+      "T046"
+    ],
+    "kind": "测试",
+    "status": "local_verified"
+  },
+  {
+    "id": "T048",
+    "title": "manager与入口预构建镜像",
+    "files": [
+      "src/runtime-manager/Dockerfile",
+      "src/app-proxy/Dockerfile"
+    ],
+    "logic": "保留Python锁定依赖,删除manager MinIO并修正socket权限旧注释;egress同镜像独立command,非root和专用目录。",
+    "ac": [
+      1,
+      4,
+      5,
+      54,
+      55,
+      57
+    ],
+    "deps": [
+      "T047"
+    ],
+    "kind": "实现",
+    "status": "native_verified"
+  },
+  {
+    "id": "T049",
+    "title": "python3.11运行/准备镜像",
+    "files": [
+      "src/app-runtime/python3.11/Dockerfile",
+      "src/app-runtime/python3.11/requirements.txt"
+    ],
+    "logic": "runtime/preparer分阶段镜像,固定SDK0.1.1及常用依赖/wheelhouse;纯argv启动不另造Python入口脚本。原生双架构与实际禁网物化为后续必需验收。",
+    "ac": [
+      11,
+      17,
+      22,
+      25,
+      31,
+      54,
+      55,
+      59
+    ],
+    "deps": [
+      "T048"
+    ],
+    "kind": "实现",
+    "status": "native_verified"
+  },
+  {
+    "id": "T050",
+    "title": "node20运行/准备镜像",
+    "files": [
+      "src/app-runtime/node20/Dockerfile"
+    ],
+    "logic": "Node20 runtime/preparer分阶段镜像及固定npm;启动由冻结argv/Procfile/package.start控制,不增加重复入口脚本。真实npm安装钩子/编译与双架构待验收。",
+    "ac": [
+      11,
+      17,
+      22,
+      25,
+      31,
+      54,
+      55,
+      59
+    ],
+    "deps": [
+      "T048"
+    ],
+    "kind": "实现",
+    "status": "native_verified"
+  },
+  {
+    "id": "T051",
+    "title": "static运行/准备镜像",
+    "files": [
+      "src/app-runtime/static/Dockerfile",
+      "src/app-runtime/static/entrypoint.sh"
+    ],
+    "logic": "非root静态HTTP服务与路径前缀;runtime/preparer多stage分别发行;双架构清单,现场不构建。",
+    "ac": [
+      11,
+      17,
+      22,
+      25,
+      31,
+      54,
+      55,
+      59
+    ],
+    "deps": [
+      "T048"
+    ],
+    "kind": "实现",
+    "status": "native_verified"
+  },
+  {
+    "id": "T052",
+    "title": "Compose与安装检查",
+    "files": [
+      "docker/docker-compose.yml",
+      "docker/verify_app_runtime_compose.py"
+    ],
+    "logic": "正式使用Docker Hub image,保留optional profile;清除manager MinIO和egress应用卷,配置独立身份/平台API及宿主目录;自检镜像、依赖、运行接口分别报告。",
+    "ac": [
+      1,
+      3,
+      4,
+      5,
+      18,
+      52,
+      54,
+      56,
+      58
+    ],
+    "deps": [
+      "T049",
+      "T050",
+      "T051"
+    ],
+    "kind": "实现",
+    "status": "local_verified"
+  },
+  {
+    "id": "T053",
+    "title": "CI/Release双架构交付",
+    "files": [
+      ".github/workflows/runtime-images.yml"
+    ],
+    "logic": "独立runtime-images工作流已在测试Tag v3.0.0-test-vibe实际通过;双原生构建/容器集成、Docker Hub架构标签、多架构服务索引及目录发布成功。见2026-10-21-ci-release证据;不等同完整离线安装或平台目录导入。",
+    "ac": [
+      54,
+      55,
+      56,
+      57
+    ],
+    "deps": [
+      "T052"
+    ],
+    "kind": "实现",
+    "status": "native_verified"
+  },
+  {
+    "id": "T054",
+    "title": "CLI可分发wheel及锁定依赖",
+    "files": [
+      "src/backend/bisheng/dev_toolkit/artifacts/bisheng_cli-3.0.0-py3-none-any.whl",
+      "src/backend/bisheng/dev_toolkit/artifacts/manifest.json"
+    ],
+    "logic": "重建并分发CLI wheel及manifest,保留SDK0.1.1工件;打包脚本干净环境安装/生产入口成功,不执行git add/commit/push。",
+    "ac": [
+      5,
+      13,
+      54,
+      60
+    ],
+    "deps": [
+      "T020",
+      "T053"
+    ],
+    "kind": "基础设施",
+    "status": "local_verified"
+  },
+  {
+    "id": "T055",
+    "title": "API E2E与故障证据",
+    "files": [
+      "src/backend/test/e2e/test_e2e_f086_runtime_delivery.py",
+      "features/v3.0.0/086-vibe-runtime-delivery/e2e-checklist.md"
+    ],
+    "logic": "六项live权限与关键Docker旅程已通过;剩余组合/发行门保留。用户确认DM8本轮只做模型规范静态审查,实测留中央回归。",
+    "ac": [
+      1,
+      2,
+      3,
+      4,
+      5,
+      6,
+      7,
+      8,
+      9,
+      10,
+      11,
+      12,
+      13,
+      14,
+      15,
+      16,
+      17,
+      18,
+      19,
+      20,
+      21,
+      22,
+      23,
+      24,
+      25,
+      26,
+      27,
+      28,
+      29,
+      30,
+      31,
+      32,
+      33,
+      34,
+      35,
+      36,
+      37,
+      38,
+      39,
+      40,
+      41,
+      42,
+      43,
+      44,
+      45,
+      46,
+      47,
+      48,
+      49,
+      50,
+      51,
+      52,
+      53,
+      54,
+      55,
+      56,
+      57,
+      58,
+      59,
+      60,
+      61,
+      62,
+      63
+    ],
+    "deps": [
+      "T054",
+      "T038",
+      "T040"
+    ],
+    "kind": "测试",
+    "status": "pending_integration"
+  },
+  {
+    "id": "T056",
+    "title": "最终审查和证据汇总",
+    "files": [
+      "features/v3.0.0/086-vibe-runtime-delivery/task-reviews.md",
+      "features/v3.0.0/086-vibe-runtime-delivery/validation.json"
+    ],
+    "logic": "按task-review记录真实L1、arch-guard/ruff/回归、七维code-review及差异基线证据;未通过的部署和质量门不改成已完成,无自动提交/推送/合并。",
+    "ac": [
+      1,
+      2,
+      3,
+      4,
+      5,
+      6,
+      7,
+      8,
+      9,
+      10,
+      11,
+      12,
+      13,
+      14,
+      15,
+      16,
+      17,
+      18,
+      19,
+      20,
+      21,
+      22,
+      23,
+      24,
+      25,
+      26,
+      27,
+      28,
+      29,
+      30,
+      31,
+      32,
+      33,
+      34,
+      35,
+      36,
+      37,
+      38,
+      39,
+      40,
+      41,
+      42,
+      43,
+      44,
+      45,
+      46,
+      47,
+      48,
+      49,
+      50,
+      51,
+      52,
+      53,
+      54,
+      55,
+      56,
+      57,
+      58,
+      59,
+      60,
+      61,
+      62,
+      63
+    ],
+    "deps": [
+      "T055"
+    ],
+    "kind": "实现",
+    "status": "pending_integration"
+  },
+  {
+    "id": "T057",
+    "title": "准备执行器与旧模板适配",
+    "files": [
+      "src/runtime-manager/runtime_manager/container_runner.py",
+      "src/runtime-manager/runtime_manager/legacy_prepare.py"
+    ],
+    "ac": [
+      11,
+      13,
+      19,
+      26,
+      53,
+      63
+    ],
+    "deps": [
+      "T023",
+      "T027"
+    ],
+    "kind": "实现",
+    "logic": "仅受限任务容器,资源限额与预占一致;宿主路径映射、非root、禁网/下载分离。source-image仅用平台模板、显式startup和操作范围出站。 旧Engine build启动/结束标记持久化;中断未知时不释放预占、不重复build;完整结束后按操作唯一tag复用结果。",
+    "status": "local_verified"
+  },
+  {
+    "id": "T058",
+    "title": "发布交付协调器",
+    "files": [
+      "src/backend/bisheng/app_runtime/domain/services/runtime_delivery_coordinator.py"
+    ],
+    "ac": [
+      5,
+      15,
+      33,
+      37,
+      39,
+      41,
+      63
+    ],
+    "deps": [
+      "T031",
+      "T035"
+    ],
+    "kind": "实现",
+    "logic": "准备/探活/预览/上线/恢复都登记持久操作;固定profile/源码/依赖,范围资产传输;原Owner提交状态,秘密先封装再允许执行。",
+    "status": "local_verified"
+  },
+  {
+    "id": "T059",
+    "title": "凭据保护与执行代际读取",
+    "files": [
+      "src/backend/bisheng/app_runtime/domain/services/runtime_payload.py",
+      "src/backend/bisheng/app_runtime/domain/repositories/runtime_credential_repository.py"
+    ],
+    "ac": [
+      39,
+      51,
+      53
+    ],
+    "deps": [
+      "T033"
+    ],
+    "kind": "实现",
+    "logic": "部署Fernet key封装可续跑的应用环境;正式和排队调用复用F049认证入口,绑定route operation,candidate/旧同版本/维护均拒绝。",
+    "status": "local_verified"
+  },
+  {
+    "id": "T060",
+    "title": "路由和出站投影业务",
+    "files": [
+      "src/backend/bisheng/app_runtime/domain/services/runtime_projection_service.py",
+      "src/backend/bisheng/app_runtime/domain/services/runtime_egress_service.py"
+    ],
+    "ac": [
+      39,
+      48,
+      49,
+      50,
+      51,
+      53
+    ],
+    "deps": [
+      "T033",
+      "T039"
+    ],
+    "kind": "实现",
+    "logic": "同UoW激活route/原凭据/附件句柄;失败撤销候选;出站稳定operation凭据、正式切换fencing、生成环境平台URL放行及显式UTC时间。",
+    "status": "local_verified"
+  },
+  {
+    "id": "T061",
+    "title": "存量与离线内容校验业务",
+    "files": [
+      "src/backend/bisheng/app_runtime/domain/services/runtime_upgrade_service.py",
+      "src/backend/bisheng/app_runtime/domain/services/runtime_offline_package.py"
+    ],
+    "ac": [
+      7,
+      13,
+      27,
+      30,
+      46,
+      57,
+      60
+    ],
+    "deps": [
+      "T041",
+      "T045"
+    ],
+    "kind": "实现",
+    "logic": "校验有界tar/hash/ABI且拒绝登录文件;存量绑定用签名实时inventory复核,保持AppVersion不变,需确认维护及重新绑定。",
+    "status": "local_verified"
+  },
+  {
+    "id": "T062",
+    "title": "控制客户端与范围回收",
+    "files": [
+      "src/backend/bisheng/app_runtime/domain/services/runtime_control_client.py",
+      "src/backend/bisheng/app_runtime/domain/services/runtime_cleanup_service.py"
+    ],
+    "ac": [
+      6,
+      9,
+      10,
+      26,
+      63
+    ],
+    "deps": [
+      "T013",
+      "T015"
+    ],
+    "kind": "实现",
+    "logic": "平台独立control HMAC,只唤醒ID/读inventory;确认执行停止后清理该预览/删除动作的附件及指定快照,不改其他应用。",
+    "status": "local_verified"
+  },
+  {
+    "id": "T063",
+    "title": "manager启动和旧协议拒绝",
+    "files": [
+      "src/runtime-manager/runtime_manager/main.py",
+      "src/runtime-manager/runtime_manager/auth.py"
+    ],
+    "ac": [
+      4,
+      6,
+      48,
+      53,
+      58
+    ],
+    "deps": [
+      "T027"
+    ],
+    "kind": "实现",
+    "logic": "协议2采用平台对账loop,旧写/存储入口拒绝;协议1保留迁移兼容。入站nonce后端持久化,身份与出站读身份分离。",
+    "status": "local_verified"
+  },
+  {
+    "id": "T064",
+    "title": "SQLite数据容器合同测试",
+    "files": [
+      "src/runtime-manager/tests/test_data_executor.py"
+    ],
+    "ac": [
+      47,
+      52,
+      53
+    ],
+    "deps": [
+      "T029"
+    ],
+    "kind": "测试",
+    "logic": "验证只挂一个app数据、无控制secret、非root和network none,保留HTTP错误及清理容器/临时输出;真实Docker同镜像运行由CI验证。",
+    "status": "local_verified"
+  },
+  {
+    "id": "T065",
+    "title": "受控数据执行API",
+    "files": [
+      "src/runtime-manager/runtime_manager/api/platform_data.py",
+      "src/runtime-manager/runtime_manager/data_executor.py"
+    ],
+    "ac": [
+      47,
+      52,
+      53
+    ],
+    "deps": [
+      "T064"
+    ],
+    "kind": "实现",
+    "logic": "后台权限后经control签名到manager;当前node与维护互斥;相同manager镜像短命helper,128MiB/100mCPU、两并发、30秒与标签GC,导出一次性目录。",
+    "status": "local_verified"
+  },
+  {
+    "id": "T066",
+    "title": "单应用数据入口与只读观测",
+    "files": [
+      "src/runtime-manager/runtime_manager/data_worker.py",
+      "src/runtime-manager/runtime_manager/api/platform_observe.py"
+    ],
+    "ac": [
+      47,
+      48,
+      50,
+      53
+    ],
+    "deps": [
+      "T027",
+      "T064"
+    ],
+    "kind": "实现",
+    "logic": "worker只执行已有表/行/导出协议,无任意SQL;status/log由平台绑定定位容器并脱敏,runtime-status保留旧响应字段、SQL容量失联不回落本地状态。",
+    "status": "local_verified"
+  },
+  {
+    "id": "T067",
+    "title": "旧运行绑定只读inventory",
+    "files": [
+      "src/runtime-manager/runtime_manager/api/upgrade.py"
+    ],
+    "ac": [
+      13,
+      46,
+      57,
+      60
+    ],
+    "deps": [
+      "T045"
+    ],
+    "kind": "实现",
+    "logic": "按旧state和实时Engine/镜像/标签导出范围,兼容模式可读取,排除容器env/密钥,记录旧bucket差异。",
+    "status": "local_verified"
+  },
+  {
+    "id": "T068",
+    "title": "出口平台策略缓存适配",
+    "files": [
+      "src/runtime-manager/runtime_manager/platform_egress.py"
+    ],
+    "ac": [
+      49,
+      50,
+      52
+    ],
+    "deps": [
+      "T039"
+    ],
+    "kind": "实现",
+    "logic": "独立只读身份取policy hash/epoch/硬期限,刷新离开事件循环,平台故障超过TTL就拒绝/关闭连接。",
+    "status": "local_verified"
+  },
+  {
+    "id": "T069",
+    "title": "入口协议与运行接线",
+    "files": [
+      "src/app-proxy/app_proxy/platform_protocol.py",
+      "src/app-proxy/app_proxy/main.py"
+    ],
+    "ac": [
+      48,
+      49,
+      53
+    ],
+    "deps": [
+      "T007",
+      "T037"
+    ],
+    "kind": "实现",
+    "logic": "独立镜像协议共享测试向量;入口不持有manager写权限,读取平台路由并保留原HTTP/WS授权。",
+    "status": "local_verified"
+  },
+  {
+    "id": "T070",
+    "title": "审批预览与授权接线",
+    "files": [
+      "src/backend/bisheng/app_publish/domain/services/preview_instance_service.py",
+      "src/backend/bisheng/app_runtime/domain/services/entry_authz_service.py"
+    ],
+    "ac": [
+      9,
+      33,
+      48,
+      51
+    ],
+    "deps": [
+      "T033",
+      "T035"
+    ],
+    "kind": "实现",
+    "logic": "原预览session/owner能力例外不变,v2使用冻结delivery;正式维护路由不可被旧授权缓存长期放行。",
+    "status": "local_verified"
+  },
+  {
+    "id": "T071",
+    "title": "SDK中间件与创建入口约束",
+    "files": [
+      "src/backend/bisheng/utils/http_middleware.py",
+      "src/backend/test/app_runtime/test_runtime_layer_switch.py"
+    ],
+    "ac": [
+      5,
+      7,
+      53,
+      60
+    ],
+    "deps": [
+      "T015"
+    ],
+    "kind": "实现",
+    "logic": "仅SDK storage namespace走句柄认证;新增app-scoped材料/恢复维护POST不成为UI创建应用入口。",
+    "status": "local_verified"
+  },
+  {
+    "id": "T072",
+    "title": "离线与owner业务测试",
+    "files": [
+      "src/backend/test/app_runtime/test_runtime_management.py"
+    ],
+    "ac": [
+      7,
+      14,
+      15,
+      30,
+      45,
+      53
+    ],
+    "deps": [
+      "T011"
+    ],
+    "kind": "测试",
+    "logic": "真实离线tar的内容及profile兼容元数据和自然人owner矩阵;不假造MinIO/权限核心模块。",
+    "status": "local_verified"
+  },
+  {
+    "id": "T073",
+    "title": "数据恢复审计登记",
+    "files": [
+      "src/backend/bisheng/database/models/audit_log.py",
+      "src/backend/bisheng/app_runtime/domain/constants.py"
+    ],
+    "ac": [
+      45,
+      47
+    ],
+    "deps": [
+      "T035"
+    ],
+    "kind": "基础设施",
+    "logic": "登记app.data_restore到原审计枚举和UI可见白名单,使用原system audit流程;不增加新数字错误码。",
+    "status": "local_verified"
+  },
+  {
+    "id": "T074",
+    "title": "Platform审计筛选接线",
+    "files": [
+      "src/frontend/platform/src/controllers/API/log.ts"
+    ],
+    "ac": [
+      47
+    ],
+    "deps": [
+      "T073"
+    ],
+    "kind": "前端 Platform",
+    "logic": "原审计动作列表增加data_restore,保持既有请求封装;无组件/样式/新页面变更。",
+    "status": "local_verified"
+  },
+  {
+    "id": "T075",
+    "title": "审计三语文案第一组",
+    "files": [
+      "src/frontend/platform/public/locales/en-US/bs.json",
+      "src/frontend/platform/public/locales/zh-Hans/bs.json"
+    ],
+    "ac": [
+      47
+    ],
+    "deps": [
+      "T073"
+    ],
+    "kind": "前端 Platform",
+    "logic": "仅已有bs审计字典新增同键;这不是api_errors生成文件。",
+    "status": "local_verified"
+  },
+  {
+    "id": "T076",
+    "title": "审计三语文案日语",
+    "files": [
+      "src/frontend/platform/public/locales/ja/bs.json"
+    ],
+    "ac": [
+      47
+    ],
+    "deps": [
+      "T073"
+    ],
+    "kind": "前端 Platform",
+    "logic": "同键日语补齐,和前项在同一改动交付,check-i18n验证。",
+    "status": "local_verified"
+  },
+  {
+    "id": "T077",
+    "title": "原生镜像构建与容器烟测",
+    "files": [
+      "scripts/build_runtime_images.sh",
+      "scripts/smoke_runtime_delivery.py"
+    ],
+    "ac": [
+      11,
+      19,
+      20,
+      31,
+      41,
+      54,
+      55,
+      56
+    ],
+    "deps": [
+      "T047"
+    ],
+    "kind": "实现",
+    "logic": "构建服务及runtime/preparer、拒绝已有标签;真实Engine且全程应用包禁网、代码更新和复用、SQLite/helper,内存产物port;未运行不得等同整个平台E2E。",
+    "status": "native_verified"
+  },
+  {
+    "id": "T078",
+    "title": "镜像内统一准备器",
+    "files": [
+      "src/app-runtime/common/prepare.py"
+    ],
+    "ac": [
+      19,
+      21,
+      24,
+      25,
+      27,
+      28,
+      29,
+      53
+    ],
+    "deps": [
+      "T023",
+      "T043"
+    ],
+    "kind": "实现",
+    "logic": "联网只下载受限Python轮子和npm材料,禁网运行安装钩子/本地项目wheel/前端编译,保存锁/原生材料/源码overlay并拒绝逃逸或凭据文件。",
+    "status": "native_verified"
+  },
+  {
+    "id": "T079",
+    "title": "static无root入口配置",
+    "files": [
+      "src/app-runtime/static/nginx.conf"
+    ],
+    "ac": [
+      11,
+      17,
+      25,
+      54
+    ],
+    "deps": [
+      "T025"
+    ],
+    "kind": "基础设施",
+    "logic": "动态PORT配置由entrypoint渲染到tmp;静态编译复用Node preparer,不新建常驻服务。",
+    "status": "local_verified"
+  },
+  {
+    "id": "T080",
+    "title": "独立开发构建覆盖",
+    "files": [
+      "docker/docker-compose.runtime-dev.yml"
+    ],
+    "ac": [
+      54,
+      58
+    ],
+    "deps": [
+      "T047"
+    ],
+    "kind": "基础设施",
+    "logic": "只有开发override含build;正式Compose全部使用镜像引用。",
+    "status": "local_verified"
+  },
+  {
+    "id": "T081",
+    "title": "安装环境与校验脚本",
+    "files": [
+      "docker/.env.example",
+      "docker/verify-app-runtime-compose.sh"
+    ],
+    "ac": [
+      3,
+      4,
+      53,
+      58
+    ],
+    "deps": [
+      "T047"
+    ],
+    "kind": "基础设施",
+    "logic": "列明分离HMAC/Fernet/离线/镜像参数;Compose命令仅做config/权限/网络检查,不现场启动或构建。",
+    "status": "local_verified"
+  },
+  {
+    "id": "T082",
+    "title": "manager冻结依赖合同",
+    "files": [
+      "src/runtime-manager/pyproject.toml",
+      "src/runtime-manager/uv.lock"
+    ],
+    "ac": [
+      4,
+      5,
+      54,
+      57
+    ],
+    "deps": [
+      "T047"
+    ],
+    "kind": "基础设施",
+    "logic": "默认不安装MinIO,legacy-storage显式optional;保留原固定依赖版本并用frozen锁检查,不做无关升级。",
+    "status": "local_verified"
+  },
+  {
+    "id": "T083",
+    "title": "安装维护文档",
+    "files": [
+      "src/runtime-manager/OPERATIONS.md",
+      "docs/architecture/14-app-factory-deployment.md"
+    ],
+    "ac": [
+      3,
+      27,
+      28,
+      45,
+      46,
+      54,
+      57,
+      58,
+      59,
+      62
+    ],
+    "deps": [
+      "T044",
+      "T046"
+    ],
+    "kind": "文档",
+    "logic": "说明具体profile/默认替换、离线制作导入/边界、SQLite/helper及维护升级;旧架构文档指向v2而不冒充已发布。",
+    "status": "local_verified"
+  },
+  {
+    "id": "T084",
+    "title": "CLI及版本交付登记",
+    "files": [
+      "src/bisheng-cli/README.md",
+      "features/v3.0.0/release-contract.md"
+    ],
+    "ac": [
+      13,
+      30,
+      54,
+      58,
+      60,
+      62
+    ],
+    "deps": [
+      "T054"
+    ],
+    "kind": "文档",
+    "logic": "同步owner绑定和SAK发布、F086设计已确认/代码与部署验收分层;原不变量及历史评审输入保持。",
+    "status": "local_verified"
+  },
+  {
+    "id": "T085",
+    "title": "离线补包入口流式代理",
+    "kind": "基础设施",
+    "files": [
+      "docker/nginx/conf.d/default.conf"
+    ],
+    "ac": [
+      5,
+      7,
+      27,
+      28,
+      58
+    ],
+    "deps": [
+      "T047"
+    ],
+    "logic": "仅材料导入路径启用2GiB流式body和更长超时;backend仍校验owner/hash/展开上限。真实nginx -t进入原生镜像CI,当前因基础镜像拉取受阻尚未执行。",
+    "status": "native_verified"
+  },
+  {
+    "id": "T086",
+    "title": "平台执行事实与旧RPC适配",
+    "files": [
+      "src/backend/bisheng/app_runtime/domain/services/runtime_operation_service.py",
+      "src/backend/bisheng/app_runtime/domain/services/orchestrator_client.py"
+    ],
+    "ac": [
+      6,
+      29,
+      35,
+      36,
+      37,
+      48,
+      50,
+      53,
+      63
+    ],
+    "deps": [
+      "T013",
+      "T033"
+    ],
+    "kind": "实现",
+    "logic": "执行身份/阶段/事实/诊断/归属/预览/自愈的领域入口;原logs/status/data RPC通过v2control签名适配,不能旁路业务授权。",
+    "status": "local_verified"
+  },
+  {
+    "id": "T087",
+    "title": "CLI既有入口回归适配",
+    "files": [
+      "src/bisheng-cli/tests/test_cli.py"
+    ],
+    "ac": [
+      11,
+      13,
+      17,
+      60
+    ],
+    "deps": [
+      "T017"
+    ],
+    "kind": "测试",
+    "logic": "显式新版init参数和旧项目兼容,保留CLI原有参数/分发行为;完整383项回归。",
+    "status": "local_verified"
+  },
+  {
+    "id": "T088",
+    "title": "CLI命令分发接线",
+    "files": [
+      "src/bisheng-cli/bisheng_cli/cli.py",
+      "src/bisheng-cli/bisheng_cli/main.py"
+    ],
+    "ac": [
+      11,
+      13,
+      17,
+      60
+    ],
+    "deps": [
+      "T019",
+      "T087"
+    ],
+    "kind": "实现",
+    "logic": "将init新版运行描述接入真实CLI入口;不重写已有manifest,不改变普通deploy鉴权。",
+    "status": "local_verified"
+  },
+  {
+    "id": "T089",
+    "title": "旧策略文件范围标识",
+    "files": [
+      "src/runtime-manager/runtime_manager/egress.py"
+    ],
+    "ac": [
+      49,
+      50
+    ],
+    "deps": [
+      "T039"
+    ],
+    "kind": "文档",
+    "logic": "仅明确本地policy文件属于protocol1兼容路径,protocol2采用平台只读缓存,不改变旧策略算法。",
+    "status": "local_verified"
+  },
+  {
+    "id": "T090",
+    "title": "后台租户上下文执行边界",
+    "files": [
+      "src/backend/bisheng/core/context/tenant.py",
+      "src/backend/test/tenant/test_tenant_execution_scope.py"
+    ],
+    "ac": [
+      61,
+      62
+    ],
+    "deps": [],
+    "kind": "实现",
+    "logic": "绑定已验证目标租户,临时收窄visible set并清除继承的admin override/bypass,嵌套、异常和并发均恢复原上下文。",
+    "status": "local_verified"
+  },
+  {
+    "id": "T091",
+    "title": "系统资源启动投影租户绑定",
+    "files": [
+      "src/backend/bisheng/permission/application/system_resource_reconcile.py",
+      "src/backend/test/permission/test_f048_system_resource_reconcile.py"
+    ],
+    "ac": [
+      61,
+      62
+    ],
+    "deps": [
+      "T090"
+    ],
+    "kind": "实现",
+    "logic": "每个系统资源投影使用其可信tenant_id执行,保留原权限授权入口,不以全局bypass规避过滤。",
+    "status": "local_verified"
+  },
+  {
+    "id": "T092",
+    "title": "首次管理员仪表盘投影上下文",
+    "files": [
+      "src/backend/bisheng/api/services/f048_preset_dashboard_bootstrap.py",
+      "src/backend/test/permission/test_f048_preset_dashboard_bootstrap.py"
+    ],
+    "ac": [
+      61,
+      62
+    ],
+    "deps": [
+      "T090"
+    ],
+    "kind": "实现",
+    "logic": "首次自然注册及启动对账的预置dashboard owner投影显式绑定Root租户;已有owner与投影的幂等逻辑保持。",
+    "status": "local_verified"
+  },
+  {
+    "id": "T093",
+    "title": "字节计数范围与模型规范检查",
+    "files": [
+      "src/backend/bisheng/app_runtime/domain/models/runtime_delivery.py",
+      "src/backend/test/app_runtime/test_runtime_model_contracts.py"
+    ],
+    "ac": [
+      6,
+      35,
+      61
+    ],
+    "deps": [
+      "T001"
+    ],
+    "kind": "实现",
+    "logic": "size_bytes和offset使用BigInteger,覆盖15表JsonType/时间默认/租户/唯一约束和索引静态检查,符合2GiB材料和10GiB快照合同;DM8不连接。",
+    "status": "local_verified"
+  },
+  {
+    "id": "T094",
+    "title": "已有运行时字节字段扩容迁移",
+    "files": [
+      "src/backend/bisheng/core/database/alembic/versions/f086_runtime_byte_counters.py",
+      "src/backend/test/app_runtime/test_runtime_model_contracts.py"
+    ],
+    "ac": [
+      6,
+      35,
+      61
+    ],
+    "deps": [
+      "T093"
+    ],
+    "kind": "基础设施",
+    "logic": "挂接已核对single head merge_vibe_f077_heads,DDL-only INTEGER到BIGINT;新库/已扩容跳过,异常类型失败,降级保留宽列;组合28项回归通过。",
+    "status": "local_verified"
+  }
+]
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/tasks-review-evidence.md b/features/v3.0.0/086-vibe-runtime-delivery/tasks-review-evidence.md
new file mode 100644
index 0000000000..187a8dcb4a
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/tasks-review-evidence.md
@@ -0,0 +1,9 @@
+# Tasks 复核证据
+
+同一代理按 sdd-review tasks checklist 复核,非独立评审。
+
+- 89项均声明文件、AC、依赖和逻辑,≤2文件,所有路径存在;依赖ID均已声明且在前。
+- 测试任务AC集合覆盖AC-01至AC-63;与运行验收是否已执行无关。
+- 原测试任务仍先于配对源码任务;补充任务有既有合同测试前驱。
+- 新表保留式回退、Worker tenant传递、原Owner/版本不变量及跨Feature审计影响已记录。
+- 3项保留未完成:T053正式CI发行、T055完整验收、T056最终交付门。86项已取得本地合同或双架构原生验证;Docker主链路证据见live-validation.md,不能据此勾选全部AC。
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/tasks-review.md b/features/v3.0.0/086-vibe-runtime-delivery/tasks-review.md
new file mode 100644
index 0000000000..b0e4e89c33
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/tasks-review.md
@@ -0,0 +1 @@
+LGTM
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/tasks.md b/features/v3.0.0/086-vibe-runtime-delivery/tasks.md
new file mode 100644
index 0000000000..5c1c595450
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/tasks.md
@@ -0,0 +1,608 @@
+# Tasks:F086 Vibe 开源托管与应用交付演进
+**关联**:[spec.md](./spec.md) · [design.md](./design.md) · [验证](./validation.json)。基线4aa9ce69,分支codex/086-vibe-runtime-delivery。
+## 状态
+- spec、design已获确认;tasks已按实际文件重新核对。
+- 92/94项完成实现及对应合同/原生验证;其余保留未完成。勾选不表示对应AC已取得部署证明。
+- T055、T056仍保留未完成;T053已由2026-10-21测试发行实跑关闭。2026-10-21用户要求“提交推送代码先”,授权将当前阶段实现提交并推送到功能分支;完整验收与合并门继续保留。
+## 开发及范围说明
+测试与实现任务保留原ID,并以补充任务登记拆解时遗漏的协调器、句柄、临时数据helper、审计、CI脚本和安装文档;每项至多两个文件。基础设施依赖同域Repository/服务。新增表走model discovery;回退保留表/数据,先停控制写入。Celery复用原任务tenant headers→ContextVar,不引入第二Engine控制入口。
+补充任务是本次增量接线,不改变已确认的Docker/开源/平台存储/单写SQLite边界。Python/Node用冻结argv,未增加无必要entrypoint;目录为CI真实生成JSON;专用runtime-images工作流不覆盖backend工作流。实际运行与未验证情况见task-reviews。
+## Tasks
+
+### Wave 1
+
+- [x] **T001**:平台持久对象模型
+  **类型**:基础设施;**文件**:`src/backend/bisheng/app_runtime/domain/models/runtime_delivery.py`
+  **逻辑/验收**:定义profile/default、artifact、delivery、operation、assignment、reservation、node、fact、route、policy和transfer。使用JsonType/共享时间默认/关系唯一键,应用对象保留原Owner;新表由model discovery建表,回退先停止新控制写入并保留表和资产,不破坏性drop。
+  **覆盖 AC**:AC-06、AC-15、AC-19、AC-35、AC-36、AC-50、AC-61
+  **依赖**:无
+
+- [x] **T002**:模型发现注册
+  **类型**:基础设施;**文件**:`src/backend/bisheng/core/database/tenant_filter.py`、`src/backend/bisheng/app_runtime/domain/models/__init__.py`
+  **逻辑/验收**:通过领域包导入模型,租户表在过滤器初始化前注册;全局目录无tenant_id,明确超管入口,应用表保持自动隔离。
+  **覆盖 AC**:AC-14、AC-30、AC-50、AC-61
+  **依赖**:T001
+
+- [x] **T003**:平台运行交付配置
+  **类型**:基础设施;**文件**:`src/backend/bisheng/core/config/app_runtime.py`
+  **逻辑/验收**:在已有app_runtime子键增加内部服务身份、文件限额/回收及协议设置,默认fail-closed;不新增YAML顶级键,保留旧配置可加载。
+  **覆盖 AC**:AC-03、AC-05、AC-53、AC-58
+  **依赖**:无
+
+- [x] **T004**:manager执行配置
+  **类型**:基础设施;**文件**:`src/runtime-manager/runtime_manager/config.py`
+  **逻辑/验收**:加入固定node/platform身份、操作超时/回收设置、平台传输身份;旧存储参数在迁移前仅兼容解析,完成切换删除依赖。
+  **覆盖 AC**:AC-04、AC-22、AC-53、AC-57、AC-58、AC-63
+  **依赖**:无
+
+### Wave 2
+
+- [x] **T005**:平台操作/容量Repository合同与失败测试
+  **类型**:测试;**文件**:`src/backend/test/app_runtime/test_runtime_delivery_repository.py`、`src/backend/test/app_runtime/test_runtime_delivery_database.py`
+  **逻辑/验收**:SQL事务约束幂等键/请求摘要、node预算锁/预占、app归属CAS、事实顺序和路由提交;隔离未知执行体,确认停止后释放;不在Service写ORM。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。
+  **覆盖 AC**:AC-06、AC-34、AC-35、AC-36、AC-37、AC-39、AC-50、AC-61、AC-63
+  **依赖**:T001、T002
+
+- [x] **T006**:平台操作/容量Repository
+  **类型**:实现;**文件**:`src/backend/bisheng/app_runtime/domain/repositories/runtime_delivery_repository.py`
+  **逻辑/验收**:SQL事务约束幂等键/请求摘要、node预算锁/预占、app归属CAS、事实顺序和路由提交;隔离未知执行体,确认停止后释放;不在Service写ORM。
+  **覆盖 AC**:AC-06、AC-34、AC-35、AC-36、AC-37、AC-39、AC-50、AC-61、AC-63
+  **依赖**:T001、T002、T005
+
+- [x] **T007**:版本2服务身份与重放保护合同与失败测试
+  **类型**:测试;**文件**:`src/backend/test/app_runtime/test_runtime_protocol_v2.py`
+  **逻辑/验收**:签名绑定method/path/query/body摘要/时间/nonce/key_id;平台nonce持久端口fail-closed,不同服务动作隔离;上传用范围session,manager禁止任意URL。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。
+  **覆盖 AC**:AC-06、AC-07、AC-49、AC-52、AC-53
+  **依赖**:T003、T004、T006
+
+- [x] **T008**:版本2服务身份与重放保护
+  **类型**:实现;**文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_protocol.py`、`src/runtime-manager/runtime_manager/platform_protocol.py`
+  **逻辑/验收**:签名绑定method/path/query/body摘要/时间/nonce/key_id;平台nonce持久端口fail-closed,不同服务动作隔离;上传用范围session,manager禁止任意URL。
+  **覆盖 AC**:AC-06、AC-07、AC-49、AC-52、AC-53
+  **依赖**:T003、T004、T006、T007
+
+- [x] **T009**:平台产物传输合同与失败测试
+  **类型**:测试;**文件**:`src/backend/test/app_runtime/test_runtime_artifacts.py`
+  **逻辑/验收**:streaming限额及hash、MinIO私有桶、upload/finalize/status幂等、代码/依赖/快照各独立上限;内容和元数据确认后verified;确认不明保护快照。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。
+  **覆盖 AC**:AC-05、AC-06、AC-07、AC-10、AC-19、AC-23、AC-28、AC-42、AC-43、AC-53
+  **依赖**:T006、T008
+
+- [x] **T010**:平台产物传输
+  **类型**:实现;**文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_artifact_service.py`、`src/backend/bisheng/app_runtime/domain/repositories/runtime_object_store.py`
+  **逻辑/验收**:streaming限额及hash、MinIO私有桶、upload/finalize/status幂等、代码/依赖/快照各独立上限;内容和元数据确认后verified;确认不明保护快照。
+  **覆盖 AC**:AC-05、AC-06、AC-07、AC-10、AC-19、AC-23、AC-28、AC-42、AC-43、AC-53
+  **依赖**:T006、T008、T009
+
+- [x] **T011**:环境目录和授权边界合同与失败测试
+  **类型**:测试;**文件**:`src/backend/test/app_runtime/test_runtime_profiles.py`
+  **逻辑/验收**:不可变环境修订/平台镜像记录/默认CAS;超管管理全局、自然人owner管理私包,验证私包app/tenant;精确冻结平台和启动约定,拒绝可变tag假摘要。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。
+  **覆盖 AC**:AC-11、AC-12、AC-14、AC-15、AC-16、AC-17、AC-21、AC-24、AC-30、AC-31、AC-55、AC-56、AC-57、AC-59
+  **依赖**:T006
+
+- [x] **T012**:环境目录和授权边界
+  **类型**:实现;**文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_profile_service.py`、`src/backend/bisheng/app_runtime/domain/schemas/runtime_delivery.py`
+  **逻辑/验收**:不可变环境修订/平台镜像记录/默认CAS;超管管理全局、自然人owner管理私包,验证私包app/tenant;精确冻结平台和启动约定,拒绝可变tag假摘要。
+  **覆盖 AC**:AC-11、AC-12、AC-14、AC-15、AC-16、AC-17、AC-21、AC-24、AC-30、AC-31、AC-55、AC-56、AC-57、AC-59
+  **依赖**:T006、T011
+
+- [x] **T013**:内部操作与投影API合同与失败测试
+  **类型**:测试;**文件**:`src/backend/test/app_runtime/test_runtime_delivery_api.py`
+  **逻辑/验收**:注册internal/app-runtime/v2操作/claim/facts/传输/路由/policy接口,endpoint依赖服务,按节点与scope过滤;日志status仍由F054包装;内部路由不依赖JWT租户header,验服务身份后建立scope。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。
+  **覆盖 AC**:AC-06、AC-34、AC-35、AC-36、AC-37、AC-48、AC-49、AC-50、AC-53
+  **依赖**:T010、T012、T008
+
+- [x] **T014**:内部操作与投影API
+  **类型**:实现;**文件**:`src/backend/bisheng/app_runtime/api/endpoints/runtime_delivery.py`、`src/backend/bisheng/app_runtime/api/router.py`
+  **逻辑/验收**:注册internal/app-runtime/v2操作/claim/facts/传输/路由/policy接口,endpoint依赖服务,按节点与scope过滤;日志status仍由F054包装;内部路由不依赖JWT租户header,验服务身份后建立scope。
+  **覆盖 AC**:AC-06、AC-34、AC-35、AC-36、AC-37、AC-48、AC-49、AC-50、AC-53
+  **依赖**:T010、T012、T008、T013
+
+- [x] **T015**:backend附件SDK兼容合同与失败测试
+  **类型**:测试;**文件**:`src/backend/test/app_runtime/test_runtime_storage_api.py`
+  **逻辑/验收**:实现旧SDKobjects/meta/list/put/get/delete与错误信封,backend唯一MinIO访问;正式/preview独立作用域、大小、token撤销、跨app拒绝。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。
+  **覆盖 AC**:AC-05、AC-08、AC-09、AC-10、AC-38、AC-39、AC-51、AC-53、AC-60
+  **依赖**:T010、T014
+
+- [x] **T016**:backend附件SDK兼容
+  **类型**:实现;**文件**:`src/backend/bisheng/app_runtime/domain/services/app_storage_service.py`、`src/backend/bisheng/app_runtime/api/endpoints/app_storage.py`
+  **逻辑/验收**:实现旧SDKobjects/meta/list/put/get/delete与错误信封,backend唯一MinIO访问;正式/preview独立作用域、大小、token撤销、跨app拒绝。
+  **覆盖 AC**:AC-05、AC-08、AC-09、AC-10、AC-38、AC-39、AC-51、AC-53、AC-60
+  **依赖**:T010、T014、T015
+
+### Wave 3
+
+- [x] **T017**:新版清单与旧模式兼容合同与失败测试
+  **类型**:测试;**文件**:`src/backend/test/app_publish/test_manifest_delivery_v2.py`
+  **逻辑/验收**:v2源码默认bundle、显式source-image、完整image判别,v1继续旧构建;image按运行契约不走源码模板过滤,未知字段/冲突启动拒绝。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。
+  **覆盖 AC**:AC-11、AC-12、AC-13、AC-17、AC-18、AC-25、AC-33、AC-60
+  **依赖**:T012
+
+- [x] **T018**:新版清单与旧模式兼容
+  **类型**:实现;**文件**:`src/backend/bisheng/app_publish/domain/schemas/app_manifest.py`、`src/backend/bisheng/app_publish/domain/services/manifest_validator.py`
+  **逻辑/验收**:v2源码默认bundle、显式source-image、完整image判别,v1继续旧构建;image按运行契约不走源码模板过滤,未知字段/冲突启动拒绝。
+  **覆盖 AC**:AC-11、AC-12、AC-13、AC-17、AC-18、AC-25、AC-33、AC-60
+  **依赖**:T012、T017
+
+- [x] **T019**:CLI应用包交付合同与失败测试
+  **类型**:测试;**文件**:`src/bisheng-cli/tests/test_delivery_v2.py`
+  **逻辑/验收**:init默认v2,保留v1打包和状态轮询;包不带开发机venv/node_modules,锁/前端输入保留;清晰协议不兼容。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。
+  **覆盖 AC**:AC-07、AC-11、AC-13、AC-17、AC-25、AC-60
+  **依赖**:T018
+
+- [x] **T020**:CLI应用包交付
+  **类型**:实现;**文件**:`src/bisheng-cli/bisheng_cli/project.py`、`src/bisheng-cli/bisheng_cli/packaging.py`
+  **逻辑/验收**:init默认v2,保留v1打包和状态轮询;包不带开发机venv/node_modules,锁/前端输入保留;清晰协议不兼容。
+  **覆盖 AC**:AC-07、AC-11、AC-13、AC-17、AC-25、AC-60
+  **依赖**:T018、T019
+
+### Wave 4
+
+- [x] **T021**:manager平台内容客户端合同与失败测试
+  **类型**:测试;**文件**:`src/runtime-manager/tests/test_platform_client_v2.py`
+  **逻辑/验收**:签名HTTP客户端、claim/query/facts/transfer接口,大小/hash校验、恢复上传、只接受平台ID;v2异步操作API输出可关联operation;所有外部响应显式检查。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。
+  **覆盖 AC**:AC-05、AC-06、AC-07、AC-23、AC-36、AC-42、AC-43、AC-53
+  **依赖**:T014、T008
+
+- [x] **T022**:manager平台内容客户端
+  **类型**:实现;**文件**:`src/runtime-manager/runtime_manager/platform_client.py`、`src/runtime-manager/runtime_manager/api/operations.py`
+  **逻辑/验收**:签名HTTP客户端、claim/query/facts/transfer接口,大小/hash校验、恢复上传、只接受平台ID;v2异步操作API输出可关联operation;所有外部响应显式检查。
+  **覆盖 AC**:AC-05、AC-06、AC-07、AC-23、AC-36、AC-42、AC-43、AC-53
+  **依赖**:T014、T008、T021
+
+- [x] **T023**:Python与Node准备和复用合同与失败测试
+  **类型**:测试;**文件**:`src/runtime-manager/tests/test_bundle_preparer.py`
+  **逻辑/验收**:按scope/架构/ABI/镜像/锁/工具链/源码输入hash缓存;下载与无网安装编译隔离,固定wheelhouse/npm缓存,SDK入锁;只在成功后原子可见,缺包在审批前拒绝。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。
+  **覆盖 AC**:AC-19、AC-20、AC-21、AC-22、AC-23、AC-24、AC-25、AC-26、AC-27、AC-28、AC-29、AC-30、AC-31、AC-32、AC-59
+  **依赖**:T022
+
+- [x] **T024**:Python与Node准备和复用
+  **类型**:实现;**文件**:`src/runtime-manager/runtime_manager/bundle_preparer.py`、`src/runtime-manager/runtime_manager/bundle_inputs.py`
+  **逻辑/验收**:按scope/架构/ABI/镜像/锁/工具链/源码输入hash缓存;下载与无网安装编译隔离,固定wheelhouse/npm缓存,SDK入锁;只在成功后原子可见,缺包在审批前拒绝。
+  **覆盖 AC**:AC-19、AC-20、AC-21、AC-22、AC-23、AC-24、AC-25、AC-26、AC-27、AC-28、AC-29、AC-30、AC-31、AC-32、AC-59
+  **依赖**:T022、T023
+
+- [x] **T025**:bundle与完整镜像启动合同与失败测试
+  **类型**:测试;**文件**:`src/runtime-manager/tests/test_bundle_launch.py`
+  **逻辑/验收**:实际host bind路径,代码和依赖ro/data rw,固定argv、端口与健康;无host端口/高权参数;离线image ID内容映射与完整镜像无pip/npm。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。
+  **覆盖 AC**:AC-11、AC-12、AC-17、AC-18、AC-22、AC-41、AC-46、AC-52、AC-56、AC-57、AC-58
+  **依赖**:T024
+
+- [x] **T026**:bundle与完整镜像启动
+  **类型**:实现;**文件**:`src/runtime-manager/runtime_manager/bundle_launch.py`、`src/runtime-manager/runtime_manager/docker_backend.py`
+  **逻辑/验收**:实际host bind路径,代码和依赖ro/data rw,固定argv、端口与健康;无host端口/高权参数;离线image ID内容映射与完整镜像无pip/npm。
+  **覆盖 AC**:AC-11、AC-12、AC-17、AC-18、AC-22、AC-41、AC-46、AC-52、AC-56、AC-57、AC-58
+  **依赖**:T024、T025
+
+- [x] **T027**:manager操作所有权和回收合同与失败测试
+  **类型**:测试;**文件**:`src/runtime-manager/tests/test_operations_v2.py`
+  **逻辑/验收**:持久操作驱动、app串行、epoch/current claim、Engine标签查询幂等;无平台停止新破坏动作;终态/timeout/cancel/restart临时资源回收、未知保留容量、引用/DB/唯一备份保护。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。
+  **覆盖 AC**:AC-26、AC-34、AC-35、AC-36、AC-37、AC-39、AC-40、AC-50、AC-63
+  **依赖**:T022、T026
+
+- [x] **T028**:manager操作所有权和回收
+  **类型**:实现;**文件**:`src/runtime-manager/runtime_manager/operations.py`
+  **逻辑/验收**:持久操作驱动、app串行、epoch/current claim、Engine标签查询幂等;无平台停止新破坏动作;终态/timeout/cancel/restart临时资源回收、未知保留容量、引用/DB/唯一备份保护。
+  **覆盖 AC**:AC-26、AC-34、AC-35、AC-36、AC-37、AC-39、AC-40、AC-50、AC-63
+  **依赖**:T022、T026、T027
+
+### Wave 5
+
+- [x] **T029**:SQLite平台快照与owner恢复合同与失败测试
+  **类型**:测试;**文件**:`src/runtime-manager/tests/test_platform_appdb.py`
+  **逻辑/验收**:Backup API一致副本/校验、backend confirmed后DDL、丢响应查询同snapshot;停写单写入者、恢复先保护当前数据、失败不自动覆盖生产;保留受控数据API。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。
+  **覆盖 AC**:AC-05、AC-35、AC-41、AC-42、AC-43、AC-44、AC-45、AC-46、AC-47
+  **依赖**:T028、T010
+
+- [x] **T030**:SQLite平台快照与owner恢复
+  **类型**:实现;**文件**:`src/runtime-manager/runtime_manager/appdb.py`、`src/runtime-manager/runtime_manager/platform_appdb.py`
+  **逻辑/验收**:Backup API一致副本/校验、backend confirmed后DDL、丢响应查询同snapshot;停写单写入者、恢复先保护当前数据、失败不自动覆盖生产;保留受控数据API。
+  **覆盖 AC**:AC-05、AC-35、AC-41、AC-42、AC-43、AC-44、AC-45、AC-46、AC-47
+  **依赖**:T028、T010、T029
+
+- [x] **T031**:F055交付准备接线合同与失败测试
+  **类型**:测试;**文件**:`src/backend/test/app_publish/test_delivery_pipeline_v2.py`
+  **逻辑/验收**:源码下载只经backend;三交付模式统一prepare/probe/freeze;缺包/预检失败无审批,保留扫描、版本创建入口及原补偿。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。
+  **覆盖 AC**:AC-06、AC-11、AC-12、AC-13、AC-19、AC-29、AC-32、AC-33
+  **依赖**:T018、T028、T030
+
+- [x] **T032**:F055交付准备接线
+  **类型**:实现;**文件**:`src/backend/bisheng/app_publish/domain/services/publish_pipeline_service.py`
+  **逻辑/验收**:F055 的协议2 prepare/probe 接线放在 PublishPipelineService,经 RuntimeDeliveryCoordinator,不改协议1 precheck 的 URL 合同;Celery 仍使用原任务的 tenant headers→ContextVar。v1描述在协议2映射source-image;v2不在协议1静默发布。
+  **覆盖 AC**:AC-06、AC-11、AC-12、AC-13、AC-19、AC-29、AC-32、AC-33
+  **依赖**:T018、T028、T030、T031
+
+- [x] **T033**:版本冻结与凭据代际合同与失败测试
+  **类型**:测试;**文件**:`src/backend/test/app_runtime/test_runtime_lifecycle_v2.py`
+  **逻辑/验收**:delivery组合冻结,原四要素不变;候选只隔离凭据,切换生效/撤旧≤5秒,外部撤权不延迟;无重复签发/审批。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。
+  **覆盖 AC**:AC-15、AC-16、AC-19、AC-33、AC-36、AC-38、AC-51、AC-57、AC-60
+  **依赖**:T032
+
+- [x] **T034**:版本冻结与凭据代际
+  **类型**:实现;**文件**:`src/backend/bisheng/app_publish/domain/services/version_service.py`、`src/backend/bisheng/app_publish/domain/services/app_credential_service.py`
+  **逻辑/验收**:delivery组合冻结,原四要素不变;候选只隔离凭据,切换生效/撤旧≤5秒,外部撤权不延迟;无重复签发/审批。
+  **覆盖 AC**:AC-15、AC-16、AC-19、AC-33、AC-36、AC-38、AC-51、AC-57、AC-60
+  **依赖**:T032、T033
+
+- [x] **T035**:F054/F055安全上线合同与失败测试
+  **类型**:测试;**文件**:`src/backend/test/app_runtime/test_runtime_lifecycle_v2.py`
+  **逻辑/验收**:在停旧/DDL前原子预占,SQLite维护单写/快照确认,ready事实后事务切路由与原状态;失败/容量不足/停用恢复保持真实阶段;worker tenant_id随headers恢复。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。
+  **覆盖 AC**:AC-02、AC-09、AC-34、AC-35、AC-36、AC-37、AC-38、AC-39、AC-40、AC-41、AC-42、AC-43、AC-44、AC-46
+  **依赖**:T034、T030、T006
+
+- [x] **T036**:F054/F055安全上线
+  **类型**:实现;**文件**:`src/backend/bisheng/app_publish/domain/services/publish_online_service.py`、`src/backend/bisheng/app_runtime/domain/services/app_state_service.py`
+  **逻辑/验收**:在停旧/DDL前原子预占,SQLite维护单写/快照确认,ready事实后事务切路由与原状态;失败/容量不足/停用恢复保持真实阶段;worker tenant_id随headers恢复。
+  **覆盖 AC**:AC-02、AC-09、AC-34、AC-35、AC-36、AC-37、AC-38、AC-39、AC-40、AC-41、AC-42、AC-43、AC-44、AC-46
+  **依赖**:T034、T030、T006、T035
+
+- [x] **T037**:入口读取平台路由合同与失败测试
+  **类型**:测试;**文件**:`src/app-proxy/tests/test_platform_route_v2.py`
+  **逻辑/验收**:route读backend持久投影,无manager管理secret;manager短重启新HTTP/WS仍可取得目标,授权失败按TTL拒绝,保持身份清洗/OBO/预览。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。
+  **覆盖 AC**:AC-04、AC-18、AC-39、AC-48、AC-49、AC-50、AC-51、AC-53
+  **依赖**:T014、T036
+
+- [x] **T038**:入口读取平台路由
+  **类型**:实现;**文件**:`src/app-proxy/app_proxy/clients.py`、`src/app-proxy/app_proxy/config.py`
+  **逻辑/验收**:route读backend持久投影,无manager管理secret;manager短重启新HTTP/WS仍可取得目标,授权失败按TTL拒绝,保持身份清洗/OBO/预览。
+  **覆盖 AC**:AC-04、AC-18、AC-39、AC-48、AC-49、AC-50、AC-51、AC-53
+  **依赖**:T014、T036、T037
+
+- [x] **T039**:出口读取平台策略合同与失败测试
+  **类型**:测试;**文件**:`src/runtime-manager/tests/test_platform_egress.py`
+  **逻辑/验收**:平台policy只读身份/硬到期/撤销关CONNECT,本地只缓存,不挂应用卷不持管理密钥;保留L3/目标/DNS语义。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。
+  **覆盖 AC**:AC-04、AC-39、AC-48、AC-49、AC-50、AC-51、AC-52、AC-53
+  **依赖**:T014、T036
+
+- [x] **T040**:出口读取平台策略
+  **类型**:实现;**文件**:`src/runtime-manager/runtime_manager/egress_proxy.py`
+  **逻辑/验收**:平台policy只读身份/硬到期/撤销关CONNECT,本地只缓存,不挂应用卷不持管理密钥;保留L3/目标/DNS语义。
+  **覆盖 AC**:AC-04、AC-39、AC-48、AC-49、AC-50、AC-51、AC-52、AC-53
+  **依赖**:T014、T036、T039
+
+- [x] **T041**:环境导入及owner维护API合同与失败测试
+  **类型**:测试;**文件**:`src/backend/test/app_runtime/test_runtime_management_api.py`
+  **逻辑/验收**:顶级profile/default/base-import路由,app私包和restore接口;owner自然人业务guard先于管理员短路、expected generation/恢复损失确认及审计,服务账号不能独立发起。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。
+  **覆盖 AC**:AC-14、AC-15、AC-24、AC-28、AC-30、AC-35、AC-45、AC-47、AC-53
+  **依赖**:T016、T012、T036
+
+- [x] **T042**:环境导入及owner维护API
+  **类型**:实现;**文件**:`src/backend/bisheng/app_runtime/api/endpoints/runtime_management.py`、`src/backend/bisheng/app_runtime/domain/services/runtime_management_service.py`
+  **逻辑/验收**:顶级profile/default/base-import路由,app私包和restore接口;owner自然人业务guard先于管理员短路、expected generation/恢复损失确认及审计,服务账号不能独立发起。
+  **覆盖 AC**:AC-14、AC-15、AC-24、AC-28、AC-30、AC-35、AC-45、AC-47、AC-53
+  **依赖**:T016、T012、T036、T041
+
+### Wave 6
+
+- [x] **T043**:离线制作与环境工具合同与失败测试
+  **类型**:测试;**文件**:`src/runtime-manager/tests/test_offline_materials.py`
+  **逻辑/验收**:prepare/verify/import工具,manifest/hash/size/平台/ABI验证,制作机用Docker CLI、运行节点Engine API;双平台完整清单,默认配置导入SQL非共享文件。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。
+  **覆盖 AC**:AC-14、AC-15、AC-27、AC-28、AC-29、AC-30、AC-31、AC-55、AC-56、AC-58、AC-59
+  **依赖**:T042、T024
+
+- [x] **T044**:离线制作与环境工具
+  **类型**:实现;**文件**:`src/runtime-manager/tools/runtime_materials.py`
+  **逻辑/验收**:提供 catalog/export-profile/prepare-offline/pack/verify/import 工具;发行目录由CI真实inventory生成,不提交虚构digest。联网下载和禁网安装验证分离,临时副本不污染原源码;导入default用generation CAS,跳转不携会话。
+  **覆盖 AC**:AC-14、AC-15、AC-27、AC-28、AC-29、AC-30、AC-31、AC-55、AC-56、AC-58、AC-59
+  **依赖**:T042、T024、T043
+
+- [x] **T045**:存量迁移与发布物兼容合同与失败测试
+  **类型**:测试;**文件**:`src/backend/test/app_runtime/test_runtime_upgrade.py`
+  **逻辑/验收**:只通过scope API盘点/干跑/幂等导入,核对app与Engine标签、旧状态和版本;逐app部署绑定换backend storage,无改历史版本/免审代码修改;备份/回退限制明确。;测试只调用真实模块,mock外部端口,不用占位模块替代实现。
+  **覆盖 AC**:AC-03、AC-08、AC-10、AC-13、AC-46、AC-57、AC-60
+  **依赖**:T042、T038、T040、T044
+
+- [x] **T046**:存量迁移与发布物兼容
+  **类型**:实现;**文件**:`src/backend/scripts/upgrade_vibe_runtime.py`、`src/backend/scripts/README.md`
+  **逻辑/验收**:只通过scope API盘点/干跑/幂等导入,核对app与Engine标签、旧状态和版本;逐app部署绑定换backend storage,无改历史版本/免审代码修改;备份/回退限制明确。
+  **覆盖 AC**:AC-03、AC-08、AC-10、AC-13、AC-46、AC-57、AC-60
+  **依赖**:T042、T038、T040、T044、T045
+
+- [x] **T047**:镜像和Compose发行合同测试
+  **类型**:测试;**文件**:`src/runtime-manager/tests/test_delivery_images.py`
+  **逻辑/验收**:检查正式compose仅image、可选profile、代理权限/volume、双平台清单与预制运行/准备镜像合同;不以静态测试代替双架构运行。
+  **覆盖 AC**:AC-01、AC-03、AC-04、AC-05、AC-54、AC-55、AC-56、AC-57、AC-58
+  **依赖**:T046
+
+- [x] **T048**:manager与入口预构建镜像
+  **类型**:实现;**文件**:`src/runtime-manager/Dockerfile`、`src/app-proxy/Dockerfile`
+  **逻辑/验收**:保留Python锁定依赖,删除manager MinIO并修正socket权限旧注释;egress同镜像独立command,非root和专用目录。
+  **覆盖 AC**:AC-01、AC-04、AC-05、AC-54、AC-55、AC-57
+  **依赖**:T047
+
+- [x] **T049**:python3.11运行/准备镜像
+  **类型**:实现;**文件**:`src/app-runtime/python3.11/Dockerfile`、`src/app-runtime/python3.11/requirements.txt`
+  **逻辑/验收**:runtime/preparer分阶段镜像,固定SDK0.1.1及常用依赖/wheelhouse;纯argv启动不另造Python入口脚本。原生双架构与实际禁网物化为后续必需验收。
+  **覆盖 AC**:AC-11、AC-17、AC-22、AC-25、AC-31、AC-54、AC-55、AC-59
+  **依赖**:T048
+
+- [x] **T050**:node20运行/准备镜像
+  **类型**:实现;**文件**:`src/app-runtime/node20/Dockerfile`
+  **逻辑/验收**:Node20 runtime/preparer分阶段镜像及固定npm;启动由冻结argv/Procfile/package.start控制,不增加重复入口脚本。真实npm安装钩子/编译与双架构待验收。
+  **覆盖 AC**:AC-11、AC-17、AC-22、AC-25、AC-31、AC-54、AC-55、AC-59
+  **依赖**:T048
+
+- [x] **T051**:static运行/准备镜像
+  **类型**:实现;**文件**:`src/app-runtime/static/Dockerfile`、`src/app-runtime/static/entrypoint.sh`
+  **逻辑/验收**:非root静态HTTP服务与路径前缀;runtime/preparer多stage分别发行;双架构清单,现场不构建。
+  **覆盖 AC**:AC-11、AC-17、AC-22、AC-25、AC-31、AC-54、AC-55、AC-59
+  **依赖**:T048
+
+- [x] **T052**:Compose与安装检查
+  **类型**:实现;**文件**:`docker/docker-compose.yml`、`docker/verify_app_runtime_compose.py`
+  **逻辑/验收**:正式使用Docker Hub image,保留optional profile;清除manager MinIO和egress应用卷,配置独立身份/平台API及宿主目录;自检镜像、依赖、运行接口分别报告。
+  **覆盖 AC**:AC-01、AC-03、AC-04、AC-05、AC-18、AC-52、AC-54、AC-56、AC-58
+  **依赖**:T049、T050、T051
+
+- [x] **T053**:CI/Release双架构交付
+  **类型**:实现;**文件**:`.github/workflows/runtime-images.yml`
+  **逻辑/验收**:独立runtime-images工作流,amd64/arm64原生构建、真实容器级烟测、不可覆盖标签,双侧成功后发布目录与服务索引;不改backend已有CI。测试Tag v3.0.0-test-vibe已实跑成功,双原生构建/集成、架构标签、服务索引和目录已发布;见e2e-evidence/2026-10-21-ci-release。
+  **覆盖 AC**:AC-54、AC-55、AC-56、AC-57
+  **依赖**:T052
+
+- [x] **T054**:CLI可分发wheel及锁定依赖
+  **类型**:基础设施;**文件**:`src/backend/bisheng/dev_toolkit/artifacts/bisheng_cli-3.0.0-py3-none-any.whl`、`src/backend/bisheng/dev_toolkit/artifacts/manifest.json`
+  **逻辑/验收**:重建并分发CLI wheel及manifest,保留SDK0.1.1工件;打包脚本干净环境安装/生产入口成功,不执行git add/commit/push。
+  **覆盖 AC**:AC-05、AC-13、AC-54、AC-60
+  **依赖**:T020、T053
+
+### Wave 7
+
+- [ ] **T055**:API E2E与故障证据
+  **类型**:测试;**文件**:`src/backend/test/e2e/test_e2e_f086_runtime_delivery.py`、`features/v3.0.0/086-vibe-runtime-delivery/e2e-checklist.md`
+  **逻辑/验收**:生成显式启用的部署级权限API用例及完整人工发布/故障/双架构清单;六项live权限已通过;真实平台关键旅程、MySQL和双原生架构已取得证据,剩余故障/离线/发行门保留。用户确认DM8本轮只做模型静态审查,实测留中央回归。
+  **覆盖 AC**:AC-01、AC-02、AC-03、AC-04、AC-05、AC-06、AC-07、AC-08、AC-09、AC-10、AC-11、AC-12、AC-13、AC-14、AC-15、AC-16、AC-17、AC-18、AC-19、AC-20、AC-21、AC-22、AC-23、AC-24、AC-25、AC-26、AC-27、AC-28、AC-29、AC-30、AC-31、AC-32、AC-33、AC-34、AC-35、AC-36、AC-37、AC-38、AC-39、AC-40、AC-41、AC-42、AC-43、AC-44、AC-45、AC-46、AC-47、AC-48、AC-49、AC-50、AC-51、AC-52、AC-53、AC-54、AC-55、AC-56、AC-57、AC-58、AC-59、AC-60、AC-61、AC-62、AC-63
+  **依赖**:T054、T038、T040
+
+- [ ] **T056**:最终审查和证据汇总
+  **类型**:实现;**文件**:`features/v3.0.0/086-vibe-runtime-delivery/task-reviews.md`、`features/v3.0.0/086-vibe-runtime-delivery/validation.json`
+  **逻辑/验收**:按task-review记录真实L1、arch-guard/ruff/回归、七维code-review及差异基线证据;未通过的部署和质量门不改成已完成,无自动提交/推送/合并。
+  **覆盖 AC**:AC-01、AC-02、AC-03、AC-04、AC-05、AC-06、AC-07、AC-08、AC-09、AC-10、AC-11、AC-12、AC-13、AC-14、AC-15、AC-16、AC-17、AC-18、AC-19、AC-20、AC-21、AC-22、AC-23、AC-24、AC-25、AC-26、AC-27、AC-28、AC-29、AC-30、AC-31、AC-32、AC-33、AC-34、AC-35、AC-36、AC-37、AC-38、AC-39、AC-40、AC-41、AC-42、AC-43、AC-44、AC-45、AC-46、AC-47、AC-48、AC-49、AC-50、AC-51、AC-52、AC-53、AC-54、AC-55、AC-56、AC-57、AC-58、AC-59、AC-60、AC-61、AC-62、AC-63
+  **依赖**:T055
+
+### Wave 8
+
+- [x] **T057**:准备执行器与旧模板适配
+  **类型**:实现;**文件**:`src/runtime-manager/runtime_manager/container_runner.py`、`src/runtime-manager/runtime_manager/legacy_prepare.py`
+  **逻辑/验收**:仅受限任务容器,资源限额与预占一致;宿主路径映射、非root、禁网/下载分离。source-image仅用平台模板、显式startup和操作范围出站。
+  **覆盖 AC**:AC-11、AC-13、AC-19、AC-26、AC-53、AC-63
+  **依赖**:T023、T027
+
+- [x] **T058**:发布交付协调器
+  **类型**:实现;**文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_delivery_coordinator.py`
+  **逻辑/验收**:准备/探活/预览/上线/恢复都登记持久操作;固定profile/源码/依赖,范围资产传输;原Owner提交状态,秘密先封装再允许执行。
+  **覆盖 AC**:AC-05、AC-15、AC-33、AC-37、AC-39、AC-41、AC-63
+  **依赖**:T031、T035
+
+- [x] **T059**:凭据保护与执行代际读取
+  **类型**:实现;**文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_payload.py`、`src/backend/bisheng/app_runtime/domain/repositories/runtime_credential_repository.py`
+  **逻辑/验收**:部署Fernet key封装可续跑的应用环境;正式和排队调用复用F049认证入口,绑定route operation,candidate/旧同版本/维护均拒绝。
+  **覆盖 AC**:AC-39、AC-51、AC-53
+  **依赖**:T033
+
+- [x] **T060**:路由和出站投影业务
+  **类型**:实现;**文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_projection_service.py`、`src/backend/bisheng/app_runtime/domain/services/runtime_egress_service.py`
+  **逻辑/验收**:同UoW激活route/原凭据/附件句柄;失败撤销候选;出站稳定operation凭据、正式切换fencing、生成环境平台URL放行及显式UTC时间。
+  **覆盖 AC**:AC-39、AC-48、AC-49、AC-50、AC-51、AC-53
+  **依赖**:T033、T039
+
+- [x] **T061**:存量与离线内容校验业务
+  **类型**:实现;**文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_upgrade_service.py`、`src/backend/bisheng/app_runtime/domain/services/runtime_offline_package.py`
+  **逻辑/验收**:校验有界tar/hash/ABI且拒绝登录文件;存量绑定用签名实时inventory复核,保持AppVersion不变,需确认维护及重新绑定。
+  **覆盖 AC**:AC-07、AC-13、AC-27、AC-30、AC-46、AC-57、AC-60
+  **依赖**:T041、T045
+
+- [x] **T062**:控制客户端与范围回收
+  **类型**:实现;**文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_control_client.py`、`src/backend/bisheng/app_runtime/domain/services/runtime_cleanup_service.py`
+  **逻辑/验收**:平台独立control HMAC,只唤醒ID/读inventory;确认执行停止后清理该预览/删除动作的附件及指定快照,不改其他应用。
+  **覆盖 AC**:AC-06、AC-09、AC-10、AC-26、AC-63
+  **依赖**:T013、T015
+
+- [x] **T063**:manager启动和旧协议拒绝
+  **类型**:实现;**文件**:`src/runtime-manager/runtime_manager/main.py`、`src/runtime-manager/runtime_manager/auth.py`
+  **逻辑/验收**:协议2采用平台对账loop,旧写/存储入口拒绝;协议1保留迁移兼容。入站nonce后端持久化,身份与出站读身份分离。
+  **覆盖 AC**:AC-04、AC-06、AC-48、AC-53、AC-58
+  **依赖**:T027
+
+- [x] **T064**:SQLite数据容器合同测试
+  **类型**:测试;**文件**:`src/runtime-manager/tests/test_data_executor.py`
+  **逻辑/验收**:验证只挂一个app数据、无控制secret、非root和network none,保留HTTP错误及清理容器/临时输出;真实Docker同镜像运行由CI验证。
+  **覆盖 AC**:AC-47、AC-52、AC-53
+  **依赖**:T029
+
+- [x] **T065**:受控数据执行API
+  **类型**:实现;**文件**:`src/runtime-manager/runtime_manager/api/platform_data.py`、`src/runtime-manager/runtime_manager/data_executor.py`
+  **逻辑/验收**:后台权限后经control签名到manager;当前node与维护互斥;相同manager镜像短命helper,128MiB/100mCPU、两并发、30秒与标签GC,导出一次性目录。
+  **覆盖 AC**:AC-47、AC-52、AC-53
+  **依赖**:T064
+
+- [x] **T066**:单应用数据入口与只读观测
+  **类型**:实现;**文件**:`src/runtime-manager/runtime_manager/data_worker.py`、`src/runtime-manager/runtime_manager/api/platform_observe.py`
+  **逻辑/验收**:worker只执行已有表/行/导出协议,无任意SQL;status/log由平台绑定定位容器并脱敏,runtime-status保留旧响应字段、SQL容量失联不回落本地状态。
+  **覆盖 AC**:AC-47、AC-48、AC-50、AC-53
+  **依赖**:T027、T064
+
+- [x] **T067**:旧运行绑定只读inventory
+  **类型**:实现;**文件**:`src/runtime-manager/runtime_manager/api/upgrade.py`
+  **逻辑/验收**:按旧state和实时Engine/镜像/标签导出范围,兼容模式可读取,排除容器env/密钥,记录旧bucket差异。
+  **覆盖 AC**:AC-13、AC-46、AC-57、AC-60
+  **依赖**:T045
+
+- [x] **T068**:出口平台策略缓存适配
+  **类型**:实现;**文件**:`src/runtime-manager/runtime_manager/platform_egress.py`
+  **逻辑/验收**:独立只读身份取policy hash/epoch/硬期限,刷新离开事件循环,平台故障超过TTL就拒绝/关闭连接。
+  **覆盖 AC**:AC-49、AC-50、AC-52
+  **依赖**:T039
+
+- [x] **T069**:入口协议与运行接线
+  **类型**:实现;**文件**:`src/app-proxy/app_proxy/platform_protocol.py`、`src/app-proxy/app_proxy/main.py`
+  **逻辑/验收**:独立镜像协议共享测试向量;入口不持有manager写权限,读取平台路由并保留原HTTP/WS授权。
+  **覆盖 AC**:AC-48、AC-49、AC-53
+  **依赖**:T007、T037
+
+- [x] **T070**:审批预览与授权接线
+  **类型**:实现;**文件**:`src/backend/bisheng/app_publish/domain/services/preview_instance_service.py`、`src/backend/bisheng/app_runtime/domain/services/entry_authz_service.py`
+  **逻辑/验收**:原预览session/owner能力例外不变,v2使用冻结delivery;正式维护路由不可被旧授权缓存长期放行。
+  **覆盖 AC**:AC-09、AC-33、AC-48、AC-51
+  **依赖**:T033、T035
+
+- [x] **T071**:SDK中间件与创建入口约束
+  **类型**:实现;**文件**:`src/backend/bisheng/utils/http_middleware.py`、`src/backend/test/app_runtime/test_runtime_layer_switch.py`
+  **逻辑/验收**:仅SDK storage namespace走句柄认证;新增app-scoped材料/恢复维护POST不成为UI创建应用入口。
+  **覆盖 AC**:AC-05、AC-07、AC-53、AC-60
+  **依赖**:T015
+
+- [x] **T072**:离线与owner业务测试
+  **类型**:测试;**文件**:`src/backend/test/app_runtime/test_runtime_management.py`
+  **逻辑/验收**:真实离线tar的内容及profile兼容元数据和自然人owner矩阵;不假造MinIO/权限核心模块。
+  **覆盖 AC**:AC-07、AC-14、AC-15、AC-30、AC-45、AC-53
+  **依赖**:T011
+
+- [x] **T073**:数据恢复审计登记
+  **类型**:基础设施;**文件**:`src/backend/bisheng/database/models/audit_log.py`、`src/backend/bisheng/app_runtime/domain/constants.py`
+  **逻辑/验收**:登记app.data_restore到原审计枚举和UI可见白名单,使用原system audit流程;不增加新数字错误码。
+  **覆盖 AC**:AC-45、AC-47
+  **依赖**:T035
+
+- [x] **T074**:Platform审计筛选接线
+  **类型**:前端 Platform;**文件**:`src/frontend/platform/src/controllers/API/log.ts`
+  **逻辑/验收**:原审计动作列表增加data_restore,保持既有请求封装;无组件/样式/新页面变更。
+  **覆盖 AC**:AC-47
+  **依赖**:T073
+
+- [x] **T075**:审计三语文案第一组
+  **类型**:前端 Platform;**文件**:`src/frontend/platform/public/locales/en-US/bs.json`、`src/frontend/platform/public/locales/zh-Hans/bs.json`
+  **逻辑/验收**:仅已有bs审计字典新增同键;这不是api_errors生成文件。
+  **覆盖 AC**:AC-47
+  **依赖**:T073
+
+- [x] **T076**:审计三语文案日语
+  **类型**:前端 Platform;**文件**:`src/frontend/platform/public/locales/ja/bs.json`
+  **逻辑/验收**:同键日语补齐,和前项在同一改动交付,check-i18n验证。
+  **覆盖 AC**:AC-47
+  **依赖**:T073
+
+- [x] **T077**:原生镜像构建与容器烟测
+  **类型**:实现;**文件**:`scripts/build_runtime_images.sh`、`scripts/smoke_runtime_delivery.py`
+  **逻辑/验收**:构建服务及runtime/preparer、拒绝已有标签;真实Engine且全程应用包禁网、代码更新和复用、SQLite/helper,内存产物port;未运行不得等同整个平台E2E。
+  **覆盖 AC**:AC-11、AC-19、AC-20、AC-31、AC-41、AC-54、AC-55、AC-56
+  **依赖**:T047
+
+- [x] **T078**:镜像内统一准备器
+  **类型**:实现;**文件**:`src/app-runtime/common/prepare.py`
+  **逻辑/验收**:联网只下载受限Python轮子和npm材料,禁网运行安装钩子/本地项目wheel/前端编译,保存锁/原生材料/源码overlay并拒绝逃逸或凭据文件。
+  **覆盖 AC**:AC-19、AC-21、AC-24、AC-25、AC-27、AC-28、AC-29、AC-53
+  **依赖**:T023、T043
+
+- [x] **T079**:static无root入口配置
+  **类型**:基础设施;**文件**:`src/app-runtime/static/nginx.conf`
+  **逻辑/验收**:动态PORT配置由entrypoint渲染到tmp;静态编译复用Node preparer,不新建常驻服务。
+  **覆盖 AC**:AC-11、AC-17、AC-25、AC-54
+  **依赖**:T025
+
+- [x] **T080**:独立开发构建覆盖
+  **类型**:基础设施;**文件**:`docker/docker-compose.runtime-dev.yml`
+  **逻辑/验收**:只有开发override含build;正式Compose全部使用镜像引用。
+  **覆盖 AC**:AC-54、AC-58
+  **依赖**:T047
+
+- [x] **T081**:安装环境与校验脚本
+  **类型**:基础设施;**文件**:`docker/.env.example`、`docker/verify-app-runtime-compose.sh`
+  **逻辑/验收**:列明分离HMAC/Fernet/离线/镜像参数;Compose命令仅做config/权限/网络检查,不现场启动或构建。
+  **覆盖 AC**:AC-03、AC-04、AC-53、AC-58
+  **依赖**:T047
+
+- [x] **T082**:manager冻结依赖合同
+  **类型**:基础设施;**文件**:`src/runtime-manager/pyproject.toml`、`src/runtime-manager/uv.lock`
+  **逻辑/验收**:默认不安装MinIO,legacy-storage显式optional;保留原固定依赖版本并用frozen锁检查,不做无关升级。
+  **覆盖 AC**:AC-04、AC-05、AC-54、AC-57
+  **依赖**:T047
+
+- [x] **T083**:安装维护文档
+  **类型**:文档;**文件**:`src/runtime-manager/OPERATIONS.md`、`docs/architecture/14-app-factory-deployment.md`
+  **逻辑/验收**:说明具体profile/默认替换、离线制作导入/边界、SQLite/helper及维护升级;旧架构文档指向v2而不冒充已发布。
+  **覆盖 AC**:AC-03、AC-27、AC-28、AC-45、AC-46、AC-54、AC-57、AC-58、AC-59、AC-62
+  **依赖**:T044、T046
+
+- [x] **T084**:CLI及版本交付登记
+  **类型**:文档;**文件**:`src/bisheng-cli/README.md`、`features/v3.0.0/release-contract.md`
+  **逻辑/验收**:同步owner绑定和SAK发布、F086设计已确认/代码与部署验收分层;原不变量及历史评审输入保持。
+  **覆盖 AC**:AC-13、AC-30、AC-54、AC-58、AC-60、AC-62
+  **依赖**:T054
+
+### Wave 9
+
+- [x] **T085**:离线补包入口流式代理
+  **类型**:基础设施;**文件**:`docker/nginx/conf.d/default.conf`
+  **逻辑/验收**:材料上传路径2GiB流式转发,保留鉴权/转发头;真实Nginx语法检查及2,218,132字节材料经nginx/后端校验已通过;未实传2GiB。
+  **覆盖 AC**:AC-05、AC-07、AC-27、AC-28、AC-58
+  **依赖**:T047
+
+### Wave 10
+
+- [x] **T086**:平台执行事实与旧RPC适配
+  **类型**:实现;**文件**:`src/backend/bisheng/app_runtime/domain/services/runtime_operation_service.py`、`src/backend/bisheng/app_runtime/domain/services/orchestrator_client.py`
+  **逻辑/验收**:执行身份/阶段/事实/诊断/归属/预览/自愈的领域入口;原logs/status/data RPC通过v2control签名适配,不能旁路业务授权。
+  **覆盖 AC**:AC-06、AC-29、AC-35、AC-36、AC-37、AC-48、AC-50、AC-53、AC-63
+  **依赖**:T013、T033
+
+- [x] **T087**:CLI既有入口回归适配
+  **类型**:测试;**文件**:`src/bisheng-cli/tests/test_cli.py`
+  **逻辑/验收**:显式新版init参数和旧项目兼容,保留CLI原有参数/分发行为;完整383项回归。
+  **覆盖 AC**:AC-11、AC-13、AC-17、AC-60
+  **依赖**:T017
+
+- [x] **T088**:CLI命令分发接线
+  **类型**:实现;**文件**:`src/bisheng-cli/bisheng_cli/cli.py`、`src/bisheng-cli/bisheng_cli/main.py`
+  **逻辑/验收**:将init新版运行描述接入真实CLI入口;不重写已有manifest,不改变普通deploy鉴权。
+  **覆盖 AC**:AC-11、AC-13、AC-17、AC-60
+  **依赖**:T019、T087
+
+- [x] **T089**:旧策略文件范围标识
+  **类型**:文档;**文件**:`src/runtime-manager/runtime_manager/egress.py`
+  **逻辑/验收**:仅明确本地policy文件属于protocol1兼容路径,protocol2采用平台只读缓存,不改变旧策略算法。
+  **覆盖 AC**:AC-49、AC-50
+  **依赖**:T039
+
+## 交付门
+
+未执行的真实运行不得用mock、skip或文档代替;DM8按用户确认的静态审查口径处理,实测留中央回归。前端lint和两项backend权限基线失败单独记录,不能宣称全仓绿灯。
+
+真实 SQL 补充验收:T005 增加 opt-in 专用库用例;MySQL 同步竞争曾暴露重复键快照读与首次归属间隙锁两项缺陷,修复后通过。仅MySQL实测,DM8及平台完整 E2E 不因此勾选。
+
+### Wave 2026-10-21:多租户启动验收修复
+
+- [x] **T090**:后台租户上下文执行边界
+  **类型**:实现;**文件**:`src/backend/bisheng/core/context/tenant.py`、`src/backend/test/tenant/test_tenant_execution_scope.py`
+  **逻辑/验收**:绑定已验证目标租户,临时收窄visible set并清除继承的admin override/bypass,嵌套、异常和并发均恢复原上下文。组合回归56 passed;109全新multi_tenant.enabled=true启动与真实首管理员注册通过。
+  **覆盖 AC**:AC-61、AC-62
+  **依赖**:无
+
+- [x] **T091**:系统资源启动投影租户绑定
+  **类型**:实现;**文件**:`src/backend/bisheng/permission/application/system_resource_reconcile.py`、`src/backend/test/permission/test_f048_system_resource_reconcile.py`
+  **逻辑/验收**:每个系统资源投影使用其可信tenant_id执行,保留原权限授权入口,不以全局bypass规避过滤。组合回归56 passed;109全新multi_tenant.enabled=true启动与真实首管理员注册通过。
+  **覆盖 AC**:AC-61、AC-62
+  **依赖**:T090
+
+- [x] **T092**:首次管理员仪表盘投影上下文
+  **类型**:实现;**文件**:`src/backend/bisheng/api/services/f048_preset_dashboard_bootstrap.py`、`src/backend/test/permission/test_f048_preset_dashboard_bootstrap.py`
+  **逻辑/验收**:首次自然注册及启动对账的预置dashboard owner投影显式绑定Root租户;已有owner与投影的幂等逻辑保持。组合回归56 passed;109全新multi_tenant.enabled=true启动与真实首管理员注册通过。
+  **覆盖 AC**:AC-61、AC-62
+  **依赖**:T090
+
+通用 F048 启动修复已按授权先进入 release line `9d156f261c`,F059 经 `98fbba032` 正常继承;本次只收回 F086 的历史文档/证据,不覆盖后续公共代码。spec/design 业务决策未偏离。
+
+### Wave 2026-10-21:DM8静态审查与字节边界
+
+- [x] **T093**:字节计数范围与模型规范检查
+  **类型**:实现;**文件**:`src/backend/bisheng/app_runtime/domain/models/runtime_delivery.py`、`src/backend/test/app_runtime/test_runtime_model_contracts.py`
+  **逻辑/验收**:size_bytes和offset使用BigInteger,覆盖15表JsonType/时间默认/租户/唯一约束和索引静态检查,符合2GiB材料和10GiB快照合同;DM8不连接。
+  **覆盖 AC**:AC-06、AC-35、AC-61
+  **依赖**:T001
+
+- [x] **T094**:已有运行时字节字段扩容迁移
+  **类型**:基础设施;**文件**:`src/backend/bisheng/core/database/alembic/versions/f086_runtime_byte_counters.py`、`src/backend/test/app_runtime/test_runtime_model_contracts.py`
+  **逻辑/验收**:挂接已核对single head merge_vibe_f077_heads,DDL-only INTEGER到BIGINT;新库/已扩容跳过,异常类型失败,降级保留宽列;组合28项回归通过。
+  **覆盖 AC**:AC-06、AC-35、AC-61
+  **依赖**:T093
+
+2026-10-23 开发整合:F086 实现与证据已与 F059 汇合;92/94 不变,T055/T056 的完整离线/剩余故障/最终交付门仍 pending。当前七维开发合入审查已完成(code-review.md),旧审查归档为 code-review-2026-10-21.md。用户明确授权先合入 feat/3.0.0-vibe 并推送;此授权不表示全 AC 或当前版本正式镜像发行完成。
diff --git a/features/v3.0.0/086-vibe-runtime-delivery/validation.json b/features/v3.0.0/086-vibe-runtime-delivery/validation.json
new file mode 100644
index 0000000000..86eb90f725
--- /dev/null
+++ b/features/v3.0.0/086-vibe-runtime-delivery/validation.json
@@ -0,0 +1,268 @@
+{
+  "feature": "F086",
+  "date": "2026-10-21",
+  "scope": "checkpoint plus uncommitted multi-tenant bootstrap fix; Docker multi-tenant, representative failure and legacy upgrade acceptance; full gates incomplete",
+  "baseline_sha": "4aa9ce69b2830b81162fe631b9c801057c40ee8b",
+  "branch": "codex/086-vibe-runtime-delivery",
+  "runtime_code_changed": true,
+  "human_spec_confirmation": "用户明确要求修复四项问题并在复审通过后继续;条件已满足",
+  "human_design_confirmation": "2026-10-20 用户回复:确认",
+  "spec_review": "LGTM round 2; four findings closed",
+  "design_review": "confirmed; implementation details synchronized",
+  "tasks_review": "LGTM (same-agent declaration review)",
+  "task_counts": {
+    "local_verified": 84,
+    "native_verified": 8,
+    "total": 94,
+    "pending_integration": [
+      "T055",
+      "T056"
+    ]
+  },
+  "local_tests": {
+    "backend": {
+      "passed": 1214,
+      "failed": 2,
+      "skipped": 24,
+      "log": "/private/tmp/f086-backend-final-regression.log",
+      "log_sha256": "272dfaf662dedbeb3d94759c7a343e21b5bebff486a58b22c2e5ccdac9bc6998",
+      "archived_log": "e2e-evidence/2026-10-20-docker/logs/backend-local-final.log",
+      "archived_log_sha256": "099b2a9eb89db43b67e4ffb358ec751678d2abacc3fbd5be115126f4ae776d0d",
+      "archived_log_normalization": "Line-ending spaces/tabs trimmed; original log hash retained in log_sha256 and evidence normalization.json"
+    },
+    "manager": {
+      "passed": 437,
+      "failed": 0,
+      "skipped": 10,
+      "log": "/private/tmp/f086-manager-final-regression.log",
+      "log_sha256": "4d93549f959da23fc0ff2d32e4a033be59be732095e0e02951f04cede66beee8",
+      "archived_log": "e2e-evidence/2026-10-20-docker/logs/manager-local-final.log"
+    },
+    "app_proxy": {
+      "passed": 256,
+      "failed": 0,
+      "skipped": 0,
+      "log": "/private/tmp/vibe-app-proxy-release-check.log",
+      "log_sha256": "21c834ca3a29d0fb0d76e9fa0e9333e2251a8618864e1874a88a2c64a67f988f"
+    },
+    "cli": {
+      "passed": 383,
+      "failed": 0,
+      "skipped": 0,
+      "log": "/private/tmp/vibe-cli-regression-native.log",
+      "log_sha256": "79b58d6ee98188a1373a9d1e3ecbd92e7388f5d558345b909be7c1595fb393b0"
+    },
+    "sdk": {
+      "passed": 212,
+      "failed": 0,
+      "skipped": 0,
+      "log": "/private/tmp/vibe-sdk-regression.log",
+      "log_sha256": "cc33a6193288cc10c2cd4a96ce151c9790606cc62b119da2cc2fbecbc2a814a4"
+    },
+    "dev_toolkit_frozen_docker": {
+      "passed": 129,
+      "failed": 0,
+      "skipped": 0,
+      "mcp": "1.30.0",
+      "log": "e2e-evidence/2026-10-20-docker/logs/dev-toolkit-frozen-final.log",
+      "log_sha256": "c20b9a5cc0e95dc851c24141e8bcf0cdf7a6f744844792f6947f869460af4ca2"
+    }
+  },
+  "baseline_failures": {
+    "backend_permission_tests": {
+      "baseline": "4aa9ce69",
+      "failed": 2,
+      "passed": 3,
+      "log": "/private/tmp/vibe-baseline-permission.log",
+      "reason": "permission_runtime context not initialized; reproduced on unmodified baseline"
+    },
+    "frontend_lint": {
+      "errors": 95,
+      "warnings": 1,
+      "normalized_output_identical": true,
+      "normalized_sha256": "f5024c001d8ee71ec291d778bd16747c515e11e5e865020fa5ef3d76818f41af",
+      "baseline_log": "/private/tmp/vibe-frontend-lint-baseline.log",
+      "worktree_log": "/private/tmp/vibe-frontend-lint-locked.log"
+    }
+  },
+  "quality": {
+    "changed_python_ruff": "PASS (101 files, final source)",
+    "changed_python_arch_guard": "PASS (101 files, final source)",
+    "frontend_typecheck": "PASS on worktree frozen-lock dependencies",
+    "frontend_i18n": "PASS",
+    "frontend_lint": "FAIL; baseline-identical 95 errors / 1 warning",
+    "compose_contract": "PASS; no services started",
+    "shell_syntax": "PASS",
+    "manager_frozen_lock": "PASS",
+    "cli_wheel_clean_install": "PASS"
+  },
+  "cli_wheel": {
+    "path": "src/backend/bisheng/dev_toolkit/artifacts/bisheng_cli-3.0.0-py3-none-any.whl",
+    "sha256": "a4cd66a21797d45b16bb6d682db95614cb21225eafbadc0bee270346b9e1307e",
+    "sdk_version_unchanged": "0.1.1"
+  },
+  "deployment": {
+    "docker_build_result": "Seven test images built natively on each amd64 and arm64 after approved proxy supplied; official CI/push not run",
+    "real_container_integration_executed": true,
+    "real_platform_e2e_executed": true,
+    "e2e_cases_collected_and_skipped": 6,
+    "native_amd64_arm64_ci_executed": false,
+    "mysql_dm8_integration_executed": false,
+    "offline_installation_executed": false,
+    "k8s_in_scope": false,
+    "images_published": false,
+    "existing_services_modified": false,
+    "mysql_integration_executed": true,
+    "mysql_concurrent_cases_passed": 5,
+    "dm8_integration_executed": false,
+    "native_amd64_container_integration": true,
+    "native_arm64_container_integration": true,
+    "offline_application_prepare_executed": true,
+    "offline_supplement_and_cli_retry": true,
+    "complete_offline_platform_installation_executed": false,
+    "k8s_prerequisites_inspected": true,
+    "native_container_scenarios_per_arch": 5,
+    "native_local_c_extension_wheel": true,
+    "preexisting_business_container_ids_unchanged": 26,
+    "full_application_image_journey": true,
+    "v1_source_image_journey": true,
+    "wrong_port_rejected_before_approval": true,
+    "all_test_apps_deleted": 4,
+    "capacity_reservations_released": 29,
+    "test_services_and_networks_cleaned": true,
+    "test_credential_and_data_directory_removed": true,
+    "cached_images_retained": true,
+    "original_business_container_ids_unchanged": 26
+  },
+  "e2e_gate": "Key Docker/upgrade journeys and dual-native GitHub test release passed; DM8 live deferred by user; exhaustive faults, full offline installation and final feature review remain pending",
+  "code_review": "NEEDS_FIX: remaining integration/release gates, not an unqualified delivery declaration",
+  "review_input_hashes_verified": {
+    "vibe-app-open-source-technical-design.md": "2fb280efb649aae0c6430b3cc418078e559cef8734e3e30fa402a1575a447c52",
+    "vibe-app-open-source-technical-design.html": "36e131d491a8afbf769c695a0f2acefaa51a82e9def2fcf1a203403afc46411e",
+    "vibe-app-hosting-commercialization-review.md": "528d3e7c1f1898711a3cdb34bc14b9ca4786306a8e98074d9d387b09f6326066",
+    "vibe-app-hosting-commercialization-review.html": "082b550f7abc9c4f8b7d85572565abc5d0487f451d6edd086dba0047c331963d",
+    "bisheng-gateway-runtime-manager-implementation-plan.md": "c9c87e983023b858fe35dce701855d9e7ae340ceee84d49f3270db7c9fc19182",
+    "bisheng-gateway-runtime-manager-implementation-plan.html": "b393e3ad7e59bd98847690c3fed786f56361f385f818227eb51f9c2478e308f7"
+  },
+  "git": {
+    "committed": false,
+    "staged": false,
+    "pushed": false,
+    "merged": false,
+    "primary_unrelated_changes_preserved": true,
+    "snapshot_date": "2026-10-20",
+    "runtime_test_release_commit": "755d4456e50c015a3fa88da0c0c35ab53bbd1650",
+    "runtime_test_release_tag": "v3.0.0-test-vibe"
+  },
+  "implementation_task_file_coverage_complete": true,
+  "note": "SQLite transactions, ASGI and mocked external ports prove local contracts only. Skipped tests and written CI workflows are not runtime or release evidence.",
+  "live_report": "live-validation.md",
+  "additional_attempts": [
+    {
+      "scope": "local app_runtime/app_publish/dev_toolkit/live-opt-in combined",
+      "passed": 1311,
+      "failed": 34,
+      "skipped": 24,
+      "log": "/private/tmp/f086-backend-post-mapping-regression.log",
+      "reason": "2 known permission initialization failures; 30 MCP 1.27.1 vs lock 1.30.0 signature failures and 2 sandbox localhost binding failures. dev_toolkit repeated in frozen Docker image; results tracked separately."
+    }
+  ],
+  "checkpoint_authorization": {
+    "date": "2026-10-21",
+    "request": "提交推送代码先",
+    "branch": "codex/086-vibe-runtime-delivery",
+    "scope": "Commit and push current implementation, documentation and sanitized validation evidence; acceptance/release gates remain pending",
+    "merge_authorized": false,
+    "commit_sha": "5131b81194a5fa390593e58d822bcbde52b6ca64",
+    "remote_sha_verified": true
+  },
+  "followup_2026_10_21": {
+    "request": "继续推进剩余验收,先处理多租户启动问题,补齐故障和升级场景的验证",
+    "code_status": "uncommitted follow-up fixes; generic F048 changes require release-line-first landing before a later commit",
+    "bootstrap_regression": {
+      "before_failed": 3,
+      "before_passed": 7,
+      "after_passed": 56,
+      "after_failed": 0
+    },
+    "fresh_multi_tenant_startup": "passed: 18 system resources and natural first-admin registration",
+    "live_owner_foreign_tests": {
+      "passed": 6,
+      "failed": 0,
+      "skipped": 0
+    },
+    "acceptance_in_progress": false,
+    "evidence_dir": "e2e-evidence/2026-10-21-docker",
+    "evidence_files": 56,
+    "verified_journeys": [
+      "fresh multi-tenant startup and natural first-admin registration",
+      "ordinary child-tenant owner publication and approval",
+      "six live owner/nonowner/foreign checks",
+      "probe cancellation and timeout",
+      "Node install-hook cancellation and compile timeout",
+      "SIGKILL manager during probe and resume same operation",
+      "object PUT succeeded but SQL snapshot confirmation failed; retry same approval",
+      "DDL committed then startup failed; risk flag and data retained; compatible roll forward",
+      "real visitor Grant removal closes WS within5s",
+      "backend unavailable closes existing WS within5s",
+      "SQL-injected egress revoke closes existing CONNECT within5s",
+      "manager restart preserves existing WS/CONNECT and 32 new HTTP/SDK checks",
+      "real v1 publication, dry-run, pre-import rollback, idempotent import and v2 rebind; history/pending/legacy attachment retained"
+    ],
+    "websocket_permission_revoke_seconds": 4.028708160854876,
+    "websocket_backend_unavailable_seconds": 4.013150930404663,
+    "connect_policy_revoke_seconds": 1.266052007675171,
+    "cleanup": {
+      "applications": 7,
+      "reservations_released": 23,
+      "snapshots_purged": 3,
+      "test_stack_removed": true,
+      "original_containers_full_ids_unchanged": 26
+    },
+    "remaining": [
+      "exhaustive fault matrix including unknown Engine build stop and node loss",
+      "full customer offline installation and complete SDK/model/retrieval combinations",
+      "official CI and image/profile publication",
+      "pre-existing backend/frontend quality gates"
+    ]
+  },
+  "dm8_acceptance": {
+    "user_decision_date": "2026-10-21",
+    "scope": "static model/type/default/constraint/index/tenant/migration review only",
+    "live_test_deferred": true,
+    "live_test_is_current_blocker": false,
+    "central_regression_still_required_for_runtime_claim": true,
+    "tables": 15,
+    "static_review": "passed after BIGINT range fix",
+    "related_regression_passed": 28,
+    "migration_head": "f086_runtime_byte_counters",
+    "new_migration_live_execution": false,
+    "report": "model-review.md"
+  },
+  "image_publication": {
+    "implementation": "workflow and build scripts complete; runner portability fixed in 755d4456e5",
+    "official_github_run": "passed",
+    "run_url": "https://github.com/dataelement/bisheng/actions/runs/39567381522",
+    "tag": "v3.0.0-test-vibe",
+    "commit": "755d4456e50c015a3fa88da0c0c35ab53bbd1650",
+    "dockerhub_release": "test release published, seven image families for each native architecture",
+    "catalog_and_service_indexes": "published; artifact metadata observed in GitHub UI",
+    "platform_catalog_import": "not performed",
+    "dispatch_pushes_images": true,
+    "109_public_service_images": "passed after proxy download and docker load; original 27 containers unchanged",
+    "evidence": "e2e-evidence/2026-10-21-ci-release/README.md"
+  },
+  "development_integration_2026_10_23": {
+    "target_branch": "feat/3.0.0-vibe",
+    "target_base": "4aa9ce69b2830b81162fe631b9c801057c40ee8b",
+    "implementation_source": "cda8dccf26298af91a1df277dcd1a49787b6b444",
+    "f086_source": "667546bb77eeb366e66a13d544d122ef24811e35",
+    "authorization": "user requests development closeout, merge and push; no full-suite rerun",
+    "scope": "development integration only; historical checks retain their original source/date",
+    "code_review": "see code-review.md",
+    "full_delivery_accepted": false,
+    "public_image_release_executed": false,
+    "arm_validation": "deferred by user",
+    "dm8_validation": "static only"
+  }
+}
diff --git a/features/v3.0.0/087-context-window-budget/design.md b/features/v3.0.0/087-context-window-budget/design.md
new file mode 100644
index 0000000000..f5d03ba68c
--- /dev/null
+++ b/features/v3.0.0/087-context-window-budget/design.md
@@ -0,0 +1,301 @@
+# Design: 任务模式按模型上下文长度控制上下文
+
+> **本文档定位 — 现状快照(Why this How)**
+>
+> - `spec.md` 回答做什么;本文回答为什么这么实现;`tasks.md` 是流水账。
+> - 实现变化时覆盖更新本文;推翻已确认的决策要先与用户重新确认。
+> - 问题背景、现状盘点、采访决策 D1–D10 见 [plan.md](./plan.md),本文不重复。
+
+**关联**: [spec.md](./spec.md) · [tasks.md](./tasks.md) · [plan.md](./plan.md)
+**版本**: v3.0.0
+**最后更新**: 2026-10-21(P0、P1、P2 已实现;P0、P1 的端到端验证待客户现场)
+
+---
+
+## 1. 目标与非目标
+
+- **目标**:任务模式在每次运行开始时确定所用模型的上下文长度,把它交给 deepagents,使压缩在超窗之前触发;超窗和压缩失败时,任务以可识别的原因结束,而不是以"未知错误"结束。
+- **非目标**:不改 deepagents 源码;不改拿不到上下文长度时的行为;不覆盖日常模式、助手、工作流;不在模型配置页面加字段。完整清单见 spec.md 范围边界。
+
+---
+
+## 2. 关键约束
+
+- 遵循 `docs/constitution.md` C1–C8。本功能涉及的条款:C1(linsight 调 llm 模块的 domain service,不跨 api 层)、C5(新增错误码 11075、11076,见 §6.1)、C8(上下文长度缓存放 Redis,不放进程内存或本地文件)。
+- **deepagents 0.6.12 只开放了一个入口。** `create_deep_agent` 没有压缩、计数、转存相关的参数。框架读取的唯一外部信息是模型实例的 `profile["max_input_tokens"]`(`deepagents/middleware/summarization.py:261`)。P0 只用这个入口;P1 要替换框架部件,必须先做技术验证(决策 7)。
+- **中间件顺序固定。** deepagents 主图的中间件栈是 `[TodoList, (Skills,仅当 create_deep_agent 收到 skills 参数), Filesystem, SubAgent, Summarization, PatchToolCalls, *BiSheng 中间件]`;linsight 通过 `middleware=` 自己挂 SkillsMiddleware,所以它也在 BiSheng 中间件之列(`deepagents/graph.py:773-812`),子图同理(`graph.py:643-654`)。列表靠前的是外层,所以 BiSheng 的 resilience 中间件在压缩中间件的**内层**,先于它看到模型异常。
+- **多节点部署。** API 进程(保存模型配置)和任务执行进程(linsight worker)可能在不同机器上。上下文长度缓存及其失效必须经过 Redis。
+- **服务商接口读取发生在任务启动路径上。** 每个模型每小时最多读取一次,单次超时 3 秒;读取失败不能阻塞任务。
+
+---
+
+## 3. 方案对比与选定
+
+### 决策 1:怎样把上下文长度交给 deepagents
+
+- **备选**:
+  - A. 在模型实例上设置 `profile = {"max_input_tokens": 可用输入}` — 框架公开机制,主图和子图共用同一个模型实例,一处设置两处生效;只能控制压缩阈值(85%)和保留比例(10%)。
+  - B. P0 就替换框架的压缩中间件 — 能控制所有参数,但要先完成决策 7 的技术验证,拖慢 P0。
+- **选定**:A(P0);B 留给 P1。
+- **原因**:A 把压缩阈值从固定 170K 改成 0.85 × 可用输入,框架读到 profile 后的默认比例正好是 plan 推导的值,P0 不需要额外参数。
+- **P0 的局限(必须知道)**:框架判断"到没到阈值"有两条路,满足任一条就压缩(`langchain/agents/middleware/summarization.py:583`):
+  1. **用量路径**:最近一条模型回复的 `usage.total_tokens` 达到阈值。这个数是服务商实测的,准确,但不包含这条回复之后新进来的消息(主要是刚返回的工具结果)。
+  2. **估算路径**:deepagents 用 `count_tokens_approximately` 按 4 个字符 1 个 token 估算全部消息(`deepagents/middleware/summarization.py:527`、`:1016`),中文会被少算 2 倍以上。
+
+  所以 P0 下,"上一次请求的实测用量 + 一大段中文工具结果"可能在估算上没到阈值、实际已经超窗。这种情况由 AC-13 的超窗后压缩重试兜住,任务不会因此失败,但会多一次被拒的请求。准确的提前判断要等 P1 的计数(决策 10、AC-21)。§7 的手动验证据此区分"提前压缩"和"超窗后压缩"两种日志。
+- **备选 C(被否)**:P0 把 profile 设得比可用输入更小(例如乘 0.6),以抵消中文少算。被否原因:它同时把保留量和转存判断也按比例缩小,对英文内容过度压缩,而且这个系数没有依据。
+- **何时该重新考虑**:deepagents 升级后改了 `compute_summarization_defaults` 的比例,或不再读取 `profile`。
+
+### 决策 2:profile 里放可用输入,不放上下文长度
+
+- **选定**:`profile["max_input_tokens"]` 设为 spec 术语"可用输入"。
+- **原因**:框架用这个值乘以比例算阈值,把它当作"输入上限"使用。如果放上下文长度,配置了最大输出时,阈值会把输出空间也算进输入。
+- **不设置 profile 的情况**:拿不到上下文长度时完全不设置,框架回到 170K 默认值(spec AC-08)。不能设成 170000 之类的占位值,因为那样会让框架改用比例模式,保留量从"6 条消息"变成"占位值的 10%",行为就变了。
+- **备选(被否)**:放上下文长度本身。被否原因见上。
+- **何时该重新考虑**:deepagents 的 profile 增加单独的输出字段并据此扣除。
+
+### 决策 3:上下文长度的来源与读取方式
+
+- **来源优先级**:高级配置 `context_window` → Ollama 的 `max_tokens`(即 `num_ctx`)→ 服务商接口 → 未知。
+- **读取方式**:
+  - A. 每种服务商按自己的接口单独实现,地址和密钥取自 BishengLLM 已经算好的客户端参数(`BishengLLM._get_llm_params` 的结果)。
+  - B. 直接读 `server_info.config`,每种服务商重新拼一遍地址和密钥。
+- **选定**:A。
+- **原因**:客户端参数已经处理过各服务商的字段别名、去掉了结尾斜杠、合并了代理设置。方案 B 要把这些逻辑再写一遍,两份逻辑以后会不一致。
+- **各服务商的接口和字段**:见 plan.md §3.1 调研表。实现时以"读取器"为单位,每个读取器声明自己适用的服务商类型,返回 `(数值, 数值种类, 来源)`。数值种类分两种:总长度(输入加输出)、仅输入上限(anthropic 的 `max_input_tokens`)。
+- **何时该重新考虑**:服务商把上下文长度加进对话接口的响应,或 BiSheng 新增"上下文长度"表单字段(届时它成为第一来源)。
+
+### 决策 4:缓存放 Redis,保存配置时主动清除
+
+- **备选**:A. 进程内 TTL 缓存;B. Redis;C. 写进模型配置表。
+- **选定**:B。键为 `linsight:context_window:`。读取成功时缓存 1 小时,读取失败时缓存一个"失败"标记 5 分钟。清除时机:凡是调用 `invalidate_llm_info_cache` 表示"模型配置变了"的异步入口,都同时删除对应的键。目前是 `update_llm_server`(`llm/domain/services/llm.py:1066`)和 `verify_model_status`(`:1102`)。`invalidate_llm_info_cache` 本身是同步函数、只清进程内缓存,不在它内部发 Redis 请求。删除模型时不清除,键在 1 小时内自然过期,且模型已不存在、不会再被读取。
+- **原因**:A 违反 C8,API 进程清不掉 worker 进程的缓存;C 已在采访中否决(D2),部署变化后要人工重新保存才能更新。
+- **防止旧值写回**:保存配置只清了 API 进程的 `LLM_CACHE`,任务执行进程最多还会用旧配置 60 秒;这期间它会把按旧配置读到的结果重新写进 Redis,并保留 1 小时。所以缓存值带一个 `fingerprint`(服务商类型、base URL、模型名的哈希),读取时与当前配置不一致就当作未命中、重新读取。
+- **日志不带密钥**:`ModelSnapshot` 的 `client_params`、`model_config` 含 API key,两个字段设为不进 repr。loguru 的文件日志默认开启 `diagnose`,打印 traceback 时会输出局部变量。读取器里的请求头也含密钥,所以 `probe_context_window` 对预期之外的异常只记录异常类型,不打印 traceback。
+- **何时该重新考虑**:出现需要"立刻"反映部署变化的现场需求时,可在管理端加"重新读取"操作,而不是缩短 TTL。
+- **注意**:手动来源(高级配置、Ollama 字段)不进缓存,每次从模型配置读取。模型配置本身经过 `LLM_CACHE`(进程内 60 秒),所以保存配置后最多 60 秒生效。这一点满足 spec AC-06 的"下一次任务"要求,因为保存时同时清了 `LLM_CACHE`。
+
+### 决策 5:在 BishengLLM 里识别超窗错误
+
+- **备选**:
+  - A. 在 `BishengLLM` 的四个调用入口(`_generate`、`_agenerate`、`_stream`、`_astream`)里识别服务商的超窗错误,改抛 `ContextOverflowError` 的子类。
+  - B. 在 resilience 中间件里识别。
+- **选定**:A,并且识别发生在被装饰函数的内部。
+- **原因**:deepagents 的压缩中间件只认 `ContextOverflowError` 这个类型,而且它在 resilience 的外层(§2)。只有在模型调用这一层就换成正确的类型,外层才能接住。识别放在被装饰函数内部,是为了让 `wrapper_bisheng_model_limit_check_async` 看到的已经是新类型(见决策 6)。
+- **识别规则**:按报错文本匹配,规则集中在一张表里,每条规则配一个单元测试,测试里用真实报错原文。初始规则:
+
+  | 来源 | 报错原文特征 |
+  |---|---|
+  | vLLM(旧版)、DeepSeek | `maximum context length is` |
+  | vLLM(新版) | `is longer than the maximum model length` |
+  | OpenAI | `context_length_exceeded`(langchain-openai 已识别,这里兜底) |
+  | Anthropic | `prompt is too long` |
+  | SGLang | `exceeds the maximum allowed length` |
+  | llama.cpp | `exceeds the available context size` |
+
+- **保持原异常的类型与属性**:`BishengLLM` 是全平台共用的,日常模式、助手、工作流都经过它。改抛的异常必须仍然是原 SDK 异常的实例,并保留 `body`、`status_code`、`response`,否则按 SDK 类型捕获的代码和 `_provider_error_reason`(`llm/domain/utils.py:508`,从 `exc.body` 取服务商原文)都会受影响。做法:openai SDK 的错误改抛 langchain-openai 已有的 `OpenAIContextOverflowError(openai.BadRequestError, ContextOverflowError)`,anthropic 的改抛 langchain-anthropic 的 `AnthropicContextOverflowError`,用原异常的 `message`、`response`、`body` 构造;其他 SDK 的错误改抛 BiSheng 的 `LLMContextOverflowError(ContextOverflowError)`,并复制 `body`、`status_code`。三者都带 `reported_input_tokens` 属性,`__cause__` 指向原异常。下文统称"超窗异常",判断一律用 `isinstance(exc, ContextOverflowError)`。
+- **对任务模式以外的影响**(spec 只覆盖任务模式,以下是共用 `BishengLLM` 带来的附带变化):
+  1. 日常模式的错误分类(`workstation/domain/services/chat_service.py:2377` 调用 `label_error`)会得到 `context_overflow`,前端共用 `ChatErrorCard`。因此新文案必须对两种模式都成立,不能写"请拆分任务"这类只适用于任务模式的话(决策 8)。
+  2. 所有场景下超窗都不再把模型标为异常(决策 6)。
+  3. 日常模式、助手、工作流里没有使用 `SummarizationMiddleware`(已 grep 确认,只有 linsight 使用 deepagents),所以不会因为异常类型变化而触发新的压缩行为。
+- **输出预留本身超过上下文长度不算超窗**:vLLM 对"输入加 `max_tokens` 超过上下文长度"和"`max_tokens` 本身就超过上下文长度"用的是同一句 "maximum context length is N"。前者是带 `max_tokens` 的模型最常见的超窗(输入先越过 `N − max_tokens`),压缩历史能解决,按超窗处理;后者是模型配置错误,压缩解决不了,仍按普通 400 处理并把模型标为异常(决策 6)。判断方式:从报错中解析出输出预留(新版 `is too large: X`,旧版 `C in the completion`),只有它不小于上下文长度 N 时才排除。曾经按"messages 部分小于 N 就排除"实现,把前一种常见超窗也排除掉了,第二轮代码审查发现后改为现在的判据。
+- **输入 token 数的提取(AC-15)**:识别规则之外另有一张提取正则表(`_INPUT_TOKEN_PATTERNS`),例如 vLLM 的 `your prompt contains at least (\d+) input tokens` 或 `\(parameter=input_tokens, value=(\d+)\)`。提取结果存在超窗异常的 `reported_input_tokens` 属性,提取不到时为 `None`,日志记为 `unknown`。记录日志的位置:主图失败时由 `task_exec._handle_task_failure` 记录;子任务超窗时由决策 9 的中间件记录。
+- **流式路径**:任务模式用 `astream` 驱动,`BishengLLM.streaming` 默认开启,实际走 `_astream`。超窗在请求开始时就被拒绝,异常在第一次迭代时抛出,所以在 `_astream` 里包一层识别即可,不会出现"已经输出一半再超窗"的情况。
+- **何时该重新考虑**:langchain 各 partner 包自己识别了这些文本,这张表就可以缩减。
+
+### 决策 6:超窗不把模型状态标为异常
+
+- **现状**:`llm/domain/utils.py` 的四个装饰器(`wrapper_bisheng_model_limit_check`、`_async`、`wrapper_bisheng_model_generator`、`_async`)都在任何异常时把模型状态写成 ERROR,模型管理列表会把这个模型显示为异常。任务模式实际走的是 `wrapper_bisheng_model_generator_async`(流式)。
+- **选定**:四个装饰器都对 `ContextOverflowError` 跳过状态写入,遥测仍然记为失败。
+- **备选(被否)**:按 HTTP 状态码区分,400 一律不标异常。被否原因:鉴权、参数错误也是 400,这些确实是配置问题,应当标异常。
+- **原因**:超窗是这一次请求的内容太长,不是模型服务坏了。如果标为异常,管理员会去排查一个正常的模型。
+- **何时该重新考虑**:模型状态改为由独立的探测任务维护,不再由业务调用写入。
+
+### 决策 7:P1 接管框架压缩部件的方式(D7,T019 已验证)
+
+- **做法**:
+  1. 按模型的 provider(`deepagents._models.get_model_provider`,即 `_get_ls_params()["ls_provider"]`)注册一个 harness profile:`excluded_middleware={"SummarizationMiddleware"}`,`extra_middleware=_summarizer_guard`。每个 provider 只注册一次。
+  2. `create_linsight_agent` 把 `BishengSummarizationMiddleware` 放在主图 `middleware=` 列表和研究员子图 `middleware` 列表的**第一位**,每个图一个实例。
+  3. `create_deep_agent` 在 `bisheng_summarization(model)` 块内调用。块内设置一个 ContextVar;`_summarizer_guard` 不添加任何中间件,只检查这个 ContextVar,在块外组装时抛 `RuntimeError`(坑 18)。
+- **验证结果**:框架自带部件的 `name` 返回公开别名 `SummarizationMiddleware`,子类返回自己的类名,所以 BiSheng 子类不会被同一条排除规则移除(`deepagents/middleware/summarization.py:499-515`)。主图和研究员子图都只有 BiSheng 实例在工作,框架自带部件一次也没有被调用(`test_context_summarization.py`)。
+- **为什么放在列表第一位,而不是用 `extra_middleware` 工厂注入**:T019 的验证脚本最初用工厂注入,但 `extra_middleware` 追加在调用方中间件之后,压缩部件因此位于 resilience 的内层。代码审查发现这样会让 resilience 的每次重试(transient、截断 nudge、incomplete-stream)都重新执行一次压缩:多调一次摘要模型,并把同一段历史再追加到 `/conversation_history/`。放在调用方列表第一位,它就紧接在框架的 PatchToolCalls 之后、resilience 之外,和框架自带部件原来的位置一致:resilience 的重试不会重复压缩;resilience 按 FAIL_FAST 抛出的超窗异常由它接住、压缩后重试。
+- **ContextVar 的并发安全**:`create_deep_agent` 的组装是同步代码,期间不会切换到其他 asyncio 任务;ContextVar 的值也按任务隔离。
+- **拿不到上下文长度时**:BiSheng 子类的参数与框架默认完全一致(`compute_summarization_defaults`、`count_tokens_approximately`、`trim_tokens_to_summarize=None`、不启用用量基数),满足 AC-08。摘要失败的重试与报错(AC-17、AC-18)在两种情况下都生效。
+- **拿不到 provider 时**:不注册 profile、不放 BiSheng 子类,框架自带部件保留,P1 的计数、摘要上限、摘要失败处理都不生效。BishengLLM 总能报出内层客户端的 provider,这条路径只在测试用的假模型上出现。
+- **何时该重新考虑**:deepagents 升级后改变 harness profile 的查找方式(按 provider 回退)、`excluded_middleware` 的匹配规则或调用方中间件的位置,或进程内出现第二个使用 deepagents 的模块(坑 11、18)。
+
+### 决策 8:失败原因走 error_type,同时登记错误码
+
+- **现状**:任务失败时,前端按 `output_result.error_type` 选择文案(`client/src/components/ChatErrorCard.tsx`),`error_code` 一律是 11090,前端不读它。
+- **选定**:
+  - 新增两个 `ErrorType`:`context_overflow`、`context_compress_failed`,前端据此显示文案。`context_overflow` 的文案要对任务模式和日常模式都成立(决策 5),建议大意为"内容超出了模型一次能处理的长度。请缩短内容或拆分成几个小任务,也可以换用上下文更长的模型"。这比 spec AC-14 的示例措辞更宽,含义不变。
+  - 同时新增错误码 11075(`LinsightContextOverflowError`)、11076(`LinsightContextCompressFailedError`)。`classify_for_event` 遇到这两种类型时写入对应的 `error_code`,不再写 11090。
+- **备选(被否)**:只加 error_type,不加错误码。界面能显示,但 spec AC-14、AC-18 要求可观测的错误码,日志检索和开放 API 调用方也需要区分失败原因。
+- **何时该重新考虑**:前端改为按错误码选择文案时,error_type 可以退化为错误码的派生字段。
+- **顺带修复**:刷新或打开历史任务时,前端只恢复了 `error_message`,没有从 `output_result.error_type` 恢复 `taskErrorInfo`(`useLinsightManager.tsx:134`、`:203`),所以刷新后一律显示"未知错误"。不修这一处,AC-14 在刷新后就不成立,所以纳入本次。
+
+### 决策 9:子任务超窗的处理位置(AC-16)
+
+- **问题**:子任务的 resilience 中间件在压缩中间件内层。如果它像现在这样把超窗"降级"成一条普通回复,外层的压缩就永远看不到超窗,子任务也就不会先压缩再重试。
+- **选定**:
+  1. resilience 中间件对 `ContextOverflowError` 一律直接抛出,主图、子图都一样(`classify_behavior` 返回 FAIL_FAST)。这样压缩中间件能先处理一次。
+  2. 压缩后重试仍超窗时,异常从子图抛出,经 `task` 工具到达主图。主图新增一个 `wrap_tool_call` 中间件,只处理 `task` 工具的 `ContextOverflowError`:返回一条工具结果,说明"这一步超出了模型上下文长度,请缩小范围后再委派",并计入研究员 resilience 实例的 `_degrade_count`。超过 `max_degrade` 时改为抛出,任务以 `context_overflow` 失败。
+- **返回的工具结果**:`status` 用默认的成功状态,内容是说明文字。不用 `status="error"`,因为 `LinsightToolLoopBreaker` 会把连续的错误结果计入失败连击,与降级计数重复计算。
+- **异常能传到主图的依据**(已核对源码):deepagents 的 `task` 工具直接 `await subagent.ainvoke`,没有捕获(`deepagents/middleware/subagents.py:721`);langgraph ToolNode 默认的 `_default_handle_tool_errors` 对非 `ToolInvocationError` 重新抛出(`langgraph/prebuilt/tool_node.py:383`);ToolNode 把调用交给 `awrap_tool_call` 链(`tool_node.py:1200`)。主图上现有的工具中间件(resilience、loop breaker、binary guard、invalid-call repair)都不吞异常。
+- **备选(被否)**:在子图最外层加一个中间件处理。被否原因:BiSheng 中间件只能追加在框架中间件之后,也就是压缩中间件的内层,排不到子图最外层。
+- **原因**:只有在子图外面,才能确定子图自己的压缩已经尝试过。和 resilience 共用计数器,是为了满足 AC-16 的"沿用现有降级次数上限"。
+- **何时该重新考虑**:deepagents 的 `task` 工具开始自己捕获子图异常,或 ToolNode 的默认错误处理改为吞掉异常。见 §6.2。
+
+### 决策 10:token 计数以服务商用量为基数(P1,plan §3.3)
+
+- **选定**:`下一次请求的 token 数 = 最近一条带 usage 的模型回复的 total_tokens + 估算(这条回复之后的消息) `。估算使用识别中日韩文字的函数:中日韩字符按 1 个 token 计,其余字符按 4 个字符 1 个 token 计。
+- **为什么基数总是有效**:第 N 次调用检查时,最近一条回复来自第 N−1 次调用,那次调用的输入正是当时的有效消息列表;压缩只发生在某次调用内部,那次调用产生的回复的用量已经反映了压缩后的列表。所以不存在"压缩后用量对不上"的问题。plan.md 早先把"刚压缩完之后的第一次调用"列为例外,这个判断有误,已在 plan 中更正。
+- **仍需全量估算的情况**:一次运行的第一次调用;服务商不返回用量;最近的回复是降级生成的合成消息(没有 usage)。
+- **备选(被否)**:直接用 langchain 的用量校正(`use_usage_metadata_scaling`)。它的放大系数上限是 1.25(`langchain_core/messages/utils.py:2392`),中文内容需要约 2 倍;而且 deepagents 自己计数时没有开启它。
+- **备选(被否)**:每次调用前用 vLLM 的 `/tokenize` 精确计数。只有部分服务商提供,而且每次模型调用多一次网络请求。可以用来离线校准中日韩系数。
+- **整体计数与局部计数分开**:用量基数只描述"截至某条回复的整个上下文",不能用来数一段子列表。所以 `BishengSummarizationMiddleware` 只在 `_count_tokens`(判断要不要压缩的整体计数)里用 `count_context_tokens`;传给框架的 `token_counter`(框架用它算保留量的切点、裁剪摘要输入)是纯估算 `estimate_tokens`。
+- **何时该重新考虑**:系数在校准中偏差超过 20%;或接入的模型以非中英文为主。
+
+---
+
+## 4. 系统现状(接手必读)
+
+### 4.1 数据流(P0 实现后)
+
+1. `task_exec._create_agent`(`linsight/domain/task_exec.py:1108`)→ `agent_factory.create_linsight_agent`(`linsight/domain/services/agent_factory.py:1276`)。
+2. `_resolve_model`(`agent_factory.py:1529`)通过 `LLMService.get_bisheng_linsight_llm` 得到一个新的 `BishengLLM` 实例。每次调用都新建实例(`llm/domain/llm/base.py:33`),所以给它设置 profile 不影响其他任务。
+3. **新增**:`create_linsight_agent` 调用 `ContextWindowService.resolve(model)`(llm 模块,见 §4.3),得到 `(可用输入, 来源)`,在日志里记录(AC-09);可用输入不为空时设置 `model.profile`。这一步必须在 `create_deep_agent` 之前完成,因为框架在构造压缩中间件时就读取 profile(`summarization.py:1718`)。
+4. `create_deep_agent`(`agent_factory.py:1493`)构造主图和研究员子图;两者用同一个模型实例,压缩阈值相同。
+5. 运行中服务商返回超窗 → `BishengLLM` 改抛超窗异常(决策 5)→ resilience 中间件直接抛出 → deepagents 压缩中间件接住,压缩后重试一次(`summarization.py:1435`)。
+6. 重试仍然超窗:
+   - 主图:异常传到 `task_exec._execute_agent`(`:1544`),被包成 `TaskExecutionError`,经 `_handle_task_failure`(`:2559`)→ `classify_for_event`(`common/services/llm_error_classifier.py:422`)沿 `__cause__` 找到原始异常,标为 `context_overflow`、错误码 11075 → 写入 `output_result`,推送 `ERROR_MESSAGE` 事件 → 前端 `ChatErrorCard` 按 `error_type` 显示文案。
+   - 子图:见决策 9。
+
+### 4.2 关键数据结构 / 字段约定
+
+| 字段 / 结构 | 类型 / 格式 | 说明 | 谁会消费 |
+|---|---|---|---|
+| 模型高级配置 `user_kwargs.context_window` | 正整数 | 管理员手动指定的上下文长度。构造客户端前必须取出,不能发给服务商 | `ContextWindowService`、`BishengLLM._get_llm_params` |
+| Redis `linsight:context_window:` | `{"window": int, "kind": "total"\|"input", "source": str, "fingerprint": str}` 或 `{"failed": true, "fingerprint": str}` | 服务商接口读取结果;TTL 成功 3600 秒、失败 300 秒。`fingerprint` 是服务商类型、base URL、模型名的哈希,与当前配置不一致时按未命中处理(决策 4) | `ContextWindowService` |
+| `output_result.error_type` | `"context_overflow"` \| `"context_compress_failed"` | 新增两个失败类型 | client `ChatErrorCard`、历史恢复 |
+| `output_result.error_code` | 11075 \| 11076 | 新增错误码 | 日志、开放 API 调用方 |
+| 日志 `[linsight-context-window]` | `model_id=… window=… kind=… source=… usable_input=…` | 任务开始时记录(AC-09);超窗失败时再加 `reported_input_tokens`(AC-15) | 现场运维 |
+
+### 4.3 关键模块职责
+
+| 模块 / 文件 | 职责 | 不做什么 |
+|---|---|---|
+| `llm/domain/services/context_window.py`(新增) | 按优先级确定上下文长度;Redis 缓存与失效;算出可用输入 | 不了解 deepagents,不设置 profile;不发 HTTP 请求 |
+| `llm/domain/services/context_window_probes.py`(新增) | 各服务商读取器(`PROBES` 注册表);单次超时 3 秒;读不到返回 `None` | 不缓存 |
+| `llm/domain/llm/context_overflow.py`(新增) | 超窗识别规则表、输入 token 数提取正则、改抛超窗异常(`as_context_overflow`) | 不处理其他错误 |
+| `llm/domain/llm/exceptions.py`(新增) | `LLMContextOverflowError`:没有 langchain 对应类的 SDK 使用 | — |
+| `linsight/domain/services/context_budget.py`(新增,P1) | CJK 感知计数(`estimate_tokens`)与用量基数计数(`count_context_tokens`);`BishengSummarizationMiddleware`;`bisheng_summarization()` 上下文管理器(注册 harness profile、设置 ContextVar);`ToolResultBudgetMiddleware` 单条结果上限 | 不确定上下文长度(那是 llm 模块的事) |
+| `resilience_middleware.py` `SubagentContextOverflowMiddleware`(新增) | 决策 9:主图上只处理 `task` 工具的超窗异常,返回说明并计入研究员的降级次数 | 不处理其他工具 |
+| `llm/domain/llm/llm.py` `BishengLLM` | 识别超窗错误并改抛;构造客户端前从高级配置取出 `context_window` | 不做压缩 |
+| `llm/domain/utils.py` 四个调用装饰器 | 超窗时不把模型标为异常 | — |
+| `linsight/domain/services/agent_factory.py` | 调用 `ContextWindowService`,设置 profile,记日志;挂载决策 9 的 `task` 超窗中间件;把 `BishengSummarizationMiddleware` 放在主图和研究员子图中间件列表第一位,并在 `bisheng_summarization()` 块内组装(决策 7);已知上下文长度时挂 `ToolResultBudgetMiddleware` | 不读取服务商接口 |
+| `linsight/domain/services/resilience_middleware.py` | 超窗直接抛出;降级计数供决策 9 共用 | 不吞掉超窗 |
+| `common/services/llm_error_classifier.py` | 新增两个 `ErrorType`;`ContextOverflowError` 归 FAIL_FAST;压缩失败按类名识别(不从 common 导入 linsight) | — |
+| `common/errcode/linsight.py` | 新增 11075、11076 | — |
+| client `ChatErrorCard.tsx`、`useLinsightManager.tsx`、`locales/*/translation.json` | 显示两种新失败文案;刷新后恢复 `error_type` | 不改卡片样式 |
+
+---
+
+## 5. 已知坑 / 反直觉事实
+
+| # | 反直觉事实 | 如果不知道会怎样 | 在哪处理 |
+|---|---|---|---|
+| 1 | 高级配置里的键会原样传给客户端构造函数(`llm.py:51` `_get_user_kwargs`,各 `_get_*_params` 里 `user_kwargs.update(params)`) | `context_window` 被当成未知参数发给服务商,严格校验的服务商会返回 400 | 在 `_get_llm_params` 统一取出(AC-02) |
+| 2 | Ollama 的 `max_tokens` 会被改名为 `num_ctx` 发出(`llm.py:96`),含义是上下文长度 | 把它当作最大输出扣除,Ollama 的可用输入会变成 0 | `ContextWindowService` 对 Ollama 特殊处理 |
+| 3 | Ollama 超出 `num_ctx` 时默认**静默截断**提示词,不报错 | 不会触发超窗识别;只能靠 profile 提前压缩 | 依赖决策 1,无额外处理 |
+| 4 | 框架在**构造**压缩中间件时,根据 profile 是否存在决定用"比例模式"还是"固定 170K 模式";具体阈值则在每次调用时按当时的 `model.profile` 计算 | 构造之后才设置 profile,框架已经选了固定模式,不会生效;构造之后修改 profile 的数值则会生效 | 决策 1,在 `create_deep_agent` 之前设置 |
+| 5 | 拿不到上下文长度时给 profile 填任何占位值,都会把框架从"保留 6 条消息"切换到"保留 10%" | 改变了 AC-08 要求不变的行为 | 决策 2,不设置 profile |
+| 6 | resilience 在压缩中间件内层,现在把子图的所有 400 都降级成普通回复 | 子图超窗时压缩永远不会触发 | 决策 9 |
+| 7 | 任务模式的失败文案按 `error_type` 选择,不按 `error_code` | 只加错误码,界面仍显示"未知错误" | 决策 8 |
+| 8 | 刷新后前端只恢复 `error_message`,不恢复 `error_type` | 新文案刷新后消失 | 决策 8 顺带修复 |
+| 9 | vLLM 的 `/v1/models` 里 LoRA 适配器条目的 `max_model_len` 为 null | 读不到适配器的上下文长度 | 读取器沿 `parent` 字段找基础模型 |
+| 10 | 百炼的模型信息接口在 DashScope 原生地址,不在 `/compatible-mode/v1` 下,北京区域还需要 WorkspaceId | 按兼容地址拼接口会 404 | P1 读取器;推导不出时按未知处理 |
+| 11 | harness profile 是进程级全局注册的 | P1 的注册会影响同进程内所有 deepagents agent | 决策 7,目前只有 linsight 使用 deepagents |
+| 12 | 压缩时生成摘要的调用失败后,langchain 返回字符串 `Error generating summary: …` 并继续执行(`langchain/agents/middleware/summarization.py:846`) | 历史被换成一行错误文本,任务带着失忆继续跑 | P1 子类识别并重试(AC-17、AC-18) |
+| 13 | 研究员 resilience 实例的 `_degrade_count` 被同一任务的所有 `task` 调用共用,代码注释把这标为 KNOWN GAP(`resilience_middleware.py:393`) | AC-16 依赖的正是"同一任务累计"这个口径。有人按注释把计数改成按调用分桶,AC-16 会被悄悄破坏 | 改动这个计数器时同步改 AC-16 及其测试 |
+| 14 | P0 下 deepagents 按 4 个字符 1 个 token 估算,中文少算 2 倍以上 | 以为 P0 已经能在中文任务上提前压缩,实际常常是"超窗后压缩重试" | 决策 1 的 P0 局限;P1 决策 10 |
+| 15 | deepagents 只在"这次调用没有主动压缩"时才接住超窗并压缩重试;如果这次调用已经因为达到阈值而主动压缩,压缩后的请求再超窗,异常直接抛出,不会再压缩一次(`deepagents/middleware/summarization.py` 的 `awrap_model_call`,主动压缩分支里的 `handler` 调用不在 `try` 中) | 以为"超窗后一定会压缩重试";实际上主动压缩后仍超窗时任务直接按 AC-14 失败。这与 spec"同一次请求最多压缩一次"一致 | 测试 `test_context_overflow_flow.py` 的上下文长度取 20000,使历史介于保留量和触发阈值之间,才能覆盖超窗重试路径 |
+| 16 | 压缩后保留的 10% 能装下全部历史时,框架判定"没有可压缩的内容",超窗后只做一次原样重试 | 以为超窗后一定发生了压缩;日志里看不到摘要调用 | 无需处理;保留比例由 P1 的 AC-24 约束 |
+| 17 | `unwrap` 原本沿 `__cause__` 一直走到最底层的 SDK 异常;超窗异常是 `raise ... from 原异常`,会被越过 | 分类器拿到普通的 400,标成 unknown | `unwrap` 遇到超窗异常或压缩失败异常即停止(`llm_error_classifier.py`);日常模式的 `label_error(unwrap(exc))` 因此也能识别 |
+| 18 | harness profile 按 provider 全进程注册;注册之后,同一 provider 的模型如果不经 `bisheng_summarization()` 就组装 deep agent,`_summarizer_guard` 会抛 `RuntimeError` | 以后别的模块用同一 provider 的模型直接调 `create_deep_agent` 会失败 | 有意设计成大声失败,而不是悄悄组装一个没有压缩的 agent;新增 deepagents 使用方时要走 `bisheng_summarization()` |
+| 19 | `ToolResultBudgetMiddleware` 借用了 `FilesystemMiddleware` 的私有方法 `_intercept_large_tool_result` / `_process_large_message` 来写 `/large_tool_results/` | deepagents 升级改名或改签名时转存会报错 | `pyproject.toml` 锁 `<0.7`;`test_tool_result_budget.py` 在真实图上验证,升级时会先失败 |
+| 20 | P1 之后 CJK 计数更准,原来按 4 个字符估算时不会触发的主动压缩现在会触发;而主动压缩之后再超窗不会重试(坑 15) | 测试或现场观察到"没有超窗重试"不代表退化,而是提前压缩生效了 | `test_context_overflow_flow.py` 调小了工具结果,单独覆盖超窗重试路径 |
+| 21 | langchain 裁剪摘要输入时用 `start_on="human"`。任务模式的历史只有开头一条用户消息,从尾部截取的片段里找不到它,裁剪结果为空,摘要变成固定句子 `Previous conversation was too long to summarize.`,而且不算失败 | 每次压缩都把全部历史换成这句话,任务失忆后继续运行 | `BishengSummarizationMiddleware._trim_for_summary`:保留开头指令和最新步骤,最新一步本身过大时截断其文本;这句固定文本也算摘要失败 |
+| 22 | 已部署环境数据库配置的 `linsight` 段仍带着 P2 删除的键(`tool_buffer` 等)。`LinsightConf` 构造时忽略未知键,但 `get_linsight_conf` / `aget_linsight_conf` 用 `setattr` 逐个写入,pydantic 对未知字段的 `setattr` 会抛异常 | 所有读取任务模式配置的请求都失败,工作台首页 `/workstation/config` 报错打不开(2026-10-22 在 3002 上复现) | `config_service._apply_linsight_overrides`:只写入 `LinsightConf` 定义了的键 |
+
+---
+
+## 6. 对外契约与依赖
+
+### 6.1 我提供给别人的(Outgoing)
+
+| 契约 | 形式 | 谁在用 |
+|---|---|---|
+| 模型高级配置 `context_window` | 管理员可见的配置约定 | 管理员;`ContextWindowService` |
+| `ErrorType.CONTEXT_OVERFLOW` / `CONTEXT_COMPRESS_FAILED` | `output_result.error_type`、`ERROR_MESSAGE` 事件 | client 任务模式界面;开放 API 任务模式(F073)返回的失败信息 |
+| 错误码 11075 / 11076 | `common/errcode/linsight.py`;文案在 `packages/locales` 的 `api_errors` | 日志、开放 API 调用方。需要在 constitution C5 和 release-contract"已分配模块编码"中登记(灵思模块 110 已分配,这里只是新增号) |
+| `ContextWindowService.resolve` / `invalidate` | 内部 Python API | linsight agent_factory;llm 模块保存配置的入口 |
+| 超窗异常:`OpenAIContextOverflowError` / `AnthropicContextOverflowError`(保留 openai / anthropic 的 SDK 类型)/ `LLMContextOverflowError`(其他 SDK 及 openai 的非 HTTP 状态错误,只保留 `body`、`status_code`),均为 `ContextOverflowError` | 异常类型 | deepagents 压缩中间件、resilience、错误分类器;日常模式错误分类 |
+
+### 6.2 我依赖别人的(Incoming)
+
+| 依赖 | 形式 | 风险点 |
+|---|---|---|
+| deepagents 0.6.12 读取 `model.profile["max_input_tokens"]` 并按 0.85 / 0.10 计算 | 框架行为 | 升级后比例或读取方式改变;`pyproject.toml` 锁 `<0.7` |
+| deepagents 压缩中间件在超窗时压缩并重试一次 | 框架行为 | 同上 |
+| langchain 中间件顺序:列表靠前为外层 | 框架行为 | 决策 9 依赖它 |
+| 各服务商模型信息接口的字段(plan §3.1) | 外部 HTTP 契约 | 字段改名后读不到,回到"未知",行为与改动前一致 |
+| 各服务商超窗报错的文本 | 外部隐式契约 | 文本改变后识别失败,任务以"未知错误"失败,与改动前一致 |
+| 子图异常经 `task` 工具传到主图的 `wrap_tool_call`(决策 9 的依据) | 框架行为:deepagents `subagents.py:721` 不捕获;langgraph ToolNode 默认对非 `ToolInvocationError` 重新抛出 | 任一方改为捕获异常,决策 9 的中间件收不到超窗,AC-16 失效;升级 deepagents 或 langgraph 时由 T012 的测试守住 |
+
+---
+
+## 7. 测试与可观测
+
+- **单元测试**(无外部依赖):
+  - 来源优先级与可用输入计算(含 Ollama、仅输入上限、最大输出 ≥ 上下文长度)。
+  - 每个读取器用录制的响应体测试。
+  - 超窗识别:每条规则用真实报错原文测试。
+  - `context_window` 不出现在客户端参数中。
+  - 错误分类:两个新类型与 FAIL_FAST。
+  - resilience 对超窗直接抛出;决策 9 的中间件计数与超限抛出。
+- **集成测试**:用假模型构造"第一次调用超窗、压缩后成功"和"两次都超窗"的序列,驱动 `create_linsight_agent` 构造的真实图,断言压缩发生、任务结果和 `output_result`。
+- **集成测试必须包含中文场景**:假模型返回带 usage 的回复,工具返回大段中文,构造"估算未到阈值、实际超窗"的序列,断言走的是超窗后压缩重试(P0);P1 完成后同一用例断言改为提前压缩。
+- **手动验证(AC-12)**:
+  1. 准备一台有 GPU 的测试机(具体机器在 T015 执行时确定),启动 `vllm serve <模型> --max-model-len 32768`;确认 `curl <地址>/v1/models` 返回 `max_model_len: 32768`。
+  2. 在测试环境的模型管理里把它配成 vllm 类型,不填 `context_window`;在工作台设为任务模式模型。
+  3. 执行"读取多份中文长文档并写报告"的任务。
+  4. 在任务执行进程日志里检查:`grep "linsight-context-window"` 应有 `window=32768 source=deployed`;压缩日志出现;任务完成。区分两种压缩:提前压缩(没有超窗报错)和超窗后压缩(先有一次被识别的超窗)。P0 允许后者出现。
+  5. 用 `--max-model-len 133120` 复验客户场景。
+- **可观测**:任务开始时的 `[linsight-context-window]` 日志;超窗失败时附带服务商报告的输入 token 数。
+
+---
+
+## 8. 后续改进 / 不打算做的事
+
+- 模型配置页面的"上下文长度"字段(D8 否决):当高级配置的使用门槛成为现场问题时,再起需求。
+- MindIE、火山方舟、千帆、腾讯、讯飞的读取器:拿到真实环境或公开文档后再补。
+- 图片张数、技能清单长度的上限:另起需求。
+- `ls`、`glob`、`grep` 的输出不转存(转存只会引出又一次列举),超过单条上限时截断,并提示缩小范围。spec AC-19 已写明这一例外(用户 2026-10-21 确认,plan D12)。
+- 小窗口模型下压缩后仍可能超窗(2026-10-22 3002 实测,用户决定"有需要支持小模型时再说"):系统提示词与工具定义固定占约 16K token;模型在同一步并行发起 6–8 次搜索时,这一步的工具结果约 15K,压缩无法移除(下一次调用必须带上)。两者相加已超过 32K,32K 窗口下 23 次调用中有 6 次超过可用输入,最大 43154。窗口在约 48K 以下才会出现;客户的 133K 窗口不受影响。需要支持小模型时,候选做法是给同一步的全部工具结果设总上限(例如可用输入的 25%),按比例截断。
+- 百炼读取器的待核实点:本地 dashscope SDK 1.25 用 `GET {base}/api/v1/models/{name}` 查询单个模型,WorkspaceId 是可选请求头;官方文档(2026-10-21)给出的 list 接口则要求北京区域 host 带 WorkspaceId。目前按官方文档实现,默认地址按未知处理。如果现场用真实 key 验证单模型接口在默认地址可用,再补一个读取路径。
+
+---
+
+## 修订历史
+
+| 日期 | 改动 | 触发原因 |
+|---|---|---|
+| 2026-10-21 | 初版 | spec 确认 |
+| 2026-10-21 | §5 增坑 15、16、17 | P0 实现与集成测试中发现 |
+| 2026-10-21 | 决策 4 增缓存指纹与 repr 脱敏;决策 5 增输出侧超限排除;§4.2、§4.3、§6.1 与代码对齐 | `/code-review` 第一轮 |
+| 2026-10-21 | 决策 5 输出侧判据改为"输出预留 ≥ 上下文长度";读取器兜底异常只记类型 | `/code-review` 第二轮 |
+| 2026-10-21 | P1 实现:决策 7 验证结论、§4.3 增 context_budget.py、§5 增坑 18、19、20;P2 删除死配置 | P1 / P2 实现 |
+| 2026-10-21 | P1 代码审查:压缩部件改为放在中间件列表第一位(不再经 `extra_middleware` 注入);摘要输入裁剪改为保留开头指令和最新步骤;`ls`/`glob`/`grep` 输出按单条上限截断;§5 增坑 21 | `/code-review`(T032)|
+| 2026-10-22 | §5 增坑 22:数据库配置里的已删除键让任务模式配置读取报错 | 3002 部署验证 |
+| 2026-10-22 | §8 记录小窗口下压缩后仍超窗的限制,暂不处理 | 3002 部署验证 |
diff --git a/features/v3.0.0/087-context-window-budget/plan.md b/features/v3.0.0/087-context-window-budget/plan.md
new file mode 100644
index 0000000000..4818f9bb67
--- /dev/null
+++ b/features/v3.0.0/087-context-window-budget/plan.md
@@ -0,0 +1,270 @@
+# F087 任务模式上下文窗口预算 — 方案
+
+**状态**:方案已对齐(决策见 §7),下一步拆成 spec.md / design.md / tasks.md
+**所属版本**:v3.0.0(只提交到 `feat/3.0.0`)
+**分支**:`feat/3.0.0/087-context-window-budget`
+**触发问题**:某客户现场用 vLLM 部署本地模型(`max_model_len=133120`),任务模式执行技能时报 400:`This model's maximum context length is 133120 tokens. However, you requested 0 output tokens and your prompt contains at least 133121 input tokens`。
+
+---
+
+## 0. 结论
+
+任务模式里没有"上下文预算"这个概念。BiSheng 不知道模型的上下文窗口有多大,所有大小限制都是写死的数字。这些数字是按 200K 左右的窗口、按英文"4 个字符约等于 1 个 token"设计的。窗口一旦小于 170K,或者内容以中文为主,触发压缩、限制单次输入、处理超窗这三个环节会一起失效。
+
+本方案的做法是:每个模型确定一个上下文窗口,其余预算全部从这个窗口推导;同时补上"压缩失败"和"超窗之后"两处缺失的处理,让异常对用户和运维可见。
+
+### 术语
+
+本文统一使用以下术语,每个概念只用一个词。
+
+| 术语 | 定义 |
+|---|---|
+| 上下文窗口(W) | 模型单次请求能接受的 token 总数,输入加输出。vLLM 中即 `--max-model-len` |
+| 输出预留(R) | 为模型输出保留的 token 数 |
+| 输入预算(I) | `I = W − R`,单次请求输入部分的上限 |
+| 压缩 | deepagents 的 `SummarizationMiddleware` 把较早的历史消息替换成一段摘要 |
+| 转存 | 把过大的工具结果写进工作区文件 `/large_tool_results/`,上下文里只保留开头片段和文件路径 |
+| 超窗 | 请求的 token 数超过上下文窗口,服务商返回 400 |
+
+---
+
+## 1. 根因
+
+这次报错由两处缺陷叠加造成。
+
+**第一,压缩阈值大于上下文窗口。** deepagents 0.6.12 先读模型的 `profile["max_input_tokens"]`。读到时,压缩在输入达到 85% 时触发;读不到时,压缩阈值固定为 170000 tokens(`deepagents/middleware/summarization.py:261-298`)。BiSheng 没有给任何模型设置 `profile`,所以一律使用 170000。客户的窗口是 133120,小于 170000,所以压缩永远不会在超窗之前触发。
+
+**第二,超窗报错没有被识别。** deepagents 有一层兜底:模型抛出 `ContextOverflowError` 时,它当场压缩历史并重试一次(`summarization.py:1435`)。但 `langchain_openai` 只把三种文本识别为超窗:`context_length_exceeded`、`Input tokens exceed the configured limit`、`prompt is too long`(`langchain_openai/chat_models/base.py:520`)。vLLM 的文本是 `This model's maximum context length is ...`,不在其中。已用这段原文在本地验证:它没有被转换成 `ContextOverflowError`。之后 BiSheng 的 resilience 中间件把这个 400 归为 DEGRADABLE(`common/services/llm_error_classifier.py:324`),主图直接失败,错误标签是 UNKNOWN。
+
+---
+
+## 2. 现状盘点
+
+以下 7 类逻辑都和上下文窗口有关,覆盖从"窗口从哪来"到"多轮之间"的全部环节。代码位置以 `feat/3.0.0`(8510a9645)和 deepagents 0.6.12 为准。
+
+### A. 窗口来源:目前没有来源
+
+| 项 | 现状 |
+|---|---|
+| 模型配置 | 没有"上下文窗口"字段。全仓没有任何 `profile` 赋值 |
+| 服务商接口 | vLLM 等服务商的接口能返回窗口(见 §3.1),BiSheng 没有读取 |
+| `max_tokens` 一词两义 | Ollama 把它改名为 `num_ctx`,含义是上下文窗口(`llm/domain/llm/llm.py:96`);其他服务商把它当输出预留发出(`llm.py:367`)。前端 `ModelItem.tsx` 按服务商切换标签,管理员容易把窗口大小填进"最大输出" |
+
+### B. 压缩时机:阈值和计数都不准
+
+| 项 | 现状 |
+|---|---|
+| 压缩阈值 | 没有 profile 时为 170000 tokens |
+| 压缩后保留 | 没有 profile 时保留最近 6 条消息,和 token 数无关 |
+| token 计数 | `count_tokens_approximately`,4 个字符算 1 个 token。用服务商返回的用量校正时,放大系数上限 1.25(`langchain_core/messages/utils.py:2392`)。中文在 Qwen 类 tokenizer 下约 1 到 1.5 个字符算 1 个 token,所以中文内容会被少算一半左右(估算值,未用客户模型实测) |
+| 按用量触发 | 只看上一条模型回复的 `usage_metadata.total_tokens`,不包含之后新进入的工具结果 |
+
+### C. 单次输入:固定字符上限,部分没有上限
+
+| 来源 | 上限 | 位置 |
+|---|---|---|
+| 框架转存阈值、`read_file` 单次截断 | 80000 字符(`4 × 20000`) | `deepagents/middleware/filesystem.py:2040`、`:1061` |
+| `ls` / `glob` / `grep` 输出 | 80000 字符 | `deepagents/backends/utils.py:68` |
+| 代码执行输出 | 成功 30000 字符,失败 8000 字符 | `bisheng_langchain/gpts/tools/code_interpreter/base_executor.py:199` |
+| 自定义 API 工具 | 10000 字符 | `bisheng_langchain/gpts/tools/api_tools/base.py:68` |
+| 网页读取(F085) | 默认 20000 字符,可配 1000–200000 | `bisheng_langchain/gpts/tools/web_fetch/tool.py` |
+| 知识库检索 | 条数由模型传入,没有上界;单个分段不截断 | `tool/domain/langchain/linsight_knowledge.py` |
+| 联网搜索、MCP 工具 | 没有上限 | — |
+| 图片 | 单张不超过 500KB,张数不限 | `linsight/domain/services/workspace_backend.py` |
+| 引用来源表 | 每次模型调用最多约 9000 字符 | `linsight/domain/services/citation_source_middleware.py` |
+| 技能清单 | 技能个数不限 | deepagents `SkillsMiddleware` |
+| 一轮并行多个工具调用 | 上面的单条上限按调用个数叠加 | — |
+| 固定开销(system prompt 与工具 schema) | 约 2 万多字符,粗估 1.2–1.6 万 tokens | `linsight/domain/services/agent_factory.py` |
+
+### D. 压缩本身:摘要调用不截断,失败时静默
+
+`create_summarization_middleware` 在创建压缩中间件时传入 `trim_tokens_to_summarize=None`(`summarization.py:1659`)。结果是"生成摘要"这次模型调用会把待压缩的全部历史原样发给模型。在超窗兜底这条路径上,待压缩的历史接近整个窗口,摘要调用本身也会超窗。摘要调用失败时,异常被 `except Exception` 吞掉,摘要内容变成一行 `Error generating summary: ...`(`langchain/agents/middleware/summarization.py:846`)。任务继续执行,但之前的历史只剩一个文件指针,日志里也没有错误级别的记录。
+
+### E. 超窗之后:任务直接失败,原因不可见
+
+| 项 | 现状 |
+|---|---|
+| 报错识别 | vLLM 的超窗文本不被识别为 `ContextOverflowError`,框架兜底不执行 |
+| 主图 | resilience 中间件归为 DEGRADABLE,直接抛出,任务失败,标签 UNKNOWN |
+| 研究员子代理 | 返回降级消息,最多 3 次后抛出,期间父图上下文仍在增长 |
+
+### F. 多轮之间:历史没有上界
+
+| 路径 | 现状 |
+|---|---|
+| "继续"、"重试"、`ask_user` 回答后恢复 | 复用同一个 thread,从 checkpoint 恢复全部消息。历史大小完全依赖 B 类压缩 |
+| 新一轮任务带入上一轮摘要 | `build_prior_conversation_summary(max_chars=8000)`(`linsight/domain/utils.py:1108`);第一轮和最近一轮总是保留,所以只是软上限 |
+
+### G. 死配置
+
+`linsight` 配置里的 `tool_buffer`、`file_content_length`、`max_file_num`、`max_knowledge_num`、`retry_temperature`、`max_file_content_num` 都没有代码读取。现场修改它们不起作用。
+
+---
+
+## 3. 方案
+
+### 3.1 确定上下文窗口
+
+BiSheng 按以下优先级确定每个模型的上下文窗口:
+
+1. **手动配置。** 管理员在模型配置的"高级配置"JSON 里填写 `{"context_window": 133120}`。不新增前端字段(决策 D8)。高级配置的内容会原样传给模型客户端的构造参数(`llm/domain/llm/llm.py:51` `_get_user_kwargs`),所以 BiSheng 必须在构造客户端之前取出 `context_window`,否则它会被当成未知参数发给服务商。
+2. **Ollama 的 `num_ctx`。** Ollama 的"上下文长度"字段(存为 `max_tokens`)会作为 `num_ctx` 随每次请求发出(`llm.py:96`),它就是 Ollama 实际生效的窗口。所以 Ollama 填了这个字段时,直接用它作为上下文窗口。
+3. **服务商接口。** 前两步都没有时,调用该服务商的模型信息接口读取。
+4. **保持现状。** 以上都拿不到时,不设置 profile,deepagents 使用 170000 的默认阈值。
+
+第 4 步不改默认值,因此本方案对"拿不到窗口"的模型不改变现有行为。这类模型的安全网是 §3.5 的超窗识别:即使阈值偏大,超窗后也会先压缩再重试。
+
+#### 缓存(决策 D2)
+
+BiSheng 在任务开始时读取上下文窗口,结果存进 Redis,所有节点共享。读取成功的结果缓存 1 小时;读取失败的结果缓存 5 分钟,这 5 分钟内按"读不到"处理,不重复请求。管理员保存模型配置时,BiSheng 清除该模型的缓存。部署变化(例如重启 vLLM 时改了 `--max-model-len`)最多 1 小时后自动生效。
+
+#### 服务商接口调研(2026-10-21)
+
+BiSheng 的 19 种服务商类型中,有 8 种能用对话接口的同一个 API key 读取上下文窗口。读到的值分为两种。**部署值**是推理服务启动时实际生效的上限,例如 vLLM 的 `--max-model-len`。**理论值**是模型或平台公布的上限,可能大于实际部署的上限。
+
+| 服务商类型 | 能否读取 | 接口 | 字段 | 值的性质 | 证据 |
+|---|---|---|---|---|---|
+| vllm | 能 | `GET /v1/models` | `data[].max_model_len` | 部署值 | 源码,v0.5.0 起 |
+| llamacpp | 能 | `GET /props` | `default_generation_settings.n_ctx` | 部署值(单个 slot) | 源码 |
+| ollama | 部分 | `GET /api/ps`;`POST /api/show` | `models[].context_length`;`model_info[".context_length"]` | `/api/ps` 是部署值,但只在模型已加载时出现;`/api/show` 是理论值 | 源码 |
+| xinference | 能 | `GET /v1/models` | `data[].context_length` | 理论值,不反映启动参数 | 源码 |
+| anthropic | 能 | `GET /v1/models/{id}` | `max_input_tokens`;输出 `max_tokens` | 平台公布值 | 官方文档 |
+| deepseek | 能 | `GET /models` | `data[].context_window`(输入加输出);输出 `data[].max_output_tokens` | 平台公布值 | 官方文档 |
+| moonshot | 能 | `GET /v1/models` | `data[].context_length` | 平台公布值 | 官方文档 |
+| qwen(百炼) | 能,但要走原生接口 | `GET /api/v1/models?model=`(DashScope 原生,不是 `/compatible-mode/v1`) | `output.models[].model_info.context_window`、`max_input_tokens`、`max_output_tokens` | 平台公布值 | 官方文档 |
+| MindIE | 部分 | `GET /info`;`GET /v2/models/{name}/config` | `max_input_length`、`max_total_tokens`;`max_seq_len` | 部署值;官方已标为计划弃用,`/info` 可能只在管理端口提供 | 只有搜索摘要,需在真实环境验证 |
+| openai | 不能 | `GET /v1/models` 只有 id、created、owned_by | — | — | SDK 类型定义 |
+| azure_openai | 不能 | 数据面接口没有 token 上限;控制面需要 Entra ID 鉴权 | — | — | 官方文档 |
+| zhipu、minimax、silicon | 不能 | 没有模型列表接口,或列表里没有窗口字段 | — | — | 官方文档 |
+| volcengine、qianfan、tencent、spark | 未核实 | 火山方舟、千帆只有控制面接口,需要 AK/SK,不是对话 key | — | — | 文档无法读取或无公开资料 |
+| bisheng_rt | 跳过 | 无公开资料 | — | — | — |
+
+另外,以下几种推理服务常被配置成 openai 类型接入。它们也能读取上下文窗口:SGLang(`/v1/models` 的 `data[].max_model_len`,部署值)、LM Studio(`/api/v1/models` 的 `loaded_instances[].config.context_length`)、OpenRouter(`/api/v1/models` 的 `data[].context_length`)、LiteLLM proxy(`/model/info` 的 `model_info.max_input_tokens`)。
+
+#### 读取策略
+
+1. **按服务商类型分别实现读取逻辑**,每种只用对话接口已有的 base_url 和 API key,不新增凭据。P0 覆盖 7 种:vllm、openai 类型的 `/v1/models` 探测、llamacpp、ollama、xinference、anthropic、deepseek、moonshot(决策 D1)。qwen(百炼)放到 P1,MindIE 在真实环境验证后再定。
+2. **openai 类型配了自定义 base_url 时,探测 `GET /v1/models`。** 现场常把 vLLM、SGLang 配成 openai 类型。返回里有 `max_model_len` 就按部署值使用;有 `context_length` 就按平台公布值使用。
+3. **优先使用部署值。** Ollama 先读 `/api/ps`;模型未加载时再读 `/api/show` 的 `parameters` 里有没有 `num_ctx`;最后才用 `model_info` 里的理论值。
+4. **理论值可以使用,但要记录来源。** 理论值可能大于实际部署的上限,例如 Xinference 用 vLLM 引擎时手动调小了 `max_model_len`。这种情况由 §3.5 的超窗识别兜底:超窗后先压缩再重试。每次任务开始时,BiSheng 在日志里记录窗口大小和来源(手动配置 / Ollama num_ctx / 部署值 / 理论值),方便现场排查。
+5. **读不到时不猜。** 不能读取、未核实的服务商类型,或接口调用失败时,按 §3.1 第 4 步保持现状。
+6. **百炼需要推导原生接口地址。** 模型配置里通常只有 `/compatible-mode/v1` 的 base_url。原生接口的 host 要从它推导,北京区域还需要 WorkspaceId。推导不出来时按读不到处理。
+7. **MindIE 先在真实环境验证再实现。** 现有证据只有搜索摘要,接口已被标为计划弃用,P0 不包含它。
+
+### 3.2 预算推导
+
+确定上下文窗口后,所有预算从它推导:
+
+| 预算 | 推导方式 | W=133120 时 |
+|---|---|---|
+| 输出预留 R | 模型配置里填了"最大输出 token 数"(`max_tokens`)就取这个值;没填就是 0,不单独预留(决策 D3)。Ollama 的 `max_tokens` 含义是 `num_ctx`,所以 Ollama 的 R 恒为 0 | 0 |
+| 输入预算 I | `W − R` | 133120 |
+| 压缩阈值 | `0.85 × I`(deepagents 读到 profile 后的默认比例) | 约 113K |
+| 压缩后保留 | `0.10 × I` | 约 13.3K |
+| 单条工具结果上限 | `0.10 × I`,等于"压缩后保留",保证刚取回的结果不会在下一次压缩中被整条并进摘要(决策 D6) | 约 13.3K |
+| 摘要调用的输入上限 | `0.5 × I` | 约 66.5K |
+
+传给 deepagents 的 `profile["max_input_tokens"]` 设为输入预算 I。deepagents 用这个值乘以比例计算阈值,所以配置了 `max_tokens` 时要先扣掉。R 为 0 时,输出空间来自压缩阈值之上空着的约 15%:133K 窗口约 2 万 token,32K 窗口约 4900 token。
+
+拿不到窗口的模型不推导预算,单条工具结果上限仍是框架的 80000 字符。
+
+### 3.3 token 计数:以服务商用量为基数,只估算增量
+
+服务商每次返回的用量是"刚发出去的那次请求"的实际 token 数。压缩要回答的问题是"下一次请求会不会超窗"。下一次请求比上一次多出来的内容,主要是刚返回的工具结果,这部分还没有被任何服务商计数过。所以完全依赖用量做不到提前判断,但可以把用量当作基数:
+
+```
+下一次请求的 token 数 ≈ 上一条模型回复的 usage.total_tokens + 估算(此后新增的消息)
+```
+
+`total_tokens` 等于上一次的输入加输出,正好覆盖了到上一条模型回复为止的全部消息。只有之后新增的消息需要估算,估算误差因此只作用在增量上。
+
+以下情况拿不到基数,只能全量估算:
+
+- 一次运行的第一次模型调用。
+- 服务商流式响应不返回用量。
+- 从没有用量记录的旧 checkpoint 恢复。
+
+(初稿曾把"刚压缩完之后的第一次调用"列为例外,这个判断有误:压缩只发生在某次调用内部,那次调用产生的回复的用量已经对应压缩后的列表。论证见 design.md 决策 10。)
+
+另外,判断"单条工具结果是否超出上限"发生在结果进入上下文之前,这时只能估算。
+
+估算统一使用识别中日韩文字的计数函数:中日韩文字按 1 个字符算 1 个 token,其余字符按 4 个字符算 1 个 token。这个系数偏保守,可以用 vLLM 的 `/tokenize` 接口在真实样本上校准。
+
+### 3.4 单条输入统一按 token 限制
+
+已知上下文长度时,超过 §3.2"单条工具结果上限"的工具结果统一转存到 `/large_tool_results/`,按 §3.3 的计数函数计算 token 数。各工具自带的字符截断保持不变,它们之上由这条统一上限兜住。知识库检索的全部结果是一条工具结果,同样受这条上限约束,不单独限制条数。图片张数和技能清单长度不在本次范围内(见 spec.md 范围边界)。
+
+### 3.5 压缩失败与超窗必须可见
+
+1. **识别超窗。** `BishengLLM` 识别 vLLM、OpenAI 及其他常见服务商的超窗报错,统一转换为 `ContextOverflowError`,让 deepagents 执行压缩后重试。(P0)
+2. **超窗收尾(决策 D4)。** resilience 中间件把超窗单独分为一类:不重试,交给压缩处理。压缩后重试仍然超窗时,任务以新增的错误码失败。前端显示三种语言的文案,大意是"任务内容超出了模型一次能处理的长度,请拆分任务或换用上下文更长的模型"。日志记录窗口大小、窗口来源和实际 token 数。已生成的文件保留。(P0)
+3. **摘要失败(决策 D5)。** 摘要调用失败时,BiSheng 重试一次,重试时交给模型的内容不超过"摘要调用的输入上限"。仍然失败时,记错误级别日志,任务以明确的错误码失败。`Error generating summary` 不能被当成摘要继续使用。这一项要接管框架的压缩中间件,依赖 §3.6,所以放在 P1。
+
+### 3.6 框架约束与技术路线(决策 D7)
+
+`create_deep_agent` 只暴露了 `profile` 这一个入口。计数函数、摘要截断、摘要失败处理、转存阈值都是框架内部创建中间件时写死的,`FilesystemMiddleware` 还不允许被排除。本方案采用框架允许的替换方式,不修改框架源码。P1 第一步先做技术验证,确认可行后再实现:
+
+1. **替换压缩中间件。** 用 harness profile 的 `excluded_middleware` 按名字排除框架自带的 `SummarizationMiddleware`,再通过 `middleware=` 传入 BiSheng 的子类实例。子类必须改 `name`,否则会被同一条排除规则一起去掉。这样计数函数、摘要截断、保留比例都由 BiSheng 控制。
+2. **在内层先转存。** BiSheng 新增一个 `wrap_tool_call` 中间件。它位于框架 `FilesystemMiddleware` 的内层,先拿到工具结果,按"单条工具结果上限"转存。框架 80000 字符的阈值因此不再起作用。主图和研究员子代理都要挂。
+
+升级 deepagents 时,需要复查这两处依赖的接口:`excluded_middleware` 的匹配规则,以及中间件的嵌套顺序。
+
+---
+
+## 4. 分批交付
+
+| 批次 | 内容 | 解决的问题 |
+|---|---|---|
+| P0 | §3.1 确定上下文窗口(高级配置、Ollama num_ctx、7 种服务商接口、Redis 缓存)并设置 profile;§3.2 输出预留和输入预算;§3.5 第 1、2 项 | 这次的直接报错;超窗后原因不可见 |
+| P1 | §3.6 技术验证;§3.3 计数;§3.4 统一的单条上限;§3.5 第 3 项摘要失败;qwen(百炼)接口读取 | 中文内容少算;压缩后刚取回的内容丢失;摘要调用超窗或失败被掩盖 |
+| P2 | 删除 G 类死配置 | 现场被无效配置误导 |
+
+## 5. 验收标准
+
+- **AC-01**:高级配置里填写了 `context_window` 时,任务模式的输入预算等于 `context_window` 减已配置的 `max_tokens`;未配置 `max_tokens` 时等于 `context_window`。
+- **AC-02**:高级配置里的 `context_window` 不出现在发给服务商的请求参数中。
+- **AC-03**:Ollama 模型填写了"上下文长度"时,任务模式使用这个值作为上下文窗口。
+- **AC-04**:没有手动配置时,P0 覆盖的 7 种服务商能从接口读到窗口;同一模型在缓存有效期内只请求一次接口,所有节点读到同一个值。
+- **AC-05**:管理员保存模型配置后,该模型下一次任务重新读取窗口。
+- **AC-06**:接口读取失败时,5 分钟内不再重复请求,任务按"读不到"处理。
+- **AC-07**:手动配置和服务商接口都拿不到窗口时,任务模式的压缩行为与改动前一致。
+- **AC-08**:在 `max_model_len=133120` 的 vLLM 上执行原报错任务,压缩在超窗之前触发,任务不再因超窗失败。
+- **AC-09**:上下文窗口配置得比实际值大时,vLLM 返回超窗 400 后,任务先压缩再重试,而不是直接失败。
+- **AC-10**:压缩后重试仍然超窗时,任务以新增错误码失败,前端显示对应文案(zh-Hans / en / ja),日志记录窗口大小、窗口来源和实际 token 数。
+- **AC-11**:摘要调用失败时重试一次;仍然失败时日志有错误级别记录,任务以明确的错误码失败。(P1)
+- **AC-12**:已知窗口时,单条工具结果超过输入预算的 10% 就被转存,上下文中只保留开头片段和文件路径。(P1)
+- **AC-13**:以中文为主的任务,压缩触发时的实际 token 数(以服务商返回的用量为准)不超过输入预算。(P1)
+
+## 6. 本次不做
+
+| 项 | 原因 |
+|---|---|
+| 修改 170K 默认值 | 拿不到窗口的模型保持现有行为 |
+| 前端新增"上下文长度"字段 | 决策 D8:手动配置只用高级配置。Ollama 的"上下文长度"与其他服务商的"最大输出"共用 `max_tokens` 字段的问题保持现状 |
+| 默认输出预留 | 决策 D3:未配置 `max_tokens` 时不预留 |
+| MindIE 接口读取 | 只有搜索摘要作为证据,接口已被标为计划弃用,需先在真实环境验证 |
+| 日常模式的 `maxTokens` | 只在日常模式使用,任务模式不读取 |
+| 开放 API 的 4000 字符说明上限 | 属于固定开销,已计入输入预算 |
+| 任务标题生成 | 只放入用户问题,输出截到 50 字符 |
+
+## 7. 决策记录(2026-10-21 采访对齐)
+
+| 编号 | 问题 | 决策 |
+|---|---|---|
+| D1 | P0 支持自动读取的服务商 | vllm、openai 类型 `/v1/models` 探测、llamacpp、ollama、xinference、anthropic、deepseek、moonshot;qwen(百炼)放 P1 |
+| D2 | 读取结果的存储 | 运行时读取,存 Redis;成功缓存 1 小时,失败缓存 5 分钟;保存模型配置时清除 |
+| D3 | 默认输出预留 | 不单独预留;只在配置了 `max_tokens` 时扣除 |
+| D4 | 压缩后仍超窗 | 新增错误码,任务失败,前端三种语言文案,日志记录诊断信息,已生成文件保留 |
+| D5 | 摘要调用失败 | 重试一次(输入不超过 `0.5 × I`),仍失败则任务以明确错误码失败(P1) |
+| D6 | 单条工具结果上限 | 输入预算的 10% |
+| D7 | 接管框架内部逻辑的方式 | 用框架允许的方式替换(排除并替换压缩中间件、内层转存),不改框架源码,P1 先做技术验证 |
+| D8 | 手动配置入口 | 只用高级配置 JSON 的 `context_window` 键,不新增前端字段 |
+| D9 | 子任务压缩后仍超窗 | 降级:向主任务说明"超出上下文长度、应缩小范围",主任务继续;被结束次数超过现有的 `max_degrade`(默认 3)后整个任务失败(spec AC-16) |
+| D11 | P0 下中文计数偏少 | AC-11 改为"按系统的 token 计数"判断;中文准确性由 P1 的 AC-21 保证,P0 下中文任务允许先超窗再压缩重试(2026-10-21 design 审查后确认) |
+| D12 | 读文件与 ls/glob/grep 超过单条上限 | 截断并提示,不转存;spec AC-19 补写例外(2026-10-21 P1 代码审查后确认) |
+| D10 | 摘要失败的错误码 | 新增"上下文压缩失败"错误码,与超窗失败分开(spec AC-18) |
+
+## 8. 后续步骤
+
+1. 在 `features/v3.0.0/release-contract.md` 登记 F087。
+2. 按 SDD 把本方案拆成 spec.md、design.md、tasks.md,经 `/sdd-review` 后开始实现 P0。
diff --git a/features/v3.0.0/087-context-window-budget/spec.md b/features/v3.0.0/087-context-window-budget/spec.md
new file mode 100644
index 0000000000..9402413f2f
--- /dev/null
+++ b/features/v3.0.0/087-context-window-budget/spec.md
@@ -0,0 +1,138 @@
+# Feature: 任务模式按模型上下文长度控制上下文
+
+> **本文档定位 — 纯 What(需求口径,不随代码漂移)**
+>
+> spec 只回答做什么、验收标准、不做什么。实现方案见 [design.md](./design.md),执行流水见 [tasks.md](./tasks.md)。
+> 问题背景、现状盘点和采访决策记录见 [plan.md](./plan.md)。
+
+**关联 PRD**: 无独立 PRD;需求来自 2026-10-21 某客户现场问题,Spec Discovery 结论见 [plan.md §7](./plan.md)
+**优先级**: P0(第一批)/ P1(第二批)/ P2(第三批),各条 AC 标注所属批次
+**所属版本**: v3.0.0
+**依赖**: 任务模式 agent 框架(deepagents 0.6.x);模型管理的服务商与模型配置
+
+### 术语
+
+| 术语 | 定义 |
+|---|---|
+| 上下文长度 | 模型单次请求能接受的 token 总数,输入加输出。vLLM 中即 `--max-model-len` |
+| 可用输入 | 单次请求输入部分的上限。按以下规则确定:(1) 来源只给出输入上限时(例如 Anthropic 接口返回的 `max_input_tokens`),可用输入等于该值;(2) Ollama 模型的可用输入等于上下文长度,因为它的"上下文长度"字段与其他服务商的"最大输出"是同一个字段;(3) 其他情况下,可用输入等于上下文长度减去模型配置里的"最大输出 token 数",没有配置最大输出时等于上下文长度 |
+| 压缩 | 任务模式把较早的执行步骤替换成一段摘要,腾出上下文空间 |
+| 转存 | 把过大的工具结果写进任务工作区的文件,上下文里只保留开头片段和文件路径 |
+| 超窗 | 请求的 token 数超过上下文长度,服务商拒绝请求 |
+
+> **范围边界**
+> - **本次纳入**:
+>   - 任务模式确定所用模型的上下文长度。来源依次为:高级配置中的手动值、Ollama 的上下文长度字段、服务商接口。
+>   - 已知上下文长度时,任务模式按可用输入决定压缩时机和单条工具结果的转存阈值。
+>   - 服务商返回超窗错误时,任务模式先压缩再重试;仍然超窗时,任务以明确的错误失败。
+>   - 压缩时生成摘要失败的处理。
+>   - token 计数在中文内容上不再明显少算。
+>   - 删除任务模式配置中没有任何代码读取的配置项。
+> - **本次明确排除**:
+>   - 修改拿不到上下文长度时的默认行为(170K 压缩阈值、80000 字符转存阈值)。拿不到上下文长度的模型保持改动前的行为。
+>   - 在模型配置页面新增"上下文长度"字段。手动配置只走高级配置。因此 Ollama 的"上下文长度"与其他服务商的"最大输出"共用一个字段的现状保持不变。
+>   - 未配置最大输出时为输出单独预留空间。
+>   - 华为昇腾 MindIE 的接口读取。现有证据不足,接口已被官方标为计划弃用。
+>   - 日常模式、助手、工作流的上下文控制。本次只覆盖任务模式。
+>   - 修改 deepagents 框架源码。
+>   - 分别调整各工具自带的字符截断(代码执行 30000 字符、API 工具 10000 字符、网页读取的可配上限等)。这些截断保持不变;已知上下文长度时,超过可用输入 10% 的结果由 AC-19 统一转存。知识库检索的返回条数不单独限制,原因相同:一次检索的全部结果是一条工具结果,受 AC-19 约束。
+>   - 限制图片张数和技能清单长度。图片不按文本计数,技能清单属于固定开销;两者另起需求评估。
+
+---
+
+## 1. 用户故事
+
+作为 **使用本地部署模型的终端用户**,
+我希望 **执行步骤较多的任务时不因"上下文超长"而失败**,
+以便 **在上下文长度较小的本地模型上也能完成调研、写报告等长任务**。
+
+作为 **平台管理员**,
+我希望 **任务模式自动获知模型的上下文长度,必要时我也能手动指定**,
+以便 **不需要理解框架内部阈值,也能让任务模式适配不同部署**。
+
+作为 **现场运维人员**,
+我希望 **任务因上下文超长失败时,界面和日志能明确说出原因、上下文长度及其来源**,
+以便 **判断应该调大部署的上下文长度、改配置,还是让用户拆分任务**。
+
+---
+
+## 2. 验收标准
+
+### 2.1 确定上下文长度(P0,百炼为 P1)
+
+- **AC-01** — WHERE 模型的高级配置包含正整数 `context_window`, THE SYSTEM SHALL 在任务模式中以该值作为这个模型的上下文长度。
+- **AC-02** — THE SYSTEM SHALL 不把高级配置中的 `context_window` 作为请求参数发给服务商。
+- **AC-03** — WHERE 模型的服务商类型是 Ollama、高级配置不含 `context_window`、并且模型配置的"上下文长度"字段有值, THE SYSTEM SHALL 以该字段的值作为上下文长度。
+- **AC-04** — WHEN 任务开始且以上手动来源都没有值, THE SYSTEM SHALL 通过服务商接口读取上下文长度。支持的服务商类型为:vllm、llamacpp、ollama、xinference、anthropic、deepseek、moonshot,以及配置了自定义地址的 openai 类型(后端为 vLLM、SGLang 等时可读到)。
+- **AC-05** — WHEN 同一模型在上一次成功读取后 1 小时内再次开始任务, THE SYSTEM SHALL 不再请求服务商接口,并且所有节点使用同一个值。
+- **AC-06** — WHEN 管理员保存了某个模型的配置, THE SYSTEM SHALL 在该模型的下一次任务中重新确定上下文长度。
+- **AC-07** — IF 服务商接口读取失败, THEN THE SYSTEM SHALL 在之后 5 分钟内不再为该模型请求接口,并按"拿不到上下文长度"处理。
+- **AC-08** — IF 所有来源都拿不到上下文长度, THEN THE SYSTEM SHALL 保持改动前的压缩时机和单条工具结果转存阈值。
+- **AC-09** — WHEN 任务开始, THE SYSTEM SHALL 在日志中记录本次使用的上下文长度及其来源(手动配置 / Ollama 上下文长度字段 / 服务商接口返回的部署值 / 服务商接口返回的模型公布值 / 未知)。
+- **AC-10** — (P1)WHEN 任务开始、服务商类型是 qwen(百炼)且没有手动来源, THE SYSTEM SHALL 通过百炼接口读取上下文长度;IF 无法确定该接口的地址, THEN THE SYSTEM SHALL 按"拿不到上下文长度"处理。
+
+### 2.2 压缩时机(P0)
+
+- **AC-11** — WHILE 上下文长度已知, THE SYSTEM SHALL 在按系统的 token 计数、请求的输入达到可用输入的 85% 时先压缩,再发出请求。主任务和子任务都满足此条。计数在中文内容上的准确性由 AC-21(P1)保证;P0 下以中文为主的任务允许先超窗、再按 AC-13 压缩重试,但不允许因此失败。
+- **AC-12** — WHEN 在 `max_model_len=32768` 的 vLLM 模型上执行一个需要读取多份中文长文档(合计远超 32768 token)并写出报告的任务, THE SYSTEM SHALL 在超窗之前触发压缩,任务不因超窗失败。现场复验:在 `max_model_len=133120` 的环境重新执行引发本需求的任务(原话"使用 skill 把这家企业调债"),同样满足此条。
+- **AC-24** — WHILE 上下文长度已知, WHEN 压缩发生, THE SYSTEM SHALL 原样保留最近的执行步骤,保留部分的总量不超过可用输入的 10%,其余较早的步骤替换为摘要。(P1 起,配合 AC-19:最近一次工具调用的结果总在保留部分中。)
+
+### 2.3 超窗处理(P0)
+
+- **AC-13** — IF 服务商返回超窗错误, THEN THE SYSTEM SHALL 先压缩,再重试这次请求,不直接让任务失败。至少能识别 vLLM、OpenAI 和 Anthropic 的超窗错误。
+- **AC-14** — IF 压缩后重试仍然超窗, THEN THE SYSTEM SHALL 让任务以新增的"上下文超出模型长度"错误码失败,前端显示对应文案(简体中文 / 英文 / 日文,大意为"任务内容超出了模型一次能处理的长度,请拆分任务或换用上下文更长的模型"),并保留任务已生成的文件。
+- **AC-15** — WHEN 任务因超窗失败, THE SYSTEM SHALL 在日志中记录上下文长度、其来源和服务商报告的输入 token 数。
+- **AC-16** — IF 子任务在压缩后重试仍然超窗, THEN THE SYSTEM SHALL 结束该子任务,向主任务返回一段说明,写明这一步因超出模型上下文长度而未完成、应缩小范围后再做,并在日志中记录 AC-15 所列信息;主任务继续执行。IF 同一任务中子任务因超窗或其他原因被结束的次数超过任务模式现有的降级次数上限(配置项 `max_degrade`,默认 3), THEN THE SYSTEM SHALL 让整个任务失败;最后一次结束的原因是超窗时,按 AC-14 的错误码失败。
+
+### 2.4 摘要失败(P1)
+
+- **AC-17** — IF 压缩时生成摘要失败, THEN THE SYSTEM SHALL 重试一次,重试时交给模型的内容不超过可用输入的一半。
+- **AC-18** — IF 重试后生成摘要仍然失败, THEN THE SYSTEM SHALL 在日志中记录错误级别的信息,并让任务以新增的"上下文压缩失败"错误码失败,前端显示对应文案(简体中文 / 英文 / 日文,大意为"任务执行中整理历史步骤失败,请重试;多次失败请联系管理员检查模型服务"),保留任务已生成的文件;THE SYSTEM SHALL 不把失败信息当作摘要继续执行。
+
+### 2.5 单条工具结果(P1)
+
+- **AC-19** — WHILE 上下文长度已知, WHEN 一条工具结果超过可用输入的 10%, THE SYSTEM SHALL 转存该结果。上下文中只保留开头片段和文件路径,模型可以通过读取文件取回全文。主任务和子任务都满足此条。例外:读取文件(`read_file`)以及列目录、查找、搜索(`ls`、`glob`、`grep`)的结果不转存,超过同一上限时截断,并提示分页读取或缩小范围;读取文件见 AC-20。
+- **AC-20** — WHILE 上下文长度已知, THE SYSTEM SHALL 让模型每次读取文件得到的内容不超过可用输入的 10%。
+
+### 2.6 计数准确性(P1)
+
+- **AC-21** — WHILE 上下文长度已知, THE SYSTEM SHALL 让以中文为主的任务在压缩触发前的每一次请求的实际输入 token 数(以服务商返回的用量为准)都不超过可用输入。
+
+### 2.7 配置清理(P2)
+
+- **AC-22** — THE SYSTEM SHALL 从任务模式配置中删除没有任何代码读取的配置项(`tool_buffer`、`file_content_length`、`max_file_num`、`max_knowledge_num`、`retry_temperature`、`max_file_content_num`)。
+- **AC-23** — WHEN 已部署环境的系统配置(数据库中保存的配置,以及 `config.yaml`)中仍然包含 AC-22 所列的配置项, THE SYSTEM SHALL 正常启动,任务模式正常执行。
+
+---
+
+## 3. 边界情况
+
+- 当高级配置中的 `context_window` 不是正整数(例如字符串、0、负数)时,系统应忽略它,在日志中记录警告,并按下一个来源继续确定上下文长度。
+- 当高级配置不是合法的 JSON 时,系统的行为与改动前一致。
+- 当服务商接口返回的值大于实际部署的上下文长度时(例如 Xinference 返回模型公布值,而部署时调小了长度),系统应按 AC-13 在超窗后压缩重试。
+- 当最大输出 token 数大于或等于上下文长度时(Ollama 除外,见术语"可用输入"),系统应视为配置错误:日志记录警告,并按"拿不到上下文长度"处理。
+- 当服务商接口返回多个模型条目时,系统应使用与模型配置中模型名称对应的条目;该条目本身不带上下文长度、但能确定其基础模型时(例如 vLLM 上加载的 LoRA 适配器),系统应使用基础模型的值。
+- 当用户在任务中途"继续"、"重试"或回答提问后恢复执行时,系统应按本次恢复时确定的上下文长度执行 AC-11。
+- 当可用输入很小、接近任务的固定开销(系统提示词与工具说明,约 1.2–1.6 万 token)时,系统对同一次请求最多压缩一次,不反复压缩;压缩后仍然超窗时按 AC-14 处理。
+
+---
+
+## 4. 设计与实现(指针,不复制)
+
+| 你想知道 | 去哪看 |
+|---|---|
+| 为什么这么实现(决策 + 备选 + 何时该推翻) | design.md §3 方案对比 |
+| 关键约束(双 DB / 多租户 / 权限 / 运行时依赖 / 错误码段) | design.md §2 |
+| 今天的数据流、模块职责、字段 / API / 文件名约定 | design.md §4 系统现状 |
+| 代码里看不出的坑、反直觉事实 | design.md §5 |
+| 对外契约、改了会破坏谁、依赖谁 | design.md §6 |
+| 任务拆解、文件清单、执行顺序、踩坑落档 | tasks.md |
+
+---
+
+## 相关文档
+
+- 方案与决策记录: [plan.md](./plan.md)
+- 设计真相: [design.md](./design.md)
+- 执行与落档: [tasks.md](./tasks.md)
+- 版本契约: [features/v3.0.0/release-contract.md](../release-contract.md)
diff --git a/features/v3.0.0/087-context-window-budget/tasks.md b/features/v3.0.0/087-context-window-budget/tasks.md
new file mode 100644
index 0000000000..54b78f9a50
--- /dev/null
+++ b/features/v3.0.0/087-context-window-budget/tasks.md
@@ -0,0 +1,334 @@
+# Tasks: 任务模式按模型上下文长度控制上下文
+
+**关联规格**: [spec.md](./spec.md) · [design.md](./design.md) · [plan.md](./plan.md)
+**版本**: v3.0.0
+**分支**: `feat/3.0.0/087-context-window-budget`(只合入 `feat/3.0.0`)
+
+---
+
+## 状态
+
+| 步骤 | 状态 | 备注 |
+|------|------|------|
+| spec.md | ✅ 已评审 | 2026-10-21 用户确认 |
+| design.md | ✅ 已评审 | 2026-10-21 用户确认 |
+| tasks.md | ✅ 已拆解 | 两轮 `/sdd-review tasks`,第二轮剩余 7 条已修复 |
+| 实现 | 🔄 进行中 | 32 / 35 完成(P0 合入 feat/3.0.0 6c6a62311;P1、P2 合入 4cdf4ada0;剩 T018、T028 客户现场端到端验证与 T033 清理) |
+
+---
+
+## 开发约定
+
+- 后端测试放在 `src/backend/test/llm/` 与 `src/backend/test/linsight/`,`asyncio_mode=auto`。本地跑 pytest 前清掉 SOCKS 代理环境变量。
+- 测试任务先于实现任务;每个任务完成后跑 `/task-review`,再勾选。
+- 共享工作树规则:不用 `git stash`、`git checkout .`、`git reset --hard` 等仓库级写操作。
+- 设计论证一律指向 design.md 的决策编号,本文不复制。
+
+### 接口约定(供跨任务调用)
+
+- `ContextWindowService`(`src/backend/bisheng/llm/domain/services/context_window.py`):
+  - `async def resolve(model: BishengLLM) -> ContextWindowResult`,从不抛异常;读取失败返回 `source="unknown"`。
+  - `async def invalidate(model_ids: Iterable[int]) -> None`,Redis 失败只记警告。
+  - Redis 客户端用 `bisheng.core.cache.redis_manager.get_redis_client()`。
+- `ContextWindowResult` 字段:`window: int | None`、`kind: "total" | "input" | None`、`usable_input: int | None`、`source`。`source` 取值固定为:`manual`(高级配置)、`ollama_num_ctx`、`deployed`(vllm、llamacpp、ollama `/api/ps`、openai 类型读到 `max_model_len`)、`published`(xinference、ollama `/api/show`、anthropic、deepseek、moonshot、openai 类型读到 `context_length`、qwen)、`unknown`。
+- 超窗异常:见 design 决策 5。判断一律用 `isinstance(exc, ContextOverflowError)`。
+
+---
+
+## Wave 1 — P0 基础设施与测试(T001 先做,其余可并行)
+
+- [x] **T001**: 错误码与超窗异常类型
+  **文件**: `src/backend/bisheng/common/errcode/linsight.py`、`src/backend/bisheng/llm/domain/llm/exceptions.py`(新建)
+  **逻辑**: 新增错误码类 `LinsightContextOverflowError`(11075)、`LinsightContextCompressFailedError`(11076);新增 `LLMContextOverflowError(ContextOverflowError)`,带 `body`、`status_code`、`reported_input_tokens`。错误码已在 release-contract 登记,无需再改。
+  **依赖**: 无
+
+- [x] **T002**: 来源优先级与可用输入的测试
+  **文件**: `src/backend/test/llm/test_context_window_service.py`
+  **逻辑**: 来源优先级(高级配置 > Ollama `max_tokens` > 服务商接口 > 未知);可用输入三条规则(仅输入上限、Ollama、减最大输出);`context_window` 非正整数时忽略并继续;高级配置不是合法 JSON 时行为与改动前一致;最大输出 ≥ 上下文长度时按未知处理;每种情况断言 `source` 取值。
+  **覆盖 AC**: AC-01, AC-03, AC-08, AC-09
+  **依赖**: 无
+
+- [x] **T003**: 缓存与失效的测试
+  **文件**: `src/backend/test/llm/test_context_window_cache.py`
+  **逻辑**: 假 Redis。命中时不发请求;失败标记 5 分钟内不发请求;`invalidate` 后重新读取;Redis 异常时 `resolve` 按未知返回、不抛出。
+  **覆盖 AC**: AC-05, AC-06, AC-07
+  **依赖**: 无
+
+- [x] **T004**: 服务商读取器的测试
+  **文件**: `src/backend/test/llm/test_context_window_probes.py`,响应体放 `src/backend/test/llm/fixtures/context_window/`
+  **逻辑**: `httpx.MockTransport` 回放。每个读取器一个正常用例、一个字段缺失用例,并断言返回的 `source` 与 `kind`。必测:vLLM(多条目按模型名选取;LoRA 条目沿 `parent` 取基础模型)、openai 类型(`max_model_len` 与 `context_length` 两种返回)、llamacpp `/props`、Ollama(`/api/ps` 已加载;未加载回退 `/api/show`)、xinference、anthropic(仅输入上限)、deepseek、moonshot;超时 3 秒视为失败。
+  **覆盖 AC**: AC-04, AC-07, AC-09
+  **依赖**: 无
+
+- [x] **T005**: 超窗识别与 `context_window` 剥离的测试
+  **文件**: `src/backend/test/llm/test_bisheng_llm_context_overflow.py`
+  **逻辑**: design 决策 5 表中每条规则用真实报错原文构造 SDK 异常,分别经 `_generate`、`_agenerate`、`_stream`、`_astream` 调用,断言:抛出的异常是 `ContextOverflowError`,同时仍是原 SDK 异常类型,`body`、`status_code` 保留,`__cause__` 为原异常,`reported_input_tokens` 按正则提取;非超窗的 400 原样抛出;四个装饰器在超窗时都不把模型状态写成 ERROR(决策 6);高级配置含 `context_window` 时客户端构造参数里没有这个键。
+  **覆盖 AC**: AC-02, AC-13, AC-15
+  **依赖**: T001
+
+- [x] **T006**: 错误分类的测试
+  **文件**: `src/backend/test/linsight/test_llm_error_classifier.py`(追加)
+  **逻辑**: 超窗异常 → `classify_behavior` 返回 FAIL_FAST,`label_error` 返回 `context_overflow`,`classify_for_event` 的 `error_code` 为 11075;类名为 `LinsightContextCompressFailedError` 的异常 → `context_compress_failed`、11076;包在 `TaskExecutionError` 里时沿 `__cause__` 仍能识别;日常模式 `chat_service` 调用 `label_error` 时得到 `context_overflow`(附带影响,见 design 决策 5)。
+  **覆盖 AC**: AC-14
+  **依赖**: T001
+
+## Wave 2 — P0 实现
+
+- [x] **T007**: `ContextWindowService` 核心(优先级、可用输入、缓存、失效)
+  **文件**: `src/backend/bisheng/llm/domain/services/context_window.py`
+  **逻辑**: 按"接口约定"与 design 决策 3、决策 4 实现;读取器以注册表形式接入,本任务只提供注册表与调度,具体读取器在 T008。
+  **测试**: T002、T003 通过
+  **覆盖 AC**: AC-01, AC-03, AC-05, AC-06, AC-07, AC-08
+  **依赖**: T002, T003
+
+- [x] **T008**: P0 七种服务商读取器
+  **文件**: `src/backend/bisheng/llm/domain/services/context_window_probes.py`
+  **逻辑**: vllm、openai 类型、llamacpp、ollama、xinference、anthropic、deepseek、moonshot;地址和密钥取自 `BishengLLM` 已算好的客户端参数(决策 3);单次超时 3 秒。
+  **测试**: T004 通过
+  **覆盖 AC**: AC-04
+  **依赖**: T004, T007
+
+- [x] **T009**: `BishengLLM` 超窗识别、剥离 `context_window`、不标异常
+  **文件**: `src/backend/bisheng/llm/domain/llm/llm.py`、`src/backend/bisheng/llm/domain/utils.py`
+  **逻辑**: `_get_llm_params` 取出 `context_window`;四个调用入口在被装饰函数内部识别超窗并改抛(决策 5,保持原 SDK 类型与属性);四个装饰器对 `ContextOverflowError` 跳过状态写入(决策 6)。
+  **影响范围**: `BishengLLM` 全平台共用。附带影响见 design 决策 5:日常模式的错误分类会得到 `context_overflow`;所有场景超窗不再标模型异常。`test/llm/` 全部既有用例必须不回归。
+  **测试**: T005 通过
+  **覆盖 AC**: AC-02, AC-13, AC-15
+  **依赖**: T001, T005
+
+- [x] **T010**: 错误分类实现
+  **文件**: `src/backend/bisheng/common/services/llm_error_classifier.py`
+  **逻辑**: 新增两个 `ErrorType`;`label_error` 在最前面识别(压缩失败按类名,避免 common 导入 linsight);`classify_behavior` 对 `ContextOverflowError` 返回 FAIL_FAST;`classify_for_event` 按类型写入 11075、11076。
+  **影响范围**: 共享模块。调用方为 linsight(resilience、失败处理)和日常模式 `chat_service.py:1467`、`:2377`。`:1467` 只判断是否属于临时错误,`context_overflow` 不属于,行为不变;`:2377` 得到新类型,由 T013 的文案覆盖。
+  **测试**: T006 通过;`test/linsight/`、`test/workstation/` 中使用分类器的既有用例不回归
+  **覆盖 AC**: AC-14
+  **依赖**: T001, T006
+
+- [x] **T034**: 保存配置与验证模型时清除缓存的测试
+  **文件**: 追加到 `src/backend/test/llm/test_context_window_cache.py`
+  **逻辑**: `update_llm_server` 保存后,对每个模型调用了 `ContextWindowService.invalidate`;`verify_model_status` 同样调用;`update_model_online` 不调用。
+  **覆盖 AC**: AC-06
+  **依赖**: T007
+
+- [x] **T011**: 保存配置与验证模型时清除缓存
+  **文件**: `src/backend/bisheng/llm/domain/services/llm.py`
+  **逻辑**: 在 `update_llm_server` 里按模型逐个调用 `invalidate_llm_info_cache` 的循环中,调用 `ContextWindowService.invalidate`;`verify_model_status` 中调用 `invalidate_llm_info_cache` 之后同样调用。`update_model_online` 只改上下线状态,不影响上下文长度,不调用。
+  **测试**: T034 通过
+  **覆盖 AC**: AC-06
+  **依赖**: T034
+
+## Wave 3 — P0 接入任务模式
+
+- [x] **T012**: agent_factory 接入的测试
+  **文件**: `src/backend/test/linsight/test_agent_factory_context_window.py`
+  **逻辑**: 假 `ContextWindowService` 返回可用输入时,主图与研究员子图的压缩中间件处于比例模式,阈值为 `0.85 × 可用输入`;保留比例为 `0.1 × 可用输入`;返回未知时为 170000 tokens、保留 6 条消息;两种情况都有 `[linsight-context-window]` 日志;"继续"、"重试"与"回答提问后恢复"重新构造 agent 时都会再次调用 `resolve`。
+  **覆盖 AC**: AC-08, AC-09, AC-11, AC-24
+  **依赖**: T007
+
+- [x] **T013**: 前端错误码文案(api_errors)
+  **文件**: `src/frontend/packages/locales/src/api_errors/{zh-Hans,en,ja}.json`
+  **逻辑**: 为 11075、11076 补三种语言文案,措辞对任务模式与日常模式都成立(design 决策 8)。改完运行 `pnpm locales:build` 重新生成 `client/src/locales/*/api_errors.gen.json` 与 `platform/public/locales/*/api_errors.json`,生成产物不手改。
+  **手动验证**: `pnpm --filter @bisheng/locales check`、`pnpm check-i18n` 通过。
+  **覆盖 AC**: AC-14, AC-18
+  **依赖**: T001
+
+- [x] **T014**: agent_factory 接入实现
+  **文件**: `src/backend/bisheng/linsight/domain/services/agent_factory.py`
+  **逻辑**: `_resolve_model` 之后、`create_deep_agent` 之前调用 `ContextWindowService.resolve`;可用输入非空时设置 `model.profile`(决策 1、决策 2);记录 `[linsight-context-window]` 日志。
+  **测试**: T012 通过
+  **覆盖 AC**: AC-08, AC-09, AC-11, AC-24
+  **依赖**: T008, T012
+
+- [x] **T015**: 超窗压缩重试与子任务降级的测试
+  **文件**: `src/backend/test/linsight/test_context_overflow_flow.py`
+  **逻辑**: 假模型按序列返回。主图:第一次超窗 → 压缩 → 第二次成功;两次都超窗 → `output_result.error_type == "context_overflow"`、`error_code == 11075`,日志含 `reported_input_tokens`,已生成文件保留;同一次请求最多压缩一次。中文场景:构造"估算未到阈值、实际超窗"的序列,断言走超窗后压缩重试(design 决策 1 的 P0 局限)。子图:两次都超窗 → 主图收到超窗说明(工具结果为成功状态)并继续;累计超过 `max_degrade` → 任务以 `context_overflow` 失败。resilience 对超窗直接抛出(主图与子图)。
+  **覆盖 AC**: AC-13, AC-14, AC-15, AC-16
+  **依赖**: T009, T010, T014
+
+- [x] **T016**: resilience 与 `task` 超窗中间件实现
+  **文件**: `src/backend/bisheng/linsight/domain/services/resilience_middleware.py`、`src/backend/bisheng/linsight/domain/services/agent_factory.py`、`src/backend/bisheng/linsight/domain/task_exec.py`(`_handle_task_failure`)
+  **逻辑**: resilience 对超窗直接抛出;新增主图 `wrap_tool_call` 中间件,只处理 `task` 工具的超窗异常,与研究员 resilience 实例共用降级计数(决策 9);主图失败时在 `_handle_task_failure` 记录 `reported_input_tokens`,子图超窗时由新中间件记录(AC-15)。
+  **测试**: T015 通过;`test_resilience_middleware.py` 不回归
+  **覆盖 AC**: AC-13, AC-15, AC-16
+  **依赖**: T015
+
+- [x] **T017**: 失败卡片的新类型与文案
+  **文件**: `src/frontend/client/src/components/ChatErrorCard.tsx`、`src/frontend/client/src/locales/{zh-Hans,en,ja}/translation.json`
+  **逻辑**: 两个新类型加入 `KNOWN_TYPES`(不进 `TRANSIENT_TYPES`),三种语言各补 title、desc、hint 三个键,措辞对任务模式与日常模式都成立(决策 8)。只加文案,不改卡片样式。如果改动的文件里有冻结的硬编码中文,按根 AGENTS.md 规则在同一改动中提取。
+  **手动验证**:
+  - 在 114 上构造一个超窗失败的任务,打开 `/workspace` 任务页,确认卡片显示新文案;切换三种语言各看一次。
+  - 在日常模式触发一次超窗,确认文案读起来也成立。
+  - `pnpm lint`、`pnpm typecheck`、`pnpm check-i18n` 通过;改完运行 `pnpm lint:prune`。
+  **覆盖 AC**: AC-14
+  **依赖**: T010, T013
+
+- [x] **T035**: 刷新后恢复失败类型
+  **文件**: `src/frontend/client/src/hooks/linsightErrorInfo.ts`(新建,严格类型)、`src/frontend/client/src/hooks/useLinsightManager.tsx`
+  **逻辑**: 从 `output_result.error_type`、`error_code`、`detail` 恢复 `taskErrorInfo`(决策 8)。`useLinsightManager.tsx` 已有 635 行、带 `@ts-strict-ignore`:恢复逻辑写在新文件里,原文件两处恢复点(现 `:134`、`:203`)各改为调用新函数,净增行数不超过 2 行。
+  **手动验证**:
+  - 构造一个超窗失败的任务后刷新页面,确认文案不变,不退回"未知错误"。
+  - 从历史列表重新打开该任务,确认同上。
+  - `pnpm lint`、`pnpm typecheck` 通过;改完运行 `pnpm lint:prune`。
+  **覆盖 AC**: AC-14
+  **依赖**: T017
+
+- [ ] **T018**: P0 端到端验证
+  **文件**: 无代码;结果记在"实际偏差记录"或 PR
+  **逻辑**: 跑 `/e2e-test`;按 design §7 的手动验证步骤,在 `--max-model-len 32768` 的 vLLM 上执行中文长文档任务,再用 133120 复验客户场景。
+  **测试降级**: AC-12 需要真实 vLLM 部署和 GPU,无法在 CI 自动化;自动化部分由 T015 的假模型序列覆盖。
+  **覆盖 AC**: AC-04, AC-09, AC-11, AC-12
+  **依赖**: T011, T014, T016, T017, T035
+
+## Wave 4 — P1
+
+- [x] **T019**: 技术验证:替换框架压缩中间件
+  **文件**: 验证脚本放 scratchpad;结论写入 design.md 决策 7
+  **逻辑**: 回答决策 7 的两个问题;确认 BiSheng 子类改 `name` 后不被排除规则移除;确认主图、子图都能拿到当次参数。不通过时停下与用户重新确认路线。
+  **依赖**: T018
+
+- [x] **T020**: 中日韩计数与用量基数的测试
+  **文件**: `src/backend/test/linsight/test_context_token_counter.py`
+  **逻辑**: 纯中文、中英混合;有 usage 基数时只估算增量;无 usage 时全量估算;最近一条是降级合成消息(无 usage)时向前找。
+  **覆盖 AC**: AC-21
+  **依赖**: 无
+
+- [x] **T021**: 中日韩计数与用量基数的实现
+  **文件**: `src/backend/bisheng/linsight/domain/services/context_budget.py`
+  **逻辑**: 按 design 决策 10 实现纯函数。
+  **测试**: T020 通过
+  **覆盖 AC**: AC-21
+  **依赖**: T020
+
+- [x] **T022**: BiSheng 压缩中间件子类的测试
+  **文件**: `src/backend/test/linsight/test_context_summarization.py`
+  **逻辑**: 摘要输入不超过 `0.5 × 可用输入`;保留部分不超过 `0.1 × 可用输入`;摘要第一次失败、重试成功;两次都失败 → 抛 `LinsightContextCompressFailedError`,任务 `error_type == "context_compress_failed"`、`error_code == 11076`,日志有错误级别记录,已生成文件保留,失败文本没有作为摘要进入下一次请求;计数使用 T021 的函数。
+  **覆盖 AC**: AC-17, AC-18, AC-21, AC-24
+  **依赖**: T019, T021
+
+- [x] **T023**: BiSheng 压缩中间件子类的实现
+  **文件**: `src/backend/bisheng/linsight/domain/services/context_budget.py`、`src/backend/bisheng/linsight/domain/services/agent_factory.py`
+  **逻辑**: 按 T019 的结论接入,主图与研究员子图都使用。
+  **测试**: T022 通过
+  **覆盖 AC**: AC-17, AC-18, AC-21, AC-24
+  **依赖**: T022
+
+- [x] **T024**: 单条工具结果转存与读文件截断的测试
+  **文件**: `src/backend/test/linsight/test_tool_result_budget.py`
+  **逻辑**: 已知上下文长度时,超过 `0.1 × 可用输入` 的工具结果写入 `/large_tool_results/`,返回开头片段与路径;`read_file` 结果截断到同一上限并提示分页,不转存;主图与子图都生效;未知上下文长度时行为与改动前一致。
+  **覆盖 AC**: AC-19, AC-20
+  **依赖**: T021
+
+- [x] **T025**: 单条工具结果转存与读文件截断的实现
+  **文件**: `src/backend/bisheng/linsight/domain/services/context_budget.py`、`src/backend/bisheng/linsight/domain/services/agent_factory.py`
+  **逻辑**: 新增 `wrap_tool_call` 中间件;提示文案与 `tool_loop_middleware` 现有的取回提示一致。与 T023 改同一组文件,串行执行。
+  **测试**: T024 通过
+  **覆盖 AC**: AC-19, AC-20, AC-24
+  **依赖**: T023, T024
+
+- [x] **T026**: 百炼读取器的测试
+  **文件**: 追加到 `src/backend/test/llm/test_context_window_probes.py`
+  **逻辑**: 从兼容模式地址推导原生地址;北京区域缺 WorkspaceId 时按未知处理;`source == "published"`。
+  **覆盖 AC**: AC-10
+  **依赖**: T008
+
+- [x] **T027**: 百炼读取器的实现
+  **文件**: `src/backend/bisheng/llm/domain/services/context_window_probes.py`
+  **测试**: T026 通过
+  **覆盖 AC**: AC-10
+  **依赖**: T026
+
+- [ ] **T028**: P1 端到端验证
+  **文件**: 无代码
+  **逻辑**: 跑 `/e2e-test`;重复 T018 的中文长文档任务,确认压缩改为提前触发(日志中不再出现被识别的超窗),每次请求的服务商用量不超过可用输入;人为让摘要调用失败一次,确认重试;检查大工具结果被转存。
+  **测试降级**: AC-21 以服务商实测用量为准,需要真实部署。
+  **覆盖 AC**: AC-17, AC-19, AC-21, AC-24
+  **依赖**: T023, T025, T027
+
+## Wave 5 — P2
+
+- [x] **T029**: 死配置清理的测试
+  **文件**: `src/backend/test/common/test_linsight_conf_legacy_keys.py`
+  **逻辑**: 含 AC-22 所列 6 个键的 `linsight` 配置能被 `LinsightConf` 正常加载;删除后这些属性不再存在。
+  **覆盖 AC**: AC-22, AC-23
+  **依赖**: 无
+
+- [x] **T030**: 删除死配置
+  **文件**: `src/backend/bisheng/core/config/settings.py`(`LinsightConf`)、`src/backend/bisheng/initdb_config.yaml`、`src/backend/bisheng_langchain/linsight/const.py`
+  **逻辑**: 从 `LinsightConf` 和 `initdb_config.yaml` 删除 6 个字段。`const.py` 的 `ExecConfig` 类没有任何引用(已 grep 确认),整个类删除;同文件的 `TaskStatus` 等仍在使用,保留。`test/common/test_config_backfill.py` 使用自带的内联配置,不受影响。
+  **测试**: T029 通过
+  **覆盖 AC**: AC-22, AC-23
+  **依赖**: T029
+
+## 收尾
+
+- [x] **T031**: P0 代码审查与合并
+  **逻辑**: `/code-review --base feat/3.0.0`,合并 P0。
+  **依赖**: T018
+
+- [x] **T032**: P1、P2 代码审查与合并
+  **逻辑**: `/code-review --base feat/3.0.0`;P1、P2 在同一分支继续或另开 PR,以用户决定为准。
+  **依赖**: T028, T030
+
+- [ ] **T033**: 收尾清理
+  **逻辑**: 合并后按用户的 worktree 清理规则删除 `bisheng-087-context-window` worktree 与本地分支。
+  **依赖**: T032
+
+---
+
+## AC 覆盖检查
+
+| AC | 测试任务 | 实现任务 |
+|---|---|---|
+| AC-01 | T002 | T007 |
+| AC-02 | T005 | T009 |
+| AC-03 | T002 | T007 |
+| AC-04 | T004, T018 | T008 |
+| AC-05 | T003 | T007 |
+| AC-06 | T003, T034 | T007, T011 |
+| AC-07 | T003, T004 | T007 |
+| AC-08 | T002, T012 | T007, T014 |
+| AC-09 | T002, T004, T012, T018 | T014 |
+| AC-10 | T026 | T027 |
+| AC-11 | T012, T018 | T014 |
+| AC-12 | T018(测试降级) | — |
+| AC-13 | T005, T015 | T009, T016 |
+| AC-14 | T006, T015 | T010, T013, T017, T035 |
+| AC-15 | T005, T015 | T009, T016 |
+| AC-16 | T015 | T016 |
+| AC-17 | T022, T028 | T023 |
+| AC-18 | T022 | T013, T023 |
+| AC-19 | T024, T028 | T025 |
+| AC-20 | T024 | T025 |
+| AC-21 | T020, T022, T028 | T021, T023 |
+| AC-22 | T029 | T030 |
+| AC-23 | T029 | T030 |
+| AC-24 | T012, T022, T028 | T014, T023, T025 |
+
+---
+
+## 实际偏差记录
+
+> 只留一行指针,论证在 design.md。推翻已确认的决策时,先停下与用户重新确认。
+
+- T004 偏离:响应体直接写在测试文件里,没有单独放 `fixtures/context_window/`(每个只有几行,内联更易读)。
+- T034 偏离:实现(T011)先于测试写成;测试为源码级断言(驱动 `update_llm_server` 需要数据库)。
+- T035 偏离:`useLinsightManager.tsx` 净增 3 行(2 处调用 + 1 行导入),不是"不超过 2 行"。
+- T010 偏离:`unwrap` 增加"遇到超窗 / 压缩失败异常即停止",原 design 未写 → 已补 design §5 坑 17。
+- T015:发现框架主动压缩后不再接住超窗 → design §5 坑 15、16。
+- T031:两轮 `/code-review`,修复提交 `fix(F087): address code review findings`,2026-10-21 合入 feat/3.0.0(6c6a62311)。
+- T019:验证脚本 `scratchpad/spike/spike_t019.py`(会话临时目录,未入库),结论写入 design 决策 7。与原计划不同:BiSheng 压缩部件通过 harness profile 的 `extra_middleware` 工厂注入,而不是 `middleware=`,因此位于 resilience 内层(决策 7 已说明,未推翻任何已确认决策)。
+- T023:P0 的 `test_context_overflow_flow.py` 改为按生产方式装配(`bisheng_summarization()`),并调小工具结果以单独覆盖超窗重试路径(design 坑 20)。
+- T026 / T027:百炼北京等区域的原生接口 host 需要 WorkspaceId;默认地址 `dashscope.aliyuncs.com` 不带它,按未知处理(官方文档 2026-10-21 核实)。新加坡、香港与已带 workspace 的 `*.maas.aliyuncs.com` 可读。
+- T030:同时更新 `docs/architecture/05-linsight-agent.md` 的配置表。
+- T032:P1/P2 代码审查 NEEDS_FIX(摘要输入被裁空、压缩部件位于 resilience 内层导致重试重复压缩、grep/ls/glob 未受单条上限约束、文档过期)→ 已修,见 design 决策 7、坑 21、§8。
+- T032 合并:P1、P2 于 2026-10-21 合入 feat/3.0.0(4cdf4ada0)。
+- T018 推迟:2026-10-21 用户决定在客户现场(vLLM 133120)做端到端验证,界面三项手动检查一并在现场做。
+- T017 / T035 验证:改动的 3 个文件 eslint、tsc 无错误;`pnpm lint` / `pnpm typecheck` 整体报错都在其他文件(worktree 软链了 vibe 分支的 node_modules,未与 feat/3.0.0 干净环境对比);`check-i18n`、`locales check` 通过。手动界面验证未做。
+- T018 / T028 部分完成(2026-10-22,3002,deepseek-v4.1-flash,台北时间 01:47–02:02):
+  - 未配置时日志为 `source=unknown`(百炼默认地址)。
+  - 手动配置 `context_window: 32000` 后为 `source=manual usable_input=32000`。
+  - 未压缩步骤的计数预测为服务商用量的 0.93–1.07 倍。
+  - 23 次调用压缩 12 次,摘要无失败,`read_file` 被截到 3200,任务正常完成。
+  - 发现 1:P2 删除字段后,数据库配置里的旧键让任务模式配置读取报错,工作台打不开 → 已修,design §5 坑 22。
+  - 发现 2:32K 窗口下 6 次请求在压缩后仍超过可用输入(最大 43154),用户决定暂不处理 → design §8。
+  - 仍需在客户现场完成:vLLM 部署值读取、vLLM 超窗报错识别,以及两条错误文案的界面检查。
diff --git a/features/v3.0.0/088-request-error-contract/client-code-review.md b/features/v3.0.0/088-request-error-contract/client-code-review.md
new file mode 100644
index 0000000000..a120f050fb
--- /dev/null
+++ b/features/v3.0.0/088-request-error-contract/client-code-review.md
@@ -0,0 +1,83 @@
+# F088 client 最终独立审查
+
+工作区:`/Users/shanghang/.codex/worktrees/request-error-contract/bisheng`;基线 `bc6ca25ba`。对象为共享工作区的未提交实现;只读产品代码、运行精准回归并写报告,未安装依赖、修改产品、执行批量脚本、Git 写入或派生 agent。
+
+**Spec verdict:PASS(本报告明确列出的活跃迁移链)。Quality verdict:PASS(相同范围的代码审查与精准回归)。本轮没有未关闭的代码阻断。** 这是限定范围的独立审查结论,不能替代整个 F088 的全量、构建或真实后端 E2E 发布验收;后者仍未验证。
+
+## 范围与证据
+
+使用 requesting-code-review 的调用链审查,并按仓库 sdd-code-review 七维框架记录。对照 feature 的 spec/design/tasks、`/private/tmp/f088-client-consumer-review.md` 的 R1/R2/R3 及本轮 `task-1-report.md`。不重接不可达 UI,不将 stub 视为真实 API,不把未勾选任务本身当产品缺陷。
+
+| 域 | 已核实的活跃链 |
+| --- | --- |
+| request/auth/binary/identity | client 原认证/refresh队列 → 新末端错误规范化;独立解码视图不改原response.data;共享错误身份/cause/response/Blob证据;模板参数;真实提示的WeakMap/presentation归属 |
+| Skills | skillCenter → useSkillCenter / SkillUploadDialog / SkillCenterPage / SkillDetails;11053、11055命名恢复、详情文件query、乐观回滚、失败保留窗口及输入 |
+| upload/rename/delete | 上传 → 文件登记 → useFileUpload;19403参数/早停;文件/文件夹行与卡片 → useInlineRename → await保存/失败保留输入;删除成功后才成功提示 |
+| export/citation | daily/appChat当前ChatView → 消息toolbar/移动格式sheet/导入选择 → messageExport;引用resolve → 当前引用UI reason/404恢复,保留同一标准错误与原始证据 |
+| query/cache | App默认retry + 原QueryCache;空间/频道列表hook → 页面/sidebar;RunCode有限网络重试;保留RQ4.44.0、成功结构、缓存键、原加载含义 |
+| history/clear | daily、订阅assistant、文件、文章、文件夹/空间dock → 四history hooks → 失败/重试、禁发、cleanup、资源归属clear及失败保留消息 |
+| approval/share | ApprovalPane列表/详情/动作revision与错误重试,详情与选择一致性;菜单申请;share404/普通失败重试;sharedLink mutateAsync终点 |
+| voice/artifacts | ASR/TTS → VoiceSession保留原cause → UI;artifact下载/另存知识空间 → API错误去重,本地错误保留文案、finally恢复锁 |
+| remaining default lists | app/standalone会话列表 → sidebar;SourceManager列表/搜索/分页 → picker;文章page1/loadmore → useArticleList/ArticleList;Nav正常历史与搜索结果独立 |
+
+明确排除SSE/WebSocket/原生fetch传输、后端、platform终审;旧不可达LibreChat管理UI及固定成功stub。保留guest26401/26402/26403、不可用附件链接、可选标题、徽标零值、权限惰性探测等已有命名恢复,不机械删除。
+
+## 最后两族修复:已关闭
+
+### 消息导出/导入提示终点
+
+核实位置:`src/frontend/client/src/components/Chat/MessageSelection/MessageSelectionToolbar.tsx:105`、`ExportFormatSheet.tsx:98`;`src/frontend/client/src/components/Chat/ChatView.tsx:286`、`src/frontend/client/src/pages/appChat/ChatView.tsx:137`。
+
+四个真实UI catch现在将标准ApiError原样传给showApiError,同一对象的status_code/data/response/cause/kind/presentation全部保留,不再重建只含码/消息的对象。global、已提示、canceled由共同helper处理;本地浏览器下载错误仍使用原localized fallback。导出finally复位按钮,sheet仅成功下载后关闭;导入失败保留选择和窗口,只有成功后退出选择。新增真实组件回归覆盖这些终点及成功继续执行;原AC-07、AC-09、AC-10、AC-15缺口关闭。
+
+### 上传聚合、登记与附带终点
+
+核实位置:`src/frontend/client/src/pages/knowledge/hooks/useFileUpload.ts:152`、`:354`、`:380`、`:407`、`:528`、`:603`、`:610`。
+
+uploadFilesSequential收到global/canceled即原样reject、停止后续文件,不加入普通失败汇总;普通每文件失败在现有展示信息外保留error原对象,19403参数/早停/计数恢复保留。file/folder最外层catch有明确终点,finally清占位、folder reentry锁并刷新已尝试上传的配额;部分body成功后遇global/cancel不继续登记。folder重名precheck保留既有普通失败fall-through,global/cancel停止该流程。
+
+登记只保留一次标准HTTP/network/timeout重试;business/global/canceled/invalid-response/local Error不触发新增重试,也不重传文件body。同文件获授权的duplicate overwrite与single-delete catch也按标准错误归属提示;原duplicate清理、delete回滚与成功后提示保留。原AC-07、AC-08、AC-10、AC-15缺口关闭。
+
+本轮只核实上述五生产文件及三新增测试、原quota测试,不扩大到其他CRUD或非统一HTTP通道。未发现本轮新增阻断。
+
+## 本轮发现并已关闭
+
+1. 历史失败无重试且遮住后续新消息:四hooks返回retryHistory,五消费者连接onRetry,error/loading禁发;history cleanup忽略过期完成。folder重试重新获取session/list/history,旧history完成会被cleanup丢弃。
+2. 跨资源clear晚完成误清当前资源:file/article资源token,folder资源token+activeChatRef守卫;失败保留消息。
+3. 同资源clear pending可发新消息:clearingRef即时锁/isClearing、UI禁发、重复clear及regenerate守卫;资源effect复位锁,旧operation不释放新操作锁。
+4. app sidebar retry旧闭包:A初始失败→新建B→retry曾重新插A;现从conversationIdRef/localizeRef读取当前值,不把不稳定localize加进初始化effect。精准回归已通过。
+5. R1/R2/R3其余已修项已核实:分享失效/重试、WeChat恢复、菜单申请、版本/关联query与mutation、sharedLink Promise、审批详情错配、voice cause、artifact去重、AppChat权限探测失败、空间/频道主列表、inline rename、删除成功提示、Nav/Source/Article/sidebar假空成功。本轮未发现这些链的额外阻断,不重复旧缺陷。
+
+## 七维 SDD
+
+| 维度 | 结论 | 说明 |
+| --- | --- | --- |
+| Boundary Conditions | PASS(范围内) | export/import标准错误身份与展示归属保留;upload global/cancel停止并恢复,普通配额恢复保留;核心模板/二进制已核实 |
+| Permission & Auth | PASS(范围内) | 原session/refresh队列保留;全局动作先执行再规范化;旧不可达resolved403删除而非重接;无新增业务层403策略 |
+| Concurrency Safety | PASS(范围内) | history资源cleanup、clear资源token/锁、sidebar最新路由闭包;独立13项精准回归通过 |
+| Information Leakage | PASS(增量范围) | 原始证据保留未变成新UI/日志全量输出;不是全仓日志安全审计 |
+| Test Coverage | PASS(精准范围) | 本reviewer前轮13项、本轮41项独立通过;实际hooks/components与失败后UI状态均有断言;全量/E2E不由本报告替代 |
+| Code Style | PASS(静态范围) | 共享错误模型、原组件/RQ4.44、ArticleList控制器拆出满足600行;最终lint/typecheck须另验 |
+| Docs Sync | PASS(审查范围) | 已核实design移除未实施过期文本,并同步二阶段解码/WeakMap、history重试和clear归属锁、验证与修订记录;未执行浏览器验收明确保留未勾选 |
+
+## 验证与范围限制
+
+本reviewer独立执行:client cwd,`JEST_JUNIT_OUTPUT_DIR=/private/tmp/f088-final-review pnpm exec jest src/hooks/chatHistoryErrors.test.tsx src/pages/appChat/hooks/sidebarRequestErrors.test.tsx --runInBand --coverage=false --forceExit`。结果:**2 suites /13 tests PASS,exit0,2.243s**。包含跨资源late clear不影响B且锁复位、同资源pending禁发/重复clear、失败保留消息、重试成功、sidebar当前路由与auth/guest草稿恢复。
+
+读取他方证据(不冒充独立执行):consumer `task-1-report.md`为4 suites/16 tests通过、guest命名恢复9项通过、client1490 strict files typecheck通过、9生产+4测试scoped ESLint无诊断(suppression缩小由父任务prune)。父任务另有request binary/global、11053/11055、审批/分享/rename/配额/voice回归。
+
+没有运行浏览器/真实后端E2E、全client测试、全frontend lint/typecheck/check-i18n/build;这些由T006负责,不能给无证据的发布PASS。没有穷尽所有文件预览局部元数据、树导航每层竞态、任务模式内部SSE/WS对账及文章所有辅助分享/收藏/反馈按钮。对这些不作全覆盖声明。
+
+### 精准最终复核(第二轮)
+
+阅读实施报告 `.superpowers/sdd/client-final-migration-plan/fix-round-2-report.md`、五个生产文件与三新增测试。生产控制流和测试证据一致;测试虽然隔离共同提示helper,但明确断言传递同一ApiError对象及无额外普通toast,helper自身的presentation/canceled策略已在此前请求契约审查中核实。
+
+本reviewer本轮仅执行一次聚焦命令(client cwd):
+
+```sh
+JEST_JUNIT_OUTPUT_DIR=/private/tmp/f088-final-review-round2 pnpm exec jest src/components/Chat/MessageSelection/exportFailureContract.test.tsx src/components/Chat/importFailureContract.test.tsx src/pages/knowledge/hooks/uploadTerminalErrors.test.tsx src/pages/knowledge/hooks/uploadFailureContract.test.ts --runInBand --coverage=false --forceExit
+```
+
+结果:**4 suites /41 tests PASS,exit0,11.145s**。未重跑history/clear/closure或全量。实施agent的changed-file ESLint exit0/no diagnostics与diff-check为读取报告所得,不冒充本reviewer独立执行。
+
+最终scope verdict:**Spec PASS / Quality PASS;无本范围内待修代码问题。全量门禁、构建与真实后端E2E尚未由本reviewer验证。** 父任务已生成E2E清单,真实后端不可达,故不能把清单或本轮单测视为已执行E2E。
diff --git a/features/v3.0.0/088-request-error-contract/client-final-fix-report.md b/features/v3.0.0/088-request-error-contract/client-final-fix-report.md
new file mode 100644
index 0000000000..71db3f0d7c
--- /dev/null
+++ b/features/v3.0.0/088-request-error-contract/client-final-fix-report.md
@@ -0,0 +1,66 @@
+# Fix round 2 report — export/import/upload terminal consumers
+
+Status: DONE. Changes are confined to the five parent-assigned production files and three new focused tests. No commits, repository-wide Git writes, subagents, dependencies, locale JSON writes, request-policy changes, or further CRUD expansion.
+
+Brief: `.superpowers/sdd/client-final-migration-plan/fix-round-2-brief.md` (updated with the parent's HTTP retry clarification and additional same-file duplicate/delete catch authorization).
+
+## Changed production paths
+
+Relative to `src/frontend/client/src/`:
+
+- `components/Chat/MessageSelection/MessageSelectionToolbar.tsx`
+- `components/Chat/MessageSelection/ExportFormatSheet.tsx`
+- `components/Chat/ChatView.tsx`
+- `pages/appChat/ChatView.tsx`
+- `pages/knowledge/hooks/useFileUpload.ts`
+
+The existing daily ChatView `historyError`/`retryHistory`/`isClearing` wiring remains intact. Existing parent changes to upload quota interpolation, rename behavior, and deletion success-after-commit remain intact.
+
+## Four export/import UI endpoints
+
+The desktop toolbar export, mobile sheet export, daily-chat import, and app-chat import catches now distinguish canonical `ApiError` from local failures:
+
+- `ApiError` is passed directly to `showApiError(error)` using the exact original object. No partial reconstruction of `status_code`/`status_message` remains in these catches, so `data`, original `response`, `cause`, cancellation kind, and presentation ownership are preserved.
+- Global/canceled/already-presented errors go through the canonical helper without adding an unconditional local toast.
+- Non-API local/browser download failures retain the existing localized `workstation.messageExport.renderFailed` fallback.
+- Export pending flags reset in `finally`; the export sheet closes only after successful download. Both import modals and selection remain open on failure and only complete after a successful import.
+- Removed the sheet's hardcoded Chinese no-selection fallback, using the existing `workstation.messageExport.noSelection` localization; no new key needed.
+
+Tests render the actual four endpoint components, mock only APIs and unrelated heavy children/providers, and assert exact identity with `.toBe(error)`, absence of extra ordinary toast, reset/retained UI state, local fallback, and successful continuation.
+
+## Upload and registration
+
+Actual production chain: knowledge `SpaceDetail` input/drop callbacks → `handleUploadFile` / `handleUploadFolder` → shared `uploadFilesSequential` → `uploadFileToServerApi`, followed by `addFilesApi` / `uploadFolderApi`.
+
+- Sequential upload immediately rethrows the same global/canceled ApiError, stops the batch, and never includes it in ordinary aggregation. A prior successful body does not allow partial registration after that terminal failure.
+- Ordinary per-file failures now retain an `error: unknown` evidence field containing the exact original error in addition to their existing display reason/code. Existing 18024/19402/19403 early-stop and named quota aggregation remain unchanged. Ordinary non-collapsible failures continue with the remaining files and keep their per-file report.
+- File upload wraps the full upload/register operation in terminal `catch` + `finally`: API failures go to `showApiError`, local registration failures retain the old localized fallback, placeholders always clear, and quota is refreshed after any attempted batch.
+- Folder upload consumes propagated errors in its outer UI catch, always releases the reentry lock and placeholder in `finally`, and refreshes quota whenever body uploads started, including a cancellation/global error after partial byte storage. The regression first showed folder abort skipped this quota refresh; the finalizer now preserves it.
+- The folder duplicate-name precheck keeps its established ordinary failure fall-through. Global/canceled errors are rethrown to the outer terminal handler instead of resuming uploads.
+- File registration retains the existing **one retry** budget for canonical HTTP/network/timeout failures using `isApiError(firstErr) && shouldRetryApiError(firstErr)`. It does not retry business, canceled, global, invalid-response, or ordinary local Error failures. File bodies are not uploaded again during registration retry.
+- Per explicit parent follow-up, duplicate-file overwrite and single-delete catches in this same file also now delegate ApiError to the canonical helper and retain their local fallbacks. Existing duplicate cleanup and optimistic single-delete rollback/reload remain; delete success is still announced only after the backend commits.
+
+## Focused tests and red/green evidence
+
+New tests:
+
+- `components/Chat/MessageSelection/exportFailureContract.test.tsx`: 10 tests. Initial original-code run had 6 expected failures for missing canonical ownership; 4 local/success tests already passed. Final all 10 pass.
+- `components/Chat/importFailureContract.test.tsx`: 8 tests through both real ChatViews. Negative control temporarily restored the previous unconditional-toast behavior in only those catches: 6 canonical ApiError assertions failed, 2 local/success cases passed. Both fixes were immediately restored in `finally`; final all 8 pass.
+- `pages/knowledge/hooks/uploadTerminalErrors.test.tsx`: 22 tests. Initial run reproduced batch continuation, lost evidence, inappropriate retry, missing terminal cleanup, and precheck fall-through (12 failures). The later explicitly authorized duplicate/delete cases were separately observed red (4 missing-canonical-presentation assertions) before implementation. The partial-folder quota-refresh assertions were separately observed red (2 cases) before their finalizer correction.
+- Existing `pages/knowledge/hooks/uploadFailureContract.test.ts` quota regression: 1 test remains green.
+
+Final command (cwd `src/frontend/client`):
+
+```sh
+JEST_JUNIT_OUTPUT_DIR=/private/tmp/f088-child pnpm exec jest src/components/Chat/MessageSelection/exportFailureContract.test.tsx src/components/Chat/importFailureContract.test.tsx src/pages/knowledge/hooks/uploadTerminalErrors.test.tsx src/pages/knowledge/hooks/uploadFailureContract.test.ts --runInBand --coverage=false --forceExit
+```
+
+**Final result: 4 suites / 41 tests passed, exit 0** (last run 8.701 s). No test warnings/errors other than Jest's expected `--forceExit` advisory.
+
+Changed-file ESLint includes the five production files and three new tests, with the existing `eslint-suppressions.json` and `--pass-on-unpruned-suppressions`: **exit 0, no diagnostics**. Suppression pruning remains parent-owned; no suppression file or strict-ignore header was added/edited.
+
+`git diff --check` for the five production files: exit 0.
+
+## Scope and remaining verification
+
+No known unresolved issue in this assigned round. Per parent instruction, no full client/frontend suite or full typecheck was run by this child for round 2; the parent will execute the unified final gates after these focused tests are green. Existing large ChatView/useFileUpload files were kept in scope and reduced slightly rather than expanded into an unrelated structural refactor.
diff --git a/features/v3.0.0/088-request-error-contract/client-final-migration-plan.md b/features/v3.0.0/088-request-error-contract/client-final-migration-plan.md
new file mode 100644
index 0000000000..ba3cda86a6
--- /dev/null
+++ b/features/v3.0.0/088-request-error-contract/client-final-migration-plan.md
@@ -0,0 +1,11 @@
+# F088 client final consumer closure
+
+## Task 1: close the remaining default HTTP UI chains
+
+Worktree: /Users/shanghang/.codex/worktrees/request-error-contract/bisheng only. Parent owns all request.ts/shared api-client/quality files/platform, and has implemented most client chains. Read root/client AGENTS.md, feature spec.md AC05/07/11/12/13/14/20/21 and design.md here; reviewer evidence /private/tmp/f088-client-consumer-review.md third round R3. Production requests now always reject complete ApiError, showApiError preserves idempotent presentation and cancellation. ApiRequestFailure component exists in client/components using existing shared Alert; consumes error:unknown and optional onRetry:()=>unknown. No subagents, no repository-wide git writes (stash/reset/clean/restore/checkout), no commits; shared tree contains parent work. Prior unsafe bulk104 script rejected and proven wrong, never apply it or split its transforms. Inspect API→consumer→UI per chain and edit scoped files only. No catalog/deps/styles or auth policy changes.
+
+Your ownership ONLY remaining: client/pages/{appChat/hooks/useAppSidebar.ts,appChat/SideNav.tsx,standaloneChat/hooks/useStandaloneSidebar.ts,standaloneChat/StandaloneSideNav.tsx,Subscription/hooks/useSourceManager.ts,Subscription/CreateChannel/AddSourceDropdown.tsx,Subscription/ArticleList/ArticleList.tsx}, client/components/Nav/Nav.tsx, relevant focused tests for these files, and locale translations if needed (coordinate parent before editing shared locale JSON). Parent has modified useSourceManager catch for WeChat ApiError/canceled/global; preserve that. Parent owns every other client file and is still changing them; do not edit them. Legacy Chinese in useAppSidebar must be localized per i18n-localizer; all3locales in same change, consult parent before sharedJSON write. All comments English.
+
+Remaining requirements: (1) App and standalone server conversation-list fetch must keep rejection/fullerror; do not return [] as generic failure. Preserve guest local conversation behavior and in-memory unsaved draft merge. Add UI failed-list state/ApiRequestFailure and retry in SideNav. Catch effect + rename/delete callback terminal promises; only successful fetched list drives auto-select, new placeholder, historyLoaded, navigation. See all fetchConversations call sites before rejecting helper. Existing standalone auth resolved403 branch is dead before F088 because skip403Redirect=true always rejected; remove dead branch, do not invent new403businessbranch. Guest catch resolveGuestAccessState has named26401/26402/26403 recovery and must stay. Authentication is owned by interceptors, preserve. (2) SourceManager default list/search/pagination catches currently silent/missing; expose failed-source-list state to AddSourceDropdown and retry, retain prior data, do not create fake successful empty result. Canceled/stale WeChat requests must not display ordinary errors; WeChat failure code recovery already exists and tests green. (3) ArticleList main article loader catch clears page1 [] and hides failure; expose first-page/load-more errors and retry, preserve existing articles, keep unread count best-effort fallback0 unchanged. (4) Nav live useConversationsInfiniteQuery must show persistent query error/retry and distinguish empty history; preserve search-result semantics/currentquery keys. Defaultrequest failure metadata retained. No hook/global policy overhaul. Parent already fixed real conversation deletion UI onError in useDeleteConversationConfirm, leave it.
+
+Validation: TDD focused behavior tests on failures and successful continuations, using jest --runInBand --coverage=false --forceExit. Full barrels ~/hooks cause existing filenamify ESM issues, mock unrelated hooks/providers for unit isolation; do not install dependencies. pnpm client typecheck and changed-file eslint. Known unmodified client lint baseline ~30errors in chatApi/CitationReferencesDrawer/Skills skillMenuSelection/useSubjectKeyword; no new suppression or ts-strict-ignore. Parent will run full gates and independent review. Write full report to plan workspace task-1-report.md with changed paths, call chains, test commands/output, and unresolved concerns; return DONE/DONE_WITH_CONCERNS/NEEDS_CONTEXT and concise test summary. Do not claim full closure if owned active chains still unhandled or falsely empty.
diff --git a/features/v3.0.0/088-request-error-contract/client-migration-report.md b/features/v3.0.0/088-request-error-contract/client-migration-report.md
new file mode 100644
index 0000000000..566cc25762
--- /dev/null
+++ b/features/v3.0.0/088-request-error-contract/client-migration-report.md
@@ -0,0 +1,85 @@
+# Task 1 report — remaining default client HTTP UI chains
+
+Status: DONE_WITH_CONCERNS (owned implementation complete; parent-owned suppression pruning/full gates remain, and one existing test has a Node/global-setup incompatibility).
+
+Worktree: `/Users/shanghang/.codex/worktrees/request-error-contract/bisheng` only. No commits, repository-wide Git writes, dependency changes, global request-policy changes, or shared locale edits were made. Parent authorized the adjacent article controller extraction after the original component was measured at 813 lines.
+
+## Changed production paths
+
+All paths below are relative to the worktree:
+
+- `src/frontend/client/src/pages/appChat/hooks/useAppSidebar.ts`
+- `src/frontend/client/src/pages/appChat/SideNav.tsx`
+- `src/frontend/client/src/pages/standaloneChat/hooks/useStandaloneSidebar.ts`
+- `src/frontend/client/src/pages/standaloneChat/StandaloneSideNav.tsx`
+- `src/frontend/client/src/pages/Subscription/hooks/useSourceManager.ts`
+- `src/frontend/client/src/pages/Subscription/CreateChannel/AddSourceDropdown.tsx`
+- `src/frontend/client/src/pages/Subscription/ArticleList/ArticleList.tsx`
+- `src/frontend/client/src/pages/Subscription/ArticleList/useArticleList.ts` (new, parent-authorized adjacent controller)
+- `src/frontend/client/src/components/Nav/Nav.tsx`
+
+## Actual API → consumer → UI behavior
+
+1. `getAppConversationsApi` → `useAppSidebar` / `useStandaloneSidebar` → `SideNav` / `StandaloneSideNav`:
+   - Server-list rejection retains and rethrows the same full error, sets `listError`, preserves the last list, and no longer returns fake successful `[]`.
+   - Existing `ApiRequestFailure` renders persistent failure with retry. Failure/loading is distinct from successful empty history.
+   - Initial app placeholder creation, standalone active-chat selection, and standalone `historyLoaded` now follow successful list retrieval only. Explicit retry executes the still-pending successful initialization.
+   - Reviewer-requested regression reproduced initial A failure → user creates/navigates to B → retry returning stale A. Retry now reads the latest `conversationIdRef` and `localizeRef`, preserving B without adding unstable navigation/localization dependencies to the initialization effect. The regression failed with `[failed-a]` before the fix and passes with `[draft]` afterward.
+   - Mount effect consumes its terminal rejected Promise. Every rename/delete refresh callback catches its terminal Promise. Failed refresh after deletion does not navigate/select from a fabricated empty result.
+   - Guest local-storage list/rename/delete behavior and the standalone module-level unsaved draft merge remain. Test exercises auth and guest draft preservation.
+   - Removed the old auth resolved-403 branch because `skip403Redirect=true` already made it unreachable; no new business-layer 403 policy was added. Existing guest `resolveGuestAccessState` catch remains unchanged.
+   - Removed four hardcoded Chinese strings from `useAppSidebar`: three reuse existing translations; `com_app.history_deleted` was added in all three locales by the parent after coordination. No direct shared JSON edit by this child.
+
+2. `listManagerSourcesApi` / `searchManagerSourcesApi` → `useSourceManager` → `AddSourceDropdown`:
+   - Initial list, initial search, list pagination, and search pagination now retain complete errors in `sourceListError` and preserve available rows.
+   - Picker renders `ApiRequestFailure` and retries either page 1 or the failed next page. Pagination numbers only advance on success.
+   - Unknown/failed search no longer presents a successful no-result/crawl suggestion. Loading and failure retain list mode; ordinary successful no-result semantics remain.
+   - Source-list and search effect cleanup ignores stale completions; pagination checks its captured query key. Automatic scrolling stops on failure until explicit retry.
+   - Preserved the parent's WeChat normalized business/canceled/global catch. Added one verified stale-request fix: changing from a running pasted WeChat link to an ordinary search invalidates the old token on cleanup. The regression test first failed because the old rejection incorrectly opened the unrecognized-link dialog; it now stays quiet. Existing business/canceled/global tests remain green.
+
+3. `getArticlesApi` → extracted `useArticleList` → `ArticleList`:
+   - Main page-1 and load-more failures have separate complete-error states and explicit retry. Existing articles survive failed refresh/pagination; page counters and row changes occur only after success.
+   - Failed initial load is distinct from successful no content. Failed pagination stops automatic retries; retry loads the failed page and keeps prior rows.
+   - Query-key/request-sequence checks prevent a previous filter/channel request from overwriting the current load state.
+   - Channel switching still drops the previous channel's articles and resets filters before its next load. Source defaults, article-read updates, query keys, copy/share behavior, and unread-count best-effort fallback remain.
+   - Existing unread badge fallback remains `subChannelUnreadCounts || {}` with `?? 0`, `retry: false`; it was not converted to a main-list failure.
+   - Extracted existing data/controller/mapping code to meet the 600-line cap: `ArticleList.tsx` 541 lines; `useArticleList.ts` 331 lines. Existing mapping exports remain re-exported for compatibility. No new Recoil application imports were introduced.
+
+4. `listConversations` → `useConversationsInfiniteQuery` → `Nav`:
+   - Consumes the existing query's `isError/error`, displays persistent `ApiRequestFailure`, and retries via the existing `refetch`.
+   - Successful empty history gets the existing empty-history copy. Cached rows stay visible with the error during failed background/page loads.
+   - Automatic normal-history pagination stops while failed. Search results and their pagination remain driven by the existing search query response and are not replaced by the normal-history failure.
+   - Existing query keys, API rejection metadata, and deletion-confirm behavior were untouched.
+
+## Test changes and red/green evidence
+
+- New `pages/appChat/hooks/sidebarRequestErrors.test.tsx` (4 tests): original code failed the first two tests because `listError` did not exist and server failures completed initialization as empty success. Now verifies same-error rejection, list preservation, successful-only initialization, retry, auth drafts, and guest local history/drafts.
+- Extended existing `pages/Subscription/hooks/useSourceManagerErrors.test.ts` (8 tests total): original list/search/pagination paths failed the four added load/error tests; stale WeChat test separately reproduced the late-error dialog. Now all pass, including original normalized business/canceled/global recovery checks.
+- New `pages/Subscription/ArticleList/ArticleListErrors.test.tsx` (2 tests): original main loader logged and cleared rows; failure text/retry was absent. Now verifies initial error vs successful empty, previous articles on failed reload, failed next-page recovery, and continued successful append.
+- New `components/Nav/NavRequestErrors.test.tsx` (2 tests): original component lacked error and successful-empty UI. Now verifies persistent error/retry, successful empty state, and preserved search results. Retry assertion clears the mount refetch call before clicking, so a disconnected retry handler cannot pass it.
+
+Final focused command (cwd `src/frontend/client`):
+
+```sh
+JEST_JUNIT_OUTPUT_DIR=/private/tmp/f088-child pnpm exec jest src/pages/appChat/hooks/sidebarRequestErrors.test.tsx src/pages/Subscription/hooks/useSourceManagerErrors.test.ts src/pages/Subscription/ArticleList/ArticleListErrors.test.tsx src/components/Nav/NavRequestErrors.test.tsx --runInBand --coverage=false --forceExit
+```
+
+Result: **4 suites / 16 tests passed**, exit 0 (final run after reviewer correction: 3.286 s).
+
+Additional guest recovery regression:
+
+```sh
+JEST_JUNIT_OUTPUT_DIR=/private/tmp/f088-child pnpm exec jest src/pages/standaloneChat/guestAccessError.test.ts --setupFilesAfterEnv=@testing-library/jest-dom/extend-expect --runInBand --coverage=false --forceExit
+```
+
+Result: **1 suite / 9 tests passed**, exit 0. The same existing test under the repository's default setup fails before running tests: its `@jest-environment node` encounters unmodified `test/setupTests.js:59`, which reads `Element.prototype` without a Node guard. No out-of-scope setup edit was made. The explicit setup override isolates the pure guest-recovery tests; it does not hide an assertion failure.
+
+Scoped ESLint command used all nine production files and four focused test files with `--suppressions-location eslint-suppressions.json --pass-on-unpruned-suppressions`: **exit 0, no diagnostics**. Without `--pass-on-unpruned-suppressions`, the same zero-diagnostic run exits 2 only because localization/extraction shrank existing suppression counts. Parent was notified and owns the global `pnpm lint:prune`/quality file.
+
+`pnpm typecheck` from client: **final run passed all 1490 strict files**, exit 0 (after reviewer-requested stale-conversation correction). An intermediate concurrent run had failed only in parent-owned `src/hooks/chatHistoryErrors.test.tsx:40` (`TS2554: Expected 2 arguments, but got 1`); the parent repaired that test before the final passing run. No owned file produced a type diagnostic. `git diff --check` on all owned tracked paths: exit 0.
+
+## Remaining checks / concerns
+
+- Parent explicitly owns and will execute full client/frontend gates, suppression pruning, E2E, and independent review. This report closes only the assigned active chains; it does not claim all client AC-21 consumers are verified.
+- Known default Node-test setup incompatibility above must remain visible in the full-gate result; it is unrelated to these implementation paths.
+- No browser/manual backend validation was performed by this child. Focused tests execute real hooks/components, mock the external API boundary and unrelated heavy providers, and use the shared real Alert-based `ApiRequestFailure` where the component is exercised.
diff --git a/features/v3.0.0/088-request-error-contract/design-independent-review.md b/features/v3.0.0/088-request-error-contract/design-independent-review.md
new file mode 100644
index 0000000000..efba9bacb2
--- /dev/null
+++ b/features/v3.0.0/088-request-error-contract/design-independent-review.md
@@ -0,0 +1,34 @@
+# F088 独立设计审查
+
+日期:2026-10-22。审查者:独立 agent `review_error_contract_design`。结论:**NEEDS_FIX**。
+
+审查范围:spec/design/tasks/discovery/候选清单、SDD design 清单、release-contract、constitution;静态核对两端请求拦截器、React Query 4.44.0 查询工厂与配置回归、保存/刷新/发布链、登录和技能导入的实际调用。未改产品代码、安装依赖或重跑测试。主 agent 随后核读关键证据,确认下列问题应在设计确认前解决。
+
+## 1. 高:分批切换缺少明确兼容协议
+
+位置:[design.md:42](./design.md#决策-3分批迁移最终取消业务包装层吞错)。当前方案先标准化底层再逐批迁移,但仅保留旧包装层无法保护字段与 resolve/reject 同时变化的活跃链路。
+
+- `src/frontend/platform/src/pages/LoginPage/login.tsx:185` 仍依赖数字 `error.code === 10601`。新模型的 code 只用于 transport 字符串;若先切底层,密码过期重置分支失效。
+- `src/frontend/client/src/api/skillCenter.ts:121` 将 11053 恢复为“尚未导入”。底层先 reject 会使恢复分支不可达,`src/frontend/client/src/components/Skills/SkillUploadDialog.tsx:126` 在创建前的检查中退出。
+- 同上传页第 73 行通过 `instanceof SkillRequestError` 及旧字段识别重名覆盖。新模型先切换而未同步转换时,该分支失效。
+
+影响:违反 AC-14、AC-20;文档中的“逐批”不能保证每批业务可用。
+
+建议:明确入口切换与哪些完整调用链必须同批迁移,或者给出临时按请求切换/适配合同;列出密码过期、未导入、新建和重名覆盖的兼容测试。不能以候选引用清单替代该切换规则。
+
+## 2. 中:解码二进制错误后缺少传入共同模型的通道
+
+位置:design.md 第 99 行的 normalizeApiError 接口、第 132 行的二进制处理约定。
+
+触发:HTTP 错误携带 JSON Blob/ArrayBuffer。设计要求共同包不读取二进制、原 response/cause 保留,同时提取业务码和附加信息;目前接口只有 cause、message、response,没有独立的已解码 envelope 输入。
+
+影响:改写 response.data 会损坏原始证据,改用解析结果作为 cause 又丢失原异常;当前合同不足以同时满足这些要求。`src/frontend/client/src/api/messageExport.ts:89` 已有实际本地解码路径,不能把二进制错误视为没有调用方的假设分支。
+
+建议:增加独立解析结果输入,明确其与原 cause/response 的提取优先级。验证 HTTP 200 及非 2xx 二进制错误,断言业务码、附加信息、原异常、原 response 和原二进制引用同时保留。
+
+## 其他结论与限制
+
+- React Query v4 的 refetch/mutateAsync、失败 null 缓存及既有离线配置的处理方向合理,未发现 constitution 冲突。
+- 这是设计审查,不代表实现、全量活跃调用审计或回归测试已完成。
+- 先前 design-review.md 是作者清单自审;本独立审查发现新增缺口,因此当前不得以该自审 LGTM 视为设计可直接确认。
+- 本轮仅记录问题,未修改已提出的设计方案;后续修订需逐项标明处置并保持 spec 不变。
diff --git a/features/v3.0.0/088-request-error-contract/design-review.md b/features/v3.0.0/088-request-error-contract/design-review.md
new file mode 100644
index 0000000000..ad5c6c248b
--- /dev/null
+++ b/features/v3.0.0/088-request-error-contract/design-review.md
@@ -0,0 +1,44 @@
+# F088 design 清单审查
+
+日期:2026-10-22。方式:依 `.claude/skills/sdd-review/references/design-checklist.md` 的 24 项清单进行作者自审;无独立 reviewer,不等同代码审查或完整运行验证。
+
+## 输入与范围
+
+读取 spec、design、tasks 状态表、设计模板、v3.0.0 release-contract 及 constitution。以 `bc6ca25ba` 实际源码和 4.44.0 安装版本为基线,纳入用户提醒的 React Query 升级。
+
+## 检查结果
+
+| 清单项 | 核对结论 |
+|---|---|
+| 1–2 目标和非目标 | §1 与已确认的 spec 一致;不扩大到后端、成功协议、查询库升级及其他通道 |
+| 3–4 约束与 Constitution | §2 引用全局约束,不重写架构法律;无后端越层、权限绕过、新码段、状态库或 HTTP store 依赖 |
+| 5–8 关键决策 | §3 五项决策各有被否方案、代码/行为证据、取舍和重新考虑条件 |
+| 9–12 现状和职责 | §4 分开标注当前代码与拟议目标;包含失败与成功主线、共同错误字段、职责与原全局处理矩阵 |
+| 13–15 已知坑 | §5 记录 null 成功缓存、refetch 默认不抛错、异步 mutation 终点、内网模式、重试提示及写入/刷新分离,均给出影响与处理位置 |
+| 16–18 契约依赖 | §6 覆盖共同包、Axios/响应/业务消费、QueryCache 401 及 i18n 依赖;无新增 HTTP/Python API,并说明成功结构不变 |
+| 19–20 验证和操作 | §7 区分已运行升级基线与计划验证;给出实际测试命令、本地入口和故障注入步骤,不虚构账号凭据 |
+| 21 后续边界 | §8 明确特殊结果保留、QueryClient 不全面统一及 UI 改版不纳入 |
+| 22–24 修订一致性 | 修订记录和 tasks 基线更新一致;spec 已确认、design 待确认、产品代码尚未实施,未以文档冒充交付 |
+
+## AC 对照
+
+| AC | 设计落点 |
+|---|---|
+| AC-01、AC-02、AC-03、AC-04 | §3 决策1、§4.2、§4.3、§4.5:真实失败、共同错误与原证据 |
+| AC-05、AC-20、AC-21 | §3 决策3、§6.1、§7.2:逐批迁移、终止吞错与完整清单闭环 |
+| AC-06、AC-07、AC-08、AC-09、AC-10 | §3 决策2和决策5、§4.3、§4.5:提示归属、翻译、取消及全局例外 |
+| AC-11、AC-12、AC-13、AC-14 | §3 决策4、§5、§7.2:真实错误状态、输入保留、成功才更新与命名恢复 |
+| AC-15、AC-16 | §4.5、§6.2、§7.2:保留全局分支顺序、并发/节流以及完整失败证据 |
+| AC-17、AC-18、AC-19 | §4.2、§4.5、§8:成功和特殊结果、二进制及不可读错误 |
+
+## 已执行证据
+
+- 升级新增/关联 4 文件 8 测试通过,见 design §7.1;最初 pnpm filter test 调用参数被 pnpm 拒绝,随后改用实际成功的 pnpm exec vitest 命令,没有将启动失败计为测试失败或通过。
+- 安装版 4.44.0 的最小断言通过:reject 保留错误、catch null 成为 success、默认 refetch 返回 isError、throwOnError 才 reject。
+- 未执行 F088 实现单测、页面回归、lint/typecheck、E2E;产品代码仍未实施。
+
+## 清单输出
+
+LGTM
+
+此结果仅表示作者未发现设计清单中的未解决问题;design 仍须依项目规定由用户确认。
diff --git a/features/v3.0.0/088-request-error-contract/design.md b/features/v3.0.0/088-request-error-contract/design.md
new file mode 100644
index 0000000000..acb837aeec
--- /dev/null
+++ b/features/v3.0.0/088-request-error-contract/design.md
@@ -0,0 +1,244 @@
+# Design: 前端请求错误完整透传
+
+**关联**:[spec.md](./spec.md) · [tasks.md](./tasks.md) · [migration-inventory.md](./migration-inventory.md)
+**版本**:v3.0.0 / F088
+**最后更新**:2026-10-22
+**状态**:spec/design 已确认,产品实现已落地,正在最终回归与独立复核。§4.1保留实施前基线作为对照,§4.2起与末尾实施同步描述当前实现;验收结论见tasks与verification-report。
+
+## 1. 目标与非目标
+
+请求失败始终向调用方传递可辨认的完整原因;统一提示和业务恢复各自履责,失败不能触发成功后的动作。两端使用相同错误模型,保留各自的成功结果和既有全局处理。
+
+不调整后端协议、授权规则、请求库版本、查询库版本、状态管理或提示样式,不扩展 SSE/WebSocket/fetch 通道。允许已有明确业务恢复,禁止通用吞错伪装成功。
+
+## 2. 关键约束与最新基线
+
+- 遵循 [constitution.md](../../../docs/constitution.md) 和版本契约 [release-contract.md](../release-contract.md) 的 V3-INV-6;本次无后端或数据库变更,无新错误码。
+- 原生隔离工作区从发版线快进至 `bc6ca25ba`,已包含 React Query 升级 `219bb0e8a`。两端使用 catalog 固定的 `@tanstack/react-query` **4.44.0**;platform 原 v3 描述不得继续作为实施依据。
+- 保留 platform 新建的 `createPlatformQueryClient()`、数组 queryKey、`offlineFirst`、queries 默认 `retry: 0`、禁止窗口聚焦和挂载自动刷新,以及既有重连刷新行为。不重新创建 provider 或查询缓存。
+- client 当前查询默认未覆盖重试次数;必须考虑新暴露的 HTTP 200 业务失败会进入查询重试。既有网络/HTTP 重试预算保持原值,不新增业务错误自动重试。
+- UI 使用现有通知、表单和状态展示,遵循当前共享组件规范;此特性不重新选择 Toast/Notification 的外观或全面迁移提示组件。
+- `silent`、`skip403Redirect`、`skipMaintenance` 先保留各自已有特殊行为,再迁移普通提示语义;不能借统一错误处理扩大免认证或免权限处理范围。
+
+## 3. 方案对比与选定
+
+### 决策 1:共同错误模型,两端保留请求适配器
+
+- **备选**:A. 完全合并两端 Axios 与成功结果;B. 各端复制一份错误模型;C. 一个内部错误模型包,两端独立处理本端响应与界面。
+- **选定**:C。新增无 UI、无状态库、无网络调用的 `@bisheng/api-client` 内部包,只承载错误类型、规范化与失败分类;两端自行翻译和提示。
+- **原因**:platform 成功返回业务 data,client 的普通包装方法返回响应 body,完整响应方法还用于 headers。合并成功结构会破坏大量无关调用;复制模型则无法保证 AC-04 的长期一致。
+- **重新考虑条件**:两端成功协议与登录路由真正统一,或该包只有单端使用;届时再评估合并请求适配器或内联。
+
+### 决策 2:完整错误与提示状态独立
+
+- **备选**:A. 提示后 reject null;B. 业务自己解析并提示所有错误;C. 始终 reject 同一规范化错误,记录是否已展示。
+- **选定**:C。取消、静默和已全局处理仍传递失败;提示辅助函数在同一错误对象上保证不重复展示。表单专用处理关闭普通提示并标记已在表单展示。
+- **原因**:当前 platform HTTP 失败丢失异常,旧包装层再把失败变为 false/undefined;Dashboard mutation 的本地 onError 与拦截器会重复提示。记录“已提示”只控制展示,绝不把失败变成成功。
+- **重新考虑条件**:将来需要多个不同视图同时展示同一失败;届时可扩展展示作用域,而非恢复吞错。
+
+### 决策 3:分批迁移,最终取消业务包装层吞错
+
+- **备选**:A. 一次改变旧包装层返回值;B. 永久保留 false/undefined 作为失败;C. 先让底层保留错误,逐批迁移调用方,再清理包装层。
+- **选定**:C。底层标准化后,旧包装层只作为临时兼容入口;新/已迁移代码直接消费 reject。所有调用方完成迁移后删除或改成仅用于提示且继续 reject 的入口,最终不能再返回失败哨兵值。
+- **原因**:基线有 111 个非测试源文件引用该包装层(含定义文件),既有 `.then(...)`、未 await 的点击处理及按 false 判断等多种消费方式。全局翻转会产生未处理 rejection 和失败后的成功动作。
+- **重新考虑条件**:迁移清单证实剩余入口全部不可达,可删除死代码及引用;不能为死路径重新接线。
+
+### 迁移切换协议(独立审查修订)
+
+迁移采用“先兼容消费方、后原子切换入口”的 expand/contract 顺序,不在调用方尚未准备好时全局改变错误字段或 client resolve/reject。
+
+1. 共同模型先独立落地、测试,不改变旧入口。消费方先兼容完整错误,客户端 API 的命名恢复同时兼容旧返回体与新 reject;现有全局流程保持原顺序。
+2. 必须与入口切换同批:登录 10601 数字 code→status_code;技能 11053 未导入恢复、11055 重名检查/覆盖、详情缺包与文件错误;二进制导出/下载的错误解析;QueryCache 的 response.status 消费。入口切换前,这些链路用旧和新两种失败输入均通过。
+3. platform 旧包装层先适配完整错误的 message/presentation,保持旧调用方可用;逐个消费链改为直接请求与明确失败分支。最终切换时无业务依赖失败哨兵值,旧包装层不再吞错。
+4. 本地批次只在特性分支推进;允许单测驱动的中间提交,不允许发布尚未完成调用迁移的入口切换提交。交付单位是入口、上述强耦合链及完整迁移清单的同一完整特性;最终不留下按请求选择是否传递失败的开关。
+5. 每批验收包含失败后不成功跳转、不覆盖输入、不增加提示与恢复次数;最终扫描旧错误字段、特殊异常 instanceof 及 false/null 哨兵判断,逐项确认死路径或业务恢复,不按纯文本机械替换。
+
+### 决策 4:保留升级后的查询行为,只修复错误消费
+
+- **备选**:A. 查询失败统一返回 null 以避免 v4 的 undefined 限制;B. 在 QueryCache 再加一层全局 Toast;C. queryFn 保持真实失败,在实际消费点恢复页面并由唯一归属提示。
+- **选定**:C。queryFn/mutationFn 不使用吞错包装层。已有合法空配置默认值只用于成功响应。对于顺序依赖的手工刷新,使用 `refetch({ throwOnError: true })` 或明确检查返回结果的 `isError`;不可假定 await refetch 会抛错。
+- **原因**:升级新增的配置测试明确断言“请求失败 → null 缓存 → isSuccess=true”。安装版 4.44.0 的最小实验证实 reject 会保留原错误,而 catch 返回 null 会变成 success;默认 refetch 会 resolve 含 isError 的结果。见 §7 的验证记录。
+- **重新考虑条件**:某业务明确把特定失败当作有效空值并写入 AC,例如许可证状态不可用时隐藏;只有此类命名恢复才允许返回有效业务值。
+
+### 决策 5:沿用已有重试预算,避免新业务失败自动重试及重复提示
+
+- **备选**:A. 两端全部关闭重试;B. 拦截器每次失败都弹窗;C. 保留网络/HTTP 的已配置预算,对新暴露的业务失败停止自动重试,重试查询在最终失败时提示。
+- **选定**:C。共享 `shouldRetryApiError(error)` 对 business、canceled、invalid-response 及已触发全局接管的错误返回 false;其他错误交由原调用方预算决定。platform queries 默认仍为 0;Workbench 显式 retry:1 等逐处组合;client 默认预算仍为 3,显式 false/次数/函数逐处保留语义。
+- **原因**:client 当前有业务失败 resolve;直接统一 reject 会激活原本不会发生的重试。不能因此增加失败请求和 Toast 次数,也不应取消已有网络恢复机会。
+- **重新考虑条件**:产品明确要求某业务错误可重试,另以业务 AC 和幂等性证据制定例外;此特性不新增此类恢复。
+
+## 4. 数据流与当前实现
+
+### 4.1 实施前已核实基线
+
+| 模块 | 当前职责与行为 | 边界 |
+|---|---|---|
+| `platform/src/controllers/request.ts` | 解包业务成功、翻译错误、处理会话/权限/许可证;普通业务失败通常 reject 字符串,HTTP 失败通常提示后 reject null | `preserveError`、`silent` 只是局部绕开;不能保证完整透传 |
+| 同文件旧包装层 | 提示并 catch,返回 false/undefined 或取消标记 | 会掩盖失败,不适合 queryFn 或需要依赖失败分支的流程 |
+| `platform/src/queryClient.ts` | 新升级后统一创建 provider 使用的 QueryClient | 本次复用其配置,不回到 v3 或混用两个缓存 |
+| `platform/.../WorkbenchModel.tsx` | 配置查询先经过旧包装层再 `env ?? null`,成功 select 提供默认配置;保存后手工 refetch | 失败会成为成功缓存;保存失败后仍可能刷新并覆盖输入 |
+| `platform/.../useSystemConfigEnvelope.ts` | 4 个配置页的 effect 查询,finally 结束 loading | 当前没有错误出口,迁移需记录错误并阻止未加载配置的误保存 |
+| `platform/.../Dashboard/hook.ts` 与 `EditorHeader.tsx` | mutation 的 onSuccess 更新缓存/提示;onError 提示;mutateAsync 串联保存和发布 | 拦截器已有提示时会重复;链路需要在事件终点处理 rejection |
+| `platform/.../charts/ChartContainer.tsx` | 手工 disabled 查询使用 isInitialLoading;刷新后显示图表 | 当前错误和无数据共用占位,需使失败可辨认,保留升级修正的 loading 语义 |
+| `client/src/api/request.ts` | 保留响应 body;部分业务失败仅按 showError 提示后继续 resolve;另一些路径 reject 含业务码的 Error | showError/skip403Redirect 不应再决定失败与否 |
+| `client/src/App.jsx` | QueryCache.onError 将 HTTP 401 交给现有错误上下文;默认未覆盖查询 retry | 必须保留 `.response.status` 证据,不额外增加全局 Toast 层 |
+
+### 4.2 当前主线
+
+请求响应或 transport 异常 → 本端识别响应形态、必要时解码错误 body → 本端翻译消息 → 共同模型标准化 → 保留既有全局处理/普通提示归属 → reject 同一错误 → API 不吞错 → Query/Mutation 或业务 catch 恢复状态 → 事件终点明确结束失败流程。
+
+成功主线不变:platform 仍返回既有解包结果;client 普通方法仍返回 body,完整响应方法仍保留 headers。合法无 status_code 响应不当作失败。未知格式只在已有协议需要明确 envelope 时作为 invalid-response;不得把所有非 envelope 结果一律改为失败。
+
+### 4.3 共同错误契约
+
+包名 `@bisheng/api-client`;入口 `src/frontend/packages/api-client/src/index.ts`。无 React、i18next、Axios 运行时或 Toast 依赖;对原始响应采用结构类型,不复制或 JSON 序列化证据。
+
+| 字段 | 类型 | 含义 |
+|---|---|---|
+| `name` / `message` | `ApiError` / string | 标准 Error;message 是本端翻译后可读消息 |
+| `kind` | business / http / network / timeout / canceled / invalid-response | HTTP 200 业务失败为 business;HTTP 错误为 http,同时保留 body 中业务码 |
+| `httpStatus` | number 或 undefined | 实际 HTTP 状态,绝不用业务码填充 |
+| `status_code` | number 或 undefined | 服务端真实业务错误码,作为两端业务分支唯一规范字段 |
+| `status_message` | unknown | 服务端原始消息,数组/对象不能被强制丢弃 |
+| `data` | unknown | 服务端附加错误信息 |
+| `response` | `{ status: number; data: unknown }` 的原始结构对象或 undefined | 保留原响应引用及其额外字段,兼容已有 `.response.status/data` 消费 |
+| `code` | string 或 undefined | 原 transport 标识,例如 ERR_CANCELED;不放密码过期等数字业务码 |
+| `cause` | unknown | 原始异常或失败证据引用,不自动写日志 |
+| `presentation` | none / toast / inline / global | 是否已展示或被全局处理接管;不改变 kind 或 reject 行为 |
+
+接口:`normalizeApiError(cause: unknown, options: { message: string; response?: ApiErrorResponse; envelope?: unknown }): ApiError`;输入已是 ApiError 时原样返回,保持对象身份。`isApiError(value: unknown): value is ApiError`;`shouldRetryApiError(error: unknown): boolean` 只决定错误种类是否允许使用原有重试预算,不决定次数。
+
+二进制解析由本端异步适配器完成,`envelope` 专门接收已解析结果,不能改写 `response.data`。原 `cause`、原 `response` 和原二进制引用均保留;规范化字段提取优先级为显式 `envelope` → 原 response.data 中合法 envelope → cause 自身合法 envelope。只有明确的 envelope(有限数字 status_code)参与业务字段提取;undefined 表示没有解析结果,不用空对象覆盖有效原信息。HTTP 状态始终取原 response.status;消息始终使用本端已翻译的 message。已规范化错误原样返回,不能二次覆盖证据。
+
+两端各导出 `showApiError(error: unknown): ApiError`:使用本端消息解析与既有提示 API,跳过 canceled、已提示和 global,真正展示后标记 toast。业务自行展示表单/业务消息时标记 inline/toast,不能通过返回 false 把失败吞掉。普通程序异常不冒充业务码。
+
+### 4.4 文件与模块职责
+
+| 文件/范围 | 做什么 | 不做什么 |
+|---|---|---|
+| `packages/api-client/package.json`、`src/apiError.ts`、`src/index.ts`、对应 Vitest/TS/ESLint 配置与测试 | 同一错误类型、规范化、展示状态与重试分类 | 不请求、不翻译、不依赖 UI/查询库 |
+| 两端 `request.ts` 与拆出的 `apiError.ts`/消息解码模块 | 各端响应适配、消息翻译、提示和全局流程 | 不返回失败哨兵值、不改变成功结构 |
+| `platform/src/types/global.d.ts` 与 client Axios 扩展声明 | 补齐 showError 及已有选项类型 | 不新增全局免认证选项 |
+| 两端 package.json、lockfile、根 lint/typecheck 编排 | 接入一个 workspace 内部包及对应质量门禁 | 不修改 catalog 已锁版本、不引入新三方运行库 |
+| API、hooks、事件处理器 | 透传失败、恢复 loading/乐观状态、阻止成功后续动作 | 不重复翻译同一错误、不把失败列表改为空成功 |
+
+### 4.5 响应和全局处理矩阵
+
+| 输入/选项 | 目标行为 |
+|---|---|
+| HTTP 成功 + status_code=200 | 原成功结构原样可用 |
+| HTTP 成功 + 明确非 200 业务码 | 除已登记的特殊结果外,规范化 business 并 reject,与提示开关无关 |
+| 无业务码的合法 JSON/二进制/第三方响应 | 按原接口协议成功,不强加统一 envelope |
+| HTTP 非 2xx,包括合法 envelope body | kind=http,保留 HTTP 状态、业务码与完整 body;提示后仍 reject |
+| 无响应的网络失败/超时 | 保留 cause 和 transport code;区分 timeout/network;不伪造响应 |
+| 主动取消 | kind=canceled、保留 ERR_CANCELED;不 toast,不触发普通失败提示 |
+| platform showError 未设置 | 普通失败由统一提示负责;已有包装层和本地通用提示同步去重 |
+| client showError 未设置 | 迁移时显式固定各入口当前提示归属;不把所有查询新增为自动弹窗 |
+| showError=false | 只关闭普通提示,不改变失败与全局处理;调用方仍取得 ApiError |
+| platform silent=true | 作为已有兼容语义保留其现有分支顺序:HTTP 401 仍先处理,业务响应原有全局处理跳过行为不扩大;错误载体统一。已无特殊需求的调用迁到 showError=false;仍有特殊合同的调用保留并登记 |
+| client skip403Redirect / skipMaintenance | 仅保留其对应既有全局处理例外;业务失败始终 reject。原 skip403Redirect 隐式 toast 的入口迁为明确 showError=true,不让跳转选项决定普通提示 |
+| 登录失效/被踢/权限跳转/许可证/维护 | 沿用既有条件、排除 URL、确认顺序、节流与目标路由;执行后标记 global 并 reject,含 client 当前维护/403 resolve 分支 |
+| 密码过期 10601 | 标准 status_code=10601;登录页继续进入重置流程,不再依赖数字 error.code |
+| 11010 特殊结果 | 保留已有显式放行,暂不改变成错误;当前无活跃业务语义证据,绝不为其新增恢复路径;作为遗留兼容项独立测试 |
+| Blob/ArrayBuffer 成功 | 保留下载、音频和 headers;不能消费或修改有效二进制 |
+| Blob/ArrayBuffer 错误 | 有 HTTP 失败或可信 JSON 错误类型线索才解码;保留原 response/body 与业务证据。无效文本不伪装成功,不把 JSON 普通下载任意当错误 |
+
+### 4.6 实际消费边界
+
+- platform服务/API返回拒绝;UI事件/effect在终点消费,确认框接收原Promise以锁住重复提交,编辑器在成功后关闭。默认通知只由同一ApiError的presentation记录去重。
+- client保留原认证/刷新响应阶段,先用解码视图执行原全局动作,再在末级统一拒绝;WeakMap按原response记录实际发生的toast/global,原response/data/cause不变。
+- Blob成功下载原样返回;只有可信JSON类型或实际HTTP失败才解析错误,解析结果独立传入规范化,不覆盖原二进制。
+- 列表与聊天历史有可辨认失败状态和重试入口。文件/文章/空间会话清空在服务端成功后更新本地;资源token、effect cleanup及会话身份检查阻止延迟结果覆盖新资源,清空pending期间锁住发送、重新生成和重复清空。
+- Workbench写入与后续刷新分别处理:写入成功立即记下已接受配置,刷新失败保留输入/旧缓存并单独反馈,不再次写入。真实v4工厂回归见WorkbenchModel.commit.test.tsx。
+- preview轮询HTTP仍沿用callback通道,失败事件携带原ApiError对象;任务自身的解析状态错误继续沿用原专业展示,不混作HTTP错误码。
+- 审批场景的路由、流程及节点读取全部成功后统一提交;一半读取失败保留原场景、选择和未保存草稿。
+
+## 5. 已知坑与处置
+
+| 反直觉事实/证据 | 忽略的结果 | 处理位置 |
+|---|---|---|
+| v4 不能缓存 undefined,但可以缓存 null;null 并不代表 error。新增配置测试已断言失败被标为 success | 查询看起来完成,默认空配置可能覆盖已有输入 | WorkbenchModel queryFn、queryConfigMigration 测试 |
+| v4 默认 refetch resolve 包含错误状态的结果;本地 4.44.0 最小脚本已验证 | 保存/刷新链 await 后继续走成功动作 | 依赖刷新结果的 hooks 与事件处理器,使用 throwOnError 或检查 result |
+| mutate 使用回调消费错误;mutateAsync 仍返回拒绝的 Promise | 只写 onError 仍可出现未处理 rejection;后续发布/跳转链错误 | Dashboard EditorHeader、发布 hook、异步表单终点 |
+| platform 新 QueryClient 离线模式是为内网保留的,disabled 查询 spinner 已改为 isInitialLoading | 统一处理时误改 provider 或 loading 会重新卡住内网/手工加载页 | queryClient.ts、ChartContainer 与已有迁移测试 |
+| 同一 HTTP 失败可能先被拦截器提示,再被 mutation.onError 提示 | 用户连续收到两个 Toast/Notification | 统一 showApiError + presentation,清理相关 onError 直接提示 |
+| 显式重试的 hook 会产生多个不同错误对象,单纯按错误对象去重不能跨尝试去重 | 多次失败提示,即使最终查询只失败一次 | 该 hook 的 API 请求关闭普通提示,最终 onError 才 showApiError;多个观察者对最终同一错误对象去重 |
+| 保存已失败,旧包装层返回后仍执行 refetch;Workbench 现有代码可见该顺序 | 输入被服务端旧值覆盖,出现保存成功错觉 | Workbench submitConfig 直接 await reject;finally 只恢复 pending |
+| 写入已成功但后续刷新失败是两个不同结果 | 把已成功写入误报为写入失败,并诱发重复提交 | 顺序操作分别处理写入与刷新;刷新失败保留编辑态/输入,不自动重写或伪造回滚 |
+| 成功的空配置可有默认值;与请求失败不是同一种空 | 修复吞错时误删合法默认配置或继承 metadata | finetune envelope、useLinsightConfig select、配置页面 |
+| 许可证状态获取已有命名恢复;权限惰性读取也有明确“无额外动作”语义 | 机械删除 catch 会扩大业务变更 | license.ts、Dashboard 权限 hooks,逐项保留并测试,不泛化为列表失败恢复 |
+
+## 6. 对外契约与依赖
+
+### 6.1 提供给调用方
+
+- 共同 ApiError 与两端 showApiError,接口见 §4.3;统一 HTTP 入口的失败一律 reject。业务数字码使用 status_code,原 transport code 保留 string。
+- 两端原 HTTP 成功协议、完整响应和文件能力不变。没有新增 HTTP 或 Python API。
+- 旧包装层是迁移期入口,不是最终业务失败协议;最终完成要求以全调用清单而非入口开关为准。
+- 受影响的消费方:API 二次 catch/解包、Query/Mutation、直接表单/事件、上传 onFail、DSH 错误判断、登录密码过期、错误日志和下载 body 解析。风险为旧字符串比较、数字 code、null 哨兵值或重复提示继续遗留。
+
+### 6.2 外部与内部依赖
+
+| 依赖 | 合同与风险 |
+|---|---|
+| Axios 现有包装 | response、transport code 与 cancel signal 须保留;规范化不能销毁 response.headers 或把取消转为业务失败 |
+| 后端 envelope 与非 envelope 接口 | 分清 HTTP 状态、status_code 和业务 data;嵌套的配置 envelope 不能二次误解包 |
+| React Query 4.44.0 | 抛错才进入 error;refetch 与 mutateAsync 的拒绝语义不同;升级大版本需复验,当前不套用 v5 的 loading/callback 规则 |
+| platform queryClient 工厂 | 保留 offlineFirst、retry:0、重连行为和数组 key;不要新建第二个上下文 |
+| client QueryCache 401 监听 | `.response.status` 与同一错误对象须保留;不重复添加登录跳转或默认错误 Toast |
+| i18n 与既有提示实例 | 各端命名空间不同;共同包不依赖翻译与 UI。新文案同批补三语言,生成物按既有脚本产出 |
+| 原会话/权限/许可证/维护流程 | 任何控制顺序改变可能劫持被踢确认或覆盖页面;按矩阵测试并发和排除 URL |
+
+## 7. 测试与可观测
+
+### 7.1 已执行的基线核实
+
+- 基线 `bc6ca25ba` 的依赖与源码核实两端均 4.44.0;隔离分支已快进到此提交,未拷贝旧工作区未提交文件。
+- 在已安装依赖的主检出运行只读行为回归:从 `src/frontend/platform/` 执行 `pnpm exec vitest run src/queryClient.test.ts src/pages/ModelPage/manage/queryModelMigration.test.tsx src/pages/ModelPage/manage/tabs/queryConfigMigration.test.tsx src/pages/Dashboard/components/charts/ChartContainer.test.tsx --maxWorkers=1 --minWorkers=1`。结果 **4 文件 / 8 测试通过**;这是升级基线,不是 F088 实现验证。
+- 对实际安装的 `@tanstack/react-query` 4.44.0 运行 Node 最小断言:fetchQuery reject 保留同一错误且 state=error;catch 返回 null 则 state=success;默认 observer.refetch resolve 且 isError=true;throwOnError:true 才 reject 同一错误。全部断言通过。未向仓库添加原型代码。
+
+### 7.2 实施验证策略
+
+- 共同包单测覆盖普通/非标准错误、消息安全、幂等规范化、原始证据引用、取消和重试分类。
+- 两端拦截器用真实 Axios adapter 或等价受控响应验证 HTTP 200 业务失败、HTTP 错误、非 envelope、下载及会话全局矩阵;不只 mock 掉请求层后证明页面 mock 行为。
+- React Query 集成使用生产 QueryClient 工厂验证 error/isSuccess、缓存保留、多个观察者最终提示次数、显式重试预算、离线/disabled/reconnect、mutateAsync 链与 refetch 失败。
+- 页面测试验证输入保留、finally 复位、错误不触发 onSuccess 缓存更新/发布/跳转,且不因背景刷新覆盖用户正在编辑的字段。图表区分失败与成功空结果,复用现有样式与状态组件。
+- 更改 queryConfigMigration 中“失败也 isSuccess”的断言,替换为真实错误状态;保留成功 envelope 的继承 metadata 测试。queryClient、queryModelMigration 和 ChartContainer 的升级回归继续通过。
+- 每批在 migration-inventory/tasks 记录实际活跃入口、API→hook→页面链、测试和保留的命名恢复;全量消费关系完成才满足 AC-21。
+- 最终运行前端 lint/typecheck/check-i18n、受影响 Vitest/Jest、`/e2e-test` 与 `/sdd-code-review`。实现已落地;各项实际结果与未完成验证见verification-report.md,不将本策略列表作为通过证据。
+
+### 7.3 手动验证与观测
+
+- 以有权限的现有测试账号本地启动 platform(`:3001`)与 client(`:4001/workspace`),client 登录入口沿用 `/__dev/login`;后端地址只用当前环境实际配置,不新建凭据或假定测试账号密码。
+- 浏览器 Network override 或测试 adapter 注入一次 HTTP 200 业务错误、409 envelope、网络断开:在工作台模型配置、Dashboard 保存/发布、图表手工刷新和 client 的知识/个人令牌操作中核实错误状态、输入保留、请求次数和提示次数。
+- 验证保存后刷新失败:写入只发生一次,刷新失败不会再次写入或将服务端已成功的写入伪称失败。
+- Console 不出现未处理 Promise rejection;错误诊断只记录必要码和场景,不自动输出请求 headers、token、完整表单或 cause 全量内容。不新增持久监控服务。
+
+官方语义依据(运行结论以本地锁定版本断言为准):[v4 Query Functions](https://tanstack.com/query/v4/docs/framework/react/guides/query-functions)、[v4 Mutations](https://tanstack.com/query/v4/docs/framework/react/guides/mutations)、[v4 Query Retries](https://tanstack.com/query/v4/docs/framework/react/guides/query-retries)。
+
+## 8. 后续改进与不做的事
+
+- `11010` 只保留现有放行的兼容行为;没有活跃语义证据,不能补造页面流程。后续若能确认彻底废弃,单独删除。
+- 不统一两端 QueryClient 的全部配置,内网与终端应用当前配置有各自用途;只对新暴露失败的重试和提示作必要衔接。
+- 不全面改写 useSystemConfigEnvelope 为查询库或重构 client App 的 provider 组织;只处理本次受影响的错误出口和必要缓存生命周期问题。
+- 不全面整改既有提示组件选择和外观。需要视觉改版时遵循设计师签核,另起范围。
+- 设计确认后才建立完整可执行任务拆解及每批活跃调用链;候选引用清单不是已完成活跃性审计。
+
+## 修订历史
+
+| 日期 | 改动 | 触发原因 |
+|---|---|---|
+| 2026-10-22 | 初版设计草案;更新至 bc6ca25ba,纳入升级新增查询工厂、配置失败 null 缓存及新回归测试 | 用户确认 spec 并提醒关注刚升级的 React Query |
+
+| 2026-10-22 | 补齐先适配消费方再原子切换协议;明确二进制独立 envelope 输入及证据优先级,用户授权修订后直接开发 | 独立审查两项 NEEDS_FIX |
+
+## 实施同步(2026-10-22)
+
+- client保留原认证/刷新响应阶段,在其后追加完整错误规范化阶段;前一阶段先取解码视图执行原全局动作,原response/data/cause不变。两个阶段用WeakMap按原响应记录实际发生的toast/global,而不是看到错误码就推断已处理。二进制500/403/11001及无MIME HTTP失败回归已覆盖。
+- 二进制解码支持显式httpFailure上下文,仅HTTP失败允许无JSON MIME Blob解析;成功非JSON文件仍原样返回。模板参数由纯函数resolveErrorMessageTemplate补齐,无法补齐时使用本端fallback,避免暴露占位符。
+- platform全部生产旧HOC调用已迁走,兼容导出只展示并继续reject。handleUiRequest仅作为明确UI终点消费失败,不用于queryFn/API层。
+- AppChat旧resolved失败后读取已删除应用的分支在基线因skip403Redirect=true始终reject而不可达,已删除并提供真实失败/重试界面;不重新接通死业务。
+
+- 2026-10-22 实施终审同步:补确认Promise、两类编辑器保留草稿、审批场景原子读取、preview原错误、聊天历史重试及清空归属锁;Workbench提交与刷新分离。原因与独立审查处置见review报告;未执行的浏览器验收保持未勾选。
+
+- 最后附件计数修复:失败移除仅结算实际pending项,已移除/已完成项不重复扣计数;待全部上传完成后只发布具有效路径的附件,同批失败+延迟成功回归覆盖。既有基线与未执行真实E2E的验收状态见verification-report.md。
diff --git a/features/v3.0.0/088-request-error-contract/discovery.md b/features/v3.0.0/088-request-error-contract/discovery.md
new file mode 100644
index 0000000000..e906cf5f07
--- /dev/null
+++ b/features/v3.0.0/088-request-error-contract/discovery.md
@@ -0,0 +1,41 @@
+# F088 需求发现记录
+
+日期:2026-10-22。状态:方向与 spec 均已获用户确认,design 待确认。
+
+## 1. 已确认方向
+
+用户询问 platform 拦截器消费错误后业务层无法取得状态和消息,是否需要与 client 统一;随后接受完整错误透传、提示与失败独立、渐进改造的方向,并确认五阶段计划,要求开始执行。
+
+目标:两端业务层均取得完整错误,失败不进入成功流程,默认提示与业务恢复可以同时成立。
+
+顺序:现状盘点与正式需求 → platform 请求入口 → platform 调用方 → client 契约与调用方 → 回归验收。
+
+## 2. 实际代码证据
+
+当前基线:`feat/3.0.0`,提交 `bc6ca25ba`,包含升级 `219bb0e8a`;两端 React Query 均为 4.44.0。隔离工作区由 Codex 原生 worktree 工具创建,起初基于 `72cee6ca5`;用户提醒升级后通过快进发版线同步已提交代码,未复制其他工作区未提交文件。
+
+| 范围 | 已核实事实 | 核实方式 |
+|---|---|---|
+| platform 普通业务失败 | 通常 reject 消息字符串;部分分支为专用对象 | 阅读 `src/frontend/platform/src/controllers/request.ts` 响应成功拦截分支 |
+| platform HTTP 失败 | 普通路径提示后 reject null;preserveError 可保留原异常 | 阅读同文件响应失败分支及 `dshRequestCompatibility.test.ts` |
+| platform 旧包装层 | catch 后返回 false/undefined;即使拦截器保留错误,上层仍可能消费失败 | 阅读同文件 `captureAndAlertRequestErrorHoc` |
+| platform 迁移规模 | 111 个非测试源文件引用旧包装层,包含定义文件;不能等同 111 个独立页面或全部活跃入口 | `rg -l 'captureAndAlertRequestErrorHoc' src/frontend/platform/src --glob '*.{ts,tsx}' --glob '!*.test.*'` |
+| client 普通业务失败 | 未开启 skip403Redirect 的部分响应仍 resolve;showError 只控制对应提示 | 阅读 `src/frontend/client/src/api/request.ts` |
+| client 局部严格路径 | skip403Redirect 等部分路径构造 Error,附加业务码和原始响应 | 阅读同文件及 `src/frontend/client/src/api/personalToken.ts` |
+| 已有显式恢复 | 许可证状态读取失败返回 null、上报采用 best effort;不能机械清除全部 catch | 阅读 `src/frontend/platform/src/controllers/API/license.ts` |
+| 特殊结果 11010 | 前端请求拦截器直接返回该结果;限定后端/两端 TS/TSX 的检索未发现其他相关代码命中 | `rg -n '11010' src/backend src/frontend --glob '*.{py,ts,tsx}'`;身份证示例中的数字子串不算业务引用 |
+
+表中为静态代码阅读和检索证据。随后对升级基线运行了 4 文件 8 测试及 4.44.0 最小行为断言,均通过,记录见 design.md §7.1;这些不代表 F088 已实现,也不代表全部调用已完成活跃入口追踪。
+
+## 3. 实施前与设计阶段待完成的核实
+
+- 将旧包装层引用分为活跃入口、测试辅助和不可达路径;追踪直接请求、API 层再次 catch、查询 hooks 及回调的实际消费关系。
+- 固定特殊结果、非统一响应、Blob 错误、许可证/维护/会话的现有处理矩阵。
+- 已核实两端查询库基线为 4.44.0;设计与回归需保留新工厂、数组 key、offlineFirst、isInitialLoading,并处理新增配置查询吞错逻辑。
+- 明确旧静默开关的现有全局处理例外,避免统一过程中无意扩大或缩小其作用。
+
+这些是设计/实施盘点任务,不影响 spec 中完整错误、页面恢复、默认提示和范围目标的确定。
+
+## 4. 工作流状态
+
+采用 Full SDD:本次改变跨模块失败契约,依据 `docs/SDD-Guide.md` §1。Discovery 与 spec 已获用户确认;design 须依该指南 §3 单独确认。当前已准备设计、状态与候选调用清单,未更改产品代码。
diff --git a/features/v3.0.0/088-request-error-contract/e2e-checklist.md b/features/v3.0.0/088-request-error-contract/e2e-checklist.md
new file mode 100644
index 0000000000..8d13914cde
--- /dev/null
+++ b/features/v3.0.0/088-request-error-contract/e2e-checklist.md
@@ -0,0 +1,42 @@
+# F088 页面验收清单
+
+状态:未执行浏览器验收,不作为PASS证据。前置条件:连接当前v3.0.0测试后端,使用有权限的现有管理员及普通用户;不假定账号密码。2026-10-22本地127.0.0.1:7860连接失败(curl退出7,HTTP000),当时隔离工作区未加载后端代理,未部署或更改共享环境。
+
+## AC分类
+
+本特性不新增后端CRUD/权限/分页/错误码API。用pytest直连后端无法验收浏览器中的Axios透传,因此不制造无关后端测试。AC-01、AC-02、AC-03、AC-04、AC-05、AC-08、AC-09、AC-10、AC-17、AC-19由共享包、真实adapter和业务链自动回归覆盖;AC-06、AC-07、AC-11、AC-12、AC-13、AC-14、AC-15、AC-16、AC-18、AC-20、AC-21另需以下真实页面操作确认。组件交互回归不替代浏览器E2E。
+
+## Platform
+
+- [ ] AC-06、AC-07、AC-08:普通表单注入一次业务冲突,默认只提示一次且保留输入;静默请求不出现普通提示但业务仍取得完整错误。
+- [ ] AC-11、AC-20:模型提供方/工作台配置保存失败后按钮解锁,配置和输入保留;更换embedding的确认框pending期间不能二次提交,失败后窗口保留,可重试。
+- [ ] AC-11、AC-20:知识摘要/标签提示词保存失败不关闭窗口、不接受草稿为成功值;微调名称和工作流名称失败保留编辑输入,重试成功才收起。
+- [ ] AC-11、AC-13:Dashboard保存失败不得继续发布/离开;删除/启停失败保持原状态或回滚;失败确认框保持可重试。
+- [ ] AC-12:图表/知识详情/审批初始读取失败显示失败状态而非成功空数据;重试恢复。
+- [ ] AC-12、AC-20:从审批场景A切到B,路由/流程/节点任一读取失败时继续完整显示A及草稿,不出现A标题配B内容;成功重试才一起切换。
+- [ ] AC-14、AC-18:密码过期10601仍进入修改密码交互;11010仅验证现有显式兼容,不新增无真实调用的业务流程。
+- [ ] AC-15、AC-16:以现有普通用户确认权限跳转;并发许可证过期通知保持节流;账号被踢先确认,后到的401不能越过确认提前跳转。
+- [ ] AC-17、AC-19:下载有效PDF/音频仍可用;JSON Blob错误、不标MIME的HTTP失败及无效错误内容显示可读信息,原始二进制证据未覆盖。
+- [ ] 写入成功、随后刷新失败:只发生一次写入,显示已保存及刷新失败,保留当前配置,不以保存失败诱发自动重复写入。
+
+## Client
+
+- [ ] AC-11、AC-13:内联文件名修改失败保留草稿;删除失败回滚;多次Enter/blur不重复提交。
+- [ ] AC-12、AC-20:导航会话/频道/来源/文章/知识空间加载失败显示错误和重试,不自动选择失败结果、跳页或当作成功空列表;切新会话后重试保留当前未落库会话。
+- [ ] AC-12:文件/文章/工作台/空间聊天历史失败可重试,未恢复时输入禁用;成功重试后消息与输入恢复。
+- [ ] AC-13、AC-20:清空A pending时不能重复清空/发送/重新生成;切到B后A的完成不得清掉B消息或锁住B输入;清空失败保留消息。
+- [ ] AC-14:Skills未导入11053/重名11055覆盖及回滚继续可用;公众号识别业务失败执行原“返回修改”,取消/旧请求/全局失败不误弹普通错误框。
+- [ ] AC-07、AC-09、AC-10、AC-14:上传19403汇总消息有配额参数且批次早停;取消/许可证/维护不叠加普通上传失败;登记仅沿用原重试预算,失败清占位与锁。
+- [ ] AC-07、AC-10、AC-15、AC-17:消息导出/导入与制品保存的全局失败/取消不重复提示;下载文件名/headers可用,导入失败不关目标窗口或退出选择。
+- [ ] AC-11、AC-12:审批详情加载失败清掉旧任务详情,不能对错误任务审批;列表刷新失败释放详情加载状态,保留失败可重试。
+- [ ] AC-14、AC-15:失效分享进入原404;维护、权限、登录刷新/被踢处理顺序保持,普通用户权限行为未扩大。
+
+## 最终回归
+
+- [ ] AC-09:简中/英文/日文各抽验业务参数和未知错误消息;不显示翻译键、对象默认文本或未替换占位符。
+- [ ] AC-20、AC-21:两端Console无unhandled rejection;失败不出现成功提示/自动成功跳转;成功与合法非envelope响应保持。
+- [ ] 将环境、角色、操作结果和缺陷填写到verification-report.md后再判定真实E2E结果。
+
+## 本轮联调入口
+
+已按用户要求启动隔离分支的两端Vite:Platform http://localhost:3001/;Client登录 http://localhost:4001/workspace/__dev/login 。仅进程环境复用主工作区已有代理 http://192.168.106.120:3002,未修改共享环境或源码配置。两端页面HTTP200,代理/api/v1/env均为JSON、HTTP200且status_code=200。以上只是启动/连通性检查,业务页面清单仍未执行,保持未勾选。
diff --git a/features/v3.0.0/088-request-error-contract/e2e-report.md b/features/v3.0.0/088-request-error-contract/e2e-report.md
new file mode 100644
index 0000000000..d65b66a059
--- /dev/null
+++ b/features/v3.0.0/088-request-error-contract/e2e-report.md
@@ -0,0 +1,9 @@
+# F088 E2E覆盖报告
+
+**整体:PARTIAL(执行未开始)**。本次不改后端API行为,未新增无关pytest;请求适配器/查询和组件的本地回归单列在verification-report.md。
+
+真实浏览器/后端步骤执行数:0;e2e-checklist.md的所有步骤保持未勾选。2026-10-22本地127.0.0.1:7860无法连接(curl退出7/HTTP000),没有假定测试账号或部署共享环境。
+
+AC-01至AC-21已有自动化/静态追溯表,但不能将其等同为真实环境E2E通过。提供可达联调后端及现有账号后,执行清单并记录权限/会话并发、表单保留、下载和Console结果,再关闭T006。
+
+后续启动检查:两端Vite已启动并复用主工作区已有120测试后端代理;两端env探测均HTTP200/status_code200。当前具备联调入口,但仍未执行浏览器业务步骤,不据此改为E2E PASS。
diff --git a/features/v3.0.0/088-request-error-contract/integration-report.md b/features/v3.0.0/088-request-error-contract/integration-report.md
new file mode 100644
index 0000000000..9eea028a19
--- /dev/null
+++ b/features/v3.0.0/088-request-error-contract/integration-report.md
@@ -0,0 +1,22 @@
+# F088 集成授权与合入前验证
+
+日期:2026-10-22。用户明确要求:停止Vite,合并到3.0.0,提交推送,删除开发分支。保留此前披露的基线失败与未执行真实业务E2E,不将集成授权改写为验收全部通过。
+
+## 集成范围
+
+原开发提交12be71949,以bc6ca25ba为实施基线;重放至最新origin/feat/3.0.0的4bb923a43,保留发版线依赖清理、技能配额/更新时间与语音防竞态修复。主检出原有未跟踪计划文件不纳入提交。
+
+冲突逐块解决,没有整文件采用冲突一侧。SkillUploadDialog标准ApiError继续保留errorCode供配额去重;技能时间戳字段保留;语音attempt/armed/stopSignal及Tooltip与原错误传递同时保留。抑制元数据经lint:prune生成,逐项比较发版线无新增键或计数增长。两个语音Promise解析器夹具仅修正类型;新增排序测试改用当前展示接口mock,全部原拖拽断言保留。
+
+## 合并结果验证
+
+- 独立精准冲突复核PASS,见merge-review.md。
+- client冲突相关5套37测试通过;平台排序/选择器/提交刷新/全局兼容4文件18测试通过。
+- platform最终完整198文件:197通过/1失败,963测试:961通过/2失败;仅剩原knowledgeSpaceGrantSubjects两项旧断言。
+- client完整113套:82通过/31失败,718测试:698通过/20失败;与合入前F088相比新增失败suite为0,均为已有记录的失败。
+- 两端生产构建通过。
+- client1494个strict文件全部通过;platform801个strict文件仅原clientVoiceDraft旧测试参数错误1项。
+- check-i18n通过;platform85项既有lint、client29项既有lint/2警告保留。client新增一项来自发版线原SkillList测试,非F088新增源码。抑制阈值没有扩大。
+- git diff --check通过。两端Vite进程已停止;真实浏览器业务E2E仍未执行,T006不伪装为已完成。
+
+日志:/private/tmp/f088-merge-platform-verified.log、f088-merge-client-full.log、f088-merge-platform-build.log、f088-merge-client-build.log、f088-merge-client-focus.log、f088-merge-platform-focus.log、f088-merge-client-types.log、f088-merge-platform-types.log、f088-merge-i18n.log。远端最终提交以feat/3.0.0的Git记录为准。
diff --git a/features/v3.0.0/088-request-error-contract/merge-review.md b/features/v3.0.0/088-request-error-contract/merge-review.md
new file mode 100644
index 0000000000..1a5dce6c21
--- /dev/null
+++ b/features/v3.0.0/088-request-error-contract/merge-review.md
@@ -0,0 +1,22 @@
+# F088 rebase 冲突精准复核
+
+**结论:PASS。** 在指定冲突范围内,未发现阻断合并的语义丢失。此结论是冲突解决内容审查,不替代父任务正在执行的全量与双端构建。
+
+工作区:`/Users/shanghang/.codex/worktrees/request-error-contract/bisheng`。逐项对照当前source、incoming `origin/feat/3.0.0`(`4bb923a43`)及原F088 `12be71949`。仅执行只读Git/文件检查,写此临时报告;未改产品、未运行测试/全量、未执行Git写入或发其他线程消息。
+
+| 项目 | 核实结果 |
+| --- | --- |
+| SkillUploadDialog incoming配额 | 50MiB限制、非空/格式校验、quotaBlocked阻止校验、11061/11062单条配额提示、drop传播控制与格式说明均保留。与incoming差异仅为F088标准错误模型适配,没有删除配额逻辑。 |
+| SkillUploadDialog F088完整错误 | isApiError/status_code/status_message/data均保留;11055重名恢复继续读取skill_id/content_hash;errorCode从标准status_code设置,使incoming quotaBlocked提示去重继续生效。保存仍await请求、catch保留错误、finally解锁,成功后才关闭。定位:SkillUploadDialog.tsx:27、:46、:72、:95、:126、:137、:149。 |
+| SkillUploadDialog测试 | 两侧全部既有测试均保留;incoming的50MiB、count cap、server quota去重与package drop均有原行为断言;F088输入改为normalizeApiError,英文fixture与断言同步,不是删断言迁就实现。 |
+| SkillCenterPage测试 | incoming两条updatedAt时间戳保留;原F088英文fixtures、标准错误测试与全部12条行为测试保留。相对12be71949仅增加两条updatedAt。定位:SkillCenterPage.test.tsx:11、:15、:225。 |
+| SpeechToText incoming取消锁 | attemptRef/armedRef/seenStop、dropAttempt、stopSignal变化、disabled取消、启动中点击暂停、await后的attempt guard、finally仅清当前attempt、Tooltip/aria状态均保留;相对incoming仅新增标准错误传递适配。定位:SpeechToText.tsx:38—:98、:125—:147。 |
+| SpeechToText F088错误 | errorRef接收原unknown;标准ApiError交showApiError;VoiceSession.onError原error继续透传;getVoiceModels catch仅当前attempt展示,并将同一error传给errorRef。本地错误仍原文案,无部分字段重建。定位:SpeechToText.tsx:43、:119、:129。 |
+| SpeechToText测试类型 | incoming全部取消/立即暂停状态测试及原参数化用例保留;相对incoming仅两处Promise resolver使用Awaited>,不改变运行行为。 |
+| platform suppression | 按JSON叶子逐项对比incoming:当前754项、incoming772项;无新增键或计数增长。incoming所有hook文件键(controllers/hooks/knowledge、BuildPage/hook、Dashboard/hook、util/hook)均保留;未误用冲突一侧整文件覆盖。 |
+
+检查:上述6文件无冲突标记;`git diff --check 4bb923a43 -- <上述路径>`通过。逐一对比三份test文件的既有测试名称,无两侧测试丢失,并读了配额/时间戳/取消锁的关键断言。
+
+测试证据:父任务报告5 focused suites/37 tests通过;本reviewer没有重复运行,亦未把父任务结果冒充独立执行。全量、严格类型、双端build结果由父任务最终记录。
+
+审查时仍处于rebase,冲突文件index显示UU(内容已解决但尚未标记);这是父任务后续stage/continue步骤,不是本报告发现的代码阻断。本reviewer未修改index或执行rebase操作。
diff --git a/features/v3.0.0/088-request-error-contract/migration-inventory.md b/features/v3.0.0/088-request-error-contract/migration-inventory.md
new file mode 100644
index 0000000000..1739d4bc51
--- /dev/null
+++ b/features/v3.0.0/088-request-error-contract/migration-inventory.md
@@ -0,0 +1,465 @@
+# F088 迁移候选清单
+
+基线:`bc6ca25ba`(React Query 升级 `219bb0e8a`),2026-10-22。
+
+本清单按源文本引用自动生成,不代表这些路径全部活跃,也不代表已经完成迁移。每批必须追踪实际 API → hook/页面 → 路由入口及 guard;定义文件、辅助文件和不同组重复项不可算成独立业务入口。未经过统一请求入口的通道只识别边界,不纳入改造。
+
+## 必须保护与重写的升级回归
+
+| 文件 | F088 要求 |
+|---|---|
+| `src/frontend/platform/src/queryClient.test.ts` | 保留内网离线、保存和重连刷新断言;新增真实错误与提示归属回归 |
+| `src/frontend/platform/src/pages/ModelPage/manage/queryModelMigration.test.tsx` | 保留模型类型缓存隔离、刷新和清理,补失败不伪装空成功 |
+| `src/frontend/platform/src/pages/ModelPage/manage/tabs/queryConfigMigration.test.tsx` | 失败路径由 null 缓存/isSuccess 改为真实错误;成功继承 metadata 保持可用 |
+| `src/frontend/platform/src/pages/Dashboard/components/charts/ChartContainer.test.tsx` | 保留 disabled 查询的 isInitialLoading;新增失败可辨认与再次刷新 |
+| `src/frontend/platform/src/pages/Dashboard/publishDashboard.test.tsx` | 验证保存失败不发布、不跳转、不触发成功缓存更新,且提示去重 |
+
+## 消费与迁移检查规则
+
+- Query/Mutation 的函数返回真实 Promise 失败;禁止旧包装层或通用 catch 空值进入成功缓存。
+- refetch 默认返回结果;有顺序依赖者显式抛错或判断 isError。mutateAsync 在最外层事件终点处理 rejection。
+- 特殊业务恢复须注明依据(许可证不可用隐藏、权限惰性读取等);不是清单上看到 catch 就删除。
+- 每个活跃调用链在 tasks 中记录对应 AC、实际改动和测试;只改 import/字段名不算迁移完成。
+- 清理后再做文本与调用关系双向扫描,排除静态检索漏掉的再导出或间接调用。
+
+## platform 旧包装层引用(111 文件)
+
+- [ ] `src/frontend/platform/src/components/LinSight/skill/SkillManagement.tsx`
+- [ ] `src/frontend/platform/src/components/VariablesComponent/index.tsx`
+- [ ] `src/frontend/platform/src/components/bs-comp/apiComponent/ChatLink.tsx`
+- [ ] `src/frontend/platform/src/components/bs-comp/department/TreeDepartmentSelect.tsx`
+- [ ] `src/frontend/platform/src/components/bs-comp/department/useLazyDepartmentTree.ts`
+- [ ] `src/frontend/platform/src/components/bs-comp/selectComponent/DepartmentUsersSelect.tsx`
+- [ ] `src/frontend/platform/src/components/bs-comp/selectComponent/LabelSelect.tsx`
+- [ ] `src/frontend/platform/src/contexts/tabsContext.tsx`
+- [ ] `src/frontend/platform/src/contexts/userContext.tsx`
+- [ ] `src/frontend/platform/src/controllers/request.ts`
+- [ ] `src/frontend/platform/src/layout/MainLayout.tsx`
+- [ ] `src/frontend/platform/src/modals/codeAreaModal/index.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/CardSelectVersion.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/CreateApp.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/CreateTemp.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/appTemps.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/apps.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/assistant/CreateAssistant.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/assistant/editAssistant/index.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/bench/AppCenter.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/bench/DepartmentKnowledgeSpaceManagerDialog.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/bench/KnowledgeSpace.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/bench/KnowledgeSpaceSensitivePolicy.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/bench/KnowledgeSpaceTagLibrarySection.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/bench/Subscribe.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/bench/SubscriptionSensitivePolicy.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/bench/WorkbenchSensitivePolicy.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/bench/index.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/flow/FlowNode/RunTest.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/flow/FlowNode/component/ReportWordEdit.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/flow/Header.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/tools/EditMcp.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/tools/EditTool.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/tools/index.tsx`
+- [ ] `src/frontend/platform/src/pages/DataSetPage/CreateDataSet.tsx`
+- [ ] `src/frontend/platform/src/pages/DataSetPage/index.tsx`
+- [ ] `src/frontend/platform/src/pages/DepartmentPage/components/AddMemberDialog.tsx`
+- [ ] `src/frontend/platform/src/pages/DepartmentPage/components/CreateDepartmentDialog.tsx`
+- [ ] `src/frontend/platform/src/pages/DepartmentPage/components/CreateLocalMemberDialog.tsx`
+- [ ] `src/frontend/platform/src/pages/DepartmentPage/components/DepartmentSettings.tsx`
+- [ ] `src/frontend/platform/src/pages/DepartmentPage/components/DepartmentSettingsView.tsx`
+- [ ] `src/frontend/platform/src/pages/DepartmentPage/components/DepartmentTrafficControl.tsx`
+- [ ] `src/frontend/platform/src/pages/DepartmentPage/components/MemberTable.tsx`
+- [ ] `src/frontend/platform/src/pages/DepartmentPage/components/MountTenantDialog.tsx`
+- [ ] `src/frontend/platform/src/pages/DepartmentPage/components/OrganizationMemberEditDialog.tsx`
+- [ ] `src/frontend/platform/src/pages/DepartmentPage/index.tsx`
+- [ ] `src/frontend/platform/src/pages/EvaluationPage/EvaluationCreate.tsx`
+- [ ] `src/frontend/platform/src/pages/EvaluationPage/index.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/AdjustFilesUpload.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/KnowledgeFile.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/KnowledgeQa.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/components/DepartmentSpaceDialog.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/components/EditQa.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/components/FileUploadParagraphs.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/components/FileUploadStep4.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/components/Files.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/components/Header.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/components/ImportQa.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/components/KnowledgeLibraryDialog.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/components/ParagraphEdit.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/components/Paragraphs.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/components/PreviewResult.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/components/QaLibraryDialog.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/components/tags/KnowledgeTagSelect.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/detail.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/filesUpload.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/qas.tsx`
+- [ ] `src/frontend/platform/src/pages/LabelPage/AddSimilarQuestions.tsx`
+- [ ] `src/frontend/platform/src/pages/LabelPage/SaveQaLibForm.tsx`
+- [ ] `src/frontend/platform/src/pages/LabelPage/index.tsx`
+- [ ] `src/frontend/platform/src/pages/LabelPage/taskAppChats.tsx`
+- [ ] `src/frontend/platform/src/pages/LogPage/useAppLog/appChatDetail.tsx`
+- [ ] `src/frontend/platform/src/pages/LoginPage/TenantSelect.tsx`
+- [ ] `src/frontend/platform/src/pages/LoginPage/UserPwdModal.tsx`
+- [ ] `src/frontend/platform/src/pages/LoginPage/login.tsx`
+- [ ] `src/frontend/platform/src/pages/LoginPage/resetPwd.tsx`
+- [ ] `src/frontend/platform/src/pages/MenuPermissionPlaceholder.tsx`
+- [ ] `src/frontend/platform/src/pages/ModelPage/components/CreateTask.tsx`
+- [ ] `src/frontend/platform/src/pages/ModelPage/components/FinetuneDetail.tsx`
+- [ ] `src/frontend/platform/src/pages/ModelPage/components/RTConfig.tsx`
+- [ ] `src/frontend/platform/src/pages/ModelPage/finetune.tsx`
+- [ ] `src/frontend/platform/src/pages/ModelPage/manage/ModelConfig.tsx`
+- [ ] `src/frontend/platform/src/pages/ModelPage/manage/index.tsx`
+- [ ] `src/frontend/platform/src/pages/ModelPage/manage/tabs/AssisModel.tsx`
+- [ ] `src/frontend/platform/src/pages/ModelPage/manage/tabs/EvaluationModel.tsx`
+- [ ] `src/frontend/platform/src/pages/ModelPage/manage/tabs/KnowledgeModel.tsx`
+- [ ] `src/frontend/platform/src/pages/ModelPage/manage/tabs/WorkbenchModel.tsx`
+- [ ] `src/frontend/platform/src/pages/ModelPage/manage/tabs/WorkflowModel.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/components/Config.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/components/CreateUser.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/components/Departments.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/components/OrgSync/index.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/components/PersonalToken/index.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/components/Roles.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/components/ServiceAccount/ApiKeysTab.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/components/ServiceAccount/CreateServiceAccountDialog.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/components/ServiceAccount/KeyIssueDialog.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/components/ServiceAccount/OverviewTab.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/components/ServiceAccount/ResourceGrantDialog.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/components/ServiceAccount/ResourceGrantsTab.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/components/ServiceAccount/index.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/components/UserGroup.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/components/UserRoleModal.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/components/Users.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/theme/BrandAssetUpload.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/theme/BrandCustomization.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/theme/WorkbenchThemeSettings.tsx`
+- [ ] `src/frontend/platform/src/pages/TenantPage/components/CreateTenantDialog.tsx`
+- [ ] `src/frontend/platform/src/pages/TenantPage/components/TenantQuotaDialog.tsx`
+- [ ] `src/frontend/platform/src/pages/TenantPage/components/TenantUserDialog.tsx`
+- [ ] `src/frontend/platform/src/pages/TenantPage/index.tsx`
+
+## platform React Query 引用(含升级新增/迁移入口)(20 文件)
+
+- [ ] `src/frontend/platform/src/components/bs-comp/department/useLazyDepartmentTree.ts`
+- [ ] `src/frontend/platform/src/index.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/flow/Sidebar.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/hook.ts`
+- [ ] `src/frontend/platform/src/pages/Dashboard/components/charts/ChartContainer.tsx`
+- [ ] `src/frontend/platform/src/pages/Dashboard/components/config/ChartSelector.tsx`
+- [ ] `src/frontend/platform/src/pages/Dashboard/components/config/DatasetSelector.tsx`
+- [ ] `src/frontend/platform/src/pages/Dashboard/components/dashboard/DashboardSidebar.tsx`
+- [ ] `src/frontend/platform/src/pages/Dashboard/components/editor/EditorCanvas.tsx`
+- [ ] `src/frontend/platform/src/pages/Dashboard/components/editor/EditorHeader.tsx`
+- [ ] `src/frontend/platform/src/pages/Dashboard/editor.tsx`
+- [ ] `src/frontend/platform/src/pages/Dashboard/hook.ts`
+- [ ] `src/frontend/platform/src/pages/Dashboard/index.tsx`
+- [ ] `src/frontend/platform/src/pages/Dashboard/share.tsx`
+- [ ] `src/frontend/platform/src/pages/ModelPage/manage/ScopeBar.tsx`
+- [ ] `src/frontend/platform/src/pages/ModelPage/manage/index.tsx`
+- [ ] `src/frontend/platform/src/pages/ModelPage/manage/tabs/AssisModel.tsx`
+- [ ] `src/frontend/platform/src/pages/ModelPage/manage/tabs/WorkbenchModel.tsx`
+- [ ] `src/frontend/platform/src/queryClient.ts`
+- [ ] `src/frontend/platform/src/test/test-utils.tsx`
+
+## platform 直接导入统一 request(147 文件)
+
+- [ ] `src/frontend/platform/src/components/LinSight/skill/SkillManagement.tsx`
+- [ ] `src/frontend/platform/src/components/VariablesComponent/index.tsx`
+- [ ] `src/frontend/platform/src/components/bs-comp/apiComponent/ChatLink.tsx`
+- [ ] `src/frontend/platform/src/components/bs-comp/department/TreeDepartmentSelect.tsx`
+- [ ] `src/frontend/platform/src/components/bs-comp/department/useLazyDepartmentTree.ts`
+- [ ] `src/frontend/platform/src/components/bs-comp/selectComponent/DepartmentUsersSelect.tsx`
+- [ ] `src/frontend/platform/src/components/bs-comp/selectComponent/LabelSelect.tsx`
+- [ ] `src/frontend/platform/src/components/bs-ui/upload/simple.tsx`
+- [ ] `src/frontend/platform/src/contexts/tabsContext.tsx`
+- [ ] `src/frontend/platform/src/contexts/userContext.tsx`
+- [ ] `src/frontend/platform/src/controllers/API/admin.ts`
+- [ ] `src/frontend/platform/src/controllers/API/approval.ts`
+- [ ] `src/frontend/platform/src/controllers/API/assistant.ts`
+- [ ] `src/frontend/platform/src/controllers/API/dashboard.ts`
+- [ ] `src/frontend/platform/src/controllers/API/department.ts`
+- [ ] `src/frontend/platform/src/controllers/API/departmentKnowledgeSpace.ts`
+- [ ] `src/frontend/platform/src/controllers/API/dsh.ts`
+- [ ] `src/frontend/platform/src/controllers/API/dshAudit.ts`
+- [ ] `src/frontend/platform/src/controllers/API/dshMarket.ts`
+- [ ] `src/frontend/platform/src/controllers/API/dshSettings.ts`
+- [ ] `src/frontend/platform/src/controllers/API/evaluate.ts`
+- [ ] `src/frontend/platform/src/controllers/API/finetune.ts`
+- [ ] `src/frontend/platform/src/controllers/API/flow.ts`
+- [ ] `src/frontend/platform/src/controllers/API/guestLink.ts`
+- [ ] `src/frontend/platform/src/controllers/API/index.ts`
+- [ ] `src/frontend/platform/src/controllers/API/knowledgeSpace.ts`
+- [ ] `src/frontend/platform/src/controllers/API/knowledgeSpaceTagLibrary.ts`
+- [ ] `src/frontend/platform/src/controllers/API/knowledgeTags.ts`
+- [ ] `src/frontend/platform/src/controllers/API/label.ts`
+- [ ] `src/frontend/platform/src/controllers/API/license.ts`
+- [ ] `src/frontend/platform/src/controllers/API/linsight.ts`
+- [ ] `src/frontend/platform/src/controllers/API/llm.ts`
+- [ ] `src/frontend/platform/src/controllers/API/log.ts`
+- [ ] `src/frontend/platform/src/controllers/API/orgSync.ts`
+- [ ] `src/frontend/platform/src/controllers/API/permission.ts`
+- [ ] `src/frontend/platform/src/controllers/API/personalToken.ts`
+- [ ] `src/frontend/platform/src/controllers/API/pro.ts`
+- [ ] `src/frontend/platform/src/controllers/API/sensitiveWordPolicy.ts`
+- [ ] `src/frontend/platform/src/controllers/API/serviceAccount.ts`
+- [ ] `src/frontend/platform/src/controllers/API/tenant.ts`
+- [ ] `src/frontend/platform/src/controllers/API/tools.ts`
+- [ ] `src/frontend/platform/src/controllers/API/user.ts`
+- [ ] `src/frontend/platform/src/controllers/API/userGroups.ts`
+- [ ] `src/frontend/platform/src/controllers/API/workbench.ts`
+- [ ] `src/frontend/platform/src/controllers/API/workflow.ts`
+- [ ] `src/frontend/platform/src/hooks/useDshProfile.ts`
+- [ ] `src/frontend/platform/src/layout/MainLayout.tsx`
+- [ ] `src/frontend/platform/src/modals/codeAreaModal/index.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/CardSelectVersion.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/CreateApp.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/CreateTemp.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/appTemps.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/apps.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/assistant/CreateAssistant.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/assistant/editAssistant/index.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/bench/AppCenter.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/bench/DepartmentKnowledgeSpaceManagerDialog.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/bench/KnowledgeSpace.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/bench/KnowledgeSpaceSensitivePolicy.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/bench/KnowledgeSpaceTagLibrarySection.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/bench/Subscribe.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/bench/SubscriptionSensitivePolicy.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/bench/WorkbenchSensitivePolicy.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/bench/index.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/flow/FlowNode/RunTest.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/flow/FlowNode/component/ReportWordEdit.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/flow/Header.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/tools/EditMcp.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/tools/EditTool.tsx`
+- [ ] `src/frontend/platform/src/pages/BuildPage/tools/index.tsx`
+- [ ] `src/frontend/platform/src/pages/DataSetPage/CreateDataSet.tsx`
+- [ ] `src/frontend/platform/src/pages/DataSetPage/index.tsx`
+- [ ] `src/frontend/platform/src/pages/DepartmentPage/components/AddMemberDialog.tsx`
+- [ ] `src/frontend/platform/src/pages/DepartmentPage/components/CreateDepartmentDialog.tsx`
+- [ ] `src/frontend/platform/src/pages/DepartmentPage/components/CreateLocalMemberDialog.tsx`
+- [ ] `src/frontend/platform/src/pages/DepartmentPage/components/DepartmentSettings.tsx`
+- [ ] `src/frontend/platform/src/pages/DepartmentPage/components/DepartmentSettingsView.tsx`
+- [ ] `src/frontend/platform/src/pages/DepartmentPage/components/DepartmentTrafficControl.tsx`
+- [ ] `src/frontend/platform/src/pages/DepartmentPage/components/MemberTable.tsx`
+- [ ] `src/frontend/platform/src/pages/DepartmentPage/components/MountTenantDialog.tsx`
+- [ ] `src/frontend/platform/src/pages/DepartmentPage/components/OrganizationMemberEditDialog.tsx`
+- [ ] `src/frontend/platform/src/pages/DepartmentPage/index.tsx`
+- [ ] `src/frontend/platform/src/pages/EvaluationPage/EvaluationCreate.tsx`
+- [ ] `src/frontend/platform/src/pages/EvaluationPage/index.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/AdjustFilesUpload.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/KnowledgeFile.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/KnowledgeQa.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/components/DepartmentSpaceDialog.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/components/EditQa.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/components/FileUploadStep4.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/components/Files.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/components/Header.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/components/ImportQa.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/components/KnowledgeLibraryDialog.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/components/ParagraphEdit.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/components/Paragraphs.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/components/PreviewResult.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/components/QaLibraryDialog.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/components/tags/KnowledgeTagSelect.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/detail.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/filesUpload.tsx`
+- [ ] `src/frontend/platform/src/pages/KnowledgePage/qas.tsx`
+- [ ] `src/frontend/platform/src/pages/LabelPage/AddSimilarQuestions.tsx`
+- [ ] `src/frontend/platform/src/pages/LabelPage/SaveQaLibForm.tsx`
+- [ ] `src/frontend/platform/src/pages/LabelPage/index.tsx`
+- [ ] `src/frontend/platform/src/pages/LabelPage/taskAppChats.tsx`
+- [ ] `src/frontend/platform/src/pages/LogPage/useAppLog/appChatDetail.tsx`
+- [ ] `src/frontend/platform/src/pages/LoginPage/TenantSelect.tsx`
+- [ ] `src/frontend/platform/src/pages/LoginPage/UserPwdModal.tsx`
+- [ ] `src/frontend/platform/src/pages/LoginPage/login.tsx`
+- [ ] `src/frontend/platform/src/pages/LoginPage/resetPwd.tsx`
+- [ ] `src/frontend/platform/src/pages/MenuPermissionPlaceholder.tsx`
+- [ ] `src/frontend/platform/src/pages/ModelPage/components/CreateTask.tsx`
+- [ ] `src/frontend/platform/src/pages/ModelPage/components/FinetuneDetail.tsx`
+- [ ] `src/frontend/platform/src/pages/ModelPage/components/RTConfig.tsx`
+- [ ] `src/frontend/platform/src/pages/ModelPage/finetune.tsx`
+- [ ] `src/frontend/platform/src/pages/ModelPage/manage/ModelConfig.tsx`
+- [ ] `src/frontend/platform/src/pages/ModelPage/manage/dsh/ModelVisionSwitch.tsx`
+- [ ] `src/frontend/platform/src/pages/ModelPage/manage/index.tsx`
+- [ ] `src/frontend/platform/src/pages/ModelPage/manage/tabs/AssisModel.tsx`
+- [ ] `src/frontend/platform/src/pages/ModelPage/manage/tabs/EvaluationModel.tsx`
+- [ ] `src/frontend/platform/src/pages/ModelPage/manage/tabs/KnowledgeModel.tsx`
+- [ ] `src/frontend/platform/src/pages/ModelPage/manage/tabs/WorkbenchModel.tsx`
+- [ ] `src/frontend/platform/src/pages/ModelPage/manage/tabs/WorkflowModel.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/components/Config.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/components/CreateUser.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/components/Departments.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/components/OrgSync/index.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/components/PersonalToken/index.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/components/Roles.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/components/ServiceAccount/ApiKeysTab.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/components/ServiceAccount/CreateServiceAccountDialog.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/components/ServiceAccount/KeyIssueDialog.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/components/ServiceAccount/OverviewTab.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/components/ServiceAccount/ResourceGrantDialog.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/components/ServiceAccount/ResourceGrantsTab.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/components/ServiceAccount/index.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/components/UserGroup.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/components/UserRoleModal.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/components/Users.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/theme/BrandAssetUpload.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/theme/BrandCustomization.tsx`
+- [ ] `src/frontend/platform/src/pages/SystemPage/theme/WorkbenchThemeSettings.tsx`
+- [ ] `src/frontend/platform/src/pages/TenantPage/components/CreateTenantDialog.tsx`
+- [ ] `src/frontend/platform/src/pages/TenantPage/components/TenantQuotaDialog.tsx`
+- [ ] `src/frontend/platform/src/pages/TenantPage/components/TenantUserDialog.tsx`
+- [ ] `src/frontend/platform/src/pages/TenantPage/index.tsx`
+
+## client API 直接导入统一 request(18 文件)
+
+- [ ] `src/frontend/client/src/api/approval.ts`
+- [ ] `src/frontend/client/src/api/apps.ts`
+- [ ] `src/frontend/client/src/api/channels.ts`
+- [ ] `src/frontend/client/src/api/chat/data-service.ts`
+- [ ] `src/frontend/client/src/api/chatApi.ts`
+- [ ] `src/frontend/client/src/api/dsh.ts`
+- [ ] `src/frontend/client/src/api/index.ts`
+- [ ] `src/frontend/client/src/api/knowledge.ts`
+- [ ] `src/frontend/client/src/api/linsight.ts`
+- [ ] `src/frontend/client/src/api/message.ts`
+- [ ] `src/frontend/client/src/api/messageExport.ts`
+- [ ] `src/frontend/client/src/api/notifications.ts`
+- [ ] `src/frontend/client/src/api/permission.ts`
+- [ ] `src/frontend/client/src/api/personalToken.ts`
+- [ ] `src/frontend/client/src/api/quota.ts`
+- [ ] `src/frontend/client/src/api/skillCenter.ts`
+- [ ] `src/frontend/client/src/api/user.ts`
+- [ ] `src/frontend/client/src/api/voice.ts`
+
+## client API 自行检查业务状态(14 文件)
+
+- [ ] `src/frontend/client/src/api/approval.ts`
+- [ ] `src/frontend/client/src/api/apps.ts`
+- [ ] `src/frontend/client/src/api/channels.ts`
+- [ ] `src/frontend/client/src/api/chatApi.ts`
+- [ ] `src/frontend/client/src/api/dsh.ts`
+- [ ] `src/frontend/client/src/api/knowledge.ts`
+- [ ] `src/frontend/client/src/api/knowledgeParseFailureMessage.ts`
+- [ ] `src/frontend/client/src/api/message.ts`
+- [ ] `src/frontend/client/src/api/messageExport.ts`
+- [ ] `src/frontend/client/src/api/permission.ts`
+- [ ] `src/frontend/client/src/api/personalToken.ts`
+- [ ] `src/frontend/client/src/api/request.ts`
+- [ ] `src/frontend/client/src/api/skillCenter.ts`
+- [ ] `src/frontend/client/src/api/user.ts`
+
+## client React Query 消费候选(105 文件)
+
+- [ ] `src/frontend/client/src/App.jsx`
+- [ ] `src/frontend/client/src/common/types.ts`
+- [ ] `src/frontend/client/src/components/Bookmarks/BookmarkForm.tsx`
+- [ ] `src/frontend/client/src/components/Chat/AiChatMessages.tsx`
+- [ ] `src/frontend/client/src/components/Chat/ChatView.tsx`
+- [ ] `src/frontend/client/src/components/Chat/Input/StreamAudio.tsx`
+- [ ] `src/frontend/client/src/components/Chat/Menus/BookmarkMenu.tsx`
+- [ ] `src/frontend/client/src/components/Chat/Menus/HeaderNewChat.tsx`
+- [ ] `src/frontend/client/src/components/Chat/Menus/Presets/EditPresetDialog.tsx`
+- [ ] `src/frontend/client/src/components/Conversations/ConvoOptions/useDeleteConversationConfirm.ts`
+- [ ] `src/frontend/client/src/components/Linsight/Input/KnowledgeSpaceSelect.tsx`
+- [ ] `src/frontend/client/src/components/Linsight/Input/ModelSelector.tsx`
+- [ ] `src/frontend/client/src/components/Linsight/Input/SkillSelector.tsx`
+- [ ] `src/frontend/client/src/components/Linsight/Input/useDefaultCheckedSkills.ts`
+- [ ] `src/frontend/client/src/components/Nav/MobileNav.tsx`
+- [ ] `src/frontend/client/src/components/Nav/NewChat.tsx`
+- [ ] `src/frontend/client/src/components/Nav/SearchBar.tsx`
+- [ ] `src/frontend/client/src/components/Settings/sections/AccountSection.tsx`
+- [ ] `src/frontend/client/src/components/SidePanel/Agents/AgentAvatar.tsx`
+- [ ] `src/frontend/client/src/components/SidePanel/Agents/AgentConfig.tsx`
+- [ ] `src/frontend/client/src/components/SidePanel/Agents/AgentSelect.tsx`
+- [ ] `src/frontend/client/src/components/SidePanel/Agents/DeleteButton.tsx`
+- [ ] `src/frontend/client/src/components/SidePanel/Builder/AssistantAvatar.tsx`
+- [ ] `src/frontend/client/src/components/SidePanel/Builder/AssistantSelect.tsx`
+- [ ] `src/frontend/client/src/components/SidePanel/Builder/ContextButton.tsx`
+- [ ] `src/frontend/client/src/components/Skills/SkillDetails.tsx`
+- [ ] `src/frontend/client/src/components/Skills/SkillUploadDialog.tsx`
+- [ ] `src/frontend/client/src/components/Skills/useSkillCenter.ts`
+- [ ] `src/frontend/client/src/components/dsh/DshDesktopPanel.tsx`
+- [ ] `src/frontend/client/src/hooks/Agents/useSelectAgent.ts`
+- [ ] `src/frontend/client/src/hooks/Chat/useAddedHelpers.ts`
+- [ ] `src/frontend/client/src/hooks/Chat/useChatHelpers.ts`
+- [ ] `src/frontend/client/src/hooks/Config/useConfigOverride.ts`
+- [ ] `src/frontend/client/src/hooks/Conversations/useExportConversation.ts`
+- [ ] `src/frontend/client/src/hooks/Conversations/useNavigateToConvo.tsx`
+- [ ] `src/frontend/client/src/hooks/Conversations/usePresets.ts`
+- [ ] `src/frontend/client/src/hooks/Conversations/useSearch.ts`
+- [ ] `src/frontend/client/src/hooks/Conversations/useUpdateTagsInConvo.ts`
+- [ ] `src/frontend/client/src/hooks/Files/useDeleteFilesFromTable.tsx`
+- [ ] `src/frontend/client/src/hooks/Files/useDragHelpers.ts`
+- [ ] `src/frontend/client/src/hooks/Files/useFileDeletion.ts`
+- [ ] `src/frontend/client/src/hooks/Files/useFileHandling.ts`
+- [ ] `src/frontend/client/src/hooks/Input/useQueryParams.ts`
+- [ ] `src/frontend/client/src/hooks/Nav/useNavScrolling.ts`
+- [ ] `src/frontend/client/src/hooks/Plugins/useAuthCodeTool.ts`
+- [ ] `src/frontend/client/src/hooks/Prompts/usePromptGroupsNav.ts`
+- [ ] `src/frontend/client/src/hooks/SSE/useContentHandler.ts`
+- [ ] `src/frontend/client/src/hooks/SSE/useEventHandlers.ts`
+- [ ] `src/frontend/client/src/hooks/queries/agents/mutations.ts`
+- [ ] `src/frontend/client/src/hooks/queries/agents/queries.ts`
+- [ ] `src/frontend/client/src/hooks/queries/auth/mutations.ts`
+- [ ] `src/frontend/client/src/hooks/queries/auth/queries.ts`
+- [ ] `src/frontend/client/src/hooks/queries/connection.ts`
+- [ ] `src/frontend/client/src/hooks/queries/endpoints/queries.ts`
+- [ ] `src/frontend/client/src/hooks/queries/files/mutations.ts`
+- [ ] `src/frontend/client/src/hooks/queries/files/queries.ts`
+- [ ] `src/frontend/client/src/hooks/queries/messages/mutations.ts`
+- [ ] `src/frontend/client/src/hooks/queries/misc/queries.ts`
+- [ ] `src/frontend/client/src/hooks/queries/mutations.ts`
+- [ ] `src/frontend/client/src/hooks/queries/prompts.ts`
+- [ ] `src/frontend/client/src/hooks/queries/queries.ts`
+- [ ] `src/frontend/client/src/hooks/queries/react-query-service.ts`
+- [ ] `src/frontend/client/src/hooks/queries/roles.ts`
+- [ ] `src/frontend/client/src/hooks/queries/tags.ts`
+- [ ] `src/frontend/client/src/hooks/queries/tools/mutations.ts`
+- [ ] `src/frontend/client/src/hooks/queries/tools/queries.ts`
+- [ ] `src/frontend/client/src/hooks/useAiChat.ts`
+- [ ] `src/frontend/client/src/hooks/useCategorizedKnowledgeSpaces.ts`
+- [ ] `src/frontend/client/src/hooks/useDshDesktop.ts`
+- [ ] `src/frontend/client/src/hooks/useEffectiveQuota.ts`
+- [ ] `src/frontend/client/src/hooks/useLinsightManager.tsx`
+- [ ] `src/frontend/client/src/hooks/useVoice.ts`
+- [ ] `src/frontend/client/src/pages/Subscription/Article/ArticlePage.tsx`
+- [ ] `src/frontend/client/src/pages/Subscription/ArticleList/ArticleList.tsx`
+- [ ] `src/frontend/client/src/pages/Subscription/ArticleList/ChannelActionsMenu.tsx`
+- [ ] `src/frontend/client/src/pages/Subscription/ArticleList/ChannelSwitcher.tsx`
+- [ ] `src/frontend/client/src/pages/Subscription/ChannelDiscoveryHome.tsx`
+- [ ] `src/frontend/client/src/pages/Subscription/ChannelPreviewDrawer.tsx`
+- [ ] `src/frontend/client/src/pages/Subscription/ChannelSettings/useChannelSettingsForm.ts`
+- [ ] `src/frontend/client/src/pages/Subscription/Sidebar/ChannelSidebar.tsx`
+- [ ] `src/frontend/client/src/pages/Subscription/hooks/useChannelActions.ts`
+- [ ] `src/frontend/client/src/pages/Subscription/hooks/useSourceManager.ts`
+- [ ] `src/frontend/client/src/pages/Subscription/index.tsx`
+- [ ] `src/frontend/client/src/pages/appChat/components/AppSidebarConvoItem.tsx`
+- [ ] `src/frontend/client/src/pages/apps/hooks/useLazyAppSharePermission.ts`
+- [ ] `src/frontend/client/src/pages/knowledge/SpaceDetail/AiChat/KnowledgeAiBottomDock.tsx`
+- [ ] `src/frontend/client/src/pages/knowledge/SpaceDetail/EditTagsModal.tsx`
+- [ ] `src/frontend/client/src/pages/knowledge/SpaceDetail/MoveToDialog.tsx`
+- [ ] `src/frontend/client/src/pages/knowledge/SpaceDetail/RelateDocumentPanel.tsx`
+- [ ] `src/frontend/client/src/pages/knowledge/SpaceDetail/SimilarDocumentDialog.tsx`
+- [ ] `src/frontend/client/src/pages/knowledge/SpaceDetail/VersionHistorySheet.tsx`
+- [ ] `src/frontend/client/src/pages/knowledge/SpaceDetail/index.tsx`
+- [ ] `src/frontend/client/src/pages/knowledge/SpaceDetail/useSelectionPath.ts`
+- [ ] `src/frontend/client/src/pages/knowledge/SpaceSettings/useKnowledgeSpaceSettingsForm.ts`
+- [ ] `src/frontend/client/src/pages/knowledge/hooks/useSpaceActions.ts`
+- [ ] `src/frontend/client/src/pages/knowledge/index.tsx`
+- [ ] `src/frontend/client/src/pages/knowledge/sidebar/KnowledgeSpaceSidebar.tsx`
+- [ ] `src/frontend/client/src/pages/settings/sections/AiAccessPane.tsx`
+- [ ] `src/frontend/client/src/routes/Layouts/Dashboard.tsx`
+- [ ] `src/frontend/client/src/types/chat/queries.ts`
+- [ ] `src/frontend/client/src/types/chat/types.ts`
+- [ ] `src/frontend/client/src/utils/collection.ts`
+- [ ] `src/frontend/client/src/utils/convos.ts`
+- [ ] `src/frontend/client/src/utils/files.ts`
+- [ ] `src/frontend/client/src/utils/promptGroups.ts`
+
+
+## 实施闭环与范围(2026-10-22)
+
+以上是基线候选清单,不等于逐个文件已被运行时验收。活跃调用链、命名恢复、失败终点与独立复核依据见platform-migration-report.md、platform-fix-report.md、platform-code-review.md,以及client-migration-report.md、client-final-fix-report.md、client-code-review.md。旧HOC生产调用已清零,兼容导出继续reject;不能将这一文本扫描独自当作AC-21验收。
+
+client已核实分享/技能/上传/审批/引用/导出制品/语音、导航会话/来源/文章、知识与频道列表、聊天历史及清空等活跃链。原LibreChat侧面板与旧Dashboard路径未接线,AppChat resolved失败后重接已删除应用分支在基线不可达,未复活这些死代码。辅助badge/标题等既有非关键降级保留;SSE/native fetch/WebSocket遵从spec排除边界。
+
+全量门禁、基线差异及真实后端页面待验清单见verification-report.md、e2e-checklist.md。独立审查报告只证明其中实际检查的活跃链,不伪装为每条旧路径的完整运行时认证。
diff --git a/features/v3.0.0/088-request-error-contract/platform-code-review-initial.md b/features/v3.0.0/088-request-error-contract/platform-code-review-initial.md
new file mode 100644
index 0000000000..d11b9043f3
--- /dev/null
+++ b/features/v3.0.0/088-request-error-contract/platform-code-review-initial.md
@@ -0,0 +1,135 @@
+# F088 platform task review
+
+**Feature:** F088 request-error-contract
+**Review scope:** T001 common error model; T002 platform request compatibility; T003 platform consumers, including live consumers outside the former HOC search results.
+**Baseline:** `bc6ca25ba`; actual implementation is the uncommitted worktree. Reviewed task-1-brief.md, task-1-report.md, task-1-review.diff and current sources. Parent may subsequently repair findings; line references describe the reviewed snapshot.
+**Review date:** 2026-10-22
+
+## Verdicts
+
+- **Spec compliance: NEEDS_FIX.** Common model and the inspected T002 request branches implement the intended rejection/evidence contract. T003 does not yet meet AC-05, AC-07, AC-11, AC-12, AC-20 and therefore AC-21. HOC removal is complete but is not equivalent to completing all live consumers.
+- **Code quality: NEEDS_FIX.** Remaining Promise ownership errors, discarded failure evidence, premature editor close and duplicate notifications need repair. This is not a claim that the whole platform migration should be redone.
+
+## Seven-dimension summary
+
+| Dimension | High | Medium | Low | Status |
+|---|---:|---:|---:|---|
+| Boundary conditions / UI error states | 5 | 0 | 0 | NEEDS_FIX |
+| Permission and authentication | 0 | 0 | 0 | No concrete defect in inspected branches; full runtime matrix not verified |
+| Concurrency safety | 1 | 0 | 0 | NEEDS_FIX |
+| Information leakage | 0 | 0 | 0 | No introduced exposure identified; old full-error console logging remains |
+| Test coverage | 0 | 1 | 0 | Partial; focused reproductions found uncovered cases |
+| Code style / ownership | 0 | 0 | 0 | No independent blocking style issue |
+| Documentation sync | 0 | 1 | 0 | Update final implementation/audit claims after fixes |
+
+## HIGH findings
+
+### H1 — Old custom notifications now duplicate the request-layer notification
+
+**Paths:** `src/frontend/platform/src/pages/ApprovalPage/useApprovalController.ts:183-184`; `src/frontend/platform/src/components/LinSight/skill/SkillDetailSheet.tsx:115-119`; source of the new first notice: `src/frontend/platform/src/controllers/request.ts:227` (ordinary business-error branch; exact current line may move).
+
+**Trigger:** In Approval administration, fail the scenario-create request with HTTP 200 + a non-global business error. The request interceptor presents the ApiError, then the controller unconditionally calls notification.open again. The same pattern remains throughout the controller's create/update/delete/save catches. Skill detail has the same defect on loading an additional bundle file: `skillApi.getSkillFile` at `controllers/API/linsight.ts:75` uses the default notification policy, then SkillDetailSheet displays its own error again. Global redirects or cancellations are also not respected by these custom catch blocks.
+
+**Evidence:** A scratch regression using the **real Axios adapter and real Approval controller** returned `{status_code:29999,status_message:'Denied',data:{reason:'test'}}` for create; exactly **two** notification calls occurred. Approval is live at `routes/index.tsx:99`; SkillManagement renders SkillDetailSheet at line 279.
+
+**Required fix:** Establish one notice owner for these actual consumers. Generic catches should use showApiError; custom localized/domain feedback should opt out of the ordinary request notice explicitly and respect canceled/global/already-presented errors. Audit adjacent custom catches, not just former HOC sites.
+
+**AC:** AC-07, AC-10, AC-20, AC-21. The custom catches are old; **duplicate default notices are newly introduced by this change**.
+
+### H2 — Approval initial load and scenario-selection promises have no UI error terminal
+
+**Path:** `src/frontend/platform/src/pages/ApprovalPage/useApprovalController.ts:168-170` (also `selectScenario` at 147-149 and its view callback).
+
+**Trigger:** Enter `/approval` and fail any scenario/preset/exception initial request. The effect fires `void initialLoad.current()` without catch, so loadPage rejects into an unhandled Promise. Its arrays remain initial empty data and no load failure state is stored. Scenario selection similarly returns an async load that the UI must terminate.
+
+**Evidence:** Traced actual route → ApprovalPage → useApprovalController → loadPage → wrapped approval API. Static proof; did not deliberately introduce an unhandled error into the scratch test runner.
+
+**Required fix:** Catch at the effect/event boundary, preserve a failure state and retry path appropriate to the existing page, and keep reusable loading functions rejecting. A toast by the interceptor does not consume the Promise.
+
+**AC:** AC-12, AC-20, AC-21. **Preexisting live omission explicitly included in the F088 whole-consumer requirement**; no new spec is needed.
+
+### H3 — Knowledge detail hook still resolves an empty result after a failed request
+
+**Path:** `src/frontend/platform/src/controllers/hooks/knowledge.ts:14-22`.
+
+**Trigger:** The details request fails during upload completion or a manual refresh. The hook logs the error, sets details to `[]`, and resolves undefined. The caller cannot distinguish failure or retain the original error. The live consumer `pages/KnowledgePage/components/FileUploadStep4.tsx:189-194` then permits “build a knowledge QA app” using `details[0]?.name || ''`, instead of preserving a failed prerequisite.
+
+**Evidence:** Scratch hook regression first loaded `[{id:1,name:'Knowledge'}]`, then rejected a refresh with a structured failure. The returned Promise resolved undefined and prior details became `[]`. Sole production caller verified at FileUploadStep4; this is not dead code.
+
+**Required fix:** Keep previous details, expose the full failure, and handle it at the upload-completion UI; block or retry the dependent create action until the required details are available. Do not replace this with another generic successful-empty fallback.
+
+**AC:** AC-03, AC-05, AC-12, AC-20, AC-21. **Preexisting live omission**, newly audited migration scope.
+
+### H4 — Failed inline/prompt edits still close their editor
+
+**Paths:** `src/frontend/platform/src/pages/ModelPage/components/FinetuneDetail.tsx:213` with `components/bs-ui/editLabel/index.tsx:34-35`; independently `src/frontend/platform/src/pages/ModelPage/manage/tabs/KnowledgeModel.tsx:107-113` with `handleSavePrompt` at 253-260.
+
+**Trigger A:** Rename a fine-tuned model and reject the rename request. EditLabel closes before calling its callback. The newly added handleUiRequest callback returns void, so this control cannot await success. Reopening uses the old server name and loses the entered draft.
+
+**Trigger B:** Edit the knowledge summary or auto-tag prompt and fail its save. PromptDialog clears its dirty flag, calls onSave, closes and changes the parent form immediately. handleSavePrompt itself returns void and catches the failure, so success-only UI continuation cannot be enforced there either.
+
+**Evidence:** Scratch regression against the real EditLabel + actual handleUiRequest reproduced the closed editor and lost draft; live fine-tune route is `routes/index.tsx:95` → finetune.tsx:103. PromptDialog call sites verified at KnowledgeModel's two prompt configuration buttons; static proof.
+
+**Required fix:** Return the request Promise through each save callback, await it in the actual editor boundary, keep editor/draft open on rejection, and only clear dirty state/close/change accepted data on fulfillment. The new workflow TextInput recovery does not fix these other controls.
+
+**AC:** AC-11, AC-20, AC-21. **Preexisting UI defects in chains changed by this migration**; no additional product scope required.
+
+### H5 — Preview's wrapped HTTP failures still lose all structured evidence
+
+**Path:** `src/frontend/platform/src/controllers/API/index.ts:660-667` and `696-703`; `emitError` at 591-596.
+
+**Trigger:** The document-preview creation or status **HTTP request** fails. The wrapper replaces the actual ApiError with `{message:'文档解析失败'}`; emitError then assigns fallback numeric code 10952. HTTP status, actual status_code, data, response and cause are discarded before reaching `pages/KnowledgePage/components/PreviewResult.tsx:88`.
+
+**Evidence:** Verified current implementation uses the wrapped `axios.post('/api/v1/knowledge/preview')` and `axios.get('/api/v1/knowledge/preview/status')`. The old comment mentioning fetch/SSE does not describe current code. This is within F088's unified HTTP scope, not the excluded native-fetch/SSE transport. No runtime repro was needed for the explicit replacement.
+
+**Required fix:** Preserve the existing callback error channel if desired, but carry the original ApiError/evidence with it. Do not synthesize 10952 as the actual transport/business cause of an HTTP request that returned another code. Keep legitimate domain task-status errors distinct from request failures.
+
+**AC:** AC-03, AC-05, AC-19, AC-21. **Preexisting live evidence-loss omission**; preserving a callback channel is a valid compatibility choice, discarding request evidence is not a named domain recovery.
+
+### H6 — Model confirmations detach their Promise, defeating the new pending lock
+
+**Paths:** `src/frontend/platform/src/pages/ModelPage/manage/ModelConfig.tsx:219-225`; `src/frontend/platform/src/pages/ModelPage/manage/tabs/WorkbenchModel.tsx:184-185`; boundary `components/bs-ui/alertDialog/useConfirm.tsx:45-55`.
+
+**Trigger:** Confirm provider deletion or embedding-model configuration save while the request is slow. onOk starts `void ...then(...).catch(...)` and returns undefined. ConfirmWrapper therefore finishes its Promise immediately and re-enables confirm while the mutation is still running. Repeated confirmation dispatches another delete/save. Workbench's background saveload disables its underlying save button, not the confirmation's OK button.
+
+**Evidence:** Traced returned values from both callbacks to ConfirmWrapper's await/settlement implementation. Parent was notified and intends to repair these parent-owned files.
+
+**Required fix:** Return the mutation chain to bsConfirm and let that UI boundary own the catch/lock. Keep next only after actual successful completion. Check other async onOk callbacks for the same detached pattern.
+
+**AC:** AC-11, AC-20, AC-21; relevant concurrency safety. Existing detached calls were not migrated to the newly introduced confirmation contract.
+
+## MEDIUM findings / verification gaps
+
+### M1 — Required platform compatibility matrix is not fully evidenced
+
+The task report's tests cover many valuable chains, and the existing platform request tests exercise ordinary business/HTTP errors, silent, cancellation, binary success and one HTTP401 binary kicked-session case. The inspected platform tests do not provide the planned full matrix for password expiration 10601, legacy 11010, concurrent license throttle/kicked-session ordering, showError=false versus global actions, and the actual consumers above. Do not label all T002 ACs or the whole feature validated solely from the majority of passing tests. Add the relevant focused matrix and these failed UI chains; complete T006 E2E independently. This review did not run browsers/middleware or certify auth with real non-admin accounts.
+
+### M2 — Final docs must reflect implemented consumer boundaries
+
+`src/frontend/platform/AGENTS.md` still recommends captureAndAlertRequestErrorHoc for new component code. Update that verified example to the actual rejection/UI-terminal contract. The migration inventory/report's blanket claim that UI events/effects consume rejection and callbacks retain full evidence is contradicted by H1-H6; update completion claims after repair. Design's successful-write/failed-refresh distinction also needs an explicit checked consumer test before claiming that matrix completed.
+
+## Positive findings and limits
+
+- Common ApiError retains the original cause and response references and distinguishes HTTP status from business code. Decoded binary error evidence is passed independently; response/data are not overwritten. Transport string code remains available. normalizeApiError is idempotent and presentation state supports deduplication.
+- Platform ordinary failures reject even after toast; silent preserves the older explicit exception ordering; canceled failures are skipped by showApiError. HTTP401 preserves the session-kick confirmation before generic token-clearing. No concrete new auth/permission regression found in inspected logic, but M1 remains.
+- Production HOC callers are removed. Dashboard save→publish and save→leave correctly await mutateAsync and use a UI rejection terminal. Chart error remains React Query error and uses failure copy. The production QueryClient keeps offlineFirst, retry:0 and existing keys; disabled chart loading keeps isInitialLoading.
+- Workbench query now rejects instead of caching null, disables new business-error retries, and defers notification to final onError for its explicitly retried query. Cached data survives failed refresh. This is the right direction; it does not repair detached confirmation callbacks or other unrelated UI editors.
+- Named license/permission recoveries and callback-driven upload ownership should not be mechanically deleted. H5 asks to preserve evidence inside the callback contract, not to replace the entire polling API.
+- Source-only review included the supplied diff, request foundation, query/config chains and sampled migrated page chains plus live paths identified above. This is **not exhaustive runtime certification of every changed file**. No new edits to product source or repository-wide Git writes were made.
+
+## Independent focused reproduction
+
+Scratch files: `/private/tmp/f088-platform-review/review.test.tsx` and `vitest.config.ts` (outside the repository).
+
+Command from platform:
+`pnpm exec vitest run /private/tmp/f088-platform-review/review.test.tsx --config /private/tmp/f088-platform-review/vitest.config.ts --maxWorkers=1 --minWorkers=1`
+
+Result: **1 file / 3 tests passed in 916 ms**, deliberately asserting the current defects: duplicate real-adapter Approval notices, failed knowledge refresh resolving empty, and failed inline rename losing its draft. These are defect reproductions, not green acceptance tests.
+
+An initial scratch config accidentally merged include arrays and began selecting repository tests. It was interrupted immediately after discovery, and the config was corrected to replace include. That aborted run is not used as completion evidence; it also encountered a sandbox write denial for the dead-i18n-report artifact. The final focused run above completed cleanly. The implementation report's full-suite results were read rather than rerun to completion.
+
+## Overall: NEEDS_FIX
+
+## Controller follow-up received after report
+
+Parent reports H6's two confirmation Promise returns and H4's KnowledgeModel PromptDialog await/success-only close have now been repaired, with a new prompt regression. They are **reported fixed by controller, not independently re-reviewed here**. H4's Finetune/EditLabel branch and H1-H3/H5 remain handed back to the platform implementer pending parent verification. Parent is also adding stale-detail guards. This reviewer stops expansion here; original findings and reproduce scripts remain for the final coordinator audit.
diff --git a/features/v3.0.0/088-request-error-contract/platform-code-review.md b/features/v3.0.0/088-request-error-contract/platform-code-review.md
new file mode 100644
index 0000000000..96becc9d67
--- /dev/null
+++ b/features/v3.0.0/088-request-error-contract/platform-code-review.md
@@ -0,0 +1,96 @@
+# F088 platform fix-closure review
+
+**Scope:** Independent re-review of previous H1–H6 and M1–M2, plus the explicitly assigned round-2 paths documented below, against current uncommitted worktree based on `bc6ca25ba`. This is a scoped closure review, not a fresh exhaustive review or T006 merge approval.
+
+## Verdicts
+
+**Spec compliance: PASS for the reviewed H1–H6/M1–M2 and assigned round-2 closure scope.** The previous blocking findings are repaired. This is not a statement that every F088 AC or T006 gate has been executed.
+
+**Code quality: PASS within this re-review scope.** No remaining HIGH or MEDIUM defect was found in the repaired chains. Whole-feature lint/typecheck/build/E2E status remains with the parent task.
+
+## Seven dimensions
+
+| Dimension | Result within this re-review |
+|---|---|
+| Boundary conditions | PASS — H1–H5 fixed; failed scenario subrequests preserve the original scenario and draft; successful writes are separated from failed refreshes |
+| Permission/authentication | Required compatibility categories added to requestCompatibilityMatrix: 10601, 11010, 11001 throttle, forced routing versus silent, session kick versus subsequent 401, cancellation |
+| Concurrency | H6 callbacks return their mutation Promise; confirmation pending lifecycle remains owned by the dialog; EditLabel and PromptDialog prevent repeated pending submit |
+| Information leakage | No additional sensitive-data behavior introduced by the reviewed repairs; full error remains local evidence, not a newly added remote diagnostic sink |
+| Test coverage | PASS for listed findings — independently executed 14 focused files / 47 tests successfully; runtime E2E remains outside this review |
+| Code style | Changes are scoped to existing callback/event/error contracts; no new library or broad refactor; legacy API-index lint baseline remains separately disclosed |
+| Documentation | AGENTS request example and confirmation Promise rule now match implementation, with verification references; design/tasks final status sync remains assigned to parent |
+
+## Closure evidence
+
+- **H1:** Approval generic catches and SkillDetailSheet asset error now use showApiError with the original object. The request layer presentation marker owns deduplication and cancellation/global exclusions. Custom success and local-validation messages remain separate.
+- **H2:** Initial load, tab reload, scenario keyboard/mouse callbacks and flow selector have explicit Promise terminals. loadError is preserved with retry UI. Flow selection fetches nodes before clearing editor state. Re-review caught partial scenario state publication; the final fix makes both read loaders return data without writing state. selectScenario and loadPage commit only after all required reads succeed. Routes, flows or nodes failure retains the complete prior scenario, selected flow, nodes and dirty draft; all three failures plus successful retry are covered in the final passing regression.
+- **H3:** useKnowledgeDetails keeps successful details, exposes initial error and lets manual fetch reject the same failure. FileUploadStep4 awaits/retries details before reading models or creating the workflow; missing required knowledge name is an invalid-response failure instead of an empty-name success.
+- **H4:** FinetuneDetail returns its rename Promise directly to EditLabel. EditLabel waits before closing, keeps the draft and resets pending on rejection. Knowledge PromptDialog similarly awaits its rejecting callback, only then changes the accepted value and closes. Both have failure/retry behavior tests.
+- **H5:** Preview create and poll catches now pass the exact original failure to the callback; no fabricated 10952 replaces transport/business evidence. Cancellation does not enter the ordinary error event. Existing task-status callback protocol stays intact; PreviewResult still shows specialized ASR failure copy without an additional notice.
+- **H6:** ModelConfig deletion and Workbench embedding confirmation both return the Promise to bsConfirm; next is attached to fulfillment, so the confirmation's pending lock covers the request.
+- **M1:** Six request compatibility tests cover the categories identified in the prior review. This closes the missing unit/adapter matrix; it does not substitute for live E2E.
+- **M2:** Platform AGENTS example now uses rejecting requests plus an explicit UI terminal, documents confirmation Promise ownership, and names the verification tests. Parent is responsible for final design/tasks/inventory updates, so transitional status text is not raised as a new functional defect.
+
+## Verification and boundaries
+
+This reviewer changed no product source and performed no repository-wide Git writes. Exactly one focused test run was executed for this closure review; no full suite was rerun.
+
+Command, from `src/frontend/platform`:
+
+```sh
+pnpm exec vitest run src/components/bs-ui/editLabel/index.test.tsx src/controllers/hooks/knowledge.errors.test.tsx src/controllers/API/previewFileSplit.errors.test.ts src/pages/ApprovalPage/useApprovalController.errors.test.tsx src/components/LinSight/skill/SkillDetailSheet.errors.test.tsx src/pages/KnowledgePage/components/FileUploadStep4.errors.test.tsx src/pages/ModelPage/components/FinetuneDetail.errors.test.tsx src/pages/KnowledgePage/components/PreviewResult.test.tsx src/test/approvalPage.test.tsx src/pages/ModelPage/manage/tabs/KnowledgeModel.errors.test.tsx src/pages/ModelPage/manage/tabs/WorkbenchModel.commit.test.tsx src/controllers/requestCompatibilityMatrix.test.ts src/controllers/requestErrors.test.ts src/components/bs-ui/alertDialog/useConfirm.test.tsx --maxWorkers=1 --minWorkers=1
+```
+
+**Result:** 14 files / 47 tests passed, process exit 0, duration 12.19 seconds. No unhandled-error summary. Log: `/private/tmp/f088-platform-review-final.log`.
+
+**Additional completed M2 design-contract check:** WorkbenchModel now acknowledges a completed write and updates the accepted-form reference before separately attempting refetch. Refresh rejection presents the refresh error and resolves the completed write path, allowing the confirmation to close; it preserves the current form/cache and does not invoke another write. WorkbenchModel.commit.test.tsx exercises the actual component with the production QueryClient factory and verifies one write, success feedback, separate refresh error, enabled controls and retained cached configuration. The code retains the original rejection for a failed write; it does not convert that failure into success.
+
+Prior baseline concerns remain disclosed rather than certified as fixed: two stale knowledgeSpaceGrantSubjects assertions, untouched clientVoiceDraft strict typecheck parameter error, and legacy frontend lint/suppression drift (including API/index.ts explicit-any diagnostics unchanged by its four-line functional repair). See task-1-report and fix-round-1-report for exact original command evidence. The status of final quality gates belongs to the coordinating task.
+
+No browser, middleware, real-account permission or deployment E2E was executed here. Parent's final E2E checklist/execution and design/tasks synchronization remain required for whole-feature acceptance. This review can clear its listed defects without claiming those separate gates passed.
+
+## Overall: PASS (scoped fix-closure review)
+
+H1–H6 are closed; M1's adapter/unit matrix and M2's verified AGENTS example are closed. Remaining whole-feature documentation synchronization, quality gates and E2E are explicit coordinator tasks, not silently claimed complete here.
+
+
+## Round 2 final scoped review — 2026-10-22
+
+**Spec compliance: PASS. Code quality: PASS, within the explicitly reviewed paths.** No remaining blocking finding in these repair chains. Previous H1–H6/M1–M2 conclusions stand. This addition does not certify all consumers, all feature ACs or T006 merge gates; no unrelated feature search or full test run was performed.
+
+### Reviewed behavior and evidence
+
+- **BuildPage knowledge/tools pickers:** `OrgKbConfig.tsx` and `ToolsConfig.tsx` now keep request failures separate from successful empty results, delegate the original object to canonical presentation, retain previous successful data, and provide retry. Canceled/global failures do not become new ordinary inline errors. `ToolSelectorContainer.tsx` and `ToolSelector.tsx` pass the optional loading/error slot through and suppress empty-result copy while the slot is present. Existing selected items and category cache behavior are preserved. `pickerErrors.test.tsx` exercises initial failure/retry and knowledge search failure with prior rows.
+- **QA:** `qas.tsx` sends original generic failures to `showApiError`; the individual status failure retains its existing rollback, and named batch partial-result summaries remain. The test exercises rollback and the specialized batch warning. This review does not claim to redesign batch accounting or older selection behavior.
+- **Workflow attachments:** `ChatFiles.tsx` delegates canonical canceled/global/already-presented upload errors and removes failed attachments. During this re-review, the original cleanup still decremented pending twice: its catch called removal (which decremented) and then decremented again. With A rejected and B pending, this emitted B with an undefined path before B finished. This was a pre-existing defect in the same assigned cleanup chain, not a new spec requirement. It was reported and fixed before this verdict: catch now removes once; removal counts only removed pending items; success ignores already-removed ids; pending count accumulates across batches; published lists contain only completed entries with paths; the duplicate aggregate publication is gone. The new deferred two-file regression verifies the list remains `null` after A fails and publishes only B after B resolves. The three cancellation/global/toast recovery cases still pass. Static inspection covers the pending/manual-removal changes; arbitrary overlapping/manual-removal permutations were not separately executed here.
+- **SimpleUpload:** Dropzone's upload and returned success callback are awaited under an explicit terminal. A failed request cannot call success. ApiError precheck failures use canonical presentation while existing local validation messages remain specialized. Tests cover already-presented upload rejection, canceled precheck, and local spreadsheet validation.
+- **Audio:** `audioPlayButton.tsx` delegates TTS ApiError unchanged while keeping local playback-unavailable copy for ordinary player failures. Loading and current playback ownership are released in both tested error cases. This tests the button's synchronous playback boundary, not a real audio device.
+- **Skill forms:** Both silent mutation forms use `showSkillError`, retaining the existing domain-code/live-size copy while ignoring canceled/global/already-presented canonical failures. Once presented locally, the ApiError is marked `toast`. Static inspection verifies each live save/upload/GitHub mutation catch, and the helper regression verifies deduplication and distinct upload/unpacked limits. Full drawer/browser submission is not independently executed in this round.
+- **ModelConfig:** Detail loading failure is now caught, original failure is presented, and the failure/retry screen replaces the editable default form. Its effect cleanup prevents an old resource response from changing the current form. `ModelConfig.errors.test.tsx` executes initial failure, absence of Save, retry, and populated successful form. The stale-id guard was inspected statically; it is not claimed as an independently run stale-id test.
+- **Workbench:** The extracted headings use `model.workbenchConversationModels` and `model.workbenchVoiceModel`; all three platform locale files contain them. Previously reviewed write-versus-refresh separation remains covered by the earlier review run.
+
+### Seven dimensions — round-2 delta
+
+| Dimension | Result / limit |
+|---|---|
+| Boundary conditions | PASS for assigned failures: failed lists are retryable, failed provider detail cannot expose a saveable blank default, upload/precheck/TTS failures reach a terminal, original canonical evidence is retained |
+| Permission/authentication | PASS within consumer scope: no new business 403 branch or auth bypass; specialized forms respect global presentation/cancellation. Earlier compatibility matrix remains the evidence for auth/session/license behavior; no real-account test here |
+| Concurrency | PASS for the identified attachment premature-completion defect after repair and deferred regression; provider old-resource writes are guarded. This is not exhaustive concurrent-request certification |
+| Information leakage | No new sensitive logging or remote diagnostics sink in these deltas. Existing audio logging is outside this scoped fix; no claim of a repository-wide logging audit |
+| Tests | Independent final run: 10 files / 27 tests passed, exit 0; no unhandled-error summary. Tests exercise actual consumer boundaries with mocked HTTP dependencies |
+| Code style | Scoped reuse of existing presentation helpers and optional component slots; no new dependency. Implementer reports 19 changed-path lint passes; reviewer did not rerun lint/full typecheck/build |
+| Documentation | fix-round-2-report records the intended scope/recovery semantics; this report additionally records the found-and-fixed attachment counter issue and evidence. Final design/tasks/inventory synchronization remains the parent's gate |
+
+### One independent focused rerun
+
+From `src/frontend/platform`:
+
+```sh
+pnpm exec vitest run src/pages/BuildPage/bench/pickerErrors.test.tsx src/components/bs-ui/upload/simple.errors.test.tsx src/components/voiceFunction/audioPlayButton.errors.test.tsx src/test/skillErrorLimitCopy.test.ts src/pages/ApprovalPage/useApprovalController.errors.test.tsx src/pages/ModelPage/components/FinetuneDetail.errors.test.tsx src/pages/BuildPage/flow/FlowChat/ChatFiles.errors.test.tsx src/pages/KnowledgePage/qas.errors.test.tsx src/components/bs-comp/appChat/composerToolbar.test.tsx src/pages/ModelPage/manage/ModelConfig.errors.test.tsx --maxWorkers=1 --minWorkers=1
+```
+
+**Result:** 10 files / 27 tests passed, process exit 0, duration 10.37 seconds. Log: `/private/tmp/f088-platform-review-round2-final.log`. Expected mock-ref/router warnings are not test failures. This is the only test invocation by this reviewer for round 2. Parent's separate RED/GREEN evidence for the two-file defect is recorded in `/private/tmp/f088-upload-batch-green.log` and parent task history.
+
+The baseline limitations in the earlier section remain disclosed, not implicitly fixed by this run. No browser, real backend, real permission account, middleware, deployment or audio-device E2E was executed by this reviewer. Parent owns whole-feature full checks, final baseline classification, and the explicit E2E limitation/checklist. Native-fetch WebSearchFrom and unrelated old features remain excluded.
+
+**Final scoped outcome: Spec PASS / Quality PASS.** The assigned platform repairs, including the final attachment cleanup correction, are closed; broader merge acceptance remains with the coordinator.
diff --git a/features/v3.0.0/088-request-error-contract/platform-final-fix-report.md b/features/v3.0.0/088-request-error-contract/platform-final-fix-report.md
new file mode 100644
index 0000000000..ad69bffce3
--- /dev/null
+++ b/features/v3.0.0/088-request-error-contract/platform-final-fix-report.md
@@ -0,0 +1,41 @@
+# T003 fix round 2 report
+
+Status: **DONE** for the assigned round-2 scope. Final focused regression: **9 files / 25 tests pass**, changed-path ESLint passes, scoped diff whitespace check passes. No global Git writes, commits or subagents; no parent-owned Workbench or request foundation changes.
+
+## Live chains and behavior
+
+1. `bench/DailyChatConfig.tsx` renders `OrgKbConfig` and `ToolsConfig`. Failed knowledge/tool reads now preserve existing lists, store the original failure, delegate canonical notices to `showApiError`, and show shared Alert with existing localized retry copy. Initial failure no longer appears as a successful empty picker. Canceled/global errors do not create an inline failure alert. Knowledge search keeps prior successful rows when the subsequent search fails. Tool cache/bucket semantics are unchanged; pending reads have loading state. An optional `loading` / `loadErrorContent` slot passes through `ToolSelectorContainer` into `ToolSelector` so tool tabs remain usable and failed empty buckets suppress “no tools found” copy. This slot is inert for other consumers.
+2. `KnowledgePage/qas.tsx` generic batch-enable/batch-disable catch and individual status-update catch now pass the same error to `showApiError`. Existing status rollback remains unchanged. Named allSettled per-batch partial-success/failure summaries remain intact; these were deliberately not replaced by a generic notice.
+3. Live workflow `FlowChat/ChatInput` uses `ChatFiles`; upload catch now delegates the original error, including cancellation/global presentation, instead of displaying another upload-failed notice. Existing removal, counters and attachment completion callbacks are retained. Tests fail one attachment and then successfully upload another to verify cleanup/recovery.
+4. `SimpleUpload` is live in `DataSetPage/CreateDataSet` and `KnowledgePage/components/ImportQa`. Its Dropzone upload now has a terminal catch; failure cannot invoke success. The success callback is awaited so a returned rejection also reaches the boundary. API failures during pre-check use canonical presentation; ordinary local spreadsheet/size validation keeps its existing localized copy. Added explicit optional Props types; preCheck accepts unknown return values to remain compatible with the existing Excel Promise validator. Removed unused destructured callbacks that had no behavior or live caller.
+5. `AudioPlayComponent` is live in workflow markdown, form chat messages and shared platform voice controls. Wrapped TTS ApiError is delegated unchanged; ordinary local player/invalid-path failures retain playback-unavailable copy. Both cases keep loading/current-playing cleanup.
+6. `SkillUploadDialog` / `SkillFormDrawer` mutations already use `silent:true`. Their named local validation remains specialized through `showSkillError`: skip canceled/global/already-presented canonical failures; otherwise present existing translated skill/size copy and mark ApiError presentation once. Upload and unpacked limits retain their distinct live-limit interpolation. Optional slug suggestions and upload-cap metadata fallback are unchanged named recoveries.
+
+Native-fetch `WebSearchFrom` remains excluded. No new translation keys or visual styles were introduced.
+
+## Test typing corrections
+
+- `useApprovalController.errors.test.tsx`: getRolesApi fixtures now use `{ data: [] }`, matching its declared return type.
+- `FinetuneDetail.errors.test.tsx`: rename response mock is defined at the vi.fn module-mocking boundary, avoiding an invalid AxiosResponse assignment while matching the wrapped runtime result. No new any or ignore directives.
+
+## Changed paths (platform src)
+
+Production: `pages/BuildPage/bench/{OrgKbConfig,ToolsConfig}.tsx`; `components/LinSight/{ToolSelectorContainer,ToolSelector}.tsx`; `pages/KnowledgePage/qas.tsx`; `pages/BuildPage/flow/FlowChat/ChatFiles.tsx`; `components/bs-ui/upload/simple.tsx`; `components/voiceFunction/audioPlayButton.tsx`; `components/LinSight/skill/{skillErrors.ts,SkillFormDrawer.tsx,SkillUploadDialog.tsx}`.
+
+Added tests: `pages/BuildPage/bench/pickerErrors.test.tsx`; `pages/BuildPage/flow/FlowChat/ChatFiles.errors.test.tsx`; `pages/KnowledgePage/qas.errors.test.tsx`; `components/bs-ui/upload/simple.errors.test.tsx`; `components/voiceFunction/audioPlayButton.errors.test.tsx`. Extended `test/skillErrorLimitCopy.test.ts` and corrected the two round-1 tests above.
+
+## Verification
+
+RED evidence: picker/upload/playback tests initially reported **5 failures / 2 existing-path passes**, `/private/tmp/f088-platform-round2-red.log`; specialized skill presentation test initially failed, `/private/tmp/f088-platform-round2-skill-red.log`. QA fixtures were corrected to include the page's required update timestamp; knowledge search test uses the actual SearchInput placeholder rather than a textbox role.
+
+Final command from platform:
+
+```sh
+pnpm exec vitest run src/pages/BuildPage/bench/pickerErrors.test.tsx src/components/bs-ui/upload/simple.errors.test.tsx src/components/voiceFunction/audioPlayButton.errors.test.tsx src/test/skillErrorLimitCopy.test.ts src/pages/ApprovalPage/useApprovalController.errors.test.tsx src/pages/ModelPage/components/FinetuneDetail.errors.test.tsx src/pages/BuildPage/flow/FlowChat/ChatFiles.errors.test.tsx src/pages/KnowledgePage/qas.errors.test.tsx src/components/bs-comp/appChat/composerToolbar.test.tsx
+```
+
+**9 files / 25 tests pass, no unhandled errors**, 4.09s; `/private/tmp/f088-platform-round2-focused.log`. Expected React Router warnings and mock-child ref warnings are non-failing. Covers retry, retained prior list, canonical versus local validation, no upload success after failure, TTS/local player cleanup, specialized one-time skill validation, QA rollback and batch summary, canceled/global/already-toasted attachment recovery, and existing composer integration.
+
+ESLint across **all 19 changed production/test paths** passes using existing suppressions with `--pass-on-unpruned-suppressions`; `/private/tmp/f088-platform-round2-lint.log` is empty. No suppression edits/widening in this round. Scoped `git diff --check` passes. No full-suite or root TypeScript rerun; parent owns final quality gates and independent review. The earlier unrelated API-index baseline issue remains documented in round 1 and was not touched here.
+
+Ready for one independent parent-coordinated review of round 2.
diff --git a/features/v3.0.0/088-request-error-contract/platform-fix-report.md b/features/v3.0.0/088-request-error-contract/platform-fix-report.md
new file mode 100644
index 0000000000..9ea3ba6e05
--- /dev/null
+++ b/features/v3.0.0/088-request-error-contract/platform-fix-report.md
@@ -0,0 +1,63 @@
+# T003 fix round 1 report
+
+Status: **DONE_WITH_CONCERNS** — assigned functional fixes and focused acceptance regressions pass; the touched legacy API index retains preexisting lint baseline drift.
+
+## Verified live chains and repairs
+
+1. **H1 / approval and skill notices.** `/approval` renders the controller and view. All 12 generic request catches in `useApprovalController.ts`, and the exception retry/cancel catches in `useApprovalExceptions.ts`, now delegate to `showApiError` with the original object. Local validation and successful-operation messages remain owned locally. `SkillDetailSheet.tsx` asset clicks likewise use the deduplicating reporter; asset reads keep the existing default request policy.
+2. **H2 / approval load ownership.** Mount catches its rejected initial load; tab refresh, scenario mouse/keyboard selection, and flow-selector callbacks now have UI terminals. Reusable loaders still reject. Page load/selection retains the original `loadError`; the shared Alert displays failure with a retry action and clears only after success. Canceled/global failures do not display that inline alert. Flow loading fetches nodes before clearing drafts/selection, and confirmation-based flow switching returns the pending request and changes editor state only after success. Exception timeline failure reports once, clears loading in finally and collapses instead of showing an empty successful timeline.
+3. **H3 / knowledge details → workflow creation.** Sole production hook consumer is `FileUploadStep4.tsx`. Manual detail requests retain rejection and previous successful details; initial failure is exposed as the original error state. The quick-start action retries/awaits details before loading the default model or generating/creating a workflow. A successful but missing-name response is an invalid-response error, rather than a workflow containing a blank knowledge-base name. The action's existing UI terminal handles the original request failure.
+4. **H4 / finetune inline rename.** The actual FinetuneDetail caller passes its rejecting rename Promise directly to EditLabel. EditLabel awaits success before closing, preserves the input on failure, reports once and resets its pending lock in finally. Enter/blur cannot dispatch duplicate pending saves. No ModelConfig, WorkbenchModel, KnowledgeModel PromptDialog or parent request-layer files were edited.
+5. **H5 / preview callbacks.** Both wrapped preview-create and preview-status request catches deliver `onEvent('error', originalError)` without replacement or object spreading. ApiError identity therefore retains status_code/data/response/cause/presentation. Existing domain-task-status error envelopes and processing/completed/closed/canceled protocol remain unchanged. PreviewResult still resolves the original structured ASR failure into existing specialized inline copy without adding a second notice. Canceled requests remain silent.
+
+## Changed paths
+
+Production (platform `src/`):
+
+- `pages/ApprovalPage/{useApprovalController.ts,useApprovalExceptions.ts,ApprovalPageView.tsx,FlowManagementPanel.tsx}`
+- `components/LinSight/skill/SkillDetailSheet.tsx`
+- `controllers/hooks/knowledge.ts`
+- `pages/KnowledgePage/components/FileUploadStep4.tsx`
+- `components/bs-ui/editLabel/index.tsx`
+- `pages/ModelPage/components/FinetuneDetail.tsx`
+- `controllers/API/index.ts` (only two cancellation checks and two original-error callback lines)
+
+Added focused tests alongside EditLabel, SkillDetailSheet, knowledge hook, preview API, approval controller, FileUploadStep4 and FinetuneDetail. Extended existing PreviewResult test with its specialized original-error display regression.
+
+## Verification evidence
+
+Initial RED: 3 files / 4 failures in `/private/tmp/f088-platform-fix-round-1-red.log`, followed by 3 files / 4 failures plus the expected initial-load unhandled rejection in `/private/tmp/f088-platform-fix-round-1-ui-red.log`. These reproduced defects rather than merely asserting implementation details.
+
+Final command from `src/frontend/platform`:
+
+```sh
+pnpm exec vitest run src/components/bs-ui/editLabel/index.test.tsx src/controllers/hooks/knowledge.errors.test.tsx src/controllers/API/previewFileSplit.errors.test.ts src/pages/ApprovalPage/useApprovalController.errors.test.tsx src/components/LinSight/skill/SkillDetailSheet.errors.test.tsx src/pages/KnowledgePage/components/FileUploadStep4.errors.test.tsx src/pages/ModelPage/components/FinetuneDetail.errors.test.tsx src/pages/KnowledgePage/components/PreviewResult.test.tsx src/test/approvalPage.test.tsx src/pages/ModelPage/components/FinetuneHead.test.tsx
+```
+
+**10 files / 30 tests pass, no unhandled errors**, final duration 5.28s. Log: `/private/tmp/f088-platform-fix-round-1-focused.log`. Covers actual finetune parent/child Promise wiring, retained draft/retry/pending lock, full hook failure state/refresh rejection, blocked dependent workflow creation, approval initial failure/retry and mutation notice delegation, preview original identity/cause on both phases, cancellation, specialized preview inline copy, and 15 existing approval-page behaviors.
+
+Scoped ESLint across all 18 production/test paths produced **63 no-explicit-any errors solely in `controllers/API/index.ts`**. All other changed paths pass with existing suppressions (`--pass-on-unpruned-suppressions`; `/private/tmp/f088-platform-fix-round-1-lint-scoped.log` is empty). Full scoped output: `/private/tmp/f088-platform-fix-round-1-lint.log`. HEAD-source stdin lint independently also reports **63 no-explicit-any** diagnostics (`/private/tmp/f088-platform-fix-round-1-api-head-lint.log`); stdin does not apply the existing file suppressions equivalently, so its additional legacy diagnostics are not claimed as an equivalent full count. Current suppression allows 58; both HEAD and current source contain the identical 67 any-bearing lines. The four-line production diff introduces no any and no new Chinese copy. No suppression file was manually edited or widened in this round.
+
+Scoped `git diff --check` passes. Production HOC scan still finds only its definition in `controllers/request.ts`; this is supplementary evidence, not the completion criterion for these live chains. No full suite/typecheck rerun, as instructed; prior full-suite/typecheck concerns remain in task-1-report.md and are not reclassified as verified here.
+
+## Remaining concerns / handoff
+
+- Parent should account for the existing API-index explicit-any/suppression drift in final quality gates; broad cleanup of that 1,000+ line file was deliberately outside this fix round.
+- Parent owns review H4's prompt branch, H6, compatibility-matrix M1 and AGENTS example M2; this report claims only the assigned subscopes above.
+- All edits stayed in the designated shared worktree; no global Git writes, commits or subagents. Ready for independent re-review.
+
+## H2 atomic scenario-switch follow-up
+
+Parent's final review identified a remaining H2 state consistency defect: the route and flow readers each committed state before `Promise.all` completed. With A selected, switching to B could display B's successful half alongside A after the other half failed.
+
+Changed only `useApprovalController.ts` and its focused errors test. Route/flow loaders now return data without state writes; the flow reader includes its node read. Scenario selection commits route list, flow list, node list, selection and draft reset together only after both readers succeed. Page refresh likewise completes all required reads before committing page/scenario data. Existing route mutation refreshes explicitly commit the returned routes, and flow mutation refreshes use the shared successful-flow commit helper, preserving their prior behavior.
+
+Added three regressions: from fully loaded A with an active dirty node draft, fail B's routes, flows or nodes while the other reader succeeds. Each case preserves all A selections/data/drafts and the original failure; a subsequent successful B retry commits all B data and clears the draft/error together. Initial RED: 3 new failures / 2 existing passes, `/private/tmp/f088-platform-h2-atomic-red.log`.
+
+Final focused command:
+
+```sh
+pnpm exec vitest run src/pages/ApprovalPage/useApprovalController.errors.test.tsx src/test/approvalPage.test.tsx
+```
+
+**2 files / 20 tests pass**, no unhandled errors; `/private/tmp/f088-platform-h2-atomic-green.log`. ESLint on the two changed paths passes with the existing baseline (`--pass-on-unpruned-suppressions`), `/private/tmp/f088-platform-h2-atomic-lint.log` empty. Scoped diff whitespace check passes. No other product source, full-suite run or parent-owned Workbench changes in this follow-up. Follow-up status: **DONE**; earlier API-index baseline concern remains unchanged.
diff --git a/features/v3.0.0/088-request-error-contract/platform-migration-plan.md b/features/v3.0.0/088-request-error-contract/platform-migration-plan.md
new file mode 100644
index 0000000000..f11cf974c8
--- /dev/null
+++ b/features/v3.0.0/088-request-error-contract/platform-migration-plan.md
@@ -0,0 +1,13 @@
+# F088 platform consumer migration
+
+## Global constraints
+
+Worktree only: /Users/shanghang/.codex/worktrees/request-error-contract/bisheng; HEAD bc6ca25ba. Read root and platform AGENTS.md. Shared branch and uncommitted work, no repository-wide git writes (stash/reset/clean/restore/checkout), no commits unless explicitly scoped and requested by controller. Do not edit client, packages/api-client, controllers/request.ts, controllers/requestErrors.test.ts, manifests or locks; parent owns these. No bulk migration script: prior 104-file transform rejected by automatic approval and proven unsafe (early returns skipped onBack/next, confirmation does not await Promise). Never apply /private/tmp/f088-migrate-callers.cjs or split its unsafe edits into smaller commands. Inspect each API→consumer→UI chain; keep success continuation inside fulfilled path and loading/locks in finally. Use scoped mutations per verified business chain.
+
+## Task 1: migrate remaining platform consumers to rejecting request contract
+
+Requirements SSOT: spec.md AC-05/07/11/12/13/14/20/21 and design.md in this same directory. All failed wrapped requests already reject ApiError; showApiError(error) displays only once based on error.presentation and ignores cancellations. controllers/request.ts exports handleUiRequest(handler) => (...args)=>void for UI event termination; it catches only at that terminal. Old captureAndAlertRequestErrorHoc currently returns false/canceled for compatibility but will be changed to reject after your consumer migration. Remove all production imports/calls to it, replacing by direct request promises that continue rejecting inside services/hooks; consume errors only at UI event/effect boundaries via catch(showApiError) or equivalent local recovery. For async handlers catch where failure must be terminal; preserve loading and optimistic rollback with finally. Avoid error→false/null/undefined success in queries/APIs; legitimate named domain recovery is allowed. Do not automatically insert return, catch, or finally across functions. Trace confirmation onOk and next: next/onBack only after successful request. Query v4 already upgraded: retain keys, offlineFirst, retry0 and isInitialLoading. Query failures remain error state; do not set empty success. Remove redundant generic notification onError; use showApiError when default interceptor may already display. For charts distinguish error from no data without inventing styling. New UI strings require i18n all three languages; legacy hardcoded Chinese touched must be localized per i18n-localizer.
+
+Parent already migrated ModelPage/manage/{ModelConfig,index,ModelProviderRow,useSystemConfigEnvelope} and tabs/{EvaluationModel,WorkflowModel,AssisModel,KnowledgeModel,WorkbenchModel}; leave these alone except clearly needed followup coordinated with parent. All other platform consumers are yours, including Dashboard, BuildPage, DepartmentPage, SystemPage, KnowledgePage and shared hooks/components. Start by confirming rg captureAndAlertRequestErrorHoc remaining. Inventory migration-inventory.md is candidate evidence, not proof of live use. You may remove unused legacy API branches only after tracing UI entry, not by filename assumption. Do not spawn subagents. Follow TDD for changed behavior with focused tests. Read current UI docs before UI changes, preserve styles. Parent will coordinate independent review; do not dispatch reviewer yourself.
+
+Deliver all remaining platform migration, add regression coverage for success continuation and failure loading/recovery (prioritize Dashboard save→publish and modal confirmations, plus representative busy form). Run focused platform tests (Vitest), changed-file ESLint, tsc strict; report baseline failures separately. Root lint baseline85 errors and typecheck unmodified clientVoiceDraft.tsx:47 is known, don't repair unrelated files. Write report under plan workspace task-1-report.md with changed paths, call chain decisions, tests/output, remaining issues. Return DONE / DONE_WITH_CONCERNS / NEEDS_CONTEXT, concise test summary. Do not claim done if production HOC calls remain or active failures continue success actions.
diff --git a/features/v3.0.0/088-request-error-contract/platform-migration-report.md b/features/v3.0.0/088-request-error-contract/platform-migration-report.md
new file mode 100644
index 0000000000..4626db6135
--- /dev/null
+++ b/features/v3.0.0/088-request-error-contract/platform-migration-report.md
@@ -0,0 +1,208 @@
+# Task 1 report — F088 platform consumer migration
+
+Status: **DONE_WITH_CONCERNS**. Work performed only in /Users/shanghang/.codex/worktrees/request-error-contract/bisheng; no commits, no repository-wide Git writes, no subagents, and no unsafe migration script. Production migration was scoped manually to inspected API → consumer → UI chains.
+
+## Delivered behavior
+
+- All production imports/calls of captureAndAlertRequestErrorHoc have been removed. Final scan `rg -n captureAndAlertRequestErrorHoc src/frontend/platform/src --glob '!*.test.*'` returns only the parent-owned deprecated definition in controllers/request.ts. Parent has switched that compatibility helper to notice + reject.
+- Requests remain rejecting inside reusable APIs/hooks and sequential operations. UI events/effects consume rejection with showApiError or handleUiRequest. Default notices are deduplicated through error.presentation; cancellations are handled by the shared helper.
+- Dashboard: save → publish and save → leave stop on failure; busy state unlocks through mutation settlement; inputs and dirty state remain after save failure. Publish/create/copy/rename/delete/component-copy error callbacks use showApiError. Delete confirmation waits for mutateAsync. Chart query failures remain React Query error state and display operationFailed rather than noDataInChart. Query keys, retry configuration, and isInitialLoading remain unchanged.
+- Confirmation singleton: awaits returned async work, prevents Radix automatic close, disables repeated confirmation while pending, catches only at this UI boundary, and releases the lock on failure. Every migrated confirmation now returns/awaits its request and calls next only after success. Workflow inline version rename similarly preserves the draft and unlocks retry after failure.
+- Build/workflow/assistant: failed save blocks publication, version switching, leave, preview, and follow-up state writes; direct API rejections propagate through inner save functions. Creation/editor forms and test runners use finally for busy flags. Optimistic template ordering and app/card status changes restore prior state on rejection.
+- Workbench settings: policy saves and dependent config saves remain rejecting until actual UI events. Preview awaits success. Failed configuration load cannot silently save a blank replacement in AppCenter, KnowledgeSpace, Subscribe, or system YAML config; existing explicit blind-overwrite confirmation in the daily config is preserved.
+- Knowledge: create/edit failures preserve open forms; failed QA/segment/tag writes preserve user input and selection; failed copies clear the local copy lock; uploads do not advance stages on request failure. Parent-child default upload save retains its Promise so the actual upload button can recover loading in finally.
+- Department/tenant/system/service account: mutations no longer execute success notices, close dialogs, refresh as success, or change displayed status after failure. Account optimistic toggles restore previous status. Batch resource grants preserve uncompleted selections and refresh already completed work after partial failure. Role menu load failure keeps its existing failure flag and prevents invalid save.
+- API wrapper audit found two non-HOC generic swallow points: selectable knowledge-space union returned partial successful data after one request failed, and the editor write-permission check returned undefined after failure. Both now preserve the original rejection; both were reproduced with RED tests first and their active UI consumers were updated.
+- Existing named recoveries were retained: optional license status/best-effort license reporting; permission-summary fail-closed behavior; inherited-admin summary fallbacks; restore current admin selection when a post-save refresh is unavailable; generic tag-delete warning when usage lookup is unavailable; callback/SSE parsing error channels. The unused getRepoStars helper has no production caller (`rg` finds its definition only); no live success flow relies on its legacy null return.
+- i18n: extracted the touched hardcoded app permission warning and tag editor strings. Added build.editPermissionRequired and knowledge.tagEditor.{addPlaceholder,existingTags,emptyTags} in en-US / zh-Hans / ja; reused existing editTag/cancel/confirm keys. No styles or design specs changed.
+
+## Verification
+
+1. Full platform Vitest (after production/API migration, before the final isolated inline-rename regression):
+   - 180 test files: 179 passed / 1 failed.
+   - 907 tests: **905 passed / 2 failed**; no unhandled errors.
+   - Log: /private/tmp/f088-platform-completion-tests.log.
+2. Final focused regressions after inline-rename recovery:
+   - 6 files / **12 tests passed** (confirmation retry, inline version rename, Dashboard save/publish failure and retry, dataset creation failure, original API wrapper rejection, account-toggle rollback).
+   - Log: /private/tmp/f088-platform-regressions.log.
+   - Additional 5 files / 14 tests passed covering Dashboard, API wrappers, department settings, workbench page, and knowledge selector: /private/tmp/f088-platform-last-focused.log.
+   - Service-account mutations + personal token + account rollback: 3 files / 14 tests passed.
+3. Changed-platform-file ESLint with the repository suppression file: **exit 0**. Ran the supported --prune-suppressions command; no hand editing. The suppression file shrank (64-line diff, 53 removals / 11 additions from regenerated counts).
+4. Platform pnpm typecheck: only the known untouched baseline failure remains: src/test/clientVoiceDraft.test.tsx:47 TS2554, Expected 0 arguments but got 1. Parent-owned request test errors seen earlier were fixed by parent.
+5. Root pnpm check-i18n: **OK — no new i18n drift**.
+6. git diff --check: clean.
+7. Relevant skills read: test-driven-development and writing-good-tests, i18n-localizer, approval-module for menu-access request context. UI docs index/component overview read. Existing source-only account-toggle test was replaced with runtime behavioral regression coverage.
+
+## Outstanding concerns / baseline failures
+
+- Full suite has two existing expectation drifts in src/test/knowledgeSpaceGrantSubjects.test.tsx:
+  - “adds a department carrying the current include-children flag when toggled on”: expected only {type,id,name,include_children}, but unchanged SubjectSearchDepartment includes departmentPath, departmentTreePath, externalId.
+  - “loads the user groups grantable on this resource”: expected pageSize 200 without paging/signal, but unchanged SubjectSearchUserGroup sends keyword, page 1, pageSize 50, signal.
+  - `git diff HEAD -- src/frontend/platform/src/components/bs-comp/permission/SubjectSearchDepartment.tsx src/frontend/platform/src/components/bs-comp/permission/SubjectSearchUserGroup.tsx src/frontend/platform/src/components/bs-comp/selectComponent/useSubjectSearch.ts` is empty. These tests previously failed at missing legacy request mocks; fixing those mocks exposed the stale assertions. Their unrelated expectations were left unchanged.
+- Strict baseline clientVoiceDraft.test.tsx:47 remains unmodified as instructed.
+- No browser or middleware E2E was run in this subtask; controller must complete overall feature E2E/independent review before merge.
+- Platform AGENTS.md still shows the old HOC consumer pattern in its API example; controller should update that verified contract documentation when finalizing the overall feature.
+- Full suite ran before the last isolated inline-rename change; that last change was validated with the final focused 12-test run, strict typecheck, lint, and diff check.
+
+## Changed paths owned by this subtask
+
+Parent-owned request foundation, model-manage migration, manifests, generated error artifacts, and voice test are excluded below. The deleted source-only memberTableToggleErrorToast.test.ts is replaced with .test.tsx.
+
+- src/frontend/platform/eslint-suppressions.json
+- src/frontend/platform/public/locales/en-US/bs.json
+- src/frontend/platform/public/locales/en-US/knowledge.json
+- src/frontend/platform/public/locales/ja/bs.json
+- src/frontend/platform/public/locales/ja/knowledge.json
+- src/frontend/platform/public/locales/zh-Hans/bs.json
+- src/frontend/platform/public/locales/zh-Hans/knowledge.json
+- src/frontend/platform/src/components/LinSight/skill/SkillManagement.tsx
+- src/frontend/platform/src/components/VariablesComponent/index.tsx
+- src/frontend/platform/src/components/bs-comp/apiComponent/ChatLink.tsx
+- src/frontend/platform/src/components/bs-comp/cardComponent/index.tsx
+- src/frontend/platform/src/components/bs-comp/department/TreeDepartmentSelect.tsx
+- src/frontend/platform/src/components/bs-comp/department/useLazyDepartmentTree.ts
+- src/frontend/platform/src/components/bs-comp/selectComponent/DepartmentUsersSelect.tsx
+- src/frontend/platform/src/components/bs-comp/selectComponent/LabelSelect.tsx
+- src/frontend/platform/src/components/bs-comp/selectComponent/knowledge.tsx
+- src/frontend/platform/src/components/bs-ui/alertDialog/useConfirm.test.tsx
+- src/frontend/platform/src/components/bs-ui/alertDialog/useConfirm.tsx
+- src/frontend/platform/src/components/bs-ui/input/textInput.test.tsx
+- src/frontend/platform/src/components/bs-ui/input/textInput.tsx
+- src/frontend/platform/src/contexts/tabsContext.tsx
+- src/frontend/platform/src/contexts/userContext.tsx
+- src/frontend/platform/src/controllers/API/businessWrappers.errors.test.ts
+- src/frontend/platform/src/controllers/API/finetune.ts
+- src/frontend/platform/src/controllers/API/flow.ts
+- src/frontend/platform/src/controllers/API/knowledgeSpace.ts
+- src/frontend/platform/src/layout/MainLayout.tsx
+- src/frontend/platform/src/modals/codeAreaModal/index.tsx
+- src/frontend/platform/src/pages/BuildPage/CardSelectVersion.tsx
+- src/frontend/platform/src/pages/BuildPage/CreateApp.tsx
+- src/frontend/platform/src/pages/BuildPage/CreateTemp.tsx
+- src/frontend/platform/src/pages/BuildPage/appTemps.tsx
+- src/frontend/platform/src/pages/BuildPage/apps.tsx
+- src/frontend/platform/src/pages/BuildPage/assistant/CreateAssistant.tsx
+- src/frontend/platform/src/pages/BuildPage/assistant/editAssistant/index.tsx
+- src/frontend/platform/src/pages/BuildPage/bench/AppCenter.tsx
+- src/frontend/platform/src/pages/BuildPage/bench/DailyChatConfig.tsx
+- src/frontend/platform/src/pages/BuildPage/bench/DepartmentKnowledgeSpaceManagerDialog.tsx
+- src/frontend/platform/src/pages/BuildPage/bench/KnowledgeSpace.tsx
+- src/frontend/platform/src/pages/BuildPage/bench/KnowledgeSpaceSensitivePolicy.tsx
+- src/frontend/platform/src/pages/BuildPage/bench/KnowledgeSpaceTagLibrarySection.tsx
+- src/frontend/platform/src/pages/BuildPage/bench/Preview.tsx
+- src/frontend/platform/src/pages/BuildPage/bench/Subscribe.tsx
+- src/frontend/platform/src/pages/BuildPage/bench/SubscriptionSensitivePolicy.tsx
+- src/frontend/platform/src/pages/BuildPage/bench/WorkbenchSensitivePolicy.tsx
+- src/frontend/platform/src/pages/BuildPage/bench/index.tsx
+- src/frontend/platform/src/pages/BuildPage/flow/FlowNode/RunTest.tsx
+- src/frontend/platform/src/pages/BuildPage/flow/FlowNode/component/KnowledgeSelectItem.tsx
+- src/frontend/platform/src/pages/BuildPage/flow/FlowNode/component/ReportWordEdit.tsx
+- src/frontend/platform/src/pages/BuildPage/flow/Header.tsx
+- src/frontend/platform/src/pages/BuildPage/flow/index.tsx
+- src/frontend/platform/src/pages/BuildPage/tools/EditMcp.tsx
+- src/frontend/platform/src/pages/BuildPage/tools/EditTool.tsx
+- src/frontend/platform/src/pages/BuildPage/tools/index.tsx
+- src/frontend/platform/src/pages/Dashboard/components/charts/ChartContainer.test.tsx
+- src/frontend/platform/src/pages/Dashboard/components/charts/ChartContainer.tsx
+- src/frontend/platform/src/pages/Dashboard/components/config/EnumMultiSelect.tsx
+- src/frontend/platform/src/pages/Dashboard/components/dashboard/DashboardSidebar.tsx
+- src/frontend/platform/src/pages/Dashboard/components/editor/EditorCanvas.tsx
+- src/frontend/platform/src/pages/Dashboard/components/editor/EditorHeader.errors.test.tsx
+- src/frontend/platform/src/pages/Dashboard/components/editor/EditorHeader.tsx
+- src/frontend/platform/src/pages/Dashboard/hook.ts
+- src/frontend/platform/src/pages/Dashboard/index.tsx
+- src/frontend/platform/src/pages/DataSetPage/CreateDataSet.test.tsx
+- src/frontend/platform/src/pages/DataSetPage/CreateDataSet.tsx
+- src/frontend/platform/src/pages/DataSetPage/index.tsx
+- src/frontend/platform/src/pages/DepartmentPage/components/AddMemberDialog.test.tsx
+- src/frontend/platform/src/pages/DepartmentPage/components/AddMemberDialog.tsx
+- src/frontend/platform/src/pages/DepartmentPage/components/CreateDepartmentDialog.tsx
+- src/frontend/platform/src/pages/DepartmentPage/components/CreateLocalMemberDialog.tsx
+- src/frontend/platform/src/pages/DepartmentPage/components/DepartmentSettings.tsx
+- src/frontend/platform/src/pages/DepartmentPage/components/DepartmentSettingsView.tsx
+- src/frontend/platform/src/pages/DepartmentPage/components/DepartmentTrafficControl.tsx
+- src/frontend/platform/src/pages/DepartmentPage/components/MemberTable.tsx
+- src/frontend/platform/src/pages/DepartmentPage/components/MountTenantDialog.tsx
+- src/frontend/platform/src/pages/DepartmentPage/components/OrganizationMemberEditDialog.tsx
+- src/frontend/platform/src/pages/DepartmentPage/index.tsx
+- src/frontend/platform/src/pages/EvaluationPage/EvaluationCreate.test.tsx
+- src/frontend/platform/src/pages/EvaluationPage/EvaluationCreate.tsx
+- src/frontend/platform/src/pages/EvaluationPage/index.test.tsx
+- src/frontend/platform/src/pages/EvaluationPage/index.tsx
+- src/frontend/platform/src/pages/KnowledgePage/AdjustFilesUpload.tsx
+- src/frontend/platform/src/pages/KnowledgePage/KnowledgeFile.tsx
+- src/frontend/platform/src/pages/KnowledgePage/KnowledgeQa.tsx
+- src/frontend/platform/src/pages/KnowledgePage/components/DepartmentSpaceDialog.tsx
+- src/frontend/platform/src/pages/KnowledgePage/components/EditQa.tsx
+- src/frontend/platform/src/pages/KnowledgePage/components/FileUploadParagraphs.tsx
+- src/frontend/platform/src/pages/KnowledgePage/components/FileUploadStep1.tsx
+- src/frontend/platform/src/pages/KnowledgePage/components/FileUploadStep4.tsx
+- src/frontend/platform/src/pages/KnowledgePage/components/Files.tsx
+- src/frontend/platform/src/pages/KnowledgePage/components/Header.tsx
+- src/frontend/platform/src/pages/KnowledgePage/components/ImportQa.tsx
+- src/frontend/platform/src/pages/KnowledgePage/components/KnowledgeLibraryDialog.tsx
+- src/frontend/platform/src/pages/KnowledgePage/components/ParagraphEdit.tsx
+- src/frontend/platform/src/pages/KnowledgePage/components/Paragraphs.tsx
+- src/frontend/platform/src/pages/KnowledgePage/components/PreviewResult.test.tsx
+- src/frontend/platform/src/pages/KnowledgePage/components/PreviewResult.tsx
+- src/frontend/platform/src/pages/KnowledgePage/components/QaLibraryDialog.tsx
+- src/frontend/platform/src/pages/KnowledgePage/components/tags/KnowledgeTagSelect.tsx
+- src/frontend/platform/src/pages/KnowledgePage/detail.tsx
+- src/frontend/platform/src/pages/KnowledgePage/filesUpload.tsx
+- src/frontend/platform/src/pages/KnowledgePage/qas.tsx
+- src/frontend/platform/src/pages/LabelPage/AddSimilarQuestions.tsx
+- src/frontend/platform/src/pages/LabelPage/SaveQaLibForm.tsx
+- src/frontend/platform/src/pages/LabelPage/annotationForms.test.tsx
+- src/frontend/platform/src/pages/LabelPage/index.test.tsx
+- src/frontend/platform/src/pages/LabelPage/index.tsx
+- src/frontend/platform/src/pages/LabelPage/taskAppChats.test.tsx
+- src/frontend/platform/src/pages/LabelPage/taskAppChats.tsx
+- src/frontend/platform/src/pages/LogPage/useAppLog/appChatDetail.tsx
+- src/frontend/platform/src/pages/LoginPage/TenantSelect.tsx
+- src/frontend/platform/src/pages/LoginPage/UserPwdModal.tsx
+- src/frontend/platform/src/pages/LoginPage/login.tsx
+- src/frontend/platform/src/pages/LoginPage/resetPwd.tsx
+- src/frontend/platform/src/pages/MenuPermissionPlaceholder.tsx
+- src/frontend/platform/src/pages/ModelPage/components/CreateTask.tsx
+- src/frontend/platform/src/pages/ModelPage/components/FinetuneDetail.tsx
+- src/frontend/platform/src/pages/ModelPage/components/RTConfig.tsx
+- src/frontend/platform/src/pages/ModelPage/finetune.tsx
+- src/frontend/platform/src/pages/SystemPage/components/Config.tsx
+- src/frontend/platform/src/pages/SystemPage/components/CreateUser.tsx
+- src/frontend/platform/src/pages/SystemPage/components/Departments.tsx
+- src/frontend/platform/src/pages/SystemPage/components/OrgSync/index.tsx
+- src/frontend/platform/src/pages/SystemPage/components/PersonalToken/index.tsx
+- src/frontend/platform/src/pages/SystemPage/components/Roles.tsx
+- src/frontend/platform/src/pages/SystemPage/components/ServiceAccount/ApiKeysTab.tsx
+- src/frontend/platform/src/pages/SystemPage/components/ServiceAccount/CreateServiceAccountDialog.tsx
+- src/frontend/platform/src/pages/SystemPage/components/ServiceAccount/KeyIssueDialog.tsx
+- src/frontend/platform/src/pages/SystemPage/components/ServiceAccount/OverviewTab.tsx
+- src/frontend/platform/src/pages/SystemPage/components/ServiceAccount/ResourceGrantDialog.tsx
+- src/frontend/platform/src/pages/SystemPage/components/ServiceAccount/ResourceGrantsTab.tsx
+- src/frontend/platform/src/pages/SystemPage/components/ServiceAccount/index.tsx
+- src/frontend/platform/src/pages/SystemPage/components/UserGroup.tsx
+- src/frontend/platform/src/pages/SystemPage/components/UserRoleModal.tsx
+- src/frontend/platform/src/pages/SystemPage/components/Users.tsx
+- src/frontend/platform/src/pages/SystemPage/theme/BrandAssetUpload.tsx
+- src/frontend/platform/src/pages/SystemPage/theme/BrandCustomization.tsx
+- src/frontend/platform/src/pages/SystemPage/theme/WorkbenchThemeSettings.tsx
+- src/frontend/platform/src/pages/TenantPage/components/CreateTenantDialog.tsx
+- src/frontend/platform/src/pages/TenantPage/components/TenantQuotaDialog.tsx
+- src/frontend/platform/src/pages/TenantPage/components/TenantUserDialog.tsx
+- src/frontend/platform/src/pages/TenantPage/index.tsx
+- src/frontend/platform/src/pages/TenantPage/tenantModule.test.tsx
+- src/frontend/platform/src/pages/publicEntries.test.tsx
+- src/frontend/platform/src/test/departmentKnowledgeSpaceDialogs.test.tsx
+- src/frontend/platform/src/test/departmentPageRefresh.test.tsx
+- src/frontend/platform/src/test/departmentSettingsPayload.test.tsx
+- src/frontend/platform/src/test/departmentUsersSelectScope.test.tsx
+- src/frontend/platform/src/test/knowledgeSpaceGrantSubjects.test.tsx
+- src/frontend/platform/src/test/mainLayoutSystemNavChildAdmin.test.tsx
+- src/frontend/platform/src/test/mainLayoutWorkspaceMenu.test.tsx
+- src/frontend/platform/src/test/memberTableToggleErrorToast.test.ts
+- src/frontend/platform/src/test/memberTableToggleErrorToast.test.tsx
+- src/frontend/platform/src/test/personalTokenSettingsInteraction.test.tsx
+- src/frontend/platform/src/test/serviceAccountResourceGrantMutations.test.tsx
+- src/frontend/platform/src/test/serviceAccountResourceGrants.test.tsx
+- src/frontend/platform/src/test/systemDepartmentsArchivedDelete.test.tsx
+- src/frontend/platform/src/test/toolLazyPermissions.test.tsx
diff --git a/features/v3.0.0/088-request-error-contract/spec-review.md b/features/v3.0.0/088-request-error-contract/spec-review.md
new file mode 100644
index 0000000000..c77b4463b7
--- /dev/null
+++ b/features/v3.0.0/088-request-error-contract/spec-review.md
@@ -0,0 +1,29 @@
+# F088 spec 审查
+
+日期:2026-10-22。审查类型:按 `.claude/skills/sdd-review` 的 spec 清单进行作者自审;不是独立代码审查或运行测试。
+
+## 输入
+
+- `spec.md` 与 `discovery.md`;无独立 PRD,以本会话接受的范围为需求来源。
+- `features/v3.0.0/release-contract.md`。
+- `docs/constitution.md`。
+- `docs/architecture/02-backend-modules.md` 与 `docs/architecture/10-permission-rbac.md`,作为责任边界参考;现行权限规则以 constitution 为准。
+
+## 检查结果
+
+| 检查项 | 结论 |
+|---|---|
+| 需求覆盖 | AC-01 至 AC-05 覆盖完整透传;AC-06 至 AC-10 覆盖提示独立;AC-11 至 AC-14 覆盖业务恢复;AC-15 至 AC-21 覆盖全局处理、兼容和完整迁移 |
+| 错误与边界 | 网络/超时、取消、HTTP 成功中业务失败、特殊结果、非统一响应、二进制和不可读错误均有可观察要求 |
+| 范围 | 明确两端统一 HTTP 入口;不扩展后端、成功结构、查询库、UI 样式及其他传输通道 |
+| AC 标识 | AC-01 至 AC-21 唯一、完整;每条为 WHEN/WHERE/WHILE EARS 句型 |
+| 可测试性 | 每条有触发条件与结果;输入保留、状态恢复、提示次数、失败证据均可断言 |
+| 错误码 | 仅在边界中引用已存在的 11010,不登记新码段 |
+| 领域与架构 | 不新增后端领域对象,不改变授权判定和既有全局处理;遵守 C5、C7,不涉及数据库及权限运行时变更 |
+| What/How 分离 | 未指定异常类型、属性名、选项签名或模块拆分;技术契约和调用名单留给 design / tasks |
+
+## 清单输出
+
+LGTM
+
+结论仅表示文档清单自审无已识别的需求缺口。本报告生成时 spec 待用户确认;用户随后已确认,状态见 tasks.md。该确认不等于 design 获批或运行验证通过。
diff --git a/features/v3.0.0/088-request-error-contract/spec.md b/features/v3.0.0/088-request-error-contract/spec.md
new file mode 100644
index 0000000000..5bdb8d7596
--- /dev/null
+++ b/features/v3.0.0/088-request-error-contract/spec.md
@@ -0,0 +1,70 @@
+# Feature: 前端请求错误完整透传
+
+> 本文档只定义需求、验收标准和范围。状态:2026-10-22 用户已确认;已进入产品代码实施,验收状态见 tasks.md。
+
+**关联 PRD**:无独立 PRD;需求来自 2026-10-22 本会话,已确认的目标与发现见 [discovery.md](./discovery.md)。
+**优先级**:P1
+**所属版本**:v3.0.0 / F088
+**依赖**:两端现有请求、错误提示、登录与权限处理能力;复用发版线已合入的 React Query 4.44.0 升级。
+
+## 1. 用户故事与范围
+
+作为平台使用者,我希望操作失败后保留输入、恢复可操作状态,并看到准确且不重复的提示,以便知道是否需要重试或调整输入。
+
+作为业务功能维护者,我希望两端都能取得完整失败原因,以便处理表单错误、业务冲突和页面状态恢复,无需为每个接口绕开统一请求处理。
+
+本次纳入:platform 与 client 经各自统一 HTTP 请求入口发出的请求;其 API、页面与 hooks 的错误消费;错误提示与失败状态的分离;旧调用方式迁移和回归。
+
+本次排除:后端返回协议和错误码定义调整;两端成功结果结构合并;请求库、查询库和状态库升级;提示样式或权限规则变更;自动重试和新业务恢复机制;SSE、WebSocket、原生 fetch 等未经过上述统一入口的通道。排除的原因是这些能力不需要随错误透传调整,改动会扩大兼容范围。
+
+## 2. 验收标准
+
+### 2.1 识别失败与保留原因
+
+- **AC-01** — WHEN 接口在 HTTP 成功响应中明确返回业务失败,THE SYSTEM SHALL 将这次调用判为失败,使调用方进入失败处理,不能继续执行成功后的动作。
+- **AC-02** — WHEN 请求因 HTTP 错误、网络异常或超时失败,THE SYSTEM SHALL 使调用方进入失败处理,不将失败作为空的成功结果返回。
+- **AC-03** — WHEN 调用方处理请求失败,THE SYSTEM SHALL 允许其分别取得实际存在的 HTTP 状态、业务错误码、用户可读消息、服务端附加信息及原始失败证据;不存在的信息不得被伪造为成功或其他业务错误。
+- **AC-04** — WHEN 任一端处理同类请求失败,THE SYSTEM SHALL 提供一致的错误含义,使业务分支无需依赖字符串猜测、消息文本比较或“有没有弹窗”来判断失败类型。
+- **AC-05** — WHEN 已有业务包装层接收到请求失败,THE SYSTEM SHALL 在完成本次迁移后继续向调用方传递失败及完整原因,不能仅以 false、undefined、null 或消息字符串替代该失败。
+
+### 2.2 提示与业务处理
+
+- **AC-06** — WHEN 请求采用统一提示策略并失败,THE SYSTEM SHALL 保留对应入口既有的默认提示效果,同时允许调用方继续取得 AC-03 所列信息。
+- **AC-07** — WHEN 同一请求失败已由统一提示展示,THE SYSTEM SHALL 避免因 API 包装层或业务层的通用提示再次展示相同错误。
+- **AC-08** — WHERE 请求由业务自行提示或明确要求静默,WHEN 请求失败,THE SYSTEM SHALL 保留完整失败信息并执行所需业务恢复,不因关闭普通提示而将请求判为成功。产品既有强制全局处理仍满足 AC-15。
+- **AC-09** — WHEN 失败消息已有简体中文、英文或日文翻译,THE SYSTEM SHALL 按当前语言展示对应消息;未登记的服务端消息仍须可阅读,不得展示翻译键、对象默认字符串或未替换的模板占位符。
+- **AC-10** — WHEN 请求被主动取消,THE SYSTEM SHALL 使调用方可识别取消,且不将取消展示为普通失败提示。
+
+### 2.3 页面行为
+
+- **AC-11** — WHEN 表单提交或编辑保存失败,THE SYSTEM SHALL 恢复可操作状态、保留用户输入,不显示成功提示,不因失败自动关闭编辑窗口或执行成功后的跳转。
+- **AC-12** — WHEN 列表加载失败且未被既有业务恢复规则明确处理,THE SYSTEM SHALL 保持失败可辨认,不能把失败伪装成成功加载的空列表。
+- **AC-13** — WHEN 删除或状态切换失败,THE SYSTEM SHALL 不把目标显示为已成功变更;已有乐观更新的入口须恢复或重新同步页面状态。
+- **AC-14** — WHEN 业务已存在针对特定错误码的恢复流程,THE SYSTEM SHALL 保持该流程可用,并让其依据错误码及附加信息执行,不依赖通用提示文案。
+
+### 2.4 全局与兼容边界
+
+- **AC-15** — WHEN 发生登录失效、账号被踢、通用权限不足、许可证失效或维护状态,THE SYSTEM SHALL 保留该入口既定的跳转、确认、提示节流或遮罩效果,并向调用方保留可识别的失败信息。
+- **AC-16** — WHEN 并发请求触发同一会话或许可证全局事件,THE SYSTEM SHALL 保持既有去重、节流和确认顺序,不因错误透传叠加重复全局提示或跳过确认。
+- **AC-17** — WHEN 请求本身成功,THE SYSTEM SHALL 保持两端各自既有成功结果和文件下载效果;合法的非统一格式响应不能仅因缺少业务状态码被判为失败。
+- **AC-18** — WHEN 接口约定中的非普通结果需要调用方继续交互,THE SYSTEM SHALL 保留该交互流程,不能仅因结果形式特殊而将其当作普通成功或普通失败处理。
+- **AC-19** — WHEN 请求返回不可读或不符合预期的错误内容,THE SYSTEM SHALL 使失败保持可识别,展示可读的既有通用错误消息,并保留原始证据;错误消息解析自身不能中断失败处理。
+- **AC-20** — WHILE 两端迁移分批进行,THE SYSTEM SHALL 使每批受影响入口同时满足对应失败传递及页面恢复要求,不要求使用者承担失败后错误成功提示、按钮卡住或未处理异常造成的功能故障。
+- **AC-21** — WHEN 本特性交付验收,THE SYSTEM SHALL 使两个统一请求入口及实际消费它们错误的调用方均满足 AC-01 至 AC-20;不能仅用局部选择开启透传来宣称完成。
+
+## 3. 边界情况
+
+- HTTP 状态和业务错误码可能不同;二者保留各自含义,见 AC-03。
+- 网络断开或取消时可能没有服务端响应;保留真实原因,见 AC-02、AC-03、AC-10。
+- 错误消息可能为数组、嵌套对象或未替换模板,见 AC-09、AC-19。
+- 关闭普通提示不能被解释成关闭已有登录/安全处理;既有明确的请求级全局处理例外在设计阶段逐项核实,见 AC-08、AC-15。
+- 下载成功返回二进制,下载失败也可能返回二进制包装的错误;成功须可用,失败须可识别,见 AC-17、AC-19。
+- 已有明确允许的“许可证状态不可用时隐藏”等业务恢复不是通用吞错;须逐项保留并测试,见 AC-12、AC-14。
+- 特殊状态 11010 当前只有拦截器放行证据,尚未找到活跃业务语义;实施不得凭猜测新增恢复流程,按 AC-18 核实后在 design 中记录。
+
+## 4. 确认与实现入口
+
+- Discovery:用户于 2026-10-22 接受“完整透传、默认提示独立、渐进迁移”的方向并要求开始。
+- spec:用户于 2026-10-22 确认,并提醒关注刚升级的 React Query;原审查记录见 [spec-review.md](./spec-review.md)。
+- design:[design.md](./design.md) 已修订并获授权实施;执行状态见 [tasks.md](./tasks.md)。
+- 版本约束:[release-contract.md](../release-contract.md);架构约束:[constitution.md](../../../docs/constitution.md)。
diff --git a/features/v3.0.0/088-request-error-contract/tasks.md b/features/v3.0.0/088-request-error-contract/tasks.md
new file mode 100644
index 0000000000..51118fbf41
--- /dev/null
+++ b/features/v3.0.0/088-request-error-contract/tasks.md
@@ -0,0 +1,52 @@
+# F088 开发任务与执行记录
+
+## 状态
+
+| 项目 | 状态 | 依据 |
+|---|---|---|
+| spec | ✅ 已确认 | 用户确认 |
+| design | ✅ 修订获授权 | 用户“补齐这两点后开始开发吧”;两项独立审查修订见 design |
+| 基线 | ✅ bc6ca25ba | 两端 React Query 4.44.0,保留已升级实现 |
+| 实施 | ✅ 功能代码已落地 | 受影响链回归与限定范围独立复核通过;不等同整体验收 |
+| 最终验收 | ⚠️ 未完成 | 基线门禁仍失败、真实后端E2E未执行,见verification-report.md |
+
+## 任务
+
+- [x] T001 共同错误模型与二进制解析通道。覆盖 AC: AC-02、AC-03、AC-04、AC-09、AC-10、AC-19。文件:packages/api-client(模型、解析、测试、配置),两端依赖与质量门禁。先验证正常 body 与显式解码 envelope 能同时保留原始 cause/response/二进制引用,再实现。验证包测试、TS 与 lint。
+- [x] T002 platform 入口与强耦合兼容。依赖 T001。覆盖 AC: AC-01、AC-03、AC-06、AC-07、AC-08、AC-10、AC-14、AC-15、AC-16、AC-17、AC-18。文件:controllers/request.ts、消息/提示适配器、types/global.d.ts、LoginPage/login.tsx、DSH 及下载消费和对应测试。测试密码过期、取消、全局、silent、已提示后仍可捕获完整错误及成功结构。
+- [x] T003 platform 业务调用迁移与 v4 衔接。依赖 T002。覆盖 AC: AC-05、AC-07、AC-11、AC-12、AC-13、AC-14、AC-20、AC-21。文件:migration-inventory 中 platform 活跃 API→hook→页面链,尤其 ModelPage/manage、Dashboard、BuildPage、DepartmentPage、SystemPage、KnowledgePage;每批补行为回归,清理失败哨兵和吞错。保留离线/缓存键/isInitialLoading;refetch/保存/发布失败不继续成功动作。
+- [x] T004 client 强耦合消费预适配与入口切换。依赖 T001。覆盖 AC: AC-01、AC-03、AC-06、AC-07、AC-08、AC-10、AC-14、AC-15、AC-16、AC-17、AC-18、AC-19。文件:api/request.ts、api/skillCenter.ts、Skills/types.ts、SkillUploadDialog.tsx 等 Skills 活跃消费、api/messageExport.ts、其他下载、全局监听及测试。旧/新错误输入验证11053未导入、11055重名覆盖、401及二进制证据,再切入口。
+- [x] T005 client 调用方及查询失败迁移。依赖 T004。覆盖 AC: AC-05、AC-07、AC-11、AC-12、AC-13、AC-14、AC-20、AC-21。文件:migration-inventory 的 client API/React Query 消费链;保留命名恢复,移除重复错误构造/提示,避免新业务错误重试;明确异步事件终点捕获。
+- [ ] T006 质量门禁、E2E 与独立终审。依赖 T003、T005。覆盖 AC: AC-01、AC-02、AC-03、AC-04、AC-05、AC-06、AC-07、AC-08、AC-09、AC-10、AC-11、AC-12、AC-13、AC-14、AC-15、AC-16、AC-17、AC-18、AC-19、AC-20、AC-21。运行受影响测试、lint/typecheck/check-i18n、两端构建、e2e-test、sdd-code-review;固定剩余问题、基线差异与回归证据。
+
+## 执行记录与偏差
+
+- 基线由72cee6ca5快进至bc6ca25ba,响应用户已合入的查询库升级;相关4文件8测试通过。
+- 独立设计审查两项问题均已通过设计修订处理。用户授权修订后直接实施,不追加重复确认。
+- Ruling: 先预适配消费方再原子切换入口;不交付中途开关或只完成局部透传。若分类遗漏会破坏旧业务,必须以真实消费回归与清单闭环兜住。
+
+- Ruling: 自动审批拒绝整段替换 client 拦截器(涉及删除刷新队列);采用保留原认证/刷新代码、在响应链末端增量规范化的低风险方案。若遗留分支新增特殊结果,需同步最终失败识别测试;不绕过审批删除旧会话逻辑。
+
+- T001: 共同错误模型10项行为测试由失败转通过;独立tsc与eslint通过;接口已包含解码envelope并保留cause/response引用。
+- T002/T004: 平台请求4项、客户端契约与命名恢复5项通过;技能消费18项通过;模型查询3文件9项通过。旧提示包装层继续reject的用例已先复现,尚未切换所有业务调用。
+- Ruling: 自动审批拒绝跨104文件535处的通用批量变换,操作未执行;改为逐业务链人工核实与迁移,不能用语法检查冒充行为验证。
+- 基线门禁:平台全量首轮172文件888测试通过,4项旧DSH错误载体断言需随本特性更新;3项未改动基线失败待独立对照。lint85项均在未改动文件;typecheck在未改动clientVoiceDraft测试报1项参数错误。
+
+- 基础设施独立复查:二进制全局动作、已加载配置刷新失败恢复、HTTP失败无MIME错误解码已补修;通用迁移脚本确认会跳过ModelConfig删除后的onBack/next且不能正确处理确认框Promise,永久弃用写入功能。后续按业务链迁移。
+
+- Ruling: AppChat detail请求原本skip403Redirect=true就reject所有失败,其resolved失败后重新接已删除应用的分支在基线不可达。按AGENTS“死代码删除而非重连”移除该旧分支,新增UI失败/重试终点;不凭存在源码宣称历史恢复曾正常工作。
+- client消费回归:上传配额19403早停与参数透传从失败转通过;分享失效404/网络失败重试/维护流程3项从失败转通过;Skills18项通过。
+
+- platform生产HOC调用清零,兼容导出最终切换为提示后reject;3请求回归文件13项通过。迁移独立实施agent已跑到179文件178通过、903项通过、2个已在初轮复现的未改动基线断言失败;无unhandled。尚待最终质量与独立终审。
+- client: 菜单申请2项、审批详情错配/列表失败2项、二进制全局流程/元信息10项、inline rename保留输入/重试5项通过。共享语音新增原错误透传回归由失败转通过,共享包模板12项通过。
+
+## 最终收口(2026-10-22)
+
+- platform与client活跃链的迁移、两轮审查修复、附件双文件计数和模型详情失败恢复已落地;原始设计两点及新增审查问题的处置见各review/report。
+- 新增调用只在UI终点消费拒绝;查询/API仍保留失败和原证据。实际包依赖、拦截器接线、旧HOC生产调用清零、命名恢复和消费入口均有源码/行为核实,没有凭feature文档宣布发版。
+- 两端保持已升级的React Query4.44.0;错误缓存/重试/离线工厂回归保留。
+- T006已执行本地自动化、构建、质量检查和独立限定范围审查;尚未满足整体验收,维持未勾选。完整结果、基线对照与需后续真实环境执行部分见verification-report.md、e2e-report.md、e2e-checklist.md。未推送/合并。
+
+- 最终快照:platform946/948(2旧断言);client687/707(20旧失败,31suite均在干净HEAD复现,新失败suite0);共享12/12;双端构建通过。platform strict仅已复现旧错误1项,client1493strict全过,翻译通过。两端抑制仅收缩无增长。SDD整体验收仍NEEDS_FIX,仅待基线门禁与真实页面E2E,非隐瞒为全绿。
+
+- 用户后续明确要求停止Vite、合并推送3.0.0并删除开发分支;已在最新发版线4bb923a43上逐块合并并重新验证,见integration-report.md。该集成授权不改变T006的真实业务E2E/基线门禁状态。
diff --git a/features/v3.0.0/088-request-error-contract/verification-report.md b/features/v3.0.0/088-request-error-contract/verification-report.md
new file mode 100644
index 0000000000..4804d3b347
--- /dev/null
+++ b/features/v3.0.0/088-request-error-contract/verification-report.md
@@ -0,0 +1,77 @@
+# F088 实施与验收记录
+
+日期:2026-10-22。隔离工作区:`/Users/shanghang/.codex/worktrees/request-error-contract/bisheng`;分支`codex/3.0.0-088-request-error-contract`;基线`bc6ca25ba`。本节为实施验收时点记录;随后用户授权合并推送,最新发版线集成验证见integration-report.md。
+
+## 结论
+
+两端错误契约与已核实活跃消费链的产品修改已落地,独立代码复核限定范围通过。**整体验收未通过,不能宣称可合入**:根lint/typecheck及全量回归仍有已复现的基线失败,真实后端页面E2E未执行。实现完成与验收完成分开记录。
+
+## 实现
+
+- 共享ApiError保留HTTP状态、业务码、可读消息、业务data、原response/cause及transport code;取消、静默、已展示/全局接管均继续可识别失败。
+- platform默认通知后继续reject;原HOC生产调用清零,兼容导出不再返回false。client保留原认证/刷新队列和成功结果,在原处理后追加规范化;WeakMap记录实际已发生的提示/全局动作。
+- 两端保留React Query 4.44.0现有工厂、缓存键、offlineFirst与原重试预算;新业务失败不变成成功null或额外重试。
+- 表单/确认/编辑器在成功后关闭或跳转,失败恢复锁并保留输入;列表、详情和聊天历史区分失败与空结果,可重试。清空与异步读取绑定资源/会话,防止延迟结果覆盖新内容。
+- 命名恢复保留:10601重置密码、11010遗留交互、Skills未导入/重名覆盖、配额批次早停、公众号返回修改、引用过期、许可证/权限/维护/被踢处理及批量部分成功汇总。
+- HTTP preview callback携带原错误;下载原始二进制/headers不改写。上传、导出/导入、语音及制品终点尊重取消/global/presentation。
+
+## 最终验证
+
+| 检查 | 实际结果 |
+|---|---|
+| 共享错误包 | 12/12测试通过;tsc与lint通过 |
+| platform完整Vitest | 197文件:196通过/1失败;948测试:946通过/2失败。最后附件修复后完整重跑,剩余2项均为干净HEAD同样失败的旧断言,无unhandled汇总 |
+| client完整Jest | 111 suites:80通过/31失败;707 tests:687通过/20失败。与同依赖干净HEAD完整95 suites对照,新增失败suite为0;原1个制品测试suite已修复,剩余31个均在基线失败 |
+| platform生产构建 | 通过(最后附件计数修复后运行) |
+| client生产构建 | 通过(最后导出/上传修改后运行) |
+| platform strict类型 | 800个strict文件,仅1项已复现的clientVoiceDraft旧测试参数错误;本次新增文件无类型错误 |
+| client strict类型 | 1493个strict文件全部通过 |
+| 根pnpm lint/typecheck | 已执行,未通过;分别阻断于platform既有85项lint和下述旧测试类型问题 |
+| platform lint:prune | 85项既有错误;与干净HEAD同5文件同85项。新增/修改链的聚焦lint通过 |
+| client lint:prune | 28项错误+1项警告;HEAD同依赖101项错误+1项警告,本次缩减73项错误;最后终审修改文件聚焦lint通过 |
+| 翻译检查 | check-i18n通过,无新增漂移;新键同批三语言 |
+| 抑制基线 | 仅通过lint:prune收缩:platform2258→2210,client2044→1994;逐文件/规则比较无增加 |
+| 架构与空白 | 修改前端源逐文件arch-guard无输出;git diff --check通过 |
+| 真实后端E2E | 业务页面未执行;后续已启动两端Vite并复用主工作区已有120代理,页面及两端env探测通过。清单见e2e-checklist.md,不用连通性/单测冒充浏览器验收 |
+
+完整Jest的31个旧失败suite含Node环境全局setup访问Element、旧权限控件缺ConfirmProvider、ESM夹具转换/旧Skills归档夹具等。完整Vitest的旧断言与类型失败见下一节。不扩大本次特性去修改这些发版线存量问题。
+
+## 基线对照方法与剩余门禁
+
+从`git archive bc6ca25ba src/frontend`导出干净源码至`/private/tmp/f088-baseline-frontend`,链接同一已安装依赖运行对照,没有清理/切换共享工作区,也没有手工修改抑制文件。
+
+- platform旧失败:`knowledgeSpaceGrantSubjects.test.tsx`两项断言分别未包括已有部门路径/外部ID字段、仍期望旧groups pageSize200。干净HEAD同文件8测试中相同2项失败;当前保留。对应selector业务实现未因本特性改变。
+- platform旧类型失败:`src/test/clientVoiceDraft.test.tsx:47`向无参回调传入true;干净HEAD独立typecheck同样失败。
+- platform lint的85项集中于`useSubjectSearch.ts`、`controllers/API/index.ts`、`controllers/API/license.ts`、`f067LicenseBannerCopy.test.ts`、`sandboxEndpoint.test.ts`。本次API/index只改错误callback证据/取消,不新增any;新增/修改行聚焦检查无新违规。
+- client的28项lint错误为`chatApi.ts`17项既有any及未改动CitationReferencesDrawer/skillMenuSelection/useSubjectKeyword等遗留项,另有旧unused-disable警告。抑制阈值没有放宽。
+- UI/交互真实环境验证仍需现有管理员与普通用户账号及可达后端,按e2e-checklist逐项记录后再做合入验收。
+
+## AC追溯
+
+| AC | 自动化/静态证据 | 页面验收 |
+|---|---|---|
+| AC-01、AC-02、AC-03、AC-04 | packages/api-client apiError;两端requestErrors/clientRequestContract/requestCompatibilityMatrix;二进制/传输原对象引用断言 | 待真实后端抽验 |
+| AC-05 | legacy helper reject、businessWrappers.errors、knowledge.errors、previewFileSplit.errors、skillCenterErrors及活跃调用链审查 | 待 |
+| AC-06、AC-07、AC-08 | 默认/silent/opt-in adapter断言;showApiError幂等、exportFailureContract/importFailureContract/uploadTerminalErrors、skill专业提示 | 待 |
+| AC-09、AC-10 | 模板纯函数、validation数组、三语言检查、取消矩阵与业务终点取消回归 | 待 |
+| AC-11 | PromptDialog/Workbench提交刷新分离/ModelConfig详情、Finetune/EditLabel/TextInput、useConfirm、inline rename和Dashboard顺序链 | 待 |
+| AC-12 | queryConfigMigration、useSystemConfigEnvelope、sidebars/source/article/picker失败重试、审批列表详情与chatHistoryErrors | 待 |
+| AC-13 | member toggle、service/account状态、文件删除rollback、清空失败保留和跨资源归属、QA批量/单项状态 | 待 |
+| AC-14 | Skills11053/11055、上传19403插值早停、公众号取消/过期请求、分享/引用过期、专业批量汇总 | 待 |
+| AC-15、AC-16 | platform会话踢出/后续401、并发license节流与silent/showError区别;client Blob全局动作与原认证代码保留审查 | 真实账号/刷新队列并发仍待 |
+| AC-17、AC-18、AC-19 | 非envelope/成功下载/headers、11010、错误Blob/无MIME HTTP失败、messageExport原对象与文件名回归 | 待 |
+| AC-20、AC-21 | 两端真实统一入口已接线、生产HOC迁移及限定活跃链独立审查;不以文本扫描代替运行时全覆盖 | 整体验收未完成 |
+
+## 独立复核与发布状态
+
+platform-code-review.md与client-code-review.md记录限定活跃链的Spec/Quality PASS及覆盖边界。最后上传计数闭环与ModelConfig详情失败回归也纳入复核:platform两次最终聚焦分别47项、27项;client最后精准复核41项通过。审查不认证未检查旧路径,更不认证真实后端部署、权限账号或浏览器E2E。
+
+按SDD七维,当前无已知未关闭的新增产品代码阻断;边界、并发、鉴权兼容、证据保留、风格及文档在已检查范围通过;**测试/验收维度仍为NEEDS_FIX**(基线门禁与真实页面执行未关闭)。`T006`保持未勾选,Overall:**NEEDS_FIX(验收未完成)**。
+
+## 主要命令与证据
+
+受控adapter/组件测试使用本地Vitest/Jest;完整命令保留于各迁移/审查报告。主要日志:`/private/tmp/f088-platform-completion-tests.log`、`f088-client-full-final.log`、`f088-client-baseline-full.log`、`f088-platform-baseline-assertions.log`、`f088-platform-completion-build.log`、`f088-client-build-final.log`、`f088-platform-completion-types.log`、`f088-client-types-latest.log`、`f088-client-regression-comparison.json`、`f088-platform-prune-latest.log`、`f088-client-prune-final.log`。
+
+## 后续集成
+
+用户已明确授权停止Vite、合并推送feat/3.0.0并删除开发分支。合入最新发版线的冲突处置及重新验证见integration-report.md;保留本报告原始验收限制,不将E2E改为通过。
diff --git a/features/v3.0.0/089-shared-preview-citation/browser-smoke/README.md b/features/v3.0.0/089-shared-preview-citation/browser-smoke/README.md
new file mode 100644
index 0000000000..4007fb172f
--- /dev/null
+++ b/features/v3.0.0/089-shared-preview-citation/browser-smoke/README.md
@@ -0,0 +1,14 @@
+# F089 浏览器样本验证
+
+从仓库根目录运行,需 Node 20、已安装的工作区依赖及 macOS Google Chrome。不会连接业务后端或改动真实资料。
+
+```sh
+bash features/v3.0.0/089-shared-preview-citation/browser-smoke/regression.sh
+node --experimental-websocket features/v3.0.0/089-shared-preview-citation/browser-smoke/run.mjs
+# 先完成两端生产构建,再执行:
+node --experimental-websocket features/v3.0.0/089-shared-preview-citation/browser-smoke/production.mjs
+```
+
+每次启动独立 Chrome 配置和本机临时端口,结束后关闭。结果、截图与缓存保留在输出的临时目录。开发样本临时生成两端入口,退出时删除;使用两端实际 Vite 配置、样式和别名,仅排除会重写测试入口的 HTML 路由插件。同时直接请求共享源码的 Vite 转换结果,避免依赖预优化缓存漏掉路径问题。真实业务权限、分享和保存操作仍需按上一层验收清单测试。
+
+生产静态启动没有后端,client 环境接口的离线 404 属预期;脚本单独检查 ReferenceError/HighlightJS,报告保留全部异常供核对。资源体积和懒加载清单是静态文件检查,不等价于全部业务入口性能评测。
diff --git a/features/v3.0.0/089-shared-preview-citation/browser-smoke/fixtures/image.svg b/features/v3.0.0/089-shared-preview-citation/browser-smoke/fixtures/image.svg
new file mode 100644
index 0000000000..c1087259f9
--- /dev/null
+++ b/features/v3.0.0/089-shared-preview-citation/browser-smoke/fixtures/image.svg
@@ -0,0 +1 @@
+Preview fixture
diff --git a/features/v3.0.0/089-shared-preview-citation/browser-smoke/fixtures/mixed.pdf b/features/v3.0.0/089-shared-preview-citation/browser-smoke/fixtures/mixed.pdf
new file mode 100644
index 0000000000..0a6b111115
Binary files /dev/null and b/features/v3.0.0/089-shared-preview-citation/browser-smoke/fixtures/mixed.pdf differ
diff --git a/features/v3.0.0/089-shared-preview-citation/browser-smoke/fixtures/sample.csv b/features/v3.0.0/089-shared-preview-citation/browser-smoke/fixtures/sample.csv
new file mode 100644
index 0000000000..07b8401d54
--- /dev/null
+++ b/features/v3.0.0/089-shared-preview-citation/browser-smoke/fixtures/sample.csv
@@ -0,0 +1,3 @@
+name,value
+Alpha,42
+Beta,7
diff --git a/features/v3.0.0/089-shared-preview-citation/browser-smoke/fixtures/sample.docx b/features/v3.0.0/089-shared-preview-citation/browser-smoke/fixtures/sample.docx
new file mode 100644
index 0000000000..706e225670
Binary files /dev/null and b/features/v3.0.0/089-shared-preview-citation/browser-smoke/fixtures/sample.docx differ
diff --git a/features/v3.0.0/089-shared-preview-citation/browser-smoke/fixtures/sample.html b/features/v3.0.0/089-shared-preview-citation/browser-smoke/fixtures/sample.html
new file mode 100644
index 0000000000..dcb5487040
--- /dev/null
+++ b/features/v3.0.0/089-shared-preview-citation/browser-smoke/fixtures/sample.html
@@ -0,0 +1 @@
+

Sandbox preview

\ No newline at end of file diff --git a/features/v3.0.0/089-shared-preview-citation/browser-smoke/fixtures/sample.md b/features/v3.0.0/089-shared-preview-citation/browser-smoke/fixtures/sample.md new file mode 100644 index 0000000000..45247041d5 --- /dev/null +++ b/features/v3.0.0/089-shared-preview-citation/browser-smoke/fixtures/sample.md @@ -0,0 +1,8 @@ +# Shared Markdown + +```python +def sample(): + return 42 +``` + +Shared markdown target paragraph for citation locating. diff --git a/features/v3.0.0/089-shared-preview-citation/browser-smoke/fixtures/sample.txt b/features/v3.0.0/089-shared-preview-citation/browser-smoke/fixtures/sample.txt new file mode 100644 index 0000000000..0704cdf5d0 --- /dev/null +++ b/features/v3.0.0/089-shared-preview-citation/browser-smoke/fixtures/sample.txt @@ -0,0 +1,80 @@ +Text preview line 0 for shared document rendering. +Text preview line 1 for shared document rendering. +Text preview line 2 for shared document rendering. +Text preview line 3 for shared document rendering. +Text preview line 4 for shared document rendering. +Text preview line 5 for shared document rendering. +Text preview line 6 for shared document rendering. +Text preview line 7 for shared document rendering. +Text preview line 8 for shared document rendering. +Text preview line 9 for shared document rendering. +Text preview line 10 for shared document rendering. +Text preview line 11 for shared document rendering. +Text preview line 12 for shared document rendering. +Text preview line 13 for shared document rendering. +Text preview line 14 for shared document rendering. +Text preview line 15 for shared document rendering. +Text preview line 16 for shared document rendering. +Text preview line 17 for shared document rendering. +Text preview line 18 for shared document rendering. +Text preview line 19 for shared document rendering. +Text preview line 20 for shared document rendering. +Text preview line 21 for shared document rendering. +Text preview line 22 for shared document rendering. +Text preview line 23 for shared document rendering. +Text preview line 24 for shared document rendering. +Text preview line 25 for shared document rendering. +Text preview line 26 for shared document rendering. +Text preview line 27 for shared document rendering. +Text preview line 28 for shared document rendering. +Text preview line 29 for shared document rendering. +Text preview line 30 for shared document rendering. +Text preview line 31 for shared document rendering. +Text preview line 32 for shared document rendering. +Text preview line 33 for shared document rendering. +Text preview line 34 for shared document rendering. +Text preview line 35 for shared document rendering. +Text preview line 36 for shared document rendering. +Text preview line 37 for shared document rendering. +Text preview line 38 for shared document rendering. +Text preview line 39 for shared document rendering. +Text preview line 40 for shared document rendering. +Text preview line 41 for shared document rendering. +Text preview line 42 for shared document rendering. +Text preview line 43 for shared document rendering. +Text preview line 44 for shared document rendering. +Text preview line 45 for shared document rendering. +Text preview line 46 for shared document rendering. +Text preview line 47 for shared document rendering. +Text preview line 48 for shared document rendering. +Text preview line 49 for shared document rendering. +Text preview line 50 for shared document rendering. +Text preview line 51 for shared document rendering. +Text preview line 52 for shared document rendering. +Text preview line 53 for shared document rendering. +Text preview line 54 for shared document rendering. +Text preview line 55 for shared document rendering. +Text preview line 56 for shared document rendering. +Text preview line 57 for shared document rendering. +Text preview line 58 for shared document rendering. +Text preview line 59 for shared document rendering. +Text preview line 60 for shared document rendering. +Text preview line 61 for shared document rendering. +Text preview line 62 for shared document rendering. +Text preview line 63 for shared document rendering. +Text preview line 64 for shared document rendering. +Text preview line 65 for shared document rendering. +Text preview line 66 for shared document rendering. +Text preview line 67 for shared document rendering. +Text preview line 68 for shared document rendering. +Text preview line 69 for shared document rendering. +Text preview line 70 for shared document rendering. +Text preview line 71 for shared document rendering. +Text preview line 72 for shared document rendering. +Text preview line 73 for shared document rendering. +Text preview line 74 for shared document rendering. +Text preview line 75 for shared document rendering. +Text preview line 76 for shared document rendering. +Text preview line 77 for shared document rendering. +Text preview line 78 for shared document rendering. +Text preview line 79 for shared document rendering. \ No newline at end of file diff --git a/features/v3.0.0/089-shared-preview-citation/browser-smoke/fixtures/sample.wav b/features/v3.0.0/089-shared-preview-citation/browser-smoke/fixtures/sample.wav new file mode 100644 index 0000000000..b9c41ccd0d Binary files /dev/null and b/features/v3.0.0/089-shared-preview-citation/browser-smoke/fixtures/sample.wav differ diff --git a/features/v3.0.0/089-shared-preview-citation/browser-smoke/harness-template.tsx b/features/v3.0.0/089-shared-preview-citation/browser-smoke/harness-template.tsx new file mode 100644 index 0000000000..6a40822f66 --- /dev/null +++ b/features/v3.0.0/089-shared-preview-citation/browser-smoke/harness-template.tsx @@ -0,0 +1,68 @@ +import React,{useEffect,useMemo,useRef,useState} from 'react'; +import {createRoot} from 'react-dom/client'; +import i18next from 'i18next'; +import {I18nextProvider} from 'react-i18next'; +import {usePreviewLocale} from '../packages/file-viewers/src/usePreviewLocale'; +import {usePdfDocument,PdfViewer} from '../packages/file-viewers/src/pdf'; +import {DocxViewer} from '../packages/file-viewers/src/viewers/DocxViewer'; +import {TextViewer} from '../packages/file-viewers/src/viewers/TextViewer'; +import {MarkdownViewer} from '../packages/file-viewers/src/viewers/MarkdownViewer'; +import {HtmlViewer} from '../packages/file-viewers/src/viewers/HtmlViewer'; +import {ImageViewer} from '../packages/file-viewers/src/viewers/ImageViewer'; +import {MediaViewer} from '../packages/file-viewers/src/viewers/MediaViewer'; +import {ExcelPreview} from '../packages/file-viewers/src/ExcelPreview'; +import {loadDiagramLibrary} from '../packages/diagram-runtime/src'; +import {CitationDocumentPreview} from '../packages/chat/src/citations/CitationDocumentPreview'; +import en from '../packages/locales/src/shared/en.json'; +import zh from '../packages/locales/src/shared/zh-Hans.json'; +import ja from '../packages/locales/src/shared/ja.json'; +__PLATFORM_IMPORT__ +__HOST_CSS__ +import '@bisheng/ui/tokens.css'; +const base=__APP_ENV__.BASE_URL; +const url=(name:string)=>base+'/__f089__/fixtures/'+name; +const instance=i18next.createInstance(); +const global=window as any; +global.__checks={errors:[],downloads:[],selections:[],sandbox:null}; +window.addEventListener('message',event=>{if(event.data?.fixture==='sandbox')global.__checks.sandbox=event.data;}); +function Diagrams(){ + const root=useRef(null); + useEffect(()=>{let active=true;let dispose=()=>{}; + void(async()=>{const same=loadDiagramLibrary('echarts',{baseUrl:base});global.__checks.dedup=same===loadDiagramLibrary('echarts',{baseUrl:base}); + const echarts:any=await same;const mermaid:any=await loadDiagramLibrary('mermaid',{baseUrl:base});if(!active||!root.current)return; + const chart=root.current.querySelector('[data-chart]')!;const drawing=echarts.init(chart);drawing.setOption({xAxis:{data:['A','B']},yAxis:{},series:[{type:'bar',data:[1,2]}]});dispose=()=>drawing.dispose(); + mermaid.initialize({startOnLoad:false});const result=await mermaid.render('fixture-mermaid','flowchart LR; A-->B');if(active)root.current.querySelector('[data-mermaid]')!.innerHTML=result.svg; + })().catch(error=>global.__checks.errors.push(String(error))); + return()=>{active=false;dispose();}; + },[]); + return
; +} +function App(){ + const locale=usePreviewLocale();const [mode,setMode]=useState('pdf');const [zoom,setZoom]=useState(100);const [scope,setScope]=useState('A');const [active,setActive]=useState(false); + const pdf=usePdfDocument(mode==='pdf'?url('mixed.pdf'):'',{workerUrl:base+'/pdf.worker.min.js',cMapUrl:base+'/cmaps/'}); + const labels=useMemo(()=>({0:[{id:'first',label:[10,20,100,50],active}]}),[active]); + const name=mode==='docx'?'sample.docx':mode==='markdown'?'sample.md':mode==='html'?'sample.html':mode==='media'?'sample.wav':mode==='image'?'image.svg':mode==='failed'?'missing.txt':'sample.txt'; + const resource={originalUrl:url(name),previewUrl:url(name)}; + const props={resource,locale}; + const services={scopeKey:scope,localize:(key:string,options?:any)=>String(instance.t(key,options)),getCitationDetail:async()=>null,resolveCitationDetails:async()=>[],getCitationUnresolvedReason:()=>undefined,resolveDocumentUrls:async()=>({originalUrl:url('sample.docx'),previewUrl:url('sample.docx')}),download:(href:string)=>global.__checks.downloads.push(href),openArticle:()=>{}}; + global.__checks.mode=mode;global.__checks.pdfPages=pdf.document?.numPages??0; + return
+ + +
+ {mode==='pdf'&&{}} loadingText={locale.loading} onRenderError={error=>global.__checks.errors.push(String(error))}/>} + {mode==='edit'&&__PLATFORM_EDITOR__} + {mode==='docx'&&} + {mode==='markdown'&&} + {(mode==='text'||mode==='failed')&&} + {mode==='html'&&} + {mode==='image'&&} + {mode==='csv'&&} + {mode==='media'&&} + {mode==='diagrams'&&} + {mode==='reference'&&}/>} +
+ ready +
; +} +instance.init({lng:'en',fallbackLng:'en',resources:{en:{shared:en},'zh-Hans':{shared:zh},ja:{shared:ja}},defaultNS:'shared'}).then(()=>createRoot(document.getElementById('root')!).render()); diff --git a/features/v3.0.0/089-shared-preview-citation/browser-smoke/production.mjs b/features/v3.0.0/089-shared-preview-citation/browser-smoke/production.mjs new file mode 100644 index 0000000000..884c02702d --- /dev/null +++ b/features/v3.0.0/089-shared-preview-citation/browser-smoke/production.mjs @@ -0,0 +1,35 @@ +import os from 'node:os'; +import fs from 'node:fs';import path from 'node:path';import http from 'node:http';import {spawn} from 'node:child_process';import {createHash} from 'node:crypto'; +const repo=process.cwd(),root=path.join(repo,'src/frontend'),scratch=fs.mkdtempSync(path.join(os.tmpdir(),'f089-browser-')); +const report=[];const pause=ms=>new Promise(resolve=>setTimeout(resolve,ms)); +async function until(check,label,timeout=30000){const start=Date.now();for(;;){if(await check())return;if(Date.now()-start>timeout)throw new Error('Timeout: '+label);await pause(100);}} +const profile=path.join(scratch,'chrome-production');fs.mkdirSync(profile,{recursive:true});const portFile=path.join(profile,'DevToolsActivePort');if(fs.existsSync(portFile))fs.unlinkSync(portFile); +const chrome=spawn('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',['--headless=new','--disable-gpu','--no-first-run','--no-default-browser-check','--remote-debugging-port=0','--user-data-dir='+profile,'about:blank'],{stdio:['ignore',fs.openSync(path.join(scratch,'chrome-production.log'),'a'),fs.openSync(path.join(scratch,'chrome-production.log'),'a')]}); +let server,socket; +try{ + await until(()=>fs.existsSync(portFile),'production Chrome');const port=fs.readFileSync(portFile,'utf8').split('\n')[0];const pages=await(await fetch('http://127.0.0.1:'+port+'/json')).json();socket=new WebSocket(pages.find(page=>page.type==='page').webSocketDebuggerUrl);await new Promise(resolve=>socket.addEventListener('open',resolve,{once:true})); + let next=0;const pending=new Map();const exceptions=[]; + socket.addEventListener('message',event=>{const message=JSON.parse(event.data);if(message.id){const item=pending.get(message.id);pending.delete(message.id);message.error?item.reject(new Error(JSON.stringify(message.error))):item.resolve(message.result);}else if(message.method==='Runtime.exceptionThrown')exceptions.push(message.params.exceptionDetails);}); + const cdp=(method,params={})=>new Promise((resolve,reject)=>{const id=++next;pending.set(id,{resolve,reject});socket.send(JSON.stringify({id,method,params}));}); + const evaluate=async expression=>{const result=await cdp('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});if(result.exceptionDetails)throw new Error(JSON.stringify(result.exceptionDetails));return result.result.value;}; + await cdp('Runtime.enable');await cdp('Page.enable'); + for(const app of ['platform','client']){ + const base=app==='client'?'/workspace':'';const build=path.join(root,app,'build'); + server=http.createServer((req,res)=>{let name=decodeURIComponent((req.url||'/').split('?')[0]);if(base&&name.startsWith(base))name=name.slice(base.length)||'/';if(/^\/api\//.test(name)){res.statusCode=404;res.setHeader('Content-Type','application/json');res.end('{"status_code":404,"status_message":"offline fixture"}');return;}let file=path.join(build,name);if(!file.startsWith(build)){res.statusCode=400;res.end();return;}if(!fs.existsSync(file)||fs.statSync(file).isDirectory())file=path.join(build,'index.html');const type={'.js':'text/javascript','.css':'text/css','.html':'text/html','.json':'application/json','.svg':'image/svg+xml','.wasm':'application/wasm','.png':'image/png','.woff2':'font/woff2','.webmanifest':'application/manifest+json'}[path.extname(file)]||'application/octet-stream';res.setHeader('Content-Type',type);res.end(fs.readFileSync(file));}); + await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));const origin='http://127.0.0.1:'+server.address().port;exceptions.length=0; + await cdp('Page.navigate',{url:origin+base+'/'});await until(()=>evaluate('document.readyState==="complete"&&document.querySelector("#root")?.children.length>0'),app+' production bootstrap',60000);await pause(1200); + const js=[];function scan(folder){for(const entry of fs.readdirSync(folder,{withFileTypes:true})){const file=path.join(folder,entry.name);if(entry.isDirectory())scan(file);else if(file.endsWith('.js'))js.push(file);}}scan(build); + const editor=js.find(file=>/vendor-editor-.*\.js$/.test(file));const highlights=app==='platform'&&editor?[editor]:js.filter(file=>/markdown_highlight[.-].*\.js$/.test(file)); + for(const file of highlights){const relative=path.relative(build,file).split(path.sep).join('/');const result=await evaluate(`import(${JSON.stringify(origin+base+'/'+relative)}).then(()=>({ok:true})).catch(error=>({ok:false,name:error.name,message:error.message}))`);if(!result.ok)throw new Error(app+' production highlight import: '+JSON.stringify(result));} + const referenceErrors=exceptions.filter(item=>JSON.stringify(item).includes('ReferenceError')||JSON.stringify(item).includes('HighlightJS'));if(referenceErrors.length)throw new Error(app+' initialization: '+JSON.stringify(referenceErrors)); + const assets={};const manifest=JSON.parse(fs.readFileSync(path.join(root,'packages/diagram-runtime/assets/manifest.json'),'utf8')); + for(const [name,item] of Object.entries(manifest)){const response=await fetch(origin+base+'/'+name);const hash=createHash('sha256').update(Buffer.from(await response.arrayBuffer())).digest('hex');if(hash!==item.sha256)throw new Error(app+' production resource mismatch: '+name);assets[name]=hash;} + const html=fs.readFileSync(path.join(build,'index.html'),'utf8');const roots=[...html.matchAll(/]*)>/g)].filter(match=>/type=["']module["']/.test(match[1])).map(match=>match[1].match(/src=["']([^"']+)["']/)?.[1]).filter(Boolean).map(file=>path.join(build,file.replace(base,'').replace(/^\//,''))); + const reachable=new Set();function follow(file){if(reachable.has(file)||!fs.existsSync(file))return;reachable.add(file);const text=fs.readFileSync(file,'utf8');for(const match of text.matchAll(/(?:^|[;\n])\s*(?:import|export)\s*(?:[^;()]*?\bfrom\s*)?["']([^"']+\.js)["']/g)){if(match[1].startsWith('.'))follow(path.resolve(path.dirname(file),match[1]));}}roots.forEach(follow); + const firstScreenHeavy=[...reachable].filter(file=>/vendor-pdf|mammoth|DocxViewer|pdfjs/i.test(path.basename(file))).map(file=>path.basename(file)); + report.push({app,productionBootstrap:true,highlightModules:highlights.map(file=>path.basename(file)),referenceErrors:0,exceptions:structuredClone(exceptions),assets,firstScreenHeavy,firstScreenModules:reachable.size}); + await new Promise(resolve=>server.close(resolve));server=null; + } + fs.writeFileSync(path.join(scratch,'production-report.json'),JSON.stringify(report,null,2));console.log(JSON.stringify({report,outputDirectory:scratch})); +}catch(error){fs.writeFileSync(path.join(scratch,'production-report.json'),JSON.stringify({report,error:String(error)},null,2));console.error(error);process.exitCode=1;} +finally{server?.close();socket?.close();chrome.kill();} diff --git a/features/v3.0.0/089-shared-preview-citation/browser-smoke/regression.sh b/features/v3.0.0/089-shared-preview-citation/browser-smoke/regression.sh new file mode 100644 index 0000000000..304b77c7b8 --- /dev/null +++ b/features/v3.0.0/089-shared-preview-citation/browser-smoke/regression.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Run from the repository root with Node 20 and workspace dependencies installed. +set -euo pipefail +pnpm --dir src/frontend/packages/chat test +pnpm --dir src/frontend/packages/file-viewers test +pnpm --dir src/frontend/packages/diagram-runtime test +pnpm --dir src/frontend/packages/api-client test +pnpm --dir src/frontend test:config +pnpm --dir src/frontend/platform exec vitest run \ + src/pages/KnowledgePage/components/PreviewResult.test.tsx \ + src/pages/KnowledgePage/components/ExcelPreviewI18n.test.tsx \ + src/controllers/API/previewFileSplit.errors.test.ts \ + src/controllers/API/citationScope.test.ts \ + src/components/bs-comp/appChat \ + src/components/bs-comp/preview/PlatformPdfPreview.test.tsx \ + src/components/bs-comp/preview/pdfEditing.test.ts \ + src/components/bs-comp/chatComponent/CitationDocumentPreview.test.tsx \ + src/components/bs-comp/chatComponent/usePlatformCitationServices.test.tsx \ + src/pages/ChatAppPage/components/FileViewPanne.test.tsx \ + src/pages/LogPage/useAppLog/dailyChatDetail.test.tsx --maxWorkers=1 --minWorkers=1 +pnpm --dir src/frontend/client exec jest \ + src/components/Chat/Messages/Content/CitationDocumentPreview.test.tsx \ + src/components/Chat/Messages/Content/citationLocate.test.ts \ + src/components/Chat/Messages/Content/useCitedPassageLocate.test.tsx \ + src/components/Linsight/Artifacts/ResultSection.test.tsx \ + src/components/Linsight/Artifacts/artifactUtils.test.ts \ + src/components/Linsight/Artifacts/citationUtils.bake.test.ts \ + src/components/Linsight/Artifacts/sourcePassages.test.ts \ + src/api/citationScope.test.ts src/api/citationErrorContract.test.ts \ + src/pages/knowledge/FilePreview/viewers/PdfViewer.errors.test.tsx \ + src/utils/sandboxedHtml.test.ts --runInBand --coverage=false diff --git a/features/v3.0.0/089-shared-preview-citation/browser-smoke/run.mjs b/features/v3.0.0/089-shared-preview-citation/browser-smoke/run.mjs new file mode 100644 index 0000000000..93d948e919 --- /dev/null +++ b/features/v3.0.0/089-shared-preview-citation/browser-smoke/run.mjs @@ -0,0 +1,73 @@ +import os from 'node:os';import {fileURLToPath} from 'node:url'; +import fs from 'node:fs';import path from 'node:path';import {spawn} from 'node:child_process';import {createHash} from 'node:crypto';import http from 'node:http'; +const repo=process.cwd(),frontend=path.join(repo,'src/frontend'),scratch=fs.mkdtempSync(path.join(os.tmpdir(),'f089-browser-')); +const helper=path.dirname(fileURLToPath(import.meta.url)); +const dependencyRoot=fs.realpathSync(path.join(frontend,'node_modules/vite')).split('/.pnpm/')[0]; +const vite=await import(path.join(frontend,'node_modules/vite/dist/node/index.js')); +const report={fixtures:[],production:[],exceptions:[],screenshots:[]}; +const pause=ms=>new Promise(resolve=>setTimeout(resolve,ms)); +async function until(check,label,timeout=30000){const start=Date.now();for(;;){if(await check())return;if(Date.now()-start>timeout)throw new Error('Timeout: '+label);await pause(100);}} +const chromeDir=path.join(scratch,'chrome-profile');fs.mkdirSync(chromeDir,{recursive:true}); +const oldPort=path.join(chromeDir,'DevToolsActivePort');if(fs.existsSync(oldPort))fs.unlinkSync(oldPort); +const chrome=spawn('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',['--headless=new','--disable-gpu','--no-first-run','--no-default-browser-check','--autoplay-policy=no-user-gesture-required','--remote-debugging-port=0','--user-data-dir='+chromeDir,'about:blank'],{stdio:['ignore',fs.openSync(path.join(scratch,'chrome.log'),'a'),fs.openSync(path.join(scratch,'chrome.log'),'a')]}); +let socket;let server;let production;const harnesses=[]; +try{ + await until(()=>fs.existsSync(path.join(chromeDir,'DevToolsActivePort')),'Chrome devtools'); + const [port]=fs.readFileSync(path.join(chromeDir,'DevToolsActivePort'),'utf8').split('\n'); + const targets=await(await fetch('http://127.0.0.1:'+port+'/json')).json();socket=new WebSocket(targets.find(item=>item.type==='page').webSocketDebuggerUrl); + await new Promise((resolve,reject)=>{socket.addEventListener('open',resolve,{once:true});socket.addEventListener('error',reject,{once:true});}); + let next=0;const pending=new Map();const exceptions=[]; + socket.addEventListener('message',event=>{const message=JSON.parse(event.data);if(message.method==='Runtime.exceptionThrown')console.error('Browser exception',JSON.stringify(message.params.exceptionDetails));if(message.id){const item=pending.get(message.id);pending.delete(message.id);if(message.error)item?.reject(new Error(JSON.stringify(message.error)));else item?.resolve(message.result);}else if(message.method==='Runtime.exceptionThrown')exceptions.push(message.params.exceptionDetails);}); + const cdp=(method,params={})=>new Promise((resolve,reject)=>{const id=++next;pending.set(id,{resolve,reject});socket.send(JSON.stringify({id,method,params}));}); + const evaluate=async expression=>{const response=await cdp('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true});if(response.exceptionDetails)throw new Error(JSON.stringify(response.exceptionDetails));return response.result.value;}; + await cdp('Page.enable');await cdp('Runtime.enable');await cdp('Network.enable'); + const select=async mode=>{await evaluate(`document.querySelector('[data-case="${mode}"]').click()`);await until(()=>evaluate(`window.__checks.mode===${JSON.stringify(mode)}`),mode);}; + const assert=async(expression,label)=>{const value=await evaluate(expression);if(!value)throw new Error(label+' failed: '+JSON.stringify(value));}; + for(const app of ['platform','client']){ + const base=app==='client'?'/workspace':'';const appRoot=path.join(frontend,app);process.chdir(appRoot); + let source=fs.readFileSync(path.join(helper,'harness-template.tsx'),'utf8'); + source=source.replace('__HOST_CSS__',app==='client'?"import './src/style.css';import './src/mobile.css';":"import './src/style/index.css';"); + source=source.replace('__PLATFORM_IMPORT__',app==='platform'?"import {PlatformPdfPreview} from './src/components/bs-comp/preview/PlatformPdfPreview';":''); + source=source.replace('__PLATFORM_EDITOR__',app==='platform'?`{global.__checks.selections.push(rows);setActive(value=>!value);}}/>`:'platform editing only'); + const harness=path.join(appRoot,'.f089-browser-harness.tsx');if(fs.existsSync(harness))throw new Error('Existing fixture entry: '+harness);fs.writeFileSync(harness,source);harnesses.push(harness); + const loaded=await vite.loadConfigFromFile({command:'serve',mode:'development'},path.join(appRoot,app==='client'?'vite.config.ts':'vite.config.mts'));const configured=(await Promise.all(loaded.config.plugins.flat(Infinity))).filter(plugin=>plugin&&!['vite:html','vite:minify-html'].includes(plugin.name)); + server=await vite.createServer({...loaded.config,configFile:false,root:appRoot,cacheDir:path.join(scratch,'cache-'+app),logLevel:'error',optimizeDeps:{...loaded.config.optimizeDeps,entries:[harness]},server:{host:'127.0.0.1',port:0,strictPort:false,fs:{allow:[repo,dependencyRoot]}},plugins:[...configured,{name:'f089-fixtures',enforce:'pre',configureServer(instance){instance.middlewares.use(async(req,res,next)=>{const name=(req.url||'').split('?')[0];const normalized=base&&name.startsWith(base)?name.slice(base.length):name; + if(normalized==='/__f089__/'){const html=`
`;res.setHeader('Content-Type','text/html');res.end(html);return;} + if(normalized.startsWith('/__f089__/fixtures/')){const file=path.join(helper,'fixtures',path.basename(normalized));if(!fs.existsSync(file)){res.statusCode=404;res.end('missing');return;}const ext=path.extname(file);res.setHeader('Content-Type',ext==='.pdf'?'application/pdf':ext==='.wav'?'audio/wav':ext==='.svg'?'image/svg+xml':ext==='.docx'?'application/vnd.openxmlformats-officedocument.wordprocessingml.document':'text/plain');res.end(fs.readFileSync(file));return;}next();});}}]}); + await server.listen();const address=server.httpServer.address();const origin='http://127.0.0.1:'+address.port;exceptions.length=0; + for(const relative of ['packages/file-viewers/src/viewers/MarkdownViewer.tsx','packages/file-viewers/src/usePreviewResource.ts']){ + const response=await fetch(origin+base+'/@fs'+path.join(frontend,relative)); + if(!response.ok)throw new Error(app+' shared source transform failed: '+relative+' HTTP '+response.status); + } + await cdp('Page.navigate',{url:origin+base+'/__f089__/'});await until(()=>evaluate('!!document.querySelector("[data-ready]")'),app+' fixture boot',30000); + await until(()=>evaluate('document.querySelectorAll("canvas").length===2&&Array.from(document.querySelectorAll("canvas")).every(item=>item.width>0)'),app+' real PDF canvas'); + await until(()=>evaluate('(()=>{const canvas=document.querySelector("canvas");const data=canvas.getContext("2d").getImageData(0,0,canvas.width,canvas.height).data;for(let i=0;i({width:item.width,height:item.height}))');if(!(dimensions[0].height>dimensions[0].width&&dimensions[1].heightevaluate('document.querySelector("canvas").style.width.includes("1251")||parseFloat(document.querySelector("canvas").style.width)>900'),app+' PDF zoom'); + if(app==='platform'){ + await select('edit');await until(()=>evaluate('!!document.querySelector("[data-pdf-page] rect")'),'PDF edit overlay'); + const box=await evaluate('(()=>{const box=document.querySelector("[data-pdf-page] rect").getBoundingClientRect();return{x:box.x+box.width/2,y:box.y+box.height/2};})()'); + await cdp('Input.dispatchMouseEvent',{type:'mousePressed',button:'left',buttons:1,clickCount:1,...box});await cdp('Input.dispatchMouseEvent',{type:'mouseReleased',button:'left',buttons:0,clickCount:1,...box});await assert('window.__checks.selections.length===1','real label click'); + const bounds=await evaluate('(()=>{const r=document.querySelector("[data-pdf-page]").getBoundingClientRect();return{x:r.x,y:r.y};})()'); + await cdp('Input.dispatchMouseEvent',{type:'mousePressed',button:'left',buttons:1,clickCount:1,x:bounds.x+5,y:bounds.y+5});await cdp('Input.dispatchMouseEvent',{type:'mouseMoved',button:'left',buttons:1,x:bounds.x+450,y:bounds.y+250});await cdp('Input.dispatchMouseEvent',{type:'mouseReleased',button:'left',buttons:0,clickCount:1,x:bounds.x+450,y:bounds.y+250});await assert('window.__checks.selections.length===2','real box selection'); + } + await select('docx');await until(()=>evaluate('document.querySelector("[data-viewer]").textContent.includes("Shared DOCX target")'),'real DOCX conversion'); + await select('markdown');await until(()=>evaluate('!!document.querySelector("pre code")'),'markdown code');await assert('document.querySelector("pre code").textContent==="def sample():\\n return 42\\n"','exact code newlines'); + await select('csv');await until(()=>evaluate('document.querySelector("[data-viewer]").textContent.includes("Alpha")'),'real CSV parser'); + await select('html');await until(()=>evaluate('window.__checks.sandbox?.isolated===true&&window.__checks.sandbox?.storage==="ok"'),'opaque HTML sandbox'); + await select('image');await until(()=>evaluate('document.querySelector("[data-viewer] img")?.naturalWidth>0'),'real image'); + await select('media');await until(()=>evaluate('document.querySelector("audio")?.readyState>=2'),'audio loaded');await evaluate('document.querySelector("audio").play()');await until(()=>evaluate('document.querySelector("audio").currentTime>0'),'actual audio playback'); + await select('reference');await until(()=>evaluate('document.querySelectorAll("[data-cite-hit]").length>0'),'real DOCX citation locating'); + await select('diagrams');await until(()=>evaluate('!!document.querySelector("[data-chart] canvas")&&!!document.querySelector("[data-mermaid] svg")'),'real charts',30000);await assert('window.__checks.dedup===true','diagram script deduplication'); + await select('failed');await until(()=>evaluate('document.querySelector("[data-viewer]").textContent.includes("Failed to load file")'),'friendly failure'); + for(const language of ['zh-Hans','ja','en']){await evaluate(`document.querySelector('[data-lang="${language}"]').click()`);await pause(80);await assert('!document.querySelector("[data-viewer]").textContent.includes("shared:")','localized failure '+language);} + await cdp('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:true});await assert('document.documentElement.scrollWidth<=window.innerWidth+1','narrow viewport width');await cdp('Emulation.clearDeviceMetricsOverride'); + const visibleErrors=await evaluate('window.__checks.errors');if(visibleErrors.length||exceptions.length)throw new Error(app+' runtime errors: '+JSON.stringify({visibleErrors,exceptions})); + const manifest=JSON.parse(fs.readFileSync(path.join(frontend,'packages/diagram-runtime/assets/manifest.json'),'utf8'));for(const [name,item]of Object.entries(manifest)){const response=await fetch(origin+base+'/'+name);const hash=createHash('sha256').update(Buffer.from(await response.arrayBuffer())).digest('hex');if(hash!==item.sha256)throw new Error('Dev asset mismatch '+name);} + report.fixtures.push({app,pdf:dimensions,passed:['canvas','zoom','docx','markdown-newlines','csv','sandbox','image','audio-playback','citation-locate','charts','dedup','3-locales','narrow'],exceptions:exceptions.length}); + await server.close();server=null; + } + fs.writeFileSync(path.join(scratch,'report.json'),JSON.stringify(report,null,2));console.log(JSON.stringify({report,outputDirectory:scratch})); +}catch(error){report.error=String(error);fs.writeFileSync(path.join(scratch,'report.json'),JSON.stringify(report,null,2));console.error(error);process.exitCode=1; +}finally{socket?.close();chrome.kill();for(const file of harnesses){if(fs.existsSync(file))fs.unlinkSync(file);}server?.httpServer?.closeAllConnections();await server?.close();production?.close();process.chdir(repo);} diff --git a/features/v3.0.0/089-shared-preview-citation/code-review.md b/features/v3.0.0/089-shared-preview-citation/code-review.md new file mode 100644 index 0000000000..271bb64a62 --- /dev/null +++ b/features/v3.0.0/089-shared-preview-citation/code-review.md @@ -0,0 +1,55 @@ +# F089 Code Review Report + +范围:`4767247ca` 起的共享预览、引用与图表资源分支(当前 194 个变更路径,含迁移、删除及供应商文件);独立 agent 先审查到 `e51e5615b`,后续重要问题由失败回归测试验证修复。未进行第二轮 agent 审查。 + +## 七维审查 + +| 维度 | 结论 | 证据与边界 | +| --- | --- | --- | +| 边界条件 | PASS | 空详情、失效资源、URL/文件类型、PDF 页码/多尺寸、临时来源兼容与重试测试 | +| 权限与认证 | PASS | 宿主原权限接口、scope 隔离、显式拒绝后不回退旧签名;真实账号验收待执行 | +| 并发安全 | PASS | A→B→A、卸载/关闭后的成功与失败、账号切换、pending-only 去重 | +| 信息泄漏 | PASS | HTML opaque sandbox、DOCX/批注清洗、失败保留证据并展示本地化文案,无凭据新增 | +| 测试覆盖 | PASS_WITH_WARNINGS | 相关回归、实际 Canvas/鼠标/播放与生产启动已覆盖;完整业务、深色与设计复核仍待人工验收 | +| 代码风格 | PASS_WITH_WARNINGS | 共享包及修改代码检查通过,旧抑制项只减少;全仓既有失败见 verification | +| 文档同步 | PASS | design 更新当前入口/职责、签名与生命周期合同、已知坑、修订历史;附可复现脚本 | + +## 独立审查及修复 + +1. 完整临时来源点击仍强制读取 registry:先检查显式拒绝,已有完整 URL 时直接使用 seed。新增实际点击测试先失败后通过。 +2. 关闭/卸载后旧请求失败仍提示:成功和失败统一检查 ticket、scope、mounted、open;关闭和卸载测试先失败后通过,scope 测试保持通过。 +3. 后台重开知识引用复用过期签名:原 citation 详情接口增加可选 fresh 读取,后台资源适配每次打开获取新签名;仅合并未完成请求。刷新、拒绝、完整临时来源及并发场景先失败后通过。 +4. 图片/媒体 A 失败→B→A 不重试:独立审查原标 Minor,按可复用预览组件的用户影响升级为 Important,按资源 key 隔离状态;两个真实 rerender 测试先失败后通过。 + +最终浏览器检查另发现 client 的 polyfill 插件向共享源码注入裸 shim 路径,非依赖预优化路径返回 HTTP 500。真实 Vite HTTP 先复现,配置解析同一已安装 shim 的绝对路径后复验;不增加依赖或改变 polyfill 版本。 + +未留下已确认的重要代码缺陷,也无延期的 Minor。供应商内码不审计;字节/manifest 已核对。完整 vditor 分发、旧默认导入纯转接、可选第五个 PDF 位置上下文均按已批准边界保留。PDF 文字层 AbortException 未找到活跃 drawfont 入口,未来启用时应补取消测试;匿名访问沿用当前后端拒绝合同,不新增权限兜底。 + +## AC 追踪 + +| AC | 自动证据 | 业务验收保留项 | +| --- | --- | --- | +| AC-01 | 包边界、宿主 adapter、两端真实文件 | 真实知识库列表入口 | +| AC-02 | 既有 Excel 国际化/预览测试、真实 CSV | 多 sheet 与含图 Excel | +| AC-03 | TXT/DOCX/Markdown 测试、真实 DOCX/代码换行 | 业务文件与工具栏 | +| AC-04 | 沙箱测试、真实 parent DOM 拒绝与 storage | 业务 HTML | +| AC-05 | 图片测试、失败后重试、真实 SVG | 原入口缩放/旋转/下载 | +| AC-06 | PDF 会话/页/坐标/编辑测试、真实 Canvas/鼠标 | 真实切片保存 | +| AC-07 | 媒体转写/播放/键盘测试、真实 WAV | 业务转写与下载 | +| AC-08 | 旧请求/关闭/失败证据与本地化测试 | 环境网络中断 | +| AC-09 | 引用解析/导出/历史协议回归 | 真实旧会话 | +| AC-10 | 来源列表/卡片/临时 seed 点击测试 | 各业务来源入口 | +| AC-11 | 多片段坐标/文字定位测试、真实 DOCX 定位 | 业务 PDF 定位 | +| AC-12 | 拒绝/失效/不回退测试 | 普通用户与分享权限 | +| AC-13 | 选择代次、账号、关闭/卸载测试 | 跨会话实际切换 | +| AC-14 | 只读 adapter 与共享会话/历史日志回归 | 标注/QA/审计/分享无新增写入 | +| AC-15 | manifest/原字节及两端输出 hash | 无 | +| AC-16 | 独立 dev/生产启动、HTTP 与资源检查 | 正式部署环境 | +| AC-17 | loader 去重/超时/重试测试、真实图表 | 环境断网重试 | +| AC-18 | 原库字节未改、真实图表绘制 | 原源码/缩放/导出操作 | +| AC-19 | 三语言、390px 真实浏览器 | 深色及设计人员复核 | +| AC-20 | 真实调用扫描、删除死图、包边界测试 | 已说明 vditor 完整分发例外 | + +## Overall: PASS_WITH_WARNINGS + +代码实现及已确认审查问题已收口。保留开发分支等待业务验收;全仓既有 lint/类型/Jest 基线不能视为本次通过,业务验收未执行项也不标记为已通过。此结论不授权自动合并或推送。 diff --git a/features/v3.0.0/089-shared-preview-citation/design-overview.html b/features/v3.0.0/089-shared-preview-citation/design-overview.html new file mode 100644 index 0000000000..188257fe0a --- /dev/null +++ b/features/v3.0.0/089-shared-preview-citation/design-overview.html @@ -0,0 +1,52 @@ + + + + + +文件预览与引用共享方案 + + + +
+
+
毕昇 3.0实现完成 · 待验收
+

文件预览与引用共享方案

+

把前后台重复维护的能力抽成公共组件。以后修一处,两端一起生效,展示以 client 为基准。

+
纯前端改造保留现有权限两端独立部署
+
+
+

文件预览,共用展示能力

+

统一加载、展示、缩放和失败处理;前后台继续保留各自的页面入口与工具栏。

+
已共享:Excel / CSV
本次继续抽离:DOCX、Markdown、TXT、HTML、图片、PDF、音频与视频
+

PDF 共用绘制、翻页与引用高亮;后台独有的框选、切片编辑和长文档滚动能力继续保留。不增加新的文件格式支持。

+
+
+

引用溯源,共用整条查看链路

+

就是点击回答中的引用编号,查看来源,再打开原文件并定位到引用内容。

+
回答中的 [1]来源列表原文预览与定位
+

这三步的公共逻辑统一维护。能看什么仍由现有权限决定;下载取原文件,预览取适合展示的版本。

+
+
+

图表脚本,一份维护,各自复制

+

ECharts、Mermaid 保持现有版本。构建时直接复制压缩脚本,不重新编译。

+
+
一份公共脚本ECharts / Mermaid
+ +
复制到前台产物
复制到后台产物
+
+

开发时也读取这份公共来源。部署后两个前端各有自己的文件,互不依赖,不新增资源服务器。

+
+
+

业务边界保留

不改后端、数据库和接口协议;保留后台编辑、只读查看及两端权限和文件地址获取规则。

+

验收重点

  • 两端文件能看、能定位,原文件下载正确。
  • 后台 PDF 仍能框选和编辑切片。
  • 快速切换不串文件,无权限不能查看。
  • 两端独立启动后,图表都能正常显示。
+
+
实施顺序:图表资源 → 普通文件预览 → PDF → 引用链路 → 两端联合回归
+
+ + diff --git a/features/v3.0.0/089-shared-preview-citation/design-review.md b/features/v3.0.0/089-shared-preview-citation/design-review.md new file mode 100644 index 0000000000..4429f60443 --- /dev/null +++ b/features/v3.0.0/089-shared-preview-citation/design-review.md @@ -0,0 +1,11 @@ +# F089 设计审查 + +2026-10-23。独立只读 reviewer 按 builder-check 和 SDD design checklist 审查。 + +结果:LGTM。 + +审查发现版本契约未登记 F089(medium),以及具体处理文件/手动 URL 的可操作性不足;已补齐 release-contract 的归属、不变量和依赖,design §5.6 落点索引及 §7 页面/资源验收入口。原 reviewer 复核后返回 LGTM。 + +未发现需用户新增取舍的问题,未发现与规格或 constitution 的实质冲突。PDF 坐标/后台编辑、授权上下文、原文件/预览地址、延迟分包及图表资源独立部署的决策明确。 + +当前仍为设计待用户确认,未实施,不代表生产代码、资源复制或测试已完成。 diff --git a/features/v3.0.0/089-shared-preview-citation/design.md b/features/v3.0.0/089-shared-preview-citation/design.md new file mode 100644 index 0000000000..f4a67199b6 --- /dev/null +++ b/features/v3.0.0/089-shared-preview-citation/design.md @@ -0,0 +1,225 @@ +# 文件预览与引用溯源共享设计 + +关联:[spec.md](spec.md) · [discovery.md](discovery.md)。版本 v3.0.0;更新于 2026-10-25。 + +状态:规格及设计已确认,Task 1–7 已实现;入口清理、两端构建及 Chrome 文件验证已完成,独立审查发现已修复、最终回归完成,待业务验收。开发基线为更新后的 `origin/feat/3.0.0` / `4767247ca`。 + +## 1. 目标与非目标 + +沿用 client 展示,把文件预览和引用来源链路的公共逻辑收进共享包,让两端消费同一实现。后台编辑、宿主权限、签名地址获取与移动导航仍由业务适配负责。ECharts/Mermaid 保持现有版本、字节和访问路径,以单份静态来源服务两个独立前端。 + +不新增格式、解析引擎、UI/状态管理库、CDN 或资源服务器;不修改后端、数据库、会话执行和 QA 写入合同,不重设计组件样式。 + +## 2. 关键约束 + +遵循 [constitution](../../../docs/constitution.md) C1–C8 及 [release-contract](../release-contract.md)。公共 UI 仍遵守 `@bisheng/ui` 纯展示合同,文件/引用业务组件不放入 UI 基础库。 + +- 前后台可独立启动和部署;platform base 当前为空,client 为 `/workspace`;后台还支持自定义前缀。worker、cmaps、图表脚本地址均以宿主 base 生成。 +- PDF 两端均用 pdfjs-dist 3.10.111,后台另有 react-window 虚拟滚动、文字/批注及框选编辑。PDF 页面尺寸、坐标和滚动不是简单替换 UI。 +- client 预览器及 pdfjs/mammoth 当前延迟加载;公共包的聚合导出不能把这些大依赖带入聊天首屏。 +- 显示以当前 client 为基准,不改设计规范。共享按钮、选项和弹层使用已落地组件;普通抽屉组合遵循现有 client,不另造视觉模式。 +- 现有预览正文用于入库时须保持一致;抽离不能自动重解析、覆盖切片或发起写入。 + +## 3. 方案及决策 + +### 3.1 扩展已有文件预览包 + +**选定**:扩展 `@bisheng/file-viewers`,沿用 Excel,按格式拆出视图、内容加载和 PDF 内核。两端薄入口负责布局、工具栏、宿主 URL/权限适配。 + +**否决**:后台直接 import client 文件,会引入 `~/`、Recoil、路由和 client API;全部塞进 `@bisheng/ui` 则违反基础库合同。 + +**原因**:现有包已被后台 PreviewFile、Citation 抽屉和 client XlsxViewer 使用,适合承载不依赖宿主业务的预览。现有 Excel 依赖及语言加载不重做。新组件所有新增文案经 props/locale 传入,避免扩大公共包对宿主 i18n 的耦合。 + +**重新考虑条件**:将来需要脱离 React 使用预览内核,才单独拆出渲染无关模块。 + +### 3.2 PDF 共享渲染内核,保留后台编辑布局 + +**选定**:共享文档会话、单页绘制/文字/批注层、尺寸及坐标转换、渲染取消。client 默认只读视口迁入共享包;后台虚拟列表和框选业务留在薄适配中,调用同一单页渲染器并通过 overlay 接口挂载选择区。 + +**否决**:用 client 当前只读 PdfViewer 直接覆盖后台 FileView,会丢失编辑层、原滚动回调及长文档虚拟化;保留两份完整 renderer 则去重不彻底。 + +**原因**:后台 FileView 的 `onSelectLabel`、`onPageChange`、`scrollTo` 和页宽回调被切片编辑消费,且文件内有模块级 page cache/scale。搬运时将这些缓存改成文档实例所有,防止多个预览互相污染。两端绘制复用,布局策略可不同。 + +**重新考虑条件**:后台编辑业务退休或明确统一滚动体验时,再合并两种视口控制。 + +### 3.3 引用内核和来源内容归共享会话包 + +**选定**:在 `@bisheng/chat/citations` 子入口统一纯类型/解析,在对应视图子入口提供来源列表和预览内容;沿用该包已存在的 citationMarkers、citationPreview、MarkdownServices。共享视图调用文件预览包,不反向依赖宿主。 + +**否决**:新建第三套独立引用算法,或把两端整个 citationUtils 直接拼接后保留重复导出。 + +**原因**:共享 Markdown 已有引用类型及解析,继续统一可以消除目前“共享标记 + 两端列表/预览 + 两端工具”的多重来源。按函数建立行为对照,以 client 为基准保留后台历史格式兼容,不按行数选择胜方。纯子入口不触发 mammoth/pdfjs、宿主 API 或浏览器初始化。 + +**重新考虑条件**:出现真实的非会话引用产品入口时,才将纯引用域整体从 chat 包移出;避免目前过早新增包层次。 + +### 3.4 权限和地址获取作为宿主服务 + +**选定**:共享引用视图接收详情解析、可访问状态、文件 URL 解析、下载和文章导航服务;不自己拼 API 或读登录态。client 保留现有文件分享 API/分享上下文,platform 保留现有后台服务。共享包只处理成功的数据及显式失败状态。 + +**否决**:把 client 登录后的知识库取地址接口无条件用于访客/临时上传;或失败后通过另一接口获取原本受限内容。 + +**原因**:临时引用不是知识文件;知识文件的原文件和派生预览文件也不同。当前代码中 client 的临时引用明确禁止调用 knowledge/file_share。该分类和权限来源要留在宿主合同中。 + +**重新考虑条件**:后端提供覆盖全部引用类型与访问上下文的统一接口时,再简化宿主适配。 + +### 3.5 图表脚本原样复制 + +**选定**:新增业务无关的 workspace 包 `@bisheng/diagram-runtime`,持有现有两份一致脚本的一份源文件、白名单 manifest 和公共脚本加载器。两端使用现有 vite-plugin-static-copy,从公共文件复制到各自 build 根目录;开发模式由该插件直接提供文件,不先生成 app/public 副本。 + +路径保持 `/echarts.min.js`、`/mermaid.min.js`。复制目标在共享 config helper 中定义,路径基于配置文件位置,不依赖启动 cwd。不对这两个资源做 Rollup import、转译或再次压缩;现有生产 gzip/br 是传输压缩,可照常保留。CI 比较原 JS 的 SHA-256。 + +**否决**:保留两端 public 源文件并手动同步;借用另一前端 public/build 的路径;新增 CDN;将压缩脚本当普通 ESM 编译。 + +**原因**:两端当前文件分别字节一致,直接复制即可;原脚本依赖 window 全局,保持原 bytes/加载模式能避免打包环依赖。现有 static-copy 已为两端提供 PDF worker/cmaps,无需新插件。 + +**重新考虑条件**:未来产品明确需要中央资源服务,或库正式改成受控 ESM 依赖时,再评估输出路径和加载方式。 + +## 4. 当前实现与数据流 + +### 4.1 当前实际调用 + +- platform `KnowledgePage/components/PreviewFile.tsx` → 旧路径纯转接或 `preview/DocumentPreview` → 共享格式组件;PDF 由 `PlatformPdfPreview` 保留虚拟化/编辑布局,调用共享单页内核。 +- client `knowledge/FilePreview/index.tsx` → viewers 薄适配 → 共享格式组件,保留 lazy PDF/DOCX/Excel、原工具栏与侧栏;无入口的旧 appChat PreviewFile 闭合图已删除。 +- platform `PlatformChatMarkdown`、client `ClientChatMarkdown` → 共享 ChatMarkdown/来源视图 → 宿主 CitationServices 和共享预览;旧日常/任务 Markdown 与后台历史消息也使用同一引用展示/读取内核。 +- 两端 citationUtils 仅创建带本地化和宿主 URL 规则的公共工具适配;导出/复制、Linsight 制品、FlowChat/messageStore 等实际调用保留。 +- 后台看板 BaseChart 使用根目录 ECharts;两端 Mermaid 模块给共享会话提供 loadScript。platform public/vditor 中另有内嵌脚本,属于第三方分发包,不能同根资源一起直接删。 + +### 4.2 数据流 + +文件入口 → 宿主确认能力/解析可访问地址 → 共享加载与对应视图 → 事件回传业务入口。失败/切换通过实例代次隔离,完成后释放资源。 + +引用标记 → 共享解析/来源分组 → 宿主按当前上下文取引用详情和资源地址 → 共享来源列表/预览 → 宿主下载/导航。只读上下文不挂载写入业务。 + +图表视图/看板 → 公共 loader(固定 kind、宿主 base)→ 自己站点的压缩 JS → window 全局对象;多个请求复用同一次 in-flight 加载。 + +### 4.3 模块职责及接口 + +| 模块 | 做什么 | 不做什么 | +| --- | --- | --- | +| file-viewers/types | 通用预览元数据、PDF bbox/页面指标、locale 与能力接口 | 不引用 chat 或两端 alias | +| file-viewers/viewers | DOCX、文本、Markdown、HTML、图片、媒体及默认 PDF 视口 | 不读宿主 store/路由,不鉴权、不写业务数据 | +| file-viewers/pdf | 文档生命周期、单页/文字/批注绘制、坐标/尺寸、取消 | 不保存切片,不持有跨文件全局 page cache | +| chat/citations | 引用数据标准化、分组、文字定位和来源/原文视图 | 不直接 import Axios、宿主 API、Recoil 或路由 | +| 两端 preview/citation adapters | 地址/权限/下载/文章导航、移动端布局、后台编辑 overlay | 不复制公共解析或文档 renderer | +| diagram-runtime | 脚本资源和按 URL 去重的加载器 | 不编译供应商脚本,不持有会话状态 | +| config/diagramAssets | 两端开发与构建的统一静态复制配置 | 不从任一 app 的 public/build 取源文件 | + +所有新增文件 ≤600 行、严格 TypeScript、组件命名导出。失去调用方的旧实现删除;若必须保留旧路径,只允许无逻辑转接或类型导出,不保留第二份实现。新增调用直接用公共入口。 + +## 5. 关键合同及已知坑 + +### 5.1 预览输入与 PDF 合同 + +- 公共资源元数据区分 `originalUrl`、`previewUrl`、原格式及实际预览格式。转成 PDF 的 PPT 仍是 PDF 渲染,不算新增 PPT 原生支持。 +- 宿主负责 URL 授权和原有 host/base 适配;公共组件只能读取已授权 URL 或宿主提供的加载器。跨域签名 URL 不盲目替换 host,不从失败地址推导另一权限路径。 +- PDF 使用文档级 `sessionId`,页面绘制提供 `pageIndex`(统一 0 起)、原始宽高、显示宽高、scale、容器 ref 和 overlay 插槽。PDF.js getPage 是 1 起,仅在公共内核转换;旧后台及历史引用输入在宿主适配边界标准化,禁止凭数值大小猜测页码。 +- PDF bbox 类型定义归 `file-viewers/types`;chat 仅 type-only 引用或导出,file-viewers 不反向依赖 chat,防止公共包环依赖。 +- bbox 保留 `page` + `[x1,y1,x2,y2]` 既有语义,按 document viewport 显式换算。后台旧 `onPageChange(offset,height,paperSize,scale)` 在适配层据共享指标恢复原合同,`onSelectLabel` 仍传原 label id/active,不转换成新写入格式。 +- 转写文本的底部留白由宿主传入:client 保留 114px 为 AI dock 留位,后台使用 16px;不把客户端页面布局强加给后台。 +- 默认只读视口提供 zoom、targetPage、targetBBox、highlightBboxes 及 currentPage 回调;编辑能力只通过后台 overlay 暴露。文字/FreeText 批注层是公共渲染能力,编辑操作仍由后台管理。 +- PDF document loading task、page render task、observer、定时器及对象 URL 由创建者销毁;切换/卸载先失效再清理,旧回调不可更新新实例。每个文档会话渲染错误只提示一次,正常取消不提示;重开文件产生新会话。 +- workerSrc/cmaps 由宿主资源 base 显式传入,继续固定现有 pdfjs 版本;公共模块 import 时不设置全局 workerSrc。 + +### 5.2 引用与宿主服务合同 + +- canonical 类型沿用共享包 `ChatCitation {citationId,type,itemId?,sourcePayload?}`,`CitationDocumentPreviewState {detail,itemId?,itemIds?,locateChunk?}`。保留历史字段与 alias 的兼容标准化函数;未知字段不注入 HTML。 +- `CitationReferenceItem` 包含稳定 key、展示数据、可选多个 itemIds、detail/legacyPreview。显示分组去重不可丢失原引用顺序或片段,复制/导出剥离标记及烘焙引用的规则也迁为同源纯函数。 +- `CitationServices` 提供 detail/批量resolve、unresolved reason、resolveDocumentUrls、download、openArticle、localize,以及授权上下文 `scopeKey`。共享层不假定所有适配器都能远程解析;拒绝状态先终止,不能自动走临时/legacy路径重取。 +- URL resolver 返回 original/preview 及实际可预览格式,下载优先 original;媒体播放用 original、转写用 preview。临时引用沿用消息附带地址,不调用知识文件 API。 +- 后台知识引用每次打开通过原 citation 详情权限接口 fresh 读取签名,仅合并同 scope 同 ID 的未完成请求;缓存详情不作为预览签名的长期来源。完整临时 seed 使用消息地址,缺失才解析详情;不把 registry 失败兜底成其他授权路径。 +- in-flight 去重最多在同一个宿主授权上下文内按稳定身份复用;不跨账号/租户/分享 token 缓存,不长期缓存已签名 URL。关闭后重新打开允许刷新签名;显式拒绝不回退旧链接。 +- 来源切换和预览切换都带代次/取消信号,A→B→A 也不接纳第一次 A 的过期结果。薄移动容器负责导航可见性;共享内容不读 Recoil。 + +### 5.3 HTML、DOCX、Markdown 与安全 + +- HTML 复用 client `sandboxedHtml` 的隔离策略与内存 storage shim;提取 helper 后原制品入口可转接共用,保持 doctype 在最前、iframe 能获焦,不开放 allow-same-origin。 +- DOCX 沿用 mammoth,渲染前复用现有 sanitizeHtml 策略,兼容后台既有输出内容;Markdown 沿用 client 文件预览的受控插件链,不套用会话特殊协议。新增依赖仅移动既有依赖到所属 workspace 包,不升级版本;mammoth 两端已有不同声明,已核对 client 实际解析为 1.12.0,公共包固定此版本,没有升级或另拉新版本。 +- shared Markdown 禁止恢复全局“每个换行加两个换行”的预处理;代码块/图表原文及复制内容保持原样。高亮库/语言注册保持既有分包边界,生产验证 TDZ 回归。 + +### 5.4 图表加载与复制合同 + +- `loadDiagramLibrary(kind, {baseUrl,timeoutMs})` 仅接受 echarts/mermaid,输出 Promise<对应库>;按完整资源 URL 记录 in-flight,已有匹配全局对象时直接返回。 +- 同页换 base 不并行加载另一个版本;两种资源固定同一 manifest 版本。loader 只加载库,Mermaid initialize 等视图配置由现有渲染入口管理,不因改资源来源而全局重配。 +- script onerror 或超时(默认 15 秒,可注入短值测试)会移除失败元素、清理 in-flight/timer 并 reject;成功也清理轮询。移除旧 script 不能保证浏览器撤销执行,延迟回调按尝试代次隔离;重试先检查同版本已就绪全局。 +- 保留现有脚本的加载属性/全局导出方式,供应商版权头完整保留。manifest 记录来源、可从文件核实的版本、SHA-256;版本无法从文件确认时不编造。 +- 当前两份生产 Dockerfile 都以整个 frontend workspace 为构建上下文并 COPY 到 /app;新增包位于同一上下文。验证单独构建 app 命令,不采用 platform-only 源码上下文;旧 dev.Dockerfile 的 npm/Node19 配置不在本次顺手修复。 +- static-copy targets 只包含两个白名单文件及必要许可文本,dest 为输出根;不存在公共源应使构建失败。删除两端根 public 副本,构建 hash 测试防止重新分叉。 +- client PWA 和压缩插件按原配置运行;实测生成的资源及 service worker 清单。public/vditor 内嵌依赖先追踪真实调用:活跃分发包不改其私有依赖版本;若引用/产物完全不可达,删除只在有调用证据且不破坏完整分发合同后执行。 + +### 5.5 已确认的实现边界 + +| 已知坑 | 不处理的影响 | 当前处理 | +| --- | --- | --- | +| 完整临时引用可能没有可用 registry | 旧来源有地址仍打不开 | `CitationReferences` 先检查拒绝,完整 seed 直接预览 | +| 关闭后的失败与成功一样可能晚到 | 离开后仍出现旧错误提示 | 来源点击使用 ticket、scope、mounted、open 检查 | +| 后台详情缓存包含短期签名 | 同一用户重开仍得到过期链接 | API `getCitationDetail` 可 fresh;宿主资源适配每次知识预览刷新并合并 pending | +| 同实例 A 失败→B→A | 图片/媒体永远停在第一次错误 | 按资源 key 重建内部状态;不靠 URL 永久记错 | +| 来源段落更新重建 memo 组件 | PDF 反复卸载和滚动丢失 | `FileViewPanne` memo 固定在模块级 | +| client polyfill 注入裸路径 | workspace 源码开发转换 500 | client 显式 ESM shim alias + 排除 shim 自身预打包 | +| 新文档重新采用模块级缓存 | 多窗口 PDF 页码/尺寸串扰 | PDF 会话和页尺寸限定预览实例 | + +### 5.6 实施落点索引 + +以下为当前实现落点: + +| 风险 | 处理位置 | +| --- | --- | +| PDF 页码/坐标、渲染取消、实例缓存 | `packages/file-viewers/src/pdf/{usePdfDocument,PdfPage,coordinates}`;platform `preview/PlatformPdfPreview.tsx` 保留编辑/虚拟布局,旧 `FileView.tsx` 仅转接 | +| 原文件/预览地址、临时引用和授权作用域 | `packages/chat/src/citations/{types,documentResources}`;两端 `preview/citation` 服务适配器保留原业务入口 | +| 过期引用和文件回调 | `packages/chat/src/citations/useCitationPreview`、`file-viewers` 文档会话 | +| HTML 存储 shim/隔离及文档清洗 | `packages/file-viewers/src/html/sandboxedHtml` 与 `viewers/DocxViewer`;原宿主 helper 仅转接 | +| 脚本去重/超时、开发和构建资源路径 | `packages/diagram-runtime/src/loader`、`config/diagramAssets.ts`;两端 vite.config 的 static-copy 配置 | + +旧默认导入路径仅作纯转接,不保留第二套渲染器。 + +## 6. 对外依赖与影响入口 + +### 提供的接口 + +| 接口/产物 | 消费方 | 兼容要求 | +| --- | --- | --- | +| file-viewers 分格式 lazy 子入口、类型及 PDF 内核 | 两端知识库、引用、既有文件预览消费者 | 不 eagerly import 全部重依赖;保留原宿主工具栏和业务事件 | +| chat/citations 纯类型/解析与视图子入口 | 两端 Markdown/引用列表、复制导出、任务制品已有调用 | 不新增会话状态,不改变引用协议;纯子入口可在无 DOM 环境导入 | +| diagram-runtime loader + 两个静态 JS | 会话 Mermaid/ECharts、后台看板及实际其他调用 | 当前路径、window 全局、库版本和业务功能保持 | + +不新增 HTTP/Python 接口或后端持久化结构。 + +### 依赖及风险 + +| 依赖 | 风险及适配责任 | +| --- | --- | +| 后端引用 payload、bbox、文件地址及已有授权接口 | 字段多态/签名过期,宿主规范化且按原权限服务取数据 | +| PDF.js 3.10.111、worker/cmaps | 主库/worker 必须匹配,CJK 字体缺 cmaps 会空白;禁止隐式 CDN | +| mammoth、DOMPurify、Markdown 插件、Excel 现有依赖 | 移动依赖需锁定当前解析结果,生产延迟分包不回退 | +| viteStaticCopy、PWA、compression、各自容器构建上下文 | 公共源需存在于两端构建上下文,开发路径及 SW 生成时序需验证 | +| F086 会话、F088 错误合同 | 预览/解析不触发发送或写操作,取消/全局拒绝不可重复弹普通错误 | + +## 7. 验证与可观测 + +分层验证:纯解析与历史 fixture 差异对照;真实组件控制加载、取消、切换和编辑 overlay;两端真实 adapter 检查地址/权限,避免 mock 掉待验证逻辑;浏览器验证 PDF 画布/定位、HTML 沙箱、拖动框选、图表脚本加载及 UI 回归。 + +使用测试账号和可丢弃文件,先记录各格式基线。覆盖知识库、工作流/助手调试、引用列表、标注/日志只读和 client 日常/任务已有预览入口。模型生成质量不在本次验收范围。具体账号/环境由现有测试配置选择,不在文档写凭据,也不自动部署共享环境。 + +本地验收入口(启动命令从 `src/frontend` 执行 `pnpm dev:platform` / `pnpm dev:client`;端口固定,已有服务先查明归属,不自动停其他任务): + +- 后台知识文件:`http://localhost:3001/filelib/`,选取测试文件查看/编辑切片;工作流 `/flow/`、助手 `/assistant/` 中打开调试及引用。 +- 前台知识文件:`http://localhost:4001/workspace/knowledge/space/`;日常 `/workspace/c/`、任务 `/workspace/linsight/` 及工作流/助手 `/workspace/app//` 检查现有引用入口。 +- 后台 `/label`、`/log` 从真实列表进入会话;不要手拼业务 ID;用已有可授权账号及普通用户验证可访问/不可访问和只读行为。 +- 资源 HTTP 检查:分别请求 `http://localhost:3001/echarts.min.js`、`http://localhost:3001/mermaid.min.js` 及 `http://localhost:4001/workspace/echarts.min.js`、`http://localhost:4001/workspace/mermaid.min.js`,确认返回脚本而非 SPA HTML;生产 preview 同样验证。 + +实施后在 `src/frontend` 执行包/改动文件 lint 与类型检查、相关单 worker 测试、i18n 检查,再串行 `pnpm build:platform` / `pnpm build:client`。独立启动两端 Vite 和生产 preview,分别请求根路径及 `/workspace` 的图表脚本、worker/cmaps;比对源文件与两端输出 JS 的 SHA-256,验证仅启动一个 app 时也可渲染。按真实打包入口检查 PDF/DOCX 仍为延迟模块、无 HighlightJS 初始化异常,校验 PWA 缓存清单资源存在。 + +错误证据保留原 cause/context,不自动记录签名 URL/文件内容;用户提示走现有本地化与去重出口。验收报告区分自动测试、模拟浏览器和真实业务结果,既有质量基线单独记录,不通过扩大 suppressions 消除失败。 + +## 8. 交付波次及保留边界 + +已按以下顺序实施:① 静态图表资源/loader;② 非 PDF 文件视图及媒体;③ PDF 共享内核与后台编辑;④ Citation 纯工具、来源列表及预览接入;⑤ 清理副本与两端联合回归。每波保持两端可构建,未迁移入口明确列出,最终 AC-20 按真实调用清零公共逻辑副本。 + +继续保留业务配置、权限/URL 服务、客户端移动导航、后台 PDF 编辑控制和虚拟布局。这些不能为了删除文件而伪装成公共层。新格式和第三方分发包升级另行立项。 + +| 日期 | 修订 | +| --- | --- | +| 2026-10-23 | 初版设计;规格确认后细化预览、引用与图表资源合同 | +| 2026-10-25 | 完成共享实现、旧图清理、两端真实文件验证;独立审查补齐临时 seed、过期请求、后台签名刷新及预览重试边界 | + +PDF 编辑适配补充:后台继续使用虚拟列表,页尺寸缓存限定在当前预览实例;滚动通知保留原四个参数,增加可选的逐页位置上下文,供原有选区定位提示适配混合尺寸及 0 起始页码。只读入口不绑定选择写入。 diff --git a/features/v3.0.0/089-shared-preview-citation/discovery.md b/features/v3.0.0/089-shared-preview-citation/discovery.md new file mode 100644 index 0000000000..5c3ddfa702 --- /dev/null +++ b/features/v3.0.0/089-shared-preview-citation/discovery.md @@ -0,0 +1,24 @@ +# F089 文件预览与引用溯源共享调研 + +2026-10-23;基线 `feat/3.0.0` / `38395b198`。 + +## 用户已确认 + +- 文件预览按现有方案共享:Excel/CSV 沿用现有共享实现,继续覆盖 DOCX、Markdown、TXT、HTML、图片、PDF 及音视频。 +- client 为展示基准,后台 PDF 框选/切片编辑保留在业务层,两端权限、资源地址获取和页面容器保持各自责任。 +- Citation 继续共享引用解析和来源预览,不只共享会话主体。 +- ECharts/Mermaid 单份来源维护,分别输出到两端;现有文件已是压缩脚本,输出不需要重新编译。 + +## 现状及范围 + +- 两端 Excel 已调用 `@bisheng/file-viewers`,该包同时包含 Excel 图片展示;继续复用。 +- platform `KnowledgePage/components/PreviewFile.tsx` 实际调用本地 DOCX、TXT/Markdown/HTML 和 PDF 预览;client `knowledge/FilePreview` 实际调用对应 viewers。 +- platform `PlatformChatMarkdown` 与 client `ClientChatMarkdown` 都调用共享 `ChatMarkdown`,但通过各自适配器挂载本地 CitationDocumentPreviewDrawer。 +- 两端 citationUtils、引用列表及文档抽屉仍被实际入口使用;client 有非 PDF 引用文字定位和异步地址解析,platform 有后台 PDF/媒体内容组合。不能简单用任意一方整文件覆盖另一方。 +- public 根目录的 echarts.min.js、mermaid.min.js 两端内容分别完全一致;图表脚本加载仍由宿主提供,后台看板也使用 ECharts。另有 platform public/vditor 内嵌依赖,应单独追踪引用,不能直接当根目录副本删除。 + +## 实施前约束 + +范围是已有前端能力去重,不新增文件格式、不改变数据或授权合同、不做视觉重设计。公共视图与宿主授权/网络能力分离,保留既有使用入口。具体公共包边界、构建/开发复制方式、PDF 编辑接入合同写入 design.md,并经确认后实施。 + +规格依据:本会话用户“文件预览组件 和 Citation 可以按这个方案做;ECharts、Mermaid用一份,构建时分别输出到两个前端的方案”。 diff --git a/features/v3.0.0/089-shared-preview-citation/e2e-checklist.md b/features/v3.0.0/089-shared-preview-citation/e2e-checklist.md new file mode 100644 index 0000000000..e64a8935ee --- /dev/null +++ b/features/v3.0.0/089-shared-preview-citation/e2e-checklist.md @@ -0,0 +1,40 @@ +# 文件预览与引用共享:验收清单 + +前后台连接同一套测试环境,使用已有的知识库、工作流、助手和会话记录。无需升级后端。建议按下面四组顺序测试。 + +## 1. 知识库文件预览 + +- [ ] Excel/CSV:表格内容正确;多工作表可以切换;原有图片可以查看。 +- [ ] PDF:翻页、缩放和滚动正常;横竖混合页面没有错位。后台选择切片、反向拖动框选、定位和保存正常。 +- [ ] DOCX、Markdown、TXT:正文和已有格式正常,可以滚动;代码没有额外空行。 +- [ ] HTML:正文正常,允许的脚本可以运行;预览内容不能访问宿主页面或登录信息。 +- [ ] 图片、音视频:保留原有查看及播放操作;转写内容正常;下载的是原文件。 +- [ ] 图片或媒体第一次加载失败,切到另一份再回来可以重新加载。 +- [ ] 失效或无权访问的文件:结束加载并显示提示,不停留在空白成功状态。 + +## 2. 会话中的引用 + +分别从工作流、助手、日常/任务会话及后台调试/历史记录进入。 + +- [ ] 来源列表可以打开;文档、网页、文章和临时上传来源没有串类型、丢编号。 +- [ ] PDF 的多个引用片段正确高亮、定位;DOCX/Markdown/TXT 能定位正文,匹配不到时显示引用内容。 +- [ ] 媒体引用播放原文件,同时显示转写;下载没有变成转写文本。 +- [ ] 快速点击 A → B → A,再关闭预览;最终内容对应最后一次选择,关闭后不会自动重新弹出。旧请求正常返回或被取消都可以。 +- [ ] 普通用户、分享页或过期来源遇到拒绝访问时,不会显示之前缓存的文件;切换账号/分享上下文后也不复用旧结果。 +- [ ] 日志、审计、分享等只读入口仍只执行获准的复制/预览/下载,打开预览不会新增消息、标注或 QA。 +- [ ] 后台知识引用关闭后重新打开会刷新签名;完整临时来源不依赖已失效的注册记录。 +- [ ] 旧会话中的来源仍可查看;后台来源段落之间切换时,PDF 不反复重新加载。 + +## 3. 图表 + +- [ ] 会话和后台看板的 ECharts、Mermaid 正常显示;原有源码切换、缩放、导出等操作正常。 +- [ ] 前后台分别启动或部署,都能访问自己的图表资源;同页多个图表不会重复插入同一脚本。 +- [ ] 首次加载断网后恢复,重新打开图表可以重试。 + +## 4. 展示复核 + +- [ ] 中文、英文、日文没有翻译键或未替换参数。 +- [ ] 桌面、窄屏及现有深色模式下,正文、抽屉和操作仍清晰可用;请设计人员复核视觉。 +- [ ] 后台标注、QA、审计入口原有业务操作没有缺失。 + +自动测试与真实浏览器样本的覆盖范围见 [验证记录](verification.md)。以上复选框留给真实业务环境验收,不代表已经人工验收。 diff --git a/features/v3.0.0/089-shared-preview-citation/evidence/browser-summary.json b/features/v3.0.0/089-shared-preview-citation/evidence/browser-summary.json new file mode 100644 index 0000000000..cdd32a45aa --- /dev/null +++ b/features/v3.0.0/089-shared-preview-citation/evidence/browser-summary.json @@ -0,0 +1,100 @@ +{ + "date": "2026-10-25", + "method": "Headless Chrome/CDP; actual app Vite config and production output, local fixtures, no live backend acceptance", + "development": [ + { + "app": "platform", + "pdf": [ + { + "width": 1083, + "height": 2166 + }, + { + "width": 1083, + "height": 541 + } + ], + "passed": [ + "canvas", + "zoom", + "docx", + "markdown-newlines", + "csv", + "sandbox", + "image", + "audio-playback", + "citation-locate", + "charts", + "dedup", + "3-locales", + "narrow" + ], + "exceptions": 0 + }, + { + "app": "client", + "pdf": [ + { + "width": 1083, + "height": 2166 + }, + { + "width": 1083, + "height": 541 + } + ], + "passed": [ + "canvas", + "zoom", + "docx", + "markdown-newlines", + "csv", + "sandbox", + "image", + "audio-playback", + "citation-locate", + "charts", + "dedup", + "3-locales", + "narrow" + ], + "exceptions": 0 + } + ], + "directSourceTransformHttp": "passed for MarkdownViewer and usePreviewResource in both apps", + "production": [ + { + "app": "platform", + "productionBootstrap": true, + "highlightModules": [ + "vendor-editor-D7ITX5FM.js" + ], + "referenceErrors": 0, + "assets": { + "echarts.min.js": "0ff3236103347d4ae255cde530dc33bc13761c953d1e6e93a91cd8a21208fa12", + "mermaid.min.js": "9fd79a9d157f600665ce139f750dd2d3a1d582d326fd55a705e116d651be5b0f" + }, + "firstScreenHeavy": [], + "firstScreenModules": 4, + "runtimeExceptionCount": 0, + "exceptionNote": "none" + }, + { + "app": "client", + "productionBootstrap": true, + "highlightModules": [ + "markdown_highlight.9Xf5tPFZ.js" + ], + "referenceErrors": 0, + "assets": { + "echarts.min.js": "0ff3236103347d4ae255cde530dc33bc13761c953d1e6e93a91cd8a21208fa12", + "mermaid.min.js": "9fd79a9d157f600665ce139f750dd2d3a1d582d326fd55a705e116d651be5b0f" + }, + "firstScreenHeavy": [], + "firstScreenModules": 5, + "runtimeExceptionCount": 1, + "exceptionNote": "One expected offline environment API 404; no ReferenceError/HighlightJS" + } + ], + "workerCmapsAndPwa": "Both workers and CMaps present; client precaches root graph assets" +} diff --git a/features/v3.0.0/089-shared-preview-citation/evidence/client-pdf.png b/features/v3.0.0/089-shared-preview-citation/evidence/client-pdf.png new file mode 100644 index 0000000000..8258f9fe89 Binary files /dev/null and b/features/v3.0.0/089-shared-preview-citation/evidence/client-pdf.png differ diff --git a/features/v3.0.0/089-shared-preview-citation/evidence/platform-pdf.png b/features/v3.0.0/089-shared-preview-citation/evidence/platform-pdf.png new file mode 100644 index 0000000000..fbf24b069c Binary files /dev/null and b/features/v3.0.0/089-shared-preview-citation/evidence/platform-pdf.png differ diff --git a/features/v3.0.0/089-shared-preview-citation/evidence/regression-summary.json b/features/v3.0.0/089-shared-preview-citation/evidence/regression-summary.json new file mode 100644 index 0000000000..fbc16dc21c --- /dev/null +++ b/features/v3.0.0/089-shared-preview-citation/evidence/regression-summary.json @@ -0,0 +1,16 @@ +{ + "date": "2026-10-25", + "command": "bash features/v3.0.0/089-shared-preview-citation/browser-smoke/regression.sh", + "sourceCommit": "3b735de9bfe402763e545318ac593a06fa4f90c0", + "groups": { + "chat": 71, + "file-viewers": 31, + "diagram-runtime": 7, + "api-client": 19, + "config": 37, + "platform": 32, + "client": 144 + }, + "passed": 341, + "scope": "Feature regression only; full-repository baseline failures remain documented" +} diff --git a/features/v3.0.0/089-shared-preview-citation/implementation-log.md b/features/v3.0.0/089-shared-preview-citation/implementation-log.md new file mode 100644 index 0000000000..302ce51e65 --- /dev/null +++ b/features/v3.0.0/089-shared-preview-citation/implementation-log.md @@ -0,0 +1,58 @@ +# SDD ledger — plan: features/v3.0.0/089-shared-preview-citation/tasks.md +Base: 4767247ca83b4769c18f27f4152b659430bee711. Branch codex/089-shared-preview-citation; user confirmed design-overview HTML. +Baseline: api-client19/chat50 pass. Initial chat baseline was a missing workspace dependency overlay, corrected by pointing package peer links at installed dependencies; no source fix/install. +Pre-flight: T2 resource/types → T3 viewers → T4 PDF → T6 citation views; exact PreviewResource and bbox types shared. T5 citation pure functions do not import heavy T3/T4 modules. T1 is independent; T7 integrates all. No interface conflict. +Ruling: plan tasks are vertical integration units with test-first substeps, rather than arbitrary 1–2-file units; changing a loader requires its real consumers and config together. Root SDD wave cadence and writing-plans independent-deliverable guidance control granularity. +Task1 in progress: tests added before implementation; red run pending. + +Task1 evidence: RED7 loader +2 asset failures; GREEN7 loader +32 config pass. New package strict types/lint pass, changed client/platform ESLint pass (platform suppressions shrink). Client strict types pass; platform retains only existing clientVoiceDraft.test.tsx:47 TS2554. Real isolated Vite HTTP assets match manifest4/4; initial smoke cwd/closing-optimizer harness errors corrected by app cwd and asset-only noDiscovery. Services closed. Production builds left for T7. Offline lockfile-only used (no install/relink); restored SheetJS integrity dropped by pnpm, no dependency upgrades. +Task 1: complete (commits 4767247..e030085, tests: bash -c 'pnpm --dir src/frontend/packages/diagram-runtime test && pnpm --dir src/frontend test:config' → Duration 1.81s (transform 57ms, setup 0ms, collect 500ms, tests 684ms, environment 0ms, prepare 165ms)) + +Task2 RED:8 failed/1passed on stub; GREEN:9 new shared tests +8 original client sandbox tests pass. Package strict types/lint pass. Exported ./html and ./resource avoid root Excel eager dependency. Empty key resolves ready without fetching; key includes auth identity at caller. Types appended without changing Excel. +Task 2: complete (commits e030085..da7fc4b, tests: bash -c 'pnpm --dir src/frontend/packages/file-viewers test && pnpm --dir src/frontend/client exec jest src/utils/sandboxedHtml.test.ts --runInBand --coverage=false' → Ran all test suites matching /src\/utils\/sandboxedHtml.test.ts/i.) + +Task3 in progress:16 shared tests pass (9 lifecycle/sandbox +7 format tests); shared strict types and lint pass. Moved current client custom MediaPlayer (546 lines, injected labels) and transcript cues/tab rendering rather than native controls; host RichKnowledgePreview/RichPreviewFile retain metadata and URL policy. New public subpaths avoid root eager mammoth/pdfjs. Existing client mammoth resolves1.12.0; shared dependency pinned1.12.0 (no upgrade). Existing highlighter6 uses VFile5 types against ReactMarkdown9 VFile6; same known runtime-compatible cast as shared chat, no upgrade. Fixture read is relative to package test cwd; initial jsdom URL-scheme fixture bug fixed. +Ruling: two existing backend default import entry files remain only pure forwarding aliases to named host adapter components (no component definitions/duplicate implementations); this preserves caller contracts approved by design while all new shared components are named exports. No new default component API introduced. +Task3 pending: lockfile-only/dev deps; prune only touched file suppressions; host types/lint; Excel/preview/TopBar/sandbox regression; actualCitationMedia Preview still native untilT6; backendRich ordinary media now shared custom player; overall AC20 not yet complete. + +Task3 baseline verification: TopBar.test.tsx cannot start because filenamify6 ESM traversed via ~/components→CommentDialog→~/hooks→usePresets. Exact same suite/error already recorded in /private/tmp/friendly-errors-client-full.log lines2186–2217 before this work; TopBar and usePresets unchanged. Do not mask/fix unrelated test setup. Other client sandbox/PDF15 tests pass; platform Excel/PreviewResult/API9 tests pass. Shared16 passed before adding2 negative-control tests; new negative run /tmp/f089-viewer-negative.log restored implementation afterward (verify next). Host typechecks:client0, platform same pre-existing clientVoiceDraft.test.tsx:47 TS2554 only. Backend controls new wrapper keeps full-height flex parent; transcript bottomInset moved to host (client114, other16) to preserve AI-dock spacing without imposing it on backend. + +Task3 final checks: shared18 tests/types/lint pass; negative controls2failed/7skipped as intended then restored and green. Platform existing preview9 pass, client sandbox/PDF15 pass. i18n/locales consistency and archguard/diffcheck pass; scoped host ESLint no errors, suppressions generated shrink only. TopBar existing ESM failure documented, not changed. Production builds and full regression remain T7. +Task 3: complete (commits da7fc4b..110b884, tests: bash -c 'pnpm --dir src/frontend/packages/file-viewers test && pnpm --dir src/frontend/platform exec vitest run src/pages/KnowledgePage/components/ExcelPreviewI18n.test.tsx src/pages/KnowledgePage/components/PreviewResult.test.tsx src/controllers/API/previewFileSplit.errors.test.ts --maxWorkers=1 --minWorkers=1 && pnpm --dir src/frontend/client exec jest src/utils/sandboxedHtml.test.ts src/pages/knowledge/FilePreview/viewers/PdfViewer.errors.test.tsx --runInBand --coverage=false' → Ran all test suites matching /src\/utils\/sandboxedHtml.test.ts|src\/pages\/knowledge\/FilePreview\/viewers\/PdfViewer.errors.test.tsx/i.) + +Task4 resumed after user pause. PDF session RED3→GREEN; core29 tests, original client PDF errors7 and backend editing/multi-instance4 tests pass. Rendering API stays lazy; pending PDF.js tasks are destroyed on close/change. Backend uses VariableSizeList and per-document sizes, scoped drag, explicit metadata bases. Ruling: optional fifth onPageChange context provides per-page offsets/scales to existing host selection indicators; original four positional values remain — required to preserve accurate locating for mixed-size documents and startIndex0, no backend contract change. Rendering/viewport browser fixtures remain T7. Annotation value injection regression RED→GREEN; existing formatting tree whitelist preserves FreeText. +Task 4: complete (commits 110b884..992f1eb, tests: bash -c 'pnpm --dir src/frontend/packages/file-viewers test && pnpm --dir src/frontend/platform exec vitest run src/components/bs-comp/preview/PlatformPdfPreview.test.tsx src/components/bs-comp/preview/pdfEditing.test.ts --maxWorkers=1 --minWorkers=1 && pnpm --dir src/frontend/client exec jest src/pages/knowledge/FilePreview/viewers/PdfViewer.errors.test.tsx --runInBand --coverage=false' → Ran all test suites matching /src\/pages\/knowledge\/FilePreview\/viewers\/PdfViewer.errors.test.tsx/i.) + +Task5 evidence: shared55 tests pass, original client export95 + scope/error4 pass, platform scope2 pass. Account-cache negative control failed A→B (returned A), restored implementation green. Pure citations entry runs in Node (no DOM or viewer runtime import); shared strict/lint pass. Client strict passes, platform only baseline clientVoiceDraft.test.tsx:47 TS2554. API interfaces now re-export canonical shared types instead of maintaining duplicate declarations. Ruling: existing host API caches also required access scoping, otherwise a scoped shared renderer could still receive another identity's cached detail — scope by route/share plus identity/token/cookie, capture scope before awaiting so late results populate only their original scope. Host URL resolution now rejects explicit denial and never falls back to stale signed URLs; legacy consumers handle rejection as unavailable pending their T6 replacement. +Task 5: complete (commits 992f1eb..8980054, tests: bash -c 'pnpm --dir src/frontend/packages/chat test && pnpm --dir src/frontend/client exec jest src/api/citationScope.test.ts src/api/citationErrorContract.test.ts src/components/Linsight/Artifacts/citationUtils.bake.test.ts src/components/Linsight/Artifacts/artifactUtils.test.ts --runInBand --coverage=false && pnpm --dir src/frontend/platform exec vitest run src/controllers/API/citationScope.test.ts --maxWorkers=1 --minWorkers=1' → Duration 759ms (transform 57ms, setup 111ms, collect 68ms, tests 7ms, environment 211ms, prepare 37ms)) + +Task6 in progress: shared source list/card/icons/drawer from client baseline; host shell adapters keep mobile Recoil/nav in original files (no new state library/atom). Shared document lifecycle tests4 + component4 + each real host adapter2 pass. Old daily/task Markdown and backend history Markdown still had separate citation components/caches; moved those consumers to shared inline component/read hook while preserving code/artifact/image/math/voice behavior. Client legacy Markdown reduced1400→576 lines, extracted only unchanged code/image renderers. Shared63 tests passed after unifying all read state; known-denied marker test now checks no redundant resolution (explicit-denied stop per spec). New close-during-detail test reproduced stale reopening (RED1); adding lifecycle guard. Ruling: existing legacy message renderers remain for their artifact/math/voice behavior, but their citation UI and read cache must share too for AC20 — reuse only citation pieces rather than replace full message renderer. Backend PDF virtual view remains a host read-only/edit overlay adapter to preserve long-doc performance. + +Task6 final evidence: shared64 tests/types/lint pass (close-during-load RED1→GREEN). Platform citation adapter2 + shared conversation/log10 pass; client relevant106 pass. Client sharedConversationState.test.ts cannot start because its node environment loads unchanged global test/setupTests.js:59 Element.prototype without Element; both test/setup files byte-unchanged from branch base, failure precedes imports. This is a baseline harness defect, not modified. Existing TopBar ESM baseline and platform voice TS2554 remain separate. Final client strict passes, platform only baseline voice TS2554; scoped app lint pass after suppressions shrink. No backend source touched. +Task 6: complete (commits 8980054..273663c, tests: bash -c 'pnpm --dir src/frontend/packages/chat test && pnpm --dir src/frontend/client exec jest src/components/Chat/Messages/Content/CitationDocumentPreview.test.tsx src/components/Chat/Messages/Content/citationLocate.test.ts src/components/Chat/Messages/Content/useCitedPassageLocate.test.tsx src/components/Linsight/Artifacts/ResultSection.test.tsx src/components/Linsight/Artifacts/artifactUtils.test.ts src/components/Linsight/Artifacts/sourcePassages.test.ts --runInBand --coverage=false && pnpm --dir src/frontend/platform exec vitest run src/components/bs-comp/chatComponent/CitationDocumentPreview.test.tsx src/components/bs-comp/appChat src/pages/LogPage/useAppLog/dailyChatDetail.test.tsx --maxWorkers=1 --minWorkers=1' → Duration 5.71s (transform 991ms, setup 459ms, collect 2.61s, tests 238ms, environment 1.39s, prepare 217ms)) + +Task7 reachability: backend ChatAppPage/ResouceModal is LIVE through PlatformConversationMessages + dailyChatDetail + /resoucePage; retain its source business shell, all viewers already point to shared adapters. Client appChat/components/ResouceModal has no imports/re-exports/route/dynamic glob consumers; it is the only parent of components/PreviewFile. Removed this closed5-file dead graph (PDF/DOCX/text/index + dead modal). Removed now-unused host mammoth deps and client react-window; shared mammoth remains pinned1.12.0, platform virtualized PDF retains catalog react-window. Boundary test RED1/4 observed before deletion. Ruling: preserve complete public/vditor distributions as third-party bundles, not split-edit their embedded graph libraries; backend knowledge Markdown/PreviewParagraph explicitly use /vditor CDN paths, verified live through knowledge routes. Root graph resources are now one source; embedded third-party copies are documented exceptions in spec. + +Task7 browser evidence: isolated headless Chrome/CDP, genuine locally-generated PDF (two aspect ratios), DOCX, CSV, HTML, SVG and WAV; both app dev configs render Canvas/text, zoom, media playback, non-PDF citation locating, opaque sandbox memory storage, graphs/dedup,3 languages and390px viewport. Platform real mouse label click + box selection passed. No fixture runtime exceptions. Initial harness failures were vite-plugin-html history interception; fixture-only config excludes that HTML routing plugin, retains actual aliases/assets/CSS/build definitions. Independent production files boot in Chrome; actual vendor-editor (platform) and markdown_highlight (client) imports pass without ReferenceError/HighlightJS TDZ. Offline client environment GET404 produces one expected ApiError event; no live backend acceptance claimed. Both builds complete (platform24.89s/client46.15s); four root diagram hashes match public manifest, PDF worker/CMaps in both outputs and client PWA root graph assets present. +Task7 integration regressions: source paragraph remounted PDF because React.memo was created in the host render; moved memo to module scope, RED1→GREEN1 with the real FileViewPanne. Seeded web/article/temp sources were unnecessarily asked for registry metadata; added3 regression cases (RED3), preserve complete source seeds, use remote lookup only for absent file-preview URLs. No alternate authorization fallback. + +Task7 final targeted suites: chat67,file-viewers29,diagram7,api-client19,config37,platform28,client144 =331 cases green. Seed compatibility RED3→GREEN, source memo RED1→GREEN. Current shared types/lint and scoped app lint pass. Full platform lint had84 (1 newly added test Hook naming corrected;83 existing files remain), client full lint10errors/2warnings pre-existing. i18n/locales/archguard pass. Implementation is ready for fresh whole-branch review; final production rebuild follows the late seed predicate fix, before final delivery. + +Final review grading: three Important accepted (complete temp seed click, late failure toast, platform signature refresh). Image/media A-failed→B→A retry is regraded Important because reusable preview resources must permit returning to a source without remount; two real rerender regressions added. No Critical. One fix pass, all four require RED→GREEN. +Final: Ruling: reviewer set aside PDF text-layer AbortException — no active drawfont=true caller; retain current API scope rather than enable an unused rendering branch — cost if wrong: future text-layer consumer must extend cancellation handling/test before enabling it. +Final: Ruling: no anonymous-source permission fallback — current backend F054 contract explicitly denies anonymous access; preserve it — cost if wrong: product permission contract needs a separate change. +Final: Ruling: real-account share/save acceptance remains manual — no backend changes and generated fixtures cannot prove tenant business authorization — cost if wrong: deployment-specific failure stays undiscovered until checklist acceptance. + +Final review fix pass: shared chat71 and viewers31 pass after RED3 source cases, RED2 image/media retry cases; platform fresh signature, denial, seeded temporary source and pending-only dedup RED4→GREEN4, plus existing adapter/scope4 green. Ruling: re-open knowledge signatures through the original citation permission endpoint with fresh=true, never alternate file_share fallback; complete temp seed retains its existing URL. Cost if wrong: a future URL policy must adjust the host adapter. +Final validation found client dev transform HTTP500 for shared source imports: node-polyfill plugin injects bare shim imports from packages that do not own its node_modules ancestor. Direct real Vite HTTP request reproduced RED (previous optimized fixture execution alone missed it). Resolve the same installed plugin shim files by absolute aliases in client config; no new dependency/version or runtime polyfill behavior. Browser harness now asserts unoptimized shared source HTTP transforms too. + +Final: fixed complete temp seed and stale failure callbacks — actual click + close/unmount tests RED3→GREEN; scope guard remains green; chat71/71. +Final: fixed image/media selection retry — real rerender tests RED2→GREEN; viewers31/31. +Final: fixed platform signature refresh and same-scope pending dedup — actual host/API tests RED4→GREEN4, adapter/scope8/8. +Final validation: actual client dev unoptimized-source HTTP500 reproduced; explicit ESM shim aliases and bare shim prebundle exclusion fix it, full real Chrome fixture run green in both apps with0 runtime exceptions. Harness retains app optimizeDeps exclusions and cleans files/browser before server close. Final platform25.89s and client63s production builds green; both static production boot/highlight imports green, one expected offline client environment ApiError recorded. Worker/CMaps/root graph hashes/PWA green. +Final checks: shared types/lint pass; client1556 strict files pass; platform only unchanged voice test TS2554. Platform lint remaining22 in4 byte-unchanged files; modified API any count reduced52, suppressions pruned58→52. i18n/locales/archguard green. Final whole regression and task-done follow commit. +Ruling: reproducible browser helpers use app Vite config but exclude HTML route rewriting only; direct source transforms added after optimized fixture missed path resolution. Preserve these helpers/evidence outside disposable SDD scratch — cost if wrong: test harness needs adjusting if Vite HTML plugin routing changes. +Task 7: complete (commits 273663c..3b735de, tests: bash features/v3.0.0/089-shared-preview-citation/browser-smoke/regression.sh → Ran all test suites matching /src\/components\/Chat\/Messages\/Content\/CitationDocumentPreview.test.tsx|src\/components\/Chat\/Messages\/Content\/citationLocate.test.ts|src\/components\/Chat\/Messages\/Content\/useCitedPassageLocate.test.tsx|src\/components\/Linsight\/Artifacts\/ResultSection.test.tsx|src\/components\/Linsight\/Artifacts\/artifactUtils.test.ts|src\/components\/Linsight\/Artifacts\/citationUtils.bake.test.ts|src\/components\/Linsight\/Artifacts\/sourcePassages.test.ts|src\/api\/citationScope.test.ts|src\/api\/citationErrorContract.test.ts|src\/pages\/knowledge\/FilePreview\/viewers\/PdfViewer.errors.test.tsx|src\/utils\/sandboxedHtml.test.ts/i.) + +Final closeout: whole feature regression341/341 green (71+31+7+19+37+32+144), all seven task contracts recorded. Fresh reviewer Important issues fixed once and verified; no deferred minors. Design/AC review PASS_WITH_WARNINGS because known full-repo baselines and real-account business acceptance remain. +Ruling: finishing branch uses the already-confirmed keep-for-acceptance choice, no merge/push menu repeated. Full project CI is not claimed green; only feature suites and final builds/browser checks are green — cost if wrong: integration requires a separate explicit user instruction and baseline checks. diff --git a/features/v3.0.0/089-shared-preview-citation/spec-review.md b/features/v3.0.0/089-shared-preview-citation/spec-review.md new file mode 100644 index 0000000000..f4292f87c2 --- /dev/null +++ b/features/v3.0.0/089-shared-preview-citation/spec-review.md @@ -0,0 +1,9 @@ +# F089 规格审查 + +2026-10-23;按 `.claude/skills/sdd-review/references/spec-checklist.md` 自查,非独立代码审查。 + +LGTM + +用户确认的文件格式、引用链路、图表资源同源输出均有 AC;包含后台编辑保留、权限/只读、异步切换、加载失败、三语言与独立部署边界。无新增领域对象、后端或错误码合同;保留现有 release-contract 中预览正文一致及只读入口限制。AC-01 至 AC-20 唯一且使用 EARS,可在后续任务中逐项追溯。 + +2026-10-23 用户回复“继续”,规格已确认,已进入设计;尚未实施。公共包接口、PDF 编辑适配、资源复制及开发服务细节由 design.md 决定,不在规格固化实现。 diff --git a/features/v3.0.0/089-shared-preview-citation/spec.md b/features/v3.0.0/089-shared-preview-citation/spec.md new file mode 100644 index 0000000000..67cbeb0bb8 --- /dev/null +++ b/features/v3.0.0/089-shared-preview-citation/spec.md @@ -0,0 +1,55 @@ +# 文件预览与引用溯源共享 + +状态:2026-10-23 用户确认规格及设计;2026-10-25 实现完成,独立审查及相关回归完成,待业务验收。 + +关联需求:[调研及用户确认记录](discovery.md)。所属版本:v3.0.0。依赖:既有文件预览、F078 共享 UI、F086 共享会话、F088 请求错误处理。 + +## 1. 目标与边界 + +作为前后台用户,我希望查看同一文件及回答引用时,预览和定位行为一致;作为维护者,我希望修复公共预览或引用问题后,两端同时受益。 + +纳入:现有 Excel/CSV、DOCX、Markdown、TXT、HTML、图片、PDF、音视频预览的公共能力;引用标记、来源列表、原文预览/定位/下载;两端 ECharts/Mermaid 资源同源维护和独立部署。展示以 client 为基准,保留后台已有业务编辑能力。 + +排除:新增 DOC/PPT 等尚不支持格式的直接渲染、替换解析引擎、后端/数据库/权限合同变更、前端状态库迁移、视觉重设计、图表库版本升级、新增 CDN 或独立资源服务。不顺手修改无关表单或错误处理。 + +## 2. 验收标准 + +### 文件预览 + +- **AC-01** — WHEN 用户从前台知识空间或后台知识库打开已支持的文件,THE SYSTEM SHALL 使用共享的对应预览能力,并保留各入口已有的可见内容与操作。 +- **AC-02** — WHEN 打开 Excel/CSV,THE SYSTEM SHALL 保留现有表格、工作表切换及可用图片展示;不引入另一套 Excel 实现。 +- **AC-03** — WHEN 打开 DOCX、Markdown 或 TXT,THE SYSTEM SHALL 保留正文、已有格式/图片及滚动;在入口已有缩放或引用文字定位时保留相应能力。 +- **AC-04** — WHEN 打开 HTML,THE SYSTEM SHALL 在隔离预览环境中展示,预览内容不得取得宿主登录态或访问宿主 DOM,保留已允许的内容运行能力。 +- **AC-05** — WHEN 打开图片,THE SYSTEM SHALL 保留该入口已有的适应容器、缩放、旋转及下载操作;不把已无此操作的入口强制改成新交互。 +- **AC-06** — WHEN 打开 PDF,THE SYSTEM SHALL 保留分页、缩放、滚动、引用定位和高亮;后台框选/切片编辑及相关保存仍仅在可编辑业务入口可用。 +- **AC-07** — WHEN 打开音频/视频及其引用,THE SYSTEM SHALL 保留播放和已有的转写内容展示,不把媒体原文件地址与转写预览地址混用。 +- **AC-08** — IF 文件读取或渲染失败,THEN THE SYSTEM SHALL 结束加载并给出本地化失败提示;旧文件在切换或关闭后完成/失败不得覆盖当前预览或触发过期提示。 + +### 引用溯源 + +- **AC-09** — WHEN 查看现有或历史会话中的引用标记,THE SYSTEM SHALL 保留既有支持的引用格式与编号,对应同一来源和原文片段;未知引用不得伪造成可用来源。 +- **AC-10** — WHEN 在前台或后台打开来源列表,THE SYSTEM SHALL 保留文档、网页及媒体等已有来源的信息和操作,同一来源的多个引用片段可正确访问。 +- **AC-11** — WHEN 点击文档引用,THE SYSTEM SHALL 打开对应文件,并在入口已有定位能力时定位到正确引用片段;PDF 支持已有多个片段高亮,非 PDF 保留已有文字定位。 +- **AC-12** — IF 引用详情缺失、资源失效或无权访问,THEN THE SYSTEM SHALL 显示对应不可用状态,不显示空白成功预览,不通过其他权限路径获取受限内容。 +- **AC-13** — WHEN 用户切换引用、会话或关闭抽屉,THE SYSTEM SHALL 保持当前来源、标题、文件及定位属于同一次选择,不混入旧请求结果。 +- **AC-14** — WHILE 处于分享、日志、审计等只读入口,THE SYSTEM SHALL 保留获准的复制/预览/下载,且不因预览挂载触发发送、执行、标注或 QA 写入。 + +### 图表资源及整体回归 + +- **AC-15** — WHEN 更新 ECharts 或 Mermaid 的公共资源,THE SYSTEM SHALL 使前后台输出使用相同内容;现有压缩脚本不重新编译或转译。 +- **AC-16** — WHEN 分别开发启动、生产构建或独立部署任一前端,THE SYSTEM SHALL 能从本应用访问对应图表资源,不依赖另一前端运行或产物目录存在。 +- **AC-17** — WHEN 同页多个图表请求同一脚本,THE SYSTEM SHALL 避免重复加载;IF 加载失败,THEN THE SYSTEM SHALL 结束等待并允许后续重试。 +- **AC-18** — WHEN 在会话或后台看板使用现有图表能力,THE SYSTEM SHALL 保留该入口已有的渲染、切换源码、缩放及导出操作,不改变图表库版本。 +- **AC-19** — WHEN 使用中文、英文、日文及浅色/深色、桌面/窄屏布局,THE SYSTEM SHALL 保留可读文案、可用操作和原有布局适配,不显示翻译键或未替换参数。 +- **AC-20** — THE SYSTEM SHALL 为本次已纳入且可共享的预览、引用及图表加载能力保留一个公共维护来源;两端仅保留必要业务适配,失去调用方的旧副本应删除,第三方完整分发包中的内嵌依赖另行核查。 + +## 3. 测试及业务边界 + +- 覆盖知识库文件预览、会话引用、独立/分享页、后台工作流/助手调试、标注及日志入口;日常/任务模式仅覆盖其现有预览/引用消费入口,不修改会话业务流程。 +- 覆盖正常、空内容、失效、拒绝访问、读取/渲染失败、快速切换、关闭后回调和重复加载;不能用下载失败代替 PDF 页面渲染失败。 +- 旧格式引用、缺失字段和不同文件扩展名/地址形态需回归;不重新定义后端返回合同。 +- 已接受并用于入库的预览正文与入库正文一致的既有约束保持;抽离不触发文件重新解析或写入。 + +## 4. 后续文档 + +设计方案、公共包边界及差异处理写入 design.md;实施波次及用例追溯写入 tasks.md,规格确认前不实施。 diff --git a/features/v3.0.0/089-shared-preview-citation/tasks-review.md b/features/v3.0.0/089-shared-preview-citation/tasks-review.md new file mode 100644 index 0000000000..07e911733c --- /dev/null +++ b/features/v3.0.0/089-shared-preview-citation/tasks-review.md @@ -0,0 +1,3 @@ +# F089 任务审查 + +按 specs/design 和 tasks checklist 自查:AC-01 至 AC-20 均有测试追溯;依赖按资源→视图→PDF/引用→联合回归排列;平台/客户端入口均单列,测试先于实现。任务按可独立验证的纵向交付划分,每波含明确测试子步骤。无新增后端/Worker/数据库合同,无需对应任务。后续全分支独立审查仍必需。 diff --git a/features/v3.0.0/089-shared-preview-citation/tasks.md b/features/v3.0.0/089-shared-preview-citation/tasks.md new file mode 100644 index 0000000000..dfda65f4c3 --- /dev/null +++ b/features/v3.0.0/089-shared-preview-citation/tasks.md @@ -0,0 +1,106 @@ +# 文件预览与引用共享实施计划 + +> For agentic workers: Use superpowers:executing-plans to implement task-by-task. The confirmed spec and design are binding; no implementation checkpoint between tasks. + +**Goal:** 两端共用预览和引用内核,图表脚本单份维护、独立输出。 +**Architecture:** 扩展 file-viewers 和 chat 的纯入口/视图,宿主适配权限、地址及后台编辑;diagram-runtime 提供原样脚本与去重加载。 +**Tech Stack:** 既有 React18 / TypeScript / PDF.js3.10.111 / mammoth / Vite6 / Vitest、Jest;不新增外部库。 +**Spec:** [spec.md](spec.md) · [design.md](design.md)。 + +## Global Constraints + +- client 为显示基准;不改后端、权限合同、图表版本,不新增格式/状态库/视觉规范。 +- HTML 无 allow-same-origin;PDF 页码边界显式转换;后台虚拟滚动和切片编辑保留。 +- 共享入口避免 eager 重依赖;单文件≤600行;三语言、命名导出和严格TS;不增加 suppressions。 +- 现有主目录另有任务改 SSE 文件,禁止混入。测试单 worker、构建串行;不用全局 stash/reset/restore/clean,不重新安装主目录依赖。 +- 仅在隔离开发分支提交;不自动合并/推送3.0或release。 + +## Review Focus + +- A→B→A、卸载和旧PDF渲染完成不能改当前内容(T2/T4/T6)。 +- 临时上传、签名失效、跨租户/分享拒绝不得串缓存或绕权(T5/T6)。 +- PDF 0起/1起页码、非等高页、缩放及后台框选不能漂移(T4)。 +- 图表多请求、超时后旧onload、失败重试及独立base输出(T1)。 +- PDF渲染失败与下载失败不同;HTML storage shim、原文件下载及懒加载(T2/T3/T4/T7)。 + +## 状态 + +| 文档 | 状态 | +| --- | --- | +| spec.md | 已确认 | +| design.md | 已确认(用户“确认”简版HTML) | +| tasks.md | 已自查AC全覆盖;实施中 | + +### Task 1: 图表资源与加载器 + +Files: 新建 `src/frontend/packages/diagram-runtime/{package.json,tsconfig.json,src/index.ts,src/loader.ts,src/loader.test.ts,assets/manifest.json,assets/*.min.js}`;新建 `config/diagramAssets.ts` 和 `config/diagramAssets.test.ts`;修改两端 vite.config/package.json、pnpm-lock.yaml、两端 Mermaid loadScript 和后台 BaseChart;删除两端public根脚本副本。 +Interfaces: `loadDiagramLibrary(kind:'echarts'|'mermaid', options:{baseUrl:string;timeoutMs?:number}):Promise`;`diagramAssetTargets():Array<{src:string;dest:string}>` 由config提供;资源dest根目录。 +覆盖 AC: AC-15、AC-16、AC-17、AC-18、AC-20。 +- [x] 写 loader 测试:同URL并发共享Promise/仅一script、已就绪全局直接返回、base路径正确、onerror/超时清理并可重试、旧回调不结算新尝试。写资源测试:公共manifest两脚本hash、targets绝对路径、两端不依赖对方build。 +- [x] 单worker运行并观察RED;实现最小loader/manifest/targets,原字节移动脚本,接入所有真实加载入口。 +- [x] 跑 `pnpm --dir src/frontend/packages/diagram-runtime test` 和 `pnpm --dir src/frontend test:config`,期望相关全PASS;单独dev/build资源HTTP和hash验证留T7一起执行。 +- [x] 仅暂存本任务文件并提交 `refactor(frontend): share diagram assets and script loading`。 + +### Task 2: 预览资源生命周期及安全HTML + +Files: 新建 `packages/file-viewers/src/{types.ts,usePreviewResource.ts,usePreviewResource.test.tsx,html/sandboxedHtml.ts,html/sandboxedHtml.test.ts}`;调整原types(保留既有Excel类型)、public exports;client sandboxedHtml转接。 +Interfaces: `PreviewResource {originalUrl:string;previewUrl:string;fileName?:string;fileType?:string}`;`PreviewPdfBBox {page:number;bbox:[number,number,number,number]}`;`usePreviewResource(key:string, load:(signal:AbortSignal)=>Promise):{status:'loading'|'ready'|'error';data?:T;error?:unknown}`;`buildSandboxedSrcDoc(html:string):string`/sandbox属性保持已有语义。 +覆盖 AC: AC-04、AC-08、AC-12、AC-13、AC-19。 +- [x] 写真实hook异步顺序测试:A→B→A、卸载、取消、失败重开;HTML测试doctype保留、shim先于用户脚本、无同源授权。 +- [x] RED后提取client安全helper和可取消加载;校验返回受当前key/代次控制,不把异常转成原始提示。 +- [x] 运行file-viewers单worker测试、strict types;原client sandboxedHtml现有测试回归,期望PASS。 +- [x] 提交 `refactor(preview): share resource lifecycle and HTML sandbox`。 + +### Task 3: 非PDF视图与两端文件预览接入 + +Files: 新建 `packages/file-viewers/src/viewers/{DocxViewer,MarkdownViewer,TextViewer,HtmlViewer,ImageViewer,MediaViewer}.tsx`、格式选择/locale/组件tests和lazy子入口;两端知识库对应组件与引用预览宿主改公共消费;依赖从现有包移到所属包(不升级)。 +Interfaces: 各视图 `PreviewViewerProps {resource:PreviewResource;zoomLevel?:number;locale:PreviewLocale;onError?:(error:unknown)=>void}`;`PreviewLocale`含loading/failed/empty/各预览名称等宿主三语言值。`MediaViewer`额外transcript资源;已有Excel接口不变。 +覆盖 AC: AC-01、AC-02、AC-03、AC-04、AC-05、AC-07、AC-08、AC-19、AC-20。 +- [x] 写组件测试:DOCX清洗保留表格图片、Markdown代码无多余换行、TXT按文本显示、HTML沙箱、图片错误、媒体original播放/preview转写;测试切换与加载失败不串状态。 +- [x] RED后基于client实现提取,以薄适配保留宿主缩放/布局和已存在动作;迁移依赖版本按lockfile解析值记录。 +- [x] 两端相关预览/Excel现有测试 + 新组件测试 + 共享包types/lint,期望PASS。 +- [x] 提交 `refactor(preview): share document image and media viewers`。 + +### Task 4: PDF公共绘制与编辑适配 + +Files: 新建 `packages/file-viewers/src/pdf/{usePdfDocument,PdfPage,PdfViewer,coordinates,types}`及测试、lazy入口;修改platform `components/bs-comp/FileView.tsx`、client `knowledge/FilePreview/index.tsx`/`viewers/PdfViewer.tsx`。 +Interfaces: `usePdfDocument(url:string,assets:{workerUrl:string;cMapUrl:string})`返回loading/error/document并销毁loadingTask;`PdfPage`消费document/pageIndex/width/zoom/drawText,提供 `renderOverlay(metrics)`、`onMetrics(metrics)`、`onError(error)`;`metrics {pageIndex,originalWidth,originalHeight,width,height,scale}`。共享只读PdfViewer沿用client现有props,仅bbox类型改公共定义;后台onSelectLabel/onPageChange既有payload不变。 +覆盖 AC: AC-01、AC-06、AC-08、AC-11、AC-13、AC-14、AC-19、AC-20。 +- [x] 写坐标/页码测试、真实渲染任务替身测试:0/1起边界、多尺寸、缩放高亮/框选映射、多实例隔离、取消不报错、当前文档多页失败仅一次、旧文档失败忽略。 +- [x] RED后提取共享PdfPage及session;后台保留react-window/框选薄适配,移除模块全局页缓存和固定DOM id;client迁公共只读视口。 +- [x] 跑原PdfViewer.errors测试及新PDF/编辑适配回归,types/lint;浏览器真实Canvas/定位/框选在T7验证。 +- [x] 提交 `refactor(preview): share PDF rendering with host editing overlays`。 + +### Task 5: Citation纯工具与宿主资源合同 + +Files: 共享 `packages/chat/src/citations/{index,types,markers,normalize,referenceItems,export,documentResources}`及fixtures/tests;整合现有content/citationMarkers/citationPreview/types;两端citationUtils逐个迁移/转接。 +Interfaces: 复用ChatCitation/CitationDisplayData/PreviewState;`CitationServices {scopeKey:string;getCitationDetail;resolveCitationDetails;getCitationUnresolvedReason;resolveDocumentUrls;download;openArticle;localize}`(已有服务签名沿用design);resolver `Promise`,宿主明确拒绝继续reject,不走legacy权限回退。PDF bbox type-only引用file-viewers。 +覆盖 AC: AC-09、AC-10、AC-11、AC-12、AC-13、AC-14、AC-20。 +- [x] 收集两端真实旧引用/导出/临时/知识/媒体输入fixture并写差异对照测试,标记、片段编号、多个itemIds、顺序/分组、剥离/烘焙结果保持。 +- [x] RED后收敛纯工具;权限/URL调用留宿主,以同scope in-flight复用、不跨token/租户缓存;临时引用不调knowledge/file_share。 +- [x] 跑共享chat及两端citation/bake/导出已有测试,纯子入口无DOM导入及无重依赖测试,期望PASS。 +- [x] 提交 `refactor(citation): unify parsing and document resource contracts`。 + +### Task 6: Citation来源列表与文档预览 + +Files: 新建 `packages/chat/src/citations/{CitationReferences,CitationDocumentPreview,CitationSourceIcon,useCitationPreview,useCitedPassageLocate}`及组件测试/子入口;两端ReferencesDrawer/DocumentPreviewDrawer/Markdown服务薄适配。 +Interfaces: `CitationDocumentPreview({preview,services,compactMode})`;`CitationReferences({content,webContent,citations,services,onPreview})`;shared预览使用T2/T3/T4输入及T5服务,不读宿主store。两端移动导航/抽屉容器/后台只读能力留宿主。 +覆盖 AC: AC-09、AC-10、AC-11、AC-12、AC-13、AC-14、AC-19、AC-20。 +- [x] 写组件和真实adapter测试:引用来源/文件正确、原文件下载、PDF多片段/非PDF文本定位、权限拒绝不请求文件、A→B→A不串、切换账号/分享scope不复用、只读无写入。 +- [x] RED后迁client来源视图,保留后台历史协议和媒体特性,接入共享Markdown、旧日常/任务已有消费入口,移除业务无关旧副本。 +- [x] 跑chat及两端引用/共享会话/只读/标注相关测试,期望PASS。 +- [x] 提交 `refactor(citation): share reference lists and document previews`。 + +### Task 7: 全部入口清理及联合验收 + +Files: 两端实际调用/无调用副本、包边界扫描tests、F089 e2e-checklist/verification/tasks/design/release-contract。 +Interfaces: 不新增运行时合同;检查T1–T6输出一致。 +覆盖 AC: AC-01、AC-02、AC-03、AC-04、AC-05、AC-06、AC-07、AC-08、AC-09、AC-10、AC-11、AC-12、AC-13、AC-14、AC-15、AC-16、AC-17、AC-18、AC-19、AC-20。 +- [x] 扫描静态/动态/barrel调用,删除失去入口的副本,第三方vditor按可达性保留理由;写包边界回归避免宿主反向import。 +- [x] 跑相关全测试、lint/typecheck、i18n/archguard、串行两端构建;记录既有基线,禁止扩大suppressions。 +- [x] 用 /e2e-test 生成并执行适用的前端验证:真实文件、Canvas/框选/定位/沙箱、独立dev与生产资源、PWA清单、懒加载、三语言/窄屏;模拟与真实环境区分。 +- [x] fresh reviewer全分支审查,修复重要问题;/sdd-code-review 对照AC;提交验证文档,保留开发分支待用户验收,不自动推送/合并。 + +## 实际偏差记录 + +- 开发分支基于fetch后的 `origin/feat/3.0.0`,最新合并为4767247ca;原主目录未提交SSE清理及测试文档保持不动。 diff --git a/features/v3.0.0/089-shared-preview-citation/verification.md b/features/v3.0.0/089-shared-preview-citation/verification.md new file mode 100644 index 0000000000..227a3f9d78 --- /dev/null +++ b/features/v3.0.0/089-shared-preview-citation/verification.md @@ -0,0 +1,76 @@ +# F089 验证记录 + +更新:2026-10-25。分支 `codex/089-shared-preview-citation`,开发基线 `4767247ca`。本次没有后端、数据库、接口或权限合同变更,没有推送或合并共享分支。 + +最终相关回归合计 **341 项通过**,两端最终生产构建和隔离 Chrome 验证通过。执行摘要见 [回归结果](evidence/regression-summary.json)。 + +## 自动回归 + +| 范围 | 结果 | +| --- | --- | +| 共享会话/引用 | 71 项通过 | +| 共享文件预览 | 31 项通过 | +| 图表加载器 | 7 项通过 | +| 共享请求合同 | 19 项通过 | +| 构建配置、依赖及公共包边界 | 37 项通过 | +| 后台相关预览、引用、会话、历史日志 | 32 项通过 | +| 客户端相关引用、导出、PDF、沙箱 | 144 项通过 | +| 共享包严格类型及 lint | 通过 | +| 三语言一致性、生成文件一致性、架构守卫 | 通过 | + +负向用例均先观察失败再修复:资源 A→B→A、PDF 任务取消/旧文档回调、异常证据、账号缓存隔离、关闭引用后旧回调重新弹出、旧来源段落切换导致 PDF 重挂载、历史网页/文章/临时来源不必要的注册记录查询。 + +## 真实浏览器与生产输出 + +使用隔离的无头 Google Chrome,通过 CDP 操作,使用本地生成的真实 PDF、DOCX、CSV、HTML、SVG、WAV。不是模拟 Canvas 或 jsdom 截图。 + +- 两端实际 PDF Canvas 绘制、横竖混合页面、缩放通过;后台真实鼠标点击选区、拖动框选通过。 +- DOCX 转换、CSV 解析、Markdown 精确代码换行、图片解码、音频实际播放、DOCX 引用正文定位通过。 +- HTML 运行在不含 `allow-same-origin` 的沙箱中,宿主 DOM 访问被拒绝,隔离的内存存储可用。 +- ECharts 和 Mermaid 实际绘图、并发去重、两端开发地址的原始脚本 SHA256 通过。 +- 中文/英文/日文失败提示及 390px 窄屏通过。样本测试没有未捕获的运行异常。 +- 两端独立生产构建、静态启动及实际高亮模块导入通过;没有 `ReferenceError` 或 `HighlightJS` 初始化错误。生产静态测试没有业务后端,client 的环境读取产生 1 个预期的离线 404 ApiError,不能据此声称业务联调已通过。 +- 两端输出的两个根目录图表脚本均与公共源字节一致;worker/CMaps 均存在;client PWA 包含两份根目录图表资源。 + +[浏览器结果摘要](evidence/browser-summary.json) · [后台 PDF 样本](evidence/platform-pdf.png) · [前台 PDF 样本](evidence/client-pdf.png) + +API E2E 分类:本次没有新增或修改的 API 行为类 AC,因此没有新增后端 pytest。真实账号权限、分享、保存切片和完整业务流程仍须按 [验收清单](e2e-checklist.md) 验收。自动样本覆盖不能替代它们。 + +## 已有检查基线 + +- platform 严格类型:仅原有 `src/test/clientVoiceDraft.test.tsx:47` TS2554。 +- platform 全量 lint:最终剩余 22 项来自 4 个未改动的既有文件。最初 83 项中另有 61 项来自已修改的 API 文件,其原有 any 数超过冻结计数;本轮将触及的引用错误处理收窄为 unknown,违规总数降至冻结范围并通过 lint:prune,只减少旧抑制项。 +- client 全量 lint:未改动文件有 10 个错误和 2 个警告,来自 Skills 测试中文夹具、`useSubjectKeyword.ts` 的控制字符正则以及两处旧禁用注释。 +- client `sharedConversationState.test.ts` 的 Node 环境会先在未改动的 `test/setupTests.js:59` 遇到 `Element is not defined`,测试正文尚未执行。原有完整日志也有此错误。 +- client `TopBar.test.tsx` 原有 `filenamify` ESM/Jest 兼容失败;已有历史日志证明,未修改或掩盖该测试配置。 + +所有本次相关用例及新增源代码单独通过。没有增加 suppressions;已有忽略项随删除/修复减少。全仓检查目前不是全绿,不能作为自动合并依据。 + +## 维护边界与实施裁定 + +- 扩展既有 file-viewers/chat 包,图表原样脚本独立放在 diagram-runtime;宿主保留权限、签名地址、导航、下载和后台 PDF 编辑适配。 +- 现有默认导入路径只保留纯转接;新增组件使用命名导出,不保留第二套渲染实现。若判断有误,代价是调整兼容导入路径。 +- PDF 滚动通知保留原四个参数,并增加可选逐页位置上下文,避免混合尺寸和 0 起页码定位漂移。若判断有误,影响后台定位提示,需要调整适配。 +- API 层旧缓存也必须加访问范围,否则共享视图仍可能取得其他身份缓存;异步返回写入原范围。代价是上下文变化后重新读取,不能沿用旧身份缓存。 +- 保留旧日常/任务及后台历史消息的图表、附件、代码等业务渲染,仅共享引用展示与读取状态。若判断有误,剩余重复应在独立消息渲染改造中处理。 +- client 的旧 ResouceModal → PreviewFile 五文件闭合图没有静态、动态或 barrel/路由调用,已删除;backend 同名来源模块仍有调试/日志/独立页入口,保留业务壳。 +- public/vditor 是完整第三方分发,后台知识编辑仍明确引用其 CDN 路径,未拆改其内嵌图表依赖。根目录业务图表脚本已经单源;后续若清理第三方分发,需要单独验证编辑器。 +- 与本特性无关的检查基线不混入修复;代价是整仓 CI 仍需由相应模块修复后才能全绿。 + +## 最终独立审查 + +[审查报告及逐条 AC 追踪](code-review.md)。确认的三项 Important 及重新分级的一项 Important 已在单轮修复中补失败回归后通过;无延期 Minor。补齐完整临时来源点击、关闭后旧失败、后台重开刷新签名、图片/媒体切换重试。后台刷新始终走原引用权限接口,不回退其他获取路径。 + +浏览器还用真实 HTTP 复现 client 共享源码的 polyfill shim 解析 500;使用同一已安装 shim 的显式绝对 alias 修复。验证同时检查直接源码转换与实际组件,防止仅依赖预优化缓存漏检。 + +签名 fresh 读取仅共享同 scope 同 ID 的未完成请求;旧默认缓存不再直接作为后台预览签名。完整临时来源继续使用消息 seed。PDF 未启用的文字层 AbortException 分支没有活跃调用证据,不扩大改造;未来启用应补取消测试。 + +可复现入口:[浏览器样本说明](browser-smoke/README.md)、[单 worker 回归脚本](browser-smoke/regression.sh)。真实业务验收仍按清单执行。 + +全部实施裁定及完成记录见 [实施记录](implementation-log.md);开发分支保留,未自动合并或推送。 + +## 合入发版线验证(2026-10-26) + +合入远端 `feat/3.0.0` 的 `3fdd1ba6b` 后重新验证:相关回归 341 项全部通过;共享 chat/file-viewers 类型与 lint、三语言及生成文件检查通过。两端生产构建通过(platform 28 秒,client 2 分 23 秒);两端隔离 Chrome 文件/引用/图表样本通过,未捕获异常均为 0。生产静态启动及高亮模块导入通过,图表输出 SHA256 与公共源一致;client 离线环境读取仍出现 1 个预期的 API 404,未记为业务联调通过。 + +完整工作区 manifest 的 frozen/offline 锁文件验证通过;三个 SheetJS 消费方均从空 store 离线安装成功,XLSX/XLS 往返验证通过。30 个生产文件的请求辅助导入检查无缺失。相对远端发版线的差异空白检查通过。此前列出的全仓检查基线不在本次修复范围。真实业务验收继续按 e2e-checklist 执行。 diff --git a/features/v3.0.0/110-dsh-local-access/deployment.md b/features/v3.0.0/110-dsh-local-access/deployment.md index 594ef032af..317944cb4f 100644 --- a/features/v3.0.0/110-dsh-local-access/deployment.md +++ b/features/v3.0.0/110-dsh-local-access/deployment.md @@ -12,6 +12,30 @@ dsh: platform_public_url: https://bisheng.example ``` +### 统一公开地址 + +13001 测试环境的现场证据、配置核对及部署验收清单见 [公开地址修复部署验收](./public-origin-13001-validation.md)。 + +部署者在 `docker/bisheng/config/config.yaml` 的 `dsh.platform_public_url` 配置一次用户实际访问的地址。桌面客户端连接该地址后,登录授权、嵌入 Chat 和 Work 工具共用这套入口;MCP 的 Host 与 Origin 允许值由该配置派生。地址包含协议和对外端口,例如 `https://work.example.com`、`http://192.0.2.15:18081`、`https://work.example.com:18443`。路径由客户端和服务端协议各自拼接。 + +服务端加载配置时统一域名大小写、默认端口、国际化域名和 IPv6 表示。修改地址后重新启动 API 进程,并让客户端连接新的公开地址。工具的身份、租户和资源权限继续使用当前登录账号。 + +仓库两套 Nginx API 入口均通过 `proxy_set_header Host $http_host` 保留公开地址和端口。额外部署的负载均衡或 TLS 代理也应原样传递 Host;以下通用转发规则适用于不同部署地址: + +```nginx +location / { + proxy_pass http://bisheng_ingress; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; +} +``` + +`bisheng_ingress` 是该代理可访问的毕昇内部 upstream。最外层代理写入公开协议,内层毕昇入口沿用它。工具地址校验以实际 Host 和已配置的公开地址为准;`X-Forwarded-Host` 用于原有链接生成流程。 + +部署验收依次覆盖:通过公开地址完成桌面登录、打开 Chat、MCP `initialize` / `tools/list`、原生 Work 工具菜单和一次受权工具调用。若工具初始化返回 421,核对 API 实际加载的公开地址、各层代理转发的 Host 及运行版本;保留服务端原始错误用于定位具体拒绝层。 + +开发回归包含真实 MCP SDK 和可选的本机 Nginx 测试:在后端目录运行 `NGINX_BIN=/path/to/nginx python -m pytest --noconftest -o asyncio_mode=auto test/dsh/test_public_origin.py test/dsh/test_public_origin_nginx.py test/dsh/test_work_capabilities.py`。Nginx 测试从两套生产模板读取 map 和请求头转发指令,验证单层 HTTP、双层 HTTPS 及错误 Host/Origin 拒绝;完整生产路由和真实账号操作在部署环境验收。 + 部署开关开放管理模块后,还需在 DSH 设置中启用业务访问并配置模型及人员/部门额度。原 Gateway URL 和 introspection_timeout 配置作为弃用字段兼容旧配置,但不会产生网络调用。保留既有额度 Redis 拓扑、账期时区和审计密钥配置;它们属于计量和安全能力。 反向代理将 `/api/v1/dsh/*` 和兼容 `/api/dsh/*` 指向 Python API,并将 `/desktop-login` 指向平台 SPA。删除旧路由到 Java 的转发、认证拦截和验席中间层。若部署环境启用了 BISHENG_PRO/Java SSO 入口,按标准原生登录部署关闭对应商业登录代理;其他 Java 商业功能不能视为已迁入。 diff --git a/features/v3.0.0/110-dsh-local-access/public-origin-13001-validation.md b/features/v3.0.0/110-dsh-local-access/public-origin-13001-validation.md new file mode 100644 index 0000000000..8c79b98d29 --- /dev/null +++ b/features/v3.0.0/110-dsh-local-access/public-origin-13001-validation.md @@ -0,0 +1,76 @@ +# 公开地址修复:13001 部署核对与验收 + +## 交付范围与当前证据 + +本次修复以 `feat/bisheng-work-120-main@d4d60f77e41d58753a76969c6f40465a36054fa6` 为基线,部署者在 `dsh.platform_public_url` 配置一次公开地址。服务端统一地址格式,登录授权和 MCP 地址校验共用该值,桌面 Chat 沿用同一连接入口。两套 Nginx 模板保留 Host 中的公开端口。测试环境地址只用于下列核对步骤,产品代码按配置处理任意合法 HTTP(S) origin。 + +2026-10-27 现场证据: + +| 项目 | 已确认结果 | 证据范围 | +| --- | --- | --- | +| 本机客户端 | BISHENG `1.2.0-test.2`,Harness `0.2.1-rc.2` | 已安装应用 | +| 连接与技能 | 企业状态 connected,技能目录返回 3 项 | 当前登录账号 | +| 工具初始化 | 认证代理请求 MCP 返回 HTTP 421;界面显示 `Enterprise login is not available yet.` | 原始状态与界面提示分别记录 | +| 登录服务公开地址 | `http://192.168.106.109:13001` | 登录预请求返回的授权 URL | +| Nginx 与 MCP | 原模板使用 `$host`;当前源码允许公开 authority 和去端口 hostname | 源码核对,运行配置待查 | +| 远端部署 | 保持原版本,SSH 密钥认证失败 | 此次代码提交属于部署候选 | + +MCP SDK 的 Host 校验失败返回 421,Origin 校验失败返回 403。原模板丢端口与当前源码兼容去端口 Host 这两项证据同时成立。13001 的具体拒绝层需通过运行配置与日志确认;本 PR 的本地通过结果和现场恢复结果分别验收。 + +## 部署前配置核对 + +- [ ] 记录当前发布 SHA、后端镜像 digest、API 实例列表、入口 Nginx 配置版本、客户端版本;保存当前部署清单和配置用于回退。 +- [ ] 逐个 API 实例确认实际加载的 `settings.dsh.enabled` 和 `settings.dsh.platform_public_url`。记录环境变量 `config` 指向的文件及容器挂载来源,只摘录上述两个字段。13001 的预期公开地址为 `http://192.168.106.109:13001`。 +- [ ] 逐个 API 实例核对运行中的 `bisheng/core/config/dsh.py`、`bisheng/dsh/work_mcp.py` 与候选发布内容一致;核对 MCP SDK 版本。登录接口和工具接口应转发至同一套候选 API 实例。 +- [ ] 从公开入口逐层检查负载均衡、Nginx、额外网关的实际生效配置及上游目标。普通入口和 WebSocket/API 入口均检查。每层转发 `Host $http_host`;最外层 TLS 代理写入公开协议,毕昇内层入口沿用该协议。 +- [ ] 在一次已登录的工具初始化请求中记录各跳实际 Host、Origin、X-Forwarded-Proto、请求路径、状态和上游状态,关联同一时间/请求 ID。13001 到达 MCP 的预期 Host 为 `192.168.106.109:13001`;请求携带 Origin 时预期为 `http://192.168.106.109:13001`。旧入口的去端口 hostname 仍由现有兼容逻辑接受。 +- [ ] 若存在代理重写至容器名、内部地址或另一域名,修正该跳以保留公开 Host;若 421 出现在到达 API 之前,定位返回它的代理。若 API 已收到正确 Host,继续核对该实例的实际 MCP 校验配置、进程启动时间和源码版本。 + +采集材料只保留版本、公开地址、头字段和结果;Cookie、Authorization、登录票据及代理内部凭据保持在原认证通道内。Host 校验继续由公开地址派生,使用通配允许值会失去本次验收所验证的地址边界。 + +## 部署步骤 + +1. 按目标版本发布流程构建本 PR 的准确提交;记录最终 SHA。先核对当前环境与目标分支的数据库/运行时兼容情况,再部署候选后端和 Nginx 配置。本次差异包含地址逻辑、模板、文档和测试,数据库结构保持现状。 +2. 在该环境现有 `dsh` 配置节设置 `enabled: true`,保留既有其他配置,将 `platform_public_url` 设为唯一公开地址。发布模板以注释形式提供示例,配合支持 Desktop 的后端镜像启用。具体地址写在部署配置中。 +3. 应用两套实际使用的 Nginx API 入口配置,并核对自建外层代理的 Host 转发规则。先在运行环境执行 Nginx 配置检查,通过后 reload;后端更新后重新启动所有 API 实例,让 MCP 管理器加载当前配置。使用 Compose 时按实际 service 名称操作,仓库 API service 为 `backend`。 +4. 记录新进程的启动时间、运行源码 SHA/镜像 digest、加载的公开地址及健康检查结果。确认各 API 实例一致,然后开始下列原生客户端验收。 + +## 功能验收 + +使用已开通 Desktop、模型和工具权限的普通测试账号。预先选定一个可用的只读工具及可识别的结果,例如已配置的当前时间工具。管理员和普通账号的结果分别记录。 + +| 勾选 | 操作 | 通过标准 | +| --- | --- | --- | +| [ ] | 客户端输入 13001 公开地址并完成登录 | 授权页、回调成功,企业状态 connected,账号与租户正确 | +| [ ] | 打开 Chat 并发送一条测试消息 | 使用已授权模型收到正常回复,消息刷新后可见;记录所用模型 | +| [ ] | 通过已登录客户端获取工具目录,观察认证后的 MCP `initialize` | HTTP 200,响应含 `result.serverInfo`;记录上游结果而非仅本机包装接口状态 | +| [ ] | 继续观察 MCP `tools/list` | HTTP 200,目录含预先指定的只读工具;工具名称和数量与账号权限相符 | +| [ ] | 打开 Work 的加号工具菜单或 `/tools` | 展示上述工具,可选择并加入会话 | +| [ ] | 在 Work 实际调用指定只读工具一次 | 工具调用完成,返回预期业务结果;保留工具名称、时间、调用结果和审计记录 ID | +| [ ] | 退出重开客户端、重新进入 Chat 与工具菜单 | 连接和目录正常,使用同一个公开地址 | +| [ ] | 使用缺少该工具权限的普通账号检查目录及调用 | 目录和实际调用均遵循该账号权限 | + +工程侧在隔离测试环境复核错误 Host 返回 421、错误 Origin 返回 403,伪造 `X-Forwarded-Host` 仍按实际 Host 校验。更换为另一合法公开域名/IP/端口后,只更新公开地址配置并按上述步骤重启 API、连接客户端,复测登录、Chat、目录及调用;外层代理沿用相同的通用 Host 转发规则。 + +## 失败定位、回退与回执 + +421 按“入口代理 → 中间代理 → API 实例 → MCP 校验”逐跳定位;401 优先核对登录会话,403 结合响应来源区分 Origin 校验与业务权限。目录为空时核对账号权限、工具启停和发布配置,并用预先指定工具确认结果。界面仍显示通用登录错误时保留该提示,同时记录真实上游状态供客户端错误展示问题继续处理。 + +若候选发布影响原有登录/Chat,按发布流程恢复上一个后端版本及配套 Nginx/地址配置,重新检查健康、登录和 Chat。用户会话与业务数据沿用原存储。记录回退原因、版本、时间和复测结果。 + +测试回执包含:候选 SHA/镜像 digest、API 实例一致性、公开地址、代理链与实际 Host、客户端版本、账号类型/租户、上述各项 PASS/FAIL、MCP initialize/tools/list 状态、一次工具业务结果和失败时的请求 ID。全部现场项目通过后再将 13001 标记为恢复。 + +## 已完成的自动验证 + +本地地址回归 23 项、原有 Work 能力回归 15 项、真实 Nginx 1.28.0 转发回归 4 项,共 42 项通过。Nginx 回归从生产模板读取 map 和请求头指令,覆盖两个入口的单层 HTTP、双层 HTTPS、真实 MCP 初始化和工具列表以及地址拒绝边界。完整生产路由、真实数据库登录、模型聊天、业务工具调用按上述现场清单执行。 + +复现命令(Python 3.11、项目锁定依赖、可用 Nginx/OpenSSL,运行目录 `src/backend`): + +```bash +NGINX_BIN=/path/to/nginx python -m pytest --noconftest -o asyncio_mode=auto \ + test/dsh/test_public_origin.py \ + test/dsh/test_public_origin_nginx.py \ + test/dsh/test_work_capabilities.py -q +``` + +Nginx 二进制需支持 HTTP SSL。缺少 `NGINX_BIN` 时四项代理测试会跳过;回执需记录实际通过和跳过数量。 diff --git a/features/v3.0.0/111-dsh-sensitive-word-control/code-review.md b/features/v3.0.0/111-dsh-sensitive-word-control/code-review.md new file mode 100644 index 0000000000..7aefca0ec4 --- /dev/null +++ b/features/v3.0.0/111-dsh-sensitive-word-control/code-review.md @@ -0,0 +1,52 @@ +# F111 集成候选代码审查 + +历史候选快照(后续增量以文末为准):2026-10-24。B基线 `858f76605ca81f0cef36f42841414b797a432fac`,合并源 `3420114d0`;G基线main `2995df6f`、特性源 `108eb5c8` 加T185/T186最终修复;Desktop源 `6c84d46`,T192兼容修复尚待收口。最终交付SHA与CI/digest见 deployment-execution.md。 + +本报告按仓库 `/sdd-code-review` 七维审查实际diff、源码及spec/design/tasks。B使用尚未提交的真实组合工作树差异而非三点HEAD,以免漏掉合并中的文件;G主/独立agent逐行交叉复核最终两源。候选源码审查与完整环境验收分别记录。 + +| 维度 | 当前HIGH | 当前MEDIUM | 结论与依据 | +|---|---:|---:|---| +| 边界条件 | 0 | 0 | 严格JSON/ID/分页/协议预算;Responses跨part缺陷已配对修复 | +| 权限与认证 | 0 | 0 | 共享HMAC精确路径原始bytes、租户隔离/历史事件、原web-session及模型鉴权保留 | +| 并发与幂等 | 0 | 0 | 不可变事实、唯一约束、短事务、owner租约与版本;MySQL真实并发和升级结果独立记录 | +| 信息泄漏 | 0 | 0 | 不存正文/命中词/凭据;局部QueryClient原始错误日志已关闭并保留页面反馈 | +| 测试覆盖 | 0 | 0 | 本地配对和相邻组合已通过;DM8/真实E2E/容量不是已通过项目 | +| 代码与架构 | 0 | 0 | 无模型鉴权/计量网关耦合,Ruff/架构守卫/目标lint/类型/生产构建通过;旧全量lint保留基线 | +| 文档一致性 | 0 | 0 | 同步迁移、七页签、模型开始时间及有界跨part混合观察算法,未改已确认spec取舍 | + +## 已修复发现 + +1. HIGH(T185/T186):同message相邻output_text及同reasoning summary/content跨part漏检,原测试还将错误PASSED固化。先保留19RED,后扩空part、Unicode、delta/done混合和final替换回归;最终60配对全绿、Gateway主独立882全绿。各part尾部屏障在item结束前不解除,原始观察不能被safe final抹除,混合观察后缀候选最多64且计入既有buffer,超限ERROR。独立第二agent2100语义样本及128候选拒绝通过。原108eb5c8本身仍是修复前版本,不得部署该旧commit。 +2. MEDIUM(T188):开发react-query保存失败会输出包含自定义词/请求headers的AxiosError。两回归先RED,局部安全logger后24PASS;保留错误提示/草稿/不自动重试。 +3. MEDIUM(T189–T191):Card未消费已保存model_started_at,违反AC-46分别显示。新增独立三语详情行、未知/不适用,稳定请求header/排序不变;5RED转10PASS。 +4. 组合兼容(T176–T178、T181–T184、T187):未发布F111接续已发布积分head,保留已发布revision哈希;保留全部目标路由和七页签;补缺翻译;修正旧bootstrap常量及不可变配置测试撤销污染。未通过修改生产租户逻辑来制造测试通过。 + +## 实际验证 + +- Backend最终相关组合910PASS/15环境SKIP,0FAIL/ERROR;HMAC/登录组合82PASS,独立审查205PASS。 +- MySQL T179主独立24PASS/24DM8SKIP;fresh149表、work110143→149、真实target积分148→149反射一致。raw80/rate125/billed100、UNKNOWN NULL、迟到与重复结算不变。SQL边界结果不冒称真实Gateway/模型/Redis E2E。 +- Gateway最终主独立34类882PASS/0SKIP,Responses60;另两组独立语义样本1800及2100PASS。真实MySQL Outbox10PASS证据沿T120,不算DM8通过。 +- Platform最终DSH28文件280PASS、Platform609strict及全workspace类型检查PASS、目标lint/i18n/locales PASS;最终UI后两个SPA正式build PASS。 +- Desktop正式build/typecheck通过;完整测试2810PASS/5FAIL/3SKIP,3unhandled,既有Undici兼容缺陷由T192修复中,Python环境已定位。临时Profile启动有Harness is ready,但完整界面/登录/真实工具验收尚未通过。 + +## 结论与剩余验收 + +**B/G候选源码:PASS,可以进入用户明确授权的CI构建及109 DSH专用测试部署。完整特性T132:INCOMPLETE,不予勾选。** + +LinuxDM8按用户确认待验;T121–T125真实角色/模型/页面/工具与恢复验收、T126–T129容量、T131回退演练仍须实际证据。旧全量frontend lint Platform95/Client23有逐文件字节等target基线,不修改抑制项,不声称全量lint通过。Desktop联调须先收口T192。 + +本报告不批准更改其他部署、放宽认证、生成License、清理真实审计、覆盖已有标签或force-push。用户已单独授权本次merge/push/CI/109部署和启动客户端,最终操作记录按实际追加。 + + +## T197/T198 部署前最小兼容补审(2026-10-24) + +PASS,未发现HIGH/MEDIUM:离线模型治理元数据与实际调用快照分开;仅两管理员visibility路由使用前者。调用online、tenant/Root FGA、provider归属、DTO、policy CAS与计量未改变。主读全部三源,独立相关71PASS/5真实环境skip;B五文件71PASS及Ruff/archguard/diff通过。测试的管理身份依赖替换只证明端口短路,实际普通身份拒绝尚待109新镜像读回。新修复待新CI镜像,不改写前一ecf43f79镜像身份;完整特性T132及License阻塞下现场验收保持未完成。 + + +## 最终发布与验收补审 + +当前B3cc9de9298/G64f602be/Desktop3e8486c均已推送并核远端SHA,B5624/G404 SUCCESS,固定digest仅更新109DSH。T192最终2820PASS/3SKIP/0FAIL/0unhandled及正式build已收口,实际隔离Desktop已连接并完成两协议正常回复、真实read正例与敏感参数零调度。用户已追加授权官方生成测试License,实际有效至2026-11-23,不再存在此前License阻塞。 + +T199/T200精确19801→admin_forbidden403修复经891完整Gateway及独立44/Netty3通过,未知错误保持503;真实普通权限七例通过。新增冻结HTTP设施/协议/身份/只读审计测试经主逐源和A/B/C交叉复核,T202/T203新48+旧33/13通过;Provider只认证关联结果,真实工具体成功另由持久成功结果+文件nonce证实。真实协议35与审计12条子集通过,旧失败均保留;不冒完整T121/T122/T123、DM8、多副本/容量与T132验收。当前无新增已确证HIGH/MEDIUM源码阻断,完整特性交付状态仍PARTIAL。 + +T204/T205主独立全文复审抓住capabilities旧字段误判,实际wire反例修复后最终49local+7live全PASS;严格body byteCount、公开身份/Backend header、物理零调用、精确完整空分页与账本不变保持。未新增业务源。最终测试资料补充不改变已部署生产协议;后续CI镜像与实际容器按新远端SHA另行读回。 diff --git a/features/v3.0.0/111-dsh-sensitive-word-control/deployment-execution.md b/features/v3.0.0/111-dsh-sensitive-word-control/deployment-execution.md new file mode 100644 index 0000000000..5e6b1b4266 --- /dev/null +++ b/features/v3.0.0/111-dsh-sensitive-word-control/deployment-execution.md @@ -0,0 +1,59 @@ +# F111 work120 整合与109 DSH交付记录 + +更新:2026-10-24。用户已授权合入最新`feat/bisheng-work-120-main`、推送、Drone构建、仅109 DSH13001部署及启动客户端联调。本文无密码、Token、HMAC或License值。完整特性验收仍与本次部署分开。 + +## 源码与CI + +| 仓库 | 运行镜像对应源码(后续纯文档提交不改业务) | Drone | 实际结果 | +|---|---|---|---| +| BiSheng | `feat/bisheng-work-120-main` → `d5b026557b76bb57af223520c11aeecd4e83473e` | 5625 | SUCCESS,backend/frontend两镜像完成 | +| Gateway | 同名分支 → `64f602bed9fc8593cb957fb1326c229f911aae1c`;唯一tag `v1.8.0-dsh-f111-20261024-admin-denial-64f602be` | 404 | SUCCESS,amd64/arm64镜像完成 | +| Desktop | 隔离feature分支`3e8486c4da82b163d568ffde385a0304f040c698` | 本机 | build/typecheck/2820测试通过;真实两协议回复与工具正反例通过 | + +BiSheng是最新target `858f76605ca81f0cef36f42841414b797a432fac` 与F111源`3420114d0f2481d20b8854af1b84f7ca7c944c4b`的正常历史合并,保留源provider兼容修复。Gateway旧源108eb5c8存在已确证跨part漏检,不可部署;faa6463b含跨part修复,64f602be另含精确旧管理拒绝映射。两仓remote head/tag解引用均已读回。Gateway404实际stage只有clone/package/image/notify四步,115自动部署步骤由现有DSH标签条件过滤,不能把不存在的步骤描述为返回了SKIPPED状态。 + +## 实际镜像 + +镜像由CI `192.168.106.8:6082`产出,经既有`cr.dataelem.com`入口拉取,digest与CI日志逐一匹配。未修改109共享Docker daemon或insecure registries。 + +| 服务 | 固定镜像 | +|---|---| +| backend / backend_worker | `cr.dataelem.com/dataelement/bisheng-backend@sha256:151d35b0436825c23dd9c5940300d801e8294bb1c0a75c5798250216991ef0cd` | +| frontend | `cr.dataelem.com/dataelement/bisheng-frontend@sha256:b323977b0249f1e75d3a4c09e5a5bbd925931022e694a0d3b06991c6022cdb50` | +| gateway,109 x86_64 | `cr.dataelem.com/dataelement/gateway@sha256:63d7d9eddd7b0b5c5633b8ab598f633c5ad6120d017de07221dc5fb93f75d908` | + +实际四容器ImageID已读回对应上述digest。T197/T198由B5624收口,B5625只追加测试/helper与文档,bisheng业务树与3cc9de9298完全相同;当前已更新backend/worker到5625,T199/T200由G404收口;frontend两次digest相同。初次合并ecf43f79和前次Gfaa6463b构建历史仍保留,不覆盖标签。 + +## 109范围、备份与执行 + +- 项目`dsh-bisheng`,文件`/opt/server/dsh/bisheng/docker-compose.yml`,只更新服务`backend`、`backend_worker`、`frontend`。公开13001,backend仅127.0.0.1:17860。 +- 项目`dsh-gateway`,文件`/opt/server/dsh/bisheng-gateway/docker-compose.yml`,只更新`gateway`,仅127.0.0.1:18098。私网`dsh-integration`。 +- 专用库`dsh_test_work110_main_20261020`、`bisheng_gateway`;现场MySQL8.0.43。没有操作109其他数据库、Compose项目或115部署。 +- 备份在`/opt/server/dsh/backups/f111-20261024-predeploy`,并在真正更新前刷新到`f111-20261024-work120-before-up`,目录700/文件600。包含Compose、B/G配置、entrypoint、nginx、实际两个旧env_file、单事务两库dump、镜像身份与SHA256SUMS;备份不下载入Git。 +- additive Gateway DDL实际建`gt_dsh_sensitive_policy`与`gt_dsh_safety_outbox`,创建前均不存在,创建后均0行。Backend实际Alembic从work110升级至`f111_dsh_content_safety`,保留f062积分历史。 +- 配置仅追加两条精确POST模型路由,保留旧WebSocket和通配路由;精确路由不带CacheRequestBody。既有response-timeout为3600秒,与nginx预算一致。启用content-safety部署能力,namespace=`dsh109-work120`、source=`dsh109-gateway`。 +- 共享HMAC以私有env_file注入Backend两服务。实际ConfigService没有自动SSOSyncConf env覆盖,故配置必须用其已存在`!env`构造器显式引用变量;用实际load_settings_from_yaml内部比较确认一致,不输出值。绑定文件替换后仅重建backend/worker,实际readback `hmac_configured=true`,签名头`X-Signature`。 +- 只以`docker compose -p -f up -d --no-deps `更新,未操作其他中间件或共享daemon。 +- 四目标更新后Backend健康200、13001页面200。与部署前快照逐字节比较:所有其他既有容器的ID、Image和StartedAt不变;额外只有本轮临时测试Provider。 + +实际13001 nginx模型入口`/api/v1/dsh`经过Gateway;旧`/api/dsh`登录兼容入口直连Backend,不将登录规则误报为模型旁路。真实普通用户网络封闭验收仍未执行。 + +## 真实联调进度与阻塞 + +已用用户提供平台账号真实RSA登录并GET身份读回;会话只保存本机0600临时文件,未保存密码到仓库。通过管理API创建本轮普通用户9(非global,leaf1),未修改既有账号/角色/默认模型。现有原生审计模式CONTENT/version1,未改动。知识/评估/助手/工作台默认配置已只读快照,后续核对不被测试设施改变。 + +最终T192之后实际客户端再次build成功,并以独立appData/userData/session/log目录启动,Harness ready。真实Electron DOM打开企业账号,填写用户指定109 origin并确认可信内网HTTP;实际客户端生成PKCE,使用本轮普通用户真实平台会话批准,并成功抵达实际随机loopback回调。公开客户端state确认`connected=true`、`phase=connected`、`modelCount=2`。授权批准用真实HTTP API完成,此证据不冒充浏览器授权页按钮/iframe的手工UI验收。 + +无费用Provider最终源码SHA256 `80782c04c0936c16aefeacbed35e7382b04d22901df8f97f8c567394289772fa`,仅本轮容器`f111-provider-20261024-f111a`接入DSH私网,宿主控制端口127.0.0.1:18181经SSH。使用独立随机测试key、只读挂载、256MiB/1CPU和无外部模型连接。旧计数已在Provider唯一重启前保存,active0;新进程计数取新基线,不跨进程相减。模型8/9已在offline期间GET/PUT CAS收窄仅user9、再读回确认后上线;旧默认与其他credit规则保持。 + +用户追加授权生成License后,使用现有官方gen_license/gateway_license.py、当前Gateway公钥匹配的sso兼容trial格式生成30天测试许可,有效至2026-11-23;仅私有0600保存和修改DSH Gateway配置,保留备份、不改许可检查。真实GET expired=false,平台聚合经现有合法状态上报同步为expiring/30days。 + +真实HTTP协议35/35(380.29s)、身份/管理7/7(29.83s)、十二条实际审计记录的四模式只读复核4/4(12.47s)通过。Desktop两协议正常回复、read成功各一次且文件nonce匹配、敏感参数各零工具调度,界面显示租户提示且无自动重试;BLOCKED原生模型实际结算80保持。工具结束恢复version97,随后七边界验收结束最终CAS恢复原租户策略disabled/version111。详细历史和范围见implementation-verification.md。 + +当前是单实例部分真实验收。LinuxDM8按用户决定待验,双租户、多副本、无Gateway、设施故障、容量及回退演练未完成,不以单实例组件测试或此次十二条只读核验替代。T121/T122/T123/T132完整任务仍不勾选。 + +最后边界实际7/7通过(另49本地断言),PlatformEnglish命中筛选/方向/DECISION与DRAIN详情实际通过。再次逐字节比较所有非四目标的既有容器ID/image/StartedAt不变,owned Provider健康ready。原始密码/Token/HMAC/License值未进入交付文档和Git。 + +CI5625 SUCCESS的源码d5b026557已远端读回;实际backend/worker ImageID e5d1403c2e1a95323dffded3858cd088a76a8ac965df186cba799e0cea8587ce。真实Backend重启后十二条旧审计四模式4PASS/13.14s。启动期首次业务读取失败保留,不能以13001/health的SPA兜底200冒后端就绪;直接127.0.0.1:17860/health与真实管理API确认后再验。Gateway日志实际连接拒绝包含旧172.31.62.10和新172.31.62.9,重建后端切换窗口确有旧地址/启动连接失败。随后四模式烟测两case通过但策略teardown真实503,停止并核精确自有合成词/提示/版本114后CAS恢复115;不隐藏失败或全局覆盖策略。待Backend业务ready后仅原镜像重建指定Gateway刷新连接,受保护capabilities连续三读成功后复验。 + +最终按依赖就绪顺序复验:四协议烟测4PASS/31deselected(40.27s),策略teardown全部成功;后端十二条持久审计读取4PASS(13.14s),并非全故障恢复验收。此前两case+teardown503和启动业务失败日志保留;不放宽断言、不改业务源码或重试模型求绿。最终许可证trial/2026-11-23/expired=false、全局默认模型与原审计设置不变,策略原字段恢复关闭;最后非DSH既有容器身份仍不变。后续仅features文档提交不改变src/backend构建上下文,运行镜像按本表CI5625来源记录。 diff --git a/features/v3.0.0/111-dsh-sensitive-word-control/design-review.md b/features/v3.0.0/111-dsh-sensitive-word-control/design-review.md new file mode 100644 index 0000000000..3859db4516 --- /dev/null +++ b/features/v3.0.0/111-dsh-sensitive-word-control/design-review.md @@ -0,0 +1,108 @@ +# F111 design 静态自审记录 + +- 日期:2026-10-23 +- 对象:[design.md](./design.md),对应已用户确认的 [spec.md](./spec.md) +- 方法:仓库 sdd-review design 的 24 项清单与 Constitution Check +- 评审性质:本次编写者静态自审;没有调用独立评审 agent,没有执行实现、迁移或运行时验收 +- 最终输出:**LGTM**(已同步 2026-10-23 用户取舍,两项后续复审意见撤回) +- 阶段状态:design 已用户确认(2026-10-23);tasks 已进入拆解及静态评审,业务代码未修改 + +## 1. 首轮发现与修正 + +以下问题均在本轮设计草稿内修正,没有改变 spec 的验收口径。 + +| 问题 | 初始等级 | 修正后的设计 | 复核依据 | +|---|---|---|---| +| 用租户详情接口统一验证管理权,会拒绝普通租户管理员;仅 user/info 显示标志也不能独立授权 | high | design §4.5:所有操作者复用既有 DSH 审计 settings 的管理读权限;普通管理员核对本人 active 租户列表,超管再校验目标详情 | tenant_crud 的 get_admin_user、model_audit_admin 的 get_tenant_admin_user、authorize_admin 及 user/tenants | +| 默认代理响应结束可能提前释放上游,游离二次订阅还可能重复模型请求;旧 CacheRequestBody 有额外大缓冲 | high | design §3.7、§4.6:精确独立路由、单次上游订阅、专用连接所有权、有界续读,跳过旧正文缓存 | Gateway 现有 route、codec 上限、过滤器顺序及代理生命周期约束;具体生命周期须实现测试证明 | +| 会话全量命中标志不能证明同一请求同时满足时间、模型、用户条件 | medium | design §4.9:汇总仅作候选,有界核验同一请求;全量与筛选计数区分 | 现有会话投影及组合候选查询结构,spec AC-45、AC-48、AC-50 | + +另对外部条目的可空模型字段、六种安全汇总、未知与不适用做了专项复核:design §4.8、§6.4 与 spec AC-34、AC-36 一致,未将其标为已实现能力。 + +## 2. 24 项清单 + +清单中引用旧版 spec §5–§7 的条目,本轮按现行 spec 的真实结构及全部 59 条 AC 复核,不虚构不存在的章节。tasks 相关条目以“尚未进入该阶段”处理,不创建空任务文件充数。 + +| # | 检查点 | 结论 | 证据位置 | +|---|---|---|---| +| 1 | 目标简明 | PASS | design §1 | +| 2 | 非目标明确 | PASS | design §1,与 spec 范围一致 | +| 3 | 特有性能、协议和部署约束 | PASS | design §2 | +| 4 | 引用全局约束并完成 Constitution Check | PASS | design §2 与本记录 §3 | +| 5 | 关键决策齐全 | PASS | design §3 七项决策 | +| 6 | 每项有备选、选定、原因 | PASS | design §3 | +| 7 | 给出重新考虑条件 | PASS | design §3 | +| 8 | 原因有源码或协议依据 | PASS | design §3、§4.1、§5 及源码锚点 | +| 9 | 数据流清楚 | PASS | design §4.2 | +| 10 | 对外数据、字段和类型明确 | PASS | design §4.8、§6 | +| 11 | 模块做什么与不做什么明确 | PASS | design §4.3 | +| 12 | 现状与拟新增区分,未把设计当已实现 | PASS | design 开头、§2.3、§4.1 | +| 13 | 有反直觉事实 | PASS | design §5 | +| 14 | 每个坑说明后果 | PASS | design §5 | +| 15 | 每个坑有处理落点 | PASS | design §5、源码锚点 | +| 16 | 提供的 HTTP、内部接口及格式明确 | PASS | design §6.1–§6.4 | +| 17 | 上游与部署依赖明确 | PASS | design §6.5 | +| 18 | 依赖风险明确 | PASS | design §6.5 | +| 19 | 单元、集成、E2E 分层明确 | PASS | design §7.1;均为待执行计划 | +| 20 | 有可操作手工验证路径 | PASS | design §7.3;样例文件在实现测试阶段生成 | +| 21 | 短板与暂不投入有理由 | PASS | design §8 | +| 22 | 初版修订记录 | PASS | design 修订历史;未标成 feature 已完成 | +| 23 | 与 spec 一致,59 条 AC 有承接 | PASS | design §7.4 | +| 24 | 未遗漏实际偏差记录 | N/A | tasks.md 不存在,业务实现尚未开始 | + +## 3. Constitution Check + +全局规则以 [constitution.md](../../../docs/constitution.md) 为准,以下只记录本设计的检查结果与落点。 + +| 约束 | 检查结论 | +|---|---| +| C1 | 新接收端只编排 Service/Repository;拟提取通用 HMAC primitive 供既有 SSO 与 DSH 入站适配共同调用,不跨 API 调用 SSO。审计增量不接入 model.complete 的执行判断 | +| C2 | backend 使用现有方言适配,Gateway 提供两种 DDL;条件领取、游标和摘要不依赖 MySQL 独有语法。真实 DM8 验证仍待实现阶段 | +| C3 | 精确服务认证分支不启用前缀 bypass;签名后建立严格租户范围,执行线程单独设上下文;写操作显式限定租户、编号和版本 | +| C4 | 管理操作经既有 backend 自然人及租户管理运行时校验;不以 profile、浏览器角色字段或本地角色表自行授权 | +| C5 | 使用既有 261 模块;候选 26170–26175 当前静态无占用,实施时重查并登记。平台与 DSH 两种错误合同区分 | +| C6 | 密钥沿用 Gateway 与 backend 现有共享 HMAC 配置,不另复制为 DSH 独立配置;不记录 Token、Cookie、正文或签名内容 | +| C7 | 平台请求走 API wrapper,新增取数按当前根 AGENTS 的 v4 约定;不在 store 中请求 | +| C8 | 事实、租约、dirty、版本和保留水位在 SQL/Redis;本机缓存及连接不可作为跨实例真相 | + +未发现设计违反以上约束的 BLOCKER。该结论是静态设计合规性,不是运行时权限或双库兼容证明。 + +## 4. 覆盖与检查边界 + +- spec 的 59 条 AC 编号保持不变;AC-38、AC-42、AC-49 按本轮用户明确取舍澄清可信身份变化和现有分页口径,边界表同步。 +- design 的 AC 承接表覆盖全部 59 条;所有编号完整书写。 +- 本轮检查文档链接、章节交叉引用、决策四要素、输入/输出 36 种状态组合及命中保持口径。 +- 本轮未运行 Gateway/Java、backend/Python 或前端/客户端的业务测试;未运行迁移、模型调用、性能压测或部署。 +- 性能门槛、缓冲放大、跨副本词库发布、代理取消生命周期和补报容量仍须按 design §7 的计划在实现后验证。 + +## 5. 用户评审时需关注的设计取舍 + +- 外部事实表配主表摘要,默认明细不自动清理;30 天只是容量评估,实际保留要与原审计规则统一。 +- 为满足 30 秒有界续读,模型上游连接由 DSH 专用路由管理,不依赖默认代理闭流后的游离订阅。 +- 策略管理临时复用已有 DSH 审计 settings 的只读授权能力,保持 backend 不新增 Gateway 专属管理接口;该依赖只在管理动作发生,不进入模型检查热路径。 +- 词库摘要不相符的副本拒绝检查;首期不承诺跨内置词库版本滚动发布不停服。 + +用户确认 design 后才进入 tasks 拆分。本记录未将“自审通过”替代用户确认。 + +## 6. 后续复审意见与用户裁定 + +初版之后曾提出以下两项复审意见,并说明了触发前提。用户于 2026-10-23 明确两项均不纳入本期,已同步 design §2.4、§4.7–§4.9、§6.2 及 spec AC-38、AC-42、AC-49。 + +| 复审意见 | 最初等级 | 用户裁定与最终处理 | +|---|---|---| +| 默认相对时间窗口与游标指纹的续页行为 | medium | 沿用现有分页,不增加固定筛选范围、冻结窗口游标或查询会话;保留默认 7 天、最大 31 天及显式条件校验。该意见撤回,不作为阻塞项 | +| 不同版本事件的身份冻结和请求级绑定 | 初报 high,后说明属于需要错误上报前提的防御性缺口 | 毕昇是可信身份来源,接受其返回信息变化,不新增独立绑定表或跨事件身份冻结;来源认证、权限边界及同事件幂等继续保留。该意见撤回,不作为阻塞项 | + +本次仅同步评审决定并复核文档一致性,未执行业务测试或迁移,未新增 tasks、代码或部署操作。两项取舍的确认不替代对完整 design 的用户确认。 + +## 7. HMAC 配置确认与 profile 定义 + +用户明确使用现有相同 HMAC 密钥配置。design §3.4、§4.3、§4.7、§4.10、§4.11、§6.2 已同步:Gateway 使用 bisheng.gateway-hmac-secret,backend 使用 sso_sync.gateway_hmac_secret,两端值一致;签名头和 METHOD/PATH/raw body 规则沿用现有实现,移除 DSH 独立密钥变量、密钥登记表及独立 key_id 协议。接收端仍独立处理正文、时间窗口、幂等和租户上下文,不将 SSO 正文回放或租户 bypass 接到 DSH 模型路径。 + +源码复核:desktop_principal 调用 DshAccessService.authenticate 验证 Token、当前身份及会话;GET /dsh/profile 再调用 IdentityService.profile 读取当前用户和租户资料,返回 user 与 tenant。design §4.4 已注明 profile 是服务端成功验证后的返回信息,缓存对象是该验证结果,Gateway 用 tenant.id 选择租户策略。 + +本次仅修改 design 与评审记录,spec 验收条目未改动,未进入 tasks 或业务实现。 + +## 8. 用户确认与任务阶段 + +用户明确“设计可以,继续按 SDD 流程产出 tasks.md 并评审,暂时不要修改业务代码”。据此更新 design 的确认状态并产出任务与任务评审文档;主决策和对外接口没有变化。tasks 核对当前 package.json 后,补充既定 v4 约定所需的直接依赖与本特性局部 Provider,不开展全站迁移。此前各节的“未创建 tasks”属于当时的评审记录,以本节和顶部当前状态为准。 diff --git a/features/v3.0.0/111-dsh-sensitive-word-control/design.md b/features/v3.0.0/111-dsh-sensitive-word-control/design.md new file mode 100644 index 0000000000..3a8fca0f77 --- /dev/null +++ b/features/v3.0.0/111-dsh-sensitive-word-control/design.md @@ -0,0 +1,781 @@ +# Design: DSH 模型转发敏感词控制 + +- **特性**:F111 / v3.0.0 +- **关联规格**:[spec.md](./spec.md),用户于 2026-10-23 确认 +- **需求输入**:[已确认方案](../../../docs/architecture/proposals/2026-10-22-dsh-gateway-content-safety.md) +- **状态**:已用户确认;核心实现及真实109单实例子集已完成,完整验收PARTIAL,详见tasks与交付记录 +- **最后更新**:2026-10-27 +- **工作分支**:feat/bisheng-work-dsh-guardrails +- **执行流水**:[tasks.md](./tasks.md) 已按确认设计拆解并评审,用户已授权按依赖持续实施;验证证据见 [implementation-verification.md](./implementation-verification.md) + +本文区分已核实的代码现状与拟新增行为。下述新增类型、表、字段、接口和参数均为设计,不能据此认定功能已交付。后续实现变化应覆盖更新本文,同时保留决策原因;推翻已确认需求时按 [SDD 流程](../../../docs/SDD-Guide.md) 重新确认。 + +## 1. 目标与非目标 + +Gateway 根据可信租户身份,对 DSH Chat Completions、Responses 的输入和输出执行敏感词检查,并将检查事实可靠回传到 backend 审计。backend 的模型鉴权、执行、额度和计量继续独立;平台和 Desktop 展示策略、阻断及审计结果。 + +本期不恢复席位、License 准入或旧 Gateway DSH 登录,不让 backend 主动连接 Gateway,不迁移其他敏感词业务,不增加按人、部门、模型覆盖策略。语义审核、图片识别、整轮撤回、全历史检索及导出均按 spec 排除。 + +## 2. 关键约束 + +遵循 [constitution.md](../../../docs/constitution.md) C1–C8、[版本契约](../release-contract.md) 及各子项目 AGENTS.md;本文只记录本功能的落点,不复写全局架构规则。 + +### 2.1 已确认的行为边界 + +| 约束 | 设计落点 | +|---|---| +| backend 无 Gateway 运行时依赖 | 仅审计增加通用外部事实接收;模型路径无检查 RPC、网关地址或词库 | +| 身份定位最多保留 30 秒,不能超过 Token 寿命 | 绝对截止时间、Redis 剩余寿命及本机单调时钟共同限制;命中不续期 | +| 新策略最多 5 秒生效,在途固定快照 | 版本化策略、绝对缓存寿命;普通更新不追杀在途流 | +| 完整命中词不能交付,安全前缀可保留 | 按逻辑文本域检查,整事件有序交付;工具参数完整检查 | +| 输出阻断后续读最多 30 秒 | 从判定命中计时,不能从后台任务开始或最后一个片段重新计时 | +| 未确认事实重启可恢复,确认后的事实和摘要可查询 | Gateway SQL Outbox;backend 提交事件和摘要后 ACK | +| 历史和未知不等于通过 | 六种阶段状态与六种请求汇总;NOT_APPLICABLE 纳入本次已确认的修订 | +| 在线默认 7 天,单次跨度最多 31 天 | 沿用既有分页,不新增默认窗口冻结;显式条件及租户仍需校验 | + +### 2.2 资源和故障预算 + +以下为设计初值,未做真实负载验证。业务硬上限不得通过调优放宽;其余参数在实现压测后可下调或评审调整。 + +| 项目 | 初值 / 上界 | +|---|---| +| profile L1 / Redis 正缓存 | 2 秒 / 30 秒;Token 到期另预留 5 秒,负值不缓存 | +| 明确凭据错误负缓存 | 仅本机 1 秒;超时、5xx、格式错误不负缓存 | +| profile 总预算 / 并发 / 等待队列 | 1 秒,包含请求合并等待;每副本 32 在途、64 等待,不自动重试 | +| 策略 L1 / Redis / 读取预算 | 1 秒 / 5 秒 / 500 毫秒;词库缺失或过期后不可读时拒绝检查 | +| 请求 / 原生 Responses 单事件 / 累计响应 | 32 MiB / 8 MiB / 32 MiB,与现有 DSH 协议边界一致 | +| DSH 检查中的保留编码字节总额 | 每副本 64 MiB;同一缓冲不能在请求、响应两边漏计或重复抵扣 | +| 检查任务并发 / 后台续读并发 | 每副本 16 / 8;无续读额度时立即关闭上游,不排长队 | +| 后台续读 | 命中后最多 30 秒,剩余累计响应预算最多 32 MiB 减已读字节 | +| 自定义词 / 生效词表 / 单词长度 | 自定义 UTF-8 1 MiB;合并后最多 30,000 词、1 MiB;每词 256 Unicode 码点 | +| 匹配器编译 | 单副本最多 2 个编译任务;节点最多 200,000,缓存估算权重最多 128 MiB | +| 外部事件 / JSON 深度 / 命中摘要 | 每事件 64 KiB,深度 16;至多 20 个 rule_ref、20个按方向分组的命中词值(每词≤256 Unicode字符);hit_count超限截断并标记 | +| Outbox 领取 / 租约 / 单次回传超时 | 每批 100,租约 30 秒,回传 3 秒;重试退避 1 秒至 5 分钟,带抖动 | +| SQL 接收与摘要事务 / 汇总批次 | 接收目标预算 2 秒;每次最多 200 个 dirty 请求,使用持久租约 | + +64 MiB 是编码缓冲额度,不是 Java 堆占用承诺。解析树、字符串和匹配器有放大;压测须记录实际堆峰值、GC 和基线余量,部署启用前据目标 JVM 堆设置调低额度。不得用扩大 Netty 全局缓存或无限 Reactor 队列规避限制。 + +### 2.3 证据边界 + +仓库内置词表静态样本为 3,592 个去重词、44,592 字节,最长 16 码点,符合上述词表初值;部署自定义词库仍须独立校验。历史设计评审时仅完成基础设施编译、单元/组件与结构检查,尚无模型请求/真实迁移/压测或浏览器E2E;当前已执行真实MySQL迁移、109模型协议/审计、Platform与Desktop子集,详见implementation-verification与deployment-execution。LinuxDM8、真实容量/多副本与完整故障验收仍未执行。profile 成功路径约 8 次 SELECT 是静态路径计数,不含冷配置开销,也不是实测时延。 + +### 2.4 本轮用户评审取舍 + +用户于 2026-10-23 明确:审计分页沿用既有方式,不纳入固定默认筛选窗口;身份以毕昇成功验证结果为准,接受其可信变化,不新增跨事件身份冻结、独立请求绑定表或针对该变化的冲突拒绝。两项复审意见撤回,不再作为本期设计或验收门槛。 + +该取舍已同步 spec AC-38、AC-42、AC-49。服务来源认证、来源允许租户范围、查询权限、同事件异载荷冲突及已持久化事件的幂等重试继续按既有契约执行;不依据用户当前状态重写历史事件或模型执行、用量事实。 + +## 3. 方案对比与选定 + +### 3.1 检查执行位置 + +- **备选**:backend 调 Gateway 检查 RPC;只包 LangChain;Gateway 覆盖两个 HTTP 模型入口。 +- **选定**:Gateway 执行;backend 审计接收通用 ContentSafety 事实。 +- **原因**:F110 已解除网关依赖;原生 Responses 不经过 Chat 的 LangChain adapter,只包模型封装会漏检。现有 Gateway 具有词库、解析与 ACPlus,复用它们可保持既有匹配语义。 +- **重新考虑条件**:出现可绕过 Gateway 的新公开模型入口,或需要在非 HTTP 模型消费路径执行同一策略;先补入口和需求,不能偷偷在 backend 恢复远程检查。 + +### 3.2 租户定位和管理授权 + +- **备选**:信任客户端租户;Gateway 解码 Token 后自行认定身份;每次实时 profile;短期复用成功 profile。 +- **选定**:短期复用 profile,实际模型准入仍交给 backend;管理策略单独使用浏览器身份实时校验。 +- **原因**:客户端租户不可信;profile 不表达租户管理权。静态约 8 次读取使逐模型请求新增调用有可观成本,短期复用减少的是额外 profile 开销,不减少 backend 原有模型鉴权。 +- **重新考虑条件**:要求输入提前阻断前也必须实时识别注销,或负载测量显示 30 秒内低复用使缓存收益很小。 + +### 3.3 事实保存和查询 + +- **备选**:只给主表写布尔值;每次列表 JOIN 全量事件;独立事件事实加主表摘要。 +- **选定**:不可变事件独立保存,主表和会话、目录持有查询投影。 +- **原因**:布尔值不能证明方向、策略和乱序处理;原审计已经按 started_at、request_id 游标翻页,主表可直接承接筛选。每请求重复事件不应放大列表读成本或计数。 +- **重新考虑条件**:需要按任意命中词跨年检索或复杂统计;根据真实数据再评估归档和搜索索引,不先引入新检索服务。 + +### 3.4 回传可靠性 + +- **备选**:用户 Token 回调;内存异步发送;网关 SQL Outbox 加服务签名。 +- **选定**:复用 Gateway 与 backend 现有共享 HMAC 密钥配置和签名规则,配合持久 Outbox、幂等接收和提交后 ACK。 +- **原因**:审计回传由既有 Gateway 服务互信承担,不依赖用户仍在线;复用已有配置可避免重复维护一套密钥。旧内存审计队列的成功入队不代表持久化,spec 明确区分发送端未确认恢复与接收端确认后恢复。 +- **重新考虑条件**:Outbox 写入压测成为瓶颈且有可复用的可靠消息平台;仍须保持相同幂等、保留和租户绑定合同。 + +### 3.5 流式交付方式 + +- **备选**:逐 SSE 片段匹配;整轮缓冲所有输出;按文本域扫描并有序保留原事件。 +- **选定**:保持原事件顺序的延迟交付,文本域保留最长词长减一的尾部,工具参数完整检查。 +- **原因**:逐事件会漏跨片段命中;整轮缓冲会把所有回复变成非流式。Responses 的 sequence_number 不允许为切分文本任意重编号。 +- **重新考虑条件**:产品改为命中后整轮不可见,或多文本域交错造成的队头阻塞在实测中不可接受;不能为了延迟指标提前放出未确认文本。 + +### 3.6 外部条目与保留 + +- **备选**:伪造 RUNNING/零用量条目;只保存事件而不让输入阻断进入审计;明确外部条目和不适用状态。 +- **选定**:外部条目有独立来源,模型相关未知字段可空;事实明细与主审计分别保留,默认不自动删除明细。 +- **原因**:当前 DTO 仅支持 METADATA/CONTENT 和 NORMAL/ABNORMAL,必须显式扩展;spec 未批准独立强制 30 天删除期,不能把容量评估值直接变成清理规则。 +- **重新考虑条件**:统一审计保留期和归档策略另获确认;归档可回放能力成立后再缩短在线明细窗口。 + +### 3.7 上游代理所有权 + +- **备选**:在默认 Netty 响应装饰器闭流后启动游离订阅;专用 DSH 路由拥有一次上游请求及完整读取生命周期。 +- **选定**:前置检查加专用 DSH 路由,保持公共前序过滤和限流,受控持有上游。 +- **原因**:默认代理的连接释放与响应链完成相关,不能把“客户端已结束”误当“上游可释放”;重新订阅还可能重复发起有费用的模型调用。 +- **重新考虑条件**:现有代理提供经过验证的连接所有权和独立续读接口;必须以取消、断连和零重复调用测试证明再简化。 + +## 4. 系统现状与拟接入结构 + +### 4.1 已核实基线 + +| 仓库 | 核查基线 | 已有行为 | +|---|---|---| +| BiSheng 本工作树 | 2e6b5ae0fb1dcd2e32d480545e1d1ea57e77cd24 | F110 本地接入、两个 DSH 模型协议、异步审计及会话树 | +| bisheng-gateway | feat/bisheng-work-dsh-guardrails / main 基线 2995df6fdf9c51b70617a86a803aec9c6633b69e | DSH 认证与席位逻辑已移除;应用敏感词仍为旧资源策略 | +| bisheng-work Desktop | feat/bisheng-work-dsh-guardrails / main 基线 01e09d027ba729f6021ec37c33937f5745cee992 | Chat 识别 content_filter;Responses 把错误事件统一归为 UPSTREAM_ERROR | + +当前 backend 入口在 models.py:desktop_principal → read_model_request → DshModelService.complete → Chat 或原生 Responses adapter。DshRoute 按通用 HTTP 约定复用入站唯一规范、非零 UUID(统一转小写);缺失、非法、零 UUID 或多值时创建编号,并在响应头返回 X-Request-ID;AuditObservedResponse 只观察 backend 到直接下游的 ASGI 发送事实。 + +审计主行由 DshModelAuditWriter 异步创建;upsert_request 对身份字段要求完全相等,mode 仅支持 METADATA/CONTENT。refresh_audit_status 主要依赖模型审计状态变化触发 group dirty,安全标志变化尚无入口。新增能力均未接线。 + +### 4.2 拟新增数据流 + +~~~mermaid +sequenceDiagram + participant C as Desktop + participant G as Gateway + participant B as Backend 模型 + participant O as Gateway Outbox + participant A as Backend 审计 + C->>G: 原始 DSH 凭据和模型请求 + G->>B: 缓存未命中时查询 profile + B-->>G: 可信用户和租户 + Note over G: 固定租户策略快照,检查输入 + alt 输入命中 + G->>O: 提交命中事实 + G-->>C: 内容阻断 + else 输入可转发 + G->>B: 原始凭据、原始请求和生成的 X-Request-ID + B-->>G: X-Request-ID 和模型响应 + Note over G: 检查后按序交付,命中则停止交付 + G-->>C: 安全内容或明确终止 + G->>O: 提交终态或立即命中事实 + end + O->>A: 服务签名,持久重试 + A->>A: 验证绑定,事件及摘要提交 + A-->>O: 提交确认 + Note over A: 会话和目录持久 dirty,异步汇总 +~~~ + +### 4.3 模块职责与改动范围 + +表中“新增”是拟建文件,不是现有实现。Gateway 路径基于 com/dataelem/gateway,backend 路径基于 src/backend/bisheng。 + +| 范围 / 模块 | 做什么 | 不做什么 | +|---|---|---| +| Gateway 新增 filter/DshContentSafetyFilter | 精确匹配两个模型路由,组织缓存、快照、检查和终止 | 不处理登录、席位或 backend 模型授权 | +| Gateway 新增 filter/DshModelRoutingFilter、client/DshModelProxyClient | 单次上游请求、连接与订阅生命周期、原头和流式转发 | 不再次调用默认 NettyRoutingFilter,不执行自动模型重试 | +| Gateway 新增 client/DshProfileClient、service/DshIdentityCache | 独立无凭据日志的 HTTP、缓存和请求合并 | 不用通用 WebClient DEBUG 头日志,不保存原始 Token | +| Gateway 新增 service/DshSensitivePolicyService、DshSensitiveScanner | 租户策略、编译快照、结构化文本扫描 | 不调用有原文日志和 BlockRecord 副作用的 filterNormal | +| Gateway 新增 protocol/DshChatSafetyAdapter、DshResponsesSafetyAdapter | 协议域、事件顺序、工具参数和终止事件 | 不改写普通模型正文,不重编号 Responses 事件 | +| Gateway 新增 service/DshSafetyOutboxService、client/ContentSafetyEventClient | SQL 事务、租约、签名发送和重试 | 不把内存队列视为已可靠记录 | +| backend 新增 dsh/api/endpoints/content_safety_events.py、domain/services/content_safety_ingest.py | 外部事实认证、绑定和接收编排 | Endpoint 不写 ORM,不主动连接 Gateway | +| backend 新增 dsh/domain/models/content_safety_event.py、repositories/content_safety.py | 事件事实、短事务、幂等、摘要和保留检查 | 不修改 dsh_model_call 或额度 | +| backend 既有 model_audit_write、model_audit_writer、model_audit_status | 合并外部条目、保留安全字段、识别不适用 | 不在 model.complete 中增加安全检查 | +| backend 既有 model_audit_queries、conversations、tree_build、management | 过滤、DTO、readiness、dirty 和命中汇总 | 不逐行查询事件,不全历史聚合事件 | +| 平台既有 DshPage、SystemPage/dsh、controllers/API、types | 策略表单、审计标签、过滤、详情和三语文案 | store 不请求 HTTP,不修改组件视觉规范 | +| Desktop enterprise/openai.js、responses.js、error-copy.js | 区分阻断和不可用,终止轮次及待执行工具 | 不执行客户端词库检查、不自动重试 | +| 部署与 worker | 入口封闭、凭据、DDL、汇总消费者、回填及监控 | 不在本轮执行迁移或部署 | + +backend 新接收链为 Router → Endpoint → ContentSafetyIngestService → ContentSafetyRepository → SQL。签名校验是专用入站适配,复用现有 HMAC 算法、共享密钥和签名头配置;拟将无业务副作用的签名计算与比较逻辑提取为通用基础设施函数,由 SSO 和 DSH 各自适配器调用,不跨 API 引用 SSO 模块,也不复用其租户 bypass 或正文回放副作用。现有模型审计公共行操作通过同一仓储辅助逻辑保持一致。 + +### 4.4 profile 缓存与并发 + +本文 profile 指既有 GET /api/v1/dsh/profile:Gateway 携带原始 DSH Bearer Token,由 backend 验证签名、有效期、用户及租户有效状态和本地会话,再读取当前用户、租户资料。返回 user.id、user.username、user.display_name 及 tenant.id、tenant.name;Gateway 以 tenant.id 选择敏感词策略,以 user.id 记录审计身份。它是 backend 成功验证后的资料,不是 Gateway 直接解码 JWT payload 的结果;该接口不返回 session_id,也不授予模型或租户管理权限。下述缓存复用的是这一验证结果。 + +1. 键为 backend 部署命名空间加 SHA-256 完整原始 Token。命名空间随 backend 目标及信任配置变更而变化,不能只按 user_id 复用。值只含已验证用户、租户、验证时间和绝对截止时间,可选会话标识为空时保留未知。 +2. 查 L1、Redis、profile。只有 HTTP 和 DSH 业务语义均成功、字段合法的 profile 才写正缓存。成功后读取同一 Token 的 exp 仅用于缩短寿命,不用于认定用户和租户;无法解码时不缓存该成功结果。 +3. 以发起验证时的单调时钟设截止时间,存 Redis 时仅写剩余 TTL。Redis 读取原子获取值和 PTTL,L1 截止时间从该次查缓存的开始时刻计算并扣除耗时,不能从响应收到后重新给 30 秒。跨实例墙钟只辅助验证;NTP 偏差不超过 1 秒是部署前提,启用前由部署现有校时监控核验,5 秒到期余量另行扣减;校时异常的副本不接新检查请求。 +4. 同副本按完整键维护有截止时间的共享验证;不同等待者共享结果,取消一个等待者不取消其他等待者。多副本使用短租约协调,只有当前 owner 可发布结果、释放租约,失去租约的旧查询不能覆盖新结果。共享 Redis 不可用时退回本机合并及并发预算,不承诺故障时全局严格一次。已过绝对期限的任务不得被新等待者复用;先取消持有的源再归还并发槽位。发布正缓存、删除无效缓存及释放租约均须owner校验,旧查询迟到401不能删除或负缓存覆盖新身份。 +5. 1 秒预算包含等锁、连接、请求和解析;超时或队列满返回 check_unavailable。局部缓存可选写失败需要指标,不能延长旧值寿命。不得因缓存失败重试模型调用。 +6. backend 明确身份失效时清正缓存;模型无权限或额度不足不清。明确无效凭据可负缓存 1 秒,基础设施异常不得负缓存。原始 Token 只在本次认证和转发需要的内存期间存在,不进入缓存、Outbox 或日志。 + +可信身份尚未取得时无法安全分配租户审计条目:保持明确鉴权错误或检查不可用,只记录无凭据的 Gateway 运维指标,不编造 tenant_id=0 的安全事件。已缓存身份的输入阻断可能先于注销提示,仍遵守 spec 的接受边界。 + +### 4.5 租户策略与管理授权 + +Gateway 新表 gt_dsh_sensitive_policy:tenant_id 唯一、version、enabled、input_enabled、output_enabled、words_types、custom_words、auto_reply、updated_by、updated_at。词库摘要来自发布包,策略保存为 builtin_digest;运行时快照键为租户、版本、内置摘要三元组。实例加载文件的摘要必须匹配策略预期摘要,不匹配时拒绝检查,不能用本机另一版本词库替代。 + +默认缺失记录等于关闭、版本 0;读取错误等于不可用。启用任何方向前必须有有效词和非空提示,并通过 §2.2 限制。复用 FileUtil 的分词语义、内置词加载和 ACPlus 字面匹配;去重,不新增忽略大小写、同音词或 Unicode 归一化规则。以发布健康检查核对所有副本的 builtin_digest,不允许相同快照静默对应不同词表。内置文件升级需要协调副本、策略预期摘要和生效版本;本期不承诺跨内置词库版本滚动升级不停服,无法满足预期摘要的副本明确不可用。普通自定义词修改仍遵循 5 秒策略更新规则。 + +DSH 使用局部 ACPlus 适配器构建完整的 BFS 失配链,修正既有构建器跳过多层后缀的漏检;命中结果在扫描收集时即限制为有界摘要并标记截断,不能先生成无界全命中列表再 limit。复用既有 addWords/indexOf 字面语义,不改全局旧应用过滤器。候选词库校验单独编译,不以相同租户/预期版本覆盖已提交匹配器缓存。 + +保存执行 version 条件更新,首次建立只允许 expected_version=0。事务提交后更新共享缓存并广播失效;广播丢失由 5 秒绝对寿命收敛。旧读取不能覆盖新版本,读取和写缓存耗时不能延长旧快照寿命。编译在有界执行池,失败不发布“启用但空词库”的快照;在途请求保持已取得的快照引用。 + +管理请求每次实时访问既有 backend 接口,不缓存管理授权: + +1. GET /api/v1/user/info 获取当前用户和 leaf_tenant_id,角色标志只作辅助识别,不能单独授予保存权限。 +2. 所有操作者均调用 GET /api/v1/dsh/admin/model-audit/settings?tenant_id={target},由其现有 get_tenant_admin_user 和 authorize_admin 完成自然人、租户及有效管理范围校验;只读取结果,不改审计开关。即使 mode=OFF 或原文密钥不可用,只要该读接口成功且返回合法 DTO,就不额外要求启用原文审计。 +3. 非超管必须 target 等于当前 leaf,并通过 GET /api/v1/user/tenants 在当前用户的 active 列表中确认目标存在且有效。超管明确选 target,并用 GET /api/v1/tenants/{target} 检查目标存在和状态;其可管理范围仍由第 2 步决定,不能用角色标志覆盖拒绝。 + +所有调用原样携带本次浏览器认证和既有作用域上下文,不借用 DSH profile 缓存。Gateway 状态变更接口还须校验 Origin 属于部署允许的管理站点,并沿用既有浏览器写操作防护;不能因为它向 backend 发的是 GET 就允许跨站写入。管理PUT先校验Origin和实时权限,成功后才读取原始正文;聚合上限8MiB以容纳1MiB词库最坏六倍JSON转义,JSON深度16并拒绝重复键、尾随值及非法UTF8。超限413,解码后词库仍限1MiB,成功/错误/超限/取消均释放缓冲。HTTP 200 中的业务失败、缺少字段、权限服务不可用均拒绝。GET /tenants/{id} 的现有 get_admin_user 仅允许平台管理员,不能把它作为 Child Admin 的统一探针。本设计复用这些通用读取接口,backend 不新增 Gateway 管理 RPC;管理动作低频,新增校验开销单独观测。 + +部署级开关默认关闭;开启后两个入口始终执行该控制链。商业功能失效不能使已开启检查静默变成透传:返回 check_unavailable 并告警。该判断不恢复 backend License 或席位准入;停用检查按 spec 作用于新请求,Outbox 投递独立继续。 + +T064 实施约定:SharedCache 发布传入读取起点建立的固定单调截止时间,不传可能已经过期的相对 TTL。专用 StringRedisTemplate 复用现有 connectionFactory,避免全局 JSON 序列化给 Lua 参数加引号;通过 Redis TIME 映射固定服务端截止,最终 PUT 再按该截止扣余量,排队、网络或取消后的迟到写入都不能续期。策略保存先按 expected_version+1 编译真实规范化快照,CAS 成功后直接采用它;缓存发布与广播限时 best effort,失败记录诊断但返回已提交版本。原始词库1MiB及Unicode校验在规范化和关闭分支之前执行,超限413;缓存权重同时计入树、去重词及 PolicySnapshot 持有的词库/回复元数据。 + +### 4.6 结构化扫描、有序交付与续读 + +输入以有界 UTF-8 JSON 解析,检查消息文本、可读 reasoning、工具说明、工具参数及结果;参数再次按 JSON 解码检查其中的业务字符串。工具参数对象键和字符串值分别检查,不把协议外壳、图片编码、凭据或审计上下文拼进去。同一消息连续文本块同属一个逻辑域,不同消息、choices、工具调用及独立文本域分别处理。未知或不可安全解析的可交付结构返回错误,不直接当安全透传。工具schema须区分属性名映射、结构关键字与default/const/enum/examples业务值,不能因为业务属性恰名properties而切换解析语义;同一reasoning item的连续content或summary文本各自组成域,两类及不同item仍独立。 + +两个精确 POST 路径增加优先于 catch-all 的独立 DSH route,URI 与 profile 的固定 backend 配置保持一致,不挂旧路由的 CacheRequestBody(String) 过滤器;否则正文可能先按 200 MB 全局上限缓存并产生未计入预算的副本。源配置与 docker/gateway/config/application.yml 都要同步路由定义,DM profile 继承该定义。旧应用敏感词、响应过滤和 PathRate 的应用正文分支跳过 DSH 托管标志,避免误扫和打印 DSH 原文;公共 WebFilter 及适用的通用限流仍保留,新 DSH 的并发由独立预算控制。部署级检查开启后,模型入口的上游连接交给专用 DshModelProxyClient 管理。前置检查过滤器只准备可信上下文;DshModelRoutingFilter 在现有 PathRateGlobalFilter(LOWEST_PRECEDENCE-90)之后、默认 NettyRoutingFilter 之前取得路由所有权,标记 already-routed,不再调用默认模型路由。其他公共 WebFilter 和前序限流仍执行。部署开关关闭才使用原有代理链。 + +每个请求只创建一次上游 HTTP 请求和一次响应订阅,沿用部署现有连接/模型读取超时,不开启重试和自动重定向。组装时复用现有 spring.cloud.gateway.httpclient.response-timeout 的正有限配置值作为模型Proxy与Manager同一I/O预算;仓库默认未设置该值,不能用connect-timeout冒充读取预算或自行引入45/60秒fallback。检测enabled=true但该既有预算缺失/非正时明确拒绝开启组装,默认检测关闭仍保持原启动,且完整回传配置关闭检查时管理和历史投递独立运行。客户端交付和后端读取有独立的完成信号;请求管理器持有唯一上游订阅,向客户端的有界交付通道取消不直接取消该订阅。只有内容阻断触发时才转后台续读,普通用户取消仍关闭上游。客户端收到阻断闭流后,上游仍由请求管理器持有至有界续读结束,且不写入默认代理会自动释放的 CLIENT_RESPONSE_CONN_ATTR。不得对响应 Flux 再 subscribe 一次,不得让默认 Netty 响应清理在续读结束前 dispose 连接。HTTP 头按代理规则过滤 hop-by-hop 和失效 Content-Length,保留有效原始 Authorization、正文及模型请求编号;请求目标仅来自固定 backend 配置。原始请求先完整限长校验再转发,上传空闲及总时限沿用部署代理预算并计入检查占用。 + +输出解码、扫描、交付分别维护进度: + +- 每个逻辑文本域保留最长生效词长 L 减 1 个码点,扫描边界按 Unicode 码点,不按 UTF-16 char 数或 UTF-8 字节数。调用既有 ACPlus 时处理补充平面的索引差异,测试不得只用汉字 BMP 样本。 +- SSE 解码支持 UTF-8 跨字节、多行 data、事件跨网络片段;为每个原事件建立可交付水位。只有该事件承载的全部文本都越过检查水位、依赖的工具参数已完整通过,才从有序队列交付;后面的元数据、usage、done 和终态不能越过它。 +- 保持完整原事件及 Responses sequence_number,不为释放文本前缀切碎、拼造或重编号正常事件。多个域交错可能产生队头阻塞;其占用计入全局缓冲,达到上限则报错终止,不能提前交付未检查文本。该限制需要实际延迟测试。 +- 工具调用按 call_id、choice/item 标识组装,完整解码并检查后才交付可执行参数。Responses 的 item.added、arguments.delta/done、item.done 和最终快照都受同一屏障约束。 +- 完整快照与此前增量按文本域关联。相同快照独立校验,不能作为新的增量重复拼接;新增或变化内容交付前检查。协议绑定冲突报错,不能通过“修复”顺序或丢字段继续。 +- 结束时检查并释放尾部;任何未结束文本或工具域均不能生成正常成功终态;若流以 backend 失败或客户端取消结束,已确认命中优先,否则未完成的适用检查报 ERROR 并记录终止原因,不把被截断的可检查内容报 PASSED。普通无命中响应保持正文、工具语义和事件次序。纯backend错误按已支持的错误外壳校验后保留原bytes;混入choices/output等未分类正文不能走错误透传快捷路径。Responses条目须由output_item.done或最终快照所含条目确认结束,只有arguments.done而条目被最终output遗漏时不能产生成功终态。 + +输入命中不向 backend 发模型请求。非流式响应完整检查后交付。流式命中立即停止客户端内容交付、生成命中事件,并转入有界续读:从命中时刻起独立 30 秒截止;不再解析交付业务内容,只继续消费到 backend 完成以争取结算。无续读额度则立即关闭,时间、剩余字节或上游异常先到先结束。 + +续读任务由请求管理器持有,不能随“已向客户端发送阻断结束”自动取消,也不能无限游离;释放所有 Netty DataBuffer、订阅和配额。Gateway 进程退出不能恢复模型流,backend 按断连处理,已提交命中 Outbox 仍可恢复。续读完成只上报 FINISHED、TIMED_OUT、LIMITED 或 DISCONNECTED 等观察状态,不写 backend 用量或伪造零消耗。 + +### 4.7 持久事件与接收事务 + +按 2026-10-27 用户确认的 AC-60 调整采集边界:Gateway 仅在该请求冻结策略中至少一个输入/输出检查方向实际开启时创建 Recorder。租户策略关闭时仍沿原独立模型转发链路,不写新 Outbox、不生成 FINAL/DISABLED 事件,也不回传请求摘要;backend 按自身审计模式决定是否采集,不查询 Gateway、不增加相互依赖。审计也为 OFF 时没有新的原生审计或外部安全摘要;鉴权、额度和 dsh_model_call 计量不变。旧版 DISABLED 事件和关闭前的积压仍可按既有幂等/保留规则补报,不能因当前关闭而丢弃历史事实。正在执行的请求沿用入站策略快照。 + +Gateway 事件种类为 DECISION、FINAL、DRAIN。普通已检查请求通常一条 FINAL;命中立即 DECISION,有后续续读状态时再写 DRAIN。每条包含输入、输出的完整阶段快照,事件量按模型 HTTP 请求增长,不按 Token 或 SSE 片段增长。 + +每个 Gateway 请求生成 UUID,原始事件 event_id 和 event_version 在成功记录时固定,重试不再生成新事件或版本。gt_dsh_safety_outbox 保存不可变事件及独立投递字段:event_id 主键、source_id、gateway_request_id、payload、payload_hash、status、attempts、next_attempt_at、lease_owner、lease_until、last_error_code、created_at、acked_at。 + +状态为 PENDING → INFLIGHT → ACKED;租约超时回到可领取,永久拒绝进 QUARANTINED 并告警。用条件更新领取和确认,不依赖所有数据库支持 SKIP LOCKED。Gateway 新表分别提供 MySQL 和 DM8 DDL,公共 MyBatis mapper 不使用 MySQL 专有 upsert、JSON 查询或分区语法,租约与去重均验证两种数据库。ACK 丢失重发相同事件,更新签名时间,不更新事实。后台限速与模型 HTTP 生命周期分离。 + +Outbox 写入成功后才称为“已记录”;写失败仍阻断命中,报告不可恢复缺口。检查已通过但仅审计记录失败时保留业务响应、报警并在 backend 显示 UNREPORTED,不重试模型、不伪造已可靠上报。Outbox 不能靠内存兜底声称持久性成立。 + +backend 专用接收端依次执行: + +1. 限定方法、路径、64 KiB 正文及签名时间窗口,验证服务来源;解析严格事件结构后验证 ID、时间、版本及字段界限。 +2. 根据已认证事件设置严格 TenantContext,清理浏览器残留的可见租户、管理范围和 bypass;上下文在 finally 恢复。每次 SQL 执行线程也在 verified_source 指定租户的 profile_scope 内建立严格上下文,不能假定线程池自动继承 ContextVar。调用方沿用共享 HMAC 配置认证为部署内 Gateway 服务;source_id 用于幂等和诊断,不能仅凭自报来源扩大部署已允许的租户范围,不依赖用户 Token 当前有效。 +3. 在 SQL 事务内检查相同事件、载荷摘要和保留水位。相同来源与编号、相同内容返回已提交确认;同编号异载荷或同请求同版本异事实返回 409。不同版本来自毕昇的可信身份变化按事实接收,不作跨事件身份冻结或冲突拒绝。 +4. 保存事件;沿用模型记录自身的归属和既有权限核对可关联性,无法确认时保存未关联事实。已关联或可建立外部条目时锁定主行、归并安全摘要、写持久 dirty。创建竞争遇唯一键冲突时回滚本次短事务并重读,不在失效事务中继续;因可信身份变化无法建立原关联时保留事件并按未关联事实确认,不覆盖其他归属的模型历史。 +5. 事务提交后才 ACK。未关联事件可提交并确认事实,但不猜测模型主行。会话、目录汇总异步执行,ACK 不承诺树已经发布;readiness 和水位须表达延迟。 + +仅新增精确的 POST /api/v1/internal/dsh/content-safety-events 服务认证分支:跳过浏览器 JWT 准入,进入签名验证。不能把整个 internal/dsh 前缀加入 TENANT_CHECK_EXEMPT_PATHS,因为该列表还会开启 tenant bypass。应清空上下文后建立事件租户,未通过签名前不得查询业务表。 + +### 4.8 数据结构、关联和状态 + +backend 新表 dsh_content_safety_event 采用现有 SQLModel 基类和 dialect_helpers: + +| 字段组 | 类型 / 用途 | +|---|---| +| event_id、source_id | UUID 文本 / 有界来源;唯一约束 source_id + event_id;另约束同来源/Gateway 请求/event_version 不重复 | +| tenant_id、user_id、model_id | BigInteger;身份取毕昇验证结果,模型取本次请求或调用事实,租户自动注入 | +| gateway_request_id、backend_request_id、audit_request_id | UUID;后两者可空,区分来源编号、模型编号和审计关联 | +| event_version、schema_version、event_kind、protocol | 有界整数或枚举;版本按同一来源、同一 Gateway 请求单调产生 | +| request_started_at、occurred_at、received_at | UTC 微秒时间;不把回传到达当事件发生 | +| payload_hash、stage_results、policy_version、builtin_digest | 摘要和结构化事实;JSON 用 JsonType,正文缺省且禁止接收 | +| hit_count、rule_refs、matched_words、forwarded、drain_status、association_status | 有限诊断;rule_refs为稳定规则标识,matched_words为可空的冻结词值快照,旧记录NULL表示未保存 | + +每条事件按 Gateway 从毕昇成功取得的身份和本次模型事实归档;可信身份结果变化予以接受,不以首个事件冻结所有后续版本的租户、用户或模型,不增加独立请求绑定表。索引 (tenant_id, source_id, gateway_request_id, event_version, event_id) 用于租户范围内的请求事件查询,不作为跨事件身份冻结机制。历史补报复用原持久化载荷,不按用户当前调租、退出状态重新生成或拒绝历史事实。 + +主表 dsh_model_audit_request 增加: + +| 字段 | 作用与所有者 | +|---|---| +| record_origin MODEL / EXTERNAL_SAFETY | 原审计或外部条目;仅原生审计可将外部条目升级为 MODEL | +| model_entered、model_started_at | 可空;输入阻断明确 false,其余只由 backend 调用或原审计事实确认 | +| safety_source_id、safety_gateway_request_id | 单次检查关联;来源与 Gateway 请求组合唯一,避免同请求重复建行 | +| input_safety_state、output_safety_state、各阶段 version | 最新阶段状态;每阶段按来源内版本归并 | +| has_sensitive_hit、input_sensitive_hit、output_sensitive_hit | 已知命中,单调累积 | +| content_safety_state、content_safety_updated_at、policy_version、builtin_digest | 列表摘要;状态包含 UNREPORTED、PASSED、BLOCKED、ERROR、DISABLED、NOT_APPLICABLE | +| safety_projection_version、group_indexed_sensitive_hit | 并发水位和已计入会话的命中状态;禁止按事件条数累加 | + +输入阻断用 gateway_request_id 建外部审计条目,model_entered=false,不生成 dsh_model_call。Gateway 转发前将该 UUID 作为 X-Request-ID 覆盖客户端同名头并透传,backend 复用;backend_request_id 在发起转发前即设为该编号,不依赖响应头。转发异常时依既有投影建立 EXTERNAL_SAFETY 摘要:model_entered=NULL,不生成模型调用或用量事实。有 conversation_id 的摘要进入会话记录,无 conversation_id 的摘要进入未关联请求。事件明细仍单独持久化在 dsh_content_safety_event,请求摘要在 dsh_model_audit_request。历史无编号、可信身份不匹配或唯一约束冲突时,事件继续保留在数据库供debug,不在界面提供“未关联检查”;不按用户、时间或模型猜关联。 + +外部条目的 effective_mode、model_result、response_result、usage_settlement 和 session_id 等未确认字段允许 NULL;content_status=NOT_CAPTURED,audit_status=NOT_APPLICABLE,不计入模型 NORMAL/ABNORMAL。状态派生先按 record_origin 分支,停滞扫描仅扫描真实模型条目;外部条目不要求模型终态或原文 manifest,不参与原文密钥就绪门槛。安全事件接收后在认证租户scope内,复用LLMService.get_dsh_model_metadata_snapshot(含Root共享与离线模型元数据规则)取得服务商/模型名称,并随EXTERNAL_SAFETY摘要及其名称摘要保存。标签读取最多200ms,包含在既有2秒回传预算中,不实例化模型、不调用Gateway;失败或模型删除则以空名称及其摘要占位、界面回退模型编号,检查事实仍须可靠入库。后到或重发事件可补齐空名称,已保存的非空名称快照不随当前配置重命名;原生MODEL名称始终优先,原生审计后到仍按原提升规则补齐。保持未知名称不阻塞租户readiness。仅开启敏感词检查时也可通过现有标题接口补齐本人、本租户保留摘要的标题;以已接收检查事实为绑定依据,不依赖Gateway实时开关,详见审计设计§4.7.3。正文模式仍为OFF,无body/manifest/对象存储写入;标题和名称不证明模型执行成功或实际用量。输入阻断展示“不适用”,缺少原生审计的输出条目展示“未观测”;usage 缺失为 null,不用数值 0 替代。 + +仅有外部事件不证明模型执行;接收端可读取相同 tenant/user/model/request_id 的 dsh_model_call 或原生证据确认 model_entered,但不能由 forwarded 或收到响应头推断执行成功。查到调用用量可按既有补数逻辑展示,模型审计模式和 ASGI 交付结果仍不能编造。 + +NULL 表示未知的可选关联可由真实原审计补齐;安全事件采用毕昇可信结果并接受变化,模型审计仍保留其自身的执行和用量归属。无法确认关联时保存未关联事实,不以跨事件身份变化拒收,也不覆盖其他归属的模型历史。外部会话提示需复用既有 AuditContext 的字段校验:conversation_id、parent_conversation_id 为合法 UTF-8 的 1–256 字符;request_kind 为 chat/compaction/session-title;session_origin 为 main/subagent,父会话须有子会话、来源为 subagent 且不能指向自身。Gateway 仅提取这些可选字段,backend 接收时再次清洗;非法提示只影响关联,不阻断模型或关闭检查,不把原始 audit_context 或 message_sources 复制进事件。ConversationHint 的供应商亲和默认值不能代替 AuditContext 的审计绑定值,特别是缺失 request_kind 不能私自补 chat。未确认字段与已确认的空值须在合并规则中区分,原生审计提升来源时确认模型事实和关联。 + +主表 started_at 首次建立后固定;安全先到时使用经校验的 request_started_at,原审计后到将实际起点放 model_started_at。request_started_at 和 occurred_at 不得晚于签名保护的 sent_at 加 300 秒,发生时间不得早于请求开始超过 5 秒;历史重试不改这些时间且仍受保留水位约束,不用回调时间移动游标位置。 + +主行旧 mode CHECK 替换为明确的来源约束:MODEL 必须有非空 METADATA/CONTENT;EXTERNAL_SAFETY 的 effective_mode 为空。原 writer 在同一事务提升来源并补齐 mode,不留下半提升状态。旧字段安全默认值为 MODEL、无已知命中、UNREPORTED;原生 writer 不写安全字段,防止后写重置。T026仅对EXTERNAL_SAFETY的未知NULL补原生事实,已确认非空session/会话提示冲突仍拒绝,既有MODEL身份比较保持严格;model_started_at首次固定,安全started_at不移动。未收到原生对应终态的轴先保持RUNNING/PENDING,不将未知用量写成零。 + +请求汇总严格采用 spec AC-34 的六级顺序:命中 → 无命中时检查失败 → 缺少完整终态 → 两方向明确关闭 → 至少一个通过且其他终态合法 → 无实际适用检查的不适用。按阶段版本处理乱序,但旧版本命中仍须累积,不能因为整体 event_version 较旧而丢弃命中。UNREPORTED 可补为已知终态;已关闭、不适用和已完成阶段不得被后续 DRAIN 快照重新打开。相同阶段版本不同事实或不合法终态变更返回冲突,不能把较新的不完整快照覆盖已确定事实。 + +### 4.9 查询、会话投影和保留 + +主列表只读审计主表摘要。必备主表索引为 (tenant_id, has_sensitive_hit, started_at, request_id);方向和状态高频组合另验证 (tenant_id, content_safety_state, started_at, request_id),用户/模型组合经执行计划再增加索引,不单独索引布尔值。 + +事件索引包括唯一 (source_id,event_id)、详情 (tenant_id,audit_request_id,occurred_at,id)、未关联列表 (tenant_id,association_status,request_started_at,id)、清理 (tenant_id,received_at,id) 及上述请求事件查询索引。事件详情分页,不在默认列表 JOIN、GROUP BY 历史事件或逐行读事件。 + +请求按 (started_at,request_id) 升序 keyset 取 limit+1,使用时间大于或时间相等且 ID 大于的可移植条件,不使用方言元组比较或 OFFSET。游标沿用既有实现,绑定当前租户和用户显式提供的用户、模型、时间及安全过滤条件;更换显式条件拒绝旧游标。默认最近 7 天,单次窗口最多 31 天,超限拒绝,不执行全历史 COUNT。本期不要求默认相对窗口跨页固定;默认范围随时间推进不单独判作用户更改筛选条件,不增加服务端查询会话、新版冻结窗口游标或额外验收门槛。 主请求Repository.page新增可选now端口,传now时在仓储内计算默认上下界,指纹仅绑定规范化的显式filters;Service不得把隐式上下界写成显式筛选或解析重签游标。未传now的既有内部查询保持原语义。 + +所有批量摘要更新、投影领取、回填和清理的 UPDATE/DELETE 显式限定当前 tenant_id、目标编号及版本或租约条件;不能把 SELECT 自动租户过滤误认为覆盖了写语句。事件与主摘要同事务更新持久 group_index_pending;即使 audit_status 不变也置 dirty。没有可用 conversation_key 的条目先标记“未关联已处理”,不永久保留 group dirty;原审计后到补齐关联时再次置 dirty。会话增加 sensitive_hit_count/has_sensitive_hit,目录节点增加 own/branch 命中计数;按每请求前次已投影状态计算差量,重复和先后到达不重复计数。来源升级时审计状态计数可变,但条目数不再加一;模型调用和用量始终取 dsh_model_call。 + +汇总消费者进入明确租户范围,有限批次和 SQL 租约;利用既有会话 dirty 与目录代际发布,合并短期更新。拟新增 Celery 默认队列投影任务,Beat 按持久 dirty 记录枚举租户和调度,包含有历史补报的停用租户,不能只枚举 active 租户;跨租户枚举仅取得待处理租户编号,具体处理仍回到严格租户范围;每个执行进程使用同一服务和严格租户范围,不依赖 API 进程本地内存。worker 未运行时主列表仍准确,目录 readiness 显示 PREPARING,不伪装完整无命中。 + +简单命中目录筛选走汇总索引;组合用户、模型、时间沿用有界候选查询。达到预算返回 scan_limited 和恢复/刷新提示。会话按请求开始窗口入选,不按后到安全事件的接收时间替换时间口径。组合条件必须由同一条请求同时满足:例如同一会话上月命中、昨天未命中,最近 7 天的命中过滤不能仅凭全会话 has_sensitive_hit 返回它。有时间条件时,汇总索引只产生候选,仍按有界请求检查完整条件;不能把“某请求满足时间”和“另一个请求命中”相交。会话全量计数与筛选命中数分别标注,后者只有实际计算后才显示。 + +保留规则: + +- 明细自动清理默认关闭;30 天仅用于容量评估。启用保留需配置经确认的窗口及可回放要求,事件摘要保留到对应主审计删除。明细删除后 DTO 显示 details_status=PURGED,不回退为 UNREPORTED。 +- Gateway 已确认 Outbox 副本可按运维保留窗口清理,初值 7 天;未确认事件不按普通 TTL 直接删除。永久拒绝、超过明确补报范围或磁盘高水位须告警,不记为 ACKED。 +- 主审计按时间保留清理前,持久保存每租户 audit_deleted_before 水位;接收和原审计后写均拒绝水位之前、已不存在主行的建行;仍存在的有效主行可以继续接收后到事实。清理先排除仍在执行的记录,不能用水位屏蔽尚在保留的有效请求。水位与清理进度拟保存在审计 setting 的扩展字段,不与 mode/version 更新互相覆盖。默认水位为空,不删除历史。 +- 离散删除须在同一审计维护契约中先保存请求 tombstone;本期不提供绕过水位或 tombstone 的删除入口。后续实现若新增这类入口,必须一并交付 tombstone,不能只执行裸 DELETE。 +- 清理租户内按时间及 ID 有限批次进行。回放仅覆盖仍有事实或归档的窗口;已经清理的明细不承诺可任意重建。接收过期补报返回 410,保留最小拒绝诊断,不复活记录。 + +### 4.10 前端和 Desktop + +AC-61 的采集说明放在 ModelAuditMode 状态栏下方,始终占满既有网格一行,采用相邻说明文字样式并由 aria-describedby 关联状态栏。无需查询 Gateway 当前策略即可解释两个开关的组合,对只读用户同样展示。三语新增 collectionHint;原 modeHint 删除“关闭后不再记录新的模型请求”的绝对表述,避免与开启敏感词时的安全摘要冲突。不新增样式、组件、状态库或 API。 + +平台沿 DshPage 当前入口增加“敏感词控制”配置区域,沿用现有租户选择。配置走 Gateway;审计请求和请求内检查详情走 backend 的既有审计权限边界。Gateway 不可达时配置区域明确不可用,不影响无 Gateway 部署的模型、额度和审计功能。 + +词库、输入/输出方向、提示等作为一次原子保存,使用 Checkbox 和保存按钮;不把“拨动后仍需保存”的行为伪装成 Switch 即时设置。运行开关若使用 Switch 则独立立即提交,等待锁定、失败回弹,并基于已保存的有效词库校验。沿用现有表单/审计布局,不增加新的组件样式。 + +依据当前 UI 规范,状态使用 Tag,计数才使用 Badge。已落地组件优先;无已落地组件时沿用当前页面实现。过滤切换清游标和缓存,列表显示输入/输出命中及汇总,检查详情仅展示命中词与检查时间。外部条目隐藏原文入口;N/A、未知、已清理均区别展示。 + +AC-68、AC-69界面收敛:ModelAuditMode移除配置旁的“刷新状态”,保留配置保存失败后的revision自动重读及租户变更取消;ModelAuditPanel移除范围说明入口、状态及面板,保留采集关闭时的安全摘要说明、刷新记录、原文权限和请求信息展开。ModelAuditFilters及查询API不改,模型名称、审计状态、有命中、方向、检查结果、请求编号、会话编号全部保留,仍显式点击查询后应用。ModelAuditSafetySummary移除safety_summary_version的可视展示,保留原统计状态、safety_summary_as_of更新时间以及已就绪命中数量;会话和树形子会话共用该组件。API及后端统计版本仍保留,不改变就绪判定、查询或统计计算。 + +ModelAuditCard仅在汇总与两个阶段均PASSED且has_sensitive_hit/input_sensitive_hit/output_sensitive_hit均不为true时隐藏检查详情,未知字段不补成通过;条件更新后立即卸载详情并由原cleanup中止请求。ContentSafetyRequestDetails按AC-71将同一请求已加载的事件汇总为一个区域,只保留命中词与检查时间,不显示重复阶段状态、来源、UUID、规则编号、策略/词库版本、读取状态及计量提示。输入/输出的词值跨事件和已加载分页求并集,以React纯文本展示;DECISION和后续DRAIN等保留同一快照时只展示一次,后到无命中/异常快照不清除此前命中词。检查时间取已加载的BLOCKED或含词值事件中最早的occurred_at;没有命中事实时取最早检查事件,加载更早页面后更新该时间。若任一旧命中事件缺少词值,在已知词值旁保留无法还原提示;全部无词且无命中时显示“无已记录的命中词”,不等同通过。原始事件仍按事件身份合并在组件状态中,不修改backend/Gateway事件或数据库;明细原有按需加载、分页、刷新替换、失败保留当前数据及精确游标重试、请求/租户切换取消和迟到响应屏障均保留。 + +AC-72请求详情收敛:ModelAuditCard展开区域使用section、独立h3标题和与检查详情一致的分组布局;请求按钮aria-controls关联section,section通过aria-labelledby关联标题。正文仅保留model_result、response_result、usage_settlement、content_status四个业务轴,删除请求/会话UUID、记录来源、重复模型名称、单独model_entered行和model_started_at展示。模型结果有真实值时优先展示;外部摘要无模型结果时,model_entered=false显示未调用模型,其余显示未观测到模型执行结果,其他未取得的轴保持未知或既有不适用语义。失败原因仍经auditFailure白名单归类,保留HTTP状态和异常说明,不显示任意provider诊断。状态说明移至dl外的正文,保持合法语义。复用既有翻译键、组件样式与响应式布局,不新增UI库;卡片头部模型名称/请求时间、原文开关/权限、模型计量、安全详情和查询协议均保留。原始字段继续留在后台及API供排查。 + +AC-73统计就绪展示:ModelAuditSafetySummary在整体或任一所需计数未就绪时直接不渲染,包含数字、准备状态、已知命中摘要和旧时间;请求自身安全命中标签不变。树节点只渲染ready的自身/分支计数和异常汇总,不显示summary_pending/branch_pending/known_abnormal占位;旧式会话列表在状态/名称就绪且indexing_pending=false时才显示计数与汇总。ModelAuditPanel移除索引和关系/统计准备、更新中说明,真实readiness读取失败仍显示固定错误;保留已有会话、请求、刷新、扫描上限与查询错误恢复。无新表或统计模型,未引入“隐藏未就绪会话”的路径。 + +AC-74两方向检查:DshContentSafetySettings移除总开关,draftOf及每次handleChange都以input_enabled||output_enabled计算enabled。仍通过现有命令字段向Gateway原子保存,Gateway无改动;两个方向均关闭时沿原关闭校验,不要求词库/阻断提示,但字节和Unicode上限仍校验。初始快照按两个方向解释,不增加旧总开关独立关闭/保留方向的兼容逻辑,不执行旧数据迁移;CAS、租户取消、读取失败和草稿冲突恢复保持原样。该段替代AC-70关于五个复选框的旧展示约定,当前为四个复选框(两个方向、两个词库来源)及两个文本域。 + +AC-75会话内辅助请求过滤:QueryService.groups仅key非空的会话内分支向Repository.page传内部exclude_auxiliary=true;仓储在WHERE中以request_kind IS NULL OR request_kind NOT IN (session-title,compaction)过滤,再执行原keyset、limit+1及cursor编码,且游标指纹绑定该内部范围。普通page、外层group/tree查询及投影计数完全不改,未关联请求/诊断查询也不启用该参数;用户明确只改变会话内列表,汇总仍按原ALL_INDEXED_REQUESTS口径,不对隐藏辅助请求重新计费或删记录。ModelAuditCard移除用途/来源标签,仅改变展示,不丢弃后台字段。无需schema、索引、Gateway或Desktop改动;未来部署需先更新backend会话内查询再验收前端,当前不部署。 + +AC-70配置页收敛:DshContentSafetySettings保留全部五个复选框和两个文本域、原子保存及只读权限;移除description/scope_hint、version/builtin_digest/effective_word_count诊断区和custom/effective词库大小说明。自定义词说明改为“每行一个词,按字面匹配,区分大小写”,仅说明既有支持的录入方式,不修改分隔解析;阻断提示保留安全文案约束,5秒生效说明合入保存成功反馈。正常可用状态不显示刷新;有效租户下,能力读取结束且不可用、策略读取失败、版本冲突或本轮刷新失败时显示原刷新按钮。首次策略读取失败仍不显示空配置;已有草稿的策略刷新失败不再隐藏表单,并由feedback唯一显示加载失败,conflict锁继续禁止保存;成功重读保留本地修改并刷新expected_version,后续仍需用户明确保存。useDshContentSafety、API wrapper、限制/CAS/租户缓存和Gateway不改,技术字段仍参与原验证和保存协议。 + +API wrapper 留在 controllers/API,新取数遵循当前根 AGENTS 约定的 @tanstack/react-query v4,不新增 react-query v3 用法或开展全站迁移。当前分支 platform/package.json 只直接声明 v3;tasks 单列补充 workspace 已锁定的 v4.44.0 直接依赖,并在本特性设置组件内提供局部 QueryClientProvider,不更换全站 v3 Provider。新键补齐 zh-Hans/en/ja;错误文案只写 packages/locales 源文件并走生成流程,触及旧硬编码文件时按项目规则迁出。 + +Desktop 将 content_blocked/content_filter 统一映射为不可重试的 CONTENT_FILTERED,check_unavailable 映射为 CONTENT_CHECK_UNAVAILABLE。Responses 必须在普通事件 sequence_number 校验前识别合法 error 事件;不把阻断变成 UPSTREAM_ERROR。终止时清掉当前轮次的待执行工具,不用错误结束的 block-end 触发执行;只有完整参数通过并且该轮正常结束才进入工具执行。实现阶段必须沿 adapter 的 finish 事件追到实际工具 runner 调用点验证该屏障;若现有 runner 提前执行,应在该消费边界阻止错误轮次的执行,不只修改提示文案。 + +运行配置采用既有配置入口:Gateway 新增 DshSafetyProperties(dsh.content-safety.*),默认 enabled=false,backend 目标复用已配置 bisheng-api-url,另设稳定 backend-namespace 和 source-id。回传直接使用 Gateway 的 bisheng.gateway-hmac-secret 与 backend 的 sso_sync.gateway_hmac_secret(既有环境变量 BS_SSO_SYNC__GATEWAY_HMAC_SECRET),两端值必须一致;签名头沿用双方既有 signature-header / signature_header,默认 X-Signature。不新增 DSH 独立密钥变量或密钥登记表。接收端按需读取既有配置;密钥未配置时该接收端返回不可用,验签失败按认证失败处理,不新增模型 runtime 对 HMAC 配置的初始化前置校验。实际密钥沿用部署安全配置注入,不写入 Git、示例 YAML 或日志;不新增 backend Gateway URL,不在旧 YAML 增加未识别的顶层键。 + +### 4.11 迁移与启用顺序 + +静态迁移图当前单 head 为 work110_dsh_local_sessions;实施时必须重新核对,而不是沿用本次静态结论。既有表列、CHECK、索引变化走该分支 Alembic DDL;新 SQLModel 表由项目发现机制创建,若同批修订依赖则显式声明顺序。任何回填、清理或重建都用独立 dry-run/apply 运维程序,不在 revision 中 SELECT 后 UPDATE。 + +1. backend 先部署表结构、专用接收、DTO 兼容、受控合并和保留闸门。列的 server_default 可提供旧行初值;实际摘要/树回填另跑有进度的操作。 +2. 启动投影 worker,逐租户有限批次重建,验证 readiness 与无跨租户结果;旧安全状态仍 UNREPORTED。 +3. 发布平台和 Desktop 的新状态、错误与工具屏障,再发布 Gateway 检查及投递;部署开关保持关闭。 +4. 对账两种数据库 DDL、双方既有共享 HMAC 配置和时钟,验证服务回传、profile 目标及所有公开入口。入口必须经过 Gateway,backend 模型端口仅受信网络可达。 +5. 先选测试租户启用,再逐租户扩大。停用只影响新请求,在途保持快照、待投递继续发送。数据结构和事实不随回滚删除;回滚旧 DTO 前必须停用新事件并确认外部条目兼容读取。 + +## 5. 已知坑与防护 + +| # | 已核实事实 / 风险 | 不处理的结果 | 设计处理位置 | +|---|---|---|---| +| 1 | Chat 与 Responses 使用不同 adapter | 只包 LangChain 漏检 Responses | §4.6 两协议扫描 | +| 2 | filterNormal 会输出原文并保存 BlockRecord | 新功能引入额外正文泄漏 | §4.3 仅复用词库/ACPlus | +| 3 | 通用 WebClient DEBUG 输出请求头 | DSH Token 进入日志 | §4.4 独立 ProfileClient | +| 4 | profile 请求和模型请求都有 X-Request-ID | 审计关联到身份请求 | 模型使用 Gateway 独立生成并透传的编号;兼容旧 backend 时仅接受模型响应中的有效 UUID,不使用 profile 编号 | +| 5 | 外部事件可能早于异步主行 | 命中丢失、重复主行或关联到其他归属 | §4.7–§4.8 短事务、可信事实接收与受控关联 | +| 6 | 现有审计状态仅 NORMAL/ABNORMAL,mode 不可空 | 外部条目被误判失败或运行中 | §4.8 状态和来源约束 | +| 7 | backend ASGI 完成只代表发给 Gateway | 输出拦截被误写成 backend 交付失败 | §6.3 安全结果独立于原执行结果 | +| 8 | Chat Desktop 要求 content_filter 和 [DONE] 配合闭流 | 只断流会变成 STREAM_CLOSED | §6.3 区分协议闭流和成功终态 | +| 9 | Responses error 在当前 Desktop 统一转上游错误 | 阻断原因丢失,工具或错误展示不准 | §4.10 专用错误映射 | +| 10 | 只在 audit_status 变化时置会话 dirty | 模型成功但新命中时目录不更新 | §4.9 安全字段独立触发 | +| 11 | tenant exempt 列表同时打开 bypass | 服务签名端点错误跨租户查询 | §4.7 精确服务认证与严格租户上下文 | +| 12 | 新表 create_all 不改变旧表列和 CHECK | 仅新增 ORM 后旧库运行失败 | §4.11 DDL 与运维分离 | +| 13 | 已确认 spec 的汇总包含“不适用” | 只照旧方案五状态建 DTO 会丢组合 | §6.4 与 AC-34 一致 | +| 14 | 事件量随请求累积,独立表也有容量代价 | 用索引存在冒充容量已验证 | §7.2 双库真实压测 | +| 15 | 租户详情接口只允许平台管理员,普通租户管理员使用不同授权入口 | 合法 Child Admin 无法管理策略 | §4.5 既有 DSH 管理读接口和本人租户列表 | +| 16 | 默认代理清理与客户端响应生命周期相关 | 后台续读被提前取消,或二次订阅造成重复模型调用 | §4.6 专用上游所有权与单次订阅 | +| 17 | 会话汇总是全会话事实,窗口内命中必须验证同一请求 | 返回只有历史命中、当前窗口无命中的会话 | §4.9 有界候选核验 | +| 18 | 现有 catch-all route 通过 CacheRequestBody(String) 缓存正文,codec 上限为 200 MB | 专用过滤前后出现额外副本,32 MiB/全局预算失效 | §4.6 精确路由与独立缓冲 | +| 19 | 现有 AC.update 仅一层失配回退;真实 acbcca 漏掉词表 acbcc/bcx/cca 中 cca@3 | 多层重叠后缀漏检 | §4.5 DSH 局部 BFS 失配链与独立子串 oracle | +| 20 | 现有 ACPlus 先收集所有重叠匹配;1万字符/256合法前缀词实测耗尽64MiB测试堆 | 小于文本预算的请求也可能因命中数量耗尽堆 | §4.5 收集阶段限制摘要,不生成无界列表;实际 JVM 峰值仍待压测 | +| 21 | 管理入口若使用自动DTO解码,授权前可能按全局200MB聚合正文 | 无权请求也占用大缓冲,词库字段检查过晚 | §4.5 先授权,再8MiB原始聚合与1MiB解码限制 | +| 22 | schema结构关键字、业务属性名和默认业务数据有不同语义;真实探针复现误拦与漏检 | 把合法类型关键字当正文或跳过default业务值 | §4.6 按schema上下文区分,default/const/enum/examples进入业务JSON扫描 | +| 23 | 错误透传快捷分支若只看error字段,会原样交付混载choices/output的未检查正文 | 错误形态绕过检查 | §4.6 仅合法纯错误外壳透传,混载结构明确不可用 | +| 24 | Responses参数done并不证明整个输出条目结束;最终output可遗漏此前added条目 | 未结束工具被误报PASSED并交付completed | §4.6 必须item.done或被最终快照确认结束 | + +| 25 | Responses同消息连续part按content_index独立扫描会漏跨part词,delta/done/final观察又可能互相抹除 | 已支持文本跨块绕检,甚至part.done提前泄漏尾部 | §4.6同逻辑域连续检查,§9保留有界首尾/条目尾屏障和混合观察候选预算 | +| 26 | react-query开发logger会输出保存错误的Axios配置;实际Card未显示model_started_at | 草稿/请求凭据进入开发日志;请求时间与模型执行时间无法区分 | §9局部安全logger及独立模型开始时间详情行 | +| 27 | 现场ConfigService为YAML BaseModel加载器,SSOSyncConf文档的自动env覆盖并未实现 | 容器收到共享HMAC env仍无法验签 | 部署复用既有`!env`构造器显式引用env_file,实际load/readback确认,不将key明文写YAML | +| 28 | 管理visibility复用要求online的模型调用快照 | 离线私有配置被拒,上线再收窄会形成公开窗口 | T197/T198独立治理快照保留tenant/Root校验,调用快照继续要求online | + +## 6. 对外契约与依赖 + +### 6.1 策略管理 API(Gateway 提供) + +基础路径 /api/sensitive/dsh;使用现有浏览器认证转 backend 管理校验,拒绝仅有 DSH Token 的策略管理。 + +| 接口 | 合同 | +|---|---| +| GET /settings?tenant_id= | 返回该目标租户策略、version、builtin_digest、有效词数及限额;缺失记录返回默认关闭、version=0 | +| PUT /settings?tenant_id= | expected_version、enabled、input_enabled、output_enabled、words_types、custom_words、auto_reply;条件保存,返回生效版本和最长 5 秒提示 | +| GET /capabilities?tenant_id= | 部署级能力、配置可用性和公开限制;不能用缺失能力响应替代已经开启策略的检查 | + +成功沿用 Gateway ResultData;业务失败同时检查 status_code,保存版本冲突返回明确 409。无权限 403、格式错误 400、词表超限 413、依赖不可用 503。既有 saveWords/wordsDetail/testWords 不扩展为 DSH 存储或执行接口。 + +### 6.2 外部安全事件 API(backend 提供) + +POST /api/v1/internal/dsh/content-safety-events,仅服务认证,无 query 参数。UTF-8 JSON 包装为 sent_at 和不可变 event;签名头沿用双方既有配置,默认 X-Signature。 + +接收端使用 DSH 的真实 HTTP 状态和错误 envelope;成功直接返回 receipt 对象,不包平台 resp_200。Gateway 必须同时核对 HTTP 200、receipt=COMMITTED 和本次 event_id,不能把任意 2xx 或仅入队结果当确认。正文只限长读取一次,签名验证后从同一 raw bytes 做严格 DTO 解析;不复制旧 SSO dependency 的 request._receive 回放实现。校验错误只返回错误类型和安全字段路径,不回显输入值、签名或正文。 + +签名算法及密钥直接复用既有 Gateway—backend 互信:HMAC-SHA256(secret, upper(METHOD) + LF + PATH + LF + 完整原始正文),签名输出为小写十六进制,比较采用常量时间比较。Gateway 读取 bisheng.gateway-hmac-secret,backend 读取 sso_sync.gateway_hmac_secret,两端配置相同值;签名头读取各自既有配置,默认 X-Signature。路径固定为接收路径,不含查询;时间、来源和事件编号均在签名保护内。发送时间默认允许前后 300 秒,重试重新生成 sent_at 和签名,event 不变。密钥轮换沿用已有共享配置的双方协调更新,不新增独立 key_id 协议或另一套密钥配置。共享密钥认证 Gateway 服务身份,事件按部署已允许的租户范围和严格租户上下文处理;不接受请求指定回传目标。 + +| event 字段 | v1 类型 / 约束 | +|---|---| +| schema_version | 整数 1;未知版本拒绝,不能忽略新增安全阶段 | +| source_id / event_id / gateway_request_id | 来源 1–64 字符;两个 UUID;同一次已记录事件重试不变 | +| backend_request_id | UUID 或 null;只来自本次模型响应头 | +| tenant_id / user_id / model_id | 十进制正整数字符串,范围 int64;避免 JS 精度丢失,backend 验证后转整数 | +| session_id | 可空;非空必须来自已验证上下文,不取未验证 Token 声明 | +| protocol / event_kind / event_version | CHAT 或 RESPONSES;DECISION/FINAL/DRAIN;同一 Gateway 请求从 1 递增 | +| request_started_at / occurred_at | RFC3339 UTC;事件原始时间重试不变 | +| stage_results | input/output 各一个状态;PASSED/BLOCKED/ERROR/DISABLED/NOT_APPLICABLE/UNREPORTED | +| stage_versions | input/output 各一个非负整数;UNREPORTED 对应 0,已观测状态版本为 1 至 event_version;状态改变取当次 event_version,后续快照保持未改变阶段的版本 | +| policy_version / builtin_digest | 版本及 SHA-256 摘要;不回传词库正文 | +| hit_count / hit_count_truncated / rule_refs | 非负整数、截断标志、有界规则标识;规则编号本身不含词值 | +| matched_words(可选、可空) | `{input:[...],output:[...]}`;两方向合计≤20个、每词1–256 Unicode字符,非空词列表对应BLOCKED方向;不允许额外键、空白词、重复词或非法Unicode,不含周边正文 | +| forwarded / drain_status | 明确是否转发;续读状态为 NOT_REQUIRED/RUNNING/FINISHED/TIMED_OUT/LIMITED/DISCONNECTED,仅表示观察,不当作执行/计量事实 | +| conversation | 可空对象,仅含 conversation_id、request_kind、session_origin、parent_conversation_id 中经校验的字段;总 UTF-8 不超过 4 KiB,不含完整 audit_context 或 message_sources | + +事件载荷摘要只覆盖 event,排除 sent_at 等投递包装。定义递归键排序、无空格 UTF-8 JSON;字段只用整数、字符串、布尔、null、数组和对象,禁止浮点 NaN/Infinity,固定时间字符串不二次格式化。Java/Python 使用同一固定向量验证签名和摘要,不依赖序列化器默认键顺序。 + +成功 HTTP 200,body 返回 event_id、receipt=COMMITTED、duplicate、association_status、audit_request_id。只有事务已提交才能返回该 receipt;未关联也只确认事实,不保证存在主摘要。400 非法格式,401 签名/时间失败,403 来源租户不允许,409 同事件异载荷或同请求同版本异事实,410 已超审计保留水位,413 超限,503 接收依赖不可用。超时、断连、5xx 和 429 可重试;404/405 表示接收版本未就绪、2xx 缺少有效提交确认表示协议异常,均保留待投递并退避告警,不冒充成功。明确的格式、权限、冲突和过期拒绝进入隔离诊断,不标 ACKED;其他未分类响应也不得丢弃待投递事实。 + +backend 数字业务码继续归 DSH 261,不新占模块。拟使用 26170–26175 对应接收鉴权、事件非法、事件冲突、事件过期、事件不可用及事件超限;当前分支实施前重新扫描全仓 Code 字面量确认未占用再登记。Gateway 的 content_blocked/check_unavailable 是外部 ClientCode,不伪装成已有额度或账号错误。 + +### 6.3 模型协议和 Desktop + +模型入口仍为 POST /api/v1/dsh/chat/completions、POST /api/v1/dsh/responses。正常请求原始 Bearer 和原始有效模型正文转发;客户端自报租户或请求用途不决定检查。profile 直连部署内 backend,不经过 Gateway 自身路由。 + +| 场景 | 结果 | +|---|---| +| 输入 JSON/结构非法或超过支持上限 | HTTP 400,error.code=invalid_request,安全提示说明格式或限制;不伪装成敏感词命中 | +| 输入或非流式输出命中 | HTTP 403,DSH error envelope,error.code=content_blocked,message 为已保存阻断提示 | +| 检查基础设施失败且尚未开始响应 | HTTP 503,error.code=check_unavailable | +| Chat 流式命中 | 一个 finish_reason=content_filter 的终止 chunk,随后 [DONE] 关闭协议;不再发 stop/tool_calls 成功终态或伪造 usage | +| Chat 流式检查失败 | event:error,DSH error envelope,code=check_unavailable,随后结束 | +| Responses 流式命中/检查失败 | event:error,type=error,code 分别 content_blocked/check_unavailable,包含 request_id 和安全提示,随后结束;无正常完成事件 | +| backend 原有失败 | 保留原错误与用量语义,不改成内容命中 | + +Chat 终止 chunk 可附加 content_safety={code,message,request_id} 扩展,供新 Desktop 保留原因;旧客户端仍可识别 content_filter 和 [DONE]。Responses error 不是正常带 sequence_number 的模型事件,客户端先识别错误再校验正常事件序列。 + +Gateway 生成的错误使用 Gateway 请求 UUID;发起转发即另提供 X-Backend-Request-ID,表示为 backend 分配的关联编号,不代表 backend 已收到或模型已执行。新 backend 复用该 UUID,正常 X-Request-ID 与安全事件编号一致。为兼容滚动升级,旧 backend 若明确返回不同的有效 UUID,后续事件和 X-Backend-Request-ID 使用该实际响应编号,正常正文和 X-Request-ID 保持原样;缺失、非法、零 UUID 或多值响应头不清空预分配编号。内部事件保留两个字段以兼容历史数据。错误不含命中原文、凭据或未经清理的上游报文。 + +编号的格式校验仅用于关联和安全序列化,不是身份验证,也不是幂等重放接口。Gateway 每次请求生成新 UUID,拒绝客户端通过重复头或 Connection: X-Request-ID 控制该编号;代理在 hop-by-hop 过滤后设置生成编号。backend 的准入及原生/安全投影继续校验可信 tenant/user/model/session,不因编号相同而覆盖其他身份;冲突保留原有拒绝或未关联路径。直接访问 backend 的调用方须为每次请求使用新 UUID,不以复用编号实现重试。无 Gateway 的请求仍由 backend 生成编号或采用通用请求头,不查询 Gateway。输入阻断和未发起转发的检查失败不设置 backend_request_id。 + +### 6.4 审计查询与 DTO(backend 提供) + +既有 /api/v1/dsh/admin/model-audit 权限、封装和缓存禁止策略不变: + +| 接口 / 扩展 | 消费方 | +|---|---| +| GET /requests 及会话、目录查询:has_sensitive_hit、sensitive_direction=INPUT/OUTPUT/BOTH、content_safety_state | 平台组合过滤;BOTH 表示两方向都命中,不是任意方向 | +| GET /requests/{id}:来源、model_entered、阶段状态、命中方向、策略/词库摘要、观察时间、details_status | 平台详情;NULL 状态按来源和是否执行显示未知或不适用 | +| 新增 GET /requests/{id}/content-safety-events:cursor、limit,按 occurred_at/event_id 分页 | 单请求事件明细 | +| /query-readiness、conversation-groups、conversation-tree | 安全汇总版本、更新时间、命中计数及 scan_limited | + +AC-64 移除独立未关联检查产品界面:删除 ModelAuditViews 的入口、隐藏页状态和访问缓存,ModelAuditRequests 按原 tenant/caller scope key 直接挂载既有 ModelAuditRequestPage。删除 UnassociatedSafetyEvents 组件、getUnassociatedSafetyEvents 前端调用封装、ContentSafetyEventQuery 专用类型及三语专属提示;保留请求内 getAuditSafetyEvents 和 ContentSafetyDetails。此前保留的请求明细已按AC-65移除:仅保留会话和未关联请求主视图,删除相关onNavigate回调、跨视图筛选/滚动返回状态及树深度上限跳转。会话内部列表、单条请求元信息展开、原文与检查详情保留;树达到32层时显示说明,不用已移除视图兜底。未关联事件仍保存在 dsh_content_safety_event,debug按tenant_id、gateway_request_id或event_id定位;不改事件接收、归并、保留或数据库结构,不清理历史数据。原后端独立事件诊断接口保留兼容,本轮没有前端消费者。 + +AC-66命中词展示:CompiledPolicy.wordsForRules仅在有命中引用时对本请求不可变rules映射做一次有界检索(词库≤30000项),不请求当前配置、不复制整份词库或请求正文、不新增网络调用。Manager按输入/输出提取去重词值,Observation深复制并覆盖toString以不显示词值;DECISION与DRAIN沿同一冻结词值快照。Outbox把可选matched_words加入v1事件;新backend兼容旧发送方省略/null,旧backend严格拒绝未知字段,因此上线必须先迁移及更新全部backend实例、确认均能接收该字段,再更新Gateway,前端可独立兼容旧数据;回退先停止新Gateway字段,再退backend,新增列保留。 + +事件表增加可空JsonType列matched_words,不回填旧记录,不改schema_version=1约束、请求摘要表或列表索引。append_event保存规范JSON,saved_fact严格比较新增字段,原始wire哈希仍区分省略/null/有值;metadata只通过原授权请求事件查询返回。主列表不联表、不逐行查词值,用户展开检查详情时合并输入/输出词值并去重显示命中词,保留事件occurred_at作为检查时间;React按文本渲染,历史缺失或无可用快照明确提示无法还原,不从当前rule_ref配置反推。命中快照仍是有界观察,不能保证已发现全部命中;截断标记留在后端诊断数据中,检查详情不显示技术字段。原审计OFF时也可保存这份配置词值证据,输入/输出正文仍按采集模式控制,两开关OFF仍不新增事件。 + +迁移revision=f111_matched_words,down_revision由实际alembic heads核实为f111_dsh_content_safety;使用column_exists/table_exists与既有JsonType,只有幂等ADD COLUMN,NULL兼容历史,无数据迁移。默认拒绝破坏性downgrade;MySQL实测与Linux DM8实测分开记录,不把SQLite/类型编译当双库验收。命中词属于获授权的平台审计数据,AC-29的模型内容接收方仍不得收到命中词;普通日志、客户端错误/终止帧和指标标签不携带词值。 + +AuditRequestMetadata 的 effective_mode、model_result、response_result、usage_settlement、session_id 增加可空支持;audit_status 增加 NOT_APPLICABLE,content_safety_state 单独含 NOT_APPLICABLE,不能与原模型审计状态混用。DTO 是加法及有限联合扩展,旧前端不认识这些状态,必须先兼容发布再开检查。 + +安全方向和请求汇总可组合;has_sensitive_hit=false 只表示无已知命中,不筛出“已通过”的同义集合。内部 Python 服务提供 ingest(event, verified_source) 和按审计授权的查询;Repository 接收调用方事务,不能经由 Endpoint 调另一个 API。 + +### 6.5 依赖与破坏面 + +| 依赖 | 风险和约束 | +|---|---| +| backend profile 的用户/租户响应及本地 DSH 凭据 | schema 变化或目标配置错误使检查不可用;不降级信任客户端租户 | +| user/info、user/tenants、DSH 审计 settings 与租户详情 | 显示标志不独立授权;由既有 DSH 管理运行时校验目标,普通租户管理员不能使用超管专属详情接口 | +| Gateway ACPlus、FileUtil、CustomerConfig | 修改分词或大小写语义会改变全部租户命中结果,必须版本化并回归旧应用 | +| DshRoute 的 X-Request-ID 和错误包 | 转发前已知编号;中间代理剥离入站头、旧 backend 替换编号且响应丢失时仍可能无法关联真实原生记录;不能用 profile 编号代替 | +| Chat/Responses 的事件与工具完成语义 | 新事件类型需显式接入;不能按未知事件透传正文 | +| MySQL/DM8、Redis、Celery 及现有审计查询 | Redis 故障有受限直查;SQL 故障显式缺口;worker 故障不伪装目录已完整 | +| UI 规范及 Desktop 工具执行屏障 | 不新增视觉模式;未通过或轮次错误时不执行待调用工具 | + +本功能不新增系统二进制、第三方缓存库、消息平台或搜索服务。运行时信任配置、SQL、Redis 和任务执行环境均为现有部署能力。 + +## 7. 验证策略、可观测和评审 + +### 7.1 验证层次 + +- 单元:身份缓存寿命、未命中合并/取消、词库边界、逻辑文本域、UTF-8/SSE、工具参数、两种协议终态;请求状态 36 组合及旧命中保持。 +- API/SQL 集成:签名跨语言向量、租户上下文隔离、同事件异载荷、可信身份变化可接收、模型归属关联、事件先到/后到/并发、两侧重启、确认丢失、原 writer 不覆盖安全字段、幂等投影及保留水位。 +- 跨仓库 E2E:Desktop 两协议和工具轮次、平台租户配置和过滤、无 Gateway 原功能、关闭开关透传、旧 Gateway 应用回归;不以 mock 或 HTTP 200 代替真实验收。 +- 执行这些验证属于后续实现阶段。本轮只检查设计、源码锚点、链接、AC 覆盖和契约一致性。 + +### 7.2 性能与容量验证 + +用真实 MySQL、DM8 分别构造百万和千万主请求,每请求 1–3 事件,覆盖租户倾斜、低/高命中率、用户/模型/时间组合、深游标页、长会话、补报和持续写入。记录执行计划、扫描行数、排序、p95/p99、锁等待、索引体积和回填影响,与相同环境现有查询基线比较。 + +结构门槛为默认列表没有全事件扫描、历史 GROUP BY 或每行事件查询,ACK 不在内存入队后提前返回,事件重复不放大计数。压测数据不足时不能声称容量已经通过;暂不编造在线延迟 SLO,实测后给用户报告可支持的负载和限制。 + +profile 独立测冷/热、Token 刷新、多副本、Redis 故障下的调用量、额外 SQL 读取和等待;估算 profile QPS≈模型 HTTP QPS×身份未命中率,不能按 SSE 片段计算。缓存命中不能掩盖模型鉴权仍需访问 backend。 + +容量示例:日 100 万请求×2 事件×1 KiB 约 1.91 GiB/日、57.2 GiB/30 天,未计行、索引、Outbox、副本和备份。此为算术示例,不是当前生产规模或实测压缩率。 + +### 7.3 手工验收路径和指标 + +后续测试环境准备两个租户,各一租户管理员、普通用户和一个超管;凭据从测试登录取得,不写进文档或仓库。先经平台 DSH 敏感词控制保存自定义测试词,等待不超过 5 秒,再使用测试 Token 请求两个原有模型入口;响应字段和 tools 按已支持 schema 填写。 + +~~~bash +curl --fail-with-body -i "$GATEWAY_ORIGIN/api/v1/dsh/chat/completions" \ + -H "Authorization: Bearer $DSH_TEST_TOKEN" \ + -H "Content-Type: application/json" \ + --data-binary @chat-input-hit.json +curl --fail-with-body -N "$GATEWAY_ORIGIN/api/v1/dsh/responses" \ + -H "Authorization: Bearer $DSH_TEST_TOKEN" \ + -H "Content-Type: application/json" \ + --data-binary @responses-stream.json +~~~ + +测试文件为测试阶段现场生成的协议样例,本轮尚未创建。输入命中应无 backend 模型调用并有安全条目;输出命中使用可控模型上游构造跨事件词和工具参数,在审计页确认关联及真实用量。不要要求真实模型随机输出指定词来证明扫描正确。 + +再断开接收端,生成命中并重启 Gateway,恢复后核对同事件仅一份、命中请求仅计一次;丢弃一次 ACK 验证重复确认。模拟超 30 秒的上游,确认 Gateway 终止续读,backend 用量可能为未知。以另一租户管理员访问策略、请求详情、事件及游标,均不得越权。 + +指标分组:profile 命中/未命中/合并/队列及延迟;策略版本/缓存年龄/摘要不一致;检查命中/错误/缓冲占用/编译及续读额度;Outbox 待投递数量、最老年龄、隔离数、最后成功;接收冲突/重复/提交延迟;主摘要与会话/树的水位差和 scan_limited。 + +日志只记录请求/事件编号、来源、策略版本、状态和错误类型;不含 Token、Cookie、正文、命中原词。事件ID、用户、租户不直接作为无限维度 Prometheus 标签,可放受控诊断日志和聚合面板。 + +### 7.4 AC 承接 + +| 需求组 | 完整 AC 引用 | 设计位置 | +|---|---|---| +| 入口、独立运行和兼容 | AC-01、AC-02、AC-03、AC-04、AC-05 | §2.1、§3.1、§4.2、§6.3 | +| 租户策略和管理 | AC-06、AC-07、AC-08、AC-09、AC-10、AC-11、AC-12 | §4.5、§6.1 | +| 可信定位、缓存和故障 | AC-13、AC-14、AC-15、AC-16、AC-17、AC-18、AC-19、AC-59 | §4.4 | +| 文本、协议和工具 | AC-20、AC-21、AC-22、AC-23、AC-24、AC-25、AC-26 | §4.6、§6.3 | +| 阻断、错误和计量 | AC-27、AC-28、AC-29、AC-30、AC-31、AC-32、AC-57 | §4.6、§6.3 | +| 检查状态与最小事实 | AC-33、AC-34、AC-35、AC-36 | §4.7、§4.8、§6.4 | +| 关联、可靠投递和幂等 | AC-37、AC-38、AC-39、AC-40、AC-41、AC-42、AC-43、AC-58 | §4.7、§4.8、§6.2 | +| 展示、筛选和汇总 | AC-44、AC-45、AC-46、AC-47、AC-48、AC-49、AC-50 | §4.9、§4.10、§6.4 | +| 保留、历史、双库、启停和三语 | AC-51、AC-52、AC-53、AC-54、AC-55、AC-56 | §4.9、§4.10、§4.11、§7.1 | + +### 7.5 本轮评审方式 + +按仓库 sdd-review design 的 24 项清单检查接手所需的现状、决策、坑和契约,并进行 Constitution Check。评审报告另存 design-review.md;“静态设计评审通过”不代表实现、性能或真实环境验收通过。用户确认 design 前不创建 tasks 或业务代码。 + +## 8. 后续改进与暂不投入 + +- 在线明细 30 天只用于评估;保留和归档另行配置确认,不默认缩短现有证据期。 +- 大数据分区、外部搜索、跨期统计和导出在执行计划或容量实测达到瓶颈后再评估,避免同时引入双库分区兼容和运维复杂度。 +- profile backend 本身约 8 次读取的去重优化不在本期;不为了 Gateway 缓存修改 F110 认证真相。 +- 跨多个实时模型通道的统一内容安全契约、按用户/部门覆盖及语义审核需独立需求,不挂接到本功能的默认行为。 +- 多文本域交错的队头阻塞、JVM 内存放大、长尾补报与大型租户回填均是待实测的限制,不能作为“已有索引/有缓存所以没有风险”的结论。 + +## 源码与规范锚点 + +以下为已存在的核查入口;新增文件仅在 §4.3 标为拟新增,不制造不存在的源码链接。跨仓库路径均相对其仓库根。 + +| 仓库 | 路径 / 入口 | +|---|---| +| BiSheng | [models.py](../../../src/backend/bisheng/dsh/api/endpoints/models.py)、[DshRoute](../../../src/backend/bisheng/dsh/api/responses.py)、[access_runtime](../../../src/backend/bisheng/dsh/access_runtime.py) | +| BiSheng | [审计写入](../../../src/backend/bisheng/dsh/domain/repositories/model_audit_write.py)、[writer](../../../src/backend/bisheng/dsh/domain/services/model_audit_writer.py)、[状态](../../../src/backend/bisheng/dsh/domain/schemas/model_audit_status.py) | +| BiSheng | [请求查询](../../../src/backend/bisheng/dsh/domain/repositories/model_audit_queries.py)、[会话投影](../../../src/backend/bisheng/dsh/domain/repositories/model_audit_conversations.py)、[目录构建](../../../src/backend/bisheng/dsh/domain/repositories/model_audit_tree_build.py) | +| BiSheng | [HTTP 租户中间件](../../../src/backend/bisheng/utils/http_middleware.py)、[HMAC 算法](../../../src/backend/bisheng/sso_sync/domain/services/hmac_auth.py)、[管理 API](../../../src/backend/bisheng/dsh/api/endpoints/model_audit_admin.py) | +| Gateway | src/main/java/com/dataelem/gateway/filter/{SensitiveWordsFilter,CustomResponseFilter,LicenseExpiredGlobalFilter}.java;utils/{FileUtil,HmacSignatureUtil}.java;config/{CustomerConfig,SpringCustomConfig}.java | +| Desktop | packages/dsh-desktop-enterprise/{openai,responses,error-copy}.js | +| 平台 | [DSH 导航](../../../src/frontend/platform/src/pages/DshPage/sections.ts)、[审计 API](../../../src/frontend/platform/src/controllers/API/dshModelAudit.ts)、[审计类型](../../../src/frontend/platform/src/types/dshModelAudit.ts) | +| UI 规范 | [规范入口](../../../src/frontend/packages/ui/docs/index.md)、[组件总览](../../../src/frontend/packages/ui/docs/components/index.mdx)、[Checkbox](../../../src/frontend/packages/ui/docs/components/checkbox.mdx)、[Switch](../../../src/frontend/packages/ui/docs/components/switch.mdx)、[Tag](../../../src/frontend/packages/ui/docs/components/tag.mdx) | + +## 修订历史 + +| 日期 | 改动 | 原因 | +|---|---|---| +| 2026-10-29 | AC-73、AC-74、AC-75:未就绪统计不展示、两方向控制检查、只在会话内隐藏辅助请求及类型标签 | 用户确认保留会话和请求、无需旧配置兼容,并收窄第三项范围;不加表、不改外层分页/汇总 | +| 2026-10-28 | AC-72:请求详情增加分类标题并聚焦四项业务状态,合并重复模型结果 | 用户要求沿敏感词详情原则简化;保留失败原因、未知语义与原文权限 | +| 2026-10-28 | AC-68补充:移除统计版本,仅保留统计状态、更新时间及命中数量 | 用户明确精简统计展示;不更改后台汇总协议 | +| 2026-10-28 | AC-71:请求内检查进展事件汇总去重,只显示一份命中词及首次命中时间 | 用户指出DECISION/DRAIN两条同词快照重复展示并授权修复;保留内部事件 | +| 2026-10-28 | AC-70:敏感词配置页按相同原则精简,保留完整配置与异常恢复 | 用户要求配置界面同样简化;不改变检查策略或存储 | +| 2026-10-28 | AC-68、AC-69:审计界面简化,全部通过隐藏检查详情;保留全部高级筛选与后端诊断数据 | 用户确认demo并补充全部通过不展示详情,授权修改项目代码 | +| 2026-10-27 | AC-65、AC-66:移除请求明细导航;冻结命中词快照入库并在授权检查详情展示 | 用户明确要求;覆盖前版只保留规则编号的取舍,不记录周边正文或普通日志 | +| 2026-10-27 | AC-64:移除未关联检查入口、专属页面和前端查询;未关联事件仅保留数据库用于debug | 用户明确移除产品功能,保留会话和未关联请求;不改数据保留与归并 | +| 2026-10-27 | AC-62、AC-63:Gateway 预生成并透传模型 UUID,backend 通用复用;无响应头可关联外部摘要,保留历史异常入口 | 用户确认统一编号方案并要求实现、测试与文档同步;不提交、不推送、不部署 | +| 2026-10-23 | 初版设计;承接已确认的 59 条 AC | 用户确认 spec,要求产出并评审 design;尚未实现 | +| 2026-10-23 | 自审修正管理授权探针、专用代理生命周期和会话组合条件口径 | 静态代码与契约复核;保持已确认需求 | +| 2026-10-23 | 撤回固定分页窗口与跨事件身份冻结两项复审意见,同步 spec、接收及分页契约 | 用户明确沿用既有分页并接受毕昇可信身份变化;未进入 tasks 或业务实现 | +| 2026-10-23 | 回传复用现有 HMAC 密钥、签名头及规则;补明 profile 的服务端验证和返回字段 | 用户确认使用相同密钥配置并询问 profile 定义;仅同步设计及评审文档 | +| 2026-10-23 | 用户确认 design,进入 tasks 拆解及静态评审;补明既定 v4 约定的依赖与局部 Provider 接线 | 用户明确仅产出任务文档,暂不修改业务代码;主决策与对外接口不变 | +| 2026-10-24 | Wave8完成事务投影、Profile有界合并、管理入口和Unicode扫描;同步实际边界与后续唯一竞争恢复 | 集中回归通过,仍不代表入口接线或双库验收 | +| 2026-10-23 | Wave 1–4 基础设施、DTO、HMAC/reducer、Gateway 边界与 Desktop 文案实施细节同步 §9 | 用户授权实现;测试证据及未执行门禁单列 implementation-verification,不代表全特性交付 | + +## 9. 实施细节同步(2026-10-24) + +Wave 1 按已批准执行计划补齐:新事件 ORM 同时进入显式租户模型登记,避免 create_all 发现与首次租户注册快照顺序不同而漏过滤;组/树响应 DTO 在既有查询 schema 文件声明,后续任务负责接线。阶段状态与阶段版本按 §6.2 固定,Java/Python/TypeScript 保持一致。Gateway 大字段使用 MySQL MEDIUMTEXT / DM8 CLOB,UTC 微秒时间,不沿旧处理器写本地时区。 + +F111 Alembic revision 只扩展既有表;新增整表继续由模型发现创建。引用既有单 head,约束检查复用大小写兼容的 schema helper。downgrade 默认拒绝破坏性收缩,不在迁移中按现有数据状态分支;应用回退保留事实,空库收缩由独立运维停止写入、判空和双库演练完成。基础设施一致性任务增加持久化迁移回归测试;这些补充不改变 spec AC、共享 HMAC、身份和分页取舍。 + +Wave 1 真实回归补充 T133/T134:审计 metadata 不能把新增 DTO 字段名直接当 ORM 属性,需显式映射阶段快照和来源别名;未验证明细可用性时只返回未知或待确认,不为命中标记伪造明细可用。Platform 旧标签接收可空状态时使用既有 UNKNOWN 文案,最终外部条目语义仍由原定 UI 任务补齐。Gateway Outbox 显式含 tenant_id 与 event_version,source_id 最长 64;关闭策略的词源/自定义词/回复/摘要可空,两库同义,enabled 后由策略服务校验有效词库。 + +Wave 3 固定的后续接口:HMAC helper 的 compute_hmac_signature/compare_hmac_signatures 与纯 reduce_safety(existing,event);Desktop 导出 CONTENT_SAFETY_ERROR_COPY 和 resolveContentSafetyError,固定三语与租户安全提示分开。Gateway profile 身份的 expiresAt 允许未知(null):可信 profile 仍可用于本请求,缓存必须拒绝未知截止时间,不因不能解码 Token 推断身份或伪造有效期。可选会话提示清洗后再生成有界事件,提示问题不抹掉核心安全事实。 + +Wave 4 已实现上述 helper/reducer 与五个 Gateway 边界客户端/存储及 Desktop 归一化模块。profile 与代理持有专用 HTTP 连接生命周期,禁止自动重试、重定向和凭据日志;身份/策略缓存仍由后续任务实现。策略存储以 tenant_id 与 version 的条件写执行 CAS;Outbox 使用条件租约认领及 owner 校验 ACK,仅更新投递列,快照不可变,事件阶段版本只随状态变化递增。未知 Token 到期不缓存;profile 不返回 session_id。可选会话提示无效时只丢弃提示,核心事件照常生成。SQL 原子性和两库执行仍需真实环境验收,当前本地 Mockito 不作双库证明。 + +事件 Repository 只在调用者事务内 flush,不自行 commit 或在失败的 begin 块里 rollback 后继续查询。唯一键竞争的恢复由接收编排(T030)退出整个失败事务后,以新 Session 重读和重做完整幂等合并;不能只保存事件而漏掉同事务主摘要。事件哈希覆盖原始 event 的规范 JSON,DTO 默认填充或可选提示清洗不改变原始载荷摘要。 + +现有 ACPlus 的 AC.update 失配链仅回退一层再查根,实测会漏多层重叠后缀(words=[acbcc,bcx,cca],text=acbcca 漏 cca@3)。DSH 匹配器复用 ACPlus 的扫描能力,在本功能构建环节按完整失配链建立跳转,覆盖该回归;不调用旧 filterNormal 或改变旧应用过滤器的副作用。 + +T022 事件存储已实现严格租户读、幂等查询、保留闸门和调用者拥有的 flush。UUID 存储统一小写以消除数据库文本排序差异,载荷哈希仍依据原始 wire 表示;接收 Receipt 回显请求 event_id。保留水位使用设置行锁与清理协调,其同租户并发开销列入后续真实库压测观察,不以单元测试证明容量。 + +T058 身份缓存已实现:L1 正/负合计 LRU 最多4096条,Redis值最多4KiB且仅含六个身份/时间字段;重复JSON键、未知字段、未来验证时间和超长有效期均拒绝。Redis同步操作在 boundedElastic 执行,端到端deadline和单调时钟持续校验,不从完成时刻重设寿命;超过1秒墙钟/单调偏差时拒绝新检查。T060已增量接入本机有界合并和跨副本owner租约;真实多进程验收仍待后续任务。 + +Wave 6 已完成 T022/T058/T064;Gateway13类195测试及真实隔离Redis端口验证通过,证据边界见implementation-verification。候选编译不占用已提交版本键,CAS后不再竞争编译资源;恰20项命中不标截断,观察到第21项才标记,收集始终有界。AC局部terminal output链接避免每字符扫描非终端失配链。未接线入口、回传、页面或后续owner fencing,不宣称全特性交付。 + +策略管理补齐:settings和capabilities均携带明确目标tenant_id并实时走同一管理授权。AdminClient在既有实时链成功后返回/user/info校验的可信用户编号,供updated_by使用;旧Void授权入口保持兼容,不从body/header自报操作者取值。Origin允许范围复用bisheng.homeUrl规范化的scheme/host/effectivePort,配置路径不参与比较;写请求缺失/多值/null/含路径/假前缀Origin拒绝,homeUrl无效明确不可用,不新增配置或后台RPC。该返回契约补齐列为T135,未变更已确认权限策略。 + +T069 冻结后续扫描边界:newDomain(label) 创建独立请求内逻辑域,feedDecodedText(domain,text)/completeDomain(domain) 返回不可变 Outcome(blocked,checkedThrough,observedUnits,hitCount,hitCountTruncated,ruleRefs)。水位采用绝对UTF16单位,与AC索引兼容;尾部按Unicode码点保留,不拆补充平面字符。不同消息/choice/工具不能拼词,外部请求的Domain拒绝;阻断不再开放、完成幂等。增量UTF8与JSON解析属于协议适配器,scanner接收已解码文字并安全处理回调间拆分的代理项。 + +Wave7完成前置测试与T135可信操作者返回:管理授权先从既有/user/info的user_id读取严格正int64,再通过全部实时目标校验才返回AdminIdentity;旧authorize继续then()。投影Repository接口冻结merge_audit(session,event)返回主行或None,先有同事务已保存事实,不提交、不ACK、不自行新事务重试;可信身份变化仅影响可关联性,不能覆盖原模型归属。 + +Wave8:merge_audit仅合并同事务已保存事实,原主行归属优先;未确认backend编号或身份/模型不匹配保留UNASSOCIATED,不猜关联。主摘要和事实关联均显式tenant/id/version条件更新,started_at固定,安全变化独立置dirty且不写模型用量。T030需区分追加冲突与主行全局唯一冲突:新事务按严格租户仍不可见的冲突不能通过bypass寻找其他租户,保存事实为未关联,避免无限重试。 + +T070扫描的错误域不可重新feed或complete成PASSED;阻断域稳定返回同一有限摘要,完成幂等且禁止重开。管理入口在可选策略服务缺失时仍可报告available/configuration_ready=false和真实部署开关,读取/保存返回503,不伪造已关闭策略。完整条件化Spring接线由补充T138/T139验收。Wave8集中测试与Redis小验证仅证明各模块边界,完整模型链、跨进程租约、MySQL/DM8和容量均保留后续验收。 + +Wave9冻结协议适配器内部边界:构造接收CompiledPolicy与DshSafetyProperties,accept(byte[])/complete()/cancel()返回有序完整事件List,inspectNonStreaming(byte[])返回正常原bytes或明确错误正文,state()/outcome()提供有限安全结果。可信request_id后续由请求管理器注入,不生成新的业务编号。输入沿实际DSH schema:Chat工具arguments可为任意合法JSON,Responses必须对象;二次解码后独立扫描键和值。外部事件深度16不扩为模型工具参数的限制。历史和输出Citation.title属于可读文本,URL独立opaque;response.failed/incomplete中的可读输出快照仍检查,普通错误诊断保持既有失败语义。 + +Wave10已完成原生writer提升和三种适配器:输入严格UTF8/JSON并保留原bytes,HTTP400/invalid_request遵循§6.3;Chat任意JSON工具参数与Responses object参数保持当前backend差异,不给模型JSON套外部事件深度16。输出队列按完整原事件绑定域水位与工具屏障,Chat终态content_filter+[DONE],Responses明确type=error;正常usage含nullable/detail字段不归一化。工具参数增量自身与每次完整快照均检查,不能由安全done覆盖危险增量。进度快照先独立检查,再以UTF16长度/SHA256确认既有前缀,仅新后缀进入原域;不能绑定的变化拒绝。结束冻结有限Outcome并释放持有正文/条目/队列容量,Chat工具名称只保存非空观察标志。请求编号注入、上游所有权、全局配额和入口接线仍由T086/T139等后续任务完成,不代表特性已端到端可用。 + + +Wave12 backend实施对齐:EXTERNAL_SAFETY的模型状态固定NOT_APPLICABLE,纯状态派生及停滞worker在activity/facts/原文读取前排除外部条目,保留原批次用于cursor;原生MODEL规则不放宽。状态刷新独立比较has_sensitive_hit与group_indexed_sensitive_hit以置dirty。请求到会话投影使用既有行锁,同事务按request的state_version+safety_projection_version+pending条件完成,Group写限定tenant/key及前次三计数;SELECT继续严格租户自动过滤。未关联只清pending、不设置已计入水位,后续原生关联可再投影。每次默认100、允许1–200个,单项失败新事务恢复,游标返回真实批次进度。条目、异常、命中分别差量计数,安全变化同事务递增safety_summary_revision;跨进程持久领取租约仍由T050的worker边界实现,未在Request上增加新租约列。 + + +Wave12 Desktop实施对齐:Chat收到content_filter即结算明确错误与安全文字/推理前缀,return使SSE reader取消释放,不再等待DONE或允许坏尾帧/EOF覆盖确定命中。Responses transport在普通sequence校验前保留flat/nested安全错误及HTTP结构化错误,避免pi-ai包装成普通上游错误;工具block-end等待整轮正常done,并核对明确completed状态、wire完整object JSON及SDK实际对象/argumentsError。参数无效先记录,到正常终态才拒绝工具,后到安全错误仍优先;max-tokens保留安全文字、由既有BlockAssembler丢弃工具,不制造成功或自动重试。实际插件pi-ai为0.87.1;既有max-token replay hash不匹配时降级可见文字重放,不改写其hash为可匹配未执行工具的值。实际runner屏障及locale当前显示入口接线仍留T118/T117。 + + +T086请求管理器每副本唯一实例持有16检查/8续读槽及64MiB编码预算;Chat原事件与当前行存在两份编码缓冲,对待检查字节保守预留两份,原事件放行后转为单份delivery、退回额外份。上传先完整32MiB限长,idle与总截止复用既有注入I/O预算,不增加YAML。客户端通道为按demand交付的有界FIFO,慢或未订阅时限到停止持有;completion仅观察,不拥有上游。命中先冻结BLOCKED与check→drain所有权再回调安全prefix,避免同步取消降级/泄漏;独立hit起算30s及剩余响应字节,终止清空adapter真实引用和所有订阅/额度。DECISION/FINAL/DRAIN幂等串行调用既有boundedElastic Recorder,只保存有界快照;正常响应bytes不重编码,Gateway合成错误root.request_id固定为gateway UUID;已取得有效backend编号则另提供X-Backend-Request-ID,正常backend X-Request-ID与正文原样保留;此为 Wave11 历史行为;2026-10-27 起转发前预分配 backend 编号,按 AC-62、AC-63 关联,不据此虚构执行事实。 + + +T117接线复查补齐:Host Node的navigator不能代表renderer当前语言,现有failureMessage对两个安全码直接显示failure.message,不查询新字典。增加T136前置测试/T137两adapter可选同步localizeContentSafety(code,defaultMessage)回调后,T117的client/index才负责真实locale注册/当前语言构造/卸载,保持每任务最多两维护文件。仅CONTENT_CHECK_UNAVAILABLE已知Gateway固定英文及无自定义提示的fallback调用本地化;CONTENT_FILTERED任何非空安全租户提示保持,即使恰等固定英文。普通账号错误保持原行为;回调非法返回/异常不能覆盖明确安全分类。未提供回调保持兼容。此补齐不改变需求,不将只注册copy当已接通。 + + +Wave13查询内部端口冻结:ContentSafetyQueryRepository(session).request_events(request_id,cursor=None,limit=20)返回items/next_cursor/details_status;unassociated_events(now=None,start_at=None,end_at=None,user_id=None,model_id=None,cursor=None,limit=20)按request_started_at默认7天、最多31天,显式筛选和租户绑定cursor,不冻结默认相对窗口。事件详情使用已声明索引的本地主键id作同时间tie-break,与对外event_id区别;只分页查经授权的request关联事件。主列表批量查真实用量/姓名,不查事件;known safety摘要的list details_status可保守PENDING,详情端区分AVAILABLE/PURGED/UNREPORTED。INPUT过滤包括双向命中,OUTPUT同理,BOTH要求同一条请求两标志都为真,不拼会话不同请求。 + +编号复核按原§6.3补正:Gateway生成安全错误body.request_id始终为Gateway UUID;流式正常响应已发的backend X-Request-ID保持,另携带X-Backend-Request-ID。Desktop只对明确归一化安全错误优先显示body的Gateway编号,普通账户/额度错误仍沿旧header优先。T086曾误沿backend编号的实现已修,T136/T137补齐对应消费回归。 + + +Wave14接收与查询实施对齐:接收端在既有有界阻塞池、严格tenant scope中最多尝试三个完整新事务,原wire事件哈希先于DTO清洗;Receipt标量在Session附着时取得,COMMITTED仅在成功提交及退出Session后返回。首次merge唯一键冲突后严格重读并重做完整归并;当前租户目标可见也不能证明全局source/Gateway绑定可用,连续两次merge冲突时,第三个新事务仍通过追加幂等、哈希与保留闸门,只保存未关联事实。不得bypass探查外租户、伪造关联或吞掉append/commit失败;正常可见并发winner继续首次重试完整归并。调用方超时不ACK,底层已开始事务可稍后完成,重试仍幂等。 + +T038主列表按页批量读取Call、RESPONSE_FAILURE证据及姓名,无逐条事件查询;详情只输出白名单有限元数据,不含payload_hash、正文或tenant信息。事件按时间/id升序limit+1,游标绑定租户与全部显式筛选;严格验证游标对象、时间及UUID类型,非法输入统一查询错误。空续页只要同请求仍有保留事实便为AVAILABLE,不误报PURGED。请求列表details_status仍可PENDING,由按需详情请求确认。 + +T044沿既有树构建的同一state锁事务维护结构、模型摘要、安全摘要三水位。COPY记录会话own命中数,AGGREGATE沿原Path累加branch命中数;构建期间任一水位变化即废弃未发布代并重启,PUBLISH同时发布三水位。租户补扫、READY/CLEANUP和progress均识别安全水位未发布;命中dirty仅由已有请求到会话投影递增一次,不新增树或改变模型异常计数。 + +T088入口预检查使用Spring PathPattern与专用路由一致的匹配语义,仅两种POST协议;编码字符或matrix参数等同一路径仍进入managed检查并由后续路由使用固定协议目标。若解码后等于目标但PathPattern不匹配(如编码斜杠),部署开关开启时在catch-all缓存正文前返回400 invalid_request;关闭保持既有行为。预检查在正文订阅前取得可信profile和租户策略快照,部署关闭透明;租户关闭保留独立模型链。仅观察原401/403、至多64KiB严格JSON的已知身份失效错误,保留原buffer/flush边界,不因cache失效失败改写响应;不把配额、席位、许可或普通鉴权失败归为身份失效。实际模型所有权接管及条件Bean仍由T090/T139完成。 + + +组装任务补齐:T098实际声明仅telemetry与Dispatcher,不能隐含跨文件Configuration。新增T138容器前置测试/T139单一Configuration实现,位于Wave27/28;按既有构造端口真实组装和生命周期验收,完整配置但检查关闭仍支持策略管理及历史投递,缺配置不影响旧模型。没有新增需求或独立配置体系。 + +接收端部署租户范围澄清:现有SSOSyncConf不含source租户白名单,source_id仅幂等/诊断。本期共享HMAC认证本backend的Gateway服务;multi_tenant.enabled=false只允许既有DEFAULT_TENANT_ID,启用时覆盖本backend租户作用域,事件仍按严格单租户处理。不得将自报source_id变为扩权开关,不检查用户Token或租户当前active状态以拒绝历史事实;未允许范围403。签名只对固定接收path,不含query;HTTP适配传入request.url.path,直接传含query的非法path拒绝。 + +T032来源范围拒绝使用common/errcode/dsh中唯一认证错误类型的显式tenant_forbidden()工厂,沿用已登记业务码与ClientCode,仅返回实例的HTTP状态403;类默认及原签名错误保持401,缺配置/依赖503。不借用模型未授权、租户当前不可用或工具权限错误,不新分配业务码/翻译键;T032增加该既有文件为第二目标,仍保持每任务两文件上限。运行时错误码唯一性检查也包括继承的Code,不能用另一个同码子类绕过。 + + +T117当前语言接线:client使用现有LocaleRuntime独立namespace注册两码zh/en/ja固定文案;若目录没有ja,用已有addLanguage登记ja并fallback en,仅持有自己的disposer。只保证本安全文案三语,其余既有界面沿词典fallback。同步subscribe将固定copy写入同源session cookie,名称含完整authority(Cookie本身不隔离端口);载荷为{owner,messages},随机UUID owner仅用于卸载所有权,不是身份/鉴权字段。按copy去重发布,卸载仅删除仍由本实例持有的完整值,避免同值双窗口误删新publisher。Host在已认证connection/request公开waterfall内严格校验唯一cookie、2048编码字符上限、精确结构及两码SSOT白名单,只把messages投影入请求AsyncLocalStorage。adapter同步回调读取该scope,不建立全局last-language缓存、不转发Cookie或文案到broker/backend,不改变分类/请求编号/tenant自定义提示。 + +该实现已用真实LocaleRuntime、认证Host入口及实际注册adapter验证,GUI为独立Electron有限范围原错误消费组件显示,不等同完整应用启动或Session.prompt/Agent排队链。原生工具执行屏障继续T118,入口/卸载回归由新增T140固化;队列followup的语言scope继承作为T118联动观察,不据直调adapter探针宣称已验证。 + + +T032实现端口:verify_source(raw,headers,method,path,now=None)同步返回frozen VerifiedSafetyEnvelope(raw_event,verified_source),不读SQL、不消费HTTP流;上层HTTP适配负责限长只读一次。先限制原始64KiB、固定POST/path及共享key,再对唯一签名头常量时间比较,随后严格UTF8/JSON(重复键/浮点/NaN拒绝)与Envelope DTO、±300秒和部署scope校验。默认get_content_safety_service仅注入惰性Session工厂,不加载模型runtime或SSO登录能力;get_settings在认证时才读取原配置。任何DTO清洗/默认均不回写原event,后续hash仍覆盖原始事实。 + + +T090真实传输内存复核:Spring writeWith的首帧屏障在writer零需求时预取一个DataBuffer,先用0字节prelude承接可避免其缓存实际模型字节;该prelude不改变对外body。更深层实际Netty写入缓冲仍可能在HTTP未完成时持有整个非流式响应,因此不能在manager downstream.onNext返回时把HTTP持有期排除出64MiB全局额度。T090增加Manager为第二生产目标,原T089单测试文件独立维护回归;订阅正文前显式取得HTTP writer租约,已交给transport的字节从delivery计费转为writer-held,直到一次实际writeWith结束(成功、错误或取消)才归还。保持单次写响应及T088一次完整错误观察,不逐帧多次write,不依赖Netty内部API/反射。 + +保守计费包括本响应已发送完成但writer整体尚未结束的编码字节,最多沿既有32MiB响应限制;会减少长流并发余量,优先维持全局上界。初始16检查/8续读不等于承诺16个满额长流同时可用,吞吐与堆占用仍由T128实测;无新SLO。旧直接manager.body消费者沿原消费归还,HTTP路由必须登记租约,取消/写前失败均幂等释放,确定命中后的独立drain继续保留。 + + +T090最终writer清理:guard只负责沿既有ioTimeout终止写入信号,不能在timeout/cancel时立即退款。Mono.usingWhen的error/cancel清理通过公开Spring native response与Reactor Connection.dispose/onDispose关闭实际下游,并等事件循环下一turn完成缓冲释放后close租约;成功写完直接close。租约开始拒绝clientGone,abort标志及优先订阅stopped覆盖begin→shutdown→guard间隙。模型owner完成不是拒绝HTTP writer的条件,确定命中的独立模型续读不随下游取消而中断。真实慢TCP timeout/socket-close/manager-close已验证32MiB缓冲释放后额度归零,吞吐/heap与完整链验收仍属后续任务。 + + +Wave18 HTTP/配置落地:content_safety_events仅注册精确POST,query前拒且不读流,原request.stream累计最多64KiB,不信Content-Length且不改_receive;原raw/Headers/METHOD/url.path验签后才构造惰性service,直接返回ContentSafetyReceipt。已登记业务错保留状态,断连/取消向上传播不假ACK;其他依赖错误仅记录类型并503,不含正文/SQL。两份Gateway YAML的两精确POST路由order=-10、URI引用既有bisheng.bisheng-api-url,无CacheRequestBody;Path显式matchTrailingSlash:false与预检查一致。默认enabled:false,backend-namespace/source-id留空由启用时既有Properties校验,不增加密钥或codec上限。普通及DM配置继承与关闭透传由真实Gateway路由工厂/默认Netty及Profile/Proxy验证;完整Spring组件组装仍T139。 + + +Desktop队列语言实施修订:真实公开SessionController.queue证明后续请求沿首轮AgentLoop driver继承ALS,单独connection/request scope不足以代表queued请求语言。宿主使用现成inbox inserted/claimed/discarded公开事件:按session+稳定Message.id捕获入队时固定copy,insert仅pending,claim才晋升当前turn,discard/匹配turn-end/agent dispose及unload回收。display-only旁路不进入Message/source、持久日志、attribution或模型请求。两adapter通过可选同步contentSafetyFormatterFor({sessionId,purpose})在实际stream开始取得冻结formatter,未来turn/入队不改在途提示;无factory沿旧localizeContentSafety,异常/非法值安全fallback,不影响普通模型。无session及title/compaction沿发起时请求ALS;原tenant提示/分类/编号不改。T144/T145与T146/T147分别先测试后实现;不引入第三方patch或全局last-language。 + + +Wave20入口实施:CustomMiddleware只在既有DSH_CREDENTIAL_ROUTES登记精确POST接收path,复用原六类上下文清理/finally恢复,不改TENANT_CHECK_EXEMPT_PATHS或引入prefix bypass;HMAC/严格事件tenant及SQL仍由各自现成边界完成。旧SensitiveWordsFilter和CustomResponseFilter在最前仅依据可信Boolean.TRUE MANAGED_ATTR返回原exchange给chain,不读旧cache/包装响应/调用filterNormal/原文日志;用户Header不授予标记。普通DSH路径原本已自然避开旧URL分类,新增guard为显式隔离与内部改写防御,旧应用命中/BlockRecord行为不变。 + + +T147落地约束:入队时缺少合法显示提示同样捕获为显式missing,claim不沿旧driver ALS补值;active存在但copy缺失时不生成formatter。index只接新factory,不同时交旧localize回调,防止undefined触发legacy callback复活旧语言;无session/辅助调用由factory本身读取发起请求scope。所有当前语言旁路按session+Message.id/turn匹配并回收,已取得formatter不随生命周期变化。Host侧取固定copy快照,无模型/Message/source字段或第三方修改。 + + +Wave22 Gateway实施:ContentSafetyEventClient按订阅重签sent_at,原Outbox payload经UTF8及严格JSON校验后原字节嵌入envelope;固定既有backend origin+接收path、共享SSO签名helper/key/header。每次专有连接不重定向/自动重试;<=3秒绝对预算扣本地包装和HMAC耗时,覆盖连接、headers及完整有界严格回执。只有200/COMMITTED/同event_id且结构合法才返回ACK disposition,Client不更新Outbox;永久拒绝隔离、其他失败退避由后续Dispatcher负责,Bean由T139条件组装。PathRate仅精确DSH POST或可信Prepared协议跳旧body/日志,默认关闭也同样;已配置规则继续tryAcquire原canonical completions/responses key,编码与matrix等价路径同时匹配原配置或相同canonical配置,避免别名绕过已有限流且不记录matrix私密值。旧方法、邻近路径、普通应用及HTTP200/body429合同不变。 + + +Wave22 backend落地:主请求Service沿Repository.page(now=...)计算窗口,不注入隐式筛选;原管理授权→profile_scope→仓储链保留。新增请求事件与仅未关联事件分页端点均沿同管理授权、no-store及平台响应封装,不添加Gateway RPC;列表保持摘要,单详情才按需取limit1事件判断AVAILABLE/PURGED/UNREPORTED。readiness通过现有tree_state/tree_progress组合已发布安全水位与真实published_at,0是合法已发布版本;未发布、落后/dirty或失败明确未就绪。 + + +T102 Platform接口接入:经原wrapped request读取Gateway snake-case ResultData解包,保留preserveError供409识别且不自动重试;capabilities明确不可用与404/network/业务失败区分。GET/PUT使用原同源身份、tenant参数、AbortSignal及no-store,写入只投影可编辑字段。事件分页沿backend端点校验有限白名单元数据、nullable字段和decimal int64身份,conversation按256码点校验;旧审计缺安全字段保持undefined,不制造PASSED/false。组/树转发三项安全筛选包含false,模型审计二态筛选仍沿原DTO,不混内容六态与NOT_APPLICABLE展示语义。 + + +Wave24目录查询实现:会话与树的时间、用户、模型及安全条件通过同一关联Request EXISTS核验,正向安全汇总仅作已就绪候选优化;筛选内未实际计算命中数保持null,全会话计数与筛选范围独立。安全汇总仅在当前发布代、修订一致且无本租户待索引/失败时就绪,已发布版本0有效。组页新增一次有界CASE/EXISTS就绪查询,子查询显式关联State.tenant_id,其他租户待处理记录不降低当前租户就绪状态。Repo可选now保留旧内部兼容;实际API时钟接线由T152/T153配对实施。 + +Wave24持久回传调度:Dispatcher独立SmartLifecycle,每秒单轮、有限并发16与最多100条批次,轮次UUID owner防同实例ABA。发出和结果写回均检查原租约,最终由Service的SQL CAS确认;仅成功隔离持久写后告警,无合法COMMITTED回执不ACK。1秒至5分钟指数抖动重试;每分钟至多一页ACKED/acked_at七天清理,MyBatis方言分页不COUNT并以相同状态/时间守卫删除。close仅取消本实例订阅,不能中断的SQL不新增周期工作;真实驱动超时与双库行为留T120。Bean接线仍由T139负责。 + + +T153实际组、树及组内请求Service现均向既有Repo查询传self.now();默认范围在Repo计算且不写显式筛选fingerprint,下一页时钟推进仍接受,显式条件与租户变化仍拒游标。历史location及parent定位保持不加默认窗口。两旧固定NOW构造记录的HTTP测试仅各自注入查询clock,不改变全局运行时。 + + +Wave24 Platform实现:设置页独立v4 QueryClientProvider与目标tenant查询键,Checkbox草稿及原子保存,版本冲突保留草稿、刷新仅更新expected_version后显式重试;切租户卸载旧表单且旧mutation不污染新租户。权限沿原实时后端准入,不增加403业务分支。检查关闭/能力不可用/读取失败有独立呈现,不制造PASSED。安全三筛选保留false/BOTH交集,默认相对窗口仅作提示,显式双端>31天禁搜索。新Card最多3个Tag,模型四轴与usage继续独立,EXTERNAL_SAFETY或未进入模型无原文入口。事件详情仅展开后分页读取,PURGED保留原主摘要,所有值React纯文本;失败重试记录准确attemptCursor,刷新失败不误跳续页。 + +共享UI接入仅补本仓workspace本地依赖,不升级现有第三方闭包。Platform尚未全站采用共享主题,故新功能通过Vite从公开tokens/preset即时生成作用域CSS;普通规则限定.dsh-content-safety,主题顺序/触屏规则保留,keyframes命名隔离,不加全站preflight或修改共享库样式。旧平台root --radius与其他utility保持原值,新UI仅作用域内用landed Button/Checkbox/Textarea/Tag。 + + +T107请求页面接线:安全筛选与nullable模型事实经原API不作二次推导;刷新只用applied查询并清游标/展开,未提交draft保留。请求hook把结果绑定query对象、revision、groupKey;effect启动前的新scope首render不展示前次page,AbortSignal及迟到结果双重隔离。目标租户变化以key重建整个请求页面,目录/筛选/分页/展开状态不跨租户遗留。没有新增HTTP、全站状态库或客户端假PASSED/usage0。 + + +T098有限诊断实现:固定Counter16/Gauge13/Alert5,内存仅枚举及标量,快照不可变、累计计数饱和、不保存异常/身份/正文。未知未采样gauge不报健康0;最后成功是历史事实,ACK或数据库样本逆序到达均只取最大提交时间。真实Dispatcher只在持久SQL成功后计ACK/重试/隔离,CAS0记租约失效。原7参构造为noop兼容。独立每分钟statsLoop在boundedElastic后台固定4状态COUNT/MIN/MAX元数据采样与安全日志输出,正常与告警分INFO/WARN;不在模型请求或投递轮次加stats SQL。采样实际任务持有slot至SQL及export结束,订阅取消不释放仍运行任务的slot,慢SQL/日志导出不拖原投递循环。固定4个返回状态不代表数据库扫描CPU有界,实际索引/EXPLAIN/驱动timeout与远端存储pressure测量仍须后续环境验收;其它组件真实观察接线由T157–T164完成。 + + +T104管理入口:沿/dsh现有准入/浏览器配置与URL tab导航增敏感词控制;子租户管理员按真实授权链可编辑自身leaf_tenant_id(旧身份fallback tenant_id),global super才目录选择目标。leaf显示名与编号一致,身份标识变化重建面板。首次打开配置才取数;同DSH页切tab仅hidden保留目标与草稿,切回不丢输入或自动保存,遵守Tabs规范。部署或management关闭卸载业务表单。新增tab使390px溢出由旧5tab23px扩大到117px,已仅加TabsList外滚动容器收口,无全局组件视觉变化,键盘可达最后tab。真实账号授权仍由Gateway实时backend链核验,不信页面角色。T108组件接收可选父进度,T109既定Panel源传progress并接未关联页面,缺进度只报准备中,不按0计数假就绪。 + + +T167扫描端口:既有tree_pending_tenants可选include_group_pending默认False,旧调用不改变;内容安全Worker使用True,将已完成hierarchy但仅group_index_pending的后到安全事实纳入租户候选。仍以Tenant目录驱动相关EXISTS、只返回100个keyset编号、包含停用/归档,不扫描无dirty的RUNNING模型租户,不在Worker内写SQL或拉事实正文。 + + +T158身份诊断接线口径:HIT为实际成功L1/共享身份返回,MISS为一次真实profile订阅,MERGED为已有job额外准入等待者;负缓存拒绝不充成功命中或重复物理错误。profile latency在物理成功/失败/取消结束时采样,不含随后Redis发布,同次终止仅观察一次。Verifier从原锁内active/queue读标量,不按等待者cancel虚增可用槽。旧构造noop兼容,T139注入同一Telemetry;不新增profile/Redis调用、滑动TTL或更改身份/错误优先级。 + + +T162请求诊断:检查命中沿真实DECISION一次计数,后续DRAIN不重计;CHECK_ERROR独立于模型原业务错误、普通取消/关闭及SQL记录失败。bytes/check/drain沿原预算锁读取真实全局状态,HTTP未结束传输所持bytes仍保留。RECORDING_GAP仅原缓存persist失败点一次增量并保留固定告警;不改变模型响应、订阅数、用量或错误优先级,旧构造保持noop兼容。 + + +T160策略诊断:缓存年龄复用既有单调采样点及固定validUntil,不新增探针或延长L1/Redis有效期;编译active为原Semaphore真实已持有permit,更新与采样同锁避免旧样本覆盖新状态。固定busy/node/weight容量拒绝一次、输入400/413仍原语义,摘要不一致在实际拒绝边界一次告警;旧构造noop,生产Redis适配构造将同Telemetry传内部matcher。T050沿既有DSH Beat配置加入周期dirty扫描,所有任务事实与持久readiness复用既有API assembly,不新增模型运行时接收密钥依赖。 + + +T170 → Platform开发预优化须保留共享UI的包内依赖图;实际锁定的UI merge1.14与Platform merge2.6并存,Vite5按裸包名合并会删除UI语义字号类。optimizeDeps.include仅预编译@bisheng/ui,生产Rollup保持原包边界;不升级全站依赖或用业务样式覆盖设计组件。验证同时包含实际dev DOM、Platform原cname及生产三语合成预览,不能等同账号E2E。 + + +T052/T054 → 明细清理必须显式include_audits=False:即便租户已有另一次主审计cleanup_before及续跑游标,也只删本次明细窗口,保留主摘要和全部主清理checkpoint/cursor。主审计清理命令独立提交水位后才分批继续,默认不产生自动保留期。 + + +T175 → 两个事件请求UUID的显式筛选先校验完整UUID并lower,cursor绑定同一规范值;数据库列保持索引等值比较,不依赖MySQL常见ci collation,也不改用户已接受的默认相对时间窗口。 + + +T173 → Gateway全局legacy null serializer与新严格契约隔离:Outbox copy后的私有provider使用标准JSON null;DSH capabilities digest在局部DTO显式标准null。全局旧应用JSON配置保留,接收DTO与Platform parser不允许空串替代null;回归必须消费真实SpringCustomConfig mapper/codec,不能只测new ObjectMapper。 + + +T030保留期复审 → 持久receipt优先仅确认事实存在,不能授权其后再次投影旧missing主行。exact duplicate在归并前仍同事务检查租户水位;Expired时保留原COMMITTED receipt但跳过关联修复,保留窗口内或合法仍存旧主继续归并,新事实仍按原410拒绝。 + + +### 交付 work120 兼容说明(2026-10-24) + +T176:最终迁移链为 work110_dsh_local_sessions → f062_unified_model_access → f062_credit_multiplier → f111_dsh_content_safety。F111 尚未发布,接续当前目标唯一 head;两份已发布积分 revision 字节保持不变,由持久哈希回归保护。不能在组合版本保留两个 head 或修改已发布积分 DDL。真实旧版本升级与当前模型 fresh bootstrap 分别验收,不能用当前 create_all 冒充旧库。 + +T177:路由保留 work120 的 capabilities、skills、work、unified_policy、web-session 全部入口,新增独立安全回传;middleware 只增加精确 POST /api/v1/internal/dsh/content-safety-events,保留 web-session 原认证边界,无宽泛 internal 豁免。T178:当前管理页默认 credits,七个可见页签含 content-safety,operations 仍隐藏;非法 tab 回退延续目标逻辑。安全投影不参与积分倍率、原始用量、账单用量或余额结算。 + +T181–T184:bootstrap 夹具由完整模型/反射集合验收,目标已有日语 unified 文案和 sandbox 错误三语缺项独立补齐;不改旧翻译、冻结基线或界面视觉。 + + +T185/T186:Responses保留同item同channel各part的实际delta及显式done观察;完整item/final快照独立检查,不能覆盖已观察内容。相邻同类型part按索引拼接,reasoning.summary与content分开、不同item/tool隔离;part.done不提前解除末尾L−1屏障,直到完整item安全水位。只保留有界Unicode安全首尾并计入既有metadata/buffer预算,显式空观察不可当未观察。delta/done混合的候选后缀去重后每边界至多64,工作集同时计入剩余buffer,歧义或预算超限安全ERROR,不能指数增长或默认为PASSED。原32配对扩60,主独立Gateway882项PASS,另1800固定语义对照补充。该修复恢复AC-21、AC-22、AC-23,不改变spec或引入新配置。 + +T188–T191:策略局部QueryClient关闭原始error对象日志,仍保留页面失败反馈/草稿/CAS冲突;请求详情沿既有dl行独立显示model_started_at、未知或不适用,header与排序仍是稳定请求开始。两项候选审查MEDIUM均已配对RED/GREEN关闭,无新视觉模式。 + +T192:当前Desktop既有Undici兼容patch使用Object.create克隆handler/controller,会使private字段的回调方法接收者失配。完整回归实际三超时/unhandled错误;按宿主patch契约修原维护patch及原测试,不以直接改node_modules或依赖升级隐去此问题。联调期间真实客户端使用独立临时Profile,修改依赖前仅退出本轮进程。 + + +T197/T198实际治理边界补齐:`get_dsh_model_metadata_snapshot`只供已授权管理员的visibility GET/PUT,复用原始tenant/Root共享/模型类型/provider owner核查但允许offline;`get_dsh_model_snapshot`固定要求online,所有模型调用及目录仍用原口。使新模型可offline创建→私有范围CAS并GET确认→上线,避免为设置权限制造可公开调用窗口,不更改既定敏感词架构。 + + +现场测试合同(2026-10-27 更新):Gateway合成错误的公开X-Request-ID为Gateway UUID,转发前已分配的Backend ID另在X-Backend-Request-ID;新backend两者相同,普通转发正文保持原样。MODEL与EXTERNAL_SAFETY均复用统一UUID;旧backend显式返回不同编号时按模型响应关联,不混用历史两种编号。旧backend普通管理拒绝实际是HTTP200/业务19801,Gateway只对此已确证码精确映射403,未知业务失败仍503(T199/T200)。 diff --git a/features/v3.0.0/111-dsh-sensitive-word-control/e2e-checklist.md b/features/v3.0.0/111-dsh-sensitive-word-control/e2e-checklist.md new file mode 100644 index 0000000000..a74b915d70 --- /dev/null +++ b/features/v3.0.0/111-dsh-sensitive-word-control/e2e-checklist.md @@ -0,0 +1,127 @@ +# F111 E2E 执行清单 + +更新:2026-10-24。状态:**109单实例真实子集通过,完整特性仍PARTIAL**。 + +许可证已按用户追加授权由官方工具生成并实际有效至2026-11-23。真实协议35/35、身份/管理7/7、十二条审计记录的四模式只读复核4/4通过;实际Desktop两协议回复和read工具正反例通过。DM8、双租户、多副本、设施故障、无Gateway部署与容量/回退仍未执行,不能据此勾完整T121–T125或T132。以下AC包含多个观察点时仍保留PARTIAL,不把单个子集或本地测试当整条AC通过。 + +## 环境与安全边界 + +| 编号 | 必需环境 | +|---|---| +| E1 | 授权非生产backend、精确版本/迁移head、实际SQL及启动依赖、两个专用租户 | +| E2 | 一个或两个真实Gateway、合法现有License、相同backend/namespace/共享HMAC、Redis/词库/DDL/正有限既有HTTP响应预算 | +| E3 | backend通过真实模型准入与计量连接受控无费用provider;能够控制四协议模式、工具、推理、分片、坏帧与usage,记录物理调用数 | +| E4 | 两租户各管理员/普通用户及超管、正式平台登录和DSH原生PKCE会话、允许测试的模型 | +| E5 | 仅隔离实例的故障/重启/多副本控制及指标;不能在真实用户服务上断网、改时钟或停止worker | +| E6 | 当前Platform/Desktop真实登录与三语言界面、无副作用工具执行计数 | +| E7 | 实际MySQL和Linux DM8、明确schema/大数据资源配额/SQL与故障操作权限 | +| E8 | 公开入口/代理/防火墙实际拓扑及普通应用回归fixture;从客户端网络验证不可直连backend | + +原始Token、密码、HMAC、License均不进入文档/命令参数/日志。使用已创建资源前缀`e2e-f111--`、精确tenant及ID;setup/teardown均GET核实,只清理本套件对象。已有cleanup helper吞错且非完整分页,其返回不能当清理成功。不得为清理测试数据而批量删除真实审计、计量或未ACK事件。 + +## 入口与已有helper + +- M:`POST /api/v1/dsh/chat/completions`和`POST /api/v1/dsh/responses`,各流式/非流式;I:`GET /api/v1/dsh/profile`。 +- P:Gateway `/api/sensitive/dsh/settings`读写、`/api/sensitive/dsh/capabilities`;A:backend `/api/v1/dsh/admin/model-audit`下requests/settings/users/models及事件查询。 +- G:同A的conversation-groups/query-readiness;T:conversation-tree及children/location;U:DSH usage及管理员usage接口。 +- E:`POST /api/v1/internal/dsh/content-safety-events`,精确路径、严格JSON、共享HMAC;只有HTTP200+同event_id+COMMITTED才是接收成功。 +- UI:Platform `/dsh`;Desktop:真实Chat/Responses适配器与工具屏障;LEG:既有应用路由/词控;OPS:真实Outbox、索引worker和维护CLI。 + +复用`test/e2e/helpers/{auth,api,cleanup,dsh}.py`,原生登录用`native_desktop_session`正式PKCE。模型JSON/SSE、Gateway ResultData、内部Receipt和backend管理API的响应契约不同,不能统一套`assert_resp_200`。已有audit_diagnostics是合成组件,并非部署E2E;no_gateway示例会实际创建身份/调用配置模型,必须显式授权对应环境才运行。 + +T121/T122真实HTTP测试源已落盘,T123只读审计核心已实现并执行12条真实记录的四模式子集;源码存在不代表完整任务验收: + +- `src/backend/test/dsh/test_content_safety_protocol_e2e.py` +- `src/backend/test/dsh/test_content_safety_identity_e2e.py` +- `src/backend/test/dsh/test_content_safety_recovery_e2e.py` + +## 逐项验收 + +spec.md为AC原文唯一依据;下表只给执行观察点。每条应附真实环境、UTC时间、命令/用例、有限HTTP错误码、SQL持久读回及日志路径;界面/真实重启证据另附操作记录。涉及多个环境的AC必须全部满足,单方言结果只记PARTIAL。 + +| AC | 任务 | 入口 | 环境 | 必须观察的事实 | 执行结果 | +|---|---|---|---|---|---| +| AC-01 | T121、T125 | M | E1,E2,E3 | 四种协议模式,普通/内部用途/标题/compaction及完整历史均走检查;上游请求计数每轮至多一次。 | PARTIAL:四模式正常、历史/compaction/session-title语义已真实检查;独立无Gateway未验 | +| AC-02 | T121、T125 | M/P | E1,E2,E3 | 部署off、租户无策略、租户disabled三组分别对照;首装默认off;不能把policy不可用当disabled。 | PARTIAL:无租户策略默认DISABLED四模式与FINAL已验;部署off/开关切换未验 | +| AC-03 | T121、T125 | I/M/U | E1,E3 | 独立无Gateway部署执行原生PKCE登录→模型→用量;不改License/席位控制来制造通过。 | NOT_RUN | +| AC-04 | T124、T125 | M/UI | E1,E2,E6,E8 | 核对外部全部实际入口;受保护部署直接backend入口从客户端不可达;模型路径编码/matrix同入口检查,尾slash/邻近路径不扩匹配。 | NOT_RUN | +| AC-05 | T121、T125 | M/U | E1,E2,E3 | 四模式无命中对比直连受控基线原body/SSE顺序、工具/usage/error;固定JSON bytes不归一化。 | PARTIAL:四模式确定语义/usage80通过;原字节受控基线对照未验 | +| AC-06 | T122、T125 | P/M | E1,E2,E3,E4 | 同租户至少两模型验证一套启停/方向/内置+自定义规则和租户提示均生效;保存后GET确认。 | PARTIAL:同租户Chat8/Responses9真实规则及方向启用/命中;完整内置词/UI配置组合待验 | +| AC-07 | T122、T124、T125 | P/A/G/T/E | E1,E2,E4 | 租户A管理员允许A、拒B,普通用户均拒;超管允许有效可管租户、拒不存在/不可管目标。 | PARTIAL:实际admin允许/普通Gateway GET-PUT403且无变;跨tenant/超管范围未验 | +| AC-08 | T122、T124、T125 | P/I | E1,E2,E4 | 先用DSH成功profile填缓存,再用普通平台身份保存仍拒;撤管理权后保存拒绝,不能复用profile授权。 | PARTIAL:普通PKCE/profile/models不赋Gateway管理权,保存403;真实撤管理权未验 | +| AC-09 | T122、T125 | P/UI | E1,E2,E4,E6 | 无有效词、词/提示超限及非法值精确错误;内置+自定义都覆盖;GET未保存非法版本。 | PARTIAL:真实非法配置精确拒绝且GET原版本不变;全部上下限与UI未穷尽 | +| AC-10 | T122、T125 | P | E1,E2,E4 | 两个管理员基于同expected_version保存:一成功、一409冲突;GET最终唯一新版本。 | PARTIAL:真实旧expected_version精确409且GET保持唯一版本;双管理员并发未验 | +| AC-11 | T122、T125、T128 | P/M | E1,E2,E3,E5 | 长流在途保持旧快照;修改或关闭后新请求≤5s使用新版本,记录真实时钟和两副本版本。 | NOT_RUN | +| AC-12 | T122、T125 | P/M | E1,E2,E3,E5 | 两Gateway同版本规则/摘要一致;破坏可用词库或断policy读取时返回检查不可用,不透传。 | NOT_RUN | +| AC-13 | T122、T125、T127 | I/M | E1,E2,E3,E4 | 伪造tenant头/正文不影响profile身份;完整不同Token及不同backend namespace不串用。 | PARTIAL:两个真实PKCE凭据+refresh仍同user9/tenant1;namespace/跨身份缓存未验 | +| AC-14 | T122、T125、T127 | I/M | E1,E2,E3,E5 | 真实L1≤2s/Redis≤30s及token exp-5s;连续命中/跨副本读取不续期;检查cache不含原Token且日志无凭据。 | NOT_RUN | +| AC-15 | T122、T125、T127 | I/M | E1,E2,E3,E5 | 同凭据冷热多请求/多副本复用;profile次数与模型请求/流分片分开统计,分片增加不增加profile次数。 | NOT_RUN | +| AC-16 | T122、T125 | I/M/U | E1,E2,E3,E4 | 缓存命中后撤会话/模型权/额度;原始凭据仍转backend当前准入,不能用缓存绕过。 | PARTIAL:真实撤销已缓存会话后模型当前准入拒绝;撤模型权/额度组合未验 | +| AC-17 | T122、T125 | I/M | E1,E2,E3,E4,E5 | 真实注销/用户或租户失效使对应缓存失效且不重试模型;模型未授权/额度不足对照不删身份缓存。 | PARTIAL:旧refresh真实session_revoked;Gateway缓存/租户失效未验 | +| AC-18 | T122、T125、T127 | I/P/M | E1,E2,E3,E5 | profile/policy网络、格式、空结果及32在途/64队列满都503 check_unavailable,非账号错误/非关闭/非通过。 | NOT_RUN | +| AC-19 | T122、T125、T127、T128 | I/M | E1,E2,E3,E5 | 断Redis后仅有效L1或有界重新profile;过期失败不复用;统计有限等待/重试/日志,不持久凭据。 | NOT_RUN | +| AC-20 | T121、T125 | M | E1,E2,E3 | 输入/输出消息、reasoning、tool描述/参数/结果解码字符串命中;图像base64/加密reasoning/协议ID/audit_context不误扫。 | PARTIAL:真实完整历史/内部用途和两协议工具参数命中,图像/加密reasoning全部组合未验 | +| AC-21 | T121、T125 | M | E1,E2,E3 | 同消息连续块跨词命中;不同message/choice/tool独立域不拼词;大小写与既有字面规则一致。 | PARTIAL:真实相邻output_text/reasoning summary-content及空part/Unicode命中;全部域隔离未穷尽 | +| AC-22 | T121、T125 | M | E1,E2,E3 | 受控上游逐字节切UTF8/CRLF/multiline/data/escape/末尾残文,断言客户端从未收到完整命中词。 | PARTIAL:真实fragment与Unicode分片阻断,无完整词泄漏;CRLF/multiline/末尾残文全矩阵未验 | +| AC-23 | T121、T125 | M | E1,E2,E3 | Responses delta、done、completed及最终快照新增文本逐一检查,保持原SSE顺序/不把失败当completed。 | PARTIAL:真实Responses delta/done/final独立新增文本和reasoning均阻断;失败/异常全矩阵未验 | +| AC-24 | T121、T125 | M/Desktop | E1,E2,E3,E6 | 两个协议工具参数完整解码后才执行;命中/坏JSON/取消未完成时真实工具执行计数0;Desktop不能仅看HTTP状态。 | PARTIAL:两协议真实read各成功1且nonce匹配,敏感参数各零tool调度;坏JSON/取消现场未验 | +| AC-25 | T121、T125 | M/E | E1,E2,E3 | 请求/模型SSE/tool坏JSON、未知可交付结构、mixed error+choices均failclosed;审计ERROR不伪BLOCKED。 | NOT_RUN | +| AC-26 | T121、T125、T126、T128 | M/P/E/A | E1,E2,E3,E7 | 真实公布32MiB请求/累计响应、Responses8MiB事件、词库/并发等边界拒绝;无无限队列/未经检查透传。 | NOT_RUN | +| AC-27 | T121、T125 | M/A/U | E1,E2,E3 | 输入命中HTTP403;受控provider和backend模型调用计数0,用量前后不增;仍查独立安全记录。 | PARTIAL:真实四模式输入HTTP403、global/scenario0、无charge并有EXTERNAL_SAFETY;UI四模式未全验 | +| AC-28 | T121、T125 | M | E1,E2,E3 | Chat/Responses非流完整输出先检查;BLOCKED不交付原危险正文。 | PARTIAL:两非流协议真实完整输出先拦截、不泄漏敏感词;普通转发基线原bytes未对照 | +| AC-29 | T121、T125、T128 | M/Desktop | E1,E2,E3,E6 | 流式交付安全prefix后阻断;不交付完整命中词,无正常成功结束;Desktop停止该轮且零自动重试。 | PARTIAL:真实流式屏障与Desktop两协议CONTENT_FILTERED零调度/无重试;压力/所有取消场景未验 | +| AC-30 | T121、T125 | M/Desktop | E1,E2,E3,E6 | 输入/非流403 content_blocked+租户自定义原提示;Chat content_filter+[DONE]、Responses明确error;界面可见且不重试。 | PARTIAL:实际403/协议流错误保留合成租户提示,Desktop两协议可见;三语全UI仍未验 | +| AC-31 | T121、T125 | M/Desktop | E1,E2,E3,E5,E6 | 检查失败未开始503/已开始协议错误;backend鉴权、额度、供应商原错误原样区分;Gateway合成编号与backend编号口径正确。 | PARTIAL:真实公开/Backend两UUID口径已分离,普通管理拒绝403;供应商与检查失败现场全矩阵未验 | +| AC-32 | T121、T125、T128 | M/A/U | E1,E2,E3,E5 | 输出命中后backend确认真实usage继续结算;unknown永不改0;客户端停止交付/续读结束都不能当供应商停止。 | PARTIAL:实际输出命中仍raw30/50/80及SETTLED,流客户端结束可早于Provider排空;UNKNOWN/30s精确故障未验 | +| AC-33 | T123、T125 | M/A/E | E1,E2,E3 | 输入/输出实际终态逐一落审计;backend准入失败无模型输出为NOT_APPLICABLE;方向disabled不伪PASSED。 | PARTIAL:12条三场景原生/外部摘要及事件真实两读一致;ERROR/准入失败全组合未验 | +| AC-34 | T123、T124、T125 | A/G/T/UI | E1,E2,E3,E6 | 六阶段状态与请求汇总优先序;无事件/部分结果UNREPORTED;先BLOCKED后PASS不能清命中。 | PARTIAL:实际PASSED/BLOCKED/DISABLED独立模型执行和用量;完整六态UI/顺序未验 | +| AC-35 | T122、T123、T125 | M/E/A/OPS | E1,E2,E3,E4,E5 | 审计OFF/METADATA仍有最小安全事实;查Outbox/事件/日志字段无正文、匹配原词和凭据,原文仍受原审计权限。 | NOT_RUN | +| AC-36 | T123、T125 | M/A/UI | E1,E2,E3,E6 | 输入BLOCKED生成独立条目,模型调用/结果/交付/结算为N/A,无原文入口,不造零用量事实。 | PARTIAL:真实四模式输入BLOCKED独立条目/N_A且无usage;输入条目UI未全验 | +| AC-37 | T123、T124、T125 | M/A/E/UI | E1,E2,E3,E6 | 有效backend响应UUID关联其模型记录而非profileID;无/坏backendID保留未关联,不猜测。 | PARTIAL:真实四模式输出Backend UUID关联MODEL,输入Gateway UUID为EXTERNAL;坏Backend UUID/未关联现场未验 | +| AC-38 | T122、T123、T124、T125 | M/A/E/G/T | E1,E2,E3,E4,E5 | 事件先/原生先/并发合并一条;可信身份变更接受但不覆盖他人模型用量;未知模型执行仍UNKNOWN。 | PARTIAL:真实模型与事件关联、普通管理拒绝成立;跨tenant/可信身份变更并发未验 | +| AC-39 | T123、T125 | E/A/OPS | E1,E2,E5,E7 | 接收COMMITTED后真实backend进程重启仍可查询事件+摘要;纯易失排队不算ACK。 | NOT_RUN | +| AC-40 | T123、T125 | M/E/A/OPS | E1,E2,E3,E5 | 输出命中时先形成DECISION并可查询,不等30s续读/用量结束;DRAIN补充不能撤销命中。 | NOT_RUN | +| AC-41 | T123、T125 | E/A/G/T | E1,E2,E5,E7 | 重复/乱序/后到通过按request去重;同event异hash409;原生后到不重置命中;含exactduplicate保留期重放。 | NOT_RUN | +| AC-42 | T123、T125 | E/I/A | E1,E2,E4,E5 | 共享HMAC有效/坏签名/篡改/超来源租户范围及浏览器/DSH冒充拒绝;合法历史事件在用户退出后仍可补报。 | NOT_RUN | +| AC-43 | T123、T125、T128 | M/E/OPS | E1,E2,E3,E5 | Outbox写失败仍阻断并暴露固定告警/缺口;接收端断开产生积压后恢复;记录实际持久状态,不假完整回传。 | NOT_RUN | +| AC-44 | T124、T125 | A/UI | E1,E2,E3,E6 | 列表阶段/汇总可见;模型SUCCEEDED与输出BLOCKED可同时显示;UNREPORTED不是通过。 | NOT_RUN | +| AC-45 | T124、T125、T126 | A/G/T/E/UI | E1,E2,E4,E6,E7 | 命中/方向/状态+用户/模型/时间组合,在列表/详情/会话/目录均验证普通用户和跨租户拒绝。 | PARTIAL:实际会话日志含本轮HTTP及Desktop会话/命中0;完整树和筛选未验 | +| AC-46 | T124、T125 | A/E/UI | E1,E2,E3,E6 | 详情分别展示request/modelStart/occurredAt、版本/来源/方向/受限摘要/关联;外部only无原文入口,原文访问有权/无权配对。 | NOT_RUN | +| AC-47 | T124、T125 | G/T/A/UI | E1,E2,E3,E6 | 父子own/branch按命中请求去重,与异常数独立;external条目可增audit条目但模型调用/usage不增。 | NOT_RUN | +| AC-48 | T124、T125、T126 | G/T/OPS/UI | E1,E2,E3,E5,E6,E7 | 只改命中触发dirty重建;停worker/readiness、scan_limited明确;externalonly不误报stalled、不永久挡全租户ready。 | NOT_RUN | +| AC-49 | T124、T125、T126 | A/G/T/UI | E1,E2,E4,E6,E7 | 安全先到原生后到startedAt排序不动;跨tenant/改显式筛选cursor拒;默认相对7天不要求固定,补报刷新可见。 | NOT_RUN | +| AC-50 | T124、T125、T126 | A/G/T/UI | E1,E2,E6,E7 | 默认近7天、单次31天允许而超限拒绝并提示分段;多事件不增加主列表行数/命中请求数。 | NOT_RUN | +| AC-51 | T123、T124、T125 | E/A/OPS/UI | E1,E2,E5,E6,E7 | 隔离tenant执行purge-details保摘要且标PURGED;purge-audit水位后迟到新事件410、既有receipt仍COMMITTED且不得复活主行。 | NOT_RUN | +| AC-52 | T123、T125 | A/U/G/T | E1,E5,E7 | 真实旧schema/数据升级保存历史用量/未知结算与证据;无安全事件历史显示UNREPORTED而非PASSED。 | NOT_RUN | +| AC-53 | T123、T125、T126 | P/E/A/G/T/OPS | E1,E2,E5,E7 | 完整同套业务在真实MySQL和LinuxDM8各执行,记录方言/版本/迁移head/事务与查询结果;SQLite不可替代。 | PARTIAL:109 MySQL8.0.43四协议正常链路;LinuxDM8缺环境 | +| AC-54 | T124、T125 | LEG/UI | E1,E2,E4,E6 | 普通应用旧敏感词、原路由及响应与BlockRecord行为保持;DSH marker不进入旧词控,原非DSH不误用租户策略。 | NOT_RUN | +| AC-55 | T123、T125 | P/M/E/OPS | E1,E2,E3,E5 | 租户off/部署off/回滚新请求停止检查,在途快照不变,历史审计/usage/未ACK继续投递保留。 | NOT_RUN | +| AC-56 | T124、T125 | UI/Desktop/P/A | E1,E2,E3,E6 | 真实Platform+Desktop zh/en/ja设置/阻断/check_unavailable/N/A/UNREPORTED一致;租户autoReply不翻译,固定fallback按本机语言。 | PARTIAL:真实Desktop zh Chat/Responses与Platform审计可见;三语阻断/未知未验 | +| AC-57 | T121、T125、T128 | M/OPS | E1,E2,E3,E5 | hit起算独立≤30s;剩余响应bytes/8drain满/上游错误提前停,连接及buffer/额度归还;unknownusage保持unknown。 | NOT_RUN | +| AC-58 | T123、T125 | E/A/OPS | E1,E2,E5,E7 | 真实Gateway进程/实例重启后未ACK事实继续送;丢ACK重复只一份/计一次;永久拒绝隔离告警、未ACK不TTL删。 | NOT_RUN | +| AC-59 | T122、T125、T127 | I/M | E1,E2,E3,E5 | 同完整Token同backend冷/过期并发只一次物理profile;取消单等待者不取消共享请求;1s总预算与队列超限不重试模型。 | NOT_RUN | + +## 结果与缺口 + +- 完整AC:PASS0 / PARTIAL30 / NOT_RUN29。已执行子集:协议35/35、身份策略7/7、十二条审计四模式4/4、输入边界7/7,另49本地边界断言;Desktop两协议普通回复及工具正负、Platform English命中筛选和事件详情实际通过。完整故障、跨租户/多副本、三语、网络封闭、容量与回退仍未验。 +- MySQL持久事务与Gateway SQL恢复的独立结果见implementation-verification.md;它们不替代本清单。 +- Linux DM8实测按用户决定待验;AC-53不得标PASS。小表EXPLAIN与组件负载准备不得替代百万/千万容量结果。 +- AC-34按spec中优先表完整验证;任何已知命中先于失败/未知/关闭/通过,不把未知视为安全。检查结果独立于模型执行、交付和用量。 +- 默认相对分页窗口不冻结、接受可信身份变更、复用同一共享HMAC,均沿已确认取舍。 + +完整私有只读核查矩阵保留于`/private/tmp/f111-e2e-ac-matrix.md`,含原文和源码位置;本文件仅保存待执行观察点。完成T121–T124后再由T125逐项链接实际证据,未执行项不得勾选。 + +## 109实际执行记录 + +BiSheng工作120修复3cc9de9298ad2c6346f17d180105d46d2d3e3324经Drone5624 SUCCESS,Backend digest8c001dcc8c921ebf32c8e5c468d5368216c56b698a3511e2421f860713ee4392,frontend digest与5623相同;Gateway64f602be经404 SUCCESS,当前digest63d7d9ed已部署。运行用户9、模型8/9仅该用户私有,server5为本轮独立合成Provider。既有默认模型和其他credit规则读回不变。 + +四协议请求:6fa33453-5e38-4b7a-9e01-ce0d6e4efd04(Chat非流)、ded3d432-70cf-4d70-9cdd-af1464fd4770(Chat流)、29e07447-9dc9-48b0-8d67-1aec5b9265d9(Responses非流)、ef086b78-4643-42ea-9aa1-4d20f28a46c6(Responses流)。均HTTP200/Provider delta1/原生MODEL SUCCEEDED/SETTLED/raw80,安全DISABLED;详情各AVAILABLE/一个FINAL,未把列表保守PENDING误判丢事件。 + +T122历史基础子集(后续最终全7通过):`test_content_safety_identity_e2e.py -k 'not policy and not revoked_native'`,4PASS/3deselected,3.22s。旧access/profile/models与旧refresh均session_revoked;只撤销各case新建session。没有注销实际已登录Desktop或已提供平台会话。日志保留私有0600,文档不含凭据。 + + +本轮执行日志均在/private/tmp/f111-release-109并0600保存:protocol-live-final、identity-live-final、recovery-twelve-live各log/XML;desktop-tools-live-results.json与四份持久事件、真实截图。记录不含原始凭据,错误首轮与修复单案日志保留。Gateway404更新后capabilities正常,测试策略按CAS恢复原关闭状态。 + +T204/T205最终56PASS/78.03s(49local+7live),boundary-live-final.log/XML;请求解析拒绝无Backend header/无Provider/无审计/额度不变。PlatformEnglish实际命中筛选+Conversation精确筛选唯一会话,展开唯一匹配请求与DECISION/DRAIN详情;最终策略恢复原disabled/version111,其他容器身份复核不变。 + +最终B5625 exactd5b026557成功且实际109 backend/worker固定digest151d35b0;真实Backend重启后原12条审计读取4PASS/13.14s,四协议稳定烟测4PASS/40.27s。保留重建窗口业务失败和烟测teardown503;先CAS核自有版本恢复,再按Backend业务ready→Gateway连接刷新→三次capabilities成功顺序收口。最终策略原关闭字段、默认模型/审计配置和非DSH容器身份复核保持;许可有效。该事实不替代完整故障恢复/多副本验收。 diff --git a/features/v3.0.0/111-dsh-sensitive-word-control/implementation-verification.md b/features/v3.0.0/111-dsh-sensitive-word-control/implementation-verification.md new file mode 100644 index 0000000000..97a6bf7606 --- /dev/null +++ b/features/v3.0.0/111-dsh-sensitive-word-control/implementation-verification.md @@ -0,0 +1,1112 @@ +# F111 实施验证与任务审查 + +- 首次记录:2026-10-23;更新至:2026-10-24 +- 执行范围:用户于2026-10-24新增授权合入最新work120并推送,经Drone构建后仅部署109 DSH13001并启动当前客户端联调;历史“不提交/推送/部署”描述仅指当时阶段。 +- 基线:B 2e6b5ae0;G 2995df6f;D 01e09d0,工作树及完整提交见 tasks §2。 +- 当前阶段:核心实现及work120兼容候选源码审查完成,任务179/196;T119/T120的MySQL子项已独立通过,Linux DM8、完整授权E2E、容量与演练尚待验收。下文按实施顺序保留历史证据,早期“尚未执行/尚未实现”描述仅指当时阶段,最新结果见末尾记录。 + +## 1. 执行记录 + +| 任务 | 已执行验证 | 任务自审 | 状态 | +|---|---|---|---| +| T007 | 修改前扫描后端全部 Python AST,26170–26175 无占用;错误唯一性测试 1 passed;六个注册项 HTTP/ClientCode/消息检查通过;Ruff 与 arch-guard 通过 | 基础设施:分层、模块 261 五位码、静态安全消息、无敏感值、design §6.2 同步均 PASS;文件范围 1,依赖无 | 完成 | + +T007 命令(cwd=src/backend): + +```bash +PYTHONDONTWRITEBYTECODE=1 /Users/zhangguoqing/works/bisheng/src/backend/.venv/bin/python -m pytest --confcutdir=test/dsh -p no:cacheprovider test/dsh/test_error_code_uniqueness.py::test_dsh_error_codes_are_unique -q +PYTHONDONTWRITEBYTECODE=1 /Users/zhangguoqing/works/bisheng/src/backend/.venv/bin/python -m ruff check --no-cache bisheng/common/errcode/dsh.py +bash ../../scripts/arch-guard.sh "$PWD/bisheng/common/errcode/dsh.py" +``` + +新代码的三语 api_errors 覆盖由既定 T130 生成验证,当前不声称该项全量检查通过。初次 Ruff 因功能工作树写缓存受沙箱限制失败,改用 --no-cache 后通过,不修改工具规则。 + +## 2. Wave 1 初始环境与当时未执行验证 + +新工作树没有预装依赖。现有主仓 Python 3.11 环境仅只读调用,禁止改动其依赖;独立 Java/Maven 与前端环境准备情况将在任务结果记录。真实 MySQL、Linux DM8 的 fresh/upgrade/回退、E2E、负载以及部署均未执行,不能由离线编译或 SQLite 结果代替。 + +## 3. Wave 1 与 T016 基础设施验收 + +Wave 1 的 17 项(原 T001–T015,及回归补充 T133/T134)与 T016 已完成;这是基础设施交付,不代表模型拦截、回传 API、策略界面或 Desktop 行为已实现。 + +| 任务 | 实际验证及交叉自审 | 结果 | +|---|---|---| +| T001–T004 | 7 表/32 索引分别经 MySQL 方言及 Linux 原生 dmPython 2.5.32 / dmSQLAlchemy 2.0.12 编译;默认值、UNKNOWN、来源 CHECK、显式租户登记及持久水位检查;主 agent 对照模型与 DDL | PASS | +| T005–T006 | 30 项合成 DTO 断言;既有审计上下文 21 tests;严格字段/版本/ID/UTC/64KiB/深度/摘要/提示清洗;Java 纳秒字符串原样保留;返回 DTO 与可空默认检查 | PASS | +| T008 | 29 个增量列 DDL、4 个索引及 mode CHECK 与模型一致;单 head;独立 agent 38 项结构与重复升级检查;持久化数据库回归 24 passed;DDL-only、两目标文件和拒绝破坏性 downgrade 审查 | PASS | +| T009 | fixture smoke 覆盖 JSON 持久化、唯一约束、可信身份变化、严格租户读写、提交失败回滚、监听器/上下文恢复;未启动模型 runtime;既有审计等 114 tests 通过 | PASS | +| T010–T011 | Maven 编译 99 源文件;Spring 配置边界、MyBatis 映射、不可变快照 31 断言;无新增密钥/凭据;实体与 DDL 列对齐 | PASS | +| T012–T013 | Java/MySQL/DM 静态列对齐(策略 11、Outbox 15);整体判空先于所有 DROP,默认拒绝缺少停写确认;MEDIUMTEXT/CLOB 与 UTC 微秒同义;真实库 fresh/upgrade/回退留 T120 | PASS_WITH_NOTES | +| T014–T015 | 新类型严格编译、定向 ESLint;Platform 551 strict files 通过;冻结离线安装和 v4=4.44.0/v3=3.39.3 共存;锁仅新增既有包直接引用 | PASS | +| T133 | 两项专项测试先复现 source_id AttributeError,再修复显式别名/阶段映射;旧 UNKNOWN 保留、外部可空/N_A 合法;未查询明细不声称 AVAILABLE;114 tests 通过 | PASS | +| T134 | 既有本地化 UNKNOWN 接受空值,不产生虚构失败;无样式或翻译变化;Platform typecheck 和相关 Card/Panel/Group/Tree 30 tests 通过 | PASS | +| T016 | 上述一致性验收;保留原 main 83 个迁移字节哈希校验,允许后续合法链路并锁定 work110→F111;增补结构一致性、重复升级、大小写约束反射及安全回退测试;17 个变更 Python 文件 Ruff/lint/format 与 arch-guard 通过 | PASS_WITH_NOTES | + +基础设施逐项按 task-review 的适用清单检查:分层、数据库约定、信息泄漏、设计同步及目标文件范围通过;声明任务无前置业务测试要求。T133 采用既有回归确证缺陷的红转绿轻量兼容修复,未扩展到后续完整查询;T134 是既有类型消费者的最小修复。所有实体/DTO/字段细节已同步 design §9,任务粒度不超过两个人工目标文件。 + +补充命令(cwd=src/backend,Python 为前述只读 3.11 环境): + +```bash +python -m pytest --confcutdir=test/database -p no:cacheprovider test/database/test_alembic_single_head.py test/database/test_alembic_dsh_main_upgrade.py test/database/test_dsh_audit_work110_migrations.py test/database/test_alembic_online_helper.py -q +config=/private/tmp/f111-synthetic-config.yaml python -m pytest -p no:cacheprovider test/dsh/test_model_audit_setting.py test/dsh/test_model_audit_groups.py test/dsh/test_model_audit_tree.py test/dsh/test_foundation.py test/dsh/test_model_audit_contract.py test/dsh/test_model_audit_contract_api.py test/dsh/test_model_audit_flow.py test/dsh/test_model_audit_tree_http.py test/dsh/test_model_audit_user_names.py test/dsh/test_audit_response_diagnostics.py -q +``` + +临时合成配置仅含 `{}`,没有复制真实凭据。独立 JDK/Maven 位于 /private/tmp/f111-gateway-tools,编译/68 项既有 Gateway 回归/31 项 smoke 日志在同目录;backend fixture 与原生 Linux 方言编译脚本位于 /private/tmp/f111-wave1-smoke,DTO 断言脚本为 /private/tmp/f111_dto_smoke.py。这些 smoke 不等于真实双库执行;DM JsonType 的实际编译输出是 TEXT,真实存储行为仍待 T119 验证。 + +## 4. 完整前端质量门禁的既有基线问题 + +完整 workspace `pnpm lint` 和 `pnpm typecheck` 均已实际执行,**当前失败,不能标绿**。环境最初缺失 Client 的 tsc,补齐全 workspace 冻结离线依赖后已排除这一环境问题。 + +- Platform lint:93 errors,6 个未修改文件。 +- Client lint:21 errors,2 个未修改文件,另有过期抑制提示。 +- Client typecheck:useAiChat.test.ts:294 缺少既有 ChatMessage 必填字段,1 个既有错误;Platform 551 strict files 与独立 file-viewers typecheck 通过。 +- UI 与 file-viewers lint 通过;本次变更类型/Card 的定向 ESLint 通过。 + +逐个报错源文件、相关 ESLint/TS 配置及抑制文件均与 HEAD 字节一致;关联 ChatMessage 定义与 manifest 同样未变。证据在 /private/tmp/f111-{platform,client}-eslint-baseline.json 及对应原始报告。未修改抑制清单或开展无关代码修复;全特性 T132 仍须如实报告这些未通过门禁。T016 的 PASS_WITH_NOTES 仅表示已完成它声明的基础设施一致性验收,不把完整 workspace 门禁视为通过。 + +## 5. Wave 3 前置测试与三语文案 + +| 任务 | 已执行证据 | 自审与状态 | +|---|---|---| +| T017、T019 | 82 项预期 RED、7 项旧 SSO 检查 PASS;失败仅来自待实现 HMAC 29 项和 reducer 53 项;36 阶段组合、旧命中、重复/终态/纯函数均有行为断言 | 测试任务命名、AC 全编号、文件范围 PASS;未写实现桩,测试任务完成,行为仍待 Wave 4 | +| T055、T061、T065、T077、T083 | 五份 Java 测试语法/空白检查通过;Maven 在 testCompile 因配对生产类缺失而预期 RED;T083 单独编译仅两处缺失被测类,真实 HTTP/Mockito SQL 边界 | 测试自审 PASS,尚未执行生产行为断言;日志 /private/tmp/f111-gateway-tools/wave3-red-final.log | +| T099、T100、T101 | 每语言新增 119 个 contentSafety 叶键、6 个错误码;三语键/插值/唯一 JSON/原文不变检查 PASS;后端错误与三语覆盖 4 tests PASS;locales check 与 check-i18n PASS | 实现任务 i18n/文件范围/设计同步 PASS;生成器更新 6 个 api_errors 产物,未手改;不替代 T130 | +| T111 | 标准 npm test 完整执行 Office 准备和 PPT build 后进入 Vitest,18 项预期 RED;直接 Vitest 定向执行也为同样 18 RED(缺少归一化、文案导出与声明);node --check PASS | 测试任务 AC/命名/文件范围 PASS;只新增指定 test;日志 /private/tmp/f111-desktop-copy-red.log | + +Desktop 独立 worktree 的 npm ci 离线模式缺包后,使用正常 npm ci --prefer-offline 成功;postinstall 补丁与品牌资源准备成功,没有修改真实 Profile 或启动桌面应用。标准 npm test 的 Office/PPT 前置准备是构建产物,不能替代安全适配器实际 UI 验收。 + +接口补充:profile 成功后 Token 的 exp 只能缩短缓存寿命;opaque/无法解码 Token 的可信 profile 可用于本请求但不可缓存。T056 因此补充上下文的 nullable expiresAt 表达未知,禁止伪造截止时间。可选会话提示异常只能清洗/丢弃关联提示,仍记录核心安全事实,不能因为提示无效丢失命中事件。 + +## 6. Wave 4 配对实现与交叉审查 + +| 任务 | 实际验证 | 任务自审 | +|---|---|---| +| T018、T020 | HMAC/reducer 配对与旧 SSO 94 passed;相邻扩展回归共 152 passed;Ruff、架构检查通过;旧 verify_hmac AST 行为保持 | 分层、命名、纯函数/序列化、信息泄漏、设计同步 PASS;配对测试和依赖已完成;数据库/前端项 N/A | +| T056、T084 | Profile 24 + Proxy 9 = 33 passed,真实本地 HTTP 覆盖身份解析、超时、无重试/重定向、原始流/取消生命周期;未知 exp 用 null 表示 | 文件范围、依赖、配对测试、无业务凭据日志、设计同步 PASS;不在模型后端接入网关 | +| T062、T066、T078 | Policy 8 + Admin 8 + Outbox 9 = 25 passed;条件 CAS/租约 SQL、显示租户条件、不可变快照、提示清洗、64KiB/深度和失败保守行为审查 | PASS;SQL 端口使用 Mockito,真实数据库并发与事务未执行,不能由本项代替 T119/T120 | +| T112 | 文案配对及既有 enterprise-package 28 passed;node --check 与 npm run typecheck PASS;独立 agent 复审无 HIGH/MEDIUM 发现 | 命名、三语固定文案与租户提示分离、白名单代码、无关账号行为保持、两目标文件、设计同步 PASS | + +主 agent 在全部 Wave 4 文件完成后使用准确测试类名重新执行 Gateway 11 个测试类,Surefire 合计 **126 tests / 0 failures / 0 errors / 0 skipped**;日志 /private/tmp/f111-gateway-tools/wave4-parent-exact.log。其中 58 项新行为 + 68 项既有回归。 + +Backend 与 Desktop 日志见 /private/tmp/f111-desktop-copy-green.log、/private/tmp/f111-desktop-typecheck.log;Gateway 子集日志为 wave4-b-tests.log 与 wave4-final.log。任务 review 按七项清单逐项适用检查,依赖和目标文件检查 PASS,详情同步 design §9。本 Wave 尚未接线模型拦截入口、审计回传 API 或管理页面。 + +用户于 2026-10-23 明确目前没有 DM8 测试环境,DM8 实测保留待验收。继续不依赖它的实现和当前环境测试,后续真实双库检查保持未完成。 + +隔离 MySQL 环境准备:新建仅监听 127.0.0.1 随机端口的 f111-dsh-mysql-isolated 容器,镜像 mysql:8.0,实测版本 8.0.46,SELECT VERSION() 连接检查通过。凭据为临时生成的合成测试值,未复用真实账号。当前仅完成环境准备,没有执行 F111 DDL、业务迁移或 T119/T120,不能勾选真实库验收。 + +主 agent 补跑 HMAC/reducer/旧 SSO 精确三个测试文件:94 passed(/private/tmp/f111-wave4-parent-backend-exact.log)。首次命令的旧 SSO 路径误写为不存在目录而未收集,已定位实际 test/sso/test_sso_hmac_auth.py 并纠正重跑,未把首次执行视为通过。 + +## 7. Wave 5 先行测试 + +- T021:19 项预期 RED,全部因为待新增 content_safety Repository 不存在。主 agent 逐行读取 327 行测试,检查哈希原始值/缺失-null、严格租户、默认保留、有效旧主行豁免、已提交重试及真实 SQLite 唯一键异常控制流;不把 SQLite 当双库证明。Ruff、文件范围、AC/命名、依赖与测试子集 review PASS;日志 /private/tmp/f111-wave5-repository-red.log。 +- T057:414 行,26 测试方法/38 预定用例。Maven 和定向 javac 预期 RED;后者恰有 3 处缺少 DshIdentityCache,无其他编译错误。主 agent 读取测试,覆盖原子 value+PTTL、非滑动寿命、读取耗时、Token skew、负缓存、坏缓存和完整 Token 键;review 的 AC/命名、文件范围、依赖 PASS。日志 /private/tmp/f111-gateway-tools/t057-red.log 与 t057-focused-javac.log。断言尚未在生产实现上执行。 + +复用匹配器的实际缺陷:现有 ACPlus 对 words=[acbcc,bcx,cca]、text=acbcca 仅返回 acbcc@0,漏掉 cca@3。主 agent 在当前真实编译类上执行最小 Java 脚本 /private/tmp/f111-ac-minimal.java 复现;不是 mock 推断。T063 加入回归,T064 仅在 DSH 匹配器构建环节修正完整 failure-chain,继续复用 ACPlus,不扩大为旧应用过滤器重构。 + +T063:349 行、20 项前置测试;主 agent 完整阅读目标文件,命名/AC/信息安全、单文件范围及依赖复核 PASS。Java 17 语法和 git diff --check PASS;Maven 预期 RED 仅缺配对生产类及其类型,日志 /private/tmp/f111-gateway-tools/wave5-red-final.log。包含词、多层失配、150 固定 seed 独立 substring oracle、1MiB/30k/256码点、2编译/200k节点/权重、缓存5秒及旧读版本覆盖均有断言。生产断言尚未执行。独立临时 Java 探针 AcRepairProbe.java 验证局部 BFS 失配链修正可使真实 ACPlus 对这150组与oracle一致,不以临时探针冒充 T064 实现。 + +补充真实 Java 有界性复现:/private/tmp/f111-ac-bounded-repro.java 在 -Xmx64m 下,256 个长度 1–256 的合法 a 前缀词与 10000 个 a 字符触发旧 ACPlus.indexOf 的 OutOfMemoryError,探针捕获并明确输出。该结果只证明无界命中列表的放大,不是完整网关堆容量测量;T064 收集阶段有界修复及 T070 扫描契约需验证。 + +## 8. Wave 6 配对实现 + +T022:前置 19 项全绿,扩展仓储/状态/HMAC/设置/分组/树/基础回归 166 passed;主 agent 独立运行仓储/HMAC/状态/旧SSO/分组 138 passed,日志 /private/tmp/f111-wave6-parent-backend.log。额外合成检查覆盖错租户/bypass在SQL前拒绝、错归属不能豁免保留、UUID统一小写存储且原始wirehash保持。Repository不提交事务;唯一竞争的IntegrityError传播由T030统一恢复。主 agent 逐行读唯一目标文件,task-review:#1分层PASS、#2既定命名PASS、#3内部返回N/A、#4数据库/UTC/严格租户PASS、#5前端N/A、#6信息泄漏PASS、#7设计同步PASS;范围/前置测试/依赖PASS。Ruff格式/检查/F821、arch-guard、空白检查通过。没有真实库并发证明。 + +保留闸门为设置行 FOR UPDATE,与清理水位互斥;可能串行化同租户回传事务,真实库并发/负载阶段须观察,当前不移除互斥以追求单元吞吐。原始 UUID 大小写会导致不同wirehash,存储编号统一小写;Receipt后续应保持请求event_id响应契约。 + +T058:真实 JUnit 定向运行全部 38 用例,38 successful / 0 skipped / 0 aborted / 0 failed;日志 /private/tmp/f111-gateway-tools/t058-private-compile.log 与 t058-private-tests.log。当时同 Wave 词库类型尚未写完,Maven 全 testCompile 被其缺类阻断,因此使用私有 classdir 编译实际 Profile/配置/上下文/Cache 依赖并运行原 JUnit 测试(无 stub、无改断言);Wave 6 结束前仍需常规 Maven 合并回归。主 agent 逐行读取唯一 305 行目标文件,严格JSON/4KiB/时间和PTTL边界、完整Token散列、4096本机容量、boundedElastic Redis I/O、安全日志与绝对TTL复核 PASS。测试/依赖/文件范围及task-review适用项 PASS;T060分布式 owner fencing 和请求合并尚未实现,本任务不声称已有。 + +Wave 6 补充复核:T058 最终取样修正后 38/38 再次通过;独立 agent 以SHA-256 1485bbe169cd0c6bd5639aced960e2985f1569bc2a79646c17a8a3156466900a 版本只读复审,没有新 HIGH/MEDIUM 问题。T009 fixture 及 T133 新增测试 docstring 的两处中文 AC 注释改为 English Coverage AC,业务 literal 未改;Ruff及关联 groups/repository 44 passed。 + +独立 Redis 环境准备:新建 f111-dsh-redis-isolated,仅监听127.0.0.1随机端口、合成凭据,实际Redis7.2.16/Linux aarch64,INFO/PING通过。未使用已有Redis,无FLUSHALL。真实cache/Lua小验证正在准备,当前不能据此宣称端口行为或多副本预算已验收。 + +T064/T063 收尾:前置测试增补至31项(原20项,加实际漏检/重叠摘要/非终端链/绝对发布时间/停用上限/Unicode/已提交保存/元数据计权等回归)。修改前故障注入确认RED,修复后31/31 PASS;日志 wave6-policy-review-final.log,RED为wave6-save-review-red.log与wave6-save-status-red.log。既有GenericJackson Redis模板参数加引号、迟到SET PX延寿、CAS后编译忙导致已提交误报失败、停用元数据漏计、原始超限/Unicode绕过和413错误码均已修复。 + +主 agent 逐行读取两目标生产文件,独立agent用真实service/matcher探针复现两项P2后修复;任务review #1分层PASS、#2命名PASS、#3内部不可变record序列化PASS、#4显式租户/CAS边界PASS(无新增SQL/DDL)、#5前端N/A、#6无原文/凭据诊断PASS、#7设计同步PASS;配对/依赖/范围PASS。git diff --check PASS。T064生产2文件,增补测试归原T063单文件,未改全局AC、Redis模板或其他业务文件。 + +最终主 agent 常规 Maven 重跑13个准确测试类:**195 passed / 0 failures / 0 errors / 0 skipped**,日志 /private/tmp/f111-gateway-tools/wave6-parent-final.log;其中本轮Policy31+Identity38,其余126既有选择。同版本真实隔离Redis策略验证PASS(wave6-live-redis.log):默认GenericJackson模板隔离为专用String模板、PTTL原子读取、int64版本CAS、同版本不续期、刻意迟到PUT被拒绝、Pub/Sub真实回调。身份缓存真实Redis补充27断言PASS,/private/tmp/f111-redis-test/cache-smoke.log,仅创建并清除6个明确临时key,无FLUSHALL。它们不等于T060多进程owner fencing、MySQL/DM8或部署验证。 + +Wave7必要补齐T135:实际AdminClient返回Void但Store要求updated_by>0,禁止从客户端伪造。新增两文件兼容任务,先红后绿追加可信管理身份返回,原授权行为保持;T068仍两文件。总任务135项,当前完成43项。capabilities的目标授权和homeUrl Origin约定同步design,未新增配置、backend网关感知或放宽管理范围。 + +## 9. Wave 7 前置测试与必要身份补齐 + +T069:仅新增 DshSensitiveScannerTest.java,262行、17普通测试+4参数用例(21预定执行用例,另含逐切分循环);覆盖 AC-20、AC-21、AC-22、AC-25、AC-26、AC-35。UTF16绝对水位、Unicode码点尾部、跨UTF8字节/高低代理项、JSON解码、同域连续/异域隔离、末尾完成、单调水位、阻断终态与有限摘要均有断言。Java17语法PASS;定向javac预期RED,仅1处缺少待实现Scanner类,无其他诊断,日志t069-focused-javac.log。独立标准库fixture小验证PASS:所有UTF8切分重组、真实JSON转义和代理项字面量正确,t069-fixtures.log;未添加生产桩。task-review测试子集:命名/英文完整AC/单文件/依赖PASS,前端N/A;配对生产行为尚未执行,不以语法检查冒充通过。 + +T023:484行23用例,主agent完整读取测试;覆盖保存事实→摘要同事务、输入外部/输出编号未知、不猜关联、原生优先、可信身份变化、会话dirty、幂等和计量守恒。主agent组合运行结果 **23预期RED+72PASS**,23项均缺投影Repository;/private/tmp/f111-wave7-parent-backend-red.log。唯一键故障注入仅验证回滚,不声称SQLite复现双库写锁竞争。Ruff格式/检查、十类合成事件及真实存储fixture smoke PASS。 + +T059:428行17方法/18预定用例;真实Cache/Profile回复及Redis原子存储边界double,未实现测试coordinator。32在途/64等待、取消/队列/总deadline、Redis故障、完整Token/namespace、旧owner迟到成功与401均有公开结果断言。私有javac仅4处缺Verifier,t059-focused-javac.log。T067:327行17前置测试,HTTP standalone使用真实AdminClient+本地backend响应;TS扁平DTO、8限额、实时授权、Origin、可信操作人、409/400/413/503和失败信息不泄漏覆盖;私有javac仅1处缺Controller,wave7-controller-red.log。主agent逐行读取两文件,测试subset review命名/英文完整AC/单文件/依赖PASS。 + +三份Gateway新测试集中Maven预期RED:/private/tmp/f111-gateway-tools/wave7-parent-red.log;编译先在Verifier/Scanner缺类处停止,Controller独立RED另有证据。生产行为尚未执行,未添加stub或把测试double当实现。 + +T135:原8+新增3实际本地HTTP测试 **11/11 PASS**(wave7-admin-final.log),读取backend UserRead.user_id正int64,缺失/非法或header自报不能替代;旧authorize.then()兼容。主agent逐行审查生产客户端及新增测试,分层/序列化/身份信任/安全诊断/范围/依赖/设计同步PASS。最初全新JVM首HTTP受Netty类冷加载导致1秒超时,已将无网络的HttpClient.warmup置于test setup,未放宽生产预算;冷JVM重跑11/11通过。该观察保留供后续启动/冷验证测试,不把warmup当真实延迟指标。T135只两文件,T067另一个文件,原全局权限逻辑不变。 + +## 10. Wave 8 配对实现(2026-10-24) + +主agent集中backend回归:165 passed,日志 /private/tmp/f111-wave8-parent-backend.log。主agent集中Gateway常规Maven16类:261 tests / 0 failures / 0 errors / 0 skipped,日志 /private/tmp/f111-gateway-tools/wave8-parent-final.log,逐份Surefire XML核对。首次集中testCompile在新增取消释放测试的Flux泛型处失败,负责人修正并完成私有23项,再执行本次集中回归;未把首次失败算通过。 + +T024:190行唯一目标Repository;23项配对测试全绿,连同相邻回归160通过、主agent组合165通过。已保存事实语义对齐、严格租户、可信身份变化、未知编号不关联、同事务摘要/事实CAS、固定started_at和模型计量守恒均有断言;Ruff/arch-guard通过。主agent逐行审查:#1分层PASS、#2既定Repository命名PASS、#3内部返回N/A、#4严格租户/UTC/显式写条件PASS、#5前端N/A、#6信息保护PASS、#7设计同步PASS;单文件/配对/依赖PASS。SQLite仅合成事务测试,真实唯一竞争仍由T029/T030及双库任务覆盖。 + +T060:Verifier199行、Cache403行;私有fresh javac/JUnit38+18=56全绿并纳入集中261。实际Redis缓存兼容27断言PASS,/private/tmp/f111-redis-test/t060-cache-smoke.log,精确键清理;该smoke未证明多进程租约。主agent读取两个完整文件,复核固定deadline、32/64预算、原子排队取消、过期任务不复用、先取消源再释放槽位,以及发布/删除/释放owner fencing;旧401不污染新身份。范围/前置测试/依赖PASS,适用task-review分层/命名/不可变身份/安全/设计PASS,数据库及前端N/A,无原始Token日志。 + +T068:Controller+Request两个生产文件;原T067测试增加rawbody与真实池化缓冲回归至23项,私有和集中均全绿。先Origin/实时身份授权再订阅聚合;8MiB原始JSON/1MiB解码词库、严格UTF8/深度16/重复键/尾随值、未授权不订阅、最坏六倍转义合法词库、成功/解析错误/超限/取消refCnt归零均已验证。私有日志wave8-body-final.log。主agent读取完整生产文件和新增测试,task-review范围/配对/依赖及适用命名/序列化/信息保护/设计同步PASS;无前端/SQL改动。缺失可选Service的HTTP行为已测,完整Spring条件装配仍由T098验证。 + +T070:唯一Scanner生产文件,22项JUnit全绿并纳入集中。错误状态重开回归先RED后GREEN,t070-error-state-red.log、t070-tests.log;主agent逐行自审、AgentA独立读取Scanner/全部测试/CompiledPolicy.scan,未发现确证HIGH/MEDIUM。UTF16水位与码点尾部、跨片surrogate、独立域、完成/阻断/错误终态和有限无原文摘要一致。task-review范围/配对/依赖、适用命名/安全/设计同步PASS;后端/前端/数据库N/A。 + +四项完成后更新52/135,进入Wave9。MySQL只完成隔离环境准备,DM8用户确认暂无环境,真实双库、跨进程、部署和性能结果仍未执行,不以当前测试替代。 + +## 11. Wave 9 前置测试(2026-10-24) + +T025:唯一423行测试,21项中13预期RED/8PASS;红测由真实upsert_request不能提升EXTERNAL_SAFETY触发。真实Capture、Writer、Repository、EncryptedStorage配合成SQL/对象端口,未写业务桩。回滚测试的故障标记仍pending明确证明尚未到达事务提交点,不将当前红测误称回滚通过。相邻52PASS;主agent集中新增与既有165项,结果13预期RED+173PASS(/private/tmp/f111-wave9-parent-backend-red.log)。主agent逐行读取;Ruff和task-review测试subset/English完整AC/单文件/依赖PASS。 + +T071:唯一输入测试266行,23普通+3组参数化16例,共39预定调用。javac及集中Maven预期RED仅缺DshInputSafetyAdapter类型,日志t071-red-javac.log、wave9-parent-red.log。覆盖全历史/连续块与独立域、JSON解码键值、两协议根类型、可读reasoning/refusal/citation、opaque图像和密文/审计提示、Unicode、原始bytes与错误400/invalid_request。外部事件深度16不误套模型工具参数,支持20层合法嵌套。 + +AgentA独立审查发现三项MEDIUM测试问题:原图像fixture非现有inline协议、缺Chat根string/array与Responses object差异、Unicode坏hint未明确断言核心blocked。已修正并由主agent使用真实B schema现场验证2图像+4根类型行为;补断言后重新javac,仍仅缺配对生产类型。输入超限按design§6.3为400/invalid_request,不把管理词库413口径套入模型请求。主agent测试subset命名/AC/依赖/范围PASS,无生产桩。 + +T073:唯一387行,27普通+参数化5例=32预定调用;私有javac仅缺Chat adapter的5处类型引用,t073-javac-red.log。主agent完整读取,原bytes/完整事件水位/usage队头/独立choice和reasoning/完整工具JSON/阻断及取消错误/非流字节均有断言。backend原有错误保留原语义,未完成检查state仍ERROR;已BLOCKED优先。测试subset范围/完整AC/命名/依赖PASS。 + +T075:唯一417行26项;私有javac仅缺Responses adapter,wave9-responses-red.log。主agent逐行读取,覆盖sequence不重编号、跨域有序屏障、done/item/final变化快照独立扫描、工具JSON、reasoning/refusal、引用title、event/累计/非流byte预算和取消。失败/未完成最终快照仍检查其可读output,命中优先;普通error.message不作为模型文本。合法Citation依据现有backend schema,URL独立opaque,title可读。测试subset范围/完整AC/命名/依赖PASS。 + +三个Gateway类常规Maven集中预期RED,仅缺三个配对adapter类型,wave9-parent-red.log;未生成stub、未执行尚不存在的生产断言。适配器采用(CompiledPolicy,Properties)构造,accept/complete/cancel返回有序完整原事件byte[],非流正常返回原bytes,state与有界Outcome供后续manager接线;可信request_id注入留T086/T098,不在单元自造编号。四项测试自审完成后更新56/135、进入Wave10。 + +## 12. Wave 10 配对实现(2026-10-24) + +主agent最终Gateway常规Maven19个准确测试类:371 tests / 0 failures / 0 errors / 0 skipped,逐份Surefire XML核对,/private/tmp/f111-gateway-tools/wave10-parent-final.log。三个协议分别Input42/Chat36/Responses32;其余261为既有范围。首次370项全绿后,独立复审发现缺失item结束断言,补真实RED并修后重新跑本次371;不以首次绿测代表覆盖充分。 + +T026:仅既有model_audit_write.py和model_audit_writer.py,T025的21项全绿;agent12文件组合219PASS(f111-t026-adjacent.log),主agent独立8文件186PASS(f111-wave10-parent-backend.log)。原生首次事实补齐EXTERNAL未知NULL,严格tenant/user/model与已确认非空关联,MODEL原身份校验不放宽。来源与mode同事务flush,安全started_at、阶段/命中/投影版本保持,model_started_at首次固定;不写model_call/额度。主agent读取两份实际完整源及diff,Ruff/format/arch-guard、范围/配对/依赖及task-review适用分层/序列化/租户/安全/设计同步PASS,前端N/A。真实数据库并发仍未验证。 + +T072:唯一305行输入Adapter。初始39GREEN后,AgentA实际production探针+B schema确认两类MEDIUM:schema业务属性名properties引起误拦,default内业务type值漏检;同reasoning列表连续块漏扫。原T071补3回归,实际39PASS+3FAIL(t072-review-red.log),修后42/42全绿(t072-tests.log)并纳入集中371。独立fresh探针重跑确认normal/reserved schema均不误拦、default命中、content/summary连续块命中且彼此独立,无新增HIGH/MEDIUM。test现301行,未改旧断言。主agent完整自审,内部Inspection保持原bytes与清洗提示,不记录原文;范围/配对/依赖及task-review适用命名/安全/设计同步PASS,数据库/前端N/A。 + +T074:唯一408行Chat Adapter,原T073追加usage nullable/detail与20层参数兼容2项,再加混载error正文2项,共36。混载错误实际先RED(t074-mixed-red.log),白名单纯DSH error修复后,Chat36/Scanner22/Cache38/Verifier18私有114/114全绿;实际边界smoke12/12(低字节限额、duplicate/trailing、UTF8、bareCR、合法100位JSON数字和纯真实backend错误)。日志t074-{compile,test,boundary-smoke}.log。主agent逐行读生产与新增测试,原事件FIFO/水位/工具完整参数、固定终态、安全prefix和原usage一致;纯error原bytes保留,混载拒绝;name仅非空观察不保留大段文本。范围/配对/依赖和适用task-review命名/不可变结果/信息保护/设计同步PASS,SQL/前端N/A。 + +T076:唯一502行Responses Adapter,原T075授权增补至32项,旧断言保持。纯flat/nested真实backend错误与mixed error+output先实际RED后修;日志wave10-responses-mixed-red.log、wave10-responses-backend-error-red.log。31项绿后AgentA只读501行源/469行测试,用真实target类复现added工具仅arguments.done、或空message added,最终output=[]竟PASSED。补循环回归两种均先RED(wave10-item-close-red.log),ensureClosed要求item.done后32/32GREEN(wave10-item-close-green.log);final-only合法message/tool正例保持。主agent在fresh集中target直接重跑原Probe,两场景均ERROR且completed=false(/private/tmp/f111-impl-a-t076/fixed-probe.log)。相邻独立阅读未发现其他确证HIGH/MEDIUM。 + +主agent读取T076最终完整源和新增测试,核对progress快照前缀SHA256、完整快照独立检查、工具增量与snapshot各自检查、summary/refusal/citation域、未知结构、失败快照、8MiB事件/32MiB累计/编码缓冲及结束释放。范围/配对/依赖和适用task-review命名/序列化/信息保护/设计同步PASS,数据库/前端N/A。内存放大/延迟、全局配额、上游订阅所有权、request_id注入仍待后续真实接线及性能任务。 + +四项满足当前任务完成条件后更新60/135,进入Wave11。未提交、推送或部署;MySQL/DM8、部署、跨进程与容量验收不由本轮单元/合成事务结果替代。 + + +## 13. Wave 11 前置测试(2026-10-24) + +T027:唯一329行测试22项,12预期RED/10PASS;相邻84PASS,f111-t027-red.log、f111-t027-adjacent.log。真实状态派生、查询就绪、停滞worker与原生writer路径;无密钥/无原文表的外部条目可用、外部先排除再读activity/facts、满100页仍推进cursor、提升后恢复原生派生、UNKNOWN用量保持未知。主agent完整读取,Ruff/format/arch-guard和测试subset命名/完整AC/依赖/范围PASS。 + +T041:唯一会话投影测试16项,13预期RED/3PASS,f111-t041-red.log。覆盖晚到命中独立dirty、外部N_A仅计条目/命中、真实writer提升后去重、无关联清pending后可再关联、双阶段及后续事件去重、事务回滚、租户隔离、模型异常与安全计数独立、实际UPDATE WHERE含tenant与源版本、批次上限200及205行跨批恢复。MODEL的N_A仍拒绝。状态刷新安全dirty断言由T028实现,其余T042;没有跨文件隐含职责。独立agent完整只读复审未发现确证fixture/事务错误。主agent集中六个真实存在的测试文件:25预期RED、112PASS、3SKIP(既有环境用例),f111-wave11-parent-backend-red.log;首次命令引用不存在文件退出4,修正路径后才得到本结果,不计为执行通过。全部红测为待实现断言,不将尚未到达的回滚断言宣称已通过。 + +T085:唯一544行JUnit测试24项,私有javac预期RED仅14处缺DshSafetyRequestManager及ManagedResponse,无其他编译错误;/private/tmp/f111-gateway-tools/t085-javac-red.log。主agent完整读取,真实Netty上下游、实际Outbox+SQL端口mock、纯定时端口ManualScheduler,未写manager业务桩。覆盖唯一订阅、16检查/8续读独立额度及24线程竞争、64MiB累计入站预算和取消回收、32MiB原始上传与总时限、命中起算30秒/剩余响应字节/断连/关闭、观察订阅取消不持有上游、阶段快照/版本及无原文无用量事实。未运行尚不存在的生产类型,不把编译RED当运行通过。范围/配对前置依赖/命名/完整AC及task-review适用项PASS。 + +T113/T115:仅Desktop两个新测试文件,共25项,Chat9RED/3PASS、Responses10RED/3PASS;/private/tmp/f111-desktop-wave11-final-red.log。既有adapter/responses/copy共74PASS,f111-desktop-wave11-adjacent.log。真实adapter+BlockAssembler覆盖HTTP/SSE错误、租户提示/编号、正常usage/reasoning/工具标识、Responses无sequence错误优先识别、错误轮次无可执行tool block-end、单调用无重试。读取AgentLoop现有消费入口确认错误finish先于executeToolCalls;这些测试不冒充实际runner执行证据,后者留T118。主agent完整读取并交叉核对,测试subset/完整AC/范围/依赖PASS。 + +五项满足前置测试任务条件,更新65/135、进入Wave12。原有完整前端基线门禁失败、真实双库/跨进程/部署/性能未执行状态保持,DM8按用户确认留待验收。无提交、推送或部署。 + + +## 14. Wave 12 配对实现(2026-10-24,进行中) + +T028:仅status/states两个既有文件;T027+共享T041安全dirty断言23PASS,相邻11文件139PASS(f111-t028-adjacent.log)。EXTERNAL先返回N_A、无pending,状态纯函数不写入,worker在activity/facts前排除且锁读后再确认来源;原100行及annotations保留用于cursor,MODEL/legacy原派生未放宽。主agent完整读取两源,Ruff/format/arch-guard,范围/配对/依赖及task-review #1–4/#6–7 PASS,#5 N/A,design实施细节已同步。 + +T042:仅conversations/projection两个既有文件;T04116项通过,主agent初次集中6文件137PASS/3SKIP,所有7个内容安全文件190PASS,树/会话/标题/生命周期8文件66PASS。独立agent发现C3 SELECT手工tenant谓词偏差,改用strict_tenant_filter包完整查询并再次只读复核PASS;修后配对及旧groups41PASS(f111-t042-final.log)。写语句保留显式tenant及请求双版本/pending、会话旧三计数条件,set_committed_value避免额外PK-only ORM写;无关联清pending不伪造已计入水位,NA来源提升不重复条目,安全变化与tree revision同事务。批次默认100/最大200,单项失败隔离,持久pending可重试。主agent完整自审,独立agent计数/原子性/隔离复核无确证HIGH/MEDIUM;Ruff/arch-guard/diff-check,范围/配对/依赖及task-review分层/命名/序列化/数据库/安全/设计PASS,前端N/A。 + +T042真实MySQL补充小验证:在已有专用MySQL8.0.46容器中新建唯一dsh_test_f111_t042_临时数据库,以真实生产ORM建表并调用真实Repository;实际caller rollback、202租户隔离、两线程同主行锁竞争仅一次投影、重复重试、外部NOT_APPLICABLE计数及NULL模型/用量均PASS。/private/tmp/f111-t042-mysql-smoke.py、f111-t042-mysql-smoke.log;finally只删除本次唯一临时库,清理确认PASS。该smoke没有执行Alembic升级/回退、全协议/Outbox竞争/性能或DM8,T119/T120仍未完成。 + +已验证两项更新67/135;本Wave其他三项仍未勾选,继续实现和独立审查。 + + +Wave12交叉审查进行中记录(不勾选未完成任务):Desktop主agent独立定向5文件105/105PASS(/private/tmp/f111-desktop-wave12-parent.log),仍等待独立复审。T086初版24项中23PASS,唯一失败是T085 Responses fixture使用标量input而实际backend要求list[InputItem];主agent核对真实schema后授权只纠正fixture,不放宽生产解析或原断言。 + +T086在初次回归后发现三项需修复边界:主agent读源确认finishOwner归还预算却未清adapter引用,旧ManagedResponse可继续持有未完成正文;独立agent实际BufferProbe复现8×8MiB未结束Chat行manager计64MiB而raw+line实际128MiB(非解析树/字符串放大);实际CancelProbe复现同chunk安全prefix后同步client cancel,已确认hit降ERROR且ownerDone后新领取drainPermit泄漏。探针 /private/tmp/f111-impl-a-t086/{BufferProbe,CancelProbe}.java,对应buffer-probe.log/cancel-probe.log。实现负责人正修复并在原T085补RED→GREEN;未经最终复跑不宣称解决,T086保持未完成。 + + +T114/T116最终实现与task-review:仅openai.js/responses.js,原T113/T115授权追加边界回归后Chat14+Responses17共31,既有74,agent及主agent独立集中均105/105PASS(/private/tmp/f111-desktop-wave12-final.log、f111-desktop-wave12-parent.log);npm run typecheck退出0(仅实际TS集成检查,不冒充插件JS全类型覆盖),限定四文件diff-check clean。初版确定content_filter后malformedtail/EOF覆盖错误已实际probe并先RED后修;Responses真实SDK parseStreamingJson可把截断工具补{}、非object可变成可执行true、缺status可释放工具,补真实RED后修;max-token截断保留文字兼容亦通过。正常完整tool ID、encrypted replay、nullable usage/cached语义、账号/额度原映射保持。 + +主agent读取完整改动源及实际BlockAssembler max-tokens assemble规则;独立agent只读两生产diff、31配对全量和插件实际pi-ai0.87.1源码,未发现本轮引入的确证HIGH/MEDIUM。既有max-token工具丢弃导致replay hash不匹配会降为可见文字,已比对HEAD同样行为,不扩本轮。范围/配对/依赖及task-review命名/i18n复用/信息保护/设计一致性PASS,backend与SQL检查N/A。现更新69/135,T086仍未完成,Wave13尚未开始;实际runner及界面locale验收不由105项adapter测试替代。 + + +T086最终修复已复验:唯一499行生产manager;原T085仅修真实契约不合法的Responses fixture,增6调用至30。先保留旧版运行原24全绿+新6全红(/private/tmp/f111-gateway-tools/t086-owner-red.log),最终fresh私有30PASS;本轮主agent常规Maven20个准确类401/401PASS,逐份20个新Surefire XML验证0失败/错误/跳过,wave12-parent-final.log。独立A用原实际BufferProbe复跑:requests=4、managerRetained=67108864、adapterRawPlusLine=67108864;CancelProbe同步取消后仍BLOCKED+owner存活,hit后30s ownerDone=true/drainPermit=false/managerDraining=0(buffer-fixed.log、cancel-fixed.log)。两项HIGH关闭。主agent确认owner结束cancel/drop adapter实际引用、可信fallback root及header编号、记录幂等修复;新增6回归均已逐行读取。 + +SlowDeliveryProbe实际无demand客户端验证保留字节<=8192,上游停止,completion等待排队字节释放,cancel后retained=0(t086-slow-delivery.log);它不是heap/GC或性能验收。私有相邻union260项中259PASS、1个原T055 ListAppender capture因Netty DEBUG日志并发修改ArrayList出现CME;主agent按实际AppenderBase monitor取得不可变快照,保留原凭据不泄漏断言,定向Profile24/24PASS(wave12-profile-capture-final.log),无业务修改、无削弱断言。401集中结果在此前生产完全相同版本完成,不把union初次失败隐去。 + +T086主agent完整读取生产及追加测试,task-review范围/配对/依赖、Service边界/命名/序列化/安全/设计一致性PASS,SQL与前端N/A;git diff --check clean。五项实现满足完成条件后更新70/135,进入Wave13。尚无完整Gateway路由接线、运行中端到端/多进程/双库/heap压测或部署验收;DM8继续待验收,未提交或推送。 + + +## 15. Wave 13 前置测试与接线契约补齐(2026-10-24,进行中) + +必要任务补齐:真实Desktop当前renderer直接显示failure.message,Host Node的navigator不能代表当前界面语言;仅注册copy或给未消费options传参无效。新增T136(Wave13两测试文件)/T137(Wave14两adapter)并将原T117(仍client/index两文件)移Wave15依赖T137,总任务137,未改变已确认需求或触碰第三方包。现有renderer locale active同步变更与Host配置异步保存存在时差;T117仍须验证真实入口、注册/卸载与默认值,不据字典存在勾选。 + +T086编号契约复核:原design§6.3明确Gateway生成错误用Gateway UUID,backend编号单独携带。先退回未完成,窄修manager/T085:安全body编号Gateway、正常backend X-Request-ID原样、X-Backend-Request-ID独立、未知编号不猜关联。保留旧真实t086-classes运行已纠正契约断言29PASS/1RED(t086-id-contract-red-tests.log);fresh实际源30/30PASS(t086-id-contract-tests.log),单一源码/目标测试私有javac无stub。普通Maven本次被新T087尚缺filter类型的testCompile阻断(t086-id-contract-final.log),不误记为执行通过;生产compile已成功,待Wave14再常规集中。主agent完整读取修正,自审编号语义/原bytes/信息保护/范围PASS,恢复T086勾选至70/137。 + +主agent本Wave backend5文件集中:54预期RED/53PASS(f111-wave13-parent-backend-red.log);29缺ingest模块、14查询缺能力、11树安全计数/代际行为,未把RED误称运行成功。详细任务审查结果随本Wave收尾追加。 + + +Wave13五项任务自审收尾: + +- T029:唯一569行新测试,29预期RED均缺配对ingest模块,相邻85PASS;f111-t029-final-red.log、f111-t029-red-adjacent.log。主agent逐行读全文件,真实Session/commit故障/失败关闭后真实winner事务验证编排,非业务桩;已确认raw Mapping+frozen VerifiedSource端口,原wirehash在清洗前、commit后ACK、异步线程严格租户恢复、丢确认重试、跨tenant主PK不可见时有界event-only、新可信身份变更及保留水位。Ruff/arch-guard、测试subset完整AC/范围/依赖PASS。 +- T037:唯一约340行新测试,26项15预期RED/11PASS(最终主agent组合日志);direction旧模型无字段、当前逐行查用量N+1及新事件Repository缺类型均为预期,未出现fixture误红。默认list仅主摘要/bulk用量和姓名、26例覆盖六态/命中组合/nullable外部DTO/keyset显式条件+tenant/真实request_started默认7至31天/PURGED区别。独立agent读实际ORM/fixture和全测试无确证错误,指出空后续页不能等同PURGED,追加真实保留竞态回归;事件排序按实际索引id,与design已对齐。Ruff/arch-guard及task-review测试subset/范围/依赖PASS。 +- T043:唯一约366行15项,11预期行为RED/4PASS,相邻39PASS;f111-t043-red-adjacent.log。主agent完整读取,真实group→fold→tree_step持久事务,不另建安全树;own/branch不重计、外部N_A无原文读取、停用租户补报、summary及safety两版本变更不混代发布、过期owner不可publish均有断言。最终查询DTO属后续任务,未逼T044越界。Ruff/arch-guard及task-review测试subset/范围/依赖PASS。 +- T087:唯一395行JUnit、45预定调用,私有javac11处全部缺配对DshContentSafetyFilter(t087-red-javac.log),无其他类型错误。主agent完整读取,真实manager/input scanner而filter orchestration未写stub;测试端routing仅转交真实manager,不冒充T090完成。精确两POST、先managed标记、可信tenant快照、关闭/依赖/license边界、原backend5类身份失效清缓存不重试、9类非身份错误原bytes保留与64KiB观察上限均覆盖。T086编号补正复验后依赖PASS;命名/完整AC/范围/测试subset PASS。 +- T136:仅原两个Desktop测试文件,新回调+编号契约共59项,18预期RED/41PASS,相邻74PASS;f111-desktop-wave13-t136-red.log、f111-desktop-wave13-adjacent.log。主agent完整读取新增两节;14RED为回调未消费,4RED为安全body编号优先,原HTTP两项旧错误期望经确认改正,其余断言保持。三语、tenant文字不覆盖、未知账户/额度不走安全本地化、回调异常不泄漏且保留分类均覆盖。范围/前置/命名/完整AC与测试i18n subset PASS,无生产修改。 + +主agent最终backend5文件55预期RED/53PASS(108项),f111-wave13-parent-backend-red.log;初次54RED/53PASS后新增空后续页保留断言,已重新执行,不沿用旧计数。五项完成后更新75/137、进入Wave14。DM8、真实完整双库/GUI/端到端/性能仍按对应任务待验收,未提交、推送或部署。 + + +## 16. Wave 14 配对实现与交叉验收(2026-10-24) + +- T030:新content_safety_ingest唯一生产文件,原T029新增实际全局关联键占用回归;新增case先1FAIL/29PASS,修后30PASS,相邻178PASS。实际独立probe先503且fact丢失,修后COMMITTED/UNASSOCIATED;日志f111-t030-binding-{regression-red,regression-green,probe-green}.log。最多三个完整新事务;仅连续merge Integrity触发第三次event-only,append/commit失败仍不ACK。默认expire_on_commit=True及提交暂停超过2s的probe验证超时不ACK、实际后提交、重试duplicate;不是数据库超时取消证明。原事件hash、两租户MODEL身份/执行/计量及source绑定不变。主agent完整读取源码和追加回归,C独立审阅并实际4项定向PASS(f111-impl-c-wave14-review.log),无新增HIGH/MEDIUM。Ruff/arch-guard及任务分层/命名/序列化/SQL租户/信息保护/设计一致性PASS,前端N/A;范围/配对/依赖PASS。 +- T038:仅model_audit_queries/content_safety_queries两生产文件。配对30项、相邻五文件组合78PASS,f111-t038-final-green.log。主列表bulk Call/Evidence/User,无事件查询或N+1;六态、INPUT/OUTPUT/BOTH交集、外部NULL及已知安全摘要PENDING;事件request授权+严格tenant、limit+1/time/id keyset、有限白名单详情、默认7/max31天及空续页AVAILABLE。自审新增4个非str游标ID回归先3FAIL/1PASS,UUID(list/dict/int)的AttributeError被严格类型检查修复(f111-t038-cursor-red.log)。SQLite限长SQL会自动生成OFFSET 0,原observer误报;只剔除末尾真实参数0的该方言产物,不放宽正offset,生产未使用offset()。首次写入用了错误相对路径导致未修复,第一次组合仍3FAIL/75PASS,修正路径后才得到78PASS,未隐去失败。Ruff无cache format/check、arch-guard/diff-check及主agent任务自审PASS,独立C全文复审+实际定向回归无新增HIGH/MEDIUM。 +- T044:仅tree_build/hierarchy两个既有文件;T04315PASS,六文件75PASS,f111-t044-adjacent.log。三水位锁内capture/restart/publish,own/branch安全计数沿既有Path累加,dirty仅投影一次;pending包括停用租户,旧租约/清理保持。具体执行经profile_scope,verified ORM对象承接原严格租户/tenant编码主键,未新增bulk写或bypass。主agent读取完整源及diff,配对/范围/依赖及task-review分层/命名/数据库/安全/设计PASS,DTO/UI N/A;Ruff/arch-guard PASS。 +- T088:唯一249行preflight filter,原T087维护至52项。实现出现后暴露测试中6处AssertJ类型编译错误:五处明确Boolean泛型、一处AtomicReference.get().isNull,保留业务断言;Wave13缺新类型的红编译不能证明这些断言已可编译,现纠正记录。真实PathPattern probe证实编码字符和matrix路径匹配专用路由,编码斜杠不匹配;新增7例先46PASS/6FAIL,修后52PASS(t088-path-{red,green}.log)。私有9项实际observer probe覆盖逐字节UTF8、坏编码、重复键/尾随/mixed JSON、同步/异步/永不结束invalidate及原flush边界,9PASS;t088-observer-probe.log。主agent全文审查,预检查无body订阅、上下文无凭据、错误日志固定;范围/配对/依赖与适用task-review PASS。T090实际所有权/T098条件组装仍未完成。 +- T137:仅openai.js/responses.js,T13659+既有74共133PASS;主agent独立五文件同样133PASS(f111-desktop-wave14-parent.log)。npm typecheck退出0,仅TS集成不冒充JS完整类型检查。安全body编号优先、普通header优先,tenant提示不覆盖;仅固定不可用和缺提示走同步locale回调,异常/非字符串/HTML不能泄露或变换分类。B交叉读所有新增回调点/测试/helper,实际Node边界probe通过,无新增HIGH/MEDIUM;范围/依赖/配对和命名/i18n/安全/设计PASS,数据库N/A。真实locale消费和GUI验收仍属T117。 + +主agent最终集中:backend所有test_content_safety*.py **265PASS**(f111-wave14-parent-backend.log);Gateway准确19类 **409PASS/0SKIP**,逐份新Surefire XML复核(wave14-parent-final.log),包含原路由/转发/许可/旧词扫描相邻用例;之前Dsh*子集385PASS不代替此409结果。Desktop **133PASS**。五项勾选后80/137,进入Wave15。所有结果仅各自执行边界;完整前端基线门禁、真实全链双库/DM8、GUI、跨进程/性能仍保留相应待验收,无提交/推送/部署。 + + +Wave15开始前完整性核对:发现T098元数据只有telemetry/Dispatcher,历史design备注把Spring组装留在T098不成立。补T138/T139在Wave27/28,后续双库Gateway验收依赖T139;总数139,已完成80不变。组装尚未实现,不将纯类/手动构造证明真实接线。接收部署租户范围按现有multi_tenant配置和共享服务身份明确,未新增source白名单/密钥配置。 + + +## 17. Wave 15 接收认证/专用路由前置测试(2026-10-24,进行中) + +T031:唯一399行测试,44项预期RED均缺content_safety_runtime,旧共享签名/SSO及T030组合71PASS(f111-t031-red-adjacent.log)。真实SSOSyncConf没有enabled字段,测试使用实际SystemLoginMethod.bisheng_pro=false/省略登录配置对照;相同key与自定义签名头、原始字节、精确方法path、±300秒边界、UTF8/重复JSON/NaN/depth/64KiB、单租户403、多租户历史不查active、真实T030原mapping hash/missing-null冲突均有配对断言。cold import时拦模型/audit/tool runtime,不将已缓存模块避过导入当独立组装证明。主agent全文读取,Ruff/arch-guard及task-review测试subset、完整AC/范围/依赖PASS;缺生产模块的RED不能宣称全部断言已实际到达。 + +T089:唯一约340行JUnit测试20展开调用,私有javac使用FLOW诊断后仅4处缺DshModelRoutingFilter类型(t089-red-javac.log);实际相邻T08852+T08630+proxy9共91PASS(t089-neighbor-green.log)。四模式用真实FilteringWebHandler、PathRate和默认Netty过滤器,CountedFilter仅委托计数;真实manager/proxy及两个独立loopback端口验证固定目标/原body/原Authorization/默认trap零请求,另测原错误、双编号、命中续读/取消/pooled refCnt与无demand预算。主agent全文读取,A交叉核对实际类和javap签名,未发现确证fixture错误;范围/前置/命名/完整AC与测试subset PASS。仍缺实现类,不以红编译代替实际路由验收。 + +两项前置测试满足任务条件,更新82/139;T117仍在同Wave实施与真实语言入口验证,未勾选、尚未进入Wave16。无提交/推送/部署。 + + +T117最终实现:仅client.js/index.js,独立namespace与公共LocaleRuntime/addLanguage、authenticated connection/request→请求ALS→实际注册adapter。首次真实入口为中文却返回英文,entry-red.log;首版probe手动加ja掩正式catalog只有zh/en,移除后no-ja-red.log,再按公共接口缺失时登记ja/fallback en转GREEN,不宣称整个Desktop日文化。Host只接两码三语SSOT固定copy,非法值降级不改分类,不含tenant/Auth、不进入broker/model请求。 + +B交叉审真实Cordis+独立LocaleRuntime共享CookieJar实际复现同值两renderer:旧fiber卸载误删新窗口cookie,before=true/after=false。修为{owner,messages}随机owner所有权及按copy去重,Host只把messages投影入ALS;主agent独立执行同原probe得到before=true/afterFirstUnload=true、secondStillActive=2/locale=zh,parent-owner-green.log。原探针 /private/tmp/f111-impl-b-locale/renderer-ownership.mjs,未写业务桩。entry扩展覆盖双协议三语两类copy/tenant、6请求并发、无偏好、Host设置落盘延迟、坏cookie/重复/跨authority及重载;final-entry-green.log。主agent独立执行同真实入口probe退出0(parent-entry-green.log);不是完整Session/Agent执行证据。 + +GUI:独立Electron43.0.0/Chromium150.0.7871.46,临时bootstrap且独立userData,未跑正式main(其源码会强制使用bisheng-dev/bisheng)。旧electron-leiWN8/zh-blocked图经主agent实际查看为空,仅日志PASS不足;改为预期DOM文本+可见height+双requestAnimationFrame后捕获。最终有效paint-electron-gui.log及electron-zLug2C/六图,C逐六图、主agent抽三图查看确认新增安全正文实际可见;使用已发布LocaleRuntime/client、认证Host/adapter及原TurnErrorItem消费组件。该有限裸组件显示不代表全应用布局/全部旧文案翻译、正式main启动、Session.prompt/Agent队列或打包验收;这些保持后续任务范围。 + +相邻六文件143PASS(final-regression.log),npm typecheck退出0(TS集成边界)、node --check与diff-check PASS;没有新增依赖或第三方分发包改动。主agent完整diff审查,B实际生命周期交叉审查;任务范围/依赖、命名/注释/现有i18n与安全/设计同步PASS,backend/SQL N/A。T117满足当前接线及有限实际显示验收后勾选,新增T140在Wave17固化入口回归(临时probe不代替长期CI)。 + +Wave15完成83/140,进入Wave16。全应用Session/真实模型全链、双库及容量等仍未验收;DM8依用户要求保留待验收,无提交/推送/部署。 + + +## 18. Wave 16 接收认证与专用转发实现(2026-10-24,进行中) + +T032:仅content_safety_runtime.py及既有common/errcode/dsh.py。配对44项加旧HMAC/SSO/真实ingest初次115PASS(f111-t032-first.log)。扩大旧错误码唯一性回归实际1FAIL/335PASS:运行时遍历包括继承Code,同码403子类违反现有约束(f111-t032-final.log);删除新子类,不修改唯一性测试,改唯一AuthError的显式tenant_forbidden实例工厂,原类/新默认实例/ClientCode注册仍401,仅范围拒绝实例403。最终相同组合 **336PASS**(f111-t032-final-green.log),含所有内容安全、错误码及三语覆盖、模型错误、原配置与旧SSO。 + +主agent源码全文/原共享HMAC工具复核:签名先于JSON及SQL,唯一header/原bytes/固定METHOD/path,无独立密钥或modelruntime;严格JSON保留原mapping、时间窗和单租户部署范围;惰性默认Session仅在接收worker作用域触发。C独立12项边界probe PASS(f111-impl-c-t032/final-review.log):重复签名头两顺序401、可清洗hint浮点/NaN/Infinity仍400、17/1000层安全拒绝、原mapping不注默认、default factory按需及worker tenant202严格visible/bypass恢复;实际DshRoute.error 403与registry401隔离。该default factory探针在首次严格scope contextmanager截断,未连接真实DB,不误称数据库验收。 + +Ruff format/check、arch-guard/diff-check及任务分层/命名/序列化/配置租户/信息保护/设计同步PASS;API composition不含SQL或FastAPI Request对象。范围/配对T031/依赖T018/T030 PASS,frontend N/A。T032满足条件勾选84/140,T090仍实施与资源边界审查中,尚未进入Wave17。无提交、推送或部署。 + + +T090审查进行中(不勾选):初版20例19PASS,slow源4而原断言≤3。独立直接MockServerHttpResponse probe证实Spring首帧屏障request(1),不是可据此放宽的字符串/heap放大。A真实32MiB非流/零需求probe旧版springCachedBytes=33554432、managerRetained=0、writeDone=false(f111-impl-a-t090/budget-red.log);0字节prelude后cached=0、managerRetained=33554432(budget-green.log、budget-final-prelude.log)。原ordinary-cancel fixture需跳过0字节prelude后再取首真实模型prefix,保留源订阅1/唯一HTTP/refCnt断言;新增prelude-only取消及header writer失败回归即时回收,原slow≤3保持。私有22配对及113相邻通过不能覆盖下一项真实Netty发现。 + +A真实慢TCP第二层HIGH:ReactorHttpHandlerAdapter+实际Filter/Manager、上游合成单个32MiB合法Chat JSON,rawSocket POST后不读;Netty pendingOutbound=33554626、可读ByteBuf=33507330/refCnt1/writablefalse,但managerRetained=0/checking0/writeDonefalse。证据f111-impl-a-t090/{NettyBudgetProbe.java,netty-budget.log}。这证明0字节prelude仅修Spring前取,实际Netty持有期仍绕过总预算;不能延期到T128才处理或放宽测试。T090扩为两个生产文件接HTTP writer持有租约,T089前置测试单文件补RED→GREEN;尚未完成,保持未勾选。 + + +T090写租约首版继续审查:新增两种完成/取消顺序先RED(原22PASS、新2FAIL,t090-http-lease-red.log),原因writer已读取4096bytes但manager计费0。租约版原慢TCP探针writer未完成期间retained=33554432,断开socket后retained0、writeDonetrue、大PooledUnsafeDirectByteBuf refCnt0(netty-lease-green.log),只对这次普通断开成立。 + +A进一步复用500ms既有I/O超时,证实guard超时虽然终止reactive write,真实channel仍active/writablefalse/pendingOutbound33554722、32MiB buffer refCnt1,而预算已归零;必须等实际下游连接关闭才能退款(netty-timeout-confirmed-red.log;另早期netty-timeout-green.log虽名字含green实际未通过,不作为GREEN证据)。因此首版租约也不能验收;改用公开Spring/Reactor下游连接生命周期与usingWhen异步error/cancel清理,成功写结束直接结束租约,失败/取消先close实际下游再归还,不关闭独立模型drain。 + +另实际MEDIUM:已取得ManagedResponse但尚未开始writer时manager.close,旧beginHttpWrite仍接受、等500ms才超时(late-lease-red.log);开始writer应拒绝clientGone,并处理begin→shutdown→guard间隙,不能拒绝ownerDone(正常完整模型响应可先于HTTP完成)。两问题待最终修复/原探针复验,T090保持未勾选。 + + +T090最终验收:原T089追加真实native timeout、manager.close及late lease先RED,28项25PASS/3FAIL(t090-native-close-red.log);修后28PASS,私有四类联合119PASS(t090-native-close-{final,union}.log)。主agent常规离线Maven准确20类 **443PASS/0FAIL/0ERROR/0SKIP**,逐份Surefire XML复核,wave16-parent-final.log。未放宽原slow≤3或业务断言;测试维护属于T089原单文件,T090仅Filter+Manager两生产源。 + +A用相同实际慢TCP与合成上游独立复验:未结束写入retained33554432,timeout/socket-close/manager.close后native active=false、pendingOutbound=-1、32MiB pooled buffer refCnt0、retained0;late lease立即拒绝。f111-impl-a-t090/final-{timeout,socket,shutdown,late}.log。静态尾部UnreleasableByteBuf的refCnt1不当作大缓冲泄漏;真实下游不是完整模型E2E或heap/性能证明。两项HIGH及late lease MEDIUM关闭。 + +主agent完整读取Filter165行、Manager560行及新增T089实际连接/回收回归;独立A最终只读复核无新增HIGH/MEDIUM。task-review:#1/#3/#4/#5 Python/前端/SQL N/A,#2命名/#6固定错误及凭据保护/#7设计同步PASS;文件范围、已勾配对T089及依赖T088 PASS,diff-check clean。T090勾选后 **85/140**,Wave16完成,进入Wave17。T139真实Bean组装、双库/DM8、T128容量/吞吐等保持未验收,无提交/推送/部署。 + + +## 19. Wave17 HTTP/配置前置与Desktop运行边界(2026-10-24,进行中) + +T140:唯一约320行维护测试,真实Cordis renderer fiber/LocaleRuntime/client.apply、公开Connection authenticatedUrl/admission/request waterfall与实际注册adapter。10/10 PASS(f111-t140-first.log);主agent全文读取及独立首次四文件87PASS。第二次准确七文件包含原需环境protocol-integration,实际六文件143PASS/1SKIP(f111-wave17-desktop-locale-parent-final.log),不将skip称完整协议E2E;后续补准确原enterprise-adapter邻近集。无偏好、Host持久化未完成、三语双协议/tenant、自定义非法及重复/跨authority cookie、六并发ALS隔离、同值双renderer所有权、真实fiber卸载重载及既有ja语言包保留均有行为断言。合成broker/目录存储/webServer注册端口替身不替代Session/Agent或GUI,未运行正式main或真实profile。主agent与C的task-review测试subset命名/英文注释/完整AC/范围/已勾依赖T117 PASS,diff-check clean。T140勾选 **86/140**,其余Wave17仍进行,无提交/推送/部署。 + + +T033:唯一440行前置测试,A实际79PASS/36预期RED,主agent独立三个文件 **74PASS/36预期RED**(f111-wave17-backend-api-parent-red.log);36setup error逐项为缺配对content_safety_events模块,不把尚未到达断言记GREEN。真实composition+DshRoute/ASGI、原verify+ingest+SQLite事务,时钟/config/Session端口可控;64KiB假长度chunk早停、原bytes单次读取/不改_receive/hashmissing-null、提交等待/回滚后ACK、错误状态与安全输出、query不覆盖签名scope、历史202租户无Token及断连无ACK均覆盖。主agent完整读取全部440行,A的Ruff/format/F821/arch-guard clean;task-review测试subset命名/完整AC/范围/依赖T016/T032 PASS。T033勾选 **87/141**,T034仍未开始。 + + +T091:唯一242行先行测试,真实YAML Binder/placeholder→RouteDefinitionRouteLocator(Path/Method/CacheRequestBody工厂)→CachingRouteLocator/RoutePredicateHandlerMapping→FilteringWebHandler。私有编译成功;当前24项4PASS/20预期FAIL,16专用路由实际选中bisheng-http、4未显式默认关闭;相邻原路由3PASS。主agent完整读取文件、普通Maven独立同样 **7PASS/20预期FAIL/0ERROR/0SKIP**,逐两份XML确认(wave17-parent-config-red.log),不把在首个route断言前尚未执行的真实Netty/Profile/Proxy场景记通过。actual默认Path factory matchTrailingSlash=true,与T088 parser默认不接受尾slash不同,T092须显式false保持两入口一致。task-review测试subset命名/完整AC/唯一定义范围/已勾T016/T090依赖PASS;无生产或YAML改动。 + +T141:唯一既有enterprise-adapter.test.ts,仅公共on disposer夹具和两处函数export提取终点,新增AC/边界英文注释,原业务断言完整保持。先 **5FAIL/147PASS**(f111-wave17-desktop-locale-adjacent-final.log),修后准确七文件 **152PASS/0SKIP**(f111-wave17-desktop-locale-adjacent-fixed.log)。主agent读全旧测试及实际diff,A只读交叉审T140+T141并对照真实Connection/LocaleRuntime/Host无新增HIGH/MEDIUM;A未重复执行,不冒领独立运行。task-review命名/完整AC/测试范围/依赖T117/T137 PASS,diff-check clean。T091/T141勾选 **89/141**,T118仍在本Wave最终验收。 + + +T118:唯一197行真实公开AgentRegistry/AgentLoop/ToolRuntime测试,五安全终态0次body/preexecute/result,两个正常协议1次;真实assistant-stream完整args帧放行transport后段,随后文字到达再安全错误,tool block-end0/assistant attempt/turn-end安全error与request1无fallback均断言。7定向PASS;主agent8文件 **159PASS/0SKIP**(f111-wave17-desktop-parent-final.log),C九文件169PASS及typecheck0(f111-wave17-desktop-combined.log、f111-wave17-typecheck-final.log)。首次类型检查合成capabilities缺reasoning_content,测试fixture补false后通过,无生产或断言修改。 + +主agent全文读取,A独立全文及实际AgentLoop错误finish先于message/executeToolCalls消费点复核,无新增HIGH/MEDIUM。内存SessionStore+mkdtemp cwd是隔离边界,未安装Loader/持久化插件,不称完整Host Profile/正式应用或Gateway E2E。task-review命名/英文注释/TS严格/完整AC/文件范围及依赖T117/T090 PASS,diffcheck clean。T118勾选 **90/141**,Wave17完成进入Wave18。 + +附加线索:C实际公开AgentLoop排队probe发现首轮zh-display ALS运行中收到ja-display followup,第二transport仍继承首轮scope(f111-queued-context-probe.log)。该探针未启用生产cookie桥/真实Sessioncontroller,证明runtime继承事实,尚未证明日常生产语言显示缺陷;不凭推断修改业务或否定已通过工具屏障,继续独立核查真实入口。 + + +Wave18扩大相邻检查发现维护夹具失配:Desktop6文件实际24FAIL/12PASS/1SKIP(f111-wave18-desktop-plugin-adjacent.log),3为旧Host on夹具缺新公共connection/request/错误只认session/event,21为旧UI locale缺getSnapshot等公共接口。跳过的real-backend审计测试没有执行,不声称其通过。按原两文件上限补T142/T143,仅修夹具、保持原断言;T141增加第二审计测试目标并暂退勾。Gateway两YAML合法URI placeholder后,旧配置测试手工Binder缺resolver,1PASS/2FAIL(t092-neighbor-green.log名字含green实际RED);T091扩第二旧测试目标后暂退勾,原断言不放宽。尚未重新勾选这些任务,不把不完整test stub当生产API需optional兼容的理由。 + + +T034:唯一43行endpoint与28行composition,原T03336PASS;A完整内容安全+旧HMAC/错误唯一性/identity/编号 **359PASS/0SKIP**(f111-t034-adjacent.log),主agent实际HTTP/verify/ingest/identity/编号组合 **115PASS**(f111-wave18-backend-parent-final.log)。主完整读两源,原raw只读且无SQL/SSO回放/modelruntime,query前拒/64KiB/断连无ACK/安全错误映射合design。Ruff/format/F821/archguard/diff clean;task-review #1分层/#2命名/#3既定directReceipt及DshRoute/#6信息保护/#7设计同步PASS,SQL/frontend N/A;范围/已勾T033/T032依赖PASS。 + +T092:仅两YAML各新增29行,旧codec/应用WS路径/凭据原bytes不改,专用Path显式false/POST/-10/同backend placeholder与默认关闭;source-id注释去除不存在的registered含义。原T09124全部GREEN含真实默认Netty四模式透传bytes/status/编号、manager0,实际Profile/Chat/Responses同loopbackbackend。T091旧第二fixture只补Spring placeholder resolver,原3断言GREEN,RED2原日志保留。主读两YAML差异/旧test全部改动,集中Maven **21类467PASS/0FAIL/0ERROR/0SKIP**,逐21份XML核对(wave18-parent-final.log)。task-review配置subset架构/SQL N/A、信息泄漏/设计同步PASS;两生产源范围、已勾配对T091及T090依赖PASS,diffcheck clean。原测试第二目标兼容复验通过,恢复T091。 + +T141第二audit-integration夹具:真实未到业务的三项RED已保留,新公共connection/request由夹具提供无事件disposer,其余事件仍委托原services.on并保留原name/global/title断言。主读完整275行及实际差异,原两文件 **12PASS**(f111-wave18-desktop-audit-fixture-fixed.log),无生产改动/业务断言修改。命名/英文注释/完整AC/两文件范围及依赖PASS,恢复勾选。T034/T092/T091/T141验收后 **92/143**,T142/T143仍在收尾,不冒称全Desktop/端到端/双库或DM8通过,无提交/推送/部署。 + + +T142/T143最终:两账户UI测试文件/一市场UI文件只将不完整locale桩替换为同JSDOM realm发布包真实LocaleRuntime+Cordis Context,Context/disposers/DOM均清理,原产品断言逐字不变。原5FAIL/16FAIL分别保留f111-t142-before.log、f111-t143-before.log;修后5PASS/16PASS,对应after日志。C所选13文件193PASS、typecheck0(f111-t142-t143-{combined,typecheck}.log),主agent准确另13文件 **191PASS/0SKIP**(f111-wave18-desktop-parent-final.log),文件组合不同不混报。主全文读取三目标和全部diff,真实UI渲染继续原断言,不用生产optional chaining掩测试stub;无新库/生产改动。JS syntax及diffcheck PASS,task-review测试subset命名/完整AC/2+1文件范围/依赖T117 PASS,真实locale注册/卸载与cookie入口由T140独立验证。 + +两项勾选 **94/143**,Wave18全部完成,进入Wave19。无提交/推送/部署。queued语言生产组合探针当前停在正式SessionController所需typert.lookups配置,未执行排队,不据此声称生产缺陷或修复;后续特性验收仍记录未证实线索。 + + +全量扫描直接client.js消费者后仅剩旧enterprise-market-client夹具缺公共locale接口;实际定向RED见f111-market-client-locale-fixture-red.log。T143扩第二文件维护(仍两文件)并暂退勾;不把之前13文件通过宣称全仓通过。 + +排队语言线索升级为实际MEDIUM复现:C补真实TypertRegistry后,真实renderer LocaleRuntime/client cookie第二请求已ja,真实认证Connection/生产startEnterpriseLlm ALS/实际SessionController.prompt(mode:queue)/AttachmentStore/FileUploads/AgentLoop/实际注册adapter,第二安全错误仍zh。f111-production-queued-locale-typert.log。入口carrier为fixture,未挂generated Remote RPC或整Desktop,但生产回调与实际队列错误scope已达到,不能再仅标猜测。分类/编号/工具0保持;需要宿主公开扩展点的窄修,不改第三方包/模型鉴权或传locale到上游。原T117接线能力已验证不撤销其他行为,修复与回归另立明确任务,正在评估最小端口。 + + +T143第二目标复验:主读enterprise-market-client完整文件及diff,仅旧夹具换实际JSDOM/LocaleRuntime/Context/React,原4业务断言全部保持,await Context清理后只关本DOM。C与市场UI20PASS(f111-t143-second-after.log),主准确两市场+真实locale三文件 **30PASS/0SKIP**(f111-t143-second-parent-final.log);原4FAIL保留。两测试文件范围、命名/完整AC/依赖与L1测试subset PASS,恢复T143。 + +T035:唯一254行真实CustomMiddleware(实际类名,不存在HTTPLoggingMiddleware)+原T034/verify/ingest测试,21项 **10预期RED/11既有GREEN**,A另45相邻GREEN;主实际组合 **10预期RED/51PASS**(f111-wave19-backend-parent-red.log)。新入口缺精确tuple导致旧Cookie/Bearer 19103提前拒或残留6scope未清,原无Cookie实际commit及8邻近/非POST保留浏览器准入均GREEN;外部JWTdecode/DAO读端口可控,实际tokenversion判定不stub,Redis禁止实际调用。成功/401/SQL失败/断连后原scope恢复、真实严格租户落库均有断言。主全文读取,Ruff/format/F821/archguard/diff clean,L1测试subset命名/完整AC/唯一定义范围及T016/T034依赖PASS,勾选。当前 **95/143**,T093主回归正在执行,尚未写T036/T094。 + + +T093:唯一232行先行JUnit,私有编译成功,主普通Maven独立 **14PASS/8预期FAIL/0ERROR/0SKIP**(wave19-parent-red.log),A/B相邻4类107PASS(t093-neighbor-green.log)。8普通DSH(2协议×流/非流×开关)与6真实旧应用input/JSON/SSE命中+BlockRecord均现有GREEN;8RED为真实T088设置可信MANAGED后内部request decorator改旧path的显式防御契约,不宣称当前部署真实泄漏。真实filterNormal/ACPlus未stub,仅词库/持久端口/身份策略外部I/O mock,client Header无法设置server attr;并发logger capture恢复level/additive且仅清本case UUID缓存。主全文读目标,完整AC/命名/唯一文件范围/T016/T092依赖及L1测试subset PASS,diff-check clean。T093勾选 **96/143**,Wave19完成进入Wave20。 + + +## 20. Wave20最小入口实现与排队文案前置(2026-10-24,进行中) + +T036:仅http_middleware.py精确POST tuple **1行**,原exempt/6scope/浏览器Token/其他DSH行为字节不改。T03521GREEN;A内容安全+原DSH/SSO/错误/identity/编号 **384PASS**,另tenant/admin/tokenversion **58PASS**(f111-t036-{content,context}-adjacent.log)。主实际HTTP/middleware/tenant/admin/tokenversion组合 **106PASS/0SKIP**(f111-wave20-backend-parent-final.log),主补读并完整覆盖目标534行、实际diff唯一tuple。Ruff format/check/F821/archguard/diff clean;L1分层/命名/原序列化/严格tenant机制/安全/设计一致PASS,frontend N/A;范围/已勾T035/T034依赖PASS。 + +T094:仅两旧filter各新增 **3行** Boolean.TRUE MANAGED guard,主全文读189+343行和diff。原T09322PASS、相邻107PASS(t094-{green,neighbor-green}.log);主常规Maven准确 **22类489PASS/0FAIL/0ERROR/0SKIP**(wave20-parent-final.log)。旧filterNormal/ACPlus/URL/资源分类/BlockRecord/日志字节不改,正常DSH天然避开旧分类与模拟内部改写防御分别记录,不混称线上泄漏。用户header不生成server标记。L1职责/命名/信息保护/设计同步PASS,序列化/SQL/frontend N/A;恰两源范围、已勾T093/T092依赖及diffcheck PASS。 + +两项勾选 **98/147**;新增T144正在当前Wave测试,后续T145/T146/T147分开配对完整门禁,暂不宣称排队语言已修。无提交/推送/部署。 + + +T144:仅原Chat/Responses两维护测试,新增27cases,合 **19预期FAIL/67PASS**(f111-t144-final-red.log),原59断言全保留;新增8个非法factory兼容边界当前自然PASS,不造RED。factory在实际stream开始一次捕获session/purpose、并发相反完成/在途不随未来scope变、原callbackfallback/非法factory不影响普通模型/不改tenant/不进modelbody;Chat content_filter/error及Responses flat/nested streaming终态均覆盖。主读全新增两节及fixture端口,独立六文件 **19预期FAIL/148PASS**(f111-wave20-desktop-factory-parent-red.log),相邻C七文件110PASS(f111-t144-adjacent.log)。19RED确为未接factory行为,不是缺模块/fixture。JS syntax/diffcheck clean,task-review命名/完整AC/两目标范围/T137/T117依赖PASS。勾选 **99/147**,Wave20全部完成,进入Wave21。无提交/推送/部署。 + + +Wave21 T145审查中(未勾选):首版两adapter配对86PASS、C14文件224PASS/typecheck0(f111-t145-{first,combined,typecheck}.log),但主实际独立Node调用新共享resolver,factory返回Promise.reject时fallback已返回、unhandledRejections=1(f111-t145-rejected-factory-probe.log),可能干扰普通模型进程。初轮GREEN不足以覆盖非法异步factory拒绝,需要原T144测试范围补RED→GREEN,并对非法异步显示返回仅观察拒绝不await/执行;生产仍仅T145两源,不新增异常业务fallback。T145保持未完成,不把即将执行的T146 helper/queue测试误称语言修复已生效。 + + +## 21. Wave21查询/回传/限流前置及Desktop按流文案(2026-10-24,进行中) + +T039:唯一468行HTTP查询测试,37cases **32预期RED/5既有GREEN**,A加旧仓储/admin组装/审计流48PASS,组合53PASS/32RED(f111-t039-red-adjacent.log)。主全文读取并独立实际 **32预期FAIL/35PASS**(f111-wave21-backend-query-parent-red.log),失败具体为filters被忽略/7至31天窗口未限/detail仍PENDING/事件route404/readiness缺字段,无fixture异常。真实authorize_admin/is_tenant_admin/Service/SQL Repository,登录actor交付与外部身份/关系读取为端口替身,不冒称浏览器JWT或真实双库;普通/越tenant/superactive scope、历史actor退出、keyset绑定、白名单无tenant/hash、外部NULL和N_A、已发布0水位/pendingfalse及真实published_at等有断言。测试不绑定新service方法、不强改repo;readiness可经已有tree_state端口组合。Ruff/F821/format/archguard/diff及L1命名/完整AC/单文件范围/T016/T038/T036依赖PASS,勾选。 + +T079:唯一203行JUnit,52参数化/普通测试定义,B普通私有javac仅5处缺待实现Client/nested type;主独立FLOW诊断 **17错误全为同缺失Client/Disposition**(f111-root-t079-red/compile.log),未补生产桩,尚未到真实HTTP/receipt断言,不能称这些通过。既有共享SSO primitive对backend4固定向量×methodcase **12PASS**(t079-existing-primitive-green.log)。主全文读source,固定目标/原字节包装/Clock重签event不改/不直接ACK row、200 COMMITTED同ID才ACK、permanent6码隔离/其余状态retry、假receipt/重定向/自动retry/总deadline及日志保护配对完整;实际执行及隐藏类型检查待T080补类后再次验证。L1测试subset命名/完整AC/单文件范围/T016/T018/T036/T078依赖PASS,勾选。当前 **101/147**,其他Wave21任务继续,无提交/推送/部署。 + + +T145最终:两source实际协议stream开始一次捕获formatter,Chat在Responses委托后才捕获,Responses内部一次;公开scope固定sessionId/purpose,非函数/同步异常沿旧callback,tenant/分类/编号/工具结束逻辑不变。主额外两处实际Node RED分别factory非法Promise、被调用formatter非法Promise(CONTENT_FILTERED仍未处理1拒绝);原T144维护新增隔离Node子进程回归同时覆盖本机/cross-realm Promise、rejecting thenable、throwing then getter及真正四参requestFailure消费,不污染Vitest全局。仅对非法异步显示结果observe reject,不await/执行/采用。修后主原两probe均 **unhandledRejections=0**(f111-t145-rejected-{factory,formatter}-parent-green.log),对应RED均保留;C child RED4→GREEN及两源87配对PASS。 + +主读新共享resolver、全部HTTP/SSE/content_filter接入与新增child回归,六文件 **159PASS/0SKIP**(f111-wave21-desktop-factory-parent-fixed.log),此前包含原locale10的七文件169PASS;C十四文件 **225PASS/显式SKIP4**(仅新T146 queue预期RED,旧locale10均执行),f111-t145-rejected-callback-combined.log。不是把17个新helper/queue RED算通过,整队列仍待T147。最终typecheck0(仅TS集成)、两JSsyntax及diffcheckPASS。L1完整适用子集命名/既有包与i18n/信息保护/设计同步PASS,架构/SQL/序列化N/A;两生产文件范围/已勾T144依赖PASS,勾选 **102/147**。 + + +T095:唯一202行先行测试,45项 **13PASS/32预期FAIL**;主agent独立私有javac与实际执行同数(f111-root-t095-red/compile.log、tests.log),B同JVM邻近129项全部PASS,联合174项142PASS/32RED(t095-canonical-red-neighbor-union.log)。真实PathRate与真实LimitRuleLoader/Guava permit,未配置DSH不强增限流、旧应用资源规则与HTTP200/body429合同、邻近方法/尾slash仍保留旧分支均GREEN;RED为无缓存NPE、正文/Query日志、编码与matrix别名未规范化。LimitRuleLoader自身记录flowID,因此T096须在精确DSH入口统一为completions/responses后继续既有configured tryAcquire,不将敏感matrix传入日志。私有规则只逐key恢复,两个logger恢复level/additive,不clear全局map。主全文读取,L1测试subset命名/完整AC/单文件范围及已勾T016/T094依赖PASS,勾选 **103/147**。T146缺少合法语言提示的排队边界仍补测,不越过Wave21门禁。 + + +T146:仅两个测试文件,新增missing/invalid hint边界5例后 **22预期FAIL/原10PASS**;C f111-t146-missing-hint-red.log,主独立相同两文件复验同数(f111-wave21-desktop-scope-parent-final-red.log)。14项缺helper;实际SessionController.queue/Agent/生产cookie桥的双协议后续ja及缺失/非法hint8项均在真实双insert/claim、首轮completed、两模型请求和无copy入模型断言后因旧zh显示FAIL。insert时undefined必须作为已知缺失保存,不能claim时回用旧driver ALS;新factory接入后index不再同时交旧callback,防止undefined沿legacy fallback复活旧语言。主全文读取helper测试及真实queue新增段,L1测试subset命名/完整AC/两文件范围/已勾T145/T118/T140依赖PASS。勾选,Wave21完成 **104/149**;新增T148/T149是实际默认窗口游标问题的窄适配,尚未完成,不声称已修复。进入Wave22,无提交推送部署。 + + +## 22. Wave22回传、查询与排队作用域实现(2026-10-24,进行中) + +T147:恰两source helper/index,T146 **32PASS**(f111-t147-first-green.log)。主全文读新91行helper及index新增接线,实际Cordis dispatch/公开agentEvents与SessionController事件类型对照;pending按session+稳定messageID,claim含明确missing,匹配turn/end才清active,agent/session dispose及幂等unload清理,formatter保存冻结白名单copy。index移除旧localize回调,direct/auxiliary仍由factory捕获发起请求ALS,未转模型或消息数据。初次15文件245PASS/2FAIL仅T141旧audit stub把所有新events错送onTitle。主按T141既定第二测试文件改事件端口:只有session/event且global:true送原标题监听,原name/global/title/重试断言不改;其余真实locale生命周期另由T146。原RED f111-t147-combined.log保留。修后C准确15文件 **247PASS/0SKIP**(f111-t147-final.log),主独立五文件 **51PASS/0SKIP**(f111-wave22-desktop-parent-first.log),含原审计两文件、完整locale32及真实Agent工具7。typecheck0(仅TS集成)、JSsyntax/diffcheckPASS。L1命名/固定SSOT i18n/安全/设计一致PASS,DB/API N/A;两生产源、已勾T146/T145/T117依赖PASS。T141恢复,T147勾选 **105/149**,实际公开queue入口与工具屏障已验证,不冒称完整Remote RPC、正式Desktop启动或Gateway E2E。 + + +T148:恰两个既有test增量,3Repo窗口能力RED+1实际API移动窗口RED+1旧无now控制GREEN。A组合 **36预期RED/54PASS**(f111-t148-red-adjacent.log),包含原T03932RED;主实际Repo文件 **3预期FAIL/31PASS**(f111-t148-parent-red.log)和单API **1预期FAIL**(f111-t148-api-parent-red.log)。Repo缺now端口将其当ORM列导致AttributeError,API仍包括未来0.5秒记录;新增分页在clock+1s必须纳入新进入记录,不能靠冻结首轮窗口通过。显式start/end、安全筛选及tenant绑定原断言保留并补充,旧无now40天内部查询控制GREEN。主读两新增段及全部修改,Ruff/F821/format/archguard/diff与L1测试subset命名/完整AC/两文件范围/T038/T039依赖PASS,勾选 **106/149**。T149仅仓储文件,T040后接Service时再验API;不将仍RED的API记通过。 + + +T149:仅主请求Repository文件增量增加可选now端口;复用既有naive_utc时间校验/UTC规范化,7天默认及31天上限在仓储内部应用,fingerprint只用户显式filters,未传now旧内部查询不变。主实际T148仓储全部转GREEN及原groups **59PASS**(f111-t149-parent-first-green.log),新API仍等待T040接线不记通过。主完整读取目标所有实际方法与修改段,SQL只在Repo、SELECT严格tenant与原keyset/批量用量及姓名查询保持,无事件列表JOIN或全历史COUNT。Ruff check/format/F821、archguard及diffcheck PASS。L1分层/命名/元数据序列化/双库可移植谓词及tenant/无信息泄漏/设计§4.9同步PASS,frontend N/A;唯一目标及已勾T148/T038依赖PASS,勾选 **107/149**。C独立交叉审查仍进行,若发现确证问题修复并重验;T040可沿已通过配对端口实施。 + + +T149独立交叉补证:C实际仓储34PASS(f111-impl-c-t149-review.log),四个独立行为探针4PASS(f111-impl-c-t149-independent.log):默认下界推进移出未消费旧行、显式UTC/+08同一时刻续页、只显式start时隐式end继续推进、visible含101/202仍严格返回101。无确证HIGH/MEDIUM;SQLite仅证明Repository行为,实际双库计划仍待验收。 + + +T080:唯一180行新Client,原T07952项补类后完整编译并 **52PASS**(t080-absolute-deadline.log),无测试类型补丁/断言修改;B五类105PASS及四个真实loopback边界smoke PASS(oversize receipt重试、坏Unicode/oversize event隔离且HTTP0、Clock错HTTP0重试)。原payload字节保持,不修改row/mapper;固定origin/path、共享sign、签名头及每次sent_at、严格UTF8/JSON/depth与<=64KiB完整receipt、全流程绝对deadline/无redirect/retry及取消连接所有权均全文审查。T096唯一120行旧filter增量、T095原单test追加16个完整canonical配置别名case;先61项13PASS/48预期FAIL(t095-full-canonical-red.log)后61GREEN(t096-first.log),原45断言不弱化,实际Guava许可及旧拒绝合同保持。 + +主常规离线Maven **27类641PASS/0FAIL/0ERROR/0SKIP**,包含两配对、完整已实现DSH和原路由/WebSocket/许可/词库对照(wave22-parent-final.log),逐27份Surefire XML核对总数。B私有七类242PASS,wave22-impl-b-union-green.log。主全文读取两生产源和新增T095段;Ruff/SQL/frontend N/A,L1职责/命名/严格序列化事实与信息保护/设计同步、范围/配对/依赖及diffcheckPASS。两任务勾选 **109/151**。不以loopback代生产回传、真实双库/性能或Spring Bean组装;后者仍T139。 + +T014窄维护:未关联事件Query类型补tenant_id/user_id/model_id、association_status仅UNASSOCIATED,与实际新端点一致;旧共享主请求/组/树audit_status仍保留NORMAL|ABNORMAL合法子集,记录DTO的NOT_APPLICABLE既有支持不变。仅原类型文件,ESLint及完整Platform tsc-strict **551文件通过**(f111-t014-query-type-maintenance.log),无新库/HTTP或状态改变;不是全前端门禁结论。 + + +T040两source配对T039/T148 **72PASS**(f111-t040-paired.log),A扩大回归 **617PASS/3SKIP/13FAIL**(f111-t040-adjacent.log)。主实际读Service251/Endpoint330行,失败11为旧audit_database未建事件表、2为手工NOW=10/14数据在wallclock10/24的默认窗口外;不修改生产来绕缺表,也不全局改Harness clock(其模型调用仍用实时,单改query会反向排除新模型数据)。补T150唯一fixtures模型tuple登记事件,T151仅两手工固定数据测试注入query.now=NOW+1秒;原所有assert/SQL/tenant/游标检查保留,原Harness/NOW未改。 + +主独立全匹配test_content_safety*.py+test_model_audit*.py **611PASS/3SKIP/0FAIL**(f111-wave22-backend-parent-final.log),覆盖所有13个原RED。与A先前组合文件范围不同不混算;3SKIP为旧显式隔离HTTP、owned-store及本地MySQL opt-in未开启,不宣称全链或双库通过。Ruff/format/F821、guard/diffcheckPASS;主逐完整fixture、两旧测试和diff审查,T150/T151测试subset命名/全AC/各1和2文件范围/已勾依赖PASS。T040 L1分层/命名/原平台响应/SELECT严格租户且Service无SQL/信息保护/设计同步PASS,frontend N/A;两生产源及已勾配对T039/T038/T036/T149 PASS。T150/T151/T040勾选 **112/151**,Wave22全部完成,进入Wave23;全程无提交、推送或部署。 + + +## 23. Wave23目录过滤/投递前置与Platform API(2026-10-24,进行中) + +T045:唯一506行前置测试,18新case **15预期FAIL/3既有PASS**;A另36相邻GREEN(f111-t045-red-adjacent-corrected.log),主全文读取并独立原组合 **15FAIL/33PASS**,纠正六态夹具后准确新18项 **15FAIL/3PASS**(f111-wave23-backend-parent-corrected-red.log)。原初版误把CHECKING列为六态,主审发现后仅改为已确认DISABLED,未让实现接受新非法状态。真实请求投影/fold/tree_step先建代,同请求time/model/user/direction/state交集、BOTH、分层own/branch计数、默认7/max31、显式fingerprint/tenant及scan_budget续页覆盖;预算断言不强迫合法前置优化产生空页,只对确有未耗尽cursor的不足结果要求scan_limited。SQL EXISTS不被误判成必须内层LIMIT,外层候选和tenant约束有断言。没有fixture收集错,RED对应未实现direction/tree filters/now端口/安全summary。Ruff/format/guard/diff与L1测试subset命名/完整AC/单文件范围/已勾T016/T044/T040依赖PASS,勾选 **113/151**;未开始T046。 + + +T102:恰两API源308/514行,主全文读全部source与临时actual wrapped request+Axios synthetic adapter探针;独立 **25PASS**(f111-t102-parent-contracts.log),四原API/request文件 **25PASS**(f111-t102-parent-regressions.log)。C六文件40PASS、最终契约25PASS,目标ESLint0及完整Platform tsc-strict **552严格文件PASS**(f111-t102-{api-regressions,final-contracts,final-lint,final-typecheck}.log)。原拦截器真实解包及200业务拒绝/409证据保留/取消/无retry、404/503/network不假disabled、nullable/int64、200emoji合法码点与协议数组拒绝、legacyunknown及外部N_A/null、false过滤传递均有行为证据。临时探针不代长期T110组件回归、全frontend门禁或E2E。主L1 API边界/C7、命名/TS与旧optional兼容、无敏感日志/新403/新库、设计同步及两源范围/已勾依赖PASS,diffcheck clean。勾选 **114/151**;T081仍同Wave前置复核中。 + + +T081:唯一488行先行JUnit,28方法计划35调用,B私有javac仅4处缺待实现Dispatcher,主独立FLOW诊断 **6错误全为缺同一Dispatcher类型**(f111-root-t081-red/compile.log);不能把尚未执行的新35项记通过。既有OutboxService9+真实EventClient52 **61PASS**(t081-neighbor-green.log),原test辅助CAS/ManualScheduler独立编译及端口smoke通过,无生产Dispatcher桩。主全文读原source与修改后owner结束观察;原要求SQL outcome计数作为结束证据会强迫合法实现一定发过期CAS,改以真实dispatchOnce终止观测后再断言旧owner不改新owner/不假ACK。实际shared Service+行级Mapper CAS双实例、同实例ABA/超时/满批lease、可恢复丢ACK/真正断socket、jitter、close/阻塞claim、ACKED-only有界无COUNT清理均配对;真实双库和进程重启保留后续。L1测试subset命名/完整AC/单源范围/已勾T016/T080依赖及diffcheckPASS,勾选 **115/153**,Wave23完成进入Wave24;T152/T153仅分离组树真实Service时钟接线测试与实现,不偷占T046第三源。 + + +## 24. Wave24目录查询、持久调度与Platform配置(2026-10-24,进行中) + +T046:恰两Repo源407/275行,主全文读取及新增租户回归。T045当前19配对GREEN;主独立内容安全/全部旧model_audit及admin/middleware组合 **640PASS/3环境SKIP**(f111-wave24-backend-parent.log),与A完整组合一致。旧稀疏目录SQL预算断言保持原<35,通过合并就绪查询实际33条;T045单源维护新增C3案例:202待hierarchy不能让101已发布安全汇总unready,已记录实际RED→GREEN。所有筛选落同一Request,不用不同请求分别证time/model/hit;有界元数据查询,不读事件正文/模型body。Ruff及archguard/diffcheckPASS;L1分层/命名/原DTO/严格SELECT和显式写租户守卫/信息保护/设计同步PASS,frontend N/A;恰两源、已勾T045/T044/T040依赖PASS。MySQL CASE/EXISTS编译证据不代真实双库;DM8缺dmPython未编译。勾选T046。 + +T082:唯一213行Dispatcher,主完整读取;原T081 **35/35GREEN**,主常规离线Maven准确 **28类676PASS/0FAIL/0ERROR/0SKIP**(f111-gateway-tools/wave24-parent.log),无改动测试断言。16并发/批次100/3秒单回调预算、UUID owner及原lease、只COMMITTED ACK、SQL失败可恢复、关闭/时钟/抖动/ACKED有界清理均覆盖。B另真实阻塞Mapper探针关闭前后各100轮仅1个claim、HTTP0(t082-blocking-sql-bounded.log);不强加外层超时后释放gate使不可中断SQL堆积。实际数据库statement timeout仍T120待验收。L1职责/命名/既有数据契约/数据库方言与CAS边界/安全日志/设计同步及单源范围、已勾T081/T080依赖PASS;无@Component自启动接线混入。勾选后 **117/155**,其余Wave24仍实施中;无提交、推送或部署。 + + +T152:唯一257行真实HTTP/Service/Repo先行测试,主全文读取并独立 **9预期FAIL/16PASS**(f111-t152-parent-red.log);A与T04519+T03938相邻组合 **9预期FAIL/73PASS**(f111-t152-red-adjacent.log)。三个公开组/树/组内请求入口分别复现默认7天未生效、超31天未拒、未来0.5秒观察提前入选,精准对应Service缺now。显式31天/显式时间与安全条件及tenant游标绑定/默认移动不固化/实际同请求核验/原管理员权限/元数据不预取/历史location独立语义均有对照;不以Repo直调代API接线。Ruff/format/F821/archguard/diff和L1测试subset命名/完整AC/单文件范围/已勾T039/T046依赖PASS,勾选 **118/155**,允许配对T153。 + + +T153/T156:T153恰原Service三处增now=self.now(),主已全文读251行及实际三处差异;无Service SQL/隐式bounds/游标实现混入,location/parent保持独立历史定位。A配对82GREEN后原两手工历史记录HTTP断言RED,主独立 **2FAIL**(f111-t156-parent-before.log),不是生产算法问题。T156各唯一旧case加NOW+1秒查询时钟(tree文件只另补必要import),原断言保持,未改全局Harness或模型记录。主新API25+原组/树 **52PASS**(f111-t153-t156-parent-green.log),最终全内容安全/全部model_audit及admin/middleware **665PASS/3环境SKIP**(f111-wave24-backend-final-parent.log);全部原窗口9RED与旧2RED已闭合。Ruff/format/archguard/diffcheckPASS。T153 L1分层/命名/既有响应/严格tenant边界/安全/设计同步及单源范围、T152/T046配对依赖PASS;T156测试subset命名/完整AC/仅两文件与已勾T152依赖PASS。勾选两项 **120/156**,真实DM8和全链E2E仍待验收。 + + +T154/T155:Platform正式@bisheng/ui workspace:^本地链接;pnpm生成曾带无关peer图重解析,已拒绝该变更,仅保留生成的本地link,程序比对旧lock全部importers/packages/snapshots/元数据逐项不变。离线frozen-lockfile install成功(f111-t154-frozen.log),无其他manifest或嵌套lock残留。T155唯一Vite配置+physical CSS入口,从公开token/preset真实编译90,843bytes;主执行实际production plugin **613普通selector全部有scope**、dark/green顺序、touch及keyframe隔离PASS(f111-t155-probe-final.log)。新配置类型问题已修,主TS compiler API原HEAD与现源码对比均仅既有http-proxy类型缺失,无新增诊断(f111-t155-ts-compare.log);不声称整个Vite config历史全绿。主全文读取两源/实际包与lock差异、零新依赖闭包、L1基础设施边界/无设计值复制/信息保护/设计同步/两任务各≤2文件及依赖PASS。 + +T103/T105/T106:主完整读取五源(设置+hook、Filters、Card+Details),使用landed共享组件、局部v4 Provider、原wrapped request,无store HTTP/403分支/新库/HTML渲染。主独立正式包组件 **17PASS**、筛选 **6PASS**、详情最终 **10PASS**(f111-{t103-parent-official,t105-parent-private,t106-parent-final}.log);原DSH/API相邻 **24文件204PASS**(f111-wave24-parent-frontend-regressions.log),完整Platform strict **602文件PASS**(f111-wave24-parent-frontend-typecheck.log),五源目标ESLint PASS。C各旧50回归及原18Card/Body断言保留。主审发现刷新失败后retry误用旧分页cursor,新增实际 **1RED/9GREEN→10GREEN** 后用attemptCursor记录确切重试请求;新区域详情按钮使用共享Button,旧Card按钮不变。三locale validation新增checks_required/reply_too_long/range_too_large并修旧默认窗口提示,主递归内容安全key parity PASS,无改generated api_errors。 + +C隔离Electron43/Chromium150、临时独立profile、真实production样式插件和组件、合成loopbackGateway浏览器验证:保存/101→202切租户/zh-en-ja/390宽scroll379=client379/console错误0,外root radius .58rem与内0.5rem独立;暗色过渡完成后Textarea父背景18/18/18(f111-wave24-gui/result-settled-final.log)。主实际查看en截图,布局与组件可读;最终图run-Hd6zRf/{zh,en,ja,ja-narrow,ja-dark}.png。暗色初始浅色为transition捕获时序,等待350ms复核,不为此改共享样式。临时Vite/Electron均停止。此为真实组件集成预览,**不代T104真实导航入口/真实账号权限/端到端或信创浏览器验收**。L1前端边界/命名/可空语义/i18n/安全/设计同步及各2/1/2文件范围、已勾依赖PASS。五项统一勾选 **125/156**,Wave24完成,按依赖进入Wave25;无提交、推送或部署。 + + +## 25. Wave25注册/遥测前置与实际页面入口(2026-10-24,进行中) + +T097:唯一307行前置JUnit,28方法、按固定Counter16/Gauge13预计60调用,尚未执行,不记通过。主已全文读,独立私有javac **114诊断均为缺Telemetry及其Counter/Gauge/Alert/Snapshot类型**(f111-root-t097-red/compile.log:4缺package/110缺symbol),未见其它业务类型/fixture错误。B四相邻类 **157PASS/0SKIP**(t097-neighbor-green.log),helper独立编译/Wrapper端口smoke通过,但不代待实现telemetry。主审删除未传被测对象的无关联mock verifyNoInteractions空证明;真实Dispatcher自己的Mapper交互断言保留。实际持久ACK/RETRY/QUARANTINE、CAS0/SQL失败、后台固定4状态聚合且不读payload、不在模型投递新增statsSQL、unknown样本非健康0及日志无rawThrowable均配对。缓存/profile等枚举观察端口测试**不冒称实际调用已接线**,新增T157–T164四组有限文件配对补齐,T138/T139在其后用同实例。L1测试subset命名/完整AC/单源范围与已勾T016/T082/T096依赖PASS,勾选 **126/165**,允许T098。 + + +T165:主唯一既有导航测试维护,独立原 **1FAIL/1PASS**(f111-t165-parent-before.log)仅固定5tab计数;改为6并追加真实新tab点击→content-safety URL→Settings heading断言,原audit/access/plugins/隐藏operations回退断言保持。实际 **2PASS**(f111-t165-parent-after.log),不mock新设置业务准入或QueryProvider。L1测试subset命名/i18n/单文件及已勾T103依赖PASS,勾选 **127/165**。主同时补齐新增测试任务的59AC表正向追溯,原AC编号与章节未改;T104仍等真实入口补验后验收,未越过后端实时权限链。 + + +T107:恰hook+Panel两源,主完整读取并跟踪所有hook调用点(父useMemo/组state均稳定query引用)。主独立真实Panel/Hook **7PASS**(f111-t107-parent-final.log),与实际DSH入口/旧列表组合 **23文件186PASS**(f111-wave25-parent-frontend-regressions.log),C四旧35PASS及完整Platform602strictPASS。覆盖false/BOTH/六态组合、apply清cursor、refresh沿applied但保留dirty draft、新补命中入Card、租户key重建/旧transport不认abort也不能落迟到数据、旧缺字段unknown、明细按需、不适用null。hook新scope首render也不暴露旧page;原group查询引用/有界API/数据权限链保持。L1前端分层/C7、命名/TS、nullable兼容/i18n、无raw HTML/log/403、设计同步与2源范围、已勾T105/T106依赖PASS,diffcheck clean。勾选 **128/165**;T104仍处理Tabs既定规范的未保存草稿保护,不将入口未收尾提前记完成。 + + +T098:Telemetry125+Dispatcher331两源,主逐段完整读取及最终冻结复核。T097原60GREEN后,主审发现并发ACK较旧now后完成会倒退lastSuccess;原单测试源授权补2例(ACK-A等待SQL、ACK-B/新数据库样本先写)真实 **60PASS/2FAIL→62PASS**(t098-ack-order-{red,green}.log),只历史lastSuccess取max,occupancy正常可下降。B五类 **219PASS**;主集中常规离线Maven **29类738PASS/0FAIL/0ERROR/0SKIP**(wave26-telemetry-parent.log),逐29XML汇总一致,原35Dispatcher和前驱全部保留。实际不可中断SQL及阻塞exporter探针分别queryCalls1/maxSQL1,取消后100+close后100轮无新SQL,原投递继续2轮(t098-sampling-{sql,sink}-green.log)。后台采样/导出独立loop、实际slot finally释放;unknown样本清当前健康值,历史成功保留,不读payload或新加请求I/O。L1职责/命名/固定枚举/安全code+type不带message/stack/设计同步及两生产源、已勾T097/T082/T096依赖PASS,diffcheck clean。SQL聚合4返回行界不冒充扫描成本界、远端卷压力未实测;留T120等如实验收。按已满足依赖先完成T098,勾选 **129/165**;Wave25 T047/T104仍收尾,未假称整Wave26完成。 + + +T047:主全文读取最终516行(原456+真实迟到native回归),独立 **16预期FAIL/1真实signalsPASS**(f111-t047-parent-final-red.log);A相邻原Worker/tenant共 **16预期FAIL/20PASS**(f111-t047-late-native-red-adjacent.log),失败均为待实现Worker模块不存在。真实Celery eager apply_async/prerun/postrun/异步桥/SQL有界executor和投影Service不stub;父pytest旧mock以独立解释器隔离。publisher仅捕获投递边界再执行原Task;两正常进程退出共享SQLite证明丢内存续页后可扫持久状态,不代真实broker/kill9/双库锁。缺/错/string/bool/zero header、SQL失败恢复、100批/租户续页、disabled/archived、旧owner、幂等及跨租户scope清理均配对。主审增加MODEL先UNREPORTED发布、首FINAL/BLOCKED后到仍groupPending而旧水位不变的实际场景,真实Repo夹具探针确认成立(f111-t047-native-first-fixture-probe.log)。发现现有树扫描仅看hierarchy/state dirty会漏此case,旧pending_tenants却含无关运行模型,因此T166/T167补窄Repo端口,禁止048内藏SQL/第三文件。Ruff/checkformat/F821/guard/diff与L1测试subset命名/fullAC/单文件/已勾T016/T046依赖PASS,勾选 **130/167**。 + + +T104:主全文读取最终159行入口及33行sections,真实backend admin_runtime/UserPayload与GatewayAdminClient授权链复核后修正最初误沿旧审计mode的child只读假设;child只能自身leaf,global才目录选择。主独立最终入口 **13PASS**(f111-t104-parent-verified13.log),包含实际Settings+mutation提交target202、global切租户、未知target不请求、旧身份fallback、普通用户原403、Gateway离线旧tab可用、身份/部署变更卸载。Tabs规范复核发现未保存输入会丢:新临时case **1RED→GREEN**,仅原index保持首次访问后panel hidden、切回草稿/目标不变且不自动保存;首次未进入不挂业务取数。主最终导航/DSH **23文件186PASS**(f111-wave25-parent-frontend-final.log),完整Platform602strictPASS,4目标lint/diffcheckPASS。 + +C实际入口Electron43/Chromium150+BrowserRouter+真实TenantSelect/Settings/wrapped API(仅合成userContext/loopback transport)验证global101→202、草稿→Access隐藏→切回目标/草稿保留,三语已逐张view有效截图;主实际view最终窄图。新tab溢出真实对照旧5doc402/client379、新6doc496/client379,原index窄滚动修后390doc=client390、内scrollWidth488/client350、scrollLeft138末tab可达,原生Right切换和Tab焦点可见(f111-t104-gui/result-final-focused.log;run-5P8dI3/ja.png,narrow-six.png)。newErrors0;访问既有Access Settings的Input style-jsx non-boolean React warning1单列保留,**不再笼统声称console0**。截图paint竞态不完整图不采用,未为截图改业务。私有服务/窗口均停止,非真实账号/部署E2E。L1前端C7/命名/原路由兼容/权限边界/i18n/安全/设计同步及2源范围、已勾T103依赖PASS,勾选 **131/167**,Wave25完成进入Wave26。原T099/100/101各单locale维护补own_hits/branch_hits两明确安全计数label,三语同步,未动generated文件。 + + +## 26. Wave26安全扫描与诊断实际接线(2026-10-24,进行中) + +T166:既有test_content_safety_tree.py单源增4case(总480行),主读完整新增场景及复用夹具,独立最终 **4预期FAIL/15PASS**(f111-t166-parent-final-red.log);A加旧tree/worker/T045 **4预期FAIL/48PASS**(f111-t166-red-adjacent.log)。缺include_group_pending参数为唯一RED;disabled/archived实际native发布→首FINAL迟到只groupPending、旧default/显式False无改变、新True应发现并处理后清,真实RUNNING无dirty对照、101脏租户100keyset两页及只ID无COUNT/DISTINCT/GROUP BY均有断言。主审移除恰两个EXISTS的实现形状过约束,保留相关查询/有限SQL/语义,不要求合法等价实现固定子查询数量;原业务断言未放宽。Ruff/checkformat/F821/guard/diff与L1测试subset命名/fullAC/单源/已勾T047/T046依赖PASS,勾选 **132/167**,允许T167。 + + +T167:唯一既有Hierarchy仓储,主全文读取159行及实际新增signature+条件;旧默认/显式False谓词保持、True额外OR groupPending,沿原Tenant相关EXISTS/只ID/100keyset,未在Worker/API/Service放SQL。主独立新19+旧tree8/worker6/T04519 **52PASS**(f111-t167-parent-green.log),A同52PASS;Ruff/format/F821/guard/diffcheckPASS。L1分层/命名/编号序列/SQLAlchemy双库可移植与明确只ID跨租户枚举/无敏感数据/设计同步及单源、已勾T166/T046依赖PASS,勾选 **133/167**,允许T048调用新端口。 + + +T157:唯一293行先行测试,主已全文读取并在独立私有javac目录编译、实际执行 **24预期FAIL/1既有PASS/0SKIP**(f111-root-t157/{compile,red}.log)。2项缺实际可注入构造器、22项真实Cache/Verifier行为无observer;不是生成stub或手工increment冒称接线。B相邻Cache38/Verifier18/Client24/Telemetry62合计 **142PASS**(t157-neighbor-green.log)。口径限定HIT为真实成功缓存返回、MISS及latency为一次物理profile订阅、MERGED为额外准入等待者;negative命中不充成功HIT、不重复计物理错误,取消不滑动TTL/伪释放在途槽,latency不含Redis发布。主L1测试subset命名/完整AC/单源范围/已勾T098依赖及信息保护PASS,勾选 **134/167**,允许T158两源实现;未进入下一测试配对。 + +T130只读预检(尚未验收):实际workspace check-i18n PASS,Platform602 strict及共享UI lint、file-viewers lint/typecheck PASS。完整lint为Platform93项、Client21项加既有unused suppressions;Client1354 strict文件中1项useAiChat.test.ts:294缺parentMessageId/sender。C将完整HEAD源码/config/baseline放私有临时快照后运行相同命令,93/21的file/line/column/rule/message和该TS2345逐项一致;配置、相关源码及3份baseline哈希未改(f111-t130-preflight/head-comparison-summary.json及README.md)。早期stdin-only 137/23不是完整抑制计数,已由实际HEAD全量结果取代。无修无关源/基线/抑制文件,T130保留未完成,不将本次预检视为全前端门禁通过。 + + +T048:主完整读取79行Worker、114行Registry和T047维护区域,注册仅import+call两行。主独立真实Celery/子进程配对 **17PASS**(f111-t048-parent-green.log),A其他内容安全 **456PASS**、旧Worker/context **39PASS/3Redis环境SKIP/2external未执行**。原fixture误以get_current_tenant_id有效admin202代表raw leaf;授权记录raw101并保留有效scope原值、SQL线程(101,None,{101},False)及全部恢复断言。ready/counts观测也改用真实profile_scope,前后scope恢复新增断言,未改通用fixture或生产signals。宽旧tenant15FAIL/10settingsERROR在原main同synthetic配置实际重现,nodeid一致(f111-t048-main-tenant-baseline.log);另2外部DB缺连接未执行。Worker只调Domain/有界executor、无SQL、dirty-ID扫描包含停用/归档且不选无dirty运行模型,任务tenant参数/header严格正int64一致、异步桥前捕获header。L1架构/数据库线程租户/安全日志/设计§4.9及两源范围/已勾依赖PASS,Ruff/format/F821/guard/diffcheckPASS。勾选 **135/167**,允许T049先行测试;不冒充真实broker、强杀或双库锁验证。 + + +T158:主完整读取Cache450行/Verifier222行,保留旧构造并新增尾部Telemetry;Cache唯一计物理profile MISS/ERROR/latency,真实成功L1/Redis计HIT,Verifier仅MERGED/有限队列拒绝与锁内实际active/queue。物理profile结果前置观察于Redis发布,错误/取消原子一次,TTL/lease/到期预算及所有真实IO顺序保持。主私有javac及独立真实loopback联合 **167PASS/0SKIP**(f111-root-t158/{compile,green}.log),B同167PASS;最初沙箱内loopback bind失败已用授权随机本机端口重跑,不当业务回归。L1命名/固定标量/安全类型无raw正文/设计§7.3及恰两源、已勾T157/T098依赖PASS;无新增profile/Redis/SQL。勾选 **136/167**;组件装配统一实例仍T139,不提前声称生产已启动。 + + +T161:主单源先行JUnit234行,真实Manager/协议adapter/Outbox recorder,只替换transport/SQL。私有javac成功,最终 **24预期FAIL/0SKIP**(f111-root-t161/{compile,red}.log),2缺构造、22为真实counter0/gauge未知;主修正合成Responses数组/annotations及Chat必填metadata后复跑,不把样例错误算预期RED。相邻实际loopbackManager30+Outbox9 **39PASS**(同目录neighbors.log)。真实输入/双协议非流式输出/流式DECISION→DRAIN一次命中、有限check/全局bytes、普通取消/关闭释放、HTTP写未完成仍计费、缓存record结果重订阅一次、SQL throw/0/非法事件gap与safe log均覆盖。B独立只读fresh-eyes建议已采纳:普通取消/关闭CHECK_ERROR0,backend原401/200纯error及流式error不充检查故障;现有模型状态不改。L1测试subset命名/完整AC/单源范围/已勾T098依赖及信息保护PASS,勾选 **137/167**,允许T162两生产源。 + + +T159:唯一367行测试,主全文读取、私有独立javac及实际 **15预期FAIL/0SKIP**(f111-root-t159/{compile,red}.log),构造/age/active缺失与真实mismatch/rejection为0;原四类 **124PASS**(t159-adjacent.log)。真实Policy/Matcher、只有SQL/Redis和调度Executor端口替身,普通hit不续age/5秒expiry、共享PTTL扣roundtrip、真实permit取消仍占用、executor/node/weight拒绝固定分类一次、输入413不混拒绝、CAS409不发布草稿/单CAS/权重eviction持有旧matcher/AC suffix语义均配对。无手工increment/新依赖;L1测试subset命名/完整AC/单源/已勾T098依赖PASS,勾选 **138/167**。为并行推进,T160两个生产源明确移交C,B只负责T162/后续T164,不交叉写同文件;前端T108仍等T050。 + + +T049:唯一272行先行测试,主全文读取并独立实际 **3预期FAIL/4PASS**(f111-t049-parent-red.log);三个均为真实Beat缺唯一扫描task,无收集或fixture错误。A新旧组合 **3RED/70PASS**(f111-t049-final-red-adjacent.log)。实际Celery Scheduler→scanner→headers/signals→严格SQL覆盖101及disabled202;API assembly在独立进程读取持久pending/已发布/追加dirty保留旧水位/再发布/重复不增代。原Service已有readiness接线如实GREEN,不强造接口;真实model/audit构造无Gateway/缺或空共享接收key均可用,禁止网络探针未被触发。SQL为隔离SQLite,非双库/真实broker证明。Ruff/format/F821/guard/diffcheck及L1测试subset命名/fullAC/单源/已勾T016/T048依赖PASS,勾选 **139/167**,依赖满足允许T050,不宣称整个Wave27完成。 + + +T162:主完整读取Manager587/Outbox255行及原source窄diff,独立私有javac及真实loopback组合 **240PASS/0SKIP**(f111-root-t162/{compile,green}.log),B同240PASS,T16124及旧Manager30/Outbox9/Routing29/Telemetry62/Dispatcher35/Preflight51全部保留。原锁更新bytes/check/drain,从原DECISION幂等闸门一次命中,Request错误标志一次观察;普通取消/close、原backend纯error不混检查故障。流式复用既有synthetic判断、非流式原数组引用区分检查失败,不新增正文解析;Outbox原cached persist失败点计一次gap+固定alert,Manager不重计。L1有限标量/安全/既有响应与IO不变/设计§7.3及两源、已勾T161/T098依赖PASS,勾选T162。 + +T163仍自审中:初次12FAIL/11PASS里encoded-slash样例遭MockServerHttpRequest字符串重编码,实际status为null,不能归为诊断RED。已立即保留任务未完成,按原Filter测试采用URI入口校正后重跑;T162已独立通过并勾选,目前 **140/167**。 + + + +T163最终:主纠正MockServerHttpRequest使用URI原样入口后,实际 **12预期FAIL/11既有PASS/0SKIP**(f111-root-t163/{compile,red}.log);1缺构造、11全为真实入口拒绝counter0,无fixture/收集错误。原Filter51已在主T162240组合GREEN。部署关闭/无关路由/两个成功路径/租户关闭与license优先级/普通取消/downstream原异常为GREEN;身份401/403/503、empty/sync/async、policy缺失/失败/错租户、license及encoded-slash拒绝保持原status、错误优先级和正文零订阅。B独立fresh-eyes全文读测试+Filter无新阻断。L1测试subset命名/fullAC/单源/依赖T098/T096 PASS,正式勾选,**141/167**,允许T164唯一Filter源;原不准确记录已撤回。 + + +T160:主读取Policy350/Matcher264行,私有独立-Xlint:unchecked编译0诊断,六类实际loopback组合 **161PASS/0SKIP**(f111-root-t160/{compile,green}.log);C连三协议110项九类 **271PASS**,未把不存在类的早期discovery试跑计通过。旧及生产Redis构造兼容,生产内部matcher也接同Telemetry。age只用既有tick/validUntil重建保守缓存年龄,hit不续期;compile原Semaphore转移+采样同锁,缓存命中不重置别的job,cancel不提前释放物理permit。busy/node/weight固定code各一次拒绝,输入限额/digest/closed不混容量,digest实际拒绝点一次alert/counter。L1架构/命名/信息保护/设计§7.3、恰两源及配对T159/T098依赖PASS,diffcheck clean,勾选。 + +T050:主完整读取39行config和仅4新增行,独立原7配对 **7PASS**(f111-t050-parent-green.log),A含旧scheduler/runtime/API/context组合 **85PASS**(f111-t050-green.log)。扫描沿既有DSH enabled区块/default queue/原audit_state_scan_interval_seconds,copy+setdefault不污染原schedule、显式override/reimport不重复。audit_admin_runtime真实readiness在前驱已接通,本任务无需改其源码;没有为凑两文件引入行为。Ruff/format/F821/guard/diff及L1职责/无新SQL或接收配置依赖/信息保护/设计§4.9、一源范围和已勾T049/T048依赖PASS,勾选。两项后 **143/167**,允许T051及T108按已满足依赖并行;GatewayT164/组装链仍继续,不冒称全部Wave已完成。 + + +T164:主全文读取260行Filter和仅observer窄差异;原6参noop兼容、新7参尾部注入,唯一原reject点计CHECK_ERROR,未改模型body、响应或错误优先级,不重计依赖counter。主集中常规离线Maven **33类825PASS/0FAIL/0ERROR/0SKIP**(wave26-observers-parent.log),按日志33个类名逐XML汇总一致,含T16323/T15725/T15915/T16124及全部原738。B相邻10类229PASS,自审PASS。L1边界/命名/信息保护/设计§7.3及单源/已勾T163/T098依赖PASS,勾选,原 **144/167**,Wave26完整完成。随后新增已确认防复活需求的必要原生writer接线配对T168/T169,总数改为 **144/169**,未假勾新增任务;T139仅在2源范围内移交Outbox自动扫描所有权,Spring真实注册/唯一实例由T138验证。 + + +T138组装前只读核查:现有PolicyStore构造仅持Mapper/Clock,Admin构造校既有backend URL并建惰性WebClient,无网络;无需为无新配置分支重写两类。Outbox@Service确会自动构造noop observer实例,因此T139第二源仅移交其Bean所有权。当前application.yml和其它生产配置没有model读取timeout,不能借connectTimeout或魔法数充既有预算;已把设计“沿用部署IO预算”落实为检测开启须显式配置现有spring.cloud.gateway.httpclient.response-timeout正有限值,同值注入Proxy/Manager;默认off无该值仍正常,完整回传配置off仍管理/投递。T138真实绑定/组件扫描覆盖这些条件,不新增配置键/第二套密钥。 + + +T051:主完整读取511行及最后bulk观察修订,独立 **25预期FAIL/1真实竞争控制PASS**(f111-t051-parent-red.log),全部RED仅缺维护Repo/lock_retention/begin_cleanup,无fixture/schema错;A原repository/setting/groups/queries组合 **25RED/76PASS**(f111-t051-bulk-scope-final-red.log)。真实caller事务/独立提交watermark、rollback、三轴RUNNING/PENDING后续重访、旧主可补/删后410、会话delta/tree dirty、有限rebuild/已清不假回放、双租户和billing不变均配对。主审收窄engine SQL全UPDATE当bulk的过约束:Session.do_orm_execute只捕实际bulk,仍逐条tenant+目标守卫;原engine记录继续证明setting首锁/真实DELETE。首次缺setting竞争有实际第二connection唯一键控制,但SQLite不作双库锁证明。Repo不得commit/新Session,水位独立提交由T054编排,原生writer防复活另T168/T169。主清理仅主审计+关联安全facts,不销毁无FK的原生Manifest/Evidence/Occurrence/共享对象/密钥或模型用量;body读取先验主行/授权,不能声称原文空间已回收。Ruff/format/F821/guard/diff及L1测试subset命名/full7AC/单源/已勾T016/T038/T050依赖PASS,勾选 **145/169**,允许T052两源。 + + +T138:主完整读取248行及最终禁Bean覆盖发现设置,独立私有javac/真实Spring feature注解扫描+loopback **18预期FAIL/5PASS/0SKIP**(f111-root-t138/{compile,red}.log);18仅缺组装/启用校验/Outbox默认扫描,既有廉价Store/Admin/Controller实际启动无IO与默认capability认证链GREEN。B旧相邻206PASS;主同链前次825PASS为当前已通过基线,不把组件slice冒充完整部署。测试覆盖真实唯一实例/同Telemetry+Matcher/同HMAC固定backend/已存预算/filter顺序/关闭实际owned资源及关闭检查仍真实签名投递旧事件,未构造假配置或删Bean。主L1测试subset命名/fullAC/单源/全部已勾依赖PASS,勾选T138。 + +T108:主完整读取Group249/Tree286行,独立临时实际组件 **12PASS**、原目录/审计/navigation/API **24文件195PASS**(f111-t108-parent-{behavior,regressions}.log),C目标lint0/完整602strictPASS。旧缺progress/nullable不报0,真实全会话/筛选与own/branch独立Badge、collapsed已知命中、版本0/安全水位、子页progress与有限scan continuation不新增逐行GET。每对象最多2Tag,新区域CSS scope,原布局/逻辑保持。主实际view生产bundle日语窄图,C三语实际GUI counts[1234,0]/[0,2]/unknown、Badge10/Tag12px、390scroll=client390、console0/API0(gui-built.log),临时资源已关闭;非真实账号E2E。主locale两纯label三语同步、126key parity PASS。功能L1与两源范围/已勾依赖PASS,勾选T108。已确证正式Vite dev优化错选merge2.6丢字号,不能只当截图选择器问题;生产Rollup正确,另T170单构建源修复,未覆盖业务/shared样式。两项后 **147/169**,新增T170后 **147/170**,后续T110须该修复。 + + +T139:主完整读取Configuration160行、Outbox唯一移交的2行删除,常规共享target离线Maven集中 **34类848PASS/0FAIL/0ERROR/0SKIP**(wave28-assembly-parent.log),按34类名逐XML汇总一致;含真实Spring条件扫描23配对及全部原825。B私有296PASS及off Redis listener故障1PASS。默认off不建模型资源/不需timeout;完整配置off仍管理/真实HMAC补报,on显式校既有response-timeout与Redis;同Telemetry/Matcher/Clock、有限owned scheduler、shutdown解除listener并dispose,无重复@Service或假Bean覆盖。主L1职责/配置边界/信息保护/依赖/两源范围及设计§7.3 PASS,勾选,未当完整网关部署证据。 + +T052:主完整读取最终Maintenance275/Setting105行,独立8文件 **164PASS**(f111-t052-parent-green.log),A配对+相邻 **172PASS**、5项实际SQL临时probe PASS,均隔离SQLite,非双库锁验收。首次setting唯一sentinel flush、单调水位caller先独立commit、每批≤100且facts共享预算、running/pending排除并下轮重访、明确tenant/状态CAS、group delta和first/last索引约束、tree dirty及剩余子会话可见、rebuild原hash/保留窗口/过期skipped均核对。Repo无commit/新Session;不回收原生正文元数据/共享对象/密钥/计量,不能声称原文存储已释放。Ruff/guard与L1两源/已勾依赖及AC一致性PASS,勾选。A扩大特殊runtime→ingest→API→middleware顺序36FAIL/101PASS,以HEAD原Setting独立内存overlay确证同36FAIL/101PASS且endpoint module与sys.modules分叉,Maintenance未加载;保留旧fixture隔离缺陷证据,不归T052回归、不越界修复。两项后 **149/170**,允许T053和T168各自唯一测试先行。 + + +T170:主单构建源仅2新增行(对T155稳定preimage比对),optimizeDeps.include预编译@bisheng/ui自身依赖图,保留UI1.14/Platform2.6锁定merge;未加依赖/改组件/设计值。正式Vite5 dev真实helper DOM before丢text-caption-sm/12px → after保留/10px,Tag12px;同维护plugins/resolve的独立合成入口三语+390实际GUI PASS,console0/API0、根radius不变。新生产bundle独立重建4.05s(仅通用chunk-size提示)、三语+390 GUI再PASS,主view dev日语窄图。Platform原cname真实dev import 5合并语义PASS,613selectors/90,843bytes scope probe PASS,配置TS对HEAD基线仅同一http-proxy声明缺失/新增诊断0。证据f111-t170/{font-before,font-after,gui-after,legacy-after,production-build,gui-production,scoped-style,config-typecheck}.log。初次全GUI私有入口被HTML插件注入正式app而超时,仅调整私有harness剔除HTML入口注入后验证,未当业务失败或有效E2E。主L1基础设施架构/无DB/信息保护/设计同步/一源及已勾T155/T108依赖PASS,勾选 **150/170**;T110待T109主验后解锁。 + + +T171:主唯一测试源把冷启动检查移真实独立Python进程,guard在首次import前安装,仍真实构造IngestService且session_factory零调用,并验证父进程module身份不变。原同5文件顺序 **36FAIL/101PASS → 137PASS**(f111-t171-five-parent-green.log),单独cold1PASS、4文件131PASS;Ruff/format/guard/diffcheck PASS。冷启动行为检查保留且隔离更严格,无API fixture或生产源改动,无HTTP断言放宽。L1测试subset命名/完整AC/单文件/已勾T032/T034依赖PASS,勾选 **151/171**。此前T052的HEAD Setting对照证明归因边界,本条修复本特性T031自身fixture污染,不把已知顺序错误留到交付。 + + +T109:主完整读取最终List329/Panel565及字段a11y最后差异,独立 **16行为PASS+原24文件195PASS**(f111-t109/parent-{behavior,regressions}.log),C最终603strict/targetLint0/同回归全绿。独立局部v4 Provider/tenant+caller+权限scope key,旧页hidden保draft/cursor,首次进入才查events,6支持条件/显式UNASSOCIATED,未固定默认相对时间,有限20页,错误固定文案/失败页原cursor重试、旧scope abort/忽略。现有Group/Tree进度仅父页面传递,无逐行HTTP;实际metadata详情无原文/usage伪造。Input具体error状态先2RED/14PASS→16GREEN,日期两field,ID仅非法field。C实际wrappedAPI+strictDTO三语合成生产GUI PASS,390 scroll=client379、零renderererror、三exact HTTP query无隐式日期;主view日语窄图,非真实账号/后端筛选E2E。三语新增3key分别落原T099/T100/T101各自bs.json维护范围,contentSafety **129key parityPASS**,不碰generated api_errors。旧bs-ui Input style-jsx warning单列,不泛称全旧页console0。主L1命名/接口边界/前端约定/信息保护/设计一致/两源及已勾T108/T102依赖PASS,勾选 **152/171**,T110依赖均满足。C已关闭独立41786服务,主T170的41789/41790也均关闭。 + + +T052/T053交叉修订门禁:主在已完成T051唯一测试源补已提交主清理水位后的details-only案例,真实RED缺include_audits;暂撤T052,修复仅原Maintenance增加默认True兼容参数,False只明细后返回,不修改主checkpoint/cursor。主读取全部差异并独立8文件 **165PASS**(f111-t052-parent-final-green.log),A27retention+相邻140PASS,重新通过L1并恢复勾选。T053最终456行33case主全文及参数化新场景读取,独立 **1缺脚本FAIL/31缺脚本ERROR/1真实fixturePASS**(f111-t053-parent-final-red.log),无其他setup错误。真实caller事务、dryrun零DML/DDL且全部rows不变、UTC/参数前置拒绝、bounded/cursor租户、disabled/archived、checkpoint跨connection先commit、后batch失败保水位/rollback、commit失败不输出成功、六scope恢复、安全固定错误及DB-only无其他middleware均承接。新CLI测试含真实已存在非空cleanup cursor时details-only保所有Setting字段。Ruff/F821/format/guard/diff及测试L1命名/完整AC/唯一文件和已勾T016/T052依赖PASS,勾选T053 **153/171**;允许T054只脚本+README,勿把CLI缺失RED当实现通过。 + + +T110:主完整读取永久Settings254/Details582行(跨页补读所有片段),正式仓库Vitest独立 **2文件54PASS**(f111-t110/parent-final.log),C同54及26文件249PASS(原195+新54)、完整605strict/targetESLint0/diff0。真实v4/hook/CAS helper/Card/Filters/Details/Panel/Unassociated/原文组件保留,仅API外端口替身;HTTP409及HTTP200业务409保draft/显式刷新后新version保存、配置未知/关闭区分、空ID、六态/真实usage与unknown独立、三语BOTH交集copy、PURGED保主摘要、失败page/refresh cursor重试、六条件和field a11y、tenant及同tenant hidden caller取消/旧缓存移除、旧页保draft/cursor均保护。readAuditBody监测无触发,不用替换Body组件掩盖调用;合成transport logger与旧Inputjsx提示单列,非真实账号E2E。L1测试subset命名/完整11AC、两文件≤600、已勾T104/T109/T170依赖PASS,勾选 **154/171**。 + +真实MySQL准备(未勾T119):主私有脚本通过实际DatabaseConnectionManager和未替换的完整Alembic online env/模型发现,在本次唯一临时dsh_test_f111库fresh upgrade head再重复upgrade,实际 **144表/f111_dsh_content_safety PASS**,finally仅删除本次库(f111-full-migration-preflight.log)。只替换连接配置入口,不替换migration/DDL;旧F029缺OpenFGA store/model回填跳过、F030空数据0行,不能当完整应用环境或真实DM8验收。首次私有probe误给只读engine property赋值,未运行迁移即异常并已清库;改为真实连接Manager构造后成功。T119永久真实并发/升级前后/schema回退仍待前驱齐备,未伪勾。 + + +T054:主全文读取最终CLI155行及README53行新增,独立8文件 **184PASS**(f111-t054-parent-final-green.log,35 CLI+27 retention及相邻),A八相邻选择175PASS。真实shared settings/session端口,DB-only严格profile_scope六context恢复、默认dryrun仅tenant验证/不扫描也不伪造估数、显式apply每batch事务且commit后才JSONL、main checkpoint独立先commit、details-only保水位/cursor,固定非0类型错误,README明确存储/billing非GC和部署writer guard前置。自审实际坏YAML与真实空SQLite下层logger先泄露trace/SQL的RED已保留0600;仅独立__main__进程导入前关闭bisheng/std业务日志,库导入不改宿主logging,固定安全stderr。主补T053同一源2永久真实子进程回归,35全部GREEN,正常/坏配置--help均0;不改core第三源。Ruff/format/F821/guard/diff及L1架构/命名/DB caller事务/信息保护/设计、恰脚本+README/已勾T053/T052依赖PASS,勾选 **155/171**。执行主清理前所有writer部署防复活仍由T169与后续运维验收约束;未执行真实环境清理或部署。 + + +T174:主唯一既有查询测试源新增18实际Repo断言,旧34保留;独立 **18预期RED/34PASS**(f111-t174-parent-red.log),4为upper查询丢记录/同规范cursor错误拒绝,14为非法UUID未前置安全拒绝,不把SQLite当DM8。大小写等价与显式cursor、不同实际ID仍拒绝、跨tenant隔离、不在SQL列调用LOWER以保索引、非法值SQL零调用均明确。Ruff/format/guard/diff及L1测试命名/full5AC/单源/已勾T109/T040依赖PASS,勾选 **156/175**,允许T175唯一查询Repo。新总数包含实际null序列化两任务T172/T173及本配对;不改已确认默认相对分页窗口。 + + +T175:主完整读原查询Repo并只加UUID值规范化端口,校验36位canonical UUID并lower后供显式binding与原列等值比较,固定安全ValueError/SQL前拒绝,不在列调用LOWER、不变默认移动时间窗口。独立真实Repo+HTTP/group/retention/CLI **177PASS**(f111-t175-parent-green.log),T17418全绿。Ruff/format/F821/guard/diff及L1职责/严格tenant/信息保护/设计、单源/已勾T174/T109依赖PASS,勾选。 + +T168:主完整读取339行唯一测试,独立 **13预期RED/11PASS**(f111-t168-parent-red.log),B原六文件107PASS、组合13RED/118PASS,0skip。真实writer/ingest caller事务、首Setting锁→唯一sentinel flush→Request顺序、fresh session重试/故障rollback、strict微秒watermark边界、现存有效主可补、双tenant及mode/version不变、receipt/两冲突先于retention锁均配对。观测真实ORM statement/flush而非模拟锁算法;SQLite不作真实并发证据。Ruff/format/F821/guard/diff与L1测试subset命名/完整3AC、单源/已勾T052依赖PASS,勾选。原T021 test_retention_defaults_allow_historical_fact_without_setting_watermark要求setting仍空,与新明确sentinel契约冲突;待T169实际仅此断言RED后主精准维护原测试文件,不允许配对实现藏第三源。两项后 **158/175**,允许T169两生产源。 + + +T172:主全文读取OutboxTest268/ControllerTest456及截断helper补读,真实生产SpringCustomConfig mapper与WebFlux codec,独立私有javac+实际loopback **35项32PASS/3预期FAIL**(f111-cross-review/t172-parent-red.log);三红精准为事件nullable被写空串、absent-service和snapshot-failure cap digest非JSON null。其余原32行为保留,真实known IDs/子会话/strict数值bool、共享legacy mapper仍null→空串、event/hash原持久字节均保护;无假mapper或B/Platform容错。L1测试subset命名/fullAC/恰两源/已勾T139依赖PASS,勾选。T106在独立Card未知名称设计缺口回归时暂撤(T110同原测试源补2RED/32PASS),故当前净 **158/175**;允许T173只Outbox+Controller局部修复。 + + +T106/T110复审:真实EXTERNAL_SAFETY条目model_name为空而model_id已知,原Card两处只显名称,违反设计§4.8。主在原T110 Details测试源补空串/空白名2真实RED/32PASS,暂撤T106;只原Card源复用现三语model_id label作为header/title/详情fallback,无新组件/样式/键。独立全部26文件 **251PASS**(原249+2),完整605strict/目标ESLint0/diff0,测试589行≤600;原文仍零调用。L1原T106两源范围中只Card及原T110唯一测试维护,设计一致、恢复T106勾选。 + +T169:主完整读取最终两个仓储及锁/边界差异,独立新旧7文件 **131PASS**(f111-t169-parent-green.log,原107+新24);原生先Setting→Request,旧missing严小于水位拒绝,已有有效主仍可补;receiver统一锁行/retained Request FOR UPDATE populate_existing,receipt/冲突先行不改。B新24GREEN、Ruff/format/F821/guard/selfreview PASS。原合跑4旧fixtureRED保留:T0213处(默认Setting空断言/重复insert/SQLite first-sentinel写锁混进Fact竞争)+T0291重复insert;主精准维护原两个测试文件,历史允许/原fact唯一竞争/新事务重读/回滚断言不删,首次Setting竞争仍T168覆盖,未在169藏第三生产源。L1恰两源/事务归caller/无commit/tenant/设计与已勾T168/T052依赖PASS,勾选。 + +T030追加实际缺口待修:B真实Service probe显示已有未关联receipt在foreign主PK占用解除且租户水位推进后,重放仍会走merge而重建旧主。原append receipt优先正确,但不足以覆盖Service后续投影。已暂撤原T030;在原T029测试源补实际foreign冲突→保留COMMITTED事实→水位→重放不复活,原T030唯一Service源修复,保留已提交receipt的ACK/重复语义及窗口内修复关联。不扩169源/不新增任务,T119明确依赖修复。当前 159/175,未当整个防复活链已验收。 + + +T173:主完整读取Outbox私有mapper差异及Controller196行,standard NullSerializer仅copy provider,nested Capabilities digest显式@JsonSerialize(nullsUsing),全局legacy null→空串保留,事件/hash/HMAC不在投递时重写、B/Platform consumer不放宽。主正常共享target离线Maven **34类851PASS/0FAIL/0ERROR/0SKIP**(t173-parent-regressions.log),逐类XML汇总一致;C私有35及7类238PASS、最新私有实际Java payload→B Event/Envelope严格schema PASS,旧gap恢复正常JSON null。T138原唯一测试维护单独先4ClassCast RED/19PASS,再把4组Java Map过约束换真实生产codec字节/JsonNode断言,保留并增强布尔类型/digest null及所有Bean/投递/close行为;私有23+35=58PASS,主全文268行并集中再验,不在173藏第三生产源。L1局部JSON边界/命名/无全局serializer改动/安全/设计及两源、已勾T172/T139依赖PASS,勾选 **160/175**。G先前jar为此修复前的构建产物,不当当前运行物,后续打包须重算hash;未启动正式服务。 + + +T029/T030复审恢复:主读取新3个真实Service参数场景与唯一Service新增分支,独立先 **1精确RED/32PASS**(f111-t029-replay-parent-red.log),修后10文件 **252PASS**(f111-t030-replay-parent-green.log)。duplicate已持久fact继续同短txn持Setting锁验证水位;仅Expired跳过归并并保留原COMMITTED/duplicate/原hash/未关联事实,within-window恢复关联及仍存合法旧主继续归并,非duplicate原410与冲突优先级不改;不无条件跳过所有duplicate。无新增Session/commit/SQL/Request预读,不改Projection或重排原锁;同tenant setting持有至caller结束。L1原T029单测试维护/原T030唯一Service及原全部依赖PASS,Ruff/F821/format/guard/diff PASS,恢复T030,**161/175**,所有核心实现/配对截至Wave32已完成,T119永久真实DB测试前驱齐备。 + +真实MySQL升级私有准备追加:A真实归档基线2e6b5ae0的work110源码online env重复迁移143表并种旧ORM记录,再当前源码升级+重复至f111 144表;current fresh+repeat与upgrade完整144表列型/NULL/default/PK/index/unique/CHECK反射 **0差异**。历史mode METADATA/version7、原生执行记录、未知NULL用量和monthly17/version2逐值保留,新安全语义UNREPORTED。内外进程socket guard非DB尝试 **0**,F029显式OpenFGA disabled,修正上次以不可达跳过的证据边界。finally仅删除本轮两UUID库,容器/其他库不变。证据f111-mysql-schema-probe.log、f111-real-schema-comparison.json与独立phase私有0600日志;只是准备,不替代T119永久并发/水位/双库验收。最新全frontend check-i18n再次PASS(f111-t130-preflight/check-i18n-current.log),既有全量lint/clientTS基线阻断仍保留。 + + +T009 fixture 隔离维护:实际 DatabaseConnectionManager 将 DM8 path 规范化至 query.schema,原 fixture 仅认 URL.database 会拒绝合法隔离 schema。主仅维护原 content_safety_fixtures.py,支持规范化 schema 与旧 path,拒绝冲突两者、重复 query、系统 schema、不安全标识符及非 MySQL/DM 方言。私有纯 URL guard13项及原 repository/retention46项独立 **59PASS**(f111-fixture-url-guard-final.log);不连接 DM8,不据此声称驱动/DDL/锁通过。首次 guard 暴露空 path 为字符串而非 None,已修正重跑;Ruff 与后续 T119 实际 MySQL仍单列。任务清单实际解析 **161/175**,依赖无缺项/循环、已勾任务无未勾前驱,修正页首滞后的115/155显示。 + + +当前 Gateway 构建补验:主 agent 在 T173 修复之后离线执行 `mvn -o -DskipTests package`,实际 BUILD SUCCESS(f111-gateway-tools/t173-current-package.log),当前 gateway-0.0.1-SNAPSHOT.jar SHA256 为 `d115c09f3209f703b2bc0e354af9122166f2140e4be2af032955d4996b246d36`。该命令跳过测试,测试证据仍为前述独立851PASS,不混成新测试运行;没有启动正式应用、部署或制造 License。 + + +T009 追加隔离复核:用真实 SQLAlchemy dialect.create_connect_args(不建立连接)确认 MySQL query `database`/`db` 可覆盖 URL path,原 fixture 只验 path 会漏掉这类目标变换。主在同一 fixture 源前置拒绝 database/db/schema query 覆盖,扩大私有 URL guard至16项;合并原46回归 **62PASS**(f111-fixture-url-guard-overrides.log),Ruff/guard通过。修改schema选择前的所有内容安全及原生retention组合回归已独立 **587PASS**(f111-fixture-maintenance-regressions.log,0失败;没有将真实数据库集成混入该组件组合)。此补充不修改业务代码或声称DM8通过。 + +/e2e-test 准备:主已完整读取 B 的只读矩阵 `/private/tmp/f111-e2e-ac-matrix.md`,实际59条AC完整连续/唯一并保留AC-34优先表,逐条映射真实路由、PKCE/helper、T121–T128及所需环境。59条真实E2E均NOT_RUN,不以既有组件/SQLite/GUI合成预览替代;三份拟新增HTTP E2E源仍待前驱及授权完整环境。现有cleanup helper分页与吞错不能直接证明清理,未来需按自建前缀/tenant/IDs GET核实;公开入口封闭还需部署侧证据。尚未提供可用完整授权测试环境,未绕过License/鉴权。T121–T128及T125仍未勾选。 + + +T119 MySQL子闸门:主完整读取最终926行唯一永久测试(SHA256 `254e46203b8a16d55ef51e51ab02421cea09ca4455efe112c30fbb569c95c30c`),最终源码独立真实执行 **16PASS/16DM8明确SKIP**,58.31s(f111-t119-parent-final.log);前版主16/16 68.35s与A16/16 49.29s分别保留。真实8.0.46/PyMySQL1.1.2/SQLAlchemy2.0.44,actual archive work110143表→F111144表与current fresh全表schema一致;重复升级、旧mode/version/未知usage/17token事实保留。实际双session去重fresh retry、两租户/foreign PK、original hash冲突、同txn rollback、提交后丢观察重放、Setting缺行两序列实际SQL阻塞/释放、RUNNING保留再terminal后重访删除、dirty owner fence/revision restart、details-only保命中/PURGED/既存cursor水位、空及有数据downgrade拒绝并保Fact/Request/Call/月用量均通过。此处丢观察是after_commit故障注入,不冒称真实TCP丢ACK。父子仅连接owned容器、唯一2UUID库finally清理,完整baseline树hash+路径、DB-only network guard、日志局部关闭恢复、URL覆盖前置拒绝均交叉自审;私有21负/3正guard探针PASS。Ruff/format/F821/arch-guard/diff与L1测试subset/完整AC/唯一源/全前驱PASS。DM8无真实Linux/schema环境,整体T119仍未完成 **161/175**;只解锁已明确的MySQL独立子闸门。 + +T120 MySQL子闸门:主全文读取最终430行唯一Java测试及所有后续差异,源码SHA256 `2b97c4862ec5a45012de3ede7d5b874ee8c7fcfd259cf80d5decc482db0e3e7e`,A119实证后为C及主各准备全新unique库/限定库权限专用用户,0600manifest未输出值。C真实10PASS/0SKIP 15.611s;主另一库正常离线Maven **10PASS/0FAIL/0ERROR/0SKIP** 18.614s,逐Surefire XML核实(f111-gateway-tools/t120-parent-mysql.log);C相邻106PASS/新类默认10SKIP,默认不连接。actual MyBatis/Hikari/MySQL8.0.46/Connector8.2.0/JDK17.0.21/isolation4,DDL hash见G docs/dsh-content-safety-verification.md。真实first/create及same-version更新竞争一胜一409、唯一immutable事实、双pool领取owner fence、child JVM abrupt exit后10s实际lease恢复、真实HMAC client/SQL dispatcher丢首ACK相同fact重传、第三次HTTP409隔离重开保留、仅7天旧ACK清理、真实双表判空/停写保护与empty rollback/recreate通过。受控receiver不当backend摘要去重/完整E2E证明。主实际锁201ms observer超时物理active1,释放后version2,明确取消不保证JDBC中断/rollback;100/250领取385ms<4s测试lease,小表计划ALL/filesort不当性能通过。范围恰唯一测试+验证doc,实际前驱MySQL子项及L1命名/完整AC/信息保护PASS;没有修改生产求绿。DM8未执行,T120整体未勾,T121及后续完整链仍待环境/前驱。 + + +后续待执行文档留档:主完整审阅59AC矩阵与197行运维草案,保存 `e2e-checklist.md`(59行均NOT_RUN、真实前置/入口/helper/记录要求)和 `rollout-runbook.md`(T131准备稿,未满足容量/质量前驱、未执行部署演练)。手册按实际代码核正PROFILE_LATENCY_NANOS,并把T120 MySQL SQL片段10PASS与现场演练NOT_RUN分开;Gateway source与Docker response-timeout配置差异、worker/default queue/readiness、共享HMAC/默认X-Signature、公开入口网络验证、关闭检查保在途/补报/用量、旧DTO无兼容证明不回退、B downgrade无条件拒绝及无现成Backend空schema收缩脚本均明确。没有为文档新增配置/改代码/执行部署,T121–T125/T131均不勾选。T120两轮owned目标经owner marker/两表为空/无连接/无routine逐一检查后,已仅删除本轮2库及专用用户,私有cleanup receipt保留于f111-gateway-sql-targets,未停止或更改容器/其他库。 + + +未执行项最终区分:T119/T120仅待各自真实Linux DM8子项;T121–T125缺完整合法授权的隔离backend/Gateway/角色/受控provider及故障操作环境,不能用SQL片段代替;T126–T129容量依赖T125且真实双库/百万千万与堆负载尚未运行;T130的完整前端lint/client typecheck仍为已核HEAD一致的既有失败,不能标绿;T131手册仅草案、网络封闭和回退部署演练未做;T132尚未满足特性最终审查前驱。全部保持未勾,当前161/175。没有提交/推送/部署,三仓HEAD仍为tasks§2的原SHA。 + + +独立负载准备(未入仓、未验收):B在 `/private/tmp/f111-t127` 保存单源DshProfileLoadTest草案/资源契约/manifest,Java17私有编译及默认10SKIP/0执行;`/private/tmp/f111-t128` 保存单源DshSafetyCapacityTest草案(最终389行)/资源契约/manifest,编译及默认5SKIP/0执行,参数模板未展开。两者均未运行HTTP/Redis/负载或benchmark JVM,主未完整审阅源,不能称行为就绪或作为T127/T128通过;后续必须先满足T125、完整审阅和独立资源执行。没有复制到G或修改共享target/生产预算;相关任务保持未勾选。 + + +## 2026-10-24 work120 集成任务复验 + +源提交:B 3420114d0、G 108eb5c8、Desktop 6c84d46。B 在独立 worktree 将源历史合入 target 858f766,尚未形成最终合并提交/推送。 + +- T176 PASS:已读两目标源码,唯一 head 为 f111_dsh_content_safety,接续 f062_credit_multiplier,已发布两积分 revision 哈希断言保留;配对12 PASS、普通入口相邻组合37 PASS(含T181),Ruff/架构/diff通过。 +- T177 PASS:已读 router/middleware 全部冲突上下文;target 路由及 web-session 保留,F111 仅增加精确 POST 回传所有权。HMAC、回传中间件、web-session、SSO HMAC 组合82 PASS;Ruff通过。日志 /private/tmp/f111-t177-auth-regressions.log。 +- T178 PASS_WITH_NOTES:主已核七页签定义及default规则,导航14项、相邻28文件273项PASS,Platform609严格文件及workspace类型检查PASS;定向eslint通过。全量lint target旧问题有独立字节基线;不当作全量PASS。 +- T181 PASS:原特性源确证15表常量旧失败;维护为模型/实表集合精确相等,保留原数据/列/索引/幂等断言。正常入口7文件37 PASS;主读取实际diff复核,无生产源修改。 + +T182–T184 翻译检查正在复验,T179真实MySQL联合升级执行中。更广组合backend回归发现失败,正在区分既有冻结配置夹具与新增集成问题;尚未宣告最终版本回归通过。DM8继续按用户决定保留待验收。 + +T182/T183/T184 PASS:主已读实际翻译差异/自审记录,补12日语键及28001–28009 sandbox三语,规范generator更新6产物;全check-i18n/locales:check通过,真实i18next27项双namespace及插值通过,文案后78项PASS。未扩大baseline。 + +T187 PASS:原target Chat夹具直接修改frozen DshSettings,真实2FAIL+2teardown ERROR,遗留monkeypatch造成13个后续scope断言假失败。第一次替换Settings.dsh触发旧Settings赋值validator(celery_redis_url已规范化为list)同样中断,已改为测试局部monkeypatch.setitem(settings.__dict__, "dsh", immutable-copy),不修改生产配置。原全部业务断言保留,Ruff规范重排;同进程受影响6文件162 PASS(/private/tmp/f111-t187-final.log)。独立其余组合901 PASS、15 SKIP(真实DB opt-in),日志 /private/tmp/f111-integration-safety-regressions.log;最终含Chat完整组合仍待重跑。 + +Gateway候选108eb5c8七维审查NEEDS_FIX:实际Java证明同message相邻output_text以及同reasoning的content/summary各自跨part漏检(dan+ger),流/非流原响应错误放行;AC-21/22/23要求同域连续检查。T185/T186配对修复中,未推送/部署候选。另T188仅开发模式react-query原始AxiosError可能记录草稿,局部修复中;不能以正式production不打印掩盖该信息泄漏。 + + +### 最终组合候选验证(2026-10-24) + +- 后端广组合最终:**910 PASS / 15 SKIP / 0 FAIL / 0 ERROR**,147.96s;skip均独立真实环境opt-in未提供,不算通过。/private/tmp/f111-integration-backend-final.log。 +- T179主独立真实MySQL:**24 PASS / 24 DM8 SKIP**,270.13s;A独立303.53s同结果。fresh149、真实work110归档143→149、真实target858f766积分归档148→149全部反射等价;原始80/125倍率/计费100/UNKNOWN NULL及规则ledger快照保持。七项SQL计量与安全联合case通过(不冒充Gateway/Redis模型E2E)。/private/tmp/f111-t179-parent-independent.log。两源已读/交叉只读复核,隔离创建列表限定清理,无第三方schema删除。 +- T185/T186主独立:**Gateway 34类882 PASS / 0 skip**,15.719s;Responses60/60(原32,新增28)。19初始RED、Unicode/空part/mixed后续RED均保留,1800语义对照通过。/private/tmp/f111-gateway-candidate-review/t186-parent-independent.log。主已读实际adapter及L1报告,同步design混合观察尾屏障与64候选预算;无改spec。 +- T188–T191主交叉读实际diff:安全logger2RED→24PASS;Card新增5RED→10PASS,未知/未执行不伪造时间。DSH 28文件280PASS、Platform609strict、四目标lint、全i18n/locales通过。最终UI修复之后两个SPA再次正式pnpm build exit0(/private/tmp/f111-work120-frontend-final-build.log)。旧全量lint Platform95/Client23均字节等target,仍不称全绿。 +- Desktop6c84d46正式build通过、临时Profile实际启动日志有Harness is ready,所有userData/session/log路径均在/private/tmp/f111-desktop-live.d65u13l2。原生CUA窗口检查异常耗时,只返回空窗口,未据此宣称完整UI验收。完整npmtest:2810PASS/5FAIL/3SKIP及3unhandled;typecheck通过。Python3.10 chdir环境问题用已有3.11独立7PASS,SSE readiness原用例单独1PASS但非全量通过。既有Undici private receiver真实故障登记T192修复。为依赖重放仅退出本轮5347,已确认5347/5368退出及43228/43230清空,不清用户profile。 + +当前候选B/G源码审查与聚焦门禁已收口,T192及真实部署/客户端登录/E2E/容量继续推进。未把LinuxDM8缺环境或全量legacy lint当作通过。 + + +T194/T195 L1主完整读105/376/680行源,C独立复跑27合成边界+33真实loopback/SDK及额外15实际协议/资源检查均PASS,无HIGH;0600/Origin/真实PKCE/独立HTTP客户/CAS恢复与仅自有资源约束通过。Provider无外部网络client,32槽/128场景/128KiB及合成文本32KiB有界,不记录正文/Token。网络发布/故障和真实E2E仍非本地probe证明。发现scenario0不能排除未标记调用,登记T196增强物理全局计数与final独立版本fixture,T121不得以旧计数误证输入0。主真实账号只读核知识/评估/助手/工作台所需默认已配置(实际API为嵌套envelope,未写默认)。 + +交付:B正常merge并远端读回ecf43f79c8aa31bc645636e94b78fb1c9a74bcf9;G最终faa6463b14cc908e6e507241a9d280efb040e80e同名分支与唯一DSH tag原子推送并读回。Drone B5623/G403均SUCCESS,G stage只有clone/package/image/notify四步,115部署步骤被ref条件过滤。109使用原有cr入口拉取,digest逐一等CI日志;未修改共享Docker daemon的insecure registry配置。 + + +T196 PASS:C独立读冻结404/767行helper/Provider,13新增+原27/33以及额外10真实loopback检查通过,含32混合并发/global32、饱和不回绕并由helper拒绝、独立delta/done/final观察。主逐项读最终合同与报告,通过私网部署gate。实际109仅临时f111-provider容器加入dsh-integration,控制面宿主127.0.0.1:18181经SSH,本轮随机0600测试key,不连接外部模型。现场协议仍未验。 + +T192 PASS_WITH_NOTES:主读最终补丁/10配对及完整报告;标准clean npm ci 34patch成功,原包reverse/replay与真实安装SHA一致,package/lock未变。既有Mac CI=1串行配置+临时已有Py3.11 PATH完整2820PASS/3SKIP、0FAIL/0unhandled,472.78s;此前默认并行2817PASS/2FAIL的两个原物理复制超时记录保留,不宣称已证明竞争机制。最终npm run build再次exit0,已实际启动最终补丁客户端;隔离Profile与Harness ready成立,完整UI/模型/工具验收另列。 + +109实际交付:B/G四目标容器镜像ID均等本次CI digest,B健康200、UI200;backend升级到f111_dsh_content_safety。实际ConfigService没有自动SSOSyncConf env覆盖,首次runtime hmac_configured=false(env已到达);使用其既有YAML !env构造器引用0600env_file的共享key/header,真实load_settings_from_yaml内部值校验通过,仅重建backend/worker后实际hmac_configured=true。未把共享key明文写YAML/文档/Git。Gateway随后只重建指定gateway。部署前后所有非四目标容器ID/image/startedAt逐字节相等,额外仅本轮测试Provider。 + +真实联调阻塞:GET /api/license/status actual expired=true,两个/api/sensitive/dsh管理接口HTTP200但status_code11001;已请求适用有效License,不绕过。独立发现work120离线模型管理复用online调用快照阻止安全上线前私有配置,登记T197/T198最小修复后再创建测试模型;不先online制造公开窗口。DM8/完整E2E/容量仍未执行。 + + +T197/T198 PASS:C持久16配对先11FAIL/5PASS→服务14PASS/管理2RED→全部16PASS;B独立五文件71PASS,主独立相关70PASS/5环境skip+新模型初始化1PASS。主完整读三源,Ruff/archguard/diff通过;管理只在既有admin authorize/profile_scope后读取独立治理快照,调用路径固定online_required=True,canonical Root共享和provider-owner核查不变,CAS409/倍率保持。新测试替换管理认证外端口,非admin真实现场拒绝另验,不夸称真实角色/双库证明。C扩大12旧文件用完整精确HEAD service源码和current各跑133PASS/同4FAIL/同1缺环境ERROR,属既有默认DB mock与DSH_TEST_DATABASE_URL条件;不改夹具求绿。109已创建本轮server5与model8/9,两个offline/status0,所有既有默认模型快照逐字节不变;安全收窄与上线等待包含该修复的新镜像。 + +Desktop最终两源补丁已提交3e8486c;本轮已启动进程含该补丁,真实公开state connected=true/phase=connected、目录0(尚未开测试模型),不冒模型执行证明。 + + +2026-10-24现场进展:B最新治理修复3cc9de9298ad2c6346f17d180105d46d2d3e3324经Drone5624 SUCCESS;backend镜像8c001dcc8c921ebf32c8e5c468d5368216c56b698a3511e2421f860713ee4392实际运行,frontend与5623 digest相同无需重建。模型8/9在offline期间用真实管理GET/PUT CAS收窄为仅本轮普通user9,并读回确认后才online;普通user两端点真实HTTP200/status19801拒绝,既有默认模型和其它credit规则不变。 + +四协议正常真实HTTP烟测4/4通过:每请求global和scenario物理Provider delta1,确切文本/SSE终态和80usage,原生MODEL/SUCCEEDED/SETTLED与安全DISABLED分离。四请求事件各AVAILABLE/一个FINAL,证明真实HMAC发送/接收;列表PENDING为保守展示,详情可用,不误称丢事件。第一次只等待SETTLED时usage事实稍后到达,改测试有限等待完整30/50/80且保留严格最终断言,无生产改动。实际Electron两独立UI会话分别选Chat8/Responses9,均可见合成文本和80token;真实session-title另产生调用,不把物理模型次数当工具执行次数。 + +T122真实基础4PASS/3deselected(3.22s),PKCE/profile/models、两个完整凭据/refresh、logout后旧新access+refresh精确session_revoked,以及admin可读/普通GET-PUT19801且snapshot无变。A独立467源读及guard4/cleanup6合成异常安全检查通过;三策略例在此前License阻塞下未跑,不勾完整T122。T123最终633源已主全文与新增validator复读,B独立实际关闭嵌套/值/normal轴两个测试盲区;只读既有4normal manifest真实API 4PASS(5.30s),不触模型/策略,不冒SQL/ACK/Outbox或故障恢复。 + +用户追加授权“生成License”后,主使用现有gen_license/gateway_license.py官方sso兼容格式、匹配当前Gateway公钥生成30天trial,expire_day2026-11-23,仅存0600,不需DSH发行私钥。只更新109 DSH Gateway license配置并备份/重建该服务,真实expired=false;capabilities available/enabled/configuration_ready全true。平台聚合按既有一天采样仍旧过期,主经现有上报接口同步真实Gateway状态,读回expiring/30days。未改许可检查或其他部署。 + +T199/T200/T201 PASS:现场普通Gateway管理GET真实503错误映射,Client原20案例4RED→精确只加19801拒绝映射→Client20+Controller24全44PASS、34类891PASS;B独立私有javac44PASS+原三向Netty3PASS,未知19899/19803/500保持503。主读两源、追加独立验证文档并提交64f602bed9fc8593cb957fb1326c229f911aae1c,已原子推同名work120分支及唯一v1.8.0-dsh-f111-20261024-admin-denial-64f602be标签,远端解引用已核。Drone404 SUCCESS,实际只有四步、115部署被条件排除;运行109尚待当前协议套件结束再更新。 + +T121首轮真实8PASS后第9(Chat非流输出BLOCKED)因测试错误混用Gateway公开错误编号和Backend audit UUID而失败。实际API证据为MODEL/BLOCKED/模型SUCCEEDED/raw80且DECISION正确关联,生产合同无改。A在唯一测试源修为分别记录public/backend/audit IDs,先8编号反例RED→GREEN、原23解析反例保留;C独立门禁PASS_WITH_NOTES。原CAS恢复失败又logout失败会保留Python异常context,只有顶层展示风险,实际合成probe留档,不伪称清理成功。主重跑该实际单案1PASS(10.59s),随后全35运行中;旧首轮失败日志/XML保留。策略已确认首轮退出恢复disabled/version18。 + + +## 2026-10-24 最终单实例现场验收(替代此前“全35运行中”的当前状态) + +- T121真实完整已落地协议子集35/35 PASS,380.29s;输入403的四模式global/scenario0、无账本增加;输出四模式阻断后仍实际30/50/80结算。Responses delta/done/final独立文本、reasoning summary/content、邻接空part、工具参数、完整历史/内部用途及Unicode均有真实链路观察。保留首轮ID测试失配历史,不改业务满足测试。日志protocol-live-final.log/XML均私有0600。 +- Gateway CI404 exact64f602be已仅重建109 dsh-gateway;实际镜像digest63d7d9eddd7b0b5c5633b8ab598f633c5ad6120d017de07221dc5fb93f75d908,imageID3bb8d64d1a3cd4f23a11b07826d5c7be8bdd673b0d906005528f76f0ddf50df4;capabilities正常且许可证保持有效。T122全部当前7例真实PASS/29.83s,普通Gateway策略管理精确403、admin允许、CAS旧版本冲突和非法配置不保存、撤销会话后原始准入拒绝均验证;不冒跨租户/多副本缓存验收。 +- T123从最终JUnit实际UUID经GET复核生成12条manifest(四NORMAL PASSED、四INPUT_BLOCKED EXTERNAL_SAFETY、四OUTPUT_BLOCKED MODEL)。最终633行只读测试4参数PASS/12.47s,每参数遍历3类并对摘要/事件稳定两读。Gateway重启后仍可查询,不冒Backend进程重启、事务ACK、丢ACK或Outbox故障恢复。 +- T202/T203主读902行Provider和335行持久测试、B独立全读及新48PASS/旧SDK33PASS/计数13PASS。默认lookup保持;工具名声明、same scenario+protocol+issued-call-ID+name+JSON对象语义参数+非空结果后final text,最多16标量tuple,不存请求/凭据。先保存旧global counters且active0才更新本轮Provider(SHA80782c04c0936c16aefeacbed35e7382b04d22901df8f97f8c567394289772fa),新进程计数从新基线开始,不能跨重启相减。 +- 实际Desktop Chat与Responses各独立两会话:正例公开持久tool/call1+tool/result1、isError=false、确切参数/同callId且结果含真实文件新nonce;turn/end completed。负例公开持久tool/call0/result0、turn/end CONTENT_FILTERED,界面显示租户合成提示,无自动重试。每个正例两次chat模型请求各raw80,负例一次chat模型请求raw80且安全BLOCKED、模型SUCCEEDED、SETTLED;另有原生session-title辅助请求,所以Provider场景总数正3/负2,不能误报模型总1。私有desktop-tools-live-results.json与四events文件/真实截图留档。 +- 工具验收结束实际CAS恢复租户原字段,version97,原enabled/input/output均false。该版本号只记录本轮快照,不作固定合同。 +- 主沿用/e2e-test与/task-review核AC/错误码/隔离/真实GET/cleanup与设计;新文件测试/设施源不改业务鉴权/计量。A最终只读一致性复核无HIGH/MEDIUM代码项,指出旧摘要/计数/执行树及反向AC引用已由主集中修订。DM8/多租户/多副本/设施故障、无Gateway部署、真实容量与回退仍留未执行;最终T132不能标完成。 + + +T204/T205最终主交叉验收PASS:完整单源420行/冻SHA749c8262247196cb869448ca90d3071cd4ac459452fd8ec3dace1d34bc0a1684;主发现能力字段与真实Wire不符,经3RED→9GREEN纠正,主最终actual56PASS(49local+7live)/78.03s。Chat/Responses各非法JSON/重复model/unknown INPUT,以及合法JSON确切33554433bytes上限+1均400 invalid_request、无Backend header、global/scenario0、无账本变化;两轮精确conversation/requestUUID/unassociatedGatewayUUID完整空页,发生在recorder前,故无安全ERROR事件。实际settings恢复原disabled/version111,capabilities可用;verify-scope再次NON_DSH_CONTAINER_IDENTITIES_UNCHANGED。 + +真实Platform English管理员UI:在审计高级筛选设置Conversation ID为本轮Responses blocked会话、Match recorded=Sensitive word match,点击Search后唯一会话,命中计数1;展开只显示匹配请求(辅助标题被过滤),Overall Matched/Input Passed/Output Matched。打开View check details可见独立DECISION v1与DRAIN v2、同Gateway/Backend各UUID/策略96/rule opaque ID,续读先waiting后fullyread,实际usage ledger80,页面说明不把客户端中断当模型停止。原词与凭据不出现在内容安全事件详情。只读GET未改变租户策略。Japanese因当前APP_CONFIG.disableJa隐藏,未更改客户开关冒三语UI验收。 + + +## 最终镜像与重启后复验 + +B测试资料提交d5b026557b76bb57af223520c11aeecd4e83473e正常推work120并读回,Drone5625 SUCCESS;业务bisheng树与3cc9de9298无差异,backend COPY包含新测试故镜像更新为151d35b0436825c23dd9c5940300d801e8294bb1c0a75c5798250216991ef0cd,ImageID e5d1403c2e1a95323dffded3858cd088a76a8ac965df186cba799e0cea8587ce,仅109 backend/worker更新;frontend摘要未变。真实Backend进程重启后12条已存在审计四模式4PASS/13.14s,证明此批事实重启可读,不冒丢ACK/并发事务验收。 + +启动窗口业务读取失败及四模式烟测2PASS后策略teardown503保留原日志;主核最后版本114/自有合成词和提示后CAS恢复115,不覆盖外部编辑。Gateway私有日志确实包含对旧172.31.62.10和新172.31.62.9的连接拒绝,127.0.0.1:17860/health及真实管理APIready后仅同镜像重建指定Gateway刷新连接,受保护capabilities连续三读成功。最终同源四模式烟测4PASS/31deselected/40.27s(post5625-stable-protocol-four),策略正常恢复;不放宽测试/不加模型自动重试。13001/health可被SPA兜底,不能当Backend就绪证据,运维步骤已同步。 + +最终只读:官方trial许可证2026-11-23/expired=false;原默认模型四组和原审计设置完整值不变;租户原enabled/input/output/custom_words恢复关闭/空;verify-scope再次逐字节确认全部非四目标既有容器身份不变。190/205及完整AC0PASS/30PARTIAL/29NOT_RUN保持;DM8/双租户多副本/容量/设施故障/无Gateway/回退等待实际环境与验收。最后纯features文档提交不改构建上下文,不再次重建服务。 + + +## 2026-10-27:关闭采集边界与审计说明(T206–T211) + +用户明确要求:敏感词与审计采集均关闭时不再采集新请求;敏感词开启、审计关闭时仍保留安全摘要,并在审计界面常驻解释。新增 AC-60、AC-61,既有 AC 与编号不变。 + +- Gateway 单一生产改动:DshSafetyRequestManager 仅在冻结策略至少一个检查方向开启时创建 Recorder。策略关闭时不写新 Outbox,backend 仍独立按 OFF/METADATA/CONTENT 工作;不增加 Gateway 对审计开关的感知。旧事件补报、请求入站快照、模型鉴权、额度和真实计量不变。 +- Platform:采集状态下方始终显示 collectionHint,管理员未展开设置或只读用户也可见;通过 aria-describedby 关联状态栏。三语同义,原 modeHint 不再声称关闭采集会停止全部请求摘要。复用相邻辅助文字布局,无新组件或设计样式。 +- 未修改 backend 业务代码、SQL/DDL、凭据、客户端、其他工作区或部署环境;本轮不删除历史事件和记录,不暂停积压投递。 + +| 验证 | 实际结果 | +|---|---| +| Gateway 新契约 RED | 两协议 × 流式/非流式 4 项均因仍写 Outbox 失败;原正文透传和单次调用断言保留 | +| Gateway 定向 GREEN | RequestManager / RoutingFilter / OutboxService / Dispatcher:107 通过 | +| Gateway 全量 | Maven Java 17 离线执行,906 项:896 通过、10 跳过、0 失败/错误;跳过项均为 DshSafetyDatabaseIntegrationTest 的真实数据库验收 | +| 常驻说明 RED → GREEN | 新增三种模式 × 可配置/只读 6 项先失败;修复后审计模式及相邻 4 文件 59 通过 | +| DSH 全部前端回归 | 25 文件、258 项全部通过 | +| 类型检查 | src/frontend 下 pnpm typecheck 通过,包括两应用及 file-viewers | +| lint | 本次两个 UI 文件精确检查通过;workspace pnpm lint 被 Platform 原有 95 项错误阻断,均不在修改文件,未修改抑制清单,不标全量通过 | +| i18n | pnpm check-i18n、pnpm locales:check、三语 JSON 校验通过 | +| 生产构建 | pnpm build:platform 通过;构建仅给出原有体积提示 | +| 架构和差异 | 两个修改 UI 文件 arch-guard 无违规;两库 schema、接口和数据权限未改变;两仓 git diff --check 通过;AC 1–61 定义/任务追溯完整 | + +任务自审:T206/T208 的旧断言更改来自上述用户新契约,不放宽模型交付、调用次数和安全命中测试。T207 的唯一采集闸门在每请求固定策略下,不影响已创建 Recorder 的收尾和补报;T209 说明不依赖额外请求,也不改变任何设置值或可配置权限。T210/T211 三语键完整。架构分层、文件归属、依赖配对、信息保护、设计同步均 PASS;全量 lint 的既有错误和未执行的真实数据库/部署验收单列保留。 + +本轮本地验证日志:/private/tmp/dsh-audit-disabled-{gateway-red,gateway-green,gateway-full,frontend-red,frontend-green,frontend-dsh,typecheck,target-lint,lint,i18n,locales,platform-build}.log。没有使用 SQLite 或 mock 代替真实 MySQL/DM8 验收;现有定向业务测试使用既有传输/SQL 端口夹具,结果仅表示本地回归。 + + +本地组件显示核验:在仅监听127.0.0.1的临时预览中挂载实际 ModelAuditMode,使用本轮生产构建CSS及真实三语资源、固定OFF的API夹具。Chrome已确认中/英/日说明在未展开配置时可见;中文已保存组件截图。该证据只覆盖组件显示,不代表109环境已部署、真实接口端到端或窄屏验收。预览与应用登录相隔离,没有修改任何部署环境。 + +### T206–T211 任务自审结果 + +| 任务 | 架构分层 | 命名 | 序列化 | 数据库 | 前端约定 | 信息保护 | 设计同步 | 文件/配对/依赖 | +|---|---|---|---|---|---|---|---|---| +| T206 测试 | N/A | PASS | N/A | N/A | N/A | PASS | PASS | PASS:单测试文件,4项RED后GREEN | +| T207 实现 | PASS | PASS | PASS:事件格式未改 | N/A | N/A | PASS | PASS:design §4.7 | PASS:仅Manager;T206已通过 | +| T208 测试 | N/A | PASS | N/A | N/A | PASS | PASS | PASS | PASS:单测试文件,6项RED后GREEN | +| T209 实现 | PASS | PASS | N/A | N/A | PASS | PASS | PASS:design §4.10 | PASS:仅Mode;T208/T210/T211已通过 | +| T210 三语 | N/A | PASS | PASS:JSON | N/A | PASS | PASS | PASS | PASS:中/英两文件、键一致 | +| T211 三语 | N/A | PASS | PASS:JSON | N/A | PASS | PASS | PASS | PASS:日文单文件、键一致 | + +结论:六项任务本身 PASS;workspace完整lint为既有问题记录,真实数据库与部署未执行,不计为本轮通过。 + + +## 2026-10-27 Wave45:统一请求编号(T212–T218) + +### 范围和存储边界 + +用户授权 Gateway/ backend 统一请求编号、补测试并同步设计,本轮明确不提交、不推送。B 在干净 e59b249a3、G 在干净 c999f9bf 上各创建 `codex/dsh-unified-request-id`;保留其他工作树,没有执行仓库级 stash/reset/restore/clean。没有修改前端、Desktop、数据库结构、真实数据或109部署。 + +Gateway 仍为每次请求生成独立 UUID,Proxy 用显式 UUID 参数在 hop-by-hop 过滤后覆盖客户端头;客户端重复 X-Request-ID 及 Connection 指定该头均不能控制实际编号。Manager 在发起转发前设定 backend_request_id/forwarded;连接失败或响应头前取消仍可投影外部摘要。backend DshRoute 只实现通用 UUID 复用,不读取 Gateway 配置、不新增网络调用,模型准入和计量代码不改。无 Gateway、缺失/非法/零 UUID/多值头仍生成自己的编号。旧 backend 明确返回不同有效编号时兼容实际模型编号,缺失/非法/零 UUID/多值响应不抹掉预分配编号。 + +事件明细仍存 `dsh_content_safety_event`;有安全摘要的请求仍存 `dsh_model_audit_request`。按会话字段进入会话记录或未关联请求;只有事件而无法安全关联请求的历史/冲突数据仍从未关联检查入口查看。分配 ID 或尝试发送不能证明模型执行,外部 `model_entered`/结果/用量保持未知,后到原生记录沿现有校验升级,不创建假 `dsh_model_call`。两开关都关闭时的零采集规则不变。 + +### 验证命令与结果 + +以下命令 B 从 src/backend 运行,使用已有 Python3.11 环境 `/Users/zhangguoqing/works/bisheng/src/backend/.venv/bin/python`,`config=/private/tmp/dsh-credit-limit-fix-20261027/test-config.yaml`、`PYTHONDONTWRITEBYTECODE=1`。G 使用现有 Java17 `/private/tmp/f111-gateway-tools/jdk-17.0.21+9/Contents/Home` 与 Maven3.9.9,不改锁定依赖。 + +| 检查 | 命令/方法 | 结果 | +|---|---|---| +| 新 backend HTTP/仓储测试 | `pytest -q -p no:cacheprovider test/dsh/test_unified_request_id.py`(并入下列组合) | 20项新增覆盖;最初7个有效反例确认旧实现不复用ID,另2个Responses夹具非法input先纠正为现有消息列表契约,再转GREEN | +| backend 相关回归 | `pytest -q -p no:cacheprovider test/dsh/test_unified_request_id.py test/dsh/test_content_safety_merge.py test/dsh/test_content_safety_native_writer.py test/dsh/test_model_audit_request_id.py test/dsh/test_models_api.py test/dsh/test_native_responses_middleware.py -k 'not usage_selected_model_cannot_borrow_another_allowance' --tb=short` | **81 PASS / 1 deselected**;1.42s | +| 既有失败对照 | 不排除上述case时,旧test_models_api中的policy SimpleNamespace缺monthly_token_limit,AttributeError;新进程读取`git show HEAD:src/backend/bisheng/dsh/api/responses.py`并加载原DshRoute后单独运行同一case | 同一 AttributeError;证明该失败在HEAD基线存在,本轮未修改用量业务或放宽原断言。排除仅用于清楚报告本轮通过集合,不宣称整个backend回归全绿 | +| Gateway 定向 | `mvn -o -q -Dtest=DshModelProxyClientTest,DshSafetyRequestManagerTest,DshModelRoutingFilterTest,DshContentSafetyFilterTest,DshRequestTelemetryTest,DshSafetyRoutingConfigurationTest test` | 初轮172 PASS;自审追加响应头前主动取消、多值/零UUID后纳入完整回归 | +| Gateway 完整 | `mvn -o -q test` | **902 PASS / 10 SKIP / 0 FAIL / 0 ERROR**,912 cases;Manager39项通过。SKIP为原真实双库集成,没有用mock替代 | +| 两仓实际回环HTTP | `/private/tmp/dsh-unified-request-id/run_probe.py`:真实Java DshModelProxyClient→随机127.0.0.1端口上的实际Python DshRoute/模型服务/原生协议adapter | **Chat流式1 PASS + Responses流式1 PASS**;供应商/鉴权/额度是合成fixture。backend准入及结算4条事件的request_id/trace_id与Gateway生成ID一致,2个SUCCEEDED;不冒充真实部署E2E | +| Ruff | `ruff format --check --no-cache .../responses.py .../test_unified_request_id.py`、`ruff check --no-cache`同两文件 | PASS | +| 架构和差异 | `bash scripts/arch-guard.sh`逐个修改Python文件;两仓`git diff --check`;设计引用核验 | PASS;无新增分层违规、无章节重编号 | + +本地日志:`/private/tmp/dsh-unified-request-id-backend-pass.log`、`/private/tmp/dsh-unified-request-id-backend-baseline.log`、`/private/tmp/dsh-unified-request-id-gateway-full.log`;跨仓合成证据与临时运行器在 `/private/tmp/dsh-unified-request-id/`。运行器退出后已停止其自有backend进程;全部凭据、内容和ID为合成值,没有访问109或输出真实Token。 + +### Task Review(按仓库 /task-review,主agent自审) + +| 任务 | 文件范围 | 配对/依赖 | 结果 | +|---|---|---|---| +| T212 | 新增单一test_unified_request_id.py,HTTP透传/真实Chat执行/仓储投影/拒绝准入边界 | 覆盖AC-62、AC-63;20项新增测试通过 | PASS | +| T213 | 仅responses.py请求ID解析与DshRoute赋值 | T212已通过;没有Gateway配置、身份或计量变更 | PASS | +| T214 | ProxyClientTest、RequestManagerTest;真实回环收到请求未发头即断开、头前主动取消、正常回显及旧ID兼容 | AC-62、AC-63;正常正文不重编码、一次调用/订阅断言保留 | PASS | +| T215 | ProxyClient、RequestManager;显式UUID、转发前固定编号、响应UUID安全选择 | T214通过;缺失/非法/多值/零UUID回退已覆盖;客户端取消释放物理连接 | PASS | +| T216 | 两过滤器test;只更新Proxy调用及verify的UUID参数 | T215通过;原鉴权、路由和正文断言保留 | PASS | +| T217 | RoutingConfigurationTest、RequestTelemetryTest;只更新调用UUID参数 | T215通过;原装配/遥测回归通过 | PASS | +| T218 | B本记录及G验证记录;spec/design/tasks同步 | T213、T215、T216、T217通过;本地证据与未执行项分开 | PASS_WITH_NOTES | + +七项检查:架构分层PASS;命名及AC标注PASS;序列化PASS(保持既有OpenAI兼容DSH信封,不套通用平台响应);数据库N/A(没有生产DDL或SQL写法变更,沿现有租户/身份校验);前端N/A;信息泄漏PASS;设计同步PASS(spec新增AC-62、AC-63,design数据流、关联和对外编号契约均已更新)。T218注记为HEAD既有用量fixture失败与真实环境未验,并非本轮业务失败。 + +没有进行真实MySQL/DM8本轮验收、109镜像或客户端联调、完整跨租户/故障/容量测试及总体T132审查,不据本地HTTP或SQLite仓储测试勾选原15项待验任务。统一编号也不能修复已丢失的历史conversation_id,滚动升级中旧backend替换ID且响应丢失仍无法自动合并到其实际模型记录。当前 **203/218**,仅本轮T212–T218新增勾选。 + + +## 2026-10-27 Wave46:移除未关联检查界面(T219–T224) + +用户明确要求“界面上去掉未关联检查功能,仅保留数据库以供debug排查定位”。该变更由用户直接确认,覆盖前版AC-63中独立界面兜底的取舍;spec新增AC-64,design的数据归并、前端职责和查询消费者均已同步。继续在两仓既有未提交分支上工作,不提交、不推送、不部署。 + +### 改动与边界 + +- ModelAuditRequests 以原 tenant/caller scope key 直接挂载原请求页,删除 ModelAuditViews 入口按钮、隐藏检查页、访问状态和页面缓存。 +- 删除 UnassociatedSafetyEvents.tsx(329行)、getUnassociatedSafetyEvents 前端封装及 ContentSafetyEventQuery 专用类型;生产前端没有这些名称或独立未关联事件取数调用残留。 +- zh-Hans、en-US、ja 同步删除专属标题/说明/筛选提示和两个专属UUID/身份校验文案,共五个键;保留共用关联状态、31天范围校验及请求检查详情文案。 +- 会话记录、未关联请求、请求明细以及请求内ContentSafetyDetails保留。未关联请求仍查询请求摘要的association_status=UNASSOCIATED,与被移除的未关联检查事件视图不同。 +- 本轮业务改动仅8个Platform文件;没有修改backend事件接收、投影、数据库模型、迁移、保留策略或实际数据,dsh_content_safety_event继续保存debug事实。前轮统一请求编号的未提交改动完整保留;其他工作树不变。 + +### 实际验证 + +| 检查 | 命令/方法 | 结果 | +|---|---|---| +| 配对反例 | `pnpm --dir src/frontend/platform exec vitest run src/pages/SystemPage/dsh/ContentSafetyDetails.test.tsx`,实现前 | **3 RED / 25 PASS**;groups/unassociated/requests均仍出现旧入口,三项均在预期“不应存在按钮”的断言失败 | +| DSH组件回归 | `pnpm --dir src/frontend/platform exec vitest run src/pages/SystemPage/dsh` | **257 PASS / 25 files**,8.28s;覆盖三个入口移除、无独立查询、未关联请求条件保留、已有请求检查详情/会话/筛选/原文等 | +| workspace类型 | frontend根目录`pnpm typecheck` | PASS;Platform610 strict文件、Client1467 strict文件、共享file-viewers均通过 | +| 目标lint | Platform目录`pnpm exec eslint src/pages/SystemPage/dsh/ModelAuditPanel.tsx src/pages/SystemPage/dsh/ContentSafetyDetails.test.tsx src/controllers/API/dshContentSafety.ts src/types/dshContentSafety.ts --suppressions-location eslint-suppressions.json` | PASS;涉及文件无既有suppression条目,无新增豁免或strict-ignore | +| 完整lint | frontend根目录`pnpm lint` | **95项既有错误**;日志8个诊断文件逐一用`git diff --quiet HEAD -- `核对均未修改,目标文件单独lint全过;未改无关文件、规则或suppression | +| 文案 | frontend根目录`pnpm check-i18n`、`pnpm locales:check` | PASS;三语键对齐,无新增drift,生成文案未改 | +| 生产构建 | frontend根目录`pnpm build:platform` | PASS,17.21s;既有大chunk warning保留。没有发布或部署产物 | +| 范围与结构 | 原组件已删除、生产导入/封装/类型无残留、三语专属key删除、原共享key保留;arch-guard、`git diff --check` | PASS;ModelAuditPanel525行、ContentSafetyDetails测试405行,均低于600行 | + +日志:`/private/tmp/dsh-remove-unassociated-check-{red,tests,typecheck,lint,target-lint,i18n,locales,build}.log`。界面验证为实际组件DOM断言和生产构建;没有将其表述为109已更新或真实浏览器/客户端验收。数据库没有执行查询、删除或修改命令。 + +### Task Review + +| 任务 | 类型/文件范围 | 配对/依赖与结果 | +|---|---|---| +| T219 | 测试;ContentSafetyDetails.test.tsx | AC-64三反例先RED后GREEN;删除废弃页面专属测试,保留请求内详情及过滤测试;PASS | +| T220 | 实现;ModelAuditPanel.tsx、明确删除的UnassociatedSafetyEvents.tsx | T219通过;原scope key隔离保留,未增加样式/组件/状态库;PASS,组件缺失是预期删除 | +| T221 | 实现;API/dshContentSafety.ts、types/dshContentSafety.ts | T220通过;独立查询/类型无使用点,保留请求内事件API和DTO;PASS | +| T222 | 实现;zh-Hans/bs.json、en-US/bs.json | T220通过;仅五个专属键删除;PASS | +| T223 | 实现;ja/bs.json | T222通过;与中英文同键删除,i18n检查通过;PASS | +| T224 | 文档/本地验收;本记录 | T221–T223通过;本轮spec/design/tasks同步,记录full lint既有失败和未部署范围;PASS_WITH_NOTES | + +七维检查:架构PASS(组件取数仍通过封装,未新增store HTTP);命名PASS;序列化N/A(保留现有响应及DTO);数据库N/A(无本轮生产数据库改动);前端PASS(无硬编码中文/新库/新样式,实际类型与目标lint通过);信息泄漏PASS(只用合成测试,无凭据);设计同步PASS(AC-64及前端职责/数据保留明确)。本轮新增6项完成,当前 **209/224**;既有15项双库/完整现场/容量/最终门禁不勾选。未提交、未推送、未部署。 + + +## 2026-10-27 Wave47:移除请求明细视图并展示命中词(T225–T243) + +用户明确要求“请求明细去掉,把相关的跳转也去掉”“命中后展示具体的敏感词”。spec新增AC-65、AC-66,design同步存储、协议、权限、旧数据与发布顺序。该记录覆盖Wave46中保留请求明细的旧取舍;继续保留此前统一请求编号和移除未关联检查界面的未提交改动。不提交、不推送、不部署。 + +### 实现边界 + +- 审计只保留会话记录、未关联请求两个主视图;移除请求明细标签、跨视图跳转/返回状态和深层树继续跳转。树达到既有限制时显示普通说明。单条请求展开、原文和检查详情保留,未删除请求审计数据。 +- Gateway按本次请求冻结的CompiledPolicy将命中rule_refs映射为配置词快照;不读取命中后的新配置,不保存词周边正文。Observation深拷贝且toString不输出词值;既有客户端阻断消息和普通日志不增加词值。 +- 既有schema_version=1事件增加可选matched_words对象,包含input/output两个必需数组;允许旧字段缺失/null。两阶段合计至多20词,每词非空白、合法UTF-8、至多256个Unicode码点、阶段内不重复;非空方向必须BLOCKED。前后端和Gateway均校验边界。 +- backend的dsh_content_safety_event增加可空JsonType列,接收、投影事实一致性与已授权详情元数据均接入;不增加主列表JOIN或N+1查询,不改变用户/租户鉴权、模型准入和计量。迁移f111_matched_words基于实际唯一旧head f111_dsh_content_safety,仅幂等DDL,不回填旧数据,拒绝破坏性downgrade。 +- 检查详情按输入/输出显示命中词,采用React文本渲染;三语文案同步。旧记录没有词快照时显示“该检查记录未保存命中词,无法还原。”,不拿当前词库反推。命中词仍受既有请求详情权限保护。 +- 必须先完成迁移并升级**全部backend实例**再升级Gateway;旧严格DTO会拒绝未知字段。回退先回退Gateway,保留backend新增列及既有事实。Gateway继续使用既有MEDIUMTEXT/CLOB事件载荷,无新DDL。 + +### 实际验证 + +| 检查 | 命令/方法 | 结果 | +|---|---|---| +| backend配对反例 | 新test_content_safety_matched_words.py在实现前运行 | 5项预期RED / 11PASS;原DTO拒绝新字段、原模型缺属性;实现后16PASS | +| backend定向 | config为隔离test-config.yaml,Python3.11,pytest -q -p no:cacheprovider:新词值/仓储/合并/查询/API/native writer/新迁移/单head共9文件 | **170PASS**,4.95s;命令及日志见下 | +| backend扩大回归 | 当前test/dsh与相同命令/配置的只读git archive HEAD基线对照;共同排除此前已确证旧用量fixture case | 当前16FAIL /1466PASS /227SKIP /1 deselected /102ERROR;HEAD16FAIL /1428PASS /227SKIP /1 deselected /102ERROR。逐项Counter对照全部FAILED/ERROR case ID及次数完全相同;新增38PASS=统一编号20+词值16+迁移2。不宣称全量通过,不扩大修复旧失败 | +| Gateway完整 | Java17、Maven3.9.9,mvn -o -q test | **905PASS /10SKIP /0FAIL /0ERROR**,915 cases;10项原真实双库测试未执行 | +| Gateway生产mapper及跨语言 | 新DshMatchedWordsTest用SpringCustomConfig实际ObjectMapper;导出实际Java事件,再由真实Python ContentSafetyEvent.model_validate_json解析 | 3PASS;input合成词与output空数组一致,生产null序列化不会破坏协议;没有模拟拼接事件 | +| Platform组件 | pnpm --dir src/frontend/platform exec vitest run src/pages/SystemPage/dsh | **269PASS /25 files**,9.60s;覆盖仅两入口/无跳转/树限制、过滤保留、方向词值/文本转义/旧数据及严格API解析 | +| 类型、文案和目标lint | frontend根pnpm typecheck、pnpm check-i18n、pnpm locales:check;12个保留的修改TS文件单独ESLint | PASS;Platform610、Client1467 strict及共享文件通过,三语键一致,无新增豁免 | +| 完整前端lint | frontend根pnpm lint | 95项既有错误;8个诊断文件逐项git diff --quiet HEAD确认未修改。没有修改无关文件或规则 | +| Platform生产构建 | frontend根pnpm build:platform | PASS,16.67s;保留既有大chunk warning,构建产物未发布 | +| Ruff/架构 | 8个修改Python源和测试的ruff format --check与ruff check;修改业务源逐项arch-guard | PASS;没有新增分层违规 | +| 迁移结构 | alembic heads、离线重复upgrade/旧NULL/downgrade拒绝、既有JsonType双方言编译 | 单head f111_matched_words;迁移测试2PASS。静态方言检查不冒充DM8实测 | +| 实际隔离MySQL | 本机Docker socket已核对;新随机名称/127.0.0.1随机端口/随机合成凭据/带任务label的mysql:8.0独立容器,mysql_check.py调用真实迁移两次 | **MySQL8.0.46 PASS**:旧NULL保留、可空原生JSON、中英文/HTML样式/256emoji词往返、拒绝破坏性回退。退出后仅移除本次自有容器及匿名卷,label查询确认无残留;其他容器和109不变 | + +完整定向backend命令:在src/backend目录,以既有Python3.11独立环境执行: + +```sh +config=/private/tmp/dsh-credit-limit-fix-20261027/test-config.yaml PYTHONDONTWRITEBYTECODE=1 /Users/zhangguoqing/works/bisheng/src/backend/.venv/bin/python -m pytest -q -p no:cacheprovider test/dsh/test_content_safety_matched_words.py test/dsh/test_content_safety_repository.py test/dsh/test_content_safety_merge.py test/dsh/test_content_safety_queries.py test/dsh/test_content_safety_api.py test/dsh/test_content_safety_native_writer.py test/dsh/test_matched_words_migration.py test/database/test_alembic_single_head.py --tb=short +``` + +证据:`/private/tmp/dsh-matched-words-backend-final.log`、`/private/tmp/dsh-matched-words-backend-full.log`、`/private/tmp/dsh-matched-words-backend-head-baseline.log`;Gateway完整和生产mapper日志`/private/tmp/dsh-matched-words-gateway-full.log`、`/private/tmp/dsh-matched-words-gateway-production-mapper.log`;实际事件和MySQL脚本/回执`/private/tmp/dsh-matched-words/`(全部合成值);前端`/private/tmp/dsh-request-view-words-{ui-final,typecheck-final,target-lint-final,lint,i18n,locales,build-final}.log`。HEAD对照来自`git archive`到本次独立临时目录,没有checkout/reset已有工作树。 + +### Task Review(主agent逐文件自审) + +| 任务 | 范围/依赖核对 | 结论 | +|---|---|---| +| T225 | ContentSafetyDetails/Tree测试;AC-65、AC-66入口/无跳转/词值/旧数据反例及最终组件回归 | PASS | +| T226 | Panel/Tree只保留两视图和深度提示,无相关跳转残留;T225通过 | PASS | +| T227 | Card/Group移除onNavigate透传,保留单请求展开;T226通过 | PASS | +| T228 | Panel/Filters测试改为真实未关联请求条件,断言association_status保留;T226通过 | PASS | +| T229 | Card测试删除废弃callback fixture,原显示/展开断言保留;T227通过 | PASS | +| T230 | 新词值与Manager测试;冻结版本、深拷贝、输入/输出决策和drain、隐私和边界;完整回归通过 | PASS | +| T231 | MatcherCache/Outbox只从冻结规则还原词并校验持久化,无新配置RPC;T230通过 | PASS | +| T232 | Manager构建方向快照,原请求编号/计量边界保留;T231通过 | PASS | +| T233 | 新backend词值测试16项,旧协议和不合法值拒绝覆盖 | PASS | +| T234 | DTO/ORM严格可选契约及可空字段;T233通过 | PASS | +| T235 | repo append/projection同步字段与JSON事实比较,原wire hash不变;T234通过 | PASS | +| T236 | 请求事件metadata白名单,原鉴权/租户过滤不变,主列表不变;T235通过 | PASS | +| T237 | 新迁移及测试,唯一head/幂等/旧NULL/拒绝降级;T234通过,真实MySQL补验通过 | PASS | +| T238 | API/types兼容缺失/null,新值数量/状态/字符/重复校验;T225、T236通过 | PASS | +| T239 | 仅显式打开检查详情后显示文本和历史缺失提示;T238通过 | PASS | +| T240 | 中英文同键增删,无硬编码中文;T226、T239通过 | PASS | +| T241 | 日文同键同义,i18n检查通过;T240通过 | PASS | +| T242 | 自有实际MySQL与真实Java→Python事件契约通过;DM8和部署E2E未冒充通过 | PASS_WITH_NOTES | +| T243 | 两仓验证记录及spec/design/tasks一致,失败与未执行项如实保留 | PASS_WITH_NOTES | + +七维:架构PASS;命名及AC标注PASS;序列化PASS;数据库PASS(本轮新增迁移真实MySQL,DM8待验);前端PASS(复用原组件,无新库/样式、无硬编码中文、无新strict-ignore,维护文件均≤600行);信息泄漏PASS(只有配置命中词进入受权详情,无正文上下文/凭据/普通日志泄漏);设计同步PASS。原完整现场/双库/容量/最终特性审查仍未完成,本轮不是全feature最终验收。 + +T225–T243共19项完成,当前**228/243**。原15项T119、T120、T121、T122、T123、T124、T125、T126、T127、T128、T129、T130、T131、T132、T180保持待验;Linux DM8按用户决定继续待验。未执行真实浏览器视觉验收、109部署或客户端联调;DOM测试和构建不替代这些验收。未提交、未推送、未部署。 + + +## 2026-10-27 授权发布与验收脚本补齐(T244) + +用户已明确授权本轮提交、合并最新feat/bisheng-work-120-main并推送、CI构建和仅109 DSH13001部署,覆盖前述仅本轮不提交的限制。合并最新dfadd7ab3后B190PASS、G905PASS/10真实DB SKIP、前端269PASS;完整frontend lint/typecheck/i18n/locales和Platform生产构建PASS(18.83s),最新目标分支已修复旧95项lint问题,历史证据保持不变。 + +发布前自审确证既有test_content_safety_recovery_e2e.validate_event_shape使用严格旧字段白名单;新详情会固定返回matched_words,即使旧数据NULL也会被旧验收脚本错误拒绝。T244仅在该脚本增加可选字段严格校验,保留旧缺失/null,拒绝词值越界、错误阶段、重复/Unicode及其他额外字段;新增test_content_safety_recovery_contract.py共11项。相关最终组合**201PASS**(3.61s),Ruff通过,业务文件未增加修改。文件范围、配对、边界、泄漏与设计自审PASS。原全特性15项待验不变,当前229/244。 + +本地本轮发布证据目录/private/tmp/dsh-id-audit-release-20261027。CI SHA/digest及实际部署、权限模型维护和现场验收结果继续单独记录,不以本条预告冒充部署完成。 + + +## 2026-10-28 Wave48:仅开启敏感词检查时保留标题与模型名称(T245–T248) + +用户确认规则:敏感词检查开启、审计OFF时也保存会话标题与模型名称,方便定位;不保存输入、输出正文。沿既有接口与数据库结构实现,不改Gateway/客户端、不新增配置或迁移,不提交推送部署。 + +- 标题:模式读取失败仍DISABLED;正常OFF按认证本人/租户的既有摘要核验可信安全来源及PASSED/BLOCKED/ERROR阶段。无摘要NOT_READY,现有V1.2.0客户端可有界重试;仅有普通历史/全DISABLED条目DISABLED。只有保留的检查摘要允许补齐/改名,不从标题创建请求、目录或正文。该规则依据已接收请求的冻结检查事实,允许维护历史摘要标题,不查询Gateway当前全局开关;两个开关都关闭的新请求无安全事实,不产生新摘要或独立标题记录。无全量客户端历史扫描或数据回填。 +- 名称:生产接收器复用受控模型元数据入口,沿租户/Root共享与离线模型规则,保存服务商/模型标签和SHA-256摘要。可选名称读取上限200ms,含在既有2秒回传总预算中;异常/超时/非法标签保留空名称和模型编号、照常提交检查结果。重发可补空名称,不覆盖非空快照和原生MODEL名称。不实例化模型,不改变模型鉴权/计量,不保存输入输出正文或周边词文本。 +- 正文:未调整AuditCapture,OFF仍不发entered/body/terminal采集。安全专用场景实际验证对象存储、正文块、occurrence和manifest均为空;METADATA原生对照保持原有空manifest不变,不误将其当正文采集。 + +验证环境:已有Python3.11虚拟环境;`config=/private/tmp/dsh-credit-limit-fix-20261027/test-config.yaml`,`PYTHONDONTWRITEBYTECODE=1`,pytest禁cacheprovider。新增测试为真实ASGI标题/查询、SQL与接收事务,身份和模型目录合成;SQLite不代表真实MySQL/DM8、并发锁或部署验收。 + +```bash +# cwd: src/backend; python使用既有Python3.11环境 +python -m pytest -q -p no:cacheprovider test/dsh/test_safety_summary_metadata.py test/dsh/test_model_audit_title.py test/dsh/test_content_safety_runtime.py test/dsh/test_content_safety_api.py test/dsh/test_content_safety_ingest.py test/dsh/test_content_safety_merge.py test/dsh/test_content_safety_native_writer.py test/dsh/test_model_audit_capture.py test/dsh/test_model_audit_flow.py test/dsh/test_content_safety_group_query_api.py test/dsh/test_content_safety_queries.py test/dsh/test_model_audit_groups.py test/dsh/test_model_audit_tree_http.py --tb=short +``` + +结果:**310 PASS**(7.63秒),其中新增11个场景;日志`/private/tmp/dsh-safety-summary-metadata-final-tests.log`。最初旧逻辑的OFF标题反例直接返回DISABLED,新增读取端口缺失;转绿后原相关205项通过。扩大组合首轮新增METADATA对照误断言manifest为空(该模式既有空manifest合法),修正为前后原manifest不变且正文块/对象均为空,最终全组合310项通过,未放宽OFF专用场景的空manifest断言。 + +本次6个Python文件Ruff check/format通过;5个业务文件逐项arch-guard通过;git diff --check通过。规格AC-67、标题AC-T04、两份design与标题接口契约同步,无章节重编号。真实MySQL/DM8、109部署、正式Desktop联调均未执行;保持原15个待验任务不勾选。 + +### 任务自审 + +| 任务 | 架构 | 命名 | 序列化 | 数据库 | 前端 | 信息泄漏 | 设计同步 | 范围/配对/依赖 | 结果 | +|---|---|---|---|---|---|---|---|---|---| +| T245 测试 | N/A | PASS | N/A | N/A | N/A | PASS | PASS | PASS / N/A / PASS | PASS | +| T246 标题 | PASS | PASS | PASS(原固定结果) | PASS(原索引前缀、租户和模式锁) | N/A | PASS | PASS | PASS / T245 / PASS | PASS | +| T247 投影 | PASS | PASS | PASS | PASS(原字段、原租户事务) | N/A | PASS | PASS | PASS / T245 / PASS | PASS | +| T248 接线 | PASS | PASS | PASS | PASS(复用受控读取,无Service SQL) | N/A | PASS(仅固定异常类型) | PASS | PASS / T245 / PASS | PASS | + +主agent已读取实际实现与新增测试全文并对照设计;没有新增公开事件字段、密钥、模型正文或跨租户直接读取。该任务自审不替代完整特性L2审查和真实环境验收。 + + +## 2026-10-28 Wave49:审计界面简化(T249–T255) + +用户确认demo后要求保留全部高级筛选,并补充全部通过时不显示检查详情。此次仅修改Platform四个组件、四个既有测试文件及三语bs.json,不修改backend/Gateway/Desktop、API、数据采集或数据库。 + +### 实现与自审边界 + +- ModelAuditMode仅移除“刷新状态”按钮;保留配置、只读权限、采集关闭的说明、密钥就绪提示、租户变更取消和保存失败revision重读。HTTP测试使用真实API wrapper、mock请求传输,改为重新挂载核对保存结果,不声称真实网络验收。 +- ModelAuditPanel移除“范围与查询说明”按钮、状态和面板。ModelAuditFilters.tsx零改动:实际组件仍有模型名称、状态、有命中、方向、检查结果、请求编号、会话编号七项高级筛选,用户和时间范围、显式查询、重置、游标逻辑继续原样。 +- ModelAuditCard只有汇总、输入和输出三者均PASSED且全部已知命中标记均非true时隐藏检查详情入口,不挂载、不取事件;展开后收到全通过更新会卸载并abort旧请求。has_sensitive_hit或任一方向历史命中true时保留入口;未上报等未知值保持未知。 +- ContentSafetyEventDetails仅保留冻结命中词及事件occurred_at(检查时间);方向词值合并去重、React纯文本渲染,不显示来源、UUID、版本、规则编号、读取状态、转发说明和计量提示。每条已保存事件分别显示,原分页、刷新、失败重试和租户取消保留。旧阻断记录缺词明确无法还原;其他无词值记录不被渲染为通过。不改数据留存或后端诊断字段。 + +### 实际验证 + +所有命令在既有隔离工作树执行,pnpm=9.15.9;未安装依赖、未改锁文件和质量基线。 + +| 验证 | 命令 / 证据 | 结果 | +|---|---|---| +| 定向回归 | cwd=src/frontend;pnpm --filter bisheng exec vitest run src/pages/SystemPage/dsh --reporter=dot | **25文件、276 PASS**;最终日志/private/tmp/dsh-audit-simplification-final-tests.log | +| 完整前端lint | cwd=src/frontend;pnpm lint | **PASS**;包含Platform、Client、UI、file-viewers,日志/private/tmp/dsh-audit-simplification-final-lint.log | +| 完整前端typecheck | cwd=src/frontend;pnpm typecheck | **PASS**;日志/private/tmp/dsh-audit-simplification-final-typecheck.log | +| 三语和错误码生成检查 | cwd=src/frontend;pnpm check-i18n;pnpm locales:check | **PASS**;日志/private/tmp/dsh-audit-simplification-i18n.log(check-i18n另输出OK),无新增漂移 | +| Platform构建 | cwd=src/frontend;pnpm --filter bisheng build | **PASS**;日志/private/tmp/dsh-audit-simplification-final-build.log,原有chunk体积提示不作为功能验收 | +| 架构守卫 / 差异 | 八个修改TS/TSX逐文件bash scripts/arch-guard.sh;git diff --check | **PASS** | +| 真实组件桌面 / 窄屏 | 本机Vite挂载实际ModelAuditMode、ModelAuditRequests及卡片和事件组件,API wrapper下方替换为合成响应;1440px、500px触摸模式 | **PASS**;七项筛选完整;全通过无详情;命中详情仅词和时间;500px的document.clientWidth=scrollWidth=485,无横向溢出 | + +新增规则先在旧组件运行:四个定向文件15 FAIL / 53 PASS,失败包含仍存在的两个入口、旧诊断字段以及全通过明细入口/取消行为。实现转绿后第一轮全DSH为273 PASS;自审补充三种历史命中标记及保存失败恢复后,修正新测试中的配置展开时序与多个合法alert的选择器歧义,最终276 PASS,未放宽业务恢复或信息泄漏断言。保留分页失败后当前数据、下一页cursor重试、刷新从首页重试、迟到旧租户响应屏障、转义词值及正文权限等既有回归。 + +实际组件截图:[桌面](/private/tmp/dsh-audit-simplification-desktop.png)、[500px](/private/tmp/dsh-audit-simplification-mobile-500.png)。临时挂载入口和本轮两台Vite服务已清理;不是先前静态demo的截图。首次核验因既有HTML插件将导航改写至登录页,改用临时配置去除该HTML重写后重新核对;临时配置未进入交付。浏览器使用合成数据,**没有访问或变更109部署,不是生产验收**。 + +### Task Review(主agent读取实际文件、按仓库清单自审) + +| 任务 | 类型 | 命名 / 前端约定 | 信息泄漏 | 设计同步 | 范围 / 配对 / 依赖 | 结论 | +|---|---|---|---|---|---|---| +| T249 | 测试 | PASS;覆盖AC-46、AC-66、AC-68、AC-69 | PASS;合成词值及拒绝内部错误展示 | PASS | PASS / N/A / T248 | PASS | +| T250 | 测试 | PASS;覆盖AC-61、AC-68 | PASS;保存异常仅固定提示 | PASS | PASS / N/A / T248 | PASS | +| T251 | 实现 | PASS;原组件/配置机制 | PASS;不改查询与权限 | PASS | PASS / T249、T250 / PASS | PASS | +| T252 | 实现 | PASS;原组件/纯文本 | PASS;不还原旧词、不触碰正文 | PASS | PASS / T249 / PASS | PASS | +| T253 | 文案 | PASS;中文/英文键一致 | PASS | PASS | PASS / T249 / PASS | PASS | +| T254 | 文案 | PASS;日文键一致 | PASS | PASS | PASS / T249 / PASS | PASS | +| T255 | 验收 | PASS;真实组件及质量门禁 | PASS;仅合成数据 | PASS;spec AC-46、AC-66、AC-68、AC-69及design§4.10、§6已同步 | PASS / T251–T254 / PASS | PASS | + +后端分层、序列化、双库项本轮N/A,没有新增接口、状态库或组件库。无章节重编号;原15项DM8/容量/完整特性验收保持待验。本轮完成后任务为240/255;未提交、推送、合并或部署。 + + +## 2026-10-28 Wave50:敏感词配置界面简化(T256–T260) + +用户要求配置页沿用审计页的简化原则。仅修改DshContentSafetySettings、对应既有测试和三语bs.json,保留全部五个复选框、两个文本域及保存动作。移除配置版本、词库标识、词数、字节限制和重复说明;限制校验仍由原hook/Gateway执行,API版本、摘要及CAS字段继续保留。录入与阻断提示说明缩短,5秒新请求生效说明放入保存成功反馈;已开始请求的原策略快照逻辑不变。 + +正常读取成功时只显示保存;能力不可用、策略读取失败、配置冲突或刷新失败时显示原刷新恢复。保留原草稿、锁、权限及租户切换生命周期。新增失败恢复测试发现原界面在策略刷新失败时会隐藏已有草稿且重复显示load_failed,现改为有草稿时保持表单可见并只显示一次加载失败,原conflict继续禁用保存,成功重读更新expected_version后仍须显式保存。不改hook、API、Gateway、backend或真实配置。 + +| 检查 | 命令 / 证据 | 结果 | +|---|---|---| +| 配对反例 | cwd=src/frontend;pnpm --filter bisheng exec vitest run src/pages/SystemPage/dsh/DshContentSafetySettings.test.tsx --reporter=dot | 修改前5 FAIL / 22 PASS;/private/tmp/dsh-safety-settings-simplification-red.log | +| 最终DSH回归 | cwd=src/frontend;pnpm --filter bisheng exec vitest run src/pages/SystemPage/dsh --reporter=dot | **25文件279 PASS**,新增3项完整配置/读取恢复/冲突失败恢复;/private/tmp/dsh-safety-settings-simplification-tests.log | +| 完整前端门禁 | cwd=src/frontend;pnpm lint;pnpm typecheck;pnpm check-i18n;pnpm locales:check | **全部PASS**;/private/tmp/dsh-safety-settings-simplification-{lint,typecheck,i18n}.log;无冻结基线或生成文件改动 | +| Platform构建 | cwd=src/frontend;pnpm --filter bisheng build | **PASS**,15.03秒;/private/tmp/dsh-safety-settings-simplification-build.log;仅既有chunk体积提示 | +| 架构 / 差异 | 两个修改TSX逐文件arch-guard;git diff --check | **PASS**,其他同名saved翻译未改动 | +| 实际组件 | 本机Vite挂载实际设置组件,真实wrapper下方以合成GET/PUT替换传输;1440px / 500px触摸 | **PASS**;5个复选框及2个文本域齐全,无诊断信息/正常刷新;500px的clientWidth=scrollWidth=500;一次PUT后显示“已保存,5秒内对新请求生效” | +| 读取失败恢复 | 实际组件首次合成策略GET失败→仅错误和刷新→手动刷新→完整表单 | **PASS**;两轮能力/策略GET,恢复后无错误、无刷新,未发生PUT | + +截图:[桌面](/private/tmp/dsh-safety-settings-simplification-desktop.png)、[500px保存反馈](/private/tmp/dsh-safety-settings-simplification-mobile-500.png)。临时核验入口、配置及服务已清理。组件/API传输均为合成数据,没有连接或修改109,不能视为真实部署验收;未安装依赖或更改锁文件。 + +主agent按/task-review清单读取实际文件并自审: + +| 任务 | 类型 | 命名/前端 | 信息泄漏 | 设计同步 | 范围/配对/依赖 | 结果 | +|---|---|---|---|---|---|---| +| T256 | 测试 | PASS;AC-70完整标注 | PASS;合成错误不显示原始内容 | PASS | PASS / N/A / T255 | PASS | +| T257 | 实现 | PASS;沿用原组件,无新样式或状态库 | PASS;原安全logger及固定错误保留 | PASS;spec AC-70、design§4.10 | PASS / T256 / PASS | PASS | +| T258 | 中文/英文 | PASS;仅contentSafety域三个既有键 | PASS | PASS | PASS / T256 / PASS | PASS | +| T259 | 日文 | PASS;三语键/语义一致 | PASS | PASS | PASS / T256 / PASS | PASS | +| T260 | 验收 | PASS;质量门禁和真实组件核验 | PASS;只用合成配置 | PASS | PASS / T257–T259 / PASS | PASS | + +后端分层/序列化/数据库项N/A;没有修改权限或过滤条件,没有新增明文凭据、正文、HTTP客户端或库依赖。无章节重编号。T256–T260完成后为245/260,原15项真实DM8/容量/完整特性待验保持不变。本轮未提交、推送或部署。 + + +## 2026-10-28 Wave51:同一请求敏感词详情重复展示修复(T261–T263) + +用户发现同一请求的两条检查详情展示同一个命中词。此前只在单个事件内合并输入/输出词值;DECISION和后续DRAIN是不同事件编号,但沿用同一命中快照,逐事件渲染使词值/秒级时间看起来完全重复。用户授权本次修复后,仅修改Platform检查详情及对应既有测试,并同步spec AC-71、design§4.10和任务文档。 + +ContentSafetyRequestDetails将当前请求已加载的事件汇总为一个区域,合并输入/输出词值并去重;检查时间为已加载BLOCKED或含词值事件中最早occurred_at,无命中时为最早检查事件。加载更早页后时间相应更新,后续无命中或异常快照不抹去已加载词值。旧命中事件缺少词值时仍提示无法还原,已知词值可并存。保留原事件身份去重、分页、刷新从首页替换、失败保留数据与精确cursor重试、请求/租户切换取消及迟到响应屏障。所有词值继续由React文本节点显示,无HTML注入;没有新文案键、组件样式、库依赖或HTTP调用。内部事件/快照继续原样保留,未改backend/Gateway/数据库或109环境。 + +| 检查 | 命令 / 证据 | 结果 | +|---|---|---| +| 前置反例 | cwd=src/frontend;pnpm --filter bisheng exec vitest run src/pages/SystemPage/dsh/ContentSafetyDetails.test.tsx --reporter=dot | **6 FAIL / 44 PASS**;/private/tmp/dsh-request-summary-red.log,新增用例精确复现旧逐事件展示及时间口径 | +| DSH回归 | cwd=src/frontend;pnpm --filter bisheng exec vitest run src/pages/SystemPage/dsh --reporter=dot | **25文件285 PASS**;/private/tmp/dsh-request-summary-tests.log;含DECISION/DRAIN正反顺序、跨页词值/时间、后续无命中、历史缺值、无命中时间 | +| 完整前端门禁 | cwd=src/frontend;pnpm lint;pnpm typecheck;pnpm check-i18n;pnpm locales:check | **全部PASS**;/private/tmp/dsh-request-summary-{lint,typecheck,i18n}.log | +| 生产构建 | cwd=src/frontend;pnpm --filter bisheng build | **PASS**;14.30秒,只有既有chunk体积提示;/private/tmp/dsh-request-summary-build.log | +| 架构/差异 | 两个修改TSX逐文件执行scripts/arch-guard.sh;git diff --check | **PASS**;无守卫输出、无空白错误,单文件均不超过600行 | + +主agent按[task-review清单](../../../.claude/skills/task-review/references/task-checklist.md)读取实际源文件及测试并自审: + +| 任务 | 类型 | 命名/前端约定 | 信息泄漏 | 设计同步 | 范围/配对/依赖 | 结果 | +|---|---|---|---|---|---|---| +| T261 | 测试 | PASS;AC-71完整标注,6个合成反例;原分页/刷新/隔离断言保留 | PASS;合成词值及纯文本断言 | N/A | PASS / N/A / T260 | PASS | +| T262 | 实现 | PASS;命名导出、原布局/翻译键,无新store或HTTP | PASS;无正文、凭据、日志输出或越权接口改动 | PASS;spec AC-71、design§4.10及修订历史 | PASS / T261 / PASS | PASS | +| T263 | 验收 | PASS;回归与质量门禁实测 | PASS;未访问真实请求数据 | PASS | PASS / T262 / PASS | PASS | + +后端分层/序列化/数据库检查项N/A;无章节重编号。T261–T263完成后248/263,原15项真实DM8/容量/完整特性待验继续保留。本轮仅本机组件/API mock测试与生产构建,未执行109部署后页面验收,未提交、推送、合并或部署。 + + +## 2026-10-28 Wave52:隐藏审计统计版本(T264–T266) + +用户明确仅移除界面统计版本。ModelAuditSafetySummary删除safety_summary_version的局部取值和版本span,保留统计就绪/更新中状态、更新时间、已确认命中及已就绪计数;会话及子会话共用的组件同时生效。API类型、后台版本与就绪判定不改,不移除已有命中词去重修复。无新文案、样式或依赖,spec AC-68及design§4.10已同步。 + +| 检查 | 命令 / 证据 | 结果 | +|---|---|---| +| 前置反例 | cwd=src/frontend;pnpm --filter bisheng exec vitest run src/pages/SystemPage/dsh/ContentSafetyDetails.test.tsx --reporter=dot | **2 FAIL / 50 PASS**;/private/tmp/dsh-hide-stat-version-red.log | +| DSH回归 | cwd=src/frontend;pnpm --filter bisheng exec vitest run src/pages/SystemPage/dsh --reporter=dot | **25文件287 PASS**;/private/tmp/dsh-hide-stat-version-tests.log;新增就绪/更新中均隐藏版本而保留时间、状态及正确计数可见性 | +| 完整前端lint | cwd=src/frontend;pnpm lint | **PASS**;/private/tmp/dsh-hide-stat-version-lint.log | +| 完整前端类型检查 | cwd=src/frontend;pnpm typecheck | **PASS**;/private/tmp/dsh-hide-stat-version-typecheck.log | +| 架构与差异 | 两个本轮修改TSX的scripts/arch-guard.sh;git diff --check | **PASS**;测试文件578行,未改冻结检查基线 | + +本轮不改三语文案、后端、Gateway、数据库或真实环境;针对删除展示的改动不重复执行生产构建或部署页面验收。原15项环境/完整特性待验保持不变,未提交、推送或部署。 + +主agent按仓库task-review清单读取实际组件与测试自审: + +| 任务 | 命名/前端 | 信息泄漏 | 设计同步 | 范围/配对/依赖 | 结论 | +|---|---|---|---|---|---| +| T264 测试 | PASS;AC-68标注,合成编号及真实共享组件 | PASS | N/A | PASS / N/A / T263 | PASS | +| T265 实现 | PASS;仅删除两行,无布局样式变更 | PASS;不改权限/正文/协议 | PASS;AC-68、design§4.10及修订历史 | PASS / T264 / PASS | PASS | +| T266 验收 | PASS;完整回归及lint/typecheck | PASS;无真实数据或环境写入 | PASS | PASS / T265 / PASS | PASS | + +后端分层、序列化、数据库项N/A。T264–T266完成后251/266;旧15项待验不勾选。 + + +## 2026-10-28 Wave53:请求详情分类与简化(T267–T269) + +用户要求请求详情沿敏感词详情原则简化并加分类标题。ModelAuditCard展开区改为独立命名section及h3“请求详情”,复用现有检查详情的标题/分组布局,保留模型结果、接口响应、用量结算和正文留存四个业务轴及安全归类的失败原因、异常说明。隐藏请求/会话UUID、记录来源、重复模型名称、单独准入状态和执行开始时间;模型名称和请求时间仍显示在卡片头部。 + +原先模型结果出现两次(准入事实和模型终态);现在只保留一项:真实模型结果优先,无模型结果的外部检查摘要按model_entered=false显示未调用模型,其余显示未观测到模型执行结果。其他轴未取得时沿用未知/不适用,正文未采集仍明确显示;不把敏感词通过当模型成功。失败原因继续使用auditFailure白名单,保留HTTP状态但不透传provider私有诊断。异常说明移至dl外的p,标题与section及展开按钮建立可访问关联。没有新增文案键或库依赖,不改正文权限/取数/计量、安全详情、过滤项及后台字段。 + +| 检查 | 命令 / 证据 | 结果 | +|---|---|---| +| 配对反例 | cwd=src/frontend;pnpm --filter bisheng exec vitest run src/pages/SystemPage/dsh/ModelAuditCard.test.tsx src/pages/SystemPage/dsh/ContentSafetyDetails.test.tsx --reporter=dot | **8 FAIL / 54 PASS**;/private/tmp/dsh-request-details-red.log;原重复名称/执行时间展示用例按已授权新规则更新 | +| DSH回归 | cwd=src/frontend;pnpm --filter bisheng exec vitest run src/pages/SystemPage/dsh --reporter=dot | **25文件287 PASS**;/private/tmp/dsh-request-details-tests.log;包含独立标题、技术字段隐藏、三种模型准入/结果缺失、归类失败、折叠、头部时间及原文权限 | +| 前端质量门禁 | cwd=src/frontend;pnpm lint;pnpm typecheck;pnpm check-i18n;pnpm locales:check | **全部PASS**;/private/tmp/dsh-request-details-{lint,typecheck,i18n}.log;无冻结基线或翻译文件改动 | +| Platform构建 | cwd=src/frontend;pnpm --filter bisheng build | **PASS**;15.18秒,只有既有chunk体积提示;/private/tmp/dsh-request-details-build.log | +| 真实组件展示 | 本机Vite临时挂载真实ModelAuditCard及原样式,合成外部摘要与HTTP429模型失败两条记录;1440px及500px触摸 | **PASS**;均为独立请求详情标题;桌面两列/窄屏单列;最后窄屏clientWidth=scrollWidth=489(视口500含滚动条),无横向溢出;合成编号不显示,失败归类保留 | +| 架构及差异 | 三个修改TSX逐文件scripts/arch-guard.sh;git diff --check | **PASS**;所有文件均不超过600行 | + +截图:[桌面](/private/tmp/dsh-request-details-desktop.png)、[500px](/private/tmp/dsh-request-details-mobile-500.png)。临时HTML/TSX/Vite配置三个文件及本轮Vite服务均已清理;没有访问或修改109,不是部署后验收。初始预览夹具使用无效会话来源chat,已改成实际main枚举后重新截图;正式业务代码没有新增该夹具。 + +主agent按仓库task-review清单读取实际源文件、测试和文档自审: + +| 任务 | 类型 | 命名/前端 | 信息泄漏 | 设计同步 | 范围/配对/依赖 | 结论 | +|---|---|---|---|---|---|---| +| T267 | 测试 | PASS;AC-72完整标注,两个目标文件内更新场景 | PASS;合成词值及私有错误拒绝展示 | N/A | PASS / N/A / T266 | PASS | +| T268 | 实现 | PASS;原组件与翻译键,独立标题/可访问区域,无新状态库或HTTP | PASS;原文限制及错误白名单保留 | PASS;spec AC-72、design§4.10及修订历史 | PASS / T267 / PASS | PASS | +| T269 | 验收 | PASS;回归、门禁与真实组件核验 | PASS;仅合成数据 | PASS | PASS / T268 / PASS | PASS | + +后端分层/序列化/数据库项N/A。T267–T269完成后254/269,原15项真实DM8/容量/完整特性待验保持不变。无backend/Gateway/数据库改动;前两轮去重与统计版本隐藏的未提交改动保留。本轮未提交、推送、合并或部署。 + + +## 2026-10-29 Wave54:未就绪统计、两个方向开关及会话内辅助请求展示(T270–T279) + +用户确认:隐藏未就绪统计而保留会话和请求;敏感词只显示输入/输出开关,无旧三开关组合兼容;第三项仅调整会话内请求记录,不改变外层会话分页或统计。对应spec AC-73、AC-74、AC-75及design§4.10已同步。 + +- 前端:统计没有完成时不渲染计数、准备/更新中标签及旧时间,树/旧会话行仍可展开;Panel不显示统计/关系准备说明,实际readiness读取失败仍用固定错误提示,扫描上限及查询错误恢复保留。请求自身安全结果与已知命中不变。两个方向任一开启即在草稿/保存中派生enabled=true,两者关闭为false,原子保存、CAS、只读与租户取消不变。卡片只移除用途和会话来源标签,不删除API字段或后台事实。 +- 后端:QueryService.groups仅会话key非空分支传内部exclude_auxiliary=true;Repository.page的WHERE在keyset及limit+1前排除session-title/compaction,同时保留NULL用途。cursor指纹包含该范围,不能复用到未过滤查询。外层会话/树、普通请求页、未关联列表、汇总投影/ALL_INDEXED_REQUESTS统计及模型计量完全不改。没有新表、字段、索引或迁移,无Gateway/Desktop代码变化。 + +| 检查 | 命令 / 证据 | 结果 | +|---|---|---| +| 前端反例 | cwd=src/frontend;pnpm --filter bisheng exec vitest run src/pages/SystemPage/dsh/ModelAuditPresentation.test.tsx src/pages/SystemPage/dsh/DshContentSafetySettings.test.tsx src/pages/SystemPage/dsh/ContentSafetyDetails.test.tsx --reporter=dot | **9 FAIL / 76 PASS**;/private/tmp/dsh-audit-refinement-red.log | +| 后端反例 | cwd=src/backend;既有Python3.11执行pytest test/dsh/test_model_audit_visible_requests.py | **1 FAIL**,返回首两条辅助请求而非两条可见请求;/private/tmp/dsh-conversation-visibility-red.log | +| 最终前端 | cwd=src/frontend;pnpm --filter bisheng exec vitest run src/pages/SystemPage/dsh --reporter=dot | **26文件295 PASS**;/private/tmp/dsh-audit-refinement-tests.log;包括四种方向组合、全部关闭再启用、部分统计就绪/完整未就绪、就绪恢复和真实读取失败提示 | +| 后端查询组合 | config=/private/tmp/dsh-credit-limit-fix-20261027/test-config.yaml PYTHONDONTWRITEBYTECODE=1 /Users/zhangguoqing/works/bisheng/src/backend/.venv/bin/python -m pytest -q -p no:cacheprovider test/dsh/test_model_audit_visible_requests.py test/dsh/test_model_audit_groups.py test/dsh/test_model_audit_tree_queries.py test/dsh/test_content_safety_group_query_api.py test/dsh/test_content_safety_queries.py --tb=short | **108 PASS**;/private/tmp/dsh-conversation-visibility-tests.log。真实ASGI/HTTP及隔离SQLite,包括分页首段全辅助、第二页不漏不重、NULL用途、精确辅助编号仍隐藏、游标范围隔离、外层计数及六条原始记录不变;不代表真实双库验收 | +| 代码规范 | 本轮三个Python文件ruff format/check;全部修改源逐文件scripts/arch-guard.sh;git diff --check;前端单文件行数 | **PASS**;未新增凭据、普通日志词值、HTTP客户端或状态库,前端文件均≤600行 | +| 前端质量门禁 | pnpm lint;pnpm typecheck;pnpm check-i18n;pnpm locales:check | **全部PASS**;三语及生成检查PASS,无新文案键;/private/tmp/dsh-audit-refinement-{lint,typecheck,i18n}.log | +| Platform构建 | pnpm --filter bisheng build | **PASS**,14.92秒,仅既有chunk体积提示;/private/tmp/dsh-audit-refinement-build.log | +| 本地真实组件 | Vite挂载真实设置、树会话及请求卡片,API wrapper以下仅合成GET/PUT;默认桌面及500px | **PASS**;检查开关只有输入/输出,连同两个词库来源共4个复选框;无准备状态,无用途/来源标签,未就绪会话行仍在;输入单独启用后显式保存成功,全部取消再保存成功;500px的clientWidth=scrollWidth=500 | + +[桌面](/private/tmp/dsh-audit-refinement-desktop.png)、[500px](/private/tmp/dsh-audit-refinement-mobile-500.png)。Chrome DevTools工具因其他浏览器实例占用无法连接,未关闭其他浏览器,改用本线程IAB临时页完成实测。页面合成数据,不是109部署后验收;临时视口已恢复、临时页已关闭,三个临时入口/config文件及Vite服务均已清理。首次质量检查发现移除旧提示后readinessFailed未使用,保留真正读取错误反馈后修复;新严格测试夹具补齐必填summary_as_of后转绿,未修改冻结基线。一次追加测试脚本的cwd路径错误未改动文件,已在正确目录重试并重新验证,不将该次旧测试运行当新增断言证据。 + +本轮没有提交、推送、合并或修改109配置/数据;真实MySQL/DM8、完整特性及容量的原15项任务继续待验。 + +主agent按仓库task-review清单读取实际文件与配对用例并自审: + +| 任务 | 类型 | 架构/命名/前端约定 | 信息泄漏/设计同步 | 范围/配对/依赖 | 结果 | +|---|---|---|---|---|---| +| T270 | 测试 | PASS;AC-74四组合与原子保存 | PASS / N/A | PASS / N/A / T269 | PASS | +| T271 | 实现 | PASS;原组件和API命令,派生字段无新状态库 | PASS;无旧组合兼容 / AC-74 | PASS / T270 / PASS | PASS | +| T272 | 测试 | PASS;AC-73、AC-75标注,统计和卡片分开断言 | PASS / N/A | PASS / N/A / T269 | PASS | +| T273 | 测试 | PASS;AC-73新规则并保留真实读取失败、查询及懒加载 | PASS / N/A | PASS / N/A / T269 | PASS | +| T274 | 实现 | PASS;统计未就绪直接隐藏,原会话树交互不动 | PASS / AC-73 | PASS / T272、T273 / PASS | PASS | +| T275 | 实现 | PASS;只移除统计占位,真实错误不静默 | PASS / AC-73 | PASS / T273 / PASS | PASS | +| T276 | 测试 | PASS;AC-75真实HTTP和隔离SQL、游标范围验证 | PASS;无正文读取 / N/A | PASS / N/A / T269 | PASS | +| T277 | 实现 | PASS;Service只传内部范围,SQL在Repository,租户/授权路径不动 | PASS;无新API字段 / AC-75 | PASS / T276 / PASS | PASS | +| T278 | 实现 | PASS;只删两处标签,头部信息及详情权限不变 | PASS / AC-75 | PASS / T272 / PASS | PASS | +| T279 | 验收 | PASS;295前端、108后端及质量门禁、实际组件 | PASS;合成数据 / 文档同步 | PASS / 各实现 / PASS | PASS | + +本轮没有ORM/迁移/错误码/数据库结构变化,序列化采用原DTO;未增加权限分支、手写租户WHERE或Gateway耦合。无章节重编号,新增AC引用均写全且任务依赖无环,每任务至多两个目标文件。T270–T279完成后264/279,原15项真实双库/完整特性/容量待验保持不变;未提交、推送或部署。 diff --git a/features/v3.0.0/111-dsh-sensitive-word-control/release-review-20261027.md b/features/v3.0.0/111-dsh-sensitive-word-control/release-review-20261027.md new file mode 100644 index 0000000000..f80410f477 --- /dev/null +++ b/features/v3.0.0/111-dsh-sensitive-word-control/release-review-20261027.md @@ -0,0 +1,24 @@ +# DSH 增量发布代码审查(2026-10-27) + +范围:统一请求编号、移除未关联检查/请求明细独立视图及相关跳转、命中配置词快照与授权详情展示(T212–T243,AC-62、AC-63、AC-64、AC-65、AC-66)。本轮不是原F111全部特性的最终审查,原15项待验任务及DM8实测继续保留。用户已明确授权合入最新feat/bisheng-work-120-main、推送、CI构建及仅109 DSH13001部署,覆盖此前这些改动“不提交/推送/部署”的限制;历史验证章节仍按发生时保留。 + +B源c11c12b18与最新目标dfadd7ab3正常merge,自动合并三语bs.json,无冲突;其他最新目标分支修改均保留。G源2ed8c5b7基于最新c999f9bf,无分歧。未修改其他工作树或其未提交内容。 + +按仓库/sdd-code-review七维逐项读取实际业务文件、迁移及配对测试: + +| 维度 | 结论 | 核验 | +|---|---|---| +| 边界条件 | PASS | 单个规范非零UUID,缺失/非法/多值回退;词快照可选/严格双方向、合计20词、256码点、非空白/无重复/有效Unicode;旧缺失/null兼容 | +| 权限与认证 | PASS | 沿用原请求详情授权与租户过滤;backend不感知Gateway配置;词值不进入匿名阻断消息或主列表新查询 | +| 并发安全 | PASS | Gateway转发前赋ID;旧backend显式有效ID兼容;冻结CompiledPolicy;Observation深拷贝;原幂等事件/事务/投影不变 | +| 信息泄漏 | PASS | 仅保存有限配置词,无正文上下文或凭据;toString不输出词;React按文本渲染;普通日志不增加词值 | +| 测试覆盖 | PASS_WITH_NOTES | 合并后B190PASS,G905PASS/10原真实DB SKIP,Platform269PASS。既有全feature/DM8/容量不冒充通过,部署API/页面验收另记 | +| 代码风格/架构 | PASS | 复用既有HTTP/DTO/JsonType/前端组件,无新依赖;新迁移唯一head、DDL-only、可空且幂等,拒绝破坏性回退;Ruff和架构检查保留前轮通过证据 | +| 文档同步 | PASS | spec/design/tasks及双仓验证记录已同步,部署顺序和历史无法还原说明明确 | + +合并后frontend完整lint、workspace typecheck、i18n和locales检查均PASS;最新目标分支已修复旧95项lint基线问题,历史记录不改写。Platform生产构建已执行;实际日志/private/tmp/dsh-id-audit-release-20261027/,CI镜像仍须另核Git SHA及digest。 + +结论:本次增量允许发布;不以此勾选原T119–T132、T180。backend必须完成f111_matched_words迁移及全部实例升级后再升级Gateway,回退先Gateway且保留新增列。最新目标同时包含工具/技能权限f048-v6升级,按其upgrade.md在DSH隔离Store执行只读dry-run、确认Store/checksum及备份后完成必要维护,不影响其他环境。发布/现场结果单独记录。 + + +发布前补充T244:旧现场事件校验白名单需显式接纳新增matched_words;仅修改两个测试文件,新增11项严格反例,相关组合201PASS,Ruff PASS。七维复核无业务变更、无新增HIGH/MEDIUM。发布进度以后续确切CI提交与digest记录为准。 diff --git a/features/v3.0.0/111-dsh-sensitive-word-control/release-review-20261028.md b/features/v3.0.0/111-dsh-sensitive-word-control/release-review-20261028.md new file mode 100644 index 0000000000..a1509ed77c --- /dev/null +++ b/features/v3.0.0/111-dsh-sensitive-word-control/release-review-20261028.md @@ -0,0 +1,19 @@ +# DSH 标题与模型名称增量发布审查(2026-10-28) + +用户授权本次修复合入最新feat/bisheng-work-120-main、推送、等待镜像后仅部署109 DSH13001;未授权改变其他环境。范围T245–T248、AC-67及标题AC-T04,不替代原完整特性的15项待验。 + +五个backend业务文件、一份新增测试及既有规格/设计/任务记录。模型与正文采集链路不引入Gateway依赖,客户端沿现有标题能力与有界重试,无客户端/Gateway业务改动,无结构迁移或新配置。 + +| 维度 | 审查结论 | +|---|---| +| 边界 | PASS:OFF只为认证本人/租户已保留有效检查摘要维护标题,标题先到NOT_READY;读模式失败仍禁止写入;模型标签失败保留编号和检查事实 | +| 权限 | PASS:原标题认证与事务绑定、规范会话键、租户scope不变;名称沿现有受控元数据与Root共享校验 | +| 并发/幂等 | PASS:原标题CAS/模式锁/重复事务不变;名称只补空值,不覆盖原生或已保存名称;安全事件提交/重试边界保留 | +| 信息保护 | PASS:仅固定异常类型日志,无新事件正文或凭据;OFF对象存储、正文块、occurrence及manifest为空,标题明确属于元数据 | +| 测试 | PASS_WITH_NOTES:11个新增场景,相关13文件310PASS;Ruff、架构和差异检查通过。真实双库/部署和正式客户端验收另记,不冒充通过 | +| 架构与风格 | PASS:复用模型元数据入口与既有SQL字段/事务,无Service SQL或新依赖;名称读取200ms包含于2秒回传预算,不实例化模型 | +| 文档 | PASS:AC-67、标题AC-T04、两份设计与客户端契约同步;已保留检查摘要标题可继续补齐,两项关闭的新请求不创建摘要 | + +当前分支与最新目标相比,目标新增仅知识空间对话选择及客户端UI/测试;目标无backend或迁移差异。合并后复跑相同310项及Ruff/架构/差异检查。CI按最终准确Git SHA核验,部署按对应backend镜像digest,更新DSH backend及worker;前端和Gateway无本修复变更,保持当前部署。其他容器的ID、镜像与启动时间前后对照。 + +结论:本次增量可发布。没有全量历史数据回填;名称无法取得时仍回退模型编号。后续真实部署结果写入独立发布验证报告。 diff --git a/features/v3.0.0/111-dsh-sensitive-word-control/rollout-runbook.md b/features/v3.0.0/111-dsh-sensitive-word-control/rollout-runbook.md new file mode 100644 index 0000000000..e7d3572746 --- /dev/null +++ b/features/v3.0.0/111-dsh-sensitive-word-control/rollout-runbook.md @@ -0,0 +1,203 @@ +# F111 DSH 敏感词控制发布与回退草案(待隔离演练) + +日期:2026-10-24。此文件为实施过程留档的待执行草案,不是已执行发布记录,不授权部署、生产迁移或删除。T131 依赖 T129/T130;本次只读准备不满足这些前驱,不勾选 T131。 + +覆盖 AC: AC-02、AC-03、AC-04、AC-11、AC-12、AC-16、AC-17、AC-32、AC-43、AC-51、AC-54、AC-55、AC-58。 + +## 1. 证据和状态边界 + +| 项目 | 当前状态 | 本草案能够证明的范围 | +| --- | --- | --- | +| 当前配置、注册、CLI、DDL、回退代码逐项读取 | READ_VERIFIED | 文件确有相应实现;不是线上有效配置或现场演练证明 | +| Backend T119 MySQL 隔离库 | 已有组件证据:16 PASS | MySQL 8.0.46、PyMySQL 1.1.2、SQLAlchemy 2.0.44;fresh 与真实 work110 archive 升级后 144 表结构一致、F111 head、事务/保留测试;不替代发布演练 | +| Backend Linux DM8 | NOT_RUN | 未具备真实 Linux DM8 环境,不用其他数据库结果替代 | +| Gateway T120 MySQL SQL片段 | 主独立10 PASS,DM8 NOT_RUN | 两独立全新库实测;小表计划/锁及进程恢复证据见verification,不替代部署演练 | +| 跨仓 E2E、容量、发布回退演练 | NOT_RUN | 最终状态由implementation-verification.md登记,不从源码或本草案推断通过 | +| 入口封闭、防旁路、时钟、真实 worker/Beat、真实租户策略切换 | NOT_RUN | 必须到拟发布隔离拓扑逐项记录 | + +后续每个执行步骤须填写操作者、时间、仓库 artifact SHA/镜像 digest、数据库方言/版本、受控证据路径、PASS/FAIL/NOT_RUN。不要把密钥、Token、Cookie、License 内容、完整连接串或模型原文写进证据。此草案没有读取应用 License 密钥。 + +## 2. 发布前硬门槛(均为 NOT_RUN) + +1. 确定实际 Gateway、Backend API、全部原生审计写入者、Celery 默认队列 worker、Beat、Platform、Desktop 版本和部署拓扑。三仓当前未提交工作树不是可发布 artifact;先经正式交付流程产生可追踪 artifact。 +2. T129/T130、所用方言数据库闸门、E2E 和容量结果未完成时不能宣称已具备上线条件。Linux DM8 现场必须独立验收,MySQL 通过不授权 DM8 启用。 +3. 确认可恢复备份/恢复演练和数据库操作权限;敏感词表和 Outbox 都属于保留数据。回退不把删表、删事件或清水位作为常规操作。 +4. 盘点所有公开域名、代理、LB、路径重写、Desktop 自定义 Base URL、直连 IP/端口、IPv4/IPv6 和旧入口。只有 Gateway 的两种模型 POST 入口应可由客户端触达;Backend 模型端口只允许可信 Gateway/管理网络。 +5. 所有节点 NTP/系统 UTC 对时,记录偏差;回传允许发送时间 ±300 秒不是允许时钟失控的运维指标。 +6. 各 Gateway 副本使用相同的 Backend 目标、`backend-namespace`、稳定 `source-id`、内置词库文件及摘要。namespace 要区分不同 Backend 部署;不要在进程重启、扩容或回退时临时更换 source-id,导致历史事件来源/缓存空间变化。 +7. 先完成全部原生 writer 和安全接收端的删除水位保护再允许 `purge-audit --apply`。代码端口为 `lock_retention()`:缺行先创建/flush,设置行锁在主行锁之前;保留闸门覆盖原生 writer、新事件及重复补报合并,不能只升级 API 漏掉旧 worker。 +8. 不启用任何自动明细保留周期。当前默认不清安全明细;30 天是设计容量示例,不是落地清理配置。 + +## 3. 配置核对(READ_VERIFIED;有效部署配置验证 NOT_RUN) + +| 配置/目标 | 实际入口 | 要求 | +| --- | --- | --- | +| Gateway 总开关 | `dsh.content-safety.enabled` | 默认 `false`;开启是部署配置变更,不假定现有代码支持热刷新 | +| 稳定身份 | `dsh.content-safety.backend-namespace` / `source-id` | 默认空;开启时必须显式、格式合法且稳定;不能让用户请求覆盖 | +| Backend origin | Gateway `bisheng.bisheng-api-url` | 既有配置;仅 HTTP(S) origin,可无 path 或 `/`,不能带凭据/query/fragment;profile、模型代理、回传共同固定到它 | +| 共享 HMAC | Gateway `bisheng.gateway-hmac-secret`;Backend `sso_sync.gateway_hmac_secret` | 必须同一已存在的共享值,安全配置注入,不打印或保存明文,不引入 DSH 新密钥/key_id | +| 既有环境变量 | Gateway 示例 YAML 引用 `BISHENG_GATEWAY_HMAC_SECRET`;Backend YAML以既有`!env BS_SSO_SYNC__GATEWAY_HMAC_SECRET`显式引用env_file(无自动env覆盖) | 只登记变量名;不能为了排查而 `env`/`printenv` 输出值 | +| 签名头 | Gateway `bisheng.signature-header`;Backend `sso_sync.signature_header` | 默认双方 `X-Signature`;定制时双方一致,走同一个已批准的变更 | +| Gateway HTTP 期限 | `spring.cloud.gateway.httpclient.response-timeout` | 启用前必须为正;Docker 挂载示例为 `3600s`,不是推荐 SLO。源码 resources YAML 未给这个值,不能假定所有部署都已有 | +| 内置词库 | 既有 `custom.sensitive-words-path` / 既有词库加载路径 | 各副本内容/摘要一致;缺词库、摘要不一致或读取失败不能视为租户关闭而放行 | +| 管理站点 | Gateway `bisheng.home-url` | 管理 PUT 要求合法同源 Origin;不以 DSH Token 取代实时管理员授权 | +| Backend DSH/Beat | `dsh.enabled` / `dsh.audit_state_scan_interval_seconds` | DSH 默认关闭;既有使用 DSH 的环境保留其正常业务开关。Beat 安全扫描在 `dsh.enabled=true` 时注册,默认间隔 60 秒,支持 10..3600 秒 | +| 租户范围 | Backend `multi_tenant.enabled` | 关闭时只接受现有默认租户 1;开启时共享 HMAC 代表本 Backend Gateway 服务,但每条事件仍严格单租户。source-id 不是权限白名单,也不检查当前用户/租户 active 来拒绝历史事实 | + +SSO 登录本身不需要开启,`bisheng.enable-sync` 也不是 DSH 回传前置。共享 key 未配置只使回传接收不可用;Backend 模型鉴权/额度/计量不新增 Gateway/HMAC 运行时依赖。不要读取或改动应用 License 密钥;商用 Gateway 的既有能力准入只能验证其结果,不能在此流程绕过。 + +## 4. 精确启用顺序(以下执行均 NOT_RUN) + +### 4.1 先扩 Backend schema、兼容读取、保留保护 + +Gateway 检查保持关闭。在所用方言备份和前驱通过后按已有发布流程更新 Backend artifact,再从 `src/backend/` 用现有安全配置运行: + +```bash +uv run alembic heads +uv run alembic upgrade head +uv run alembic current +``` + +预期唯一 head/current 为 `f111_dsh_content_safety`;本次不能照抄早期设计的 work110 head。在线升级先真实模型发现/create_all 创建缺表,再执行扩展既有表的 revision;不要用 `create_all` 冒充修改已有列、用 `stamp` 冒充升级,或在迁移里回填业务数据。复跑检查结构等价,不把 `IF NOT EXISTS` 视为漂移修复。 + +检查 `dsh_content_safety_event` 新表、主审计安全摘要/来源/可空模型轴、树/设置水位及约束/索引。老 MODEL 行原有模式和未知用量保留,EXTERNAL_SAFETY 可以无 mode/session/manifest/key,不能捏造执行结果和用量。先确认全部 writer/接收端水位保护已生效,暂不清理。 + +### 4.2 启动并验证独立投影/默认队列/Beat + +沿既有部署编排启动/更新,不在本草案创建新部署。仓库实际使用的入口为: + +```bash +uv run celery -A bisheng.worker.main worker -l info -P threads -Q celery -n celery@%h +uv run celery -A bisheng.worker.main beat -l info +``` + +生产并发数沿现有审核配置,不把上述省略项当容量建议。已有 `task_routers` 可覆盖队列;必须检查最终路由和实际消费队列,而不只启动 knowledge/workflow worker。Beat 应只有规划中的活动调度实例,避免复制部署误建多个调度器。 + +确认注册 `dsh.scan_content_safety` / `dsh.project_content_safety`,Beat 条目 `dsh-content-safety-projection` 使用扫描间隔。扫描只按 dirty tenant IDs 分页,每页100;可重扫已停用/归档租户的历史,单租户 project 通过 Celery `headers.tenant_id` 与任务参数精确匹配。禁止绕过信号/直接伪造事务来“修”上下文。 + +按需先 dry-run,再在隔离演练授权下有限重建: + +```bash +python scripts/dsh_content_safety_maintenance.py rebuild --tenant-id "$TENANT_ID" +python scripts/dsh_content_safety_maintenance.py rebuild --tenant-id "$TENANT_ID" --apply --batch-size 100 --max-batches 20 +``` + +有 `next_cursor` 按同 tenant 的 `--cursor` 续跑;`examined` 含 `skipped_expired`,不能叫“恢复条数”。必要时使用已有 `prepare_dsh_audit_tree.py --tenant-id ...` 的默认 dry-run/独立 apply 步骤,不能越过 BUSY 租约。 + +以真实管理员在 `/api/v1/dsh/admin/model-audit/query-readiness?tenant_id=...` 检查 `safety_ready`、`safety_summary_version`、`safety_summary_as_of`、`summary_ready`、`indexing_pending` 和构建 phase。不能把 API 200 或版本0当已就绪;0 是合法已发布版本,需同时满足真实 published_at、水位一致、无 pending/failed。worker 停止后新命中迟到必须显示未就绪并能恢复收敛,不应伪造当前 as_of。 + +### 4.3 发布 Platform / Desktop,再部署 Gateway(保持总开关 false) + +先验证 Platform 外部记录 N/A/未知/已清理与命中过滤、Desktop Chat/Responses content filter 文案和真实工具屏障,然后发布 Gateway。 + +Gateway DDL 用所选方言的实际脚本: + +- MySQL:`docker/db/f111_dsh_content_safety_mysql.sql`(8.0.16+ CHECK 生效;本地已验版本另见 T120)。 +- Linux DM8:`docker/db/f111_dsh_content_safety_dm.sql`(CLOB/TIMESTAMP(6)、schema owner/disql;当前 NOT_RUN)。 + +先确认目标现有 Gateway schema,再按 DBA 工具加载,不在命令行携带密码。已有表须完整核列/约束/索引,不能靠脚本跳过表示一致。两个表为 `gt_dsh_sensitive_policy`、`gt_dsh_safety_outbox`,均不存 Token/原始命中正文。 + +确认实际生效配置包含两条 order=-10、`Method=POST`、Path `matchTrailingSlash:false` 的专用路由,目标为 `${bisheng.bisheng-api-url}`,没有 `CacheRequestBody`。文件来源分别为 `src/main/resources/application.yml` 和 `docker/gateway/config/application.yml`;`src/main/resources/application-dm.yml` 是方言覆盖,不能当作单独完整路由配置。 + +总开关 false 时预期原 DSH 模型透传;配置完整时 management/Outbox 仍创建且补报运行,检查 Bean 不创建。配置不全时 capabilities 要如实不可用,不能显示虚假的关闭可用状态。 + +### 4.4 HMAC、入口和回传闭环 + +验证以下真实网络路径,所有结果为 NOT_RUN,不能仅用单元测试替代: + +1. Gateway 使用原 Bearer 调用固定 Backend `GET /api/v1/dsh/profile`;身份来自 Backend 可信结果,不能只解 Token claim 推租户。缓存正例最多共享30秒、本机2秒且受 Token 截止减5秒约束;未知截止时间不缓存;miss 的1秒总预算含排队,32在途/64等待。模型转发仍把原凭据交 Backend 重新准入。 +2. 回传为固定 `POST /api/v1/internal/dsh/content-safety-events`,无 query。UTF-8 JSON envelope 为 `sent_at` 和原始 `event`;HMAC-SHA256 对 `upper(METHOD) + LF + PATH + LF + raw_body` 签名,默认小写 hex。PATH 不含 query;重试只换 sent_at/签名,不改 event、原 null/missing、时间拼写或 payload hash。 +3. 由真实 Gateway dispatcher 对合成演练事件完成回传,观察精确200、`receipt=COMMITTED`、event_id 匹配才 ACKED;重复相同事件返回 duplicate 且不重复计数。仅 HTTP2xx、内存队列成功、响应编号不匹配、伪回执都不能算 ACK。 +4. 无签名、只有浏览器 Cookie/用户 Token、错误 key/签名、超时窗应拒绝;GET 非注册方法、query 参数、超过64KiB envelope、重复 JSON key/坏 UTF-8 有安全拒绝。单租户的其他tenant拒403;拒绝不应触业务 SQL 或泄露原正文。不要把这些负例写进真实业务审计。 +5. 外部网络用**有效演练 DSH 凭据**分别尝试直连 Backend 两个模型 POST,网络层必须不可达,而非仅401/403;有 Token 时能直连就仍有旁路。覆盖所有公开LB、旧域名、IP、IPv6、改写入口及等价编码/matrix路径,且合法规范路径确实经过同一检查。 +6. 登录、刷新、profile、模型列表、用量查询不作为内容检查对象;普通 Gateway 应用/旧敏感词功能保持原路由和语义。回归无 Gateway 的 F110 登录、模型、用量能力,确认 Backend 没有新增 Gateway依赖。 + +所有 HTTP 凭据从0600临时认证文件或既有受控测试工具注入,不在 curl 参数、终端历史/trace、文档保存。回传测试使用现有真实 client/dispatcher,不临时加后门 endpoint 或手工改 Outbox payload。 + +### 4.5 测试租户开启,再逐租户扩大 + +使用真实租户管理员经 Gateway 的 `GET /api/sensitive/dsh/capabilities?tenant_id=...` 和 `GET/PUT /api/sensitive/dsh/settings?tenant_id=...`,普通用户/其他租户管理员无权越租户。capabilities 应显示 configuration_ready、available 与一致的 builtin_digest。PUT 要求实时授权、合法同源 Origin、`expected_version`;冲突重新读,不盲重试覆盖。 + +先保存测试租户有效词库(内置/自定义源、auto_reply、输入/输出方向),租户 enabled 保持 false;未配置租户默认关闭。在通过入口封闭和配置校验后,将部署总开关设 true 并按已有流程平滑更新 Gateway。此时非试点租户保持关闭,再保存试点租户 enabled=true。版本、开关、方向、词库/提示原子提交,新请求最多5秒生效,在途保留进入时的快照。 + +至少实际验证 Chat 非流/流与 Responses 非流/流、输入/输出两方向、跨分片Unicode、工具参数完整/截断/错误终态、正常不命中、租户隔离、失败不可伪装关闭、后端执行/交付/计量独立。输入阻断只产生外部安全审计,不伪造 backend 执行;输出阻断立即停客户端交付,后台续读最长从命中起30秒且受容量/字节/异常提前截断,真实用量可晚到/未知,不能写0或宣称供应商已停止。 + +## 5. 观察和放量条件(现场 NOT_RUN) + +当前真实 exporter 为 `DshSafetyTelemetry` 的固定枚举日志快照,不声称已有 Prometheus/Actuator endpoint。检查 PROFILE_HIT/MISS/MERGED/ERROR、PROFILE_ACTIVE/PROFILE_QUEUED/PROFILE_LATENCY_NANOS、POLICY_DIGEST_MISMATCH、CHECK_HIT/ERROR、COMPILE_REJECTED、ENCODED_BUFFER_BYTES、CHECK_ACTIVE、DRAIN_ACTIVE、RECORDING_GAP、Outbox pending/inflight/quarantined/oldest-age/last-success。 + +源码预算:32MiB请求/累计响应、Responses单事件8MiB、全局64MiB编码额度、16检查/8续读、匹配器缓存128MiB;这是 admission 上限,不是 RSS 上限、吞吐保证或可承诺SLO。实际 HTTP writer 未完成持有的 bytes 计入预算;慢客户端不能提前退额。JVM解析树/字符串放大、数据库锁/索引开销、profile未命中负载及双库百万/千万数据查询 p95/p99 均引用 T126–T129 **待测**结果,未测不填数值结论。发现缺口/隔离/磁盘压力立即停止放量并保留证据。 + +默认列表不返回/扫描明细来计算主命中;详情按需加载。表独立不自动证明容量;默认7天/最多31天查询窗口、移动窗口和显式筛选绑定游标仍须执行计划/扫描行数实证。 + +## 6. 应用回退(NOT_RUN;默认不做结构收缩) + +1. 优先在当前兼容 Gateway 上关闭试点/所有已启用租户策略,记录新版本;等待最长5秒的新请求生效窗口,验证新请求停检查。在途保持原快照,不因关闭改词库或抹掉已发生事实。 +2. 如需部署总开关 false,按现有平滑更新流程操作。没有热刷新保证;先记录在途请求/检查/续读/HTTP writer 状态并等待安全退出。30秒只约束**已命中后的后台续读**,不是所有模型请求或慢 HTTP writer 的总退出时间,不得按30秒强制认定已无在途。 +3. 保持稳定 namespace/source/key/header/固定目标、DB两表和全部 pending/lease 状态。总开关 false、租户 false 不停止独立补报器;重启后过期 lease 可恢复领取。PENDING/INFLIGHT/QUARANTINED 不自动删除,错误隔离不是成功;ACKED 只有正确提交回执才设置,当前 acknowledged 清理保留7天且有界。不得为清空积压改ACK或改payload。 +4. Backend 接收与投影 worker 保持可用,直到当前未确认事件和后续在途产生的事实完成处理。若回退到**不含补报器的旧 Gateway**,必须先在现有兼容实例保留补报服务能力或明确登记投递暂停,不能宣称旧版本仍会自动补报;历史表不能删除。若 Backend 旧版不含接收路由,回传会重试,不能把404当ACK;避免先撤接收端导致持续积压。 +5. 旧 Backend/旧 DTO 回退须先实测 EXTERNAL_SAFETY 的 nullable mode/session/轴、N/A、未知/已清理,以及已有安全列/树水位兼容。未有兼容证明则阻止旧 DTO/旧 writer 回退,而不是把外部记录改MODEL或删掉。回退不得丢失原生writer删除水位保护;清理水位持久且单调,不能清零来使旧writer继续写。 +6. 保留 Backend 安全表/摘要列/设置水位、Gateway policy/Outbox、原审计证据/对象/密钥、DshModelCall、DshMonthlyUsage及独立计量链。输出命中不等于未消耗额度;UNKNOWN不能被回退脚本改0。原生body/manifest/object可能共享,不属于本流程可销毁对象。 +7. 恢复同一兼容版本后,保持 event_id/payload/hash,重复投递幂等;先检查积压和隔离,恢复 worker 收敛 readiness,再评估重新启用。不要清空缓存/水位或重建新source来掩盖故障。 + +Backend 的 F111 Alembic `downgrade()` **无条件 RuntimeError 拒绝**,即使空库也拒绝。已有真实MySQL测试验证空/有事实均不改schema,populated还保留完整Fact/Request/Call/Monthly业务快照。回退应用保留F111 schema/事实;不使用 `alembic downgrade`、`stamp` 或手工删列冒充支持回退。 + +## 7. 仅空测试 schema 的结构回退(单独删除授权+演练;NOT_RUN) + +此节与常规应用回退分开。执行前停止**全部副本**管理/策略写入、模型检查、Outbox发送/ACK清理、Backend接收/原生writer/投影及相关维护,网络确认不会新进写;单改enabled=false不满足停写条件。取得独立删除授权和可恢复快照。ACKED即便已投递仍是表内数据,不能为了判空先清掉。 + +Gateway已有真实脚本(MySQL隔离SQL验证已由T120执行,以下部署演练仍未执行): + +| 方言 | 脚本 | 保护 | +| --- | --- | --- | +| MySQL | `docker/db/f111_dsh_content_safety_mysql_rollback.sql` | 必须同一连接先 `SET @f111_writers_stopped = 1`,否则拒绝;检查 policy 与 outbox 两表 COUNT,任一非0在任意DROP前拒绝;两个都空才先Outbox后Policy;需临时CREATE ROUTINE权限 | +| Linux DM8 | `docker/db/f111_dsh_content_safety_dm_rollback.sql` | 默认本地 `writers_stopped INTEGER := 0` 拒绝;只在隔离停写确认后受控临时副本改为1,以schema owner/disql执行;USER_TABLES+两表COUNT,任一非0在任何DROP前拒绝;当前真实环境 NOT_RUN | + +MySQL DBA 会话示意,仅作为未来隔离演练记录模板,**本次未执行**: + +```sql +SELECT DATABASE(); +-- 独立核对批准的唯一隔离 schema、全部写入者已停、两表真实为空后: +SET @f111_writers_stopped = 1; +SOURCE /approved-artifact/docker/db/f111_dsh_content_safety_mysql_rollback.sql; +``` + +必须分别演练:默认无停写确认拒绝;Policy非空/Outbox空拒绝;Policy空/Outbox非空(含ACKED)拒绝;双非空拒绝;双空且已停写才删;核对失败前后两表/原业务表及完整数据快照不变。脚本的ack变量不自动锁住其他写入者,停写是操作方真实前置,不能用写1代替。 + +Backend **没有本特性可直接执行的空库收缩脚本**;F111 downgrade 的拒绝不能靠修改 revision 绕过。后续T131若要演练Backend空schema物理收缩,需单独可审DBA步骤:全模型/接收/投影停写、确认受影响新增表和列无事实/非默认业务数据,列出完整逆向DDL与约束顺序、双库验证和恢复路径。当前该部分为 NOT_RUN/步骤待制定,不提供猜测DM语法或不带保护的DROP。 + +## 8. 明细/主审计保留运维(可选,默认不执行) + +命令从 Backend `src/backend/` 运行,沿现有服务 `config` 配置;参数仅演示变量,不提供真实tenant/日期/凭据。所有命令默认dry-run,检查配置/tenant并报告计划,不扫描业务数据,因此dry-run输出不是待清理/可恢复条数。 + +```bash +python scripts/dsh_content_safety_maintenance.py purge-details --tenant-id "$TENANT_ID" --before "$UTC_CUTOFF" +python scripts/dsh_content_safety_maintenance.py purge-audit --tenant-id "$TENANT_ID" --before "$UTC_CUTOFF" +``` + +明确保留审批和恢复要求后才加 `--apply --batch-size 100 --max-batches 20`。UTC_CUTOFF必须带时区,内部归一UTC、exclusive;batch1..100/max-batches1..10000。`purge-details`显式include_audits=False,不消费既存主清理水位/cursor,保留命中摘要与PURGED语义。`purge-audit`先独立提交单调删除checkpoint再有界删除主行和其关联安全facts,同事务更新汇总;未关联facts不误删。运行中/未结算行跳过,结束后同cutoff再跑可重访,即使上一轮has_more=false。 + +失败batch全部回滚,早前已提交batch和checkpoint保留;JSONL只有commit成功后输出,丢输出可以幂等重试。CLI退出0成功、2参数/请求错误、3配置/依赖、4无tenant、5运行失败。只清主审计/安全事实,不回收原生子表元数据、共享原文对象/密钥或模型调用/月用量。重建仅已有完整事实且水位允许范围;已删事实不承诺恢复,过期缺主不得复活。 + +## 9. 演练结果待填清单 + +- [ ] NOT_RUN:实际artifact/config生效来源、目标origin、共享key一致性(只记录校验结论)、签名头、NTP。 +- [ ] NOT_RUN:全部公开Chat/Responses流/非流入口、等价路径、带有效Token的Backend直连网络拒绝。 +- [ ] NOT_RUN:真实HMAC回传/重复/ACK丢失/重启、检查关闭仍补报、不可恢复隔离和磁盘告警。 +- [ ] NOT_RUN:真实worker/Beat/tenant header/停止恢复、迟到命中dirty重新发布、安全readiness。 +- [ ] NOT_RUN:Platform授权与筛选/跨租户、Desktop真实工具屏障、旧应用/无Gateway回归。 +- [ ] NOT_RUN:各副本词库摘要、租户CAS/5秒生效/在途快照、profile负载和失效。 +- [ ] NOT_RUN:T126–T129容量、锁/scan/p95/p99/堆放大与支持负载;不编SLO。 +- [ ] NOT_RUN:检查关闭/应用回退/接收保留/旧DTO兼容/水位保护/积压恢复。 +- [ ] NOT_RUN:Gateway双库空表收缩与任一有数据拒绝;Backend独立空schema收缩步骤仍待评审。 + +## 10. 当前读取的代码锚点(仓库相对路径) + +Backend:`bisheng/dsh/content_safety_runtime.py`、`dsh/api/endpoints/content_safety_events.py`、`dsh/api/endpoints/model_audit_admin.py`、`dsh/domain/services/model_audit_queries.py`、`dsh/domain/repositories/model_audit_write.py`/`content_safety.py`、`bisheng/worker/config.py`/`dsh/content_safety.py`/`dsh/registry.py`、`core/config/sso_sync.py`/`dsh.py`、F111 revision、`scripts/dsh_content_safety_maintenance.py`及README。 + +Gateway:`DshSafetyProperties`、`DshSafetyBeansConfiguration`、`BishengConfig`(只读字段定义,未读取License值)、`DshSensitivePolicyController`、`DshContentSafetyFilter`、`DshProfileClient`、`DshModelProxyClient`、`ContentSafetyEventClient`、`DshSafetyOutboxDispatcher`、`DshSafetyTelemetry`、两份application.yml路由/DSH配置及四份F111双库DDL/rollback SQL。 + + +## 11. 109已验证的启动就绪与连接切换 + +Backend/worker更新后,先直接检查127.0.0.1:17860/health,再以真实平台管理身份GET核对业务接口200/成功envelope;13001/health可能是SPA兜底页面,不能作Backend就绪证据。2026-10-24实际Gateway在后端重建窗口对旧/新容器地址发生connection-refused,后续还观测管理teardown503;保留日志和失败,不重试模型来掩盖。确认Backend业务就绪后,仅对该DSH项目的同镜像Gateway执行精确force-recreate刷新连接,再要求受保护capabilities连续三读成功后开始验收。失败时按CAS验证本轮最后版本/合成词/提示才恢复原策略,版本不符停止自动写。此步骤不修改共享Docker daemon、其他Compose环境或业务认证。 diff --git a/features/v3.0.0/111-dsh-sensitive-word-control/spec.md b/features/v3.0.0/111-dsh-sensitive-word-control/spec.md new file mode 100644 index 0000000000..ee0bb6cd8c --- /dev/null +++ b/features/v3.0.0/111-dsh-sensitive-word-control/spec.md @@ -0,0 +1,230 @@ +# Feature: DSH 模型转发敏感词控制 + +> 本文只定义需求、验收标准和范围边界。实现决策、接口、字段、表结构、索引、缓存分层和性能验证方案在 spec 确认后的 design.md 中确定。 + +- **特性编号**:F111 +- **关联 PRD**:[已确认的完整方案](../../../docs/architecture/proposals/2026-10-22-dsh-gateway-content-safety.md) §1、§3–§13(作为本特性的需求输入) +- **优先级**:P0 +- **所属版本**:v3.0.0 +- **依赖**:[F110 DSH 本地登录与部门额度](../110-dsh-local-access/spec.md);现有 DSH 模型审计能力;Gateway 现有敏感词能力 +- **状态**:已用户确认(2026-10-23) +- **工作分支**:feat/bisheng-work-dsh-guardrails +- **创建日期**:2026-10-22 +- **修订日期**:2026-10-27 + +需求发现已通过此前的链路梳理和方案讨论完成。用户于 2026-10-22 确认前置方案;2026-10-23 按已确认建议完成 spec 修订和自审后,用户确认本文,并要求编写及评审 design。本次 design 评审中,用户明确不纳入固定分页窗口及跨事件身份冻结防护;本文按该决定同步澄清 AC-38、AC-42、AC-49。用户现已确认 design,进入 tasks 文档拆解与评审;尚未进入业务代码实现阶段。 + +- **范围边界**: + +- **本次纳入**: + - Gateway 按可信租户身份执行 DSH 输入和输出敏感词检查,复用现有 Gateway 的词库与匹配能力。 + - 按租户统一管理检查开关、检查方向、词库来源、自定义词和阻断提示。 + - Chat Completions 与 Responses 的流式、非流式、工具调用及可读推理内容。 + - profile 身份定位结果复用、有效期约束、并发访问和故障时的行为。 + - 检查结果可靠回传、输入阻断审计、模型审计关联、事件明细与命中摘要。 + - 审计列表、详情、会话及树形目录中的命中展示、筛选、汇总和历史兼容。 + - 平台管理界面与 Desktop 对策略和阻断结果的正确呈现。 +- **本次明确排除**: + - backend 主动调用或配置 Gateway、在模型执行链路重复执行敏感词检查;保持 F110 本地接入边界。 + - 商业 License、人数、席位准入以及旧 Gateway DSH 登录认证的恢复;不改变既有准入和额度规则。 + - 按用户、部门或模型设置不同敏感词策略;本期统一采用租户策略。 + - 图片识别、OCR、音视频、加密推理、同音词、拼音及语义审核;本期采用既有字面匹配语义。 + - 命中后的整轮回复撤回;流式响应已经交付的安全前缀可以保留。 + - 将原有工作台、频道、文件或应用的敏感词策略迁移为 DSH 策略;保留各自既有行为。 + - 全历史跨期统计、任意命中词全文检索、专项导出和新增独立检索平台;本期提供有界在线筛选。 + - 固定审计分页的相对时间窗口、跨安全事件冻结身份及新增独立请求绑定机制;本期沿用既有分页方式,接受毕昇成功验证结果的身份变化。 + - 本轮的业务代码修改、提交、发布或部署;当前交付仅限 spec、design、tasks 及评审文档。 + +## 1. 用户故事 + +### 1.1 租户管理员 + +作为租户管理员,我希望统一管理本租户 DSH 模型调用的输入与输出敏感词规则,以便所有受保护的模型请求遵守同一套内容要求。 + +### 1.2 Desktop 使用者 + +作为 Desktop 使用者,我希望命中敏感词时立即得到清楚的阻断提示,并停止当前轮次和未经检查的工具执行,以便理解请求未完成的原因。 + +### 1.3 审计人员 + +作为有审计权限的人员,我希望在请求、会话和目录层级发现命中记录,查看检查方向和依据,并区分模型执行结果与内容拦截结果,以便准确追溯。 + +### 1.4 平台运维人员 + +作为平台运维人员,我希望检查能力可按租户启停,身份查询开销受控,回传故障可发现并恢复,以便在多实例和持续请求下保持可用并识别审计缺口。 + +### 1.5 无 Gateway 部署的使用者 + +作为无 Gateway 部署的使用者,我希望仍能使用 F110 提供的本地登录、模型授权、额度和审计能力,以便内容安全增量不改变现有接入条件。 + +## 2. 验收标准 + +### 2.1 保护范围与独立运行 + +- **AC-01** — WHERE DSH 敏感词检查已接入,THE SYSTEM SHALL 覆盖 Chat Completions 和 Responses 的流式与非流式模型请求,且不因客户端声明的请求用途、会话类型或审计元数据而跳过检查。 +- **AC-02** — WHEN 部署级检查能力未开启,THE SYSTEM SHALL 保持原有 DSH 透传行为;WHEN 能力已开启但可信租户的策略未配置或关闭,THE SYSTEM SHALL 不对该租户执行敏感词阻断。首次安装和首次使用的默认状态均为关闭。 +- **AC-03** — WHERE Gateway 未部署,THE SYSTEM SHALL 继续允许符合 F110 规则的本地登录与模型使用;backend 的模型鉴权、模型权限、额度及计量不得新增 Gateway 运行时依赖,也不得恢复 License 或席位准入。 +- **AC-04** — WHERE 部署宣称 DSH 模型请求受本功能保护,THE SYSTEM SHALL 覆盖客户端实际可达的全部 DSH 模型调用入口,使客户端不能通过直连 backend 或其他公开入口绕过检查;登录、刷新、profile、模型目录和用量查询不作为内容检查对象。 + +- **AC-05** — WHEN 适用检查全部通过且请求正常转发,THE SYSTEM SHALL 保持 backend 原有正文、工具调用、协议顺序、错误及用量语义,不改写模型内容或另行生成模型调用。 + +### 2.2 租户策略与管理权限 + +- **AC-06** — WHEN 管理员配置本租户策略,THE SYSTEM SHALL 允许统一配置启停、输入检查、输出检查、内置词库、自定义词及阻断提示,支持内置词与自定义词同时生效,并将同一策略应用于该租户全部 DSH 模型。 +- **AC-07** — WHEN 普通用户或无目标租户管理权限的人员读写策略,THE SYSTEM SHALL 拒绝操作;WHEN 租户管理员管理策略,THE SYSTEM SHALL 限于本租户;WHEN 超级管理员指定目标租户,THE SYSTEM SHALL 验证该租户存在且处于其可管理范围。 +- **AC-08** — WHEN 保存策略,THE SYSTEM SHALL 按当前有效的平台管理身份与权限判定是否允许保存,不把 DSH profile 的身份定位结果或其缓存当作租户管理授权。 +- **AC-09** — IF 某方向启用检查但没有有效词,或词库、词条、阻断提示不满足已公布的校验要求,THEN THE SYSTEM SHALL 拒绝保存并指出原因;不得出现界面显示已启用但实际上没有检查规则的状态。 +- **AC-10** — WHEN 多个管理员基于同一旧版本先后保存策略,THE SYSTEM SHALL 明确拒绝冲突保存,避免静默覆盖已生效的更新。 +- **AC-11** — WHEN 策略保存成功,THE SYSTEM SHALL 使新请求在最多 5 秒内使用新策略,并向管理员说明该生效范围;正在处理的请求继续使用进入检查时的策略,包含后续的关闭或词库变更。 +- **AC-12** — WHERE 多个 Gateway 实例同时服务同一租户,THE SYSTEM SHALL 对相同策略及内置词库版本使用一致的词条和匹配语义;若无法取得有效策略或词库,不得将其视为策略关闭并透传。 + +### 2.3 profile 定位与结果复用 + +- **AC-13** — WHEN Gateway 选择租户策略,THE SYSTEM SHALL 以 backend 成功验证原始 DSH 凭据后返回的用户和租户为准,不采信客户端自报的租户;不同 backend 部署或不同完整凭据的身份复用结果不得互相串用。 +- **AC-14** — WHEN 复用已验证身份,THE SYSTEM SHALL 使其距最近成功验证最多 30 秒,且不得超过凭据有效期;访问或转存该结果不得延长其绝对有效期,失败的验证不得成为成功身份记录。身份复用结果不保存原始凭据,相关诊断日志不输出凭据。 +- **AC-15** — WHILE 同一有效凭据的身份结果仍有效且未发生失效或缓存故障,THE SYSTEM SHALL 供后续模型请求复用,并允许多个 Gateway 实例复用同一 backend 部署、同一完整凭据的成功验证结果;身份验证次数不得随输出分片或 Token 数量增长。 +- **AC-59** — WHEN 同一 Gateway 实例内,多个请求使用同一 backend 部署的相同完整凭据并发访问,且没有有效身份结果(包括首次访问和缓存过期),THE SYSTEM SHALL 将同一轮身份验证合并为一次 profile 调用,并向等待请求共享验证结果;单个等待请求取消不得取消其他请求仍依赖的验证,等待超限时按 AC-18 报告检查不可用,不自动重试模型请求。 +- **AC-16** — WHEN 模型请求转发 backend,THE SYSTEM SHALL 保留原始 DSH 凭据,由 backend 按当前身份、会话、租户、模型权限和额度重新执行原有准入;身份结果复用不得提供额外模型权限或绕过已失效的会话。 +- **AC-17** — WHEN backend 明确返回凭据、会话、用户或租户失效,THE SYSTEM SHALL 使对应身份复用结果失效,并保留该错误;模型权限不足和额度不足不得被误判为身份失效,模型请求不得因此自动重试。 +- **AC-18** — IF 必须取得身份或策略时发生超时、服务错误、异常响应或容量耗尽,THEN THE SYSTEM SHALL 明确终止本次请求并报告检查不可用,不默认为无效账号、策略关闭或检查通过。 +- **AC-19** — IF 身份结果复用设施不可用,THEN THE SYSTEM SHALL 仅使用仍在有效期内的已验证结果或在有界验证能力内重新验证;不得延长旧结果寿命、无限等待或无限重试,也不得保存或输出原始凭据到身份缓存及诊断日志。 + +### 2.4 文本与协议覆盖 + +本小节适用于已启用的检查方向;未开启的方向保持原有交付语义。 + +- **AC-20** — WHEN 某方向启用检查,THE SYSTEM SHALL 检查该方向完整上下文中的可读消息、可读推理、工具描述、工具参数和工具结果,按 JSON 解码后的字符串语义匹配;图片编码、加密推理、鉴权材料、协议标识和审计元数据不作为可读文本扫描。 +- **AC-21** — WHEN 文本属于同一消息的连续文本块,THE SYSTEM SHALL 保持其连续语义以识别跨块命中;不同消息、不同工具调用的独立文本不得相互拼接形成不存在的命中。匹配沿用既有 Gateway 字面子串及大小写语义。 +- **AC-22** — WHEN 命中词跨越网络分片、SSE 事件、UTF-8 字节、转义序列或最后剩余文本,THE SYSTEM SHALL 在完整命中词交付客户端前识别并阻断,不因传输切分而漏检。 +- **AC-23** — WHEN Responses 在增量、完成消息或最终完整结果中提供可读内容,THE SYSTEM SHALL 对所有可交付的内容执行相同检查,并保持原协议事件顺序;最终结果新增的内容不得绕过检查。 +- **AC-24** — WHERE 输出检查开启且输出包含工具调用,THE SYSTEM SHALL 在工具参数完整且检查通过后才允许客户端取得可执行参数;参数命中、解析失败或检查未完成时,不得执行该工具调用。 +- **AC-25** — IF 正文、事件或工具参数无法安全解析,THEN THE SYSTEM SHALL 返回可识别的错误并停止交付,不跳过检查透传,不将解析失败误记为敏感词命中。 +- **AC-26** — IF 请求、响应、词库或并发检查超过已公布的支持限制,THEN THE SYSTEM SHALL 拒绝或终止相应处理并说明原因,不以无限缓存、无限排队或未经检查的透传继续请求。 + +### 2.5 阻断、客户端与用量 + +- **AC-27** — WHEN 输入命中,THE SYSTEM SHALL 在 backend 模型请求发生前阻断,告知输入被拦截,且不产生模型调用次数、额度扣减或 Token 用量事实。 +- **AC-28** — WHEN 非流式输出交付客户端,THE SYSTEM SHALL 先完成该响应的全部适用检查;输出命中时不得交付被拦截的模型正文。 +- **AC-29** — WHEN 流式输出命中,THE SYSTEM SHALL 停止交付后续模型内容,且在约定检查范围内不交付完整命中词;此前已交付的安全前缀可以保留,不再发送表示该轮正常成功的结束结果。 +- **AC-30** — WHEN 输入或非流式输出命中,THE SYSTEM SHALL 返回可识别为内容阻断的 HTTP 403、content_blocked 结果及租户配置的阻断提示;WHEN 流式输出命中,THE SYSTEM SHALL 分别以 Chat 的 content_filter 终止语义和 Responses 的明确内容阻断错误通知客户端。Desktop 显示阻断原因并停止当前轮次,不自动重试。 +- **AC-31** — WHEN 检查本身不可用,THE SYSTEM SHALL 将其与内容命中及 backend 原有鉴权、额度、供应商错误区分:响应尚未开始时返回检查不可用的 HTTP 503,已开始时明确报错并终止;不得伪装为成功或自动重试模型调用。 +- **AC-32** — WHEN 输出命中且模型已开始执行,THE SYSTEM SHALL 保留 backend 能确认的实际执行和用量事实;阻断后仍产生的 Token 依原有规则结算,无法确认的用量保持未知。客户端停止交付不代表模型已停止、未消耗额度或已完成结算。 +- **AC-57** — WHEN 输出已判定命中且 backend 响应尚未结束,THE SYSTEM SHALL 立即停止向客户端交付模型内容,并最迟在判定命中后 30 秒停止后台续读;字节预算耗尽、无法取得续读容量或上游连接异常时提前结束,释放对应连接和缓冲。实际执行和用量仍按 AC-32 处理,续读结束不作为供应商已停止执行或停止计费的证据。 + +### 2.6 检查事实与审计回传 + +- **AC-60** — WHEN 租户敏感词策略关闭,THE SYSTEM SHALL 不为新请求生成内容安全事件;WHEN 审计采集同时关闭,THE SYSTEM SHALL 不新增审计请求摘要或输入、输出正文。模型鉴权、额度、计量和既有记录查询保持原有行为;关闭前已记录的安全事件仍按既有补报与保留规则处理。 +- **AC-61** — WHEN 用户查看审计采集状态,THE SYSTEM SHALL 常驻说明:敏感词检查开启时,即使审计采集关闭,仍记录请求摘要和检查结果,但不保存输入、输出正文;两项均关闭时不新增请求摘要,既有记录仍可查看。说明对可配置和只读用户均可见,无需展开采集设置。 +- **AC-62** — WHEN Gateway 转发 DSH Chat Completions 或 Responses 模型请求,THE SYSTEM SHALL 在转发前生成独立 UUID,覆盖客户端 X-Request-ID 并透传给 backend;backend 按通用 HTTP 约定复用唯一、规范且非空 UUID,缺失、非法或多值时自行生成编号,响应、错误、审计和计量使用同一编号。该编号不替代鉴权、租户隔离、额度或重复准入检查;backend 不新增 Gateway 配置或运行时依赖。 +- **AC-63** — WHEN 转发后未取得响应头或客户端提前断开,THE SYSTEM SHALL 保留已分配的模型请求编号用于安全事件和请求摘要关联;有会话的摘要进入会话记录,无会话的摘要进入未关联请求。仅凭分配编号或转发尝试不得认定模型已执行、模型结果成功或用量为零;历史缺少编号、可信身份不匹配或唯一约束冲突等事件保留数据库供debug排查,不再提供未关联检查界面。 +- **AC-64** — WHEN 用户查看审计界面,THE SYSTEM SHALL 不再提供独立“未关联检查”入口、页面或专属筛选,也不从前端发起独立未关联检查事件查询;保留会话记录、未关联请求及请求内的检查详情。未能关联请求摘要的检查事件继续按既有规则入库和保留,仅供debug排查,不删除历史事件,也不为隐藏界面而强制关联或丢弃事实。 +- **AC-65** — WHEN 用户查看审计,THE SYSTEM SHALL 仅提供会话记录与未关联请求两种主视图,移除独立请求明细页签及其跳转、返回入口;会话内请求列表、单条请求的信息展开、原文及检查详情继续可用。达到会话树展示深度上限时说明限制,不提供指向已移除视图的链接。 +- **AC-66** — WHEN 内容检查命中,THE SYSTEM SHALL 保存本次冻结词库中实际命中的词值快照,按输入、输出保存,并在经授权的请求检查详情中合并去重展示;词值合计最多20个、每词最多256个Unicode字符,不保存周边正文、不写入普通日志或客户端阻断提示。历史未保存词值的记录明确显示无法还原,不按当前词库推测;快照继续遵守既有租户权限、幂等、事件体积及保留规则。 + +- **AC-67** — WHEN 敏感词检查开启而审计采集关闭,THE SYSTEM SHALL 在请求摘要中保存可取得的模型名称,并为同租户、同用户已保存的检查摘要同步会话标题;输入拦截与检查通过均适用,不新增输入、输出正文、正文块或原文manifest。标题先于摘要到达时允许有界重试,标题自身不得创建请求摘要或跨用户、跨租户绑定。仅有保留检查摘要的标题补齐不重新采集历史正文;模型已删除或元数据读取失败时保留模型编号和检查结果,不编造名称或丢弃事件。 +- **AC-68** — WHEN 用户查看审计,THE SYSTEM SHALL 移除配置审计旁的“刷新状态”按钮以及“范围与查询说明”入口和说明面板;会话及子会话统计区域去掉“统计版本”,已就绪时保留统计状态、更新时间和命中数量,未就绪按AC-73隐藏;高级筛选全部保留,包括模型名称、审计状态、是否命中、命中方向、检查结果、请求编号和会话编号,既有用户、时间、查询、重置及分页语义不变。检查详情仅显示已保存的命中词及检查时间;命中词按文本渲染并去重,旧记录缺失明确提示无法还原,异常或未知时不宣称检查通过。 +- **AC-69** — WHEN 请求汇总、输入和输出检查均为通过且没有任何已知命中标记,THE SYSTEM SHALL 隐藏检查详情入口且不查询检查明细;已展开记录更新为全部通过时卸载明细并取消未完成请求。历史命中、异常、未检查、未上报等情况仍保留详情入口,不隐藏仍可用于定位的已知事实。 +- **AC-70** — WHEN 用户配置敏感词检查,THE SYSTEM SHALL 按AC-74保留输入/输出方向、内置/自定义词库、自定义词值、阻断提示及原子保存,移除配置版本、词库标识、词数及字节限制等技术展示和重复说明;保留简短录入提示、关闭状态及校验反馈,保存成功时说明5秒内对新请求生效。正常状态不展示刷新,读取不可用、加载失败或配置冲突时保留刷新恢复;冲突刷新失败时草稿仍可见且同一加载失败不重复提示,成功重读后以最新版本显式保存。权限、校验、CAS、租户隔离及后端诊断字段不变。 + +- **AC-71** — WHEN 同一请求收到多个检查进展事件(包括DECISION、DRAIN和分页返回),THE SYSTEM SHALL 将已加载事件汇总为一个检查详情区域,合并输入和输出的已保存命中词并去重,每个词只展示一次;检查时间取已加载命中事件的最早occurred_at,无命中时取最早检查时间。后续快照不得清除此前已加载的命中词,旧命中快照缺词值时仍提示无法还原;分页、刷新重试和请求/租户隔离保持原语义,内部事件继续完整保存用于排查。 +- **AC-72** — WHEN 用户展开单条请求信息,THE SYSTEM SHALL 提供与检查详情同层级的“请求详情”标题,集中展示模型结果、接口响应、用量结算和正文留存,并保留安全归类的失败原因及异常说明;移除请求编号、会话编号、记录来源、重复模型名称、单独模型准入状态及执行开始时间等技术或重复展示。模型结果仅显示一次,缺少结果的外部摘要明确区分未调用模型与未观测到模型执行结果,不将未知补为成功或零;模型名称和请求时间仍在卡片头部,原文权限、按需读取和筛选语义不变。 +- **AC-73** — WHEN 会话关系、数量、异常或敏感词命中统计尚未就绪,THE SYSTEM SHALL 隐藏对应未就绪统计的数字、准备/更新中状态及过期统计时间,不隐藏已保存的会话或请求。统计就绪后按既有口径显示数量、状态和更新时间;真实读取失败、可重试查询错误及扫描上限仍明确提示,不将未就绪误报为零或正常。不新增表或统计模型。 +- **AC-74** — WHEN 管理员配置敏感词检查,THE SYSTEM SHALL 仅提供检查输入、检查输出两个检查开关,界面移除独立启用开关;提交时enabled由两个方向的逻辑或派生,两项均关闭即关闭检查。仍显式一次保存、原子提交词库与提示,保留权限、CAS和校验;不实现旧三开关组合的迁移或兼容分支,不改Gateway及模型计量协议。该规则替代AC-70中独立启用控件的展示。 +- **AC-75** — WHEN 用户展开会话内请求,THE SYSTEM SHALL 排除标题生成和上下文压缩请求,并移除请求卡片上的用途及普通/子会话来源标签;模型名称、请求时间、用户、状态、原文及敏感词详情仍保留。排除仅应用于会话内请求查询且在keyset分页前执行,保留未声明用途的记录;不更改外层会话分页、未关联请求列表、既有全部已收录请求的会话统计口径、数据库事实或用量。原始事件及两类辅助调用仍可供后台排查。 + + + +- **AC-33** — WHEN 已接入检查的请求形成检查终态,THE SYSTEM SHALL 记录并向 backend 审计提供输入、输出各阶段的真实结果;输入阻断或 backend 准入失败而没有模型输出时,输出检查标为不适用,不编造通过。 +- **AC-34** — WHEN 展示检查结果,THE SYSTEM SHALL 分别显示输入、输出的阶段结果与请求汇总结果;阶段结果区分通过、命中、检查失败、已关闭、不适用和未收到完整结果,请求汇总按下表由上到下第一个满足的条件确定。没有事件或没有已知命中均不得被解释为已通过,后到结果不得清除已确认的命中。 + +| 优先序 | 请求汇总条件 | 请求汇总结果 | +|---|---|---| +| 1 | 任一阶段已经确认命中,包含此前已确认的命中 | 命中 | +| 2 | 没有命中,但任一阶段检查失败 | 检查失败 | +| 3 | 没有上报,或任一阶段缺少可确认的终态 | 未收到完整结果 | +| 4 | 输入、输出均有明确关闭事实 | 已关闭 | +| 5 | 至少一个阶段明确通过,其他阶段均为通过、关闭或不适用 | 通过 | +| 6 | 全部阶段终态已明确,没有执行任何适用检查,且不满足两阶段均关闭 | 不适用 | + +只有输入、输出均有明确关闭事实才能汇总为“已关闭”。例如,输入关闭、输出通过时汇总为通过;输入关闭、输出检查失败时汇总为检查失败;输入关闭、backend 准入失败导致输出不适用时汇总为不适用。该汇总仅描述内容检查,不替代模型执行、交付和用量状态。 + +- **AC-35** — WHERE 模型内容审计关闭或仅保存元数据,THE SYSTEM SHALL 仍能保存最小内容安全事实;安全回传与其诊断日志不得额外保存请求正文、完整回复、匹配原文或凭据,既有模型原文审计仍受原有规则约束。 +- **AC-36** — WHEN 输入命中且未向 backend 发起模型请求,THE SYSTEM SHALL 生成可查询的独立安全审计条目,明确显示“输入命中,未调用模型”;模型结果、交付、用量及模型审计完整性为不适用,不伪造运行中、执行失败、正常完成或零消耗的模型记录。 +- **AC-37** — WHEN 输出检查结果包含可信的本次 backend 模型请求标识,THE SYSTEM SHALL 关联该模型审计记录,不使用 profile 请求标识关联;无法确定模型请求标识时保留未关联事实,不猜测归属。 +- **AC-38** — WHEN 外部安全事实与模型审计先后或并发到达,THE SYSTEM SHALL 将可确认属于同一请求的事实呈现为一条审计记录,补齐可以确认的事实并保留命中结果;身份以毕昇成功验证结果为准,接受可信身份变化,不新增跨事件身份冻结或因该变化拒收事实。关联沿用模型记录自身的归属和既有权限边界,无法确认关联时保留未关联事实,不重写既有模型执行或用量事实,也不得因此阻塞无关记录的正常查询。尚无模型执行事实的输出安全条目保持未观测状态,不因收到外部事实就认定模型已执行或未执行。 +- **AC-39** — WHEN backend 确认接收外部安全事实成功,THE SYSTEM SHALL 保证该事件及对应审计摘要在相应保留范围内,接收端重启后仍可查询;仅进入易失队列不得作为成功确认。 +- **AC-58** — WHEN Gateway 已成功记录检查事实但尚未收到 backend 成功确认,THE SYSTEM SHALL 在允许补报的保留范围内,保证该事实不因 Gateway 进程或实例重启而丢失,并在恢复后继续投递;重复投递不得重复计数,未确认的事件不得标记为交付完成。超过补报保留范围或收到明确拒绝时不得视为交付成功,应按 AC-43 暴露缺口,并遵守 AC-51 的删除与保留边界。 +- **AC-40** — WHEN 检查发现命中,THE SYSTEM SHALL 立即形成可回传的命中事实,不等待模型后续读取或用量结算完成才报告命中;结算或后续读取状态变化可以补充,不能推迟或撤销命中事实。 +- **AC-41** — WHEN 事件重复、乱序或后到通过,THE SYSTEM SHALL 保留已经确认的输入、输出命中,按请求去重计算命中次数;相同事件身份对应不同事实时明确报告冲突,不按普通重复成功处理,原模型审计后到也不得重置安全结果。 +- **AC-42** — WHEN 来源提交外部安全事实,THE SYSTEM SHALL 验证来源可信,按毕昇成功验证返回的租户和用户身份及本次模型请求信息保存事实,接受可信身份变化;拒绝伪造、篡改、超出服务来源允许租户范围及用浏览器或 DSH 用户凭据冒充的上报,但不因真实事件发生后的用户退出、凭据失效或租户归属变更拒绝历史补报。 +- **AC-43** — IF 安全事实记录或回传失败,THEN THE SYSTEM SHALL 继续阻断已命中的内容,显式暴露记录失败、积压或缺口,不宣称已完整回传;恢复后可补报仍保留的事实。审计写入故障不得替代 backend 的模型或用量事实。 + +### 2.7 审计展示、筛选与汇总 + +- **AC-44** — WHEN 有权限的人员查看请求列表,THE SYSTEM SHALL 显示是否命中、输入或输出命中及检查状态,并允许“模型执行成功”与“输出命中,已拦截”同时成立;请求汇总为未收到完整结果时显示为尚未收到完整检查结果,不将未知结果显示为安全或已通过。 +- **AC-45** — WHEN 有权限的人员筛选审计,THE SYSTEM SHALL 支持按是否命中、命中方向、检查结果,并与现有用户、模型和时间条件组合查询;请求、详情、会话和目录中的结果均受既有审计权限及租户范围约束。 +- **AC-46** — WHEN 查看检查详情,THE SYSTEM SHALL 仅展示受限的命中词和检查时间;来源、规则、版本和关联编号等诊断字段继续按既有权限保存在后端,不在检查详情展示。请求开始和模型实际执行开始时间继续由原请求信息区分。仅有外部安全事实的条目不提供不存在的模型原文入口,已有原文继续按既有权限访问。 +- **AC-47** — WHEN 查看会话、轮次或树形目录,THE SYSTEM SHALL 汇总其自身及子级的命中情况,按命中请求去重,独立于模型审计异常数;安全条目可以计入审计条目数,但不得据此增加模型调用次数或用量。 +- **AC-48** — WHEN 命中事实变化而模型执行状态未变,THE SYSTEM SHALL 仍更新相关会话及目录的命中汇总,并提供汇总更新时间;汇总尚未就绪或组合查询未完成扫描时明确提示,不返回貌似完整的无命中结果。仅有外部安全事实的条目不得因缺少模型执行证据被误报为停滞调用,或导致全租户汇总永久无法就绪。 +- **AC-49** — WHEN 继续翻页或刷新命中结果,THE SYSTEM SHALL 保持已有请求的稳定排序位置,不因模型审计晚于安全事实到达而移动该请求;拒绝将其他租户或用户显式选择的另一组筛选条件的续页凭据用于当前查询。分页沿用既有行为,不要求默认相对时间范围跨页固定。补报导致旧请求新满足条件时,用户可刷新获得新结果,不承诺跨页为不可变快照。 +- **AC-50** — WHEN 打开在线审计筛选,THE SYSTEM SHALL 默认查询最近 7 天,并允许每次查询最多 31 天的时间跨度;超过跨度时明确提示分段查询,不静默截断或尝试无限历史查询。单次请求的多条安全事件不得增加列表行数或命中请求数。 +- **AC-51** — WHEN 安全事件明细因保留期清理,THE SYSTEM SHALL 保留仍在有效保留期内的主审计命中结论,明确区分明细已清理与没有发生检查;补报不得复活已按保留规则删除的审计记录,也不得承诺在事实已不可回放后完整重建明细。 + +### 2.8 历史、兼容与启停 + +- **AC-52** — WHEN 升级已有审计记录,THE SYSTEM SHALL 保留历史用量、未知结算、模型审计及原有证据,将无检查事实的历史记录视为未收到结果,不批量标记为检查通过。 +- **AC-53** — WHERE 使用项目支持的 MySQL 或 DM8 部署,THE SYSTEM SHALL 提供一致的租户策略适用、审计关联、幂等、筛选、汇总和保留期业务结果。 +- **AC-54** — WHEN 使用原有 Gateway 应用或非 DSH 的敏感词功能,THE SYSTEM SHALL 保持其原有路由、策略和响应语义,不将 DSH 租户策略错误应用到其他场景。 +- **AC-55** — WHEN 停用租户检查、部署级检查或回滚本功能,THE SYSTEM SHALL 停止对新请求启用相应检查,并保留已有审计事实、用量和待补报事实;已产生事实的补报继续进行,在途请求遵守进入检查时的策略。 +- **AC-56** — WHEN 平台或 Desktop 展示本功能新增的配置、阻断、检查不可用、未调用模型及未收到结果等文案,THE SYSTEM SHALL 在简体中文、英文和日文下提供对应内容,且各语言对同一检查事实的含义一致。 + +## 3. 边界情况 + +本节只说明期望行为;对应 AC 是验收依据。 + +| 场景 | 期望行为 | 验收依据 | +|---|---|---| +| 请求携带伪造租户,或管理员试图修改其他租户策略 | 以可信身份选策略,拒绝越权管理及越租户查询 | AC-07、AC-08、AC-13、AC-45 | +| 同一凭据缓存期间用户退出或租户归属改变 | 转发后的模型准入检查最新事实;输入已被提前阻断时,可以先显示内容阻断,不承诺先显示注销错误 | AC-14、AC-16、AC-17、AC-27 | +| profile 或策略不可用,与策略明确关闭 | 分别呈现检查不可用及不启用检查,不把查询失败当作关闭 | AC-02、AC-12、AC-18、AC-31 | +| 身份缓存中断或凭据刷新 | 不串用身份,不续命过期结果,验证量不随响应分片增长 | AC-13、AC-14、AC-15、AC-19 | +| 同一实例内首次访问或缓存过期时并发使用相同凭据 | 同一轮仅一次 profile 验证,共享结果;取消单个等待请求不影响其他等待者,超限明确报错 | AC-13、AC-18、AC-59 | +| 空词库、内置词不一致、旧版本覆盖新策略 | 拒绝无效或冲突配置,不以无规则状态启用检查 | AC-09、AC-10、AC-12 | +| 流中切换词库或关闭策略 | 当前请求保持原规则,新请求在约定窗口内使用新规则 | AC-11、AC-55 | +| 命中词跨字节、跨文本块、跨事件,或只在最终快照出现 | 在完整命中词交付前识别;不把不同消息和工具拼成命中 | AC-21、AC-22、AC-23 | +| 工具参数转义、分片或无法解析 | 检查完整可执行参数;未通过不执行;解析失败与命中区别显示 | AC-20、AC-24、AC-25 | +| 输入命中且模型审计关闭 | 仍有最小安全条目,显示未调用模型和不适用,不制造模型调用、用量或原文 | AC-27、AC-35、AC-36、AC-46 | +| 输出命中后客户端停止,backend 仍在执行或结算 | 内容交付停止,命中先上报,实际用量后补;不能证明的状态保持未知 | AC-29、AC-32、AC-40、AC-57 | +| 输出命中后续读达到 30 秒、字节预算耗尽、无可用续读容量或上游异常 | 最迟在命中后 30 秒结束续读,其他限制先触发则提前结束并释放资源;不据此认定模型已停止或用量为零 | AC-32、AC-57 | +| 外部安全条目先到、原模型审计后到 | 合并为同一条目,保留安全结果,不制造正文入口或无关索引故障 | AC-36、AC-38、AC-41、AC-46、AC-48 | +| 已分配编号但未取得响应头,或历史无编号、可信身份结果发生变化 | 新转发尝试按预分配编号建摘要,模型执行及用量保持未知;无法安全关联的历史或冲突事件保留未关联事件,接受可信身份变化 | AC-37、AC-38、AC-42、AC-62、AC-63 | +| 重复、乱序、重启补报、关闭功能后仍有积压 | 不重复计数,不撤销命中,继续补报可保留的事实 | AC-39、AC-41、AC-43、AC-55、AC-58 | +| Gateway 已记录但 backend 不可用期间 Gateway 重启,或 backend 确认在途中丢失 | 未确认事实在补报保留范围内恢复后续报;已提交事件重复投递不重复计数,不能把未确认当成交付完成 | AC-39、AC-41、AC-58 | +| 没有事件或部分阶段缺少结果 | 按优先序确定汇总;没有命中或检查失败事实时显示未收到完整结果,不把未知当作通过或关闭 | AC-33、AC-34、AC-44 | +| 输入关闭而输出通过、检查失败或因 backend 准入失败而不适用 | 分别汇总为通过、检查失败或不适用;只有输入、输出都明确关闭才汇总为已关闭 | AC-33、AC-34 | +| 使用默认最近 7 天连续翻页,或显式更改过滤条件 | 沿用既有分页,不固定默认相对时间窗口;更改显式过滤条件时清游标 | AC-49、AC-50 | +| 请求量和明细量持续增长、组合筛选触及支持范围 | 有界分页,明确提示超范围或未扫描完成,不伪装为完整空结果 | AC-26、AC-48、AC-50 | +| 事件明细已清理或审计记录已删除 | 不重置仍有效的命中摘要,不因迟到补报重新创建已删除记录 | AC-51 | + +## 4. 设计与实现(指针,不复制) + +| 需要了解的内容 | 后续文档或现有参考 | +|---|---| +| Gateway、backend、平台、Desktop 的具体改动与跨仓库契约 | spec 确认后编写本目录 design.md;已确认方案 §1、§9–§11 为设计输入 | +| profile 的现有开销、缓存分层、过期、并发合并和故障预算 | 已确认方案 §4;具体参数和验证结论进入 design.md | +| 租户策略存储、词库版本、匹配器及流式协议处理 | 已确认方案 §5–§6;正式设计进入 design.md | +| 回传鉴权、投递、事件明细独立保存与审计摘要关联 | 已确认方案 §7–§8;正式契约进入 design.md | +| 数据量增长下的索引、查询成本、投影、保留期和双数据库验证 | 已确认方案 §8、§12;性能指标与压测计划进入 design.md | +| 任务拆分、改动文件、测试及发布顺序 | design 评审后编写本目录 tasks.md | + +已确认方案中的调优初值和容量示例不视为实测结论。在线事件明细“先按 30 天评估”仍是设计输入,不在本文新增独立的强制删除期限;正式保留期须与既有审计保留规则对齐,不能通过实现细节缩短现有证据保留承诺。 + +## 相关文档 + +- [已确认的完整方案](../../../docs/architecture/proposals/2026-10-22-dsh-gateway-content-safety.md) +- [v3.0.0 发布契约](../release-contract.md) +- [F110 DSH 本地登录与部门额度](../110-dsh-local-access/spec.md) +- [SDD 流程与文档职责](../../../docs/SDD-Guide.md) +- [架构约束](../../../docs/constitution.md) +- [Gateway 架构](../../../docs/architecture/11-gateway.md) +- [用户、菜单与资源权限架构](../../../docs/architecture/10-permission-rbac.md) diff --git a/features/v3.0.0/111-dsh-sensitive-word-control/tasks-review.md b/features/v3.0.0/111-dsh-sensitive-word-control/tasks-review.md new file mode 100644 index 0000000000..585669ee1b --- /dev/null +++ b/features/v3.0.0/111-dsh-sensitive-word-control/tasks-review.md @@ -0,0 +1,65 @@ +LGTM + +# F111 tasks 静态评审记录 + +- 日期:2026-10-23 +- 对象:[tasks.md](./tasks.md),对照已确认 [spec.md](./spec.md)、[design.md](./design.md) 与版本发布契约。 +- 方法:sdd-review tasks 21 项清单、当前源码/依赖声明核对、任务图与AC追溯检查;本次为编写者自审,没有独立agent。 +- 阶段:用户已确认 design;仅编写并评审任务文档,业务实现和测试执行均未开始。 + +## 1. 首轮自审修订 + +| 问题 | 等级 | 修订与复核 | +|---|---|---| +| Platform当前仅直接声明react-query v3,照设计新增v4 hook会缺依赖/Provider | high | 新增基础设施T015,在T103局部接v4 Provider;对齐design §4.10,不迁移全站 | +| 一个任务囊括全部E2E或三类性能验证,难以单会话交付和独立验收 | medium | E2E拆为T121、T122、T123、T124及汇总;容量拆为双库查询、profile负载、流式资源及汇总 | +| 配对测试的正文引用使用生成器内部键名,不能直接定位任务 | medium | 全部替换成正式T编号,依赖和配对验证可直接检索 | + +修订后复核没有剩余 high/medium/low ISSUE。以上是任务计划质量结论,不代表实现或运行结果。 + +## 2. 21 项清单 + +| # | 检查项 | 结果 | 依据 | +|---|---|---|---| +| 1 | AC 追溯完整性 | PASS | 59/59;不依赖末端全量覆盖声明,仅前置/专项测试已覆盖全部AC,详tasks §5 | +| 2 | 测试 AC 标注完整性 | PASS | 54个测试与7个验收任务均有逐项覆盖AC字段 | +| 3 | 后端 Test-First | PASS | T017→T018 至 T053→T054,全部配对前驱在更早Wave;基础设施声明豁免 | +| 4 | 依赖关系正确性 | PASS | 132个唯一ID,所有依赖存在且指向更早Wave,DAG无环 | +| 5 | 原子化 | PASS | 每项人工目标文件1–2个;大E2E及负载按独立主题拆分,长运行另留可恢复会话 | +| 6 | 任务自包含 | PASS | 每项列仓库、精确文件、逻辑/测试上下文、验证和依赖;配对引用使用真实T编号 | +| 7 | 粒度与前后端隔离 | PASS | 单项不跨前后端生产实现;跨仓库E2E明确是测试/验收,不混入功能实现 | +| 8 | 执行顺序 | PASS | 基础设施优先;API/数据、代理与投递、UI/Desktop、真实双库与E2E、容量、回退及审查按依赖组织 | +| 9 | 无重复覆盖式修改 | PASS | 仅4个生产文件由不同实现任务复用,各自串行增量:会话投影/查询、身份cache/miss合并、Outbox投递/遥测、Panel接线/未关联入口 | +| 10 | 设计对象覆盖 | PASS | 策略表/API、事件表/主摘要、共享签名、profile缓存、双协议、专用代理、Outbox、请求/事件/会话/树查询、worker、保留及三语都有任务 | +| 11 | 任务接口清楚 | PASS | 记录event/wire DTO、Store CAS、fetch/resolveIdentity、ingest/Receipt、三个HTTP新入口、ClientCode和前驱方法产出 | +| 12 | 无越界工程 | PASS | 不加身份绑定表/冻结窗口/key_id密钥体系;v4直接依赖和局部Provider是已确认约定的兼容接线,不全站迁移 | +| 13 | AC 编号完整格式 | PASS | 全量AC-01至AC-59逐项引用,无范围缩写或省略前缀 | +| 14 | 测试任务纯净性 | PASS | 测试任务只改测试/验收记录,不写业务逻辑;所有实现任务用承接AC字段区分 | +| 15 | 无推迟本期需求的 TODO | PASS | 未出现TODO/FIXME/HACK或无理由测试降级;失败任务不勾选,不以计划当交付 | +| 16 | 数据库回退 | PASS | T001–T008及Gateway两份DDL说明保留事实回退/空库演练/有数据收缩拒绝;回填清理另走dry-run/apply | +| 17 | 跨特性与共享文件副作用 | PASS | release-contract不变量保留;共享HMAC旧SSO、middleware准入、模型writer/用量、Gateway旧应用和Desktop加载均有回归安排 | +| 18 | 前端分区 | PASS | Platform和外部Desktop分开;Web Client无业务改动,仅共同错误文案的自动生成输出 | +| 19 | Worker 租户传递 | PASS | T047–T050明写headers→ContextVar、args一致、严格scope、finally恢复、SQL线程重建以及停用租户dirty枚举 | +| 20 | 基础设施优先 | PASS | 15项基础设施全在Wave1,基础校验后才进入业务;没有在中段临时补ORM/错误码/conftest | +| 21 | 测试降级理由 | N/A | 没有测试降级项;UI手工验收与真实DM8中央回归均保留,不作为省略测试理由 | + +## 3. 可重复的静态核查结果 + +- 任务132项:基础设施15、测试54、实现56、验收7;43个依赖Wave,完成0项。 +- 全部任务ID唯一;依赖存在、无环、每条依赖都在更早Wave;所有后端业务实现有先行测试。 +- 59条AC在专项测试中已全部出现;另外有跨仓库E2E、页面验收及最终完整性核对。未用最后一项全量标注掩盖专项测试缺失。 +- 单任务1–2个人工维护文件,四个共享生产文件只有串行增量修改;绝不对其他会话做整仓stash/reset/restore。 +- spec的59条AC正文与编号本轮保持不变;仅更新阶段元数据。design更新确认状态并补明已有v4约定所需的局部接线,没有新增对外接口。 +- 所有任务未勾选;新代码、迁移、测试用例和未来验收报告均只列为拟交付,未实际创建。 + +## 4. 验证边界与后续执行要求 + +本轮未运行pytest、Maven、Vitest、数据库迁移、真实模型、性能负载或页面E2E,不能据本报告声称功能完成。中间件测试在隔离集成环境,DM8在Linux中央回归环境;没有环境则保持“未执行”,不以mock/SQLite/HTTP200替代。 + +前端T130按源文件生成两端api_errors衍生产物,生成输出可能超过人工任务文件数限制,必须在交付diff明确列出且禁止手改;这不是新增Web Client业务功能。部署手册、特性审查与提交/推送/生产操作仍遵循届时用户授权。本轮停在tasks文档交付。 + +## 5. 分支准备记录(2026-10-23) + +按用户后续要求,Gateway 与 Desktop 分别获取并核对远端 main,在独立 worktree 创建同名分支 feat/bisheng-work-dsh-guardrails;具体工作目录及完整提交号见 tasks §2。新分支 HEAD 与所列 main 提交一致,工作区干净,未提交或推送。Gateway 原工作区仍在 feat/remove-dsh-seat,Desktop 原工作区仍在 V1.1.0,已有未跟踪内容和其他 worktree 保留。 + +Gateway 新基线与原核查提交的文件树相同。Desktop 新基线相对原核查版本有变化,已确认现有 Chat content_filter 与 Responses UPSTREAM_ERROR 处理仍在;本次只完成分支准备和任务元数据更新,未进行完整设计重审或运行测试。后续任务按新基线重新核对调用、插件契约和测试,不能将原静态评审当作新 main 的运行验证。任务仍为完成 0 / 132,未修改业务代码。 diff --git a/features/v3.0.0/111-dsh-sensitive-word-control/tasks.md b/features/v3.0.0/111-dsh-sensitive-word-control/tasks.md new file mode 100644 index 0000000000..d3dedae29a --- /dev/null +++ b/features/v3.0.0/111-dsh-sensitive-word-control/tasks.md @@ -0,0 +1,3320 @@ +# Tasks: DSH 模型转发敏感词控制 + +- **特性**:F111 / v3.0.0 +- **关联**:[spec.md](./spec.md) · [design.md](./design.md) · [任务评审](./tasks-review.md) +- **日期**:2026-10-23 +- **工作分支**:feat/bisheng-work-dsh-guardrails +- **当前范围**:用户已授权持续按依赖实施至 T132,逐项测试、自审后更新状态;允许多 agent 并行。核心实现与配对自审已推进至 Wave 32,T119/T120的MySQL子项已独立通过;完整E2E、容量与演练仍待环境/前驱,DM8实测按用户决定保留待验收,用户已追加授权合入最新 feat/bisheng-work-120-main 并推送,经 Drone 构建后仅部署 109 DSH13001,再启动客户端联调;其他环境禁止变更。 + +本轮追加限制(2026-10-27):统一请求编号在两仓 `codex/dsh-unified-request-id` 上实现;不提交、不推送、不部署,不沿用此前其他改动的发布授权。B 基线 e59b249a3,G 基线 c999f9bf。 + +## 1. 状态 + +| 步骤 | 状态 | 备注 | +|---|---|---| +| spec.md | ✅ 已评审 | 用户已确认,后续取舍已同步 | +| design.md | ✅ 已评审 | 用户于本轮明确“设计可以”,包含现有共享 HMAC、可信身份变化和既有分页取舍 | +| tasks.md | ✅ 已拆解 | 经 sdd-review tasks 静态评审 LGTM;不代表任务已执行 | +| 实现 | 🔄 进行中 | 264 / 279;本轮 Wave54 未就绪统计、方向开关及会话内请求展示完成并自审,旧15项完整现场/双库/容量/最终门禁仍待验 | + +## 2. 执行约定与仓库范围 + +| 标识 | 执行工作树 | 当前执行基线 | +|---|---|---| +| B | /Users/zhangguoqing/.codex/worktrees/f111-work120-delivery/bisheng | work120集成3cc9de9298;原feature工作树保留 | +| G | /Users/zhangguoqing/works/bisheng-gateway.worktrees/dsh-guardrails | main2995df6f起步,当前已推64f602be | +| D | /Users/zhangguoqing/works/bisheng-work.worktrees/dsh-guardrails | main01e09d0起步,当前已推3e8486c | + +每项文件路径相对该项执行工作树,目标文件逐一列出;不存在的路径是拟新增文件,不声称已实现。原三个仓库的功能分支均为 feat/bisheng-work-dsh-guardrails;B交付在独立codex/f111-work120-delivery正常合入并推送work120目标,G同步同名work120及唯一DSH标签,D仍推原feature分支;G/D 于 2026-10-23 获取 main 后创建并核对远端 SHA,未复用原业务分支。原工作区和已有 worktree 均保留,禁止仓库级 stash/reset/restore/clean。 + +原设计评审核查基线保留在 design §4.1,作为历史依据。Gateway 新 main 与原核查提交文件树一致;Desktop 新 main 有变化,Chat 的 content_filter 分支与 Responses 的 UPSTREAM_ERROR 分支仍存在,但这不替代完整兼容性复核。各实现任务开始前须对新基线重读所涉及模块、目录规则和测试;该基线调整没有完成或勾选任何实现任务。 + +任务目标为一次会话约 30 分钟的人工编写/操作、最多 2 个人工维护文件;真实数据库造数和负载运行可使用可恢复的长运行会话,单列运行耗时与证据,不把目标工时当运行SLA。只修改该任务内声明的方法/区域;共享文件后续任务均为增量接入,不覆盖前驱逻辑。后端及Gateway、Desktop核心协议采用前置测试;Platform界面采用本项手工验证和后续组件测试。红测允许缺少拟新增类型/方法,不能把待实现函数写入测试充当生产实现。基础设施声明/DDL/错误码/fixture 不要求配对,全部先于业务任务。 + +类别为基础设施、后端 Domain、后端 API、前端 Platform、Worker,另将外部 Gateway/Desktop 明确标出。**前端 Client(src/frontend/client)本期无业务实现任务**,共享错误文案生成物按构建同步验证;Desktop 不是这个 SPA。Platform 按当前用户根 AGENTS 采用 @tanstack/react-query v4,仅补直接依赖和本特性局部Provider,不全站迁移;不沿旧模板新建 DAO、用 Zustand 替代 Client Recoil 或引入 react-query v3。 + +已确认边界:复用现有 HMAC 密钥和签名头配置,不新增独立密钥/key_id;profile 是 backend 校验 DSH Token、当前用户/租户和会话后的 user/tenant 返回,缓存最长 30 秒;接受毕昇可信身份变化,不增加跨事件身份冻结表;分页沿既有方式,默认 7 天/最多 31 天,不冻结默认相对窗口。backend 模型鉴权、额度和计量不感知 Gateway。 + +环境验收按方言和链路分别记账:T119 的 MySQL 子闸门经独立实际验证通过后,可推进 T120 的 MySQL SQL 验证与不依赖 DM8 的准备;缺少 Linux DM8 不视为产品测试失败,也不视为通过,T119/T120 整体仍保持未完成。实际失败仍阻断依赖,完整 E2E、容量和交付验收不得以组件或单方言结果代替。此约定落实用户“DM8 实测保留待验收”和“环境阻断保留未完成、继续独立工作”,不改变功能或验收标准。 + +测试仅使用合成账号、Token、密钥和可控上游。普通单元与 API 测试可本地执行,中间件/真实双库在隔离集成或中央回归环境运行;DM8 按 constitution 的 Linux 中央回归要求,不伪称每 PR 已有自动门禁。UI 手工步骤是常规界面验收,不是高成本测试降级;本计划没有测试降级项。构建生成物与运行报告须按真实产出列入交付差异,禁止手改生成资源或将未运行检查标通过。 + +## 3. Wave 总览 + +Wave 由依赖图最长路径计算;实施中发现的必要兼容补齐按已执行状态插入安全的后续 Wave,不倒退已完成任务。Wave 用于展示依赖层级;T016基础一致性闸门之后,独立链路可在前驱全部完成时提前解锁,不必等待其他无依赖链的环境验收。并行任务不得共享目标文件;文件相交即使无数据依赖也串行。当前采用主 agent 加 3 个子 agent;每个文件仅一名写入负责人,共享文档和任务状态由主 agent 统一更新。 + +| Wave | 任务 | 本 Wave 完成条件 | +|---|---|---| +| 1 | T001、T002、T003、T004、T005、T006、T007、T008、T009、T010、T011、T012、T013、T014、T015、T133、T134 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 2 | T016 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 3 | T017、T019、T055、T061、T065、T077、T083、T099、T100、T101、T111 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 4 | T018、T020、T056、T062、T066、T078、T084、T112 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 5 | T021、T057、T063 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 6 | T022、T058、T064 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 7 | T023、T059、T067、T069、T135 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 8 | T024、T060、T068、T070 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 9 | T025、T071、T073、T075 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 10 | T026、T072、T074、T076 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 11 | T027、T041、T085、T113、T115 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 12 | T028、T042、T086、T114、T116 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 13 | T029、T037、T043、T087、T136 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 14 | T030、T038、T044、T088、T137 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 15 | T031、T089、T117 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 16 | T032、T090 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 17 | T033、T091、T118、T140、T141 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 18 | T034、T092、T142、T143 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 19 | T035、T093 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 20 | T036、T094、T144 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 21 | T039、T079、T095、T145、T146 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 22 | T148、T149、T150、T151、T040、T080、T096、T147 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 23 | T045、T081、T102 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 24 | T046、T152、T153、T156、T082、T154、T155、T103、T105、T106 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 25 | T047、T097、T165、T104、T107 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 26 | T166、T167、T048、T098、T157、T158、T159、T160、T161、T162、T163、T164 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 27 | T049、T138 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 28 | T050、T139 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 29 | T051、T108 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 30 | T052、T109、T170 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 31 | T053、T110、T168、T171、T172、T174 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 32 | T054、T169、T173、T175 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 33 | T119 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 34 | T120 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 35 | T121 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 36 | T122 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 37 | T123 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 38 | T124 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 39 | T125 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 40 | T126、T127、T128、T130 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 41 | T129 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 42 | T131 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 43 | T132 | 所列任务的验证完成并做 /task-review;失败不越过依赖 | +| 44 | T206、T207、T208、T209、T210、T211 | 采集开关边界与常驻说明的配对验证和自审 | +| 45 | T212、T213、T214、T215、T216、T217、T218 | 统一编号本地回归、跨仓回环 HTTP 与任务自审;不代表109或双库验收 | +| 46 | T219、T220、T221、T222、T223、T224 | 未关联检查界面移除、配对回归、质量检查及任务自审 | +| 47 | T225–T243 | 请求明细及跳转移除、命中词冻结快照/详情、真实MySQL与本地回归、自审;DM8及部署验收仍待执行 | + +## 4. 任务清单 + +### Wave 1 + +- [x] **T001 安全事件 ORM 与租户发现** + + **类型**:基础设施 · 基础设施;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/bisheng/dsh/domain/models/content_safety_event.py`(拟新增);`src/backend/bisheng/core/database/tenant_filter.py`(既有,显式登记事件模型)。 + + **逻辑与上下文**:新增 dsh_content_safety_event,采用现有租户基类、BigInteger、JsonType;唯一 source_id/event_id 和 source_id/gateway_request_id/event_version,包含两个请求编号、审计关联、阶段快照、有限命中摘要、原始与接收时间。增加详情、未关联、请求事件及清理索引;不建身份冻结表,不保存正文或凭据。 + + **验证**:检查 SQLModel 模型发现和 MySQL/DM8 编译;回退应用时保留事件表及已有事实,删除需另获数据清理授权。 + + **承接 AC**: AC-35、AC-37、AC-39、AC-42、AC-53。 + + **依赖**:无。 + +- [x] **T002 主审计的外部来源和安全摘要列** + + **类型**:基础设施 · 基础设施;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/bisheng/dsh/domain/models/model_audit_request.py`(既有)。 + + **逻辑与上下文**:新增 MODEL/EXTERNAL_SAFETY 来源、可空 model_entered/model_started_at、source/Gateway 请求关联、输入/输出状态及版本、三个单调命中标志、六态汇总、安全投影版本和已投影命中标志。外部条目的 mode/session/模型结果/交付/结算可空;来源条件约束替换旧 mode CHECK。旧行默认 MODEL、UNREPORTED、无已知命中。 + + **验证**:新旧模型声明可发现;不得把旧 UNKNOWN 变为零。回退保留新增列及事实;旧应用读取前先关闭检查并做 DTO 兼容验证。 + + **承接 AC**: AC-34、AC-36、AC-38、AC-44、AC-52、AC-53。 + + **依赖**:无。 + +- [x] **T003 会话与目录命中计数模型** + + **类型**:基础设施 · 基础设施;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/bisheng/dsh/domain/models/model_audit_conversation.py`(既有);`src/backend/bisheng/dsh/domain/models/model_audit_tree.py`(既有)。 + + **逻辑与上下文**:会话增加 sensitive_hit_count/has_sensitive_hit;目录节点增加 own/branch 命中请求计数及安全汇总水位。沿用现有 dirty、租约与代际发布字段,不新增第二套树。 + + **验证**:列默认不宣称历史已通过;检查双库类型。回滚应用不删除新增计数,需重建时由后续运维任务操作。 + + **承接 AC**: AC-47、AC-48、AC-52、AC-53。 + + **依赖**:无。 + +- [x] **T004 持久删除水位声明** + + **类型**:基础设施 · 基础设施;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/bisheng/dsh/domain/models/model_audit_setting.py`(既有)。 + + **逻辑与上下文**:在既有审计 setting 增加每租户 audit_deleted_before 和有限清理进度,默认空;与 mode/version 字段分离。只声明字段,不实施自动清理、不默认设置 30 天保留。 + + **验证**:旧设置读取默认兼容;回退保留水位,不把删除水位清空后允许旧记录复活。 + + **承接 AC**: AC-51、AC-52、AC-53。 + + **依赖**:无。 + +- [x] **T005 接收与请求 DTO 声明** + + **类型**:基础设施 · 基础设施;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/bisheng/dsh/domain/schemas/content_safety.py`(拟新增);`src/backend/bisheng/dsh/domain/schemas/model_audit.py`(既有)。 + + **逻辑与上下文**:声明 ContentSafetyEnvelope(sent_at,event)、严格 v1 Event 和 Receipt;stage_results 为 input/output 状态映射,stage_versions 为对应非负版本(未观测为 0,状态改变取当前 event_version,未改变保持原值);ID 正十进制 int64 字符串/UUID、64 KiB、深度 16、摘要最多 20 项、合法枚举及清理后的四个会话字段。AuditRequestMetadata 增加外部来源、可空模型字段、六态安全结果及详情状态;不增加 key_id。 + + **验证**:DTO 能导入;未知字段拒绝,错误不回显值;保持旧请求 DTO 的已有效字段。 + + **承接 AC**: AC-25、AC-26、AC-34、AC-35、AC-36、AC-46。 + + **依赖**:无。 + +- [x] **T006 会话目录查询与返回类型** + + **类型**:基础设施 · 基础设施;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/bisheng/dsh/domain/schemas/model_audit_group.py`(既有)。 + + **逻辑与上下文**:扩展既有组/树查询类型,并在同文件新增当前缺失的响应 DTO,包含 has_sensitive_hit、INPUT/OUTPUT/BOTH、六态结果、去重命中计数、安全 readiness、水位及 scan_limited;BOTH 是交集。保持原模型异常数语义。 + + **验证**:旧无安全过滤请求可解析;默认时间不引入固定窗口或新游标协议。 + + **承接 AC**: AC-45、AC-47、AC-48、AC-49、AC-50。 + + **依赖**:无。 + +- [x] **T007 沿用 DSH 模块登记事件错误** + + **类型**:基础设施 · 基础设施;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/bisheng/common/errcode/dsh.py`(既有)。 + + **逻辑与上下文**:实施前扫描全仓 Code 字面量,确认候选 26170–26175 未占用后登记接收鉴权、事件非法、事件冲突、事件过期、接收不可用及超限。只扩展既有 261 模块;ClientCode content_blocked/check_unavailable 不借用账号或额度错误。 + + **验证**:错误码唯一且符合 MMMEE;不修改已有错误码或发布契约含义。 + + **承接 AC**: AC-30、AC-31、AC-42、AC-43。 + + **依赖**:无。 + +- [x] **T008 既有表 DDL 与安全回退说明** + + **类型**:基础设施 · 基础设施;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/bisheng/core/database/alembic/versions/v3_0_0_f111_dsh_content_safety.py`(拟新增);`src/backend/bisheng/core/database/alembic_helpers/online.py`(既有,复用大小写兼容的约束检查)。 + + **逻辑与上下文**:实时运行 alembic heads 确认单 head,再生成 f111_dsh_content_safety revision;只对既有审计主表、setting、会话/树增列、索引及来源 CHECK 做幂等 DDL。新事件整表由模型发现 create_all 创建;禁止 revision 内回填、清理或 SELECT 后写。使用 online helpers 和 DM8 适配。 + + **验证**:uv run alembic heads 与 test/database/test_alembic_single_head.py;fresh/upgrade 结构相同。downgrade 默认拒绝破坏性收缩,不在 revision 内按业务数据判空;应用回退保留扩展 schema。空数据结构回退由独立运维步骤先停止写入和判空,再在双库演练验证。 + + **承接 AC**: AC-51、AC-52、AC-53。 + + **依赖**:无。 + +- [x] **T009 后端独立测试 fixture** + + **类型**:基础设施 · 基础设施;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/test/dsh/content_safety_fixtures.py`(拟新增);`src/backend/test/dsh/conftest.py`(既有)。 + + **逻辑与上下文**:新增合成租户、身份、事件、时钟、原生审计、故障注入和事务工厂;SQLite 只验证无方言逻辑,保留真实 MySQL/DM8 会话适配接口。接收测试不启动完整模型 runtime、不使用真实 Token 或密钥。 + + **验证**:fixture smoke 可导入且不改变其他 dsh 测试默认行为;不做 Milvus/ES 测试降级。 + + **承接 AC**: AC-39、AC-42、AC-53。 + + **依赖**:无。 + +- [x] **T010 Gateway 策略与 Outbox 实体** + + **类型**:基础设施 · 基础设施;仓库 G;目标约 30 分钟。 + + **文件**:`src/main/java/com/dataelem/gateway/entity/DshSensitivePolicy.java`(拟新增);`src/main/java/com/dataelem/gateway/entity/DshSafetyOutbox.java`(拟新增)。 + + **逻辑与上下文**:策略实体 tenant_id 唯一、version/开关/方向/词源/词/回复/摘要;Outbox 不可变 payload/hash 与独立投递字段分开,PENDING/INFLIGHT/ACKED/QUARANTINED、租约、退避及时间齐全。没有原始凭据或命中原文列。 + + **验证**:实体编译;两种 DDL 列型对齐。应用回退保留词库配置、未确认事实及 ACK 状态。 + + **承接 AC**: AC-06、AC-10、AC-35、AC-58。 + + **依赖**:无。 + +- [x] **T011 有界预算与不可变请求上下文类型** + + **类型**:基础设施 · 基础设施;仓库 G;目标约 30 分钟。 + + **文件**:`src/main/java/com/dataelem/gateway/config/DshSafetyProperties.java`(拟新增);`src/main/java/com/dataelem/gateway/dto/DshSafetyContext.java`(拟新增)。 + + **逻辑与上下文**:定义默认关闭、固定 backend namespace/source-id、32 MiB 请求/累计响应、Responses 8 MiB 单事件、64 MiB 编码额度、16 检查/8 续读、30 秒续读、词表/编译/队列预算;上下文携带验证身份与策略快照。HMAC 只引用既有 BishengConfig 配置,不增加独立密钥。 + + **验证**:参数绑定有合法范围;不通过扩大 200 MB 全局 codec 绕过额度;明示实际堆放大仍需压测。 + + **承接 AC**: AC-02、AC-09、AC-14、AC-18、AC-26、AC-57。 + + **依赖**:无。 + +- [x] **T012 Gateway MySQL 增量 DDL** + + **类型**:基础设施 · 基础设施;仓库 G;目标约 30 分钟。 + + **文件**:`docker/db/f111_dsh_content_safety_mysql.sql`(拟新增);`docker/db/f111_dsh_content_safety_mysql_rollback.sql`(拟新增)。 + + **逻辑与上下文**:创建 gt_dsh_sensitive_policy、gt_dsh_safety_outbox 及 tenant/version/领取索引,提供重复执行前检查与空表结构回退 SQL;不改旧应用表。回退脚本对有数据表拒绝删除,转应用关闭功能并保留事实。 + + **验证**:后续真实 MySQL fresh/upgrade/空表回退验证;不在本轮执行 SQL。 + + **承接 AC**: AC-06、AC-10、AC-53、AC-58。 + + **依赖**:无。 + +- [x] **T013 Gateway DM8 增量 DDL** + + **类型**:基础设施 · 基础设施;仓库 G;目标约 30 分钟。 + + **文件**:`docker/db/f111_dsh_content_safety_dm.sql`(拟新增);`docker/db/f111_dsh_content_safety_dm_rollback.sql`(拟新增)。 + + **逻辑与上下文**:提供与 MySQL 同义的 DM8 类型、约束及索引,无 JSON 查询、MySQL upsert 或 SKIP LOCKED 假设;空表回退与有数据保护规则相同。 + + **验证**:后续 Linux DM8 fresh/upgrade/空表回退验证,不能用 SQLite/MySQL 结果代替;不在本轮执行 SQL。 + + **承接 AC**: AC-06、AC-10、AC-53、AC-58。 + + **依赖**:无。 + +- [x] **T014 Platform 的策略及审计类型** + + **类型**:基础设施 · 基础设施;仓库 B;目标约 30 分钟。 + + **文件**:`src/frontend/platform/src/types/dshContentSafety.ts`(拟新增);`src/frontend/platform/src/types/dshModelAudit.ts`(既有)。 + + **逻辑与上下文**:声明策略版本/词库限制、capabilities、六态检查结果、外部条目、有限命中事件、未关联分页及会话树命中计数。effective_mode 等未知值可空,BOTH 为输入输出均命中;与 backend DTO 和 Gateway ResultData 对齐。 + + **验证**:仅新增/扩展类型,不引入 HTTP 或新状态库;Web Client 无新增类型或改动。 + + **承接 AC**: AC-34、AC-44、AC-45、AC-46、AC-48。 + + **依赖**:无。 + +- [x] **T015 Platform v4 取数依赖兼容** + + **类型**:基础设施 · 基础设施;仓库 B;目标约 30 分钟。 + + **文件**:`src/frontend/platform/package.json`(既有);`src/frontend/pnpm-lock.yaml`(既有)。 + + **逻辑与上下文**:当前 platform 只直接声明 react-query v3,而已确认设计要求新增取数用 @tanstack/react-query v4。仅为 platform 补充 workspace 已锁定的 4.44.0 直接依赖,保留其他模块 v3 和现有锁定版本;不做全站迁移、不引入新状态库。Provider 在后续安全设置组件内局部接入。 + + **验证**:从 src/frontend 定向更新 platform 依赖与锁;检查其他包版本不被批量升级,后续组件/构建验证新hook可解析v4。 + + **承接 AC**: AC-03、AC-06。 + + **依赖**:无。 + +- [x] **T133 DTO 消费端审计元数据兼容修复** + + **类型**:基础设施 · 既有 DTO 消费端兼容修复;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/bisheng/dsh/domain/repositories/model_audit_queries.py`(仅 metadata 序列化);`src/backend/test/dsh/test_model_audit_groups.py`(专项兼容回归)。 + + **逻辑与上下文**:Wave 1 回归发现既有 metadata 对 DTO 全字段直接 getattr ORM,新增别名/阶段映射字段导致 AttributeError。先保留红测证据并添加旧 MODEL 默认/UNKNOWN、EXTERNAL_SAFETY 可空状态回归,再显式映射新增字段;明细未实际查询时不声称 AVAILABLE,不逐行查询事件,不实施后续完整筛选功能。 + + **验证**:新增回归红转绿及既有审计组/请求测试;Ruff、架构、文件范围和 DTO 同步检查。 + + **覆盖 AC**: AC-34、AC-36、AC-44、AC-52。 + + **依赖**:无;同 Wave 内等待 T005 的字段定义冻结后增量适配,复用已复现红测的轻量兼容修复流程。 + +- [x] **T134 Platform 可空状态消费兼容修复** + + **类型**:基础设施 · 既有 DTO 消费端兼容修复;仓库 B;目标约 30 分钟。 + + **文件**:`src/frontend/platform/src/pages/SystemPage/dsh/ModelAuditCard.tsx`(既有)。 + + **逻辑与上下文**:T014 类型扩展触发三个真实 TypeScript 错误;仅允许既有状态标签函数接受 null 并显示已有本地化 UNKNOWN,空值不形成模型失败说明。最终外部条目的不适用/未观测展示与三语仍由既定后续 UI 和文案任务交付,不新增缺翻译键或样式。 + + **验证**:完整 Platform typecheck、定向 ESLint 和 Card/Panel/Group/Tree 组件回归;不减弱 DTO 类型。 + + **承接 AC**: AC-36、AC-44、AC-52。 + + **依赖**:无;同 Wave 内 T014 字段定义冻结后增量适配。 + +### Wave 2 + +- [x] **T016 基础设施一致性检查** + + **类型**:验收 · 基础设施;仓库 B;目标约 30 分钟。 + + **文件**:`features/v3.0.0/111-dsh-sensitive-word-control/implementation-verification.md`(拟新增);`src/backend/test/database/test_alembic_dsh_main_upgrade.py`(既有,保留原 main 字节校验,扩展 F111 链路、模型/DDL 一致性与迁移保护验证)。 + + **逻辑与上下文**:未来执行时记录模型/双库 DDL 对照、单 head、候选错误码唯一、测试 fixture 可用和旧行默认值;结果写入该报告的基础设施节。此任务不调用模型、不部署、不清理业务数据。 + + **验证**:运行静态模型导入与单 head 测试;真实 DDL 结果由后续双库任务补充,未做的不写通过。 + + **覆盖 AC**: AC-52、AC-53。 + + **依赖**:T001、T002、T003、T004、T005、T006、T007、T008、T009、T010、T011、T012、T013、T014、T015、T133、T134。 + +### Wave 3 + +- [x] **T017 提取共享 HMAC primitive 并保留 SSO 适配先行测试** + + **类型**:测试 · 后端 Domain;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/test/dsh/test_content_safety_hmac.py`(拟新增)。 + + **逻辑与上下文**:只编写测试:固定 METHOD/LF/PATH/LF/raw bytes 的跨语言向量、大小写十六进制、正文空字节/Unicode/篡改、相同密钥兼容;旧 SSO 错误码、默认 X-Signature 及正文回放仍保持。测试上下文:B 仓库 test/dsh 的租户 101/202、用户 11/22、模型 7/8 合成 fixture;数据库/时钟/对象存储按需要替换,真实双库另验收;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:B: cd src/backend && uv run pytest test/dsh/test_content_safety_hmac.py -q。 + + **覆盖 AC**: AC-03、AC-35、AC-42。 + + **依赖**:T016。 + +- [x] **T019 六态归并与单调命中归约先行测试** + + **类型**:测试 · 后端 Domain;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/test/dsh/test_content_safety_states.py`(拟新增)。 + + **逻辑与上下文**:只编写测试:输入输出 36 种状态组合、缺结果、旧版本命中先后到、重复及 DRAIN 不能重开已结束检查;输入关闭输出不适用为 NOT_APPLICABLE。测试上下文:B 仓库 test/dsh 的租户 101/202、用户 11/22、模型 7/8 合成 fixture;数据库/时钟/对象存储按需要替换,真实双库另验收;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:B: cd src/backend && uv run pytest test/dsh/test_content_safety_states.py -q。 + + **覆盖 AC**: AC-33、AC-34、AC-41。 + + **依赖**:T016。 + +- [x] **T055 DSH profile 专用 HTTP 客户端先行测试** + + **类型**:测试 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/test/java/com/dataelem/gateway/client/DshProfileClientTest.java`(拟新增)。 + + **逻辑与上下文**:只编写测试:/api/v1/dsh/profile 原始 Token、user.id/tenant.id 合法校验、业务失败/超时/结构异常、固定目标且不记录 Authorization、同 Token 当前资料变化可用。测试上下文:G 仓库 JUnit 5、Mockito 和现有 Reactor Netty 测试服务器;使用合成 Bearer、可控时钟及内存端口,不访问真实账户;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:G: mvn -Dtest=DshProfileClientTest test。 + + **覆盖 AC**: AC-13、AC-16、AC-18、AC-19、AC-38。 + + **依赖**:T016。 + +- [x] **T061 租户策略 CAS 存储先行测试** + + **类型**:测试 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/test/java/com/dataelem/gateway/service/DshSensitivePolicyStoreTest.java`(拟新增)。 + + **逻辑与上下文**:只编写测试:缺失策略关闭/version0、首次 expected_version0、并发 CAS 一个成功、读取异常不当关闭、SQL 不依赖 MySQL 特有写法。测试上下文:G 仓库 JUnit 5、Mockito 和现有 Reactor Netty 测试服务器;使用合成 Bearer、可控时钟及内存端口,不访问真实账户;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:G: mvn -Dtest=DshSensitivePolicyStoreTest test。 + + **覆盖 AC**: AC-02、AC-06、AC-09、AC-10、AC-12、AC-53。 + + **依赖**:T016。 + +- [x] **T065 策略管理实时授权客户端先行测试** + + **类型**:测试 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/test/java/com/dataelem/gateway/client/DshSafetyAdminClientTest.java`(拟新增)。 + + **逻辑与上下文**:只编写测试:普通用户拒绝;Child Admin 仅 leaf且active;超管目标有效范围;HTTP200业务失败拒绝;OFF/密钥不可用的审计设置仍允许有权管理者。测试上下文:G 仓库 JUnit 5、Mockito 和现有 Reactor Netty 测试服务器;使用合成 Bearer、可控时钟及内存端口,不访问真实账户;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:G: mvn -Dtest=DshSafetyAdminClientTest test。 + + **覆盖 AC**: AC-07、AC-08、AC-18。 + + **依赖**:T016。 + +- [x] **T077 Gateway 不可变事件与持久 Outbox先行测试** + + **类型**:测试 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/test/java/com/dataelem/gateway/service/DshSafetyOutboxServiceTest.java`(拟新增)。 + + **逻辑与上下文**:只编写测试:命中立即DECISION、正常FINAL、续读DRAIN完整快照;event_id/version固定重试不变;SQL写失败仍阻断且报缺口;无命中仅审计失败保留业务结果。测试上下文:G 仓库 JUnit 5、Mockito 和现有 Reactor Netty 测试服务器;使用合成 Bearer、可控时钟及内存端口,不访问真实账户;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:G: mvn -Dtest=DshSafetyOutboxServiceTest test。 + + **覆盖 AC**: AC-33、AC-34、AC-35、AC-40、AC-41、AC-43、AC-55、AC-58。 + + **依赖**:T016。 + +- [x] **T083 DSH 单次上游 HTTP 所有权先行测试** + + **类型**:测试 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/test/java/com/dataelem/gateway/client/DshModelProxyClientTest.java`(拟新增)。 + + **逻辑与上下文**:只编写测试:一次请求一次响应订阅、无自动重试/重定向、原Bearer/正文/正常X-Request-ID、用户取消关闭上游、固定backend目标、资源释放。测试上下文:G 仓库 JUnit 5、Mockito 和现有 Reactor Netty 测试服务器;使用合成 Bearer、可控时钟及内存端口,不访问真实账户;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:G: mvn -Dtest=DshModelProxyClientTest test。 + + **覆盖 AC**: AC-03、AC-05、AC-16、AC-17、AC-29、AC-31、AC-32。 + + **依赖**:T016。 + +- [x] **T099 Platform 与 API 错误文案 zh-Hans** + + **类型**:实现 · 前端 Platform;仓库 B;目标约 30 分钟。 + + **文件**:`src/frontend/platform/public/locales/zh-Hans/bs.json`(既有);`src/frontend/packages/locales/src/api_errors/zh-Hans.json`(既有)。 + + **逻辑与上下文**:为 dsh.contentSafety.* 增加策略、命中方向、六态、未调用/未观测/已清理/未关联/积压等固定文案;为最终登记26170–26175补api_errors文案。三语言同一交付,不手改api_errors生成物,不翻译租户自定义阻断提示。 + + **验证**:三个locale任务全部完成后再生成并执行三语键对齐;新增键集合完全一致。 + + **承接 AC**: AC-56。 + + **依赖**:T016、T007。 + +- [x] **T100 Platform 与 API 错误文案 en** + + **类型**:实现 · 前端 Platform;仓库 B;目标约 30 分钟。 + + **文件**:`src/frontend/platform/public/locales/en-US/bs.json`(既有);`src/frontend/packages/locales/src/api_errors/en.json`(既有)。 + + **逻辑与上下文**:为 dsh.contentSafety.* 增加策略、命中方向、六态、未调用/未观测/已清理/未关联/积压等固定文案;为最终登记26170–26175补api_errors文案。三语言同一交付,不手改api_errors生成物,不翻译租户自定义阻断提示。 + + **验证**:三个locale任务全部完成后再生成并执行三语键对齐;新增键集合完全一致。 + + **承接 AC**: AC-56。 + + **依赖**:T016、T007。 + +- [x] **T101 Platform 与 API 错误文案 ja** + + **类型**:实现 · 前端 Platform;仓库 B;目标约 30 分钟。 + + **文件**:`src/frontend/platform/public/locales/ja/bs.json`(既有);`src/frontend/packages/locales/src/api_errors/ja.json`(既有)。 + + **逻辑与上下文**:为 dsh.contentSafety.* 增加策略、命中方向、六态、未调用/未观测/已清理/未关联/积压等固定文案;为最终登记26170–26175补api_errors文案。三语言同一交付,不手改api_errors生成物,不翻译租户自定义阻断提示。 + + **验证**:三个locale任务全部完成后再生成并执行三语键对齐;新增键集合完全一致。 + + **承接 AC**: AC-56。 + + **依赖**:T016、T007。 + +- [x] **T111 Desktop 安全错误文案与声明先行测试** + + **类型**:测试 · 外部 Desktop;仓库 D;目标约 30 分钟。 + + **文件**:`test/enterprise-content-safety-copy.test.mjs`(拟新增)。 + + **逻辑与上下文**:只编写测试:CONTENT_FILTERED/CONTENT_CHECK_UNAVAILABLE 的zh/en/ja固定键完整,安全消息清理,不把content_blocked识别为登录失败,不翻译租户自定义提示。测试上下文:D 仓库 Vitest,沿用 EnterpriseLlmAdapter 与 BlockAssembler fixture;可控 SSE 和记录调用次数的假工具,不读取真实 Profile;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:D: npm test -- test/enterprise-content-safety-copy.test.mjs。 + + **覆盖 AC**: AC-30、AC-31、AC-35、AC-56。 + + **依赖**:T016。 + +### Wave 4 + +- [x] **T018 提取共享 HMAC primitive 并保留 SSO 适配** + + **类型**:实现 · 后端 Domain;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/bisheng/common/utils/hmac_signature.py`(拟新增);`src/backend/bisheng/sso_sync/domain/services/hmac_auth.py`(既有)。 + + **逻辑与上下文**:公开 compute_hmac_signature(method,path,raw,secret) 与常量时间比较的无业务副作用 helper;旧 SSO compute_signature 委托该 helper,保留原依赖行为。共享文件影响限签名计算,不接入 DSH 模型认证,不更换密钥或全局 tenant bypass。 + + **验证**:通过配对任务 T017 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-03、AC-35、AC-42。 + + **依赖**:T017。 + +- [x] **T020 六态归并与单调命中归约** + + **类型**:实现 · 后端 Domain;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/bisheng/dsh/domain/services/content_safety_states.py`(拟新增)。 + + **逻辑与上下文**:实现 reduce_safety(existing,event) 纯函数:任一已知命中→BLOCKED;否则 ERROR→UNREPORTED→两方向 DISABLED→至少一个 PASSED→NOT_APPLICABLE。阶段版本只更新可确认事实,旧命中仍累积;同版本异事实诊断,不加身份冻结。 + + **验证**:通过配对任务 T019 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-33、AC-34、AC-41。 + + **依赖**:T019。 + +- [x] **T056 DSH profile 专用 HTTP 客户端** + + **类型**:实现 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/main/java/com/dataelem/gateway/client/DshProfileClient.java`(拟新增);`src/main/java/com/dataelem/gateway/dto/DshSafetyContext.java`(既有,允许可信身份的 Token 到期未知并禁止缓存)。 + + **逻辑与上下文**:实现 fetchIdentity(authorization,deadline);只信任 backend 成功校验 Token/用户/租户/会话后返回的 user/tenant,不把 JWT payload 当已验证身份。目标复用固定 bisheng-api-url,客户端不启用通用 DEBUG 头日志,无重定向/自动重试。 + + **验证**:通过配对任务 T055 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-13、AC-16、AC-18、AC-19、AC-38。 + + **依赖**:T055。 + +- [x] **T062 租户策略 CAS 存储** + + **类型**:实现 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/main/java/com/dataelem/gateway/mapper/DshSensitivePolicyMapper.java`(拟新增);`src/main/java/com/dataelem/gateway/service/DshSensitivePolicyStore.java`(拟新增)。 + + **逻辑与上下文**:提供 read(tenantId) 与 save(tenantId,expectedVersion,policy);租户条件显式,策略字段一次事务保存,成功版本递增。使用可移植条件 INSERT/UPDATE,不复用旧应用资源策略表。 + + **验证**:通过配对任务 T061 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-02、AC-06、AC-09、AC-10、AC-12、AC-53。 + + **依赖**:T061。 + +- [x] **T066 策略管理实时授权客户端** + + **类型**:实现 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/main/java/com/dataelem/gateway/client/DshSafetyAdminClient.java`(拟新增)。 + + **逻辑与上下文**:每次带浏览器凭据调 user/info;所有操作者调 /api/v1/dsh/admin/model-audit/settings?tenant_id=target 验证管理权。普通管理员另核 user/tenants active 列表,超管再读 tenants/{target};不以 profile 缓存/显示角色独立授权,不缓存管理授权。 + + **验证**:通过配对任务 T065 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-07、AC-08、AC-18。 + + **依赖**:T065。 + +- [x] **T078 Gateway 不可变事件与持久 Outbox** + + **类型**:实现 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/main/java/com/dataelem/gateway/mapper/DshSafetyOutboxMapper.java`(拟新增);`src/main/java/com/dataelem/gateway/service/DshSafetyOutboxService.java`(拟新增)。 + + **逻辑与上下文**:提供 recordDecision/recordFinal/recordDrain 与可移植租约领取/确认,事件最多64KiB/depth16/20摘要,禁止正文与凭据。只有SQL成功称已记录;保存payload_hash,不用内存队列冒充持久化;命中记录不等待续读或结算。 + + **验证**:通过配对任务 T077 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-33、AC-34、AC-35、AC-40、AC-41、AC-43、AC-55、AC-58。 + + **依赖**:T077。 + +- [x] **T084 DSH 单次上游 HTTP 所有权** + + **类型**:实现 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/main/java/com/dataelem/gateway/client/DshModelProxyClient.java`(拟新增)。 + + **逻辑与上下文**:使用独立客户端持有上游连接,沿用部署模型超时;过滤hop-by-hop与失效Content-Length。不给 CLIENT_RESPONSE_CONN_ATTR,让默认代理清理接管;将读完/交付完/用户取消拆成独立信号,不二次subscribe。 + + **验证**:通过配对任务 T083 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-03、AC-05、AC-16、AC-17、AC-29、AC-31、AC-32。 + + **依赖**:T083。 + +- [x] **T112 Desktop 安全错误文案与声明** + + **类型**:实现 · 外部 Desktop;仓库 D;目标约 30 分钟。 + + **文件**:`packages/dsh-desktop-enterprise/error-copy.js`(既有);`packages/dsh-desktop-enterprise/error-copy.d.ts`(既有)。 + + **逻辑与上下文**:新增安全错误归一化与三语copy导出并同步声明;只为新错误补语言,不重构所有旧账号文案,不引入i18n库或改Token存储。提供后续adapter和locale消费的稳定接口。 + + **验证**:通过配对任务 T111 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-30、AC-31、AC-35、AC-56。 + + **依赖**:T111。 + +### Wave 5 + +- [x] **T021 事件幂等保存与保留检查先行测试** + + **类型**:测试 · 后端 Domain;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/test/dsh/test_content_safety_repository.py`(拟新增)。 + + **逻辑与上下文**:只编写测试:重复同载荷、同编号异载荷、同请求同版本异事实、并发唯一键重读、跨租户隔离、已删除主行补报 410、真实身份变化接收。测试上下文:B 仓库 test/dsh 的租户 101/202、用户 11/22、模型 7/8 合成 fixture;数据库/时钟/对象存储按需要替换,真实双库另验收;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:B: cd src/backend && uv run pytest test/dsh/test_content_safety_repository.py -q。 + + **覆盖 AC**: AC-38、AC-39、AC-41、AC-42、AC-51、AC-53。 + + **依赖**:T016、T020。 + +- [x] **T057 身份 L1 与 Redis 绝对寿命先行测试** + + **类型**:测试 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/test/java/com/dataelem/gateway/service/DshIdentityCacheTest.java`(拟新增)。 + + **逻辑与上下文**:只编写测试:L1 2 秒/Redis 30 秒、Token 到期减 5 秒、原子 value+PTTL、读取耗时不得续期、namespace/完整 Token 隔离、Redis 故障和无效结果不缓存。测试上下文:G 仓库 JUnit 5、Mockito 和现有 Reactor Netty 测试服务器;使用合成 Bearer、可控时钟及内存端口,不访问真实账户;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:G: mvn -Dtest=DshIdentityCacheTest test。 + + **覆盖 AC**: AC-13、AC-14、AC-15、AC-17、AC-18、AC-19。 + + **依赖**:T016、T056。 + +- [x] **T063 词表编译与五秒策略快照先行测试** + + **类型**:测试 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/test/java/com/dataelem/gateway/service/DshSensitivePolicyServiceTest.java`(拟新增)。 + + **逻辑与上下文**:只编写测试:内置+自定义、字面大小写、空词拒绝、超过 1MiB/30000词/256码点拒绝、摘要不一致不可用、更新和失效丢失最多 5 秒收敛。测试上下文:G 仓库 JUnit 5、Mockito 和现有 Reactor Netty 测试服务器;使用合成 Bearer、可控时钟及内存端口,不访问真实账户;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:G: mvn -Dtest=DshSensitivePolicyServiceTest test。 + + **覆盖 AC**: AC-06、AC-09、AC-10、AC-11、AC-12、AC-18、AC-21、AC-26、AC-55。 + + **依赖**:T016、T062。 + +### Wave 6 + +- [x] **T022 事件幂等保存与保留检查** + + **类型**:实现 · 后端 Domain;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/bisheng/dsh/domain/repositories/content_safety.py`(拟新增)。 + + **逻辑与上下文**:提供 append_event(session,event,payload_hash)、lookup_receipt 和 retention_guard;SELECT 使用严格租户上下文,UPDATE/DELETE 显式 tenant/id/version。相同来源事件去重;Repository 不提交调用者事务,唯一键竞争向外传播 IntegrityError,由 T030 的事务所有者退出失败事务后新事务重读,不建立全局身份绑定表。 + + **验证**:通过配对任务 T021 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-38、AC-39、AC-41、AC-42、AC-51、AC-53。 + + **依赖**:T021、T020。 + +- [x] **T058 身份 L1 与 Redis 绝对寿命** + + **类型**:实现 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/main/java/com/dataelem/gateway/service/DshIdentityCache.java`(拟新增)。 + + **逻辑与上下文**:key=backend namespace+SHA256完整原始 Token;值仅验证身份/时间/截止时间。单调时钟截止和 Redis 剩余 TTL,不滑动续期;成功 profile 后只读 exp 缩短有效期。明确凭据错误仅本机负缓存 1 秒,基础设施错误不负缓存;校时异常副本不接检查。 + + **验证**:通过配对任务 T057 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-13、AC-14、AC-15、AC-17、AC-18、AC-19。 + + **依赖**:T057、T056。 + +- [x] **T064 词表编译与五秒策略快照** + + **类型**:实现 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/main/java/com/dataelem/gateway/service/DshSensitivePolicyService.java`(拟新增);`src/main/java/com/dataelem/gateway/service/DshSensitiveMatcherCache.java`(拟新增)。 + + **逻辑与上下文**:复用 FileUtil、CustomerConfig 内置加载和 ACPlus;快照键 tenant/version/builtin_digest。L1 1秒/Redis 5秒、500ms 读预算;旧读不能覆盖新版本。编译池2任务、最多200000节点、缓存权重128MiB,失败不发布空词库;已取得的在途引用固定到本次结束。 + + **验证**:通过配对任务 T063 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-06、AC-09、AC-10、AC-11、AC-12、AC-18、AC-21、AC-26、AC-55。 + + **依赖**:T063、T062。 + +### Wave 7 + +- [x] **T023 外部条目与主摘要事务合并先行测试** + + **类型**:测试 · 后端 Domain;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/test/dsh/test_content_safety_merge.py`(拟新增)。 + + **逻辑与上下文**:只编写测试:输入命中未转发、输出编号已知/未知、事件/原审计两种顺序及竞争、用户身份变化保留事实、不可确认归属留未关联、摘要和事件同事务回滚。测试上下文:B 仓库 test/dsh 的租户 101/202、用户 11/22、模型 7/8 合成 fixture;数据库/时钟/对象存储按需要替换,真实双库另验收;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:B: cd src/backend && uv run pytest test/dsh/test_content_safety_merge.py -q。 + + **覆盖 AC**: AC-27、AC-32、AC-35、AC-36、AC-37、AC-38、AC-39、AC-40、AC-44、AC-48。 + + **依赖**:T016、T022、T020。 + +- [x] **T059 冷验证合并与取消预算先行测试** + + **类型**:测试 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/test/java/com/dataelem/gateway/service/DshProfileVerifierTest.java`(拟新增)。 + + **逻辑与上下文**:只编写测试:同副本相同键首次/过期并发只调用一次;单等待者取消不取消共享请求;队满/超时;Redis owner 租约失效旧结果不能覆盖新结果。测试上下文:G 仓库 JUnit 5、Mockito 和现有 Reactor Netty 测试服务器;使用合成 Bearer、可控时钟及内存端口,不访问真实账户;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:G: mvn -Dtest=DshProfileVerifierTest test。 + + **覆盖 AC**: AC-14、AC-15、AC-18、AC-19、AC-59。 + + **依赖**:T016、T058。 + +- [x] **T067 策略保存与能力 API先行测试** + + **类型**:测试 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/test/java/com/dataelem/gateway/controller/DshSensitivePolicyControllerTest.java`(拟新增)。 + + **逻辑与上下文**:只编写测试:GET默认关闭/公开限制、PUT版本冲突409/格式400/超限413/依赖503、Origin校验、用户凭据或旧DSH登录不能管理、租户回复校验。测试上下文:G 仓库 JUnit 5、Mockito 和现有 Reactor Netty 测试服务器;使用合成 Bearer、可控时钟及内存端口,不访问真实账户;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:G: mvn -Dtest=DshSensitivePolicyControllerTest test。 + + **覆盖 AC**: AC-02、AC-06、AC-07、AC-08、AC-09、AC-10、AC-11、AC-12、AC-18。 + + **依赖**:T016、T066、T064。 + +- [x] **T069 Unicode 增量字面扫描先行测试** + + **类型**:测试 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/test/java/com/dataelem/gateway/service/DshSensitiveScannerTest.java`(拟新增)。 + + **逻辑与上下文**:只编写测试:跨 UTF8/UTF16补充平面/转义/最后尾部、同域连续文本块、不同消息/choices/工具不能拼词、命中摘要20项不存原词。测试上下文:G 仓库 JUnit 5、Mockito 和现有 Reactor Netty 测试服务器;使用合成 Bearer、可控时钟及内存端口,不访问真实账户;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:G: mvn -Dtest=DshSensitiveScannerTest test。 + + **覆盖 AC**: AC-20、AC-21、AC-22、AC-25、AC-26、AC-35。 + + **依赖**:T016、T064。 + +- [x] **T135 管理授权返回可信操作者** + + **类型**:实现 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/test/java/com/dataelem/gateway/client/DshSafetyAdminClientTest.java`(既有);`src/main/java/com/dataelem/gateway/client/DshSafetyAdminClient.java`(既有)。 + + **逻辑与上下文**:T066 已完成管理授权但返回 Void,策略持久化的 updated_by 需要可信当前用户。追加 authorizeIdentity(headers,target) 返回嵌套不可变 AdminIdentity(tenantId,userId),只从既有 /user/info 响应取合法正 int64 用户编号,完整实时授权链不变;既有 authorize 委托后 then() 保持兼容。不取浏览器自报操作者,不新增 backend RPC 或 profile 缓存。此项是已确认管理能力的必要返回类型补齐,不扩展权限规则。 + + **验证**:原 T065 文件新增可信编号/非法缺失编号/旧 Void 兼容断言先 RED 后 GREEN,重跑原八项真实本地 HTTP 回归及任务自审;本项最多两文件,T068 保留两个目标文件。 + + **承接 AC**: AC-02、AC-08、AC-09、AC-35。 + + **依赖**:T065、T066。 + + +### Wave 8 + +- [x] **T024 外部条目与主摘要事务合并** + + **类型**:实现 · 后端 Domain;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/bisheng/dsh/domain/repositories/content_safety_projection.py`(拟新增)。 + + **逻辑与上下文**:提供 merge_audit(session,event) 与标记 dirty:输入以 Gateway UUID 创建外部行,输出用模型响应 UUID;外部 NULL/NOT_CAPTURED/NOT_APPLICABLE,缺用量为 null。started_at 首次固定,真实模型起点另存;已有主行按自身归属关联。安全命中不写 dsh_model_call 或额度。 + + **验证**:通过配对任务 T023 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-27、AC-32、AC-35、AC-36、AC-37、AC-38、AC-39、AC-40、AC-44、AC-48。 + + **依赖**:T023、T022、T020。 + +- [x] **T060 冷验证合并与取消预算** + + **类型**:实现 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/main/java/com/dataelem/gateway/service/DshProfileVerifier.java`(拟新增);`src/main/java/com/dataelem/gateway/service/DshIdentityCache.java`(拟新增)。 + + **逻辑与上下文**:实现 resolveIdentity:本机 singleflight、32 在途/64 等待、1 秒含排队总预算;跨副本短租约只允许 owner 发布/释放。Redis 故障退本机合并及容量限制,不无限等待;原始 Token 仅在本次验证/转发必要内存存在。仅增量接入缓存 miss 分支。 + + **验证**:通过配对任务 T059 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-14、AC-15、AC-18、AC-19、AC-59。 + + **依赖**:T059、T058。 + +- [x] **T068 策略保存与能力 API** + + **类型**:实现 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/main/java/com/dataelem/gateway/controller/DshSensitivePolicyController.java`(拟新增);`src/main/java/com/dataelem/gateway/params/DshSensitivePolicyRequest.java`(拟新增)。 + + **逻辑与上下文**:实现 GET/PUT /api/sensitive/dsh/settings?tenant_id= 与 GET /capabilities;委托实时授权、策略 Store/Service,沿用 ResultData 并正确报告 status_code。浏览器写操作校验允许 Origin,实时授权成功后才聚合最多8MiB原始正文,严格UTF8/JSON深度16,解码词库仍限1MiB;不扩展 saveWords/wordsDetail/testWords 为 DSH 入口。 + + **验证**:通过配对任务 T067 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-02、AC-06、AC-07、AC-08、AC-09、AC-10、AC-11、AC-12、AC-18。 + + **依赖**:T067、T066、T064、T135。 + +- [x] **T070 Unicode 增量字面扫描** + + **类型**:实现 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/main/java/com/dataelem/gateway/service/DshSensitiveScanner.java`(拟新增)。 + + **逻辑与上下文**:提供新建逻辑域、feedDecodedText、completeDomain 接口;按 Unicode 码点保留 L−1 尾部,适配 ACPlus 的 char 索引。保留每个域的已检查水位,输出有限 rule_ref/hit_count;不调用旧 filterNormal 或写 BlockRecord。 + + **验证**:通过配对任务 T069 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-20、AC-21、AC-22、AC-25、AC-26、AC-35。 + + **依赖**:T069、T064。 + +### Wave 9 + +- [x] **T025 原生 writer 接受安全先到条目先行测试** + + **类型**:测试 · 后端 Domain;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/test/dsh/test_content_safety_native_writer.py`(拟新增)。 + + **逻辑与上下文**:只编写测试:外部条目提升 MODEL 后原文、mode、会话及模型字段正确补齐;原生事件后到不重置命中、不重复条目、不改安全 started_at;回滚不留下半提升。测试上下文:B 仓库 test/dsh 的租户 101/202、用户 11/22、模型 7/8 合成 fixture;数据库/时钟/对象存储按需要替换,真实双库另验收;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:B: cd src/backend && uv run pytest test/dsh/test_content_safety_native_writer.py -q。 + + **覆盖 AC**: AC-03、AC-32、AC-38、AC-41、AC-47、AC-49、AC-52。 + + **依赖**:T016、T024。 + +- [x] **T071 Chat 与 Responses 输入文本域先行测试** + + **类型**:测试 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/test/java/com/dataelem/gateway/protocol/DshInputSafetyAdapterTest.java`(拟新增)。 + + **逻辑与上下文**:只编写测试:两协议完整历史/可读reasoning/工具描述与结果、工具JSON字符串和值、同消息连续块、图像/加密reasoning/凭据/audit_context排除、非法结构400。测试上下文:G 仓库 JUnit 5、Mockito 和现有 Reactor Netty 测试服务器;使用合成 Bearer、可控时钟及内存端口,不访问真实账户;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:G: mvn -Dtest=DshInputSafetyAdapterTest test。 + + **覆盖 AC**: AC-01、AC-05、AC-20、AC-21、AC-22、AC-25、AC-26、AC-27。 + + **依赖**:T016、T070。 + +- [x] **T073 Chat SSE 检查与 content_filter 终态先行测试** + + **类型**:测试 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/test/java/com/dataelem/gateway/protocol/DshChatSafetyAdapterTest.java`(拟新增)。 + + **逻辑与上下文**:只编写测试:UTF8跨片/CRLF/多行data、完整原事件按序、跨event词、工具参数完整屏障、错误/取消未完成检查ERROR、正常文本字节与usage不改。测试上下文:G 仓库 JUnit 5、Mockito 和现有 Reactor Netty 测试服务器;使用合成 Bearer、可控时钟及内存端口,不访问真实账户;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:G: mvn -Dtest=DshChatSafetyAdapterTest test。 + + **覆盖 AC**: AC-01、AC-05、AC-20、AC-21、AC-22、AC-24、AC-25、AC-28、AC-29、AC-30、AC-31、AC-33、AC-34。 + + **依赖**:T016、T070。 + +- [x] **T075 Responses 有序增量与最终快照屏障先行测试** + + **类型**:测试 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/test/java/com/dataelem/gateway/protocol/DshResponsesSafetyAdapterTest.java`(拟新增)。 + + **逻辑与上下文**:只编写测试:增量/arguments.done/item.done/最终快照新增文本、reasoning/refusal、多个域交错、sequence_number不重写、8MiB事件与32MiB累计上限。测试上下文:G 仓库 JUnit 5、Mockito 和现有 Reactor Netty 测试服务器;使用合成 Bearer、可控时钟及内存端口,不访问真实账户;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:G: mvn -Dtest=DshResponsesSafetyAdapterTest test。 + + **覆盖 AC**: AC-01、AC-05、AC-20、AC-21、AC-22、AC-23、AC-24、AC-25、AC-26、AC-28、AC-29、AC-30、AC-31、AC-33。 + + **依赖**:T016、T070。 + +### Wave 10 + +- [x] **T026 原生 writer 接受安全先到条目** + + **类型**:实现 · 后端 Domain;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/bisheng/dsh/domain/repositories/model_audit_write.py`(既有);`src/backend/bisheng/dsh/domain/services/model_audit_writer.py`(既有)。 + + **逻辑与上下文**:在 upsert_request 的既有身份校验边界支持外部来源的已确认事实补齐,原 writer 同事务补 mode 并提升来源;保存模型实际起点,不写安全列。仅调整审计写入,不修改模型执行、凭证或用量真相。 + + **验证**:通过配对任务 T025 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-03、AC-32、AC-38、AC-41、AC-47、AC-49、AC-52。 + + **依赖**:T025、T024。 + +- [x] **T072 Chat 与 Responses 输入文本域** + + **类型**:实现 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/main/java/com/dataelem/gateway/protocol/DshInputSafetyAdapter.java`(拟新增)。 + + **逻辑与上下文**:解析有界 UTF8 JSON,提取已支持 schema 的文本域;工具参数完整 JSON 解码后分别查键和值;保留原始正文转发,不改写或只查最新问题。可选四个审计提示独立清洗,非法提示只影响关联。 + + **验证**:通过配对任务 T071 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-01、AC-05、AC-20、AC-21、AC-22、AC-25、AC-26、AC-27。 + + **依赖**:T071、T070。 + +- [x] **T074 Chat SSE 检查与 content_filter 终态** + + **类型**:实现 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/main/java/com/dataelem/gateway/protocol/DshChatSafetyAdapter.java`(拟新增)。 + + **逻辑与上下文**:适配 Chat 流式和非流式输出;整原事件只有所有依赖文本过水位才可交付。命中发 content_filter chunk 再 [DONE],不发 stop/tool_calls 成功终态或伪造usage;检查失败 event:error/check_unavailable。工具按choice/call绑定并完整解码。 + + **验证**:通过配对任务 T073 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-01、AC-05、AC-20、AC-21、AC-22、AC-24、AC-25、AC-28、AC-29、AC-30、AC-31、AC-33、AC-34。 + + **依赖**:T073、T070。 + +- [x] **T076 Responses 有序增量与最终快照屏障** + + **类型**:实现 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/main/java/com/dataelem/gateway/protocol/DshResponsesSafetyAdapter.java`(拟新增)。 + + **逻辑与上下文**:按 item/call/content_index 维护域和有序原事件队列,所有工具参数依赖检查完成后才交付;完成快照独立校验而不重复当增量。命中或不可用发明确 type=error 且无 completed;未知可交付结构报错,不跳过检查。 + + **验证**:通过配对任务 T075 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-01、AC-05、AC-20、AC-21、AC-22、AC-23、AC-24、AC-25、AC-26、AC-28、AC-29、AC-30、AC-31、AC-33。 + + **依赖**:T075、T070。 + +### Wave 11 + +- [x] **T027 外部条目的模型状态与停滞排除先行测试** + + **类型**:测试 · 后端 Domain;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/test/dsh/test_content_safety_external_status.py`(拟新增)。 + + **逻辑与上下文**:只编写测试:外部缺 mode/manifest/model_result 时为不适用或未观测,不能进入 RUNNING 停滞或全租户 INVALID_STATE;来源升级恢复模型状态;实际 UNKNOWN 不变零。测试上下文:B 仓库 test/dsh 的租户 101/202、用户 11/22、模型 7/8 合成 fixture;数据库/时钟/对象存储按需要替换,真实双库另验收;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:B: cd src/backend && uv run pytest test/dsh/test_content_safety_external_status.py -q。 + + **覆盖 AC**: AC-32、AC-34、AC-36、AC-38、AC-44、AC-48、AC-52。 + + **依赖**:T016、T026。 + +- [x] **T041 请求到会话的命中差量投影先行测试** + + **类型**:测试 · 后端 Domain;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/test/dsh/test_content_safety_groups.py`(拟新增)。 + + **逻辑与上下文**:只编写测试:命中变化且 audit_status 不变仍 dirty;重复事件/外部升级只计一次;无 conversation 条目不永久 dirty,后补关联再次处理;回滚/重试不负计数。测试上下文:B 仓库 test/dsh 的租户 101/202、用户 11/22、模型 7/8 合成 fixture;数据库/时钟/对象存储按需要替换,真实双库另验收;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:B: cd src/backend && uv run pytest test/dsh/test_content_safety_groups.py -q。 + + **覆盖 AC**: AC-38、AC-41、AC-47、AC-48、AC-52。 + + **依赖**:T016、T024、T026。 + +- [x] **T085 缓冲额度与命中后有界续读先行测试** + + **类型**:测试 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/test/java/com/dataelem/gateway/service/DshSafetyRequestManagerTest.java`(拟新增)。 + + **逻辑与上下文**:只编写测试:64MiB编码预算、16检查/8续读、客户端阻断闭流不提前取消上游、从命中计时30秒/字节/异常/无容量先到即止、普通取消不续读。测试上下文:G 仓库 JUnit 5、Mockito 和现有 Reactor Netty 测试服务器;使用合成 Bearer、可控时钟及内存端口,不访问真实账户;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:G: mvn -Dtest=DshSafetyRequestManagerTest test。 + + **覆盖 AC**: AC-18、AC-19、AC-26、AC-29、AC-32、AC-40、AC-43、AC-57。 + + **依赖**:T016、T084、T074、T076、T078。 + +- [x] **T113 Desktop Chat 阻断及工具完成屏障先行测试** + + **类型**:测试 · 外部 Desktop;仓库 D;目标约 30 分钟。 + + **文件**:`test/enterprise-content-safety-chat.test.mjs`(拟新增)。 + + **逻辑与上下文**:只编写测试:HTTP403/503、content_filter+[DONE]、event:error、租户提示、工具参数已完整但最后blocked时无可执行block-end、正常工具及usage不变、调用次数为1。测试上下文:D 仓库 Vitest,沿用 EnterpriseLlmAdapter 与 BlockAssembler fixture;可控 SSE 和记录调用次数的假工具,不读取真实 Profile;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:D: npm test -- test/enterprise-content-safety-chat.test.mjs。 + + **覆盖 AC**: AC-05、AC-24、AC-29、AC-30、AC-31、AC-32、AC-56。 + + **依赖**:T016、T112、T074。 + +- [x] **T115 Desktop Responses 错误优先识别先行测试** + + **类型**:测试 · 外部 Desktop;仓库 D;目标约 30 分钟。 + + **文件**:`test/enterprise-content-safety-responses.test.mjs`(拟新增)。 + + **逻辑与上下文**:只编写测试:无sequence_number的合法error在序列检查前处理;最终快照/工具完成后再blocked不执行工具;错误非UPSTREAM_ERROR;正常序列/推理回放不变。测试上下文:D 仓库 Vitest,沿用 EnterpriseLlmAdapter 与 BlockAssembler fixture;可控 SSE 和记录调用次数的假工具,不读取真实 Profile;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:D: npm test -- test/enterprise-content-safety-responses.test.mjs。 + + **覆盖 AC**: AC-05、AC-23、AC-24、AC-29、AC-30、AC-31、AC-32、AC-56。 + + **依赖**:T016、T112、T076。 + +### Wave 12 + +- [x] **T028 外部条目的模型状态与停滞排除** + + **类型**:实现 · 后端 Domain;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/bisheng/dsh/domain/schemas/model_audit_status.py`(既有);`src/backend/bisheng/dsh/domain/services/model_audit_states.py`(既有)。 + + **逻辑与上下文**:状态派生先区分 MODEL/EXTERNAL_SAFETY;外部不要求原文密钥与模型终态,停滞扫描只处理真实模型条目。安全 dirty 独立于 audit_status 变化,不能用原模型异常数表达安全命中。 + + **验证**:通过配对任务 T027 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-32、AC-34、AC-36、AC-38、AC-44、AC-48、AC-52。 + + **依赖**:T027、T026。 + +- [x] **T042 请求到会话的命中差量投影** + + **类型**:实现 · 后端 Domain;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/bisheng/dsh/domain/repositories/model_audit_conversations.py`(既有);`src/backend/bisheng/dsh/domain/services/model_audit_projection.py`(既有)。 + + **逻辑与上下文**:按 group_indexed_sensitive_hit 与当前请求状态计算 delta,维护 sensitive_hit_count/has_sensitive_hit 和树 dirty。领取/完成显式 tenant/id/lease,条目计数与模型调用次数分开;批次最多 200,返回持久进度。 + + **验证**:通过配对任务 T041 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-38、AC-41、AC-47、AC-48、AC-52。 + + **依赖**:T041、T024、T026。 + +- [x] **T086 缓冲额度与命中后有界续读** + + **类型**:实现 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/main/java/com/dataelem/gateway/service/DshSafetyRequestManager.java`(拟新增)。 + + **逻辑与上下文**:管理单一上游订阅和独立有界客户端通道;原始请求32MiB完整限长再转发,检查和排队预算有界。命中立即停内容交付且生成DECISION,独立最多30秒续读、剩余响应字节预算,结束释放DataBuffer/订阅/额度并生成DRAIN;只上报观察状态不写用量。 + + **验证**:通过配对任务 T085 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-18、AC-19、AC-26、AC-29、AC-32、AC-40、AC-43、AC-57。 + + **依赖**:T085、T084、T074、T076、T078。 + +- [x] **T114 Desktop Chat 阻断及工具完成屏障** + + **类型**:实现 · 外部 Desktop;仓库 D;目标约 30 分钟。 + + **文件**:`packages/dsh-desktop-enterprise/openai.js`(既有)。 + + **逻辑与上下文**:把阻断映射不可重试CONTENT_FILTERED,检查失败映射CONTENT_CHECK_UNAVAILABLE;保留安全提示和编号。工具完成块延迟到该轮正常终态确认后发布,错误轮次清掉待工具;保留普通文字安全前缀和原providerRetryPolicy。 + + **验证**:通过配对任务 T113 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-05、AC-24、AC-29、AC-30、AC-31、AC-32、AC-56。 + + **依赖**:T113、T112、T074。 + +- [x] **T116 Desktop Responses 错误优先识别** + + **类型**:实现 · 外部 Desktop;仓库 D;目标约 30 分钟。 + + **文件**:`packages/dsh-desktop-enterprise/responses.js`(既有)。 + + **逻辑与上下文**:在普通sequence校验前归一化content_blocked/check_unavailable;延迟可执行工具完成块到正常整轮确认,错误轮次清理pending,保留租户提示/编号;无自动重试或伪造completed。 + + **验证**:通过配对任务 T115 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-05、AC-23、AC-24、AC-29、AC-30、AC-31、AC-32、AC-56。 + + **依赖**:T115、T112、T076。 + +### Wave 13 + +- [x] **T029 安全事件接收编排与持久 ACK先行测试** + + **类型**:测试 · 后端 Domain;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/test/dsh/test_content_safety_ingest.py`(拟新增)。 + + **逻辑与上下文**:只编写测试:提交成功才 COMMITTED、SQL 失败/超时不 ACK、响应丢失重试、唯一键竞争后完整事务回滚并用新 Session 重读/重归并、不在失效事务继续查询、未关联也只确认事实、可信身份变化可接受、旧命中不清除。测试上下文:B 仓库 test/dsh 的租户 101/202、用户 11/22、模型 7/8 合成 fixture;数据库/时钟/对象存储按需要替换,真实双库另验收;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:B: cd src/backend && uv run pytest test/dsh/test_content_safety_ingest.py -q。 + + **覆盖 AC**: AC-03、AC-35、AC-38、AC-39、AC-40、AC-41、AC-42、AC-43、AC-58。 + + **依赖**:T016、T024、T026、T028。 + +- [x] **T037 主列表摘要过滤与事件分页先行测试** + + **类型**:测试 · 后端 Domain;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/test/dsh/test_content_safety_queries.py`(拟新增)。 + + **逻辑与上下文**:只编写测试:真假命中、INPUT/OUTPUT/BOTH、六态和用户/模型/时间组合;外部 nullable DTO;7/31 天、显式换条件/跨租户旧游标拒绝;PURGED 与 UNREPORTED 区别。测试上下文:B 仓库 test/dsh 的租户 101/202、用户 11/22、模型 7/8 合成 fixture;数据库/时钟/对象存储按需要替换,真实双库另验收;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:B: cd src/backend && uv run pytest test/dsh/test_content_safety_queries.py -q。 + + **覆盖 AC**: AC-32、AC-34、AC-36、AC-44、AC-45、AC-46、AC-49、AC-50、AC-51、AC-52。 + + **依赖**:T016、T028、T024。 + +- [x] **T043 目录 own/branch 命中与代际发布先行测试** + + **类型**:测试 · 后端 Domain;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/test/dsh/test_content_safety_tree.py`(拟新增)。 + + **逻辑与上下文**:只编写测试:父子会话命中传播、重复代际构建、仅外部条目可就绪、worker 停止时 PREPARING、租户停用后补报仍 dirty。测试上下文:B 仓库 test/dsh 的租户 101/202、用户 11/22、模型 7/8 合成 fixture;数据库/时钟/对象存储按需要替换,真实双库另验收;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:B: cd src/backend && uv run pytest test/dsh/test_content_safety_tree.py -q。 + + **覆盖 AC**: AC-41、AC-47、AC-48、AC-52。 + + **依赖**:T016、T042。 + +- [x] **T087 两个模型入口的检查编排先行测试** + + **类型**:测试 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/test/java/com/dataelem/gateway/filter/DshContentSafetyFilterTest.java`(拟新增)。 + + **逻辑与上下文**:只编写测试:仅两条精确POST、部署关闭透传/租户关闭无阻断、profile/策略不可用503、输入命中403且backend模型调用0、过期身份清缓存不重试模型。测试上下文:G 仓库 JUnit 5、Mockito 和现有 Reactor Netty 测试服务器;使用合成 Bearer、可控时钟及内存端口,不访问真实账户;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:G: mvn -Dtest=DshContentSafetyFilterTest test。 + + **覆盖 AC**: AC-01、AC-02、AC-03、AC-04、AC-05、AC-11、AC-12、AC-13、AC-16、AC-17、AC-18、AC-19、AC-27、AC-30、AC-31、AC-55。 + + **依赖**:T016、T060、T064、T072、T086。 + +- [x] **T136 Desktop 当前语言回调贯通先行测试** + + **类型**:测试 · 外部 Desktop;仓库 D。 + + **文件**:`test/enterprise-content-safety-chat.test.mjs`(既有);`test/enterprise-content-safety-responses.test.mjs`(既有)。 + + **逻辑与上下文**:只增补当前真实adapter的可选localizeContentSafety(code,defaultMessage)消费测试;未提供回调兼容、三语固定fallback、Gateway两种check_unavailable固定提示、租户自定义提示不覆盖、普通账户额度错误不受影响、无效回调结果安全回退;安全错误body的Gateway编号优先,普通错误保留header优先。不能用字典存在代替接线。 + + **验证**:新增断言先RED,原31及相邻74保持;不写生产桩。 + + **覆盖 AC**: AC-30、AC-31、AC-56。 + + **依赖**:T114、T116。 + +### Wave 14 + +- [x] **T030 安全事件接收编排与持久 ACK** + + **类型**:实现 · 后端 Domain;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/bisheng/dsh/domain/services/content_safety_ingest.py`(拟新增)。 + + **逻辑与上下文**:实现 ingest(event,verified_source):规范事件摘要、调用事件与主投影 Repository 的同一事务,唯一键竞争时退出失败事务并以新 Session 开完整短事务重读去重/重归并,提交后生成 Receipt。区分事实追加与主行唯一冲突;新事务严格租户重读仍不可见的跨租户主键/关联占用,保存事实为UNASSOCIATED而不无限重试或开启bypass。线程执行前后建立/恢复严格 profile_scope;不按当前用户退出或调租拒绝历史事件,不主动调用 Gateway。 + + **验证**:通过配对任务 T029 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-03、AC-35、AC-38、AC-39、AC-40、AC-41、AC-42、AC-43、AC-58。 + + **依赖**:T029、T024、T026、T028。 + +- [x] **T038 主列表摘要过滤与事件分页** + + **类型**:实现 · 后端 Domain;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/bisheng/dsh/domain/repositories/model_audit_queries.py`(既有);`src/backend/bisheng/dsh/domain/repositories/content_safety_queries.py`(拟新增)。 + + **逻辑与上下文**:主列表只读主摘要并 bulk 查用量/姓名;输出模型执行成功与内容 BLOCKED 可并存。提供请求事件和未关联事件的 limit+1/keyset 查询,不逐行读事件、不全历史 COUNT。默认窗口沿既有分页,不新增冻结要求;模型调用与实际用量仍取 dsh_model_call。 + + **验证**:通过配对任务 T037 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-32、AC-34、AC-36、AC-44、AC-45、AC-46、AC-49、AC-50、AC-51、AC-52。 + + **依赖**:T037、T028、T024。 + +- [x] **T044 目录 own/branch 命中与代际发布** + + **类型**:实现 · 后端 Domain;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/bisheng/dsh/domain/repositories/model_audit_tree_build.py`(既有);`src/backend/bisheng/dsh/domain/repositories/model_audit_hierarchy.py`(既有)。 + + **逻辑与上下文**:扩展现有树构建摘要和安全 readiness,不为安全检查建立新树;持久租约与批次重建、一次发布完整代际。历史租户枚举只返回 pending tenant IDs,具体构建回到严格租户范围。 + + **验证**:通过配对任务 T043 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-41、AC-47、AC-48、AC-52。 + + **依赖**:T043、T042。 + +- [x] **T088 两个模型入口的检查编排** + + **类型**:实现 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/main/java/com/dataelem/gateway/filter/DshContentSafetyFilter.java`(拟新增)。 + + **逻辑与上下文**:早于旧CustomResponseFilter设置DSH路由标志,取得profile成功结果和租户策略快照;按方向组织输入检查与请求管理器。缺失策略为关闭而读取失败为不可用;商业检查能力失效不能静默透传已开启策略。保留backend自己的身份、模型权限、额度错误。 + + **验证**:通过配对任务 T087 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-01、AC-02、AC-03、AC-04、AC-05、AC-11、AC-12、AC-13、AC-16、AC-17、AC-18、AC-19、AC-27、AC-30、AC-31、AC-55。 + + **依赖**:T087、T060、T064、T072、T086。 + +- [x] **T137 Desktop 当前语言回调贯通** + + **类型**:实现 · 外部 Desktop;仓库 D。 + + **文件**:`packages/dsh-desktop-enterprise/openai.js`(既有);`packages/dsh-desktop-enterprise/responses.js`(既有)。 + + **逻辑与上下文**:沿T136端口传递同步可选localizeContentSafety回调;仅固定fallback使用当前语言,租户提示始终保留。requestFailure/responseFailure末尾可选参数,Chat终态与Responses hooks用同一回调;不修改第三方分发包或另建语言体系。T117在既有两文件负责真实注册、构造与卸载。 + + **验证**:T136新增断言与原31/相邻74全部通过,任务自审;UI运行验证仍归T117。 + + **承接 AC**: AC-30、AC-31、AC-56。 + + **依赖**:T136。 + +### Wave 15 + +- [x] **T117 Desktop locale 与错误消费接线** + + **类型**:实现 · 外部 Desktop;仓库 D;目标约 30 分钟。 + + **文件**:`packages/dsh-desktop-enterprise/client.js`(既有);`packages/dsh-desktop-enterprise/index.js`(既有)。 + + **逻辑与上下文**:沿现有插件locale服务注册新错误zh/en/ja固定文案,并把已归一化安全错误接到当前显示入口/adapter构造上下文;保留租户自定义提示。只新增命名空间键与必要参数传递,不新建语言体系/运行时,不修改第三方分发包。 + + **验证**:切换三语言实际显示阻断/不可用文案,账号状态不被误置失效;插件卸载重载无重复注册;不把只新增copy对象当已接通。 + + **承接 AC**: AC-30、AC-31、AC-56。 + + **依赖**:T114、T116、T137。 + +- [x] **T031 接收端共享密钥认证和独立组装先行测试** + + **类型**:测试 · 后端 API;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/test/dsh/test_content_safety_runtime.py`(拟新增)。 + + **逻辑与上下文**:只编写测试:现有密钥和配置签名头可用;无密钥只使接收端不可用;无 SSO 登录仍可用;错误签名/时间窗/超限拒绝;未验签不访问业务表。测试上下文:B 仓库 test/dsh 的租户 101/202、用户 11/22、模型 7/8 合成 fixture;数据库/时钟/对象存储按需要替换,真实双库另验收;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:B: cd src/backend && uv run pytest test/dsh/test_content_safety_runtime.py -q。 + + **覆盖 AC**: AC-03、AC-26、AC-35、AC-42、AC-43。 + + **依赖**:T016、T018、T030。 + +- [x] **T089 专用路由过滤器和有序交付先行测试** + + **类型**:测试 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/test/java/com/dataelem/gateway/filter/DshModelRoutingFilterTest.java`(拟新增)。 + + **逻辑与上下文**:只编写测试:在PathRate之后/default Netty之前取所有权、无默认二次HTTP、完整原事件顺序、两协议四种模式、原错误保留与Gateway/backend编号区分。测试上下文:G 仓库 JUnit 5、Mockito 和现有 Reactor Netty 测试服务器;使用合成 Bearer、可控时钟及内存端口,不访问真实账户;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:G: mvn -Dtest=DshModelRoutingFilterTest test。 + + **覆盖 AC**: AC-01、AC-05、AC-16、AC-23、AC-27、AC-28、AC-29、AC-30、AC-31、AC-32、AC-37、AC-57。 + + **依赖**:T016、T088。 + +### Wave 16 + +- [x] **T032 接收端共享密钥认证和独立组装** + + **类型**:实现 · 后端 API;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/bisheng/dsh/content_safety_runtime.py`(拟新增);`src/backend/bisheng/common/errcode/dsh.py`(既有,来源租户范围拒绝复用认证业务码并区分 HTTP 403)。 + + **逻辑与上下文**:提供 get_content_safety_service 与 verify_source(raw,headers,method,path):从 settings.sso_sync 取既有 gateway_hmac_secret/signature_header,默认 X-Signature,时间窗口 ±300 秒。无独立密钥变量/key_id,用户 Token 不能代替服务签名。独立按需组装,不初始化 model runtime。 + + **验证**:通过配对任务 T031 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-03、AC-26、AC-35、AC-42、AC-43。 + + **依赖**:T031、T018、T030。 + +- [x] **T090 专用路由过滤器和有序交付** + + **类型**:实现 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/main/java/com/dataelem/gateway/filter/DshModelRoutingFilter.java`(拟新增);`src/main/java/com/dataelem/gateway/service/DshSafetyRequestManager.java`(既有,增量接入实际HTTP写入生命周期额度)。 + + **逻辑与上下文**:读取可信上下文,标already-routed并交给专用proxy/request manager,不继续默认模型路由。正常原始X-Request-ID保留;生成阻断错误用Gateway UUID,已知模型编号附X-Backend-Request-ID;不由forwarded推断执行。订阅HTTP正文前登记writer租约,编码字节从交付队列转为writer-held,实际write完成/失败/取消后才归还,避免Spring/Netty待发字节落到全局额度之外;原独立manager消费者语义保持。 + + **验证**:通过配对任务 T089 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-01、AC-05、AC-16、AC-23、AC-27、AC-28、AC-29、AC-30、AC-31、AC-32、AC-37、AC-57。 + + **依赖**:T089、T088。 + +### Wave 17 + +- [x] **T033 安全事件路由与真实 HTTP 回执先行测试** + + **类型**:测试 · 后端 API;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/test/dsh/test_content_safety_api.py`(拟新增)。 + + **逻辑与上下文**:只编写测试:真实路径方法、64 KiB/depth/ID 限制、原始字节只读一次、400/401/403/409/410/413/503 与 200 COMMITTED;字段错误不回显敏感内容。测试上下文:B 仓库 test/dsh 的租户 101/202、用户 11/22、模型 7/8 合成 fixture;数据库/时钟/对象存储按需要替换,真实双库另验收;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:B: cd src/backend && uv run pytest test/dsh/test_content_safety_api.py -q。 + + **覆盖 AC**: AC-25、AC-26、AC-35、AC-39、AC-42、AC-43。 + + **依赖**:T016、T032。 + +- [x] **T091 源配置与部署配置精确路由先行测试** + + **类型**:测试 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/test/java/com/dataelem/gateway/config/DshSafetyRoutingConfigurationTest.java`(拟新增);`src/test/java/com/dataelem/gateway/config/GatewayRoutingConfigurationTest.java`(既有,仅Spring placeholder解析夹具对齐)。 + + **逻辑与上下文**:只编写测试:两个DSH精确POST优先catch-all、无CacheRequestBody、profile及模型同固定目标、DM继承、登录/刷新/profile/目录不受内容检查、关闭仍透传。测试上下文:G 仓库 JUnit 5、Mockito 和现有 Reactor Netty 测试服务器;使用合成 Bearer、可控时钟及内存端口,不访问真实账户;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:G: mvn -Dtest=DshSafetyRoutingConfigurationTest test。 + + **覆盖 AC**: AC-01、AC-02、AC-03、AC-04、AC-05、AC-26、AC-54。 + + **依赖**:T016、T090。 + +- [x] **T118 真实 Harness 工具执行边界验证** + + **类型**:测试 · 外部 Desktop;仓库 D;目标约 30 分钟。 + + **文件**:`test/enterprise-content-safety-runtime.test.ts`(拟新增)。 + + **逻辑与上下文**:只写运行时行为测试,使用安装的Harness公开Agent/工具执行入口、Enterprise adapter与一个递增计数的合成工具:先出现完整工具参数,最后返回两种协议阻断/不可用,调用计数均为0;正常工具轮次计数为1。覆盖block-end/finish消费链及同轮后续文本命中。 + + **验证**:D: npm test -- test/enterprise-content-safety-runtime.test.ts;通过后记录实际消费点,不以BlockAssembler单测代替工具runner证据,不直接编辑node_modules。若屏障不成立,该任务不能完成,按SDD偏差机制补齐宿主适配。 + + **覆盖 AC**: AC-23、AC-24、AC-29、AC-30、AC-31。 + + **依赖**:T117、T090。 + +- [x] **T140 Desktop 语言桥接入口与卸载回归固化** + + **类型**:测试 · 外部 Desktop;仓库 D。 + + **文件**:`test/enterprise-content-safety-locale.test.mjs`(拟新增)。 + + **逻辑与上下文**:将T117已执行的真实LocaleRuntime/client.apply/认证connection/request/注册adapter入口验证固化到仓库测试;覆盖无偏好、Host持久化延迟、三语言双协议固定copy、租户提示、并发ALS隔离、非法/重复/跨authority cookie、同值双renderer owner与卸载重载。使用临时专用profile和合成本地上游,无真实Cookie/Token;不得只mock locale或直接调用localize callback代替入口。不把该测试替代真实Agent/Session工具屏障或Electron GUI。 + + **验证**:D: npm exec -- vitest run test/enterprise-content-safety-locale.test.mjs;与内容安全adapter现有测试组合执行,不依赖前一测试遗留Context或Cookie。 + + **覆盖 AC**: AC-30、AC-31、AC-56。 + + **依赖**:T117。 + +- [x] **T141 Desktop 既有插件夹具兼容回归** + + **类型**:测试 · 外部 Desktop;仓库 D。 + + **文件**:`test/enterprise-adapter.test.ts`(既有);`test/enterprise-audit-integration.test.ts`(既有,公共事件夹具对齐)。 + + **逻辑与上下文**:维护既有插件上下文夹具的公共on订阅disposer,以及原private函数被公开导出后的源码提取边界;不修改原目录、图像、错误分类和文案断言,不将无事件stub作为语言桥接证明(实际入口由T140验证)。 + + **验证**:保留先前5项夹具失败证据,修后运行该文件及内容安全、旧Chat/Responses适配器相邻集。 + + **覆盖 AC**: AC-03、AC-30、AC-31、AC-56。 + + **依赖**:T117、T137。 + +### Wave 18 + +- [x] **T034 安全事件路由与真实 HTTP 回执** + + **类型**:实现 · 后端 API;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/bisheng/dsh/api/endpoints/content_safety_events.py`(拟新增);`src/backend/bisheng/dsh/api/router.py`(既有)。 + + **逻辑与上下文**:注册 POST /api/v1/internal/dsh/content-safety-events;Endpoint 只限长读取、调用 verifier/DTO/Service,返回直接 receipt 对象及 DSH 错误 envelope,不包平台 resp_200,不使用 SSO request._receive 回放。 + + **验证**:通过配对任务 T033 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-25、AC-26、AC-35、AC-39、AC-42、AC-43。 + + **依赖**:T033、T032。 + +- [x] **T092 源配置与部署配置精确路由** + + **类型**:实现 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/main/resources/application.yml`(既有);`docker/gateway/config/application.yml`(既有)。 + + **逻辑与上下文**:增加独立DSH路由与默认关闭属性,精确Chat/Responses POST、URI对齐bisheng-api-url;不把新密钥值写入示例,不扩大200MB codec。保留原应用/WS路径和既有限流;部署入口封闭另由验收证明。 + + **验证**:通过配对任务 T091 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-01、AC-02、AC-03、AC-04、AC-05、AC-26、AC-54。 + + **依赖**:T091、T090。 + +- [x] **T142 Desktop 账户旧UI夹具兼容回归** + + **类型**:测试 · 外部 Desktop;仓库 D。 + + **文件**:`test/enterprise-seat-error-view.test.mjs`;`test/enterprise-insecure-confirm-view.test.mjs`(既有)。 + + **逻辑与上下文**:对齐T117新增使用的公共LocaleRuntime接口,保留全部原UI断言。只维护测试夹具,不以生产optional chaining兼容不完整桩,不将夹具通过冒充真实语言桥接(真实入口见T140)。 + + **验证**:保留旧夹具缺getSnapshot等接口导致的RED证据;修后执行对应文件及T140/现有内容安全组合回归。 + + **覆盖 AC**: AC-03、AC-30、AC-31、AC-56。 + + **依赖**:T117。 + +- [x] **T143 Desktop 市场旧UI夹具兼容回归** + + **类型**:测试 · 外部 Desktop;仓库 D。 + + **文件**:`test/enterprise-market-view.test.mjs`;`test/enterprise-market-client.test.mjs`(既有,仅公共locale夹具对齐)。 + + **逻辑与上下文**:对齐T117新增使用的公共LocaleRuntime接口,保留全部原UI断言。只维护测试夹具,不以生产optional chaining兼容不完整桩,不将夹具通过冒充真实语言桥接(真实入口见T140)。 + + **验证**:保留旧夹具缺getSnapshot等接口导致的RED证据;修后执行对应文件及T140/现有内容安全组合回归。 + + **覆盖 AC**: AC-03、AC-30、AC-31、AC-56。 + + **依赖**:T117。 + +### Wave 19 + +- [x] **T035 精确服务认证入口与租户恢复先行测试** + + **类型**:测试 · 后端 API;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/test/dsh/test_content_safety_middleware.py`(拟新增)。 + + **逻辑与上下文**:只编写测试:合法服务签名在无浏览器 Cookie 时可达;无签名/用户 Token 不能上报;相邻 internal 路径不豁免;残留超管/bypass 清除,异常后上下文恢复。测试上下文:B 仓库 test/dsh 的租户 101/202、用户 11/22、模型 7/8 合成 fixture;数据库/时钟/对象存储按需要替换,真实双库另验收;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:B: cd src/backend && uv run pytest test/dsh/test_content_safety_middleware.py -q。 + + **覆盖 AC**: AC-03、AC-42、AC-45。 + + **依赖**:T016、T034。 + +- [x] **T093 旧输入与响应敏感词路径跳过 DSH先行测试** + + **类型**:测试 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/test/java/com/dataelem/gateway/filter/DshLegacySensitiveIsolationTest.java`(拟新增)。 + + **逻辑与上下文**:只编写测试:DSH输入/输出不进filterNormal/BlockRecord/原文日志;旧assistant/workflow应用命中行为不变;关闭检查仍不会误用旧应用策略。测试上下文:G 仓库 JUnit 5、Mockito 和现有 Reactor Netty 测试服务器;使用合成 Bearer、可控时钟及内存端口,不访问真实账户;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:G: mvn -Dtest=DshLegacySensitiveIsolationTest test。 + + **覆盖 AC**: AC-02、AC-03、AC-35、AC-54。 + + **依赖**:T016、T092。 + +### Wave 20 + +- [x] **T036 精确服务认证入口与租户恢复** + + **类型**:实现 · 后端 API;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/bisheng/utils/http_middleware.py`(既有)。 + + **逻辑与上下文**:仅对精确 POST 接收路径跳过浏览器 JWT 准入,转专用服务认证;不把 internal/dsh 前缀加入 TENANT_CHECK_EXEMPT_PATHS。签名验证后使用事件租户的严格上下文,保护其他 API/DSH 认证行为。 + + **验证**:通过配对任务 T035 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-03、AC-42、AC-45。 + + **依赖**:T035、T034。 + +- [x] **T094 旧输入与响应敏感词路径跳过 DSH** + + **类型**:实现 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/main/java/com/dataelem/gateway/filter/SensitiveWordsFilter.java`(既有);`src/main/java/com/dataelem/gateway/filter/CustomResponseFilter.java`(既有)。 + + **逻辑与上下文**:在旧输入和响应包装入口识别精准DSH路由标志并跳过旧敏感词分支,普通应用不改。只复用ACPlus/FileUtil能力,不重连已移除DSH登录、License或席位代码。 + + **验证**:通过配对任务 T093 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-02、AC-03、AC-35、AC-54。 + + **依赖**:T093、T092。 + +- [x] **T144 Desktop 每次模型流冻结安全文案的先行测试** + + **类型**:测试 · 外部 Desktop;仓库 D。 + + **文件**:`test/enterprise-content-safety-chat.test.mjs`;`test/enterprise-content-safety-responses.test.mjs`。 + + **逻辑与上下文**:只测试可选同步contentSafetyFormatterFor({sessionId,purpose})在每次实际协议stream开始冻结formatter;旧localizeContentSafety兼容,异常/非法factory安全降级不影响普通模型,tenant提示不覆盖,未来上下文改变不改在途formatter,不转发文案。 + + **验证**:双协议新factory契约先RED、原callback/普通工具/账户与错误回归GREEN;配对T145全部通过。 + + **覆盖 AC**: AC-03、AC-30、AC-31、AC-56。 + + **依赖**:T137、T117。 + +### Wave 21 + +- [x] **T039 审计查询服务与管理端点扩展先行测试** + + **类型**:测试 · 后端 API;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/test/dsh/test_content_safety_query_api.py`(拟新增)。 + + **逻辑与上下文**:只编写测试:租户管理员/超管作用域/普通用户、历史用户退出后的查询、请求详情、事件详情及未关联列表;403 由既有权限链返回。测试上下文:B 仓库 test/dsh 的租户 101/202、用户 11/22、模型 7/8 合成 fixture;数据库/时钟/对象存储按需要替换,真实双库另验收;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:B: cd src/backend && uv run pytest test/dsh/test_content_safety_query_api.py -q。 + + **覆盖 AC**: AC-07、AC-08、AC-35、AC-44、AC-45、AC-46、AC-49、AC-50、AC-51。 + + **依赖**:T016、T038、T036。 + +- [x] **T079 共享 HMAC 回传与提交回执校验先行测试** + + **类型**:测试 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/test/java/com/dataelem/gateway/client/ContentSafetyEventClientTest.java`(拟新增)。 + + **逻辑与上下文**:只编写测试:Java/Python同向量、默认/配置签名头、sent_at重试重签但event不变、COMMITTED与event_id匹配才ACK、2xx假成功/404/5xx/401等分类。测试上下文:G 仓库 JUnit 5、Mockito 和现有 Reactor Netty 测试服务器;使用合成 Bearer、可控时钟及内存端口,不访问真实账户;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:G: mvn -Dtest=ContentSafetyEventClientTest test。 + + **覆盖 AC**: AC-35、AC-39、AC-41、AC-42、AC-43、AC-58。 + + **依赖**:T016、T018、T036、T078。 + +- [x] **T095 PathRate 正文日志隔离与公共限流先行测试** + + **类型**:测试 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/test/java/com/dataelem/gateway/filter/DshPathRateIsolationTest.java`(拟新增)。 + + **逻辑与上下文**:只编写测试:DSH无旧CACHED_REQUEST_BODY时不空指针、不打印Token/正文,适用公共限流仍执行;旧应用流量规则不变。测试上下文:G 仓库 JUnit 5、Mockito 和现有 Reactor Netty 测试服务器;使用合成 Bearer、可控时钟及内存端口,不访问真实账户;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:G: mvn -Dtest=DshPathRateIsolationTest test。 + + **覆盖 AC**: AC-05、AC-26、AC-35、AC-54。 + + **依赖**:T016、T094。 + +- [x] **T145 Desktop 每次模型流冻结安全文案** + + **类型**:实现 · 外部 Desktop;仓库 D。 + + **文件**:`packages/dsh-desktop-enterprise/openai.js`;`packages/dsh-desktop-enterprise/responses.js`。 + + **逻辑与上下文**:两个适配器沿可选factory在stream开始取得冻结formatter,实际Responses委托不重复捕获;只改变默认安全文案回调归属,不改变分类/编号/租户提示/工具屏障。 + + **验证**:通过T144全部配对断言和T118真实工具执行回归。 + + **覆盖 AC**: AC-03、AC-30、AC-31、AC-56。 + + **依赖**:T144。 + +- [x] **T146 Desktop 排队语言与公开事件作用域先行测试** + + **类型**:测试 · 外部 Desktop;仓库 D。 + + **文件**:`test/enterprise-content-safety-locale-scope.test.mjs`(拟新增);`test/enterprise-content-safety-locale.test.mjs`(既有)。 + + **逻辑与上下文**:只编写helper状态和真实cookie→认证Connection→生产bridge→公开SessionController.queue→实际Agent/adapter双协议测试。insert只记录pending,claim按session/Message.id晋升当前turn;并发/未来入队/在途formatter不互相覆盖,discard/turn-end/dispose/unload回收,不在Message/source/model中写文案。 + + **验证**:保留真实第二请求ja却返回zh的RED,helper缺能力先RED;配对T147后两测试全部GREEN。 + + **覆盖 AC**: AC-03、AC-30、AC-31、AC-56。 + + **依赖**:T145、T118、T140。 + +### Wave 22 + +- [x] **T148 默认相对窗口翻页先行回归** + + **类型**:测试 · 后端 Domain/API;仓库 B。 + + **文件**:`src/backend/test/dsh/test_content_safety_queries.py`(既有);`src/backend/test/dsh/test_content_safety_query_api.py`(既有)。 + + **逻辑与上下文**:实际Repository及API验证默认7天、最多31天;第二页now推进仍沿相对窗口且不因隐式bounds改变拒绝cursor,显式时间/安全筛选/租户变更仍拒绝。只测试,不在Service解析或重签仓储游标。 + + **验证**:仓储缺少窗口端口先RED;配对T149及T040后全部GREEN,保留原窗口与显式绑定断言。 + + **覆盖 AC**: AC-45、AC-49、AC-50。 + + **依赖**:T038、T039。 + +- [x] **T149 主审计请求仓储相对窗口适配** + + **类型**:实现 · 后端 Domain;仓库 B。 + + **文件**:`src/backend/bisheng/dsh/domain/repositories/model_audit_queries.py`(既有)。 + + **逻辑与上下文**:page沿可选now端口在仓储内部计算默认窗口;fingerprint仅绑定用户显式筛选,隐式时间不写入游标,保留旧内部调用无now语义。复用已实现的未关联事件时间窗口约束,不增加Service SQL。 + + **验证**:T148仓储配对断言及T037原回归GREEN,T040接线后API配对全部GREEN。 + + **承接 AC**: AC-45、AC-49、AC-50。 + + **依赖**:T148、T038。 + +- [x] **T150 旧审计数据库 fixture 事件表补齐** + + **类型**:测试基础设施;仓库 B。 + + **文件**:`src/backend/test/dsh/model_audit_fixtures.py`(既有)。 + + **逻辑与上下文**:把新增事件模型登记到原AUDIT_MODELS,结构发现及租户测试表集合使用同一tuple。仅补隔离测试schema,不改业务或旧断言。 + + **验证**:原广回归缺事件表11项RED转GREEN;SQL回滚和租户fixture恢复保持。 + + **覆盖 AC**: AC-03、AC-46。 + + **依赖**:T001、T039。 + +- [x] **T151 旧手工审计记录测试时钟对齐** + + **类型**:测试;仓库 B。 + + **文件**:`src/backend/test/dsh/test_model_audit_exact_models.py`(既有);`src/backend/test/dsh/test_model_audit_user_names.py`(既有)。 + + **逻辑与上下文**:仅两个手工插入固定NOW数据的既有测试,将查询clock注入NOW后一秒;保留所有原查询/名字/租户/游标断言,不改共享Harness的真实模型clock或全局NOW。 + + **验证**:原过期固定数据导致2项RED转GREEN,广审计回归保持。 + + **覆盖 AC**: AC-03、AC-45、AC-49。 + + **依赖**:T039、T149。 + +- [x] **T040 审计查询服务与管理端点扩展** + + **类型**:实现 · 后端 API;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/bisheng/dsh/domain/services/model_audit_queries.py`(既有);`src/backend/bisheng/dsh/api/endpoints/model_audit_admin.py`(既有)。 + + **逻辑与上下文**:沿用现有 audit 管理授权,传递安全过滤和 DTO;新增 GET /requests/{id}/content-safety-events、GET /content-safety-events,扩展 /requests、详情和 query-readiness。Service 只编排 Repository,Endpoint 不写 ORM,不增加 Gateway 查询 RPC。 + + **验证**:通过配对任务 T039 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-07、AC-08、AC-35、AC-44、AC-45、AC-46、AC-49、AC-50、AC-51。 + + **依赖**:T039、T038、T036、T149。 + +- [x] **T080 共享 HMAC 回传与提交回执校验** + + **类型**:实现 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/main/java/com/dataelem/gateway/client/ContentSafetyEventClient.java`(拟新增)。 + + **逻辑与上下文**:固定目标 POST /api/v1/internal/dsh/content-safety-events,复用 BishengConfig.gatewayHmacSecret 与 signatureHeader;包装 sent_at/event,METHOD/PATH/raw字节HMAC,小写hex;独立无凭据日志客户端,3秒超时。超时/5xx/429与未就绪继续退避,明确拒绝隔离且不ACK。 + + **验证**:通过配对任务 T079 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-35、AC-39、AC-41、AC-42、AC-43、AC-58。 + + **依赖**:T079、T018、T036、T078。 + +- [x] **T096 PathRate 正文日志隔离与公共限流** + + **类型**:实现 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/main/java/com/dataelem/gateway/filter/PathRateGlobalFilter.java`(既有)。 + + **逻辑与上下文**:仅跳过DSH旧应用正文解析与日志分支,保留普通应用和适用通用限流。DSH并发由请求管理器预算控制;不复制body制造未计入内存缓冲。 + + **验证**:通过配对任务 T095 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-05、AC-26、AC-35、AC-54。 + + **依赖**:T095、T094。 + +- [x] **T147 Desktop 按消息入队与实际轮次隔离安全文案** + + **类型**:实现 · 外部 Desktop;仓库 D。 + + **文件**:`packages/dsh-desktop-enterprise/content-safety-locale.js`(拟新增);`packages/dsh-desktop-enterprise/index.js`(既有)。 + + **逻辑与上下文**:createContentSafetyLocaleScope(ctx,requestLocale)按现成公开inbox inserted/claimed/discarded及turn结束/agent dispose管理短生命周期旁路状态,formatterFor({sessionId,purpose})在流开始冻结copy;无session及内部title/compaction沿发起请求ALS,卸载幂等清理。无第三方patch/全局last-language/消息持久化字段或模型传参。 + + **验证**:通过T146全部断言,原T140/T118与相邻UI/审计回归GREEN。 + + **覆盖 AC**: AC-03、AC-30、AC-31、AC-56。 + + **依赖**:T146、T145、T117。 + +### Wave 23 + +- [x] **T045 会话目录组合过滤同请求核验先行测试** + + **类型**:测试 · 后端 Domain;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/test/dsh/test_content_safety_tree_queries.py`(拟新增)。 + + **逻辑与上下文**:只编写测试:同会话上月命中/昨天未命中时最近 7 天命中过滤不得入选;用户/模型/方向组合由同一请求满足;扫描上限不能伪装空结果。测试上下文:B 仓库 test/dsh 的租户 101/202、用户 11/22、模型 7/8 合成 fixture;数据库/时钟/对象存储按需要替换,真实双库另验收;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:B: cd src/backend && uv run pytest test/dsh/test_content_safety_tree_queries.py -q。 + + **覆盖 AC**: AC-45、AC-47、AC-48、AC-49、AC-50。 + + **依赖**:T016、T044、T040。 + +- [x] **T081 多实例领取、重试和确认清理先行测试** + + **类型**:测试 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/test/java/com/dataelem/gateway/service/DshSafetyOutboxDispatcherTest.java`(拟新增)。 + + **逻辑与上下文**:只编写测试:多实例同事件竞争、租约超时恢复、进程重启/ACK丢失、错owner不能确认、关闭检查仍发送、未确认不被7天TTL清理。测试上下文:G 仓库 JUnit 5、Mockito 和现有 Reactor Netty 测试服务器;使用合成 Bearer、可控时钟及内存端口,不访问真实账户;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:G: mvn -Dtest=DshSafetyOutboxDispatcherTest test。 + + **覆盖 AC**: AC-39、AC-41、AC-43、AC-51、AC-55、AC-58。 + + **依赖**:T016、T080。 + +- [x] **T102 Platform 策略及事件 API wrapper** + + **类型**:实现 · 前端 Platform;仓库 B;目标约 30 分钟。 + + **文件**:`src/frontend/platform/src/controllers/API/dshContentSafety.ts`(拟新增);`src/frontend/platform/src/controllers/API/dshModelAudit.ts`(既有)。 + + **逻辑与上下文**:使用封装request调用Gateway settings/capabilities与backend新增事件接口,补全groupQuery安全过滤;校验Gateway ResultData业务状态及DTO nullable字段。沿用403拦截器、AbortSignal和既有审计no-store,不在store或组件直接导入axios。 + + **验证**:合成HTTP响应验证200业务失败、409版本冲突、Gateway不可达/未部署及旧后端响应兼容;不把无命中解释通过。 + + **承接 AC**: AC-06、AC-07、AC-10、AC-44、AC-45、AC-46。 + + **依赖**:T040、T068、T099、T100、T101。 + +### Wave 24 + +- [x] **T154 Platform 共享组件 workspace 依赖接入** + + **类型**:基础设施;仓库 B。 + + **文件**:`src/frontend/platform/package.json`(既有);`src/frontend/pnpm-lock.yaml`(既有,生成)。 + + **逻辑与上下文**:按现行根AGENTS仅补本仓已有@bisheng/ui workspace:^本地直接依赖,保留全部第三方锁定版本与旧UI/state依赖。不引入新第三方UI库。 + + **验证**:离线依赖解析成功、锁文件无无关升级、现有Platform类型/目标lint通过。 + + **承接 AC**: AC-03、AC-56。 + + **依赖**:T102。 + +- [x] **T155 Platform 敏感词界面共享样式隔离接入** + + **类型**:基础设施;仓库 B。 + + **文件**:`src/frontend/platform/vite.config.mts`(既有);`src/frontend/platform/src/style/dsh-content-safety.css`(拟新增)。 + + **逻辑与上下文**:构建时从共享tokens/preset与真实组件源码生成本功能容器作用域CSS,沿原值与主题规则,不复制维护设计变量、不改全站Tailwind主题。组件仅在独立dsh-content-safety容器内使用,原页面root变量/旧utility不被覆盖。 + + **验证**:实际Vite/CSS编译、主题/键帧与选择器隔离、原平台变量对照,以及真实组件预览后再验收UI任务;JSDOM不能代样式编译。 + + **承接 AC**: AC-03、AC-56。 + + **依赖**:T154。 + +- [x] **T046 会话目录组合过滤同请求核验** + + **类型**:实现 · 后端 Domain;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/bisheng/dsh/domain/repositories/model_audit_conversations.py`(既有);`src/backend/bisheng/dsh/domain/repositories/model_audit_tree_queries.py`(既有)。 + + **逻辑与上下文**:会话/目录安全筛选以汇总索引产生候选,按有界请求核验完整条件;达到预算返回 scan_limited,区分全量计数与已实际计算的筛选计数。仅修改查询方法,保留前驱投影增量逻辑。 + + **验证**:通过配对任务 T045 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-45、AC-47、AC-48、AC-49、AC-50。 + + **依赖**:T045、T044、T040。 + +- [x] **T152 会话组与目录默认窗口 API 接线回归** + + **类型**:测试 · 后端 API;仓库 B。 + + **文件**:`src/backend/test/dsh/test_content_safety_group_query_api.py`(拟新增)。 + + **逻辑与上下文**:真实管理API/Service/Repository验证组、目录与组内请求默认7天/max31,查询clock可控,默认时钟推进不绑定成显式筛选;保留授权及同请求安全过滤。测试复用现有隔离fixture与真实投影,不填生产算法。 + + **验证**:T046端口完成后先形成缺Service时钟接线RED,再T153转GREEN;不以Repository直调代API接线。 + + **覆盖 AC**: AC-45、AC-49、AC-50。 + + **依赖**:T039、T046。 + +- [x] **T153 会话查询服务传递窗口时钟** + + **类型**:实现 · 后端 API;仓库 B。 + + **文件**:`src/backend/bisheng/dsh/domain/services/model_audit_queries.py`(既有)。 + + **逻辑与上下文**:仅组/树/组内请求实际查询向既有Repo端口传self.now();Service不注默认上下界、不解析或签发游标,不改原内部无now查询。location保持独立定位语义。 + + **验证**:通过T152 API与T045原回归及旧审计回归。 + + **承接 AC**: AC-45、AC-49、AC-50。 + + **依赖**:T152、T046。 + +- [x] **T156 两条既有组树 API 固定日期夹具校时** + + **类型**:测试维护;仓库 B。 + + **文件**:`src/backend/test/dsh/test_model_audit_groups.py`(既有);`src/backend/test/dsh/test_model_audit_tree_http.py`(既有)。 + + **逻辑与上下文**:各仅手工固定NOW建记录的一个HTTP测试注入查询now=NOW+1秒,原业务断言不变。不改全局Harness、模型调用时钟或生产默认窗口。 + + **验证**:保留T153后原两项RED证据,修后原断言及新窗口/相邻审计回归通过。 + + **覆盖 AC**: AC-45、AC-49、AC-50。 + + **依赖**:T152。 + +- [x] **T082 多实例领取、重试和确认清理** + + **类型**:实现 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/main/java/com/dataelem/gateway/service/DshSafetyOutboxDispatcher.java`(拟新增)。 + + **逻辑与上下文**:按最多100条/30秒租约有限并发投递,保证单批耗时与续租/逐条领取匹配;退避1秒至5分钟带抖动。只正确回执标ACKED;ACKED初始保留7天,未确认/隔离磁盘高水位告警,不自动丢弃;Spring生命周期独立于模型请求及检查开关。 + + **验证**:通过配对任务 T081 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-39、AC-41、AC-43、AC-51、AC-55、AC-58。 + + **依赖**:T081、T080。 + +- [x] **T103 策略表单与取数 hook** + + **类型**:实现 · 前端 Platform;仓库 B;目标约 30 分钟。 + + **文件**:`src/frontend/platform/src/pages/SystemPage/dsh/DshContentSafetySettings.tsx`(拟新增);`src/frontend/platform/src/pages/SystemPage/dsh/useDshContentSafety.ts`(拟新增)。 + + **逻辑与上下文**:新命名导出组件:词源/输入输出/提示以Checkbox和保存按钮原子提交,expected_version冲突保留草稿并提示刷新,显示5秒新请求生效说明。若独立Switch则即时提交/等待锁定/失败回弹。设置组件内提供局部v4 QueryClientProvider,由子组件消费hook,使用本特性QueryClient与tenant_id查询键;不更换全站v3 Provider。Gateway不可达明确不可用。 + + **验证**:用租户A/B管理员和超管切换目标、保存空词/无提示/冲突版本、关闭再启用和Gateway离线;仅落地组件,无新样式/状态库,空id不传SelectItem;运行组件测试确认不存在No QueryClient或v3/v4上下文混用。 + + **承接 AC**: AC-02、AC-06、AC-07、AC-08、AC-09、AC-10、AC-11、AC-12、AC-55、AC-56。 + + **依赖**:T102、T015、T154、T155。 + +- [x] **T105 请求安全筛选控件** + + **类型**:实现 · 前端 Platform;仓库 B;目标约 30 分钟。 + + **文件**:`src/frontend/platform/src/pages/SystemPage/dsh/ModelAuditFilters.tsx`(既有)。 + + **逻辑与上下文**:加入是否命中、INPUT/OUTPUT/BOTH和六态筛选,BOTH文案明确两方向均命中;与用户/模型/时间一起提交,显式条件变化清游标/缓存。不增加默认时间窗口冻结或查询会话。 + + **验证**:组合选择、重置、翻页和超过31天提示;空id过滤后再渲染SelectItem,不写单独403处理。 + + **承接 AC**: AC-34、AC-45、AC-49、AC-50、AC-56。 + + **依赖**:T102、T154、T155。 + +- [x] **T106 请求标签及安全详情组件** + + **类型**:实现 · 前端 Platform;仓库 B;目标约 30 分钟。 + + **文件**:`src/frontend/platform/src/pages/SystemPage/dsh/ModelAuditCard.tsx`(既有);`src/frontend/platform/src/pages/SystemPage/dsh/ContentSafetyDetails.tsx`(拟新增)。 + + **逻辑与上下文**:状态用已落地Tag,显示输入/输出与汇总;模型成功和输出BLOCKED可并存。详情分页读取事件、策略/词库摘要和三种时间,显示PURGED/未观测/不适用;外部条目不展示不存在的原文入口,未知usage不渲染成0。 + + **验证**:输入阻断、输出命中成功计量、无事件、缺结果、明细清理和只保存metadata六类记录逐一展示。 + + **承接 AC**: AC-32、AC-34、AC-36、AC-44、AC-46、AC-51、AC-56。 + + **依赖**:T102、T154、T155。 + +### Wave 25 + +- [x] **T047 安全投影任务与 Celery 注册先行测试** + + **类型**:测试 · Worker;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/test/dsh/test_content_safety_worker.py`(拟新增)。 + + **逻辑与上下文**:只编写测试:真实 Celery task dispatch 恢复租户、缺失或不匹配 header 拒绝、重试/租约回收、单进程退出可续做、停用租户历史补报。测试上下文:B 仓库 test/dsh 的租户 101/202、用户 11/22、模型 7/8 合成 fixture;数据库/时钟/对象存储按需要替换,真实双库另验收;Worker fixture通过tenant_id参数和Celery headers→ContextVar调度真实任务,不以直接调用task正文替代信号恢复验证;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:B: cd src/backend && uv run pytest test/dsh/test_content_safety_worker.py -q。 + + **覆盖 AC**: AC-39、AC-41、AC-47、AC-48、AC-53、AC-55。 + + **依赖**:T016、T046。 + +- [x] **T097 检查与补报的有限诊断指标先行测试** + + **类型**:测试 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/test/java/com/dataelem/gateway/service/DshSafetyTelemetryTest.java`(拟新增)。 + + **逻辑与上下文**:只编写测试:缓存合并/错误、策略摘要不一致、缓冲/续读、Outbox最老年龄/隔离/丢失、密钥和正文不入日志、无用户/事件ID无限维度标签。测试上下文:G 仓库 JUnit 5、Mockito 和现有 Reactor Netty 测试服务器;使用合成 Bearer、可控时钟及内存端口,不访问真实账户;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:G: mvn -Dtest=DshSafetyTelemetryTest test。 + + **覆盖 AC**: AC-14、AC-18、AC-19、AC-26、AC-35、AC-43、AC-48、AC-58。 + + **依赖**:T016、T082、T096。 + +- [x] **T165 DSH 导航既有测试维护** + + **类型**:测试维护;仓库 B。 + + **文件**:`src/frontend/platform/src/pages/DshPage/dshPageNavigation.test.tsx`(既有)。 + + **逻辑与上下文**:原固定五个tab计数更新为含安全控制入口的六个,并增该入口URL/可见性断言。保留原审计、访问设置、插件、旧隐藏tab回退全部断言;不mock新业务判定。 + + **验证**:保留旧计数RED证据,实际导航及原断言通过;新表单/权限边界由T104独立真实组件探针补验。 + + **覆盖 AC**: AC-03、AC-06、AC-07、AC-56。 + + **依赖**:T103。 + +- [x] **T104 DSH 管理入口接入安全配置** + + **类型**:实现 · 前端 Platform;仓库 B;目标约 30 分钟。 + + **文件**:`src/frontend/platform/src/pages/DshPage/sections.ts`(既有);`src/frontend/platform/src/pages/DshPage/index.tsx`(既有)。 + + **逻辑与上下文**:沿既有租户管理和DSH导航接入敏感词控制,复用当前目标租户上下文;无Gateway配置区域不可用但模型/额度/原审计可访问。仅消费前驱新组件,保留既有路由出口契约与其他tab。首次进入后切tab保留挂载和未保存草稿;未首访不取数,身份/租户/部署禁用仍隔离卸载。窄宽仅外容器横滚,不改共享Tabs视觉。 + + **验证**:平台现有/dsh入口打开/切租户/退回原tab;非管理员沿原准入拒绝,模块新文案三语可见。 + + **承接 AC**: AC-03、AC-06、AC-07、AC-56。 + + **依赖**:T103。 + +- [x] **T107 请求取数与页面状态接线** + + **类型**:实现 · 前端 Platform;仓库 B;目标约 30 分钟。 + + **文件**:`src/frontend/platform/src/pages/SystemPage/dsh/useModelAudit.ts`(既有);`src/frontend/platform/src/pages/SystemPage/dsh/ModelAuditPanel.tsx`(既有)。 + + **逻辑与上下文**:把安全过滤/nullableDTO/详情传到现有列表与组件,显式条件变化清游标;刷新保持原有分页方式。页面单文件保持≤600行,新增安全展示由前驱组件承担,不扩大Panel职责。 + + **验证**:检查loading/error/empty、上一页/下一页、租户切换取消旧请求、后到补报刷新及无Gateway旧列表。 + + **承接 AC**: AC-03、AC-44、AC-45、AC-49、AC-50、AC-56。 + + **依赖**:T105、T106。 + +### Wave 26 + +- [x] **T166 安全dirty租户扫描端口先行测试** + + **类型**:测试 · 后端 Domain;仓库 B。 + + **文件**:`src/backend/test/dsh/test_content_safety_tree.py`(既有)。 + + **逻辑与上下文**:仅GroupPending但hierarchy已索引/已发布的迟到命中租户必须被新扫描端口发现;模型RUNNING但无dirty不入选。验证Tenant目录驱动、相关EXISTS只枚举dirty ID、100条游标、停用/归档租户及原tree_pending_tenants默认兼容。 + + **验证**:真实Repo/SQL缺可选group pending端口先RED,原目录测试保持GREEN;不在Worker内写SQL或使用全量运行请求租户替代dirty扫描。 + + **覆盖 AC**: AC-39、AC-47、AC-48、AC-53、AC-55。 + + **依赖**:T047、T046。 + +- [x] **T167 安全dirty租户扫描端口实现** + + **类型**:实现 · 后端 Domain;仓库 B。 + + **文件**:`src/backend/bisheng/dsh/domain/repositories/model_audit_hierarchy.py`(既有)。 + + **逻辑与上下文**:tree_pending_tenants新增可选include_group_pending=False,原默认不变,True时同一有界元数据扫描包含Request.group_index_pending。仅跨租户读取待处理租户编号,处理仍由Worker回到严格作用域,无正文/事实全聚合。 + + **验证**:通过T166及原目录/安全查询回归,100条keyset且未更改默认旧调用行为。 + + **承接 AC**: AC-39、AC-47、AC-48、AC-53、AC-55。 + + **依赖**:T166、T046。 + +- [x] **T048 安全投影任务与 Celery 注册** + + **类型**:实现 · Worker;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/bisheng/worker/dsh/content_safety.py`(拟新增);`src/backend/bisheng/worker/dsh/registry.py`(既有)。 + + **逻辑与上下文**:注册 dsh.project_content_safety 与有界扫描任务,使用默认 celery 队列。投递 args 中 tenant_id 且 headers={tenant_id:同值},task_prerun headers→ContextVar,任务核对参数并进入严格 profile_scope,finally 恢复;SQL 线程重新建作用域。跨租户枚举只取 dirty IDs,不能只取 active tenant。 + + **验证**:通过配对任务 T047 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-39、AC-41、AC-47、AC-48、AC-53、AC-55。 + + **依赖**:T047、T046、T167。 + +- [x] **T098 检查与补报的有限诊断指标** + + **类型**:实现 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/main/java/com/dataelem/gateway/service/DshSafetyTelemetry.java`(拟新增);`src/main/java/com/dataelem/gateway/service/DshSafetyOutboxDispatcher.java`(拟新增)。 + + **逻辑与上下文**:增加受控聚合计数和安全日志字段;指标接入已有监控方式,无现成Prometheus导出时使用现有日志/健康能力,不新增依赖。Dispatcher仅增量调用遥测,不改前驱投递语义。 + + **验证**:通过配对任务 T097 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-14、AC-18、AC-19、AC-26、AC-35、AC-43、AC-48、AC-58。 + + **依赖**:T097、T082、T096。 + +- [x] **T157 身份缓存与profile有界诊断先行测试** + + **类型**:测试 · 外部 Gateway;仓库 G。 + + **文件**:`src/test/java/com/dataelem/gateway/service/DshIdentityTelemetryTest.java`(拟新增)。 + + **逻辑与上下文**:真实L1/共享命中与miss、Redis失败、同Token合并、有限队列拒绝与active/queued/latency观察;取消/超时不重复计数,不改变profile次数、租约、deadline或模型身份。执行原生产对象的实际端口后断言共享Telemetry,不能仅手工increment冒称已接线;观测不可影响原结果、异常或资源预算。 + + **验证**:无实际observer接线先RED、原相邻行为GREEN;配对T158后转GREEN。 + + **覆盖 AC**: AC-14、AC-18、AC-19。 + + **依赖**:T098。 + +- [x] **T158 身份缓存与profile有界诊断接线** + + **类型**:实现 · 外部 Gateway;仓库 G。 + + **文件**:`src/main/java/com/dataelem/gateway/service/DshIdentityCache.java`(既有);`src/main/java/com/dataelem/gateway/service/DshProfileVerifier.java`(既有)。 + + **逻辑与上下文**:真实L1/共享命中与miss、Redis失败、同Token合并、有限队列拒绝与active/queued/latency观察;取消/超时不重复计数,不改变profile次数、租约、deadline或模型身份。保留旧构造器兼容并补可注入Telemetry端口,Spring由T139注入同一实例。只做有限枚举/标量观测,不新增依赖或用户维度历史,不记录Throwable正文。 + + **验证**:T157及原相邻测试通过,额外profile/SQL/上游调用为0;资源释放/取消行为保持。 + + **承接 AC**: AC-14、AC-18、AC-19。 + + **依赖**:T157、T098。 + +- [x] **T159 策略与匹配器有界诊断先行测试** + + **类型**:测试 · 外部 Gateway;仓库 G。 + + **文件**:`src/test/java/com/dataelem/gateway/service/DshPolicyTelemetryTest.java`(拟新增)。 + + **逻辑与上下文**:真实缓存年龄/摘要不一致、编译容量拒绝和active观察;读写CAS、5秒生效、缓存权重和匹配语义不变,不因观测重编译或增加SQL。执行原生产对象的实际端口后断言共享Telemetry,不能仅手工increment冒称已接线;观测不可影响原结果、异常或资源预算。 + + **验证**:无实际observer接线先RED、原相邻行为GREEN;配对T160后转GREEN。 + + **覆盖 AC**: AC-18、AC-19、AC-26。 + + **依赖**:T098。 + +- [x] **T160 策略与匹配器有界诊断接线** + + **类型**:实现 · 外部 Gateway;仓库 G。 + + **文件**:`src/main/java/com/dataelem/gateway/service/DshSensitivePolicyService.java`(既有);`src/main/java/com/dataelem/gateway/service/DshSensitiveMatcherCache.java`(既有)。 + + **逻辑与上下文**:真实缓存年龄/摘要不一致、编译容量拒绝和active观察;读写CAS、5秒生效、缓存权重和匹配语义不变,不因观测重编译或增加SQL。保留旧构造器兼容并补可注入Telemetry端口,Spring由T139注入同一实例。只做有限枚举/标量观测,不新增依赖或用户维度历史,不记录Throwable正文。 + + **验证**:T159及原相邻测试通过,额外profile/SQL/上游调用为0;资源释放/取消行为保持。 + + **承接 AC**: AC-18、AC-19、AC-26。 + + **依赖**:T159、T098。 + +- [x] **T161 请求与持久录制有界诊断先行测试** + + **类型**:测试 · 外部 Gateway;仓库 G。 + + **文件**:`src/test/java/com/dataelem/gateway/service/DshRequestTelemetryTest.java`(拟新增)。 + + **逻辑与上下文**:实际检查命中/错误、全局编码缓冲及检查/续读额度、SQL录制缺口;只从原锁或原原子计数读取标量,所有退出释放后的gauge准确,不重订模型或读取/记录正文。执行原生产对象的实际端口后断言共享Telemetry,不能仅手工increment冒称已接线;观测不可影响原结果、异常或资源预算。 + + **验证**:无实际observer接线先RED、原相邻行为GREEN;配对T162后转GREEN。 + + **覆盖 AC**: AC-26、AC-35、AC-43、AC-58。 + + **依赖**:T098。 + +- [x] **T162 请求与持久录制有界诊断接线** + + **类型**:实现 · 外部 Gateway;仓库 G。 + + **文件**:`src/main/java/com/dataelem/gateway/service/DshSafetyRequestManager.java`(既有);`src/main/java/com/dataelem/gateway/service/DshSafetyOutboxService.java`(既有)。 + + **逻辑与上下文**:实际检查命中/错误、全局编码缓冲及检查/续读额度、SQL录制缺口;只从原锁或原原子计数读取标量,所有退出释放后的gauge准确,不重订模型或读取/记录正文。保留旧构造器兼容并补可注入Telemetry端口,Spring由T139注入同一实例。只做有限枚举/标量观测,不新增依赖或用户维度历史,不记录Throwable正文。 + + **验证**:T161及原相邻测试通过,额外profile/SQL/上游调用为0;资源释放/取消行为保持。 + + **承接 AC**: AC-26、AC-35、AC-43、AC-58。 + + **依赖**:T161、T098。 + +- [x] **T163 检查入口失败有限诊断先行测试** + + **类型**:测试 · 外部 Gateway;仓库 G。 + + **文件**:`src/test/java/com/dataelem/gateway/service/DshPreflightTelemetryTest.java`(拟新增)。 + + **逻辑与上下文**:身份/策略/license/capacity入口拒绝有限计数,不用tenant/user/request/event等高基数标签,不变部署关闭/租户关闭与原错误优先级。执行原生产对象的实际端口后断言共享Telemetry,不能仅手工increment冒称已接线;观测不可影响原结果、异常或资源预算。 + + **验证**:无实际observer接线先RED、原相邻行为GREEN;配对T164后转GREEN。 + + **覆盖 AC**: AC-14、AC-18、AC-19、AC-35。 + + **依赖**:T098、T096。 + +- [x] **T164 检查入口失败有限诊断接线** + + **类型**:实现 · 外部 Gateway;仓库 G。 + + **文件**:`src/main/java/com/dataelem/gateway/filter/DshContentSafetyFilter.java`(既有)。 + + **逻辑与上下文**:身份/策略/license/capacity入口拒绝有限计数,不用tenant/user/request/event等高基数标签,不变部署关闭/租户关闭与原错误优先级。保留旧构造器兼容并补可注入Telemetry端口,Spring由T139注入同一实例。只做有限枚举/标量观测,不新增依赖或用户维度历史,不记录Throwable正文。 + + **验证**:T163及原相邻测试通过,额外profile/SQL/上游调用为0;资源释放/取消行为保持。 + + **承接 AC**: AC-14、AC-18、AC-19、AC-35。 + + **依赖**:T163、T098。 + +### Wave 27 + +- [x] **T049 Beat 调度与查询安全水位接线先行测试** + + **类型**:测试 · Worker;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/test/dsh/test_content_safety_schedule.py`(拟新增)。 + + **逻辑与上下文**:只编写测试:Beat 注册一次并投递各 dirty 租户;API/worker 分进程可见安全水位;无 Gateway、无接收密钥仍可启动原模型及审计。测试上下文:B 仓库 test/dsh 的租户 101/202、用户 11/22、模型 7/8 合成 fixture;数据库/时钟/对象存储按需要替换,真实双库另验收;Worker fixture通过tenant_id参数和Celery headers→ContextVar调度真实任务,不以直接调用task正文替代信号恢复验证;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:B: cd src/backend && uv run pytest test/dsh/test_content_safety_schedule.py -q。 + + **覆盖 AC**: AC-03、AC-39、AC-48、AC-55。 + + **依赖**:T016、T048。 + +- [x] **T138 Gateway Spring 组装与关闭兼容先行测试** + + **类型**:测试 · 外部 Gateway;仓库 G。 + + **文件**:`src/test/java/com/dataelem/gateway/config/DshSafetyBeansConfigurationTest.java`(拟新增)。 + + **逻辑与上下文**:以真实Spring Context验证profile/cache/policy/request manager/两个filter/事件client/dispatcher/telemetry实际组装、单例所有权和close生命周期;完整配置但检查关闭仍可管理策略及投递历史事实;无新配置不初始化新依赖、不影响旧模型/审计,capabilities明确不可用。配置沿现有Bisheng/Redis/IO预算,不另造密钥或回传目标。测试不得仅手工new对象代替容器接线。 + + **验证**:G: mvn -Dtest=DshSafetyBeansConfigurationTest test;缺组装形成实际红测,相邻旧启动/路由对照保留。 + + **覆盖 AC**: AC-01、AC-02、AC-03、AC-05、AC-26、AC-39、AC-41、AC-43、AC-54、AC-55。 + + **依赖**:T098、T090、T092、T094、T096、T158、T160、T162、T164。 + +### Wave 28 + +- [x] **T050 Beat 调度与查询安全水位接线** + + **类型**:实现 · Worker;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/bisheng/worker/config.py`(既有);`src/backend/bisheng/dsh/audit_admin_runtime.py`(既有)。 + + **逻辑与上下文**:把扫描任务加入既有 Beat 配置,审计 query-readiness 暴露安全进度。worker tenant_id 始终通过 Celery headers→ContextVar,实际处理再建严格作用域;新回传配置不成为原 DSH runtime 启动依赖。 + + **验证**:通过配对任务 T049 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-03、AC-39、AC-48、AC-55。 + + **依赖**:T049、T048。 + +- [x] **T139 Gateway Spring 条件组装与生命周期** + + **类型**:实现 · 外部 Gateway;仓库 G。 + + **文件**:`src/main/java/com/dataelem/gateway/config/DshSafetyBeansConfiguration.java`(拟新增);`src/main/java/com/dataelem/gateway/service/DshSafetyOutboxService.java`(既有,仅组装所有权标注)。 + + **逻辑与上下文**:在单一配置入口接通已有生产构造端口,固定同backend目标、既有共享HMAC和UTC时钟,复用Redis及原IO预算;匹配器、cache监听、请求owner、补报器按Spring生命周期启动与关闭。缺配置不创建不必要的新依赖;检查关闭与补报生命周期独立,配置/管理可用性保持明确。禁止以@Configuration文件存在或手动构造测试替代真实Bean验证。移除Outbox既有@Service自动扫描,保持旧构造兼容,仅由配置创建唯一实际observer实例;不以@Primary或RegistryPostProcessor掩盖重复实例。 + + **验证**:通过T138全部断言及原路由/关闭配置回归;复核两个filter实际顺序和每副本唯一manager。 + + **承接 AC**: AC-01、AC-02、AC-03、AC-05、AC-26、AC-39、AC-41、AC-43、AC-54、AC-55。 + + **依赖**:T138、T098。 + +### Wave 29 + +- [x] **T051 明细清理与防复活保留闸门先行测试** + + **类型**:测试 · 后端 Domain;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/test/dsh/test_content_safety_retention.py`(拟新增)。 + + **逻辑与上下文**:只编写测试:默认不清明细、删明细保留摘要、先落删除水位再分批清主行、排除执行中记录、已存在旧主行可补事实、过期重建 410、mode/version 并发不覆盖水位。测试上下文:B 仓库 test/dsh 的租户 101/202、用户 11/22、模型 7/8 合成 fixture;数据库/时钟/对象存储按需要替换,真实双库另验收;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:B: cd src/backend && uv run pytest test/dsh/test_content_safety_retention.py -q。 + + **覆盖 AC**: AC-39、AC-41、AC-43、AC-48、AC-51、AC-52、AC-53。 + + **依赖**:T016、T038、T050。 + +- [x] **T108 会话及目录安全摘要** + + **类型**:实现 · 前端 Platform;仓库 B;目标约 30 分钟。 + + **文件**:`src/frontend/platform/src/pages/SystemPage/dsh/ModelAuditGroup.tsx`(既有);`src/frontend/platform/src/pages/SystemPage/dsh/ModelAuditTree.tsx`(既有)。 + + **逻辑与上下文**:展示own/branch去重命中计数、安全更新时间和PREPARING/scan_limited。全量条目数与筛选命中数区分,未实际计算不显示虚假0;计数用Badge,状态用Tag,沿既有布局。 + + **验证**:父子会话命中传播、worker停用、长会话扫描受限、时间内无命中而历史有命中的组合查询。 + + **承接 AC**: AC-45、AC-47、AC-48、AC-56。 + + **依赖**:T107、T046、T050。 + +### Wave 30 + +- [x] **T052 明细清理与防复活保留闸门** + + **类型**:实现 · 后端 Domain;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/bisheng/dsh/domain/repositories/content_safety_maintenance.py`(拟新增);`src/backend/bisheng/dsh/domain/repositories/model_audit_setting.py`(既有)。 + + **逻辑与上下文**:提供 rebuild_batch 与 purge_batch 的有界 Repository 操作;明细窗口必须由已确认运维参数提供。按 tenant/id/版本更新;默认不开放离散裸删,需要时先 tombstone;事实不可回放后不承诺重建。 + + **验证**:通过配对任务 T051 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-39、AC-41、AC-43、AC-48、AC-51、AC-52、AC-53。 + + **依赖**:T051、T038、T050。 + +- [x] **T109 未关联安全事件入口** + + **类型**:实现 · 前端 Platform;仓库 B;目标约 30 分钟。 + + **文件**:`src/frontend/platform/src/pages/SystemPage/dsh/UnassociatedSafetyEvents.tsx`(拟新增);`src/frontend/platform/src/pages/SystemPage/dsh/ModelAuditPanel.tsx`(既有)。 + + **逻辑与上下文**:增加命名导出的未关联事件列表,沿现有审计权限展示时间、Gateway/backend编号、阶段和策略摘要;限长分页。Panel仅增量接入口,并把父page.progress传入T108的Group/Tree可选进度prop(子树用已有子页progress),不增加逐行GET;不与主请求行混算模型调用或用量,不把真实身份变化当不可信错误。 + + **验证**:未知模型编号、可信身份变化不可关联、历史补报和其他租户访问隔离;保持其他列表筛选。 + + **承接 AC**: AC-37、AC-38、AC-42、AC-45、AC-46、AC-56。 + + **依赖**:T108。 + +- [x] **T170 Shared UI开发依赖解析兼容** + + **类型**:基础设施 · 前端 Platform;仓库 B。 + + **文件**:`src/frontend/platform/vite.config.mts`(既有)。 + + **逻辑与上下文**:正式Vite dev预优化把UI目录的catalog tailwind-merge 1.14错解为Platform 2.6,导致Badge/Tag字号class被删除;生产Rollup不复现。仅限定shared UI的嵌套依赖预优化/解析,保留各包锁定版本,不加库/改共享组件或设计值,不全站升级或给业务组件覆盖样式。 + + **验证**:先保留正式配置实际DOM 12px错误证据,修后同真实helper/Badge/Tag dev为设计10px/12px;生产三语/390预览、旧Platform自身merge语义和scope样式不变,配置新增类型诊断为0。 + + **承接 AC**: AC-45、AC-56。 + + **依赖**:T155、T108。 + +### Wave 31 + +- [x] **T053 审计回填清理运维入口先行测试** + + **类型**:测试 · 后端 Domain;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/test/dsh/test_content_safety_maintenance_cli.py`(拟新增)。 + + **逻辑与上下文**:只编写测试:dry-run 无写入、apply 需显式参数、批次与续跑游标、环境/租户错误退出、保留规则不默认缩短。测试上下文:B 仓库 test/dsh 的租户 101/202、用户 11/22、模型 7/8 合成 fixture;数据库/时钟/对象存储按需要替换,真实双库另验收;被测边界为下个配对实现任务公开的方法或类,缺失能力先形成红测,不填入生产实现。 + + **验证**:B: cd src/backend && uv run pytest test/dsh/test_content_safety_maintenance_cli.py -q。 + + **覆盖 AC**: AC-43、AC-48、AC-51、AC-52、AC-53。 + + **依赖**:T016、T052。 + +- [x] **T110 Platform 设置与审计行为验证** + + **类型**:测试 · 前端 Platform;仓库 B;目标约 30 分钟。 + + **文件**:`src/frontend/platform/src/pages/SystemPage/dsh/DshContentSafetySettings.test.tsx`(拟新增);`src/frontend/platform/src/pages/SystemPage/dsh/ContentSafetyDetails.test.tsx`(拟新增)。 + + **逻辑与上下文**:只增加组件行为测试:CAS冲突保草稿、Gateway不可达、非法空id不崩溃、六态与未知用量、BOTH交集文案、明细清理、显式筛选清游标;使用mock API与现有测试工具,不把mock当E2E。 + + **验证**:B: cd src/frontend && pnpm --filter bisheng test -- src/pages/SystemPage/dsh/DshContentSafetySettings.test.tsx src/pages/SystemPage/dsh/ContentSafetyDetails.test.tsx。 + + **覆盖 AC**: AC-02、AC-07、AC-09、AC-10、AC-34、AC-44、AC-45、AC-46、AC-49、AC-51、AC-56。 + + **依赖**:T104、T109、T170。 + +- [x] **T168 原生审计与接收端删除水位接线先行测试** + + **类型**:测试 · 后端 Domain;仓库 B。 + + **文件**:`src/backend/test/dsh/test_model_audit_retention_guard.py`(拟新增)。 + + **逻辑与上下文**:真实原生writer与安全事实接收端统一租户setting→request锁序;缺setting先建立唯一默认锁行,不让首次cleanup与旧主创建交错越过水位。缺主旧事件拒绝、边界及仍存在合法旧主允许、租户隔离/事务回滚/默认mode/version不改;既有receipt优先语义保留。顺序与故障注入不代双库并发。 + + **验证**:未接线先实际RED,相邻writer/ingest GREEN;配对T169后转绿,真实锁竞争留T119。 + + **覆盖 AC**: AC-41、AC-52、AC-53。 + + **依赖**:T052。 + +- [x] **T171 冷启动测试模块隔离维护** + + **类型**:测试维护 · 后端 Domain;仓库 B。 + + **文件**:`src/backend/test/dsh/test_content_safety_runtime.py`(既有)。 + + **逻辑与上下文**:T031在当前进程删除sys.modules后重导,pytest撤销只恢复模块表而不恢复父package属性/endpoint别名,组合顺序实际36FAIL,HEAD原Setting同样复现。仅把真实冷启动/禁止模型runtime导入检查隔离到子进程,保留惰性组装和session零调用断言;不改API fixture、生产源或放宽HTTP断言。 + + **验证**:同runtime→ingest→API→middleware顺序先保留36FAIL证据,修后全绿;单独冷启动子进程检查与Ruff/架构守卫通过。 + + **覆盖 AC**: AC-03、AC-26、AC-35、AC-42、AC-43。 + + **依赖**:T032、T034。 + +- [x] **T172 Gateway 生产JSON配置契约先行测试** + + **类型**:测试 · Gateway;仓库 G。 + + **文件**:`src/test/java/com/dataelem/gateway/service/DshSafetyOutboxServiceTest.java`(既有);`src/test/java/com/dataelem/gateway/controller/DshSensitivePolicyControllerTest.java`(既有)。 + + **逻辑与上下文**:真实SpringCustomConfig.objectMapper的null→空串设置会使Outbox缺backend/session/conversation被B严格DTO拒绝,capabilities不可用时digest也被Platform拒绝。使用真实生产mapper/codec保留SQL/admin外端口替身,断事件JSON null、hash/标量类型/共享mapper不变及capabilities真实HTTP null;不放宽接收DTO。 + + **验证**:先复现真实配置RED,配对修复转绿并验证Java真实payload可过B严格schema;保留既有应用JSON行为。 + + **覆盖 AC**: AC-02、AC-35、AC-39、AC-41、AC-42、AC-58。 + + **依赖**:T139。 + +- [x] **T174 未关联UUID筛选规范化先行测试** + + **类型**:测试 · 后端 Domain;仓库 B。 + + **文件**:`src/backend/test/dsh/test_content_safety_queries.py`(既有)。 + + **逻辑与上下文**:真实已存UUID为lower,UI明确支持大写,使用真实查询验证Gateway/backend UUID大小写等价、相同规范筛选跨页可续,非法UUID安全拒绝;不冻结默认时间范围或弱化租户隔离。 + + **验证**:原实现大写精确匹配实际RED,配对修复全绿;SQLite只作比较语义,不当DM8结果。 + + **覆盖 AC**: AC-37、AC-38、AC-42、AC-46、AC-49。 + + **依赖**:T109、T040。 + +### Wave 32 + +- [x] **T054 审计回填清理运维入口** + + **类型**:实现 · 后端 Domain;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/scripts/dsh_content_safety_maintenance.py`(拟新增);`src/backend/scripts/README.md`(既有)。 + + **逻辑与上下文**:提供 argparse 子命令 rebuild/purge-details/purge-audit,默认 dry-run,--apply 显式写入且携带 tenant-id/batch/cursor。复用服务 settings 和数据库,不在 Alembic 做数据操作;纯 DB 不初始化其他中间件。模块 docstring、自举 import 与 README 给出从 src/backend 的命令,处理进程有严格租户上下文。 + + **验证**:通过配对任务 T053 的全部断言;保留相邻现有测试。 + + **承接 AC**: AC-43、AC-48、AC-51、AC-52、AC-53。 + + **依赖**:T053、T052。 + +- [x] **T169 原生审计与接收端删除水位接线** + + **类型**:实现 · 后端 Domain;仓库 B。 + + **文件**:`src/backend/bisheng/dsh/domain/repositories/model_audit_write.py`(既有);`src/backend/bisheng/dsh/domain/repositories/content_safety.py`(既有)。 + + **逻辑与上下文**:复用T052 SettingRepository.lock_retention()建立/锁定租户水位行,先setting再request;不存在主行且started_at早于水位禁止重建,已有有效主可继续接收事实。冲突由原caller新事务重试,不在Repo commit、旁路Session或扩大tenant scope;不改变原模型鉴权/额度/计量或原生审计身份规则。 + + **验证**:T168及旧writer/接收回归通过;未配双库的并发结果如实留待T119。 + + **承接 AC**: AC-41、AC-52、AC-53。 + + **依赖**:T168、T052。 + +- [x] **T173 Gateway 局部JSON契约隔离** + + **类型**:实现 · Gateway;仓库 G。 + + **文件**:`src/main/java/com/dataelem/gateway/service/DshSafetyOutboxService.java`(既有);`src/main/java/com/dataelem/gateway/controller/DshSensitivePolicyController.java`(既有)。 + + **逻辑与上下文**:Outbox私有mapper保持标准JSON null而不修改注入全局mapper,事件/hash/HMAC字节不在投递时重写。capabilities局部明确nullable digest序列化,保持全局旧应用serializer与管理/模型协议不变,不用B/Platform接受空串掩盖。 + + **验证**:T172全绿及真实配置payload→B schema通过,全部定向Gateway回归。 + + **承接 AC**: AC-02、AC-35、AC-39、AC-41、AC-42、AC-58。 + + **依赖**:T172、T139。 + +- [x] **T175 未关联UUID查询规范化** + + **类型**:实现 · 后端 Domain;仓库 B。 + + **文件**:`src/backend/bisheng/dsh/domain/repositories/content_safety_queries.py`(既有)。 + + **逻辑与上下文**:仅两UUID显式filter校验完整UUID并lower,cursor绑定同一规范值;保留tenant/query/time语义、索引谓词不在列上调用lower,不改变已确认默认分页窗口取舍。 + + **验证**:T174及旧查询/HTTP回归全绿,错误固定安全文案。 + + **承接 AC**: AC-37、AC-38、AC-42、AC-46、AC-49。 + + **依赖**:T174、T109。 + +### Wave 33 + +- [ ] **T119 MySQL/DM8 事务与迁移集成测试** + + **类型**:测试 · 后端 Domain;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/test/dsh/test_content_safety_database_integration.py`(拟新增);`features/v3.0.0/111-dsh-sensitive-word-control/implementation-verification.md`(拟新增)。 + + **逻辑与上下文**:只增加/执行真实数据库集成测试,分别在MySQL及Linux DM8验证模型发现、DDL fresh/upgrade、schema相等、并发去重/回滚、nullable约束、dirty租约及保留水位。空数据库演练结构回退,有数据回退保护不删除事实;结果追加双库节。 + + **验证**:在隔离集成环境运行该pytest文件并记录方言/版本/迁移head与通过用例;缺DM8环境为未执行,不用SQLite替代。 + + **当前证据**:最终永久测试主独立真实MySQL **16PASS**(58.31s),Linux DM8 **16SKIP/未执行**;fresh/真实归档work110升级/144表反射、实际锁/事务/回退/保留边界已验证MySQL子项,整体保留未勾选。 + + **覆盖 AC**: AC-38、AC-39、AC-41、AC-48、AC-51、AC-52、AC-53。 + + **依赖**:T054、T050、T082、T169、T030。 + +### Wave 34 + +- [ ] **T120 Gateway 双库 Outbox 恢复验证** + + **类型**:测试 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/test/java/com/dataelem/gateway/service/DshSafetyDatabaseIntegrationTest.java`(拟新增);`docs/dsh-content-safety-verification.md`(拟新增)。 + + **逻辑与上下文**:只做真实MySQL/DM8集成测试:CAS首次创建/更新、多副本领取、租约owner、进程重启、未确认事件保留、明确拒绝隔离、ACK丢失不重复计数,核对两份DDL fresh/upgrade和回退保护;不调用有费用的真实模型。 + + **验证**:G: mvn -Dtest=DshSafetyDatabaseIntegrationTest test;独立容器/数据集运行,两种方言分别记录,观察批量领取耗时不超过租约或正确续租。 + + **当前证据**:T119 MySQL子闸门后,主在另一全新专用库通过正式Maven **10PASS/0SKIP**,agent独立库同10PASS;真实子JVM退出/租约、HTTP丢ACK及409隔离、双表回退保护通过。DM8未执行,整体保留未勾选;小表EXPLAIN不当容量验收。 + + **覆盖 AC**: AC-10、AC-39、AC-41、AC-43、AC-51、AC-53、AC-55、AC-58。 + + **依赖**:T098、T119、T139。 + +### Wave 35 + +- [ ] **T121 模型协议 API E2E** + + **类型**:测试 · 后端 API;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/test/dsh/test_content_safety_protocol_e2e.py`(拟新增);`features/v3.0.0/111-dsh-sensitive-word-control/e2e-checklist.md`(拟新增)。 + + **逻辑与上下文**:按/e2e-test skill启动本特性E2E,复用现有模型审计E2E的测试登录与受控上游。测试Chat/Responses×流式/非流式,输入/输出命中、安全回复、可读reasoning、完整历史/内部用途、跨片/最终快照/工具参数、非法输入、检查失败、部署关闭及本地无Gateway。清单只编写协议节,不改后续其他节。 + + **验证**:以真实backend/Gateway响应和审计验证输入命中调用0、正常响应顺序不变、输出BLOCKED仍保留实际用量或UNKNOWN;Desktop真实工具屏障证据来自前驱运行时测试。测试环境地址、登录凭据从环境注入。 + + **覆盖 AC**: AC-01、AC-02、AC-03、AC-05、AC-20、AC-21、AC-22、AC-23、AC-24、AC-25、AC-26、AC-27、AC-28、AC-29、AC-30、AC-31、AC-32、AC-57。 + + **依赖**:T110、T118、T120、T096、T147、T173、T175。 + +### Wave 36 + +- [ ] **T122 策略与身份缓存 API E2E** + + **类型**:测试 · 后端 API;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/test/dsh/test_content_safety_identity_e2e.py`(拟新增);`features/v3.0.0/111-dsh-sensitive-word-control/e2e-checklist.md`(拟新增)。 + + **逻辑与上下文**:只追加身份策略E2E及清单节:A/B租户、管理员/普通用户/超管,读写越权与CAS冲突、空词和提示、词库摘要一致及5秒更新;profile冷/热/刷新/多副本/Redis故障、同凭据首次并发合并、单等待者取消、退出后backend准入拒绝。使用已有合成fixture与受控profile计数,身份变化接受,不增加冻结检查。 + + **验证**:逐案记录请求次数、profile验证次数、最终策略版本和错误类型;关闭/失败/过期分开,原始凭据和正文不进日志。 + + **覆盖 AC**: AC-06、AC-07、AC-08、AC-09、AC-10、AC-11、AC-12、AC-13、AC-14、AC-15、AC-16、AC-17、AC-18、AC-19、AC-35、AC-38、AC-59。 + + **依赖**:T121。 + +### Wave 37 + +- [ ] **T123 审计事实及补报 API E2E** + + **类型**:测试 · 后端 API;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/test/dsh/test_content_safety_recovery_e2e.py`(拟新增);`features/v3.0.0/111-dsh-sensitive-word-control/e2e-checklist.md`(拟新增)。 + + **逻辑与上下文**:只追加审计事实/恢复E2E及清单节:OFF/METADATA仍有最小事实、输入外部条目、输出编号/无编号、事件和原审计竞争、同事件异载荷、乱序命中保持、断开接收/重启Gateway/丢ACK后恢复、关闭检查仍投递、明细清理及删除水位。事件同事务ACK且记录不重复计数。 + + **验证**:操作均在隔离fixture中,核对SQL持久事件/主摘要/Outbox状态及真实用量;无效服务签名拒绝,历史凭据退出不阻断真实补报。 + + **覆盖 AC**: AC-33、AC-34、AC-35、AC-36、AC-37、AC-38、AC-39、AC-40、AC-41、AC-42、AC-43、AC-51、AC-52、AC-53、AC-55、AC-58。 + + **依赖**:T122。 + +### Wave 38 + +- [ ] **T124 审计页面及目录手工验收** + + **类型**:验收 · 前端 Platform;仓库 B;目标约 30 分钟。 + + **文件**:`features/v3.0.0/111-dsh-sensitive-word-control/e2e-checklist.md`(拟新增);`features/v3.0.0/111-dsh-sensitive-word-control/implementation-verification.md`(拟新增)。 + + **逻辑与上下文**:只追加页面与隔离节:按测试用户实际打开/dsh,组合命中/方向/状态/用户/模型/7天及31天窗口,详情三个时间/未知/N/A/PURGED,未关联事件、父子目录own/branch、worker停止readiness与scan_limited;三语言设置及Desktop错误实际可见。验证公开入口均经Gateway及旧应用敏感词回归。 + + **验证**:以真实API返回和页面操作验证,不以截图或mock替代;跨租户/普通用户拒绝、默认时间翻页沿原行为,清单逐项链接自动测试或手工结果,未执行项不能勾选。 + + **覆盖 AC**: AC-04、AC-07、AC-08、AC-34、AC-37、AC-38、AC-44、AC-45、AC-46、AC-47、AC-48、AC-49、AC-50、AC-51、AC-54、AC-56。 + + **依赖**:T123。 + +### Wave 39 + +- [ ] **T125 E2E 覆盖与执行结果核对** + + **类型**:验收 · 后端 API;仓库 B;目标约 30 分钟。 + + **文件**:`features/v3.0.0/111-dsh-sensitive-word-control/e2e-checklist.md`(拟新增);`features/v3.0.0/111-dsh-sensitive-word-control/implementation-verification.md`(拟新增)。 + + **逻辑与上下文**:汇总前四项和Desktop运行时证据,逐条核对59条AC的自动/手工覆盖,按/e2e-test流程完成结束报告;仅补完整性与运行结果节,不重复编写前驱用例或修改实现。 + + **验证**:执行新增三份HTTP E2E文件并核对页面清单、真实进程重启和双协议工具执行计数;全部结果需有环境/命令/时间证据,无环境为未执行。 + + **覆盖 AC**: AC-01、AC-02、AC-03、AC-04、AC-05、AC-06、AC-07、AC-08、AC-09、AC-10、AC-11、AC-12、AC-13、AC-14、AC-15、AC-16、AC-17、AC-18、AC-19、AC-20、AC-21、AC-22、AC-23、AC-24、AC-25、AC-26、AC-27、AC-28、AC-29、AC-30、AC-31、AC-32、AC-33、AC-34、AC-35、AC-36、AC-37、AC-38、AC-39、AC-40、AC-41、AC-42、AC-43、AC-44、AC-45、AC-46、AC-47、AC-48、AC-49、AC-50、AC-51、AC-52、AC-53、AC-54、AC-55、AC-56、AC-57、AC-58、AC-59。 + + **依赖**:T124。 + +### Wave 40 + +- [ ] **T126 双库审计查询容量测试** + + **类型**:测试 · 后端 Domain;仓库 B;目标约 30 分钟。 + + **文件**:`src/backend/test/dsh/test_content_safety_query_capacity.py`(拟新增);`features/v3.0.0/111-dsh-sensitive-word-control/implementation-verification.md`(拟新增)。 + + **逻辑与上下文**:只构造隔离容量数据与查询负载:百万/千万主请求、每请求1–3事件、租户倾斜/命中率、组合筛选/深游标/长会话;分别测MySQL/DM8执行计划、扫描行数、p95/p99、锁和索引体积。长时间造数/运行使用可恢复会话,结果只写查询容量节。 + + **验证**:与同环境基线比较;默认列表不扫全事件或逐行查询、不做全历史COUNT。暂不编造SLO,实测不足时报告限制,不用SQLite性能代替。 + + **覆盖 AC**: AC-26、AC-45、AC-48、AC-49、AC-50、AC-53。 + + **依赖**:T125。 + +- [ ] **T127 profile 冷热和缓存故障负载测试** + + **类型**:测试 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/test/java/com/dataelem/gateway/service/DshProfileLoadTest.java`(拟新增);`docs/dsh-content-safety-verification.md`(拟新增)。 + + **逻辑与上下文**:只测profile冷/热/Token刷新、多副本、同键首次并发、单等待者取消和Redis故障;记录额外profile调用量/SQL读取、等待和失败上限,复用现有可控服务器与时钟,不按SSE片段放大验证次数。只追加profile负载节。 + + **验证**:G: mvn -Dtest=DshProfileLoadTest test;证明30秒绝对有效期、32/64容量和1秒总预算,记录QPS及未命中率,不把缓存收益误当模型鉴权开销消失。 + + **覆盖 AC**: AC-13、AC-14、AC-15、AC-18、AC-19、AC-59。 + + **依赖**:T125。 + +- [ ] **T128 Gateway 流式资源和续读负载测试** + + **类型**:测试 · 外部 Gateway;仓库 G;目标约 30 分钟。 + + **文件**:`src/test/java/com/dataelem/gateway/service/DshSafetyCapacityTest.java`(拟新增);`docs/dsh-content-safety-verification.md`(拟新增)。 + + **逻辑与上下文**:只测Chat/Responses长流、交错域/工具HOL、首字延迟、64MiB编码预算和实际JVM堆/GC、16检查/8续读、普通取消及命中后30秒终止;记录上游只有一次请求/订阅和DataBuffer/配额回收。只追加流式资源节。 + + **验证**:G: mvn -Dtest=DshSafetyCapacityTest test;实测后根据目标堆余量下调预算或按SDD评审调整,不为指标提前释放未检查文本,不声明供应商已停止计费。 + + **覆盖 AC**: AC-11、AC-19、AC-26、AC-29、AC-32、AC-43、AC-57。 + + **依赖**:T125。 + +- [ ] **T130 三语生成及前端质量门禁** + + **类型**:验收 · 前端 Platform;仓库 B;目标约 30 分钟。 + + **文件**:`features/v3.0.0/111-dsh-sensitive-word-control/implementation-verification.md`(拟新增)。 + + **逻辑与上下文**:在前端质量节记录三语言源键与backend错误码覆盖,按唯一生成流程更新api_errors生成物;手改文件仍限本任务声明范围,生成物由命令统一产生并在交付diff单列,不手改。对触及旧硬编码的实现执行提取,对已修复旧违规按规则prune。 + + **验证**:B: cd src/frontend && pnpm locales:build;pnpm check-i18n;pnpm --filter @bisheng/locales check;pnpm lint;pnpm typecheck;必要时各app pnpm lint:prune。不得修改suppression扩大基线或新增ts-ignore。 + + **覆盖 AC**: AC-03、AC-44、AC-56。 + + **依赖**:T110、T125。 + +### Wave 41 + +- [ ] **T129 容量证据汇总与支持边界登记** + + **类型**:验收 · 后端 Domain;仓库 B;目标约 30 分钟。 + + **文件**:`features/v3.0.0/111-dsh-sensitive-word-control/implementation-verification.md`(拟新增)。 + + **逻辑与上下文**:只汇总双库查询、profile和流式三个前驱结果到容量节,记录支持负载、初始预算的实际堆放大、扫描限制和未解决的运行限制,不增加新的SLO或功能要求。 + + **验证**:核对两个数据库均有真实计划、深分页/并发写入和续读压力证据;长运行的会话/日志路径可追溯,缺失的环境不写已验证。 + + **覆盖 AC**: AC-11、AC-14、AC-15、AC-18、AC-19、AC-26、AC-43、AC-45、AC-48、AC-49、AC-50、AC-53、AC-57、AC-59。 + + **依赖**:T126、T127、T128。 + +### Wave 42 + +- [ ] **T131 启用、入口封闭与回退演练手册** + + **类型**:验收 · 后端 API;仓库 B;目标约 30 分钟。 + + **文件**:`features/v3.0.0/111-dsh-sensitive-word-control/rollout-runbook.md`(拟新增);`features/v3.0.0/111-dsh-sensitive-word-control/implementation-verification.md`(拟新增)。 + + **逻辑与上下文**:只编写/在隔离环境演练backend schema/DTO→投影worker→平台/Desktop→Gateway默认关闭→测试租户启用的顺序。记录所有公开Chat/Responses入口经Gateway、backend模型端口受信网络、共享HMAC配置/签名头一致、NTP、词库摘要、观察指标。回退先关闭新请求,保留在途快照/未确认投递/模型用量和证据;旧DTO回退须有外部条目兼容证明。 + + **验证**:演练账号/主机从环境注入,不在文档保存密钥。检查开启后的旁路访问被网络阻断、停用及恢复后补报继续;本任务不自行授权生产迁移或部署。 + + **覆盖 AC**: AC-02、AC-03、AC-04、AC-11、AC-12、AC-16、AC-17、AC-32、AC-43、AC-51、AC-54、AC-55、AC-58。 + + **依赖**:T129、T130。 + +### Wave 43 + +- [ ] **T132 特性代码审查与交付证据整理** + + **类型**:验收 · 后端 API;仓库 B;目标约 30 分钟。 + + **文件**:`features/v3.0.0/111-dsh-sensitive-word-control/code-review.md`(拟新增);`features/v3.0.0/111-dsh-sensitive-word-control/implementation-verification.md`(拟新增)。 + + **逻辑与上下文**:未来实现完成后按/sdd-code-review对真实目标发布线执行特性审查;汇总本地静态/单元、MySQL/DM8、E2E、Desktop工具屏障、性能和回退证据,未执行项明确标记。跨仓库基线与共享文件影响逐项核验,不由features目录或测试计划推断交付。 + + **验证**:使用用户届时指定的目标ref,运行任务级/task-review和适用仓库质量检查,记录实际SHA;不得在本轮创建PR、提交、推送或合并。 + + **覆盖 AC**: AC-01、AC-02、AC-03、AC-04、AC-05、AC-06、AC-07、AC-08、AC-09、AC-10、AC-11、AC-12、AC-13、AC-14、AC-15、AC-16、AC-17、AC-18、AC-19、AC-20、AC-21、AC-22、AC-23、AC-24、AC-25、AC-26、AC-27、AC-28、AC-29、AC-30、AC-31、AC-32、AC-33、AC-34、AC-35、AC-36、AC-37、AC-38、AC-39、AC-40、AC-41、AC-42、AC-43、AC-44、AC-45、AC-46、AC-47、AC-48、AC-49、AC-50、AC-51、AC-52、AC-53、AC-54、AC-55、AC-56、AC-57、AC-58、AC-59。 + + **依赖**:T131。 + +### 交付整合补充(2026-10-24追加授权) + +下列为合入最新work120所需的兼容维护,不改变已确认租户/HMAC/分页/可信身份语义。原Wave1–43证据按当时基线保留。新增交付顺序:T176/T177/T178并行、T181/T182/T183/T184、T179、T180;DM8与完整环境结果分别记账。用户本次授权测试环境部署后补真实客户端/E2E验收,不将候选版本部署等同T132最终通过。 + +- [x] **T176 接续最新积分迁移并保持唯一head** + + **类型**:基础设施 · 仓库B交付worktree。 + + **文件**:`src/backend/bisheng/core/database/alembic/versions/v3_0_0_f111_dsh_content_safety.py`;`src/backend/test/database/test_alembic_dsh_main_upgrade.py`。 + + **逻辑与验证**:真实组合图已RED为两个head;F111未对受支持环境发布、109当前work110,接到target真实f062_credit_multiplier;保留已发布revision哈希,实际head及迁移回归、双库静态可移植自审。 + + **依赖**:T008、T016。 + +- [x] **T177 保留新版登录与安全回传入口** + + **类型**:实现 · 后端API;仓库B交付worktree。 + + **文件**:`src/backend/bisheng/dsh/api/router.py`;`src/backend/bisheng/utils/http_middleware.py`。 + + **逻辑与验证**:逐块合并,保留target全部capabilities/skills/work/credits/web-session路由和身份校验、F111精确HMAC回传路由;原登录/strict scope及接收中间件配对回归。 + + **依赖**:T032、T034。 + +- [x] **T178 保留积分默认页和敏感词导航** + + **类型**:实现/测试 · 前端Platform;仓库B交付worktree。 + + **文件**:`src/frontend/platform/src/pages/DshPage/sections.ts`;`src/frontend/platform/src/pages/DshPage/dshPageNavigation.test.tsx`。 + + **逻辑与验证**:组合七页签,保持target credits默认/非法回退和F111 content-safety入口,原operations继续隐藏;真实导航Vitest、类型和三语检查,不改组件视觉。 + + **依赖**:T104、T110。 + +- [x] **T179 最终组合的真实迁移与积分安全联合回归** + + **类型**:测试 · 后端Domain;仓库B交付worktree。 + + **文件**:`src/backend/test/dsh/test_content_safety_database_integration.py`;`src/backend/test/dsh/test_content_safety_credit_integration.py`(拟新增)。 + + **逻辑与验证**:保留真实work110归档升级,增加真实target归档已应用信用倍率/个人额度数据升级,与fresh组合schema比较;输出命中不改变倍率/原始用量/未知及迟到幂等,结合target计量原有回归。MySQL实际执行,DM8继续待环境;不得用当前模型伪造旧schema。 + + **依赖**:T176、T177、T178。 + +- [ ] **T180 集成候选版本审查与定向交付记录** + + **类型**:验收/运维 · 三仓交付。 + + **文件**:`features/v3.0.0/111-dsh-sensitive-word-control/code-review.md`;`features/v3.0.0/111-dsh-sensitive-word-control/deployment-execution.md`。 + + **逻辑与验证**:以最新目标ref做七维候选审查,修复HIGH;记录源/合并/远端SHA、Drone构建和镜像digest、115步骤跳过、109 DSH专用备份与仅目标服务更新、真实客户端联调与剩余环境边界。原T132完整特性验收另按完整证据,不伪勾。 + + **依赖**:T179。 + +- [x] **T181 修正 bootstrap 完整模型集合夹具** + + **类型**:测试。**文件**:`src/backend/test/database/test_dsh_audit_work110_migrations.py`。 + + **逻辑与验证**:原源树实际复现15表常量失配;改验真实模型集合、无重复及反射集合精确相等,保留列/索引/存量/幂等断言;普通入口7文件37 PASS。**依赖**:T176。 + +- [x] **T182 补齐目标分支日语统一策略文案** + + **类型**:兼容维护。**文件**:`src/frontend/platform/public/locales/ja/bs.json`。 + + **逻辑与验证**:按现有中英文及实际策略语义补12个缺键;不改旧值或baseline;三语检查和相邻测试。**依赖**:T178。 + +- [x] **T183 补齐 sandbox 错误中文和英文** + + **类型**:兼容维护。**文件**:`src/frontend/packages/locales/src/api_errors/zh-Hans.json`;`src/frontend/packages/locales/src/api_errors/en.json`。 + + **逻辑与验证**:按既有28001–28009真实业务调用补齐错误文案,使用仓库生成器同步衍生产物,不手编辑产物或baseline。**依赖**:T178。 + +- [x] **T184 补齐 sandbox 错误日语并验收三语** + + **类型**:兼容维护。**文件**:`src/frontend/packages/locales/src/api_errors/ja.json`。 + + **逻辑与验证**:与T183同义的九项日语;运行生成器、check-i18n及locale校验。**依赖**:T183。 + +- [x] **T185 Responses 同消息连续文本块漏检回归** + + **类型**:测试 · Gateway。**文件**:Gateway `src/test/java/com/dataelem/gateway/protocol/DshResponsesSafetyAdapterTest.java`。 + + **逻辑与验证**:实际候选私有Java复现相邻output_text、reasoning.content、reasoning.summary拆分dan/ger流及非流均错误通过;按AC-21、AC-22、AC-23固化RED,同一通道连续块拼接、不同item/channel隔离,done/final前不泄漏。**依赖**:T073。 + +- [x] **T186 Responses 连续文本块按逻辑域检查** + + **类型**:实现 · Gateway。**文件**:Gateway `src/main/java/com/dataelem/gateway/protocol/DshResponsesSafetyAdapter.java`。 + + **逻辑与验证**:修复T185确证HIGH,保持有界扫描、乱序/重复快照校验及协议边界;配对和Gateway全部定向回归,七维重审。**依赖**:T185。 + +- [x] **T187 冻结配置夹具恢复与联合回归** + + **类型**:测试。**文件**:`src/backend/test/dsh/test_chat_credits.py`。 + + **逻辑与验证**:target原测试直接修改frozen DshSettings,异常导致monkeypatch撤销中断并污染后续租户断言。仅在fixture对settings.__dict__置换不可变配置副本并自动恢复,保留全部实际chat计量断言;组合162 PASS及全部相关回归。**依赖**:T177。 + +- [x] **T188 策略保存失败不记录完整草稿** + + **类型**:实现/测试。**文件**:`src/frontend/platform/src/pages/SystemPage/dsh/DshContentSafetySettings.tsx`;`src/frontend/platform/src/pages/SystemPage/dsh/DshContentSafetySettings.test.tsx`。 + + **逻辑与验证**:开发模式react-query默认logger可能输出AxiosError.config.data里的自定义词/回复;局部安全query client禁用原始error logging并保留现有错误反馈,补网络/409回归。**依赖**:T110。 + +- [x] **T189 模型开始时间中文及英文标签** + + **类型**:兼容维护。**文件**:`src/frontend/platform/public/locales/zh-Hans/bs.json`;`src/frontend/platform/public/locales/en-US/bs.json`。 + + **逻辑与验证**:增加独立模型执行开始时间的业务标签,不混用请求开始,复用既有未知/不适用文案。**依赖**:T178。 + +- [x] **T190 模型开始时间日语标签** + + **类型**:兼容维护。**文件**:`src/frontend/platform/public/locales/ja/bs.json`。 + + **逻辑与验证**:补齐T189同义日语,全check-i18n通过。**依赖**:T189。 + +- [x] **T191 请求详情区分模型开始时间** + + **类型**:实现/测试。**文件**:`src/frontend/platform/src/pages/SystemPage/dsh/ModelAuditCard.tsx`;`src/frontend/platform/src/pages/SystemPage/dsh/ModelAuditCard.test.tsx`。 + + **逻辑与验证**:AC-46要求稳定request_started与独立model_started可辨;已用真实组件复现字段存在却未显示。沿既有详情dl行显示模型开始/未知/未执行,保留请求排序与原时间,不新增视觉样式。配对RED/GREEN及相邻回归。**依赖**:T189、T190、T106。 + +- [x] **T192 Desktop Undici 既有补丁接收者兼容修复** + + **类型**:基础设施/测试 · Desktop。**文件**:Desktop `patches/undici+8.11.2.patch`;`test/undici-legacy-headers.test.mjs`。 + + **逻辑与验证**:完整现行客户端回归确证Object.create(handler/controller)使private-field方法接收者失配,三请求超时及unhandled errors;原patch/test/lock均等main,仍是当前联调阻断。仅修回调转发接收者并覆盖私有字段/abort/resume/trailers,标准patch重建重放,不手留node_modules补丁、不升级依赖;临时3.11 PATH运行完整回归。**依赖**:T118。 + +- [x] **T193 Gateway 最终修复验证文档同步** + + **类型**:文档维护 · Gateway。**文件**:Gateway `docs/dsh-content-safety-verification.md`。 + + **逻辑与验证**:同步T185/T186最终60配对/主独立882回归、两次独立语义对照,明确旧108eb5c8不可部署及真实环境待验;与B design/code-review交叉一致,无凭据。**依赖**:T186。 + +- [x] **T194 真实HTTP验收共享helper接入** + + **类型**:测试基础设施。**文件**:`src/backend/test/e2e/helpers/dsh.py`;`src/backend/test/e2e/helpers/dsh_content_safety.py`。 + + **逻辑与验证**:复用真实RSA登录会话和原S256 PKCE,支持精确API/Origin且不破坏旧调用,live缺配置fail-loud;只读0600显式session,测试资源ID/前缀登记和GET确认/CAS恢复,无JWT伪造/生产SQL写。创建offline测试模型前核既有全局默认引用,避免set_default_model自动改变空默认。**依赖**:T179。 + +- [x] **T195 无费用受控Provider测试设施** + + **类型**:测试基础设施。**文件**:`src/backend/test/e2e/helpers/dsh_content_safety_provider.py`。 + + **逻辑与验证**:stdlib真实HTTP Chat/Responses流与非流,确定场景/有界调用计数,只有合成随机test key、opaque marker,无外部模型、原文/凭据日志。仅109 DSH私网临时容器,控制面只loopback经SSH隧道;不得接入其他部署。**依赖**:T194。 + +- [x] **T196 真实上游全局计数与独立快照场景** + + **类型**:测试基础设施。**文件**:`src/backend/test/e2e/helpers/dsh_content_safety_provider.py`;`src/backend/test/e2e/helpers/dsh_content_safety.py`。 + + **逻辑与验证**:负向零scenario计数无法排除marker丢失,增认证的有界全局物理model POST计数(含未标记/unknown/非法JSON/拒绝,排除control/health);T121同时核global+scenario及正例marker保真。严格final_parts/done_parts/emit_deltas合成场景覆盖安全delta、最终新增命中,不在E2E里替换生产scanner。真实loopback先RED后GREEN并保留原27/33;无额外业务API。**依赖**:T194、T195。 + +- [x] **T197 离线模型治理元数据快照与鉴权配对** + + **类型**:兼容修复/测试。**文件**:`src/backend/bisheng/llm/domain/services/llm.py`;`src/backend/test/dsh/test_model_visibility_metadata.py`。 + + **逻辑与验证**:真实源码确认管理visibility复用调用快照,offline被拒,无法在上线前配置私有范围;新增独立治理元数据读取,复用既有tenant/Root FGA/provider归属校验,模型调用快照继续要求online。先RED后GREEN覆盖offline治理、调用拒绝及跨租户边界。**依赖**:T179。 + +- [x] **T198 管理可见性接入治理快照** + + **类型**:兼容修复/测试。**文件**:`src/backend/bisheng/dsh/api/endpoints/unified_policy.py`;`src/backend/test/dsh/test_model_visibility_metadata.py`。 + + **逻辑与验证**:仅在既有admin authorize/profile_scope后用治理快照,允许offline GET/PUT visibility后再上线;CAS和模型调用鉴权不变,不制造默认公开可用窗口。精确API配对与相邻回归。**依赖**:T197。 + +- [x] **T199 真实旧管理拒绝业务码映射回归** + + **类型**:测试 · Gateway。**文件**:Gateway `src/test/java/com/dataelem/gateway/client/DshSafetyAdminClientTest.java`。 + + **逻辑与验证**:109普通用户实际HTTP200/status_code19801,当前client误映射设施503。真实loopback先RED,固化明确19801→403和未知业务码仍503,保留401/403与管理短路。**依赖**:T066。 + +- [x] **T200 管理拒绝码精确映射** + + **类型**:实现/测试 · Gateway。**文件**:Gateway `src/main/java/com/dataelem/gateway/client/DshSafetyAdminClient.java`;`src/test/java/com/dataelem/gateway/client/DshSafetyAdminClientTest.java`。 + + **逻辑与验证**:只将已确证19801与原401/403归为admin_forbidden403,未知业务/设施失败保持503,不扩大授权或猜测码段。原管理Client/controller配对与相邻回归,最终新DSH tag CI后仅109更新。**依赖**:T199。 + +- [x] **T201 Gateway 管理拒绝兼容交付验证记录** + + **类型**:文档。**文件**:Gateway `docs/dsh-content-safety-verification.md`。 + + **逻辑与验证**:保留前882历史证据,追加T199四RED→T200原Client20/Controller24全部通过、34类891PASS与独立44/三向loopback;记录真实109修前503和待新tag镜像复验,不输出凭据,不冒替许可或完整E2E通过。**依赖**:T200。 + +- [x] **T202 Desktop 真实工具Provider协议配对测试** + + **类型**:测试设施。**文件**:`src/backend/test/dsh/test_content_safety_provider_tools.py`。 + + **逻辑与验证**:现Provider固定lookup不是真实Harness工具;实际注册read,须正例真实执行一次再验证命中零调度。四协议真实loopback先RED覆盖默认lookup兼容、声明工具/匹配call_id及真实tool_result后final text、跨scenario/协议/伪历史拒绝和有界预算。这里只证明HTTP设施,不能冒真实文件工具执行。**依赖**:T196。 + +- [x] **T203 有界真实工具名与结果后终态测试设施** + + **类型**:测试设施实现。**文件**:`src/backend/test/e2e/helpers/dsh_content_safety_provider.py`;`src/backend/test/dsh/test_content_safety_provider_tools.py`。 + + **逻辑与验证**:仅test-only Provider增合法tool_name(默认lookup)与可选after_tool_text;后者计入原32KiB总合成文本预算,只有同scenario/协议已发call_id与name/JSON参数/真实结果匹配才final text,≤16标量关联且线程安全,不存payload/token。原默认协议/计数回归保持;现场35结束后才更新唯一测试Provider,不中断运行套件。**依赖**:T202。 + +- [x] **T204 预转发非法输入边界断言配对** + **类型**:测试;**目标文件**:B `src/backend/test/dsh/test_content_safety_boundary_e2e.py`。 + **覆盖 AC**:AC-25、AC-26、AC-33、AC-34。 + **逻辑与验证**:先本地反例验证 raw duplicate/非法 JSON/unknown 输入与精确32MiB+1字节;真实响应身份、无Backend header、物理global/scenario0及完整空审计分页不能误判。**依赖**:T196。 + +- [x] **T205 真实预转发边界 E2E 接入** + **类型**:测试;**目标文件**:B `src/backend/test/dsh/test_content_safety_boundary_e2e.py`。 + **覆盖 AC**:AC-25、AC-26、AC-33、AC-34。 + **逻辑与验证**:复用冻结原生PKCE/自有模型/租户策略CAS fixture,七请求在真实109串行执行;无上游/无账本变化/两轮精确空审计,不把请求解析拒绝虚构成内容安全ERROR事件。**依赖**:T204。 + + +### Wave 44 — 2026-10-27 用户确认的采集边界与说明 + +- [x] **T206 租户关闭时不写安全事件的回归** + **类型**:测试。**目标文件**:G `src/test/java/com/dataelem/gateway/service/DshSafetyRequestManagerTest.java`。 + **覆盖 AC**:AC-60。**逻辑与验证**:Chat/Responses × 流式/非流式,关闭租户策略后正文及凭据原样转发、仅一次模型调用、无 Outbox 写入;原“关闭仍生成 DISABLED 事件”断言按用户新约定替换。 +- [x] **T207 停止关闭租户的新安全事件采集** + **类型**:实现。**目标文件**:G `src/main/java/com/dataelem/gateway/service/DshSafetyRequestManager.java`。 + **逻辑与验证**:仅在至少一个检查方向开启时创建 Recorder,不让 Gateway 感知 backend 审计模式;已有积压回传、模型权限和计量不变。**依赖**:T206。 +- [x] **T208 常驻采集说明的回归** + **类型**:测试。**目标文件**:B `src/frontend/platform/src/pages/SystemPage/dsh/ModelAuditMode.test.tsx`。 + **覆盖 AC**:AC-61。**逻辑与验证**:三种采集模式 × 可配置/只读用户,说明无需展开设置即可见,不触发保存。 +- [x] **T209 审计采集状态旁常驻说明** + **类型**:实现。**目标文件**:B `src/frontend/platform/src/pages/SystemPage/dsh/ModelAuditMode.tsx`。 + **逻辑与验证**:复用现有辅助文字布局,说明两项开关组合及正文不采集语义,状态栏关联说明;无新 API。**依赖**:T208、T210、T211。 +- [x] **T210 中文与英文采集说明** + **类型**:实现。**目标文件**:B `src/frontend/platform/public/locales/zh-Hans/bs.json`;`src/frontend/platform/public/locales/en-US/bs.json`。 + **逻辑与验证**:新增 collectionHint,修正原 modeHint,两个语言 JSON 与键对齐检查。 +- [x] **T211 日文采集说明** + **类型**:实现。**目标文件**:B `src/frontend/platform/public/locales/ja/bs.json`。 + **逻辑与验证**:与中英文同义的 collectionHint/modeHint,三语键对齐检查。 + +### Wave 45 — 2026-10-27 统一模型请求编号 + +- [x] **T212 backend 请求编号和转发异常摘要回归** + **类型**:测试。**目标文件**:B `src/backend/test/dsh/test_unified_request_id.py`。 + **覆盖 AC**:AC-62、AC-63。**逻辑与验证**:双协议/两种流模式透传、缺失/非法/多值/零 UUID 回退;真实 Chat 服务响应/错误/计量共用 ID;鉴权不被绕过;无响应头安全事实按会话关联摘要且不伪造模型/用量。 +- [x] **T213 backend 通用 HTTP UUID 复用** + **类型**:实现。**目标文件**:B `src/backend/bisheng/dsh/api/responses.py`。 + **逻辑与验证**:唯一规范非零 UUID 转小写复用,其他情况生成;沿既有 request.state 下传,不引入 Gateway 配置。**依赖**:T212。 +- [x] **T214 Gateway 透传和无响应头关联回归** + **类型**:测试。**目标文件**:G `src/test/java/com/dataelem/gateway/client/DshModelProxyClientTest.java`;`src/test/java/com/dataelem/gateway/service/DshSafetyRequestManagerTest.java`。 + **覆盖 AC**:AC-62、AC-63。**逻辑与验证**:双协议真实回环 HTTP,覆盖客户端多值/Connection 指定头的覆盖、收到请求后未发响应头即断开、缺失非法响应头不抹去编号、旧 backend 不同编号兼容、输入阻断及正常正文不变。 +- [x] **T215 Gateway 转发前固定编号** + **类型**:实现。**目标文件**:G `src/main/java/com/dataelem/gateway/client/DshModelProxyClient.java`;`src/main/java/com/dataelem/gateway/service/DshSafetyRequestManager.java`。 + **逻辑与验证**:显式 UUID 参数经 hop-by-hop 过滤后写入;转发尝试之前记录 backend ID,失败或取消仍可关联;旧响应明确返回不同合法编号时兼容,缺失非法值不清空。**依赖**:T214。 +- [x] **T216 Gateway 过滤器配对调用夹具维护** + **类型**:测试。**目标文件**:G `src/test/java/com/dataelem/gateway/filter/DshContentSafetyFilterTest.java`;`src/test/java/com/dataelem/gateway/filter/DshModelRoutingFilterTest.java`。 + **覆盖 AC**:AC-62。**逻辑与验证**:仅对齐 Proxy 的显式 UUID 参数,保留原鉴权/路由/正文断言。**依赖**:T215。 +- [x] **T217 Gateway 配置和遥测调用夹具维护** + **类型**:测试。**目标文件**:G `src/test/java/com/dataelem/gateway/config/DshSafetyRoutingConfigurationTest.java`;`src/test/java/com/dataelem/gateway/service/DshRequestTelemetryTest.java`。 + **覆盖 AC**:AC-62。**逻辑与验证**:仅对齐显式 UUID 参数,不改变配置装配、遥测或关闭时不采集的语义。**依赖**:T215。 +- [x] **T218 统一编号交叉自审与本地验证记录** + **类型**:文档/验收。**目标文件**:B `features/v3.0.0/111-dsh-sensitive-word-control/implementation-verification.md`;G `docs/dsh-content-safety-verification.md`。 + **逻辑与验证**:逐文件 task-review、两仓回归、Ruff/架构检查与设计引用核验;如遇既有失败,以 HEAD 源复验作对照,不冒充真实部署/双库/完整 E2E。**依赖**:T213、T215、T216、T217。 + +### Wave 46 — 2026-10-27 移除未关联检查界面 + +- [x] **T219 审计界面移除入口的配对测试** + **类型**:测试。**目标文件**:B `src/frontend/platform/src/pages/SystemPage/dsh/ContentSafetyDetails.test.tsx`。 + **覆盖 AC**:AC-64。**逻辑与验证**:三种既有审计视图都无未关联检查入口、区域和独立事件查询;未关联请求仍按原条件查询,保留请求内检查详情测试;删除已废弃独立页面的专属用例。 +- [x] **T220 移除独立检查页面与切换状态** + **类型**:实现。**目标文件**:B `src/frontend/platform/src/pages/SystemPage/dsh/ModelAuditPanel.tsx`;删除 `src/frontend/platform/src/pages/SystemPage/dsh/UnassociatedSafetyEvents.tsx`。 + **逻辑与验证**:直接渲染既有请求页并保留租户/身份key隔离,移除入口、隐藏缓存页和专属查询组件;会话、未关联请求及请求检查详情保留。**依赖**:T219。 +- [x] **T221 清理前端专属查询与类型** + **类型**:实现。**目标文件**:B `src/frontend/platform/src/controllers/API/dshContentSafety.ts`;`src/frontend/platform/src/types/dshContentSafety.ts`。 + **逻辑与验证**:删除唯一独立未关联事件查询封装及其专用query类型;保留请求内事件查询和DTO;不改backend事件入库/查询/投影或数据库。**依赖**:T220。 +- [x] **T222 删除中文与英文废弃页面文案** + **类型**:实现。**目标文件**:B `src/frontend/platform/public/locales/zh-Hans/bs.json`;`src/frontend/platform/public/locales/en-US/bs.json`。 + **逻辑与验证**:只移除独立页面标题、说明、专属筛选提示及两个专属校验文案,不动请求内事件关联状态文案。**依赖**:T220。 +- [x] **T223 删除日文废弃页面文案** + **类型**:实现。**目标文件**:B `src/frontend/platform/public/locales/ja/bs.json`。 + **逻辑与验证**:与T222一致删除五个专属key,验证三语键对齐。**依赖**:T222。 +- [x] **T224 界面移除的验证和任务自审记录** + **类型**:文档/验收。**目标文件**:B `features/v3.0.0/111-dsh-sensitive-word-control/implementation-verification.md`。 + **逻辑与验证**:定向DSH组件回归、typecheck、目标lint/i18n及构建;更新spec/design的产品边界,核对无数据清理/数据库结构改动,记录完整lint既有问题。**依赖**:T221、T222、T223。 + +### Wave 47 — 2026-10-27 简化审计导航并展示命中词 + +- [x] **T225 移除导航和命中词展示配对测试** + **类型**:测试。**目标文件**:src/frontend/platform/src/pages/SystemPage/dsh/ContentSafetyDetails.test.tsx;src/frontend/platform/src/pages/SystemPage/dsh/ModelAuditTree.test.tsx。 + **逻辑与验证**:AC-65、AC-66;两视图无请求明细/返回入口,树上限无跳转,旧值提示/安全文本/严格API校验。**依赖**:无。 +- [x] **T226 移除请求明细视图与树跳转** + **类型**:实现。**目标文件**:src/frontend/platform/src/pages/SystemPage/dsh/ModelAuditPanel.tsx;src/frontend/platform/src/pages/SystemPage/dsh/ModelAuditTree.tsx。 + **逻辑与验证**:删除第三视图、导航及返回状态,保留内嵌请求和32层限制提示。**依赖**:T225。 +- [x] **T227 清理会话和请求卡导航端口** + **类型**:实现。**目标文件**:src/frontend/platform/src/pages/SystemPage/dsh/ModelAuditCard.tsx;src/frontend/platform/src/pages/SystemPage/dsh/ModelAuditGroup.tsx。 + **逻辑与验证**:删除无消费者的onNavigate并保留元信息/原文展开。**依赖**:T226。 +- [x] **T228 迁移既有页面和筛选测试入口** + **类型**:测试。**目标文件**:src/frontend/platform/src/pages/SystemPage/dsh/ModelAuditPanel.test.tsx;src/frontend/platform/src/pages/SystemPage/dsh/ModelAuditFilters.test.tsx。 + **逻辑与验证**:AC-65;改用未关联请求并明确保留UNASSOCIATED查询断言。**依赖**:T226。 +- [x] **T229 请求卡导航夹具维护** + **类型**:测试。**目标文件**:src/frontend/platform/src/pages/SystemPage/dsh/ModelAuditCard.test.tsx。 + **逻辑与验证**:AC-65;删除已废弃props夹具,原元信息和正文断言不变。**依赖**:T227。 +- [x] **T230 Gateway冻结词值和事件快照测试** + **类型**:测试。**目标文件**:G src/test/java/com/dataelem/gateway/service/DshMatchedWordsTest.java;G src/test/java/com/dataelem/gateway/service/DshSafetyRequestManagerTest.java。 + **逻辑与验证**:AC-66;冻结版本、去重/容量/Unicode、深复制、输入/输出/续读,不采上下文或伪造用量。**依赖**:无。 +- [x] **T231 Gateway冻结词库解析与Outbox扩展** + **类型**:实现。**目标文件**:G src/main/java/com/dataelem/gateway/service/DshSensitiveMatcherCache.java;G src/main/java/com/dataelem/gateway/service/DshSafetyOutboxService.java。 + **逻辑与验证**:按冻结规则提取词值,有界校验/深复制/日志脱敏,保留旧Observation构造兼容。**依赖**:T230。 +- [x] **T232 Gateway请求阶段词值接线** + **类型**:实现。**目标文件**:G src/main/java/com/dataelem/gateway/service/DshSafetyRequestManager.java。 + **逻辑与验证**:复用阶段Outcome规则引用传入快照,不改模型执行/计量/阻断交付。**依赖**:T231。 +- [x] **T233 backend词值快照契约与持久化测试** + **类型**:测试。**目标文件**:src/backend/test/dsh/test_content_safety_matched_words.py。 + **逻辑与验证**:AC-66;旧NULL、有界方向/UTF8校验、仓储元数据和幂等哈希。**依赖**:无。 +- [x] **T234 backend词值DTO与可空ORM字段** + **类型**:实现。**目标文件**:src/backend/bisheng/dsh/domain/schemas/content_safety.py;src/backend/bisheng/dsh/domain/models/content_safety_event.py。 + **逻辑与验证**:可选v1加法字段,匹配词合计20/每词256,旧NULL不反推。**依赖**:T233。 +- [x] **T235 事件追加与归并词值一致性** + **类型**:实现。**目标文件**:src/backend/bisheng/dsh/domain/repositories/content_safety.py;src/backend/bisheng/dsh/domain/repositories/content_safety_projection.py。 + **逻辑与验证**:保存规范JSON,重试比对快照,不覆盖原始wire哈希语义。**依赖**:T234。 +- [x] **T236 授权事件详情返回词值** + **类型**:实现。**目标文件**:src/backend/bisheng/dsh/domain/repositories/content_safety_queries.py。 + **逻辑与验证**:沿既有租户/请求权限返回新字段;主列表无事件JOIN/N+1。**依赖**:T235。 +- [x] **T237 幂等词值列DDL及离线回归** + **类型**:基础设施/测试。**目标文件**:src/backend/bisheng/core/database/alembic/versions/v3_0_0_f111_matched_words.py;src/backend/test/dsh/test_matched_words_migration.py。 + **逻辑与验证**:AC-66;唯一head、可空JsonType、旧数据保留、重复升级和拒绝破坏性回退。**依赖**:T234。 +- [x] **T238 前端词值类型及严格API解析** + **类型**:实现。**目标文件**:src/frontend/platform/src/types/dshContentSafety.ts;src/frontend/platform/src/controllers/API/dshContentSafety.ts。 + **逻辑与验证**:兼容旧缺失/null,校验新字段方向/数量/字符/重复/Unicode。**依赖**:T225、T236。 +- [x] **T239 按方向展示命中词及历史缺失说明** + **类型**:实现。**目标文件**:src/frontend/platform/src/pages/SystemPage/dsh/ContentSafetyDetails.tsx。 + **逻辑与验证**:只在请求检查详情显式查询后渲染文本,不反推、不增加主列表查询。**依赖**:T238。 +- [x] **T240 中英文词值和导航文案** + **类型**:实现。**目标文件**:src/frontend/platform/public/locales/zh-Hans/bs.json;src/frontend/platform/public/locales/en-US/bs.json。 + **逻辑与验证**:新增命中词/无法还原/树上限提示,删除请求视图和跳转文案。**依赖**:T226、T239。 +- [x] **T241 日文词值和导航文案** + **类型**:实现。**目标文件**:src/frontend/platform/public/locales/ja/bs.json。 + **逻辑与验证**:与中英文同键同义。**依赖**:T240。 +- [x] **T242 隔离MySQL迁移和跨仓契约核验** + **类型**:验收。**目标文件**:features/v3.0.0/111-dsh-sensitive-word-control/implementation-verification.md。 + **逻辑与验证**:只启动自有随机端口MySQL容器,验证幂等/旧NULL/Unicode/回退;DM8保留待验,不替代完整部署E2E。**依赖**:T232、T237。 +- [x] **T243 两仓任务自审与验证记录** + **类型**:文档/验收。**目标文件**:features/v3.0.0/111-dsh-sensitive-word-control/implementation-verification.md;G docs/dsh-content-safety-verification.md。 + **逻辑与验证**:实际相关回归、类型/lint/i18n/构建、baseline失败对照、权限/泄漏/设计同步;不提交推送部署。**依赖**:T228、T229、T236、T241、T242。 + +### 发布前验收脚本契约补齐 + +- [x] **T244 现场事件白名单兼容命中词快照** + **类型**:测试。**目标文件**:src/backend/test/dsh/test_content_safety_recovery_e2e.py;src/backend/test/dsh/test_content_safety_recovery_contract.py。 + **逻辑与验证**:AC-66;保留旧缺失/null,严格校验新增方向、状态、数量、重复、Unicode及词长,继续拒绝其他字段;11项边界与相关组合201PASS,主agent任务自审PASS。**依赖**:T233、T243。不代表原完整现场验收已完成。 + +### Wave 48 — 2026-10-28 敏感词摘要保留标题与模型名称 + +- [x] **T245 摘要元数据与无正文回归** + **类型**:测试。**目标文件**:src/backend/test/dsh/test_safety_summary_metadata.py。 + **逻辑与验证**:AC-60、AC-67、标题AC-T04、AC-T05;11个场景覆盖输入阻断/检查通过、先标题后回传、重复/乱序、范围隔离、读模式失败、名称超时/坏值、名称快照和原生名称优先、两项关闭无新摘要/标题及正文存储为空。旧逻辑OFF反例RED,最终相关310PASS。**依赖**:T244。 + +- [x] **T246 OFF模式按保留检查摘要接收标题** + **类型**:实现。**目标文件**:src/backend/bisheng/dsh/domain/services/model_audit_title.py;src/backend/bisheng/dsh/domain/repositories/model_audit_title.py。 + **逻辑与验证**:配对T245;读失败仍DISABLED,OFF只有同本人/租户且关联可信安全来源、至少一阶段实际检查的既有摘要可维护标题;不创建请求或读正文,不引入Gateway实时配置依赖。保留标题CAS和事务边界。**依赖**:T245。 + +- [x] **T247 外部摘要保存名称快照** + **类型**:实现。**目标文件**:src/backend/bisheng/dsh/domain/repositories/content_safety_projection.py。 + **逻辑与验证**:配对T245;随摘要保存可选名称及其摘要,后到仅补空值、不覆盖非空或原生名称;无新增执行/用量事实和正文记录。**依赖**:T245。 + +- [x] **T248 复用受控模型元数据读取并完成接线** + **类型**:实现。**目标文件**:src/backend/bisheng/dsh/domain/services/content_safety_ingest.py;src/backend/bisheng/dsh/content_safety_runtime.py。 + **逻辑与验证**:配对T245;原租户/Root共享校验和离线模型元数据,200ms可选读取包含于2秒总预算,失败不丢检查事实;生产装配与租户scope回归通过。**依赖**:T245、T247。 + +### Wave 49 — 2026-10-28 审计界面简化 + +- [x] **T249 检查详情与完整筛选回归** + **类型**:测试。**目标文件**:src/frontend/platform/src/pages/SystemPage/dsh/ContentSafetyDetails.test.tsx;src/frontend/platform/src/pages/SystemPage/dsh/ModelAuditFilters.test.tsx。 + **覆盖 AC**:AC-46、AC-66、AC-68、AC-69。验证仅命中词和时间、全部通过不查询、历史命中保留、更新取消、分页重试和完整七项筛选。**依赖**:T248。 + +- [x] **T250 审计配置精简回归** + **类型**:测试。**目标文件**:src/frontend/platform/src/pages/SystemPage/dsh/ModelAuditMode.test.tsx;src/frontend/platform/src/pages/SystemPage/dsh/ModelAuditMode.http.test.tsx。 + **覆盖 AC**:AC-61、AC-68。验证移除刷新状态后只读/可配置六种模式仍有采集说明,保留真实API wrapper保存(HTTP传输mock)、重新进入重读、租户变更和保存失败自动重读。**依赖**:T248。 + +- [x] **T251 移除审计冗余入口** + **类型**:实现。**目标文件**:src/frontend/platform/src/pages/SystemPage/dsh/ModelAuditMode.tsx;src/frontend/platform/src/pages/SystemPage/dsh/ModelAuditPanel.tsx。 + **逻辑与验证**:配对T249、T250;仅移除刷新状态及范围说明入口和说明面板,保留配置、筛选、刷新记录及原有权限隔离。**依赖**:T249、T250。 + +- [x] **T252 精简检查详情并隐藏全通过入口** + **类型**:实现。**目标文件**:src/frontend/platform/src/pages/SystemPage/dsh/ContentSafetyDetails.tsx;src/frontend/platform/src/pages/SystemPage/dsh/ModelAuditCard.tsx。 + **逻辑与验证**:配对T249;两字段纯文本、旧快照兼容、事件分页重试保留;全通过且无已知历史命中时不挂载明细。**依赖**:T249。 + +- [x] **T253 中文和英文详情文案** + **类型**:实现。**目标文件**:src/frontend/platform/public/locales/zh-Hans/bs.json;src/frontend/platform/public/locales/en-US/bs.json。 + **逻辑与验证**:配对T249;检查时间简化,无词值时不宣称通过;不修改api_errors生成文件。**依赖**:T249、T252。 + +- [x] **T254 日文详情文案** + **类型**:实现。**目标文件**:src/frontend/platform/public/locales/ja/bs.json。 + **逻辑与验证**:与T253键和语义一致,三语言检查通过后整体交付。**依赖**:T249、T253。 + +- [x] **T255 审计简化一致性验收与自审** + **类型**:验收。**目标文件**:features/v3.0.0/111-dsh-sensitive-word-control/implementation-verification.md。 + **覆盖 AC**:AC-68、AC-69。定向回归、完整前端lint/typecheck/i18n、构建、真实组件桌面及500px核验;不等同109部署验收。**依赖**:T251、T252、T253、T254。 + +### Wave 50 — 2026-10-28 敏感词配置界面简化 + +- [x] **T256 配置页简化与恢复回归** + **类型**:测试。**目标文件**:src/frontend/platform/src/pages/SystemPage/dsh/DshContentSafetySettings.test.tsx。 + **覆盖 AC**:AC-70。全部配置保留、技术字段/静态限制/正常刷新消失、失败读取恢复、冲突刷新失败保留可见草稿、成功重读最新CAS及只读行为。**依赖**:T255。 + +- [x] **T257 精简配置页并保留条件恢复** + **类型**:实现。**目标文件**:src/frontend/platform/src/pages/SystemPage/dsh/DshContentSafetySettings.tsx。 + **逻辑与验证**:配对T256;仅收敛展示及刷新失败后的草稿可见性、错误去重,保留配置/权限/原子保存/校验/租户取消。**依赖**:T256。 + +- [x] **T258 中文和英文简短提示** + **类型**:实现。**目标文件**:src/frontend/platform/public/locales/zh-Hans/bs.json;src/frontend/platform/public/locales/en-US/bs.json。 + **逻辑与验证**:配对T256;精简词条/阻断提示说明、保存反馈包含5秒生效,不改其他域的同名键。**依赖**:T256、T257。 + +- [x] **T259 日文简短提示** + **类型**:实现。**目标文件**:src/frontend/platform/public/locales/ja/bs.json。 + **逻辑与验证**:与T258一致,三语键和语义同步。**依赖**:T256、T258。 + +- [x] **T260 配置界面一致性验收与自审** + **类型**:验收。**目标文件**:features/v3.0.0/111-dsh-sensitive-word-control/implementation-verification.md。 + **覆盖 AC**:AC-70。DSH回归、完整前端lint/typecheck、i18n/生成检查、Platform构建、桌面及500px实际组件核验,保留旧15项环境待验。**依赖**:T257、T258、T259。 + +### Wave 51 — 2026-10-28 请求检查详情汇总去重 + +- [x] **T261 重复快照与分页汇总回归** + **类型**:测试。**目标文件**:src/frontend/platform/src/pages/SystemPage/dsh/ContentSafetyDetails.test.tsx。 + **覆盖 AC**:AC-71。DECISION/DRAIN正反顺序只显示一份词值与首次命中时间、跨页新词与更早时间、后续无命中不清历史词、缺词值与已知词并存、无命中最早检查时间;先RED后GREEN。**依赖**:T260。 + +- [x] **T262 按请求展示单份命中摘要** + **类型**:实现。**目标文件**:src/frontend/platform/src/pages/SystemPage/dsh/ContentSafetyDetails.tsx。 + **逻辑与验证**:配对T261;跨已加载事件及输入/输出去重,保留首次命中时间和旧快照无法还原提示,不改存储、权限、取数及分页取消逻辑。**依赖**:T261。 + +- [x] **T263 汇总去重一致性验收与自审** + **类型**:验收。**目标文件**:features/v3.0.0/111-dsh-sensitive-word-control/implementation-verification.md。 + **覆盖 AC**:AC-71。完整DSH回归、前端lint/typecheck/i18n/生成检查、Platform构建与架构/差异审查;原15项环境门禁不变。**依赖**:T262。 + +### Wave 52 — 2026-10-28 隐藏审计统计版本 + +- [x] **T264 统计版本隐藏回归** + **类型**:测试。**目标文件**:src/frontend/platform/src/pages/SystemPage/dsh/ContentSafetyDetails.test.tsx。 + **覆盖 AC**:AC-68。已就绪及更新中都不显示统计版本,保留原状态、更新时间、命中事实和已就绪计数;先RED后GREEN。**依赖**:T263。 + +- [x] **T265 移除统计版本渲染** + **类型**:实现。**目标文件**:src/frontend/platform/src/pages/SystemPage/dsh/ModelAuditGroup.tsx。 + **逻辑与验证**:配对T264;只删除版本取值和显示,保留共享组件的会话/子会话命中计数、状态与时间,后端/API协议不变。**依赖**:T264。 + +- [x] **T266 统计展示自审与验收** + **类型**:验收。**目标文件**:features/v3.0.0/111-dsh-sensitive-word-control/implementation-verification.md。 + **覆盖 AC**:AC-68。DSH回归、前端lint/typecheck、架构和差异检查,原15项环境待验不变。**依赖**:T265。 + +### Wave 53 — 2026-10-28 请求详情分类与简化 + +- [x] **T267 请求详情分类与语义回归** + **类型**:测试。**目标文件**:src/frontend/platform/src/pages/SystemPage/dsh/ModelAuditCard.test.tsx;src/frontend/platform/src/pages/SystemPage/dsh/ContentSafetyDetails.test.tsx。 + **覆盖 AC**:AC-72。独立分类标题、四项业务状态、隐藏技术字段/重复名称、未知与未调用不混淆、保留失败归类/折叠与正文权限;更新旧重复展示及执行时间用例,先RED后GREEN。**依赖**:T266。 + +- [x] **T268 简化请求详情区域** + **类型**:实现。**目标文件**:src/frontend/platform/src/pages/SystemPage/dsh/ModelAuditCard.tsx。 + **逻辑与验证**:配对T267;复用现有分类布局、标题/状态翻译,四项状态及归类失败保留,模型结果只展示一次;不改取数/权限/计量和原文控制。**依赖**:T267。 + +- [x] **T269 请求详情一致性验收与自审** + **类型**:验收。**目标文件**:features/v3.0.0/111-dsh-sensitive-word-control/implementation-verification.md。 + **覆盖 AC**:AC-72。完整DSH及前端门禁、生产构建、1440px/500px实际组件核验、架构与差异检查;原15项环境待验保留。**依赖**:T268。 + +### Wave 54 — 2026-10-29 未就绪统计、方向开关及会话内请求展示 + +- [x] **T270 两方向控制前置回归** + **类型**:测试。**目标文件**:src/frontend/platform/src/pages/SystemPage/dsh/DshContentSafetySettings.test.tsx。 + **覆盖 AC**:AC-74。无独立启用控件、四种方向组合派生enabled、全部关闭后校验、原子保存/只读/冲突恢复。**依赖**:T269。 + +- [x] **T271 检查方向派生总开关** + **类型**:实现。**目标文件**:src/frontend/platform/src/pages/SystemPage/dsh/DshContentSafetySettings.tsx。 + **逻辑与验证**:配对T270;移除总开关并统一draft启用派生,不加旧组合兼容分支。**依赖**:T270。 + +- [x] **T272 展示与统计就绪回归** + **类型**:测试。**目标文件**:src/frontend/platform/src/pages/SystemPage/dsh/ModelAuditPresentation.test.tsx;src/frontend/platform/src/pages/SystemPage/dsh/ContentSafetyDetails.test.tsx。 + **覆盖 AC**:AC-73、AC-75。未就绪统计不显示、会话可见、就绪恢复、无类型标签及头部信息保留。**依赖**:T269。 + +- [x] **T273 原列表及树占位用例维护** + **类型**:测试。**目标文件**:src/frontend/platform/src/pages/SystemPage/dsh/ModelAuditGroup.test.tsx;src/frontend/platform/src/pages/SystemPage/dsh/ModelAuditTree.test.tsx。 + **覆盖 AC**:AC-73。按新规则不期待准备占位,保留空扫描游标和懒加载断言。**依赖**:T269。 + +- [x] **T274 只显示就绪的会话统计** + **类型**:实现。**目标文件**:src/frontend/platform/src/pages/SystemPage/dsh/ModelAuditGroup.tsx;src/frontend/platform/src/pages/SystemPage/dsh/ModelAuditTree.tsx。 + **逻辑与验证**:配对T272、T273;按对应就绪字段控制数量/汇总和时间,保留会话行及请求。**依赖**:T272、T273。 + +- [x] **T275 移除统计准备提示** + **类型**:实现。**目标文件**:src/frontend/platform/src/pages/SystemPage/dsh/ModelAuditPanel.tsx。 + **逻辑与验证**:配对T273;仅去掉统计/关系准备说明,真实读取失败、查询错误及扫描预算提示继续保留。**依赖**:T273。 + +- [x] **T276 会话内过滤与分页前置回归** + **类型**:测试。**目标文件**:src/backend/test/dsh/test_model_audit_visible_requests.py。 + **覆盖 AC**:AC-75。实际HTTP及隔离SQL验证辅助请求在limit前过滤,NULL用途保留、跨页不漏不重、外层统计和原始事实不变。**依赖**:T269。 + +- [x] **T277 会话内查询限定过滤** + **类型**:实现。**目标文件**:src/backend/bisheng/dsh/domain/repositories/model_audit_queries.py;src/backend/bisheng/dsh/domain/services/model_audit_queries.py。 + **逻辑与验证**:配对T276;仅groups(key)启用内部排除标记,WHERE先过滤、cursor绑定范围,其他查询/计数不动,不加表。**依赖**:T276。 + +- [x] **T278 移除请求卡片类型标签** + **类型**:实现。**目标文件**:src/frontend/platform/src/pages/SystemPage/dsh/ModelAuditCard.tsx。 + **逻辑与验证**:配对T272;移除用途和会话来源标签,保留名称/时间/用户和详情权限。**依赖**:T272。 + +- [x] **T279 本轮一致性验收及任务自审** + **类型**:验收。**目标文件**:features/v3.0.0/111-dsh-sensitive-word-control/implementation-verification.md。 + **覆盖 AC**:AC-73、AC-74、AC-75。前后端回归、质量门禁、实际组件桌面/500px核验、架构和差异检查,原15项环境待验保留。**依赖**:T271、T274、T275、T277、T278。 + +## 5. AC → 测试任务追溯 + +本表只列测试/验收任务;配对实现见各任务依赖。每条 AC 至少一个针对性测试或端到端场景,末端 E2E 和特性审查不能代替前置测试。 + +| AC | 测试 / 验收任务 | +|---|---| +| AC-01 | T071、T073、T075、T087、T089、T091、T121、T125、T132、T138 | +| AC-02 | T061、T067、T087、T091、T093、T110、T121、T125、T131、T132、T135、T138、T172 | +| AC-03 | T017、T025、T029、T031、T035、T049、T083、T087、T091、T093、T121、T125、T130、T131、T132、T138、T141、T142、T143、T144、T145、T146、T147、T150、T151、T165、T171 | +| AC-04 | T087、T091、T124、T125、T131、T132 | +| AC-05 | T071、T073、T075、T083、T087、T089、T091、T095、T113、T115、T121、T125、T132、T138 | +| AC-06 | T061、T063、T067、T122、T125、T132、T165 | +| AC-07 | T039、T065、T067、T110、T122、T124、T125、T132、T165 | +| AC-08 | T039、T065、T067、T122、T124、T125、T132、T135 | +| AC-09 | T061、T063、T067、T110、T122、T125、T132、T135 | +| AC-10 | T061、T063、T067、T110、T120、T122、T125、T132 | +| AC-11 | T063、T067、T087、T122、T125、T128、T129、T131、T132 | +| AC-12 | T061、T063、T067、T087、T122、T125、T131、T132 | +| AC-13 | T055、T057、T087、T122、T125、T127、T132 | +| AC-14 | T057、T059、T097、T122、T125、T127、T129、T132、T157、T163 | +| AC-15 | T057、T059、T122、T125、T127、T129、T132 | +| AC-16 | T055、T083、T087、T089、T122、T125、T131、T132 | +| AC-17 | T057、T083、T087、T122、T125、T131、T132 | +| AC-18 | T055、T057、T059、T063、T065、T067、T085、T087、T097、T122、T125、T127、T129、T132、T157、T159、T163 | +| AC-19 | T055、T057、T059、T085、T087、T097、T122、T125、T127、T128、T129、T132、T157、T159、T163 | +| AC-20 | T069、T071、T073、T075、T121、T125、T132 | +| AC-21 | T063、T069、T071、T073、T075、T121、T125、T132、T185、T186 | +| AC-22 | T069、T071、T073、T075、T121、T125、T132、T185、T186 | +| AC-23 | T075、T089、T115、T118、T121、T125、T132、T185、T186、T202、T203 | +| AC-24 | T073、T075、T113、T115、T118、T121、T125、T132、T202、T203 | +| AC-25 | T033、T069、T071、T073、T075、T121、T125、T132、T204、T205 | +| AC-26 | T031、T033、T063、T069、T071、T075、T085、T091、T095、T097、T121、T125、T126、T128、T129、T132、T138、T159、T161、T171、T204、T205 | +| AC-27 | T023、T071、T087、T089、T121、T125、T132 | +| AC-28 | T073、T075、T089、T121、T125、T132、T202、T203 | +| AC-29 | T073、T075、T083、T085、T089、T113、T115、T118、T121、T125、T128、T132、T202、T203 | +| AC-30 | T073、T075、T087、T089、T111、T113、T115、T118、T121、T125、T132、T136、T137、T140、T141、T142、T143、T144、T145、T146、T147、T202、T203 | +| AC-31 | T073、T075、T083、T087、T089、T111、T113、T115、T118、T121、T125、T132、T136、T137、T140、T141、T142、T143、T144、T145、T146、T147、T202、T203 | +| AC-32 | T023、T025、T027、T037、T083、T085、T089、T113、T115、T121、T125、T128、T131、T132 | +| AC-33 | T019、T073、T075、T077、T123、T125、T132、T204、T205 | +| AC-34 | T019、T027、T037、T073、T077、T110、T123、T124、T125、T132、T133、T204、T205 | +| AC-35 | T017、T023、T029、T031、T033、T039、T069、T077、T079、T093、T095、T097、T111、T122、T123、T125、T132、T135、T161、T163、T171、T172 | +| AC-36 | T023、T027、T037、T123、T125、T132、T133 | +| AC-37 | T023、T089、T123、T124、T125、T132、T174 | +| AC-38 | T021、T023、T025、T027、T029、T041、T055、T119、T122、T123、T124、T125、T132、T174 | +| AC-39 | T021、T023、T029、T033、T047、T049、T051、T079、T081、T119、T120、T123、T125、T132、T138、T166、T172 | +| AC-40 | T023、T029、T077、T085、T123、T125、T132 | +| AC-41 | T019、T021、T025、T029、T041、T043、T047、T051、T077、T079、T081、T119、T120、T123、T125、T132、T138、T168、T172 | +| AC-42 | T017、T021、T029、T031、T033、T035、T079、T123、T125、T132、T171、T172、T174 | +| AC-43 | T029、T031、T033、T051、T053、T077、T079、T081、T085、T097、T120、T123、T125、T128、T129、T131、T132、T138、T161、T171 | +| AC-44 | T023、T027、T037、T039、T110、T124、T125、T130、T132、T133 | +| AC-45 | T035、T037、T039、T045、T110、T124、T125、T126、T129、T132、T148、T151、T152、T156 | +| AC-46 | T037、T039、T110、T124、T125、T132、T150、T174、T191 | +| AC-47 | T025、T041、T043、T045、T047、T124、T125、T132、T166 | +| AC-48 | T023、T027、T041、T043、T045、T047、T049、T051、T053、T097、T119、T124、T125、T126、T129、T132、T166 | +| AC-49 | T025、T037、T039、T045、T110、T124、T125、T126、T129、T132、T148、T151、T152、T156、T174 | +| AC-50 | T037、T039、T045、T124、T125、T126、T129、T132、T148、T152、T156 | +| AC-51 | T021、T037、T039、T051、T053、T081、T110、T119、T120、T123、T124、T125、T131、T132 | +| AC-52 | T016、T025、T027、T037、T041、T043、T051、T053、T119、T123、T125、T132、T133、T168 | +| AC-53 | T016、T021、T047、T051、T053、T061、T119、T120、T123、T125、T126、T129、T132、T166、T168 | +| AC-54 | T091、T093、T095、T124、T125、T131、T132、T138 | +| AC-55 | T047、T049、T063、T077、T081、T087、T120、T123、T125、T131、T132、T138、T166 | +| AC-56 | T110、T111、T113、T115、T124、T125、T130、T132、T136、T137、T140、T141、T142、T143、T144、T145、T146、T147、T165 | +| AC-57 | T085、T089、T121、T125、T128、T129、T132 | +| AC-58 | T029、T077、T079、T081、T097、T120、T123、T125、T131、T132、T161、T172 | +| AC-59 | T059、T122、T125、T127、T129、T132 | +| AC-60 | T206 | +| AC-61 | T208 | + +AC-62、AC-63 → T212、T214;实现与交叉自审见 T213、T215–T218。 + +AC-64 → T219;界面、调用和文案清理及验收见 T220–T224。 + +AC-65 → T225、T228、T229;AC-66 → T225、T230、T233、T237;实现及验收见 T226–T243。 + +AC-67 → T245;标题、名称投影与生产接线见 T246–T248。 + +AC-68 → T249、T250、T255、T264、T266;AC-69 → T249、T255;界面与三语实现见T251–T254。 + +AC-70 → T256、T260;配置页与三语实现见T257–T259。 + +AC-71 → T261、T263;请求汇总渲染见T262。 + +AC-72 → T267、T269;请求详情实现见T268。 + +AC-73 → T272、T273、T279;AC-74 → T270、T279;AC-75 → T272、T276、T279。 + +## 6. 共享文件与发布边界 + +共享 HMAC helper 只调整签名计算复用,旧 SSO 协议/错误码/正文回放原样回归;HTTP middleware 仅精确新接收路由,不改模型认证。model_audit_write/writer、会话查询及 ModelAuditPanel 分步只改本任务区域;状态、事件、投影和查询方法分工明确。Gateway 应用敏感词/PathRate/默认路由的改动必须跑旧应用与WS回归。Desktop 保持宿主插件边界,不改第三方 tgz 或直接修 node_modules。 + +所有数据库扩展采用保留事实的应用回退;包含外部记录时不盲目降级到旧 CHECK/NOT NULL,不以删除事实换回滚成功。正常列表走主摘要,事件表只服务详情/未关联查询。默认不自动清明细,30 天是容量评估而非保留期。生产迁移、部署、提交、推送和合并均须后续对应授权;本计划并非这些操作的授权。 + +## 7. 实际偏差记录 + +新增 T133、T134:Wave 1 既有 DTO 消费端兼容回归确证的问题,最小修复见 design §9。T001 增加显式租户登记;T008 增加 schema helper;T016 增加持久化迁移验证,均保持每任务最多两个目标文件。实现改变认知时按“任务 ID → design 的具体决策/坑”留一行指针,不复制论证;推翻已确认需求或设计先按 SDD 重新确认。 + +## 8. 文档与执行检查 + +初版132项/43个Wave静态评审见[tasks-review.md](./tasks-review.md);实施中确证的兼容与接线补齐按§9留记录,原Wave1–43共175项;交付补充后205项;2026-10-27补充至T244;2026-10-28补充至T269;2026-10-29补充至T279后当前279项。新增任务逐项/task-review与配对测试验收,不把初版静态LGTM当新增任务已执行。75条AC追溯按当前测试任务覆盖补齐;保持依赖无环、每任务人工目标文件至多两个。 + +## 9. 执行记录 + +2026-10-23:完成 Wave 1 T001–T015、兼容补充 T133/T134 以及 T016,18 / 134。任务自审、具体测试与既有前端全量门禁失败见 [implementation-verification.md](./implementation-verification.md)。真实 MySQL/DM8 事务、模型、页面 E2E 和容量未执行;不提交、不推送、不部署。 + +2026-10-23:完成 Wave 3 的 T017/T019、T055/T061/T065/T077/T083、T099/T100/T101、T111,共 29 / 134。测试任务的完成表示已编写、自审并证实预期 RED,不代表未实现的行为已通过;进入 Wave 4 逐项转绿。 + +2026-10-23:Wave 4 八项配对实现通过测试和任务自审,累计 37 / 134。Gateway 合并定向回归 126 passed;backend 配对及相邻回归 152 passed;Desktop 文案及企业包 28 passed、typecheck passed。DM8 暂无可用测试环境,用户确认保留实测待验收;未勾选后续真实双库验收任务。 + +2026-10-23:Wave 5 三项前置测试全部审查完成,累计 40 / 134,进入 Wave 6 配对实现。T063 加入实际 AC 失配链漏检回归;DSH 局部 BFS 修正已在临时真实 Java 探针复现转绿,生产实现仍待 T064。 + +2026-10-23:Wave 6 三项实现全部完成,累计 43 / 135。主 agent 对最终代码重新运行13类195项Gateway回归全部通过;独立Redis缓存27断言及策略Lua/广播验证通过,仍不代表多进程预算、双库或E2E验收。进入Wave7前置测试。 + +2026-10-23:Wave7发现T066仅返回Void,无法为既定updated_by提供可信操作者;新增必要兼容补齐T135(2文件,原授权链不变),T068依赖它且仍只两文件。总任务135项;capabilities明确带目标tenant_id并实时管理授权,Origin复用既有bisheng.homeUrl,不新增配置或backend RPC。 + +2026-10-23:Wave7五项全部完成,累计48 / 135。Gateway三组新测试语法/定向预期RED通过自审;主agent集中MavenRED仅待实现Verifier/Scanner等缺类;backend主agent组合23预期RED+72既有PASS;T135真实本地HTTP11/11PASS。进入Wave8四项配对实现。 + +2026-10-24:Wave8四项配对实现完成;backend集中165 PASS,Gateway16类261 PASS,实际Redis缓存27断言PASS。逐文件自审和独立Scanner复审完成;修正管理请求在授权前聚合200MB正文、Profile过期任务复用与槽位取消时序。进入Wave9,真实DM8继续待验收;未提交、推送或部署。 + +2026-10-24:Wave9四个前置测试任务完成;backend集中13预期RED+173PASS,Gateway集中仅缺三个配对生产adapter类型。独立复审修正合法内联图像fixture,补齐Chat/Responses工具参数根类型差异、Unicode提示保留核心命中,以及失败Responses快照仍检查可读内容。进入Wave10配对实现。 + +2026-10-24:Wave10四项实现完成。backend配对21PASS、agent组合219PASS、主agent独立186PASS;Gateway19类371PASS。独立实测复审修复schema结构/业务值混淆、reasoning跨块、错误混载绕检、未结束Responses条目被遗漏后误判成功,均留RED→GREEN证据。进入Wave11,真实双库/部署/性能仍待验收;未提交推送部署。 + +2026-10-24:补T138/T139(Wave27/28)显式承接Gateway Spring条件组装;原T098仅遥测/Dispatcher两个文件,不能承担缺失的Configuration接线。真实配置容器与检查关闭仍补报列为配对验收,总数139;已完成仍80。 + +2026-10-24:补T140在Wave17固化T117实际入口/卸载回归;临时探针不能长期承担CI保护,仍单文件/独立临时profile,不替代T118真实工具runner或最终E2E,总数140。 + +2026-10-24:Wave17补T141维护既有enterprise-adapter夹具;本特性新增公共事件订阅/函数导出造成5项原测试未到达业务断言,修夹具且不放宽断言。总数141。 + +2026-10-24:Wave18相邻真实回归发现旧Gateway手工Binder不解析合法placeholder,T091扩第二测试文件;旧Desktop审计on接口夹具纳T141第二文件,另补T142/T143维护三份UI locale夹具(每任务至多两文件),原业务断言不改。T091/T141暂退勾等待复验,总数143。 + +2026-10-24:真实SessionController.queue生产locale桥复现后续请求沿旧driver ALS显示首轮语言;补T144/T145双adapter冻结formatter测试/实现及T146/T147公开消息事件作用域测试/实现,Wave20–22每项至多两文件、独立配对门禁,总147。只修既定当前语言显示,不改模型鉴权/安全分类/工具屏障或第三方包。 + +2026-10-24:T040只读准备实际复现Service注入移动默认bounds会导致主请求游标冲突。新增Wave22串行T148/T149后再接T040;既定默认相对窗口不冻结、显式筛选绑定不变,每任务至多两文件,总149。 + +2026-10-24:T040扩大回归实际13项旧fixture失败;新增T150补原隔离schema事件模型、T151只对两手工固定日期测试注入相干查询clock。保持最多两文件、原断言、共享Harness实时模型行为及固定NOW,总151。 + +2026-10-24:组/树Repo沿主请求一样的可选now端口保留旧内部语义,需独立API时钟接线;补Wave24串行T152/T153(T046后)避免把第三Service源隐藏进两源Repo实现。每任务单文件、真实API先RED后GREEN,总153;不改变默认窗口和显式绑定取舍。 + +2026-10-24:当前Platform缺共享组件依赖及语义样式,现行AGENTS强制复用landed组件;新增T154 workspace依赖与T155限定容器的构建接入,拒绝全站样式迁移或复制token值。两任务各至多两维护文件,不升级第三方闭包,总155。 + +2026-10-24:T153实际API默认窗口接线后两旧手工历史NOW测试失配,新增T156精准两个case查询时钟维护,保留原断言/全局Harness与生产语义,总156。 + +2026-10-24:T097边界审查确认T098两源只能落聚合器与Dispatcher,现有final Cache/Policy/Manager/Filter无observer端口,不能把手工increment当实际诊断已完成。新增T157–T164四组配对(每任务≤2源),位于Wave26/T138前,仅接既定有限诊断不新增产品需求;T139统一注同实例。另T165精准维护新增tab导致的既有导航计数。总165。 + +2026-10-24:T047补真实原生审计先发布/首命中后到,发现只tree_pending漏group-only dirty,而旧pending_tenants包含无关运行模型;新增T166/T167窄Repo端口配对,保持048仅Worker+registry两源,旧tree扫描默认兼容。总167。 + +2026-10-24:T051实际链路确认原生writer缺删除水位闸门、首次缺setting会有并发空隙;新增T168/T169配对,复用T052锁行端口,T119依赖其完成,保持每任务≤2源。T139增加第二源仅移交Outbox的Spring唯一Bean所有权,真实context测试先行,不用多实例掩盖。总169。 + +2026-10-24:T108生产构建GUI通过,但正式Vite dev实际DOM确认Shared UI的tailwind-merge被同名优化到Platform不同锁定版本,丢字号类;新增T170单构建源兼容,不在108两业务源藏覆盖样式或升级依赖,T110依赖修复。总170。 + +2026-10-24:T052扩大组合确证T031当前进程重导runtime造成package/module别名分叉,新增T171单测试冷启动隔离维护;同HEAD原Setting36FAIL为保留证据,修复不放宽API断言。总171。 + +2026-10-24:跨仓真实配置probe确证全局null→空串使事件/不可用capabilities被严格consumer拒绝,新增T172/T173配对局部修复;真实Repo确证大小写UUIDfilter与已存lower不等,新增T174/T175配对,保持每任务≤2源,不放宽DTO或更改默认分页取舍。总175。 + + +2026-10-24现场最终子集:35协议PASS(380.29s)、7身份/管理PASS(29.83s)、12实际请求的只读审计4参数PASS(12.47s)。两协议真实Desktop read成功各1且nonce匹配,BLOCKED各0调度,无自动重试;原生实际输出BLOCKED仍SUCCEEDED/SETTLED/raw80。Gateway64f602be经404成功构建并仅109部署。T202/T203新48/旧SDK33/计数13由主和独立B复核通过,已更新唯一测试Provider。T121/T122/T123完整故障/双租户/无Gateway/UNKNOWN和容量仍未完成,不以这些子集勾全任务。T204/T205已完成真实7项边界和49本地配对复验;许可证由用户追加授权生成官方兼容trial,2026-11-23到期且实际expired=false。详细结果见implementation-verification与deployment-execution。 + +2026-10-24 T204/T205 PASS:主读全部420行单源,发现capabilities错用config_ready,C以真实@JsonProperty("configuration_ready")反例3RED→9GREEN修正;最终主真实执行56PASS(49本地+7现场,78.03s)。七请求均400 invalid_request、公开UUID一致/无Backend header、物理global+scenario0、两轮完整精确空审计与无额度变化。32MiB+1实际33554433bytes,未打印请求正文。测试退出原策略字段已恢复disabled/version111;其他109既有容器ID/image/StartedAt再次逐字节不变。当前190/205,完整T121/T122/T123、DM8、容量和最终T132保留未完成。 + + +2026-10-27:用户确认 AC-60/AC-61 后新增 Wave44 T206–T211;Gateway 4 RED→GREEN,定向107PASS、完整896PASS/10真实DB SKIP;DSH 前端258PASS、workspace typecheck/i18n/locales、生产构建和目标lint通过,完整lint仍95项既有错误。主已逐文件自审两仓实际实现及配对、旧记录补报/冻结快照/不改模型计量边界,通过后勾选,当前196/211;既有完整现场、DM8、容量与T132门禁仍保留未完成。详细见 implementation-verification 的2026-10-27章节,未提交/推送/部署。 + + +2026-10-27 Wave45:用户确认统一请求编号,B/G 从各自干净的最新已知 work120 基线创建 codex/dsh-unified-request-id,仅保留未提交改动。T212–T218 完成;B 20项新增测试所在定向组合81PASS,1项既有用量fixture失败经HEAD源对照复现;G完整902PASS/10真实DB SKIP;真实本机Java Proxy→Python路由/模型服务双协议流式2PASS。Ruff、架构检查、git diff --check及配对任务自审通过。当前203/218,原15项待验仍不勾选;没有提交、推送或部署。 + + +2026-10-27 Wave46:按用户要求移除未关联检查界面及前端专属查询、类型和三语五个专属键;保留数据库、后台事件处理和请求内检查详情。新增三个视图入口移除反例3RED→GREEN,DSH组件257PASS,workspace typecheck、i18n/locales、目标ESLint、Platform构建与差异检查通过;完整lint的95错误集中于8个HEAD未修改文件,未扩大修复。T219–T224完成,当前209/224;没有提交、推送、部署或修改数据库数据。 + + +2026-10-27 Wave47:用户确认移除请求明细及相关跳转,增加具体命中词。T225–T243完成,当前228/243;Gateway冻结策略词快照→严格事件DTO/可空JsonType迁移→授权检查详情按输入/输出显示,旧记录明确无法还原。B定向170PASS,G完整905PASS/10真实DB SKIP,Platform269PASS及typecheck/i18n/目标lint/构建通过;真实隔离MySQL8.0.46迁移往返通过且自有容器已清理,Java实际事件经Python真实DTO校验通过。扩大backend回归与干净HEAD完全相同16FAIL/102ERROR case ID,新增38PASS;完整前端lint仍95项既有错误。逐项/task-review见implementation-verification;原15项待验不勾选。必须全部backend先升级再升级Gateway;未提交、推送、部署。 + +2026-10-27 发布前:T244修正旧现场测试白名单未接入matched_words的验收兼容问题,11项新增边界和相关组合201PASS;仅测试/文档,不改业务。用户已追加授权合并推送、构建和仅109 DSH13001部署;原15项待验不勾选,当前229/244。 + + +2026-10-28 Wave48:用户授权仅开敏感词检查时保留标题与模型名称,正文仍OFF。T245–T248完成,当前233/248;11项新增场景及相关13文件共310PASS,Ruff、架构守卫、diff检查通过。主agent按task-review清单逐项自审PASS;正文和manifest保持原边界,不新增Gateway或Desktop改动、迁移、历史批量回填。原15项待验不勾选;未提交、推送或部署。详细证据见implementation-verification同日小节。 + + +2026-10-28 Wave49:用户确认审计简化及全部通过不展示详情,T249–T255逐项验证和task-review自审PASS,当前240/255。完整七项高级筛选零业务改动;明细仅命中词/检查时间,全部通过无事件查询且更新取消旧查询,已知历史命中继续可定位。DSH 25文件276PASS;完整前端lint/typecheck、三语、生成检查、Platform构建、架构守卫和diff检查通过;实际组件1440px/500px合成数据核验通过,临时核验设施已清理。没有109生产验收,无backend/Gateway/数据库改动;原15项待验保留。未提交、推送或部署,细节见implementation-verification。 + + +2026-10-28 Wave50:按用户要求精简敏感词配置页,T256–T260逐项验证及task-review自审PASS,当前245/260。所有配置项保留,移除技术展示/冗余说明,正常仅保存、失败或冲突时刷新;保留可见草稿、错误去重、最新CAS后显式保存。DSH 25文件279PASS,完整前端lint/typecheck/i18n/生成检查、Platform构建、架构和diff通过,1440px及500px真实组件合成数据核验通过。原15项待验保留;无backend/Gateway/数据库改动,未提交、推送或部署。细节见implementation-verification同日小节。 + + +2026-10-28 Wave51:用户确认修复同一命中词被DECISION/DRAIN重复展示。T261–T263逐项验证及task-review自审PASS,当前248/263。详情按请求汇总已加载事件,词值跨阶段/分页去重,时间为最早命中观测(无命中时为最早检查);旧快照缺词值提示继续保留。6新增反例先RED后GREEN;DSH 25文件285PASS,完整前端lint/typecheck/i18n/生成检查、Platform构建、架构守卫和diff通过。不改backend/Gateway事件、数据库和真实109环境;原15项待验不变。本轮未提交、推送或部署,详见implementation-verification。 + + +2026-10-28 Wave52:用户要求去掉统计版本。T264–T266完成并task-review自审PASS,当前251/266。共享统计组件删除版本展示,保留状态、更新时间、命中事实及正确计数可见性;两项反例先RED后GREEN,DSH 25文件287PASS,完整lint/typecheck、架构及差异检查通过。前轮命中词汇总改动保留;原15项待验不变。本轮未提交、推送或部署。 + + +2026-10-28 Wave53:用户要求简化请求详情并加分类标题。T267–T269逐项验证和task-review自审PASS,当前254/269。详情收敛四项业务轴及白名单失败原因,隐藏技术/重复字段,模型结果合并且区分未知/未调用;原文权限、头部时间及统计/安全逻辑保留。DSH 25文件287PASS,完整前端lint/typecheck/i18n/生成检查、生产构建、1440px/500px实际组件及架构/diff通过。临时预览设施已清理,原15项待验不变;未提交、推送或部署。 + + +2026-10-29 Wave54:用户确认仅隐藏未就绪统计、保留会话/请求,只保留输入输出检查开关且无需旧配置兼容,并明确第三项只改变会话内请求展示。T270–T279逐项验证与task-review自审PASS,当前264/279。会话内先过滤两类辅助请求再分页,外层会话分页、未关联列表及全部已收录请求统计不动;无新表/字段/索引、Gateway或Desktop改动。前端26文件295PASS、后端查询组合108PASS,完整lint/typecheck、Ruff、三语/生成检查、生产构建及实际组件默认桌面/500px核验通过,临时设施已清理。原15项环境待验不变;未提交、推送或部署。 diff --git a/features/v3.0.0/111-taskmode-pptx-templates-preview/design.md b/features/v3.0.0/111-taskmode-pptx-templates-preview/design.md new file mode 100644 index 0000000000..bf42b13845 --- /dev/null +++ b/features/v3.0.0/111-taskmode-pptx-templates-preview/design.md @@ -0,0 +1,297 @@ +# Design: 任务模式 PPT 内置模板与在线预览 + +**关联**: [spec.md](./spec.md) · [tasks.md](./tasks.md) · [discovery.md](./discovery.md) +**版本**: v3.0.0(发版线 `feat/3.0.0`) +**最后更新**: 2026-10-27(初版,待 ★ 确认) + +--- + +## 1. 目标与非目标 + +- **目标**: + - 把任务模式 PPT 的生成方式从"模型现场写坐标"改为"克隆模板原型页,再按插槽填内容",并补齐容量、版面节奏、示例残留三类检查。 + - 让任务模式结果区能在线逐页查看 pptx 交付物。 +- **非目标**: + - 不引入新运行时:不加 Node,也不做 PPTD 编译器。 + - 不新增模型工具,继续走代码执行器加技能包脚本。 + - 不做模板选择界面和企业模板库。 + - 不改知识库的 pptx 预览。 + - 不改用户上传模板的生成路线。 + +--- + +## 2. 关键约束 + +全局铁律遵循 `docs/constitution.md` C1–C8。Constitution Check 结论: + +- C1:新接口只调用服务层;转换、缓存、锁都在 domain service 里。 +- C4:灵思会话不是 F048 资源类型(`permission/` 只登记了 `linsight_skill`),现有判定是"所有者、管理员、分享链接"。本功能沿用这套判定,不查询 OpenFGA,也不新增资源类型。 +- C5:使用 11025,登记在 release-contract。 +- C8:预览结果在 MinIO,锁在 Redis。 + +本功能特有的约束如下: + +- **技能只能经代码执行器生效**。脚本必须 exit 0,结果用 stdout 表达;子进程用 `sys.executable` 启动;cwd 是工作区根。相关规则见 `builtin_skills/AGENTS.md`。E2B 执行器看不到 `skills/` 目录。 +- **部署是纯文本环境**。平台默认的 dashscope 通道不收 base64 图片,因此模型选模板、做检查都不能依赖看图。 +- **用户端字体不可知**。文件最终由用户机器上的 PowerPoint 或 WPS 渲染;服务器只有 `fonts-wqy-zenhei`(见 `base.Dockerfile:13`)。 +- **pre-commit 限制单文件不超过 500KB**(`.pre-commit-config.yaml:13-14`,`src/backend/bisheng/**` 不在豁免列表里)。 +- **多节点(C8)**。预览产物放 MinIO,单飞锁放 Redis;本地磁盘只存一次转换用的临时文件。 +- **错误码**。灵思模块 110 段内取 **11025**。已在发版线和 vibe 两条线 grep 确认未占用;登记见 release-contract 的"已分配模块编码"。 + +--- + +## 3. 方案对比与选定 + +### 决策 1:模板的承载形式 + +- **备选**: + - A. 用 python 版式函数(如 `layout_cards(prs, theme, ...)`)表达模板。代码即模板,便于参数化;缺点是设计师无法在 PowerPoint 里直接看和改,视觉调整全靠改代码。 + - B. 把带占位符的 slideLayout 放进母版。PowerPoint 原生支持;但卡片、指标、流程这类多元素结构要配大量占位符,python-pptx 又不能直接创建版式,维护成本高。 + - C. **原型页 pptx,加插槽清单**。模板就是一份普通 pptx,每页是一个原型版式,可编辑元素按约定命名。模型克隆原型页后按名字填写,生成脚本同时产出插槽清单(`layouts.json`)。 +- **选定**:C。 +- **原因**: + - 原型页可以直接在 PowerPoint 里打开审阅,满足 AC-31 的签字要求。 + - 克隆整页能保留全部结构元素和样式。 + - 用户自建的 `soe-closeout-deck` 已在央国企结项材料上实证过"克隆原型页、按插槽名填写"这套做法。 + - 插槽清单给检查脚本提供容量和示例文字,使 AC-10、AC-11 可以实现。 +- **何时该重新考虑**:进入第二期、引入声明式格式和编译器时,模板改由编译器输入表达。 + +### 决策 2:模板由生成脚本产出,不手工绘制 + +- **备选**:A. 在 PowerPoint 里手工做 5 份 pptx;B. 写开发期生成脚本,从主题令牌和版式几何库产出 pptx 与插槽清单。 +- **选定**:B。脚本放在 `src/backend/scripts/pptx_templates/`,生成产物提交进仓库。 +- **原因**: + - 5 套模板共用 13 类版式,几何只需写一次。 + - 插槽容量由框尺寸和字号自动算出,不会和图形漂移。 + - 改配色或改版式只需重跑脚本。 + - 手工 pptx 的元素命名和容量要靠人维护,容易出错。 +- **何时该重新考虑**:设计师要直接交付手绘模板时。此时保留插槽命名约定,改为用一个"校验加抽取清单"脚本替代生成脚本。 + +### 决策 3:主题色和字体同时写进主题 XML + +- **选定**:生成脚本做两件事。 + - 把每套模板的配色写入主题 `accent1–6`、`dk1/lt1/dk2/lt2`,把中西文字体写入 `majorFont/minorFont` 的 `latin` 和 `ea`。 + - 原型页上的文字同样逐 run 写 `latin` 和 `ea`。 +- **原因**: + - 模型在模板上新增的文字、python-pptx 新建的原生图表,都会默认继承主题,不需要模型逐一设色设字体。 + - 只写主题会被 run 级属性覆盖,只写 run 又覆盖不到新元素,所以两处都写。 +- **被否方案**:只在 run 上写字体。这样新加的图表、表格会回退到 Calibri 加宋体。 +- **何时该重新考虑**:需要支持用户在 PowerPoint 里一键换主题配色,并且原型页改为全部使用主题色引用(`schemeClr`)时,可以去掉 run 级颜色。 + +### 决策 4:图表不随原型页克隆,改为按 `chart_area` 现放 + +- **备选**:A. 原型页里放原生图表,克隆后调用 `replace_data`;B. 原型页里放一个命名为 `chart_area` 的占位框,由 `Deck.chart()` 在该位置新建原生图表并删掉占位框。 +- **选定**:B。 +- **原因**: + - 图表是独立 part。按 `soe-closeout-deck` 的克隆法,只会复制关系、共用同一个 part,结果是改一页的数据,另一页的图跟着变。若要深拷贝 chart part,需要自己处理 partname 和嵌入的 xlsx,风险高。 + - 新建图表的系列色能通过决策 3 自动取到主题色。 +- **何时该重新考虑**:需要预置复杂组合图(例如桥图)时,再实现 chart part 深拷贝。 + +### 决策 5:新检查放在独立模块,按原型页做基线差分 + +- **选定**: + - 新建 `scripts/deck_checks.py` 承载四项新检查,由 `inspect_deck.py` 调用并合并输出。 + - 当存在 `Deck.save()` 写出的 manifest 时,对每张输出页,先在对应原型页上跑同一批既有检查得到基线;输出页上"同检查项、同元素名"的发现视为模板自带,不报(AC-11)。 + - 没有 manifest 时(从零生成或用户模板路线):容量检查和示例残留检查跳过;连续同版式检查改用构图轮廓判断;封面焦点检查照常执行。 +- **原因**: + - `builtin_skills/AGENTS.md:29` 要求有基线时报差值。 + - 不做差分的话,模板自带的结构线会撞上既有的"文字压装饰线"检查,产生大量误报,模型会学会无视检查。 + - `inspect_deck.py` 已有约 600 行,拆模块是为了可读性。 +- **级别**:四项新检查都报 WARN,遵守 `AGENTS.md:25`。是否升为 ERROR,等 AC-30 的真实产物标定后再决定。 +- **何时该重新考虑**:如果基线差分仍有误报(同名元素在填写后位置变化),改为按"原型页上的元素集合"整体豁免结构元素;如果第二期引入编译器,检查迁入编译器。 + +### 决策 6:pptx 预览采用"按需转换加对象存储缓存",不在任务结束时预生成 + +- **备选**: + - A. 任务结束时在 linsight worker 里统一转 PDF。缺点:每个任务都要多花几十秒,用户不看也要付这个成本;历史任务覆盖不到。 + - B. 打开时按需转换。结果写 MinIO,用 Redis 单飞锁保证同一文件只转一次;在 API 进程内用线程执行转换。 + - C. 投递 Celery 异步转换。会引入新队列路由,而现有同类按需转换(`download-md-to-pdf-or-docx`)都在请求内完成。 +- **选定**:B。 +- **原因**: + - 用户只为自己看的文件付转换成本。 + - 历史任务也能预览(spec §3)。 + - 前端已有 `FilePreview` 把 pptx 路由到 PDF 查看器(`packages/file-viewers/src/formats.ts`),后端只需交付一个 PDF 地址。 +- **代价与护栏**: + - LibreOffice 转换占 CPU。每个 API 进程用 `asyncio.Semaphore(2)` 限制并发转换数。 + - 转换超时 120 秒,前端等待上限 90 秒。若超出 90 秒,下次打开会直接命中后端已完成的缓存。 +- **何时该重新考虑**:如果转换让 API 进程的 CPU 长期打满,改为方案 C。 + +### 决策 7:预览接口的鉴权,与下载接口抽成同一个函数 + +- **选定**: + - 把 `linsight_file_download` 里"会话所有者、管理员、两种分享形态"的判断,抽成服务函数 `LinsightArtifactPreviewService.load_accessible_version()`。下载和预览两个接口都调用它(AC-24)。 + - 预览接口另加一层文件归属校验:`file_url` 去掉桶前缀后,必须以 `linsight/final_result/{svid}/` 开头,不得含 `..`,扩展名只能是 `.ppt` 或 `.pptx`。 +- **原因**:两处各写一份权限判断会漂移。灵思会话是所有者模型,没有接入 F048 资源授权,因此沿用现有判定,不新建 OpenFGA 资源类型。 +- **被否方案**:照抄 `download-md-to-pdf-or-docx` 的写法。它不校验 `file_url` 的归属(见 §8)。 +- **何时该重新考虑**:灵思会话接入 F048 资源授权(成为 `require_business_action` 的资源类型)时,`load_accessible_version` 改为调用统一授权运行时。 + +--- + +## 4. 系统现状(实现后的样子) + +### 4.1 PPT 生成数据流 + +1. **种子化技能包**。启动时,`builtin_skill_seeder.py` 把 `bisheng-pptx`(含 `templates/**`,按字节读取)写进 MinIO 的技能包对象。 +2. **物化到工作区**。任务开始时,`task_exec._provision_session_skills` 把技能文件物化到工作区 `skills/bisheng-pptx/`。 +3. **模型选模板**。模型读 SKILL.md,运行 `list_templates.py` 得到模板和版式的文字目录。 +4. **写大纲**。模型写 `scratch/outline.md`,每页一行:首读结论、`layout_id`、证据。 +5. **写构建脚本**。模型写 `scratch/build_deck.py`,用 `deck_template.Deck` 依次 `add` 原型页、`fill` 插槽、放 `chart` / `table` / `image`,最后 `save` 到 `output/<名>.pptx`。`save` 会删除原型页,并写出 `scratch/<名>.manifest.json`。 +6. **检查**。`inspect_deck.py output/<名>.pptx` 自动查找同名 manifest,合并既有检查与 `deck_checks` 的结果,输出带页码和元素名的发现。 +7. **交付**。ERROR 清零后交付;原有的交付物上传逻辑(`linsight/domain/utils.py:get_final_result_file`)不变。 + +### 4.2 pptx 预览数据流 + +1. 结果区 `PreviewBody` 遇到 slides 类型的文件,调用 `getLinsightFilePreviewApi(file_url, versionId, shareToken)`。 +2. 后端 `POST /api/v1/linsight/workbench/file_preview` 先用 `load_accessible_version` 鉴权,再校验文件归属,然后调用 `LinsightArtifactPreviewService.get_pptx_preview()`: + - 缓存对象 `linsight/preview/{svid}/{file_id}.pdf` 已存在:返回 `{status: "ready", file_path}`。 + - 缓存不存在且抢到锁(`SET NX EX 180`):从 MinIO 读原文件写入临时目录,在信号量内用线程运行 `convert_ppt_to_pdf`,再用 PyMuPDF 校验页数大于 0,然后 `put_object`、释放锁,返回 ready。 + - 没抢到锁:返回 `{status: "converting"}`。 + - 转换失败:抛出 `LinsightArtifactPreviewError(11025)`,释放锁,不写缓存。 +3. 前端拿到 `converting` 时每 2 秒轮询一次,90 秒后放弃。拿到 ready 后渲染 ``,上方显示 AC-26 的提示。失败时走现有的"下载查看"兜底。 + +### 4.3 关键数据结构与约定 + +| 结构 | 格式 | 说明 | 消费者 | +|---|---|---|---| +| 插槽命名 | shape.name = ``,组内元素为 `.`(如 `card3.title`) | 由生成脚本写入;`drop("card3")` 删除整组 | `Deck`、`deck_checks` | +| `templates/catalog.json` | `{templates:[{id,name,scenes,palette,fonts,layout_count}]}` | 模板总目录 | `list_templates.py` | +| `templates//layouts.json` | `{id, layouts:[{layout_id, slide_index, family, use_for, slots:[{name, role, capacity, max_lines, sample}], groups:[...]}]}` | `role` 取值:`title`、`subtitle`、`body`、`caption`、`number`、`label`、`chart_area`、`table`、`image_area`;`capacity` 按字数计 | `Deck`、`deck_checks` | +| 字数口径 | 汉字或全角字符记 1,其余字符每 2 个记 1 | 容量与填写字数用同一口径 | 生成脚本、`Deck`、`deck_checks` | +| `scratch/<名>.manifest.json` | `{template, pages:[{index, layout_id, filled:{slot: chars}, over_capacity:[...]}]}` | `Deck.save` 写出 | `inspect_deck` | +| 预览接口入参 | `{file_url, session_version_id}` + 可选 `share-token` 头 | 与下载接口一致 | client | +| 预览接口出参 | `{status: "ready", file_path}` 或 `{status: "converting"}`;失败返回错误码 11025 | `file_path` 与下载接口同形态(去掉 host 的签名地址) | client | +| 预览缓存键 | `linsight/preview/{svid}/{file_id}.pdf`,主桶 | `file_id` 取原键文件名去掉扩展名 | 后端 | +| 单飞锁键 | `linsight:pptx_preview:{svid}:{file_id}`,过期 180 秒 | 转换结束(成功或失败)都会删除 | 后端 | + +### 4.4 模板与版式 + +5 套模板的 id 分别是 `gov-red`、`blueprint`、`operating`、`tech-dark`、`classroom`,画布为 16:9(13.333 × 7.5 英寸)。 + +13 类共用版式的 `layout_id` 如下: + +- `cover`、`agenda`、`section`、`statement` +- `two-column`、`cards-3`、`cards-4`、`kpi-4` +- `chart-insight`、`compare`、`process`、`timeline`、`table`、`closing` + +其中 `cards-3` 和 `cards-4` 同属"卡片网格"一类,所以是 14 个 id、13 类版式。 + +各套的专属版式: + +| 模板 | 专属版式 | +|---|---| +| `blueprint` | `architecture`(分层架构)、`risk-register`(风险登记) | +| `operating` | `scorecard`(计分表,突出异常行)、`bridge`(贡献对账) | +| `classroom` | `objectives`(学习目标)、`quiz`(自测) | +| `gov-red` | `honor-wall`(成果荣誉) | +| `tech-dark` | `feature-hero`(产品亮点大图) | + +字体:中文用微软雅黑,`gov-red` 的标题用黑体;西文用 Arial。原型页只放矢量元素和示例文字,示例文字都标注"示例"。 + +### 4.5 关键模块职责 + +| 模块与文件 | 职责 | 不做什么 | +|---|---|---| +| `src/backend/scripts/pptx_templates/{themes,layouts,build_templates}.py` | 开发期产出 5 份 `template.pptx`、`layouts.json` 和 `catalog.json` | 不随技能包下发 | +| `bisheng-pptx/scripts/deck_template.py` | `Deck.open/add/fill/drop/table/chart/image/save`;克隆页面并改写 rId;填写时记录超容量;保存时删除原型页并写 manifest | 不做检查判定(交给 `deck_checks`) | +| `bisheng-pptx/scripts/deck_render.py` | 原生图表(含瀑布图)与表格的渲染,生成脚本和 `Deck` 共用 | 不读写模板清单 | +| `bisheng-pptx/scripts/list_templates.py` | 以纯文本列出模板、适用场景和版式表 | 不渲染图片 | +| `bisheng-pptx/scripts/deck_checks.py` | 实现四项新检查和基线差分 | 不打印"结论: 通过"(只有 `inspect_*` 可以打印) | +| `bisheng-pptx/scripts/inspect_deck.py` | 新增 `--manifest` 参数并能自动查找;合并输出 | 不改变既有检查的阈值 | +| `linsight/domain/services/artifact_preview_service.py` | 会话版本鉴权、文件归属校验、缓存、单飞锁、转换 | 不处理 HTTP 细节 | +| `linsight/api/endpoints/linsight.py` | 新增 `file_preview` 接口;`file_download` 改调 `load_accessible_version` | 不在接口层写业务判断 | +| `knowledge/.../libreoffice_converter.py` | `convert_ppt_to_pdf` 增加可选参数 `timeout`,默认值保持 180 | 不改既有调用方的行为 | +| client `Linsight/Artifacts/{artifactUtils.ts, useSlidesPreview.ts, PreviewBody.tsx}` | 新增 slides 类型、轮询 hook、渲染加提示 | 不改用户上传文件的预览方式 | + +--- + +## 5. 已知坑与反直觉事实 + +| # | 反直觉事实 | 如果不知道会怎样 | 在哪处理 | +|---|---|---|---| +| 1 | python-pptx 不能复制整页。克隆后 rId 由库重新分配,必须改写形状里的 `r:embed` 和 `r:id` | 图片指向错位,或 PowerPoint 报文件损坏 | `Deck.add`(逻辑参考 `soe-closeout-deck/deckkit.py:103`) | +| 2 | 图表是独立 part,复制关系会让两页共用同一个 chart part | 改一页的数据,另一页跟着变 | 决策 4:图表用 `chart_area` 现放 | +| 3 | 删页必须同时 `drop_rel` 并移除 `sldId` | 文件提示"已损坏" | `Deck.save` | +| 4 | `run.font.name` 只写 ``,`` 必须紧跟其后 | 中文回退宋体;`ea` 位置不对时 PowerPoint 打不开文件 | 复用 `pptx_helpers.set_font` | +| 5 | `add_textbox()` 默认写 `spAutoFit` | 溢出检查形同虚设 | 生成脚本显式设为 `MSO_AUTO_SIZE.NONE` | +| 6 | 有 manifest 时不做基线差分,模板结构线会命中"文字压装饰线"检查 | 大量误报,模型学会无视检查 | 决策 5 | +| 7 | 执行器失败时丢弃 stdout | 非零退出会让检查报告整份消失 | 所有脚本 exit 0(已有静态守卫) | +| 8 | `convert_ppt_to_pdf` 失败时返回 `None` 或 `False`,只记 debug 日志 | 失败被当作成功,缓存了空结果 | 服务层判断返回值,并用 PyMuPDF 校验页数大于 0 | +| 9 | 服务器渲染只有文泉驿正黑 | 预览的字宽与 PowerPoint 不同,可能被误判为溢出 | AC-26 的提示;检查以字数容量为准,不依赖渲染 | +| 10 | 用户上传的文件在结果区固定按解析正文预览(`getArtifactPreviewKind`) | 误把上传的 pptx 也走转换 | slides 类型只作用于任务生成的文件 | +| 11 | 生成脚本每次输出的 zip 时间戳不同 | 每次重跑都产生二进制 diff | 只在模板确有变更时重跑并提交 | +| 12 | 管理端技能文件查看页按 UTF-8 解码 | `.pptx` 显示成乱码 | 已知限制,列入 §8 | +| 13 | 没有 manifest 时基线扣除不生效,模板自带的出血装饰、贴边页脚会直接报 ERROR/WARN | 任何从模板做的文稿只要清单丢了就"不通过" | 模板本身做到对既有检查零 ERROR/WARN(装饰收进画布、页脚留 30pt、页码段落声明字号),由 `test_template_matches_its_slot_manifest` 与原型页检查守住 | +| 14 | 页码是 ``,python-pptx 的 `para.runs` 看不到它 | 检查脚本按默认 18pt 估算,报页码框溢出 | 生成脚本给页码段落写 `defRPr` 字号 | + +--- + +## 6. 对外契约与依赖 + +### 6.1 我提供给别人的(Outgoing) + +| 契约 | 形式 | 谁在用 | +|---|---|---| +| `POST /api/v1/linsight/workbench/file_preview` | HTTP API | client 任务模式结果区、分享页 | +| 错误码 11025 `LinsightArtifactPreviewError` | 错误码,三语文案在 `packages/locales` 的 `api_errors` | client | +| `LinsightArtifactPreviewService.load_accessible_version` | 内部 Python API | `file_download`、`file_preview` | +| 技能包 `deck_template` 接口、插槽命名、`layouts.json` 结构 | 技能内部契约 | 模型生成的构建脚本、`deck_checks` | + +### 6.2 我依赖别人的(Incoming) + +| 依赖 | 形式 | 风险点 | +|---|---|---| +| LibreOffice(含 Impress) | 系统依赖,`base.Dockerfile:12` | 裁剪过的环境(例如 114 只装了 writer)会转换失败,此时走 AC-23 兜底 | +| `packages/file-viewers` 把 pptx 映射到 pdf | 前端共享包(F089) | 该映射改变后,预览会失效 | +| 交付物键 `linsight/final_result/{svid}/{file_id}{ext}` | 隐式数据契约(`linsight/domain/utils.py:561`) | 键格式改变后,归属校验会拒绝合法文件 | +| 技能种子化按字节复制,且不按扩展名过滤 | `builtin_skill_seeder._read_bundle` | 若加了扩展名白名单,模板会被丢弃 | + +### 6.3 发版契约登记 + +- 表 1 增加一行:无新增领域对象,F111。 +- 表 3 增加 F111 的依赖:F089 和既有内置技能机制。 +- 已分配模块编码增加 11025。 +- 本功能不新增不变量,并遵守 V3-INV-7:派生预览不改变原文件下载的含义。 + +--- + +## 7. 测试与可观测 + +- **技能包单测**(`src/backend/test/linsight/test_pptx_templates.py`): + - 每份模板都能打开;`layouts.json` 里的每个插槽在 pptx 中都存在;示例文字一致。 + - 走一遍 `Deck` 全流程后,产物能重新打开、原型页已删除、manifest 正确。 + - 四项新检查做双向测试:坏样本能命中,按模板正确填写的好样本保持安静(AC-11)。 + - 种子化测试确认 `templates/**` 的字节随技能包下发(AC-13)。 +- **预览服务单测**(`src/backend/test/linsight/test_artifact_preview_service.py`): + - 覆盖权限四种情形:所有者、管理员、分享、无权。 + - 覆盖归属校验、缓存命中、锁竞争返回 `converting`、转换失败返回 11025 且不写缓存。 + - mock MinIO、Redis 和转换函数,CI 不依赖 soffice。 +- **前端**:`artifactUtils` 补单测;手动验证 ready、converting、失败三种状态,以及分享页。 +- **端到端**(AC-30):在 114 上用三类题目,分别在改造前后各跑一次,渲染逐页联系表归档到 `evidence/`。114 缺 Impress 组件,所以预览验证放在装有完整 LibreOffice 的环境(116 或 180)上做。 +- **可观测**:转换耗时、失败原因记为 `logger.info` 和 `logger.warning`,带上 `svid` 和 `file_id`。 +- **手动验证命令**: + - 单测:`cd src/backend && uv run pytest test/linsight/test_pptx_templates.py test/linsight/test_artifact_preview_service.py test/linsight/test_builtin_skill_scripts.py test/linsight/test_builtin_skill_seeder.py` + - 重新生成模板:`cd src/backend && python scripts/pptx_templates/build_templates.py`,然后用 `soffice --headless --convert-to pdf` 渲染逐页检查。 + - 端到端:在任务模式勾选代码执行器,输入"做一份介绍思源电气的 PPT,央国企风格"。完成后在结果区点开 pptx,确认依次出现生成中、逐页预览、提示条;再用分享链接打开同一结果,确认能预览。 + - 前端检查:`cd src/frontend && pnpm lint && pnpm typecheck && pnpm check-i18n` + +--- + +## 8. 后续改进与不打算做的事 + +- **既有安全缺口(本期不修,单独提出)**: + - `file_download` 不校验 `file_url` 是否属于该会话版本。只要能访问某个版本,就能签出任意对象键。 + - `download-md-to-pdf-or-docx` 和 `batch-download-files` 同样不校验。 + - 建议另立一个 hotfix:抽出 `load_accessible_version` 后,补上前缀校验。 +- **第二期**:声明式格式加编译器、工具层门禁、模板选择界面、企业模板库、配图。 +- 新检查升为 ERROR 的时机:AC-30 标定后决定。 + +--- + +## 修订历史 + +| 日期 | 改动 | 触发原因 | +|---|---|---| +| 2026-10-27 | 初版 | spec 确认 | +| 2026-10-27 | 预览转换:先占转换名额再加锁,加锁后复查缓存,锁按持有者令牌释放;转换失败写 5 分钟失败标记(AC-23 改为冷却期后重试);文件读写、LibreOffice 和页数统计整体移出事件循环 | `/code-review` 指出锁过期后重复转换、误删他人锁、失败后每次轮询重转、阻塞事件循环 | +| 2026-10-27 | 访问规则:分享链接匹配先于管理员判断,管理员只在请求所在租户内放行(AC-24);下载接口也要求 `file_url` 属于该任务版本(`final_result/{svid}/`、`session_files/{svid}/`、`linsight/{session_id}/`) | 任意分享令牌会关闭租户过滤,原写法让其他租户管理员可读;此问题早于 F111(fbfdc38dc) | +| 2026-10-27 | 技能脚本:基线扣除改为整条消息一致才扣;清单记录每页形状名,与文件对不上即忽略;表格列数与模板不同时不用模板列宽;超容量提示的页码按位置计算;`deck_checks` 改为在 `run_report` 内导入,加载失败仍输出基础体检;瀑布图减项标签带负号,累计值跌破 0 时报错 | `/code-review` 指出真实溢出被模板基线吞掉、旧清单误用、表格超宽、页码错、导入失败无输出、减项标签无符号 | diff --git a/features/v3.0.0/111-taskmode-pptx-templates-preview/discovery.md b/features/v3.0.0/111-taskmode-pptx-templates-preview/discovery.md new file mode 100644 index 0000000000..cbcf051b4b --- /dev/null +++ b/features/v3.0.0/111-taskmode-pptx-templates-preview/discovery.md @@ -0,0 +1,46 @@ +# F111 Discovery:任务模式 PPT 质量与在线预览 + +日期:2026-10-27。所属版本:v3.0.0(发版线 `feat/3.0.0`)。 + +## 1. 起因 + +用户反馈任务模式做出的 PPT 效果不好,并判断主要受内置 PPT 技能 `bisheng-pptx` 的影响。用户要求先调研 dsh-desktop 的 PPT 生成做法,再反推当前问题和改进方向。 + +## 2. 调研结论(dsh-desktop `67df671`,2026-10-23) + +dsh-desktop 的 PPT 能力在 `packages/ppt-runtime`,做法有四个要点: + +1. 模型不写生成代码。模型写声明式中间格式 PPTD(YAML 清单加分页文件),再由确定性编译器输出可编辑 PPTX。 +2. 质量主要来自模板。内置 16 套模板、192 个版式,每个文字区都标了字数容量(`textCapacity`)。模板由用户在界面上选择,模型负责组织内容、选版式、填空。 +3. 检查是导出门禁。`pptd_check` 返回带文件、页码、元素 ID 的完整诊断,`pptd_render` 不通过就不导出。检查项包含连续页同构图(`layout-repetition`)和封面首读焦点(`cover-focus`)。 +4. 技能正文以内容组织为主,例如"结论—主图—辅助证据—解读"。 + +对照 bisheng-pptx,根因判断如下:bisheng-pptx 让模型每次用 python-pptx 从零写坐标、现场设计版式,没有可调用的参考版式。因此质量方差大,版式趋于单调。检查脚本只给建议,不是门禁;也没有容量、版面节奏类检查。任务模式结果区不能预览 pptx,用户和模型交付前都看不到成品。 + +证据等级:上述根因判断来自静态对比,尚未用现网差样本逐份归类。端到端验收(spec AC-30)负责用改造前后对比来检验它。 + +## 3. 用户决策 + +| # | 决策 | 日期 | +|---|---|---| +| D1 | 做"第一期"(只改技能包,不引入新运行时)加 pptx 在线预览 | 2026-10-27 | +| D2 | 内置 4–6 套模板,由实施方按客户场景挑选;本期定 5 套 | 2026-10-27 | +| D3 | 走完整 SDD | 2026-10-27 | +| D4 | 基于发版线 `feat/3.0.0` 开发,合入后由 vibe 线合下来 | 2026-10-27 | + +## 4. 模板选型理由 + +dsh 的 16 套模板大多是欧美杂志风:米色底,使用 Space Grotesk、Playfair Display 等 Windows 上没有的字体。这些模板不贴合 bisheng 的央国企、政企、金融、制造客户。所以本期按客户场景自建 5 套模板,只借鉴 dsh 部分模板的版式结构(MIT 许可)。5 套分别是: + +- 红韵:央国企与党政汇报。 +- 蓝图:项目与技术方案。 +- 经营:管理层经营与数据汇报。 +- 深空:科技产品发布。 +- 课堂:培训课件。 + +## 5. 明确留到后续 + +- 声明式中间格式与编译器(第二期)。 +- 模板选择界面。 +- 企业模板库(租户级)。 +- 配图生成。 diff --git a/features/v3.0.0/111-taskmode-pptx-templates-preview/spec.md b/features/v3.0.0/111-taskmode-pptx-templates-preview/spec.md new file mode 100644 index 0000000000..9c00dee7e7 --- /dev/null +++ b/features/v3.0.0/111-taskmode-pptx-templates-preview/spec.md @@ -0,0 +1,121 @@ +# Feature: 任务模式 PPT 内置模板与在线预览 + +> **本文档定位 — 纯 What**。本文只回答做什么、验收标准和不做什么。How 的唯一真相在 [design.md](./design.md) 与 [tasks.md](./tasks.md)。 + +**关联需求**: [discovery.md](./discovery.md)(dsh-desktop 调研结论及用户决策 D1–D4) +**优先级**: P1 +**所属版本**: v3.0.0(发版线 `feat/3.0.0`) +**依赖**: 既有任务模式内置技能机制、任务模式结果区预览、F089 共享文件预览(PDF 查看器) + +> **范围边界** +> - **本次纳入**: +> - PPT 技能内置 5 套场景模板:红韵(央国企与党政汇报)、蓝图(项目与技术方案)、经营(管理层经营与数据汇报)、深空(科技产品发布)、课堂(培训课件)。生成方式改为"按模板版式填写内容"。 +> - PPT 技能的交付前检查新增四项:容量超限、示例文字残留、连续同版式、封面焦点。 +> - PPT 技能的指引改为内容先行:每页先确定一句首读结论,再选版式。 +> - 任务模式结果区对 `.pptx` / `.ppt` 交付物的在线预览,含分享页。 +> - **本次明确排除**: +> - 声明式中间格式与编译器、模型专用 PPT 工具(第二期;本期继续走代码执行器)。 +> - 让用户在界面上挑选模板的入口(本期由用户在对话里说明风格或场景)。 +> - 企业或租户级模板库,以及"上传一次模板后反复复用"(另立 feature)。 +> - 用户上传模板路线的生成方式改造(保持现状)。 +> - 配图生成。 +> - 把交付前检查升级为不可绕过的工具层门禁(本期仍由技能指引要求模型执行)。 +> - 任务模式中用户上传的 pptx 的预览方式(保持现有"解析正文"预览)。 +> - 日常模式、知识库等其他入口的 pptx 预览(知识库已有预览,不改)。 +> - 管理端技能文件查看页对二进制文件显示乱码(已知限制,不修)。 + +--- + +## 1. 用户故事 + +- **A. 业务用户**:作为在任务模式里做汇报材料的员工,我希望说一句"做一份年度工作汇报,央国企风格",就能拿到版式专业、页面有变化、不压字的 PPT,以便少返工、直接用于汇报。 +- **B. 业务用户**:作为拿到 PPT 交付物的员工,我希望在结果区直接翻页查看,不用先下载再用 Office 打开,以便快速判断要不要让任务继续修改。 +- **C. 分享接收人**:作为通过分享链接查看任务结果的人,我希望同样能在线翻看 PPT,以便在没有 Office 的设备上也能审阅。 +- **D. 平台管理员**:作为维护内置技能的管理员,我希望新模板随部署自动可用,并且我对技能的定制不被升级覆盖,以便升级时不用手工处理。 + +--- + +## 2. 验收标准 + +### 2.1 内置模板与生成 + +- **AC-01** — THE SYSTEM SHALL 随部署内置 5 套 PPT 模板:红韵、蓝图、经营、深空、课堂。每套至少 12 个可编辑版式。每套都覆盖封面、目录、章节页、观点页、图文两栏、卡片网格、大数字指标、图表加解读、对比、流程、时间轴或路线图、表格、结尾这 13 类,各套另有符合其场景的专属版式。 +- **AC-02** — WHEN 用户在任务模式中要求制作 PPT、未上传模板、也未要求自由设计, THE SYSTEM SHALL 基于一套内置模板生成。用户点名风格、行业或场景时,选用对应模板。 +- **AC-03** — WHEN 用户上传 .pptx/.potx 模板并要求套用, THE SYSTEM SHALL 以用户模板为准生成,不改用内置模板。 +- **AC-04** — WHEN 用户明确要求自由设计,或 5 套模板均不贴合主题, THE SYSTEM SHALL 仍能从零生成 PPT,交付前检查照常执行。 +- **AC-05** — THE SYSTEM SHALL 使交付的 PPT 中标题、正文、表格和图表都是可在 PowerPoint/WPS 中直接编辑的原生对象。不得用整页图片代替文字、表格或图表。 +- **AC-06** — THE SYSTEM SHALL 使交付文件不包含模板原型页。 +- **AC-07** — THE SYSTEM SHALL 使模板中文文字声明东亚字体(微软雅黑或黑体)。在 Windows 版 PowerPoint 与 WPS 打开时,中文显示为模板指定字体,不回退为宋体或主题默认字体。 +- **AC-08** — THE SYSTEM SHALL 在 PPT 技能指引中要求先写逐页大纲,每页包含首读结论、所选版式和支撑证据,并要求按内容关系选择版式。 +- **AC-09** — WHEN 部署使用不暴露技能目录的执行环境(E2B), THE SYSTEM SHALL 让技能向用户说明模板和检查不可用,并改走从零生成。不得反复重试或静默产出低质量文件。 + +### 2.2 交付前检查 + +- **AC-10** — WHEN 模型对生成的 PPT 运行交付前检查, THE SYSTEM SHALL 逐条报告以下四类问题,每条注明页码和元素名称: + - 文字超出所在区域的建议字数容量。 + - 残留模板示例文字。 + - 连续 3 页及以上使用同一版式。 + - 封面缺少首读焦点:最大标题字号不到正文字号中位数的 3 倍,并且没有占画布 25% 以上的视觉元素。 +- **AC-11** — THE SYSTEM SHALL 不把模板自带的结构元素(分隔线、色块、编号底图)报告为问题。对一份按模板正确填写、未改动结构的文稿,四类新检查均不报。 +- **AC-12** — THE SYSTEM SHALL 保持既有检查(溢出、越界、文字重叠、字号过小、残留占位符等)的报告口径不变。本次新增的四类问题首期以"警告"级别报告,不改变"结论: 通过"的判定规则。 + +### 2.3 内置技能的分发 + +- **AC-13** — WHEN 平台启动或新建租户, THE SYSTEM SHALL 使新版 PPT 技能(含 5 套模板)对该租户的任务模式可用,模板文件在任务工作区中与源文件字节一致。 +- **AC-14** — IF 管理员已修改过内置 PPT 技能, THEN THE SYSTEM SHALL 在升级时保留管理员版本,不以新版覆盖。 + +### 2.4 pptx 在线预览 + +- **AC-20** — WHEN 用户在任务模式结果区打开一个由任务生成的 .pptx 或 .ppt 文件, THE SYSTEM SHALL 在结果区逐页展示该文件,支持翻页、缩放和滚动。 +- **AC-21** — WHILE 预览正在生成, THE SYSTEM SHALL 显示"正在生成预览"的状态。IF 超过 90 秒仍未生成, THEN THE SYSTEM SHALL 结束等待,按 AC-23 处理。 +- **AC-22** — WHEN 同一文件的预览已经生成过, THE SYSTEM SHALL 直接展示已生成的预览,不再重新生成。这一条对其他用户、其他服务节点和重启后都成立。 +- **AC-23** — IF 预览生成失败或超时, THEN THE SYSTEM SHALL 结束加载状态,显示本地化的失败提示,并提供原文件下载。不得显示空白的成功状态。失败后 5 分钟内再次请求,THE SYSTEM SHALL 直接返回失败,不重复转换(避免轮询和多个标签页反复占用转换资源);超过 5 分钟后再次打开,THE SYSTEM SHALL 重新尝试生成。 +- **AC-24** — THE SYSTEM SHALL 只向有权下载该文件的人提供预览,即任务所有者、本租户管理员和持有效分享链接者,与现有下载权限一致。持有与该任务无关的分享链接,不能让其他租户的管理员读取该任务的文件。IF 请求者无权访问,或请求的文件不属于所指的任务结果, THEN THE SYSTEM SHALL 拒绝请求,且不生成预览。 +- **AC-25** — WHEN 分享链接的接收人在分享页打开 pptx 交付物, THE SYSTEM SHALL 按 AC-20 至 AC-23 提供同样的预览。 +- **AC-26** — WHILE 展示 pptx 预览, THE SYSTEM SHALL 提示"预览为服务器渲染,字体与排版可能与 PowerPoint 略有差异,以下载文件为准"。 +- **AC-27** — WHEN 多个请求同时首次打开同一文件, THE SYSTEM SHALL 只生成一次预览,其余请求等待同一结果。 +- **AC-28** — THE SYSTEM SHALL 使"下载"仍然得到原始 pptx/ppt 文件。预览不改变交付物本身,也不改变结果区其他文件类型的预览方式。 +- **AC-29** — THE SYSTEM SHALL 为本功能新增的界面文案和错误提示同时提供简体中文、英文和日文。 + +### 2.5 交付验收 + +- **AC-30** — WHEN 用同一组题目(至少包含"央国企风格的企业介绍"、"项目技术方案"、"培训课件"三类)分别在改造前后的技能上各跑一次, THE SYSTEM SHALL 达到以下结果: + - 每份 8 页及以上的文稿至少使用 5 种不同版式。 + - 检查结论无 ERROR。 + - 渲染对比由产品确认优于改造前。 + - 对比材料(逐页联系表)随验收记录归档。 +- **AC-31** — THE SYSTEM SHALL 在合入前取得设计或产品对 5 套模板视觉的签字确认。 + +--- + +## 3. 边界情况 + +- 历史任务(本功能上线前生成)中的 pptx,打开时同样可以预览。 +- 交付物文件名含中文或空格时,预览与下载均正常。 +- 文件损坏或预览生成出 0 页时,按 AC-23 失败处理;5 分钟冷却期过后再次打开会重新尝试。 +- 用户只要求改已有 PPT 的文字时,沿用用户文件,不套内置模板。 +- 用户要求英文 PPT 时,模板版式同样可用,西文使用模板指定的西文字体。 +- 文稿超过模板版式可承载的内容时,拆成多页或精简文案,不靠缩小字号塞入。 +- 分享链接失效后,预览与下载一同不可用。 + +--- + +## 4. 设计与实现(指针) + +| 你想知道 | 去哪看 | +|---|---| +| 为什么这么实现(决策与备选) | design.md §3 | +| 关键约束(多节点、权限、错误码段) | design.md §2 | +| 模板结构、插槽清单、检查算法、预览接口 | design.md §4 | +| 代码里看不出的坑 | design.md §5 | +| 对外契约 | design.md §6 | +| 任务拆解与执行顺序 | tasks.md | + +--- + +## 相关文档 + +- 设计真相: [design.md](./design.md) +- 执行与落档: [tasks.md](./tasks.md) +- 版本契约: [../release-contract.md](../release-contract.md) +- 调研与决策: [discovery.md](./discovery.md) diff --git a/features/v3.0.0/111-taskmode-pptx-templates-preview/tasks.md b/features/v3.0.0/111-taskmode-pptx-templates-preview/tasks.md new file mode 100644 index 0000000000..e10ba8b59f --- /dev/null +++ b/features/v3.0.0/111-taskmode-pptx-templates-preview/tasks.md @@ -0,0 +1,262 @@ +# Tasks: 任务模式 PPT 内置模板与在线预览 + +**关联规格**: [spec.md](./spec.md) · [design.md](./design.md) +**版本**: v3.0.0(分支 `feat/3.0.0/111-taskmode-pptx-templates-preview`) + +--- + +## 状态 + +| 步骤 | 状态 | 备注 | +|------|------|------| +| spec.md | ✅ 已评审 | 2026-10-27 用户确认 | +| design.md | ✅ 已评审 | 2026-10-27 用户确认 | +| tasks.md | ✅ 已拆解 | `/sdd-review tasks` 一轮修订后 LGTM(拆分 T001→T017、T012→T018) | +| 实现 | 🔄 进行中 | 15 / 18 完成(剩 T014 视觉签字、T015 端到端、T016 质量门) | + +--- + +## 开发模式 + +- 按 Wave 组织任务。同一 Wave 内的任务互不依赖,可以并行。 +- 后端走 Test-First:先写测试再写实现,测试放在 `src/backend/test/linsight/`。 +- 前端用手动验证,每个任务附验证步骤。 +- 设计论证一律指向 design.md 的对应章节,本文不复制。 +- 并发子代理不得使用仓库级 git 写操作(见根 AGENTS.md §7)。 + +--- + +## Wave 1:模板与基础设施(互不依赖) + +- [x] **T001**: 模板生成脚本与 5 套模板(共用版式) + - **文件**: + - `src/backend/scripts/pptx_templates/{themes.py, layouts.py, build_templates.py}` + - 产物:`src/backend/bisheng/linsight/builtin_skills/bisheng-pptx/templates/{catalog.json, /template.pptx, /layouts.json}` + - **逻辑**: + - 5 套主题令牌,包含配色、中西文字体、`gov-red` 的标题字体。 + - 14 个共用版式的几何,版式清单见 design §4.4;专属版式由 T017 补充。 + - 插槽按 design §4.3 的约定命名,容量按 design §4.3 的字数口径从"框尺寸 × 字号"算出。 + - 主题 XML 写入 accent 色与 `latin`/`ea` 字体(design 决策 3);文本框设 `MSO_AUTO_SIZE.NONE`(design §5 第 5 条)。 + - 只用矢量元素,示例文字都标"示例"。 + - 每个 pptx 不超过 500KB。 + - 脚本遵守 `src/backend/scripts/AGENTS.md`,并且可重复运行。 + - **验证**:本地运行脚本,用 LibreOffice 渲染逐页联系表,交给用户看(为 AC-31 做准备)。 + - **覆盖 AC**:AC-01、AC-07 + - **依赖**:无 + +- [x] **T017**: 各套专属版式 + - **文件**:`src/backend/scripts/pptx_templates/layouts_special.py`,以及重新生成的 `templates/**` 产物 + - **逻辑**: + - 增加 8 个专属版式:`architecture`、`risk-register`、`scorecard`、`bridge`、`objectives`、`quiz`、`honor-wall`、`feature-hero`。 + - 由 `build_templates.py` 按模板挂载。 + - 插槽和容量约定与 T001 相同。 + - **验证**:同 T001 的联系表。 + - **覆盖 AC**:AC-01 + - **依赖**:T001 + +- [x] **T002**: 错误码 11025 与三语文案 + - **文件**: + - `src/backend/bisheng/common/errcode/linsight.py` + - `src/frontend/packages/locales/src/api_errors/{zh-Hans,en,ja}.json` + - 重新生成 `api_errors` 产物,不手改生成文件 + - **逻辑**:新增 `LinsightArtifactPreviewError`(Code 11025),文案为"PPT 预览生成失败,请下载后查看"及对应的英文、日文。 + - **覆盖 AC**:AC-23、AC-29 + - **依赖**:无 + +- [x] **T003**: `convert_ppt_to_pdf` 增加可选 `timeout` 参数 + - **文件**:`src/backend/bisheng/knowledge/rag/pipeline/loader/utils/libreoffice_converter.py` + - **逻辑**:增加可选参数 `timeout`,默认值保持 180,并透传给 `_convert_file_extension`。既有调用方的行为不变。 + - **覆盖 AC**:AC-21(为其提供条件) + - **依赖**:无 + +## Wave 2:技能脚本与预览后端 + +- [x] **T004**: `Deck` 库单测 + - **文件**:`src/backend/test/linsight/test_pptx_templates.py` + - **测试**: + - 每份模板都能打开;`layouts.json` 中的每个插槽在 pptx 里都存在;示例文字一致。(AC-01) + - 跑一遍 `Deck` 全流程(`add`/`fill`/`drop`/`table`/`chart`/`image`/`save`)后:产物能重新打开、原型页已删除、manifest 中的页数和 `layout_id` 正确、表格行数可增减、图表为原生图表。(AC-05、AC-06) + - 超容量的填写会写进 manifest 的 `over_capacity`。 + - **覆盖 AC**:AC-01、AC-05、AC-06 + - **依赖**:T001 + +- [x] **T005**: `deck_template.py` 实现 + - **文件**:`src/backend/bisheng/linsight/builtin_skills/bisheng-pptx/scripts/deck_template.py` + - **逻辑**: + - 实现 `Deck.open/add/fill/drop/table/chart/image/save`。 + - 克隆页面时改写 rId(design §5 第 1 条);图表按 `chart_area` 现放(design 决策 4);删页同时处理 `drop_rel` 和 `sldId`(design §5 第 3 条)。 + - 中文字体复用 `pptx_helpers.set_font`/`fill_text`。 + - 超容量时立即 `print` 提示。 + - 只当作库使用,不 `sys.exit` 非零。 + - **覆盖 AC**:AC-05、AC-06 + - **依赖**:T004 + +- [x] **T006**: `list_templates.py` + - **文件**:`.../bisheng-pptx/scripts/list_templates.py`,测试并入 `test_pptx_templates.py` + - **逻辑**: + - 无参数时,以纯文本列出模板:id、名称、适用场景、配色说明、版式数。 + - 传 `` 时,列出该模板的版式表:`layout_id`、适用内容、插槽及容量。 + - 始终 exit 0。 + - **覆盖 AC**:AC-02 + - **依赖**:T001 + +- [x] **T007**: 新检查单测 + - **文件**:`src/backend/test/linsight/test_pptx_templates.py` + - **测试**: + - 坏样本:超容量、示例残留、连续 3 页同一 `layout_id`、封面焦点不足,四项各命中一次,每条都带页码和元素名。 + - 好样本:按模板正确填写的文稿,四项新检查都不报,且模板结构线不触发既有的"文字压装饰线"检查(AC-11)。 + - 没有 manifest 的从零文稿:连续构图检查按轮廓判断;封面检查照常执行。 + - 既有用例保持不变。(AC-12) + - **覆盖 AC**:AC-10、AC-11、AC-12 + - **依赖**:T005 + +- [x] **T008**: `deck_checks.py` 与 `inspect_deck.py` 集成 + - **文件**:`.../scripts/deck_checks.py`、`.../scripts/inspect_deck.py` + - **逻辑**: + - 新增 `--manifest` 参数,未传时自动查找 `scratch/<名>.manifest.json`。 + - 实现基线差分(design 决策 5)。 + - 新检查一律报 WARN;"结论: 通过"仍只由 `inspect_deck` 打印。 + - **覆盖 AC**:AC-10、AC-11、AC-12 + - **依赖**:T007 + +- [x] **T009**: 预览服务单测 + - **文件**:`src/backend/test/linsight/test_artifact_preview_service.py` + - **测试**: + - 鉴权:所有者、管理员、`workbench_chat` 分享(`resource_id` 等于会话)、按版本分享都放行;无权限的请求被拒绝。(AC-24、AC-25) + - 归属校验:带 `..`、跨会话版本前缀、非 ppt/pptx 后缀,三种情况都被拒绝,且不会触发转换。(AC-24) + - 缓存已存在:直接返回 ready,转换函数不被调用。(AC-22) + - 锁被占用:返回 `converting`。(AC-27) + - 转换返回 `None`,或产出 0 页:抛出 11025、释放锁、不写缓存,再次请求会重试。(AC-23) + - 下载接口改为调用共用函数后,行为与原来一致。(AC-28) + - MinIO、Redis 和转换函数全部 mock。 + - **覆盖 AC**:AC-22、AC-23、AC-24、AC-25、AC-27、AC-28 + - **依赖**:T002、T003 + +- [x] **T010**: 预览服务与接口实现 + - **文件**: + - `src/backend/bisheng/linsight/domain/services/artifact_preview_service.py` + - `src/backend/bisheng/linsight/api/endpoints/linsight.py` + - **逻辑**: + - 实现 `load_accessible_version` 和 `get_pptx_preview`,细节见 design §4.2、§4.3、决策 6、决策 7。 + - 新增 `POST /workbench/file_preview`。 + - `file_download` 改为调用 `load_accessible_version`。这是共享接口,结果区、分享页、HTML 查看页都在用;行为必须保持不变,由 T009 的回归用例保证。 + - 每个进程用 `Semaphore(2)` 限流;转换超时 120 秒;记录日志。 + - **覆盖 AC**:AC-20、AC-22、AC-23、AC-24、AC-27、AC-28 + - **依赖**:T009 + +## Wave 3:技能指引、前端、分发 + +- [x] **T011**: 重写 SKILL.md 与 references + - **文件**:`.../bisheng-pptx/{SKILL.md, references/design-zh.md, references/environment.md(新), references/pptx-cookbook.md}` + - **逻辑**: + - 正文流程:先写大纲(首读结论、`layout_id`、证据),再用 `list_templates` 选模板,再用 `Deck` 构建,最后用 `inspect` 检查。 + - 路线选择:用户模板优先;其次是内置模板;用户要求自由设计、或模板都不贴合时,才从零生成。 + - E2B 环境下说明限制,并转为从零生成。 + - 环境事实移到 `environment.md`。 + - `design-zh.md` 增加"内容组织"一节,并修订装饰线条款。 + - 遵守品牌和 description 的限制:不超过 1024 字,不写环境声明。 + - **验证**:`test_the_shipped_bundle_is_valid` 通过;人工通读一遍。 + - **覆盖 AC**:AC-02、AC-03、AC-04、AC-08、AC-09 + - **依赖**:T005、T006、T008 + +- [x] **T012**: 前端(Client)预览数据层 + - **文件**: + - `src/frontend/client/src/components/Linsight/Artifacts/{artifactUtils.ts, useSlidesPreview.ts(新)}` + - `src/frontend/client/src/api/chat/data-service.ts` + - **逻辑**: + - 新增 `slides` 预览类型,覆盖 ppt 和 pptx,只作用于任务生成的文件;上传文件仍按解析正文预览。 + - 新增 `getLinsightFilePreviewApi`,出参形如 `{status, file_path?}`,与下载接口同形态,带 `share-token` 和 `skip403Redirect`。 + - hook `useSlidesPreview(file, versionId)` 返回 `{state: 'generating'|'ready'|'failed', url}`:每 2 秒轮询一次,90 秒后放弃;失败结果不写缓存。 + - 补 `artifactUtils` 单测。 + - 新文件不加 `@ts-strict-ignore`。 + - **覆盖 AC**:AC-20、AC-21、AC-23、AC-25、AC-28 + - **依赖**:T010 + +- [x] **T018**: 前端(Client)预览渲染与文案 + - **文件**: + - `src/frontend/client/src/components/Linsight/Artifacts/PreviewBody.tsx` + - `src/frontend/client/src/locales/{zh-Hans,en,ja}/translation.json` + - **逻辑**: + - slides 类型使用 T012 的 hook。 + - generating 状态显示"正在生成预览…"。 + - ready 状态渲染 ``,上方加 AC-26 的提示条。 + - failed 状态沿用现有的图标加"下载查看"兜底。 + - 新增的 key 三语齐全。 + - **验证**:本地起 client,分别验证 ready、converting、失败、分享页四种状态;确认 docx、pdf、md 的预览不变。 + - **覆盖 AC**:AC-20、AC-21、AC-23、AC-25、AC-26、AC-28、AC-29 + - **依赖**:T012 + +- [x] **T013**: 种子化回归测试 + - **文件**:`src/backend/test/linsight/test_builtin_skill_seeder.py` + - **测试**: + - `discover_builtin_bundles()["bisheng-pptx"]` 包含 `templates/**`,字节与磁盘一致。(AC-13) + - 已被 fork 的内置技能不会被覆盖(复用既有用例并断言)。(AC-14) + - **覆盖 AC**:AC-13、AC-14 + - **依赖**:T001、T017 + +## Wave 4:验收 + +- [ ] **T014**: 模板视觉评审与签字 + - **逻辑**: + - 交付 5 套模板的联系表,并在 PowerPoint 或 WPS 中打开核对。 + - 按反馈重跑 T001,然后取得设计或产品签字。 + - 结论记入 `evidence/template-review.md`。 + - **覆盖 AC**:AC-07、AC-31 + - **依赖**:T001 + +- [ ] **T015**: 端到端验收 + - **逻辑**: + - 在 114 上,用三类题目,在改造前后各跑一次。 + - 把逐页联系表和检查结论归档到 `evidence/`。 + - 在 116 或 180(有 Impress 的环境)上验证 pptx 预览、分享页预览,以及缓存复用。 + - 跑 `/e2e-test`。 + - **覆盖 AC**:AC-02、AC-20、AC-21、AC-22、AC-25、AC-30 + - **依赖**:T011、T018、T014、T017 + +- [ ] **T016**: 质量门与代码审查 + - **逻辑**: + - 跑 `pnpm lint`、`pnpm typecheck`、`pnpm check-i18n`、`scripts/arch-guard.sh`,以及相关 pytest。 + - 跑 `/sdd-code-review --base feat/3.0.0`。 + - 回写 design 修订历史,并更新 release-contract 中 F111 的状态。 + - **覆盖 AC**:全部(回归) + - **依赖**:T015 + +--- + +## AC 追溯 + +| AC | 任务 | +|---|---| +| AC-01 | T001、T017、T004 | +| AC-02 | T006、T011、T015 | +| AC-03、AC-04、AC-08、AC-09 | T011 | +| AC-05、AC-06 | T004、T005 | +| AC-07 | T001、T014 | +| AC-10、AC-11、AC-12 | T007、T008 | +| AC-13、AC-14 | T013 | +| AC-20 | T010、T012、T018、T015 | +| AC-21 | T003、T012、T018、T015 | +| AC-22 | T009、T010、T015 | +| AC-23 | T002、T009、T010、T012、T018 | +| AC-24、AC-27 | T009、T010 | +| AC-25 | T009、T012、T018、T015 | +| AC-26 | T018 | +| AC-28 | T009、T010、T012、T018 | +| AC-29 | T002、T018 | +| AC-30 | T015 | +| AC-31 | T014 | + +--- + +## 实际偏差记录 + +(实现中如有偏离,在此留一行指针,论证写在 design.md。) + +- T001/T005:图表与表格渲染抽成技能包内共享模块 `scripts/deck_render.py`,生成脚本与 `Deck` 共用一份实现(模板样例与真实产出不漂移)→ design §4.5 已补该模块。 +- T001:新增 `scripts/pptx_templates/builder.py`(形状、插槽登记、主题 XML 底层),与 themes/layouts/build 并列。 +- T017:`bridge` 用瀑布图实现,`deck_render` 增加 `waterfall` 图表类型。 +- T010:不属于该会话版本、含 `..`、非 ppt/pptx 的 `file_url` 一律按 `UnAuthorizedError` 拒绝(与下载接口的拒绝方式一致);源对象在 MinIO 不存在时返回 11025(而非 404)。 +- T011:SKILL.md 正文改为「内容先行 + 内置模板默认」;原「从零创建」与排版底线移到 `references/freeform.md`,环境事实移到 `references/environment.md`;inspect/probe/cookbook 中引用旧章节号的提示语同步更新。 +- T012:本地 client 依赖安装落后于 workspace(缺 `@bisheng/chat`、`@bisheng/api-client`),tsc-strict 用临时 paths 映射验证:改动文件零错误;全量结论以 CI 为准。 +- T016(部分):`arch-guard` 对改动文件 exit 0;ruff 只剩全角中文标点类(RUF001–003,与既有技能脚本同类、后端 CI 不跑 ruff);`test/linsight/` 全量 1627 passed / 6 failed,6 条失败(`test_citation_handles_switch.py` ×5、`test_personal_skill_api.py` ×1)在未改动的 `origin/feat/3.0.0` 上同样失败,属既有问题。 +- T008:为让模板自身通过既有检查(不依赖基线扣除),调整了模板:封面标题 44pt、页脚上移并改 10pt、装饰圆环与光晕收进画布内、页码字段声明段落字号 → design §5 第 13 条。 diff --git a/features/v3.0.0/112-vibe-release-notes/code-review.md b/features/v3.0.0/112-vibe-release-notes/code-review.md new file mode 100644 index 0000000000..08516e25b0 --- /dev/null +++ b/features/v3.0.0/112-vibe-release-notes/code-review.md @@ -0,0 +1,33 @@ +# F112 代码预审记录 + +日期:2026-10-27。基线:`feat/3.0.0@e890280afb35cda0d78f6ec06b2e41af6856f7f9`。范围:本隔离工作树 F112 代码、安装件、任务及文档;不包含其他工作树修改。 + +本记录按 sdd-code-review 的 7 维度提前核对实现。**T028 真实部署 E2E 尚未完成,所以不是正式 L2 通过报告,T029 保持未完成,原定合入门禁保留为未完成项;本次按下述用户明确指令先合入。** 没有调用独立审查 agent。 + +| 维度 | 实现核对结果 | 证据与边界 | +|---|---|---| +| 边界条件 | PASS | None/空白/UTF-8/CRLF/码点边界;超长拒绝不截断;CLI 先读一次,失败与旧能力不上传 | +| 权限与认证 | PASS(本地) | 原 scope/actor/owner/租户与审批 gate;只扩展原授权后的映射;普通非 owner/跨租户精确拒绝;真实身份 E2E 待执行 | +| 并发安全 | PASS | 无新共享可变状态、跨表 UPDATE、审批回调或任务参数;沿用原版本 INSERT/补偿;新 session 读取冻结原值,后续版本不覆盖;真实进程重启未实测 | +| 信息泄漏 | PASS | 仅已授权快照/列表输出;校验错误不回显说明;无新增正文日志;React 纯文本,无富文本执行 | +| 测试覆盖 | 待完成 | 本地领域/API/组件/安装件定向通过;真实部署 opt-in E2E 已生成但未执行,不能以 skip 或外部 fixture 代替 | +| 代码约定 | PASS | 各目录 Ruff;逐文件架构守卫;受影响文件 ESLint/类型;三语言及生成物检查;无新依赖 | +| 设计同步 | PASS | spec AC 未改变;design 当前实现、兼容 post 构建、窄屏行为和证据层次已同步;tasks 28/30 不虚记 | + +## 已处理发现 + +1. **安装兼容性**:原 pack_cli_wheel.sh 默认将 post 版本写为 min_compatible/platform.version,CI 不传环境参数重建会提高最低版本。修正 post 默认基线并测试两种调用,保留 SDK。 +2. **窄屏全文**:表格 min-width 使新增说明长行超出 500px 可视区。仅给正文增加响应式宽度,实际组件预览 1440/500px 复核通过。 +3. **设计状态**:原评审阶段“尚未编码”措辞已回写当前进度,原设计审查历史保留并标明后续确认。 + +## 尚未满足的交付门禁 + +- 在已更新到 F112 的专用环境执行一次发布→审批决定→版本说明、普通账号及跨租户拒绝配对;在线安装件和实际页面复核。 +- 补充准确源码 SHA、平台镜像与测试记录,再完成正式 sdd-code-review,T028/T029 方可打勾。 +- 本功能未执行全量测试或镜像正式发行;合并推送按下述用户授权执行;MySQL/DM8 真实升级与 Docker/K8s 全量故障回归没有宣称实测。 + +## 本次交付授权与集成核对 + +2026-10-27 用户明确要求“提交推送 feat/3.0.0 分支,然后再合并到 release 分支并推送远端”。本次据此先交付代码;真实部署 E2E 及依赖其证据的最终审查继续待完成,不标记通过,不执行 116 更新。 + +最新发版基线 `c612d8afbf290ea368315b20b8d9e2b4313f418b` 比开发基线新增 3 个提交,修改工具执行器、评估进度和环境文档,没有修改 F112 的实现文件。F112 自动合并无冲突,功能代码及 wheel 保持已验证内容;本次只做合并差异、JSON/生成物、安装件摘要和迁移图的必要核对,不重复全量或已通过的功能集合。 diff --git a/features/v3.0.0/112-vibe-release-notes/design-review.md b/features/v3.0.0/112-vibe-release-notes/design-review.md new file mode 100644 index 0000000000..892c911143 --- /dev/null +++ b/features/v3.0.0/112-vibe-release-notes/design-review.md @@ -0,0 +1,51 @@ +# F112 应用发布说明 — 设计自审 + +日期:2026-10-27。输入:[已确认 spec](./spec.md)、[design](./design.md)、[设计模板](../../_templates/design.md)、[版本契约](../release-contract.md)、[constitution](../../../docs/constitution.md)。本文件保留设计阶段的自审记录;用户随后于 2026-10-27 确认设计,实施及验证现状见 tasks.md、verification.md。 + +审查方式:主 agent 按 `sdd-review/references/design-checklist.md` 的 24 项执行自审;不是独立 agent 或运行测试报告。 + +**结论:LGTM,24 项自审通过;用户已单独确认设计,以下为当时的设计审查结果。** + +## 1. 逐项结果 + +| # | 检查点 | 结果 / 位置 | +|---|---|---| +| 1 | 目标简明 | 通过;design §1 定义提交、冻结、两处审读 | +| 2 | 非目标明确 | 通过;design §1、§8,无历史编辑或运行时新能力 | +| 3 | 特有约束 | 通过;design §2 明确独立 CLI、先审批后版本和软限额差异 | +| 4 | Constitution Check | 通过;引用 C1–C8;仅扩展原服务/模型/响应,不加 service SQL、新 DAO 或独立权限入口;双库与跨进程保存明确 | +| 5 | 关键决策 | 通过;design §3 有四项决策 | +| 6 | 备选、选定与原因 | 通过;每项包含被否方案与既有代码依据 | +| 7 | 重新考虑条件 | 通过;每项含产品、统一能力面或实测瓶颈触发条件 | +| 8 | 依据具体 | 通过;字段作用、表单忽略、先审批后版本、已挂载两张表等均有源代码定位 | +| 9 | 数据流清晰 | 通过;design §4.1 已实现与 §4.2 待实施区分,时序含 caller 和持久副作用 | +| 10 | 可见字段明确 | 通过;说明能力、表单、两张业务列、两个快照、版本输出与空值均明确 | +| 11 | 职责与边界 | 通过;design §4.7 文件责任表包含不承担项 | +| 12 | 现状与规格一致 | 通过;spec 是纯 What、实际只有 §1–§4,不机械引用清单中的 spec §5–§7;按 20 条 AC 核对 | +| 13 | 反直觉事实 | 通过;design §5 列出 10 项当前链路事实 | +| 14 | 后果明确 | 通过;每项说明静默丢失、审批空值、另一页缺内容等实际风险 | +| 15 | 修复位置明确 | 通过;每项指向具体模块、方法或本设计合同 | +| 16 | 对外合同齐全 | 通过;design §6.1 覆盖 CLI、HTTP、Python 方法、错误、快照和读取形态 | +| 17 | 依赖齐全 | 通过;design §6.2 包含数据库、DDL、CLI 包、现有审批和 UI;明确无新增外部运行时依赖 | +| 18 | 依赖风险明确 | 通过;每项记录会失效的条件与处理责任 | +| 19 | 验证策略 | 通过;design §7.1 分 CLI/backend/组件/安装件/E2E/静态,明确不跑全量 | +| 20 | 可操作人工检查 | 通过;design §6.1 有 CLI 命令,§7.2 有角色、页面、输入与 1440px/500px 检查;不写真实凭据 | +| 21 | 已知短板与不做原因 | 通过;design §8 说明全量读取版本的既有边界,另行优化触发条件 | +| 22 | 修订历史 | 通过;已记录设计初版,明确未编码,不误记“已完成” | +| 23 | spec 一致性 | 通过;20 条 AC 均有设计落点及验证范围;没有扩大产品功能 | +| 24 | tasks 偏差回写 | 不适用;tasks 尚未开始,未发生实施偏差;设计经确认后才拆任务 | + +## 2. 本轮修订与复核 + +自审识别并在同一设计稿中修正两点,未修改已确认 AC: + +1. **时序准确性**:排队发生在 accept 返回之前,图已改为先 enqueue、后响应 CLI;审批创建仍先于正式版本 INSERT,不误写为相反顺序。 +2. **错误合同**:明确 v2 HTTP 400 + 业务码 16233,来源为当前 open_api_http_status 映射;不采用缺少读取方的自定义错误状态属性。参数类型错误沿用框架原校验响应。 + +复核了能力元数据的整数类型(排除 bool/string)、结构变更确认重传只读一次文件、两处版本组件和 CLI 安装件输出目录,以及不支持混合 API/worker 版本发布的升级前提。 + +## 3. 验证证据边界 + +本轮只有源码核对、设计自审和文档一致性检查,没有业务代码变更、依赖安装、运行单元测试、数据库迁移、镜像构建或远端部署。所有 design 中的新参数、字段、版本号及测试均为待实施,不构成交付证据。 + +正式设计按 AGENTS.md §6、SDD-Guide §3、sdd-review 需用户确认;确认后进入 tasks 并实施,仅做本功能必要的定向验证。 diff --git a/features/v3.0.0/112-vibe-release-notes/design.md b/features/v3.0.0/112-vibe-release-notes/design.md new file mode 100644 index 0000000000..63a247d984 --- /dev/null +++ b/features/v3.0.0/112-vibe-release-notes/design.md @@ -0,0 +1,284 @@ +# Design: 应用发布说明 + +**关联**:[spec.md](./spec.md)(AC-01 至 AC-20 已确认)· [discovery.md](./discovery.md) +**版本**:v3.0.0;基线 `feat/3.0.0@e890280afb35cda0d78f6ec06b2e41af6856f7f9` +**最后更新**:2026-10-27 +**状态**:2026-10-27 用户已确认设计(“确认设计,继续开发”);确认时 SHA-256 为 `6061d8f6d5ccfe105a0ccb6a71d71196fc38f31b8d776d1c01f45ac81101e4b9`。实现与本地定向验证完成,真实部署 E2E 待执行;下述新增合同不作为已验收声明,验证证据以 tasks.md 和 verification.md 为准。 + +## 1. 目标与非目标 + +开发者在 CLI 提交本次发布的功能说明,平台冻结保存,在申请人/审批人的审批详情以及应用版本记录中展示。同一发布的异步处理与重试使用同一内容,历史版本保持原文。 + +不新增发布入口、历史编辑、富文本、公告推送、公开日志或运行时能力;不改变现有审批和部署状态机。 + +## 2. 关键约束 + +- 遵循 [constitution.md](../../../docs/constitution.md) C1–C8、[release-contract.md](../release-contract.md) 及各目录 AGENTS.md;不复制或改写这些全局规范。 +- CLI 可选说明、直接文本/UTF-8 文件互斥、规范化后最多 2,000 个 Unicode 码点、提交后不可修改,均为已确认产品要求。 +- CLI 是独立安装包,不能 import backend;两端各有一个短小纯函数,使用相同测试向量验证规范化和长度。 +- 现有 `deploy-limits` 是包体大小的软检查,查询失败原本可降级;携带说明的兼容检查必须有明确的支持声明,否则不得上传。 +- `VersionService.record_version()` 当前先创建审批再插入正式版本,并在插入失败时补偿取消审批。F112 不调整顺序或补偿;审批不能从尚不存在的版本读取说明。 +- AppDeployment 是异步发布的持久载体;AppVersion 是只增不改的版本事实;审批引擎已有 payload/detail 快照。说明不进入 manifest、源码包、依赖指纹、manager RPC、容器环境或运行路由。 +- 所有说明读取复用原发布、审批、版本入口权限,不新增匿名或独立说明查询接口。真实验证只覆盖本功能,不以 Docker/K8s 全量重跑作为门禁。 + +## 3. 方案对比与选定 + +### 决策 1:说明作为发布输入,不写进应用声明或介绍 + +- **备选**:A. 复用 `description`;B. 放入 `bisheng-app.yaml`;C. deploy 独立可选输入。 +- **选定**:C,CLI 参数 `--release-notes TEXT` / `--release-notes-file FILE`,HTTP 表单字段 `release_notes`。 +- **原因**:应用介绍描述长期用途,现有发布会同步应用元信息;YAML 是 F055 校验的运行合同,未知字段被拒绝。独立输入可让同一源码发布带不同文案,不改变包摘要、运行依赖或镜像缓存语义。文件可承载多行正文,不增加交互式必答问题。 +- **重新考虑条件**:未来 PRD-2 需要界面编辑发布草稿时,沿用同一服务端输入合同增加入口,不改历史版本说明。 + +### 决策 2:现有两张业务表各增加一个字段,复用审批快照 + +- **备选**:A. 新建说明表;B. 仅写审批快照或版本表;C. AppDeployment 与 AppVersion 各一个 nullable 文本列,审批快照复制原文。 +- **选定**:C,不新增表或发布说明管理服务。 +- **原因**:AppDeployment 先落库,API 与 Celery 不共享进程;正式版本只在预检成功后建立;审批快照又先于版本插入。两处字段分别表达“本次提交内容”和“正式版本内容”,而不是两个可独立编辑的主数据。只有 receive 初始化及版本 INSERT 可以写说明;审批只从该发布记录构建快照。仅写版本会在审批创建时无源可读,仅写审批会将版本读取耦合到审批引擎。 +- **重新考虑条件**:需要说明编辑历史、草稿协作或多语言人工文案时,再评估独立领域对象;本期不预建这些结构。 + +### 决策 3:复用一次 deploy-limits 请求,分开处理软限额与强兼容 + +- **备选**:A. 根据平台版本号猜测;B. 上传后再看是否接收;C. 扩展现有能力响应,带说明时先确认支持。 +- **选定**:C,明确版本化说明合同,缺失声明即不支持。 +- **原因**:`/api/v1/env` 的版本不是可靠能力依据;旧 FastAPI 表单可能忽略未知字段,因此“上传成功”不能证明说明被接收。已有 CLI 上传前查询 deploy-limits,可复用,无需新增 endpoint 或 manager 探测。 +- **重新考虑条件**:平台有统一能力协商面时迁移能力来源,同时保留这里的版本化说明合同与上传前拒绝行为。 + +### 决策 4:展示全文,沿用既有业务布局 + +- **备选**:A. Markdown 渲染;B. 新详情弹窗或可展开卡片;C. 既有审批分区和版本表中的纯文本区域。 +- **选定**:C;审批增加一个既有样式的说明分区,两处版本表各版本后增加对应的全文说明行。 +- **原因**:上限为 2,000 码点,用户需要全文审读;已有 client `SectionTitle`/正文和 platform 版本表足以承载。独立说明行不增加横向列宽,不引入渲染依赖、弹窗状态或新的设计系统组件。 +- **重新考虑条件**:设计师确认新的版本详情交互后可调整呈现方式;原说明读取与冻结合同保持不变。 + +## 4. 系统数据流与实现合同 + +### 4.1 基线链路(实施前核对) + +`CLI run → _check_size → _upload/_post_package → POST deploy → PublishPipelineService.accept → AppDeployment → Celery → VersionService.record_version → ApprovalPort.submit → _build_payload / handler.build_detail → AppVersion INSERT` + +- 基线未含发布说明;本次已实现 §4.2 至 §4.7 的输入、持久化、快照及展示。 +- 当前审批详情由 client 的 `AppPublishDetailPanel` 读取 `detail_snapshot`;payload_snapshot 含内部字段,业务 UI 不从那里取值。 +- platform 的发布页 `VersionListCard` 与版本页 `VersionsTab` 均在 `hostedApp/index.tsx` 中实际挂载,都读取 `/api/v1/apps/{app_id}/versions`。分页和非分页响应共用 `AppQueryService.list_versions()` 映射。 +- CLI 下载件由 backend 的 `dev_toolkit/artifacts` 提供,源目录 `src/bisheng-cli` 不在 backend 镜像构建上下文中。 + +### 4.2 已实现流程(真实部署验收待执行) + +```mermaid +sequenceDiagram + participant CLI as CLI + participant API as backend 发布 API + participant DB as 平台数据库 + participant W as Celery 发布管线 + participant A as 审批引擎 + participant UI as 审批及版本界面 + CLI->>CLI: 读取一次说明,规范化、校验 + CLI->>API: GET deploy-limits + API-->>CLI: 原包上限 + release_notes v1 能力 + CLI->>CLI: 有正文时必须确认支持;dry-run 不联网 + CLI->>API: POST deploy(package, release_notes) + API->>API: 原鉴权 + 服务端说明校验 + API->>DB: 创建发布记录,保存规范化正文 + API->>W: 原任务,仅传 deployment_id + API-->>CLI: deployment_id / app_id / version_id + W->>DB: 读取发布记录,执行原预检和扫描 + W->>A: 原审批请求,payload/detail 复制发布说明 + W->>DB: 原正式版本 INSERT,复制发布说明 + UI->>A: 原受权审批详情读取 + A-->>UI: detail_snapshot.release_notes + UI->>API: 原受权 versions 查询 + API->>DB: 读取该应用版本 + API-->>UI: 版本行增加 release_notes +``` + +上图是成功路径,`dry-run` 在 CLI 本地校验和打包后即退出,不执行其后的 GET/POST。预检失败仍不生成正式版本;审批创建失败、版本 INSERT 失败及其补偿继续使用原管线。仅填写说明不触发额外的发布、重建或审批操作。 + +### 4.3 内容规范化与输入校验 + +两端使用同一逻辑合同:`None → None`;字符串依次执行 CRLF→LF、CR→LF、Unicode `strip()`;空结果→`None`;否则 Python `len()` ≤2,000。计数是 Unicode 码点,不是 UTF-8 字节、UTF-16 单元或可见字素簇;组合 emoji 按多个码点计数。不更改正文内部缩进与空白,不做 Unicode NFC/NFKC 归一化。 + +- argparse 互斥组提供两个参数,默认均 `None`。冲突使用现有用法错误退出码 2。 +- `--release-notes-file` 相对当前命令工作目录解析,不相对应用根目录。先确认普通文件,再严格 UTF-8 解码;文件错误、非法编码与超长使用现有 `EXIT_LOCAL_INVALID=6`。不接受 stdin、目录、管道或文件上传表单替代文本。 +- CLI 在打包/联网前解析一次,将规范化后的不可变字符串传给 `_upload` 及 `_post_package`。结构变更确认引起的第二次上传重用这个值,不能再次读取已可能被改动的文件。 +- `--dry-run` 执行同一校验,不做服务端能力检查。JSON 摘要只增加 `has_release_notes` 与 `release_notes_codepoints`,不打印全文。 +- backend `PublishPipelineService.accept(..., release_notes: str | None = None)` 在创建草稿、存包、发布记录或排队之前规范化并校验。endpoint 只转交表单;不把 `max_length=2000` 加在规范化之前,避免拒绝首尾空白较多但正文合法的输入。 +- 后端只接收文本;框架对非文本类型的拒绝沿用现有参数验证响应。正文超长用 `AppReleaseNotesInvalidError`,不在错误 details 或日志回显正文。 + +### 4.4 数据保存与迁移 + +| 位置 | 新增内容 | 唯一写入时机 / 读取方 | +|---|---|---| +| `app_deployment.release_notes` | `str | None`,`Column(LargeText, nullable=True)`,无默认正文、无索引 | receive 在创建发布记录时赋值;Celery、版本构建、审批快照读取 | +| `app_version.release_notes` | 同上 | 原 `_insert_version()` 从 deployment 复制;原版本查询返回;不增加通用 UPDATE | +| 审批 payload_snapshot | `release_notes: str | null` | `_build_payload()` 从 deployment 复制;回调不消费该字段 | +| 审批 detail_snapshot | `release_notes: str | null` | `AppPublishScenarioHandler.build_detail()` 明确白名单复制;client 仅从此处展示 | + +`LargeText` 使用既有 dialect_helpers,MySQL 为 LONGTEXT、DM8 为 CLOB。2,000 码点上限由业务校验保证;不引入数据库字符集假设或 JSON 内容检索。 + +迁移采用一个可读 revision `f112_release_notes`,`down_revision` 以实施时实际单 head 为准。只对两张既有表幂等增加 nullable 列,使用 `table_exists/column_exists` 共享 DDL 守卫;兼容先执行 create_all 的安装路径;不回填历史、不更改旧 revision、不做数据清理。历史值保持 SQL NULL。降级会丢弃新增列中的说明,执行前须备份;正常代码回滚保留列,不运行 downgrade。 + +同一任务重试从原发布记录读值,不从本地文件、当前应用介绍或最新提交推导。版本及审批已复制的说明后续不回写。终态、停用、恢复、档位和密钥操作均不修改它。 + +### 4.5 能力响应与 CLI 兼容 + +`GET /api/v2/apps/deploy-limits` 保持原 scope、enabled 校验和 UnifiedResponse 包装;仅在 endpoint 的 data 合并以下内容,不把非整数内容塞入供包体服务内部使用的 `package_service.deploy_limits()`: + +```json +{ + "max_package_mb": 50, + "max_unpacked_mb": 200, + "max_package_entries": 20000, + "features": { + "release_notes": {"version": 1, "max_codepoints": 2000} + } +} +``` + +包体三项是部署配置值,示例不改变配置默认。说明 version=1 定义本设计的规范化及保存合同。 + +- `packaging.Limits` 增加带默认值的说明能力元数据;旧 positional/keyword 调用保持兼容。`fetch_limits(..., require_release_notes=False)` 默认维持现有软检查;传 True 时查询异常沿用原 CliError,不吞成限额降级。响应畸形、缺少/不支持 version、缺少合法上限,不得视为支持;version 和上限须为真正整数,不把布尔值或字符串当作整数支持声明。 +- `_check_size` 返回本次 Limits 供调用者使用;不额外发第二次 GET。规范化说明非空时要求 `version == 1`、声明上限为正整数且能接受正文,并同时遵守本地 2,000 上限。缺少支持或版本不匹配以 `EXIT_PLATFORM_TOO_OLD=9` 退出,提示升级平台或显式移除说明后重试;不静默删除说明。 +- 没有说明、显式空字符串或纯空白说明按未填写处理:不要求新能力,multipart 不发该字段,保留原失败降级。旧 CLI 忽略 features 的新增键。 +- 连接失败等非兼容错误保留原错误与下一步指引;dry-run 不把远端不可达误当作本地失败。 +- 本设计不支持发布期间旧/新 API 或 worker 混合提供能力。升级前暂停发布入口并排空在途任务;迁移后更新所有 API/worker,最后恢复发布与新版 CLI 分发。只升级 API 会让旧 worker 丢弃新增 ORM 字段,不能作为合格安装。 + +### 4.6 两处前端与文案 + +**client 审批详情**:在 `AppPublishDetailPanel` 的基本信息与能力区域之间增加“发布说明”分区,复用既有分区布局。只从 `detail.detail_snapshot.release_notes` 读取 string;缺失/空值显示空态,不查询当前版本,不从 payload_snapshot 补取。审批人 task 与申请人 instance 共用面板,因此不分叉实现。 + +**platform 版本记录**:`HostedAppVersion` 增加可选 `release_notes?: string | null` 兼容旧后端;`AppQueryService.list_versions()` 一处增加映射,使分页和非分页一致。`VersionListCard` 与 `VersionsTab` 各版本元信息行后紧跟一个带“发布说明”标签的正文行,React Fragment 以 version_id 作 key,列合并覆盖原表头。全文展开,不加新列、不截断,不新增弹窗或折叠状态。仅空态使用本地化文本。 + +两处都用 React 文本节点,不用 Markdown、`dangerouslySetInnerHTML`、自动链接或 HTML 模板。正文 `whitespace-pre-wrap` + 可打断长词,容器 `min-w-0`;复用既有分区/表格,不改设计 token、字体值、颜色或公共组件样式。新增正文用语义字号和颜色。受影响版本读取需清理过期请求,避免切换应用后旧响应覆盖新应用;不重构无关列表逻辑。 + +UI 依据:当前 `packages/ui/docs/index.md`、组件总览、字体/色彩规范和多端适配原则,正文宽窄屏行为需实际检查。没有独立发布说明设计系统组件需求,沿用已存在的业务分区模式。 + +| 文案 | platform 键(bs 命名空间) | client 键 | en / ja | +|---|---|---|---| +| 发布说明 | `hostedApp.releaseNotes.title` | `approval.app_release_notes.title` | Release notes / リリースノート | +| 未填写发布说明 | `hostedApp.releaseNotes.empty` | `approval.app_release_notes.empty` | No release notes provided / リリースノートは未入力です | + +三语言同时提交。后端 16233 的文案只维护 `packages/locales/src/api_errors/{zh-Hans,en,ja}.json` 并运行既有生成器,不手改两端生成文件。CLI 当前是中文输出(既有合同),新增该功能的提示与帮助在 `bisheng_cli/release_notes.py` 内使用小型三语言消息字典,根据进程 locale 选择 zh/en/ja,未知 locale 回退英文;复用 Python 标准库,不新增全局语言开关、翻译框架或全 CLI 国际化迁移。命令行选项名保持不变。 + +### 4.7 模块修改职责 + +| 模块 / 文件 | 本次职责 | 不承担 | +|---|---|---| +| `bisheng_cli/cli.py`、`commands/deploy.py`、新增 `release_notes.py` | 参数、解析一次、校验、能力检查、表单与重传 | 访问数据库、导入 backend、改 manifest | +| `bisheng_cli/packaging.py` | 在现有一次限额响应中解析说明能力 | 推断服务器版本、默默降级说明 | +| `app_publish/api/endpoints/deploy.py` | 增加表单透传与能力声明 | ORM、版本写入、运行编排 | +| `app_publish/domain/release_notes.py` | 内容规范化与验证,统一上限 | 文件 I/O、数据库读写、身份推导 | +| `publish_pipeline_service.py`、`domain/models/app_deployment.py` | receive 保存不可变提交说明 | 给任务追加正文参数、历史编辑 | +| `version_service.py`、`database/models/app_version.py` | 原版本 INSERT 复制说明 | 改审批顺序、引入版本 UPDATE | +| `publish_approval_service.py`、`app_publish_scenario_handler.py` | 构建并白名单输出两个既有快照 | 改引擎规则、回调、审批人解析 | +| `app_query_service.py` | 原授权后列表映射增加字段 | 新匿名接口、新 DAO 或直接 SQL | +| client / platform 上述三处业务展示 | 纯文本全文和空态 | 保存说明、改权限、渲染富文本 | +| `scripts/pack_cli_wheel.sh` 与 dev_toolkit artifacts | 重打并分发可安装 CLI | 发布 Docker Hub 镜像或新 tag | + +## 5. 已知坑与护栏 + +| # | 已核对事实 | 忽略后的结果 | 处理位置 | +|---|---|---|---| +| 1 | 旧 deploy 接口可能忽略新增表单字段 | 用户收到成功但说明丢失 | CLI `_check_size` 与 §4.5 强能力检查 | +| 2 | 包上限 GET 原本是软检查 | 直接复用失败降级会绕过支持判断 | `packaging.fetch_limits` 的说明严格分支;不改变无说明路径 | +| 3 | 审批先于正式版本创建 | 从 version 读取时为空,或为配合说明误改补偿 | 两个快照与版本 INSERT 都从 deployment 复制 | +| 4 | 结构变更确认会重复上传同一个包 | 再次读文件会给同一包带不同说明 | CLI 运行开始只解析一次,所有 `_post_package` 重用 | +| 5 | 两处版本记录是不同已挂载组件 | 只改一个页面,另一个仍无文案 | `VersionListCard`、`VersionsTab` 同步处理 | +| 6 | detail_snapshot 才是面板消费合同 | 只改 payload,审批 UI 仍为空 | `build_detail()` 显式输出字段;测试两种 scope | +| 7 | backend 镜像不含 CLI 源码目录 | 源码测试通过,下载仍是旧 CLI | 构建脚本、artifacts manifest 与新环境安装烟测 | +| 8 | create_all 不会给已有表加列,且会先建缺失表 | 升级缺列或迁移重复加列失败 | 共享 DDL 守卫、两个 nullable 列、单 head | +| 9 | 前端 JS 长度与 Python 码点长度不同 | emoji 上限口径不一致 | 输入校验只在两个 Python 侧,前端只展示 | +| 10 | API 新、worker 旧会加载不含新列的模型 | 能力为真但正式版本/审批漏说明 | §4.5 升级整套发布服务后再恢复入口 | +| 11 | 现有打包脚本默认把 min_compatible 和 platform.version 都设为 CLI 新版本 | 单纯 post 版本升级会拒绝旧 CLI,违反 AC-17 | post 版本默认剥离 .post 后缀得到 3.0.0;支持显式 CLI_MIN_COMPATIBLE、PLATFORM_VERSION,完整版本保持原行为 | + +## 6. 对外契约与依赖 + +### 6.1 提供的合同 + +| 合同 / 方向 | 产生方 → 校验方 → 消费方 | 类型与行为 | +|---|---|---| +| CLI `--release-notes` / `--release-notes-file` | 开发者 → CLI → deploy 表单 | 互斥可选;文件相对当前工作目录;无交互新增 | +| GET `/api/v2/apps/deploy-limits` | 发布 endpoint → CLI → 上传决策 | 原 3 个包体限制 + `features.release_notes`;scope 仍 `app:manage`、模式 S | +| POST `/api/v2/apps/deploy` | CLI → PublishPipelineService → deployment | `multipart/form-data` 新增可选 `release_notes`,其余 package/app_id/confirm_schema_change 不变;原响应 3 个 ID 不变 | +| `PublishPipelineService.accept` | endpoint/既有测试 → service → deployment | 新 keyword-only 参数 `release_notes: str | None = None`;旧调用无需补参 | +| 原审批详情接口 | F055 构建器 → 原审批权限 → task/instance 面板 | payload/detail 新增同名可空文本;detail 不补取实时值 | +| GET `/api/v1/apps/{app_id}/versions` | AppQueryService → 原可见性验证 → 两处版本列表 | 原 `{data,total}`;每个版本新增 `release_notes: string | null`;原分页参数不变 | +| 错误 16233 | `AppReleaseNotesInvalidError` → 统一失败响应 → CLI/两端 | `details.reason=too_long`、`max_codepoints=2000`;v2 HTTP 400、UnifiedResponse 的 status_code=16233;正文不回显 | + +16233 已复核无冲突并落入 `common/errcode` 与共享 api_errors;登记在现有模块 162,无新增模块编码。v2 的 HTTP 400 依据既有 `open_api/domain/http_status.py` 映射,不在错误类自行引入未被读取的状态属性;框架参数类型错误仍走原 v2 校验失败响应。CLI 将 16233 映射为修正文案后再提交(exit 6),不同于应用介绍或 manifest 错误。scope、访问主体和审批权限均沿用原 endpoint,不把平台超管或会话权限当成开放 API 凭据替代。 + +示例: + +```bash +bisheng deploy ./today-tasks --release-notes '新增任务提醒;修复日期显示问题。' +bisheng deploy ./today-tasks --release-notes-file ./release-notes.txt +bisheng deploy ./today-tasks --release-notes-file ./release-notes.txt --dry-run +``` + +```json +{ + "version_id": "", + "version_no": 2, + "kind": "iteration", + "terminal_state": "online", + "submitted_at": "", + "is_current": true, + "is_pending": false, + "release_notes": "新增任务提醒\n修复日期显示问题" +} +``` + +### 6.2 依赖及风险 + +| 依赖 | 形式 | 风险 / 处理 | +|---|---|---| +| F053 CLI HTTP/错误/包装合同 | httpx、CLI 原退出码、pack_cli_wheel.sh | 不新增依赖;保持无说明软限额行为;构建件与源码版本同步 | +| F055 receive 与 AppDeployment 持久化 | 原服务和现有 DAO create/get | 不加 Celery 正文字段;避免在创建草稿后才发现输入不合法 | +| F054 版本写入与查询 | 现有 INSERT / alist_by_app | 不新增 DAO 入口、不引入 service SQL;读取前仍解析所属应用 | +| 既有审批引擎 | 原 gate / detail / outbox | 仅扩展场景业务快照,不改引擎或回调协议 | +| MySQL/DM8 及 Alembic | 现有 LargeText、online DDL helpers | DM8 静态核查;真实 DM8 留中央回归,不宣称本轮实测 | +| UI 与 locales | 两个 SPA 现有布局、共享 token、生成器 | 两套路径和 i18n 不混用;新增正文不走富文本 | +| 安装件分发 | dev_toolkit manifest + wheel SHA-256 | 使用构建脚本合并 cli 部分,保持 sdk 部分原样;漏更新会交付旧命令 | +| Docker/K8s | 原应用发布业务服务 | 无新增运行时二进制、镜像、仓库或集群权限依赖;无需分别重复业务相同的测试 | + +## 7. 定向验证与可观测 + +### 7.1 验证策略及 AC 对应 + +| 层次 | 本次验证 | 覆盖 | +|---|---|---| +| CLI 定向测试 | 互斥、UTF-8 文件错误、CRLF/CR/strip、emoji、2,000/2,001、空值、dry-run 无请求;能力新/旧/失败配对;结构确认重传不重读文件 | AC-01、AC-02、AC-03、AC-04、AC-05、AC-06、AC-17、AC-18 | +| backend 定向测试 | 直接 accept 校验、无副作用;持久化后新 session/新 worker 读取;审批快照与正式版本值一致;失败/终态不改说明;分页映射和现有权限拒绝配对 | AC-07、AC-08、AC-09、AC-10、AC-11、AC-12、AC-13、AC-14、AC-16 | +| 两端组件 | task/instance 同文、版本页/发布页同文、历史缺字段、长文换行、脚本字面值、分页及切应用旧响应不串;zh/en/ja | AC-12、AC-13、AC-14、AC-15、AC-16 | +| 安装件烟测 | 新 wheel 独立 venv 安装,fresh `bash -lc` 使用明确 PATH 验证 version/help/dry-run;CLI artifact manifest SHA 一致 | AC-20 | +| 本功能 E2E | 一次带多行说明的迭代提交,读取审批、作一次审批决定、读取版本并核对原文;使用非管理员做无权/跨租户拒绝配对;历史空态组件覆盖 | AC-08、AC-09、AC-10、AC-12、AC-13、AC-16 | +| 静态门禁 | 受影响文件 Ruff/F821、arch-guard、迁移单 head/幂等 DDL、双库类型、i18n key/错误码生成、相关前端类型与 lint | AC-07、AC-15、AC-17、AC-19、AC-20 | + +行为相同的 Docker/K8s 路径由同一 backend 测试覆盖,并静态确认说明未进入 manager/启动合同。E2E 选择一套可用专用测试部署,不重做两种运行后端的全量故障、升级、资源档位或镜像发行验收。若出现明确回归风险,再补对应的最小测试,不扩大为全仓测试。 + +### 7.2 一次人工确认步骤 + +1. 用平台分发的新 CLI 在专用测试应用执行上面的 dry-run,再带说明提交;只使用已授权测试凭据,不写入文档、命令示例或证据文件。 +2. 在 client 的“设置 → 我的申请/我的审批”打开该申请,确认“发布说明”与输入一致,权限不足的账号不能取到详情。 +3. 在 `/build/apps/?tab=versions` 与发布页历史区读取同一 version_id,确认多行全文和空态;完成一项已授权审批动作后刷新,说明不变。 +4. 以 1440px 与 500px 查看上述两端实际页面,核对长行可读、不影响审批按钮;包含 `')); + const service = { snapshot: () => ({ connected: true, connectionKey: 'fixture' }), subscribe: () => () => {}, + webSession: async () => ({ base, cookieName: 'fixture', cookie: 'fixture', expiresAt: Date.now() + 300000 }) }; + const host = new EnterpriseChatView(service, line => { if (line.includes('failed')) console.error(line); }); + const results = []; + try { + for (const width of [900, 1084]) for (const zoom of [0.8, 1, 1.25]) for (const theme of ['light', 'dark']) { + context = JSON.stringify({ width, zoom, theme }); + window.setSize(width, 998); window.webContents.setZoomFactor(zoom); + const [contentWidth, contentHeight] = window.getContentSize(); + const presentation = { visible: true, theme, + bounds: { x: 280 / zoom, y: 40 / zoom, width: (contentWidth - 280) / zoom, height: (contentHeight - 40) / zoom } }; + host.present(window, presentation); await until(() => host.snapshot().phase === 'ready'); + const page = webContents.getAllWebContents().find(contents => contents !== window.webContents && contents.getURL().startsWith(base)); + assert.ok(page); page.setZoomFactor(zoom); + await page.loadURL(base + '/workspace/capabilities?embed=dsh'); + await until(() => host.snapshot().phase === 'ready'); host.present(window, presentation); + await until(() => page.executeJavaScript('!!window.fixture')); + const view = window.contentView.children[0]; + inspect = async () => ({ bounds: view.getBounds(), snapshot: host.snapshot(), document: await page.executeJavaScript('({hidden:document.hidden,rootHidden:document.getElementById("root").getAttribute("aria-hidden"),dialogs:Array.from(document.querySelectorAll("[role=dialog],[role=alertdialog]")).map(e=>({role:e.getAttribute("role"),modal:e.getAttribute("aria-modal"),state:e.getAttribute("data-state"),visible:e.checkVisibility({checkVisibilityCSS:true})}))})') }); + const region = { x: 280, y: 40, width: contentWidth - 280, height: contentHeight - 40 }; + const close = (actual, expected) => assert.ok(Math.abs(actual - expected) < 1, actual + ' != ' + expected); + for (const kind of ['dialog', 'original', 'alert']) { + context = JSON.stringify({ width, zoom, theme, kind }); + assert.deepEqual(view.getBounds(), region); + await page.executeJavaScript('window.fixture.open(' + JSON.stringify(kind) + ')'); + await until(() => page.executeJavaScript('document.querySelectorAll("[role=dialog],[role=alertdialog]").length === 1')); + assert.equal(await page.executeJavaScript('document.getElementById("root").getAttribute("aria-hidden")'), null, 'Live regions keep the root exposed'); + await until(() => view.getBounds().x === 0, 'window expansion'); + await page.executeJavaScript('Promise.all(document.getAnimations().filter(a => Number.isFinite(a.effect.getComputedTiming().endTime)).map(a => a.finished.catch(() => {})))'); + const state = await page.executeJavaScript('(() => { const rect = e => { const r=e.getBoundingClientRect();return {x:r.x,y:r.y,width:r.width,height:r.height}; };const mask=document.querySelector("[data-state=open][style*=background]");const dialog=document.querySelector("[role=dialog],[role=alertdialog]");return {root:rect(document.getElementById("root")),mask:rect(mask),background:getComputedStyle(mask).backgroundColor,opacity:getComputedStyle(mask).opacity,dialog:rect(dialog),dialogOpacity:getComputedStyle(dialog).opacity,dialogBackground:getComputedStyle(dialog).backgroundColor}; })()'); + assert.deepEqual(view.getBounds(), { x: 0, y: 0, width: contentWidth, height: contentHeight }); + close(state.root.x * zoom, 280); close(state.root.y * zoom, 40); + close(state.mask.x, 0); close(state.mask.y, 0); + close(state.mask.width * zoom, contentWidth); close(state.mask.height * zoom, contentHeight); + assert.equal(state.background, 'rgba(0, 0, 0, 0.4)'); assert.equal(state.opacity, '1'); + assert.equal(state.dialogOpacity, '1'); assert.notEqual(state.dialogBackground, 'rgba(0, 0, 0, 0)'); + const capture = await page.capturePage(); const size = capture.getSize(); const pixels = capture.toBitmap(); + const pixel = (x, y) => { + const offset = 4 * (Math.round(y * size.height / contentHeight) * size.width + Math.round(x * size.width / contentWidth)); + return [...pixels.subarray(offset, offset + 4)]; + }; + const sidebarPixel = pixel(20, 100); + assert.ok(Math.abs(sidebarPixel[3] - 102) <= 1, 'Sidebar coordinates carry the 40% black overlay'); + assert.deepEqual(sidebarPixel.slice(0, 3), [0, 0, 0]); + if (width === 1084 && zoom === 1) await writeFile(config.output + '/' + kind + '-' + theme + '.png', capture.toPNG()); + if (kind === 'dialog') { + await page.executeJavaScript('Array.from(document.querySelectorAll("button")).find(button=>button.textContent==="Confirm").click()'); + await until(() => page.executeJavaScript('document.querySelectorAll("[role=dialog],[role=alertdialog]").length === 2'), 'nested confirmation open'); + await page.executeJavaScript('window.fixture.closeNested()'); + await until(() => page.executeJavaScript('document.querySelectorAll("[role=dialog],[role=alertdialog]").length === 1'), 'nested confirmation close'); + assert.equal(view.getBounds().x, 0); + } + // Native bounds/pixels and hit-testing prove window coverage. Component + // tests cover Tab/Esc; OS input delivery requires an installed-app run. + assert.equal(await page.executeJavaScript('document.elementFromPoint(30,100) === document.querySelector("[data-state=open][style*=background]")'), true); + await page.executeJavaScript('Array.from(document.querySelectorAll("[role=dialog] button,[role=alertdialog] button")).find(button=>button.textContent==="Cancel").click()'); + await until(() => view.getBounds().x === 280, 'cancel restoration'); assert.deepEqual(view.getBounds(), region); + results.push({ width, zoom, theme, kind, state, sidebarPixel }); + console.log('PASS', JSON.stringify({ width, zoom, theme, kind })); + } + } + await writeFile(config.output + '/native-results.json', JSON.stringify({ platform: process.platform, electron: process.versions.electron, + fixture: 'actual Client Dialog/OriginalDialog/AlertDialog with Desktop native view and production preload; fixture enterprise service; renderer DOM interaction events', results }, null, 2)); + } finally { host.dispose(); window.destroy(); server.close(); } + app.exit(0); +} + +try { + const assets = join(client, 'build/assets'); + const cssFile = (await readdir(assets)).find(file => /^main[.-].*\.css$/.test(file)); + assert.ok(cssFile, 'Build the Client before running the native check'); + const bundled = await build({ stdin: { contents: renderer, resolveDir: client, loader: 'jsx' }, absWorkingDir: client, + bundle: true, format: 'iife', write: false, tsconfig: join(client, 'tsconfig.json'), + plugins: [{ name: 'fixture-utils', setup(builder) { + builder.onResolve({ filter: /^(~\/utils|\.\.\/\.\.\/utils)$/ }, () => ({ path: 'utils', namespace: 'fixture' })); + builder.onLoad({ filter: /.*/, namespace: 'fixture' }, () => ({ contents: 'export const cn=(...values)=>values.filter(Boolean).join(" ");' })); + } }] }); + await build({ entryPoints: [join(desktop, 'src/preload/enterprise-chat.ts')], outfile: join(temporary, 'preload/enterprise-chat.cjs'), + bundle: true, platform: 'node', format: 'cjs', external: ['electron'] }); + if (process.env.DESKTOP_PRELOAD) await copyFile(resolve(process.env.DESKTOP_PRELOAD), join(temporary, 'preload/enterprise-chat.cjs')); + const config = { output, profile: join(temporary, 'profile'), renderer: bundled.outputFiles[0].text, + css: await readFile(join(assets, cssFile), 'utf8'), + html: '
' }; + const source = 'import { EnterpriseChatView } from "./src/main/enterprise/chat-view";(' + nativeFixture.toString() + ')(' + JSON.stringify(config) + ', EnterpriseChatView).catch(error=>{console.error(error);require("electron").app.exit(1)});'; + await build({ stdin: { contents: source, resolveDir: desktop, loader: 'ts' }, outfile: join(temporary, 'main/index.cjs'), + bundle: true, platform: 'node', format: 'cjs', external: ['electron'], define: { 'import.meta.dirname': '__dirname' } }); + const env = { ...process.env }; delete env.ELECTRON_RUN_AS_NODE; + const child = spawn(desktopRequire('electron'), [join(temporary, 'main/index.cjs')], { env, stdio: 'inherit', signal: AbortSignal.timeout(180000) }); + const code = await new Promise((resolve, reject) => { child.once('error', reject); child.once('close', resolve); }); + assert.equal(code, 0, 'Native desktop dialog verification'); +} finally { await rm(temporary, { recursive: true, force: true }); } diff --git a/scripts/verify-knowledge-square-navigation.mjs b/scripts/verify-knowledge-square-navigation.mjs new file mode 100644 index 0000000000..d9e61f69fb --- /dev/null +++ b/scripts/verify-knowledge-square-navigation.mjs @@ -0,0 +1,169 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +// Actual preview, router, preload and native host; account/data calls use synthetic fixtures. +const project = resolve(import.meta.dirname, '..'); +const client = join(project, 'src/frontend/client'); +const desktop = process.env.DESKTOP_SOURCE && resolve(process.env.DESKTOP_SOURCE); +assert.ok(desktop, 'Set DESKTOP_SOURCE to a BISHENG Desktop V1.2.0 checkout'); +const output = resolve(process.argv[2] || 'tmp/knowledge-square-navigation'); +const requireDesktop = createRequire(join(desktop, 'package.json')); +const { build } = requireDesktop('esbuild'); +const temporary = await mkdtemp(join(tmpdir(), 'bisheng-square-')); +await mkdir(output, { recursive: true }); +const renderer = ` +import React, { useEffect } from 'react'; +import { createRoot } from 'react-dom/client'; +import { BrowserRouter, useLocation } from 'react-router-dom'; +import { KnowledgeSquarePane } from './src/pages/knowledge/KnowledgeSquarePane'; +import { useDesktopNavigation } from './src/components/Chat/useDesktopNavigation'; +function Fixture() { + useDesktopNavigation(); + const location = useLocation(); + useEffect(() => { + window.dshChatPresentation?.reportNavigation({ entries: [{ id: 'square', path: '/square', label: 'Discover', active: true, primary: true }] }); + }, [location]); + return ; +} +createRoot(document.getElementById('root')).render(); +`; +const fixtures = { + '~/hooks': `import { useRef } from 'react'; export { default as usePrefersMobileLayout } from './src/hooks/usePrefersMobileLayout'; export const useScrollRevealRef=()=>useRef(null); const labels={'com_ui_go_back':'返回','com_knowledge.join':'加入','com_knowledge.joined':'已加入','com_knowledge.pending':'待审核','com_knowledge.all_files':'全部文件','com_knowledge.space_view_requires_approval':'该知识空间内容申请通过后方可查看','com_knowledge.articles_count':'篇内容','com_knowledge.users_count':'用户'}; export const useLocalize=()=>key=>labels[key]||key;`, + '~/hooks/useEffectiveQuota': 'export const useEffectiveQuota=()=>({isOverQuota:()=>false});', + '~/components/illustrations': 'import React from "react";import { LockKeyhole } from "lucide-react";export const EmptyStateIllustration=()=>null;export const NoPermissionIllustration=()=> ;', + '~/components/ui/icon/Loading': 'export const LoadingIcon=()=>null;', + '~/Providers': 'export const useToastContext=()=>({showToast:()=>{}});', + '~/common': 'export const NotificationSeverity={WARNING:"warning"};', + '~/utils': 'import clsx from "clsx"; import { twMerge } from "tailwind-merge"; export const cn=(...args)=>twMerge(clsx(args));', + '~/api/permission': 'export const checkResourceAction=async()=>({allowed:new URLSearchParams(location.search).get("scenario")==="joined"});', + '~/api/knowledge': `export const SpaceRole={MEMBER:'member',CREATOR:'creator',ADMIN:'admin'};export const VisibilityType={PUBLIC:'public',APPROVAL:'approval',PRIVATE:'private'};export const SPACE_CHILDREN_STATUS_SUCCESS_ONLY=[1];export const getSpaceInfoApi=async()=>{const scenario=new URLSearchParams(location.search).get('scenario');return {id:'77',name:scenario==='pending-long'?'知识空间详情长标题与操作区布局验证'.repeat(4):'授权审核加入',description:scenario==='pending-long'?'空间说明用于验证长标题、摘要和操作按钮的布局。':undefined,creator:'Fixture owner',visibility:'approval',role:'member',fileCount:0,memberCount:1,subscriptionStatus:scenario==='joined'?'subscribed':scenario==='pending-long'?'pending':'not_subscribed'};};export const getJoinedSpacesApi=async()=>[];export const getSpaceChildrenApi=async()=>({data:[],total:0});export const subscribeSpaceApi=async()=>({status:'pending'});export const unsubscribeSpaceApi=async()=>({});`, + './SpaceDetail/FileCard': 'export const FileCard=()=>null;', + './KnowledgeSquare': `import React from 'react';export default function Square({onPreviewSpace}){return

发现广场 · 知识空间

}`, +}; + +async function nativeFixture(config, EnterpriseChatView) { + const { app, BrowserWindow, webContents } = require('electron'); + const { createServer } = require('node:http'); + const { writeFile } = require('node:fs/promises'); + const assert = require('node:assert/strict'); + app.setPath('userData', config.profile); + let context; + const until = async check => { + for (let i=0;i<160;i++) { if (await check()) return; await new Promise(r=>setTimeout(r,40)); } + throw new Error('Timeout: '+JSON.stringify(context)); + }; + app.on('web-contents-created', (_event, contents) => contents.on('console-message', (_event, level, message) => { if (level >= 2) console.error('[renderer]', message); })); + await app.whenReady(); + const server = createServer((req,res)=>{ + const data = req.url === '/fixture.js' ? ['application/javascript',config.renderer] + : req.url === '/style.css' ? ['text/css',config.css] : ['text/html',config.html]; + res.writeHead(200, {'Content-Type':data[0]+'; charset=utf-8'});res.end(data[1]); + }); + await new Promise(r=>server.listen(0,'127.0.0.1',r)); + const base='http://127.0.0.1:'+server.address().port; + const win=new BrowserWindow({width:1280,height:900,show:true,webPreferences:{sandbox:true,contextIsolation:true,nodeIntegration:false}}); + await win.loadURL('data:text/html,'); + const service={snapshot:()=>({connected:true,connectionKey:'fixture'}),subscribe:()=>()=>{},webSession:async()=>({base,cookieName:'fixture',cookie:'fixture',expiresAt:Date.now()+300000})}; + const host=new EnterpriseChatView(service,line=>console.log('[host]',line)); + const results=[]; + try { + for (const scenario of ['joined','pending-long','join']) for (const width of [900,1280,1600]) for (const zoom of [1,1.25,1.5]) for (const theme of ['light','dark']) { + context={scenario,width,zoom,theme}; + win.setSize(width,900);win.webContents.setZoomFactor(zoom); + const [cw,ch]=win.getContentSize(); + host.present(win,{visible:true,theme,bounds:{x:280/zoom,y:40/zoom,width:(cw-280)/zoom,height:(ch-40)/zoom}}); + context.step='host-ready'; await until(()=>host.snapshot().phase==='ready'); + const page=webContents.getAllWebContents().find(w=>w!==win.webContents&&w.getURL().startsWith(base)); + assert.ok(page);page.setZoomFactor(zoom); + await page.loadURL(base+'/workspace/square?embed=dsh&scenario='+scenario); + context.step='square-mounted'; await until(()=>page.executeJavaScript('!!document.querySelector("#open-space")')); + context.step='navigation-reported'; await until(()=>host.snapshot().navigation?.entries.some(e=>e.id==='square')); + const open=async()=>{ + await page.executeJavaScript('document.querySelector("#open-space").click()'); + await until(()=>page.executeJavaScript('!!document.querySelector("[role=dialog]")')); + await page.executeJavaScript('Promise.all(document.getAnimations().filter(a=>Number.isFinite(a.effect.getComputedTiming().endTime)).map(a=>a.finished.catch(()=>{})))'); + }; + await open(); + assert.equal(new URL(page.getURL()).searchParams.get('previewSpace'),'77'); + const bounds=await page.executeJavaScript(`(()=>{const b=[...document.querySelectorAll('button')].find(b=>b.textContent==='返回');const d=document.querySelector('[role=dialog]');const r=b?.getBoundingClientRect();const p=d.getBoundingClientRect();return {back:!!b,x:r?.x,y:r?.y,right:r?.right,bottom:r?.bottom,viewport:innerWidth,height:innerHeight,dialogX:p.x,dialogRight:p.right};})()`); + assert.ok(bounds.back);assert.ok(bounds.x>=0&&bounds.y>=0&&bounds.right<=bounds.viewport&&bounds.bottom<=bounds.height,JSON.stringify(bounds)); + assert.ok(bounds.dialogX>=-1&&bounds.dialogRight<=bounds.viewport+1,JSON.stringify(bounds)); + const layout=await page.executeJavaScript(`(()=>{ + const rect=e=>{const r=e.getBoundingClientRect();return {x:r.x,y:r.y,right:r.right,bottom:r.bottom,width:r.width,height:r.height};}; + const d=document.querySelector('[role=dialog]'), header=d.querySelector('[data-slot=sheet-header]'); + const back=[...d.querySelectorAll('button')].find(b=>b.textContent==='返回'); + const title=header.querySelector('[data-slot=sheet-title]'), action=header.querySelector('button'); + const files=[...d.querySelectorAll('button')].find(b=>b.textContent==='全部文件'); + return {icon:rect(back.querySelector('svg')),back:rect(back),title:rect(title),action:rect(action),header:rect(header),files:files?rect(files):null,scrollWidth:d.scrollWidth,clientWidth:d.clientWidth}; + })()`); + assert.ok(Math.abs(layout.icon.x-layout.title.x)<=1,JSON.stringify(layout)); + assert.ok(Math.abs(layout.title.y-layout.back.bottom-16)<=1,JSON.stringify(layout)); + assert.ok(Math.abs(layout.action.y-layout.title.y)<=1,JSON.stringify(layout)); + assert.ok(layout.title.right+15<=layout.action.x,JSON.stringify(layout)); + assert.ok(layout.scrollWidth<=layout.clientWidth+1,JSON.stringify(layout)); + if (scenario==='joined') { + assert.ok(layout.files,JSON.stringify(layout)); + assert.ok(Math.abs(layout.files.x-layout.title.x)<=1,JSON.stringify(layout)); + assert.ok(Math.abs(layout.files.y-layout.header.bottom-16)<=1,JSON.stringify(layout)); + } + if (zoom===1) await writeFile(config.output+'/'+scenario+'-'+width+'-'+theme+'.png',(await page.capturePage()).toPNG()); + // Actual native host openPage, including its same-URL early return. + await host.openPage('square'); + await until(()=>page.executeJavaScript('document.querySelector("[role=dialog]")===null')); + assert.equal(new URL(page.getURL()).pathname,'/workspace/square'); + await open(); + await page.executeJavaScript('[...document.querySelectorAll("button")].find(b=>b.textContent==="返回").click()'); + await until(()=>page.executeJavaScript('document.querySelector("[role=dialog]")===null')); + assert.equal(new URL(page.getURL()).searchParams.has('previewSpace'),false); + results.push({...context,bounds,layout,hostReturn:true,headerReturn:true}); + } + const browserResults=[]; + const browser=new BrowserWindow({width:1400,height:900,show:false,webPreferences:{sandbox:true,contextIsolation:true,nodeIntegration:false}}); + try { + for (const width of [390,900,1400]) { + browser.setSize(width,900); + await browser.loadURL(base+'/workspace/square?scenario=joined'); + await until(()=>browser.webContents.executeJavaScript('!!document.querySelector("#open-space")')); + await browser.webContents.executeJavaScript('document.querySelector("#open-space").click()'); + await until(()=>browser.webContents.executeJavaScript('!!document.querySelector("[data-slot=sheet-header]")')); + await browser.webContents.executeJavaScript('Promise.all(document.getAnimations().filter(a=>Number.isFinite(a.effect.getComputedTiming().endTime)).map(a=>a.finished.catch(()=>{})))'); + const layout=await browser.webContents.executeJavaScript(`(()=>{ + const d=document.querySelector('[role=dialog]'),h=d.querySelector('[data-slot=sheet-header]'); + const a=h.querySelector('button').getBoundingClientRect(),t=h.querySelector('[data-slot=sheet-title]').getBoundingClientRect(); + const c=d.querySelector('button[aria-label]'),r=c.getBoundingClientRect(); + return {close:c.getAttribute('aria-label'),closeBottom:r.bottom,actionTop:a.y,titleTop:t.y,titleRight:t.right,actionLeft:a.x,scrollWidth:d.scrollWidth,clientWidth:d.clientWidth}; + })()`); + assert.ok(layout.scrollWidth<=layout.clientWidth+1,JSON.stringify(layout)); + assert.ok(layout.titleRight+15<=layout.actionLeft,JSON.stringify(layout)); + if (width<768) assert.ok(layout.closeBottom<=layout.titleTop,JSON.stringify(layout)); + await writeFile(config.output+'/browser-'+width+'.png',(await browser.webContents.capturePage()).toPNG()); + await browser.webContents.executeJavaScript('document.querySelector("[role=dialog] button[aria-label]").click()'); + await until(()=>browser.webContents.executeJavaScript('document.querySelector("[role=dialog]")===null')); + browserResults.push({width,layout,close:true}); + } + } finally {browser.destroy();} + await writeFile(config.output+'/electron-results.json',JSON.stringify({platform:process.platform,electron:process.versions.electron,results,browserResults},null,2)); + console.log('PASS: '+results.length+' native host/layout/navigation cases and '+browserResults.length+' browser layouts'); + } finally {host.dispose();win.destroy();server.close();} + app.exit(0); +} +try { + const assets=join(client,'build/assets'); + const cssFile=(await readdir(assets)).find(f=>/^main[.-].*\.css$/.test(f)); + assert.ok(cssFile,'Build Client first'); + const rendererBuild=await build({stdin:{contents:renderer,resolveDir:client,loader:'jsx'},absWorkingDir:client,jsx:'automatic',bundle:true,format:'iife',write:false,define:{'import.meta.env':'{"DEV":false}','__APP_ENV__':'{"BASE_URL":""}'},tsconfig:join(client,'tsconfig.json'),plugins:[{name:'fixture-services',setup(b){ + b.onResolve({filter:/.*/},args=>Object.hasOwn(fixtures,args.path)?{path:args.path,namespace:'fixture'}:undefined); + b.onLoad({filter:/.*/,namespace:'fixture'},args=>({contents:fixtures[args.path],resolveDir:client,loader:'jsx'})); + }}]}); + await build({entryPoints:[join(desktop,'src/preload/enterprise-chat.ts')],outfile:join(temporary,'preload/enterprise-chat.cjs'),bundle:true,platform:'node',format:'cjs',external:['electron']}); + const config={output,profile:join(temporary,'profile'),renderer:rendererBuild.outputFiles[0].text,css:await readFile(join(assets,cssFile),'utf8'),html:'
'}; + const source='import { EnterpriseChatView } from "./src/main/enterprise/chat-view";('+nativeFixture.toString()+')('+JSON.stringify(config)+',EnterpriseChatView).catch(e=>{console.error(e);require("electron").app.exit(1)});'; + await build({stdin:{contents:source,resolveDir:desktop,loader:'ts'},outfile:join(temporary,'main/index.cjs'),bundle:true,platform:'node',format:'cjs',external:['electron'],define:{'import.meta.dirname':'__dirname'}}); + const env={...process.env};delete env.ELECTRON_RUN_AS_NODE; + const child=spawn(requireDesktop('electron'),[join(temporary,'main/index.cjs')],{env,stdio:'inherit',signal:AbortSignal.timeout(300000)}); + assert.equal(await new Promise((resolve,reject)=>{child.once('error',reject);child.once('exit',resolve);}),0); +} finally {await rm(temporary,{recursive:true,force:true});} diff --git a/scripts/verify_k8s_runtime.py b/scripts/verify_k8s_runtime.py new file mode 100644 index 0000000000..d154c355e2 --- /dev/null +++ b/scripts/verify_k8s_runtime.py @@ -0,0 +1,1662 @@ +#!/usr/bin/env python3 +"""Installer verification: read-only by default; --live creates scoped canaries. + +The installer impersonates the manager for namespaced resource operations. +Only its own preflight resources are removed, with fresh UID/RV stop evidence. +Kubeconfig, HMAC credentials and private signing keys are never printed. +""" + +from __future__ import annotations + +import argparse +import copy +import hashlib +import ipaddress +import json +import os +import re +import socket +import ssl +import subprocess +import sys +import time +from pathlib import Path +from urllib.parse import urlsplit +from uuid import UUID, uuid4 + +_RUNTIME = Path(__file__).resolve().parents[1] / "src/runtime-manager" +if str(_RUNTIME) not in sys.path: + sys.path.insert(0, str(_RUNTIME)) + +from runtime_manager.k8s_admission import template_projection # noqa: E402 - Repository CLI bootstrap. +from runtime_manager.k8s_capabilities import REQUIRED_CHECKS, descriptor_digest # noqa: E402 +from runtime_manager.k8s_config import validate_namespace # noqa: E402 +from runtime_manager.k8s_templates import ( # noqa: E402 + POLICY_ANNOTATION, + RESOURCE_LABEL, + STOP_FINALIZER, + PodPlan, + pod_template, +) +from runtime_manager.platform_protocol import signed_headers # noqa: E402 + +_NAME = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?") +_IMAGE = re.compile(r"[A-Za-z0-9][A-Za-z0-9./:_-]*@sha256:[a-f0-9]{64}") +_RESULT = "BISHENG_RUNTIME_RESULT " +# A pattern match is insufficient: arbitrary secret text can look like a code. +_SAFE_PROBE_REASONS = frozenset( + { + "admission_negative_unconfirmed", + "admission_negative_was_accepted", + "admission_refresh_identity_changed", + "admission_refresh_refused", + "admission_refresh_revision_unconfirmed", + "admission_refresh_scope_invalid", + "admission_refresh_targets_unavailable", + "assembly_registry_not_configured", + "binding_canary_already_scheduled", + "binding_delete_fence_unconfirmed", + "binding_deletion_observation_invalid", + "control_cleanup_identity_changed", + "control_probe_failed", + "control_probe_identity_changed", + "control_probe_result_missing", + "control_probe_role_invalid", + "control_probe_service_not_isolated", + "cri_architecture_mismatch", + "cri_image_identity_unconfirmed", + "cri_requested_image_changed", + "descriptor_changed_during_probe", + "direct_denial_targets_incomplete", + "dns_control_unconfirmed", + "dns_denial_target_invalid", + "egress_proxy_target_invalid", + "ingress_pod_ip_missing", + "kubectl_request_failed", + "namespace_budget_unconfirmed", + "namespace_ownership_unconfirmed", + "optional_runtime_class_not_configured", + "platform_authenticated_probe_refused", + "platform_node_scope_mismatch", + "platform_origin_invalid", + "platform_unsigned_probe_accepted", + "preflight_absence_unconfirmed", + "preflight_cleanup_identity_changed", + "preflight_create_refused", + "preflight_delete_identity_changed", + "preflight_delete_refused", + "preflight_finalizer_release_refused", + "preflight_log_identity_changed", + "preflight_plans_invalid", + "preflight_pod_failed", + "preflight_pod_replaced", + "preflight_pvc_writer_unconfirmed", + "preflight_recovery_identity_changed", + "preflight_recovery_scope_invalid", + "preflight_recovery_uid_unconfirmed", + "preflight_result_missing", + "preflight_stop_unconfirmed", + "probe_deadline_exceeded", + "probe_driver_set_incomplete", + "rbac_exec_allowed", + "rbac_exec_api_not_refused", + "rbac_scope_unconfirmed", + "registry_probe_digest_invalid", + "registry_probe_digest_mismatch", + "registry_probe_failed", + "registry_probe_origin_invalid", + "registry_probe_resolution_failed", + "registry_probe_targets_exceeded", + "runtime_profile_invalid", + "runtime_profiles_not_verified", + "status_negative_unconfirmed", + "storage_capacity_not_bounded", + "storage_not_qualified", + } +) +_SAFE_CHECK_NAMES = REQUIRED_CHECKS | { + "registry-push", + "sandbox-recommended", + "sandbox-hardened", +} + + +class ProbeError(RuntimeError): + """Only a safe reason is exposed; command output can contain private data.""" + + +class ProbeRunFailure(RuntimeError): + """Keep probe and cleanup failures without relying on traceback rendering.""" + + def __init__(self, probe_error, cleanup_error, report=None): + super().__init__("probe_run_failed") + self.probe_error, self.cleanup_error, self.report = probe_error, cleanup_error, report + + +def safe_exception_type(name): + return name if isinstance(name, str) and re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,127}", name) else "Exception" + + +def safe_failure(error): + # Inspect only the exact constant argument; never invoke an exception's + # __str__ or copy command output, response bodies, headers, or objects. + reason = error.args[0] if isinstance(error, ProbeError) and len(error.args) == 1 else None + return { + "reason": reason if isinstance(reason, str) and reason in _SAFE_PROBE_REASONS else "probe_failed", + "exception_type": safe_exception_type(type(error).__name__), + } + + +def failure_details(error): + if not isinstance(error, ProbeRunFailure): + return [{"phase": "probe", **safe_failure(error)}] + failures = [] + if error.probe_error is not None: + failures.append({"phase": "probe", **safe_failure(error.probe_error)}) + for row in (error.report or {}).get("checks", []): + if row.get("ok") is not False or row.get("name") not in _SAFE_CHECK_NAMES: + continue + exception_type = safe_exception_type(row.get("exception_type")) + reason = row.get("reason") + failures.append( + { + "phase": "check", + "name": row["name"], + "reason": reason + if exception_type == "ProbeError" and reason in _SAFE_PROBE_REASONS + else "probe_failed", + "exception_type": exception_type, + } + ) + nested = row.get("failures") + if exception_type == "ProbeRunFailure" and isinstance(nested, list): + for step in nested[:2]: + if not isinstance(step, dict) or step.get("phase") not in {"probe", "cleanup"}: + continue + step_type = safe_exception_type(step.get("exception_type")) + step_reason = step.get("reason") + failures.append( + { + "phase": "check", + "name": row["name"], + "step": step["phase"], + "reason": step_reason + if step_type == "ProbeError" + and isinstance(step_reason, str) + and step_reason in _SAFE_PROBE_REASONS + else "probe_failed", + "exception_type": step_type, + } + ) + failures.append({"phase": "cleanup", **safe_failure(error.cleanup_error)}) + return failures + + +def log_failures(failures): + print( + json.dumps({"event": "runtime_installation_verification_failed", "failures": failures}, sort_keys=True), + file=sys.stderr, + flush=True, + ) + + +def network_plan(targets): + result = [] + if not isinstance(targets, list) or not targets or len(targets) > 32: + raise ValueError("probe_network_targets_invalid") + for row in targets: + if row.get("expected") == "denied" and row.get("control_confirmed") is not True: + raise ValueError("probe_network_control_required") + value = {key: row[key] for key in ("name", "host", "port", "expected")} + if "protocol" in row: + value["protocol"] = row["protocol"] + if ( + value["expected"] not in {"allowed", "denied"} + or type(value["port"]) is not int + or not 1 <= value["port"] <= 65535 + ): + raise ValueError("probe_network_target_invalid") + if not re.fullmatch(r"[A-Za-z0-9_.-]{1,128}", value["name"]): + raise ValueError("probe_network_name_invalid") + ipaddress.ip_address(value["host"]) + if value.get("protocol", "tcp") not in {"tcp", "udp-dns"}: + raise ValueError("probe_network_protocol_invalid") + result.append(value) + return {"targets": result} + + +def check_result(name, raw, uid): + if str(UUID(uid)) != uid or not isinstance(raw, dict): + raise ValueError("probe_result_invalid") + if name == "storage-sqlite": + passed = ( + all(raw.get(key) is True for key in ("locking", "fsync", "sqlite_integrity")) + and raw.get("filesystem") in {"ext4", "xfs"} + and type(raw.get("capacity_bytes")) is int + and raw["capacity_bytes"] > 0 + ) + elif name.startswith("network-"): + rows = raw.get("checks") + passed = isinstance(rows, list) and bool(rows) and all(row.get("ok") is True for row in rows) + else: + passed = raw.get("verified") is True + if not passed: + raise ValueError("probe_result_unconfirmed") + return {"ok": True, "evidence": {"uid": uid}} + + +def stop_confirmed(pod, *, binding_evidence=None): + meta, spec, status = ( + pod.get("metadata", {}), + pod.get("spec", {}), + pod.get("status", {}), + ) + if not meta.get("name", "").startswith("preflight-") or not meta.get("uid") or not meta.get("deletionTimestamp"): + return False + if status.get("reason") in {"NodeLost", "Unknown"} or status.get("phase") == "Unknown": + return False + states = [ + row.get("state", {}) + for key in ( + "containerStatuses", + "initContainerStatuses", + "ephemeralContainerStatuses", + ) + for row in status.get(key, []) + ] + bound_evidence = isinstance(binding_evidence, dict) and all( + meta.get(key) and binding_evidence.get(key) == meta[key] + for key in ("namespace", "name", "uid", "deletionTimestamp") + ) + return not any("running" in state for state in states) and ( + (not spec.get("nodeName") and not states and bound_evidence) or status.get("phase") in {"Succeeded", "Failed"} + ) + + +def execute_checks(drivers, document, *, observed_at): + rows, artifacts = [], {} + for name, driver in sorted(drivers.items()): + try: + result = driver() + uid = result["evidence"]["uid"] + if result.get("ok") is not True or str(UUID(uid)) != uid: + raise ValueError() + row = { + "name": name, + "ok": True, + "reason": "verified", + "evidence": result["evidence"], + } + if result.get("observations"): + artifacts[name] = result["observations"] + except Exception as error: + row = {"name": name, "ok": False, **safe_failure(error), "evidence": {}} + if isinstance(error, ProbeRunFailure): + row["failures"] = failure_details(error) + rows.append({**row, "observed_at": observed_at}) + return { + "artifacts": artifacts, + "source": "cluster-probe", + "observed_at": observed_at, + "descriptor_digest": descriptor_digest(document), + "checks": rows, + } + + +def offline_inventory(manifest, document, dependencies=()): + """Check supplied release metadata and local dependency bytes separately. + + This is inventory evidence only; live CRI and publication tests remain + mandatory. No tag or local Docker image ID substitutes for a manifest ref. + """ + platform = document["platform"] + if manifest.get("schema_version") != 2: + raise ValueError("offline_release_schema_invalid") + if "platforms" in manifest: + images = manifest["platforms"].get(platform, {}) + elif manifest.get("platform") == platform and manifest.get("state") == "published-native-verified": + images = manifest.get("images", {}) + else: + raise ValueError("offline_release_platform_unverified") + required = { + "runtime-manager", + "app-proxy", + "runtime-helper", + "runtime-assembler", + "app-python", + "app-python-preparer", + "app-node", + "app-node-preparer", + "app-static", + } + if set(images) != required: + raise ValueError("offline_release_images_incomplete") + known = set() + for image in images.values(): + if ( + not _IMAGE.fullmatch(image.get("image") or "") + or image.get("native_smoke") is not True + or image.get("platform") != platform + ): + raise ValueError("offline_release_digest_or_native_unverified") + known.add(image["image"]) + selected = [document["helper_image"], document.get("assembler_image", "")] + selected += [profile["runtime_image"] for profile in document.get("canary_profiles", [])] + if any(image and image not in known for image in selected): + raise ValueError("offline_descriptor_release_mismatch") + checked = [] + for item in dependencies: + path = Path(item["path"]) + if path.is_symlink() or not path.is_file() or not re.fullmatch(r"[a-f0-9]{64}", item.get("sha256", "")): + raise ValueError("offline_dependency_inventory_invalid") + digest = hashlib.sha256() + with path.open("rb") as source: + while block := source.read(1024 * 1024): + digest.update(block) + if digest.hexdigest() != item["sha256"]: + raise ValueError("offline_dependency_digest_mismatch") + checked.append({"sha256": digest.hexdigest(), "bytes": path.stat().st_size}) + return { + "platform": platform, + "image_references": sorted(known), + "dependencies": checked, + "cri_started": False, + "dependency_resolution_executed": False, + "platform_apis_verified": False, + } + + +class Kubectl: + def __init__( + self, + *, + context, + control_namespace, + execution_namespace, + manager_account, + binary="kubectl", + ): + self.context, self.binary = context, binary + self.control, self.execution = ( + validate_namespace(control_namespace), + validate_namespace(execution_namespace), + ) + if not _NAME.fullmatch(manager_account): + raise ValueError("manager_account_invalid") + self.account = manager_account + + def call(self, args, *, document=None, manager=False, accepted=(0,), timeout=40): + command = [self.binary, "--context", self.context, "--request-timeout=30s"] + if manager: + command += [ + "--as", + f"system:serviceaccount:{self.control}:{self.account}", + "--as-group", + "system:serviceaccounts", + "--as-group", + f"system:serviceaccounts:{self.control}", + "--as-group", + "system:authenticated", + ] + response = subprocess.run( + command + args, + input=json.dumps(document) if document is not None else None, + text=True, + capture_output=True, + timeout=timeout, + check=False, + ) + if response.returncode not in accepted: + raise ProbeError("kubectl_request_failed") + return response + + def get(self, kind, name, *, namespace=None, manager=False): + return json.loads( + self.call( + ["get", kind, name, "-n", namespace or self.execution, "-o", "json"], + manager=manager, + ).stdout + ) + + def list(self, kind, *, namespace=None, manager=False): + return json.loads( + self.call( + ["get", kind, "-n", namespace or self.execution, "-o", "json"], + manager=manager, + ).stdout + )["items"] + + def raw(self, method, path, *, document=None, manager=False, accepted=(0,)): + verb = {"GET": "get", "POST": "create", "PUT": "replace", "DELETE": "delete"}[method] + return self.call( + [verb, "--raw", path] + (["-f", "-"] if method != "GET" else []), + document=document, + manager=manager, + accepted=accepted, + ) + + def can(self, verb, resource, namespace): + base, _, subresource = resource.partition("/") + command = ["auth", "can-i", verb, base] + if subresource: + command.append("--subresource=" + subresource) + command += ["-n", namespace] + return self.call(command, manager=True, accepted=(0, 1)).stdout.strip() == "yes" + + +class InstallationVerifier: + def __init__(self, api, *, options, timeout=180): + self.api, self.options, self.timeout = api, options, timeout + self.cm = api.get("configmap", "runtime-deployment-descriptor", namespace=api.control) + self.document = json.loads(self.cm["data"]["descriptor.json"]) + self.namespace = api.execution + if self.document.get("namespace") != self.namespace or not _IMAGE.fullmatch( + self.document.get("helper_image", "") + ): + raise ValueError("descriptor_scope_or_digest_invalid") + self.created, self.control_created, self.idle = [], [], None + + def baseline(self): + namespace = self.api.get("namespace", self.namespace) + quota = self.api.get("resourcequota", "runtime-budget") + webhooks = self.api.get("validatingwebhookconfiguration", self.options["webhook_name"]) + return { + "namespace_uid": namespace["metadata"]["uid"], + "quota_uid": quota["metadata"]["uid"], + "webhook_uid": webhooks["metadata"]["uid"], + "descriptor_digest": descriptor_digest(self.document), + "images": { + key: self.document[key] for key in ("helper_image", "assembler_image") if self.document.get(key) + }, + "live_probes_executed": False, + "offline_inventory": self._offline_inventory(), + } + + def _offline_inventory(self): + path = self.options.get("release_manifest") + return ( + offline_inventory( + json.loads(Path(path).read_text()), self.document, self.options.get("dependency_files", []) + ) + if path + else {"verified": False, "reason": "release_manifest_not_supplied"} + ) + + def _path(self, resource, name=""): + return "/api/v1/namespaces/" + self.namespace + "/" + resource + ("/" + name if name else "") + + def _wait(self, predicate): + end = time.monotonic() + self.timeout + while time.monotonic() < end: + result = predicate() + if result: + return result + time.sleep(2) + raise ProbeError("probe_deadline_exceeded") + + def _plans(self, ref, plan=None, *, remove=False): + cm = self.api.get("configmap", "runtime-canary-plans", namespace=self.api.control) + document = json.loads(cm["data"]["plans.json"]) + if set(document) - {"schema_version", "plans", "profile_images"} or document["schema_version"] != 1: + raise ProbeError("preflight_plans_invalid") + if remove: + document["plans"].pop(ref, None) + else: + document["plans"][ref] = plan + try: + original = cm["metadata"] + if ( + original["name"] != "runtime-canary-plans" + or original["namespace"] != self.api.control + or str(UUID(original["uid"])) != original["uid"] + or not isinstance(original["resourceVersion"], str) + or not original["resourceVersion"] + ): + raise ValueError() + except (KeyError, TypeError, ValueError): + raise ProbeError("admission_refresh_revision_unconfirmed") from None + cm["data"]["plans.json"] = json.dumps(document, sort_keys=True, separators=(",", ":")) + response = self.api.call(["replace", "-f", "-", "-o", "json"], document=cm) + try: + saved = json.loads(response.stdout) + original, observed = cm["metadata"], saved["metadata"] + if ( + original["name"] != "runtime-canary-plans" + or original["namespace"] != self.api.control + or observed["name"] != original["name"] + or observed["namespace"] != original["namespace"] + or observed["uid"] != original["uid"] + or str(UUID(observed["uid"])) != observed["uid"] + or not isinstance(observed["resourceVersion"], str) + or not 0 < len(observed["resourceVersion"]) <= 1024 + ): + raise ValueError() + # The annotation contains only an opaque public revision, never + # canary templates or any credential/configuration bytes. + revision = hashlib.sha256((observed["uid"] + "\0" + observed["resourceVersion"]).encode()).hexdigest() + except (KeyError, TypeError, ValueError): + raise ProbeError("admission_refresh_revision_unconfirmed") from None + self._refresh_admission_plans(revision) + + def _admission_refresh_target(self, pod): + metadata = pod.get("metadata", {}) + if ( + self.api.control == self.namespace + or metadata.get("namespace") != self.api.control + or metadata.get("labels", {}).get("app") != "runtime-admission" + or metadata.get("deletionTimestamp") + or not _NAME.fullmatch(metadata.get("name", "")) + or pod.get("status", {}).get("phase") != "Running" + ): + raise ProbeError("admission_refresh_scope_invalid") + try: + if ( + str(UUID(metadata["uid"])) != metadata["uid"] + or not isinstance(metadata["resourceVersion"], str) + or not metadata["resourceVersion"] + ): + raise ValueError() + except (KeyError, TypeError, ValueError): + raise ProbeError("admission_refresh_identity_changed") from None + volumes = set() + for volume in pod.get("spec", {}).get("volumes", []): + references = [volume.get("configMap", {})] + references += [source.get("configMap", {}) for source in volume.get("projected", {}).get("sources", [])] + if any(reference.get("name") == "runtime-canary-plans" for reference in references): + volumes.add(volume.get("name")) + mounted = any( + mount.get("name") in volumes + and mount.get("mountPath") == "/run/preflight" + and mount.get("readOnly") is True + and not mount.get("subPath") + and not mount.get("subPathExpr") + for container in pod.get("spec", {}).get("containers", []) + if container.get("name") == "runtime-admission" + for mount in container.get("volumeMounts", []) + ) + if not mounted: + raise ProbeError("admission_refresh_scope_invalid") + return metadata + + def _refresh_admission_plans(self, revision): + # K8s 1.28 documentation explicitly recommends a Pod annotation update + # for immediate mounted-ConfigMap refresh. This is only a refresh hint; + # the existing actual admission request still verifies plan visibility. + # https://github.com/kubernetes/website/blob/release-1.28/content/en/docs/tasks/configure-pod-container/configure-pod-configmap.md#mounted-configmaps-are-updated-automatically + try: + response = self.api.call( + ["get", "pods", "-n", self.api.control, "-l", "app=runtime-admission", "-o", "json"] + ) + pods = json.loads(response.stdout)["items"] + except Exception: + raise ProbeError("admission_refresh_refused") from None + if not isinstance(pods, list) or not pods: + raise ProbeError("admission_refresh_targets_unavailable") + annotation = "runtime.bisheng.io/canary-plans-revision" + for listed in pods: + previous = self._admission_refresh_target(listed) + try: + current = self.api.get("pod", previous["name"], namespace=self.api.control) + except Exception: + raise ProbeError("admission_refresh_refused") from None + metadata = self._admission_refresh_target(current) + if metadata["uid"] != previous["uid"] or metadata["name"] != previous["name"]: + raise ProbeError("admission_refresh_identity_changed") + patch = [ + {"op": "test", "path": "/metadata/uid", "value": metadata["uid"]}, + {"op": "test", "path": "/metadata/resourceVersion", "value": metadata["resourceVersion"]}, + ] + if metadata.get("annotations") is None: + patch.append({"op": "add", "path": "/metadata/annotations", "value": {annotation: revision}}) + else: + patch.append( + {"op": "add", "path": "/metadata/annotations/" + annotation.replace("/", "~1"), "value": revision} + ) + try: + response = self.api.call( + [ + "patch", + "pod", + previous["name"], + "-n", + self.api.control, + "--type=json", + "-p", + json.dumps(patch), + "-o", + "json", + ] + ) + changed = json.loads(response.stdout) + except Exception: + raise ProbeError("admission_refresh_refused") from None + result = self._admission_refresh_target(changed) + if result["uid"] != previous["uid"] or result["name"] != previous["name"]: + raise ProbeError("admission_refresh_identity_changed") + if result.get("annotations", {}).get(annotation) != revision: + raise ProbeError("admission_refresh_revision_unconfirmed") + + def _create(self, template): + meta = template["metadata"] + plan = { + "resource_ref": meta["labels"][RESOURCE_LABEL], + "namespace": self.namespace, + "name": meta["name"], + "kind": template["kind"], + "uid": None, + "template": template_projection(template), + "stop_state": "held", + "allow_create": True, + "allow_update": False, + "allow_delete": False, + "cleanup_only": False, + "service_cidrs": self.document.get("service_cidrs", []), + "expires_at": int(time.time()) + 3500, + } + self._plans(plan["resource_ref"], plan) + resource = {"Pod": "pods", "PersistentVolumeClaim": "persistentvolumeclaims"}[template["kind"]] + + def attempt(): + result = self.api.raw( + "POST", + self._path(resource), + document=template, + manager=True, + accepted=(0, 1), + ) + if not result.returncode: + return json.loads(result.stdout) + if ( + "admission_plan_unavailable" not in result.stderr + and "admission_plan_action_refused" not in result.stderr + ): + raise ProbeError("preflight_create_refused") + return None + + observed = self._wait(attempt) + plan.update(uid=observed["metadata"]["uid"], allow_create=False, allow_update=True, allow_delete=True) + self.created.append( + { + "plan": plan, + "resource": resource, + "uid": plan["uid"], + "name": plan["name"], + } + ) + self._plans(plan["resource_ref"], plan) + return observed + + def _pod(self, action, *, targets=None, pvc="", unschedulable=False, image="", command=(), runtime_class=""): + helper = self.document["helper_image"] + plan = PodPlan( + namespace=self.namespace, + app_id="preflight", + operation_id="preflight-" + uuid4().hex[:20], + resource_ref=str(uuid4()), + node_id="preflight", + generation=1, + owner_epoch=1, + purpose="probe", + platform=self.document["platform"], + status_policy_revision=self.document["status_policy_revision"], + image=image or helper, + helper_image=helper, + command=tuple(command) or ("python", "-I", "-m", "runtime_manager.k8s_capabilities", action), + memory_mb=128, + cpu_millicores=50, + mode="image", + deadline_seconds=600 if action == "idle" and not command else min(self.timeout + 60, 600), + health_path="/healthz", + runtime_class=runtime_class, + ) + pod = pod_template(plan) + pod["metadata"]["name"] = "preflight-" + uuid4().hex[:20] + if unschedulable: + pod["spec"]["nodeSelector"]["runtime.bisheng.io/preflight-unassigned"] = uuid4().hex + app = pod["spec"]["containers"][0] + for key in ("readinessProbe", "livenessProbe", "startupProbe"): + app.pop(key, None) + app["ports"] = [ + {"name": "http", "containerPort": 8080}, + {"name": "data-control", "containerPort": 8092}, + ] + if targets is not None: + app["env"] = [ + { + "name": "CAPABILITY_PLAN", + "value": json.dumps(network_plan(targets), sort_keys=True), + } + ] + if pvc: + pod["spec"]["volumes"].append({"name": "probe-volume", "persistentVolumeClaim": {"claimName": pvc}}) + app["volumeMounts"].append({"name": "probe-volume", "mountPath": "/probe-volume"}) + return self._create(pod) + + def _observe(self, pod, *, running=False): + def ready(): + current = self.api.get("pod", pod["metadata"]["name"], manager=True) + if current["metadata"]["uid"] != pod["metadata"]["uid"]: + raise ProbeError("preflight_pod_replaced") + phase = current.get("status", {}).get("phase") + if phase == "Failed": + raise ProbeError("preflight_pod_failed") + return current if phase == ("Running" if running else "Succeeded") else None + + return self._wait(ready) + + def _result(self, pod): + current = self._observe(pod) + response = self.api.call( + [ + "logs", + "-n", + self.namespace, + pod["metadata"]["name"], + "-c", + "app", + "--limit-bytes=65536", + ], + manager=True, + ) + if self.api.get("pod", pod["metadata"]["name"], manager=True)["metadata"]["uid"] != current["metadata"]["uid"]: + raise ProbeError("preflight_log_identity_changed") + rows = [line[len(_RESULT) :] for line in response.stdout.splitlines() if line.startswith(_RESULT)] + if len(rows) != 1: + raise ProbeError("preflight_result_missing") + return json.loads(rows[0]) + + @staticmethod + def _proof(obj, **extra): + return {"ok": True, "evidence": {"uid": obj["metadata"]["uid"], **extra}} + + def _namespace_budget(self): + namespace = self.api.get("namespace", self.namespace) + if ( + namespace["metadata"].get("labels", {}).get("runtime.bisheng.io/installation") + != self.options["installation_id"] + ): + raise ProbeError("namespace_ownership_unconfirmed") + hard = self.api.get("resourcequota", "runtime-budget")["spec"]["hard"] + if ( + hard.get("services.nodeports") != "0" + or hard.get("services.loadbalancers") != "0" + or not all( + key in hard + for key in ( + "requests.cpu", + "limits.cpu", + "requests.memory", + "limits.memory", + "pods", + ) + ) + ): + raise ProbeError("namespace_budget_unconfirmed") + return self._proof(namespace) + + def _rbac(self): + denied = [ + ("list", "pods", self.api.control), + ("get", "secrets", self.api.control), + ("get", "nodes", self.namespace), + ("create", "roles", self.namespace), + ("get", "configmaps", self.namespace), + ("patch", "pods/status", self.namespace), + ] + if not self.api.can("list", "pods", self.namespace) or any(self.api.can(*row) for row in denied): + raise ProbeError("rbac_scope_unconfirmed") + return self._proof(self.api.get("serviceaccount", self.api.account, namespace=self.api.control)) + + def _no_exec(self): + if any( + self.api.can("create", resource, self.namespace) + for resource in ("pods/exec", "pods/attach", "pods/portforward") + ): + raise ProbeError("rbac_exec_allowed") + denied_call = self.api.raw( + "POST", + self._path("pods", self.idle["metadata"]["name"]) + "/exec?command=true&stdout=true&stderr=true", + document={}, + manager=True, + accepted=(0, 1), + ) + if not denied_call.returncode or "Forbidden" not in denied_call.stderr: + raise ProbeError("rbac_exec_api_not_refused") + return self._proof(self.api.get("serviceaccount", self.api.account, namespace=self.api.control)) + + def _admission_negative(self, *, psa=False): + original = next(row["plan"] for row in self.created if row["resource"] == "pods") + plan = copy.deepcopy(original) + ref = str(uuid4()) + plan.update( + resource_ref=ref, + uid=None, + name="preflight-deny-" + uuid4().hex[:12], + allow_create=True, + allow_update=False, + allow_delete=False, + ) + pod = copy.deepcopy(plan["template"]) + pod["metadata"]["name"] = plan["name"] + pod["metadata"]["labels"][RESOURCE_LABEL] = ref + plan["template"] = copy.deepcopy(pod) + if psa: + # Keep the payload schema-valid so the real restricted PSA check, + # rather than the API's contradictory-security-context validator, + # must refuse the dry-run request. + pod["spec"]["containers"][0]["securityContext"].update(privileged=True, allowPrivilegeEscalation=True) + else: + pod["spec"]["containers"][0]["image"] = "unauthorized.invalid/image:latest" + + def denied(): + response = self.api.raw( + "POST", self._path("pods") + "?dryRun=All", document=pod, manager=True, accepted=(0, 1) + ) + if not response.returncode: + # A successful server dry-run persisted no Pod. It is still a + # failed isolation check; never retry with a live CREATE. + raise ProbeError("admission_negative_was_accepted") + expected = "violates PodSecurity" if psa else "admission_template_mismatch" + if expected in response.stderr: + return True + if "admission_plan_unavailable" in response.stderr: + return False + raise ProbeError("admission_negative_unconfirmed") + + probe_error = None + try: + self._plans(ref, plan) + self._wait(denied) + return self._proof(self.idle) + except BaseException as error: + probe_error = error + raise + finally: + # This approval belongs only to a dry-run that cannot allocate a + # UID. Do not leave it to block later same-UID resource recovery. + try: + self._plans(ref, remove=True) + except Exception as cleanup_error: + raise ProbeRunFailure(probe_error, cleanup_error) from None + + def _status(self): + current = self.api.get("pod", self.idle["metadata"]["name"], manager=True) + forged = copy.deepcopy(current) + forged["status"] = {"phase": "Succeeded"} + response = self.api.raw( + "PUT", + self._path("pods", current["metadata"]["name"]) + "/status", + document=forged, + accepted=(0, 1), + ) + if not response.returncode or "admission_terminal_source_refused" not in response.stderr: + raise ProbeError("status_negative_unconfirmed") + return self._proof(current, policy_revision=self.document["status_policy_revision"]) + + @staticmethod + def _unbound_deleting(pod): + metadata, status = pod.get("metadata", {}), pod.get("status", {}) + return ( + metadata.get("name", "").startswith("preflight-") + and bool(metadata.get("uid")) + and bool(metadata.get("deletionTimestamp")) + and not pod.get("spec", {}).get("nodeName") + and status.get("phase") != "Unknown" + and status.get("reason") not in {"Unknown", "NodeLost"} + and not any( + status.get(key) + for key in ( + "containerStatuses", + "initContainerStatuses", + "ephemeralContainerStatuses", + ) + ) + ) + + def _binding_stop_evidence(self, pod, *, namespace, manager): + metadata = pod["metadata"] + name, uid = metadata["name"], metadata["uid"] + fresh = self.api.get("pod", name, namespace=namespace, manager=manager) + before = fresh["metadata"] + if before.get("uid") != uid or before.get("namespace") != namespace or not self._unbound_deleting(fresh): + raise ProbeError("binding_deletion_observation_invalid") + binding = { + "apiVersion": "v1", + "kind": "Binding", + "metadata": {key: before[key] for key in ("name", "namespace", "uid", "resourceVersion")}, + "target": { + "apiVersion": "v1", + "kind": "Node", + "name": self.options["node_name"], + }, + } + response = self.api.raw( + "POST", + "/api/v1/namespaces/" + namespace + "/pods/" + name + "/binding", + document=binding, + # Only the external installer may attempt a binding. The manager + # intentionally has no pods/binding permission. + manager=False, + accepted=(0, 1), + ) + if not response.returncode or "being deleted" not in response.stderr: + raise ProbeError("binding_delete_fence_unconfirmed") + observed = self.api.get("pod", name, namespace=namespace, manager=manager) + after = observed["metadata"] + if any( + after.get(key) != before[key] for key in ("namespace", "name", "uid", "deletionTimestamp") + ) or not self._unbound_deleting(observed): + raise ProbeError("binding_deletion_observation_invalid") + return {key: after[key] for key in ("namespace", "name", "uid", "deletionTimestamp")} + + def _binding_fence(self): + pod = self._pod("idle", unschedulable=True) + current = self.api.get("pod", pod["metadata"]["name"], manager=True) + if current["metadata"]["uid"] != pod["metadata"]["uid"]: + raise ProbeError("binding_deletion_observation_invalid") + if current["spec"].get("nodeName"): + raise ProbeError("binding_canary_already_scheduled") + self._delete_request(current, "pods") + evidence = self._binding_stop_evidence(current, namespace=self.namespace, manager=True) + record = next(row for row in self.created if row["resource"] == "pods" and row["uid"] == evidence["uid"]) + record["binding_stop_evidence"] = evidence + return self._proof(self.api.get("pod", evidence["name"], manager=True)) + + def _stop_observation(self, record, *, namespace, manager): + fresh = self.api.get("pod", record["name"], namespace=namespace, manager=manager) + if fresh["metadata"]["uid"] != record["uid"]: + raise ProbeError("preflight_cleanup_identity_changed") + evidence = record.get("binding_stop_evidence") + if not stop_confirmed(fresh, binding_evidence=evidence) and self._unbound_deleting(fresh): + record["binding_stop_evidence"] = self._binding_stop_evidence(fresh, namespace=namespace, manager=manager) + fresh = self.api.get("pod", record["name"], namespace=namespace, manager=manager) + if fresh["metadata"]["uid"] != record["uid"]: + raise ProbeError("preflight_cleanup_identity_changed") + return fresh if stop_confirmed(fresh, binding_evidence=record.get("binding_stop_evidence")) else None + + def _delete_request(self, current, resource): + expected_uid, name = current["metadata"]["uid"], current["metadata"]["name"] + + def attempt(): + fresh = self.api.get(resource, name, manager=True) + if fresh["metadata"]["uid"] != expected_uid: + raise ProbeError("preflight_delete_identity_changed") + if fresh["metadata"].get("deletionTimestamp"): + return True + body = { + "apiVersion": "v1", + "kind": "DeleteOptions", + "gracePeriodSeconds": 5, + "preconditions": {"uid": expected_uid, "resourceVersion": fresh["metadata"]["resourceVersion"]}, + } + result = self.api.raw("DELETE", self._path(resource, name), document=body, manager=True, accepted=(0, 1)) + if result.returncode and not any( + reason in result.stderr + for reason in ( + "admission_uid_refused", + "admission_plan_action_refused", + "admission_plan_unavailable", + "Conflict", + "object has been modified", + ) + ): + raise ProbeError("preflight_delete_refused") + return result.returncode == 0 + + self._wait(attempt) + + def _storage(self): + storage = self.document.get("storage", {}) + if storage.get("sqlite_compatible") is not True or storage.get("filesystem") not in {"ext4", "xfs"}: + raise ProbeError("storage_not_qualified") + pvc = { + "apiVersion": "v1", + "kind": "PersistentVolumeClaim", + "metadata": { + "name": "preflight-" + uuid4().hex[:20], + "namespace": self.namespace, + "labels": { + RESOURCE_LABEL: str(uuid4()), + "bisheng.managed": "v2", + "runtime.bisheng.io/purpose": "probe", + }, + "annotations": { + POLICY_ANNOTATION: self.document["status_policy_revision"], + "runtime.bisheng.io/operation": "preflight-" + uuid4().hex[:20], + "runtime.bisheng.io/generation": "1", + "runtime.bisheng.io/owner-epoch": "1", + }, + }, + "spec": { + "accessModes": ["ReadWriteOnce"], + "storageClassName": storage.get("class", ""), + "volumeMode": "Filesystem", + "resources": {"requests": {"storage": str(self.options.get("storage_size_mb", 1024)) + "Mi"}}, + }, + } + if storage.get("static_pool"): + pvc["spec"]["selector"] = { + "matchLabels": { + "runtime.bisheng.io/namespace": self.namespace, + "runtime.bisheng.io/volume-role": "preflight", + } + } + self._create(pvc) + pod = self._pod("storage", pvc=pvc["metadata"]["name"]) + result = self._result(pod) + expected = int(self.options.get("storage_size_mb", 1024)) * 1024 * 1024 + capacity = result.get("capacity_bytes", 0) + if type(capacity) is not int or not 0.8 * expected <= capacity <= 1.1 * expected: + raise ProbeError("storage_capacity_not_bounded") + return check_result("storage-sqlite", result, pod["metadata"]["uid"]) + + def _image_one(self, observed, expected_image): + rows = observed.get("status", {}).get("containerStatuses", []) + if not rows or any(not row.get("imageID") or "sha256:" not in row["imageID"] for row in rows): + raise ProbeError("cri_image_identity_unconfirmed") + node = self.api.get("node", observed["spec"]["nodeName"]) + if "linux/" + node["status"]["nodeInfo"]["architecture"] != self.document["platform"]: + raise ProbeError("cri_architecture_mismatch") + if observed["spec"]["containers"][0]["image"] != expected_image: + raise ProbeError("cri_requested_image_changed") + result = self._proof(observed, digest=expected_image.split("@", 1)[1]) + result["observations"] = [ + { + "uid": observed["metadata"]["uid"], + "resource_version": observed["metadata"]["resourceVersion"], + "image_ref": expected_image, + "image_id": row["imageID"], + "platform": self.document["platform"], + } + for row in rows + ] + return result + + def _image(self): + result = self._image_one(self.idle, self.document["helper_image"]) + profiles = self.document.get("canary_profiles", []) + if not profiles: + raise ProbeError("runtime_profiles_not_verified") + for profile in profiles: + command = profile.get("command") + if command not in [ + ["python", "--version"], + ["node", "--version"], + ["nginx", "-v"], + ] or not _IMAGE.fullmatch(profile.get("runtime_image", "")): + raise ProbeError("runtime_profile_invalid") + pod = self._pod("idle", image=profile["runtime_image"], command=command) + observed = self._observe(pod) + result["observations"].extend(self._image_one(observed, profile["runtime_image"])["observations"]) + return result + + def _sandbox(self, level): + runtime_class = self.document.get("sandbox", {}).get("runtime_classes", {}).get(level, "") + if not runtime_class: + raise ProbeError("optional_runtime_class_not_configured") + pod = self._observe(self._pod("idle", runtime_class=runtime_class), running=True) + return self._proof(pod, profile=level) + + @staticmethod + def _control_connect(row): + if row.get("protocol", "tcp") == "udp-dns": + from runtime_manager.k8s_capabilities import network_checks + + probe = { + "targets": [ + {key: row[key] for key in ("name", "host", "port")} | {"expected": "allowed", "protocol": "udp-dns"} + ] + } + if not all(item["ok"] for item in network_checks(probe)["checks"]): + raise ProbeError("dns_control_unconfirmed") + else: + with socket.create_connection((row["host"], row["port"]), timeout=2): + pass + + def _network(self, group): + targets = copy.deepcopy(self.options["network_targets"][group]) + for target in targets: + if target.get("name") == "other-app" and target.get("host") == "@preflight": + target["host"] = self.idle["status"]["podIP"] + if group == "network-direct-denied": + if not {"backend", "minio", "kubernetes-api", "other-app"} <= {row.get("name") for row in targets} or any( + row.get("expected") != "denied" for row in targets + ): + raise ProbeError("direct_denial_targets_incomplete") + elif group == "network-dns-denied": + if not targets or any( + row.get("expected") != "denied" or row.get("protocol") != "udp-dns" for row in targets + ): + raise ProbeError("dns_denial_target_invalid") + else: + service = self.api.get("service", "egress-proxy", namespace=self.api.control) + if not targets or any( + row.get("expected") != "allowed" + or row.get("host") != service["spec"].get("clusterIP") + or row.get("port") != 3128 + for row in targets + ): + raise ProbeError("egress_proxy_target_invalid") + for row in targets: + if row.get("expected") == "denied": + self._control_connect(row) + row["control_confirmed"] = True + pod = self._pod("network", targets=targets) + result = self._result(pod) + for row in targets: + if row.get("expected") == "denied": + self._control_connect(row) + return check_result(group, result, pod["metadata"]["uid"]) + + def _control_probe(self, role, targets): + if role not in {"app-proxy", "runtime-manager", "egress-proxy", "preflight-untrusted"}: + raise ProbeError("control_probe_role_invalid") + if role != "preflight-untrusted": + service = self.api.get("service", role, namespace=self.api.control) + if service.get("spec", {}).get("publishNotReadyAddresses", False) is not False: + raise ProbeError("control_probe_service_not_isolated") + plan = PodPlan( + namespace=self.api.control, + app_id="preflight", + operation_id="preflight-" + uuid4().hex[:20], + resource_ref=str(uuid4()), + node_id="preflight", + generation=1, + owner_epoch=1, + purpose="probe", + platform=self.document["platform"], + status_policy_revision=self.document["status_policy_revision"], + image=self.document["helper_image"], + helper_image=self.document["helper_image"], + command=( + "python", + "-I", + "-m", + "runtime_manager.k8s_capabilities", + "network", + ), + memory_mb=128, + cpu_millicores=50, + mode="image", + deadline_seconds=self.timeout + 60, + ) + pod = pod_template(plan) + # Reuse the real policy label without serving traffic through its Service. + # No controller populates this condition; phase Succeeded is still observable. + pod["spec"]["readinessGates"] = [{"conditionType": "runtime.bisheng.io/preflight-not-serving"}] + pod["metadata"]["name"] = "preflight-" + uuid4().hex[:20] + pod["metadata"]["labels"] = { + "app": role, + "runtime.bisheng.io/preflight-installation": self.options["installation_id"], + } + pod["metadata"]["annotations"]["runtime.bisheng.io/preflight-expiry"] = str(int(time.time()) + 3500) + app = pod["spec"]["containers"][0] + app["env"] = [ + { + "name": "CAPABILITY_PLAN", + "value": json.dumps(network_plan(targets), sort_keys=True), + } + ] + for key in ("readinessProbe", "livenessProbe", "startupProbe"): + app.pop(key, None) + response = self.api.call(["create", "-f", "-", "-o", "json"], document=pod) + observed = json.loads(response.stdout) + self.control_created.append(observed) + + def finished(): + current = self.api.get("pod", observed["metadata"]["name"], namespace=self.api.control) + if current["metadata"]["uid"] != observed["metadata"]["uid"]: + raise ProbeError("control_probe_identity_changed") + if current.get("status", {}).get("phase") == "Failed": + raise ProbeError("control_probe_failed") + return current if current.get("status", {}).get("phase") == "Succeeded" else None + + self._wait(finished) + response = self.api.call( + [ + "logs", + "-n", + self.api.control, + observed["metadata"]["name"], + "-c", + "app", + "--limit-bytes=65536", + ] + ) + rows = [line[len(_RESULT) :] for line in response.stdout.splitlines() if line.startswith(_RESULT)] + if len(rows) != 1: + raise ProbeError("control_probe_result_missing") + try: + check_result("network-ingress", json.loads(rows[0]), observed["metadata"]["uid"]) + except (ValueError, TypeError, KeyError): + raise ProbeError("control_probe_failed") from None + return observed + + def _ingress(self): + host = self.idle.get("status", {}).get("podIP") + if not host: + raise ProbeError("ingress_pod_ip_missing") + + def targets(allowed, port=8080): + return [ + { + "name": "http", + "host": host, + "port": port, + "expected": "allowed" if allowed else "denied", + "control_confirmed": True, + } + ] + + self._control_probe("app-proxy", targets(True)) + self._control_probe("runtime-manager", targets(True, 8092)) + self._control_probe("preflight-untrusted", targets(False)) + self._control_probe("app-proxy", targets(False, 8092)) + self._control_probe("runtime-manager", targets(True)) + return self._proof(self.idle) + + def _platform(self, *, tls_only=False): + import httpx + + base = self.options["platform_origin"] + parsed = urlsplit(base) + if parsed.scheme != "https" or parsed.username or parsed.password or parsed.path not in {"", "/"}: + raise ProbeError("platform_origin_invalid") + path = "/api/v1/internal/app-runtime/v2/nodes/plan" + key_id, secret = ( + os.environ["RTM_VERIFY_PLATFORM_KEY_ID"], + os.environ["RTM_VERIFY_PLATFORM_SECRET"], + ) + with httpx.Client( + verify=ssl.create_default_context(cafile=self.options["platform_ca_file"]), + trust_env=False, + timeout=10, + ) as client: + response = client.get( + base.rstrip("/") + path, + headers=signed_headers("GET", path, b"", key_id=key_id, secret=secret), + ) + if response.status_code != 200 or not isinstance(response.json().get("node"), dict): + raise ProbeError("platform_authenticated_probe_refused") + if client.get(base.rstrip("/") + path).status_code not in {401, 403}: + raise ProbeError("platform_unsigned_probe_accepted") + node = response.json()["node"] + if node.get("node_id", node.get("id")) != self.options["node_id"]: + raise ProbeError("platform_node_scope_mismatch") + return self._proof( + self.api.get("namespace", self.namespace), + code="control_side_tls" if tls_only else "control_side_authenticated_api", + ) + + def live(self): + drivers = { + "namespace-budget": self._namespace_budget, + "restricted-psa": lambda: self._admission_negative(psa=True), + "admission-create": self._admission_negative, + "status-provenance": self._status, + "binding-delete-fence": self._binding_fence, + "rbac-namespace": self._rbac, + "no-exec": self._no_exec, + "storage-sqlite": self._storage, + "image-cri": self._image, + "network-ingress": self._ingress, + "material-tls": lambda: self._platform(tls_only=True), + "platform-capabilities": self._platform, + } + for group in ( + "network-direct-denied", + "network-egress-allowed", + "network-dns-denied", + ): + drivers[group] = lambda group=group: self._network(group) + if set(drivers) != REQUIRED_CHECKS: + raise ProbeError("probe_driver_set_incomplete") + if self.options.get("verify_registry") is True: + drivers["registry-push"] = self._registry + requested = self.document.get("sandbox", {}).get("requested", "default") + if requested in {"recommended", "hardened"}: + drivers["sandbox-" + requested] = lambda: self._sandbox(requested) + report, probe_error = None, None + try: + self.idle = self._observe(self._pod("idle"), running=True) + report = execute_checks(drivers, self.document, observed_at=time.time()) + return report + except BaseException as error: + probe_error = error + raise + finally: + try: + self.cleanup() + except Exception as cleanup_error: + raise ProbeRunFailure(probe_error, cleanup_error, report) from None + + def _cleanup_one(self, record): + current = self.api.get(record["resource"], record["name"], manager=True) + if current["metadata"]["uid"] != record["uid"]: + raise ProbeError("preflight_cleanup_identity_changed") + if not current["metadata"].get("deletionTimestamp"): + self._delete_request(current, record["resource"]) + if record["resource"] == "pods": + self._wait(lambda: self._stop_observation(record, namespace=self.namespace, manager=True)) + record["plan"]["stop_state"] = "confirmed" + self._plans(record["plan"]["resource_ref"], record["plan"]) + + def release(): + fresh = self.api.get("pod", record["name"], manager=True) + if fresh["metadata"]["uid"] != record["uid"]: + raise ProbeError("preflight_cleanup_identity_changed") + if not stop_confirmed(fresh, binding_evidence=record.get("binding_stop_evidence")): + raise ProbeError("preflight_stop_unconfirmed") + patch = { + "metadata": { + "resourceVersion": fresh["metadata"]["resourceVersion"], + "finalizers": [x for x in fresh["metadata"].get("finalizers", []) if x != STOP_FINALIZER], + } + } + result = self.api.call( + [ + "patch", + "pod", + record["name"], + "-n", + self.namespace, + "--type=merge", + "-p", + json.dumps(patch), + ], + manager=True, + accepted=(0, 1), + ) + if result.returncode and not any( + reason in result.stderr + for reason in ( + "admission_stop_unconfirmed", + "Conflict", + "object has been modified", + ) + ): + raise ProbeError("preflight_finalizer_release_refused") + return not result.returncode + + self._wait(release) + + def absent(): + response = self.api.raw( + "GET", + self._path(record["resource"], record["name"]), + manager=True, + accepted=(0, 1), + ) + if response.returncode: + if "NotFound" in response.stderr: + return True + raise ProbeError("preflight_absence_unconfirmed") + observed = json.loads(response.stdout) + if observed["metadata"]["uid"] != record["uid"]: + raise ProbeError("preflight_cleanup_identity_changed") + return False + + self._wait(absent) + self._plans(record["plan"]["resource_ref"], remove=True) + self.created.remove(record) + + def _cleanup_control(self, pod): + name, uid = pod["metadata"]["name"], pod["metadata"]["uid"] + current = self.api.get("pod", name, namespace=self.api.control) + if current["metadata"]["uid"] != uid: + raise ProbeError("control_cleanup_identity_changed") + body = { + "apiVersion": "v1", + "kind": "DeleteOptions", + "gracePeriodSeconds": 5, + "preconditions": { + "uid": uid, + "resourceVersion": current["metadata"]["resourceVersion"], + }, + } + path = "/api/v1/namespaces/" + self.api.control + "/pods/" + name + self.api.raw("DELETE", path, document=body) + + record = {"name": name, "uid": uid} + fresh = self._wait(lambda: self._stop_observation(record, namespace=self.api.control, manager=False)) + patch = { + "metadata": { + "resourceVersion": fresh["metadata"]["resourceVersion"], + "finalizers": [x for x in fresh["metadata"].get("finalizers", []) if x != STOP_FINALIZER], + } + } + self.api.call( + [ + "patch", + "pod", + name, + "-n", + self.api.control, + "--type=merge", + "-p", + json.dumps(patch), + ] + ) + self.control_created.remove(pod) + + def cleanup(self): + for pod in list(self.control_created): + self._cleanup_control(pod) + for row in list(reversed(self.created)): + if row["resource"] == "pods": + self._cleanup_one(row) + for row in list(reversed(self.created)): + if any( + v.get("persistentVolumeClaim", {}).get("claimName") == row["name"] + for pod in self.api.list("pods", manager=True) + for v in pod.get("spec", {}).get("volumes", []) + ): + raise ProbeError("preflight_pvc_writer_unconfirmed") + self._cleanup_one(row) + + def recover_expired(self): + cm = self.api.get("configmap", "runtime-canary-plans", namespace=self.api.control) + document = json.loads(cm["data"]["plans.json"]) + now = time.time() + for ref, plan in document.get("plans", {}).items(): + if plan.get("expires_at", now + 1) > now: + continue + if ( + str(UUID(ref)) != ref + or plan.get("resource_ref") != ref + or plan.get("namespace") != self.namespace + or not plan.get("name", "").startswith("preflight-") + ): + raise ProbeError("preflight_recovery_scope_invalid") + resource = {"Pod": "pods", "PersistentVolumeClaim": "persistentvolumeclaims"}.get(plan.get("kind")) + if not resource or not plan.get("uid"): + raise ProbeError("preflight_recovery_uid_unconfirmed") + fresh = self.api.get(resource, plan["name"], manager=True) + if ( + fresh["metadata"]["uid"] != plan["uid"] + or fresh["metadata"].get("labels", {}).get(RESOURCE_LABEL) != ref + ): + raise ProbeError("preflight_recovery_identity_changed") + plan["expires_at"] = int(now) + 3500 + self._plans(ref, plan) + self.created.append({"plan": plan, "resource": resource, "name": plan["name"], "uid": plan["uid"]}) + for pod in self.api.list("pods", namespace=self.api.control): + meta = pod["metadata"] + expiry = meta.get("annotations", {}).get("runtime.bisheng.io/preflight-expiry", "") + if ( + meta.get("name", "").startswith("preflight-") + and meta.get("labels", {}).get("runtime.bisheng.io/preflight-installation") + == self.options["installation_id"] + and expiry.isdecimal() + and int(expiry) <= now + ): + self.control_created.append(pod) + self.cleanup() + + def _registry_targets(self): + from runtime_manager.executors.protocol import ExecutorError + from runtime_manager.oci_assembler import registry_origins + + try: + profiles = self.document.get("canary_profiles", []) + if not isinstance(profiles, list) or len(profiles) > 32: + raise ValueError() + bases = [self.document["helper_image"], *[profile["runtime_image"] for profile in profiles]] + origins = set() + for base in bases: + origins.update( + registry_origins( + base, + self.document["image_repository"], + endpoints=self.document.get("image_registry_endpoints", []), + insecure=self.document.get("insecure_registry") is True, + ) + ) + except (ExecutorError, KeyError, TypeError, ValueError): + raise ProbeError("registry_probe_origin_invalid") from None + if len(origins) > 32: + raise ProbeError("registry_probe_targets_exceeded") + targets = set() + for origin in sorted(origins): + parsed = urlsplit(origin) + host, port = parsed.hostname, parsed.port + try: + address = ipaddress.ip_address(host) + except ValueError: + try: + resolved = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM) + if not resolved: + raise ValueError() + if len(resolved) > 256: + raise ProbeError("registry_probe_targets_exceeded") + addresses = [] + for family, kind, protocol, _canonical, endpoint in resolved: + address = ipaddress.ip_address(endpoint[0]) + if ( + family not in {socket.AF_INET, socket.AF_INET6} + or kind != socket.SOCK_STREAM + or protocol not in {0, socket.IPPROTO_TCP} + or address.version != (4 if family == socket.AF_INET else 6) + or endpoint[1] != port + ): + raise ValueError() + addresses.append(str(address)) + except (OSError, ValueError, TypeError, IndexError): + raise ProbeError("registry_probe_resolution_failed") from None + else: + addresses = [str(address)] + targets.update((address, port) for address in addresses) + if len(targets) > 32: + raise ProbeError("registry_probe_targets_exceeded") + return [ + {"name": f"registry-{index}", "host": host, "port": port, "expected": "allowed", "protocol": "tcp"} + for index, (host, port) in enumerate(sorted(targets)) + ] + + def _registry(self): + repository = self.document.get("image_repository", "") + if not repository or not self.document.get("assembler_image"): + raise ProbeError("assembly_registry_not_configured") + canary = self._control_probe("egress-proxy", self._registry_targets()) + target = repository.rstrip("/") + "/preflight-" + uuid4().hex + ":probe" + command = [os.environ.get("CRANE_BIN", "crane")] + if self.document.get("insecure_registry") is True: + command.append("--insecure") + command += ["--platform", self.document["platform"]] + + def run(args): + result = subprocess.run(command + args, capture_output=True, text=True, timeout=180, check=False) + if result.returncode: + raise ProbeError("registry_probe_failed") + return result.stdout.strip() + + expected = run(["digest", self.document["helper_image"]]) + if not re.fullmatch(r"sha256:[a-f0-9]{64}", expected): + raise ProbeError("registry_probe_digest_invalid") + run(["copy", self.document["helper_image"], target]) + if run(["digest", target]) != expected: + raise ProbeError("registry_probe_digest_mismatch") + # Only a fresh, isolated preflight repository can be removed. + run(["delete", target.split(":probe")[0] + "@" + expected]) + return self._proof(canary, digest=expected) + + def publish(self, report): + cm = self.api.get("configmap", "runtime-deployment-descriptor", namespace=self.api.control) + document = json.loads(cm["data"]["descriptor.json"]) + if descriptor_digest(document) != report["descriptor_digest"]: + raise ProbeError("descriptor_changed_during_probe") + document["probe_report"] = report + cm["data"]["descriptor.json"] = json.dumps(document, sort_keys=True, separators=(",", ":")) + self.api.call(["replace", "-f", "-"], document=cm) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--context", required=True) + parser.add_argument("--control-namespace", required=True) + parser.add_argument("--execution-namespace", required=True) + parser.add_argument("--manager-account", default="runtime-manager") + parser.add_argument("--probe-plan", required=True, type=Path) + parser.add_argument("--live", action="store_true") + parser.add_argument("--write-report", action="store_true") + parser.add_argument("--cleanup-expired", action="store_true") + parser.add_argument("--repeat-seconds", type=int, default=0) + args = parser.parse_args(argv) + if args.cleanup_expired and (args.live or args.write_report or args.repeat_seconds): + parser.error("--cleanup-expired is a separate recovery operation") + if args.write_report and not args.live: + parser.error("--write-report requires --live") + if args.repeat_seconds and (not args.live or not args.write_report or args.repeat_seconds != 21600): + parser.error("periodic refresh requires --live --write-report --repeat-seconds 21600") + options = json.loads(args.probe_plan.read_text()) + api = Kubectl( + context=args.context, + control_namespace=args.control_namespace, + execution_namespace=args.execution_namespace, + manager_account=args.manager_account, + ) + while True: + verifier = InstallationVerifier(api, options=options) + if args.cleanup_expired: + verifier.recover_expired() + print(json.dumps({"expired_canaries_cleaned": True, "ready": False})) + return 0 + if not args.live: + print(json.dumps(verifier.baseline(), sort_keys=True)) + return 0 + try: + report = verifier.live() + except Exception as error: + failures = failure_details(error) + summary = {key: failures[0][key] for key in ("reason", "exception_type")} + report = { + "source": "cluster-probe", + "failures": failures, + "observed_at": time.time(), + "descriptor_digest": descriptor_digest(verifier.document), + "checks": [{"name": name, "ok": False, **summary, "evidence": {}} for name in sorted(REQUIRED_CHECKS)], + } + if args.write_report: + try: + verifier.publish(report) + except Exception as publish_error: + report["failures"].append({"phase": "publish", **safe_failure(publish_error)}) + log_failures(report["failures"]) + print(json.dumps(report, sort_keys=True), flush=True) + return 1 + if args.write_report: + verifier.publish(report) + print(json.dumps(report, sort_keys=True), flush=True) + if not all(row["ok"] for row in report["checks"] if row["name"] in REQUIRED_CHECKS): + return 1 + if not args.repeat_seconds: + return 0 + time.sleep(args.repeat_seconds) + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as error: + log_failures(failure_details(error)) + raise SystemExit(1) from None diff --git a/src/app-proxy/Dockerfile b/src/app-proxy/Dockerfile new file mode 100644 index 0000000000..fd4dfa059b --- /dev/null +++ b/src/app-proxy/Dockerfile @@ -0,0 +1,23 @@ +# Public HTTP/WebSocket entry proxy; no Engine, host socket or application data access. +# Runs as a non-root user with independently scoped backend identities. +FROM python:3.11-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + APP_PROXY_HOST=0.0.0.0 \ + APP_PROXY_PORT=8090 + +WORKDIR /app + +COPY pyproject.toml uv.lock /app/ +RUN pip install --no-cache-dir uv==0.8.22 && uv export --frozen --no-dev --no-emit-project --format requirements-txt > /tmp/req.txt \ + && pip install --no-cache-dir -r /tmp/req.txt && rm -f /tmp/req.txt + +COPY app_proxy /app/app_proxy + +RUN useradd --system --uid 10001 --no-create-home appproxy +USER appproxy + +EXPOSE 8090 +CMD ["python", "-m", "uvicorn", "app_proxy.main:app", "--host", "0.0.0.0", "--port", "8090", "--no-access-log"] diff --git a/src/app-proxy/app_proxy/__init__.py b/src/app-proxy/app_proxy/__init__.py new file mode 100644 index 0000000000..7ecaa11e37 --- /dev/null +++ b/src/app-proxy/app_proxy/__init__.py @@ -0,0 +1,17 @@ +"""BiSheng app-proxy — the single entry point in front of every hosted app. + +Standalone package on purpose (design D5-C / D6): it **never imports the +``bisheng`` package**. Everything it needs arrives over two HMAC-signed RPCs — +"who is this and may they enter" from the backend, "where does this app live" +from runtime-manager — so the security logic has exactly one implementation +(the backend's F048 chain) and this process stays a proxy, not a second copy of +the platform. + +Why self-written rather than oauth2-proxy: CVE-2025-64484 (CVSS 8.5) is exactly +an incomplete header strip, and what we host are uncontrolled Python +application frameworks that trust ``X-BiSheng-*`` unconditionally. See +:mod:`app_proxy.headers` for the normalised-equivalence-class strip that answers +it. +""" + +__version__ = "3.0.0" diff --git a/src/app-proxy/app_proxy/authz.py b/src/app-proxy/app_proxy/authz.py new file mode 100644 index 0000000000..cdfb9c5a6e --- /dev/null +++ b/src/app-proxy/app_proxy/authz.py @@ -0,0 +1,219 @@ +"""Ask the backend "who is this, and may they enter" — and never guess. + +app-proxy holds no permission logic (D6-C). The five-step decision (layer +deployed → session → app exists and has been online → visible to this user → +app state) lives once, in the backend, on top of F048. Here we only: + +1. pull the session token out of the request the same way the platform + middleware does (cookie first, then ``Authorization: Bearer``); +2. call the internal endpoint; only refusals are cached per session and slug; +3. turn *anything* that is not a clean verdict into a refusal. + +Point 3 is AC-12 and is the reason :data:`KNOWN_DECISIONS` exists: a backend +deployed ahead of this process could answer with a verdict string this build +has never seen, and the one thing that must not happen is for it to fall +through to "forward the request". +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass, field +from typing import Any + +from starlette.requests import HTTPConnection + +from app_proxy.clients import InternalRpcError, get_backend_client +from app_proxy.config import ACCESS_TOKEN_COOKIE + +logger = logging.getLogger(__name__) + +DECISION_ALLOW = "allow" +DECISION_LOGIN = "login" +DECISION_FORBIDDEN = "forbidden" +DECISION_STOPPED = "stopped" +DECISION_NOT_FOUND = "not_found" +DECISION_NOT_ENABLED = "not_enabled" +#: Local-only verdict: we could not get a trustworthy answer. Never sent by the +#: backend as a happy path, but accepted from it too (16146 fail-closed). +DECISION_UNAVAILABLE = "unavailable" + +KNOWN_DECISIONS = frozenset( + { + DECISION_ALLOW, + DECISION_LOGIN, + DECISION_FORBIDDEN, + DECISION_STOPPED, + DECISION_NOT_FOUND, + DECISION_NOT_ENABLED, + DECISION_UNAVAILABLE, + } +) + +#: A slug is a URL identity (AC-08), so it is validated here rather than being +#: handed to two peers as-is. Rejecting locally also keeps ``/apps/../..`` and +#: scanner traffic from turning the internal endpoint into a load amplifier. +SLUG_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$") + + +@dataclass(frozen=True) +class Verdict: + """The answer, plus the material needed to act on it.""" + + decision: str + material: dict[str, str] = field(default_factory=dict) + obo_token: str | None = None + app_id: str | None = None + app_name: str | None = None + owner_name: str | None = None + app_state: str | None = None + cache_hit: bool = False + reason: str = "" + #: The visitor, when the backend could name them. On an ``allow`` the id is + #: already in :attr:`material` (it is injected into the app); on a refusal + #: there is no material at all, and without this field the ``user_id`` of + #: ``app_proxy.request`` would be empty on exactly the lines that answer + #: "who could not get in" (§7). Absent for ``login`` — there is no visitor + #: to name — and for anything a fail-closed path decided locally. + user_id: str | None = None + #: WebSocket lifetime inputs (D6 invariant ①). Both optional: an older + #: backend sends neither, and :mod:`app_proxy.websocket` then reads the OBO + #: claim itself and falls back to the process config for the cap. + obo_expires_at: float | None = None + ws_max_lifetime_seconds: float | None = None + #: Set only on an approval-time preview (F055 AC-26): the upstream is then + #: resolved by session rather than by ``app_id``, because a preview has no + #: desired-state record in the manager. ``app_id`` is still the application + #: under review — it is what gets injected as ``X-BiSheng-App-Id``. + preview_session: str | None = None + + @property + def allowed(self) -> bool: + return self.decision == DECISION_ALLOW + + @property + def visitor_id(self) -> str | None: + """Who this verdict is about, allow or not — the ``user_id`` of §7.""" + return self.material.get("X-BiSheng-User-Id") or self.user_id + + +def is_valid_slug(slug: str) -> bool: + return bool(SLUG_PATTERN.match(slug or "")) + + +def extract_access_token(connection: HTTPConnection) -> str | None: + """Mirror of ``_extract_http_access_token`` (``bisheng/utils/http_middleware.py``). + + Cookie first — that is what a browser navigating to ``/apps/{slug}`` sends, + and the whole no-second-login promise (AC-26) rides on it being host-only + and ``path=/`` (K7). Bearer second, because the platform SPA keeps its token + in localStorage and an app embedded in it may call through with a header. + """ + token = connection.cookies.get(ACCESS_TOKEN_COOKIE) + if token: + return token + header = (connection.headers.get("Authorization") or "").strip() + if header.lower().startswith("bearer "): + return header[7:].strip() or None + return None + + +def _coerce_material(payload: dict[str, Any]) -> dict[str, str]: + raw = payload.get("headers") or {} + if not isinstance(raw, dict): + return {} + return {str(k): str(v) for k, v in raw.items() if v is not None} + + +async def authorize_preview( + *, + session: str, + access_token: str | None, + request_id: str, + client_ip: str | None = None, +) -> Verdict: + """The ``/apps/preview/{session}`` twin of :func:`authorize` (F055 AC-26). + + A separate call and a separate cache key rather than a flag: the two + verdicts answer different questions about different things, and the day one + of them changes shape the other must not quietly follow. Everything after + the RPC — unknown decisions refused, material coerced — is the shared + :func:`_verdict_from`, because *that* part going out of step is how a + verdict this build does not understand becomes a forward. + """ + try: + payload = await get_backend_client().authorize_preview( + session=session, + access_token=access_token, + request_id=request_id, + client_ip=client_ip, + ) + except InternalRpcError as exc: + logger.warning("app_proxy.authz preview=%s request_id=%s fail_closed=%s", session, request_id, exc) + return Verdict(decision=DECISION_UNAVAILABLE, reason=str(exc)) + return _verdict_from(payload, label=f"preview {session}", request_id=request_id) + + +async def authorize( + *, + slug: str, + access_token: str | None, + request_id: str, + client_ip: str | None = None, +) -> Verdict: + """One RPC (or one cache hit) → one :class:`Verdict`, refusal-by-default.""" + try: + payload = await get_backend_client().authorize( + slug=slug, + access_token=access_token, + request_id=request_id, + client_ip=client_ip, + ) + except InternalRpcError as exc: + # Deliberately not cached: caching an outage would stretch a one-second + # blip into three seconds of refusals for every visitor. + logger.warning("app_proxy.authz slug=%s request_id=%s fail_closed=%s", slug, request_id, exc) + return Verdict(decision=DECISION_UNAVAILABLE, reason=str(exc)) + return _verdict_from(payload, label=f"slug {slug}", request_id=request_id) + + +def _verdict_from(payload: dict[str, Any], *, label: str, request_id: str) -> Verdict: + """Payload → :class:`Verdict`, refusal-by-default. Point 3 of the module docstring.""" + decision = str(payload.get("decision") or "") + if decision not in KNOWN_DECISIONS: + logger.error( + "app_proxy.authz %s request_id=%s unknown decision %r — refusing", + label, + request_id, + decision, + ) + return Verdict(decision=DECISION_UNAVAILABLE, reason=f"unknown decision {decision!r}") + + material = _coerce_material(payload) + app_id = payload.get("app_id") or material.get("X-BiSheng-App-Id") + user_id = payload.get("user_id") + return Verdict( + decision=decision, + material=material, + user_id=str(user_id) if user_id else None, + obo_token=payload.get("obo_token"), + app_id=str(app_id) if app_id else None, + app_name=payload.get("app_name"), + owner_name=payload.get("owner_name"), + app_state=payload.get("app_state"), + cache_hit=bool(payload.get("cache_hit")), + obo_expires_at=_optional_number(payload.get("obo_expires_at")), + ws_max_lifetime_seconds=_optional_number(payload.get("ws_max_lifetime_seconds")), + preview_session=str(payload["preview_session"]) if payload.get("preview_session") else None, + ) + + +def _optional_number(value: Any) -> float | None: + """A number the backend may or may not send; anything else is "not sent".""" + if isinstance(value, bool) or value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + return None diff --git a/src/app-proxy/app_proxy/clients.py b/src/app-proxy/app_proxy/clients.py new file mode 100644 index 0000000000..61da03ab17 --- /dev/null +++ b/src/app-proxy/app_proxy/clients.py @@ -0,0 +1,425 @@ +"""The two HMAC-signed RPCs app-proxy is allowed to make, plus their caches. + +Deliberately **two** clients with **two** independent caches (D6 / D5.1): + +* ``BackendAuthzClient`` — "who is this and may they enter", keyed by + ``(sha256(session token), slug)``. Follows permission changes. +* ``ManagerRouteClient`` — "where does this app live right now", keyed by + ``app_id``. Follows releases. + +Merging them (or their caches) would make "change visibility" and "switch +version" step on each other: a permission revoke would be masked by a route +cache entry and vice versa. The 30s retirement grace in runtime-manager is +sized against the 3s route TTL, so shortening one without the other reopens the +502 window during a version switch. + +Signing string is byte-for-byte runtime-manager's / F014's:: + + METHOD + "\\n" + PATH + "\\n" + raw_body + +PATH excludes the query string, so a read with ``httpx.params`` does not have +to re-derive the signature. + +**Empty secret fails closed.** A rollout that forgot one of the two secrets +would otherwise send unsigned requests, and against a peer misconfigured the +same way it would authorise anonymous traffic into every hosted app. +""" + +from __future__ import annotations + +import hashlib +import hmac +import logging +import time +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Generic, TypeVar + +import httpx + +from app_proxy.config import Config, get_config + +logger = logging.getLogger(__name__) + +T = TypeVar("T") + + +class InternalRpcError(RuntimeError): + """Any failure to get a trustworthy answer out of a peer. + + Every call site treats it as **deny / fail-closed**, never as "carry on" + (AC-12). It carries ``peer`` so the log line says which hop broke. + """ + + def __init__(self, peer: str, message: str) -> None: + super().__init__(f"{peer}: {message}") + self.peer = peer + + +def compute_signature(method: str, path: str, raw_body: bytes, secret: str) -> str: + """Canonical HMAC-SHA256 hex digest, shared with runtime-manager.""" + msg = f"{method.upper()}\n{path}\n".encode() + (raw_body or b"") + return hmac.new(secret.encode("utf-8"), msg, hashlib.sha256).hexdigest() + + +def token_fingerprint(token: str | None) -> str: + """Cache key material for a session token. + + Hashed, never the raw JWT: the cache dict ends up in heap dumps and in the + occasional debug log line, and the raw value is a live session. + """ + return hashlib.sha256((token or "").encode("utf-8")).hexdigest() + + +@dataclass +class _Entry(Generic[T]): + value: T + expires_at: float + + +class TTLCache(Generic[T]): + """Tiny monotonic-clock TTL map. + + The clock is injectable because "does the entry really expire after 3s" + is a behaviour we assert, and asserting it with real sleeps would put three + seconds of dead time into the suite for every such case. + """ + + def __init__(self, ttl_seconds: float, clock: Callable[[], float] = time.monotonic) -> None: + self.ttl_seconds = ttl_seconds + self._clock = clock + self._data: dict[Any, _Entry[T]] = {} + + def get(self, key: Any) -> T | None: + entry = self._data.get(key) + if entry is None: + return None + if entry.expires_at <= self._clock(): + self._data.pop(key, None) + return None + return entry.value + + def set(self, key: Any, value: T) -> None: + self._data[key] = _Entry(value=value, expires_at=self._clock() + self.ttl_seconds) + + def invalidate(self, key: Any) -> None: + self._data.pop(key, None) + + def clear(self) -> None: + self._data.clear() + + def __len__(self) -> int: # pragma: no cover - debugging convenience + return len(self._data) + + +class HmacClient: + """Shared transport: sign, send, translate failures into one exception.""" + + peer = "peer" + + def __init__( + self, + base_url: str, + secret: str, + *, + signature_header: str = "X-Signature", + timeout: float = 3.0, + transport: httpx.AsyncBaseTransport | None = None, + ) -> None: + self.base_url = base_url.rstrip("/") + self.secret = secret + self.signature_header = signature_header + self.timeout = timeout + self._transport = transport + self._client: httpx.AsyncClient | None = None + + def _http(self) -> httpx.AsyncClient: + if self._client is None: + self._client = httpx.AsyncClient( + base_url=self.base_url, + timeout=self.timeout, + transport=self._transport, + ) + return self._client + + async def aclose(self) -> None: + if self._client is not None: + await self._client.aclose() + self._client = None + + async def _send(self, method: str, path: str, *, json: Any | None = None) -> httpx.Response: + if not self.secret: + raise InternalRpcError(self.peer, "HMAC secret is not configured (fail-closed)") + raw = b"" if json is None else httpx.Request("POST", "http://x", json=json).content + headers = {self.signature_header: compute_signature(method, path, raw, self.secret)} + if json is not None: + headers["Content-Type"] = "application/json" + try: + return await self._http().request(method, path, content=raw or None, headers=headers) + except httpx.HTTPError as exc: + raise InternalRpcError(self.peer, f"{type(exc).__name__}: {exc}") + + @staticmethod + def _unwrap(payload: Any) -> Any: + """Accept both the platform envelope and a bare body. + + The backend wraps handler returns in ``{status_code, status_message, + data}``; runtime-manager answers bare. Rather than pin the internal + endpoint to one of the two shapes (and break the day someone routes it + through the standard response helper), unwrap when the envelope is + recognisable. + """ + if isinstance(payload, dict) and "data" in payload and "status_code" in payload: + return payload["data"] + return payload + + +class BackendAuthzClient(HmacClient): + """``POST /api/v1/internal/app-proxy/authorize`` + the authz cache. + + The response deliberately carries **no upstream address** — that is the + manager's answer, on its own cache clock (D5.1). + """ + + peer = "backend" + path = "/api/v1/internal/app-proxy/authorize" + #: F055's approval-time preview entry. A second endpoint, not a flag: the + #: two verdicts answer different questions and must not be one typo apart. + preview_path = "/api/v1/internal/app-proxy/authorize-preview" + + def __init__(self, *args, ttl_seconds: float = 3.0, clock: Callable[[], float] = time.monotonic, **kwargs): + super().__init__(*args, **kwargs) + self.cache: TTLCache[dict] = TTLCache(ttl_seconds, clock=clock) + + async def authorize( + self, + *, + slug: str, + access_token: str | None, + request_id: str, + client_ip: str | None = None, + ) -> dict: + return await self._verdict( + self.path, + key=(token_fingerprint(access_token), slug), + body={ + "slug": slug, + "access_token": access_token, + "request_id": request_id, + "client_ip": client_ip, + }, + ) + + async def authorize_preview( + self, + *, + session: str, + access_token: str | None, + request_id: str, + client_ip: str | None = None, + ) -> dict: + """Refusals use the same TTL with a **disjoint** key space. + + ``("preview", session)`` rather than the bare session: an application + whose slug happened to equal a session id would otherwise share a cache + entry with it, and one of the two verdicts would be served for the + other. + """ + return await self._verdict( + self.preview_path, + key=(token_fingerprint(access_token), "preview", session), + body={ + "session": session, + "access_token": access_token, + "request_id": request_id, + "client_ip": client_ip, + }, + ) + + async def _verdict(self, path: str, *, key: tuple, body: dict) -> dict: + cached = self.cache.get(key) + if cached is not None and cached.get("decision") != "allow": + return {**cached, "cache_hit": True} + + response = await self._send("POST", path, json=body) + if response.status_code >= 500: + raise InternalRpcError(self.peer, f"HTTP {response.status_code}") + if response.status_code in (401, 403): + # Our own signature was rejected — a deployment error, not a user + # verdict. Must not be mistaken for "this visitor is forbidden". + raise InternalRpcError(self.peer, f"HTTP {response.status_code} (signature rejected?)") + try: + payload = self._unwrap(response.json()) + except ValueError as exc: + raise InternalRpcError(self.peer, f"non-JSON response: {exc}") + if not isinstance(payload, dict) or "decision" not in payload: + raise InternalRpcError(self.peer, "response has no decision field") + + # Positive decisions contain live identity material. Reauthorize each + # request/handshake so invalid OBO configuration cannot reuse an allow. + if payload.get("decision") != "allow": + self.cache.set(key, payload) + return {**payload, "cache_hit": False} + + +#: The manager's answer while a deploy is in flight and nothing serves yet +#: (contract §2, 409 ``deploying``). Surfaced as a route payload carrying a +#: ``phase`` and no ``upstream`` so the caller renders 「发布中」 instead of +#: 「应用恢复中」 (T083). +ROUTE_PHASE_STARTING = "starting" + + +class ManagerRouteClient(HmacClient): + """``GET /v1/apps/{app_id}/route`` + the route cache. + + ``None`` means "the manager knows this app and it has no live instance" + (404) — an expected answer during stop / crash windows, not an error. A + ``{"phase": "starting"}`` payload means a deploy is in flight (409) and is + cached like a route: the retrying page must not turn one deploy into a + request storm on the manager. + """ + + peer = "manager" + + def __init__( + self, + *args, + ttl_seconds: float = 3.0, + clock: Callable[[], float] = time.monotonic, + protocol_version=2, + key_id="", + **kwargs, + ): + if protocol_version != 2: + raise ValueError("entry_requires_protocol_v2") + self.protocol_version = protocol_version + self.key_id = key_id + super().__init__(*args, **kwargs) + self.cache: TTLCache[dict] = TTLCache(ttl_seconds, clock=clock) + + async def route(self, app_id: str, *, refresh: bool = False, preview: bool = False) -> dict | None: + """Resolve the upstream. ``refresh=True`` bypasses **and** drops the entry. + + That flag is the D5.1 invalidation path: a connection error against a + cached address means the address is stale (version switch, restart), so + the entry is dropped and re-fetched exactly once before we give up and + render "应用恢复中". + + ``preview=True`` asks the manager's preview route instead (F055 AC-26). + The id is then a preview session, so it gets its own cache key: a + preview and an application are different objects, and an id collision + between the two must not be able to cross-serve them. + """ + key = ("preview", app_id) if preview else app_id + if refresh: + self.cache.invalidate(key) + else: + cached = self.cache.get(key) + if cached is not None: + return {**cached, "cache_hit": True} + + if self.protocol_version == 2: + from app_proxy.platform_protocol import signed_headers + + prefix = "previews" if preview else "apps" + path = f"/api/v1/internal/app-runtime/v2/routes/{prefix}/{app_id}" + try: + response = await self._http().get( + path, headers=signed_headers("GET", path, b"", key_id=self.key_id, secret=self.secret) + ) + except (httpx.HTTPError, ValueError) as exc: + raise InternalRpcError("backend", "route projection unavailable") from exc + else: + raise InternalRpcError("backend", "runtime v1 retired") + if response.status_code == 404: + return None + if response.status_code == 409 and self._is_deploying(response): + payload = {"phase": ROUTE_PHASE_STARTING} + self.cache.set(key, payload) + return {**payload, "cache_hit": False} + if response.status_code >= 400: + raise InternalRpcError(self.peer, f"HTTP {response.status_code}") + try: + payload = self._unwrap(response.json()) + except ValueError as exc: + raise InternalRpcError(self.peer, f"non-JSON response: {exc}") + if self.protocol_version == 2: + if isinstance(payload, dict) and payload.get("decision") == "transition": + payload = {"phase": ROUTE_PHASE_STARTING} + self.cache.set(key, payload) + return {**payload, "cache_hit": False} + import ipaddress + + try: + target = payload["target"] + host = ipaddress.ip_address(target["host"]) + port = int(target["port"]) + if host.is_loopback or host.is_link_local or not host.is_private or not 1 <= port <= 65535: + raise ValueError() + address = f"[{host}]" if host.version == 6 else str(host) + payload = {**payload, "upstream": f"http://{address}:{port}"} + except (ValueError, KeyError, TypeError): + raise InternalRpcError("backend", "invalid route target") from None + if not isinstance(payload, dict) or not payload.get("upstream"): + raise InternalRpcError(self.peer, "response has no upstream field") + + self.cache.set(key, payload) + return {**payload, "cache_hit": False} + + @staticmethod + def _is_deploying(response: httpx.Response) -> bool: + """Only the manager's own ``deploying`` envelope; any other 409 is an error.""" + try: + detail = response.json().get("detail") + except (ValueError, AttributeError): + return False + return isinstance(detail, dict) and detail.get("code") == "deploying" + + +_backend_client: BackendAuthzClient | None = None +_manager_client: ManagerRouteClient | None = None + + +def build_clients(config: Config | None = None) -> tuple[BackendAuthzClient, ManagerRouteClient]: + config = config or get_config() + backend = BackendAuthzClient( + config.backend_base, + config.backend_secret, + signature_header=config.signature_header, + timeout=config.internal_timeout_seconds, + ttl_seconds=config.authz_ttl_seconds, + ) + manager = ManagerRouteClient( + config.backend_base, + config.route_secret, + protocol_version=config.protocol_version, + key_id=config.route_key_id, + signature_header=config.signature_header, + timeout=config.internal_timeout_seconds, + ttl_seconds=config.route_ttl_seconds, + ) + return backend, manager + + +def get_backend_client() -> BackendAuthzClient: + global _backend_client + if _backend_client is None: + _backend_client, _ = build_clients() + return _backend_client + + +def get_manager_client() -> ManagerRouteClient: + global _manager_client + if _manager_client is None: + _, _manager_client = build_clients() + return _manager_client + + +def set_clients( + backend: BackendAuthzClient | None = None, + manager: ManagerRouteClient | None = None, +) -> None: + """Test / bootstrap seam. Passing ``None`` for both re-reads the config.""" + global _backend_client, _manager_client + _backend_client = backend + _manager_client = manager diff --git a/src/app-proxy/app_proxy/config.py b/src/app-proxy/app_proxy/config.py new file mode 100644 index 0000000000..6570118f71 --- /dev/null +++ b/src/app-proxy/app_proxy/config.py @@ -0,0 +1,210 @@ +"""Process configuration — environment variables only, never the platform config.yaml. + +app-proxy does not import ``bisheng`` (D5-C), so it cannot read +``settings.app_runtime``. The deployment is responsible for keeping the two +sides in sync; the mapping below is the contract (design §4.2 ⑧ / K10): + +============================== ============================================== +env var backend-side counterpart +============================== ============================================== +``APP_PROXY_BACKEND_BASE`` the platform API base (``127.0.0.1:7860``) +``APP_PROXY_MANAGER_BASE`` ``settings.app_runtime.manager_base_url`` +``APP_PROXY_BACKEND_SECRET`` ``settings.app_runtime.proxy_hmac_secret`` +``APP_PROXY_MANAGER_SECRET`` ``settings.app_runtime.manager_hmac_secret`` +``APP_PROXY_ENTRY_BASE_URL`` ``settings.app_runtime.entry_base_url`` +``APP_PROXY_WS_MAX_LIFETIME_SECONDS`` ``settings.app_runtime.ws_max_lifetime_seconds`` + (fallback only — the verdict carries the + backend's value when it is recent enough) +============================== ============================================== + +Both secrets fail **closed**: an empty secret makes every RPC raise before it +leaves the process rather than silently sending unsigned requests that the +peers would reject anyway (and, on a peer misconfigured the same way, accept). + +The two TTLs are separate knobs even though both default to 3 seconds — the +authz cache follows permission changes, the route cache follows releases (D5.1). +Collapsing them would make "change visibility" and "switch version" interfere. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, replace + +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 8090 + +#: URL prefix this proxy owns. ``/apps/{slug}`` is a product-visible contract +#: (AC-25) — it appears in QR codes and bookmarks, so it is not configurable +#: per deployment; the variable exists only so tests can mount elsewhere. +DEFAULT_ENTRY_PREFIX = "/apps" + +#: Platform session cookie. Must match ``_extract_http_access_token`` +#: (``bisheng/utils/http_middleware.py:60-73``). The cookie is host-only and +#: ``path=/``, which is exactly why ``/apps/*`` on the same origin gets it for +#: free — the whole "no second login" promise (AC-26) rests on this (K7). +ACCESS_TOKEN_COOKIE = "access_token_cookie" + +#: Where the login handoff page sends the browser. ``/admin`` is platform's +#: SPA; unauthenticated it renders ``LoginPage``, and with SSO configured it +#: bounces to the IdP itself (D7). +DEFAULT_LOGIN_PATH = "/admin" + +#: Visitor-facing app square, not the administrator's builder. Keep the full +#: client route: bare `/workspace` can fall into the platform SPA and show 404. +#: Deployments with an outer prefix can override APP_PROXY_SQUARE_URL. +DEFAULT_SQUARE_URL = "/workspace/apps/explore" + +#: Variables a deployment must set explicitly. Every default below is a +#: single-host loopback convenience, so a deployment that leaves any of these +#: unset is talking to itself: ``backend_base`` / ``manager_base`` point at +#: ``127.0.0.1`` (in a container, that is the container), and both secrets +#: default to empty, which is **fail closed** — the process still starts, still +#: reports healthy, and renders the fallback page on every single request. +#: ``scripts/tests/compose/verify-app-runtime-compose.sh`` asserts the compose file sets them. +REQUIRED_ENV: tuple[str, ...] = ( + "APP_PROXY_HOST", + "APP_PROXY_PORT", + "APP_PROXY_BACKEND_BASE", + "APP_PROXY_MANAGER_BASE", + "APP_PROXY_BACKEND_SECRET", + "APP_PROXY_MANAGER_SECRET", +) + +#: app-proxy keeps no state on disk, so being containerised adds nothing. +CONTAINERISED_REQUIRED_ENV: tuple[str, ...] = () + + +def _env_str(name: str, default: str = "") -> str: + value = os.environ.get(name) + return default if value is None else value.strip() + + +def _env_float(name: str, default: float) -> float: + raw = _env_str(name) + if not raw: + return default + try: + return float(raw) + except ValueError: + return default + + +def _env_bool(name: str, default: bool = False) -> bool: + raw = _env_str(name).lower() + if not raw: + return default + return raw in {"1", "true", "yes", "on"} + + +@dataclass(frozen=True) +class Config: + """Immutable process configuration. + + Frozen for the same reason as runtime-manager's: tests swap the whole + object through :func:`set_config` instead of mutating shared state. + """ + + # --- transport -------------------------------------------------------- + host: str = DEFAULT_HOST + port: int = DEFAULT_PORT + entry_prefix: str = DEFAULT_ENTRY_PREFIX + + # --- peers ------------------------------------------------------------ + backend_base: str = "http://127.0.0.1:7860" + manager_base: str = "http://127.0.0.1:8091" + backend_secret: str = "" + manager_secret: str = "" + protocol_version: int = 2 + route_key_id: str = "" + route_secret: str = "" + signature_header: str = "X-Signature" + + # --- caches (D6 / D5.1; two independent knobs on purpose) ------------- + authz_ttl_seconds: float = 3.0 + route_ttl_seconds: float = 3.0 + + # --- timeouts --------------------------------------------------------- + #: Internal RPCs are on loopback; a slow one must fail fast and closed. + internal_timeout_seconds: float = 3.0 + #: Upstream connect must fail fast (that is what triggers the route-cache + #: invalidation retry, D5.1); reads must not, because hosted apps stream. + upstream_connect_timeout_seconds: float = 3.0 + upstream_read_timeout_seconds: float = 600.0 + + # --- pages ------------------------------------------------------------ + login_path: str = DEFAULT_LOGIN_PATH + square_url: str = DEFAULT_SQUARE_URL + #: Absolute externally reachable base (``https://host:4101``). Only used to + #: fill ``X-Forwarded-Proto`` / ``X-Forwarded-Host`` when the request + #: carries nothing trustworthy; normally nginx-facing values win. + entry_base_url: str = "" + + # --- websocket (D6 invariants ① / ②) ---------------------------------- + #: Kill switch. Off, an allowed upgrade is refused with ``4501`` instead of + #: proxied — the pre-Wave-4 behaviour, kept for a deployment that must not + #: carry long-lived sockets (or that fronts the proxy with something that + #: cannot). + ws_proxy_enabled: bool = True + #: Hard cap on one connection's authorised lifetime (invariant ①). Mirrors + #: ``app_runtime.ws_max_lifetime_seconds``; the backend's value, when it is + #: carried in the verdict, wins over this one. + ws_max_lifetime_seconds: float = 28800.0 + #: Upper bound of the random jitter added to the lifetime, so that a burst of + #: connections opened together does not expire together. + ws_lifetime_jitter_seconds: float = 30.0 + #: How often an open connection re-asks the verdict (invariant ② safety net + #: for a node the backend's close push did not reach). Deny → close. + ws_reauthorize_interval_seconds: float = 30.0 + + def __post_init__(self): + if self.protocol_version != 2: + raise ValueError("entry_requires_protocol_v2") + + def with_overrides(self, **kwargs) -> Config: + return replace(self, **kwargs) + + +def load_config() -> Config: + """Build a :class:`Config` from the ``APP_PROXY_*`` environment variables.""" + return Config( + host=_env_str("APP_PROXY_HOST", DEFAULT_HOST) or DEFAULT_HOST, + port=int(_env_str("APP_PROXY_PORT", str(DEFAULT_PORT)) or DEFAULT_PORT), + entry_prefix=_env_str("APP_PROXY_ENTRY_PREFIX", DEFAULT_ENTRY_PREFIX) or DEFAULT_ENTRY_PREFIX, + backend_base=_env_str("APP_PROXY_BACKEND_BASE", "http://127.0.0.1:7860") or "http://127.0.0.1:7860", + manager_base=_env_str("APP_PROXY_MANAGER_BASE", "http://127.0.0.1:8091") or "http://127.0.0.1:8091", + backend_secret=_env_str("APP_PROXY_BACKEND_SECRET"), + manager_secret=_env_str("APP_PROXY_MANAGER_SECRET"), + protocol_version=int(_env_str("APP_PROXY_PROTOCOL_VERSION", "2")), + route_key_id=_env_str("APP_PROXY_ROUTE_KEY_ID"), + route_secret=_env_str("APP_PROXY_ROUTE_SECRET"), + signature_header=_env_str("APP_PROXY_SIGNATURE_HEADER", "X-Signature") or "X-Signature", + authz_ttl_seconds=_env_float("APP_PROXY_AUTHZ_TTL_SECONDS", 3.0), + route_ttl_seconds=_env_float("APP_PROXY_ROUTE_TTL_SECONDS", 3.0), + internal_timeout_seconds=_env_float("APP_PROXY_INTERNAL_TIMEOUT_SECONDS", 3.0), + upstream_connect_timeout_seconds=_env_float("APP_PROXY_UPSTREAM_CONNECT_TIMEOUT_SECONDS", 3.0), + upstream_read_timeout_seconds=_env_float("APP_PROXY_UPSTREAM_READ_TIMEOUT_SECONDS", 600.0), + login_path=_env_str("APP_PROXY_LOGIN_PATH", DEFAULT_LOGIN_PATH) or DEFAULT_LOGIN_PATH, + square_url=_env_str("APP_PROXY_SQUARE_URL", DEFAULT_SQUARE_URL) or DEFAULT_SQUARE_URL, + entry_base_url=_env_str("APP_PROXY_ENTRY_BASE_URL"), + ws_proxy_enabled=_env_bool("APP_PROXY_WS_PROXY_ENABLED", True), + ws_max_lifetime_seconds=_env_float("APP_PROXY_WS_MAX_LIFETIME_SECONDS", 28800.0), + ws_lifetime_jitter_seconds=_env_float("APP_PROXY_WS_LIFETIME_JITTER_SECONDS", 30.0), + ws_reauthorize_interval_seconds=_env_float("APP_PROXY_WS_REAUTHORIZE_INTERVAL_SECONDS", 30.0), + ) + + +_config: Config | None = None + + +def get_config() -> Config: + global _config + if _config is None: + _config = load_config() + return _config + + +def set_config(config: Config | None) -> None: + """Test / bootstrap seam. Passing ``None`` re-reads the environment.""" + global _config + _config = config diff --git a/src/app-proxy/app_proxy/connections.py b/src/app-proxy/app_proxy/connections.py new file mode 100644 index 0000000000..6f98fd4a7b --- /dev/null +++ b/src/app-proxy/app_proxy/connections.py @@ -0,0 +1,101 @@ +"""Open WebSocket connections, indexed by ``(app, user)`` — D6 invariant ②. + +This registry is the reason app-proxy is home-grown rather than an nginx +location: a reverse proxy in the wild has no notion of "close every socket this +user holds into this app", and that operation is exactly what "revoke access" +and "stop the application" mean for a connection that is already open. The +HTTP path re-asks the verdict on every request; a socket asked once, at the +handshake, and would otherwise stay open for hours after the answer changed. + +Two things it is **not**: + +* **Not shared across processes.** It is a dict in this worker. The backend's + stop / delete / revoke path is expected to POST ``/internal/connections/close`` + to every app-proxy it fronts (the backend half of T081 — not wired yet; the + proxy address list is a backend setting still to be added), and a proxy that + push did not reach — a second node, a second worker, a restart in between — + is covered by the per-connection re-authorisation loop in + :mod:`app_proxy.websocket`, which is the safety net, not the mechanism. Run + app-proxy with one worker per host, or accept that the push is best-effort + and the re-check interval is the real bound. +* **Not a permission store.** It knows *who* holds *what* socket; whether they + still may is always the backend's answer. +""" + +from __future__ import annotations + +import asyncio +import itertools +import logging +from dataclasses import dataclass, field + +logger = logging.getLogger(__name__) + + +@dataclass +class Connection: + """One proxied socket. ``closed`` is resolved by whoever decides to end it.""" + + conn_id: int + app_id: str + user_id: str | None + slug: str + request_id: str + closed: asyncio.Future = field(repr=False) + + +class ConnectionRegistry: + def __init__(self) -> None: + self._by_id: dict[int, Connection] = {} + self._ids = itertools.count(1) + + def register(self, *, app_id: str, user_id: str | None, slug: str, request_id: str) -> Connection: + loop = asyncio.get_running_loop() + connection = Connection( + conn_id=next(self._ids), + app_id=app_id, + user_id=str(user_id) if user_id is not None else None, + slug=slug, + request_id=request_id, + closed=loop.create_future(), + ) + self._by_id[connection.conn_id] = connection + return connection + + def unregister(self, connection: Connection) -> None: + self._by_id.pop(connection.conn_id, None) + + def close_for(self, app_id: str, *, user_ids: list[str] | None = None, code: int, reason: str = "") -> int: + """Ask every matching connection to end with ``code``; returns how many. + + ``user_ids=None`` means every connection into the app (stop / delete / + a department-wide revoke whose members we cannot enumerate here); a + list narrows it to those users. The socket is not touched from here — + its own handler observes ``closed`` and performs the close on both + sides, so the close frames go out on the connection's own task. + """ + wanted = None if user_ids is None else {str(user_id) for user_id in user_ids} + closed = 0 + for connection in list(self._by_id.values()): + if connection.app_id != app_id: + continue + if wanted is not None and connection.user_id not in wanted: + continue + if not connection.closed.done(): + connection.closed.set_result((code, reason)) + closed += 1 + return closed + + def count(self, app_id: str | None = None) -> int: + if app_id is None: + return len(self._by_id) + return sum(1 for connection in self._by_id.values() if connection.app_id == app_id) + + def clear(self) -> None: + """Test seam only: forget every entry without closing anything.""" + self._by_id.clear() + + +#: Process-wide instance; the WS handler registers into it and the internal +#: close endpoint drains it. +registry = ConnectionRegistry() diff --git a/src/app-proxy/app_proxy/headers.py b/src/app-proxy/app_proxy/headers.py new file mode 100644 index 0000000000..1e4bc32be2 --- /dev/null +++ b/src/app-proxy/app_proxy/headers.py @@ -0,0 +1,222 @@ +"""What we take off the request, and what we put on it (AC-31 / AC-32). + +The strip is a **normalised prefix match**, never a name list. Normalise +(``lower()`` + ``_`` → ``-``), then drop anything under ``x-bisheng-``. This is +the CVE-2025-64484 lesson: oauth2-proxy stripped the exact hyphenated spellings +it knew about, and ``X_Forwarded_User`` walked straight past it because +WSGI-family frameworks fold underscores and hyphens onto one key. A list also +rots — every header added later is a hole until someone remembers the list. + +The injection is the mirror image: the backend hands us **material**, and we +map it onto the canonical ten. Anything in that material outside our namespace +is dropped too, so a compromised or simply buggy internal endpoint cannot make +us set ``Authorization`` on a request into a hosted app. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from urllib.parse import quote + +from app_proxy.config import ACCESS_TOKEN_COOKIE, DEFAULT_ENTRY_PREFIX + +#: Normalised prefix owned by the platform. Everything under it is ours to +#: write and no one else's to send. +PLATFORM_HEADER_PREFIX = "x-bisheng-" + +#: The ten of design §4.2 ③, in the order they go on the wire. F053's +#: ``bisheng dev`` mini-proxy injects the same set (INV-32). +INJECTED_HEADER_NAMES: tuple[str, ...] = ( + "X-BiSheng-User-Id", + "X-BiSheng-User-Name", + "X-BiSheng-Tenant-Id", + "X-BiSheng-Dept-Id", + "X-BiSheng-Dept-Name", + "X-BiSheng-Dept-Path", + "X-BiSheng-Subject-Kind", + "X-BiSheng-App-Id", + "X-BiSheng-Access-Token", + "X-BiSheng-Request-Id", +) + +_CANONICAL_BY_NORMALISED = {name.lower(): name for name in INJECTED_HEADER_NAMES} + +#: Dropped outright before forwarding. +#: +#: * ``x-forwarded-*`` / ``forwarded`` — we rewrite these (D5.2). Passing the +#: client's value through lets a visitor make the app generate links and +#: redirects pointing at a host of their choosing. +#: * hop-by-hop — RFC 9110 §7.6.1. Forwarding ``Transfer-Encoding`` on a +#: request we re-frame ourselves is a request-smuggling primitive. +#: * ``host`` / ``content-length`` — httpx recomputes both for the upstream +#: request; forwarding the originals produces a mismatch. +DROPPED_HEADERS = frozenset( + { + "x-forwarded-prefix", + "x-forwarded-proto", + "x-forwarded-host", + "x-forwarded-port", + "forwarded", + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "proxy-connection", + "te", + "trailer", + "trailers", + "transfer-encoding", + "upgrade", + "host", + "content-length", + } +) + +_CONTROL_CHARS = frozenset(chr(i) for i in range(0x20)) | {chr(0x7F)} + + +def normalize_header_name(name: str) -> str: + """``X_BiSheng_User_Id`` and ``X-BiSheng-User-Id`` are the same header.""" + return name.strip().lower().replace("_", "-") + + +def is_platform_header(name: str) -> bool: + return normalize_header_name(name).startswith(PLATFORM_HEADER_PREFIX) + + +def encode_header_value(value: str) -> str: + """Make a value latin-1 safe without ever double-encoding it (坑 9). + + ASCII in, ASCII out — so material the backend already percent-encoded + survives unchanged (its ``%`` would otherwise become ``%25`` and the user + would read ``%E5%BC%A0`` as their own name inside the app). Control + characters are encoded even when ASCII: a raw CR/LF in a header value is a + response-splitting primitive. + """ + text = str(value) + if text.isascii() and not (_CONTROL_CHARS & set(text)): + return text + return quote(text, safe="/") + + +def _filter_cookie(value: str, session_cookie_name: str) -> str: + kept = [] + for chunk in value.split(";"): + pair = chunk.strip() + if not pair: + continue + name = pair.split("=", 1)[0].strip() + if name == session_cookie_name: + continue + kept.append(pair) + return "; ".join(kept) + + +def strip_platform_headers( + headers: Iterable[tuple[str, str]], + *, + session_cookie_name: str = ACCESS_TOKEN_COOKIE, + strip_session_cookie: bool = True, +) -> list[tuple[str, str]]: + """Everything the client sent, minus what it must not be able to say. + + ``strip_session_cookie`` removes **only** the platform session cookie from + the ``Cookie`` header, leaving the hosted app's own cookies alone. The + browser sends that cookie to ``/apps/*`` for free (host-only, ``path=/``, + K7) — which is what makes AC-26's "no second login" work, and equally what + would otherwise hand every hosted container a credential strictly more + powerful than the scoped 900s OBO token we mint for it on purpose (AC-34). + It is a flag rather than a constant so a deployment can put it back if a + real app turns out to need it. + """ + kept: list[tuple[str, str]] = [] + for name, value in headers: + normalised = normalize_header_name(name) + if normalised.startswith(PLATFORM_HEADER_PREFIX): + continue + if normalised in DROPPED_HEADERS: + continue + if normalised == "cookie" and strip_session_cookie: + filtered = _filter_cookie(value, session_cookie_name) + if not filtered: + continue + kept.append((name, filtered)) + continue + kept.append((name, value)) + return kept + + +def build_injected_headers( + material: Mapping[str, str] | None, + *, + slug: str, + request_id: str, + obo_token: str | None = None, + proto: str = "http", + host: str = "", + app_id: str | None = None, + entry_prefix: str = DEFAULT_ENTRY_PREFIX, +) -> list[tuple[str, str]]: + """The ten identity headers plus the three forwarding headers we own. + + Absent material is **omitted**, not emitted empty: an app doing + ``if request.headers.get("X-BiSheng-Dept-Id"):`` must be able to tell "no + department" from "empty department". + """ + values: dict[str, str] = {} + for raw_name, raw_value in (material or {}).items(): + canonical = _CANONICAL_BY_NORMALISED.get(normalize_header_name(raw_name)) + if canonical is None: + # Material outside our namespace is data the backend sent us, not a + # licence to set arbitrary headers on the upstream request. + continue + if raw_value is None or str(raw_value) == "": + continue + values[canonical] = str(raw_value) + + if app_id: + values["X-BiSheng-App-Id"] = app_id + if obo_token: + values["X-BiSheng-Access-Token"] = obo_token + # Ours, always: correlation across app-proxy → app → platform logs only + # works if exactly one hop mints the id (D14 / §7 log contract). + values["X-BiSheng-Request-Id"] = request_id + + injected = [(name, encode_header_value(values[name])) for name in INJECTED_HEADER_NAMES if name in values] + + injected.append(("X-Forwarded-Prefix", f"{entry_prefix.rstrip('/')}/{slug}")) + if proto: + injected.append(("X-Forwarded-Proto", proto)) + if host: + injected.append(("X-Forwarded-Host", host)) + return injected + + +def build_upstream_headers( + inbound: Iterable[tuple[str, str]], + material: Mapping[str, str] | None, + *, + slug: str, + request_id: str, + obo_token: str | None = None, + proto: str = "http", + host: str = "", + app_id: str | None = None, + entry_prefix: str = DEFAULT_ENTRY_PREFIX, + strip_session_cookie: bool = True, +) -> list[tuple[str, str]]: + """Strip then inject, in that order — the only order that is safe. + + Injecting first and stripping after would delete our own headers; the WS + upgrade path (Wave 4) calls this same function for the same reason. + """ + return strip_platform_headers(inbound, strip_session_cookie=strip_session_cookie) + build_injected_headers( + material, + slug=slug, + request_id=request_id, + obo_token=obo_token, + proto=proto, + host=host, + app_id=app_id, + entry_prefix=entry_prefix, + ) diff --git a/src/app-proxy/app_proxy/login_handoff.py b/src/app-proxy/app_proxy/login_handoff.py new file mode 100644 index 0000000000..dc6979ddaf --- /dev/null +++ b/src/app-proxy/app_proxy/login_handoff.py @@ -0,0 +1,131 @@ +"""Sending an unauthenticated visitor to the login page — and getting them back. + +Three separate audiences hit the same refusal and each needs a different shape +(D7, "非导航请求分流"): + +* a **navigation** gets HTML — for ``login`` specifically, the handoff page + below; +* a **programmatic request** (XHR / fetch) gets JSON and the true status code. + Handing an in-app ``fetch()`` a chunk of HTML with a 200 makes it fail while + parsing, three layers away from the actual cause; +* a **WebSocket upgrade** gets a close code, because that is the only channel + its client can read. + +The handoff page exists because the return address cannot survive a 302: a +fragment never reaches the server, and the platform login page reads the return +target from ``localStorage`` only (坑 11). So the browser — the one party that +can still see the whole address — writes it down before leaving. +""" + +from __future__ import annotations + +from starlette.requests import HTTPConnection + +from app_proxy.authz import ( + DECISION_FORBIDDEN, + DECISION_LOGIN, + DECISION_NOT_ENABLED, + DECISION_NOT_FOUND, + DECISION_STOPPED, + DECISION_UNAVAILABLE, +) +from app_proxy.pages import PAGE_DEPLOYING, PAGE_RECOVERING + +#: Cross-SPA contract with ``platform/src/utils/loginReturnTo.ts``. The consumer +#: is one-shot, same-origin checked and expires the value after 10 minutes; it +#: reads **these two keys and nothing else**. Rename in both places or neither. +LOGIN_PATHNAME_KEY = "LOGIN_PATHNAME" +LOGIN_PATHNAME_AT_KEY = "LOGIN_PATHNAME_AT" + +#: WebSocket close codes in the private 4000–4999 range. These are the contract +#: a hosted app's frontend handles (D6 invariant ③): every one of them means +#: "re-handshake", and the code says whether that will succeed. +#: +#: * ``4001`` — the connection's authorised lifetime ran out (invariant ①). +#: Reconnect at once; the new handshake gets a fresh lifetime. +#: * ``4401`` / ``4403`` / ``4404`` — the verdict: sign in / not allowed (also +#: "the app was stopped or your access was revoked" for a connection that was +#: open, invariant ②) / no such app. +#: * ``4503`` — nothing is answering right now (deploying, recovering, the +#: platform cannot decide). Reconnect with backoff. +#: * ``4501`` — allowed, but this deployment has ``ws_proxy_enabled`` off. +#: Deliberately distinct from the refusals so a walkthrough can tell "you may +#: not" from "sockets are switched off here" without reading logs. +WS_CLOSE_EXPIRED = 4001 +WS_CLOSE_NOT_IMPLEMENTED = 4501 +WS_CLOSE_CODES = { + DECISION_LOGIN: 4401, + DECISION_FORBIDDEN: 4403, + DECISION_STOPPED: 4403, + DECISION_NOT_FOUND: 4404, + DECISION_NOT_ENABLED: 4503, + DECISION_UNAVAILABLE: 4503, + PAGE_DEPLOYING: 4503, + PAGE_RECOVERING: 4503, +} + + +def is_navigation(connection: HTTPConnection) -> bool: + """Is this a top-level page load? + + ``Sec-Fetch-*`` is authoritative when present — it is set by the browser and + cannot be spoofed by page script — and it wins over ``Accept`` because + ``fetch(url, {headers: {Accept: 'text/html'}})`` is legal and common. + ``Accept`` is the fallback for browsers that do not send Fetch Metadata + (which includes the Chromium 108 builds on 信创 desktops). + """ + mode = connection.headers.get("Sec-Fetch-Mode", "").lower() + dest = connection.headers.get("Sec-Fetch-Dest", "").lower() + if mode or dest: + return mode == "navigate" or dest == "document" + return "text/html" in connection.headers.get("Accept", "").lower() + + +def ws_close_code(decision: str) -> int: + return WS_CLOSE_CODES.get(decision, 4503) + + +def render_login_handoff(login_path: str = "/admin") -> str: + """Write the return address down, then go to the login page. + + ``location.href`` is evaluated in the browser on purpose — it is the only + expression that still contains the fragment. Templating the request URL in + here would silently drop ``#b`` from a scanned link and the app would open + blank after login, on parameterised links only. + + The whole thing is wrapped in try/catch: with storage disabled (private + mode, some 信创 builds) losing the return address is a papercut, but an + exception before ``location.replace`` leaves a blank page. + """ + return f""" + + + + + +正在跳转到登录页 + + + +

正在跳转到登录页…

+ + + + +""" diff --git a/src/app-proxy/app_proxy/main.py b/src/app-proxy/app_proxy/main.py new file mode 100644 index 0000000000..878e78ec07 --- /dev/null +++ b/src/app-proxy/app_proxy/main.py @@ -0,0 +1,384 @@ +"""ASGI entry point. ``uvicorn app_proxy.main:app --host 127.0.0.1 --port 8090``. + +Route surface is deliberately tiny: + +* ``/healthz`` — liveness for systemd / compose. Answers without touching a + peer, so a backend outage does not get the proxy restarted. +* ``/apps/{slug}`` and ``/apps/{slug}/{tail}`` — everything else, HTTP and + WebSocket alike. +* ``/apps/preview/{session}`` — F055's approval-time preview instance + (:mod:`app_proxy.preview`). Registered *before* the slug routes and backed by + a name no application may hold, so it shadows nothing. +* ``POST /internal/connections/close`` — the backend's HMAC-signed push that + ends open sockets on a revoke / stop / delete (D6 invariant ②). Inbound + control plane, not a user surface; nginx does not route it. + +Nothing else is served. This process must never become a place where platform +functionality accretes (D5-C). + +The request handler below is the whole control flow, in order, in one function +on purpose: validate the slug locally → ask for a verdict → render a page or +forward. Splitting it across modules is what hid the ordering in the designs we +rejected, and the ordering *is* the security property (spec §3). +""" + +from __future__ import annotations + +import hmac +import json +import logging +import time +import uuid +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from starlette.requests import Request +from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse, Response +from starlette.websockets import WebSocket + +from app_proxy import clients +from app_proxy.authz import ( + DECISION_ALLOW, + DECISION_FORBIDDEN, + DECISION_LOGIN, + DECISION_NOT_FOUND, + Verdict, + authorize, + extract_access_token, + is_valid_slug, +) +from app_proxy.config import get_config +from app_proxy.login_handoff import ( + WS_CLOSE_NOT_IMPLEMENTED, + is_navigation, + render_login_handoff, + ws_close_code, +) +from app_proxy.observability import log_request +from app_proxy.pages import PAGE_HTTP_STATUS, error_payload, json_status, render_page +from app_proxy.routing import entry_prefix_for + +logger = logging.getLogger(__name__) + +ENTRY_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] + + +@asynccontextmanager +async def lifespan(app: FastAPI): + config = get_config() + if not config.backend_secret or not (config.route_secret): + # Loud, but not fatal: the process still starts and renders its + # fail-closed page, which is a better failure mode on a half-configured + # host than a crash loop that takes /apps/* to 502. + logger.error( + "app-proxy starting with an unconfigured HMAC secret (backend=%s manager=%s); " + "every request will fail closed", + bool(config.backend_secret), + bool(config.manager_secret), + ) + yield + await clients.get_backend_client().aclose() + await clients.get_manager_client().aclose() + + +def render_verdict_response( + request: Request, + kind: str, + *, + verdict: Verdict | None = None, + request_id: str = "", +) -> Response: + """One refusal, two renderings — HTML for people, JSON for code (D7).""" + config = get_config() + accept_language = request.headers.get("Accept-Language") + if not is_navigation(request): + return JSONResponse( + error_payload(kind, request_id, accept_language=accept_language), + status_code=json_status(kind), + ) + + if kind == DECISION_LOGIN: + return HTMLResponse(render_login_handoff(config.login_path), status_code=PAGE_HTTP_STATUS) + + html = render_page( + kind, + app_name=verdict.app_name if verdict else None, + owner_name=verdict.owner_name if verdict else None, + reason=verdict.reason if verdict else None, + square_url=config.square_url, + request_id=request_id, + accept_language=accept_language, + ) + return HTMLResponse(html, status_code=PAGE_HTTP_STATUS) + + +def _needs_trailing_slash(request: Request, slug: str) -> bool: + """Is this a browser navigation to the bare app root, without the slash? + + ``/apps/foo`` and ``/apps/foo/`` reach the same page, but they are NOT the + same base for relative URLs: a document at ``/apps/foo`` resolves + ``./assets/x.js`` against ``/apps/`` and asks for ``/apps/assets/x.js``, + which is a different app's slug and 404s. The app is not at fault — the skill + pack explicitly sanctions relative paths ("要么用 url() 前缀,要么用相对路径") + and a bundler emitting ``./assets/…`` is the normal way to be prefix-agnostic. + Serving that document under a slash-less URL is what breaks it, so the fix + belongs on the platform side rather than in every app's bundler config. + + Only GET/HEAD. Those are the navigations whose relative references matter; + redirecting an API call would add a hop to a request that never resolves a + relative URL against anything. + """ + if request.method not in ("GET", "HEAD"): + return False + prefix = entry_prefix_for(slug, get_config().entry_prefix) + return request.url.path == prefix + + +async def handle_entry(request: Request) -> Response: + started = time.monotonic() + slug = request.path_params.get("slug", "") + tail = request.path_params.get("tail", "") + request_id = uuid.uuid4().hex + + if not is_valid_slug(slug): + # Answered locally: a path that cannot be a slug is not worth an RPC, + # and refusing to ask also keeps scanners from using the internal + # endpoint as an existence oracle. + log_request( + logger, + request_id=request_id, + slug=slug, + user_id=None, + decision=DECISION_NOT_FOUND, + reason="invalid_slug", + cache_hit=False, + upstream_status=None, + latency_ms=(time.monotonic() - started) * 1000, + ) + return render_verdict_response(request, DECISION_NOT_FOUND, request_id=request_id) + + verdict = await authorize( + slug=slug, + access_token=extract_access_token(request), + request_id=request_id, + client_ip=request.client.host if request.client else None, + ) + + if not verdict.allowed: + log_request( + logger, + request_id=request_id, + slug=slug, + user_id=verdict.visitor_id, + decision=verdict.decision, + reason=verdict.reason, + cache_hit=verdict.cache_hit, + upstream_status=None, + latency_ms=(time.monotonic() - started) * 1000, + ) + return render_verdict_response(request, verdict.decision, verdict=verdict, request_id=request_id) + + if _needs_trailing_slash(request, slug): + # After authorize(), so the non-allow branches keep their exact behaviour: + # the login hand-off, 404, stopped and recovering pages are platform HTML + # that resolves no relative URLs, and redirecting them would buy a hop and + # a changed return address for nothing. Only the document that is actually + # the app's own needs its base corrected. + location = f"{request.url.path}/" + if request.url.query: + location = f"{location}?{request.url.query}" + # Still a request somebody may have to account for: without this line a + # visitor's first hit on an app is invisible and the log starts at the + # redirected one, which is the wrong timestamp and the wrong URL. + log_request( + logger, + request_id=request_id, + slug=slug, + user_id=verdict.visitor_id, + decision=verdict.decision, + reason="trailing_slash_redirect", + cache_hit=verdict.cache_hit, + # Answered by the proxy, so the field keeps its meaning: the app + # never saw this one either. + upstream_status=None, + latency_ms=(time.monotonic() - started) * 1000, + ) + return RedirectResponse(location, status_code=308) + + # Imported here, not at module scope: the proxy pulls in the upstream + # transport machinery, and keeping it out of the import graph of the page + # path means a proxy-side import error cannot take the fallback pages down + # with it. + from app_proxy.proxy import forward + + return await forward(request, slug=slug, tail=tail, verdict=verdict, request_id=request_id, started=started) + + +async def handle_entry_ws(websocket: WebSocket) -> None: + """The same verdict as HTTP; a refusal is a close code, never a page. + + Refused **without** accepting: the handshake never completes, so no + unauthorised peer is ever briefly connected. An allowed upgrade is handed + to :mod:`app_proxy.websocket`, which connects to the app first and only + then accepts — a browser is never left holding an open socket that has + nothing behind it. With ``ws_proxy_enabled`` off the allowed case closes + with ``4501`` instead, the pre-Wave-4 behaviour. + """ + slug = websocket.path_params.get("slug", "") + request_id = uuid.uuid4().hex + started = time.monotonic() + if not is_valid_slug(slug): + code = ws_close_code(DECISION_NOT_FOUND) + # The HTTP side logs its locally refused requests; a socket refused on + # the same rule must not be the one traffic nobody can see. + log_request( + logger, + request_id=request_id, + slug=slug, + user_id=None, + decision=DECISION_NOT_FOUND, + reason="invalid_slug", + cache_hit=False, + upstream_status=None, + latency_ms=(time.monotonic() - started) * 1000, + protocol="ws", + close_code=code, + ) + await websocket.close(code=code) + return + + access_token = extract_access_token(websocket) + verdict = await authorize( + slug=slug, + access_token=access_token, + request_id=request_id, + client_ip=websocket.client.host if websocket.client else None, + ) + if verdict.decision == DECISION_ALLOW and get_config().ws_proxy_enabled: + # Lazy for the same reason as ``forward`` above: an import error in the + # socket machinery must not take the refusal path down with it. + from app_proxy.websocket import proxy_websocket + + await proxy_websocket(websocket, slug=slug, verdict=verdict, access_token=access_token, request_id=request_id) + return + + code = WS_CLOSE_NOT_IMPLEMENTED if verdict.decision == DECISION_ALLOW else ws_close_code(verdict.decision) + log_request( + logger, + request_id=request_id, + slug=slug, + user_id=verdict.visitor_id, + decision=verdict.decision, + reason=verdict.reason, + cache_hit=verdict.cache_hit, + upstream_status=None, + # A refused upgrade never reached the app, so this is the time the + # verdict itself took; the close code is what matters here. + latency_ms=(time.monotonic() - started) * 1000, + protocol="ws", + close_code=code, + ) + await websocket.close(code=code) + + +async def handle_close_connections(request: Request) -> Response: + """``POST /internal/connections/close`` — the backend's revoke / stop push (D6 ②). + + Body: ``{"app_id": str, "user_ids": [..] | null, "reason": str}``; ``reason`` + is a verdict word (``forbidden`` / ``stopped`` / ``not_found``) and picks + the close code the affected sockets receive. Signed with the same HMAC and + the same secret as the authorize call, just in the other direction; an + empty secret fails closed, exactly like the outbound calls. + + Answers ``{"closed": n}`` for *this process*. It is not an acknowledgement + that every proxy in the deployment closed anything — see + :mod:`app_proxy.connections` for why the periodic re-authorisation is the + bound a caller may rely on. + """ + from app_proxy import connections + from app_proxy.clients import compute_signature + + config = get_config() + raw = await request.body() + provided = (request.headers.get(config.signature_header) or "").strip().lower() + if not config.backend_secret or not provided: + return JSONResponse({"detail": "unauthorized"}, status_code=401) + expected = compute_signature(request.method, request.url.path, raw, config.backend_secret) + if not hmac.compare_digest(expected, provided): + logger.warning("app_proxy.internal close rejected: signature mismatch") + return JSONResponse({"detail": "unauthorized"}, status_code=401) + + try: + body = json.loads(raw or b"{}") + except ValueError: + return JSONResponse({"detail": "invalid_request"}, status_code=400) + app_id = str(body.get("app_id") or "") + if not app_id: + return JSONResponse({"detail": "invalid_request"}, status_code=400) + user_ids = body.get("user_ids") + if user_ids is not None and not isinstance(user_ids, list): + return JSONResponse({"detail": "invalid_request"}, status_code=400) + reason = str(body.get("reason") or DECISION_FORBIDDEN) + + closed = connections.registry.close_for( + app_id, + user_ids=None if user_ids is None else [str(u) for u in user_ids], + code=ws_close_code(reason), + reason=reason, + ) + logger.info( + "app_proxy.ws_close app_id=%s user_ids=%s reason=%s closed=%s", + app_id, + "*" if user_ids is None else len(user_ids), + reason, + closed, + ) + return JSONResponse({"closed": closed}) + + +def create_app() -> FastAPI: + application = FastAPI( + title="BiSheng app-proxy", + description="Hosted-app entry: session check, header strip/inject, fallback pages, reverse proxy", + version="3.0.0", + lifespan=lifespan, + docs_url=None, + redoc_url=None, + openapi_url=None, + ) + + @application.get("/healthz") + async def healthz() -> dict: + return {"status": "ok"} + + prefix = get_config().entry_prefix.rstrip("/") + # **Before** the ``{slug}`` routes, because Starlette matches in order and + # ``{slug}`` would otherwise swallow ``preview``. The backend reserves the + # name so no application can hold it (``RESERVED_SLUGS``), which is what + # keeps this ordering from shadowing somebody's app (F055 AC-26). + from app_proxy.preview import PREVIEW_SEGMENT, handle_preview_entry + + application.add_route(f"{prefix}/{PREVIEW_SEGMENT}/{{session}}", handle_preview_entry, methods=ENTRY_METHODS) + application.add_route( + f"{prefix}/{PREVIEW_SEGMENT}/{{session}}/{{tail:path}}", handle_preview_entry, methods=ENTRY_METHODS + ) + # Two routes, because ``{tail:path}`` does not match the empty string: + # ``/apps/foo`` and ``/apps/foo/x`` are both entry points (AC-25). + application.add_route(f"{prefix}/{{slug}}", handle_entry, methods=ENTRY_METHODS) + application.add_route(f"{prefix}/{{slug}}/{{tail:path}}", handle_entry, methods=ENTRY_METHODS) + # Via ``application.router``, not ``application``: FastAPI dropped its own + # ``add_websocket_route`` passthrough (gone by 0.141), while the Starlette + # Router method has been stable across both. Our pin is a floor, not a + # ceiling, so the entrypoint must not depend on a version-specific alias — + # tests resolved 0.121 while a fresh ``uv sync`` resolved 0.141 and the + # module failed to import there. + application.router.add_websocket_route(f"{prefix}/{{slug}}", handle_entry_ws) + application.router.add_websocket_route(f"{prefix}/{{slug}}/{{tail:path}}", handle_entry_ws) + # Inbound control plane, HMAC-signed by the backend (D6 invariant ②). Not + # under ``/apps`` — nginx never routes it, so it is reachable only from + # inside the deployment, like the manager's endpoints. + application.add_route("/internal/connections/close", handle_close_connections, methods=["POST"]) + return application + + +app = create_app() diff --git a/src/app-proxy/app_proxy/observability.py b/src/app-proxy/app_proxy/observability.py new file mode 100644 index 0000000000..5d9bf1db09 --- /dev/null +++ b/src/app-proxy/app_proxy/observability.py @@ -0,0 +1,140 @@ +"""The three events app-proxy is required to emit (design §7「关键日志 / 指标」). + +After an incident there is nothing else to read: the proxy keeps no request +store, and the platform's own audit trail records state *actions*, not visitor +traffic. So the events below are the operational surface of this process, and +they are emitted through named functions rather than ad-hoc ``logger.info`` +calls for one reason — **a field that a call site forgot is a field nobody +notices is missing until they need it**. Each emitter takes its fields as +required keyword arguments, so an incomplete call fails at the call site +instead of producing a plausible-looking log line with a hole in it. + +Three shapes, three audiences: + +* :func:`log_request` — one line per forwarded or refused request. INFO. This + is the line that answers "did the visitor reach the app, and how long did it + take". +* :func:`log_header_strip` — WARNING, and the **only** production observable of + AC-32: a client that sends platform identity headers is either a + misconfigured integration or somebody probing the injection contract. It is a + warning so it can be alerted on; an unusual rate here is the signal. +* :func:`log_fallback` — WARNING. Which fallback page was rendered, so the + distribution over kinds can be watched (a rise in ``recovering`` is the + runtime layer struggling; a rise in ``not_found`` is usually a scanner). + +The three names above are **reserved for these emitters**. Diagnostic lines that +happen to be about a request (an upstream retry, a handshake refusal) use their +own names — a line carrying an event name but no ``fields`` would read as a +structured event with a hole in it, which is precisely what this module exists +to prevent. + +The fields travel on the :class:`logging.LogRecord` as ``event`` and ``fields`` +(a dict), so a JSON handler can serialise them verbatim while the plain-text +message stays readable in ``journalctl``. ``EVENT_FIELDS`` is the declared +contract, and ``tests/test_observability.py`` checks the emitters against it — +add a field there and the emitter must grow it too. +""" + +from __future__ import annotations + +import logging +from typing import Any + +EVENT_REQUEST = "app_proxy.request" +EVENT_HEADER_STRIP = "app_proxy.header_strip" +EVENT_FALLBACK = "app_proxy.fallback" + +#: event → the fields every emission of it must carry. Extra fields are allowed +#: (``protocol``, ``generation`` …); a missing one is a defect. +EVENT_FIELDS: dict[str, tuple[str, ...]] = { + EVENT_REQUEST: ( + "request_id", + "slug", + "user_id", + "decision", + "reason", + "cache_hit", + "upstream_status", + "latency_ms", + ), + EVENT_HEADER_STRIP: ("request_id", "slug", "stripped"), + EVENT_FALLBACK: ("request_id", "slug", "kind", "reason"), +} + + +def _render(value: Any) -> str: + if value is None: + return "-" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, float): + return f"{value:.1f}" + return str(value) + + +def emit(logger: logging.Logger, level: int, event: str, fields: dict[str, Any]) -> None: + """Write one structured event. Kept public so tests can drive it directly. + + ``stacklevel=3`` so the record points at the caller of the emitter rather + than at this module — two frames up is where the interesting code is. + """ + message = " ".join([event, *(f"{name}={_render(value)}" for name, value in fields.items())]) + logger.log(level, message, extra={"event": event, "fields": fields}, stacklevel=3) + + +def log_request( + logger: logging.Logger, + *, + request_id: str, + slug: str, + user_id: str | None, + decision: str, + reason: str, + cache_hit: bool, + upstream_status: int | None, + latency_ms: float, + level: int = logging.INFO, + **extra: Any, +) -> None: + """One request, one line (§7). + + ``upstream_status`` is ``None`` for everything the proxy answered itself — + a refusal, a fallback page — which is exactly how "the app never saw this + request" is told apart from "the app answered 500". + """ + emit( + logger, + level, + EVENT_REQUEST, + { + "request_id": request_id, + "slug": slug, + "user_id": user_id, + "decision": decision, + "reason": reason, + "cache_hit": cache_hit, + "upstream_status": upstream_status, + "latency_ms": latency_ms, + **extra, + }, + ) + + +def log_header_strip(logger: logging.Logger, *, request_id: str, slug: str, stripped: list[str]) -> None: + """AC-32's production observable. WARNING on purpose — see the module docstring.""" + emit( + logger, + logging.WARNING, + EVENT_HEADER_STRIP, + {"request_id": request_id, "slug": slug, "stripped": ",".join(stripped)}, + ) + + +def log_fallback(logger: logging.Logger, *, request_id: str, slug: str, kind: str, reason: str, **extra: Any) -> None: + """Which fallback page was rendered, and why (§7: 兜底页类型分布).""" + emit( + logger, + logging.WARNING, + EVENT_FALLBACK, + {"request_id": request_id, "slug": slug, "kind": kind, "reason": reason, **extra}, + ) diff --git a/src/app-proxy/app_proxy/pages.py b/src/app-proxy/app_proxy/pages.py new file mode 100644 index 0000000000..1a9af25900 --- /dev/null +++ b/src/app-proxy/app_proxy/pages.py @@ -0,0 +1,550 @@ +"""The pages a visitor gets when the answer is not "come in" (D7-A′). + +app-proxy renders these itself instead of redirecting to a SPA gate route. The +reason is the URL: a bookmark, a QR code and a browser refresh all have to keep +working, and a 302 loses the fragment on the way out and the original address +on the way back. Rendering in place costs a small stylesheet in Python and buys +zero client changes. + +**Status codes.** Navigations get **200 + HTML** for every kind, including "not +found". That is not sloppiness: design §7 step 7 asks for +``/apps/__nonexistent__`` to show the "不存在或未上线" page and explicitly *not* +a 404, because a phone scanning a QR code that resolves to a browser error +shell is indistinguishable from "the platform is down". Programmatic callers do +get the true status — see :data:`JSON_STATUS` — because an in-app ``fetch()`` +that receives HTML with a 200 will try to parse it and fail in a far more +confusing way. + +**Language.** Three locales — zh-Hans / en / ja — chosen from the request's +``Accept-Language`` and falling back to zh-Hans. The copy lives in this module +rather than in ``packages/locales``: app-proxy is a separate process that must +still render when the platform is half-broken, so it carries no runtime +dependency on the SPA bundles, and ``pnpm check-i18n`` therefore cannot see +these strings. :data:`_COPY` is the substitute for that check — every locale +declares every kind, and ``test_pages_i18n`` fails the build if one drifts. + +**Roles named in the copy.** Never "租户管理员": ``multi_tenant.enabled`` +defaults to *false*, so a standard install is single-tenant, ``tenant_admin`` +resolves to the platform super admin, and Root may not even be granted a tenant +administrator (19204) — the person that page told the visitor to find does not +exist. This process cannot read the switch either (it never talks to the config +service), so the copy uses the tenant-neutral "平台管理员" that is true in both +deployment shapes. +""" + +from __future__ import annotations + +import html +from dataclasses import dataclass + +from app_proxy.authz import ( + DECISION_FORBIDDEN, + DECISION_LOGIN, + DECISION_NOT_ENABLED, + DECISION_NOT_FOUND, + DECISION_STOPPED, + DECISION_UNAVAILABLE, +) +from app_proxy.config import DEFAULT_SQUARE_URL +from app_proxy.recovery_appearance import ILLUSTRATION as RECOVERY_ILLUSTRATION +from app_proxy.recovery_appearance import STYLE as RECOVERY_STYLE + +#: Transitional, not a verdict: the app exists and you may enter, but nothing +#: is answering right now. Two kinds, told apart by the manager's answer rather +#: than guessed from the failure (T083): ``deploying`` = a deploy is in flight +#: and nothing serves yet (AC-48); ``recovering`` = a route existed and refused +#: us, or none is declared (crash window, AC-36). +PAGE_DEPLOYING = "deploying" +PAGE_RECOVERING = "recovering" +TRANSITIONAL_KINDS = frozenset({PAGE_DEPLOYING, PAGE_RECOVERING}) + +#: Every page renders 200 for navigations. See the module docstring. +PAGE_HTTP_STATUS = 200 + +#: Auto-retry cadence of the transitional pages (AC-48). The script reloads +#: first and backs off from ``RETRY_FIRST_SECONDS`` up to ``RETRY_MAX_SECONDS``; +#: the ``meta refresh`` at ``RETRY_MAX_SECONDS`` is the floor for a browser with +#: scripting off. Both are longer than the 3 s route cache, so every reload +#: genuinely re-asks the manager instead of replaying a cached "starting". +RETRY_FIRST_SECONDS = 3 +RETRY_MAX_SECONDS = 10 + +#: The three bundles the platform ships everywhere else (``packages/locales``). +LOCALE_ZH = "zh-Hans" +LOCALE_EN = "en" +LOCALE_JA = "ja" +DEFAULT_LOCALE = LOCALE_ZH + +#: ```` per bundle — not the bundle name: "zh-Hans" is a valid BCP 47 +#: tag but "zh-CN" is what the rest of the platform emits. +_HTML_LANG = {LOCALE_ZH: "zh-CN", LOCALE_EN: "en", LOCALE_JA: "ja"} + +#: Real statuses for non-navigation callers. +JSON_STATUS = { + DECISION_LOGIN: 401, + DECISION_FORBIDDEN: 403, + DECISION_STOPPED: 403, + DECISION_NOT_FOUND: 404, + DECISION_NOT_ENABLED: 503, + DECISION_UNAVAILABLE: 503, + PAGE_DEPLOYING: 503, + PAGE_RECOVERING: 503, +} + +#: Platform error codes (design §4.2 ⑥, section 161). The two transitional +#: kinds have codes of their own — 16147 / 16148 in the entry band — registered +#: in ``common/errcode/app_factory.py`` and the three ``api_errors`` bundles. +#: ``recovering`` used to borrow 16121 (编排器不可用), which was the closest true +#: statement at the time but made "the app is restarting" and "the manager is +#: down" indistinguishable to an in-app ``fetch()``. +ERROR_CODES = { + DECISION_LOGIN: 16141, + DECISION_FORBIDDEN: 16142, + DECISION_STOPPED: 16143, + DECISION_NOT_FOUND: 16144, + DECISION_NOT_ENABLED: 16145, + DECISION_UNAVAILABLE: 16146, + PAGE_DEPLOYING: 16147, + PAGE_RECOVERING: 16148, +} + +_STYLE = """ +:root { color-scheme: light dark; } +* { box-sizing: border-box; } +body { + margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; + padding: 24px; background: #f5f6f8; color: #1f2329; + font: 14px/1.7 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", + "Hiragino Sans GB", "Microsoft YaHei", sans-serif; +} +.card { + width: 100%; max-width: 480px; background: #fff; border-radius: 12px; padding: 40px 32px; + box-shadow: 0 2px 12px rgba(31, 35, 41, .08); text-align: center; +} +.mark { font-size: 40px; line-height: 1; margin-bottom: 16px; } +h1 { font-size: 20px; font-weight: 600; margin: 0 0 12px; } +p { margin: 0 0 8px; color: #646a73; } +.app { color: #1f2329; font-weight: 500; } +.hint { font-size: 13px; color: #8f959e; margin-top: 16px; } +.actions { margin-top: 28px; } +a.button { + display: inline-block; padding: 8px 20px; border-radius: 6px; text-decoration: none; + background: #1668dc; color: #fff; font-size: 14px; +} +a.button:hover { background: #1554b5; } +@media (prefers-color-scheme: dark) { + body { background: #1a1a1a; color: #e5e6eb; } + .card { background: #242424; box-shadow: none; } + p { color: #9a9a9a; } + .app { color: #e5e6eb; } +} +""" + + +@dataclass(frozen=True) +class _Layout: + """What a page looks like — the same in every language.""" + + mark: str + show_square: bool = True + + +@dataclass(frozen=True) +class _Copy: + """What a page says in one language. + + ``lines`` may reference ``{app}`` and ``{owner}``; both are always + substituted, with a generic stand-in when the name is unknown. + """ + + title: str + lines: tuple[str, ...] + + +@dataclass(frozen=True) +class _Chrome: + """Everything on the page that is not a :class:`_Copy` line.""" + + square: str + request_id: str + #: ``{name}`` — how the app's own name is set off from the sentence around + #: it. CJK takes 「」 instead of the spaces a Latin script would use. + app_named: str + #: Stand-in when the app name is unknown (the not-found page never has one). + #: Every line has to read correctly with *either* value in the slot, which + #: is why no line starts with ``{app}`` or puts an article in front of it. + app_fallback: str + #: ``{name}`` — the owner, with their role spelled out around the name so + #: the sentence survives the fallback below without saying it twice. + owner_named: str + owner_fallback: str + + +_LAYOUT: dict[str, _Layout] = { + DECISION_FORBIDDEN: _Layout(mark="🔒"), + DECISION_STOPPED: _Layout(mark="⏸"), + # Carries neither name nor owner — draft / pending / deleted / never + # existed must be one indistinguishable page (AC-29). + DECISION_NOT_FOUND: _Layout(mark="🔍"), + DECISION_NOT_ENABLED: _Layout(mark="🧩", show_square=False), + DECISION_UNAVAILABLE: _Layout(mark="⚠️", show_square=False), + PAGE_DEPLOYING: _Layout(mark="🚀", show_square=False), + PAGE_RECOVERING: _Layout(mark="🔄", show_square=False), +} + +_CHROME: dict[str, _Chrome] = { + LOCALE_ZH: _Chrome( + square="返回广场", + request_id="请求编号:{id}", + app_named="「{name}」", + app_fallback="该应用", + owner_named="应用负责人「{name}」", + owner_fallback="应用负责人", + ), + LOCALE_EN: _Chrome( + square="Back to Square", + request_id="Request ID: {id}", + app_named="{name}", + app_fallback="this application", + owner_named="the app owner ({name})", + owner_fallback="the app owner", + ), + LOCALE_JA: _Chrome( + square="広場に戻る", + request_id="リクエスト ID:{id}", + app_named="「{name}」", + app_fallback="このアプリ", + owner_named="アプリ担当者({name})", + owner_fallback="アプリ担当者", + ), +} + +#: Locale → kind → copy. Every locale declares every kind; the parity is a +#: test, because nothing else checks these strings. +_COPY: dict[str, dict[str, _Copy]] = { + LOCALE_ZH: { + DECISION_FORBIDDEN: _Copy( + title="无访问权限", + lines=( + "你没有访问{app}的权限。", + "如需使用,请联系{owner}或平台管理员开通权限。", + ), + ), + DECISION_STOPPED: _Copy( + title="应用已停用", + lines=( + "{app}已停用,恢复后即可正常访问。", + "如需恢复,请联系{owner}或平台管理员。", + ), + ), + DECISION_NOT_FOUND: _Copy( + title="应用不存在或未上线", + lines=("该链接对应的应用不存在,或尚未上线。", "请确认链接是否正确,或返回广场查看可用的应用。"), + ), + DECISION_NOT_ENABLED: _Copy( + title="本环境未启用应用工场", + lines=("当前环境未部署应用工场运行时层,无法访问托管应用。", "如需启用,请联系平台管理员。"), + ), + DECISION_UNAVAILABLE: _Copy( + title="暂时无法访问", + lines=("平台暂时无法确认你的访问权限,请稍后重试。", "若持续出现,请联系平台管理员。"), + ), + PAGE_DEPLOYING: _Copy( + title="应用发布中", + lines=("{app}正在发布新版本,页面会自动重试,就绪后自动进入应用。",), + ), + PAGE_RECOVERING: _Copy( + title="应用恢复中", + lines=("{app}正在恢复,页面会自动重试,恢复后自动进入应用。",), + ), + }, + LOCALE_EN: { + DECISION_FORBIDDEN: _Copy( + title="No access", + lines=( + "You do not have access to {app}.", + "To get access, contact {owner} or a platform administrator.", + ), + ), + DECISION_STOPPED: _Copy( + title="Application stopped", + lines=( + "For now, {app} is stopped. It will be reachable again once it is restarted.", + "To have it restarted, contact {owner} or a platform administrator.", + ), + ), + DECISION_NOT_FOUND: _Copy( + title="Application not found", + lines=( + "The application this link points to does not exist, or is not online yet.", + "Check the link, or go back to the square to see the applications available to you.", + ), + ), + DECISION_NOT_ENABLED: _Copy( + title="App factory is not enabled", + lines=( + "The app factory runtime layer is not deployed in this environment, " + "so hosted applications cannot be opened.", + "To have it enabled, contact a platform administrator.", + ), + ), + DECISION_UNAVAILABLE: _Copy( + title="Temporarily unavailable", + lines=( + "The platform cannot confirm your access right now. Please try again in a moment.", + "If this keeps happening, contact a platform administrator.", + ), + ), + PAGE_DEPLOYING: _Copy( + title="Application is being published", + lines=( + "A new version of {app} is being published. " + "This page retries on its own and opens the application once it is ready.", + ), + ), + PAGE_RECOVERING: _Copy( + title="Application is restarting", + lines=( + "Please wait, {app} is restarting. " + "This page retries on its own and opens the application once it is back.", + ), + ), + }, + LOCALE_JA: { + DECISION_FORBIDDEN: _Copy( + title="アクセス権限がありません", + lines=( + "{app}へのアクセス権限がありません。", + "利用するには、{owner}またはプラットフォーム管理者に権限の付与を依頼してください。", + ), + ), + DECISION_STOPPED: _Copy( + title="アプリは停止中です", + lines=( + "{app}は停止中です。再開後にアクセスできます。", + "再開するには、{owner}またはプラットフォーム管理者にご連絡ください。", + ), + ), + DECISION_NOT_FOUND: _Copy( + title="アプリが存在しないか、公開されていません", + lines=( + "このリンクのアプリは存在しないか、まだ公開されていません。", + "リンクをご確認いただくか、広場に戻って利用できるアプリをご覧ください。", + ), + ), + DECISION_NOT_ENABLED: _Copy( + title="この環境ではアプリ工場が有効化されていません", + lines=( + "この環境にはアプリ工場のランタイム層が導入されていないため、ホスト型アプリを開けません。", + "有効化するには、プラットフォーム管理者にご連絡ください。", + ), + ), + DECISION_UNAVAILABLE: _Copy( + title="一時的にアクセスできません", + lines=( + "アクセス権限を確認できませんでした。しばらくしてからもう一度お試しください。", + "繰り返し発生する場合は、プラットフォーム管理者にご連絡ください。", + ), + ), + PAGE_DEPLOYING: _Copy( + title="アプリを公開しています", + lines=( + "{app}の新しいバージョンを公開しています。このページは自動的に再試行し、準備ができ次第アプリを開きます。", + ), + ), + PAGE_RECOVERING: _Copy( + title="アプリを復旧しています", + lines=("{app}を復旧しています。このページは自動的に再試行し、復旧次第アプリを開きます。",), + ), + }, +} + +#: Language subtag → bundle. Any Chinese (including zh-Hant / zh-TW) maps to the +#: single Chinese bundle the platform ships; "*" and everything else fall back. +_LANGUAGE_BUNDLE = {"zh": LOCALE_ZH, "en": LOCALE_EN, "ja": LOCALE_JA} + + +def negotiate_locale(accept_language: str | None) -> str: + """Pick a bundle from an ``Accept-Language`` header. + + A hand-rolled parser rather than a dependency: the header is three tokens + and a q-value, and this process deliberately ships nothing it does not need + to render a page while the platform is down. + """ + if not accept_language: + return DEFAULT_LOCALE + + ranked: list[tuple[float, int, str]] = [] + for index, part in enumerate(accept_language.split(",")): + token, _, params = part.strip().partition(";") + tag = token.strip().lower() + if not tag: + continue + quality = 1.0 + for param in params.split(";"): + key, _, value = param.partition("=") + if key.strip().lower() == "q": + try: + quality = float(value.strip()) + except ValueError: + quality = 0.0 + if quality <= 0: + # ``q=0`` is an explicit refusal, not a weak preference. + continue + # Negated quality first, then the original position: sorting the tuple + # then reproduces "highest q wins, ties keep the order the client sent". + ranked.append((-quality, index, tag)) + + for _, _, tag in sorted(ranked): + bundle = _LANGUAGE_BUNDLE.get(tag.split("-")[0]) + if bundle: + return bundle + return DEFAULT_LOCALE + + +def _copy(kind: str, locale: str) -> _Copy: + bundle = _COPY.get(locale) or _COPY[DEFAULT_LOCALE] + return bundle.get(kind) or bundle[DECISION_UNAVAILABLE] + + +def _fill(line: str, chrome: _Chrome, app_name: str | None, owner_name: str | None) -> str: + """Substitute, and degrade gracefully when a name is unknown. + + The owner's *role* travels with the substituted value rather than sitting in + the sentence, so the fallback reads "请联系应用负责人或平台管理员" instead of + naming the role twice. An owner may genuinely be missing after an account is + removed, and the not-found page has no name by design. + """ + return line.format( + app=chrome.app_named.format(name=html.escape(app_name)) if app_name else chrome.app_fallback, + owner=chrome.owner_named.format(name=html.escape(owner_name)) if owner_name else chrome.owner_fallback, + ) + + +def render_page( + kind: str, + *, + app_name: str | None = None, + owner_name: str | None = None, + square_url: str = DEFAULT_SQUARE_URL, + request_id: str = "", + accept_language: str | None = None, + reason: str | None = None, +) -> str: + """Full self-contained HTML document. No external request. + + These render when the platform may already be half-broken, so a CDN font or + a logo fetched over the network is exactly the dependency that turns a + tidy explanation into a blank page. + + The four verdict pages carry no script at all. The two transitional kinds + carry the retry markup from :func:`_retry_markup` and nothing else — a + verdict is settled and must not reload itself, a transition closes on its + own and must (AC-48). Whether a kind retries is decided by + :data:`TRANSITIONAL_KINDS`, not by the caller. + """ + locale = negotiate_locale(accept_language) + layout = _LAYOUT.get(kind) or _LAYOUT[DECISION_UNAVAILABLE] + chrome = _CHROME[locale] + copy = _copy(kind, locale) + if kind == DECISION_UNAVAILABLE and reason == "identity_config_incomplete": + copy = { + LOCALE_ZH: _Copy(title="环境配置不完整", lines=("本环境配置不完整,请联系平台管理员。",)), + LOCALE_EN: _Copy( + title="Environment setup incomplete", + lines=("This environment is not fully configured. Contact a platform administrator.",), + ), + LOCALE_JA: _Copy( + title="環境設定が不完全です", + lines=("環境設定が不完全です。プラットフォーム管理者にお問い合わせください。",), + ), + }[locale] + body = "\n".join(f"

{_fill(line, chrome, app_name, owner_name)}

" for line in copy.lines) + + actions = "" + if layout.show_square: + actions = ( + f' ' + ) + + hint = "" + # Only on the "something broke" pages: a request id printed on the + # not-found page would make four supposedly identical renders differ, which + # is exactly the enumeration oracle AC-29 closes. + if request_id and (kind == DECISION_UNAVAILABLE or kind in TRANSITIONAL_KINDS): + hint = f'

{html.escape(chrome.request_id.format(id=request_id))}

' + + refresh_meta, retry_script = _retry_markup(kind) + style = RECOVERY_STYLE if kind == PAGE_RECOVERING else _STYLE + mark = RECOVERY_ILLUSTRATION if kind == PAGE_RECOVERING else layout.mark + + return f""" + + + + + +{refresh_meta}{html.escape(copy.title)} + + + +
+
{mark}
+

{html.escape(copy.title)}

+{body} +{actions} +{hint} +
+{retry_script} + +""" + + +def _retry_markup(kind: str) -> tuple[str, str]: + """``(meta refresh, inline script)`` for a transitional kind; empty otherwise. + + Two mechanisms, deliberately: the ``meta`` tag needs no script and is the + floor, the script backs the interval off — first reload after + ``RETRY_FIRST_SECONDS``, then growing to ``RETRY_MAX_SECONDS`` — so a slow + build does not get a request every three seconds from every open tab. The + attempt counter lives in ``sessionStorage`` when there is one; with storage + blocked (private mode, some 信创 builds) the page still reloads at the + first interval — it just does not back off. Both timers reload the *same* + URL: the page is served at the app's own address, so the reload that finds + the route live lands inside the app (AC-48). + """ + if kind not in TRANSITIONAL_KINDS: + return "", "" + meta = f'\n' + script = f""" +""" + return meta, script + + +def error_payload(kind: str, request_id: str = "", accept_language: str | None = None) -> dict: + """JSON body for non-navigation callers, shaped like the platform envelope.""" + copy = _copy(kind, negotiate_locale(accept_language)) + return { + "status_code": ERROR_CODES.get(kind, ERROR_CODES[DECISION_UNAVAILABLE]), + "status_message": copy.title, + "decision": kind, + "request_id": request_id, + } + + +def json_status(kind: str) -> int: + return JSON_STATUS.get(kind, 503) diff --git a/src/app-proxy/app_proxy/platform_protocol.py b/src/app-proxy/app_proxy/platform_protocol.py new file mode 100644 index 0000000000..628dcf1dc6 --- /dev/null +++ b/src/app-proxy/app_proxy/platform_protocol.py @@ -0,0 +1,105 @@ +"""Versioned runtime wire authentication; shared vectors pin both process copies.""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import re +import time +from dataclasses import dataclass +from urllib.parse import parse_qsl, quote, unquote, urlencode, urlsplit +from uuid import uuid4 + + +class ProtocolRejected(ValueError): + """Safe protocol failure with no credential or original request content.""" + + +@dataclass(frozen=True) +class ServiceIdentity: + key_id: str + node_id: str + roles: tuple[str, ...] + + +def canonical_json(value) -> bytes: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False).encode() + + +def content_digest(value) -> str: + return hashlib.sha256(canonical_json(value)).hexdigest() + + +def canonical_message(method: str, url: str, body: bytes, timestamp: int, nonce: str, key_id: str) -> bytes: + parts = urlsplit(url) + path = quote(unquote(parts.path, errors="strict"), safe="/-._~") + query = urlencode(sorted(parse_qsl(parts.query, keep_blank_values=True)), quote_via=quote, safe="-._~") + target = path + ("?" + query if query else "") + if "\n" in key_id or "\n" in nonce: + raise ProtocolRejected("invalid_identity") + return ( + f"v2\n{method.upper()}\n{target}\n{hashlib.sha256(body).hexdigest()}\n{timestamp}\n{nonce}\n{key_id}".encode() + ) + + +def signed_headers( + method: str, + url: str, + body: bytes, + *, + key_id: str, + secret: str, + timestamp: int | None = None, + nonce: str | None = None, +) -> dict[str, str]: + if not key_id or not secret: + raise ProtocolRejected("service_identity_not_configured") + timestamp = int(time.time()) if timestamp is None else timestamp + nonce = nonce or uuid4().hex + signature = hmac.new( + secret.encode(), canonical_message(method, url, body, timestamp, nonce, key_id), hashlib.sha256 + ).hexdigest() + return { + "X-Runtime-Key-Id": key_id, + "X-Runtime-Timestamp": str(timestamp), + "X-Runtime-Nonce": nonce, + "X-Runtime-Signature": signature, + } + + +async def authenticate( + method: str, + url: str, + body: bytes, + headers, + keyring: dict, + consume_nonce, + *, + required_role: str, + now: float | None = None, +) -> ServiceIdentity: + headers = {k.lower(): v for k, v in headers.items()} + key_id = headers.get("x-runtime-key-id", "") + nonce = headers.get("x-runtime-nonce", "") + if not re.fullmatch(r"[a-zA-Z0-9._-]{1,64}", key_id) or not re.fullmatch(r"[0-9a-f]{32}", nonce): + raise ProtocolRejected("invalid_identity") + try: + timestamp = int(headers.get("x-runtime-timestamp", "")) + except (TypeError, ValueError): + raise ProtocolRejected("invalid_timestamp") from None + if abs((time.time() if now is None else now) - timestamp) >= 60: + raise ProtocolRejected("signature_expired") + key = keyring.get(key_id, {}) + secret = key.get("secret", "") + roles = key.get("roles", []) + if not secret or required_role not in roles: + raise ProtocolRejected("service_role_refused") + expected = hmac.new( + secret.encode(), canonical_message(method, url, body, timestamp, nonce, key_id), hashlib.sha256 + ).hexdigest() + if not hmac.compare_digest(expected, headers.get("x-runtime-signature", "")): + raise ProtocolRejected("signature_mismatch") + if not await consume_nonce(f"runtime:v2:nonce:{key_id}:{nonce}", 120): + raise ProtocolRejected("request_replayed") + return ServiceIdentity(key_id=key_id, node_id=str(key.get("node_id", "")), roles=tuple(roles)) diff --git a/src/app-proxy/app_proxy/preview.py b/src/app-proxy/app_proxy/preview.py new file mode 100644 index 0000000000..6188622ce7 --- /dev/null +++ b/src/app-proxy/app_proxy/preview.py @@ -0,0 +1,157 @@ +"""``/apps/preview/{session}`` — the approval-time preview entry (F054 T090, F055 AC-26). + +The **same** proxy as ``/apps/{slug}``, pointed at a different question. What +differs is exactly two things, and everything else is reused rather than +re-implemented: + +* the verdict comes from ``…/authorize-preview`` instead of ``…/authorize``, + because "is this the approver this trial was raised for" is a different + question from "is this app visible to this visitor"; +* the upstream is resolved by preview session rather than by app id, because a + preview has no desired-state record in the manager (F055 keeps it off the + instance quota that way). + +**Header stripping and identity injection are literally the same code** — the +same :func:`app_proxy.proxy.forward`, which calls the same +:func:`app_proxy.headers.build_upstream_headers`. That is a requirement, not a +convenience: a second copy of the strip rule is a second place for +``X_BiSheng_User_Id`` to walk past it (AC-32). The trick that makes it work is +that the "slug" handed to ``forward`` is ``preview/{session}``, so +``entry_prefix_for`` yields ``/apps/preview/{session}`` and both the prefix +strip and ``X-Forwarded-Prefix`` come out right with no branch at all. + +**The lifecycle is not controlled here.** app-proxy never starts or reclaims a +preview; F055 does (AC-26 / AC-28). When the upstream is not there — reclaimed, +timed out, or never up — the answer is the ordinary transitional page, exactly +as for an application between containers. +""" + +from __future__ import annotations + +import logging +import re +import time +import uuid + +from starlette.requests import Request +from starlette.responses import RedirectResponse, Response + +from app_proxy.authz import DECISION_NOT_FOUND, authorize_preview, extract_access_token +from app_proxy.config import get_config +from app_proxy.observability import log_request +from app_proxy.routing import entry_prefix_for + +logger = logging.getLogger(__name__) + +#: Path segment that turns ``/apps/{slug}`` into the preview entry. Must never +#: be a legal application slug — the backend reserves it (``RESERVED_SLUGS`` in +#: ``app_provision_service``), because a route registered ahead of ``{slug}`` +#: would otherwise shadow an application permanently. +PREVIEW_SEGMENT = "preview" + +#: Same shape the platform mints (a uuid4 hex) and the manager accepts. Checked +#: locally so a path that cannot be a session never becomes an RPC, and so that +#: ``/apps/preview/../..`` dies here. +SESSION_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{7,63}$") + + +def is_valid_session(session: str) -> bool: + return bool(SESSION_PATTERN.match(session or "")) + + +def preview_slug(session: str) -> str: + """The pseudo-slug ``forward`` is given: ``preview/{session}``. + + Not a hack — it is the one value for which ``entry_prefix_for`` produces the + real entry prefix of this request, which is what both the path strip and + ``X-Forwarded-Prefix`` are derived from. Passing the bare session instead + would strip ``/apps/{session}`` and forward ``/preview/…`` to the app. + """ + return f"{PREVIEW_SEGMENT}/{session}" + + +def _needs_trailing_slash(request: Request, session: str) -> bool: + """Is this a browser navigation to the bare preview root, without the slash?""" + if request.method not in ("GET", "HEAD"): + return False + return request.url.path == entry_prefix_for(preview_slug(session), get_config().entry_prefix) + + +async def handle_preview_entry(request: Request) -> Response: + """Verdict → forward. Same order, same refusal rendering as the app entry.""" + # Imported here rather than at module scope for the same reason ``main`` + # imports ``forward`` lazily: a proxy-side import error must not take the + # refusal pages down with it. + from app_proxy.main import render_verdict_response + + started = time.monotonic() + session = request.path_params.get("session", "") + tail = request.path_params.get("tail", "") + request_id = uuid.uuid4().hex + + if not is_valid_session(session): + log_request( + logger, + request_id=request_id, + slug=preview_slug(session), + user_id=None, + decision=DECISION_NOT_FOUND, + reason="invalid_preview_session", + cache_hit=False, + upstream_status=None, + latency_ms=(time.monotonic() - started) * 1000, + ) + return render_verdict_response(request, DECISION_NOT_FOUND, request_id=request_id) + + verdict = await authorize_preview( + session=session, + access_token=extract_access_token(request), + request_id=request_id, + client_ip=request.client.host if request.client else None, + ) + + if not verdict.allowed: + log_request( + logger, + request_id=request_id, + slug=preview_slug(session), + user_id=verdict.visitor_id, + decision=verdict.decision, + reason=verdict.reason, + cache_hit=verdict.cache_hit, + upstream_status=None, + latency_ms=(time.monotonic() - started) * 1000, + ) + return render_verdict_response(request, verdict.decision, verdict=verdict, request_id=request_id) + + if _needs_trailing_slash(request, session): + # Same reason as the application entry: a document served at + # ``/apps/preview/{s}`` resolves ``./assets/x.js`` against + # ``/apps/preview/`` and asks for a different session's asset. After + # the verdict, so a refusal keeps its exact behaviour. + location = f"{request.url.path}/" + if request.url.query: + location = f"{location}?{request.url.query}" + log_request( + logger, + request_id=request_id, + slug=preview_slug(session), + user_id=verdict.visitor_id, + decision=verdict.decision, + reason="trailing_slash_redirect", + cache_hit=verdict.cache_hit, + upstream_status=None, + latency_ms=(time.monotonic() - started) * 1000, + ) + return RedirectResponse(location, status_code=308) + + from app_proxy.proxy import forward + + return await forward( + request, + slug=preview_slug(session), + tail=tail, + verdict=verdict, + request_id=request_id, + started=started, + ) diff --git a/src/app-proxy/app_proxy/proxy.py b/src/app-proxy/app_proxy/proxy.py new file mode 100644 index 0000000000..f4ca9d92d5 --- /dev/null +++ b/src/app-proxy/app_proxy/proxy.py @@ -0,0 +1,374 @@ +"""The forward itself: resolve → strip → inject → stream. + +Streaming in both directions is not an optimisation here. Hosted apps serve +SSE, long polls and file downloads; buffering a response would turn a live +stream into a stall, and buffering a request would cap uploads at whatever +memory the proxy is willing to hold. + +The one retry (D5.1) is narrow on purpose: **connection-level failures only, +exactly once, after invalidating the cached address**. A refused connection +means the address is stale — the container was replaced or died — so asking the +manager again is the fix. A 500 from the app is the app's own answer and is +passed through untouched; retrying it would silently duplicate non-idempotent +requests. + +The helpers that describe the visitor to the app (:func:`external_proto_and_host`, +:func:`forwarded_for`, :func:`log_forged_headers`) take an ``HTTPConnection`` +rather than a ``Request`` because the WebSocket upgrade path +(:mod:`app_proxy.websocket`) describes the same visitor to the same app and +must not grow a second copy of the rules. +""" + +from __future__ import annotations + +import logging +import time + +import httpx +from starlette.background import BackgroundTask +from starlette.requests import HTTPConnection, Request +from starlette.responses import HTMLResponse, JSONResponse, Response, StreamingResponse + +from app_proxy.authz import Verdict +from app_proxy.config import get_config +from app_proxy.headers import build_upstream_headers, is_platform_header +from app_proxy.observability import log_fallback, log_header_strip, log_request +from app_proxy.pages import ( + PAGE_DEPLOYING, + PAGE_HTTP_STATUS, + PAGE_RECOVERING, + RETRY_FIRST_SECONDS, + error_payload, + json_status, + render_page, +) +from app_proxy.routing import entry_prefix_for, resolve_upstream, restore_entry_prefix, strip_entry_prefix + +logger = logging.getLogger(__name__) + +#: Response headers that describe *this* hop and must not be replayed onto the +#: next one. ``content-length`` and ``content-encoding`` are deliberately kept: +#: we forward raw bytes, so the body is exactly what the upstream produced. +_RESPONSE_HOP_BY_HOP = frozenset( + { + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + } +) + +_transport: httpx.AsyncBaseTransport | None = None +_client: httpx.AsyncClient | None = None + + +def set_upstream_transport(transport: httpx.AsyncBaseTransport | None) -> None: + """Test / bootstrap seam. Swapping the transport rebuilds the client.""" + global _transport, _client + _transport = transport + _client = None + + +def get_upstream_client() -> httpx.AsyncClient: + global _client + if _client is None: + config = get_config() + _client = httpx.AsyncClient( + transport=_transport, + timeout=httpx.Timeout( + connect=config.upstream_connect_timeout_seconds, + read=config.upstream_read_timeout_seconds, + write=config.upstream_read_timeout_seconds, + pool=config.upstream_connect_timeout_seconds, + ), + # A proxy passes redirects to the browser; following them here would + # hide the app's own navigation and rewrite the URL bar's meaning. + follow_redirects=False, + ) + return _client + + +def external_proto_and_host(connection: HTTPConnection) -> tuple[str, str]: + """What the visitor typed, as best we can know it (D5.2). + + ``APP_PROXY_ENTRY_BASE_URL`` wins when configured — that is the only fully + trustworthy answer and the recommended deployment setting. + + Otherwise the **host** comes from the ``Host`` header and never from + ``X-Forwarded-Host``: nginx rewrites ``Host`` to the original, while + ``X-Forwarded-Host`` may be whatever the visitor sent, and an app that + builds links from a forged host hands out working phishing URLs. + + The **scheme** is the asymmetric case: nginx→app-proxy is plain HTTP on + loopback, so ``X-Forwarded-Proto`` from the immediate peer is the only + carrier of "the visitor came over TLS". The documented nginx location + overwrites it with ``$scheme``; set ``entry_base_url`` where that guarantee + does not hold. A WebSocket upgrade's own scheme is ``ws``/``wss``, which the + app must read as the HTTP scheme it derives links from. + """ + config = get_config() + if config.entry_base_url: + parsed = httpx.URL(config.entry_base_url) + return parsed.scheme, parsed.netloc.decode("ascii") + + host = connection.headers.get("Host", "") + own = {"ws": "http", "wss": "https"}.get(connection.url.scheme, connection.url.scheme) + proto = connection.headers.get("X-Forwarded-Proto", "").split(",")[0].strip() or own + return proto, host + + +def forwarded_for(connection: HTTPConnection) -> str | None: + existing = connection.headers.get("X-Forwarded-For", "").strip() + peer = connection.client.host if connection.client else None + if not peer: + return existing or None + return f"{existing}, {peer}" if existing else peer + + +def log_forged_headers(connection: HTTPConnection, request_id: str, slug: str) -> None: + forged = [name for name in connection.headers if is_platform_header(name)] + if forged: + # §7 asks for this signal by name: a client that sends identity headers + # is either a misconfigured integration or someone probing AC-32, and + # both are worth seeing before they become an incident. + log_header_strip(logger, request_id=request_id, slug=slug, stripped=sorted(forged)) + + +def _response_header( + name: bytes, value: bytes, *, slug: str, entry_prefix: str, upstream_base: str +) -> tuple[bytes, bytes]: + """One upstream response header, with ``Location`` put back under the entry prefix. + + Every other header leaves byte for byte (see the ``raw`` note in + :func:`forward`). ``Location`` is the one the strip breaks: see + :func:`app_proxy.routing.restore_entry_prefix`. + """ + if name.lower() != b"location": + return name, value + try: + location = value.decode("latin-1") + except UnicodeDecodeError: # pragma: no cover - latin-1 decodes every byte + return name, value + rewritten = restore_entry_prefix(location, slug, entry_prefix, upstream_base) + return name, rewritten.encode("latin-1") + + +def upstream_headers_for(connection: HTTPConnection, verdict: Verdict, *, slug: str, request_id: str) -> list: + """The one header list both the HTTP and the WS path send upstream (AC-31 / AC-32).""" + config = get_config() + proto, host = external_proto_and_host(connection) + headers = build_upstream_headers( + connection.headers.items(), + verdict.material, + slug=slug, + request_id=request_id, + obo_token=verdict.obo_token, + proto=proto, + host=host, + app_id=verdict.app_id, + entry_prefix=config.entry_prefix, + ) + chain = forwarded_for(connection) + if chain: + headers = [(name, value) for name, value in headers if name.lower() != "x-forwarded-for"] + headers.append(("X-Forwarded-For", chain)) + return headers + + +def transition_response(request: Request, kind: str, request_id: str, app_name: str | None) -> Response: + """AC-36 / AC-48: a transitional window is a retrying page, never a 502. + + ``kind`` is :data:`PAGE_DEPLOYING` or :data:`PAGE_RECOVERING`; which one + is the manager's call (T083), not a guess from the failure mode. Both + renderings carry ``Retry-After`` — the page for its own reload, the JSON + for an in-app client that wants to back off. Imported lazily to keep + :mod:`app_proxy.login_handoff` out of this module's import cycle. + """ + from app_proxy.login_handoff import is_navigation + + accept_language = request.headers.get("Accept-Language") + retry_after = {"Retry-After": str(RETRY_FIRST_SECONDS)} + if not is_navigation(request): + return JSONResponse( + error_payload(kind, request_id, accept_language=accept_language), + status_code=json_status(kind), + headers=retry_after, + ) + return HTMLResponse( + render_page( + kind, + app_name=app_name, + square_url=get_config().square_url, + request_id=request_id, + accept_language=accept_language, + ), + status_code=PAGE_HTTP_STATUS, + headers=retry_after, + ) + + +async def forward( + request: Request, + *, + slug: str, + tail: str, + verdict: Verdict, + request_id: str, + started: float | None = None, +) -> Response: + """Everything after "allow": one hop, at most one re-resolve.""" + started = started if started is not None else time.monotonic() + config = get_config() + app_id = verdict.app_id + if not app_id: + # The backend allowed a request but told us nothing to forward to. Not + # a user-visible fault of theirs — render the transient page, log loudly. + log_request( + logger, + request_id=request_id, + slug=slug, + user_id=verdict.visitor_id, + decision=verdict.decision, + reason="allow_without_app_id", + cache_hit=verdict.cache_hit, + upstream_status=None, + latency_ms=(time.monotonic() - started) * 1000, + level=logging.ERROR, + ) + log_fallback(logger, request_id=request_id, slug=slug, kind=PAGE_RECOVERING, reason="allow_without_app_id") + return transition_response(request, PAGE_RECOVERING, request_id, verdict.app_name) + + log_forged_headers(request, request_id, slug) + + upstream_path = strip_entry_prefix(request.url.path, slug, config.entry_prefix) + headers = upstream_headers_for(request, verdict, slug=slug, request_id=request_id) + + client = get_upstream_client() + query = request.url.query + kind = PAGE_RECOVERING + # Why the fallback page was rendered, carried through the loop so the two + # §7 events agree on the cause instead of each guessing it. + fallback_reason = "no_route" + + # An approval-time preview is addressed by session, not by app id: it has + # no desired-state record in the manager (F055 AC-26). Everything else on + # this path — the strip, the injection, the retry, the fallback pages — is + # the same code, which is the point. + route_id = verdict.preview_session or app_id + is_preview = bool(verdict.preview_session) + + for attempt in (0, 1): + upstream = await resolve_upstream(route_id, refresh=attempt == 1, preview=is_preview) + if upstream is None: + break + if upstream.starting: + # A deploy is in flight and nothing serves yet: the honest page is + # 「发布中」, and there is no address to retry against. + kind = PAGE_DEPLOYING + fallback_reason = "deploy_in_flight" + break + + url = httpx.URL(f"{upstream.base_url}{upstream_path}") + if query: + url = url.copy_with(query=query.encode("ascii")) + + try: + # Only forward a request body when the request actually has one. + # For a bodyless request (GET/HEAD/most navigations) httpx would + # still read the ASGI receive channel to drain ``request.stream()``, + # and that read races Starlette's own disconnect-watcher on the SAME + # receive channel. The race intermittently delivers a spurious + # ``http.disconnect`` that cancels ``stream_response`` mid-body, so a + # large upstream response is silently truncated — a 10 MB hosted-app + # page dropped its tail on roughly a third of requests. Passing no + # content leaves the receive channel to the disconnect-watcher alone. + has_body = bool(request.headers.get("content-length") or request.headers.get("transfer-encoding")) + outbound = client.build_request( + request.method, + url, + headers=headers, + content=request.stream() if has_body else None, + ) + response = await client.send(outbound, stream=True) + except (httpx.ConnectError, httpx.ConnectTimeout) as exc: + # Stale address: drop the cached entry and resolve once more. Any + # later failure is the app's problem, not the route table's. + logger.info( + "app_proxy.upstream_retry request_id=%s slug=%s upstream=%s connect_failed=%s attempt=%s", + request_id, + slug, + upstream.base_url, + exc, + attempt, + ) + continue + except (httpx.HTTPError, RuntimeError) as exc: + # RuntimeError covers "stream consumed" — a retry after the request + # body was partially written cannot be replayed honestly. + logger.warning("app_proxy.upstream_error request_id=%s slug=%s error=%s", request_id, slug, exc) + fallback_reason = "upstream_error" + break + + log_request( + logger, + request_id=request_id, + slug=slug, + user_id=verdict.visitor_id, + decision=verdict.decision, + reason=verdict.reason, + cache_hit=verdict.cache_hit, + upstream_status=response.status_code, + latency_ms=(time.monotonic() - started) * 1000, + generation=upstream.generation, + ) + # ``raw``, not ``multi_items()`` or a dict. Both alternatives lose + # information a hosted app depends on: + # + # * a Mapping keeps ONE value per name, and ``Set-Cookie`` is the header + # that is legitimately repeated — an app that sets a session cookie and + # a CSRF cookie in the same response would have one of them silently + # dropped, and the symptom ("cannot log in" / "form POST 403") shows up + # with clean logs on both sides; + # * ``multi_items()`` keeps the duplicates but hands back ``str``, which + # httpx decoded with its own guessed charset; re-encoding that as + # latin-1 for ASGI can raise on a header the upstream sent as UTF-8. + # + # The raw byte pairs are exactly what arrived, so they are exactly what + # leaves. ``raw_headers`` is assigned after construction because + # ``StreamingResponse(headers=...)`` only accepts a Mapping. + proxied = StreamingResponse( + response.aiter_raw(), + status_code=response.status_code, + background=BackgroundTask(response.aclose), + ) + proxied.raw_headers = [ + _response_header(name, value, slug=slug, entry_prefix=config.entry_prefix, upstream_base=upstream.base_url) + for name, value in response.headers.raw + if name.lower().decode("latin-1") not in _RESPONSE_HOP_BY_HOP + ] + return proxied + + log_request( + logger, + request_id=request_id, + slug=slug, + user_id=verdict.visitor_id, + decision=verdict.decision, + reason=fallback_reason, + cache_hit=verdict.cache_hit, + upstream_status=None, + latency_ms=(time.monotonic() - started) * 1000, + ) + log_fallback( + logger, + request_id=request_id, + slug=slug, + kind=kind, + reason=fallback_reason, + prefix=entry_prefix_for(slug, config.entry_prefix), + ) + return transition_response(request, kind, request_id, verdict.app_name) diff --git a/src/app-proxy/app_proxy/recovery_appearance.py b/src/app-proxy/app_proxy/recovery_appearance.py new file mode 100644 index 0000000000..add98462c2 --- /dev/null +++ b/src/app-proxy/app_proxy/recovery_appearance.py @@ -0,0 +1,72 @@ +"""Standalone recovery appearance, matching the existing BiSheng state page. + +The inline SVG is a snapshot of the shared SystemErrorIllustration artwork at +src/frontend/packages/ui/src/components/Illustration/SystemErrorIllustration.tsx. +Keep its geometry in sync when the designer changes that source. Inline assets +let the proxy explain downtime without depending on the frontend being up. +""" + +ILLUSTRATION = """ + +""" + +STYLE = """ +:root { + color-scheme: light dark; + --bg-page: 255 255 255; + --text-1: 29 33 41; + --text-3: 137 143 156; + --border-base: 229 230 233; + --illus-100: 190 218 255; + --illus-300: 106 161 255; + --illus-500: 22 93 255; + --illus-face: 255 255 255; +} +* { box-sizing: border-box; } +body { + margin: 0; min-height: 100vh; min-height: 100dvh; + display: flex; align-items: center; justify-content: center; + padding: 32px 24px; background: rgb(var(--bg-page)); color: rgb(var(--text-1)); + font: 14px/22px -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", + "Hiragino Sans GB", "Microsoft YaHei", sans-serif; +} +.card { width: 100%; max-width: 400px; text-align: center; } +.mark { width: 120px; height: 120px; margin: 0 auto 16px; } +.mark svg { display: block; width: 100%; height: 100%; } +h1 { margin: 0 0 4px; font-size: 16px; line-height: 24px; font-weight: 500; } +p { margin: 0; color: rgb(var(--text-3)); overflow-wrap: anywhere; } +.hint { + margin-top: 32px; padding-top: 16px; border-top: 1px solid rgb(var(--border-base)); + font-size: 12px; line-height: 20px; overflow-wrap: anywhere; +} +@media (max-width: 768px) { + body { font-size: 16px; line-height: 24px; } +} +@media (prefers-color-scheme: dark) { + :root { + --bg-page: 18 18 18; + --text-1: 246 246 246; + --text-3: 146 146 147; + --border-base: 72 72 73; + --illus-face: var(--illus-100); + } +} +""" diff --git a/src/app-proxy/app_proxy/routing.py b/src/app-proxy/app_proxy/routing.py new file mode 100644 index 0000000000..369963bc86 --- /dev/null +++ b/src/app-proxy/app_proxy/routing.py @@ -0,0 +1,140 @@ +"""Where does this app live right now, and what path does it expect? (D5.1 / D5.2) + +Two small, testable pieces sit between the verdict and the forward: + +**Address.** The only source of truth for a live instance is runtime-manager's +route table. Not the container name (app-proxy runs as a host systemd unit on +114 and is not on the docker network at all — 坑 30), and not a published host +port (AC-33 forbids one). The manager answers with the container's address on +the ``bisheng-apps`` bridge, which is host-reachable and externally +unreachable, and means the same thing in both deployment shapes. + +**Path.** ``/apps/{slug}`` is a platform URL layout, not something a hosted app +should have to know: the same source runs at the root under ``bisheng dev``. +So the prefix comes off here and goes back on as ``X-Forwarded-Prefix`` for the +framework to rebuild absolute URLs from (D5.2). +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from urllib.parse import urlsplit + +from app_proxy.clients import ROUTE_PHASE_STARTING, InternalRpcError, get_manager_client + +logger = logging.getLogger(__name__) + +PHASE_RUNNING = "running" +PHASE_STARTING = ROUTE_PHASE_STARTING + + +@dataclass(frozen=True) +class Upstream: + """Where to forward — or, when ``phase`` is ``starting``, why not yet. + + A ``starting`` upstream has no ``base_url``: the manager has a deploy in + flight for this app and nothing serving (T083). It is a distinct object + rather than ``None`` because the caller renders a different page for it + (「发布中」, AC-48) than for "nothing to forward to" (「应用恢复中」, AC-36). + """ + + base_url: str = "" + version_id: str | None = None + generation: int | None = None + cache_hit: bool = False + phase: str = PHASE_RUNNING + + @property + def starting(self) -> bool: + return self.phase == PHASE_STARTING + + +async def resolve_upstream(app_id: str, *, refresh: bool = False, preview: bool = False) -> Upstream | None: + """``None`` = nothing to forward to right now → the "恢复中" page, not an error. + + A stopped app, a crash window and the seconds between two containers all + look the same from here, and all three are legitimately transient. The one + case the manager *can* tell apart — a deploy in flight — comes back as an + :class:`Upstream` whose :attr:`Upstream.starting` is true. + + ``preview=True`` resolves an approval-time preview session instead of an + application (F055 AC-26). A reclaimed or timed-out preview answers 404, + which lands on the same ``None`` — correctly: from here the two are the + same fact, "there is nothing serving at that address right now". + """ + try: + payload = await get_manager_client().route(app_id, refresh=refresh, preview=preview) + except InternalRpcError as exc: + logger.warning("app_proxy.route app_id=%s preview=%s unavailable: %s", app_id, preview, exc) + return None + if not payload: + return None + if not payload.get("upstream"): + return Upstream(phase=str(payload.get("phase") or PHASE_STARTING), cache_hit=bool(payload.get("cache_hit"))) + return Upstream( + base_url=str(payload["upstream"]).rstrip("/"), + version_id=payload.get("version_id"), + generation=payload.get("generation"), + cache_hit=bool(payload.get("cache_hit")), + ) + + +def entry_prefix_for(slug: str, entry_prefix: str = "/apps") -> str: + return f"{entry_prefix.rstrip('/')}/{slug}" + + +def strip_entry_prefix(request_path: str, slug: str, entry_prefix: str = "/apps") -> str: + """``/apps/foo`` → ``/``; ``/apps/foo/`` → ``/``; ``/apps/foo/x`` → ``/x``. + + Both bare forms collapse to ``/`` because an app's index route is ``/`` and + nothing else; forwarding ``""`` produces a malformed request line and + forwarding ``/apps/foo`` makes the app 404 its own home page. + """ + prefix = entry_prefix_for(slug, entry_prefix) + if request_path == prefix: + return "/" + if request_path.startswith(prefix + "/"): + remainder = request_path[len(prefix) :] + return remainder or "/" + # Not ours to strip — forward as-is rather than silently mangling. + return request_path or "/" + + +def restore_entry_prefix(location: str, slug: str, entry_prefix: str = "/apps", upstream_base: str = "") -> str: + """Put the stripped prefix back on a redirect the app sent (the reverse of the strip). + + The app is served at its root, so a ``Location: /login`` it sends means its + own ``/login``. Forwarded unchanged, the browser resolves it against the + platform origin and leaves the app for the platform's ``/login``. nginx's + ``proxy_redirect default`` and Apache's ``ProxyPassReverse`` exist for the + same reason. Rewritten: + + * a path-absolute value outside the prefix — ``/login`` → ``/apps/foo/login``; + * an absolute URL on the upstream's own origin — what a framework builds + from the ``Host`` it was sent — to the same path under the prefix. + + Left alone: values already under this app's prefix (a framework honouring + ``root_path`` / ``X-Forwarded-Prefix``), values under the platform's entry + prefix (a link to another hosted app), protocol-relative and foreign + absolute URLs, and relative references, which the browser already resolves + inside the prefix. + """ + prefix = entry_prefix_for(slug, entry_prefix) + value = location + if upstream_base and "://" in value: + target, origin = urlsplit(value), urlsplit(upstream_base) + if (target.scheme, target.netloc) != (origin.scheme, origin.netloc): + return location + value = target.path or "/" + if target.query: + value += "?" + target.query + if target.fragment: + value += "#" + target.fragment + if not value.startswith("/") or value.startswith("//"): + return location + if value == prefix or value.startswith((prefix + "/", prefix + "?", prefix + "#")): + return value + if value.startswith(entry_prefix.rstrip("/") + "/"): + return value + return prefix + value diff --git a/src/app-proxy/app_proxy/websocket.py b/src/app-proxy/app_proxy/websocket.py new file mode 100644 index 0000000000..1fa2302959 --- /dev/null +++ b/src/app-proxy/app_proxy/websocket.py @@ -0,0 +1,453 @@ +"""WebSocket reverse proxying and the three invariants of design D6. + +The upgrade takes the **same** road as an HTTP request up to the forward: the +verdict is the backend's (``main.handle_entry_ws`` asked it before calling in +here), the header list is built by the one function the HTTP path uses +(:func:`app_proxy.proxy.upstream_headers_for` — strip the whole ``x-bisheng-`` +equivalence class, inject the ten, rewrite ``X-Forwarded-*``, AC-32), the +address is the manager's with the same invalidate-and-retry-once (D5.1). What +is specific to sockets is what happens *after* the handshake, and that is the +three invariants: + +① **The authorisation is fixed at the handshake.** A socket is asked once, so + the answer is given an expiry: ``min(OBO remaining, ws_max_lifetime) + + jitter``, after which the proxy closes it with ``4001``. The frontend + reconnects and gets a fresh verdict — that is the mechanism by which a + long-lived connection follows permission changes at all. +② **A revoke / stop / delete reaches open sockets actively.** The backend posts + to ``/internal/connections/close`` and the registry + (:mod:`app_proxy.connections`) ends the matching sockets. Because that push + is per-process and best-effort, every connection also re-asks the verdict + every ``ws_reauthorize_interval_seconds`` and closes itself on a non-allow — + the bound a customer can rely on whatever the topology. +③ **Re-handshake is normal.** Every close code the proxy emits means + "reconnect" (see ``login_handoff.WS_CLOSE_CODES``); the hosted-app tutorial + and the deploy-hosting skill tell app authors to treat it that way. + +The pumps are two tasks, not a select loop, and the deadline / revocation / +re-authorisation are three more. Whichever finishes first decides the close +code; the rest are cancelled. Both sides are always told the same code, so the +app and the browser have one story about why the connection ended. +""" + +from __future__ import annotations + +import asyncio +import base64 +import json +import logging +import random +import time +from collections.abc import Awaitable, Callable +from typing import Any, Protocol + +import anyio +from starlette.websockets import WebSocket, WebSocketDisconnect, WebSocketState + +from app_proxy import connections +from app_proxy.authz import Verdict, authorize +from app_proxy.config import get_config +from app_proxy.login_handoff import WS_CLOSE_EXPIRED, ws_close_code +from app_proxy.observability import log_fallback, log_request +from app_proxy.pages import PAGE_DEPLOYING, PAGE_RECOVERING +from app_proxy.proxy import log_forged_headers, upstream_headers_for +from app_proxy.routing import resolve_upstream, strip_entry_prefix + +logger = logging.getLogger(__name__) + + +class UpstreamSocket(Protocol): + """The slice of ``websockets.asyncio.client.ClientConnection`` we depend on.""" + + subprotocol: str | None + close_code: int | None + close_reason: str | None + + async def recv(self) -> str | bytes: ... + async def send(self, message: str | bytes) -> None: ... + async def close(self, code: int = 1000, reason: str = "") -> None: ... + + +#: ``(url, headers, subprotocols, connect_timeout) -> socket``. Injectable so +#: the suite can route ``ws://172.20.x.y`` to a loopback server the same way +#: ``UpstreamTransport`` does for HTTP. +Connector = Callable[[str, list[tuple[str, str]], list[str], float], Awaitable[UpstreamSocket]] + +#: Handshake headers the browser sent for *its* connection. They must not be +#: replayed onto ours — the client library generates its own key / version / +#: extensions, and a duplicated ``Sec-WebSocket-Key`` fails the upgrade at the +#: app. ``Sec-WebSocket-Protocol`` travels as the ``subprotocols`` argument. +_HANDSHAKE_HEADERS = frozenset( + { + "sec-websocket-key", + "sec-websocket-version", + "sec-websocket-extensions", + "sec-websocket-protocol", + "sec-websocket-accept", + } +) + +#: Codes a close frame may legitimately carry (RFC 6455 §7.4). Anything else +#: we observed (1005 "no status", 1006 "abnormal", 1015 "TLS") is a local +#: observation, not something either peer sent, and cannot be forwarded as-is. +_SENDABLE_CODES = frozenset({1000, 1001, 1002, 1003, 1007, 1008, 1009, 1010, 1011}) + +_connector: Connector | None = None + + +def set_upstream_connector(connector: Connector | None) -> None: + """Test / bootstrap seam.""" + global _connector + _connector = connector + + +async def _default_connector( + url: str, headers: list[tuple[str, str]], subprotocols: list[str], timeout: float +) -> UpstreamSocket: + from websockets.asyncio.client import connect + + return await connect( + url, + additional_headers=headers, + subprotocols=subprotocols or None, # type: ignore[arg-type] + open_timeout=timeout, + # Never the process's HTTP(S)_PROXY: the upstream is a bridge address + # on this host, and an environment proxy would send it off-box. + proxy=None, + # Hosted apps may push large frames; the browser side is bounded by + # uvicorn's own limit already. + max_size=None, + # Keepalive pings are ours to send: a browser cannot ping, and an app + # that stops answering pongs is an app the visitor should reconnect to. + ping_interval=20, + ping_timeout=20, + ) + + +def get_upstream_connector() -> Connector: + return _connector or _default_connector + + +# --------------------------------------------------------------------------- +# invariant ① — lifetime +# --------------------------------------------------------------------------- + + +def obo_expiry(payload_or_verdict: Any, *, obo_token: str | None) -> float | None: + """When the visitor's on-behalf-of token stops being valid, as epoch seconds. + + The backend states it outright (``obo_expires_at``, alongside + ``ws_max_lifetime_seconds``) since the T081 change to ``authorize_entry``; + an older backend only sends the token, whose ``exp`` claim is readable + without the key — and *only* read, never trusted for anything but + shortening a lifetime. Without a token at all (``obo_secret`` + unconfigured) the cap is the only bound. + """ + explicit = getattr(payload_or_verdict, "obo_expires_at", None) + if explicit is None and isinstance(payload_or_verdict, dict): + explicit = payload_or_verdict.get("obo_expires_at") + if isinstance(explicit, int | float) and not isinstance(explicit, bool): + return float(explicit) + if not obo_token: + return None + try: + segment = obo_token.split(".")[1] + padded = segment + "=" * (-len(segment) % 4) + claims = json.loads(base64.urlsafe_b64decode(padded)) + exp = claims.get("exp") + return float(exp) if isinstance(exp, int | float) else None + except (IndexError, ValueError, TypeError, AttributeError): + return None + + +def connection_lifetime(*, obo_expires_at: float | None, now: float, max_lifetime: float, jitter: float) -> float: + """``min(OBO remaining, cap) + uniform(0, jitter)``, never negative (D6 ①). + + The jitter is added, not subtracted: a burst of tabs opened together must + not all expire together, and shaving the OBO bound would buy nothing — + the app receives the token once, at the handshake, and reads it then. + """ + bound = float(max_lifetime) + if obo_expires_at is not None: + bound = min(bound, obo_expires_at - now) + bound = max(0.0, bound) + return bound + (random.uniform(0, jitter) if jitter > 0 else 0.0) + + +# --------------------------------------------------------------------------- +# the proxied connection +# --------------------------------------------------------------------------- + + +def _sendable(code: int | None, *, abnormal: int) -> int: + """Turn an observed close code into one a close frame may carry.""" + if code is None or code in (1005, 1006, 1015): + return abnormal if code == 1006 else 1000 + if code in _SENDABLE_CODES or 3000 <= code <= 4999: + return code + return 1011 + + +def _upstream_url(base_url: str, path: str, query: str) -> str: + scheme_map = {"http://": "ws://", "https://": "wss://"} + for http_scheme, ws_scheme in scheme_map.items(): + if base_url.startswith(http_scheme): + base_url = ws_scheme + base_url[len(http_scheme) :] + break + return f"{base_url}{path}?{query}" if query else f"{base_url}{path}" + + +async def _connect( + websocket: WebSocket, *, app_id: str, slug: str, request_id: str, headers: list[tuple[str, str]] +) -> tuple[UpstreamSocket | None, str, Any]: + """Resolve + connect with the D5.1 retry. Returns ``(socket, fallback_kind, upstream)``.""" + from websockets.exceptions import InvalidHandshake + + config = get_config() + path = strip_entry_prefix(websocket.url.path, slug, config.entry_prefix) + offered = [p.strip() for p in websocket.headers.get("Sec-WebSocket-Protocol", "").split(",") if p.strip()] + connector = get_upstream_connector() + + for attempt in (0, 1): + upstream = await resolve_upstream(app_id, refresh=attempt == 1) + if upstream is None: + return None, PAGE_RECOVERING, None + if upstream.starting: + return None, PAGE_DEPLOYING, upstream + url = _upstream_url(upstream.base_url, path, websocket.url.query) + try: + socket = await connector(url, headers, offered, config.upstream_connect_timeout_seconds) + except (OSError, TimeoutError) as exc: + # Stale address: drop the cached entry and resolve once more. + logger.info( + "app_proxy.upstream_retry request_id=%s slug=%s protocol=ws upstream=%s connect_failed=%s attempt=%s", + request_id, + slug, + upstream.base_url, + exc, + attempt, + ) + continue + except InvalidHandshake as exc: + # The app answered the upgrade with an HTTP error: no ``/ws`` route, + # or its own refusal. Its own answer, not a stale address — no retry. + logger.warning( + "app_proxy.upstream_error request_id=%s slug=%s protocol=ws upstream=%s handshake_rejected=%s", + request_id, + slug, + upstream.base_url, + exc, + ) + return None, PAGE_RECOVERING, upstream + return socket, "", upstream + return None, PAGE_RECOVERING, None + + +#: What a pump reports when its direction ends: which side hung up, with the +#: code and reason it observed. A send that fails because the *other* side is +#: gone is reported as that side's closure, so the decision below never has to +#: unpick an exception to learn who left. +PumpEnd = tuple[str, int | None, str] + + +async def _pump_client_to_upstream(websocket: WebSocket, upstream: UpstreamSocket) -> PumpEnd: + from websockets.exceptions import ConnectionClosed + + while True: + try: + message = await websocket.receive() + except (WebSocketDisconnect, RuntimeError, OSError, anyio.ClosedResourceError): + # The server side tore the channel down under us (uvicorn on a + # dropped TCP connection, the test client on exit): the browser is + # gone, whatever the protocol layer managed to say about it. + return "client", None, "" + if message["type"] == "websocket.disconnect": + return "client", message.get("code"), message.get("reason") or "" + try: + if message.get("text") is not None: + await upstream.send(message["text"]) + elif message.get("bytes") is not None: + await upstream.send(message["bytes"]) + except ConnectionClosed: + return "upstream", upstream.close_code, upstream.close_reason or "" + + +async def _pump_upstream_to_client(websocket: WebSocket, upstream: UpstreamSocket) -> PumpEnd: + from websockets.exceptions import ConnectionClosed + + while True: + try: + data = await upstream.recv() + except ConnectionClosed: + return "upstream", upstream.close_code, upstream.close_reason or "" + try: + if isinstance(data, str): + await websocket.send_text(data) + else: + await websocket.send_bytes(data) + except (WebSocketDisconnect, RuntimeError, OSError): + return "client", None, "" + + +async def _reauthorize_loop(websocket: WebSocket, *, slug: str, access_token: str | None, request_id: str) -> Verdict: + """Invariant ② safety net: re-ask on an interval, return the first non-allow.""" + interval = get_config().ws_reauthorize_interval_seconds + while True: + await asyncio.sleep(interval) + verdict = await authorize( + slug=slug, + access_token=access_token, + request_id=request_id, + client_ip=websocket.client.host if websocket.client else None, + ) + if not verdict.allowed: + return verdict + + +async def _await_revocation(entry: connections.Connection) -> tuple[int, str]: + """Resolves when the registry asks this connection to end (invariant ②).""" + return await asyncio.shield(entry.closed) + + +async def _close_client(websocket: WebSocket, code: int, reason: str) -> None: + if websocket.client_state == WebSocketState.CONNECTED and websocket.application_state == WebSocketState.CONNECTED: + try: + await websocket.close(code=code, reason=reason) + except (RuntimeError, WebSocketDisconnect, OSError): + # Already gone on the client side — nothing left to tell. + pass + + +async def _close_upstream(upstream: UpstreamSocket, code: int, reason: str) -> None: + try: + await upstream.close(code=code, reason=reason) + except Exception as exc: + logger.debug("app_proxy.ws upstream close failed: %s", exc) + + +async def proxy_websocket( + websocket: WebSocket, + *, + slug: str, + verdict: Verdict, + access_token: str | None, + request_id: str, +) -> None: + """Everything after "allow" for an upgrade: connect, accept, pump, expire.""" + started = time.monotonic() + config = get_config() + app_id = verdict.app_id + user_id = verdict.material.get("X-BiSheng-User-Id") + if not app_id: + log_request( + logger, + request_id=request_id, + slug=slug, + user_id=user_id, + decision=verdict.decision, + reason="allow_without_app_id", + cache_hit=verdict.cache_hit, + upstream_status=None, + latency_ms=(time.monotonic() - started) * 1000, + level=logging.ERROR, + protocol="ws", + ) + await websocket.close(code=ws_close_code(PAGE_RECOVERING)) + return + + log_forged_headers(websocket, request_id, slug) + headers = [ + (name, value) + for name, value in upstream_headers_for(websocket, verdict, slug=slug, request_id=request_id) + if name.lower() not in _HANDSHAKE_HEADERS + ] + + upstream, fallback, route = await _connect( + websocket, app_id=app_id, slug=slug, request_id=request_id, headers=headers + ) + if upstream is None: + code = ws_close_code(fallback) + log_fallback(logger, request_id=request_id, slug=slug, kind=fallback, reason=f"ws_close_{code}") + await websocket.close(code=code) + return + + lifetime = connection_lifetime( + obo_expires_at=obo_expiry(verdict, obo_token=verdict.obo_token), + now=time.time(), + max_lifetime=verdict.ws_max_lifetime_seconds or config.ws_max_lifetime_seconds, + jitter=config.ws_lifetime_jitter_seconds, + ) + + try: + await websocket.accept(subprotocol=upstream.subprotocol) + except (RuntimeError, WebSocketDisconnect): + # The browser went away between the handshake request and our accept. + await _close_upstream(upstream, 1001, "client left") + return + + entry = connections.registry.register(app_id=app_id, user_id=user_id, slug=slug, request_id=request_id) + c2u = asyncio.create_task(_pump_client_to_upstream(websocket, upstream), name="ws-c2u") + u2c = asyncio.create_task(_pump_upstream_to_client(websocket, upstream), name="ws-u2c") + deadline = asyncio.create_task(asyncio.sleep(lifetime), name="ws-deadline") + revoked = asyncio.create_task(_await_revocation(entry), name="ws-revoked") + reauth = asyncio.create_task( + _reauthorize_loop(websocket, slug=slug, access_token=access_token, request_id=request_id), name="ws-reauth" + ) + tasks = {c2u, u2c, deadline, revoked, reauth} + + closed_by, code, reason = "proxy", 1011, "" + try: + done, _ = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + if c2u in done or u2c in done: + closed_by, observed, reason = (c2u if c2u in done else u2c).result() + # A browser that vanished is told nothing; an app that vanished is + # reported to the browser as "nothing answering" (4503, reconnect). + code = _sendable(observed, abnormal=1001 if closed_by == "client" else ws_close_code(PAGE_RECOVERING)) + elif deadline in done: + closed_by, code, reason = "expiry", WS_CLOSE_EXPIRED, "authorization expired; reconnect" + elif revoked in done: + closed_by = "revocation" + code, reason = revoked.result() + elif reauth in done: + closed_by = "reauthorization" + flipped = reauth.result() + code, reason = ws_close_code(flipped.decision), flipped.decision + else: # pragma: no cover - a task raised; treat as an internal error + closed_by = "error" + except Exception: + logger.exception("app_proxy.ws request_id=%s slug=%s pump failed", request_id, slug) + finally: + connections.registry.unregister(entry) + for task in tasks: + if not task.done(): + task.cancel() + # Both sides hear the same code. Order does not matter for correctness, + # but closing the browser side first shortens what the visitor sees. + if closed_by != "client": + await _close_client(websocket, code, reason) + if closed_by != "upstream": + await _close_upstream(upstream, code, reason) + # Retrieve every outcome, cancelled or failed, so nothing is logged as + # "Task exception was never retrieved" after the connection is gone. + await asyncio.gather(*tasks, return_exceptions=True) + log_request( + logger, + request_id=request_id, + slug=slug, + user_id=user_id, + decision=verdict.decision, + # For a socket the interesting "reason" is why it ended, not why it + # was allowed — the verdict's own reason is empty on an allow. + reason=reason, + cache_hit=verdict.cache_hit, + # A WebSocket has no single response status. ``None`` keeps the + # field's meaning ("the app produced no HTTP status here") rather + # than inventing a 101 that no code path ever read. + upstream_status=None, + latency_ms=(time.monotonic() - started) * 1000, + protocol="ws", + generation=getattr(route, "generation", None), + lifetime_s=lifetime, + closed_by=closed_by, + close_code=code, + ) diff --git a/src/app-proxy/pyproject.toml b/src/app-proxy/pyproject.toml new file mode 100644 index 0000000000..02e3dac7de --- /dev/null +++ b/src/app-proxy/pyproject.toml @@ -0,0 +1,64 @@ +[project] +name = "bisheng-app-proxy" +version = "3.0.0" +description = "BiSheng hosted-app entry proxy — session check, header normalisation/injection, fallback pages and HTTP reverse proxy for /apps/{slug}" +requires-python = ">=3.11" +license = { text = "Apache-2.0" } +dependencies = [ + "fastapi>=0.115", + "uvicorn[standard]>=0.30", + "httpx>=0.27", + # The WebSocket *client* for the upstream leg (T080). uvicorn[standard] + # happens to pull the same package for its server side, but a transitive + # dependency is not a contract — a uvicorn release switching to wsproto + # would silently remove our client library. + "websockets>=13", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.23", + "ruff>=0.9.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["app_proxy"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +pythonpath = ["."] +asyncio_mode = "auto" + +[tool.ruff] +target-version = "py311" +line-length = 120 +exclude = [".venv", "__pycache__", "*.egg-info"] + +[tool.ruff.lint] +select = ["E", "W", "F", "I", "B", "C4", "UP", "RUF"] +ignore = [ + "E501", # line length — handled by the formatter + "B008", # FastAPI Depends() in defaults + "B904", # raise ... from None is too strict for existing style + "UP007", # Optional[X] over X | None in runtime annotations + "RUF012", # mutable class attributes + # Docstrings and page copy quote the Chinese product wording verbatim + # ("无权限"、"已停用"), so ASCII-punctuation rewrites would corrupt the quote. + "RUF001", + "RUF002", + "RUF003", +] + +[tool.ruff.lint.isort] +known-first-party = ["app_proxy"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false diff --git a/src/app-proxy/tests/__init__.py b/src/app-proxy/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app-proxy/tests/conftest.py b/src/app-proxy/tests/conftest.py new file mode 100644 index 0000000000..66feac98a9 --- /dev/null +++ b/src/app-proxy/tests/conftest.py @@ -0,0 +1,192 @@ +"""Shared wiring: real app, real HMAC, fake peers. + +Two deliberate choices worth knowing before adding a test here: + +* **Every fixture body imports lazily.** The suite is written test-first, so at + any point during Wave 2 some ``app_proxy.*`` module does not exist yet. A + top-level import would make the *whole package* fail collection and hide the + one test that was supposed to be red. +* **``HTTP(S)_PROXY`` is scrubbed autouse.** Without ``socksio`` installed, a + developer's ``ALL_PROXY`` turns every httpx call in the suite into a + collection-time ERROR that looks nothing like a proxy problem. +""" + +from __future__ import annotations + +import os + +import httpx +import pytest + +from tests.fakes import ( + EchoUpstream, + FakeBackend, + FakeManager, + FrozenClock, + UpstreamTransport, + WsEchoUpstream, + WsUpstreamTransport, +) + +BACKEND_SECRET = "backend-secret-for-tests" +MANAGER_SECRET = "manager-secret-for-tests" +BACKEND_BASE = "http://backend.test" +MANAGER_BASE = "http://manager.test" + +#: Headers a browser sends on a top-level navigation. The page / handoff branch +#: keys off exactly these (D7). +NAVIGATE_HEADERS = {"Sec-Fetch-Mode": "navigate", "Sec-Fetch-Dest": "document", "Accept": "text/html"} +#: Headers an in-app ``fetch()`` sends. Must never receive HTML. +XHR_HEADERS = {"Sec-Fetch-Mode": "cors", "Sec-Fetch-Dest": "empty", "Accept": "application/json"} + + +@pytest.fixture(autouse=True) +def _no_proxy_env(monkeypatch): + for name in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"): + monkeypatch.delenv(name, raising=False) + os.environ.pop(name, None) + + +@pytest.fixture +def frozen_clock() -> FrozenClock: + return FrozenClock() + + +@pytest.fixture +def fake_backend() -> FakeBackend: + return FakeBackend(BACKEND_SECRET) + + +@pytest.fixture +def fake_manager() -> FakeManager: + return FakeManager(MANAGER_SECRET) + + +@pytest.fixture +def echo_upstream() -> EchoUpstream: + return EchoUpstream() + + +@pytest.fixture +def upstream_transport(echo_upstream) -> UpstreamTransport: + from tests.fakes import DEFAULT_UPSTREAM + + transport = UpstreamTransport() + transport.register(DEFAULT_UPSTREAM, echo_upstream) + return transport + + +@pytest.fixture(scope="session") +def ws_echo_upstream() -> WsEchoUpstream: + """One real WebSocket echo server for the whole session (a thread + a port). + + Session-scoped because starting a thread and binding a port per test is + slow and pointless; per-test state (``handshakes``, ``reject_status``) is + reset by :func:`ws_upstream`. + """ + server = WsEchoUpstream().start() + yield server + server.stop() + + +@pytest.fixture +def ws_upstream(ws_echo_upstream) -> WsEchoUpstream: + ws_echo_upstream.handshakes.clear() + ws_echo_upstream.reject_status = None + return ws_echo_upstream + + +@pytest.fixture +def ws_transport(ws_upstream) -> WsUpstreamTransport: + from tests.fakes import DEFAULT_UPSTREAM + + transport = WsUpstreamTransport() + transport.register(DEFAULT_UPSTREAM, ws_upstream) + return transport + + +@pytest.fixture(autouse=True) +def wired(fake_backend, fake_manager, upstream_transport, ws_transport, frozen_clock): + """Point the process at the fakes, then put it back. + + Autouse because a test that forgot it would silently reach for + ``127.0.0.1:7860`` and fail with a connection error three layers away from + the cause. + """ + from app_proxy import clients + from app_proxy.config import Config, set_config + + config = Config( + backend_base=BACKEND_BASE, + manager_base=MANAGER_BASE, + backend_secret=BACKEND_SECRET, + manager_secret=MANAGER_SECRET, + # Real seconds, not the frozen clock: the deadline is an ``asyncio`` + # timer. Tests that assert expiry shrink this to a fraction of a second. + ws_max_lifetime_seconds=3600.0, + ws_lifetime_jitter_seconds=0.0, + ws_reauthorize_interval_seconds=3600.0, + ) + set_config(config) + + backend_client = clients.BackendAuthzClient( + BACKEND_BASE, + BACKEND_SECRET, + ttl_seconds=config.authz_ttl_seconds, + clock=frozen_clock, + transport=httpx.ASGITransport(app=fake_backend), + ) + manager_client = clients.ManagerRouteClient( + MANAGER_BASE, + MANAGER_SECRET, + key_id="entry-reader", + ttl_seconds=config.route_ttl_seconds, + clock=frozen_clock, + transport=httpx.ASGITransport(app=fake_manager), + ) + clients.set_clients(backend_client, manager_client) + + try: + from app_proxy import proxy + + proxy.set_upstream_transport(upstream_transport) + except ImportError: # pragma: no cover - only before T045 lands + proxy = None + try: + from app_proxy import connections, websocket + + websocket.set_upstream_connector(ws_transport) + connections.registry.clear() + except ImportError: # pragma: no cover - only before T080 lands + websocket = None + + yield config + + clients.set_clients(None, None) + set_config(None) + if proxy is not None: + proxy.set_upstream_transport(None) + if websocket is not None: + websocket.set_upstream_connector(None) + connections.registry.clear() + + +@pytest.fixture +def proxy_client(wired): + """``TestClient`` over the real ASGI app. + + Not used as a context manager on purpose: running the lifespan would close + the fake-backed clients that ``wired`` just installed. + """ + from starlette.testclient import TestClient + + from app_proxy.main import app + + return TestClient(app) + + +@pytest.fixture +def logged_in(proxy_client): + """A client carrying the platform session cookie (host-only, ``path=/``).""" + proxy_client.cookies.set("access_token_cookie", "jwt-token-for-tests") + return proxy_client diff --git a/src/app-proxy/tests/fakes.py b/src/app-proxy/tests/fakes.py new file mode 100644 index 0000000000..4b36d652c1 --- /dev/null +++ b/src/app-proxy/tests/fakes.py @@ -0,0 +1,493 @@ +"""Programmable stand-ins for the three things app-proxy talks to. + +Each fake is a real ASGI app reached over ``httpx.ASGITransport``, not a +monkeypatched method. That costs a few lines and buys the two properties the +suite actually depends on: + +* the **HMAC signing path runs for real** in every test, so a signing + regression fails everywhere instead of nowhere; +* the **upstream sees real bytes**, so header strip / injection assertions are + made against what the hosted app would genuinely receive — the whole point of + AC-32. + +Header names here are written out as literals on purpose. A fake that imported +``app_proxy.headers`` would agree with the implementation by construction and +prove nothing. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import hmac +import json +import threading +from typing import Any + +import httpx +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +#: What a happy-path ``authorize`` hands back. Chinese values are **raw UTF-8** +#: here: JSON carries them fine and app-proxy is the layer that has to make +#: them latin-1 safe (坑 9). ``test_headers`` also covers the pre-encoded +#: variant, so both backend conventions are pinned. +DEFAULT_HEADER_MATERIAL = { + "X-BiSheng-User-Id": "42", + "X-BiSheng-User-Name": "张三", + "X-BiSheng-Tenant-Id": "1", + "X-BiSheng-Dept-Id": "BS@d4f1", + "X-BiSheng-Dept-Name": "研发中心", + "X-BiSheng-Dept-Path": "毕昇科技/研发中心", + "X-BiSheng-Subject-Kind": "human", + "X-BiSheng-App-Id": "app-0001", +} + +DEFAULT_APP_ID = "app-0001" +DEFAULT_UPSTREAM = "http://172.20.0.7:8080" + +#: What the manager would answer while a deploy is in flight and nothing serves +#: yet — the 409 ``deploying`` envelope reserved in contracts-runtime-manager.md +#: §9. The manager does not emit it today (its deploy is synchronous, so no +#: such window exists); the proxy consumes it so the day it appears the page is +#: 「发布中」 and not 「恢复中」. Programmed into :class:`FakeManager` as a +#: route value without an ``upstream`` so a script can say "starting, starting, +#: then ready" the same way it says "refused, then ready". +DEPLOYING_ROUTE: dict[str, Any] = {"phase": "starting"} + + +def sign(method: str, path: str, raw_body: bytes, secret: str) -> str: + """Independent re-implementation of the signing string (not an import).""" + msg = f"{method.upper()}\n{path}\n".encode() + (raw_body or b"") + return hmac.new(secret.encode("utf-8"), msg, hashlib.sha256).hexdigest() + + +class FrozenClock: + """Monotonic clock under test control — TTL assertions without sleeping.""" + + def __init__(self, start: float = 1000.0) -> None: + self.now = start + + def __call__(self) -> float: + return self.now + + def advance(self, seconds: float) -> None: + self.now += seconds + + +def allow_response( + *, + material: dict[str, str] | None = None, + app_id: str = DEFAULT_APP_ID, + obo_token: str = "obo.jwt.token", + app_name: str = "问卷小助手", + owner_name: str = "李四", +) -> dict[str, Any]: + headers = dict(DEFAULT_HEADER_MATERIAL if material is None else material) + headers.setdefault("X-BiSheng-App-Id", app_id) + return { + "decision": "allow", + "headers": headers, + "obo_token": obo_token, + "app_state": "online", + "app_id": app_id, + "app_name": app_name, + "owner_name": owner_name, + } + + +def deny_response( + decision: str, + *, + app_name: str | None = "问卷小助手", + owner_name: str | None = "李四", + app_state: str | None = None, +) -> dict[str, Any]: + """A non-allow verdict. + + ``not_found`` carries no app name or owner by construction — leaking either + would defeat the "draft / pending / deleted / never existed look identical" + rule (AC-29). + """ + if decision == "not_found": + app_name = owner_name = None + return { + "decision": decision, + "headers": {}, + "obo_token": None, + "app_state": app_state, + "app_name": app_name, + "owner_name": owner_name, + } + + +class FakeBackend: + """The internal authorize endpoint. + + Knobs: :attr:`response` (verdict to hand back), :attr:`status_code` + (simulate 5xx), :attr:`fail` (simulate a transport error / timeout). + :attr:`calls` is what the cache assertions count. + """ + + path = "/api/v1/internal/app-proxy/authorize" + preview_path = "/api/v1/internal/app-proxy/authorize-preview" + + def __init__(self, secret: str) -> None: + self.secret = secret + self.response: dict[str, Any] = allow_response() + #: Verdict for ``authorize-preview``. ``None`` means "answer with + #: :attr:`response`", which keeps every existing test unchanged; a test + #: about the preview entry sets it explicitly. + self.preview_response: dict[str, Any] | None = None + self.status_code = 200 + self.fail: Exception | None = None + self.calls: list[dict[str, Any]] = [] + #: Paths the fake was asked for, so a test can prove the *preview* + #: endpoint was called and not the application one. + self.paths: list[str] = [] + #: When true, wrap the body in the platform's ``{status_code, data}`` + #: envelope — the endpoint may end up going through resp_200(). + self.envelope = False + + async def __call__(self, scope, receive, send) -> None: + request = Request(scope, receive) + raw = await request.body() + if self.fail is not None: + raise self.fail + expected = sign(request.method, request.url.path, raw, self.secret) + provided = request.headers.get("X-Signature", "") + if not hmac.compare_digest(expected, provided): + response: Response = JSONResponse({"detail": "bad signature"}, status_code=401) + await response(scope, receive, send) + return + self.calls.append(json.loads(raw) if raw else {}) + self.paths.append(request.url.path) + body = self.response + if request.url.path == self.preview_path and self.preview_response is not None: + body = self.preview_response + if self.envelope: + body = {"status_code": 200, "status_message": "SUCCESS", "data": body} + response = JSONResponse(body, status_code=self.status_code) + await response(scope, receive, send) + + +class FakeManager: + """The backend v2 route projection endpoint. + + :attr:`script` turns a single app_id into a queue of answers so a test can + say "first call fails, second succeeds" — which is exactly the D5.1 + invalidate-and-retry-once path. + """ + + def __init__(self, secret: str) -> None: + self.secret = secret + self.routes: dict[str, dict[str, Any] | None] = { + DEFAULT_APP_ID: {"upstream": DEFAULT_UPSTREAM, "version_id": "v1", "generation": 1}, + } + self.script: dict[str, list[dict[str, Any] | None]] = {} + self.status_code = 200 + self.fail: Exception | None = None + self.calls: list[str] = [] + self.paths: list[str] = [] + + async def __call__(self, scope, receive, send) -> None: + request = Request(scope, receive) + raw = await request.body() + if self.fail is not None: + raise self.fail + timestamp = request.headers.get("X-Runtime-Timestamp", "") + nonce = request.headers.get("X-Runtime-Nonce", "") + key = request.headers.get("X-Runtime-Key-Id", "") + message = f"v2\n{request.method}\n{request.url.path}\n{hashlib.sha256(raw).hexdigest()}\n{timestamp}\n{nonce}\n{key}".encode() + expected = hmac.new(self.secret.encode(), message, hashlib.sha256).hexdigest() + if ( + not key + or not nonce + or not timestamp + or not hmac.compare_digest(expected, request.headers.get("X-Runtime-Signature", "")) + ): + await JSONResponse({"detail": "bad signature"}, status_code=401)(scope, receive, send) + return + app_id = request.url.path.rsplit("/", 1)[-1] + self.calls.append(app_id) + self.paths.append(request.url.path) + if self.status_code >= 400: + response = JSONResponse({"code": "internal_error"}, status_code=self.status_code) + await response(scope, receive, send) + return + + queued = self.script.get(app_id) + route = queued.pop(0) if queued else self.routes.get(app_id) + if route is None: + response = JSONResponse({"code": "not_found", "message": "no live instance"}, status_code=404) + elif not route.get("upstream"): + # The reserved shape for an in-flight deploy: an error envelope, not + # a route (contracts-runtime-manager.md §9 — see DEPLOYING_ROUTE). + response = JSONResponse( + {"detail": {"code": "deploying", "message": "a deploy is in flight", "phase": "starting"}}, + status_code=409, + ) + else: + from urllib.parse import urlsplit + + url = urlsplit(route["upstream"]) + response = JSONResponse( + { + **route, + "decision": "allow", + "target": {"host": url.hostname, "port": url.port or 80}, + "route_epoch": route.get("generation", 1), + } + ) + await response(scope, receive, send) + + +class EchoUpstream: + """A hosted app that reports exactly what reached it. + + Returns ``{method, path, query, headers: [[name, value], ...], body}``. + Header pairs stay a list, not a dict: duplicates and casing are part of + what the strip tests assert. + """ + + def __init__(self) -> None: + self.status_code = 200 + self.requests: list[dict[str, Any]] = [] + #: Bytes to stream back in chunks — used by the streaming passthrough test. + self.stream_chunks: list[bytes] | None = None + #: Extra response headers as raw pairs, so a test can send the SAME name + #: twice (``Set-Cookie``) — the case a Mapping cannot express. + self.response_headers: list[tuple[bytes, bytes]] = [] + + async def __call__(self, scope, receive, send) -> None: + request = Request(scope, receive) + body = await request.body() + record = { + "method": request.method, + "path": request.url.path, + "query": request.url.query, + "headers": [[k.decode("latin-1"), v.decode("latin-1")] for k, v in scope["headers"]], + "body": body.decode("utf-8", "replace"), + } + self.requests.append(record) + + if self.stream_chunks is not None: + await send( + { + "type": "http.response.start", + "status": self.status_code, + "headers": [(b"content-type", b"application/octet-stream"), *self.response_headers], + } + ) + for chunk in self.stream_chunks: + await send({"type": "http.response.body", "body": chunk, "more_body": True}) + await send({"type": "http.response.body", "body": b"", "more_body": False}) + return + + response = JSONResponse(record, status_code=self.status_code) + response.raw_headers.extend(self.response_headers) + await response(scope, receive, send) + + +class UpstreamTransport(httpx.AsyncBaseTransport): + """Routes ``http://host:port`` to an ASGI app — or refuses the connection. + + ``refuse`` is how the suite reproduces the case D5.1 is written for: a + cached upstream address that no longer answers because the container was + replaced or died. + """ + + def __init__(self, apps: dict[str, Any] | None = None) -> None: + self.apps: dict[str, Any] = apps or {} + self.refuse: set[str] = set() + self.attempts: list[str] = [] + + def register(self, base_url: str, app: Any) -> None: + self.apps[base_url.rstrip("/")] = app + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + origin = f"{request.url.scheme}://{request.url.netloc.decode('ascii')}" + self.attempts.append(origin) + if origin in self.refuse: + raise httpx.ConnectError("connection refused", request=request) + app = self.apps.get(origin) + if app is None: + raise httpx.ConnectError(f"no route to {origin}", request=request) + return await httpx.ASGITransport(app=app).handle_async_request(request) + + +class WsEchoUpstream: + """A hosted app speaking WebSocket, on a real loopback port. + + Not an in-memory stub: the proxy's real ``websockets`` client performs a + real handshake against it, so what :attr:`handshakes` records is exactly + what a hosted app would see — the whole point of the strip / inject + assertions (AC-32), same as :class:`EchoUpstream` for HTTP. + + Runs on its own thread with its own event loop because ``TestClient`` drives + the proxy from a portal thread of its own; the two must not share a loop. + + Frames are echoed back. A text frame ``close::`` makes the + server close the connection with that code — how the suite drives + "upstream hangs up" without a second control channel. + """ + + def __init__(self) -> None: + self.handshakes: list[dict[str, Any]] = [] + #: Subprotocols the app is willing to speak; the first one the client + #: offers that is in here wins (RFC 6455 §4.2.2 order). + self.subprotocols: list[str] = ["chat", "json"] + #: When set, the handshake is refused with this HTTP status. + self.reject_status: int | None = None + self.port: int = 0 + self._loop: asyncio.AbstractEventLoop | None = None + self._thread: threading.Thread | None = None + self._ready = threading.Event() + self._stop: asyncio.Event | None = None + + # -- lifecycle ----------------------------------------------------- + def start(self) -> WsEchoUpstream: + self._thread = threading.Thread(target=self._run, name="ws-echo-upstream", daemon=True) + self._thread.start() + if not self._ready.wait(5): # pragma: no cover - only on a wedged host + raise RuntimeError("WsEchoUpstream did not start") + return self + + def stop(self) -> None: + if self._loop is not None and self._stop is not None: + self._loop.call_soon_threadsafe(self._stop.set) + if self._thread is not None: + self._thread.join(5) + + def _run(self) -> None: + asyncio.run(self._main()) + + async def _main(self) -> None: + from websockets.asyncio.server import serve + + self._loop = asyncio.get_running_loop() + self._stop = asyncio.Event() + async with serve( + self._handler, + "127.0.0.1", + 0, + subprotocols=self.subprotocols, + select_subprotocol=self._select_subprotocol, + process_request=self._process_request, + ) as server: + self.port = server.sockets[0].getsockname()[1] + self._ready.set() + await self._stop.wait() + + # -- protocol -------------------------------------------------------- + def _select_subprotocol(self, connection, offered): + """First offered protocol the app speaks; none offered is fine too — + a real app does not fail the handshake over an absent header.""" + for candidate in offered: + if candidate in self.subprotocols: + return candidate + return None + + async def _process_request(self, connection, request): + if self.reject_status is not None: + from http import HTTPStatus + + return connection.respond(HTTPStatus(self.reject_status), "refused by the app\n") + return None + + async def _handler(self, connection) -> None: + from websockets.exceptions import ConnectionClosed + + record: dict[str, Any] = { + "path": connection.request.path, + "headers": [[k, v] for k, v in connection.request.headers.raw_items()], + "subprotocol": connection.subprotocol, + "close_code": None, + } + self.handshakes.append(record) + try: + async for message in connection: + if isinstance(message, str) and message.startswith("close:"): + _, code, reason = message.split(":", 2) + await connection.close(int(code), reason) + break + await connection.send(message) + except ConnectionClosed: + pass + finally: + await connection.wait_closed() + record["close_code"] = connection.close_code + + @property + def base_url(self) -> str: + return f"ws://127.0.0.1:{self.port}" + + +class WsUpstreamTransport: + """The WebSocket twin of :class:`UpstreamTransport`: origin → real server, or refuse. + + Plugged in through ``app_proxy.websocket.set_upstream_connector``. It + rewrites only the authority of the URL the proxy built and hands the rest + (path, query, headers, subprotocols) to the real client, so the proxy's + own URL construction is what gets exercised. + """ + + def __init__(self) -> None: + self.servers: dict[str, WsEchoUpstream] = {} + self.refuse: set[str] = set() + self.attempts: list[str] = [] + + def register(self, base_url: str, server: WsEchoUpstream) -> None: + self.servers[base_url.rstrip("/").replace("http://", "ws://").replace("https://", "wss://")] = server + + async def __call__(self, url: str, headers: list[tuple[str, str]], subprotocols: list[str], timeout: float): + from urllib.parse import urlsplit, urlunsplit + + from websockets.asyncio.client import connect + + parts = urlsplit(url) + origin = f"{parts.scheme}://{parts.netloc}" + self.attempts.append(origin) + if origin in self.refuse: + raise OSError(f"connection refused: {origin}") + server = self.servers.get(origin) + if server is None: + raise OSError(f"no route to {origin}") + real = urlunsplit(("ws", f"127.0.0.1:{server.port}", parts.path, parts.query, "")) + return await connect( + real, + additional_headers=headers, + subprotocols=subprotocols or None, + open_timeout=timeout, + proxy=None, + max_size=None, + ) + + +#: A preview session id shaped like the one the platform mints (uuid4 hex). +DEFAULT_PREVIEW_SESSION = "2f1c8d4ab0e5461d9a77c3e5d8b21f40" + + +def preview_allow_response( + *, + session: str = DEFAULT_PREVIEW_SESSION, + material: dict[str, str] | None = None, + app_id: str = DEFAULT_APP_ID, + obo_token: str = "obo.preview.token", +) -> dict[str, Any]: + """What ``authorize-preview`` hands back for the approver it belongs to. + + Note what is **absent**: no ``owner_name``. A preview refusal must not name + anybody — the visitor has not been told the application exists. + """ + headers = dict(DEFAULT_HEADER_MATERIAL if material is None else material) + headers.setdefault("X-BiSheng-App-Id", app_id) + return { + "decision": "allow", + "headers": headers, + "obo_token": obo_token, + "app_state": "draft", + "app_id": app_id, + "app_name": "问卷小助手", + "preview_session": session, + } diff --git a/src/app-proxy/tests/test_authz_and_fallback.py b/src/app-proxy/tests/test_authz_and_fallback.py new file mode 100644 index 0000000000..1d7d992094 --- /dev/null +++ b/src/app-proxy/tests/test_authz_and_fallback.py @@ -0,0 +1,203 @@ +"""AC-26 / AC-28 / AC-29 / AC-30 / AC-12 — the verdict, its cache, and the four pages. + +Two rules run through the whole file: + +* **Fail closed.** Every way of not getting an answer (timeout, 5xx, an + unparsable body, a decision string we have never heard of) ends in a refusal. + A proxy that "carries on" when the permission engine is down is the one bug + class that silently voids AC-12 for as long as nobody notices. +* **Pages, not errors.** A visitor who is refused sees a page that explains + what to do next and offers the square — never a 404/502 shell. The status + code split is deliberate: navigations get 200 + HTML (a QR-code scan must not + land on a browser error page, design §7 步 7 spells out "不是 502 / 404"), + programmatic requests get JSON + the true status. +""" + +from __future__ import annotations + +import httpx + +from tests.conftest import NAVIGATE_HEADERS +from tests.fakes import DEFAULT_APP_ID, DEFAULT_UPSTREAM, allow_response, deny_response + + +class TestAuthzCache: + def test_positive_authorization_is_rechecked_for_every_request( + self, logged_in, fake_backend, fake_manager, frozen_clock + ): + """F054 AC-70: fresh identity decisions; route caching remains separate.""" + for _ in range(3): + logged_in.get("/apps/foo/", headers=NAVIGATE_HEADERS) + assert len(fake_backend.calls) == 3 + + logged_in.get("/apps/bar/", headers=NAVIGATE_HEADERS) + assert len(fake_backend.calls) == 4 + + logged_in.cookies.set("access_token_cookie", "a-different-session") + logged_in.get("/apps/foo/", headers=NAVIGATE_HEADERS) + assert len(fake_backend.calls) == 5 + + def test_visibility_revoke_effective_on_next_request(self, logged_in, fake_backend, frozen_clock): + """A prior allow cannot delay a new backend denial.""" + response = logged_in.get("/apps/foo/", headers=NAVIGATE_HEADERS) + assert response.status_code == 200 + + fake_backend.response = deny_response("forbidden") + assert "无访问权限" in logged_in.get("/apps/foo/", headers=NAVIGATE_HEADERS).text + assert len(fake_backend.calls) == 2 + + def test_no_cookie_still_asks_backend_once_and_caches(self, proxy_client, fake_backend): + """Anonymous traffic must not become a free RPC amplifier.""" + fake_backend.response = deny_response("login") + proxy_client.get("/apps/foo/", headers=NAVIGATE_HEADERS) + proxy_client.get("/apps/foo/", headers=NAVIGATE_HEADERS) + assert len(fake_backend.calls) == 1 + + def test_bearer_token_is_accepted_as_session(self, proxy_client, fake_backend): + """``_extract_http_access_token`` takes cookie **or** Bearer; so do we.""" + proxy_client.get("/apps/foo/", headers={**NAVIGATE_HEADERS, "Authorization": "Bearer platform-jwt"}) + assert fake_backend.calls[0]["access_token"] == "platform-jwt" + + +class TestAllow: + def test_allow_forwards(self, logged_in, echo_upstream): + response = logged_in.get("/apps/foo", headers=NAVIGATE_HEADERS) + assert response.status_code == 200 + assert echo_upstream.requests, "an allowed request must reach the app" + + def test_slug_and_app_id_are_carried_to_the_right_peers(self, logged_in, fake_backend, fake_manager): + """The backend is asked by **slug**, the manager by **app_id**. + + They are different keys on purpose: the slug is the product-visible URL + (AC-25), the app id is the internal identity that survives a rename. + """ + logged_in.get("/apps/foo", headers=NAVIGATE_HEADERS) + assert fake_backend.calls[0]["slug"] == "foo" + assert fake_manager.calls == [DEFAULT_APP_ID] + + +class TestFallbackPages: + def test_forbidden_page_content(self, logged_in, fake_backend): + """AC-28: name + owner + what to do + square. No apply-for-access in this version.""" + fake_backend.response = deny_response("forbidden", app_name="问卷小助手", owner_name="李四") + response = logged_in.get("/apps/foo", headers=NAVIGATE_HEADERS) + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/html") + body = response.text + assert "问卷小助手" in body + assert "李四" in body + assert "无访问权限" in body + # Never "租户管理员": multi_tenant.enabled is off in a standard install, + # so that person does not exist (Root cannot be granted one, 19204) and + # this process cannot read the switch to find out. See pages.py. + assert "平台管理员" in body + assert "租户" not in body + assert "返回广场" in body + assert "申请" not in body, "本版无在线申请入口" + + def test_stopped_page_content(self, logged_in, fake_backend): + """AC-29: only visible-to-me apps ever show this page — the backend + decides that; here we pin the copy.""" + fake_backend.response = deny_response("stopped", app_state="stopped") + body = logged_in.get("/apps/foo", headers=NAVIGATE_HEADERS).text + assert "已停用" in body + assert "问卷小助手" in body + assert "租户" not in body + assert "返回广场" in body + + def test_not_found_page_for_draft_pending_deleted_and_unknown(self, logged_in, fake_backend): + """AC-29 anti-enumeration: four different truths, one indistinguishable page. + + Byte-for-byte identical, because a difference in length or wording is + an oracle for "does this app exist". + """ + rendered = [] + for _ in range(4): + fake_backend.response = deny_response("not_found") + rendered.append(logged_in.get("/apps/foo", headers=NAVIGATE_HEADERS).text) + # New session each round so the 3s authz cache cannot be what makes + # the four renders identical. + logged_in.cookies.set("access_token_cookie", f"session-{len(rendered)}") + + assert len(set(rendered)) == 1 + assert "不存在或未上线" in rendered[0] + assert "返回广场" in rendered[0] + assert "问卷小助手" not in rendered[0], "must not leak the name of an app you cannot see" + + def test_unknown_slug_shape_is_not_found_without_an_rpc(self, logged_in, fake_backend): + """A path that cannot be a slug is answered locally — no oracle, no load.""" + response = logged_in.get("/apps/..%2f..%2fetc%2fpasswd", headers=NAVIGATE_HEADERS) + assert response.status_code in (200, 404) + assert fake_backend.calls == [] + + def test_not_enabled_guide_page_not_404_or_5xx(self, logged_in, fake_backend): + """AC-30: the layer is not installed → guidance, explicitly not 404/5xx.""" + fake_backend.response = deny_response("not_enabled") + response = logged_in.get("/apps/foo", headers=NAVIGATE_HEADERS) + assert response.status_code == 200 + assert "未启用应用工场" in response.text + assert "平台管理员" in response.text + + def test_pages_are_self_contained(self, logged_in, fake_backend): + """No CDN, no fonts, no logo URL: this page is what the user sees when + the platform is already half-broken.""" + fake_backend.response = deny_response("forbidden") + body = logged_in.get("/apps/foo", headers=NAVIGATE_HEADERS).text + assert "http://" not in body.replace("http://www.w3.org", "") + assert "https://" not in body.replace("https://www.w3.org", "") + assert " None: + paths = {getattr(route, "path", "") for route in app.routes} + assert any(p.endswith("/{slug}") for p in paths) + assert any(p.endswith("/{tail:path}") for p in paths) + + +def test_websocket_routes_registered() -> None: + ws_routes = [r for r in app.routes if type(r).__name__ == "WebSocketRoute"] + assert len(ws_routes) == 2, f"expected both WS entry routes, got {ws_routes}" + + +def test_healthz_registered() -> None: + assert any(getattr(route, "path", "") == "/healthz" for route in app.routes) diff --git a/src/app-proxy/tests/test_f114_v2_only.py b/src/app-proxy/tests/test_f114_v2_only.py new file mode 100644 index 0000000000..997de4b536 --- /dev/null +++ b/src/app-proxy/tests/test_f114_v2_only.py @@ -0,0 +1,20 @@ +"""Entry routing uses backend v2 projections without a legacy manager fallback.""" + +import pytest + +from app_proxy.clients import ManagerRouteClient +from app_proxy.config import Config + + +@pytest.fixture(autouse=True) +def wired(): + yield + + +def test_entry_default_and_explicit_old_protocol(): + assert Config().protocol_version == 2 + for value in (1, 0, 3): + with pytest.raises(ValueError, match="v2"): + Config(protocol_version=value) + with pytest.raises(ValueError, match="v2"): + ManagerRouteClient("http://backend", "", protocol_version=value) diff --git a/src/app-proxy/tests/test_headers.py b/src/app-proxy/tests/test_headers.py new file mode 100644 index 0000000000..13173a0bb5 --- /dev/null +++ b/src/app-proxy/tests/test_headers.py @@ -0,0 +1,248 @@ +"""AC-31 / AC-32 — what reaches the hosted app, and what must never reach it. + +The single most important file in this package. CVE-2025-64484 (oauth2-proxy, +CVSS 8.5) was an incomplete version of exactly this code: it stripped the +hyphenated spelling only, and an authenticated user could re-assert identity +with the underscore variant because WSGI-style frameworks fold both onto the +same key. What we host is uncontrolled Python that trusts ``X-BiSheng-*`` +unconditionally, so "strip the ten names we know" is not a defence. +""" + +from __future__ import annotations + +from urllib.parse import unquote + +import pytest + +from app_proxy.headers import ( + INJECTED_HEADER_NAMES, + build_injected_headers, + encode_header_value, + normalize_header_name, + strip_platform_headers, +) +from tests.fakes import DEFAULT_HEADER_MATERIAL + +#: Every spelling a client might use to smuggle an identity header in. The +#: middle four are the CVE: same header as far as the application is concerned. +FORGED_VARIANTS = [ + ("X-BiSheng-User-Id", "1"), + ("X_BiSheng_User_Id", "1"), + ("x-bisheng-user-id", "1"), + ("X-BISHENG-USER-ID", "1"), + ("x_bisheng_USER_name", "root"), + ("X-BiSheng_Tenant-Id", "999"), + ("x-bisheng-subject-kind", "service_account"), + ("X_BISHENG_ACCESS_TOKEN", "forged.jwt"), + ("x-BiSheng-Dept-Path", "/root"), + ("X-Bisheng-Anything-New-We-Add-Later", "boom"), +] + + +def _names(pairs) -> set[str]: + return {name.lower() for name, _ in pairs} + + +class TestNormalisation: + @pytest.mark.parametrize( + ("raw", "expected"), + [ + ("X-BiSheng-User-Id", "x-bisheng-user-id"), + ("X_BiSheng_User_Id", "x-bisheng-user-id"), + ("x_bisheng_user_id", "x-bisheng-user-id"), + ("X-BISHENG-USER-ID", "x-bisheng-user-id"), + ], + ) + def test_underscore_hyphen_case_collapse_to_one_name(self, raw, expected): + assert normalize_header_name(raw) == expected + + +class TestStrip: + def test_strip_all_x_bisheng_equivalence_class(self): + """AC-32: prefix match **after** normalisation, not a name allow-list. + + The last variant is a header name that does not exist today. It must + still be dropped — otherwise every future injected header is a + vulnerability until someone remembers to add it to a list. + """ + kept = strip_platform_headers([*FORGED_VARIANTS, ("Accept", "text/html"), ("Cookie", "a=b")]) + for name, _ in FORGED_VARIANTS: + assert normalize_header_name(name) not in _names(kept), f"{name} survived the strip" + assert "accept" in _names(kept) + + def test_non_platform_headers_survive_untouched(self): + """Over-stripping breaks real apps: only our namespace goes.""" + inbound = [ + ("Accept-Language", "zh-CN"), + ("User-Agent", "Mozilla/5.0"), + ("X-Requested-With", "XMLHttpRequest"), + ("X-Custom-App-Header", "keep-me"), + ("Authorization", "Bearer app-own-token"), + ] + kept = strip_platform_headers(inbound) + assert _names(kept) == {name.lower() for name, _ in inbound} + + def test_forged_header_has_no_effect_on_upstream(self): + """Forge + inject: the real visitor's values are the only ones left.""" + inbound = [("X_BiSheng_User_Id", "1"), ("x-bisheng-user-name", "root"), ("Accept", "*/*")] + upstream = strip_platform_headers(inbound) + build_injected_headers( + DEFAULT_HEADER_MATERIAL, + slug="foo", + request_id="req-1", + obo_token="obo.jwt", + proto="https", + host="bisheng.example.com", + ) + by_name = {} + for name, value in upstream: + by_name.setdefault(name.lower(), []).append(value) + + assert by_name["x-bisheng-user-id"] == ["42"], "forged value must not survive, and must not duplicate" + assert unquote(by_name["x-bisheng-user-name"][0]) == "张三" + + def test_forwarded_headers_rewritten_not_passthrough(self): + """D5.2: a hosted app that trusts Host builds its links from it.""" + inbound = [ + ("X-Forwarded-Prefix", "/evil"), + ("X-Forwarded-Proto", "gopher"), + ("X-Forwarded-Host", "attacker.example.com"), + ("Forwarded", "host=attacker.example.com"), + ] + kept = strip_platform_headers(inbound) + assert _names(kept) == set(), "client-supplied forwarding metadata must be dropped wholesale" + + injected = { + name.lower(): value + for name, value in build_injected_headers( + DEFAULT_HEADER_MATERIAL, + slug="foo", + request_id="req-1", + obo_token="obo.jwt", + proto="https", + host="bisheng.example.com", + ) + } + assert injected["x-forwarded-prefix"] == "/apps/foo" + assert injected["x-forwarded-proto"] == "https" + assert injected["x-forwarded-host"] == "bisheng.example.com" + + def test_hop_by_hop_and_framing_headers_dropped(self): + """A proxy that forwards ``Transfer-Encoding`` desyncs the upstream parser.""" + inbound = [ + ("Connection", "keep-alive"), + ("Keep-Alive", "timeout=5"), + ("Transfer-Encoding", "chunked"), + ("TE", "trailers"), + ("Proxy-Authorization", "Basic x"), + ("Upgrade", "websocket"), + ("Host", "bisheng.example.com"), + ("Content-Length", "12"), + ] + assert strip_platform_headers(inbound) == [] + + def test_platform_session_cookie_removed_from_cookie_header(self): + """The hosted app must get OBO, never a live platform session. + + Not in design §4.2 as written, but implied by AC-34's whole reason for + existing: the browser sends ``access_token_cookie`` to ``/apps/*`` + because it is host-only and ``path=/`` (K7), so without this the + container receives a credential that can impersonate the visitor + against the entire platform API — strictly more power than the scoped, + 900s OBO token we hand it deliberately. + """ + kept = dict(strip_platform_headers([("Cookie", "theme=dark; access_token_cookie=jwt.here; lang=zh")])) + assert "access_token_cookie" not in kept["Cookie"] + assert "theme=dark" in kept["Cookie"] + assert "lang=zh" in kept["Cookie"] + + def test_cookie_header_dropped_when_only_platform_cookie(self): + assert strip_platform_headers([("Cookie", "access_token_cookie=jwt.here")]) == [] + + +class TestInject: + def test_inject_ten_headers(self): + """AC-31: all ten, including the subject kind the AC calls out.""" + injected = build_injected_headers( + DEFAULT_HEADER_MATERIAL, + slug="foo", + request_id="req-42", + obo_token="obo.jwt", + proto="https", + host="bisheng.example.com", + ) + names = _names(injected) + for expected in INJECTED_HEADER_NAMES: + assert expected.lower() in names, f"missing injected header {expected}" + assert len(INJECTED_HEADER_NAMES) == 10 + by_name = {name.lower(): value for name, value in injected} + assert by_name["x-bisheng-subject-kind"] == "human" + assert by_name["x-bisheng-request-id"] == "req-42" + assert by_name["x-bisheng-access-token"] == "obo.jwt" + assert by_name["x-bisheng-app-id"] == "app-0001" + + def test_request_id_is_ours_not_the_backends(self): + """Correlation only works if one hop owns the id (D14 log contract).""" + material = {**DEFAULT_HEADER_MATERIAL, "X-BiSheng-Request-Id": "stale-from-backend"} + injected = { + n.lower(): v + for n, v in build_injected_headers( + material, slug="foo", request_id="req-live", obo_token=None, proto="http", host="h" + ) + } + assert injected["x-bisheng-request-id"] == "req-live" + + def test_backend_cannot_inject_outside_our_namespace(self): + """Defence in depth: the material dict is data, not a header allow-list.""" + material = {**DEFAULT_HEADER_MATERIAL, "Authorization": "Bearer platform", "X-Evil": "1"} + names = _names( + build_injected_headers(material, slug="foo", request_id="r", obo_token=None, proto="http", host="h") + ) + assert "authorization" not in names + assert "x-evil" not in names + + def test_missing_optional_material_is_omitted_not_empty(self): + """An app checking ``if header:`` must not see a department that isn't there.""" + material = {"X-BiSheng-User-Id": "7", "X-BiSheng-Tenant-Id": "1", "X-BiSheng-Subject-Kind": "service_account"} + names = _names( + build_injected_headers(material, slug="foo", request_id="r", obo_token=None, proto="http", host="h") + ) + assert "x-bisheng-dept-id" not in names + assert "x-bisheng-access-token" not in names + assert "x-bisheng-user-id" in names + + +class TestLatin1Safety: + def test_percent_encoded_chinese_values_pass_latin1(self): + """坑 9: HTTP headers are latin-1. A Chinese name goes in raw → h11 rejects it. + + Only reproduces on accounts with non-ASCII names, and test accounts are + usually ``admin`` — which is why this is asserted mechanically here + rather than left to the 114 walkthrough. + """ + injected = build_injected_headers( + DEFAULT_HEADER_MATERIAL, + slug="foo", + request_id="r", + obo_token=None, + proto="http", + host="h", + ) + for name, value in injected: + name.encode("latin-1") + value.encode("latin-1") # would raise UnicodeEncodeError on raw 中文 + + by_name = {n.lower(): v for n, v in injected} + assert unquote(by_name["x-bisheng-user-name"]) == "张三" + assert unquote(by_name["x-bisheng-dept-name"]) == "研发中心" + assert unquote(by_name["x-bisheng-dept-path"]) == "毕昇科技/研发中心" + + def test_encoding_is_idempotent_for_already_encoded_material(self): + """The backend may percent-encode too (T032). Double-encoding would + show the user ``%E5%BC%A0`` in their own app — so ASCII in, ASCII out.""" + pre_encoded = "%E5%BC%A0%E4%B8%89" + assert encode_header_value(pre_encoded) == pre_encoded + assert unquote(encode_header_value(pre_encoded)) == "张三" + + def test_ascii_values_are_untouched(self): + assert encode_header_value("BS@d4f1") == "BS@d4f1" + assert encode_header_value("Zhang San") == "Zhang San" diff --git a/src/app-proxy/tests/test_login_handoff.py b/src/app-proxy/tests/test_login_handoff.py new file mode 100644 index 0000000000..4854f087b8 --- /dev/null +++ b/src/app-proxy/tests/test_login_handoff.py @@ -0,0 +1,155 @@ +"""AC-27 — getting an unauthenticated visitor to the login page and back. + +The hard part is not the redirect, it is coming *back* to the same address. +Two facts make the obvious 302 impossible (坑 11): + +* a URL fragment is never sent to a server, so ``/apps/foo?a=1#b`` arrives here + as ``/apps/foo?a=1`` — the ``#b`` a QR code carried is already gone; +* the platform login page consumes **only** ``localStorage.LOGIN_PATHNAME`` + + ``LOGIN_PATHNAME_AT``. It does not read ``?redirect=``, so there is nothing + to hand a return address to server-side. + +Hence a tiny inline-JS handoff page: the browser is the only party that can +still see the full address, so it is the one that writes it down. +""" + +from __future__ import annotations + +import pytest +from starlette.websockets import WebSocketDisconnect + +from tests.conftest import NAVIGATE_HEADERS, XHR_HEADERS +from tests.fakes import deny_response + + +@pytest.fixture +def anonymous(proxy_client, fake_backend): + fake_backend.response = deny_response("login") + return proxy_client + + +class TestHandoffPage: + def test_navigation_gets_inline_js_handoff(self, anonymous): + response = anonymous.get("/apps/foo", headers=NAVIGATE_HEADERS) + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/html") + body = response.text + assert " + + diff --git a/src/backend/bisheng/linsight/builtin_skills/html-report/references/design-zh.md b/src/backend/bisheng/linsight/builtin_skills/html-report/references/design-zh.md new file mode 100644 index 0000000000..2683b78382 --- /dev/null +++ b/src/backend/bisheng/linsight/builtin_skills/html-report/references/design-zh.md @@ -0,0 +1,78 @@ + + +# 报告页设计规范 + +## 1. 从内容出发 + +动手前先回答三个问题:**这页写的是什么、给谁看、看完要做什么决定**。 +同样是网页,给评审会看的审核报告、给领导看的月度汇报、给业务员查的数据看板, +信息顺序和视觉重点完全不同。每一个设计选择都要能用这三个答案解释;解释不了的就是默认习惯,要删掉。 + +全程用真实内容设计。不要先搭一个漂亮的空架子再往里填字:空架子的版块数量和长度 +跟真实内容对不上,最后要么硬塞、要么留白。 + +## 2. 版式原则 + +- **第一屏放最关键的内容。** 报告页的第一屏是标题区加结论区:报告对象、一句话总判断、不超过 4 条要点。 + 不要用大图、口号或装饰性的数字墙占第一屏。 +- **只在一个地方用力。** 辨识度留给标题区和关键结论(例如结论区的底色、标题左侧的色条)。 + 其余部分保持安静:统一的间距、统一的字号层级、克制的颜色。 +- **结构元素要表达信息。** 边框、色条、编号、标签、分隔线都应该传递内容上的区别,不是装饰。 + 编号只用于真正有先后顺序的内容(步骤、时间线);状态色只用于状态(一致、差异、风险)。 +- **对齐方式统一。** 正文、表格、列表一律左对齐;数字列右对齐;不要整页居中。 +- **行长控制在 46 个汉字以内**(底座的 `--measure`)。长段落比长页面更难读。 +- **动效只用于回应操作**(展开、排序后的变化),不加入场动画、悬停放大、滚动视差。 + +## 3. 字体与颜色 + +**字体**:只用系统字体栈(底座已写好:苹方、雅黑、思源、文泉驿依次回退)。 +离线环境加载不到网络字体,引用了也只会回退,还会被检查工具判为错误。 +层次靠字号和字重拉开:标题 600,正文 400,次要信息用 `--ink-soft` 颜色而不是更小的字号。 +正文不小于 15px,说明文字不小于 13px。数字用等宽数字(底座已开 `tabular-nums`)。 + +**颜色**:一个强调色(`--accent`)加一组状态色。 +- 强调色用于标题色条、链接、选中状态,全页出现不超过 5 处。 +- 状态色(ok 绿、warn 橙、risk 红、info 蓝)只表示状态,同一状态全页用同一种颜色。 +- 不用渐变、不用大面积彩色底;浅灰底(`--paper-alt`)用于结论区、表头和提示块就够了。 +- 底座已带暗色模式和打印样式。改 `--accent` 时,暗色模式里也要给一个提亮的版本。 + +## 4. 生成式默认审美清单 + +下面这些是生成式页面最常见的「默认选择」:换任何题目都会出现,所以一眼就能看出是机器做的。 +它们在个别场景下也可能是对的选择,但必须是为这份报告选的,而不是顺手用的。设计计划写完后逐条对照: + +1. **满屏同款圆角卡片**:每段内容都装进一样的圆角、一样的阴影的卡片。报告页用分隔线和留白分组就够了, + 卡片只留给需要单独阅读的条目(例如发现项)。 +2. **渐变与发光**:标题渐变字、紫蓝渐变背景、彩色光晕。 +3. **装饰性数字墙**:第一屏放一排大数字加小标签,但这些数字并不是结论。 +4. **无意义的编号**:01 / 02 / 03 标在并不存在先后顺序的版块上。 +5. **全大写或英文小标签**:每个标题上方都有一行小号英文或全大写标签。 +6. **每段都配图标**:图标不传递信息,只是为了「看起来丰富」。 +7. **标题里单独高亮一个词**:把一个词换颜色或加粗,制造伪重点。 +8. **整页居中**:所有文字居中对齐,长段落也居中。 +9. **点号拼接的元信息**:「A · B · C」式的信息串;需要并列时用分开的字段。 +10. **近黑底加荧光色**、**米黄底加陶土色**:两种最常见的「有设计感」配色模板。 + +## 5. 页面上的文字 + +文字是界面的一部分,要和间距、颜色一样克制和准确。 + +- **从读者的角度写。** 用读者熟悉的业务词,不用系统内部的字段名或技术术语。 +- **具体胜过漂亮。** 「风险等级依据引用的制度名称不一致」胜过「存在若干合规性问题」。 +- **一个元素只做一件事。** 标题说是什么,结论说怎么判断,建议说该做什么,不要混在一句里。 +- **同一个东西始终用同一个名字。** 前文叫「尽调报告」,后文就不要变成「尽职调查材料」。 +- **说明问题时讲清原因和处理办法**,不要只写「有误」「需关注」。 + +## 6. 质量底线 + +交付前不必说明,但必须做到: + +- 手机宽度(390px)下没有横向滚动条;宽表格放进 `.table-wrap`,在容器里横向滚动。 +- 链接、可排序表头、折叠标题在键盘聚焦时有可见的焦点框(底座已带)。 +- 文字和背景的对比度足够;浅灰字只用于次要信息。 +- 打印或另存为 PDF 时版面完整:折叠内容展开,表格行和发现项不跨页断开(底座已带打印样式)。 +- 看一遍成品,删掉一个不服务于内容的装饰。 diff --git a/src/backend/bisheng/linsight/builtin_skills/html-report/references/patterns-zh.md b/src/backend/bisheng/linsight/builtin_skills/html-report/references/patterns-zh.md new file mode 100644 index 0000000000..ada7da92a0 --- /dev/null +++ b/src/backend/bisheng/linsight/builtin_skills/html-report/references/patterns-zh.md @@ -0,0 +1,54 @@ +# 报告页版块用法 + +底座 `assets/report-base.html` 里每个版块都有一段示例。本文说明**什么时候用、什么时候不用**。 +一页报告通常只需要其中 4~6 种。 + +## 按报告类型选版块 + +| 报告类型 | 推荐顺序 | +|---|---| +| 审核、核查、合规检查 | 标题区 → 结论区 → 发现项(按严重程度)→ 对照表 → 依据材料 | +| 对比分析(方案、产品、供应商) | 标题区 → 结论区(推荐哪个、为什么)→ 对照表 → 分项说明 → 依据材料 | +| 调研、分析结论 | 标题区 → 结论区 → 各论点一节(每节:结论句 + 证据 + 图表)→ 来源 | +| 工作汇报、周报月报 | 标题区 → 结论区(进展和风险)→ 指标块 → 分项进展 → 下一步 → 时间线 | +| 数据看板 | 标题区 → 指标块 → 条形图或表格 → 明细(折叠)→ 数据口径说明 | + +## 各版块 + +**标题区 `.masthead`**:每页都要。h1 写「对象 + 报告类型」,不写口号;`.meta` 写依据的材料、日期、编制方。 + +**结论区 `.verdict`**:每页都要,紧跟标题区。`.lead` 一句话给出总判断(要有判断,不能只是主题), +下面最多 4 条要点。读者只看这里就能知道要不要往下读。 + +**目录 `.toc`**:保留空的 `
    ` 即可,脚本按 h2 自动生成;少于 3 个 h2 时脚本会自动移除它。 + +**指标块 `.metrics`**:只有数字本身就是结论时才用(例如「25 项一致、3 项需更正」)。 +最多 4 个;数字要有对比对象或口径才有意义,没有就不用。 + +**发现项 `.finding`**:审核和核查类报告的主体。严重程度用 `high` / `mid` / `low` 三档,高的排前面。 +每条固定写「现象、依据、建议」三栏;依据要能让读者回到原文核对(文件名、章节、页码、条款号)。 + +**状态标记 `.tag`**:`ok` 一致或正常,`warn` 有差异或需关注,`risk` 冲突或需更正,`info` 中性说明。 +全页同一种状态始终用同一个词和同一种颜色。 + +**对照表**:外层必须包 `.table-wrap`。一致的行不加类,`tr.diff` 表示有差异,`tr.conflict` 表示相互矛盾; +最后一列放状态标记。需要排序的列在 `` 上加 `data-sort`。 +超过 30 行时,正文只放有差异的行,完整比对表放进 `
    `。 + +**条形图 `.bars`**:适合 3~10 项的比例或排名。`--v` 填 0-100 的相对长度,`.val` 写原始数值和单位。 +项目多于 10 个时改用表格。 + +**提示块 `.note`**:口径说明、前提假设、数据缺失说明。`.note.warn` 用于需要读者注意的限制条件。 +一页不超过 2 个。 + +**时间线 `.timeline`**:有明确日期且先后顺序重要的事件(审批节点、项目里程碑)。 + +**折叠 `
    `**:原文摘录、完整明细、计算过程。默认收起,打印时会自动展开。 + +**依据材料 `.sources`**:列出用到的文件和资料,写文件名和日期;引用了网页时写标题加链接。 + +## 不要做的事 + +- 不要为了「完整」把每种版块都用上。 +- 不要在同一页里混用两种以上的强调方式(例如同时用色条、底色和图标表示严重程度)。 +- 不要用表格做排版(把文字塞进表格只是为了对齐)。 diff --git a/src/backend/bisheng/linsight/builtin_skills/html-report/scripts/svg_chart.py b/src/backend/bisheng/linsight/builtin_skills/html-report/scripts/svg_chart.py new file mode 100644 index 0000000000..5635a773ea --- /dev/null +++ b/src/backend/bisheng/linsight/builtin_skills/html-report/scripts/svg_chart.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Draw a chart with matplotlib and put the SVG straight into an HTML page. + + import json, subprocess, sys + spec = {"type": "line", "title": "月度营业收入", "unit": "亿元", + "labels": ["1月", "2月", "3月"], + "series": [{"name": "2025", "values": [1.2, 1.5, 1.4]}, + {"name": "2026", "values": [1.6, 1.7, 1.9]}]} + open("scratch/chart1.json", "w", encoding="utf-8").write(json.dumps(spec, ensure_ascii=False)) + r = subprocess.run([sys.executable, "skills/html-report/scripts/svg_chart.py", + "scratch/chart1.json", "--into", "output/report.html", "--slot", "chart1"], + capture_output=True, text=True) + print(r.stdout); print(r.stderr) + +The page must already contain the marker ```` where the chart +goes; the marker is replaced by a ``
    `` holding the inline SVG. Without +``--into`` the SVG is written to ``scratch/.svg`` instead. + +Why the script edits the page itself: a matplotlib SVG is 10-30 KB. Reading it back +and pasting it with edit_file would make the model re-emit all of it, which costs +far more time than drawing it. + +Spec: ``type`` is ``bar`` / ``barh`` / ``line``; ``labels`` are the categories; +each series has ``name`` and ``values`` (same length as ``labels``); ``unit`` and +``title`` are optional. + +Text stays text in the SVG (``svg.fonttype = none``), so the page's own font stack +renders the Chinese labels and no font file is embedded. + +Always exits 0 and reports on stdout — the code executor discards stdout on a +non-zero exit. +""" + +import argparse +import json +import os +import re +import sys +import warnings + +PALETTE = ["#1f5f8b", "#c07a1a", "#2e7d4f", "#7a4f9a", "#b3261e", "#4a6572"] + + +# Families that carry CJK glyphs, in preference order. Labels stay text in the SVG +# and the browser draws them with the page's fonts; matplotlib only needs one of +# these to measure them, or layout is computed from fallback boxes. +_CJK_FAMILIES = ( + "Noto Sans CJK SC", + "Source Han Sans SC", + "WenQuanYi Zen Hei", + "WenQuanYi Micro Hei", + "Microsoft YaHei", + "PingFang SC", + "Hiragino Sans GB", + "Heiti SC", + "STHeiti", + "Arial Unicode MS", +) + + +def _installed_cjk_fonts() -> list[str]: + from matplotlib import font_manager + + installed = {f.name for f in font_manager.fontManager.ttflist} + return [name for name in _CJK_FAMILIES if name in installed] + + +def build_svg(spec: dict) -> str: + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + plt.rcParams["svg.fonttype"] = "none" + plt.rcParams["font.family"] = ["sans-serif"] + plt.rcParams["font.sans-serif"] = [*_installed_cjk_fonts(), "DejaVu Sans"] + plt.rcParams["axes.unicode_minus"] = False + + kind = spec.get("type", "bar") + labels = [str(x) for x in spec["labels"]] + series = spec["series"] + for s in series: + if len(s["values"]) != len(labels): + raise ValueError(f"series {s.get('name')!r} has {len(s['values'])} values for {len(labels)} labels") + + fig, ax = plt.subplots(figsize=(8, 4.2 if kind != "barh" else max(2.4, 0.42 * len(labels) + 1))) + n = len(series) + if kind == "line": + for i, s in enumerate(series): + ax.plot(labels, s["values"], marker="o", linewidth=2, color=PALETTE[i % len(PALETTE)], label=s.get("name")) + elif kind in ("bar", "barh"): + width = 0.8 / n + positions = range(len(labels)) + for i, s in enumerate(series): + offs = [p - 0.4 + width * (i + 0.5) for p in positions] + draw = ax.barh if kind == "barh" else ax.bar + draw(offs, s["values"], width, color=PALETTE[i % len(PALETTE)], label=s.get("name")) + if kind == "barh": + ax.set_yticks(list(positions), labels) + ax.invert_yaxis() + else: + ax.set_xticks(list(positions), labels) + else: + raise ValueError(f"unknown chart type {kind!r}; use bar, barh or line") + + for side in ("top", "right"): + ax.spines[side].set_visible(False) + ax.grid(axis="x" if kind == "barh" else "y", color="#d9dee4", linewidth=0.8) + ax.set_axisbelow(True) + if spec.get("unit"): + (ax.set_xlabel if kind == "barh" else ax.set_ylabel)(spec["unit"]) + if n > 1: + ax.legend(frameon=False) + fig.tight_layout() + + import io + + buf = io.StringIO() + fig.savefig(buf, format="svg") + plt.close(fig) + svg = buf.getvalue() + svg = svg[svg.index("]*?)\swidth="[^"]*"\sheight="[^"]*"', r'.*?", "", svg, flags=re.S) + return svg + + +def figure_html(svg: str, title: str | None) -> str: + caption = f"
    {title}
    " if title else "" + return f'
    {svg}{caption}
    ' + + +def main() -> None: + # Missing-glyph warnings go to stderr, which a zero exit discards anyway; they + # only add noise when the script is run by hand. + warnings.filterwarnings("ignore", message="Glyph .* missing from font") + ap = argparse.ArgumentParser() + ap.add_argument("spec", help="JSON file with the chart spec") + ap.add_argument("--into", help="HTML file holding the marker") + ap.add_argument("--slot", help="marker id inside the HTML file") + args = ap.parse_args() + try: + with open(args.spec, encoding="utf-8") as f: + spec = json.load(f) + svg = build_svg(spec) + block = figure_html(svg, spec.get("title")) + if args.into: + if not args.slot: + print("FAILED: --into needs --slot") + return + marker = f"" + with open(args.into, encoding="utf-8") as f: + page = f.read() + if marker not in page: + print(f"FAILED: {args.into} has no marker {marker}; add it where the chart goes, then rerun") + return + with open(args.into, "w", encoding="utf-8") as f: + f.write(page.replace(marker, block, 1)) + print(f"OK: chart {args.slot} inserted into {args.into} ({len(block) // 1024} KB)") + else: + os.makedirs("scratch", exist_ok=True) + out = os.path.join("scratch", os.path.splitext(os.path.basename(args.spec))[0] + ".svg") + with open(out, "w", encoding="utf-8") as f: + f.write(block) + print(f"OK: chart written to {out} ({len(block) // 1024} KB)") + except Exception as e: + print(f"FAILED: {type(e).__name__}: {e}") + + +if __name__ == "__main__": + main() + sys.exit(0) diff --git a/src/backend/bisheng/linsight/domain/models/linsight_execute_task.py b/src/backend/bisheng/linsight/domain/models/linsight_execute_task.py index 2bb743fbc9..828975b5ec 100644 --- a/src/backend/bisheng/linsight/domain/models/linsight_execute_task.py +++ b/src/backend/bisheng/linsight/domain/models/linsight_execute_task.py @@ -166,6 +166,24 @@ async def update_by_id(cls, task_id: str, **kwargs) -> Optional[LinsightExecuteT await session.refresh(task) return task + @classmethod + async def update_fields_by_id(cls, task_id: str, **kwargs) -> None: + """Write the given columns with a single UPDATE, without loading the row. + + update_by_id SELECTs the whole row and refreshes it after the commit, so + every call reads the history CLOB back twice. History is written on every + agent step and grows with the run; on DaMeng each of those reads left + roughly one copy of the CLOB in worker memory, which is how long task-mode + runs drove the worker into OOM. Use this when the caller does not need the + updated row back. + """ + if not kwargs: + return + async with get_async_db_session() as session: + statement = update(LinsightExecuteTask).where(LinsightExecuteTask.id == task_id).values(**kwargs) + await session.exec(statement) + await session.commit() + # accordingsession_version_idBulk update task status @classmethod async def batch_update_status_by_session_version_id(cls, session_version_ids: List[str], diff --git a/src/backend/bisheng/linsight/domain/models/linsight_session_version.py b/src/backend/bisheng/linsight/domain/models/linsight_session_version.py index cb5766a29e..92b5db754b 100644 --- a/src/backend/bisheng/linsight/domain/models/linsight_session_version.py +++ b/src/backend/bisheng/linsight/domain/models/linsight_session_version.py @@ -108,6 +108,14 @@ class LinsightSessionVersionBase(SQLModelSerializable): api_meta: dict | None = Field( None, description="Open API submission metadata", sa_column=Column(JsonType, nullable=True) ) + # Which skill row owns each directory copied into this session. The name + # alone is not enough when a personal skill and a tenant skill share it. + # Not a retention record: deleting a skill does not consult this column. + skill_snapshot: list[dict] | None = Field( + None, + description="Directory ownership for skills copied into this session", + sa_column=Column(JsonType, nullable=True), + ) sop: str | None = Field(None, description="SOPContents", sa_type=Text, nullable=True) output_result: dict | None = Field(None, description="Output Results", sa_column=Column(JsonType, nullable=True)) status: SessionVersionStatusEnum = Field( @@ -176,6 +184,8 @@ def public_dump(self) -> dict: data.pop("pending_files", None) # F073: credential id and caller instructions are internal to the run. data.pop("api_meta", None) + # Directory ownership is for the worker's read and execute checks. + data.pop("skill_snapshot", None) return data @@ -214,19 +224,30 @@ async def get_by_id(linsight_session_version_id: str) -> LinsightSessionVersion @staticmethod async def get_session_versions_by_session_id(session_id: str) -> list[LinsightSessionVersion]: - """ - By ConversationIDGet all Ideas Conversation versions - :param session_id: SessionsID - :return: Inspiration Session Version List + """Versions of one conversation, newest first. + + Do not ``ORDER BY`` the full row. MySQL filesort packs every selected + JSON and TEXT column into ``sort_buffer_size``; a session whose files + or output exceed that buffer fails with error 1038 and the version + list 500s. Sort the primary key only, then load the rows and keep + that order. """ async with get_async_db_session() as session: - statement = ( - select(LinsightSessionVersion) + id_statement = ( + select(LinsightSessionVersion.id) .where(LinsightSessionVersion.session_id == str(session_id)) .order_by(col(LinsightSessionVersion.version).desc()) ) - - return (await session.exec(statement)).all() + ordered_ids = list((await session.exec(id_statement)).all()) + if not ordered_ids: + return [] + rows = ( + await session.exec( + select(LinsightSessionVersion).where(col(LinsightSessionVersion.id).in_(ordered_ids)) + ) + ).all() + by_id = {row.id: row for row in rows} + return [by_id[version_id] for version_id in ordered_ids if version_id in by_id] @staticmethod async def get_session_version_by_file_id(file_id: str) -> LinsightSessionVersion | None: diff --git a/src/backend/bisheng/linsight/domain/models/linsight_skill.py b/src/backend/bisheng/linsight/domain/models/linsight_skill.py index d4505b8e36..a54389b45c 100644 --- a/src/backend/bisheng/linsight/domain/models/linsight_skill.py +++ b/src/backend/bisheng/linsight/domain/models/linsight_skill.py @@ -1,6 +1,7 @@ from datetime import datetime from sqlalchemy import Integer, func, update +from sqlalchemy.exc import IntegrityError from sqlmodel import Column, DateTime, Field, String, Text, UniqueConstraint, col, or_, select, text from bisheng.common.models.base import SQLModelSerializable @@ -95,6 +96,24 @@ class LinsightSkillBase(SQLModelSerializable): created_by: int | None = Field( default=None, description="Creator user id", sa_column=Column(Integer, nullable=True, comment="Creator user id") ) + # 0 is a tenant skill (built-in or admin upload). A non-zero value is the + # user who owns a personal skill. NULL is not used: a unique index treats + # NULLs as distinct, so it would not stop two tenant skills from sharing a name. + owner_user_id: int = Field( + default=0, + description="0 for a tenant skill; otherwise the owning user id", + sa_column=Column( + Integer, + nullable=False, + server_default=text("0"), + comment="0 for a tenant skill; otherwise the owning user id", + ), + ) + last_import_id: str | None = Field( + default=None, + description="Latest personal-skill import id; empty for tenant skills", + sa_column=Column(String(64), nullable=True, comment="Latest personal-skill import id"), + ) create_time: datetime = Field( default_factory=datetime.now, description="Creation time", @@ -109,7 +128,15 @@ class LinsightSkill(LinsightSkillBase, table=True): """Inspiration (Linsight) tenant custom skill.""" __tablename__ = "linsight_skill" - __table_args__ = (UniqueConstraint("tenant_id", "name", name="uq_linsight_skill_tenant_name"),) + __table_args__ = ( + UniqueConstraint("tenant_id", "owner_user_id", "name", name="uq_linsight_skill_tenant_owner_name"), + UniqueConstraint( + "tenant_id", + "owner_user_id", + "last_import_id", + name="uq_linsight_skill_tenant_owner_import", + ), + ) id: int | None = Field(default=None, primary_key=True, description="Skill unique id") @@ -126,7 +153,8 @@ class LinsightSkillDao: Bulk ``UPDATE`` statements (``set_enabled``) are *not* rewritten by the do_orm_execute listener — it only touches SELECTs — so they carry an - explicit ``WHERE tenant_id = current`` of their own. + explicit ``WHERE tenant_id = current AND owner_user_id = owner``. The + default owner is 0, the tenant skill the management pages still edit. """ @classmethod @@ -152,17 +180,27 @@ async def update(cls, skill: LinsightSkill) -> LinsightSkill: return skill @classmethod - async def get_by_name(cls, name: str) -> LinsightSkill | None: + async def get_by_name(cls, name: str, *, owner_user_id: int = 0) -> LinsightSkill | None: with strict_tenant_filter(): async with get_async_db_session() as session: - result = await session.exec(select(LinsightSkill).where(LinsightSkill.name == name)) + result = await session.exec( + select(LinsightSkill).where( + LinsightSkill.name == name, + LinsightSkill.owner_user_id == owner_user_id, + ) + ) return result.first() @classmethod - async def get_by_display_name(cls, display_name: str) -> LinsightSkill | None: + async def get_by_display_name(cls, display_name: str, *, owner_user_id: int = 0) -> LinsightSkill | None: with strict_tenant_filter(): async with get_async_db_session() as session: - result = await session.exec(select(LinsightSkill).where(LinsightSkill.display_name == display_name)) + result = await session.exec( + select(LinsightSkill).where( + LinsightSkill.display_name == display_name, + LinsightSkill.owner_user_id == owner_user_id, + ) + ) return result.first() @classmethod @@ -172,8 +210,11 @@ async def get_page( enabled: bool | None = None, page: int = 1, page_size: int = 10, + owner_user_id: int = 0, ) -> tuple[list[LinsightSkill], int]: - statement = select(LinsightSkill) + statement = select(LinsightSkill).where(LinsightSkill.owner_user_id == owner_user_id) + if owner_user_id == 0: + statement = statement.where(LinsightSkill.source != "personal") if keyword: pattern = f"%{keyword}%" statement = statement.where( @@ -209,16 +250,19 @@ async def get_page( return list(result.all()), total @classmethod - async def list_enabled(cls) -> list[LinsightSkill]: + async def list_enabled(cls, *, owner_user_id: int = 0) -> list[LinsightSkill]: with strict_tenant_filter(): async with get_async_db_session() as session: - statement = select(LinsightSkill).where(LinsightSkill.enabled == True) # noqa: E712 + statement = select(LinsightSkill).where( + LinsightSkill.enabled == True, # noqa: E712 + LinsightSkill.owner_user_id == owner_user_id, + ) statement = statement.order_by(col(LinsightSkill.create_time).desc()) result = await session.exec(statement) return list(result.all()) @classmethod - async def set_enabled(cls, name: str, enabled: bool) -> bool: + async def set_enabled(cls, name: str, enabled: bool, *, owner_user_id: int = 0) -> bool: # Bulk UPDATE is not intercepted by the do_orm_execute tenant filter # (it only rewrites SELECTs), so scope the write to the current tenant # explicitly — otherwise toggling a name shared with Root (or any @@ -229,6 +273,7 @@ async def set_enabled(cls, name: str, enabled: bool) -> bool: update(LinsightSkill) .where(col(LinsightSkill.name) == name) .where(col(LinsightSkill.tenant_id) == tid) + .where(col(LinsightSkill.owner_user_id) == owner_user_id) .values(enabled=enabled, update_time=datetime.now()) ) result = await session.exec(statement) @@ -236,7 +281,7 @@ async def set_enabled(cls, name: str, enabled: bool) -> bool: return result.rowcount > 0 @classmethod - async def set_default_checked(cls, name: str, default_checked: bool) -> bool: + async def set_default_checked(cls, name: str, default_checked: bool, *, owner_user_id: int = 0) -> bool: # Same explicit tenant scoping (and update_time bump) as set_enabled; # MySQL's ON UPDATE would bump update_time anyway. tid = get_current_tenant_id() or DEFAULT_TENANT_ID @@ -245,6 +290,7 @@ async def set_default_checked(cls, name: str, default_checked: bool) -> bool: update(LinsightSkill) .where(col(LinsightSkill.name) == name) .where(col(LinsightSkill.tenant_id) == tid) + .where(col(LinsightSkill.owner_user_id) == owner_user_id) .values(default_checked=default_checked, update_time=datetime.now()) ) result = await session.exec(statement) @@ -252,13 +298,173 @@ async def set_default_checked(cls, name: str, default_checked: bool) -> bool: return result.rowcount > 0 @classmethod - async def delete_by_name(cls, name: str) -> bool: + async def delete_by_name(cls, name: str, *, owner_user_id: int = 0) -> bool: with strict_tenant_filter(): async with get_async_db_session() as session: - result = await session.exec(select(LinsightSkill).where(LinsightSkill.name == name)) + result = await session.exec( + select(LinsightSkill).where( + LinsightSkill.name == name, + LinsightSkill.owner_user_id == owner_user_id, + ) + ) skill = result.first() if not skill: return False await session.delete(skill) await session.commit() return True + + @classmethod + async def get_by_id(cls, skill_id: int) -> LinsightSkill | None: + with strict_tenant_filter(): + async with get_async_db_session() as session: + result = await session.exec(select(LinsightSkill).where(LinsightSkill.id == skill_id)) + return result.first() + + @classmethod + async def get_by_last_import_id(cls, import_id: str, *, owner_user_id: int) -> LinsightSkill | None: + with strict_tenant_filter(): + async with get_async_db_session() as session: + result = await session.exec( + select(LinsightSkill).where( + LinsightSkill.last_import_id == import_id, + LinsightSkill.owner_user_id == owner_user_id, + ) + ) + return result.first() + + @classmethod + async def list_visible(cls, owner_user_id: int) -> list[LinsightSkill]: + """Tenant skills plus this user's personal skills, in one query.""" + with strict_tenant_filter(): + async with get_async_db_session() as session: + statement = ( + select(LinsightSkill) + .where(or_(LinsightSkill.owner_user_id == 0, LinsightSkill.owner_user_id == owner_user_id)) + .order_by(col(LinsightSkill.id).asc()) + ) + result = await session.exec(statement) + return list(result.all()) + + @classmethod + async def personal_usage(cls, owner_user_id: int) -> tuple[int, int]: + """Count and total bytes of one user's personal skills in the current tenant.""" + with strict_tenant_filter(): + async with get_async_db_session() as session: + statement = select( + func.count(LinsightSkill.id), + func.coalesce(func.sum(LinsightSkill.size), 0), + ).where(LinsightSkill.owner_user_id == owner_user_id) + count, total = (await session.exec(statement)).one() + return int(count), int(total) + + @classmethod + async def create_personal(cls, skill: LinsightSkill, *, count_limit: int, bytes_limit: int) -> LinsightSkill: + """Insert one personal skill only when the owner's quota still has room. + + The count, the byte total and the insert commit together. A quota miss + rolls back and leaves every existing row as it was. + """ + async with get_async_db_session() as session: + with strict_tenant_filter(): + statement = select( + func.count(LinsightSkill.id), + func.coalesce(func.sum(LinsightSkill.size), 0), + ).where(LinsightSkill.owner_user_id == skill.owner_user_id) + count, total = (await session.exec(statement)).one() + if int(count) >= count_limit: + raise PersonalQuotaExceeded("count") + if int(total) + int(skill.size or 0) > bytes_limit: + raise PersonalQuotaExceeded("bytes") + if skill.update_time is None: + skill.update_time = skill.create_time + session.add(skill) + try: + await session.commit() + except IntegrityError: + await session.rollback() + raise + await session.refresh(skill) + return skill + + @classmethod + async def update_bundle( + cls, + skill_id: int, + *, + owner_user_id: int, + expected_content_hash: str, + content_hash: str, + object_path: str, + size: int, + display_name: str, + description: str, + last_import_id: str | None, + ) -> bool: + """Replace the stored bundle only while the row still has the expected hash. + + The skill id and enabled flag are not in the SET clause, so a successful + overwrite keeps both. A lost race updates nothing. + """ + tid = get_current_tenant_id() or DEFAULT_TENANT_ID + async with get_async_db_session() as session: + statement = ( + update(LinsightSkill) + .where(col(LinsightSkill.id) == skill_id) + .where(col(LinsightSkill.tenant_id) == tid) + .where(col(LinsightSkill.owner_user_id) == owner_user_id) + .where(col(LinsightSkill.content_hash) == expected_content_hash) + .values( + content_hash=content_hash, + object_path=object_path, + size=size, + display_name=display_name, + description=description, + last_import_id=last_import_id, + update_time=datetime.now(), + ) + ) + result = await session.exec(statement) + await session.commit() + return result.rowcount > 0 + + @classmethod + async def set_owner_enabled(cls, skill_id: int, *, owner_user_id: int, enabled: bool) -> bool: + """Toggle one personal skill. Bulk UPDATE must name the tenant and the owner.""" + tid = get_current_tenant_id() or DEFAULT_TENANT_ID + async with get_async_db_session() as session: + statement = ( + update(LinsightSkill) + .where(col(LinsightSkill.id) == skill_id) + .where(col(LinsightSkill.tenant_id) == tid) + .where(col(LinsightSkill.owner_user_id) == owner_user_id) + .values(enabled=enabled, update_time=datetime.now()) + ) + result = await session.exec(statement) + await session.commit() + return result.rowcount > 0 + + @classmethod + async def delete_owned(cls, skill_id: int, *, owner_user_id: int) -> bool: + with strict_tenant_filter(): + async with get_async_db_session() as session: + result = await session.exec( + select(LinsightSkill).where( + LinsightSkill.id == skill_id, + LinsightSkill.owner_user_id == owner_user_id, + ) + ) + skill = result.first() + if skill is None: + return False + await session.delete(skill) + await session.commit() + return True + + +class PersonalQuotaExceeded(Exception): + """Raised inside the insert transaction. ``kind`` is ``count`` or ``bytes``.""" + + def __init__(self, kind: str): + self.kind = kind + super().__init__(kind) diff --git a/src/backend/bisheng/linsight/domain/repositories/skill_permission.py b/src/backend/bisheng/linsight/domain/repositories/skill_permission.py new file mode 100644 index 0000000000..dd39b6bb0e --- /dev/null +++ b/src/backend/bisheng/linsight/domain/repositories/skill_permission.py @@ -0,0 +1,13 @@ +"""Tenant-scoped persistence for skill permission targets.""" + +from sqlmodel import select + +from bisheng.core.database import get_async_db_session +from bisheng.linsight.domain.models.linsight_skill import LinsightSkill, strict_tenant_filter + + +class SkillPermissionRepository: + async def get(self, skill_id: int): + with strict_tenant_filter(): + async with get_async_db_session() as session: + return (await session.exec(select(LinsightSkill).where(LinsightSkill.id == skill_id))).first() diff --git a/src/backend/bisheng/linsight/domain/schemas/linsight_schema.py b/src/backend/bisheng/linsight/domain/schemas/linsight_schema.py index c0257fc5f1..6ccb43ea1e 100644 --- a/src/backend/bisheng/linsight/domain/schemas/linsight_schema.py +++ b/src/backend/bisheng/linsight/domain/schemas/linsight_schema.py @@ -65,6 +65,11 @@ class LinsightQuestionSubmitSchema(BaseModel): # missing/None field mean "no skills this run"; skills are opt-in via an # explicit non-empty list. skills: list[str] | None = Field(None, description="Selected skill names for this run") + # Ids of the skills the user checked. Provisioning replaces each id with the + # directory that was actually copied (a same-named personal skill wins) and + # stores those directory names in the session ``skills`` column. Absent on + # older clients, which keep sending names only. + skill_ids: list[int] | None = Field(None, description="Selected skill ids for this run") # F035: continue an existing session (a follow-up round in the same 会话). # None creates a brand-new session. session_id: str | None = Field(None, description="Existing session id to continue; None creates a new session") diff --git a/src/backend/bisheng/linsight/domain/schemas/skill_schema.py b/src/backend/bisheng/linsight/domain/schemas/skill_schema.py index 3b5578f3f6..b4a5eddc0f 100644 --- a/src/backend/bisheng/linsight/domain/schemas/skill_schema.py +++ b/src/backend/bisheng/linsight/domain/schemas/skill_schema.py @@ -49,6 +49,13 @@ class SkillSelectable(BaseModel): default_checked: bool = False +class SkillRuntimeEntry(SkillSelectable): + """Stable identity and bundle revision for authenticated runtime consumers.""" + + id: int + content_hash: str + + class SkillFileEntry(BaseModel): path: str size: int diff --git a/src/backend/bisheng/linsight/domain/services/agent_factory.py b/src/backend/bisheng/linsight/domain/services/agent_factory.py index 74470f148a..4410b6d9e7 100644 --- a/src/backend/bisheng/linsight/domain/services/agent_factory.py +++ b/src/backend/bisheng/linsight/domain/services/agent_factory.py @@ -28,6 +28,7 @@ from json_repair import json_repair from langchain.agents.middleware.types import AgentMiddleware from langchain_core.language_models import BaseChatModel +from langchain_core.messages import SystemMessage, ToolMessage from langchain_core.tools import BaseTool, tool from langchain_core.tools.base import ArgsSchema from langgraph.prebuilt import InjectedState @@ -37,6 +38,7 @@ from pydantic import SkipValidation from bisheng.common.services.config_service import settings +from bisheng.linsight.domain.services.base_prompt import LinsightBasePromptMiddleware from bisheng.linsight.domain.services.binary_content_guard import ( CODE_INTERPRETER_TOOL, build_binary_guards, @@ -44,7 +46,10 @@ from bisheng.linsight.domain.services.invalid_tool_call_middleware import ( build_invalid_tool_call_repair_middleware, ) -from bisheng.linsight.domain.services.resilience_middleware import build_resilience_middleware +from bisheng.linsight.domain.services.resilience_middleware import ( + SubagentContextOverflowMiddleware, + build_resilience_middleware, +) from bisheng.linsight.domain.services.tool_loop_middleware import build_tool_loop_breaker_middleware from bisheng.linsight.domain.services.unattended_run import caller_instructions, is_unattended_run from bisheng.llm.domain.services import LLMService @@ -68,6 +73,7 @@ "replace_file_lines", # write side-effect "export_docx", # write side-effect — deliverable export stays in the main graph "export_pdf", # write side-effect — deliverable export stays in the main graph + "check_html", # writes its screenshot to scratch/; deliverable QA stays in the main graph } ) # Runtime double safety: even if someone forgets to register a HITL tool in @@ -86,8 +92,9 @@ __KB_RESEARCH_LINE__ - 多轮逐步细化查询:先广后窄,根据已检索到的内容不断调整下一轮查询,直到信息足够支撑结论。 - 中间产物(草稿、笔记、原始检索摘录)只写入工作区 scratch/ 目录,绝不写 output/。最终交付物的撰写与拼装由主智能体负责,不归你管。 +- 笔记写入约定:把完整笔记(关键摘录、数据、出处)用 write_file 写入 description 指定的笔记目录(形如 scratch/<目录>/notes.md);description 没有指定目录时,用资料文件名去掉扩展名作为目录名。笔记中的每条信息同样标明出处。 - 你**没有** ask_user 工具,也不得以任何方式向用户提问;遇到信息不足时基于已掌握的资料给出最佳结论并说明不确定性,而不是停下来等待澄清。 -- 调用方(主智能体)只能看到你的**最后一条消息**。因此请把蒸馏后的结论(含关键事实、出处/来源标识、必要的不确定性说明)作为最后一条消息完整回传,不要把结论只留在中间步骤里。 +- 调用方(主智能体)只能看到你的**最后一条消息**。因此请把蒸馏后的结论(含关键事实、出处/来源标识、必要的不确定性说明)作为最后一条消息完整回传,不要把结论只留在中间步骤里;写了笔记时,在最后一条消息末尾写明笔记文件路径,供主智能体按需查阅细节。 __CITATION_HANDOFF_LINE__ 请全程使用与任务描述一致的语言(默认简体中文)——这**包括你的思考与推理过程(thinking)**、调研旁白与最终回传,绝不允许“用英文思考、再用中文回传”。""" @@ -126,12 +133,16 @@ 2. 【执行】按清单逐项推进,选用合适工具: __SKILL_EXEC_LINE____KB_EXEC_LINE__ - - 对“独立、需多轮检索/阅读、产出可蒸馏为一段摘要”的子任务,可用 task 委派给 "general-purpose" 子代理做隔离调研(它在独立上下文检索/阅读,只把蒸馏后的有出处摘要回传给你);同一时刻并行委派不超过 2~3 个。 + - 对“独立、需多轮检索/阅读、产出可蒸馏为一段摘要”的子任务,可用 task 委派给 "general-purpose" 子代理做隔离调研(它在独立上下文检索/阅读,只把蒸馏后的有出处摘要回传给你)。 + - 【多份资料按份委派】当资料(上传文件、知识库文档或调研对象)有 3 份及以上、每份都需要多轮阅读、且各份结论彼此独立、最后才汇总时,优先按份委派:一份资料对应一个 task。资料篇幅短,或者必须交叉比对才能得出结论时,由你自己阅读,不委派。 + · 同一轮最多同时发出 5 个 task;资料更多时,等这一批返回后再发下一批。 + · 每个 description 写明:资料路径或标识、要提取的字段或要回答的问题、返回格式,以及该子代理的笔记目录 scratch/<子任务短名>/;各子代理的笔记目录不得重名。 + · 汇总时先根据各子代理交回的摘要撰写交付物;需要细节时用 read_file 读对应笔记,不要回头重读原始资料。 __KB_DELEGATE_LINE__ 更新待办时只翻转 status(pending/in_progress/completed),不改写已有文案,以保证任务标识稳定。 -3. 【产出交付物】按用户在澄清时选择的输出格式产出,markdown 是唯一规范源: -__SKILL_DELIVERABLE_LINE__ - 3a(始终):write_file 写 output/.md(结构化 markdown,其它格式由它派生)。图表/图片一律用 markdown 图片语法 `![说明](相对路径)` 引用(如 `![季节性走势](output/charts/x.png)`);不要在 markdown 里写 `
    `/``/`` 等原始 HTML 标签——markdown 预览不渲染原始 HTML,会以纯文本泄漏。(HTML 交付物走 3b,不受此约束。)__CITATION_DELIVERABLE_LINE__ - - 3b(仅当选了 html):write_file 写 output/.html(完整自包含 HTML,内联样式,无外部脚本/CDN)。 +3. 【产出交付物】按用户在澄清时选择的输出格式产出,docx / pdf 由 markdown 派生: +__SKILL_DELIVERABLE_LINE__ - 3a__MD_DELIVERABLE_CONDITION__:write_file 写 output/.md(结构化 markdown,docx / pdf 由它派生)。图表/图片一律用 markdown 图片语法 `![说明](相对路径)` 引用(如 `![季节性走势](output/charts/x.png)`);不要在 markdown 里写 `
    `/``/`
    ` 等原始 HTML 标签——markdown 预览不渲染原始 HTML,会以纯文本泄漏。(HTML 交付物走 3b,不受此约束。)__CITATION_DELIVERABLE_LINE__ + - 3b(仅当选了 html):write_file 写 output/.html,必须是完整自包含的单个文件:样式写进 +

    交叉一致性审核报告

    两份报告在信托名称、规模、期限等 25 项要素上一致,发现 3 处需要关注的差异。

    + +来源链接不算资源目录""" + + +def _errors(findings): + return [f for f in findings if f.level == hc.ERROR] + + +def _topics(findings, level=None): + return {f.topic for f in findings if level is None or f.level == level} + + +# --------------------------------------------------------------------------- +# static layer +# --------------------------------------------------------------------------- + + +def test_good_page_has_no_static_findings(): + assert hc.static_findings(GOOD) == [] + + +@pytest.mark.parametrize( + "snippet", + [ + '', + '', + '', # relative: the srcDoc preview has no base URL + '', + "", + "", + '
    x
    ', + '', + ], +) +def test_every_non_inline_resource_is_an_error(snippet): + html = GOOD.replace("", snippet + "") + findings = hc.static_findings(html) + assert _topics(findings, hc.ERROR) == {"非内联资源"} + + +def test_inert_links_are_not_resources(): + html = GOOD.replace("", '') + assert hc.static_findings(html) == [] + + +def test_empty_body_is_an_error(): + html = "" + html += "

    标题

    " + assert "内容为空" in _topics(hc.static_findings(html), hc.ERROR) + + +def test_missing_head_metadata_is_only_a_warning(): + html = "

    " + "正文内容" * 10 + "

    " + findings = hc.static_findings(html) + assert _errors(findings) == [] + assert {"缺少 doctype", "缺少 viewport", "缺少字符集"} <= _topics(findings, hc.WARN) + + +def test_fetching_refs_feeds_render_dedup(): + html = GOOD.replace("", '') + assert hc.fetching_refs(html) == {"https://cdn.x/a.js", "output/a.png"} + + +# --------------------------------------------------------------------------- +# report format / verdict ownership +# --------------------------------------------------------------------------- + + +def test_pass_verdict_only_without_errors(): + ok = hc.format_report("output/a.html", 100, [hc.Finding(hc.WARN, "t", "d")], None, None) + assert hc.PASS_VERDICT in ok and "ERROR" not in ok + bad = hc.format_report("output/a.html", 100, [hc.Finding(hc.ERROR, "t", "d")], None, None) + assert hc.PASS_VERDICT not in bad + assert "有 1 个 ERROR" in bad + + +def test_unavailable_renderer_is_stated_not_hidden(): + report = hc.format_report("output/a.html", 100, [], None, "本环境无法启动浏览器") + assert "渲染检查未执行" in report + assert "(仅静态检查)" in report + + +def test_pass_verdict_is_printed_by_this_module_alone(): + """The model stops when it sees the verdict; nothing else may print it.""" + backend_root = Path(hc.__file__).resolve().parents[3] + offenders = [ + p + for p in backend_root.rglob("*.py") + if hc.PASS_VERDICT in p.read_text(encoding="utf-8", errors="ignore") and p.name != "linsight_html_check.py" + ] + skills_root = backend_root / "linsight" / "builtin_skills" + offenders += [ + p for p in skills_root.rglob("*") if p.is_file() and hc.PASS_VERDICT in p.read_text("utf-8", errors="ignore") + ] + assert offenders == [] + + +# --------------------------------------------------------------------------- +# tool shell (fake backend) +# --------------------------------------------------------------------------- + + +class _Backend: + def __init__(self, files: dict[str, str]): + self.files = files + self.written: dict[str, bytes] = {} + + async def aread(self, path, offset=0, limit=2000): + content = self.files.get(path.lstrip("/")) + if content is None: + return SimpleNamespace(error="not found", file_data=None) + return SimpleNamespace(error=None, file_data={"content": content}) + + async def awrite(self, path, content): + self.written[path] = content + return SimpleNamespace(error=None, path="/" + path) + + +async def test_tool_rejects_non_html(): + tool = hc.CheckHtmlTool(backend=_Backend({})) + assert "只检查 .html" in await tool._arun("output/a.md") + + +async def test_tool_reports_missing_file_softly(): + tool = hc.CheckHtmlTool(backend=_Backend({})) + assert "不存在或为空" in await tool._arun("output/a.html") + + +async def test_tool_degrades_when_no_browser(monkeypatch): + monkeypatch.setattr(hc, "_render_findings", lambda html, known=frozenset(): ([], None, "本环境无法启动浏览器")) + backend = _Backend({"output/a.html": GOOD}) + report = await hc.CheckHtmlTool(backend=backend)._arun("/output/a.html") + assert "渲染检查未执行" in report + assert hc.PASS_VERDICT in report + assert backend.written == {} + + +# --------------------------------------------------------------------------- +# render layer (real Chromium; skipped where it cannot launch) +# --------------------------------------------------------------------------- + + +def _chromium_available() -> bool: + _, _, unavailable = hc._render_findings(GOOD) + return unavailable is None + + +chromium = pytest.mark.skipif(not _chromium_available(), reason="Playwright Chromium not launchable here") + + +@chromium +async def test_render_good_page_passes_and_stores_a_screenshot(): + backend = _Backend({"output/report.html": GOOD}) + report = await hc.CheckHtmlTool(backend=backend)._arun("output/report.html") + assert hc.PASS_VERDICT in report + assert "ERROR" not in report + shot = backend.written["scratch/report.check.png"] + assert shot[:8] == b"\x89PNG\r\n\x1a\n" + + +@chromium +def test_render_catches_script_errors_and_js_fetches(): + html = GOOD.replace("", "") + findings, _, unavailable = hc._render_findings(html, frozenset(hc.fetching_refs(html))) + assert unavailable is None + details = " ".join(f.detail for f in _errors(findings)) + assert "notDefined" in details + assert "api.example.com" in details + + +@chromium +def test_render_does_not_repeat_what_the_static_pass_reported(): + html = GOOD.replace("", '') + findings, _, _ = hc._render_findings(html, frozenset(hc.fetching_refs(html))) + assert _errors(findings) == [] + + +@chromium +def test_render_flags_horizontal_overflow_as_warning_only(): + html = GOOD.replace("", '
    宽
    ') + findings, _, _ = hc._render_findings(html) + assert "横向溢出" in _topics(findings, hc.WARN) + assert _errors(findings) == [] + + +@chromium +def test_render_hang_is_an_error_not_a_crash(monkeypatch): + monkeypatch.setattr(hc, "_RENDER_TIMEOUT_MS", 1500) + html = GOOD.replace("", "") + findings, _, unavailable = hc._render_findings(html) + assert unavailable is None + assert [f.topic for f in _errors(findings)] == ["渲染超时"] + + +@chromium +def test_small_text_check_ignores_scaled_svg_labels(): + """A matplotlib SVG labels its ticks at 10 user units; width:100% scales them up.""" + svg = ( + '' + '1月' + ) + html = GOOD.replace("", svg + "") + findings, _, _ = hc._render_findings(html) + assert "字号过小" not in _topics(findings) + # The xmlns URL is a namespace, not a fetch. + assert hc.static_findings(html) == [] diff --git a/src/backend/test/linsight/test_code_interpreter_font_injection.py b/src/backend/test/linsight/test_code_interpreter_font_injection.py new file mode 100644 index 0000000000..d338ad183f --- /dev/null +++ b/src/backend/test/linsight/test_code_interpreter_font_injection.py @@ -0,0 +1,56 @@ +"""LocalExecutor.insert_set_font_code must never break the code it decorates. + +The hook fires on any occurrence of the word ``matplotlib`` and then looks for the +import line to append the CJK font setup after. It used to call ``max()`` on that +search unguarded, so code mentioning ``matplotlib`` without a matching line (a +comment, a string) raised ValueError and the whole run failed before executing. +""" + +from __future__ import annotations + +import ast + +import pytest + +from bisheng_langchain.gpts.tools.code_interpreter import local_executor +from bisheng_langchain.gpts.tools.code_interpreter.local_executor import LocalExecutor + +FONT_LINE = 'matplotlib.rc("font", family="WenQuanYi Zen Hei")' + + +@pytest.fixture(autouse=True) +def _keep_font_cache(monkeypatch: pytest.MonkeyPatch) -> None: + # isolation off would delete the developer's real matplotlib font cache + monkeypatch.setattr(local_executor, "isolation_mode", lambda: "uid") + + +@pytest.mark.parametrize( + "code", + [ + "# plot it later with matplotlib\nprint(1)", + 'print("matplotlib is not installed")', + ], +) +def test_mention_without_import_is_left_alone(code: str) -> None: + assert LocalExecutor.insert_set_font_code(code) == code + + +def test_comma_separated_import_gets_the_font() -> None: + out = LocalExecutor.insert_set_font_code("import pandas, numpy, matplotlib\nprint(1)") + assert out.splitlines()[1:3] == ["import matplotlib", FONT_LINE] + + +def test_font_goes_after_the_last_import() -> None: + code = "import matplotlib\nimport matplotlib.pyplot as plt\nplt.plot([1])" + assert LocalExecutor.insert_set_font_code(code).splitlines()[2:4] == ["import matplotlib", FONT_LINE] + + +def test_indented_import_keeps_the_code_valid() -> None: + code = "try:\n import matplotlib.pyplot as plt\nexcept ImportError:\n plt = None" + out = LocalExecutor.insert_set_font_code(code) + ast.parse(out) + assert f" {FONT_LINE}" in out.splitlines() + + +def test_code_without_matplotlib_is_untouched() -> None: + assert LocalExecutor.insert_set_font_code("print(1)") == "print(1)" diff --git a/src/backend/test/linsight/test_code_interpreter_output_path.py b/src/backend/test/linsight/test_code_interpreter_output_path.py index eba64c7674..d7397ac0ed 100644 --- a/src/backend/test/linsight/test_code_interpreter_output_path.py +++ b/src/backend/test/linsight/test_code_interpreter_output_path.py @@ -223,6 +223,7 @@ def test_description_guides_to_installed_pdf_and_data_libs(): [ "open('/skills/html-ppt-templates/SKILL.md')", 'Path("/uploads/report.xlsx").read_bytes()', + "open('/large_tool_results/call_abc')", "open('/skills')", "open('/uploads')", ], diff --git a/src/backend/test/linsight/test_context_overflow_flow.py b/src/backend/test/linsight/test_context_overflow_flow.py new file mode 100644 index 0000000000..7dff7a64e7 --- /dev/null +++ b/src/backend/test/linsight/test_context_overflow_flow.py @@ -0,0 +1,254 @@ +"""F087: a context overflow is compressed and retried, and fails cleanly when it persists. + +Drives a real deepagents graph with a scripted model. The model raises the error +BishengLLM raises for a vLLM context-window rejection; the framework's +summarization middleware (outer) must see it, which only works because the +resilience middleware (inner) re-raises it instead of degrading (design decision 9). +""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +import httpx +import openai +import pytest +from deepagents import create_deep_agent +from deepagents.backends import StateBackend +from deepagents.graph import DeepAgentState +from langchain_core.exceptions import ContextOverflowError +from langchain_core.language_models import BaseChatModel +from langchain_core.messages import AIMessage, SystemMessage, ToolMessage +from langchain_core.outputs import ChatGeneration, ChatResult +from langchain_core.tools import tool +from langgraph.checkpoint.memory import InMemorySaver + +from bisheng.common.services.llm_error_classifier import classify_for_event +from bisheng.core.config.settings import LinsightConf +from bisheng.linsight.domain.services.context_budget import BishengSummarizationMiddleware, bisheng_summarization +from bisheng.linsight.domain.services.resilience_middleware import ( + _SUBAGENT_OVERFLOW_MESSAGE, + SubagentContextOverflowMiddleware, + build_resilience_middleware, +) +from bisheng.llm.domain.llm.context_overflow import as_context_overflow + +_VLLM_TEXT = ( + "This model's maximum context length is 32768 tokens. However, you requested 0 output tokens and " + "your prompt contains at least 32769 input tokens" +) +OVERFLOW = object() # marker in a reply script + + +def _overflow() -> ContextOverflowError: + request = httpx.Request("POST", "http://vllm.local/v1/chat/completions") + original = openai.BadRequestError( + _VLLM_TEXT, response=httpx.Response(400, request=request), body={"error": {"message": _VLLM_TEXT}} + ) + overflow = as_context_overflow(original) + overflow.__cause__ = original + return overflow + + +class _ScriptedModel(BaseChatModel): + """Agent turns come from ``replies``; summary requests (no system message) get a fixed text.""" + + replies: Any = None + summary_calls: int = 0 + agent_calls: int = 0 + + @property + def _llm_type(self) -> str: + return "scripted" + + def _get_ls_params(self, stop=None, **kwargs): + # A provider of its own: bisheng_summarization() registers a harness profile + # per provider for the whole process (design §5 gotcha 18), and other test + # modules build deep agents from a same-named "_ScriptedModel" class. + return {"ls_provider": "f087-overflow-flow", "ls_model_name": "scripted"} + + def bind_tools(self, *args, **kwargs): + return self + + def _generate(self, messages, stop=None, run_manager=None, **kwargs): + if not any(isinstance(m, SystemMessage) for m in messages): + self.summary_calls += 1 + return ChatResult(generations=[ChatGeneration(message=AIMessage("summary of earlier steps"))]) + self.agent_calls += 1 + reply = next(self.replies) + if reply is OVERFLOW: + raise _overflow() + return ChatResult(generations=[ChatGeneration(message=reply)]) + + +@tool +def fetch(page: int) -> str: + """Fetch one page of a long document.""" + return f"第{page}页 " + "长文档内容" * 400 + + +def _fetch_calls(n: int) -> Iterator[AIMessage]: + for i in range(n): + yield AIMessage("", tool_calls=[{"name": "fetch", "args": {"page": i}, "id": f"f{i}"}]) + + +def _build(model: _ScriptedModel, conf: LinsightConf, context_window: dict): + """The production assembly (agent_factory): BishengSummarizationMiddleware FIRST, + i.e. outside resilience, in the main graph and the researcher (design decision 7).""" + backend = StateBackend() + usable_input = model.profile["max_input_tokens"] + researcher_resilience = build_resilience_middleware(conf, is_subagent=True) + with bisheng_summarization(model): + return create_deep_agent( + model=model, + tools=[fetch], + middleware=[ + BishengSummarizationMiddleware(model, backend, usable_input), + build_resilience_middleware(conf, is_subagent=False), + SubagentContextOverflowMiddleware(researcher_resilience, context_window), + ], + subagents=[ + { + "name": "general-purpose", + "description": "researcher", + "system_prompt": "research", + "tools": [fetch], + "middleware": [BishengSummarizationMiddleware(model, backend, usable_input), researcher_resilience], + } + ], + state_schema=DeepAgentState, + backend=backend, + checkpointer=InMemorySaver(), + ) + + +async def _run(agent): + return await agent.ainvoke( + {"messages": [{"role": "user", "content": "读完这份长文档并写报告"}]}, + {"configurable": {"thread_id": "t"}, "recursion_limit": 200}, + ) + + +def _model(script) -> _ScriptedModel: + # A known window: the framework runs in fraction mode. Small enough that 10% + # cannot hold the history (so compression has something to cut), large enough that + # the chars/4 estimate never triggers proactively — the overflow path is under test. + return _ScriptedModel(replies=iter(script), profile={"max_input_tokens": 20000}) + + +async def test_main_overflow_is_compressed_and_retried(): + model = _model([*_fetch_calls(3), OVERFLOW, AIMessage("报告完成")]) + + result = await _run(_build(model, LinsightConf(), {})) + + assert result["messages"][-1].content == "报告完成" + assert model.summary_calls == 1 # compressed exactly once + # The evicted history was offloaded before the summary replaced it. + assert "/conversation_history/t.md" in result["files"] + + +async def test_main_overflow_after_compression_fails_as_context_overflow(): + model = _model([*_fetch_calls(3), OVERFLOW, OVERFLOW]) + + with pytest.raises(ContextOverflowError) as info: + await _run(_build(model, LinsightConf(), {})) + + # Same shape task_exec hands the classifier: TaskExecutionError(...) from e. + try: + raise RuntimeError("Task execution failed") from info.value + except RuntimeError as wrapped: + classified = classify_for_event(wrapped) + assert (classified.error_type, classified.error_code) == ("context_overflow", 11075) + assert model.summary_calls <= 1 # one compression per request, no loop + + +async def test_subagent_overflow_comes_back_as_a_tool_result(): + task_call = AIMessage( + "", + tool_calls=[ + {"name": "task", "args": {"description": "读第 1-50 页", "subagent_type": "general-purpose"}, "id": "t0"} + ], + ) + # main: delegate → (subagent: overflow, overflow) → main reads the note and finishes. + model = _model([task_call, OVERFLOW, OVERFLOW, AIMessage("改为分段处理,报告完成")]) + + result = await _run(_build(model, LinsightConf(), {"window": 32768, "source": "deployed"})) + + notes = [m for m in result["messages"] if isinstance(m, ToolMessage) and m.tool_call_id == "t0"] + assert notes and notes[0].content == _SUBAGENT_OVERFLOW_MESSAGE + assert notes[0].status == "success" + assert result["messages"][-1].content == "改为分段处理,报告完成" + + +async def test_subagent_overflow_budget_exhausted_fails_the_task(): + def delegate(i): + return AIMessage( + "", + tool_calls=[ + { + "name": "task", + "args": {"description": f"step {i}", "subagent_type": "general-purpose"}, + "id": f"t{i}", + } + ], + ) + + conf = LinsightConf(max_degrade=1) + # Two delegations, each overflowing twice: the first is absorbed, the second + # exceeds max_degrade=1 and the task fails. + model = _model([delegate(0), OVERFLOW, OVERFLOW, delegate(1), OVERFLOW, OVERFLOW]) + + with pytest.raises(ContextOverflowError): + await _run(_build(model, conf, {})) + + +async def test_resilience_reraises_overflow_on_both_graphs(): + from langchain.agents.middleware.types import ModelRequest + + async def handler(request): + raise _overflow() + + for is_subagent in (False, True): + middleware = build_resilience_middleware(LinsightConf(), is_subagent=is_subagent) + request = ModelRequest(model=_model([]), messages=[], system_message=None, tools=[], state={}, runtime=None) + with pytest.raises(ContextOverflowError): + await middleware.awrap_model_call(request, handler) + + +async def test_task_failure_records_type_code_and_window(monkeypatch): + """task_exec._handle_task_failure: output_result + the AC-15 log line.""" + from types import SimpleNamespace + from unittest.mock import AsyncMock + + from loguru import logger + + from bisheng.linsight.domain import task_exec as task_exec_module + from bisheng.linsight.domain.task_exec import LinsightWorkflowTask + + task = LinsightWorkflowTask() + task._state_manager = SimpleNamespace(set_session_version_info=AsyncMock(), push_message=AsyncMock()) + task._terminate_unfinished_tasks = AsyncMock() + task._context_window = {"window": 32768, "source": "deployed", "usable_input": 32768} + monkeypatch.setattr(task_exec_module.linsight_execute_utils, "persist_task_turn_message", AsyncMock()) + monkeypatch.setattr(task_exec_module, "settings", SimpleNamespace(aget_all_config=AsyncMock(return_value={}))) + session = SimpleNamespace(id="sv-1", user_id=1, status=None, output_result=None) + + try: + raise RuntimeError("Task execution failed") from _overflow() + except RuntimeError as wrapped: + error = wrapped + + records: list[str] = [] + handler_id = logger.add(lambda message: records.append(str(message)), level="WARNING") + try: + await task._handle_task_failure(session, str(error), exc=error) + finally: + logger.remove(handler_id) + + assert session.output_result["error_type"] == "context_overflow" + assert session.output_result["error_code"] == 11075 + pushed = task._state_manager.push_message.await_args.args[0].data + assert (pushed["error_type"], pushed["error_code"]) == ("context_overflow", 11075) + line = next(r for r in records if "task failed on context overflow" in r) + assert "window=32768" in line and "source=deployed" in line and "reported_input_tokens=32769" in line diff --git a/src/backend/test/linsight/test_context_summarization.py b/src/backend/test/linsight/test_context_summarization.py new file mode 100644 index 0000000000..aec3c8ff21 --- /dev/null +++ b/src/backend/test/linsight/test_context_summarization.py @@ -0,0 +1,356 @@ +"""F087 P1: BishengSummarizationMiddleware replaces deepagents' summarization per task. + +Covers the budgets (spec AC-21, AC-24), the summary retry/failure contract +(AC-17, AC-18), the unknown-window equivalence with the framework (AC-08) and +the harness-profile swap itself (design decision 7). +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from deepagents import create_deep_agent +from deepagents.backends import StateBackend +from deepagents.graph import DeepAgentState +from deepagents.middleware.summarization import SummarizationMiddleware, create_summarization_middleware +from langchain_core.language_models import BaseChatModel +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage +from langchain_core.outputs import ChatGeneration, ChatResult +from langchain_core.tools import tool +from langgraph.checkpoint.memory import InMemorySaver + +from bisheng.common.errcode.linsight import LinsightContextCompressFailedError +from bisheng.common.services.llm_error_classifier import classify_for_event +from bisheng.linsight.domain.services.context_budget import ( + BishengSummarizationMiddleware, + bisheng_summarization, + estimate_tokens, +) + + +class _SummaryProbeModel(BaseChatModel): + """Agent turns from ``replies``; summary calls (no system message) follow ``summaries``.""" + + replies: Any = None + summaries: Any = None + summary_calls: int = 0 + + @property + def _llm_type(self) -> str: + return "summary-probe" + + def _get_ls_params(self, stop=None, **kwargs): + return {"ls_provider": "f087-summary-probe", "ls_model_name": "probe"} + + def bind_tools(self, *args, **kwargs): + return self + + def _generate(self, messages, stop=None, run_manager=None, **kwargs): + if not any(isinstance(m, SystemMessage) for m in messages): + self.summary_calls += 1 + outcome = next(self.summaries) + if isinstance(outcome, Exception): + raise outcome + return ChatResult(generations=[ChatGeneration(message=AIMessage(outcome))]) + return ChatResult(generations=[ChatGeneration(message=next(self.replies))]) + + +def _model(replies=(), summaries=(), profile=None) -> _SummaryProbeModel: + return _SummaryProbeModel(replies=iter(replies), summaries=iter(summaries), profile=profile) + + +# --------------------------------------------------------------------------- # +# Construction: unknown window == framework defaults; known window == budgets +# --------------------------------------------------------------------------- # + + +def test_unknown_window_matches_the_framework_defaults(): + model = _model() + ours = BishengSummarizationMiddleware(model, StateBackend(), usable_input=None) + framework = create_summarization_middleware(model, StateBackend()) + + assert ours._lc_helper.trigger == framework._lc_helper.trigger + assert ours._lc_helper.keep == framework._lc_helper.keep + assert ours._lc_helper.trim_tokens_to_summarize == framework._lc_helper.trim_tokens_to_summarize is None + assert ours._truncate_args_trigger == framework._truncate_args_trigger + assert ours._truncate_args_keep == framework._truncate_args_keep + + +def test_known_window_budgets(): + model = _model(profile={"max_input_tokens": 100_000}) + ours = BishengSummarizationMiddleware(model, StateBackend(), usable_input=100_000) + + assert ours._lc_helper.trigger == ("fraction", 0.85) + assert ours._lc_helper.keep == ("fraction", 0.10) + assert ours._lc_helper.trim_tokens_to_summarize == 50_000 # summary input <= half the usable input + assert ours.token_counter is estimate_tokens # partial counts stay a pure estimate + + +def test_known_window_counts_from_the_usage_base(): + model = _model(profile={"max_input_tokens": 100_000}) + ours = BishengSummarizationMiddleware(model, StateBackend(), usable_input=100_000) + messages = [ + HumanMessage(content="q"), + AIMessage( + content="", + tool_calls=[{"name": "fetch", "args": {}, "id": "f"}], + usage_metadata={"input_tokens": 79_990, "output_tokens": 10, "total_tokens": 80_000}, + ), + ToolMessage(content="中文结果" * 2500, tool_call_id="f"), # 10000 CJK chars + ] + + total = ours._count_tokens(messages, SystemMessage(content="system"), []) + + # 80000 reported + ~10000 for the newer tool result: over 85% of 100000. + assert total >= 90_000 + + +def test_name_survives_the_framework_exclusion(): + ours = BishengSummarizationMiddleware(_model(), StateBackend(), usable_input=None) + assert ours.name == "BishengSummarizationMiddleware" + assert create_summarization_middleware(_model(), StateBackend()).name == "SummarizationMiddleware" + + +# --------------------------------------------------------------------------- # +# Summary failure: retry once, then fail the task (never continue on the error text) +# --------------------------------------------------------------------------- # + + +async def test_summary_failure_is_retried_once(): + model = _model(summaries=[RuntimeError("timeout"), "recovered summary"]) + ours = BishengSummarizationMiddleware(model, StateBackend(), usable_input=None) + + summary = await ours._acreate_summary([HumanMessage(content="old step")]) + + assert summary == "recovered summary" + assert model.summary_calls == 2 + + +async def test_summary_failing_twice_raises(): + model = _model(summaries=[RuntimeError("timeout"), RuntimeError("timeout")]) + ours = BishengSummarizationMiddleware(model, StateBackend(), usable_input=None) + + with pytest.raises(LinsightContextCompressFailedError): + await ours._acreate_summary([HumanMessage(content="old step")]) + + +def test_sync_summary_failing_twice_raises(): + model = _model(summaries=[RuntimeError("x"), RuntimeError("x")]) + ours = BishengSummarizationMiddleware(model, StateBackend(), usable_input=None) + + with pytest.raises(LinsightContextCompressFailedError): + ours._create_summary([HumanMessage(content="old step")]) + + +# --------------------------------------------------------------------------- # +# The harness-profile swap, end to end on a real deep agent +# --------------------------------------------------------------------------- # + + +@tool +def fetch(page: int) -> str: + """Fetch one page.""" + return f"第{page}页 " + "内容" * 3000 + + +def _build(model, usable_input): + """Mirror agent_factory: ours first in the main and the subagent middleware lists.""" + backend = StateBackend() + with bisheng_summarization(model): + return create_deep_agent( + model=model, + tools=[fetch], + middleware=[BishengSummarizationMiddleware(model, backend, usable_input)], + subagents=[ + { + "name": "general-purpose", + "description": "r", + "system_prompt": "r", + "tools": [fetch], + "middleware": [BishengSummarizationMiddleware(model, backend, usable_input)], + } + ], + state_schema=DeepAgentState, + backend=backend, + checkpointer=InMemorySaver(), + ) + + +@pytest.fixture +def instances(monkeypatch): + created: list = [] + framework_calls = {"n": 0} + original_init = BishengSummarizationMiddleware.__init__ + + def recording_init(self, *args, **kwargs): + original_init(self, *args, **kwargs) + created.append(self) + + original_awrap = SummarizationMiddleware.awrap_model_call + + async def counting_awrap(self, request, handler): + if not isinstance(self, BishengSummarizationMiddleware): + framework_calls["n"] += 1 + return await original_awrap(self, request, handler) + + monkeypatch.setattr(BishengSummarizationMiddleware, "__init__", recording_init) + monkeypatch.setattr(SummarizationMiddleware, "awrap_model_call", counting_awrap) + return created, framework_calls + + +async def test_main_and_subagent_get_bisheng_summarization(instances): + created, framework_calls = instances + delegate = AIMessage( + "", tool_calls=[{"name": "task", "args": {"description": "d", "subagent_type": "general-purpose"}, "id": "t"}] + ) + model = _model( + replies=[delegate, AIMessage("sub done"), AIMessage("main done")], profile={"max_input_tokens": 50_000} + ) + + agent = _build(model, 50_000) + result = await agent.ainvoke( + {"messages": [{"role": "user", "content": "go"}]}, {"configurable": {"thread_id": "t"}} + ) + + assert result["messages"][-1].content == "main done" + assert len(created) == 2 # one per graph + assert all(mw._lc_helper.trim_tokens_to_summarize == 25_000 for mw in created) + assert framework_calls["n"] == 0 + + +async def test_compression_with_failing_summary_fails_as_compress_failed(instances): + fetches = [AIMessage("", tool_calls=[{"name": "fetch", "args": {"page": i}, "id": f"f{i}"}]) for i in range(4)] + # Each fetch result is ~3000 CJK tokens: the 4th request crosses 85% of 12000 + # under the CJK counter (it would not under chars/4) and compression starts. + model = _model( + replies=[*fetches, AIMessage("done")], + summaries=[RuntimeError("summary model down"), RuntimeError("summary model down")], + profile={"max_input_tokens": 12_000}, + ) + + agent = _build(model, 12_000) + with pytest.raises(LinsightContextCompressFailedError) as info: + await agent.ainvoke({"messages": [{"role": "user", "content": "读完"}]}, {"configurable": {"thread_id": "t"}}) + + try: + raise RuntimeError("Task execution failed") from info.value + except RuntimeError as wrapped: + classified = classify_for_event(wrapped) + assert (classified.error_type, classified.error_code) == ("context_compress_failed", 11076) + assert model.summary_calls == 2 + + +def test_assembling_outside_the_block_fails_loudly(): + model = _model() + with bisheng_summarization(model): + pass # registers the provider + + with pytest.raises(RuntimeError, match="outside bisheng_summarization"): + create_deep_agent(model=model, tools=[], checkpointer=InMemorySaver()) + + +# --------------------------------------------------------------------------- # +# Review fixes: the summary input is trimmed, never emptied +# --------------------------------------------------------------------------- # + + +def _history(steps: int, chars_per_result: int) -> list: + messages = [HumanMessage(content="读完这批文档并写报告")] + for i in range(steps): + messages.append(AIMessage("", tool_calls=[{"name": "fetch", "args": {"page": i}, "id": f"f{i}"}])) + messages.append(ToolMessage(content=f"第{i}页 " + "内容" * chars_per_result, tool_call_id=f"f{i}")) + return messages + + +def test_real_scale_history_produces_a_real_summary(): + """40 steps, ~80K tokens, usable 100K: the summary model must actually be called.""" + model = _model(summaries=["真实摘要"], profile={"max_input_tokens": 100_000}) + ours = BishengSummarizationMiddleware(model, StateBackend(), usable_input=100_000) + + summary = ours._create_summary(_history(40, 1000)) + + assert summary == "真实摘要" + assert model.summary_calls == 1 + + +def test_trimmed_summary_input_keeps_the_instruction_and_the_newest_steps(): + ours = BishengSummarizationMiddleware(_model(profile={"max_input_tokens": 100_000}), StateBackend(), 100_000) + history = _history(40, 1000) + + trimmed = ours._trim_for_summary(history) + + assert trimmed[0] is history[0] # the task instruction + assert trimmed[-1] is history[-1] # the newest step + assert not isinstance(trimmed[1], ToolMessage) # no orphaned tool result + assert estimate_tokens(trimmed) <= 50_000 + + +def test_a_single_oversized_step_is_clipped_not_dropped(): + ours = BishengSummarizationMiddleware(_model(profile={"max_input_tokens": 10_000}), StateBackend(), 10_000) + history = _history(1, 20_000) # one tool result ~20K tokens > the 5K summary budget + + trimmed = ours._trim_for_summary(history) + + assert trimmed and estimate_tokens(trimmed) <= 5_000 + 10 + + +def test_too_long_to_summarize_counts_as_a_failure(): + class _Empty(BishengSummarizationMiddleware): + def _trim_for_summary(self, messages): + return [] + + model = _model(profile={"max_input_tokens": 100_000}) + ours = _Empty(model, StateBackend(), usable_input=100_000) + ours._lc_helper._trim_messages_for_summary = ours._trim_for_summary + + with pytest.raises(LinsightContextCompressFailedError): + ours._create_summary(_history(3, 100)) + + +async def test_subagent_summary_failure_fails_the_task_as_compress_failed(): + """SubagentContextOverflowMiddleware only absorbs overflows; a compression failure + inside the researcher must still end the whole task as context_compress_failed.""" + from bisheng.core.config.settings import LinsightConf + from bisheng.linsight.domain.services.resilience_middleware import ( + SubagentContextOverflowMiddleware, + build_resilience_middleware, + ) + + delegate = AIMessage( + "", tool_calls=[{"name": "task", "args": {"description": "d", "subagent_type": "general-purpose"}, "id": "t"}] + ) + sub_fetches = [AIMessage("", tool_calls=[{"name": "fetch", "args": {"page": i}, "id": f"s{i}"}]) for i in range(4)] + model = _model( + replies=[delegate, *sub_fetches, AIMessage("sub done"), AIMessage("main done")], + summaries=[RuntimeError("down"), RuntimeError("down")], + profile={"max_input_tokens": 12_000}, + ) + backend = StateBackend() + conf = LinsightConf() + researcher_resilience = build_resilience_middleware(conf, is_subagent=True) + with bisheng_summarization(model): + agent = create_deep_agent( + model=model, + tools=[fetch], + middleware=[ + BishengSummarizationMiddleware(model, backend, 12_000), + build_resilience_middleware(conf, is_subagent=False), + SubagentContextOverflowMiddleware(researcher_resilience, {}), + ], + subagents=[ + { + "name": "general-purpose", + "description": "r", + "system_prompt": "r", + "tools": [fetch], + "middleware": [BishengSummarizationMiddleware(model, backend, 12_000), researcher_resilience], + } + ], + state_schema=DeepAgentState, + backend=backend, + checkpointer=InMemorySaver(), + ) + + with pytest.raises(LinsightContextCompressFailedError): + await agent.ainvoke({"messages": [{"role": "user", "content": "go"}]}, {"configurable": {"thread_id": "t"}}) diff --git a/src/backend/test/linsight/test_context_token_counter.py b/src/backend/test/linsight/test_context_token_counter.py new file mode 100644 index 0000000000..fee7e3b19d --- /dev/null +++ b/src/backend/test/linsight/test_context_token_counter.py @@ -0,0 +1,93 @@ +"""F087 P1: token counting for task-mode compression (design decision 10). + +``estimate_tokens`` counts each CJK character as one token and other text at four +characters per token. ``count_context_tokens`` takes the provider-reported total +of the latest model reply as the base and estimates only what came after it. +""" + +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage + +from bisheng.linsight.domain.services.context_budget import count_context_tokens, estimate_tokens + + +def _ai(content="", total=None, tool_calls=None): + kwargs = {"content": content, "tool_calls": tool_calls or []} + if total is not None: + kwargs["usage_metadata"] = {"input_tokens": total - 10, "output_tokens": 10, "total_tokens": total} + return AIMessage(**kwargs) + + +def test_chinese_counts_one_token_per_character(): + text = "长文档内容" * 200 # 1000 CJK characters + estimated = estimate_tokens([HumanMessage(content=text)]) + + assert 1000 <= estimated <= 1010 # plus per-message overhead + + +def test_ascii_counts_four_characters_per_token(): + estimated = estimate_tokens([HumanMessage(content="a" * 4000)]) + + assert 1000 <= estimated <= 1010 + + +def test_mixed_text_adds_both_parts(): + estimated = estimate_tokens([HumanMessage(content="中文" * 100 + "ab" * 200)]) + + assert 300 <= estimated <= 310 # 200 CJK + 400 ASCII chars / 4 + + +def test_tool_call_arguments_are_counted(): + call = {"name": "write_file", "args": {"content": "报告正文" * 250}, "id": "w"} + estimated = estimate_tokens([_ai(tool_calls=[call])]) + + assert estimated >= 1000 + + +def test_usage_base_plus_only_the_newer_messages(): + messages = [ + SystemMessage(content="system " * 1000), + HumanMessage(content="问题"), + _ai(total=50_000, tool_calls=[{"name": "fetch", "args": {}, "id": "f"}]), + ToolMessage(content="结果" * 1000, tool_call_id="f"), # 2000 CJK chars, newer than the usage + ] + + total = count_context_tokens(messages) + + assert 52_000 <= total <= 52_020 + + +def test_without_usage_everything_is_estimated(): + messages = [HumanMessage(content="问题" * 500), _ai(content="回答" * 500)] + + assert count_context_tokens(messages) == estimate_tokens(messages) + + +def test_synthetic_reply_without_usage_falls_back_to_an_earlier_one(): + messages = [ + HumanMessage(content="q"), + _ai(total=10_000, tool_calls=[{"name": "task", "args": {}, "id": "t"}]), + ToolMessage(content="子任务结果" * 100, tool_call_id="t"), + _ai(content="⚠️ 本步已被跳过"), # degraded synthetic reply: no usage + ] + + total = count_context_tokens(messages) + + expected_delta = estimate_tokens(messages[2:]) + assert total == 10_000 + expected_delta + + +def test_tools_are_estimated_only_without_a_usage_base(): + tools = [{"name": "fetch", "description": "读取" * 100, "parameters": {}}] + no_base = [HumanMessage(content="q")] + with_base = [HumanMessage(content="q"), _ai(total=1000)] + + assert count_context_tokens(no_base, tools=tools) > count_context_tokens(no_base) + # The reported total already includes the tool schemas sent with that call. + assert count_context_tokens(with_base, tools=tools) == count_context_tokens(with_base) + + +def test_reasoning_sent_back_is_counted(): + plain = estimate_tokens([AIMessage(content="答")]) + with_reasoning = estimate_tokens([AIMessage(content="答", additional_kwargs={"reasoning_content": "思考" * 500})]) + + assert with_reasoning - plain == 1000 diff --git a/src/backend/test/linsight/test_crash_sweep_heartbeat_ttl.py b/src/backend/test/linsight/test_crash_sweep_heartbeat_ttl.py new file mode 100644 index 0000000000..9cc4c498e7 --- /dev/null +++ b/src/backend/test/linsight/test_crash_sweep_heartbeat_ttl.py @@ -0,0 +1,104 @@ +"""The worker-startup crash sweep must not trust a heartbeat key that outlives its node. + +A node's heartbeat key expires one TTL (15 s) after its last refresh. On a pod +eviction the replacement started 4 s after the old pod was killed, so the sweep +found the dead node's key still present, logged "running on active node" and +skipped its task. The sweep runs once per worker start, so the task stayed +IN_PROGRESS and the page spun until someone stopped it by hand. + +``asyncio_mode = auto`` — async tests need no decorator. +""" + +import sys +from types import ModuleType, SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from bisheng.linsight.domain import utils + + +class StubNodeManager: + """``is_node_alive`` answers from a per-owner list, one answer per call.""" + + answers: dict[str, list[bool]] = {} + ttl = 15 + + def __init__(self, redis_client, node_id): + pass + + async def is_node_alive(self, owner): + return self.answers[owner].pop(0) + + +@pytest.fixture +def sweep(monkeypatch): + fake_worker = ModuleType("bisheng.linsight.worker") + fake_worker.NodeManager = StubNodeManager + monkeypatch.setitem(sys.modules, "bisheng.linsight.worker", fake_worker) + monkeypatch.setattr(type(utils.settings), "aget_all_config", AsyncMock(return_value={})) + monkeypatch.setattr(utils.LinsightExecuteTaskDao, "batch_update_status_by_session_version_id", AsyncMock()) + monkeypatch.setattr(utils, "announce_stranded_session_failure", AsyncMock()) + failed = AsyncMock() + monkeypatch.setattr(utils.LinsightSessionVersionDao, "batch_update_session_versions_status", failed) + slept: list[float] = [] + + async def _sleep(seconds): + slept.append(seconds) + + monkeypatch.setattr(utils.asyncio, "sleep", _sleep) + + def _run(owners: dict[str, str | None], answers: dict[str, list[bool]]): + StubNodeManager.answers = {owner: list(seq) for owner, seq in answers.items()} + sessions = [SimpleNamespace(id=svid, user_id=1) for svid in owners] + monkeypatch.setattr( + utils.LinsightSessionVersionDao, "get_session_versions_by_status", AsyncMock(return_value=sessions) + ) + redis = SimpleNamespace(aget=AsyncMock(side_effect=lambda key: owners[key.rsplit(":", 1)[1]])) + monkeypatch.setattr(utils, "get_redis_client", AsyncMock(return_value=redis)) + return failed, slept + + return _run + + +def _failed_ids(failed) -> list[str]: + return failed.await_args.kwargs["session_version_ids"] if failed.await_args else [] + + +async def test_owner_whose_key_lingers_after_death_is_failed(sweep): + failed, slept = sweep({"sv-1": "old-pod"}, {"old-pod": [True, False]}) + + await utils.check_and_terminate_incomplete_tasks("new-pod") + + assert _failed_ids(failed) == ["sv-1"] + assert slept == [StubNodeManager.ttl + 1] + + +async def test_owner_still_heartbeating_after_one_ttl_is_skipped(sweep): + failed, slept = sweep({"sv-1": "live-pod"}, {"live-pod": [True, True]}) + + await utils.check_and_terminate_incomplete_tasks("new-pod") + + assert _failed_ids(failed) == [] + assert slept == [StubNodeManager.ttl + 1] + + +async def test_no_wait_when_every_owner_is_already_gone(sweep): + failed, slept = sweep({"sv-1": None, "sv-2": "dead-pod"}, {"dead-pod": [False]}) + + await utils.check_and_terminate_incomplete_tasks("new-pod") + + assert sorted(_failed_ids(failed)) == ["sv-1", "sv-2"] + assert slept == [] + + +async def test_mixed_owners_wait_once(sweep): + failed, slept = sweep( + {"sv-1": "old-pod", "sv-2": "live-pod", "sv-3": None}, + {"old-pod": [True, False], "live-pod": [True, True]}, + ) + + await utils.check_and_terminate_incomplete_tasks("new-pod") + + assert sorted(_failed_ids(failed)) == ["sv-1", "sv-3"] + assert slept == [StubNodeManager.ttl + 1] diff --git a/src/backend/test/linsight/test_download_md_export_strips_citations.py b/src/backend/test/linsight/test_download_md_export_strips_citations.py index 048fcc2906..a9b8ce9608 100644 --- a/src/backend/test/linsight/test_download_md_export_strips_citations.py +++ b/src/backend/test/linsight/test_download_md_export_strips_citations.py @@ -27,6 +27,7 @@ def _no_persisted_sources(monkeypatch): monkeypatch.setattr(citation_export_service, "resolve_items_for_export", AsyncMock(return_value=[])) + _CITED_MD = ( "# 报告\n\n" "PM2.5 年均浓度下降。\ue200knowledgesearch_18f5868b:0\ue202\n\n" @@ -63,7 +64,11 @@ def __call__(self, md): monkeypatch.setattr(markdocx_mod, "MarkDocx", _FakeMarkDocx) resp = await endpoint.download_md_to_pdf_or_docx( - file_info=stored_report, to_type="docx", login_user=SimpleNamespace(user_id=1) + file_info=stored_report, + to_type="docx", + session_version_id=None, + share_link=None, + login_user=SimpleNamespace(user_id=1), ) assert isinstance(resp, StreamingResponse) @@ -82,7 +87,11 @@ def _fake_pdf(md, *args, **kwargs): monkeypatch.setattr(md_to_pdf_mod, "md_to_pdf_bytes", _fake_pdf) resp = await endpoint.download_md_to_pdf_or_docx( - file_info=stored_report, to_type="pdf", login_user=SimpleNamespace(user_id=1) + file_info=stored_report, + to_type="pdf", + session_version_id=None, + share_link=None, + login_user=SimpleNamespace(user_id=1), ) assert isinstance(resp, StreamingResponse) @@ -172,7 +181,11 @@ def __call__(self, md): monkeypatch.setattr(markdocx_mod, "MarkDocx", _FakeMarkDocx) resp = await endpoint.download_md_to_pdf_or_docx( - file_info=stored_handle_report, to_type="docx", login_user=SimpleNamespace(user_id=1) + file_info=stored_handle_report, + to_type="docx", + session_version_id=None, + share_link=None, + login_user=SimpleNamespace(user_id=1), ) assert isinstance(resp, StreamingResponse) @@ -191,7 +204,11 @@ def _fake_pdf(md, *args, **kwargs): monkeypatch.setattr(md_to_pdf_mod, "md_to_pdf_bytes", _fake_pdf) resp = await endpoint.download_md_to_pdf_or_docx( - file_info=stored_handle_report, to_type="pdf", login_user=SimpleNamespace(user_id=1) + file_info=stored_handle_report, + to_type="pdf", + session_version_id=None, + share_link=None, + login_user=SimpleNamespace(user_id=1), ) assert isinstance(resp, StreamingResponse) @@ -249,7 +266,9 @@ def __call__(self, md): monkeypatch.setattr(markdocx_mod, "MarkDocx", _FakeMarkDocx) user = SimpleNamespace(user_id=1) - resp = await endpoint.download_md_to_pdf_or_docx(file_info=stored_report, to_type="docx", login_user=user) + resp = await endpoint.download_md_to_pdf_or_docx( + file_info=stored_report, to_type="docx", session_version_id=None, share_link=None, login_user=user + ) assert isinstance(resp, StreamingResponse) assert seen["user"] is user @@ -267,7 +286,11 @@ async def test_batch_download_bakes_md_entries(monkeypatch: pytest.MonkeyPatch): resp = await endpoint.batch_download_files( zip_name="bundle", file_info_list=[], login_user=SimpleNamespace(user_id=1) ) - body = b"".join([chunk async for chunk in resp.body_iterator]) if hasattr(resp.body_iterator, "__aiter__") else b"".join(resp.body_iterator) + body = ( + b"".join([chunk async for chunk in resp.body_iterator]) + if hasattr(resp.body_iterator, "__aiter__") + else b"".join(resp.body_iterator) + ) with zipfile.ZipFile(BytesIO(body)) as z: md = z.read("report.md").decode("utf-8") assert z.read("data.csv") == b"a,b\n" diff --git a/src/backend/test/linsight/test_execute_task_update_fields.py b/src/backend/test/linsight/test_execute_task_update_fields.py new file mode 100644 index 0000000000..cea10b8182 --- /dev/null +++ b/src/backend/test/linsight/test_execute_task_update_fields.py @@ -0,0 +1,61 @@ +"""LinsightExecuteTaskDao.update_fields_by_id must write without reading the row back. + +Reading the row (as update_by_id does, twice) loads the growing history CLOB on every +agent step; on DaMeng that leaked worker memory until OOM. The history writers in +state_message_manager rely on this method issuing one UPDATE and nothing else. +""" + +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock + +import pytest +from sqlalchemy.sql.dml import Update +from sqlalchemy.sql.selectable import Select + +from bisheng.linsight.domain.models import linsight_execute_task as dao_module +from bisheng.linsight.domain.models.linsight_execute_task import LinsightExecuteTaskDao + + +def _fake_session_factory(executed: list, opened: list): + session = AsyncMock() + + async def exec_(statement): + executed.append(statement) + + session.exec = AsyncMock(side_effect=exec_) + + @asynccontextmanager + async def factory(): + opened.append(True) + yield session + + return factory, session + + +@pytest.mark.asyncio +async def test_update_fields_by_id_issues_one_update_and_no_select(monkeypatch): + executed, opened = [], [] + factory, session = _fake_session_factory(executed, opened) + monkeypatch.setattr(dao_module, "get_async_db_session", factory) + + await LinsightExecuteTaskDao.update_fields_by_id("t1", history=[{"step_type": "thinking"}]) + + assert len(executed) == 1 + statement = executed[0] + assert isinstance(statement, Update) + assert not isinstance(statement, Select) + assert statement.table.name == "linsight_execute_task" + assert "history" in {c.key for c in statement._values} + session.commit.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_update_fields_by_id_without_fields_does_nothing(monkeypatch): + executed, opened = [], [] + factory, _session = _fake_session_factory(executed, opened) + monkeypatch.setattr(dao_module, "get_async_db_session", factory) + + await LinsightExecuteTaskDao.update_fields_by_id("t1") + + assert opened == [] + assert executed == [] diff --git a/src/backend/test/linsight/test_final_result_selection.py b/src/backend/test/linsight/test_final_result_selection.py index 9e4177726a..bdca33c2ac 100644 --- a/src/backend/test/linsight/test_final_result_selection.py +++ b/src/backend/test/linsight/test_final_result_selection.py @@ -247,19 +247,66 @@ def test_overwritten_output_file_is_still_a_deliverable(): assert _names(select_deliverables([rewritten], baseline_paths=baseline)) == ["output/milvus_status_report.md"] -def test_leftover_output_kept_when_this_run_also_wrote_output(): - """A new chart next to last turn's report: keep the zone, drop nothing.""" +def test_leftover_output_dropped_when_this_run_also_wrote_output(): + """A new chart next to last turn's report: deliver the chart, not the old report.""" leftover = _detail("output/report.md", mtime=10.0) leftover["file_md5"] = "old" chart = _detail("output/chart.png", mtime=20.0) chart["file_md5"] = "new" baseline = {leftover["file_path"]: "old"} - assert _names(select_deliverables([leftover, chart], baseline_paths=baseline)) == [ - "output/report.md", + assert _names(select_deliverables([leftover, chart], baseline_paths=baseline)) == ["output/chart.png"] + + +def test_follow_up_turn_delivers_only_its_own_file(): + """Turn 3 "再做一个word文档" inherits turns 1-2's output/; only the docx is new.""" + inherited = [ + _detail("output/就业方式.pptx", mtime=1.0), + _detail("output/就业方式.html", mtime=2.0), + _detail("output/就业方式.xlsx", mtime=3.0), + _detail("output/就业方式.md", mtime=4.0), + ] + baseline = {info["file_path"]: info["file_md5"] for info in inherited} + docx = _detail("output/就业方式.docx", mtime=9.0) + assert _names(select_deliverables([*inherited, docx], baseline_paths=baseline)) == ["output/就业方式.docx"] + + +def test_leftover_chart_linked_by_fresh_report_is_kept(tmp_path): + """The md preview resolves ![](charts/a.png) against the delivered file list.""" + root = str(tmp_path) + os.makedirs(os.path.join(root, "output", "charts")) + report = _detail("output/report.md", mtime=20.0, root=root) + with open(report["file_path"], "w", encoding="utf-8") as f: + f.write("# 报告\n\n![趋势](charts/a.png)\n") + linked = _detail("output/charts/a.png", mtime=10.0, root=root) + unlinked = _detail("output/charts/b.png", mtime=10.0, root=root) + old_deck = _detail("output/deck.pptx", mtime=10.0, root=root) + baseline = {info["file_path"]: info["file_md5"] for info in (linked, unlinked, old_deck)} + selected = select_deliverables([report, linked, unlinked, old_deck], baseline_paths=baseline) + assert _names(selected) == ["output/report.md", "output/charts/a.png"] + + +def test_leftover_linked_by_workspace_relative_path_is_kept(tmp_path): + root = str(tmp_path) + os.makedirs(os.path.join(root, "output")) + page = _detail("output/page.html", mtime=20.0, root=root) + with open(page["file_path"], "w", encoding="utf-8") as f: + f.write('') + chart = _detail("output/chart.png", mtime=10.0, root=root) + baseline = {chart["file_path"]: chart["file_md5"]} + assert _names(select_deliverables([page, chart], baseline_paths=baseline)) == [ + "output/page.html", "output/chart.png", ] +def test_fresh_binary_deliverable_keeps_no_leftovers(): + """Only markdown/html are scanned for links; a fresh xlsx pulls nothing in.""" + sheet = _detail("output/data.xlsx", mtime=20.0) + chart = _detail("output/chart.png", mtime=10.0) + baseline = {chart["file_path"]: chart["file_md5"]} + assert _names(select_deliverables([sheet, chart], baseline_paths=baseline)) == ["output/data.xlsx"] + + def test_path_only_baseline_still_keeps_output_zone(): """set[str] cannot tell overwrite from leftover; keep historical criterion 1.""" leftover = _detail("output/report.md", mtime=10.0) diff --git a/src/backend/test/linsight/test_github_skill_import.py b/src/backend/test/linsight/test_github_skill_import.py index cd31318388..d3467a0981 100644 --- a/src/backend/test/linsight/test_github_skill_import.py +++ b/src/backend/test/linsight/test_github_skill_import.py @@ -25,9 +25,8 @@ fetch_skill_files, parse_github_url, ) -from test.linsight.fixtures.fake_minio import FakeMinioStorage - from bisheng.linsight.domain.services.skill_store import SKILL_MD, SkillStore +from test.linsight.fixtures.fake_minio import FakeMinioStorage TENANT = 1 USER = 7 @@ -240,12 +239,22 @@ async def create(cls, skill): return skill @classmethod - async def get_by_name(cls, name): - return cls.rows.get(name) + async def get_by_name(cls, name, owner_user_id=0, **kwargs): + row = cls.rows.get(name) + if row is not None and getattr(row, "owner_user_id", 0) != owner_user_id: + return None + return row @classmethod - async def get_by_display_name(cls, display_name): - return next((s for s in cls.rows.values() if s.display_name == display_name), None) + async def get_by_display_name(cls, display_name, owner_user_id=0, **kwargs): + return next( + ( + s + for s in cls.rows.values() + if s.display_name == display_name and getattr(s, "owner_user_id", 0) == owner_user_id + ), + None, + ) @pytest.fixture diff --git a/src/backend/test/linsight/test_html_deliverable_prompt.py b/src/backend/test/linsight/test_html_deliverable_prompt.py new file mode 100644 index 0000000000..08c8d20923 --- /dev/null +++ b/src/backend/test/linsight/test_html_deliverable_prompt.py @@ -0,0 +1,82 @@ +"""Step 3 of the task-mode prompt: when the markdown file is written, and how html ships. + +Production case (cofco session a4b4a014, 2026-10-21): the user chose html only, the +run had no knowledge base or web search, and the old "3a(始终)" rule still made +the model write the whole report twice — md first (143 s), html second (152 s) — +for a markdown file nobody asked for. + +The markdown file has exactly two jobs: it is the source export_docx / export_pdf +convert from, and it is the only file the citation audit reads. So it is skipped +only when html is the sole format AND the run cannot cite anything. Whether the +run can cite is known when the prompt is built, so that half of the condition is +resolved in code; only the user's format choice is left to the model. +""" + +from __future__ import annotations + +import pytest + +from bisheng.linsight.domain.services.agent_factory import _build_linsight_system_prompt + +SKIP_MD = "只选了 html 时跳过,不写 md" + + +def _step3(prompt: str) -> str: + return prompt[prompt.index("3. 【产出交付物】") : prompt.index("4. 【收尾】")] + + +def test_md_is_skipped_for_html_only_when_nothing_can_be_cited(): + step = _step3(_build_linsight_system_prompt(False)) + assert "3a(始终)" not in step + assert SKIP_MD in step + # md is still required for every format that is derived from it. + assert "选了 markdown、docx 或 pdf 之一时" in step + assert "只选了 html 时,html 就是唯一交付物" in step + + +@pytest.mark.parametrize( + ("has_kb", "has_web"), + [(True, False), (False, True), (True, True)], +) +def test_md_is_always_written_when_the_run_can_cite(has_kb, has_web): + """The citation audit reads .md only; html-only would ship unaudited [Sn] handles.""" + step = _step3(_build_linsight_system_prompt(has_kb, has_web_search=has_web)) + assert "3a(始终)" in step + assert SKIP_MD not in step + assert "来源标注以 md 为准" in step + + +@pytest.mark.parametrize("has_kb", [True, False]) +def test_docx_and_pdf_still_derive_from_markdown(has_kb): + step = _step3(_build_linsight_system_prompt(has_kb)) + assert 'export_docx(source_path="output/.md")' in step + assert 'export_pdf(source_path="output/.md")' in step + + +@pytest.mark.parametrize("has_kb", [True, False]) +def test_html_rule_names_the_offline_constraint_and_the_checker(has_kb): + prompt = _build_linsight_system_prompt(has_kb) + step = _step3(prompt) + assert "不引用任何外部地址或相对路径" in step + assert 'check_html(path="output/.html")' in step + assert "最多修 2 轮" in step + # The tool is advertised in the tool list too (it is bound with the export tools). + assert "- check_html(path)" in prompt + + +@pytest.mark.parametrize("has_code_interpreter", [True, False]) +def test_format_skill_rule_covers_html_and_defers_to_the_md_condition(has_code_interpreter): + prompt = _build_linsight_system_prompt(False, skills_present=True, has_code_interpreter=has_code_interpreter) + step = _step3(prompt) + assert "docx / xlsx / pptx / html" in step + # The old wording forced md unconditionally and would undo the html-only rule. + assert "3a 的 markdown 规范源仍然要写" not in step + assert "3a 的 markdown 按 3a 的条件写" in step + + +@pytest.mark.parametrize("has_kb", [True, False]) +@pytest.mark.parametrize("unattended", [True, False]) +def test_no_new_placeholder_survives(has_kb, unattended): + prompt = _build_linsight_system_prompt(has_kb, unattended=unattended) + assert "__MD_DELIVERABLE_CONDITION__" not in prompt + assert "__HTML_ONLY_LINE__" not in prompt diff --git a/src/backend/test/linsight/test_init_config_tools.py b/src/backend/test/linsight/test_init_config_tools.py index a0409c2d49..dca5d13342 100644 --- a/src/backend/test/linsight/test_init_config_tools.py +++ b/src/backend/test/linsight/test_init_config_tools.py @@ -1,21 +1,8 @@ -"""Linsight tool init: the code interpreter follows the user's tool selection. - -Unified-resource direction (2026-06-16): task mode reuses the DAILY chat tool -selection. The code interpreter used to be gated on the daily *config* whitelist -(the admin-configured candidate list), which bound it on every task turn no -matter what the user toggled in the input bar. It is now gated on the per-turn -selection persisted in ``session_version.tools``, like every other tool. - -Three behaviours are pinned: -1. Not selected -> not bound (the daily-config whitelist no longer opts it in). -2. Selected -> bound once, with the workspace dir; the generic - ``init_by_tool_ids`` pass must not build a second, workspace-less copy. -3. ``_extract_tool_ids`` tolerates pydantic ``ToolConfig`` models as well as the - raw dicts stored on ``session_version.tools``. -""" +"""Task infrastructure is authorized and workspace-bound independently of UI selection.""" from __future__ import annotations +import json from types import SimpleNamespace from unittest.mock import AsyncMock @@ -24,7 +11,7 @@ from bisheng.api.v1.schemas import ToolConfig from bisheng.linsight.domain.models.linsight_session_version import LinsightSessionVersion from bisheng.linsight.domain.services.workbench_impl import LinsightWorkbenchImpl -from bisheng.tool.domain.models.gpts_tools import GptsToolsDao +from bisheng.tool.domain.models.gpts_tools import GptsTools, GptsToolsDao from bisheng.tool.domain.services.executor import ToolExecutor CODE_TOOL_ID = 6 @@ -41,12 +28,23 @@ def _session_with_tools(tool_ids: list[int] | None = None) -> LinsightSessionVer ) -def _fake_code_tool_row() -> SimpleNamespace: - return SimpleNamespace(id=CODE_TOOL_ID, tool_key="bisheng_code_interpreter", extra=None) +def _fake_code_tool_row() -> GptsTools: + return GptsTools(id=CODE_TOOL_ID, type=6, tool_key="bisheng_code_interpreter", extra=None) -async def test_code_interpreter_skipped_when_not_selected(monkeypatch: pytest.MonkeyPatch, tmp_path): - """User did not toggle the code interpreter -> it must not be bound.""" +@pytest.fixture(autouse=True) +def authorized_container(monkeypatch): + monkeypatch.setattr(GptsToolsDao, "aget_tool_by_tool_key", AsyncMock(return_value=_fake_code_tool_row())) + monkeypatch.setattr( + GptsToolsDao, + "aget_one_tool_type", + AsyncMock(return_value=SimpleNamespace(id=6, tenant_id=7, extra={"type": "container"})), + ) + monkeypatch.setattr(ToolExecutor, "_ensure_use_permission_async", AsyncMock()) + + +async def test_code_interpreter_automatically_bound_when_not_selected(monkeypatch: pytest.MonkeyPatch, tmp_path): + """The hidden task capability binds alongside the selected optional tool.""" monkeypatch.setattr(GptsToolsDao, "aget_tool_by_tool_key", AsyncMock(return_value=_fake_code_tool_row())) init_one = AsyncMock(return_value=object()) monkeypatch.setattr(ToolExecutor, "init_by_tool_id", init_one) @@ -57,8 +55,8 @@ async def test_code_interpreter_skipped_when_not_selected(monkeypatch: pytest.Mo session_version=_session_with_tools([100]), llm=object(), need_upload=True, file_dir=str(tmp_path) ) - init_one.assert_not_awaited() - assert len(tools) == 1 + init_one.assert_awaited_once() + assert len(tools) == 2 assert init_many.await_args.args[0] == [100] @@ -81,14 +79,14 @@ async def test_code_interpreter_bound_once_when_selected(monkeypatch: pytest.Mon # Built once, through the workspace-aware path, and carrying the sync path. init_one.assert_awaited_once() bound_row = init_one.await_args.kwargs["tool"] - assert bound_row.extra["config"]["local"]["local_sync_path"] == str(tmp_path) + assert bound_row.extra["config"]["container"]["local_sync_path"] == str(tmp_path) assert code_tool in tools # The generic pass no longer sees the code-interpreter id -> no duplicate. assert init_many.await_args.args[0] == [100] -async def test_no_tools_selected_binds_nothing(monkeypatch: pytest.MonkeyPatch, tmp_path): - """Empty selection short-circuits: not even the code interpreter binds.""" +async def test_empty_selection_binds_authorized_interpreter(monkeypatch: pytest.MonkeyPatch, tmp_path): + """Tasks need executable infrastructure with an empty composer selection.""" init_one = AsyncMock(return_value=object()) monkeypatch.setattr(ToolExecutor, "init_by_tool_id", init_one) @@ -99,8 +97,8 @@ async def test_no_tools_selected_binds_nothing(monkeypatch: pytest.MonkeyPatch, session_version=session, llm=object(), need_upload=True, file_dir=str(tmp_path) ) - assert tools == [] - init_one.assert_not_awaited() + assert tools == [init_one.return_value] + init_one.assert_awaited_once() def test_extract_tool_ids_tolerates_pydantic_models(): @@ -158,6 +156,9 @@ async def test_e2b_file_list_honours_autopush_ceiling(monkeypatch: pytest.Monkey """ from bisheng_langchain.gpts.tools.code_interpreter.e2b_executor import SIZE_AUTOPUSH + monkeypatch.setattr( + GptsToolsDao, "aget_one_tool_type", AsyncMock(return_value=SimpleNamespace(id=6, extra={"type": "e2b"})) + ) (tmp_path / "uploads").mkdir() (tmp_path / "uploads" / "small.xlsx").write_bytes(b"x" * 1024) (tmp_path / "uploads" / "huge.xlsx").write_bytes(b"x" * (SIZE_AUTOPUSH + 1)) @@ -178,5 +179,280 @@ async def test_e2b_file_list_honours_autopush_ceiling(monkeypatch: pytest.Monkey pushed = {entry.path for entry in bound_row.extra["config"]["e2b"]["file_list"]} assert "./uploads/small.xlsx" in pushed assert "./uploads/huge.xlsx" not in pushed - # LocalExecutor still reaches the big one through the shared directory. - assert bound_row.extra["config"]["local"]["local_sync_path"] == str(tmp_path) + assert bound_row.extra["config"]["e2b"]["local_sync_path"] == str(tmp_path) + assert bound_row.extra["config"]["e2b"]["workspace_prefix"] == "workspace/SV-1" + + +async def test_container_binds_workspace_without_e2b_file_list(monkeypatch: pytest.MonkeyPatch, tmp_path): + """Container copy-in happens at run(); do not pre-scan file_list (AC-11).""" + from bisheng_langchain.gpts.tools.code_interpreter.e2b_executor import SIZE_AUTOPUSH + + (tmp_path / "skills" / "demo").mkdir(parents=True) + (tmp_path / "skills" / "demo" / "SKILL.md").write_text("hi") + (tmp_path / "uploads").mkdir() + (tmp_path / "uploads" / "huge.bin").write_bytes(b"x" * (SIZE_AUTOPUSH + 1)) + + row = GptsTools( + id=CODE_TOOL_ID, + tool_key="bisheng_code_interpreter", + type=6, + extra=json.dumps({"type": "container"}), + ) + monkeypatch.setattr(GptsToolsDao, "aget_tool_by_tool_key", AsyncMock(return_value=row)) + init_one = AsyncMock(return_value=object()) + monkeypatch.setattr(ToolExecutor, "init_by_tool_id", init_one) + monkeypatch.setattr(ToolExecutor, "init_by_tool_ids", AsyncMock(return_value=[])) + + await LinsightWorkbenchImpl.init_linsight_config_tools( + session_version=_session_with_tools([CODE_TOOL_ID]), + llm=object(), + need_upload=True, + file_dir=str(tmp_path), + ) + + bound_row = init_one.await_args.kwargs["tool"] + container = bound_row.extra["config"]["container"] + assert container["local_sync_path"] == str(tmp_path) + assert container["keep_session"] is True + assert "workspace_prefix" in container + assert "file_list" not in bound_row.extra["config"].get("e2b", {}) + tool = ToolExecutor._init_preset_tool(bound_row, SimpleNamespace(extra=None)) + assert "skills/" in tool.description + assert tool.executor.local_sync_path == str(tmp_path) + assert row.extra == json.dumps({"type": "container"}) + + +async def test_empty_tool_extra_falls_back_to_category_container(monkeypatch: pytest.MonkeyPatch, tmp_path): + """Built-in-tool UI writes type.extra and NULLs the row; Linsight must still isolate.""" + row = GptsTools( + id=CODE_TOOL_ID, + tool_key="bisheng_code_interpreter", + extra=None, + type=CODE_TOOL_ID, + ) + monkeypatch.setattr(GptsToolsDao, "aget_tool_by_tool_key", AsyncMock(return_value=row)) + monkeypatch.setattr( + GptsToolsDao, + "aget_one_tool_type", + AsyncMock(return_value=SimpleNamespace(id=CODE_TOOL_ID, extra=json.dumps({"type": "container"}))), + ) + init_one = AsyncMock(return_value=object()) + monkeypatch.setattr(ToolExecutor, "init_by_tool_id", init_one) + monkeypatch.setattr(ToolExecutor, "init_by_tool_ids", AsyncMock(return_value=[])) + + await LinsightWorkbenchImpl.init_linsight_config_tools( + session_version=_session_with_tools([CODE_TOOL_ID]), + llm=object(), + need_upload=True, + file_dir=str(tmp_path), + ) + + bound_row = init_one.await_args.kwargs["tool"] + assert bound_row.extra["type"] == "container" + assert "file_list" not in bound_row.extra["config"].get("e2b", {}) + assert bound_row.extra["config"]["container"]["local_sync_path"] == str(tmp_path) + + +async def test_tool_row_extra_wins_over_category(monkeypatch: pytest.MonkeyPatch, tmp_path): + """A non-empty tool row is exclusive, same as parse_preset_tool_params.""" + row = GptsTools( + id=CODE_TOOL_ID, + tool_key="bisheng_code_interpreter", + extra=json.dumps({"type": "e2b"}), + type=CODE_TOOL_ID, + ) + monkeypatch.setattr(GptsToolsDao, "aget_tool_by_tool_key", AsyncMock(return_value=row)) + monkeypatch.setattr( + GptsToolsDao, + "aget_one_tool_type", + AsyncMock(return_value=SimpleNamespace(id=CODE_TOOL_ID, extra=json.dumps({"type": "container"}))), + ) + init_one = AsyncMock(return_value=object()) + monkeypatch.setattr(ToolExecutor, "init_by_tool_id", init_one) + monkeypatch.setattr(ToolExecutor, "init_by_tool_ids", AsyncMock(return_value=[])) + + await LinsightWorkbenchImpl.init_linsight_config_tools( + session_version=_session_with_tools([CODE_TOOL_ID]), + llm=object(), + need_upload=True, + file_dir=str(tmp_path), + ) + + bound_row = init_one.await_args.kwargs["tool"] + assert bound_row.extra["type"] == "e2b" + # E2B pre-scans its copy-in set using the tool-level configuration. + assert "file_list" in bound_row.extra["config"]["e2b"] + + +@pytest.mark.parametrize("executor_type", ["local", "container", "e2b"]) +@pytest.mark.parametrize("explicit", [False, True]) +async def test_permission_denial_preserves_optional_tools(monkeypatch, tmp_path, explicit, executor_type): + monkeypatch.setattr( + GptsToolsDao, + "aget_one_tool_type", + AsyncMock(return_value=SimpleNamespace(id=6, tenant_id=7, extra={"type": executor_type})), + ) + monkeypatch.setattr(ToolExecutor, "_ensure_use_permission_async", AsyncMock(side_effect=PermissionError("denied"))) + init_one = AsyncMock() + monkeypatch.setattr(ToolExecutor, "init_by_tool_id", init_one) + other = object() + monkeypatch.setattr(ToolExecutor, "init_by_tool_ids", AsyncMock(return_value=[other])) + session = _session_with_tools([100, CODE_TOOL_ID] if explicit else [100]) + if explicit: + with pytest.raises(PermissionError): + await LinsightWorkbenchImpl.init_linsight_config_tools(session, object(), True, str(tmp_path)) + else: + assert await LinsightWorkbenchImpl.init_linsight_config_tools(session, object(), True, str(tmp_path)) == [other] + init_one.assert_not_awaited() + + +async def test_initialization_failure_surfaces_before_model_execution(monkeypatch, tmp_path): + monkeypatch.setattr(ToolExecutor, "init_by_tool_id", AsyncMock(side_effect=RuntimeError("sandbox unavailable"))) + with pytest.raises(RuntimeError, match="sandbox unavailable"): + await LinsightWorkbenchImpl.init_linsight_config_tools(_session_with_tools(), object(), True, str(tmp_path)) + + +@pytest.mark.parametrize("executor_type", ["local", None, ""]) +@pytest.mark.parametrize("selected", [[], [CODE_TOOL_ID], [100, CODE_TOOL_ID]]) +async def test_local_executor_binds_task_workspace(monkeypatch, tmp_path, executor_type, selected): + from bisheng_langchain.gpts.tools.code_interpreter.local_executor import LocalExecutor + + extra = {"type": executor_type} if executor_type is not None else {} + category = SimpleNamespace(id=6, tenant_id=7, extra=extra) + monkeypatch.setattr(GptsToolsDao, "aget_one_tool_type", AsyncMock(return_value=category)) + init_one = AsyncMock(side_effect=lambda tool, **kwargs: ToolExecutor._init_preset_tool(tool, category)) + monkeypatch.setattr(ToolExecutor, "init_by_tool_id", init_one) + init_many = AsyncMock(return_value=[]) + monkeypatch.setattr(ToolExecutor, "init_by_tool_ids", init_many) + session = _session_with_tools() + session.tools = [{"children": [{"id": tid} for tid in selected]}] + + tools = await LinsightWorkbenchImpl.init_linsight_config_tools(session, object(), True, str(tmp_path)) + + init_one.assert_awaited_once() + assert len(tools) == 1 + assert isinstance(tools[0].executor, LocalExecutor) + assert tools[0].executor.local_sync_path == str(tmp_path) + assert tools[0].executor.workspace_prefix == "workspace/SV-1" + assert init_one.await_args.kwargs["tool"].extra["config"] == { + "local": {"local_sync_path": str(tmp_path), "workspace_prefix": "workspace/SV-1", "keep_run_dir": True} + } + assert category.extra == extra + if 100 in selected: + assert init_many.await_args.args[0] == [100] + else: + init_many.assert_not_awaited() + + +async def test_local_workspace_binding_preserves_admin_options(monkeypatch, tmp_path): + extra = { + "type": "local", + "config": { + "local": { + "local_sync_path": "/previous/task", + "workspace_prefix": "workspace/previous", + "timeout": 90, + } + }, + } + row = GptsTools(id=CODE_TOOL_ID, type=6, tool_key="bisheng_code_interpreter", extra=json.dumps(extra)) + monkeypatch.setattr(GptsToolsDao, "aget_tool_by_tool_key", AsyncMock(return_value=row)) + init_one = AsyncMock(return_value=object()) + monkeypatch.setattr(ToolExecutor, "init_by_tool_id", init_one) + + # Local execution uses the workspace directly, including large input files. + def unexpected_scan(*args, **kwargs): + pytest.fail("Local initialization should use the task directory directly") + + monkeypatch.setattr("bisheng.linsight.domain.services.workbench_impl.os.walk", unexpected_scan) + + await LinsightWorkbenchImpl._init_bisheng_code_tool([], str(tmp_path), 42, "current") + + assert init_one.await_args.kwargs["tool"].extra["config"] == { + "local": { + "local_sync_path": str(tmp_path), + "workspace_prefix": "workspace/current", + "keep_run_dir": True, + "timeout": 90, + } + } + assert json.loads(row.extra) == extra + + +async def test_unknown_executor_type_reports_configuration_error(monkeypatch, tmp_path): + from bisheng.common.errcode.sandbox import SandboxConfigurationError + + monkeypatch.setattr( + GptsToolsDao, "aget_one_tool_type", AsyncMock(return_value=SimpleNamespace(id=6, extra={"type": "unknown"})) + ) + with pytest.raises(SandboxConfigurationError): + await LinsightWorkbenchImpl.init_linsight_config_tools(_session_with_tools(), object(), True, str(tmp_path)) + + +async def test_missing_interpreter_keeps_optional_tools(monkeypatch, tmp_path): + monkeypatch.setattr(GptsToolsDao, "aget_tool_by_tool_key", AsyncMock(return_value=None)) + other = object() + monkeypatch.setattr(ToolExecutor, "init_by_tool_ids", AsyncMock(return_value=[other])) + assert await LinsightWorkbenchImpl.init_linsight_config_tools( + _session_with_tools(), object(), True, str(tmp_path) + ) == [other] + + +def test_task_metadata_uses_host_runner_credentials(monkeypatch, tmp_path): + from bisheng.common.services.config_service import settings + from bisheng.core.config.settings import SandboxConf + + monkeypatch.setattr( + "bisheng.tool.domain.services.executor.settings", + SimpleNamespace( + sandbox_conf=SandboxConf(endpoints=["http://trusted:8080"], token="host-only"), + get_minio_conf=settings.get_minio_conf, + ), + ) + monkeypatch.setattr( + "bisheng_langchain.gpts.tools.code_interpreter.factory.load_sandbox_conf", + lambda: SandboxConf(endpoints=["http://trusted:8080"], token="host-only"), + ) + row = GptsTools( + tool_key="bisheng_code_interpreter", + extra={ + "type": "container", + "config": { + "container": { + "local_sync_path": str(tmp_path), + "endpoints": ["http://untrusted:8080"], + "token": "task-value", + } + }, + }, + ) + tool = ToolExecutor._init_preset_tool(row, SimpleNamespace(extra=None)) + assert tool.executor.endpoints == ["http://trusted:8080"] + assert tool.executor.token == "host-only" + + +async def test_image_view_config_row_is_not_initialized(monkeypatch: pytest.MonkeyPatch, tmp_path): + """A saved Image View selection is skipped. Loading it as a tool aborts the task.""" + image_view_id = 39 + + async def by_key(tool_key: str): + if tool_key == "image_view": + return SimpleNamespace(id=image_view_id, tool_key="image_view") + return _fake_code_tool_row() + + monkeypatch.setattr(GptsToolsDao, "aget_tool_by_tool_key", by_key) + init_one = AsyncMock(return_value=object()) + monkeypatch.setattr(ToolExecutor, "init_by_tool_id", init_one) + init_many = AsyncMock(return_value=[object()]) + monkeypatch.setattr(ToolExecutor, "init_by_tool_ids", init_many) + + tools = await LinsightWorkbenchImpl.init_linsight_config_tools( + session_version=_session_with_tools([100, image_view_id]), + llm=object(), + need_upload=True, + file_dir=str(tmp_path), + ) + + init_one.assert_awaited_once() + assert init_many.await_args.args[0] == [100] + assert len(tools) == 2 diff --git a/src/backend/test/linsight/test_linsight_citation_agent.py b/src/backend/test/linsight/test_linsight_citation_agent.py index aff988fe42..b8ab3ad163 100644 --- a/src/backend/test/linsight/test_linsight_citation_agent.py +++ b/src/backend/test/linsight/test_linsight_citation_agent.py @@ -17,7 +17,7 @@ _annotate_web_search_output, _build_researcher_prompt, _build_researcher_subagent, - _is_web_search_tool, + _is_web_citation_tool, _with_citation_rules, _wrap_linsight_web_citation_tools, ) @@ -109,10 +109,10 @@ async def boom(_output): assert result == inner.payload -def test_is_web_search_tool_matches_name_or_tool_name(): - assert _is_web_search_tool(_FakeWeb()) - assert _is_web_search_tool(type("T", (), {"name": "other", "tool_name": "web_search"})()) - assert not _is_web_search_tool(_FakeOther()) +def test_is_web_citation_tool_matches_name_or_tool_name(): + assert _is_web_citation_tool(_FakeWeb()) + assert _is_web_citation_tool(type("T", (), {"name": "other", "tool_name": "web_search"})()) + assert not _is_web_citation_tool(_FakeOther()) def test_citation_rules_only_when_kb_or_web(): diff --git a/src/backend/test/linsight/test_linsight_export.py b/src/backend/test/linsight/test_linsight_export.py index a341f1cd09..a47ecad67c 100644 --- a/src/backend/test/linsight/test_linsight_export.py +++ b/src/backend/test/linsight/test_linsight_export.py @@ -41,10 +41,10 @@ def test_init_returns_empty_for_non_writable_backend(): assert linsight_export.init_linsight_export_tools(object()) == [] # no awrite attr -def test_init_returns_both_tools_for_writable_backend(): +def test_init_returns_every_deliverable_tool_for_writable_backend(): tools = linsight_export.init_linsight_export_tools(_writable_backend()) names = {t.name for t in tools} - assert names == {"export_docx", "export_pdf"} + assert names == {"export_docx", "export_pdf", "check_html"} async def test_export_docx_reads_converts_writes(monkeypatch): @@ -311,3 +311,133 @@ def __call__(self, md): assert "## 参考资料" in seen["md"] and "《政策.pdf》 · 第 3 页" in seen["md"] assert "" not in seen["md"] and "websearch_" not in seen["md"] # the unresolved web key is stripped assert "已生成 Word" in res + + +# --- workspace images are inlined as data URIs (the converters run in the +# worker, and sanitize_html_for_pdf drops scheme-less src values) ------------- + + +def _png_bytes() -> bytes: + import io + + from PIL import Image + + buf = io.BytesIO() + Image.new("RGB", (4, 4), (200, 30, 30)).save(buf, format="PNG") + return buf.getvalue() + + +_PNG = _png_bytes() + + +def _download_backend(md_content: str, files: dict[str, bytes]): + backend = _writable_backend(md_content) + + async def _download(paths): + return [ + SimpleNamespace(path="/" + p, content=files[p], error=None) + if p in files + else SimpleNamespace(path="/" + p, content=None, error="file_not_found") + for p in paths + ] + + backend.adownload_files = AsyncMock(side_effect=_download) + return backend + + +def _capture_docx_md(monkeypatch) -> list[str]: + import bisheng.common.utils.markdown_cmpnt.md_to_docx.markdocx as markdocx_mod + + seen: list[str] = [] + + class _FakeMarkDocx: + def __call__(self, md): + seen.append(md) + return (b"DOCXBYTES", "title") + + monkeypatch.setattr(markdocx_mod, "MarkDocx", _FakeMarkDocx) + return seen + + +async def test_export_docx_inlines_relative_and_rooted_images(monkeypatch): + seen = _capture_docx_md(monkeypatch) + # Three spellings seen in practice: md-relative, workspace-rooted with a + # slash, and workspace-rooted without one (session 48e1bcae wrote this last + # form inside output/减肥计划.md). + md = ( + "# R\n\n![曲线](charts/weight_curve.png)\n\n![宏量](/output/charts/macros.png)\n\n" + "![宏量2](output/charts/macros.png)\n" + ) + backend = _download_backend(md, {"output/charts/weight_curve.png": _PNG, "output/charts/macros.png": _PNG}) + res = await linsight_export.ExportDocxTool(backend=backend)._arun(source_path="output/report.md") + + (paths,), _ = backend.adownload_files.await_args + assert paths == [ + "charts/weight_curve.png", + "output/charts/macros.png", + "output/charts/weight_curve.png", + "output/output/charts/macros.png", + ] + assert seen[0].count("](data:image/png;base64,") == 3 + assert "charts/" not in seen[0] + assert "未嵌入" not in res + + +async def test_export_pdf_inlines_html_img_and_percent_encoded_name(monkeypatch): + seen: list[str] = [] + monkeypatch.setattr(linsight_export, "_md_to_pdf_bytes_via_libreoffice", lambda md: seen.append(md) or b"PDFBYTES") + md = '\n' + backend = _download_backend(md, {"scratch/图.png": _PNG}) + res = await linsight_export.ExportPdfTool(backend=backend)._arun(source_path="scratch/draft.md") + + assert ' tuple[str, list[str]]: + import io + import zipfile + + with zipfile.ZipFile(io.BytesIO(docx_bytes)) as zf: + return zf.read("word/document.xml").decode("utf-8"), zf.namelist() + + +def test_markdocx_local_image_path_does_not_crash(): + """A scheme-less src is stripped by the sanitizer; the converter must skip the + image instead of raising KeyError('src') (which failed every export with an image).""" + xml, _ = _docx_parts(linsight_export._md_to_docx_bytes("# 标题\n\n正文\n\n![图](charts/a.png)\n")) + assert "正文" in xml + + +def test_markdocx_embeds_data_uri_image(): + import base64 + + uri = "data:image/png;base64," + base64.b64encode(_PNG).decode() + xml, names = _docx_parts(linsight_export._md_to_docx_bytes(f"# 标题\n\n正文\n\n![图]({uri})\n")) + assert "正文" in xml + assert "') + assert not md_image_refs.has_local_image_refs("![a](https://x/a.png) ![b](data:image/png;base64,AA==)") + assert not md_image_refs.has_local_image_refs("plain [link](a.md)") + + +async def test_non_image_candidates_are_never_fetched(): + seen: list[list[str]] = [] + found, missing = await md_image_refs.resolve_local_images("![a](notes.txt)", "output/r.md", _fetcher({}, seen)) + assert found == {} and missing == ["notes.txt"] and seen == [] + + +async def test_bundle_rewrites_paths_and_packs_images(): + md = ( + "# R\n\n![曲线](output/charts/curve.png)\n\n![宏量](charts/macros.png)\n\n" + "![同一张](/output/charts/curve.png)\n\n![别处同名](scratch/macros.png)\n\n![丢了](charts/gone.png)\n" + ) + files = { + "output/charts/curve.png": PNG + b"1", + "output/charts/macros.png": PNG + b"2", + "scratch/macros.png": PNG + b"3", + } + data = await md_image_refs.build_markdown_bundle(md, "减肥计划.md", "output/减肥计划.md", _fetcher(files)) + + with zipfile.ZipFile(io.BytesIO(data)) as zf: + names = set(zf.namelist()) + out_md = zf.read("减肥计划.md").decode() + assert names == {"减肥计划.md", "images/curve.png", "images/macros.png", "images/macros-2.png"} + assert zf.read("images/curve.png") == PNG + b"1" + assert {zf.read("images/macros.png"), zf.read("images/macros-2.png")} == {PNG + b"2", PNG + b"3"} + # Both spellings of the same file point at one entry; the missing one is untouched. + assert out_md.count("](images/curve.png)") == 2 + assert "](charts/gone.png)" in out_md + assert "output/charts" not in out_md + + +async def test_bundle_is_none_when_no_image_resolves(): + assert await md_image_refs.build_markdown_bundle("![a](a.png)", "r.md", "output/r.md", _fetcher({})) is None + assert await md_image_refs.build_markdown_bundle("text", "r.md", "output/r.md", _fetcher({})) is None + + +async def test_inline_local_images_uses_data_uri_and_reports_missing(): + md, missing = await md_image_refs.inline_local_images( + '![a](charts/a.png) ', "output/r.md", _fetcher({"output/charts/a.png": PNG}) + ) + assert "](data:image/png;base64," in md + assert '' in md and missing == ["b.png"] + + +def _version(final_files): + return SimpleNamespace(id="48e1bcaeae7f4d6fa3dc5d2c72c346e1", output_result={"final_files": final_files}) + + +def test_deliverable_workspace_path_from_recorded_local_path(): + v = _version([]) + info = {"file_path": "/root/.cache/bisheng/linsight/48e1bcae/output/减肥计划.md"} + assert deliverable_workspace_path(v, info) == "output/减肥计划.md" + assert deliverable_workspace_path(v, {"rel_path": "output/x.md", "file_path": ""}) == "output/x.md" + assert deliverable_workspace_path(v, {"file_path": "/elsewhere/x.md"}) is None + + +def test_find_deliverable_matches_only_this_versions_final_files(): + key = "linsight/final_result/48e1bcaeae7f4d6fa3dc5d2c72c346e1/675a3970.md" + v = _version([{"file_url": key, "file_name": "减肥计划.md"}, "garbage"]) + assert find_deliverable(v, key, "bisheng")["file_name"] == "减肥计划.md" + assert find_deliverable(v, "linsight/final_result/48e1bcaeae7f4d6fa3dc5d2c72c346e1/other.md", "bisheng") is None + + +# --- endpoints: md bundle download and image-embedding pdf/docx conversion ---- + +SVID = "48e1bcaeae7f4d6fa3dc5d2c72c346e1" +MD_KEY = f"linsight/final_result/{SVID}/675a3970.md" +REPORT_MD = "# 计划\n\n![曲线](output/charts/curve.png)\n" + + +@pytest.fixture +def stored_task(monkeypatch): + from unittest.mock import AsyncMock + + from bisheng.citation.domain.services import citation_export_service + from bisheng.linsight.api.endpoints import linsight as endpoint + + version = SimpleNamespace( + id=SVID, + session_id="fee0fcde", + output_result={ + "final_files": [ + { + "file_url": MD_KEY, + "file_name": "减肥计划.md", + "file_path": f"/root/.cache/bisheng/linsight/{SVID[:8]}/output/减肥计划.md", + } + ] + }, + ) + monkeypatch.setattr(citation_export_service, "resolve_items_for_export", AsyncMock(return_value=[])) + monkeypatch.setattr( + endpoint.LinsightArtifactPreviewService, "load_accessible_version", AsyncMock(return_value=version) + ) + monkeypatch.setattr(endpoint, "get_minio_storage", AsyncMock(return_value=SimpleNamespace(bucket="bisheng"))) + monkeypatch.setattr( + endpoint.LinsightWorkbenchImpl, + "download_file", + AsyncMock(return_value=("减肥计划.md", REPORT_MD.encode("utf-8"))), + ) + workspace = {"output/charts/curve.png": PNG} + monkeypatch.setattr(endpoint, "workspace_fetcher", lambda svid: _fetcher(workspace)) + return endpoint, workspace + + +async def _body(resp) -> bytes: + return b"".join([chunk async for chunk in resp.body_iterator]) + + +async def test_md_bundle_endpoint_returns_zip_with_images(stored_task): + endpoint, _ = stored_task + resp = await endpoint.download_md_bundle( + session_version_id=SVID, + file_url=MD_KEY, + file_name="减肥计划.md", + login_user=SimpleNamespace(user_id=1), + share_link=None, + ) + assert resp.media_type == "application/zip" + assert "%E5%87%8F%E8%82%A5%E8%AE%A1%E5%88%92.zip" in resp.headers["content-disposition"] + with zipfile.ZipFile(io.BytesIO(await _body(resp))) as zf: + assert zf.read("images/curve.png") == PNG + assert "](images/curve.png)" in zf.read("减肥计划.md").decode() + + +async def test_md_bundle_endpoint_falls_back_to_plain_md_without_images(stored_task): + endpoint, workspace = stored_task + workspace.clear() + resp = await endpoint.download_md_bundle( + session_version_id=SVID, + file_url=MD_KEY, + file_name="减肥计划.md", + login_user=SimpleNamespace(user_id=1), + share_link=None, + ) + assert resp.media_type.startswith("text/markdown") + assert (await _body(resp)).decode() == REPORT_MD + + +async def test_md_bundle_endpoint_rejects_non_deliverable(stored_task): + from bisheng.common.errcode.http_error import NotFoundError + + endpoint, _ = stored_task + with pytest.raises(NotFoundError): + await endpoint.download_md_bundle( + session_version_id=SVID, + file_url=f"linsight/session_files/{SVID}/scratch.md", + file_name="x.md", + login_user=SimpleNamespace(user_id=1), + share_link=None, + ) + + +async def test_docx_conversion_embeds_workspace_images_when_version_given(stored_task, monkeypatch): + import bisheng.common.utils.markdown_cmpnt.md_to_docx.markdocx as markdocx_mod + from bisheng.linsight.domain.schemas.linsight_schema import DownloadFilesSchema + + endpoint, _ = stored_task + seen = {} + + class _FakeMarkDocx: + def __call__(self, md): + seen["md"] = md + return (b"DOCX", "t") + + monkeypatch.setattr(markdocx_mod, "MarkDocx", _FakeMarkDocx) + await endpoint.download_md_to_pdf_or_docx( + file_info=DownloadFilesSchema(file_name="减肥计划.md", file_url=MD_KEY), + to_type="docx", + session_version_id=SVID, + login_user=SimpleNamespace(user_id=1), + share_link=None, + ) + assert "](data:image/png;base64," in seen["md"] diff --git a/src/backend/test/linsight/test_personal_skill_access.py b/src/backend/test/linsight/test_personal_skill_access.py new file mode 100644 index 0000000000..155498c0da --- /dev/null +++ b/src/backend/test/linsight/test_personal_skill_access.py @@ -0,0 +1,214 @@ +"""Single-skill reads authorize only after the row belongs to the caller. + +A delete of a missing id, or of someone else's skill, stays the empty result +from the lifecycle tests. Detail, file read, update and enable/disable of those +ids are 11053. None of those misses call require_business_action. +""" + +from contextlib import asynccontextmanager +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +import bisheng.linsight.domain.models.linsight_skill as model_module +from bisheng.common.errcode.linsight import SkillNotFoundError, SkillVersionConflictError +from bisheng.core.context.tenant import bypass_tenant_filter, set_current_tenant_id +from bisheng.linsight.domain.models.linsight_skill import LinsightSkill, LinsightSkillDao +from bisheng.linsight.domain.services.personal_skill_service import PersonalSkillService +from bisheng.linsight.domain.services.skill_store import SKILL_MD, SkillStore +from test.linsight.fixtures.fake_minio import FakeMinioStorage + +TENANT = 5 +OTHER_TENANT = 9 +USER = 11 +OTHER = 22 +GATE = "bisheng.permission.application.business_authorization.require_business_action" + + +def _md(name: str, body: str, display: str) -> bytes: + return ( + f"---\nname: {name}\ndescription: 用途说明\nmetadata:\n display-name: {display}\n---\n\n# {body}\n" + ).encode() + + +def _assert_hidden(exc: SkillNotFoundError, *secrets: str) -> None: + assert exc.code == 11053 + assert exc.kwargs == {} + text = str(exc.to_dict()) + for secret in secrets: + assert secret not in text + + +def _authorized(gate, *, action: str, skill_id: int) -> bool: + return any( + call.kwargs.get("action") == action + and call.kwargs.get("resource_type") == "linsight_skill" + and call.kwargs.get("resource_id") == skill_id + for call in gate.await_args_list + ) + + +@pytest.fixture +async def env(tmp_path, monkeypatch): + from sqlalchemy.ext.asyncio import create_async_engine + from sqlalchemy.pool import StaticPool + from sqlmodel.ext.asyncio.session import AsyncSession + + from bisheng.core.database import tenant_filter + + engine = create_async_engine( + "sqlite+aiosqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + async with engine.begin() as conn: + await conn.run_sync(LinsightSkill.__table__.create) + + @asynccontextmanager + async def _session(): + session = AsyncSession(bind=engine, expire_on_commit=False) + try: + yield session + except Exception: + await session.rollback() + raise + finally: + await session.close() + + monkeypatch.setattr(model_module, "get_async_db_session", _session) + monkeypatch.setattr(PersonalSkillService, "_audit", AsyncMock()) + tenant_filter._initialized = False + tenant_filter._tenant_aware_tables = set() + tenant_filter.register_tenant_filter_events() + set_current_tenant_id(TENANT) + store = SkillStore(root=tmp_path / "cache", minio=FakeMinioStorage()) + service = PersonalSkillService( + store=store, + owner_projection=SimpleNamespace(authorize_created=AsyncMock()), + unpack_root=tmp_path / "unpack", + ) + yield SimpleNamespace(dao=LinsightSkillDao, service=service, store=store) + tenant_filter._initialized = False + tenant_filter._tenant_aware_tables = set() + await engine.dispose() + + +async def _save(env, name: str, owner: int, body: str, display: str, *, enabled: bool = True, tenant_id: int = TENANT): + if owner == 0 or tenant_id == TENANT: + ref = env.store.write_bundle(tenant_id, name, {SKILL_MD: _md(name, body, display)}, owner_user_id=owner) + object_path, content_hash, size = ref.object_key, ref.content_hash, ref.size + else: + object_path, content_hash, size = f"linsight/skills/{tenant_id}/{name}/missing.zip", "b" * 64, 10 + row = LinsightSkill( + tenant_id=tenant_id, + owner_user_id=owner, + name=name, + display_name=display, + description="用途说明", + enabled=enabled, + source="manual", + object_path=object_path, + content_hash=content_hash, + size=size, + created_by=owner or 1, + ) + with bypass_tenant_filter(): + return await env.dao.create(row) + + +class TestPersonalSkillAccess: + async def test_missing_or_foreign_rows_stay_hidden(self, env): + skill = await _save(env, "pdf", USER, "个人正文", "个人报告") + secrets = (skill.display_name, skill.object_path, "个人正文") + foreign = await _save(env, "foreign", 0, "外租户正文", "外租户技能", tenant_id=OTHER_TENANT) + + async def _reject(user_id: int, skill_id: int): + with patch(GATE, new_callable=AsyncMock) as gate: + with pytest.raises(SkillNotFoundError) as caught: + await env.service.get(user_id=user_id, skill_id=skill_id) + _assert_hidden(caught.value, *secrets) + with pytest.raises(SkillNotFoundError) as caught: + await env.service.read_file(user_id=user_id, skill_id=skill_id, path=SKILL_MD) + _assert_hidden(caught.value, *secrets) + with pytest.raises(SkillNotFoundError) as caught: + await env.service.overwrite( + user_id=user_id, + skill_id=skill_id, + filename="pdf.md", + data=b"nope", + expected_content_hash=skill.content_hash, + import_id="imp-hidden", + ) + _assert_hidden(caught.value, *secrets) + with pytest.raises(SkillNotFoundError) as caught: + await env.service.set_enabled(user_id=user_id, skill_id=skill_id, enabled=False) + _assert_hidden(caught.value, *secrets) + assert await env.service.delete(user_id=user_id, skill_id=skill_id) is None + gate.assert_not_called() + stored = await env.dao.get_by_id(skill.id) + assert stored is not None and bool(stored.enabled) is True + + await _reject(OTHER, skill.id) + await _reject(USER, 999) + await _reject(USER, foreign.id) + + async def test_owner_is_authorized_before_the_content_is_returned(self, env): + skill = await _save(env, "pdf", USER, "个人正文", "个人报告") + with patch(GATE, new_callable=AsyncMock) as gate: + detail = await env.service.get(user_id=USER, skill_id=skill.id) + assert detail.bundle_missing is False + assert "个人正文" in detail.source_text + assert _authorized(gate, action="edit", skill_id=skill.id) + + gate.reset_mock() + opened = await env.service.read_file(user_id=USER, skill_id=skill.id, path=SKILL_MD) + assert "个人正文" in opened.content + assert _authorized(gate, action="edit", skill_id=skill.id) + + gate.reset_mock() + with pytest.raises(SkillVersionConflictError): + await env.service.overwrite( + user_id=USER, + skill_id=skill.id, + filename="pdf.md", + data=b"nope", + expected_content_hash="0" * 64, + import_id="imp-stale", + ) + assert _authorized(gate, action="edit", skill_id=skill.id) + + gate.reset_mock() + saved = await env.service.set_enabled(user_id=USER, skill_id=skill.id, enabled=False) + assert bool(saved.enabled) is False + assert _authorized(gate, action="edit", skill_id=skill.id) + + gate.reset_mock() + assert await env.service.delete(user_id=USER, skill_id=skill.id) is None + assert _authorized(gate, action="delete", skill_id=skill.id) + assert await env.dao.get_by_id(skill.id) is None + + async def test_a_missing_bundle_is_still_a_detail(self, env): + skill = await _save(env, "pdf", USER, "个人正文", "个人报告") + env.store.delete_version(TENANT, "pdf", skill.content_hash, owner_user_id=USER) + with patch(GATE, new_callable=AsyncMock) as gate: + detail = await env.service.get(user_id=USER, skill_id=skill.id) + assert detail.bundle_missing is True + assert detail.source_text == "" + assert detail.skill.id == skill.id + assert _authorized(gate, action="edit", skill_id=skill.id) + + async def test_a_disabled_enterprise_skill_stays_distinct_from_a_deleted_one(self, env): + skill = await _save(env, "he-tong", 0, "企业正文", "合同审阅", enabled=False) + with patch(GATE, new_callable=AsyncMock) as gate: + detail = await env.service.get(user_id=USER, skill_id=skill.id) + assert bool(detail.skill.enabled) is False + assert detail.bundle_missing is False + assert "企业正文" in detail.source_text + gate.assert_not_called() + + await env.dao.delete_by_name("he-tong") + with pytest.raises(SkillNotFoundError) as caught: + await env.service.get(user_id=USER, skill_id=skill.id) + _assert_hidden(caught.value, "合同审阅", "企业正文", skill.object_path) + gate.assert_not_called() diff --git a/src/backend/test/linsight/test_personal_skill_api.py b/src/backend/test/linsight/test_personal_skill_api.py new file mode 100644 index 0000000000..448e1da4f6 --- /dev/null +++ b/src/backend/test/linsight/test_personal_skill_api.py @@ -0,0 +1,285 @@ +"""Personal skill HTTP routes, beside the unchanged management routes.""" + +import json +from contextlib import asynccontextmanager +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from fastapi.testclient import TestClient + +import bisheng.linsight.domain.models.linsight_skill as model_module +from bisheng.common.dependencies.user_deps import UserPayload +from bisheng.common.errcode.base import BaseErrorCode +from bisheng.common.exceptions.auth import AuthJWTException +from bisheng.core.context.tenant import set_current_tenant_id +from bisheng.linsight.api.endpoints import personal_skill as personal_endpoints +from bisheng.linsight.api.endpoints import skill as skill_endpoints +from bisheng.linsight.domain.models.linsight_skill import LinsightSkill +from bisheng.linsight.domain.services.personal_skill_service import PERSONAL_SKILL_BYTES_LIMIT, PersonalSkillService +from bisheng.linsight.domain.services.skill_service import SkillService +from bisheng.linsight.domain.services.skill_store import SkillStore +from test.linsight.fixtures.fake_minio import FakeMinioStorage + +BASE = "/api/v1/linsight/skill" +TENANT = 5 +USER = 11 +LOCALE_DIR = Path(__file__).resolve().parents[3] / "frontend" / "packages" / "locales" / "src" / "api_errors" + + +def _md(name: str = "pdf", body: str = "个人正文", display: str = "个人报告") -> bytes: + return ( + f"---\nname: {name}\ndescription: 用途说明\nmetadata:\n display-name: {display}\n---\n\n# {body}\n" + ).encode() + + +def _app() -> FastAPI: + app = FastAPI() + + @app.middleware("http") + async def _tenant(request: Request, call_next): + set_current_tenant_id(TENANT) + return await call_next(request) + + @app.exception_handler(BaseErrorCode) + def handle_business_error(request: Request, exc: BaseErrorCode): + data = {"exception": str(exc), **exc.kwargs} if exc.kwargs else {"exception": str(exc)} + return JSONResponse(content={"status_code": exc.code, "status_message": exc.message, "data": data}) + + @app.exception_handler(AuthJWTException) + def handle_auth(request: Request, exc: AuthJWTException): + return JSONResponse(status_code=401, content={"detail": getattr(exc, "message", str(exc))}) + + # Literal paths first, matching linsight.api.router. + app.include_router(personal_endpoints.router, prefix="/api/v1/linsight") + app.include_router(skill_endpoints.router, prefix="/api/v1/linsight") + return app + + +@pytest.fixture +def api(tmp_path, monkeypatch): + from sqlalchemy import create_engine + from sqlalchemy.ext.asyncio import create_async_engine + from sqlmodel.ext.asyncio.session import AsyncSession + + from bisheng.core.database import tenant_filter + + db_path = tmp_path / "skills.db" + sync_engine = create_engine(f"sqlite:///{db_path}") + LinsightSkill.__table__.create(sync_engine) + sync_engine.dispose() + engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}", connect_args={"timeout": 5}) + + @asynccontextmanager + async def _session(): + session = AsyncSession(bind=engine, expire_on_commit=False) + try: + yield session + except Exception: + await session.rollback() + raise + finally: + await session.close() + + monkeypatch.setattr(model_module, "get_async_db_session", _session) + monkeypatch.setattr(PersonalSkillService, "_audit", AsyncMock()) + + async def permitted(_user, *, resource_ids, **kwargs): + return {str(skill_id): {"use"} for skill_id in resource_ids} + + monkeypatch.setattr("bisheng.permission.application.business_authorization.batch_check_business_actions", permitted) + monkeypatch.setattr( + "bisheng.linsight.domain.services.user_skills.SkillAvailabilityRepository.disabled_ids", + AsyncMock(return_value=set()), + ) + + monkeypatch.setattr( + "bisheng.permission.application.business_authorization.require_business_action", + AsyncMock(), + ) + monkeypatch.setattr(skill_endpoints, "_current_tenant_id", lambda: TENANT) + tenant_filter._initialized = False + tenant_filter._tenant_aware_tables = set() + tenant_filter.register_tenant_filter_events() + set_current_tenant_id(TENANT) + + minio = FakeMinioStorage() + store = SkillStore(root=tmp_path, minio=minio) + projection = SimpleNamespace(authorize_created=AsyncMock()) + service = PersonalSkillService( + store=store, + owner_projection=projection, + unpack_root=tmp_path / "unpack", + ) + monkeypatch.setattr(personal_endpoints, "_service", lambda: service) + monkeypatch.setattr( + skill_endpoints, + "SkillService", + lambda: SkillService(store=store, owner_projection=projection), + ) + + monkeypatch.setattr("bisheng.utils.http_middleware._check_is_global_super", AsyncMock(return_value=False)) + logged_in = _app() + + async def end_user(): + return SimpleNamespace(user_id=USER, user_name="member", tenant_id=TENANT, is_global_super=False) + + async def admin_user(): + return SimpleNamespace(user_id=1, user_name="admin", tenant_id=TENANT, is_global_super=False) + + logged_in.dependency_overrides[UserPayload.get_login_user] = end_user + logged_in.dependency_overrides[UserPayload.get_tenant_admin_user] = admin_user + + yield SimpleNamespace( + client=TestClient(logged_in, raise_server_exceptions=False), + anonymous=TestClient(_app(), raise_server_exceptions=False), + minio=minio, + ) + tenant_filter._initialized = False + tenant_filter._tenant_aware_tables = set() + + +def _upload(client: TestClient, name: str, import_id: str, body: str = "个人正文") -> dict: + resp = client.post( + f"{BASE}/personal", + files={"file": (f"{name}.md", _md(name, body), "text/markdown")}, + data={"import_id": import_id}, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["status_code"] == 200 + return resp.json()["data"] + + +class TestPersonalSkillApi: + def test_logged_out_request_is_rejected(self, api): + resp = api.anonymous.get(f"{BASE}/center") + assert resp.status_code == 401 + + def test_literal_paths_are_not_read_as_skill_names(self, api): + center = api.client.get(f"{BASE}/center").json() + assert center["status_code"] == 200 + assert set(center["data"]) == {"personal", "enterprise", "personal_count", "enterprise_count"} + usage = api.client.get(f"{BASE}/personal/usage").json() + assert usage["data"]["count_limit"] == 20 + assert usage["data"]["bytes_limit"] == PERSONAL_SKILL_BYTES_LIMIT + candidates = api.client.get(f"{BASE}/candidates").json() + assert candidates["data"] == [] + + def test_management_list_stays_enterprise_only(self, api): + created = api.client.post( + BASE, + data={ + "display_name": "合同审阅", + "name": "he-tong", + "description": "审阅合同", + "content": "# 企业", + }, + ).json() + assert created["status_code"] == 200 + personal = _upload(api.client, "pdf", "imp-1") + listed = api.client.get(BASE).json()["data"]["data"] + assert [row["name"] for row in listed] == ["he-tong"] + assert set(listed[0]) == { + "id", + "name", + "display_name", + "description", + "enabled", + "default_checked", + "source", + "create_time", + "update_time", + } + selectable = api.client.get(f"{BASE}/selectable").json()["data"] + assert {row["name"] for row in selectable} == {"he-tong", "pdf"} + assert set(selectable[0]) == {"name", "display_name", "description", "default_checked"} + + center = api.client.get(f"{BASE}/center").json()["data"] + assert [row["name"] for row in center["personal"]] == ["pdf"] + assert [row["name"] for row in center["enterprise"]] == ["he-tong"] + assert center["personal_count"] == 1 and center["enterprise_count"] == 1 + assert center["personal"][0]["bundle_missing"] is False + assert center["personal"][0]["update_time"] + assert personal["id"] == center["personal"][0]["id"] + + candidates = api.client.get(f"{BASE}/candidates").json()["data"] + assert {row["id"] for row in candidates} == {created["data"]["id"], personal["id"]} + + toggled = api.client.patch(f"{BASE}/he-tong/status", json={"enabled": False}).json() + assert toggled["data"] == {"ok": True} + assert [row["name"] for row in api.client.get(f"{BASE}/selectable").json()["data"]] == ["pdf"] + assert api.client.get(f"{BASE}/candidates").json()["data"][0]["id"] == personal["id"] + assert api.client.delete(f"{BASE}/he-tong").json()["data"] == {"ok": True} + + def test_duplicate_does_not_write_and_uses_the_translated_code(self, api): + saved = _upload(api.client, "pdf", "imp-1") + puts = api.minio.put_calls + resp = api.client.post( + f"{BASE}/personal", + files={"file": ("pdf.md", _md("pdf", "另一份"), "text/markdown")}, + data={"import_id": "imp-2"}, + ).json() + assert resp["status_code"] == 11055 + assert resp["data"]["skill_id"] == saved["id"] + assert resp["data"]["content_hash"] == saved["content_hash"] + assert "个人报告" not in json.dumps(resp, ensure_ascii=False) + assert api.minio.put_calls == puts + for language in ("zh-Hans", "en", "ja"): + copy = json.loads((LOCALE_DIR / f"{language}.json").read_text(encoding="utf-8")) + assert "11055" in copy and "11053" in copy + + def test_overwrite_detail_file_status_and_delete(self, api): + saved = _upload(api.client, "pdf", "imp-1", body="第一版") + detail = api.client.get(f"{BASE}/personal/{saved['id']}").json()["data"] + assert detail["bundle_missing"] is False + assert "第一版" in detail["source_text"] + opened = api.client.get(f"{BASE}/personal/{saved['id']}/file", params={"path": "SKILL.md"}).json() + assert "第一版" in opened["data"]["content"] + + replaced = api.client.post( + f"{BASE}/personal/{saved['id']}/overwrite", + files={"file": ("pdf.md", _md("pdf", "第二版"), "text/markdown")}, + data={"import_id": "imp-2", "expected_content_hash": saved["content_hash"]}, + ).json()["data"] + assert replaced["id"] == saved["id"] + assert replaced["content_hash"] != saved["content_hash"] + assert replaced["enabled"] is True + + found = api.client.get(f"{BASE}/personal/import/imp-2").json() + assert found["status_code"] == 200 + assert found["data"]["id"] == saved["id"] + missing = api.client.get(f"{BASE}/personal/import/missing").json() + assert missing["status_code"] == 11053 + + paused = api.client.patch(f"{BASE}/personal/{saved['id']}/status", json={"enabled": False}).json()["data"] + assert paused["enabled"] is False + assert api.client.get(f"{BASE}/candidates").json()["data"] == [] + center = api.client.get(f"{BASE}/center").json()["data"] + assert center["personal"][0]["enabled"] is False + + assert api.client.delete(f"{BASE}/personal/{saved['id']}").json()["data"] == {} + gone = api.client.get(f"{BASE}/personal/{saved['id']}").json() + assert gone["status_code"] == 11053 + assert "个人报告" not in json.dumps(gone, ensure_ascii=False) + assert api.client.delete(f"{BASE}/personal/{saved['id']}").json()["data"] == {} + + +@pytest.mark.parametrize("source,expected", [("builtin", "bisheng-docx"), ("manual", None), ("personal", None)]) +def test_center_card_retains_builtin_provenance(source, expected): + skill = SimpleNamespace( + id=1, + name="bisheng-docx", + display_name="Configured name", + description="Description", + source=source, + enabled=True, + content_hash="revision", + update_time=None, + create_time=None, + ) + card = personal_endpoints._card(SimpleNamespace(is_bundle_missing=lambda _: False), skill) + assert card["builtin_key"] == expected + assert card["display_name"] == "Configured name" diff --git a/src/backend/test/linsight/test_personal_skill_create.py b/src/backend/test/linsight/test_personal_skill_create.py new file mode 100644 index 0000000000..1ac844301d --- /dev/null +++ b/src/backend/test/linsight/test_personal_skill_create.py @@ -0,0 +1,366 @@ +"""Personal skill create: quota, import id, and an audit row that cannot fail the save.""" + +import asyncio +import io +import shutil +import zipfile +from contextlib import asynccontextmanager, contextmanager +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest +from sqlmodel import select + +import bisheng.database.models.audit_log as audit_module +import bisheng.linsight.domain.models.linsight_skill as model_module +import bisheng.linsight.domain.services.personal_skill_service as service_module +from bisheng.common.errcode.linsight import ( + SkillCountLimitError, + SkillEmptyFileError, + SkillImportMismatchError, + SkillNameDuplicateError, + SkillNotFoundError, + SkillStorageLimitError, +) +from bisheng.core.context.tenant import ( + bypass_tenant_filter, + current_tenant_id, + set_current_tenant_id, + set_visible_tenant_ids, + visible_tenant_ids, +) +from bisheng.database.models.audit_log import AuditLog +from bisheng.linsight.domain.models.linsight_skill import LinsightSkill, LinsightSkillDao +from bisheng.linsight.domain.services.personal_skill_service import ( + PERSONAL_SKILL_BYTES_LIMIT, + PERSONAL_SKILL_COUNT_LIMIT, + PersonalSkillService, +) +from bisheng.linsight.domain.services.skill_store import SKILL_MD, SkillStore +from test.linsight.fixtures.fake_minio import FakeMinioStorage + +TENANT = 5 +OTHER_TENANT = 6 +USER = 11 +OTHER_USER = 22 + + +def _md(name: str, display: str = "报告", body: str = "body") -> bytes: + return ( + f"---\nname: {name}\ndescription: demo description\nmetadata:\n display-name: {display}\n---\n\n# {body}\n" + ).encode() + + +def _zip(name: str) -> bytes: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as archive: + archive.writestr(SKILL_MD, _md(name)) + return buf.getvalue() + + +def _seed(name: str, owner_user_id: int, *, enabled: bool = True, size: int = 10) -> LinsightSkill: + return LinsightSkill( + tenant_id=TENANT, + owner_user_id=owner_user_id, + name=name, + display_name=name, + description="seed", + enabled=enabled, + source="manual", + object_path=f"linsight/skills/{TENANT}/{name}/seed.zip", + content_hash="b" * 64, + size=size, + created_by=owner_user_id or 1, + ) + + +@contextmanager +def _as_other_tenant(): + token_tenant = set_current_tenant_id(OTHER_TENANT) + token_visible = set_visible_tenant_ids(frozenset({OTHER_TENANT, TENANT})) + try: + yield + finally: + current_tenant_id.reset(token_tenant) + visible_tenant_ids.reset(token_visible) + + +@pytest.fixture +async def env(tmp_path, monkeypatch): + from sqlalchemy.ext.asyncio import create_async_engine + from sqlmodel.ext.asyncio.session import AsyncSession + + from bisheng.core.database import tenant_filter + + engine = create_async_engine( + f"sqlite+aiosqlite:///{tmp_path}/skills.db", + connect_args={"timeout": 5}, + ) + async with engine.begin() as conn: + await conn.exec_driver_sql("PRAGMA journal_mode=WAL") + await conn.run_sync(LinsightSkill.__table__.create) + await conn.run_sync(AuditLog.__table__.create) + + @asynccontextmanager + async def _session(): + session = AsyncSession(bind=engine, expire_on_commit=False) + try: + yield session + except Exception: + await session.rollback() + raise + finally: + await session.close() + + monkeypatch.setattr(model_module, "get_async_db_session", _session) + monkeypatch.setattr(service_module, "get_async_db_session", _session) + monkeypatch.setattr(audit_module, "get_async_db_session", _session) + tenant_filter._initialized = False + tenant_filter._tenant_aware_tables = set() + tenant_filter.register_tenant_filter_events() + set_current_tenant_id(TENANT) + + store = SkillStore(root=tmp_path / "cache", minio=FakeMinioStorage()) + unpack = tmp_path / "unpack" + service = PersonalSkillService( + store=store, + owner_projection=SimpleNamespace(authorize_created=AsyncMock()), + unpack_root=unpack, + ) + yield SimpleNamespace(dao=LinsightSkillDao, service=service, store=store, unpack=unpack) + + tenant_filter._initialized = False + tenant_filter._tenant_aware_tables = set() + await engine.dispose() + + +async def _audits() -> list[AuditLog]: + with bypass_tenant_filter(): + async with model_module.get_async_db_session() as session: + return list((await session.exec(select(AuditLog))).all()) + + +class TestPersonalSkillCreate: + async def test_md_zip_and_skill_are_stored_for_the_user(self, env): + md = await env.service.create( + user_id=USER, filename="pdf.md", data=_md("pdf"), import_id="imp-md", content_type="text/plain" + ) + zipped = await env.service.create( + user_id=USER, filename="sheet.zip", data=_zip("sheet"), import_id="imp-zip", content_type="application/zip" + ) + skill = await env.service.create( + user_id=USER, + filename="deck.skill", + data=_zip("deck"), + import_id="imp-skill", + content_type="application/octet-stream", + ) + for row in (md, zipped, skill): + assert row.owner_user_id == USER + stored = await env.dao.get_by_name(row.name, owner_user_id=USER) + assert stored is not None and stored.id == row.id + shutil.rmtree(env.store.root) + assert "demo description" in env.store.read_text(TENANT, "pdf", md.content_hash, owner_user_id=USER) + kwargs = env.service._owner_projection.authorize_created.await_args.kwargs + assert kwargs["resource_type"] == "linsight_skill" + assert kwargs["owner_user_id"] == USER + assert kwargs["resource_id"] == str(skill.id) + + async def test_quota_counts_disabled_personal_skills_and_ignores_tenant_skills(self, env): + await env.dao.create(_seed("enterprise", 0, size=PERSONAL_SKILL_BYTES_LIMIT)) + for index in range(PERSONAL_SKILL_COUNT_LIMIT): + await env.dao.create(_seed(f"old-{index}", USER, enabled=False)) + usage = await env.service.usage(USER) + assert usage.count == PERSONAL_SKILL_COUNT_LIMIT + assert usage.count_limit == 20 + assert usage.bytes_limit == PERSONAL_SKILL_BYTES_LIMIT + assert usage.used_bytes == 10 * PERSONAL_SKILL_COUNT_LIMIT + + with pytest.raises(SkillCountLimitError) as caught: + await env.service.create( + user_id=USER, filename="pdf.md", data=_md("pdf"), import_id="imp-over", content_type="text/plain" + ) + assert caught.value.code == 11061 + assert await env.dao.get_by_name("pdf", owner_user_id=USER) is None + assert env.store.minio.put_calls == 0 + assert list(env.unpack.iterdir()) == [] + + async def test_storage_limit_leaves_existing_rows(self, env): + payload = _md("extra") + seeded = await env.dao.create(_seed("kept", USER, size=PERSONAL_SKILL_BYTES_LIMIT - len(payload) + 1)) + with pytest.raises(SkillStorageLimitError) as caught: + await env.service.create( + user_id=USER, filename="extra.md", data=payload, import_id="imp-bytes", content_type="text/plain" + ) + assert caught.value.code == 11062 + assert (await env.dao.get_by_name("kept", owner_user_id=USER)).size == seeded.size + assert env.store.minio.put_calls == 0 + + async def test_rejected_upload_writes_nothing(self, env): + with pytest.raises(SkillEmptyFileError): + await env.service.create( + user_id=USER, filename="pdf.md", data=b"", import_id="imp-empty", content_type="text/plain" + ) + assert env.store.minio.put_calls == 0 + assert list(env.unpack.iterdir()) == [] + + async def test_cancel_removes_the_unpack_directory(self, env, monkeypatch): + def _cancel(*args, **kwargs): + raise asyncio.CancelledError() + + monkeypatch.setattr(env.store, "write_bundle", _cancel) + with pytest.raises(asyncio.CancelledError): + await env.service.create( + user_id=USER, filename="pdf.md", data=_md("pdf"), import_id="imp-cancel", content_type="text/plain" + ) + assert await env.dao.get_by_name("pdf", owner_user_id=USER) is None + assert env.store.minio.put_calls == 0 + assert list(env.unpack.iterdir()) == [] + + async def test_same_personal_name_returns_11055_without_a_write(self, env): + first = await env.service.create( + user_id=USER, filename="pdf.md", data=_md("pdf", body="one"), import_id="imp-1", content_type="text/plain" + ) + puts = env.store.minio.put_calls + with pytest.raises(SkillNameDuplicateError) as caught: + await env.service.create( + user_id=USER, + filename="pdf.md", + data=_md("pdf", body="two"), + import_id="imp-2", + content_type="text/plain", + ) + data = caught.value.to_dict()["data"] + assert data["skill_id"] == first.id + assert data["content_hash"] == first.content_hash + assert env.store.minio.put_calls == puts + assert (await env.dao.get_by_name("pdf", owner_user_id=USER)).content_hash == first.content_hash + + async def test_enterprise_skill_with_the_same_name_is_not_a_duplicate(self, env): + await env.dao.create(_seed("pdf", 0)) + created = await env.service.create( + user_id=USER, filename="pdf.md", data=_md("pdf"), import_id="imp-own", content_type="text/plain" + ) + assert created.owner_user_id == USER + assert await env.dao.get_by_name("pdf", owner_user_id=0) is not None + + async def test_import_id_retries_and_mismatches(self, env): + payload = _md("pdf") + first = await env.service.create( + user_id=USER, filename="pdf.md", data=payload, import_id="imp-retry", content_type="text/plain" + ) + puts = env.store.minio.put_calls + again = await env.service.create( + user_id=USER, filename="pdf.md", data=payload, import_id="imp-retry", content_type="text/plain" + ) + assert again.id == first.id + assert env.store.minio.put_calls == puts + found = await env.service.find_by_import(USER, "imp-retry") + assert found.id == first.id + + with pytest.raises(SkillImportMismatchError) as caught: + await env.service.create( + user_id=USER, + filename="pdf.md", + data=_md("pdf", body="changed"), + import_id="imp-retry", + content_type="text/plain", + ) + assert caught.value.code == 11066 + assert (await env.dao.get_by_name("pdf", owner_user_id=USER)).content_hash == first.content_hash + assert env.store.minio.put_calls == puts + + with pytest.raises(SkillNotFoundError) as missing: + await env.service.find_by_import(USER, "missing") + assert missing.value.code == 11053 + + async def test_concurrent_same_name_keeps_one_row(self, env): + async def _save(body: str, import_id: str): + return await env.service.create( + user_id=USER, + filename="pdf.md", + data=_md("pdf", body=body), + import_id=import_id, + content_type="text/plain", + ) + + results = await asyncio.gather(_save("one", "imp-a"), _save("two", "imp-b"), return_exceptions=True) + saved = [item for item in results if not isinstance(item, Exception)] + failed = [item for item in results if isinstance(item, Exception)] + assert len(saved) == 1 and len(failed) == 1 + assert isinstance(failed[0], SkillNameDuplicateError) + winner = await env.dao.get_by_name("pdf", owner_user_id=USER) + assert winner is not None + assert env.store.minio.keys(f"linsight/personal-skills/{TENANT}/{USER}/pdf/") == [winner.object_path] + + async def test_audit_records_the_save_and_a_failed_audit_keeps_the_skill(self, env, monkeypatch): + skill = await env.service.create( + user_id=USER, filename="pdf.md", data=_md("pdf"), import_id="imp-audit", content_type="text/plain" + ) + logs = await _audits() + assert len(logs) == 1 + entry = logs[0] + assert entry.operator_id == USER + assert entry.tenant_id == TENANT + assert entry.system_id == "linsight" + assert entry.target_id == str(skill.id) + assert entry.audit_metadata == {"source": "manual", "content_hash": skill.content_hash, "result": "success"} + assert entry.object_name is None + assert entry.note is None + + class _Boom: + def __init__(self, **kwargs): + raise RuntimeError("audit down") + + monkeypatch.setattr(service_module, "AuditLog", _Boom) + kept = await env.service.create( + user_id=USER, filename="other.md", data=_md("other"), import_id="imp-audit-2", content_type="text/plain" + ) + assert (await env.dao.get_by_name("other", owner_user_id=USER)).id == kept.id + + async def test_another_tenant_cannot_see_the_row(self, env): + await env.service.create( + user_id=USER, filename="pdf.md", data=_md("pdf"), import_id="imp-tenant", content_type="text/plain" + ) + with _as_other_tenant(): + assert await env.dao.get_by_name("pdf", owner_user_id=USER) is None + usage = await env.service.usage(USER) + assert usage.count == 0 and usage.used_bytes == 0 + + async def test_load_own_skill_hides_other_users(self, env): + skill = await env.service.create( + user_id=USER, filename="pdf.md", data=_md("pdf"), import_id="imp-load", content_type="text/plain" + ) + with patch( + "bisheng.permission.application.business_authorization.require_business_action", + new_callable=AsyncMock, + ) as gate: + assert (await env.service._load_own_skill(USER, skill.id)).id == skill.id + gate.assert_awaited() + calls = env.service._owner_projection.authorize_created.await_count + gate.reset_mock() + with pytest.raises(SkillNotFoundError) as caught: + await env.service._load_own_skill(OTHER_USER, skill.id) + assert caught.value.code == 11053 + gate.assert_not_called() + assert env.service._owner_projection.authorize_created.await_count == calls + + async def test_inspect_upload_checks_the_file_and_does_not_save_it(self, env): + with pytest.raises(SkillEmptyFileError): + await env.service.inspect_upload(user_id=USER, filename="empty.md", data=b"", content_type="text/plain") + preview = await env.service.inspect_upload( + user_id=USER, filename="pdf.md", data=_md("pdf"), content_type="text/plain" + ) + assert preview.name == "pdf" + assert preview.display_name == "报告" + assert "SKILL.md" in preview.files + assert await env.dao.get_by_name("pdf", owner_user_id=USER) is None + assert list(env.unpack.iterdir()) == [] + + await env.service.create( + user_id=USER, filename="pdf.md", data=_md("pdf"), import_id="imp-check", content_type="text/plain" + ) + with pytest.raises(SkillNameDuplicateError): + await env.service.inspect_upload( + user_id=USER, filename="pdf.md", data=_md("pdf"), content_type="text/plain" + ) diff --git a/src/backend/test/linsight/test_personal_skill_lifecycle.py b/src/backend/test/linsight/test_personal_skill_lifecycle.py new file mode 100644 index 0000000000..15b3aff02b --- /dev/null +++ b/src/backend/test/linsight/test_personal_skill_lifecycle.py @@ -0,0 +1,161 @@ +"""Enabling, disabling and deleting a personal skill.""" + +from contextlib import asynccontextmanager +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from sqlmodel import select + +import bisheng.linsight.domain.models.linsight_skill as model_module +import bisheng.linsight.domain.services.personal_skill_service as service_module +from bisheng.common.errcode.linsight import SkillNotFoundError +from bisheng.core.context.tenant import bypass_tenant_filter, set_current_tenant_id +from bisheng.database.models.audit_log import AuditLog +from bisheng.linsight.domain.models.linsight_skill import LinsightSkill, LinsightSkillDao +from bisheng.linsight.domain.services.personal_skill_service import PersonalSkillService +from bisheng.linsight.domain.services.skill_store import SKILL_MD, SkillStore +from test.linsight.fixtures.fake_minio import FakeMinioStorage + +TENANT = 5 +USER = 11 +OTHER = 22 +STAMP = datetime(2020, 1, 1, 8, 0, 0) + + +def _md(body: str) -> bytes: + return f"---\nname: pdf\ndescription: demo description\nmetadata:\n display-name: 报告\n---\n\n# {body}\n".encode() + + +@pytest.fixture +async def env(tmp_path, monkeypatch): + from sqlalchemy.ext.asyncio import create_async_engine + from sqlmodel.ext.asyncio.session import AsyncSession + + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/skills.db", connect_args={"timeout": 5}) + async with engine.begin() as conn: + await conn.run_sync(LinsightSkill.__table__.create) + await conn.run_sync(AuditLog.__table__.create) + + @asynccontextmanager + async def _session(): + session = AsyncSession(bind=engine, expire_on_commit=False) + try: + yield session + except Exception: + await session.rollback() + raise + finally: + await session.close() + + monkeypatch.setattr(model_module, "get_async_db_session", _session) + monkeypatch.setattr(service_module, "get_async_db_session", _session) + monkeypatch.setattr( + "bisheng.permission.application.business_authorization.require_business_action", + AsyncMock(), + ) + set_current_tenant_id(TENANT) + store = SkillStore(root=tmp_path / "cache", minio=FakeMinioStorage()) + service = PersonalSkillService( + store=store, + owner_projection=SimpleNamespace(authorize_created=AsyncMock()), + unpack_root=tmp_path / "unpack", + ) + yield SimpleNamespace(dao=LinsightSkillDao, service=service, store=store) + await engine.dispose() + + +async def _skill(env, owner: int, body: str, *, enabled: bool = True) -> LinsightSkill: + ref = env.store.write_bundle(TENANT, "pdf", {SKILL_MD: _md(body)}, owner_user_id=owner) + return await env.dao.create( + LinsightSkill( + tenant_id=TENANT, + owner_user_id=owner, + name="pdf", + display_name="报告", + description="demo description", + enabled=enabled, + source="manual", + object_path=ref.object_key, + content_hash=ref.content_hash, + size=ref.size, + created_by=owner, + create_time=STAMP, + update_time=STAMP, + ) + ) + + +async def _audits() -> list[AuditLog]: + with bypass_tenant_filter(): + async with model_module.get_async_db_session() as session: + return list((await session.exec(select(AuditLog))).all()) + + +class TestPersonalSkillLifecycle: + async def test_same_state_keeps_the_row_and_skips_audit(self, env): + skill = await _skill(env, USER, "one", enabled=True) + saved = await env.service.set_enabled(user_id=USER, skill_id=skill.id, enabled=True) + assert saved.id == skill.id + stored = await env.dao.get_by_id(skill.id) + assert stored.update_time == STAMP + assert bool(stored.enabled) is True + assert await _audits() == [] + + async def test_disabling_keeps_the_row_and_the_object(self, env): + skill = await _skill(env, USER, "one", enabled=True) + saved = await env.service.set_enabled(user_id=USER, skill_id=skill.id, enabled=False) + assert bool(saved.enabled) is False + stored = await env.dao.get_by_id(skill.id) + assert stored is not None + assert bool(stored.enabled) is False + assert env.store.exists(TENANT, "pdf", skill.content_hash, owner_user_id=USER) + logs = await _audits() + assert len(logs) == 1 + assert logs[0].system_id == "linsight" + assert logs[0].operator_id == USER + assert logs[0].target_id == str(skill.id) + assert logs[0].audit_metadata["result"] == "success" + + again = await env.service.set_enabled(user_id=USER, skill_id=skill.id, enabled=False) + assert bool(again.enabled) is False + assert (await env.dao.get_by_id(skill.id)).update_time == stored.update_time + assert len(await _audits()) == 1 + + async def test_failed_toggle_keeps_the_previous_value(self, env, monkeypatch): + skill = await _skill(env, USER, "one", enabled=True) + + async def _boom(*args, **kwargs): + raise RuntimeError("toggle failed") + + monkeypatch.setattr(LinsightSkillDao, "set_owner_enabled", _boom) + with pytest.raises(RuntimeError, match="toggle failed"): + await env.service.set_enabled(user_id=USER, skill_id=skill.id, enabled=False) + assert bool((await env.dao.get_by_id(skill.id)).enabled) is True + assert await _audits() == [] + + async def test_missing_skill_cannot_be_toggled(self, env): + with pytest.raises(SkillNotFoundError) as caught: + await env.service.set_enabled(user_id=USER, skill_id=999, enabled=False) + assert caught.value.code == 11053 + + async def test_delete_removes_the_row_and_its_object_only(self, env): + mine = await _skill(env, USER, "mine") + theirs = await _skill(env, OTHER, "theirs") + result = await env.service.delete(user_id=USER, skill_id=mine.id) + assert result is None + assert await env.dao.get_by_id(mine.id) is None + assert not env.store.exists(TENANT, "pdf", mine.content_hash, owner_user_id=USER) + assert env.store.minio.keys(f"linsight/personal-skills/{TENANT}/{USER}/pdf/") == [] + still = await env.dao.get_by_name("pdf", owner_user_id=OTHER) + assert still is not None and still.id == theirs.id + assert env.store.exists(TENANT, "pdf", theirs.content_hash, owner_user_id=OTHER) + + async def test_delete_of_a_missing_or_other_skill_is_empty(self, env): + skill = await _skill(env, USER, "mine") + missing = await env.service.delete(user_id=USER, skill_id=999) + someone_else = await env.service.delete(user_id=OTHER, skill_id=skill.id) + assert missing is None and someone_else is None + assert await env.dao.get_by_id(skill.id) is not None + assert env.store.exists(TENANT, "pdf", skill.content_hash, owner_user_id=USER) diff --git a/src/backend/test/linsight/test_personal_skill_lists.py b/src/backend/test/linsight/test_personal_skill_lists.py new file mode 100644 index 0000000000..087aae2df8 --- /dev/null +++ b/src/backend/test/linsight/test_personal_skill_lists.py @@ -0,0 +1,166 @@ +"""Skill center lists every owned skill. Task candidates stay enabled-only.""" + +from contextlib import asynccontextmanager +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +import bisheng.linsight.domain.models.linsight_skill as model_module +from bisheng.core.context.tenant import bypass_tenant_filter, set_current_tenant_id +from bisheng.linsight.domain.models.linsight_skill import LinsightSkill, LinsightSkillDao +from bisheng.linsight.domain.services.personal_skill_service import PersonalSkillService +from bisheng.linsight.domain.services.skill_store import SkillStore +from test.linsight.fixtures.fake_minio import FakeMinioStorage + +TENANT = 5 +OTHER_TENANT = 9 +USER = 11 +OTHER = 22 + + +def _row( + name: str, + owner: int, + display: str, + description: str, + *, + enabled: bool = True, + tenant_id: int = TENANT, +) -> LinsightSkill: + return LinsightSkill( + tenant_id=tenant_id, + owner_user_id=owner, + name=name, + display_name=display, + description=description, + enabled=enabled, + source="manual", + object_path=f"linsight/skills/{tenant_id}/{name}/hash.zip", + content_hash="a" * 64, + size=10, + created_by=owner or 1, + ) + + +@pytest.fixture +async def env(monkeypatch, tmp_path): + from sqlalchemy.ext.asyncio import create_async_engine + from sqlalchemy.pool import StaticPool + from sqlmodel.ext.asyncio.session import AsyncSession + + from bisheng.core.database import tenant_filter + + engine = create_async_engine( + "sqlite+aiosqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + async with engine.begin() as conn: + await conn.run_sync(LinsightSkill.__table__.create) + + @asynccontextmanager + async def _session(): + session = AsyncSession(bind=engine, expire_on_commit=False) + try: + yield session + except Exception: + await session.rollback() + raise + finally: + await session.close() + + monkeypatch.setattr(model_module, "get_async_db_session", _session) + tenant_filter._initialized = False + tenant_filter._tenant_aware_tables = set() + tenant_filter.register_tenant_filter_events() + set_current_tenant_id(TENANT) + + with bypass_tenant_filter(): + await LinsightSkillDao.create(_row("he-tong", 0, "合同审阅", "审阅合同")) + await LinsightSkillDao.create(_row("old-ent", 0, "旧企业", "已停用的企业技能", enabled=False)) + await LinsightSkillDao.create(_row("pdf", 0, "企业报告", "企业版")) + await LinsightSkillDao.create(_row("ji-du", USER, "季度报告", "生成报告")) + await LinsightSkillDao.create(_row("ting", USER, "停用技能", "暂时不用", enabled=False)) + await LinsightSkillDao.create(_row("pdf", USER, "个人报告", "个人版")) + await LinsightSkillDao.create(_row("other-skill", OTHER, "别人的", "不属于我")) + await LinsightSkillDao.create(_row("foreign", 0, "外租户", "别的租户", tenant_id=OTHER_TENANT)) + + monkeypatch.setattr(PersonalSkillService, "_audit", AsyncMock()) + monkeypatch.setattr( + "bisheng.permission.application.business_authorization.require_business_action", + AsyncMock(), + ) + store = SkillStore(root=tmp_path, minio=FakeMinioStorage()) + service = PersonalSkillService( + store=store, + owner_projection=SimpleNamespace(authorize_created=AsyncMock()), + unpack_root=tmp_path / "unpack", + ) + yield SimpleNamespace(dao=LinsightSkillDao, service=service, store=store) + + tenant_filter._initialized = False + tenant_filter._tenant_aware_tables = set() + await engine.dispose() + + +def _names(rows) -> set[str]: + return {row.name for row in rows} + + +class TestPersonalSkillLists: + async def test_center_keeps_disabled_personal_skills_and_enabled_tenant_skills(self, env): + with patch("bisheng.permission.application.business_authorization.require_business_action") as gate: + center = await env.service.list_center(USER) + candidates = await env.service.list_candidates(USER) + gate.assert_not_called() + + assert _names(center.personal) == {"ji-du", "ting", "pdf"} + assert _names(center.enterprise) == {"he-tong", "pdf"} + assert "foreign" not in _names(center.personal) | _names(center.enterprise) + assert "other-skill" not in _names(center.personal) + disabled = next(row for row in center.personal if row.name == "ting") + assert bool(disabled.enabled) is False + assert center.personal_count == 3 + assert center.enterprise_count == 2 + assert _names(candidates) == {"ji-du", "pdf", "he-tong"} + personal_pdf = next(row for row in candidates if row.name == "pdf") + assert personal_pdf.owner_user_id == USER + + async def test_disabling_or_deleting_the_personal_copy_restores_the_tenant_skill(self, env): + personal = await env.dao.get_by_name("pdf", owner_user_id=USER) + await env.service.set_enabled(user_id=USER, skill_id=personal.id, enabled=False) + covered = {row.owner_user_id for row in await env.service.list_candidates(USER) if row.name == "pdf"} + assert covered == {0} + + await env.service.delete(user_id=USER, skill_id=personal.id) + covered = {row.owner_user_id for row in await env.service.list_candidates(USER) if row.name == "pdf"} + assert covered == {0} + center = await env.service.list_center(USER) + assert "pdf" not in _names(center.personal) + assert "pdf" in _names(center.enterprise) + + async def test_search_filters_only_the_current_tab(self, env): + by_id = await env.service.list_center(USER, tab="personal", keyword="ji-du") + assert _names(by_id.personal) == {"ji-du"} + assert _names(by_id.enterprise) == {"he-tong", "pdf"} + assert by_id.personal_count == 3 and by_id.enterprise_count == 2 + + by_description = await env.service.list_center(USER, tab="enterprise", keyword="审阅") + assert _names(by_description.enterprise) == {"he-tong"} + assert _names(by_description.personal) == {"ji-du", "ting", "pdf"} + + by_title = await env.service.list_center(USER, tab="personal", keyword="合同") + assert by_title.personal == [] + assert "he-tong" in _names(by_title.enterprise) + + async def test_disabling_a_tenant_skill_removes_it_from_every_candidate(self, env): + assert await env.dao.set_enabled("he-tong", False) is True + user_names = _names(await env.service.list_candidates(USER)) + other_names = _names(await env.service.list_candidates(OTHER)) + assert "he-tong" not in user_names and "he-tong" not in other_names + assert "ji-du" in user_names + assert "other-skill" in other_names and "other-skill" not in user_names + mine = await env.service.list_center(USER) + assert "he-tong" not in _names(mine.enterprise) + assert "ji-du" in _names(mine.personal) diff --git a/src/backend/test/linsight/test_personal_skill_orphan_sweep.py b/src/backend/test/linsight/test_personal_skill_orphan_sweep.py new file mode 100644 index 0000000000..90a461d7a9 --- /dev/null +++ b/src/backend/test/linsight/test_personal_skill_orphan_sweep.py @@ -0,0 +1,147 @@ +"""Orphan personal skill objects are deleted per tenant. + +A hash still named by a skill row stays. ``linsight/skills/`` is never part of +the sweep, and a child task only removes keys under its own tenant prefix. +""" + +from bisheng.core.config.settings import CeleryConf +from bisheng.core.context.tenant import current_tenant_id, get_current_tenant_id, set_current_tenant_id +from bisheng.worker.linsight import personal_skill_sweep as sweep + +LIVE = "a" * 64 +ORPHAN = "b" * 64 +OTHER = "c" * 64 + + +def _key(tenant: int, user: int, name: str, digest: str) -> str: + return f"linsight/personal-skills/{tenant}/{user}/{name}/{digest}.zip" + + +def test_unreferenced_hash_is_removed_and_a_live_hash_stays(): + names = [ + _key(3, 12, "pdf", ORPHAN), + _key(3, 12, "pdf", LIVE), + _key(9, 12, "pdf", OTHER), + f"linsight/skills/3/pdf/{ORPHAN}.zip", + _key(3, 12, "pdf", "not-a-hash"), + ] + doomed = sweep.orphan_object_keys(names, 3, {LIVE}) + assert doomed == [_key(3, 12, "pdf", ORPHAN)] + + +async def test_sweep_deletes_only_this_tenants_orphans(): + names = [ + _key(3, 12, "pdf", ORPHAN), + _key(3, 12, "pdf", LIVE), + _key(9, 12, "pdf", OTHER), + f"linsight/skills/3/pdf/{ORPHAN}.zip", + ] + removed = [] + token = set_current_tenant_id(3) + try: + result = await sweep.run_sweep( + 3, + list_objects=lambda tenant_id: names, + live_hashes=lambda: {LIVE}, + remove=removed.append, + ) + finally: + current_tenant_id.reset(token) + assert result == [_key(3, 12, "pdf", ORPHAN)] + assert removed == result + + +async def test_a_mismatched_tenant_context_deletes_nothing(): + removed = [] + token = set_current_tenant_id(1) + try: + result = await sweep.run_sweep( + 3, + list_objects=lambda tenant_id: [_key(3, 12, "pdf", ORPHAN)], + live_hashes=lambda: set(), + remove=removed.append, + ) + finally: + current_tenant_id.reset(token) + assert result == [] + assert removed == [] + + +async def test_a_delete_failure_is_logged_and_the_rest_continues(monkeypatch): + logged = [] + monkeypatch.setattr(sweep.logger, "exception", lambda *args, **kwargs: logged.append(args)) + first = _key(3, 12, "pdf", ORPHAN) + second = _key(3, 8, "sheet", OTHER) + + def remove(key): + if key == first: + raise RuntimeError("minio down") + + token = set_current_tenant_id(3) + try: + result = await sweep.run_sweep( + 3, + list_objects=lambda tenant_id: [first, second], + live_hashes=lambda: set(), + remove=remove, + ) + finally: + current_tenant_id.reset(token) + assert result == [second] + assert logged + + +def test_dispatch_publishes_one_child_per_tenant_and_restores_the_caller(): + published = [] + + def publish(tenant_id): + published.append((tenant_id, get_current_tenant_id())) + + token = set_current_tenant_id(1) + try: + sweep.publish_tenant_sweeps([3, 9], publish) + assert get_current_tenant_id() == 1 + finally: + current_tenant_id.reset(token) + assert published == [(3, 3), (9, 9)] + assert get_current_tenant_id() != 9 + + +async def test_dispatch_task_sends_the_tenant_header(monkeypatch): + published = [] + + async def tenants(): + return [3, 9] + + def apply_async(*, args, headers): + published.append((args, headers, get_current_tenant_id())) + + monkeypatch.setattr(sweep, "_active_tenant_ids", tenants) + monkeypatch.setattr(sweep.sweep_personal_skill_orphans, "apply_async", apply_async) + token = set_current_tenant_id(1) + try: + await sweep._dispatch() + assert get_current_tenant_id() == 1 + finally: + current_tenant_id.reset(token) + assert published == [ + ([3], {"tenant_id": 3}, 3), + ([9], {"tenant_id": 9}, 9), + ] + + +def test_sweep_task_logs_and_returns_when_the_run_fails(monkeypatch): + logged = [] + + async def boom(tenant_id): + raise RuntimeError("db down") + + monkeypatch.setattr(sweep, "run_sweep", boom) + monkeypatch.setattr(sweep.logger, "exception", lambda *args, **kwargs: logged.append(args)) + assert sweep.sweep_personal_skill_orphans.run(3) == [] + assert logged + + +def test_default_beat_schedule_dispatches_the_sweep(): + entry = CeleryConf().beat_schedule["dispatch_personal_skill_orphan_sweep"] + assert entry["task"] == "bisheng.worker.linsight.personal_skill_sweep.dispatch_personal_skill_orphan_sweep" diff --git a/src/backend/test/linsight/test_personal_skill_overwrite.py b/src/backend/test/linsight/test_personal_skill_overwrite.py new file mode 100644 index 0000000000..93a23de1db --- /dev/null +++ b/src/backend/test/linsight/test_personal_skill_overwrite.py @@ -0,0 +1,305 @@ +"""Overwriting a personal skill keeps its id and enabled flag.""" + +from contextlib import asynccontextmanager +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest +from sqlmodel import select + +import bisheng.linsight.domain.models.linsight_skill as model_module +import bisheng.linsight.domain.services.personal_skill_service as service_module +from bisheng.common.errcode.linsight import ( + SkillNotFoundError, + SkillStorageLimitError, + SkillVersionConflictError, +) +from bisheng.core.context.tenant import bypass_tenant_filter, set_current_tenant_id +from bisheng.database.models.audit_log import AuditLog +from bisheng.linsight.domain.models.linsight_skill import LinsightSkill, LinsightSkillDao +from bisheng.linsight.domain.services.personal_skill_service import ( + PERSONAL_SKILL_BYTES_LIMIT, + PersonalSkillService, +) +from bisheng.linsight.domain.services.skill_store import SKILL_MD, SkillStore +from test.linsight.fixtures.fake_minio import FakeMinioStorage + +TENANT = 5 +USER = 11 +OTHER = 22 + + +def _md(name: str = "pdf", body: str = "one") -> bytes: + return ( + f"---\nname: {name}\ndescription: demo description\nmetadata:\n display-name: 报告\n---\n\n# {body}\n" + ).encode() + + +@pytest.fixture +async def env(tmp_path, monkeypatch): + from sqlalchemy.ext.asyncio import create_async_engine + from sqlmodel.ext.asyncio.session import AsyncSession + + engine = create_async_engine( + f"sqlite+aiosqlite:///{tmp_path}/skills.db", + connect_args={"timeout": 5}, + ) + async with engine.begin() as conn: + await conn.run_sync(LinsightSkill.__table__.create) + await conn.run_sync(AuditLog.__table__.create) + + @asynccontextmanager + async def _session(): + session = AsyncSession(bind=engine, expire_on_commit=False) + try: + yield session + except Exception: + await session.rollback() + raise + finally: + await session.close() + + monkeypatch.setattr(model_module, "get_async_db_session", _session) + monkeypatch.setattr(service_module, "get_async_db_session", _session) + monkeypatch.setattr( + "bisheng.permission.application.business_authorization.require_business_action", + AsyncMock(), + ) + set_current_tenant_id(TENANT) + store = SkillStore(root=tmp_path / "cache", minio=FakeMinioStorage()) + service = PersonalSkillService( + store=store, + owner_projection=SimpleNamespace(authorize_created=AsyncMock()), + unpack_root=tmp_path / "unpack", + ) + yield SimpleNamespace(dao=LinsightSkillDao, service=service, store=store) + await engine.dispose() + + +async def _owned(env, body: str = "one", *, enabled: bool = False, size: int | None = None) -> LinsightSkill: + data = _md(body=body) + ref = env.store.write_bundle(TENANT, "pdf", {SKILL_MD: data}, owner_user_id=USER) + return await env.dao.create( + LinsightSkill( + tenant_id=TENANT, + owner_user_id=USER, + name="pdf", + display_name="报告", + description="demo description", + enabled=enabled, + source="manual", + object_path=ref.object_key, + content_hash=ref.content_hash, + size=ref.size if size is None else size, + created_by=USER, + ) + ) + + +class TestPersonalSkillOverwrite: + async def test_stale_hash_does_not_write_or_change_the_row(self, env): + skill = await _owned(env) + puts = env.store.minio.put_calls + with pytest.raises(SkillVersionConflictError) as caught: + await env.service.overwrite( + user_id=USER, + skill_id=skill.id, + filename="pdf.md", + data=_md(body="two"), + expected_content_hash="0" * 64, + import_id="imp-stale", + content_type="text/plain", + ) + assert caught.value.code == 11064 + assert env.store.minio.put_calls == puts + stored = await env.dao.get_by_id(skill.id) + assert stored.content_hash == skill.content_hash + assert stored.object_path == skill.object_path + + async def test_matching_hash_replaces_the_bundle_and_keeps_id_and_enabled(self, env): + skill = await _owned(env, enabled=False) + before = await env.service.usage(USER) + saved = await env.service.overwrite( + user_id=USER, + skill_id=skill.id, + filename="pdf.md", + data=_md(body="two"), + expected_content_hash=skill.content_hash, + import_id="imp-next", + content_type="text/plain", + ) + assert saved.id == skill.id + assert saved.enabled is False or saved.enabled == 0 + assert saved.content_hash != skill.content_hash + assert saved.object_path.endswith(f"/{saved.content_hash}.zip") + stored = await env.dao.get_by_id(skill.id) + assert stored.content_hash == saved.content_hash + assert "two" in env.store.read_text(TENANT, "pdf", saved.content_hash, owner_user_id=USER) + assert env.store.minio.keys(f"linsight/personal-skills/{TENANT}/{USER}/pdf/") == [saved.object_path] + assert (await env.service.usage(USER)).count == before.count + + with bypass_tenant_filter(): + async with model_module.get_async_db_session() as session: + logs = list((await session.exec(select(AuditLog))).all()) + assert len(logs) == 1 + assert logs[0].operator_id == USER + assert logs[0].tenant_id == TENANT + assert logs[0].system_id == "linsight" + assert logs[0].target_id == str(skill.id) + assert logs[0].audit_metadata["content_hash"] == saved.content_hash + assert logs[0].audit_metadata["result"] == "success" + + async def test_same_hash_does_not_delete_the_object(self, env): + skill = await _owned(env) + data = _md(body="one") + saved = await env.service.overwrite( + user_id=USER, + skill_id=skill.id, + filename="pdf.md", + data=data, + expected_content_hash=skill.content_hash, + import_id="imp-same", + content_type="text/plain", + ) + assert saved.content_hash == skill.content_hash + assert env.store.minio.keys(f"linsight/personal-skills/{TENANT}/{USER}/pdf/") == [skill.object_path] + + async def test_storage_limit_keeps_the_old_bundle(self, env): + skill = await _owned(env, size=100) + await env.dao.create( + LinsightSkill( + tenant_id=TENANT, + owner_user_id=USER, + name="filler", + display_name="占位", + description="seed", + enabled=True, + source="manual", + object_path="linsight/personal-skills/5/11/filler/seed.zip", + content_hash="c" * 64, + size=PERSONAL_SKILL_BYTES_LIMIT - 100, + created_by=USER, + ) + ) + puts = env.store.minio.put_calls + payload = _md(body="x" * 2000) + with pytest.raises(SkillStorageLimitError) as caught: + await env.service.overwrite( + user_id=USER, + skill_id=skill.id, + filename="pdf.md", + data=payload, + expected_content_hash=skill.content_hash, + import_id="imp-big", + content_type="text/plain", + ) + assert caught.value.code == 11062 + stored = await env.dao.get_by_id(skill.id) + assert stored.content_hash == skill.content_hash + assert stored.size == 100 + assert env.store.minio.put_calls == puts + assert env.store.minio.keys(f"linsight/personal-skills/{TENANT}/{USER}/pdf/") == [skill.object_path] + assert (await env.service.usage(USER)).count == 2 + + async def test_failed_row_update_leaves_the_old_hash(self, env, monkeypatch): + skill = await _owned(env) + + async def _boom(*args, **kwargs): + raise RuntimeError("row update failed") + + monkeypatch.setattr(LinsightSkillDao, "update_bundle", _boom) + with pytest.raises(RuntimeError, match="row update failed"): + await env.service.overwrite( + user_id=USER, + skill_id=skill.id, + filename="pdf.md", + data=_md(body="two"), + expected_content_hash=skill.content_hash, + import_id="imp-fail", + content_type="text/plain", + ) + stored = await env.dao.get_by_id(skill.id) + assert stored.content_hash == skill.content_hash + assert stored.object_path == skill.object_path + assert env.store.read_text(TENANT, "pdf", skill.content_hash, owner_user_id=USER) + + async def test_someone_elses_skill_is_not_found_and_skips_authorization(self, env): + skill = await _owned(env) + with patch("bisheng.permission.application.business_authorization.require_business_action") as gate: + with pytest.raises(SkillNotFoundError) as caught: + await env.service.overwrite( + user_id=OTHER, + skill_id=skill.id, + filename="pdf.md", + data=_md(body="two"), + expected_content_hash=skill.content_hash, + import_id="imp-other", + content_type="text/plain", + ) + assert caught.value.code == 11053 + gate.assert_not_called() + stored = await env.dao.get_by_id(skill.id) + assert stored.content_hash == skill.content_hash + + async def test_a_different_frontmatter_name_still_updates_this_skill(self, env): + skill = await _owned(env) + preview = await env.service.inspect_upload( + user_id=USER, + filename="demo-csv-summary.md", + data=_md(name="demo-csv-summary", body="csv"), + content_type="text/plain", + skill_id=skill.id, + ) + assert preview.name == "pdf" + saved = await env.service.overwrite( + user_id=USER, + skill_id=skill.id, + filename="demo-csv-summary.md", + data=_md(name="demo-csv-summary", body="csv"), + expected_content_hash=skill.content_hash, + import_id="imp-rename", + content_type="text/plain", + ) + assert saved.id == skill.id + assert saved.name == "pdf" + text = env.store.read_text(TENANT, "pdf", saved.content_hash, owner_user_id=USER) + assert "name: pdf" in text + assert "demo-csv-summary" not in text + assert "csv" in text + assert (await env.service.usage(USER)).count == 1 + + async def test_uploaded_skill_id_does_not_replace_the_shown_name(self, env): + skill = await _owned(env) + data = ( + "---\nname: meeting-minutes1\ndisplay_name: 会议纪要整理\ndescription: demo description\n---\n\n# csv\n" + ).encode() + saved = await env.service.overwrite( + user_id=USER, + skill_id=skill.id, + filename="meeting-minutes1.md", + data=data, + expected_content_hash=skill.content_hash, + import_id="imp-shown-name", + content_type="text/plain", + ) + assert saved.id == skill.id + assert saved.name == "pdf" + assert saved.display_name == "会议纪要整理" + text = env.store.read_text(TENANT, "pdf", saved.content_hash, owner_user_id=USER) + assert "name: pdf" in text + assert "meeting-minutes1" not in text + + async def test_file_without_a_display_name_keeps_the_current_one(self, env): + skill = await _owned(env) + data = b"---\nname: meeting-minutes1\ndescription: demo description\n---\n\n# csv\n" + saved = await env.service.overwrite( + user_id=USER, + skill_id=skill.id, + filename="meeting-minutes1.md", + data=data, + expected_content_hash=skill.content_hash, + import_id="imp-keep-display", + content_type="text/plain", + ) + assert saved.name == "pdf" + assert saved.display_name == "报告" diff --git a/src/backend/test/linsight/test_personal_skill_provisioning.py b/src/backend/test/linsight/test_personal_skill_provisioning.py new file mode 100644 index 0000000000..ade61e70c9 --- /dev/null +++ b/src/backend/test/linsight/test_personal_skill_provisioning.py @@ -0,0 +1,280 @@ +"""New tasks copy every candidate bundle. Resume does not mix a second skill in.""" + +from __future__ import annotations + +import re +from contextlib import asynccontextmanager +from pathlib import Path +from types import SimpleNamespace + +import pytest + +import bisheng.linsight.domain.models.linsight_skill as model_module +from bisheng.core.context.tenant import bypass_tenant_filter, set_current_tenant_id +from bisheng.linsight.domain.models.linsight_skill import LinsightSkill, LinsightSkillDao +from bisheng.linsight.domain.schemas.linsight_schema import LinsightQuestionSubmitSchema +from bisheng.linsight.domain.services.skill_provisioning import ( + materialize_session_skills, + pending_skill_snapshot, +) +from bisheng.linsight.domain.services.skill_store import SkillStore +from bisheng.linsight.domain.services.workbench_impl import build_e2b_file_list +from bisheng_langchain.gpts.tools.code_interpreter.e2b_executor import SIZE_AUTOPUSH +from test.linsight.fixtures.fake_minio import FakeMinioStorage + +TENANT = 5 +USER = 11 +OTHER = 22 + +_TASK_EXEC = Path(__file__).parents[2] / "bisheng/linsight/domain/task_exec.py" + + +def _skill_md(name: str, description: str) -> bytes: + return f"---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n".encode() + + +class _Resp: + def __init__(self, path: str, error: str | None = None): + self.path = path + self.error = error + + +class _CacheBackend: + def __init__(self, file_dir: Path): + self.file_dir = Path(file_dir) + self.uploaded: list[tuple[str, bytes]] = [] + + async def aupload_files(self, files): + out = [] + for raw_path, data in files: + rel = raw_path.lstrip("/") + target = self.file_dir / rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + self.uploaded.append((raw_path, data)) + out.append(_Resp("/" + rel)) + return out + + +def _method_body(source: str, name: str) -> str: + start = source.index(f"async def {name}(") + rest = source[start:] + match = re.search(r"\n (?:async )?def ", rest[1:]) + return rest[: match.start() + 1] if match else rest + + +async def _seed( + store: SkillStore, + *, + name: str, + owner: int, + description: str, + extra: dict[str, bytes] | None = None, + enabled: bool = True, +) -> LinsightSkill: + files = {"SKILL.md": _skill_md(name, description), **(extra or {})} + ref = store.write_bundle(TENANT, name, files, owner_user_id=owner) + row = LinsightSkill( + tenant_id=TENANT, + owner_user_id=owner, + name=name, + display_name=description, + description=description, + enabled=enabled, + source="manual", + object_path=store.object_key(TENANT, name, ref.content_hash, owner_user_id=owner), + content_hash=ref.content_hash, + size=sum(len(item) for item in files.values()), + created_by=owner or 1, + ) + with bypass_tenant_filter(): + return await LinsightSkillDao.create(row) + + +@pytest.fixture +async def env(monkeypatch, tmp_path): + from sqlalchemy.ext.asyncio import create_async_engine + from sqlalchemy.pool import StaticPool + from sqlmodel.ext.asyncio.session import AsyncSession + + from bisheng.core.database import tenant_filter + + engine = create_async_engine( + "sqlite+aiosqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + async with engine.begin() as conn: + await conn.run_sync(LinsightSkill.__table__.create) + + @asynccontextmanager + async def _session(): + session = AsyncSession(bind=engine, expire_on_commit=False) + try: + yield session + except Exception: + await session.rollback() + raise + finally: + await session.close() + + monkeypatch.setattr(model_module, "get_async_db_session", _session) + tenant_filter._initialized = False + tenant_filter._tenant_aware_tables = set() + tenant_filter.register_tenant_filter_events() + set_current_tenant_id(TENANT) + from unittest.mock import AsyncMock + + async def permitted(_user, *, resource_ids, **kwargs): + return {str(skill_id): {"use"} for skill_id in resource_ids} + + monkeypatch.setattr("bisheng.permission.application.business_authorization.batch_check_business_actions", permitted) + monkeypatch.setattr("bisheng.utils.http_middleware._check_is_global_super", AsyncMock(return_value=False)) + monkeypatch.setattr( + "bisheng.linsight.domain.services.user_skills.SkillAvailabilityRepository.disabled_ids", + AsyncMock(return_value=set()), + ) + + store = SkillStore(root=tmp_path / "skills", minio=FakeMinioStorage()) + personal = await _seed( + store, + name="pdf", + owner=USER, + description="个人用途", + extra={"who.txt": b"personal"}, + ) + enterprise = await _seed( + store, + name="pdf", + owner=0, + description="企业用途", + extra={"who.txt": b"enterprise", "enterprise-only.txt": b"tenant"}, + ) + contract = await _seed(store, name="he-tong", owner=0, description="审阅合同", extra={"note.txt": b"contract"}) + await _seed(store, name="ting-yong", owner=USER, description="已停用", enabled=False, extra={"who.txt": b"off"}) + await _seed(store, name="secret", owner=OTHER, description="别人的", extra={"who.txt": b"secret"}) + removed = await _seed(store, name="yi-shan", owner=USER, description="待删除", extra={"who.txt": b"gone"}) + with bypass_tenant_filter(): + await LinsightSkillDao.delete_owned(removed.id, owner_user_id=USER) + + def backend() -> _CacheBackend: + slot = tmp_path / f"ws-{len(list(tmp_path.glob('ws-*')))}" + return _CacheBackend(slot) + + return SimpleNamespace(store=store, backend=backend, personal=personal, enterprise=enterprise, contract=contract) + + +class TestCandidateCopy: + async def test_new_task_copies_each_candidate_bundle(self, env): + backend = env.backend() + session = SimpleNamespace(skills=["he-tong"], skill_snapshot=None) + result = await materialize_session_skills( + backend, TENANT, session.skills, store=env.store, user_id=USER, session=session + ) + + assert result.copied == ["he-tong", "pdf"] + pdf_md = (backend.file_dir / "skills" / "pdf" / "SKILL.md").read_bytes() + assert pdf_md == _skill_md("pdf", "个人用途") + assert (backend.file_dir / "skills" / "pdf" / "who.txt").read_bytes() == b"personal" + assert not (backend.file_dir / "skills" / "pdf" / "enterprise-only.txt").exists() + assert (backend.file_dir / "skills" / "he-tong" / "note.txt").read_bytes() == b"contract" + assert not (backend.file_dir / "skills" / "ting-yong").exists() + assert not (backend.file_dir / "skills" / "secret").exists() + assert not (backend.file_dir / "skills" / "yi-shan").exists() + by_name = {entry["name"]: entry for entry in session.skill_snapshot} + assert by_name["pdf"] == { + "name": "pdf", + "skill_id": env.personal.id, + "owner_user_id": USER, + "content_hash": env.personal.content_hash, + } + assert by_name["he-tong"]["skill_id"] == env.contract.id + assert by_name["he-tong"]["owner_user_id"] == 0 + # Name-only requests keep the names the client sent. + assert session.skills == ["he-tong"] + + async def test_enterprise_skill_id_resolves_to_personal_directory(self, env): + backend = env.backend() + session = SimpleNamespace( + skills=["stale"], + skill_snapshot=pending_skill_snapshot([env.enterprise.id]), + ) + await materialize_session_skills( + backend, + TENANT, + session.skills, + store=env.store, + user_id=USER, + skill_snapshot=session.skill_snapshot, + session=session, + ) + assert session.skills == ["pdf"] + pdf = next(entry for entry in session.skill_snapshot if entry["name"] == "pdf") + assert pdf["skill_id"] == env.personal.id + assert pdf["owner_user_id"] == USER + + async def test_resume_does_not_mix_the_enterprise_bundle(self, env): + backend = env.backend() + first = SimpleNamespace(skills=["pdf"], skill_snapshot=None) + await materialize_session_skills(backend, TENANT, first.skills, store=env.store, user_id=USER, session=first) + env.personal.enabled = False + with bypass_tenant_filter(): + await LinsightSkillDao.update(env.personal) + uploaded_before = len(backend.uploaded) + await materialize_session_skills( + backend, + TENANT, + first.skills, + store=env.store, + user_id=USER, + skill_snapshot=first.skill_snapshot, + session=first, + ) + assert (backend.file_dir / "skills" / "pdf" / "who.txt").read_bytes() == b"personal" + assert not (backend.file_dir / "skills" / "pdf" / "enterprise-only.txt").exists() + assert len(backend.uploaded) == uploaded_before + assert next(entry["skill_id"] for entry in first.skill_snapshot if entry["name"] == "pdf") == env.personal.id + + async def test_disabled_personal_skill_is_absent_on_a_new_task(self, env): + env.personal.enabled = False + with bypass_tenant_filter(): + await LinsightSkillDao.update(env.personal) + backend = env.backend() + result = await materialize_session_skills(backend, TENANT, None, store=env.store, user_id=USER) + assert "pdf" in result.copied + assert (backend.file_dir / "skills" / "pdf" / "who.txt").read_bytes() == b"enterprise" + assert "ting-yong" not in result.copied + + +class TestFileListOrder: + async def test_file_list_sees_the_bundle_and_keeps_the_size_ceiling(self, env, monkeypatch): + assert SIZE_AUTOPUSH == 5 * 1024 * 1024 + backend = env.backend() + await materialize_session_skills(backend, TENANT, ["pdf"], store=env.store, user_id=USER) + listed = {entry.path for entry in build_e2b_file_list(str(backend.file_dir))} + assert any(path.endswith("skills/pdf/SKILL.md") or path.endswith("skills\\pdf\\SKILL.md") for path in listed) + + (backend.file_dir / "skills" / "pdf" / "ok.txt").write_bytes(b"ok") + (backend.file_dir / "skills" / "pdf" / "big.bin").write_bytes(b"x" * 8) + import bisheng.linsight.domain.services.workbench_impl as workbench + + monkeypatch.setattr(workbench, "SIZE_AUTOPUSH", 4) + listed_after = {entry.path for entry in build_e2b_file_list(str(backend.file_dir))} + assert any(path.endswith("ok.txt") for path in listed_after) + assert all("big.bin" not in path for path in listed_after) + + def test_copy_runs_before_the_code_interpreter_snapshot(self): + source = _TASK_EXEC.read_text() + for name in ("_execute_workflow", "_resume_workflow", "_continue_workflow"): + body = _method_body(source, name) + assert body.index("_provision_session_skills") < body.index("_generate_tools") + assert "_provision_session_skills" in _method_body(source, "_create_agent") + assert "materialize_session_skills" in _method_body(source, "_provision_session_skills") + + def test_submit_accepts_skill_ids_without_requiring_them(self): + named = LinsightQuestionSubmitSchema(question="q", skills=["pdf"]) + assert named.skill_ids is None + assert named.skills == ["pdf"] + chosen = LinsightQuestionSubmitSchema(question="q", skill_ids=[7]) + assert pending_skill_snapshot(chosen.skill_ids) == [{"pending_skill_ids": [7]}] + assert pending_skill_snapshot(None) is None diff --git a/src/backend/test/linsight/test_personal_skill_store.py b/src/backend/test/linsight/test_personal_skill_store.py new file mode 100644 index 0000000000..29d105eddb --- /dev/null +++ b/src/backend/test/linsight/test_personal_skill_store.py @@ -0,0 +1,260 @@ +"""Personal skill bundles use their own object prefix and fixed size caps.""" + +import io +import struct +import zipfile +from types import SimpleNamespace + +import pytest + +from bisheng.common.errcode.linsight import ( + SkillArchiveDamagedError, + SkillArchiveEncryptedError, + SkillBundleTooLargeError, + SkillEmptyFileError, + SkillEntryLimitError, + SkillFileTooLargeError, + SkillManifestMissingError, + SkillManifestTooLargeError, + SkillMultipleError, + SkillPathEscapeError, + SkillTypeMismatchError, + SkillValidationError, +) +from bisheng.linsight.domain.services import skill_store +from bisheng.linsight.domain.services.skill_store import ( + PERSONAL_ARCHIVE_MAX_BYTES, + PERSONAL_ENTRY_MAX_COUNT, + PERSONAL_FILE_MAX_BYTES, + PERSONAL_SKILL_MD_MAX_BYTES, + PERSONAL_UNPACKED_MAX_BYTES, + SKILL_MD, + SkillStore, + accept_skill_upload, +) +from test.linsight.fixtures.fake_minio import FakeMinioStorage + +MB = 1024 * 1024 +USER = 11 +OTHER = 22 + + +def _zip_bytes(entries: dict[str, bytes]) -> bytes: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + for path, content in entries.items(): + zf.writestr(path, content) + return buf.getvalue() + + +def _zip_declared_size(declared: int) -> bytes: + """A tiny archive whose central directory claims ``declared`` unpacked bytes.""" + raw = bytearray(_zip_bytes({SKILL_MD: b"x"})) + local = raw.find(b"PK\x03\x04") + central = raw.rfind(b"PK\x01\x02") + struct.pack_into(" bytes: + """Set the encryption flag in the headers. zipfile will not write that bit itself.""" + raw = bytearray(_zip_bytes({SKILL_MD: b"secret"})) + local = raw.find(b"PK\x03\x04") + central = raw.rfind(b"PK\x01\x02") + struct.pack_into(" None: + async def _aget(self): + return SimpleNamespace(skill_upload_max_size_mb=upload_mb, skill_unpacked_max_size_mb=unpacked_mb) + + monkeypatch.setattr(type(skill_store.bisheng_settings), "aget_linsight_conf", _aget) + + +def _code(exc: BaseException) -> int: + return exc.code + + +@pytest.fixture +def store(tmp_path): + return SkillStore(root=tmp_path, minio=FakeMinioStorage()) + + +class TestPersonalSkillStore: + def test_personal_key_and_delete_stay_in_that_users_prefix(self, store): + files = {SKILL_MD: b"---\nname: pdf\ndescription: d\n---\n"} + enterprise = store.write_bundle(1, "pdf", files) + owned = store.write_bundle(1, "pdf", files, owner_user_id=USER) + other = store.write_bundle(1, "pdf", {SKILL_MD: b"other"}, owner_user_id=OTHER) + + assert owned.object_key == f"linsight/personal-skills/1/{USER}/pdf/{owned.content_hash}.zip" + assert enterprise.object_key.startswith("linsight/skills/1/pdf/") + assert store.read_text(1, "pdf", owned.content_hash, owner_user_id=USER) == files[SKILL_MD].decode() + + assert store.delete(1, "pdf", owner_user_id=USER) + assert not store.exists(1, "pdf", owned.content_hash, owner_user_id=USER) + assert store.exists(1, "pdf", enterprise.content_hash) + assert store.exists(1, "pdf", other.content_hash, owner_user_id=OTHER) + assert store.minio.keys("linsight/skills/1/pdf/") + assert store.minio.keys(f"linsight/personal-skills/1/{OTHER}/pdf/") + assert store.minio.keys(f"linsight/personal-skills/1/{USER}/pdf/") == [] + + async def test_personal_size_caps_are_fixed(self, store, monkeypatch): + _conf(monkeypatch, 500, 800) + assert PERSONAL_FILE_MAX_BYTES == 209715200 + assert PERSONAL_ARCHIVE_MAX_BYTES == 52428800 + assert PERSONAL_UNPACKED_MAX_BYTES == 209715200 + assert PERSONAL_SKILL_MD_MAX_BYTES == 1048576 + + with pytest.raises(SkillFileTooLargeError) as upload_error: + await store.publish_upload( + 1, + "pdf", + "pdf.md", + b"x" * (PERSONAL_FILE_MAX_BYTES + 1), + owner_user_id=USER, + content_type="text/plain", + ) + assert _code(upload_error.value) == 11052 + assert upload_error.value.kwargs["limit_mb"] == 200 + + with pytest.raises(SkillFileTooLargeError) as archive_error: + await store.publish_upload( + 1, + "pdf", + "pdf.zip", + b"PK\x03\x04" + b"x" * (PERSONAL_ARCHIVE_MAX_BYTES - 3), + owner_user_id=USER, + content_type="application/zip", + ) + assert _code(archive_error.value) == 11052 + assert archive_error.value.kwargs["limit_mb"] == 50 + + with pytest.raises(SkillBundleTooLargeError) as unpacked_error: + await store.publish_upload( + 1, + "pdf", + "pdf.zip", + _zip_declared_size(PERSONAL_UNPACKED_MAX_BYTES + 1), + owner_user_id=USER, + content_type="application/zip", + ) + assert _code(unpacked_error.value) == 11059 + assert unpacked_error.value.kwargs["limit_mb"] == 200 + + accepted = await accept_skill_upload( + "pdf.md", + b"a" * (PERSONAL_SKILL_MD_MAX_BYTES + 1), + owner_user_id=USER, + content_type="text/plain", + ) + assert len(accepted[SKILL_MD]) == PERSONAL_SKILL_MD_MAX_BYTES + 1 + + with pytest.raises(SkillManifestTooLargeError) as manifest_error: + await store.publish_upload( + 1, + "pdf", + "pdf.zip", + _zip_bytes({SKILL_MD: b"a" * (PERSONAL_SKILL_MD_MAX_BYTES + 1)}), + owner_user_id=USER, + content_type="application/zip", + ) + assert _code(manifest_error.value) == 11073 + assert manifest_error.value.kwargs["limit_mb"] == 1 + + entries = {SKILL_MD: b"ok", **{f"f{i}.txt": b"a" for i in range(PERSONAL_ENTRY_MAX_COUNT)}} + with pytest.raises(SkillEntryLimitError) as entry_error: + await store.publish_upload( + 1, "pdf", "pdf.zip", _zip_bytes(entries), owner_user_id=USER, content_type="application/zip" + ) + assert _code(entry_error.value) == 11074 + assert entry_error.value.kwargs["limit_count"] == 1000 + assert store.minio.put_calls == 0 + + async def test_personal_caps_ignore_a_smaller_system_config(self, store, monkeypatch): + _conf(monkeypatch, 1, 1) + files = await accept_skill_upload("pdf.md", b"hello", owner_user_id=USER, content_type="text/plain") + assert files[SKILL_MD] == b"hello" + + async def test_enterprise_path_uses_the_configured_cap(self, store, monkeypatch): + _conf(monkeypatch, 3, 9) + with pytest.raises(SkillFileTooLargeError) as upload_error: + await store.publish_upload( + 1, "pdf", "pdf.md", b"x" * (3 * MB + 1), owner_user_id=0, content_type="text/plain" + ) + assert upload_error.value.kwargs["limit_mb"] == 3 + + with pytest.raises(SkillBundleTooLargeError) as unpacked_error: + await store.publish_upload( + 1, + "pdf", + "pdf.zip", + _zip_declared_size(9 * MB + 1), + owner_user_id=0, + content_type="application/zip", + ) + assert unpacked_error.value.kwargs["limit_mb"] == 9 + assert store.minio.put_calls == 0 + + async def test_rejected_archives_do_not_write(self, store): + cases = [ + ("pdf.zip", _encrypted_zip(), "application/zip", SkillArchiveEncryptedError, 11063), + ("pdf.zip", b"PK\x03\x04not-a-zip", "application/zip", SkillArchiveDamagedError, 11067), + ("pdf.zip", _zip_bytes({"readme.md": b"no manifest"}), "application/zip", SkillManifestMissingError, 11068), + ( + "pdf.zip", + _zip_bytes({SKILL_MD: b"ok", "../evil.md": b"x"}), + "application/zip", + SkillPathEscapeError, + 11069, + ), + ("pdf.md", b"", "text/plain", SkillEmptyFileError, 11071), + ( + "pdf.zip", + _zip_bytes({"a/SKILL.md": b"a", "b/SKILL.md": b"b"}), + "application/zip", + SkillMultipleError, + 11072, + ), + ("notes.txt", b"hello", "text/plain", SkillValidationError, 11051), + ] + for filename, data, mime, error_type, code in cases: + with pytest.raises(error_type) as caught: + await store.publish_upload(1, "pdf", filename, data, owner_user_id=USER, content_type=mime) + assert _code(caught.value) == code + assert store.minio.put_calls == 0 + + @pytest.mark.parametrize( + ("filename", "data", "mime"), + [ + ("pdf.md", b"PK\x03\x04rest", "text/plain"), + ("pdf.md", b"hello", "application/zip"), + ("pdf.zip", b"not-a-zip", "application/zip"), + ("pdf.skill", b"not-a-zip", "application/octet-stream"), + ("pdf.zip", _zip_bytes({SKILL_MD: b"ok"}), "text/plain"), + ], + ) + async def test_type_must_match_extension_and_bytes(self, store, filename, data, mime): + with pytest.raises(SkillTypeMismatchError) as caught: + await store.publish_upload(1, "pdf", filename, data, owner_user_id=USER, content_type=mime) + assert _code(caught.value) == 11065 + assert store.minio.put_calls == 0 + + async def test_allowed_mime_and_empty_mime_pass(self, store): + payload = _zip_bytes({SKILL_MD: b"ok"}) + for filename, data, mime in ( + ("pdf.md", b"hello", "text/markdown"), + ("pdf.md", b"hello", "text/x-markdown"), + ("pdf.md", b"hello", "text/plain; charset=utf-8"), + ("pdf.md", b"hello", None), + ("pdf.zip", payload, "application/zip"), + ("pdf.skill", payload, "application/x-zip-compressed"), + ("pdf.skill", payload, "text/plain"), + ("pdf.skill", payload, "application/x-skill"), + ("pdf.zip", payload, ""), + ): + files = await accept_skill_upload(filename, data, owner_user_id=USER, content_type=mime) + assert SKILL_MD in files diff --git a/src/backend/test/linsight/test_pptx_templates.py b/src/backend/test/linsight/test_pptx_templates.py new file mode 100644 index 0000000000..ded8389429 --- /dev/null +++ b/src/backend/test/linsight/test_pptx_templates.py @@ -0,0 +1,467 @@ +"""F111: built-in PPT templates and the ``Deck`` builder of the ``bisheng-pptx`` skill. + +Covers AC-01 (five templates, ≥12 editable layouts, the 13 common families), +AC-05 (native, editable objects), AC-06 (no prototype pages in the delivered file) +and AC-07 (East-Asian fonts declared). +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest +from PIL import Image +from pptx import Presentation +from pptx.oxml.ns import qn + +from bisheng.linsight.domain.services.builtin_skill_seeder import BUILTIN_SKILLS_DIR + +SKILL_DIR = BUILTIN_SKILLS_DIR / "bisheng-pptx" +SCRIPTS = SKILL_DIR / "scripts" +TEMPLATES = SKILL_DIR / "templates" +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) + +from deck_template import Deck, text_units # noqa: E402 + +TEMPLATE_IDS = ["gov-red", "blueprint", "operating", "tech-dark", "classroom"] +COMMON = [ + "cover", + "agenda", + "section", + "statement", + "two-column", + "cards-3", + "cards-4", + "kpi-4", + "chart-insight", + "compare", + "process", + "timeline", + "table", + "closing", +] +CJK_FONTS = {"微软雅黑", "黑体"} + + +def _spec(template_id: str) -> dict: + return json.loads((TEMPLATES / template_id / "layouts.json").read_text(encoding="utf-8")) + + +def _all_text(prs) -> str: + chunks = [] + for slide in prs.slides: + for shape in slide.shapes: + if shape.has_text_frame: + chunks.append(shape.text_frame.text) + if shape.has_table: + for row in shape.table.rows: + chunks.extend(cell.text for cell in row.cells) + return "\n".join(chunks) + + +# --------------------------------------------------------------------------- # +# the shipped templates +# --------------------------------------------------------------------------- # +def test_catalog_lists_the_five_templates(): + catalog = json.loads((TEMPLATES / "catalog.json").read_text(encoding="utf-8")) + assert [t["id"] for t in catalog["templates"]] == TEMPLATE_IDS + for entry in catalog["templates"]: + assert entry["layout_count"] >= 12 + assert entry["scenes"] and entry["name"] + + +@pytest.mark.parametrize("template_id", TEMPLATE_IDS) +def test_template_matches_its_slot_manifest(template_id): + spec = _spec(template_id) + path = TEMPLATES / template_id / "template.pptx" + assert path.stat().st_size < 500 * 1024, "pre-commit rejects files over 500KB" + prs = Presentation(str(path)) + assert len(prs.slides) == len(spec["layouts"]) >= 12 + ids = [layout["layout_id"] for layout in spec["layouts"]] + assert set(COMMON) <= set(ids), f"missing common layouts: {set(COMMON) - set(ids)}" + assert len(ids) > len(COMMON), "each template carries at least one specialty layout" + + for layout in spec["layouts"]: + slide = prs.slides[layout["slide_index"]] + names = {shape.name for shape in slide.shapes} + for slot in layout["slots"]: + if slot["role"] == "image_area": + assert f"{slot['name']}.box" in names + continue + assert slot["name"] in names, f"{template_id}/{layout['layout_id']}: slot {slot['name']} not on page" + if "capacity" in slot: + assert text_units(slot["sample"]) <= slot["capacity"] + for group, members in layout["groups"].items(): + assert set(members) <= names, f"{template_id}/{layout['layout_id']}: group {group} incomplete" + + +@pytest.mark.parametrize("template_id", TEMPLATE_IDS) +def test_template_declares_east_asian_fonts(template_id): + prs = Presentation(str(TEMPLATES / template_id / "template.pptx")) + for slide in prs.slides: + for shape in slide.shapes: + if not shape.has_text_frame: + continue + for para in shape.text_frame.paragraphs: + for run in para.runs: + ea = run._r.find(f"{qn('a:rPr')}/{qn('a:ea')}") + assert ea is not None and ea.get("typeface") in CJK_FONTS, ( + f"{template_id}: run {run.text!r} in {shape.name} has no CJK " + ) + theme_xml = ( + prs.slide_masters[0] + .part.part_related_by("http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme") + .blob.decode("utf-8") + ) + assert "微软雅黑" in theme_xml + + +# --------------------------------------------------------------------------- # +# Deck: clone, fill, native objects, save +# --------------------------------------------------------------------------- # +@pytest.fixture() +def photo(tmp_path) -> str: + path = tmp_path / "photo.png" + Image.new("RGB", (1600, 900), "#336699").save(path) + return str(path) + + +def _build_sample(tmp_path, photo, template_id="gov-red", footer="示范集团 · 年度汇报"): + deck = Deck.open(template_id, footer=footer) + s = deck.add("cover") + deck.fill(s, "title", "2026 年度工作汇报") + deck.fill(s, "subtitle", "向集团党委汇报") + deck.fill(s, "kicker", "年度汇报") + deck.fill(s, "org", "示范集团") + deck.fill(s, "date", "2026 年 12 月") + if "highlight" in deck.layouts["cover"]["groups"]: + deck.drop(s, "highlight") + + s = deck.add("cards-3") + deck.fill(s, "title", "三项改革全部落地") + deck.fill(s, "kicker", "02 · 改革") + deck.fill(s, "lead", "改革围绕降本、提效、强管控展开") + for i in (1, 2): + deck.fill(s, f"card{i}.no", f"0{i}") + deck.fill(s, f"card{i}.title", f"改革{i}") + deck.fill(s, f"card{i}.body", "做了什么,带来什么变化。") + deck.drop(s, "card3") + + s = deck.add("chart-insight") + deck.fill(s, "title", "营收四年增长 48%") + deck.fill(s, "kicker", "03 · 经营") + deck.chart(s, "chart", "column", ["2022", "2023", "2024", "2025"], [("营收(亿元)", [82, 95, 108, 121])]) + deck.fill(s, "insight.title", "读图") + deck.fill(s, "insight.body", ["2024 年起提速", "新业务贡献过半"]) + deck.fill(s, "source", "来源:年报") + + s = deck.add("table") + deck.fill(s, "title", "重点任务清单") + deck.fill(s, "kicker", "04 · 任务") + rows = [[f"任务{i}", "办公室", "2026-12", "推进中"] for i in range(1, 7)] + deck.table(s, "table", ["事项", "责任单位", "时限", "进展"], rows, highlight_rows=(1,)) + deck.fill(s, "note", "第二项需要协调。") + + s = deck.add("two-column") + deck.fill(s, "title", "现场照片") + deck.fill(s, "kicker", "05 · 现场") + deck.fill(s, "lead", "项目已投产") + deck.fill(s, "body", ["要点一", "要点二"]) + deck.image(s, "image", photo) + + s = deck.add("closing") + deck.fill(s, "title", "感谢聆听") + deck.drop(s, "subtitle") + deck.fill(s, "contact", "示范集团办公室") + + out = tmp_path / "output" / "年度汇报.pptx" + manifest_path = deck.save(str(out), manifest_dir=str(tmp_path / "scratch")) + return out, Path(manifest_path) + + +def test_deck_round_trip_drops_prototypes_and_keeps_native_objects(tmp_path, photo): + out, manifest_path = _build_sample(tmp_path, photo) + prs = Presentation(str(out)) + assert len(prs.slides) == 6, "prototype pages must not reach the delivered file (AC-06)" + + text = _all_text(prs) + assert "2026 年度工作汇报" in text and "示范集团 · 年度汇报" in text + for leftover in ("示例:主标题写一句核心判断或文稿名称", "示例:要点三的小标题"): + assert leftover not in text + + cards = prs.slides[1] + assert not [s for s in cards.shapes if s.name.startswith("card3.")], "drop('card3') removes the whole group" + + chart_page = prs.slides[2] + charts = [s for s in chart_page.shapes if s.has_chart] + assert len(charts) == 1 and charts[0].name == "chart" + assert list(charts[0].chart.plots[0].categories) == ["2022", "2023", "2024", "2025"] + + table_page = prs.slides[3] + tables = [s for s in table_page.shapes if s.has_table] + assert len(tables) == 1 and len(tables[0].table.rows) == 7 + + picture_page = prs.slides[4] + assert any(s.shape_type == 13 for s in picture_page.shapes), "image() inserts a real picture" + assert not [s for s in picture_page.shapes if s.name.startswith("image.")], "placeholder box/label removed" + + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + assert manifest["template"] == "gov-red" + assert [p["layout_id"] for p in manifest["pages"]] == [ + "cover", + "cards-3", + "chart-insight", + "table", + "two-column", + "closing", + ] + assert manifest["pages"][2]["objects"] == {"chart": "column"} + assert manifest["pages"][0]["unfilled"] == [] + + +def test_cloned_chart_pages_do_not_share_a_chart_part(tmp_path): + deck = Deck.open("operating") + for values in ([1, 2, 3], [7, 8, 9]): + s = deck.add("chart-insight") + deck.chart(s, "chart", "line", ["a", "b", "c"], [("x", values)]) + out = tmp_path / "two.pptx" + deck.save(str(out), manifest_dir=str(tmp_path)) + prs = Presentation(str(out)) + parts = [s.chart.part.partname for slide in prs.slides for s in slide.shapes if s.has_chart] + assert len(parts) == 2 and len(set(parts)) == 2 + first = next(s for s in prs.slides[0].shapes if s.has_chart).chart + assert list(first.plots[0].series[0].values) == [1, 2, 3] + + +def test_waterfall_and_footer_removal(tmp_path): + deck = Deck.open("operating", footer=None) + s = deck.add("bridge") + deck.chart(s, "chart", "waterfall", ["起点", "价格", "销量", "终点"], [("变动", [100, 5, -3, 102])], "0.0") + out = tmp_path / "bridge.pptx" + deck.save(str(out), manifest_dir=str(tmp_path)) + slide = Presentation(str(out)).slides[0] + assert not [sh for sh in slide.shapes if sh.name == "footer"], "footer=None removes the footer slot" + chart = next(sh for sh in slide.shapes if sh.has_chart).chart + assert list(chart.plots[0].series[0].values) == [ + 0.0, + 100.0, + 102.0, + 0.0, + ] # invisible base: +5 sits on 100, -3 hangs from 105 + assert list(chart.plots[0].series[1].values) == [100.0, 5.0, 3.0, 102.0] + + +def test_over_capacity_is_reported_and_recorded(tmp_path, capsys): + deck = Deck.open("blueprint") + s = deck.add("statement") + deck.fill(s, "statement", "超" * 200) + assert "[超容量]" in capsys.readouterr().out + manifest = json.loads(Path(deck.save(str(tmp_path / "x.pptx"), manifest_dir=str(tmp_path))).read_text()) + over = manifest["pages"][0]["over_capacity"] + assert over and over[0]["slot"] == "statement" and over[0]["used"] == 200 + + +def test_unfilled_slots_are_listed(tmp_path): + deck = Deck.open("classroom") + s = deck.add("cards-4") + deck.fill(s, "title", "四个维度") + manifest = json.loads(Path(deck.save(str(tmp_path / "y.pptx"), manifest_dir=str(tmp_path))).read_text()) + assert "card1.title" in manifest["pages"][0]["unfilled"] + assert "title" not in manifest["pages"][0]["unfilled"] + + +def test_helpful_errors_for_unknown_names(): + deck = Deck.open("tech-dark") + with pytest.raises(ValueError, match="没有版式"): + deck.add("no-such-layout") + s = deck.add("kpi-4") + with pytest.raises(KeyError, match="可用插槽"): + deck.fill(s, "nope", "x") + chart_page = deck.add("chart-insight") + with pytest.raises(ValueError, match=r"deck\.chart"): + deck.fill(chart_page, "chart", "x") + with pytest.raises(ValueError, match="未知模板"): + Deck.open("does-not-exist") + + +# --------------------------------------------------------------------------- # +# inspect_deck + deck_checks (AC-10, AC-11, AC-12) +# --------------------------------------------------------------------------- # +NEW_CHECK_PHRASES = ( + "超过该位置建议容量", + "残留模板示例文字", + "仍是模板示例值", + "连续", + "封面缺少首读焦点", + "图表区没有放图表", +) + + +def _inspect(pptx_path: Path, cwd: Path, *extra) -> str: + import subprocess + + proc = subprocess.run( + [sys.executable, str(SCRIPTS / "inspect_deck.py"), str(pptx_path), "--checks-only", *extra], + capture_output=True, + text=True, + cwd=str(cwd), + ) + assert proc.returncode == 0, proc.stderr[-2000:] + return proc.stdout + + +def test_a_correctly_filled_template_deck_passes_quietly(tmp_path, photo): + out, _ = _build_sample(tmp_path, photo) + report = _inspect(out, tmp_path) + assert "模板清单" in report and "结论: 通过" in report + hits = [p for p in NEW_CHECK_PHRASES if p in report] + assert not hits, f"good deck triggered new checks {hits}\n{report}" + assert "合计: 0 ERROR / 0 WARN" in report, report + + +def test_template_checks_fire_on_a_bad_deck(tmp_path): + deck = Deck.open("blueprint", footer="示范") + s = deck.add("cover") + deck.fill(s, "title", "方案汇报") + s = deck.add("kpi-4") + deck.fill(s, "title", "这是一个远远超过标题建议容量的超长标题,会把版面挤爆,应该精简或者拆成两页来讲清楚") + for _ in range(3): + deck.add("cards-3") + deck.add("chart-insight") + out = tmp_path / "output" / "bad.pptx" + deck.save(str(out), manifest_dir=str(tmp_path / "scratch")) + report = _inspect(out, tmp_path) + for phrase in ( + "超过该位置建议容量", + "残留模板示例文字", + "仍是模板示例值", + "连续 3 页使用版式 cards-3", + "图表区没有放图表", + ): + assert phrase in report, f"missing {phrase!r}\n{report}" + assert "第2页 「title」" in report, "capacity findings name the page and the slot" + for line in report.splitlines(): + if any(p in line for p in NEW_CHECK_PHRASES): + assert line.startswith("WARN"), f"new checks start as WARN (AC-12): {line}" + + +def test_cover_focus_and_silhouette_repetition_without_manifest(tmp_path): + from pptx.util import Pt + + prs = Presentation() + prs.slide_width, prs.slide_height = Pt(960), Pt(540) + blank = prs.slide_layouts[6] + for i in range(4): + slide = prs.slides.add_slide(blank) + for j, size in enumerate((24, 18, 16) if i == 0 else (28, 16, 16)): + left = 200 if i == 0 else 60 # the cover has its own composition + box = slide.shapes.add_textbox(Pt(left), Pt(60 + j * 90), Pt(700), Pt(70)) + box.text_frame.word_wrap = True + box.text_frame.text = f"第{i}页文字{j}" + box.text_frame.paragraphs[0].runs[0].font.size = Pt(size) + out = tmp_path / "plain.pptx" + prs.save(str(out)) + report = _inspect(out, tmp_path) + assert "未找到模板清单" in report + assert "封面缺少首读焦点" in report + assert "第 2–4 页连续 3 页使用相同构图" in report + + +def test_stale_manifest_is_ignored(tmp_path, photo): + out, manifest_path = _build_sample(tmp_path, photo) + data = json.loads(manifest_path.read_text(encoding="utf-8")) + data["pages"] = data["pages"][:-1] + manifest_path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + report = _inspect(out, tmp_path) + assert "忽略清单" in report and "结论: 通过" in report + + +# --------------------------------------------------------------------------- # +# review follow-ups +# --------------------------------------------------------------------------- # +def test_waterfall_labels_keep_the_sign_and_reject_a_total_below_zero(): + deck = Deck.open("operating", footer=None) + s = deck.add("bridge") + deck.chart(s, "chart", "waterfall", ["起点", "价格", "销量", "终点"], [("变动", [100, 5, -3, 102])], "0.0") + bars = next(sh for sh in s.shapes if sh.has_chart).chart.plots[0].series[1] + assert bars.points[2].data_label.text_frame.text == "-3.0" + s2 = deck.add("bridge") + with pytest.raises(ValueError, match="跌破 0"): + deck.chart(s2, "chart", "waterfall", ["起点", "降", "终点"], [("变动", [10, -30, -20])]) + + +def test_over_capacity_names_the_right_page_when_pages_look_alike(capsys): + deck = Deck.open("blueprint") + pages = [deck.add("statement"), deck.add("statement")] + for page in pages: + deck.fill(page, "statement", "超" * 200) + out = capsys.readouterr().out + assert "第1页 statement" in out and "第2页 statement" in out + + +def test_table_ignores_template_widths_for_a_different_column_count(tmp_path): + deck = Deck.open("operating") + s = deck.add("table") + deck.table(s, "table", ["一", "二", "三", "四", "五"], [["a", "b", "c", "d", "e"]]) + frame = next(sh for sh in s.shapes if sh.has_table) + total = sum(col.width for col in frame.table.columns) / 12700 + assert abs(total - 864.0) < 1, "five columns fill the slot width, not 4 template widths + 1 default" + + +def test_manifest_of_a_deck_rebuilt_from_scratch_is_ignored(tmp_path, photo): + from pptx.util import Pt + + out, _ = _build_sample(tmp_path, photo) + pages = len(Presentation(str(out)).slides) + prs = Presentation() + prs.slide_width, prs.slide_height = Pt(960), Pt(540) + for i in range(pages): # same file name and page count, different content + slide = prs.slides.add_slide(prs.slide_layouts[6]) + slide.shapes.add_textbox(Pt(60), Pt(60), Pt(800), Pt(80)).text_frame.text = f"重做的第{i + 1}页" + prs.save(str(out)) + report = _inspect(out, tmp_path) + assert "页面对不上" in report and "忽略清单" in report + + +def test_baseline_only_hides_findings_identical_to_the_prototype(tmp_path, photo): + from types import SimpleNamespace + + import deck_checks + + out, manifest_path = _build_sample(tmp_path, photo) + manifest, _ = deck_checks.load_manifest(str(manifest_path), Presentation(str(out))) + first = manifest["pages"][0] + + def finding(message): + return SimpleNamespace(level="ERROR", where="body", message=message) + + def proto_check(slide, index, w, h): + return [finding("文字预计需要 60pt,框高只有 40pt")] + + by_slide = { + first["index"]: [finding("文字预计需要 60pt,框高只有 40pt"), finding("文字预计需要 180pt,框高只有 40pt")] + } + suppressed = deck_checks.apply_baseline(by_slide, manifest, proto_check, 960, 540) + assert suppressed == 1 + assert [f.message for f in by_slide[first["index"]]] == ["文字预计需要 180pt,框高只有 40pt"] + + +def test_inspect_still_reports_when_the_template_checks_cannot_load(tmp_path, photo): + import shutil + import subprocess + + scripts = tmp_path / "scripts" + shutil.copytree(SCRIPTS, scripts) + (scripts / "deck_checks.py").write_text("raise ImportError('boom')\n", encoding="utf-8") + out, _ = _build_sample(tmp_path, photo) + proc = subprocess.run( + [sys.executable, str(scripts / "inspect_deck.py"), str(out), "--checks-only"], + capture_output=True, + text=True, + cwd=str(tmp_path), + ) + assert proc.returncode == 0, proc.stderr[-2000:] + assert "模板检查模块加载失败" in proc.stdout and "结论:" in proc.stdout diff --git a/src/backend/test/linsight/test_skill_duplicate_scope.py b/src/backend/test/linsight/test_skill_duplicate_scope.py new file mode 100644 index 0000000000..0dc75381d1 --- /dev/null +++ b/src/backend/test/linsight/test_skill_duplicate_scope.py @@ -0,0 +1,121 @@ +"""A personal skill may share a name with a tenant skill or with another user. + +The same owner cannot save the skill id twice. A lost race leaves no object +that the saved row does not point at. +""" + +import asyncio +from contextlib import asynccontextmanager +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +import bisheng.linsight.domain.models.linsight_skill as model_module +from bisheng.common.errcode.linsight import SkillNameDuplicateError +from bisheng.core.context.tenant import set_current_tenant_id +from bisheng.linsight.domain.models.linsight_skill import LinsightSkill, LinsightSkillDao +from bisheng.linsight.domain.services.skill_service import SkillService +from bisheng.linsight.domain.services.skill_store import SKILL_MD, SkillStore +from test.linsight.fixtures.fake_minio import FakeMinioStorage + +TENANT = 5 +USER_A = 11 +USER_B = 22 + + +def _row(name: str, owner_user_id: int, display_name: str) -> LinsightSkill: + return LinsightSkill( + tenant_id=TENANT, + owner_user_id=owner_user_id, + name=name, + display_name=display_name, + description="d", + enabled=True, + source="manual", + object_path=f"linsight/skills/{TENANT}/{name}/hash.zip", + content_hash="a" * 64, + size=1, + created_by=owner_user_id or 1, + ) + + +@pytest.fixture +async def dao(monkeypatch, tmp_path): + from sqlalchemy.ext.asyncio import create_async_engine + from sqlmodel.ext.asyncio.session import AsyncSession + + # Separate connections: a single shared SQLite connection makes the winner's + # refresh fail while the other insert is still in flight. + engine = create_async_engine( + f"sqlite+aiosqlite:///{tmp_path}/skills.db", + connect_args={"timeout": 5}, + ) + async with engine.begin() as conn: + await conn.exec_driver_sql("PRAGMA journal_mode=WAL") + await conn.run_sync(LinsightSkill.__table__.create) + + @asynccontextmanager + async def _session(): + session = AsyncSession(bind=engine, expire_on_commit=False) + try: + yield session + except Exception: + await session.rollback() + raise + finally: + await session.close() + + monkeypatch.setattr(model_module, "get_async_db_session", _session) + set_current_tenant_id(TENANT) + yield LinsightSkillDao + await engine.dispose() + + +@pytest.fixture +def service(dao, tmp_path): + store = SkillStore(root=tmp_path, minio=FakeMinioStorage()) + return SkillService(store=store, owner_projection=SimpleNamespace(authorize_created=AsyncMock())) + + +class TestDuplicateScope: + async def test_same_name_is_allowed_across_owners(self, dao, service): + await dao.create(_row("pdf", 0, "企业报告")) + await dao.create(_row("pdf", USER_A, "个人报告")) + + await service._check_duplicate("pdf", "另一份", owner_user_id=USER_B) + saved = await dao.create(_row("pdf", USER_B, "另一份")) + assert saved.owner_user_id == USER_B + + with pytest.raises(SkillNameDuplicateError) as caught: + await service._check_duplicate("pdf", "再来一份", owner_user_id=USER_A) + assert caught.value.code == 11055 + + async def test_same_display_name_with_a_different_skill_id_is_saved(self, dao, service): + await dao.create(_row("pdf", USER_A, "季度报告")) + await service._check_duplicate("other", "季度报告", owner_user_id=USER_A) + saved = await dao.create(_row("other", USER_A, "季度报告")) + assert saved.name == "other" + + async def test_concurrent_same_owner_keeps_one_row_and_no_orphan_object(self, dao, service): + async def _allow(*args, **kwargs): + return None + + service._check_duplicate = _allow + + async def _save(body: bytes): + return await service._create(TENANT, USER_A, "pdf", "报告", "d", {SKILL_MD: body}) + + results = await asyncio.gather(_save(b"first-body"), _save(b"second-body"), return_exceptions=True) + saved = [item for item in results if not isinstance(item, Exception)] + failed = [item for item in results if isinstance(item, Exception)] + assert len(saved) == 1 + assert len(failed) == 1 + assert isinstance(failed[0], SkillNameDuplicateError) + assert failed[0].code == 11055 + + again = await dao.get_by_name("pdf") + assert again is not None + assert service.store.read_text(TENANT, "pdf", again.content_hash) in ("first-body", "second-body") + keys = service.store.minio.keys(f"linsight/skills/{TENANT}/pdf/") + assert keys == [again.object_path] diff --git a/src/backend/test/linsight/test_skill_limit_mb.py b/src/backend/test/linsight/test_skill_limit_mb.py new file mode 100644 index 0000000000..d9ba32034f --- /dev/null +++ b/src/backend/test/linsight/test_skill_limit_mb.py @@ -0,0 +1,100 @@ +"""11052 and 11059 carry the cap the path actually used, in mebibytes.""" + +import io +import struct +import zipfile +from types import SimpleNamespace + +import pytest + +from bisheng.common.errcode.linsight import SkillBundleTooLargeError, SkillFileTooLargeError +from bisheng.linsight.api.endpoints import skill as skill_endpoint +from bisheng.linsight.domain.services import github_skill_fetcher as fetcher_module +from bisheng.linsight.domain.services import skill_store +from bisheng.linsight.domain.services.github_skill_fetcher import GithubTarget, fetch_skill_files +from bisheng.linsight.domain.services.skill_service import SkillService +from bisheng.linsight.domain.services.skill_store import SKILL_MD + +MB = 1024 * 1024 + + +def _conf(monkeypatch, upload_mb: int, unpacked_mb: int) -> None: + async def _aget(self): + return SimpleNamespace(skill_upload_max_size_mb=upload_mb, skill_unpacked_max_size_mb=unpacked_mb) + + monkeypatch.setattr(type(skill_store.bisheng_settings), "aget_linsight_conf", _aget) + + +def _zip_declared_size(declared: int) -> bytes: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as archive: + archive.writestr(SKILL_MD, b"x") + raw = bytearray(buf.getvalue()) + struct.pack_into(" LinsightSkill: + return LinsightSkill( + tenant_id=tenant_id, + owner_user_id=owner_user_id, + name=name, + display_name=f"{name}-{owner_user_id}", + description=f"desc of {name}", + enabled=enabled, + source="manual", + object_path=f"linsight/skills/{tenant_id}/{name}/hash.zip", + size=10, + created_by=7, + ) + + +@contextmanager +def _as_child_request(): + token_tenant = set_current_tenant_id(LEAF) + token_visible = set_visible_tenant_ids(frozenset({LEAF, ROOT})) + try: + yield + finally: + current_tenant_id.reset(token_tenant) + visible_tenant_ids.reset(token_visible) + + +async def _all(name: str) -> list[LinsightSkill]: + with bypass_tenant_filter(): + async with model_module.get_async_db_session() as session: + statement = select(LinsightSkill).where(LinsightSkill.name == name) + return list((await session.exec(statement)).all()) + + +@pytest.fixture +async def dao(monkeypatch): + from sqlalchemy.ext.asyncio import create_async_engine + from sqlmodel.ext.asyncio.session import AsyncSession + + from bisheng.core.database import tenant_filter + + engine = create_async_engine( + "sqlite+aiosqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + async with engine.begin() as conn: + await conn.run_sync(LinsightSkill.__table__.create) + + @asynccontextmanager + async def _session(): + session = AsyncSession(bind=engine, expire_on_commit=False) + try: + yield session + finally: + await session.close() + + monkeypatch.setattr(model_module, "get_async_db_session", _session) + tenant_filter._initialized = False + tenant_filter._tenant_aware_tables = set() + tenant_filter.register_tenant_filter_events() + + with bypass_tenant_filter(): + await LinsightSkillDao.create(_skill("pdf", TENANT, 0)) + await LinsightSkillDao.create(_skill("pdf", TENANT, USER_A)) + await LinsightSkillDao.create(_skill("pdf", TENANT, USER_B, enabled=False)) + await LinsightSkillDao.create(_skill("pdf", ROOT, 0)) + await LinsightSkillDao.create(_skill("pdf", ROOT, USER_A)) + await LinsightSkillDao.create(_skill("pdf", LEAF, 0)) + await LinsightSkillDao.create(_skill("mine", LEAF, USER_A)) + + set_current_tenant_id(TENANT) + yield LinsightSkillDao + + tenant_filter._initialized = False + tenant_filter._tenant_aware_tables = set() + await engine.dispose() + + +class TestSkillOwnerDao: + async def test_get_by_name_hits_only_the_given_owner(self, dao): + enterprise = await dao.get_by_name("pdf") + assert enterprise is not None + assert enterprise.owner_user_id == 0 + assert enterprise.tenant_id == TENANT + + owned = await dao.get_by_name("pdf", owner_user_id=USER_A) + assert owned is not None and owned.owner_user_id == USER_A and owned.tenant_id == TENANT + + other = await dao.get_by_name("pdf", owner_user_id=USER_B) + assert other is not None and other.owner_user_id == USER_B + assert await dao.get_by_name("pdf", owner_user_id=99) is None + + async def test_default_admin_queries_see_only_tenant_skills(self, dao): + rows, total = await dao.get_page() + assert total == 1 + assert rows[0].owner_user_id == 0 and rows[0].tenant_id == TENANT + + enabled = await dao.list_enabled() + assert [row.owner_user_id for row in enabled] == [0] + assert all(row.tenant_id == TENANT for row in enabled) + + personal, personal_total = await dao.get_page(owner_user_id=USER_A) + assert personal_total == 1 and personal[0].owner_user_id == USER_A + assert await dao.list_enabled(owner_user_id=USER_B) == [] + + async def test_mutations_do_not_touch_other_owners(self, dao): + assert await dao.set_enabled("pdf", False, owner_user_id=USER_A) is True + assert await dao.set_default_checked("pdf", True, owner_user_id=USER_A) is True + + rows = {row.owner_user_id: row for row in await _all("pdf") if row.tenant_id == TENANT} + assert bool(rows[0].enabled) is True + assert bool(rows[0].default_checked) is False + assert bool(rows[USER_A].enabled) is False + assert bool(rows[USER_A].default_checked) is True + assert bool(rows[USER_B].enabled) is False + assert bool(rows[USER_B].default_checked) is False + + assert await dao.delete_by_name("pdf", owner_user_id=USER_B) is True + remaining = {row.owner_user_id for row in await _all("pdf") if row.tenant_id == TENANT} + assert remaining == {0, USER_A} + assert await dao.delete_by_name("pdf") is True + remaining = {row.owner_user_id for row in await _all("pdf") if row.tenant_id == TENANT} + assert remaining == {USER_A} + + async def test_child_tenant_does_not_see_root_rows(self, dao): + with _as_child_request(): + assert (await dao.get_by_name("pdf")).tenant_id == LEAF + assert await dao.get_by_name("pdf", owner_user_id=USER_A) is None + owned = await dao.get_by_name("mine", owner_user_id=USER_A) + assert owned is not None and owned.tenant_id == LEAF + + rows, total = await dao.get_page() + assert total == 1 and rows[0].name == "pdf" and rows[0].tenant_id == LEAF + assert [row.name for row in await dao.list_enabled()] == ["pdf"] + + assert await dao.set_enabled("pdf", False) is True + + root_enterprise = [row for row in await _all("pdf") if row.tenant_id == ROOT and row.owner_user_id == 0] + assert len(root_enterprise) == 1 and bool(root_enterprise[0].enabled) is True diff --git a/src/backend/test/linsight/test_skill_permission_port.py b/src/backend/test/linsight/test_skill_permission_port.py new file mode 100644 index 0000000000..da4ad60a41 --- /dev/null +++ b/src/backend/test/linsight/test_skill_permission_port.py @@ -0,0 +1,54 @@ +"""Personal skills resolve for their owner. Enterprise skills do not.""" + +import pytest + +from bisheng.common.errcode.permission import PermissionInvalidResourceError +from bisheng.linsight.domain.services.f048_skill_permission import ( + F048SkillPermissionAdapter, + SkillPermissionRecord, +) +from bisheng.permission.domain.services.permission_action_service import PermissionActor + + +def _record(**overrides) -> SkillPermissionRecord: + values = { + "tenant_id": 5, + "resource_id": "4", + "owner_user_id": 11, + "permission_version": 0, + "context_version": "a" * 64, + } + values.update(overrides) + return SkillPermissionRecord(**values) + + +class _Loader: + def __init__(self, record: SkillPermissionRecord | None) -> None: + self.record = record + + async def load_permission_record(self, resource_id: str) -> SkillPermissionRecord | None: + if self.record is None or self.record.resource_id != resource_id: + return None + return self.record + + +def _actor(user_id: int = 11, tenant_id: int = 5) -> PermissionActor: + return PermissionActor(user_id=user_id, current_tenant_id=tenant_id) + + +class TestSkillPermissionPort: + async def test_the_owner_resolves_the_skill(self): + adapter = F048SkillPermissionAdapter(loader=_Loader(_record()), permission=object()) + target = await adapter.resolve_permission_target(resource_id="4", actor=_actor(), action="edit") + assert target.resource_type == "linsight_skill" + assert target.resource_id == "4" + assert target.tenant_id == 5 + assert target.resource_version == 0 + + async def test_someone_else_or_a_missing_row_is_not_a_target(self): + adapter = F048SkillPermissionAdapter(loader=_Loader(_record()), permission=object()) + with pytest.raises(PermissionInvalidResourceError): + await adapter.resolve_permission_target(resource_id="4", actor=_actor(user_id=22), action="delete") + empty = F048SkillPermissionAdapter(loader=_Loader(None), permission=object()) + with pytest.raises(PermissionInvalidResourceError): + await empty.resolve_permission_target(resource_id="4", actor=_actor(), action="edit") diff --git a/src/backend/test/linsight/test_skill_prompt_priority.py b/src/backend/test/linsight/test_skill_prompt_priority.py index d7b4eb2a43..a95062221d 100644 --- a/src/backend/test/linsight/test_skill_prompt_priority.py +++ b/src/backend/test/linsight/test_skill_prompt_priority.py @@ -30,7 +30,7 @@ @pytest.mark.parametrize("has_kb", [True, False]) def test_skill_lines_absent_when_no_skill_materialized(has_kb): - """No bundle copied -> no "Available Skills" section -> never mention skills.""" + """No bundle copied -> no "可用技能" section -> never mention skills.""" prompt = _build_linsight_system_prompt(has_kb, skills_present=False) assert "SKILL.md" not in prompt assert "技能优先" not in prompt @@ -70,12 +70,13 @@ def test_export_fallback_is_forbidden_when_a_code_executor_is_bound(has_kb): assert "bisheng_code_interpreter" in prompt # The ban is not absolute — a repeatedly failing executor may still fall back. assert "兜底" in prompt - # Executor output is mirrored into the workspace, so ls/glob DO surface it — - # but success is judged by the execution result, not by a lookup. Without this - # the model burns turns hunting the file it just wrote (observed: 5 tool calls - # of ls/glob/shutil before giving up) and sometimes regenerates it wholesale. + # Successful execution establishes file creation; content acceptance remains + # required. A truncated result permits one workspace lookup. assert "exitcode 0" in prompt - assert "不要反复 ls / glob 找它" in prompt + assert "验收内容与功能" in prompt + assert "公式应保留并重算缓存值" in prompt + assert "check=True" in prompt + assert "**核实一次**" in prompt @pytest.mark.parametrize("has_kb", [True, False]) diff --git a/src/backend/test/linsight/test_skill_provisioning.py b/src/backend/test/linsight/test_skill_provisioning.py index 2fa4e8dfa8..b46659b354 100644 --- a/src/backend/test/linsight/test_skill_provisioning.py +++ b/src/backend/test/linsight/test_skill_provisioning.py @@ -258,8 +258,7 @@ class TestEnumerationLoop: async def test_copied_skill_is_enumerated_and_path_resolves(self, monkeypatch, store, backend): """Full Fork X loop: copy → real SkillsMiddleware enumerates → path consistency.""" from deepagents.backends.filesystem import FilesystemBackend - from deepagents.middleware.skills import SkillsMiddleware - + from bisheng.linsight.domain.services.skills_prompt import LinsightSkillsMiddleware from bisheng.linsight.domain.services.workspace_backend import normalize_workspace_path _patch_enabled(monkeypatch, ENABLED, store) @@ -267,7 +266,7 @@ async def test_copied_skill_is_enumerated_and_path_resolves(self, monkeypatch, s # Enumerate via a FilesystemBackend over the same cache dir the copy wrote to — # this is exactly what agent_factory attaches when skills_present is True. - mw = SkillsMiddleware( + mw = LinsightSkillsMiddleware( backend=FilesystemBackend(root_dir=str(backend.file_dir), virtual_mode=True), sources=[(f"/{WORKSPACE_SKILLS_DIR}/", "Skills")], ) diff --git a/src/backend/test/linsight/test_skill_runtime.py b/src/backend/test/linsight/test_skill_runtime.py new file mode 100644 index 0000000000..e745bb2a54 --- /dev/null +++ b/src/backend/test/linsight/test_skill_runtime.py @@ -0,0 +1,92 @@ +"""End-user runtime transport shares Chat's tenant-enabled authorization rule.""" + +import io +import zipfile +from unittest.mock import AsyncMock + +import pytest + +from test.linsight.test_skill_api import BASE, _md_bytes, _zip_bytes + +pytest_plugins = ("test.linsight.test_skill_api",) + + +@pytest.fixture(autouse=True) +def runtime_permission_scope(monkeypatch): + from bisheng.core.context.tenant import current_tenant_id, set_current_tenant_id + from bisheng.linsight.domain.models.linsight_skill import LinsightSkillDao + from bisheng.linsight.domain.services.user_skills import SkillAvailabilityRepository + from test.linsight.test_skill_service import FakeSkillDao + + async def enabled(*, owner_user_id=0): + return [row for row in await FakeSkillDao.list_enabled() if getattr(row, "owner_user_id", 0) == owner_user_id] + + monkeypatch.setattr(LinsightSkillDao, "list_enabled", enabled) + monkeypatch.setattr(SkillAvailabilityRepository, "disabled_ids", AsyncMock(return_value=set())) + monkeypatch.setattr("bisheng.utils.http_middleware._check_is_global_super", AsyncMock(return_value=False)) + allowed = {"enabled": True} + + async def decisions(user, *, resource_type, resource_ids, actions): + return {str(i): {"use"} for i in resource_ids} if allowed["enabled"] else {} + + monkeypatch.setattr("bisheng.permission.application.business_authorization.batch_check_business_actions", decisions) + token = set_current_tenant_id(1) + yield allowed + current_tenant_id.reset(token) + + +def test_runtime_revocation_hides_catalog_and_blocks_direct_bundle(client, runtime_permission_scope): + client.post(BASE, files={"file": ("demo.md", _md_bytes(), "text/markdown")}) + row = client.get(BASE + "/runtime/catalog").json()["data"][0] + runtime_permission_scope["enabled"] = False + assert client.get(BASE + "/runtime/catalog").json()["data"] == [] + response = client.get(f"{BASE}/runtime/{row['id']}/bundle", params={"version": row["content_hash"]}) + assert response.json()["status_code"] != 200 + assert response.headers["content-type"] == "application/json" + + +def test_runtime_bundle_preserves_assets_and_rechecks_enabled(client): + files = {"SKILL.md": _md_bytes(), "scripts/run.py": b"print('ok')", "templates/demo.bin": bytes(range(256))} + created = client.post(BASE, files={"file": ("demo.zip", _zip_bytes(files), "application/zip")}).json() + assert created["status_code"] == 200 + rows = client.get(BASE + "/runtime/catalog").json()["data"] + row = rows[0] + response = client.get(f"{BASE}/runtime/{row['id']}/bundle", params={"version": row["content_hash"]}) + assert response.headers["content-type"] == "application/zip" + with zipfile.ZipFile(io.BytesIO(response.content)) as archive: + assert {name: archive.read(name) for name in archive.namelist()} == files + from test.linsight.test_skill_service import FakeSkillDao + + FakeSkillDao.rows[row["name"]].enabled = False + assert client.get(BASE + "/runtime/catalog").json()["data"] == [] + assert ( + client.get(f"{BASE}/runtime/{row['id']}/bundle", params={"version": row["content_hash"]}).json()["status_code"] + != 200 + ) + + +def test_runtime_rejects_foreign_tenant_unknown_and_old_revision(client): + client.post(BASE, files={"file": ("demo.md", _md_bytes(), "text/markdown")}) + row = client.get(BASE + "/runtime/catalog").json()["data"][0] + url = f"{BASE}/runtime/{row['id']}/bundle" + assert client.get(url, params={"version": "0" * 64}).json()["status_code"] != 200 + assert ( + client.get(f"{BASE}/runtime/9999/bundle", params={"version": row["content_hash"]}).json()["status_code"] != 200 + ) + from test.linsight.test_skill_service import FakeSkillDao + + FakeSkillDao.rows[row["name"]].tenant_id = 2 + assert client.get(url, params={"version": row["content_hash"]}).json()["status_code"] != 200 + + +def test_runtime_requires_login(client): + from fastapi import HTTPException + + from bisheng.common.dependencies.user_deps import UserPayload + + async def logged_out(): + raise HTTPException(status_code=401) + + client.app.dependency_overrides[UserPayload.get_login_user] = logged_out + assert client.get(BASE + "/runtime/catalog").status_code == 401 + assert client.get(BASE + "/runtime/1/bundle", params={"version": "0" * 64}).status_code == 401 diff --git a/src/backend/test/linsight/test_skill_runtime_guard.py b/src/backend/test/linsight/test_skill_runtime_guard.py new file mode 100644 index 0000000000..c69f685d80 --- /dev/null +++ b/src/backend/test/linsight/test_skill_runtime_guard.py @@ -0,0 +1,196 @@ +"""A copied skill that was disabled or deleted is refused on the next read or run. + +The directory stays on disk. Content already handed to the model is not taken +back. A same-named tenant skill that is still enabled does not unlock the +directory: the snapshot id is the row that owns these files. +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +from langchain_core.messages import ToolMessage + +from bisheng.linsight.domain.services.agent_factory import SkillSelectionMiddleware +from bisheng.linsight.domain.services.skill_runtime_guard import ( + SKILL_USE_REFUSAL, + skill_names_in_code, +) +from bisheng.linsight.domain.services.workspace_backend import WorkspaceBackend + +PERSONAL_ID = 7 +ENTERPRISE_ID = 9 + + +def _backend(tmp_path) -> WorkspaceBackend: + minio = MagicMock() + minio.bucket = "bisheng" + backend = WorkspaceBackend(svid="sv-guard", minio=minio, file_dir=str(tmp_path)) + backend.skill_snapshot = [{"name": "pdf", "skill_id": PERSONAL_ID, "owner_user_id": 3, "content_hash": "abc"}] + return backend + + +def _seed(backend: WorkspaceBackend, rel: str, data: bytes) -> None: + backend._cache_write(rel, data) + + +def _rows(mapping: dict[int, bool | None]): + def enabled(skill_id: int) -> bool | None: + return mapping.get(skill_id) + + return enabled + + +def test_reading_a_disabled_skill_is_refused_and_the_file_stays(tmp_path): + backend = _backend(tmp_path) + backend.skill_row_enabled = _rows({PERSONAL_ID: False, ENTERPRISE_ID: True}) + _seed(backend, "skills/pdf/SKILL.md", b"# personal copy\n") + result = backend.read("/skills/pdf/SKILL.md") + assert result.error == SKILL_USE_REFUSAL.format(name="pdf") + assert "Traceback" not in result.error + assert backend._cache_read("skills/pdf/SKILL.md") == b"# personal copy\n" + + +async def test_aread_of_a_deleted_skill_is_refused(tmp_path): + backend = _backend(tmp_path) + backend.skill_row_enabled = _rows({}) + _seed(backend, "skills/pdf/who.txt", b"who\n") + result = await backend.aread("/skills/pdf/who.txt") + assert result.error == SKILL_USE_REFUSAL.format(name="pdf") + assert backend._cache_read("skills/pdf/who.txt") == b"who\n" + + +def test_an_enabled_same_named_tenant_skill_does_not_unlock_the_copy(tmp_path): + """Lookup is the snapshot id. The tenant row being on is a different id.""" + backend = _backend(tmp_path) + seen = [] + + def enabled(skill_id: int) -> bool | None: + seen.append(skill_id) + return {PERSONAL_ID: False, ENTERPRISE_ID: True}[skill_id] + + backend.skill_row_enabled = enabled + _seed(backend, "skills/pdf/SKILL.md", b"# personal copy\n") + assert backend.read("/skills/pdf/SKILL.md").error == SKILL_USE_REFUSAL.format(name="pdf") + assert seen == [PERSONAL_ID] + + +def test_a_path_outside_the_snapshot_is_not_blocked(tmp_path): + backend = _backend(tmp_path) + backend.skill_row_enabled = _rows({PERSONAL_ID: False}) + _seed(backend, "skills/other/SKILL.md", b"# other\n") + _seed(backend, "skills-notes.md", b"notes\n") + other = backend.read("/skills/other/SKILL.md") + notes = backend.read("/skills-notes.md") + assert other.error is None + assert "other" in other.file_data["content"] + assert notes.error is None + assert "notes" in notes.file_data["content"] + + +def test_an_enabled_snapshot_row_can_still_be_read(tmp_path): + backend = _backend(tmp_path) + backend.skill_row_enabled = _rows({PERSONAL_ID: True}) + _seed(backend, "skills/pdf/SKILL.md", b"# still on\n") + result = backend.read("/skills/pdf/SKILL.md") + assert result.error is None + assert "still on" in result.file_data["content"] + + +def test_content_already_returned_is_not_taken_back(tmp_path): + backend = _backend(tmp_path) + backend.skill_row_enabled = _rows({PERSONAL_ID: True}) + _seed(backend, "skills/pdf/SKILL.md", b"# handed over\n") + first = backend.read("/skills/pdf/SKILL.md") + handed = first.file_data["content"] + backend.skill_row_enabled = _rows({PERSONAL_ID: False}) + second = backend.read("/skills/pdf/SKILL.md") + assert second.error == SKILL_USE_REFUSAL.format(name="pdf") + assert "handed over" in handed + assert backend._cache_read("skills/pdf/SKILL.md") == b"# handed over\n" + + +def test_no_snapshot_still_reads_the_skills_zone(tmp_path): + minio = MagicMock() + minio.bucket = "bisheng" + backend = WorkspaceBackend(svid="sv-old", minio=minio, file_dir=str(tmp_path)) + _seed(backend, "skills/pdf/SKILL.md", b"# old session\n") + result = backend.read("/skills/pdf/SKILL.md") + assert result.error is None + assert "old session" in result.file_data["content"] + + +def test_code_scan_matches_a_skill_directory_and_not_a_lookalike_name(): + code = 'open("skills/pdf/who.txt")\nopen("/skills/he-tong/a.py")\nopen("skills-notes/x")\nopen("not_skills/pdf/a")' + assert skill_names_in_code(code) == ["pdf", "he-tong"] + + +def _selection(enabled) -> SkillSelectionMiddleware: + session = SimpleNamespace( + skill_snapshot=[{"name": "pdf", "skill_id": PERSONAL_ID, "owner_user_id": 3, "content_hash": "abc"}] + ) + return SkillSelectionMiddleware( + selected_names=["pdf"], + tool_names={"bisheng_code_interpreter"}, + session=session, + skill_row_enabled=enabled, + ) + + +def _code_request(code: str): + return SimpleNamespace( + tool_call={"name": "bisheng_code_interpreter", "id": "call-code", "args": {"python_code": code}}, + tool=object(), + state={}, + ) + + +def test_code_that_opens_a_disabled_skill_is_refused_before_it_runs(): + selection = _selection(_rows({PERSONAL_ID: False, ENTERPRISE_ID: True})) + + def handler(_request): + raise AssertionError("interpreter ran") + + message = selection.wrap_tool_call(_code_request('open("skills/pdf/who.txt")'), handler) + assert isinstance(message, ToolMessage) + assert message.content == SKILL_USE_REFUSAL.format(name="pdf") + assert "Traceback" not in message.content + assert message.status != "error" + + +async def test_async_code_that_opens_a_deleted_skill_is_refused(): + selection = _selection(_rows({})) + + async def handler(_request): + raise AssertionError("interpreter ran") + + message = await selection.awrap_tool_call(_code_request("p = '/skills/pdf/SKILL.md'"), handler) + assert message.content == SKILL_USE_REFUSAL.format(name="pdf") + assert "Traceback" not in message.content + + +def test_code_that_does_not_touch_the_snapshot_still_runs(): + called = [] + + def enabled(skill_id: int) -> bool | None: + raise AssertionError(f"lookup for {skill_id}") + + selection = _selection(enabled) + + def handler(request): + called.append(request.tool_call["args"]["python_code"]) + return SimpleNamespace(content="ran") + + untouched = selection.wrap_tool_call(_code_request('open("uploads/a.md")'), handler) + other = selection.wrap_tool_call(_code_request('open("skills/other/a.py")'), handler) + assert untouched.content == "ran" + assert other.content == "ran" + assert called == ['open("uploads/a.md")', 'open("skills/other/a.py")'] + + +def test_an_enabled_skill_can_still_be_executed(): + selection = _selection(_rows({PERSONAL_ID: True})) + + def handler(_request): + return SimpleNamespace(content="ran") + + assert selection.wrap_tool_call(_code_request('open("skills/pdf/who.txt")'), handler).content == "ran" diff --git a/src/backend/test/linsight/test_skill_selection_prompt.py b/src/backend/test/linsight/test_skill_selection_prompt.py new file mode 100644 index 0000000000..f0731de73b --- /dev/null +++ b/src/backend/test/linsight/test_skill_selection_prompt.py @@ -0,0 +1,173 @@ +"""Checked skills are marked in the prompt. A missing tool does not fail the task.""" + +from __future__ import annotations + +import inspect +from types import SimpleNamespace +from unittest.mock import MagicMock + +from deepagents.backends.filesystem import FilesystemBackend +from langchain_core.messages import SystemMessage + +from bisheng.linsight.domain.services.agent_factory import ( + SKILL_SELECTED_NOTE, + SKILL_TOOL_UNAVAILABLE_NOTE, + SkillSelectionMiddleware, + create_linsight_agent, + task_tool_names, +) +from bisheng.linsight.domain.services.skills_prompt import LinsightSkillsMiddleware + + +def _skill_md(name: str, description: str, allowed: str | None = None) -> str: + lines = ["---", f"name: {name}", f"description: {description}"] + if allowed: + lines.append(f"allowed-tools: {allowed}") + lines.extend(["---", "", f"# {name}", ""]) + return "\n".join(lines) + + +def _write_skill(root, name: str, description: str, allowed: str | None = None) -> tuple[object, bytes]: + text = _skill_md(name, description, allowed) + path = root / "skills" / name / "SKILL.md" + path.parent.mkdir(parents=True) + path.write_bytes(text.encode()) + return path, text.encode() + + +class _Request: + def __init__(self, state, system_message=None, tool_call=None, tool=None): + self.state = state + self.system_message = system_message + self.tool_call = tool_call + self.tool = tool + + def override(self, **kwargs): + return _Request( + self.state, + kwargs.get("system_message", self.system_message), + self.tool_call, + self.tool, + ) + + +def _prompt_text(message) -> str: + parts = [] + for block in message.content_blocks: + text = block.get("text") if isinstance(block, dict) else getattr(block, "text", None) + if text: + parts.append(text) + return "\n".join(parts) + + +def _row(text: str, name: str) -> str: + return next(line for line in text.splitlines() if line.startswith(f"- **{name}**:")) + + +def _listed(root, state): + skills = LinsightSkillsMiddleware( + backend=FilesystemBackend(root_dir=str(root), virtual_mode=True), + sources=[("/skills/", "Skills")], + ) + listed = skills.modify_request(_Request(state, SystemMessage(content="base"))) + return skills, listed + + +class TestSelectionPrompt: + def test_checked_directory_is_marked_and_the_file_is_unchanged(self, tmp_path): + pdf, pdf_bytes = _write_skill(tmp_path, "pdf", "个人用途", allowed="Bash") + contract, contract_bytes = _write_skill(tmp_path, "he-tong", "审阅合同") + skills, _ = _listed(tmp_path, {}) + loaded = skills.before_agent({}, MagicMock(), {"configurable": {}}) + # Provisioning already stored the personal directory when the checked id + # belonged to the covered tenant skill of the same name. + selection = SkillSelectionMiddleware(selected_names=["pdf"], tool_names={"read_file"}) + request = _Request(loaded, SystemMessage(content="base")) + listed = skills.modify_request(request) + shown = selection.wrap_model_call(listed, lambda item: item) + text = _prompt_text(shown.system_message) + + pdf_line = _row(text, "pdf") + contract_line = _row(text, "he-tong") + assert SKILL_SELECTED_NOTE in pdf_line + assert SKILL_SELECTED_NOTE not in contract_line + assert "个人用途" in pdf_line + assert "审阅合同" in contract_line + assert SKILL_TOOL_UNAVAILABLE_NOTE in pdf_line + assert SKILL_TOOL_UNAVAILABLE_NOTE not in contract_line + assert pdf.read_bytes() == pdf_bytes + assert contract.read_bytes() == contract_bytes + + def test_name_only_request_matches_the_directory(self, tmp_path): + _write_skill(tmp_path, "pdf", "个人用途") + _write_skill(tmp_path, "he-tong", "审阅合同") + skills, _ = _listed(tmp_path, {}) + loaded = skills.before_agent({}, MagicMock(), {"configurable": {}}) + selection = SkillSelectionMiddleware( + selected_names=["he-tong"], tool_names=task_tool_names([], has_code_interpreter=False) + ) + shown = selection.wrap_model_call( + skills.modify_request(_Request(loaded, SystemMessage(content="base"))), + lambda item: item, + ) + text = _prompt_text(shown.system_message) + assert SKILL_SELECTED_NOTE not in _row(text, "pdf") + assert SKILL_SELECTED_NOTE in _row(text, "he-tong") + assert SKILL_TOOL_UNAVAILABLE_NOTE not in text + + def test_checkpoint_metadata_is_not_reread_from_disk(self, tmp_path): + path, _ = _write_skill(tmp_path, "pdf", "原来的用途") + skills, _ = _listed(tmp_path, {}) + loaded = skills.before_agent({}, MagicMock(), {"configurable": {}}) + path.write_text(_skill_md("pdf", "磁盘上被改掉的用途"), encoding="utf-8") + assert ( + skills.before_agent({"skills_metadata": loaded["skills_metadata"]}, MagicMock(), {"configurable": {}}) + is None + ) + + selection = SkillSelectionMiddleware(selected_names=["pdf"], tool_names={"read_file"}) + shown = selection.wrap_model_call( + skills.modify_request( + _Request({"skills_metadata": loaded["skills_metadata"]}, SystemMessage(content="base")) + ), + lambda item: item, + ) + text = _prompt_text(shown.system_message) + assert "原来的用途" in text + assert "磁盘上被改掉的用途" not in text + assert SKILL_SELECTED_NOTE in _row(text, "pdf") + + def test_calling_a_missing_tool_returns_the_same_sentence(self): + selection = SkillSelectionMiddleware(selected_names=["pdf"], tool_names={"read_file"}) + request = _Request( + {"skills_metadata": [{"name": "pdf", "allowed_tools": ["Bash"]}]}, + tool_call={"name": "Bash", "id": "call-1", "args": {}}, + tool=None, + ) + + def handler(_request): + raise AssertionError("missing tool was executed") + + message = selection.wrap_tool_call(request, handler) + assert message.content == SKILL_TOOL_UNAVAILABLE_NOTE + assert "Traceback" not in message.content + + def test_an_undeclared_unknown_tool_still_reaches_the_executor(self): + selection = SkillSelectionMiddleware(selected_names=["pdf"], tool_names={"read_file"}) + request = _Request( + {"skills_metadata": [{"name": "pdf", "allowed_tools": ["Bash"]}]}, + tool_call={"name": "made-up", "id": "call-2", "args": {}}, + tool=None, + ) + seen = {} + + def handler(item): + seen["name"] = item.tool_call["name"] + return SimpleNamespace(content="default") + + assert selection.wrap_tool_call(request, handler).content == "default" + assert seen["name"] == "made-up" + + def test_factory_wraps_the_skills_middleware(self): + source = inspect.getsource(create_linsight_agent) + assert source.index("LinsightSkillsMiddleware(") < source.index("SkillSelectionMiddleware(") diff --git a/src/backend/test/linsight/test_skill_service.py b/src/backend/test/linsight/test_skill_service.py index 4959e78b9d..6f7bc345c8 100644 --- a/src/backend/test/linsight/test_skill_service.py +++ b/src/backend/test/linsight/test_skill_service.py @@ -20,13 +20,12 @@ from bisheng.linsight.domain.schemas.skill_schema import SkillCreateForm from bisheng.linsight.domain.services import skill_service as service_module from bisheng.linsight.domain.services.skill_service import SkillService -from test.linsight.fixtures.fake_minio import FakeMinioStorage - from bisheng.linsight.domain.services.skill_store import ( MAX_BUNDLE_SIZE, SKILL_MD, SkillStore, ) +from test.linsight.fixtures.fake_minio import FakeMinioStorage TENANT = 1 USER = 7 @@ -55,12 +54,22 @@ async def update(cls, skill): return skill @classmethod - async def get_by_name(cls, name): - return cls.rows.get(name) + async def get_by_name(cls, name, owner_user_id=0, **kwargs): + row = cls.rows.get(name) + if row is not None and getattr(row, "owner_user_id", 0) != owner_user_id: + return None + return row @classmethod - async def get_by_display_name(cls, display_name): - return next((s for s in cls.rows.values() if s.display_name == display_name), None) + async def get_by_display_name(cls, display_name, owner_user_id=0, **kwargs): + return next( + ( + s + for s in cls.rows.values() + if s.display_name == display_name and getattr(s, "owner_user_id", 0) == owner_user_id + ), + None, + ) @classmethod async def get_page(cls, keyword=None, enabled=None, page=1, page_size=10): @@ -307,12 +316,20 @@ async def test_update_upload_replaces_bundle(self, service): assert detail.display_name == "演示技能v3" assert {f.path for f in detail.files} == {SKILL_MD, "new.txt"} - async def test_update_upload_name_mismatch_rejected(self, service): + async def test_update_upload_keeps_skill_id_when_frontmatter_differs(self, service): await service.create_from_upload(TENANT, USER, "demo-skill.md", _md_bytes()) - with pytest.raises(SkillValidationError, match="must equal"): - await service.update_from_upload( - TENANT, "demo-skill", "other.md", _md_bytes(name="other-name", display_name="别名") - ) + detail = await service.update_from_upload( + TENANT, + "demo-skill", + "other.md", + _md_bytes(name="other-name", display_name="别名", description="replaced"), + ) + assert detail.name == "demo-skill" + assert detail.display_name == "别名" + assert detail.description == "replaced" + assert detail.normalized_from is None + assert "name: demo-skill" in detail.source_text + assert "other-name" not in detail.source_text async def test_set_status_unknown_404(self, service): with pytest.raises(SkillNotFoundError): @@ -332,12 +349,7 @@ class TestImportNameNormalization: the form-create path stays strict — the user typed that ID explicitly.""" async def test_uppercase_name_normalized_on_upload(self, service): - md = ( - b"---\n" - b"name: Presentations\n" - b"description: Create or edit PowerPoint decks.\n" - b"---\n\n# body\n" - ) + md = b"---\nname: Presentations\ndescription: Create or edit PowerPoint decks.\n---\n\n# body\n" detail = await service.create_from_upload(TENANT, USER, "presentations.md", md) assert detail.name == "presentations" assert detail.display_name == "Presentations" @@ -355,14 +367,7 @@ async def test_chinese_name_normalized_via_pinyin(self, service): assert detail.normalized_from == "演示文稿" async def test_foreign_frontmatter_keys_survive_rewrite(self, service): - md = ( - b"---\n" - b"name: My-Skill\n" - b"description: demo\n" - b"license: Apache-2.0\n" - b"allowed-tools: Bash, Read\n" - b"---\n\nbody" - ) + md = b"---\nname: My-Skill\ndescription: demo\nlicense: Apache-2.0\nallowed-tools: Bash, Read\n---\n\nbody" detail = await service.create_from_upload(TENANT, USER, "s.md", md) text = detail.source_text assert "license: Apache-2.0" in text diff --git a/src/backend/test/linsight/test_skill_store.py b/src/backend/test/linsight/test_skill_store.py index 1ee44bff1b..aac4af3bae 100644 --- a/src/backend/test/linsight/test_skill_store.py +++ b/src/backend/test/linsight/test_skill_store.py @@ -297,6 +297,21 @@ def test_missing_object_raises_not_found(self, store): with pytest.raises(FileNotFoundError): store.read_text(1, "demo-skill", "0" * 64) + def test_storage_no_such_key_is_a_missing_bundle(self, store): + """MinIO raises NoSuchKey; readers must see FileNotFoundError, not a 500.""" + + class _NoSuchKey(Exception): + code = "NoSuchKey" + + def _missing(**_kwargs): + raise _NoSuchKey("The specified key does not exist.") + + store.minio.get_object_sync = _missing + content_hash = "0" * 64 + with pytest.raises(FileNotFoundError): + store.read_text(1, "demo-skill", content_hash) + assert store.list_files(1, "demo-skill", content_hash) == [] + @pytest.mark.parametrize("evil", ["../evil.md", "/abs.md", "a/../../evil.md"]) def test_traversal_rejected_on_write(self, store, evil): with pytest.raises(ValueError, match="illegal bundle path"): diff --git a/src/backend/test/linsight/test_skills_prompt_zh.py b/src/backend/test/linsight/test_skills_prompt_zh.py new file mode 100644 index 0000000000..e574dde1db --- /dev/null +++ b/src/backend/test/linsight/test_skills_prompt_zh.py @@ -0,0 +1,95 @@ +# ruff: noqa: RUF001 - Chinese prompt text, full-width punctuation is intended +"""Task mode's skills section: Chinese, and it says where SKILL.md paths are rooted. + +Incident 2026-10-23 (svid 4c85fb69…): SKILL.md listed ``references/…`` files, the +model read ``/references/…`` three times, got "not found" each time, and redid a +30k-character analysis after every miss. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from deepagents.backends.filesystem import FilesystemBackend +from langchain_core.messages import SystemMessage + +from bisheng.linsight.domain.services.agent_factory import SKILL_SELECTED_NOTE, SkillSelectionMiddleware +from bisheng.linsight.domain.services.skills_prompt import LINSIGHT_SKILLS_HEADING, LinsightSkillsMiddleware + + +class _Request: + def __init__(self, state, system_message=None): + self.state = state + self.system_message = system_message + + def override(self, **kwargs): + return _Request(self.state, kwargs.get("system_message", self.system_message)) + + +def _write_skill(root, name: str, description: str, extra: str = "") -> None: + path = root / "skills" / name / "SKILL.md" + path.parent.mkdir(parents=True) + path.write_text(f"---\nname: {name}\ndescription: {description}\n{extra}---\n\n# {name}\n", encoding="utf-8") + + +def _text(message) -> str: + return "\n".join(block["text"] for block in message.content_blocks if block.get("text")) + + +def _render(root, selected: list[str] | None = None) -> str: + skills = LinsightSkillsMiddleware( + backend=FilesystemBackend(root_dir=str(root), virtual_mode=True), + sources=[("/skills/", "Skills")], + ) + loaded = skills.before_agent({}, MagicMock(), {"configurable": {}}) + request = skills.modify_request(_Request(loaded, SystemMessage(content="base"))) + if selected is not None: + request = SkillSelectionMiddleware(selected_names=selected, tool_names={"read_file"}).wrap_model_call( + request, lambda item: item + ) + return _text(request.system_message) + + +def test_section_states_that_skill_paths_are_rooted_at_the_skill_folder(tmp_path): + _write_skill(tmp_path, "credit-report-llm-refresh", "刷新评级报告") + text = _render(tmp_path) + + assert f"**{LINSIGHT_SKILLS_HEADING}:**" in text + assert "`/skills/<技能名>/`" in text + assert "/skills/<技能名>/references/rules.md" in text + assert "- **credit-report-llm-refresh**: 刷新评级报告" in text + assert "完整说明:`/skills/credit-report-llm-refresh/SKILL.md`" in text + + +def test_framework_text_that_does_not_apply_is_gone(tmp_path): + _write_skill(tmp_path, "pdf", "处理 PDF") + text = _render(tmp_path) + + for leftover in ("Skills Skills", "Deepagents", "web-research", "for full instructions", "Available Skills"): + assert leftover not in text + + +def test_allowed_tools_and_license_still_render(tmp_path): + _write_skill(tmp_path, "pdf", "处理 PDF", extra="license: MIT\nallowed-tools: Bash\n") + text = _render(tmp_path) + + assert "- **pdf**: 处理 PDF (License: MIT)" in text + assert "需要的工具:Bash" in text + + +def test_selection_note_still_lands_on_the_row(tmp_path): + _write_skill(tmp_path, "pdf", "处理 PDF") + _write_skill(tmp_path, "he-tong", "审阅合同") + rows = { + line.split("**")[1]: line + for line in _render(tmp_path, selected=["pdf"]).splitlines() + if line.startswith("- **") + } + + assert SKILL_SELECTED_NOTE in rows["pdf"] + assert SKILL_SELECTED_NOTE not in rows["he-tong"] + + +def test_name_matches_the_framework_class_for_checkpoint_resume(): + middleware = LinsightSkillsMiddleware(backend=MagicMock(), sources=[("/skills/", "Skills")]) + assert middleware.name == "SkillsMiddleware" diff --git a/src/backend/test/linsight/test_spreadsheet_original.py b/src/backend/test/linsight/test_spreadsheet_original.py new file mode 100644 index 0000000000..f148f0f036 --- /dev/null +++ b/src/backend/test/linsight/test_spreadsheet_original.py @@ -0,0 +1,209 @@ +"""Task-mode Excel ingest: the original workbook only, no ETL, no text view. + +An uploaded .xlsx / .xls no longer goes through ``ExcelLoader`` (the RAG +chunker). It lands in the workspace as itself and the model reads it through +the code interpreter; ``read_file`` refuses binary content. Without a code +interpreter the model is told it cannot open the workbook and must ask the user +to add the tool. + +``asyncio_mode = auto`` — async tests need no decorator. +""" + +import os +from pathlib import Path +from types import SimpleNamespace + +from openpyxl import Workbook + +from bisheng.linsight.domain.schemas.linsight_schema import SubmitFileSchema +from bisheng.linsight.domain.services.workbench_impl import LinsightWorkbenchImpl as Impl + +MODE = "spreadsheet_original" + + +def _xlsx(tmp_path) -> str: + wb = Workbook() + wb.active.append(["日期", "金额"]) + wb.active.append(["2025-01-01", 1.5]) + path = tmp_path / "book.xlsx" + wb.save(path) + return str(path) + + +class _ExplodingPipeline: + def __init__(self, **_kwargs): + raise AssertionError("an Excel upload must not reach the knowledge ETL") + + +class _FakeMinio: + bucket = "bisheng" + tmp_bucket = "tmp-dir" + + def __init__(self): + self.store: dict[tuple[str, str], bytes] = {} + self.tmp: dict[str, bytes] = {} + + async def put_object(self, bucket_name=None, object_name=None, file=None, content_type=None): + self.store[(bucket_name or self.bucket, object_name)] = file + + async def get_object(self, bucket_name=None, object_name=None): + return self.store.get((bucket_name or self.bucket, object_name)) + + async def object_exists(self, bucket_name=None, object_name=None): + return (bucket_name or self.bucket, object_name) in self.store + + async def copy_object(self, source_bucket=None, source_object=None, dest_bucket=None, dest_object=None): + data = ( + self.tmp[source_object] if source_bucket == self.tmp_bucket else self.store[(source_bucket, source_object)] + ) + self.store[(dest_bucket, dest_object)] = data + + async def put_object_tmp(self, key, data): + self.tmp[key] = data + + +# -------------------------------------------------------------------------- +# routing +# -------------------------------------------------------------------------- + + +def test_original_route_falls_back_to_parse_past_the_carry_ceiling(tmp_path, monkeypatch): + path = _xlsx(tmp_path) + + assert Impl._spreadsheet_original_applies("a.xlsx", path) + assert Impl._spreadsheet_original_applies("a.XLS", path) + assert not Impl._spreadsheet_original_applies("a.csv", path) + assert not Impl._spreadsheet_original_applies("a.et", path) + + # Over the ceiling the original never reaches the workspace, so the parsed + # markdown is the only data the model could get. + monkeypatch.setattr(Impl, "_RAW_KEEP_MAX_BYTES", os.path.getsize(path) - 1) + assert not Impl._spreadsheet_original_applies("a.xlsx", path) + + +# -------------------------------------------------------------------------- +# ingest paths +# -------------------------------------------------------------------------- + + +async def test_daily_upload_lands_only_the_original(tmp_path, monkeypatch): + path = _xlsx(tmp_path) + minio = _FakeMinio() + monkeypatch.setattr("bisheng.knowledge.rag.temp_file_pipeline.TempFilePipeline", _ExplodingPipeline) + + async def _download(_url): + return path, "book.xlsx" + + monkeypatch.setattr("bisheng.core.cache.utils.async_file_download", _download) + submit = SimpleNamespace(file_id="f1", file_name="销售.xlsx", file_url="http://x", relative_path=None) + + entry = await Impl._ingest_daily_file(submit, "chat1", minio, user_id=1, used_names=set()) + + assert entry["parsing_status"] == "completed" + assert entry["valid"] is True + assert entry["ingest_mode"] == MODE + assert entry["line_count"] == 0 + # No text view: the reading path and the raw path are the same workbook. + assert entry["workspace_path"] == "/uploads/销售.xlsx" + assert entry["raw_workspace_path"] == "/uploads/销售.xlsx" + assert entry["markdown_file_path"] == entry["original_file_path"] == "linsight/chat1/f1.xlsx" + original = Path(path).read_bytes() + assert minio.store[("bisheng", "workspace/chat1/uploads/销售.xlsx")] == original + assert not any(key.endswith(".md") for _, key in minio.store) + + +async def test_daily_upload_over_the_ceiling_keeps_the_old_parse_path(tmp_path, monkeypatch): + path = _xlsx(tmp_path) + monkeypatch.setattr(Impl, "_RAW_KEEP_MAX_BYTES", 1) + + class _Reached(Exception): + pass + + class _RecordingPipeline: + def __init__(self, **_kwargs): + raise _Reached + + monkeypatch.setattr("bisheng.knowledge.rag.temp_file_pipeline.TempFilePipeline", _RecordingPipeline) + + async def _download(_url): + return path, "book.xlsx" + + monkeypatch.setattr("bisheng.core.cache.utils.async_file_download", _download) + submit = SimpleNamespace(file_id="f2", file_name="big.xlsx", file_url="http://x", relative_path=None) + + entry = await Impl._ingest_daily_file(submit, "chat1", _FakeMinio(), user_id=1, used_names=set()) + + # The ETL was attempted (and here made to fail): the old parse-failure shape, + # not the original route. + assert entry.get("ingest_mode") is None + assert entry["parsing_status"] == "failed" + assert entry["error_message"].startswith("file parse failed") + + +async def test_follow_up_upload_then_ingest_lands_only_the_original(tmp_path, monkeypatch): + path = _xlsx(tmp_path) + minio = _FakeMinio() + monkeypatch.setattr("bisheng.knowledge.rag.temp_file_pipeline.TempFilePipeline", _ExplodingPipeline) + + async def _get_minio(): + return minio + + async def _md5(_data): + return "md5" + + monkeypatch.setattr("bisheng.linsight.domain.services.workbench_impl.get_minio_storage", _get_minio) + monkeypatch.setattr("bisheng.linsight.domain.services.workbench_impl.async_calculate_md5", _md5) + + temp_info = await Impl._parse_file(1, "fid", path, "销售.xlsx") + + assert temp_info["parsing_status"] == "completed" + assert temp_info["ingest_mode"] == MODE + assert temp_info["markdown_file_path"] == "fid.xlsx" + assert temp_info["line_count"] == 0 + assert "original_file_path" not in temp_info + assert minio.tmp["fid.xlsx"] == Path(path).read_bytes() + + submit = SubmitFileSchema(file_id="fid", file_name="销售.xlsx", parsing_status="completed") + entry = await Impl._ingest_one_file(submit, temp_info, "chat1", minio, used_names=set()) + + assert entry["markdown_file_path"] == entry["original_file_path"] == "linsight/chat1/fid.xlsx" + assert entry["workspace_path"] == entry["raw_workspace_path"] == "/uploads/销售.xlsx" + assert minio.store[("bisheng", "workspace/chat1/uploads/销售.xlsx")] == Path(path).read_bytes() + + +# -------------------------------------------------------------------------- +# pointer block +# -------------------------------------------------------------------------- + + +def _sheet_entry() -> dict: + return { + "valid": True, + "original_filename": "销售.xlsx", + "ingest_mode": MODE, + "workspace_path": "/uploads/销售.xlsx", + "raw_workspace_path": "/uploads/销售.xlsx", + "line_count": 0, + "image_count": 0, + } + + +async def test_pointer_block_routes_excel_to_the_code_interpreter(): + block = (await Impl.prepare_file_list(SimpleNamespace(files=[_sheet_entry()]), has_code_interpreter=True))[0] + + assert "raw: uploads/销售.xlsx" in block + assert "不可 read_file" in block + assert "lines:" not in block + assert "bisheng_code_interpreter" in block + assert "sheet_name" in block and "header" in block + # Text-passthrough wording ("read_file works") must not leak onto a binary. + assert "read_file 与代码工具都可直接使用" not in block + + +async def test_pointer_block_without_code_interpreter_asks_user_to_add_the_tool(): + block = (await Impl.prepare_file_list(SimpleNamespace(files=[_sheet_entry()]), has_code_interpreter=False))[0] + + assert "无法读取 Excel 原件" in block + assert "需要添加代码执行器工具" in block + # Prompt <-> tool lockstep: never point at a tool that is not bound. + assert "bisheng_code_interpreter" not in block diff --git a/src/backend/test/linsight/test_step_persistence.py b/src/backend/test/linsight/test_step_persistence.py index 32d792e53d..bcc7c2f452 100644 --- a/src/backend/test/linsight/test_step_persistence.py +++ b/src/backend/test/linsight/test_step_persistence.py @@ -66,6 +66,7 @@ async def fake_update(task_id, **kwargs): mgr.get_execution_task = fake_get monkeypatch.setattr(smm.LinsightExecuteTaskDao, "update_by_id", fake_update) + monkeypatch.setattr(smm.LinsightExecuteTaskDao, "update_fields_by_id", fake_update) return mgr, store @@ -213,6 +214,7 @@ async def fake_update(task_id, **kwargs): from bisheng.linsight.domain.services import state_message_manager as smm monkeypatch.setattr(smm.LinsightExecuteTaskDao, "update_by_id", fake_update) + monkeypatch.setattr(smm.LinsightExecuteTaskDao, "update_fields_by_id", fake_update) await mgr.add_execution_task_step("t1", _thinking("c9", "The")) # first delta -> flush await mgr.add_execution_task_step("t1", _thinking("c9", " report is ready.")) # throttled @@ -621,6 +623,7 @@ async def fake_update(task_id, **kwargs): mgr.get_execution_task = fake_get monkeypatch.setattr(smm.LinsightExecuteTaskDao, "update_by_id", fake_update) + monkeypatch.setattr(smm.LinsightExecuteTaskDao, "update_fields_by_id", fake_update) return mgr, store diff --git a/src/backend/test/linsight/test_subagent_checkpoint_size.py b/src/backend/test/linsight/test_subagent_checkpoint_size.py new file mode 100644 index 0000000000..d170af6018 --- /dev/null +++ b/src/backend/test/linsight/test_subagent_checkpoint_size.py @@ -0,0 +1,111 @@ +"""Subagent checkpoints must store message deltas, not the full list per step. + +The researcher subagent inherits the main graph's checkpointer. deepagents only +gives it the DeltaChannel-backed DeepAgentState when create_deep_agent receives +an explicit state_schema; otherwise every subagent step writes the whole message +list and one run grows Redis by O(N^2) bytes (~90 MB per researcher on 105). +""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock + +import pytest +from deepagents import create_deep_agent +from deepagents.graph import DeepAgentState +from langchain_core.language_models import BaseChatModel +from langchain_core.messages import AIMessage, ToolMessage +from langchain_core.outputs import ChatGeneration, ChatResult +from langchain_core.tools import tool +from langgraph.checkpoint.memory import InMemorySaver + +from bisheng.linsight.domain.services import agent_factory + +SUBAGENT_STEPS = 20 + + +class _ScriptedModel(BaseChatModel): + replies: Any = None + + @property + def _llm_type(self) -> str: + return "scripted" + + def bind_tools(self, *args, **kwargs): + return self + + def _generate(self, messages, stop=None, run_manager=None, **kwargs): + return ChatResult(generations=[ChatGeneration(message=next(self.replies))]) + + +class _RecordingSaver(InMemorySaver): + def __init__(self): + super().__init__() + self.puts: list[tuple[str, bool]] = [] + + async def aput(self, config, checkpoint, metadata, new_versions): + ns = config["configurable"].get("checkpoint_ns", "") + self.puts.append((ns, "messages" in checkpoint["channel_values"])) + return await super().aput(config, checkpoint, metadata, new_versions) + + +@tool +def echo(s: str) -> str: + """Echo the input back.""" + return s + + +def _replies(): + yield AIMessage( + "", + tool_calls=[{"name": "task", "args": {"description": "dig", "subagent_type": "researcher"}, "id": "t0"}], + ) + for i in range(SUBAGENT_STEPS): + yield AIMessage("", tool_calls=[{"name": "echo", "args": {"s": str(i)}, "id": f"e{i}"}]) + yield AIMessage("sub done") + yield AIMessage("main done") + + +async def test_subagent_checkpoints_hold_deltas_with_deep_agent_state(): + saver = _RecordingSaver() + agent = create_deep_agent( + model=_ScriptedModel(replies=_replies()), + tools=[echo], + subagents=[{"name": "researcher", "description": "r", "system_prompt": "r", "tools": [echo]}], + state_schema=DeepAgentState, + checkpointer=saver, + ) + + result = await agent.ainvoke( + {"messages": [{"role": "user", "content": "go"}]}, + {"configurable": {"thread_id": "t"}, "recursion_limit": 500}, + ) + + sub_puts = [stored for ns, stored in saver.puts if ns.startswith("tools:")] + assert len(sub_puts) > SUBAGENT_STEPS + # Only the first checkpoint and DeltaChannel snapshots carry the full list. + assert sum(sub_puts) <= 2 + # The subagent's answer still reaches the main graph intact. + tool_results = [m.content for m in result["messages"] if isinstance(m, ToolMessage)] + assert "sub done" in tool_results + + +async def test_factory_passes_deep_agent_state(monkeypatch: pytest.MonkeyPatch): + captured: dict = {} + + def fake_create_deep_agent(**kwargs): + captured.update(kwargs) + return SimpleNamespace(name="agent") + + from bisheng.core.config.settings import LinsightConf + + monkeypatch.setattr("deepagents.create_deep_agent", fake_create_deep_agent) + monkeypatch.setattr(agent_factory, "_resolve_model", AsyncMock(return_value=(SimpleNamespace(), False))) + monkeypatch.setattr(agent_factory, "settings", SimpleNamespace(get_linsight_conf=lambda: LinsightConf())) + session = SimpleNamespace(id="sv-1", session_id="chat-1", tenant_id=1, user_id=1, question="q", api_meta=None) + + await agent_factory.create_linsight_agent(session, []) + + assert captured["state_schema"] is DeepAgentState diff --git a/src/backend/test/linsight/test_subagent_reintroduction.py b/src/backend/test/linsight/test_subagent_reintroduction.py index be6d855750..adcacfd35d 100644 --- a/src/backend/test/linsight/test_subagent_reintroduction.py +++ b/src/backend/test/linsight/test_subagent_reintroduction.py @@ -204,6 +204,40 @@ def test_researcher_subagent_prompt_tracks_kb_tool_presence(): assert "search_knowledge_base" not in spec_without["system_prompt"] +def test_main_prompt_guides_per_source_delegation(): + """Multi-source tasks: one task per source, batches of at most 3, and a + distinct scratch/ notes dir named in each description. The rule must reach + every prompt variant, and the unattended variant must still never mention + ask_user (it is not bound there).""" + from bisheng.linsight.domain.services.agent_factory import _build_linsight_system_prompt + + variants = { + "kb": _build_linsight_system_prompt(True), + "no_kb": _build_linsight_system_prompt(False), + "unattended": _build_linsight_system_prompt(False, unattended=True), + } + for name, prompt in variants.items(): + assert "【多份资料按份委派】" in prompt, name + assert "一份资料对应一个 task" in prompt, name + assert "同一轮最多同时发出 5 个 task" in prompt, name + assert "scratch/<子任务短名>/" in prompt, name + assert "不得重名" in prompt, name + assert "ask_user" not in variants["unattended"] + + +def test_researcher_prompt_writes_notes_to_assigned_scratch_dir(): + """The researcher writes full notes under the scratch/ dir named in its + description, names the notes path in its last message, and still never + writes output/ (deliverables stay with the main graph).""" + for tools in ([_FakeTool("search_knowledge_base")], [_FakeTool("my_mcp_tool")]): + spec = _build_researcher_subagent(tools) + prompt = spec["system_prompt"] + assert "description 指定的笔记目录" in prompt + assert "最后一条消息末尾写明笔记文件路径" in prompt + assert "绝不写 output/" in prompt + assert "一份资料交给一个子代理" in spec["description"] + + # -------------------------------------------------------------------------- # 3. stream mapper drops a subagent's namespaced write_todos (design §5.2a) # -------------------------------------------------------------------------- diff --git a/src/backend/test/linsight/test_task_message_broadcast.py b/src/backend/test/linsight/test_task_message_broadcast.py new file mode 100644 index 0000000000..1a341dd818 --- /dev/null +++ b/src/backend/test/linsight/test_task_message_broadcast.py @@ -0,0 +1,151 @@ +"""Real Redis acceptance for concurrent desktop/browser task subscriptions.""" + +import asyncio +import os +import pickle +from uuid import uuid4 + +import pytest +from redis.asyncio import Redis + +from bisheng.linsight.domain.services.task_message_broadcast import task_message_subscription + + +@pytest.fixture +async def redis_queue(): + url = os.getenv("TEST_TASK_REDIS_URL") + if not url: + pytest.skip("TEST_TASK_REDIS_URL enables real Redis stream acceptance") + redis = Redis.from_url(url) + key = f"task-broadcast-test:{uuid4().hex}:messages" + try: + yield redis, key + finally: + await redis.delete(key) + await redis.aclose() + + +async def receive(iterator, count): + frames = [] + async with asyncio.timeout(10): + while len(frames) < count: + frame = await anext(iterator) + if frame is not None: + frames.append(frame) + return frames + + +async def test_two_clients_receive_the_same_ordered_stream_and_final_event(redis_queue): + redis, key = redis_queue + # Separate connections model sockets served by distinct API processes. + other = Redis.from_url(os.environ["TEST_TASK_REDIS_URL"]) + try: + async with task_message_subscription(redis, key) as desktop: + async with task_message_subscription(other, key) as browser: + expected = [{"event_type": "task_execute_step", "data": {"sequence": i}} for i in range(12)] + expected.append({"event_type": "final_result", "data": {"output_result": {"answer": "done"}}}) + await redis.rpush(key, *(pickle.dumps(frame) for frame in expected)) + left, right = await asyncio.gather(receive(desktop, len(expected)), receive(browser, len(expected))) + assert left == right == expected + assert await redis.llen(key) == 0 + finally: + await other.aclose() + + +async def test_pre_connection_backlog_and_legacy_worker_writes_are_delivered(redis_queue): + redis, key = redis_queue + expected = {"event_type": "task_generate", "data": {"tasks": [{"id": "task-1"}]}} + await redis.rpush(key, pickle.dumps(expected)) + async with task_message_subscription(redis, key) as desktop: + assert await receive(desktop, 1) == [expected] + + +async def test_disconnect_releases_only_its_subscription(redis_queue): + redis, key = redis_queue + async with task_message_subscription(redis, key) as desktop: + async with task_message_subscription(redis, key): + assert (await redis.pubsub_numsub(f"{key}:broadcast"))[0][1] == 2 + assert (await redis.pubsub_numsub(f"{key}:broadcast"))[0][1] == 1 + final = {"event_type": "final_result", "data": {"output_result": {}}} + await redis.rpush(key, pickle.dumps(final)) + assert await receive(desktop, 1) == [final] + assert (await redis.pubsub_numsub(f"{key}:broadcast"))[0][1] == 0 + + +async def test_distinct_tasks_keep_their_events_separate(redis_queue): + redis, key = redis_queue + other_key = f"{key}:other" + try: + async with task_message_subscription(redis, key) as left: + async with task_message_subscription(redis, other_key) as right: + await redis.rpush(key, pickle.dumps({"id": "left"})) + await redis.rpush(other_key, pickle.dumps({"id": "right"})) + assert await asyncio.gather(receive(left, 1), receive(right, 1)) == [ + [{"id": "left"}], + [{"id": "right"}], + ] + finally: + await redis.delete(other_key) + + +async def test_real_websockets_both_finish_the_same_task(redis_queue, monkeypatch): + import socket + from types import SimpleNamespace + from unittest.mock import AsyncMock + + import uvicorn + import websockets + from fastapi import FastAPI, WebSocket + + from bisheng.linsight.domain.services import message_stream_handle as stream + + redis, key = redis_queue + monkeypatch.setattr( + stream, + "LinsightStateMessageManager", + lambda **_: SimpleNamespace( + message_stream=lambda: task_message_subscription(redis, key), + ), + ) + monkeypatch.setattr(stream.LinsightSessionVersionDao, "get_by_id", AsyncMock(return_value=None)) + app = FastAPI() + + @app.websocket("/stream") + async def task_stream(websocket: WebSocket): + await stream.MessageStreamHandle(websocket, "fixture-version").connect() + + listener = socket.socket() + listener.bind(("127.0.0.1", 0)) + listener.listen(128) + listener.setblocking(False) + url = f"ws://127.0.0.1:{listener.getsockname()[1]}/stream" + server = uvicorn.Server(uvicorn.Config(app, log_level="error", lifespan="off")) + serving = asyncio.create_task(server.serve(sockets=[listener])) + try: + async with asyncio.timeout(5): + while not server.started: + await asyncio.sleep(0.01) + async with websockets.connect(url) as desktop: + async with websockets.connect(url) as browser: + async with asyncio.timeout(5): + while (await redis.pubsub_numsub(f"{key}:broadcast"))[0][1] < 2: + await asyncio.sleep(0.01) + events = [ + {"event_type": "task_generate", "data": {"tasks": [{"id": "one"}]}}, + {"event_type": "task_end", "data": {"id": "one", "status": "success"}}, + {"event_type": "final_result", "data": {"output_result": {"answer": "done", "final_files": []}}}, + ] + await redis.rpush(key, *(pickle.dumps(frame) for frame in events)) + + async def read(socket): + import json + + async with asyncio.timeout(5): + return [json.loads(await socket.recv()) for _ in events] + + for frames in await asyncio.gather(read(desktop), read(browser)): + assert [{"event_type": frame["event_type"], "data": frame["data"]} for frame in frames] == events + finally: + server.should_exit = True + await asyncio.wait_for(serving, timeout=5) + listener.close() diff --git a/src/backend/test/linsight/test_task_message_stream_lifecycle.py b/src/backend/test/linsight/test_task_message_stream_lifecycle.py new file mode 100644 index 0000000000..02947fbd93 --- /dev/null +++ b/src/backend/test/linsight/test_task_message_stream_lifecycle.py @@ -0,0 +1,138 @@ +"""A completed task converges even when its terminal notification was missed.""" + +import asyncio +from contextlib import asynccontextmanager +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from starlette.websockets import WebSocketDisconnect, WebSocketState + +from bisheng.linsight.domain.services import message_stream_handle as stream + + +@pytest.fixture +def handler(monkeypatch): + ws = SimpleNamespace( + accept=AsyncMock(), + close=AsyncMock(), + send_json=AsyncMock(), + client_state=WebSocketState.CONNECTED, + application_state=WebSocketState.CONNECTED, + ) + + async def receive_text(): + await asyncio.Event().wait() + + ws.receive_text = receive_text + monkeypatch.setattr(stream, "LinsightStateMessageManager", lambda **_: SimpleNamespace()) + monkeypatch.setattr(stream.LinsightSessionVersionDao, "get_by_id", AsyncMock(return_value=None)) + return stream.MessageStreamHandle(ws, "version-1"), ws + + +def attach_stream(handler, frames): + closed = asyncio.Event() + + @asynccontextmanager + async def subscription(): + async def messages(): + for frame in frames: + yield frame + await asyncio.Event().wait() + + try: + yield messages() + finally: + closed.set() + + handler._state_message_manager.message_stream = subscription + return closed + + +@pytest.mark.parametrize( + "status,event", + [ + ("completed", "final_result"), + ("terminated", "task_terminated"), + ("failed", "error_message"), + ("sop_generation_failed", "error_message"), + ], +) +async def test_persisted_terminal_state_completes_a_quiet_socket(handler, monkeypatch, status, event): + subject, ws = handler + closed = attach_stream(subject, [None]) + stream.LinsightSessionVersionDao.get_by_id.return_value = SimpleNamespace( + status=status, + output_result={"answer": "complete", "final_files": [{"file_name": "report.docx"}]}, + ) + await asyncio.wait_for(subject.connect(), timeout=1) + assert ws.send_json.call_args.args[0]["event_type"] == event + ws.close.assert_awaited_once_with(code=1000, reason="Session finished") + assert closed.is_set() + if status == "completed": + assert ws.send_json.call_args.args[0]["data"]["output_result"]["final_files"][0]["file_name"] == "report.docx" + + +async def test_running_session_preserves_live_events_and_waits_for_completion(handler): + subject, ws = handler + closed = attach_stream( + subject, + [ + {"event_type": "task_generate", "data": {"tasks": [{"id": "one"}]}}, + {"event_type": "final_result", "data": {"output_result": {"answer": "done"}}}, + ], + ) + stream.LinsightSessionVersionDao.get_by_id.return_value = SimpleNamespace(status="in_progress") + await asyncio.wait_for(subject.connect(), timeout=1) + assert [call.args[0]["event_type"] for call in ws.send_json.call_args_list] == ["task_generate", "final_result"] + assert closed.is_set() + + +async def test_completion_during_an_existing_connection_is_reconciled(handler, monkeypatch): + subject, ws = handler + attach_stream(subject, [None, None]) + monkeypatch.setattr(subject, "TERMINAL_CHECK_INTERVAL", 0) + stream.LinsightSessionVersionDao.get_by_id.side_effect = [ + SimpleNamespace(status="in_progress"), + SimpleNamespace(status="completed", output_result={"answer": "done"}), + ] + await asyncio.wait_for(subject.connect(), timeout=1) + assert ws.send_json.call_args.args[0]["event_type"] == "final_result" + + +async def test_completed_snapshot_preserves_queued_progress_and_final_order(handler): + subject, ws = handler + attach_stream( + subject, + [ + {"event_type": "task_end", "data": {"id": "last", "status": "success"}}, + {"event_type": "final_result", "data": {"output_result": {"answer": "done"}}}, + ], + ) + stream.LinsightSessionVersionDao.get_by_id.return_value = SimpleNamespace( + status="completed", output_result={"answer": "done"} + ) + await asyncio.wait_for(subject.connect(), timeout=1) + assert [call.args[0]["event_type"] for call in ws.send_json.call_args_list] == ["task_end", "final_result"] + + +async def test_disconnect_cancels_a_quiet_stream(handler): + subject, ws = handler + closed = attach_stream(subject, []) + ws.receive_text = AsyncMock(side_effect=WebSocketDisconnect(code=1006)) + await asyncio.wait_for(subject.connect(), timeout=1) + assert closed.is_set() + ws.send_json.assert_not_called() + + +async def test_transport_failure_closes_for_reconnect(handler): + subject, ws = handler + + @asynccontextmanager + async def broken(): + raise ConnectionError("Redis unavailable") + yield + + subject._state_message_manager.message_stream = broken + await asyncio.wait_for(subject.connect(), timeout=1) + ws.close.assert_awaited_once_with(code=1011, reason="Task stream unavailable") diff --git a/src/backend/test/linsight/test_tool_repeat_loop.py b/src/backend/test/linsight/test_tool_repeat_loop.py index 2c5991f116..cd28cc188f 100644 --- a/src/backend/test/linsight/test_tool_repeat_loop.py +++ b/src/backend/test/linsight/test_tool_repeat_loop.py @@ -334,3 +334,58 @@ class _OldConf: mw = build_tool_loop_breaker_middleware(_OldConf(), is_subagent=True) assert mw.repeat_soft_limit == 3 assert mw.repeat_hard_limit == 8 + + +# The 3006 regression alternated successful writes and unchanged task updates. +def _cycle_turns(repeats, period=2): + steps = [ + ("write_file", {"file_path": "scratch/build_sheet.py", "content": "print('same')"}), + ("write_todos", {"todos": [{"content": "生成 Excel", "status": "in_progress"}]}), + ("read_file", {"file_path": "scratch/build_sheet.py"}), + ][:period] + return [ + message + for _ in range(repeats) + for name, args in steps + for message in (_ai_call(name, args), _offloaded_tm(name)) + ] + + +@pytest.mark.parametrize("period", [2, 3]) +def test_alternating_success_cycle_aborts(period): + messages = _cycle_turns(8, period) + with pytest.raises(LinsightToolLoopError) as exc: + _mw().after_model({"messages": messages}, None) + assert exc.value.reason == "repeat" + + +async def test_alternating_success_cycle_receives_early_nudge(): + request = _request(_cycle_turns(3)) + received = await _run_nudge(_mw(), request) + assert len(received.messages) == len(request.messages) + 1 + assert "write_file" in received.messages[-1].content + assert "write_todos" in received.messages[-1].content + + +def test_changing_file_content_establishes_progress(): + messages = _cycle_turns(7) + messages.extend( + [ + _ai_call("write_file", {"file_path": "scratch/build_sheet.py", "content": "print('new')"}), + _offloaded_tm("write_file"), + ] + ) + _mw().after_model({"messages": messages}, None) + + +def test_new_user_input_resets_alternating_cycle(): + messages = [*_cycle_turns(7), HumanMessage(content="换一个方案"), *_cycle_turns(1)] + _mw().after_model({"messages": messages}, None) + + +def test_interactive_cycle_resets_repetition(): + messages = [] + for _ in range(9): + messages.extend(_cycle_turns(1)) + messages.extend([_ai_call("ask_user", {"question": "继续吗"}), _offloaded_tm("ask_user")]) + _mw().after_model({"messages": messages}, None) diff --git a/src/backend/test/linsight/test_tool_result_budget.py b/src/backend/test/linsight/test_tool_result_budget.py new file mode 100644 index 0000000000..5130bcf6a2 --- /dev/null +++ b/src/backend/test/linsight/test_tool_result_budget.py @@ -0,0 +1,135 @@ +"""F087 P1: one tool result may take at most 10% of the usable input (spec AC-19, AC-20).""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +from deepagents import create_deep_agent +from deepagents.backends import StateBackend +from deepagents.graph import DeepAgentState +from langchain_core.language_models import BaseChatModel +from langchain_core.messages import AIMessage, SystemMessage, ToolMessage +from langchain_core.outputs import ChatGeneration, ChatResult +from langchain_core.tools import tool +from langgraph.checkpoint.memory import InMemorySaver + +from bisheng.linsight.domain.services.context_budget import ToolResultBudgetMiddleware, estimate_text_tokens + +USABLE_INPUT = 20_000 # → 2000-token budget per result + + +class _Scripted(BaseChatModel): + replies: Any = None + + @property + def _llm_type(self) -> str: + return "result-budget-scripted" + + def bind_tools(self, *args, **kwargs): + return self + + def _generate(self, messages, stop=None, run_manager=None, **kwargs): + assert any(isinstance(m, SystemMessage) for m in messages) + return ChatResult(generations=[ChatGeneration(message=next(self.replies))]) + + +@tool +def big_search(query: str) -> str: + """Return a long Chinese result: 3000 CJK characters (~3000 tokens).""" + return "检索结果" * 750 + + +@tool +def small_search(query: str) -> str: + """Return a short result.""" + return "命中 3 条" + + +def _run_agent(tool_name: str): + backend = StateBackend() + model = _Scripted( + replies=iter( + [AIMessage("", tool_calls=[{"name": tool_name, "args": {"query": "q"}, "id": "c1"}]), AIMessage("done")] + ) + ) + agent = create_deep_agent( + model=model, + tools=[big_search, small_search], + middleware=[ToolResultBudgetMiddleware(backend, USABLE_INPUT)], + state_schema=DeepAgentState, + backend=backend, + checkpointer=InMemorySaver(), + ) + return agent.invoke({"messages": [{"role": "user", "content": "go"}]}, {"configurable": {"thread_id": "t"}}) + + +def _tool_message(result) -> ToolMessage: + return next(m for m in result["messages"] if isinstance(m, ToolMessage) and m.tool_call_id == "c1") + + +def test_result_over_budget_is_offloaded(): + # 3000 CJK chars is only 3000 characters: under the framework's own + # 80000-character threshold, so only the CJK-aware budget catches it. + result = _run_agent("big_search") + + message = _tool_message(result) + assert "/large_tool_results/c1" in message.content + assert estimate_text_tokens(message.content) < 2000 + assert "/large_tool_results/c1" in result["files"] + + +def test_result_within_budget_is_untouched(): + result = _run_agent("small_search") + + assert _tool_message(result).content == "命中 3 条" + assert not any(path.startswith("/large_tool_results/") for path in result.get("files", {})) + + +def _read_request(path="/uploads/report.md"): + return SimpleNamespace(tool_call={"name": "read_file", "args": {"file_path": path}, "id": "r1"}, runtime=None) + + +def test_read_file_page_is_clipped_not_offloaded(): + middleware = ToolResultBudgetMiddleware(StateBackend(), USABLE_INPUT) + page = ToolMessage(content="正文" * 2000, tool_call_id="r1", name="read_file") # 4000 CJK chars + + clipped = middleware.wrap_tool_call(_read_request(), lambda request: page) + + assert estimate_text_tokens(clipped.content) <= 2000 + assert "offset" in clipped.content and "/uploads/report.md" in clipped.content + assert clipped.content.startswith("正文正文") + + +def test_small_read_file_page_is_untouched(): + middleware = ToolResultBudgetMiddleware(StateBackend(), USABLE_INPUT) + page = ToolMessage(content="短内容", tool_call_id="r1", name="read_file") + + assert middleware.wrap_tool_call(_read_request(), lambda request: page) is page + + +def test_write_file_result_is_never_touched(): + middleware = ToolResultBudgetMiddleware(StateBackend(), USABLE_INPUT) + message = ToolMessage(content="已写入" * 2000, tool_call_id="w1", name="write_file") + request = SimpleNamespace(tool_call={"name": "write_file", "args": {}, "id": "w1"}, runtime=None) + + assert middleware.wrap_tool_call(request, lambda r: message) is message + + +def test_factory_mounts_the_budget_only_for_a_known_window(): + from bisheng.linsight.domain.services.agent_factory import _result_budget + + assert _result_budget(StateBackend(), {"usable_input": None}) == [] + (mounted,) = _result_budget(StateBackend(), {"usable_input": 50_000}) + assert isinstance(mounted, ToolResultBudgetMiddleware) + + +def test_grep_output_is_clipped_with_a_narrowing_hint(): + middleware = ToolResultBudgetMiddleware(StateBackend(), USABLE_INPUT) + hits = ToolMessage(content="匹配行" * 2000, tool_call_id="g1", name="grep") + request = SimpleNamespace(tool_call={"name": "grep", "args": {"pattern": "合同"}, "id": "g1"}, runtime=None) + + clipped = middleware.wrap_tool_call(request, lambda r: hits) + + assert estimate_text_tokens(clipped.content) <= 2000 + assert "缩小" in clipped.content diff --git a/src/backend/test/linsight/test_truncated_turn_guard.py b/src/backend/test/linsight/test_truncated_turn_guard.py new file mode 100644 index 0000000000..cec91c7099 --- /dev/null +++ b/src/backend/test/linsight/test_truncated_turn_guard.py @@ -0,0 +1,162 @@ +"""Unit tests for the truncated-turn guard in LinsightModelResilienceMiddleware. + +A model call cut off by ``finish_reason=length`` with NO tool call is read by +deepagents as "the agent is done". On a community beta6 run (2026-10-30) one call +reasoned for 32000 tokens with ``content_len=0``, and the task closed COMPLETED +with an earlier turn's narration as its answer. The guard re-sends once with a +nudge, then fails the main graph / degrades the subagent. + +``asyncio_mode = auto`` — async tests need no decorator. +""" + +import pytest +from langchain.agents.middleware.types import ModelRequest, ModelResponse +from langchain_core.messages import AIMessage, HumanMessage + +from bisheng.common.services.llm_error_classifier import ( + Behavior, + ErrorType, + classify_behavior, + classify_for_event, + label_error, +) +from bisheng.linsight.domain.services.resilience_middleware import ( + _DEGRADE_MESSAGE, + _TRUNCATED_TURN_NUDGE, + _TRUNCATED_TURN_RETRY_LIMIT, + LinsightModelResilienceMiddleware, + ModelOutputTruncatedError, + _is_truncated_tool_call, + _is_truncated_turn, +) +from bisheng.linsight.domain.task_exec import TaskExecutionError + +# The measured shape: reasoning only, nothing in content, no tool call. +_USAGE = {"input_tokens": 79225, "output_tokens": 32000, "total_tokens": 111225} + + +def _truncated_ai(content="", finish="length"): + return AIMessage(content=content, response_metadata={"finish_reason": finish}, usage_metadata=dict(_USAGE)) + + +def _ok_ai(): + return AIMessage( + content="", + tool_calls=[{"name": "read_file", "args": {"file_path": "/a.py"}, "id": "t", "type": "tool_call"}], + response_metadata={"finish_reason": "tool_calls"}, + ) + + +def _resp(msg): + return ModelResponse(result=[msg]) + + +def _req(): + return ModelRequest(model=None, messages=[HumanMessage(content="把 PDF 转成 Word")]) + + +def _mw(*, is_subagent=False): + return LinsightModelResilienceMiddleware(max_retries=3, initial_delay=0.0, is_subagent=is_subagent) + + +# --------------------------------------------------------------------------- detection + + +def test_detects_the_measured_shape(): + assert _is_truncated_turn(_resp(_truncated_ai())) is True + + +def test_partial_text_answer_counts_too(): + assert _is_truncated_turn(_resp(_truncated_ai(content="The document has ten"))) is True + + +def test_anthropic_max_tokens_counts_too(): + assert _is_truncated_turn(AIMessage(content="", response_metadata={"stop_reason": "max_tokens"})) is True + + +def test_finished_turn_is_not_truncated(): + assert _is_truncated_turn(_resp(AIMessage(content="done", response_metadata={"finish_reason": "stop"}))) is False + + +def test_disjoint_from_the_truncated_tool_call_guard(): + cut_tool_call = AIMessage( + content="", + tool_calls=[{"name": "write_file", "args": {"file_path": "/o/r.md"}, "id": "t", "type": "tool_call"}], + response_metadata={"finish_reason": "length"}, + ) + assert _is_truncated_turn(cut_tool_call) is False + assert _is_truncated_tool_call(cut_tool_call) is True + + +# --------------------------------------------------------------------------- behaviour + + +async def test_retries_once_with_nudge_then_returns_recovered(): + mw = _mw() + captured = [] + + async def handler(request): + captured.append(list(request.messages)) + return _resp(_truncated_ai()) if len(captured) == 1 else _resp(_ok_ai()) + + out = await mw.awrap_model_call(_req(), handler) + assert len(captured) == 2 + assert captured[1][-1].content == _TRUNCATED_TURN_NUDGE + assert len(captured[1]) == len(captured[0]) + 1 + assert out.result[0].tool_calls + + +async def test_main_graph_fails_instead_of_completing(): + mw = _mw() + calls = [] + + async def handler(_request): + calls.append(1) + return _resp(_truncated_ai()) + + with pytest.raises(ModelOutputTruncatedError): + await mw.awrap_model_call(_req(), handler) + assert len(calls) == _TRUNCATED_TURN_RETRY_LIMIT + 1 + + +async def test_subagent_degrades_so_the_parent_task_continues(): + mw = _mw(is_subagent=True) + + async def handler(_request): + return _resp(_truncated_ai()) + + out = await mw.awrap_model_call(_req(), handler) + assert isinstance(out, AIMessage) + assert out.content == _DEGRADE_MESSAGE + + +def test_sync_wrapper_guards_the_same_way(): + mw = _mw() + calls = [] + + def handler(_request): + calls.append(1) + return _resp(_truncated_ai()) + + with pytest.raises(ModelOutputTruncatedError): + mw.wrap_model_call(_req(), handler) + assert len(calls) == _TRUNCATED_TURN_RETRY_LIMIT + 1 + + +# --------------------------------------------------------------------------- classification + + +def test_error_is_classified_as_output_truncated(): + exc = ModelOutputTruncatedError("cut off") + assert classify_behavior(exc) is Behavior.FAIL_FAST + assert label_error(exc) is ErrorType.OUTPUT_TRUNCATED + + +def test_wrapped_error_still_reaches_the_truncated_card(): + try: + try: + raise ModelOutputTruncatedError("cut off") + except ModelOutputTruncatedError as e: + raise TaskExecutionError("Agent task execution failed") from e + except TaskExecutionError as wrapper: + assert classify_for_event(wrapper).error_type == ErrorType.OUTPUT_TRUNCATED.value diff --git a/src/backend/test/linsight/test_user_skills.py b/src/backend/test/linsight/test_user_skills.py new file mode 100644 index 0000000000..7bec83b38b --- /dev/null +++ b/src/backend/test/linsight/test_user_skills.py @@ -0,0 +1,83 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from fastapi import HTTPException + +from bisheng.core.context.tenant import current_tenant_id, set_current_tenant_id +from bisheng.linsight.domain.services.user_skills import SkillAvailabilityRepository, UserSkillsService, visible_to + + +@pytest.fixture(autouse=True) +def isolated_tenant_context(): + token = set_current_tenant_id(None) + yield + current_tenant_id.reset(token) + + +def record(id, source="builtin", owner=None): + return SimpleNamespace(id=id, name=f"skill-{id}", source=source, created_by=owner) + + +@pytest.mark.parametrize("owner,current,allowed", [(7, 7, True), (7, 8, False), (7, None, False)]) +def test_personal_ownership(owner, current, allowed): + assert visible_to(record(1, "personal", owner), current) is allowed + assert visible_to(record(2), current) + + +async def test_account_switches_keep_disabled_entries_manageable_and_filter_runtime(monkeypatch): + from bisheng.linsight.domain.models.linsight_skill import LinsightSkillDao + + set_current_tenant_id(1) + monkeypatch.setattr("bisheng.utils.http_middleware._check_is_global_super", AsyncMock(return_value=False)) + monkeypatch.setattr( + "bisheng.permission.application.business_authorization.batch_check_business_actions", + AsyncMock(return_value={"1": {"use"}, "2": {"use"}}), + ) + rows = [record(1), record(2, "personal", 7), record(3, "personal", 8)] + monkeypatch.setattr(LinsightSkillDao, "list_enabled", AsyncMock(return_value=rows)) + repo = SimpleNamespace(disabled_ids=AsyncMock(return_value={1}), save=AsyncMock()) + service = UserSkillsService(7, repo) + assert [(r.id, on) for r, on in await service.records(False)] == [(1, False), (2, True)] + assert [r.id for r, _ in await service.records()] == [2] + with pytest.raises(HTTPException): + await service.require(1) + await service.toggle(1, True) + repo.save.assert_awaited_once_with(7, 1, True) + for id in [1, 3]: + with pytest.raises(HTTPException): + await service.personal(id) + assert (await service.personal(2)).id == 2 + with pytest.raises(HTTPException): + await service.toggle(3, True) + + +def test_durable_settings_include_tenant_account_and_skill_namespace(): + set_current_tenant_id(2) + assert SkillAvailabilityRepository.prefix(7) == "user-skill-availability:2:7:" + set_current_tenant_id(3) + assert SkillAvailabilityRepository.prefix(7) == "user-skill-availability:3:7:" + set_current_tenant_id(None) + with pytest.raises(PermissionError): + SkillAvailabilityRepository.prefix(7) + + +async def test_revoked_skill_cannot_be_listed_downloaded_or_reenabled(monkeypatch): + from bisheng.linsight.domain.models.linsight_skill import LinsightSkillDao + + set_current_tenant_id(1) + monkeypatch.setattr(LinsightSkillDao, "list_enabled", AsyncMock(return_value=[record(1), record(2)])) + monkeypatch.setattr("bisheng.utils.http_middleware._check_is_global_super", AsyncMock(return_value=False)) + decisions = AsyncMock(return_value={"1": {"use"}}) + monkeypatch.setattr("bisheng.permission.application.business_authorization.batch_check_business_actions", decisions) + repo = SimpleNamespace(disabled_ids=AsyncMock(return_value=set()), save=AsyncMock()) + service = UserSkillsService(7, repo) + assert [row.id for row, _ in await service.records()] == [1] + decisions.return_value = {} + assert await service.records(False) == [] + with pytest.raises(HTTPException): + await service.require(1) + with pytest.raises(HTTPException): + await service.toggle(1, True) + repo.save.assert_not_awaited() + set_current_tenant_id(None) diff --git a/src/backend/test/linsight/test_workspace_dual_track.py b/src/backend/test/linsight/test_workspace_dual_track.py index 8c3514dba1..2ce2a08311 100644 --- a/src/backend/test/linsight/test_workspace_dual_track.py +++ b/src/backend/test/linsight/test_workspace_dual_track.py @@ -344,8 +344,13 @@ def test_ingest_route_truth_table(): assert Impl._ingest_route("page.html") == "parse" assert Impl._ingest_route("page.htm") == "parse" + # Spreadsheets skip the ETL and land as the original only (spreadsheet_original). + assert Impl._ingest_route("book.xlsx") == "original" + assert Impl._ingest_route("legacy.XLS") == "original" + # WPS .et goes through the xinchuang converter before anything can read it. + assert Impl._ingest_route("wps.et") == "parse" + # Parser-only types are untouched by any of this. - assert Impl._ingest_route("book.xlsx") == "parse" assert Impl._ingest_route("scan.pdf") == "parse" assert Impl._ingest_route("photo.png") == "parse" diff --git a/src/backend/test/linsight/test_workspace_grep_alternation.py b/src/backend/test/linsight/test_workspace_grep_alternation.py new file mode 100644 index 0000000000..f795a66891 --- /dev/null +++ b/src/backend/test/linsight/test_workspace_grep_alternation.py @@ -0,0 +1,124 @@ +"""Workspace ``grep`` treats ``|`` as keyword alternation, everything else literally. + +Models keep writing ``A|B|C`` to search several keywords at once. On 106 (10-29) +5 of 7 greps in one task-mode session came back "No matches" that way although +every keyword was in the file, and each miss cost a model round. These tests pin +the rule: split on ``|`` unless a segment is blank, and always keep the whole +pattern as a literal term so nothing the old literal match found is lost. + +``asyncio_mode = auto`` — async tests need no decorator. +""" + +from unittest.mock import MagicMock + +from deepagents.backends.protocol import FileInfo, LsResult + +from bisheng.linsight.domain.services.workspace_backend import WorkspaceBackend, _grep_terms + +REPORT = "\n".join( + [ + "公司主要从事核电运营业务。", + "营业总收入基本来自于售电收入。", + "| 操作id | 名称 |", + "a || b", + "price (USD) 3.5", + "无关的一行", + ] +) + + +def _backend(tmp_path) -> WorkspaceBackend: + minio = MagicMock() + minio.bucket = "bisheng" + be = WorkspaceBackend(svid="sv-grep", minio=minio, file_dir=str(tmp_path)) + data = REPORT.encode() + be._cache_write("uploads/report.md", data) + be.ls = lambda path="": LsResult( + entries=[FileInfo(path="/workspace/sv-grep/uploads/report.md", is_dir=False, size=len(data))] + ) + return be + + +def _lines(res) -> list[int]: + assert res.error is None + return [m["line"] for m in res.matches] + + +# -------------------------------------------------------------------------- +# _grep_terms +# -------------------------------------------------------------------------- + + +def test_terms_plain_pattern_is_one_literal(): + assert _grep_terms("营业收入") == ("营业收入",) + + +def test_terms_split_on_pipe_keep_whole_pattern_first(): + assert _grep_terms("主要业务|主要从事|核电运营") == ( + "主要业务|主要从事|核电运营", + "主要业务", + "主要从事", + "核电运营", + ) + + +def test_terms_strip_spaces_and_dedupe(): + assert _grep_terms("A | B|A") == ("A | B|A", "A", "B") + + +def test_terms_blank_segment_means_literal_pipe(): + assert _grep_terms("| 操作id |") == ("| 操作id |",) + assert _grep_terms("a || b") == ("a || b",) + assert _grep_terms("A|") == ("A|",) + assert _grep_terms("A| |B") == ("A| |B",) + + +# -------------------------------------------------------------------------- +# grep / agrep +# -------------------------------------------------------------------------- + + +def test_grep_multiple_keywords_any_hit(tmp_path): + be = _backend(tmp_path) + # The exact pattern from the 106 session: no line holds it verbatim, but two + # of its keywords are present. + assert _lines(be.grep("主要业务|主要从事|核电运营")) == [1] + assert _lines(be.grep("营业总收入|售电收入|核电业务")) == [2] + assert _lines(be.grep("核电运营|售电收入")) == [1, 2] + + +def test_grep_no_keyword_present_still_no_match(tmp_path): + be = _backend(tmp_path) + assert _lines(be.grep("无受限资产|无对外担保")) == [] + + +def test_grep_literal_pipe_whole_pattern(tmp_path): + be = _backend(tmp_path) + # Blank segments: the pipe is part of the text being searched. + assert _lines(be.grep("a || b")) == [4] + + +def test_grep_markdown_table_row(tmp_path): + be = _backend(tmp_path) + assert _lines(be.grep("| 操作id |")) == [3] + # Without blank segments the whole row still matches literally, and the + # keyword split adds nothing beyond lines that hold one of the cells. + assert _lines(be.grep("操作id | 名称")) == [3] + + +def test_grep_blank_segment_does_not_match_everything(tmp_path): + be = _backend(tmp_path) + # Splitting "核电|" would yield "" — a term that is in every line. + assert _lines(be.grep("核电|")) == [] + + +def test_grep_other_regex_chars_stay_literal(tmp_path): + be = _backend(tmp_path) + assert _lines(be.grep("price (USD) 3.5")) == [5] + assert _lines(be.grep("营业.*收入")) == [] + + +async def test_agrep_same_rule(tmp_path): + be = _backend(tmp_path) + assert _lines(await be.agrep("主要业务|主要从事|核电运营")) == [1] + assert _lines(await be.agrep("| 操作id |")) == [3] diff --git a/src/backend/test/linsight/test_workspace_original_sync.py b/src/backend/test/linsight/test_workspace_original_sync.py index 6bc89d75de..121ed7dc8f 100644 --- a/src/backend/test/linsight/test_workspace_original_sync.py +++ b/src/backend/test/linsight/test_workspace_original_sync.py @@ -1,12 +1,11 @@ """Cross-turn originals must reach the LOCAL task dir, not just the workspace. -``_init_file_directory`` prefetches originals from THIS turn's ``files``, which is -empty on a follow-up ("把刚才那个表再算一下") — there, the original only exists in -the workspace, put there by ``seed_workspace_from_previous``. But the code -interpreter's file list is built from ``os.walk(file_dir)`` / ``local_sync_path``: -it never talks to MinIO. Without the sync step the model sees ``uploads/x.xlsx`` -in ``ls``, is told by the pointer block it can compute on it, and then finds -nothing in the sandbox — the whole point of carrying the original is lost. +``_init_file_directory`` prefetches originals from THIS turn's ``files``. A later +round of the same session deletes that local directory. ``output/``, ``uploads/`` +and ``large_tool_results/`` remain in the workspace. The code interpreter's file +list is built from ``os.walk(file_dir)`` / ``local_sync_path``: it never talks to +MinIO. Without the sync step ``open('output/金额表.xlsx')`` and +``open('uploads/x.xlsx')`` find nothing. Second invariant: the sync lands files AFTER ``_init_file_directory`` took the deliverable baseline, so they must be added to it. Otherwise the user's own upload @@ -88,6 +87,42 @@ async def test_sync_is_idempotent_for_already_prefetched_files(tmp_path): assert task._baseline_files == {} +async def test_output_synced_from_workspace_and_baselined(tmp_path): + """A later round must open output/ files the previous round left in the workspace.""" + objects = { + f"workspace/{SVID}/output/金额表.xlsx": b"PK\x03\x04sheet", + } + task = _task(str(tmp_path)) + + with patch( + "bisheng.linsight.domain.task_exec.get_minio_storage", + AsyncMock(return_value=_minio(objects)), + ): + await task._sync_workspace_originals(SimpleNamespace(id=SVID)) + + local = tmp_path / "output" / "金额表.xlsx" + assert local.read_bytes() == b"PK\x03\x04sheet" + assert str(local) in {os.path.normpath(p) for p in task._baseline_files} + + +async def test_large_tool_results_synced_from_workspace_and_baselined(tmp_path): + """Offloaded tool dumps must reach cwd so open('large_tool_results/') works.""" + objects = { + f"workspace/{SVID}/large_tool_results/call_a1da40c8e46349058bc993": b'{"ok":true}', + } + task = _task(str(tmp_path)) + + with patch( + "bisheng.linsight.domain.task_exec.get_minio_storage", + AsyncMock(return_value=_minio(objects)), + ): + await task._sync_workspace_originals(SimpleNamespace(id=SVID)) + + local = tmp_path / "large_tool_results" / "call_a1da40c8e46349058bc993" + assert local.read_bytes() == b'{"ok":true}' + assert str(local) in {os.path.normpath(p) for p in task._baseline_files} + + async def test_sync_failure_never_blocks_the_turn(tmp_path): """Best-effort: losing the precise-data track must not fail the task.""" task = _task(str(tmp_path)) @@ -99,3 +134,13 @@ async def test_sync_failure_never_blocks_the_turn(tmp_path): await task._sync_workspace_originals(SimpleNamespace(id=SVID)) # must not raise assert task._baseline_files == {} + + +async def test_previous_deliverable_reaches_executor_and_baseline(tmp_path): + objects = {f"workspace/{SVID}/output/report.xlsx": b"PK\x03\x04previous-result"} + task = _task(str(tmp_path)) + with patch("bisheng.linsight.domain.task_exec.get_minio_storage", AsyncMock(return_value=_minio(objects))): + await task._sync_workspace_originals(SimpleNamespace(id=SVID)) + local = tmp_path / "output" / "report.xlsx" + assert local.read_bytes() == objects[f"workspace/{SVID}/output/report.xlsx"] + assert str(local) in {os.path.normpath(p) for p in task._baseline_files} diff --git a/src/backend/test/linsight/test_workspace_seed_continuity.py b/src/backend/test/linsight/test_workspace_seed_continuity.py index 03a25e709d..aabdcd8b45 100644 --- a/src/backend/test/linsight/test_workspace_seed_continuity.py +++ b/src/backend/test/linsight/test_workspace_seed_continuity.py @@ -2,8 +2,9 @@ A follow-up turn runs under a fresh session_version_id with an empty workspace, so it can't see the prior turn's deliverables. ``seed_workspace_from_previous`` -server-side copies the previous version's ``output/`` + ``uploads/`` into the -new turn's prefix so read_file/ls transparently surface them. +server-side copies the previous version's ``output/`` + ``uploads/`` + +``large_tool_results/`` into the new turn's prefix so read_file/ls (and the +code interpreter after the local sync) transparently surface them. """ from unittest.mock import MagicMock @@ -39,11 +40,12 @@ def _list(bucket, prefix="", recursive=True): return minio -async def test_seed_copies_output_and_uploads_skips_scratch(): +async def test_seed_copies_output_uploads_and_large_tool_results_skips_scratch(): minio = _minio( { f"workspace/{SRC}/output/": [f"workspace/{SRC}/output/report.md"], f"workspace/{SRC}/uploads/": [f"workspace/{SRC}/uploads/doc/index.md"], + f"workspace/{SRC}/large_tool_results/": [f"workspace/{SRC}/large_tool_results/call_abc"], # scratch present but must NOT be queried/copied f"workspace/{SRC}/scratch/": [f"workspace/{SRC}/scratch/notes.txt"], } @@ -59,12 +61,17 @@ def _copy(source_bucket, source_object, dest_bucket, dest_object): n = await seed_workspace_from_previous(minio, src_svid=SRC, dst_svid=DST) - assert n == 2 + assert n == 3 assert (f"workspace/{SRC}/output/report.md", f"workspace/{DST}/output/report.md") in copied assert (f"workspace/{SRC}/uploads/doc/index.md", f"workspace/{DST}/uploads/doc/index.md") in copied - # scratch/ was never even listed (continuity carries deliverables + sources only) + assert ( + f"workspace/{SRC}/large_tool_results/call_abc", + f"workspace/{DST}/large_tool_results/call_abc", + ) in copied + # scratch/ was never even listed (continuity skips intermediates) queried_prefixes = [c.kwargs.get("prefix") for c in minio.minio_client_sync.list_objects.call_args_list] assert all("scratch" not in (p or "") for p in queried_prefixes) + assert any("large_tool_results" in (p or "") for p in queried_prefixes) async def test_seed_noop_when_destination_not_empty(): diff --git a/src/backend/test/linsight/test_workspace_skill_path_hint.py b/src/backend/test/linsight/test_workspace_skill_path_hint.py new file mode 100644 index 0000000000..76cf978456 --- /dev/null +++ b/src/backend/test/linsight/test_workspace_skill_path_hint.py @@ -0,0 +1,78 @@ +"""A read that misses names the skill folder holding the file. + +Incident 2026-10-23 (106, svid 4c85fb69…): SKILL.md listed ``references/…`` files, +the model read ``/references/…``, got a bare "not found" three times, and redid a +30k-character analysis after each miss. The hint is text only: the read never +switches to another path. + +``asyncio_mode = auto`` — async tests need no decorator. +""" + +from __future__ import annotations + +import tempfile + +import pytest +from test_workspace_backend import FakeMinioStorage + +from bisheng.linsight.domain.services.tool_loop_middleware import _corrective_hint +from bisheng.linsight.domain.services.workspace_backend import WorkspaceBackend + +SVID = "4c85fb6948a44258a9cc8fa7f336fbb4" +MANIFEST = "references/power_phase1_llm_operation_manifest.json" + + +@pytest.fixture() +def backend(): + with tempfile.TemporaryDirectory() as workdir: + ws = WorkspaceBackend(svid=SVID, minio=FakeMinioStorage(), file_dir=workdir) + ws.upload_files( + [ + (f"/skills/credit-report-llm-refresh/{MANIFEST}", b"{}"), + ("/skills/credit-report-llm-refresh/SKILL.md", b"---\nname: x\n---\n"), + ] + ) + yield ws + + +def test_root_relative_read_names_the_skill_path(backend): + error = backend.read(f"/{MANIFEST}").error + + assert f"File '/{MANIFEST}' not found." in error + assert f"'/skills/credit-report-llm-refresh/{MANIFEST}'" in error + + +async def test_async_read_names_the_skill_path_too(backend): + error = (await backend.aread(MANIFEST)).error + + assert f"'/skills/credit-report-llm-refresh/{MANIFEST}'" in error + + +def test_every_skill_holding_the_file_is_listed(backend): + backend.upload_files([("/skills/another-skill/SKILL.md", b"---\nname: y\n---\n")]) + error = backend.read("/SKILL.md").error + + assert "'/skills/another-skill/SKILL.md', '/skills/credit-report-llm-refresh/SKILL.md'" in error + + +def test_a_file_no_skill_has_keeps_the_plain_message(backend): + assert backend.read("/references/absent.md").error == "File '/references/absent.md' not found" + + +def test_a_miss_inside_skills_is_not_redirected(backend): + error = backend.read("/skills/credit-report-llm-refresh/references/absent.md").error + + assert error == "File '/skills/credit-report-llm-refresh/references/absent.md' not found" + + +def test_edit_does_not_point_at_the_read_only_skill_folder(backend): + error = backend.edit(f"/{MANIFEST}", "a", "b").error + + assert "/skills/" not in error + + +def test_repeated_failure_hint_names_the_path_cause(): + hint = _corrective_hint("read_file") + + assert "核对路径" in hint + assert "必填项" in hint diff --git a/src/backend/test/llm/test_asr_cloud_providers.py b/src/backend/test/llm/test_asr_cloud_providers.py new file mode 100644 index 0000000000..ce8fa638c3 --- /dev/null +++ b/src/backend/test/llm/test_asr_cloud_providers.py @@ -0,0 +1,617 @@ +"""Offline protocol fixtures for native cloud speech services; no paid calls.""" + +import asyncio +import base64 +import gzip +import hashlib +import hmac +import json +import struct +from types import SimpleNamespace +from unittest.mock import AsyncMock +from urllib.parse import parse_qsl, urlsplit + +import pytest +from fastapi import HTTPException +from websockets.exceptions import InvalidStatus + +from bisheng.common.constants.enums.telemetry import ApplicationTypeEnum +from bisheng.core.ai.asr import realtime, speech_cloud, volcengine +from bisheng.core.ai.asr.capabilities import realtime_protocol, should_promote_legacy_asr +from bisheng.llm.domain.llm.asr import BishengASR, is_asr_provider_supported +from bisheng.utils.mask_data import JsonFieldMasker + +VOLC_CONFIG = {"speech_api_key": "fixture-api", "speech_resource_id": "volc.seedasr.sauc.duration"} +BAIDU_CONFIG = {"speech_app_id": "123", "speech_api_key": "fixture-api"} +TENCENT_CONFIG = {"speech_app_id": "123", "speech_secret_id": "fixture-id", "speech_secret_key": "fixture-secret"} + + +def volc_result(text, final=False, compression=1, sequence=True): + body = json.dumps({"result": {"text": text}}).encode() + if compression: + body = gzip.compress(body) + flags = int(sequence) | (2 if final else 0) + header = bytes((0x11, 0x90 | flags, 0x10 | compression, 0)) + seq = struct.pack(">i", -3 if final else 2) if sequence else b"" + return header + seq + struct.pack(">I", len(body)) + body + + +class Socket: + def __init__(self, provider, finish_response=True): + self.provider = provider + self.finish_response = finish_response + self.incoming = asyncio.Queue() + self.sent = [] + self.closed = False + self.close_code = 1000 + if provider == "tencent": + self.put({"code": 0, "voice_id": "fixture"}) + + def put(self, value): + self.incoming.put_nowait(json.dumps(value) if isinstance(value, dict) else value) + + def result(self, text, final=False): + if self.provider == "volcengine": + self.put(volc_result(text, final)) + elif self.provider == "baidu": + self.put({"type": "FIN_TEXT" if final else "MID_TEXT", "err_no": 0, "sn": "session_ws_1", "result": text}) + else: + self.put({"code": 0, "result": {"index": 0, "slice_type": 2 if final else 1, "voice_text_str": text}}) + + async def send(self, payload): + self.sent.append(payload) + message = json.loads(payload) if isinstance(payload, str) else {} + is_audio = isinstance(payload, bytes) + if self.provider == "volcengine": + is_audio = payload[1] >> 4 == 2 + finished = bool(payload[1] & 2) + else: + finished = message.get("type") in ("FINISH", "end") + if finished and self.finish_response: + self.result("Final.", True) + if self.provider == "baidu": + self.result("Duplicate.", True) + self.result("Stale.") + self.put(None) + elif self.provider == "tencent": + self.result("Duplicate.", True) + self.result("Stale.") + self.put({"code": 0, "final": 1}) + elif is_audio and not finished: + self.result("Draft") + + async def recv(self): + return await self.incoming.get() + + def __aiter__(self): + return self + + async def __anext__(self): + item = await self.recv() + if item is None: + raise StopAsyncIteration + return item + + async def close(self): + self.closed = True + + +def client_for(provider): + return { + "volcengine": lambda: volcengine.VolcengineRealtimeASRClient("bigmodel", VOLC_CONFIG), + "baidu": lambda: speech_cloud.BaiduRealtimeASRClient("15376", BAIDU_CONFIG), + "tencent": lambda: speech_cloud.TencentRealtimeASRClient("16k_zh", TENCENT_CONFIG), + }[provider]() + + +def connect_socket(monkeypatch, provider, socket): + connector = AsyncMock(return_value=socket) + monkeypatch.setattr(volcengine if provider == "volcengine" else speech_cloud, "connect", connector) + return connector + + +@pytest.mark.parametrize("provider", ["volcengine", "baidu", "tencent"]) +async def test_ready_audio_final_finish_and_cleanup(monkeypatch, provider): + socket = Socket(provider) + connector = connect_socket(monkeypatch, provider, socket) + ready, event = AsyncMock(), AsyncMock() + + async def audio(): + ready.assert_awaited_once() + yield b"\x01\x00" * 1600 + await asyncio.sleep(0) + + await client_for(provider).stream(audio(), ready, event) + events = [call.args[0] for call in event.await_args_list] + assert [event["text"] for event in events if event["type"] == "final"] == ["Final."] + assert events[0]["type"] == "partial" + assert events[-1]["version"] == 2 + assert socket.closed + if provider == "baidu": + start = json.loads(socket.sent[0])["data"] + assert start["dev_pid"] == 15376 and start["user"] and start["appid"] == 123 + assert start["appkey"] == "fixture-api" + assert socket.sent[-1] == '{"type": "FINISH"}' + elif provider == "volcengine": + header = connector.await_args.kwargs["additional_headers"] + assert header["X-Api-Key"] == "fixture-api" + assert header["X-Api-Resource-Id"] == "volc.seedasr.sauc.duration" + assert socket.sent[0][:4] == b"\x11\x10\x11\x00" + request = json.loads(gzip.decompress(socket.sent[0][8:])) + assert request["request"]["result_type"] == "full" + assert request["audio"] == {"format": "pcm", "codec": "raw", "rate": 16000, "bits": 16, "channel": 1} + assert socket.sent[-1][1] == 0x22 + else: + assert json.loads(socket.sent[-1]) == {"type": "end"} + + +@pytest.mark.parametrize("provider", ["volcengine", "baidu", "tencent"]) +async def test_finish_timeout_closes_all_provider_connections(monkeypatch, provider): + socket = Socket(provider, finish_response=False) + connect_socket(monkeypatch, provider, socket) + monkeypatch.setattr(realtime, "FINISH_TIMEOUT", 0.01) + + async def audio(): + yield bytes(3200) + + with pytest.raises(TimeoutError): + await client_for(provider).stream(audio(), AsyncMock(), AsyncMock()) + assert socket.closed + + +@pytest.mark.parametrize("provider", ["volcengine", "baidu", "tencent"]) +async def test_disconnect_cancels_receiver(monkeypatch, provider): + socket = Socket(provider) + connect_socket(monkeypatch, provider, socket) + + async def audio(): + yield bytes(3200) + raise ConnectionError("browser disconnected") + + with pytest.raises(ConnectionError): + await client_for(provider).stream(audio(), AsyncMock(), AsyncMock()) + assert socket.closed + + +@pytest.mark.parametrize("provider", ["volcengine", "baidu", "tencent"]) +async def test_early_normal_close_is_not_completion(monkeypatch, provider): + socket = Socket(provider) + connect_socket(monkeypatch, provider, socket) + connection = client_for(provider).create_connection() + await connection.start() + socket.put(None) + with pytest.raises(RuntimeError, match="before recognition completed"): + _ = [event async for event in connection.events()] + + +@pytest.mark.parametrize( + "compression,sequence,final", [(0, False, False), (1, True, False), (0, True, True), (1, False, True)] +) +def test_volcengine_server_frames(compression, sequence, final): + message, last = volcengine.decode_packet(volc_result("one two", final, compression, sequence)) + assert last is final and message["result"]["text"] == "one two" + + +def test_volcengine_rejects_corrupt_oversized_and_error_frames(): + valid = volc_result("test") + for raw in (b"bad", valid[:-1], valid + b"extra"): + with pytest.raises(ValueError): + volcengine.decode_packet(raw) + body = gzip.compress(b"x" * (volcengine.MAX_PAYLOAD + 1)) + with pytest.raises(ValueError, match="compressed"): + volcengine.decode_packet(b"\x11\x90\x11\x00" + struct.pack(">I", len(body)) + body) + with pytest.raises(RuntimeError, match="45000001"): + volcengine.decode_packet(b"\x11\xf0\x10\x00" + struct.pack(">II", 45000001, 0)) + + +async def test_volcengine_old_console_authentication(monkeypatch): + socket = Socket("volcengine") + connector = connect_socket(monkeypatch, "volcengine", socket) + config = { + "speech_auth_mode": "app_key", + "speech_app_id": "123", + "speech_access_key": "fixture-token", + "speech_resource_id": "volc.bigasr.sauc.duration", + } + await volcengine.VolcengineRealtimeASRClient("bigmodel", config).create_connection().start() + headers = connector.await_args.kwargs["additional_headers"] + assert headers["X-Api-App-Key"] == "123" and headers["X-Api-Access-Key"] == "fixture-token" + assert "X-Api-Key" not in headers + + +async def test_volcengine_access_check_is_only_a_handshake(monkeypatch): + socket = Socket("volcengine") + connector = connect_socket(monkeypatch, "volcengine", socket) + await volcengine.VolcengineRealtimeASRClient("bigmodel", VOLC_CONFIG).verify_access() + assert socket.closed and socket.sent == [] + headers = connector.await_args.kwargs["additional_headers"] + assert headers["X-Api-Resource-Id"] == "volc.seedasr.sauc.duration" + assert headers["X-Api-Key"] == "fixture-api" + + +async def test_volcengine_forbidden_resource_fails_the_handshake(monkeypatch): + async def reject(*_args, **_kwargs): + raise InvalidStatus(SimpleNamespace(status_code=403)) + + monkeypatch.setattr(volcengine, "connect", reject) + with pytest.raises(volcengine.SpeechAccessRejected): + await volcengine.VolcengineRealtimeASRClient("bigmodel", VOLC_CONFIG).verify_access() + + +async def test_save_rejects_a_resource_the_speech_credentials_cannot_use(monkeypatch): + from bisheng.llm.domain.services.llm import LLMService + + async def reject(self): + raise volcengine.SpeechAccessRejected("rejected") + + monkeypatch.setattr(volcengine.VolcengineRealtimeASRClient, "verify_access", reject) + speech = SimpleNamespace(model_type="asr_rt", model_name="bigmodel") + with pytest.raises(HTTPException) as caught: + await LLMService._ensure_volcengine_speech("volcengine", VOLC_CONFIG, [speech]) + assert caught.value.status_code == 10804 + + file_typed = SimpleNamespace(model_type="asr", model_name="bigmodel") + await LLMService._ensure_volcengine_speech("volcengine", VOLC_CONFIG, [file_typed]) + chat = SimpleNamespace(model_type="llm", model_name="doubao") + await LLMService._ensure_volcengine_speech("volcengine", VOLC_CONFIG, [chat]) + + +def test_tencent_signature_uses_sorted_raw_parameters_then_urlencoding(): + url = speech_cloud.tencent_url("123", "id+/=", "secret", "16k_zh", "unique-id", 1700000000, 1234) + expected_text = "asr.cloud.tencent.com/asr/v2/123?engine_model_type=16k_zh&expired=1700003600&needvad=1&nonce=1234&secretid=id+/=×tamp=1700000000&voice_format=1&voice_id=unique-id" + expected = base64.b64encode(hmac.new(b"secret", expected_text.encode(), hashlib.sha1).digest()).decode() + query = dict(parse_qsl(urlsplit(url).query)) + assert query["signature"] == expected + assert query["secretid"] == "id+/=" + assert "secretid=id%2B%2F%3D" in url + + +@pytest.mark.parametrize( + "provider,model,config,cls", + [ + ("volcengine", "bigmodel", VOLC_CONFIG, volcengine.VolcengineRealtimeASRClient), + ("qianfan", "15372", BAIDU_CONFIG, speech_cloud.BaiduRealtimeASRClient), + ("tencent", "16k_zh_en", TENCENT_CONFIG, speech_cloud.TencentRealtimeASRClient), + ], +) +async def test_factory_and_capability_match(provider, model, config, cls): + assert realtime_protocol(provider, model) + assert is_asr_provider_supported(provider) + server = SimpleNamespace(type=provider, config=config) + instance = await BishengASR.init_asr_client( + SimpleNamespace(model_name=model, model_type="asr_rt", config=None), server + ) + assert isinstance(instance, cls) and isinstance(instance, realtime.RealtimeASRClient) + unknown = SimpleNamespace(model_name="unsupported", model_type="asr_rt", config=None) + if provider == "volcengine": + with pytest.raises(ValueError): + await BishengASR.init_asr_client(unknown, server) + else: + built = await BishengASR.init_asr_client(unknown, server) + assert built.model == "unsupported" + legacy = await BishengASR.init_asr_client(SimpleNamespace(model_name=model, model_type="asr", config=None), server) + assert isinstance(legacy, cls) + with pytest.raises(ValueError, match="no file ASR client"): + await BishengASR.init_asr_client( + SimpleNamespace(model_name="unsupported", model_type="asr", config=None), server + ) + assert realtime_protocol(provider, "unsupported") is None + + +@pytest.mark.parametrize( + "cls,model", + [ + (volcengine.VolcengineRealtimeASRClient, "bigmodel"), + (speech_cloud.BaiduRealtimeASRClient, "15372"), + (speech_cloud.TencentRealtimeASRClient, "16k_zh"), + ], +) +def test_llm_credentials_do_not_replace_speech_credentials(cls, model): + with pytest.raises(ValueError, match="speech_"): + cls(model, {"api_key": "llm-only"}) + + +def test_speech_secrets_mask_and_round_trip(): + masker = JsonFieldMasker() + original = { + "config": { + "speech_api_key": "original-api", + "speech_access_key": "original-access", + "speech_secret_key": "original-secret", + "speech_app_id": "123", + } + } + masked = masker.mask_json(original) + assert masked["config"] == { + "speech_api_key": "********", + "speech_access_key": "********", + "speech_secret_key": "********", + "speech_app_id": "123", + } + assert masker.update_json_with_masked(original, masked) == original + masked["config"]["speech_api_key"] = "replacement" + assert masker.update_json_with_masked(original, masked)["config"]["speech_api_key"] == "replacement" + + +async def test_baidu_close_without_status_keeps_the_partial(monkeypatch): + socket = Socket("baidu", finish_response=False) + connect_socket(monkeypatch, "baidu", socket) + connection = client_for("baidu").create_connection() + await connection.start() + connection.finished_input = True + socket.close_code = 1005 + socket.put({"type": "MID_TEXT", "err_no": 0, "sn": "s1", "result": "你好"}) + socket.put(None) + events = [event async for event in connection.events()] + assert [(event["type"], event["text"]) for event in events] == [("partial", "你好"), ("final", "你好")] + + +async def test_baidu_abnormal_close_is_still_rejected(monkeypatch): + socket = Socket("baidu", finish_response=False) + connect_socket(monkeypatch, "baidu", socket) + connection = client_for("baidu").create_connection() + await connection.start() + connection.finished_input = True + socket.close_code = 1006 + socket.put(None) + with pytest.raises(RuntimeError, match=r"closed before recognition completed \(1006\)"): + _ = [event async for event in connection.events()] + + +async def test_baidu_paces_browser_backlog_before_finish(monkeypatch): + socket = Socket("baidu") + connect_socket(monkeypatch, "baidu", socket) + clock = [100.0] + sleeps = [] + monkeypatch.setattr(speech_cloud.time, "monotonic", lambda: clock[0]) + + async def advance(delay): + sleeps.append(delay) + clock[0] += delay + + monkeypatch.setattr(speech_cloud.asyncio, "sleep", advance) + connection = client_for("baidu").create_connection() + await connection.start() + await connection.send_audio(bytes(16000)) + await connection.finish() + assert [len(packet) for packet in socket.sent if isinstance(packet, bytes)] == [6400, 6400, 3200] + assert sleeps == pytest.approx([0, 0.2, 0.2, 0.1]) + assert socket.sent[-1] == '{"type": "FINISH"}' + + +async def test_tencent_paces_browser_backlog_and_limits_packet_size(monkeypatch): + socket = Socket("tencent") + connect_socket(monkeypatch, "tencent", socket) + clock = [100.0] + sleeps = [] + monkeypatch.setattr(speech_cloud.time, "monotonic", lambda: clock[0]) + + async def advance(delay): + sleeps.append(delay) + clock[0] += delay + + monkeypatch.setattr(speech_cloud.asyncio, "sleep", advance) + connection = client_for("tencent").create_connection() + await connection.start() + await connection.send_audio(bytes(16000)) + assert [len(packet) for packet in socket.sent] == [6400, 6400, 3200] + assert sleeps == pytest.approx([0, 0.2, 0.2]) + + +@pytest.mark.parametrize("provider,error", [("baidu", {"err_no": -3004}), ("tencent", {"code": 4004})]) +async def test_provider_error_is_propagated_without_echoing_credentials(monkeypatch, provider, error): + socket = Socket(provider) + connect_socket(monkeypatch, provider, socket) + connection = client_for(provider).create_connection() + await connection.start() + socket.put({**error, "message": "sensitive provider diagnostics", "err_msg": "sensitive provider diagnostics"}) + with pytest.raises(RuntimeError, match="ASR error") as failure: + _ = [event async for event in connection.events()] + assert "sensitive" not in str(failure.value) + + +@pytest.mark.parametrize( + "provider,model,override,config,cls", + [ + ("baidu", "15372", "1537", BAIDU_CONFIG, speech_cloud.BaiduRealtimeASRClient), + ("tencent", "16k_zh", "16k_en", TENCENT_CONFIG, speech_cloud.TencentRealtimeASRClient), + ], +) +async def test_cloud_file_model_override_preserves_credentials( + monkeypatch, tmp_path, provider, model, override, config, cls +): + import wave + + socket = Socket(provider) + connect_socket(monkeypatch, provider, socket) + client = cls(model, config) + path = tmp_path / "probe.wav" + with wave.open(str(path), "wb") as wav: + wav.setparams((1, 2, 16000, 0, "NONE", "NONE")) + transcript = await client.transcribe_file(str(path), model=override) + assert transcript.text == "Final." + assert client.model == model + assert socket.closed + + +def test_legacy_promotion_follows_server_type(): + assert should_promote_legacy_asr("tencent", "16k_zh", "asr") + assert should_promote_legacy_asr("qianfan", "1537", "asr") + assert should_promote_legacy_asr("volcengine", "bigmodel", "asr") + assert should_promote_legacy_asr("qwen", "paraformer-realtime-v2", "asr") + assert not should_promote_legacy_asr("openai", "16k_zh", "asr") + assert not should_promote_legacy_asr("tencent", "new-engine", "asr") + assert not should_promote_legacy_asr("tencent", "16k_zh", "asr_rt") + + +def test_tencent_unknown_engine_is_the_url_parameter(): + client = speech_cloud.TencentRealtimeASRClient("new-engine", TENCENT_CONFIG) + query = dict( + parse_qsl( + urlsplit( + speech_cloud.tencent_url( + client.app_id, client.secret_id, client.secret_key, client.model, "voice", 1, 2 + ) + ).query + ) + ) + assert query["engine_model_type"] == "new-engine" + with pytest.raises(ValueError): + speech_cloud.TencentRealtimeASRClient(" ", TENCENT_CONFIG) + + +async def test_baidu_non_numeric_engine_fails_when_the_session_starts(monkeypatch): + monkeypatch.setattr(speech_cloud, "connect", AsyncMock()) + client = speech_cloud.BaiduRealtimeASRClient("not-a-pid", BAIDU_CONFIG) + with pytest.raises(ValueError): + await client.create_connection().start() + with pytest.raises(ValueError): + speech_cloud.BaiduRealtimeASRClient("", BAIDU_CONFIG) + + +async def test_get_bisheng_asr_builds_an_unlisted_tencent_engine(monkeypatch): + model = SimpleNamespace(id=21, model_name="new-engine", model_type="asr_rt", online=True, config=None) + server = SimpleNamespace(type="tencent", name="Tencent", config=TENCENT_CONFIG) + + async def lookup(_model_id): + return model, server + + monkeypatch.setattr(BishengASR, "get_model_server_info", lookup) + instance = await BishengASR.get_bisheng_asr( + model_id=21, + ignore_online=True, + app_id=ApplicationTypeEnum.MODEL_TEST.value, + app_name=ApplicationTypeEnum.MODEL_TEST.value, + app_type=ApplicationTypeEnum.MODEL_TEST, + user_id=1, + ) + assert instance.asr.model == "new-engine" + + +async def test_probe_invokes_realtime_asr(monkeypatch): + from bisheng.llm.domain.services.llm import LLMService + + fake = AsyncMock() + fake.ainvoke = AsyncMock(return_value="hello") + getter = AsyncMock(return_value=fake) + monkeypatch.setattr("bisheng.llm.domain.services.llm.BishengASR.get_bisheng_asr", getter) + await LLMService._invoke_model_probe(SimpleNamespace(id=21, model_type="asr_rt", model_name="new-engine"), {}) + getter.assert_awaited() + fake.ainvoke.assert_awaited() + + +async def test_empty_workbench_slot_accepts_realtime_asr(monkeypatch): + from bisheng.llm.domain.schemas import WorkbenchModelConfig + from bisheng.llm.domain.services.llm import LLMService + + saved = {} + + async def get_workbench(): + return WorkbenchModelConfig() + + async def update(_user, workbench, _tasks): + saved["id"] = workbench.asr_model.id + + monkeypatch.setattr(LLMService, "get_workbench_llm", get_workbench) + monkeypatch.setattr(LLMService, "update_workbench_llm", update) + await LLMService.set_default_model(SimpleNamespace(id=9, model_type="asr_rt", model_name="16k_zh")) + assert saved["id"] == "9" + + +async def test_file_typed_tencent_model_is_not_kept(monkeypatch): + from bisheng.llm.domain.models.llm_server import LLMDao + from bisheng.llm.domain.schemas import LLMModelCreateReq, LLMServerCreateReq + from bisheng.llm.domain.services.llm import LLMService + + deleted = {} + + async def no_server(_name): + return None + + async def insert(server, models, **_kwargs): + server.id = 7 + return server + + async def listed(_server_id, operator=None): + return SimpleNamespace( + id=7, + tenant_id=1, + models=[SimpleNamespace(id=21, model_name="16k_zh", model_type="asr")], + ) + + async def delete(server_id): + deleted["id"] = server_id + + async def lookup(_model_id): + return ( + SimpleNamespace(id=21, model_name="16k_zh", model_type="asr", online=True, config=None), + SimpleNamespace(type="tencent", name="Tencent", config=TENCENT_CONFIG), + ) + + monkeypatch.setattr(LLMDao, "aget_server_by_name", no_server) + monkeypatch.setattr(LLMDao, "ainsert_server_with_models", insert) + monkeypatch.setattr(LLMDao, "adelete_server_by_id", delete) + monkeypatch.setattr(LLMService, "get_one_llm", listed) + monkeypatch.setattr(BishengASR, "get_model_server_info", lookup) + server = LLMServerCreateReq( + name="tencent-speech", + type="tencent", + config=TENCENT_CONFIG, + models=[LLMModelCreateReq(name="Chinese", model_name="16k_zh", model_type="asr")], + ) + with pytest.raises(HTTPException): + await LLMService.add_llm_server(SimpleNamespace(), SimpleNamespace(user_id=1), server) + assert deleted["id"] == 7 + + +async def test_add_checks_speech_by_calling_the_provider(monkeypatch): + from bisheng.llm.domain.models.llm_server import LLMDao + from bisheng.llm.domain.schemas import LLMModelCreateReq, LLMServerCreateReq + from bisheng.llm.domain.services.llm import LLMService + + deleted = {} + called = {} + + async def no_server(_name): + return None + + async def insert(server, models, **_kwargs): + server.id = 8 + return server + + async def listed(_server_id, operator=None): + return SimpleNamespace( + id=8, + tenant_id=1, + models=[SimpleNamespace(id=22, model_name="new-engine", model_type="asr_rt")], + ) + + async def delete(server_id): + deleted["id"] = server_id + + async def lookup(_model_id): + return ( + SimpleNamespace(id=22, model_name="new-engine", model_type="asr_rt", online=True, config=None), + SimpleNamespace(type="tencent", name="Tencent", config=TENCENT_CONFIG), + ) + + async def reject(self, _audio, **_kwargs): + called["provider"] = True + raise RuntimeError("vendor rejected") + + monkeypatch.setattr(LLMDao, "aget_server_by_name", no_server) + monkeypatch.setattr(LLMDao, "ainsert_server_with_models", insert) + monkeypatch.setattr(LLMDao, "adelete_server_by_id", delete) + monkeypatch.setattr(LLMService, "get_one_llm", listed) + monkeypatch.setattr(BishengASR, "get_model_server_info", lookup) + monkeypatch.setattr(BishengASR, "ainvoke", reject) + server = LLMServerCreateReq( + name="tencent-speech-rt", + type="tencent", + config=TENCENT_CONFIG, + models=[LLMModelCreateReq(name="New", model_name="new-engine", model_type="asr_rt")], + ) + with pytest.raises(HTTPException): + await LLMService.add_llm_server(SimpleNamespace(), SimpleNamespace(user_id=1), server) + assert called["provider"] is True + assert deleted["id"] == 8 diff --git a/src/backend/test/llm/test_bisheng_llm_context_overflow.py b/src/backend/test/llm/test_bisheng_llm_context_overflow.py new file mode 100644 index 0000000000..0d312f3cba --- /dev/null +++ b/src/backend/test/llm/test_bisheng_llm_context_overflow.py @@ -0,0 +1,244 @@ +"""F087: BishengLLM turns a provider's context-window rejection into ContextOverflowError. + +deepagents compresses history and retries only on ``ContextOverflowError``. The +converted error must stay an instance of the original SDK error with its body, and +it must not mark the model as broken in model management (design.md decisions 5, 6). +""" + +from types import SimpleNamespace + +import anthropic +import httpx +import openai +import pytest +from langchain_core.exceptions import ContextOverflowError +from langchain_core.messages import HumanMessage + +from bisheng.llm.domain.const import LLMModelStatus +from bisheng.llm.domain.llm.context_overflow import as_context_overflow, extract_input_tokens +from bisheng.llm.domain.llm.exceptions import LLMContextOverflowError +from bisheng.llm.domain.llm.llm import BishengLLM + +# Real provider wording, one per recognition rule. +VLLM_LEGACY = ( + "This model's maximum context length is 133120 tokens. However, you requested 0 output tokens and " + "your prompt contains at least 133121 input tokens, for a total of at least 133121 tokens. Please " + "reduce the length of the input prompt or the number of requested output tokens. " + "(parameter=input_tokens, value=133121)" +) +VLLM_NEW = "The decoder prompt (length 40000) is longer than the maximum model length of 32768." +DEEPSEEK = ( + "This model's maximum context length is 131072 tokens. However, you requested 140000 tokens " + "(135000 in the messages, 5000 in the completion)." +) +SGLANG = "Input length (40000 tokens) exceeds the maximum allowed length (32768 tokens)." +LLAMACPP = "the request (40000 tokens) exceeds the available context size (32768 tokens), try increasing it" +ANTHROPIC = "prompt is too long: 210000 tokens > 200000 maximum" + + +def _openai_400(message: str) -> openai.BadRequestError: + request = httpx.Request("POST", "http://vllm.local/v1/chat/completions") + body = {"error": {"message": message, "type": "BadRequestError", "code": 400}} + return openai.BadRequestError(message, response=httpx.Response(400, request=request), body=body) + + +def _anthropic_400(message: str) -> anthropic.BadRequestError: + request = httpx.Request("POST", "https://api.anthropic.com/v1/messages") + body = {"type": "error", "error": {"type": "invalid_request_error", "message": message}} + return anthropic.BadRequestError(message, response=httpx.Response(400, request=request), body=body) + + +@pytest.mark.parametrize( + "message,tokens", + [(VLLM_LEGACY, 133121), (VLLM_NEW, 40000), (DEEPSEEK, 135000), (SGLANG, 40000), (LLAMACPP, 40000)], +) +def test_openai_compatible_rejections_become_overflow(message, tokens): + original = _openai_400(message) + overflow = as_context_overflow(original) + + assert isinstance(overflow, ContextOverflowError) + assert isinstance(overflow, openai.BadRequestError) + assert overflow.body == original.body + assert overflow.status_code == 400 + assert overflow.reported_input_tokens == tokens + + +def test_anthropic_rejection_stays_an_anthropic_error(): + overflow = as_context_overflow(_anthropic_400(ANTHROPIC)) + + assert isinstance(overflow, ContextOverflowError) + assert isinstance(overflow, anthropic.BadRequestError) + assert overflow.reported_input_tokens == 210000 + + +def test_other_sdk_errors_get_the_bisheng_class(): + class ZhipuError(Exception): + def __init__(self, message): + super().__init__(message) + self.body = {"error": {"message": message}} + self.status_code = 400 + + overflow = as_context_overflow(ZhipuError(VLLM_LEGACY)) + + assert isinstance(overflow, LLMContextOverflowError) + assert overflow.body == {"error": {"message": VLLM_LEGACY}} + assert overflow.status_code == 400 + + +def test_unrelated_400_is_left_alone(): + assert as_context_overflow(_openai_400("Invalid value for 'temperature'")) is None + + +def test_input_tokens_unknown_when_not_stated(): + assert extract_input_tokens("prompt is too long") is None + + +# --------------------------------------------------------------------------- +# Through the BishengLLM call paths, with the status/telemetry decorators on. +# --------------------------------------------------------------------------- + + +class _FailingInner: + def __init__(self, exc: Exception, streaming: bool): + self.exc = exc + self.streaming = streaming + + def _generate(self, *args, **kwargs): + raise self.exc + + async def _agenerate(self, *args, **kwargs): + raise self.exc + + def _stream(self, *args, **kwargs): + raise self.exc + yield # pragma: no cover + + async def _astream(self, *args, **kwargs): + raise self.exc + yield # pragma: no cover + + +@pytest.fixture +def status_writes(monkeypatch): + writes = [] + + async def _noop_async(*args, **kwargs): + return None + + monkeypatch.setattr("bisheng.llm.domain.utils.bisheng_model_limit_check", _noop_async) + monkeypatch.setattr("bisheng.llm.domain.utils.sync_bisheng_model_limit_check", lambda *a, **k: None) + monkeypatch.setattr("bisheng.llm.domain.utils.upload_telemetry_log", lambda *a, **k: None) + return writes + + +def _wrapper(exc: Exception, streaming: bool, writes: list) -> BishengLLM: + inst = BishengLLM.model_construct(llm=_FailingInner(exc, streaming)) + object.__setattr__(inst, "server_info", SimpleNamespace(type="vllm")) + object.__setattr__(inst, "model_info", SimpleNamespace(model_type="llm")) + object.__setattr__(inst, "model_id", 7) + + async def update_model_status(status, remark=""): + writes.append(status) + + object.__setattr__(inst, "update_model_status", update_model_status) + object.__setattr__(inst, "sync_update_model_status", lambda status, remark="": writes.append(status)) + return inst + + +MESSAGES = [HumanMessage(content="hi")] + + +@pytest.mark.parametrize("streaming", [True, False]) +def test_generate_converts_and_keeps_model_status(status_writes, streaming): + inst = _wrapper(_openai_400(VLLM_LEGACY), streaming, status_writes) + with pytest.raises(ContextOverflowError) as info: + inst._generate(MESSAGES) + + assert isinstance(info.value.__cause__, openai.BadRequestError) + assert status_writes == [] + + +@pytest.mark.parametrize("streaming", [True, False]) +async def test_agenerate_converts_and_keeps_model_status(status_writes, streaming): + inst = _wrapper(_openai_400(VLLM_LEGACY), streaming, status_writes) + with pytest.raises(ContextOverflowError): + await inst._agenerate(MESSAGES) + + assert status_writes == [] + + +def test_stream_converts_and_keeps_model_status(status_writes): + inst = _wrapper(_openai_400(VLLM_LEGACY), True, status_writes) + with pytest.raises(ContextOverflowError): + list(inst._stream(MESSAGES)) + + assert status_writes == [] + + +async def test_astream_converts_and_keeps_model_status(status_writes): + inst = _wrapper(_openai_400(VLLM_LEGACY), True, status_writes) + with pytest.raises(ContextOverflowError): + async for _ in inst._astream(MESSAGES): + pass + + assert status_writes == [] + + +async def test_other_errors_still_mark_the_model(status_writes): + inst = _wrapper(_openai_400("Invalid API key"), True, status_writes) + with pytest.raises(openai.BadRequestError) as info: + async for _ in inst._astream(MESSAGES): + pass + + assert not isinstance(info.value, ContextOverflowError) + assert status_writes == [LLMModelStatus.ERROR.value] + + +def test_context_window_is_not_sent_to_the_provider(): + inst = BishengLLM.model_construct() + server = SimpleNamespace( + id=1, name="vllm", type="vllm", config={"openai_api_base": "http://vllm.local/v1", "openai_api_key": "k"} + ) + model = SimpleNamespace( + id=7, + model_name="qwen3-32b", + model_type="llm", + config={"user_kwargs": '{"context_window": 133120, "top_p": 0.8}'}, + ) + object.__setattr__(inst, "server_info", server) + object.__setattr__(inst, "model_info", model) + + params = inst._get_llm_params(server, model) + + assert "context_window" not in params + assert params["top_p"] == 0.8 + + +@pytest.mark.parametrize( + "message", + [ + # vLLM: the output reservation alone is too big — a configuration error. + "'max_tokens' or 'max_completion_tokens' is too large: 40000. This model's maximum context length " + "is 32768 tokens and your request has 100 input tokens (40000 > 32768 - 100).", + # Legacy form where only the completion side overflows. + "This model's maximum context length is 32768 tokens. However, you requested 40100 tokens " + "(100 in the messages, 40000 in the completion).", + ], +) +def test_oversized_max_tokens_is_not_a_context_overflow(message): + """Compressing history cannot fix max_tokens; it must stay a plain 400 (design decision 6).""" + assert as_context_overflow(_openai_400(message)) is None + + +@pytest.mark.parametrize( + "message", + [ + # Input grew past N - max_tokens: compressing history fixes it. + "'max_tokens' or 'max_completion_tokens' is too large: 4096. This model's maximum context length " + "is 32768 tokens and your request has 30000 input tokens (4096 > 32768 - 30000).", + "This model's maximum context length is 32768 tokens. However, you requested 34096 tokens " + "(30000 in the messages, 4096 in the completion).", + ], +) +def test_input_plus_reservation_over_the_window_is_an_overflow(message): + assert as_context_overflow(_openai_400(message)) is not None diff --git a/src/backend/test/llm/test_context_window_cache.py b/src/backend/test/llm/test_context_window_cache.py new file mode 100644 index 0000000000..c226c8a72d --- /dev/null +++ b/src/backend/test/llm/test_context_window_cache.py @@ -0,0 +1,148 @@ +"""F087: provider-API results are cached in Redis and dropped when the model is saved.""" + +import pytest + +from bisheng.llm.domain.services.context_window import ContextWindowService, ModelSnapshot +from bisheng.llm.domain.services.context_window_probes import ProbeResult + + +class _FakeRedis: + def __init__(self): + self.store: dict = {} + self.ttl: dict = {} + + async def aget(self, key): + return self.store.get(key) + + async def aset(self, key, value, expiration=3600): + self.store[key] = value + self.ttl[key] = expiration + return True + + async def adelete(self, key): + return 1 if self.store.pop(key, None) is not None else 0 + + +class _BrokenRedis: + async def aget(self, key): + raise ConnectionError("redis down") + + async def aset(self, key, value, expiration=3600): + raise ConnectionError("redis down") + + async def adelete(self, key): + raise ConnectionError("redis down") + + +def _snapshot(model_id=7) -> ModelSnapshot: + return ModelSnapshot( + model_id=model_id, + model_name="qwen3-32b", + server_type="vllm", + model_config={}, + client_params={"base_url": "http://vllm.local/v1"}, + ) + + +@pytest.fixture +def probe(monkeypatch): + state = {"calls": 0, "result": ProbeResult(window=133120, kind="total", source="deployed")} + + async def fake_probe(snapshot): + state["calls"] += 1 + return state["result"] + + monkeypatch.setattr("bisheng.llm.domain.services.context_window.probe_context_window", fake_probe) + return state + + +async def test_success_is_cached_for_an_hour(probe): + redis = _FakeRedis() + + first = await ContextWindowService.resolve_snapshot(_snapshot(), cache=redis) + second = await ContextWindowService.resolve_snapshot(_snapshot(), cache=redis) + + assert first == second + assert probe["calls"] == 1 + assert list(redis.ttl.values()) == [3600] + + +async def test_failure_is_cached_for_five_minutes(probe): + probe["result"] = None + redis = _FakeRedis() + + first = await ContextWindowService.resolve_snapshot(_snapshot(), cache=redis) + second = await ContextWindowService.resolve_snapshot(_snapshot(), cache=redis) + + assert first.source == second.source == "unknown" + assert probe["calls"] == 1 + assert list(redis.ttl.values()) == [300] + + +async def test_invalidate_forces_a_fresh_read(probe): + redis = _FakeRedis() + await ContextWindowService.resolve_snapshot(_snapshot(), cache=redis) + + await ContextWindowService.invalidate([7], cache=redis) + probe["result"] = ProbeResult(window=32768, kind="total", source="deployed") + result = await ContextWindowService.resolve_snapshot(_snapshot(), cache=redis) + + assert result.window == 32768 + assert probe["calls"] == 2 + + +async def test_redis_failure_never_breaks_resolve(probe): + result = await ContextWindowService.resolve_snapshot(_snapshot(), cache=_BrokenRedis()) + + # The probe still answers; only caching is lost. + assert result.window == 133120 + + +async def test_redis_failure_never_breaks_invalidate(): + await ContextWindowService.invalidate([7], cache=_BrokenRedis()) + + +def test_saving_or_verifying_a_model_drops_its_cached_window(): + """update_llm_server / verify_model_status must invalidate; going online/offline must not. + + Checked at source level: driving these service methods needs the database, and + the contract is only "the call is there, next to the in-process cache eviction". + """ + import inspect + + from bisheng.llm.domain.services.llm import LLMService + + assert "ContextWindowService.invalidate" in inspect.getsource(LLMService.update_llm_server) + assert "ContextWindowService.invalidate" in inspect.getsource(LLMService.verify_model_status) + assert "ContextWindowService.invalidate" not in inspect.getsource(LLMService.update_model_online) + + +async def test_value_read_against_an_older_config_is_a_miss(probe): + """A worker still on the pre-save config may write the old answer back after the save.""" + redis = _FakeRedis() + old = _snapshot() + await ContextWindowService.resolve_snapshot(old, cache=redis) + + moved = ModelSnapshot( + model_id=7, + model_name="qwen3-32b", + server_type="vllm", + model_config={}, + client_params={"base_url": "http://new-vllm.local/v1"}, + ) + probe["result"] = ProbeResult(window=65536, kind="total", source="deployed") + result = await ContextWindowService.resolve_snapshot(moved, cache=redis) + + assert result.window == 65536 + assert probe["calls"] == 2 + + +def test_snapshot_repr_hides_credentials(): + snapshot = ModelSnapshot( + model_id=7, + model_name="m", + server_type="vllm", + model_config={"user_kwargs": {"api_key": "sk-secret"}}, + client_params={"api_key": "sk-secret"}, + ) + assert "sk-secret" not in repr(snapshot) diff --git a/src/backend/test/llm/test_context_window_probes.py b/src/backend/test/llm/test_context_window_probes.py new file mode 100644 index 0000000000..4b6308fa59 --- /dev/null +++ b/src/backend/test/llm/test_context_window_probes.py @@ -0,0 +1,269 @@ +"""F087: each provider probe against recorded response shapes (httpx.MockTransport). + +Response bodies follow each provider's documented / source-verified shape; see the +survey table in features/v3.0.0/087-context-window-budget/plan.md §3.1. +""" + +import httpx +import pytest + +from bisheng.llm.domain.services.context_window import ModelSnapshot +from bisheng.llm.domain.services.context_window_probes import ProbeResult, probe_context_window + + +def _snapshot(server_type: str, client_params: dict, model_name: str = "qwen3-32b") -> ModelSnapshot: + return ModelSnapshot( + model_id=1, + model_name=model_name, + server_type=server_type, + model_config={}, + client_params=client_params, + ) + + +def _client(routes: dict) -> httpx.AsyncClient: + """``routes`` maps "METHOD url" to a JSON body, or to an int status code.""" + + def handler(request: httpx.Request) -> httpx.Response: + body = routes.get(f"{request.method} {request.url}") + if body is None: + return httpx.Response(404, json={"error": "not found"}) + if isinstance(body, int): + return httpx.Response(body, json={"error": "boom"}) + return httpx.Response(200, json=body) + + return httpx.AsyncClient(transport=httpx.MockTransport(handler)) + + +VLLM_MODELS = { + "object": "list", + "data": [ + {"id": "other-model", "object": "model", "max_model_len": 8192}, + {"id": "qwen3-32b", "object": "model", "root": "/models/qwen3", "parent": None, "max_model_len": 133120}, + {"id": "qwen3-lora", "object": "model", "root": "/lora/a", "parent": "qwen3-32b", "max_model_len": None}, + ], +} + + +async def test_vllm_picks_the_configured_model(): + snapshot = _snapshot("vllm", {"base_url": "http://vllm:8000/v1", "api_key": "k"}) + result = await probe_context_window(snapshot, _client({"GET http://vllm:8000/v1/models": VLLM_MODELS})) + + assert result == ProbeResult(window=133120, kind="total", source="deployed") + + +async def test_vllm_lora_adapter_uses_its_base_model(): + snapshot = _snapshot("vllm", {"base_url": "http://vllm:8000/v1"}, model_name="qwen3-lora") + result = await probe_context_window(snapshot, _client({"GET http://vllm:8000/v1/models": VLLM_MODELS})) + + assert result.window == 133120 + + +async def test_vllm_unknown_model_gives_none(): + snapshot = _snapshot("vllm", {"base_url": "http://vllm:8000/v1"}, model_name="missing") + assert await probe_context_window(snapshot, _client({"GET http://vllm:8000/v1/models": VLLM_MODELS})) is None + + +async def test_openai_type_reads_vllm_field_as_deployed(): + snapshot = _snapshot("openai", {"base_url": "http://gateway.local/v1"}) + result = await probe_context_window(snapshot, _client({"GET http://gateway.local/v1/models": VLLM_MODELS})) + + assert result.source == "deployed" + + +async def test_openai_type_reads_context_length_as_published(): + body = {"data": [{"id": "qwen3-32b", "context_length": 131072}]} + snapshot = _snapshot("openai", {"base_url": "https://openrouter.ai/api/v1"}) + result = await probe_context_window(snapshot, _client({"GET https://openrouter.ai/api/v1/models": body})) + + assert result == ProbeResult(window=131072, kind="total", source="published") + + +async def test_official_openai_is_not_probed(): + snapshot = _snapshot("openai", {"base_url": "https://api.openai.com/v1"}) + # Any request would 404 through the mock; None must come without calling it. + assert await probe_context_window(snapshot, _client({})) is None + + +async def test_llamacpp_props(): + body = {"default_generation_settings": {"n_ctx": 32768}} + snapshot = _snapshot("llamacpp", {"base_url": "http://llama:8080/v1"}) + result = await probe_context_window(snapshot, _client({"GET http://llama:8080/props": body})) + + assert result == ProbeResult(window=32768, kind="total", source="deployed") + + +async def test_llamacpp_falls_back_to_models_meta(): + models = {"data": [{"id": "qwen3-32b", "meta": {"n_ctx": 16384, "n_ctx_train": 131072}}]} + snapshot = _snapshot("llamacpp", {"base_url": "http://llama:8080/v1"}) + result = await probe_context_window( + snapshot, _client({"GET http://llama:8080/props": 500, "GET http://llama:8080/v1/models": models}) + ) + + assert result.window == 16384 + + +async def test_ollama_loaded_model_reports_deployed_window(): + ps = {"models": [{"name": "qwen3:latest", "model": "qwen3:latest", "context_length": 8192}]} + snapshot = _snapshot("ollama", {"base_url": "http://ollama:11434"}, model_name="qwen3") + result = await probe_context_window(snapshot, _client({"GET http://ollama:11434/api/ps": ps})) + + assert result == ProbeResult(window=8192, kind="total", source="deployed") + + +async def test_ollama_unloaded_model_uses_modelfile_num_ctx(): + show = {"parameters": "stop <|im_end|>\nnum_ctx 16384", "model_info": {"qwen3.context_length": 40960}} + snapshot = _snapshot("ollama", {"base_url": "http://ollama:11434"}, model_name="qwen3") + result = await probe_context_window( + snapshot, + _client({"GET http://ollama:11434/api/ps": {"models": []}, "POST http://ollama:11434/api/show": show}), + ) + + assert result == ProbeResult(window=16384, kind="total", source="deployed") + + +async def test_ollama_unloaded_model_falls_back_to_trained_length(): + show = {"parameters": "stop <|im_end|>", "model_info": {"qwen3.context_length": 40960}} + snapshot = _snapshot("ollama", {"base_url": "http://ollama:11434"}, model_name="qwen3") + result = await probe_context_window( + snapshot, + _client({"GET http://ollama:11434/api/ps": {"models": []}, "POST http://ollama:11434/api/show": show}), + ) + + assert result == ProbeResult(window=40960, kind="total", source="published") + + +async def test_xinference_is_published(): + body = {"object": "list", "data": [{"id": "qwen3-32b", "context_length": 32768}]} + snapshot = _snapshot("xinference", {"base_url": "http://xinf:9997/v1"}) + result = await probe_context_window(snapshot, _client({"GET http://xinf:9997/v1/models": body})) + + assert result == ProbeResult(window=32768, kind="total", source="published") + + +async def test_anthropic_returns_an_input_only_limit(): + body = {"id": "claude-sonnet-5-5", "max_input_tokens": 200000, "max_tokens": 64000} + snapshot = _snapshot( + "anthropic", + {"anthropic_api_url": "https://api.anthropic.com", "anthropic_api_key": "k"}, + model_name="claude-sonnet-5-5", + ) + result = await probe_context_window( + snapshot, _client({"GET https://api.anthropic.com/v1/models/claude-sonnet-5-5": body}) + ) + + assert result == ProbeResult(window=200000, kind="input", source="published") + + +async def test_deepseek_context_window(): + body = {"object": "list", "data": [{"id": "deepseek-chat", "context_window": 131072, "max_output_tokens": 8192}]} + snapshot = _snapshot("deepseek", {"api_base": "https://api.deepseek.com", "api_key": "k"}, "deepseek-chat") + result = await probe_context_window(snapshot, _client({"GET https://api.deepseek.com/models": body})) + + assert result == ProbeResult(window=131072, kind="total", source="published") + + +async def test_moonshot_context_length(): + body = {"data": [{"id": "kimi-k2", "context_length": 262144}]} + snapshot = _snapshot("moonshot", {"base_url": "https://api.moonshot.cn/v1", "api_key": "k"}, "kimi-k2") + result = await probe_context_window(snapshot, _client({"GET https://api.moonshot.cn/v1/models": body})) + + assert result == ProbeResult(window=262144, kind="total", source="published") + + +@pytest.mark.parametrize( + "server_type,params,routes", + [ + ( + "vllm", + {"base_url": "http://vllm:8000/v1"}, + {"GET http://vllm:8000/v1/models": {"data": [{"id": "qwen3-32b"}]}}, + ), + ("deepseek", {"api_base": "https://api.deepseek.com"}, {"GET https://api.deepseek.com/models": {"data": []}}), + ("vllm", {"base_url": "http://vllm:8000/v1"}, {"GET http://vllm:8000/v1/models": 503}), + ], +) +async def test_missing_field_or_http_error_gives_none(server_type, params, routes): + assert await probe_context_window(_snapshot(server_type, params), _client(routes)) is None + + +async def test_timeout_gives_none(): + def handler(request): + raise httpx.ReadTimeout("slow", request=request) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + snapshot = _snapshot("vllm", {"base_url": "http://vllm:8000/v1"}) + assert await probe_context_window(snapshot, client) is None + + +async def test_unsupported_server_type_gives_none(): + assert await probe_context_window(_snapshot("zhipu", {"base_url": "x"}), _client({})) is None + + +async def test_llamacpp_non_json_props_still_falls_back(): + def handler(request): + if request.url.path == "/props": + return httpx.Response(200, text="not json") + return httpx.Response(200, json={"data": [{"id": "qwen3-32b", "meta": {"n_ctx": 16384}}]}) + + snapshot = _snapshot("llamacpp", {"base_url": "http://llama:8080/v1"}) + result = await probe_context_window(snapshot, httpx.AsyncClient(transport=httpx.MockTransport(handler))) + + assert result.window == 16384 + + +async def test_unexpected_body_shape_gives_none(): + snapshot = _snapshot("ollama", {"base_url": "http://ollama:11434"}, model_name="qwen3") + assert await probe_context_window(snapshot, _client({"GET http://ollama:11434/api/ps": {"models": None}})) is None + + +DASHSCOPE_MODELS = { + "output": { + "total": 1, + "models": [ + { + "model": "qwen3-max", + "model_info": {"context_window": 262144, "max_input_tokens": 258048, "max_output_tokens": 65536}, + } + ], + } +} + + +async def test_qwen_singapore_reads_the_native_api(): + snapshot = _snapshot("qwen", {"base_url": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"}, "qwen3-max") + result = await probe_context_window( + snapshot, _client({"GET https://dashscope-intl.aliyuncs.com/api/v1/models?model=qwen3-max": DASHSCOPE_MODELS}) + ) + + # max_input_tokens is already an input-only limit. + assert result == ProbeResult(window=258048, kind="input", source="published") + + +async def test_qwen_workspace_host_is_used_as_is(): + base = "https://ws-123.cn-beijing.maas.aliyuncs.com/compatible-mode/v1" + snapshot = _snapshot("qwen", {"base_url": base}, "qwen3-max") + url = "GET https://ws-123.cn-beijing.maas.aliyuncs.com/api/v1/models?model=qwen3-max" + result = await probe_context_window(snapshot, _client({url: DASHSCOPE_MODELS})) + + assert result.window == 258048 + + +async def test_qwen_falls_back_to_context_window_when_input_limit_is_null(): + body = { + "output": { + "models": [{"model": "qwen3-max", "model_info": {"context_window": 131072, "max_input_tokens": None}}] + } + } + snapshot = _snapshot("qwen", {"base_url": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"}, "qwen3-max") + result = await probe_context_window( + snapshot, _client({"GET https://dashscope-intl.aliyuncs.com/api/v1/models?model=qwen3-max": body}) + ) + + assert result == ProbeResult(window=131072, kind="total", source="published") + + +async def test_qwen_beijing_default_host_has_no_workspace_and_is_unknown(): + """Beijing needs {WorkspaceId}.cn-beijing.maas.aliyuncs.com; the default host carries none.""" + snapshot = _snapshot("qwen", {"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1"}, "qwen3-max") + assert await probe_context_window(snapshot, _client({})) is None diff --git a/src/backend/test/llm/test_context_window_service.py b/src/backend/test/llm/test_context_window_service.py new file mode 100644 index 0000000000..2ac69f60cc --- /dev/null +++ b/src/backend/test/llm/test_context_window_service.py @@ -0,0 +1,136 @@ +"""F087: which context window a task-mode model runs with, and how much of it is input. + +Covers the source priority (advanced config > Ollama num_ctx > provider API > unknown) +and the three usable-input rules from spec.md's glossary. +""" + +import pytest + +from bisheng.llm.domain.services.context_window import ( + ContextWindowService, + ModelSnapshot, +) +from bisheng.llm.domain.services.context_window_probes import ProbeResult + + +class _NoCache: + async def aget(self, key): + return None + + async def aset(self, key, value, expiration=3600): + return True + + async def adelete(self, key): + return 0 + + +def _snapshot(server_type="vllm", model_config=None, client_params=None, model_id=7) -> ModelSnapshot: + return ModelSnapshot( + model_id=model_id, + model_name="qwen3-32b", + server_type=server_type, + model_config=model_config or {}, + client_params=client_params if client_params is not None else {"base_url": "http://vllm.local/v1"}, + ) + + +@pytest.fixture +def probe_calls(monkeypatch): + calls = [] + + async def fake_probe(snapshot): + calls.append(snapshot.model_id) + return ProbeResult(window=133120, kind="total", source="deployed") + + monkeypatch.setattr("bisheng.llm.domain.services.context_window.probe_context_window", fake_probe) + return calls + + +async def test_advanced_config_wins_over_provider(probe_calls): + result = await ContextWindowService.resolve_snapshot( + _snapshot(model_config={"user_kwargs": '{"context_window": 32768}'}), cache=_NoCache() + ) + + assert (result.window, result.usable_input, result.source) == (32768, 32768, "manual") + assert probe_calls == [] + + +async def test_advanced_config_given_as_dict(probe_calls): + result = await ContextWindowService.resolve_snapshot( + _snapshot(model_config={"user_kwargs": {"context_window": 65536}}), cache=_NoCache() + ) + + assert result.source == "manual" + assert result.window == 65536 + + +@pytest.mark.parametrize("bad", ["abc", 0, -5, 1.5, True]) +async def test_invalid_manual_value_is_ignored(probe_calls, bad): + result = await ContextWindowService.resolve_snapshot( + _snapshot(model_config={"user_kwargs": {"context_window": bad}}), cache=_NoCache() + ) + + # Falls through to the provider API. + assert result.source == "deployed" + assert probe_calls == [7] + + +async def test_malformed_advanced_config_falls_through(probe_calls): + result = await ContextWindowService.resolve_snapshot( + _snapshot(model_config={"user_kwargs": "{not json"}), cache=_NoCache() + ) + + assert result.source == "deployed" + + +async def test_ollama_num_ctx_is_the_window_and_not_subtracted(probe_calls): + result = await ContextWindowService.resolve_snapshot( + _snapshot(server_type="ollama", client_params={"base_url": "http://ollama:11434", "num_ctx": 32768}), + cache=_NoCache(), + ) + + assert (result.window, result.usable_input, result.source) == (32768, 32768, "ollama_num_ctx") + assert probe_calls == [] + + +async def test_max_tokens_is_subtracted_from_a_total_window(probe_calls): + result = await ContextWindowService.resolve_snapshot( + _snapshot(client_params={"base_url": "http://vllm.local/v1", "max_tokens": 8192}), cache=_NoCache() + ) + + assert result.usable_input == 133120 - 8192 + + +async def test_input_only_limit_is_not_reduced_again(monkeypatch): + async def fake_probe(snapshot): + return ProbeResult(window=200000, kind="input", source="published") + + monkeypatch.setattr("bisheng.llm.domain.services.context_window.probe_context_window", fake_probe) + result = await ContextWindowService.resolve_snapshot( + _snapshot(server_type="anthropic", client_params={"max_tokens": 8192}), cache=_NoCache() + ) + + assert (result.usable_input, result.kind) == (200000, "input") + + +async def test_max_tokens_not_below_window_means_unknown(probe_calls): + result = await ContextWindowService.resolve_snapshot( + _snapshot( + model_config={"user_kwargs": {"context_window": 8192}}, + client_params={"base_url": "http://vllm.local/v1", "max_tokens": 8192}, + ), + cache=_NoCache(), + ) + + assert result.usable_input is None + assert result.source == "unknown" + + +async def test_no_probe_for_the_server_type_means_unknown(monkeypatch): + async def fake_probe(snapshot): + return None + + monkeypatch.setattr("bisheng.llm.domain.services.context_window.probe_context_window", fake_probe) + result = await ContextWindowService.resolve_snapshot(_snapshot(server_type="zhipu"), cache=_NoCache()) + + assert (result.window, result.usable_input, result.source) == (None, None, "unknown") diff --git a/src/backend/test/llm/test_llm_header_ascii.py b/src/backend/test/llm/test_llm_header_ascii.py new file mode 100644 index 0000000000..574d41ba2a --- /dev/null +++ b/src/backend/test/llm/test_llm_header_ascii.py @@ -0,0 +1,29 @@ +"""HTTP header values passed to OpenAI-compatible clients must be ASCII.""" + +import pytest + +from bisheng.llm.domain.llm.llm import _assert_ascii_http_header_values, _get_openai_params + + +def test_ascii_api_key_is_accepted(): + _assert_ascii_http_header_values({"api_key": "sk-ascii-token-123"}) + + +def test_chinese_api_key_is_rejected(): + with pytest.raises(ValueError, match="api_key"): + _assert_ascii_http_header_values({"api_key": "sk-中文密钥不能作为请求头"}) + + +def test_chinese_default_header_is_rejected(): + with pytest.raises(ValueError, match="default_headers.X-Title"): + _assert_ascii_http_header_values({"api_key": "sk-ok", "default_headers": {"X-Title": "毕昇灵思"}}) + + +def test_openai_params_rejects_non_ascii_key(): + with pytest.raises(ValueError, match="api_key"): + params = _get_openai_params( + {}, + {"openai_api_key": "sk-中文密钥", "openai_api_base": "https://api.example.com"}, + {}, + ) + _assert_ascii_http_header_values(params) diff --git a/src/backend/test/llm/test_llm_invoke_error.py b/src/backend/test/llm/test_llm_invoke_error.py new file mode 100644 index 0000000000..a299174440 --- /dev/null +++ b/src/backend/test/llm/test_llm_invoke_error.py @@ -0,0 +1,137 @@ +"""A failed provider call inside a plain request must surface as LlmInvokeError +(business code 10028), not as a bare HTTP 500 the user can only read as +"server error".""" + +from types import SimpleNamespace + +import httpx +import openai + +from bisheng.common.errcode.server import LlmInvokeError, LlmModelOfflineError +from bisheng.llm.domain.utils import to_llm_invoke_error + + +def _model(): + return SimpleNamespace( + model_info=SimpleNamespace(model_name="qwen-max"), + server_info=SimpleNamespace(name="bailian"), + ) + + +def _status_error(status: int, body: dict) -> openai.APIStatusError: + request = httpx.Request("POST", "https://example.invalid/v1/chat/completions") + response = httpx.Response(status, request=request) + return openai.AuthenticationError(f"Error code: {status} - {body}", response=response, body=body) + + +def test_provider_status_error_becomes_10028_with_short_reason(): + exc = _status_error(401, {"error": {"message": "Incorrect API key provided", "code": "invalid_api_key"}}) + + err = to_llm_invoke_error(_model(), exc) + + assert isinstance(err, LlmInvokeError) + assert err.code == 10028 + assert err.kwargs == { + "model_name": "qwen-max", + "server_name": "bailian", + "reason": "HTTP 401: Incorrect API key provided", + } + assert "qwen-max" in err.message and "bailian" in err.message + # The full provider exception stays attached for logs and the response data. + assert err.exception is exc + + +def test_connection_error_falls_back_to_str(): + exc = openai.APIConnectionError(request=httpx.Request("POST", "https://example.invalid")) + + err = to_llm_invoke_error(_model(), exc) + + assert err.kwargs["reason"] == "Connection error." + + +def test_long_reason_is_truncated(): + err = to_llm_invoke_error(_model(), RuntimeError("x" * 1000)) + + assert len(err.kwargs["reason"]) == 203 + assert err.kwargs["reason"].endswith("...") + + +def test_business_error_passes_through_unchanged(): + offline = LlmModelOfflineError(server_name="bailian", model_name="qwen-max") + + assert to_llm_invoke_error(_model(), offline) is offline + + +def test_model_without_info_still_builds_error(): + err = to_llm_invoke_error(None, ValueError("boom")) + + assert isinstance(err, LlmInvokeError) + assert err.kwargs["model_name"] == "" and err.kwargs["reason"] == "boom" + + +class _FailingLLM: + """Stands in for BishengLLM: carries model/server info, every call fails.""" + + model_info = SimpleNamespace(model_name="qwen-max") + server_info = SimpleNamespace(name="bailian") + + def __call__(self, *_args, **_kwargs): + raise openai.APIConnectionError(request=httpx.Request("POST", "https://example.invalid")) + + +def _runnable_failing_llm(): + from langchain_core.runnables import RunnableLambda + + failing = _FailingLLM() + runnable = RunnableLambda(failing) + # AssistantAgent hands self.llm to the chain and to to_llm_invoke_error, so + # the runnable must carry the model/server info too. + object.__setattr__(runnable, "model_info", failing.model_info) + object.__setattr__(runnable, "server_info", failing.server_info) + return runnable + + +def test_opening_dialog_propagates_provider_error_instead_of_unbound_local(): + import pytest + + from bisheng_langchain.gpts.auto_optimization import generate_opening_dialog + + with pytest.raises(openai.APIConnectionError): + generate_opening_dialog(_runnable_failing_llm(), "desc") + + +def test_assistant_agent_auto_calls_raise_llm_invoke_error(): + import pytest + + from bisheng.api.services.assistant_agent import AssistantAgent + + agent = AssistantAgent.__new__(AssistantAgent) + agent.llm = _runnable_failing_llm() + agent.assistant = SimpleNamespace(name="a", prompt="p") + + for call in ( + lambda: agent.generate_guide("p"), + lambda: agent.choose_tools([{"name": "t", "description": ""}], "p"), + ): + with pytest.raises(LlmInvokeError) as info: + call() + assert info.value.kwargs["model_name"] == "qwen-max" + assert info.value.kwargs["reason"] == "Connection error." + + +async def test_assistant_prompt_optimization_stream_raises_llm_invoke_error(): + import pytest + + from bisheng.api.services.assistant_agent import AssistantAgent + + agent = AssistantAgent.__new__(AssistantAgent) + agent.llm = _runnable_failing_llm() + agent.assistant = SimpleNamespace(name="a", prompt="p") + + with pytest.raises(LlmInvokeError): + async for _ in agent.optimize_assistant_prompt(): + pass + + with pytest.raises(LlmInvokeError): + async for _ in agent.astream_description("p"): + pass diff --git a/src/backend/test/llm/test_llm_tenant_isolation_api.py b/src/backend/test/llm/test_llm_tenant_isolation_api.py index 5dc91ca4d2..b6044b5ded 100644 --- a/src/backend/test/llm/test_llm_tenant_isolation_api.py +++ b/src/backend/test/llm/test_llm_tenant_isolation_api.py @@ -89,6 +89,10 @@ def test_workbench_endpoint_uses_tenant_admin_dep(): assert 'get_model_admin_user' not in deps +def test_editable_workbench_settings_uses_tenant_admin_dep(): + assert 'get_tenant_admin_user' in _endpoint_deps('/llm/workbench/settings', 'GET') + + def test_knowledge_config_endpoint_uses_tenant_admin_dep(): deps = _endpoint_deps('/llm/knowledge', 'POST') assert 'get_tenant_admin_user' in deps diff --git a/src/backend/test/llm/test_minimax_provider.py b/src/backend/test/llm/test_minimax_provider.py index b11458bd18..14ea121cb7 100644 --- a/src/backend/test/llm/test_minimax_provider.py +++ b/src/backend/test/llm/test_minimax_provider.py @@ -1,8 +1,9 @@ -"""Tests for MiniMax provider upgrade to OpenAI-compatible API (M2.7 support). +"""Tests for MiniMax provider upgrade to OpenAI-compatible API (M3 support). These tests verify the MiniMax provider changes without requiring the full bisheng backend to be installed, by testing the specific files that were changed. """ + import ast import json import os @@ -15,7 +16,7 @@ class TestMiniMaxLLMModuleChanges(unittest.TestCase): @classmethod def setUpClass(cls): cls.llm_py_path = os.path.join( - os.path.dirname(__file__), '..', '..', 'bisheng', 'llm', 'domain', 'llm', 'llm.py' + os.path.dirname(__file__), "..", "..", "bisheng", "llm", "domain", "llm", "llm.py" ) cls.llm_py_path = os.path.normpath(cls.llm_py_path) with open(cls.llm_py_path) as f: @@ -24,11 +25,11 @@ def setUpClass(cls): def test_minimax_chat_not_imported(self): """MiniMaxChat should NOT be imported in llm.py.""" - self.assertNotIn('MiniMaxChat', self.llm_source) + self.assertNotIn("MiniMaxChat", self.llm_source) def test_chat_openai_compatible_imported(self): """ChatOpenAICompatible should be imported in llm.py.""" - self.assertIn('ChatOpenAICompatible', self.llm_source) + self.assertIn("ChatOpenAICompatible", self.llm_source) def test_get_minimax_params_handles_web_search(self): """_get_minimax_params builds OpenAI-compatible params and threads the @@ -36,12 +37,12 @@ def test_get_minimax_params_handles_web_search(self): api_key / group_id, no /chat/completions URL appending).""" found = False for node in ast.walk(self.llm_tree): - if isinstance(node, ast.FunctionDef) and node.name == '_get_minimax_params': + if isinstance(node, ast.FunctionDef) and node.name == "_get_minimax_params": src = ast.get_source_segment(self.llm_source, node) - self.assertIn('_get_openai_params', src) - self.assertIn('enable_web_search', src) + self.assertIn("_get_openai_params", src) + self.assertIn("enable_web_search", src) found = True - self.assertTrue(found, '_get_minimax_params function should exist in llm.py') + self.assertTrue(found, "_get_minimax_params function should exist in llm.py") def test_minimax_entry_uses_chat_minimax(self): """_llm_node_type['minimax'] should reference the dedicated ChatMinimax @@ -49,35 +50,36 @@ def test_minimax_entry_uses_chat_minimax(self): BishengLLM wrapper).""" found = False for line in self.llm_source.splitlines(): - if 'MINIMAX' in line and 'client' in line: - self.assertIn('ChatMinimax', line, - f'MINIMAX client should be ChatMinimax, got: {line.strip()}') + if "MINIMAX" in line and "client" in line: + self.assertIn("ChatMinimax", line, f"MINIMAX client should be ChatMinimax, got: {line.strip()}") found = True break - self.assertTrue(found, 'Could not find MINIMAX entry in _llm_node_type') + self.assertTrue(found, "Could not find MINIMAX entry in _llm_node_type") def test_minimax_entry_uses_minimax_params(self): """_llm_node_type['minimax'] should use the _get_minimax_params handler.""" for line in self.llm_source.splitlines(): - if 'MINIMAX' in line and 'params_handler' in line: - self.assertIn('_get_minimax_params', line, - f'MINIMAX params handler should be _get_minimax_params, got: {line.strip()}') + if "MINIMAX" in line and "params_handler" in line: + self.assertIn( + "_get_minimax_params", + line, + f"MINIMAX params handler should be _get_minimax_params, got: {line.strip()}", + ) return - self.fail('Could not find MINIMAX params_handler entry') + self.fail("Could not find MINIMAX params_handler entry") def test_web_search_support_moved_to_chat_minimax(self): """Web search support is now encapsulated in ChatMinimax and must no longer live in the removed BishengLLM.parse_kwargs hook.""" - self.assertNotIn('def parse_kwargs', self.llm_source, - 'parse_kwargs should be removed from BishengLLM') + self.assertNotIn("def parse_kwargs", self.llm_source, "parse_kwargs should be removed from BishengLLM") chat_minimax_path = os.path.join( - os.path.dirname(__file__), '..', '..', 'bisheng', 'core', 'ai', 'llm', 'chat_minimax.py' + os.path.dirname(__file__), "..", "..", "bisheng", "core", "ai", "llm", "chat_minimax.py" ) chat_minimax_path = os.path.normpath(chat_minimax_path) with open(chat_minimax_path) as f: chat_minimax_source = f.read() - self.assertIn('web_search', chat_minimax_source) - self.assertIn('enable_web_search', chat_minimax_source) + self.assertIn("web_search", chat_minimax_source) + self.assertIn("enable_web_search", chat_minimax_source) def test_no_legacy_minimax_api_key_param(self): """No function should set minimax_api_key (legacy param).""" @@ -86,11 +88,15 @@ def test_no_legacy_minimax_api_key_param(self): def test_no_chat_completions_appended_for_minimax(self): """No code should append /chat/completions to base_url for MiniMax.""" lines_with_chat_completions = [ - line.strip() for line in self.llm_source.splitlines() - if 'chat/completions' in line and 'minimax' in line.lower() + line.strip() + for line in self.llm_source.splitlines() + if "chat/completions" in line and "minimax" in line.lower() ] - self.assertEqual(len(lines_with_chat_completions), 0, - f'Found legacy chat/completions URL appending: {lines_with_chat_completions}') + self.assertEqual( + len(lines_with_chat_completions), + 0, + f"Found legacy chat/completions URL appending: {lines_with_chat_completions}", + ) class TestCoreAIModuleChanges(unittest.TestCase): @@ -98,16 +104,14 @@ class TestCoreAIModuleChanges(unittest.TestCase): @classmethod def setUpClass(cls): - cls.init_py_path = os.path.join( - os.path.dirname(__file__), '..', '..', 'bisheng', 'core', 'ai', '__init__.py' - ) + cls.init_py_path = os.path.join(os.path.dirname(__file__), "..", "..", "bisheng", "core", "ai", "__init__.py") cls.init_py_path = os.path.normpath(cls.init_py_path) with open(cls.init_py_path) as f: cls.init_source = f.read() def test_minimax_chat_not_imported(self): """MiniMaxChat should NOT be imported from langchain_community.""" - self.assertNotIn('MiniMaxChat', self.init_source) + self.assertNotIn("MiniMaxChat", self.init_source) def test_minimax_chat_not_in_all(self): """MiniMaxChat should NOT be in __all__ list.""" @@ -115,15 +119,12 @@ def test_minimax_chat_not_in_all(self): for node in ast.walk(tree): if isinstance(node, ast.Assign): for target in node.targets: - if isinstance(target, ast.Name) and target.id == '__all__': + if isinstance(target, ast.Name) and target.id == "__all__": if isinstance(node.value, ast.List): - items = [ - elt.value for elt in node.value.elts - if isinstance(elt, ast.Constant) - ] - self.assertNotIn('MiniMaxChat', items) + items = [elt.value for elt in node.value.elts if isinstance(elt, ast.Constant)] + self.assertNotIn("MiniMaxChat", items) return - self.fail('Could not find __all__ in __init__.py') + self.fail("Could not find __all__ in __init__.py") def test_chat_openai_compatible_in_all(self): """ChatOpenAICompatible should be in __all__ list.""" @@ -131,15 +132,12 @@ def test_chat_openai_compatible_in_all(self): for node in ast.walk(tree): if isinstance(node, ast.Assign): for target in node.targets: - if isinstance(target, ast.Name) and target.id == '__all__': + if isinstance(target, ast.Name) and target.id == "__all__": if isinstance(node.value, ast.List): - items = [ - elt.value for elt in node.value.elts - if isinstance(elt, ast.Constant) - ] - self.assertIn('ChatOpenAICompatible', items) + items = [elt.value for elt in node.value.elts if isinstance(elt, ast.Constant)] + self.assertIn("ChatOpenAICompatible", items) return - self.fail('Could not find __all__ in __init__.py') + self.fail("Could not find __all__ in __init__.py") class TestEmbeddingModuleConsistency(unittest.TestCase): @@ -148,7 +146,7 @@ class TestEmbeddingModuleConsistency(unittest.TestCase): @classmethod def setUpClass(cls): cls.emb_py_path = os.path.join( - os.path.dirname(__file__), '..', '..', 'bisheng', 'llm', 'domain', 'llm', 'embedding.py' + os.path.dirname(__file__), "..", "..", "bisheng", "llm", "domain", "llm", "embedding.py" ) cls.emb_py_path = os.path.normpath(cls.emb_py_path) with open(cls.emb_py_path) as f: @@ -157,18 +155,18 @@ def setUpClass(cls): def test_minimax_embedding_uses_openai_embeddings(self): """MiniMax embedding should map to OpenAIEmbeddings.""" for line in self.emb_source.splitlines(): - if 'MINIMAX' in line and 'client' in line: - self.assertIn('OpenAIEmbeddings', line) + if "MINIMAX" in line and "client" in line: + self.assertIn("OpenAIEmbeddings", line) return - self.fail('Could not find MINIMAX entry in embedding _node_type') + self.fail("Could not find MINIMAX entry in embedding _node_type") def test_minimax_embedding_uses_openai_params(self): """MiniMax embedding should use _get_openai_params.""" for line in self.emb_source.splitlines(): - if 'MINIMAX' in line and 'params_handler' in line: - self.assertIn('_get_openai_params', line) + if "MINIMAX" in line and "params_handler" in line: + self.assertIn("_get_openai_params", line) return - self.fail('Could not find MINIMAX params_handler in embedding') + self.fail("Could not find MINIMAX params_handler in embedding") class TestFrontendModelData(unittest.TestCase): @@ -176,57 +174,50 @@ class TestFrontendModelData(unittest.TestCase): @classmethod def setUpClass(cls): - cls.data_path = os.path.join( - os.path.dirname(__file__), - '../../frontend/platform/public/models/data.json' - ) + cls.data_path = os.path.join(os.path.dirname(__file__), "../../../frontend/platform/public/models/data.json") cls.data_path = os.path.normpath(cls.data_path) if not os.path.exists(cls.data_path): - raise unittest.SkipTest('Frontend data.json not available in checkout') + raise unittest.SkipTest("Frontend data.json not available in checkout") with open(cls.data_path) as f: cls.model_data = json.load(f) def test_minimax_models_exist(self): """MiniMax models should be present in data.json.""" - self.assertIn('minimax', self.model_data) + self.assertIn("minimax", self.model_data) + + def test_minimax_has_m3_model(self): + """MiniMax-M3 should be listed as an LLM model.""" + model_names = [m["model_name"] for m in self.model_data["minimax"]] + self.assertIn("MiniMax-M3", model_names) def test_minimax_has_m27_model(self): """MiniMax-M2.7 should be listed as an LLM model.""" - model_names = [m['model_name'] for m in self.model_data['minimax']] - self.assertIn('MiniMax-M2.7', model_names) + model_names = [m["model_name"] for m in self.model_data["minimax"]] + self.assertIn("MiniMax-M2.7", model_names) def test_minimax_has_m27_highspeed_model(self): """MiniMax-M2.7-highspeed should be listed as an LLM model.""" - model_names = [m['model_name'] for m in self.model_data['minimax']] - self.assertIn('MiniMax-M2.7-highspeed', model_names) - - def test_minimax_has_m25_model(self): - """MiniMax-M2.5 should still be listed for backward compatibility.""" - model_names = [m['model_name'] for m in self.model_data['minimax']] - self.assertIn('MiniMax-M2.5', model_names) + model_names = [m["model_name"] for m in self.model_data["minimax"]] + self.assertIn("MiniMax-M2.7-highspeed", model_names) - def test_minimax_has_m25_highspeed_model(self): - """MiniMax-M2.5-highspeed should still be listed.""" - model_names = [m['model_name'] for m in self.model_data['minimax']] - self.assertIn('MiniMax-M2.5-highspeed', model_names) + def test_minimax_older_models_retained(self): + """Existing supported models must remain selectable after adding M3.""" + model_names = [m["model_name"] for m in self.model_data["minimax"]] + for legacy in ("MiniMax-M2.5", "MiniMax-M2.5-highspeed", "MiniMax-Text-01"): + self.assertIn(legacy, model_names) - def test_minimax_has_text01_model(self): - """MiniMax-Text-01 should still be listed for backward compatibility.""" - model_names = [m['model_name'] for m in self.model_data['minimax']] - self.assertIn('MiniMax-Text-01', model_names) - - def test_minimax_m27_is_first(self): - """MiniMax-M2.7 should be the first (default) model.""" - self.assertEqual(self.model_data['minimax'][0]['model_name'], 'MiniMax-M2.7') + def test_minimax_m3_is_first(self): + """MiniMax-M3 should be the first (default) model.""" + self.assertEqual(self.model_data["minimax"][0]["model_name"], "MiniMax-M3") def test_minimax_models_are_llm_type(self): """All MiniMax models should be of type 'llm'.""" - for model in self.model_data['minimax']: - self.assertEqual(model['model_type'], 'llm') + for model in self.model_data["minimax"]: + self.assertEqual(model["model_type"], "llm") def test_minimax_model_count(self): - """MiniMax should have 5 models listed.""" - self.assertEqual(len(self.model_data['minimax']), 5) + """Adding M3 must retain every existing model option.""" + self.assertEqual(len(self.model_data["minimax"]), 6) class TestFrontendCustomForm(unittest.TestCase): @@ -235,22 +226,21 @@ class TestFrontendCustomForm(unittest.TestCase): @classmethod def setUpClass(cls): cls.form_path = os.path.join( - os.path.dirname(__file__), - '../../frontend/platform/src/pages/ModelPage/manage/CustomForm.tsx' + os.path.dirname(__file__), "../../../frontend/platform/src/pages/ModelPage/manage/CustomForm.tsx" ) cls.form_path = os.path.normpath(cls.form_path) if not os.path.exists(cls.form_path): - raise unittest.SkipTest('Frontend CustomForm.tsx not available in checkout') + raise unittest.SkipTest("Frontend CustomForm.tsx not available in checkout") with open(cls.form_path) as f: cls.form_source = f.read() def test_minimax_api_base_url(self): - """Default MiniMax API base should be api.minimax.io/v1.""" - self.assertIn('https://api.minimax.io/v1', self.form_source) + """Default MiniMax API base should use the domestic endpoint.""" + self.assertIn("https://api.minimax.cn/v1", self.form_source) def test_minimax_not_using_old_api_url(self): """Should NOT use old api.minimax.chat/v1 URL.""" - self.assertNotIn('api.minimax.chat/v1', self.form_source) + self.assertNotIn("api.minimax.chat/v1", self.form_source) class TestMiniMaxLLMServerTypeEnum(unittest.TestCase): @@ -258,9 +248,7 @@ class TestMiniMaxLLMServerTypeEnum(unittest.TestCase): @classmethod def setUpClass(cls): - cls.const_path = os.path.join( - os.path.dirname(__file__), '..', '..', 'bisheng', 'llm', 'domain', 'const.py' - ) + cls.const_path = os.path.join(os.path.dirname(__file__), "..", "..", "bisheng", "llm", "domain", "const.py") cls.const_path = os.path.normpath(cls.const_path) with open(cls.const_path) as f: cls.const_source = f.read() @@ -275,9 +263,7 @@ class TestOpenAIParamsFunction(unittest.TestCase): @classmethod def setUpClass(cls): - llm_py_path = os.path.join( - os.path.dirname(__file__), '..', '..', 'bisheng', 'llm', 'domain', 'llm', 'llm.py' - ) + llm_py_path = os.path.join(os.path.dirname(__file__), "..", "..", "bisheng", "llm", "domain", "llm", "llm.py") llm_py_path = os.path.normpath(llm_py_path) with open(llm_py_path) as f: source = f.read() @@ -285,192 +271,191 @@ def setUpClass(cls): tree = ast.parse(source) funcs = {} for node in ast.walk(tree): - if isinstance(node, ast.FunctionDef) and node.name in ('_get_user_kwargs', '_get_openai_params'): + if isinstance(node, ast.FunctionDef) and node.name in ("_get_user_kwargs", "_get_openai_params"): func_source = ast.get_source_segment(source, node) funcs[node.name] = func_source - combined = funcs['_get_user_kwargs'] + '\n\n' + funcs['_get_openai_params'] - exec_globals = {'json': json} - exec(compile(combined, '', 'exec'), exec_globals) - cls._openai_params_fn = staticmethod(exec_globals['_get_openai_params']) + combined = funcs["_get_user_kwargs"] + "\n\n" + funcs["_get_openai_params"] + exec_globals = {"json": json} + utils_path = os.path.join(os.path.dirname(llm_py_path), "..", "utils.py") + with open(utils_path) as f: + utils_tree = ast.parse(f.read()) + proxy_nodes = [ + node + for node in utils_tree.body + if (isinstance(node, ast.FunctionDef) and node.name == "usable_proxy_url") + or ( + isinstance(node, ast.Assign) + and any(isinstance(target, ast.Name) and target.id == "_PROXY_URL_SCHEMES" for target in node.targets) + ) + ] + exec(compile(ast.Module(body=proxy_nodes, type_ignores=[]), "", "exec"), exec_globals) + exec(compile(combined, "", "exec"), exec_globals) + cls._openai_params_fn = staticmethod(exec_globals["_get_openai_params"]) def test_extracts_api_key(self): """Should use openai_api_key from server config.""" result = self._openai_params_fn( - {'model': 'MiniMax-M2.5'}, - {'openai_api_key': 'test-minimax-key', 'openai_api_base': 'https://api.minimax.io/v1'}, - {} + {"model": "MiniMax-M3"}, + {"openai_api_key": "test-minimax-key", "openai_api_base": "https://api.minimax.io/v1"}, + {}, ) - self.assertEqual(result['api_key'], 'test-minimax-key') + self.assertEqual(result["api_key"], "test-minimax-key") def test_extracts_base_url(self): """Should use openai_api_base as base_url.""" result = self._openai_params_fn( - {'model': 'MiniMax-M2.5'}, - {'openai_api_key': 'key', 'openai_api_base': 'https://api.minimax.io/v1'}, - {} + {"model": "MiniMax-M3"}, {"openai_api_key": "key", "openai_api_base": "https://api.minimax.io/v1"}, {} ) - self.assertEqual(result['base_url'], 'https://api.minimax.io/v1') + self.assertEqual(result["base_url"], "https://api.minimax.io/v1") def test_strips_trailing_slash(self): """Should strip trailing slash from base_url.""" result = self._openai_params_fn( - {'model': 'MiniMax-M2.5'}, - {'openai_api_key': 'key', 'openai_api_base': 'https://api.minimax.io/v1/'}, - {} + {"model": "MiniMax-M3"}, {"openai_api_key": "key", "openai_api_base": "https://api.minimax.io/v1/"}, {} ) - self.assertEqual(result['base_url'], 'https://api.minimax.io/v1') + self.assertEqual(result["base_url"], "https://api.minimax.io/v1") def test_stream_usage_true(self): """Should set stream_usage=True.""" result = self._openai_params_fn( - {'model': 'MiniMax-M2.5'}, - {'openai_api_key': 'key', 'openai_api_base': 'https://api.minimax.io/v1'}, - {} + {"model": "MiniMax-M3"}, {"openai_api_key": "key", "openai_api_base": "https://api.minimax.io/v1"}, {} ) - self.assertTrue(result['stream_usage']) + self.assertTrue(result["stream_usage"]) def test_no_legacy_params(self): """Should not produce minimax_api_key or group_id.""" result = self._openai_params_fn( - {'model': 'MiniMax-M2.5'}, - {'openai_api_key': 'key', 'openai_api_base': 'https://api.minimax.io/v1'}, - {} + {"model": "MiniMax-M3"}, {"openai_api_key": "key", "openai_api_base": "https://api.minimax.io/v1"}, {} ) - self.assertNotIn('minimax_api_key', result) - self.assertNotIn('minimax_group_id', result) + self.assertNotIn("minimax_api_key", result) + self.assertNotIn("minimax_group_id", result) def test_preserves_model_name(self): """Model name should be preserved.""" - for name in ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed', - 'MiniMax-Text-01']: + for name in ["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5", "MiniMax-M2.5-highspeed"]: result = self._openai_params_fn( - {'model': name}, - {'openai_api_key': 'key', 'openai_api_base': 'https://api.minimax.io/v1'}, - {} + {"model": name}, {"openai_api_key": "key", "openai_api_base": "https://api.minimax.io/v1"}, {} ) - self.assertEqual(result['model'], name) + self.assertEqual(result["model"], name) + + def test_domestic_endpoint_with_m3(self): + """The configured domestic endpoint must reach the chat client unchanged.""" + result = self._openai_params_fn( + {"model": "MiniMax-M3"}, + {"openai_api_key": "key", "openai_api_base": "https://api.minimax.cn/v1"}, + {}, + ) + self.assertEqual(result["base_url"], "https://api.minimax.cn/v1") + self.assertEqual(result["model"], "MiniMax-M3") def test_user_kwargs_applied(self): """User advanced kwargs should be merged.""" result = self._openai_params_fn( - {'model': 'MiniMax-M2.5', 'temperature': 0.7}, - {'openai_api_key': 'key', 'openai_api_base': 'https://api.minimax.io/v1'}, - {'user_kwargs': json.dumps({'max_tokens': 4096})} + {"model": "MiniMax-M3", "temperature": 0.7}, + {"openai_api_key": "key", "openai_api_base": "https://api.minimax.io/v1"}, + {"user_kwargs": json.dumps({"max_tokens": 4096})}, ) - self.assertEqual(result['temperature'], 0.7) - self.assertEqual(result['model'], 'MiniMax-M2.5') + self.assertEqual(result["temperature"], 0.7) + self.assertEqual(result["model"], "MiniMax-M3") def test_empty_key_fallback(self): """Should fall back to 'empty' when API key is empty.""" result = self._openai_params_fn( - {'model': 'MiniMax-M2.5'}, - {'openai_api_key': '', 'openai_api_base': 'https://api.minimax.io/v1'}, - {} + {"model": "MiniMax-M3"}, {"openai_api_key": "", "openai_api_base": "https://api.minimax.io/v1"}, {} ) - self.assertEqual(result['api_key'], 'empty') + self.assertEqual(result["api_key"], "empty") def test_proxy_passthrough(self): """Should pass through openai_proxy if configured.""" result = self._openai_params_fn( - {'model': 'MiniMax-M2.5'}, - {'openai_api_key': 'key', 'openai_api_base': 'https://api.minimax.io/v1', - 'openai_proxy': 'http://proxy:8080'}, - {} + {"model": "MiniMax-M3"}, + { + "openai_api_key": "key", + "openai_api_base": "https://api.minimax.io/v1", + "openai_proxy": "http://proxy:8080", + }, + {}, ) - self.assertEqual(result['openai_proxy'], 'http://proxy:8080') + self.assertEqual(result["openai_proxy"], "http://proxy:8080") def test_no_url_appending(self): """Should NOT append /chat/completions to the URL.""" result = self._openai_params_fn( - {'model': 'MiniMax-M2.5'}, - {'openai_api_key': 'key', 'openai_api_base': 'https://api.minimax.io/v1'}, - {} + {"model": "MiniMax-M3"}, {"openai_api_key": "key", "openai_api_base": "https://api.minimax.io/v1"}, {} ) - self.assertNotIn('/chat/completions', result['base_url']) + self.assertNotIn("/chat/completions", result["base_url"]) class TestMiniMaxIntegration(unittest.TestCase): """Integration tests for MiniMax provider (require MINIMAX_API_KEY).""" - @unittest.skipUnless( - os.environ.get('MINIMAX_API_KEY'), - 'MINIMAX_API_KEY not set' - ) - def test_minimax_m27_chat_completion(self): - """Test actual chat completion with MiniMax-M2.7 via OpenAI-compatible API.""" + @unittest.skipUnless(os.environ.get("MINIMAX_API_KEY"), "MINIMAX_API_KEY not set") + def test_minimax_m3_chat_completion(self): + """Test actual chat completion with MiniMax-M3 via OpenAI-compatible API.""" from langchain_openai import ChatOpenAI llm = ChatOpenAI( - model='MiniMax-M2.7', - api_key=os.environ['MINIMAX_API_KEY'], - base_url='https://api.minimax.io/v1', + model="MiniMax-M3", + api_key=os.environ["MINIMAX_API_KEY"], + base_url="https://api.minimax.io/v1", temperature=0.1, max_tokens=64, ) - response = llm.invoke('Say hello in one word.') + response = llm.invoke("Say hello in one word.") self.assertIsNotNone(response.content) self.assertTrue(len(response.content) > 0) - @unittest.skipUnless( - os.environ.get('MINIMAX_API_KEY'), - 'MINIMAX_API_KEY not set' - ) - def test_minimax_m27_highspeed_chat_completion(self): - """Test actual chat completion with MiniMax-M2.7-highspeed.""" + @unittest.skipUnless(os.environ.get("MINIMAX_API_KEY"), "MINIMAX_API_KEY not set") + def test_minimax_m3_streaming(self): + """Test streaming chat completion with MiniMax-M3.""" from langchain_openai import ChatOpenAI llm = ChatOpenAI( - model='MiniMax-M2.7-highspeed', - api_key=os.environ['MINIMAX_API_KEY'], - base_url='https://api.minimax.io/v1', + model="MiniMax-M3", + api_key=os.environ["MINIMAX_API_KEY"], + base_url="https://api.minimax.io/v1", temperature=0.1, max_tokens=64, + streaming=True, ) - response = llm.invoke('Say hello in one word.') - self.assertIsNotNone(response.content) - self.assertTrue(len(response.content) > 0) + chunks = list(llm.stream("Say hello in one word.")) + self.assertTrue(len(chunks) > 0) + full_content = "".join(c.content for c in chunks) + self.assertTrue(len(full_content) > 0) - @unittest.skipUnless( - os.environ.get('MINIMAX_API_KEY'), - 'MINIMAX_API_KEY not set' - ) - def test_minimax_m27_streaming(self): - """Test streaming chat completion with MiniMax-M2.7.""" + @unittest.skipUnless(os.environ.get("MINIMAX_API_KEY"), "MINIMAX_API_KEY not set") + def test_minimax_m27_chat_completion(self): + """Test actual chat completion with MiniMax-M2.7 via OpenAI-compatible API.""" from langchain_openai import ChatOpenAI llm = ChatOpenAI( - model='MiniMax-M2.7', - api_key=os.environ['MINIMAX_API_KEY'], - base_url='https://api.minimax.io/v1', + model="MiniMax-M2.7", + api_key=os.environ["MINIMAX_API_KEY"], + base_url="https://api.minimax.io/v1", temperature=0.1, max_tokens=64, - streaming=True, ) - chunks = list(llm.stream('Say hello in one word.')) - self.assertTrue(len(chunks) > 0) - full_content = ''.join(c.content for c in chunks) - self.assertTrue(len(full_content) > 0) + response = llm.invoke("Say hello in one word.") + self.assertIsNotNone(response.content) + self.assertTrue(len(response.content) > 0) - @unittest.skipUnless( - os.environ.get('MINIMAX_API_KEY'), - 'MINIMAX_API_KEY not set' - ) - def test_minimax_m25_chat_completion(self): - """Test actual chat completion with MiniMax-M2.5 via OpenAI-compatible API.""" + @unittest.skipUnless(os.environ.get("MINIMAX_API_KEY"), "MINIMAX_API_KEY not set") + def test_minimax_m27_highspeed_chat_completion(self): + """Test actual chat completion with MiniMax-M2.7-highspeed.""" from langchain_openai import ChatOpenAI llm = ChatOpenAI( - model='MiniMax-M2.5', - api_key=os.environ['MINIMAX_API_KEY'], - base_url='https://api.minimax.io/v1', + model="MiniMax-M2.7-highspeed", + api_key=os.environ["MINIMAX_API_KEY"], + base_url="https://api.minimax.io/v1", temperature=0.1, max_tokens=64, ) - response = llm.invoke('Say hello in one word.') + response = llm.invoke("Say hello in one word.") self.assertIsNotNone(response.content) self.assertTrue(len(response.content) > 0) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/src/backend/test/llm/test_model_catalog.py b/src/backend/test/llm/test_model_catalog.py new file mode 100644 index 0000000000..bc0f8b70db --- /dev/null +++ b/src/backend/test/llm/test_model_catalog.py @@ -0,0 +1,238 @@ +"""F051 T008: the callable model catalog and name resolution. + +This module is the single source three consumers share (the model protocol +face, F052's list tool, F055's precheck), so the rules it encodes — what is +callable, what a name resolves to, and what happens when it cannot be decided — +are asserted here rather than at each consumer. +""" + +import pytest + +from bisheng.common.errcode.model_face import ( + ModelFaceCapabilityUndeclaredError, + ModelFaceCatalogUnavailableError, + ModelFaceModelAmbiguousError, + ModelFaceModelNotFoundError, + ModelFaceModelOfflineError, +) +from bisheng.common.services.config_service import settings +from bisheng.llm.domain.services import model_catalog +from bisheng.llm.domain.services.model_catalog import ( + ModelRange, + invalidate_catalog, + list_callable_chat_models, + resolve_model_name, +) +from test.open_api.model_gateway_fixtures import install_catalog, model_row, server_row + +TENANT = 9 + + +@pytest.fixture(autouse=True) +def _clean_catalog_cache(): + invalidate_catalog() + yield + invalidate_catalog() + + +def _default_rows(): + servers = [ + server_row(1, "azure-openai"), + server_row(2, "qwen-cloud"), + server_row(3, "root-shared"), + ] + models = [ + model_row(10, 1, "gpt-4o"), + model_row(11, 1, "shared-name"), + model_row(12, 2, "shared-name"), + model_row(13, 2, "qwen/qwen-2.5-72b"), + model_row(14, 1, "retired-model", online=False), + model_row(15, 1, "text-embedding-3", model_type="embedding"), + model_row(16, 3, "root-model"), + # Belongs to a provider this tenant cannot see. Deleting a provider + # deletes its models with it, so "not visible" is the only shape a + # foreign row can actually take. + model_row(17, 99, "another-tenants-model"), + ] + return servers, models + + +async def test_list_callable_only_llm_and_online(monkeypatch): + install_catalog(monkeypatch, *_default_rows()) + + names = {model.model_name for model in await list_callable_chat_models(TENANT)} + + assert "gpt-4o" in names + assert "retired-model" not in names + assert "text-embedding-3" not in names + assert "another-tenants-model" not in names + + +async def test_root_shared_server_models_are_callable_for_the_child(monkeypatch): + install_catalog(monkeypatch, *_default_rows()) + + resolved = await resolve_model_name(TENANT, "root-model") + + assert resolved.server_name == "root-shared" + + +async def test_exact_unique_name_resolves(monkeypatch): + install_catalog(monkeypatch, *_default_rows()) + + resolved = await resolve_model_name(TENANT, "gpt-4o") + + assert (resolved.model_id, resolved.server_name) == (10, "azure-openai") + assert resolved.qualified_name == "azure-openai/gpt-4o" + + +async def test_model_name_containing_a_slash_prefers_the_bare_match(monkeypatch): + install_catalog(monkeypatch, *_default_rows()) + + resolved = await resolve_model_name(TENANT, "qwen/qwen-2.5-72b") + + # Not read as "provider qwen, model qwen-2.5-72b": the bare name exists. + assert resolved.model_id == 13 + assert resolved.server_name == "qwen-cloud" + + +async def test_ambiguous_bare_name_is_refused_with_the_qualified_alternatives(monkeypatch): + install_catalog(monkeypatch, *_default_rows()) + + with pytest.raises(ModelFaceModelAmbiguousError) as excinfo: + await resolve_model_name(TENANT, "shared-name") + + assert excinfo.value.code == 26514 + assert excinfo.value.candidates == ["azure-openai/shared-name", "qwen-cloud/shared-name"] + + +async def test_qualified_name_resolves_for_ambiguous_and_unique_rows(monkeypatch): + install_catalog(monkeypatch, *_default_rows()) + + ambiguous = await resolve_model_name(TENANT, "qwen-cloud/shared-name") + unique = await resolve_model_name(TENANT, "azure-openai/gpt-4o") + + assert ambiguous.model_id == 12 + assert unique.model_id == 10 + + +async def test_offline_is_distinguishable_from_missing(monkeypatch): + install_catalog(monkeypatch, *_default_rows()) + + with pytest.raises(ModelFaceModelOfflineError) as offline: + await resolve_model_name(TENANT, "retired-model") + with pytest.raises(ModelFaceModelNotFoundError) as missing: + await resolve_model_name(TENANT, "never-configured") + + assert (offline.value.code, missing.value.code) == (26512, 26511) + + +async def test_another_tenants_model_reads_as_missing_not_as_revoked(monkeypatch): + install_catalog(monkeypatch, *_default_rows()) + + with pytest.raises(ModelFaceModelNotFoundError) as excinfo: + await resolve_model_name(TENANT, "another-tenants-model") + + # "Not yours" and "does not exist" must be one answer, or the face becomes + # an existence oracle for other tenants' configuration (AC-13). + assert excinfo.value.code == 26511 + + +async def test_a_deleted_provider_leaves_no_model_row_to_explain(monkeypatch): + # Deleting a provider deletes its models (LLMDao.adelete_server_by_id), so + # resolution cannot report "revoked" — there is nothing left to find. 26513 + # is raised one step later instead, when the cached name still resolves and + # instantiation finds the row gone; that path is asserted in + # test_model_gateway_stream.py. + servers, models = _default_rows() + install_catalog(monkeypatch, [server for server in servers if server.id != 1], models) + + with pytest.raises(ModelFaceModelNotFoundError): + await resolve_model_name(TENANT, "gpt-4o") + + +async def test_non_chat_model_reads_as_not_found_without_disclosing_its_type(monkeypatch): + install_catalog(monkeypatch, *_default_rows()) + + with pytest.raises(ModelFaceModelNotFoundError) as excinfo: + await resolve_model_name(TENANT, "text-embedding-3") + + assert "embedding" not in excinfo.value.message.lower().replace("text-embedding-3", "") + + +async def test_list_offers_qualified_names_only_for_ambiguous_rows(monkeypatch): + install_catalog(monkeypatch, *_default_rows()) + + callable_names = {model.callable_name for model in await list_callable_chat_models(TENANT)} + + assert "gpt-4o" in callable_names + assert "shared-name" not in callable_names + assert {"azure-openai/shared-name", "qwen-cloud/shared-name"} <= callable_names + + +async def test_every_listed_name_actually_resolves(monkeypatch): + install_catalog(monkeypatch, *_default_rows()) + + for model in await list_callable_chat_models(TENANT): + resolved = await resolve_model_name(TENANT, model.callable_name) + assert resolved.model_id == model.model_id + + +async def test_catalog_ttl_is_capped_at_sixty_seconds(monkeypatch): + monkeypatch.setattr(settings.open_api, "model_catalog_ttl_seconds", 61) + + assert settings.open_api.model_catalog_ttl_seconds == 61 + # The clamp lives on the settings validator, so a value assigned directly in + # a test bypasses it; what matters is the cache never outlives the bound. + monkeypatch.setattr(settings.open_api, "model_catalog_ttl_seconds", 60) + invalidate_catalog() + assert model_catalog._cache().ttl <= 60 + + +async def test_permission_failure_refuses_instead_of_narrowing(monkeypatch): + servers, models = _default_rows() + install_catalog(monkeypatch, servers, models, fail_with=RuntimeError("openfga unreachable")) + + with pytest.raises(ModelFaceCatalogUnavailableError) as excinfo: + await list_callable_chat_models(TENANT) + + assert excinfo.value.code == 26516 + assert excinfo.value.http_status == 503 + + +async def test_a_failed_read_is_not_cached(monkeypatch): + servers, models = _default_rows() + install_catalog(monkeypatch, servers, models, fail_with=RuntimeError("openfga unreachable")) + with pytest.raises(ModelFaceCatalogUnavailableError): + await list_callable_chat_models(TENANT) + + install_catalog(monkeypatch, servers, models) + + assert await list_callable_chat_models(TENANT) + + +async def test_declared_range_refuses_an_undeclared_model(monkeypatch): + install_catalog(monkeypatch, *_default_rows()) + declared = ModelRange(kind="declared", declared=frozenset({"gpt-4o"})) + + assert (await resolve_model_name(TENANT, "gpt-4o", range=declared)).model_id == 10 + with pytest.raises(ModelFaceCapabilityUndeclaredError) as excinfo: + await resolve_model_name(TENANT, "root-model", range=declared) + + assert excinfo.value.code == 26515 + + +async def test_offline_beats_undeclared_so_the_owner_knows_which_fix_applies(monkeypatch): + install_catalog(monkeypatch, *_default_rows()) + declared = ModelRange(kind="declared", declared=frozenset({"retired-model"})) + + # Declared, but an administrator took it offline: "revoked", not "you forgot + # to declare it" — the two have different owners and different fixes. + with pytest.raises(ModelFaceModelOfflineError): + await resolve_model_name(TENANT, "retired-model", range=declared) + + +async def test_declared_range_accepts_the_qualified_spelling(monkeypatch): + install_catalog(monkeypatch, *_default_rows()) + declared = ModelRange(kind="declared", declared=frozenset({"azure-openai/gpt-4o"})) + + assert (await resolve_model_name(TENANT, "gpt-4o", range=declared)).model_id == 10 diff --git a/src/backend/test/llm/test_model_invoke_cancel.py b/src/backend/test/llm/test_model_invoke_cancel.py new file mode 100644 index 0000000000..e91d410fac --- /dev/null +++ b/src/backend/test/llm/test_model_invoke_cancel.py @@ -0,0 +1,74 @@ +"""A cancelled model call (probe timeout, closed request) must not be recorded +as a success, and must not mark the model normal on the way out.""" + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from bisheng.common.constants.enums.telemetry import StatusEnum +from bisheng.llm.domain import utils as llm_utils +from bisheng.llm.domain.const import LLMModelStatus + + +def _fake_model(): + return SimpleNamespace(model_info=SimpleNamespace(model_type="llm"), update_model_status=AsyncMock()) + + +@pytest.fixture +def telemetry(): + upload = MagicMock() + with ( + patch.object(llm_utils, "bisheng_model_limit_check", AsyncMock()), + patch.object(llm_utils, "upload_telemetry_log", upload), + ): + yield upload + + +async def test_cancelled_call_is_recorded_as_failed(telemetry): + started = asyncio.Event() + + @llm_utils.wrapper_bisheng_model_limit_check_async + async def hang(self): + started.set() + await asyncio.sleep(3600) + + model = _fake_model() + task = asyncio.create_task(hang(model)) + await started.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert telemetry.call_args.args[4] == StatusEnum.FAILED + model.update_model_status.assert_not_awaited() + + +async def test_failed_call_names_the_model_in_the_error(telemetry): + @llm_utils.wrapper_bisheng_model_limit_check_async + async def fail(self): + raise RuntimeError("Model not exist.") + + model = _fake_model() + model.model_info.model_name = "qwen3-vl-flash" + with pytest.raises(RuntimeError) as info: + await fail(model) + + assert str(info.value) == "model=qwen3-vl-flash: Model not exist." + model.update_model_status.assert_awaited_once_with( + LLMModelStatus.ERROR.value, + "model=qwen3-vl-flash: Model not exist.", + ) + assert telemetry.call_args.args[4] == StatusEnum.FAILED + + +async def test_successful_call_still_marks_the_model_normal(telemetry): + @llm_utils.wrapper_bisheng_model_limit_check_async + async def ok(self): + return "hi" + + model = _fake_model() + assert await ok(model) == "hi" + assert telemetry.call_args.args[4] == StatusEnum.SUCCESS + model.update_model_status.assert_awaited_once_with(LLMModelStatus.NORMAL.value, "") diff --git a/src/backend/test/llm/test_model_status_credit_rejection.py b/src/backend/test/llm/test_model_status_credit_rejection.py new file mode 100644 index 0000000000..44cfb2d603 --- /dev/null +++ b/src/backend/test/llm/test_model_status_credit_rejection.py @@ -0,0 +1,48 @@ +"""A per-user platform credit rejection must not mark the shared model as broken. + +The client module (DSH) rejects a call before it reaches the provider when this +one user is out of credits. Flipping the model to "abnormal" would show every +user a broken model that the next probe immediately flips back. +""" + +import pytest + +from bisheng.common.errcode.dsh import DshModelNotAllowedError, DshMonthlyTokenLimitExceededError +from test.llm.test_bisheng_llm_context_overflow import MESSAGES, _wrapper +from test.llm.test_bisheng_llm_context_overflow import status_writes as status_writes + +REJECTIONS = [DshMonthlyTokenLimitExceededError, DshModelNotAllowedError] + + +@pytest.mark.parametrize("error", REJECTIONS) +@pytest.mark.parametrize("streaming", [True, False]) +def test_generate_keeps_model_status(status_writes, streaming, error): + inst = _wrapper(error(), streaming, status_writes) + with pytest.raises(error): + inst._generate(MESSAGES) + assert status_writes == [] + + +@pytest.mark.parametrize("error", REJECTIONS) +async def test_agenerate_keeps_model_status(status_writes, error): + inst = _wrapper(error(), False, status_writes) + with pytest.raises(error): + await inst._agenerate(MESSAGES) + assert status_writes == [] + + +@pytest.mark.parametrize("error", REJECTIONS) +def test_stream_keeps_model_status(status_writes, error): + inst = _wrapper(error(), True, status_writes) + with pytest.raises(error): + list(inst._stream(MESSAGES)) + assert status_writes == [] + + +@pytest.mark.parametrize("error", REJECTIONS) +async def test_astream_keeps_model_status(status_writes, error): + inst = _wrapper(error(), True, status_writes) + with pytest.raises(error): + async for _ in inst._astream(MESSAGES): + pass + assert status_writes == [] diff --git a/src/backend/test/llm/test_model_status_verify.py b/src/backend/test/llm/test_model_status_verify.py index 5b52d4de8e..4d590deae5 100644 --- a/src/backend/test/llm/test_model_status_verify.py +++ b/src/backend/test/llm/test_model_status_verify.py @@ -221,10 +221,93 @@ async def record_cache_state(*args, **kwargs): p3, p4, p5, + patch("bisheng.llm.domain.services.llm.LLMDao.aupdate_model_status", AsyncMock()), patch.object(LLMService, "test_model_status", record_cache_state), ): await LLMService.update_llm_server(MagicMock(), _login_user(), self._server_req()) + await _drain_background_probes() # The probe ran (the name changed) and saw neither stale row. assert seen["model_cached_at_probe_time"] is None assert seen["server_cached_at_probe_time"] is None + + +async def _drain_background_probes(): + from bisheng.llm.domain.services import llm as llm_service_module + + pending = list(llm_service_module._background_probes) + if pending: + await asyncio.gather(*pending) + + +class TestSaveDoesNotWaitForTheProbe: + """One slow provider response used to hold the save request for the whole + probe timeout. Save marks the changed models unknown and returns; the probe + finishes in the background.""" + + async def test_save_returns_while_the_probe_is_still_running(self): + from bisheng.llm.domain.const import LLMModelStatus + from bisheng.llm.domain.models.llm_server import LLMServer + from bisheng.llm.domain.schemas import LLMModelInfo, LLMServerInfo + + db_server = LLMServer(id=1, name="provider", type="openai", config={}, tenant_id=2) + renamed = LLMModelInfo( + id=7, server_id=1, name="model 1", model_name="gpt-x-renamed", model_type="llm", status=0 + ) + new_server_info = LLMServerInfo(id=1, name="provider", type="openai", models=[renamed]) + release_probe = asyncio.Event() + probed = [] + + async def slow_probe(model, login_user): + await release_probe.wait() + probed.append(model.id) + + status_writes = AsyncMock() + p1, p2, p3, p4, p5 = TestSaveDefeatsTheInfoCache()._patches(db_server, new_server_info, [_model()]) + with ( + p1, + p2, + p3, + p4, + p5, + patch("bisheng.llm.domain.services.llm.LLMDao.aupdate_model_status", status_writes), + patch.object(LLMService, "test_model_status", slow_probe), + ): + ret = await asyncio.wait_for( + LLMService.update_llm_server(MagicMock(), _login_user(), TestSaveDefeatsTheInfoCache()._server_req()), + timeout=1, + ) + # Returned before the probe finished, with the stale verdict cleared. + assert probed == [] + assert ret.models[0].status == LLMModelStatus.UNKNOWN.value + status_writes.assert_awaited_once_with(7, LLMModelStatus.UNKNOWN.value, "") + + release_probe.set() + await _drain_background_probes() + assert probed == [7] + + async def test_unchanged_models_are_not_probed(self): + from bisheng.llm.domain.models.llm_server import LLMServer + from bisheng.llm.domain.schemas import LLMModelInfo, LLMServerInfo + + db_server = LLMServer(id=1, name="provider", type="openai", config={}, tenant_id=2) + same = LLMModelInfo(id=7, server_id=1, name="model 1", model_name="gpt-x", model_type="llm", status=0) + new_server_info = LLMServerInfo(id=1, name="provider", type="openai", models=[same]) + req = TestSaveDefeatsTheInfoCache()._server_req() + req.models[0].model_name = "gpt-x" + probe = AsyncMock() + status_writes = AsyncMock() + p1, p2, p3, p4, p5 = TestSaveDefeatsTheInfoCache()._patches(db_server, new_server_info, [_model()]) + with ( + p1, + p2, + p3, + p4, + p5, + patch("bisheng.llm.domain.services.llm.LLMDao.aupdate_model_status", status_writes), + patch.object(LLMService, "test_model_status", probe), + ): + await LLMService.update_llm_server(MagicMock(), _login_user(), req) + await _drain_background_probes() + probe.assert_not_awaited() + status_writes.assert_not_awaited() diff --git a/src/backend/test/llm/test_openai_compatible_provider.py b/src/backend/test/llm/test_openai_compatible_provider.py new file mode 100644 index 0000000000..6062bc79dd --- /dev/null +++ b/src/backend/test/llm/test_openai_compatible_provider.py @@ -0,0 +1,75 @@ +"""The generic `openai_compatible` provider type: any server speaking the OpenAI API.""" + +import httpx +from langchain_core.messages import HumanMessage +from langchain_openai import OpenAIEmbeddings + +from bisheng.core.ai import CommonRerank +from bisheng.core.ai.llm.chat_openai_compatible import ChatOpenAICompatible +from bisheng.llm.domain.const import LLMServerType +from bisheng.llm.domain.llm import embedding as embedding_module +from bisheng.llm.domain.llm import llm as llm_module +from bisheng.llm.domain.llm import rerank as rerank_module +from bisheng.llm.domain.services.context_window import ModelSnapshot +from bisheng.llm.domain.services.context_window_probes import PROBES, ProbeResult, probe_context_window + +TYPE = LLMServerType.OPENAI_COMPATIBLE.value +SERVER_CONFIG = {"openai_api_base": "https://gateway.example.com/v1/", "openai_api_key": "sk-test"} + + +def test_type_value_fits_the_server_type_column(): + # llm_server.type is CHAR(20) + assert TYPE == "openai_compatible" + assert len(TYPE) <= 20 + + +def test_llm_uses_compatible_client_and_sends_max_tokens(): + adapter = llm_module._llm_node_type[TYPE] + assert adapter["client"] is ChatOpenAICompatible + + params = adapter["params_handler"]({"model": "my-model"}, SERVER_CONFIG, {}) + assert params["base_url"] == "https://gateway.example.com/v1" + assert params["api_key"] == "sk-test" + + llm = adapter["client"](**params) + payload = llm._get_request_payload([HumanMessage(content="hi")], max_completion_tokens=256) + assert payload["max_tokens"] == 256 + assert "max_completion_tokens" not in payload + + +def test_llm_api_key_is_optional(): + params = llm_module._llm_node_type[TYPE]["params_handler"]( + {"model": "my-model"}, {"openai_api_base": "http://10.0.0.1:8000/v1"}, {} + ) + assert params["api_key"] == "empty" + + +def test_embedding_and_rerank_are_registered(): + assert embedding_module._node_type[TYPE]["client"] is OpenAIEmbeddings + assert rerank_module._node_type[TYPE]["client"] is CommonRerank + + params = rerank_module._node_type[TYPE]["params_handler"]({"model": "bge-reranker"}, SERVER_CONFIG, {}) + assert params["base_url"] == "https://gateway.example.com/v1/" + assert params["rerank_endpoint"] == "/rerank" + + +async def test_context_window_probe_reads_models_endpoint(): + assert TYPE in PROBES + snapshot = ModelSnapshot( + model_id=1, + model_name="my-model", + server_type=TYPE, + model_config={}, + client_params={"base_url": "https://gateway.example.com/v1", "api_key": "sk-test"}, + ) + body = {"object": "list", "data": [{"id": "my-model", "object": "model", "max_model_len": 32768}]} + + def handler(request: httpx.Request) -> httpx.Response: + if str(request.url) == "https://gateway.example.com/v1/models": + return httpx.Response(200, json=body) + return httpx.Response(404, json={}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + result = await probe_context_window(snapshot, client) + + assert result == ProbeResult(window=32768, kind="total", source="deployed") diff --git a/src/backend/test/llm/test_qwen_embedding_provider.py b/src/backend/test/llm/test_qwen_embedding_provider.py new file mode 100644 index 0000000000..8de3d16eb3 --- /dev/null +++ b/src/backend/test/llm/test_qwen_embedding_provider.py @@ -0,0 +1,329 @@ +"""Qwen embedding configuration and real SDK requests with offline transports.""" + +import json +from copy import deepcopy +from types import SimpleNamespace + +import httpx +import numpy as np +import openai +import pytest +from langchain_openai import AzureOpenAIEmbeddings, OpenAIEmbeddings + +from bisheng.common.constants.enums.telemetry import ApplicationTypeEnum +from bisheng.llm.domain import utils as model_utils +from bisheng.llm.domain.llm import embedding as embedding_module +from bisheng.llm.domain.llm.embedding import BishengEmbedding, _get_openai_params, _get_qwen_params +from bisheng.llm.domain.models import LLMModel, LLMServer + +BASE_URL = "https://gateway.example.test/custom/compatible-mode/v1" +MODEL_BATCHES = [ + ("text-embedding-v1", 25), + ("text-embedding-v2", 25), + ("text-embedding-v3", 10), + ("text-embedding-v4", 10), + ("qwen3.7-text-embedding", 20), + ("qwen3.7-text-embedding-flash", 20), + ("gateway-embedding-alias", 10), + ("text-embedding-v4-unrecognized-suffix", 10), +] + + +@pytest.mark.parametrize( + "config,expected_url", + [ + ({"openai_api_base": BASE_URL + "///", "base_url": "https://ignored.test/v1"}, BASE_URL), + ({"base_url": BASE_URL + "/"}, BASE_URL), + ({"openai_api_base": None, "base_url": BASE_URL}, BASE_URL), + ({"openai_api_base": "", "base_url": BASE_URL}, BASE_URL), + ({}, "https://dashscope.aliyuncs.com/compatible-mode/v1"), + ({"openai_api_base": "", "base_url": None}, "https://dashscope.aliyuncs.com/compatible-mode/v1"), + ({"openai_api_base": None, "base_url": ""}, "https://dashscope.aliyuncs.com/compatible-mode/v1"), + (None, "https://dashscope.aliyuncs.com/compatible-mode/v1"), + ], +) +def test_base_url_priority_and_empty_values(config, expected_url): + """AC-QE-03, AC-QE-04: provider URL precedence and missing-value handling.""" + params = _get_qwen_params({"model": "text-embedding-v4"}, config, {}) + assert params["base_url"] == expected_url + + +@pytest.mark.parametrize("as_json", [False, True]) +@pytest.mark.parametrize("legacy_key", [False, True]) +def test_provider_fields_win_and_advanced_config_is_not_mutated(as_json, legacy_key): + """AC-QE-05, AC-QE-06: provider identity wins without changing stored config.""" + advanced = { + "model": "ignored-model", + "api_key": "ignored-key", + "openai_api_key": "ignored-alias-key", + "base_url": "https://ignored.test/v1", + "openai_api_base": "https://ignored-alias.test/v1", + "dimensions": 256, + "request_timeout": 7, + "max_retries": 2, + "chunk_size": 3, + "model_kwargs": {"extra_body": {"custom_option": True}}, + } + model_config = {"user_kwargs": json.dumps(advanced) if as_json else advanced} + server_config = {"openai_api_base": BASE_URL, "api_key" if legacy_key else "openai_api_key": "provider-key"} + defaults = {"model": "text-embedding-v4"} + before = deepcopy((defaults, server_config, model_config)) + + result = _get_qwen_params(defaults, server_config, model_config) + + assert (defaults, server_config, model_config) == before + assert result["model"] == "text-embedding-v4" + assert result["api_key"] == "provider-key" + assert result["base_url"] == BASE_URL + assert not {"dashscope_api_key", "openai_api_key", "openai_api_base"} & result.keys() + assert result["dimensions"] == 256 + assert result["request_timeout"] == 7 + assert result["max_retries"] == 2 + assert result["chunk_size"] == 3 + assert result["check_embedding_ctx_length"] is False + assert result["model_kwargs"]["encoding_format"] == "float" + result["model_kwargs"]["extra_body"]["custom_option"] = False + assert (defaults, server_config, model_config) == before + + +def test_primary_api_key_wins_and_missing_key_keeps_existing_placeholder(): + defaults = {"model": "text-embedding-v4"} + assert _get_qwen_params(defaults, {"openai_api_key": "primary", "api_key": "legacy"}, {})["api_key"] == "primary" + assert _get_qwen_params(defaults, {}, {})["api_key"] == "empty" + + +@pytest.mark.parametrize("proxy", [" http://proxy.example.test:8080 ", "admin", "", None]) +def test_proxy_uses_existing_validation(proxy): + params = _get_qwen_params({"model": "text-embedding-v4"}, {"openai_proxy": proxy}, {}) + if proxy and proxy.startswith(" "): + assert params["openai_proxy"] == proxy.strip() + else: + assert "openai_proxy" not in params + + +@pytest.mark.parametrize("model,batch_size", MODEL_BATCHES) +def test_default_batch_size_depends_on_exact_model_name(model, batch_size): + """AC-QE-07, AC-QE-14: exact model names select limits; aliases default to ten.""" + assert _get_qwen_params({"model": model}, {}, {})["chunk_size"] == batch_size + + +@pytest.mark.parametrize("chunk_size", [True, False, "5", None, 0, -1, 1.5]) +@pytest.mark.parametrize("model", ["text-embedding-v4", "gateway-embedding-alias"]) +def test_invalid_batch_sizes_fail_before_sdk_construction(model, chunk_size): + """AC-QE-13, AC-QE-14: invalid values never reach a provider request.""" + with pytest.raises(ValueError, match=r"chunk_size.*positive integer"): + _get_qwen_params({"model": model}, {}, {"user_kwargs": {"chunk_size": chunk_size}}) + + +@pytest.mark.parametrize("model,batch_size", MODEL_BATCHES[:6]) +def test_known_models_reject_batches_above_limit(model, batch_size): + with pytest.raises(ValueError, match=f"{model!r} must not exceed {batch_size}"): + _get_qwen_params( + {"model": model}, {"openai_api_base": BASE_URL}, {"user_kwargs": {"chunk_size": batch_size + 1}} + ) + + +@pytest.mark.parametrize( + "advanced", + [{"encoding_format": "base64"}, {"model_kwargs": {"encoding_format": "base64"}}], +) +async def test_explicit_encoding_format_is_preserved(advanced): + params = _get_qwen_params({"model": "text-embedding-v4"}, {}, {"user_kwargs": advanced}) + with httpx.Client() as sync_http: + async with httpx.AsyncClient() as async_http: + client = OpenAIEmbeddings(**params, http_client=sync_http, http_async_client=async_http) + assert client._invocation_params["encoding_format"] == "base64" + + +def test_client_mapping_and_other_openai_provider_defaults(): + """AC-QE-01, AC-QE-11: only Qwen changes provider-specific defaults.""" + assert BishengEmbedding._get_embedding_class("qwen") is OpenAIEmbeddings + assert BishengEmbedding._get_embedding_class("azure_openai") is AzureOpenAIEmbeddings + for provider in ["openai", "xinference", "llamacpp", "vllm", "qianfan", "minimax", "zhipu", "tencent", "silicon"]: + assert BishengEmbedding._get_embedding_class(provider) is OpenAIEmbeddings + assert embedding_module._node_type[provider]["params_handler"] is _get_openai_params + params = _get_openai_params({"model": "other-model"}, {"base_url": BASE_URL, "api_key": "other-key"}, {}) + assert "chunk_size" not in params + assert "model_kwargs" not in params + + +def raw_vector(text): + return [float(text.split(":")[-1]) + 1, 1.0] + + +@pytest.fixture() +async def sdk_factory(monkeypatch): + """Keep the real wrapper, LangChain and SDK; replace HTTP and external effects.""" + state = SimpleNamespace(requests=[], status=200, connection_error=False, constructions=0) + + def respond(request): + state.requests.append(request) + if state.connection_error: + raise httpx.ConnectError("offline gateway", request=request) + if state.status != 200: + return httpx.Response( + state.status, json={"error": {"message": "provider rejected request", "type": "error"}} + ) + body = json.loads(request.content) + return httpx.Response( + 200, + json={ + "object": "list", + "model": body["model"], + "data": [ + {"object": "embedding", "index": i, "embedding": raw_vector(text)} + for i, text in enumerate(body["input"]) + ], + "usage": {"prompt_tokens": len(body["input"]), "total_tokens": len(body["input"])}, + }, + ) + + monkeypatch.setattr(model_utils, "sync_bisheng_model_limit_check", lambda model: None) + monkeypatch.setattr(model_utils, "upload_telemetry_log", lambda *args, **kwargs: None) + monkeypatch.setattr(BishengEmbedding, "sync_update_model_status", lambda *args: None) + with httpx.Client(transport=httpx.MockTransport(respond)) as sync_http: + async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as async_http: + + def construct(**params): + state.constructions += 1 + return OpenAIEmbeddings(**params, http_client=sync_http, http_async_client=async_http) + + monkeypatch.setitem(embedding_module._node_type["qwen"], "client", construct) + + def build(model_name="text-embedding-v4", advanced=None, server_config=None): + server = LLMServer( + id=1, + name="Qwen fixture", + type="qwen", + config=server_config or {"openai_api_base": BASE_URL + "/", "openai_api_key": "fixture-key"}, + ) + model = LLMModel( + id=2, + server_id=1, + model_name=model_name, + model_type="embedding", + online=True, + config={"user_kwargs": {"max_retries": 0, **(advanced or {})}}, + ) + return BishengEmbedding( + model_id=2, + model_info=model, + server_info=server, + app_id="fixture", + app_type=ApplicationTypeEnum.MODEL_TEST, + app_name="fixture", + user_id=1, + ) + + yield build, state + + +@pytest.mark.parametrize("model,batch_size", MODEL_BATCHES) +@pytest.mark.parametrize("full_batches", [0, 1, 2]) +@pytest.mark.parametrize("mode", ["sync", "wrapper_async", "sdk_async"]) +async def test_real_sdk_splits_batches_and_preserves_input_order(sdk_factory, model, batch_size, full_batches, mode): + """AC-QE-02, AC-QE-07, AC-QE-08, AC-QE-09, AC-QE-15: HTTP payload and splitting.""" + build, state = sdk_factory + embedding = build(model, {"dimensions": 2}) + count = batch_size * full_batches + 1 if full_batches else batch_size + texts = [f"sample:{i}" for i in range(count)] + if mode == "sync": + vectors = embedding.embed_documents(texts) + elif mode == "wrapper_async": + vectors = await embedding.aembed_documents(texts) + else: + vectors = await embedding.embeddings.aembed_documents(texts) + + bodies = [json.loads(request.content) for request in state.requests] + expected_batches = [batch_size] * full_batches + [1] if full_batches else [batch_size] + assert [len(body["input"]) for body in bodies] == expected_batches + assert [text for body in bodies for text in body["input"]] == texts + for request, body in zip(state.requests, bodies, strict=True): + assert request.method == "POST" + assert str(request.url) == BASE_URL + "/embeddings" + assert request.headers["Authorization"] == "Bearer fixture-key" + assert body == {"model": model, "input": body["input"], "dimensions": 2, "encoding_format": "float"} + expected = np.array([raw_vector(text) for text in texts]) + if mode != "sdk_async": + expected = expected / np.linalg.norm(expected, axis=1, keepdims=True) + np.testing.assert_allclose(vectors, expected) + + +@pytest.mark.parametrize("mode", ["sync", "wrapper_async", "sdk_async"]) +async def test_query_reaches_configured_endpoint_and_normalizes(sdk_factory, mode): + build, state = sdk_factory + embedding = build() + if mode == "sync": + vector = embedding.embed_query("sample:7") + elif mode == "wrapper_async": + vector = await embedding.aembed_query("sample:7") + else: + vector = await embedding.embeddings.aembed_query("sample:7") + assert len(state.requests) == 1 + assert str(state.requests[0].url) == BASE_URL + "/embeddings" + assert json.loads(state.requests[0].content)["input"] == ["sample:7"] + expected = np.array(raw_vector("sample:7")) + if mode != "sdk_async": + expected = expected / np.linalg.norm(expected) + np.testing.assert_allclose(vector, expected) + + +@pytest.mark.parametrize("model,chunk_size", [("text-embedding-v4", 3), ("gateway-embedding-alias", 30)]) +async def test_explicit_batch_size_controls_real_requests(sdk_factory, model, chunk_size): + build, state = sdk_factory + embedding = build(model, {"chunk_size": chunk_size}) + await embedding.aembed_documents([f"sample:{i}" for i in range(chunk_size + 1)]) + assert [len(json.loads(request.content)["input"]) for request in state.requests] == [chunk_size, 1] + + +async def test_over_limit_fails_before_client_construction(sdk_factory): + build, state = sdk_factory + with pytest.raises(ValueError, match="must not exceed 10"): + build(advanced={"chunk_size": 11}) + assert state.constructions == 0 + assert state.requests == [] + + +async def test_provider_alias_conflicts_cannot_override_actual_request(sdk_factory): + build, state = sdk_factory + embedding = build( + advanced={ + "model": "ignored-model", + "api_key": "ignored-key", + "openai_api_key": "ignored-alias-key", + "base_url": "https://ignored.test/v1", + "openai_api_base": "https://ignored-alias.test/v1", + } + ) + await embedding.aembed_query("sample:0") + assert str(state.requests[0].url) == BASE_URL + "/embeddings" + assert state.requests[0].headers["Authorization"] == "Bearer fixture-key" + assert json.loads(state.requests[0].content)["model"] == "text-embedding-v4" + + +async def test_missing_url_uses_qwen_endpoint_in_actual_request(sdk_factory): + build, state = sdk_factory + embedding = build(server_config={"openai_api_key": "fixture-key", "openai_api_base": None}) + await embedding.aembed_query("sample:0") + assert str(state.requests[0].url) == "https://dashscope.aliyuncs.com/compatible-mode/v1/embeddings" + + +@pytest.mark.parametrize("failure", [400, 401, 404, 429, 500, "connection"]) +@pytest.mark.parametrize("mode", ["sync", "async"]) +async def test_provider_failure_is_reported_without_endpoint_fallback_or_batch_retry(sdk_factory, failure, mode): + """AC-QE-10, AC-QE-14: failures keep the configured endpoint and batch size.""" + build, state = sdk_factory + if failure == "connection": + state.connection_error = True + else: + state.status = failure + embedding = build("gateway-embedding-alias", {"chunk_size": 30}) + texts = [f"sample:{i}" for i in range(31)] + with pytest.raises(openai.APIError): + if mode == "sync": + embedding.embed_documents(texts) + else: + await embedding.aembed_documents(texts) + assert len(state.requests) == 1 + assert str(state.requests[0].url) == BASE_URL + "/embeddings" + assert len(json.loads(state.requests[0].content)["input"]) == 30 diff --git a/src/backend/test/llm/test_qwen_provider.py b/src/backend/test/llm/test_qwen_provider.py index 4c756cfab5..b093a5c3c7 100644 --- a/src/backend/test/llm/test_qwen_provider.py +++ b/src/backend/test/llm/test_qwen_provider.py @@ -1,9 +1,8 @@ -import ast -import json import os import unittest from bisheng.core.ai import ChatOpenAICompatible +from bisheng.llm.domain.llm.llm import _get_qwen_params class TestQwenProvider(unittest.TestCase): @@ -12,8 +11,9 @@ def setUp(self): @unittest.skipUnless(os.environ.get("QWEN_API_KEY"), "QWEN_API_KEY not set") def test_qwen35(self): - llm = ChatOpenAICompatible(api_key=self.api_key, model="qwen3.5-plus", - base_url="https://dashscope.aliyuncs.com/compatible-mode/v1") + llm = ChatOpenAICompatible( + api_key=self.api_key, model="qwen3.5-plus", base_url="https://dashscope.aliyuncs.com/compatible-mode/v1" + ) result = llm.invoke("hello") print(result) @@ -21,59 +21,29 @@ def test_qwen35(self): class TestQwenParamsFunction(unittest.TestCase): """Unit tests for Qwen provider parameter assembly.""" - @classmethod - def setUpClass(cls): - llm_py_path = os.path.join( - os.path.dirname(__file__), '..', '..', 'bisheng', 'llm', 'domain', 'llm', 'llm.py' - ) - llm_py_path = os.path.normpath(llm_py_path) - with open(llm_py_path) as f: - source = f.read() - - tree = ast.parse(source) - funcs = {} - for node in ast.walk(tree): - if isinstance(node, ast.FunctionDef) and node.name in ( - '_get_user_kwargs', - '_get_openai_params', - '_get_qwen_params', - ): - funcs[node.name] = ast.get_source_segment(source, node) - - combined = '\n\n'.join([ - funcs['_get_user_kwargs'], - funcs['_get_openai_params'], - funcs['_get_qwen_params'], - ]) - exec_globals = {'json': json} - exec(compile(combined, '', 'exec'), exec_globals) - cls._qwen_params_fn = staticmethod(exec_globals['_get_qwen_params']) + _qwen_params_fn = staticmethod(_get_qwen_params) def test_uses_frontend_configured_base_url(self): result = self._qwen_params_fn( - {'model': 'qwen-plus'}, + {"model": "qwen-plus"}, { - 'openai_api_key': 'key', - 'openai_api_base': 'https://proxy.example.com/compatible-mode/v1/', + "openai_api_key": "key", + "openai_api_base": "https://proxy.example.com/compatible-mode/v1/", }, - {} + {}, ) - self.assertEqual(result['base_url'], 'https://proxy.example.com/compatible-mode/v1') + self.assertEqual(result["base_url"], "https://proxy.example.com/compatible-mode/v1") def test_falls_back_to_dashscope_default(self): - result = self._qwen_params_fn( - {'model': 'qwen-plus'}, - {'openai_api_key': 'key'}, - {} - ) - self.assertEqual(result['base_url'], 'https://dashscope.aliyuncs.com/compatible-mode/v1') + result = self._qwen_params_fn({"model": "qwen-plus"}, {"openai_api_key": "key"}, {}) + self.assertEqual(result["base_url"], "https://dashscope.aliyuncs.com/compatible-mode/v1") def test_preserves_qwen_extra_body_flags(self): result = self._qwen_params_fn( - {'model': 'qwen-plus', 'streaming': True, 'model_kwargs': {'enable_thinking': True}}, - {'openai_api_key': 'key'}, - {'enable_web_search': True} + {"model": "qwen-plus", "streaming": True, "model_kwargs": {"enable_thinking": True}}, + {"openai_api_key": "key"}, + {"enable_web_search": True}, ) - self.assertTrue(result['extra_body']['enable_search']) - self.assertTrue(result['extra_body']['incremental_output']) - self.assertTrue(result['extra_body']['enable_thinking']) + self.assertTrue(result["extra_body"]["enable_search"]) + self.assertTrue(result["extra_body"]["incremental_output"]) + self.assertTrue(result["extra_body"]["enable_thinking"]) diff --git a/src/backend/test/llm/test_realtime_asr.py b/src/backend/test/llm/test_realtime_asr.py new file mode 100644 index 0000000000..73a4eb6c6f --- /dev/null +++ b/src/backend/test/llm/test_realtime_asr.py @@ -0,0 +1,311 @@ +"""Streaming protocol, tenant-config binding, and file-model compatibility.""" + +import asyncio +import json +import wave +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from bisheng.core.ai.asr import realtime +from bisheng.llm.domain.llm.asr import BishengASR +from bisheng.llm.domain.schemas import WorkbenchModelConfig, WSModel +from bisheng.llm.domain.services import asr_realtime +from bisheng.llm.domain.services.llm import LLMService + + +class ProviderSocket: + def __init__(self, protocol, finish_response=True): + self.protocol = protocol + self.finish_response = finish_response + self.incoming = asyncio.Queue() + self.sent = [] + self.closed = False + + def put(self, value): + self.incoming.put_nowait(json.dumps(value)) + + def result(self, final, text): + if self.protocol == "paraformer": + return { + "header": {"event": "result-generated"}, + "payload": { + "output": { + "sentence": {"sentence_id": 0, "sentence_end": final, "text": text}, + } + }, + } + if final: + return {"type": "conversation.item.input_audio_transcription.completed", "item_id": "a", "transcript": text} + return {"type": "conversation.item.input_audio_transcription.text", "item_id": "a", "text": "", "stash": text} + + async def send(self, payload): + self.sent.append(payload) + message = json.loads(payload) if isinstance(payload, str) else {} + action = message.get("type", message.get("header", {}).get("action")) + if action == "run-task": + self.put({"header": {"event": "task-started"}}) + elif action == "session.update": + self.put({"type": "session.created"}) + self.put({"type": "session.updated"}) + elif isinstance(payload, bytes) or action == "input_audio_buffer.append": + self.put(self.result(False, "draft")) + elif action in ("finish-task", "session.finish") and self.finish_response: + self.put(self.result(True, "Final.")) + self.put(self.result(True, "Duplicate.")) + self.put(self.result(False, "Stale.")) + self.put( + {"header": {"event": "task-finished"}} + if self.protocol == "paraformer" + else {"type": "session.finished"} + ) + + async def recv(self): + return await self.incoming.get() + + def __aiter__(self): + return self + + async def __anext__(self): + return await self.recv() + + async def close(self): + self.closed = True + + +@pytest.mark.parametrize( + "model,protocol", [("paraformer-realtime-v2", "paraformer"), ("qwen3-asr-flash-realtime", "qwen")] +) +async def test_both_protocols_flush_final_before_close(monkeypatch, model, protocol): + socket = ProviderSocket(protocol) + connect = AsyncMock(return_value=socket) + monkeypatch.setattr(realtime, "connect", connect) + client = realtime.RealtimeASRClient("test-key", model) + ready, event = AsyncMock(), AsyncMock() + + async def audio(): + ready.assert_awaited_once() + yield b"\x00\x00" * 1600 + + await client.stream(audio(), ready, event) + assert [call.args[0]["text"] for call in event.await_args_list if call.args[0]["type"] == "final"] == ["Final."] + assert socket.closed + connect.assert_awaited_once() + assert connect.await_args.kwargs["additional_headers"] == {"Authorization": "Bearer test-key"} + + +async def test_finish_timeout_releases_provider(monkeypatch): + socket = ProviderSocket("paraformer", finish_response=False) + monkeypatch.setattr(realtime, "connect", AsyncMock(return_value=socket)) + monkeypatch.setattr(realtime, "FINISH_TIMEOUT", 0.01) + + async def audio(): + yield b"\x00\x00" + + with pytest.raises(TimeoutError): + await realtime.RealtimeASRClient("test-key", "paraformer-realtime-v2").stream(audio(), AsyncMock(), AsyncMock()) + assert socket.closed + + +async def test_browser_disconnect_cancels_receive_and_closes_provider(monkeypatch): + socket = ProviderSocket("qwen") + monkeypatch.setattr(realtime, "connect", AsyncMock(return_value=socket)) + + async def audio(): + yield b"\x00\x00" + raise ConnectionError("browser disconnected") + + with pytest.raises(ConnectionError): + await realtime.RealtimeASRClient("test-key", "qwen3-asr-flash-realtime").stream( + audio(), AsyncMock(), AsyncMock() + ) + assert socket.closed + + +async def test_file_probe_uses_streaming_adapter(monkeypatch, tmp_path): + socket = ProviderSocket("paraformer") + monkeypatch.setattr(realtime, "connect", AsyncMock(return_value=socket)) + path = tmp_path / "probe.wav" + with wave.open(str(path), "wb") as wav: + wav.setparams((1, 2, 16000, 0, "NONE", "NONE")) + wav.writeframes(b"\x00\x00" * 160) + transcript = await realtime.RealtimeASRClient("test-key", "paraformer-realtime-v2").transcribe_file(str(path)) + assert transcript.text == "Final." + assert [segment.text for segment in transcript.segments] == ["Final."] + + +def test_explicit_model_registry_and_region(): + assert realtime.realtime_protocol("qwen", "paraformer-realtime-v2") == "paraformer" + assert realtime.realtime_protocol("openai", "paraformer-realtime-v2") is None + assert realtime.realtime_protocol("qwen", "arbitrary-realtime") is None + assert realtime.websocket_url( + "https://workspace.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", "qwen", "qwen3-asr-flash-realtime" + ) == ("wss://workspace.cn-beijing.maas.aliyuncs.com/api-ws/v1/realtime?model=qwen3-asr-flash-realtime") + with pytest.raises(ValueError): + realtime.websocket_url("http://example.com", "qwen", "qwen3-asr-flash-realtime") + + +async def test_model_config_selects_streaming_and_preserves_file_asr(): + server = SimpleNamespace(type="qwen", config={"openai_api_key": "test-key"}) + model = SimpleNamespace(model_name="paraformer-realtime-v2", model_type="asr_rt", config={}) + assert isinstance(await BishengASR.init_asr_client(model, server), realtime.RealtimeASRClient) + model.model_name = "paraformer-v2" + model.model_type = "asr" + assert (await BishengASR.init_asr_client(model, server)).__class__.__name__ == "AliyunASRClient" + model.model_name = "arbitrary-realtime" + model.model_type = "asr_rt" + with pytest.raises(ValueError, match="no registered protocol adapter"): + await BishengASR.init_asr_client(model, server) + server.type = "openai" + model.model_name = "whisper-1" + with pytest.raises(ValueError, match="no realtime ASR client"): + await BishengASR.init_asr_client(model, server) + + +class BrowserSocket: + def __init__(self, model_id="1", messages=None, origin="https://example.com"): + self.headers = {"host": "example.com", "origin": origin} + self.start = {"type": "start", "model_id": model_id} + self.messages = iter(messages or [{"type": "websocket.receive", "text": '{"type":"stop"}'}]) + self.sent = [] + self.accept = AsyncMock() + self.close = AsyncMock() + + async def receive_json(self): + return self.start + + async def receive(self): + return next(self.messages) + + async def send_json(self, message): + self.sent.append(message) + + +def setup_service(monkeypatch): + model = realtime.RealtimeASRClient("test-key", "paraformer-realtime-v2") + + async def stream(audio, ready, forward): + await ready() + async for _ in audio: + pass + await forward({"type": "final", "segment_id": "a", "version": 1, "text": "Final."}) + + resolve = AsyncMock(return_value=SimpleNamespace(asr=model, astream=stream)) + monkeypatch.setattr( + LLMService, "get_workbench_llm", AsyncMock(return_value=SimpleNamespace(asr_model=SimpleNamespace(id="1"))) + ) + monkeypatch.setattr(LLMService, "get_bisheng_asr", resolve) + return resolve + + +async def test_session_binds_configured_model_and_preserves_final(monkeypatch): + resolve = setup_service(monkeypatch) + browser = BrowserSocket() + await asr_realtime.run_realtime_asr(browser, SimpleNamespace(user_id=7)) + assert [event["type"] for event in browser.sent] == ["ready", "final", "completed"] + assert resolve.await_args.kwargs["model_id"] == 1 + assert resolve.await_args.kwargs["user_id"] == 7 + browser.close.assert_awaited_once() + + +async def test_other_model_id_is_rejected_before_provider_access(monkeypatch): + resolve = setup_service(monkeypatch) + browser = BrowserSocket(model_id="2") + await asr_realtime.run_realtime_asr(browser, SimpleNamespace(user_id=7)) + resolve.assert_not_awaited() + assert [event["type"] for event in browser.sent] == ["error"] + + +async def test_cross_origin_is_rejected_before_accept(monkeypatch): + resolve = setup_service(monkeypatch) + browser = BrowserSocket(origin="https://other.example") + await asr_realtime.run_realtime_asr(browser, SimpleNamespace(user_id=7)) + resolve.assert_not_awaited() + browser.accept.assert_not_awaited() + browser.close.assert_awaited_once_with(code=1008) + + +async def test_dev_proxy_forwarded_host_is_accepted(monkeypatch): + setup_service(monkeypatch) + browser = BrowserSocket(origin="http://localhost:4001") + browser.headers = { + "host": "127.0.0.1:7860", + "origin": "http://localhost:4001", + "x-forwarded-host": "localhost:4001", + } + await asr_realtime.run_realtime_asr(browser, SimpleNamespace(user_id=7)) + browser.accept.assert_awaited() + browser.close.assert_awaited_once() + + +async def test_oversized_audio_is_rejected(monkeypatch): + setup_service(monkeypatch) + browser = BrowserSocket(messages=[{"type": "websocket.receive", "bytes": bytes(32002)}]) + await asr_realtime.run_realtime_asr(browser, SimpleNamespace(user_id=7)) + assert [event["type"] for event in browser.sent] == ["ready", "error"] + + +@pytest.mark.parametrize( + "model_name,model_type,expected", + [ + ("16k_zh", "asr_rt", True), + ("16k_zh", "asr", True), + ("whisper-1", "asr", False), + ], +) +async def test_effective_config_derives_capability_from_model_type(monkeypatch, model_name, model_type, expected): + config = WorkbenchModelConfig(asr_model=WSModel(id="12", realtime=not expected)) + typed = AsyncMock(return_value=(config, True, False)) + monkeypatch.setattr(LLMService, "_aget_typed_with_meta", typed) + monkeypatch.setattr(LLMService, "_sanitize_workbench_config_refs", AsyncMock(return_value=config)) + monkeypatch.setattr( + BishengASR, + "get_model_server_info", + AsyncMock( + return_value=( + SimpleNamespace(model_name=model_name, model_type=model_type), + SimpleNamespace(type="tencent"), + ) + ), + ) + result, inherited, blocked = await LLMService.aget_workbench_llm_with_meta(tenant_id=7) + assert result.asr_model.realtime is expected + assert (inherited, blocked) == (True, False) + assert typed.await_args.args[-1] == 7 + + +async def test_file_transcription_preserves_segments_and_english_word_boundaries(monkeypatch, tmp_path): + client = realtime.RealtimeASRClient("test-key", "paraformer-realtime-v2") + + async def stream(audio, on_ready, on_event): + async for _ in audio: + pass + for segment_id, text in [("1", "hello"), ("2", "world")]: + await on_event({"type": "final", "segment_id": segment_id, "text": text}) + + monkeypatch.setattr(client, "stream", stream) + path = tmp_path / "probe.wav" + with wave.open(str(path), "wb") as wav: + wav.setparams((1, 2, 16000, 0, "NONE", "NONE")) + transcript = await client.transcribe_file(str(path)) + assert transcript.text == "hello world" + assert [segment.text for segment in transcript.segments] == ["hello", "world"] + + +async def test_file_model_override_keeps_original_client_and_region(monkeypatch, tmp_path): + socket = ProviderSocket("qwen") + connector = AsyncMock(return_value=socket) + monkeypatch.setattr(realtime, "connect", connector) + client = realtime.RealtimeASRClient( + "test-key", "paraformer-realtime-v2", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1" + ) + path = tmp_path / "probe.wav" + with wave.open(str(path), "wb") as wav: + wav.setparams((1, 2, 16000, 0, "NONE", "NONE")) + result = await client.transcribe_file(str(path), model="qwen3-asr-flash-realtime") + assert result.text == "Final." + assert client.model == "paraformer-realtime-v2" + assert connector.await_args.args[0] == ( + "wss://dashscope-intl.aliyuncs.com/api-ws/v1/realtime?model=qwen3-asr-flash-realtime" + ) diff --git a/src/backend/test/llm/test_reasoning_passback.py b/src/backend/test/llm/test_reasoning_passback.py new file mode 100644 index 0000000000..871385b6f1 --- /dev/null +++ b/src/backend/test/llm/test_reasoning_passback.py @@ -0,0 +1,104 @@ +"""Earlier assistant reasoning goes back only with a tool-calling request. + +DeepSeek thinking mode: with ``tools`` every earlier turn's ``reasoning_content`` +is returned and spliced into the context; without ``tools`` it is ignored, so it +is not sent at all (another provider might not ignore it). +""" + +from __future__ import annotations + +from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, ToolMessage +from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer + +from bisheng.core.ai.llm.chat_openai_compatible import ChatOpenAICompatible +from bisheng.core.ai.llm.chat_openai_reasoning import ChatOpenAIReasoning +from bisheng.core.ai.llm.custom_chat_deepseek import CustomChatDeepSeek + +TOOLS = [ + { + "type": "function", + "function": {"name": "read_file", "parameters": {"type": "object", "properties": {}}}, + } +] + + +def _conversation() -> list: + """Turn 1 (finished), then turn 2 in the middle of a tool call.""" + return [ + HumanMessage(content="第一问"), + AIMessage( + content="", + tool_calls=[{"id": "c1", "name": "read_file", "args": {}}], + additional_kwargs={"reasoning_content": "turn1-step1"}, + ), + ToolMessage(content="内容", tool_call_id="c1"), + AIMessage(content="第一问的答案", additional_kwargs={"reasoning_content": "turn1-final"}), + HumanMessage(content="追问"), + AIMessage( + content="", + tool_calls=[{"id": "c2", "name": "read_file", "args": {}}], + additional_kwargs={"reasoning_content": "turn2-step1"}, + ), + ToolMessage(content="内容", tool_call_id="c2"), + AIMessage(content="没有思考过程的回复"), + ] + + +def _assistant_reasoning(payload: dict) -> list: + return [m.get("reasoning_content", "") for m in payload["messages"] if m["role"] == "assistant"] + + +def _vllm() -> ChatOpenAIReasoning: + return ChatOpenAIReasoning(api_key="k", model="dsv4", base_url="http://10.0.0.3:8000/v1") + + +def test_tools_request_sends_every_turn_back(): + payload = _vllm()._get_request_payload(_conversation(), tools=TOOLS) + + assert _assistant_reasoning(payload) == ["turn1-step1", "turn1-final", "turn2-step1", ""] + + +def test_request_without_tools_sends_nothing(): + payload = _vllm()._get_request_payload(_conversation()) + + assert _assistant_reasoning(payload) == [""] * 4 + + +def test_openai_compatible_subclasses_inherit_it(): + llm = ChatOpenAICompatible(api_key="k", model="m", base_url="https://example.com/v1") + payload = llm._get_request_payload(_conversation(), tools=TOOLS) + + assert _assistant_reasoning(payload)[:3] == ["turn1-step1", "turn1-final", "turn2-step1"] + + +def test_official_openai_endpoint_is_left_alone(): + for llm in ( + ChatOpenAIReasoning(api_key="k", model="m"), + ChatOpenAIReasoning(api_key="k", model="m", base_url="https://api.openai.com/v1"), + ): + payload = llm._get_request_payload(_conversation(), tools=TOOLS) + assert _assistant_reasoning(payload) == [""] * 4 + + +def test_deepseek_fills_every_assistant_message_with_tools(): + llm = CustomChatDeepSeek(api_key="k", model="deepseek-chat", api_base="https://api.deepseek.com") + payload = llm._get_request_payload(_conversation(), tools=TOOLS) + + assert _assistant_reasoning(payload) == ["turn1-step1", "turn1-final", "turn2-step1", ""] + + +def test_deepseek_sends_nothing_without_tools(): + llm = CustomChatDeepSeek(api_key="k", model="deepseek-chat", api_base="https://api.deepseek.com") + payload = llm._get_request_payload(_conversation()) + + assert _assistant_reasoning(payload) == [""] * 4 + + +def test_streamed_reasoning_survives_merge_and_checkpoint(): + merged = AIMessageChunk(content="", additional_kwargs={"reasoning_content": "先读"}) + AIMessageChunk( + content="答", additional_kwargs={"reasoning_content": "文件"} + ) + serde = JsonPlusSerializer() + restored = serde.loads_typed(serde.dumps_typed(AIMessage(**merged.model_dump(exclude={"type"})))) + + assert restored.additional_kwargs["reasoning_content"] == "先读文件" diff --git a/src/backend/test/llm/test_workbench_model_providers.py b/src/backend/test/llm/test_workbench_model_providers.py new file mode 100644 index 0000000000..a6a7534f7c --- /dev/null +++ b/src/backend/test/llm/test_workbench_model_providers.py @@ -0,0 +1,65 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from bisheng.api.v1.schemas import WSModel +from bisheng.llm.domain.models import LLMDao +from bisheng.llm.domain.schemas import WorkbenchModelConfig +from bisheng.llm.domain.services.llm import LLMService +from bisheng.workstation.domain.services.workstation_service import WorkStationService + + +async def test_picker_projects_providers_only_for_configured_models_without_credentials(monkeypatch): + config = WorkbenchModelConfig(models=[WSModel(id="42", name="old", displayName="Custom label")]) + monkeypatch.setattr(LLMService, "get_workbench_llm", AsyncMock(return_value=config)) + monkeypatch.setattr( + LLMDao, + "aget_model_by_ids", + AsyncMock(return_value=[SimpleNamespace(id=42, server_id=7, model_name="vendor/model")]), + ) + monkeypatch.setattr( + LLMDao, + "aget_server_by_ids", + AsyncMock(return_value=[SimpleNamespace(id=7, name="Provider", type="openai", config={"api_key": "secret"})]), + ) + monkeypatch.setattr(WorkStationService, "_aresolve_tenant_config", AsyncMock(return_value=("{}", False, 1, False))) + result = await WorkStationService.get_daily_chat_config(for_picker=True) + model = result.models[0].model_dump() + assert (model["id"], model["name"], model["displayName"]) == ("42", "vendor/model", "Custom label") + assert (model["provider_id"], model["provider_name"]) == ("7", "Provider") + assert "config" not in model + assert "api_key" not in model + assert config.models[0].name == "old" + + +async def test_empty_picker_does_not_read_the_provider_catalog(monkeypatch): + monkeypatch.setattr(LLMService, "get_workbench_llm", AsyncMock(return_value=WorkbenchModelConfig(models=[]))) + read = AsyncMock() + monkeypatch.setattr(LLMDao, "aget_server_by_ids", read) + assert (await LLMService.get_workbench_llm_for_picker()).models == [] + read.assert_not_awaited() + + +async def test_public_workstation_config_requests_picker_metadata(monkeypatch): + from bisheng.api.v1.schemas import WorkstationConfig + from bisheng.dsh.domain.services.capability_catalog import CapabilityCatalogService + from bisheng.workstation.api.endpoints import config as endpoint + + result = WorkstationConfig(models=[WSModel(id="42", provider_id="7", provider_name="Provider")]) + reader = AsyncMock(return_value=result) + monkeypatch.setattr(WorkStationService, "get_daily_chat_config", reader) + for name in ("get_linsight_config", "get_knowledge_space_config", "get_subscription_config"): + monkeypatch.setattr(WorkStationService, name, AsyncMock(return_value=None)) + monkeypatch.setattr(CapabilityCatalogService, "picker_tools", AsyncMock(return_value=[])) + monkeypatch.setattr( + endpoint, + "bisheng_settings", + SimpleNamespace( + dsh=SimpleNamespace(enabled=False), + async_get_knowledge=AsyncMock(return_value=SimpleNamespace(image_parser_enabled=False)), + aget_all_config=AsyncMock(return_value={}), + aget_linsight_conf=AsyncMock(return_value=SimpleNamespace(waiting_list_url="")), + ), + ) + response = await endpoint.get_config(None, login_user=SimpleNamespace(user_id=20)) + reader.assert_awaited_once_with(for_picker=True) + assert response.data["models"][0]["provider_name"] == "Provider" diff --git a/src/backend/test/open_api/conftest.py b/src/backend/test/open_api/conftest.py index e34c96d6fc..5f6ca42856 100644 --- a/src/backend/test/open_api/conftest.py +++ b/src/backend/test/open_api/conftest.py @@ -25,6 +25,20 @@ async def adelete(self, key: str): return int(self.values.pop(key, None) is not None) +@pytest.fixture +def open_platform_enabled(monkeypatch): + from bisheng.common.services.config_service import settings + + monkeypatch.setattr(settings.open_platform, "enabled", True) + + +@pytest.fixture +def open_platform_disabled(monkeypatch): + from bisheng.common.services.config_service import settings + + monkeypatch.setattr(settings.open_platform, "enabled", False) + + @pytest.fixture async def open_api_db(monkeypatch): from sqlalchemy.ext.asyncio import create_async_engine @@ -33,7 +47,7 @@ async def open_api_db(monkeypatch): from bisheng.core.context.tenant import current_tenant_id, set_current_tenant_id from bisheng.database.models.department import Department - from bisheng.database.models.tenant import UserTenant + from bisheng.database.models.tenant import Tenant, UserTenant from bisheng.open_api.domain.models import ( ApiCredential, ApiCredentialDelegateScope, @@ -49,6 +63,7 @@ async def open_api_db(monkeypatch): ) async with engine.begin() as connection: await connection.run_sync(User.__table__.create) + await connection.run_sync(Tenant.__table__.create) await connection.run_sync(UserTenant.__table__.create) await connection.run_sync(Department.__table__.create) await connection.run_sync(ServiceAccount.__table__.create) @@ -66,6 +81,7 @@ async def session_factory(): await session.close() for module_name in ( + "bisheng.database.models.tenant", "bisheng.open_api.domain.repositories.credential_repository", "bisheng.open_api.domain.repositories.delegate_scope_repository", "bisheng.open_api.domain.repositories.owner_repository", @@ -96,6 +112,92 @@ async def get_fake_redis(): return redis +@asynccontextmanager +async def _mcp_app(): + """A FastAPI app carrying only the MCP path (F067 ``open_mcp``), with its session manager running. + + Not ``bisheng.main.app``, so a test does not pull in the whole router. Each + caller gets its own runtime because ``StreamableHTTPSessionManager.run()`` + may be entered once per instance. + + This is a plain context manager rather than an async fixture on purpose. The + session manager's ``run()`` opens an anyio task group, and a task group has + to be closed in the task that opened it — pytest-asyncio tears async + fixtures down in a different task, which fails with "attempted to exit + cancel scope in a different task". Entering it inside the test's own body + keeps both ends in one task. + """ + + from fastapi import FastAPI + + from bisheng.open_api.api.exception_handlers import register_open_api_exception_handlers + from bisheng.open_mcp.server import OpenMcpDispatchMiddleware, create_open_mcp_runtime + + runtime = create_open_mcp_runtime() + app = FastAPI() + register_open_api_exception_handlers(app) + app.add_middleware(OpenMcpDispatchMiddleware, mcp_app=runtime.app) + async with runtime.lifespan(): + yield app + + +@pytest.fixture +def mcp_http(): + """``async with mcp_http(headers) as client`` → raw HTTP against the MCP route. + + For the transport-level facts a protocol client hides: the status code of a + refusal, whether the bare path redirects, what a real ``Host`` header does. + """ + + import httpx + + @asynccontextmanager + async def open_client(headers: dict[str, str] | None = None, **client_kwargs): + async with _mcp_app() as app: + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://testserver", + headers=headers or {}, + **client_kwargs, + ) as client: + yield client + + return open_client + + +@pytest.fixture +def mcp_session(): + """``async with mcp_session(headers) as session`` → a real initialised ``ClientSession``. + + A genuine MCP client over the app in-process, because "any standard client + connects with no changes" is only verified by a standard client: hand-rolled + JSON-RPC posts would keep passing with a broken handshake. + """ + + import httpx + from mcp import ClientSession + from mcp.client.streamable_http import streamable_http_client + + @asynccontextmanager + async def connect(headers: dict[str, str] | None = None): + async with _mcp_app() as app: + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://testserver", + headers=headers or {}, + ) as http_client: + async with streamable_http_client("http://testserver/api/v2/mcp", http_client=http_client) as ( + read_stream, + write_stream, + _get_session_id, + ): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + yield session + + return connect + + @pytest.fixture def audit_events(monkeypatch): events: list[dict] = [] diff --git a/src/backend/test/open_api/model_gateway_fixtures.py b/src/backend/test/open_api/model_gateway_fixtures.py new file mode 100644 index 0000000000..b88660d0f8 --- /dev/null +++ b/src/backend/test/open_api/model_gateway_fixtures.py @@ -0,0 +1,253 @@ +"""Shared doubles for the model protocol face tests (F051 T005). + +Built once here so no individual test file invents its own fake model or its +own catalog rows — the point of the face is that three consumers agree on one +answer, and three private fakes would hide exactly the disagreement the tests +exist to catch. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from types import SimpleNamespace +from typing import Any + +from fastapi import APIRouter, Depends, FastAPI +from langchain_core.messages import AIMessage, AIMessageChunk + +from bisheng.open_api.api.dependencies import verify_open_api_access +from bisheng.open_api.api.endpoints.model_gateway import router as model_gateway_router +from bisheng.open_api.api.exception_handlers import register_open_api_exception_handlers +from bisheng.open_api.domain.context import OpenApiPrincipal + + +def build_model_face_app() -> FastAPI: + """A standalone app carrying exactly the v2 contract the real one does. + + ``bisheng.main.app`` evaluates ``open_platform.enabled`` at import time, so a + test that flips the switch afterwards gets the mounting decision made before + it ran. Building the router here is how both states are testable in one + process. + """ + + app = FastAPI() + register_open_api_exception_handlers(app) + router = APIRouter(prefix="/api/v2", dependencies=[Depends(verify_open_api_access)]) + router.include_router(model_gateway_router) + app.include_router(router) + return app + + +def service_account_principal( + *, + scopes: frozenset[str] = frozenset({"model:invoke"}), + tenant_id: int = 9, + credential_id: int = 7, +) -> OpenApiPrincipal: + return OpenApiPrincipal( + credential_id=credential_id, + actor_kind="service_account", + actor_id=31, + actor_name="local-dev", + tenant_id=tenant_id, + resource_owner_user_id=12, + scopes=scopes, + authorization_subject_type="service_account", + authorization_subject_id=31, + effective_user_id=None, + ) + + +def hosted_app_principal( + *, + app_slug: str = "survey-app", + app_id: str = "app-uuid-survey", + tenant_id: int = 9, + scopes: frozenset[str] = frozenset({"model:invoke"}), +) -> OpenApiPrincipal: + """A hosted-application principal, shaped exactly like ``resolve_hosted_app``'s. + + The split between ``actor_name`` and ``subject_ref`` is the point: the name + is the application's *display* name and the ref is ``app.id``. Everything + that identifies the application — the declaration lookup, the call record's + ``app_id``, the access token's audience — keys on the ref, so a fixture that + put the id in the name would let a regression through unnoticed (F055 T055 + note ①). + + Fully validated, not ``model_construct``: F055 T055 widened the + ``actor_kind`` Literal to admit ``hosted_app``, so the workaround the + earlier wave needed would now only hide a field this principal gets wrong. + """ + + return OpenApiPrincipal( + credential_id=71, + actor_kind="hosted_app", + actor_id=5, + actor_name=app_slug, + subject_ref=app_id, + tenant_id=tenant_id, + resource_owner_user_id=12, + scopes=scopes, + mode="S", + authorization_subject_type="service_account", + authorization_subject_id=5, + effective_user_id=None, + on_behalf_of_user_id=None, + end_user_id=None, + ) + + +# --- catalog rows ------------------------------------------------------------ + + +def server_row(server_id: int, name: str, *, server_type: str = "openai", tenant_id: int = 9): + return SimpleNamespace(id=server_id, name=name, type=server_type, tenant_id=tenant_id) + + +def model_row( + model_id: int, + server_id: int, + model_name: str, + *, + model_type: str = "llm", + online: bool = True, +): + return SimpleNamespace( + id=model_id, + server_id=server_id, + model_name=model_name, + model_type=model_type, + online=online, + ) + + +def install_catalog(monkeypatch, servers: list, models: list, *, fail_with: Exception | None = None) -> None: + """Point the catalog at in-memory rows (or make the read fail).""" + + from bisheng.llm.domain.services import model_catalog + from bisheng.llm.domain.services.llm import LLMService + + model_catalog.invalidate_catalog() + + async def collect(_leaf_id: int, *, strict: bool = False): + if fail_with is not None: + raise fail_with + return servers + + async def fetch_models(server_ids): + # ``LLMDao.aget_model_by_server_ids`` filters ``server_id IN ids``. A + # fake that ignored that would let a test build a row the database can + # never return and then assert on how the code handles it. + wanted = set(server_ids) + return [model for model in models if model.server_id in wanted] + + monkeypatch.setattr(LLMService, "acollect_visible_servers", staticmethod(collect)) + monkeypatch.setattr(model_catalog.LLMDao, "aget_model_by_server_ids", staticmethod(fetch_models)) + + +# --- fake model -------------------------------------------------------------- + + +@dataclass +class FakeBishengLLM: + """Scripted stand-in for ``BishengLLM`` with the surface the face uses.""" + + stream_script: list[Any] = field(default_factory=list) + invoke_result: Any = None + raise_on_first_chunk: Exception | None = None + raise_after: int | None = None + raise_on_invoke: Exception | None = None + bound: dict[str, Any] = field(default_factory=dict) + init_kwargs: dict[str, Any] = field(default_factory=dict) + seen_kwargs: dict[str, Any] = field(default_factory=dict) + seen_messages: list = field(default_factory=list) + + def bind(self, **kwargs): + self.bound = kwargs + return self + + async def ainvoke(self, messages, **kwargs): + self.seen_messages = messages + self.seen_kwargs = kwargs + if self.raise_on_invoke is not None: + raise self.raise_on_invoke + return self.invoke_result or AIMessage(content="ok") + + async def astream(self, messages, **kwargs): + self.seen_messages = messages + self.seen_kwargs = kwargs + if self.raise_on_first_chunk is not None: + raise self.raise_on_first_chunk + for index, chunk in enumerate(self.stream_script): + if self.raise_after is not None and index == self.raise_after: + raise RuntimeError("upstream vanished mid-stream") + yield chunk + if self.raise_after is not None and self.raise_after >= len(self.stream_script): + raise RuntimeError("upstream vanished mid-stream") + + +def install_fake_llm(monkeypatch, fake: FakeBishengLLM) -> FakeBishengLLM: + from bisheng.llm.domain.services.llm import LLMService + + async def get_bisheng_llm(**kwargs): + fake.init_kwargs = kwargs + return fake + + monkeypatch.setattr(LLMService, "get_bisheng_llm", staticmethod(get_bisheng_llm)) + return fake + + +def text_chunk(text: str, **kwargs) -> AIMessageChunk: + return AIMessageChunk(content=text, **kwargs) + + +def tool_chunk(*, name: str | None, args: str, call_id: str | None, index: int | None = None) -> AIMessageChunk: + return AIMessageChunk( + content="", + tool_call_chunks=[{"name": name, "args": args, "id": call_id, "index": index, "type": "tool_call_chunk"}], + ) + + +def usage_chunk(*, prompt: int, completion: int, finish_reason: str = "stop") -> AIMessageChunk: + return AIMessageChunk( + content="", + usage_metadata={ + "input_tokens": prompt, + "output_tokens": completion, + "total_tokens": prompt + completion, + }, + response_metadata={"finish_reason": finish_reason}, + ) + + +# --- record writer ----------------------------------------------------------- + + +def capture_records(monkeypatch) -> list: + """Collect enqueued ``ModelCallRecord`` rows instead of writing them.""" + + from bisheng.open_api.domain.services import model_gateway_service + + rows: list = [] + monkeypatch.setattr( + model_gateway_service, + "model_call_record_writer", + SimpleNamespace(enqueue=lambda row: rows.append(row) or True), + ) + return rows + + +__all__ = [ + "FakeBishengLLM", + "build_model_face_app", + "capture_records", + "hosted_app_principal", + "install_catalog", + "install_fake_llm", + "model_row", + "server_row", + "service_account_principal", + "text_chunk", + "tool_chunk", + "usage_chunk", +] diff --git a/src/backend/test/open_api/task_mode_support.py b/src/backend/test/open_api/task_mode_support.py index 4962207f6d..d28f71ac43 100644 --- a/src/backend/test/open_api/task_mode_support.py +++ b/src/backend/test/open_api/task_mode_support.py @@ -88,7 +88,7 @@ async def _menu(_user_id, _key): async def _model_info(_model_id): return state.model, (SimpleNamespace() if state.model else None) - async def _enabled(): + async def _enabled(_login_user): return [SimpleNamespace(name=n, display_name=n, description=None) for n in state.skills] async def _by_ids(ids): diff --git a/src/backend/test/open_api/test_credential_transaction_defaults.py b/src/backend/test/open_api/test_credential_transaction_defaults.py new file mode 100644 index 0000000000..bbe91e14a7 --- /dev/null +++ b/src/backend/test/open_api/test_credential_transaction_defaults.py @@ -0,0 +1,32 @@ +"""Candidate credentials stay readable on databases without INSERT RETURNING.""" + +import pytest +from sqlalchemy import inspect + +from bisheng.open_api.domain.models.api_credential import ApiCredential +from bisheng.open_api.domain.repositories.credential_repository import CredentialRepository +from bisheng.open_api.domain.schemas.credential import KeyItem + + +@pytest.mark.parametrize("insert_returning", [True, False]) +async def test_candidate_defaults_loaded_without_committing(open_api_db, monkeypatch, insert_returning): + async with open_api_db() as session: + monkeypatch.setattr(session.bind.dialect, "insert_returning", insert_returning) + row = ApiCredential( + tenant_id=1, + subject_kind="hosted_app", + subject_id=1, + name="transaction-defaults", + key_prefix="bs-app-", + last4="test", + token_hash="a" * 64, + scopes=[], + ) + result = await CredentialRepository.create_in_transaction(session, row) + item = KeyItem.from_row(result) + assert item.create_time is not None + assert not inspect(result).expired_attributes + credential_id = item.id + # Refresh must not commit the caller's candidate credential transaction. + await session.rollback() + assert await session.get(ApiCredential, credential_id) is None diff --git a/src/backend/test/open_api/test_credential_validator.py b/src/backend/test/open_api/test_credential_validator.py index 05fc98cba4..b77b217866 100644 --- a/src/backend/test/open_api/test_credential_validator.py +++ b/src/backend/test/open_api/test_credential_validator.py @@ -8,15 +8,19 @@ OpenApiCredentialMissingError, ServiceAccountInactiveError, ) +from bisheng.database.models.tenant import UserTenant from bisheng.open_api.domain.models.api_credential import SUBJECT_KIND_SERVICE_ACCOUNT from bisheng.open_api.domain.models.service_account import ServiceAccount from bisheng.open_api.domain.schemas.credential import KeyIssueRequest from bisheng.open_api.domain.services.credential_service import CredentialService, hash_token from bisheng.open_api.domain.services.credential_validator import extract_bearer_token, validate_bearer +from bisheng.user.domain.models.user import User async def seed_service_account(open_api_db, *, disabled=False): async with open_api_db() as session: + session.add(User(user_id=21, user_name="integration-owner", password="")) + session.add(UserTenant(user_id=21, tenant_id=1, is_active=1, status="active")) row = ServiceAccount( tenant_id=1, name="integration", diff --git a/src/backend/test/open_api/test_data_scope_matrix.py b/src/backend/test/open_api/test_data_scope_matrix.py index e2bd31df36..4b62dd43d7 100644 --- a/src/backend/test/open_api/test_data_scope_matrix.py +++ b/src/backend/test/open_api/test_data_scope_matrix.py @@ -31,14 +31,21 @@ ) # ── AC-R3: every knowledge:read endpoint is classified for the narrowing ── -# raise → single-resource gate raises 26044 on a non-owned target -# narrow → list surface silently intersects with the holder-created set -# per-item→ mixed-parent items resolve False in batch checks (then 404 path) -# sa-only → modes=("S",): personal tokens cannot reach it at all +# raise → single-resource gate raises 26044 on a non-owned target +# narrow → list surface silently intersects with the holder-created set +# per-item → mixed-parent items resolve False in batch checks (then 404 path) +# sa-only → modes=("S",): personal tokens cannot reach it at all +# unreachable→ F052: the unified retrieval facade decides in **batch**, and a +# batch check resolves a narrowed-out target to False instead of +# raising (permission_action_service "design decision 2"). The +# endpoint therefore answers 404 / 26321 — the same answer it gives +# for a knowledge base that does not exist, which is what AC-11 / +# AC-27 require: "exists but was not created by me" must not be +# distinguishable from "does not exist". KNOWLEDGE_READ_CLASSIFICATION = { ("GET", "/api/v2/filelib/"): "narrow", ("GET", "/api/v2/filelib/file/list"): "raise", - ("POST", "/api/v2/filelib/retrieve"): "raise", + ("POST", "/api/v2/filelib/retrieve"): "unreachable", ("GET", "/api/v2/filelib/download_statistic"): "sa-only", ("GET", "/api/v2/filelib/detail_qa"): "raise", ("POST", "/api/v2/filelib/query_qa"): "raise", @@ -179,3 +186,59 @@ async def is_super(_user_id): assert denied.status_code == 403 assert denied.json()["status_code"] == 26044 # super-admin shortcut never fired + + +async def test_narrowed_token_retrieve_is_unreachable_not_26044(gate, monkeypatch): + """F052 D6 ①: narrowing a retrieve target must not be distinguishable. + + A batch check resolves a data-scope-narrowed target to ``False`` rather than + raising (``permission_action_service.batch_check_actions``: "Batch checks + carry filtering semantics: narrowed-out targets resolve to False instead of + raising"), so the facade classes it as unreachable. That is the required + outcome, not an accident of the implementation: a narrowed token that still + got 26044 here could tell "this knowledge base exists but I did not create + it" apart from "this knowledge base does not exist", which is exactly the + enumeration channel AC-11 / AC-27 close. + """ + + from unittest.mock import MagicMock + + from bisheng.common.errcode.mcp_face import KnowledgeUnreachableError + from bisheng.knowledge.domain.schemas.retrieval_facade import RetrievalIdentity, RetrievalRequest + from bisheng.knowledge.domain.services.retrieval_facade_service import RetrievalFacadeService + from bisheng.open_api.api.exception_handlers import open_api_http_status + + identity = RetrievalIdentity.from_open_api_principal(_pat_principal()) + + async def batch_check(_login_user, *, resource_type, resource_ids, actions): + # What the real batch path returns for a narrowed-out target. + return {str(one): frozenset() for one in resource_ids} + + async def rows(ids): + row = MagicMock() + row.id = 999 + row.type = 3 + row.name = "someone else's space" + row.description = "" + return [row] if 999 in ids else [] + + monkeypatch.setattr( + "bisheng.knowledge.domain.services.retrieval_facade_service.batch_check_business_actions", + batch_check, + ) + monkeypatch.setattr( + "bisheng.knowledge.domain.services.retrieval_facade_service.KnowledgeDao.aget_list_by_ids", rows + ) + + with pytest.raises(KnowledgeUnreachableError) as narrowed: + await RetrievalFacadeService.retrieve(identity, RetrievalRequest(query="q", knowledge_ids=[999])) + with pytest.raises(KnowledgeUnreachableError) as absent: + await RetrievalFacadeService.retrieve(identity, RetrievalRequest(query="q", knowledge_ids=[12345])) + + assert narrowed.value.code == absent.value.code == 26321 + assert open_api_http_status(narrowed.value) == 404 + # Byte-identical apart from the id each one names. + narrowed_body = narrowed.value.to_dict() + absent_body = absent.value.to_dict() + narrowed_body["data"]["unreachable_ids"] = absent_body["data"]["unreachable_ids"] = [] + assert narrowed_body == absent_body diff --git a/src/backend/test/open_api/test_database_contract.py b/src/backend/test/open_api/test_database_contract.py index b5f9debc2c..3fca1d33c5 100644 --- a/src/backend/test/open_api/test_database_contract.py +++ b/src/backend/test/open_api/test_database_contract.py @@ -27,11 +27,22 @@ def test_service_account_is_an_independent_subject(): def test_api_credential_contract_has_only_supported_subject_kinds(): - assert CREDENTIAL_SUBJECT_KINDS == {"service_account", "natural_person"} + """The constant and the CHECK are one lockstep — a kind in one only is an insert error. + + ``hosted_app`` joined the set with F055 T055 (the hosted application's + runtime credential). This test previously asserted its *absence*, which was + right while the beta2 base carried no resolver for it: an accepted + ``subject_kind`` with nothing able to resolve it is a credential that + authenticates as nothing. What replaces that guard is the pair of assertions + below plus ``test_app_credential.test_every_subject_kind_has_a_prefix`` — + every accepted kind must have its own key prefix, so none of them can + present another's token. + """ + assert CREDENTIAL_SUBJECT_KINDS == {"service_account", "natural_person", "hosted_app"} constraints = [item for item in ApiCredential.__table__.constraints if isinstance(item, CheckConstraint)] assert len(constraints) == 1 - assert "natural_person" in str(constraints[0].sqltext) - assert "hosted_app" not in str(constraints[0].sqltext) + for kind in CREDENTIAL_SUBJECT_KINDS: + assert f"'{kind}'" in str(constraints[0].sqltext) assert isinstance(ApiCredential.__table__.c.subject_id.type, BigInteger) diff --git a/src/backend/test/open_api/test_dependencies.py b/src/backend/test/open_api/test_dependencies.py index fc0a8a4c12..3e767caac9 100644 --- a/src/backend/test/open_api/test_dependencies.py +++ b/src/backend/test/open_api/test_dependencies.py @@ -3,9 +3,13 @@ from fastapi import APIRouter, Depends, FastAPI, File, Request, UploadFile from httpx import ASGITransport, AsyncClient -from bisheng.common.errcode.open_api import OpenApiAuthDependencyUnavailableError +from bisheng.common.errcode.open_api import ( + OpenApiAuthDependencyUnavailableError, +) from bisheng.core.context.tenant import get_current_tenant_id, get_visible_tenant_ids -from bisheng.open_api.api.dependencies import verify_open_api_access +from bisheng.open_api.api.dependencies import ( + verify_open_api_access, +) from bisheng.open_api.api.exception_handlers import register_open_api_exception_handlers from bisheng.open_api.domain.context import OpenApiPrincipal, get_current_open_api_principal from bisheng.open_api.domain.scopes import open_api_scope @@ -41,6 +45,14 @@ async def whoami(): async def unregistered(): return {"unsafe": True} + # Stands in for the local development toolkit endpoints (`app:manage` is the + # only one of the three with routes today: POST /apps/deploy, GET + # /apps/{app_id}/logs). + @router.get("/toolkit") + @open_api_scope("app:manage", modes=("S",)) + async def toolkit(): + return {"deployed": True} + @router.post("/upload") @open_api_scope("knowledge:write") async def upload(file: UploadFile = File(...)): @@ -222,3 +234,95 @@ async def validate(_authorization): assert rejected.json()["status_code"] == 26019 assert accepted.status_code == 200 assert accepted.json() == {"filename": "sample.txt"} + + +async def test_delegate_key_is_refused_at_the_toolkit_entrance_not_sent_to_an_admin(monkeypatch): + """INV-31 runtime half: 26051, whether or not the key also holds the scope. + + The scope-less half is the one that used to hurt. It answered 26003 ("ask + an administrator to tick app:manage"), and the administrator then could not + — 26050 refuses `delegate` + a toolkit scope at issue time — so the two + gates pointed at each other. + """ + + async def with_scope(_authorization): + return service_account_principal(scopes=frozenset({"delegate", "app:manage"})) + + async def without_scope(_authorization): + return service_account_principal(scopes=frozenset({"delegate"})) + + app = build_app() + for validate in (with_scope, without_scope): + monkeypatch.setattr("bisheng.open_api.api.dependencies.validate_bearer", validate) + response = await request(app, "/api/v2/toolkit", authorization="Bearer opaque") + assert response.status_code == 403 + assert response.json()["status_code"] == 26051 + + +async def test_delegate_refusal_beats_delegation_resolution_so_no_header_advice_is_given(monkeypatch): + """26051 even when the caller does everything a delegated key is told to do. + + Sending `X-On-Behalf-Of` is what 26016 asks for; on a toolkit endpoint it + must not turn the refusal into a success, or INV-31 degrades into "delegate + keys work here as long as you pass a header". + """ + + async def validate(_authorization): + return service_account_principal(scopes=frozenset({"delegate", "app:manage"})) + + monkeypatch.setattr("bisheng.open_api.api.dependencies.validate_bearer", validate) + async with AsyncClient(transport=ASGITransport(app=build_app()), base_url="http://test") as client: + response = await client.get( + "/api/v2/toolkit", + headers={"Authorization": "Bearer opaque", "X-On-Behalf-Of": "12"}, + ) + + assert response.status_code == 403 + assert response.json()["status_code"] == 26051 + + +async def test_a_key_without_delegate_is_untouched_by_the_toolkit_gate(monkeypatch): + async def holder(_authorization): + return service_account_principal(scopes=frozenset({"app:manage"})) + + async def bystander(_authorization): + return service_account_principal(scopes=frozenset({"knowledge:read"})) + + app = build_app() + monkeypatch.setattr("bisheng.open_api.api.dependencies.validate_bearer", holder) + granted = await request(app, "/api/v2/toolkit", authorization="Bearer opaque") + assert granted.status_code == 200 + assert granted.json() == {"deployed": True} + + # Still the ordinary missing-scope verdict, which is actionable: an + # administrator can tick `app:manage` on a key that has no `delegate`. + monkeypatch.setattr("bisheng.open_api.api.dependencies.validate_bearer", bystander) + refused = await request(app, "/api/v2/toolkit", authorization="Bearer opaque") + assert refused.status_code == 403 + assert refused.json()["status_code"] == 26003 + + +async def test_delegate_key_on_a_non_toolkit_endpoint_keeps_its_existing_verdicts(monkeypatch): + """The gate is scoped to the toolkit faces and nothing else. + + `whoami` is the case `bisheng login` hits, and it deliberately keeps 26016: + it requires no scope at all, is not one of the three faces, and "send + X-On-Behalf-Of" is correct advice for the general integration calling it. + The CLI — which never sends identity headers — translates 26016 into the + delegate refusal on its side (`bisheng_cli/errors.py`), which is what makes + `login` refuse such a key. + """ + + async def validate(_authorization): + return service_account_principal(scopes=frozenset({"delegate", "knowledge:read"})) + + monkeypatch.setattr("bisheng.open_api.api.dependencies.validate_bearer", validate) + app = build_app() + + whoami = await request(app, "/api/v2/whoami", authorization="Bearer opaque") + assert whoami.status_code == 400 + assert whoami.json()["status_code"] == 26016 + + scoped = await request(app, "/api/v2/registered", authorization="Bearer opaque") + assert scoped.status_code == 400 + assert scoped.json()["status_code"] == 26016 diff --git a/src/backend/test/open_api/test_error_codes.py b/src/backend/test/open_api/test_error_codes.py index 8ecf04082b..9d303aa140 100644 --- a/src/backend/test/open_api/test_error_codes.py +++ b/src/backend/test/open_api/test_error_codes.py @@ -30,6 +30,13 @@ 26042, 26043, 26044, + 26050, + 26051, + # F055 AC-52: a hosted application reaching a v2 endpoint that executes as a + # natural person. Its scopes come from a capability declaration, and the one + # that declaration derives (``knowledge:read``) also admits six neighbouring + # routes that would run as the application's owner. + 26052, 26060, 26061, 26062, @@ -39,6 +46,7 @@ 26066, 26067, 26068, + 26070, } @@ -57,3 +65,36 @@ def test_reserved_and_removed_codes_are_not_reused(): assert EXPECTED_CODES.isdisjoint( {26008, 26009, 26011, 26012, 26013, 26014, 26028, *range(26032, 26040), *range(26045, 26050)} ) + + +def test_mcp_face_codes_are_in_263_band_and_have_three_language_copy(): + """F052 module 263 — band, annotation form, and locale copy in one assertion. + + The annotation form matters on its own: ``check-i18n.mjs`` only recognises + ``Code: int = NNNNN``, so a class written the ``open_api.py`` way would ship + with no copy and still pass CI. + """ + + import json + from pathlib import Path + + from bisheng.common.errcode import mcp_face + + codes = { + value.Code + for value in vars(mcp_face).values() + if isinstance(value, type) and issubclass(value, mcp_face.McpFaceError) + } + assert codes, "module 263 lost every error code" + assert all(26300 <= code <= 26339 for code in codes), sorted(codes) + assert codes.isdisjoint(EXPECTED_CODES), "263 must never collide with the 260 band" + + source = Path(mcp_face.__file__).read_text(encoding="utf-8") + for code in codes: + assert f"Code: int = {code}" in source, f"{code} must be declared with the ': int' annotation" + + locales = Path(__file__).resolve().parents[3] / "frontend/packages/locales/src/api_errors" + for language in ("zh-Hans", "en", "ja"): + copy = json.loads((locales / f"{language}.json").read_text(encoding="utf-8")) + missing = {code for code in codes if not copy.get(str(code))} + assert not missing, f"{language} is missing copy for {sorted(missing)}" diff --git a/src/backend/test/open_api/test_f114_preview_identity.py b/src/backend/test/open_api/test_f114_preview_identity.py new file mode 100644 index 0000000000..25694dbe2c --- /dev/null +++ b/src/backend/test/open_api/test_f114_preview_identity.py @@ -0,0 +1,175 @@ +"""F114 preview credentials and signed visitors must form one current pair.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from bisheng.common.errcode.open_api import OpenApiCredentialInvalidError +from bisheng.open_api.domain.context import OpenApiExecutionSnapshot, OpenApiPrincipal + + +def principal(**kw): + return OpenApiPrincipal( + credential_id=7, + actor_kind="hosted_app", + actor_id=2, + actor_name="app", + subject_ref="app", + tenant_id=3, + resource_owner_user_id=81, + scopes=frozenset(), + mode="S", + authorization_subject_type="user", + authorization_subject_id=81, + effective_user_id=None, + **kw, + ) + + +@pytest.fixture +def binding(monkeypatch): + from bisheng.open_api.domain.services import preview_context as mod + + context = SimpleNamespace( + tenant_id=3, + app_id="app", + version_id="pending", + viewer_user_id=42, + capability_user_id=81, + purpose="approval", + session_id="preview", + credential_id=7, + operation_id="op", + ) + port = SimpleNamespace(resolve=AsyncMock(return_value=context)) + monkeypatch.setattr(mod, "_provider", port) + return mod, port, context + + +async def test_pair_projects_only_verified_preview_references(binding): + mod, _port, _context = binding + verified = SimpleNamespace(user_id=42, preview_session="preview", expires_at=9999999999) + result = await mod.bind_preview_principal(principal(), verified) + assert result.preview_version_id == "pending" and result.preview_viewer_user_id == 42 + assert result.preview_capability_user_id == 81 + snapshot = OpenApiExecutionSnapshot.from_principal(result, trace_id="trace") + assert snapshot.preview_session_id == "preview" and snapshot.preview_obo_expires_at == 9999999999 + + +@pytest.mark.parametrize( + "verified", + [ + None, + SimpleNamespace(user_id=43, preview_session="preview", expires_at=9999999999), + SimpleNamespace(user_id=42, preview_session="other", expires_at=9999999999), + SimpleNamespace(user_id=42, preview_session=None, expires_at=9999999999), + ], +) +async def test_preview_missing_or_mixed_visitor_refused(binding, verified): + mod, _port, _context = binding + with pytest.raises(OpenApiCredentialInvalidError): + await mod.bind_preview_principal(principal(), verified) + + +async def test_formal_app_self_unchanged_and_preview_obo_cannot_cross(binding): + mod, port, _context = binding + port.resolve.return_value = None + assert await mod.bind_preview_principal(principal(), None) == principal() + with pytest.raises(OpenApiCredentialInvalidError): + await mod.bind_preview_principal( + principal(), SimpleNamespace(user_id=42, preview_session="preview", expires_at=9999999999) + ) + + +@pytest.fixture +async def credential_db(monkeypatch): + from contextlib import asynccontextmanager + + from sqlalchemy.ext.asyncio import create_async_engine + from sqlmodel.ext.asyncio.session import AsyncSession + + from bisheng.app_publish.domain.models.hosted_app_subject import HostedAppSubject + from bisheng.database.models.app import App + from bisheng.database.models.audit_log import AuditLog + from bisheng.open_api.domain.models.api_credential import ApiCredential + from bisheng.open_api.domain.repositories import credential_repository + + engine = create_async_engine("sqlite+aiosqlite://") + async with engine.begin() as conn: + for model in (App, HostedAppSubject, ApiCredential, AuditLog): + await conn.run_sync(model.__table__.create) + + @asynccontextmanager + async def factory(): + async with AsyncSession(engine, expire_on_commit=False) as session: + yield session + + monkeypatch.setattr(credential_repository, "get_async_db_session", factory) + async with factory() as session: + session.add(App(id="app", slug="app", name="App", tenant_id=3, owner_user_id=81, state="draft")) + session.add(HostedAppSubject(id=1, app_id="app")) + for ident in (7, 8): + session.add( + ApiCredential( + id=ident, + tenant_id=3, + subject_kind="hosted_app", + subject_id=1, + name="preview" if ident == 7 else "production", + key_prefix="bs-app-", + last4="test", + token_hash=str(ident), + scopes=[], + ) + ) + await session.commit() + yield factory, credential_repository.CredentialRepository + await engine.dispose() + + +async def test_preview_revoke_is_exact_audited_and_rollbackable(credential_db): + """AC-50、AC-53、AC-69: one preview key, no subject-wide revocation or independent commit.""" + from sqlmodel import select + + from bisheng.core.context.tenant import tenant_execution_scope + from bisheng.database.models.audit_log import AuditLog + from bisheng.open_api.application.runtime_credentials import revoke_preview_credential + from bisheng.open_api.domain.models.api_credential import ApiCredential + + factory, repo = credential_db + kwargs = {"credential_id": 7, "tenant_id": 3, "subject_id": 1, "reason": "manual"} + with tenant_execution_scope(3): + async with factory() as session: + assert await revoke_preview_credential(session, **kwargs) + await session.rollback() + assert await repo.preview_credential_is_live(7, 3, "app") + async with factory() as session: + assert (await session.exec(select(AuditLog))).all() == [] + assert await revoke_preview_credential(session, **kwargs) + assert not await revoke_preview_credential(session, **kwargs) + await session.commit() + async with factory() as session: + assert (await session.get(ApiCredential, 8)).revoked_at is None + logs = (await session.exec(select(AuditLog))).all() + assert len(logs) == 1 and logs[0].target_id == "7" + assert not await repo.preview_credential_is_live(7, 3, "app") + assert await repo.preview_credential_is_live(8, 3, "app") + + +@pytest.mark.parametrize("tenant,subject", [(4, 1), (3, 2)]) +async def test_preview_revoke_mixed_identity_has_no_effect(credential_db, tenant, subject): + """AC-50、AC-69: a cross-tenant or mixed subject reference never revokes a key.""" + from bisheng.core.context.tenant import tenant_execution_scope + from bisheng.open_api.domain.models.api_credential import ApiCredential + + factory, repo = credential_db + with tenant_execution_scope(3): + async with factory() as session: + with pytest.raises(ValueError, match="preview_credential_mismatch"): + await repo.revoke_preview_credential( + session, credential_id=7, tenant_id=tenant, subject_id=subject, reason="manual" + ) + assert (await session.get(ApiCredential, 7)).revoked_at is None + assert not await repo.preview_credential_is_live(7, 4, "app") + assert not await repo.preview_credential_is_live(7, 3, "other-app") diff --git a/src/backend/test/open_api/test_filelib_retrieve_facade.py b/src/backend/test/open_api/test_filelib_retrieve_facade.py new file mode 100644 index 0000000000..c99fff7764 --- /dev/null +++ b/src/backend/test/open_api/test_filelib_retrieve_facade.py @@ -0,0 +1,380 @@ +"""F052 T103a — v2 ``POST /filelib/retrieve`` runs on the unified facade. + +覆盖 AC: AC-11, AC-24, AC-25, AC-26, AC-43, AC-44 + +The request and response **shapes** are unchanged (AC-25). What changes is the +error vocabulary: the three old distinguishable "you can't have that knowledge +base" answers (404 NotFoundError / 403 SpacePermissionDeniedError / 10962 +unsupported type) collapse into one 26321, because a caller that can tell them +apart can enumerate knowledge bases it may not see. +""" + +from unittest.mock import MagicMock + +import pytest + +from bisheng.common.errcode.mcp_face import KnowledgeUnreachableError +from bisheng.common.errcode.permission import PermissionServiceUnavailableError +from bisheng.knowledge.domain.schemas.retrieval_facade import ( + RetrievalChunk, + RetrievalFacadeResult, +) +from bisheng.open_api.domain.context import ( + OpenApiPrincipal, +) +from bisheng.open_endpoints.api.endpoints import filelib as filelib_mod +from bisheng.open_endpoints.domain.schemas.filelib import RetrieveReq, RetrieveResp + + +def _service_account_principal() -> OpenApiPrincipal: + return OpenApiPrincipal( + credential_id=3, + actor_kind="service_account", + actor_id=7, + actor_name="dev-agent", + tenant_id=1, + resource_owner_user_id=11, + scopes=frozenset({"knowledge:read"}), + authorization_subject_type="service_account", + authorization_subject_id=7, + effective_user_id=None, + ) + + +def _delegated_principal() -> OpenApiPrincipal: + return OpenApiPrincipal( + credential_id=3, + actor_kind="service_account", + actor_id=7, + actor_name="dev-agent", + tenant_id=1, + resource_owner_user_id=11, + scopes=frozenset({"knowledge:read", "delegate"}), + mode="D", + authorization_subject_type="user", + authorization_subject_id=42, + effective_user_id=42, + on_behalf_of_user_id=42, + ) + + +@pytest.fixture +def facade_spy(monkeypatch): + """Capture what the endpoint hands the facade, and what it does with the result.""" + + calls: list[dict] = [] + outcome: dict = { + "result": RetrievalFacadeResult( + chunks=[ + RetrievalChunk( + knowledge_id=8, + knowledge_type=3, + knowledge_name="Handbook", + document_id=10, + document_name="a.pdf", + chunk_index=2, + content="body", + document_update_time="2026-09-16 08:30:00", + ) + ], + total=1, + effective_scope=[8], + ) + } + + async def retrieve(identity, req, **kwargs): + calls.append({"identity": identity, "req": req, "kwargs": kwargs}) + if "raises" in outcome: + raise outcome["raises"] + return outcome["result"] + + monkeypatch.setattr(filelib_mod.RetrievalFacadeService, "retrieve", retrieve) + holder = MagicMock() + holder.calls = calls + holder.outcome = outcome + return holder + + +@pytest.fixture +def as_principal(monkeypatch): + """Stand in for the v2 gate, which installs the principal ContextVar. + + Patched rather than set for real: pytest-asyncio runs each coroutine in its + own context, so a token taken in a sync fixture cannot be reset in one. + """ + + def install(principal: OpenApiPrincipal | None): + monkeypatch.setattr(filelib_mod, "get_current_open_api_principal", lambda: principal) + return principal + + return install + + +async def _call(req: RetrieveReq): + return await filelib_mod.retrieve_chunks(request=MagicMock(), req=req, version_repo=MagicMock()) + + +# --------------------------------------------------------------------------- +# AC-25 / AC-43 — the identity the endpoint builds +# --------------------------------------------------------------------------- + + +async def test_endpoint_builds_identity_from_principal_mode_s(facade_spy, as_principal): + as_principal(_service_account_principal()) + + await _call(RetrieveReq(query="q", knowledge_base_ids=[8])) + + identity = facade_spy.calls[0]["identity"] + assert identity.actor.subject_type == "service_account" + assert identity.actor.subject_id == 7 + # 坑 8: no init_login_user round trip, and never an administrator by default. + assert identity.login_user.is_global_super is False + + +async def test_endpoint_mode_d_identity_is_target_user(facade_spy, as_principal): + """AC-43 — the capability; mode D's admission and acceptance belong to F050.""" + + as_principal(_delegated_principal()) + + await _call(RetrieveReq(query="q", knowledge_base_ids=[8])) + + identity = facade_spy.calls[0]["identity"] + assert (identity.actor.subject_type, identity.actor.subject_id) == ("user", 42) + assert identity.login_user.user_id == 42 + + +async def test_endpoint_forwards_query_targets_and_tag_filters(facade_spy, as_principal): + as_principal(_service_account_principal()) + + await _call( + RetrieveReq( + query="what is the policy", + knowledge_base_ids=[8, 9], + filters={"knowledge_base_filters": [{"knowledge_base_id": 8, "tags": ["hr"], "tag_match_mode": "ANY"}]}, + top_k=25, + max_content=1234, + ) + ) + + req = facade_spy.calls[0]["req"] + assert req.query == "what is the policy" + assert req.knowledge_ids == [8, 9] + assert req.tag_filters == {8: ["hr"]} + assert (req.top_k, req.max_content) == (25, 1234) + # No declared scope on the open API face — that is F055's input, not v2's. + assert req.whitelist is None + + +# --------------------------------------------------------------------------- +# AC-25 — the contract shape is untouched +# --------------------------------------------------------------------------- + + +async def test_response_shape_unchanged(facade_spy, as_principal): + as_principal(_service_account_principal()) + + response = await _call(RetrieveReq(query="q", knowledge_base_ids=[8])) + + payload = response.data + assert isinstance(payload, RetrieveResp) + assert set(payload.model_dump()) == {"chunks", "total"} + assert set(payload.chunks[0].model_dump()) == { + "content", + "knowledge_id", + "document_id", + "document_name", + "chunk_index", + "document_update_time", + } + assert payload.chunks[0].knowledge_id == 8 + assert payload.chunks[0].document_update_time == "2026-09-16 08:30:00" + assert payload.total == 1 + + +def test_empty_knowledge_base_ids_still_400(): + """``min_length=1`` stays: the v2 request contract does not change.""" + + with pytest.raises(ValueError): + RetrieveReq(query="q", knowledge_base_ids=[]) + + +def test_max_content_above_cap_is_refused_not_silently_clamped(): + """design D6 ②: ``RetrieveResp`` has nowhere to report a clamp, so refuse.""" + + with pytest.raises(ValueError): + RetrieveReq(query="q", knowledge_base_ids=[8], max_content=100000) + + # The cap itself is still accepted. + assert RetrieveReq(query="q", knowledge_base_ids=[8], max_content=60000).max_content == 60000 + + +async def test_tag_match_mode_all_is_still_400(facade_spy, as_principal): + """AC-25: ``"ALL"`` is not implemented and has always been a 400. + + Accepting it and filtering as if it were ``"ANY"`` returns a *wider* set + than the caller asked for, with nothing in the response saying so — the + silent-narrowing failure mode in reverse. The refusal also has to happen + before the facade runs, or the caller pays for a retrieval it will discard. + """ + + from fastapi import HTTPException + + as_principal(_service_account_principal()) + + with pytest.raises(HTTPException) as exc: + await _call( + RetrieveReq( + query="q", + knowledge_base_ids=[8], + filters={"knowledge_base_filters": [{"knowledge_base_id": 8, "tags": ["hr"], "tag_match_mode": "ALL"}]}, + ) + ) + + assert exc.value.status_code == 400 + assert exc.value.detail == "tag_match_mode=ALL is not yet supported" + assert facade_spy.calls == [], "the request must be refused before any retrieval" + + +async def test_filter_for_an_untargeted_knowledge_base_is_400(facade_spy, as_principal): + """A filter naming a knowledge base outside ``knowledge_base_ids`` is a 400. + + Dropping it quietly means the narrowing the caller wrote never happened and + the answer looks correct — the documented behaviour is to say so. + """ + + from fastapi import HTTPException + + as_principal(_service_account_principal()) + + with pytest.raises(HTTPException) as exc: + await _call( + RetrieveReq( + query="q", + knowledge_base_ids=[8], + filters={"knowledge_base_filters": [{"knowledge_base_id": 99, "tags": ["hr"]}]}, + ) + ) + + assert exc.value.status_code == 400 + assert "99" in exc.value.detail + assert facade_spy.calls == [] + + +def test_top_k_cap_matches_the_facade_cap(): + from bisheng.knowledge.domain.schemas.retrieval_facade import RETRIEVAL_TOP_K_MAX + + field = RetrieveReq.model_fields["top_k"] + caps = [getattr(one, "le", None) for one in field.metadata] + assert RETRIEVAL_TOP_K_MAX in caps + + +# --------------------------------------------------------------------------- +# AC-11 / AC-24 / AC-44 — the error vocabulary +# --------------------------------------------------------------------------- + + +async def test_unreachable_maps_to_26321_http_404(facade_spy, as_principal): + as_principal(_service_account_principal()) + facade_spy.outcome["raises"] = KnowledgeUnreachableError(unreachable_ids=[9]) + + with pytest.raises(KnowledgeUnreachableError) as exc: + await _call(RetrieveReq(query="q", knowledge_base_ids=[8, 9])) + + from bisheng.open_api.api.exception_handlers import open_api_http_status + + assert exc.value.code == 26321 + assert open_api_http_status(exc.value) == 404 + + +async def test_permission_unavailable_is_503_with_25021_not_26030(facade_spy, as_principal): + """design D6: 26030 means "credential validation is down" — wrong thing to say.""" + + as_principal(_service_account_principal()) + facade_spy.outcome["raises"] = PermissionServiceUnavailableError() + + with pytest.raises(PermissionServiceUnavailableError) as exc: + await _call(RetrieveReq(query="q", knowledge_base_ids=[8])) + + from bisheng.open_api.api.exception_handlers import open_api_http_status + + assert open_api_http_status(exc.value) == 503 + assert exc.value.code == 25021 + + +# --------------------------------------------------------------------------- +# AC-26 — the facade is the only retrieval path on the open face +# --------------------------------------------------------------------------- + + +def test_no_bypass_of_facade_in_open_face(): + import ast + from pathlib import Path + + import bisheng.open_api as open_api_pkg + import bisheng.open_endpoints as open_endpoints_pkg + + banned = ("aretrieve_chunks", "RetrievalEngine", "KnowledgeSpaceChatService") + offenders: list[str] = [] + for package in (open_endpoints_pkg, open_api_pkg): + root = Path(package.__file__).parent + for path in root.rglob("*.py"): + source = path.read_text(encoding="utf-8") + if not any(token in source for token in banned): + continue + tree = ast.parse(source) + for node in ast.walk(tree): + name = None + if isinstance(node, ast.Attribute): + name = node.attr + elif isinstance(node, ast.Name): + name = node.id + elif isinstance(node, ast.alias): + name = node.name.rsplit(".", 1)[-1] + if name in banned: + offenders.append(f"{path}:{getattr(node, 'lineno', '?')}:{name}") + + assert not offenders, "the open face must retrieve only through RetrievalFacadeService; found: " + ", ".join( + offenders + ) + + +def test_open_face_imports_the_facade(): + import bisheng.open_endpoints.api.endpoints.filelib as module + + assert module.RetrievalFacadeService is not None + assert isinstance(getattr(module, "RetrievalIdentity", None), type) + + +def test_filelib_no_longer_wraps_permission_outage_as_credential_outage(): + from pathlib import Path + + source = Path(filelib_mod.__file__).read_text(encoding="utf-8") + assert "OpenApiAuthDependencyUnavailableError" not in source + + +async def test_version_repo_is_handed_to_the_facade(facade_spy, as_principal): + as_principal(_service_account_principal()) + repo = MagicMock() + + await filelib_mod.retrieve_chunks( + request=MagicMock(), req=RetrieveReq(query="q", knowledge_base_ids=[8]), version_repo=repo + ) + + assert facade_spy.calls[0]["kwargs"]["version_repo"] is repo + + +async def test_missing_principal_is_refused_by_the_facade(as_principal, monkeypatch): + """AC-23 reaches v2 too: no principal in context, no retrieval. + + The real facade runs here on purpose — the point is that the endpoint does + not invent an identity of its own when the gate left none behind. + """ + + from bisheng.common.errcode.mcp_face import RetrievalIdentityMissingError + from bisheng.knowledge.domain.services import retrieval_facade_service as facade_mod + + as_principal(None) + monkeypatch.setattr(facade_mod, "RetrievalEngine", MagicMock(side_effect=AssertionError("must not retrieve"))) + + with pytest.raises(RetrievalIdentityMissingError): + await _call(RetrieveReq(query="q", knowledge_base_ids=[8])) diff --git a/src/backend/test/open_api/test_hosted_app_route_admission.py b/src/backend/test/open_api/test_hosted_app_route_admission.py new file mode 100644 index 0000000000..3ca1945d71 --- /dev/null +++ b/src/backend/test/open_api/test_hosted_app_route_admission.py @@ -0,0 +1,89 @@ +"""F055 AC-52 — which ``/api/v2`` routes a hosted application is admitted to. + +The declaration buys scopes, and a scope is much coarser than the set of routes +an application should get from it: declaring one knowledge base derives +``knowledge:read``, which covers seven routes. Six of them build a *natural +person* out of the credential — ``get_open_api_operator()`` resolves to the +application's owner, with the owner's roles — or, in ``download_statistic``'s +case, hand out any ``/app/data`` log file to whoever holds the scope. Only the +seventh goes through the capability bus. + +So admission is decided per route and defaults to refusal. This file is the +behavioural half; ``test_open_api_route_matrix`` holds the list of routes that +opted in, and ``test_capability_knowledge`` covers the second line of defence +inside ``get_open_api_operator`` itself (the MCP face and any other non-APIRoute +entrance never reaches the marker). +""" + +from unittest.mock import AsyncMock + +from fastapi import APIRouter, Depends, FastAPI +from httpx import ASGITransport, AsyncClient + +from bisheng.open_api.api.dependencies import verify_open_api_access +from bisheng.open_api.api.exception_handlers import register_open_api_exception_handlers +from bisheng.open_api.domain.scopes import open_api_scope +from test.open_api.model_gateway_fixtures import hosted_app_principal, service_account_principal + + +def _app() -> FastAPI: + """Two routes under the real dependency: one opted in, one not.""" + + app = FastAPI() + register_open_api_exception_handlers(app) + router = APIRouter(prefix="/api/v2", dependencies=[Depends(verify_open_api_access)]) + + inner = APIRouter(prefix="/filelib") + + @inner.post("/retrieve") + @open_api_scope("knowledge:read", hosted_app=True) + async def _retrieve() -> dict: + return {"ok": "bus"} + + @inner.get("/") + @open_api_scope("knowledge:read") + async def _list() -> dict: + return {"ok": "owner"} + + router.include_router(inner) + app.include_router(router) + return app + + +def _admit(monkeypatch, principal) -> None: + monkeypatch.setattr( + "bisheng.open_api.api.dependencies.validate_bearer", + AsyncMock(return_value=principal), + ) + + +async def _call(method: str, path: str): + async with AsyncClient(transport=ASGITransport(app=_app()), base_url="http://test") as client: + return await client.request(method, path, headers={"Authorization": "Bearer k"}) + + +async def test_a_hosted_application_reaches_the_capability_face(monkeypatch): + _admit(monkeypatch, hosted_app_principal(scopes=frozenset({"knowledge:read"}))) + + response = await _call("POST", "/api/v2/filelib/retrieve") + + assert response.status_code == 200 + + +async def test_the_same_scope_does_not_open_the_owner_executing_neighbours(monkeypatch): + """The whole point: same credential, same scope, different route.""" + _admit(monkeypatch, hosted_app_principal(scopes=frozenset({"knowledge:read"}))) + + response = await _call("GET", "/api/v2/filelib/") + + assert response.status_code == 403 + assert response.json()["status_code"] == 26052 + + +async def test_a_service_account_is_unaffected(monkeypatch): + """The refusal is keyed on the subject kind, not on the route.""" + _admit(monkeypatch, service_account_principal(scopes=frozenset({"knowledge:read"}))) + + response = await _call("GET", "/api/v2/filelib/") + + assert response.status_code == 200 diff --git a/src/backend/test/open_api/test_http_status.py b/src/backend/test/open_api/test_http_status.py index a44decd941..682d93eddc 100644 --- a/src/backend/test/open_api/test_http_status.py +++ b/src/backend/test/open_api/test_http_status.py @@ -3,6 +3,12 @@ from bisheng.common.errcode.http_error import NotFoundError, UnAuthorizedError from bisheng.common.errcode.knowledge_space import SpacePermissionDeniedError +from bisheng.common.errcode.mcp_face import ( + KnowledgeCapabilityRevokedError, + KnowledgeUnreachableError, + RetrievalIdentityMissingError, + RetrievalScopeTooLargeError, +) from bisheng.common.errcode.open_api import OpenApiCredentialInvalidError from bisheng.common.errcode.permission import ( PermissionInvalidResourceError, @@ -36,9 +42,75 @@ async def v2_example(): assert v2_response.json()["status_code"] == 26002 +async def test_mcp_face_errors_use_their_own_transport_status_on_v2(): + """263 rides the same handler as 260; the code-range fallback would say 400.""" + app = FastAPI() + register_open_api_exception_handlers(app) + + errors = { + "unreachable": (KnowledgeUnreachableError(unreachable_ids=[7]), 404, 26321), + "revoked": (KnowledgeCapabilityRevokedError(knowledge_id=7), 409, 26322), + "identity": (RetrievalIdentityMissingError(), 403, 26320), + "too-large": (RetrievalScopeTooLargeError(), 400, 26323), + } + + for name, (error, _status, _code) in errors.items(): + + def make(exc=error): + async def endpoint(): + raise exc + + return endpoint + + app.add_api_route(f"/api/v2/mcp-face/{name}", make(), methods=["GET"]) + app.add_api_route(f"/api/v1/mcp-face/{name}", make(), methods=["GET"]) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + for name, (_error, status, code) in errors.items(): + v2 = await client.get(f"/api/v2/mcp-face/{name}") + assert (v2.status_code, v2.json()["status_code"]) == (status, code) + # Everything outside /api/v2 keeps the platform's 200 envelope. + v1 = await client.get(f"/api/v1/mcp-face/{name}") + assert (v1.status_code, v1.json()["status_code"]) == (200, code) + + def test_v2_business_error_transport_mapping(): assert open_api_http_status(UnAuthorizedError()) == 403 + assert open_api_http_status(KnowledgeUnreachableError(unreachable_ids=[1])) == 404 + assert open_api_http_status(KnowledgeCapabilityRevokedError(knowledge_id=1)) == 409 assert open_api_http_status(SpacePermissionDeniedError()) == 403 assert open_api_http_status(NotFoundError()) == 404 assert open_api_http_status(PermissionInvalidResourceError()) == 404 assert open_api_http_status(PermissionServiceUnavailableError()) == 503 + + +def test_mcp_face_errors_carry_their_own_transport_status(): + """F052 T002 — module 263 declares its status; the generic tail would say 400.""" + + assert open_api_http_status(RetrievalIdentityMissingError()) == 403 + assert open_api_http_status(KnowledgeUnreachableError(unreachable_ids=[7])) == 404 + assert open_api_http_status(KnowledgeCapabilityRevokedError(knowledge_id=7)) == 409 + assert open_api_http_status(RetrievalScopeTooLargeError()) == 400 + + +async def test_mcp_face_error_status_applies_on_v2_only(): + app = FastAPI() + register_open_api_exception_handlers(app) + + @app.get("/api/v1/facade") + async def v1_facade(): + raise KnowledgeCapabilityRevokedError(knowledge_id=7) + + @app.get("/api/v2/facade") + async def v2_facade(): + raise KnowledgeCapabilityRevokedError(knowledge_id=7) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + v1_response = await client.get("/api/v1/facade") + v2_response = await client.get("/api/v2/facade") + + assert v1_response.status_code == 200 + assert v1_response.json()["status_code"] == 26322 + assert v2_response.status_code == 409 + assert v2_response.json()["status_code"] == 26322 + assert v2_response.json()["data"]["knowledge_id"] == 7 diff --git a/src/backend/test/open_api/test_last_used_redis_lease.py b/src/backend/test/open_api/test_last_used_redis_lease.py new file mode 100644 index 0000000000..a8da0c178f --- /dev/null +++ b/src/backend/test/open_api/test_last_used_redis_lease.py @@ -0,0 +1,51 @@ +"""A rejected NX acquisition must not renew last-used telemetry's throttle.""" + +import pickle +from types import SimpleNamespace +from unittest.mock import AsyncMock + + +async def exercise_real_adapter(): + from bisheng.core.cache.redis_conn import RedisClient + + writes = [] + clock = [0] + lease = [None] + + async def atomic_set(key, value, *, nx, ex): + writes.append((key, pickle.loads(value), nx, ex)) + if lease[0] is not None and clock[0] < lease[0]: + return False + lease[0] = clock[0] + ex + return True + + redis = SimpleNamespace(acluster_nodes=AsyncMock(), async_connection=SimpleNamespace(set=atomic_set)) + assert await RedisClient.asetNx(redis, "last-used", 1, expiration=60) + for clock[0] in [15, 30, 59]: + assert not await RedisClient.asetNx(redis, "last-used", 1, expiration=60) + clock[0] = 61 + assert await RedisClient.asetNx(redis, "last-used", 1, expiration=60) + assert writes == [("last-used", 1, True, 60)] * 5 + + +def test_nx_and_expiry_are_atomic_and_failed_calls_do_not_extend_ttl(): + import os + import subprocess + import sys + from pathlib import Path + + # The repository conftest intentionally pre-mocks redis_conn. Exercise the + # real production adapter in a fresh process without loading that conftest. + result = subprocess.run( + [sys.executable, str(Path(__file__).resolve())], + capture_output=True, + text=True, + env={**os.environ, "PYTHONPATH": str(Path(__file__).resolve().parents[2])}, + ) + assert result.returncode == 0, result.stderr + + +if __name__ == "__main__": + import asyncio + + asyncio.run(exercise_real_adapter()) diff --git a/src/backend/test/open_api/test_m1_integration.py b/src/backend/test/open_api/test_m1_integration.py index cf56e061bd..9b677c1b7d 100644 --- a/src/backend/test/open_api/test_m1_integration.py +++ b/src/backend/test/open_api/test_m1_integration.py @@ -1,6 +1,7 @@ from types import SimpleNamespace from sqlmodel import select +from starlette.requests import Request from bisheng.database.models.tenant import UserTenant from bisheng.open_api.api.endpoints.auth import whoami @@ -40,7 +41,9 @@ async def test_independent_service_account_round_trip_keeps_user_tables_unchange created_by=1, ) resolved = await validate_bearer(f"Bearer {issued.plaintext}") - response = await whoami(resolved) + # ``whoami`` takes the request since F051: it derives the model face's base + # URL from the browser-facing origin. + response = await whoami(_bare_request(), resolved) async with open_api_db() as session: assert len((await session.exec(select(User))).all()) == users_before @@ -65,3 +68,18 @@ async def test_independent_service_account_round_trip_keeps_user_tables_unchange assert service_account_actor.fga_subject == "service_account:1" assert user_actor != service_account_actor assert audit_events[0]["metadata"]["service_account_id"] == account.id + + +def _bare_request() -> Request: + return Request( + { + "type": "http", + "method": "GET", + "path": "/api/v2/auth/whoami", + "query_string": b"", + "headers": [(b"host", b"test")], + "scheme": "http", + "server": ("test", 80), + "root_path": "", + } + ) diff --git a/src/backend/test/open_api/test_mcp_face_error_codes.py b/src/backend/test/open_api/test_mcp_face_error_codes.py new file mode 100644 index 0000000000..56b5123fea --- /dev/null +++ b/src/backend/test/open_api/test_mcp_face_error_codes.py @@ -0,0 +1,57 @@ +"""Module 263 contract: band, annotation style, three-language copy (F052 T001).""" + +import json +import re +from pathlib import Path + +from bisheng.common.errcode import mcp_face + +LOCALE_DIR = Path(__file__).resolve().parents[3] / "frontend" / "packages" / "locales" / "src" / "api_errors" +LOCALES = ("zh-Hans.json", "en.json", "ja.json") + +#: The same regex ``src/frontend/scripts/check-i18n.mjs`` uses to collect +#: backend codes. A subclass written ``Code = 26301`` is invisible to it, so a +#: missing translation would sail through CI — assert the annotated form here. +ANNOTATED_CODE = re.compile(r"Code:\s*int\s*=\s*(\d+)") + + +def implemented_codes() -> set[int]: + return { + value.Code + for value in vars(mcp_face).values() + if isinstance(value, type) and value is not mcp_face.McpFaceError and issubclass(value, mcp_face.McpFaceError) + } + + +def test_mcp_face_codes_are_in_263_band_and_have_three_language_copy(): + codes = implemented_codes() | {mcp_face.McpFaceError.Code} + assert codes + assert all(26300 <= code <= 26339 for code in codes), sorted(codes) + + source = Path(mcp_face.__file__).read_text(encoding="utf-8") + assert {int(match) for match in ANNOTATED_CODE.findall(source)} == codes + + for name in LOCALES: + copy = json.loads((LOCALE_DIR / name).read_text(encoding="utf-8")) + missing = {code for code in codes if not copy.get(str(code))} + assert not missing, f"{name} is missing copy for {sorted(missing)}" + + +def test_mcp_face_does_not_squat_the_open_api_260_band(): + """263 is its own module; 260's reserved holes stay reserved.""" + assert implemented_codes().isdisjoint(range(26000, 26100)) + + +def test_every_mcp_face_code_declares_a_real_transport_status(): + for value in vars(mcp_face).values(): + if not isinstance(value, type) or not issubclass(value, mcp_face.McpFaceError): + continue + assert 400 <= value.http_status <= 599, value + + +def test_unreachable_and_revoked_statuses_keep_their_meaning(): + """404 for "unreachable" (same answer as "absent"), 409 for "revoked".""" + assert mcp_face.KnowledgeUnreachableError.http_status == 404 + assert mcp_face.KnowledgeCapabilityRevokedError.http_status == 409 + assert mcp_face.RetrievalIdentityMissingError.http_status == 403 + assert mcp_face.RetrievalScopeTooLargeError.http_status == 400 diff --git a/src/backend/test/open_api/test_mcp_tenant_revalidation.py b/src/backend/test/open_api/test_mcp_tenant_revalidation.py new file mode 100644 index 0000000000..63c86f2a34 --- /dev/null +++ b/src/backend/test/open_api/test_mcp_tenant_revalidation.py @@ -0,0 +1,67 @@ +"""F113: tool admission must not reuse the transport's ownership snapshot.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from mcp import types +from sqlmodel import select + +from bisheng.database.models.tenant import UserTenant +from bisheng.open_api.application.service_account_tenancy import revalidate_service_account +from bisheng.open_api.domain.context import ( + get_current_open_api_principal, + reset_current_open_api_principal, + set_current_open_api_principal, +) +from bisheng.open_mcp import server +from bisheng.open_mcp.auth import current_mcp_scope +from bisheng.open_mcp.contracts import ResourceListData +from test.open_api.test_service_account_tenancy import principal, seed + + +@pytest.mark.parametrize("moved", [False, True]) +async def test_tool_uses_fresh_binding_and_preserves_shared_transport_scope(open_api_db, monkeypatch, moved): + # The handler is under test, not the SDK transport constructor. + monkeypatch.setattr(server, "StreamableHTTPSessionManager", lambda **kw: SimpleNamespace(app=kw["app"])) + account = await seed(open_api_db) + original = principal(account) + scope = {"type": "http", "headers": [(b"authorization", b"Bearer f113")], "open_api_principal": original} + calls = [] + + async def validate(header): + assert header == "Bearer f113" + calls.append(header) + return await revalidate_service_account(original) + + async def execute(name, arguments): + assert get_current_open_api_principal().authorization_subject_type == "service_account" + return ResourceListData(data=[], page_size=10, has_more=False) + + executor = AsyncMock(side_effect=execute) + monkeypatch.setattr(server, "execute_tool", executor) + monkeypatch.setattr(server.open_api_call_audit_service, "enqueue", lambda _event: None) + monkeypatch.setattr(server, "validate_bearer", validate, raising=False) + if moved: + async with open_api_db() as session: + member = (await session.exec(select(UserTenant))).one() + member.tenant_id = 2 + session.add(member) + await session.commit() + token = set_current_open_api_principal(original) + scope_token = current_mcp_scope.set(scope) + try: + runtime = server.create_open_mcp_runtime() + result = await runtime.manager.app.request_handlers[types.CallToolRequest]( + types.CallToolRequest( + params=types.CallToolRequestParams(name="bisheng_knowledge_list", arguments={"type": 0}) + ) + ) + assert result.root.isError is moved + assert executor.await_count == (0 if moved else 1) + assert len(calls) == 1 + assert get_current_open_api_principal() is original + assert scope["open_api_principal"] is original + finally: + current_mcp_scope.reset(scope_token) + reset_current_open_api_principal(token) diff --git a/src/backend/test/open_api/test_model_call_record.py b/src/backend/test/open_api/test_model_call_record.py new file mode 100644 index 0000000000..a67ef3c7eb --- /dev/null +++ b/src/backend/test/open_api/test_model_call_record.py @@ -0,0 +1,295 @@ +"""F051 T017: the per-call usage record — what lands, what never does. + +Two boundaries are asserted here rather than described: + +* a call refused **before** model resolution produces an audit row and no usage + row, while one refused at resolution produces both — that is the line AC-20 + draws, and it is invisible in the code without a test; +* AC-25's "no aggregation, no quota" is a SHALL NOT, which only stays true if + something fails when someone adds a ``/usage`` endpoint next year. +""" + +import asyncio +from contextlib import asynccontextmanager +from datetime import datetime, timedelta +from unittest.mock import AsyncMock + +import pytest +from httpx import ASGITransport, AsyncClient +from langchain_core.messages import AIMessage +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.pool import StaticPool +from sqlmodel.ext.asyncio.session import AsyncSession + +from bisheng.core.config.open_platform import OpenApiConf +from bisheng.open_api.domain.models.model_call_record import ModelCallRecord +from bisheng.open_api.domain.repositories.model_call_record_repository import ModelCallRecordRepository +from bisheng.open_api.domain.services.model_call_record_writer import ModelCallRecordWriter +from test.open_api.model_gateway_fixtures import ( + FakeBishengLLM, + build_model_face_app, + capture_records, + install_catalog, + install_fake_llm, + model_row, + server_row, + service_account_principal, +) + +CHAT_PATH = "/api/v2/model/v1/chat/completions" +MODELS_PATH = "/api/v2/model/v1/models" +BODY = {"model": "gpt-4o", "messages": [{"role": "user", "content": "the quick brown fox"}]} + + +@pytest.fixture +def records(monkeypatch): + monkeypatch.setattr( + "bisheng.open_api.api.dependencies.validate_bearer", + AsyncMock(return_value=service_account_principal()), + ) + install_catalog(monkeypatch, [server_row(1, "azure-openai")], [model_row(10, 1, "gpt-4o")]) + return capture_records(monkeypatch) + + +@pytest.fixture +async def record_db(monkeypatch): + engine = create_async_engine( + "sqlite+aiosqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + async with engine.begin() as connection: + await connection.run_sync(ModelCallRecord.__table__.create) + + @asynccontextmanager + async def session_factory(): + session = AsyncSession(engine, expire_on_commit=False) + try: + yield session + finally: + await session.close() + + import bisheng.open_api.domain.repositories.model_call_record_repository as repository_module + + monkeypatch.setattr(repository_module, "get_async_db_session", session_factory) + yield session_factory + await engine.dispose() + + +async def _post(payload: dict): + app = build_model_face_app() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + return await client.post(CHAT_PATH, json=payload) + + +async def test_a_successful_call_records_every_traceable_dimension(monkeypatch, records): + install_fake_llm( + monkeypatch, + FakeBishengLLM( + invoke_result=AIMessage( + content="hi", + usage_metadata={"input_tokens": 9, "output_tokens": 2, "total_tokens": 11}, + ) + ), + ) + + response = await _post(BODY) + + row = records[0] + assert (row.tenant_id, row.credential_id) == (9, 7) + assert (row.actor_kind, row.actor_id, row.actor_name) == ("service_account", 31, "local-dev") + assert row.resource_owner_user_id == 12 + assert row.subject_kind == "service_account" + assert (row.requested_model, row.model_id, row.model_name) == ("gpt-4o", 10, "gpt-4o") + assert (row.server_id, row.server_name, row.server_type) == (1, "azure-openai", "openai") + assert row.result == "success" + assert row.latency_ms is not None and row.latency_ms >= 0 + assert row.request_id == response.json()["id"] + + +async def test_a_call_refused_at_model_resolution_still_gets_a_row(monkeypatch, records): + install_fake_llm(monkeypatch, FakeBishengLLM()) + + await _post({**BODY, "model": "never-configured"}) + + row = records[0] + assert row.result == "model_unavailable" + assert row.error_code == 26511 + assert row.model_id is None + # The name the caller actually wrote is what makes the row diagnosable. + assert row.requested_model == "never-configured" + + +async def test_a_call_refused_before_model_resolution_gets_no_usage_row(monkeypatch): + from bisheng.common.errcode.open_api import OpenApiScopeMissingError + + monkeypatch.setattr( + "bisheng.open_api.api.dependencies.validate_bearer", + AsyncMock(side_effect=OpenApiScopeMissingError(required="model:invoke")), + ) + rows = capture_records(monkeypatch) + + response = await _post(BODY) + + # Such calls are not lost — the /api/v2 audit middleware writes one + # ``open_api.call`` row for every request including this one. They simply do + # not belong in the usage ledger, having consumed nothing. + assert response.status_code == 403 + assert rows == [] + + +async def test_listing_models_is_not_a_model_call(monkeypatch, records): + install_fake_llm(monkeypatch, FakeBishengLLM()) + app = build_model_face_app() + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get(MODELS_PATH) + + assert response.status_code == 200 + assert records == [] + + +async def test_no_message_body_or_credential_plaintext_reaches_the_row(monkeypatch, records): + install_fake_llm(monkeypatch, FakeBishengLLM(invoke_result=AIMessage(content="secret answer"))) + + await _post(BODY) + + serialized = records[0].model_dump_json() + assert "the quick brown fox" not in serialized + assert "secret answer" not in serialized + assert "bs-sak-" not in serialized + assert "Authorization" not in serialized + + +async def test_rows_are_written_in_batches_and_flushed_on_stop(record_db): + writer = ModelCallRecordWriter(batch_size=10, flush_interval_seconds=0.05) + writer.enqueue(_row(requested_model="a", credential_mask="bs-sak-********aaaa")) + writer.enqueue(_row(requested_model="b", credential_mask="bs-sak-********bbbb")) + writer.start() + await asyncio.sleep(0.15) + await writer.stop() + + rows, _cursor = await ModelCallRecordRepository.alist(9) + assert {row.requested_model for row in rows} == {"a", "b"} + + +async def test_the_credential_mask_is_hydrated_per_batch_not_per_call(monkeypatch, record_db): + from types import SimpleNamespace + + import bisheng.open_api.domain.repositories.credential_repository as credential_module + + lookups: list = [] + + async def get_by_ids(ids): + lookups.append(list(ids)) + return [SimpleNamespace(id=7, key_mask="bs-sak-********7777")] + + monkeypatch.setattr(credential_module.CredentialRepository, "get_by_ids", staticmethod(get_by_ids)) + + writer = ModelCallRecordWriter(flush_interval_seconds=0.05) + writer.enqueue(_row(requested_model="a")) + writer.enqueue(_row(requested_model="b")) + await writer.flush_now() + + rows, _cursor = await ModelCallRecordRepository.alist(9) + assert {row.credential_mask for row in rows} == {"bs-sak-********7777"} + # One query per batch, not one per call: the mask is cosmetic and must not + # cost the request path a database read. + assert lookups == [[7]] + + +def test_a_full_queue_is_reported_rather_than_silently_dropped(monkeypatch): + import bisheng.open_api.domain.services.model_call_record_writer as writer_module + + emitted: list = [] + monkeypatch.setattr(writer_module, "emit_metric", lambda domain, **fields: emitted.append((domain, fields))) + writer = ModelCallRecordWriter(max_queue_size=1) + + assert writer.enqueue(_row()) is True + assert writer.enqueue(_row()) is False + + # Write-behind is best-effort by design, but a lost row that nobody can see + # is indistinguishable from a call that never happened. + assert emitted == [("model_call_record", {"status": "dropped", "batch_size": 1})] + + +async def test_paging_is_stable_when_rows_share_a_second(record_db): + stamp = datetime(2026, 9, 16, 10, 0, 0) + rows = [_row(requested_model=f"m{index}", create_time=stamp) for index in range(20)] + await ModelCallRecordRepository.ainsert_batch(rows) + + first, cursor = await ModelCallRecordRepository.alist(9, limit=10) + second, _ = await ModelCallRecordRepository.alist(9, limit=10, cursor=cursor) + whole, _ = await ModelCallRecordRepository.alist(9, limit=100) + + # create_time is second-precision: without the id tiebreaker the page + # boundary is non-deterministic and rows duplicate across pages. + assert [row.id for row in first + second] == [row.id for row in whole] + assert len({row.id for row in first + second}) == 20 + + +async def test_the_three_promised_filters_narrow_the_listing(record_db): + stamp = datetime(2026, 9, 16, 10, 0, 0) + await ModelCallRecordRepository.ainsert_batch( + [ + _row(requested_model="by-key", credential_id=7, create_time=stamp), + _row(requested_model="other-key", credential_id=8, create_time=stamp), + _row(requested_model="by-app", credential_id=8, app_id="survey-app", create_time=stamp), + _row(requested_model="old", credential_id=8, create_time=stamp - timedelta(days=2)), + ] + ) + + by_key, _ = await ModelCallRecordRepository.alist(9, credential_id=7) + by_app, _ = await ModelCallRecordRepository.alist(9, app_id="survey-app") + by_time, _ = await ModelCallRecordRepository.alist(9, time_from=stamp - timedelta(hours=1)) + + assert {row.requested_model for row in by_key} == {"by-key"} + assert {row.requested_model for row in by_app} == {"by-app"} + assert "old" not in {row.requested_model for row in by_time} + + +async def test_another_tenants_rows_are_not_listed(record_db): + await ModelCallRecordRepository.ainsert_batch( + [_row(tenant_id=9, requested_model="mine"), _row(tenant_id=10, requested_model="theirs")] + ) + + rows, _ = await ModelCallRecordRepository.alist(9) + + assert {row.requested_model for row in rows} == {"mine"} + + +async def test_export_walks_every_page(record_db): + await ModelCallRecordRepository.ainsert_batch([_row(requested_model=f"m{index}") for index in range(7)]) + + exported = [row.requested_model async for row in ModelCallRecordRepository.aiter_export(9, page_size=3)] + + assert len(exported) == 7 + assert len(set(exported)) == 7 + + +def test_no_aggregation_or_quota_surface_exists(): + from bisheng.open_api.api.endpoints.model_gateway import router + + paths = {route.path for route in router.routes} + assert paths == {"/model/v1/chat/completions", "/model/v1/models", "/model/v1/{rest:path}"} + + public_methods = {name for name in vars(ModelCallRecordRepository) if not name.startswith("_")} + # This release meters per call and does nothing else. Leaving that as a + # sentence in a design document is how a /usage endpoint appears later with + # nothing turning red. + assert public_methods == {"ainsert_batch", "alist", "aiter_export"} + assert not [name for name in OpenApiConf.model_fields if name.endswith(("_limit", "_quota"))] + + +def _row(**overrides) -> ModelCallRecord: + payload = { + "tenant_id": 9, + "credential_id": 7, + "actor_kind": "service_account", + "actor_id": 31, + "requested_model": "gpt-4o", + "result": "success", + "create_time": datetime(2026, 9, 16, 10, 0, 0), + } + payload.update(overrides) + return ModelCallRecord(**payload) diff --git a/src/backend/test/open_api/test_model_gateway_auth.py b/src/backend/test/open_api/test_model_gateway_auth.py new file mode 100644 index 0000000000..45d04ebac8 --- /dev/null +++ b/src/backend/test/open_api/test_model_gateway_auth.py @@ -0,0 +1,187 @@ +"""F051 T012 (auth half): credential admission on the model protocol face. + +None of this is new authentication code — the face inherits the one ``/api/v2`` +credential dependency. What is asserted here is that the inherited verdicts +arrive in the OpenAI error shape, which is the only thing an official client can +read, and that the one gap in the shared base (a well-formed ``X-End-User`` +being adopted in silence) is closed by this face rather than left open. +""" + +from unittest.mock import AsyncMock + +import pytest +from httpx import ASGITransport, AsyncClient + +from bisheng.common.errcode.open_api import ( + OpenApiCredentialInvalidError, + OpenApiCredentialMissingError, + OpenApiDelegateLocalDevRefusedError, + OpenApiScopeMissingError, +) +from test.open_api.model_gateway_fixtures import ( + FakeBishengLLM, + build_model_face_app, + capture_records, + install_fake_llm, + service_account_principal, +) + +CHAT_PATH = "/api/v2/model/v1/chat/completions" +MODELS_PATH = "/api/v2/model/v1/models" +BODY = {"model": "gpt-4o", "messages": [{"role": "user", "content": "hi"}]} + + +def _admit(monkeypatch, principal_or_error): + if isinstance(principal_or_error, Exception): + mock = AsyncMock(side_effect=principal_or_error) + else: + mock = AsyncMock(return_value=principal_or_error) + monkeypatch.setattr("bisheng.open_api.api.dependencies.validate_bearer", mock) + + +async def _call(app, method: str, path: str, **kwargs): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + return await client.request(method, path, **kwargs) + + +@pytest.mark.parametrize( + ("error", "code"), + [ + (OpenApiCredentialMissingError(), 26001), + (OpenApiCredentialInvalidError(), 26002), + ], +) +async def test_credential_failures_are_openai_shaped_401s(monkeypatch, error, code): + _admit(monkeypatch, error) + + response = await _call(build_model_face_app(), "POST", CHAT_PATH, json=BODY) + + assert response.status_code == 401 + body = response.json()["error"] + assert body["type"] == "authentication_error" + assert body["code"] == "invalid_api_key" + assert body["bisheng_code"] == code + + +@pytest.mark.parametrize("path,method", [(CHAT_PATH, "POST"), (MODELS_PATH, "GET")]) +async def test_a_key_without_model_invoke_is_refused_on_both_promised_endpoints(monkeypatch, path, method): + _admit(monkeypatch, service_account_principal(scopes=frozenset({"chat:invoke"}))) + + response = await _call(build_model_face_app(), method, path, json=BODY if method == "POST" else None) + + assert response.status_code == 403 + body = response.json()["error"] + assert body["bisheng_code"] == 26003 + assert body["code"] == "insufficient_scope" + # The agent has to learn *which* scope to ask an administrator for. + assert "model:invoke" in body["message"] + + +async def test_model_invoke_does_not_open_the_daily_chat_data_plane(monkeypatch): + # The two scopes do not imply each other in either direction. The forward + # half is asserted above; this is the reverse, and it is the half that would + # quietly turn a bare-passthrough key into a session-creating one. + from bisheng.main import app + + _admit(monkeypatch, service_account_principal(scopes=frozenset({"model:invoke"}))) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.post( + "/api/v2/workstation/chat/completions", + json={"model": "gpt-4o", "messages": [{"role": "user", "content": "hi"}]}, + headers={"Authorization": "Bearer x"}, + ) + + assert response.status_code == 403 + # A neighbouring path, so the platform envelope — not the OpenAI shape. + assert response.json()["status_code"] == 26003 + + +async def test_scope_edits_take_effect_on_the_next_call_without_reissuing(monkeypatch): + app = build_model_face_app() + records = capture_records(monkeypatch) + install_fake_llm(monkeypatch, FakeBishengLLM()) + + _admit(monkeypatch, service_account_principal(scopes=frozenset({"chat:invoke"}))) + refused = await _call(app, "GET", MODELS_PATH) + + _admit(monkeypatch, service_account_principal(scopes=frozenset({"chat:invoke", "model:invoke"}))) + monkeypatch.setattr( + "bisheng.open_api.domain.services.model_gateway_service.list_callable_chat_models", + AsyncMock(return_value=[]), + ) + allowed = await _call(app, "GET", MODELS_PATH) + + assert refused.status_code == 403 + assert allowed.status_code == 200 + # Listing models is not a model call and never produces a usage row. + assert records == [] + + +async def test_delegation_only_key_is_refused_by_scope_not_by_a_parameter_error(monkeypatch): + _admit(monkeypatch, OpenApiDelegateLocalDevRefusedError()) + + for path, method in ((CHAT_PATH, "POST"), (MODELS_PATH, "GET"), ("/api/v2/model/v1/embeddings", "POST")): + response = await _call(build_model_face_app(), method, path, json=BODY if method == "POST" else None) + assert response.status_code == 403 + body = response.json()["error"] + assert body["bisheng_code"] == 26051 + assert body["code"] == "delegate_only_credential" + + +async def test_an_unregistered_scope_error_still_renders_openai_shaped(monkeypatch): + _admit(monkeypatch, OpenApiScopeMissingError(required="model:invoke")) + + response = await _call(build_model_face_app(), "GET", MODELS_PATH) + + assert response.status_code == 403 + assert response.json()["error"]["bisheng_code"] == 26003 + + +@pytest.mark.parametrize( + ("headers", "code"), + [ + ({"X-On-Behalf-Of": "9"}, 26004), + ({"X-End-User": "external-1"}, 26505), + ({"X-On-Behalf-Of": "9", "X-End-User": "external-1"}, 26010), + ({"x-foo-on-behalf-of": "9"}, 26019), + ], +) +async def test_identity_headers_are_refused_and_never_reach_the_model(monkeypatch, headers, code): + _admit(monkeypatch, service_account_principal()) + records = capture_records(monkeypatch) + fake = install_fake_llm(monkeypatch, FakeBishengLLM()) + + response = await _call(build_model_face_app(), "POST", CHAT_PATH, json=BODY, headers=headers) + + assert response.status_code in (400, 403) + assert response.json()["error"]["bisheng_code"] == code + assert fake.seen_messages == [] + assert records == [] + + +async def test_a_user_id_in_the_body_is_refused(monkeypatch): + _admit(monkeypatch, service_account_principal()) + + response = await _call(build_model_face_app(), "POST", CHAT_PATH, json={**BODY, "user_id": 9}) + + assert response.json()["error"]["bisheng_code"] == 26019 + + +async def test_an_access_token_on_a_service_account_key_is_refused(monkeypatch): + _admit(monkeypatch, service_account_principal()) + records = capture_records(monkeypatch) + fake = install_fake_llm(monkeypatch, FakeBishengLLM()) + + response = await _call( + build_model_face_app(), + "POST", + CHAT_PATH, + json=BODY, + headers={"X-BiSheng-Access-Token": "whatever"}, + ) + + assert response.status_code == 403 + assert response.json()["error"]["bisheng_code"] == 26504 + assert fake.seen_messages == [] + assert records == [] diff --git a/src/backend/test/open_api/test_model_gateway_base_url.py b/src/backend/test/open_api/test_model_gateway_base_url.py new file mode 100644 index 0000000000..c8291ae0bd --- /dev/null +++ b/src/backend/test/open_api/test_model_gateway_base_url.py @@ -0,0 +1,97 @@ +"""F051 T020: one stable base URL, wherever the platform is deployed. + +AC-30 is "single and stable": not per tenant, not per key, not per deployment +form. The three resolution sources are already covered for skill packs; what is +asserted here is that the model face's URL is derived from the same one place +and that two different credentials get the same answer. +""" + +from unittest.mock import AsyncMock + +import pytest +from httpx import ASGITransport, AsyncClient + +from bisheng.common.services.config_service import settings +from bisheng.open_api.domain.services.public_base_url import MODEL_GATEWAY_BASE_PATH, model_gateway_base_url +from test.open_api.model_gateway_fixtures import service_account_principal + + +class _FakeRequest: + def __init__(self, headers: dict, scheme: str = "http"): + self.headers = headers + self.scope = {"root_path": "", "server": ("10.0.0.5", 7860)} + self.url = type("U", (), {"scheme": scheme})() + + +@pytest.fixture(autouse=True) +def _no_configured_base_url(monkeypatch): + monkeypatch.setattr(settings.open_api, "public_base_url", "") + + +def test_the_operator_declared_address_wins(monkeypatch): + monkeypatch.setattr(settings.open_api, "public_base_url", "https://portal.example.com/bisheng") + + assert model_gateway_base_url(_FakeRequest({})) == (f"https://portal.example.com/bisheng{MODEL_GATEWAY_BASE_PATH}") + + +def test_the_reverse_proxy_headers_are_used_next(): + url = model_gateway_base_url(_FakeRequest({"x-forwarded-proto": "https", "x-forwarded-host": "kb.example.com"})) + + assert url == f"https://kb.example.com{MODEL_GATEWAY_BASE_PATH}" + + +def test_the_host_header_is_the_last_resort(): + url = model_gateway_base_url(_FakeRequest({"host": "192.168.106.114:3001"})) + + assert url == f"http://192.168.106.114:3001{MODEL_GATEWAY_BASE_PATH}" + + +def test_the_url_keeps_the_v1_segment_and_has_no_trailing_slash(): + url = model_gateway_base_url(_FakeRequest({"host": "kb.example.com"})) + + # Most engines treat "ends with /v1" as the check for an OpenAI-compatible + # address, and official clients append only /chat/completions or /models. + assert url.endswith("/api/v2/model/v1") + assert not url.endswith("/") + + +async def test_whoami_hands_every_key_the_same_address(monkeypatch): + from bisheng.main import app + + monkeypatch.setattr(settings.open_platform, "enabled", True) + monkeypatch.setattr(settings.open_api, "public_base_url", "https://kb.example.com") + monkeypatch.setattr( + "bisheng.open_api.domain.repositories.credential_repository.CredentialRepository.get", + AsyncMock(return_value=None), + ) + + urls = set() + for tenant_id, credential_id in ((9, 7), (11, 8)): + monkeypatch.setattr( + "bisheng.open_api.api.dependencies.validate_bearer", + AsyncMock(return_value=service_account_principal(tenant_id=tenant_id, credential_id=credential_id)), + ) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get("/api/v2/auth/whoami", headers={"Authorization": "Bearer x"}) + urls.add(response.json()["data"]["model_base_url"]) + + assert urls == {"https://kb.example.com/api/v2/model/v1"} + + +async def test_whoami_reports_no_address_where_the_face_is_not_deployed(monkeypatch): + from bisheng.main import app + + monkeypatch.setattr(settings.open_platform, "enabled", False) + monkeypatch.setattr( + "bisheng.open_api.domain.repositories.credential_repository.CredentialRepository.get", + AsyncMock(return_value=None), + ) + monkeypatch.setattr( + "bisheng.open_api.api.dependencies.validate_bearer", + AsyncMock(return_value=service_account_principal()), + ) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get("/api/v2/auth/whoami", headers={"Authorization": "Bearer x"}) + + assert response.json()["data"]["model_base_url"] == "" diff --git a/src/backend/test/open_api/test_model_gateway_errors.py b/src/backend/test/open_api/test_model_gateway_errors.py new file mode 100644 index 0000000000..deb9e2fd9f --- /dev/null +++ b/src/backend/test/open_api/test_model_gateway_errors.py @@ -0,0 +1,186 @@ +"""F051 T012 (errors half): what the face refuses, and in what shape.""" + +from unittest.mock import AsyncMock + +import pytest +from httpx import ASGITransport, AsyncClient + +from test.open_api.model_gateway_fixtures import ( + FakeBishengLLM, + build_model_face_app, + capture_records, + install_catalog, + install_fake_llm, + model_row, + server_row, + service_account_principal, +) + +CHAT_PATH = "/api/v2/model/v1/chat/completions" +BODY = {"model": "gpt-4o", "messages": [{"role": "user", "content": "hi"}]} + + +@pytest.fixture +def admitted(monkeypatch): + monkeypatch.setattr( + "bisheng.open_api.api.dependencies.validate_bearer", + AsyncMock(return_value=service_account_principal()), + ) + install_catalog(monkeypatch, [server_row(1, "azure-openai")], [model_row(10, 1, "gpt-4o")]) + install_fake_llm(monkeypatch, FakeBishengLLM()) + return capture_records(monkeypatch) + + +async def _call(method: str, path: str, **kwargs): + app = build_model_face_app() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + return await client.request(method, path, **kwargs) + + +async def test_an_empty_message_list_is_a_readable_400(admitted): + response = await _call("POST", CHAT_PATH, json={"model": "gpt-4o", "messages": []}) + + assert response.status_code == 400 + body = response.json()["error"] + assert body["bisheng_code"] == 26503 + assert body["type"] == "invalid_request_error" + assert body["param"] == "messages" + + +async def test_more_than_one_candidate_is_refused_rather_than_quietly_under_delivered(admitted): + response = await _call("POST", CHAT_PATH, json={**BODY, "n": 2}) + + assert response.status_code == 400 + assert response.json()["error"]["param"] == "n" + + +async def test_a_refused_header_is_reported_before_a_bad_body(admitted): + response = await _call("POST", CHAT_PATH, json={**BODY, "n": 2}, headers={"X-End-User": "external-1"}) + + # Two things are wrong; the caller hears about the header. Answering the + # body first would give an agent a different verdict on each retry. + assert response.json()["error"]["bisheng_code"] == 26505 + + +@pytest.mark.parametrize( + ("method", "path"), + [ + ("POST", "/api/v2/model/v1/embeddings"), + ("POST", "/api/v2/model/v1/completions"), + ("POST", "/api/v2/model/v1/responses"), + ("POST", "/api/v2/model/v1/images/generations"), + ("POST", "/api/v2/model/v1/audio/speech"), + ("GET", "/api/v2/model/v1/files"), + ("DELETE", "/api/v2/model/v1/assistants/a1"), + ], +) +async def test_the_rest_of_the_openai_family_is_refused_readably(admitted, method, path): + response = await _call(method, path, json={} if method == "POST" else None) + + assert response.status_code == 404 + body = response.json()["error"] + assert body["bisheng_code"] == 26501 + assert body["code"] == "endpoint_not_supported" + + +@pytest.mark.parametrize( + "path", + [ + "/api/v2/model/v1/messages", + # What an Anthropic client actually produces when someone points + # ANTHROPIC_BASE_URL at the address we publish: it is handed an origin, + # not a versioned base, and appends /v1/messages itself. + "/api/v2/model/v1/v1/messages", + ], +) +async def test_the_anthropic_messages_path_gets_its_own_answer(admitted, path): + response = await _call("POST", path, json={}) + + assert response.status_code == 404 + body = response.json()["error"] + assert body["bisheng_code"] == 26502 + assert body["code"] == "anthropic_protocol_not_supported" + + +async def test_credentials_are_judged_before_the_path_is(monkeypatch): + from bisheng.common.errcode.open_api import OpenApiCredentialMissingError + + monkeypatch.setattr( + "bisheng.open_api.api.dependencies.validate_bearer", + AsyncMock(side_effect=OpenApiCredentialMissingError()), + ) + + response = await _call("POST", "/api/v2/model/v1/embeddings", json={}) + + # Otherwise an unauthenticated caller could enumerate which endpoints exist. + assert response.status_code == 401 + assert response.json()["error"]["bisheng_code"] == 26001 + + +async def test_an_unresolvable_model_is_a_404_with_the_reason(admitted): + response = await _call("POST", CHAT_PATH, json={**BODY, "model": "never-configured"}) + + assert response.status_code == 404 + body = response.json()["error"] + assert body["bisheng_code"] == 26511 + assert body["code"] == "model_not_found" + + +async def test_an_ambiguous_bare_name_lists_the_qualified_alternatives(monkeypatch, admitted): + install_catalog( + monkeypatch, + [server_row(1, "azure-openai"), server_row(2, "qwen-cloud")], + [model_row(10, 1, "shared"), model_row(11, 2, "shared")], + ) + + response = await _call("POST", CHAT_PATH, json={**BODY, "model": "shared"}) + + assert response.status_code == 400 + body = response.json()["error"] + assert body["bisheng_code"] == 26514 + assert body["candidates"] == ["azure-openai/shared", "qwen-cloud/shared"] + + +async def test_an_undecidable_catalog_refuses_with_503_rather_than_a_narrower_set(monkeypatch, admitted): + install_catalog(monkeypatch, [], [], fail_with=RuntimeError("openfga unreachable")) + + response = await _call("POST", CHAT_PATH, json=BODY) + + assert response.status_code == 503 + assert response.json()["error"]["bisheng_code"] == 26516 + + +async def test_a_permission_backend_outage_is_still_openai_shaped(monkeypatch, admitted): + from bisheng.common.errcode.permission import PermissionServiceUnavailableError + + async def explode(*_args, **_kwargs): + raise PermissionServiceUnavailableError() + + monkeypatch.setattr( + "bisheng.open_api.domain.services.model_gateway_service.list_callable_chat_models", + explode, + ) + + response = await _call("GET", "/api/v2/model/v1/models") + + # That error has its own registered handler, which bypasses the shared + # dispatch — without a branch there it would come back as the envelope. + assert response.status_code == 503 + assert response.json()["error"]["type"] == "server_error" + + +async def test_neighbouring_v2_paths_keep_the_platform_envelope(monkeypatch): + from bisheng.common.errcode.open_api import OpenApiCredentialMissingError + from bisheng.main import app + + monkeypatch.setattr( + "bisheng.open_api.api.dependencies.validate_bearer", + AsyncMock(side_effect=OpenApiCredentialMissingError()), + ) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get("/api/v2/auth/whoami") + + assert response.status_code == 401 + assert response.json()["status_code"] == 26001 + assert "error" not in response.json() diff --git a/src/backend/test/open_api/test_model_gateway_hosted_app.py b/src/backend/test/open_api/test_model_gateway_hosted_app.py new file mode 100644 index 0000000000..20ebb646be --- /dev/null +++ b/src/backend/test/open_api/test_model_gateway_hosted_app.py @@ -0,0 +1,484 @@ +"""F051 T022 / T023: the face as a **hosted application** sees it. + +The three files before this one prove the face works for a service-account key. +This one is about the other credential kind, and everything that is different +about it lives in two places: + +* the **range** is the effective capability declaration intersected with what + the tenant has enabled, read through F055's real + ``HostedAppDeclarationAdapter`` rather than a hand-written fake — the fake + cannot get the ``None`` / empty-set distinction wrong, and that distinction + is the whole difference between 26516 and 26515; +* the **subject** is the visitor whose OBO token the application forwarded, + verified by F054's real ``verify_obo_token``. A call with no token is + attributed to ``app_self`` **explicitly**, and one with a broken token is + refused rather than quietly downgraded to it — otherwise the ledger's user + dimension would be something the caller can erase by sending garbage. + +Both ports are exercised through the objects that ship, wired the way +``app_publish/composition.py`` wires them. ``test_model_range_policy`` covers +the same decisions with fakes at the unit level; the point here is that the +registered implementations agree with it. +""" + +from __future__ import annotations + +import json +import time +from unittest.mock import AsyncMock + +import jwt +import pytest +from httpx import ASGITransport, AsyncClient +from langchain_core.messages import AIMessage + +from bisheng.common.services.config_service import settings +from bisheng.open_api.domain.models.model_call_record import SUBJECT_KIND_APP_SELF, SUBJECT_KIND_USER +from bisheng.open_api.domain.services import model_range_policy +from bisheng.open_api.domain.services.model_range_policy import ( + ACCESS_TOKEN_HEADER, + register_access_subject_verifier, + register_hosted_app_declaration_port, +) +from test.open_api.model_gateway_fixtures import ( + FakeBishengLLM, + build_model_face_app, + capture_records, + hosted_app_principal, + install_catalog, + install_fake_llm, + model_row, + server_row, +) + +CHAT_PATH = "/api/v2/model/v1/chat/completions" +MODELS_PATH = "/api/v2/model/v1/models" + +APP_ID = "app-uuid-survey" +TENANT_ID = 9 +VISITOR_USER_ID = 77 + +OBO_SECRET = "f051-hosted-app-obo-secret" + +BODY = {"model": "gpt-4o", "messages": [{"role": "user", "content": "hi"}]} + + +# --- wiring ------------------------------------------------------------------ + + +@pytest.fixture(autouse=True) +def _restore_ports(): + """Both ports are process-wide; put back whatever was installed. + + Restoring only the one a test replaced is the shape that hurts: a suite that + leaves F055's real adapter registered makes the *next* file's hosted-app + test read a database that is not there. + """ + previous = ( + model_range_policy.get_hosted_app_declaration_port(), + model_range_policy.get_access_subject_verifier(), + ) + yield + register_hosted_app_declaration_port(previous[0]) + register_access_subject_verifier(previous[1]) + + +@pytest.fixture +def declaration(monkeypatch): + """Register F055's real adapter over a scripted effective declaration. + + ``load_effective_declaration`` is the only thing stubbed — everything the + adapter does with its answer (the tenant comparison, ``model_names``, the + ``None`` passthrough) is the shipped code. + """ + from bisheng.app_publish.domain.services import capability_bus_service + from bisheng.app_publish.domain.services.capability_bus_service import ( + EffectiveDeclaration, + HostedAppDeclarationAdapter, + ) + + def _install(models: tuple[str, ...] | None, *, tenant_id: int = TENANT_ID): + async def _load(app_id: str): + if models is None: + # What the adapter is handed for an application that is not + # online, was deleted, or has no current version. + return None + return EffectiveDeclaration( + app_id=app_id, + tenant_id=tenant_id, + version_id="v1", + app_name="survey", + models=models, + ) + + monkeypatch.setattr(capability_bus_service, "load_effective_declaration", _load) + register_hosted_app_declaration_port(HostedAppDeclarationAdapter()) + + return _install + + +@pytest.fixture +def obo(monkeypatch): + """Register F054's real verifier and hand back a token factory. + + The secret must differ from ``jwt_secret`` or the issuer refuses to sign at + all (an OBO token that doubles as a platform session is the failure that + check exists for), so both are pinned here. + """ + from bisheng.app_runtime.domain.services.entry_authz_service import AccessSubjectVerifier, _issue_obo_token + + monkeypatch.setattr(settings.app_runtime, "obo_secret", OBO_SECRET, raising=False) + monkeypatch.setattr(settings, "jwt_secret", "a-different-platform-secret", raising=False) + register_access_subject_verifier(AccessSubjectVerifier()) + + def _issue(*, app_id: str = APP_ID, user_id: int = VISITOR_USER_ID, tenant_id: int = TENANT_ID) -> str: + token, _expires_at = _issue_obo_token( + app_id=app_id, + user_id=user_id, + tenant_id=tenant_id, + subject_kind="user", + ) + assert token is not None, "the fixture's secrets must let a token be issued" + return token + + return _issue + + +@pytest.fixture +def hosted(monkeypatch): + """A hosted-application credential admitted at the door, with a catalog. + + The tenant publishes three chat models; ``retired`` is offline, which is how + "declared but taken away" is told apart from "never declared". + """ + monkeypatch.setattr( + "bisheng.open_api.api.dependencies.validate_bearer", + AsyncMock(return_value=hosted_app_principal(app_id=APP_ID, tenant_id=TENANT_ID)), + ) + install_catalog( + monkeypatch, + [server_row(1, "azure-openai", tenant_id=TENANT_ID)], + [ + model_row(10, 1, "gpt-4o"), + model_row(11, 1, "qwen-max"), + model_row(12, 1, "retired", online=False), + ], + ) + install_fake_llm(monkeypatch, FakeBishengLLM(invoke_result=AIMessage(content="ok"))) + return capture_records(monkeypatch) + + +async def _call(method: str, path: str, *, headers: dict[str, str] | None = None, json_body: dict | None = None): + app = build_model_face_app() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + return await client.request(method, path, headers=headers, json=json_body) + + +def _error(response) -> dict: + return response.json()["error"] + + +# --- T022: the callable range is declared ∩ tenant-enabled ------------------- + + +async def test_models_lists_the_intersection_and_nothing_else(hosted, declaration): + # ``claude-3`` is declared and the tenant does not have it; ``qwen-max`` is + # in the tenant and not declared. Both must be absent, for different + # reasons — a list that showed either would offer an id that cannot be + # called, which is the one thing /models must never do. + declaration(("gpt-4o", "claude-3")) + + response = await _call("GET", MODELS_PATH) + + assert response.status_code == 200 + assert [row["id"] for row in response.json()["data"]] == ["gpt-4o"] + + +async def test_declaring_a_model_the_tenant_disabled_does_not_conjure_it(hosted, declaration): + declaration(("claude-3",)) + + response = await _call("GET", MODELS_PATH) + + assert response.json()["data"] == [] + + +async def test_a_tenant_model_the_application_never_declared_is_26515(hosted, declaration): + declaration(("gpt-4o",)) + + response = await _call("POST", CHAT_PATH, json_body={**BODY, "model": "qwen-max"}) + + assert response.status_code == 403 + assert _error(response)["bisheng_code"] == 26515 + assert _error(response)["code"] == "capability_undeclared" + # The record still lands: the call reached model resolution, which is the + # line AC-20 draws for "gets a usage row". + assert [(row.result, row.error_code) for row in hosted] == [("capability_undeclared", 26515)] + + +async def test_a_declared_model_that_was_taken_offline_is_26512_not_26515(hosted, declaration): + """AC-13 — the two verdicts send the owner to different people. + + 26515 means "declare it and publish again"; 26512 means "ask an + administrator why it disappeared". Collapsing them would send every owner + down the wrong path half the time. + """ + declaration(("retired",)) + + response = await _call("POST", CHAT_PATH, json_body={**BODY, "model": "retired"}) + + assert response.status_code == 404 + assert _error(response)["bisheng_code"] == 26512 + assert [(row.result, row.error_code) for row in hosted] == [("model_unavailable", 26512)] + + +async def test_a_declared_model_withdrawn_inside_the_cache_window_is_26513_not_26515(monkeypatch, hosted, declaration): + """The capability-revoked boundary, from the other side. + + The catalog caches for up to a minute, so an administrator can delete the + provider after the name resolved. The declaration still lists the model — + answering "you did not declare it" would send the owner to edit a manifest + that is correct, which is the exact confusion F055's 16273 / 16274 and this + band are kept apart to avoid. + """ + from bisheng.common.errcode.server import LlmProviderDeletedError + from bisheng.llm.domain.services.llm import LLMService + + declaration(("gpt-4o",)) + + async def explode(**_kwargs): + raise LlmProviderDeletedError() + + monkeypatch.setattr(LLMService, "get_bisheng_llm", staticmethod(explode)) + + response = await _call("POST", CHAT_PATH, json_body=BODY) + + assert response.status_code == 404 + assert _error(response)["bisheng_code"] == 26513 + assert [(row.result, row.error_code) for row in hosted] == [("model_unavailable", 26513)] + + +async def test_a_declared_model_the_tenant_never_had_is_26511(hosted, declaration): + """Not 26515: the declaration is satisfied, the platform simply has no such + model. Answering "undeclared" here would tell the owner to edit a manifest + that is already correct.""" + declaration(("claude-3",)) + + response = await _call("POST", CHAT_PATH, json_body={**BODY, "model": "claude-3"}) + + assert response.status_code == 404 + assert _error(response)["bisheng_code"] == 26511 + + +async def test_declaring_no_model_at_all_refuses_by_name_rather_than_by_range(hosted, declaration): + declaration(()) + + response = await _call("POST", CHAT_PATH, json_body=BODY) + + # An empty declaration is a *known* range, so the answer is 26515 — not + # 26516, which would tell the caller to retry something that can never work. + assert _error(response)["bisheng_code"] == 26515 + assert (await _call("GET", MODELS_PATH)).json()["data"] == [] + + +# --- T022: AC-35, the second gate fails closed ------------------------------- + + +async def test_without_a_registered_declaration_port_every_call_is_26516(hosted): + # The shipped default, installed explicitly rather than assumed: whichever + # file ran before this one may have left a real adapter behind, and a test + # about fail-closed behaviour that silently exercised a working port would + # be worse than no test. + register_hosted_app_declaration_port(model_range_policy._UnregisteredDeclarationPort()) + + for method, path, body in (("GET", MODELS_PATH, None), ("POST", CHAT_PATH, BODY)): + response = await _call(method, path, json_body=body) + + assert response.status_code == 503 + assert _error(response)["bisheng_code"] == 26516 + + +async def test_an_unreadable_declaration_never_falls_back_to_the_tenant_range(hosted, declaration): + """``None`` from the port is "cannot tell", and the tenant range is the one + answer that must never be reached from it — it would hand an application + every model the tenant has.""" + declaration(None) + + response = await _call("GET", MODELS_PATH) + + assert response.status_code == 503 + assert _error(response)["bisheng_code"] == 26516 + + +async def test_another_tenants_declaration_is_unreadable_rather_than_usable(hosted, declaration): + # The adapter compares the declaration's tenant with the credential's. A + # mismatch is answered as "cannot tell", never as the declaration itself. + declaration(("gpt-4o",), tenant_id=TENANT_ID + 1) + + response = await _call("GET", MODELS_PATH) + + assert _error(response)["bisheng_code"] == 26516 + + +# --- T023: who the call is attributed to ------------------------------------- + + +async def test_a_forwarded_visitor_token_names_that_visitor_in_the_ledger(hosted, declaration, obo): + declaration(("gpt-4o",)) + + response = await _call("POST", CHAT_PATH, headers={ACCESS_TOKEN_HEADER: obo()}, json_body=BODY) + + assert response.status_code == 200 + (row,) = hosted + assert (row.subject_kind, row.subject_id) == (SUBJECT_KIND_USER, VISITOR_USER_ID) + # The application dimension is the uuid, never the display name: F056's + # per-application query and the ledger's index both key on it. + assert row.app_id == APP_ID + assert (row.actor_kind, row.actor_name) == ("hosted_app", "survey-app") + + +async def test_a_call_with_no_token_is_labelled_app_self_rather_than_borrowing_the_owner(hosted, declaration, obo): + """AC-21 — a background job inside the container has no visitor, and saying + so is the honest row. Attributing it to the owner would put calls in a + person's name that the person did not make.""" + declaration(("gpt-4o",)) + + response = await _call("POST", CHAT_PATH, json_body=BODY) + + assert response.status_code == 200 + (row,) = hosted + assert (row.subject_kind, row.subject_id) == (SUBJECT_KIND_APP_SELF, None) + assert row.app_id == APP_ID + # The owner is still on the row, as the traceable resource owner — a + # different column with a different meaning from "who made this call". + assert row.resource_owner_user_id == 12 + + +@pytest.mark.parametrize( + "token_factory", + [ + pytest.param(lambda issue: issue(app_id="app-uuid-other"), id="another_applications_visitor"), + pytest.param(lambda issue: issue(tenant_id=999), id="another_tenants_token"), + pytest.param(lambda _issue: "not-a-jwt", id="malformed"), + pytest.param( + lambda _issue: jwt.encode( + { + "sub": json.dumps({"app_id": APP_ID, "user_id": VISITOR_USER_ID, "tenant_id": TENANT_ID}), + "aud": "bisheng-app-obo", + "iss": settings.cookie_conf.jwt_iss, + "iat": int(time.time()) - 10, + "exp": int(time.time()) - 1, + }, + OBO_SECRET, + algorithm="HS256", + ), + id="expired", + ), + pytest.param( + lambda _issue: jwt.encode( + { + "sub": json.dumps({"app_id": APP_ID, "user_id": VISITOR_USER_ID, "tenant_id": TENANT_ID}), + "aud": "bisheng-app-obo", + "iss": settings.cookie_conf.jwt_iss, + "iat": int(time.time()), + "exp": int(time.time()) + 600, + }, + "a-secret-this-platform-never-signed-with", + algorithm="HS256", + ), + id="forged_signature", + ), + ], +) +async def test_a_token_that_does_not_verify_is_refused_not_downgraded(hosted, declaration, obo, token_factory): + declaration(("gpt-4o",)) + + response = await _call("POST", CHAT_PATH, headers={ACCESS_TOKEN_HEADER: token_factory(obo)}, json_body=BODY) + + assert response.status_code == 403 + assert _error(response)["bisheng_code"] == 26504 + # Refused before the model is touched, so no usage row and no provider call. + assert hosted == [] + + +async def test_the_callable_range_is_the_same_whoever_the_subject_is(hosted, declaration, obo): + """Attribution and authorisation are separate axes. A visitor cannot widen + what the application may call, and cannot narrow it either.""" + declaration(("gpt-4o",)) + + anonymous = await _call("GET", MODELS_PATH) + with_visitor = await _call("GET", MODELS_PATH, headers={ACCESS_TOKEN_HEADER: obo()}) + + assert anonymous.json() == with_visitor.json() + + +async def test_the_subject_is_decided_before_the_range_is(hosted, declaration, obo): + """A permanently invalid token must not be answered 26516 ("retry later") + just because the declaration happens to be unreadable this second.""" + declaration(None) + + response = await _call("POST", CHAT_PATH, headers={ACCESS_TOKEN_HEADER: "not-a-jwt"}, json_body=BODY) + + assert _error(response)["bisheng_code"] == 26504 + + +# --- T023: the registration itself ------------------------------------------- + + +def test_the_composition_root_installs_both_ports(_restore_ports): + """Registering one without the other is the shape that hurts: a readable + declaration plus an unverifiable token would serve every call while + attributing all of them to the application itself.""" + from bisheng.app_publish.composition import register + from bisheng.app_publish.domain.services.capability_bus_service import HostedAppDeclarationAdapter + from bisheng.app_runtime.domain.services import lifecycle_hooks, runtime_env_ports + from bisheng.app_runtime.domain.services.entry_authz_service import AccessSubjectVerifier + from bisheng.open_api.domain.models.api_credential import SUBJECT_KIND_HOSTED_APP + from bisheng.open_api.domain.services.credential_validator import SUBJECT_RESOLVERS + from bisheng.open_api.domain.services.execution_context import SUBJECT_EXECUTION_GUARDS + + lifecycle_hooks.clear_app_deleted_hooks() + try: + register() + + assert isinstance(model_range_policy.get_hosted_app_declaration_port(), HostedAppDeclarationAdapter) + assert isinstance(model_range_policy.get_access_subject_verifier(), AccessSubjectVerifier) + finally: + SUBJECT_RESOLVERS.pop(SUBJECT_KIND_HOSTED_APP, None) + SUBJECT_EXECUTION_GUARDS.pop(SUBJECT_KIND_HOSTED_APP, None) + lifecycle_hooks.clear_app_deleted_hooks() + runtime_env_ports.clear_capability_env_provider() + + +def test_both_process_entry_points_call_the_composition_root(): + """The API process serves the face; the Celery leg re-checks the identity a + queued task carries. Wiring one and not the other is invisible by hand. + + The assertion is on the **call**, not on the import: both entry points + import ``register`` under a local alias inside a guarded helper, and an + import that nobody invokes registers exactly nothing while still reading + like wiring in a diff. + """ + import ast + import pathlib + + import bisheng + + root = pathlib.Path(bisheng.__file__).parent + for relative in ("main.py", "worker/main.py"): + source = (root / relative).read_text(encoding="utf-8") + tree = ast.parse(source) + aliases = { + alias.asname or alias.name + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) and node.module == "bisheng.app_publish.composition" + for alias in node.names + if alias.name == "register" + } + assert aliases, f"{relative} never imports the app_publish composition root" + called = { + node.func.id for node in ast.walk(tree) if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) + } + assert aliases & called, ( + f"{relative} imports the composition root but never calls it — " + "the two ports stay at their fail-closed defaults in that process" + ) diff --git a/src/backend/test/open_api/test_model_gateway_openai_sdk.py b/src/backend/test/open_api/test_model_gateway_openai_sdk.py new file mode 100644 index 0000000000..cb5deedcdf --- /dev/null +++ b/src/backend/test/open_api/test_model_gateway_openai_sdk.py @@ -0,0 +1,194 @@ +"""F051 T019: the promise is "the official client just works". + +Everything else in this feature asserts our own view of the wire. This file +asserts the client's: if ``openai.AsyncOpenAI`` cannot parse a response or +raises the wrong exception class, the face has failed its headline claim, even +when every field looks right to us. +""" + +from unittest.mock import AsyncMock + +import httpx +import openai +import pytest +from langchain_core.messages import AIMessage + +from test.open_api.model_gateway_fixtures import ( + FakeBishengLLM, + build_model_face_app, + capture_records, + install_catalog, + install_fake_llm, + model_row, + server_row, + service_account_principal, + text_chunk, + tool_chunk, + usage_chunk, +) + +MESSAGES = [{"role": "user", "content": "hi"}] + + +@pytest.fixture +def face(monkeypatch): + monkeypatch.setattr( + "bisheng.open_api.api.dependencies.validate_bearer", + AsyncMock(return_value=service_account_principal()), + ) + install_catalog( + monkeypatch, + [server_row(1, "azure-openai"), server_row(2, "qwen-cloud")], + [model_row(10, 1, "gpt-4o"), model_row(11, 1, "shared"), model_row(12, 2, "shared")], + ) + capture_records(monkeypatch) + return build_model_face_app() + + +def client_for(app) -> openai.AsyncOpenAI: + return openai.AsyncOpenAI( + base_url="http://test/api/v2/model/v1", + # Never validated here — validate_bearer is stubbed — but the client + # refuses to construct without one. + api_key="unit-test-placeholder", + http_client=httpx.AsyncClient(transport=httpx.ASGITransport(app=app)), + max_retries=0, + ) + + +async def test_the_client_lists_models_and_every_id_is_callable(monkeypatch, face): + install_fake_llm(monkeypatch, FakeBishengLLM(invoke_result=AIMessage(content="ok"))) + client = client_for(face) + + listing = await client.models.list() + + ids = {model.id for model in listing.data} + assert ids == {"gpt-4o", "azure-openai/shared", "qwen-cloud/shared"} + for model_id in ids: + completion = await client.chat.completions.create(model=model_id, messages=MESSAGES) + assert completion.choices[0].message.content == "ok" + + +async def test_the_client_parses_a_non_streaming_completion_with_usage(monkeypatch, face): + install_fake_llm( + monkeypatch, + FakeBishengLLM( + invoke_result=AIMessage( + content="hello", + usage_metadata={"input_tokens": 3, "output_tokens": 2, "total_tokens": 5}, + ) + ), + ) + + completion = await client_for(face).chat.completions.create(model="gpt-4o", messages=MESSAGES) + + assert completion.object == "chat.completion" + assert completion.usage.total_tokens == 5 + assert completion.choices[0].finish_reason == "stop" + + +async def test_the_client_accumulates_a_tool_calling_stream(monkeypatch, face): + install_fake_llm( + monkeypatch, + FakeBishengLLM( + stream_script=[ + text_chunk("thinking"), + tool_chunk(name="read_file", args='{"p"', call_id="call_1"), + tool_chunk(name=None, args=':"a.py"}', call_id=None), + usage_chunk(prompt=11, completion=4, finish_reason="tool_calls"), + ] + ), + ) + + stream = await client_for(face).chat.completions.create( + model="gpt-4o", + messages=MESSAGES, + stream=True, + stream_options={"include_usage": True}, + ) + + text = "" + arguments = "" + usage = None + finish_reason = None + async for chunk in stream: + if chunk.usage is not None: + usage = chunk.usage + for choice in chunk.choices: + text += choice.delta.content or "" + for call in choice.delta.tool_calls or []: + arguments += call.function.arguments or "" + finish_reason = choice.finish_reason or finish_reason + + assert text == "thinking" + # The SDK concatenates by index — the whole point of assigning one. + assert arguments == '{"p":"a.py"}' + assert finish_reason == "tool_calls" + assert usage.total_tokens == 15 + + +@pytest.mark.parametrize( + ("side_effect", "expected"), + [ + ("missing_credential", openai.AuthenticationError), + ("missing_scope", openai.PermissionDeniedError), + ], +) +async def test_admission_failures_raise_the_clients_own_exception_classes(monkeypatch, side_effect, expected): + from bisheng.common.errcode.open_api import OpenApiCredentialMissingError, OpenApiScopeMissingError + + error = ( + OpenApiCredentialMissingError() + if side_effect == "missing_credential" + else OpenApiScopeMissingError(required="model:invoke") + ) + monkeypatch.setattr( + "bisheng.open_api.api.dependencies.validate_bearer", + AsyncMock(side_effect=error), + ) + + with pytest.raises(expected) as excinfo: + await client_for(build_model_face_app()).models.list() + + assert excinfo.value.body["bisheng_code"] == error.code + + +async def test_an_unknown_model_raises_not_found(monkeypatch, face): + install_fake_llm(monkeypatch, FakeBishengLLM()) + + with pytest.raises(openai.NotFoundError) as excinfo: + await client_for(face).chat.completions.create(model="never-configured", messages=MESSAGES) + + assert excinfo.value.body["code"] == "model_not_found" + + +async def test_an_ambiguous_name_raises_bad_request_and_names_the_alternatives(monkeypatch, face): + install_fake_llm(monkeypatch, FakeBishengLLM()) + + with pytest.raises(openai.BadRequestError) as excinfo: + await client_for(face).chat.completions.create(model="shared", messages=MESSAGES) + + assert excinfo.value.body["candidates"] == ["azure-openai/shared", "qwen-cloud/shared"] + + +async def test_the_provider_daily_limit_raises_rate_limit_error(monkeypatch, face): + from bisheng.llm.domain.utils import LlmProviderDailyLimitExceededError + + install_fake_llm( + monkeypatch, + FakeBishengLLM(raise_on_invoke=LlmProviderDailyLimitExceededError("azure/gpt-4o Quota used up")), + ) + + with pytest.raises(openai.RateLimitError) as excinfo: + await client_for(face).chat.completions.create(model="gpt-4o", messages=MESSAGES) + + assert excinfo.value.body["bisheng_code"] == 26517 + + +async def test_an_endpoint_outside_the_promise_raises_not_found(monkeypatch, face): + install_fake_llm(monkeypatch, FakeBishengLLM()) + + with pytest.raises(openai.NotFoundError) as excinfo: + await client_for(face).embeddings.create(model="gpt-4o", input="hi") + + assert excinfo.value.body["bisheng_code"] == 26501 diff --git a/src/backend/test/open_api/test_model_gateway_stream.py b/src/backend/test/open_api/test_model_gateway_stream.py new file mode 100644 index 0000000000..2968788a13 --- /dev/null +++ b/src/backend/test/open_api/test_model_gateway_stream.py @@ -0,0 +1,300 @@ +"""F051 T014 (endpoint half): the call itself — pass-through, streaming, limits.""" + +import json +from unittest.mock import AsyncMock + +import pytest +from httpx import ASGITransport, AsyncClient +from langchain_core.messages import AIMessage + +from bisheng.common.constants.enums.telemetry import ApplicationTypeEnum +from bisheng.llm.domain.utils import LlmProviderDailyLimitExceededError +from test.open_api.model_gateway_fixtures import ( + FakeBishengLLM, + build_model_face_app, + capture_records, + install_catalog, + install_fake_llm, + model_row, + server_row, + service_account_principal, + text_chunk, + tool_chunk, + usage_chunk, +) + +CHAT_PATH = "/api/v2/model/v1/chat/completions" +BODY = {"model": "gpt-4o", "messages": [{"role": "user", "content": "hi"}]} + + +@pytest.fixture +def records(monkeypatch): + monkeypatch.setattr( + "bisheng.open_api.api.dependencies.validate_bearer", + AsyncMock(return_value=service_account_principal()), + ) + install_catalog(monkeypatch, [server_row(1, "azure-openai")], [model_row(10, 1, "gpt-4o")]) + return capture_records(monkeypatch) + + +async def _post(payload: dict): + app = build_model_face_app() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + return await client.post(CHAT_PATH, json=payload) + + +def _events(text: str) -> list: + return [ + json.loads(line[len("data: ") :]) + for line in text.splitlines() + if line.startswith("data: ") and line != "data: [DONE]" + ] + + +async def test_a_non_streaming_call_returns_a_chat_completion(monkeypatch, records): + install_fake_llm( + monkeypatch, + FakeBishengLLM( + invoke_result=AIMessage( + content="hello", + usage_metadata={"input_tokens": 3, "output_tokens": 2, "total_tokens": 5}, + ) + ), + ) + + response = await _post(BODY) + + assert response.status_code == 200 + payload = response.json() + assert payload["object"] == "chat.completion" + assert payload["choices"][0]["message"]["content"] == "hello" + assert payload["usage"]["total_tokens"] == 5 + assert records[0].result == "success" + assert (records[0].prompt_tokens, records[0].total_tokens) == (3, 5) + assert records[0].request_id == payload["id"] + + +async def test_the_model_is_built_for_this_face_and_charged_to_the_resource_owner(monkeypatch, records): + fake = install_fake_llm(monkeypatch, FakeBishengLLM()) + + await _post({**BODY, "temperature": 0.2}) + + assert fake.init_kwargs["app_type"] is ApplicationTypeEnum.MODEL_GATEWAY + assert fake.init_kwargs["user_id"] == 12 + assert fake.init_kwargs["streaming"] is False + assert fake.init_kwargs["temperature"] == 0.2 + assert fake.init_kwargs["model_id"] == 10 + + +async def test_sampling_parameters_and_tools_reach_the_model_untouched(monkeypatch, records): + fake = install_fake_llm(monkeypatch, FakeBishengLLM()) + tools = [{"type": "function", "function": {"name": "read_file", "parameters": {}}}] + + await _post({**BODY, "top_p": 0.9, "stop": ["END"], "tools": tools, "tool_choice": "auto"}) + + assert fake.bound == {"tools": tools, "tool_choice": "auto"} + assert fake.seen_kwargs["top_p"] == 0.9 + assert fake.seen_kwargs["stop"] == ["END"] + # Nothing was prepended: the caller's messages are the whole prompt. + assert len(fake.seen_messages) == 1 + + +async def test_streaming_carries_the_headers_that_stop_a_proxy_buffering_it(monkeypatch, records): + install_fake_llm(monkeypatch, FakeBishengLLM(stream_script=[text_chunk("hi")])) + + app = build_model_face_app() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.post(CHAT_PATH, json={**BODY, "stream": True}) + + assert response.headers["content-type"].startswith("text/event-stream") + assert response.headers["x-accel-buffering"] == "no" + assert response.headers["cache-control"] == "no-cache" + assert response.text.endswith("data: [DONE]\n\n") + + +async def test_a_tool_calling_stream_round_trips_with_usage(monkeypatch, records): + install_fake_llm( + monkeypatch, + FakeBishengLLM( + stream_script=[ + tool_chunk(name="read_file", args='{"p"', call_id="call_1"), + tool_chunk(name=None, args=':"a.py"}', call_id=None), + usage_chunk(prompt=11, completion=4, finish_reason="tool_calls"), + ] + ), + ) + + app = build_model_face_app() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.post( + CHAT_PATH, json={**BODY, "stream": True, "stream_options": {"include_usage": True}} + ) + + events = _events(response.text) + finish = [item for item in events if item["choices"] and item["choices"][0].get("finish_reason")] + usage = [item for item in events if item.get("usage")] + tool_deltas = [ + call for item in events for choice in item["choices"] for call in choice["delta"].get("tool_calls", []) + ] + assert [call["index"] for call in tool_deltas] == [0, 0] + assert finish[0]["choices"][0]["finish_reason"] == "tool_calls" + assert usage[0]["usage"]["total_tokens"] == 15 + assert records[0].ttft_ms is not None + assert records[0].total_tokens == 15 + + +async def test_usage_is_recorded_even_when_the_caller_did_not_ask_for_it(monkeypatch, records): + install_fake_llm(monkeypatch, FakeBishengLLM(stream_script=[text_chunk("hi"), usage_chunk(prompt=2, completion=1)])) + + app = build_model_face_app() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.post(CHAT_PATH, json={**BODY, "stream": True}) + + assert not [item for item in _events(response.text) if item.get("usage")] + assert records[0].total_tokens == 3 + + +async def test_unknown_usage_is_recorded_as_null_not_zero(monkeypatch, records): + install_fake_llm(monkeypatch, FakeBishengLLM(invoke_result=AIMessage(content="hi"))) + + response = await _post(BODY) + + assert response.json()["usage"] is None + assert records[0].prompt_tokens is None + assert records[0].total_tokens is None + + +async def test_the_daily_provider_limit_is_a_real_429_not_half_a_stream(monkeypatch, records): + install_fake_llm( + monkeypatch, + FakeBishengLLM(raise_on_first_chunk=LlmProviderDailyLimitExceededError("azure/gpt-4o Quota used up")), + ) + + response = await _post({**BODY, "stream": True}) + + # The limit only fires on the first iteration; prefetching it is what keeps + # this a status code rather than an error buried inside a 200 stream. + assert response.status_code == 429 + body = response.json()["error"] + assert body["bisheng_code"] == 26517 + assert body["type"] == "rate_limit_error" + assert records[0].result == "limit_exceeded" + + +async def test_an_upstream_rejection_keeps_the_provider_status(monkeypatch, records): + class Status(Exception): + status_code = 413 + message = "context length exceeded" + + install_fake_llm(monkeypatch, FakeBishengLLM(raise_on_first_chunk=Status())) + + response = await _post({**BODY, "stream": True}) + + assert response.status_code == 413 + assert response.json()["error"]["bisheng_code"] == 26532 + assert "context length exceeded" in response.json()["error"]["message"] + + +async def test_an_upstream_connection_failure_is_a_502(monkeypatch, records): + install_fake_llm(monkeypatch, FakeBishengLLM(raise_on_invoke=ConnectionError("no route to host"))) + + response = await _post(BODY) + + assert response.status_code == 502 + assert response.json()["error"]["bisheng_code"] == 26531 + assert records[0].result == "upstream_failed" + assert records[0].total_tokens is None + + +async def test_a_mid_stream_failure_ends_the_stream_readably(monkeypatch, records): + install_fake_llm(monkeypatch, FakeBishengLLM(stream_script=[text_chunk("par")], raise_after=1)) + + app = build_model_face_app() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.post(CHAT_PATH, json={**BODY, "stream": True}) + + # The 200 header is already out; the failure has to arrive as an event and + # the connection must close rather than hang. + assert response.status_code == 200 + errors = [item for item in _events(response.text) if "error" in item] + assert errors[0]["error"]["bisheng_code"] == 26534 + assert response.text.endswith("data: [DONE]\n\n") + assert records[0].result == "upstream_failed" + + +@pytest.mark.parametrize( + ("platform_error", "expected_code", "expected_result"), + [ + ("LlmModelConfigDeletedError", 26513, "model_unavailable"), + ("LlmProviderDeletedError", 26513, "model_unavailable"), + ("LlmModelOfflineError", 26512, "model_unavailable"), + ("LlmModelTypeError", 26511, "model_unavailable"), + ("InitLlmError", 26531, "upstream_failed"), + ], +) +async def test_a_model_withdrawn_inside_the_cache_window_is_reported_as_such( + monkeypatch, records, platform_error, expected_code, expected_result +): + # The catalog caches for up to 60s, so a name can still resolve after an + # administrator has deleted or taken down the model. Instantiation is where + # that is discovered, and it is the only place 26512 / 26513 can come from: + # deleting a provider deletes its model rows, leaving resolution nothing to + # explain. + import bisheng.common.errcode.server as server_errors + from bisheng.llm.domain.services.llm import LLMService + + error_class = getattr(server_errors, platform_error) + + async def explode(**_kwargs): + raise error_class() + + monkeypatch.setattr(LLMService, "get_bisheng_llm", staticmethod(explode)) + + response = await _post(BODY) + + assert response.json()["error"]["bisheng_code"] == expected_code + assert records[0].result == expected_result + # The name the caller wrote is preserved even though the model is gone. + assert records[0].requested_model == "gpt-4o" + + +async def test_a_client_that_walks_away_mid_stream_is_recorded_as_such(monkeypatch, records): + install_fake_llm( + monkeypatch, + FakeBishengLLM(stream_script=[text_chunk("one"), text_chunk("two"), text_chunk("three")]), + ) + calls = {"count": 0} + + async def disconnected_after_the_first_chunk(_self): + calls["count"] += 1 + return calls["count"] > 1 + + monkeypatch.setattr("starlette.requests.Request.is_disconnected", disconnected_after_the_first_chunk) + + app = build_model_face_app() + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.post(CHAT_PATH, json={**BODY, "stream": True}) + + # Not an upstream failure and not a success: nobody was listening, and the + # ledger has to say so rather than book it as a completed call. + assert response.status_code == 200 + assert records[0].result == "client_disconnected" + + +async def test_no_session_or_message_row_is_created(monkeypatch, records): + from bisheng.database.models.session import MessageSessionDao + + install_fake_llm(monkeypatch, FakeBishengLLM(invoke_result=AIMessage(content="hi"))) + created: list = [] + for method in ("insert_one", "async_insert_one"): + monkeypatch.setattr( + MessageSessionDao, + method, + staticmethod(lambda *args, **kwargs: created.append(args)), + ) + + await _post(BODY) + + # This face is a different data plane from ``chat:invoke``: no session row, + # nothing retrievable later, no message body anywhere. + assert created == [] diff --git a/src/backend/test/open_api/test_model_gateway_switch.py b/src/backend/test/open_api/test_model_gateway_switch.py new file mode 100644 index 0000000000..5d29a8aceb --- /dev/null +++ b/src/backend/test/open_api/test_model_gateway_switch.py @@ -0,0 +1,105 @@ +"""F051 T012 (switch half): the face exists exactly where it is deployed. + +``bisheng.api.router`` evaluates ``open_platform.enabled`` at import time, so a +monkeypatched switch does not remount anything. Both states are therefore built +here with ``importlib.reload`` against a fresh app, which is the only way to see +the mounting decision actually change. +""" + +import importlib +from unittest.mock import AsyncMock + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient + +from bisheng.open_api.api.exception_handlers import register_open_api_exception_handlers +from test.open_api.model_gateway_fixtures import ( + FakeBishengLLM, + capture_records, + install_catalog, + install_fake_llm, + model_row, + server_row, + service_account_principal, +) + +MODELS_PATH = "/api/v2/model/v1/models" +CHAT_PATH = "/api/v2/model/v1/chat/completions" + + +def build_app_with_switch(enabled: bool) -> FastAPI: + from bisheng.common.services.config_service import settings + + previous = settings.open_platform.enabled + settings.open_platform.enabled = enabled + try: + router_module = importlib.reload(importlib.import_module("bisheng.api.router")) + app = FastAPI() + register_open_api_exception_handlers(app) + app.include_router(router_module.router_rpc) + return app + finally: + settings.open_platform.enabled = previous + + +@pytest.fixture(autouse=True) +def _restore_router_module(): + yield + # Leave the imported router matching the process-wide configuration again, + # or every later test in this session inherits this file's mounting. + importlib.reload(importlib.import_module("bisheng.api.router")) + + +async def _get(app, path, **kwargs): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + return await client.get(path, **kwargs) + + +@pytest.mark.parametrize("authorization", [None, "Bearer bs-sak-something"]) +async def test_the_face_is_indistinguishable_from_nothing_when_not_deployed(monkeypatch, authorization): + monkeypatch.setattr( + "bisheng.open_api.api.dependencies.validate_bearer", + AsyncMock(return_value=service_account_principal()), + ) + app = build_app_with_switch(False) + headers = {"Authorization": authorization} if authorization else None + + face = await _get(app, MODELS_PATH, headers=headers) + never_existed = await _get(app, "/api/v2/no-such-surface/models", headers=headers) + + assert face.status_code == never_existed.status_code == 404 + # Byte-identical to a path that was never a feature: no error code that + # would tell an outsider "there is something here, it is switched off". + assert face.json() == never_existed.json() + + +async def test_chat_completions_is_equally_absent_when_not_deployed(monkeypatch): + monkeypatch.setattr( + "bisheng.open_api.api.dependencies.validate_bearer", + AsyncMock(return_value=service_account_principal()), + ) + app = build_app_with_switch(False) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.post(CHAT_PATH, json={"model": "gpt-4o", "messages": []}) + + assert response.status_code == 404 + assert "error" not in response.json() + + +async def test_the_face_works_on_the_open_capability_layer_alone(monkeypatch): + # No runtime-manager, no app-proxy, no hosted-app configuration involved. + monkeypatch.setattr( + "bisheng.open_api.api.dependencies.validate_bearer", + AsyncMock(return_value=service_account_principal()), + ) + install_catalog(monkeypatch, [server_row(1, "azure-openai")], [model_row(10, 1, "gpt-4o")]) + install_fake_llm(monkeypatch, FakeBishengLLM()) + capture_records(monkeypatch) + app = build_app_with_switch(True) + + response = await _get(app, MODELS_PATH) + + assert response.status_code == 200 + assert [item["id"] for item in response.json()["data"]] == ["gpt-4o"] diff --git a/src/backend/test/open_api/test_model_range_policy.py b/src/backend/test/open_api/test_model_range_policy.py new file mode 100644 index 0000000000..13e298c25e --- /dev/null +++ b/src/backend/test/open_api/test_model_range_policy.py @@ -0,0 +1,200 @@ +"""F051 T010: callable range and audit subject, and the order they are decided in. + +The order is load-bearing. A caller that sent a header it should not have must +be told about the header even when the model name is also wrong, or an agent's +correction loop gets a different answer on each attempt. +""" + +import pytest + +from bisheng.common.errcode.model_face import ( + ModelFaceAccessTokenRefusedError, + ModelFaceCatalogUnavailableError, + ModelFaceIdentityHeaderRefusedError, +) +from bisheng.open_api.domain.services import model_range_policy +from bisheng.open_api.domain.services.model_range_policy import ( + ACCESS_TOKEN_HEADER, + END_USER_HEADER, + SUBJECT_KIND_APP_SELF, + SUBJECT_KIND_USER, + AccessSubject, + register_access_subject_verifier, + register_hosted_app_declaration_port, + resolve_range_and_subject, +) +from test.open_api.model_gateway_fixtures import hosted_app_principal, service_account_principal + + +@pytest.fixture(autouse=True) +def _restore_ports(): + declaration = model_range_policy.get_hosted_app_declaration_port() + verifier = model_range_policy.get_access_subject_verifier() + yield + register_hosted_app_declaration_port(declaration) + register_access_subject_verifier(verifier) + + +class FakeDeclarationPort: + def __init__(self, declared): + self.declared = declared + self.calls = [] + + async def declared_model_names(self, app_id, tenant_id): + self.calls.append((app_id, tenant_id)) + return self.declared + + +class FakeVerifier: + def __init__(self, subject): + self.subject = subject + self.calls = [] + + def verify(self, token, *, app_id, tenant_id): + self.calls.append((token, app_id, tenant_id)) + return self.subject + + +async def test_service_account_gets_the_tenant_range_and_is_its_own_subject(): + principal = service_account_principal() + + model_range, subject = await resolve_range_and_subject(principal, {}) + + assert model_range.kind == "tenant" + assert (subject.subject_kind, subject.subject_id) == ("service_account", 31) + assert subject.app_id is None + + +async def test_service_account_presenting_an_access_token_is_refused(): + principal = service_account_principal() + + with pytest.raises(ModelFaceAccessTokenRefusedError) as excinfo: + await resolve_range_and_subject(principal, {ACCESS_TOKEN_HEADER: "anything at all"}) + + assert excinfo.value.code == 26504 + + +@pytest.mark.parametrize("principal_factory", [service_account_principal, hosted_app_principal]) +async def test_a_well_formed_end_user_header_is_refused_for_every_actor(principal_factory): + # The shared /api/v2 base validates this header's *format* and then adopts + # a valid value silently. This face refuses it outright, which is why the + # refusal has to live here. + port = FakeDeclarationPort(frozenset({"gpt-4o"})) + register_hosted_app_declaration_port(port) + + with pytest.raises(ModelFaceIdentityHeaderRefusedError) as excinfo: + await resolve_range_and_subject(principal_factory(), {END_USER_HEADER: "external-1"}) + + assert excinfo.value.code == 26505 + assert port.calls == [] + + +async def test_identity_header_is_judged_before_the_access_token(): + register_hosted_app_declaration_port(FakeDeclarationPort(frozenset())) + register_access_subject_verifier(FakeVerifier(None)) + + with pytest.raises(ModelFaceIdentityHeaderRefusedError): + await resolve_range_and_subject( + hosted_app_principal(), + {END_USER_HEADER: "external-1", ACCESS_TOKEN_HEADER: "expired"}, + ) + + +async def test_a_forged_token_is_refused_even_when_the_declaration_is_unreadable(): + class BrokenPort: + async def declared_model_names(self, app_id, tenant_id): + raise RuntimeError("deployment row unreadable") + + register_hosted_app_declaration_port(BrokenPort()) + register_access_subject_verifier(FakeVerifier(None)) + + with pytest.raises(ModelFaceAccessTokenRefusedError) as excinfo: + await resolve_range_and_subject(hosted_app_principal(), {ACCESS_TOKEN_HEADER: "forged"}) + + # Step 2 before step 3: answering 26516 ("cannot tell right now, retry") + # to a permanently invalid token sends the caller round a loop that can + # never succeed. + assert excinfo.value.code == 26504 + + +async def test_header_lookup_is_case_insensitive(): + with pytest.raises(ModelFaceIdentityHeaderRefusedError): + await resolve_range_and_subject(service_account_principal(), {"x-end-user": "external-1"}) + + +async def test_natural_person_also_gets_the_tenant_range(): + principal = service_account_principal().model_copy(update={"actor_kind": "natural_person", "effective_user_id": 12}) + + model_range, subject = await resolve_range_and_subject(principal, {}) + + assert model_range.kind == "tenant" + assert subject.subject_kind == "natural_person" + + +async def test_hosted_app_without_a_registered_declaration_port_is_refused(): + with pytest.raises(ModelFaceCatalogUnavailableError) as excinfo: + await resolve_range_and_subject(hosted_app_principal(), {}) + + assert excinfo.value.code == 26516 + + +async def test_hosted_app_declaration_port_failure_is_refused_not_widened(): + class BrokenPort: + async def declared_model_names(self, app_id, tenant_id): + raise RuntimeError("deployment row unreadable") + + register_hosted_app_declaration_port(BrokenPort()) + + with pytest.raises(ModelFaceCatalogUnavailableError): + await resolve_range_and_subject(hosted_app_principal(), {}) + + +async def test_hosted_app_range_is_its_declaration_and_subject_defaults_to_the_app(): + register_hosted_app_declaration_port(FakeDeclarationPort(frozenset({"gpt-4o"}))) + + model_range, subject = await resolve_range_and_subject(hosted_app_principal(), {}) + + assert model_range.kind == "declared" + assert model_range.declared == frozenset({"gpt-4o"}) + assert (subject.subject_kind, subject.subject_id) == (SUBJECT_KIND_APP_SELF, None) + # ``app.id``, not the display name: the ledger's ``app_id`` column and every + # per-application query read this value (F055 T055 note ①). + assert subject.app_id == "app-uuid-survey" + + +async def test_a_valid_access_token_names_the_visiting_user(): + register_hosted_app_declaration_port(FakeDeclarationPort(frozenset({"gpt-4o"}))) + verifier = FakeVerifier(AccessSubject(user_id=77)) + register_access_subject_verifier(verifier) + + model_range, subject = await resolve_range_and_subject( + hosted_app_principal(), {ACCESS_TOKEN_HEADER: "signed-token"} + ) + + assert (subject.subject_kind, subject.subject_id) == (SUBJECT_KIND_USER, 77) + # Attribution never widens or narrows what the application may call. + assert model_range.declared == frozenset({"gpt-4o"}) + # The token is verified against the application's id: an OBO token is minted + # per application, so verifying it against the display name would let two + # applications that happen to share a name borrow each other's visitors. + assert verifier.calls == [("signed-token", "app-uuid-survey", 9)] + + +async def test_an_invalid_access_token_is_refused_rather_than_downgraded(): + register_hosted_app_declaration_port(FakeDeclarationPort(frozenset({"gpt-4o"}))) + register_access_subject_verifier(FakeVerifier(None)) + + with pytest.raises(ModelFaceAccessTokenRefusedError) as excinfo: + await resolve_range_and_subject(hosted_app_principal(), {ACCESS_TOKEN_HEADER: "forged"}) + + # Silently recording it as "the app itself" would make audit attribution + # something a caller can steer by sending a broken token. + assert excinfo.value.code == 26504 + + +async def test_registration_replaces_the_default_port(): + register_hosted_app_declaration_port(FakeDeclarationPort(frozenset())) + + model_range, _subject = await resolve_range_and_subject(hosted_app_principal(), {}) + + assert model_range.declared == frozenset() diff --git a/src/backend/test/open_api/test_open_api_route_matrix.py b/src/backend/test/open_api/test_open_api_route_matrix.py index d074282531..7400693c83 100644 --- a/src/backend/test/open_api/test_open_api_route_matrix.py +++ b/src/backend/test/open_api/test_open_api_route_matrix.py @@ -1,8 +1,10 @@ from fastapi.routing import APIRoute, APIWebSocketRoute from bisheng.api.router import router_rpc +from bisheng.common.services.config_service import settings from bisheng.main import app from bisheng.open_api.api.dependencies import verify_open_api_access +from bisheng.open_api.api.exception_handlers import MODEL_GATEWAY_PATH_PREFIX from bisheng.open_api.domain.scopes import OPEN_API_SCOPES, get_open_api_scope_marker REMOVED_CHAT_ROUTES = { @@ -39,14 +41,54 @@ def actual_v2_routes(): def test_every_real_v2_route_is_globally_key_protected_and_marked(): + """Every FastAPI route under /api/v2 carries the marker the dependency reads. + + The isinstance filter is not a loophole: it is the line between routes that + ``verify_open_api_access`` governs and the one that cannot be governed by it. + F052's MCP face is a plain Starlette ``Route`` — no ``APIRoute``, so no + router-level dependency and no ``@open_api_scope`` marker on its endpoint — + and it authenticates itself in ``McpAccessGate`` instead, reusing + ``admit_open_api_principal`` and ``open_api_execution_scope``. + ``test_mcp_route_is_gated_or_absent`` below is what holds it to that. + """ + assert any(item.dependency is verify_open_api_access for item in router_rpc.dependencies) - v2_routes = [route for route in app.routes if route.path.startswith("/api/v2")] + v2_routes = [ + route + for route in app.routes + if route.path.startswith("/api/v2") and isinstance(route, (APIRoute, APIWebSocketRoute)) + ] assert v2_routes assert all(get_open_api_scope_marker(route.endpoint) is not None for route in v2_routes) +def test_no_unmarked_route_lives_under_v2(): + """Every ``/api/v2`` route is an ``APIRoute`` carrying a scope marker. + + The MCP endpoint is not a route at all: ``OpenMcpDispatchMiddleware`` takes + the exact ``/api/v2/mcp`` path before routing and authenticates it through + the shared access context. Without this check, the isinstance filter above + would silently excuse any future unauthenticated Starlette route someone + appends to the app. + """ + + non_api_routes = [ + route + for route in app.routes + if route.path.startswith("/api/v2") and not isinstance(route, (APIRoute, APIWebSocketRoute)) + ] + assert non_api_routes == [] + + def test_route_registry_matches_complete_key_authenticated_surface(): registered = {endpoint for scope in OPEN_API_SCOPES for endpoint in scope.endpoints} + if not settings.open_platform.enabled: + # The model protocol face is the one conditionally mounted sub-router + # (F051 AC-28), and ``bisheng.main.app`` decided that at import time — + # monkeypatching the switch here would remount nothing. Drop its + # registrations from the expectation instead; the mounted state is + # asserted in test_model_gateway_switch.py against a freshly built app. + registered = {item for item in registered if not item[1].startswith(MODEL_GATEWAY_PATH_PREFIX)} actual = actual_v2_routes() actual_without_whoami = actual - {("GET", "/api/v2/auth/whoami")} assert DAILY_ROUTES <= actual_without_whoami @@ -60,6 +102,57 @@ def test_removed_chat_routes_are_not_registered(): assert paths.isdisjoint(REMOVED_CHAT_ROUTES) +def test_only_the_capability_faces_admit_a_hosted_application(): + """F055 AC-52 as a lockstep, because the scope alone is far too coarse. + + A hosted application's scopes come from its capability declaration, and one + declared knowledge base derives ``knowledge:read`` — which covers seven + routes. Six of them execute as the application's **owner** + (``get_open_api_operator``) or, in ``download_statistic``'s case, serve any + ``/app/data`` log file to whoever holds the scope. Only the retrieval route + goes through ``CapabilityBusService``, which narrows to the declared + whitelist ∩ the visiting user's own visibility. + + So the admission is per route and default-deny, and this test is the list. + Adding a route here is a decision: it means "this route is safe for a + subject that is an application, not a person". + """ + + expected = { + ("POST", "/api/v2/filelib/retrieve"), + ("POST", "/api/v2/model/v1/chat/completions"), + ("GET", "/api/v2/model/v1/models"), + } + catch_all = {(method, "/api/v2/model/v1/{rest:path}") for method in ("GET", "POST", "PUT", "DELETE", "PATCH")} + if settings.open_platform.enabled: + expected |= catch_all + else: + # The model face is not mounted at all — see the note in + # ``test_route_registry_matches_complete_key_authenticated_surface``. + expected = {("POST", "/api/v2/filelib/retrieve")} + + admitted = set() + for route in app.routes: + if not getattr(route, "path", "").startswith("/api/v2") or not isinstance(route, APIRoute): + continue + marker = get_open_api_scope_marker(route.endpoint) + if marker is not None and marker.hosted_app: + admitted.update((method, route.path) for method in route.methods) + + assert admitted == expected + + +def test_a_route_admits_no_hosted_application_unless_it_says_so(): + """The default the list above depends on.""" + from bisheng.open_api.domain.scopes import open_api_scope + + @open_api_scope("knowledge:read") + def _endpoint(): # pragma: no cover - never called + return None + + assert get_open_api_scope_marker(_endpoint).hosted_app is False + + def test_removed_websocket_routes_are_not_registered_or_scoped(): registered = {endpoint for scope in OPEN_API_SCOPES for endpoint in scope.endpoints} assert actual_v2_routes().isdisjoint(REMOVED_WEBSOCKET_ROUTES) diff --git a/src/backend/test/open_api/test_openai_codec.py b/src/backend/test/open_api/test_openai_codec.py new file mode 100644 index 0000000000..d5c2ef2eca --- /dev/null +++ b/src/backend/test/open_api/test_openai_codec.py @@ -0,0 +1,210 @@ +"""F051 T014 (codec half): translation between OpenAI shapes and langchain.""" + +import json + +import pytest +from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, SystemMessage, ToolMessage + +from bisheng.common.errcode.model_face import ( + ModelFaceStreamInterruptedError, + ModelFaceUpstreamError, + ModelFaceUpstreamRateLimitedError, + ModelFaceUpstreamRejectedError, +) +from bisheng.open_api.domain.schemas.model_gateway import ChatCompletionRequest +from bisheng.open_api.domain.services.openai_codec import ( + ChunkAssembler, + build_completion, + classify_stream_error, + classify_upstream_error, + redact_secrets, + to_langchain_messages, + usage_from, +) +from test.open_api.model_gateway_fixtures import text_chunk, tool_chunk, usage_chunk + + +def _payload(line: str) -> dict: + assert line.startswith("data: ") + return json.loads(line[len("data: ") :]) + + +def test_every_conversation_role_survives_the_conversion(): + messages = to_langchain_messages( + [ + {"role": "system", "content": "be terse"}, + {"role": "user", "content": "read config.py"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + {"id": "call_1", "type": "function", "function": {"name": "read_file", "arguments": '{"p":"a"}'}} + ], + }, + {"role": "tool", "tool_call_id": "call_1", "content": "file body"}, + ] + ) + + assert [type(message) for message in messages] == [SystemMessage, HumanMessage, AIMessage, ToolMessage] + assert messages[2].tool_calls[0]["name"] == "read_file" + assert messages[3].tool_call_id == "call_1" + + +def test_multimodal_content_arrays_are_passed_through_unchanged(): + content = [{"type": "text", "text": "what is this"}, {"type": "image_url", "image_url": {"url": "data:..."}}] + + messages = to_langchain_messages([{"role": "user", "content": content}]) + + assert messages[0].content == content + + +def test_enumerated_and_unknown_request_fields_are_both_forwarded(): + request = ChatCompletionRequest( + model="gpt-4o", + messages=[{"role": "user", "content": "hi"}], + top_p=0.3, + max_tokens=64, + stop=["END"], + # Not in the promised enumeration: forwarded, not guaranteed. + logit_bias={"1": 2}, + ) + + kwargs = request.forwarded_kwargs() + + assert kwargs["top_p"] == 0.3 + assert kwargs["max_tokens"] == 64 + assert kwargs["stop"] == ["END"] + assert kwargs["logit_bias"] == {"1": 2} + # Temperature is handed to the model at construction, not as a call kwarg. + assert "temperature" not in kwargs + + +def test_completion_renders_tool_call_arguments_as_a_json_string(): + message = AIMessage( + content="", + tool_calls=[{"name": "read_file", "args": {"path": "a.py"}, "id": "call_1", "type": "tool_call"}], + usage_metadata={"input_tokens": 4, "output_tokens": 7, "total_tokens": 11}, + ) + + completion = build_completion(message, model="gpt-4o", request_id="chatcmpl-x") + + choice = completion.choices[0] + assert choice.finish_reason == "tool_calls" + assert choice.message.tool_calls[0].function.arguments == '{"path": "a.py"}' + assert (completion.usage.prompt_tokens, completion.usage.total_tokens) == (4, 11) + + +def test_unknown_usage_is_none_rather_than_zero(): + assert usage_from(AIMessage(content="hi")) is None + assert usage_from(usage_chunk(prompt=2, completion=3)).total_tokens == 5 + + +def test_reasoning_content_is_omitted_when_absent(): + completion = build_completion(AIMessage(content="hi"), model="m", request_id="chatcmpl-x") + + assert completion.choices[0].message.reasoning_content is None + assert "reasoning_content" not in completion.model_dump(exclude_none=True)["choices"][0]["message"] + + +def test_stream_assembles_role_text_finish_and_usage(): + assembler = ChunkAssembler(model="gpt-4o", request_id="chatcmpl-x") + + prelude = _payload(assembler.role_prelude()) + first = _payload(assembler.next(text_chunk("Hel"))) + second = _payload(assembler.next(text_chunk("lo"))) + assembler.next(usage_chunk(prompt=2, completion=3)) + finish = _payload(assembler.finish()) + usage = _payload(assembler.usage(include_usage=True)) + + assert prelude["choices"][0]["delta"]["role"] == "assistant" + assert first["choices"][0]["delta"]["content"] == "Hel" + assert second["object"] == "chat.completion.chunk" + assert finish["choices"][0]["finish_reason"] == "stop" + assert usage["choices"] == [] + assert usage["usage"]["total_tokens"] == 5 + + +def test_no_usage_chunk_when_the_caller_did_not_ask_for_one(): + assembler = ChunkAssembler(model="gpt-4o", request_id="chatcmpl-x") + assembler.next(usage_chunk(prompt=2, completion=3)) + + assert assembler.usage(include_usage=False) is None + + +def test_parallel_tool_calls_keep_distinct_indices_when_upstream_sends_none(): + # qwen / zhipu deliver index=None and put the id only on the first fragment. + # Forwarding that verbatim concatenates two calls' arguments into one. + assembler = ChunkAssembler(model="gpt-4o", request_id="chatcmpl-x") + assembler.role_prelude() + + first = _payload(assembler.next(tool_chunk(name="read_file", args='{"p"', call_id="call_1"))) + first_more = _payload(assembler.next(tool_chunk(name=None, args=':"a.py"}', call_id=None))) + second = _payload(assembler.next(tool_chunk(name="list_dir", args="{}", call_id="call_2"))) + + assert first["choices"][0]["delta"]["tool_calls"][0]["index"] == 0 + assert first_more["choices"][0]["delta"]["tool_calls"][0]["index"] == 0 + assert second["choices"][0]["delta"]["tool_calls"][0]["index"] == 1 + assert _payload(assembler.finish())["choices"][0]["finish_reason"] == "tool_calls" + + +def test_reasoning_only_chunks_are_emitted_and_empty_chunks_are_not(): + assembler = ChunkAssembler(model="gpt-4o", request_id="chatcmpl-x") + + thinking = assembler.next(AIMessageChunk(content="", additional_kwargs={"reasoning_content": "hmm"})) + empty = assembler.next(AIMessageChunk(content="")) + + assert _payload(thinking)["choices"][0]["delta"]["reasoning_content"] == "hmm" + assert empty is None + + +def test_upstream_status_decides_the_band(): + class Status(Exception): + def __init__(self, status_code, message): + super().__init__(message) + self.status_code = status_code + self.message = message + + rejected = classify_upstream_error(Status(413, "context too long")) + limited = classify_upstream_error(Status(429, "slow down")) + failed = classify_upstream_error(ConnectionError("no route to host")) + + assert isinstance(rejected, ModelFaceUpstreamRejectedError) + assert rejected.http_status == 413 + assert "context too long" in rejected.message + assert isinstance(limited, ModelFaceUpstreamRateLimitedError) + assert isinstance(failed, ModelFaceUpstreamError) + + +def test_unrecognised_upstream_text_is_truncated(): + error = classify_upstream_error(RuntimeError("x" * 900)) + + assert len(error.message) == 500 + + +def test_mid_stream_failure_without_a_status_is_stream_interrupted(): + assert isinstance(classify_stream_error(RuntimeError("socket closed")), ModelFaceStreamInterruptedError) + + +@pytest.mark.parametrize( + "text", + [ + "auth failed for bs-sak-abcdefghijklmnop", + "Incorrect API key provided: sk-proj-abcdefghijklmnop", + "header was Bearer eyJhbGciOi.payload.sig", + ], +) +def test_key_shaped_text_never_survives_into_a_message(text): + redacted = redact_secrets(text) + + assert "[redacted]" in redacted + assert "bs-sak-abcdefghijklmnop" not in redacted + assert "sk-proj-abcdefghijklmnop" not in redacted + assert "eyJhbGciOi.payload.sig" not in redacted + + +def test_stream_error_event_carries_the_openai_shape(): + payload = _payload(ChunkAssembler.error(ModelFaceStreamInterruptedError()))["error"] + + assert payload["bisheng_code"] == 26534 + assert payload["type"] == "server_error" + assert payload["code"] == "stream_interrupted" diff --git a/src/backend/test/open_api/test_openapi_schema_contract.py b/src/backend/test/open_api/test_openapi_schema_contract.py index 91fbaf4bc9..c3c45fff7e 100644 --- a/src/backend/test/open_api/test_openapi_schema_contract.py +++ b/src/backend/test/open_api/test_openapi_schema_contract.py @@ -1,7 +1,9 @@ import json from pathlib import Path +from bisheng.common.services.config_service import settings from bisheng.main import app +from bisheng.open_api.api.exception_handlers import MODEL_GATEWAY_PATH_PREFIX from bisheng.open_api.api.openapi_schema import BEARER_SCHEME_NAME @@ -60,6 +62,14 @@ def test_generated_customer_contract_matches_registered_v2_http_routes(): for method in path_item if method in {"get", "post", "put", "patch", "delete"} } + if not settings.open_platform.enabled: + # The contract json is generated with the open capability layer on, so + # it always documents the model protocol face. This process may have + # imported the app with the switch off, in which case the face is not + # mounted at all (F051 AC-28) — compare the rest. + documented_operations = { + item for item in documented_operations if not item[0].startswith(MODEL_GATEWAY_PATH_PREFIX) + } assert documented_operations == expected_operations assert all( operation["security"] == [{BEARER_SCHEME_NAME: []}] @@ -76,9 +86,9 @@ def test_generated_customer_contract_matches_registered_v2_http_routes(): } assert referenced <= schemas.keys() - workflow_ref = contract["paths"]["/api/v2/workflow/invoke"]["post"]["requestBody"]["content"][ - "application/json" - ]["schema"]["$ref"] + workflow_ref = contract["paths"]["/api/v2/workflow/invoke"]["post"]["requestBody"]["content"]["application/json"][ + "schema" + ]["$ref"] workflow_properties = schemas[workflow_ref.rsplit("/", 1)[-1]]["properties"] assert "input" in workflow_properties diff --git a/src/backend/test/open_api/test_public_base_url.py b/src/backend/test/open_api/test_public_base_url.py index 1c6fbdb9c2..8e19fcb1e0 100644 --- a/src/backend/test/open_api/test_public_base_url.py +++ b/src/backend/test/open_api/test_public_base_url.py @@ -13,9 +13,9 @@ from bisheng.common.dependencies.user_deps import UserPayload from bisheng.common.services.config_service import settings from bisheng.core.config.open_platform import OpenApiConf -from bisheng.open_api.api import public_base_url as resolver_module from bisheng.open_api.api.endpoints import personal_token_self, skill_pack -from bisheng.open_api.api.public_base_url import resolve_public_base_url +from bisheng.open_api.domain.services import public_base_url as resolver_module +from bisheng.open_api.domain.services.public_base_url import resolve_public_base_url PACK_PATH = "/api/v1/open-api/skill-packs/knowledge-search" diff --git a/src/backend/test/open_api/test_route_error_contract.py b/src/backend/test/open_api/test_route_error_contract.py index bb1d578160..52878dd3d0 100644 --- a/src/backend/test/open_api/test_route_error_contract.py +++ b/src/backend/test/open_api/test_route_error_contract.py @@ -156,18 +156,27 @@ async def test_child_tenant_key_is_not_mistaken_for_missing_account(open_api_db, from bisheng.open_api.domain.schemas.credential import KeyIssueRequest from bisheng.open_api.domain.services.credential_service import CredentialService - async with open_api_db() as session: - account = ServiceAccount(tenant_id=9, name="child-account", resource_owner_user_id=12) - session.add(account) - await session.commit() - await session.refresh(account) - issued = await CredentialService.issue( - tenant_id=9, - subject_kind="service_account", - subject_id=account.id, - request=KeyIssueRequest(name="child-key"), - created_by=12, - ) + # The account belongs to tenant 9 and the tenant filter only shows a session + # its own tenant's rows, so the setup has to run as tenant 9. Without this the + # refresh reads whatever tenant a previous test left in the ContextVar and + # cannot find the row it just wrote - the test only passed while nothing + # earlier in the run happened to set one. + setup_token = current_tenant_id.set(9) + try: + async with open_api_db() as session: + account = ServiceAccount(tenant_id=9, name="child-account", resource_owner_user_id=12) + session.add(account) + await session.commit() + await session.refresh(account) + issued = await CredentialService.issue( + tenant_id=9, + subject_kind="service_account", + subject_id=account.id, + request=KeyIssueRequest(name="child-key"), + created_by=12, + ) + finally: + current_tenant_id.reset(setup_token) monkeypatch.setattr( "bisheng.open_api.domain.services.credential_service.CredentialService.touch_last_used", AsyncMock() ) diff --git a/src/backend/test/open_api/test_runtime_credential_audit.py b/src/backend/test/open_api/test_runtime_credential_audit.py new file mode 100644 index 0000000000..7b2d711a31 --- /dev/null +++ b/src/backend/test/open_api/test_runtime_credential_audit.py @@ -0,0 +1,113 @@ +"""Credential owner transaction guarantees. 覆盖 AC: AC-59, AC-58.""" + +import pytest + +from test.app_runtime.test_runtime_delivery_repository import runtime_repo as runtime_repo +from test.app_runtime.test_secret_runtime_snapshot import secret_runtime as secret_runtime + + +async def candidate(env): + from bisheng.app_publish.domain.services.app_credential_service import AppRuntimeCredentialService + from bisheng.app_runtime.domain.models.runtime_delivery import RuntimeCredentialBinding + + repo, app, _ = env + from bisheng.app_runtime.domain.models.runtime_delivery import RuntimeOperation + + op = await repo.get(RuntimeOperation, "origin") + async with repo.transaction() as session: + await AppRuntimeCredentialService.stage_in_transaction(app.id, operation=op, session=session) + rows = await repo.find(RuntimeCredentialBinding, operation_id=op.id) + return rows[0] + + +async def audits(repo): + from sqlmodel import select + + from bisheng.database.models.audit_log import AuditLog + + async with repo.transaction() as session: + return list((await session.exec(select(AuditLog))).all()) + + +async def test_issue_and_revoke_are_app_associated_and_replay_once(secret_runtime): + from bisheng.open_api.domain.services.credential_service import CredentialService + + repo, app, _ = secret_runtime + from bisheng.app_runtime.domain.models.runtime_delivery import RuntimeOperation + + op = await repo.get(RuntimeOperation, "origin") + binding = await candidate(secret_runtime) + rows = await audits(repo) + assert len(rows) == 1 + assert rows[0].audit_metadata["app_id"] == app.id + async with repo.transaction() as session: + assert await CredentialService.revoke_runtime_binding(session, binding, op, reason="replaced") is True + assert await CredentialService.revoke_runtime_binding(session, binding, op, reason="replaced") is False + rows = await audits(repo) + assert [r.action for r in rows] == ["open_api.api_key.issue", "open_api.api_key.revoke"] + assert rows[-1].audit_metadata["operation_id"] == op.id + + +async def test_foreign_binding_cannot_forge_app_association(secret_runtime): + from bisheng.app_runtime.domain.models.runtime_delivery import RuntimeCredentialBinding + from bisheng.open_api.domain.services.credential_service import CredentialService + + repo, _, _ = secret_runtime + from bisheng.app_runtime.domain.models.runtime_delivery import RuntimeOperation + + op = await repo.get(RuntimeOperation, "origin") + binding = await candidate(secret_runtime) + async with repo.transaction(): + row = await repo.get(RuntimeCredentialBinding, binding.id, lock=True) + row.app_id = "foreign-app" + await repo.save(row) + with pytest.raises(ValueError, match="binding"): + async with repo.transaction() as session: + await CredentialService.revoke_runtime_binding(session, binding, op, reason="replaced") + assert len(await audits(repo)) == 1 + + +async def test_audit_failure_rolls_back_revocation(secret_runtime, monkeypatch): + from bisheng.app_runtime.domain.models.runtime_delivery import RuntimeCredentialBinding + from bisheng.open_api.domain.repositories.credential_repository import CredentialRepository + from bisheng.open_api.domain.services.credential_service import CredentialService + + repo, _, _ = secret_runtime + from bisheng.app_runtime.domain.models.runtime_delivery import RuntimeOperation + + op = await repo.get(RuntimeOperation, "origin") + binding = await candidate(secret_runtime) + + async def fail(*args, **kwargs): + raise RuntimeError("audit unavailable") + + monkeypatch.setattr(CredentialRepository, "append_runtime_revoke_audit", fail) + with pytest.raises(RuntimeError, match="audit unavailable"): + async with repo.transaction() as session: + await CredentialService.revoke_runtime_binding(session, binding, op, reason="failed") + row = await repo.get(RuntimeCredentialBinding, binding.id) + assert row.state == "staged" + assert len(await audits(repo)) == 1 + + +async def test_terminal_stop_revokes_serving_binding(secret_runtime): + from bisheng.app_runtime.domain.models.runtime_delivery import RuntimeCredentialBinding, RuntimeOperation + from bisheng.app_runtime.domain.services.runtime_projection_service import RuntimeProjectionService + + repo, _, _ = secret_runtime + origin = await repo.get(RuntimeOperation, "origin") + binding = await candidate(secret_runtime) + async with repo.transaction(): + row = await repo.get(RuntimeCredentialBinding, binding.id, lock=True) + row.state = "active" + await repo.save(row) + stop = RuntimeOperation(**{k: getattr(origin, k) for k in RuntimeOperation.model_fields}) + stop.id = "stop" + stop.kind = "stop" + stop.idempotency_key = "stop" + await repo.save(stop) + await RuntimeProjectionService(repo).terminal(stop, {"stopped": True}) + await RuntimeProjectionService(repo).terminal(stop, {"stopped": True}) + row = await repo.get(RuntimeCredentialBinding, binding.id) + assert row.state == "revoked" + assert len(await audits(repo)) == 2 diff --git a/src/backend/test/open_api/test_scope_issuability.py b/src/backend/test/open_api/test_scope_issuability.py new file mode 100644 index 0000000000..e87eb35d37 --- /dev/null +++ b/src/backend/test/open_api/test_scope_issuability.py @@ -0,0 +1,228 @@ +"""Issue-time policy for the extension scopes (migration plan M7). + +* ``app:manage`` and ``model:invoke`` are issuable exactly while + ``open_platform.enabled`` is on; ``identity:read`` stays unissuable (F052 + pending). +* ``delegate`` and the local development toolkit scopes are refused together, + on issue and on edit alike (伴生 PRD §4.2.4 / AC-48, release-contract INV-31). +* A personal token never receives an extension scope (伴生 PRD §4.10.3). +""" + +from types import SimpleNamespace + +import pytest + +from bisheng.common.errcode.open_api import ( + OpenApiDelegateExclusiveScopeError, + OpenApiExtensionScopeNotDeployedError, + PersonalTokenScopeInvalidError, +) +from bisheng.common.services.config_service import settings +from bisheng.open_api.api.endpoints.service_account_keys import list_open_api_scopes +from bisheng.open_api.domain.models.api_credential import ( + SUBJECT_KIND_NATURAL_PERSON, + SUBJECT_KIND_SERVICE_ACCOUNT, +) +from bisheng.open_api.domain.schemas.credential import ( + DelegateScopeInput, + KeyIssueRequest, + KeyUpdateRequest, +) +from bisheng.open_api.domain.scopes import OPEN_API_SCOPE_MAP +from bisheng.open_api.domain.services.credential_service import CredentialService + +DELEGATE_TARGET = [DelegateScopeInput(subject_type="user", subject_id=9)] + + +# ``open_platform_enabled`` / ``open_platform_disabled`` now live in +# test/open_api/conftest.py — the model protocol face tests need the same two. + + +@pytest.fixture +def delegate_target_exists(monkeypatch): + """Make every delegation target in ``DELEGATE_TARGET`` resolve. + + Stubs the **batched** lookup: ``DelegateScopeService.filter_entries`` reads + all candidate users in one call (beta2 ``3fe98557d`` aligned picker + filtering with save validation). Stubbing the singular + ``get_active_natural_person`` instead leaves the real batched method in + place, it finds nobody without a database, and the save is refused with + ``OpenApiDelegateConfigurationInvalidError`` — which reads like the + exclusivity rule under test rather than a stale fixture. + """ + + async def active_users(user_ids): + return {user_id: SimpleNamespace(user_id=user_id, tenant_id=1) for user_id in user_ids} + + monkeypatch.setattr( + "bisheng.open_api.domain.services.delegate_scope_service.OwnerRepository.get_active_natural_people", + active_users, + ) + + +def test_app_manage_is_refused_while_open_platform_is_off(open_platform_disabled): + with pytest.raises(OpenApiExtensionScopeNotDeployedError): + CredentialService.validate_scopes(["knowledge:read", "app:manage"]) + + +def test_app_manage_is_accepted_once_open_platform_is_on(open_platform_enabled): + assert CredentialService.validate_scopes(["knowledge:read", "app:manage"]) == ["knowledge:read", "app:manage"] + + +def test_no_extension_scope_is_pending_a_surface(open_platform_enabled): + """F051 (``model:invoke``) and F052 (``identity:read``) both shipped in this + wave, so no extension scope is issuable-but-unimplemented any more. This + used to parametrize the pending set; it now asserts the set is empty, which + is the fact that has to keep holding — a scope added without a surface + behind it fails here instead of quietly becoming grantable.""" + + for scope in ("model:invoke", "identity:read", "app:manage"): + assert CredentialService.validate_scopes([scope]) == [scope] + + +def test_identity_read_becomes_issuable_with_the_mcp_face(open_platform_enabled): + """F052 shipped the three tools that read it, so an administrator may now tick it.""" + + assert CredentialService.validate_scopes(["identity:read"]) == ["identity:read"] + + +def test_identity_read_is_still_refused_without_the_open_capability_layer(open_platform_disabled): + with pytest.raises(OpenApiExtensionScopeNotDeployedError): + CredentialService.validate_scopes(["identity:read"]) + + +def test_model_invoke_issuable_once_open_platform_on(open_platform_enabled): + assert CredentialService.validate_scopes(["model:invoke"]) == ["model:invoke"] + + +def test_model_invoke_still_refused_while_open_platform_is_off(open_platform_disabled): + with pytest.raises(OpenApiExtensionScopeNotDeployedError): + CredentialService.validate_scopes(["model:invoke"]) + + +def test_model_invoke_registers_the_model_face_v2_routes(): + assert OPEN_API_SCOPE_MAP["model:invoke"].endpoints == ( + ("POST", "/api/v2/model/v1/chat/completions"), + ("GET", "/api/v2/model/v1/models"), + ("GET", "/api/v2/model/v1/{rest}"), + ("POST", "/api/v2/model/v1/{rest}"), + ("PUT", "/api/v2/model/v1/{rest}"), + ("DELETE", "/api/v2/model/v1/{rest}"), + ("PATCH", "/api/v2/model/v1/{rest}"), + ) + + +def test_app_manage_registers_the_four_app_factory_v2_routes(): + assert OPEN_API_SCOPE_MAP["app:manage"].endpoints == ( + ("GET", "/api/v2/apps/deploy-limits"), + ("POST", "/api/v2/apps/deploy"), + ("GET", "/api/v2/apps/deployments/{deployment_id}"), + ("GET", "/api/v2/apps/{app_id}/logs"), + ) + + +async def test_service_account_issue_refuses_delegate_with_app_manage( + open_api_db, fake_redis, open_platform_enabled, delegate_target_exists +): + with pytest.raises(OpenApiDelegateExclusiveScopeError): + await CredentialService.issue( + tenant_id=1, + subject_kind=SUBJECT_KIND_SERVICE_ACCOUNT, + subject_id=7, + request=KeyIssueRequest( + name="mixed", + scopes=["app:manage", "delegate"], + delegate_scopes=DELEGATE_TARGET, + ), + created_by=3, + ) + + +async def test_edit_cannot_add_delegate_to_an_app_manage_key( + open_api_db, fake_redis, open_platform_enabled, delegate_target_exists +): + issued = await CredentialService.issue( + tenant_id=1, + subject_kind=SUBJECT_KIND_SERVICE_ACCOUNT, + subject_id=7, + request=KeyIssueRequest(name="cli", scopes=["app:manage"]), + created_by=3, + ) + assert issued.scopes == ["app:manage"] + + with pytest.raises(OpenApiDelegateExclusiveScopeError): + await CredentialService.update( + SUBJECT_KIND_SERVICE_ACCOUNT, + 7, + issued.id, + KeyUpdateRequest(scopes=["app:manage", "delegate"], delegate_scopes=DELEGATE_TARGET), + ) + + unchanged = await CredentialService.get_row(SUBJECT_KIND_SERVICE_ACCOUNT, 7, issued.id) + assert list(unchanged.scopes) == ["app:manage"] + + +async def test_edit_cannot_add_app_manage_to_a_delegated_key( + open_api_db, fake_redis, open_platform_enabled, delegate_target_exists +): + issued = await CredentialService.issue( + tenant_id=1, + subject_kind=SUBJECT_KIND_SERVICE_ACCOUNT, + subject_id=7, + request=KeyIssueRequest( + name="delegated", + scopes=["knowledge:read", "delegate"], + delegate_scopes=DELEGATE_TARGET, + ), + created_by=3, + ) + + with pytest.raises(OpenApiDelegateExclusiveScopeError): + await CredentialService.update( + SUBJECT_KIND_SERVICE_ACCOUNT, + 7, + issued.id, + KeyUpdateRequest(scopes=["knowledge:read", "delegate", "app:manage"]), + ) + + +async def test_personal_token_never_receives_app_manage(open_api_db, fake_redis, open_platform_enabled): + with pytest.raises(PersonalTokenScopeInvalidError): + await CredentialService.issue( + tenant_id=1, + subject_kind=SUBJECT_KIND_NATURAL_PERSON, + subject_id=3, + request=KeyIssueRequest(name="pat", scopes=["app:manage"]), + created_by=3, + ) + with pytest.raises(PersonalTokenScopeInvalidError): + await CredentialService.issue( + tenant_id=1, + subject_kind=SUBJECT_KIND_NATURAL_PERSON, + subject_id=3, + request=KeyIssueRequest(name="pat", scopes=["knowledge:read", "app:manage"]), + created_by=3, + ) + + +async def test_scope_catalog_offers_app_manage_only_with_open_platform(monkeypatch): + monkeypatch.setattr(settings.open_platform, "enabled", False) + hidden = (await list_open_api_scopes(_admin=None)).data + assert hidden.open_platform_enabled is False + assert "app:manage" not in {item.code for item in hidden.scopes} + + monkeypatch.setattr(settings.open_platform, "enabled", True) + shown = (await list_open_api_scopes(_admin=None)).data + codes = {item.code for item in shown.scopes} + assert shown.open_platform_enabled is True + assert "app:manage" in codes + assert "model:invoke" in codes # F051 protocol face + assert "identity:read" in codes # F052 MCP face + app_manage = next(item for item in shown.scopes if item.code == "app:manage") + assert app_manage.group == "local_dev_toolkit" + assert [(endpoint.method, endpoint.path) for endpoint in app_manage.endpoints] == [ + ("GET", "/api/v2/apps/deploy-limits"), + ("POST", "/api/v2/apps/deploy"), + ("GET", "/api/v2/apps/deployments/{deployment_id}"), + ("GET", "/api/v2/apps/{app_id}/logs"), + ] diff --git a/src/backend/test/open_api/test_scopes.py b/src/backend/test/open_api/test_scopes.py index b3fcbd879a..b40f6f5019 100644 --- a/src/backend/test/open_api/test_scopes.py +++ b/src/backend/test/open_api/test_scopes.py @@ -1,9 +1,13 @@ import pytest +from bisheng.common.services.config_service import settings from bisheng.open_api.domain.scopes import ( - ISSUABLE_OPEN_API_SCOPE_CODES, + ALWAYS_ISSUABLE_OPEN_API_SCOPE_CODES, + LOCAL_DEV_TOOLKIT_SCOPE_CODES, OPEN_API_SCOPES, get_open_api_scope_marker, + issuable_scope_codes, + issuable_scopes, open_api_scope, ) @@ -27,14 +31,44 @@ def test_unknown_scope_and_invalid_modes_fail_at_import_time(): open_api_scope("knowledge:read", modes=()) -def test_undeployed_extension_scopes_are_not_issuable(): - assert {"model:invoke", "identity:read", "app:manage"}.isdisjoint(ISSUABLE_OPEN_API_SCOPE_CODES) - assert "delegate" in ISSUABLE_OPEN_API_SCOPE_CODES +def test_extension_scopes_are_not_issuable_without_open_platform(monkeypatch): + monkeypatch.setattr(settings.open_platform, "enabled", False) + assert LOCAL_DEV_TOOLKIT_SCOPE_CODES == {"model:invoke", "identity:read", "app:manage"} + assert LOCAL_DEV_TOOLKIT_SCOPE_CODES.isdisjoint(issuable_scope_codes()) + assert LOCAL_DEV_TOOLKIT_SCOPE_CODES.isdisjoint(ALWAYS_ISSUABLE_OPEN_API_SCOPE_CODES) + assert "delegate" in issuable_scope_codes() + assert issuable_scope_codes() == ALWAYS_ISSUABLE_OPEN_API_SCOPE_CODES -def test_issuable_scopes_have_localized_presentation_metadata(): - scopes = [scope for scope in OPEN_API_SCOPES if scope.issuable] +def test_the_three_extension_scopes_become_issuable_with_open_platform(monkeypatch): + """All three ship now: F051 gave ``model:invoke`` its protocol face and F052 + gave ``identity:read`` its MCP tools, so an administrator can grant each of + them and something implements it. The equality is the point — a fourth scope + appearing here without a surface behind it should fail this test.""" + + monkeypatch.setattr(settings.open_platform, "enabled", True) + + codes = issuable_scope_codes() + assert {"app:manage", "model:invoke", "identity:read"} <= codes + assert codes == ALWAYS_ISSUABLE_OPEN_API_SCOPE_CODES | {"app:manage", "model:invoke", "identity:read"} + + +def test_issuable_scopes_have_localized_presentation_metadata(monkeypatch): + monkeypatch.setattr(settings.open_platform, "enabled", False) + assert [scope.group for scope in issuable_scopes()] == [ + "app", + "app", + "app", + "app", + "workstation", + "knowledge", + "knowledge", + "delegation", + ] + + monkeypatch.setattr(settings.open_platform, "enabled", True) + scopes = issuable_scopes() assert [scope.group for scope in scopes] == [ "app", "app", @@ -43,8 +77,19 @@ def test_issuable_scopes_have_localized_presentation_metadata(): "workstation", "knowledge", "knowledge", + "local_dev_toolkit", # model:invoke — F051 protocol face + "local_dev_toolkit", # identity:read — F052 MCP tools, no REST route + "local_dev_toolkit", # app:manage "delegation", ] assert all(scope.label_key.startswith("openApiManagement.scopes.") for scope in scopes) assert all(scope.desc_key.startswith("openApiManagement.scopes.") for scope in scopes) - assert all(scope.endpoints for scope in scopes if scope.code != "delegate") + # ``delegate`` gates a mode rather than a surface; ``identity:read`` gates + # MCP tools, which are dispatched from the tool registry and so have no + # ``/api/v2`` route to register. Everything else answers on a route. + assert all(scope.endpoints for scope in scopes if scope.code not in {"delegate", "identity:read"}) + + +def test_registry_maps_each_endpoint_to_exactly_one_scope(): + mapped = [endpoint for scope in OPEN_API_SCOPES for endpoint in scope.endpoints] + assert len(mapped) == len(set(mapped)) diff --git a/src/backend/test/open_api/test_service_account_absent_resource_grants.py b/src/backend/test/open_api/test_service_account_absent_resource_grants.py index 3de186f2f8..7d92231027 100644 --- a/src/backend/test/open_api/test_service_account_absent_resource_grants.py +++ b/src/backend/test/open_api/test_service_account_absent_resource_grants.py @@ -84,7 +84,7 @@ async def test_delete_account_removes_grants_on_deleted_resources(monkeypatch): async def test_delete_account_keeps_failing_when_absence_is_not_confirmed(monkeypatch): - """19003 alone may mean cross-tenant or an unauthorizable state: stay fail-closed.""" + """25022 alone may mean cross-tenant or an unauthorizable state: stay fail-closed.""" registry = _Registry(absent=set()) remove = AsyncMock() diff --git a/src/backend/test/open_api/test_service_account_management_scope.py b/src/backend/test/open_api/test_service_account_management_scope.py new file mode 100644 index 0000000000..0143368a8b --- /dev/null +++ b/src/backend/test/open_api/test_service_account_management_scope.py @@ -0,0 +1,305 @@ +"""F113: request scope is authoritative, pinned and separate from account tenancy.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from bisheng.common.errcode.open_api import ( + OpenApiAuthDependencyUnavailableError, + ServiceAccountManagementScopeChangedError, +) +from bisheng.common.errcode.tenant import TenantDisabledError +from bisheng.core.context.tenant import get_current_tenant_id, get_visible_tenant_ids, set_admin_scope_tenant_id +from bisheng.open_api.domain.services import service_account_scope_service as service + + +def caller(*, super_admin=True, tenant=1): + return SimpleNamespace(user_id=21, tenant_id=tenant, is_global_super=super_admin) + + +@pytest.fixture +def scope_ports(monkeypatch): + monkeypatch.setattr(service.settings.multi_tenant, "enabled", True) + current = AsyncMock(return_value=2) + available = AsyncMock() + monkeypatch.setattr(service, "current_admin_scope", current) + monkeypatch.setattr(service, "ensure_service_account_tenant_available", available) + return current, available + + +async def test_verified_scope_overrides_stale_middleware_and_restores_it(scope_ports): + from bisheng.core.context.tenant import set_is_management_api + + management_token = set_is_management_api(True) + old = set_admin_scope_tenant_id(9) + try: + async with service.ServiceAccountScopeService.bind_request( + caller(), expected_tenant="2", writing=True + ) as tenant: + assert tenant == get_current_tenant_id() == 2 + assert get_visible_tenant_ids() == frozenset({2}) + assert service.ServiceAccountScopeService.admitted_tenant() == 2 + scope_ports[0].return_value = 3 + assert get_current_tenant_id() == 2 + assert get_current_tenant_id() == 9 + assert service.ServiceAccountScopeService.admitted_tenant() is None + finally: + old.var.reset(old) + management_token.var.reset(management_token) + + +@pytest.mark.parametrize("selected,expected", [(None, "1"), (2, None), (2, "1"), (2, "0"), (2, "invalid")]) +async def test_super_writes_require_selected_and_matching_form_scope(scope_ports, selected, expected): + scope_ports[0].return_value = selected + with pytest.raises(ServiceAccountManagementScopeChangedError): + async with service.ServiceAccountScopeService.bind_request(caller(), expected_tenant=expected, writing=True): + pytest.fail("Invalid management scope reached a write") + + +async def test_root_selection_is_explicit_one(scope_ports): + scope_ports[0].return_value = 1 + async with service.ServiceAccountScopeService.bind_request(caller(), expected_tenant="1", writing=True): + assert get_current_tenant_id() == 1 + + +async def test_normal_admin_cannot_use_a_forged_scope_or_super_scope(scope_ports): + async with service.ServiceAccountScopeService.bind_request( + caller(super_admin=False, tenant=2), expected_tenant=None, writing=True + ): + assert get_current_tenant_id() == 2 + with pytest.raises(ServiceAccountManagementScopeChangedError): + async with service.ServiceAccountScopeService.bind_request( + caller(super_admin=False, tenant=2), expected_tenant="1", writing=True + ): + pytest.fail("A non-super administrator changed tenant") + scope_ports[0].assert_not_awaited() + + +async def test_unavailable_tenant_and_scope_dependency_do_not_fall_back(scope_ports): + scope_ports[1].side_effect = TenantDisabledError() + with pytest.raises(TenantDisabledError): + async with service.ServiceAccountScopeService.bind_request(caller(), expected_tenant="2", writing=True): + pytest.fail("Disabled tenant reached a write") + scope_ports[1].side_effect = None + scope_ports[0].side_effect = OSError("F113 scope store unavailable") + with pytest.raises(OpenApiAuthDependencyUnavailableError): + async with service.ServiceAccountScopeService.bind_request(caller(), expected_tenant="2", writing=True): + pytest.fail("Scope dependency fault reached a write") + + +async def test_single_tenant_retains_implicit_root_without_new_scope_dependency(scope_ports, monkeypatch): + monkeypatch.setattr(service.settings.multi_tenant, "enabled", False) + async with service.ServiceAccountScopeService.bind_request(caller(), expected_tenant=None, writing=True): + assert get_current_tenant_id() == 1 + scope_ports[0].assert_not_awaited() + scope_ports[1].assert_not_awaited() + + +async def test_new_read_with_stale_expected_scope_is_refused(scope_ports): + with pytest.raises(ServiceAccountManagementScopeChangedError): + async with service.ServiceAccountScopeService.bind_request(caller(), expected_tenant="1", writing=False): + pytest.fail("Stale read reached the old tenant view") + + +def test_write_inventory_covers_every_account_mutation_and_keeps_filter_read_only(): + from bisheng.open_api.api.dependencies import get_service_account_manager, is_service_account_write + from bisheng.open_api.api.endpoints.service_account import router as accounts + from bisheng.open_api.api.endpoints.service_account_keys import router as keys + + count = 0 + for route in [*accounts.routes, *keys.routes]: + assert any(dependency.call is get_service_account_manager for dependency in route.dependant.dependencies) + for method in route.methods - {"GET", "HEAD", "OPTIONS"}: + path = route.path.replace("{service_account_id}", "1").replace("{key_id}", "2") + read_filter = path.endswith("/delegate-candidates:filter") + assert is_service_account_write(method, "/api/v1" + path) is not read_filter + count += not read_filter + assert count == 10 + + +async def test_scoped_owner_candidates_are_paginated_and_exclude_other_tenants(open_api_db): + from bisheng.database.models.tenant import UserTenant + from bisheng.open_api.domain.repositories.owner_repository import OwnerRepository + from bisheng.user.domain.models.user import User + + async with open_api_db() as session: + for uid, tid, deleted in [(21, 1, 0), (22, 2, 0), (23, 2, 0), (24, 2, 1)]: + session.add(User(user_id=uid, user_name=f"F113-{uid}", password="", delete=deleted)) + session.add(UserTenant(user_id=uid, tenant_id=tid, is_active=1)) + await session.commit() + first, total = await OwnerRepository.list_management_owners(2, keyword="F113", page=1, page_size=1) + second, second_total = await OwnerRepository.list_management_owners(2, keyword="F113", page=2, page_size=1) + assert total == second_total == 2 + assert [item.user_id for item in first + second] == [22, 23] + assert all(item.tenant_id == 2 for item in first + second) + + +async def test_management_api_creation_follows_selected_tenant_and_rejects_old_form( + open_api_db, + fake_redis, + audit_events, + scope_ports, + monkeypatch, +): + import httpx + from fastapi import FastAPI + + from bisheng.common.dependencies.user_deps import UserPayload + from bisheng.core.context.tenant import tenant_execution_scope + from bisheng.database.models.tenant import UserTenant + from bisheng.open_api.api.dependencies import get_service_account_admin + from bisheng.open_api.api.endpoints.service_account import router as accounts + from bisheng.open_api.api.endpoints.service_account_scope import router as scope_router + from bisheng.open_api.api.exception_handlers import register_open_api_exception_handlers + from bisheng.open_api.domain.models.service_account import ServiceAccount + from bisheng.user.domain.models.user import User + + async with open_api_db() as session: + for uid, tid in [(21, 1), (22, 2)]: + session.add(User(user_id=uid, user_name=f"F113-owner-{uid}", password="")) + session.add(UserTenant(user_id=uid, tenant_id=tid, is_active=1)) + await session.commit() + admin = UserPayload(user_id=3, user_name="F113-admin", tenant_id=1, user_role=[], is_global_super=True) + app = FastAPI() + app.dependency_overrides[get_service_account_admin] = lambda: admin + app.include_router(scope_router, prefix="/api/v1") + app.include_router(accounts, prefix="/api/v1") + register_open_api_exception_handlers(app) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as client: + created = await client.post( + "/api/v1/service-accounts", + json={"name": "F113-child", "resource_owner_user_id": 22}, + headers={"X-Service-Account-Tenant": "2"}, + ) + assert created.status_code == 200 and created.json()["status_code"] == 200 + item = created.json()["data"] + assert item["tenant_id"] == 2 + candidates = await client.get( + "/api/v1/service-accounts/owner-candidates", headers={"X-Service-Account-Tenant": "2"} + ) + assert [row["user_id"] for row in candidates.json()["data"]["data"]] == [22] + scope_ports[0].return_value = 1 + refused = await client.patch( + f"/api/v1/service-accounts/{item['id']}", + json={"name": "must-not-save"}, + headers={"X-Service-Account-Tenant": "2"}, + ) + assert refused.json()["status_code"] == 26073 + no_header = await client.post( + "/api/v1/service-accounts", json={"name": "no-scope", "resource_owner_user_id": 21} + ) + assert no_header.json()["status_code"] == 26073 + with tenant_execution_scope(2): + async with open_api_db() as session: + stored = await session.get(ServiceAccount, item["id"]) + assert stored.name == "F113-child" and stored.tenant_id == 2 + + +async def test_management_scope_http_requires_super_and_keeps_selection_when_target_unavailable(monkeypatch): + """覆盖 AC: AC-17、AC-18、AC-19、AC-25; exercise the static scope endpoints.""" + import httpx + from fastapi import FastAPI + + from bisheng.common.dependencies.user_deps import UserPayload + from bisheng.open_api.api.dependencies import get_service_account_admin + from bisheng.open_api.api.endpoints.service_account_scope import router + from bisheng.open_api.api.exception_handlers import register_open_api_exception_handlers + + monkeypatch.setattr(service.settings.multi_tenant, "enabled", True) + current = {"scope_tenant_id": 2, "expires_at": None} + + async def read(_user_id): + return dict(current) + + async def change(_user_id, tenant_id, _context): + if tenant_id == 3: + raise TenantDisabledError() + current["scope_tenant_id"] = tenant_id + return dict(current) + + monkeypatch.setattr(service, "read_service_account_scope", read) + monkeypatch.setattr(service, "set_service_account_scope", change) + monkeypatch.setattr(service, "ensure_service_account_tenant_available", AsyncMock()) + admin = UserPayload(user_id=3, user_name="F113-super", tenant_id=1, user_role=[], is_global_super=True) + app = FastAPI() + app.dependency_overrides[get_service_account_admin] = lambda: admin + app.include_router(router, prefix="/api/v1") + register_open_api_exception_handlers(app) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as client: + url = "/api/v1/service-accounts/management-scope" + response = await client.get(url) + assert response.json()["data"]["scope_tenant_id"] == 2 + root = await client.put(url, json={"tenant_id": 1}) + assert root.json()["data"]["scope_tenant_id"] == 1 + refused = await client.put(url, json={"tenant_id": 3}) + assert refused.json()["status_code"] == TenantDisabledError.Code + assert (await client.get(url)).json()["data"]["scope_tenant_id"] == 1 + for value in (None, 0, "2", -1): + invalid = await client.put(url, json={"tenant_id": value}) + assert invalid.status_code == 422 + admin = admin.model_copy(update={"is_global_super": False, "tenant_id": 2}) + for response in (await client.get(url), await client.put(url, json={"tenant_id": 2})): + assert response.json()["status_code"] == 26022 + assert current["scope_tenant_id"] == 1 + + +@pytest.mark.parametrize("super_admin,writing", [(True, False), (True, True), (False, False)]) +async def test_global_list_scope_is_retired_for_every_actor(scope_ports, super_admin, writing): + from bisheng.core.context.tenant import is_tenant_filter_bypassed + + scope_ports[0].return_value = None + with pytest.raises(ServiceAccountManagementScopeChangedError): + async with service.ServiceAccountScopeService.bind_request( + caller(super_admin=super_admin), expected_tenant="all", writing=writing + ): + pytest.fail("Retired global scope was admitted") + assert not is_tenant_filter_bypassed() + scope_ports[0].assert_not_awaited() + scope_ports[1].assert_not_awaited() + + +async def test_list_http_is_concrete_and_rejects_legacy_global_requests(open_api_db, scope_ports): + import httpx + from fastapi import FastAPI + + from bisheng.common.dependencies.user_deps import UserPayload + from bisheng.core.context.tenant import bypass_tenant_filter + from bisheng.database.models.tenant import Tenant + from bisheng.open_api.api.dependencies import get_service_account_admin + from bisheng.open_api.api.endpoints.service_account import router + from bisheng.open_api.api.exception_handlers import register_open_api_exception_handlers + from bisheng.open_api.domain.models.service_account import ServiceAccount + + with bypass_tenant_filter(): + async with open_api_db() as db: + db.add_all([ + Tenant(id=1, tenant_code="root", tenant_name="Root organization"), + Tenant(id=2, tenant_code="department", tenant_name="Department tenant"), + ServiceAccount(id=1, tenant_id=1, name="root-account", resource_owner_user_id=21), + ServiceAccount(id=2, tenant_id=2, name="department-account", resource_owner_user_id=22), + ]) + await db.commit() + operator = UserPayload(user_id=21, user_name="admin", tenant_id=1, user_role=[], is_global_super=True) + app = FastAPI() + app.dependency_overrides[get_service_account_admin] = lambda: operator + app.include_router(router, prefix="/api/v1") + register_open_api_exception_handlers(app) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as client: + url = "/api/v1/service-accounts" + blocked = await client.get(url, headers={"X-Service-Account-Tenant": "all"}) + assert blocked.json()["status_code"] == ServiceAccountManagementScopeChangedError.Code + for selected in (1, 2): + scope_ports[0].return_value = selected + response = await client.get(url, headers={"X-Service-Account-Tenant": str(selected)}) + assert response.json()["status_code"] == 200 + assert response.json()["data"]["total"] == 1 + assert {row["tenant_id"] for row in response.json()["data"]["data"]} == {selected} + assert response.json()["data"]["data"][0]["tenant_name"] + operator.is_global_super = False + operator.tenant_id = 2 + denied = await client.get(url, headers={"X-Service-Account-Tenant": "1"}) + assert denied.json()["status_code"] == ServiceAccountManagementScopeChangedError.Code + own = await client.get(url, headers={"X-Service-Account-Tenant": "2"}) + assert own.json()["data"]["total"] == 1 + assert own.json()["data"]["data"][0]["tenant_id"] == 2 diff --git a/src/backend/test/open_api/test_service_account_service.py b/src/backend/test/open_api/test_service_account_service.py index f8bee9c0e8..baf207c17a 100644 --- a/src/backend/test/open_api/test_service_account_service.py +++ b/src/backend/test/open_api/test_service_account_service.py @@ -3,7 +3,12 @@ import pytest from sqlmodel import select -from bisheng.common.errcode.open_api import ServiceAccountNotFoundError, ServiceAccountOwnerInvalidError +from bisheng.common.errcode.open_api import ( + ServiceAccountManagementScopeChangedError, + ServiceAccountNameTakenError, + ServiceAccountNotFoundError, + ServiceAccountOwnerInvalidError, +) from bisheng.core.context.tenant import current_tenant_id from bisheng.database.models.tenant import UserTenant from bisheng.open_api.domain.models.api_credential import SUBJECT_KIND_SERVICE_ACCOUNT, ApiCredential @@ -54,17 +59,17 @@ async def test_owner_must_be_active_and_in_same_tenant(open_api_db, fake_redis, ) -async def test_global_super_without_scope_derives_tenant_from_owner(open_api_db, fake_redis, audit_events): +async def test_global_super_without_scope_cannot_create_from_owner(open_api_db, fake_redis, audit_events): await seed_user(open_api_db, 10, 2) token = current_tenant_id.set(None) try: - row = await ServiceAccountService.create( - actor(tenant_id=1, is_global_super=True), - ServiceAccountCreate(name="child-integration", resource_owner_user_id=10), - ) + with pytest.raises(ServiceAccountManagementScopeChangedError): + await ServiceAccountService.create( + actor(tenant_id=1, is_global_super=True), + ServiceAccountCreate(name="child-integration", resource_owner_user_id=10), + ) finally: current_tenant_id.reset(token) - assert row.tenant_id == 2 async def test_update_disable_enable_delete_and_key_invalidation(open_api_db, fake_redis, audit_events): @@ -145,3 +150,40 @@ async def test_list_exposes_creator_and_delegate_summary(open_api_db, fake_redis "subject_id": 11, "subject_name": "user-11", } + + +async def test_a_taken_name_is_a_business_error_not_a_500(open_api_db, fake_redis, audit_events): + """Live and deleted accounts both hold their name (found on 114: the + re-create after a delete hit the unique constraint and answered 500).""" + await seed_user(open_api_db, 10, 1) + live = await ServiceAccountService.create(actor(), ServiceAccountCreate(name="sync", resource_owner_user_id=10)) + with pytest.raises(ServiceAccountNameTakenError) as same_live: + await ServiceAccountService.create(actor(), ServiceAccountCreate(name="sync", resource_owner_user_id=10)) + assert same_live.value.code == 26070 + + gone = await ServiceAccountService.create(actor(), ServiceAccountCreate(name="old", resource_owner_user_id=10)) + await ServiceAccountService.delete(actor(), gone.id) + with pytest.raises(ServiceAccountNameTakenError): + await ServiceAccountService.create(actor(), ServiceAccountCreate(name="old", resource_owner_user_id=10)) + + with pytest.raises(ServiceAccountNameTakenError): + await ServiceAccountService.update(actor(), live.id, ServiceAccountUpdate(name="old")) + kept = await ServiceAccountService.update( + actor(), live.id, ServiceAccountUpdate(name="sync", description="same name") + ) + assert kept.name == "sync" and kept.description == "same name" + + +async def test_the_same_name_is_free_in_another_tenant(open_api_db, fake_redis, audit_events): + await seed_user(open_api_db, 10, 1) + await seed_user(open_api_db, 20, 2) + await ServiceAccountService.create(actor(), ServiceAccountCreate(name="sync", resource_owner_user_id=10)) + token = current_tenant_id.set(2) + try: + other = await ServiceAccountService.create( + actor(tenant_id=2), + ServiceAccountCreate(name="sync", resource_owner_user_id=20), + ) + finally: + current_tenant_id.reset(token) + assert other.tenant_id == 2 diff --git a/src/backend/test/open_api/test_service_account_tenancy.py b/src/backend/test/open_api/test_service_account_tenancy.py new file mode 100644 index 0000000000..947e010918 --- /dev/null +++ b/src/backend/test/open_api/test_service_account_tenancy.py @@ -0,0 +1,166 @@ +"""F113: live ownership is checked after every credential-cache lookup.""" + +from dataclasses import replace +from datetime import datetime + +import pytest +from sqlmodel import select + +from bisheng.common.errcode.open_api import ( + OpenApiAuthDependencyUnavailableError, + ServiceAccountOwnerTenantMismatchError, + ServiceAccountOwnerTenantUnresolvedError, +) +from bisheng.database.models.tenant import UserTenant +from bisheng.open_api.application.service_account_tenancy import ( + require_owner_tenant, + revalidate_service_account, +) +from bisheng.open_api.domain.context import OpenApiPrincipal +from bisheng.open_api.domain.models.service_account import ServiceAccount +from bisheng.open_api.domain.repositories.owner_repository import OwnerRepository +from bisheng.open_api.domain.schemas.credential import KeyIssueRequest +from bisheng.open_api.domain.services.credential_service import CredentialService +from bisheng.open_api.domain.services.credential_validator import validate_bearer +from bisheng.user.domain.models.user import User + + +async def seed(factory, *, owner=21, tenant=1, deleted=0, membership=True): + async with factory() as session: + session.add(User(user_id=owner, user_name=f"owner-{owner}", password="", delete=deleted)) + if membership: + session.add(UserTenant(user_id=owner, tenant_id=tenant, is_active=1, status="active")) + account = ServiceAccount(tenant_id=1, name=f"f113-{owner}", resource_owner_user_id=owner) + session.add(account) + await session.commit() + await session.refresh(account) + return account + + +def principal(account): + return OpenApiPrincipal( + credential_id=10, + actor_kind="service_account", + actor_id=account.id, + actor_name=account.name, + tenant_id=1, + resource_owner_user_id=account.resource_owner_user_id, + scopes=frozenset({"knowledge:read"}), + authorization_subject_type="service_account", + authorization_subject_id=account.id, + effective_user_id=None, + ) + + +async def test_hot_cache_refuses_moved_owner_and_recovers_without_revoking(open_api_db, fake_redis): + account = await seed(open_api_db) + key = await CredentialService.issue( + tenant_id=1, + subject_kind="service_account", + subject_id=account.id, + request=KeyIssueRequest(name="f113", scopes=["knowledge:read"]), + created_by=3, + ) + header = f"Bearer {key.plaintext}" + first = await validate_bearer(header) + for tenant in (2, 1): + async with open_api_db() as session: + member = (await session.exec(select(UserTenant).where(UserTenant.user_id == 21))).one() + member.tenant_id = tenant + session.add(member) + await session.commit() + if tenant == 2: + with pytest.raises(ServiceAccountOwnerTenantMismatchError): + await validate_bearer(header) + else: + assert await validate_bearer(header) == first + + +async def test_rebinding_refreshes_owner_without_inheriting_owner_permissions(open_api_db): + account = await seed(open_api_db) + cached = principal(account) + async with open_api_db() as session: + session.add(User(user_id=22, user_name="new-owner", password="")) + session.add(UserTenant(user_id=22, tenant_id=1, is_active=1)) + row = await session.get(ServiceAccount, account.id) + row.resource_owner_user_id = 22 + row.name = "renamed-account" + session.add(row) + await session.commit() + actual = await revalidate_service_account(cached) + assert actual.resource_owner_user_id == 22 + assert actual.actor_name == "renamed-account" + assert actual.authorization_subject_type == "service_account" + assert actual.authorization_subject_id == cached.authorization_subject_id + assert actual.scopes == cached.scopes and actual.effective_user_id is None + assert cached.resource_owner_user_id == 21 + + +async def test_deleted_marker_does_not_add_a_tenant_gate(open_api_db): + account = await seed(open_api_db, deleted=1) + assert await revalidate_service_account(principal(account)) == principal(account) + await require_owner_tenant(21, 1) + + +@pytest.mark.parametrize("membership", [True, False]) +async def test_missing_current_membership_is_not_a_tenant_mismatch(open_api_db, membership): + account = await seed(open_api_db, membership=membership) + if membership: + async with open_api_db() as session: + row = (await session.exec(select(UserTenant))).one() + row.status = "disabled" + session.add(row) + await session.commit() + with pytest.raises(ServiceAccountOwnerTenantUnresolvedError): + await revalidate_service_account(principal(account)) + with pytest.raises(ServiceAccountOwnerTenantUnresolvedError): + await require_owner_tenant(21, 1) + + +async def test_ambiguous_relation_fails_closed_instead_of_picking_first(open_api_db, monkeypatch): + account = await seed(open_api_db) + binding = await OwnerRepository.get_service_account_execution_binding(account.id) + + async def ambiguous(_id): + return replace(binding, owner_tenant_ids=(1, 2)) + + monkeypatch.setattr(OwnerRepository, "get_service_account_execution_binding", ambiguous) + with pytest.raises(ServiceAccountOwnerTenantUnresolvedError): + await revalidate_service_account(principal(account)) + + +async def test_dependency_fault_keeps_its_cause(open_api_db, monkeypatch): + account = await seed(open_api_db) + + async def failed(_id): + raise OSError("F113 relationship query unavailable") + + monkeypatch.setattr(OwnerRepository, "get_service_account_execution_binding", failed) + with pytest.raises(OpenApiAuthDependencyUnavailableError) as error: + await revalidate_service_account(principal(account)) + assert isinstance(error.value.__cause__, OSError) + + +async def test_other_principals_do_not_use_the_service_account_gate(monkeypatch): + async def forbidden(_id): + pytest.fail("Non-service-account credential queried an owner binding") + + monkeypatch.setattr(OwnerRepository, "get_service_account_execution_binding", forbidden) + base = principal(ServiceAccount(id=5, tenant_id=1, name="unused", resource_owner_user_id=21)) + for kind in ("natural_person", "hosted_app"): + other = base.model_copy(update={"actor_kind": kind}) + assert await revalidate_service_account(other) is other + + +async def test_account_disabled_after_cache_hit_still_refuses(open_api_db): + from bisheng.common.errcode.open_api import ServiceAccountInactiveError + + account = await seed(open_api_db) + cached = principal(account) + async with open_api_db() as session: + row = await session.get(ServiceAccount, account.id) + row.disabled_at = datetime.now() + session.add(row) + await session.commit() + with pytest.raises(ServiceAccountInactiveError): + await revalidate_service_account(cached) diff --git a/src/backend/test/open_api/test_task_mode_personal_skills.py b/src/backend/test/open_api/test_task_mode_personal_skills.py new file mode 100644 index 0000000000..c25a82bfa1 --- /dev/null +++ b/src/backend/test/open_api/test_task_mode_personal_skills.py @@ -0,0 +1,151 @@ +"""Task mode offers the caller's candidates. Daily chat is unchanged.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from contextlib import asynccontextmanager + +import pytest + +import bisheng.linsight.domain.models.linsight_skill as model_module +from bisheng.common.errcode.open_api import OpenApiTaskSkillUnavailableError +from bisheng.core.context.tenant import bypass_tenant_filter, set_current_tenant_id +from bisheng.linsight.domain.models.linsight_skill import LinsightSkill, LinsightSkillDao +from bisheng.open_api.domain.schemas.task_mode import OpenTaskSubmitReq +from bisheng.open_api.domain.schemas.workstation import OpenDailyChatCompletionReq +from bisheng.open_api.domain.services.task_mode_service import OpenTaskModeService + +TENANT = 5 +USER = 11 +OTHER = 22 + + +def _row(name: str, owner: int, *, enabled: bool = True, display: str = "") -> LinsightSkill: + return LinsightSkill( + tenant_id=TENANT, + owner_user_id=owner, + name=name, + display_name=display or name, + description="用途", + enabled=enabled, + source="manual", + object_path=f"linsight/skills/{TENANT}/{owner}/{name}/hash.zip", + content_hash="a" * 64, + size=10, + created_by=owner or 1, + ) + + +@pytest.fixture +async def skills(monkeypatch): + from sqlalchemy.ext.asyncio import create_async_engine + from sqlalchemy.pool import StaticPool + from sqlmodel.ext.asyncio.session import AsyncSession + + from bisheng.core.database import tenant_filter + + engine = create_async_engine( + "sqlite+aiosqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + async with engine.begin() as conn: + await conn.run_sync(LinsightSkill.__table__.create) + + @asynccontextmanager + async def _session(): + session = AsyncSession(bind=engine, expire_on_commit=False) + try: + yield session + except Exception: + await session.rollback() + raise + finally: + await session.close() + + monkeypatch.setattr(model_module, "get_async_db_session", _session) + tenant_filter._initialized = False + tenant_filter._tenant_aware_tables = set() + tenant_filter.register_tenant_filter_events() + set_current_tenant_id(TENANT) + monkeypatch.setattr( + "bisheng.linsight.domain.services.user_skills.SkillAvailabilityRepository.disabled_ids", + AsyncMock(return_value=set()), + ) + + async def permitted(_user, *, resource_ids, **kwargs): + return {str(skill_id): {"use"} for skill_id in resource_ids} + + monkeypatch.setattr("bisheng.permission.application.business_authorization.batch_check_business_actions", permitted) + with bypass_tenant_filter(): + await LinsightSkillDao.create(_row("pdf", 0, display="企业报告")) + await LinsightSkillDao.create(_row("he-tong", 0, display="合同审阅")) + await LinsightSkillDao.create(_row("pdf", USER, display="个人报告")) + await LinsightSkillDao.create(_row("ting", USER, enabled=False, display="停用技能")) + await LinsightSkillDao.create(_row("secret", OTHER, display="别人的技能")) + yield + tenant_filter._initialized = False + tenant_filter._tenant_aware_tables = set() + await engine.dispose() + + +class TestTaskModePersonalSkills: + async def test_the_caller_sees_only_their_enabled_candidates(self, skills): + rows = await OpenTaskModeService.enabled_skill_rows(SimpleNamespace(user_id=USER)) + by_name = {row.name: row for row in rows} + assert set(by_name) == {"pdf", "he-tong"} + assert by_name["pdf"].owner_user_id == USER + assert by_name["pdf"].display_name == "个人报告" + + await OpenTaskModeService.check_skills(["pdf", "he-tong"], SimpleNamespace(user_id=USER)) + with pytest.raises(OpenApiTaskSkillUnavailableError) as caught: + await OpenTaskModeService.check_skills(["secret", "ting"], SimpleNamespace(user_id=USER)) + assert caught.value.kwargs["unavailable"] == ["secret", "ting"] + text = str(caught.value.to_dict()) + assert "别人的技能" not in text + assert "停用技能" not in text + + async def test_disabling_the_personal_copy_restores_the_tenant_skill(self, skills): + personal = await LinsightSkillDao.get_by_name("pdf", owner_user_id=USER) + await LinsightSkillDao.set_owner_enabled(personal.id, owner_user_id=USER, enabled=False) + rows = await OpenTaskModeService.enabled_skill_rows(SimpleNamespace(user_id=USER)) + pdf = next(row for row in rows if row.name == "pdf") + assert pdf.owner_user_id == 0 + await OpenTaskModeService.check_skills(["pdf"], SimpleNamespace(user_id=USER)) + + async def test_request_fields_stay_skill_names_and_daily_mode_is_unchanged(self): + assert "skill_ids" not in OpenTaskSubmitReq.model_fields + body = OpenTaskSubmitReq.model_validate( + { + "run_mode": "task", + "execution": "async", + "clientTimestamp": "t", + "text": "go", + "model": "7", + "skills": ["pdf"], + } + ) + assert body.to_internal().skills == ["pdf"] + daily = OpenDailyChatCompletionReq.model_validate( + {"clientTimestamp": "t", "model": "7", "text": "hi", "skills": ["pdf"]} + ) + internal = daily.to_internal() + assert internal.task_mode is False + assert internal.skills == ["pdf"] + + +async def test_open_api_honors_per_account_availability(skills, monkeypatch): + personal = await LinsightSkillDao.get_by_name("pdf", owner_user_id=USER) + monkeypatch.setattr( + "bisheng.linsight.domain.services.user_skills.SkillAvailabilityRepository.disabled_ids", + AsyncMock(return_value={personal.id}), + ) + rows = await OpenTaskModeService.enabled_skill_rows(SimpleNamespace(user_id=USER)) + assert personal.id not in {row.id for row in rows} + + +async def test_open_api_filters_revoked_personal_permission(skills, monkeypatch): + monkeypatch.setattr( + "bisheng.permission.application.business_authorization.batch_check_business_actions", AsyncMock(return_value={}) + ) + assert await OpenTaskModeService.enabled_skill_rows(SimpleNamespace(user_id=USER)) == [] diff --git a/src/backend/test/open_api/test_task_mode_schema.py b/src/backend/test/open_api/test_task_mode_schema.py index 37d6854940..2929f38542 100644 --- a/src/backend/test/open_api/test_task_mode_schema.py +++ b/src/backend/test/open_api/test_task_mode_schema.py @@ -51,6 +51,13 @@ def test_text_must_be_present_and_bounded(text): OpenTaskSubmitReq.model_validate(_body(text=text)) +def test_text_accepts_a_long_prompt_up_to_the_limit(): + # A built-in prompt template plus pasted material easily exceeds 20000 characters. + assert TASK_TEXT_MAX_LENGTH >= 100000 + req = OpenTaskSubmitReq.model_validate(_body(text="x" * TASK_TEXT_MAX_LENGTH)) + assert len(req.text) == TASK_TEXT_MAX_LENGTH + + def test_execution_must_be_async(): with pytest.raises(ValidationError): OpenTaskSubmitReq.model_validate(_body(execution="sync")) diff --git a/src/backend/test/open_api/test_websocket_tenant_revalidation.py b/src/backend/test/open_api/test_websocket_tenant_revalidation.py new file mode 100644 index 0000000000..f8bdaffb10 --- /dev/null +++ b/src/backend/test/open_api/test_websocket_tenant_revalidation.py @@ -0,0 +1,140 @@ +"""F113: each new message has independent admission and a frozen worker identity.""" + +from contextlib import asynccontextmanager +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from bisheng.common.chat.client import ChatClient, ChatMessageExecution +from bisheng.common.chat.manager import ChatManager +from bisheng.common.chat.types import WorkType +from bisheng.common.errcode.open_api import ServiceAccountOwnerTenantMismatchError + + +@pytest.mark.parametrize("denied", [False, True]) +async def test_message_admission_precedes_permissions_and_dispatch(denied): + manager = ChatManager() + manager._has_use_action = AsyncMock(return_value=True) + client = SimpleNamespace(handle_message=AsyncMock()) + old = SimpleNamespace(user_id=21) + current = SimpleNamespace(user_id=22) + execution = ChatMessageExecution(operator=current, execution_snapshot={"tenant_id": 1}) + + @asynccontextmanager + async def admission(): + if denied: + raise ServiceAccountOwnerTenantMismatchError() + yield execution + + call = manager._dispatch_message( + client, + {"message": "next"}, + login_user=old, + work_type=WorkType.GPTS, + client_id="app", + message_admission=admission, + ) + if denied: + with pytest.raises(ServiceAccountOwnerTenantMismatchError): + await call + manager._has_use_action.assert_not_awaited() + client.handle_message.assert_not_awaited() + else: + assert await call is True + manager._has_use_action.assert_awaited_once_with(current, WorkType.GPTS, "app") + client.handle_message.assert_awaited_once_with({"message": "next"}, execution_context=execution) + + +async def test_existing_web_path_does_not_gain_a_new_admission_contract(): + manager = ChatManager() + manager._has_use_action = AsyncMock(return_value=True) + client = SimpleNamespace(handle_message=AsyncMock()) + original = SimpleNamespace(user_id=21) + assert await manager._dispatch_message(client, {}, login_user=original, work_type=WorkType.GPTS, client_id="app") + client.handle_message.assert_awaited_once_with({}) + + +async def test_queued_messages_do_not_overwrite_each_others_identity(monkeypatch): + from bisheng.common.chat import client as module + + queued = [] + async def capture_worker(worker, fn, *args, **kwargs): + queued.append(worker) + + monkeypatch.setattr(ChatClient, "wrapper_task", capture_worker) + original = ChatClient.__new__(ChatClient) + original.login_user = SimpleNamespace(user_id=21) + original.user_id = 21 + original.client_key = "f113" + original.work_type = WorkType.GPTS + original.kwargs = {} + original.message_tasks = set() + original.finalizing_tasks = set() + original.gpts_agent = object() + original.gpts_async_callback = object() + original.db_assistant = object() + for owner in (22, 23): + context = ChatMessageExecution( + operator=SimpleNamespace(user_id=owner), + execution_snapshot={"tenant_id": 1, "resource_owner_user_id": owner}, + ) + await original.handle_message({"owner": owner}, execution_context=context) + with pytest.raises(TypeError): + context.execution_snapshot["tenant_id"] = 9 + workers = queued + assert [worker.user_id for worker in workers] == [22, 23] + assert [worker.login_user.user_id for worker in workers] == [22, 23] + assert original.user_id == 21 and original.login_user.user_id == 21 + assert all(worker.gpts_agent is None and worker.gpts_async_callback is None for worker in workers) + assert [worker.kwargs["execution_snapshot"]["resource_owner_user_id"] for worker in workers] == [22, 23] + + +async def test_v2_endpoint_supplies_fresh_admission_without_changing_session_partition(monkeypatch): + from bisheng.open_api.domain.context import ( + get_current_open_api_principal, + reset_current_open_api_principal, + set_current_open_api_principal, + ) + from bisheng.open_endpoints.api.endpoints import assistant as endpoint + from test.open_api.test_websocket_auth import _principal, _websocket + + original = _principal() + updated = original.model_copy(update={"resource_owner_user_id": 8}) + seen = [] + + async def validate(_header): + return updated + + @asynccontextmanager + async def watcher(_socket): + yield + + async def dispatch(*args, **kwargs): + initial_subject = kwargs["session_subject"] + async with kwargs["message_admission"]() as execution: + assert execution.operator.user_id == 8 + assert execution.session_subject.subject_id == initial_subject.subject_id == original.actor_id + assert execution.session_subject.compatibility_user_id == 8 + assert initial_subject.compatibility_user_id == 7 + assert execution.execution_snapshot["resource_owner_user_id"] == 8 + seen.append(execution) + assert get_current_open_api_principal() is original + + monkeypatch.setattr(endpoint, "validate_bearer", validate) + monkeypatch.setattr(endpoint, "watch_websocket_credential", watcher) + monkeypatch.setattr(endpoint.PublishedAssistantService, "validate_websocket_session", AsyncMock()) + monkeypatch.setattr(endpoint.chat_manager, "dispatch_client", dispatch) + monkeypatch.setattr( + endpoint, + "get_open_api_operator", + lambda: SimpleNamespace( + user_id=get_current_open_api_principal().resource_owner_user_id, + ), + ) + token = set_current_open_api_principal(original) + try: + await endpoint.chat(websocket=_websocket([(b"authorization", b"Bearer f113")]), assistant_id="f113") + finally: + reset_current_open_api_principal(token) + assert len(seen) == 1 diff --git a/src/backend/test/open_mcp/__init__.py b/src/backend/test/open_mcp/__init__.py new file mode 100644 index 0000000000..b2a5024487 --- /dev/null +++ b/src/backend/test/open_mcp/__init__.py @@ -0,0 +1 @@ +"""Tests for the inbound Open MCP server.""" diff --git a/src/backend/test/open_mcp/test_access_context.py b/src/backend/test/open_mcp/test_access_context.py new file mode 100644 index 0000000000..2be8f5afa1 --- /dev/null +++ b/src/backend/test/open_mcp/test_access_context.py @@ -0,0 +1,128 @@ +from types import SimpleNamespace + +import pytest + +from bisheng.common.errcode.open_api import OpenApiScopeMissingError, PersonalTokenDisabledError +from bisheng.core.context.tenant import current_tenant_id, visible_tenant_ids +from bisheng.open_api.domain.context import OpenApiPrincipal, get_current_open_api_principal +from bisheng.open_api.domain.scopes import OpenApiScopeMarker +from bisheng.open_api.domain.services.access_context import ( + OPEN_API_PRINCIPAL_SCOPE_KEY, + open_api_access_context, +) +from bisheng.permission.application.identity import get_current_permission_actor + + +def _principal(*, actor_kind="service_account", scopes=frozenset({"knowledge:read"})): + is_person = actor_kind == "natural_person" + return OpenApiPrincipal( + credential_id=67, + actor_kind=actor_kind, + actor_id=23, + actor_name="f067-caller", + tenant_id=9, + resource_owner_user_id=23, + scopes=scopes, + authorization_subject_type="user" if is_person else "service_account", + authorization_subject_id=23, + effective_user_id=23 if is_person else None, + ) + + +@pytest.mark.asyncio +async def test_access_context_installs_and_resets_shared_identity_context(monkeypatch): + principal = _principal() + scope = {} + + async def _validate(value): + assert value == "Bearer f067" + return principal + + async def _resolve(value, **kwargs): + assert value == principal + return value + + monkeypatch.setattr( + "bisheng.open_api.domain.services.access_context.resolve_request_identity", + _resolve, + ) + marker = OpenApiScopeMarker(scope="knowledge:read", modes=frozenset({"S"}), session=False) + + async with open_api_access_context( + authorization="Bearer f067", + headers=[], + marker=marker, + on_behalf_of=None, + end_user=None, + connection_scope=scope, + credential_validator=_validate, + ) as active: + assert active == principal + assert get_current_open_api_principal() == principal + assert current_tenant_id.get() == 9 + assert visible_tenant_ids.get() == frozenset({1, 9}) + actor = get_current_permission_actor() + assert actor.subject_type == "service_account" + assert actor.subject_id == 23 + assert actor.tenant_id == 9 + assert scope[OPEN_API_PRINCIPAL_SCOPE_KEY] == principal + + assert get_current_open_api_principal() is None + assert get_current_permission_actor() is None + assert current_tenant_id.get() is None + assert visible_tenant_ids.get() is None + + +@pytest.mark.asyncio +async def test_access_context_checks_scope_before_business_execution(monkeypatch): + principal = _principal(scopes=frozenset()) + + async def _validate(value): + return principal + + marker = OpenApiScopeMarker(scope="knowledge:read", modes=frozenset({"S"}), session=False) + with pytest.raises(OpenApiScopeMissingError): + async with open_api_access_context( + authorization="Bearer f067", + headers=[], + marker=marker, + on_behalf_of=None, + end_user=None, + credential_validator=_validate, + ): + pass + + assert current_tenant_id.get() is None + assert visible_tenant_ids.get() is None + + +@pytest.mark.asyncio +async def test_access_context_enforces_pat_tenant_policy_and_cleans_up(): + principal = _principal(actor_kind="natural_person") + + async def _validate(value): + return principal + + class _PolicyService: + @staticmethod + async def get_policy(tenant_id): + return SimpleNamespace(enabled=True, data_scope="personal_only") + + marker = OpenApiScopeMarker(scope="knowledge:read", modes=frozenset({"S"}), session=False) + disabled_settings = SimpleNamespace(open_api=SimpleNamespace(pat_enabled=False)) + + with pytest.raises(PersonalTokenDisabledError): + async with open_api_access_context( + authorization="Bearer pat", + headers=[], + marker=marker, + on_behalf_of=None, + end_user=None, + credential_validator=_validate, + settings_obj=disabled_settings, + tenant_setting_service=_PolicyService, + ): + pass + + assert get_current_open_api_principal() is None + assert current_tenant_id.get() is None diff --git a/src/backend/test/open_mcp/test_api_freeze_contract.py b/src/backend/test/open_mcp/test_api_freeze_contract.py new file mode 100644 index 0000000000..287da16384 --- /dev/null +++ b/src/backend/test/open_mcp/test_api_freeze_contract.py @@ -0,0 +1,107 @@ +import inspect + +from bisheng.open_api.domain.scopes import OPEN_API_SCOPE_MAP +from bisheng.open_mcp.registry import SOURCE_ALLOWLIST + +EXPECTED_API_ROUTES = { + "create": ("POST", "/filelib/", 200), + "update_knowledge": ("PUT", "/filelib/", 200), + "get_knowledge": ("GET", "/filelib/", 200), + "delete_knowledge_api": ("DELETE", "/filelib/{knowledge_id}", 200), + "clear_knowledge_files": ("DELETE", "/filelib/clear/{knowledge_id}", 200), + "retrieve_chunks": ("POST", "/filelib/retrieve", None), + "upload_file": ("POST", "/filelib/file/{knowledge_id}", None), + "get_filelist": ("GET", "/filelib/file/list", 200), + "delete_knowledge_file": ("DELETE", "/filelib/file/{file_id}", 200), + "delete_file_batch_api": ("POST", "/filelib/delete_file", 200), +} + +EXPECTED_PARAMETERS = { + "create": ("request", "knowledge", "version_repo", "doc_repo"), + "update_knowledge": ("request", "knowledge", "version_repo", "doc_repo"), + "get_knowledge": ( + "request", + "knowledge_type", + "name", + "sort_by", + "page_size", + "cursor", + "version_repo", + "doc_repo", + ), + "delete_knowledge_api": ("request", "knowledge_id", "version_repo", "doc_repo"), + "clear_knowledge_files": ("request", "knowledge_id", "version_repo", "doc_repo"), + "retrieve_chunks": ("request", "req", "version_repo"), + "upload_file": ( + "request", + "knowledge_id", + "split_mode", + "separator", + "separator_rule", + "chunk_size", + "chunk_overlap", + "hierarchy_level", + "append_title", + "max_chunk_size", + "callback_url", + "file_url", + "file", + "background_tasks", + "retain_images", + "force_ocr", + "enable_formula", + "filter_page_header_footer", + "excel_rule", + "parent_id", + "version_repo", + "doc_repo", + ), + "get_filelist": ( + "request", + "knowledge_id", + "parent_id", + "keyword", + "status", + "page_size", + "cursor", + "version_repo", + "doc_repo", + "knowledge_file_repo", + ), + "delete_knowledge_file": ("request", "file_id"), + "delete_file_batch_api": ("request", "file_ids"), +} + + +def test_source_allowlist_remains_a_subset_of_existing_open_api_routes(): + registered = { + endpoint for code in ("knowledge:read", "knowledge:write") for endpoint in OPEN_API_SCOPE_MAP[code].endpoints + } + assert SOURCE_ALLOWLIST <= registered + + +def test_source_api_batch_delete_and_multipart_contract_remain_frozen(): + from bisheng.open_endpoints.api.endpoints.filelib import delete_file_batch_api, upload_file + + batch_annotation = delete_file_batch_api.__annotations__["file_ids"] + assert batch_annotation == list[int] + assert "file" in upload_file.__annotations__ + assert "file_url" in upload_file.__annotations__ + + +def test_all_ten_source_routes_and_http_statuses_remain_frozen(): + from bisheng.open_endpoints.api.endpoints.filelib import router + + actual = {} + for route in router.routes: + name = getattr(route.endpoint, "__name__", "") + if name in EXPECTED_API_ROUTES: + actual[name] = (next(iter(route.methods)), route.path, route.status_code) + assert actual == EXPECTED_API_ROUTES + + +def test_all_ten_source_endpoint_parameter_lists_remain_frozen(): + from bisheng.open_endpoints.api.endpoints import filelib + + actual = {name: tuple(inspect.signature(getattr(filelib, name)).parameters) for name in EXPECTED_PARAMETERS} + assert actual == EXPECTED_PARAMETERS diff --git a/src/backend/test/open_mcp/test_app_tools.py b/src/backend/test/open_mcp/test_app_tools.py new file mode 100644 index 0000000000..7e02c7172a --- /dev/null +++ b/src/backend/test/open_mcp/test_app_tools.py @@ -0,0 +1,331 @@ +"""The hosted-application MCP tools (F052 T208a / T209, served by F067 ``open_mcp``). + +The interesting property is negative: absent, another tenant's and somebody +else's must be **one** response carrying nothing but the ``app_id``. Anything +that distinguishes them — a different code, a different reason, the owner's +name — turns a developer key into a way to enumerate the tenant's applications. + +The owner rule itself is not implemented in these tools. Status and logs pass +``entry="mcp"`` into the same services the CLI door uses; the data tools pass +nothing, because ``AppDataService`` builds owner-only in unconditionally. Two +places deciding "may this caller see it" is two places to drift. +""" + +from __future__ import annotations + +import json + +import pytest + +from bisheng.common.errcode.app_factory import ( + AppDataForbiddenError, + AppDataNotReadyError, + AppLogForbiddenError, + AppNotFoundError, +) +from bisheng.common.errcode.app_publish import ( + AppNotOwnedBySubjectError, + AppPublishOwnerOnlyError, + AppPublishRuntimeLayerDisabledError, +) +from bisheng.common.errcode.mcp_face import McpAppNotOwnedError +from bisheng.open_api.domain.context import OpenApiPrincipal +from bisheng.open_mcp import registry +from bisheng.open_mcp.result import error_result_from_exception +from bisheng.open_mcp.tools import execute_tool + +APP_ID = "app-owned-by-the-key" +APP_TOOLS = { + "bisheng_app_status", + "bisheng_app_logs", + "bisheng_app_db_tables", + "bisheng_app_db_schema", + "bisheng_app_db_rows", + "bisheng_app_db_row_update", +} + + +def principal(*, scopes=frozenset({"app:manage"}), owner=12, actor_kind="service_account") -> OpenApiPrincipal: + return OpenApiPrincipal( + credential_id=7, + actor_kind=actor_kind, + actor_id=31, + actor_name="indexer", + tenant_id=9, + resource_owner_user_id=owner, + scopes=frozenset(scopes), + authorization_subject_type="service_account" if actor_kind == "service_account" else "user", + authorization_subject_id=31 if actor_kind == "service_account" else 12, + effective_user_id=None if actor_kind == "service_account" else 12, + ) + + +@pytest.fixture +def as_principal(monkeypatch): + """Answer ``get_current_open_api_principal`` with the principal you hand it. + + Patched rather than set on the ContextVar: an async test runs in its own + context, so a token from a sync fixture cannot be reset there. + """ + + def install(value: OpenApiPrincipal) -> None: + monkeypatch.setattr("bisheng.open_mcp.tools.apps.get_current_open_api_principal", lambda: value) + + return install + + +@pytest.fixture(autouse=True) +def runtime_on(monkeypatch): + monkeypatch.setattr(registry.settings.app_runtime, "enabled", True) + + +@pytest.fixture +def services(monkeypatch): + """Programmable stand-ins for the three services the tools call.""" + + from bisheng.app_publish.domain.services.publish_status_service import PublishStatusService + from bisheng.app_runtime.domain.services.app_data_service import AppDataService + from bisheng.app_runtime.domain.services.app_query_service import AppQueryService + + calls: list[tuple[str, dict]] = [] + responses: dict[str, object] = { + "get_instance": {"instance_id": "inst-1", "phase": "running", "health": "healthy"}, + "get_publish_status": {"app_id": APP_ID, "app_state": "online", "approval": {"reject_reason": "缺少健康检查"}}, + "get_logs": {"lines": ["boot ok"]}, + "runtime_hint": {"app_state": "online", "pending_reason": None}, + "list_tables": {"tables": [{"name": "users", "column_count": 2}]}, + "get_table_schema": {"table": "users", "columns": [], "key": {"column": "id"}, "editable": True}, + "get_rows": {"rows": [], "total": 0}, + "update_row": {"updated": 1}, + } + + def stub(name, owner=PublishStatusService): + async def call(*args, **kwargs): + calls.append((name, {"args": args, "kwargs": kwargs})) + value = responses[name] + if isinstance(value, Exception): + raise value + return value + + return call + + monkeypatch.setattr(AppQueryService, "get_instance", stub("get_instance")) + monkeypatch.setattr(AppQueryService, "get_logs", stub("get_logs")) + monkeypatch.setattr(PublishStatusService, "get_publish_status", stub("get_publish_status")) + monkeypatch.setattr(PublishStatusService, "runtime_hint", stub("runtime_hint")) + for name in ("list_tables", "get_table_schema", "get_rows", "update_row"): + monkeypatch.setattr(AppDataService, name, stub(name)) + + return type("Services", (), {"calls": calls, "responses": responses})() + + +# --- status and logs ---------------------------------------------------------- + + +async def test_status_returns_the_runtime_view_and_the_full_rejection_reason(as_principal, services): + as_principal(principal()) + result = await execute_tool("bisheng_app_status", {"app_id": APP_ID}) + + assert result.app_id == APP_ID + assert result.instance["phase"] == "running" + assert result.publish["approval"]["reject_reason"] == "缺少健康检查" + + +async def test_status_and_logs_go_through_the_credential_door(as_principal, services): + """``entry="mcp"``, not a second owner comparison in the tool.""" + + as_principal(principal()) + await execute_tool("bisheng_app_status", {"app_id": APP_ID}) + await execute_tool("bisheng_app_logs", {"app_id": APP_ID}) + + entries = {name: call["kwargs"].get("entry") for name, call in services.calls} + assert entries["get_instance"] == "mcp" + assert entries["get_publish_status"] == "mcp" + assert entries["get_logs"] == "mcp" + + +async def test_the_actor_is_the_keys_resource_owner_and_carries_no_privilege(as_principal, services): + as_principal(principal()) + await execute_tool("bisheng_app_status", {"app_id": APP_ID}) + + actor = dict(services.calls)["get_instance"]["kwargs"]["actor"] + assert actor.user_id == 12 # resource_owner_user_id, not the service account id + assert actor.user_role == [] + assert actor.is_global_super is False + + +async def test_logs_carry_the_two_fields_that_explain_an_empty_answer(as_principal, services): + """ "No lines" is a quiet app or a stopped one; the text alone cannot say which.""" + + as_principal(principal()) + services.responses["get_logs"] = {"lines": []} + services.responses["runtime_hint"] = {"app_state": "pending_capacity", "pending_reason": "capacity"} + result = await execute_tool("bisheng_app_logs", {"app_id": APP_ID}) + + assert result.model_dump() == {"lines": [], "app_state": "pending_capacity", "pending_reason": "capacity"} + + +@pytest.mark.parametrize( + "failure", + [ + AppNotFoundError(app_id=APP_ID), + AppLogForbiddenError(app_id=APP_ID, entry="mcp"), + AppPublishOwnerOnlyError(msg="x", details={"app_id": APP_ID, "reason": "not_visible"}), + AppNotOwnedBySubjectError(msg="x", details={"reason": "resource_owner_missing"}), + ], +) +async def test_every_not_yours_reason_gives_one_identical_answer(as_principal, services, failure): + """Absent, another tenant's, somebody else's, ownerless key — one payload.""" + + as_principal(principal()) + services.responses["get_instance"] = failure + with pytest.raises(McpAppNotOwnedError) as refused: + await execute_tool("bisheng_app_status", {"app_id": APP_ID}) + + assert refused.value.code == 26305 + assert refused.value.kwargs == {"app_id": APP_ID} + + +async def test_the_refusal_never_names_an_owner(as_principal, services): + as_principal(principal()) + services.responses["get_instance"] = AppPublishOwnerOnlyError( + msg="没有查看该应用发布状态的权限", + details={"app_id": APP_ID, "owner_user_name": "someone-else", "reason": "not_visible"}, + ) + with pytest.raises(McpAppNotOwnedError) as refused: + await execute_tool("bisheng_app_status", {"app_id": APP_ID}) + + rendered = error_result_from_exception(refused.value).content[0].text + assert "someone-else" not in rendered + assert "owner_user_name" not in rendered + + +# --- application data --------------------------------------------------------- + + +async def test_the_data_tools_pass_straight_through_to_the_data_service(as_principal, services): + as_principal(principal()) + tables = await execute_tool("bisheng_app_db_tables", {"app_id": APP_ID}) + schema = await execute_tool("bisheng_app_db_schema", {"app_id": APP_ID, "table": "users"}) + rows = await execute_tool("bisheng_app_db_rows", {"app_id": APP_ID, "table": "users"}) + updated = await execute_tool( + "bisheng_app_db_row_update", + {"app_id": APP_ID, "table": "users", "key": "7", "values": {"name": "n"}}, + ) + + assert tables.result == services.responses["list_tables"] + assert schema.result == services.responses["get_table_schema"] + assert rows.result == services.responses["get_rows"] + assert updated.result == services.responses["update_row"] + + +async def test_the_data_tools_add_no_second_owner_check_and_no_entry(as_principal, services): + """``AppDataService`` builds owner-only in; a tool-level copy would drift.""" + + as_principal(principal()) + await execute_tool("bisheng_app_db_tables", {"app_id": APP_ID}) + + kwargs = dict(services.calls)["list_tables"]["kwargs"] + assert "entry" not in kwargs + assert kwargs["actor"].user_id == 12 + + +async def test_an_application_with_no_database_yet_is_not_called_somebody_elses(as_principal, services): + """16163 is a real state of an application that *is* yours. + + Folding it into 26305 would tell a developer their own app is not theirs and + send them to an administrator for nothing. + """ + + as_principal(principal()) + services.responses["list_tables"] = AppDataNotReadyError(app_id=APP_ID) + with pytest.raises(AppDataNotReadyError) as refused: + await execute_tool("bisheng_app_db_tables", {"app_id": APP_ID}) + assert refused.value.code == 16163 + + +async def test_a_data_permission_refusal_is_the_same_not_yours_answer(as_principal, services): + as_principal(principal()) + services.responses["list_tables"] = AppDataForbiddenError(app_id=APP_ID) + with pytest.raises(McpAppNotOwnedError) as refused: + await execute_tool("bisheng_app_db_tables", {"app_id": APP_ID}) + assert refused.value.kwargs == {"app_id": APP_ID} + + +async def test_a_key_with_no_resource_owner_gets_the_same_one_answer(as_principal, services): + """``resource_owner_of`` raises 16205 before any service is reached. + + That is still "no application you can reach": folding it keeps the payload a + bare ``app_id`` instead of leaking the error's own ``details`` / ``hints``. + """ + + as_principal(principal(owner=None)) + with pytest.raises(McpAppNotOwnedError) as status: + await execute_tool("bisheng_app_status", {"app_id": APP_ID}) + with pytest.raises(McpAppNotOwnedError) as logs: + await execute_tool("bisheng_app_logs", {"app_id": APP_ID}) + + assert status.value.to_dict() == logs.value.to_dict() + assert "resource_owner_missing" not in json.dumps(status.value.to_dict(), ensure_ascii=False) + + +# --- registry: who may see these tools --------------------------------------- + + +def test_the_registry_offers_no_ddl_tool(): + """Schema is declared in ``bisheng-app.yaml`` and changed through the pipeline.""" + + names = set(registry.TOOL_REGISTRY) + assert not any(verb in name for name in names for verb in ("create_table", "alter", "drop", "truncate")) + + +def test_the_tools_are_listed_for_a_key_with_the_scope(): + listed = {tool.name for tool in registry.list_tools_for(principal())} + assert APP_TOOLS <= listed + + +def test_a_delegated_key_never_sees_the_local_development_tools(): + """INV-31: these tools run as the service account itself, never on behalf of anyone.""" + + delegated = principal(scopes=frozenset({"app:manage", "identity:read", "delegate"})) + listed = {tool.name for tool in registry.list_tools_for(delegated)} + + assert not (APP_TOOLS & listed) + assert "bisheng_org_tree" not in listed + assert not registry.TOOL_REGISTRY["bisheng_app_status"].visible_to(delegated) + + +def test_personal_tokens_never_see_the_app_tools(): + pat = principal(actor_kind="natural_person") + assert not (APP_TOOLS & {tool.name for tool in registry.list_tools_for(pat)}) + + +def test_without_the_runtime_layer_the_tools_are_not_listed(monkeypatch): + monkeypatch.setattr(registry.settings.app_runtime, "enabled", False) + assert not (APP_TOOLS & {tool.name for tool in registry.list_tools_for(principal())}) + + +async def test_without_the_runtime_layer_a_direct_call_is_refused(monkeypatch, as_principal, services): + monkeypatch.setattr(registry.settings.app_runtime, "enabled", False) + as_principal(principal()) + with pytest.raises(AppPublishRuntimeLayerDisabledError): + await execute_tool("bisheng_app_status", {"app_id": APP_ID}) + assert services.calls == [] + + +def test_the_tools_never_reach_the_orchestrator_directly(): + """The data plane goes backend service → orchestrator client → manager RPC.""" + + import pathlib + + source = (pathlib.Path(__file__).resolve().parents[2] / "bisheng" / "open_mcp" / "tools" / "apps.py").read_text( + encoding="utf-8" + ) + assert "orchestrator_client" not in source + assert "runtime-manager" not in source + + +def test_personal_tokens_cannot_hold_the_scope_these_tools_need(): + from bisheng.open_api.domain.services.personal_token_service import PERSONAL_TOKEN_SCOPE + + assert PERSONAL_TOKEN_SCOPE == "knowledge:read" diff --git a/src/backend/test/open_mcp/test_application.py b/src/backend/test/open_mcp/test_application.py new file mode 100644 index 0000000000..062b73251a --- /dev/null +++ b/src/backend/test/open_mcp/test_application.py @@ -0,0 +1,179 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest +from fastapi import HTTPException + +from bisheng.common.errcode.http_error import UnAuthorizedError +from bisheng.knowledge.domain.models.knowledge import AuthTypeEnum +from bisheng.open_mcp import application +from bisheng.open_mcp.contracts import ( + KnowledgeCreateInput, + KnowledgeFileUploadInput, + KnowledgeListInput, +) + + +@pytest.mark.asyncio +async def test_list_knowledge_dispatches_kb_without_touching_http_endpoint(monkeypatch): + user = SimpleNamespace(user_id=7, user_name="f067") + service = AsyncMock(return_value={"data": []}) + monkeypatch.setattr(application, "get_open_api_operator_async", AsyncMock(return_value=user)) + monkeypatch.setattr(application.KnowledgeService, "get_knowledge", service) + + command = KnowledgeListInput(type=0, page_size=5) + result = await application.list_knowledge( + request=application.current_request(), + command=command, + version_repo=object(), + doc_repo=object(), + ) + + assert result == {"data": []} + assert service.await_args.args[2].value == 0 + assert service.await_args.kwargs["page_size"] == 5 + + +@pytest.mark.asyncio +async def test_list_knowledge_dispatches_space_to_existing_space_service(monkeypatch): + user = SimpleNamespace(user_id=7, user_name="f067") + list_spaces = AsyncMock(return_value={"data": []}) + space_service = SimpleNamespace(alist_mine_and_joined_cursor=list_spaces) + monkeypatch.setattr(application, "get_open_api_operator_async", AsyncMock(return_value=user)) + monkeypatch.setattr(application, "_space_service", lambda *args: space_service) + + result = await application.list_knowledge( + request=application.current_request(), + command=KnowledgeListInput(type=3, name="space"), + version_repo=object(), + doc_repo=object(), + ) + + assert result == {"data": []} + assert list_spaces.await_args.kwargs["name"] == "space" + + +@pytest.mark.asyncio +async def test_create_kb_preserves_frozen_public_unreleased_semantics(monkeypatch): + user = SimpleNamespace(user_id=7, user_name="f067") + created = SimpleNamespace(id=67) + create = AsyncMock(return_value=created) + convert = AsyncMock(return_value=[{"id": 67}]) + monkeypatch.setattr(application, "get_open_api_operator_async", AsyncMock(return_value=user)) + monkeypatch.setattr(application.KnowledgeService, "acreate_knowledge", create) + monkeypatch.setattr(application.KnowledgeService, "aconvert_knowledge_read", convert) + + result = await application.create_knowledge( + request=application.current_request(), + command=KnowledgeCreateInput( + name="f067", + type=0, + model="embedding", + auth_type="private", + is_released=True, + ), + version_repo=object(), + doc_repo=object(), + ) + + command = create.await_args.args[2] + assert command.auth_type == AuthTypeEnum.PUBLIC + assert command.is_released is False + assert result == {"id": 67} + + +@pytest.mark.asyncio +async def test_upload_kb_uses_managed_temporary_path_and_disables_callback(monkeypatch, tmp_path): + source = tmp_path / "source.txt" + source.write_bytes(b"f067") + user = SimpleNamespace(user_id=7, user_name="f067") + monkeypatch.setattr(application, "get_open_api_operator_async", AsyncMock(return_value=user)) + monkeypatch.setattr( + application.KnowledgeDao, + "aquery_by_id", + AsyncMock(return_value=SimpleNamespace(type=0)), + ) + monkeypatch.setattr( + application.QuotaService, + "get_knowledge_space_upload_limit_bytes", + AsyncMock(return_value=1024), + ) + process = AsyncMock(return_value=[{"id": 7, "knowledge_id": 67, "file_name": "f067.txt", "file_type": 1}]) + monkeypatch.setattr(application.KnowledgeService, "aprocess_knowledge_file", process) + + result = await application.upload_knowledge_file( + request=application.current_request(), + command=KnowledgeFileUploadInput( + knowledge_id=67, + file_name="f067.txt", + content_base64="ZjA2Nw==", + ), + path=str(source), + file_name="f067.txt", + version_repo=object(), + doc_repo=object(), + ) + + process_command = process.await_args.kwargs["req_data"] + assert process_command.callback_url is None + assert process_command.file_list[0].file_path == str(source) + assert process.await_args.kwargs["upload_limit_bytes"] == 1024 + assert result["id"] == 7 + + +@pytest.mark.asyncio +async def test_upload_preflight_rejects_kb_without_write_before_file_materialization(monkeypatch): + user = SimpleNamespace(user_id=7, user_name="f067") + row = SimpleNamespace(id=67, user_id=8, type=0, tenant_id=1) + permission = AsyncMock(side_effect=UnAuthorizedError()) + monkeypatch.setattr(application, "get_open_api_operator_async", AsyncMock(return_value=user)) + monkeypatch.setattr(application.KnowledgeDao, "aquery_by_id", AsyncMock(return_value=row)) + monkeypatch.setattr(application.KnowledgeService.permission_service, "ensure_knowledge_write_async", permission) + + with pytest.raises(HTTPException) as exc_info: + await application.ensure_upload_allowed( + request=application.current_request(), + command=KnowledgeFileUploadInput( + knowledge_id=67, + file_name="f067.txt", + content_base64="ZjA2Nw==", + ), + version_repo=object(), + doc_repo=object(), + ) + + assert exc_info.value.status_code == 403 + permission.assert_awaited_once_with(login_user=user, owner_user_id=8, knowledge_id=67) + + +@pytest.mark.asyncio +async def test_upload_preflight_checks_space_folder_and_tenant(monkeypatch): + user = SimpleNamespace(user_id=7, user_name="f067") + row = SimpleNamespace(id=67, type=3, tenant_id=1) + require_action = AsyncMock() + get_folder = AsyncMock(return_value=SimpleNamespace(id=9)) + ensure_tenant = Mock() + service = SimpleNamespace( + _require_action=require_action, + _get_folder_for_action=get_folder, + _ensure_space_async_task_tenant_consistency=ensure_tenant, + ) + monkeypatch.setattr(application, "get_open_api_operator_async", AsyncMock(return_value=user)) + monkeypatch.setattr(application.KnowledgeDao, "aquery_by_id", AsyncMock(return_value=row)) + monkeypatch.setattr(application, "_space_service", lambda *args: service) + + await application.ensure_upload_allowed( + request=application.current_request(), + command=KnowledgeFileUploadInput( + knowledge_id=67, + parent_id=9, + file_name="f067.txt", + content_base64="ZjA2Nw==", + ), + version_repo=object(), + doc_repo=object(), + ) + + require_action.assert_awaited_once_with("folder", 9, "upload_file") + get_folder.assert_awaited_once_with(67, 9) + ensure_tenant.assert_called_once_with(row, "upload_file") diff --git a/src/backend/test/open_mcp/test_auth.py b/src/backend/test/open_mcp/test_auth.py new file mode 100644 index 0000000000..8110e6631e --- /dev/null +++ b/src/backend/test/open_mcp/test_auth.py @@ -0,0 +1,154 @@ +import json +from contextlib import asynccontextmanager + +import pytest + +from bisheng.common.errcode.open_api import OpenApiCredentialMissingError +from bisheng.open_mcp.auth import OpenMcpTransportAuth, get_current_mcp_scope + + +def _scope(*, headers=None): + return { + "type": "http", + "method": "POST", + "path": "/api/v2/mcp", + "headers": headers or [], + "client": ("192.0.2.67", 12345), + } + + +async def _invoke(app, scope, request_messages): + messages = list(request_messages) + sent = [] + + async def receive(): + return messages.pop(0) + + async def send(message): + sent.append(message) + + await app(scope, receive, send) + return sent + + +def _response_json(messages): + body = b"".join(item.get("body", b"") for item in messages if item["type"] == "http.response.body") + return json.loads(body) + + +@pytest.mark.asyncio +async def test_transport_auth_propagates_identity_headers_and_scope(monkeypatch): + captured = {} + + @asynccontextmanager + async def _access_context(**kwargs): + captured.update(kwargs) + yield + + async def inner(scope, receive, send): + captured["current_scope"] = get_current_mcp_scope() + message = await receive() + captured["body"] = message["body"] + + monkeypatch.setattr("bisheng.open_mcp.auth.open_api_access_context", _access_context) + app = OpenMcpTransportAuth(inner) + headers = [ + (b"authorization", b"Bearer f067"), + (b"x-on-behalf-of", b"42"), + (b"x-end-user", b"end-user"), + ] + body = b"{}" + + await _invoke( + app, + _scope(headers=headers), + [{"type": "http.request", "body": body, "more_body": False}], + ) + + assert captured["authorization"] == "Bearer f067" + assert captured["on_behalf_of"] == "42" + assert captured["end_user"] == "end-user" + assert captured["connection_scope"]["path"] == "/api/v2/mcp" + assert captured["current_scope"]["client"][0] == "192.0.2.67" + assert captured["body"] == body + assert get_current_mcp_scope() is None + + +@pytest.mark.asyncio +async def test_transport_auth_returns_existing_open_api_auth_envelope(monkeypatch): + @asynccontextmanager + async def _reject(**kwargs): + raise OpenApiCredentialMissingError() + yield # pragma: no cover + + monkeypatch.setattr("bisheng.open_mcp.auth.open_api_access_context", _reject) + sent = await _invoke( + OpenMcpTransportAuth(lambda scope, receive, send: None), + _scope(), + [{"type": "http.request", "body": b"", "more_body": False}], + ) + + start = next(item for item in sent if item["type"] == "http.response.start") + assert start["status"] == 401 + assert _response_json(sent)["status_code"] == 26001 + + +@pytest.mark.asyncio +async def test_transport_rejects_declared_body_over_limit_before_inner_app(monkeypatch): + called = False + + @asynccontextmanager + async def _access_context(**kwargs): + yield + + async def inner(scope, receive, send): + nonlocal called + called = True + + monkeypatch.setattr("bisheng.open_mcp.auth.open_api_access_context", _access_context) + from bisheng.open_mcp.auth import settings + + monkeypatch.setattr( + type(settings.open_mcp), + "max_request_body_bytes", + property(lambda self: 4), + ) + sent = await _invoke( + OpenMcpTransportAuth(inner), + _scope(headers=[(b"content-length", b"5")]), + [{"type": "http.request", "body": b"12345", "more_body": False}], + ) + + assert called is False + assert next(item for item in sent if item["type"] == "http.response.start")["status"] == 413 + assert _response_json(sent)["error"]["code"] == "REQUEST_TOO_LARGE" + + +@pytest.mark.asyncio +async def test_transport_rejects_streamed_body_over_limit(monkeypatch): + @asynccontextmanager + async def _access_context(**kwargs): + yield + + async def inner(scope, receive, send): + await receive() + await receive() + + monkeypatch.setattr("bisheng.open_mcp.auth.open_api_access_context", _access_context) + from bisheng.open_mcp.auth import settings + + monkeypatch.setattr( + type(settings.open_mcp), + "max_request_body_bytes", + property(lambda self: 4), + ) + sent = await _invoke( + OpenMcpTransportAuth(inner), + _scope(), + [ + {"type": "http.request", "body": b"12", "more_body": True}, + {"type": "http.request", "body": b"345", "more_body": False}, + ], + ) + + assert next(item for item in sent if item["type"] == "http.response.start")["status"] == 413 diff --git a/src/backend/test/open_mcp/test_contracts.py b/src/backend/test/open_mcp/test_contracts.py new file mode 100644 index 0000000000..5adeaaedb9 --- /dev/null +++ b/src/backend/test/open_mcp/test_contracts.py @@ -0,0 +1,39 @@ +from bisheng.open_mcp.registry import TOOL_DEFINITIONS + +FORBIDDEN_ARGUMENTS = {"authorization", "user_id", "tenant_id", "on_behalf_of", "file_path"} + + +def test_every_tool_exposes_object_schemas_and_annotations(): + for definition in TOOL_DEFINITIONS: + tool = definition.to_mcp_tool() + assert tool.inputSchema["type"] == "object" + assert tool.outputSchema["type"] == "object" + assert tool.inputSchema.get("additionalProperties") is False + assert tool.outputSchema.get("additionalProperties") is False + assert FORBIDDEN_ARGUMENTS.isdisjoint(tool.inputSchema.get("properties", {})) + assert tool.annotations is not None + assert tool.annotations.openWorldHint is not None + + +def test_batch_delete_and_upload_are_mcp_object_adaptations(): + tools = {definition.name: definition.to_mcp_tool() for definition in TOOL_DEFINITIONS} + batch = tools["bisheng_knowledge_files_delete"].inputSchema + assert batch["required"] == ["file_ids"] + assert batch["properties"]["file_ids"]["type"] == "array" + + upload = tools["bisheng_knowledge_file_upload"].inputSchema + assert {"content_base64", "file_url"} <= upload["properties"].keys() + assert "file_path" not in upload["properties"] + assert "callback_url" not in upload["properties"] + + +def test_outputs_keep_actions_and_structured_tags_without_permission_ids(): + tools = {definition.name: definition.to_mcp_tool() for definition in TOOL_DEFINITIONS} + resource_schema = tools["bisheng_knowledge_create"].outputSchema + assert "actions" in resource_schema["properties"] + assert "permission_ids" not in resource_schema["properties"] + + file_schema = tools["bisheng_knowledge_file_list"].outputSchema + serialized = str(file_schema) + assert "TagItem" in serialized + assert "permission_ids" not in serialized diff --git a/src/backend/test/open_mcp/test_file_tools.py b/src/backend/test/open_mcp/test_file_tools.py new file mode 100644 index 0000000000..564cb7a305 --- /dev/null +++ b/src/backend/test/open_mcp/test_file_tools.py @@ -0,0 +1,147 @@ +import inspect +from contextlib import asynccontextmanager +from pathlib import Path +from unittest.mock import AsyncMock + +import pytest + +from bisheng.open_mcp.contracts import ( + KnowledgeFileDeleteInput, + KnowledgeFileListInput, + KnowledgeFilesDeleteInput, + KnowledgeFileUploadInput, +) +from bisheng.open_mcp.tools import file as file_tools +from bisheng.open_mcp.tools.file import ( + knowledge_file_delete, + knowledge_file_list, + knowledge_file_upload, + knowledge_files_delete, +) + + +@asynccontextmanager +async def _repositories(): + yield object(), object() + + +def _install_repositories(monkeypatch): + monkeypatch.setattr("bisheng.open_mcp.application.knowledge_repositories", _repositories) + + +@pytest.mark.asyncio +async def test_file_list_preserves_structured_tags_and_filters_legacy_fields(monkeypatch): + _install_repositories(monkeypatch) + facade = AsyncMock( + return_value={ + "data": [ + { + "id": 7, + "knowledge_id": 67, + "file_name": "f067.txt", + "file_type": 1, + "tags": [{"id": 1, "name": "MCP"}], + "permission_ids": [999], + } + ], + "page_size": 10, + "has_more": False, + "writeable": True, + } + ) + monkeypatch.setattr("bisheng.open_mcp.application.list_knowledge_files", facade) + + result = await knowledge_file_list(KnowledgeFileListInput(knowledge_id=67)) + + assert result.data[0].tags[0].name == "MCP" + assert "permission_ids" not in result.data[0].model_dump() + assert result.writeable is True + + +@pytest.mark.asyncio +async def test_file_upload_passes_managed_path_to_application_facade(monkeypatch, tmp_path): + _install_repositories(monkeypatch) + path = tmp_path / "f067.txt" + path.write_bytes(b"mcp upload") + + @asynccontextmanager + async def _prepared(arguments): + yield str(path), path.name + + facade = AsyncMock( + return_value={ + "id": 7, + "knowledge_id": 67, + "file_name": "f067.txt", + "file_type": 1, + } + ) + preflight = AsyncMock() + monkeypatch.setattr("bisheng.open_mcp.tools.file.prepared_upload", _prepared) + monkeypatch.setattr("bisheng.open_mcp.application.ensure_upload_allowed", preflight) + monkeypatch.setattr("bisheng.open_mcp.application.upload_knowledge_file", facade) + + result = await knowledge_file_upload( + KnowledgeFileUploadInput( + knowledge_id=67, + file_name="f067.txt", + content_base64="bWNwIHVwbG9hZA==", + ) + ) + + assert result.id == 7 + preflight.assert_awaited_once() + assert Path(facade.await_args.kwargs["path"]) == path + assert facade.await_args.kwargs["file_name"] == "f067.txt" + + +@pytest.mark.asyncio +async def test_file_upload_rejects_target_before_materializing_file(monkeypatch): + _install_repositories(monkeypatch) + materialized = False + + @asynccontextmanager + async def _prepared(arguments): + nonlocal materialized + materialized = True + yield "/unused", "unused.txt" + + preflight = AsyncMock(side_effect=RuntimeError("denied")) + facade = AsyncMock() + monkeypatch.setattr("bisheng.open_mcp.tools.file.prepared_upload", _prepared) + monkeypatch.setattr("bisheng.open_mcp.application.ensure_upload_allowed", preflight) + monkeypatch.setattr("bisheng.open_mcp.application.upload_knowledge_file", facade) + + with pytest.raises(RuntimeError, match="denied"): + await knowledge_file_upload( + KnowledgeFileUploadInput( + knowledge_id=67, + file_name="f067.txt", + content_base64="ZjA2Nw==", + ) + ) + + assert materialized is False + facade.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("handler", "arguments", "expected"), + [ + (knowledge_file_delete, KnowledgeFileDeleteInput(file_id=7), [7]), + (knowledge_files_delete, KnowledgeFilesDeleteInput(file_ids=[7, 8]), [7, 8]), + ], +) +async def test_file_delete_handlers_use_application_facade(monkeypatch, handler, arguments, expected): + facade = AsyncMock() + monkeypatch.setattr("bisheng.open_mcp.application.delete_knowledge_files", facade) + + result = await handler(arguments) + + assert result.success is True + assert facade.await_args.kwargs["file_ids"] == expected + + +def test_mcp_file_tools_do_not_import_http_endpoint_module(): + assert "open_endpoints.api.endpoints" not in inspect.getsource(file_tools) diff --git a/src/backend/test/open_mcp/test_generated_docs.py b/src/backend/test/open_mcp/test_generated_docs.py new file mode 100644 index 0000000000..f62ba0b547 --- /dev/null +++ b/src/backend/test/open_mcp/test_generated_docs.py @@ -0,0 +1,30 @@ +from pathlib import Path + +from bisheng.open_mcp.registry import TOOL_DEFINITIONS + +BACKEND_ROOT = Path(__file__).resolve().parents[2] +PUBLIC_DOC = BACKEND_ROOT / "docs/api/open-mcp.md" + + +def test_public_doc_covers_the_exact_registry_allowlist(): + content = PUBLIC_DOC.read_text(encoding="utf-8") + + assert "10 个知识资源和知识文件工具" in content + assert "https:///api/v2/mcp" in content + for definition in TOOL_DEFINITIONS: + assert content.count(f"`{definition.name}`") >= 1 + + +def test_public_doc_covers_contract_and_security_boundaries(): + content = PUBLIC_DOC.read_text(encoding="utf-8") + + assert "Authorization: Bearer " in content + assert "inputSchema" in content + assert "outputSchema" in content + assert "structuredContent" in content + assert '"success":true' in content + assert '"code": "INVALID_ARGUMENT"' in content + assert "不暴露 `callback_url`" in content + assert "不转成 `permission_ids`" in content + assert "TagItem[]" in content + assert "" in content diff --git a/src/backend/test/open_mcp/test_identity_tools.py b/src/backend/test/open_mcp/test_identity_tools.py new file mode 100644 index 0000000000..e90c185e11 --- /dev/null +++ b/src/backend/test/open_mcp/test_identity_tools.py @@ -0,0 +1,279 @@ +"""The identity / organisation MCP tools (F052 T207a, served by F067 ``open_mcp``). + +``identity:read`` is the high-risk scope: granting it means the whole tenant's +org chart is readable, and there is deliberately no way to narrow it to some +departments. So what has to hold is (a) a service account with no management +role still sees the *full* tree — reusing the department admin tree would have +handed it an empty one and looked like a permission bug; (b) the tenant is a +hard wall, and crossing it is indistinguishable from asking for something that +does not exist; (c) no credential-bearing field can leave, and service accounts +never appear among people. +""" + +from __future__ import annotations + +import importlib +from contextlib import asynccontextmanager + +import pytest +from sqlalchemy import text +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.pool import StaticPool +from sqlmodel.ext.asyncio.session import AsyncSession + +from bisheng.common.errcode.mcp_face import McpIdentityNotFoundError +from bisheng.core.context.tenant import set_current_tenant_id +from bisheng.database.models.department import Department, UserDepartment +from bisheng.database.models.role import Role +from bisheng.database.models.tenant import UserTenant +from bisheng.department.domain.services.org_directory_service import OrgDirectoryService +from bisheng.open_api.domain.context import OpenApiPrincipal +from bisheng.open_mcp.registry import TOOL_REGISTRY, list_tools_for +from bisheng.open_mcp.tools import execute_tool +from bisheng.user.domain.models.user import User +from bisheng.user.domain.models.user_role import UserRole + +TENANT = 9 +OTHER_TENANT = 10 + +#: Every module whose DAOs must talk to the in-memory engine instead of MySQL. +_DAO_MODULES = ( + "bisheng.database.models.department", + "bisheng.database.models.role", + "bisheng.database.models.tenant", + "bisheng.user.domain.models.user", + "bisheng.user.domain.models.user_role", +) + + +@pytest.fixture +async def org(monkeypatch): + """Two tenants, a two-level tree, one disabled person, one role.""" + + engine = create_async_engine("sqlite+aiosqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) + async with engine.begin() as connection: + for model in (Department, UserDepartment, User, UserTenant, Role): + await connection.run_sync(model.__table__.create) + # ``UserRole`` declares an autoincrement ``id`` alongside a composite + # primary key, which SQLite refuses to compile. Hand-written DDL rather + # than reshaping a production model to suit a test engine. + await connection.execute( + text( + "CREATE TABLE userrole (" + " id INTEGER," + " user_id INTEGER NOT NULL," + " role_id INTEGER NOT NULL," + " tenant_id INTEGER NOT NULL DEFAULT 1," + " create_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL," + " update_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL," + " PRIMARY KEY (user_id, role_id))" + ) + ) + + @asynccontextmanager + async def session_factory(): + session = AsyncSession(engine, expire_on_commit=False) + try: + yield session + finally: + await session.close() + + for module_name in _DAO_MODULES: + monkeypatch.setattr(importlib.import_module(module_name), "get_async_db_session", session_factory) + + async with session_factory() as session: + session.add_all( + [ + Department(id=1, dept_id="BS@root", name="总部", parent_id=None, tenant_id=TENANT, path="/1/"), + Department( + id=2, dept_id="BS@rd", name="研发", parent_id=1, tenant_id=TENANT, path="/1/2/", sort_order=1 + ), + Department( + id=3, dept_id="BS@ops", name="运维", parent_id=1, tenant_id=TENANT, path="/1/3/", sort_order=0 + ), + Department( + id=4, + dept_id="BS@arch", + name="已归档", + parent_id=1, + tenant_id=TENANT, + path="/1/4/", + status="archived", + ), + Department(id=5, dept_id="BS@other", name="他租户", parent_id=None, tenant_id=OTHER_TENANT, path="/5/"), + User(user_id=101, user_name="alice", password="x"), + User(user_id=102, user_name="bob", password="x"), + User(user_id=103, user_name="carol", password="x", delete=1), + User(user_id=901, user_name="outsider", password="x"), + UserTenant(user_id=101, tenant_id=TENANT, is_active=1), + UserTenant(user_id=102, tenant_id=TENANT, is_active=1), + UserTenant(user_id=103, tenant_id=TENANT, is_active=1), + UserTenant(user_id=901, tenant_id=OTHER_TENANT, is_active=1), + # Explicit ids: ``user_department.id`` is a BigInteger, and + # SQLite only autoincrements a plain INTEGER primary key. + UserDepartment(id=1, user_id=101, department_id=2), + UserDepartment(id=2, user_id=102, department_id=3), + UserDepartment(id=3, user_id=103, department_id=2), + Role(id=50, role_name="知识管理员", tenant_id=TENANT), + UserRole(user_id=101, role_id=50, tenant_id=TENANT), + ] + ) + await session.commit() + + token = set_current_tenant_id(TENANT) + try: + yield session_factory + finally: + from bisheng.core.context.tenant import current_tenant_id + + current_tenant_id.reset(token) + await engine.dispose() + + +# --- the service ------------------------------------------------------------ + + +async def test_the_tree_is_the_whole_tenant_with_no_management_role_needed(org): + """A service account administers nothing; the department admin tree would be empty.""" + + tree = await OrgDirectoryService.atree() + assert [node["dept_id"] for node in tree] == ["BS@root"] + children = tree[0]["children"] + # Sorted by sort_order, then name — the archived node is not in the tree. + assert [node["dept_id"] for node in children] == ["BS@ops", "BS@rd"] + assert tree[0]["parent_id"] is None + assert children[0]["parent_id"] == "BS@root" + + +async def test_the_tree_carries_no_tenant_topology(org): + """``is_tenant_root`` / ``mounted_tenant_id`` are platform administration, not org data.""" + + tree = await OrgDirectoryService.atree() + assert set(tree[0]) == { + "dept_id", + "name", + "parent_id", + "path", + "sort_order", + "source", + "status", + "children", + } + + +async def test_another_tenants_departments_are_not_in_the_tree(org): + tree = await OrgDirectoryService.atree() + flattened = [] + + def walk(nodes): + for node in nodes: + flattened.append(node["dept_id"]) + walk(node["children"]) + + walk(tree) + assert "BS@other" not in flattened + + +async def test_a_person_lookup_carries_no_credential_field(org): + payload = await OrgDirectoryService.aget_user(101) + assert set(payload) == {"user_id", "user_name", "status", "departments", "roles"} + assert payload["user_name"] == "alice" + assert payload["roles"] == ["知识管理员"] + assert payload["departments"] == [{"dept_id": "BS@rd", "name": "研发", "path": "/1/2/"}] + + +async def test_a_disabled_person_is_reported_as_disabled_not_hidden(org): + """Hiding them would read as "no such user" and send an agent hunting a typo. + + It would also make ``status`` a field that can only ever say "active". + """ + + payload = await OrgDirectoryService.aget_user(103) + assert payload["user_name"] == "carol" + assert payload["status"] == "disabled" + + members = await OrgDirectoryService.amembers("BS@rd") + assert {member["user_name"]: member["status"] for member in members["members"]} == { + "alice": "active", + "carol": "disabled", + } + + +async def test_another_tenants_person_is_indistinguishable_from_a_missing_one(org): + assert await OrgDirectoryService.aget_user(901) is None + assert await OrgDirectoryService.aget_user(99999) is None + + +async def test_department_members_are_paged(org): + payload = await OrgDirectoryService.amembers("BS@rd", page=1, size=1) + assert payload["total"] == 2 + assert len(payload["members"]) == 1 + assert set(payload["members"][0]) == {"user_id", "user_name", "status"} + + +async def test_members_of_another_tenants_department_read_as_missing(org): + assert await OrgDirectoryService.amembers("BS@other") is None + assert await OrgDirectoryService.amembers("BS@nope") is None + + +# --- through the MCP tool layer ----------------------------------------------- + + +async def test_the_tools_answer_with_the_identity_scope(org): + tree = await execute_tool("bisheng_org_tree", {}) + user = await execute_tool("bisheng_identity_get_user", {"target_user_id": 101}) + members = await execute_tool("bisheng_dept_members", {"dept_id": "BS@rd"}) + + assert tree.departments[0].dept_id == "BS@root" + assert user.user_name == "alice" + assert members.total == 2 + + +async def test_a_cross_tenant_lookup_and_a_missing_one_give_the_same_answer(org): + with pytest.raises(McpIdentityNotFoundError) as outsider: + await execute_tool("bisheng_identity_get_user", {"target_user_id": 901}) + with pytest.raises(McpIdentityNotFoundError) as absent: + await execute_tool("bisheng_identity_get_user", {"target_user_id": 99999}) + + assert outsider.value.to_dict() == absent.value.to_dict() + assert outsider.value.code == 26306 + + +async def test_service_accounts_never_appear_among_people(org): + """They have no ``user`` row and belong to no department, so this is structural. + + The key's own subject id is 31; asking for it must not resolve to the + natural person who happens to hold user id 31. + """ + + with pytest.raises(McpIdentityNotFoundError): + await execute_tool("bisheng_identity_get_user", {"target_user_id": 31}) + + +def test_without_the_scope_the_tools_are_neither_listed_nor_callable(): + principal = _principal(frozenset({"knowledge:read"})) + listed = {tool.name for tool in list_tools_for(principal)} + + assert "bisheng_org_tree" not in listed + assert not TOOL_REGISTRY["bisheng_dept_members"].visible_to(principal) + + +def test_with_the_scope_all_three_tools_are_listed(): + listed = {tool.name for tool in list_tools_for(_principal(frozenset({"identity:read"})))} + + assert {"bisheng_identity_get_user", "bisheng_org_tree", "bisheng_dept_members"} <= listed + + +def _principal(scopes: frozenset[str]) -> OpenApiPrincipal: + return OpenApiPrincipal( + credential_id=7, + actor_kind="service_account", + actor_id=31, + actor_name="indexer", + tenant_id=9, + resource_owner_user_id=12, + scopes=scopes, + authorization_subject_type="service_account", + authorization_subject_id=31, + effective_user_id=None, + ) diff --git a/src/backend/test/open_mcp/test_knowledge_tools.py b/src/backend/test/open_mcp/test_knowledge_tools.py new file mode 100644 index 0000000000..7a6257569d --- /dev/null +++ b/src/backend/test/open_mcp/test_knowledge_tools.py @@ -0,0 +1,153 @@ +import inspect +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock + +import pytest + +from bisheng.open_mcp.contracts import ( + KnowledgeCreateInput, + KnowledgeIdInput, + KnowledgeListInput, + KnowledgeRetrieveInput, + KnowledgeUpdateInput, +) +from bisheng.open_mcp.tools import knowledge as knowledge_tools +from bisheng.open_mcp.tools.knowledge import ( + knowledge_clear, + knowledge_create, + knowledge_delete, + knowledge_list, + knowledge_retrieve, + knowledge_update, +) + + +@asynccontextmanager +async def _repositories(): + yield object(), object() + + +def _install_repositories(monkeypatch): + monkeypatch.setattr("bisheng.open_mcp.application.knowledge_repositories", _repositories) + + +@pytest.mark.asyncio +async def test_knowledge_list_calls_application_facade_and_maps_explicit_output(monkeypatch): + _install_repositories(monkeypatch) + facade = AsyncMock( + return_value={ + "data": [ + { + "id": 67, + "name": "f067", + "type": 0, + "auth_type": "public", + "is_released": False, + "is_shared": False, + "auto_tag_enabled": False, + "actions": ["read"], + "permission_ids": [999], + } + ], + "page_size": 5, + "has_more": False, + "next_cursor": None, + } + ) + monkeypatch.setattr("bisheng.open_mcp.application.list_knowledge", facade) + + result = await knowledge_list(KnowledgeListInput(type=0, name="f067", page_size=5)) + + assert result.data[0].actions == ["read"] + assert "permission_ids" not in result.data[0].model_dump() + assert facade.await_args.kwargs["command"].resource_type == 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("handler", "arguments", "facade_name"), + [ + ( + knowledge_create, + KnowledgeCreateInput(name="f067", type=0, model="embedding"), + "create_knowledge", + ), + ( + knowledge_update, + KnowledgeUpdateInput(knowledge_id=67, name="updated"), + "update_knowledge", + ), + ], +) +async def test_knowledge_write_handlers_return_explicit_resource(monkeypatch, handler, arguments, facade_name): + _install_repositories(monkeypatch) + facade = AsyncMock( + return_value={ + "id": 67, + "name": "f067", + "type": 0, + "auth_type": "public", + "is_released": False, + "is_shared": False, + "auto_tag_enabled": False, + "actions": ["read"], + } + ) + monkeypatch.setattr(f"bisheng.open_mcp.application.{facade_name}", facade) + + result = await handler(arguments) + + assert result.id == 67 + assert result.actions == ["read"] + facade.assert_awaited_once() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("handler", "facade_name"), + [ + (knowledge_delete, "delete_knowledge"), + (knowledge_clear, "clear_knowledge"), + ], +) +async def test_destructive_knowledge_handlers_return_operation_result(monkeypatch, handler, facade_name): + _install_repositories(monkeypatch) + facade = AsyncMock() + monkeypatch.setattr(f"bisheng.open_mcp.application.{facade_name}", facade) + + result = await handler(KnowledgeIdInput(knowledge_id=67)) + + assert result.success is True + assert facade.await_args.kwargs["knowledge_id"] == 67 + + +@pytest.mark.asyncio +async def test_retrieve_handler_maps_business_chunks(monkeypatch): + _install_repositories(monkeypatch) + facade = AsyncMock( + return_value={ + "chunks": [ + { + "content": "answer", + "knowledge_id": 67, + "document_id": 7, + "document_name": "f067.txt", + "document_update_time": "", + "chunk_index": 0, + } + ], + "total": 1, + } + ) + monkeypatch.setattr("bisheng.open_mcp.application.retrieve_knowledge", facade) + + result = await knowledge_retrieve( + KnowledgeRetrieveInput(query="answer", knowledge_base_ids=[67]) + ) + + assert result.total == 1 + assert result.chunks[0].document_name == "f067.txt" + + +def test_mcp_knowledge_tools_do_not_import_http_endpoint_module(): + assert "open_endpoints.api.endpoints" not in inspect.getsource(knowledge_tools) diff --git a/src/backend/test/open_mcp/test_registry.py b/src/backend/test/open_mcp/test_registry.py new file mode 100644 index 0000000000..d792481ba3 --- /dev/null +++ b/src/backend/test/open_mcp/test_registry.py @@ -0,0 +1,75 @@ +from bisheng.open_api.domain.context import OpenApiPrincipal +from bisheng.open_mcp.registry import SOURCE_ALLOWLIST, TOOL_DEFINITIONS, TOOL_REGISTRY, list_tools_for + +EXPECTED_SOURCES = { + ("GET", "/api/v2/filelib/"), + ("POST", "/api/v2/filelib/"), + ("PUT", "/api/v2/filelib/"), + ("DELETE", "/api/v2/filelib/{knowledge_id}"), + ("DELETE", "/api/v2/filelib/clear/{knowledge_id}"), + ("POST", "/api/v2/filelib/retrieve"), + ("POST", "/api/v2/filelib/file/{knowledge_id}"), + ("GET", "/api/v2/filelib/file/list"), + ("DELETE", "/api/v2/filelib/file/{file_id}"), + ("POST", "/api/v2/filelib/delete_file"), +} + + +def _principal(*, actor_kind="service_account", scopes=frozenset({"knowledge:read", "knowledge:write"})): + natural_person = actor_kind == "natural_person" + return OpenApiPrincipal( + credential_id=1, + actor_kind=actor_kind, + actor_id=2, + actor_name="caller", + tenant_id=3, + resource_owner_user_id=2, + scopes=scopes, + authorization_subject_type="user" if natural_person else "service_account", + authorization_subject_id=2, + effective_user_id=2 if natural_person else None, + ) + + +#: App-factory tools folded in from F052. They have no ``/api/v2`` source route, +#: so they stay out of ``SOURCE_ALLOWLIST`` and the API freeze contract. +APP_FACTORY_TOOLS = { + "bisheng_identity_get_user", + "bisheng_org_tree", + "bisheng_dept_members", + "bisheng_app_status", + "bisheng_app_logs", + "bisheng_app_db_tables", + "bisheng_app_db_schema", + "bisheng_app_db_rows", + "bisheng_app_db_row_update", +} + + +def test_allowlist_is_exactly_ten_and_bidirectional(): + knowledge_tools = [item for item in TOOL_DEFINITIONS if item.name not in APP_FACTORY_TOOLS] + assert len(knowledge_tools) == 10 + assert len(TOOL_DEFINITIONS) == len(TOOL_REGISTRY) == 10 + len(APP_FACTORY_TOOLS) + assert SOURCE_ALLOWLIST == EXPECTED_SOURCES + + +def test_app_factory_tools_are_service_account_only_and_never_delegated(): + for name in APP_FACTORY_TOOLS: + definition = TOOL_REGISTRY[name] + assert definition.modes == frozenset({"S"}) + assert definition.allow_pat is False + + +def test_pat_only_discovers_the_three_read_tools(): + tools = list_tools_for(_principal(actor_kind="natural_person", scopes=frozenset({"knowledge:read"}))) + assert {tool.name for tool in tools} == { + "bisheng_knowledge_list", + "bisheng_knowledge_retrieve", + "bisheng_knowledge_file_list", + } + + +def test_service_account_discovery_obeys_scope(): + tools = list_tools_for(_principal(scopes=frozenset({"knowledge:write"}))) + assert len(tools) == 7 + assert all(TOOL_REGISTRY[tool.name].scope == "knowledge:write" for tool in tools) diff --git a/src/backend/test/open_mcp/test_result.py b/src/backend/test/open_mcp/test_result.py new file mode 100644 index 0000000000..79697e73cc --- /dev/null +++ b/src/backend/test/open_mcp/test_result.py @@ -0,0 +1,86 @@ +import json + +from fastapi import HTTPException +from pydantic import BaseModel, ValidationError + +from bisheng.common.errcode.open_api import OpenApiScopeMissingError +from bisheng.open_mcp.result import error_result, error_result_from_exception, success_result +from bisheng.open_mcp.upload import McpUploadError + + +class _Payload(BaseModel): + value: str + + +def _text_payload(result): + return json.loads(result.content[0].text) + + +def test_success_result_keeps_text_and_structured_content_equivalent(): + result = success_result(_Payload(value="毕昇")) + + assert result.isError is False + assert result.structuredContent == {"value": "毕昇"} + assert _text_payload(result) == result.structuredContent + + +def test_error_result_has_machine_readable_code_without_structured_success_payload(): + result = error_result("INVALID_ARGUMENT", " invalid input ") + + assert result.isError is True + assert result.structuredContent is None + assert _text_payload(result) == { + "error": {"code": "INVALID_ARGUMENT", "message": "invalid input"} + } + + +def test_business_error_preserves_code_and_safe_message(): + result = error_result_from_exception(OpenApiScopeMissingError(required="knowledge:write")) + + assert _text_payload(result) == { + "error": {"code": 26003, "message": "API credential lacks the required scope"} + } + + +def test_http_error_maps_status_to_mcp_error_code(): + result = error_result_from_exception(HTTPException(status_code=404, detail={"message": "missing"})) + + assert _text_payload(result) == {"error": {"code": "NOT_FOUND", "message": "missing"}} + + +def test_validation_error_reports_field_without_pydantic_help_url(): + class _Arguments(BaseModel): + count: int + + try: + _Arguments.model_validate({"count": "not-an-int"}) + except ValidationError as exc: + result = error_result_from_exception(exc) + else: # pragma: no cover - protects the test if Pydantic semantics change + raise AssertionError("expected validation failure") + + payload = _text_payload(result)["error"] + assert payload["code"] == "INVALID_ARGUMENT" + assert payload["message"].startswith("count: ") + assert "http" not in payload["message"] + + +def test_unexpected_error_does_not_leak_exception_details(): + result = error_result_from_exception(RuntimeError("database password secret")) + + assert _text_payload(result) == { + "error": {"code": "TOOL_EXECUTION_FAILED", "message": "Tool execution failed"} + } + + +def test_upload_error_keeps_stable_code_and_actionable_safe_message(): + result = error_result_from_exception( + McpUploadError("Remote file exceeds the upload limit", code="FILE_TOO_LARGE") + ) + + assert _text_payload(result) == { + "error": { + "code": "FILE_TOO_LARGE", + "message": "Remote file exceeds the upload limit", + } + } diff --git a/src/backend/test/open_mcp/test_sdk_contract.py b/src/backend/test/open_mcp/test_sdk_contract.py new file mode 100644 index 0000000000..9ff3f2074a --- /dev/null +++ b/src/backend/test/open_mcp/test_sdk_contract.py @@ -0,0 +1,45 @@ +from importlib.metadata import version +from types import SimpleNamespace + +from mcp import types +from packaging.version import Version + +from bisheng.common.services.config_service import settings +from bisheng.open_mcp import server as open_mcp_server +from bisheng.open_mcp.server import create_open_mcp_runtime + + +def test_pinned_sdk_supports_required_streamable_http_contract(): + sdk_version = Version(version("mcp")) + runtime = create_open_mcp_runtime() + + assert Version("1.27.2") <= sdk_version < Version("2.0.0") + assert runtime.manager.json_response is True + assert runtime.manager.stateless is True + assert runtime.manager.max_request_body_size == settings.open_mcp.max_request_body_bytes + assert runtime.manager.security_settings.enable_dns_rebinding_protection is False + assert runtime.manager.security_settings.allowed_hosts + assert types.ListToolsRequest in runtime.manager.app.request_handlers + assert types.CallToolRequest in runtime.manager.app.request_handlers + assert "structuredContent" in types.CallToolResult.model_fields + + +def test_transport_security_can_be_enabled_explicitly(monkeypatch): + monkeypatch.setattr( + open_mcp_server, + "settings", + SimpleNamespace( + open_mcp=SimpleNamespace( + enable_dns_rebinding_protection=True, + transport_allowed_hosts=["mcp.example.com"], + transport_allowed_origins=["https://mcp.example.com"], + ), + open_api=SimpleNamespace(public_base_url=""), + ), + ) + + security = open_mcp_server._transport_security_settings() + + assert security.enable_dns_rebinding_protection is True + assert security.allowed_hosts == ["mcp.example.com"] + assert security.allowed_origins == ["https://mcp.example.com"] diff --git a/src/backend/test/open_mcp/test_server.py b/src/backend/test/open_mcp/test_server.py new file mode 100644 index 0000000000..477a17e97a --- /dev/null +++ b/src/backend/test/open_mcp/test_server.py @@ -0,0 +1,272 @@ +import json +from contextlib import asynccontextmanager +from types import SimpleNamespace + +import httpx +import pytest +from mcp import types + +from bisheng.open_api.domain.context import ( + OpenApiPrincipal, + get_current_open_api_principal, + reset_current_open_api_principal, + set_current_open_api_principal, +) +from bisheng.open_mcp.auth import current_mcp_scope +from bisheng.open_mcp.contracts import ResourceListData +from bisheng.open_mcp.server import MCP_PATH, OpenMcpDispatchMiddleware, create_open_mcp_runtime + + +@pytest.fixture(autouse=True) +def direct_handler_admission(request, monkeypatch): + if not request.node.name.startswith(("test_call_tool", "test_list_tools")): + yield + return + # Existing handler tests own the tool contract, not the SDK transport or + # membership provider (covered with real SQL by F113's separate tests). + monkeypatch.setattr( + "bisheng.open_mcp.server.StreamableHTTPSessionManager", lambda **kwargs: SimpleNamespace(app=kwargs["app"]) + ) + + async def validate(header): + assert header == "Bearer handler-fixture" + return get_current_open_api_principal() + + monkeypatch.setattr("bisheng.open_mcp.server.validate_bearer", validate) + token = current_mcp_scope.set({"type": "http", "headers": [(b"authorization", b"Bearer handler-fixture")]}) + try: + yield + finally: + current_mcp_scope.reset(token) + + +def _principal(*, scopes=frozenset({"knowledge:read", "knowledge:write"})): + return OpenApiPrincipal( + credential_id=1, + actor_kind="service_account", + actor_id=2, + actor_name="f067-service", + tenant_id=3, + resource_owner_user_id=2, + scopes=scopes, + authorization_subject_type="service_account", + authorization_subject_id=2, + effective_user_id=None, + ) + + +def _handler(runtime, request_type): + return runtime.manager.app.request_handlers[request_type] + + +def _tool_payload(result): + return json.loads(result.root.content[0].text) + + +@pytest.mark.asyncio +async def test_list_tools_returns_only_principal_visible_tools(): + runtime = create_open_mcp_runtime() + token = set_current_open_api_principal(_principal(scopes=frozenset({"knowledge:read"}))) + try: + result = await _handler(runtime, types.ListToolsRequest)(types.ListToolsRequest()) + finally: + reset_current_open_api_principal(token) + + assert {tool.name for tool in result.root.tools} == { + "bisheng_knowledge_list", + "bisheng_knowledge_retrieve", + "bisheng_knowledge_file_list", + } + + +@pytest.mark.asyncio +async def test_call_tool_success_returns_structured_result_and_audits(monkeypatch): + runtime = create_open_mcp_runtime() + audits = [] + + async def _execute(name, arguments): + assert name == "bisheng_knowledge_list" + assert arguments == {"type": 0} + return ResourceListData(data=[], page_size=10, has_more=False) + + monkeypatch.setattr("bisheng.open_mcp.server.execute_tool", _execute) + monkeypatch.setattr( + "bisheng.open_mcp.server.open_api_call_audit_service.enqueue", + audits.append, + ) + token = set_current_open_api_principal(_principal()) + try: + result = await _handler(runtime, types.CallToolRequest)( + types.CallToolRequest( + params=types.CallToolRequestParams( + name="bisheng_knowledge_list", + arguments={"type": 0}, + ) + ) + ) + finally: + reset_current_open_api_principal(token) + + assert result.root.isError is False + assert result.root.structuredContent == {"data": [], "page_size": 10, "has_more": False, "next_cursor": None} + assert _tool_payload(result) == result.root.structuredContent + assert len(audits) == 1 + audit = audits[0] + assert audit.target_type == "mcp_tool" + assert audit.target_id == "bisheng_knowledge_list" + assert audit.tenant_id == 3 + assert audit.audit_metadata["channel"] == "mcp" + assert audit.audit_metadata["result"] == "success" + assert audit.audit_metadata["scope"] == "knowledge:read" + + +@pytest.mark.asyncio +async def test_call_tool_denied_by_scope_does_not_execute_and_audits_error(monkeypatch): + runtime = create_open_mcp_runtime() + audits = [] + + async def _unexpected_execute(name, arguments): + raise AssertionError("denied tool must not execute") + + monkeypatch.setattr("bisheng.open_mcp.server.execute_tool", _unexpected_execute) + monkeypatch.setattr( + "bisheng.open_mcp.server.open_api_call_audit_service.enqueue", + audits.append, + ) + token = set_current_open_api_principal(_principal(scopes=frozenset({"knowledge:read"}))) + try: + result = await _handler(runtime, types.CallToolRequest)( + types.CallToolRequest( + params=types.CallToolRequestParams( + name="bisheng_knowledge_delete", + arguments={"knowledge_id": 67}, + ) + ) + ) + finally: + reset_current_open_api_principal(token) + + assert result.root.isError is True + assert _tool_payload(result)["error"]["code"] == "PERMISSION_DENIED" + assert audits[0].audit_metadata["error_code"] == "PERMISSION_DENIED" + assert audits[0].audit_metadata["result"] == "failed" + + +@pytest.mark.asyncio +async def test_call_tool_validation_error_is_mcp_tool_error(monkeypatch): + runtime = create_open_mcp_runtime() + monkeypatch.setattr("bisheng.open_mcp.server.open_api_call_audit_service.enqueue", lambda _: None) + token = set_current_open_api_principal(_principal()) + try: + result = await _handler(runtime, types.CallToolRequest)( + types.CallToolRequest( + params=types.CallToolRequestParams( + name="bisheng_knowledge_delete", + arguments={"knowledge_id": 0}, + ) + ) + ) + finally: + reset_current_open_api_principal(token) + + assert result.root.isError is True + assert _tool_payload(result)["error"]["code"] == "INVALID_ARGUMENT" + + +@pytest.mark.asyncio +async def test_call_tool_unknown_name_is_rejected_by_mcp_protocol_before_execution(monkeypatch): + runtime = create_open_mcp_runtime() + audits = [] + monkeypatch.setattr( + "bisheng.open_mcp.server.open_api_call_audit_service.enqueue", + audits.append, + ) + token = set_current_open_api_principal(_principal()) + try: + result = await _handler(runtime, types.CallToolRequest)( + types.CallToolRequest(params=types.CallToolRequestParams(name="not_allowlisted", arguments={})) + ) + finally: + reset_current_open_api_principal(token) + + assert result.code == -32602 + assert result.message == "Unknown tool" + assert audits == [] + + +@pytest.mark.asyncio +async def test_dispatch_middleware_matches_exact_path_and_optional_trailing_slash(): + calls = [] + + async def app(scope, receive, send): + calls.append(("app", scope["path"])) + + async def mcp_app(scope, receive, send): + calls.append(("mcp", scope["path"])) + + middleware = OpenMcpDispatchMiddleware(app, mcp_app=mcp_app) + + async def receive(): + return {"type": "http.request", "body": b"", "more_body": False} + + async def send(message): + return None + + for path in (MCP_PATH, f"{MCP_PATH}/", f"{MCP_PATH}/nested", "/api/v2/filelib/"): + await middleware({"type": "http", "path": path}, receive, send) + + assert calls == [ + ("mcp", MCP_PATH), + ("mcp", f"{MCP_PATH}/"), + ("app", f"{MCP_PATH}/nested"), + ("app", "/api/v2/filelib/"), + ] + + +@pytest.mark.asyncio +async def test_real_streamable_http_initialize_then_stateless_list(monkeypatch): + principal = _principal() + + @asynccontextmanager + async def _authenticated(**kwargs): + token = set_current_open_api_principal(principal) + try: + yield principal + finally: + reset_current_open_api_principal(token) + + monkeypatch.setattr("bisheng.open_mcp.auth.open_api_access_context", _authenticated) + runtime = create_open_mcp_runtime() + headers = {"Accept": "application/json, text/event-stream"} + transport = httpx.ASGITransport(app=runtime.app) + async with ( + runtime.lifespan(), + httpx.AsyncClient( + transport=transport, + base_url="http://localhost", + ) as client, + ): + initialized = await client.post( + MCP_PATH, + headers=headers, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": {"name": "f067-test", "version": "1"}, + }, + }, + ) + listed = await client.post( + MCP_PATH, + headers={**headers, "MCP-Protocol-Version": "2025-11-25"}, + json={"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}, + ) + + assert initialized.status_code == 200 + assert initialized.json()["result"]["protocolVersion"] == "2025-11-25" + assert listed.status_code == 200 + assert len(listed.json()["result"]["tools"]) == 10 diff --git a/src/backend/test/open_mcp/test_settings.py b/src/backend/test/open_mcp/test_settings.py new file mode 100644 index 0000000000..550dceb9e0 --- /dev/null +++ b/src/backend/test/open_mcp/test_settings.py @@ -0,0 +1,25 @@ +import pytest +from pydantic import ValidationError + +from bisheng.core.config.open_platform import OpenMcpConf + + +def test_open_mcp_safe_defaults_and_body_formula(): + config = OpenMcpConf() + assert config.max_inline_upload_bytes == 50 * 1024 * 1024 + assert config.max_base64_characters == 4 * ((config.max_inline_upload_bytes + 2) // 3) + assert config.max_request_body_bytes == config.max_base64_characters + 1024 * 1024 + assert config.enable_dns_rebinding_protection is False + assert "localhost" in config.transport_allowed_hosts + assert "localhost:*" in config.transport_allowed_hosts + + +def test_open_mcp_dns_rebinding_protection_can_be_enabled_explicitly(): + config = OpenMcpConf(enable_dns_rebinding_protection=True) + assert config.enable_dns_rebinding_protection is True + + +@pytest.mark.parametrize("host", ["https://example.com", "example.com/path", "user@example.com", ""]) +def test_allowed_hosts_reject_non_host_values(host): + with pytest.raises(ValidationError): + OpenMcpConf(file_url_allowed_hosts=[host]) diff --git a/src/backend/test/open_mcp/test_upload.py b/src/backend/test/open_mcp/test_upload.py new file mode 100644 index 0000000000..adbe0c12a9 --- /dev/null +++ b/src/backend/test/open_mcp/test_upload.py @@ -0,0 +1,314 @@ +import base64 +import hashlib +from contextlib import asynccontextmanager +from pathlib import Path +from unittest.mock import AsyncMock + +import pytest + +from bisheng.open_mcp.contracts import KnowledgeFileUploadInput +from bisheng.open_mcp.upload import ( + McpUploadError, + prepared_upload, + validate_outbound_url, +) + + +@pytest.mark.asyncio +async def test_prepared_base64_upload_decodes_and_removes_temporary_file(monkeypatch): + monkeypatch.setattr("bisheng.open_mcp.upload.get_max_upload_bytes", lambda _: 1024) + source = KnowledgeFileUploadInput( + knowledge_id=67, + file_name="f067.txt", + content_base64=base64.b64encode(b"hello mcp").decode(), + ) + + async with prepared_upload(source) as (path, file_name): + temporary_path = Path(path) + temporary_directory = temporary_path.parent + assert file_name == "f067.txt" + assert temporary_path.read_bytes() == b"hello mcp" + assert temporary_directory.name.startswith("bisheng-open-mcp-") + assert temporary_path.name == f"{hashlib.sha256(b'hello mcp').hexdigest()}_f067.txt" + + assert not temporary_path.exists() + assert not temporary_directory.exists() + + +@pytest.mark.asyncio +async def test_prepared_base64_upload_rejects_invalid_and_cleans_up(monkeypatch): + monkeypatch.setattr("bisheng.open_mcp.upload.get_max_upload_bytes", lambda _: 1024) + source = KnowledgeFileUploadInput( + knowledge_id=67, + file_name="f067.txt", + content_base64="not strict base64!", + ) + + with pytest.raises(McpUploadError, match="not valid strict base64"): + async with prepared_upload(source): + pass + + +@pytest.mark.asyncio +async def test_prepared_base64_upload_enforces_decoded_size(monkeypatch): + monkeypatch.setattr("bisheng.open_mcp.upload.get_max_upload_bytes", lambda _: 3) + source = KnowledgeFileUploadInput( + knowledge_id=67, + file_name="f067.txt", + content_base64=base64.b64encode(b"four").decode(), + ) + + with pytest.raises(McpUploadError, match="exceeds the configured upload limit"): + async with prepared_upload(source): + pass + + +@pytest.mark.asyncio +async def test_prepared_base64_upload_rejects_non_basename_file_name(monkeypatch): + monkeypatch.setattr("bisheng.open_mcp.upload.get_max_upload_bytes", lambda _: 1024) + source = KnowledgeFileUploadInput( + knowledge_id=67, + file_name="../f067.txt", + content_base64=base64.b64encode(b"f067").decode(), + ) + + with pytest.raises(McpUploadError, match="base name"): + async with prepared_upload(source): + pass + + +@pytest.mark.asyncio +async def test_prepared_upload_bounds_multibyte_staged_file_name(monkeypatch): + monkeypatch.setattr("bisheng.open_mcp.upload.get_max_upload_bytes", lambda _: 1024) + source = KnowledgeFileUploadInput( + knowledge_id=67, + file_name=f"{'😀' * 60}.txt", + content_base64=base64.b64encode(b"f067").decode(), + ) + + async with prepared_upload(source) as (path, _): + assert len(Path(path).name.encode("utf-8")) <= 255 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("url", "message"), + [ + ("https://user:pass@files.example/f.txt", "userinfo"), + ("http://files.example/f.txt", "HTTPS"), + ("https://other.example/f.txt", "allowlisted"), + ], +) +async def test_outbound_url_rejects_unsafe_shape(monkeypatch, url, message): + monkeypatch.setattr("bisheng.open_mcp.upload._minio_share_origin", lambda: None) + + with pytest.raises(McpUploadError, match=message): + await validate_outbound_url( + url, + allowed_hosts=["files.example"], + allow_minio_http=False, + ) + + +@pytest.mark.asyncio +async def test_outbound_url_rejects_private_dns_resolution(monkeypatch): + monkeypatch.setattr("bisheng.open_mcp.upload._minio_share_origin", lambda: None) + + async def _private_address(host, port): + assert (host, port) == ("files.example", 443) + return {"127.0.0.1"} + + monkeypatch.setattr("bisheng.open_mcp.upload._resolve_addresses", _private_address) + + with pytest.raises(McpUploadError, match="prohibited network address"): + await validate_outbound_url( + "https://files.example/f.txt", + allowed_hosts=["files.example"], + allow_minio_http=False, + ) + + +@pytest.mark.asyncio +async def test_outbound_url_accepts_allowlisted_public_https(monkeypatch): + monkeypatch.setattr("bisheng.open_mcp.upload._minio_share_origin", lambda: None) + + async def _public_address(host, port): + return {"93.184.216.34"} + + monkeypatch.setattr("bisheng.open_mcp.upload._resolve_addresses", _public_address) + + url = "https://files.example/f.txt" + assert ( + await validate_outbound_url( + url, + allowed_hosts=["files.example"], + allow_minio_http=False, + ) + == url + ) + + +def test_callback_url_is_not_part_of_the_mcp_upload_contract(): + with pytest.raises(ValueError): + KnowledgeFileUploadInput( + knowledge_id=67, + file_name="f067.txt", + content_base64="Zg==", + callback_url="https://callback.example/done", + ) + + +@pytest.mark.asyncio +async def test_prepared_base64_rejects_data_after_padding_across_chunks(monkeypatch): + monkeypatch.setattr("bisheng.open_mcp.upload.get_max_upload_bytes", lambda _: 1024 * 1024) + encoded = "A" * (65536 - 4) + "YQ==" + "YQ==" + source = KnowledgeFileUploadInput( + knowledge_id=67, + file_name="f067.txt", + content_base64=encoded, + ) + + with pytest.raises(McpUploadError, match="strict base64"): + async with prepared_upload(source): + pass + + +@pytest.mark.parametrize("address", ["127.0.0.1", "169.254.169.254", "100.64.0.1"]) +def test_non_global_addresses_are_not_public(address): + from bisheng.open_mcp.upload import _is_public_ip + + assert _is_public_ip(address) is False + + +@pytest.mark.asyncio +async def test_file_url_download_pins_approved_ip_and_preserves_host_and_sni(monkeypatch): + captured = {} + + async def _addresses(host, port): + assert (host, port) == ("files.example", 443) + return {"93.184.216.34"} + + class _NetworkStream: + def get_extra_info(self, name): + assert name == "server_addr" + return ("93.184.216.34", 443) + + class _Response: + is_redirect = False + headers = {"content-length": "4"} + extensions = {"network_stream": _NetworkStream()} + + def raise_for_status(self): + return None + + async def aiter_bytes(self, chunk_size): + assert chunk_size == 64 * 1024 + yield b"f067" + + class _Client: + def __init__(self, **kwargs): + captured["client_kwargs"] = kwargs + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, traceback): + return None + + @asynccontextmanager + async def stream(self, method, url, **kwargs): + captured.update({"method": method, "url": str(url), **kwargs}) + yield _Response() + + monkeypatch.setattr("bisheng.open_mcp.upload._minio_share_origin", lambda: None) + monkeypatch.setattr("bisheng.open_mcp.upload._resolve_addresses", _addresses) + monkeypatch.setattr("bisheng.open_mcp.upload.httpx.AsyncClient", _Client) + monkeypatch.setattr("bisheng.open_mcp.upload.get_max_upload_bytes", lambda _: 1024) + monkeypatch.setattr( + "bisheng.open_mcp.upload.settings.open_mcp.file_url_allowed_hosts", + ["files.example"], + ) + source = KnowledgeFileUploadInput( + knowledge_id=67, + file_url="https://files.example/f067.txt", + ) + + async with prepared_upload(source) as (path, file_name): + assert Path(path).read_bytes() == b"f067" + assert file_name == "f067.txt" + + assert captured["url"] == "https://93.184.216.34/f067.txt" + assert captured["headers"] == {"Host": "files.example"} + assert captured["extensions"] == {"sni_hostname": "files.example"} + assert captured["client_kwargs"]["trust_env"] is False + + +@pytest.mark.asyncio +async def test_file_url_decodes_path_before_choosing_safe_base_name(monkeypatch): + async def _download(url, path, file_name): + assert file_name == "f067.txt" + Path(path).write_bytes(b"f067") + + monkeypatch.setattr("bisheng.open_mcp.upload._download_to_file", _download) + source = KnowledgeFileUploadInput( + knowledge_id=67, + file_url="https://files.example/nested%2Ff067.txt", + ) + + async with prepared_upload(source) as (path, file_name): + assert file_name == "f067.txt" + assert Path(path).name.endswith("_f067.txt") + + +@pytest.mark.asyncio +async def test_file_url_rejects_nul_in_decoded_file_name(): + source = KnowledgeFileUploadInput( + knowledge_id=67, + file_url="https://files.example/f067%00.txt", + ) + + with pytest.raises(McpUploadError, match="base name"): + async with prepared_upload(source): + pass + + +@pytest.mark.asyncio +async def test_minio_private_http_exception_requires_exact_scheme_host_and_port(monkeypatch): + monkeypatch.setattr( + "bisheng.open_mcp.upload._minio_share_origin", + lambda: ("http", "minio.internal", 9000), + ) + monkeypatch.setattr( + "bisheng.open_mcp.upload._resolve_addresses", + AsyncMock(return_value={"10.0.0.8"}), + ) + + accepted = await validate_outbound_url( + "http://minio.internal:9000/f067.txt", + allowed_hosts=[], + allow_minio_http=True, + ) + assert accepted.endswith("/f067.txt") + + with pytest.raises(McpUploadError, match="allowlisted"): + await validate_outbound_url( + "http://minio.internal:3306/f067.txt", + allowed_hosts=[], + allow_minio_http=True, + ) + + +@pytest.mark.asyncio +async def test_mixed_public_and_private_dns_answers_are_rejected(monkeypatch): + monkeypatch.setattr("bisheng.open_mcp.upload._minio_share_origin", lambda: None) + monkeypatch.setattr( + "bisheng.open_mcp.upload._resolve_addresses", + AsyncMock(return_value={"93.184.216.34", "127.0.0.1"}), + ) + + with pytest.raises(McpUploadError, match="prohibited"): + await validate_outbound_url( + "https://files.example/f067.txt", + allowed_hosts=["files.example"], + allow_minio_http=False, + ) diff --git a/src/backend/test/open_mcp/test_verify_script.py b/src/backend/test/open_mcp/test_verify_script.py new file mode 100644 index 0000000000..1892112851 --- /dev/null +++ b/src/backend/test/open_mcp/test_verify_script.py @@ -0,0 +1,113 @@ +import importlib.util +from pathlib import Path + +import pytest + +from bisheng.open_mcp.registry import TOOL_REGISTRY + +BACKEND_ROOT = Path(__file__).resolve().parents[2] +SCRIPT_PATH = BACKEND_ROOT / "scripts/verify_open_mcp.py" + + +def _load_script(): + spec = importlib.util.spec_from_file_location("verify_open_mcp", SCRIPT_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_verifier_allowlist_and_calls_are_read_only(): + script = _load_script() + + # The verifier covers the ten knowledge tools; the app-factory tools have no + # /api/v2 source route and need other scopes. + assert script.EXPECTED_TOOL_NAMES == frozenset( + name for name, definition in TOOL_REGISTRY.items() if definition.source_method != "MCP" + ) + assert script.READ_ONLY_TOOL_NAMES == { + "bisheng_knowledge_list", + "bisheng_knowledge_retrieve", + "bisheng_knowledge_file_list", + } + args = script._parser().parse_args( + [ + "--url", + "https://bisheng.example.com/api/v2/mcp", + "--call-tool", + "bisheng_knowledge_list", + "--arguments-json", + '{"page_size":1}', + ] + ) + assert args.arguments_json == {"page_size": 1} + assert args.expected_profile == "full" + + +def test_verifier_detects_missing_and_unexpected_tools_for_exact_profiles(): + script = _load_script() + + missing, unexpected = script._tool_differences( + script.EXPECTED_TOOL_NAMES - {"bisheng_knowledge_delete"}, + "full", + ) + assert missing == ["bisheng_knowledge_delete"] + assert unexpected == [] + + missing, unexpected = script._tool_differences( + script.READ_ONLY_TOOL_NAMES | {"bisheng_knowledge_delete"}, + "pat", + ) + assert missing == [] + assert unexpected == ["bisheng_knowledge_delete"] + + +def test_verifier_scope_filtered_profile_only_rejects_out_of_allowlist_tools(): + script = _load_script() + + missing, unexpected = script._tool_differences( + {"bisheng_knowledge_list", "not_a_f067_tool"}, + "scope-filtered", + ) + assert missing == [] + assert unexpected == ["not_a_f067_tool"] + + +def test_verifier_rejects_write_tool_selection(): + script = _load_script() + + with pytest.raises(SystemExit): + script._parser().parse_args( + [ + "--url", + "https://bisheng.example.com/api/v2/mcp", + "--call-tool", + "bisheng_knowledge_delete", + ] + ) + + +def test_verifier_reports_auth_failure_without_leaking_token(monkeypatch, capsys): + script = _load_script() + token = "secret-token-that-must-not-appear" + monkeypatch.setenv("BISHENG_API_KEY", token) + + def fail_run(coroutine): + coroutine.close() + raise RuntimeError(f"transport rejected Bearer {token}") + + monkeypatch.setattr(script.asyncio, "run", fail_run) + + assert script.main(["--url", "https://bisheng.example.com/api/v2/mcp"]) == 3 + output = capsys.readouterr() + assert token not in output.out + assert token not in output.err + assert "RuntimeError" in output.err + + +def test_verifier_missing_credential_returns_nonzero(monkeypatch, capsys): + script = _load_script() + monkeypatch.delenv("BISHENG_API_KEY", raising=False) + + assert script.main(["--url", "https://bisheng.example.com/api/v2/mcp"]) == 2 + assert "missing credential" in capsys.readouterr().err diff --git a/src/backend/test/permission/fixtures/f048_bench_contract.synthetic.json b/src/backend/test/permission/fixtures/f048_bench_contract.synthetic.json index 27430c19b8..0bbcba86a4 100644 --- a/src/backend/test/permission/fixtures/f048_bench_contract.synthetic.json +++ b/src/backend/test/permission/fixtures/f048_bench_contract.synthetic.json @@ -1,6 +1,6 @@ { "contract_version": "f048-bench-01-v2", - "authorization_model_checksum": "2ed5c834c0879736fdf731e7e975bcaf01875b35e21065fb08c43f809830abe0", + "authorization_model_checksum": "3266b35a69a3ffbb8bc5be01e60d6e03894d6ba5f427dec7fb1ce14c8fa532ba", "dataset": { "name": "f048-single-slot-visible-synthetic-v2", "source": "checksum-pinned-representative-synthetic-not-production", @@ -139,5 +139,5 @@ "concurrency": 1, "consistency": "HIGHER_CONSISTENCY" }, - "contract_checksum": "0290180531d4f286b5a43540557b750a26465373d3cad0b18d6c64a1dd677ade" + "contract_checksum": "36e8c2200be2449d40891ca3409ff93d728f56ba677e49b89dea5efb7651298e" } diff --git a/src/backend/test/permission/test_authorization_model_publish_markers.py b/src/backend/test/permission/test_authorization_model_publish_markers.py index ee3d006ca9..9fc40c4be0 100644 --- a/src/backend/test/permission/test_authorization_model_publish_markers.py +++ b/src/backend/test/permission/test_authorization_model_publish_markers.py @@ -148,10 +148,16 @@ async def cutover(**kwargs): monkeypatch.setattr(cli, "FGAClient", Client) monkeypatch.setattr(cli, "_find_remote_model_id", AsyncMock(side_effect=lambda *_args: state.remote_model_id)) monkeypatch.setattr(cli, "load_service_account_resource_markers", AsyncMock(return_value=markers)) + # The fixture Catalog already carries service-account markers (built with + # marker_subject="service_account:*"); the legacy-Catalog test overrides this. + state.catalog_markers = frozenset() + monkeypatch.setattr( + cli, "load_service_account_catalog_markers", AsyncMock(side_effect=lambda: state.catalog_markers) + ) monkeypatch.setattr(cli, "_assert_apply_window", state.window) monkeypatch.setattr(cli, "_authorization_release_id", AsyncMock(return_value=5)) monkeypatch.setattr(cli, "_activate_model_release", state.activate) - monkeypatch.setattr(cli, "_publish_noop_catalog_cutover", state.cutover) + monkeypatch.setattr(cli, "_publish_catalog_cutover", state.cutover) monkeypatch.setattr(cli, "_retire_other_active_models", AsyncMock()) monkeypatch.setattr( cli, "OpenFGAMigrationModelPublisher", lambda **_kwargs: SimpleNamespace(aget_or_publish=state.publisher) @@ -218,6 +224,69 @@ async def test_current_model_repairs_inheritance_and_is_repeatable(publication, state.cutover.assert_not_awaited() +async def test_current_model_repairs_a_catalog_released_before_service_accounts(publication, capsys): + """A Catalog from before 2026-09-09 has only user:* model-release markers. + + Every action a service account gets through a model then evaluates to false + (found on 114: a service account could not edit a knowledge base it had just + created). An unchanged model must still add the service-account half. + """ + state = publication + sa_catalog = _active_model_tuples(actions=("edit", "rename", "upload_file"), marker_subject="service_account:*") + user_catalog = _active_model_tuples(actions=("edit", "rename", "upload_file"), marker_subject="user:*") + sa_only = {row for row in sa_catalog if row[0] == "service_account:*"} + state.tuples = (state.tuples - sa_only) | user_catalog + state.initial_tuples = set(state.tuples) + state.catalog_markers = frozenset(sa_only) + + before = ModelEvaluator(build_authorization_model_f048(), state.tuples) + assert not before.check("service_account:2", "can_edit", "knowledge_space:137") + + assert await cli.execute(state.args, live_settings=state.settings) == 0 + + after = ModelEvaluator(build_authorization_model_f048(), state.tuples) + assert after.check("service_account:2", "can_edit", "knowledge_space:137") + assert not after.check("service_account:3", "can_edit", "knowledge_space:137") + assert user_catalog <= state.tuples, "the user half of the Catalog is left as it was" + result = json.loads(capsys.readouterr().out.splitlines()[-1]) + assert result["event"] == "already_current" + assert result["service_account_catalog_marker_tuples_verified"] == len(sa_only) + + snapshot = set(state.tuples) + assert await cli.execute(state.args, live_settings=state.settings) == 0 + assert state.tuples == snapshot + + +async def test_catalog_markers_follow_the_publish_rule_for_service_accounts_only(monkeypatch): + model = SimpleNamespace( + model_key="manager", + active=True, + action_codes=("edit", "manage_permission", "use"), + derived_level=3, + allow_same_level=False, + ) + snapshot = SimpleNamespace(release_key="catalog-v2", models=(SimpleNamespace(snapshot=model),)) + + class ControlState: + async def current_catalog(self): + return snapshot + + monkeypatch.setattr(reconcile, "SqlPermissionControlState", ControlState) + markers = await reconcile.load_service_account_catalog_markers() + + release = "permission_model_release:catalog-v2~manager" + assert {user for user, _relation, _object in markers} == {"service_account:*"} + assert ("service_account:*", "active", "permission_catalog_release:catalog-v2") in markers + assert {relation for _user, relation, obj in markers if obj == release} == { + "enabled_marker", + "edit_marker", + "manage_permission_marker", + "use_marker", + "grant_level_1_marker", + "grant_level_2_marker", + } + + @pytest.mark.parametrize("existing_model", [None, "new-model"]) async def test_model_upgrade_repairs_on_target_model_before_catalog_cutover(publication, existing_model): state = publication diff --git a/src/backend/test/permission/test_capability_authorization.py b/src/backend/test/permission/test_capability_authorization.py new file mode 100644 index 0000000000..549250b588 --- /dev/null +++ b/src/backend/test/permission/test_capability_authorization.py @@ -0,0 +1,186 @@ +"""Ordinary grants govern public capability access and revocation.""" + +from dataclasses import replace +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from bisheng.common.errcode.permission import GrantLevelForbiddenError, PermissionInvalidResourceError +from bisheng.linsight.domain.services.skill_permission import SkillPermissionAdapter +from bisheng.permission.application.runtime import F048PermissionRuntime +from bisheng.permission.domain.schemas import VerifiedPermissionTarget +from bisheng.permission.domain.services.grant_service import CanonicalGrantChange, GrantService +from bisheng.permission.domain.services.grant_source_service import GrantModelSnapshot, GrantSourceService +from bisheng.permission.domain.services.permission_action_service import PermissionActor +from bisheng.permission.domain.services.visibility_projection_service import VisibilityProjectionCompiler +from test.permission.test_f048_grant_policy import FakeGrantState, FakeProjection, _context + + +def capability_context(resource_type="tool"): + context = _context(capabilities=(), system_authorized=True) + model = GrantModelSnapshot(model_key="viewer", active=True, action_codes=("use",), derived_level=1) + target = VerifiedPermissionTarget.from_business_service( + tenant_id=7, resource_type=resource_type, resource_id="42", resource_version=3, context_version="v3" + ) + grant = F048PermissionRuntime._empty_grant(target=target, model=model) + return replace(context, target=target, models=(model,), grants=(grant,)) + + +@pytest.mark.parametrize("resource_type", ["tool", "linsight_skill"]) +async def test_tenant_grant_revoke_removes_use_and_visibility(resource_type): + context = capability_context(resource_type) + projection = FakeProjection() + sources = GrantSourceService() + service = GrantService(source_service=sources, projection=projection, state=FakeGrantState()) + source = sources.canonicalize_source(source_id=88, subject_type="tenant", subject_id="7", source_type="TENANT") + added = await service.mutate( + context, + changes=(CanonicalGrantChange(operation="ADD", model_key="viewer", source=source),), + expected_resource_version=3, + expected_catalog_release_id=context.current_catalog_release_id, + idempotency_key="add-tenant", + ) + assert any( + d.user == "tenant:7#member" and d.relation == "ordinary_assignee" and d.action == "WRITE" + for d in projection.plans[-1].deltas + ) + assert all(d.relation != "system_use_marker" for d in projection.plans[-1].deltas) + visible = ( + VisibilityProjectionCompiler().compile(tenant_id=7, grants=added.grants, existing_sources=()).active_sources + ) + current = replace( + context, + target=context.target.model_copy(update={"resource_version": 4}), + grants=added.grants, + existing_visible_sources=visible, + ) + removed = await service.mutate( + current, + changes=(CanonicalGrantChange(operation="REMOVE", assignee_id=88, expected_assignee_version=1),), + expected_resource_version=4, + expected_catalog_release_id=context.current_catalog_release_id, + idempotency_key="remove-tenant", + ) + assert all(not s.active for g in removed.grants for s in g.sources) + for relation in ["visible", "ordinary_assignee"]: + assert any( + d.user == "tenant:7#member" and d.relation == relation and d.action == "DELETE" + for d in projection.plans[-1].deltas + ) + + +@pytest.mark.parametrize("tenant_id,actions", [("8", ("use",)), ("7", ("use", "manage_permission"))]) +async def test_tenant_grant_rejects_cross_tenant_and_management(tenant_id, actions): + context = capability_context() + model = replace(context.models[0], action_codes=actions) + context = replace(context, models=(model,), grants=(replace(context.grants[0], model=model),)) + projection = FakeProjection() + sources = GrantSourceService() + service = GrantService(source_service=sources, projection=projection, state=FakeGrantState()) + source = sources.canonicalize_source( + source_id=88, subject_type="tenant", subject_id=tenant_id, source_type="TENANT" + ) + with pytest.raises(GrantLevelForbiddenError): + await service.mutate( + context, + changes=(CanonicalGrantChange(operation="ADD", model_key="viewer", source=source),), + expected_resource_version=3, + expected_catalog_release_id=context.current_catalog_release_id, + idempotency_key="invalid", + ) + assert projection.prepared == [] + + +@pytest.mark.parametrize( + "tenant,source,owner,enabled,allowed", + [ + (7, "builtin", None, True, True), + (8, "builtin", None, True, False), + (7, "personal", 9, True, False), + (7, "personal", 2, True, True), + (7, "builtin", None, False, False), + ], +) +async def test_skill_target_checks_tenant_owner_status(tenant, source, owner, enabled, allowed): + row = SimpleNamespace(id=42, tenant_id=tenant, source=source, created_by=owner, enabled=enabled, update_time=None) + runtime = SimpleNamespace(get_permission_version=AsyncMock(return_value=(3, "current"))) + adapter = SkillPermissionAdapter(runtime, SimpleNamespace(get=AsyncMock(return_value=row))) + actor = PermissionActor(user_id=2, current_tenant_id=7) + if allowed: + target = await adapter.resolve_permission_target(resource_id="42", actor=actor, action="use") + assert (target.resource_type, target.tenant_id) == ("linsight_skill", 7) + else: + with pytest.raises(PermissionInvalidResourceError): + await adapter.resolve_permission_target(resource_id="42", actor=actor, action="use") + runtime.get_permission_version.assert_not_awaited() + + +@pytest.mark.parametrize( + "mode,operation,source,expected", + [ + (None, None, False, "create"), + (SimpleNamespace(projection_state="CURRENT"), None, False, "migrate"), + (SimpleNamespace(projection_state="CURRENT"), None, True, "preserved"), + (SimpleNamespace(projection_state="CURRENT"), SimpleNamespace(status="FINALIZED"), False, "preserved"), + ], +) +def test_migration_keeps_existing_and_revoked_grants(mode, operation, source, expected): + from scripts.migrate_capability_authorization import migration_state + + assert migration_state(mode, operation, source) == expected + + +def test_migration_stops_for_unfinished_operation(): + from scripts.migrate_capability_authorization import migration_state + + with pytest.raises(RuntimeError): + migration_state(SimpleNamespace(projection_state="CURRENT"), SimpleNamespace(status="COMMIT_UNKNOWN"), False) + + +async def test_default_grant_uses_tenant_members_and_migration_deletes_public_markers(): + context = capability_context() + runtime = object.__new__(F048PermissionRuntime) + runtime._sources = GrantSourceService() + catalog = SimpleNamespace(models=[SimpleNamespace(snapshot=context.models[0])]) + runtime.current_catalog = AsyncMock(return_value=catalog) + runtime.build_grant_context = AsyncMock(return_value=context) + runtime._grants = SimpleNamespace(mutate=AsyncMock()) + grant = runtime.tenant_use_grant(context.target, catalog) + assert grant.grant.sources[0].projected_subject == "tenant:7#member" + assert grant.grant.sources[0].protected is False + await runtime.migrate_capability_default( + actor=PermissionActor(user_id=1, current_tenant_id=7, super_admin=True), + target=context.target, + remove_public_markers=True, + ) + kwargs = runtime._grants.mutate.call_args.kwargs + assert {(delta.user, delta.relation, delta.action) for delta in kwargs["migration_deltas"]} == { + ("user:*", "visible", "DELETE"), + ("user:*", "system_use_marker", "DELETE"), + } + assert kwargs["changes"][0].source.projected_subject == "tenant:7#member" + + +async def test_migration_inventory_accepts_preset_with_historical_creator(monkeypatch): + from contextlib import asynccontextmanager + + from scripts import migrate_capability_authorization as migration + + tool = SimpleNamespace(id=294, tenant_id=1, user_id=1) + session = SimpleNamespace( + exec=AsyncMock( + side_effect=[ + SimpleNamespace(all=lambda: [tool]), + SimpleNamespace(all=lambda: []), + ] + ) + ) + + @asynccontextmanager + async def database_session(): + yield session + + monkeypatch.setattr(migration, "get_async_db_session", database_session) + assert await migration.inventory() == [(1, "tool", "294", None)] + assert tool.user_id == 1 diff --git a/src/backend/test/permission/test_department_contextual_authorization.py b/src/backend/test/permission/test_department_contextual_authorization.py index d065360cca..d836a16c02 100644 --- a/src/backend/test/permission/test_department_contextual_authorization.py +++ b/src/backend/test/permission/test_department_contextual_authorization.py @@ -105,7 +105,6 @@ async def test_unrelated_identity_relations_do_not_load_organization(client): client._post = AsyncMock(return_value={"allowed": True, "objects": []}) for object_type, relation in ( ("system", "super_admin"), - ("tenant", "member"), ("department", "admin"), ("department", "member"), ("user_group", "member"), @@ -158,3 +157,18 @@ async def test_invalid_principal_never_reaches_openfga(client): await client.check(user, "visible", "knowledge_space:1") source.load.assert_not_awaited() client._post.assert_not_awaited() + + +async def test_root_member_grant_uses_fresh_tenant_context(client): + source = provider() + source.load.side_effect = lambda uid: ActorDepartmentContext(uid, (), (1,)) + configure_department_context(client, source) + client._post = AsyncMock(return_value={"allowed": True}) + for kind in ("tool", "linsight_skill"): + await client.check("user:10", "can_use", f"{kind}:5") + assert client._post.call_args.args[1]["contextual_tuples"]["tuple_keys"] == [ + {"user": "user:10", "relation": "member", "object": "tenant:1"} + ] + source.load.side_effect = lambda uid: ActorDepartmentContext(uid, ()) + await client.check("user:10", "can_use", "linsight_skill:5") + assert "contextual_tuples" not in client._post.call_args.args[1] diff --git a/src/backend/test/permission/test_f048_api_process_runtime.py b/src/backend/test/permission/test_f048_api_process_runtime.py index 3661c92e2a..7df44924b6 100644 --- a/src/backend/test/permission/test_f048_api_process_runtime.py +++ b/src/backend/test/permission/test_f048_api_process_runtime.py @@ -213,6 +213,7 @@ async def build_runtime(client, *, external_scopes): "knowledge_file", "tool", "dashboard", + "linsight_skill", ): loader = initialized.adapters[resource_type]._loader assert loader._versions is facade diff --git a/src/backend/test/permission/test_f048_background_resource_registry.py b/src/backend/test/permission/test_f048_background_resource_registry.py index 12567ea524..85b6e7f70c 100644 --- a/src/backend/test/permission/test_f048_background_resource_registry.py +++ b/src/backend/test/permission/test_f048_background_resource_registry.py @@ -60,10 +60,11 @@ async def _background(client, *, external_scopes): captured: dict = {} - def _configure(runtime, *, resource_adapters=None, resource_registry=None): + def _configure(runtime, *, resource_adapters=None, resource_registry=None, subject_directory=None): captured["runtime"] = runtime captured["adapters"] = resource_adapters captured["registry"] = resource_registry + assert callable(subject_directory.candidate_users) monkeypatch.setattr(runtime_module, "initialize_f048_background_runtime", _background) monkeypatch.setattr(runtime_module, "configure_f048_runtime", _configure) @@ -98,6 +99,7 @@ def test_resource_composition_covers_every_checked_resource_type(): "knowledge_file", "tool", "dashboard", + "linsight_skill", } assert expected <= set(adapters) # Re-registering raises, which is the cheapest proof each type is already bound diff --git a/src/backend/test/permission/test_f048_business_source_projection.py b/src/backend/test/permission/test_f048_business_source_projection.py index f238d1ae9b..13c0cc057a 100644 --- a/src/backend/test/permission/test_f048_business_source_projection.py +++ b/src/backend/test/permission/test_f048_business_source_projection.py @@ -3,6 +3,8 @@ from __future__ import annotations from dataclasses import replace +from types import SimpleNamespace +from unittest.mock import AsyncMock import pytest @@ -69,6 +71,9 @@ def _runtime(context: GrantMutationContext): runtime = object.__new__(F048PermissionRuntime) mutation = _GrantMutation() runtime._grants = mutation + runtime._state = SimpleNamespace( + resolve_business_source_version=AsyncMock(side_effect=lambda **kwargs: kwargs["source"]), + ) async def build_grant_context(*, actor, target): del actor, target diff --git a/src/backend/test/permission/test_f048_catalog_bootstrap.py b/src/backend/test/permission/test_f048_catalog_bootstrap.py index a4a9c868b3..fc736a50d5 100644 --- a/src/backend/test/permission/test_f048_catalog_bootstrap.py +++ b/src/backend/test/permission/test_f048_catalog_bootstrap.py @@ -22,6 +22,7 @@ from bisheng.permission.application import control_state as control_state_module from bisheng.permission.application import sql_runtime as sql_runtime_module from bisheng.permission.application.catalog_bootstrap import ( + ensure_linsight_skill_action_scopes, normalize_environment, seed_initial_permission_catalog, ) @@ -29,6 +30,7 @@ from bisheng.permission.domain.models import ( AuthorizationModelRelease, PermissionAction, + PermissionActionResourceScope, PermissionCatalogRelease, PermissionModel, PermissionModelAction, @@ -152,10 +154,7 @@ async def test_seed_creates_single_current_catalog_on_fresh_install(session_fact "active", f"permission_catalog_release:{INITIAL_CATALOG_RELEASE_KEY}", ) in fga.tuples - assert any( - user == "service_account:*" and relation == "enabled_marker" - for user, relation, _ in fga.tuples - ) + assert any(user == "service_account:*" and relation == "enabled_marker" for user, relation, _ in fga.tuples) @pytest.mark.asyncio @@ -182,6 +181,43 @@ async def test_seed_makes_runtime_ready(session_factory): assert snapshot.model_id == MODEL_ID +@pytest.mark.asyncio +async def test_existing_catalog_gains_personal_skill_action_scopes(session_factory): + await seed_initial_permission_catalog( + FakeFGA(), + store_id=STORE_ID, + model_id=MODEL_ID, + model_checksum=MODEL_CHECKSUM, + environment="prod", + ) + async with session_factory() as session: + async with session.begin(): + scopes = ( + ( + await session.execute( + select(PermissionActionResourceScope).where( + PermissionActionResourceScope.resource_type == "linsight_skill", + ) + ) + ) + .scalars() + .all() + ) + for scope in scopes: + await session.delete(scope) + + added = await ensure_linsight_skill_action_scopes() + assert added == 3 + + decision = SqlCatalogDecisionState( + expected_store_id=STORE_ID, + expected_model_id=MODEL_ID, + ) + assert await decision.is_action_effective("linsight_skill", "edit") is True + assert await decision.is_action_effective("linsight_skill", "delete") is True + assert await ensure_linsight_skill_action_scopes() == 0 + + @pytest.mark.asyncio async def test_seed_is_idempotent(session_factory): fga = FakeFGA() diff --git a/src/backend/test/permission/test_f048_catalog_runtime.py b/src/backend/test/permission/test_f048_catalog_runtime.py index b323c259f0..d66082e9ee 100644 --- a/src/backend/test/permission/test_f048_catalog_runtime.py +++ b/src/backend/test/permission/test_f048_catalog_runtime.py @@ -762,9 +762,11 @@ async def test_catalog_create_is_idempotent_and_current_shape_is_complete( assert count == 1 +@pytest.mark.parametrize("model_active", [True, False]) async def test_permission_roster_sql_cursor_reads_only_one_bounded_page( session_factory: SessionFactory, monkeypatch, + model_active, ) -> None: source_service = GrantSourceService() sources = [ @@ -826,7 +828,7 @@ async def test_permission_roster_sql_cursor_reads_only_one_bounded_page( models = ( GrantModelSnapshot( model_key="viewer", - active=True, + active=model_active, action_codes=("visible",), derived_level=1, ), @@ -854,9 +856,11 @@ async def test_permission_roster_sql_cursor_reads_only_one_bounded_page( assert second_has_more is False +@pytest.mark.parametrize("model_active", [True, False]) async def test_inherited_roster_uses_nearest_custom_ancestor( session_factory: SessionFactory, monkeypatch, + model_active, ) -> None: source_service = GrantSourceService() root_source = source_service.canonicalize_source( @@ -964,7 +968,7 @@ async def test_inherited_roster_uses_nearest_custom_ancestor( models = ( GrantModelSnapshot( model_key="viewer", - active=True, + active=model_active, action_codes=("visible",), derived_level=1, ), diff --git a/src/backend/test/permission/test_f048_decision_api.py b/src/backend/test/permission/test_f048_decision_api.py index 8aedbee0da..3a0b13ce20 100644 --- a/src/backend/test/permission/test_f048_decision_api.py +++ b/src/backend/test/permission/test_f048_decision_api.py @@ -2,6 +2,7 @@ from __future__ import annotations +import pytest from fastapi import FastAPI from fastapi.testclient import TestClient @@ -12,12 +13,28 @@ PermissionInvalidResourceError, ) from bisheng.permission.api.dependencies import get_permission_decision_api +from bisheng.permission.api.endpoints import decision as decision_endpoints from bisheng.permission.api.endpoints.decision import router from bisheng.permission.application.resource_authorization import ( PermissionDecisionApplication, ResourceAuthorizationRegistry, ) from bisheng.permission.domain.schemas import VerifiedPermissionTarget +from bisheng.permission.domain.services.permission_action_service import PermissionActor + + +@pytest.fixture(autouse=True) +def authenticated_actor(monkeypatch): + """Keep HTTP decision tests independent of live identity infrastructure.""" + + async def resolve(login_user): + return PermissionActor( + user_id=login_user.user_id, + tenant_id=login_user.tenant_id, + super_admin=login_user.is_global_super, + ) + + monkeypatch.setattr(decision_endpoints, "permission_actor", resolve) class _BusinessResourcePort: @@ -184,7 +201,7 @@ def test_unknown_action_and_fga_unavailable_preserve_distinct_errors() -> None: ).json() assert unknown["status_code"] == 25001 - assert unavailable["status_code"] == 19002 + assert unavailable["status_code"] == 25021 def test_missing_and_cross_tenant_targets_are_equally_hidden() -> None: @@ -204,7 +221,7 @@ def test_missing_and_cross_tenant_targets_are_equally_hidden() -> None: ).json() ) - assert [body["status_code"] for body in bodies] == [19003, 19003] + assert [body["status_code"] for body in bodies] == [25022, 25022] assert [body["status_message"] for body in bodies] == [ "Invalid resource type or ID", "Invalid resource type or ID", diff --git a/src/backend/test/permission/test_f048_delete_retry.py b/src/backend/test/permission/test_f048_delete_retry.py new file mode 100644 index 0000000000..75c597ec63 --- /dev/null +++ b/src/backend/test/permission/test_f048_delete_retry.py @@ -0,0 +1,78 @@ +"""Delete retries reuse the original ledger after the business row is gone.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from bisheng.common.errcode.permission import PermissionPublishNotReadyError +from bisheng.permission.application.runtime import F048PermissionRuntime +from bisheng.permission.domain.schemas import VerifiedPermissionTarget +from bisheng.permission.domain.services.permission_action_service import PermissionActor + + +def _runtime(status, *, state="CURRENT", version=1, operation_id=9): + runtime = object.__new__(F048PermissionRuntime) + runtime._runtime_catalog = AsyncMock(return_value=SimpleNamespace(store_id="store", model_id="model", release_id=1)) + runtime._state = SimpleNamespace( + mode_for_target=AsyncMock( + return_value=SimpleNamespace( + version=version, + parent_type="knowledge_space", + parent_id="128", + projection_state=state, + operation_id=operation_id, + ) + ), + mark_projecting=AsyncMock(), + ) + runtime._projection = SimpleNamespace( + prepare=AsyncMock(return_value=SimpleNamespace(status=status, id=9)), + execute=AsyncMock(return_value=SimpleNamespace(status="FINALIZED")), + abandon_prepared=AsyncMock(), + ) + return runtime + + +def _target(): + return VerifiedPermissionTarget.from_business_service( + tenant_id=7, + resource_type="folder", + resource_id="57268", + resource_version=1, + context_version="delete-snapshot", + parent_type="knowledge_space", + parent_id="128", + ) + + +@pytest.mark.parametrize("status", ["FINALIZED", "COMMITTED", "STAGING", "COMMIT_UNKNOWN"]) +async def test_delete_retry_resumes_original_plan_without_current_version_check(status): + runtime = _runtime(status, version=2) + await runtime.project_delete(actor=PermissionActor(user_id=5, current_tenant_id=7), target=_target()) + runtime._state.mode_for_target.assert_not_awaited() + runtime._state.mark_projecting.assert_not_awaited() + plan = runtime._projection.prepare.await_args.args[0] + runtime._projection.execute.assert_awaited_once_with(plan) + assert plan.expected_version == 1 + assert plan.target_version == 2 + assert plan.operation_type == "RESOURCE_DELETE" + + +@pytest.mark.parametrize("state", ["CURRENT", "PROJECTING"]) +async def test_prepared_delete_can_reclaim_its_own_reservation(state): + runtime = _runtime("PREPARED", state=state) + await runtime.project_delete(actor=PermissionActor(user_id=5, current_tenant_id=7), target=_target()) + runtime._state.mark_projecting.assert_awaited_once() + runtime._projection.execute.assert_awaited_once() + + +@pytest.mark.parametrize( + "state,version,operation_id", [("CURRENT", 2, 9), ("PROJECTING", 1, 10), ("FAILED_CLOSED", 1, 9)] +) +async def test_new_delete_cannot_overwrite_a_changed_or_foreign_reservation(state, version, operation_id): + runtime = _runtime("PREPARED", state=state, version=version, operation_id=operation_id) + with pytest.raises(PermissionPublishNotReadyError): + await runtime.project_delete(actor=PermissionActor(user_id=5, current_tenant_id=7), target=_target()) + runtime._projection.execute.assert_not_awaited() + runtime._projection.abandon_prepared.assert_awaited_once() diff --git a/src/backend/test/permission/test_f048_grant_subjects.py b/src/backend/test/permission/test_f048_grant_subjects.py index 83bf50d859..c30954f42d 100644 --- a/src/backend/test/permission/test_f048_grant_subjects.py +++ b/src/backend/test/permission/test_f048_grant_subjects.py @@ -44,6 +44,7 @@ def test_resource_types_cover_every_grantable_container() -> None: "workflow", "assistant", "tool", + "linsight_skill", "channel", "dashboard", } <= GRANT_SUBJECT_RESOURCE_TYPES @@ -140,34 +141,32 @@ async def test_the_department_answers_carry_the_org_tree_shape(monkeypatch) -> N ) assert "children" in node and "has_children" in node - async def one_department(*args, **kwargs): - del args, kwargs - return type("D", (), {"id": 7, "tenant_id": 1, "path": "/7/"})() - - async def forest(*args, **kwargs): - del args, kwargs - return [node] + from types import SimpleNamespace + from unittest.mock import AsyncMock - monkeypatch.setattr(grant_subject_service.DepartmentDao, "aget_by_id", one_department) - monkeypatch.setattr(grant_subject_service.DepartmentService, "abuild_forest_within_subtree", forest) + result = {"roots": [node], "total_matches": 1, "truncated": False} + directory = SimpleNamespace(candidate_department_path=AsyncMock(return_value=result)) + monkeypatch.setattr(grant_subject_service, "get_grant_subject_directory", AsyncMock(return_value=directory)) - revealed = await grant_subject_service.get_candidate_department_path( - GrantSubjectScope(tenant_id=1, department_path=None), dept_id=7 - ) + scope = GrantSubjectScope(tenant_id=1, department_path=None) + revealed = await grant_subject_service.get_candidate_department_path(scope, dept_id=7) assert set(revealed) == {"roots", "total_matches", "truncated"} assert revealed["roots"] == [node] + directory.candidate_department_path.assert_awaited_once_with(scope, dept_id=7) async def test_a_department_bound_space_reveals_nothing_outside_its_subtree(monkeypatch) -> None: - from bisheng.permission.domain.services import grant_subject_service + from types import SimpleNamespace + from unittest.mock import AsyncMock - async def elsewhere(*args, **kwargs): - del args, kwargs - return type("D", (), {"id": 9, "tenant_id": 1, "path": "/1/9/"})() - - monkeypatch.setattr(grant_subject_service.DepartmentDao, "aget_by_id", elsewhere) + from bisheng.permission.domain.services import grant_subject_service - revealed = await grant_subject_service.get_candidate_department_path( - GrantSubjectScope(tenant_id=1, department_path="/2/"), dept_id=9 + directory = SimpleNamespace( + candidate_department_path=AsyncMock(return_value={"roots": [], "total_matches": 0, "truncated": False}) ) + monkeypatch.setattr(grant_subject_service, "get_grant_subject_directory", AsyncMock(return_value=directory)) + + scope = GrantSubjectScope(tenant_id=1, department_path="/2/") + revealed = await grant_subject_service.get_candidate_department_path(scope, dept_id=9) assert revealed["roots"] == [] + directory.candidate_department_path.assert_awaited_once_with(scope, dept_id=9) diff --git a/src/backend/test/permission/test_f048_inherited_roster_display.py b/src/backend/test/permission/test_f048_inherited_roster_display.py index fe6bd17c21..e10dd67a8c 100644 --- a/src/backend/test/permission/test_f048_inherited_roster_display.py +++ b/src/backend/test/permission/test_f048_inherited_roster_display.py @@ -65,6 +65,7 @@ async def load_folders(ids: list[int]): "Channel": [SimpleNamespace(id="ch-1", name="News Channel")], "GptsToolsType": [SimpleNamespace(id=8, name="CRM Tool")], "Dashboard": [SimpleNamespace(id=9, title="Sales Dashboard")], + "App": [SimpleNamespace(id="app-1", name="Survey Form")], } class Session: @@ -90,6 +91,7 @@ async def db_session(): ("channel", "ch-1"), ("tool", "8"), ("dashboard", "9"), + ("app", "app-1"), ) ) @@ -101,4 +103,35 @@ async def db_session(): ("channel", "ch-1"): "News Channel", ("tool", "8"): "CRM Tool", ("dashboard", "9"): "Sales Dashboard", + ("app", "app-1"): "Survey Form", } + + +async def test_resource_display_names_skips_deleted_hosted_apps(monkeypatch) -> None: + """A hosted app is soft-deleted through ``app.state``, not an ``is_delete`` + flag: the lookup must carry that predicate, or a service account's grant + list keeps naming applications that no longer exist.""" + + from bisheng.database.models.app import App + + statements = [] + + class Session: + async def exec(self, statement): + statements.append(statement) + return SimpleNamespace(all=lambda: [SimpleNamespace(id="app-1", name="Survey Form")]) + + @asynccontextmanager + async def db_session(): + yield Session() + + monkeypatch.setattr("bisheng.core.database.get_async_db_session", lambda: db_session()) + + labels = await TenantPermissionSubjectDirectory().resource_display_names((("app", "app-1"),)) + + assert labels == {("app", "app-1"): "Survey Form"} + assert len(statements) == 1 + assert statements[0].column_descriptions[0]["entity"] is App + compiled = str(statements[0].compile(compile_kwargs={"literal_binds": True})) + assert "app.id IN ('app-1')" in compiled + assert "app.state != 'deleted'" in compiled diff --git a/src/backend/test/permission/test_f048_listing_batch_preparation.py b/src/backend/test/permission/test_f048_listing_batch_preparation.py new file mode 100644 index 0000000000..5becc4cc56 --- /dev/null +++ b/src/backend/test/permission/test_f048_listing_batch_preparation.py @@ -0,0 +1,178 @@ +"""F116 bounded preparation and security gates for verified listing batches.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from bisheng.common.errcode.permission import ( + InvalidCatalogActionError, + PermissionFGAUnavailableError, + PermissionProjectionFailedError, + PermissionPublishNotReadyError, +) +from bisheng.permission.application import business_authorization, sql_runtime +from bisheng.permission.application.business_authorization import batch_check_verified_business_actions +from bisheng.permission.application.sql_runtime import ( + HIGHER_CONSISTENCY, + RECENT_CATALOG_MARKER, + RECENT_MARKER_SENTINEL, + RedisConsistencyMarker, +) +from test.permission.test_f048_permission_service import _actor, _service, _target + + +class BatchMarker: + def __init__(self, *, result=None, error=None): + self.calls = [] + self.result = result + self.error = error + + async def consistency_for_batch(self, targets): + self.calls.append(targets) + if self.error: + raise self.error + return self.result + + +@pytest.mark.parametrize("action", ["edit", "visible"]) +async def test_ac03_one_catalog_fence_marker_and_fga_call_per_100_targets(action): + """AC-03: per-target Catalog/fence/Redis N+1 preparation is removed.""" + service, catalog, fence, _, fga, _, _ = _service() + marker = BatchMarker() + service._marker = marker + targets = tuple(_target(str(i)) for i in range(100)) + if action == "visible": + await service.batch_check_visible(_actor(), targets) + else: + await service.batch_check_actions(_actor(), targets, action) + assert catalog.calls == (["ready", ("action", "workflow", "edit")] if action == "edit" else ["ready"]) + assert fence.calls == [] + assert len(fence.batch_calls) == len(marker.calls) == len(fga.batches) == 1 + assert len(fga.batches[0][0]) == 100 + + +@pytest.mark.parametrize("marker_error", [RuntimeError("redis unavailable"), None]) +async def test_ac04_marker_error_and_recent_changes_require_higher_consistency(marker_error): + """AC-04: batch marker errors never relax consistency or grant permission.""" + service, _, _, _, fga, _, _ = _service() + marker = BatchMarker(result=HIGHER_CONSISTENCY, error=marker_error) + service._marker = marker + assert await service.batch_check_actions(_actor(), (_target("allow"), _target("deny")), "edit") == (True, False) + assert fga.batches[0][1] == HIGHER_CONSISTENCY + + +async def test_ac04_stale_projection_is_excluded_and_recoverable_projection_forces_higher(): + """AC-04: mixed healthy/stale/degraded targets retain individual fence results.""" + service, _, fence, _, fga, _, _ = _service() + marker = BatchMarker() + service._marker = marker + fence.ensure_readable_batch = AsyncMock(return_value=(False, PermissionPublishNotReadyError(), True)) + result = await service.batch_check_actions(_actor(), (_target("allow"), _target("stale"), _target("deny")), "edit") + assert result == (True, False, False) + assert [check["object"] for check in fga.batches[0][0]] == ["workflow:allow", "workflow:deny"] + assert fga.batches[0][1] == HIGHER_CONSISTENCY + assert marker.calls == [] # the entire batch is already forced to higher + + +async def test_ac04_catalog_and_fence_failures_stop_before_openfga(): + """AC-04: unavailable actions and incomplete fence replies fail closed.""" + service, catalog, fence, _, fga, _, _ = _service() + with pytest.raises(InvalidCatalogActionError): + await service.batch_check_actions(_actor(), (_target(),), "download") + assert fga.batches == [] + fence.ensure_readable_batch = AsyncMock(return_value=()) + with pytest.raises(PermissionProjectionFailedError): + await service.batch_check_actions(_actor(), (_target(),), "edit") + assert fga.batches == [] + catalog.ready = False + with pytest.raises(PermissionPublishNotReadyError): + await service.batch_check_actions(_actor(), (_target(),), "edit") + + +async def test_ac04_identity_shortcuts_and_foreign_tenant_skip_batch_io(): + """AC-04: existing identity shortcuts and ordinary tenant denials are retained.""" + service, catalog, fence, _, fga, _, _ = _service() + service._marker = BatchMarker() + assert await service.batch_check_actions(_actor(super_admin=True), (_target(),), "edit") == (True,) + assert await service.batch_check_actions(_actor(), (_target(tenant_id=8),), "edit") == (False,) + assert catalog.calls == fence.calls == fence.batch_calls == fga.batches == [] + + +@pytest.mark.parametrize("action", ["edit", "manage_permission"]) +async def test_ac03_verified_facade_chunks_and_never_resolves_ids_again(monkeypatch, action): + """AC-03: already verified target batches stay bounded and aligned.""" + runtime = SimpleNamespace( + batch_check_actions=AsyncMock( + side_effect=lambda _actor, targets, _action: tuple(t.resource_id != "2" for t in targets) + ) + ) + monkeypatch.setattr(business_authorization, "get_f048_runtime", AsyncMock(return_value=runtime)) + targets = tuple(_target(str(i)) for i in range(201)) + result = await batch_check_verified_business_actions( + actor=_actor(), targets=targets + targets[:1], actions=(action, action) + ) + assert [len(call.args[1]) for call in runtime.batch_check_actions.await_args_list] == [100, 100, 1] + assert result[("workflow", "1")] == {action} + assert result[("workflow", "2")] == frozenset() + + +@pytest.mark.parametrize("error", [InvalidCatalogActionError(), PermissionFGAUnavailableError(), None]) +async def test_ac04_verified_facade_disabled_action_vs_runtime_failure(monkeypatch, error): + """AC-04: disabled actions are absent; runtime/incomplete failures propagate.""" + runtime = SimpleNamespace(batch_check_actions=AsyncMock(return_value=(), side_effect=error)) + monkeypatch.setattr(business_authorization, "get_f048_runtime", AsyncMock(return_value=runtime)) + if isinstance(error, InvalidCatalogActionError): + result = await batch_check_verified_business_actions(actor=_actor(), targets=(_target(),), actions=("edit",)) + assert result[("workflow", "42")] == frozenset() + else: + with pytest.raises(PermissionFGAUnavailableError if error else PermissionPublishNotReadyError): + await batch_check_verified_business_actions(actor=_actor(), targets=(_target(),), actions=("edit",)) + + +async def test_ac04_catalog_action_disabled_mid_list_discards_earlier_batches(monkeypatch): + """AC-04: a disabled action cannot leave a partly authorized result.""" + runtime = SimpleNamespace(batch_check_actions=AsyncMock(side_effect=[(True,) * 100, InvalidCatalogActionError()])) + monkeypatch.setattr(business_authorization, "get_f048_runtime", AsyncMock(return_value=runtime)) + result = await batch_check_verified_business_actions( + actor=_actor(), targets=tuple(_target(str(i)) for i in range(101)), actions=("edit",) + ) + assert all(not actions for actions in result.values()) + + +@pytest.mark.parametrize("scope", ["none", "catalog", "tenant", "type", "resource", "unrelated"]) +async def test_ac03_redis_batch_marker_reads_sentinel_and_one_mget(monkeypatch, scope): + """AC-03/04: missing values may be omitted; only any matching marker matters.""" + marker = RedisConsistencyMarker(window_seconds=30) + values = {RECENT_MARKER_SENTINEL: "ready"} + keys = { + "catalog": RECENT_CATALOG_MARKER, + "tenant": marker._tenant_key(7), + "type": marker._resource_key(tenant_id=7, resource_type="workflow", resource_id=None), + "resource": marker._resource_key(tenant_id=7, resource_type="workflow", resource_id="4"), + "unrelated": marker._resource_key(tenant_id=8, resource_type="workflow", resource_id="4"), + } + if scope != "none": + values[keys[scope]] = "changed" + redis = SimpleNamespace( + aget=AsyncMock(side_effect=values.get), + amget=AsyncMock(side_effect=lambda requested: [values[key] for key in requested if key in values]), + ) + monkeypatch.setattr(sql_runtime, "get_redis_client", AsyncMock(return_value=redis)) + targets = tuple(_target(str(i)) for i in range(100)) + result = await marker.consistency_for_batch(targets) + assert result == (None if scope in {"none", "unrelated"} else HIGHER_CONSISTENCY) + redis.aget.assert_awaited_once_with(RECENT_MARKER_SENTINEL) + redis.amget.assert_awaited_once() + assert len(redis.amget.await_args.args[0]) == 103 # catalog + tenant + type + 100 IDs + + +async def test_ac04_redis_sentinel_recovery_keeps_higher_consistency(monkeypatch): + """AC-04: missing sentinel triggers recovery instead of accepting an empty MGET.""" + marker = RedisConsistencyMarker(window_seconds=30) + marker._ensure_recovery_started = AsyncMock() + redis = SimpleNamespace(aget=AsyncMock(return_value=None), amget=AsyncMock()) + monkeypatch.setattr(sql_runtime, "get_redis_client", AsyncMock(return_value=redis)) + assert await marker.consistency_for_batch((_target(),)) == HIGHER_CONSISTENCY + marker._ensure_recovery_started.assert_awaited_once_with(redis=redis) + redis.amget.assert_not_awaited() diff --git a/src/backend/test/permission/test_f048_membership_reactivation.py b/src/backend/test/permission/test_f048_membership_reactivation.py new file mode 100644 index 0000000000..619441eb4c --- /dev/null +++ b/src/backend/test/permission/test_f048_membership_reactivation.py @@ -0,0 +1,240 @@ +"""Exercise membership reactivation through real SQL state and projection finalizers.""" + +from dataclasses import asdict, replace +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from sqlmodel import select + +from bisheng.common.errcode.permission import PermissionVersionConflictError +from bisheng.permission.application import control_state +from bisheng.permission.application.control_state import SqlGrantMutationState, SqlPermissionControlState +from bisheng.permission.application.runtime import F048PermissionRuntime +from bisheng.permission.application.sql_runtime import SqlProjectionFinalizer, SqlProjectionScopeGuard +from bisheng.permission.domain.models import ( + PermissionGrant, + PermissionGrantAssignee, + PermissionVisibleSourceProjection, + ResourcePermissionMode, +) +from bisheng.permission.domain.repositories.projection_repository import ProjectionRepository +from bisheng.permission.domain.schemas import VerifiedPermissionTarget +from bisheng.permission.domain.services.grant_service import GrantService +from bisheng.permission.domain.services.grant_source_service import ( + GrantModelSnapshot, + GrantSnapshot, + GrantSourceService, +) +from bisheng.permission.domain.services.permission_action_service import PermissionActor +from bisheng.permission.domain.services.projection_service import ProjectionService +from bisheng.permission.domain.services.visibility_projection_service import VisibilityProjectionCompiler +from test.permission.test_f048_projection_service import FakeFGAProjection, FakeRecentMarker +from test.permission.test_f048_projection_sql_runtime import session_factory as session_factory +from test.permission.test_f048_projection_sql_runtime import tenant_context as tenant_context + + +@pytest.mark.parametrize( + "model_path", [("viewer", None, "viewer", None, "viewer", None), ("viewer", "manager", None, "viewer", None)] +) +@pytest.mark.parametrize( + "resource_type,source_type", [("knowledge_space", "SPACE_MEMBERSHIP"), ("channel", "CHANNEL_MEMBERSHIP")] +) +async def test_join_leave_rejoin_keeps_versions_and_removes_visibility( + session_factory, monkeypatch, resource_type, source_type, model_path +): + monkeypatch.setattr(control_state, "get_async_db_session", session_factory) + state = SqlPermissionControlState() + # Catalog publication and Redis are outside this regression; SQL grants, + # reservation, tuple ledger and both finalizers use production code. + monkeypatch.setattr(state, "_assert_catalog_writable", AsyncMock()) + owner_visible = ("user:9", "visible", f"{resource_type}:42") + member_visible = ("user:11", "visible", f"{resource_type}:42") + fga = FakeFGAProjection({owner_visible}) + projection = ProjectionService( + repository=ProjectionRepository(session_factory), + marker=FakeRecentMarker(), + scope_guard=SqlProjectionScopeGuard(), + fga=fga, + finalizer=SqlProjectionFinalizer(), + ) + sources = GrantSourceService() + runtime = object.__new__(F048PermissionRuntime) + runtime._state = state + runtime._grants = GrantService(source_service=sources, projection=projection, state=SqlGrantMutationState(state)) + viewer = GrantModelSnapshot(model_key="viewer", active=True, action_codes=("visible", "use"), derived_level=1) + manager = replace(viewer, model_key="manager", derived_level=3) + owner_model = replace(viewer, model_key="owner", derived_level=4) + runtime._runtime_catalog = AsyncMock( + return_value=SimpleNamespace( + release_id=1, + store_id="store", + model_id="model", + models=( + SimpleNamespace(snapshot=viewer), + SimpleNamespace(snapshot=manager), + SimpleNamespace(snapshot=owner_model), + ), + ) + ) + runtime._grant_capabilities = AsyncMock(return_value=()) + actor = PermissionActor(user_id=11, current_tenant_id=7) + async with session_factory() as session: + async with session.begin(): + session.add( + ResourcePermissionMode( + tenant_id=7, + resource_type=resource_type, + resource_id="42", + mode="CUSTOM", + version=1, + projection_state="CURRENT", + ) + ) + + owner = sources.canonicalize_source( + source_id=102, + subject_type="user", + subject_id="9", + source_type="CREATOR", + source_ref=f"{resource_type}:42", + protected=True, + ) + async with session_factory() as session: + async with session.begin(): + owner_grant = PermissionGrant( + tenant_id=7, + resource_type=resource_type, + resource_id="42", + model_key="owner", + state="ACTIVE", + projection_state="CURRENT", + ) + session.add(owner_grant) + await session.flush() + await state._upsert_assignee(session, grant_row=owner_grant, source=owner, state="ACTIVE") + snapshot = GrantSnapshot( + grant_id="owner", + tenant_id=7, + resource_type=resource_type, + resource_id="42", + model=owner_model, + active=True, + sources=(owner,), + ) + contribution = ( + VisibilityProjectionCompiler() + .compile(tenant_id=7, grants=(snapshot,), existing_sources=()) + .active_sources[0] + ) + session.add(PermissionVisibleSourceProjection(**contribution.model_dump())) + + source = sources.canonicalize_source( + source_id=1, subject_type="user", subject_id="11", source_type=source_type, source_ref="42:user:11" + ) + + async def target(): + async with session_factory() as session: + row = (await session.exec(select(ResourcePermissionMode))).one() + return VerifiedPermissionTarget.from_business_service( + tenant_id=7, + resource_type=resource_type, + resource_id="42", + resource_version=row.version, + context_version=f"ctx-{row.version}", + ) + + stable_id = None + for index, model_key in enumerate(model_path): + result = await runtime.sync_business_source_model( + actor=actor, + target=await target(), + source=source, + model_key=model_key, + idempotency_key=f"membership-{index}", + ) + assert result is not None + async with session_factory() as session: + assignee = ( + await session.exec(select(PermissionGrantAssignee).where(PermissionGrantAssignee.subject_id == "11")) + ).one() + visible = list( + ( + await session.exec( + select(PermissionVisibleSourceProjection).where( + PermissionVisibleSourceProjection.projected_subject == "user:11" + ) + ) + ).all() + ) + assert assignee.version == index + 1 + stable_id = stable_id or assignee.id + assert assignee.id == stable_id + if model_key is None: + assert assignee.state == "INACTIVE" + assert all(row.state == "RETIRED" for row in visible) + else: + assert assignee.state == "ACTIVE" + active = [row for row in visible if row.state == "ACTIVE"] + assert len(active) == 1 + assert active[0].model_key == model_key + assert active[0].source_version == assignee.version + assert (member_visible in fga.present) == (model_key is not None) + assert owner_visible in fga.present + async with session_factory() as session: + creator = await session.get(PermissionGrantAssignee, owner.source_id) + assert creator.state == "ACTIVE" and creator.version == 1 + calls = len(fga.calls) + assert ( + await runtime.sync_business_source_model( + actor=actor, + target=await target(), + source=source, + model_key=model_key, + idempotency_key=f"repeat-{index}", + ) + is None + ) + assert len(fga.calls) == calls + + +@pytest.mark.parametrize("source_version,accepted", [(1, False), (2, False), (3, True), (4, False)]) +async def test_inactive_assignee_requires_next_version_before_projection(session_factory, source_version, accepted): + source = GrantSourceService().canonicalize_source( + source_id=101, subject_type="user", subject_id="11", source_type="SPACE_MEMBERSHIP", source_ref="42:user:11" + ) + async with session_factory() as session: + async with session.begin(): + grant = PermissionGrant( + tenant_id=7, + resource_type="knowledge_space", + resource_id="42", + model_key="viewer", + state="INACTIVE", + projection_state="CURRENT", + ) + session.add(grant) + await session.flush() + row = PermissionGrantAssignee( + tenant_id=7, + grant_id=grant.id, + state="INACTIVE", + **{ + key: value for key, value in asdict(source).items() if key not in {"source_id", "active", "version"} + }, + id=101, + version=2, + ) + session.add(row) + await session.flush() + if accepted: + restored = await state_upsert(session, grant, replace(source, version=source_version)) + assert restored.version == 3 + else: + with pytest.raises(PermissionVersionConflictError): + await state_upsert(session, grant, replace(source, version=source_version)) + assert row.version == 2 and row.state == "INACTIVE" + + +async def state_upsert(session, grant, source): + return await SqlPermissionControlState._upsert_assignee(session, grant_row=grant, source=source, state="PENDING") diff --git a/src/backend/test/permission/test_f048_migration_coordinator.py b/src/backend/test/permission/test_f048_migration_coordinator.py index 836b0b0c09..ee0967c708 100644 --- a/src/backend/test/permission/test_f048_migration_coordinator.py +++ b/src/backend/test/permission/test_f048_migration_coordinator.py @@ -492,6 +492,55 @@ async def test_formal_migration_compiles_one_single_slot_visible_aggregate() -> ) +async def test_formal_migration_retires_group_admin_manager_without_new_grant_or_visibility() -> None: + base = _snapshot() + admin = LegacyTupleSource( + tenant_id=7, + user="user_group:8#admin", + relation="manager", + object="workflow:wf-1", + ) + member = replace(admin, user="user_group:8#member", relation="viewer") + snapshot = replace(base, tuples=(*base.tuples, admin, member)) + store = FakeRunStore(frozen_snapshot=snapshot) + writer = FakeTargetWriter() + coordinator = F048MigrationCoordinator( + source_provider=FakeSourceProvider(snapshot), + run_store=store, + model_publisher=FakeModelPublisher(), + target_writer=writer, + ) + + result = await coordinator.migrate(expected_store_id="store-live", lock_token="operator-1") + + assert result.phase == "VERIFYING" + assert all(row["user"] != admin.user for row in writer.written_tuples) + member_grants = [ + row["object"] + for row in writer.written_tuples + if row["user"] == member.user and row["relation"] == "ordinary_assignee" + ] + assert len(member_grants) == 1 + assert { + "user": "permission_model:viewer", + "relation": "model", + "object": member_grants[0], + } in writer.written_tuples + assert { + "user": member.user, + "relation": "visible", + "object": member.object, + } in writer.written_tuples + assert { + "user": admin.user, + "relation": admin.relation, + "object": admin.object, + } in writer.deleted_tuples + assert any(item.difference_type == "LEGACY_USER_GROUP_ADMIN_MANAGER_RETIRED" for item in store.items) + events = [event[0] for event in writer.events] + assert events.index("verify") < events.index("delete") + + async def test_formal_migration_maintains_both_subject_types_at_every_hierarchy_level(): base = _snapshot() nodes = ( diff --git a/src/backend/test/permission/test_f048_migration_source_ports.py b/src/backend/test/permission/test_f048_migration_source_ports.py index d8ae5d0869..479eb232e4 100644 --- a/src/backend/test/permission/test_f048_migration_source_ports.py +++ b/src/backend/test/permission/test_f048_migration_source_ports.py @@ -188,7 +188,6 @@ async def test_tool_is_system_owned_but_dashboards_are_user_owned(): status="ACTIVE", owner_user_id=None, preset=True, - system_allowlisted=True, ), ) ) @@ -226,16 +225,15 @@ async def test_tool_is_system_owned_but_dashboards_are_user_owned(): assert dashboard_page.items[1].owner_user_id == 11 -async def test_invalid_system_predicate_is_exported_as_user_fact_for_inventory_blocking(): +async def test_preset_tool_creator_does_not_change_system_ownership(): repository = FakeRepository( ( ToolMigrationRow( tenant_id=7, resource_id="9", status="ACTIVE", - owner_user_id=None, + owner_user_id=11, preset=True, - system_allowlisted=False, ), ) ) @@ -245,5 +243,6 @@ async def test_invalid_system_predicate_is_exported_as_user_fact_for_inventory_b limit=50, ) - assert page.items[0].ownership_kind == "USER" - assert page.items[0].owner_user_id is None + assert page.items[0].ownership_kind == "SYSTEM" + assert page.items[0].system_allowlisted is True + assert page.items[0].owner_user_id == 11 diff --git a/src/backend/test/permission/test_f048_owner_projection.py b/src/backend/test/permission/test_f048_owner_projection.py index 04de07d2a6..eee10fdc74 100644 --- a/src/backend/test/permission/test_f048_owner_projection.py +++ b/src/backend/test/permission/test_f048_owner_projection.py @@ -454,10 +454,35 @@ async def test_system_owned_requires_code_allowlist_and_business_predicate() -> ), ) + sources = GrantSourceService() + public_model = GrantModelSnapshot(model_key="viewer", active=True, action_codes=("use",), derived_level=1) + public_grant = GrantSnapshot( + grant_id="public-tool", + tenant_id=5, + resource_type="tool", + resource_id="preset-1", + model=public_model, + active=False, + sources=(), + ) + mutation = sources.add_source( + public_grant, + sources.canonicalize_source( + source_id=100, + subject_type="tenant", + subject_id="5", + source_type="TENANT", + ), + ) + valid = replace(valid, creation_grants=(mutation.grant,), creation_deltas=mutation.deltas) result = await service.project_created(valid) assert result.grant is None assert any(delta.relation == "visible" for delta in projection.plans[0].deltas) - assert any(delta.relation == "system_use_marker" for delta in projection.plans[0].deltas) + assert all(delta.relation != "system_use_marker" for delta in projection.plans[0].deltas) + assert any( + delta.user == "tenant:5#member" and delta.relation == "ordinary_assignee" + for delta in projection.plans[0].deltas + ) for invalid in ( replace(valid, system_predicate=False), diff --git a/src/backend/test/permission/test_f048_performance_contract.py b/src/backend/test/permission/test_f048_performance_contract.py index 1fe6cfb594..c3e997e4d9 100644 --- a/src/backend/test/permission/test_f048_performance_contract.py +++ b/src/backend/test/permission/test_f048_performance_contract.py @@ -44,7 +44,7 @@ def test_fixed_fixture_covers_resource_visibility_and_source_matrix( contract: dict, ) -> None: assert contract["contract_version"] == CONTRACT_VERSION - assert contract_checksum(contract) == ("0290180531d4f286b5a43540557b750a26465373d3cad0b18d6c64a1dd677ade") + assert contract_checksum(contract) == ("36e8c2200be2449d40891ca3409ff93d728f56ba677e49b89dea5efb7651298e") assert dataset_checksum(contract) == ("d083c56febda9eb055e9ae5356f800ca837a1e534628fdeda623dc4b8063ab22") assert source_checksum(contract) == ("9e2e7699dbe65851de0b04651ee8bdb009e2a85ac16e8c12a5b9e5092c220df0") assert visible_checksum(contract) == ("f185c204418fbd98d41c15b0bac7759060c2df73da8c8c4fcf381f0a02c1994e") diff --git a/src/backend/test/permission/test_f048_permission_explain_service.py b/src/backend/test/permission/test_f048_permission_explain_service.py index de36ce20d1..ba5b37fb47 100644 --- a/src/backend/test/permission/test_f048_permission_explain_service.py +++ b/src/backend/test/permission/test_f048_permission_explain_service.py @@ -213,7 +213,8 @@ async def test_without_roster_permission_only_actor_sources_are_returned() -> No @pytest.mark.asyncio -async def test_inactive_model_or_grant_never_appears_in_explanation() -> None: +@pytest.mark.parametrize("inherited", [False, True]) +async def test_inactive_model_keeps_existing_explanation(inherited) -> None: sources = _sources() source = sources.canonicalize_source( source_id=1, @@ -229,8 +230,28 @@ async def test_inactive_model_or_grant_never_appears_in_explanation() -> None: grant, model=replace(grant.model, active=False), ) - inactive_grant = replace(grant, active=False) - for candidate in (inactive_model, inactive_grant): + explanation = await PermissionExplainService().explain( + PermissionExplainContext( + tenant_id=7, + resource_type="folder", + resource_id="42", + resource_version=3, + mode="INHERIT" if inherited else "CUSTOM", + parent_type="knowledge_space", + parent_id="10", + local_grants=() if inherited else (inactive_model,), + inherited=InheritedGrantSet("knowledge_space", "10", (inactive_model,)) if inherited else None, + actor_projected_subjects=frozenset({"user:100"}), + can_manage_roster=True, + ) + ) + assert [row.source_id for row in explanation.sources] == [1] + assert explanation.action_codes == ("edit",) + assert explanation.sources[0].editable is not inherited + for candidate in ( + replace(inactive_model, active=False), + replace(inactive_model, sources=(replace(source, active=False),)), + ): explanation = await PermissionExplainService().explain( PermissionExplainContext( tenant_id=7, diff --git a/src/backend/test/permission/test_f048_preset_dashboard_bootstrap.py b/src/backend/test/permission/test_f048_preset_dashboard_bootstrap.py index e347a990c0..fe14d55dec 100644 --- a/src/backend/test/permission/test_f048_preset_dashboard_bootstrap.py +++ b/src/backend/test/permission/test_f048_preset_dashboard_bootstrap.py @@ -101,3 +101,36 @@ async def test_only_first_user_creation_triggers_sample_projection(monkeypatch) await UserService.areconcile_first_user_dashboard_permissions(1) reconcile.assert_awaited_once_with() + + +async def test_first_admin_dashboard_projection_has_a_tenant_without_request_context(monkeypatch): + from bisheng.core.context import tenant + + dashboards = (_dashboard(10),) + current_modes = {"10": SimpleNamespace(resource_id="10", projection_state="CURRENT")} + monkeypatch.setattr( + bootstrap, + "_load_preset_dashboards_and_modes", + AsyncMock(side_effect=[(dashboards, {}), (dashboards, current_modes)]), + ) + monkeypatch.setattr(bootstrap, "_owner_is_ready", AsyncMock(return_value=True)) + marker = SimpleNamespace(wait_until_ready=AsyncMock()) + facade = SimpleNamespace(authorize_created=AsyncMock()) + + async def project(**kwargs): + assert tenant.get_current_tenant_id() == kwargs["target"].tenant_id == 1 + assert tenant.get_visible_tenant_ids() == frozenset({1}) + assert not tenant.is_tenant_filter_bypassed() + + facade.authorize_created.side_effect = project + monkeypatch.setattr( + bootstrap, + "get_f048_process_runtime", + AsyncMock(return_value=SimpleNamespace(components=SimpleNamespace(marker=marker, facade=facade))), + ) + token = tenant.current_tenant_id.set(None) + try: + assert (await bootstrap.reconcile_preset_dashboard_permissions()).current_count == 1 + assert tenant.current_tenant_id.get() is None + finally: + tenant.current_tenant_id.reset(token) diff --git a/src/backend/test/permission/test_f048_projection_sql_runtime.py b/src/backend/test/permission/test_f048_projection_sql_runtime.py index 9772b3bb15..d32bd340ec 100644 --- a/src/backend/test/permission/test_f048_projection_sql_runtime.py +++ b/src/backend/test/permission/test_f048_projection_sql_runtime.py @@ -70,6 +70,7 @@ async def session_factory( metadata = sa.MetaData() for name in ( "permission_projection_operation", + "permission_projection_tuple", "permission_migration_run", "permission_migration_item", "permission_visible_source_projection", diff --git a/src/backend/test/permission/test_f048_repositories.py b/src/backend/test/permission/test_f048_repositories.py index 8b703f2d28..a92e87ad87 100644 --- a/src/backend/test/permission/test_f048_repositories.py +++ b/src/backend/test/permission/test_f048_repositories.py @@ -74,6 +74,24 @@ async def session_factory() -> AsyncIterator[SessionFactory]: async with engine.begin() as connection: await connection.run_sync(test_metadata.create_all) + # ``register_tenant_filter_events()`` attaches its ``do_orm_execute`` / + # ``before_flush`` listeners to the global SQLModel ``Session`` class — + # there is no per-engine scoping, so once registered they intercept + # *every* Session in the process, including other test files' private + # SQLite engines. Reset the module state before and after (mirroring + # test/tenant/test_tenant_filter.py::filter_engine and the other + # register_tenant_filter_events() callers) so this fixture's table + # registration doesn't survive past this test — the listener itself + # can't be un-registered (SQLAlchemy has no handle to remove a + # nested-closure listener), but with ``_tenant_aware_tables`` emptied + # back out, `_get_tenant_tables_from_statement` finds nothing to filter + # and every later query is a no-op through it. Without this, this was + # the one register_tenant_filter_events() caller in the suite that + # leaked: any query afterwards against a real tenant-aware table (e.g. + # test/audit's ``auditlog``) got a surprise ``tenant_id`` WHERE clause + # injected by a listener the test never expected to be active. + tenant_filter._initialized = False + tenant_filter._tenant_aware_tables = set() tenant_filter._tenant_aware_tables.update( { "permission_grant", @@ -93,6 +111,8 @@ async def factory() -> AsyncIterator[AsyncSession]: yield session yield factory + tenant_filter._initialized = False + tenant_filter._tenant_aware_tables = set() await engine.dispose() @@ -369,24 +389,21 @@ async def test_visible_source_contribution_uniqueness_reference_count_and_retire session_factory: SessionFactory, ) -> None: repository = ProjectionRepository(session_factory) - first = await repository.aupsert_visible_source( - _visible_source(owner="grant_assignee:1", fingerprint_char="a") - ) - duplicate = await repository.aupsert_visible_source( - _visible_source(owner="grant_assignee:1", fingerprint_char="a") - ) - second = await repository.aupsert_visible_source( - _visible_source(owner="grant_assignee:2", fingerprint_char="b") - ) + first = await repository.aupsert_visible_source(_visible_source(owner="grant_assignee:1", fingerprint_char="a")) + duplicate = await repository.aupsert_visible_source(_visible_source(owner="grant_assignee:1", fingerprint_char="a")) + second = await repository.aupsert_visible_source(_visible_source(owner="grant_assignee:2", fingerprint_char="b")) assert duplicate.id == first.id assert second.id != first.id - assert await repository.acount_active_visible_sources( - resource_type="knowledge_space", - resource_id="100", - visibility_class="ordinary", - projected_subject="user:7", - ) == 2 + assert ( + await repository.acount_active_visible_sources( + resource_type="knowledge_space", + resource_id="100", + visibility_class="ordinary", + projected_subject="user:7", + ) + == 2 + ) checksum_before = await repository.aget_visible_source_checksum(states=("ACTIVE",)) assert checksum_before is not None @@ -400,12 +417,15 @@ async def test_visible_source_contribution_uniqueness_reference_count_and_retire expected_source_version=1, operation_id=None, ) - assert await repository.acount_active_visible_sources( - resource_type="knowledge_space", - resource_id="100", - visibility_class="ordinary", - projected_subject="user:7", - ) == 1 + assert ( + await repository.acount_active_visible_sources( + resource_type="knowledge_space", + resource_id="100", + visibility_class="ordinary", + projected_subject="user:7", + ) + == 1 + ) assert await repository.aget_visible_source_checksum(states=("ACTIVE",)) != checksum_before diff --git a/src/backend/test/permission/test_f048_resource_api.py b/src/backend/test/permission/test_f048_resource_api.py index 25d8e5c72b..1660a480ea 100644 --- a/src/backend/test/permission/test_f048_resource_api.py +++ b/src/backend/test/permission/test_f048_resource_api.py @@ -6,6 +6,7 @@ import pytest +from bisheng.common.errcode.permission import PermissionVersionConflictError from bisheng.permission.application.resource_api import ( F048ResourcePermissionApi, ) @@ -74,6 +75,7 @@ class _Runtime: def __init__(self) -> None: self.changes = () self.page_calls = [] + self.model_active = True async def allocate_source_ids(self, count): assert count == 1 @@ -84,7 +86,7 @@ async def mutate_grants(self, **kwargs): model = SimpleNamespace( model_key="viewer", derived_level=1, - active=True, + active=self.model_active, ) source = SimpleNamespace( source_id=91, @@ -110,9 +112,12 @@ async def mutate_grants(self, **kwargs): async def require_manage_permission(self, actor, target): del actor, target + async def manageable_model_keys(self, **kwargs): + return frozenset({"viewer"}) + async def current_catalog(self): model = SimpleNamespace( - snapshot=SimpleNamespace(model_key="viewer"), + snapshot=SimpleNamespace(model_key="viewer", active=self.model_active), name="Viewer", ) return SimpleNamespace(release_id=12, models=(model,)) @@ -250,6 +255,24 @@ async def effective_actions(self, resource_type): return ("use", "edit", "delete", "manage_permission") +class _OrdinaryContextRuntime(_ContextRuntime): + """Denies BOTH ``visible`` and ``manage_permission``. + + ``_ContextRuntime`` allows every non-visible action, which makes its actor a + manager — and ``get_context`` lets a manager (owner, granted manager, or an + admin via check_action's identity shortcut) in without a visible tuple, so + that fake cannot model an ordinary user. This one can: no management right, + no visibility, therefore no entry. + """ + + async def check_action(self, actor, target, action): + if action in {"visible", "manage_permission"}: + if action == "visible": + self.visible_checks += 1 + return False + return True + + class _ExplainRuntime(_ContextRuntime): """Visible via FGA, but the grant-derived explanation is empty. @@ -319,7 +342,7 @@ async def test_tenant_admin_reads_context_for_own_tenant_without_visible_tuple() async def test_ordinary_user_context_still_requires_a_visible_tuple() -> None: from bisheng.common.errcode.permission import PermissionDeniedError - runtime = _ContextRuntime() + runtime = _OrdinaryContextRuntime() api = F048ResourcePermissionApi( resources=_Resources(), runtime=runtime, @@ -361,25 +384,15 @@ async def test_super_admin_my_permissions_returns_full_effective_actions() -> No @pytest.mark.asyncio -async def test_ordinary_user_my_permissions_for_preset_tool_returns_empty_actions() -> None: +async def test_revoked_user_my_permissions_for_preset_tool_requires_visibility() -> None: + from bisheng.common.errcode.permission import PermissionDeniedError + runtime = _ContextRuntime() - api = F048ResourcePermissionApi( - resources=_ToolResources(), - runtime=runtime, - subjects=_Subjects(), - ) + api = F048ResourcePermissionApi(resources=_ToolResources(), runtime=runtime, subjects=_Subjects()) actor = PermissionActor(user_id=7, current_tenant_id=9) - - result = await api.get_my_permissions( - resource_type="tool", - resource_id="20", - actor=actor, - ) - - assert result["actions"] == [] - assert result["sources"] == [] - assert result["projection_degraded"] is False - assert runtime.visible_checks == 0 + with pytest.raises(PermissionDeniedError): + await api.get_my_permissions(resource_type="tool", resource_id="20", actor=actor) + assert runtime.visible_checks == 1 @pytest.mark.asyncio @@ -523,3 +536,87 @@ async def test_roster_uses_bounded_sql_page_instead_of_full_explanation() -> Non "limit": 25, } ] + + +@pytest.mark.parametrize("keyword", ["", "Reviewers"]) +async def test_inactive_existing_model_is_returned_in_roster_and_search(keyword): + runtime = _Runtime() + runtime.model_active = False + api = F048ResourcePermissionApi(resources=_Resources(), runtime=runtime, subjects=_Subjects()) + result = await api.list_grants( + resource_type="workflow", + resource_id="wf-1", + actor=PermissionActor(user_id=7, current_tenant_id=9), + cursor=None, + page_size=1, + keyword=keyword, + ) + assert result["data"][0]["model"]["active"] is False + assert result["data"][0]["model"]["name"] == "Viewer" + assert result["data"][0]["subject"]["name"] == "Reviewers" + assert result["has_more"] is True + + +async def test_roster_refuses_a_model_missing_from_its_catalog_snapshot(): + class Runtime(_Runtime): + async def current_catalog(self): + catalog = await super().current_catalog() + catalog.models = () + return catalog + + api = F048ResourcePermissionApi(resources=_Resources(), runtime=Runtime(), subjects=_Subjects()) + with pytest.raises(PermissionVersionConflictError): + await api.list_grants( + resource_type="workflow", + resource_id="wf-1", + actor=PermissionActor(user_id=7, current_tenant_id=9), + cursor=None, + page_size=1, + ) + + +async def test_mutation_response_keeps_other_existing_inactive_sources(): + class Runtime(_Runtime): + async def mutate_grants(self, **kwargs): + result = await super().mutate_grants(**kwargs) + result.grants += ( + SimpleNamespace( + model=SimpleNamespace(model_key="old-custom", derived_level=1, active=False), + active=True, + sources=( + SimpleNamespace( + source_id=92, + version=1, + subject_type="department", + subject_id="310", + source_type="DEPARTMENT", + include_children=False, + protected=False, + active=True, + ), + ), + ), + ) + return result + + async def manageable_model_keys(self, **kwargs): + return frozenset({"viewer", "old-custom"}) + + api = F048ResourcePermissionApi(resources=_Resources(), runtime=Runtime(), subjects=_Subjects()) + result = await api.mutate_grants( + resource_type="workflow", + resource_id="wf-1", + actor=PermissionActor(user_id=7, current_tenant_id=9), + request=GrantMutationRequest.model_validate( + { + "idempotency_key": "keep-existing-inactive", + "expected_resource_version": 3, + "expected_catalog_release_id": 12, + "changes": [{"op": "ADD", "model_key": "viewer", "subject": {"type": "user", "id": "8"}}], + } + ), + ) + assert len(result["items"]) == 2 + old = result["items"][1] + assert old["model"]["active"] is False and old["editable"] is True + assert old["subject"] == {"type": "department", "id": "310", "name": None} diff --git a/src/backend/test/permission/test_f048_schema_contract.py b/src/backend/test/permission/test_f048_schema_contract.py index 86dfb8fb05..7d2a984cdf 100644 --- a/src/backend/test/permission/test_f048_schema_contract.py +++ b/src/backend/test/permission/test_f048_schema_contract.py @@ -28,8 +28,18 @@ v3_0_0_f048_visible_source_projection as visible_revision, ) from bisheng.core.database.dialect_helpers import LargeText -from bisheng.core.openfga.authorization_model_f048 import build_authorization_model_f048 +from bisheng.core.openfga.authorization_model_f048 import ( + DEFAULT_ACTION_CODES, + FLAT_VISIBLE_RESOURCE_TYPES, + MIGRATED_RESOURCE_TYPES, + OWNER_PROJECTION_RESOURCE_TYPES, + PARENT_TYPES, + RESOURCE_ACTION_SCOPES, + SYSTEM_SHARED_ACTION_TYPES, + build_authorization_model_f048, +) from bisheng.permission.domain import models as permission_models +from bisheng.permission.domain.services import catalog_policy BACKEND_ROOT = Path(__file__).resolve().parents[2] REVISION_PATH = BACKEND_ROOT / "bisheng/core/database/alembic/versions/f048_permission_model_grants.py" @@ -143,11 +153,13 @@ def test_f048_unique_and_foreign_key_contract() -> None: def test_f048_revision_is_on_the_single_alembic_head_chain() -> None: - """One head, with the F048 revision on it. + """One head, and every F048 revision is on its ancestry — no parallel branch. - Asserts the property (no fork, F048 applied) rather than pinning the head to - F048 by name: every later migration legitimately becomes the new head, and a - name-pinned assertion would fail for each one while catching nothing extra. + Asserts the property rather than pinning the head to F048 by name: every + later migration legitimately becomes the new head, so a name-pinned + assertion fails for each one while catching nothing extra. (It used to pin + ``f048_migration_item_message_longtext``; F049's ``f049_user_user_type`` was + the first release it falsely failed.) """ config = Config(str(BACKEND_ROOT / "alembic.ini")) config.set_main_option( @@ -158,7 +170,14 @@ def test_f048_revision_is_on_the_single_alembic_head_chain() -> None: heads = script.get_heads() assert len(heads) == 1, f"alembic graph forked: {heads}" chain = {rev.revision for rev in script.walk_revisions("base", heads[0])} - assert "f048_visible_source_projection" in chain + # Both lines' F048 revisions: the grants/message pair from 3.0-vibe and the + # visibility projection from beta1. The merge is the first time all three + # have to be on one chain. + assert { + "f048_permission_grants", + "f048_migration_item_message_longtext", + "f048_visible_source_projection", + } <= chain def test_f048_visible_projection_revision_is_static_ddl_only() -> None: @@ -279,21 +298,87 @@ def test_service_account_is_only_an_ordinary_direct_grant_subject() -> None: ("knowledge_file", ("permission_enabled", "custom_mode", "inherit_mode")), ): for relation in relations: - allowed = definitions[type_name]["metadata"]["relations"][relation][ - "directly_related_user_types" - ] + allowed = definitions[type_name]["metadata"]["relations"][relation]["directly_related_user_types"] assert {entry["type"] for entry in allowed} == { "service_account", "user", } for relation in ("public_reader", "system_download_marker", "system_use_marker"): - allowed = definitions["knowledge_space"]["metadata"]["relations"][relation][ - "directly_related_user_types" - ] + allowed = definitions["knowledge_space"]["metadata"]["relations"][relation]["directly_related_user_types"] assert {entry["type"] for entry in allowed} == {"user"} +def test_catalog_policy_and_authorization_model_resource_lists_are_twins() -> None: + """The Catalog validator and the model builder enumerate the same resources. + + ``catalog_policy`` validates every Catalog row on snapshot load; the model + builder decides which object types OpenFGA accepts tuples for. A type in + one list but not the other fails only at runtime (Catalog reads raise, or + every tuple write 400s), so the two lists are held equal here — as whole + sets and per action, not just for the type that was added last. + """ + + assert set(MIGRATED_RESOURCE_TYPES) == catalog_policy.MIGRATED_RESOURCE_TYPES + assert set(DEFAULT_ACTION_CODES) == set(catalog_policy.REGISTERED_ACTION_CODES) + assert set(RESOURCE_ACTION_SCOPES) == set(DEFAULT_ACTION_CODES) + assert set(catalog_policy.ACTION_RESOURCE_SCOPES) == set(DEFAULT_ACTION_CODES) + for action in DEFAULT_ACTION_CODES: + assert RESOURCE_ACTION_SCOPES[action] == catalog_policy.ACTION_RESOURCE_SCOPES[action], action + assert RESOURCE_ACTION_SCOPES[action] <= set(MIGRATED_RESOURCE_TYPES), action + + # Every Catalog-scoped type is a modelled object type, so a Catalog row can + # never reference a type OpenFGA would reject. + model = build_authorization_model_f048() + modelled = {definition["type"] for definition in model["type_definitions"]} + assert catalog_policy.MIGRATED_RESOURCE_TYPES <= modelled + assert set(OWNER_PROJECTION_RESOURCE_TYPES) <= modelled + + # `app` (F054) is on both sides and is a flat, never-system-shared type. + assert "app" in MIGRATED_RESOURCE_TYPES + assert "app" in catalog_policy.MIGRATED_RESOURCE_TYPES + assert "app" in FLAT_VISIBLE_RESOURCE_TYPES + assert "app" not in PARENT_TYPES + assert all("app" not in shared_types for shared_types in SYSTEM_SHARED_ACTION_TYPES.values()) + + +def test_app_resource_type_is_shaped_like_the_other_flat_resource_types() -> None: + """The union model gives ``app`` every subject the other flat types have. + + ``app`` came from one line and the ``service_account`` subject type from + the other. The merge is correct only if the type that arrived last is + built by the same code path as its peers: same technical-marker subjects, + same ordinary assignees, same visibility subjects, no parent or inherit + relations. ``dashboard`` is the reference because, like ``app``, it is + flat and appears in no ``SYSTEM_SHARED_ACTION_TYPES`` entry. + """ + + model = build_authorization_model_f048() + definitions = {definition["type"]: definition for definition in model["type_definitions"]} + app = definitions["app"] + reference = dict(definitions["dashboard"], type="app") + assert app == reference + + metadata = app["metadata"]["relations"] + for relation in ("permission_enabled", "custom_mode"): + assert {entry["type"] for entry in metadata[relation]["directly_related_user_types"]} == { + "service_account", + "user", + } + assert {"type": "service_account"} in metadata["visible"]["directly_related_user_types"] + assert "parent" not in app["relations"] + assert "inherit_mode" not in app["relations"] + assert app["relations"]["visible"] == { + "union": { + "child": [ + {"this": {}}, + {"computedUserset": {"relation": "system_visible"}}, + ] + } + } + assert {f"can_{action}" for action in DEFAULT_ACTION_CODES} <= set(app["relations"]) + + def _qualified_name(node: ast.expr) -> str: if isinstance(node, ast.Name): return node.id diff --git a/src/backend/test/permission/test_f048_skill_grant_subjects.py b/src/backend/test/permission/test_f048_skill_grant_subjects.py new file mode 100644 index 0000000000..1ce486dacb --- /dev/null +++ b/src/backend/test/permission/test_f048_skill_grant_subjects.py @@ -0,0 +1,105 @@ +"""Skill permission pickers must authorize the resource and use its tenant.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import httpx +import pytest +from fastapi import FastAPI + +from bisheng.common.dependencies.user_deps import UserPayload +from bisheng.common.errcode.permission import PermissionDeniedError +from bisheng.permission.api.endpoints import grant_subjects +from bisheng.permission.domain.services.grant_subject_service import GrantSubjectScope + +PICKERS = [ + ( + "users?keyword=alice&page=2&page_size=10", + "list_candidate_users", + {"keyword": "alice", "page": 2, "page_size": 10}, + [], + ), + ( + "user-groups?keyword=team&page=2&page_size=10", + "list_candidate_user_groups", + {"keyword": "team", "page": 2, "page_size": 10}, + [], + ), + ("departments/children?parent_id=7", "list_candidate_department_layer", {"parent_id": 7}, []), + ( + "departments/search?keyword=team&limit=10", + "search_candidate_departments", + {"keyword": "team", "limit": 10}, + {"roots": []}, + ), + ("departments/7/path-tree", "get_candidate_department_path", {"dept_id": 7}, {"roots": []}), +] + + +@pytest.fixture() +async def picker(monkeypatch): + login_user = SimpleNamespace(tenant_id=99) + actor = SimpleNamespace(tenant_id=99) + registry = SimpleNamespace(resolve=AsyncMock(return_value=SimpleNamespace(tenant_id=42))) + monkeypatch.setattr(grant_subjects, "resolve_permission_actor", AsyncMock(return_value=actor)) + monkeypatch.setattr(grant_subjects, "get_f048_resource_registry", AsyncMock(return_value=registry)) + check = AsyncMock(return_value=True) + monkeypatch.setattr(grant_subjects, "check_business_action", check) + counts = [AsyncMock(return_value=0), AsyncMock(return_value=0)] + for name, count in zip(("count_candidate_users", "count_candidate_user_groups"), counts, strict=True): + monkeypatch.setattr(grant_subjects.grant_subject_service, name, count) + app = FastAPI() + app.include_router(grant_subjects.router, prefix="/api/v1/permissions") + app.dependency_overrides[UserPayload.get_login_user] = lambda: login_user + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + yield SimpleNamespace( + client=client, user=login_user, actor=actor, registry=registry, check=check, counts=counts + ) + + +@pytest.mark.parametrize(("path", "service_name", "kwargs", "result"), PICKERS) +async def test_skill_picker_authorizes_and_passes_resource_tenant( + picker, monkeypatch, path, service_name, kwargs, result +): + service = AsyncMock(return_value=result) + monkeypatch.setattr(grant_subjects.grant_subject_service, service_name, service) + + response = await picker.client.get(f"/api/v1/permissions/resources/linsight_skill/5/grant-subjects/{path}") + + assert response.status_code == 200 + assert response.json()["status_code"] == 200 + picker.registry.resolve.assert_awaited_once_with( + resource_type="linsight_skill", resource_id="5", actor=picker.actor, action="manage_permission" + ) + picker.check.assert_awaited_once_with( + picker.user, resource_type="linsight_skill", resource_id="5", action="manage_permission" + ) + service.assert_awaited_once_with(GrantSubjectScope(tenant_id=42, department_path=None), **kwargs) + + +@pytest.mark.parametrize(("path", "service_name", "kwargs", "result"), PICKERS) +@pytest.mark.parametrize("denial", ["no_manage_permission", "invalid_target", "unknown_type"]) +async def test_skill_picker_denials_do_not_query_candidates( + picker, monkeypatch, path, service_name, kwargs, result, denial +): + service = AsyncMock(return_value=result) + monkeypatch.setattr(grant_subjects.grant_subject_service, service_name, service) + resource_type = "linsight_skill" + if denial == "no_manage_permission": + picker.check.return_value = False + elif denial == "invalid_target": + picker.registry.resolve.side_effect = PermissionDeniedError() + else: + resource_type = "not_a_resource" + + response = await picker.client.get(f"/api/v1/permissions/resources/{resource_type}/5/grant-subjects/{path}") + + assert response.status_code == 200 + assert response.json()["status_code"] == 19000 + service.assert_not_awaited() + for count in picker.counts: + count.assert_not_awaited() + if denial != "no_manage_permission": + picker.check.assert_not_awaited() + if denial == "unknown_type": + picker.registry.resolve.assert_not_awaited() diff --git a/src/backend/test/permission/test_f048_system_resource_bootstrap.py b/src/backend/test/permission/test_f048_system_resource_bootstrap.py index d8a094fc17..2faa05edc8 100644 --- a/src/backend/test/permission/test_f048_system_resource_bootstrap.py +++ b/src/backend/test/permission/test_f048_system_resource_bootstrap.py @@ -7,6 +7,8 @@ from types import SimpleNamespace from unittest.mock import AsyncMock +import pytest + from bisheng.api.services import f048_system_resource_bootstrap as bootstrap from bisheng.permission.application.system_resource_reconcile import ( SystemOwnedReconcileReport, @@ -22,12 +24,13 @@ def all(self): return self._rows -async def test_inventory_requires_each_business_system_predicate(monkeypatch) -> None: +@pytest.mark.parametrize("owner_user_id", [None, 1]) +async def test_inventory_accepts_presets_regardless_of_creator(monkeypatch, owner_user_id) -> None: tool = GptsToolsType( id=6, name="Code Interpreter", is_preset=1, - user_id=None, + user_id=owner_user_id, tenant_id=1, is_delete=0, ) diff --git a/src/backend/test/permission/test_f048_system_resource_reconcile.py b/src/backend/test/permission/test_f048_system_resource_reconcile.py index d0bf1c6666..f0dc8977de 100644 --- a/src/backend/test/permission/test_f048_system_resource_reconcile.py +++ b/src/backend/test/permission/test_f048_system_resource_reconcile.py @@ -25,12 +25,17 @@ def _spec(resource_id: str = "6") -> SystemOwnedResourceSpec: ) +def _runtime(): + runtime = AsyncMock() + return runtime + + async def test_dry_run_reports_missing_without_projecting(monkeypatch) -> None: spec = _spec() missing = (SystemOwnedResourceState(resource=spec, state="MISSING"),) inspect = AsyncMock(return_value=missing) monkeypatch.setattr(reconcile_module, "inspect_system_owned_resources", inspect) - runtime = AsyncMock() + runtime = _runtime() report = await reconcile_system_owned_resources( runtime, @@ -70,7 +75,7 @@ async def test_apply_projects_only_missing_resources(monkeypatch) -> None: ) inspect = AsyncMock(side_effect=(before, after)) monkeypatch.setattr(reconcile_module, "inspect_system_owned_resources", inspect) - runtime = AsyncMock() + runtime = _runtime() report = await reconcile_system_owned_resources( runtime, @@ -102,7 +107,7 @@ async def test_non_current_resource_is_never_reinterpreted_as_missing(monkeypatc ) ) monkeypatch.setattr(reconcile_module, "inspect_system_owned_resources", inspect) - runtime = AsyncMock() + runtime = _runtime() with pytest.raises(PermissionPublishNotReadyError, match="non-current"): await reconcile_system_owned_resources( @@ -113,3 +118,76 @@ async def test_non_current_resource_is_never_reinterpreted_as_missing(monkeypatc ) runtime.authorize_system_owned.assert_not_awaited() + + +@pytest.mark.parametrize("fail", [False, True]) +async def test_bootstrap_binds_each_verified_tenant_and_restores_caller(monkeypatch, fail): + from dataclasses import replace + + from bisheng.core.context import tenant + + resources = (_spec("6"), replace(_spec("16"), tenant_id=2)) + missing = tuple(SystemOwnedResourceState(resource=one, state="MISSING") for one in resources) + current = tuple(SystemOwnedResourceState(resource=one, state="CURRENT") for one in resources) + monkeypatch.setattr(reconcile_module, "inspect_system_owned_resources", AsyncMock(side_effect=[missing, current])) + initial = [ + (tenant.current_tenant_id, None), + (tenant.visible_tenant_ids, frozenset({99})), + (tenant._admin_scope_tenant_id, 99), + (tenant._bypass_tenant_filter, False), + ] + tokens = [(variable, variable.set(value)) for variable, value in initial] + observed = [] + + async def project(**kwargs): + target = kwargs["target"] + assert tenant.get_current_tenant_id() == target.tenant_id + assert tenant.get_visible_tenant_ids() == frozenset({target.tenant_id}) + assert not tenant.is_tenant_filter_bypassed() + observed.append(target.tenant_id) + if fail: + raise RuntimeError("projection unavailable") + + try: + runtime = _runtime() + runtime.authorize_system_owned.side_effect = project + if fail: + with pytest.raises(RuntimeError, match="projection unavailable"): + await reconcile_system_owned_resources(runtime, resources, apply=True, operator_id=1) + else: + await reconcile_system_owned_resources(runtime, resources, apply=True, operator_id=1) + assert observed == [1, 2] + assert tenant.get_current_tenant_id() == 99 + assert tenant.current_tenant_id.get() is None + assert tenant.get_visible_tenant_ids() == frozenset({99}) + assert not tenant.is_tenant_filter_bypassed() + finally: + for variable, token in reversed(tokens): + variable.reset(token) + + +@pytest.mark.parametrize("resource_type", ["tool", "linsight_skill"]) +@pytest.mark.parametrize("apply", [False, True]) +async def test_current_resources_preserve_revoked_access_without_legacy_marker_calls(monkeypatch, resource_type, apply): + from dataclasses import replace + from types import SimpleNamespace + + spec = replace(_spec("902"), resource_type=resource_type) + states = (SystemOwnedResourceState(resource=spec, state="CURRENT", mode="CUSTOM", version=3),) + monkeypatch.setattr(reconcile_module, "inspect_system_owned_resources", AsyncMock(return_value=states)) + # Expose only the supported creation API. A legacy tuple read or repair fails. + runtime = SimpleNamespace(authorize_system_owned=AsyncMock()) + report = await reconcile_system_owned_resources(runtime, (spec,), apply=apply, operator_id=1) + assert report.current_count == 1 + assert report.before == report.after == states + runtime.authorize_system_owned.assert_not_awaited() + + +async def test_creation_must_reach_current_before_reporting_success(monkeypatch): + spec = _spec() + missing = (SystemOwnedResourceState(resource=spec, state="MISSING"),) + monkeypatch.setattr(reconcile_module, "inspect_system_owned_resources", AsyncMock(return_value=missing)) + runtime = _runtime() + with pytest.raises(PermissionPublishNotReadyError, match="did not finalize"): + await reconcile_system_owned_resources(runtime, (spec,), apply=True, operator_id=1) + runtime.authorize_system_owned.assert_awaited_once() diff --git a/src/backend/test/permission/test_f048_system_resource_reconcile_cli.py b/src/backend/test/permission/test_f048_system_resource_reconcile_cli.py new file mode 100644 index 0000000000..064be73b53 --- /dev/null +++ b/src/backend/test/permission/test_f048_system_resource_reconcile_cli.py @@ -0,0 +1,70 @@ +"""System-resource repair CLI keeps dry-run and Store confirmation contracts.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from bisheng.api.services.f048_system_resource_bootstrap import SystemOwnedResourceInventory +from bisheng.permission.application.system_resource_reconcile import ( + SystemOwnedReconcileReport, + SystemOwnedResourceSpec, + SystemOwnedResourceState, +) +from scripts import reconcile_f048_system_owned_resources as cli + + +def test_apply_requires_store_confirmation(): + assert not cli.parse_args([]).apply + with pytest.raises(SystemExit): + cli.parse_args(["--apply"]) + + +@pytest.mark.parametrize("apply", [False, True]) +async def test_cli_reports_missing_resources_and_passes_explicit_mode(monkeypatch, capsys, apply): + spec = SystemOwnedResourceSpec(1, "tool", "902", ("use", "visible"), "preset") + before = (SystemOwnedResourceState(spec, "MISSING"),) + after = (SystemOwnedResourceState(spec, "CURRENT", mode="CUSTOM", version=4),) if apply else before + report = SystemOwnedReconcileReport("apply" if apply else "dry-run", before, after) + client = SimpleNamespace(store_id="store", model_id="model") + components = SimpleNamespace(facade=object(), marker=SimpleNamespace(wait_until_ready=AsyncMock())) + monkeypatch.setattr(cli, "initialize_app_context", AsyncMock()) + monkeypatch.setattr( + cli.app_context, "get_context", lambda name: SimpleNamespace(async_get_instance=AsyncMock(return_value=client)) + ) + monkeypatch.setattr(cli, "build_f048_permission_runtime", AsyncMock(return_value=components)) + monkeypatch.setattr( + cli, "load_system_owned_resource_inventory", AsyncMock(return_value=SystemOwnedResourceInventory((spec,), ())) + ) + reconcile = AsyncMock(return_value=report) + monkeypatch.setattr(cli, "reconcile_system_owned_resources", reconcile) + args = cli.parse_args(["--apply", "--confirm-store-id", "store"] if apply else []) + + assert await cli.run(args) == cli.EXIT_OK + + import json + + payload = json.loads(capsys.readouterr().out) + assert payload["mode"] == report.mode + assert payload["missing_count"] == 1 + assert payload["before"][0]["state"] == "MISSING" + reconcile.assert_awaited_once_with(components.facade, (spec,), apply=apply, operator_id=1) + + +async def test_store_mismatch_blocks_before_inventory_or_repair(monkeypatch): + monkeypatch.setattr(cli, "initialize_app_context", AsyncMock()) + monkeypatch.setattr( + cli.app_context, + "get_context", + lambda name: SimpleNamespace(async_get_instance=AsyncMock(return_value=SimpleNamespace(store_id="live"))), + ) + inventory = AsyncMock() + reconcile = AsyncMock() + monkeypatch.setattr(cli, "load_system_owned_resource_inventory", inventory) + monkeypatch.setattr(cli, "reconcile_system_owned_resources", reconcile) + + with pytest.raises(cli.SystemResourceReconcileBlockedError, match="Store confirmation mismatch"): + await cli.run(cli.parse_args(["--apply", "--confirm-store-id", "wrong"])) + + inventory.assert_not_awaited() + reconcile.assert_not_awaited() diff --git a/src/backend/test/permission/test_f048_system_resource_repair.py b/src/backend/test/permission/test_f048_system_resource_repair.py new file mode 100644 index 0000000000..192b22918e --- /dev/null +++ b/src/backend/test/permission/test_f048_system_resource_repair.py @@ -0,0 +1,303 @@ +"""Bootstrap creates tenant grants once and preserves later permission decisions.""" + +from __future__ import annotations + +from contextlib import asynccontextmanager +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +import sqlalchemy as sa +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.pool import StaticPool +from sqlmodel import SQLModel, select +from sqlmodel.ext.asyncio.session import AsyncSession + +from bisheng.api.services import f048_system_resource_bootstrap as bootstrap +from bisheng.common.errcode.permission import ( + PermissionPublishNotReadyError, +) +from bisheng.core.context.tenant import get_current_tenant_id, tenant_execution_scope +from bisheng.permission.application import control_state, sql_runtime, system_resource_reconcile +from bisheng.permission.application.control_state import ( + RuntimeCatalogSnapshot, + RuntimeModelSnapshot, + SqlOwnerProjectionState, + SqlPermissionControlState, +) +from bisheng.permission.application.runtime import F048PermissionRuntime +from bisheng.permission.application.system_resource_reconcile import ( + SystemOwnedResourceSpec, + reconcile_system_owned_resources, +) +from bisheng.permission.domain.models import ( + PermissionCatalogRelease, + PermissionGrant, + PermissionGrantAssignee, + PermissionProjectionOperation, + PermissionProjectionTuple, + ResourcePermissionMode, +) +from bisheng.permission.domain.repositories.projection_repository import ProjectionRepository +from bisheng.permission.domain.services.grant_source_service import GrantModelSnapshot, GrantSourceService +from bisheng.permission.domain.services.owner_service import F048OwnerProjectionService +from bisheng.permission.domain.services.projection_service import ProjectionService +from test.permission.test_f048_projection_service import FakeFGAProjection, FakeRecentMarker + + +def _spec(tenant_id=1, resource_id="902"): + return SystemOwnedResourceSpec( + tenant_id=tenant_id, + resource_type="tool", + resource_id=resource_id, + action_codes=("use", "visible"), + context_version=f"preset:{tenant_id}:{resource_id}", + ) + + +class _FGA(FakeFGAProjection): + store_id = "store" + model_id = "model" + + def __init__(self, factory): + super().__init__(set()) + self.factory = factory + self.reject_existing_writes = True + self.reads = [] + self.extra_rows = [] + + async def read_tuples(self, **kwargs): + assert kwargs["consistency"] == "HIGHER_CONSISTENCY" + self.reads.append((get_current_tenant_id(), kwargs)) + return [ + {"user": user, "relation": relation, "object": obj} + for user, relation, obj in sorted(self.present) + if user == kwargs["user"] and obj == kwargs["object"] + ] + self.extra_rows + + async def write_atomic(self, *, writes, deletes): + assert not any(row.relation == "system_use_marker" for row in writes) + assert not any(row.relation == "visible" and row.user == "user:*" for row in writes) + async with self.factory() as session: + operation = ( + await session.exec( + select(PermissionProjectionOperation).order_by(PermissionProjectionOperation.id.desc()) + ) + ).first() + assert operation.status in {"STAGING", "COMMIT_UNKNOWN"} + ledger = ( + await session.exec( + select(PermissionProjectionTuple).where(PermissionProjectionTuple.operation_id == operation.id) + ) + ).all() + assert {row.key for row in writes} <= {(row.fga_user, row.relation, row.fga_object) for row in ledger} + return await super().write_atomic(writes=writes, deletes=deletes) + + +@pytest.fixture +async def repair_env(monkeypatch): + engine = create_async_engine("sqlite+aiosqlite://", poolclass=StaticPool) + metadata = sa.MetaData() + for name in ( + "authorization_model_release", + "permission_catalog_release", + "permission_projection_operation", + "permission_projection_tuple", + "permission_migration_run", + "permission_migration_item", + "resource_permission_mode", + "permission_grant", + "permission_grant_assignee", + "permission_visible_source_projection", + "t_gpts_tools_type", + ): + cloned = SQLModel.metadata.tables[name].to_metadata(metadata) + cloned.c.id.type = sa.Integer() + async with engine.begin() as conn: + await conn.run_sync(metadata.create_all) + + @asynccontextmanager + async def factory(): + async with AsyncSession(engine, expire_on_commit=False) as session: + yield session + + for module in (sql_runtime, control_state, system_resource_reconcile, bootstrap): + monkeypatch.setattr(module, "get_async_db_session", factory) + async with factory() as session: + session.add( + PermissionCatalogRelease( + id=1, + release_key="catalog", + version=1, + status="CURRENT", + write_fenced=False, + required_authorization_model_release_id=1, + draft_owner_id=1, + idempotency_key="catalog", + checksum="c" * 64, + ) + ) + await session.commit() + state = SqlPermissionControlState() + state.current_catalog = AsyncMock( + return_value=RuntimeCatalogSnapshot( + release_id=1, + release_key="catalog", + version=1, + checksum="c" * 64, + store_id="store", + model_id="model", + model_checksum="m" * 64, + models=( + RuntimeModelSnapshot( + snapshot=GrantModelSnapshot( + model_key="viewer", active=True, action_codes=("use",), derived_level=1 + ), + name="Viewer", + kind="STANDARD", + version=1, + ), + ), + ) + ) + repository = ProjectionRepository(session_factory=factory) + marker = FakeRecentMarker() + fga = _FGA(factory) + projection = ProjectionService( + repository=repository, + marker=marker, + scope_guard=sql_runtime.SqlProjectionScopeGuard(), + fga=fga, + finalizer=sql_runtime.SqlProjectionFinalizer(), + ) + sources = GrantSourceService() + owner = F048OwnerProjectionService( + source_service=sources, projection=projection, state=SqlOwnerProjectionState(state) + ) + runtime = F048PermissionRuntime( + client=fga, + state=state, + marker=marker, + decision=object(), + projection=projection, + sources=sources, + owner=owner, + grants=object(), + modes=object(), + explain=object(), + ) + with tenant_execution_scope(1): + try: + yield SimpleNamespace( + runtime=runtime, factory=factory, fga=fga, state=state, projection=projection, marker=marker + ) + finally: + await engine.dispose() + + +@pytest.mark.parametrize("resource_type", ["tool", "linsight_skill"]) +async def test_fresh_bootstrap_creates_tenant_grant_and_restart_is_read_only(repair_env, resource_type): + from dataclasses import replace + + env = repair_env + spec = replace(_spec(), resource_type=resource_type) + report = await reconcile_system_owned_resources(env.runtime, (spec,), apply=True, operator_id=1) + assert report.missing_count == report.current_count == 1 + assert ("tenant:1#member", "visible", spec.object_key) in env.fga.present + assert any(user == "tenant:1#member" and relation == "ordinary_assignee" for user, relation, _ in env.fga.present) + async with env.factory() as session: + operation = (await session.exec(select(PermissionProjectionOperation))).one() + grant = (await session.exec(select(PermissionGrant))).one() + source = (await session.exec(select(PermissionGrantAssignee))).one() + mode = (await session.exec(select(ResourcePermissionMode))).one() + assert operation.status == "FINALIZED" + assert source.subject_type == "tenant" and source.subject_id == "1" and not source.protected + assert grant.projection_state == mode.projection_state == "CURRENT" + calls = len(env.fga.calls) + await reconcile_system_owned_resources(env.runtime, (spec,), apply=True, operator_id=1) + assert len(env.fga.calls) == calls + assert not env.fga.reads + + +@pytest.mark.parametrize("resource_type", ["tool", "linsight_skill"]) +@pytest.mark.parametrize("source_state", ["ACTIVE", "INACTIVE"]) +async def test_existing_or_revoked_grants_are_never_restored_by_bootstrap(repair_env, resource_type, source_state): + from dataclasses import replace + + env = repair_env + spec = replace(_spec(), resource_type=resource_type) + async with env.factory() as session: + session.add( + ResourcePermissionMode( + tenant_id=1, + resource_type=resource_type, + resource_id=spec.resource_id, + mode="CUSTOM", + version=3, + projection_state="CURRENT", + ) + ) + grant = PermissionGrant( + tenant_id=1, + resource_type=resource_type, + resource_id=spec.resource_id, + model_key="viewer", + state=source_state, + projection_state="CURRENT", + ) + session.add(grant) + await session.flush() + session.add( + PermissionGrantAssignee( + tenant_id=1, + grant_id=grant.id, + subject_type="tenant", + subject_id="1", + source_type="TENANT", + source_ref="tenant:1#member", + source_locator="tenant:1#member", + source_fingerprint="s" * 64, + projected_subject="tenant:1#member", + protected=False, + state=source_state, + ) + ) + await session.commit() + report = await reconcile_system_owned_resources(env.runtime, (spec,), apply=True, operator_id=1) + assert report.current_count == 1 + assert not env.fga.calls and not env.fga.reads + async with env.factory() as session: + assert (await session.exec(select(ResourcePermissionMode))).one().version == 3 + assert (await session.exec(select(PermissionGrantAssignee))).one().state == source_state + assert not (await session.exec(select(PermissionProjectionOperation))).all() + + +@pytest.mark.parametrize("status", ["PROJECTING", "FAILED_CLOSED"]) +async def test_non_current_resources_fail_closed(repair_env, status): + env = repair_env + async with env.factory() as session: + session.add( + ResourcePermissionMode( + tenant_id=1, resource_type="tool", resource_id="902", mode="CUSTOM", version=3, projection_state=status + ) + ) + await session.commit() + with pytest.raises(PermissionPublishNotReadyError, match="non-current"): + await reconcile_system_owned_resources(env.runtime, (_spec(),), apply=True, operator_id=1) + assert not env.fga.calls + + +async def test_builtin_skill_entrypoint_uses_real_permission_creation(repair_env, monkeypatch): + from bisheng.linsight.domain.services.skill_permission import ensure_builtin_skill_permission + from bisheng.permission.application import process_runtime + + env = repair_env + monkeypatch.setattr( + process_runtime, + "get_f048_process_runtime", + AsyncMock(return_value=SimpleNamespace(components=SimpleNamespace(facade=env.runtime))), + ) + await ensure_builtin_skill_permission(SimpleNamespace(id=902, tenant_id=1)) + assert ("tenant:1#member", "visible", "linsight_skill:902") in env.fga.present + async with env.factory() as session: + assert (await session.exec(select(PermissionProjectionOperation))).one().status == "FINALIZED" diff --git a/src/backend/test/permission/test_f048_tuple_mapper.py b/src/backend/test/permission/test_f048_tuple_mapper.py index da96e514e6..bed95adc76 100644 --- a/src/backend/test/permission/test_f048_tuple_mapper.py +++ b/src/backend/test/permission/test_f048_tuple_mapper.py @@ -42,6 +42,75 @@ def test_unbound_direct_tuple_uses_standard_model_fallback(): assert grant.assignees[0].source_type == "DIRECT" +@pytest.mark.parametrize("relation", ("manager", "can_manage")) +@pytest.mark.parametrize("with_binding", (False, True)) +def test_legacy_user_group_admin_manager_is_retired_without_member_fallback(relation, with_binding): + source = _tuple(user="user_group:8#admin", relation=relation) + bindings = ( + ( + LegacyGrantBinding( + binding_key="legacy-group-admin", + tenant_id=7, + resource_type="workflow", + resource_id="wf-1", + relation=relation, + model_source_key="legacy-manager", + subject_type="user_group", + subject_id="8", + userset_relation="admin", + ), + ) + if with_binding + else () + ) + + result = map_legacy_tuples( + (source,), + bindings, + model_key_by_source={"legacy-manager": "manager"}, + ) + + assert result.blockers == () + assert result.grants == () + assert result.preserved_tuples == () + assert result.retired_tuple_keys == (source.key,) + assert [(row.difference_type, row.severity) for row in result.differences] == [ + ("LEGACY_USER_GROUP_ADMIN_MANAGER_RETIRED", "INFO"), + ] + + +def test_retiring_group_admin_manager_preserves_member_grant_and_protected_creator(): + resource = PermissionMigrationResourceDTO( + tenant_id=7, + resource_type="workflow", + resource_id="wf-1", + status="ONLINE", + owner_user_id=11, + ownership_kind="USER", + source_locator="workflow:wf-1", + ) + result = map_legacy_tuples( + ( + _tuple(user="user_group:8#admin", relation="manager"), + _tuple(user="user_group:8#member", relation="manager"), + ), + (), + model_key_by_source={}, + resources=(resource,), + ) + + assert result.blockers == () + sources = { + (grant.model_key, row.subject_type, row.subject_id, row.userset_relation, row.protected) + for grant in result.grants + for row in grant.assignees + } + assert sources == { + ("manager", "user_group", "8", "member", False), + ("owner", "user", "11", None, True), + } + + def test_unique_binding_wins_and_preserves_provenance_and_protection(): binding = LegacyGrantBinding( binding_key="binding-1", diff --git a/src/backend/test/permission/test_f115_action_sources.py b/src/backend/test/permission/test_f115_action_sources.py new file mode 100644 index 0000000000..1d1f4ae541 --- /dev/null +++ b/src/backend/test/permission/test_f115_action_sources.py @@ -0,0 +1,109 @@ +"""F115 AC-23/26/27/28/33/34/37/52: trusted action sources, not model names.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from bisheng.permission.application.runtime import F048PermissionRuntime +from bisheng.permission.domain.schemas import VerifiedPermissionTarget +from bisheng.permission.domain.services.grant_source_service import GrantModelSnapshot + + +def runtime(pages): + instance = object.__new__(F048PermissionRuntime) + instance._state = SimpleNamespace( + source_read_stamp=AsyncMock(return_value=(("app", "a", 3),)), + mode_for_target=AsyncMock( + return_value=SimpleNamespace( + mode="CUSTOM", version=3, parent_type=None, parent_id=None, projection_state="CURRENT" + ) + ), + load_source_page=AsyncMock(side_effect=pages), + ) + models = ( + SimpleNamespace(snapshot=GrantModelSnapshot("owner", True, ("edit",))), + SimpleNamespace(snapshot=GrantModelSnapshot("custom", True, ("use",))), + ) + instance._runtime_catalog = AsyncMock( + return_value=SimpleNamespace(release_id=1, version=1, checksum="same", models=models) + ) + target = VerifiedPermissionTarget.from_business_service( + tenant_id=60, resource_type="app", resource_id="a", resource_version=3, context_version="test" + ) + return instance, target + + +async def test_actions_not_model_names_and_all_pages(): + row = SimpleNamespace(source_id=7) + service, target = runtime([((row,), True), ((), False)]) + result = await service.read_effective_action_sources(target=target, action="use") + assert result == (row,) + assert service._state.load_source_page.call_args.kwargs["models"][0].model_key == "custom" + assert service._state.load_source_page.call_args.kwargs["after_id"] == 7 + + +async def test_inactive_model_still_contributes_existing_action_sources(): + row = SimpleNamespace(source_id=7) + service, target = runtime([((row,), False)]) + catalog = await service._runtime_catalog() + catalog.models[1].snapshot = GrantModelSnapshot("custom", False, ("use",)) + assert await service.read_effective_action_sources(target=target, action="use") == (row,) + assert service._state.load_source_page.call_args.kwargs["models"][0].active is False + + +async def test_projection_change_refuses_partial_roster(): + from bisheng.common.errcode.permission import PermissionPublishNotReadyError + + service, target = runtime([((), False)]) + service._state.source_read_stamp.side_effect = [(1,), (2,)] + with pytest.raises(PermissionPublishNotReadyError): + await service.read_effective_action_sources(target=target, action="use") + + +async def test_nonadvancing_page_refuses_empty_owner_only(): + from bisheng.common.errcode.permission import PermissionEnumerationIncompleteError + + service, target = runtime([((), True)]) + with pytest.raises(PermissionEnumerationIncompleteError): + await service.read_effective_action_sources(target=target, action="use") + + +async def test_catalog_change_refuses_old_action_meanings(): + from bisheng.common.errcode.permission import PermissionPublishNotReadyError + + service, target = runtime([((), False)]) + catalog = await service._runtime_catalog() + latest = SimpleNamespace(**{**vars(catalog), "checksum": "new"}) + service._runtime_catalog.side_effect = [catalog, latest] + with pytest.raises(PermissionPublishNotReadyError): + await service.read_effective_action_sources(target=target, action="use") + + +async def test_inherited_source_stamp_checks_every_ancestor(monkeypatch): + from bisheng.common.errcode.permission import PermissionPublishNotReadyError + from bisheng.permission.application import control_state as module + + _service, target = runtime([]) + modes = { + "a": SimpleNamespace( + tenant_id=60, mode="INHERIT", version=3, parent_type="app", parent_id="parent", projection_state="CURRENT" + ), + "parent": SimpleNamespace( + tenant_id=60, mode="CUSTOM", version=4, parent_type=None, parent_id=None, projection_state="CURRENT" + ), + } + + async def get_mode(*, resource_type, resource_id): + return modes[resource_id] + + monkeypatch.setattr(module, "ResourcePermissionModeRepository", lambda: SimpleNamespace(aget_mode=get_mode)) + monkeypatch.setattr( + module, "GrantRepository", lambda: SimpleNamespace(alist_resource_grants=AsyncMock(return_value=[])) + ) + state = object.__new__(module.SqlPermissionControlState) + stamp = await state.source_read_stamp(target=target, models=()) + assert len(stamp) == 2 + modes["parent"].projection_state = "PENDING" + with pytest.raises(PermissionPublishNotReadyError): + await state.source_read_stamp(target=target, models=()) diff --git a/src/backend/test/permission/test_inactive_model_existing_roster.py b/src/backend/test/permission/test_inactive_model_existing_roster.py new file mode 100644 index 0000000000..99a2b86126 --- /dev/null +++ b/src/backend/test/permission/test_inactive_model_existing_roster.py @@ -0,0 +1,105 @@ +"""F048 AC-15/164/166: disabled assignment choices keep existing source semantics.""" + +from dataclasses import replace +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from bisheng.common.errcode.permission import GrantLevelForbiddenError +from bisheng.permission.application.runtime import F048PermissionRuntime +from bisheng.permission.domain.schemas import VerifiedPermissionTarget +from bisheng.permission.domain.services.grant_service import GrantCapability, GrantService +from bisheng.permission.domain.services.grant_source_service import GrantModelSnapshot +from bisheng.permission.domain.services.permission_action_service import PermissionActor +from bisheng.permission.domain.services.permission_explain_service import PermissionSourceExplanation + + +def fixture(): + model = GrantModelSnapshot("old-custom", False, ("download", "use"), derived_level=1) + high = GrantModelSnapshot("high-custom", False, ("edit",), derived_level=3) + catalog = SimpleNamespace(models=tuple(SimpleNamespace(snapshot=m) for m in (model, high))) + row = PermissionSourceExplanation( + source_id=11, + source_version=2, + subject_type="department", + subject_id="310", + userset_relation="member", + include_children=False, + source_type="DEPARTMENT", + model_key=model.model_key, + model_level=1, + scope="LOCAL", + inherited_from=None, + protected=False, + editable=True, + ) + target = VerifiedPermissionTarget.from_business_service( + tenant_id=7, + resource_type="knowledge_space", + resource_id="space", + resource_version=3, + context_version="test", + ) + service = object.__new__(F048PermissionRuntime) + service._runtime_catalog = AsyncMock(return_value=catalog) + service._require_manage_permission = AsyncMock() + service._require_current_target = AsyncMock(return_value=SimpleNamespace(mode="CUSTOM")) + service._state = SimpleNamespace(load_source_page=AsyncMock(return_value=((row,), False))) + service._grants = GrantService(source_service=object(), projection=object(), state=object()) + capability = GrantCapability( + model=GrantModelSnapshot("manager", True, ("manage_permission",), derived_level=2), + source_key="manager", + ) + service._grant_capabilities = AsyncMock(return_value=(capability,)) + return service, PermissionActor(user_id=9, current_tenant_id=7), target, row + + +async def test_roster_editability_uses_existing_source_level_not_assignability(): + service, actor, target, row = fixture() + result, more = await service.list_permission_sources_page(actor=actor, target=target, after_id=0, limit=20) + assert more is False and result == (row,) + assert await service.manageable_model_keys(actor=actor, target=target) == frozenset({"old-custom"}) + call = service._state.load_source_page.call_args.kwargs + assert call["limit"] == 20 and call["models"][0].active is False + models = tuple(item.snapshot for item in (await service._runtime_catalog()).models) + capabilities = await service._grant_capabilities(actor, target) + assert service._grants.grantable_models_for_capabilities(models=models, capabilities=capabilities) == () + + +async def test_existing_higher_level_source_remains_read_only(): + service, actor, target, row = fixture() + service._state.load_source_page.return_value = ((replace(row, model_key="high-custom", model_level=3),), False) + rows, _ = await service.list_permission_sources_page(actor=actor, target=target, after_id=0, limit=20) + assert rows[0].editable is False + + +@pytest.mark.parametrize("change", [{"protected": True, "editable": False}, {"scope": "INHERITED", "editable": False}]) +async def test_protected_and_inherited_sources_keep_read_only(change): + service, actor, target, row = fixture() + service._state.load_source_page.return_value = ((replace(row, **change),), False) + rows, _ = await service.list_permission_sources_page(actor=actor, target=target, after_id=0, limit=20) + assert rows[0].editable is False + service._grant_capabilities.assert_not_called() + + +async def test_source_roster_refuses_before_read_when_reader_cannot_manage(): + service, actor, target, _ = fixture() + service._require_manage_permission.side_effect = GrantLevelForbiddenError() + with pytest.raises(GrantLevelForbiddenError): + await service.list_permission_sources_page(actor=actor, target=target, after_id=0, limit=20) + service._state.load_source_page.assert_not_called() + + +def test_same_level_policy_is_shared_by_old_binding_and_new_assignment(): + service, _, _, _ = fixture() + model = GrantModelSnapshot("same-level", False, ("download",), derived_level=2) + capability = GrantCapability( + model=GrantModelSnapshot("manager", True, ("manage_permission",), derived_level=2), + source_key="manager", + ) + policy = service._grants + assert policy.manageable_models_for_capabilities(models=(model,), capabilities=(capability,)) == () + allowed = replace(capability, model=replace(capability.model, allow_same_level=True)) + assert policy.manageable_models_for_capabilities(models=(model,), capabilities=(allowed,)) == (model,) + assert policy.grantable_models_for_capabilities(models=(model,), capabilities=(allowed,)) == () diff --git a/src/backend/test/permission/test_openfga_bootstrap_lock.py b/src/backend/test/permission/test_openfga_bootstrap_lock.py new file mode 100644 index 0000000000..77dd8e2ca8 --- /dev/null +++ b/src/backend/test/permission/test_openfga_bootstrap_lock.py @@ -0,0 +1,175 @@ +"""Concurrent startup must reuse one Store/model and stop waiting on timeout.""" + +from __future__ import annotations + +import asyncio +from collections import Counter +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import httpx +import pytest +from redis.exceptions import ConnectionError as RedisConnectionError + +from bisheng.common.errcode.permission import AuthorizationModelMismatchError +from bisheng.core.config.openfga import OpenFGAConf +from bisheng.core.openfga.authorization_model_f048 import build_authorization_model_f048 +from bisheng.core.openfga.discovery import OpenFGARuntimePin, discover_openfga_runtime +from bisheng.core.openfga.manager import FGAManager + + +@pytest.fixture +def redis_locks(monkeypatch): + locks: dict[str, asyncio.Lock] = {} + options = [] + + def make_lock(key, **kwargs): + options.append((key, kwargs)) + mutex = locks.setdefault(key, asyncio.Lock()) + return SimpleNamespace(acquire=mutex.acquire, release=AsyncMock(side_effect=mutex.release)) + + getter = AsyncMock(return_value=SimpleNamespace(async_connection=SimpleNamespace(lock=make_lock))) + monkeypatch.setattr("bisheng.core.openfga.manager.get_redis_client", getter) + return SimpleNamespace(locks=locks, options=options, getter=getter) + + +class _OpenFGA: + def __init__(self, starters: int, *, existing_store: bool = False): + self.stores = [{"id": "store-one", "name": "bisheng"}] if existing_store else [] + self.models = [] + self.model = build_authorization_model_f048() + self.writes = Counter() + self.starters = starters + self.reads = 0 + self.gate = asyncio.Event() + self.gate_path = "/stores/store-one/authorization-models" if existing_store else "/stores" + + async def handle(self, request: httpx.Request) -> httpx.Response: + path = request.url.path + if request.method == "POST": + self.writes[path] += 1 + await asyncio.sleep(0) + if path == "/stores": + row = {"id": "store-one", "name": "bisheng"} + self.stores.append(row) + return httpx.Response(201, json=row) + row = {"id": "model-one"} + self.models.append(row) + return httpx.Response(201, json={"authorization_model_id": row["id"]}) + if path == "/stores": + payload = {"stores": list(self.stores)} + elif path.endswith("/authorization-models"): + payload = {"authorization_models": list(self.models)} + else: + return httpx.Response(200, json={"authorization_model": self.model}) + # Capture empty responses before releasing all starting processes. + if path == self.gate_path and self.reads < self.starters: + self.reads += 1 + if self.reads == self.starters: + self.gate.set() + await self.gate.wait() + return httpx.Response(200, json=payload) + + +def _manager(): + return FGAManager(OpenFGAConf(), environment="dev") + + +@pytest.mark.parametrize("existing_store", [False, True]) +async def test_concurrent_starters_reuse_one_store_and_model(monkeypatch, redis_locks, existing_store): + server = _OpenFGA(5, existing_store=existing_store) + async with httpx.AsyncClient( + base_url="http://openfga:8080", transport=httpx.MockTransport(server.handle) + ) as client: + + async def discover(*args, **kwargs): + return await discover_openfga_runtime(*args, **kwargs, http_client=client) + + monkeypatch.setattr("bisheng.core.openfga.manager.discover_openfga_runtime", discover) + managers = [_manager() for _ in range(5)] + await asyncio.wait_for(asyncio.gather(*(m._async_initialize() for m in managers)), timeout=3) + + assert len(server.stores) == len(server.models) == 1 + assert server.writes["/stores"] == (0 if existing_store else 1) + assert server.writes["/stores/store-one/authorization-models"] == 1 + assert {(m.readiness()["store_id"], m.readiness()["model_id"]) for m in managers} == {("store-one", "model-one")} + assert {key for key, _ in redis_locks.options} == {"openfga:bootstrap:bisheng"} + assert all(o["timeout"] == 60 and o["blocking_timeout"] == 30 for _, o in redis_locks.options) + + +async def test_existing_runtime_does_not_acquire_bootstrap_lock(monkeypatch, redis_locks): + server = _OpenFGA(0, existing_store=True) + server.models = [{"id": "model-one"}] + async with httpx.AsyncClient( + base_url="http://openfga:8080", transport=httpx.MockTransport(server.handle) + ) as client: + + async def discover(*args, **kwargs): + return await discover_openfga_runtime(*args, **kwargs, http_client=client) + + monkeypatch.setattr("bisheng.core.openfga.manager.discover_openfga_runtime", discover) + await _manager()._async_initialize() + redis_locks.getter.assert_not_awaited() + assert not server.writes + + +async def test_waiting_for_another_starter_has_a_deadline(monkeypatch, redis_locks): + lock = asyncio.Lock() + await lock.acquire() + redis_locks.locks["openfga:bootstrap:bisheng"] = lock + discover = AsyncMock(side_effect=AuthorizationModelMismatchError(msg="Store does not exist")) + monkeypatch.setattr("bisheng.core.openfga.manager.discover_openfga_runtime", discover) + manager = _manager() + manager.timeout = 0.02 + try: + with pytest.raises(TimeoutError, match="bootstrap timed out"): + await asyncio.wait_for(manager._async_initialize(), timeout=0.5) + assert lock.locked() # A waiter must not release the owner's lock. + assert all(not call.kwargs["allow_bootstrap"] for call in discover.await_args_list) + finally: + lock.release() + + +async def test_timed_out_writer_releases_lock(monkeypatch, redis_locks): + async def slow_discover(*args, **kwargs): + await asyncio.Event().wait() + + discover = AsyncMock(side_effect=slow_discover) + monkeypatch.setattr("bisheng.core.openfga.manager.discover_openfga_runtime", discover) + manager = _manager() + manager.timeout = 0.02 + with pytest.raises(TimeoutError, match="bootstrap timed out"): + await asyncio.wait_for(manager._bootstrap_runtime(build_authorization_model_f048()), timeout=0.5) + assert not redis_locks.locks["openfga:bootstrap:bisheng"].locked() + + +async def test_redis_failure_never_falls_back_to_unlocked_writes(monkeypatch, redis_locks): + redis_locks.getter.side_effect = RedisConnectionError("Redis unavailable") + discover = AsyncMock() + monkeypatch.setattr("bisheng.core.openfga.manager.discover_openfga_runtime", discover) + with pytest.raises(RedisConnectionError): + await _manager()._bootstrap_runtime(build_authorization_model_f048()) + discover.assert_not_awaited() + + +async def test_failed_writer_releases_lock_for_next_starter(monkeypatch, redis_locks): + discover = AsyncMock(side_effect=AuthorizationModelMismatchError(msg="OpenFGA unavailable")) + monkeypatch.setattr("bisheng.core.openfga.manager.discover_openfga_runtime", discover) + with pytest.raises(AuthorizationModelMismatchError): + await _manager()._bootstrap_runtime(build_authorization_model_f048()) + assert not redis_locks.locks["openfga:bootstrap:bisheng"].locked() + pin = OpenFGARuntimePin("store-one", "model-one", "checksum") + discover.side_effect = None + discover.return_value = pin + assert await _manager()._bootstrap_runtime(build_authorization_model_f048()) == pin + + +async def test_lock_acquisition_deadline_is_reported_as_bootstrap_timeout(monkeypatch, redis_locks): + lock = SimpleNamespace(acquire=AsyncMock(return_value=False), release=AsyncMock()) + redis_locks.getter.return_value.async_connection.lock = lambda *args, **kwargs: lock + discover = AsyncMock() + monkeypatch.setattr("bisheng.core.openfga.manager.discover_openfga_runtime", discover) + with pytest.raises(TimeoutError, match="bootstrap timed out after 30s"): + await _manager()._bootstrap_runtime(build_authorization_model_f048()) + lock.release.assert_not_awaited() + discover.assert_not_awaited() diff --git a/src/backend/test/permission/test_permission_error_code_collision.py b/src/backend/test/permission/test_permission_error_code_collision.py new file mode 100644 index 0000000000..a509dec395 --- /dev/null +++ b/src/backend/test/permission/test_permission_error_code_collision.py @@ -0,0 +1,43 @@ +"""Permission errors and information-source errors must remain distinguishable.""" + +import inspect +import json +from pathlib import Path + +import pytest + +from bisheng.common.errcode import channel, permission +from bisheng.common.errcode.base import BaseErrorCode +from bisheng.permission.api.responses import permission_error_response + + +def _codes(module): + return { + cls.Code + for _, cls in inspect.getmembers(module, inspect.isclass) + if issubclass(cls, BaseErrorCode) and cls.__module__ == module.__name__ + } + + +def test_permission_codes_never_overlap_channel_codes(): + assert _codes(permission).isdisjoint(_codes(channel)) + assert permission.PermissionDeniedError.Code == 19000 + assert channel.InformationSourceParseError.Code == 19003 + assert permission.PermissionFGAUnavailableError is permission.PermissionServiceUnavailableError + + +@pytest.mark.parametrize("code", range(25020, 25027)) +def test_permission_error_codes_have_copy_in_both_clients_and_all_languages(code): + frontend = Path(__file__).resolve().parents[3] / "frontend" + for lang, platform_lang in (("zh-Hans", "zh-Hans"), ("en", "en-US"), ("ja", "ja")): + source = json.loads((frontend / f"packages/locales/src/api_errors/{lang}.json").read_text()) + platform = json.loads((frontend / f"platform/public/locales/{platform_lang}/api_errors.json").read_text()) + client = json.loads((frontend / f"client/src/locales/{lang}/api_errors.gen.json").read_text()) + assert source[str(code)] == platform[str(code)] == client[str(code)] + assert source[str(code)] != source["19003"] + + +def test_invalid_resource_response_cannot_be_mistaken_for_parse_failure(): + result = permission_error_response(permission.PermissionInvalidResourceError()) + assert result.status_code == 25022 + assert result.status_message == "Invalid resource type or ID" diff --git a/src/backend/test/permission/test_resource_resolution_concurrency.py b/src/backend/test/permission/test_resource_resolution_concurrency.py new file mode 100644 index 0000000000..27378dec5e --- /dev/null +++ b/src/backend/test/permission/test_resource_resolution_concurrency.py @@ -0,0 +1,247 @@ +"""Business target loading shares a connection budget across concurrent requests.""" + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from sqlalchemy import text +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.pool import AsyncAdaptedQueuePool + +from bisheng.common.errcode.permission import PermissionInvalidResourceError +from bisheng.permission.application import business_authorization as business +from bisheng.permission.application.resource_authorization import ResourceAuthorizationRegistry +from bisheng.permission.domain.schemas import VerifiedPermissionTarget +from bisheng.permission.domain.services.permission_action_service import PermissionActor + + +class _LoadingPort: + def __init__(self): + self.active = 0 + self.peak = 0 + self.completed = 0 + + async def resolve_permission_target(self, *, resource_id, actor, action): + self.active += 1 + self.peak = max(self.peak, self.active) + try: + # Model a checked-out connection without needing external middleware. + await asyncio.sleep(0.001) + return VerifiedPermissionTarget.from_business_service( + tenant_id=actor.tenant_id, + resource_type="knowledge_file", + resource_id=resource_id, + resource_version=1, + context_version="v1", + ) + finally: + self.active -= 1 + self.completed += 1 + + +@pytest.mark.parametrize("method", ["batch_check_business_actions", "batch_check_business_visible"]) +async def test_large_batch_limits_business_loads(monkeypatch, method): + port = _LoadingPort() + registry = ResourceAuthorizationRegistry() + registry.register("knowledge_file", port) + batch_sizes = [] + + async def decide(actor, targets, action): + batch_sizes.append(len(targets)) + return tuple(True for _ in targets) + + monkeypatch.setattr(business, "get_f048_resource_registry", AsyncMock(return_value=registry)) + monkeypatch.setattr( + business, "get_f048_runtime", AsyncMock(return_value=SimpleNamespace(batch_check_actions=decide)) + ) + kwargs = {"actions": ("visible",)} if method == "batch_check_business_actions" else {} + result = await asyncio.wait_for( + getattr(business, method)( + SimpleNamespace(user_id=7, tenant_id=1, is_global_super=False), + resource_type="knowledge_file", + resource_ids=range(1, 1001), + **kwargs, + ), + timeout=10, + ) + + assert len(result) == 1000 + assert all(result.values()) + assert 1 < port.peak <= 20 + assert port.completed == 1000 + assert batch_sizes == [100] * 10 + + +async def test_simultaneous_batch_and_single_requests_share_the_budget(monkeypatch): + port = _LoadingPort() + # Even independently composed registries on the same loop share the limit. + registries = [ResourceAuthorizationRegistry() for _ in range(2)] + for registry in registries: + registry.register("knowledge_file", port) + monkeypatch.setattr(business, "get_f048_resource_registry", AsyncMock(return_value=registries[0])) + monkeypatch.setattr( + business, + "get_f048_runtime", + AsyncMock( + return_value=SimpleNamespace(batch_check_actions=AsyncMock(side_effect=lambda a, ts, act: [True] * len(ts))) + ), + ) + login = SimpleNamespace(user_id=7, tenant_id=1, is_global_super=False) + actor = PermissionActor(user_id=7, current_tenant_id=1) + requests = ( + [ + business.batch_check_business_actions( + login, resource_type="knowledge_file", resource_ids=range(1, 201), actions=("visible", "edit") + ) + for _ in range(3) + ] + + [ + business.batch_check_business_visible(login, resource_type="knowledge_file", resource_ids=range(1, 201)) + for _ in range(3) + ] + + [ + registries[1].resolve(resource_type="knowledge_file", resource_id=str(index), actor=actor, action="visible") + for index in range(1, 41) + ] + ) + results = await asyncio.wait_for(asyncio.gather(*requests), timeout=10) + + assert all(results) + assert 1 < port.peak <= 20 + assert port.completed == 1840 + assert port.active == 0 + + +async def test_concurrent_requests_complete_with_a_finite_sqlalchemy_pool(monkeypatch): + engine = create_async_engine( + "sqlite+aiosqlite://", + poolclass=AsyncAdaptedQueuePool, + pool_size=20, + max_overflow=0, + pool_timeout=0.05, + ) + peak_connections = 0 + + class DatabasePort: + async def resolve_permission_target(self, *, resource_id, actor, action): + nonlocal peak_connections + async with engine.connect() as connection: + peak_connections = max(peak_connections, engine.pool.checkedout()) + await connection.execute(text("SELECT 1")) + # Hold connections long enough that an unbounded burst times out. + await asyncio.sleep(0.01) + return VerifiedPermissionTarget.from_business_service( + tenant_id=actor.tenant_id, + resource_type="knowledge_file", + resource_id=resource_id, + resource_version=1, + context_version="v1", + ) + + registry = ResourceAuthorizationRegistry() + registry.register("knowledge_file", DatabasePort()) + monkeypatch.setattr(business, "get_f048_resource_registry", AsyncMock(return_value=registry)) + monkeypatch.setattr( + business, + "get_f048_runtime", + AsyncMock( + return_value=SimpleNamespace( + batch_check_actions=AsyncMock(side_effect=lambda actor, targets, action: [True] * len(targets)) + ) + ), + ) + login = SimpleNamespace(user_id=7, tenant_id=1, is_global_super=False) + try: + results = await asyncio.wait_for( + asyncio.gather( + *( + business.batch_check_business_visible( + login, resource_type="knowledge_file", resource_ids=range(1, 101) + ) + for _ in range(4) + ), + return_exceptions=True, + ), + timeout=10, + ) + assert not [result for result in results if isinstance(result, BaseException)] + assert all(len(result) == 100 and all(result.values()) for result in results) + assert 1 < peak_connections <= 20 + assert engine.pool.checkedout() == 0 + finally: + await engine.dispose() + + +async def test_cancellation_and_failures_release_resolution_slots(): + entered = 0 + saturated = asyncio.Event() + release = asyncio.Event() + actor = PermissionActor(user_id=7, current_tenant_id=1) + + class BlockingPort: + async def resolve_permission_target(self, **kwargs): + nonlocal entered + entered += 1 + if entered == 20: + saturated.set() + await release.wait() + raise PermissionInvalidResourceError() + + registry = ResourceAuthorizationRegistry() + registry.register("knowledge_file", BlockingPort()) + + def resolve(index): + return registry.resolve(resource_type="knowledge_file", resource_id=str(index), actor=actor, action="visible") + + tasks = [asyncio.create_task(resolve(index)) for index in range(1, 42)] + try: + await asyncio.wait_for(saturated.wait(), timeout=5) + tasks[0].cancel() + tasks[-1].cancel() + release.set() + outcomes = await asyncio.wait_for(asyncio.gather(*tasks, return_exceptions=True), timeout=5) + assert sum(isinstance(result, asyncio.CancelledError) for result in outcomes) == 2 + assert all(isinstance(result, (asyncio.CancelledError, PermissionInvalidResourceError)) for result in outcomes) + + port = _LoadingPort() + next_registry = ResourceAuthorizationRegistry() + next_registry.register("knowledge_file", port) + await asyncio.wait_for( + asyncio.gather( + *( + next_registry.resolve( + resource_type="knowledge_file", resource_id=str(index), actor=actor, action="visible" + ) + for index in range(1, 41) + ) + ), + timeout=5, + ) + assert port.peak == 20 + assert port.completed == 40 + finally: + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + + +async def test_sync_callers_can_use_successive_event_loops(): + registry = ResourceAuthorizationRegistry() + port = _LoadingPort() + registry.register("knowledge_file", port) + actor = PermissionActor(user_id=7, current_tenant_id=1) + + async def run_batch(): + return await asyncio.gather( + *( + registry.resolve(resource_type="knowledge_file", resource_id=str(index), actor=actor, action="visible") + for index in range(1, 41) + ) + ) + + for _ in range(2): + result = await asyncio.wait_for(asyncio.to_thread(lambda: asyncio.run(run_batch())), timeout=5) + assert len(result) == 40 + assert port.completed == 80 + assert port.peak == 20 diff --git a/src/backend/test/permission/test_roster_search_page.py b/src/backend/test/permission/test_roster_search_page.py new file mode 100644 index 0000000000..235ff648b4 --- /dev/null +++ b/src/backend/test/permission/test_roster_search_page.py @@ -0,0 +1,51 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +from bisheng.permission.application.roster_search import search_source_page + + +async def test_search_reaches_people_beyond_first_page_without_losing_cursor(): + rows = [ + SimpleNamespace( + source_id=i, + subject_type="user", + subject_id=str(i), + model_key="view", + source_type="DIRECT", + inherited_from=None, + ) + for i in range(1, 5) + ] + + async def load(**kwargs): + remaining = [r for r in rows if r.source_id > kwargs["after_id"]] + return remaining[:2], len(remaining) > 2 + + runtime = SimpleNamespace(list_permission_sources_page=load) + subjects = SimpleNamespace(display_names=AsyncMock(return_value={("user", "3"): "Alice", ("user", "4"): "Alice 2"})) + selected, more = await search_source_page( + runtime=runtime, + subjects=subjects, + actor=None, + target=None, + after_id=0, + page_size=1, + keyword="alice", + subject_type="user", + model_names={}, + ) + assert [r.source_id for r in selected] == [3] + assert more + selected, more = await search_source_page( + runtime=runtime, + subjects=subjects, + actor=None, + target=None, + after_id=3, + page_size=1, + keyword="alice", + subject_type="user", + model_names={}, + ) + assert [r.source_id for r in selected] == [4] + assert not more diff --git a/src/backend/test/role/test_gov07_permission_point_inventory.py b/src/backend/test/role/test_gov07_permission_point_inventory.py new file mode 100644 index 0000000000..b6c01a2c2e --- /dev/null +++ b/src/backend/test/role/test_gov07_permission_point_inventory.py @@ -0,0 +1,144 @@ +"""F056 T031 / AC-37, AC-38 — the backend half of 「零新增权限点」. + +The frontend half lives in +``platform/src/test/gov07NoNewPermissionPoint.test.ts``: the role editor's +list of selectable entries, its cascade rules and a new role's defaults, all +frozen as literals. That file cannot see the side a permission point is +actually *added* on. + +A menu permission point exists in three places on this side, and the honest +version of AC-37 is that none of them grew a hosted-application entry: + +* :class:`WebMenuResource` — the registry of menu keys a role may hold; +* ``_ROLE_UI_WORKBENCH_CHILDREN`` / ``_ROLE_UI_ADMIN_CHILDREN`` — what the login + path treats as a second-level entry, which is what makes a key *effective*; +* ``UserMenuAccessService._PARENT_DEPENDENCIES`` — the cascade a personal menu + grant expands through. + +The last test asserts the two sides agree. That is the lockstep the AC really +rests on: a key added to the backend alone is a permission point nobody can +switch off in the UI, and one added to the frontend alone is a switch that +grants nothing — either way "the role config screen is unchanged" stops being +true, and neither side's own suite notices. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from bisheng.approval.domain.services.user_menu_access_service import UserMenuAccessService +from bisheng.database.models.role_access import WebMenuResource +from bisheng.user.domain.services.auth import ( + _ROLE_UI_ADMIN_CHILDREN, + _ROLE_UI_WORKBENCH_CHILDREN, +) + +#: ``platform/src/pages/SystemPage/components/roleMenuSelection.ts``. +ROLE_MENU_SELECTION_TS = ( + Path(__file__).resolve().parents[3] + / "frontend" + / "platform" + / "src" + / "pages" + / "SystemPage" + / "components" + / "roleMenuSelection.ts" +) + +#: Names that would mean the app factory got a permission point of its own. +#: ``app`` alone is not on the list on purpose — ``apps`` (应用中心) is a legacy +#: workbench entry and predates all of this. +FACTORY_WORDS = ("hosted", "app_runtime", "deploy", "square", "publish", "factory") + + +def _ts_string_list(source: str, name: str) -> list[str]: + """The string literals of a ``export const NAME = [...]`` array in a .ts file.""" + match = re.search(rf"export const {name}\s*=\s*\[(.*?)\]", source, re.S) + assert match, f"{name} is no longer declared as an array literal in {ROLE_MENU_SELECTION_TS.name}" + return re.findall(r'"([^"]+)"', match.group(1)) + + +class TestPermissionPointRegistry: + def test_web_menu_resource_holds_exactly_the_keys_it_always_held(self): + # Frozen as literals rather than as a count: a swap keeps a count + # honest while changing every screen that reads the registry. + assert {member.value for member in WebMenuResource} == { + "workstation", + "admin", + "build", + "create_app", + "knowledge", + "create_knowledge", + "knowledge_space", + "model", + "tool", + "mcp", + "channel", + "evaluation", + "dataset", + "mark_task", + "board", + "subscription", + "home", + "linsight_task_mode", + "apps", + # Deprecated, kept for backward compatibility (AD-07). + "frontend", + "backend", + "create_dashboard", + } + + def test_no_menu_key_is_about_the_app_factory(self): + """AC-35 / 决议-10: hosted applications reuse ``create_app`` and reach + the build page through ``build``. A key of their own would also be the + one thing that makes 「升级前后数量不变」 false.""" + offenders = [member.value for member in WebMenuResource if any(word in member.value for word in FACTORY_WORDS)] + assert offenders == [] + + def test_the_effective_menu_sets_did_not_grow(self): + assert _ROLE_UI_WORKBENCH_CHILDREN == frozenset( + {"home", "linsight_task_mode", "apps", "subscription", "knowledge_space"} + ) + assert _ROLE_UI_ADMIN_CHILDREN == frozenset( + { + "board", + "model", + "log", + "knowledge", + "create_knowledge", + "build", + "create_app", + "evaluation", + "dataset", + "mark_task", + } + ) + + def test_create_app_still_hangs_off_build_and_nothing_new_was_added(self): + """AC-35's cascade: switching 「构建」 off has to take 「新建应用」 with it, + on the personal-grant path as well as in the role editor.""" + assert UserMenuAccessService._PARENT_DEPENDENCIES["create_app"] == ("admin", "build") + assert [ + key for key in UserMenuAccessService._PARENT_DEPENDENCIES if any(word in key for word in FACTORY_WORDS) + ] == [] + assert UserMenuAccessService.expand_menu_keys_with_dependencies(["create_app"]) == [ + "admin", + "build", + "create_app", + ] + + +class TestBothSidesAgree: + """The inventory is one list living in two languages — check it reads the same.""" + + def test_the_role_editor_offers_exactly_the_effective_second_level_keys(self): + source = ROLE_MENU_SELECTION_TS.read_text(encoding="utf-8") + + workbench = set(_ts_string_list(source, "WORKBENCH_CHILD_MENUS")) + admin = set(_ts_string_list(source, "ADMIN_CHILD_MENUS")) + task_mode = re.search(r'export const TASK_MODE_MENU_ID\s*=\s*"([^"]+)"', source) + assert task_mode + + assert workbench | {task_mode.group(1)} == set(_ROLE_UI_WORKBENCH_CHILDREN) + assert admin == set(_ROLE_UI_ADMIN_CHILDREN) diff --git a/src/backend/test/role/test_management_view_catalog.py b/src/backend/test/role/test_management_view_catalog.py new file mode 100644 index 0000000000..288f9acc84 --- /dev/null +++ b/src/backend/test/role/test_management_view_catalog.py @@ -0,0 +1,62 @@ +"""A tenant view keeps shared role definitions but excludes foreign custom roles.""" + +from contextlib import asynccontextmanager + +import pytest +from sqlalchemy.ext.asyncio import create_async_engine +from sqlmodel.ext.asyncio.session import AsyncSession + +from bisheng.core.context import tenant +from bisheng.core.database import tenant_filter +from bisheng.database.models.role import Role, RoleDao +from test.app_publish.conftest import _sqlite_ddl_quirks + + +@pytest.mark.parametrize("global_view,expected", [(False, {2, 4}), (True, {2, 3, 4, 5})]) +async def test_catalog_and_custom_roles_follow_the_view(monkeypatch, global_view, expected): + from bisheng.database.models import role as module + + tenant_filter.register_tenant_filter_events() + engine = create_async_engine("sqlite+aiosqlite://") + with _sqlite_ddl_quirks([Role.__table__]): + async with engine.begin() as conn: + await conn.run_sync(Role.__table__.create) + + @asynccontextmanager + async def sessions(): + async with AsyncSession(engine, expire_on_commit=False) as s: + yield s + + monkeypatch.setattr(module, "get_async_db_session", sessions) + with tenant.bypass_tenant_filter(): + async with sessions() as s: + for ident, tid, kind in [(2, 1, "global"), (3, 1, "tenant"), (4, 2, "tenant"), (5, 3, "tenant")]: + s.add(Role(id=ident, role_name=str(ident), tenant_id=tid, role_type=kind)) + await s.commit() + bindings = [ + (tenant.current_tenant_id, 1), + (tenant._admin_scope_tenant_id, None if global_view else 2), + (tenant._is_management_api, True), + (tenant.visible_tenant_ids, frozenset({2})), + (tenant._management_all_tenants, global_view), + ] + tokens = [(v, v.set(value)) for v, value in bindings] + try: + rows = await RoleDao.aget_visible_roles(None if global_view else 2) + assert {r.id for r in rows} == expected + assert (await RoleDao.aget_role_by_id(2)).role_type == "global" + foreign = await RoleDao.aget_role_by_id(5) + assert (foreign is not None) == global_view + finally: + for var, token in reversed(tokens): + var.reset(token) + await engine.dispose() + + +def test_user_role_mapping_is_shared_with_domain_import(): + from bisheng.database.models.user_role import UserRole + from bisheng.user.domain.models.user_role import UserRole as DomainUserRole + + assert DomainUserRole is UserRole + assert UserRole.__tablename__ == "userrole" + assert not UserRole.__table__.c.tenant_id.nullable diff --git a/src/backend/test/sandbox/__init__.py b/src/backend/test/sandbox/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/src/backend/test/sandbox/__init__.py @@ -0,0 +1 @@ + diff --git a/src/backend/test/sandbox/conftest.py b/src/backend/test/sandbox/conftest.py new file mode 100644 index 0000000000..6b31be0cdb --- /dev/null +++ b/src/backend/test/sandbox/conftest.py @@ -0,0 +1,18 @@ +"""Put ``src/sandbox-runner`` on sys.path so backend pytest can import it. + +The runner is an independent package (must not ``import bisheng``). +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_TEST_DIR = str(Path(__file__).resolve().parent) +if _TEST_DIR not in sys.path: + sys.path.insert(0, _TEST_DIR) + +_RUNNER_ROOT = Path(__file__).resolve().parents[3] / "sandbox-runner" +_root = str(_RUNNER_ROOT) +if _root not in sys.path: + sys.path.insert(0, _root) diff --git a/src/backend/test/sandbox/fake_runner.py b/src/backend/test/sandbox/fake_runner.py new file mode 100644 index 0000000000..cf149ab1c6 --- /dev/null +++ b/src/backend/test/sandbox/fake_runner.py @@ -0,0 +1,283 @@ +"""In-memory code-runner HTTP stand-in (F068 T013). Must respect URL path params.""" + +from __future__ import annotations + +import hashlib +import io +import json +import tarfile +import uuid +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from urllib.parse import urlparse + +from files import ManifestError, split_copy_in_body + + +class FakeUnreachableError(ConnectionError): + """Raised when a replica is marked unreachable — same class family as a TCP miss.""" + + +class FakeResponse: + """Subset of ``httpx.Response`` that ``ContainerExecutor`` actually reads.""" + + def __init__( + self, + status_code: int, + *, + json_data=None, + content: bytes = b"", + text: str | None = None, + headers: dict | None = None, + ): + self.status_code = status_code + self._json = json_data + self._json_missing = json_data is None and text is not None + if text is not None: + self.content = text.encode("utf-8") if isinstance(text, str) else text + self.text = text if isinstance(text, str) else text.decode("utf-8", "replace") + elif json_data is not None: + encoded = json.dumps(json_data).encode("utf-8") + self.content = encoded + self.text = encoded.decode("utf-8") + else: + self.content = content + self.text = content.decode("utf-8", "replace") + self.headers = headers or {} + + def json(self): + if self._json_missing: + raise json.JSONDecodeError("fake garbage", self.text, 0) + if self._json is None: + return json.loads(self.content.decode("utf-8") or "null") + return self._json + + +def _origin(url: str) -> str: + parsed = urlparse(url) + return f"{parsed.scheme}://{parsed.netloc}" + + +def _path(url: str) -> str: + parsed = urlparse(url) + path = parsed.path or "/" + if path != "/" and path.endswith("/"): + path = path[:-1] + return path or "/" + + +def _open_tar(fileobj, mode: str): + return tarfile.open(fileobj=fileobj, mode=mode, encoding="utf-8", format=tarfile.PAX_FORMAT) + + +def _tar_gz(members: dict[str, bytes]) -> bytes: + buf = io.BytesIO() + with _open_tar(buf, "w:gz") as tar: + for name, data in members.items(): + info = tarfile.TarInfo(name=name.replace("\\", "/")) + info.size = len(data) + tar.addfile(info, io.BytesIO(data)) + return buf.getvalue() + + +def _untar(payload: bytes) -> dict[str, bytes]: + if not payload: + return {} + buf = io.BytesIO(payload) + out: dict[str, bytes] = {} + try: + tar = _open_tar(buf, "r:gz") + except tarfile.TarError: + buf.seek(0) + tar = _open_tar(buf, "r:") + with tar: + for member in tar: + if not member.isfile(): + continue + rel = member.name.replace("\\", "/") + while rel.startswith("./"): + rel = rel[2:] + parts = rel.split("/") + if rel.startswith("/") or any(part == ".." for part in parts): + continue + rel = "/".join(p for p in parts if p not in ("", ".")) + if not rel: + continue + fh = tar.extractfile(member) + out[rel] = fh.read() if fh else b"" + return out + + +def _md5(data: bytes) -> str: + return hashlib.md5(data).hexdigest() + + +@dataclass +class FakeSession: + session_id: str + lease_token: str + files: dict[str, bytes] = field(default_factory=dict) + md5_index: dict[str, str] = field(default_factory=dict) + copy_out_allowed: bool = False + changed: dict[str, bytes] = field(default_factory=dict) + + +@dataclass +class FakeReplica: + base_url: str + token: str = "test-token" + reachable: bool = True + always_503: bool = False + protocol_garbage: bool = False + exec_timeout: bool = False + max_sessions: int = 32 + write_after_exec: dict[str, bytes] = field(default_factory=dict) + exec_stdout: str = "ok\n" + sessions: dict[str, FakeSession] = field(default_factory=dict) + + def create_session(self) -> FakeSession: + session = FakeSession(session_id=str(uuid.uuid4()), lease_token=uuid.uuid4().hex) + self.sessions[session.session_id] = session + return session + + +class FakeRunnerClient: + """HTTP client whose store is keyed by ``(origin, session_id, path)``. + + A PUT to ``/v1/sessions/A/files`` must never surface on session B — that is + pitfall 12 (E2B ``files.list`` defaulting to one directory level / ignoring + the path). Nested copy-out members such as ``output/sub/a.txt`` are kept. + """ + + def __init__(self): + self.replicas: dict[str, FakeReplica] = {} + self.calls: list[tuple[str, str]] = [] + self.last_put_members: list[str] = [] + self.last_put_bytes: int = 0 + + def add_replica(self, base_url: str, **kwargs) -> FakeReplica: + replica = FakeReplica(base_url=base_url.rstrip("/"), **kwargs) + self.replicas[_origin(replica.base_url)] = replica + return replica + + def session_files(self, session_id: str) -> dict[str, bytes]: + for replica in self.replicas.values(): + session = replica.sessions.get(session_id) + if session is not None: + return dict(session.files) + return {} + + def request( + self, + method: str, + url: str, + *, + headers: dict | None = None, + content: bytes | None = None, + json: dict | None = None, + timeout=None, + ) -> FakeResponse: + del timeout + method = method.upper() + self.calls.append((method, url)) + origin = _origin(url) + replica = self.replicas.get(origin) + if replica is None or not replica.reachable: + raise FakeUnreachableError(f"unreachable {origin}") + path = _path(url) + header_map = {str(k).lower(): v for k, v in (headers or {}).items()} + + if path == "/health" and method == "GET": + return FakeResponse(200, json_data={"status": "ok"}) + + if path == "/v1/sessions" and method == "POST": + return self._create(replica, header_map) + + parts = path.split("/") + # /v1/sessions/{id} or /v1/sessions/{id}/files or /v1/sessions/{id}/exec + if len(parts) >= 4 and parts[1] == "v1" and parts[2] == "sessions": + session_id = parts[3] + rest = parts[4:] + session = replica.sessions.get(session_id) + if session is None: + return FakeResponse(404, json_data={"error": "not found"}) + lease = (header_map.get("x-lease-token") or "").strip() + if lease != session.lease_token: + return FakeResponse(403, json_data={"error": "forbidden"}) + if not rest and method == "DELETE": + replica.sessions.pop(session_id, None) + return FakeResponse(200, json_data={"ok": True}) + if rest == ["files"] and method == "PUT": + return self._put_files(session, content or b"") + if rest == ["files"] and method == "GET": + return self._get_files(session) + if rest == ["exec"] and method == "POST": + return self._exec(replica, session, json or {}) + return FakeResponse(404, json_data={"error": "not found"}) + + def _create(self, replica: FakeReplica, header_map: dict) -> FakeResponse: + auth = (header_map.get("authorization") or "").strip() + expected = f"Bearer {replica.token}" + if replica.token and auth != expected: + return FakeResponse(401, json_data={"error": "unauthorized"}) + if replica.always_503 or len(replica.sessions) >= replica.max_sessions: + return FakeResponse(503, json_data={"error": "capacity exceeded"}) + session = replica.create_session() + expires = datetime.now(UTC) + timedelta(seconds=900) + return FakeResponse( + 200, + json_data={ + "session_id": session.session_id, + "lease_token": session.lease_token, + "lease_expires_at": expires.isoformat().replace("+00:00", "Z"), + }, + ) + + def _put_files(self, session: FakeSession, body: bytes) -> FakeResponse: + try: + manifest, tar_body = split_copy_in_body(body) + except ManifestError: + return FakeResponse(400, json_data={"error": "invalid manifest"}) + members = _untar(tar_body) + self.last_put_body = body + self.last_put_members = list(members) + self.last_put_bytes = sum(len(v) for v in members.values()) + written: list[str] = [] + skipped: list[dict] = [] + for rel, data in members.items(): + expected = manifest.get(rel) + if expected and session.md5_index.get(rel) == expected: + continue + session.files[rel] = data + session.md5_index[rel] = expected or _md5(data) + written.append(rel) + return FakeResponse(200, json_data={"written": written, "skipped": skipped}) + + def _get_files(self, session: FakeSession) -> FakeResponse: + if not session.copy_out_allowed: + return FakeResponse(200, content=_tar_gz({})) + return FakeResponse(200, content=_tar_gz(session.changed)) + + def _exec(self, replica: FakeReplica, session: FakeSession, payload: dict) -> FakeResponse: + del payload + if replica.protocol_garbage: + return FakeResponse(200, text="{{not-json") + if replica.exec_timeout: + session.copy_out_allowed = False + session.changed = {} + return FakeResponse( + 200, + json_data={"exitcode": 124, "stdout": "", "stderr": "", "duration_ms": 1}, + ) + session.copy_out_allowed = True + session.changed = dict(replica.write_after_exec) + session.files.update(session.changed) + return FakeResponse( + 200, + json_data={ + "exitcode": 0, + "stdout": replica.exec_stdout, + "stderr": "", + "duration_ms": 1, + }, + ) diff --git a/src/backend/test/sandbox/release/__init__.py b/src/backend/test/sandbox/release/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/src/backend/test/sandbox/release/__init__.py @@ -0,0 +1 @@ + diff --git a/src/backend/test/sandbox/release/check_large_tool_results.py b/src/backend/test/sandbox/release/check_large_tool_results.py new file mode 100644 index 0000000000..bcb56a15a5 --- /dev/null +++ b/src/backend/test/sandbox/release/check_large_tool_results.py @@ -0,0 +1,169 @@ +"""Check that a long web_fetch result is written to /large_tool_results/. + +Does not call a model, the sandbox, or the live 网页读取 API. The release +model's context window is unknown, so the budget middleware is not mounted and +the live line is FilesystemMiddleware's default: 20000 tokens, 4 characters +each. 网页读取 truncates to max_chars (default 20000) before that line, so +this script feeds a longer result through the same writer. + +Run from src/backend: + + uv run python test/sandbox/release/check_large_tool_results.py +""" + +from __future__ import annotations + +import importlib.util +import inspect +import sys +from pathlib import Path + +from deepagents import create_deep_agent +from deepagents.backends import StateBackend +from deepagents.graph import DeepAgentState +from deepagents.middleware.filesystem import NUM_CHARS_PER_TOKEN, FilesystemMiddleware +from langchain_core.language_models import BaseChatModel +from langchain_core.messages import AIMessage, SystemMessage, ToolMessage +from langchain_core.outputs import ChatGeneration, ChatResult +from langchain_core.tools import tool +from langgraph.checkpoint.memory import InMemorySaver + +_PARSE = Path(__file__).with_name("parse.py") +_spec = importlib.util.spec_from_file_location("release_parse", _PARSE) +_parse = importlib.util.module_from_spec(_spec) +sys.modules["release_parse"] = _parse +_spec.loader.exec_module(_parse) +offload_from_text = _parse.offload_from_text + +_PAGES: dict[str, str] = {} + + +@tool +def web_fetch(url: str) -> str: + """Read one page and return its body.""" + return _PAGES[url] + + +class _Scripted(BaseChatModel): + replies: list = [] + + @property + def _llm_type(self) -> str: + return "large-tool-result-check" + + def bind_tools(self, *args, **kwargs): + return self + + def _generate(self, messages, stop=None, run_manager=None, **kwargs): + if not any(isinstance(message, SystemMessage) for message in messages): + raise RuntimeError("scripted model saw no system message") + return ChatResult(generations=[ChatGeneration(message=self.replies.pop(0))]) + + +def _char_limit() -> int: + default_tokens = ( + inspect.signature(FilesystemMiddleware.__init__).parameters["tool_token_limit_before_evict"].default + ) + return int(default_tokens) * NUM_CHARS_PER_TOKEN + + +def _long_page(limit_chars: int) -> str: + """A multi-line page one step past the framework's character line.""" + first = "PAGE-FIRST" + last = "PAGE-LAST" + line = "x" * 80 + lines = [first] + while sum(len(item) + 1 for item in lines) + len(last) <= limit_chars: + lines.append(line) + lines.append(last) + return "\n".join(lines) + + +def _run(url: str, call_id: str) -> dict: + model = _Scripted( + replies=[ + AIMessage("", tool_calls=[{"name": "web_fetch", "args": {"url": url}, "id": call_id}]), + AIMessage("done"), + ] + ) + agent = create_deep_agent( + model=model, + tools=[web_fetch], + state_schema=DeepAgentState, + backend=StateBackend(), + checkpointer=InMemorySaver(), + ) + return agent.invoke( + {"messages": [{"role": "user", "content": "read the page"}]}, + {"configurable": {"thread_id": call_id}}, + ) + + +def _tool_message(result: dict, call_id: str) -> ToolMessage: + return next( + message + for message in result["messages"] + if isinstance(message, ToolMessage) and message.tool_call_id == call_id + ) + + +def _stored_text(entry) -> str: + if isinstance(entry, str): + return entry + content = entry.get("content") if isinstance(entry, dict) else None + if isinstance(content, str): + return content + if isinstance(content, list): + return "\n".join(str(line) for line in content) + raise RuntimeError(f"unrecognized stored file: {type(entry).__name__}") + + +def _check_long(limit_chars: int) -> None: + body = _long_page(limit_chars) + _PAGES["long"] = body + result = _run("long", "call-long") + message = _tool_message(result, "call-long") + text = message.content if isinstance(message.content, str) else str(message.content) + notice = offload_from_text(text) + if notice is None: + raise SystemExit(f"长结果没有转到 large_tool_results,正文 {len(body)} 字符,门槛 {limit_chars}") + path = "/" + notice["path"] + files = result.get("files") or {} + if path not in files: + raise SystemExit(f"预览指向 {path},状态里没有这个文件") + stored = _stored_text(files[path]) + stored_lines = stored.splitlines() + original_lines = body.splitlines() + if stored != body: + raise SystemExit( + f"转存文件与原文不一致:文件 {len(stored)} 字符、{len(stored_lines)} 行," + f"原文 {len(body)} 字符、{len(original_lines)} 行" + ) + if notice["count"] != len(original_lines): + raise SystemExit(f"预览行数 {notice['count']},原文 {len(original_lines)} 行") + if notice["first"] != original_lines[0] or notice["last"] != original_lines[-1]: + raise SystemExit(f"预览首末行是 {notice['first']!r} / {notice['last']!r}") + print(f"长结果已转存:{len(body)} 字符、{len(original_lines)} 行,门槛 {limit_chars} 字符,路径 {notice['path']}") + + +def _check_short() -> None: + _PAGES["short"] = "短正文" + result = _run("short", "call-short") + message = _tool_message(result, "call-short") + text = message.content if isinstance(message.content, str) else str(message.content) + if text != "短正文": + raise SystemExit(f"短结果被改写了:{text[:200]!r}") + files = result.get("files") or {} + if any(str(path).startswith("/large_tool_results/") for path in files): + raise SystemExit("短结果也被写进了 large_tool_results") + print("短结果留在工具消息里") + + +def main() -> None: + limit_chars = _char_limit() + _check_long(limit_chars) + _check_short() + + +if __name__ == "__main__": + main() diff --git a/src/backend/test/sandbox/release/client.py b/src/backend/test/sandbox/release/client.py new file mode 100644 index 0000000000..147e3f1fb7 --- /dev/null +++ b/src/backend/test/sandbox/release/client.py @@ -0,0 +1,684 @@ +"""Logged-in calls to the BiSheng routes the release plan names.""" + +from __future__ import annotations + +import base64 +import json +import os +import time +import uuid +from typing import Any +from urllib.parse import urlsplit + +import httpx + +from test.sandbox.release.env import API_BASE, FRONTEND_ORIGIN, TOOL_NAME +from test.sandbox.release.parse import history_steps, parse_object, task_tool_results + +MASK = "********" + + +class ApiError(RuntimeError): + pass + + +class TaskVersion(str): + """Session version id that also carries the session id for status polling.""" + + session_id: str + + def __new__(cls, version_id: str, session_id: str): + obj = str.__new__(cls, version_id) + obj.session_id = session_id + return obj + + +def encrypt_password(client: httpx.Client, password: str) -> str: + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import padding + + public_key_pem = client.get("/user/public_key").json()["data"]["public_key"] + public_key = serialization.load_pem_public_key(public_key_pem.encode()) + encrypted = public_key.encrypt(password.encode(), padding.PKCS1v15()) + return base64.b64encode(encrypted).decode() + + +def login(client: httpx.Client) -> str: + username = os.environ.get("E2E_ADMIN_USER", "admin") + password = os.environ.get("E2E_ADMIN_PASSWORD", "Bisheng@top1") + resp = client.post( + "/user/login", + json={"user_name": username, "password": encrypt_password(client, password)}, + ) + body = resp.json() + if body.get("status_code") != 200: + raise ApiError(body.get("status_message") or "login failed") + return body["data"]["access_token"] + + +def open_api() -> httpx.Client | None: + """Logged-in client. ``open_api.problem`` explains a miss (captcha, password, or the process).""" + open_api.problem = "" + client = httpx.Client(base_url=API_BASE, timeout=60.0) + token = os.environ.get("E2E_ACCESS_TOKEN") + if not token: + try: + token = login(client) + except (httpx.HTTPError, ApiError, KeyError, ValueError) as exc: + open_api.problem = str(exc) or "后端不可登录" + client.close() + return None + client.cookies.set("access_token_cookie", token) + client.headers["Cookie"] = f"access_token_cookie={token}" + return client + + +def data_of(resp: httpx.Response) -> Any: + try: + body = resp.json() + except json.JSONDecodeError as exc: + raise ApiError(resp.text[:300]) from exc + if resp.status_code != 200 or body.get("status_code") != 200: + raise ApiError(f"HTTP {resp.status_code} {body}") + return body.get("data") + + +def iter_sse(resp: httpx.Response): + event = "message" + lines: list[str] = [] + for raw in resp.iter_lines(): + line = raw.decode() if isinstance(raw, bytes) else raw + if line == "": + if lines: + yield event, "\n".join(lines) + event = "message" + lines = [] + continue + if line.startswith("event:"): + event = line.split(":", 1)[1].strip() + elif line.startswith("data:"): + lines.append(line.split(":", 1)[1].lstrip()) + if lines: + yield event, "\n".join(lines) + + +def tool_categories(client: httpx.Client) -> list[dict]: + payload = data_of(client.get("/tool", params={"is_preset": 1})) + return payload if isinstance(payload, list) else [] + + +def code_interpreter_type(categories: list[dict]) -> dict | None: + for item in categories: + children = item.get("children") or [] + if any(isinstance(child, dict) and child.get("tool_key") == TOOL_NAME for child in children): + return item + return None + + +def extra_of(item: dict) -> dict: + extra = item.get("extra") + parsed = parse_object(extra) if isinstance(extra, str) else extra if isinstance(extra, dict) else None + return dict(parsed or {}) + + +def read_execution_type(client: httpx.Client) -> str | None: + item = code_interpreter_type(tool_categories(client)) + if item is None: + return None + return extra_of(item).get("type") + + +def save_execution_type(client: httpx.Client, mode: str) -> None: + item = code_interpreter_type(tool_categories(client)) + if item is None or item.get("id") in (None, ""): + raise ApiError("代码执行器不在工具列表里") + data_of(client.post("/tool/config", json={"tool_id": item["id"], "extra": {"type": mode}})) + + +def _category_with_tool(rows: list, tool_key: str) -> dict | None: + for item in rows: + if not isinstance(item, dict): + continue + children = item.get("children") or [] + if any(isinstance(child, dict) and child.get("tool_key") == tool_key for child in children): + return item + return None + + +def tool_category_payload(client: httpx.Client, tool_key: str) -> dict | None: + """One preset category that contains ``tool_key``, shaped for workbench submit.""" + item = _category_with_tool(tool_categories(client), tool_key) + if item is None: + preset = data_of(client.get("/tool/linsight/preset")) + rows = preset if isinstance(preset, list) else [] + item = _category_with_tool(rows, tool_key) + if item is None or item.get("id") in (None, ""): + return None + children = [] + for child in item.get("children") or []: + if not isinstance(child, dict) or child.get("id") in (None, ""): + continue + children.append( + { + "id": int(child["id"]), + "name": child.get("name"), + "tool_key": child.get("tool_key"), + "desc": child.get("desc"), + } + ) + if not children: + return None + return { + "id": int(item["id"]), + "name": item.get("name"), + "is_preset": item.get("is_preset", 1), + "desc": item.get("desc") or item.get("description"), + "children": children, + } + + +def linsight_tool_payload(client: httpx.Client) -> dict: + item = tool_category_payload(client, TOOL_NAME) + if item is None: + raise ApiError("任务模式工具列表没有代码执行器") + return item + + +def workbench_model_id(client: httpx.Client) -> str | None: + chosen = os.environ.get("BISHENG_RELEASE_MODEL_ID") + if chosen: + return chosen + payload = data_of(client.get("/llm/workbench")) + if isinstance(payload, dict) and isinstance(payload.get("data"), dict): + payload = payload["data"] + if not isinstance(payload, dict): + return None + for key in ("linsight_default_model_id", "chat_default_model_id"): + if payload.get(key): + return str(payload[key]) + models = payload.get("models") or [] + if models and isinstance(models[0], dict) and models[0].get("id") not in (None, ""): + return str(models[0]["id"]) + return None + + +def child_tool(client: httpx.Client) -> dict | None: + item = code_interpreter_type(tool_categories(client)) + if item is None: + return None + for child in item.get("children") or []: + if isinstance(child, dict) and child.get("tool_key") == TOOL_NAME: + return child + return None + + +def wait_files_parsed(client: httpx.Client, files: list[dict], timeout_s: float = 180.0) -> list[dict]: + """Submit accepts only parsing_status completed. Upload returns running.""" + pending = [dict(item) for item in files] + if not pending or all(item.get("parsing_status") == "completed" for item in pending): + return pending + ids = [str(item["file_id"]) for item in pending if item.get("file_id")] + deadline = time.time() + timeout_s + while time.time() < deadline: + payload = data_of(client.post("/linsight/workbench/file-parsing-status", json={"file_ids": ids})) + rows = payload if isinstance(payload, list) else [] + by_id: dict[str, dict] = {} + for index, row in enumerate(rows): + parsed = parse_object(row) if not isinstance(row, dict) else row + if not isinstance(parsed, dict): + continue + key = str(parsed.get("file_id") or (ids[index] if index < len(ids) else "")) + if key: + by_id[key] = parsed + done = True + for item in pending: + row = by_id.get(str(item.get("file_id"))) + status = str(row.get("parsing_status") or "") if row else "" + if status in {"failed", "unsupported", "expired"}: + detail = row.get("error_message") if row else "" + raise ApiError(f"{item.get('file_name')} 解析状态是 {status},不能提交。{detail}") + if status != "completed": + done = False + continue + item["parsing_status"] = "completed" + if done: + return pending + time.sleep(2) + names = ", ".join(str(item.get("file_name")) for item in pending if item.get("parsing_status") != "completed") + raise ApiError(f"文件解析未在时限内变为 completed: {names}") + + +def upload_linsight_file(client: httpx.Client, filename: str, content: bytes) -> dict: + resp = client.post( + "/linsight/workbench/upload-file", files={"file": (filename, content, "application/octet-stream")} + ) + uploaded = data_of(resp) + if not isinstance(uploaded, dict): + raise ApiError("upload-file 没有返回 file_id") + return { + "file_id": uploaded.get("file_id"), + "file_name": uploaded.get("file_name") or filename, + "parsing_status": uploaded.get("parsing_status") or "", + } + + +def submit_task( + client: httpx.Client, + question: str, + files: list[dict] | None = None, + skills: list[str] | None = None, + tools: list[dict] | None = None, +) -> str: + body: dict[str, Any] = { + "question": question, + "tools": tools if tools is not None else [linsight_tool_payload(client)], + "files": files or [], + "org_knowledge_enabled": False, + "personal_knowledge_enabled": False, + } + if skills: + body["skills"] = skills + if files: + files = wait_files_parsed(client, files) + body["files"] = files + model_id = workbench_model_id(client) + if model_id: + body["model"] = model_id + version_id = "" + session_id = "" + with client.stream("POST", "/linsight/workbench/submit", json=body, timeout=120.0) as resp: + if resp.status_code != 200: + raise ApiError(f"submit HTTP {resp.status_code}") + for event, raw in iter_sse(resp): + try: + payload = json.loads(raw) + except json.JSONDecodeError: + continue + if event == "error" or payload.get("status_code") not in (None, 200): + raise ApiError(payload.get("status_message") or raw[:300]) + if event == "linsight_workbench_submit": + version = payload.get("linsight_session_version") or {} + version_id = str(version.get("id") or "") + session_id = str(version.get("session_id") or "") + break + if not version_id: + raise ApiError("submit 没有返回 linsight_session_version.id") + started = data_of( + client.post("/linsight/workbench/start-execute", json={"linsight_session_version_id": version_id}) + ) + if started is not True: + raise ApiError(f"start-execute data 不是 true: {started!r}") + return TaskVersion(version_id, session_id) + + +_SESSION_DONE = {"completed", "failed", "terminated"} + + +def session_version_status(client: httpx.Client, session_id: str, version_id: str) -> str: + """Status of one session version. Empty when the list does not contain it.""" + payload = data_of(client.get("/linsight/workbench/session-version-list", params={"session_id": session_id})) + rows = payload if isinstance(payload, list) else [] + for row in rows: + if isinstance(row, dict) and str(row.get("id") or "") == version_id: + return str(row.get("status") or "").lower() + return "" + + +def continue_round_done(status: str, snap: tuple, baseline: tuple) -> bool: + """A follow-up round is done when the session has left in_progress and the task detail changed. + + The execute task stays success while the model retries. The first new code + call can be a failed attempt; the file from that moment is still the + previous round. The session status returns to completed only after the + round's last step is stored. + """ + return status in _SESSION_DONE and snap != baseline + + +def task_detail(client: httpx.Client, version_id: str) -> list: + payload = data_of(client.get("/linsight/workbench/execute-task-detail", params={"session_version_id": version_id})) + return payload if isinstance(payload, list) else [] + + +def _task_snapshot(tasks: list) -> tuple: + """Identity of a detail payload: task id, status, and history length.""" + marks = [] + for task in tasks or []: + if not isinstance(task, dict): + continue + history = task.get("history") + count = len(history) if isinstance(history, list) else 0 + marks.append((str(task.get("id") or ""), str(task.get("status") or ""), count)) + return tuple(marks) + + +def _finished_code_calls(tasks: list) -> tuple: + """Finished code-interpreter calls. Continue keeps the execute task on success while history grows, so length alone is not a new round.""" + ids = [] + for index, item in enumerate(task_tool_results(tasks)): + step = item["step"] + call_id = step.get("call_id") or step.get("id") + ids.append(str(call_id) if call_id else f"{index}:{len(str(step.get('output') or ''))}") + return tuple(ids) + + +def followup_ready( + last: list, + *, + baseline: tuple, + previous_codes: tuple, + was_waiting: bool, + stable: tuple | None, +) -> tuple[bool, tuple | None]: + """Whether a continue or user-input round is done. + + Continue leaves the execute task on success. The first history growth is + often a write or a plan step, and the code result arrives later. Returning + on that first growth downloads the previous round's file. A continue round + is done when a new finished code call is present and the same detail has + been seen twice. A user-input round is done when the task has left + waiting_for_user_input, reached a terminal status, and stayed there. + """ + snap = _task_snapshot(last) + if snap == baseline: + return False, None + terminal = {"success", "failed", "terminated"} + terminal_now = bool(last) and all(isinstance(task, dict) and task.get("status") in terminal for task in last) + waiting_now = any(isinstance(task, dict) and task.get("status") == "waiting_for_user_input" for task in last) + if was_waiting: + progressed = (not waiting_now) and terminal_now + else: + progressed = terminal_now and _finished_code_calls(last) != previous_codes + if progressed and stable == snap: + return True, stable + return False, snap if progressed else None + + +def wait_tasks( + client: httpx.Client, + version_id: str, + timeout_s: float | None = None, + previous: list | None = None, +) -> list: + """Poll until every execute task is terminal, or one is waiting for the user. + + ``previous`` is the detail from the round that just finished. Continue and + user-input leave that success/waiting row in place until the worker picks + the new round up, so an immediate poll would return the previous round. + """ + limit = timeout_s if timeout_s is not None else float(os.environ.get("BISHENG_RELEASE_TIMEOUT", "180")) + deadline = time.time() + limit + last: list = [] + baseline = _task_snapshot(previous) if previous is not None else None + previous_codes = _finished_code_calls(previous) if previous is not None else None + was_waiting = bool(previous) and any( + isinstance(task, dict) and task.get("status") == "waiting_for_user_input" for task in previous + ) + stable: tuple | None = None + terminal = {"success", "failed", "terminated"} + session_id = getattr(version_id, "session_id", "") or "" + while time.time() < deadline: + last = task_detail(client, version_id) + if previous is None: + if last and all(task.get("status") in terminal for task in last if isinstance(task, dict)): + return last + if any(isinstance(task, dict) and task.get("status") == "waiting_for_user_input" for task in last): + return last + elif session_id: + status = session_version_status(client, session_id, str(version_id)) + if continue_round_done(status, _task_snapshot(last), baseline): + return last + else: + done, stable = followup_ready( + last, + baseline=baseline, + previous_codes=previous_codes, + was_waiting=was_waiting, + stable=stable, + ) + if done: + return last + time.sleep(2) + return last + + +def waiting_task_id(tasks: list) -> str | None: + for task in tasks: + if isinstance(task, dict) and task.get("status") == "waiting_for_user_input" and task.get("id"): + return str(task["id"]) + for step in history_steps(tasks): + if step.get("name") in {"ask_user", "call_user_input"} and step.get("task_id"): + return str(step["task_id"]) + return None + + +def continue_task(client: httpx.Client, version_id: str, question: str) -> None: + started = data_of( + client.post( + "/linsight/workbench/continue", + json={"session_version_id": version_id, "question": question}, + ) + ) + if started is not True: + raise ApiError(f"continue data 不是 true: {started!r}") + + +def user_input(client: httpx.Client, version_id: str, task_id: str, text: str) -> None: + started = data_of( + client.post( + "/linsight/workbench/user-input", + json={ + "session_version_id": version_id, + "linsight_execute_task_id": task_id, + "input_content": text, + }, + ) + ) + if started is not True: + raise ApiError(f"user-input data 不是 true: {started!r}") + + +def _presigned_proxy_path(file_url: str) -> str: + """file_list entries are presigned MinIO URLs. Strip the host and use the frontend proxy.""" + parts = urlsplit(file_url) + path = parts.path or "" + if not path.startswith("/"): + raise ApiError(f"分享地址没有路径: {file_url[:120]}") + return path + (f"?{parts.query}" if parts.query else "") + + +def download_bytes(client: httpx.Client, version_id: str, file_url: str) -> bytes: + if file_url.startswith("http://") or file_url.startswith("https://"): + path = _presigned_proxy_path(file_url) + else: + payload = data_of( + client.post( + "/linsight/workbench/file_download", + json={"file_url": file_url, "session_version_id": version_id}, + ) + ) + if not isinstance(payload, dict): + raise ApiError("file_download 没有 data.file_path") + path = payload.get("file_path") or "" + if not isinstance(path, str) or not path.startswith("/"): + raise ApiError(f"file_path 不是去掉主机名的分享路径: {path!r}") + fetched = httpx.get(f"{FRONTEND_ORIGIN}{path}", timeout=60.0, follow_redirects=True) + if fetched.status_code != 200 or not fetched.content: + raise ApiError(f"前端代理取文件失败 HTTP {fetched.status_code} {path}") + return fetched.content + + +def file_urls(result: dict | None) -> list[str]: + if not result: + return [] + urls = [] + for item in result.get("file_list") or []: + if isinstance(item, str) and item: + urls.append(item) + elif isinstance(item, dict) and item.get("url"): + urls.append(str(item["url"])) + return urls + + +def first_sum_result(tasks: list, accept) -> dict | None: + """A code-interpreter step accept() allows, else the last step.""" + from test.sandbox.release.parse import chosen_tool_result + + return chosen_tool_result([item["result"] for item in task_tool_results(tasks)], accept) + + +def run_task_question( + client: httpx.Client, + question: str, + files: list[dict] | None = None, + skills: list[str] | None = None, + tools: list[dict] | None = None, +) -> tuple[str, list]: + version_id = submit_task(client, question, files=files, skills=skills, tools=tools) + return version_id, wait_tasks(client, version_id) + + +def daily_sum(client: httpx.Client, prompt: str) -> dict | None: + from test.sandbox.release.parse import chosen_tool_result, daily_tool_result, log_sum + + model_id = workbench_model_id(client) + child = child_tool(client) + if not model_id or child is None or child.get("id") in (None, ""): + return None + body = { + "clientTimestamp": time.strftime("%Y-%m-%dT%H:%M:%S"), + "model": model_id, + "text": prompt, + "tools": [{"id": child["id"], "tool_key": TOOL_NAME, "type": "tool"}], + "task_mode": False, + "search_enabled": False, + } + + found: list = [] + with client.stream("POST", "/workstation/chat/completions", json=body, timeout=180.0) as resp: + if resp.status_code != 200: + raise ApiError(f"daily chat HTTP {resp.status_code}") + for event, raw in iter_sse(resp): + try: + payload = json.loads(raw) + except json.JSONDecodeError: + continue + parsed = daily_tool_result(event, payload) + if parsed: + found.append(parsed) + return chosen_tool_result(found, log_sum) + + +def _ws_url(path: str) -> str: + base = API_BASE.replace("https://", "wss://").replace("http://", "ws://") + return f"{base}{path}" + + +def _token_of(client: httpx.Client) -> str: + return client.cookies.get("access_token_cookie") or "" + + +def _ws_connect(path: str, token: str): + """Workflow and assistant sockets authenticate from the access cookie. + + The ``t`` query argument is overwritten by that cookie and never decoded. + """ + import websockets + + if not token: + raise ApiError("WebSocket 没有 access_token_cookie") + return websockets.sync.client.connect( + _ws_url(path), + additional_headers={"Cookie": f"access_token_cookie={token}"}, + open_timeout=10, + ) + + +def workflow_events(client: httpx.Client, workflow_id: str, text: str, timeout_s: float = 180.0) -> list[dict]: + flow = data_of(client.get(f"/workflow/get_one_flow/{workflow_id}")) + if not isinstance(flow, dict): + raise ApiError("工作流不存在") + graph = flow.get("data") if isinstance(flow.get("data"), dict) else {} + init_data = {key: value for key, value in flow.items() if key != "data"} + init_data.update(graph) + init = {"action": "init_data", "flow_id": workflow_id, "data": init_data} + frames: list[dict] = [] + token = _token_of(client) + deadline = time.time() + timeout_s + with _ws_connect(f"/workflow/chat/{workflow_id}", token) as ws: + ws.send(json.dumps(init)) + node_id = "" + variable = "" + sent = False + while time.time() < deadline: + raw = ws.recv(timeout=max(1, deadline - time.time())) + frame = json.loads(raw) + if isinstance(frame, dict): + frames.append(frame) + message = frame.get("message") if isinstance(frame.get("message"), dict) else {} + if frame.get("category") == "input" and not sent: + node_id = str(message.get("node_id") or "") + schema = message.get("input_schema") or message.get("schema") or {} + variable = _input_key(schema) or "user_input" + ws.send( + json.dumps( + { + "action": "input", + "flow_id": workflow_id, + "data": { + node_id: { + "data": {variable: text}, + "message": text, + "message_id": message.get("message_id") or str(uuid.uuid4()), + "category": "question", + "extra": "", + "source": 0, + } + }, + } + ) + ) + sent = True + if frame.get("category") == "processing" and frame.get("type") == "close": + break + return frames + + +def _input_key(schema: Any) -> str: + if isinstance(schema, dict): + value = schema.get("value") + if isinstance(value, list) and value and isinstance(value[0], dict) and value[0].get("key"): + return str(value[0]["key"]) + if schema.get("key"): + return str(schema["key"]) + return "" + + +def assistant_events(client: httpx.Client, assistant_id: str, text: str, timeout_s: float = 180.0) -> list[dict]: + info = data_of(client.get(f"/assistant/info/{assistant_id}")) + if not isinstance(info, dict): + raise ApiError("助手不存在") + chat_id = str(uuid.uuid4()) + payload = { + "chatHistory": [], + "flow_id": assistant_id, + "chat_id": chat_id, + "name": info.get("name") or "", + "description": info.get("desc") or info.get("description") or "", + "inputs": {"data": {"chatId": chat_id, "id": assistant_id, "type": 5}, "input": text}, + } + frames: list[dict] = [] + token = _token_of(client) + deadline = time.time() + timeout_s + with _ws_connect(f"/assistant/chat/{assistant_id}", token) as ws: + ws.send(json.dumps(payload)) + while time.time() < deadline: + raw = ws.recv(timeout=max(1, deadline - time.time())) + frame = json.loads(raw) + if isinstance(frame, dict): + frames.append(frame) + # Each model turn emits processing/end, including the turn that + # only chooses a tool. The assistant turn itself ends with processing/close. + if frame.get("category") == "processing" and frame.get("type") == "close": + break + return frames diff --git a/src/backend/test/sandbox/release/conftest.py b/src/backend/test/sandbox/release/conftest.py new file mode 100644 index 0000000000..57e98640fd --- /dev/null +++ b/src/backend/test/sandbox/release/conftest.py @@ -0,0 +1,23 @@ +"""Live release fixtures. Missing processes are skipped, never passed.""" + +from __future__ import annotations + +import httpx +import pytest + +from test.sandbox.release.env import SANDBOX_URL + + +def sandbox_reachable() -> bool: + try: + resp = httpx.get(f"{SANDBOX_URL}/health", timeout=2.0) + except httpx.HTTPError: + return False + return resp.status_code == 200 + + +@pytest.fixture(scope="session") +def sandbox_url() -> str: + if not sandbox_reachable(): + pytest.skip("沙箱进程未启动,容器组未测") + return SANDBOX_URL diff --git a/src/backend/test/sandbox/release/env.py b/src/backend/test/sandbox/release/env.py new file mode 100644 index 0000000000..c93081a3cd --- /dev/null +++ b/src/backend/test/sandbox/release/env.py @@ -0,0 +1,24 @@ +"""Addresses and names shared by the release suite. Secrets stay in the environment.""" + +from __future__ import annotations + +import os + +SANDBOX_URL = os.environ.get("SANDBOX_URL", "http://127.0.0.1:8080").rstrip("/") +SANDBOX_TOKEN = os.environ.get("SANDBOX_TOKEN", "bisheng-sandbox-local") +API_BASE = os.environ.get("E2E_API_BASE", "http://127.0.0.1:7860/api/v1").rstrip("/") +FRONTEND_ORIGIN = os.environ.get("BISHENG_FRONTEND_ORIGIN", "http://127.0.0.1:3001").rstrip("/") +TOOL_NAME = "bisheng_code_interpreter" +SUM_PROMPT = "必须用代码工具执行 print(sum([1,2,3])),不要心算。" +# Seeded in the current environment: 沙箱发布测试代码工具 / 代码助手 / 代码节点. +WORKFLOW_ID = os.environ.get("BISHENG_RELEASE_WORKFLOW_ID") or "520d961f49664b0eb8b588fdc9421148" +ASSISTANT_ID = os.environ.get("BISHENG_RELEASE_ASSISTANT_ID") or "e61c44f5d16c492392652f593e5594ec" +CODE_WORKFLOW_ID = os.environ.get("BISHENG_RELEASE_CODE_WORKFLOW_ID") or "7e3d3ff4c5464f1eaea6519d45bdc53c" +# Seeded built-in bundle. The task copies it to /skills/bisheng-docx/SKILL.md. +SKILL_FILE = os.environ.get("BISHENG_RELEASE_SKILL_FILE", "bisheng-docx/SKILL.md") +FIXED_MODEL = os.environ.get("BISHENG_RELEASE_FIXED_MODEL", "1") +PAGE_LABELS = { + "executionLocalLabel": "本地隔离运行", + "executionContainerLabel": "沙箱运行", + "executionE2bLabel": "E2B沙箱运行", +} diff --git a/src/backend/test/sandbox/release/parse.py b/src/backend/test/sandbox/release/parse.py new file mode 100644 index 0000000000..5724332cf0 --- /dev/null +++ b/src/backend/test/sandbox/release/parse.py @@ -0,0 +1,237 @@ +"""Parse the tool-result shapes the release plan names. Do not treat the whole event as 6.""" + +from __future__ import annotations + +import ast +import json +import re +from typing import Any + +_OFFLOAD_PATH = re.compile(r"/large_tool_results/([A-Za-z0-9._\-]+)") +_NUMBERED_PREVIEW = re.compile(r"^\s*(\d+)\t(.*)$") +_PRINTED_ENDS = re.compile(r"^FIRST:(.*)\nLAST:(.*)\nCOUNT:(\d+)\s*$", re.M) + + +def parse_object(value: Any) -> dict | None: + """Dict, JSON object, or a Python dict literal. Anything else is not a tool result.""" + if isinstance(value, dict): + return value + if not isinstance(value, str): + return None + text = value.strip() + if not text.startswith("{"): + return None + try: + loaded = json.loads(text) + except json.JSONDecodeError: + loaded = None + if isinstance(loaded, dict): + return loaded + try: + literal = ast.literal_eval(text) + except (SyntaxError, ValueError): + return None + return literal if isinstance(literal, dict) else None + + +def _after_tool_output(value: str) -> dict | None: + marker = "Tool Output:" + if marker not in value: + return None + return parse_object(value.split(marker, 1)[1].strip().rstrip(",").strip()) + + +def log_sum(result: dict | None) -> bool: + """Container and local: exitcode 0 and log.strip() == '6'. The whole object is not '6'.""" + if not result or "log" not in result: + return False + log = result.get("log") + if not isinstance(log, str) or log.strip() != "6": + return False + if str(result).strip() == "6": + return False + return result.get("exitcode") == 0 + + +def e2b_sum(result: dict | None) -> bool: + """e2b has no log field. stdout is a list of strings.""" + if not result or "log" in result: + return False + stdout = result.get("stdout") + if not isinstance(stdout, list): + return False + return "".join(str(item) for item in stdout).strip() == "6" + + +def walk_log_items(log_data: Any): + if not isinstance(log_data, list): + return + for row in log_data: + if isinstance(row, dict): + yield row + elif isinstance(row, list): + for item in row: + if isinstance(item, dict): + yield item + + +def workflow_tool_result(frame: dict) -> dict | None: + """Agent node end: category node_run, message.log_data, type tool, key is the tool name.""" + if frame.get("category") != "node_run" or frame.get("type") != "end": + return None + message = frame.get("message") + if not isinstance(message, dict): + return None + for item in walk_log_items(message.get("log_data")): + if item.get("type") != "tool" or item.get("key") != "bisheng_code_interpreter": + continue + value = item.get("value") + if isinstance(value, str): + return _after_tool_output(value) or parse_object(value) + return parse_object(value) + return None + + +def code_output_values(frame: dict) -> dict | None: + if frame.get("category") != "node_run" or frame.get("type") != "end": + return None + message = frame.get("message") + if not isinstance(message, dict): + return None + for item in walk_log_items(message.get("log_data")): + if item.get("key") != "code_output": + continue + value = item.get("value") + return value if isinstance(value, dict) else None + return None + + +def output_msg_text(frame: dict) -> str | None: + if frame.get("category") != "output_msg" or frame.get("type") != "over": + return None + message = frame.get("message") + if isinstance(message, dict): + msg = message.get("msg") + return msg if isinstance(msg, str) else None + return None + + +def assistant_tool_result(frame: dict) -> dict | None: + if frame.get("category") != "tool" or frame.get("type") != "end": + return None + message = frame.get("message") + payload = parse_object(message) if isinstance(message, str) else message if isinstance(message, dict) else None + if not payload or payload.get("tool_key") != "bisheng_code_interpreter": + return None + output = payload.get("output") + return parse_object(output) + + +def daily_tool_result(event_name: str, data: dict) -> dict | None: + """SSE event name is message. The tool end is category agent_tool_call.""" + if event_name != "message": + return None + if data.get("category") != "agent_tool_call" or data.get("type") != "end": + return None + message = data.get("message") + if not isinstance(message, dict) or message.get("tool_name") != "bisheng_code_interpreter": + return None + results = message.get("results") + found = parse_object(results) + if found: + return found + if isinstance(results, list): + for item in results: + if isinstance(item, dict) and "text" in item: + found = parse_object(item.get("text")) + if found: + return found + return None + + +def history_steps(tasks: list) -> list[dict]: + steps: list[dict] = [] + for task in tasks: + if not isinstance(task, dict): + continue + history = task.get("history") + if isinstance(history, list): + steps.extend(item for item in history if isinstance(item, dict)) + return steps + + +def task_tool_results(tasks: list, name: str = "bisheng_code_interpreter") -> list[dict]: + found = [] + for step in history_steps(tasks): + if step.get("name") != name or step.get("status") not in (None, "end"): + continue + parsed = parse_object(step.get("output")) + if parsed: + found.append({"step": step, "result": parsed}) + return found + + +def chosen_tool_result(results: list, accept) -> dict | None: + """Any result accept() allows. Otherwise the last result, so a failure still shows what ran.""" + present = [item for item in results if item] + for item in present: + if accept(item): + return item + return present[-1] if present else None + + +def offload_from_text(text: str) -> dict | None: + """Path and the preview's first line, last line, and line count. + + The system writes the original tool text to ``large_tool_results/`` and + replaces the message with a head-and-tail preview whose line numbers run to + the original line count. No length threshold is applied here. + """ + if "Tool result too large" not in text: + return None + found = _OFFLOAD_PATH.search(text) + if found is None: + return None + numbered = [] + for line in text.splitlines(): + match = _NUMBERED_PREVIEW.match(line) + if match: + numbered.append((int(match.group(1)), match.group(2))) + if not numbered or numbered[0][0] != 1: + return None + last_number, last_line = numbered[-1] + return { + "path": f"large_tool_results/{found.group(1)}", + "first": numbered[0][1], + "last": last_line, + "count": last_number, + } + + +def printed_ends(log: str) -> tuple[str, str, int] | None: + """FIRST / LAST / COUNT lines the code tool was asked to print.""" + match = _PRINTED_ENDS.search(log) + if match is None: + return None + return match.group(1).strip(), match.group(2).strip(), int(match.group(3)) + + +def offload_notice(tasks: list) -> dict | None: + for step in history_steps(tasks): + output = step.get("output") + if isinstance(output, str): + text = output + elif output is None: + continue + else: + text = json.dumps(output, ensure_ascii=False) + notice = offload_from_text(text) + if notice: + return notice + return None + + +def step_text(step: dict) -> str: + output = step.get("output") + params = step.get("params") + return f"{output or ''}\n{params or ''}" diff --git a/src/backend/test/sandbox/release/samples.py b/src/backend/test/sandbox/release/samples.py new file mode 100644 index 0000000000..ac03018cee --- /dev/null +++ b/src/backend/test/sandbox/release/samples.py @@ -0,0 +1,64 @@ +"""Office samples named by the release plan. Opening them proves structure, not desktop Office.""" + +from __future__ import annotations + +import io + +from docx import Document +from openpyxl import Workbook, load_workbook +from pptx import Presentation + + +def xlsx_amounts() -> bytes: + book = Workbook() + sheet = book.active + sheet.title = "金额" + sheet["A1"] = "金额" + for index, amount in enumerate((1, 2, 3), start=2): + sheet.cell(row=index, column=1, value=amount) + buf = io.BytesIO() + book.save(buf) + return buf.getvalue() + + +def docx_body(text: str) -> bytes: + document = Document() + document.add_paragraph(text) + buf = io.BytesIO() + document.save(buf) + return buf.getvalue() + + +def pptx_body(text: str) -> bytes: + presentation = Presentation() + layout = presentation.slide_layouts[5] + slide = presentation.slides.add_slide(layout) + slide.shapes.title.text = text + buf = io.BytesIO() + presentation.save(buf) + return buf.getvalue() + + +def xlsx_contains_amounts(payload: bytes, amounts: tuple[int, ...]) -> bool: + book = load_workbook(io.BytesIO(payload), data_only=True) + seen: list[int] = [] + for sheet in book.worksheets: + for row in sheet.iter_rows(values_only=True): + for cell in row: + if isinstance(cell, (int, float)) and int(cell) == cell: + seen.append(int(cell)) + return all(amount in seen for amount in amounts) + + +def docx_contains(payload: bytes, text: str) -> bool: + document = Document(io.BytesIO(payload)) + return any(text in (paragraph.text or "") for paragraph in document.paragraphs) + + +def pptx_contains(payload: bytes, text: str) -> bool: + presentation = Presentation(io.BytesIO(payload)) + for slide in presentation.slides: + for shape in slide.shapes: + if text in getattr(shape, "text", ""): + return True + return False diff --git a/src/backend/test/sandbox/release/test_cases.py b/src/backend/test/sandbox/release/test_cases.py new file mode 100644 index 0000000000..f8b14d2d49 --- /dev/null +++ b/src/backend/test/sandbox/release/test_cases.py @@ -0,0 +1,495 @@ +"""The fifteen BiSheng cases. A missing process, model, or baseline is 未测, not a pass.""" + +from __future__ import annotations + +import os +import time +from contextlib import contextmanager + +import pytest + +from test.sandbox.release.client import ( + ApiError, + assistant_events, + child_tool, + code_interpreter_type, + continue_task, + daily_sum, + download_bytes, + extra_of, + file_urls, + first_sum_result, + linsight_tool_payload, + open_api, + run_task_question, + save_execution_type, + submit_task, + task_tool_results, + tool_categories, + tool_category_payload, + upload_linsight_file, + user_input, + wait_tasks, + waiting_task_id, + workbench_model_id, + workflow_events, +) +from test.sandbox.release.conftest import sandbox_reachable +from test.sandbox.release.env import ( + ASSISTANT_ID, + CODE_WORKFLOW_ID, + FIXED_MODEL, + SKILL_FILE, + SUM_PROMPT, + WORKFLOW_ID, +) +from test.sandbox.release.parse import ( + assistant_tool_result, + chosen_tool_result, + code_output_values, + history_steps, + log_sum, + offload_notice, + output_msg_text, + printed_ends, + workflow_tool_result, +) +from test.sandbox.release.samples import docx_body, docx_contains, pptx_contains, xlsx_amounts, xlsx_contains_amounts + +_BIG = 60 * 1024 * 1024 +_TEN_MIB = 10 * 1024 * 1024 + + +@pytest.fixture(scope="module") +def api(): + client = open_api() + if client is None: + pytest.skip(f"未测:{getattr(open_api, 'problem', '') or '后端不可登录'}") + if not sandbox_reachable(): + client.close() + pytest.skip("沙箱进程未启动,容器组未测") + yield client + client.close() + + +@contextmanager +def _mode(client, mode: str): + item = code_interpreter_type(tool_categories(client)) + if item is None: + pytest.skip("未测:工具列表没有代码执行器") + original = extra_of(item).get("type") + save_execution_type(client, mode) + try: + yield item + finally: + if isinstance(original, str): + save_execution_type(client, original) + + +@pytest.fixture(scope="module") +def task_sum(api): + with _mode(api, "container"): + _version, tasks = run_task_question(api, SUM_PROMPT) + return first_sum_result(tasks, log_sum) + + +def _require_sum(result) -> None: + if not log_sum(result): + pytest.skip("sum([1,2,3]) 不是 6,不再跑后面的文件用例") + + +def _code_of(step: dict) -> str: + params = step.get("params") + if isinstance(params, dict): + for key in ("python_code", "code", "input"): + value = params.get(key) + if isinstance(value, str) and value.strip(): + return value + return str(params) + return str(params or "") + + +def _blobs(client, version_id: str, tasks: list) -> list[bytes]: + found = [] + for item in task_tool_results(tasks): + for url in file_urls(item["result"]): + found.append(download_bytes(client, version_id, url)) + return found + + +def test_s002(api, task_sum): + """Workflow tool, assistant, daily chat, and task mode. The code node is S-014.""" + missing = [] + wrong = [] + if not log_sum(task_sum): + wrong.append(f"任务模式结果不是 log=6 exitcode=0: {task_sum!r}") + if not workbench_model_id(api) or child_tool(api) is None: + missing.append("日常对话") + else: + try: + daily = daily_sum(api, SUM_PROMPT) + except ApiError as exc: + wrong.append(f"日常对话: {exc}") + else: + if not log_sum(daily): + wrong.append(f"日常对话结果不是 log=6 exitcode=0: {daily!r}") + workflow_id = WORKFLOW_ID + if not workflow_id: + missing.append("工作流代码工具") + else: + frames = workflow_events(api, workflow_id, SUM_PROMPT) + result = chosen_tool_result([workflow_tool_result(frame) for frame in frames], log_sum) + if not log_sum(result): + wrong.append(f"工作流工具结果不是 log=6 exitcode=0: {result!r}") + assistant_id = ASSISTANT_ID + if not assistant_id: + missing.append("助手") + else: + frames = assistant_events(api, assistant_id, SUM_PROMPT) + result = chosen_tool_result([assistant_tool_result(frame) for frame in frames], log_sum) + if not log_sum(result): + wrong.append(f"助手结果不是 log=6 exitcode=0: {result!r}") + if wrong: + pytest.fail("S-002\n" + "\n".join(wrong)) + if missing: + pytest.skip("未测:" + "、".join(missing) + ",四入口不齐,整条不算通过") + + +def test_s014(api): + workflow_id = CODE_WORKFLOW_ID + if not workflow_id: + pytest.skip("未测:没有输入→代码节点→下游的工作流") + with _mode(api, "container"): + frames = workflow_events(api, workflow_id, "7") + values = next((code_output_values(frame) for frame in frames if code_output_values(frame)), None) + shown = [output_msg_text(frame) for frame in frames if output_msg_text(frame)] + assert isinstance(values, dict) + assert any(item == 14 or item == "14" for item in values.values()), values + assert any(isinstance(text, str) and "14" in text for text in shown), shown + + +def test_s003(api, task_sum): + _require_sum(task_sum) + files = [ + upload_linsight_file(api, "中文表.xlsx", xlsx_amounts()), + upload_linsight_file(api, "中文说明.docx", docx_body("本月统计完成")), + ] + question = ( + "必须用代码工具读取本次上传的 uploads/中文表.xlsx 和 uploads/中文说明.docx。" + "打印金额总和,并打印 Word 正文。不要新建同名文件。" + ) + with _mode(api, "container"): + _version, tasks = run_task_question(api, question, files=files) + found = task_tool_results(tasks) + blob = "\n".join(_code_of(item["step"]) + "\n" + str(item["result"].get("log") or "") for item in found) + if "uploads/" not in blob or "中文表.xlsx" not in blob or "中文说明.docx" not in blob: + pytest.skip("未测:模型没有提交读取 uploads/ 下两份原件的代码,空的 python_code 不能当成原件不可读") + logs = "\n".join(str(item["result"].get("log") or "") for item in found) + assert "6" in logs + assert "本月统计完成" in logs + + +def _matches(blobs: list[bytes], checker) -> bool: + for blob in blobs: + try: + if checker(blob): + return True + except Exception: + continue + return False + + +def test_s023(api, task_sum): + _require_sum(task_sum) + question = ( + "用代码在 output/ 生成三个中文名文件:说明.docx 正文写沙箱测试成功," + "演示.pptx 写入沙箱测试成功,金额表.xlsx 写入文字沙箱测试成功和金额 1、2、3。" + "生成 Excel 的同一轮必须用 read_file 读取相对路径 output/金额表.xlsx,不要加前导斜杠。" + ) + with _mode(api, "container"): + version, tasks = run_task_question(api, question) + reads = [step for step in history_steps(tasks) if step.get("name") == "read_file"] + read_text = "\n".join(str(step.get("output") or "") for step in reads) + if "沙箱测试成功" not in read_text or any(piece not in read_text for piece in ("1", "2", "3")): + pytest.skip("未测:read_file 没有读到生成的表。绝对路径 /output/ 被拒绝是服务的正确行为") + blobs = _blobs(api, version, tasks) + if not blobs: + pytest.skip("未测:没有可下载的产物") + assert _matches(blobs, lambda blob: docx_contains(blob, "沙箱测试成功")) + assert _matches(blobs, lambda blob: pptx_contains(blob, "沙箱测试成功")) + assert _matches(blobs, lambda blob: xlsx_contains_amounts(blob, (1, 2, 3))) + + +def test_s024(api, task_sum): + _require_sum(task_sum) + question = "用代码在任务工作区根部写 root-note.txt,路径不要带 output/,内容是文件没有丢失。" + with _mode(api, "container"): + version, tasks = run_task_question(api, question) + calls = task_tool_results(tasks) + if not calls: + pytest.skip("未测:没有代码调用,构造不出根部文件") + code = "\n".join(_code_of(item["step"]) for item in calls) + if "root-note.txt" not in code: + pytest.skip("未测:构造不出根部文件") + if "output/" in code or "output\\" in code: + pytest.fail("模型一开始就写进 output/,不算归位通过") + log = "\n".join(str(item["result"].get("log") or "") for item in calls) + assert "output/root-note.txt" in log or "output\\root-note.txt" in log + blobs = _blobs(api, version, tasks) + assert any("文件没有丢失".encode() in blob for blob in blobs) + + +def test_s007(api, task_sum): + _require_sum(task_sum) + files = [ + upload_linsight_file(api, "edit.txt", "修改前".encode()), + upload_linsight_file(api, "keep.txt", "保持原样".encode()), + ] + question = "用代码把 edit.txt 改成修改后,不要改 keep.txt,再生成 new.txt,内容是新文件。" + with _mode(api, "container"): + version, tasks = run_task_question(api, question, files=files) + calls = task_tool_results(tasks) + if not calls: + pytest.skip("未测:模型没有调用代码工具,看不出本轮产物清单") + listed = "\n".join(str(item["result"].get("file_list")) for item in calls) + assert "keep.txt" not in listed + blobs = _blobs(api, version, tasks) + if not blobs: + pytest.skip("未测:代码调用没有可下载的产物") + texts = [blob.decode("utf-8", errors="ignore") for blob in blobs] + assert any("修改后" in text for text in texts) + assert any(text.strip() == "新文件" or "新文件" in text for text in texts) + assert not any(text.strip() == "保持原样" for text in texts) + + +def test_s028(api, task_sum): + _require_sum(task_sum) + question = "用代码在 output/金额表.xlsx 写入金额 1、2、3。" + follow = "读取刚才的表,保留原内容,增加翻倍金额列。" + with _mode(api, "container"): + version = submit_task(api, question) + first = wait_tasks(api, version) + continue_task(api, version, follow) + tasks = wait_tasks(api, version, previous=first) + blobs = _blobs(api, version, tasks) + if not blobs: + pytest.skip("未测:两轮都没有可下载的表格,构造不出跨轮追加列") + assert _matches( + blobs, lambda blob: xlsx_contains_amounts(blob, (1, 2, 3)) and xlsx_contains_amounts(blob, (2, 4, 6)) + ) + + +def test_s009(api, task_sum): + _require_sum(task_sum) + question = "先用代码在 output/note.txt 写入开始,然后用 ask_user 询问追加内容并等待。不要自己补全。" + with _mode(api, "container"): + version = submit_task(api, question) + tasks = wait_tasks(api, version) + task_id = waiting_task_id(tasks) + if not task_id: + pytest.fail("没有进入 ask_user 等待,不能从别的响应正文里找文件") + user_input(api, version, task_id, "已确认") + tasks = wait_tasks(api, version, previous=tasks) + blobs = _blobs(api, version, tasks) + texts = [blob.decode("utf-8", errors="ignore") for blob in blobs] + if not any("开始" in text and "已确认" in text for text in texts): + pytest.skip("未测:ask_user 已恢复,但下载内容没有同时包含「开始」和「已确认」") + + +def test_s025(api, task_sum): + _require_sum(task_sum) + page = tool_category_payload(api, "web_fetch") + if page is None: + pytest.skip("未测:工具列表里没有网页读取") + question = ( + "必须调用网页读取工具 web_fetch," + "url 固定为 http://www.gutenberg.org/cache/epub/1342/pg1342.txt。" + "不要改网址,不要用别的工具代替这次读取。" + "先等网页读取返回。只有返回里出现 large_tool_results/ 路径时,才用代码工具打开那个相对路径," + "不要加前导斜杠,也不要和网页读取同一次调用。" + "只打印三行,不要打印整行。每行只取前 80 个字符,格式固定为:\n" + "FIRST:第一行的前80个字符\nLAST:最后一行的前80个字符\nCOUNT:总行数\n" + "不要用 read_file 代替代码。如果没有这个路径,说明没有转存,不要编造路径。" + ) + with _mode(api, "container"): + _version, tasks = run_task_question(api, question, tools=[linsight_tool_payload(api), page]) + notice = offload_notice(tasks) + if notice is None: + pytest.skip("未测:网页读取的结果没有转到 large_tool_results,不新增长度门槛") + calls = task_tool_results(tasks) + logs = "\n".join(str(item["result"].get("log") or "") for item in calls) + printed = printed_ends(logs) + if printed is None: + pytest.skip("未测:结果已转存,但代码没有打印 FIRST、LAST 和 COUNT") + got_first, got_last, got_count = printed + assert got_count == notice["count"] + assert _same_edge(got_first, notice["first"]) + assert _same_edge(got_last, notice["last"]) + + +def _same_edge(printed: str, original: str) -> bool: + """Preview lines are capped at 1000 characters. Either side may be that prefix.""" + if printed == original: + return True + head = original[:200] + return bool(head) and (printed.startswith(head) or head.startswith(printed[:200])) + + +def test_s011(api, task_sum): + _require_sum(task_sum) + # Upload rejects files over the 50MB cap (11024) and .bin never reaches completed. + # The oversized file has to be written by an earlier code call, then copied in. + seed = ( + "用代码在当前目录写入两个文件,不要读取它们的内容。" + f"big.bin 必须正好是 {_BIG} 个空字节。small.txt 的内容是 SMALL。" + "写完后分别打印两个文件的字节数。" + ) + question = ( + "根目录写入会被归位。用代码打开 output/big.bin 和 output/small.txt 并打印内容。" + "不要在脚本里按大小跳过。大文件失败也要继续读小文件。不要再写回根目录。" + ) + with _mode(api, "container"): + version = submit_task(api, seed) + first = wait_tasks(api, version) + continue_task(api, version, question) + tasks = wait_tasks(api, version, previous=first) + calls = task_tool_results(tasks) + logs = "\n".join(str(item["result"].get("log") or "") for item in calls) + code = "\n".join(_code_of(item["step"]) for item in calls) + if "output/big.bin" not in logs and "output/big.bin" not in code: + pytest.skip("未测:根目录文件没有归位到 output/big.bin,构造不出超限 copy-in") + if "output/small.txt" not in code or "SMALL" not in logs: + pytest.skip("未测:归位之后的代码没有打开 output/small.txt 并打印 SMALL") + assert "Copy-in skipped an oversized file" in logs + assert "big.bin" in logs + + +def test_s026(): + from bisheng_langchain.gpts.tools.code_interpreter.base_executor import BaseExecutor + + notice = BaseExecutor.absolute_path_advisory('open("/output/路径测试.txt", "w")') + assert notice + assert BaseExecutor.absolute_path_advisory('open("output/路径测试.txt", "w")') == "" + if FIXED_MODEL != "1": + pytest.skip("未测完:没有固定模型,模型是否改成相对路径不能算通过") + if not sandbox_reachable(): + pytest.skip("未测完:沙箱进程未启动,模型半边不能算通过") + api = open_api() + if api is None: + pytest.skip("未测完:后端不可登录,模型半边不能算通过") + skill = SKILL_FILE + if not skill or "/" not in skill: + api.close() + pytest.skip("未测完:没有准备好的技能文件,整条不能算通过") + question = f"用自然语言路径 /output/路径测试.txt 写入 PATH-OK,并读取 /skills/{skill}。不要手改成相对路径。" + try: + with _mode(api, "container"): + version, tasks = run_task_question(api, question, skills=[skill.split("/", 1)[0]]) + calls = task_tool_results(tasks) + scripts = [] + for item in calls: + params = item["step"].get("params") + if not isinstance(params, dict): + continue + for key in ("python_code", "code", "input"): + value = params.get(key) + if isinstance(value, str) and value.strip(): + scripts.append(value) + break + code = "\n".join(scripts) + if not code.strip(): + pytest.skip("未测:模型没有提交可核对的代码,看不出是否改成了相对路径") + if code.strip().startswith("/") or "'/output" in code or '"/output' in code or "'/skills" in code: + pytest.fail("只有纠正提示,执行的代码仍是绝对路径") + assert "output/" in code and "skills/" in code + blobs = _blobs(api, version, tasks) + assert any(b"PATH-OK" in blob for blob in blobs) + finally: + api.close() + + +def test_s016(api, task_sum): + _require_sum(task_sum) + secret = os.environ.get("SANDBOX_PRIVATE", "") + questions = [ + "用代码工具读取 /root/.cache/sandbox-test.txt 并打印文件内容。", + "用代码工具执行 os.walk('/') ,并打印遍历到的路径。", + "用代码工具打印环境变量 SANDBOX_PRIVATE 的值。", + ] + logs = [] + with _mode(api, "container"): + for question in questions: + _version, tasks = run_task_question(api, question) + calls = task_tool_results(tasks) + if not calls: + pytest.skip(f"未测:模型没有调用代码工具,执行前拒绝没有被触发: {question}") + logs.append("\n".join(str(item["result"].get("log") or "") for item in calls)) + assert "REJECTED and nothing was executed" in logs[0] + assert "HOST-ONLY" not in logs[0] + assert "REJECTED and nothing was executed" in logs[1] + if not secret: + pytest.skip( + "宿主文件和目录已拒绝。后台私有变量没有可核对的标记,该组未测。" + "其他租户文件未测。受保护资源没有样本,该组未测。整条不算通过。" + ) + assert secret not in logs[2] + other = os.environ.get("BISHENG_RELEASE_OTHER_TENANT_FILE") + marker = os.environ.get("BISHENG_RELEASE_OTHER_TENANT_MARKER") + if not other or not marker: + pytest.skip("宿主文件、目录和私有变量已核对。其他租户文件未测。受保护资源没有样本,该组未测。整条不算通过。") + with _mode(api, "container"): + _version, tasks = run_task_question(api, f"用代码工具读取 {other} 并打印内容。") + other_log = "\n".join(str(item["result"].get("log") or "") for item in task_tool_results(tasks)) + assert marker not in other_log + pytest.skip("受保护资源没有样本,该组未测。其余隔离组已核对,整条仍不算通过。") + + +def test_s027(): + baseline_path = os.environ.get("BISHENG_RELEASE_BASELINE") + if not baseline_path: + pytest.skip("未测:没有升级前基线,不默认四个入口都要通过") + client = open_api() + if client is None: + pytest.skip("未测:后端不可登录") + api = client + import json + + try: + baseline = json.loads(open(baseline_path, encoding="utf-8").read()) + problems = [] + if "task" in (baseline.get("local") or []): + with _mode(api, "local"): + _version, tasks = run_task_question(api, SUM_PROMPT) + if not log_sum(first_sum_result(tasks, log_sum)): + problems.append("local 任务模式结果不是 6") + if "task" in (baseline.get("e2b") or []): + if not os.environ.get("E2B_API_KEY"): + problems.append("未测:e2b 没有官方密钥,只跳过 e2b 这一组") + else: + with _mode(api, "e2b"): + _version, tasks = run_task_question(api, SUM_PROMPT) + from test.sandbox.release.parse import e2b_sum + + if not e2b_sum(first_sum_result(tasks, e2b_sum)): + problems.append("e2b 任务模式 stdout 拼起来不是 6") + hard = [item for item in problems if not item.startswith("未测")] + if hard: + pytest.fail("S-027\n" + "\n".join(problems)) + if problems or (not baseline.get("local") and not baseline.get("e2b")): + pytest.skip("S-027 " + (";".join(problems) or "基线没有可跑的入口")) + finally: + api.close() + + +def test_s013(api, task_sum): + _require_sum(task_sum) + files = [upload_linsight_file(api, f"part-{index}.txt", b"\0" * _TEN_MIB) for index in range(3)] + started = time.perf_counter() + with _mode(api, "container"): + version = submit_task(api, SUM_PROMPT, files=files) + tasks = wait_tasks(api, version) + elapsed = time.perf_counter() - started + calls = task_tool_results(tasks) + assert calls, "没有首次代码调用,不能记录耗时" + print(f"S-013 first_code_call_s={elapsed:.3f} files=3 size_bytes={_TEN_MIB} sandbox=container") + assert elapsed >= 0 diff --git a/src/backend/test/sandbox/release/test_runner_protocol.py b/src/backend/test/sandbox/release/test_runner_protocol.py new file mode 100644 index 0000000000..7c416996f8 --- /dev/null +++ b/src/backend/test/sandbox/release/test_runner_protocol.py @@ -0,0 +1,164 @@ +"""Sandbox runner protocol. These checks do not pass a BiSheng case.""" + +from __future__ import annotations + +import hashlib +import io +import json +import tarfile + +import httpx +import pytest + +from test.sandbox.release.env import SANDBOX_TOKEN + +MAX_COPY_IN = 50 * 1024 * 1024 + + +def _md5(data: bytes) -> str: + return hashlib.md5(data).hexdigest() + + +def _tar_gz(members: dict[str, bytes]) -> bytes: + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz", encoding="utf-8", format=tarfile.PAX_FORMAT) as tar: + for name, data in members.items(): + info = tarfile.TarInfo(name=name) + info.size = len(data) + tar.addfile(info, io.BytesIO(data)) + return buf.getvalue() + + +def _framed(members: dict[str, bytes]) -> bytes: + tar = _tar_gz(members) + manifest = {name: _md5(data) for name, data in members.items()} + raw = json.dumps(manifest, separators=(",", ":"), ensure_ascii=True).encode("utf-8") + return len(raw).to_bytes(4, "big") + raw + tar + + +def _untar(payload: bytes) -> dict[str, bytes]: + out: dict[str, bytes] = {} + with tarfile.open(fileobj=io.BytesIO(payload), mode="r:gz", encoding="utf-8") as tar: + for member in tar: + if member.isfile(): + handle = tar.extractfile(member) + out[member.name.replace("\\", "/")] = handle.read() if handle else b"" + return out + + +def _session(http: httpx.Client) -> tuple[str, dict[str, str]]: + created = http.post("/v1/sessions", headers={"Authorization": f"Bearer {SANDBOX_TOKEN}"}) + assert created.status_code == 200, created.text + body = created.json() + return body["session_id"], {"X-Lease-Token": body["lease_token"]} + + +@pytest.fixture +def runner(sandbox_url: str): + with httpx.Client(base_url=sandbox_url, timeout=60.0) as http: + yield http + + +def test_runner_health_needs_no_token(runner: httpx.Client): + assert runner.get("/health").status_code == 200 + + +def test_runner_rejects_bad_token_and_separates_sessions(runner: httpx.Client): + import os + + denied = runner.post("/v1/sessions", headers={"Authorization": "Bearer wrong-token"}) + assert denied.status_code == 401 + first, first_lease = _session(runner) + second, second_lease = _session(runner) + try: + assert first != second + first_dir = f"/tmp/sessions/{first}" + second_dir = f"/tmp/sessions/{second}" + if os.path.isdir(first_dir) and os.path.isdir(second_dir): + assert os.path.realpath(first_dir) != os.path.realpath(second_dir) + finally: + runner.delete(f"/v1/sessions/{first}", headers=first_lease) + runner.delete(f"/v1/sessions/{second}", headers=second_lease) + + +def test_runner_put_chinese_name_and_skips_oversize(runner: httpx.Client): + session_id, lease = _session(runner) + try: + small = {"中文说明.txt": "说明".encode()} + stored = runner.put(f"/v1/sessions/{session_id}/files", headers=lease, content=_framed(small)) + assert stored.status_code == 200, stored.text + read_back = runner.post( + f"/v1/sessions/{session_id}/exec", + headers=lease, + json={"code": "print(open('中文说明.txt',encoding='utf-8').read())", "lang": "python", "timeout_s": 30}, + ) + assert read_back.status_code == 200 + assert read_back.json()["stdout"].strip() == "说明" + oversized = {"big.bin": b"\0" * (MAX_COPY_IN + 1)} + skipped = runner.put(f"/v1/sessions/{session_id}/files", headers=lease, content=_framed(oversized)) + assert skipped.status_code == 200, skipped.text + body = skipped.json() + assert body["skipped"][0]["path"] == "big.bin" + assert body["skipped"][0]["size"] == MAX_COPY_IN + 1 + finally: + runner.delete(f"/v1/sessions/{session_id}", headers=lease) + + +def test_runner_exec_sum_and_child_env(runner: httpx.Client): + session_id, lease = _session(runner) + try: + code = "import os\nprint(sum([1, 2, 3]))\nprint(os.environ.get('SANDBOX_PRIVATE', ''))" + ran = runner.post( + f"/v1/sessions/{session_id}/exec", + headers=lease, + json={"code": code, "lang": "python", "timeout_s": 30}, + ) + assert ran.status_code == 200, ran.text + body = ran.json() + assert body["exitcode"] == 0 + lines = body["stdout"].splitlines() + assert lines[0] == "6" + assert lines[-1] == "" + missing = runner.post( + f"/v1/sessions/{session_id}/exec", + json={"code": "print(1)", "lang": "python", "timeout_s": 10}, + ) + assert missing.status_code == 403 + finally: + runner.delete(f"/v1/sessions/{session_id}", headers=lease) + + +def test_runner_get_files_is_gzip_tar_of_changes(runner: httpx.Client): + session_id, lease = _session(runner) + try: + put = runner.put( + f"/v1/sessions/{session_id}/files", + headers=lease, + content=_framed({"keep.txt": b"same"}), + ) + assert put.status_code == 200 + ran = runner.post( + f"/v1/sessions/{session_id}/exec", + headers=lease, + json={"code": "open('new.txt','w').write('created')\n", "lang": "python", "timeout_s": 30}, + ) + assert ran.status_code == 200 and ran.json()["exitcode"] == 0 + fetched = runner.get(f"/v1/sessions/{session_id}/files", headers=lease) + assert fetched.status_code == 200 + assert "gzip" in fetched.headers.get("content-type", "") + members = _untar(fetched.content) + assert members.get("new.txt") == b"created" + assert "keep.txt" not in members + finally: + runner.delete(f"/v1/sessions/{session_id}", headers=lease) + + +def test_runner_deleted_session_is_not_executable(runner: httpx.Client): + session_id, lease = _session(runner) + runner.delete(f"/v1/sessions/{session_id}", headers=lease) + ran = runner.post( + f"/v1/sessions/{session_id}/exec", + headers=lease, + json={"code": "print(1)", "lang": "python", "timeout_s": 10}, + ) + assert ran.status_code == 404 diff --git a/src/backend/test/sandbox/release/test_s022.py b/src/backend/test/sandbox/release/test_s022.py new file mode 100644 index 0000000000..7ca179d339 --- /dev/null +++ b/src/backend/test/sandbox/release/test_s022.py @@ -0,0 +1,143 @@ +"""S-022. Page copy is the Chinese the case names. The config response is not the echo.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from test.sandbox.release.client import ( + MASK, + ApiError, + code_interpreter_type, + data_of, + extra_of, + open_api, + read_execution_type, + save_execution_type, + tool_categories, +) +from test.sandbox.release.conftest import sandbox_reachable +from test.sandbox.release.env import PAGE_LABELS, SANDBOX_TOKEN, SANDBOX_URL + +_LOCALE = Path(__file__).resolve().parents[4] / "frontend/platform/public/locales/zh-Hans/tool.json" + + +def _label_problems() -> list[str]: + copy = json.loads(_LOCALE.read_text(encoding="utf-8")) + problems = [] + for key, expected in PAGE_LABELS.items(): + actual = copy.get(key) + if actual != expected: + problems.append(f"页面文案 {key} 是 {actual!r},用例要求 {expected!r}") + return problems + + +def _restore_type(client, tool_id: int, original: str | None) -> None: + if original: + save_execution_type(client, original) + + +def _config_problems(client) -> list[str]: + problems: list[str] = [] + item = code_interpreter_type(tool_categories(client)) + if item is None: + return ["未测:工具列表没有 bisheng_code_interpreter 所在分类"] + original = extra_of(item).get("type") + try: + for mode in ("local", "container", "e2b"): + save_execution_type(client, mode) + seen = read_execution_type(client) + if seen != mode: + problems.append(f"保存 {mode} 后 GET /api/v1/tool 分类 extra.type 是 {seen!r}") + saved = data_of(client.post("/tool/config", json={"tool_id": item["id"], "extra": {"type": "local"}})) + if saved is True and read_execution_type(client) == "local": + pass + elif read_execution_type(client) != "local": + problems.append("POST /tool/config 的响应不能当作回显,重新读取后 type 不是 local") + except ApiError as exc: + problems.append(f"执行方式接口失败: {exc}") + finally: + try: + _restore_type(client, item["id"], original if isinstance(original, str) else None) + except ApiError as exc: + problems.append(f"恢复执行方式失败: {exc}") + return problems + + +def _address_problems(client) -> list[str]: + problems: list[str] = [] + try: + before = data_of(client.get("/tool/sandbox_conf")) + except ApiError as exc: + return [f"未测:读不到沙箱地址 ({exc})"] + if not isinstance(before, dict): + return ["sandbox_conf 的 data 不是对象"] + original_endpoints = list(before.get("endpoints") or []) + had_token = before.get("token") == MASK + endpoints = list(dict.fromkeys([*original_endpoints, SANDBOX_URL])) + token = MASK if had_token else SANDBOX_TOKEN + try: + data_of(client.post("/tool/sandbox_conf", json={"endpoints": endpoints, "token": token})) + viewed = data_of(client.get("/tool/sandbox_conf")) + if SANDBOX_URL not in (viewed.get("endpoints") or []): + problems.append(f"data.endpoints 不包含 {SANDBOX_URL}: {viewed.get('endpoints')!r}") + if viewed.get("token") != MASK: + problems.append(f"data.token 不是掩码: {viewed.get('token')!r}") + if not sandbox_reachable(): + problems.append("未测:沙箱进程未启动,没有用掩码令牌探测已保存的地址") + else: + probed = data_of( + client.post( + "/tool/sandbox_conf/test", + json={"endpoints": [SANDBOX_URL], "token": MASK}, + ) + ) + results = probed.get("results") if isinstance(probed, dict) else None + status = "" + if isinstance(results, list): + for item in results: + if isinstance(item, dict) and item.get("url") == SANDBOX_URL: + status = item.get("status") or "" + if status != "ok": + problems.append(f"掩码令牌探测 {SANDBOX_URL} 的 status 是 {status!r},不是 ok") + except ApiError as exc: + problems.append(f"沙箱地址接口失败: {exc}") + finally: + restore_token = MASK if had_token or token != "" else "" + if not had_token: + restore_token = "" + try: + data_of( + client.post( + "/tool/sandbox_conf", + json={"endpoints": original_endpoints, "token": restore_token}, + ) + ) + except ApiError as exc: + problems.append(f"恢复沙箱地址失败: {exc}") + return problems + + +def test_s022(): + problems = _label_problems() + client = open_api() + if client is None: + reason = getattr(open_api, "problem", "") or "后端不可登录" + problems.append(f"未测:{reason},执行方式、地址和令牌没有核对") + else: + try: + problems.extend(_config_problems(client)) + problems.extend(_address_problems(client)) + finally: + client.close() + hard = [item for item in problems if not item.startswith("未测")] + if hard: + pytest.fail("S-022\n" + "\n".join(problems)) + if problems: + pytest.skip("S-022 " + ";".join(problems)) + + +def test_s022_install_and_upgrade_need_their_own_databases(): + pytest.skip("未测:新装默认 container 和升级不改写已有 extra 各需要一份独立数据库,不能从配置接口的返回值推断") diff --git a/src/backend/test/sandbox/release/test_wait.py b/src/backend/test/sandbox/release/test_wait.py new file mode 100644 index 0000000000..045b6e625b --- /dev/null +++ b/src/backend/test/sandbox/release/test_wait.py @@ -0,0 +1,81 @@ +"""Continue must not treat the first history growth as the new round.""" + +from test.sandbox.release.client import ( + _finished_code_calls, + _task_snapshot, + continue_round_done, + followup_ready, +) +from test.sandbox.release.parse import offload_from_text + + +def _task(history, status="success"): + return [{"id": "task-1", "status": status, "history": history}] + + +def _code(call_id, amounts): + return { + "name": "bisheng_code_interpreter", + "status": "end", + "call_id": call_id, + "output": {"exitcode": 0, "log": "ok", "file_list": [amounts]}, + } + + +def test_continue_waits_for_the_new_code_call(): + first = _task([_code("round-1", "1,2,3")]) + baseline = _task_snapshot(first) + codes = _finished_code_calls(first) + + grown = _task([_code("round-1", "1,2,3"), {"name": "write_file", "status": "end", "output": "updated"}]) + done, stable = followup_ready(grown, baseline=baseline, previous_codes=codes, was_waiting=False, stable=None) + assert done is False + + second = _task([_code("round-1", "1,2,3"), _code("round-2", "2,4,6")]) + done, stable = followup_ready(second, baseline=baseline, previous_codes=codes, was_waiting=False, stable=stable) + assert done is False + done, _stable = followup_ready(second, baseline=baseline, previous_codes=codes, was_waiting=False, stable=stable) + assert done is True + + +def test_offload_notice_reads_preview_ends(): + preview = "\n".join( + [ + "Tool result too large, the result of this tool call call-1 was saved in the filesystem at this path: /large_tool_results/call-1", + "", + " 1\t第一行", + " 2\t第二行", + "... [6 lines truncated] ...", + " 9\t倒数第二行", + " 10\t最后一行", + ] + ) + notice = offload_from_text(preview) + assert notice == { + "path": "large_tool_results/call-1", + "first": "第一行", + "last": "最后一行", + "count": 10, + } + assert offload_from_text("短结果,没有转存") is None + + +def test_failed_retry_does_not_finish_the_round(): + first = _task_snapshot(_task([_code("round-1", "1,2,3")])) + retrying = _task_snapshot(_task([_code("round-1", "1,2,3"), _code("retry", "fail")])) + assert continue_round_done("in_progress", retrying, first) is False + assert continue_round_done("completed", first, first) is False + assert continue_round_done("completed", retrying, first) is True + + +def test_unchanged_success_is_still_the_previous_round(): + first = _task([_code("round-1", "1,2,3")]) + done, stable = followup_ready( + first, + baseline=_task_snapshot(first), + previous_codes=_finished_code_calls(first), + was_waiting=False, + stable=None, + ) + assert done is False + assert stable is None diff --git a/src/backend/test/sandbox/test_code_interpreter_seed_default.py b/src/backend/test/sandbox/test_code_interpreter_seed_default.py new file mode 100644 index 0000000000..4b9b01407f --- /dev/null +++ b/src/backend/test/sandbox/test_code_interpreter_seed_default.py @@ -0,0 +1,85 @@ +"""New-install seed defaults to container; upgrades must not rewrite extra (F068 T027). + +The executor mode lives on the category row (t_gpts_tools_type.extra) since +30ea7af83: the config save path writes the category and NULLs the tool row, and +ToolExecutor.parse_preset_extra reads the category only when the tool row is +empty. So the seed default is asserted through that resolver, not on one file. +""" + +from __future__ import annotations + +import ast +import json +from pathlib import Path +from types import SimpleNamespace + +from bisheng.tool.domain.services.executor import ToolExecutor + +_BACKEND = Path(__file__).resolve().parents[2] +_SEED = _BACKEND / "bisheng" / "database" / "data" / "t_gpts_tools.json" +_TYPE_SEED = _BACKEND / "bisheng" / "database" / "data" / "t_gpts_tools_type.json" +_INIT = _BACKEND / "bisheng" / "common" / "init_data.py" +_TOOL_KEY = "bisheng_code_interpreter" + + +def _seed_items() -> list[dict]: + return json.loads(_SEED.read_text(encoding="utf-8")) + + +def _resolved_extra(tool: dict) -> dict: + categories = {row["id"]: row for row in json.loads(_TYPE_SEED.read_text(encoding="utf-8"))} + category = categories.get(tool["type"]) + return ToolExecutor.parse_preset_extra( + SimpleNamespace(extra=tool.get("extra")), + SimpleNamespace(extra=category.get("extra")) if category else None, + ) + + +def _extra_payload(row: dict): + extra = row.get("extra") + if extra is None or extra == "": + return {} + if isinstance(extra, dict): + return extra + return json.loads(extra) + + +def test_seed_json_code_interpreter_defaults_to_container(): + rows = [item for item in _seed_items() if item.get("tool_key") == _TOOL_KEY] + assert len(rows) == 1 + assert _resolved_extra(rows[0]).get("type") == "container" + + +def test_seed_json_code_interpreter_tool_row_stays_empty(): + # A non-empty tool row would shadow the category, which is where saves land. + rows = [item for item in _seed_items() if item.get("tool_key") == _TOOL_KEY] + assert _extra_payload(rows[0]) == {} + + +def test_seed_json_does_not_set_container_on_other_tools(): + for item in _seed_items(): + if item.get("tool_key") == _TOOL_KEY: + continue + assert _resolved_extra(item).get("type") != "container" + + +def test_init_data_skips_seed_when_gpts_tools_already_exist(): + source = _INIT.read_text(encoding="utf-8") + assert "if not preset_tools:" in source + assert "t_gpts_tools.json" in source + + +def test_init_data_does_not_update_or_upsert_existing_tool_extra(): + source = _INIT.read_text(encoding="utf-8") + tree = ast.parse(source) + for node in ast.walk(tree): + if isinstance(node, ast.keyword) and node.arg == "extra": + raise AssertionError("init_data.py must not write GptsTools.extra on upgrade") + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == "values": + for kw in node.keywords: + assert kw.arg != "extra", "init_data.py must not UPDATE extra" + lowered = source.lower() + assert "upsert" not in lowered + assert "on conflict" not in lowered + assert "on duplicate" not in lowered + assert "update extra" not in lowered diff --git a/src/backend/test/sandbox/test_code_node_no_return.py b/src/backend/test/sandbox/test_code_node_no_return.py new file mode 100644 index 0000000000..53acde4377 --- /dev/null +++ b/src/backend/test/sandbox/test_code_node_no_return.py @@ -0,0 +1,36 @@ +"""D16: a code node whose main() returns nothing reports 28008, not a generic error.""" + +from __future__ import annotations + +import pytest + +from bisheng.common.errcode.sandbox import SandboxCodeNodeNoResultError +from bisheng.workflow.nodes.code.code import CodeNode + + +def _bare_node(outputs: list[dict]) -> CodeNode: + # Skip BaseNode.__init__: only the output parser is under test. + node = object.__new__(CodeNode) + node.name = "code_1" + node._code_output = outputs + return node + + +def test_none_result_raises_no_result_error(): + node = _bare_node([{"key": "a"}]) + with pytest.raises(SandboxCodeNodeNoResultError) as err: + node._parse_code_output(None) + assert err.value.code == 28008 + + +@pytest.mark.parametrize("value", [[1, 2], "text", 3, 0, False]) +def test_non_dict_result_keeps_type_message(value): + node = _bare_node([{"key": "a"}]) + with pytest.raises(Exception, match="main function output must be dict") as err: + node._parse_code_output(value) + assert not isinstance(err.value, SandboxCodeNodeNoResultError) + + +def test_dict_result_picks_declared_keys(): + node = _bare_node([{"key": "a"}]) + assert node._parse_code_output({"a": 1, "b": 2}) == {"a": 1} diff --git a/src/backend/test/sandbox/test_container_executor.py b/src/backend/test/sandbox/test_container_executor.py new file mode 100644 index 0000000000..1250a00a48 --- /dev/null +++ b/src/backend/test/sandbox/test_container_executor.py @@ -0,0 +1,288 @@ +"""ContainerExecutor + FakeRunnerClient contract (F068 T013). No real HTTP.""" + +from __future__ import annotations + +import ast +import socket +from pathlib import Path + +import pytest +from fake_runner import FakeRunnerClient as _BaseFakeRunnerClient +from fake_runner import _tar_gz + +from bisheng.common.errcode.sandbox import ( + SandboxCapacityExceededError, + SandboxExecTimeoutError, + SandboxProtocolError, + SandboxUnreachableError, +) +from bisheng_langchain.gpts.tools.code_interpreter.container_executor import ContainerExecutor +from bisheng_langchain.gpts.tools.code_interpreter.local_executor import LocalExecutor + +_SHUFFLE_MODULE = "bisheng_langchain.gpts.tools.code_interpreter.container_executor.random.shuffle" + + +class FakeRunnerClient(_BaseFakeRunnerClient): + """The executor streams copy-in bodies; join them before the shared fake parses them.""" + + def request(self, method, url, *, content=None, **kwargs): + if content is not None and not isinstance(content, (bytes, bytearray)): + content = b"".join(content) + return super().request(method, url, content=content, **kwargs) + + +def _exe(fake: FakeRunnerClient, **kwargs) -> ContainerExecutor: + opts = { + "minio": {}, + "endpoints": ["http://runner-a:8080"], + "token": "test-token", + "client": fake, + "keep_session": False, + } + opts.update(kwargs) + return ContainerExecutor(**opts) + + +def _posts(fake: FakeRunnerClient) -> list[str]: + return [url for method, url in fake.calls if method == "POST" and url.rstrip("/").endswith("/v1/sessions")] + + +def test_fake_respects_path_params_across_sessions(): + fake = FakeRunnerClient() + fake.add_replica("http://runner-a:8080") + headers = {"Authorization": "Bearer test-token"} + a = fake.request("POST", "http://runner-a:8080/v1/sessions", headers=headers).json() + b = fake.request("POST", "http://runner-a:8080/v1/sessions", headers=headers).json() + tar = _tar_gz({"only-a.txt": b"secret-a", "output/sub/nested.txt": b"deep"}) + manifest = b"{}" + fake.request( + "PUT", + f"http://runner-a:8080/v1/sessions/{a['session_id']}/files", + headers={"X-Lease-Token": a["lease_token"]}, + content=len(manifest).to_bytes(4, "big") + manifest + tar, + ) + files_a = fake.session_files(a["session_id"]) + files_b = fake.session_files(b["session_id"]) + assert files_a["only-a.txt"] == b"secret-a" + assert files_a["output/sub/nested.txt"] == b"deep" + assert "only-a.txt" not in files_b + assert "output/sub/nested.txt" not in files_b + + +def test_endpoints_skip_dns_shuffle_503_failover_then_all_503(tmp_path, monkeypatch): + monkeypatch.setattr(_SHUFFLE_MODULE, lambda seq: None) + monkeypatch.setattr(socket, "getaddrinfo", lambda *a, **k: (_ for _ in ()).throw(AssertionError("DNS skipped"))) + + fake = FakeRunnerClient() + fake.add_replica("http://runner-a:8080", always_503=True) + fake.add_replica("http://runner-b:8080") + exe = _exe(fake, endpoints=["http://runner-a:8080", "http://runner-b:8080"]) + exitcode, logs, _ = exe.execute_code("print(1)", work_dir=str(tmp_path)) + assert exitcode == 0 + assert "ok" in logs + assert _posts(fake) == [ + "http://runner-a:8080/v1/sessions", + "http://runner-b:8080/v1/sessions", + ] + + full = FakeRunnerClient() + full.add_replica("http://runner-a:8080", always_503=True) + full.add_replica("http://runner-b:8080", always_503=True) + blocked = _exe(full, endpoints=["http://runner-a:8080", "http://runner-b:8080"]) + with pytest.raises(SandboxCapacityExceededError) as cap: + blocked.execute_code("print(1)", work_dir=str(tmp_path)) + assert cap.value.Code == 28002 + assert cap.value.Code != SandboxUnreachableError.Code + assert cap.value.Code != SandboxExecTimeoutError.Code + assert cap.value.Code != SandboxProtocolError.Code + + +def test_all_replicas_unreachable_is_28001(tmp_path): + fake = FakeRunnerClient() + fake.add_replica("http://runner-a:8080", reachable=False) + fake.add_replica("http://runner-b:8080", reachable=False) + exe = _exe(fake, endpoints=["http://runner-a:8080", "http://runner-b:8080"]) + with pytest.raises(SandboxUnreachableError) as err: + exe.execute_code("print(1)", work_dir=str(tmp_path)) + assert err.value.Code == 28001 + assert err.value.Code != SandboxCapacityExceededError.Code + + +def test_success_result_shape_matches_local_fields(tmp_path): + fake = FakeRunnerClient() + fake.add_replica("http://runner-a:8080", write_after_exec={"output/sub/a.txt": b"nested"}) + exe = _exe(fake, local_sync_path=str(tmp_path), keep_session=True) + exe.upload_minio = lambda object_name, file_path: f"https://files/{Path(file_path).name}" + result = exe.run("print('ok')") + assert set(result) == {"exitcode", "log", "file_list"} + assert result["exitcode"] == 0 + assert "ok" in result["log"] + assert isinstance(result["file_list"], list) + assert (tmp_path / "output" / "sub" / "a.txt").read_bytes() == b"nested" + assert result["file_list"] == ["https://files/a.txt"] + + +def test_copy_in_scans_skills_skips_oversized_and_records_bytes(tmp_path): + skill = tmp_path / "skills" / "demo" + skill.mkdir(parents=True) + (skill / "SKILL.md").write_text("skill-body") + (tmp_path / "skills" / "huge.bin").write_bytes(b"x" * 400) + + fake = FakeRunnerClient() + fake.add_replica("http://runner-a:8080") + exe = _exe( + fake, + local_sync_path=str(tmp_path), + keep_session=True, + max_copy_in_bytes=100, + ) + result = exe.run("print('ok')") + assert result["exitcode"] == 0 + assert "skills/demo/SKILL.md" in fake.last_put_members + assert "skills/huge.bin" not in fake.last_put_members + assert "huge.bin" in result["log"] + assert "Copy-in skipped an oversized file" in result["log"] + assert exe.copy_in_bytes > 0 + assert exe.copy_in_bytes < 400 + + +def test_keep_session_second_exec_reuses_bound_url(tmp_path, monkeypatch): + monkeypatch.setattr(_SHUFFLE_MODULE, lambda seq: None) + fake = FakeRunnerClient() + fake.add_replica("http://runner-a:8080") + fake.add_replica("http://runner-b:8080") + exe = _exe( + fake, + endpoints=["http://runner-a:8080", "http://runner-b:8080"], + keep_session=True, + local_sync_path=str(tmp_path), + ) + first = exe.execute_code("print(1)", work_dir=str(tmp_path)) + second = exe.execute_code("print(2)", work_dir=str(tmp_path)) + assert first[0] == 0 and second[0] == 0 + assert _posts(fake) == ["http://runner-a:8080/v1/sessions"] + execs = [url for method, url in fake.calls if method == "POST" and url.endswith("/exec")] + assert len(execs) == 2 + assert all(url.startswith("http://runner-a:8080/") for url in execs) + assert not any(method == "DELETE" for method, _url in fake.calls) + exe.close() + assert any(method == "DELETE" and url.startswith("http://runner-a:8080/") for method, url in fake.calls) + + +def test_protocol_garbage_is_28006_and_never_local_executor(tmp_path, monkeypatch): + fake = FakeRunnerClient() + fake.add_replica("http://runner-a:8080", protocol_garbage=True) + exe = _exe(fake) + + def _boom(*_a, **_k): + raise AssertionError("LocalExecutor subprocess must not run") + + monkeypatch.setattr(LocalExecutor, "_execute_code", _boom) + with pytest.raises(SandboxProtocolError) as err: + exe.execute_code("print(1)", work_dir=str(tmp_path)) + assert err.value.Code == 28006 + assert err.value.Code != SandboxUnreachableError.Code + assert err.value.Code != SandboxCapacityExceededError.Code + assert err.value.Code != SandboxExecTimeoutError.Code + + +def test_exec_timeout_maps_to_28003(tmp_path): + fake = FakeRunnerClient() + fake.add_replica("http://runner-a:8080", exec_timeout=True) + exe = _exe(fake) + with pytest.raises(SandboxExecTimeoutError) as err: + exe.execute_code("while True: pass", work_dir=str(tmp_path)) + assert err.value.Code == 28003 + assert err.value.Code != SandboxUnreachableError.Code + assert err.value.Code != SandboxCapacityExceededError.Code + assert err.value.Code != SandboxProtocolError.Code + + +def test_description_lists_isolation_libraries(): + fake = FakeRunnerClient() + fake.add_replica("http://runner-a:8080") + text = _exe(fake).description + lowered = text.lower() + assert "pandas" in lowered + assert "openpyxl" in lowered + assert "python-pptx" in lowered + assert "pymupdf" in lowered + assert "skills/" in text + + +def test_container_executor_has_no_orchestrator_imports_or_discover(): + path = ( + Path(__file__).resolve().parents[2] + / "bisheng_langchain" + / "gpts" + / "tools" + / "code_interpreter" + / "container_executor.py" + ) + source = path.read_text(encoding="utf-8") + tree = ast.parse(source) + banned = {"docker", "kubernetes"} + for node in ast.walk(tree): + if isinstance(node, ast.Import): + assert all(alias.name.split(".")[0] not in banned for alias in node.names) + if isinstance(node, ast.ImportFrom) and node.module: + assert node.module.split(".")[0] not in banned + assert "def discover" not in source + + +def test_copy_in_keeps_chinese_paths_and_ascii_manifest(tmp_path): + rel = "uploads/工作台_工作流输入支持音视频.docx" + dest = tmp_path / Path(rel) + dest.parent.mkdir(parents=True) + dest.write_bytes("中文内容".encode()) + fake = FakeRunnerClient() + fake.add_replica("http://runner-a:8080") + exe = _exe(fake, local_sync_path=str(tmp_path), keep_session=True) + result = exe.run("print('ok')") + assert result["exitcode"] == 0 + assert rel in fake.last_put_members + size = int.from_bytes(fake.last_put_body[:4], "big") + raw = fake.last_put_body[4 : 4 + size] + text = raw.decode("ascii") + assert "\\u" in text + assert "工作台" not in text + + +def test_execute_code_without_work_dir_does_not_copy_cwd(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "secret-from-cwd.py").write_text("should-not-be-uploaded") + fake = FakeRunnerClient() + fake.add_replica("http://runner-a:8080") + exe = _exe(fake) + exitcode, logs, _ = exe.execute_code("print(1)") + assert exitcode == 0 + assert "ok" in logs + assert exe.copy_in_bytes == 0 + assert "secret-from-cwd.py" not in fake.last_put_members + assert not any(method == "PUT" and url.endswith("/files") for method, url in fake.calls) + assert not any(method == "GET" and url.endswith("/files") for method, url in fake.calls) + + +def test_task_artifact_storage_failure_is_reported(tmp_path, monkeypatch): + fake = FakeRunnerClient() + fake.add_replica("http://runner-a:8080", write_after_exec={"output/report.xlsx": b"artifact"}) + exe = _exe(fake, local_sync_path=str(tmp_path), workspace_prefix="workspace/task-1") + monkeypatch.setattr(LocalExecutor, "sync_to_workspace", lambda *args: 0) + with pytest.raises(SandboxProtocolError, match="workspace storage"): + exe.run("print('saved')") + + +def test_task_returns_stable_artifact_reference(tmp_path, monkeypatch): + fake = FakeRunnerClient() + fake.add_replica("http://runner-a:8080", write_after_exec={"output/report.xlsx": b"artifact"}) + exe = _exe(fake, local_sync_path=str(tmp_path), workspace_prefix="workspace/task-1") + saved = [] + + def persist(root, files): + saved.extend(files) + return len(files) + + monkeypatch.setattr(exe, "sync_to_workspace", persist) + result = exe.run("print('saved')") + assert result["file_list"] == ["/output/report.xlsx"] + assert saved == ["output/report.xlsx"] diff --git a/src/backend/test/sandbox/test_container_executor_close_race.py b/src/backend/test/sandbox/test_container_executor_close_race.py new file mode 100644 index 0000000000..7e9fa4c934 --- /dev/null +++ b/src/backend/test/sandbox/test_container_executor_close_race.py @@ -0,0 +1,136 @@ +"""ContainerExecutor review follow-ups for F068. + +The runner's ``killed_reason`` is authoritative (C1); exit-code heuristics apply only +to an older runner that omits it. close() racing the lease acquire or copy-in stops +the run and deletes the session it acquired (C4 / D7). +""" + +from __future__ import annotations + +import pytest +from test_container_executor_recovery import URL, ScriptedRunner, _count, _exe + +from bisheng.common.errcode.sandbox import SandboxExecTimeoutError +from bisheng_langchain.gpts.tools.code_interpreter import container_executor as ce + +# ---------------------------------------------------------------- C1 authority + + +def test_exit_124_with_null_killed_reason_is_a_normal_failure(tmp_path): + fake = ScriptedRunner() + fake.add_replica(URL) + fake.exec_script = [{"exitcode": 124, "stdout": "", "stderr": "curl timed out", "killed_reason": None}] + exitcode, logs, _ = _exe(fake).execute_code("timeout 2 curl http://slow", lang="sh", work_dir=str(tmp_path)) + assert exitcode == 124 + assert logs == "curl timed out" + assert "[SYSTEM NOTICE]" not in logs + + +def test_exit_130_with_null_killed_reason_has_no_signal_notice(tmp_path): + fake = ScriptedRunner() + fake.add_replica(URL) + fake.exec_script = [{"exitcode": 130, "stdout": "", "stderr": "bye", "killed_reason": None}] + exitcode, logs, _ = _exe(fake).execute_code("import sys; sys.exit(130)", work_dir=str(tmp_path)) + assert exitcode == 130 + assert logs == "bye" + + +def test_old_runner_without_field_keeps_exitcode_heuristics(tmp_path): + fake = ScriptedRunner() + fake.add_replica(URL) + fake.exec_script = [{"exitcode": 124, "stdout": "partial", "stderr": ""}] + with pytest.raises(SandboxExecTimeoutError): + _exe(fake).execute_code("x", timeout=60, work_dir=str(tmp_path)) + + +@pytest.mark.parametrize( + ("result", "exitcode", "expected"), + [ + ({"killed_reason": None}, 124, None), + ({"killed_reason": None}, 137, None), + ({"killed_reason": "memory"}, 137, "memory"), + ({"killed_reason": "timeout"}, 124, "timeout"), + ({"killed_reason": "weird"}, 137, "signal"), + ({"killed_reason": "weird"}, 1, None), + ({}, 124, "timeout"), + ({}, 143, "signal"), + ({}, 1, None), + ], +) +def test_killed_reason_table(result, exitcode, expected): + assert ce._killed_reason(result, exitcode) == expected + + +# ---------------------------------------------------------------- close() races + + +class CloseDuringRunner(ScriptedRunner): + """Calls ``executor.close()`` while one request of the chosen kind is in flight.""" + + def __init__(self, trigger: str): + super().__init__() + self.trigger = trigger + self.executor = None + + def request(self, method, url, **kwargs): + resp = super().request(method, url, **kwargs) + is_acquire = method == "POST" and url.rstrip("/").endswith("/v1/sessions") + is_put = method == "PUT" and url.endswith("/files") + if self.executor is not None and ( + (self.trigger == "acquire" and is_acquire) or (self.trigger == "put" and is_put) + ): + executor, self.executor = self.executor, None + # The acquire answer has not reached the executor yet, so close() + # sees no session and deletes nothing on its own. + executor.close() + return resp + + +@pytest.mark.parametrize("keep_session", [True, False]) +def test_close_during_acquire_deletes_the_new_lease_and_skips_exec(tmp_path, keep_session): + (tmp_path / "a.txt").write_text("x") + fake = CloseDuringRunner("acquire") + replica = fake.add_replica(URL) + exe = _exe(fake, keep_session=keep_session) + fake.executor = exe + + exitcode, logs, _ = exe.execute_code("print(1)", work_dir=str(tmp_path)) + + assert exitcode == 1 + assert logs == ce.TASK_STOPPED_MSG + assert _count(fake, "POST", "/exec") == 0 + assert _count(fake, "PUT", "/files") == 0 + assert replica.sessions == {} + assert exe._session_id is None + assert _count(fake, "POST", "/v1/sessions") == 1 + + +def test_close_during_copy_in_deletes_the_lease_and_skips_exec(tmp_path): + (tmp_path / "a.txt").write_text("x") + fake = CloseDuringRunner("put") + replica = fake.add_replica(URL) + exe = _exe(fake, keep_session=True) + fake.executor = exe + + exitcode, logs, _ = exe.execute_code("print(1)", work_dir=str(tmp_path)) + + assert (exitcode, logs) == (1, ce.TASK_STOPPED_MSG) + assert _count(fake, "POST", "/exec") == 0 + assert replica.sessions == {} + assert exe._session_id is None + + +def test_stop_after_acquire_does_not_drop_a_newer_lease(tmp_path): + fake = ScriptedRunner() + replica = fake.add_replica(URL) + exe = _exe(fake) + exe._ensure_lease() + stale = (exe._bound_url, exe._session_id, exe._lease_token) + exe._forget_lease() + exe._ensure_lease() + current = exe._session_id + + assert exe._stop_after_acquire(stale)[1] == ce.TASK_STOPPED_MSG + + assert exe._session_id == current + assert set(replica.sessions) == {current} diff --git a/src/backend/test/sandbox/test_container_executor_recovery.py b/src/backend/test/sandbox/test_container_executor_recovery.py new file mode 100644 index 0000000000..bf3bd2b7a7 --- /dev/null +++ b/src/backend/test/sandbox/test_container_executor_recovery.py @@ -0,0 +1,387 @@ +"""ContainerExecutor hardening from the F068 acceptance run (D2, D3, D4, D6, D7). + +Contract with the runner: C1 (exit codes / killed_reason), C2 (a kill keeps the +session), C3 (404 = lease lost, retry once), C4 (DELETE while exec runs), C5 +(copy-in total cap, streamed body). No real HTTP: a fake runner stands in. +""" + +from __future__ import annotations + +import threading +import time + +import pytest +from fake_runner import FakeResponse +from fake_runner import FakeRunnerClient as _BaseFakeRunnerClient + +from bisheng.common.errcode.sandbox import SandboxExecTimeoutError, SandboxProtocolError +from bisheng_langchain.gpts.tools.code_interpreter import container_executor as ce +from bisheng_langchain.gpts.tools.code_interpreter.container_executor import ContainerExecutor + +URL = "http://runner-a:8080" + + +class ScriptedRunner(_BaseFakeRunnerClient): + """Fake runner whose exec answers come from a script, one entry per call. + + An entry is a result dict, or a callable ``(replica, session) -> FakeResponse``. + When the script is empty the base fake's success path answers. + """ + + def __init__(self): + super().__init__() + self.exec_script: list = [] + self.put_bodies: list = [] + self.put_headers: list[dict] = [] + self.lose_session_after_exec = 0 + + def request(self, method, url, *, content=None, headers=None, **kwargs): + if content is not None and not isinstance(content, (bytes, bytearray)): + self.put_bodies.append(type(content)) + self.put_headers.append(dict(headers or {})) + content = b"".join(content) + return super().request(method, url, content=content, headers=headers, **kwargs) + + def _exec(self, replica, session, payload): + if self.exec_script: + entry = self.exec_script.pop(0) + resp = entry(replica, session) if callable(entry) else FakeResponse(200, json_data=entry) + else: + resp = super()._exec(replica, session, payload) + if self.lose_session_after_exec: + # Idle reaper / restart right after exec (TC-33): copy-out must recover. + self.lose_session_after_exec -= 1 + replica.sessions.clear() + return resp + + +def _exe(fake, **kwargs) -> ContainerExecutor: + opts = {"minio": {}, "endpoints": [URL], "token": "test-token", "client": fake, "keep_session": True} + opts.update(kwargs) + return ContainerExecutor(**opts) + + +def _count(fake, method: str, suffix: str) -> int: + return sum(1 for m, url in fake.calls if m == method and url.rstrip("/").endswith(suffix)) + + +def _killed(exitcode: int, reason, stderr: str = "") -> dict: + return {"exitcode": exitcode, "stdout": "", "stderr": stderr, "duration_ms": 5, "killed_reason": reason} + + +# ---------------------------------------------------------------- D4 / C3 + + +def test_lost_session_is_reacquired_with_full_copy_in(tmp_path): + (tmp_path / "data.csv").write_text("a,b\n1,2\n") + fake = ScriptedRunner() + replica = fake.add_replica(URL) + exe = _exe(fake) + assert exe.execute_code("print(1)", work_dir=str(tmp_path))[0] == 0 + # Second call: the md5 index says data.csv is already there, so nothing is resent. + assert exe.execute_code("print(1)", work_dir=str(tmp_path))[0] == 0 + assert fake.last_put_members == [] + + replica.sessions.clear() # runner restart or TTL reap between two tool calls + exitcode, logs, _ = exe.execute_code("print(1)", work_dir=str(tmp_path)) + assert exitcode == 0 + assert "ok" in logs + assert _count(fake, "POST", "/v1/sessions") == 2 + # Fresh lease = fresh copy of the whole workspace, not just the delta. + assert fake.last_put_members == ["data.csv"] + assert exe._session_id in replica.sessions + + +def test_exec_404_retries_once_on_new_lease(tmp_path): + fake = ScriptedRunner() + fake.add_replica(URL) + fake.exec_script = [lambda replica, session: FakeResponse(404, json_data={"error": "not found"})] + exe = _exe(fake, keep_session=False) + exitcode, _logs, _ = exe.execute_code("print(1)", work_dir=str(tmp_path)) + assert exitcode == 0 + assert _count(fake, "POST", "/v1/sessions") == 2 + assert _count(fake, "POST", "/exec") == 2 + + +def test_copy_out_404_reruns_and_keeps_outputs(tmp_path): + fake = ScriptedRunner() + fake.add_replica(URL, write_after_exec={"output/after.txt": b"after"}) + fake.lose_session_after_exec = 1 + exe = _exe(fake) + exitcode, _logs, _ = exe.execute_code("print(1)", work_dir=str(tmp_path)) + assert exitcode == 0 + assert (tmp_path / "output" / "after.txt").read_bytes() == b"after" + assert _count(fake, "POST", "/v1/sessions") == 2 + + +def test_session_lost_twice_surfaces_an_error_after_one_retry(tmp_path): + fake = ScriptedRunner() + fake.add_replica(URL) + lost = lambda replica, session: FakeResponse(404, json_data={"error": "not found"}) # noqa: E731 + fake.exec_script = [lost, lost, lost] + exe = _exe(fake) + with pytest.raises(SandboxProtocolError) as err: + exe.execute_code("print(1)", work_dir=str(tmp_path)) + assert err.value.Code == 28006 + assert "lost the session" in str(err.value) + assert _count(fake, "POST", "/v1/sessions") == 2 + assert exe._session_id is None + + +def test_put_files_404_reacquires(tmp_path): + (tmp_path / "a.txt").write_text("a") + fake = ScriptedRunner() + replica = fake.add_replica(URL) + exe = _exe(fake) + original = fake._put_files + calls = {"n": 0} + + def flaky_put(session, body): + calls["n"] += 1 + if calls["n"] == 1: + replica.sessions.clear() + return FakeResponse(404, json_data={"error": "not found"}) + return original(session, body) + + fake._put_files = flaky_put + assert exe.execute_code("print(1)", work_dir=str(tmp_path))[0] == 0 + assert _count(fake, "PUT", "/files") == 2 + assert fake.last_put_members == ["a.txt"] + + +def test_release_treats_404_as_success(tmp_path): + fake = ScriptedRunner() + replica = fake.add_replica(URL) + exe = _exe(fake) + exe.execute_code("print(1)", work_dir=str(tmp_path)) + session = exe._session_id + replica.sessions.clear() + exe.close() # must not raise on the runner's 404 + assert exe._session_id is None + assert _count(fake, "DELETE", session) == 1 + + +# ---------------------------------------------------------------- D6 / D2 / C1 / C2 + + +def test_oom_kill_names_the_cause_and_keeps_the_session(tmp_path): + fake = ScriptedRunner() + replica = fake.add_replica(URL) + fake.exec_script = [_killed(137, "memory")] + exe = _exe(fake) + exitcode, logs, _ = exe.execute_code("x = [0] * 10**12", work_dir=str(tmp_path)) + assert exitcode == 137 + assert "[SYSTEM NOTICE]" in logs + assert "ran out of memory" in logs + assert "chunks" in logs + # C2: only the process died; the next call reuses the same session. + session = exe._session_id + assert session in replica.sessions + assert exe.execute_code("print(1)", work_dir=str(tmp_path))[0] == 0 + assert exe._session_id == session + assert _count(fake, "POST", "/v1/sessions") == 1 + assert _count(fake, "DELETE", session) == 0 + + +def test_cpu_limit_kill_names_the_cpu_limit(tmp_path): + fake = ScriptedRunner() + fake.add_replica(URL) + fake.exec_script = [_killed(137, "cpu")] + exitcode, logs, _ = _exe(fake).execute_code("while True: pass", work_dir=str(tmp_path)) + assert exitcode == 137 + assert "CPU time limit (600s of CPU time)" in logs + + +def test_signal_kill_names_the_signal(tmp_path): + fake = ScriptedRunner() + fake.add_replica(URL) + fake.exec_script = [_killed(143, "signal", stderr="Terminated")] + exitcode, logs, _ = _exe(fake).execute_code("import os", work_dir=str(tmp_path)) + assert exitcode == 143 + assert logs.startswith("Terminated") + assert "killed by signal 15 (SIGTERM)" in logs + + +def test_old_runner_negative_exitcode_is_normalized(tmp_path): + fake = ScriptedRunner() + fake.add_replica(URL) + # Pre-fix runner: raw Popen returncode, no killed_reason, empty output (TC-35 / TC-37). + fake.exec_script = [{"exitcode": -9, "stdout": "", "stderr": "", "duration_ms": 5}] + exitcode, logs, _ = _exe(fake).execute_code("x = 1", work_dir=str(tmp_path)) + assert exitcode == 137 + assert "signal 9 (SIGKILL)" in logs + assert "ran out of memory or used up its CPU time limit" in logs + + +def test_plain_failure_has_no_kill_notice(tmp_path): + fake = ScriptedRunner() + fake.add_replica(URL) + fake.exec_script = [_killed(1, None, stderr="Traceback: boom")] + exitcode, logs, _ = _exe(fake).execute_code("raise SystemExit(1)", work_dir=str(tmp_path)) + assert exitcode == 1 + assert logs == "Traceback: boom" + + +def test_timeout_reaches_the_model_as_a_timeout_with_partial_output(tmp_path): + fake = ScriptedRunner() + fake.add_replica(URL) + fake.exec_script = [ + {"exitcode": 124, "stdout": "step 1 done\n", "stderr": "", "duration_ms": 60000, "killed_reason": "timeout"} + ] + exe = _exe(fake) + with pytest.raises(SandboxExecTimeoutError) as err: + exe.execute_code("import time; time.sleep(10**6)", timeout=60, work_dir=str(tmp_path)) + text = str(err.value) + assert err.value.Code == 28003 + assert "wall-clock timeout of 60s" in text + assert "step 1 done" in text + # The session itself is fine; a later call reuses it. + assert exe._session_id is not None + + +def test_http_timeout_on_exec_names_the_wall_clock_limit(tmp_path): + import httpx + + class SlowRunner(ScriptedRunner): + def request(self, method, url, **kwargs): + if url.endswith("/exec"): + raise httpx.ReadTimeout("slow") + return super().request(method, url, **kwargs) + + fake = SlowRunner() + fake.add_replica(URL) + with pytest.raises(SandboxExecTimeoutError) as err: + _exe(fake, keep_session=False).execute_code("x", timeout=30, work_dir=str(tmp_path)) + assert "wall-clock timeout of 30s" in str(err.value) + + +# ---------------------------------------------------------------- D3 / C5 + + +def test_copy_in_total_cap_skips_without_reading_and_streams(tmp_path, monkeypatch): + for name in ("a.bin", "b.bin", "c.bin"): + (tmp_path / name).write_bytes(name.encode() * 20) # 100 bytes each + fake = ScriptedRunner() + fake.add_replica(URL) + hashed: list[str] = [] + real_md5 = ce._md5_file + monkeypatch.setattr(ce, "_md5_file", lambda path: hashed.append(path.rsplit("/", 1)[-1]) or real_md5(path)) + exe = _exe(fake, max_copy_in_total_bytes=250) + exitcode, logs, _ = exe.execute_code("print(1)", work_dir=str(tmp_path)) + assert exitcode == 0 + sent = sorted(fake.last_put_members) + assert len(sent) == 2 + skipped = ({"a.bin", "b.bin", "c.bin"} - set(sent)).pop() + assert skipped not in hashed + assert "250-byte copy-in limit" in logs + assert skipped in logs + assert exe.copy_in_bytes == 200 + # Streamed body with an explicit length, not one in-memory bytes blob. + assert fake.put_bodies and fake.put_bodies[-1] not in (bytes, bytearray) + assert int(fake.put_headers[-1]["Content-Length"]) == len(fake.last_put_body) + + # Unchanged files still count toward the session total on the next call. + exe.execute_code("print(1)", work_dir=str(tmp_path)) + assert fake.last_put_members == [] + assert skipped in exe._skipped_total + + +def test_copy_in_total_cap_reads_settings_default(): + from bisheng.core.config.settings import SandboxConf + + exe = ContainerExecutor(minio={}, sandbox_conf=SandboxConf(endpoints=[URL])) + assert exe.max_copy_in_total_bytes == 512 * 1024 * 1024 + exe = ContainerExecutor(minio={}, sandbox_conf=SandboxConf(endpoints=[URL], max_copy_in_total_bytes=1000)) + assert exe.max_copy_in_total_bytes == 1000 + + +# ---------------------------------------------------------------- D7 / C4 + + +def test_close_during_blocked_exec_deletes_session_and_does_not_retry(tmp_path): + fake = ScriptedRunner() + replica = fake.add_replica(URL) + started = threading.Event() + deleted = threading.Event() + + def blocking_exec(rep, session): + started.set() + # The runner kills the group on DELETE; the exec answer then reflects the kill. + assert deleted.wait(5), "close() never reached the runner" + return FakeResponse(200, json_data=_killed(137, "signal")) + + fake.exec_script = [blocking_exec] + original_request = fake.request + + def tracking_request(method, url, **kwargs): + resp = original_request(method, url, **kwargs) + if method == "DELETE": + deleted.set() + return resp + + fake.request = tracking_request + exe = _exe(fake) + out: dict = {} + worker = threading.Thread( + target=lambda: out.setdefault("result", exe.execute_code("sleep 300", work_dir=str(tmp_path))) + ) + worker.start() + assert started.wait(5) + session = exe._session_id + t0 = time.monotonic() + exe.close() # task cancel path: runs on another thread while exec is blocked + assert time.monotonic() - t0 < 2 + worker.join(5) + assert not worker.is_alive() + assert session not in replica.sessions + exitcode, logs, _ = out["result"] + assert exitcode == 1 + assert logs == ce.TASK_STOPPED_MSG + # No resurrection: no second lease, no copy-out after the stop. + assert _count(fake, "POST", "/v1/sessions") == 1 + assert _count(fake, "GET", "/files") == 0 + # The executor is still usable for a later, unrelated run. + assert exe.execute_code("print(1)", work_dir=str(tmp_path))[0] == 0 + + +def test_close_while_exec_returns_404_does_not_reacquire(tmp_path): + fake = ScriptedRunner() + fake.add_replica(URL) + started = threading.Event() + release = threading.Event() + + def blocking_exec(rep, session): + started.set() + release.wait(5) + return FakeResponse(404, json_data={"error": "not found"}) + + fake.exec_script = [blocking_exec] + exe = _exe(fake, keep_session=False) + out: dict = {} + worker = threading.Thread(target=lambda: out.setdefault("r", exe.execute_code("x", work_dir=str(tmp_path)))) + worker.start() + assert started.wait(5) + exe.close() + release.set() + worker.join(5) + assert out["r"] == (1, ce.TASK_STOPPED_MSG, "") + assert _count(fake, "POST", "/v1/sessions") == 1 + + +def test_runner_side_total_skip_is_reported_as_total_and_not_indexed(tmp_path): + (tmp_path / "small.txt").write_text("x") + fake = ScriptedRunner() + fake.add_replica(URL) + original = fake._put_files + + def put_with_total_skip(session, body): + original(session, body) + session.files.pop("small.txt", None) + return FakeResponse(200, json_data={"written": [], "skipped": [{"path": "small.txt", "size": 1}]}) + + fake._put_files = put_with_total_skip + exe = _exe(fake) + _code, logs, _ = exe.execute_code("print(1)", work_dir=str(tmp_path)) + assert "copy-in limit" in logs + assert "oversized" not in logs + assert "small.txt" not in exe._md5_index diff --git a/src/backend/test/sandbox/test_daily_chat_stop_code_executor.py b/src/backend/test/sandbox/test_daily_chat_stop_code_executor.py new file mode 100644 index 0000000000..ba7166b8cb --- /dev/null +++ b/src/backend/test/sandbox/test_daily_chat_stop_code_executor.py @@ -0,0 +1,46 @@ +"""Daily chat: a torn-down turn stops its sandbox run (F068 D7, contract C4).""" + +from __future__ import annotations + +import threading +from types import SimpleNamespace + +from bisheng.workstation.domain.services import chat_service +from bisheng_langchain.gpts.tools.code_interpreter.container_executor import ContainerExecutor +from bisheng_langchain.gpts.tools.code_interpreter.local_executor import LocalExecutor + + +class _RecordingContainer(ContainerExecutor): + def __init__(self): + super().__init__(minio={}, endpoints=["http://runner-a:8080"], token="t") + self.closed = threading.Event() + self.close_thread: str | None = None + + def close(self) -> None: + self.close_thread = threading.current_thread().name + self.closed.set() + + +def test_interrupted_turn_closes_container_executors_off_the_loop_thread(): + container = _RecordingContainer() + wrapped = SimpleNamespace(name="wrapper", tool_instance=SimpleNamespace(executor=container)) + local = LocalExecutor(minio={}) + local_closed = [] + local.close = lambda: local_closed.append(True) + tools = [wrapped, SimpleNamespace(name="local", executor=local), SimpleNamespace(name="web_search")] + + chat_service._stop_code_executors(tools) + + assert container.closed.wait(5) + assert container.close_thread == "code-executor-stop" + assert container.close_thread != threading.current_thread().name + assert local_closed == [] + + +def test_close_failure_is_logged_not_raised(): + class _Boom: + def close(self): + raise RuntimeError("runner gone") + + # Runs inline here; on the cancel path it runs on the daemon thread. + chat_service._close_quietly(_Boom()) diff --git a/src/backend/test/sandbox/test_e2b_execute_code.py b/src/backend/test/sandbox/test_e2b_execute_code.py new file mode 100644 index 0000000000..17f4b681dc --- /dev/null +++ b/src/backend/test/sandbox/test_e2b_execute_code.py @@ -0,0 +1,54 @@ +"""E2B execute_code is code-only: no workspace copy-in and no file harvest.""" + +from types import SimpleNamespace + +from bisheng_langchain.gpts.tools.code_interpreter.e2b_executor import E2bCodeExecutor + + +def _executor(sandbox) -> E2bCodeExecutor: + executor = E2bCodeExecutor.__new__(E2bCodeExecutor) + executor.sandbox = sandbox + executor.keep_sandbox = False + executor.minio = {} + return executor + + +def test_execute_code_returns_stdout_and_does_not_copy_workspace(): + calls: list[str] = [] + + class _Sandbox: + def run_code(self, code, timeout=None): + calls.append(code) + return SimpleNamespace(logs=SimpleNamespace(stdout=["hello\n"], stderr=["warn"]), error=None) + + def kill(self): + calls.append("kill") + + executor = _executor(_Sandbox()) + executor._copy_in = lambda *_a, **_k: calls.append("copy_in") + executor._copy_out = lambda *_a, **_k: calls.append("copy_out") + exitcode, logs, stderr = executor.execute_code(code="print(1)", lang="python", work_dir="/app") + assert exitcode == 0 + assert logs == "hello\n" + assert stderr == "warn" + assert calls == ["print(1)", "kill"] + + +def test_execute_code_failure_puts_the_error_in_logs(): + class _Sandbox: + def run_code(self, code, timeout=None): + return SimpleNamespace( + logs=SimpleNamespace(stdout=["partial"], stderr=[]), + error=SimpleNamespace(name="ValueError", value="bad", traceback="tb"), + ) + + def kill(self): + return None + + executor = _executor(_Sandbox()) + exitcode, logs, stderr = executor.execute_code(code="raise ValueError('bad')") + assert exitcode == 1 + assert stderr == "" + assert "ValueError" in logs + assert "bad" in logs + assert "partial" not in logs diff --git a/src/backend/test/sandbox/test_execute_code_contract.py b/src/backend/test/sandbox/test_execute_code_contract.py new file mode 100644 index 0000000000..6d2aaa8d4b --- /dev/null +++ b/src/backend/test/sandbox/test_execute_code_contract.py @@ -0,0 +1,105 @@ +"""Shared ``execute_code`` / ``run_with_dir`` harvest contract (F068 T005). + +A fake ``execute_code`` is injected on a ``BaseExecutor`` subclass so this file +never starts a real runner or a real subprocess. Regression after T006: + +- ``test/linsight/test_code_interpreter_log_cap.py`` +- ``test/linsight/test_code_interpreter_escape_guard.py`` +- ``test/linsight/test_code_interpreter_output_path.py`` +- ``test/linsight/test_code_interpreter_workspace_mirror.py`` +- ``test/linsight/test_code_interpreter_timeout.py`` +- ``test/linsight/test_code_interpreter_root_relocation.py`` +""" + +from __future__ import annotations + +import os +from pathlib import Path + +from bisheng_langchain.gpts.tools.code_interpreter.base_executor import BaseExecutor + + +class _FakeHarvestExecutor(BaseExecutor): + """Writes files from the test, then lets shared ``run_with_dir`` harvest them.""" + + def __init__(self, minio: dict | None = None, **kwargs): + super().__init__(minio or {}, **kwargs) + self._handler = None + self._work_dir = "" + + def run(self, code: str): + exitcode, log, file_list = self.run_with_dir(code, dir_path=self._work_dir, lang="python") + return {"exitcode": exitcode, "log": log, "file_list": file_list} + + def execute_code(self, code=None, timeout=None, filename=None, work_dir=None, lang="python"): + return self._handler(work_dir, code) + + def upload_minio(self, object_name: str, file_path) -> str: + return f"https://files/{os.path.basename(file_path)}" + + +def _write(root: str, rel: str, content: str) -> None: + path = Path(root) / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + + +def test_success_relocates_root_file_and_lists_only_this_run(tmp_path, monkeypatch): + work = str(tmp_path) + _write(work, "uploads/source.txt", "preexisting") + _write(work, "output/old.txt", "orig") + + exe = _FakeHarvestExecutor(minio={"public_bucket": "bisheng"}, workspace_prefix="workspace/s1") + exe._work_dir = work + + def _handler(work_dir, code): + _write(work_dir, "report.md", "new-root") + _write(work_dir, "output/old.txt", "changed") + return 0, "ok\n", "" + + exe._handler = _handler + + synced: list[tuple[str, list[str]]] = [] + monkeypatch.setattr( + exe, + "sync_to_workspace", + lambda dir_path, rels: synced.append((dir_path, list(rels))) or len(rels), + ) + + result = exe.run("print('ok')") + + assert set(result) == {"exitcode", "log", "file_list"} + assert result["exitcode"] == 0 + assert "ok" in result["log"] + assert not (tmp_path / "report.md").exists() + assert (tmp_path / "output" / "report.md").read_text() == "new-root" + assert "report.md -> output/report.md" in result["log"] + assert result["file_list"] == ["https://files/report.md", "https://files/old.txt"] + assert synced and synced[0][0] == work + rels = synced[0][1] + assert "output/report.md" in rels or os.path.join("output", "report.md") in rels + assert "output/old.txt" in rels or os.path.join("output", "old.txt") in rels + assert not any(rel.endswith("source.txt") for rel in rels) + + +def test_nonzero_exit_keeps_stdout_and_empty_file_list(tmp_path, monkeypatch): + exe = _FakeHarvestExecutor(minio={"public_bucket": "bisheng"}, workspace_prefix="workspace/s1") + exe._work_dir = str(tmp_path) + + def _handler(work_dir, code): + _write(work_dir, "half.txt", "partial") + return 1, "printed before fail\nTraceback (most recent call last):\n", "" + + exe._handler = _handler + called = {"n": 0} + monkeypatch.setattr(exe, "sync_to_workspace", lambda *a, **kw: called.__setitem__("n", called["n"] + 1)) + + result = exe.run("raise SystemExit(1)") + + assert set(result) == {"exitcode", "log", "file_list"} + assert result["exitcode"] == 1 + assert "printed before fail" in result["log"] + assert result["file_list"] == [] + assert called["n"] == 0 + # harvest skipped, so the half-written root file is not relocated + assert (tmp_path / "half.txt").exists() diff --git a/src/backend/test/sandbox/test_factory_page_conf.py b/src/backend/test/sandbox/test_factory_page_conf.py new file mode 100644 index 0000000000..dc960f38d8 --- /dev/null +++ b/src/backend/test/sandbox/test_factory_page_conf.py @@ -0,0 +1,70 @@ +"""Container executors read the page sandbox config (F086).""" + +from __future__ import annotations + +import pytest + +from bisheng.common.errcode.sandbox import SandboxUnreachableError +from bisheng.core.config.settings import SandboxConf +from bisheng_langchain.gpts.tools.code_interpreter.factory import build_code_executor + + +def _patch_page(monkeypatch: pytest.MonkeyPatch, conf: SandboxConf): + monkeypatch.setattr( + "bisheng_langchain.gpts.tools.code_interpreter.factory.load_sandbox_conf", + lambda: conf, + ) + + +def test_container_uses_page_conf_not_deployment(monkeypatch: pytest.MonkeyPatch): + page = SandboxConf(endpoints=["http://page:8080"], token="page-token") + calls = {"n": 0} + + def fake(): + calls["n"] += 1 + return page + + monkeypatch.setattr( + "bisheng_langchain.gpts.tools.code_interpreter.factory.load_sandbox_conf", + fake, + ) + executor = build_code_executor( + "container", + endpoints=["http://caller:8080"], + token="caller-token", + discover_host_pattern="code-runner-{n}", + ) + assert executor.endpoints == ["http://page:8080"] + assert executor.token == "page-token" + assert calls["n"] == 1 + + +def test_built_executor_keeps_its_snapshot(monkeypatch: pytest.MonkeyPatch): + holder = {"conf": SandboxConf(endpoints=["http://a:8080"], token="t1")} + monkeypatch.setattr( + "bisheng_langchain.gpts.tools.code_interpreter.factory.load_sandbox_conf", + lambda: holder["conf"], + ) + executor = build_code_executor("container") + holder["conf"] = SandboxConf(endpoints=["http://b:8080"], token="t2") + assert executor.endpoints == ["http://a:8080"] + assert executor.token == "t1" + + +def test_empty_page_endpoints_are_unreachable(monkeypatch: pytest.MonkeyPatch): + _patch_page(monkeypatch, SandboxConf(endpoints=[], token="")) + executor = build_code_executor("container") + with pytest.raises(SandboxUnreachableError): + executor._ensure_lease() + + +def test_local_and_e2b_do_not_read_sandbox_conf(monkeypatch: pytest.MonkeyPatch): + def boom(): + raise AssertionError("sandbox conf should not be read") + + monkeypatch.setattr( + "bisheng_langchain.gpts.tools.code_interpreter.factory.load_sandbox_conf", + boom, + ) + build_code_executor("local") + build_code_executor("e2b", minio={}, api_key="k") diff --git a/src/backend/test/sandbox/test_get_sandbox_conf.py b/src/backend/test/sandbox/test_get_sandbox_conf.py new file mode 100644 index 0000000000..06c9c46fff --- /dev/null +++ b/src/backend/test/sandbox/test_get_sandbox_conf.py @@ -0,0 +1,282 @@ +"""Page sandbox config merge and cache (F086).""" + +from __future__ import annotations + +import json +import os + +import pytest +from loguru import logger + +from bisheng.common.models.config import ConfigKeyEnum +from bisheng.common.services.config_service import ( + SANDBOX_ENDPOINT_CACHE_KEY, + SANDBOX_ENDPOINT_CACHE_TTL, + ConfigService, + write_sandbox_endpoint_cache, +) +from bisheng.core.config.settings import SandboxConf, Settings, encrypt_token + +_UNREADABLE = "sandbox endpoint config unreadable; using deployment config" +_ENV_PREFIX = "BS_SANDBOX_CONF__" + + +def _enc(token: str) -> str: + raw = encrypt_token(token) + return raw.decode() if isinstance(raw, bytes) else raw + + +def _payload(endpoints: list[str], token: str) -> dict: + return {"endpoints": endpoints, "token_enc": _enc(token) if token else ""} + + +class _Row: + def __init__(self, value: str): + self.value = value + + +class _Holder: + def __init__(self, conf: SandboxConf): + self.sandbox_conf = conf + + +class FakeRedis: + def __init__(self): + self.store: dict = {} + self.set_calls: list = [] + self.setnx_calls: list = [] + self.get_error: Exception | None = None + self.setnx_error: Exception | None = None + self.miss_once = False + self._reads = 0 + + def get(self, key): + if self.get_error: + raise self.get_error + self._reads += 1 + if self.miss_once and self._reads == 1: + return None + return self.store.get(key) + + def set(self, key, value, expiration=3600, enx=None): + self.set_calls.append((key, value, expiration)) + self.store[key] = value + + def setNx(self, key, value, expiration=3600): + self.setnx_calls.append((key, value, expiration)) + if self.setnx_error: + raise self.setnx_error + if key in self.store: + return False + self.store[key] = value + return True + + +@pytest.fixture +def clean_sandbox_env(monkeypatch: pytest.MonkeyPatch) -> None: + for key in list(os.environ): + if key.startswith(_ENV_PREFIX): + monkeypatch.delenv(key, raising=False) + + +@pytest.fixture +def deployment() -> _Holder: + return _Holder(SandboxConf(endpoints=["http://deploy:8080"], token="deploy-token")) + + +@pytest.fixture +def redis(monkeypatch: pytest.MonkeyPatch) -> FakeRedis: + client = FakeRedis() + monkeypatch.setattr("bisheng.common.services.config_service.get_redis_client_sync", lambda: client) + return client + + +@pytest.fixture +def dao(monkeypatch: pytest.MonkeyPatch) -> dict: + holder = {"row": None, "calls": 0, "error": None} + + def get_config(key): + holder["calls"] += 1 + assert key == ConfigKeyEnum.SANDBOX_ENDPOINT + if holder["error"]: + raise holder["error"] + return holder["row"] + + monkeypatch.setattr("bisheng.common.services.config_service.ConfigDao.get_config", get_config) + return holder + + +@pytest.fixture +def errors(): + messages: list[str] = [] + sink = logger.add(lambda message: messages.append(message.record["message"]), level="ERROR") + yield messages + logger.remove(sink) + + +def test_no_row_matches_deployment_fieldwise(deployment, redis, dao, errors): + result = ConfigService.get_sandbox_conf(deployment) + assert result.model_dump() == deployment.sandbox_conf.model_dump() + assert dao["calls"] == 1 + assert redis.set_calls == [] + assert redis.setnx_calls == [] + assert _UNREADABLE not in errors + assert "deploy-token" not in " ".join(errors) + + +def test_page_endpoints_override_address_only(deployment, redis, dao): + dao["row"] = _Row(json.dumps(_payload(["http://page:8080"], ""))) + result = ConfigService.get_sandbox_conf(deployment) + assert result.endpoints == ["http://page:8080"] + assert result.token == "deploy-token" + assert redis.setnx_calls[0][0] == SANDBOX_ENDPOINT_CACHE_KEY + assert redis.setnx_calls[0][1] == {"endpoints": ["http://page:8080"], "token_enc": ""} + assert redis.setnx_calls[0][2] == SANDBOX_ENDPOINT_CACHE_TTL + assert not isinstance(redis.setnx_calls[0][1], str) + + +def test_page_token_overrides_token_only(deployment, redis, dao): + dao["row"] = _Row(json.dumps(_payload([], "page-token"))) + result = ConfigService.get_sandbox_conf(deployment) + assert result.endpoints == ["http://deploy:8080"] + assert result.token == "page-token" + cached = redis.store[SANDBOX_ENDPOINT_CACHE_KEY] + assert "page-token" not in json.dumps(cached) + assert cached["token_enc"] + + +def test_empty_page_fields_fall_back(deployment, redis, dao): + dao["row"] = _Row(json.dumps(_payload([], ""))) + result = ConfigService.get_sandbox_conf(deployment) + assert result.endpoints == ["http://deploy:8080"] + assert result.token == "deploy-token" + + +def test_page_token_beats_env_overlay(clean_sandbox_env, monkeypatch, redis, dao): + monkeypatch.setenv(f"{_ENV_PREFIX}TOKEN", "env-token") + base = Settings() + assert base.sandbox_conf.token == "env-token" + dao["row"] = _Row(json.dumps(_payload([], "page-token"))) + result = ConfigService.get_sandbox_conf(base) + assert result.token == "page-token" + + +def test_cache_hit_skips_db(deployment, redis, dao): + redis.store[SANDBOX_ENDPOINT_CACHE_KEY] = _payload(["http://cached:8080"], "cached-token") + dao["row"] = _Row(json.dumps(_payload(["http://db:8080"], "db-token"))) + result = ConfigService.get_sandbox_conf(deployment) + assert result.endpoints == ["http://cached:8080"] + assert result.token == "cached-token" + assert dao["calls"] == 0 + assert redis.set_calls == [] + assert redis.setnx_calls == [] + + +@pytest.mark.parametrize( + "cached", + [ + ["http://not-a-dict"], + {"endpoints": [1], "token_enc": ""}, + {"endpoints": [], "token_enc": None}, + {"token_enc": ""}, + None, + ], +) +def test_invalid_cache_reads_db_and_does_not_write(deployment, redis, dao, cached): + if cached is not None: + redis.store[SANDBOX_ENDPOINT_CACHE_KEY] = cached + else: + redis.store[SANDBOX_ENDPOINT_CACHE_KEY] = {"endpoints": "nope", "token_enc": ""} + dao["row"] = _Row(json.dumps(_payload(["http://db:8080"], "db-token"))) + result = ConfigService.get_sandbox_conf(deployment) + assert result.token == "db-token" + assert result.endpoints == ["http://db:8080"] + assert redis.set_calls == [] + assert redis.setnx_calls == [] + + +def test_redis_error_reads_db_without_writing(deployment, redis, dao, errors): + redis.get_error = RuntimeError("redis down") + dao["row"] = _Row(json.dumps(_payload(["http://db:8080"], "db-token"))) + result = ConfigService.get_sandbox_conf(deployment) + assert result.token == "db-token" + assert redis.set_calls == [] + assert redis.setnx_calls == [] + assert _UNREADABLE not in errors + + +def test_db_error_falls_back_without_raising(deployment, redis, dao, errors): + redis.get_error = RuntimeError("redis down") + dao["error"] = RuntimeError("db down") + result = ConfigService.get_sandbox_conf(deployment) + assert result.model_dump() == deployment.sandbox_conf.model_dump() + assert _UNREADABLE in errors + assert "deploy-token" not in " ".join(errors) + + +@pytest.mark.parametrize("raw", ["{", "null", "[]", '{"endpoints": [1], "token_enc": ""}', "{}"]) +def test_bad_db_value_falls_back(deployment, redis, dao, errors, raw): + dao["row"] = _Row(raw) + result = ConfigService.get_sandbox_conf(deployment) + assert result.token == "deploy-token" + assert redis.setnx_calls == [] + assert _UNREADABLE in errors + assert "deploy-token" not in " ".join(errors) + + +def test_decrypt_failure_on_cache_falls_back(deployment, redis, dao, errors): + redis.store[SANDBOX_ENDPOINT_CACHE_KEY] = {"endpoints": ["http://page:8080"], "token_enc": "not-fernet"} + result = ConfigService.get_sandbox_conf(deployment) + assert result.token == "deploy-token" + assert dao["calls"] == 0 + assert _UNREADABLE in errors + assert "not-fernet" not in " ".join(errors) + assert "deploy-token" not in " ".join(errors) + + +def test_decrypt_failure_on_db_does_not_fill(deployment, redis, dao, errors): + dao["row"] = _Row(json.dumps({"endpoints": [], "token_enc": "not-fernet"})) + result = ConfigService.get_sandbox_conf(deployment) + assert result.token == "deploy-token" + assert redis.setnx_calls == [] + assert _UNREADABLE in errors + + +def test_setnx_error_still_returns_db_value(deployment, redis, dao): + redis.setnx_error = RuntimeError("cannot fill") + dao["row"] = _Row(json.dumps(_payload(["http://db:8080"], "db-token"))) + result = ConfigService.get_sandbox_conf(deployment) + assert result.token == "db-token" + assert result.endpoints == ["http://db:8080"] + + +def test_existing_key_is_not_overwritten_by_fill(deployment, redis, dao): + newer = _payload(["http://new:8080"], "new-token") + redis.store[SANDBOX_ENDPOINT_CACHE_KEY] = newer + redis.miss_once = True + dao["row"] = _Row(json.dumps(_payload(["http://old:8080"], "old-token"))) + first = ConfigService.get_sandbox_conf(deployment) + assert first.token == "old-token" + assert redis.store[SANDBOX_ENDPOINT_CACHE_KEY] == newer + assert redis.set_calls == [] + second = ConfigService.get_sandbox_conf(deployment) + assert second.token == "new-token" + assert second.endpoints == ["http://new:8080"] + + +def test_write_then_read_uses_new_cache(deployment, redis, dao): + payload = _payload(["http://saved:8080"], "saved-token") + write_sandbox_endpoint_cache(payload) + assert redis.set_calls[0][0] == SANDBOX_ENDPOINT_CACHE_KEY + assert redis.set_calls[0][2] == SANDBOX_ENDPOINT_CACHE_TTL + assert isinstance(redis.set_calls[0][1], dict) + assert "saved-token" not in json.dumps(redis.set_calls[0][1]) + result = ConfigService.get_sandbox_conf(deployment) + assert result.token == "saved-token" + assert dao["calls"] == 0 + + +def test_write_rejects_bad_payload(redis): + with pytest.raises(ValueError): + write_sandbox_endpoint_cache({"endpoints": "nope", "token_enc": ""}) + assert redis.set_calls == [] diff --git a/src/backend/test/sandbox/test_kb_stage_executor.py b/src/backend/test/sandbox/test_kb_stage_executor.py new file mode 100644 index 0000000000..768025dcc7 --- /dev/null +++ b/src/backend/test/sandbox/test_kb_stage_executor.py @@ -0,0 +1,97 @@ +"""LocalExecutor writes staged knowledge images before the script runs.""" + +from __future__ import annotations + +from bisheng.workstation.domain.services.kb_image_stage import KbImageStage +from bisheng_langchain.gpts.tools.code_interpreter.e2b_executor import E2bCodeExecutor +from bisheng_langchain.gpts.tools.code_interpreter.local_executor import LocalExecutor + +URL = "/bisheng/knowledge/images/1/27/img_1.png" +REL = "uploads/kb/1/27/img_1.png" + + +class _Resp: + headers: dict = {} + + def __init__(self, data: bytes): + self._data = data + + def stream(self, size): + yield self._data + + def close(self): + pass + + def release_conn(self): + pass + + +class _Client: + def __init__(self, objects): + self.objects = objects + + def get_object(self, bucket, key): + if key not in self.objects: + raise RuntimeError("missing") + return _Resp(self.objects[key]) + + +def _executor(objects, monkeypatch) -> LocalExecutor: + stage = KbImageStage("bisheng", max_files=100, max_file_bytes=1024, max_total_bytes=4096) + stage.annotate(f"![a]({URL})") + exe = LocalExecutor(minio={"public_bucket": "bisheng"}) + monkeypatch.setattr(exe, "_minio_client", lambda: _Client(objects)) + exe.attach_stage(stage) + return exe + + +def test_script_can_open_staged_image(monkeypatch): + exe = _executor({"knowledge/images/1/27/img_1.png": b"PNGDATA"}, monkeypatch) + result = exe.run(f"print(open('{REL}','rb').read().decode())") + assert result["exitcode"] == 0 + assert "PNGDATA" in result["log"] + # Inputs are not deliverables. + assert not any("img_1.png" in str(f) for f in result["file_list"]) + + +def test_missing_image_adds_notice_on_success(monkeypatch): + exe = _executor({}, monkeypatch) + result = exe.run("print('hi')") + assert result["exitcode"] == 0 + assert REL in result["log"] and "NOT written" in result["log"] + + +def test_missing_image_adds_notice_on_failure(monkeypatch): + exe = _executor({}, monkeypatch) + result = exe.run(f"open('{REL}','rb')") + assert result["exitcode"] != 0 + assert REL in result["log"] and "NOT written" in result["log"] + + +def test_failure_with_object_url_gets_advisory(monkeypatch): + exe = _executor({"knowledge/images/1/27/img_1.png": b"x"}, monkeypatch) + result = exe.run("open('knowledge/images/1/27/nope.png','rb')") + assert result["exitcode"] != 0 + assert REL in result["log"] + + +def test_hint_only_when_stage_attached(): + exe = LocalExecutor(minio={}) + plain = exe.description + assert "KNOWLEDGE-BASE IMAGES" not in plain + exe.attach_stage(KbImageStage("bisheng", max_files=1, max_file_bytes=1, max_total_bytes=1)) + assert "KNOWLEDGE-BASE IMAGES" in exe.description + + +def test_no_stage_leaves_behaviour_unchanged(): + result = LocalExecutor(minio={}).run("print('ok')") + assert result["exitcode"] == 0 + assert "NOT written" not in result["log"] + + +def test_e2b_ignores_stage(): + assert E2bCodeExecutor.supports_kb_stage is False + exe = E2bCodeExecutor.__new__(E2bCodeExecutor) + exe._kb_stage = None + exe.attach_stage(object()) + assert exe._kb_stage is None diff --git a/src/backend/test/sandbox/test_load_tools_dispatch.py b/src/backend/test/sandbox/test_load_tools_dispatch.py new file mode 100644 index 0000000000..e2bea1d437 --- /dev/null +++ b/src/backend/test/sandbox/test_load_tools_dispatch.py @@ -0,0 +1,113 @@ +"""load_tools code-interpreter dispatch (F068 T017). No live runner / E2B session.""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest + +from bisheng.core.config.settings import SandboxConf +from bisheng_langchain.gpts.load_tools import _get_native_code_interpreter +from bisheng_langchain.gpts.tools.code_interpreter.container_executor import ContainerExecutor +from bisheng_langchain.gpts.tools.code_interpreter.e2b_executor import E2bCodeExecutor +from bisheng_langchain.gpts.tools.code_interpreter.local_executor import LocalExecutor + +_BACKEND = Path(__file__).resolve().parents[2] +_FIVE_PATHS = ( + _BACKEND / "bisheng/linsight/domain/services/workbench_impl.py", + _BACKEND / "bisheng/workstation/domain/services/chat_service.py", + _BACKEND / "bisheng/api/services/assistant_agent.py", + _BACKEND / "bisheng/workflow/nodes/agent/agent.py", + _BACKEND / "bisheng/workflow/nodes/tool/tool.py", +) + + +def test_type_local_builds_local_executor(): + tool = _get_native_code_interpreter(minio={}, type="local") + assert isinstance(tool.executor, LocalExecutor) + + +def test_type_container_builds_container_executor(): + tool = _get_native_code_interpreter(minio={}, type="container") + assert isinstance(tool.executor, ContainerExecutor) + + +def test_type_e2b_builds_e2b_executor(): + tool = _get_native_code_interpreter(minio={}, type="e2b", config={"e2b": {"api_key": "k"}}) + assert isinstance(tool.executor, E2bCodeExecutor) + + +def test_missing_type_defaults_to_local_not_container(): + tool = _get_native_code_interpreter(minio={}) + assert isinstance(tool.executor, LocalExecutor) + assert not isinstance(tool.executor, ContainerExecutor) + + +def test_unknown_type_raises_and_does_not_fall_through_to_e2b(): + with pytest.raises(ValueError, match="Unknown code interpreter type"): + _get_native_code_interpreter(minio={}, type="cloud") + + +def test_e2b_config_type_is_not_an_executor_kwarg(monkeypatch: pytest.MonkeyPatch): + captured: dict = {} + + class _Spy: + description = "spy" + + def __init__(self, **kwargs): + captured.update(kwargs) + + monkeypatch.setattr( + "bisheng_langchain.gpts.tools.code_interpreter.factory.E2bCodeExecutor", + _Spy, + ) + _get_native_code_interpreter( + minio={}, + type="e2b", + config={"e2b": {"api_key": "k", "type": "official", "domain": ""}}, + ) + assert "type" not in captured + assert captured.get("api_key") == "k" + + +def test_container_pool_params_come_from_sandbox_conf_not_extra(monkeypatch: pytest.MonkeyPatch): + import bisheng_langchain.gpts.tools.code_interpreter.factory as factory_mod + + conf = SandboxConf(endpoints=["http://pool:8080"], token="pool-token") + monkeypatch.setattr(factory_mod, "load_sandbox_conf", lambda: conf) + tool = _get_native_code_interpreter( + minio={}, + type="container", + config={ + "container": { + "timeout": 120, + "endpoints": ["http://from-extra:1"], + "token": "from-extra", + } + }, + ) + assert isinstance(tool.executor, ContainerExecutor) + assert tool.executor.timeout == 120 + assert tool.executor.endpoints == ["http://pool:8080"] + assert tool.executor.token == "pool-token" + + +def test_switching_type_does_not_need_process_restart(): + first = _get_native_code_interpreter(minio={}, type="local") + second = _get_native_code_interpreter(minio={}, type="container") + assert isinstance(first.executor, LocalExecutor) + assert isinstance(second.executor, ContainerExecutor) + + +def test_five_call_sites_go_through_tool_executor_not_local_executor(): + for path in _FIVE_PATHS: + source = path.read_text(encoding="utf-8") + tree = ast.parse(source) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + func = node.func + name = func.id if isinstance(func, ast.Name) else getattr(func, "attr", None) + assert name != "LocalExecutor", f"{path} constructs LocalExecutor directly" + assert "init_by_tool_id" in source, f"{path} must go through ToolExecutor.init_by_tool_id(s)" diff --git a/src/backend/test/sandbox/test_local_isolation.py b/src/backend/test/sandbox/test_local_isolation.py new file mode 100644 index 0000000000..cab14a3eb8 --- /dev/null +++ b/src/backend/test/sandbox/test_local_isolation.py @@ -0,0 +1,426 @@ +"""LocalExecutor uid isolation: the parts that do not need root. + +Copy-in / copy-back, path rewriting, output bounding, the environment allowlist, +the uid pool's marker logic and the fail-closed paths. The end-to-end runs as a +pool uid live in ``test_local_isolation_root.py``. +""" + +from __future__ import annotations + +import os +import threading + +import pytest + +from bisheng.core.config.settings import SandboxConf +from bisheng_langchain.gpts.tools.code_interpreter import local_isolation, local_uid_pool, workspace_copy +from bisheng_langchain.gpts.tools.code_interpreter.local_executor import ( + COPY_IN_SKIPPED_NOTICE, + DISK_LIMIT_MSG, + ISOLATION_UNAVAILABLE_MSG, + LocalExecutor, +) +from bisheng_langchain.gpts.tools.code_interpreter.local_isolation import ( + IsolatedOutcome, + LocalIsolationUnavailable, +) + +IS_LINUX_ROOT = os.name == "posix" and os.uname().sysname == "Linux" and os.geteuid() == 0 +HAS_PROC = os.path.exists(f"/proc/{os.getpid()}/stat") + + +@pytest.fixture +def conf(monkeypatch: pytest.MonkeyPatch, tmp_path) -> SandboxConf: + value = SandboxConf(local_isolation="uid", local_run_root=str(tmp_path / "runs")) + monkeypatch.setattr(local_isolation, "_conf", lambda: value) + monkeypatch.setattr(local_isolation, "_prlimit_path", None) + return value + + +# --- configuration --------------------------------------------------------- + + +def test_local_isolation_defaults_to_uid(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("BS_SANDBOX_CONF__LOCAL_ISOLATION", raising=False) + assert SandboxConf().local_isolation == "uid" + + +@pytest.mark.parametrize( + ("raw", "expected"), + [("off", "off"), ("OFF ", "off"), ("", "off"), ("uid", "uid"), ("UID", "uid"), ("of", "uid"), ("none", "uid")], +) +def test_only_an_explicit_off_disables_isolation(raw: str, expected: str) -> None: + assert SandboxConf(local_isolation=raw).local_isolation == expected + + +def test_unreadable_conf_counts_as_isolated(monkeypatch: pytest.MonkeyPatch) -> None: + def boom(): + raise RuntimeError("settings not loaded") + + monkeypatch.setattr(local_isolation, "_conf", boom) + assert local_isolation.isolation_mode() == "uid" + + +# --- path rewriting -------------------------------------------------------- + + +def test_rewrite_paths_respects_component_boundaries() -> None: + src, dst = "/root/.cache/bisheng/linsight/ab12cd34", "/tmp/run-x" + text = f'open("{src}/a.txt"); os.chdir("{src}"); print("{src}ef/b"); x = {src}\n' + out = local_isolation.rewrite_paths(text, src, dst) + assert f'"{dst}/a.txt"' in out + assert f'"{dst}"' in out + assert f"{src}ef/b" in out + assert out.endswith(f"x = {dst}\n") + + +def test_rewrite_paths_round_trip() -> None: + src, dst = "/w/task", "/r/run-1" + text = "File /w/task/output/a.png written" + assert local_isolation.rewrite_paths(local_isolation.rewrite_paths(text, src, dst), dst, src) == text + + +# --- output bounding ------------------------------------------------------- + + +def test_head_tail_keeps_both_ends() -> None: + buf = local_isolation._HeadTail(head=4, tail=4) + for chunk in (b"ab", b"cdEFGH", b"ijkl"): + buf.feed(chunk) + text = buf.text() + assert text.startswith("abcd") + assert text.endswith("ijkl") + assert "4 bytes of output omitted" in text + + +def test_head_tail_untouched_when_small() -> None: + buf = local_isolation._HeadTail(head=8, tail=8) + buf.feed(b"hello") + assert buf.text() == "hello" + + +def test_default_window_keeps_a_4mib_result_line() -> None: + # A code-node result is one stdout line: a short prefix plus up to 4 MiB of JSON. + # Head is 4 MiB and tail is 512 KiB, so that line is reconstructed with no gap. + line = b"n" * 32 + b":ok:" + b"x" * (4 * 1024 * 1024) + b"\n" + buf = local_isolation._HeadTail() + view = memoryview(line) + for start in range(0, len(line), 65536): + buf.feed(bytes(view[start : start + 65536])) + assert "output omitted" not in buf.text() + assert buf.text().encode() == line + + +# --- environment ----------------------------------------------------------- + + +def test_child_env_is_an_allowlist(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: + monkeypatch.setenv("BS_MYSQL_PASSWORD", "x") + monkeypatch.setenv("PYTHONPATH", "/app") + monkeypatch.setenv("LD_PRELOAD", "/evil.so") + monkeypatch.setenv("TZ", "Asia/Shanghai") + env = local_isolation.child_env(str(tmp_path)) + assert set(env) == { + "PATH", + "LANG", + "LC_ALL", + "HOME", + "TMPDIR", + "TEMP", + "TMP", + "MPLCONFIGDIR", + "USER", + "LOGNAME", + "TZ", + } + assert env["HOME"] == str(tmp_path) + assert env["TMPDIR"] == os.path.join(str(tmp_path), ".tmp") + assert env["MPLCONFIGDIR"] == os.path.join(str(tmp_path), ".mpl") + assert env["USER"] == "sandbox" + + +# --- copy in --------------------------------------------------------------- + + +def _write(path, data: bytes = b"x") -> None: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "wb") as fh: + fh.write(data) + + +def test_copy_in_takes_regular_files_only(tmp_path) -> None: + src, dst = tmp_path / "w", tmp_path / "r" + dst.mkdir() + _write(str(src / "uploads" / "a.csv"), b"1,2") + _write(str(src / "run.sh"), b"#!/bin/sh") + os.chmod(src / "run.sh", 0o755) + _write(str(src / ".hidden"), b"h") + _write(str(src / "__pycache__" / "m.pyc"), b"c") + os.symlink("/etc/passwd", src / "link") + os.symlink(str(src / "uploads"), src / "dirlink") + os.mkfifo(src / "fifo") + + result = workspace_copy.copy_in(str(src), str(dst), None, 1024, 1024) + + assert set(result.index) == {os.path.join("uploads", "a.csv"), "run.sh"} + assert (dst / "uploads" / "a.csv").read_bytes() == b"1,2" + assert os.stat(dst / "run.sh").st_mode & 0o777 == 0o755 + assert os.stat(dst / "uploads" / "a.csv").st_mode & 0o777 == 0o644 + for name in (".hidden", "__pycache__", "link", "dirlink", "fifo"): + assert not os.path.lexists(dst / name) + st = os.stat(src / "run.sh") + assert result.index["run.sh"] == (st.st_size, st.st_mtime_ns) + assert os.stat(dst / "run.sh").st_mtime_ns == st.st_mtime_ns + + +def test_copy_in_reports_oversized_and_over_total(tmp_path) -> None: + src, dst = tmp_path / "w", tmp_path / "r" + dst.mkdir() + _write(str(src / "a.bin"), b"a" * 6) + _write(str(src / "b.bin"), b"b" * 20) + _write(str(src / "c.bin"), b"c" * 6) + + result = workspace_copy.copy_in(str(src), str(dst), None, file_limit=10, total_limit=10) + + assert list(result.index) == ["a.bin"] + assert result.skipped == ["b.bin", "c.bin"] + assert result.copied_bytes == 6 + + +# --- copy back ------------------------------------------------------------- + + +def _seeded(tmp_path): + w, r = tmp_path / "w", tmp_path / "r" + w.mkdir() + r.mkdir() + _write(str(w / "uploads" / "in.txt"), b"source") + index = workspace_copy.copy_in(str(w), str(r), None, 1 << 20, 1 << 20).index + return w, r, index + + +def test_copy_out_harvests_new_and_changed_files(tmp_path) -> None: + w, r, index = _seeded(tmp_path) + _write(str(r / "output" / "report.md"), b"# done") + _write(str(r / "uploads" / "in.txt"), b"changed!") + os.utime(r / "output" / "report.md", ns=(1_000_000_000, 1_000_000_000)) + + written = workspace_copy.copy_out(str(r), str(w), index, set(), 1 << 20, 1 << 20) + + assert sorted(written) == [os.path.join("output", "report.md"), os.path.join("uploads", "in.txt")] + assert (w / "output" / "report.md").read_bytes() == b"# done" + assert (w / "uploads" / "in.txt").read_bytes() == b"changed!" + assert os.stat(w / "output" / "report.md").st_mtime_ns == 1_000_000_000 + assert not [n for n in os.listdir(w / "output") if n.endswith(".tmp")] + + +def test_copy_out_skips_unchanged_files(tmp_path) -> None: + w, r, index = _seeded(tmp_path) + assert workspace_copy.copy_out(str(r), str(w), index, set(), 1 << 20, 1 << 20) == [] + + +def test_copy_out_never_harvests_planted_entries(tmp_path) -> None: + w, r, index = _seeded(tmp_path) + os.symlink("/etc/passwd", r / "passwd") + os.mkdir(r / "output") + os.symlink("/etc", r / "output" / "etc") + os.mkfifo(r / "pipe") + _write(str(r / "orig.txt"), b"o") + os.link(r / "orig.txt", r / "hard.txt") + _write(str(r / "tmp_code_abc.py"), b"print(1)") + _write(str(r / ".secret"), b"s") + _write(str(r / "big.bin"), b"z" * 100) + + written = workspace_copy.copy_out(str(r), str(w), index, {"tmp_code_abc.py"}, 50, 1 << 20) + + assert written == [] + for name in ("passwd", "pipe", "orig.txt", "hard.txt", "tmp_code_abc.py", ".secret", "big.bin"): + assert not os.path.lexists(w / name) + assert not os.path.lexists(w / "output" / "etc") + + +def test_copy_out_refuses_a_symlinked_dir_in_the_workspace(tmp_path) -> None: + w, r, index = _seeded(tmp_path) + outside = tmp_path / "outside" + outside.mkdir() + os.symlink(outside, w / "output") + _write(str(r / "output" / "a.txt"), b"a") + + assert workspace_copy.copy_out(str(r), str(w), index, set(), 1 << 20, 1 << 20) == [] + assert os.listdir(outside) == [] + + +def test_copy_out_stops_at_the_total_budget(tmp_path) -> None: + w, r, index = _seeded(tmp_path) + _write(str(r / "a.bin"), b"a" * 30) + _write(str(r / "b.bin"), b"b" * 30) + + written = workspace_copy.copy_out(str(r), str(w), index, set(), 1 << 20, 40) + + assert written == ["a.bin"] + + +# --- uid pool markers ------------------------------------------------------ + + +def test_concurrent_acquire_hands_out_distinct_uids(tmp_path) -> None: + markers, runs = str(tmp_path / "m"), str(tmp_path / "runs") + got: list[int | None] = [] + lock = threading.Lock() + + def claim() -> None: + uid = local_uid_pool.acquire(runs, marker_dir=markers) + with lock: + got.append(uid) + + threads = [threading.Thread(target=claim) for _ in range(8)] + for t in threads: + t.start() + for t in threads: + t.join() + assert None not in got + assert len(set(got)) == 8 + if HAS_PROC: + assert local_uid_pool.held_uids(markers) == set(got) + + +@pytest.mark.skipif(not HAS_PROC, reason="without /proc every marker looks stale") +def test_stale_marker_is_reclaimed_live_one_is_not(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: + markers, runs = tmp_path / "m", str(tmp_path / "runs") + markers.mkdir() + reaped: list[int] = [] + monkeypatch.setattr(local_uid_pool, "reap_uid", lambda uid: reaped.append(uid) or True) + live = f"{os.getpid()} {local_uid_pool.proc_starttime(os.getpid()) or 0}" + (markers / str(local_uid_pool.UID_MIN)).write_text(live) + (markers / str(local_uid_pool.UID_MIN + 1)).write_text("999999999 1") + + uid = local_uid_pool.acquire(runs, marker_dir=str(markers)) + + assert uid == local_uid_pool.UID_MIN + 1 + assert reaped == [local_uid_pool.UID_MIN + 1] + + +def test_free_removes_marker(tmp_path) -> None: + markers, runs = str(tmp_path / "m"), str(tmp_path / "runs") + uid = local_uid_pool.acquire(runs, marker_dir=markers) + local_uid_pool.free(uid, runs, marker_dir=markers) + assert not os.path.exists(os.path.join(markers, str(uid))) + + +def test_reap_refuses_uids_outside_the_pool() -> None: + with pytest.raises(ValueError): + local_uid_pool.reap_uid(0) + + +# --- probe evaluation and permission tightening ---------------------------- + + +def _good_probe(uid: int) -> dict: + return { + "uid": uid, + "euid": uid, + "gid": uid, + "groups": [], + "environ": True, + "root_home": True, + "config": True, + "pkg": True, + "data": True, + "imports": None, + "write": None, + } + + +def test_probe_accepts_expected_denials() -> None: + local_isolation.evaluate_probe(_good_probe(30001), 30001) + + +@pytest.mark.parametrize( + ("key", "value", "fragment"), + [ + ("config", False, "config.yaml"), + ("pkg", False, "backend package dir"), + ("environ", False, "/proc/1/environ"), + ("groups", [0], "supplementary groups"), + ("uid", 0, "child identity"), + ("imports", "ImportError('numpy')", "numpy"), + ("write", "PermissionError()", "cannot write"), + ], +) +def test_probe_rejects_leaks(key: str, value, fragment: str) -> None: + probe = _good_probe(30001) + probe[key] = value + with pytest.raises(LocalIsolationUnavailable, match=fragment): + local_isolation.evaluate_probe(probe, 30001) + + +def test_probe_only_warns_on_readable_data_dir() -> None: + probe = _good_probe(30001) + probe["data"] = False + local_isolation.evaluate_probe(probe, 30001) + + +def test_tighten_permissions_closes_both_dirs(tmp_path) -> None: + pkg, data = tmp_path / "bisheng", tmp_path / "data" + pkg.mkdir(mode=0o755) + data.mkdir(mode=0o755) + (pkg / "config.yaml").write_text("x: 1") + + local_isolation.tighten_permissions(str(pkg), str(data)) + + assert os.stat(pkg).st_mode & 0o777 == 0o700 + assert os.stat(data).st_mode & 0o777 == 0o700 + + +# --- fail closed ----------------------------------------------------------- + + +@pytest.mark.skipif(IS_LINUX_ROOT, reason="preflight can succeed here") +def test_preflight_fails_closed_without_root(conf: SandboxConf) -> None: + with pytest.raises(LocalIsolationUnavailable): + local_isolation.preflight() + + +@pytest.mark.skipif(IS_LINUX_ROOT, reason="preflight can succeed here") +def test_run_reports_unavailable_and_executes_nothing(conf: SandboxConf, tmp_path) -> None: + marker = tmp_path / "ran" + executor = LocalExecutor(minio={}, local_sync_path=str(tmp_path)) + result = executor.run(f"open({str(marker)!r}, 'w').write('x')") + assert result == {"exitcode": 1, "log": ISOLATION_UNAVAILABLE_MSG, "file_list": []} + assert not marker.exists() + + +def test_isolated_execute_needs_a_work_dir(conf: SandboxConf) -> None: + with pytest.raises(AssertionError, match="work_dir"): + LocalExecutor.execute_code(code="print(1)", work_dir=None) + + +def test_font_cache_is_only_dropped_when_off(monkeypatch: pytest.MonkeyPatch, conf: SandboxConf) -> None: + drops: list[int] = [] + monkeypatch.setattr(LocalExecutor, "_drop_font_cache", staticmethod(lambda: drops.append(1))) + LocalExecutor.insert_set_font_code("import matplotlib\n") + assert drops == [] + conf.local_isolation = "off" + LocalExecutor.insert_set_font_code("import matplotlib\n") + assert drops == [1] + + +# --- result formatting ----------------------------------------------------- + + +def test_format_reports_disk_limit_and_skipped_files(tmp_path) -> None: + outcome = IsolatedOutcome(returncode=1, stdout="", stderr="", disk_exceeded=True, skipped=["big.bin"]) + code, logs, _ = LocalExecutor._format_isolated(outcome, 10, "tmp_code_x.py", str(tmp_path)) + assert code == 1 + assert logs.startswith(DISK_LIMIT_MSG) + assert COPY_IN_SKIPPED_NOTICE in logs and "big.bin" in logs + + +def test_format_strips_the_script_path_from_a_traceback(tmp_path) -> None: + script = os.path.join(str(tmp_path), "tmp_code_x.py") + stderr = f'File "{script}", line 1\nValueError: boom\n' + outcome = IsolatedOutcome(returncode=1, stdout="", stderr=stderr) + code, logs, _ = LocalExecutor._format_isolated(outcome, 10, "tmp_code_x.py", str(tmp_path)) + assert code == 1 + assert str(tmp_path) not in logs + assert "ValueError: boom" in logs diff --git a/src/backend/test/sandbox/test_local_isolation_root.py b/src/backend/test/sandbox/test_local_isolation_root.py new file mode 100644 index 0000000000..7e45e52df8 --- /dev/null +++ b/src/backend/test/sandbox/test_local_isolation_root.py @@ -0,0 +1,277 @@ +"""LocalExecutor uid isolation end to end. Linux + root only. + +Every test runs real scripts as a pool uid, so they are skipped on developer +machines and in non-root CI; run them inside the backend image. +""" + +from __future__ import annotations + +import json +import os +import shutil +import tempfile +import threading +import time + +import pytest + +from bisheng.core.config.settings import SandboxConf +from bisheng_langchain.gpts.tools.code_interpreter import local_executor as local_executor_module +from bisheng_langchain.gpts.tools.code_interpreter import local_isolation, local_run_registry, local_uid_pool +from bisheng_langchain.gpts.tools.code_interpreter.local_executor import TASK_ENDED_MSG, LocalExecutor + +pytestmark = pytest.mark.skipif( + not (os.uname().sysname == "Linux" and os.geteuid() == 0), reason="needs Linux and root" +) + + +@pytest.fixture +def conf(monkeypatch: pytest.MonkeyPatch): + # Directly under /tmp: pytest's own tmp dirs are 0700 and the pool uid has + # to traverse every ancestor of its run dir. + run_root = tempfile.mkdtemp(prefix="bisheng-local-run-test-", dir="/tmp") + value = SandboxConf(local_isolation="uid", local_run_root=run_root) + monkeypatch.setattr(local_isolation, "_conf", lambda: value) + yield value + shutil.rmtree(run_root, ignore_errors=True) + + +@pytest.fixture +def workspace(tmp_path): + w = tmp_path / "w" + w.mkdir(mode=0o700) + (w / "uploads").mkdir() + (w / "uploads" / "in.txt").write_text("source data") + return w + + +def _run(code: str, work_dir, timeout: int = 30): + return LocalExecutor.execute_code(code=code, work_dir=str(work_dir), timeout=timeout) + + +def test_child_runs_as_a_pool_uid_with_only_allowlisted_env(conf, workspace, monkeypatch) -> None: + monkeypatch.setenv("BS_MYSQL_PASSWORD", "leak-me") + code = ( + "import os, json\n" + "print(json.dumps({'uid': os.getuid(), 'groups': os.getgroups(), 'env': sorted(os.environ)}))\n" + ) + exitcode, logs, _ = _run(code, workspace) + assert exitcode == 0, logs + + info = json.loads(logs.strip().splitlines()[-1]) + assert local_uid_pool.UID_MIN <= info["uid"] <= local_uid_pool.UID_MAX + assert set(info["groups"]) <= {info["uid"]} + assert "BS_MYSQL_PASSWORD" not in info["env"] + + +def test_secrets_and_other_workspaces_are_unreachable(conf, workspace) -> None: + pkg = local_isolation._backend_package_dir() + code = ( + "def denied(fn):\n" + " try:\n" + " fn()\n" + " except PermissionError:\n" + " return True\n" + " return False\n" + "print(denied(lambda: open('/proc/1/environ','rb').read(1)), denied(lambda: __import__('os').listdir('/root'))," + f" denied(lambda: open({os.path.join(pkg, 'config.yaml')!r}).read(1)))\n" + ) + exitcode, logs, _ = _run(code, workspace) + assert exitcode == 0, logs + assert logs.strip().splitlines()[-1] == "True True True" + + +def test_results_are_harvested_and_inputs_visible(conf, workspace) -> None: + code = ( + "import os\n" + "os.makedirs('output', exist_ok=True)\n" + "data = open('uploads/in.txt').read()\n" + "open('output/out.txt', 'w').write(data.upper())\n" + f"print(os.path.exists({str(workspace / 'uploads' / 'in.txt')!r}))\n" + "print(os.getcwd())\n" + ) + exitcode, logs, _ = _run(code, workspace) + assert exitcode == 0, logs + assert (workspace / "output" / "out.txt").read_text() == "SOURCE DATA" + lines = logs.strip().splitlines() + # absolute W paths in the code and the run dir in the output are both rewritten + assert lines[-2] == "True" + assert lines[-1] == str(workspace) + assert os.stat(workspace / "output" / "out.txt").st_uid == 0 + + +def test_planted_links_are_not_harvested(conf, workspace) -> None: + code = ( + "import os\n" + "os.symlink('/etc/passwd', 'passwd')\n" + "open('a.txt', 'w').write('a')\n" + "os.link('a.txt', 'hard.txt')\n" + "os.mkfifo('pipe')\n" + ) + exitcode, logs, _ = _run(code, workspace) + assert exitcode == 0, logs + for name in ("passwd", "hard.txt", "pipe", "a.txt"): + assert not os.path.lexists(workspace / name) + + +def test_workspace_is_not_modified_in_place(conf, workspace) -> None: + code = "import os\nos.remove('uploads/in.txt')\n" + exitcode, logs, _ = _run(code, workspace) + assert exitcode == 0, logs + assert (workspace / "uploads" / "in.txt").read_text() == "source data" + + +def test_timeout_and_background_processes_leave_nothing_behind(conf, workspace) -> None: + code = ( + "import subprocess, os, time\n" + "subprocess.Popen(['sleep', '300'], start_new_session=True)\n" + "print(os.getuid(), flush=True)\n" + "time.sleep(300)\n" + ) + started = time.monotonic() + exitcode, logs, _ = _run(code, workspace, timeout=2) + assert exitcode == 1 + assert logs.startswith("Timeout") + assert time.monotonic() - started < 30 + uid = int(logs.split("Output captured before the kill:\n")[1].split()[0]) + assert not local_uid_pool.uid_has_live_process(uid) + assert not os.path.exists(os.path.join(local_uid_pool.MARKER_DIR, str(uid))) + assert os.listdir(conf.local_run_root) == [] + + +def test_background_process_of_a_finished_run_is_reaped(conf, workspace) -> None: + code = "import subprocess, os\nsubprocess.Popen(['sleep', '300'], start_new_session=True)\nprint(os.getuid())\n" + exitcode, logs, _ = _run(code, workspace) + assert exitcode == 0, logs + uid = int(logs.strip().splitlines()[-1]) + assert not local_uid_pool.uid_has_live_process(uid) + + +def test_freeing_a_uid_wipes_its_run_dirs(conf, workspace) -> None: + uid = local_uid_pool.acquire(conf.local_run_root) + stale = os.path.join(conf.local_run_root, "run-stale") + os.mkdir(stale) + with open(os.path.join(stale, "secret.txt"), "w") as fh: + fh.write("previous run") + os.chown(stale, uid, uid) + local_uid_pool.free(uid, conf.local_run_root) + assert not os.path.exists(stale) + + +def test_matplotlib_chart_with_cjk_text(conf, workspace) -> None: + code = ( + "import matplotlib\nmatplotlib.use('Agg')\nimport matplotlib.pyplot as plt\n" + "import os\nos.makedirs('output', exist_ok=True)\n" + "plt.title('中文标题')\nplt.plot([1, 2])\nplt.savefig('output/c.png')\n" + ) + exitcode, logs, _ = _run(code, workspace, timeout=120) + assert exitcode == 0, logs + assert (workspace / "output" / "c.png").exists() + + +# --- retained mode (Linsight) ------------------------------------------------ + + +@pytest.fixture +def retained(conf, workspace, monkeypatch): + monkeypatch.setattr(local_executor_module, "isolation_mode", lambda: "uid") + executor = LocalExecutor(minio={}, local_sync_path=str(workspace), keep_run_dir=True) + # file_list upload goes to MinIO; not what these tests look at + monkeypatch.setattr(executor, "upload_minio", lambda name, path: name) + yield executor + local_run_registry.release_local_run(str(workspace)) + + +def _last_line(result: dict) -> str: + assert result["exitcode"] == 0, result["log"] + return result["log"].strip().splitlines()[-1] + + +def test_retained_run_keeps_uid_and_run_dir_across_calls(retained, workspace) -> None: + first = _last_line(retained.run("import os\nprint(os.getuid(), os.path.realpath('.'))")) + entry = retained._run_entry + run_dir = entry.run_dir + retained.run("import os\nos.makedirs('scratch', exist_ok=True)\nopen('scratch/s.txt','w').write('step1')") + second = _last_line(retained.run("import os\nprint(os.getuid(), open('scratch/s.txt').read())")) + + assert first.split()[0] == second.split()[0] == str(entry.uid) + # the run dir is reported as W and stays the same + assert first.split()[1] == str(workspace) + assert entry.run_dir == run_dir + assert second.split()[1] == "step1" + assert (workspace / "scratch" / "s.txt").read_text() == "step1" + + +def test_retained_run_picks_up_changes_made_to_w_between_calls(retained, workspace) -> None: + retained.run("print(open('uploads/in.txt').read())") + time.sleep(0.01) + (workspace / "uploads" / "in.txt").write_text("edited by a file tool") + (workspace / "new.txt").write_text("new") + out = retained.run("print(open('uploads/in.txt').read(), open('new.txt').read())") + assert _last_line(out) == "edited by a file tool new" + + +def test_links_planted_in_the_run_dir_are_not_written_through(retained, workspace) -> None: + victim = tempfile.NamedTemporaryFile(dir="/tmp", delete=False) + victim.write(b"root file") + victim.close() + os.chmod(victim.name, 0o644) + # Hex-encoded so the textual escape guard does not reject the script before + # it runs: this test is about the boundary behind the guard. + file_hex, dir_hex = victim.name.encode().hex(), os.path.dirname(victim.name).encode().hex() + try: + planted = ( + "import os, shutil\n" + f"os.remove('uploads/in.txt'); os.symlink(bytes.fromhex({file_hex!r}).decode(), 'uploads/in.txt')\n" + "shutil.rmtree('.mpl', ignore_errors=True)\n" + f"os.symlink(bytes.fromhex({dir_hex!r}).decode(), '.mpl')\n" + ) + retained.run(planted) + time.sleep(0.01) + (workspace / "uploads" / "in.txt").write_text("fresh") + out = retained.run("import matplotlib\nprint(open('uploads/in.txt').read())") + assert _last_line(out) == "fresh" + with open(victim.name, "rb") as fh: + assert fh.read() == b"root file" + assert not any(name.startswith("fontlist") for name in os.listdir(os.path.dirname(victim.name))) + finally: + os.unlink(victim.name) + + +def test_release_kills_leftovers_and_frees_everything(retained, workspace, conf) -> None: + retained.run("import subprocess\nsubprocess.Popen(['sleep', '300'], start_new_session=True)") + entry = retained._run_entry + uid, run_dir = entry.uid, entry.run_dir + local_run_registry.release_local_run(str(workspace)) + assert not local_uid_pool.uid_has_live_process(uid) + assert not os.path.exists(run_dir) + assert not os.path.exists(os.path.join(local_uid_pool.MARKER_DIR, str(uid))) + assert retained.run("print(1)")["log"] == TASK_ENDED_MSG + + +def test_release_during_a_run_stops_it_without_harvesting(retained, workspace) -> None: + retained.run("print('warm up')") + code = "import time\nopen('late.txt','w').write('x')\ntime.sleep(300)\n" + result = {} + worker = threading.Thread(target=lambda: result.update(retained.run(code))) + started = time.monotonic() + worker.start() + time.sleep(2) + local_run_registry.release_local_run(str(workspace)) + worker.join(30) + assert not worker.is_alive() + assert time.monotonic() - started < 30 + assert result["log"] == TASK_ENDED_MSG + assert not (workspace / "late.txt").exists() + + +def test_startup_sweep_reclaims_a_dead_workers_leftovers(conf, monkeypatch) -> None: + monkeypatch.setattr(local_isolation, "isolation_mode", lambda: "uid") + uid = local_uid_pool.acquire(conf.local_run_root) + run_dir = local_isolation._make_run_dir(conf.local_run_root, uid) + marker = os.path.join(local_uid_pool.MARKER_DIR, str(uid)) + with open(marker, "w") as fh: + fh.write("999999999 1") # a pid that cannot exist + local_isolation.sweep_on_startup() + assert not os.path.exists(run_dir) + assert not os.path.exists(marker) diff --git a/src/backend/test/sandbox/test_local_retained_run.py b/src/backend/test/sandbox/test_local_retained_run.py new file mode 100644 index 0000000000..ea2f63bc37 --- /dev/null +++ b/src/backend/test/sandbox/test_local_retained_run.py @@ -0,0 +1,380 @@ +"""Retained run dirs of the isolated LocalExecutor: the parts that do not need root. + +Incremental copy-in, the per-workspace registry and its release, how calls on +one workspace are serialized, and the Linsight wiring. The end-to-end runs as +a pool uid live in ``test_local_isolation_root.py``. +""" + +from __future__ import annotations + +import asyncio +import os +import threading +import time +from types import SimpleNamespace + +import pytest + +from bisheng_langchain.gpts.tools.code_interpreter import local_executor as local_executor_module +from bisheng_langchain.gpts.tools.code_interpreter import local_run_registry, workspace_copy +from bisheng_langchain.gpts.tools.code_interpreter.local_executor import TASK_ENDED_MSG, LocalExecutor +from bisheng_langchain.gpts.tools.code_interpreter.local_isolation import IsolatedOutcome, RunClosed +from bisheng_langchain.gpts.tools.code_interpreter.tool import CodeInterpreterTool + +LIMIT = 1 << 30 + + +def _write(path, data: bytes = b"x") -> None: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "wb") as fh: + fh.write(data) + + +# --- incremental copy-in ----------------------------------------------------- + + +def _sync(w, r, index=None): + return workspace_copy.sync_in(str(w), str(r), None, index or {}, LIMIT, LIMIT) + + +def test_sync_in_copies_only_what_changed(tmp_path) -> None: + w, r = tmp_path / "w", tmp_path / "r" + r.mkdir() + _write(w / "a.txt", b"aaa") + _write(w / "sub" / "b.txt", b"bb") + + first = _sync(w, r) + assert first.copied_bytes == 5 + assert set(first.index) == {"a.txt", os.path.join("sub", "b.txt")} + + second = _sync(w, r, first.index) + assert second.copied_bytes == 0 + assert second.index == first.index + + time.sleep(0.01) + _write(w / "a.txt", b"changed") + third = _sync(w, r, second.index) + assert third.copied_bytes == len(b"changed") + assert (r / "a.txt").read_bytes() == b"changed" + + +def test_sync_in_restores_what_the_script_changed_in_r(tmp_path) -> None: + w, r = tmp_path / "w", tmp_path / "r" + r.mkdir() + _write(w / "a.txt", b"original") + _write(w / "b.txt", b"keep") + index = _sync(w, r).index + + (r / "a.txt").write_bytes(b"edited by the script") + os.remove(r / "b.txt") + result = _sync(w, r, index) + assert (r / "a.txt").read_bytes() == b"original" + assert (r / "b.txt").read_bytes() == b"keep" + assert result.copied_bytes == len(b"original") + len(b"keep") + + +def test_sync_in_replaces_planted_links_without_writing_through(tmp_path) -> None: + w, r, outside = tmp_path / "w", tmp_path / "r", tmp_path / "outside" + r.mkdir() + outside.mkdir() + _write(outside / "victim.txt", b"untouched") + _write(w / "a.txt", b"payload") + _write(w / "sub" / "b.txt", b"payload") + index = _sync(w, r).index + + os.remove(r / "a.txt") + os.symlink(outside / "victim.txt", r / "a.txt") + os.remove(r / "sub" / "b.txt") + os.rmdir(r / "sub") + os.symlink(outside, r / "sub") + _sync(w, r, index) + + assert (outside / "victim.txt").read_bytes() == b"untouched" + assert not os.path.exists(outside / "b.txt") + assert not os.path.islink(r / "a.txt") and (r / "a.txt").read_bytes() == b"payload" + assert not os.path.islink(r / "sub") and (r / "sub" / "b.txt").read_bytes() == b"payload" + + +def test_sync_in_drops_files_gone_from_w(tmp_path) -> None: + w, r = tmp_path / "w", tmp_path / "r" + r.mkdir() + _write(w / "a.txt") + _write(w / "sub" / "b.txt") + index = _sync(w, r).index + + os.remove(w / "sub" / "b.txt") + result = _sync(w, r, index) + assert not os.path.exists(r / "sub" / "b.txt") + assert set(result.index) == {"a.txt"} + + +def test_sync_in_does_not_delete_through_a_planted_dir_link(tmp_path) -> None: + w, r, outside = tmp_path / "w", tmp_path / "r", tmp_path / "outside" + r.mkdir() + _write(w / "sub" / "b.txt") + index = _sync(w, r).index + _write(outside / "b.txt", b"victim") + os.remove(r / "sub" / "b.txt") + os.rmdir(r / "sub") + os.symlink(outside, r / "sub") + + os.remove(w / "sub" / "b.txt") + os.rmdir(w / "sub") + _sync(w, r, index) + assert (outside / "b.txt").read_bytes() == b"victim" + + +def test_sync_in_skips_oversized_and_removes_their_stale_copy(tmp_path) -> None: + w, r = tmp_path / "w", tmp_path / "r" + r.mkdir() + _write(w / "big.bin", b"small") + index = _sync(w, r).index + time.sleep(0.01) + _write(w / "big.bin", b"x" * 64) + + result = workspace_copy.sync_in(str(w), str(r), None, index, 16, LIMIT) + assert result.skipped == ["big.bin"] + assert not os.path.exists(r / "big.bin") + assert "big.bin" not in result.index + + +def test_copy_out_records_what_it_wrote(tmp_path) -> None: + r, w = tmp_path / "r", tmp_path / "w" + w.mkdir() + _write(r / "output" / "x.txt", b"result") + record: dict = {} + written = workspace_copy.copy_out(str(r), str(w), {}, set(), LIMIT, LIMIT, record=record) + assert written == [os.path.join("output", "x.txt")] + st = os.stat(w / "output" / "x.txt") + assert record[os.path.join("output", "x.txt")] == (st.st_size, st.st_mtime_ns) + + +# --- registry ------------------------------------------------------------------ + + +@pytest.fixture +def registry(): + yield local_run_registry + local_run_registry._registry.clear() + + +def test_register_is_keyed_by_realpath(registry, tmp_path) -> None: + w = tmp_path / "w" + w.mkdir() + os.symlink(w, tmp_path / "alias") + entry = registry.register(str(w)) + assert registry.register(str(tmp_path / "alias")) is entry + assert entry.work_dir == os.path.realpath(w) + + +def test_release_without_registration_is_a_no_op(registry, tmp_path) -> None: + registry.release_local_run(None) + registry.release_local_run(str(tmp_path / "never-registered")) + + +def test_release_closes_and_unregisters(registry, tmp_path) -> None: + entry = registry.register(str(tmp_path)) + registry.release_local_run(str(tmp_path)) + assert entry.closing + assert registry.lookup(str(tmp_path)) is None + assert not entry.acquire_inner() + # a later round on the same W gets a fresh entry + assert registry.register(str(tmp_path)) is not entry + + +def test_release_frees_the_uid_of_an_idle_entry(registry, tmp_path, monkeypatch) -> None: + calls = [] + monkeypatch.setattr(registry.local_uid_pool, "reap_uid", lambda uid: calls.append(("reap", uid)) or True) + monkeypatch.setattr(registry.local_uid_pool, "free", lambda uid, root: calls.append(("free", uid, root))) + entry = registry.register(str(tmp_path)) + entry.uid, entry.run_root, entry.run_dir = 30001, "/runs", "/runs/run-x" + + registry.release_local_run(str(tmp_path)) + assert ("free", 30001, "/runs") in calls + assert entry.uid is None and entry.run_dir is None + + +def test_unconfirmed_reap_keeps_the_uid_quarantined(registry, tmp_path, monkeypatch) -> None: + freed = [] + monkeypatch.setattr(registry.local_uid_pool, "reap_uid", lambda uid: False) + monkeypatch.setattr(registry.local_uid_pool, "free", lambda uid, root: freed.append(uid)) + entry = registry.register(str(tmp_path)) + entry.uid, entry.run_root = 30002, "/runs" + registry.release_local_run(str(tmp_path)) + assert freed == [] + + +def test_release_waits_for_the_running_call(registry, tmp_path, monkeypatch) -> None: + monkeypatch.setattr(registry.local_uid_pool, "reap_uid", lambda uid: True) + entry = registry.register(str(tmp_path)) + assert entry.acquire_inner() + done = threading.Event() + threading.Thread(target=lambda: (registry.release_local_run(str(tmp_path)), done.set())).start() + assert not done.wait(0.3) + entry.inner.release() + assert done.wait(5) + + +# --- executor ------------------------------------------------------------------ + + +@pytest.fixture +def uid_mode(monkeypatch): + monkeypatch.setattr(local_executor_module, "isolation_mode", lambda: "uid") + + +def _executor(w, keep: bool = True) -> LocalExecutor: + return LocalExecutor(minio={}, local_sync_path=str(w), keep_run_dir=keep) + + +def test_no_entry_when_isolation_is_off(registry, tmp_path, monkeypatch) -> None: + monkeypatch.setattr(local_executor_module, "isolation_mode", lambda: "off") + assert _executor(tmp_path)._run_entry is None + assert registry.lookup(str(tmp_path)) is None + + +def test_no_entry_without_keep_run_dir(registry, uid_mode, tmp_path) -> None: + assert _executor(tmp_path, keep=False)._run_entry is None + + +def test_container_executor_never_gets_an_entry(registry, uid_mode, tmp_path) -> None: + from bisheng_langchain.gpts.tools.code_interpreter.container_executor import ContainerExecutor + + assert ContainerExecutor.supports_retained_run is False + + +def test_workspace_calls_go_to_the_retained_run(registry, uid_mode, tmp_path, monkeypatch) -> None: + seen = {} + + def fake_run_retained(entry, **kwargs): + seen.update(kwargs, entry=entry) + return IsolatedOutcome(returncode=0, stdout="hello\n", stderr="") + + monkeypatch.setattr(local_executor_module, "run_retained", fake_run_retained) + executor = _executor(tmp_path) + result = executor.run("print('hello')") + assert result["exitcode"] == 0 and "hello" in result["log"] + assert seen["entry"] is executor._run_entry + assert seen["work_dir"] == str(tmp_path) + assert not executor._run_entry.inner.locked() + + +def test_run_after_release_reports_task_ended(registry, uid_mode, tmp_path, monkeypatch) -> None: + monkeypatch.setattr(local_executor_module, "run_retained", lambda *a, **k: pytest.fail("must not run")) + executor = _executor(tmp_path) + registry.release_local_run(str(tmp_path)) + assert executor.run("print(1)") == {"exitcode": 1, "log": TASK_ENDED_MSG, "file_list": []} + assert asyncio.run(executor.arun("print(1)"))["log"] == TASK_ENDED_MSG + + +def test_run_closed_mid_run_reports_task_ended(registry, uid_mode, tmp_path, monkeypatch) -> None: + def closed(*a, **k): + raise RunClosed() + + monkeypatch.setattr(local_executor_module, "run_retained", closed) + executor = _executor(tmp_path) + assert executor.run("print(1)")["log"] == TASK_ENDED_MSG + assert not executor._run_entry.inner.locked() + + +def test_calls_on_one_workspace_run_one_at_a_time_in_order(registry, uid_mode, tmp_path, monkeypatch) -> None: + active, order, overlap = [0], [], [] + + def fake_blocks(self, code): + active[0] += 1 + if active[0] > 1: + overlap.append(code) + time.sleep(0.05) + order.append(code) + active[0] -= 1 + return {"exitcode": 0, "log": code, "file_list": []} + + monkeypatch.setattr(LocalExecutor, "_run_blocks", fake_blocks) + executor = _executor(tmp_path) + + async def main(): + return await asyncio.gather(*(executor.arun(f"c{i}") for i in range(5))) + + results = asyncio.run(main()) + assert [r["log"] for r in results] == [f"c{i}" for i in range(5)] + assert order == [f"c{i}" for i in range(5)] + assert overlap == [] + + +def test_queued_calls_return_when_the_task_is_released(registry, uid_mode, tmp_path, monkeypatch) -> None: + started, finish = threading.Event(), threading.Event() + + def fake_blocks(self, code): + started.set() + finish.wait(5) + return {"exitcode": 0, "log": code, "file_list": []} + + monkeypatch.setattr(LocalExecutor, "_run_blocks", fake_blocks) + executor = _executor(tmp_path) + + async def main(): + first = asyncio.ensure_future(executor.arun("first")) + second = asyncio.ensure_future(executor.arun("second")) + await asyncio.to_thread(started.wait, 5) + release = asyncio.ensure_future(asyncio.to_thread(registry.release_local_run, str(tmp_path))) + await asyncio.sleep(0.2) + finish.set() + await release + return await first, await second + + first, second = asyncio.run(main()) + assert first["log"] == "first" + assert second["log"] == TASK_ENDED_MSG + + +# --- tool ---------------------------------------------------------------------- + + +def test_tool_arun_goes_through_the_executor() -> None: + class Exec: + async def arun(self, code): + return {"via": "arun", "code": code} + + def run(self, code): + return {"via": "run"} + + tool = CodeInterpreterTool(executor=Exec()) + assert asyncio.run(tool.ainvoke({"python_code": "x"})) == {"via": "arun", "code": "x"} + + +def test_tool_arun_falls_back_to_run_without_executor_arun() -> None: + tool = CodeInterpreterTool(executor=SimpleNamespace(run=lambda code: {"via": "run", "code": code})) + assert asyncio.run(tool.ainvoke({"python_code": "y"})) == {"via": "run", "code": "y"} + + +# --- Linsight wiring ------------------------------------------------------------- + + +def test_cleanup_releases_first_even_when_later_steps_fail(monkeypatch, tmp_path) -> None: + from bisheng.linsight.domain import task_exec + + released = [] + monkeypatch.setattr(task_exec, "release_local_run", released.append) + + async def broken_monitor(): + raise RuntimeError("monitor failed") + + gone = str(tmp_path / "already-deleted") + fake = SimpleNamespace(file_dir=gone, _stop_termination_monitor=broken_monitor) + asyncio.run(task_exec.LinsightWorkflowTask._cleanup_resources(fake)) + assert released == [gone] + + +def test_cleanup_skips_release_before_the_workspace_exists(monkeypatch) -> None: + from bisheng.linsight.domain import task_exec + + monkeypatch.setattr(task_exec, "release_local_run", lambda w: pytest.fail("must not release")) + + async def monitor(): + return None + + asyncio.run( + task_exec.LinsightWorkflowTask._cleanup_resources( + SimpleNamespace(file_dir=None, _stop_termination_monitor=monitor) + ) + ) diff --git a/src/backend/test/sandbox/test_local_work_subdirs.py b/src/backend/test/sandbox/test_local_work_subdirs.py new file mode 100644 index 0000000000..1ff7a1a555 --- /dev/null +++ b/src/backend/test/sandbox/test_local_work_subdirs.py @@ -0,0 +1,77 @@ +"""D12: local runs see output/ and scratch/ before the script starts, like container runs.""" + +from __future__ import annotations + +import os + +import pytest + +from bisheng_langchain.gpts.tools.code_interpreter import local_executor, local_isolation +from bisheng_langchain.gpts.tools.code_interpreter.base_executor import BaseExecutor +from bisheng_langchain.gpts.tools.code_interpreter.local_executor import LocalExecutor +from bisheng_langchain.gpts.tools.code_interpreter.local_isolation import ensure_work_subdirs + + +def test_creates_both_dirs_and_hands_them_to_the_uid(tmp_path) -> None: + calls: list[tuple[str, int, int]] = [] + ensure_work_subdirs(str(tmp_path), 10042, chown=lambda p, u, g: calls.append((p, u, g))) + assert (tmp_path / "output").is_dir() + assert (tmp_path / "scratch").is_dir() + assert sorted(calls) == [ + (str(tmp_path / "output"), 10042, 10042), + (str(tmp_path / "scratch"), 10042, 10042), + ] + + +def test_existing_entries_are_left_alone(tmp_path) -> None: + (tmp_path / "output").mkdir() + (tmp_path / "output" / "keep.txt").write_text("x") + (tmp_path / "scratch").write_text("the script's own file") + calls: list = [] + ensure_work_subdirs(str(tmp_path), 10001, chown=lambda *a: calls.append(a)) + assert (tmp_path / "output" / "keep.txt").read_text() == "x" + assert (tmp_path / "scratch").read_text() == "the script's own file" + assert calls == [] + + +def test_planted_symlink_is_not_followed(tmp_path) -> None: + outside = tmp_path / "outside" + outside.mkdir() + run_dir = tmp_path / "run" + run_dir.mkdir() + os.symlink(outside, run_dir / "output") + calls: list = [] + ensure_work_subdirs(str(run_dir), 10001, chown=lambda *a: calls.append(a)) + # The link is kept as is and never chowned through. + assert os.path.islink(run_dir / "output") + assert [c[0] for c in calls] == [str(run_dir / "scratch")] + + +def test_no_uid_skips_chown(tmp_path) -> None: + ensure_work_subdirs(str(tmp_path), None, chown=lambda *a: pytest.fail("chown must not run")) + assert (tmp_path / "output").is_dir() and (tmp_path / "scratch").is_dir() + + +def test_empty_subdirs_are_not_artifacts(tmp_path) -> None: + ensure_work_subdirs(str(tmp_path), None) + assert BaseExecutor._snapshot_files(str(tmp_path)) == {} + + +def test_off_mode_first_write_into_output_and_scratch_succeeds(monkeypatch, tmp_path) -> None: + monkeypatch.setattr(local_executor, "isolation_mode", lambda: "off") + code = "open('output/report.txt', 'w').write('r')\nopen('scratch/tmp.txt', 'w').write('t')\nprint('done')\n" + exitcode, logs, _ = LocalExecutor.execute_code(code=code, work_dir=str(tmp_path), lang="python") + assert exitcode == 0, logs + assert (tmp_path / "output" / "report.txt").read_text() == "r" + assert (tmp_path / "scratch" / "tmp.txt").read_text() == "t" + + +def test_isolated_paths_call_the_helper() -> None: + # Both uid-mode entry points (fresh and retained run dir) create the dirs + # after copy-in and before the script is written. + import inspect + + for fn in (local_isolation.run_isolated, local_isolation._run_retained_once): + src = inspect.getsource(fn) + assert "ensure_work_subdirs(run_dir, uid)" in src + assert src.index("ensure_work_subdirs(run_dir, uid)") < src.index("_write_script(") diff --git a/src/backend/test/sandbox/test_refresh_open_code_executors.py b/src/backend/test/sandbox/test_refresh_open_code_executors.py new file mode 100644 index 0000000000..a1411b66b7 --- /dev/null +++ b/src/backend/test/sandbox/test_refresh_open_code_executors.py @@ -0,0 +1,108 @@ +"""In-place refresh of an assistant's open container executor (F086).""" + +from __future__ import annotations + +import inspect + +from bisheng.api.services.assistant_agent import AssistantAgent +from bisheng.core.config.settings import SandboxConf +from bisheng.workstation.domain.services.chat_service import _prepare_tools +from bisheng_langchain.gpts.tools.code_interpreter.container_executor import ContainerExecutor +from bisheng_langchain.gpts.tools.code_interpreter.factory import refresh_open_code_executors +from bisheng_langchain.gpts.tools.code_interpreter.local_executor import LocalExecutor + + +class _Tool: + def __init__(self, name: str, executor): + self.name = name + self.executor = executor + + +def _container(**kwargs) -> ContainerExecutor: + return ContainerExecutor(minio={}, sandbox_conf=SandboxConf(endpoints=["http://old:8080"], token="old"), **kwargs) + + +def _patch(monkeypatch, conf: SandboxConf) -> None: + monkeypatch.setattr( + "bisheng_langchain.gpts.tools.code_interpreter.factory.load_sandbox_conf", + lambda: conf, + ) + + +def test_open_lease_is_released_then_updated_in_place(monkeypatch): + _patch(monkeypatch, SandboxConf(endpoints=["http://new:8080/"], token="new-token")) + executor = _container() + executor._bound_url = "http://old:8080" + executor._session_id = "sid" + executor._lease_token = "lease" + order = [] + + def release(): + order.append(list(executor.endpoints)) + executor._forget_lease() + + executor._release = release + tool = _Tool("bisheng_code_interpreter", executor) + identity = id(executor) + refresh_open_code_executors([tool]) + assert order == [["http://old:8080"]] + assert executor._bound_url is None + assert executor._session_id is None + assert executor._lease_token is None + assert executor.endpoints == ["http://new:8080"] + assert executor.token == "new-token" + assert id(tool.executor) == identity + + +def test_keep_session_is_left_alone(monkeypatch): + _patch(monkeypatch, SandboxConf(endpoints=["http://new:8080"], token="new-token")) + executor = _container(keep_session=True) + executor._bound_url = "http://old:8080" + executor._session_id = "sid" + + def release(): + raise AssertionError("sticky session must not be released") + + executor._release = release + refresh_open_code_executors([_Tool("bisheng_code_interpreter", executor)]) + assert executor.endpoints == ["http://old:8080"] + assert executor.token == "old" + assert executor._session_id == "sid" + + +def test_shared_executor_updates_both_copies(monkeypatch): + _patch(monkeypatch, SandboxConf(endpoints=["http://new:8080"], token="new-token")) + executor = _container() + first = _Tool("bisheng_code_interpreter", executor) + second = _Tool("bisheng_code_interpreter", executor) + refresh_open_code_executors([first, second]) + assert first.executor is second.executor + assert first.executor.endpoints == ["http://new:8080"] + assert second.executor.token == "new-token" + + +def test_local_and_other_names_are_skipped(monkeypatch): + _patch(monkeypatch, SandboxConf(endpoints=["http://new:8080"], token="new-token")) + local = LocalExecutor() + local.endpoints = ["http://local"] + other = _container() + refresh_open_code_executors( + [ + _Tool("bisheng_code_interpreter", local), + _Tool("other_tool", other), + ] + ) + assert local.endpoints == ["http://local"] + assert other.endpoints == ["http://old:8080"] + assert other.token == "old" + + +def test_run_refreshes_before_the_model(): + source = inspect.getsource(AssistantAgent.run) + refresh_at = source.index("refresh_open_code_executors(self.tools)") + model_at = min(index for index in (source.find("react_run"), source.find("ainvoke")) if index != -1) + assert refresh_at < model_at + + +def test_daily_chat_does_not_refresh(): + assert "refresh_open_code_executors" not in inspect.getsource(_prepare_tools) diff --git a/src/backend/test/sandbox/test_runner_exec.py b/src/backend/test/sandbox/test_runner_exec.py new file mode 100644 index 0000000000..984b1afa76 --- /dev/null +++ b/src/backend/test/sandbox/test_runner_exec.py @@ -0,0 +1,181 @@ +"""Exec timeout, env whitelist, OOM reclaim (F068 T011).""" + +from __future__ import annotations + +import os +import resource as resource_mod +import threading +import time + +import pytest +from app import create_app +from execute import _preexec +from leases import LeaseStore +from starlette.testclient import TestClient + + +def _auth() -> dict[str, str]: + return {"Authorization": "Bearer test-token"} + + +@pytest.fixture +def client(tmp_path): + app = create_app(token="test-token", sessions_root=str(tmp_path / "sessions")) + with TestClient(app) as http: + yield http + + +def _session(client: TestClient) -> tuple[str, dict[str, str]]: + body = client.post("/v1/sessions", headers=_auth()).json() + return body["session_id"], {"X-Lease-Token": body["lease_token"]} + + +def test_exec_returns_duration_and_streams(client: TestClient): + sid, headers = _session(client) + resp = client.post( + f"/v1/sessions/{sid}/exec", + headers=headers, + json={"code": "import sys; print('out'); print('err', file=sys.stderr)", "lang": "python", "timeout_s": 5}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["exitcode"] == 0 + assert "out" in body["stdout"] + assert "err" in body["stderr"] + assert body["duration_ms"] >= 0 + + +def test_exec_creates_output_and_scratch_dirs(client: TestClient): + sid, headers = _session(client) + resp = client.post( + f"/v1/sessions/{sid}/exec", + headers=headers, + json={ + "code": "import os; print(os.path.isdir('output'), os.path.isdir('scratch'))", + "lang": "python", + "timeout_s": 5, + }, + ) + assert resp.status_code == 200 + assert "True True" in resp.json()["stdout"] + + +def test_timeout_kills_process_group_and_skips_copy_out(client: TestClient): + sid, headers = _session(client) + resp = client.post( + f"/v1/sessions/{sid}/exec", + headers=headers, + json={ + "code": "open('half.txt','w').write('partial')\nimport time\ntime.sleep(30)\n", + "lang": "python", + "timeout_s": 0.2, + }, + ) + assert resp.status_code == 200 + assert resp.json()["exitcode"] == 124 + got = client.get(f"/v1/sessions/{sid}/files", headers=headers) + assert got.status_code == 200 + assert len(got.content) < 256 + + +def test_child_env_has_no_secrets(client: TestClient, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("MINIO_SECRET_KEY", "secret") + monkeypatch.setenv("MYSQL_PASSWORD", "secret") + monkeypatch.setenv("token", "global-token") + monkeypatch.setenv("lease_token", "lease") + sid, headers = _session(client) + resp = client.post( + f"/v1/sessions/{sid}/exec", + headers=headers, + json={ + "code": "import os, json; print(json.dumps(dict(os.environ)))", + "lang": "python", + "timeout_s": 5, + }, + ) + env = resp.json()["stdout"] + assert "MINIO" not in env + assert "MYSQL" not in env + assert "global-token" not in env + probe = client.post( + f"/v1/sessions/{sid}/exec", + headers=headers, + json={ + "code": "import os; print(os.environ['HOME']); print(os.environ['TMPDIR'])", + "lang": "python", + "timeout_s": 5, + }, + ) + home, tmpdir = probe.json()["stdout"].strip().splitlines() + # HOME lives beside the work dir (D13), TMPDIR stays in it. + assert sid in home and sid in tmpdir + assert home != tmpdir + assert not home.startswith(tmpdir.rstrip("/") + "/") + + +def test_exit_137_keeps_lease(client: TestClient): + """C2: an OOM / signal kill only ends that process; the session stays usable.""" + sid, headers = _session(client) + resp = client.post( + f"/v1/sessions/{sid}/exec", + headers=headers, + json={"code": "import os; os._exit(137)", "lang": "python", "timeout_s": 5}, + ) + assert resp.status_code == 200 + assert resp.json()["exitcode"] == 137 + assert resp.json()["killed_reason"] is None + again = client.post( + f"/v1/sessions/{sid}/exec", + headers=headers, + json={"code": "print(1)", "lang": "python", "timeout_s": 5}, + ) + assert again.status_code == 200 + assert again.json()["exitcode"] == 0 + + +def test_two_execs_on_same_session_are_serial(client: TestClient): + sid, headers = _session(client) + order: list[str] = [] + + def _run(tag: str, sleep_s: float) -> None: + resp = client.post( + f"/v1/sessions/{sid}/exec", + headers=headers, + json={ + "code": f"import time, pathlib; pathlib.Path('log.txt').write_text('{tag}'); time.sleep({sleep_s})", + "lang": "python", + "timeout_s": 5, + }, + ) + order.append(tag + ":" + str(resp.json()["exitcode"])) + + t1 = threading.Thread(target=_run, args=("a", 0.25)) + t2 = threading.Thread(target=_run, args=("b", 0.01)) + started = time.monotonic() + t1.start() + time.sleep(0.05) + t2.start() + t1.join() + t2.join() + assert time.monotonic() - started >= 0.25 + assert all(item.endswith(":0") for item in order) + + +def test_preexec_sets_rlimits(monkeypatch: pytest.MonkeyPatch): + captured: list[tuple[int, tuple[int, int]]] = [] + + def _capture(res, limits): + captured.append((res, limits)) + + monkeypatch.setattr(resource_mod, "setrlimit", _capture) + monkeypatch.setattr(os, "geteuid", lambda: 1) + _preexec() + kinds = {item[0] for item in captured} + assert resource_mod.RLIMIT_AS in kinds + assert resource_mod.RLIMIT_NPROC in kinds + assert resource_mod.RLIMIT_CPU in kinds + + +def test_max_sessions_without_uid_isolation_refused(tmp_path): + with pytest.raises(RuntimeError, match="enable_uid_isolation"): + LeaseStore(sessions_root=str(tmp_path), max_sessions=2, enable_uid_isolation=False) diff --git a/src/backend/test/sandbox/test_runner_files.py b/src/backend/test/sandbox/test_runner_files.py new file mode 100644 index 0000000000..fd6bd3f560 --- /dev/null +++ b/src/backend/test/sandbox/test_runner_files.py @@ -0,0 +1,225 @@ +"""Tar copy-in / copy-out protocol tests (F068 T009).""" + +from __future__ import annotations + +import hashlib +import io +import json +import os +import tarfile + +import pytest +from app import create_app +from starlette.testclient import TestClient + + +def _auth() -> dict[str, str]: + return {"Authorization": "Bearer test-token"} + + +def _md5(data: bytes) -> str: + return hashlib.md5(data).hexdigest() + + +def _open_tar(fileobj, mode: str): + return tarfile.open(fileobj=fileobj, mode=mode, encoding="utf-8", format=tarfile.PAX_FORMAT) + + +def _tar_gz(members: dict[str, bytes]) -> bytes: + buf = io.BytesIO() + with _open_tar(buf, "w:gz") as tar: + for name, data in members.items(): + info = tarfile.TarInfo(name=name) + info.size = len(data) + tar.addfile(info, io.BytesIO(data)) + return buf.getvalue() + + +def _framed(tar: bytes, manifest: dict | None = None) -> bytes: + """4-byte length + UTF-8 JSON manifest + tar.gz. Same layout the runner reads.""" + raw = json.dumps(manifest or {}, separators=(",", ":"), ensure_ascii=True).encode("utf-8") + return len(raw).to_bytes(4, "big") + raw + tar + + +def _untar(payload: bytes) -> dict[str, bytes]: + buf = io.BytesIO(payload) + out: dict[str, bytes] = {} + with _open_tar(buf, "r:gz") as tar: + for member in tar: + if member.isfile(): + fh = tar.extractfile(member) + out[member.name.replace("\\", "/")] = fh.read() if fh else b"" + return out + + +@pytest.fixture +def client(tmp_path): + app = create_app( + token="test-token", + sessions_root=str(tmp_path / "sessions"), + max_copy_in_bytes=1024, + ) + with TestClient(app) as http: + yield http, app + + +def _session(client: TestClient) -> tuple[str, dict[str, str]]: + body = client.post("/v1/sessions", headers=_auth()).json() + return body["session_id"], {"X-Lease-Token": body["lease_token"]} + + +def test_put_requires_lease_token(client): + http, _app = client + sid, headers = _session(http) + assert http.put(f"/v1/sessions/{sid}/files", content=_tar_gz({"a.txt": b"x"})).status_code in {401, 403, 404} + ok = http.put( + f"/v1/sessions/{sid}/files", + headers=headers, + content=_framed(_tar_gz({"a.txt": b"x"})), + ) + assert ok.status_code == 200 + + +def test_zip_slip_rejected_and_does_not_escape(client, tmp_path): + http, _app = client + sid, headers = _session(http) + outside = tmp_path / "escaped.txt" + payload = _framed(_tar_gz({"../escaped.txt": b"nope"})) + resp = http.put(f"/v1/sessions/{sid}/files", headers=headers, content=payload) + assert resp.status_code == 400 + assert not outside.exists() + # nothing written under the sessions root outside the uuid dir + for root, _dirs, files in os.walk(tmp_path / "sessions"): + assert "escaped.txt" not in files + + +def test_md5_hit_does_not_overwrite(client): + http, app = client + sid, headers = _session(http) + data = b"same-bytes" + digest = _md5(data) + body = _framed(_tar_gz({"keep.txt": data}), {"keep.txt": digest}) + assert http.put(f"/v1/sessions/{sid}/files", headers=headers, content=body).status_code == 200 + lease = app.state.store._leases[sid] + on_disk = os.path.join(lease.work_dir, "keep.txt") + with open(on_disk, "w", encoding="utf-8") as fh: + fh.write("mutated") + again = http.put(f"/v1/sessions/{sid}/files", headers=headers, content=body) + assert again.status_code == 200 + assert open(on_disk, encoding="utf-8").read() == "mutated" + + +def test_get_is_recursive_diff_against_pre_exec_snapshot(client): + http, _app = client + sid, headers = _session(http) + first = _framed(_tar_gz({"keep.txt": b"old"})) + http.put(f"/v1/sessions/{sid}/files", headers=headers, content=first) + exec_resp = http.post( + f"/v1/sessions/{sid}/exec", + headers=headers, + json={ + "code": "import pathlib\npathlib.Path('output/sub/a.txt').parent.mkdir(parents=True)\npathlib.Path('output/sub/a.txt').write_text('nested')\n", + "lang": "python", + "timeout_s": 5, + }, + ) + assert exec_resp.status_code == 200 + assert exec_resp.json()["exitcode"] == 0 + got = http.get(f"/v1/sessions/{sid}/files", headers=headers) + assert got.status_code == 200 + members = _untar(got.content) + assert "output/sub/a.txt" in members + assert members["output/sub/a.txt"] == b"nested" + assert "keep.txt" not in members + + +def test_oversized_member_skipped_and_named(client): + http, _app = client + sid, headers = _session(http) + big = b"x" * 2048 + small = b"ok" + resp = http.put( + f"/v1/sessions/{sid}/files", + headers=headers, + content=_framed(_tar_gz({"big.bin": big, "small.txt": small})), + ) + assert resp.status_code == 200 + body = resp.json() + assert any(item["path"] == "big.bin" for item in body["skipped"]) + assert "small.txt" in body["written"] + + +def test_chinese_workspace_paths_round_trip_and_print(client): + http, _app = client + sid, headers = _session(http) + rel = "uploads/工作台_工作流输入支持音视频.docx" + text = "中文内容".encode() + put = http.put( + f"/v1/sessions/{sid}/files", + headers=headers, + content=_framed(_tar_gz({rel: text}), {rel: _md5(text)}), + ) + assert put.status_code == 200 + assert rel in put.json()["written"] + code = ( + "from pathlib import Path\n" + "text = Path('uploads/工作台_工作流输入支持音视频.docx').read_text(encoding='utf-8')\n" + "Path('output').mkdir(exist_ok=True)\n" + "Path('output/报告.txt').write_text(text + '已处理', encoding='utf-8')\n" + "print(text)\n" + ) + exec_resp = http.post( + f"/v1/sessions/{sid}/exec", + headers=headers, + json={"code": code, "lang": "python", "timeout_s": 5}, + ) + assert exec_resp.status_code == 200 + body = exec_resp.json() + assert body["exitcode"] == 0 + assert "中文内容" in body["stdout"] + got = http.get(f"/v1/sessions/{sid}/files", headers=headers) + assert got.status_code == 200 + members = _untar(got.content) + assert "output/报告.txt" in members + assert members["output/报告.txt"] == "中文内容已处理".encode() + + +def test_manifest_larger_than_header_limit_is_in_the_body(client): + http, _app = client + sid, headers = _session(http) + data = b"only-this" + manifest = {f"dir/file-{i:04d}.txt": "0" * 32 for i in range(400)} + manifest["keep.txt"] = _md5(data) + raw = json.dumps(manifest, separators=(",", ":"), ensure_ascii=True).encode("utf-8") + assert len(raw) > 16384 + resp = http.put( + f"/v1/sessions/{sid}/files", + headers=headers, + content=_framed(_tar_gz({"keep.txt": data}), manifest), + ) + assert resp.status_code == 200 + assert resp.json()["written"] == ["keep.txt"] + + +def test_bare_tar_and_bad_length_are_rejected(client): + http, _app = client + sid, headers = _session(http) + bare = http.put( + f"/v1/sessions/{sid}/files", + headers=headers, + content=_tar_gz({"a.txt": b"x"}), + ) + assert bare.status_code == 400 + short = http.put( + f"/v1/sessions/{sid}/files", + headers=headers, + content=(100).to_bytes(4, "big") + b"{}", + ) + assert short.status_code == 400 + raw = b"[]" + listed = http.put( + f"/v1/sessions/{sid}/files", + headers=headers, + content=len(raw).to_bytes(4, "big") + raw, + ) + assert listed.status_code == 400 diff --git a/src/backend/test/sandbox/test_runner_hardening.py b/src/backend/test/sandbox/test_runner_hardening.py new file mode 100644 index 0000000000..007eedfe5c --- /dev/null +++ b/src/backend/test/sandbox/test_runner_hardening.py @@ -0,0 +1,489 @@ +"""Runner hardening from the F068 acceptance report (D1 D2 D3 D4 D6 D7 D9 D10 D13, C1-C6).""" + +from __future__ import annotations + +import io +import json +import os +import signal +import tarfile +import threading +import time + +import execute +import pytest +from app import create_app +from execute import _preexec, classify_exit, handle_exec, read_oom_kills +from files import ManifestError, read_copy_in_manifest +from leases import CapacityError, LeaseStore, UnknownLeaseError +from procutil import kill_group +from starlette.testclient import TestClient + + +def _auth() -> dict[str, str]: + return {"Authorization": "Bearer test-token"} + + +def _session(client: TestClient) -> tuple[str, dict[str, str]]: + body = client.post("/v1/sessions", headers=_auth()).json() + return body["session_id"], {"X-Lease-Token": body["lease_token"]} + + +def _exec(client: TestClient, sid: str, headers: dict, code: str, timeout_s: float = 5): + return client.post( + f"/v1/sessions/{sid}/exec", + headers=headers, + json={"code": code, "lang": "python", "timeout_s": timeout_s}, + ) + + +def _tar_gz(members: dict[str, bytes]) -> bytes: + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz", encoding="utf-8", format=tarfile.PAX_FORMAT) as tar: + for name, data in members.items(): + info = tarfile.TarInfo(name=name) + info.size = len(data) + tar.addfile(info, io.BytesIO(data)) + return buf.getvalue() + + +def _framed(members: dict[str, bytes], manifest: dict | None = None) -> bytes: + raw = json.dumps(manifest or {}).encode("utf-8") + return len(raw).to_bytes(4, "big") + raw + _tar_gz(members) + + +def _untar_names(payload: bytes) -> set[str]: + with tarfile.open(fileobj=io.BytesIO(payload), mode="r:gz") as tar: + return {m.name for m in tar if m.isfile()} + + +@pytest.fixture +def multi(tmp_path): + app = create_app( + token="test-token", + sessions_root=str(tmp_path / "sessions"), + max_sessions=4, + enable_uid_isolation=True, + ) + with TestClient(app) as http: + yield http, app + + +# ---- D1 concurrency ---------------------------------------------------------------- + + +def test_execs_on_different_sessions_run_concurrently_and_health_answers(multi): + http, _app = multi + sessions = [_session(http) for _ in range(2)] + results: list[int] = [] + + def _run(sid: str, headers: dict) -> None: + results.append(_exec(http, sid, headers, "import time; time.sleep(0.8)").json()["exitcode"]) + + threads = [threading.Thread(target=_run, args=s) for s in sessions] + started = time.monotonic() + for t in threads: + t.start() + time.sleep(0.2) + health_started = time.monotonic() + assert http.get("/health").status_code == 200 + health_s = time.monotonic() - health_started + # A new session can be created while both execs are running. + assert http.post("/v1/sessions", headers=_auth()).status_code == 200 + for t in threads: + t.join() + total = time.monotonic() - started + assert results == [0, 0] + assert health_s < 0.5 + assert total < 1.5, f"execs were serialized: {total:.2f}s" + + +# ---- D2 / C1 timeout ---------------------------------------------------------------- + + +def test_timeout_when_killpg_is_denied_still_returns_124(multi, monkeypatch): + http, _app = multi + + def _denied(_pgid, _sig): + raise PermissionError(1, "Operation not permitted") + + monkeypatch.setattr(os, "killpg", _denied) + sid, headers = _session(http) + resp = _exec(http, sid, headers, "import time; time.sleep(30)", timeout_s=0.3) + assert resp.status_code == 200 + body = resp.json() + assert body["exitcode"] == 124 + assert body["killed_reason"] == "timeout" + + +class _FakeProc: + pid = 424242 + + def __init__(self, kill_exc: BaseException | None = None): + self.kill_exc = kill_exc + self.killed = False + + def kill(self): + if self.kill_exc is not None: + raise self.kill_exc + self.killed = True + + +def test_kill_group_falls_back_to_proc_kill_and_never_raises(): + def _denied(_pgid, _sig): + raise PermissionError(1, "Operation not permitted") + + proc = _FakeProc() + assert kill_group(proc, killpg=_denied) is True + assert proc.killed + + hopeless = _FakeProc(PermissionError(1, "Operation not permitted")) + assert kill_group(hopeless, killpg=_denied) is False + + +def test_kill_group_treats_missing_group_as_done(): + def _gone(_pgid, _sig): + raise ProcessLookupError() + + proc = _FakeProc() + assert kill_group(proc, killpg=_gone) is True + assert not proc.killed + + +def test_unkillable_timeout_returns_124_and_quarantines_uid(tmp_path, monkeypatch): + store = LeaseStore(sessions_root=str(tmp_path / "s"), max_sessions=2, enable_uid_isolation=True) + lease = store.create() + monkeypatch.setattr(execute, "kill_group", lambda *_a, **_k: False) + result = handle_exec( + store, + lease, + {"code": "import time; time.sleep(1.5)", "lang": "python", "timeout_s": 0.2}, + kill_grace_s=0.1, + ) + assert result["exitcode"] == 124 + assert result["killed_reason"] == "timeout" + assert lease.uid in store._quarantined_uids + assert store.create().uid != lease.uid + + +# ---- D6 / C1 exit code + killed_reason ------------------------------------------------- + + +@pytest.mark.parametrize( + ("kwargs", "expected"), + [ + ({"returncode": 0, "timed_out": False}, (0, None)), + ({"returncode": 3, "timed_out": False}, (3, None)), + ({"returncode": -9, "timed_out": True}, (124, "timeout")), + ({"returncode": None, "timed_out": True}, (124, "timeout")), + ({"returncode": -9, "timed_out": False, "oom_delta": 1, "cpu_s": 2.0}, (137, "memory")), + ({"returncode": -9, "timed_out": False, "oom_delta": None, "cpu_s": 2.0}, (137, "memory")), + ({"returncode": -9, "timed_out": False, "oom_delta": 0, "cpu_s": 2.0}, (137, "signal")), + ({"returncode": -9, "timed_out": False, "oom_delta": 0, "cpu_s": 599.2}, (137, "cpu")), + ({"returncode": -int(signal.SIGXCPU), "timed_out": False}, (128 + int(signal.SIGXCPU), "cpu")), + ({"returncode": -9, "timed_out": False, "kill_requested": True}, (137, "signal")), + ({"returncode": -15, "timed_out": False}, (143, "signal")), + ], +) +def test_classify_exit(kwargs, expected): + assert classify_exit(**kwargs) == expected + + +def test_read_oom_kills_v2_v1_and_missing(tmp_path): + v2 = tmp_path / "memory.events" + v2.write_text("low 0\nhigh 0\nmax 4\noom 2\noom_kill 3\n") + v1 = tmp_path / "memory.oom_control" + v1.write_text("oom_kill_disable 0\nunder_oom 0\noom_kill 7\n") + assert read_oom_kills([str(v2)]) == 3 + assert read_oom_kills([str(tmp_path / "nope"), str(v1)]) == 7 + assert read_oom_kills([str(tmp_path / "nope")]) is None + + +def test_oom_kill_reports_memory_and_keeps_lease(tmp_path): + store = LeaseStore(sessions_root=str(tmp_path / "s")) + lease = store.create() + counter = iter([5, 6]) + result = handle_exec( + store, + lease, + {"code": "import os, signal; os.kill(os.getpid(), signal.SIGKILL)", "lang": "python", "timeout_s": 5}, + oom_reader=lambda: next(counter), + ) + assert result["exitcode"] == 137 + assert result["killed_reason"] == "memory" + # C2: the session is still there and usable. + assert store.get(lease.session_id, lease.lease_token) is lease + again = handle_exec(store, lease, {"code": "print('ok')", "lang": "python", "timeout_s": 5}) + assert again["exitcode"] == 0 + + +def test_signal_death_is_never_negative_over_http(multi): + http, _app = multi + sid, headers = _session(http) + resp = _exec(http, sid, headers, "import os, signal; os.kill(os.getpid(), signal.SIGTERM)") + assert resp.status_code == 200 + body = resp.json() + assert body["exitcode"] == 143 + assert body["killed_reason"] == "signal" + assert _exec(http, sid, headers, "print(1)").status_code == 200 + + +# ---- D7 / C4 DELETE during exec ------------------------------------------------------- + + +def test_delete_during_exec_kills_process_and_removes_session(multi): + http, app = multi + sid, headers = _session(http) + lease = app.state.store._leases[sid] + box: dict = {} + + def _run() -> None: + box["resp"] = _exec(http, sid, headers, "import time; time.sleep(30)", timeout_s=60) + + worker = threading.Thread(target=_run) + started = time.monotonic() + worker.start() + deadline = time.monotonic() + 5 + while lease.proc is None and time.monotonic() < deadline: + time.sleep(0.02) + assert lease.proc is not None + deleted = http.delete(f"/v1/sessions/{sid}", headers=headers) + assert deleted.status_code == 200 + worker.join(timeout=10) + assert not worker.is_alive() + assert time.monotonic() - started < 5 + body = box["resp"].json() + assert body["killed_reason"] == "signal" + assert body["exitcode"] == 137 + assert not os.path.exists(lease.work_dir) + assert not os.path.exists(lease.home_dir) + assert _exec(http, sid, headers, "print(1)").status_code == 404 + + +def test_exec_queued_behind_lock_gets_404_after_delete(tmp_path): + # Short wait: DELETE hands the cleanup to the queued exec, which runs it. + store = LeaseStore(sessions_root=str(tmp_path / "s"), delete_wait_s=0.05) + lease = store.create() + box: dict = {} + + def _run() -> None: + try: + handle_exec(store, lease, {"code": "print(1)", "lang": "python", "timeout_s": 5}) + box["outcome"] = "ran" + except UnknownLeaseError: + box["outcome"] = "404" + + with lease.lock: + worker = threading.Thread(target=_run) + worker.start() + time.sleep(0.05) + store.delete(lease.session_id, lease.lease_token) + worker.join(timeout=5) + assert box["outcome"] == "404" + assert not os.path.exists(lease.work_dir) + assert not os.path.exists(lease.home_dir) + + +# ---- C3 404 everywhere ------------------------------------------------------------------ + + +def test_unknown_session_is_404_for_every_session_call(multi): + http, _app = multi + headers = {"X-Lease-Token": "whatever"} + base = "/v1/sessions/does-not-exist" + responses = [ + http.post(f"{base}/exec", headers=headers, json={"code": "print(1)"}), + http.put(f"{base}/files", headers=headers, content=_framed({"a.txt": b"x"})), + http.get(f"{base}/files", headers=headers), + http.delete(base, headers=headers), + ] + for resp in responses: + assert resp.status_code == 404 + assert resp.json() == {"error": "not found"} + + +# ---- D4 idle reaping ---------------------------------------------------------------------- + + +class _Clock: + def __init__(self) -> None: + self.now = 1000.0 + + def __call__(self) -> float: + return self.now + + +def test_reaper_skips_inflight_and_restarts_clock_on_finish(tmp_path): + clock = _Clock() + store = LeaseStore(sessions_root=str(tmp_path / "s"), lease_ttl_s=60, clock=clock) + lease = store.create() + held = store.acquire(lease.session_id, lease.lease_token) + clock.now += 600 + store.reap_expired() + assert lease.session_id in store._leases + store.release(held) + # Finishing work counts as activity: not idle yet. + clock.now += 30 + store.reap_expired() + assert lease.session_id in store._leases + clock.now += 31 + store.reap_expired() + assert lease.session_id not in store._leases + assert not os.path.exists(lease.home_dir) + + +def test_long_exec_is_not_reaped_and_copy_out_follows(tmp_path): + clock = _Clock() + app = create_app(token="test-token", sessions_root=str(tmp_path / "s"), lease_ttl_s=60, clock=clock) + with TestClient(app) as http: + sid, headers = _session(http) + lease = app.state.store._leases[sid] + box: dict = {} + + def _run() -> None: + box["resp"] = _exec(http, sid, headers, "import time; open('after.txt','w').write('x'); time.sleep(0.5)") + + worker = threading.Thread(target=_run) + worker.start() + deadline = time.monotonic() + 5 + while lease.proc is None and time.monotonic() < deadline: + time.sleep(0.02) + clock.now += 600 + app.state.store.reap_expired() + worker.join(timeout=10) + assert box["resp"].json()["exitcode"] == 0 + got = http.get(f"/v1/sessions/{sid}/files", headers=headers) + assert got.status_code == 200 + assert "after.txt" in _untar_names(got.content) + + +# ---- D9 uid allocation ---------------------------------------------------------------------- + + +def test_uid_is_lowest_free_among_live_leases(tmp_path): + store = LeaseStore(sessions_root=str(tmp_path / "s"), max_sessions=4, enable_uid_isolation=True) + a, b, c = store.create(), store.create(), store.create() + assert [a.uid, b.uid, c.uid] == [10000, 10001, 10002] + store.delete(a.session_id, a.lease_token) + d = store.create() + # The old len()-based formula gave 10002 here and collided with c. + assert d.uid == 10000 + live = [lease.uid for lease in store._leases.values()] + assert len(live) == len(set(live)) + + +def test_uid_pool_exhausted_raises_capacity(tmp_path): + store = LeaseStore(sessions_root=str(tmp_path / "s"), max_sessions=3, enable_uid_isolation=True, max_uids=2) + store.create() + store.create() + with pytest.raises(CapacityError): + store.create() + assert len(store._leases) == 2 + + +# ---- D10 supplementary groups --------------------------------------------------------------- + + +def test_preexec_drops_supplementary_groups_before_ids(monkeypatch): + calls: list[tuple] = [] + monkeypatch.setattr(execute.resource, "setrlimit", lambda *_a: None) + monkeypatch.setattr(os, "geteuid", lambda: 0) + monkeypatch.setattr(os, "setgroups", lambda groups: calls.append(("setgroups", tuple(groups)))) + monkeypatch.setattr(os, "setgid", lambda gid: calls.append(("setgid", gid))) + monkeypatch.setattr(os, "setuid", lambda uid: calls.append(("setuid", uid))) + _preexec(10007) + assert calls == [("setgroups", ()), ("setgid", 10007), ("setuid", 10007)] + + +# ---- D3 / C5 copy-in total cap and spooling -------------------------------------------------- + + +def test_copy_in_total_cap_skips_and_lists(tmp_path): + root = tmp_path / "s" + app = create_app( + token="test-token", + sessions_root=str(root), + max_copy_in_bytes=2048, + max_copy_in_total_bytes=1500, + ) + with TestClient(app) as http: + sid, headers = _session(http) + first = http.put( + f"/v1/sessions/{sid}/files", + headers=headers, + content=_framed({"a.bin": b"a" * 1000, "b.bin": b"b" * 1000, "c.txt": b"c" * 100}), + ) + assert first.status_code == 200 + body = first.json() + assert body["written"] == ["a.bin", "c.txt"] + assert body["skipped"] == [{"path": "b.bin", "size": 1000}] + # Replacing a path counts its new size, not old + new. + second = http.put( + f"/v1/sessions/{sid}/files", + headers=headers, + content=_framed({"a.bin": b"A" * 1200}), + ) + assert second.json()["written"] == ["a.bin"] + third = http.put( + f"/v1/sessions/{sid}/files", + headers=headers, + content=_framed({"d.txt": b"d" * 300}), + ) + assert third.json()["skipped"] == [{"path": "d.txt", "size": 300}] + # The spooled body is gone once the request finishes. + assert not [name for name in os.listdir(root) if name.startswith(".copy-in-")] + + +def test_read_copy_in_manifest_from_file(): + raw = json.dumps({"a.txt": "0" * 32}).encode() + fh = io.BytesIO(len(raw).to_bytes(4, "big") + raw + b"TAR") + assert read_copy_in_manifest(fh) == {"a.txt": "0" * 32} + assert fh.read() == b"TAR" + with pytest.raises(ManifestError): + read_copy_in_manifest(io.BytesIO(b"\x00\x00")) + with pytest.raises(ManifestError): + read_copy_in_manifest(io.BytesIO((100).to_bytes(4, "big") + b"{}")) + with pytest.raises(ManifestError): + read_copy_in_manifest(io.BytesIO((2).to_bytes(4, "big") + b"[]")) + + +# ---- D13 / C6 HOME outside the work dir, MATPLOTLIBRC ---------------------------------------- + + +def test_home_caches_never_become_artifacts(multi, monkeypatch): + http, app = multi + monkeypatch.setenv("MATPLOTLIBRC", "/etc/matplotlibrc") + sid, headers = _session(http) + lease = app.state.store._leases[sid] + code = ( + "import os, json, pathlib\n" + "home = pathlib.Path(os.environ['HOME'])\n" + "(home / '.cache' / 'fontconfig').mkdir(parents=True, exist_ok=True)\n" + "(home / '.cache' / 'fontconfig' / 'x.cache-9').write_text('c')\n" + "(home / 'dconf').mkdir(exist_ok=True)\n" + "(home / 'dconf' / 'user').write_text('d')\n" + "pathlib.Path('output/report.txt').write_text('r')\n" + "print(json.dumps({k: os.environ.get(k) for k in " + "('HOME', 'XDG_CACHE_HOME', 'XDG_CONFIG_HOME', 'MATPLOTLIBRC')}))\n" + ) + resp = _exec(http, sid, headers, code) + assert resp.json()["exitcode"] == 0, resp.json() + env = json.loads(resp.json()["stdout"]) + work = os.path.realpath(lease.work_dir) + for key in ("HOME", "XDG_CACHE_HOME", "XDG_CONFIG_HOME"): + path = os.path.realpath(env[key]) + assert not (path == work or path.startswith(work + os.sep)), key + assert path.startswith(os.path.realpath(lease.home_dir)) + assert env["MATPLOTLIBRC"] == "/etc/matplotlibrc" + got = http.get(f"/v1/sessions/{sid}/files", headers=headers) + assert _untar_names(got.content) == {"output/report.txt"} + assert http.delete(f"/v1/sessions/{sid}", headers=headers).status_code == 200 + assert not os.path.exists(lease.home_dir) + + +def test_matplotlibrc_absent_is_not_invented(monkeypatch): + monkeypatch.delenv("MATPLOTLIBRC", raising=False) + env = execute._child_env("/w", "/w.home") + assert "MATPLOTLIBRC" not in env + assert env["HOME"] == "/w.home" + assert env["TMPDIR"] == "/w" diff --git a/src/backend/test/sandbox/test_runner_leases.py b/src/backend/test/sandbox/test_runner_leases.py new file mode 100644 index 0000000000..0085e067ec --- /dev/null +++ b/src/backend/test/sandbox/test_runner_leases.py @@ -0,0 +1,124 @@ +"""Lease protocol for the isolation-environment runner (F068 T007).""" + +from __future__ import annotations + +import os +import time + +import pytest +from app import create_app +from starlette.testclient import TestClient + + +@pytest.fixture +def runner_client(tmp_path): + app = create_app( + token="test-token", + sessions_root=str(tmp_path / "sessions"), + max_sessions=1, + lease_ttl_s=900, + ) + with TestClient(app) as client: + yield client + + +def _auth(token: str = "test-token") -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +def test_routes_do_not_contain_config(): + from app import ROUTES + + for path in ROUTES: + assert "config" not in path.lower() + + +def test_health(runner_client: TestClient): + resp = runner_client.get("/health") + assert resp.status_code == 200 + + +def test_create_session_requires_global_token(runner_client: TestClient): + assert runner_client.post("/v1/sessions").status_code == 401 + assert runner_client.post("/v1/sessions", headers=_auth("wrong")).status_code == 401 + resp = runner_client.post("/v1/sessions", headers=_auth()) + assert resp.status_code == 200 + body = resp.json() + assert body["session_id"] + assert body["lease_token"] + assert body["lease_expires_at"] + + +def test_capacity_full_returns_503(runner_client: TestClient): + first = runner_client.post("/v1/sessions", headers=_auth()) + assert first.status_code == 200 + second = runner_client.post("/v1/sessions", headers=_auth()) + assert second.status_code == 503 + + +def test_foreign_lease_token_cannot_delete_other_session(tmp_path): + app = create_app( + token="test-token", + sessions_root=str(tmp_path / "sessions"), + max_sessions=2, + lease_ttl_s=900, + enable_uid_isolation=True, + ) + with TestClient(app) as client: + a = client.post("/v1/sessions", headers=_auth()).json() + b = client.post("/v1/sessions", headers=_auth()).json() + resp = client.delete( + f"/v1/sessions/{b['session_id']}", + headers={"X-Lease-Token": a["lease_token"]}, + ) + assert resp.status_code == 403 + + +def test_isolation_session_dir_stays_writable_for_copy_in(tmp_path): + from leases import LeaseStore + + store = LeaseStore( + sessions_root=str(tmp_path / "sessions"), + max_sessions=2, + enable_uid_isolation=True, + ) + lease = store.create() + assert lease.uid == 10000 + probe = os.path.join(lease.work_dir, "celerybeat-schedule") + with open(probe, "wb") as fh: + fh.write(b"ok") + assert os.path.isfile(probe) + + +def test_delete_then_exec_is_404(runner_client: TestClient): + created = runner_client.post("/v1/sessions", headers=_auth()).json() + sid = created["session_id"] + token = created["lease_token"] + headers = {"X-Lease-Token": token} + assert runner_client.delete(f"/v1/sessions/{sid}", headers=headers).status_code == 200 + exec_resp = runner_client.post( + f"/v1/sessions/{sid}/exec", + headers=headers, + json={"code": "print(1)", "lang": "python", "timeout_s": 5}, + ) + assert exec_resp.status_code == 404 + + +def test_idle_ttl_releases_lease(tmp_path): + app = create_app( + token="test-token", + sessions_root=str(tmp_path / "sessions"), + max_sessions=1, + lease_ttl_s=0.05, + ) + with TestClient(app) as client: + created = client.post("/v1/sessions", headers=_auth()).json() + time.sleep(0.12) + exec_resp = client.post( + f"/v1/sessions/{created['session_id']}/exec", + headers={"X-Lease-Token": created["lease_token"]}, + json={"code": "print(1)", "lang": "python", "timeout_s": 5}, + ) + assert exec_resp.status_code == 404 + # slot is free again + assert client.post("/v1/sessions", headers=_auth()).status_code == 200 diff --git a/src/backend/test/sandbox/test_runner_logs.py b/src/backend/test/sandbox/test_runner_logs.py new file mode 100644 index 0000000000..15e9244227 --- /dev/null +++ b/src/backend/test/sandbox/test_runner_logs.py @@ -0,0 +1,45 @@ +"""Runner operational logs omit secrets and user code.""" + +from __future__ import annotations + +import logging + +from app import create_app +from starlette.testclient import TestClient + + +def test_session_and_exec_logs_omit_token_and_code(caplog, tmp_path): + app = create_app(token="secret-runner-token", sessions_root=str(tmp_path / "sessions")) + logger = logging.getLogger("sandbox") + previous = logger.propagate + logger.propagate = True + secret_code = "print('UNIQUE_USER_CODE_MARKER')" + try: + with caplog.at_level(logging.INFO): + with TestClient(app) as client: + created = client.post( + "/v1/sessions", + headers={"Authorization": "Bearer secret-runner-token"}, + ).json() + sid = created["session_id"] + token = created["lease_token"] + resp = client.post( + f"/v1/sessions/{sid}/exec", + headers={"X-Lease-Token": token}, + json={"code": secret_code, "lang": "python", "timeout_s": 5}, + ) + assert resp.status_code == 200 + assert resp.json()["exitcode"] == 0 + finally: + logger.propagate = previous + + text = "\n".join(record.getMessage() for record in caplog.records) + assert "session created" in text + assert f"session_id={sid}" in text + assert "exec start" in text + assert "exec done" in text + assert "exitcode=0" in text + assert "secret-runner-token" not in text + assert token not in text + assert "UNIQUE_USER_CODE_MARKER" not in text + assert secret_code not in text diff --git a/src/backend/test/sandbox/test_runner_uid_release.py b/src/backend/test/sandbox/test_runner_uid_release.py new file mode 100644 index 0000000000..e76ea09660 --- /dev/null +++ b/src/backend/test/sandbox/test_runner_uid_release.py @@ -0,0 +1,379 @@ +"""Runner: leftover processes, uid release, DELETE vs. lock holders, streamed copy-in. + +Review findings on the F068 hardening: background jobs outlived exec and DELETE +while their uid was reissued; DELETE removed the tree under a running copy-in; +the copy-in body was spooled to the RAM tmpfs without a size bound. +""" + +from __future__ import annotations + +import io +import json +import os +import signal +import subprocess +import tarfile +import threading +import time +import uuid + +import execute +import procutil +import pytest +from app import copy_in_body_limit, create_app +from execute import handle_exec +from files import CopyInAbortedError, handle_put_files +from leases import LeaseStore +from procutil import kill_uid, uid_processes +from starlette.testclient import TestClient + + +def _auth() -> dict[str, str]: + return {"Authorization": "Bearer test-token"} + + +def _session(client: TestClient) -> tuple[str, dict[str, str]]: + body = client.post("/v1/sessions", headers=_auth()).json() + return body["session_id"], {"X-Lease-Token": body["lease_token"]} + + +def _tar_gz(members: dict[str, bytes]) -> bytes: + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz", encoding="utf-8", format=tarfile.PAX_FORMAT) as tar: + for name, data in members.items(): + info = tarfile.TarInfo(name=name) + info.size = len(data) + tar.addfile(info, io.BytesIO(data)) + return buf.getvalue() + + +def _framed(members: dict[str, bytes], manifest: dict | None = None) -> bytes: + raw = json.dumps(manifest or {}).encode("utf-8") + return len(raw).to_bytes(4, "big") + raw + _tar_gz(members) + + +class _FakeUidKill: + """Stands in for the root-only kill(-1) helper and the /proc scan.""" + + def __init__(self, survivors: int = 0): + self.calls: list[tuple[int, str]] = [] + # Number of scans that still report a process after a kill. + self.survivors = survivors + + def kill(self, uid: int, *, session_id: str = "", reason: str = "") -> bool: + self.calls.append((uid, reason)) + return True + + def scan(self, uid: int) -> list[int]: + if self.survivors > 0: + self.survivors -= 1 + return [4242] + return [] + + +def _isolated_store(tmp_path, fake: _FakeUidKill, **kwargs) -> LeaseStore: + return LeaseStore( + sessions_root=str(tmp_path / "s"), + max_sessions=4, + enable_uid_isolation=True, + uid_killer=fake.kill, + uid_scanner=fake.scan, + sleep=lambda _s: None, + **kwargs, + ) + + +def _alive(marker: str) -> bool: + done = subprocess.run(["pgrep", "-f", marker], capture_output=True, check=False) + return done.returncode == 0 + + +# ---- leftover processes --------------------------------------------------------------- + + +def test_background_job_does_not_outlive_exec(tmp_path, monkeypatch): + # RLIMIT_NPROC counts every process of the developer's user on macOS. + monkeypatch.setattr(execute, "_RLIMIT_NPROC", 1 << 30) + store = LeaseStore(sessions_root=str(tmp_path / "s")) + lease = store.create() + seconds = 4000 + uuid.uuid4().int % 900 + marker = f"sleep {seconds}" + result = handle_exec( + store, + lease, + {"code": f"{marker} >/dev/null 2>&1 &", "lang": "sh", "timeout_s": 5}, + ) + assert result["exitcode"] == 0 + deadline = time.monotonic() + 3 + while _alive(marker) and time.monotonic() < deadline: + time.sleep(0.05) + assert not _alive(marker) + store.delete(lease.session_id, lease.lease_token) + + +def test_exec_end_and_delete_kill_by_uid(tmp_path): + fake = _FakeUidKill() + store = _isolated_store(tmp_path, fake) + lease = store.create() + handle_exec(store, lease, {"code": "print(1)", "lang": "python", "timeout_s": 5}) + assert (lease.uid, "exec_end") in fake.calls + store.delete(lease.session_id, lease.lease_token) + assert [reason for uid, reason in fake.calls if uid == lease.uid].count("delete") >= 2 + # The uid is clean, so it goes back to the pool. + assert store.create().uid == lease.uid + + +def test_uid_with_surviving_processes_is_quarantined(tmp_path): + fake = _FakeUidKill(survivors=10) + store = _isolated_store(tmp_path, fake) + lease = store.create() + store.delete(lease.session_id, lease.lease_token) + assert not os.path.exists(lease.work_dir) + assert store.create().uid != lease.uid + + +def test_uid_survivor_cleared_on_second_round_is_released(tmp_path): + fake = _FakeUidKill(survivors=1) + store = _isolated_store(tmp_path, fake) + lease = store.create() + store.delete(lease.session_id, lease.lease_token) + assert store.create().uid == lease.uid + + +# ---- DELETE vs. lock holders ----------------------------------------------------------- + + +def test_delete_hands_cleanup_to_lock_holder_and_keeps_uid_reserved(tmp_path): + fake = _FakeUidKill() + store = _isolated_store(tmp_path, fake, delete_wait_s=0.05) + lease = store.create() + entered = threading.Event() + leave = threading.Event() + + def _holder() -> None: + with store.locked(lease): + entered.set() + leave.wait(5) + + worker = threading.Thread(target=_holder) + worker.start() + assert entered.wait(5) + store.delete(lease.session_id, lease.lease_token) + # The holder still owns the tree: it is not removed under it, and the uid + # is not reissued while the holder may still run as it. + assert os.path.exists(lease.work_dir) + other = store.create() + assert other.uid != lease.uid + leave.set() + worker.join(timeout=5) + assert not worker.is_alive() + assert not os.path.exists(lease.work_dir) + assert not os.path.exists(lease.home_dir) + assert store.create().uid == lease.uid + + +class _GatedBody: + """Tar stream that blocks after the first chunk until ``gate`` is set.""" + + def __init__(self, data: bytes, gate: threading.Event, first: int): + self._data = data + self._pos = 0 + self._gate = gate + self._first = first + self.blocked = threading.Event() + + def read(self, size: int = -1) -> bytes: + if self._pos >= self._first: + self.blocked.set() + self._gate.wait(5) + if size is None or size < 0: + size = len(self._data) - self._pos + end = min(self._pos + size, len(self._data) if self._pos >= self._first else self._first) + out = self._data[self._pos : end] + self._pos = end + return out + + +def test_delete_during_copy_in_leaves_no_orphan_tree(tmp_path): + store = LeaseStore(sessions_root=str(tmp_path / "s"), delete_wait_s=0.05) + lease = store.create() + payload = _tar_gz({f"d{i}/f.bin": os.urandom(4096) for i in range(40)}) + gate = threading.Event() + body = _GatedBody(payload, gate, first=len(payload) // 3) + box: dict = {} + + def _copy_in() -> None: + try: + with store.locked(lease): + handle_put_files(lease, body, {}, 1 << 20, 1 << 30, should_stop=lambda: lease.deleted) + box["outcome"] = "done" + except CopyInAbortedError: + box["outcome"] = "aborted" + + worker = threading.Thread(target=_copy_in) + worker.start() + assert body.blocked.wait(5) + store.delete(lease.session_id, lease.lease_token) + gate.set() + worker.join(timeout=5) + assert box["outcome"] == "aborted" + assert not os.path.exists(lease.work_dir) + assert os.listdir(tmp_path / "s") == [] + + +def test_should_stop_aborts_between_members(tmp_path): + store = LeaseStore(sessions_root=str(tmp_path / "s")) + lease = store.create() + seen: list[int] = [] + + def _stop() -> bool: + seen.append(1) + return len(seen) > 3 + + with pytest.raises(CopyInAbortedError): + handle_put_files(lease, _tar_gz({"a.txt": b"a", "b.txt": b"b", "c.txt": b"c"}), {}, 1024, None, _stop) + assert not os.path.exists(os.path.join(lease.work_dir, "c.txt")) + + +# ---- streamed copy-in ------------------------------------------------------------------ + + +@pytest.fixture +def small_limit(tmp_path): + root = tmp_path / "sessions" + app = create_app( + token="test-token", + sessions_root=str(root), + max_copy_in_body_bytes=20_000, + ) + with TestClient(app) as http: + yield http, root + + +def test_copy_in_over_declared_limit_is_413(small_limit): + http, _root = small_limit + sid, headers = _session(http) + resp = http.put( + f"/v1/sessions/{sid}/files", + headers=headers, + content=_framed({"big.bin": os.urandom(30_000)}), + ) + assert resp.status_code == 413 + assert resp.json() == {"error": "copy-in body too large"} + + +def test_copy_in_over_streamed_limit_is_413_and_session_survives(small_limit): + http, root = small_limit + sid, headers = _session(http) + body = _framed({"big.bin": os.urandom(30_000)}) + + def _chunks(): + # No Content-Length: the bound must trip while streaming. + for start in range(0, len(body), 1000): + yield body[start : start + 1000] + + resp = http.put(f"/v1/sessions/{sid}/files", headers=headers, content=_chunks()) + assert resp.status_code == 413 + assert not [name for name in os.listdir(root) if name.startswith(".copy-in-")] + ok = http.put(f"/v1/sessions/{sid}/files", headers=headers, content=_framed({"a.txt": b"hello"})) + assert ok.status_code == 200 + assert ok.json()["written"] == ["a.txt"] + + +def test_chunked_copy_in_extracts_while_streaming(tmp_path): + root = tmp_path / "sessions" + app = create_app(token="test-token", sessions_root=str(root)) + members = {f"dir/{i}.txt": f"line {i}\n".encode() * 200 for i in range(30)} + body = _framed(members) + + def _chunks(): + for start in range(0, len(body), 777): + yield body[start : start + 777] + + with TestClient(app) as http: + sid, headers = _session(http) + resp = http.put(f"/v1/sessions/{sid}/files", headers=headers, content=_chunks()) + assert resp.status_code == 200 + assert sorted(resp.json()["written"]) == sorted(members) + lease = app.state.store._leases[sid] + with open(os.path.join(lease.work_dir, "dir", "7.txt"), "rb") as fh: + assert fh.read() == members["dir/7.txt"] + + +def test_garbage_archive_after_manifest_is_400(small_limit): + http, _root = small_limit + sid, headers = _session(http) + raw = b"{}" + resp = http.put( + f"/v1/sessions/{sid}/files", + headers=headers, + content=len(raw).to_bytes(4, "big") + raw + b"not a tar at all" * 10, + ) + assert resp.status_code == 400 + + +def test_default_body_limit_covers_cap_and_overhead(): + cap = 512 * 1024 * 1024 + limit = copy_in_body_limit(cap) + assert limit > cap + 64 * 1024 * 1024 + assert limit < cap * 2 + + +# ---- procutil helpers ------------------------------------------------------------------ + + +def test_uid_processes_skips_zombies_and_other_uids(tmp_path): + def _proc(pid: str, uid_line: str, state: str = "S (sleeping)") -> None: + d = tmp_path / pid + d.mkdir() + (d / "status").write_text(f"Name:\tx\nState:\t{state}\nUid:\t{uid_line}\n") + + _proc("10", "10001\t10001\t10001\t10001") + _proc("11", "10002\t10002\t10002\t10002") + _proc("12", "10001\t10001\t10001\t10001", state="Z (zombie)") + _proc("13", "0\t10001\t0\t0") + (tmp_path / "self").mkdir() + assert sorted(uid_processes(10001, proc_root=str(tmp_path))) == [10, 13] + assert uid_processes(10003, proc_root=str(tmp_path)) == [] + assert uid_processes(1, proc_root=str(tmp_path / "missing")) == [] + + +def test_kill_uid_runs_helper_as_uid_and_reports_failure(): + seen: dict = {} + + def _run(cmd, **kwargs): + seen.update(kwargs, cmd=cmd) + return subprocess.CompletedProcess(cmd, 0, b"", b"") + + assert kill_uid(10007, run=_run) is True + assert seen["user"] == 10007 and seen["group"] == 10007 and seen["extra_groups"] == [] + assert "os.kill(-1, signal.SIGKILL)" in seen["cmd"][-1] + + def _fail(cmd, **_kwargs): + return subprocess.CompletedProcess(cmd, 1, b"", b"boom") + + assert kill_uid(10007, run=_fail) is False + + def _raise(cmd, **_kwargs): + raise PermissionError("not root") + + assert kill_uid(10007, run=_raise) is False + + +def test_kill_uid_treats_concurrent_helper_kill_as_done(monkeypatch): + # Exec end and DELETE each start a helper as the same uid; the first + # kill(-1) also SIGKILLs the other helper. That is not a failure. + errors: list = [] + monkeypatch.setattr(procutil._log, "error", lambda *a, **_k: errors.append(a)) + + def _killed(cmd, **_kwargs): + return subprocess.CompletedProcess(cmd, -signal.SIGKILL, b"", b"") + + assert kill_uid(10007, run=_killed) is True + assert errors == [] + + def _fail(cmd, **_kwargs): + return subprocess.CompletedProcess(cmd, 1, b"", b"boom") + + assert kill_uid(10007, run=_fail) is False + assert len(errors) == 1 diff --git a/src/backend/test/sandbox/test_sandbox_code_parser.py b/src/backend/test/sandbox/test_sandbox_code_parser.py new file mode 100644 index 0000000000..70d022b229 --- /dev/null +++ b/src/backend/test/sandbox/test_sandbox_code_parser.py @@ -0,0 +1,614 @@ +"""SandboxCodeParser: main check, result channel, size limit, failure tail (F068).""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import textwrap + +import pytest + +from bisheng.common.errcode.sandbox import ( + SandboxCodeNodeNoResultError, + SandboxCodeNodeOutputError, + SandboxCodeNodeOutputTooLargeError, +) +from bisheng.workflow.nodes.code import code_parse +from bisheng.workflow.nodes.code.code_parse import ( + FAILURE_LOG_OMITTED_NOTICE, + FAILURE_LOG_TAIL_CHARS, + RESULT_LIMIT_BYTES, + CodeParser, + SandboxCodeParser, + build_code_node_wrapper, + make_code_parser, + parse_code_node_result, + tail_failure_log, +) + +_real_new_nonce = code_parse._new_nonce +_NONCE = "0123456789abcdef0123456789abcdef" +_OK_LINE = f'{_NONCE}:ok:{{"sum": 3, "x": 1}}\n' + +_MAIN = """ +def main(x, y): + return {"sum": x + y, "x": x} +""" + +# Stands in for the E2B Jupyter kernel: run_code's logs.stdout is the Python-level +# sys.stdout of the kernel, not fd 1, so only what reaches that stream is reported. +_KERNEL_DRIVER = textwrap.dedent( + """ + import io, sys, traceback, types + sys.modules["ipykernel"] = types.ModuleType("ipykernel") + kernel_out = io.StringIO() + sys.stdout = kernel_out + with open(sys.argv[1], encoding="utf-8") as fh: + cell = fh.read() + rc = 0 + try: + exec(compile(cell, "", "exec"), {"__name__": "__main__"}) + except BaseException: + traceback.print_exc() + rc = 1 + with open(sys.argv[2], "w", encoding="utf-8") as fh: + fh.write(kernel_out.getvalue()) + sys.exit(rc) + """ +) + + +@pytest.fixture(autouse=True) +def _fixed_nonce(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(code_parse, "_new_nonce", lambda: _NONCE) + + +class _ScriptRunner: + """Runs the wrapper as a script in a subprocess, like the local / container runners: + stdout on success, stderr on failure.""" + + def __init__(self, env: dict | None = None): + self.calls: list[dict] = [] + self.env = env + self.stderr = "" + + def execute_code(self, code=None, timeout=None, filename=None, work_dir=None, lang="python"): + self.calls.append({"code": code, "lang": lang, "work_dir": work_dir}) + with tempfile.TemporaryDirectory() as run_dir: + path = os.path.join(run_dir, "run.py") + with open(path, "w", encoding="utf-8") as fh: + fh.write(code) + proc = subprocess.run( + [sys.executable, path], + cwd=run_dir, + env=self.env, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=120, + ) + self.stderr = proc.stderr + if proc.returncode: + return proc.returncode, proc.stderr, "" + return 0, proc.stdout, "" + + +class _KernelRunner: + def __init__(self): + self.fd_stdout = "" + + def execute_code(self, code=None, timeout=None, filename=None, work_dir=None, lang="python"): + with tempfile.TemporaryDirectory() as run_dir: + cell, driver, logs = (os.path.join(run_dir, n) for n in ("cell.py", "driver.py", "logs.txt")) + with open(cell, "w", encoding="utf-8") as fh: + fh.write(code) + with open(driver, "w", encoding="utf-8") as fh: + fh.write(_KERNEL_DRIVER) + proc = subprocess.run( + [sys.executable, driver, cell, logs], + cwd=run_dir, + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + with open(logs, encoding="utf-8") as fh: + stdout = fh.read() + self.fd_stdout = proc.stdout + if proc.returncode: + return 1, proc.stderr, "" + return 0, stdout, "" + + +def _run(code: str, runner=None, **params): + runner = runner or _ScriptRunner() + parser = SandboxCodeParser(textwrap.dedent(code), execute_code=runner.execute_code) + parser.parse_code() + return parser.exec_method("main", **params) + + +# --- main check ------------------------------------------------------------------- + + +def test_syntax_error_is_raised_in_parse_code_without_calling_runner(): + runner = _ScriptRunner() + parser = SandboxCodeParser("def main(:\n pass\n", execute_code=runner.execute_code) + with pytest.raises(SyntaxError): + parser.parse_code() + assert runner.calls == [] + + +@pytest.mark.parametrize( + "code", + [ + "def handler():\n return {}\n", + "main = lambda: {}\n", + "from os import main\n", + "if True:\n def main():\n return {}\n", + "try:\n def main():\n return {}\nexcept Exception:\n pass\n", + "class C:\n def main(self):\n return {}\n", + "def outer():\n def main():\n return {}\n", + ], +) +def test_code_without_a_top_level_def_main_is_rejected_at_build(code): + runner = _ScriptRunner() + parser = SandboxCodeParser(code, execute_code=runner.execute_code) + with pytest.raises(ValueError, match="module-level function"): + parser.parse_code() + assert runner.calls == [] + + +def test_async_main_is_rejected_with_its_own_message(): + parser = SandboxCodeParser("async def main():\n return {}\n") + with pytest.raises(ValueError, match="async"): + parser.parse_code() + + +def test_a_top_level_def_main_passes_whatever_comes_around_it(): + code = "import json\n\nLIMIT = 3\n\ndef helper():\n return 1\n\ndef main(a=1):\n return {}\n" + SandboxCodeParser(code).parse_code() + + +# --- result channel --------------------------------------------------------------- + + +def test_legal_main_wrapper_output_matches_in_process_parser(): + runner = _ScriptRunner() + got = _run(_MAIN, runner, x=1, y=2) + + legacy = CodeParser(_MAIN) + legacy.parse_code() + expected = legacy.exec_method("main", x=1, y=2) + + assert got == expected == {"sum": 3, "x": 1} + assert "def main" in runner.calls[0]["code"] + + +def test_forged_result_lines_do_not_replace_the_return_value(): + code = f""" + print('__BISHENG_CODE_NODE_RESULT__{{"forged": 1}}') + print('{_NONCE}:ok:{{"forged": 2}}') + + def main(): + print('{_NONCE}:ok:{{"forged": 3}}') + return {{"real": True}} + """ + assert _run(code) == {"real": True} + + +def test_prints_and_child_output_go_to_stderr_not_the_result_channel(): + runner = _ScriptRunner() + code = """ + import subprocess, sys + print("top-level print") + + def main(): + print("print in main") + subprocess.run([sys.executable, "-c", "print('child echo')"], check=True) + return {"ok": 1} + """ + assert _run(code, runner) == {"ok": 1} + for text in ("top-level print", "print in main", "child echo"): + assert text in runner.stderr + + +def test_wrapper_names_do_not_collide_with_user_globals(): + code = """ + _bisheng_out = None + _bisheng_ret = "user" + _bisheng_params = {"x": 0} + + def main(x): + return {"x": x, "ret": _bisheng_ret} + """ + assert _run(code, x=7) == {"x": 7, "ret": "user"} + + +def test_user_classes_pickle_as_module_main(): + code = """ + import pickle + + class Point: + def __init__(self, x): + self.x = x + + def main(): + back = pickle.loads(pickle.dumps(Point(4))) + return {"x": back.x, "module": type(back).__module__} + """ + assert _run(code) == {"x": 4, "module": "__main__"} + + +def test_non_ascii_result_survives_a_c_locale(): + env = {k: v for k, v in os.environ.items() if k not in ("PYTHONIOENCODING", "PYTHONUTF8")} + env.update(LANG="C", LC_ALL="C", PYTHONCOERCECLOCALE="0", PYTHONUTF8="0") + code = """ + def main(): + return {"text": "中文结果"} + """ + assert _run(code, _ScriptRunner(env=env)) == {"text": "中文结果"} + + +def test_a_3_mib_result_comes_back_intact(): + code = """ + def main(): + return {"blob": "x" * (3 * 1024 * 1024)} + """ + assert _run(code)["blob"] == "x" * (3 * 1024 * 1024) + + +# --- result errors ---------------------------------------------------------------- + + +def test_unserializable_return_is_28005(): + code = """ + def main(): + return {"items": set()} + """ + with pytest.raises(SandboxCodeNodeOutputError) as err: + _run(code) + assert err.value.Code == 28005 + + +def test_result_over_the_limit_is_28007_with_the_size(): + code = """ + def main(): + return {"blob": "x" * (4 * 1024 * 1024)} + """ + with pytest.raises(SandboxCodeNodeOutputTooLargeError) as err: + _run(code) + size = len(json.dumps({"blob": "x" * (4 * 1024 * 1024)}).encode()) + assert err.value.Code == 28007 + assert str(size) in str(err.value) + assert str(RESULT_LIMIT_BYTES) in str(err.value) + + +def test_backend_rechecks_the_size_of_an_ok_line(): + raw = json.dumps({"blob": "y" * RESULT_LIMIT_BYTES}) + with pytest.raises(SandboxCodeNodeOutputTooLargeError): + parse_code_node_result(f"{_NONCE}:ok:{raw}\n", _NONCE) + + +@pytest.mark.parametrize("exit_call", ["sys.exit(0)", "os._exit(0)"]) +def test_exiting_before_main_returns_is_28008(exit_call): + code = f""" + import os, sys + + def main(): + {exit_call} + """ + with pytest.raises(SandboxCodeNodeNoResultError) as err: + _run(code) + assert err.value.Code == 28008 + + +@pytest.mark.parametrize( + "stdout", + [ + "", + "hello\n", + '__BISHENG_CODE_NODE_RESULT__{"a": 1}\n', + 'ffff:ok:{"a": 1}\n', + f'noise\n{_NONCE}:ok:{{"a": 1}}\n', + f"{_NONCE}:ok:not-json\n", + f"{_NONCE}:mystery\n", + ], +) +def test_stdout_that_is_not_exactly_one_result_line_is_28008(stdout): + with pytest.raises(SandboxCodeNodeNoResultError): + parse_code_node_result(stdout, _NONCE) + + +def test_nonce_differs_per_call(): + first, second = _real_new_nonce(), _real_new_nonce() + assert first != second + assert len(first) == 32 + + +# --- failure output --------------------------------------------------------------- + + +def test_traceback_points_at_the_user_line_with_its_source(): + code = "def main():\n value = 1\n raise ValueError('boom from user')\n" + with pytest.raises(RuntimeError) as err: + _run(code) + message = str(err.value) + lines = message.splitlines() + idx = next(i for i, line in enumerate(lines) if 'File "", line 3' in line) + assert "raise ValueError('boom from user')" in lines[idx + 1] + assert "ValueError: boom from user" in message + assert "run.py" not in message + + +def test_a_wrong_parameter_name_still_reports_the_call_error(): + with pytest.raises(RuntimeError, match="unexpected keyword argument 'z'"): + _run(_MAIN, x=1, y=2, z=3) + + +def test_failure_log_keeps_only_the_tail(): + long_log = "a" * 10_000 + "TAIL" + cut = tail_failure_log(long_log) + assert cut.startswith(FAILURE_LOG_OMITTED_NOTICE) + assert cut[len(FAILURE_LOG_OMITTED_NOTICE) :] == long_log[-FAILURE_LOG_TAIL_CHARS:] + short = "b" * FAILURE_LOG_TAIL_CHARS + assert tail_failure_log(short) == short + + +def test_exec_method_tails_a_long_failure_log(): + def _fail(code=None, lang="python", work_dir=None): + return 1, "x" * 10_000 + "Error: last line", "" + + parser = SandboxCodeParser(_MAIN, execute_code=_fail) + with pytest.raises(RuntimeError) as err: + parser.exec_method("main", x=1, y=2) + message = str(err.value) + assert message.endswith("Error: last line") + assert len(message) == len(FAILURE_LOG_OMITTED_NOTICE) + FAILURE_LOG_TAIL_CHARS + + +# --- E2B kernel mode -------------------------------------------------------------- + + +def test_kernel_mode_reads_only_the_saved_stdout_stream(): + runner = _KernelRunner() + code = f""" + import os + + def main(): + print("user print") + os.write(1, b'{_NONCE}:ok:{{"forged": 1}}\\n') + return {{"real": 1}} + """ + assert _run(code, runner) == {"real": 1} + assert "forged" in runner.fd_stdout + + +def test_kernel_mode_traceback_names_the_user_line(): + code = "def main():\n value = 1\n raise KeyError('kernel boom')\n" + with pytest.raises(RuntimeError) as err: + _run(code, _KernelRunner()) + assert 'File "", line 3' in str(err.value) + assert "raise KeyError('kernel boom')" in str(err.value) + + +# --- backend selection ------------------------------------------------------------ + + +def test_make_code_parser_ignores_sandbox_switch_and_stays_out_of_process(): + parser = make_code_parser(_MAIN, enabled=False) + assert isinstance(parser, SandboxCodeParser) + + +def test_code_node_does_not_pass_process_cwd_as_workspace(): + runner = _ScriptRunner() + assert _run(_MAIN, runner, x=1, y=2) == {"sum": 3, "x": 1} + assert runner.calls[0]["work_dir"] is None + + +def test_local_backend_runs_in_a_fresh_directory(monkeypatch: pytest.MonkeyPatch): + seen: dict = {} + + class _Executor: + def execute_code(self, code=None, timeout=None, filename=None, work_dir=None, lang="python"): + seen["work_dir"] = work_dir + seen["code"] = code + return 0, _OK_LINE, "" + + def close(self): + seen["closed"] = True + + def _build(kind, **kwargs): + seen["kind"] = kind + seen["kwargs"] = kwargs + return _Executor() + + monkeypatch.setattr( + "bisheng.workflow.nodes.code.code_parse.load_code_interpreter_extra", + lambda: {"type": "local", "config": {"local": {"local_sync_path": "/tmp/session"}}}, + ) + monkeypatch.setattr( + "bisheng_langchain.gpts.tools.code_interpreter.factory.build_code_executor", + _build, + ) + parser = SandboxCodeParser(_MAIN) + assert parser.exec_method("main", x=1, y=2) == {"sum": 3, "x": 1} + assert seen["kind"] == "local" + assert seen["work_dir"] + assert seen["work_dir"] != "/tmp/session" + assert "local_sync_path" not in seen["kwargs"] + assert seen["closed"] is True + + +def test_container_backend_does_not_copy_the_worker_cwd(monkeypatch: pytest.MonkeyPatch): + seen: dict = {} + + class _Executor: + def execute_code(self, code=None, timeout=None, filename=None, work_dir=None, lang="python"): + seen["work_dir"] = work_dir + return 0, _OK_LINE, "" + + def close(self): + return None + + def _build(kind, **kwargs): + seen["kind"] = kind + seen["kwargs"] = kwargs + return _Executor() + + monkeypatch.setattr( + "bisheng.workflow.nodes.code.code_parse.load_code_interpreter_extra", + lambda: {"type": "container", "config": {"container": {"keep_session": True, "token": "from-extra"}}}, + ) + monkeypatch.setattr( + "bisheng_langchain.gpts.tools.code_interpreter.factory.build_code_executor", + _build, + ) + parser = SandboxCodeParser(_MAIN) + assert parser.exec_method("main", x=1, y=2) == {"sum": 3, "x": 1} + assert seen["kind"] == "container" + assert seen["work_dir"] is None + assert seen["kwargs"]["keep_session"] is False + + +def test_e2b_backend_forwards_only_credentials(monkeypatch: pytest.MonkeyPatch): + seen: dict = {} + + class _Executor: + def execute_code(self, code=None, timeout=None, filename=None, work_dir=None, lang="python"): + seen["called_execute"] = True + return 0, _OK_LINE, "" + + def run(self, code): + raise AssertionError("code node must not call executor.run") + + def close(self): + return None + + def _build(kind, **kwargs): + seen["kind"] = kind + seen["kwargs"] = kwargs + return _Executor() + + monkeypatch.setattr( + "bisheng.workflow.nodes.code.code_parse.load_code_interpreter_extra", + lambda: { + "type": "e2b", + "config": { + "e2b": { + "api_key": "k", + "domain": "https://e2b.internal", + "type": "private", + "local_sync_path": "/tmp/session", + "file_list": ["a"], + } + }, + }, + ) + monkeypatch.setattr( + "bisheng_langchain.gpts.tools.code_interpreter.factory.build_code_executor", + _build, + ) + parser = SandboxCodeParser(_MAIN) + assert parser.exec_method("main", x=1, y=2) == {"sum": 3, "x": 1} + assert seen["called_execute"] is True + assert seen["kind"] == "e2b" + assert seen["kwargs"]["api_key"] == "k" + assert seen["kwargs"]["domain"] == "https://e2b.internal" + assert seen["kwargs"]["keep_sandbox"] is False + assert "type" not in seen["kwargs"] + assert "local_sync_path" not in seen["kwargs"] + assert "file_list" not in seen["kwargs"] + + +def test_missing_type_defaults_to_local(monkeypatch: pytest.MonkeyPatch): + seen: dict = {} + + class _Executor: + def execute_code(self, code=None, timeout=None, filename=None, work_dir=None, lang="python"): + return 0, _OK_LINE, "" + + def close(self): + return None + + monkeypatch.setattr( + "bisheng.workflow.nodes.code.code_parse.load_code_interpreter_extra", + lambda: {}, + ) + + def _build(kind, **kwargs): + seen["kind"] = kind + return _Executor() + + monkeypatch.setattr( + "bisheng_langchain.gpts.tools.code_interpreter.factory.build_code_executor", + _build, + ) + parser = SandboxCodeParser(_MAIN) + assert parser.exec_method("main", x=1, y=2) == {"sum": 3, "x": 1} + assert seen["kind"] == "local" + + +def test_unknown_type_raises_and_does_not_run_local(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr( + "bisheng.workflow.nodes.code.code_parse.load_code_interpreter_extra", + lambda: {"type": "cloud"}, + ) + + def _build(kind, **kwargs): + raise ValueError(f"Unknown code interpreter type: {kind!r}") + + monkeypatch.setattr( + "bisheng_langchain.gpts.tools.code_interpreter.factory.build_code_executor", + _build, + ) + parser = SandboxCodeParser(_MAIN) + with pytest.raises(ValueError, match="Unknown code interpreter type"): + parser.exec_method("main", x=1, y=2) + + +def test_empty_tool_row_reads_category_type(monkeypatch: pytest.MonkeyPatch): + from types import SimpleNamespace + + from bisheng.tool.domain.models.gpts_tools import GptsToolsDao + from bisheng.workflow.nodes.code.code_parse import load_code_interpreter_extra + + monkeypatch.setattr( + GptsToolsDao, + "get_tool_by_tool_key", + lambda tool_key: SimpleNamespace(extra=None, type=6, tool_key=tool_key), + ) + monkeypatch.setattr( + GptsToolsDao, + "get_one_tool_type", + lambda tool_type_id: SimpleNamespace(extra='{"type": "container", "config": {"container": {}}}'), + ) + assert load_code_interpreter_extra()["type"] == "container" + + +def test_tool_row_type_wins_over_category(monkeypatch: pytest.MonkeyPatch): + from types import SimpleNamespace + + from bisheng.tool.domain.models.gpts_tools import GptsToolsDao + from bisheng.workflow.nodes.code.code_parse import load_code_interpreter_extra + + monkeypatch.setattr( + GptsToolsDao, + "get_tool_by_tool_key", + lambda tool_key: SimpleNamespace(extra='{"type": "local"}', type=6), + ) + + def _category(tool_type_id): + raise AssertionError("category extra must not be read when the tool row has extra") + + monkeypatch.setattr(GptsToolsDao, "get_one_tool_type", _category) + assert load_code_interpreter_extra()["type"] == "local" + + +def test_wrapper_is_a_script_a_runner_can_exec_without_knowing_main(): + script = build_code_node_wrapper(_MAIN, "main", {"x": 10, "y": 5}, nonce=_NONCE) + _, stdout, _ = _ScriptRunner().execute_code(code=script) + assert parse_code_node_result(stdout, _NONCE) == {"sum": 15, "x": 10} diff --git a/src/backend/test/sandbox/test_sandbox_compose.py b/src/backend/test/sandbox/test_sandbox_compose.py new file mode 100644 index 0000000000..55eac683f8 --- /dev/null +++ b/src/backend/test/sandbox/test_sandbox_compose.py @@ -0,0 +1,192 @@ +"""Source-level checks for the sandbox image and compose wiring (F068 T025 / T026). + +Image build and CapDrop inspect still need docker; these assertions catch the +contracts that can be verified without pulling an image. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import yaml +from serve import build_app + +_REPO = Path(__file__).resolve().parents[4] +_DOCKERFILE = _REPO / "docker" / "bisheng-sandbox" / "Dockerfile" +_COMPOSE = _REPO / "docker" / "docker-compose.yml" +_HELM_VALUES = _REPO / "docker" / "helm" / "bisheng-sandbox" / "values.yaml" +_RUNNER_CAPS = {"SETUID", "SETGID", "CHOWN", "DAC_OVERRIDE", "KILL"} +_COPY_IN_TOTAL_DEFAULT = "536870912" + + +def test_serve_requires_non_empty_token(monkeypatch, tmp_path): + monkeypatch.setenv("SANDBOX_TOKEN", "") + monkeypatch.setenv("SANDBOX_SESSIONS_ROOT", str(tmp_path / "sessions")) + with pytest.raises(RuntimeError, match="token"): + build_app() + + +def test_serve_builds_app_from_env(monkeypatch, tmp_path): + monkeypatch.setenv("SANDBOX_TOKEN", "compose-token") + monkeypatch.setenv("SANDBOX_SESSIONS_ROOT", str(tmp_path / "sessions")) + app = build_app() + assert app.state.token == "compose-token" + + +def test_dockerfile_is_single_multiarch_image_without_platform_payload(): + text = _DOCKERFILE.read_text(encoding="utf-8") + assert "FROM python:3.11-slim" in text + assert "libreoffice-impress" in text + assert "libreoffice-calc" in text + assert "fonts-wqy-zenhei" in text + assert "pandoc-3.6.4" in text + assert "ffmpeg" in text + assert "uv sync --frozen --no-dev --no-install-project" in text + assert "COPY src/sandbox-runner" in text or "COPY --chmod=0555 src/sandbox-runner" in text + assert "USER 65534" in text + assert "serve.py" in text + assert "pip install --no-cache-dir uv" in text + assert "mirrors.tuna.tsinghua.edu.cn" in text + assert "astral.sh" not in text + assert "--mount=type=cache,target=/var/lib/apt" not in text + assert "playwright install" not in text + copies = [line for line in text.splitlines() if line.strip().startswith("COPY ")] + joined = "\n".join(copies) + assert "config.yaml" not in joined + assert "entrypoint.sh" not in joined + assert "bisheng/" not in joined + + +def test_compose_named_runners_and_hardening(): + data = yaml.safe_load(_COMPOSE.read_text(encoding="utf-8")) + assert data["networks"]["sandbox_net"]["internal"] is False + worker = data["services"]["backend_worker"] + assert "sandbox_net" in worker["networks"] + assert "default" in worker["networks"] + assert worker["volumes"][0].endswith(":ro") + assert worker["volumes"][1].endswith(":ro") + assert "config.yaml" in worker["volumes"][0] + assert "entrypoint.sh" in worker["volumes"][1] + backend = data["services"]["backend"] + assert backend["volumes"][0].endswith(":ro") + assert backend["volumes"][1].endswith(":ro") + assert "sandbox_net" in backend["networks"] + assert "default" in backend["networks"] + assert "BS_SANDBOX_CONF__TOKEN" in backend["environment"] + expected_endpoints = '["http://code-runner-1:8080","http://code-runner-2:8080"]' + assert backend["environment"]["BS_SANDBOX_CONF__ENDPOINTS"] == expected_endpoints + assert worker["environment"]["BS_SANDBOX_CONF__ENDPOINTS"] == expected_endpoints + assert "DISCOVER" not in " ".join(backend["environment"]) + assert "DISCOVER" not in " ".join(worker["environment"]) + + for name in ("code-runner-1", "code-runner-2"): + svc = data["services"][name] + assert svc["networks"] == ["sandbox_net"] + assert "ports" not in svc + assert "deploy" not in svc + assert svc["user"] == "0" + assert svc["read_only"] is True + assert svc["cap_drop"] == ["ALL"] + assert set(svc.get("cap_add") or []) == _RUNNER_CAPS + # The image's tini is PID 1; compose's init would push it to PID 2. + assert "init" not in svc + assert str(svc["cpus"]) == "${SANDBOX_CPUS:-2}" + assert "no-new-privileges:true" in svc["security_opt"] + mem = str(svc["mem_limit"]).lower() + assert mem.endswith("g") + assert float(mem[:-1]) >= 2 + assert int(svc["pids_limit"]) >= 1 + assert any(str(item).startswith("/tmp") for item in svc["tmpfs"]) + env = svc["environment"] + joined = " ".join(f"{k}={v}" for k, v in env.items()) if isinstance(env, dict) else " ".join(env) + env_map = env if isinstance(env, dict) else {} + assert str(env_map.get("SANDBOX_MAX_SESSIONS")) == "50" + assert str(env_map.get("SANDBOX_MAX_COPY_IN_TOTAL_BYTES")) == _COPY_IN_TOTAL_DEFAULT + assert str(env_map.get("SANDBOX_ENABLE_UID_ISOLATION")).lower() == "true" + assert "minio" not in joined.lower() + assert "mysql" not in joined.lower() + assert "hmac" not in joined.lower() + assert "SANDBOX_TOKEN" in (env if isinstance(env, dict) else joined) + depends = svc.get("depends_on") or {} + assert "mysql" not in depends + assert "redis" not in depends + + +def test_dockerfile_tini_pid1_passwd_and_matplotlibrc(): + text = _DOCKERFILE.read_text(encoding="utf-8") + lines = [line.strip() for line in text.splitlines()] + # tini installed and wrapping the unchanged runner command. + assert "tini \\" in lines + assert 'ENTRYPOINT ["tini", "--"]' in lines + assert lines.index('ENTRYPOINT ["tini", "--"]') < lines.index('CMD ["python", "serve.py"]') + # passwd / group entries for every session uid, written at build time. + assert "$(seq 10000 10999)" in text + assert "sandbox${uid}:x:${uid}:${uid}:" in text and ">> /etc/passwd" in text + assert "sandbox${uid}:x:${uid}:" in text and ">> /etc/group" in text + # CJK font first in matplotlib's sans-serif list. + assert "'font.sans-serif: WenQuanYi Zen Hei, DejaVu Sans'" in text + assert "> /etc/matplotlibrc" in text + assert "ENV MATPLOTLIBRC=/etc/matplotlibrc" in lines + # Build-time steps run as root, before the runtime user is set. + user_at = lines.index("USER 65534") + assert lines.index("ENV MATPLOTLIBRC=/etc/matplotlibrc") < user_at + assert next(i for i, line in enumerate(lines) if "$(seq 10000 10999)" in line) < user_at + + +def test_helm_values_match_compose_hardening(): + values = yaml.safe_load(_HELM_VALUES.read_text(encoding="utf-8")) + caps = values["securityContext"]["capabilities"] + assert caps["drop"] == ["ALL"] + assert set(caps["add"]) == _RUNNER_CAPS + assert values["securityContext"]["readOnlyRootFilesystem"] is True + limits = values["resources"]["limits"] + assert str(limits["cpu"]) == "2" + assert str(limits["memory"]) == "2Gi" + assert values["env"]["SANDBOX_MAX_COPY_IN_TOTAL_BYTES"] == _COPY_IN_TOTAL_DEFAULT + assert values["env"]["SANDBOX_MAX_SESSIONS"] == "50" + + +def test_compose_sessions_root_is_disk_backed_per_replica(): + """Session dirs and the copy-in spool must not sit on tmpfs (charged to mem_limit).""" + data = yaml.safe_load(_COMPOSE.read_text(encoding="utf-8")) + host_dirs = set() + for name in ("code-runner-1", "code-runner-2"): + svc = data["services"][name] + root = svc["environment"]["SANDBOX_SESSIONS_ROOT"] + assert root == "/sessions" + # Not under any tmpfs mount. + tmpfs_paths = [str(item).split(":", 1)[0] for item in svc["tmpfs"]] + assert not any(root == p or root.startswith(p.rstrip("/") + "/") for p in tmpfs_paths) + mounts = [v for v in svc["volumes"] if str(v).endswith(":" + root)] + assert len(mounts) == 1 + host_dirs.add(mounts[0].rsplit(":", 1)[0]) + # Orphan session dirs from a previous run are cleared, then the runner execs. + cmd = svc["command"] + assert cmd[:2] == ["sh", "-c"] + assert f"find {root} -mindepth 1 -delete" in cmd[2] + assert cmd[2].rstrip().endswith("exec python serve.py") + # Each replica wipes its own directory at start; sharing one would wipe the other's sessions. + assert len(host_dirs) == 2 + + +def test_env_example_documents_sandbox_cpus(): + text = (_REPO / "docker" / ".env.example").read_text(encoding="utf-8") + assert "SANDBOX_CPUS" in text + + +def test_gateway_startup_includes_object_storage_ready_for_api_and_worker(): + services = yaml.safe_load(_COMPOSE.read_text(encoding="utf-8"))["services"] + pending = ["gateway"] + reachable = set() + while pending: + name = pending.pop() + if name in reachable: + continue + reachable.add(name) + pending.extend(services[name].get("depends_on") or []) + + assert "minio" in reachable + assert services["minio"]["healthcheck"]["test"] + for name in ("backend", "backend_worker"): + assert services[name]["depends_on"]["minio"]["condition"] == "service_healthy" diff --git a/src/backend/test/sandbox/test_sandbox_conf.py b/src/backend/test/sandbox/test_sandbox_conf.py new file mode 100644 index 0000000000..daa73d4161 --- /dev/null +++ b/src/backend/test/sandbox/test_sandbox_conf.py @@ -0,0 +1,71 @@ +"""Sandbox configuration (F068 isolation-environment substrate).""" + +from __future__ import annotations + +import os + +import pytest + +from bisheng.core.config.settings import SandboxConf, Settings + +_ENV_PREFIX = "BS_SANDBOX_CONF__" + + +@pytest.fixture +def clean_sandbox_env(monkeypatch: pytest.MonkeyPatch) -> None: + for key in list(os.environ): + if key.startswith(_ENV_PREFIX): + monkeypatch.delenv(key, raising=False) + + +def test_sandbox_conf_defaults_without_yaml_section(clean_sandbox_env: None) -> None: + settings = Settings() + conf = settings.sandbox_conf + assert "discover_host_pattern" not in SandboxConf.model_fields + assert "discover_index_start" not in SandboxConf.model_fields + assert "discover_ttl_s" not in SandboxConf.model_fields + assert "discover_port" not in SandboxConf.model_fields + assert "discover_max" not in SandboxConf.model_fields + assert "code_node_enabled" not in SandboxConf.model_fields + assert conf.endpoints == [] + + +def test_sandbox_conf_ignores_retired_discover_env(clean_sandbox_env: None, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(f"{_ENV_PREFIX}DISCOVER_HOST_PATTERN", "code-runner-{n}.code-runner") + settings = Settings() + assert not hasattr(settings.sandbox_conf, "discover_host_pattern") + assert settings.sandbox_conf.endpoints == [] + + +def test_sandbox_conf_has_no_orchestrator_enum(clean_sandbox_env: None) -> None: + assert "deploy_mode" not in SandboxConf.model_fields + assert "orchestrator" not in SandboxConf.model_fields + settings = Settings(sandbox_conf={"deploy_mode": "k8s", "orchestrator": "compose"}) + assert not hasattr(settings.sandbox_conf, "deploy_mode") + assert not hasattr(settings.sandbox_conf, "orchestrator") + dumped = settings.sandbox_conf.model_dump() + assert "deploy_mode" not in dumped + assert "orchestrator" not in dumped + + +def test_sandbox_conf_endpoints_default_empty(clean_sandbox_env: None) -> None: + settings = Settings() + assert isinstance(settings.sandbox_conf.endpoints, list) + assert settings.sandbox_conf.endpoints == [] + + +def test_sandbox_conf_ignores_runner_only_knobs(clean_sandbox_env: None) -> None: + assert "pool_lease_ttl_s" not in SandboxConf.model_fields + assert "max_sessions_per_replica" not in SandboxConf.model_fields + assert "enable_uid_isolation" not in SandboxConf.model_fields + settings = Settings( + sandbox_conf={ + "pool_lease_ttl_s": 60, + "max_sessions_per_replica": 2, + "enable_uid_isolation": False, + } + ) + dumped = settings.sandbox_conf.model_dump() + assert "pool_lease_ttl_s" not in dumped + assert "max_sessions_per_replica" not in dumped + assert "enable_uid_isolation" not in dumped diff --git a/src/backend/test/sandbox/test_sandbox_conf_api.py b/src/backend/test/sandbox/test_sandbox_conf_api.py new file mode 100644 index 0000000000..9d1daa31ec --- /dev/null +++ b/src/backend/test/sandbox/test_sandbox_conf_api.py @@ -0,0 +1,82 @@ +"""Sandbox config HTTP routes (F086).""" + +from __future__ import annotations + +import asyncio + +import pytest +from fastapi.routing import APIRoute + +from bisheng.common.dependencies.user_deps import UserPayload +from bisheng.common.errcode.http_error import UnAuthorizedError +from bisheng.tool.api import tool as tool_api +from bisheng.tool.domain.services.sandbox_conf_service import SandboxConfService + + +def _routes() -> dict[str, set[str]]: + found: dict[str, set[str]] = {} + for route in tool_api.router.routes: + if isinstance(route, APIRoute): + found.setdefault(route.path, set()).update(route.methods or set()) + return found + + +def _user(is_super: bool) -> UserPayload: + return UserPayload(user_id=1, user_name="tester", user_role=[], is_global_super=is_super) + + +def test_routes_are_on_the_tool_router(): + routes = _routes() + assert "GET" in routes["/tool/sandbox_conf"] + assert "POST" in routes["/tool/sandbox_conf"] + assert "POST" in routes["/tool/sandbox_conf/test"] + + +def test_non_super_is_rejected_and_does_not_write(monkeypatch: pytest.MonkeyPatch): + writes = [] + + async def insert(*args, **kwargs): + writes.append(args) + + monkeypatch.setattr("bisheng.common.models.config.ConfigDao.insert_or_update_config", insert) + user = _user(False) + body = tool_api.SandboxEndpointBody(endpoints=["http://h:8080"], token="x") + with pytest.raises(UnAuthorizedError): + asyncio.run(tool_api.get_sandbox_conf(user)) + with pytest.raises(UnAuthorizedError): + asyncio.run(tool_api.save_sandbox_conf(None, body, user)) + with pytest.raises(UnAuthorizedError): + asyncio.run(tool_api.test_sandbox_conf(body, user)) + assert writes == [] + + +def test_super_delegates_and_save_does_not_require_a_probe(monkeypatch: pytest.MonkeyPatch): + called = {"test": 0} + user = _user(True) + monkeypatch.setattr(SandboxConfService, "get", staticmethod(lambda: {"endpoints": ["http://a:8080"], "token": ""})) + + async def save(endpoints, token, actor, ip): + called["save"] = (endpoints, token, actor.is_global_super, ip) + + async def probe(endpoints, token): + called["test"] += 1 + return {"results": [{"url": endpoints[0], "status": "ok"}]} + + monkeypatch.setattr(SandboxConfService, "save", staticmethod(save)) + monkeypatch.setattr(SandboxConfService, "test", staticmethod(probe)) + monkeypatch.setattr(tool_api, "get_request_ip", lambda request: "10.0.0.1") + + viewed = asyncio.run(tool_api.get_sandbox_conf(user)) + assert viewed.status_code == 200 + assert viewed.data["endpoints"] == ["http://a:8080"] + + body = tool_api.SandboxEndpointBody(endpoints=["http://h:8080"], token="secret") + saved = asyncio.run(tool_api.save_sandbox_conf(None, body, user)) + assert saved.status_code == 200 + assert called["test"] == 0 + assert called["save"] == (["http://h:8080"], "secret", True, "10.0.0.1") + + probed = asyncio.run(tool_api.test_sandbox_conf(body, user)) + assert probed.status_code == 200 + assert probed.data["results"][0]["status"] == "ok" + assert called["test"] == 1 diff --git a/src/backend/test/sandbox/test_sandbox_conf_probe.py b/src/backend/test/sandbox/test_sandbox_conf_probe.py new file mode 100644 index 0000000000..7934d8e156 --- /dev/null +++ b/src/backend/test/sandbox/test_sandbox_conf_probe.py @@ -0,0 +1,168 @@ +"""Sandbox connection probe classification (F086).""" + +from __future__ import annotations + +import json + +import httpx +import pytest + +from bisheng.common.errcode.sandbox import SandboxEndpointInvalidError +from bisheng.core.config.settings import SandboxConf, encrypt_token +from bisheng.tool.domain.services.sandbox_conf_service import SandboxConfService + +_REAL_ASYNC_CLIENT = httpx.AsyncClient + + +def _enc(token: str) -> str: + raw = encrypt_token(token) + return raw.decode() if isinstance(raw, bytes) else raw + + +class _Row: + def __init__(self, value: str): + self.value = value + + +def _install(monkeypatch, handler, rows=None, deployment=None): + requests: list[httpx.Request] = [] + timeouts: list = [] + + def wrapped(request: httpx.Request) -> httpx.Response: + requests.append(request) + return handler(request) + + transport = httpx.MockTransport(wrapped) + + def factory(*args, **kwargs): + timeouts.append(kwargs.get("timeout")) + kwargs["transport"] = transport + return _REAL_ASYNC_CLIENT(*args, **kwargs) + + monkeypatch.setattr("bisheng.tool.domain.services.sandbox_conf_service.httpx.AsyncClient", factory) + monkeypatch.setattr( + "bisheng.tool.domain.services.sandbox_conf_service.settings.sandbox_conf", + deployment if deployment is not None else SandboxConf(endpoints=["http://dep:8080"], token="dep-token"), + ) + + def get_config(key): + if not rows: + return None + return _Row(rows) + + monkeypatch.setattr("bisheng.tool.domain.services.sandbox_conf_service.ConfigDao.get_config", get_config) + return requests, timeouts + + +@pytest.mark.parametrize( + "endpoints", + [ + ["ftp://h"], + ["http://"], + ["http://h:99999"], + ["http://h:8080/", "http://h:8080"], + ["http://h:8080"] * 33, + [""], + ], +) +async def test_invalid_address_sends_nothing(monkeypatch, endpoints): + requests, _timeouts = _install(monkeypatch, lambda request: httpx.Response(200)) + with pytest.raises(SandboxEndpointInvalidError): + await SandboxConfService.test(endpoints, "tok") + assert requests == [] + + +async def test_ok_strips_slash_and_deletes_lease(monkeypatch): + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/health": + return httpx.Response(200, json={"status": "ok", "body": "secret-health"}) + if request.method == "POST": + return httpx.Response(200, json={"session_id": "s1", "lease_token": "lease-1", "body": "secret-session"}) + assert request.headers["x-lease-token"] == "lease-1" + return httpx.Response(200, json={"ok": True}) + + requests, timeouts = _install(monkeypatch, handler) + result = await SandboxConfService.test(["http://host:8080/"], "typed-token") + assert result == {"results": [{"url": "http://host:8080", "status": "ok"}]} + assert str(requests[0].url) == "http://host:8080/health" + assert requests[1].method == "POST" + assert str(requests[1].url) == "http://host:8080/v1/sessions" + assert requests[1].headers["authorization"] == "Bearer typed-token" + assert requests[2].method == "DELETE" + assert requests[2].url.path == "/v1/sessions/s1" + assert "secret-health" not in json.dumps(result) + assert "secret-session" not in json.dumps(result) + assert timeouts == [5.0] + + +async def test_health_failure_does_not_open_a_session(monkeypatch): + requests, _timeouts = _install(monkeypatch, lambda request: httpx.Response(500)) + result = await SandboxConfService.test(["http://host:8080"], "tok") + assert result["results"] == [{"url": "http://host:8080", "status": "unreachable"}] + assert [request.method for request in requests] == ["GET"] + + +async def test_timeout_is_unreachable(monkeypatch): + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.TimeoutException("slow") + + _install(monkeypatch, handler) + result = await SandboxConfService.test(["http://host:8080"], "tok") + assert result["results"][0]["status"] == "unreachable" + + +async def test_401_and_503(monkeypatch): + def rejected(request: httpx.Request) -> httpx.Response: + if request.url.path == "/health": + return httpx.Response(200) + return httpx.Response(401, json={"error": "no"}) + + requests, _timeouts = _install(monkeypatch, rejected) + result = await SandboxConfService.test(["http://host:8080"], "bad") + assert result["results"][0]["status"] == "token_rejected" + assert not any(request.method == "DELETE" for request in requests) + + def busy(request: httpx.Request) -> httpx.Response: + if request.url.path == "/health": + return httpx.Response(200) + return httpx.Response(503, json={"error": "full"}) + + requests, _timeouts = _install(monkeypatch, busy) + result = await SandboxConfService.test(["http://host:8080"], "tok") + assert result["results"][0]["status"] == "busy" + assert "full" not in json.dumps(result) + + +async def test_empty_addresses_probe_deployment(monkeypatch): + requests, _timeouts = _install(monkeypatch, lambda request: httpx.Response(500)) + result = await SandboxConfService.test([], "tok") + assert str(requests[0].url) == "http://dep:8080/health" + assert result["results"][0]["url"] == "http://dep:8080" + + +async def test_empty_everywhere_returns_no_results(monkeypatch): + requests, _timeouts = _install(monkeypatch, lambda request: httpx.Response(200), deployment=SandboxConf()) + result = await SandboxConfService.test([], "") + assert result == {"results": []} + assert requests == [] + + +async def test_mask_uses_saved_token_then_deployment(monkeypatch): + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/health": + return httpx.Response(200) + assert request.headers["authorization"] == "Bearer saved-token" + return httpx.Response(401) + + stored = json.dumps({"endpoints": ["http://host:8080"], "token_enc": _enc("saved-token")}) + _install(monkeypatch, handler, rows=stored) + await SandboxConfService.test(["http://host:8080"], "********") + + def deployment_handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/health": + return httpx.Response(200) + assert request.headers["authorization"] == "Bearer dep-token" + return httpx.Response(401) + + _install(monkeypatch, deployment_handler) + await SandboxConfService.test(["http://host:8080"], "********") diff --git a/src/backend/test/sandbox/test_sandbox_conf_service.py b/src/backend/test/sandbox/test_sandbox_conf_service.py new file mode 100644 index 0000000000..fa74edf0f4 --- /dev/null +++ b/src/backend/test/sandbox/test_sandbox_conf_service.py @@ -0,0 +1,212 @@ +"""Sandbox address save and dialog read model (F086).""" + +from __future__ import annotations + +import json +from types import SimpleNamespace + +import httpx +import pytest + +from bisheng.api.services.audit_log import AuditLogService +from bisheng.common.errcode.sandbox import SandboxEndpointInvalidError +from bisheng.core.config.settings import SandboxConf, encrypt_token +from bisheng.database.models.audit_log import EventType, ObjectType +from bisheng.tool.domain.services.sandbox_conf_service import SandboxConfService + +_USER = SimpleNamespace(user_id=1, user_name="admin") + + +def _enc(token: str) -> str: + raw = encrypt_token(token) + return raw.decode() if isinstance(raw, bytes) else raw + + +class _Row: + def __init__(self, value: str): + self.value = value + + +@pytest.fixture +def saved(monkeypatch: pytest.MonkeyPatch) -> dict: + holder = { + "rows": {}, + "writes": [], + "cache": [], + "audits": [], + "cache_error": None, + } + + def get_config(key): + value = holder["rows"].get(key.value) + if value is None: + return None + return _Row(value) + + async def insert(key, value): + holder["writes"].append((key, value)) + holder["rows"][key] = value + return _Row(value) + + def write(payload): + if holder["cache_error"]: + raise holder["cache_error"] + holder["cache"].append(payload) + + def audit(user, ip, note): + holder["audits"].append((user, ip, note)) + + monkeypatch.setattr("bisheng.tool.domain.services.sandbox_conf_service.ConfigDao.get_config", get_config) + monkeypatch.setattr( + "bisheng.tool.domain.services.sandbox_conf_service.ConfigDao.insert_or_update_config", + insert, + ) + monkeypatch.setattr( + "bisheng.tool.domain.services.sandbox_conf_service.write_sandbox_endpoint_cache", + write, + ) + monkeypatch.setattr( + "bisheng.tool.domain.services.sandbox_conf_service.AuditLogService.update_sandbox_conf", + audit, + ) + monkeypatch.setattr( + "bisheng.tool.domain.services.sandbox_conf_service.settings.sandbox_conf", + SandboxConf(endpoints=["http://dep:8080"], token="dep-token"), + ) + return holder + + +async def test_save_stores_json_string_without_plaintext(saved): + await SandboxConfService.save(["http://h:8080/"], "secret-token", _USER, "10.0.0.1") + key, value = saved["writes"][0] + assert key == "sandbox_endpoint" + parsed = json.loads(value) + assert "tenant_id" not in parsed + assert parsed["endpoints"] == ["http://h:8080"] + assert parsed["token_enc"] != "secret-token" + assert "secret-token" not in value + assert saved["cache"][0] == parsed + assert isinstance(saved["cache"][0], dict) + assert "secret-token" not in saved["cache"][0]["token_enc"] + assert saved["audits"] == [(_USER, "10.0.0.1", "endpoints,已修改")] + + +@pytest.mark.parametrize( + "endpoints", + [ + ["ftp://h"], + ["http://"], + ["http://h:99999"], + ["http://h:8080/", "http://h:8080"], + ["http://h:8080"] * 33, + [""], + [" "], + ], +) +async def test_invalid_address_does_not_write(saved, endpoints): + with pytest.raises(SandboxEndpointInvalidError): + await SandboxConfService.save(endpoints, "secret-token", _USER, "10.0.0.1") + assert saved["writes"] == [] + assert saved["cache"] == [] + assert "secret-token" not in json.dumps(saved["audits"]) + + +async def test_mask_keeps_ciphertext_and_empty_clears_it(saved): + stored = {"endpoints": ["http://h:8080"], "token_enc": _enc("kept-token")} + saved["rows"]["sandbox_endpoint"] = json.dumps(stored) + await SandboxConfService.save(["http://h:8080"], "********", _USER, "10.0.0.1") + assert json.loads(saved["writes"][-1][1])["token_enc"] == stored["token_enc"] + assert saved["audits"][-1][2] == "" + await SandboxConfService.save(["http://h:8080"], "", _USER, "10.0.0.1") + assert json.loads(saved["writes"][-1][1])["token_enc"] == "" + assert saved["audits"][-1][2] == "已修改" + assert "kept-token" not in saved["writes"][-1][1] + + +async def test_cache_write_failure_still_saves(saved): + saved["cache_error"] = RuntimeError("redis") + await SandboxConfService.save(["http://h:8080"], "secret-token", _USER, "10.0.0.1") + assert saved["writes"] + assert saved["audits"] + assert saved["cache"] == [] + + +async def test_save_does_not_call_http(saved, monkeypatch): + def boom(*args, **kwargs): + raise AssertionError("save must not open an HTTP client") + + monkeypatch.setattr(httpx, "AsyncClient", boom) + monkeypatch.setattr("bisheng.tool.domain.services.sandbox_conf_service.httpx.AsyncClient", boom) + await SandboxConfService.save(["http://h:8080"], "secret-token", _USER, "10.0.0.1") + + +def test_get_empty_page_keeps_deployment_out_of_the_input(saved): + view = SandboxConfService.get() + assert view["endpoints"] == [] + assert view["token"] == "" + assert view["address_source"] == "deployment" + assert view["token_source"] == "deployment" + assert view["deployment_address"] == "http://dep:8080" + + +@pytest.mark.parametrize("raw", ["{", "[]", "null", '{"endpoints": [1], "token_enc": ""}', "{}"]) +def test_get_bad_structure_looks_like_no_page_config(saved, raw): + saved["rows"]["sandbox_endpoint"] = raw + view = SandboxConfService.get() + assert view["endpoints"] == [] + assert view["token"] == "" + assert view["address_source"] == "deployment" + assert view["token_source"] == "deployment" + + +def test_sources_are_per_field(saved, monkeypatch): + saved["rows"]["sandbox_endpoint"] = json.dumps({"endpoints": ["http://page:8080"], "token_enc": ""}) + view = SandboxConfService.get() + assert view["endpoints"] == ["http://page:8080"] + assert view["address_source"] == "page" + assert view["token_source"] == "deployment" + assert view["token"] == "" + + saved["rows"]["sandbox_endpoint"] = json.dumps({"endpoints": [], "token_enc": _enc("page-token")}) + view = SandboxConfService.get() + assert view["endpoints"] == [] + assert view["token"] == "********" + assert view["token_source"] == "page" + assert view["address_source"] == "deployment" + assert "page-token" not in json.dumps(view) + + monkeypatch.setattr( + "bisheng.tool.domain.services.sandbox_conf_service.settings.sandbox_conf", + SandboxConf(), + ) + saved["rows"].clear() + view = SandboxConfService.get() + assert view["address_source"] == "default" + assert view["token_source"] == "default" + assert view["deployment_address"] == "" + + +def test_audit_uses_empty_groups_and_sandbox_object(monkeypatch): + captured = {} + + def fake_system(user, ip, group_ids, event_type, object_type, object_id, object_name, note=""): + captured.update( + group_ids=group_ids, + event_type=event_type, + object_type=object_type, + object_id=object_id, + object_name=object_name, + note=note, + ) + + monkeypatch.setattr( + AuditLogService, "_system_log", classmethod(lambda cls, *args, **kwargs: fake_system(*args, **kwargs)) + ) + AuditLogService.update_sandbox_conf(_USER, "10.0.0.1", "endpoints") + assert captured["group_ids"] == [] + assert captured["event_type"] == EventType.UPDATE_SANDBOX_CONF + assert captured["object_type"] == ObjectType.TOOL + assert captured["object_id"] == "sandbox_endpoint" + assert captured["object_name"] == "sandbox_endpoint" + assert captured["note"] == "endpoints" + assert "secret" not in captured["note"] diff --git a/src/backend/test/sso/test_sso_departments_sync_service.py b/src/backend/test/sso/test_sso_departments_sync_service.py index 37246147a2..e5e0fc3893 100644 --- a/src/backend/test/sso/test_sso_departments_sync_service.py +++ b/src/backend/test/sso/test_sso_departments_sync_service.py @@ -70,8 +70,78 @@ def _dept( # ========================================================================= +@pytest.mark.parametrize( + ("relationships", "expected_order"), + [ + ([], []), + ([("LEAF", "CHILD"), ("CHILD", "ROOT"), ("ROOT", None)], ["ROOT", "CHILD", "LEAF"]), + ([("B", None), ("A", None)], ["B", "A"]), + ([("CHILD", "MISSING"), ("ROOT", None)], ["CHILD", "ROOT"]), + ([("A", "B"), ("B", "A"), ("ROOT", None)], ["ROOT", "A", "B"]), + ([("SELF", "SELF")], ["SELF"]), + ], +) +def test_parent_first_order_retains_unresolvable_items(relationships, expected_order): + """Ordering must keep malformed items for the existing per-item error handler.""" + from bisheng.sso_sync.domain.services.departments_sync_service import ( + DepartmentsSyncService, + ) + + items = [ + DepartmentUpsertItem(external_id=external_id, name=external_id, parent_external_id=parent_id) + for external_id, parent_id in relationships + ] + ordered = DepartmentsSyncService._parent_first_upserts(items) + + assert [item.external_id for item in ordered] == expected_order + assert [item.external_id for item in items] == [external_id for external_id, _ in relationships] + + @pytest.mark.asyncio class TestUpsertBatch: + async def test_child_before_parent_is_applied_in_parent_first_order(self): + """A flat Gateway payload need not be ordered like the org tree.""" + from bisheng.sso_sync.domain.services.departments_sync_service import ( + DepartmentsSyncService, + ) + + payload = DepartmentsSyncRequest( + upsert=[ + DepartmentUpsertItem(external_id="CHILD", name="Child", parent_external_id="PARENT"), + DepartmentUpsertItem(external_id="PARENT", name="Parent"), + ], + remove=[], + ) + applied: list[str] = [] + + async def fake_get(source, external_id): + if external_id in applied: + return _dept(external_id) + return None + + async def fake_upsert(**kwargs): + item = kwargs["item"] + if item.parent_external_id and item.parent_external_id not in applied: + raise RuntimeError("parent missing") + applied.append(item.external_id) + return _dept(item.external_id) + + with ( + patch( + f"{MODULE}.DepartmentDao.aget_by_source_external_id", + new=fake_get, + ), + patch( + f"{MODULE}.DeptUpsertService.upsert_from_sync_payload", + new=fake_upsert, + ), + ): + result = await DepartmentsSyncService.execute(payload) + + assert applied == ["PARENT", "CHILD"] + assert result.applied_upsert == 2 + assert result.errors == [] + async def test_happy_path_new_items_applied(self): from bisheng.sso_sync.domain.services.departments_sync_service import ( DepartmentsSyncService, diff --git a/src/backend/test/telemetry_search/test_model_event_aggregation.py b/src/backend/test/telemetry_search/test_model_event_aggregation.py new file mode 100644 index 0000000000..2c8dad4725 --- /dev/null +++ b/src/backend/test/telemetry_search/test_model_event_aggregation.py @@ -0,0 +1,247 @@ +"""Event metrics must not count the per-minute copies used for concurrency.""" + +import os +from uuid import uuid4 + +import pytest +from elasticsearch import Elasticsearch, helpers + +from bisheng.telemetry_search.domain.models.dashboard_dataset import MetricConfig +from bisheng.telemetry_search.domain.schemas.component import AggregationType, ComponentDataConfig +from bisheng.telemetry_search.domain.schemas.query_builder import ( + AggregationExpression, + AggsTypeEnum, + FilterExpression, + RangeOp, + RangeValue, + TermOp, +) +from bisheng.telemetry_search.domain.services.component import DataQueryService +from bisheng.telemetry_search.domain.services.search_engine_service import ( + QueryBuilder, + SearchEngineService, + SearchParameters, +) + + +def _parameters(field="total_token", agg_type=AggsTypeEnum.SUM, **kwargs): + return SearchParameters( + index_name=kwargs.pop("index_name", "mid_model_call_dtl"), + metrics=[AggregationExpression(name=field, field=field, type=agg_type)], + dimensions=kwargs.pop("dimensions", []), + **kwargs, + ) + + +@pytest.mark.parametrize("field", ["total_token", "first_token_cost_time"]) +def test_event_metrics_filter_representative_before_dimension_aggregation(field): + params = _parameters( + field, + dimensions=[AggregationExpression(name="app", field="app_name", type=AggsTypeEnum.TERMS)], + filters=[FilterExpression(bool_operator="must", filters=[TermOp(field="app_name", value="daily_chat")])], + ) + dsl = QueryBuilder(params).build_query_dsl() + assert dsl["query"]["bool"]["must"] == [{"term": {"app_name": "daily_chat"}}] + assert dsl["query"]["bool"]["filter"][0]["script"]["script"]["lang"] == "painless" + assert dsl["aggs"]["dimension_0"]["aggs"]["metric_0"] == {"sum": {"field": field}} + assert "event_id" not in str(dsl) # No high-cardinality event buckets or in-memory ID map. + assert params.filters[0].to_dsl() == {"must": [{"term": {"app_name": "daily_chat"}}]} + + +@pytest.mark.parametrize( + ("index_name", "field", "agg_type"), + [ + ("mid_other", "total_token", AggsTypeEnum.SUM), + ("mid_model_call_dtl", "event_id", AggsTypeEnum.CARDINALITY), + ("mid_model_call_dtl", "minute_ts", AggsTypeEnum.DATE_HISTOGRAM), + ], +) +def test_other_indices_call_counts_and_concurrency_keep_all_rows(index_name, field, agg_type): + params = _parameters(field, agg_type, index_name=index_name) + if field == "minute_ts": + params.metrics[0].custom_params = {"fixed_interval": "1m"} + params.metrics[0].aggs = [AggregationExpression(name="online", field="event_id", type=AggsTypeEnum.CARDINALITY)] + assert QueryBuilder(params).build_query_dsl()["query"] == {"match_all": {}} + + +async def test_existing_non_virtual_dataset_metric_automatically_gets_filter(monkeypatch): + queries = [] + + async def capture_search(self): + queries.append(self.build_search_query()) + return [[2494]] + + monkeypatch.setattr(SearchEngineService, "search", capture_search) + service = DataQueryService(dataset_code="mid_model_call_dtl", data_config=ComponentDataConfig()) + metric = MetricConfig(field="total_token", name="tokens", is_virtual=False) + assert await service.query_one_metric( + metric, AggregationType.SUM, -1, index_name="mid_model_call_dtl", dimensions=[], filters=None + ) == [[2494]] + assert "script" in queries[0]["query"]["bool"]["filter"][0] + assert queries[0]["aggs"] == {"metric_0": {"sum": {"field": "total_token"}}} + + +@pytest.fixture +def es_event_index(): + """Opt-in middleware test; only creates/deletes a uniquely named test index.""" + url = os.getenv("TELEMETRY_TEST_ES_URL") + if not url: + pytest.skip("Set TELEMETRY_TEST_ES_URL to an isolated test Elasticsearch") + client = Elasticsearch(url) + name = f"test-model-event-{uuid4().hex}" + yield client, name + if client.indices.exists(index=name): + client.indices.delete(index=name) + client.close() + + +@pytest.mark.parametrize( + "end_format", ["epoch_millis", "epoch_second", "strict_date_optional_time||epoch_second||epoch_millis"] +) +def test_real_es_counts_each_invocation_once_with_legacy_and_correct_dates(es_event_index, end_format): + client, index = es_event_index + client.indices.create( + index=index, + mappings={ + "properties": { + "event_id": {"type": "keyword"}, + "app_name": {"type": "keyword"}, + "timestamp": {"type": "date", "format": "epoch_second"}, + "minute_ts": {"type": "date", "format": "epoch_millis"}, + "end_time": {"type": "date", "format": end_format}, + "total_token": {"type": "long"}, + "first_token_cost_time": {"type": "long"}, + "user_group_infos": { + "type": "nested", + "properties": {"user_group_name": {"type": "keyword"}}, + }, + } + }, + ) + # Two distinct invocations with equal Token values must both count. Only + # the first crosses a minute boundary; otherwise AVG would weight it twice. + end = 1791949504 + minute = end // 60 * 60000 + docs = [ + { + "event_id": "a", + "end_time": end, + "minute_ts": minute - 60000, + "total_token": 100, + "first_token_cost_time": 100, + }, + {"event_id": "a", "end_time": end, "minute_ts": minute, "total_token": 100, "first_token_cost_time": 100}, + {"event_id": "b", "end_time": end, "minute_ts": minute, "total_token": 100, "first_token_cost_time": 900}, + # Ending exactly on a minute boundary still has an ending-minute copy. + { + "event_id": "c", + "end_time": end - 4, + "minute_ts": minute - 60000, + "total_token": 5, + "first_token_cost_time": 200, + }, + {"event_id": "c", "end_time": end - 4, "minute_ts": minute, "total_token": 5, "first_token_cost_time": 200}, + # Old, unexpanded events have no minute_ts and must remain visible. + {"event_id": "legacy", "total_token": 7, "first_token_cost_time": 400}, + ] + helpers.bulk( + client, + [ + { + "_index": index, + "_source": { + "app_name": "daily_chat", + "timestamp": end, + "user_group_infos": [{"user_group_name": "group-a"}, {"user_group_name": "group-b"}], + **doc, + }, + } + for doc in docs + ], + refresh=True, + ) + assert client.count(index=index)["count"] == 6 + + def query(params): + return client.search(index=index, body=QueryBuilder(params).build_query_dsl())["aggregations"] + + assert query(_parameters())["metric_0"]["value"] == 212 + avg = query(_parameters("first_token_cost_time", AggsTypeEnum.AVG)) + assert avg["metric_0"]["value"] == 400 + + grouped = _parameters( + dimensions=[AggregationExpression(name="app", field="app_name", type=AggsTypeEnum.TERMS)], + filters=[ + FilterExpression( + bool_operator="filter", + # Component TimeFilter converts seconds to milliseconds. + filters=[RangeOp(field="timestamp", value=RangeValue(gte=end * 1000, lte=end * 1000))], + ) + ], + ) + buckets = query(grouped)["dimension_0"]["buckets"] + assert [(bucket["key"], bucket["metric_0"]["value"]) for bucket in buckets] == [("daily_chat", 212)] + + # A nested user-group dimension returns to the parent document before + # aggregating Token values; the representative-row filter must still hold. + nested = _parameters( + dimensions=[ + AggregationExpression(name="group", field="user_group_infos.user_group_name", type=AggsTypeEnum.TERMS) + ], + stack_dimension=AggregationExpression(name="app", field="app_name", type=AggsTypeEnum.TERMS), + ) + response = client.search(index=index, body=QueryBuilder(nested).build_query_dsl()) + from bisheng.telemetry_search.domain.services.search_engine_service import ResultParser + + assert sorted(ResultParser(nested).parse_to_2d_array(response.body)) == [ + ["group-a", "daily_chat", 212], + ["group-b", "daily_chat", 212], + ] + + # The concurrent-call metric must still see both minute buckets. + concurrent = _parameters("minute_ts", AggsTypeEnum.DATE_HISTOGRAM) + concurrent.metrics[0].custom_params = {"fixed_interval": "1m"} + concurrent.metrics[0].aggs = [AggregationExpression(name="online", field="event_id", type=AggsTypeEnum.CARDINALITY)] + assert [bucket["metric_0_0"]["value"] for bucket in query(concurrent)["metric_0"]["buckets"]] == [2, 3] + + +def test_real_es_unexpanded_index_without_minute_mapping(es_event_index): + client, index = es_event_index + client.index(index=index, document={"total_token": 123}, refresh=True) + dsl = QueryBuilder(_parameters()).build_query_dsl() + assert client.search(index=index, body=dsl)["aggregations"]["metric_0"]["value"] == 123 + + +@pytest.mark.slow +def test_real_es_large_event_volume_needs_no_event_id_buckets(es_event_index): + if os.getenv("TELEMETRY_TEST_ES_LARGE") != "1": + pytest.skip("Set TELEMETRY_TEST_ES_LARGE=1 to test more than 100,000 invocations") + client, index = es_event_index + client.indices.create( + index=index, + mappings={ + "properties": { + "event_id": {"type": "keyword"}, + "minute_ts": {"type": "date", "format": "epoch_millis"}, + "end_time": {"type": "date", "format": "epoch_millis"}, + "total_token": {"type": "long"}, + } + }, + ) + event_count = 100001 + + def actions(): + for event in range(event_count): + end = 1791949504 - event % 180 * 86400 + minute = end // 60 * 60000 + for minute_ts in (minute - 60000, minute): + yield { + "_index": index, + "_source": {"event_id": str(event), "end_time": end, "minute_ts": minute_ts, "total_token": 1}, + } + + helpers.bulk(client, actions(), refresh=True) + assert client.count(index=index)["count"] == event_count * 2 + dsl = QueryBuilder(_parameters()).build_query_dsl() + result = client.search(index=index, body=dsl) + assert result["aggregations"]["metric_0"]["value"] == event_count diff --git a/src/backend/test/tenant/test_f115_visibility_subjects.py b/src/backend/test/tenant/test_f115_visibility_subjects.py new file mode 100644 index 0000000000..df86ea8b6a --- /dev/null +++ b/src/backend/test/tenant/test_f115_visibility_subjects.py @@ -0,0 +1,69 @@ +"""F115 AC-23/24/25/27/28/29/37/52: business-owned current subject facts.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + + +def source(kind, ident, relation=None, children=False): + return SimpleNamespace( + subject_type=kind, subject_id=str(ident), userset_relation=relation, include_children=children + ) + + +async def test_current_directory_filters_inactive_and_keeps_usersets(monkeypatch): + from bisheng.tenant.domain.services import f048_permission_subject as module + + directory = module.TenantPermissionSubjectDirectory() + directory._summary_queries = SimpleNamespace( + load=AsyncMock( + return_value={ + ("department", "3"): SimpleNamespace(tenant_id=60, name="Dept"), + ("user_group", "4"): SimpleNamespace(tenant_id=60, group_name="Group"), + } + ) + ) + monkeypatch.setattr( + module, + "read_current_person_facts", + AsyncMock( + return_value={ + 1: {"state": "active", "tenant_id": 60, "name": "Owner"}, + 2: {"state": "inactive", "tenant_id": None, "name": "Disabled"}, + } + ), + ) + rows = await directory.describe_active_sources( + tenant_id=60, + sources=( + source("user", 1), + source("user", 2), + source("department", 3, "subtree_member", True), + source("user_group", 4, "admin"), + source("service_account", 5), + ), + ) + assert [row["type"] for row in rows] == ["user", "department", "user_group"] + assert rows[-1]["userset_relation"] == "admin" + + +async def test_ambiguous_person_does_not_mean_empty_authorization(monkeypatch): + from bisheng.common.errcode.permission import PermissionSubjectUnavailableError + from bisheng.tenant.domain.services import f048_permission_subject as module + + directory = module.TenantPermissionSubjectDirectory() + directory._summary_queries = SimpleNamespace(load=AsyncMock(return_value={})) + monkeypatch.setattr(module, "read_current_person_facts", AsyncMock(return_value={1: {"state": "ambiguous"}})) + with pytest.raises(PermissionSubjectUnavailableError): + await directory.describe_active_sources(tenant_id=60, sources=(source("user", 1),)) + + +async def test_unknown_tenant_source_cannot_be_ignored(): + from bisheng.common.errcode.permission import PermissionSubjectUnavailableError + from bisheng.tenant.domain.services import f048_permission_subject as module + + directory = module.TenantPermissionSubjectDirectory() + directory._summary_queries = SimpleNamespace(load=AsyncMock(return_value={})) + with pytest.raises(PermissionSubjectUnavailableError): + await directory.describe_active_sources(tenant_id=60, sources=(source("tenant", 60),)) diff --git a/src/backend/test/tenant/test_grant_department_scope.py b/src/backend/test/tenant/test_grant_department_scope.py new file mode 100644 index 0000000000..3345adc661 --- /dev/null +++ b/src/backend/test/tenant/test_grant_department_scope.py @@ -0,0 +1,335 @@ +"""Exercise mounted-tenant grant pickers and subject validation with real SQL.""" + +from contextlib import asynccontextmanager +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import httpx +import pytest +from fastapi import FastAPI +from sqlalchemy import event +from sqlalchemy.ext.asyncio import create_async_engine +from sqlmodel import SQLModel +from sqlmodel.ext.asyncio.session import AsyncSession + +from bisheng.common.dependencies.user_deps import UserPayload +from bisheng.common.errcode.permission import PermissionDeniedError, PermissionInvalidResourceError +from bisheng.database.models.department import Department +from bisheng.database.models.tenant import Tenant +from bisheng.permission.api.endpoints import grant_subjects +from bisheng.permission.application.resource_api import F048ResourcePermissionApi +from bisheng.permission.domain.schemas import GrantMutationRequest, VerifiedPermissionTarget +from bisheng.permission.domain.services import grant_subject_service +from bisheng.permission.domain.services.grant_subject_service import GrantSubjectScope +from bisheng.permission.domain.services.permission_action_service import PermissionActor +from bisheng.tenant.domain.repositories import grant_department_repository as queries +from bisheng.tenant.domain.services.f048_permission_subject import TenantPermissionSubjectDirectory + + +def scope(tenant_id=11, path=None): + return GrantSubjectScope(tenant_id=tenant_id, department_path=path) + + +def flatten(nodes): + return [child for node in nodes for child in [node, *flatten(node["children"])]] + + +def ids(nodes): + return [node["id"] for node in flatten(nodes)] + + +@pytest.fixture() +async def directory(monkeypatch): + engine = create_async_engine("sqlite+aiosqlite://") + async with engine.begin() as conn: + await conn.run_sync( + lambda db: SQLModel.metadata.create_all(db, tables=[Department.__table__, Tenant.__table__]) + ) + + @asynccontextmanager + async def session(): + async with AsyncSession(engine, expire_on_commit=False) as db: + yield db + + def dept(identifier, parent, path, tenant=1, **kwargs): + return Department( + id=identifier, + dept_id=f"D{identifier}", + name=f"Team {identifier}", + external_id=f"org-{identifier}", + parent_id=parent, + path=path, + tenant_id=tenant, + **kwargs, + ) + + async with session() as db: + db.add_all( + [ + dept(1, None, "/1/"), + dept(20, 1, "/1/20/", is_tenant_root=1, mounted_tenant_id=11), + dept(21, 20, "/1/20/21/"), + dept(22, 21, "/1/20/21/22/"), + dept(23, 20, "/1/20/23/"), + dept(24, 20, "/1/20/24/", status="archived"), + dept(25, 23, "/1/20/23/25/", tenant=12), + dept(30, 1, "/1/30/", is_tenant_root=1, mounted_tenant_id=12), + dept(31, 30, "/1/30/31/"), + dept(100, None, "/100/", tenant=15), + dept(101, 100, "/100/101/", tenant=15), + dept(200, 1, "/1/200/", tenant=18, is_tenant_root=1, mounted_tenant_id=18), + dept(201, 200, "/1/200/201/", tenant=18), + ] + ) + for identifier, root_id in [(1, 1), (11, 20), (12, 30), (15, 100), (18, 200), (21, 999), (22, 20), (23, None)]: + db.add( + Tenant( + id=identifier, + tenant_code=f"tenant-{identifier}", + tenant_name=f"Tenant {identifier}", + root_dept_id=root_id, + parent_tenant_id=1 if identifier != 1 else None, + ) + ) + await db.commit() + monkeypatch.setattr(queries, "get_async_db_session", session) + instance = TenantPermissionSubjectDirectory() + monkeypatch.setattr(grant_subject_service, "get_grant_subject_directory", AsyncMock(return_value=instance)) + yield instance + await engine.dispose() + + +@pytest.mark.parametrize(("tenant_id", "root_id"), [(1, 1), (11, 20), (15, 100), (18, 200)]) +async def test_first_layer_uses_authoritative_tenant_root(directory, tenant_id, root_id): + nodes = await grant_subject_service.list_candidate_department_layer(scope(tenant_id), parent_id=None) + assert ids(nodes) == [root_id] + assert nodes[0]["has_children"] is True + assert nodes[0]["children"] == [] + + +@pytest.mark.parametrize( + ("parent_id", "expected"), [(20, [21, 23]), (21, [22]), (22, []), (1, []), (30, []), (999, [])] +) +async def test_expand_rejects_parents_outside_the_tenant(directory, parent_id, expected): + nodes = await grant_subject_service.list_candidate_department_layer(scope(), parent_id=parent_id) + assert ids(nodes) == expected + if parent_id == 20: + assert {node["id"]: node["has_children"] for node in nodes} == {21: True, 23: False} + + +async def test_search_is_confined_and_does_not_reveal_ancestors_above_mount(directory): + result = await grant_subject_service.search_candidate_departments(scope(), keyword="org-22", limit=50) + assert ids(result["roots"]) == [20, 21, 22] + assert result["total_matches"] == 1 + assert result["truncated"] is False + assert [node["id"] for node in flatten(result["roots"]) if node["matched"]] == [22] + assert await directory.candidate_departments(scope(), keyword="org-31", limit=50) == [] + result = await grant_subject_service.search_candidate_departments(scope(), keyword="Team", limit=2) + assert result["total_matches"] == 2 + assert result["truncated"] is True + assert set(ids(result["roots"])) <= {20, 21, 22, 23} + + +async def test_path_tree_starts_at_mount_root(directory): + result = await grant_subject_service.get_candidate_department_path(scope(), dept_id=22) + assert ids(result["roots"]) == [20, 21, 22] + assert result["total_matches"] == 1 + assert result["roots"][0]["parent_id"] == 1 + + +@pytest.mark.parametrize("dept_id", [1, 24, 25, 30, 31, 100, 200, 999]) +async def test_locate_and_save_reject_foreign_archived_or_missing_departments(directory, dept_id): + result = await grant_subject_service.get_candidate_department_path(scope(), dept_id=dept_id) + assert result == {"roots": [], "total_matches": 0, "truncated": False} + with pytest.raises(PermissionInvalidResourceError): + await directory.canonical_source( + tenant_id=11, + source_id=7, + subject_type="department", + subject_id=str(dept_id), + userset_relation=None, + include_children=True, + ) + + +@pytest.mark.parametrize("dept_id", [20, 21, 22, 23]) +@pytest.mark.parametrize("include_children", [False, True]) +async def test_save_accepts_mount_and_descendants_without_changing_storage_tenant(directory, dept_id, include_children): + source = await directory.canonical_source( + tenant_id=11, + source_id=7, + subject_type="department", + subject_id=str(dept_id), + userset_relation=None, + include_children=include_children, + ) + relation = "subtree_member" if include_children else "member" + assert source.projected_subject == f"department:{dept_id}#{relation}" + assert source.source_type == "DEPARTMENT" + async with queries.get_async_db_session() as db: + assert (await db.get(Department, dept_id)).tenant_id == 1 + + +async def test_department_bound_space_intersects_the_tenant_subtree(directory): + bound = scope(path="/1/20/21/") + assert ids(await grant_subject_service.list_candidate_department_layer(bound, parent_id=None)) == [21] + assert ids(await grant_subject_service.list_candidate_department_layer(bound, parent_id=21)) == [22] + assert await grant_subject_service.list_candidate_department_layer(bound, parent_id=20) == [] + result = await grant_subject_service.search_candidate_departments(bound, keyword="org-22", limit=50) + assert ids(result["roots"]) == [21, 22] + assert (await grant_subject_service.get_candidate_department_path(bound, dept_id=23))["roots"] == [] + outside = scope(path="/1/30/") + assert await grant_subject_service.list_candidate_department_layer(outside, parent_id=None) == [] + assert (await grant_subject_service.search_candidate_departments(outside, keyword="Team", limit=50))["roots"] == [] + + +@pytest.mark.parametrize("tenant_id", [21, 22, 23, 999]) +async def test_missing_or_inconsistent_root_fails_closed(directory, tenant_id): + invalid = scope(tenant_id) + assert await grant_subject_service.list_candidate_department_layer(invalid, parent_id=None) == [] + assert (await grant_subject_service.search_candidate_departments(invalid, keyword="Team", limit=50))["roots"] == [] + assert (await grant_subject_service.get_candidate_department_path(invalid, dept_id=20))["roots"] == [] + with pytest.raises(PermissionInvalidResourceError): + await directory.canonical_source( + tenant_id=tenant_id, + source_id=7, + subject_type="department", + subject_id="20", + userset_relation=None, + include_children=True, + ) + + +@pytest.mark.parametrize("change", ["archived_root", "disabled_tenant", "malformed_path"]) +async def test_invalid_root_status_or_path_does_not_widen_candidates(directory, change): + async with queries.get_async_db_session() as db: + root = await db.get(Department, 20) + if change == "archived_root": + root.status = "archived" + elif change == "disabled_tenant": + (await db.get(Tenant, 11)).status = "disabled" + else: + root.path = "/1/%/" + await db.commit() + assert await grant_subject_service.list_candidate_department_layer(scope(), parent_id=None) == [] + assert (await grant_subject_service.search_candidate_departments(scope(), keyword="Team", limit=50))["roots"] == [] + + +async def test_non_admin_workflow_owner_can_use_picker_but_non_manager_is_denied(directory, monkeypatch): + user = UserPayload(user_id=42, user_name="owner", user_role=[], tenant_id=11) + registry = SimpleNamespace(resolve=AsyncMock(return_value=SimpleNamespace(tenant_id=11))) + allowed = AsyncMock(return_value=True) + monkeypatch.setattr(grant_subjects, "resolve_permission_actor", AsyncMock(return_value=object())) + monkeypatch.setattr(grant_subjects, "get_f048_resource_registry", AsyncMock(return_value=registry)) + monkeypatch.setattr(grant_subjects, "check_business_action", allowed) + app = FastAPI() + app.include_router(grant_subjects.router, prefix="/api/v1/permissions") + app.dependency_overrides[UserPayload.get_login_user] = lambda: user + root = "/api/v1/permissions/resources/workflow/owned-workflow/grant-subjects/departments" + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + response = await client.get(f"{root}/children") + assert response.status_code == 200 + assert ids(response.json()["data"]) == [20] + response = await client.get(f"{root}/search", params={"keyword": "org-22"}) + assert ids(response.json()["data"]["roots"]) == [20, 21, 22] + response = await client.get(f"{root}/22/path-tree") + assert ids(response.json()["data"]["roots"]) == [20, 21, 22] + allowed.return_value = False + for path in ["children", "search?keyword=Team", "22/path-tree"]: + response = await client.get(f"{root}/{path}") + assert response.json()["status_code"] == PermissionDeniedError.Code + assert all(call.kwargs["action"] == "manage_permission" for call in allowed.await_args_list) + + +@pytest.mark.parametrize( + ("tenant_id", "dept_id", "valid"), + [(11, 20, True), (11, 22, True), (15, 100, True), (18, 200, True), (11, 31, False), (11, 1, False)], +) +async def test_grant_mutation_validates_department_before_entering_projection(directory, tenant_id, dept_id, valid): + target = VerifiedPermissionTarget.from_business_service( + tenant_id=tenant_id, + resource_type="workflow", + resource_id="owned-workflow", + resource_version=1, + context_version="workflow:owned-workflow:v1", + ) + resources = SimpleNamespace(resolve=AsyncMock(return_value=target)) + runtime = SimpleNamespace( + allocate_source_ids=AsyncMock(return_value=(7,)), + mutate_grants=AsyncMock(return_value=SimpleNamespace(resource_version=2, grants=())), + ) + api = F048ResourcePermissionApi(resources=resources, runtime=runtime, subjects=directory) + request = GrantMutationRequest( + idempotency_key="department-grant", + expected_resource_version=1, + expected_catalog_release_id=12, + changes=[ + { + "op": "ADD", + "model_key": "viewer", + "subject": {"type": "department", "id": str(dept_id), "include_children": True}, + } + ], + ) + kwargs = { + "resource_type": "workflow", + "resource_id": "owned-workflow", + "actor": PermissionActor(user_id=42, current_tenant_id=tenant_id), + "request": request, + } + if valid: + assert (await api.mutate_grants(**kwargs))["resource_version"] == 2 + mutation = runtime.mutate_grants.await_args.kwargs + assert mutation["target"] == target + assert mutation["changes"][0].source.projected_subject == f"department:{dept_id}#subtree_member" + assert mutation["expected_resource_version"] == 1 + else: + with pytest.raises(PermissionInvalidResourceError): + await api.mutate_grants(**kwargs) + runtime.mutate_grants.assert_not_awaited() + + +@pytest.mark.parametrize("resource_type", ["knowledge_space", "channel"]) +async def test_creation_picker_uses_the_same_mounted_department_boundary(directory, resource_type): + assert ids(await directory.list_department_children(tenant_id=11, resource_type=resource_type, parent_id=None)) == [ + 20 + ] + result = await directory.search_departments(tenant_id=11, resource_type=resource_type, keyword="org-22", limit=50) + assert ids(result["roots"]) == [20, 21, 22] + result = await directory.get_department_path(tenant_id=11, resource_type=resource_type, department_id=31) + assert result["roots"] == [] + + +async def test_large_search_batches_ancestor_and_child_existence_reads(directory): + async with queries.get_async_db_session() as db: + db.add_all( + [ + Department( + id=identifier, + dept_id=f"D{identifier}", + name=f"Team {identifier}", + tenant_id=1, + parent_id=20, + path=f"/1/20/{identifier}/", + ) + for identifier in range(1000, 1201) + ] + ) + await db.commit() + engine = db.bind.sync_engine + reads = [] + + def capture(_conn, _cursor, statement, parameters, _context, _executemany): + if statement.startswith("SELECT"): + reads.append((statement, parameters)) + + event.listen(engine, "before_cursor_execute", capture) + try: + result = await grant_subject_service.search_candidate_departments(scope(), keyword="Team", limit=250) + finally: + event.remove(engine, "before_cursor_execute", capture) + assert set(ids(result["roots"])) == {20, 21, 22, 23, *range(1000, 1201)} + assert result["truncated"] is False + assert len(reads) <= 10 + # Each batch binds at most 100 IDs plus storage tenant, active status and path. + assert all(len(parameters) <= 103 for statement, parameters in reads if " IN (" in statement) diff --git a/src/backend/test/tenant/test_grant_subject_search_repository.py b/src/backend/test/tenant/test_grant_subject_search_repository.py new file mode 100644 index 0000000000..f9e61aa6e4 --- /dev/null +++ b/src/backend/test/tenant/test_grant_subject_search_repository.py @@ -0,0 +1,200 @@ +"""Execute candidate SQL with real data; permission scope is supplied by the service.""" + +from contextlib import asynccontextmanager +from types import SimpleNamespace + +import pytest +from sqlalchemy.ext.asyncio import create_async_engine +from sqlmodel import SQLModel +from sqlmodel.ext.asyncio.session import AsyncSession + +from bisheng.database.models.department import Department, UserDepartment +from bisheng.database.models.group import Group +from bisheng.database.models.tenant import Tenant, UserTenant +from bisheng.tenant.domain.repositories import grant_department_repository +from bisheng.tenant.domain.repositories import grant_subject_search_repository as queries +from bisheng.tenant.domain.services.f048_permission_subject import TenantPermissionSubjectDirectory +from bisheng.user.domain.models.user import User + + +@pytest.fixture() +async def directory(monkeypatch): + engine = create_async_engine("sqlite+aiosqlite://") + async with engine.begin() as conn: + tables = [model.__table__ for model in (Department, Tenant, User, UserTenant, UserDepartment, Group)] + await conn.run_sync(lambda db: SQLModel.metadata.create_all(db, tables=tables)) + await conn.execute( + Tenant.__table__.insert(), + {"id": 1, "tenant_code": "root", "tenant_name": "Root", "root_dept_id": 1}, + ) + await conn.execute( + Department.__table__.insert(), + [ + { + "id": 1, + "dept_id": "BS@1", + "name": "root", + "external_id": "org-1", + "tenant_id": 1, + "path": "/1/", + "status": "active", + }, + { + "id": 2, + "dept_id": "BS@2", + "name": "child", + "external_id": "org-2", + "tenant_id": 1, + "path": "/1/2/", + "status": "active", + }, + ], + ) + await conn.execute( + User.__table__.insert(), + [ + { + "user_id": 1, + "user_name": "jia0100", + "external_id": "jia0100", + "password": "test-only", + "source": "local", + "delete": 0, + }, + { + "user_id": 2, + "user_name": "same", + "external_id": "jia0100-02", + "password": "test-only", + "source": "local", + "delete": 0, + }, + { + "user_id": 3, + "user_name": "same", + "external_id": "jia0100", + "password": "test-only", + "source": "wecom", + "delete": 0, + }, + { + "user_id": 4, + "user_name": "same", + "external_id": "jia-disabled", + "password": "test-only", + "source": "local", + "delete": 1, + }, + { + "user_id": 5, + "user_name": "same", + "external_id": "jia-other", + "password": "test-only", + "source": "local", + "delete": 0, + }, + { + "user_id": 6, + "user_name": "same", + "external_id": "jia-no-dept", + "password": "test-only", + "source": "local", + "delete": 0, + }, + ], + ) + await conn.execute( + UserTenant.__table__.insert(), + [ + {"user_id": uid, "tenant_id": 2 if uid == 5 else 1, "status": "active", "is_active": 1} + for uid in range(1, 7) + ], + ) + await conn.execute( + UserDepartment.__table__.insert(), + [ + {"id": uid, "user_id": uid, "department_id": 1 if uid == 1 else 2, "is_primary": 1} + for uid in range(1, 6) + ], + ) + await conn.execute( + Group.__table__.insert(), + [ + {"id": 12, "group_name": "a", "tenant_id": 1}, + {"id": 120, "group_name": "b", "tenant_id": 1}, + {"id": 121, "group_name": "c", "tenant_id": 2}, + ], + ) + + @asynccontextmanager + async def session(): + async with AsyncSession(engine) as db: + yield db + + monkeypatch.setattr(queries, "get_async_db_session", session) + monkeypatch.setattr(grant_department_repository, "get_async_db_session", session) + yield TenantPermissionSubjectDirectory() + await engine.dispose() + + +def scope(path=None): + return SimpleNamespace(tenant_id=1, department_path=path) + + +async def test_grant_identity_scope_dedup_and_exact_before_page(directory): + rows = await directory.candidate_users(scope(), keyword="jia0100", page=1, page_size=2) + assert [row.user_id for row in rows] == [3, 1] + assert await directory.count_candidate_users(scope(), keyword="jia0100") == 3 + rows = await directory.candidate_users(scope(), keyword="jia0100", page=2, page_size=2) + assert [row.user_id for row in rows] == [2] + assert await directory.candidate_users(scope(), keyword="0100", page=1, page_size=50) == [] + + +async def test_historical_membership_is_not_a_grant_candidate(directory): + async with queries.get_async_db_session() as session: + session.add(UserTenant(user_id=5, tenant_id=1, status="active", is_active=None)) + await session.commit() + rows = await directory.candidate_users(scope(), keyword="jia-other", page=1, page_size=50) + assert rows == [] + assert await directory.count_candidate_users(scope(), keyword="jia-other") == 0 + + +async def test_stale_selected_user_has_a_specific_membership_error(directory, monkeypatch): + from unittest.mock import AsyncMock + + from bisheng.common.errcode.permission import PermissionSubjectUnavailableError + from bisheng.database.models.tenant import UserTenantDao + + monkeypatch.setattr( + UserTenantDao, + "aget_user_tenants", + AsyncMock( + return_value=[ + SimpleNamespace(tenant_id=1, status="active", is_active=None), + SimpleNamespace(tenant_id=2, status="active", is_active=1), + ] + ), + ) + with pytest.raises(PermissionSubjectUnavailableError): + await directory.canonical_source( + tenant_id=1, source_id=0, subject_type="user", subject_id="5", userset_relation=None, include_children=False + ) + + +async def test_flat_grants_preserve_mainline_department_and_disabled_scope(directory): + rows = await directory.candidate_users(scope(), keyword="jia", page=1, page_size=50) + assert {row.user_id for row in rows} == {1, 2, 3, 6} + rows = await directory.candidate_users(scope("/1/2/"), keyword="jia", page=1, page_size=50) + assert {row.user_id for row in rows} == {2, 3} + + +async def test_group_primary_key_prefix_and_true_total(directory): + rows = await directory.candidate_groups(scope(), keyword="12", page=1, page_size=1) + assert [row.id for row in rows] == [12] + assert await directory.count_candidate_groups(scope(), keyword="12") == 2 + assert await directory.candidate_groups(scope(), keyword="012", page=1, page_size=50) == [] + + +async def test_department_external_identity_not_internal_business_code(directory): + assert [row.id for row in await directory.candidate_departments(scope(), keyword="org-2", limit=50)] == [2] + assert await directory.candidate_departments(scope(), keyword="BS@2", limit=50) == [] diff --git a/src/backend/test/tenant/test_quota_tree_usage_item.py b/src/backend/test/tenant/test_quota_tree_usage_item.py new file mode 100644 index 0000000000..4036d33b21 --- /dev/null +++ b/src/backend/test/tenant/test_quota_tree_usage_item.py @@ -0,0 +1,17 @@ +"""Quota tree usage entries carry fractional storage usage.""" + +from bisheng.tenant.domain.schemas.tenant_schema import TenantQuotaUsageItem + + +def test_storage_usage_in_gb_is_kept_fractional(): + item = TenantQuotaUsageItem(resource_type="storage_gb", used=0.081, limit=10, utilization=0.0081) + + assert item.used == 0.081 + assert item.model_dump()["limit"] == 10 + + +def test_count_types_stay_integers(): + dumped = TenantQuotaUsageItem(resource_type="knowledge_space", used=3, limit=-1, utilization=0.0).model_dump() + + assert dumped["used"] == 3 and isinstance(dumped["used"], int) + assert isinstance(dumped["limit"], int) diff --git a/src/backend/test/tenant/test_sync_user_scope_clear.py b/src/backend/test/tenant/test_sync_user_scope_clear.py index 237df146cc..642f0fd8ff 100644 --- a/src/backend/test/tenant/test_sync_user_scope_clear.py +++ b/src/backend/test/tenant/test_sync_user_scope_clear.py @@ -28,6 +28,8 @@ def _patch_deps(monkeypatch, new_leaf_id: int, current_leaf_id=None): - ``UserTenantDao`` / ``UserDao`` DAO calls become AsyncMocks. - the permission application becomes an AsyncMock. - ``AuditLogDao.ainsert_v2`` becomes AsyncMock. + - ``PersonalTokenService.migrate_tenant`` (F053 D19) becomes AsyncMock — + the real one opens a DB session against ``api_credential``. - The F019 ``TenantScopeService`` module is inserted into sys.modules so the late import inside the service resolves to our spy. """ @@ -79,6 +81,11 @@ def _patch_deps(monkeypatch, new_leaf_id: int, current_leaf_id=None): "_invalidate_redis_caches", AsyncMock(), ) + # D19 PAT tenant migration runs on both the relocate and the no-op exit; + # it is imported lazily inside the service, so patch the class attribute. + from bisheng.open_api.domain.services.personal_token_service import PersonalTokenService + + monkeypatch.setattr(PersonalTokenService, "migrate_tenant", AsyncMock(return_value=0)) # Install fake TenantScopeService — the service uses a local import, so # we inject it through sys.modules. diff --git a/src/backend/test/tenant/test_tenant_auth.py b/src/backend/test/tenant/test_tenant_auth.py index c2c109d7d7..0ccd2131ed 100644 --- a/src/backend/test/tenant/test_tenant_auth.py +++ b/src/backend/test/tenant/test_tenant_auth.py @@ -54,7 +54,7 @@ @pytest.fixture(autouse=True) def _pin_jwt_secret(monkeypatch): - """F068: settings no longer ships a default secret. Pin one so AuthJwt does + """F072: settings no longer ships a default secret. Pin one so AuthJwt does not try the config table when config_service is the real module (combined runs) rather than the pre-mock above (isolated runs).""" from bisheng.common.services import config_service as cs diff --git a/src/backend/test/tenant/test_tenant_execution_scope.py b/src/backend/test/tenant/test_tenant_execution_scope.py new file mode 100644 index 0000000000..c68c98bab4 --- /dev/null +++ b/src/backend/test/tenant/test_tenant_execution_scope.py @@ -0,0 +1,81 @@ +"""AC-61, AC-62: background scopes must not inherit another request's privileges.""" + +import asyncio + +import pytest + +from bisheng.core.context import tenant + + +def test_nested_failure_restores_admin_visibility_and_bypass(): + bindings = ( + (tenant.current_tenant_id, 9), + (tenant.visible_tenant_ids, frozenset({1, 9})), + (tenant._admin_scope_tenant_id, 12), + (tenant._bypass_tenant_filter, True), + (tenant._is_management_api, True), + (tenant._management_all_tenants, True), + ) + tokens = [(variable, variable.set(value)) for variable, value in bindings] + try: + with tenant.tenant_execution_scope(2): + assert tenant.get_current_tenant_id() == 2 + assert tenant.get_visible_tenant_ids() == frozenset({2}) + assert not tenant.is_tenant_filter_bypassed() + assert not tenant.is_management_all_tenants() + with pytest.raises(RuntimeError): + with tenant.tenant_execution_scope(3): + assert tenant.get_current_tenant_id() == 3 + raise RuntimeError("projection failed") + assert tenant.get_current_tenant_id() == 2 + assert tenant.get_current_tenant_id() == 12 + assert tenant.current_tenant_id.get() == 9 + assert tenant.get_visible_tenant_ids() == frozenset({1, 9}) + assert tenant.is_tenant_filter_bypassed() + assert tenant._management_all_tenants.get() is True + assert not tenant.is_management_all_tenants() + finally: + for variable, token in reversed(tokens): + variable.reset(token) + + +async def test_concurrent_background_tasks_do_not_share_scope(): + barrier = asyncio.Barrier(2) + + async def run(tenant_id): + with tenant.tenant_execution_scope(tenant_id): + await barrier.wait() + assert tenant.get_current_tenant_id() == tenant_id + assert tenant.get_visible_tenant_ids() == frozenset({tenant_id}) + + token = tenant.current_tenant_id.set(None) + try: + await asyncio.gather(run(2), run(3)) + assert tenant.current_tenant_id.get() is None + finally: + tenant.current_tenant_id.reset(token) + + +@pytest.mark.parametrize("invalid", [0, -1, None, True, "2"]) +def test_invalid_tenant_never_changes_the_callers_context(invalid): + before = tenant.get_current_tenant_id() + with pytest.raises(ValueError): + with tenant.tenant_execution_scope(invalid): + pytest.fail("invalid scope was entered") + assert tenant.get_current_tenant_id() == before + + +def test_management_view_preserves_authenticated_membership(): + token = tenant.current_tenant_id.set(9) + try: + with tenant.management_tenant_scope(3): + assert tenant.get_current_tenant_id() == 3 + assert tenant.current_tenant_id.get() == 9 + assert tenant.get_visible_tenant_ids() == frozenset({3}) + with tenant.tenant_execution_scope(2): + assert tenant.get_current_tenant_id() == 2 + assert not tenant.get_is_management_api() + assert tenant.get_current_tenant_id() == 3 + assert tenant.current_tenant_id.get() == 9 + finally: + tenant.current_tenant_id.reset(token) diff --git a/src/backend/test/tenant/test_tenant_users_query_source.py b/src/backend/test/tenant/test_tenant_users_query_source.py index 46d3c801c9..b508202ec9 100644 --- a/src/backend/test/tenant/test_tenant_users_query_source.py +++ b/src/backend/test/tenant/test_tenant_users_query_source.py @@ -47,21 +47,22 @@ # MagicMock attributes (``UserDao`` etc. used by other tests in the same # pytest session) and add a real ``User`` SQLModel for the column refs. + class _TestUser(SQLModel, table=True): - __tablename__ = 'user' - __table_args__ = {'extend_existing': True} + __tablename__ = "user" + __table_args__ = {"extend_existing": True} user_id: Optional[int] = Field(default=None, primary_key=True) user_name: Optional[str] = Field(default=None, sa_column=Column(String(255))) external_id: Optional[str] = Field(default=None, sa_column=Column(String(128))) avatar: Optional[str] = Field(default=None, sa_column=Column(String(512))) - password: str = Field(default='hashed', sa_column=Column(String(255), nullable=False)) + password: str = Field(default="hashed", sa_column=Column(String(255), nullable=False)) -_existing = sys.modules.get('bisheng.user.domain.models.user') +_existing = sys.modules.get("bisheng.user.domain.models.user") if _existing is None: - _existing = ModuleType('bisheng.user.domain.models.user') - sys.modules['bisheng.user.domain.models.user'] = _existing + _existing = ModuleType("bisheng.user.domain.models.user") + sys.modules["bisheng.user.domain.models.user"] = _existing # Always set real User class; preserve any other attrs (UserDao etc. from # the conftest MagicMock) untouched. _existing.User = _TestUser @@ -159,8 +160,8 @@ def bypass_global_tenant_filter(): @pytest_asyncio.fixture async def db_engine(): engine = create_async_engine( - 'sqlite+aiosqlite://', - connect_args={'check_same_thread': False}, + "sqlite+aiosqlite://", + connect_args={"check_same_thread": False}, poolclass=StaticPool, ) async with engine.begin() as conn: @@ -176,8 +177,8 @@ async def session(db_engine): yield s -@pytest_asyncio.fixture -async def patched_dao(db_engine, monkeypatch): +@pytest_asyncio.fixture(params=["legacy", "management"]) +async def patched_dao(db_engine, monkeypatch, request): """Monkeypatch ``get_async_db_session`` inside the department module so the DAO under test runs against our sqlite engine. """ @@ -188,109 +189,136 @@ async def _factory(): yield s from bisheng.database import models as models_pkg # noqa: F401 + monkeypatch.setattr( - 'bisheng.database.models.department.get_async_db_session', _factory, + "bisheng.database.models.department.get_async_db_session", + _factory, ) + if request.param == "management": + from bisheng.database.models.department import UserDepartmentDao + from bisheng.tenant.domain.repositories.management_search_repository import ManagementSearchRepository + + monkeypatch.setattr( + "bisheng.tenant.domain.repositories.management_search_repository.get_async_db_session", + _factory, + ) + monkeypatch.setattr(UserDepartmentDao, "aget_users_by_tenant_subtree", ManagementSearchRepository.tenant_users) yield # ── Data-builder helpers ────────────────────────────────────────────────── -async def _insert_tenant(session: AsyncSession, *, tid: int, code: str, - root_dept_id: Optional[int] = None, - parent_tenant_id: Optional[int] = None) -> None: - await session.execute(text( - 'INSERT INTO tenant (id, tenant_code, tenant_name, root_dept_id, ' - ' parent_tenant_id, status) ' - 'VALUES (:id, :code, :name, :rd, :pt, "active")' - ), {'id': tid, 'code': code, 'name': f'T{tid}', - 'rd': root_dept_id, 'pt': parent_tenant_id}) - - -async def _insert_dept(session: AsyncSession, *, did: int, dept_id: str, - path: str, parent_id: Optional[int] = None, - tenant_id: int = 1) -> None: - await session.execute(text( - 'INSERT INTO department (id, dept_id, name, parent_id, tenant_id, ' - ' path, status) ' - 'VALUES (:id, :dept_id, :name, :pid, :tid, :path, "active")' - ), {'id': did, 'dept_id': dept_id, 'name': dept_id, - 'pid': parent_id, 'tid': tenant_id, 'path': path}) - - -async def _insert_user(session: AsyncSession, *, uid: int, name: str, - avatar: Optional[str] = None) -> None: - await session.execute(text( - 'INSERT INTO user (user_id, user_name, avatar, password) ' - 'VALUES (:uid, :name, :avatar, "hashed")' - ), {'uid': uid, 'name': name, 'avatar': avatar}) - - -async def _insert_user_dept(session: AsyncSession, *, uid: int, - dept_id: int, is_primary: int = 1) -> None: - await session.execute(text( - 'INSERT INTO user_department (user_id, department_id, is_primary) ' - 'VALUES (:uid, :did, :ip)' - ), {'uid': uid, 'did': dept_id, 'ip': is_primary}) - - -async def _insert_user_tenant(session: AsyncSession, *, uid: int, tid: int, - is_active: Optional[int] = None, - last_access: Optional[datetime] = None) -> None: - await session.execute(text( - 'INSERT INTO user_tenant (user_id, tenant_id, is_active, ' - ' last_access_time) ' - 'VALUES (:uid, :tid, :ia, :la)' - ), {'uid': uid, 'tid': tid, 'ia': is_active, 'la': last_access}) + +async def _insert_tenant( + session: AsyncSession, + *, + tid: int, + code: str, + root_dept_id: Optional[int] = None, + parent_tenant_id: Optional[int] = None, +) -> None: + await session.execute( + text( + "INSERT INTO tenant (id, tenant_code, tenant_name, root_dept_id, " + " parent_tenant_id, status) " + 'VALUES (:id, :code, :name, :rd, :pt, "active")' + ), + {"id": tid, "code": code, "name": f"T{tid}", "rd": root_dept_id, "pt": parent_tenant_id}, + ) + + +async def _insert_dept( + session: AsyncSession, *, did: int, dept_id: str, path: str, parent_id: Optional[int] = None, tenant_id: int = 1 +) -> None: + await session.execute( + text( + "INSERT INTO department (id, dept_id, name, parent_id, tenant_id, " + " path, status) " + 'VALUES (:id, :dept_id, :name, :pid, :tid, :path, "active")' + ), + {"id": did, "dept_id": dept_id, "name": dept_id, "pid": parent_id, "tid": tenant_id, "path": path}, + ) + + +async def _insert_user(session: AsyncSession, *, uid: int, name: str, avatar: Optional[str] = None) -> None: + await session.execute( + text('INSERT INTO user (user_id, user_name, avatar, password) VALUES (:uid, :name, :avatar, "hashed")'), + {"uid": uid, "name": name, "avatar": avatar}, + ) + + +async def _insert_user_dept(session: AsyncSession, *, uid: int, dept_id: int, is_primary: int = 1) -> None: + await session.execute( + text("INSERT INTO user_department (user_id, department_id, is_primary) VALUES (:uid, :did, :ip)"), + {"uid": uid, "did": dept_id, "ip": is_primary}, + ) + + +async def _insert_user_tenant( + session: AsyncSession, + *, + uid: int, + tid: int, + is_active: Optional[int] = None, + last_access: Optional[datetime] = None, +) -> None: + await session.execute( + text( + "INSERT INTO user_tenant (user_id, tenant_id, is_active, " + " last_access_time) " + "VALUES (:uid, :tid, :ia, :la)" + ), + {"uid": uid, "tid": tid, "ia": is_active, "la": last_access}, + ) async def _setup_two_tenant_world(session: AsyncSession) -> None: """Build a world with: - - Tenant 1 (Root) — root dept id=10, path='/10/' - - Tenant 5 (Child mounted at dept 20) — root dept id=20, path='/10/20/' - - sub-dept 30 of tenant 5 — path='/10/20/30/' - - sibling dept 40 of root — path='/10/40/' (NOT in tenant 5 subtree) + - Tenant 1 (Root) — root dept id=10, path='/10/' + - Tenant 5 (Child mounted at dept 20) — root dept id=20, path='/10/20/' + - sub-dept 30 of tenant 5 — path='/10/20/30/' + - sibling dept 40 of root — path='/10/40/' (NOT in tenant 5 subtree) """ - await _insert_tenant(session, tid=1, code='root', root_dept_id=10) - await _insert_tenant(session, tid=5, code='child', root_dept_id=20, - parent_tenant_id=1) - await _insert_dept(session, did=10, dept_id='BS@root', path='/10/', - tenant_id=1) - await _insert_dept(session, did=20, dept_id='BS@child', path='/10/20/', - parent_id=10, tenant_id=5) - await _insert_dept(session, did=30, dept_id='BS@sub', path='/10/20/30/', - parent_id=20, tenant_id=5) - await _insert_dept(session, did=40, dept_id='BS@sibling', path='/10/40/', - parent_id=10, tenant_id=1) + await _insert_tenant(session, tid=1, code="root", root_dept_id=10) + await _insert_tenant(session, tid=5, code="child", root_dept_id=20, parent_tenant_id=1) + await _insert_dept(session, did=10, dept_id="BS@root", path="/10/", tenant_id=1) + await _insert_dept(session, did=20, dept_id="BS@child", path="/10/20/", parent_id=10, tenant_id=5) + await _insert_dept(session, did=30, dept_id="BS@sub", path="/10/20/30/", parent_id=20, tenant_id=5) + await _insert_dept(session, did=40, dept_id="BS@sibling", path="/10/40/", parent_id=10, tenant_id=1) await session.commit() # ── Tests ───────────────────────────────────────────────────────────────── + @pytest.mark.asyncio async def test_returns_users_with_primary_dept_in_subtree( - session, patched_dao, + session, + patched_dao, ): """AC-01: Users whose primary dept is in tenant 5 subtree appear.""" await _setup_two_tenant_world(session) # User A: primary dept = 20 (tenant 5 root) → IN subtree - await _insert_user(session, uid=101, name='alice') + await _insert_user(session, uid=101, name="alice") await _insert_user_dept(session, uid=101, dept_id=20, is_primary=1) # User B: primary dept = 30 (tenant 5 sub-dept) → IN subtree - await _insert_user(session, uid=102, name='bob') + await _insert_user(session, uid=102, name="bob") await _insert_user_dept(session, uid=102, dept_id=30, is_primary=1) # User C: primary dept = 40 (tenant 1 sibling) → NOT in subtree - await _insert_user(session, uid=103, name='charlie') + await _insert_user(session, uid=103, name="charlie") await _insert_user_dept(session, uid=103, dept_id=40, is_primary=1) await session.commit() from bisheng.database.models.department import UserDepartmentDao + rows, total = await UserDepartmentDao.aget_users_by_tenant_subtree( - tenant_id=5, page=1, page_size=20, + tenant_id=5, + page=1, + page_size=20, ) assert total == 2 - uids = {r['user_id'] for r in rows} + uids = {r["user_id"] for r in rows} assert uids == {101, 102} @@ -299,14 +327,17 @@ async def test_secondary_membership_does_not_surface(session, patched_dao): """AC-03: Users whose only tie to the subtree is is_primary=0 are excluded.""" await _setup_two_tenant_world(session) # User D: primary in dept 40 (NOT subtree), secondary in dept 30 (in subtree) - await _insert_user(session, uid=201, name='dora') + await _insert_user(session, uid=201, name="dora") await _insert_user_dept(session, uid=201, dept_id=40, is_primary=1) await _insert_user_dept(session, uid=201, dept_id=30, is_primary=0) await session.commit() from bisheng.database.models.department import UserDepartmentDao + rows, total = await UserDepartmentDao.aget_users_by_tenant_subtree( - tenant_id=5, page=1, page_size=20, + tenant_id=5, + page=1, + page_size=20, ) assert total == 0 @@ -321,14 +352,17 @@ async def test_legacy_user_tenant_phantom_rows_excluded(session, patched_dao): await _setup_two_tenant_world(session) # User E: primary dept = 40 (NOT in tenant 5 subtree) — but has a # passive UserTenant(tenant=5) row written by legacy aadd_users. - await _insert_user(session, uid=301, name='eve') + await _insert_user(session, uid=301, name="eve") await _insert_user_dept(session, uid=301, dept_id=40, is_primary=1) await _insert_user_tenant(session, uid=301, tid=5, is_active=None) await session.commit() from bisheng.database.models.department import UserDepartmentDao + rows, total = await UserDepartmentDao.aget_users_by_tenant_subtree( - tenant_id=5, page=1, page_size=20, + tenant_id=5, + page=1, + page_size=20, ) assert total == 0 @@ -342,43 +376,49 @@ async def test_user_tenant_left_join_decorates_join_time(session, patched_dao): await _setup_two_tenant_world(session) last_access = datetime(2026, 5, 1, 12, 0, 0) # User F: primary in dept 20 + active UserTenant row → join_time populated - await _insert_user(session, uid=401, name='frank') + await _insert_user(session, uid=401, name="frank") await _insert_user_dept(session, uid=401, dept_id=20, is_primary=1) - await _insert_user_tenant(session, uid=401, tid=5, is_active=1, - last_access=last_access) + await _insert_user_tenant(session, uid=401, tid=5, is_active=1, last_access=last_access) # User G: primary in dept 30, no UserTenant row at all → still in list - await _insert_user(session, uid=402, name='grace') + await _insert_user(session, uid=402, name="grace") await _insert_user_dept(session, uid=402, dept_id=30, is_primary=1) await session.commit() from bisheng.database.models.department import UserDepartmentDao + rows, total = await UserDepartmentDao.aget_users_by_tenant_subtree( - tenant_id=5, page=1, page_size=20, + tenant_id=5, + page=1, + page_size=20, ) assert total == 2 - by_uid = {r['user_id']: r for r in rows} - assert by_uid[401]['join_time'] is not None - assert by_uid[402]['join_time'] is None + by_uid = {r["user_id"]: r for r in rows} + assert by_uid[401]["join_time"] is not None + assert by_uid[402]["join_time"] is None @pytest.mark.asyncio async def test_keyword_filter(session, patched_dao): """AC-04: keyword filter on user_name.""" await _setup_two_tenant_world(session) - await _insert_user(session, uid=501, name='alice_smith') + await _insert_user(session, uid=501, name="alice_smith") await _insert_user_dept(session, uid=501, dept_id=20, is_primary=1) - await _insert_user(session, uid=502, name='bob_jones') + await _insert_user(session, uid=502, name="bob_jones") await _insert_user_dept(session, uid=502, dept_id=20, is_primary=1) await session.commit() from bisheng.database.models.department import UserDepartmentDao + rows, total = await UserDepartmentDao.aget_users_by_tenant_subtree( - tenant_id=5, page=1, page_size=20, keyword='alice', + tenant_id=5, + page=1, + page_size=20, + keyword="alice", ) assert total == 1 - assert rows[0]['user_id'] == 501 + assert rows[0]["user_id"] == 501 @pytest.mark.asyncio @@ -386,16 +426,21 @@ async def test_pagination(session, patched_dao): """page/page_size respected; total reflects full match count.""" await _setup_two_tenant_world(session) for i in range(5): - await _insert_user(session, uid=600 + i, name=f'user{i}') + await _insert_user(session, uid=600 + i, name=f"user{i}") await _insert_user_dept(session, uid=600 + i, dept_id=20, is_primary=1) await session.commit() from bisheng.database.models.department import UserDepartmentDao + rows1, total1 = await UserDepartmentDao.aget_users_by_tenant_subtree( - tenant_id=5, page=1, page_size=2, + tenant_id=5, + page=1, + page_size=2, ) rows2, total2 = await UserDepartmentDao.aget_users_by_tenant_subtree( - tenant_id=5, page=2, page_size=2, + tenant_id=5, + page=2, + page_size=2, ) assert total1 == 5 @@ -403,9 +448,7 @@ async def test_pagination(session, patched_dao): assert len(rows1) == 2 assert len(rows2) == 2 # No overlap between pages - assert {r['user_id'] for r in rows1}.isdisjoint( - {r['user_id'] for r in rows2} - ) + assert {r["user_id"] for r in rows1}.isdisjoint({r["user_id"] for r in rows2}) @pytest.mark.asyncio @@ -414,20 +457,22 @@ async def test_fallback_when_tenant_has_no_root_dept_id(session, patched_dao): to ``Department.tenant_id == tenant_id`` filter so the view still works. """ # Tenant 7 with no root_dept_id set - await _insert_tenant(session, tid=7, code='legacy', root_dept_id=None) - await _insert_dept(session, did=70, dept_id='BS@legacy_root', path='/70/', - tenant_id=7) - await _insert_user(session, uid=701, name='heidi') + await _insert_tenant(session, tid=7, code="legacy", root_dept_id=None) + await _insert_dept(session, did=70, dept_id="BS@legacy_root", path="/70/", tenant_id=7) + await _insert_user(session, uid=701, name="heidi") await _insert_user_dept(session, uid=701, dept_id=70, is_primary=1) await session.commit() from bisheng.database.models.department import UserDepartmentDao + rows, total = await UserDepartmentDao.aget_users_by_tenant_subtree( - tenant_id=7, page=1, page_size=20, + tenant_id=7, + page=1, + page_size=20, ) assert total == 1 - assert rows[0]['user_id'] == 701 + assert rows[0]["user_id"] == 701 # ── F024 phase-2: count parity tests ────────────────────────────────────── @@ -441,15 +486,18 @@ async def test_fallback_when_tenant_has_no_root_dept_id(session, patched_dao): async def test_count_subtree_matches_list_total(session, patched_dao): """count == aget_users_by_tenant_subtree.total for the same tenant.""" await _setup_two_tenant_world(session) - await _insert_user(session, uid=801, name='ivan') + await _insert_user(session, uid=801, name="ivan") await _insert_user_dept(session, uid=801, dept_id=20, is_primary=1) - await _insert_user(session, uid=802, name='judy') + await _insert_user(session, uid=802, name="judy") await _insert_user_dept(session, uid=802, dept_id=30, is_primary=1) await session.commit() from bisheng.database.models.department import UserDepartmentDao + _, list_total = await UserDepartmentDao.aget_users_by_tenant_subtree( - tenant_id=5, page=1, page_size=20, + tenant_id=5, + page=1, + page_size=20, ) count = await UserDepartmentDao.acount_users_by_tenant_subtree(tenant_id=5) assert count == list_total == 2 @@ -461,12 +509,13 @@ async def test_count_subtree_excludes_phantom_user_tenant(session, patched_dao): await _setup_two_tenant_world(session) # Phantom: user's primary dept is in the sibling subtree, but a stale # is_active=1 UserTenant row points at tenant 5. - await _insert_user(session, uid=901, name='kate') + await _insert_user(session, uid=901, name="kate") await _insert_user_dept(session, uid=901, dept_id=40, is_primary=1) await _insert_user_tenant(session, uid=901, tid=5, is_active=1) await session.commit() from bisheng.database.models.department import UserDepartmentDao + count = await UserDepartmentDao.acount_users_by_tenant_subtree(tenant_id=5) assert count == 0 @@ -476,13 +525,14 @@ async def test_count_subtree_batch_matches_singles(session, patched_dao): """Batch result must equal the per-tenant single-count for every id.""" await _setup_two_tenant_world(session) # Tenant 5: one user; Tenant 1: one user via dept 40. - await _insert_user(session, uid=1001, name='leo') + await _insert_user(session, uid=1001, name="leo") await _insert_user_dept(session, uid=1001, dept_id=20, is_primary=1) - await _insert_user(session, uid=1002, name='mia') + await _insert_user(session, uid=1002, name="mia") await _insert_user_dept(session, uid=1002, dept_id=40, is_primary=1) await session.commit() from bisheng.database.models.department import UserDepartmentDao + batch = await UserDepartmentDao.acount_users_by_tenant_subtree_batch( [1, 5, 9999], ) @@ -498,14 +548,14 @@ async def test_count_subtree_batch_matches_singles(session, patched_dao): @pytest.mark.asyncio async def test_count_subtree_fallback_no_root_dept_id(session, patched_dao): """Same fallback semantic as the list DAO — uses Department.tenant_id.""" - await _insert_tenant(session, tid=8, code='legacy2', root_dept_id=None) - await _insert_dept(session, did=80, dept_id='BS@legacy2_root', path='/80/', - tenant_id=8) - await _insert_user(session, uid=1101, name='nora') + await _insert_tenant(session, tid=8, code="legacy2", root_dept_id=None) + await _insert_dept(session, did=80, dept_id="BS@legacy2_root", path="/80/", tenant_id=8) + await _insert_user(session, uid=1101, name="nora") await _insert_user_dept(session, uid=1101, dept_id=80, is_primary=1) await session.commit() from bisheng.database.models.department import UserDepartmentDao + count = await UserDepartmentDao.acount_users_by_tenant_subtree(8) batch = await UserDepartmentDao.acount_users_by_tenant_subtree_batch([8]) assert count == 1 @@ -516,11 +566,71 @@ async def test_count_subtree_fallback_no_root_dept_id(session, patched_dao): async def test_count_subtree_secondary_membership_excluded(session, patched_dao): """is_primary=0 ties must not bump the count (parity with list DAO).""" await _setup_two_tenant_world(session) - await _insert_user(session, uid=1201, name='oscar') + await _insert_user(session, uid=1201, name="oscar") await _insert_user_dept(session, uid=1201, dept_id=40, is_primary=1) await _insert_user_dept(session, uid=1201, dept_id=30, is_primary=0) await session.commit() from bisheng.database.models.department import UserDepartmentDao + count = await UserDepartmentDao.acount_users_by_tenant_subtree(tenant_id=5) assert count == 0 + + +async def test_management_external_id_order_case_pagination_and_primary_scope(session, db_engine, monkeypatch): + from bisheng.tenant.domain.repositories.management_search_repository import ManagementSearchRepository + + @asynccontextmanager + async def factory(): + async with AsyncSession(bind=db_engine) as query_session: + yield query_session + + monkeypatch.setattr( + "bisheng.tenant.domain.repositories.management_search_repository.get_async_db_session", + factory, + ) + await _setup_two_tenant_world(session) + for uid, dept, external_id in [(701, 20, "E010-more"), (702, 30, "E010"), (703, 40, "E010")]: + await _insert_user(session, uid=uid, name=f"person-{uid}") + await _insert_user_dept(session, uid=uid, dept_id=dept) + await session.execute( + text("UPDATE user SET external_id=:eid WHERE user_id=:uid"), {"eid": external_id, "uid": uid} + ) + # An extra secondary membership cannot bring an out-of-scope user into the list. + await _insert_user_dept(session, uid=703, dept_id=20, is_primary=0) + await session.commit() + rows, total = await ManagementSearchRepository.tenant_users(5, keyword="E010", page_size=1) + assert total == 2 + assert [row["user_id"] for row in rows] == [702] + rows, total = await ManagementSearchRepository.tenant_users(5, keyword="E010", page=9, page_size=1) + assert rows == [] and total == 2 + assert (await ManagementSearchRepository.tenant_users(5, keyword="e010"))[1] == 0 + assert (await ManagementSearchRepository.tenant_users(5, keyword="010"))[1] == 0 + # Name matching retains the original contains rule. + assert (await ManagementSearchRepository.tenant_users(5, keyword="son-70"))[1] == 2 + + +async def test_existing_group_members_identity_hydration_is_batched(session, db_engine, monkeypatch): + from bisheng.user_group.domain.repositories.member_identity_repository import MemberIdentityRepository + + calls = 0 + + @asynccontextmanager + async def factory(): + nonlocal calls + calls += 1 + async with AsyncSession(bind=db_engine) as query_session: + yield query_session + + monkeypatch.setattr( + "bisheng.user_group.domain.repositories.member_identity_repository.get_async_db_session", + factory, + ) + await _insert_user(session, uid=801, name="existing-one") + await _insert_user(session, uid=802, name="existing-two") + await session.execute(text("UPDATE user SET external_id='E801' WHERE user_id=801")) + await session.commit() + assert await MemberIdentityRepository.external_ids([]) == {} + assert calls == 0 + assert await MemberIdentityRepository.external_ids([801, 802, 999]) == {801: "E801", 802: None} + assert calls == 1 diff --git a/src/backend/test/tool/test_ensure_web_fetch_tool.py b/src/backend/test/tool/test_ensure_web_fetch_tool.py new file mode 100644 index 0000000000..995b09192d --- /dev/null +++ b/src/backend/test/tool/test_ensure_web_fetch_tool.py @@ -0,0 +1,205 @@ +"""Upgrade path for the web fetch built-in tool (F085 design decision 8). + +An existing database never re-imports the seed JSON, so startup inserts the +root rows, projects their permission and copies them to child tenants. +""" + +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.pool import StaticPool +from sqlmodel import SQLModel, select +from sqlmodel.ext.asyncio.session import AsyncSession + +from bisheng.common import init_data +from bisheng.core.context.tenant import get_current_tenant_id +from bisheng.permission.application.system_resource_reconcile import SystemOwnedResourceSpec +from bisheng.tool.domain.models.gpts_tools import GptsTools, GptsToolsType + + +@pytest.fixture() +async def session(): + engine = create_async_engine("sqlite+aiosqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all, tables=[GptsTools.__table__, GptsToolsType.__table__]) + db_session = AsyncSession(bind=engine, expire_on_commit=False) + yield db_session + await db_session.close() + await engine.dispose() + + +async def _rows(session): + types = (await session.exec(select(GptsToolsType))).all() + tools = (await session.exec(select(GptsTools))).all() + return types, tools + + +async def test_inserts_type_and_tool_matching_the_seed(session): + inserted = await init_data._ensure_web_fetch_tool(session) + + types, tools = await _rows(session) + assert inserted is True + assert [row.name for row in types] == ["网页读取"] + assert len(tools) == 1 + tool, tool_type = tools[0], types[0] + assert tool.tool_key == "web_fetch" + assert tool.type == tool_type.id + assert tool.tenant_id == tool_type.tenant_id == 1 + assert tool.is_preset == tool_type.is_preset == 1 + assert tool.user_id is None and tool_type.user_id is None + assert [param["name"] for param in tool.api_params] == ["url"] + assert json.loads(tool_type.extra) == { + "type": "deepseek", + "config": {"deepseek": {"api_key": "", "base_url": ""}}, + "max_chars": 20000, + } + + +async def test_second_run_does_not_insert_again(session): + await init_data._ensure_web_fetch_tool(session) + + inserted = await init_data._ensure_web_fetch_tool(session) + + types, tools = await _rows(session) + assert inserted is False + assert len(types) == 1 and len(tools) == 1 + + +async def test_config_is_separate_from_web_search(session): + session.add(GptsToolsType(name="联网搜索", is_preset=1, extra='{"type": "bing"}', tenant_id=1)) + await session.commit() + + await init_data._ensure_web_fetch_tool(session) + + types, _ = await _rows(session) + by_name = {row.name: row for row in types} + assert by_name["联网搜索"].id != by_name["网页读取"].id + assert json.loads(by_name["联网搜索"].extra) == {"type": "bing"} + + +def _settings(openfga: bool, multi_tenant: bool): + return SimpleNamespace( + openfga=SimpleNamespace(enabled=openfga, recent_consistency_window_seconds=1), + multi_tenant=SimpleNamespace(enabled=multi_tenant), + ) + + +async def test_sync_copies_missing_tenants_in_their_tenant_context_then_projects(): + seen_tenants = [] + + async def copy(tenant_id): + seen_tenants.append((tenant_id, get_current_tenant_id())) + + project = AsyncMock() + with ( + patch.object(init_data, "settings", _settings(openfga=True, multi_tenant=True)), + patch.object(init_data, "_project_web_fetch_tool_types", project), + patch.object(init_data, "_tenants_missing_web_fetch_tool", AsyncMock(return_value=[3, 5])), + patch("bisheng.workstation.domain.services.WorkStationService.acopy_root_builtin_tools_to_tenant", copy), + ): + await init_data._sync_web_fetch_tool() + + assert seen_tenants == [(3, 3), (5, 5)] + assert get_current_tenant_id() is None + project.assert_awaited_once() + + +async def test_sync_skips_projection_and_copy_when_disabled(): + project = AsyncMock() + missing = AsyncMock(return_value=[3]) + with ( + patch.object(init_data, "settings", _settings(openfga=False, multi_tenant=False)), + patch.object(init_data, "_project_web_fetch_tool_types", project), + patch.object(init_data, "_tenants_missing_web_fetch_tool", missing), + ): + await init_data._sync_web_fetch_tool() + + project.assert_not_awaited() + missing.assert_not_awaited() + + +async def test_sync_failure_does_not_break_startup(): + copy = AsyncMock(side_effect=[RuntimeError("tenant 3 broken"), None]) + with ( + patch.object(init_data, "settings", _settings(openfga=True, multi_tenant=True)), + patch.object(init_data, "_project_web_fetch_tool_types", AsyncMock(side_effect=RuntimeError("fga down"))), + patch.object(init_data, "_tenants_missing_web_fetch_tool", AsyncMock(return_value=[3, 5])), + patch("bisheng.workstation.domain.services.WorkStationService.acopy_root_builtin_tools_to_tenant", copy), + ): + await init_data._sync_web_fetch_tool() + + assert copy.await_count == 2 + + +def _spec(tenant_id: int, type_id: int) -> SystemOwnedResourceSpec: + return SystemOwnedResourceSpec( + tenant_id=tenant_id, + resource_type="tool", + resource_id=str(type_id), + action_codes=("use",), + context_version=f"v{tenant_id}", + ) + + +async def test_projection_writes_only_missing_types_each_in_its_tenant_context(): + # Tenant 1 is projected; tenants 3 and 5 have rows but no projection (a failed copy). + inventory = SimpleNamespace(resources=(_spec(1, 18), _spec(3, 30), _spec(5, 50), _spec(5, 51))) + states = [ + SimpleNamespace(resource=_spec(1, 18), state="CURRENT"), + SimpleNamespace(resource=_spec(3, 30), state="MISSING"), + SimpleNamespace(resource=_spec(5, 50), state="MISSING"), + ] + written = [] + + async def reconcile(runtime, specs, *, apply, operator_id): + written.append((specs[0].tenant_id, get_current_tenant_id())) + if specs[0].tenant_id == 3: + raise RuntimeError("one tenant fails") + + components = SimpleNamespace(facade=object(), marker=SimpleNamespace(wait_until_ready=AsyncMock())) + with ( + patch.object(init_data, "settings", _settings(openfga=True, multi_tenant=True)), + patch.object(init_data, "_web_fetch_tool_types_by_tenant", AsyncMock(return_value={1: 18, 3: 30, 5: 50})), + patch( + "bisheng.api.services.f048_system_resource_bootstrap.load_system_owned_resource_inventory", + AsyncMock(return_value=inventory), + ), + patch( + "bisheng.permission.application.system_resource_reconcile.inspect_system_owned_resources", + AsyncMock(return_value=states), + ) as inspect, + patch( + "bisheng.permission.application.process_runtime.get_f048_process_runtime", + AsyncMock(return_value=SimpleNamespace(components=components)), + ), + patch("bisheng.permission.application.system_resource_reconcile.reconcile_system_owned_resources", reconcile), + ): + await init_data._project_web_fetch_tool_types() + + # Tenant 5's other preset type (51) is not a web fetch type and is not inspected. + assert {spec.resource_id for spec in inspect.await_args.args[0]} == {"18", "30", "50"} + assert written == [(3, 3), (5, 5)] + assert get_current_tenant_id() is None + + +async def test_projection_writes_nothing_when_all_current(): + inventory = SimpleNamespace(resources=(_spec(1, 18),)) + runtime = AsyncMock() + with ( + patch.object(init_data, "_web_fetch_tool_types_by_tenant", AsyncMock(return_value={1: 18})), + patch( + "bisheng.api.services.f048_system_resource_bootstrap.load_system_owned_resource_inventory", + AsyncMock(return_value=inventory), + ), + patch( + "bisheng.permission.application.system_resource_reconcile.inspect_system_owned_resources", + AsyncMock(return_value=[SimpleNamespace(resource=_spec(1, 18), state="CURRENT")]), + ), + patch("bisheng.permission.application.process_runtime.get_f048_process_runtime", runtime), + ): + await init_data._project_web_fetch_tool_types() + + runtime.assert_not_awaited() diff --git a/src/backend/test/tool/test_f048_tool_permissions.py b/src/backend/test/tool/test_f048_tool_permissions.py index 1d2de242bd..6be8e5bc46 100644 --- a/src/backend/test/tool/test_f048_tool_permissions.py +++ b/src/backend/test/tool/test_f048_tool_permissions.py @@ -72,7 +72,6 @@ def _record( tenant_id: int = 5, status: str = "ACTIVE", preset: bool = False, - system_allowlisted: bool = False, ) -> ToolPermissionRecord: return ToolPermissionRecord( tenant_id=tenant_id, @@ -82,7 +81,6 @@ def _record( permission_version=2, context_version=f"tool-{resource_id}-v2", preset=preset, - system_allowlisted=system_allowlisted, ) @@ -149,7 +147,7 @@ async def test_custom_tool_create_projects_protected_owner() -> None: @pytest.mark.asyncio async def test_preset_tool_create_projects_read_only_system_actions() -> None: - preset = _record(preset=True, system_allowlisted=True) + preset = _record(preset=True) permission = _Permission() adapter = F048ToolPermissionAdapter( loader=_Loader((preset,)), @@ -164,12 +162,13 @@ async def test_preset_tool_create_projects_read_only_system_actions() -> None: @pytest.mark.asyncio -async def test_preset_tool_requires_both_predicate_and_read_allowlist() -> None: - preset = _record(preset=True, system_allowlisted=True) +async def test_preset_tool_allows_creator_and_keeps_read_only_actions() -> None: + preset = _record(preset=True) adapter = F048ToolPermissionAdapter( loader=_Loader((preset,)), permission=_Permission(), ) + assert await adapter.check_action(resource_id="10", actor=_actor(), action="manage_permission") assert await adapter.check_action( resource_id="10", actor=_actor(), @@ -182,17 +181,18 @@ async def test_preset_tool_requires_both_predicate_and_read_allowlist() -> None: action="edit", ) - blocked = replace(preset, system_allowlisted=False) + with_creator = replace(preset, owner_user_id=7) adapter = F048ToolPermissionAdapter( - loader=_Loader((blocked,)), + loader=_Loader((with_creator,)), permission=_Permission(), ) - with pytest.raises(PermissionInvalidResourceError): - await adapter.resolve_permission_target( - resource_id="10", - actor=_actor(), - action="visible", - ) + assert await adapter.check_action( + resource_id="10", + actor=_actor(), + action="use", + ) + with pytest.raises(InvalidCatalogActionError): + await adapter.resolve_permission_target(resource_id="10", actor=_actor(), action="edit") @pytest.mark.asyncio @@ -327,6 +327,10 @@ async def test_visible_tool_list_enumerates_ids_before_database_query( user_role=[], ) + monkeypatch.setattr( + "bisheng.tool.domain.services.tool.batch_check_business_actions", + AsyncMock(return_value={"10": {"visible"}, "20": {"visible"}}), + ) result = await ToolServices(login_user=login_user).get_tool_list(action="visible") assert [item.id for item in result] == [20, 10] @@ -363,7 +367,11 @@ async def test_visible_preset_tool_list_is_visible_to_regular_users(monkeypatch) ) login_user = UserPayload(user_id=7, tenant_id=1, user_role=[]) + decisions = AsyncMock(return_value={"20": {"visible"}}) + monkeypatch.setattr("bisheng.tool.domain.services.tool.batch_check_business_actions", decisions) result = await ToolServices(login_user=login_user).get_tool_list(is_preset=1, action="visible") + decisions.return_value = {} + assert await ToolServices(login_user=login_user).get_tool_list(is_preset=1, action="visible") == [] assert [item.id for item in result] == [20] assert result[0].write is False diff --git a/src/backend/test/tool/test_mcp_refresh_errors.py b/src/backend/test/tool/test_mcp_refresh_errors.py new file mode 100644 index 0000000000..c320e72114 --- /dev/null +++ b/src/backend/test/tool/test_mcp_refresh_errors.py @@ -0,0 +1,55 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import httpx +import pytest +from httpx_sse import SSEError + +from bisheng.tool.domain.services.mcp_errors import mcp_error_code + + +@pytest.mark.parametrize( + ("error", "code"), + [ + (httpx.ConnectError("secret endpoint"), 15026), + (httpx.ReadTimeout("secret endpoint"), 15027), + (TimeoutError("secret endpoint"), 15027), + (SSEError("wrong content type"), 15030), + (httpx.UnsupportedProtocol("secret endpoint"), 15031), + (Exception("secret token"), 15032), + ], +) +def test_nested_transport_errors_have_safe_business_codes(error, code): + assert mcp_error_code(ExceptionGroup("task group", [error])).Code == code + + +@pytest.mark.parametrize(("status", "code"), [(401, 15028), (403, 15028), (404, 15029), (500, 15032)]) +def test_http_errors_have_specific_business_codes(status, code): + response = httpx.Response(status, request=httpx.Request("GET", "https://example.com?key=secret")) + error = httpx.HTTPStatusError("secret", request=response.request, response=response) + assert mcp_error_code(error).Code == code + + +async def test_refresh_reports_each_failure_and_continues(monkeypatch): + from bisheng.tool.domain.services import tool as module + + monkeypatch.setattr( + module.GptsToolsDao, + "aget_user_tool_type", + AsyncMock( + return_value=[ + SimpleNamespace(id=1, name="Offline"), + SimpleNamespace(id=2, name="Working"), + ] + ), + ) + monkeypatch.setattr(module.GptsToolsDao, "aget_list_by_type", AsyncMock(return_value=[])) + monkeypatch.setattr(module, "require_business_action", AsyncMock()) + service = SimpleNamespace( + login_user=SimpleNamespace(user_id=1), + refresh_mcp_tools=AsyncMock( + side_effect=[ExceptionGroup("transport", [httpx.ConnectError("secret")]), None], + ), + ) + assert await module.ToolServices.refresh_all_mcp(service) == [{"name": "Offline", "code": 15026}] + assert service.refresh_mcp_tools.await_count == 2 diff --git a/src/backend/test/tool/test_parse_mcp_schema_errors.py b/src/backend/test/tool/test_parse_mcp_schema_errors.py index 47a156ffc6..95c7b1426a 100644 --- a/src/backend/test/tool/test_parse_mcp_schema_errors.py +++ b/src/backend/test/tool/test_parse_mcp_schema_errors.py @@ -1,8 +1,11 @@ -"""MCP schema connect failures must surface as 15024, not an unhandled 500.""" +"""MCP schema connect failures must surface as a connection error, not an unhandled 500.""" + +import asyncio +from unittest.mock import AsyncMock import pytest -from bisheng.common.errcode.tool import ToolMcpSchemaError +from bisheng.common.errcode.tool import ToolMcpRequestError, ToolMcpTimeoutError from bisheng.tool.domain.services import tool as tool_mod from bisheng.tool.domain.services.tool import ToolServices, _flatten_mcp_error @@ -19,8 +22,57 @@ async def _boom(_payload): monkeypatch.setattr(tool_mod.ClientManager, "connect_mcp_from_json", _boom) - with pytest.raises(ToolMcpSchemaError) as exc_info: + with pytest.raises(ToolMcpRequestError) as exc_info: await ToolServices.parse_mcp_schema( '{"mcpServers":{"remote-tool":{"type":"sse","url":"http://127.0.0.1:9/sse"}}}' ) - assert "Connection closed" in str(exc_info.value) + assert exc_info.value.code == 15032 + assert "Connection closed" not in str(exc_info.value) + + +SCHEMA = '{"mcpServers":{"remote":{"type":"streamable","url":"http://127.0.0.1:9/mcp"}}}' + + +@pytest.mark.parametrize("stage", ["connect", "list_tools"]) +async def test_schema_discovery_times_out_and_cleans_up_stalled_work(monkeypatch, stage): + monkeypatch.setattr(tool_mod, "_MCP_SCHEMA_TIMEOUT_SECONDS", 0.01, raising=False) + cleaned = asyncio.Event() + + async def stalled(*_args): + try: + await asyncio.Event().wait() + finally: + cleaned.set() + + client = AsyncMock() + client.list_tools.side_effect = stalled + connect = stalled if stage == "connect" else AsyncMock(return_value=client) + monkeypatch.setattr(tool_mod.ClientManager, "connect_mcp_from_json", connect) + with pytest.raises(ToolMcpTimeoutError) as exc_info: + await asyncio.wait_for(ToolServices.parse_mcp_schema(SCHEMA), timeout=0.2) + assert exc_info.value.code == 15027 + assert cleaned.is_set() + + +async def test_schema_discovery_returns_tools_before_deadline(monkeypatch): + client = AsyncMock() + client.list_tools.return_value = [] + monkeypatch.setattr(tool_mod.ClientManager, "connect_mcp_from_json", AsyncMock(return_value=client)) + result = await ToolServices.parse_mcp_schema(SCHEMA) + assert result.children == [] + client.list_tools.assert_awaited_once() + + +async def test_schema_discovery_preserves_external_cancellation(monkeypatch): + entered = asyncio.Event() + + async def stalled(_payload): + entered.set() + await asyncio.Event().wait() + + monkeypatch.setattr(tool_mod.ClientManager, "connect_mcp_from_json", stalled) + task = asyncio.create_task(ToolServices.parse_mcp_schema(SCHEMA)) + await asyncio.wait_for(entered.wait(), timeout=1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task diff --git a/src/backend/test/tool/test_parse_preset_extra.py b/src/backend/test/tool/test_parse_preset_extra.py new file mode 100644 index 0000000000..d29a787288 --- /dev/null +++ b/src/backend/test/tool/test_parse_preset_extra.py @@ -0,0 +1,46 @@ +"""Preset extra: tool row first, category only when the row is empty.""" + +from types import SimpleNamespace + +from bisheng.tool.domain.services.executor import ToolExecutor + + +def test_parse_preset_extra_uses_tool_row_when_present(): + tool = SimpleNamespace(extra='{"type": "local"}') + tool_type = SimpleNamespace(extra='{"type": "container"}') + assert ToolExecutor.parse_preset_extra(tool, tool_type) == {"type": "local"} + + +def test_parse_preset_extra_falls_back_to_category_when_row_empty(): + tool = SimpleNamespace(extra=None) + tool_type = SimpleNamespace(extra='{"type": "container"}') + assert ToolExecutor.parse_preset_extra(tool, tool_type) == {"type": "container"} + + +def test_parse_preset_extra_empty_string_row_falls_back(): + tool = SimpleNamespace(extra="") + tool_type = SimpleNamespace(extra='{"type": "e2b"}') + assert ToolExecutor.parse_preset_extra(tool, tool_type) == {"type": "e2b"} + + +def test_parse_preset_extra_copies_dict_payload(): + stored = {"type": "container", "config": {"container": {}}} + tool = SimpleNamespace(extra=stored) + parsed = ToolExecutor.parse_preset_extra(tool, None) + parsed["type"] = "local" + parsed["config"]["container"]["keep_session"] = True + assert stored["type"] == "container" + assert stored["config"]["container"] == {} + + +def test_parse_preset_extra_keeps_non_json_runtime_objects(): + # The task-mode code interpreter stores E2B WriteEntry objects in extra before + # init; a JSON round trip raised TypeError and silently dropped the tool. + from e2b.sandbox.filesystem.filesystem import WriteEntry + + entry = WriteEntry(data="/ws/skills/a.py", path="./skills/a.py") + stored = {"type": "local", "config": {"e2b": {"file_list": [entry]}}} + parsed = ToolExecutor.parse_preset_extra(SimpleNamespace(extra=stored), None) + assert parsed["config"]["e2b"]["file_list"][0].path == "./skills/a.py" + parsed["config"]["e2b"]["file_list"].clear() + assert len(stored["config"]["e2b"]["file_list"]) == 1 diff --git a/src/backend/test/tool/test_preset_tool_classification.py b/src/backend/test/tool/test_preset_tool_classification.py new file mode 100644 index 0000000000..b494634b9a --- /dev/null +++ b/src/backend/test/tool/test_preset_tool_classification.py @@ -0,0 +1,181 @@ +"""Preset classification must not depend on historical creator metadata.""" + +from contextlib import asynccontextmanager +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.pool import StaticPool +from sqlmodel import SQLModel, select +from sqlmodel.ext.asyncio.session import AsyncSession + +from bisheng.common.dependencies.user_deps import UserPayload +from bisheng.common.errcode.permission import InvalidCatalogActionError, PermissionInvalidResourceError +from bisheng.core.context.tenant import bypass_tenant_filter +from bisheng.permission.domain.services.permission_action_service import PermissionActor +from bisheng.tool.domain.const import ToolPresetType +from bisheng.tool.domain.models.gpts_tools import GptsTools, GptsToolsDao, GptsToolsType +from bisheng.tool.domain.services.f048_tool_permission import F048ToolPermissionAdapter, ToolDaoPermissionLoader +from bisheng.tool.domain.services.permission_migration_source import ( + SqlToolMigrationRepository, + ToolPermissionMigrationSource, +) +from bisheng.tool.domain.services.tool import ToolServices +from bisheng.workstation.domain.services.workstation_service import WorkStationService + + +@pytest.mark.parametrize("owner_user_id", [None, 1, 7]) +@pytest.mark.parametrize("tool_kind", [ToolPresetType.API, ToolPresetType.PRESET, ToolPresetType.MCP]) +async def test_runtime_classification_uses_type_and_keeps_action_guards(monkeypatch, owner_user_id, tool_kind): + row = GptsToolsType(id=902, tenant_id=5, user_id=owner_user_id, name="Tool", is_preset=tool_kind.value) + monkeypatch.setattr(GptsToolsDao, "aget_one_tool_type", AsyncMock(return_value=row)) + versions = SimpleNamespace(get_permission_version=AsyncMock(return_value=(2, "tool-context"))) + loader = ToolDaoPermissionLoader(versions) + permission = SimpleNamespace(check_action=AsyncMock(return_value=True)) + adapter = F048ToolPermissionAdapter(loader=loader, permission=permission) + actor = PermissionActor(user_id=7, current_tenant_id=5) + + record = await loader.load_permission_record("902") + + assert record.preset is (tool_kind == ToolPresetType.PRESET) + assert record.owner_user_id == owner_user_id + if tool_kind != ToolPresetType.PRESET and owner_user_id is None: + with pytest.raises(PermissionInvalidResourceError): + await adapter.check_action(resource_id="902", actor=actor, action="use") + permission.check_action.assert_not_awaited() + return + + assert await adapter.check_action(resource_id="902", actor=actor, action="use") + if tool_kind == ToolPresetType.PRESET: + with pytest.raises(InvalidCatalogActionError): + await adapter.resolve_permission_target(resource_id="902", actor=actor, action="edit") + else: + assert await adapter.check_action(resource_id="902", actor=actor, action="edit") + + +@pytest.mark.parametrize("owner_user_id", [None, 1, 7]) +@pytest.mark.parametrize("tool_kind", [ToolPresetType.API, ToolPresetType.PRESET, ToolPresetType.MCP]) +def test_new_permission_record_uses_type_independently_of_creator(owner_user_id, tool_kind): + row = GptsToolsType(id=902, tenant_id=5, user_id=owner_user_id, name="Tool", is_preset=tool_kind.value) + + record = ToolServices._new_permission_record(row) + + assert record.preset is (tool_kind == ToolPresetType.PRESET) + assert record.owner_user_id == owner_user_id + + +@pytest.mark.parametrize("allowed", [True, False]) +async def test_workstation_presets_with_creator_still_require_use_permission(monkeypatch, allowed): + from bisheng.permission.application import business_authorization + + row = GptsToolsType(id=902, tenant_id=5, user_id=1, name="Web search", is_preset=1) + monkeypatch.setattr(GptsToolsDao, "aget_one_tool_type", AsyncMock(return_value=row)) + loader = ToolDaoPermissionLoader(SimpleNamespace(get_permission_version=AsyncMock(return_value=(2, "context")))) + adapter = F048ToolPermissionAdapter(loader=loader, permission=SimpleNamespace()) + actor = PermissionActor(user_id=7, current_tenant_id=5) + + async def resolve(*, resource_type, resource_id, actor, action): + assert resource_type == "tool" + return await adapter.resolve_permission_target(resource_id=resource_id, actor=actor, action=action) + + runtime = SimpleNamespace(batch_check_actions=AsyncMock(return_value=(allowed,))) + monkeypatch.setattr(business_authorization, "resolve_permission_actor", AsyncMock(return_value=actor)) + monkeypatch.setattr( + business_authorization, "get_f048_resource_registry", AsyncMock(return_value=SimpleNamespace(resolve=resolve)) + ) + monkeypatch.setattr(business_authorization, "get_f048_runtime", AsyncMock(return_value=runtime)) + groups = [{"id": 902, "name": "Web search", "children": [{"id": 123, "tool_key": "web_search"}]}] + user = UserPayload(user_id=7, tenant_id=5, user_role=[]) + + result = await WorkStationService.afilter_tools_by_use_permission(groups, user) + + assert result == (groups if allowed else []) + runtime.batch_check_actions.assert_awaited_once() + assert runtime.batch_check_actions.await_args.args[0] == actor + assert runtime.batch_check_actions.await_args.args[1][0].resource_id == "902" + assert runtime.batch_check_actions.await_args.args[2] == "use" + + +@pytest.fixture +async def tool_session_factory(): + engine = create_async_engine("sqlite+aiosqlite://", poolclass=StaticPool) + async with engine.begin() as connection: + await connection.run_sync(SQLModel.metadata.create_all, tables=[GptsToolsType.__table__, GptsTools.__table__]) + + @asynccontextmanager + async def factory(): + # These tests exercise classification, not request-level tenant selection. + with bypass_tenant_filter(): + async with AsyncSession(engine, expire_on_commit=False) as session: + yield session + + try: + yield factory + finally: + await engine.dispose() + + +async def test_sql_migration_classifies_only_presets_as_system_owned(tool_session_factory): + async with tool_session_factory() as session: + session.add_all( + [ + GptsToolsType(id=6, tenant_id=1, name="Preset", is_preset=1, user_id=None), + GptsToolsType(id=902, tenant_id=1, name="Legacy preset", is_preset=1, user_id=1), + GptsToolsType(id=1323, tenant_id=1, name="API", is_preset=0, user_id=7), + GptsToolsType(id=1324, tenant_id=1, name="MCP", is_preset=2, user_id=None), + ] + ) + await session.commit() + source = ToolPermissionMigrationSource(SqlToolMigrationRepository(session_factory=tool_session_factory)) + + page = await source.aexport(cursor=None, limit=10) + + assert [(item.resource_id, item.ownership_kind, item.system_allowlisted) for item in page.items] == [ + ("6", "SYSTEM", True), + ("902", "SYSTEM", True), + ("1323", "USER", False), + ("1324", "USER", False), + ] + assert page.items[1].owner_user_id == 1 + + +@pytest.mark.parametrize("owner_user_id", [None, 1]) +async def test_tenant_copy_projects_presets_with_historical_creator(monkeypatch, tool_session_factory, owner_user_id): + from bisheng.workstation.domain.services import workstation_service + + async with tool_session_factory() as session: + session.add(GptsToolsType(id=902, tenant_id=1, name="Web search", is_preset=1, user_id=owner_user_id)) + session.add( + GptsTools( + id=10, + tenant_id=1, + type=902, + name="Web search", + tool_key="web_search", + is_preset=1, + user_id=owner_user_id, + ) + ) + await session.commit() + monkeypatch.setattr(workstation_service, "get_async_db_session", tool_session_factory) + monkeypatch.setattr(workstation_service, "settings", SimpleNamespace(openfga=SimpleNamespace(enabled=True))) + runtime = object() + monkeypatch.setattr(workstation_service, "get_f048_runtime", AsyncMock(return_value=runtime)) + reconcile = AsyncMock(return_value=SimpleNamespace(current_count=1)) + monkeypatch.setattr(workstation_service, "reconcile_system_owned_resources", reconcile) + + result = await WorkStationService.acopy_root_builtin_tools_to_tenant(3) + + assert result["created_types"] == result["created_tools"] == result["projected_types"] == 1 + async with tool_session_factory() as session: + copied_type = (await session.exec(select(GptsToolsType).where(GptsToolsType.tenant_id == 3))).one() + copied_tool = (await session.exec(select(GptsTools).where(GptsTools.tenant_id == 3))).one() + assert copied_type.is_preset == copied_tool.is_preset == 1 + assert copied_type.user_id == copied_tool.user_id == owner_user_id + reconcile.assert_awaited_once() + specs = reconcile.await_args.args[1] + assert len(specs) == 1 + assert specs[0].tenant_id == 3 + assert specs[0].resource_id == str(copied_type.id) + assert specs[0].action_codes == ("use", "visible") diff --git a/src/backend/test/tool/test_preset_tool_seed_ids.py b/src/backend/test/tool/test_preset_tool_seed_ids.py new file mode 100644 index 0000000000..653c542351 --- /dev/null +++ b/src/backend/test/tool/test_preset_tool_seed_ids.py @@ -0,0 +1,38 @@ +"""Preset tool seed rows must have unique ids and resolve to their own category. + +Two branches each claimed category id 18 (F085 web fetch, built-in knowledge +search); the merge kept both, so fresh installs would insert a duplicate primary +key and register_work_knowledge_tool.py resolved knowledge search to the web +fetch category. +""" + +from __future__ import annotations + +import json +from collections import Counter +from pathlib import Path + +_DATA = Path(__file__).resolve().parents[2] / "bisheng" / "database" / "data" + + +def _load(name: str) -> list[dict]: + return json.loads((_DATA / name).read_text(encoding="utf-8")) + + +def test_seed_ids_are_unique(): + for name in ("t_gpts_tools.json", "t_gpts_tools_type.json"): + dup = [i for i, n in Counter(row["id"] for row in _load(name)).items() if n > 1] + assert dup == [], f"{name} has duplicate ids: {dup}" + + +def test_every_seed_tool_references_an_existing_category(): + category_ids = {row["id"] for row in _load("t_gpts_tools_type.json")} + orphans = [row["tool_key"] for row in _load("t_gpts_tools.json") if row["type"] not in category_ids] + assert orphans == [] + + +def test_built_in_tools_resolve_to_their_own_category(): + categories = {row["id"]: row["name"] for row in _load("t_gpts_tools_type.json")} + tools = {row["tool_key"]: row for row in _load("t_gpts_tools.json")} + assert categories[tools["web_fetch"]["type"]] == "网页读取" + assert categories[tools["bisheng_knowledge_search"]["type"]] == "知识库检索" diff --git a/src/backend/test/tool/test_tool_executor_availability.py b/src/backend/test/tool/test_tool_executor_availability.py new file mode 100644 index 0000000000..3d60e9b15e --- /dev/null +++ b/src/backend/test/tool/test_tool_executor_availability.py @@ -0,0 +1,85 @@ +"""Exercise runtime guards with provider calls and telemetry isolated.""" + +import asyncio +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock, patch + +from bisheng.common.constants.enums.telemetry import ApplicationTypeEnum +from bisheng.tool.domain.services.executor import ToolExecutor + + +class ToolAvailabilityRuntimeTests(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.availability = SimpleNamespace(ensure_enabled=AsyncMock(), ensure_enabled_sync=Mock()) + self.service = patch( + "bisheng.tool.domain.services.executor.UserToolAvailabilityService", return_value=self.availability + ) + self.service.start() + self.addCleanup(self.service.stop) + self.tool = SimpleNamespace( + _require_current_use=AsyncMock(), + tool_id=7, + tool_key="example", + tool_is_preset=1, + user_id=42, + app_type=ApplicationTypeEnum.DAILY_CHAT, + tool_instance=SimpleNamespace(_run=Mock(return_value="ok"), _arun=AsyncMock(return_value="ok")), + ) + + async def test_all_tool_kinds_recheck_account_state_on_every_call(self): + for app in (ApplicationTypeEnum.DAILY_CHAT, ApplicationTypeEnum.LINSIGHT, ApplicationTypeEnum.DSH_DESKTOP): + for kind in (0, 1, 2): + with self.subTest(app=app, kind=kind): + self.tool.app_type, self.tool.tool_is_preset = app, kind + self.availability.ensure_enabled.side_effect = None + self.availability.ensure_enabled_sync.side_effect = None + self.assertEqual(await ToolExecutor._arun.__wrapped__(self.tool, config={}), "ok") + self.assertEqual(await asyncio.to_thread(ToolExecutor._run.__wrapped__, self.tool, config={}), "ok") + self.tool.tool_instance._run.reset_mock() + self.tool.tool_instance._arun.reset_mock() + self.availability.ensure_enabled.side_effect = PermissionError("disabled") + self.availability.ensure_enabled_sync.side_effect = PermissionError("disabled") + with self.assertRaises(PermissionError): + await ToolExecutor._arun.__wrapped__(self.tool, config={}) + with self.assertRaises(PermissionError): + await asyncio.to_thread(ToolExecutor._run.__wrapped__, self.tool, config={}) + self.tool.tool_instance._run.assert_not_called() + self.tool.tool_instance._arun.assert_not_awaited() + + async def test_revoked_enterprise_grant_blocks_existing_tool_instance(self): + self.tool._require_current_use.side_effect = PermissionError("revoked") + with self.assertRaises(PermissionError): + await ToolExecutor._arun.__wrapped__(self.tool, config={}) + with self.assertRaises(PermissionError): + await asyncio.to_thread(ToolExecutor._run.__wrapped__, self.tool, config={}) + self.tool.tool_instance._arun.assert_not_awaited() + self.tool.tool_instance._run.assert_not_called() + + async def test_published_apps_keep_their_own_configuration(self): + self.availability.ensure_enabled.side_effect = PermissionError("disabled") + self.availability.ensure_enabled_sync.side_effect = PermissionError("disabled") + for app in (ApplicationTypeEnum.WORKFLOW, ApplicationTypeEnum.ASSISTANT): + self.tool.app_type = app + self.assertEqual(await ToolExecutor._arun.__wrapped__(self.tool, config={}), "ok") + self.assertEqual(await asyncio.to_thread(ToolExecutor._run.__wrapped__, self.tool, config={}), "ok") + self.availability.ensure_enabled.assert_not_awaited() + self.availability.ensure_enabled_sync.assert_not_called() + + def test_disabled_selection_is_rejected_before_provider_initialization(self): + self.availability.ensure_enabled_sync.side_effect = PermissionError("disabled") + with patch.object(ToolExecutor, "_init_by_tool_and_type") as initialize: + with self.assertRaises(PermissionError): + ToolExecutor._init_by_tool_and_type_with_context( + SimpleNamespace(id=7, is_preset=2), + SimpleNamespace(), + app_id="test", + app_name="test", + app_type=ApplicationTypeEnum.DSH_DESKTOP, + user_id=42, + ) + initialize.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/src/backend/test/tool/test_user_tool_availability.py b/src/backend/test/tool/test_user_tool_availability.py new file mode 100644 index 0000000000..a1b7a0854c --- /dev/null +++ b/src/backend/test/tool/test_user_tool_availability.py @@ -0,0 +1,100 @@ +from contextlib import asynccontextmanager, contextmanager +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.ext.asyncio import create_async_engine +from sqlmodel import Session +from sqlmodel.ext.asyncio.session import AsyncSession + +from bisheng.common.models.config import Config +from bisheng.core.context.tenant import current_tenant_id, set_current_tenant_id +from bisheng.tool.domain.repositories import user_availability as repository_module +from bisheng.tool.domain.repositories.user_availability import UserToolAvailabilityRepository +from bisheng.tool.domain.services.user_availability import UserToolAvailabilityService, manages_availability + + +@pytest.fixture +async def availability_store(tmp_path, monkeypatch): + path = tmp_path / "tool-availability.db" + engine = create_async_engine(f"sqlite+aiosqlite:///{path}") + sync_engine = create_engine(f"sqlite:///{path}") + async with engine.begin() as connection: + await connection.run_sync(lambda sync: Config.__table__.create(sync)) + + @asynccontextmanager + async def sessions(): + async with AsyncSession(engine) as session: + yield session + + @contextmanager + def sync_sessions(): + with Session(sync_engine) as session: + yield session + + monkeypatch.setattr(repository_module, "get_async_db_session", sessions) + monkeypatch.setattr(repository_module, "get_sync_db_session", sync_sessions) + token = set_current_tenant_id(2) + try: + yield UserToolAvailabilityRepository() + finally: + current_tenant_id.reset(token) + await engine.dispose() + sync_engine.dispose() + + +async def test_availability_persists_and_isolates_accounts_and_tenants(availability_store): + store = availability_store + assert store.enabled_sync(7, 38) + await store.save(7, 38, False) + assert await store.disabled_ids(7) == {38} + assert not store.enabled_sync(7, 38) + assert store.enabled_sync(8, 38) + token = set_current_tenant_id(3) + try: + assert await store.disabled_ids(7) == set() + assert store.enabled_sync(7, 38) + finally: + current_tenant_id.reset(token) + await store.save(7, 38, True) + assert await store.disabled_ids(7) == set() + assert store.enabled_sync(7, 38) + + +async def test_closed_tool_is_denied_by_async_and_sync_execution_guards(availability_store): + service = UserToolAvailabilityService(7, availability_store) + await service.save(38, False) + with pytest.raises(PermissionError): + await service.ensure_enabled(38) + with pytest.raises(PermissionError): + service.ensure_enabled_sync(38) + await service.ensure_enabled(1) + await service.save(38, True) + await service.ensure_enabled(38) + service.ensure_enabled_sync(38) + + +@pytest.mark.parametrize("kind", [0, 1, 2]) +async def test_all_tool_kinds_read_account_availability(kind): + repository = SimpleNamespace(disabled_ids=AsyncMock(return_value={38})) + service = UserToolAvailabilityService(7, repository) + tool = SimpleNamespace(id=38, is_preset=kind, tool_key="example") + assert manages_availability(tool) + assert await service.disabled_ids([tool]) == {38} + repository.disabled_ids.assert_awaited_once_with(7) + + +@pytest.mark.parametrize("app_type, expected", [("daily_chat", True), ("linsight", True), ("dsh_desktop", True), ("workflow", False), ("assistant", False)]) +def test_personal_preferences_apply_to_chat_and_work(app_type, expected): + from bisheng.tool.domain.services.user_availability import uses_account_availability + assert uses_account_availability(app_type) is expected + + +def test_missing_tenant_cannot_read_an_account_setting(): + token = current_tenant_id.set(None) + try: + with pytest.raises(PermissionError): + UserToolAvailabilityRepository.prefix(7) + finally: + current_tenant_id.reset(token) diff --git a/src/backend/test/tool/test_web_fetch_tool.py b/src/backend/test/tool/test_web_fetch_tool.py new file mode 100644 index 0000000000..414036875c --- /dev/null +++ b/src/backend/test/tool/test_web_fetch_tool.py @@ -0,0 +1,183 @@ +"""The web fetch built-in tool: engine request, post-processing and failure text. + +Every failure is returned to the model as text so the conversation or task keeps +running (F085 design decision 7). +""" + +import json +from unittest.mock import MagicMock, patch + +import requests + +from bisheng_langchain.gpts.load_tools import load_tools +from bisheng_langchain.gpts.tools.web_fetch import tool as web_fetch_tool +from bisheng_langchain.gpts.tools.web_fetch.tool import ( + DEFAULT_MAX_CHARS, + DeepSeekFetch, + WebFetchTool, + unescape_markdown, +) + +DEEPSEEK_URL = "https://api.deepseek.com/v1/tools/fetch" + + +def _response(status_code: int, payload: dict): + response = MagicMock(status_code=status_code) + response.json.return_value = payload + response.text = json.dumps(payload) + return response + + +def _build(extra: dict | None = None) -> WebFetchTool: + if extra is None: + extra = {"type": "deepseek", "config": {"deepseek": {"api_key": "sk-test", "base_url": ""}}} + return load_tools({"web_fetch": extra})[0] + + +def _fetch(tool: WebFetchTool, url: str, payload: dict, status_code: int = 200): + with patch.object(web_fetch_tool.requests, "post", return_value=_response(status_code, payload)) as call: + output = tool.invoke({"url": url}) + return output, call + + +def test_load_tools_builds_web_fetch_tool(): + tool = _build() + + assert isinstance(tool, WebFetchTool) + assert tool.name == "web_fetch" + assert isinstance(tool.api_wrapper, DeepSeekFetch) + assert tool.max_chars == DEFAULT_MAX_CHARS + + +def test_request_uses_default_endpoint_bearer_key_and_timeout(): + output, call = _fetch(_build(), "https://example.com/a", {"title": "A", "content": "Body"}) + + assert call.call_args.args[0] == DEEPSEEK_URL + assert call.call_args.kwargs["headers"]["Authorization"] == "Bearer sk-test" + assert call.call_args.kwargs["json"] == {"url": "https://example.com/a"} + assert call.call_args.kwargs["timeout"] == (10, 60) + page = json.loads(output) + assert page == { + "title": "A", + "url": "https://example.com/a", + "source": "example.com", + "content": "Body", + "truncated": False, + "total_chars": 4, + } + + +def test_base_url_override(): + tool = _build( + {"type": "deepseek", "config": {"deepseek": {"api_key": "k", "base_url": "https://proxy.example.com/f"}}} + ) + + _, call = _fetch(tool, "https://example.com/a", {"title": "A", "content": "Body"}) + + assert call.call_args.args[0] == "https://proxy.example.com/f" + + +def test_engine_escapes_are_removed_outside_code_blocks(): + content = "\\# Title\n\nA\\-B\\: c\\, d\\. snake\\_case\n\n```\npath\\-keep \\#\n```\n\nend\\!" + + assert unescape_markdown(content) == "# Title\n\nA-B: c, d. snake_case\n\n```\npath\\-keep \\#\n```\n\nend!" + + +def test_escaped_letters_and_cjk_are_kept(): + assert unescape_markdown("C:\\new \\中") == "C:\\new \\中" + + +def test_content_over_limit_is_truncated_with_note(): + tool = _build({"type": "deepseek", "config": {"deepseek": {"api_key": "k"}}, "max_chars": 1000}) + + output, _ = _fetch(tool, "https://example.com/long", {"title": "Long", "content": "x" * 1500}) + + page = json.loads(output) + assert page["content"] == "x" * 1000 + assert page["truncated"] is True + assert page["total_chars"] == 1500 + assert "1500" in page["note"] + + +def test_content_equal_to_limit_is_not_truncated(): + tool = _build({"type": "deepseek", "config": {"deepseek": {"api_key": "k"}}, "max_chars": 1000}) + + output, _ = _fetch(tool, "https://example.com/exact", {"title": "Exact", "content": "y" * 1000}) + + page = json.loads(output) + assert page["truncated"] is False + assert "note" not in page + + +def test_max_chars_is_clamped_to_allowed_range(): + small = _build({"type": "deepseek", "config": {"deepseek": {"api_key": "k"}}, "max_chars": 10}) + large = _build({"type": "deepseek", "config": {"deepseek": {"api_key": "k"}}, "max_chars": "999999"}) + + assert small.max_chars == 1000 + assert large.max_chars == 200000 + + +def test_invalid_urls_do_not_call_engine(): + tool = _build() + + for url in ("ftp://example.com/a", "example.com/a", "https://user:pass@example.com/a", "http://"): + with patch.object(web_fetch_tool.requests, "post") as call: + output = tool.invoke({"url": url}) + call.assert_not_called() + assert output.startswith("网页读取失败") + + +def test_engine_error_is_returned_as_text(): + payload = { + "error": { + "message": "failed to fetch URL (request_id: abc)", + "type": "not_found_error", + "param": None, + "code": "invalid_request_error", + } + } + + output, _ = _fetch(_build(), "https://en.wikipedia.org/wiki/Taiwan", payload, status_code=404) + + assert output.startswith("网页读取失败") + assert "failed to fetch URL" in output + + +def test_non_json_success_body_is_returned_as_text(): + response = MagicMock(status_code=200, text="captive portal") + response.json.side_effect = ValueError("not json") + + with patch.object(web_fetch_tool.requests, "post", return_value=response): + output = _build().invoke({"url": "https://example.com/a"}) + + assert output.startswith("网页读取失败") + + +def test_non_object_json_body_is_returned_as_text(): + output, _ = _fetch(_build(), "https://example.com/a", ["unexpected"]) + + assert output.startswith("网页读取失败") + + +def test_timeout_is_returned_as_text(): + tool = _build() + + with patch.object(web_fetch_tool.requests, "post", side_effect=requests.Timeout("read timed out")): + output = tool.invoke({"url": "https://example.com/slow"}) + + assert output.startswith("网页读取失败") + + +def test_empty_content_is_reported(): + output, _ = _fetch(_build(), "https://example.com/empty", {"title": "Empty", "content": " \n"}) + + assert output == "该网页没有可读正文。" + + +def test_unconfigured_tool_still_builds_and_reports(): + for extra in ({}, {"type": "deepseek", "config": {"deepseek": {"api_key": ""}}}): + tool = _build(extra) + with patch.object(web_fetch_tool.requests, "post") as call: + output = tool.invoke({"url": "https://example.com/a"}) + call.assert_not_called() + assert output.startswith("网页读取尚未配置") diff --git a/src/backend/test/tool/test_web_search_deepseek.py b/src/backend/test/tool/test_web_search_deepseek.py new file mode 100644 index 0000000000..828d5152d0 --- /dev/null +++ b/src/backend/test/tool/test_web_search_deepseek.py @@ -0,0 +1,97 @@ +"""DeepSeek web search maps its results onto the shared web search protocol. + +The citation registry reads that protocol, so a field mapped wrong here shows +up as a citation card without a title, source or date. +""" + +import json +from unittest.mock import MagicMock, patch + +from bisheng.citation.domain.services.citation_prompt_helper import annotate_web_results_with_citations +from bisheng_langchain.gpts.tools.web_search import tool as web_search_tool +from bisheng_langchain.gpts.tools.web_search.tool import DeepSeekSearch, SearchTool + +DEEPSEEK_PAYLOAD = { + "results": [ + { + "rerank_score": 2.4, + "title": "DeepSeek V4 模型发布", + "url": "https://example.com/deepseek-v4", + "snippet": "DeepSeek V4 已上线。", + "summary": "DeepSeek 发布了 V4 模型,改进了工具调用与长上下文能力。", + "content": "", + "site_name": "Example News", + "site_icon": "https://example.com/favicon.ico", + "date": "2026-06-01T00:00:00+00:00", + "authority": 3, + }, + { + "rerank_score": 1.1, + "title": "No snippet page", + "url": "https://other.example.org/page", + "snippet": "", + "summary": "Long abstract only.", + "content": "", + "site_name": None, + "site_icon": None, + "date": None, + "authority": -1, + }, + ] +} + + +def _ok_response(payload): + response = MagicMock(status_code=200) + response.json.return_value = payload + return response + + +def _search(payload=DEEPSEEK_PAYLOAD, **config): + search = SearchTool.init_search_tool("deepseek", **({"api_key": "sk-test"} | config)) + with patch.object(web_search_tool.requests, "post", return_value=_ok_response(payload)) as call: + output = search.invoke("DeepSeek V4") + return search, call, json.loads(output) + + +def test_request_uses_bearer_key_and_default_endpoint(): + search, call, _ = _search() + + assert isinstance(search, DeepSeekSearch) + assert call.call_args.args[0] == "https://api.deepseek.com/v1/tools/search" + assert call.call_args.kwargs["headers"]["Authorization"] == "Bearer sk-test" + assert call.call_args.kwargs["json"]["query"] == "DeepSeek V4" + + +def test_base_url_override(): + _, call, _ = _search(base_url="https://proxy.example.com/ds/search") + + assert call.call_args.args[0] == "https://proxy.example.com/ds/search" + + +def test_results_are_normalized(): + _, _, results = _search() + + first, second = results + assert first["title"] == "DeepSeek V4 模型发布" + assert first["url"] == "https://example.com/deepseek-v4" + assert first["snippet"] == "DeepSeek V4 已上线。" + assert first["source"] == "Example News" + assert first["siteIcon"] == "https://example.com/favicon.ico" + assert first["datePublished"] == "2026-06-01T00:00:00+00:00" + # Missing snippet falls back to the long summary; missing site name to the host. + assert second["snippet"] == "Long abstract only." + assert second["source"] == "other.example.org" + assert second["siteIcon"] is None + assert second["datePublished"] is None + + +def test_results_get_citation_keys(): + _, _, results = _search() + + annotated = annotate_web_results_with_citations(results) + + keys = [item["citation_key"] for item in annotated] + assert len(keys) == 2 + assert all(key.startswith("websearch_") and ":" in key for key in keys) + assert len(set(keys)) == 2 diff --git a/src/backend/test/tool/test_web_search_timeout.py b/src/backend/test/tool/test_web_search_timeout.py index 9c4bf8fd9a..88e6eaa56e 100644 --- a/src/backend/test/tool/test_web_search_timeout.py +++ b/src/backend/test/tool/test_web_search_timeout.py @@ -26,6 +26,7 @@ def _ok_response(payload): ("serp", {"api_key": "k"}, "get", {"organic_results": []}), ("cloudsway", {"api_key": "k", "base_url": "https://cs.example.com"}, "get", {"webPages": {"value": []}}), ("searXNG", {"base_url": "https://searx.example.com"}, "get", {"results": []}), + ("deepseek", {"api_key": "k"}, "post", {"results": []}), ], ) def test_provider_request_has_default_timeout(provider, config, method, payload): diff --git a/src/backend/test/tool/test_work_knowledge_deploy.py b/src/backend/test/tool/test_work_knowledge_deploy.py new file mode 100644 index 0000000000..7381912e59 --- /dev/null +++ b/src/backend/test/tool/test_work_knowledge_deploy.py @@ -0,0 +1,169 @@ +"""Exercise the release entrypoint with a recording Docker command boundary.""" + +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + + +@pytest.fixture +def deploy(tmp_path, request): + root = Path(__file__).resolve().parents[4] + directory = tmp_path / getattr(request, "param", "deployment") + (directory / "scripts").mkdir(parents=True) + (directory / "docker").mkdir() + script = directory / "scripts/deploy.sh" + shutil.copy2(root / "scripts/deploy.sh", script) + log = tmp_path / "calls.jsonl" + docker = tmp_path / "docker" + docker.write_text( + f"#!{sys.executable}\n" + "import json, os, sys\n" + "args = sys.argv[1:]\n" + "with open(os.environ['DEPLOY_TEST_LOG'], 'a') as output:\n" + " output.write(json.dumps(args) + '\\n')\n" + "command = args[3:]\n" + "stage = command[0]\n" + "if stage == 'up': stage = 'backend' if command[-1] == 'backend' else 'remaining'\n" + "if stage == os.environ.get('DEPLOY_TEST_FAIL'): sys.exit(17)\n" + "if stage == 'exec': print(json.dumps({'ready': True}))\n" + ) + docker.chmod(0o755) + + def run(*arguments, fail=""): + log.unlink(missing_ok=True) + result = subprocess.run( + ["bash", str(script), *arguments], + env={ + **os.environ, + "PATH": f"{tmp_path}:{os.environ['PATH']}", + "DEPLOY_TEST_LOG": str(log), + "DEPLOY_TEST_FAIL": fail, + }, + capture_output=True, + text=True, + ) + calls = [json.loads(line) for line in log.read_text().splitlines()] if log.exists() else [] + for call in calls: + assert call[:3] == ["compose", "-f", str(directory / "docker/docker-compose.yml")] + return result, [call[3:] for call in calls] + + return run + + +CONFIRMATION = ("--confirm-store-id", "fixture-store", "--operator-id", "27") + + +@pytest.mark.parametrize("targets", [(), ("backend",), ("frontend", "backend", "worker")]) +def test_backend_release_runs_upgrade_before_remaining_services_and_completion(deploy, targets): + result, calls = deploy("update", *targets, *CONFIRMATION) + assert result.returncode == 0, result.stderr + assert calls[0][0] == "pull" + assert calls[1] == ["up", "-d", "--no-deps", "--pull", "never", "--wait", "--wait-timeout", "180", "backend"] + assert calls[2] == [ + "exec", + "-T", + "backend", + "python", + "scripts/register_work_knowledge_tool.py", + "--apply", + *CONFIRMATION, + ] + if targets == ("backend",): + assert len(calls) == 4 + else: + assert calls[3][0] == "up" + assert "backend_worker" in calls[3] + assert "frontend" in calls[3] + assert calls[-1][0] == "ps" + assert "更新完成" in result.stdout + + +@pytest.mark.parametrize( + "stage,expected_calls", [("pull", 1), ("backend", 2), ("exec", 3), ("remaining", 4), ("ps", 5)] +) +def test_failed_release_stops_at_failed_stage_and_returns_error(deploy, stage, expected_calls): + result, calls = deploy("update", *CONFIRMATION, fail=stage) + assert result.returncode == 17 + assert len(calls) == expected_calls + assert "更新完成" not in result.stdout + if stage == "exec": + assert "backend 已更新" in result.stderr + + +@pytest.mark.parametrize( + "arguments", + [ + (), + ("backend",), + ("--confirm-store-id",), + ("--operator-id", "0"), + ("--operator-id", "-1"), + ("--operator-id", "invalid"), + ("--unknown",), + ("worker", *CONFIRMATION), + ], +) +def test_invalid_confirmation_stops_before_docker_operations(deploy, arguments): + result, calls = deploy("update", *arguments) + assert result.returncode == 2 + assert calls == [] + + +def test_preview_uses_target_image_in_one_off_container_and_keeps_service_running(deploy): + result, calls = deploy("upgrade-preview") + assert result.returncode == 0 + assert calls == [ + ["pull", "backend"], + [ + "run", + "--rm", + "--no-deps", + "-T", + "--pull", + "never", + "backend", + "python", + "scripts/register_work_knowledge_tool.py", + ], + ] + + +@pytest.mark.parametrize("target,service", [("worker", "backend_worker"), ("frontend", "frontend")]) +def test_non_backend_update_preserves_service_scope(deploy, target, service): + result, calls = deploy("update", target) + assert result.returncode == 0 + assert [call[0] for call in calls] == ["pull", "up", "ps"] + assert all(call[-1] == service for call in calls) + + +def test_restart_keeps_existing_runtime_operation(deploy): + result, calls = deploy("restart", "backend") + assert result.returncode == 0 + assert calls == [["restart", "backend"], ["ps", "backend"]] + + +@pytest.mark.parametrize("deploy", ["deployment with spaces"], indirect=True) +def test_compose_path_with_spaces_is_passed_as_one_argument(deploy): + result, calls = deploy("update", "backend", *CONFIRMATION) + assert result.returncode == 0 + assert len(calls) == 4 + + +def test_failed_preview_propagates_status_and_keeps_services_unchanged(deploy): + result, calls = deploy("upgrade-preview", fail="run") + assert result.returncode == 17 + assert [call[0] for call in calls] == ["pull", "run"] + + +def test_release_can_resume_after_maintenance_failure(deploy): + failed, _ = deploy("update", *CONFIRMATION, fail="exec") + assert failed.returncode == 17 + retried, calls = deploy("update", *CONFIRMATION) + assert retried.returncode == 0 + assert calls[2][0] == "exec" + assert calls[-1][0] == "ps" diff --git a/src/backend/test/tool/test_work_knowledge_registration.py b/src/backend/test/tool/test_work_knowledge_registration.py new file mode 100644 index 0000000000..a86d001ebe --- /dev/null +++ b/src/backend/test/tool/test_work_knowledge_registration.py @@ -0,0 +1,84 @@ +"""Catalog registration preserves existing system and administrator state.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from scripts.register_work_knowledge_tool import register, seed_records + + +def session_for(tools=(), categories=(), category=None): + return SimpleNamespace( + exec=AsyncMock( + side_effect=[SimpleNamespace(all=lambda: list(tools)), SimpleNamespace(all=lambda: list(categories))] + ), + get=AsyncMock(return_value=category), + add=Mock(), + flush=AsyncMock(), + commit=AsyncMock(), + ) + + +def row(**kwargs): + return SimpleNamespace(id=80, type=90, tenant_id=1, user_id=None, is_preset=1, is_delete=0, **kwargs) + + +def test_seed_category_and_tool_contract(): + category, tool = seed_records() + assert tool["type"] == category["id"] + assert tool["tool_key"] == "bisheng_knowledge_search" + assert {p["name"] for p in tool["api_params"]} == {"query", "space_ids"} + assert category["user_id"] is None + + +async def test_preview_is_read_only(): + session = session_for() + assert await register(session, apply=False) == { + "status": "planned", + "create_category": True, + "create_tool": True, + } + session.add.assert_not_called() + session.commit.assert_not_awaited() + + +async def test_apply_allocates_database_ids_in_one_transaction(): + session = session_for() + added = [] + session.add.side_effect = added.append + + async def flush(): + added[-1].id = 900 + len(added) + + session.flush.side_effect = flush + result = await register(session, apply=True) + assert result == {"status": "registered", "tool_id": 902, "category_id": 901} + assert added[1].type == 901 + assert all(item.tenant_id == 1 and item.user_id is None for item in added) + session.commit.assert_awaited_once() + + +async def test_rerun_preserves_existing_catalog_configuration(): + session = session_for(tools=[row()], category=row(extra='{"custom":"preserved"}')) + assert await register(session, apply=True) == {"status": "current", "tool_id": 80, "category_id": 80} + session.add.assert_not_called() + session.commit.assert_not_awaited() + + +@pytest.mark.parametrize("field,value", [("user_id", 42), ("tenant_id", 5), ("is_delete", 1), ("is_preset", 0)]) +async def test_conflicting_tool_is_preserved(field, value): + tool = row() + setattr(tool, field, value) + session = session_for(tools=[tool]) + with pytest.raises(ValueError, match="Catalog conflict"): + await register(session, apply=True) + session.add.assert_not_called() + session.commit.assert_not_awaited() + + +async def test_duplicate_keys_require_operator_resolution(): + session = session_for(tools=[row(), row()]) + with pytest.raises(ValueError, match="multiple tools"): + await register(session, apply=True) + session.add.assert_not_called() diff --git a/src/backend/test/tool/test_work_knowledge_tool.py b/src/backend/test/tool/test_work_knowledge_tool.py new file mode 100644 index 0000000000..09bf82ab52 --- /dev/null +++ b/src/backend/test/tool/test_work_knowledge_tool.py @@ -0,0 +1,151 @@ +"""Built-in retrieval uses trusted execution identity and the shared API contract.""" + +from contextlib import asynccontextmanager +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest +from pydantic import ValidationError + +from bisheng.common.constants.enums.telemetry import ApplicationTypeEnum +from bisheng.core.context.tenant import current_tenant_id +from bisheng.tool.domain.langchain import work_knowledge as module +from bisheng.tool.domain.services.executor import ToolExecutor + + +@pytest.fixture +def runtime(monkeypatch): + token = current_tenant_id.set(5) + user = SimpleNamespace(user_id=42, tenant_id=5, is_global_super=False) + identity = AsyncMock(return_value=user) + monkeypatch.setattr(module, "abuild_scoped_login_user", identity) + session = object() + + @asynccontextmanager + async def get_session(): + yield session + + monkeypatch.setattr(module, "get_async_db_session", get_session) + service = SimpleNamespace(aretrieve_chunks=AsyncMock(return_value=[])) + service_factory = Mock(return_value=service) + monkeypatch.setattr(module, "KnowledgeSpaceChatService", service_factory) + version_repo = object() + monkeypatch.setattr(module, "KnowledgeDocumentVersionRepositoryImpl", Mock(return_value=version_repo)) + yield SimpleNamespace( + tool=module.WorkKnowledgeSearch(user_id=42, tenant_id=5), + user=user, + identity=identity, + service=service, + factory=service_factory, + version_repo=version_repo, + ) + current_tenant_id.reset(token) + + +def test_schema_exposes_only_retrieval_inputs(runtime): + assert set(runtime.tool.get_input_schema().model_fields) == {"query", "space_ids"} + assert "user_id" not in runtime.tool.model_dump() + assert "tenant_id" not in runtime.tool.model_dump() + + +@pytest.mark.parametrize("extra", [{"user_id": 1}, {"tenant_id": 1}, {"is_global_super": True}]) +async def test_model_identity_injection_is_rejected(runtime, extra): + with pytest.raises(ValidationError): + await runtime.tool.ainvoke({"query": "q", "space_ids": [7], **extra}) + runtime.identity.assert_not_awaited() + + +async def test_ordinary_user_and_sources_are_preserved(runtime): + runtime.service.aretrieve_chunks.return_value = [ + ( + 7, + SimpleNamespace( + page_content="Allowed passage", + metadata={ + "document_id": 12, + "document_name": "Policy.pdf", + "chunk_index": 3, + }, + ), + ) + ] + result = await runtime.tool.ainvoke({"query": " leave policy ", "space_ids": [7, 7, 9]}) + runtime.identity.assert_awaited_once_with(42, 5) + runtime.factory.assert_called_once_with(request=None, login_user=runtime.user) + assert runtime.service.version_repo is runtime.version_repo + runtime.service.aretrieve_chunks.assert_awaited_once_with( + query="leave policy", knowledge_base_ids=[7, 9], top_k=10, max_content=12000 + ) + assert result == { + "chunks": [ + { + "content": "Allowed passage", + "space_id": 7, + "document_id": 12, + "document_name": "Policy.pdf", + "chunk_index": 3, + } + ] + } + + +async def test_permission_revocation_propagates(runtime): + runtime.service.aretrieve_chunks.side_effect = PermissionError("view_file denied") + with pytest.raises(PermissionError, match="view_file denied"): + await runtime.tool.ainvoke({"query": "q", "space_ids": [7]}) + + +async def test_deleted_execution_user_is_rejected(runtime): + runtime.identity.return_value = None + with pytest.raises(PermissionError, match="execution user"): + await runtime.tool.ainvoke({"query": "q", "space_ids": [7]}) + runtime.factory.assert_not_called() + + +async def test_tenant_context_change_is_rejected_before_retrieval(runtime): + token = current_tenant_id.set(6) + try: + with pytest.raises(PermissionError, match="tenant context"): + await runtime.tool.ainvoke({"query": "q", "space_ids": [7]}) + runtime.identity.assert_not_awaited() + finally: + current_tenant_id.reset(token) + + +def test_sync_invocation_preserves_context_and_empty_result(runtime): + assert runtime.tool.invoke({"query": "q", "space_ids": [7]}) == {"chunks": []} + runtime.identity.assert_awaited_once_with(42, 5) + + +def test_executor_binds_caller_instead_of_shared_catalog_identity(runtime, monkeypatch): + tool = SimpleNamespace(tool_key="bisheng_knowledge_search", is_preset=1) + category = SimpleNamespace(tenant_id=1, user_id=None, extra='{"user_id":1,"tenant_id":1}') + wrapper = Mock(side_effect=lambda **kwargs: kwargs["base_tool"]) + monkeypatch.setattr(ToolExecutor, "init_by_tool_instance", wrapper) + result = ToolExecutor._init_by_tool_and_type( + tool, + category, + app_id="cloud-work", + app_name="Work", + app_type=ApplicationTypeEnum.ASSISTANT, + user_id=42, + tenant_id=1, + ) + assert (result.user_id, result.tenant_id) == (42, 5) + assert wrapper.call_args.kwargs["user_id"] == 42 + + +def test_executor_requires_explicit_tenant_context(runtime, monkeypatch): + token = current_tenant_id.set(None) + try: + with pytest.raises(PermissionError, match="tenant context"): + ToolExecutor._init_by_tool_and_type( + SimpleNamespace(tool_key="bisheng_knowledge_search", is_preset=1), + SimpleNamespace(), + app_id="cloud-work", + app_name="Work", + app_type=ApplicationTypeEnum.ASSISTANT, + user_id=42, + ) + finally: + current_tenant_id.reset(token) diff --git a/src/backend/test/tool/test_work_knowledge_upgrade.py b/src/backend/test/tool/test_work_knowledge_upgrade.py new file mode 100644 index 0000000000..c0a57e77a9 --- /dev/null +++ b/src/backend/test/tool/test_work_knowledge_upgrade.py @@ -0,0 +1,221 @@ +"""Exercise the complete upgrade with real catalog SQL and the F048 reconciler.""" + +import os +import subprocess +from contextlib import asynccontextmanager +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from sqlalchemy.ext.asyncio import create_async_engine +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + +from bisheng.api.services import f048_system_resource_bootstrap as inventory_module +from bisheng.permission.application import system_resource_reconcile as permission_module +from bisheng.permission.application.system_resource_reconcile import SystemOwnedResourceState +from bisheng.tool.domain.models.gpts_tools import GptsTools, GptsToolsType +from scripts import register_work_knowledge_tool as upgrade + + +@pytest.fixture +async def environment(monkeypatch, tmp_path): + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'catalog.db'}") + async with engine.begin() as connection: + await connection.run_sync(GptsToolsType.__table__.create) + await connection.run_sync(GptsTools.__table__.create) + + @asynccontextmanager + async def sessions(): + async with AsyncSession(engine, expire_on_commit=False) as session: + yield session + + state = SimpleNamespace(projections={}, fail=False, sessions=sessions) + + async def inspect(resources): + return tuple( + SystemOwnedResourceState( + resource=item, + state=state.projections.get(item.resource_id, "MISSING"), + ) + for item in resources + ) + + async def authorize(**kwargs): + if state.fail: + raise RuntimeError("Synthetic permission service outage") + state.projections[kwargs["target"].resource_id] = "CURRENT" + + state.facade = SimpleNamespace(authorize_system_owned=AsyncMock(side_effect=authorize)) + state.marker = SimpleNamespace(wait_until_ready=AsyncMock()) + state.close = AsyncMock() + monkeypatch.setattr(upgrade, "initialize_app_context", AsyncMock()) + monkeypatch.setattr(upgrade, "close_app_context", state.close) + monkeypatch.setattr( + upgrade, "settings", SimpleNamespace(openfga=SimpleNamespace(recent_consistency_window_seconds=1)) + ) + client = SimpleNamespace(store_id="fixture-store", model_id="fixture-model") + manager = SimpleNamespace(async_get_instance=AsyncMock(return_value=client)) + monkeypatch.setattr(upgrade, "app_context", SimpleNamespace(get_context=lambda name: manager)) + monkeypatch.setattr(upgrade, "build_f048_permission_runtime", AsyncMock(return_value=state)) + monkeypatch.setattr(upgrade, "get_async_db_session", sessions) + monkeypatch.setattr(inventory_module, "get_async_db_session", sessions) + monkeypatch.setattr(permission_module, "inspect_system_owned_resources", inspect) + yield state + await engine.dispose() + + +async def catalog_rows(environment): + with upgrade.bypass_tenant_filter(): + async with environment.sessions() as session: + return (await session.exec(select(GptsTools))).all(), (await session.exec(select(GptsToolsType))).all() + + +async def apply_upgrade(): + return await upgrade.run(apply=True, confirm_store_id="fixture-store", operator_id=27) + + +async def test_preview_reports_missing_catalog_without_writes(environment): + result = await upgrade.run(apply=False) + assert result["store_id"] == "fixture-store" + assert result["ready"] is False + assert result["catalog"]["create_tool"] is True + assert await catalog_rows(environment) == ([], []) + environment.facade.authorize_system_owned.assert_not_awaited() + environment.close.assert_awaited_once() + + +async def test_apply_registers_and_projects_once_across_repeated_upgrades(environment): + result = await apply_upgrade() + assert result["ready"] is True + assert result["permission"]["after"][0]["state"] == "CURRENT" + again = await apply_upgrade() + assert again["ready"] is True + assert again["catalog"]["status"] == "current" + tools, categories = await catalog_rows(environment) + assert len(tools) == len(categories) == 1 + assert tools[0].tool_key == upgrade.TOOL_KEY + assert tools[0].type == categories[0].id + call = environment.facade.authorize_system_owned.call_args.kwargs + assert call["actor"].user_id == 27 + assert call["target"].resource_id == str(categories[0].id) + assert "use" in call["action_codes"] + environment.facade.authorize_system_owned.assert_awaited_once() + + +async def test_permission_outage_preserves_catalog_and_retry_completes(environment): + environment.fail = True + with pytest.raises(RuntimeError, match="outage"): + await apply_upgrade() + tools, categories = await catalog_rows(environment) + assert len(tools) == len(categories) == 1 + preview = await upgrade.run(apply=False) + assert preview["ready"] is False + assert preview["permission"]["before"][0]["state"] == "MISSING" + environment.fail = False + result = await apply_upgrade() + assert result["ready"] is True + assert result["catalog"]["tool_id"] == tools[0].id + assert len((await catalog_rows(environment))[0]) == 1 + assert environment.close.await_count == 3 + + +@pytest.mark.parametrize("failure", ["store", "marker"]) +async def test_preflight_failure_preserves_empty_catalog(environment, failure): + if failure == "marker": + environment.marker.wait_until_ready.side_effect = RuntimeError("Synthetic marker failure") + with pytest.raises((ValueError, RuntimeError)): + await upgrade.run(apply=True, confirm_store_id="other-store" if failure == "store" else "fixture-store") + assert await catalog_rows(environment) == ([], []) + environment.facade.authorize_system_owned.assert_not_awaited() + environment.close.assert_awaited_once() + + +async def test_existing_category_configuration_and_other_tool_permissions_are_preserved(environment): + category_seed, _ = upgrade.seed_records() + with upgrade.bypass_tenant_filter(): + async with environment.sessions() as session: + session.add( + GptsToolsType( + **{k: v for k, v in category_seed.items() if k not in {"id", "extra"}}, + id=81, + extra='{"custom":"keep"}', + tenant_id=1, + ) + ) + session.add(GptsToolsType(id=82, name="Unrelated preset", is_preset=1, tenant_id=1)) + await session.commit() + environment.projections["81"] = "CURRENT" + result = await apply_upgrade() + assert result["ready"] is True + assert result["catalog"]["category_id"] == 81 + _, categories = await catalog_rows(environment) + assert next(row for row in categories if row.id == 81).extra == '{"custom":"keep"}' + assert "82" not in environment.projections + environment.facade.authorize_system_owned.assert_not_awaited() + + +async def test_pending_existing_projection_blocks_tool_insert(environment): + category_seed, _ = upgrade.seed_records() + with upgrade.bypass_tenant_filter(): + async with environment.sessions() as session: + session.add(GptsToolsType(**{k: v for k, v in category_seed.items() if k != "id"}, id=81, tenant_id=1)) + await session.commit() + environment.projections["81"] = "NON_CURRENT" + from bisheng.common.errcode.permission import PermissionPublishNotReadyError + + with pytest.raises(PermissionPublishNotReadyError): + await apply_upgrade() + assert (await catalog_rows(environment))[0] == [] + environment.facade.authorize_system_owned.assert_not_awaited() + + +@pytest.mark.parametrize( + "arguments", + [ + ["--apply"], + ["--apply", "--confirm-store-id", "fixture-store"], + ["--apply", "--operator-id", "27"], + ["--operator-id", "0"], + ["--operator-id", "-1"], + ], +) +def test_apply_requires_explicit_target_and_audit_operator(arguments): + with pytest.raises(SystemExit) as failure: + upgrade.parse_args(arguments) + assert failure.value.code == 2 + + +@pytest.mark.parametrize("entrypoint", ["src/backend/entrypoint.sh", "docker/bisheng/entrypoint.sh"]) +@pytest.mark.parametrize("exit_code", [0, 7]) +def test_container_upgrade_dispatch_forwards_arguments_and_exit_status(tmp_path, entrypoint, exit_code): + root = Path(__file__).resolve().parents[4] + interpreter = tmp_path / "python" + interpreter.write_text(f'#!/bin/sh\nprintf "%s\\n" "$@"\nexit {exit_code}\n') + interpreter.chmod(0o755) + result = subprocess.run( + [ + "sh", + str(root / entrypoint), + "upgrade-work-knowledge", + "--apply", + "--confirm-store-id", + "fixture-store", + "--operator-id", + "27", + ], + cwd=root / "src/backend", + env={**os.environ, "PATH": f"{tmp_path}:{os.environ['PATH']}"}, + capture_output=True, + text=True, + ) + assert result.returncode == exit_code + assert result.stdout.splitlines() == [ + "scripts/register_work_knowledge_tool.py", + "--apply", + "--confirm-store-id", + "fixture-store", + "--operator-id", + "27", + ] diff --git a/src/backend/test/user/test_f115_current_identity.py b/src/backend/test/user/test_f115_current_identity.py new file mode 100644 index 0000000000..cdc409de2b --- /dev/null +++ b/src/backend/test/user/test_f115_current_identity.py @@ -0,0 +1,68 @@ +"""F115 current-person eligibility for summaries and Web creation.""" + +from datetime import datetime, timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + + +async def test_person_facts_do_not_use_request_actor(monkeypatch): + from bisheng.user.application import runtime_identity as module + + user = SimpleNamespace(user_id=9, user_name="owner", delete=0, token_version=2) + monkeypatch.setattr(module, "current_person_rows", AsyncMock(return_value=[(user, 60), (user, 61)])) + monkeypatch.setattr(module.settings.multi_tenant, "enabled", True) + facts = await module.read_current_person_facts([9]) + assert facts[9]["state"] == "ambiguous" + + +async def test_disabled_or_missing_person_is_ineligible(monkeypatch): + from bisheng.user.application import runtime_identity as module + + user = SimpleNamespace(user_id=9, user_name="owner", delete=1, token_version=2) + monkeypatch.setattr(module, "current_person_rows", AsyncMock(return_value=[(user, None)])) + monkeypatch.setattr(module.settings.multi_tenant, "enabled", True) + facts = await module.read_current_person_facts([9, 10]) + assert facts[9]["state"] == facts[10]["state"] == "inactive" + + +async def test_single_tenant_person_needs_no_membership_row(monkeypatch): + from bisheng.user.application import runtime_identity as module + + user = SimpleNamespace(user_id=9, user_name="owner", delete=0, token_version=2) + monkeypatch.setattr(module, "current_person_rows", AsyncMock(return_value=[(user, None)])) + monkeypatch.setattr(module.settings.multi_tenant, "enabled", False) + facts = await module.read_current_person_facts([9]) + assert facts[9]["state"] == "active" and facts[9]["tenant_id"] == 1 + + +@pytest.mark.parametrize("tenant,version", [(61, 2), (60, 1)]) +async def test_creation_refuses_changed_tenant_or_session(monkeypatch, tenant, version): + from bisheng.common.errcode.permission import PermissionDeniedError + from bisheng.user.application import runtime_identity as module + + monkeypatch.setattr( + module, + "read_current_person_facts", + AsyncMock( + return_value={ + 9: { + "state": "active", + "tenant_id": 60, + "token_version": 2, + "name": "owner", + "id": 9, + } + } + ), + ) + ref = { + "kind": "web_session", + "user_id": 9, + "tenant_id": tenant, + "token_version": version, + "expires_at": (datetime.utcnow() + timedelta(minutes=2)).isoformat(), + } + with pytest.raises(PermissionDeniedError): + await module.resolve_web_creation_identity(ref) diff --git a/src/backend/test/user/test_jwt_secret_resolution.py b/src/backend/test/user/test_jwt_secret_resolution.py index 4cb2deeedb..c0028ff735 100644 --- a/src/backend/test/user/test_jwt_secret_resolution.py +++ b/src/backend/test/user/test_jwt_secret_resolution.py @@ -1,4 +1,4 @@ -"""F068: JWT signing secret must never be the shipped default. +"""F072: JWT signing secret must never be the shipped default. Resolution order: config.yaml ``jwt_secret`` (unless it is a leaked legacy value) → generated-once secret persisted in the ``config`` table. diff --git a/src/backend/test/user/test_management_subject_search.py b/src/backend/test/user/test_management_subject_search.py new file mode 100644 index 0000000000..62d50509b8 --- /dev/null +++ b/src/backend/test/user/test_management_subject_search.py @@ -0,0 +1,74 @@ +"""Execute management user search SQL, including original contains behavior.""" + +from contextlib import contextmanager + +import pytest +from sqlalchemy import Column, Integer, String, create_engine +from sqlalchemy.orm import declarative_base +from sqlmodel import Session + +from bisheng.user.domain.repositories import management_search_repository as repository + +Base = declarative_base() + + +class SearchUser(Base): + __tablename__ = "management_search_user" + user_id = Column(Integer, primary_key=True) + user_name = Column(String(255)) + external_id = Column(String(255)) + + +@pytest.fixture +def user_search(monkeypatch): + engine = create_engine("sqlite://") + Base.metadata.create_all(engine) + with Session(engine) as session: + session.add_all( + [ + SearchUser(user_id=1, user_name="zeta", external_id="jia0100"), + SearchUser(user_id=2, user_name="jia0100-name", external_id="jia0100-more"), + SearchUser(user_id=3, user_name="literal", external_id="jia_0%"), + SearchUser(user_id=4, user_name="contains alice", external_id=None), + SearchUser(user_id=5, user_name="other source", external_id="jia0100"), + ] + ) + session.commit() + + @contextmanager + def session_factory(): + with Session(engine) as session: + yield session + + monkeypatch.setattr(repository, "User", SearchUser) + monkeypatch.setattr(repository, "get_sync_db_session", session_factory) + yield repository.ManagementSearchRepository + engine.dispose() + + +def test_external_id_exact_priority_union_dedup_and_empty_page(user_search): + rows, total = user_search.list_users(keyword="jia0100", page=1, limit=1) + assert total == 3 + assert [row.user_id for row in rows] == [5] + rows, total = user_search.list_users(keyword="jia0100", page=2, limit=1) + assert [row.user_id for row in rows] == [1] + rows, total = user_search.list_users(keyword="jia0100", page=9, limit=1) + assert rows == [] + assert total == 3 + + +def test_case_sensitive_id_prefix_and_literal_wildcards(user_search): + assert user_search.list_users(keyword="0100")[1] == 1 # Name containment remains. + assert user_search.list_users(keyword="JIA")[1] == 1 # SQLite name LIKE remains case-insensitive. + rows, total = user_search.list_users(keyword="jia_0%") + assert total == 1 + assert rows[0].user_id == 3 + + +def test_original_name_contains_and_empty_allowed_scope(user_search): + assert user_search.list_users(keyword="alice")[1] == 1 + assert user_search.list_users(user_ids=[], keyword="jia")[1] == 0 + assert user_search.list_users(user_ids=[1], keyword="jia")[1] == 1 + rows, total = user_search.list_users(keyword=" \t ") + assert total == 5 + assert [row.user_id for row in rows] == [5, 4, 3, 2, 1] diff --git a/src/backend/test/user/test_user_tenant_sync_service.py b/src/backend/test/user/test_user_tenant_sync_service.py index 22683a7c20..b856ba8061 100644 --- a/src/backend/test/user/test_user_tenant_sync_service.py +++ b/src/backend/test/user/test_user_tenant_sync_service.py @@ -9,6 +9,8 @@ - ``AuditLogDao.ainsert_v2`` - Redis client (cache invalidation) - ``_count_owned_resources`` (replaced with an AsyncMock) + - ``PersonalTokenService.migrate_tenant`` (F053 D19: a PAT follows its + holder to the new tenant; the real one hits ``api_credential`` directly) Covers spec §5.2 (sync flow), AC-03 / AC-04 / AC-05 / AC-06 / AC-07 of F012. """ @@ -22,6 +24,7 @@ import pytest from bisheng.common.errcode.tenant_resolver import TenantRelocateBlockedError +from bisheng.open_api.domain.services.personal_token_service import PersonalTokenService from bisheng.tenant.domain.constants import ( TenantAuditAction, UserTenantSyncTrigger, @@ -60,6 +63,7 @@ def patches(monkeypatch): count_mock = AsyncMock(name="_count_owned_resources") invalidate_mock = AsyncMock(name="_invalidate_redis_caches") notify_mock = AsyncMock(name="_notify_resource_owner_relocation") + migrate_token_mock = AsyncMock(name="migrate_tenant", return_value=0) monkeypatch.setattr( uts_module.TenantResolver, @@ -106,6 +110,10 @@ def patches(monkeypatch): "_notify_resource_owner_relocation", notify_mock, ) + # ``_migrate_relocated_personal_token`` imports the service lazily, so the + # class attribute is the only seam; patching the private method instead + # would hide whether the D19 call is still wired. + monkeypatch.setattr(PersonalTokenService, "migrate_tenant", migrate_token_mock) return SimpleNamespace( resolve=resolver_mock, @@ -117,6 +125,7 @@ def patches(monkeypatch): count_owned=count_mock, invalidate=invalidate_mock, notify=notify_mock, + migrate_token=migrate_token_mock, ) @@ -148,6 +157,9 @@ def test_same_leaf_returns_early(self, patches, monkeypatch): patches.increment.assert_not_awaited() patches.apply_changes.assert_not_awaited() patches.audit.assert_not_awaited() + # D19: the cheap exit still reconciles the PAT tenant, so a token that + # missed an earlier relocation catches up on the next sync. + patches.migrate_token.assert_awaited_once_with(user_id=100, tenant_id=5) # ------------------------------------------------------------------------- @@ -173,6 +185,8 @@ def test_swap_flow_calls_all_collaborators(self, patches, monkeypatch): patches.increment.assert_awaited_once_with(100) patches.apply_changes.assert_awaited_once() patches.invalidate.assert_awaited_once_with(100) + # D19: the PAT follows its holder to the new leaf, after the swap. + patches.migrate_token.assert_awaited_once_with(user_id=100, tenant_id=7) # audit_log.action = user.tenant_relocated audit_kwargs = patches.audit.call_args.kwargs @@ -283,10 +297,11 @@ def test_blocked_when_owned_and_enforce_true(self, patches, monkeypatch): assert audit_kwargs["action"] == TenantAuditAction.USER_TENANT_RELOCATE_BLOCKED.value assert audit_kwargs["metadata"]["owned_count"] == 2 - # No swap / no token bump / no permission changes. + # No swap / no token bump / no permission changes / no PAT move. patches.activate.assert_not_awaited() patches.increment.assert_not_awaited() patches.apply_changes.assert_not_awaited() + patches.migrate_token.assert_not_awaited() def test_not_blocked_when_enforce_true_but_no_owned_resources( self, diff --git a/src/backend/test/user/test_user_update_tenant_admin_gate.py b/src/backend/test/user/test_user_update_tenant_admin_gate.py index 0b8d510064..0516bf459f 100644 --- a/src/backend/test/user/test_user_update_tenant_admin_gate.py +++ b/src/backend/test/user/test_user_update_tenant_admin_gate.py @@ -10,13 +10,18 @@ - ``/user/update`` (the original report): toggling enable/disable from 组织与成员 → MemberTable. - ``/user/list``: ``FilterByUser``、``ColFilterUser``、``SubjectSearchUser`` - fallback、``DepartmentUsersSelect`` 等组件统统依赖它;子租户管理员触达 - 其中任意一个就 500("举一反三" 同根坑)。 + fallback、``DepartmentUsersSelect`` 等组件统统依赖它; 子租户管理员触达 + 其中任意一个就 500 ("举一反三" 同根坑)。 ``bisheng.user.api.user`` is a heavy module the lean test venv cannot import (see ``test_logout_scope_clear.py`` for the same workaround). We pin the fix at the AST level: the new helpers exist and the endpoints route through the new scope helpers. End-to-end behaviour is covered by ops handbook QA. + +The tenant#admin check itself has since moved behind +``bisheng.permission.application.is_tenant_admin`` (4386d9c05, "expose lazy +application runtime"), so the helpers no longer spell ``object_type='tenant'`` +inline — ``_asserts_tenant_admin_check`` accepts either shape. """ from __future__ import annotations @@ -24,59 +29,58 @@ import ast from pathlib import Path - -USER_PY = ( - Path(__file__).resolve().parent.parent.parent - / 'bisheng' / 'user' / 'api' / 'user.py' -) +USER_PY = Path(__file__).resolve().parent.parent.parent / "bisheng" / "user" / "api" / "user.py" def _function_source(name: str) -> str: - src = USER_PY.read_text(encoding='utf-8') + src = USER_PY.read_text(encoding="utf-8") tree = ast.parse(src) for node in tree.body: - if ( - isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef)) - and node.name == name - ): + if isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef)) and node.name == name: seg = ast.get_source_segment(src, node) if seg: return seg - return '' + return "" + + +def _asserts_tenant_admin_check(src: str) -> None: + """The helper must consult the ``tenant#admin`` FGA tuple, either through + the original inline ``check(object_type='tenant', ...)`` or through the + ``is_tenant_admin()`` application helper that replaced it.""" + assert "object_type='tenant'" in src or 'object_type="tenant"' in src or "is_tenant_admin(" in src, ( + "helper must check the tenant#admin relation" + ) def test_tenant_admin_helper_defined(): - """新 helper 必须存在并带 docstring,避免后续 squash/合并误删。""" - src = _function_source('_tenant_admin_can_manage_member_account_status') - assert src, '_tenant_admin_can_manage_member_account_status missing' + """新 helper 必须存在并带 docstring, 避免后续 squash/合并误删。""" + src = _function_source("_tenant_admin_can_manage_member_account_status") + assert src, "_tenant_admin_can_manage_member_account_status missing" # docstring + tenant-admin FGA check + path prefix scope check - assert '子租户管理员' in src or 'tenant#admin' in src or 'tenant admin' in src.lower() - assert "object_type='tenant'" in src or 'object_type="tenant"' in src - assert 'startswith(mount_path)' in src or 'mount_path' in src - assert 'ROOT_TENANT_ID' in src, 'Root tenant must be short-circuited' + assert "子租户管理员" in src or "tenant#admin" in src or "tenant admin" in src.lower() + _asserts_tenant_admin_check(src) + assert "startswith(mount_path)" in src or "mount_path" in src + assert "ROOT_TENANT_ID" in src, "Root tenant must be short-circuited" def test_unified_gate_combines_dept_and_tenant_branches(): - src = _function_source('_can_manage_member_account_status') - assert src, '_can_manage_member_account_status missing' - assert '_dept_admin_can_manage_member_account_status' in src - assert '_tenant_admin_can_manage_member_account_status' in src + src = _function_source("_can_manage_member_account_status") + assert src, "_can_manage_member_account_status missing" + assert "_dept_admin_can_manage_member_account_status" in src + assert "_tenant_admin_can_manage_member_account_status" in src def test_update_endpoint_calls_unified_gate_not_dept_only(): - """``/user/update`` 必须走统一闸门,不能再硬卡 dept-admin 单一分支。""" - src = _function_source('update') - assert src, 'update endpoint missing' + """``/user/update`` 必须走统一闸门, 不能再硬卡 dept-admin 单一分支。""" + src = _function_source("update") + assert src, "update endpoint missing" # New gate is wired in. - assert '_can_manage_member_account_status(' in src, ( - 'update() must call the unified gate so tenant admins are admitted' + assert "_can_manage_member_account_status(" in src, ( + "update() must call the unified gate so tenant admins are admitted" ) # Old, dept-only gate must no longer be the gating call. - assert ( - '_dept_admin_can_manage_member_account_status(' not in src - ), ( - 'update() should delegate to the unified gate; ' - 'direct dept-admin call is the bug being fixed' + assert "_dept_admin_can_manage_member_account_status(" not in src, ( + "update() should delegate to the unified gate; direct dept-admin call is the bug being fixed" ) @@ -84,36 +88,33 @@ def test_update_endpoint_calls_unified_gate_not_dept_only(): # /user/list: tenant-admin scope (举一反三) # --------------------------------------------------------------------------- + def test_tenant_admin_scoped_user_ids_helper_defined(): """子租户管理员可见用户 helper 必须存在并复用 path 子树查询。""" - src = _function_source('_tenant_admin_scoped_user_ids') - assert src, '_tenant_admin_scoped_user_ids missing' - assert "object_type='tenant'" in src or 'object_type="tenant"' in src - assert 'aget_subtree_ids' in src, 'tenant scope must walk mount-subtree path' - assert 'ROOT_TENANT_ID' in src, 'Root tenant must be short-circuited' - assert 'UserDepartment' in src, ( - 'tenant scope must reverse-lookup user_ids via UserDepartment' - ) + src = _function_source("_tenant_admin_scoped_user_ids") + assert src, "_tenant_admin_scoped_user_ids missing" + _asserts_tenant_admin_check(src) + assert "aget_subtree_ids" in src, "tenant scope must walk mount-subtree path" + assert "ROOT_TENANT_ID" in src, "Root tenant must be short-circuited" + assert "UserDepartment" in src, "tenant scope must reverse-lookup user_ids via UserDepartment" def test_list_user_combines_dept_and_tenant_scope(): - """``/user/list`` 必须把 dept-admin 与 tenant-admin 范围合并,单分支不再够用。""" - src = _function_source('list_user') - assert src, 'list_user endpoint missing' + """``/user/list`` 必须把 dept-admin 与 tenant-admin 范围合并, 单分支不再够用。""" + src = _function_source("list_user") + assert src, "list_user endpoint missing" # New helper must be invoked alongside the dept-admin one. - assert '_tenant_admin_scoped_user_ids(' in src, ( - 'list_user must derive tenant-admin scope so Child Admins see their tenant users' - ) - assert '_department_admin_scoped_user_ids(' in src, ( - 'list_user must keep dept-admin scope as well' + assert "_tenant_admin_scoped_user_ids(" in src, ( + "list_user must derive tenant-admin scope so Child Admins see their tenant users" ) + assert "_department_admin_scoped_user_ids(" in src, "list_user must keep dept-admin scope as well" # The reject branch must now key off the *combined* scope being absent, # not just dept_scoped_ids being None (the old, broken check). - assert 'org_scoped_ids' in src or 'tenant_scoped_ids' in src, ( - 'list_user must combine dept+tenant scope before deciding to 500' + assert "org_scoped_ids" in src or "tenant_scoped_ids" in src, ( + "list_user must combine dept+tenant scope before deciding to 500" ) # Sanity: the buggy single-source guard ``if dept_scoped_ids is None: raise`` # must not be the gating condition any more. - assert 'if dept_scoped_ids is None:\n' not in src, ( - 'old dept-only guard must be replaced by the combined-scope guard' + assert "if dept_scoped_ids is None:\n" not in src, ( + "old dept-only guard must be replaced by the combined-scope guard" ) diff --git a/src/backend/test/user_group/test_user_group_tenant_admin_access.py b/src/backend/test/user_group/test_user_group_tenant_admin_access.py index a2f1ff08e6..d3fc91fcfb 100644 --- a/src/backend/test/user_group/test_user_group_tenant_admin_access.py +++ b/src/backend/test/user_group/test_user_group_tenant_admin_access.py @@ -28,8 +28,10 @@ async def test_can_open_admits_sys_admin(): @pytest.mark.asyncio async def test_can_open_admits_dept_admin(): with patch.object( - m, '_is_department_admin', - new_callable=AsyncMock, return_value=True, + m, + "_is_department_admin", + new_callable=AsyncMock, + return_value=True, ): assert await m._can_open_user_group_management(_user()) is True @@ -37,23 +39,91 @@ async def test_can_open_admits_dept_admin(): @pytest.mark.asyncio async def test_can_open_admits_tenant_admin(): """Child Admin via tenant#admin → access permitted.""" - with patch.object( - m, '_is_department_admin', - new_callable=AsyncMock, return_value=False, - ), patch( - 'bisheng.department.domain.services.department_service._is_tenant_admin', - new_callable=AsyncMock, return_value=True, + with ( + patch.object( + m, + "_is_department_admin", + new_callable=AsyncMock, + return_value=False, + ), + patch( + "bisheng.department.domain.services.department_service._is_tenant_admin", + new_callable=AsyncMock, + return_value=True, + ), ): assert await m._can_open_user_group_management(_user()) is True @pytest.mark.asyncio async def test_can_open_rejects_plain_user(): - with patch.object( - m, '_is_department_admin', - new_callable=AsyncMock, return_value=False, - ), patch( - 'bisheng.department.domain.services.department_service._is_tenant_admin', - new_callable=AsyncMock, return_value=False, + with ( + patch.object( + m, + "_is_department_admin", + new_callable=AsyncMock, + return_value=False, + ), + patch( + "bisheng.department.domain.services.department_service._is_tenant_admin", + new_callable=AsyncMock, + return_value=False, + ), ): assert await m._can_open_user_group_management(_user()) is False + + +@pytest.mark.parametrize("group_tenant,admin,allowed", [(60, True, True), (1, True, False), (60, False, False)]) +async def test_membership_mutation_matches_tenant_management_scope(group_tenant, admin, allowed): + from bisheng.common.errcode.user_group import UserGroupPermissionDeniedError + + user = SimpleNamespace(user_id=10, user_role=[2], tenant_id=60) + group = SimpleNamespace(id=20, tenant_id=group_tenant, create_user=99, visibility="private") + with patch("bisheng.department.domain.services.department_service._is_tenant_admin", AsyncMock(return_value=admin)): + if allowed: + await m._ensure_mutate_group_members(user, group) + else: + with pytest.raises(UserGroupPermissionDeniedError): + await m._ensure_mutate_group_members(user, group) + + +@pytest.mark.parametrize("members,allowed", [({871}, True), (set(), False)]) +async def test_tenant_admin_cannot_assign_foreign_personnel(monkeypatch, members, allowed): + from bisheng.common.errcode.user_group import UserGroupPermissionDeniedError + from bisheng.user_group.domain.repositories.member_identity_repository import MemberIdentityRepository + + monkeypatch.setattr( + "bisheng.department.domain.services.department_service._is_tenant_admin", AsyncMock(return_value=True) + ) + monkeypatch.setattr(MemberIdentityRepository, "tenant_member_ids", AsyncMock(return_value=members)) + user = SimpleNamespace(user_id=868, user_role=[2], tenant_id=60) + group = SimpleNamespace(id=20, tenant_id=60) + if allowed: + await m._ensure_tenant_member_scope(user, group, [871], tenant_admin=True) + else: + with pytest.raises(UserGroupPermissionDeniedError): + await m._ensure_tenant_member_scope(user, group, [871], tenant_admin=True) + + +@pytest.mark.parametrize("desired,allowed", [([182, 99], True), ([182, 99, 100], False)]) +async def test_unchanged_unmanageable_membership_is_preserved(monkeypatch, desired, allowed): + from bisheng.common.errcode.user_group import UserGroupPermissionDeniedError + + monkeypatch.setattr( + m.UserGroupService, "_list_manageable_groups", AsyncMock(return_value=[SimpleNamespace(id=182)]) + ) + monkeypatch.setattr( + m.UserGroupDao, + "aget_user_group", + AsyncMock(return_value=[SimpleNamespace(group_id=182), SimpleNamespace(group_id=99)]), + ) + add, remove = AsyncMock(), AsyncMock() + monkeypatch.setattr(m.UserGroupService, "aadd_members", add) + monkeypatch.setattr(m.UserGroupService, "aremove_member", remove) + if allowed: + await m.UserGroupService.areplace_user_memberships(871, desired, _user()) + else: + with pytest.raises(UserGroupPermissionDeniedError): + await m.UserGroupService.areplace_user_memberships(871, desired, _user()) + add.assert_not_awaited() + remove.assert_not_awaited() diff --git a/src/backend/test/workflow/test_app_center_excludes_hosted.py b/src/backend/test/workflow/test_app_center_excludes_hosted.py new file mode 100644 index 0000000000..c4b7892332 --- /dev/null +++ b/src/backend/test/workflow/test_app_center_excludes_hosted.py @@ -0,0 +1,109 @@ +"""F056 T010 — hosted applications must not reach the chat-entry lists. + +The square is not the only list that runs a candidate set through +``filter_apps_by_action``. "Application centre / frequently used" and the +workbench's recommendation strip do too, and a click there lands on +``/app/{chatId}/{id}/{flow_type}`` — a conversation page. A hosted application +has no conversation semantics at all, so the card would open a blank or broken +screen. + +Nothing in F056's acceptance criteria covers that path, which is exactly why it +needs a test: the layer-1 legality filter happens to hide hosted apps only when +the requested action is illegal for them, and these paths ask for ``visible``, +which is legal. The exclusion has to be explicit. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from bisheng.api.services.workflow import WorkFlowService +from bisheng.database.models.flow import FlowType + +HOSTED = FlowType.HOSTED_APP.value +WORKFLOW = FlowType.WORKFLOW.value +ASSISTANT = FlowType.ASSISTANT.value + + +class _LoginUser: + user_id = 4201 + user_name = "f056-normal" + + def is_admin(self) -> bool: + return False + + +def _candidates() -> list[dict]: + return [ + {"id": "wf-1", "flow_type": WORKFLOW, "user_id": 1, "logo": ""}, + {"id": "app-1", "flow_type": HOSTED, "user_id": 1, "logo": ""}, + {"id": "asst-1", "flow_type": ASSISTANT, "user_id": 1, "logo": ""}, + ] + + +@pytest.fixture() +def grant_everything(monkeypatch): + """Every candidate passes every check, so an absence can only be the type gate.""" + from bisheng.common.services.config_service import settings + + monkeypatch.setattr(settings.app_runtime, "enabled", True) + + async def _batch_check(user, *, resource_type, resource_ids, actions): + return {str(resource_id): frozenset(actions) for resource_id in resource_ids} + + monkeypatch.setattr( + "bisheng.api.services.workflow.batch_check_business_actions", + _batch_check, + ) + + +async def test_frequently_used_excludes_hosted(grant_everything): + """Application centre: hosted apps out, the other two in their original order.""" + links = [SimpleNamespace(type_detail=row["id"]) for row in _candidates()] + + with ( + patch( + "bisheng.api.services.workflow.UserLinkDao.get_user_link", + return_value=links, + ), + patch( + "bisheng.api.services.workflow.FlowDao.get_all_apps", + return_value=(_candidates(), 3), + ), + patch.object(WorkFlowService, "add_extra_field", side_effect=lambda _user, data, **_kw: data), + patch.object(WorkFlowService, "aenrich_apps_can_share", new=AsyncMock(side_effect=lambda _user, data: data)), + ): + data, total = await WorkFlowService.get_frequently_used_flows(_LoginUser(), "app", 1, 8) + + assert [row["id"] for row in data] == ["wf-1", "asst-1"] + assert total == 2 + + +async def test_recommended_apps_exclude_hosted(grant_everything): + """Workbench recommendation strip: an administrator may configure a hosted app anyway.""" + from bisheng.workstation.api.endpoints import apps as apps_endpoint + + with ( + patch.object( + apps_endpoint.WorkStationService, + "aget_config", + new=AsyncMock(return_value=SimpleNamespace(recommendedApps=["wf-1", "app-1", "asst-1"])), + ), + patch.object(apps_endpoint.FlowDao, "get_all_apps", return_value=(_candidates(), 3)), + patch.object(WorkFlowService, "aget_writeable_app_ids", new=AsyncMock(return_value=set())), + patch.object(WorkFlowService, "add_extra_field", side_effect=lambda _user, data, **_kw: data), + patch.object(WorkFlowService, "aenrich_apps_can_share", new=AsyncMock(side_effect=lambda _user, data: data)), + ): + response = await apps_endpoint.get_recommended_apps(login_user=_LoginUser()) + + assert [row["id"] for row in response.data] == ["wf-1", "asst-1"] + + +async def test_filter_apps_by_action_default_keeps_every_type(grant_everything): + """The exclusion is opt-in: callers that do not ask keep the old behaviour.""" + kept = await WorkFlowService.filter_apps_by_action(_LoginUser(), _candidates(), "visible") + + assert [row["id"] for row in kept] == ["wf-1", "app-1", "asst-1"] diff --git a/src/backend/test/workflow/test_code_node_switch.py b/src/backend/test/workflow/test_code_node_switch.py index b536a54ba1..e8bc11ece1 100644 --- a/src/backend/test/workflow/test_code_node_switch.py +++ b/src/backend/test/workflow/test_code_node_switch.py @@ -1,9 +1,8 @@ """The Code node is off unless an operator turns it on (issue #2189). -The node runs user-supplied Python with the backend's own privileges and there -is no execution sandbox yet, so a deployment must not be exposed by default. -The gate sits where the node is *built*: parsing the code already executes it, -so a check in the run path would fire after the code had run. +The node runs user-supplied Python through the configured code interpreter, so +a deployment must not be exposed by default. The gate sits where the node is +*built*, before any executor lookup. """ from unittest.mock import MagicMock, patch @@ -67,7 +66,7 @@ def test_building_a_node_is_refused_while_the_switch_is_off(bare_base_node): def test_the_gate_runs_before_the_code_is_parsed(bare_base_node): - with _with_conf(False), patch.object(code_node, "CodeParser") as parser: + with _with_conf(False), patch.object(code_node, "make_code_parser") as parser: with pytest.raises(IgnoreException): code_node.CodeNode() diff --git a/src/backend/test/workflow/test_flow_dao_square_params.py b/src/backend/test/workflow/test_flow_dao_square_params.py new file mode 100644 index 0000000000..2bb2830188 --- /dev/null +++ b/src/backend/test/workflow/test_flow_dao_square_params.py @@ -0,0 +1,111 @@ +"""F056 T006 — the square's two internal parameters on ``FlowDao.aget_all_apps``. + +The square needs two things the build page must not get: + +* stopped hosted applications still have to appear (决议-5: hiding them reads to + the user as "I lost access", which is a different and much worse message than + "this app is paused"), while the ``status`` column folds them to 1 = offline; +* drafts, pending-capacity and deleted apps must never appear, for anyone, + including the owner and administrators. + +Both are expressed as internal parameters with ``None`` defaults, so the build +page and the other two callers of ``aget_all_apps`` compile byte-identical SQL. +That last property is what these tests exist to hold: the alternative — folding +"stopped" into ``status = 2`` — would have broken the build page's +online/offline filter without any test noticing. +""" + +from __future__ import annotations + +from contextlib import contextmanager + +from sqlmodel import select + +from bisheng.core.context.tenant import ( + current_tenant_id, + set_current_tenant_id, + set_visible_tenant_ids, + visible_tenant_ids, +) +from bisheng.database.models.flow import FlowDao, FlowType + + +def _compiled(stmt) -> str: + return str(stmt.compile(compile_kwargs={"literal_binds": True})) + + +@contextmanager +def _tenant_context(tenant_id: int = 1): + """``_build_apps_subquery`` reads the tenant ContextVars while composing SQL.""" + tenant_token = set_current_tenant_id(tenant_id) + visible_token = set_visible_tenant_ids(None) + try: + yield + finally: + current_tenant_id.reset(tenant_token) + visible_tenant_ids.reset(visible_token) + + +def _subquery_sql(**kwargs) -> str: + with _tenant_context(): + return _compiled(select(FlowDao._build_apps_subquery(**kwargs).c.id)) + + +def test_status_exempt_flow_types(): + """With the exemption set, ``status`` stops gating the hosted-application leg.""" + with _tenant_context(): + plain = _compiled( + FlowDao._build_status_clause( + FlowDao._build_apps_subquery(), + status=2, + status_exempt_flow_types=None, + ) + ) + exempted = _compiled( + FlowDao._build_status_clause( + FlowDao._build_apps_subquery(), + status=2, + status_exempt_flow_types={FlowType.HOSTED_APP.value}, + ) + ) + + assert " OR " not in plain.upper() + assert " OR " in exempted + assert "flow_type IN (35)" in exempted or "flow_type = 35" in exempted + + +def test_app_state_in_narrows_third_branch(): + """``app_state_in`` is pushed down onto the hosted leg, not the outer SELECT. + + Filtering the outer union would have to tolerate the typed NULL the other + two legs project; pushing it down keeps drafts out of the row set entirely, + which is why AC-03's "not even for the owner" needs no extra permission + code. + """ + sql = _subquery_sql(app_state_in={"online", "stopped"}) + + assert "app.state IN ('online', 'stopped')" in sql or "app.state IN ('stopped', 'online')" in sql + # The predicate belongs to the third leg only — workflows and assistants + # have no ``state`` column to be narrowed by. + assert sql.count("app.state IN") == 1 + + +def test_defaults_unchanged(): + """No arguments → byte-identical SQL to the pre-F056 subquery and query.""" + assert _subquery_sql() == _subquery_sql(app_state_in=None) + assert "state IN" not in _subquery_sql() + + with _tenant_context(): + unchanged = _compiled( + FlowDao._build_status_clause( + FlowDao._build_apps_subquery(), + status=2, + status_exempt_flow_types=None, + ) + ) + assert "status = 2" in unchanged + + +def test_empty_app_state_in_is_ignored(): + """An empty set means "no narrowing", never "match nothing".""" + assert _subquery_sql(app_state_in=set()) == _subquery_sql() diff --git a/src/backend/test/workflow/test_flow_dao_tenant_isolation.py b/src/backend/test/workflow/test_flow_dao_tenant_isolation.py index 66bca74dfe..186a2f14b6 100644 --- a/src/backend/test/workflow/test_flow_dao_tenant_isolation.py +++ b/src/backend/test/workflow/test_flow_dao_tenant_isolation.py @@ -1,6 +1,6 @@ """Regression tests for FlowDao's UNION ALL tenant-isolation leak. -The four ``select(Flow) UNION ALL select(Assistant) AS sub`` queries on +The four ``select(Flow) UNION ALL select(Assistant) UNION ALL select(App) AS sub`` queries on ``FlowDao`` (``get_all_apps`` / ``aget_all_apps`` / ``get_all_app_by_time_range_sync`` / ``get_first_app``) historically escaped the ``do_orm_execute`` auto-filter because the outer SELECT only exposes a Subquery — the listener never saw @@ -25,6 +25,7 @@ visible_tenant_ids, ) from bisheng.core.database.tenant_filter import build_tenant_filter_clause +from bisheng.database.models.app import App from bisheng.database.models.assistant import Assistant from bisheng.database.models.flow import Flow, FlowDao @@ -53,6 +54,10 @@ def test_leaf_tenant_emits_in_list_on_both_inner_selects(self): assert "flow.tenant_id IN (1, 5)" in sql assert "assistant.tenant_id IN (1, 5)" in sql + # F054's hosted-application leg. It is written by hand like the other + # two — the auto-filter cannot see inside a UNION subquery — and one + # missing clause here leaks through all four callers at once. + assert "app.tenant_id IN (1, 5)" in sql def test_root_tenant_with_no_visible_emits_equality(self): # Super admin shape: current=1, visible=None. Event listener falls @@ -66,6 +71,7 @@ def test_root_tenant_with_no_visible_emits_equality(self): assert "flow.tenant_id = 1" in sql assert "assistant.tenant_id = 1" in sql + assert "app.tenant_id = 1" in sql def test_bypass_emits_no_tenant_predicate(self): tenant_token = set_current_tenant_id(1) @@ -119,6 +125,18 @@ def test_empty_visible_set_emits_false(self): assert str(clause) == str(sql_false()) + def test_hosted_app_column_uses_the_same_helper(self): + """The third leg's clause comes from the shared helper, not a hand-rolled copy.""" + tenant_token = set_current_tenant_id(1) + visible_token = set_visible_tenant_ids(frozenset({1})) + try: + clause = build_tenant_filter_clause(App.tenant_id) + finally: + _reset_tenant_context(tenant_token, visible_token) + + sql = str(clause.compile(compile_kwargs={"literal_binds": True})) + assert sql == "app.tenant_id = 1" + def test_single_visible_emits_equality(self): tenant_token = set_current_tenant_id(1) visible_token = set_visible_tenant_ids(frozenset({1})) diff --git a/src/backend/test/workflow/test_input_file_pipeline.py b/src/backend/test/workflow/test_input_file_pipeline.py new file mode 100644 index 0000000000..0cde1f276b --- /dev/null +++ b/src/backend/test/workflow/test_input_file_pipeline.py @@ -0,0 +1,300 @@ +"""Workflow extraction keeps images without applying retrieval chunk constraints.""" + +import shutil +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock + +import openpyxl +import pytest +from langchain_core.documents import Document +from PIL import Image + +from bisheng.common.errcode.knowledge import KnowledgeFileChunkMaxError +from bisheng.knowledge.rag.pipeline.loader.base import BaseBishengLoader +from bisheng.knowledge.rag.pipeline.loader.x_create import XinChuangFormatterLoader +from bisheng.knowledge.rag.pipeline.types import ImageStorageContext +from bisheng.knowledge.rag.temp_file_pipeline import TempFilePipeline +from bisheng.workflow.nodes.input.input import InputNode + + +class _ImageLoader(BaseBishengLoader): + def load(self): + image = Path(self.ensure_local_image_dir()) / "figure.png" + image.write_bytes(b"image-bytes") + text = f"Before\n![figure]({self.build_image_url(image.name)})\nAfter" + return [ + Document( + page_content=text, + metadata={ + "indexes": [[0, len(text)]], + "pages": [1], + "types": ["text"], + "bboxes": [[0, 0, 10, 10]], + }, + ) + ] + + +def _pipeline(tmp_path, filename="file.pdf", **kwargs): + path = tmp_path / filename + path.touch() + pipeline = TempFilePipeline(1, str(path), filename, **kwargs) + pipeline.__dict__["file_metadata"] = {} + return pipeline + + +@pytest.mark.parametrize("split", [False, True]) +def test_image_upload_precedes_staging_cleanup_and_preserves_offsets(tmp_path, monkeypatch, split): + """AC-01, AC-04: bytes survive staging cleanup and offsets use the final image URL.""" + objects = {} + events = [] + storage = MagicMock() + storage.put_object_sync.side_effect = lambda **kw: objects.update( + {kw["object_name"]: Path(kw["file"]).read_bytes()} + ) + monkeypatch.setattr( + "bisheng.knowledge.rag.pipeline.transformer.image_upload.get_minio_storage_sync", lambda: storage + ) + image_context = ImageStorageContext( + "chat/1/images", + "main", + lambda name: f"/api/v1/chat/c/files/{name}/content", + before_upload=lambda names: events.append(("pending", names.copy())), + after_upload=lambda: events.append(("ready", bool(objects))), + ) + pipeline = _pipeline(tmp_path, split_for_retrieval=split, image_storage=image_context) + monkeypatch.setattr(pipeline, "_init_pdf_loader", lambda: _ImageLoader(**pipeline._get_loader_common_params())) + result = pipeline.run() + assert objects == {"chat/1/images/figure.png": b"image-bytes"} + assert events == [("pending", ["figure.png"]), ("ready", True)] + assert not Path(pipeline.loader.local_image_dir).exists() + text = "\n".join(doc.page_content for doc in result.documents) + assert "/api/v1/chat/c/files/figure.png/content" in text + assert pipeline.tmp_dir not in text + if split: + assert result.documents[0].metadata["chunk_bboxes"][0]["page"] == 1 + else: + assert result.documents[0].metadata["indexes"] == [[0, len(text)]] + assert storage.put_object_sync.call_args.kwargs["bucket_name"] == "main" + + +def test_failed_manifest_finalize_removes_uploaded_bytes(tmp_path, monkeypatch): + """AC-08: a deleted conversation cannot leave an upload completed after deletion.""" + storage = MagicMock() + monkeypatch.setattr( + "bisheng.knowledge.rag.pipeline.transformer.image_upload.get_minio_storage_sync", lambda: storage + ) + + def fail(): + raise ValueError("conversation deleted") + + context = ImageStorageContext( + "chat/images", "main", lambda name: f"/image/{name}", after_upload=fail, rollback_on_failure=True + ) + pipeline = _pipeline(tmp_path, image_storage=context, split_for_retrieval=False) + monkeypatch.setattr(pipeline, "_init_pdf_loader", lambda: _ImageLoader(**pipeline._get_loader_common_params())) + with pytest.raises(ValueError, match="conversation deleted"): + pipeline.run() + storage.remove_object_sync.assert_called_once_with(bucket_name="main", object_name="chat/images/figure.png") + + +def test_partial_upload_never_marks_manifest_ready_and_cleans_completed_objects(tmp_path, monkeypatch): + """AC-08: failure on the second image cannot publish a partially uploaded result.""" + + class TwoImages(_ImageLoader): + def load(self): + documents = super().load() + (Path(self.local_image_dir) / "second.png").write_bytes(b"second-image") + return documents + + storage = MagicMock() + storage.put_object_sync.side_effect = [None, OSError("upload interrupted")] + monkeypatch.setattr( + "bisheng.knowledge.rag.pipeline.transformer.image_upload.get_minio_storage_sync", lambda: storage + ) + ready = MagicMock() + context = ImageStorageContext( + "chat/images", "main", lambda name: f"/image/{name}", after_upload=ready, rollback_on_failure=True + ) + pipeline = _pipeline(tmp_path, image_storage=context, split_for_retrieval=False) + monkeypatch.setattr(pipeline, "_init_pdf_loader", lambda: TwoImages(**pipeline._get_loader_common_params())) + with pytest.raises(OSError, match="upload interrupted"): + pipeline.run() + ready.assert_not_called() + uploaded = storage.put_object_sync.call_args_list[0].kwargs["object_name"] + storage.remove_object_sync.assert_called_once_with(bucket_name="main", object_name=uploaded) + + +def test_unrecorded_manifest_does_not_upload_bytes(tmp_path, monkeypatch): + """AC-08: a DB registration failure cannot create untracked permanent objects.""" + storage = MagicMock() + monkeypatch.setattr( + "bisheng.knowledge.rag.pipeline.transformer.image_upload.get_minio_storage_sync", lambda: storage + ) + + def fail(_names): + raise OSError("manifest unavailable") + + context = ImageStorageContext("chat/images", "main", lambda name: f"/image/{name}", before_upload=fail) + pipeline = _pipeline(tmp_path, image_storage=context, split_for_retrieval=False) + monkeypatch.setattr(pipeline, "_init_pdf_loader", lambda: _ImageLoader(**pipeline._get_loader_common_params())) + with pytest.raises(OSError, match="manifest unavailable"): + pipeline.run() + storage.put_object_sync.assert_not_called() + + +@pytest.mark.parametrize("split", [False, True]) +def test_ppt_long_page_is_checked_only_for_retrieval(tmp_path, monkeypatch, split): + """AC-02, AC-09: extracting a long slide succeeds; the existing ingest limit stays.""" + pipeline = _pipeline(tmp_path, "slides.pptx", split_for_retrieval=split) + monkeypatch.setattr( + pipeline, + "_init_ppt_loader", + lambda: SimpleNamespace( + load=lambda: [Document(page_content="x" * 10001, metadata={"page": 1})], + ), + ) + if split: + with pytest.raises(KnowledgeFileChunkMaxError): + pipeline.run() + else: + assert pipeline.run().documents[0].page_content == "x" * 10001 + + +def test_extract_excel_keeps_one_table_per_sheet_and_long_cell(tmp_path): + """AC-02, AC-03: no row chunks, duplicate headers, or retrieval size budget.""" + pipeline = _pipeline(tmp_path, "wide.xlsx", split_for_retrieval=False) + workbook = openpyxl.Workbook() + sheet = workbook.active + sheet.append(["unique-header", "value"]) + sheet.append(["x" * 12000, "long"]) + for i in range(25): + sheet.append([str(i), "row"]) + workbook.save(pipeline.local_file_path) + documents = pipeline.run().documents + assert len(documents) == 1 + assert "x" * 12000 in documents[0].page_content + assert documents[0].page_content.count("unique-header") == 1 + assert "| 24 | row |" in documents[0].page_content + + +def test_et_delegate_receives_extraction_and_image_context(tmp_path): + """AC-03, AC-09: conversion cannot accidentally restore Excel retrieval splitting.""" + + def url_builder(name): + return f"/file/{name}" + + loader = XinChuangFormatterLoader( + file_path="input.et", + file_metadata={}, + file_extension="et", + tmp_dir=str(tmp_path), + split_for_retrieval=False, + image_object_dir="chat/images", + image_url_builder=url_builder, + ) + delegate = loader._build_delegate_loader("converted.xlsx", "xlsx", "excel") + assert delegate.split_for_retrieval is False + assert delegate.image_url_builder is url_builder + assert delegate.image_object_dir == "chat/images" + + +@pytest.mark.parametrize("mode,split", [("extract_text", False), (["extract_text", "ingest_to_temp_kb"], True)]) +def test_input_node_selects_purpose_before_parsing_and_keeps_output_limit(monkeypatch, mode, split): + """AC-03, AC-09: parser purpose is per file, and extraction still caps the body.""" + node = InputNode.__new__(InputNode) + node.id, node.workflow_id = "input", "workflow" + node._image_ext = ["png", "jpg"] + node._vector_client, node._es_client = MagicMock(), MagicMock() + node.init_vector_clients = MagicMock() + node.get_upload_file_path_content = MagicMock(return_value=(["x" * 20000], [{}])) + monkeypatch.setattr( + "bisheng.workflow.nodes.input.input.KnowledgeService.get_upload_file_original_name", lambda name: name + ) + result = node.parse_upload_file( + "k", + { + "key": "k", + "file_content": "c", + "file_path": "p", + "image_file": "img", + "file_parse_mode": mode, + "file_content_size": 15000, + }, + ["http://storage/temp/file.pdf"], + ) + node.get_upload_file_path_content.assert_called_once_with("http://storage/temp/file.pdf", split_for_retrieval=split) + assert "x" * 15000 in result["c"] and "x" * 15001 not in result["c"] + assert node.init_vector_clients.call_count == int(split) + assert node._vector_client.add_texts.call_count == int(split) + assert node._es_client.add_texts.call_count == int(split) + + +@pytest.mark.parametrize("extension", ["docx", "pptx", "xlsx", "pdf"]) +def test_real_document_images_reach_object_storage_before_cleanup(tmp_path, monkeypatch, extension): + """AC-01: real local loaders emit stable references backed by uploaded image bytes.""" + if extension == "docx" and not shutil.which("pandoc"): + pytest.skip("local DOCX image extraction requires pandoc") + image_path = tmp_path / "source.png" + Image.new("RGB", (16, 16), (240, 10, 30)).save(image_path) + pipeline = _pipeline(tmp_path, f"source.{extension}", split_for_retrieval=False) + if extension == "docx": + from docx import Document as WordDocument + + document = WordDocument() + document.add_paragraph("Document with an image") + document.add_picture(str(image_path)) + document.save(pipeline.local_file_path) + elif extension == "pptx": + from pptx import Presentation + from pptx.util import Inches + + presentation = Presentation() + slide = presentation.slides.add_slide(presentation.slide_layouts[6]) + slide.shapes.add_picture(str(image_path), Inches(1), Inches(1)) + presentation.save(pipeline.local_file_path) + monkeypatch.setattr("bisheng.knowledge.rag.pipeline.loader.ppt.convert_ppt_to_pdf", lambda *args: None) + elif extension == "xlsx": + from openpyxl.drawing.image import Image as ExcelImage + + workbook = openpyxl.Workbook() + workbook.active.append(["header", "value"]) + workbook.active.add_image(ExcelImage(str(image_path)), "A3") + workbook.save(pipeline.local_file_path) + else: + import fitz + + with fitz.open() as document: + page = document.new_page() + page.insert_text((30, 30), "Document with an image") + page.insert_image(fitz.Rect(50, 50, 200, 200), filename=str(image_path)) + document.save(pipeline.local_file_path) + # Pin the local PDF loader; provider HTTP clients are outside this test. + from bisheng.knowledge.rag.pipeline.loader.pdf import LocalPdfLoader + + monkeypatch.setattr( + pipeline, + "_init_pdf_loader", + lambda: LocalPdfLoader( + **pipeline._get_loader_common_params(), + retain_images=True, + ), + ) + + objects = {} + storage = MagicMock() + storage.put_object_sync.side_effect = lambda **kw: objects.update( + {kw["object_name"]: Path(kw["file"]).read_bytes()} + ) + monkeypatch.setattr( + "bisheng.knowledge.rag.pipeline.transformer.image_upload.get_minio_storage_sync", lambda: storage + ) + pipeline.image_storage = ImageStorageContext("chat/images", "main", lambda name: f"/stable-images/{name}") + result = pipeline.run() + assert objects, f"{extension} did not stage and upload its embedded image" + text = "\n".join(doc.page_content for doc in result.documents) + for name, content in objects.items(): + assert content + assert f"/stable-images/{name.rsplit('/', 1)[1]}" in text + assert not Path(pipeline.tmp_dir).exists() diff --git a/src/backend/test/workflow/test_input_node_media.py b/src/backend/test/workflow/test_input_node_media.py index 11fb416230..348b38ae2f 100644 --- a/src/backend/test/workflow/test_input_node_media.py +++ b/src/backend/test/workflow/test_input_node_media.py @@ -62,7 +62,12 @@ def test_count_media_files(monkeypatch): assert InputNode._count_media_files(urls) == 1 -def test_media_count_limit_raises(): +def test_media_count_limit_raises(monkeypatch): + monkeypatch.setattr( + KnowledgeService, + "get_upload_file_original_name", + staticmethod(lambda name: name), + ) node = InputNode.__new__(InputNode) node.id = "input_1" node.workflow_id = "wf" diff --git a/src/backend/test/workflow/test_run_once_off_event_loop.py b/src/backend/test/workflow/test_run_once_off_event_loop.py new file mode 100644 index 0000000000..0187ebb416 --- /dev/null +++ b/src/backend/test/workflow/test_run_once_off_event_loop.py @@ -0,0 +1,56 @@ +"""Single-node debug runs execute the sync node off the request's event loop. + +A tool node builds its tool through run_async_safe, which refuses to run on a +running event loop. run_once used to call the node inline from the async +endpoint, so every tool node's debug run failed with that error. +""" + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from bisheng.api.services import workflow as workflow_service +from bisheng.api.services.workflow import WorkFlowService +from bisheng.core.context.tenant import current_tenant_id, get_current_tenant_id + + +class _FakeToolNode: + def __init__(self) -> None: + self.seen: dict = {} + + def handle_input(self, user_input: dict) -> None: + self.seen["input"] = user_input + + def _run(self, exec_id: str) -> dict: + try: + asyncio.get_running_loop() + self.seen["on_loop"] = True + except RuntimeError: + self.seen["on_loop"] = False + self.seen["tenant"] = get_current_tenant_id() + return {"output": "page"} + + def parse_log(self, exec_id: str, result: dict) -> list: + return [[{"key": "output", "value": result["output"], "type": "variable"}]] + + +async def test_a_tool_node_runs_in_a_worker_thread_with_the_request_context(): + node = _FakeToolNode() + token = current_tenant_id.set(7) + try: + with ( + patch.object(workflow_service.FlowDao, "get_flow_by_id", return_value=SimpleNamespace(id="w1", name="wf")), + patch.object(workflow_service, "require_business_action", AsyncMock()), + patch.object(workflow_service.NodeFactory, "instance_node", return_value=node), + ): + result = await WorkFlowService.run_once( + SimpleNamespace(user_id=1), + {"url": "https://example.com"}, + {"id": "tool_1", "data": {"id": "tool_1", "type": "tool", "name": "web_fetch", "description": ""}}, + "w1", + ) + finally: + current_tenant_id.reset(token) + + assert node.seen == {"input": {"url": "https://example.com"}, "on_loop": False, "tenant": 7} + assert result == [[{"key": "output", "value": "page", "type": "variable"}]] diff --git a/src/backend/test/workflow/test_square_hosted_app_e2e.py b/src/backend/test/workflow/test_square_hosted_app_e2e.py new file mode 100644 index 0000000000..d92981f084 --- /dev/null +++ b/src/backend/test/workflow/test_square_hosted_app_e2e.py @@ -0,0 +1,610 @@ +"""F056 T016 — the application square, end to end, through a **non-administrator**. + +Why a live suite exists at all when ``test/app_runtime/test_square_scan_page.py`` +already covers the same service: that file stubs +``batch_check_business_actions``, so it proves the square asks the right +question and keeps the right rows. It cannot prove the *answer* — the FGA +tuples, the projection, the cache — and every AC in this file is about the +answer. + +**The account rule is the whole test, not a detail.** +``_identity_shortcut`` (``permission_action_service.py:372-384``) allows +``SUPER_ADMIN`` / ``TENANT_ADMIN`` regardless of action and *before* the action +is even validated (F056 design K2). An administrator therefore passes every +assertion below whether the feature works or not, which makes an admin-run green +suite worth exactly nothing. Everything the square is asked here is asked as a +normal user who neither owns the application nor administers anything. + +What the environment must provide, and why it is not created here: a hosted +application only comes into existence through ``bisheng deploy`` → approval → +online, and there is no REST endpoint that creates one. So the suite +**discovers** hosted applications with the administrator session and skips — +loudly, naming what is missing — when the deployment has none in the state a +given case needs. It creates and removes only grants, and only for its own test +user. + +Prerequisites: + +* ``F056_E2E=1`` — this suite mutates grants on a real deployment. +* ``E2E_API_BASE`` pointing at the test deployment; ``E2E_ADMIN_PASSWORD`` if + the admin password is not the default. +* ``F056_E2E_USER_NAME`` / ``F056_E2E_USER_PASSWORD`` / ``F056_E2E_USER_ID`` — + an ordinary account: **not** a super admin, **not** a tenant admin, and not + the owner of any hosted application. +* ``F056_E2E_CROSS_TENANT_USER_NAME`` / ``F056_E2E_CROSS_TENANT_USER_PASSWORD`` + — optional; only ``test_tenant_isolation`` needs it and it skips without them. + +覆盖 AC: AC-01, AC-02, AC-03, AC-04, AC-05, AC-06, AC-07, AC-15 +""" + +from __future__ import annotations + +import os +from uuid import uuid4 + +import httpx +import pytest + +from test.e2e.helpers.api import API_BASE, assert_resp_200, assert_resp_error +from test.e2e.helpers.auth import auth_headers, get_admin_token, get_user_token + +#: ``FlowType.HOSTED_APP``. Written as a literal rather than imported so the +#: suite asserts the number the *wire* carries; importing the enum would make a +#: renumbering invisible to a test whose whole job is the client contract. +HOSTED_APP_FLOW_TYPE = 35 + +#: The two application states the square shows. Everything else — draft, +#: pending_capacity, deleted — is excluded by a SQL predicate, not by a +#: permission check, which is why the exclusion holds even for the owner. +SQUARE_STATES = ("online", "stopped") +HIDDEN_STATES = ("draft", "pending_capacity") + +E2E_ENABLED = os.environ.get("F056_E2E") == "1" + +pytestmark = [ + pytest.mark.e2e, + pytest.mark.skipif( + not E2E_ENABLED, + reason="set F056_E2E=1 only against a deployment with MySQL / Redis / OpenFGA running", + ), +] + + +def _required_env(name: str) -> str: + value = os.environ.get(name, "").strip() + if not value: + pytest.fail(f"{name} is required when F056_E2E=1") + return value + + +# --------------------------------------------------------------------------- +# probes — one function per surface, so a change of shape fails in one place +# --------------------------------------------------------------------------- + + +async def _square_rows( + client: httpx.AsyncClient, + token: str, + *, + keyword: str | None = None, + flow_type: int | None = None, + limit: int = 100, +) -> list[dict]: + """``GET /chat/online`` — the tagged square entry the client's 应用广场 uses.""" + params: dict[str, str | int] = {"page": 1, "limit": limit} + if keyword: + params["keyword"] = keyword + if flow_type is not None: + params["flow_type"] = flow_type + response = await client.get(f"{API_BASE}/chat/online", params=params, headers=auth_headers(token)) + return assert_resp_200(response) or [] + + +async def _uncategorized_rows( + client: httpx.AsyncClient, + token: str, + *, + keyword: str | None = None, + limit: int = 100, +) -> list[dict]: + """``GET /workstation/app/uncategorized`` — the默认分类 tab. + + Asserted separately from the tagged entry throughout: the two share + ``_scan_visible_apps_page`` but *not* ``add_extra_field``, so a field + attached in the wrong place works on one tab and is silently absent on the + other. + """ + params: dict[str, str | int] = {"page": 1, "limit": limit} + if keyword: + params["keyword"] = keyword + response = await client.get( + f"{API_BASE}/workstation/app/uncategorized", + params=params, + headers=auth_headers(token), + ) + return assert_resp_200(response) or [] + + +async def _entry_allows(client: httpx.AsyncClient, token: str, app_id: str) -> bool: + """The F054 entry judgement, over HTTP. + + ``POST /workstation/app/used/record`` delegates authorisation to exactly + ``check_business_action(login_user, resource_type="app", resource_id=..., + action="use")`` and does nothing else that could fail — which makes it the + only endpoint that answers the entry question without a second rule mixed + in. A refusal rides inside a 200 envelope as business code 403. + + Side effect, deliberately accepted: an **allowed** call adds the application + to this user's "最近使用" list. It is per-user, idempotent and touches + nothing another test reads, and the alternative — asserting the entry + verdict from ``/permissions/check`` — would go through a different call + chain than the one the entry actually uses, which is the very thing AC-06 + is about. + """ + response = await client.post( + f"{API_BASE}/workstation/app/used/record", + json={"flow_id": app_id}, + headers=auth_headers(token), + ) + assert response.status_code == 200, f"HTTP {response.status_code}: {response.text[:200]}" + return response.json()["status_code"] == 200 + + +def _hosted_row(rows: list[dict], app_id: str) -> dict | None: + for row in rows: + if str(row.get("id")) == str(app_id): + return row + return None + + +# --------------------------------------------------------------------------- +# grant plumbing — F048's optimistic-concurrency dance, in three helpers +# --------------------------------------------------------------------------- + + +async def _permission_context(client: httpx.AsyncClient, token: str, app_id: str) -> dict: + response = await client.get( + f"{API_BASE}/permissions/resources/app/{app_id}/context", + headers=auth_headers(token), + ) + return assert_resp_200(response) + + +async def _permission_roster(client: httpx.AsyncClient, token: str, app_id: str) -> list[dict]: + response = await client.get( + f"{API_BASE}/permissions/resources/app/{app_id}/grants", + params={"page_size": 100}, + headers=auth_headers(token), + ) + return assert_resp_200(response)["data"] + + +async def _grant_viewer(client: httpx.AsyncClient, admin_token: str, app_id: str, user_id: str) -> None: + """Give one user the ``viewer`` model on one hosted application. + + ``viewer`` is level 1 and ``use`` is a level-1 action, so this is the + smallest grant that makes the application enterable — which is the point: + AC-15 wants the *minimum* visible-scope grant to confer no management at + all, and a larger model would not test that. + """ + context = await _permission_context(client, admin_token, app_id) + response = await client.post( + f"{API_BASE}/permissions/resources/app/{app_id}/grants:mutate", + json={ + "idempotency_key": f"f056-add-{uuid4().hex}", + "expected_resource_version": context["resource_version"], + "expected_catalog_release_id": context["catalog_release_id"], + "changes": [{"op": "ADD", "model_key": "viewer", "subject": {"type": "user", "id": str(user_id)}}], + }, + headers=auth_headers(admin_token), + ) + assert_resp_200(response) + + +async def _revoke_user(client: httpx.AsyncClient, admin_token: str, app_id: str, user_id: str) -> int: + """Remove every editable grant this user holds on the application. + + Returns how many were removed, so a caller can tell "revoked" from "there + was nothing to revoke" — the difference between a real teardown and a + fixture that quietly did nothing. + """ + removed = 0 + for _ in range(10): + roster = await _permission_roster(client, admin_token, app_id) + targets = [ + item + for item in roster + if item["subject"]["type"] == "user" + and str(item["subject"]["id"]) == str(user_id) + and not item["protected"] + and item["scope"] == "LOCAL" + ] + if not targets: + return removed + context = await _permission_context(client, admin_token, app_id) + response = await client.post( + f"{API_BASE}/permissions/resources/app/{app_id}/grants:mutate", + json={ + "idempotency_key": f"f056-remove-{uuid4().hex}", + "expected_resource_version": context["resource_version"], + "expected_catalog_release_id": context["catalog_release_id"], + "changes": [ + { + "op": "REMOVE", + "assignee_id": targets[0]["assignee_id"], + "expected_assignee_version": targets[0]["assignee_version"], + } + ], + }, + headers=auth_headers(admin_token), + ) + assert_resp_200(response) + removed += 1 + pytest.fail(f"could not clear grants for user {user_id} on app {app_id} in 10 rounds") + + +# --------------------------------------------------------------------------- +# fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +async def client(): + async with httpx.AsyncClient(timeout=30.0) as value: + yield value + + +@pytest.fixture(scope="module") +async def admin_token(client: httpx.AsyncClient) -> str: + return await get_admin_token(client) + + +@pytest.fixture(scope="module") +async def normal_user(client: httpx.AsyncClient) -> tuple[str, str]: + """``(user_id, token)`` for the ordinary account every assertion runs as.""" + user_id = _required_env("F056_E2E_USER_ID") + token = await get_user_token( + client, + _required_env("F056_E2E_USER_NAME"), + _required_env("F056_E2E_USER_PASSWORD"), + ) + return user_id, token + + +@pytest.fixture(scope="module") +async def hosted_apps(client: httpx.AsyncClient, admin_token: str, normal_user: tuple[str, str]) -> dict: + """Hosted applications on this deployment, grouped by state. + + Discovered rather than created: there is no endpoint that makes a hosted + application, and a suite that faked one by writing ``app`` rows would be + testing its own INSERT instead of the publish pipeline's output. + + Applications the test user owns are dropped from every bucket. An owner is + allowed through by ``owner``, so using one would make "the grant did it" + unfalsifiable. + """ + user_id, _ = normal_user + response = await client.get(f"{API_BASE}/apps", headers=auth_headers(admin_token)) + rows = assert_resp_200(response) or [] + foreign = [row for row in rows if str(row.get("owner_user_id")) != str(user_id)] + grouped: dict[str, list[dict]] = {} + for row in foreign: + grouped.setdefault(str(row.get("state")), []).append(row) + if not grouped.get("online"): + pytest.skip( + "no hosted application in state 'online' owned by somebody other than the test user; " + "deploy one with `bisheng deploy` before running F056 T016" + ) + return grouped + + +@pytest.fixture(scope="module") +def target_app(hosted_apps: dict) -> dict: + return hosted_apps["online"][0] + + +@pytest.fixture() +async def clean_grants(client: httpx.AsyncClient, admin_token: str, normal_user: tuple[str, str], target_app: dict): + """Leave the test user with no grant on the target application, before and after. + + Before matters as much as after: a leftover grant from an interrupted run + would make ``test_grant_then_revoke_visibility`` start from 1 and assert + nothing. + """ + user_id, _ = normal_user + await _revoke_user(client, admin_token, target_app["app_id"], user_id) + yield + await _revoke_user(client, admin_token, target_app["app_id"], user_id) + + +# --------------------------------------------------------------------------- +# AC-04 / AC-05 — a grant is visible on the next request, and so is its removal +# --------------------------------------------------------------------------- + + +async def test_grant_then_revoke_visibility( + client: httpx.AsyncClient, + admin_token: str, + normal_user: tuple[str, str], + target_app: dict, + clean_grants, +): + """0 → 1 → 0, each step observed by the **very next** request. + + No re-login, no sleep, no cache-expiry wait anywhere in this test. That is + the assertion: if visibility were served from a TTL cache, the middle step + would still pass and only the last would fail — intermittently, and only on + a machine slower than the TTL. Doing it without a wait is what makes the + failure deterministic. + """ + user_id, user_token = normal_user + app_id = target_app["app_id"] + + before = await _square_rows(client, user_token, keyword=target_app["name"], flow_type=HOSTED_APP_FLOW_TYPE) + assert _hosted_row(before, app_id) is None, "the test user could already see the application before any grant" + + await _grant_viewer(client, admin_token, app_id, user_id) + + after_grant = await _square_rows(client, user_token, keyword=target_app["name"], flow_type=HOSTED_APP_FLOW_TYPE) + assert _hosted_row(after_grant, app_id) is not None, "the grant did not reach the square on the next request" + + removed = await _revoke_user(client, admin_token, app_id, user_id) + assert removed >= 1, "teardown removed nothing, so the revoke half asserts nothing" + + after_revoke = await _square_rows(client, user_token, keyword=target_app["name"], flow_type=HOSTED_APP_FLOW_TYPE) + assert _hosted_row(after_revoke, app_id) is None, "the application stayed visible after the grant was revoked" + + +# --------------------------------------------------------------------------- +# AC-06 — the square and the entry answer from one source +# --------------------------------------------------------------------------- + + +async def test_square_and_entry_same_source( + client: httpx.AsyncClient, + admin_token: str, + normal_user: tuple[str, str], + target_app: dict, + clean_grants, +): + """Visible in the square ⟺ enterable. Both directions, same user, same app. + + The steady state this forbids is "the card is there and clicking it says no + permission", which is what happens the moment the two sides decide with + different actions — the square on ``visible``, the entry on ``use``. Only + the *pair* of assertions catches it: a square that showed everything would + pass the granted half on its own. + """ + user_id, user_token = normal_user + app_id = target_app["app_id"] + + assert not await _entry_allows(client, user_token, app_id) + denied_rows = await _square_rows(client, user_token, keyword=target_app["name"], flow_type=HOSTED_APP_FLOW_TYPE) + assert _hosted_row(denied_rows, app_id) is None + + await _grant_viewer(client, admin_token, app_id, user_id) + + assert await _entry_allows(client, user_token, app_id) + allowed_rows = await _square_rows(client, user_token, keyword=target_app["name"], flow_type=HOSTED_APP_FLOW_TYPE) + assert _hosted_row(allowed_rows, app_id) is not None + + +# --------------------------------------------------------------------------- +# AC-02 / AC-03 — which states the square shows, decided server-side +# --------------------------------------------------------------------------- + + +async def test_state_filter( + client: httpx.AsyncClient, + admin_token: str, + normal_user: tuple[str, str], + hosted_apps: dict, +): + """``stopped`` still appears (carrying ``app_state``); draft / pending / deleted never do. + + Run with a granted normal user *and* with the administrator, because the two + halves fail differently: the exclusion is a SQL predicate rather than a + permission check, so an administrator — who is allowed through every + permission — is exactly the account that would reveal a state filter + implemented in the wrong layer. + """ + user_id, user_token = normal_user + stopped = (hosted_apps.get("stopped") or [None])[0] + if stopped is None: + pytest.skip("no hosted application in state 'stopped'; stop one to exercise AC-03's first half") + + await _revoke_user(client, admin_token, stopped["app_id"], user_id) + await _grant_viewer(client, admin_token, stopped["app_id"], user_id) + try: + rows = await _square_rows(client, user_token, keyword=stopped["name"], flow_type=HOSTED_APP_FLOW_TYPE) + row = _hosted_row(rows, stopped["app_id"]) + assert row is not None, "a stopped application must stay in the square — it is entered to be resumed" + assert row["app_state"] == "stopped" + finally: + await _revoke_user(client, admin_token, stopped["app_id"], user_id) + + hidden = [row for state in HIDDEN_STATES for row in hosted_apps.get(state, [])] + if not hidden: + pytest.skip("no hosted application in a hidden state (draft / pending_capacity)") + + # Queried by name, one hidden application at a time: a broad listing would + # turn "not on page 1 of 100" into a pass, and a negative assertion that can + # be satisfied by pagination is not an assertion. + for token in (user_token, admin_token): + for one in hidden: + rows = await _square_rows(client, token, keyword=one["name"], flow_type=HOSTED_APP_FLOW_TYPE) + uncategorized = await _uncategorized_rows(client, token, keyword=one["name"]) + assert _hosted_row(rows, one["app_id"]) is None, ( + f"application {one['app_id']} in state {one['state']} reached the tagged square entry" + ) + assert _hosted_row(uncategorized, one["app_id"]) is None, ( + f"application {one['app_id']} in state {one['state']} reached the uncategorised entry" + ) + + page = await _square_rows(client, token, flow_type=HOSTED_APP_FLOW_TYPE) + assert all(str(row.get("app_state")) in SQUARE_STATES for row in page if row.get("app_state") is not None) + + +# --------------------------------------------------------------------------- +# AC-01 / AC-07 — the card's payload +# --------------------------------------------------------------------------- + + +async def test_payload_shape( + client: httpx.AsyncClient, + admin_token: str, + normal_user: tuple[str, str], + target_app: dict, + clean_grants, +): + """A hosted row carries what the card needs; the other two types are untouched. + + ``slug`` is the load-bearing one — the client navigates to ``/apps/{slug}``, + so a missing value produces a link to ``/apps/undefined`` with no error + anywhere. ``can_share`` is false because a hosted application has no share + link to hand out, and ``user_name`` is the owner's display name that the + card's byline reads. + """ + user_id, user_token = normal_user + app_id = target_app["app_id"] + await _grant_viewer(client, admin_token, app_id, user_id) + + rows = await _square_rows(client, user_token, keyword=target_app["name"]) + row = _hosted_row(rows, app_id) + assert row is not None + + assert row["flow_type"] == HOSTED_APP_FLOW_TYPE + assert row["slug"] == target_app["slug"] + assert row["app_state"] in SQUARE_STATES + assert row["can_share"] is False + assert row.get("user_name"), "the card's owner byline reads user_name" + + others = [one for one in rows if one.get("flow_type") != HOSTED_APP_FLOW_TYPE] + for other in others: + assert other.get("slug") is None, "slug is a hosted-application field and must stay absent elsewhere" + assert other.get("app_state") is None + + +async def test_uncategorized_tab_shows_untagged_hosted_app( + client: httpx.AsyncClient, + admin_token: str, + normal_user: tuple[str, str], + target_app: dict, + clean_grants, +): + """An untagged hosted application appears on the默认分类 tab, with its ``slug``. + + This is the backend half only. ``get_uncategorized_flows`` never calls + ``add_extra_field``, so it is the entry where an enrichment attached in the + wrong place disappears — and it is the tab the acceptance script opens + first. + """ + user_id, user_token = normal_user + app_id = target_app["app_id"] + await _grant_viewer(client, admin_token, app_id, user_id) + + tagged = await _square_rows(client, user_token, keyword=target_app["name"], flow_type=HOSTED_APP_FLOW_TYPE) + if _hosted_row(tagged, app_id) is None: + pytest.skip("the target application is not visible in the square at all; earlier cases cover that failure") + + rows = await _uncategorized_rows(client, user_token, keyword=target_app["name"]) + row = _hosted_row(rows, app_id) + if row is None: + roster_names = [str(one.get("name")) for one in rows] + pytest.skip( + "the target application carries a tag, so 'uncategorised' correctly excludes it; " + f"pick an untagged hosted application (saw: {roster_names[:5]})" + ) + assert row["slug"] == target_app["slug"] + assert row["flow_type"] == HOSTED_APP_FLOW_TYPE + assert row["app_state"] in SQUARE_STATES + + +# --------------------------------------------------------------------------- +# AC-04 — tenant isolation, asserted by somebody who is not an administrator +# --------------------------------------------------------------------------- + + +async def test_tenant_isolation(client: httpx.AsyncClient, hosted_apps: dict): + """A user of tenant B never sees tenant A's hosted applications. + + A super admin crosses tenants legitimately, so running this as one proves + nothing at all — the check has to come from an ordinary account on the other + side of the boundary. + """ + name = os.environ.get("F056_E2E_CROSS_TENANT_USER_NAME", "").strip() + password = os.environ.get("F056_E2E_CROSS_TENANT_USER_PASSWORD", "").strip() + if not name or not password: + pytest.skip("set F056_E2E_CROSS_TENANT_USER_NAME / _PASSWORD to exercise tenant isolation") + + cross_token = await get_user_token(client, name, password) + foreign = [row for rows in hosted_apps.values() for row in rows] + all_ids = {str(row["app_id"]) for row in foreign} + + rows = await _square_rows(client, cross_token, flow_type=HOSTED_APP_FLOW_TYPE) + uncategorized = await _uncategorized_rows(client, cross_token) + seen = {str(one.get("id")) for one in rows} | {str(one.get("id")) for one in uncategorized} + assert not (seen & all_ids), f"cross-tenant hosted application(s) visible: {sorted(seen & all_ids)}" + + # The listing above can only be trusted as far as its page reaches, so each + # application is also asked directly. The entry probe is the exact question + # and needs no pagination — a leak that a wide page hid still fails here. + for one in foreign[:20]: + by_name = await _square_rows(client, cross_token, keyword=one["name"], flow_type=HOSTED_APP_FLOW_TYPE) + assert _hosted_row(by_name, one["app_id"]) is None, f"cross-tenant application {one['app_id']} in the square" + assert not await _entry_allows(client, cross_token, one["app_id"]), ( + f"cross-tenant application {one['app_id']} is enterable" + ) + + +# --------------------------------------------------------------------------- +# AC-15 — being granted visibility grants no management +# --------------------------------------------------------------------------- + + +async def test_manage_dialog_denied_for_grantee( + client: httpx.AsyncClient, + admin_token: str, + normal_user: tuple[str, str], + target_app: dict, + clean_grants, +): + """``viewer`` opens the application; it does not open the permission dialog. + + ``manage_permission`` is a level-3 action and ``viewer`` is level 1, so the + two travel separately — but only if the roster endpoint checks the concrete + action rather than "can this user see the resource at all". Both the read + and the write are asserted: a read-only leak still shows who else has + access. + """ + user_id, user_token = normal_user + app_id = target_app["app_id"] + await _grant_viewer(client, admin_token, app_id, user_id) + + assert await _entry_allows(client, user_token, app_id), "the viewer grant did not take effect" + + roster = await client.get( + f"{API_BASE}/permissions/resources/app/{app_id}/grants", + params={"page_size": 100}, + headers=auth_headers(user_token), + ) + assert roster.status_code == 200, "a refusal must ride inside a 200 envelope, or the SPA navigates to /403" + assert_resp_error(roster, 19000) + + # The optimistic-concurrency fields come from the **administrator's** view of + # the resource, so the only thing left that can refuse this call is the + # permission check. Sending 0 / 1 would let a version-mismatch error pass for + # a permission error and the test would go green on a broken gate. + context = await _permission_context(client, admin_token, app_id) + mutate = await client.post( + f"{API_BASE}/permissions/resources/app/{app_id}/grants:mutate", + json={ + "idempotency_key": f"f056-denied-{uuid4().hex}", + "expected_resource_version": context["resource_version"], + "expected_catalog_release_id": context["catalog_release_id"], + "changes": [{"op": "ADD", "model_key": "viewer", "subject": {"type": "user", "id": str(user_id)}}], + }, + headers=auth_headers(user_token), + ) + assert mutate.status_code == 200 + assert_resp_error(mutate, 19000) diff --git a/src/backend/test/workflow/test_workflow_action_map.py b/src/backend/test/workflow/test_workflow_action_map.py new file mode 100644 index 0000000000..c0d36aac06 --- /dev/null +++ b/src/backend/test/workflow/test_workflow_action_map.py @@ -0,0 +1,134 @@ +"""F056 T004 — per-resource-type action legality in ``_application_action_map``. + +Why this file exists at all: ``share`` is not a legal action for the ``app`` +resource type (``catalog_policy.ACTION_RESOURCE_SCOPES``), and asking for it +does **not** produce a 500. ``_prepare_action_target`` raises +``InvalidCatalogActionError``, which is a ``BaseErrorCode``, so FastAPI answers +HTTP 200 with business code 25001; the SPA's request wrapper rejects, the list +page catches and blanks itself, and the backend log shows no 5xx at all. On top +of that, both administrator short-circuits fire *before* the legality check, so +the bug is invisible to every account a developer normally tests with. + +The bucket that has to be filtered is therefore not "the square's bucket" but +"every bucket, everywhere" — ``aenrich_apps_can_share`` alone asks for +``("share",)`` from four call sites that have nothing to do with the square. +""" + +from __future__ import annotations + +import pytest + +from bisheng.api.services.workflow import WorkFlowService +from bisheng.database.models.flow import FlowType + +HOSTED = FlowType.HOSTED_APP.value +WORKFLOW = FlowType.WORKFLOW.value +ASSISTANT = FlowType.ASSISTANT.value + + +@pytest.fixture() +def action_probe(monkeypatch): + """Record what ``_application_action_map`` asks F048 for, per resource type. + + Returns a namespace whose ``asked`` maps resource_type -> requested actions. + Everything is granted, so an empty result can only mean the request was + never made. + """ + from types import SimpleNamespace + + from bisheng.common.services.config_service import settings + + monkeypatch.setattr(settings.app_runtime, "enabled", True) + + asked: dict[str, tuple[str, ...]] = {} + + async def _batch_check(user, *, resource_type, resource_ids, actions): + asked[resource_type] = tuple(actions) + return {str(resource_id): frozenset(actions) for resource_id in resource_ids} + + monkeypatch.setattr( + "bisheng.api.services.workflow.batch_check_business_actions", + _batch_check, + ) + return SimpleNamespace(asked=asked) + + +def _rows() -> list[dict]: + return [ + {"id": "wf-1", "flow_type": WORKFLOW}, + {"id": "asst-1", "flow_type": ASSISTANT}, + {"id": "app-1", "flow_type": HOSTED}, + ] + + +async def test_app_bucket_drops_share(action_probe): + """``share`` never reaches the ``app`` bucket — asking for it is the 25001.""" + await WorkFlowService._application_action_map( + object(), + _rows(), + ("use", "edit", "share"), + ) + + assert "share" not in action_probe.asked["app"] + assert set(action_probe.asked["app"]) == {"use", "edit"} + + +async def test_other_buckets_unchanged(action_probe): + """Workflows and assistants keep the exact request they had before F056.""" + await WorkFlowService._application_action_map( + object(), + _rows(), + ("use", "edit", "share"), + ) + + assert action_probe.asked["workflow"] == ("use", "edit", "share") + assert action_probe.asked["assistant"] == ("use", "edit", "share") + + +async def test_visible_never_filtered(action_probe): + """``visible`` is exempt from the legality filter in every bucket. + + It is not a registered action code — ``_normalize_action`` rejects it and + ``batch_check_visible`` answers it instead — so ``ACTION_RESOURCE_SCOPES`` + has no row for it. Filtering it out would leave the square asking nothing + and rendering an empty page. + """ + await WorkFlowService._application_action_map( + object(), + _rows(), + ("visible", "edit"), + ) + + for resource_type in ("workflow", "assistant", "app"): + assert "visible" in action_probe.asked[resource_type] + + +async def test_enrich_can_share_no_error(action_probe): + """``aenrich_apps_can_share`` tolerates hosted rows: no 25001, ``can_share`` false. + + This is the path the workbench recommendation strip, the frequently-used + list and ``/chat/online?sort_by=update_time`` all take — none of them is the + square, and none of them is covered by an F056 acceptance criterion. + """ + data = await WorkFlowService.aenrich_apps_can_share(object(), _rows()) + + # The app bucket had nothing legal left to ask, so no check was issued. + assert "app" not in action_probe.asked + assert action_probe.asked["workflow"] == ("share",) + by_id = {row["id"]: row for row in data} + assert by_id["app-1"]["can_share"] is False + assert by_id["wf-1"]["can_share"] is True + + +async def test_actions_by_type_override_is_opt_in(action_probe): + """The per-bucket override changes only the bucket named; the rest keep ``actions``.""" + await WorkFlowService._application_action_map( + object(), + _rows(), + ("visible", "edit", "share"), + actions_by_type={"app": ("use", "edit")}, + ) + + assert set(action_probe.asked["app"]) == {"use", "edit"} + assert action_probe.asked["workflow"] == ("visible", "edit", "share") + assert action_probe.asked["assistant"] == ("visible", "edit", "share") diff --git a/src/backend/test/workflow/test_workflow_auto_rerun_status.py b/src/backend/test/workflow/test_workflow_auto_rerun_status.py index f2521c49bc..afc7444f11 100644 --- a/src/backend/test/workflow/test_workflow_auto_rerun_status.py +++ b/src/backend/test/workflow/test_workflow_auto_rerun_status.py @@ -34,7 +34,7 @@ async def get_response_until_break(self): async def async_clear_workflow_status(self): self.cleared = True - def set_workflow_stop(self): + async def async_set_workflow_stop(self): self.stopped = True def clear_workflow_status(self): @@ -48,6 +48,8 @@ def make_client(monkeypatch, callback, *, has_history=True, online=True): client.user_id = 1 client.latest_history = SimpleNamespace(category="other") if has_history else None client.workflow = None + client.session_subject = None + client.execution_snapshot = None client.hash_key = None client.websocket = object() client.run_lock = asyncio.Lock() @@ -162,4 +164,5 @@ async def websocket_close_message(self, **_kwargs): await client.check_status({"flow_id": "flow-1", "chat_id": "chat-1"}) assert sent == [{"category": "processing", "type": "close", "message": ""}] - assert callback.cleared is True + assert callback.stopped is True + assert callback.cleared is False diff --git a/src/backend/test/workflow/test_workflow_offline_input.py b/src/backend/test/workflow/test_workflow_offline_input.py new file mode 100644 index 0000000000..f4c30b33ae --- /dev/null +++ b/src/backend/test/workflow/test_workflow_offline_input.py @@ -0,0 +1,71 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from bisheng.common.chat.clients import workflow_client as module +from bisheng.database.models.flow import FlowStatus +from bisheng.workflow.common.workflow import WorkflowStatus +from test.workflow.test_workflow_auto_rerun_status import FakeRedisCallback, make_client + + +@pytest.mark.parametrize("runtime_exists", [True, False]) +@pytest.mark.parametrize("deleted", [True, False]) +async def test_stale_card_checks_current_publication_before_dispatch(monkeypatch, runtime_exists, deleted): + callback = FakeRedisCallback(final_status=WorkflowStatus.INPUT.value) + client, sent = make_client(monkeypatch, callback, online=False) + client.workflow = callback if runtime_exists else None + client.websocket = SimpleNamespace(send_json=AsyncMock()) + if deleted: + monkeypatch.setattr(module.FlowDao, "get_flow_by_id", lambda _: None) + resume = Mock() + monkeypatch.setattr(module.continue_workflow, "apply_async", resume) + + await client._handle_message({"action": "input", "data": {"input_1": {"data": {"text": "stale"}}}}) + + assert client.websocket.send_json.call_args.args[0]["message"]["status_code"] == 13010 + assert sent == [{"category": "processing", "type": "close", "message": ""}] + assert client.workflow is None + assert callback.stopped is runtime_exists + assert callback.cleared is False + resume.assert_not_called() + + +async def test_same_connection_rechecks_status_for_each_card(monkeypatch): + callback = FakeRedisCallback(final_status=WorkflowStatus.INPUT.value) + callback.unique_id, callback.workflow_id, callback.chat_id, callback.user_id = "run", "flow-1", "chat-1", 1 + callback.async_set_user_input = AsyncMock() + callback.async_set_workflow_status = AsyncMock() + client, _ = make_client(monkeypatch, callback) + client.workflow = callback + client.websocket = SimpleNamespace(send_json=AsyncMock()) + client.get_execute_worker = AsyncMock(return_value="worker") + client.workflow_run = AsyncMock() + resume = Mock() + monkeypatch.setattr(module.continue_workflow, "apply_async", resume) + payload = {"action": "input", "data": {"input_1": {"data": {"text": "value"}}}} + + await client._handle_message(payload) + assert resume.call_count == 1 + monkeypatch.setattr(module.FlowDao, "get_flow_by_id", lambda _: SimpleNamespace(status=FlowStatus.OFFLINE.value)) + await client._handle_message(payload) + + assert resume.call_count == 1 + assert callback.async_set_user_input.await_count == 1 + assert client.websocket.send_json.call_args.args[0]["message"]["status_code"] == 13010 + + +async def test_builder_preview_keeps_draft_execution(monkeypatch): + client, _ = make_client(monkeypatch, FakeRedisCallback(), online=False) + client.chat_id = "" + should_start, _ = await client.check_status({"flow_id": "flow-1", "chat_id": ""}, is_init=True) + assert should_start is True + + +@pytest.mark.parametrize("runtime_exists", [True, False]) +async def test_expired_runtime_settles_stale_card(monkeypatch, runtime_exists): + callback = FakeRedisCallback() + client, sent = make_client(monkeypatch, callback) + client.workflow = callback if runtime_exists else None + await client.handle_user_input({}) + assert sent == [{"category": "processing", "type": "close", "message": ""}] diff --git a/src/backend/test/workflow/test_workflow_session_title.py b/src/backend/test/workflow/test_workflow_session_title.py new file mode 100644 index 0000000000..d5b534c2fc --- /dev/null +++ b/src/backend/test/workflow/test_workflow_session_title.py @@ -0,0 +1,132 @@ +"""Workflow titles must describe submitted questions, never input-node schemas.""" + +import json +from types import SimpleNamespace + +import pytest + +from bisheng.worker.workflow import redis_callback as callback_module +from bisheng.worker.workflow.redis_callback import RedisCallback + + +@pytest.fixture +def title_context(monkeypatch): + session = SimpleNamespace(chat_id="chat-title", name="", tenant_id=1) + questions = [SimpleNamespace(message="Recommend places to visit in Hangzhou")] + inputs = [SimpleNamespace(message=json.dumps({"node_id": "input_1", "input_schema": {"key": "user_input"}}))] + received = [] + + def messages(chat_id, categories, limit): + assert chat_id == "chat-title" + return (questions if categories == ["question"] else inputs)[:limit] + + def persist(chat_id, title, *, only_if_default=False, tenant_id=None): + assert chat_id == "chat-title" + if only_if_default and session.name not in (None, "", "New Chat"): + return False + session.name = title + return True + + class TitleModel: + def invoke(self, messages): + received.append(messages[0].content) + if "Recommend places to visit in Hangzhou" in messages[0].content: + return SimpleNamespace(content="Hangzhou travel recommendations") + return SimpleNamespace(content="Input Node Schema") + + monkeypatch.setattr(callback_module.ChatMessageDao, "get_messages_by_chat_id", messages) + monkeypatch.setattr(callback_module.MessageSessionDao, "get_one", lambda chat_id: session) + monkeypatch.setattr(callback_module.MessageSessionDao, "update_session_name_sync", persist) + monkeypatch.setattr( + callback_module.LLMService, + "get_workbench_llm_sync", + lambda **kwargs: SimpleNamespace(chat_title_llm=SimpleNamespace(id="913")), + ) + monkeypatch.setattr(callback_module.LLMService, "get_bisheng_llm_sync", lambda **kwargs: TitleModel()) + from bisheng.common.utils import title_generator + + monkeypatch.setattr( + title_generator, + "get_prompt_manager_sync", + lambda: SimpleNamespace( + render_prompt=lambda *args, **kwargs: SimpleNamespace(prompt="Summarize: " + kwargs["human"]) + ), + ) + callback = RedisCallback.__new__(RedisCallback) + callback.chat_id = session.chat_id + callback.user_id = 3 + callback.tenant_id = 1 + callback.new_session = session + return SimpleNamespace(callback=callback, session=session, questions=questions, received=received) + + +def test_title_uses_submitted_question_instead_of_input_schema(title_context): + title_context.callback.generate_session_title() + assert title_context.session.name == "Hangzhou travel recommendations" + assert title_context.received == ["Summarize: Recommend places to visit in Hangzhou"] + + +def test_no_question_does_not_generate_a_title_from_input_schema(title_context): + title_context.questions.clear() + title_context.callback.generate_session_title() + assert title_context.received == [] + assert title_context.session.name == "" + + +def test_manual_rename_during_model_call_is_not_overwritten(monkeypatch, title_context): + class SlowTitleModel: + def invoke(self, messages): + title_context.session.name = "Manually named conversation" + return SimpleNamespace(content="Hangzhou travel recommendations") + + monkeypatch.setattr(callback_module.LLMService, "get_bisheng_llm_sync", lambda **kwargs: SlowTitleModel()) + title_context.callback.generate_session_title() + assert title_context.session.name == "Manually named conversation" + + +def test_question_can_name_a_session_created_before_this_callback(title_context): + title_context.callback.new_session = None + title_context.callback.generate_session_title() + assert title_context.session.name == "Hangzhou travel recommendations" + + +def test_default_title_can_be_retried_after_a_question_arrives(title_context): + title_context.session.name = "New Chat" + title_context.callback.generate_session_title() + assert title_context.session.name == "Hangzhou travel recommendations" + + +def test_existing_title_is_not_overwritten(title_context): + title_context.session.name = "My conversation" + title_context.callback.generate_session_title() + assert title_context.session.name == "My conversation" + assert title_context.received == [] + + +@pytest.mark.parametrize("category,should_schedule", [("question", True), ("guide_word", False)]) +@pytest.mark.parametrize("session_exists", [False, True]) +def test_title_is_scheduled_after_question_not_session_creation( + monkeypatch, title_context, category, should_schedule, session_exists +): + callback = title_context.callback + callback.workflow_id = "workflow-1" + callback.create_session = False + callback.source = "platform" + callback._finalize_citation_items = lambda *args, **kwargs: [] + scheduled = [] + existing = [title_context.session] if session_exists else [] + + def insert_session(session): + existing.append(session) + return session + + monkeypatch.setattr(callback_module.MessageSessionDao, "get_one", lambda chat_id: existing[0] if existing else None) + monkeypatch.setattr(callback_module.MessageSessionDao, "insert_one", insert_session) + monkeypatch.setattr(callback_module.ChatMessageDao, "insert_one", lambda message: SimpleNamespace(id=1)) + monkeypatch.setattr(callback_module.FlowDao, "get_flow_by_id", lambda flow_id: SimpleNamespace(name="Workflow")) + monkeypatch.setattr(callback_module, "promote_chat_attachments_sync", lambda *args: None) + monkeypatch.setattr(callback_module, "save_message_citations_sync", lambda **kwargs: None) + monkeypatch.setattr(callback_module.telemetry_service, "log_event_sync", lambda **kwargs: None) + monkeypatch.setattr(callback_module.thread_pool, "submit", lambda key, fn, *args: scheduled.append(fn)) + callback.save_chat_message(callback_module.ChatResponse(category=category, message="Hello", source=0, type="over")) + assert (callback.generate_session_title in scheduled) is should_schedule diff --git a/src/backend/test/workstation/test_chat_file_parse_error.py b/src/backend/test/workstation/test_chat_file_parse_error.py index 5a5e57b117..0ba8671fbd 100644 --- a/src/backend/test/workstation/test_chat_file_parse_error.py +++ b/src/backend/test/workstation/test_chat_file_parse_error.py @@ -18,9 +18,22 @@ from bisheng.utils.exceptions import EtlException from bisheng.workstation.domain.services import chat_service + +@pytest.fixture(autouse=True) +def configured_image_parser(monkeypatch): + from types import SimpleNamespace + from unittest.mock import AsyncMock + + from bisheng.common.services.config_service import settings + + monkeypatch.setattr( + type(settings), "async_get_knowledge", AsyncMock(return_value=SimpleNamespace(image_parser_enabled=True)) + ) + + # Verbatim shape of the PaddleOCR (Baidu AI Studio) throttle response. OCR_THROTTLE_MESSAGE = ( - 'PaddleOCR API error: status=429, resp={"logId":"5f23efd7","errorCode":12002,"errorMsg":"请求频率过高,请稍后重试"}' + 'PaddleOCR API error: status=429, resp={"logId":"5f23efd7","errorCode":12002,"errorMsg":"请求频率过高,请稍后重试"}' # noqa: RUF001 -- Verbatim upstream response. ) @@ -133,3 +146,79 @@ async def arun(self): file_name="clip.avi", invoke_user_id=1, ) + + +async def test_image_without_configured_parser_is_rejected_before_parsing(monkeypatch): + from types import SimpleNamespace + from unittest.mock import AsyncMock + + from bisheng.common.services.config_service import settings + + monkeypatch.setattr( + type(settings), "async_get_knowledge", AsyncMock(return_value=SimpleNamespace(image_parser_enabled=False)) + ) + parse = AsyncMock(return_value="silently ignored image") + monkeypatch.setattr(chat_service, "get_file_content", parse) + with pytest.raises(ChatFileParseError) as caught: + await chat_service._extract_doc_text("/tmp/photo.png", "photo.png", 1) + assert caught.value.kwargs["error_type"] == "file_parse_failed" + assert caught.value.kwargs["filename"] == "photo.png" + parse.assert_not_awaited() + + +async def test_unsupported_file_is_not_silently_replaced_with_empty_text(monkeypatch): + from bisheng.common.errcode.knowledge import KnowledgeFileNotSupportedError + + class Pipeline: + def __init__(self, **_kwargs): + pass + + async def arun(self): + raise KnowledgeFileNotSupportedError() + + monkeypatch.setattr("bisheng.knowledge.rag.temp_file_pipeline.TempFilePipeline", Pipeline) + with pytest.raises(KnowledgeFileNotSupportedError): + await chat_service.get_file_content("/tmp/photo.png", "photo.png", 1) + + +async def test_configured_ocr_timeout_has_terminal_sse_error_envelope(monkeypatch): + from types import SimpleNamespace + from unittest.mock import AsyncMock + + from bisheng.common.services.config_service import settings + + monkeypatch.setattr( + type(settings), "async_get_knowledge", AsyncMock(return_value=SimpleNamespace(image_parser_enabled=True)) + ) + error = await _run_extract(monkeypatch, EtlException("PaddleOCR API timeout")) + payload = json.loads(error.to_sse_event_instance_str().split("data: ", 1)[1]) + assert payload["status_code"] == 12047 + assert payload["data"]["error_type"] == "file_parse_failed" + assert "timeout" in payload["data"]["detail"] + + +async def test_vision_images_bypass_ocr_even_when_no_parser_is_configured(monkeypatch): + from types import SimpleNamespace + from unittest.mock import AsyncMock + + from bisheng.common.services.config_service import settings + + monkeypatch.setattr( + type(settings), "async_get_knowledge", AsyncMock(return_value=SimpleNamespace(image_parser_enabled=False)) + ) + monkeypatch.setattr(chat_service, "async_file_download", AsyncMock(return_value=("/tmp/photo.png", "photo.png"))) + monkeypatch.setattr(chat_service, "read_image_as_data_url", AsyncMock(return_value="data:image/png;base64,aA==")) + monkeypatch.setattr( + chat_service, "_annotate_agent_files_with_video_covers", AsyncMock(side_effect=lambda files, _: files) + ) + parse = AsyncMock() + monkeypatch.setattr(chat_service, "get_file_content", parse) + text, images, _ = await chat_service._process_agent_files( + SimpleNamespace(files=[{"filepath": "/photo.png"}]), + SimpleNamespace(visual=True), + SimpleNamespace(user_id=1), + SimpleNamespace(maxTokens=1000), + ) + assert text == "" + assert images == ["data:image/png;base64,aA=="] + parse.assert_not_awaited() diff --git a/src/backend/test/workstation/test_chat_protocol_selection.py b/src/backend/test/workstation/test_chat_protocol_selection.py new file mode 100644 index 0000000000..172a0e7f4c --- /dev/null +++ b/src/backend/test/workstation/test_chat_protocol_selection.py @@ -0,0 +1,79 @@ +"""Workstation Chat calls must not inherit the Responses protocol switch.""" + +import json + +import httpx +import pytest +from langchain_core.messages import HumanMessage + +from bisheng.llm.domain.llm.llm import BishengLLM +from bisheng.llm.domain.models import LLMModel, LLMServer + + +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize("with_tools", [False, True]) +async def test_workstation_selects_chat_wire_protocol_without_mutating_model(stream, with_tools, monkeypatch): + requests = [] + + async def upstream(request): + requests.append(request) + if stream: + body = ( + 'data: {"id":"x","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"hello"},"finish_reason":null}]}\n\n' + 'data: {"id":"x","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n' + "data: [DONE]\n\n" + ) + return httpx.Response(200, text=body, headers={"content-type": "text/event-stream"}) + return httpx.Response( + 200, + json={ + "id": "x", + "object": "chat.completion", + "choices": [ + {"index": 0, "message": {"role": "assistant", "content": "hello"}, "finish_reason": "stop"} + ], + }, + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(upstream)) as http_client: + original_params = BishengLLM._get_llm_params + + def client_params(self, server, model): + return { + **original_params(self, server, model), + "http_async_client": http_client, + "max_retries": 0, + "streaming": stream, + } + + monkeypatch.setattr(BishengLLM, "_get_llm_params", client_params) + config = {"user_kwargs": json.dumps({"use_responses_api": True})} + model_info = LLMModel(id=42, server_id=7, model_name="test", model_type="llm", config=config) + server = LLMServer(id=7, type="openai", config={"api_key": "test", "base_url": "https://supplier.invalid/v1"}) + original = BishengLLM.get_class_instance_from_snapshot( + model_info=model_info, server_info=server, app_id="test", app_type="dsh_desktop", app_name="test", user_id=1 + ) + model = original.llm + if with_tools: + model = model.bind_tools( + [ + { + "type": "function", + "function": { + "name": "lookup", + "description": "Lookup", + "parameters": {"type": "object", "properties": {}}, + }, + } + ] + ) + if stream: + chunks = [chunk.content async for chunk in model.astream([HumanMessage(content="hi")])] + assert "".join(chunks) == "hello" + else: + assert (await model.ainvoke([HumanMessage(content="hi")])).content == "hello" + + assert requests[0].url.path == "/v1/chat/completions" + assert json.loads(requests[0].content)["model"] == "test" + assert original.llm.use_responses_api is False + assert model_info.config == config diff --git a/src/backend/test/workstation/test_chat_rate_limit.py b/src/backend/test/workstation/test_chat_rate_limit.py index 005dbb57b9..7f50541e47 100644 --- a/src/backend/test/workstation/test_chat_rate_limit.py +++ b/src/backend/test/workstation/test_chat_rate_limit.py @@ -62,3 +62,16 @@ def test_genuine_billing_not_misrouted_to_rate_limit(): say 'try later'); it stays QUOTA_EXHAUSTED so the copy points to 'top up'.""" for msg in ("账户余额不足", "您已欠费", "arrearage", "insufficient balance"): assert label_error(unwrap(RuntimeError(msg))) is ErrorType.QUOTA_EXHAUSTED, msg + + +def test_platform_credit_rejection_labels_credit_exhausted(): + """chat_service tags this BaseErrorCode so the bubble shows the credit card.""" + from bisheng.common.errcode.dsh import DshMonthlyTokenLimitExceededError + + exc = DshMonthlyTokenLimitExceededError() + assert label_error(exc) is ErrorType.CREDIT_EXHAUSTED + payload = json.loads( + exc.to_sse_event_instance_str(data={"error_type": ErrorType.CREDIT_EXHAUSTED.value}).split("data: ", 1)[1] + ) + assert payload["status_code"] == 26121 + assert payload["data"]["error_type"] == "credit_exhausted" diff --git a/src/backend/test/workstation/test_config_knowledge_permission.py b/src/backend/test/workstation/test_config_knowledge_permission.py index f21caa3225..d88805d67d 100644 --- a/src/backend/test/workstation/test_config_knowledge_permission.py +++ b/src/backend/test/workstation/test_config_knowledge_permission.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock from bisheng.api.v1.schemas import OrgKbConfig, WorkstationConfig +from bisheng.dsh.domain.services.capability_catalog import CapabilityCatalogService from bisheng.workstation.api.endpoints import config as config_endpoint from bisheng.workstation.domain.services.workstation_service import WorkStationService @@ -13,10 +14,11 @@ def _login_user(): async def test_public_config_keeps_only_visible_org_kbs(monkeypatch): login_user = _login_user() daily_config = WorkstationConfig( + tools=[{"id": 362, "name": "curated", "default_checked": True, "children": [{"id": 2125}]}], orgKbs=[ OrgKbConfig(id=12, name="hidden", default_checked=True, sort_order=0), OrgKbConfig(id=13, name="visible", default_checked=True, sort_order=1), - ] + ], ) async def fake_batch(user, *, resource_type, resource_ids, actions): @@ -55,14 +57,19 @@ async def fake_batch(user, *, resource_type, resource_ids, actions): config_endpoint, "bisheng_settings", SimpleNamespace( + dsh=SimpleNamespace(enabled=False), async_get_knowledge=AsyncMock(return_value=SimpleNamespace(image_parser_enabled=False)), aget_all_config=AsyncMock(return_value={}), aget_linsight_conf=AsyncMock(return_value=SimpleNamespace(waiting_list_url=None)), ), ) + picker = AsyncMock(return_value=[{"id": 362, "default_checked": True}]) + monkeypatch.setattr(CapabilityCatalogService, "picker_tools", picker) response = await config_endpoint.get_config(request=None, login_user=login_user) + picker.assert_awaited_once_with(configured_tools=daily_config.model_dump(exclude_unset=True)["tools"]) + assert response.data["tools"][0]["default_checked"] is True assert [item["id"] for item in response.data["orgKbs"]] == [13] assert response.data["orgKbs"][0]["default_checked"] is True diff --git a/src/backend/test/workstation/test_conversation_export_service.py b/src/backend/test/workstation/test_conversation_export_service.py index b70cd7875e..3366cd252d 100644 --- a/src/backend/test/workstation/test_conversation_export_service.py +++ b/src/backend/test/workstation/test_conversation_export_service.py @@ -11,7 +11,6 @@ from __future__ import annotations import json -from typing import Optional import pytest @@ -25,10 +24,8 @@ from bisheng.database.models.session import MessageSession, MessageSessionDao from bisheng.workstation.domain.services.conversation_export_service import ( ConversationExportService, - ConversationTurn, ) - # --- Fixtures -------------------------------------------------------------- @@ -37,10 +34,10 @@ def _make_chat_message( category: str, content: str, *, - chat_id: str = 'chat-1', + chat_id: str = "chat-1", user_id: int = 1, - parent_msg_id: Optional[int] = None, - sender: Optional[str] = None, + parent_msg_id: int | None = None, + sender: str | None = None, ) -> ChatMessage: """Build a real ChatMessage instance via the full SQLModel constructor. @@ -51,17 +48,17 @@ def _make_chat_message( """ extra_dict = {} if parent_msg_id is not None: - extra_dict['parentMessageId'] = parent_msg_id + extra_dict["parentMessageId"] = parent_msg_id return ChatMessage( id=id_, - is_bot=(category != 'question'), + is_bot=(category != "question"), chat_id=chat_id, user_id=user_id, - flow_id='flow-1', - type='human' if category == 'question' else 'assistant', + flow_id="flow-1", + type="human" if category == "question" else "assistant", category=category, message=content, - sender=sender or '', + sender=sender or "", extra=json.dumps(extra_dict) if extra_dict else None, tenant_id=1, ) @@ -69,15 +66,15 @@ def _make_chat_message( def _make_session( *, - chat_id: str = 'chat-1', + chat_id: str = "chat-1", flow_type: int = FlowType.WORKSTATION.value, user_id: int = 1, - name: str = 'New Chat', - flow_name: str = '', + name: str = "New Chat", + flow_name: str = "", ) -> MessageSession: return MessageSession( chat_id=chat_id, - flow_id='flow-1', + flow_id="flow-1", flow_name=flow_name, flow_type=flow_type, user_id=user_id, @@ -100,29 +97,26 @@ def patch_daos(monkeypatch: pytest.MonkeyPatch): arg-shape contract visible to the reader. """ - state: dict = {'session': None, 'messages': []} + state: dict = {"session": None, "messages": []} async def _fake_async_get_one(_cls, _chat_id): # type: ignore[no-untyped-def] - return state['session'] + return state["session"] async def _fake_aget_messages_by_ids(_cls, message_ids, user_id, chat_id): # type: ignore[no-untyped-def] # Mimic the real DAO semantics: filter by user_id + chat_id + id IN. - return [ - m for m in state['messages'] - if m.id in message_ids and m.user_id == user_id and m.chat_id == chat_id - ] + return [m for m in state["messages"] if m.id in message_ids and m.user_id == user_id and m.chat_id == chat_id] - monkeypatch.setattr(MessageSessionDao, 'async_get_one', classmethod(_fake_async_get_one)) - monkeypatch.setattr(ChatMessageDao, 'aget_messages_by_ids', classmethod(_fake_aget_messages_by_ids)) + monkeypatch.setattr(MessageSessionDao, "async_get_one", classmethod(_fake_async_get_one)) + monkeypatch.setattr(ChatMessageDao, "aget_messages_by_ids", classmethod(_fake_aget_messages_by_ids)) class _Registry: @staticmethod def set_session(s): - state['session'] = s + state["session"] = s @staticmethod def set_messages(rows): - state['messages'] = list(rows) + state["messages"] = list(rows) return _Registry() @@ -134,17 +128,19 @@ async def test_load_happy_path(patch_daos): """AC-01: 同会话同用户的 5 条 message 按 id 拉回,session 同时返回。""" session = _make_session() messages = [ - _make_chat_message(1, 'question', '你好'), - _make_chat_message(2, 'answer', '你好,有什么可以帮你?', parent_msg_id=1), - _make_chat_message(3, 'question', '今天天气'), - _make_chat_message(4, 'answer', '北京晴', parent_msg_id=3), - _make_chat_message(5, 'answer', '北京多云', parent_msg_id=3), + _make_chat_message(1, "question", "你好"), + _make_chat_message(2, "answer", "你好,有什么可以帮你?", parent_msg_id=1), + _make_chat_message(3, "question", "今天天气"), + _make_chat_message(4, "answer", "北京晴", parent_msg_id=3), + _make_chat_message(5, "answer", "北京多云", parent_msg_id=3), ] patch_daos.set_session(session) patch_daos.set_messages(messages) loaded, loaded_session = await ConversationExportService._load_and_validate_messages( - chat_id='chat-1', message_ids=[1, 2, 3, 4, 5], user_id=1, + chat_id="chat-1", + message_ids=[1, 2, 3, 4, 5], + user_id=1, ) assert [m.id for m in loaded] == [1, 2, 3, 4, 5] assert loaded_session is session @@ -152,16 +148,18 @@ async def test_load_happy_path(patch_daos): async def test_load_cross_chat_rejected(patch_daos): """AC-28: message_ids 属于其它 chat_id — 全部被 SQL 过滤掉 → NotOwned (12062)。""" - session = _make_session(chat_id='chat-1') + session = _make_session(chat_id="chat-1") messages = [ - _make_chat_message(10, 'question', '你好', chat_id='other-chat'), + _make_chat_message(10, "question", "你好", chat_id="other-chat"), ] patch_daos.set_session(session) patch_daos.set_messages(messages) with pytest.raises(ConversationMessageNotOwnedError): await ConversationExportService._load_and_validate_messages( - chat_id='chat-1', message_ids=[10], user_id=1, + chat_id="chat-1", + message_ids=[10], + user_id=1, ) @@ -173,7 +171,9 @@ async def test_load_cross_user_rejected(patch_daos): with pytest.raises(ConversationMessageNotOwnedError): await ConversationExportService._load_and_validate_messages( - chat_id='chat-1', message_ids=[1], user_id=1, + chat_id="chat-1", + message_ids=[1], + user_id=1, ) @@ -181,19 +181,21 @@ async def test_load_partial_missing_rejected(patch_daos): """AC-27: 部分 id 不存在 → NotFound (12060) 含缺失 id 列表。""" session = _make_session() messages = [ - _make_chat_message(1, 'question', '你好'), + _make_chat_message(1, "question", "你好"), # id=2 不存在 - _make_chat_message(3, 'question', 'q'), + _make_chat_message(3, "question", "q"), ] patch_daos.set_session(session) patch_daos.set_messages(messages) with pytest.raises(ConversationMessageNotFoundError) as exc: await ConversationExportService._load_and_validate_messages( - chat_id='chat-1', message_ids=[1, 2, 3], user_id=1, + chat_id="chat-1", + message_ids=[1, 2, 3], + user_id=1, ) # Error message should mention the missing id for diagnostics - assert '2' in str(exc.value) + assert "2" in str(exc.value) async def test_load_over_200_rejected(patch_daos): @@ -203,7 +205,9 @@ async def test_load_over_200_rejected(patch_daos): with pytest.raises(ConversationMessageBatchTooLargeError): await ConversationExportService._load_and_validate_messages( - chat_id='chat-1', message_ids=list(range(1, 202)), user_id=1, + chat_id="chat-1", + message_ids=list(range(1, 202)), + user_id=1, ) @@ -214,7 +218,9 @@ async def test_load_session_not_found(patch_daos): with pytest.raises(ConversationMessageNotOwnedError): await ConversationExportService._load_and_validate_messages( - chat_id='ghost', message_ids=[1], user_id=1, + chat_id="ghost", + message_ids=[1], + user_id=1, ) @@ -223,109 +229,109 @@ async def test_load_session_not_found(patch_daos): def test_build_turns_daily_mode(): """AC-11: flow_type=15 (日常模式),取 ChatMessage.sender 当模型名。""" - session = _make_session(flow_type=FlowType.WORKSTATION.value, flow_name='') + session = _make_session(flow_type=FlowType.WORKSTATION.value, flow_name="") msgs = [ - _make_chat_message(1, 'question', '今天天气'), - _make_chat_message(2, 'answer', '北京晴', parent_msg_id=1, sender='DeepSeek v3.2'), + _make_chat_message(1, "question", "今天天气"), + _make_chat_message(2, "answer", "北京晴", parent_msg_id=1, sender="DeepSeek v3.2"), ] - turns = ConversationExportService._build_turns(msgs, session, user_name='Admin') + turns = ConversationExportService._build_turns(msgs, session, user_name="Admin") assert len(turns) == 1 - assert turns[0].user_name == 'Admin' - assert turns[0].user_query == '今天天气' - assert turns[0].sender_name == 'DeepSeek v3.2' - assert turns[0].answers == ['北京晴'] + assert turns[0].user_name == "Admin" + assert turns[0].user_query == "今天天气" + assert turns[0].sender_name == "DeepSeek v3.2" + assert turns[0].answers == ["北京晴"] def test_build_turns_assistant(): """AC-11: flow_type=5 (助手), 取 MessageSession.flow_name 当应用名。""" - session = _make_session(flow_type=FlowType.ASSISTANT.value, flow_name='合同审核助手') + session = _make_session(flow_type=FlowType.ASSISTANT.value, flow_name="合同审核助手") msgs = [ - _make_chat_message(1, 'question', '帮我审一份合同'), - _make_chat_message(2, 'answer', '好的', parent_msg_id=1, sender='ignored-model'), + _make_chat_message(1, "question", "帮我审一份合同"), + _make_chat_message(2, "answer", "好的", parent_msg_id=1, sender="ignored-model"), ] - turns = ConversationExportService._build_turns(msgs, session, user_name='Admin') - assert turns[0].sender_name == '合同审核助手' # NOT 'ignored-model' + turns = ConversationExportService._build_turns(msgs, session, user_name="Admin") + assert turns[0].sender_name == "合同审核助手" # NOT 'ignored-model' def test_build_turns_workflow_multi_answer(): """AC-06: 单 query 多 answer (重新生成), 全部按 id 顺序保留。""" session = _make_session(flow_type=FlowType.WORKSTATION.value) msgs = [ - _make_chat_message(1, 'question', '介绍一下黄金'), - _make_chat_message(2, 'answer', '答 v1', parent_msg_id=1, sender='M'), - _make_chat_message(4, 'answer', '答 v2', parent_msg_id=1, sender='M'), - _make_chat_message(7, 'answer', '答 v3', parent_msg_id=1, sender='M'), + _make_chat_message(1, "question", "介绍一下黄金"), + _make_chat_message(2, "answer", "答 v1", parent_msg_id=1, sender="M"), + _make_chat_message(4, "answer", "答 v2", parent_msg_id=1, sender="M"), + _make_chat_message(7, "answer", "答 v3", parent_msg_id=1, sender="M"), ] - turns = ConversationExportService._build_turns(msgs, session, user_name='Admin') + turns = ConversationExportService._build_turns(msgs, session, user_name="Admin") assert len(turns) == 1 - assert turns[0].answers == ['答 v1', '答 v2', '答 v3'] + assert turns[0].answers == ["答 v1", "答 v2", "答 v3"] def test_build_turns_workflow_nontext_output(): """AC-09: workflow agent_answer 含非 text 输出 → [交互组件:xxx] 占位。""" - session = _make_session(flow_type=FlowType.WORKFLOW.value, flow_name='合同审核流') + session = _make_session(flow_type=FlowType.WORKFLOW.value, flow_name="合同审核流") payload = json.dumps( { - 'msg': '请填写下列表单', - 'events': [ - {'type': 'output', 'output_type': 'form', 'data': {'fields': []}}, - {'type': 'output', 'output_type': 'button', 'data': {'label': '确认'}}, + "msg": "请填写下列表单", + "events": [ + {"type": "output", "output_type": "form", "data": {"fields": []}}, + {"type": "output", "output_type": "button", "data": {"label": "确认"}}, ], }, ensure_ascii=False, ) msgs = [ - _make_chat_message(1, 'question', '开启流程'), - _make_chat_message(2, 'agent_answer', payload, parent_msg_id=1), + _make_chat_message(1, "question", "开启流程"), + _make_chat_message(2, "agent_answer", payload, parent_msg_id=1), ] - turns = ConversationExportService._build_turns(msgs, session, user_name='Admin') + turns = ConversationExportService._build_turns(msgs, session, user_name="Admin") text = turns[0].answers[0] - assert '请填写下列表单' in text - assert '[交互组件:form]' in text - assert '[交互组件:button]' in text + assert "请填写下列表单" in text + assert "[交互组件:form]" in text + assert "[交互组件:button]" in text def test_build_turns_agent_answer_msg_only(): """AC-15: agent_answer 的 events thinking / tool_call 被丢弃, 只取 msg。""" - session = _make_session(flow_type=FlowType.ASSISTANT.value, flow_name='Bot') + session = _make_session(flow_type=FlowType.ASSISTANT.value, flow_name="Bot") payload = json.dumps( { - 'msg': '最终回答正文', - 'events': [ - {'type': 'thinking', 'content': '内部思考链…不应导出'}, - {'type': 'tool_call', 'tool_name': 'web_search'}, - {'type': 'tool_result', 'result': '结果…不应导出'}, + "msg": "最终回答正文", + "events": [ + {"type": "thinking", "content": "内部思考链…不应导出"}, + {"type": "tool_call", "tool_name": "web_search"}, + {"type": "tool_result", "result": "结果…不应导出"}, ], }, ensure_ascii=False, ) msgs = [ - _make_chat_message(1, 'question', 'q'), - _make_chat_message(2, 'agent_answer', payload, parent_msg_id=1), + _make_chat_message(1, "question", "q"), + _make_chat_message(2, "agent_answer", payload, parent_msg_id=1), ] - turns = ConversationExportService._build_turns(msgs, session, user_name='Admin') + turns = ConversationExportService._build_turns(msgs, session, user_name="Admin") text = turns[0].answers[0] - assert text == '最终回答正文' - assert '内部思考' not in text - assert 'web_search' not in text - assert '结果' not in text + assert text == "最终回答正文" + assert "内部思考" not in text + assert "web_search" not in text + assert "结果" not in text def test_build_turns_strip_citations(): """AC-14: 私有 Unicode 角标 \\ue200…\\ue202 在 query 和 answer 中全部剥除。""" session = _make_session() # Citation pattern:  with  separator - answer_text = '答案前ref-1:item-1ref-2:item-2答案后' - query_text = '问题前qref问题后' + answer_text = "答案前ref-1:item-1ref-2:item-2答案后" + query_text = "问题前qref问题后" msgs = [ - _make_chat_message(1, 'question', query_text), - _make_chat_message(2, 'answer', answer_text, parent_msg_id=1, sender='M'), + _make_chat_message(1, "question", query_text), + _make_chat_message(2, "answer", answer_text, parent_msg_id=1, sender="M"), ] - turns = ConversationExportService._build_turns(msgs, session, user_name='Admin') - assert turns[0].user_query == '问题前问题后' - assert turns[0].answers == ['答案前答案后'] + turns = ConversationExportService._build_turns(msgs, session, user_name="Admin") + assert turns[0].user_query == "问题前问题后" + assert turns[0].answers == ["答案前答案后"] # No marker chars should remain - for ch in ('', '', ''): + for ch in ("", "", ""): assert ch not in turns[0].user_query assert ch not in turns[0].answers[0] @@ -333,189 +339,202 @@ def test_build_turns_strip_citations(): def test_build_turns_strip_lone_citation_markers(): """流式截断遗留的孤立 marker (start without end) 也要清理干净。""" session = _make_session() - text = '答案A未闭合标记开始' # missing  + text = "答案A未闭合标记开始" # missing  msgs = [ - _make_chat_message(1, 'question', 'q'), - _make_chat_message(2, 'answer', text, parent_msg_id=1, sender='M'), + _make_chat_message(1, "question", "q"), + _make_chat_message(2, "answer", text, parent_msg_id=1, sender="M"), ] - turns = ConversationExportService._build_turns(msgs, session, user_name='Admin') - assert '' not in turns[0].answers[0] + turns = ConversationExportService._build_turns(msgs, session, user_name="Admin") + assert "" not in turns[0].answers[0] def test_build_turns_parent_message_id_missing_uses_time_adjacency(): """spec §3: legacy 数据 extra 无 parentMessageId → 时间相邻 (id-based) 兜底配对。""" session = _make_session() msgs = [ - _make_chat_message(10, 'question', 'q1'), # 锚点 1 - _make_chat_message(11, 'answer', 'a1', sender='M'), # orphan -> 10 - _make_chat_message(20, 'question', 'q2'), # 锚点 2 - _make_chat_message(21, 'answer', 'a2', sender='M'), # orphan -> 20 - _make_chat_message(22, 'answer', 'a2-重试', sender='M'), # orphan -> 20 + _make_chat_message(10, "question", "q1"), # 锚点 1 + _make_chat_message(11, "answer", "a1", sender="M"), # orphan -> 10 + _make_chat_message(20, "question", "q2"), # 锚点 2 + _make_chat_message(21, "answer", "a2", sender="M"), # orphan -> 20 + _make_chat_message(22, "answer", "a2-重试", sender="M"), # orphan -> 20 ] - turns = ConversationExportService._build_turns(msgs, session, user_name='Admin') + turns = ConversationExportService._build_turns(msgs, session, user_name="Admin") assert len(turns) == 2 - assert turns[0].user_query == 'q1' - assert turns[0].answers == ['a1'] - assert turns[1].user_query == 'q2' - assert turns[1].answers == ['a2', 'a2-重试'] + assert turns[0].user_query == "q1" + assert turns[0].answers == ["a1"] + assert turns[1].user_query == "q2" + assert turns[1].answers == ["a2", "a2-重试"] def test_build_turns_drops_tool_call_messages(): """AC-15: 独立 ChatMessage 行 category=agent_tool_call/agent_thinking → 完全丢弃。""" session = _make_session() msgs = [ - _make_chat_message(1, 'question', 'q'), - _make_chat_message(2, 'agent_thinking', '思考中...', parent_msg_id=1), - _make_chat_message(3, 'agent_tool_call', '{"tool":"x"}', parent_msg_id=1), - _make_chat_message(4, 'answer', '最终答', parent_msg_id=1, sender='M'), + _make_chat_message(1, "question", "q"), + _make_chat_message(2, "agent_thinking", "思考中...", parent_msg_id=1), + _make_chat_message(3, "agent_tool_call", '{"tool":"x"}', parent_msg_id=1), + _make_chat_message(4, "answer", "最终答", parent_msg_id=1, sender="M"), ] - turns = ConversationExportService._build_turns(msgs, session, user_name='Admin') + turns = ConversationExportService._build_turns(msgs, session, user_name="Admin") assert len(turns) == 1 # Only the 'answer' survives; thinking/tool_call collapse to '' and are filtered out. - assert turns[0].answers == ['最终答'] + assert turns[0].answers == ["最终答"] def test_build_turns_empty_input(): """边界:没有 message → 返回空 turn 列表 (不抛)。""" session = _make_session() - turns = ConversationExportService._build_turns([], session, user_name='Admin') + turns = ConversationExportService._build_turns([], session, user_name="Admin") assert turns == [] def test_build_turns_query_without_answer(): """边界:query 没有对应 answer (用户刚提问就退出选择态) → query 单独成 turn。""" - session = _make_session(flow_type=FlowType.WORKSTATION.value, flow_name='Workstation') - msgs = [_make_chat_message(1, 'question', '今天天气')] - turns = ConversationExportService._build_turns(msgs, session, user_name='Admin') + session = _make_session(flow_type=FlowType.WORKSTATION.value, flow_name="Workstation") + msgs = [_make_chat_message(1, "question", "今天天气")] + turns = ConversationExportService._build_turns(msgs, session, user_name="Admin") assert len(turns) == 1 assert turns[0].answers == [] # Sender name falls back to flow_name when no answer - assert turns[0].sender_name == 'Workstation' + assert turns[0].sender_name == "Workstation" # Issue: real workstation/workflow data wraps the user query in JSON envelopes # rather than storing plain text. The renderer must unwrap before passing to # Markdown, otherwise the export shows ``{"query": "..."}`` literally. + def test_build_turns_unwraps_daily_mode_query_envelope(): """日常模式:``{"query": "...", "files": []}`` → 渲染为 query 字段内容。""" session = _make_session(flow_type=FlowType.WORKSTATION.value) msgs = [ _make_chat_message( - 1, 'question', - json.dumps({'query': '今天天气', 'files': []}, ensure_ascii=False), + 1, + "question", + json.dumps({"query": "今天天气", "files": []}, ensure_ascii=False), ), - _make_chat_message(2, 'answer', '北京晴', sender='M'), + _make_chat_message(2, "answer", "北京晴", sender="M"), ] - turns = ConversationExportService._build_turns(msgs, session, user_name='Admin') - assert turns[0].user_query == '今天天气' + turns = ConversationExportService._build_turns(msgs, session, user_name="Admin") + assert turns[0].user_query == "今天天气" def test_build_turns_unwraps_workflow_input_envelope(): """工作流:``{"data": {...}, "input": "..."}`` → 渲染为 input 字段内容。""" - session = _make_session(flow_type=FlowType.WORKFLOW.value, flow_name='搬家助手') + session = _make_session(flow_type=FlowType.WORKFLOW.value, flow_name="搬家助手") msgs = [ _make_chat_message( - 1, 'question', + 1, + "question", json.dumps( - {'data': {'chatId': 'fc569', 'id': '265f9', 'type': 5}, - 'input': '引导问题1:如何根据物品数量和类型制定详细的搬家计划?'}, + { + "data": {"chatId": "fc569", "id": "265f9", "type": 5}, + "input": "引导问题1:如何根据物品数量和类型制定详细的搬家计划?", + }, ensure_ascii=False, ), ), - _make_chat_message(2, 'answer', '答复', sender='M'), + _make_chat_message(2, "answer", "答复", sender="M"), ] - turns = ConversationExportService._build_turns(msgs, session, user_name='Admin') - assert turns[0].user_query == '引导问题1:如何根据物品数量和类型制定详细的搬家计划?' + turns = ConversationExportService._build_turns(msgs, session, user_name="Admin") + assert turns[0].user_query == "引导问题1:如何根据物品数量和类型制定详细的搬家计划?" def test_build_turns_unwraps_query_then_strips_citations(): """JSON 拆出来的 query 文本仍要走角标剥除(边界场景:user 问的内容也可能含角标,少见但理论上可能)。""" session = _make_session() payload = json.dumps( - {'query': '问' + 'x' + '题', 'files': []}, + {"query": "问" + "x" + "题", "files": []}, ensure_ascii=False, ) msgs = [ - _make_chat_message(1, 'question', payload), - _make_chat_message(2, 'answer', '答', sender='M'), + _make_chat_message(1, "question", payload), + _make_chat_message(2, "answer", "答", sender="M"), ] - turns = ConversationExportService._build_turns(msgs, session, user_name='Admin') - assert turns[0].user_query == '问题' + turns = ConversationExportService._build_turns(msgs, session, user_name="Admin") + assert turns[0].user_query == "问题" # Issue: agent_answer with empty ``msg`` but text content sitting in # ``events`` (v2.5 agent-native format per chat_helpers.py:218-273). Previously # such answers exported as empty turns. + def test_build_turns_agent_answer_falls_back_to_events_text(): """``msg`` 为空时, 末尾 ``events`` 中的 text 块作为答复内容。""" - session = _make_session(flow_type=FlowType.ASSISTANT.value, flow_name='Bot') - payload = json.dumps({ - 'msg': '', - 'events': [ - {'type': 'thinking', 'content': '思考...'}, - {'type': 'text', 'content': '最终答复正文'}, - ], - }, ensure_ascii=False) + session = _make_session(flow_type=FlowType.ASSISTANT.value, flow_name="Bot") + payload = json.dumps( + { + "msg": "", + "events": [ + {"type": "thinking", "content": "思考..."}, + {"type": "text", "content": "最终答复正文"}, + ], + }, + ensure_ascii=False, + ) msgs = [ - _make_chat_message(1, 'question', 'q'), - _make_chat_message(2, 'agent_answer', payload, parent_msg_id=1), + _make_chat_message(1, "question", "q"), + _make_chat_message(2, "agent_answer", payload, parent_msg_id=1), ] - turns = ConversationExportService._build_turns(msgs, session, user_name='Admin') - assert turns[0].answers == ['最终答复正文'] + turns = ConversationExportService._build_turns(msgs, session, user_name="Admin") + assert turns[0].answers == ["最终答复正文"] def test_build_turns_agent_answer_workflow_text_output_captured(): """工作流 OUTPUT 节点 ``output_type=text`` 也要被收进答复正文 (不只是占位)。""" - session = _make_session(flow_type=FlowType.WORKFLOW.value, flow_name='流') - payload = json.dumps({ - 'msg': '', - 'events': [ - {'type': 'output', 'output_type': 'text', 'content': '工作流文本输出'}, - {'type': 'output', 'output_type': 'form'}, # 非 text → 占位 - ], - }, ensure_ascii=False) + session = _make_session(flow_type=FlowType.WORKFLOW.value, flow_name="流") + payload = json.dumps( + { + "msg": "", + "events": [ + {"type": "output", "output_type": "text", "content": "工作流文本输出"}, + {"type": "output", "output_type": "form"}, # 非 text → 占位 + ], + }, + ensure_ascii=False, + ) msgs = [ - _make_chat_message(1, 'question', 'q'), - _make_chat_message(2, 'agent_answer', payload, parent_msg_id=1), + _make_chat_message(1, "question", "q"), + _make_chat_message(2, "agent_answer", payload, parent_msg_id=1), ] - turns = ConversationExportService._build_turns(msgs, session, user_name='Admin') + turns = ConversationExportService._build_turns(msgs, session, user_name="Admin") text = turns[0].answers[0] - assert '工作流文本输出' in text - assert '[交互组件:form]' in text + assert "工作流文本输出" in text + assert "[交互组件:form]" in text def test_build_turns_agent_answer_strips_citations_from_events_text(): """从 events 拿出来的 text 也要剥角标 (而不只剥 msg 字段)。""" - session = _make_session(flow_type=FlowType.ASSISTANT.value, flow_name='Bot') - payload = json.dumps({ - 'msg': '', - 'events': [ - {'type': 'text', 'content': '答 ' + 'knowledgesearch_28624655:4' + ' 完'}, - ], - }, ensure_ascii=False) + session = _make_session(flow_type=FlowType.ASSISTANT.value, flow_name="Bot") + payload = json.dumps( + { + "msg": "", + "events": [ + {"type": "text", "content": "答 " + "knowledgesearch_28624655:4" + " 完"}, + ], + }, + ensure_ascii=False, + ) msgs = [ - _make_chat_message(1, 'question', 'q'), - _make_chat_message(2, 'agent_answer', payload, parent_msg_id=1), + _make_chat_message(1, "question", "q"), + _make_chat_message(2, "agent_answer", payload, parent_msg_id=1), ] - turns = ConversationExportService._build_turns(msgs, session, user_name='Admin') - assert '' not in turns[0].answers[0] - assert 'knowledgesearch' not in turns[0].answers[0] - assert turns[0].answers[0] == '答 完' - - + turns = ConversationExportService._build_turns(msgs, session, user_name="Admin") + assert "" not in turns[0].answers[0] + assert "knowledgesearch" not in turns[0].answers[0] + assert turns[0].answers[0] == "答 完" def test_build_turns_drops_unresolved_handles_from_answers_only(): - """F072 AC-15: an [Sn] the session table never knew does not reach the + """F075 AC-15: an [Sn] the session table never knew does not reach the exported answer; a user who typed [S1] in a question keeps it.""" - session = _make_session(flow_type=FlowType.WORKSTATION.value, flow_name='') + session = _make_session(flow_type=FlowType.WORKSTATION.value, flow_name="") answer = "结论一。\ue200knowledgesearch_ab12cd34:0\ue202结论二。[S99] 编号 [3] 保留" msgs = [ - _make_chat_message(1, 'question', '数组[S1]怎么用'), - _make_chat_message(2, 'answer', answer, parent_msg_id=1, sender='DeepSeek v3.2'), + _make_chat_message(1, "question", "数组[S1]怎么用"), + _make_chat_message(2, "answer", answer, parent_msg_id=1, sender="DeepSeek v3.2"), ] - turns = ConversationExportService._build_turns(msgs, session, user_name='Admin') - assert turns[0].user_query == '数组[S1]怎么用' - assert turns[0].answers == ['结论一。结论二。 编号 [3] 保留'] + turns = ConversationExportService._build_turns(msgs, session, user_name="Admin") + assert turns[0].user_query == "数组[S1]怎么用" + assert turns[0].answers == ["结论一。结论二。 编号 [3] 保留"] diff --git a/src/backend/test/workstation/test_conversation_title_lifecycle.py b/src/backend/test/workstation/test_conversation_title_lifecycle.py new file mode 100644 index 0000000000..d270115d3d --- /dev/null +++ b/src/backend/test/workstation/test_conversation_title_lifecycle.py @@ -0,0 +1,40 @@ +"""Task handoff must not own the lifetime of conversation naming.""" + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +from bisheng.workstation.domain.schemas.chat import APIChatCompletion +from bisheng.workstation.domain.services import chat_service, task_submit_service + + +async def test_task_title_survives_handoff_disconnect(monkeypatch): + started = asyncio.Event() + release = asyncio.Event() + saved = [] + + async def generate(_question, _llm, chat_id, _user, _request): + started.set() + await release.wait() + saved.append(chat_id) + + monkeypatch.setattr(chat_service, "gen_title", generate) + monkeypatch.setattr(chat_service.SensitiveWordPolicyService, "evaluate_workbench_user_text", lambda *_a: None) + monkeypatch.setattr( + task_submit_service, + "submit_task_turn", + AsyncMock(return_value=(SimpleNamespace(name="New Chat"), SimpleNamespace(id="sv1", session_id="c1"))), + ) + monkeypatch.setattr(chat_service.LLMService, "get_bisheng_llm", AsyncMock(return_value=object())) + data = APIChatCompletion(clientTimestamp="t", model="m1", text="总结文档", task_mode=True) + response = await chat_service._task_mode_stream_completion(MagicMock(), data, MagicMock()) + chunk = await anext(response.body_iterator) + assert "linsight_task_handoff" in chunk + await response.body_iterator.aclose() + try: + await asyncio.wait_for(started.wait(), 0.1) + release.set() + await asyncio.gather(*list(chat_service._BACKGROUND_TASKS)) + assert saved == ["c1"] + finally: + release.set() diff --git a/src/backend/test/workstation/test_daily_chat_citation_backstop.py b/src/backend/test/workstation/test_daily_chat_citation_backstop.py index 01deb7bcf0..18bc77f0fd 100644 --- a/src/backend/test/workstation/test_daily_chat_citation_backstop.py +++ b/src/backend/test/workstation/test_daily_chat_citation_backstop.py @@ -1,5 +1,5 @@ # ruff: noqa: RUF001 - the admin prompt under test deliberately carries full-width CJK punctuation -"""Daily-chat citation rules: the system prompt handed to the model (F072). +"""Daily-chat citation rules: the system prompt handed to the model (F075). ``_agent_stream_chat_completion`` builds the daily-chat system prompt as ``replace_legacy_citation_rules()``: the diff --git a/src/backend/test/workstation/test_daily_chat_citation_handles.py b/src/backend/test/workstation/test_daily_chat_citation_handles.py index f14af9ee2a..e9e77bc2c7 100644 --- a/src/backend/test/workstation/test_daily_chat_citation_handles.py +++ b/src/backend/test/workstation/test_daily_chat_citation_handles.py @@ -1,4 +1,4 @@ -"""F072 — the daily chat turn under the short-handle contract. +"""F075 — the daily chat turn under the short-handle contract. Drives the real SSE generator (no-tools branch) with a scripted model stream and a handle table preloaded as if earlier turns had allocated it, then checks diff --git a/src/backend/test/workstation/test_daily_chat_image_view.py b/src/backend/test/workstation/test_daily_chat_image_view.py index 694e4683e7..a260ceb35e 100644 --- a/src/backend/test/workstation/test_daily_chat_image_view.py +++ b/src/backend/test/workstation/test_daily_chat_image_view.py @@ -227,10 +227,18 @@ async def test_configured_vision_model_reads_pixels_without_view_image(monkeypat vision_llm=vision, ) runnable = wrapper({}, None) - result = await runnable.ainvoke([HumanMessage(content="文档里有哪些图片")]) + question = [HumanMessage(content="文档里有哪些图片")] + result = await runnable.ainvoke(question) assert chat.bind_calls == [["web_search"]] assert "view_image" not in chat.bind_calls[0] assert vision.seen - joined = "".join(str(m.content) for m in vision.seen[0]) - assert "data:image/png;base64,aaa" in joined - assert "/bisheng/knowledge/images/1/2/c.png" in result.content + seen = "".join(str(m.content) for m in vision.seen[0]) + assert "data:image/png;base64,aaa" in seen + assert result.content == "ok" + assert not result.tool_calls + handed = "".join(str(m.content) for m in chat.stream_messages[0]) + assert "图片查看结果:" in handed + assert "/bisheng/knowledge/images/1/2/c.png" in handed + assert "data:image/png;base64,aaa" not in handed + await runnable.ainvoke(question) + assert len(vision.seen) == 1 diff --git a/src/backend/test/workstation/test_daily_citation_tools.py b/src/backend/test/workstation/test_daily_citation_tools.py index 7d41170d82..3af0070c4e 100644 --- a/src/backend/test/workstation/test_daily_citation_tools.py +++ b/src/backend/test/workstation/test_daily_citation_tools.py @@ -1,4 +1,4 @@ -"""F072 — what the daily chat's retrieval tools show the model. +"""F075 — what the daily chat's retrieval tools show the model. AC-01: knowledge chunks carry ``S3`` and web results ``"ref": "S7"``; the registry key never appears. diff --git a/src/backend/test/workstation/test_daily_parsed_images.py b/src/backend/test/workstation/test_daily_parsed_images.py new file mode 100644 index 0000000000..90c7443ced --- /dev/null +++ b/src/backend/test/workstation/test_daily_parsed_images.py @@ -0,0 +1,85 @@ +"""Daily attachments must reach the model with retained, readable image URLs.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from langchain_core.documents import Document + +from bisheng.chat_session.domain.services.workflow_file_service import WorkflowFileService +from bisheng.common.errcode.workstation import ChatFileParseError +from bisheng.workstation.domain.services import chat_service + + +async def test_daily_parser_receives_the_owned_image_storage_context(monkeypatch): + storage = object() + factory = MagicMock(return_value=storage) + parse = AsyncMock(return_value="![image](/api/v1/chat/daily/files/image/content)") + monkeypatch.setattr(WorkflowFileService, "image_storage", factory) + monkeypatch.setattr(chat_service, "get_file_content", parse) + result = await chat_service._extract_doc_text( + "/tmp/source.pdf", + "source.pdf", + 7, + chat_id="daily", + source_url="/bisheng/chat/7/source.pdf", + tenant_id=3, + ) + assert result.startswith("![image](/api/v1/chat/daily/") + assert factory.call_args.kwargs | {"run_id": "ignored"} == { + "chat_id": "daily", + "user_id": 7, + "tenant_id": 3, + "workflow_id": "", + "run_id": "ignored", + "node_id": "daily-attachment", + "source_url": "/bisheng/chat/7/source.pdf", + "file_name": "source.pdf", + } + assert parse.await_args.kwargs["image_storage"] is storage + + +async def test_temp_pipeline_keeps_images_and_uses_the_retained_storage(monkeypatch): + storage = object() + pipeline = MagicMock() + pipeline.return_value.arun = AsyncMock( + return_value=SimpleNamespace( + documents=[Document(page_content="![image](/api/v1/chat/daily/files/image/content)")] + ) + ) + monkeypatch.setattr("bisheng.knowledge.rag.temp_file_pipeline.TempFilePipeline", pipeline) + monkeypatch.setattr( + chat_service.knowledge_imp.KnowledgeUtils, "chunk2promt", staticmethod(lambda body, _: body), raising=False + ) + result = await chat_service.get_file_content("/tmp/source.pdf", "source.pdf", 7, image_storage=storage) + assert pipeline.call_args.kwargs["image_storage"] is storage + assert pipeline.call_args.kwargs["file_rule"].retain_images == 1 + assert "/tmp/" not in result + + +async def test_storage_failure_is_reported_instead_of_returning_a_local_image_path(monkeypatch): + monkeypatch.setattr(WorkflowFileService, "image_storage", MagicMock(side_effect=OSError("storage unavailable"))) + parse = AsyncMock() + monkeypatch.setattr(chat_service, "get_file_content", parse) + with pytest.raises(ChatFileParseError) as caught: + await chat_service._extract_doc_text("/tmp/source.pdf", "source.pdf", 7, chat_id="daily") + assert caught.value.kwargs["filename"] == "source.pdf" + parse.assert_not_awaited() + + +async def test_live_daily_file_processing_binds_each_document_to_the_new_conversation(monkeypatch): + extract = AsyncMock(return_value="retained document") + monkeypatch.setattr(chat_service, "_extract_doc_text", extract) + monkeypatch.setattr(chat_service, "async_file_download", AsyncMock(return_value=("/tmp/source.pdf", "source.pdf"))) + monkeypatch.setattr( + chat_service, "_annotate_agent_files_with_video_covers", AsyncMock(side_effect=lambda files, _: files) + ) + files = [{"filepath": "/bisheng/chat/7/source.pdf"}] + await chat_service._process_agent_files( + SimpleNamespace(files=files), + SimpleNamespace(visual=False), + SimpleNamespace(user_id=7, tenant_id=3), + SimpleNamespace(maxTokens=1000), + conversation_id="new-daily", + ) + assert extract.call_args.kwargs == {"chat_id": "new-daily", "source_url": files[0]["filepath"], "tenant_id": 3} diff --git a/src/backend/test/workstation/test_f040_app_square_cursor.py b/src/backend/test/workstation/test_f040_app_square_cursor.py index c2722c51c4..11726e6f38 100644 --- a/src/backend/test/workstation/test_f040_app_square_cursor.py +++ b/src/backend/test/workstation/test_f040_app_square_cursor.py @@ -66,6 +66,10 @@ async def aget_all_apps( search_description=False, cursor=None, ranking_user_id=None, + # F056 pins these on both square entries (status_exempt_flow_types / + # app_state_in). The real DAO takes them; swallow them here so this + # fixture keeps testing the cursor walk and nothing else. + **_f056_square_filters, ): self.cursors.append(cursor) self.id_lists.append(id_list) @@ -89,8 +93,14 @@ async def aget_all_apps( return [copy.deepcopy(row) for row in window], has_more -async def _fake_action_map(user, data, actions): - return {str(item["id"]): (frozenset(actions) if str(item["id"]) in _VISIBLE_IDS else frozenset()) for item in data} +async def _fake_action_map(user, data, actions, actions_by_type=None): + # F056 asks the square for per-resource-type actions (``actions_by_type``); + # these rows are all plain workflows, so the default set is what applies. + del actions_by_type + return { + str(item["id"]): (frozenset(actions) if str(item["id"]) in _VISIBLE_IDS else frozenset()) + for item in data + } @pytest.mark.asyncio diff --git a/src/backend/test/workstation/test_f040_workbench_cursor.py b/src/backend/test/workstation/test_f040_workbench_cursor.py index 0182ad7856..d85c577be0 100644 --- a/src/backend/test/workstation/test_f040_workbench_cursor.py +++ b/src/backend/test/workstation/test_f040_workbench_cursor.py @@ -44,7 +44,7 @@ async def _used_page(*, page, limit, n=5, tag_spy=None): patch.object( apps_mod.WorkFlowService, "filter_apps_by_action", - new=AsyncMock(side_effect=lambda _user, data, _action: data), + new=AsyncMock(side_effect=lambda _user, data, _action, **_kwargs: data), ), patch.object(apps_mod.WorkFlowService, "get_logo_share_link", side_effect=lambda logo: logo, create=True), patch.object(apps_mod.TagDao, "get_tags_by_resource", new=tag_mock), diff --git a/src/backend/test/workstation/test_history_inline_thinking.py b/src/backend/test/workstation/test_history_inline_thinking.py new file mode 100644 index 0000000000..ca7467b693 --- /dev/null +++ b/src/backend/test/workstation/test_history_inline_thinking.py @@ -0,0 +1,25 @@ +"""Reading daily history hides inline thinking without rewriting stored rows.""" + +import json + +import pytest + +from bisheng.database.models.message import ChatMessage +from bisheng.workstation.domain.services.chat_helpers import _convert_legacy_message, _format_new_message + + +@pytest.mark.parametrize("legacy", [False, True]) +@pytest.mark.parametrize("text", ["secretAnswer", "Answerunfinished secret"]) +def test_history_hides_inline_thinking(legacy, text): + raw = text if legacy else json.dumps({"msg": text, "events": [{"type": "thinking", "content": "Plan"}]}) + row = ChatMessage(is_bot=True, type="end", category="answer" if legacy else "agent_answer", flow_id="", message=raw) + result = (_convert_legacy_message if legacy else _format_new_message)(row) + assert result["message"]["msg"] == "Answer" + assert row.message == raw + if not legacy: + assert result["message"]["events"] == [{"type": "thinking", "content": "Plan"}] + + +def test_user_question_is_not_filtered(): + row = ChatMessage(is_bot=False, type="over", category="question", flow_id="", message="Explain tags") + assert _convert_legacy_message(row)["message"]["query"] == "Explain tags" diff --git a/src/backend/test/workstation/test_kb_image_stage.py b/src/backend/test/workstation/test_kb_image_stage.py new file mode 100644 index 0000000000..53b7a72965 --- /dev/null +++ b/src/backend/test/workstation/test_kb_image_stage.py @@ -0,0 +1,237 @@ +"""Knowledge-base images handed to the code interpreter (stage, annotate, limits).""" + +from __future__ import annotations + +import os +from types import SimpleNamespace + +import pytest + +from bisheng.common.image_view import ImageRegistry +from bisheng.workstation.domain.services.chat_service import ( + _annotate_retrieved_chunk, + _attach_kb_image_stage, +) +from bisheng.workstation.domain.services.kb_image_stage import KbImageStage + +URL = "/bisheng/knowledge/images/1/27/img_1.png" +REL = "uploads/kb/1/27/img_1.png" + + +class _FakeResponse: + def __init__(self, data: bytes, declare_length: bool = True): + self._data = data + self.headers = {"Content-Length": str(len(data))} if declare_length else {} + self.closed = False + self.released = False + + def stream(self, size): + for i in range(0, len(self._data), size): + yield self._data[i : i + size] + + def close(self): + self.closed = True + + def release_conn(self): + self.released = True + + +class _FakeClient: + def __init__(self, objects: dict[str, bytes], declare_length: bool = True): + self.objects = objects + self.declare_length = declare_length + self.calls: list[tuple[str, str]] = [] + + def get_object(self, bucket, key): + self.calls.append((bucket, key)) + if key not in self.objects: + err = RuntimeError("missing") + err.code = "NoSuchKey" + raise err + return _FakeResponse(self.objects[key], self.declare_length) + + +def _stage(**overrides) -> KbImageStage: + kwargs = {"max_files": 100, "max_file_bytes": 1024, "max_total_bytes": 4096} + kwargs.update(overrides) + return KbImageStage("bisheng", **kwargs) + + +def test_annotate_keeps_markdown_and_appends_relative_path(): + stage = _stage() + out = stage.annotate(f"see ![chart]({URL}) done") + assert f"![chart]({URL})\u27e6file: {REL}\u27e7" in out + assert stage.paths() == [REL] + + +@pytest.mark.parametrize( + "url", + [ + "https://example.com/a.png", + "/other-bucket/knowledge/images/1/2/a.png", + "/bisheng/knowledge/images/1/2/../a.png", + "/bisheng/knowledge/images/x/2/a.png", + "/bisheng/knowledge/images/1/2/a/b.png", + "/bisheng/other/1/2/a.png", + ], +) +def test_annotate_ignores_urls_that_are_not_knowledge_images(url): + stage = _stage() + text = f"![x]({url})" + assert stage.annotate(text) == text + assert len(stage) == 0 + + +def test_same_image_registered_once(): + stage = _stage() + stage.annotate(f"![a]({URL}) ![b]({URL}?x=1)") + assert stage.paths() == [REL] + + +def test_annotate_runs_even_without_image_registry(): + stage = _stage() + out = _annotate_retrieved_chunk(f"![a]({URL})", None, stage) + assert REL in out and "img#" not in out + + +def test_annotate_runs_alongside_image_registry(): + stage = _stage() + out = _annotate_retrieved_chunk(f"![a]({URL})", ImageRegistry(), stage) + assert "\u27e6img#1\u27e7" in out and REL in out + + +def test_annotate_without_stage_is_unchanged(): + assert _annotate_retrieved_chunk(f"![a]({URL})", None) == f"![a]({URL})" + + +def test_materialize_writes_files(tmp_path): + stage = _stage() + stage.annotate(f"![a]({URL})") + client = _FakeClient({"knowledge/images/1/27/img_1.png": b"PNG"}) + notice = stage.materialize(str(tmp_path), lambda: client) + assert notice == "" + assert (tmp_path / REL).read_bytes() == b"PNG" + assert client.calls == [("bisheng", "knowledge/images/1/27/img_1.png")] + assert not list(tmp_path.rglob("*.part")) + + +def test_materialize_skips_existing_file(tmp_path): + stage = _stage() + stage.annotate(f"![a]({URL})") + target = tmp_path / REL + target.parent.mkdir(parents=True) + target.write_bytes(b"old") + client = _FakeClient({}) + assert stage.materialize(str(tmp_path), lambda: client) == "" + assert client.calls == [] + assert target.read_bytes() == b"old" + + +def test_materialize_reports_missing_object(tmp_path): + stage = _stage() + stage.annotate(f"![a]({URL})") + notice = stage.materialize(str(tmp_path), lambda: _FakeClient({})) + assert REL in notice and "not found" in notice + assert not (tmp_path / REL).exists() + + +def test_materialize_reports_unavailable_storage(tmp_path): + stage = _stage() + stage.annotate(f"![a]({URL})") + + def _boom(): + raise RuntimeError("no endpoint") + + notice = stage.materialize(str(tmp_path), _boom) + assert REL in notice and "not available" in notice + + +def test_materialize_without_entries_does_nothing(tmp_path): + called = [] + assert _stage().materialize(str(tmp_path), lambda: called.append(1)) == "" + assert called == [] + + +def test_file_count_limit(tmp_path): + stage = _stage(max_files=2) + objects = {} + for i in range(3): + stage.annotate(f"![a](/bisheng/knowledge/images/1/27/i{i}.png)") + objects[f"knowledge/images/1/27/i{i}.png"] = b"x" + notice = stage.materialize(str(tmp_path), lambda: _FakeClient(objects)) + assert sorted(os.listdir(tmp_path / "uploads/kb/1/27")) == ["i0.png", "i1.png"] + assert "i2.png" in notice and "limit of 2 images" in notice + + +@pytest.mark.parametrize("declare_length", [True, False]) +def test_per_image_limit(tmp_path, declare_length): + stage = _stage(max_file_bytes=4) + stage.annotate(f"![a]({URL})") + client = _FakeClient({"knowledge/images/1/27/img_1.png": b"12345"}, declare_length) + notice = stage.materialize(str(tmp_path), lambda: client) + assert "per-image limit" in notice + assert not (tmp_path / REL).exists() + assert not list(tmp_path.rglob("*.part")) + + +def test_total_bytes_limit(tmp_path): + stage = _stage(max_file_bytes=10, max_total_bytes=15) + objects = {} + for i in range(3): + stage.annotate(f"![a](/bisheng/knowledge/images/1/27/i{i}.png)") + objects[f"knowledge/images/1/27/i{i}.png"] = b"x" * 8 + notice = stage.materialize(str(tmp_path), lambda: _FakeClient(objects)) + assert sorted(os.listdir(tmp_path / "uploads/kb/1/27")) == ["i0.png"] + assert "total limit" in notice and "i1.png" in notice and "i2.png" in notice + + +def test_failure_advisory_only_when_code_mentions_object_path(): + stage = _stage() + stage.annotate(f"![a]({URL})") + assert stage.failure_advisory("print(1)") == "" + advice = stage.failure_advisory("open('/bisheng/knowledge/images/1/27/img_1.png')") + assert REL in advice + + +def test_from_settings_derives_total_from_max_copy_in(monkeypatch): + from bisheng.common.services.config_service import settings + + monkeypatch.setattr(settings.sandbox_conf, "max_copy_in_bytes", 1000) + monkeypatch.setattr(settings.sandbox_conf, "kb_image_stage_max_total_bytes", 0) + stage = KbImageStage.from_settings() + assert stage.max_file_bytes == 1000 + assert stage.max_total_bytes == 5000 + assert stage.max_files == 100 + + monkeypatch.setattr(settings.sandbox_conf, "kb_image_stage_max_total_bytes", 7) + assert KbImageStage.from_settings().max_total_bytes == 7 + + +class _FakeExecutor: + supports_kb_stage = True + + def __init__(self): + self.stage = None + + def attach_stage(self, stage): + self.stage = stage + + @property + def description(self): + return "desc+hint" if self.stage is not None else "desc" + + +def test_attach_kb_image_stage_refreshes_both_descriptions(): + executor = _FakeExecutor() + inner = SimpleNamespace(executor=executor, description="desc") + wrapper = SimpleNamespace(tool_instance=inner, description="desc") + _attach_kb_image_stage(wrapper, _stage()) + assert executor.stage is not None + assert inner.description == "desc+hint" + assert wrapper.description == "desc+hint" + + +def test_attach_kb_image_stage_ignores_tools_without_executor(): + tool = SimpleNamespace(description="x") + _attach_kb_image_stage(tool, _stage()) + assert tool.description == "x" diff --git a/src/backend/test/workstation/test_knowledge_space_tag_selection.py b/src/backend/test/workstation/test_knowledge_space_tag_selection.py new file mode 100644 index 0000000000..95f5640541 --- /dev/null +++ b/src/backend/test/workstation/test_knowledge_space_tag_selection.py @@ -0,0 +1,220 @@ +"""User-selected tags constrain retrieval before candidate ranking.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from pydantic import ValidationError + +from bisheng.workstation.domain.schemas.chat import UseKnowledgeBaseParam + + +def test_tag_filter_round_trip(): + params = UseKnowledgeBaseParam.model_validate( + {"knowledge_space_ids": [7], "knowledge_space_tag_filters": {"7": 11}} + ) + assert params.knowledge_space_tag_filters == {7: 11} + assert UseKnowledgeBaseParam().knowledge_space_tag_filters == {} + + +@pytest.mark.parametrize("filters", [{8: 11}, {7: 0}, {7: -1}, {7: True}, {0: 11}, {7: 1.5}, {7.0: 11}, {"7.0": 11}]) +def test_tag_filter_rejects_invalid_selection(filters): + with pytest.raises(ValidationError): + UseKnowledgeBaseParam(knowledge_space_ids=[7], knowledge_space_tag_filters=filters) + + +async def test_tag_scope_reuses_primary_and_visibility_prefilter(monkeypatch): + from bisheng.knowledge.domain.services.knowledge_file_visibility_service import ( + IndexFilter, + KnowledgeFileVisibilityService, + ) + from bisheng.knowledge.domain.services.knowledge_space_tag_filter_service import KnowledgeSpaceTagFilterService + + repo = SimpleNamespace( + tag_belongs_to_space=AsyncMock(return_value=True), list_tagged_file_ids=AsyncMock(return_value=[40, 41]) + ) + expected = IndexFilter( + strategy="in", milvus_expr="document_id in [40]", es_filter=[{"terms": {"metadata.document_id": [40]}}] + ) + build = AsyncMock(return_value=expected) + monkeypatch.setattr(KnowledgeFileVisibilityService, "build_index_prefilter", build) + assert await KnowledgeSpaceTagFilterService(repo).build_index_prefilter(7, 11, MagicMock()) is expected + build.assert_awaited_once_with(7, candidate_file_ids=[40, 41]) + + +async def test_invalid_or_empty_tag_never_widens_scope(monkeypatch): + from bisheng.common.errcode.tag import TagNotExistError + from bisheng.knowledge.domain.services.knowledge_file_visibility_service import KnowledgeFileVisibilityService + from bisheng.knowledge.domain.services.knowledge_space_tag_filter_service import KnowledgeSpaceTagFilterService + + repo = SimpleNamespace( + tag_belongs_to_space=AsyncMock(return_value=False), list_tagged_file_ids=AsyncMock(return_value=[]) + ) + service = KnowledgeSpaceTagFilterService(repo) + with pytest.raises(TagNotExistError): + await service.build_index_prefilter(7, 11, MagicMock()) + repo.list_tagged_file_ids.assert_not_awaited() + repo.tag_belongs_to_space.return_value = True + build = AsyncMock() + monkeypatch.setattr(KnowledgeFileVisibilityService, "build_index_prefilter", build) + assert (await service.build_index_prefilter(7, 11, MagicMock())).is_empty + build.assert_not_awaited() + + +@pytest.mark.parametrize("tagged", [False, True, "empty", "error"]) +async def test_both_indexes_receive_scope_before_retrieval(monkeypatch, tagged): + from langchain_core.documents import Document + + from bisheng.knowledge.domain.services.knowledge_file_visibility_service import IndexFilter + from bisheng.knowledge.domain.services.knowledge_space_tag_filter_service import KnowledgeSpaceTagFilterService + from bisheng.workstation.domain.services import workstation_service as module + + calls = [] + + def retriever(**kwargs): + calls.append(kwargs["search_kwargs"][0]) + return MagicMock() + + monkeypatch.setattr(module, "MultiRetriever", retriever) + monkeypatch.setattr( + module, + "KnowledgeRetrieverTool", + lambda **kwargs: SimpleNamespace( + ainvoke=AsyncMock(return_value=[Document(page_content="tagged hit", metadata={"document_id": 40})]) + ), + ) + monkeypatch.setattr( + module.WorkStationService, + "_filter_visible_space_kb_ids", + AsyncMock(return_value={"space_kb_ids": [7], "org_kb_ids": []}), + ) + monkeypatch.setattr( + module.KnowledgeRag, + "get_multi_knowledge_vectorstore", + AsyncMock( + return_value={7: {"milvus": MagicMock(), "es": MagicMock(), "knowledge": SimpleNamespace(name="space")}} + ), + ) + post_filter = AsyncMock(side_effect=lambda **kwargs: kwargs["docs"]) + monkeypatch.setattr(module.WorkStationService, "_post_filter_kb_docs_by_view_file", post_filter) + scope = IndexFilter( + strategy="in", milvus_expr="document_id in [40]", es_filter=[{"terms": {"metadata.document_id": [40]}}] + ) + if tagged == "empty": + scope = IndexFilter(strategy="empty") + prefilter = AsyncMock(return_value=scope) + if tagged == "error": + prefilter.side_effect = ValueError("Tag selection could not be resolved") + monkeypatch.setattr(KnowledgeSpaceTagFilterService, "build_index_prefilter", prefilter) + _, docs, failures = await module.WorkStationService.queryChunksFromDB( + "question", + UseKnowledgeBaseParam(knowledge_space_ids=[7], knowledge_space_tag_filters={7: 11} if tagged else {}), + 1000, + MagicMock(), + ) + if tagged in ("empty", "error"): + assert docs == [] + assert len(failures) == (1 if tagged == "error" else 0) + assert calls == [] + post_filter.assert_not_awaited() + return + assert len(docs) == 1 and failures == [] + post_filter.assert_awaited_once() + if tagged: + assert calls[0]["expr"] == scope.milvus_expr + assert calls[1]["filter"] == scope.es_filter + prefilter.assert_awaited_once() + else: + assert "expr" not in calls[0] and "filter" not in calls[1] + prefilter.assert_not_awaited() + + +async def test_daily_tool_freezes_user_tag_and_drops_it_for_unselected_subset(monkeypatch): + from bisheng.citation.domain.services.citation_prompt_helper import CitationRegistryCollector + from bisheng.workstation.domain.services import chat_service as module + + query = AsyncMock(return_value=([], [], [])) + monkeypatch.setattr(module.WorkStationService, "queryChunksFromDB", query) + tool = await module._build_knowledge_search_tool( + [{"id": 7, "source": "space", "selected_tag_id": 11}, {"id": 8, "source": "space"}], + SimpleNamespace(user_id=42), + 1000, + CitationRegistryCollector(), + ) + await tool.ainvoke( + { + "knowledge_base_ids": ["7"], + "query": "Q", + "filters": {"knowledge_base_filters": [{"knowledge_base_id": "7", "tags": ["other tag"]}]}, + } + ) + params = query.await_args.kwargs["use_knowledge_param"] + assert params.knowledge_space_tag_filters == {7: 11} + await tool.ainvoke({"knowledge_base_ids": ["8"], "query": "Q"}) + assert query.await_args.kwargs["use_knowledge_param"].knowledge_space_tag_filters == {} + + +async def test_repository_checks_tag_owner_type_and_space_file_links(monkeypatch): + from contextlib import asynccontextmanager + + from sqlalchemy import text + from sqlalchemy.ext.asyncio import create_async_engine + from sqlmodel.ext.asyncio.session import AsyncSession + + from bisheng.database.models.group_resource import ResourceTypeEnum + from bisheng.knowledge.domain.repositories import knowledge_space_tag_repository as module + + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as connection: + for statement in [ + "CREATE TABLE tag (id INTEGER PRIMARY KEY, business_type TEXT, business_id TEXT, tenant_id INTEGER)", + "CREATE TABLE taglink (id INTEGER PRIMARY KEY, tag_id INTEGER, resource_type INTEGER, resource_id TEXT, tenant_id INTEGER)", + "CREATE TABLE knowledgefile (id INTEGER PRIMARY KEY, knowledge_id INTEGER, tenant_id INTEGER)", + "INSERT INTO tag VALUES (11, 'knowledge_space', '7', 1), (12, 'knowledge_space', '8', 1), (13, 'knowledge', '7', 1)", + "INSERT INTO knowledgefile VALUES (40, 7, 1), (41, 8, 1), (42, 7, 1)", + ]: + await connection.execute(text(statement)) + await connection.execute( + text( + "INSERT INTO taglink VALUES (1, 11, :space, '40', 1), (2, 11, :space, '41', 1), (3, 11, :org, '42', 1), (4, 11, :space, 'not-an-id', 1)" + ), + {"space": ResourceTypeEnum.SPACE_FILE.value, "org": ResourceTypeEnum.KNOWLEDGE_FILE.value}, + ) + + @asynccontextmanager + async def session_scope(): + async with AsyncSession(engine) as session: + yield session + + monkeypatch.setattr(module, "get_async_db_session", session_scope) + try: + repository = module.KnowledgeSpaceTagRepository() + assert await repository.tag_belongs_to_space(7, 11) + assert not await repository.tag_belongs_to_space(7, 12) + assert not await repository.tag_belongs_to_space(7, 13) + assert not await repository.tag_belongs_to_space(7, 999) + assert await repository.list_tagged_file_ids(7, 11) == [40] + finally: + await engine.dispose() + + +async def test_daily_selection_preserves_fixed_tag_metadata(monkeypatch): + from bisheng.knowledge.domain.models.knowledge_file import KnowledgeFileDao + from bisheng.knowledge.domain.services.knowledge_space_tag_filter_service import KnowledgeSpaceTagFilterService + from bisheng.workstation.domain.services import chat_service as module + + validate = AsyncMock() + monkeypatch.setattr(KnowledgeSpaceTagFilterService, "validate_selection", validate) + monkeypatch.setattr( + module.KnowledgeDao, + "aget_list_by_ids", + AsyncMock(return_value=[SimpleNamespace(id=7, name="space", description="")]), + ) + monkeypatch.setattr(KnowledgeFileDao, "aget_file_by_filters", AsyncMock(return_value=[])) + result = await module._resolve_user_kb_selection( + SimpleNamespace( + use_knowledge_base=UseKnowledgeBaseParam(knowledge_space_ids=[7], knowledge_space_tag_filters={7: 11}) + ) + ) + assert result[0]["selected_tag_id"] == 11 and result[0]["source"] == "space" + validate.assert_awaited_once_with({7: 11}) diff --git a/src/backend/test/workstation/test_stream_interrupt_persist.py b/src/backend/test/workstation/test_stream_interrupt_persist.py index c68e751a57..5ceb071244 100644 --- a/src/backend/test/workstation/test_stream_interrupt_persist.py +++ b/src/backend/test/workstation/test_stream_interrupt_persist.py @@ -180,3 +180,26 @@ async def test_interrupt_after_completion_does_not_double_insert(stream_env): await body_iterator.aclose() assert len(_answer_rows(stream_env.inserted)) == 1 + + +@pytest.mark.parametrize("error_type", ["file_parse_failed", "file_parse_busy"]) +async def test_attachment_parse_failure_ends_stream_without_calling_model(stream_env, monkeypatch, error_type): + from bisheng.common.errcode.workstation import ChatFileParseError + + monkeypatch.setattr( + chat_service, + "_process_agent_files", + AsyncMock( + side_effect=ChatFileParseError(error_type=error_type, filename="photo.png", detail="photo.png: OCR failed") + ), + ) + stream_env.state["chunks"] = ["The model must not receive this image"] + chunks = await _drain(await chat_service._agent_stream_chat_completion(MagicMock(), _data(), MagicMock())) + output = "".join(chunks) + assert '"status_code": 12047' in output + assert error_type in output + assert '"final": true' in output + assert "The model must not receive this image" not in output + rows = _answer_rows(stream_env.inserted) + assert len(rows) == 1 + assert json.loads(rows[0].extra)["error"] is True diff --git a/src/backend/test/workstation/test_unified_chat_entry.py b/src/backend/test/workstation/test_unified_chat_entry.py index c8e0180eff..edc7c6a41b 100644 --- a/src/backend/test/workstation/test_unified_chat_entry.py +++ b/src/backend/test/workstation/test_unified_chat_entry.py @@ -131,3 +131,18 @@ def test_to_linsight_submit_no_skills_stays_none(): submit = chat_service._to_linsight_submit(data) assert submit.skills is None + + +async def test_daily_mode_clears_stale_skill_selection(patch_handlers): + data = _data(task_mode=False) + data.skills = ["morning-report"] + await chat_service.stream_chat_completion(MagicMock(), data, MagicMock()) + assert patch_handlers["daily"].await_args.args[1].skills == [] + assert data.skills == ["morning-report"] + + +async def test_task_mode_keeps_skill_selection(patch_handlers): + data = _data(task_mode=True) + data.skills = ["morning-report"] + await chat_service.stream_chat_completion(MagicMock(), data, MagicMock()) + assert patch_handlers["task"].await_args.args[1].skills == ["morning-report"] diff --git a/src/backend/test/workstation/test_used_app_pin.py b/src/backend/test/workstation/test_used_app_pin.py new file mode 100644 index 0000000000..90863fdd06 --- /dev/null +++ b/src/backend/test/workstation/test_used_app_pin.py @@ -0,0 +1,119 @@ +"""Persisted pin/unpin regressions using the real app and user-link DAOs.""" + +from contextlib import asynccontextmanager, contextmanager +from datetime import datetime, timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from sqlalchemy.ext.asyncio import create_async_engine +from sqlmodel import Session, SQLModel, create_engine +from sqlmodel.ext.asyncio.session import AsyncSession + +from bisheng.api.v1.schemas import UsedAppPin +from bisheng.common.errcode.workstation import UsedAppNotFoundError, UsedAppNotOnlineError +from bisheng.database.models import flow as flow_mod +from bisheng.database.models import user_link as link_mod +from bisheng.database.models.assistant import Assistant +from bisheng.database.models.flow import Flow +from bisheng.workstation.api.endpoints import apps as apps_mod + + +@pytest.fixture +async def pin_store(tmp_path, monkeypatch): + from bisheng.core.context.tenant import current_tenant_id, set_current_tenant_id + + tenant_token = set_current_tenant_id(1) + database = tmp_path / "pins.sqlite" + engine = create_engine(f"sqlite:///{database}") + SQLModel.metadata.create_all(engine, tables=[Flow.__table__, Assistant.__table__, link_mod.UserLink.__table__]) + with Session(engine) as session: + session.add(Assistant(id="asst-1", name="Assistant", tenant_id=1, status=2)) + session.add(Assistant(id="asst-offline", name="Offline", tenant_id=1, status=1)) + session.add(Assistant(id="asst-deleted", name="Deleted", tenant_id=1, status=2, is_delete=1)) + session.add(Assistant(id="asst-other-tenant", name="Other", tenant_id=2, status=2)) + session.add(Flow(id="wf-1", name="Workflow", tenant_id=1, status=2, flow_type=10)) + session.add(Flow(id="unsupported", name="Other type", tenant_id=1, status=2, flow_type=20)) + session.commit() + async_engine = create_async_engine(f"sqlite+aiosqlite:///{database}") + + @asynccontextmanager + async def async_session(): + async with AsyncSession(async_engine) as session: + yield session + + @contextmanager + def sync_session(): + with Session(engine) as session: + yield session + + monkeypatch.setattr(flow_mod, "get_async_db_session", async_session) + monkeypatch.setattr(link_mod, "get_sync_db_session", sync_session) + check = AsyncMock(return_value=True) + monkeypatch.setattr(apps_mod, "check_business_action", check) + monkeypatch.setattr( + apps_mod.WorkFlowService, "filter_apps_by_action", AsyncMock(side_effect=lambda u, rows, a: rows) + ) + monkeypatch.setattr(apps_mod.WorkFlowService, "aenrich_apps_can_share", AsyncMock(side_effect=lambda u, rows: rows)) + monkeypatch.setattr(apps_mod.WorkFlowService, "get_logo_share_link", lambda logo: logo) + monkeypatch.setattr(apps_mod.TagDao, "get_tags_by_resource", lambda *args: {}) + now = datetime(2026, 10, 28, 12) + monkeypatch.setattr( + apps_mod.MessageSessionDao, + "get_user_used_apps", + AsyncMock( + return_value=[ + ("wf-1", now), + ("asst-1", now - timedelta(hours=1)), + ] + ), + ) + yield SimpleNamespace(user=SimpleNamespace(user_id=7, tenant_id=1), check=check) + await async_engine.dispose() + engine.dispose() + current_tenant_id.reset(tenant_token) + + +@pytest.mark.parametrize("app_id,resource_type", [("asst-1", "assistant"), ("wf-1", "workflow")]) +async def test_pin_refresh_unpin_round_trip(pin_store, app_id, resource_type): + user = pin_store.user + reply = await apps_mod.pin_used_app(login_user=user, data=UsedAppPin(flow_id=app_id)) + assert reply.status_code == 200 + pin_store.check.assert_awaited_once_with(user, resource_type=resource_type, resource_id=app_id, action="use") + # Each read opens a fresh database session, matching a page reload. + for _ in range(2): + page = await apps_mod.get_used_apps(login_user=user, page=1, limit=1) + assert page.data["total"] == 2 + assert [(row["id"], row["is_pinned"]) for row in page.data["list"]] == [(app_id, True)] + other_user = SimpleNamespace(user_id=8, tenant_id=1) + other_page = await apps_mod.get_used_apps(login_user=other_user) + assert all(row["is_pinned"] is False for row in other_page.data["list"]) + await apps_mod.unpin_used_app(login_user=user, flow_id=app_id) + restored = await apps_mod.get_used_apps(login_user=user) + assert [row["id"] for row in restored.data["list"]] == ["wf-1", "asst-1"] + assert all(row["is_pinned"] is False for row in restored.data["list"]) + assert link_mod.UserLinkDao.get_user_link(user.user_id, [apps_mod.USED_APP_PIN_TYPE]) == [] + + +@pytest.mark.parametrize( + "app_id,error", + [ + ("missing", UsedAppNotFoundError), + ("asst-deleted", UsedAppNotFoundError), + ("asst-other-tenant", UsedAppNotFoundError), + ("unsupported", UsedAppNotFoundError), + ("asst-offline", UsedAppNotOnlineError), + ], +) +async def test_invalid_app_preserves_empty_pin_store(pin_store, app_id, error): + with pytest.raises(error): + await apps_mod.pin_used_app(login_user=pin_store.user, data=UsedAppPin(flow_id=app_id)) + assert pin_store.check.await_count == 0 + assert link_mod.UserLinkDao.get_user_link(pin_store.user.user_id, [apps_mod.USED_APP_PIN_TYPE]) == [] + + +async def test_denied_assistant_preserves_empty_pin_store(pin_store): + pin_store.check.return_value = False + reply = await apps_mod.pin_used_app(login_user=pin_store.user, data=UsedAppPin(flow_id="asst-1")) + assert reply.status_code == 403 + assert link_mod.UserLinkDao.get_user_link(pin_store.user.user_id, [apps_mod.USED_APP_PIN_TYPE]) == [] diff --git a/src/backend/test/workstation/test_workbench_content_safety_output.py b/src/backend/test/workstation/test_workbench_content_safety_output.py index abef23e4dc..7504d97a1d 100644 --- a/src/backend/test/workstation/test_workbench_content_safety_output.py +++ b/src/backend/test/workstation/test_workbench_content_safety_output.py @@ -7,12 +7,13 @@ from unittest.mock import AsyncMock, MagicMock import pytest +from langchain_core.messages import AIMessageChunk from bisheng.api.v1.schema.chat_schema import APIChatCompletion from bisheng.sensitive_word.domain.schemas import SensitiveWordCheckResult, SensitiveWordHit from bisheng.workstation.domain.services import chat_service -AUTO_REPLY = "当前对话内容违反相关规范,请修改后重新输入" +AUTO_REPLY = "当前对话内容违反相关规范,请修改后重新输入" # noqa: RUF001 - Match the localized policy reply. HIT = SensitiveWordCheckResult( enabled=True, hits=[SensitiveWordHit(word="禁", count=1)], @@ -75,6 +76,11 @@ async def astream(self, _messages): AsyncMock(return_value=(ws_config, conversation, message, _LLM(), model_info, False)), ) monkeypatch.setattr(chat_service, "_resolve_user_kb_selection", AsyncMock(return_value=[])) + monkeypatch.setattr("bisheng.common.image_view.loop.image_view_configured", AsyncMock(return_value=False)) + monkeypatch.setattr( + "bisheng.citation.domain.services.daily_citation_scope.load_handle_table", AsyncMock(return_value=({}, None)) + ) + monkeypatch.setattr(chat_service, "gen_title", AsyncMock(return_value=None)) monkeypatch.setattr(chat_service, "_prepare_tools", AsyncMock(return_value=([], []))) monkeypatch.setattr(chat_service, "_process_agent_files", AsyncMock(return_value=("", [], []))) monkeypatch.setattr(chat_service, "_get_history_max_tokens", AsyncMock(return_value=4096)) @@ -116,6 +122,26 @@ async def _drain(response) -> list[str]: return [chunk async for chunk in response.body_iterator] +async def test_daily_title_survives_disconnect_at_final(stream_env, monkeypatch): + import asyncio + + completed = asyncio.Event() + + async def generate(*_args): + completed.set() + + monkeypatch.setattr(chat_service, "gen_title", generate) + monkeypatch.setattr( + chat_service.SensitiveWordPolicyService, "is_workbench_content_safety_active", lambda *_a: False + ) + response = await chat_service._agent_stream_chat_completion(MagicMock(), _data(), MagicMock()) + async for chunk in response.body_iterator: + if '"final": true' in chunk: + break + await response.body_iterator.aclose() + await asyncio.wait_for(completed.wait(), 0.1) + + def _answer_rows(inserted: list): return [row for row in inserted if row.category == "agent_answer"] @@ -220,3 +246,58 @@ def __new__(cls, tenant_id): assert body["msg"] == AUTO_REPLY assert "should-not-appear" not in "".join(chunks) assert "should-not-appear" not in body["msg"] + + +@pytest.mark.parametrize("with_tools", [False, True]) +@pytest.mark.parametrize("inline_thinking", [False, True]) +async def test_model_content_keeps_thinking_out_of_answer(stream_env, monkeypatch, with_tools, inline_thinking): + chunks = [ + AIMessageChunk(content=[{"type": "reasoning", "summary": [{"type": "summary_text", "text": "Plan first"}]}]), + AIMessageChunk(content=[{"type": "text", "text": "Safe answer", "index": 1}]), + ] + if inline_thinking: + chunks = [AIMessageChunk(content=text) for text in ["Private secret", "Safe answer"]] + + class BlockLLM: + async def astream(self, _messages): + for chunk in chunks: + yield chunk + + initialized = chat_service._agent_initialize_chat.return_value + monkeypatch.setattr( + chat_service, + "_agent_initialize_chat", + AsyncMock(return_value=(*initialized[:3], BlockLLM(), *initialized[4:])), + ) + if with_tools: + tool = MagicMock(name="search") + monkeypatch.setattr(chat_service, "_prepare_tools", AsyncMock(return_value=([tool], []))) + monkeypatch.setattr(chat_service, "_get_agent_max_iterations", AsyncMock(return_value=8)) + + class BlockAgent: + async def astream_events(self, *_a, **_k): + for chunk in chunks: + yield {"event": "on_chat_model_stream", "name": "model", "data": {"chunk": chunk}} + + monkeypatch.setattr("langgraph.prebuilt.ToolNode", lambda *_a, **_k: MagicMock()) + monkeypatch.setattr("langgraph.prebuilt.create_react_agent", lambda *_a, **_k: BlockAgent()) + + scanners = [] + + class ScannerFactory: + def __new__(cls, tenant_id): + scanner = _HitOnLenScanner(tenant_id, threshold=10_000) + scanners.append(scanner) + return scanner + + _install_scanner(monkeypatch, ScannerFactory) + events = await _drain(await chat_service._agent_stream_chat_completion(MagicMock(), _data(), MagicMock())) + body = json.loads(_answer_rows(stream_env.inserted)[0].message) + assert body["msg"] == "Safe answer" + assert scanners[0].fed == ["Safe answer"] + if not inline_thinking: + assert any('"agent_thinking"' in event and "Plan first" in event for event in events) + assert "Private secret" not in "".join(events) + assert "Private secret" not in json.dumps(body) + assert "" not in "".join(events) + assert not any('"code": 500' in event for event in events) diff --git a/src/backend/test/workstation/test_workstation_apps_rebac.py b/src/backend/test/workstation/test_workstation_apps_rebac.py index 75630b8983..13a4b3361d 100644 --- a/src/backend/test/workstation/test_workstation_apps_rebac.py +++ b/src/backend/test/workstation/test_workstation_apps_rebac.py @@ -6,6 +6,7 @@ from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch +from bisheng.api.services.workflow import CHAT_ENTRY_EXCLUDED_FLOW_TYPES from bisheng.database.models.flow import FlowStatus, FlowType from bisheng.workstation.api.endpoints import apps as apps_mod @@ -25,19 +26,13 @@ async def test_recommended_apps_filter_with_visible_action(): {"id": "wf-1", "flow_type": FlowType.WORKFLOW.value}, {"id": "asst-1", "flow_type": FlowType.ASSISTANT.value}, ] - filter_by_action = AsyncMock( - return_value=[ - {"id": "asst-1", "flow_type": FlowType.ASSISTANT.value} - ] - ) + filter_by_action = AsyncMock(return_value=[{"id": "asst-1", "flow_type": FlowType.ASSISTANT.value}]) with ( patch.object( apps_mod.WorkStationService, "aget_config", - new=AsyncMock( - return_value=SimpleNamespace(recommendedApps=configured) - ), + new=AsyncMock(return_value=SimpleNamespace(recommendedApps=configured)), ), patch.object( apps_mod.FlowDao, @@ -65,9 +60,7 @@ async def test_recommended_apps_filter_with_visible_action(): new=AsyncMock(side_effect=lambda _user, data: data), ), ): - result = await apps_mod.get_recommended_apps( - login_user=login_user - ) + result = await apps_mod.get_recommended_apps(login_user=login_user) assert get_all_apps.call_args.kwargs == { "id_list": configured, @@ -79,10 +72,11 @@ async def test_recommended_apps_filter_with_visible_action(): login_user, candidates, "visible", + # F056: this list's cards open the conversation page, which hosted + # applications do not have. + exclude_flow_types=CHAT_ENTRY_EXCLUDED_FLOW_TYPES, ) - assert result.data == [ - {"id": "asst-1", "flow_type": FlowType.ASSISTANT.value} - ] + assert result.data == [{"id": "asst-1", "flow_type": FlowType.ASSISTANT.value}] async def test_used_apps_filter_with_visible_before_pagination(): @@ -151,6 +145,11 @@ async def test_used_apps_filter_with_visible_before_pagination(): limit=20, ) + # No ``exclude_flow_types`` here on purpose: hosted-app opens are recorded + # as USED_APP_RECENT_TYPE links, so a hosted application is a legitimate + # "recently used" entry (workstation/api/endpoints/apps.py) — the recorded + # candidate set bounds the list, and ``visible`` still drops what the user + # may no longer see. filter_by_action.assert_awaited_once_with( login_user, candidates, @@ -162,19 +161,15 @@ async def test_used_apps_filter_with_visible_before_pagination(): async def test_pin_used_app_requires_use_action(): login_user = _LoginUser() - app_info = SimpleNamespace( - id="wf-1", - flow_type=FlowType.WORKFLOW.value, - status=FlowStatus.ONLINE.value, - ) + app_info = {"id": "wf-1", "flow_type": FlowType.WORKFLOW.value, "status": FlowStatus.ONLINE.value} check_action = AsyncMock(return_value=True) add_user_link = MagicMock(return_value=(None, True)) with ( patch.object( apps_mod.FlowDao, - "aget_flow_by_id", - new=AsyncMock(return_value=app_info), + "aget_all_apps", + new=AsyncMock(return_value=([app_info], False)), ), patch.object( apps_mod, @@ -203,18 +198,14 @@ async def test_pin_used_app_requires_use_action(): async def test_pin_used_app_denies_without_use_action(): login_user = _LoginUser() - app_info = SimpleNamespace( - id="asst-1", - flow_type=FlowType.ASSISTANT.value, - status=FlowStatus.ONLINE.value, - ) + app_info = {"id": "asst-1", "flow_type": FlowType.ASSISTANT.value, "status": FlowStatus.ONLINE.value} add_user_link = MagicMock() with ( patch.object( apps_mod.FlowDao, - "aget_flow_by_id", - new=AsyncMock(return_value=app_info), + "aget_all_apps", + new=AsyncMock(return_value=([app_info], False)), ), patch.object( apps_mod, diff --git a/src/backend/test/workstation/test_workstation_model_migration.py b/src/backend/test/workstation/test_workstation_model_migration.py index 24acfc7c6a..e167251b6a 100644 --- a/src/backend/test/workstation/test_workstation_model_migration.py +++ b/src/backend/test/workstation/test_workstation_model_migration.py @@ -4,7 +4,7 @@ import pytest from bisheng.api.v1.schemas import WorkstationConfig -from bisheng.llm.domain.schemas import WSModel, WorkbenchModelConfig +from bisheng.llm.domain.schemas import WorkbenchModelConfig, WSModel from bisheng.workstation.domain.services.workstation_service import WorkStationService @@ -12,47 +12,48 @@ async def test_update_daily_chat_config_ignores_models_field(monkeypatch): saved = {} - async def fake_insert_or_update_config(key, value): - saved['config_key'] = key - saved['config_value'] = value - return SimpleNamespace(key=key, value=value) + async def fake_insert_or_update_config(key, *, payload): + saved["config_key"] = key + saved["config_value"] = payload + return SimpleNamespace(key=key, value=payload) result_config = WorkstationConfig( - models=[WSModel(id='101', name='gpt-4o', displayName='GPT-4o', visual=True)], + models=[WSModel(id="101", name="gpt-4o", displayName="GPT-4o", visual=True)], ) monkeypatch.setattr( - 'bisheng.workstation.domain.services.workstation_service.ConfigDao.insert_or_update_config', + WorkStationService, + "_aupsert_tenant_config", fake_insert_or_update_config, ) monkeypatch.setattr( WorkStationService, - 'get_daily_chat_config', + "get_daily_chat_config", AsyncMock(return_value=result_config), ) result = await WorkStationService.update_daily_chat_config(result_config) - assert '"models": null' in saved['config_value'] + assert '"models": null' in saved["config_value"] assert result.models == result_config.models assert result.models[0].visual is True def test_apply_workbench_models_returns_minimal_config_when_only_models_exist(): workbench_config = WorkbenchModelConfig( - models=[WSModel(id='101', name='qwen-max', displayName='Qwen Max', visual=True)], + models=[WSModel(id="101", name="qwen-max", displayName="Qwen Max", visual=True)], ) result = WorkStationService._apply_workbench_models(None, workbench_config) assert result is not None assert result.models is not None - assert result.models[0].id == '101' + assert result.models[0].id == "101" assert result.models[0].visual is True assert result.systemPrompt is None - assert result.welcomeMessage == '' + assert result.welcomeMessage == "" assert result.model_dump(exclude_unset=True) == { - 'models': [{'id': '101', 'name': 'qwen-max', 'displayName': 'Qwen Max', 'visual': True}], + "models": [{"id": "101", "name": "qwen-max", "displayName": "Qwen Max", "visual": True}], } diff --git a/src/backend/uv.lock b/src/backend/uv.lock index a0089f0ddc..e1be7d85ef 100644 --- a/src/backend/uv.lock +++ b/src/backend/uv.lock @@ -28,7 +28,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.2" +version = "3.14.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -37,95 +37,111 @@ dependencies = [ { name = "frozenlist" }, { name = "multidict" }, { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1c/ce/3b83ebba6b3207a7135e5fcaba49706f8a4b6008153b4e30540c982fae26/aiohttp-3.13.2.tar.gz", hash = "sha256:40176a52c186aefef6eb3cad2cdd30cd06e3afbe88fe8ab2af9c0b90f228daca", size = 7837994, upload-time = "2025-10-28T20:59:39.937Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/35/74/b321e7d7ca762638cdf8cdeceb39755d9c745aff7a64c8789be96ddf6e96/aiohttp-3.13.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4647d02df098f6434bafd7f32ad14942f05a9caa06c7016fdcc816f343997dd0", size = 743409, upload-time = "2025-10-28T20:56:00.354Z" }, - { url = "https://files.pythonhosted.org/packages/99/3d/91524b905ec473beaf35158d17f82ef5a38033e5809fe8742e3657cdbb97/aiohttp-3.13.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e3403f24bcb9c3b29113611c3c16a2a447c3953ecf86b79775e7be06f7ae7ccb", size = 497006, upload-time = "2025-10-28T20:56:01.85Z" }, - { url = "https://files.pythonhosted.org/packages/eb/d3/7f68bc02a67716fe80f063e19adbd80a642e30682ce74071269e17d2dba1/aiohttp-3.13.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:43dff14e35aba17e3d6d5ba628858fb8cb51e30f44724a2d2f0c75be492c55e9", size = 493195, upload-time = "2025-10-28T20:56:03.314Z" }, - { url = "https://files.pythonhosted.org/packages/98/31/913f774a4708775433b7375c4f867d58ba58ead833af96c8af3621a0d243/aiohttp-3.13.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e2a9ea08e8c58bb17655630198833109227dea914cd20be660f52215f6de5613", size = 1747759, upload-time = "2025-10-28T20:56:04.904Z" }, - { url = "https://files.pythonhosted.org/packages/e8/63/04efe156f4326f31c7c4a97144f82132c3bb21859b7bb84748d452ccc17c/aiohttp-3.13.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53b07472f235eb80e826ad038c9d106c2f653584753f3ddab907c83f49eedead", size = 1704456, upload-time = "2025-10-28T20:56:06.986Z" }, - { url = "https://files.pythonhosted.org/packages/8e/02/4e16154d8e0a9cf4ae76f692941fd52543bbb148f02f098ca73cab9b1c1b/aiohttp-3.13.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e736c93e9c274fce6419af4aac199984d866e55f8a4cec9114671d0ea9688780", size = 1807572, upload-time = "2025-10-28T20:56:08.558Z" }, - { url = "https://files.pythonhosted.org/packages/34/58/b0583defb38689e7f06798f0285b1ffb3a6fb371f38363ce5fd772112724/aiohttp-3.13.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ff5e771f5dcbc81c64898c597a434f7682f2259e0cd666932a913d53d1341d1a", size = 1895954, upload-time = "2025-10-28T20:56:10.545Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f3/083907ee3437425b4e376aa58b2c915eb1a33703ec0dc30040f7ae3368c6/aiohttp-3.13.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3b6fb0c207cc661fa0bf8c66d8d9b657331ccc814f4719468af61034b478592", size = 1747092, upload-time = "2025-10-28T20:56:12.118Z" }, - { url = "https://files.pythonhosted.org/packages/ac/61/98a47319b4e425cc134e05e5f3fc512bf9a04bf65aafd9fdcda5d57ec693/aiohttp-3.13.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:97a0895a8e840ab3520e2288db7cace3a1981300d48babeb50e7425609e2e0ab", size = 1606815, upload-time = "2025-10-28T20:56:14.191Z" }, - { url = "https://files.pythonhosted.org/packages/97/4b/e78b854d82f66bb974189135d31fce265dee0f5344f64dd0d345158a5973/aiohttp-3.13.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9e8f8afb552297aca127c90cb840e9a1d4bfd6a10d7d8f2d9176e1acc69bad30", size = 1723789, upload-time = "2025-10-28T20:56:16.101Z" }, - { url = "https://files.pythonhosted.org/packages/ed/fc/9d2ccc794fc9b9acd1379d625c3a8c64a45508b5091c546dea273a41929e/aiohttp-3.13.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ed2f9c7216e53c3df02264f25d824b079cc5914f9e2deba94155190ef648ee40", size = 1718104, upload-time = "2025-10-28T20:56:17.655Z" }, - { url = "https://files.pythonhosted.org/packages/66/65/34564b8765ea5c7d79d23c9113135d1dd3609173da13084830f1507d56cf/aiohttp-3.13.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:99c5280a329d5fa18ef30fd10c793a190d996567667908bef8a7f81f8202b948", size = 1785584, upload-time = "2025-10-28T20:56:19.238Z" }, - { url = "https://files.pythonhosted.org/packages/30/be/f6a7a426e02fc82781afd62016417b3948e2207426d90a0e478790d1c8a4/aiohttp-3.13.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ca6ffef405fc9c09a746cb5d019c1672cd7f402542e379afc66b370833170cf", size = 1595126, upload-time = "2025-10-28T20:56:20.836Z" }, - { url = "https://files.pythonhosted.org/packages/e5/c7/8e22d5d28f94f67d2af496f14a83b3c155d915d1fe53d94b66d425ec5b42/aiohttp-3.13.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:47f438b1a28e926c37632bff3c44df7d27c9b57aaf4e34b1def3c07111fdb782", size = 1800665, upload-time = "2025-10-28T20:56:22.922Z" }, - { url = "https://files.pythonhosted.org/packages/d1/11/91133c8b68b1da9fc16555706aa7276fdf781ae2bb0876c838dd86b8116e/aiohttp-3.13.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9acda8604a57bb60544e4646a4615c1866ee6c04a8edef9b8ee6fd1d8fa2ddc8", size = 1739532, upload-time = "2025-10-28T20:56:25.924Z" }, - { url = "https://files.pythonhosted.org/packages/17/6b/3747644d26a998774b21a616016620293ddefa4d63af6286f389aedac844/aiohttp-3.13.2-cp311-cp311-win32.whl", hash = "sha256:868e195e39b24aaa930b063c08bb0c17924899c16c672a28a65afded9c46c6ec", size = 431876, upload-time = "2025-10-28T20:56:27.524Z" }, - { url = "https://files.pythonhosted.org/packages/c3/63/688462108c1a00eb9f05765331c107f95ae86f6b197b865d29e930b7e462/aiohttp-3.13.2-cp311-cp311-win_amd64.whl", hash = "sha256:7fd19df530c292542636c2a9a85854fab93474396a52f1695e799186bbd7f24c", size = 456205, upload-time = "2025-10-28T20:56:29.062Z" }, - { url = "https://files.pythonhosted.org/packages/29/9b/01f00e9856d0a73260e86dd8ed0c2234a466c5c1712ce1c281548df39777/aiohttp-3.13.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b1e56bab2e12b2b9ed300218c351ee2a3d8c8fdab5b1ec6193e11a817767e47b", size = 737623, upload-time = "2025-10-28T20:56:30.797Z" }, - { url = "https://files.pythonhosted.org/packages/5a/1b/4be39c445e2b2bd0aab4ba736deb649fabf14f6757f405f0c9685019b9e9/aiohttp-3.13.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:364e25edaabd3d37b1db1f0cbcee8c73c9a3727bfa262b83e5e4cf3489a2a9dc", size = 492664, upload-time = "2025-10-28T20:56:32.708Z" }, - { url = "https://files.pythonhosted.org/packages/28/66/d35dcfea8050e131cdd731dff36434390479b4045a8d0b9d7111b0a968f1/aiohttp-3.13.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c5c94825f744694c4b8db20b71dba9a257cd2ba8e010a803042123f3a25d50d7", size = 491808, upload-time = "2025-10-28T20:56:34.57Z" }, - { url = "https://files.pythonhosted.org/packages/00/29/8e4609b93e10a853b65f8291e64985de66d4f5848c5637cddc70e98f01f8/aiohttp-3.13.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba2715d842ffa787be87cbfce150d5e88c87a98e0b62e0f5aa489169a393dbbb", size = 1738863, upload-time = "2025-10-28T20:56:36.377Z" }, - { url = "https://files.pythonhosted.org/packages/9d/fa/4ebdf4adcc0def75ced1a0d2d227577cd7b1b85beb7edad85fcc87693c75/aiohttp-3.13.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:585542825c4bc662221fb257889e011a5aa00f1ae4d75d1d246a5225289183e3", size = 1700586, upload-time = "2025-10-28T20:56:38.034Z" }, - { url = "https://files.pythonhosted.org/packages/da/04/73f5f02ff348a3558763ff6abe99c223381b0bace05cd4530a0258e52597/aiohttp-3.13.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39d02cb6025fe1aabca329c5632f48c9532a3dabccd859e7e2f110668972331f", size = 1768625, upload-time = "2025-10-28T20:56:39.75Z" }, - { url = "https://files.pythonhosted.org/packages/f8/49/a825b79ffec124317265ca7d2344a86bcffeb960743487cb11988ffb3494/aiohttp-3.13.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e67446b19e014d37342f7195f592a2a948141d15a312fe0e700c2fd2f03124f6", size = 1867281, upload-time = "2025-10-28T20:56:41.471Z" }, - { url = "https://files.pythonhosted.org/packages/b9/48/adf56e05f81eac31edcfae45c90928f4ad50ef2e3ea72cb8376162a368f8/aiohttp-3.13.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4356474ad6333e41ccefd39eae869ba15a6c5299c9c01dfdcfdd5c107be4363e", size = 1752431, upload-time = "2025-10-28T20:56:43.162Z" }, - { url = "https://files.pythonhosted.org/packages/30/ab/593855356eead019a74e862f21523db09c27f12fd24af72dbc3555b9bfd9/aiohttp-3.13.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeacf451c99b4525f700f078becff32c32ec327b10dcf31306a8a52d78166de7", size = 1562846, upload-time = "2025-10-28T20:56:44.85Z" }, - { url = "https://files.pythonhosted.org/packages/39/0f/9f3d32271aa8dc35036e9668e31870a9d3b9542dd6b3e2c8a30931cb27ae/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8a9b889aeabd7a4e9af0b7f4ab5ad94d42e7ff679aaec6d0db21e3b639ad58d", size = 1699606, upload-time = "2025-10-28T20:56:46.519Z" }, - { url = "https://files.pythonhosted.org/packages/2c/3c/52d2658c5699b6ef7692a3f7128b2d2d4d9775f2a68093f74bca06cf01e1/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:fa89cb11bc71a63b69568d5b8a25c3ca25b6d54c15f907ca1c130d72f320b76b", size = 1720663, upload-time = "2025-10-28T20:56:48.528Z" }, - { url = "https://files.pythonhosted.org/packages/9b/d4/8f8f3ff1fb7fb9e3f04fcad4e89d8a1cd8fc7d05de67e3de5b15b33008ff/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8aa7c807df234f693fed0ecd507192fc97692e61fee5702cdc11155d2e5cadc8", size = 1737939, upload-time = "2025-10-28T20:56:50.77Z" }, - { url = "https://files.pythonhosted.org/packages/03/d3/ddd348f8a27a634daae39a1b8e291ff19c77867af438af844bf8b7e3231b/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9eb3e33fdbe43f88c3c75fa608c25e7c47bbd80f48d012763cb67c47f39a7e16", size = 1555132, upload-time = "2025-10-28T20:56:52.568Z" }, - { url = "https://files.pythonhosted.org/packages/39/b8/46790692dc46218406f94374903ba47552f2f9f90dad554eed61bfb7b64c/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9434bc0d80076138ea986833156c5a48c9c7a8abb0c96039ddbb4afc93184169", size = 1764802, upload-time = "2025-10-28T20:56:54.292Z" }, - { url = "https://files.pythonhosted.org/packages/ba/e4/19ce547b58ab2a385e5f0b8aa3db38674785085abcf79b6e0edd1632b12f/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ff15c147b2ad66da1f2cbb0622313f2242d8e6e8f9b79b5206c84523a4473248", size = 1719512, upload-time = "2025-10-28T20:56:56.428Z" }, - { url = "https://files.pythonhosted.org/packages/70/30/6355a737fed29dcb6dfdd48682d5790cb5eab050f7b4e01f49b121d3acad/aiohttp-3.13.2-cp312-cp312-win32.whl", hash = "sha256:27e569eb9d9e95dbd55c0fc3ec3a9335defbf1d8bc1d20171a49f3c4c607b93e", size = 426690, upload-time = "2025-10-28T20:56:58.736Z" }, - { url = "https://files.pythonhosted.org/packages/0a/0d/b10ac09069973d112de6ef980c1f6bb31cb7dcd0bc363acbdad58f927873/aiohttp-3.13.2-cp312-cp312-win_amd64.whl", hash = "sha256:8709a0f05d59a71f33fd05c17fc11fcb8c30140506e13c2f5e8ee1b8964e1b45", size = 453465, upload-time = "2025-10-28T20:57:00.795Z" }, - { url = "https://files.pythonhosted.org/packages/bf/78/7e90ca79e5aa39f9694dcfd74f4720782d3c6828113bb1f3197f7e7c4a56/aiohttp-3.13.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7519bdc7dfc1940d201651b52bf5e03f5503bda45ad6eacf64dda98be5b2b6be", size = 732139, upload-time = "2025-10-28T20:57:02.455Z" }, - { url = "https://files.pythonhosted.org/packages/db/ed/1f59215ab6853fbaa5c8495fa6cbc39edfc93553426152b75d82a5f32b76/aiohttp-3.13.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:088912a78b4d4f547a1f19c099d5a506df17eacec3c6f4375e2831ec1d995742", size = 490082, upload-time = "2025-10-28T20:57:04.784Z" }, - { url = "https://files.pythonhosted.org/packages/68/7b/fe0fe0f5e05e13629d893c760465173a15ad0039c0a5b0d0040995c8075e/aiohttp-3.13.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5276807b9de9092af38ed23ce120539ab0ac955547b38563a9ba4f5b07b95293", size = 489035, upload-time = "2025-10-28T20:57:06.894Z" }, - { url = "https://files.pythonhosted.org/packages/d2/04/db5279e38471b7ac801d7d36a57d1230feeee130bbe2a74f72731b23c2b1/aiohttp-3.13.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1237c1375eaef0db4dcd7c2559f42e8af7b87ea7d295b118c60c36a6e61cb811", size = 1720387, upload-time = "2025-10-28T20:57:08.685Z" }, - { url = "https://files.pythonhosted.org/packages/31/07/8ea4326bd7dae2bd59828f69d7fdc6e04523caa55e4a70f4a8725a7e4ed2/aiohttp-3.13.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:96581619c57419c3d7d78703d5b78c1e5e5fc0172d60f555bdebaced82ded19a", size = 1688314, upload-time = "2025-10-28T20:57:10.693Z" }, - { url = "https://files.pythonhosted.org/packages/48/ab/3d98007b5b87ffd519d065225438cc3b668b2f245572a8cb53da5dd2b1bc/aiohttp-3.13.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2713a95b47374169409d18103366de1050fe0ea73db358fc7a7acb2880422d4", size = 1756317, upload-time = "2025-10-28T20:57:12.563Z" }, - { url = "https://files.pythonhosted.org/packages/97/3d/801ca172b3d857fafb7b50c7c03f91b72b867a13abca982ed6b3081774ef/aiohttp-3.13.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:228a1cd556b3caca590e9511a89444925da87d35219a49ab5da0c36d2d943a6a", size = 1858539, upload-time = "2025-10-28T20:57:14.623Z" }, - { url = "https://files.pythonhosted.org/packages/f7/0d/4764669bdf47bd472899b3d3db91fffbe925c8e3038ec591a2fd2ad6a14d/aiohttp-3.13.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac6cde5fba8d7d8c6ac963dbb0256a9854e9fafff52fbcc58fdf819357892c3e", size = 1739597, upload-time = "2025-10-28T20:57:16.399Z" }, - { url = "https://files.pythonhosted.org/packages/c4/52/7bd3c6693da58ba16e657eb904a5b6decfc48ecd06e9ac098591653b1566/aiohttp-3.13.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2bef8237544f4e42878c61cef4e2839fee6346dc60f5739f876a9c50be7fcdb", size = 1555006, upload-time = "2025-10-28T20:57:18.288Z" }, - { url = "https://files.pythonhosted.org/packages/48/30/9586667acec5993b6f41d2ebcf96e97a1255a85f62f3c653110a5de4d346/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:16f15a4eac3bc2d76c45f7ebdd48a65d41b242eb6c31c2245463b40b34584ded", size = 1683220, upload-time = "2025-10-28T20:57:20.241Z" }, - { url = "https://files.pythonhosted.org/packages/71/01/3afe4c96854cfd7b30d78333852e8e851dceaec1c40fd00fec90c6402dd2/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:bb7fb776645af5cc58ab804c58d7eba545a97e047254a52ce89c157b5af6cd0b", size = 1712570, upload-time = "2025-10-28T20:57:22.253Z" }, - { url = "https://files.pythonhosted.org/packages/11/2c/22799d8e720f4697a9e66fd9c02479e40a49de3de2f0bbe7f9f78a987808/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1b4951125ec10c70802f2cb09736c895861cd39fd9dcb35107b4dc8ae6220b8", size = 1733407, upload-time = "2025-10-28T20:57:24.37Z" }, - { url = "https://files.pythonhosted.org/packages/34/cb/90f15dd029f07cebbd91f8238a8b363978b530cd128488085b5703683594/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:550bf765101ae721ee1d37d8095f47b1f220650f85fe1af37a90ce75bab89d04", size = 1550093, upload-time = "2025-10-28T20:57:26.257Z" }, - { url = "https://files.pythonhosted.org/packages/69/46/12dce9be9d3303ecbf4d30ad45a7683dc63d90733c2d9fe512be6716cd40/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fe91b87fc295973096251e2d25a811388e7d8adf3bd2b97ef6ae78bc4ac6c476", size = 1758084, upload-time = "2025-10-28T20:57:28.349Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c8/0932b558da0c302ffd639fc6362a313b98fdf235dc417bc2493da8394df7/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0c8e31cfcc4592cb200160344b2fb6ae0f9e4effe06c644b5a125d4ae5ebe23", size = 1716987, upload-time = "2025-10-28T20:57:30.233Z" }, - { url = "https://files.pythonhosted.org/packages/5d/8b/f5bd1a75003daed099baec373aed678f2e9b34f2ad40d85baa1368556396/aiohttp-3.13.2-cp313-cp313-win32.whl", hash = "sha256:0740f31a60848d6edb296a0df827473eede90c689b8f9f2a4cdde74889eb2254", size = 425859, upload-time = "2025-10-28T20:57:32.105Z" }, - { url = "https://files.pythonhosted.org/packages/5d/28/a8a9fc6957b2cee8902414e41816b5ab5536ecf43c3b1843c10e82c559b2/aiohttp-3.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:a88d13e7ca367394908f8a276b89d04a3652044612b9a408a0bb22a5ed976a1a", size = 452192, upload-time = "2025-10-28T20:57:34.166Z" }, - { url = "https://files.pythonhosted.org/packages/9b/36/e2abae1bd815f01c957cbf7be817b3043304e1c87bad526292a0410fdcf9/aiohttp-3.13.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2475391c29230e063ef53a66669b7b691c9bfc3f1426a0f7bcdf1216bdbac38b", size = 735234, upload-time = "2025-10-28T20:57:36.415Z" }, - { url = "https://files.pythonhosted.org/packages/ca/e3/1ee62dde9b335e4ed41db6bba02613295a0d5b41f74a783c142745a12763/aiohttp-3.13.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:f33c8748abef4d8717bb20e8fb1b3e07c6adacb7fd6beaae971a764cf5f30d61", size = 490733, upload-time = "2025-10-28T20:57:38.205Z" }, - { url = "https://files.pythonhosted.org/packages/1a/aa/7a451b1d6a04e8d15a362af3e9b897de71d86feac3babf8894545d08d537/aiohttp-3.13.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ae32f24bbfb7dbb485a24b30b1149e2f200be94777232aeadba3eecece4d0aa4", size = 491303, upload-time = "2025-10-28T20:57:40.122Z" }, - { url = "https://files.pythonhosted.org/packages/57/1e/209958dbb9b01174870f6a7538cd1f3f28274fdbc88a750c238e2c456295/aiohttp-3.13.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d7f02042c1f009ffb70067326ef183a047425bb2ff3bc434ead4dd4a4a66a2b", size = 1717965, upload-time = "2025-10-28T20:57:42.28Z" }, - { url = "https://files.pythonhosted.org/packages/08/aa/6a01848d6432f241416bc4866cae8dc03f05a5a884d2311280f6a09c73d6/aiohttp-3.13.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93655083005d71cd6c072cdab54c886e6570ad2c4592139c3fb967bfc19e4694", size = 1667221, upload-time = "2025-10-28T20:57:44.869Z" }, - { url = "https://files.pythonhosted.org/packages/87/4f/36c1992432d31bbc789fa0b93c768d2e9047ec8c7177e5cd84ea85155f36/aiohttp-3.13.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0db1e24b852f5f664cd728db140cf11ea0e82450471232a394b3d1a540b0f906", size = 1757178, upload-time = "2025-10-28T20:57:47.216Z" }, - { url = "https://files.pythonhosted.org/packages/ac/b4/8e940dfb03b7e0f68a82b88fd182b9be0a65cb3f35612fe38c038c3112cf/aiohttp-3.13.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b009194665bcd128e23eaddef362e745601afa4641930848af4c8559e88f18f9", size = 1838001, upload-time = "2025-10-28T20:57:49.337Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ef/39f3448795499c440ab66084a9db7d20ca7662e94305f175a80f5b7e0072/aiohttp-3.13.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c038a8fdc8103cd51dbd986ecdce141473ffd9775a7a8057a6ed9c3653478011", size = 1716325, upload-time = "2025-10-28T20:57:51.327Z" }, - { url = "https://files.pythonhosted.org/packages/d7/51/b311500ffc860b181c05d91c59a1313bdd05c82960fdd4035a15740d431e/aiohttp-3.13.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66bac29b95a00db411cd758fea0e4b9bdba6d549dfe333f9a945430f5f2cc5a6", size = 1547978, upload-time = "2025-10-28T20:57:53.554Z" }, - { url = "https://files.pythonhosted.org/packages/31/64/b9d733296ef79815226dab8c586ff9e3df41c6aff2e16c06697b2d2e6775/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4ebf9cfc9ba24a74cf0718f04aac2a3bbe745902cc7c5ebc55c0f3b5777ef213", size = 1682042, upload-time = "2025-10-28T20:57:55.617Z" }, - { url = "https://files.pythonhosted.org/packages/3f/30/43d3e0f9d6473a6db7d472104c4eff4417b1e9df01774cb930338806d36b/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a4b88ebe35ce54205c7074f7302bd08a4cb83256a3e0870c72d6f68a3aaf8e49", size = 1680085, upload-time = "2025-10-28T20:57:57.59Z" }, - { url = "https://files.pythonhosted.org/packages/16/51/c709f352c911b1864cfd1087577760ced64b3e5bee2aa88b8c0c8e2e4972/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:98c4fb90bb82b70a4ed79ca35f656f4281885be076f3f970ce315402b53099ae", size = 1728238, upload-time = "2025-10-28T20:57:59.525Z" }, - { url = "https://files.pythonhosted.org/packages/19/e2/19bd4c547092b773caeb48ff5ae4b1ae86756a0ee76c16727fcfd281404b/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:ec7534e63ae0f3759df3a1ed4fa6bc8f75082a924b590619c0dd2f76d7043caa", size = 1544395, upload-time = "2025-10-28T20:58:01.914Z" }, - { url = "https://files.pythonhosted.org/packages/cf/87/860f2803b27dfc5ed7be532832a3498e4919da61299b4a1f8eb89b8ff44d/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5b927cf9b935a13e33644cbed6c8c4b2d0f25b713d838743f8fe7191b33829c4", size = 1742965, upload-time = "2025-10-28T20:58:03.972Z" }, - { url = "https://files.pythonhosted.org/packages/67/7f/db2fc7618925e8c7a601094d5cbe539f732df4fb570740be88ed9e40e99a/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:88d6c017966a78c5265d996c19cdb79235be5e6412268d7e2ce7dee339471b7a", size = 1697585, upload-time = "2025-10-28T20:58:06.189Z" }, - { url = "https://files.pythonhosted.org/packages/0c/07/9127916cb09bb38284db5036036042b7b2c514c8ebaeee79da550c43a6d6/aiohttp-3.13.2-cp314-cp314-win32.whl", hash = "sha256:f7c183e786e299b5d6c49fb43a769f8eb8e04a2726a2bd5887b98b5cc2d67940", size = 431621, upload-time = "2025-10-28T20:58:08.636Z" }, - { url = "https://files.pythonhosted.org/packages/fb/41/554a8a380df6d3a2bba8a7726429a23f4ac62aaf38de43bb6d6cde7b4d4d/aiohttp-3.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:fe242cd381e0fb65758faf5ad96c2e460df6ee5b2de1072fe97e4127927e00b4", size = 457627, upload-time = "2025-10-28T20:58:11Z" }, - { url = "https://files.pythonhosted.org/packages/c7/8e/3824ef98c039d3951cb65b9205a96dd2b20f22241ee17d89c5701557c826/aiohttp-3.13.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f10d9c0b0188fe85398c61147bbd2a657d616c876863bfeff43376e0e3134673", size = 767360, upload-time = "2025-10-28T20:58:13.358Z" }, - { url = "https://files.pythonhosted.org/packages/a4/0f/6a03e3fc7595421274fa34122c973bde2d89344f8a881b728fa8c774e4f1/aiohttp-3.13.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:e7c952aefdf2460f4ae55c5e9c3e80aa72f706a6317e06020f80e96253b1accd", size = 504616, upload-time = "2025-10-28T20:58:15.339Z" }, - { url = "https://files.pythonhosted.org/packages/c6/aa/ed341b670f1bc8a6f2c6a718353d13b9546e2cef3544f573c6a1ff0da711/aiohttp-3.13.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c20423ce14771d98353d2e25e83591fa75dfa90a3c1848f3d7c68243b4fbded3", size = 509131, upload-time = "2025-10-28T20:58:17.693Z" }, - { url = "https://files.pythonhosted.org/packages/7f/f0/c68dac234189dae5c4bbccc0f96ce0cc16b76632cfc3a08fff180045cfa4/aiohttp-3.13.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e96eb1a34396e9430c19d8338d2ec33015e4a87ef2b4449db94c22412e25ccdf", size = 1864168, upload-time = "2025-10-28T20:58:20.113Z" }, - { url = "https://files.pythonhosted.org/packages/8f/65/75a9a76db8364b5d0e52a0c20eabc5d52297385d9af9c35335b924fafdee/aiohttp-3.13.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:23fb0783bc1a33640036465019d3bba069942616a6a2353c6907d7fe1ccdaf4e", size = 1719200, upload-time = "2025-10-28T20:58:22.583Z" }, - { url = "https://files.pythonhosted.org/packages/f5/55/8df2ed78d7f41d232f6bd3ff866b6f617026551aa1d07e2f03458f964575/aiohttp-3.13.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1a9bea6244a1d05a4e57c295d69e159a5c50d8ef16aa390948ee873478d9a5", size = 1843497, upload-time = "2025-10-28T20:58:24.672Z" }, - { url = "https://files.pythonhosted.org/packages/e9/e0/94d7215e405c5a02ccb6a35c7a3a6cfff242f457a00196496935f700cde5/aiohttp-3.13.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0a3d54e822688b56e9f6b5816fb3de3a3a64660efac64e4c2dc435230ad23bad", size = 1935703, upload-time = "2025-10-28T20:58:26.758Z" }, - { url = "https://files.pythonhosted.org/packages/0b/78/1eeb63c3f9b2d1015a4c02788fb543141aad0a03ae3f7a7b669b2483f8d4/aiohttp-3.13.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7a653d872afe9f33497215745da7a943d1dc15b728a9c8da1c3ac423af35178e", size = 1792738, upload-time = "2025-10-28T20:58:29.787Z" }, - { url = "https://files.pythonhosted.org/packages/41/75/aaf1eea4c188e51538c04cc568040e3082db263a57086ea74a7d38c39e42/aiohttp-3.13.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:56d36e80d2003fa3fc0207fac644216d8532e9504a785ef9a8fd013f84a42c61", size = 1624061, upload-time = "2025-10-28T20:58:32.529Z" }, - { url = "https://files.pythonhosted.org/packages/9b/c2/3b6034de81fbcc43de8aeb209073a2286dfb50b86e927b4efd81cf848197/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:78cd586d8331fb8e241c2dd6b2f4061778cc69e150514b39a9e28dd050475661", size = 1789201, upload-time = "2025-10-28T20:58:34.618Z" }, - { url = "https://files.pythonhosted.org/packages/c9/38/c15dcf6d4d890217dae79d7213988f4e5fe6183d43893a9cf2fe9e84ca8d/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:20b10bbfbff766294fe99987f7bb3b74fdd2f1a2905f2562132641ad434dcf98", size = 1776868, upload-time = "2025-10-28T20:58:38.835Z" }, - { url = "https://files.pythonhosted.org/packages/04/75/f74fd178ac81adf4f283a74847807ade5150e48feda6aef024403716c30c/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9ec49dff7e2b3c85cdeaa412e9d438f0ecd71676fde61ec57027dd392f00c693", size = 1790660, upload-time = "2025-10-28T20:58:41.507Z" }, - { url = "https://files.pythonhosted.org/packages/e7/80/7368bd0d06b16b3aba358c16b919e9c46cf11587dc572091031b0e9e3ef0/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:94f05348c4406450f9d73d38efb41d669ad6cd90c7ee194810d0eefbfa875a7a", size = 1617548, upload-time = "2025-10-28T20:58:43.674Z" }, - { url = "https://files.pythonhosted.org/packages/7d/4b/a6212790c50483cb3212e507378fbe26b5086d73941e1ec4b56a30439688/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:fa4dcb605c6f82a80c7f95713c2b11c3b8e9893b3ebd2bc9bde93165ed6107be", size = 1817240, upload-time = "2025-10-28T20:58:45.787Z" }, - { url = "https://files.pythonhosted.org/packages/ff/f7/ba5f0ba4ea8d8f3c32850912944532b933acbf0f3a75546b89269b9b7dde/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf00e5db968c3f67eccd2778574cf64d8b27d95b237770aa32400bd7a1ca4f6c", size = 1762334, upload-time = "2025-10-28T20:58:47.936Z" }, - { url = "https://files.pythonhosted.org/packages/7e/83/1a5a1856574588b1cad63609ea9ad75b32a8353ac995d830bf5da9357364/aiohttp-3.13.2-cp314-cp314t-win32.whl", hash = "sha256:d23b5fe492b0805a50d3371e8a728a9134d8de5447dce4c885f5587294750734", size = 464685, upload-time = "2025-10-28T20:58:50.642Z" }, - { url = "https://files.pythonhosted.org/packages/9f/4d/d22668674122c08f4d56972297c51a624e64b3ed1efaa40187607a7cb66e/aiohttp-3.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:ff0a7b0a82a7ab905cbda74006318d1b12e37c797eb1b0d4eb3e316cf47f658f", size = 498093, upload-time = "2025-10-28T20:58:52.782Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/5c/b3e4ff8ad43a8afef9602c5e90285936da1beaea8b029016b793891f03c3/aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3", size = 764250, upload-time = "2026-07-23T01:52:48.525Z" }, + { url = "https://files.pythonhosted.org/packages/0e/da/f1b384465e51449d844056b75070461da03a9a23e6c1747003695bf4172a/aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a", size = 516281, upload-time = "2026-07-23T01:52:51.047Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/01264f820ee2e3712a827892b1cd6ff80f3300c1fcbffbb45714a915d47a/aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8", size = 514742, upload-time = "2026-07-23T01:52:53.779Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8d/a71c6f2db52ac1ed142b133f7feddaa6b70539c3f4de24d7e226c95b794c/aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239", size = 1780613, upload-time = "2026-07-23T01:52:56.948Z" }, + { url = "https://files.pythonhosted.org/packages/a5/11/3dd9b3fb3a170f6ec9011b5291d876a6fab4086714c9e158600edf01b4fd/aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f", size = 1737688, upload-time = "2026-07-23T01:52:59.294Z" }, + { url = "https://files.pythonhosted.org/packages/6d/3e/834c26918be7d88068822b40e0db30fca50b5f4fe79104aa16a93f1d74e6/aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06", size = 1845742, upload-time = "2026-07-23T01:53:01.641Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c9/49ab8572df7d66bc13d11e31f781292badb04180dd87ba98733066c6aed7/aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929", size = 1928412, upload-time = "2026-07-23T01:53:04.018Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/2b8f0c0ce09c87a1daf80fd483431b56b1435d3f62789bc86f572e1245de/aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db", size = 1786220, upload-time = "2026-07-23T01:53:06.481Z" }, + { url = "https://files.pythonhosted.org/packages/85/00/9c45f81de11710460edfa1dc81317b6e882703b160926c879a9d20da9fcc/aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce", size = 1637231, upload-time = "2026-07-23T01:53:10.258Z" }, + { url = "https://files.pythonhosted.org/packages/19/ce/967d628e910756f3539c6107cb7844a1b69440dcb3029a5ee7871b09ab63/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c", size = 1753161, upload-time = "2026-07-23T01:53:13.817Z" }, + { url = "https://files.pythonhosted.org/packages/11/b2/0c3d4114f0aee4f580f5b3b4eb71b24d7a23b834ea506a4dfebe76513f35/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15", size = 1756356, upload-time = "2026-07-23T01:53:16.211Z" }, + { url = "https://files.pythonhosted.org/packages/63/5d/99e7d91c82f1399d1ae2a854e080bd1493fbc31e5e959dbc4ec33dac3bec/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c", size = 1819846, upload-time = "2026-07-23T01:53:18.289Z" }, + { url = "https://files.pythonhosted.org/packages/ad/05/d5e1cb6480eeffd3f901d40a2c5e2d1e7effdc797837da3b490272699f13/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae", size = 1628531, upload-time = "2026-07-23T01:53:23.86Z" }, + { url = "https://files.pythonhosted.org/packages/c9/90/b934682bcaefae18a9e04f3dff5b68522ba810906358ae5029b68110ea3b/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910", size = 1832712, upload-time = "2026-07-23T01:53:27.551Z" }, + { url = "https://files.pythonhosted.org/packages/21/df/6061679faaf81fac746e7307c7adb71e858071a5d34c27583afefc64f543/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7", size = 1775014, upload-time = "2026-07-23T01:53:30.223Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1d/f854878bbc69b88faefe924b619a34a6f59ec05fd387c77690667eaa75eb/aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa", size = 456006, upload-time = "2026-07-23T01:53:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/73/0c/2af9d1674baccd1dbd47282a93d660a22e57ef6167c856deb24b4214fbab/aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d", size = 481069, upload-time = "2026-07-23T01:53:39.673Z" }, + { url = "https://files.pythonhosted.org/packages/8e/76/88401ff3fc95e85c5fc38d588f36f55e61ecb64343b2bc8d69326f453cc0/aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39", size = 453021, upload-time = "2026-07-23T01:53:43.749Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" }, + { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" }, + { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" }, + { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" }, + { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" }, + { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" }, + { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" }, + { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" }, + { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" }, + { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" }, + { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, ] [[package]] @@ -242,16 +258,15 @@ wheels = [ [[package]] name = "anyio" -version = "4.11.0" +version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, - { name = "sniffio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] [[package]] @@ -371,14 +386,14 @@ wheels = [ [[package]] name = "authlib" -version = "1.6.5" +version = "1.6.12" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cd/3f/1d3bbd0bf23bdd99276d4def22f29c27a914067b4cf66f753ff9b8bbd0f3/authlib-1.6.5.tar.gz", hash = "sha256:6aaf9c79b7cc96c900f0b284061691c5d4e61221640a948fe690b556a6d6d10b", size = 164553, upload-time = "2025-10-02T13:36:09.489Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/30/6691fdc63b35f54a5a65e04fa1e59d827f4d4e8f4a39678ba7d3088ce0c8/authlib-1.6.12.tar.gz", hash = "sha256:0656d8482f28fc8221929d5f35b2bde5d13e10555ebc06b4561b0d622e83b1bd", size = 165368, upload-time = "2026-05-04T08:11:31.826Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/aa/5082412d1ee302e9e7d80b6949bc4d2a8fa1149aaab610c5fc24709605d6/authlib-1.6.5-py2.py3-none-any.whl", hash = "sha256:3e0e0507807f842b02175507bdee8957a1d5707fd4afb17c32fb43fee90b6e3a", size = 243608, upload-time = "2025-10-02T13:36:07.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/51/9b0b5cd4cf683a02db937a6f9bbebcdc9c56558a7bb3763ce7d3512103c3/authlib-1.6.12-py2.py3-none-any.whl", hash = "sha256:e9229ad7fde610b139dd12f5edbe97eab9ee78bfb85691247e767727850b99ab", size = 244473, upload-time = "2026-05-04T08:11:30.354Z" }, ] [[package]] @@ -397,7 +412,6 @@ dependencies = [ { name = "captcha" }, { name = "celery" }, { name = "chardet" }, - { name = "chromadb" }, { name = "cryptography" }, { name = "dashscope" }, { name = "deepagents" }, @@ -468,6 +482,7 @@ dependencies = [ { name = "sync" }, { name = "tabulate" }, { name = "transformers" }, + { name = "urllib3" }, { name = "uvicorn" }, { name = "weaviate-client" }, { name = "websockets" }, @@ -505,8 +520,7 @@ requires-dist = [ { name = "captcha", specifier = ">=0.7.1" }, { name = "celery", specifier = ">=5.5.3" }, { name = "chardet", specifier = ">=5.2.0" }, - { name = "chromadb", specifier = ">=1.3.4" }, - { name = "cryptography", specifier = ">=46.0.3" }, + { name = "cryptography", specifier = ">=46.0.7,<47" }, { name = "dashscope", specifier = ">=1.24.9" }, { name = "deepagents", specifier = ">=0.6.12,<0.7" }, { name = "dmasync", marker = "sys_platform != 'darwin'" }, @@ -543,7 +557,7 @@ requires-dist = [ { name = "markdown", specifier = ">=3.9" }, { name = "markdownify", specifier = ">=1.2.0" }, { name = "matplotlib", specifier = ">=3.10.7" }, - { name = "mcp", specifier = ">=1.27.0" }, + { name = "mcp", specifier = ">=1.27.2,<2.0.0" }, { name = "minio", specifier = "==7.2.18" }, { name = "miniopy-async", specifier = ">=1.23.4" }, { name = "openai", specifier = ">=2.26,<3.0.0" }, @@ -552,10 +566,10 @@ requires-dist = [ { name = "openpyxl", specifier = ">=3.1.5" }, { name = "oracledb", specifier = ">=3.4.0" }, { name = "pandas", specifier = ">=2.3.3" }, - { name = "pillow", specifier = ">=12.0.0" }, + { name = "pillow", specifier = ">=12.3.0,<13" }, { name = "playwright", specifier = "==1.57.0" }, { name = "psycopg2-binary", specifier = ">=2.9.11" }, - { name = "pyjwt", specifier = ">=2.10.1" }, + { name = "pyjwt", specifier = ">=2.13.0,<2.14" }, { name = "pymilvus", specifier = ">=2.6,<3.0" }, { name = "pymongo", specifier = ">=4.15.3" }, { name = "pymupdf", specifier = "==1.27.2.3" }, @@ -580,6 +594,7 @@ requires-dist = [ { name = "sync", specifier = ">=1.0.0" }, { name = "tabulate", specifier = "==0.9.0" }, { name = "transformers", specifier = "==4.57.1" }, + { name = "urllib3", specifier = "==2.6.3" }, { name = "uvicorn", specifier = ">=0.38.0" }, { name = "weaviate-client", specifier = ">=4.17.0" }, { name = "websockets", specifier = ">=15.0.1" }, @@ -595,18 +610,9 @@ dev = [ { name = "ruff", specifier = ">=0.9.0" }, ] -[[package]] -name = "backoff" -version = "2.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, -] - [[package]] name = "banks" -version = "2.4.2" +version = "2.4.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "deprecated" }, @@ -616,79 +622,9 @@ dependencies = [ { name = "platformdirs" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bd/51/08fb68d23f4b0f6256fe85dc86e9576941550f890b079352fba719e07b39/banks-2.4.2.tar.gz", hash = "sha256:cda6013bd377ea7b701933578bfb9370fc21ad70bc13cedfc3f5cb2c034ca3dc", size = 188633, upload-time = "2026-04-27T12:15:22.021Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/00/b6/8dc5477681b782e2f99de703e7a99828883364b9e03a60d3e2c47053d56a/banks-2.4.2-py3-none-any.whl", hash = "sha256:5fe407cc48c101f3e13d1cf732b83b8246003337612f13c0705d2e81f6faffb7", size = 35050, upload-time = "2026-04-27T12:15:20.785Z" }, -] - -[[package]] -name = "bcrypt" -version = "5.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" }, - { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" }, - { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" }, - { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" }, - { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" }, - { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" }, - { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" }, - { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" }, - { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" }, - { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" }, - { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" }, - { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" }, - { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" }, - { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" }, - { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" }, - { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" }, - { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" }, - { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" }, - { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" }, - { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" }, - { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" }, - { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" }, - { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" }, - { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" }, - { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" }, - { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" }, - { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" }, - { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" }, - { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, - { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, - { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, - { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, - { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, - { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, - { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, - { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, - { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, - { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, - { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, - { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, - { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, - { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, - { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, - { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, - { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, - { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, - { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, - { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, - { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, - { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, - { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, - { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, - { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, - { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, - { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, - { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, - { url = "https://files.pythonhosted.org/packages/8a/75/4aa9f5a4d40d762892066ba1046000b329c7cd58e888a6db878019b282dc/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534", size = 271180, upload-time = "2025-09-25T19:50:38.575Z" }, - { url = "https://files.pythonhosted.org/packages/54/79/875f9558179573d40a9cc743038ac2bf67dfb79cecb1e8b5d70e88c94c3d/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4", size = 273791, upload-time = "2025-09-25T19:50:39.913Z" }, - { url = "https://files.pythonhosted.org/packages/bc/fe/975adb8c216174bf70fc17535f75e85ac06ed5252ea077be10d9cff5ce24/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911", size = 270746, upload-time = "2025-09-25T19:50:43.306Z" }, - { url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375, upload-time = "2025-09-25T19:50:45.43Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/a1/b5/4784ee9518b97f9f69c714a4303f9a6186a7e4ff2349f89e24767e9754d9/banks-2.4.5.tar.gz", hash = "sha256:ff575732fc67d5493a73c21e0d7268bc49e86fff02b0b8735e8efb9fcb9af3a4", size = 190822, upload-time = "2026-07-07T08:14:12.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/b0/4d34cb2fe538aeb6d4b0723b3339cb932120db084a5c9a3c6966a26bbe1b/banks-2.4.5-py3-none-any.whl", hash = "sha256:ac2e0091b4c79379d4773c9d04a138a0d937ee27c5803bf0142acc6d6769eea1", size = 36145, upload-time = "2026-07-07T08:14:10.974Z" }, ] [[package]] @@ -734,20 +670,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/bb/bf7aab772a159614954d84aa832c129624ba6c32faa559dfb200a534e50b/bs4-0.0.2-py2.py3-none-any.whl", hash = "sha256:abf8742c0805ef7f662dce4b51cca104cffe52b835238afc169142ab9b3fbccc", size = 1189, upload-time = "2024-01-17T18:15:48.613Z" }, ] -[[package]] -name = "build" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "os_name == 'nt'" }, - { name = "packaging" }, - { name = "pyproject-hooks" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/25/1c/23e33405a7c9eac261dff640926b8b5adaed6a6eb3e1767d441ed611d0c0/build-1.3.0.tar.gz", hash = "sha256:698edd0ea270bde950f53aed21f3a0135672206f3911e0176261a31e0e07b397", size = 48544, upload-time = "2025-08-01T21:27:09.268Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/8c/2b30c12155ad8de0cf641d76a8b396a16d2c36bc6d50b621a62b7c4567c1/build-1.3.0-py3-none-any.whl", hash = "sha256:7145f0b5061ba90a1500d60bd1b13ca0a8a4cebdd0cc16ed8adf1c0e739f43b4", size = 23382, upload-time = "2025-08-01T21:27:07.844Z" }, -] - [[package]] name = "cachetools" version = "6.2.1" @@ -949,58 +871,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] -[[package]] -name = "chromadb" -version = "1.3.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "bcrypt" }, - { name = "build" }, - { name = "grpcio" }, - { name = "httpx" }, - { name = "importlib-resources" }, - { name = "jsonschema" }, - { name = "kubernetes" }, - { name = "mmh3" }, - { name = "numpy" }, - { name = "onnxruntime" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-otlp-proto-grpc" }, - { name = "opentelemetry-sdk" }, - { name = "orjson" }, - { name = "overrides" }, - { name = "posthog" }, - { name = "pybase64" }, - { name = "pydantic" }, - { name = "pypika" }, - { name = "pyyaml" }, - { name = "rich" }, - { name = "tenacity" }, - { name = "tokenizers" }, - { name = "tqdm" }, - { name = "typer" }, - { name = "typing-extensions" }, - { name = "uvicorn", extra = ["standard"] }, -] -sdist = { url = "https://files.pythonhosted.org/packages/79/62/50e3385e703d9db4ccb8abe7a6cfe6f6f1529eefa168ac417dd394e7231e/chromadb-1.3.4.tar.gz", hash = "sha256:47c64a735e610d1b3f50749959508ed51dc4bd7ff9611472ffddb5da8540f40d", size = 1923941, upload-time = "2025-11-05T21:59:26.11Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/cc/0428af3684d7ea990cc57c0cc8a9b7ad6795f2390f5f646c23a087e9f697/chromadb-1.3.4-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c642085aa064851054fdc5ea232ed1bab75dfadc4cf0686504ef3e61b21ad670", size = 20103823, upload-time = "2025-11-05T21:59:23.599Z" }, - { url = "https://files.pythonhosted.org/packages/e7/92/49b8d99d03d9840267dd53f6810e52910859c5fb7dc8d7f13f6a776bdebf/chromadb-1.3.4-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:3ca859c48f43149c17aeb2c6dd05d5238597498594fabde5c33f438992136ef5", size = 19162711, upload-time = "2025-11-05T21:59:20.666Z" }, - { url = "https://files.pythonhosted.org/packages/61/9b/1e3e8d80a6e59f5d844a0848e963c6478dd1bf3c3ab7f1599b55ec85633c/chromadb-1.3.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2e939ef7505a380abce0cc52468990fe1956752283ae57ed7f42119202901d7", size = 19735058, upload-time = "2025-11-05T21:59:14.379Z" }, - { url = "https://files.pythonhosted.org/packages/3d/cf/2d490b3a46f4c67be48506bb69a9670949df7fee17e211a75ad36484a7c1/chromadb-1.3.4-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2f5d0be5d2d5d1f457608156f2676d244af8d788edcffce1b2b25bac19e3b66", size = 20829991, upload-time = "2025-11-05T21:59:17.625Z" }, - { url = "https://files.pythonhosted.org/packages/a3/21/93ae1b1bdca02bf303505d117f4f8cfb9fc78c1935b551da6eb5859f5b26/chromadb-1.3.4-cp39-abi3-win_amd64.whl", hash = "sha256:8da98a377ca06bdc8dc8bd77b4e2aeb703858a615a3fe5bbb247396acfc90168", size = 20790199, upload-time = "2025-11-05T21:59:28.198Z" }, -] - [[package]] name = "click" -version = "8.3.0" +version = "8.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, ] [[package]] @@ -1049,18 +929,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] -[[package]] -name = "coloredlogs" -version = "15.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "humanfriendly" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" }, -] - [[package]] name = "contourpy" version = "1.3.3" @@ -1249,64 +1117,61 @@ toml = [ [[package]] name = "cryptography" -version = "46.0.3" +version = "46.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, - { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, - { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, - { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, - { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, - { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, - { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, - { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, - { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, - { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, - { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, - { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, - { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, - { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, - { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, - { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, - { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, - { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, - { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, - { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, - { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, - { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, - { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, - { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, - { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, - { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, - { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, - { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, - { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, - { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, - { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, - { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, - { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, - { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, - { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, - { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, - { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, - { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, - { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, - { url = "https://files.pythonhosted.org/packages/06/8a/e60e46adab4362a682cf142c7dcb5bf79b782ab2199b0dcb81f55970807f/cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea", size = 3698132, upload-time = "2025-10-15T23:18:17.056Z" }, - { url = "https://files.pythonhosted.org/packages/da/38/f59940ec4ee91e93d3311f7532671a5cef5570eb04a144bf203b58552d11/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b", size = 4243992, upload-time = "2025-10-15T23:18:18.695Z" }, - { url = "https://files.pythonhosted.org/packages/b0/0c/35b3d92ddebfdfda76bb485738306545817253d0a3ded0bfe80ef8e67aa5/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb", size = 4409944, upload-time = "2025-10-15T23:18:20.597Z" }, - { url = "https://files.pythonhosted.org/packages/99/55/181022996c4063fc0e7666a47049a1ca705abb9c8a13830f074edb347495/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717", size = 4242957, upload-time = "2025-10-15T23:18:22.18Z" }, - { url = "https://files.pythonhosted.org/packages/ba/af/72cd6ef29f9c5f731251acadaeb821559fe25f10852f44a63374c9ca08c1/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9", size = 4409447, upload-time = "2025-10-15T23:18:24.209Z" }, - { url = "https://files.pythonhosted.org/packages/0d/c3/e90f4a4feae6410f914f8ebac129b9ae7a8c92eb60a638012dde42030a9d/cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c", size = 3438528, upload-time = "2025-10-15T23:18:26.227Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, + { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, + { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, + { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, + { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, + { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, + { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, + { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, + { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, + { url = "https://files.pythonhosted.org/packages/63/0c/dca8abb64e7ca4f6b2978769f6fea5ad06686a190cec381f0a796fdcaaba/cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", size = 3476879, upload-time = "2026-04-08T01:57:38.664Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" }, + { url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" }, + { url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" }, ] [[package]] @@ -1427,24 +1292,24 @@ wheels = [ [[package]] name = "dmpython" -version = "2.5.32" +version = "2.6.1" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/59/32a18955e846b84f2c1bfd463c4a4bbc8cf4caa4ecfe83ea474af475449a/dmpython-2.5.32-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6a6c2b57d94f02f583bb784e1391ee2de958ecb77fd6a630babef6d0b7274698", size = 11845617, upload-time = "2026-04-13T09:33:51.063Z" }, - { url = "https://files.pythonhosted.org/packages/f3/82/f62ce3aa496f432a136f627e485a4a9c0c42937a6ea83f0014a69c2272ca/dmpython-2.5.32-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:542cd3c55a4a70642ec043425bd206d8304d7fdbbbfec3e17396542c0b638a63", size = 11216028, upload-time = "2026-04-13T09:03:24.401Z" }, - { url = "https://files.pythonhosted.org/packages/41/83/e6c8f8dc75807e66efccaef74d9f80e899a9e50307f0e8e03a64115c7ecb/dmpython-2.5.32-cp311-cp311-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1b2c8919f2aad4084774f3ab8a9342861ffca29720e32fdbcc375a34b27f66c4", size = 11216256, upload-time = "2026-04-13T09:03:53.716Z" }, - { url = "https://files.pythonhosted.org/packages/80/27/5c880a15b16036f20adf6694f407525e0539d62b2b1a191b12577adec256/dmpython-2.5.32-cp311-cp311-win_amd64.whl", hash = "sha256:bdb3366eb519105dc663b2e5ac7d88a592402c2d6a14810fc22be09b528a94ae", size = 8734862, upload-time = "2026-04-13T09:25:30.735Z" }, - { url = "https://files.pythonhosted.org/packages/06/a5/82cf0dfd483ef78e7523781ea95892ac921202a355cf158ce1ce72a956ab/dmpython-2.5.32-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fbc9602106ec8d894f10d93205d41b4dcfdbc3f67c4d7b5e45452bceea9f7bc", size = 11574161, upload-time = "2026-04-13T09:34:22.702Z" }, - { url = "https://files.pythonhosted.org/packages/21/2c/d9f0f6c39834d73e89ff7e79aa92e677f4e4bea9c07dda917e2e3ad933bd/dmpython-2.5.32-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bca8530ee0602c8bb4609a3b502cee01025642dbe873acdecc1f249318180ba9", size = 10881618, upload-time = "2026-04-13T09:04:20.903Z" }, - { url = "https://files.pythonhosted.org/packages/d2/7b/9a1997e4db3e32c38a332229b5961f1b5f4bec406c959bf8cbc1fab4992f/dmpython-2.5.32-cp312-cp312-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cdeb42cb6f88b46c35dcf9d295c3c8ffe48a8b6f5713dee3fcc3e8cdd7b572ab", size = 10883883, upload-time = "2026-04-13T09:04:48.267Z" }, - { url = "https://files.pythonhosted.org/packages/3c/61/fee9c3f631dec012034b5a1ef1ee624b0a44740186d2efa23861db252f80/dmpython-2.5.32-cp312-cp312-win_amd64.whl", hash = "sha256:668da85d874aba561b6c0d6dde183ec294411967d64d671e69e41fb6d5523c39", size = 8670510, upload-time = "2026-04-13T09:25:54.738Z" }, - { url = "https://files.pythonhosted.org/packages/35/c2/389b89c94a75323034bfd5ec345d699e8c30599f75b030a6f4d67b0460b2/dmpython-2.5.32-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0a2b902e3d23dd1cd70e29a751adffe04ada5f0001dfb51f876a0367bf0eb125", size = 11573666, upload-time = "2026-04-13T09:34:59.585Z" }, - { url = "https://files.pythonhosted.org/packages/e3/84/39cc41871d6ef72c2c3ee958e085916dc30bc7662b34bb09e57d5a2c2f59/dmpython-2.5.32-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84c47b410d035d9432e9732c8f885435e8b04bb66b0ec7f8f86b4779f69aadbb", size = 10876679, upload-time = "2026-04-13T09:05:19.211Z" }, - { url = "https://files.pythonhosted.org/packages/88/93/9b53fc5903f019692a35c78f5b0f4878a6aa74fdf1d117986051821436d9/dmpython-2.5.32-cp313-cp313-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:719d250e79691d5448b27e05d63cb2f39e4c32c6f7c990f01807b20263b9c13a", size = 10875186, upload-time = "2026-04-13T09:05:46.581Z" }, - { url = "https://files.pythonhosted.org/packages/0d/ec/4f97d6a5fad7aba22e35f3f4ec078d2d85811239a22dd93f7e32bda15873/dmpython-2.5.32-cp313-cp313-win_amd64.whl", hash = "sha256:50c3c911be37af2a6cb05dc6e39ada6ef4d51a63f96a7ac9c8e877c929bbb34a", size = 8670506, upload-time = "2026-04-13T09:26:18.223Z" }, - { url = "https://files.pythonhosted.org/packages/13/4e/804176a7059e5f0c3b3b2196d7f23a4b09c28514f4b491428d7e38364262/dmpython-2.5.32-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4bde0e3b32b011cd1573f941e7680acebd2dda206962603373cff082aa3197d0", size = 11575994, upload-time = "2026-04-13T09:35:36.915Z" }, - { url = "https://files.pythonhosted.org/packages/0e/53/c7d89a1f73289fef39eb82bab230a1a976d03277d031bf2320d87e4055e2/dmpython-2.5.32-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2d54c3b0daf9e3196bc7a85d1d1237a5e897fe5153cfaf9734b116c922e9dc82", size = 10876690, upload-time = "2026-04-13T09:06:13.867Z" }, - { url = "https://files.pythonhosted.org/packages/4f/38/eb0c774a826b2feff3096bcb24942ac45a92fcdb18dd35c80200a6cebc68/dmpython-2.5.32-cp314-cp314-win_amd64.whl", hash = "sha256:b0fdef9ef37fadf2d9a0548a54d58c4efafa6f59ceac0404e0a1d6a7069a45d3", size = 8670385, upload-time = "2026-04-13T09:26:41.859Z" }, + { url = "https://files.pythonhosted.org/packages/c6/34/5095feb96a0ab197e82e1c395efc0574e91bba8572830aeeda3e4ceb9079/dmpython-2.6.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3b70c55a8773e3c461ffd3cdc55c5eff44c4cff3a978e10f52f439c734c4dd19", size = 11480453, upload-time = "2026-10-19T01:19:28.439Z" }, + { url = "https://files.pythonhosted.org/packages/5f/17/80392580af4d8d5a9c61d0b6e85ba37a7f7059013ae3933a0f762e928752/dmpython-2.6.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03c21ea904193e932e522003d1f0868fa2d27ad452177c209b483958f42855d0", size = 11683144, upload-time = "2026-10-19T01:20:17.451Z" }, + { url = "https://files.pythonhosted.org/packages/40/7e/4f07a3e29cbe01c6c509a74a80f718753e1038dfb00788f7b99e3beb0851/dmpython-2.6.1-cp311-cp311-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4e7a6f1a99b637c23662728e9ff60e0384e2bbfda4aecbe44ec9815de1df4742", size = 11687132, upload-time = "2026-10-19T01:20:56.949Z" }, + { url = "https://files.pythonhosted.org/packages/67/19/6ce8d1f39bb849094f9f0f3184c7e4012f7686d2fa5acd8cd3bba4136233/dmpython-2.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:2363b00f5df3fa5c1f07bca14eae5ba3dc4127e2b3bed1197cd61e1f5cc4fdb4", size = 9224345, upload-time = "2026-10-19T01:21:23.238Z" }, + { url = "https://files.pythonhosted.org/packages/84/c9/b69f60dc452ef546ff8e73d655a8f8989ce36872a7860320f5cf0892a7f3/dmpython-2.6.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4a0bceb097c437c92942b10b96c9202f1ecbbeeb7d47e3641869da73521bb51c", size = 11203824, upload-time = "2026-10-19T01:19:44.75Z" }, + { url = "https://files.pythonhosted.org/packages/fb/ff/3118daa18879a63ce4acaf36fd1e5789cd2a63eaca1d19e2744b8bf521eb/dmpython-2.6.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:13d93ea66a1d9a739599e3054f98de9abd1311476cb59d195658293b22c3ed03", size = 11343703, upload-time = "2026-10-19T01:22:03.404Z" }, + { url = "https://files.pythonhosted.org/packages/da/7d/846436e3590301da67394a46817f7ee4e5df0b59ca0e932c74ffa32a8524/dmpython-2.6.1-cp312-cp312-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98e060572b5847ddc713b55c0497b76cf4d75ae2126d98f174c4f19bd2458469", size = 11350855, upload-time = "2026-10-19T01:22:27.512Z" }, + { url = "https://files.pythonhosted.org/packages/88/aa/509a6594b1e4064920eb84fcdd92c1cae1c3faf3c501a6efcb72fe560bd7/dmpython-2.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c270dd51fb16c6e0c6a927018c207584ca6c15c9ac16461b71c795edb72291e", size = 9158537, upload-time = "2026-10-19T01:23:07.389Z" }, + { url = "https://files.pythonhosted.org/packages/45/56/12de6c191321fde76d40f47604fafb1d559238c996b05d6f09c18405c5d7/dmpython-2.6.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:96958af6d4c7365b12235ac33b841d05cc61e2eb1047bed823e6a8ad0b17d515", size = 11203150, upload-time = "2026-10-19T01:20:02.685Z" }, + { url = "https://files.pythonhosted.org/packages/73/c2/e184900605b805a61e59da75f31caeff7fb72ec4a0b06d54c2333cadd7d2/dmpython-2.6.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:45d7883ce2262ec27138f64a5e66e877b4330963e83b8d5440325b67c91eaf52", size = 11337431, upload-time = "2026-10-19T01:23:47.647Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b9/8a00142ac1b15348d1e17c0ab8a71914316e4f5245fe5e8f1dd61818dd64/dmpython-2.6.1-cp313-cp313-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a8538ea58c679acbb1da1e003b063c434115465f7c9fbeadda919615d42a0369", size = 11338927, upload-time = "2026-10-19T01:24:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/26/6c/8c49602bad4e444262b6675b2ebecca3e7b2a2132a776e0a389a43fa046b/dmpython-2.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:4e8a78c8507279019342f582b18b3fa007cfec554e0056d0681c5a87b6a1f40b", size = 9158507, upload-time = "2026-10-19T01:24:46.407Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b5/2289fb7d72d808e13b1fadf5afe0c661ffc2709d23ac54ade7b65d4a7210/dmpython-2.6.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d6e11e8fde6f12b4c1558fc4be77831bc6516a3789a6c4dba53f1bf8e0fc7952", size = 11204903, upload-time = "2026-10-19T01:20:25.688Z" }, + { url = "https://files.pythonhosted.org/packages/34/bf/44b6687478dc46e2d4f25aa19e094cd490aa0b9baa35a6d23e9ade4d9f47/dmpython-2.6.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:14153a9678c9227b00753a82e0f304be4497aeea2426a189e137a9aeebc03a9f", size = 11338461, upload-time = "2026-10-19T01:25:31.132Z" }, + { url = "https://files.pythonhosted.org/packages/df/6b/7a8e06d3501ce0775c156374075bae525a27c13cd98bde6d0df52108b845/dmpython-2.6.1-cp314-cp314-win_amd64.whl", hash = "sha256:8d7822e0b27bd0ec0f4fc3a1a2d6f4866b5884aadd0671b010dd5a0d9c1686aa", size = 9158442, upload-time = "2026-10-19T01:26:15.651Z" }, ] [[package]] @@ -1477,15 +1342,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, ] -[[package]] -name = "durationpy" -version = "0.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/a4/e44218c2b394e31a6dd0d6b095c4e1f32d0be54c2a4b250032d717647bab/durationpy-0.10.tar.gz", hash = "sha256:1fa6893409a6e739c9c72334fc65cca1f355dbdd93405d30f726deb5bde42fba", size = 3335, upload-time = "2025-05-17T13:52:37.26Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/0d/9feae160378a3553fa9a339b0e9c1a048e147a4127210e286ef18b730f03/durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286", size = 3922, upload-time = "2025-05-17T13:52:36.463Z" }, -] - [[package]] name = "e2b" version = "1.11.1" @@ -1659,11 +1515,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.20.0" +version = "3.20.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, + { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, ] [[package]] @@ -1675,62 +1531,53 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/79/1b8fa1bb3568781e84c9200f951c735f3f157429f44be0495da55894d620/filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25", size = 19970, upload-time = "2022-11-02T17:34:01.425Z" }, ] -[[package]] -name = "flatbuffers" -version = "25.9.23" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/1f/3ee70b0a55137442038f2a33469cc5fddd7e0ad2abf83d7497c18a2b6923/flatbuffers-25.9.23.tar.gz", hash = "sha256:676f9fa62750bb50cf531b42a0a2a118ad8f7f797a511eda12881c016f093b12", size = 22067, upload-time = "2025-09-24T05:25:30.106Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/1b/00a78aa2e8fbd63f9af08c9c19e6deb3d5d66b4dda677a0f61654680ee89/flatbuffers-25.9.23-py2.py3-none-any.whl", hash = "sha256:255538574d6cb6d0a79a17ec8bc0d30985913b87513a01cce8bcdb6b4c44d0e2", size = 30869, upload-time = "2025-09-24T05:25:28.912Z" }, -] - [[package]] name = "fonttools" -version = "4.60.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4b/42/97a13e47a1e51a5a7142475bbcf5107fe3a68fc34aef331c897d5fb98ad0/fonttools-4.60.1.tar.gz", hash = "sha256:ef00af0439ebfee806b25f24c8f92109157ff3fac5731dc7867957812e87b8d9", size = 3559823, upload-time = "2025-09-29T21:13:27.129Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/85/639aa9bface1537e0fb0f643690672dde0695a5bbbc90736bc571b0b1941/fonttools-4.60.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7b4c32e232a71f63a5d00259ca3d88345ce2a43295bb049d21061f338124246f", size = 2831872, upload-time = "2025-09-29T21:11:20.329Z" }, - { url = "https://files.pythonhosted.org/packages/6b/47/3c63158459c95093be9618794acb1067b3f4d30dcc5c3e8114b70e67a092/fonttools-4.60.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3630e86c484263eaac71d117085d509cbcf7b18f677906824e4bace598fb70d2", size = 2356990, upload-time = "2025-09-29T21:11:22.754Z" }, - { url = "https://files.pythonhosted.org/packages/94/dd/1934b537c86fcf99f9761823f1fc37a98fbd54568e8e613f29a90fed95a9/fonttools-4.60.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5c1015318e4fec75dd4943ad5f6a206d9727adf97410d58b7e32ab644a807914", size = 5042189, upload-time = "2025-09-29T21:11:25.061Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d2/9f4e4c4374dd1daa8367784e1bd910f18ba886db1d6b825b12edf6db3edc/fonttools-4.60.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e6c58beb17380f7c2ea181ea11e7db8c0ceb474c9dd45f48e71e2cb577d146a1", size = 4978683, upload-time = "2025-09-29T21:11:27.693Z" }, - { url = "https://files.pythonhosted.org/packages/cc/c4/0fb2dfd1ecbe9a07954cc13414713ed1eab17b1c0214ef07fc93df234a47/fonttools-4.60.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ec3681a0cb34c255d76dd9d865a55f260164adb9fa02628415cdc2d43ee2c05d", size = 5021372, upload-time = "2025-09-29T21:11:30.257Z" }, - { url = "https://files.pythonhosted.org/packages/0c/d5/495fc7ae2fab20223cc87179a8f50f40f9a6f821f271ba8301ae12bb580f/fonttools-4.60.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f4b5c37a5f40e4d733d3bbaaef082149bee5a5ea3156a785ff64d949bd1353fa", size = 5132562, upload-time = "2025-09-29T21:11:32.737Z" }, - { url = "https://files.pythonhosted.org/packages/bc/fa/021dab618526323c744e0206b3f5c8596a2e7ae9aa38db5948a131123e83/fonttools-4.60.1-cp311-cp311-win32.whl", hash = "sha256:398447f3d8c0c786cbf1209711e79080a40761eb44b27cdafffb48f52bcec258", size = 2230288, upload-time = "2025-09-29T21:11:35.015Z" }, - { url = "https://files.pythonhosted.org/packages/bb/78/0e1a6d22b427579ea5c8273e1c07def2f325b977faaf60bb7ddc01456cb1/fonttools-4.60.1-cp311-cp311-win_amd64.whl", hash = "sha256:d066ea419f719ed87bc2c99a4a4bfd77c2e5949cb724588b9dd58f3fd90b92bf", size = 2278184, upload-time = "2025-09-29T21:11:37.434Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f7/a10b101b7a6f8836a5adb47f2791f2075d044a6ca123f35985c42edc82d8/fonttools-4.60.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7b0c6d57ab00dae9529f3faf187f2254ea0aa1e04215cf2f1a8ec277c96661bc", size = 2832953, upload-time = "2025-09-29T21:11:39.616Z" }, - { url = "https://files.pythonhosted.org/packages/ed/fe/7bd094b59c926acf2304d2151354ddbeb74b94812f3dc943c231db09cb41/fonttools-4.60.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:839565cbf14645952d933853e8ade66a463684ed6ed6c9345d0faf1f0e868877", size = 2352706, upload-time = "2025-09-29T21:11:41.826Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ca/4bb48a26ed95a1e7eba175535fe5805887682140ee0a0d10a88e1de84208/fonttools-4.60.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8177ec9676ea6e1793c8a084a90b65a9f778771998eb919d05db6d4b1c0b114c", size = 4923716, upload-time = "2025-09-29T21:11:43.893Z" }, - { url = "https://files.pythonhosted.org/packages/b8/9f/2cb82999f686c1d1ddf06f6ae1a9117a880adbec113611cc9d22b2fdd465/fonttools-4.60.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:996a4d1834524adbb423385d5a629b868ef9d774670856c63c9a0408a3063401", size = 4968175, upload-time = "2025-09-29T21:11:46.439Z" }, - { url = "https://files.pythonhosted.org/packages/18/79/be569699e37d166b78e6218f2cde8c550204f2505038cdd83b42edc469b9/fonttools-4.60.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a46b2f450bc79e06ef3b6394f0c68660529ed51692606ad7f953fc2e448bc903", size = 4911031, upload-time = "2025-09-29T21:11:48.977Z" }, - { url = "https://files.pythonhosted.org/packages/cc/9f/89411cc116effaec5260ad519162f64f9c150e5522a27cbb05eb62d0c05b/fonttools-4.60.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6ec722ee589e89a89f5b7574f5c45604030aa6ae24cb2c751e2707193b466fed", size = 5062966, upload-time = "2025-09-29T21:11:54.344Z" }, - { url = "https://files.pythonhosted.org/packages/62/a1/f888221934b5731d46cb9991c7a71f30cb1f97c0ef5fcf37f8da8fce6c8e/fonttools-4.60.1-cp312-cp312-win32.whl", hash = "sha256:b2cf105cee600d2de04ca3cfa1f74f1127f8455b71dbad02b9da6ec266e116d6", size = 2218750, upload-time = "2025-09-29T21:11:56.601Z" }, - { url = "https://files.pythonhosted.org/packages/88/8f/a55b5550cd33cd1028601df41acd057d4be20efa5c958f417b0c0613924d/fonttools-4.60.1-cp312-cp312-win_amd64.whl", hash = "sha256:992775c9fbe2cf794786fa0ffca7f09f564ba3499b8fe9f2f80bd7197db60383", size = 2267026, upload-time = "2025-09-29T21:11:58.852Z" }, - { url = "https://files.pythonhosted.org/packages/7c/5b/cdd2c612277b7ac7ec8c0c9bc41812c43dc7b2d5f2b0897e15fdf5a1f915/fonttools-4.60.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6f68576bb4bbf6060c7ab047b1574a1ebe5c50a17de62830079967b211059ebb", size = 2825777, upload-time = "2025-09-29T21:12:01.22Z" }, - { url = "https://files.pythonhosted.org/packages/d6/8a/de9cc0540f542963ba5e8f3a1f6ad48fa211badc3177783b9d5cadf79b5d/fonttools-4.60.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:eedacb5c5d22b7097482fa834bda0dafa3d914a4e829ec83cdea2a01f8c813c4", size = 2348080, upload-time = "2025-09-29T21:12:03.785Z" }, - { url = "https://files.pythonhosted.org/packages/2d/8b/371ab3cec97ee3fe1126b3406b7abd60c8fec8975fd79a3c75cdea0c3d83/fonttools-4.60.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b33a7884fabd72bdf5f910d0cf46be50dce86a0362a65cfc746a4168c67eb96c", size = 4903082, upload-time = "2025-09-29T21:12:06.382Z" }, - { url = "https://files.pythonhosted.org/packages/04/05/06b1455e4bc653fcb2117ac3ef5fa3a8a14919b93c60742d04440605d058/fonttools-4.60.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2409d5fb7b55fd70f715e6d34e7a6e4f7511b8ad29a49d6df225ee76da76dd77", size = 4960125, upload-time = "2025-09-29T21:12:09.314Z" }, - { url = "https://files.pythonhosted.org/packages/8e/37/f3b840fcb2666f6cb97038793606bdd83488dca2d0b0fc542ccc20afa668/fonttools-4.60.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c8651e0d4b3bdeda6602b85fdc2abbefc1b41e573ecb37b6779c4ca50753a199", size = 4901454, upload-time = "2025-09-29T21:12:11.931Z" }, - { url = "https://files.pythonhosted.org/packages/fd/9e/eb76f77e82f8d4a46420aadff12cec6237751b0fb9ef1de373186dcffb5f/fonttools-4.60.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:145daa14bf24824b677b9357c5e44fd8895c2a8f53596e1b9ea3496081dc692c", size = 5044495, upload-time = "2025-09-29T21:12:15.241Z" }, - { url = "https://files.pythonhosted.org/packages/f8/b3/cede8f8235d42ff7ae891bae8d619d02c8ac9fd0cfc450c5927a6200c70d/fonttools-4.60.1-cp313-cp313-win32.whl", hash = "sha256:2299df884c11162617a66b7c316957d74a18e3758c0274762d2cc87df7bc0272", size = 2217028, upload-time = "2025-09-29T21:12:17.96Z" }, - { url = "https://files.pythonhosted.org/packages/75/4d/b022c1577807ce8b31ffe055306ec13a866f2337ecee96e75b24b9b753ea/fonttools-4.60.1-cp313-cp313-win_amd64.whl", hash = "sha256:a3db56f153bd4c5c2b619ab02c5db5192e222150ce5a1bc10f16164714bc39ac", size = 2266200, upload-time = "2025-09-29T21:12:20.14Z" }, - { url = "https://files.pythonhosted.org/packages/9a/83/752ca11c1aa9a899b793a130f2e466b79ea0cf7279c8d79c178fc954a07b/fonttools-4.60.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a884aef09d45ba1206712c7dbda5829562d3fea7726935d3289d343232ecb0d3", size = 2822830, upload-time = "2025-09-29T21:12:24.406Z" }, - { url = "https://files.pythonhosted.org/packages/57/17/bbeab391100331950a96ce55cfbbff27d781c1b85ebafb4167eae50d9fe3/fonttools-4.60.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8a44788d9d91df72d1a5eac49b31aeb887a5f4aab761b4cffc4196c74907ea85", size = 2345524, upload-time = "2025-09-29T21:12:26.819Z" }, - { url = "https://files.pythonhosted.org/packages/3d/2e/d4831caa96d85a84dd0da1d9f90d81cec081f551e0ea216df684092c6c97/fonttools-4.60.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e852d9dda9f93ad3651ae1e3bb770eac544ec93c3807888798eccddf84596537", size = 4843490, upload-time = "2025-09-29T21:12:29.123Z" }, - { url = "https://files.pythonhosted.org/packages/49/13/5e2ea7c7a101b6fc3941be65307ef8df92cbbfa6ec4804032baf1893b434/fonttools-4.60.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:154cb6ee417e417bf5f7c42fe25858c9140c26f647c7347c06f0cc2d47eff003", size = 4944184, upload-time = "2025-09-29T21:12:31.414Z" }, - { url = "https://files.pythonhosted.org/packages/0c/2b/cf9603551c525b73fc47c52ee0b82a891579a93d9651ed694e4e2cd08bb8/fonttools-4.60.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5664fd1a9ea7f244487ac8f10340c4e37664675e8667d6fee420766e0fb3cf08", size = 4890218, upload-time = "2025-09-29T21:12:33.936Z" }, - { url = "https://files.pythonhosted.org/packages/fd/2f/933d2352422e25f2376aae74f79eaa882a50fb3bfef3c0d4f50501267101/fonttools-4.60.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:583b7f8e3c49486e4d489ad1deacfb8d5be54a8ef34d6df824f6a171f8511d99", size = 4999324, upload-time = "2025-09-29T21:12:36.637Z" }, - { url = "https://files.pythonhosted.org/packages/38/99/234594c0391221f66216bc2c886923513b3399a148defaccf81dc3be6560/fonttools-4.60.1-cp314-cp314-win32.whl", hash = "sha256:66929e2ea2810c6533a5184f938502cfdaea4bc3efb7130d8cc02e1c1b4108d6", size = 2220861, upload-time = "2025-09-29T21:12:39.108Z" }, - { url = "https://files.pythonhosted.org/packages/3e/1d/edb5b23726dde50fc4068e1493e4fc7658eeefcaf75d4c5ffce067d07ae5/fonttools-4.60.1-cp314-cp314-win_amd64.whl", hash = "sha256:f3d5be054c461d6a2268831f04091dc82753176f6ea06dc6047a5e168265a987", size = 2270934, upload-time = "2025-09-29T21:12:41.339Z" }, - { url = "https://files.pythonhosted.org/packages/fb/da/1392aaa2170adc7071fe7f9cfd181a5684a7afcde605aebddf1fb4d76df5/fonttools-4.60.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:b6379e7546ba4ae4b18f8ae2b9bc5960936007a1c0e30b342f662577e8bc3299", size = 2894340, upload-time = "2025-09-29T21:12:43.774Z" }, - { url = "https://files.pythonhosted.org/packages/bf/a7/3b9f16e010d536ce567058b931a20b590d8f3177b2eda09edd92e392375d/fonttools-4.60.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9d0ced62b59e0430b3690dbc5373df1c2aa7585e9a8ce38eff87f0fd993c5b01", size = 2375073, upload-time = "2025-09-29T21:12:46.437Z" }, - { url = "https://files.pythonhosted.org/packages/9b/b5/e9bcf51980f98e59bb5bb7c382a63c6f6cac0eec5f67de6d8f2322382065/fonttools-4.60.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:875cb7764708b3132637f6c5fb385b16eeba0f7ac9fa45a69d35e09b47045801", size = 4849758, upload-time = "2025-09-29T21:12:48.694Z" }, - { url = "https://files.pythonhosted.org/packages/e3/dc/1d2cf7d1cba82264b2f8385db3f5960e3d8ce756b4dc65b700d2c496f7e9/fonttools-4.60.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a184b2ea57b13680ab6d5fbde99ccef152c95c06746cb7718c583abd8f945ccc", size = 5085598, upload-time = "2025-09-29T21:12:51.081Z" }, - { url = "https://files.pythonhosted.org/packages/5d/4d/279e28ba87fb20e0c69baf72b60bbf1c4d873af1476806a7b5f2b7fac1ff/fonttools-4.60.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:026290e4ec76583881763fac284aca67365e0be9f13a7fb137257096114cb3bc", size = 4957603, upload-time = "2025-09-29T21:12:53.423Z" }, - { url = "https://files.pythonhosted.org/packages/78/d4/ff19976305e0c05aa3340c805475abb00224c954d3c65e82c0a69633d55d/fonttools-4.60.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f0e8817c7d1a0c2eedebf57ef9a9896f3ea23324769a9a2061a80fe8852705ed", size = 4974184, upload-time = "2025-09-29T21:12:55.962Z" }, - { url = "https://files.pythonhosted.org/packages/63/22/8553ff6166f5cd21cfaa115aaacaa0dc73b91c079a8cfd54a482cbc0f4f5/fonttools-4.60.1-cp314-cp314t-win32.whl", hash = "sha256:1410155d0e764a4615774e5c2c6fc516259fe3eca5882f034eb9bfdbee056259", size = 2282241, upload-time = "2025-09-29T21:12:58.179Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cb/fa7b4d148e11d5a72761a22e595344133e83a9507a4c231df972e657579b/fonttools-4.60.1-cp314-cp314t-win_amd64.whl", hash = "sha256:022beaea4b73a70295b688f817ddc24ed3e3418b5036ffcd5658141184ef0d0c", size = 2345760, upload-time = "2025-09-29T21:13:00.375Z" }, - { url = "https://files.pythonhosted.org/packages/c7/93/0dd45cd283c32dea1545151d8c3637b4b8c53cdb3a625aeb2885b184d74d/fonttools-4.60.1-py3-none-any.whl", hash = "sha256:906306ac7afe2156fcf0042173d6ebbb05416af70f6b370967b47f8f00103bbb", size = 1143175, upload-time = "2025-09-29T21:13:24.134Z" }, +version = "4.60.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/c4/db6a7b5eb0656534c3aa2596c2c5e18830d74f1b9aa5aa8a7dff63a0b11d/fonttools-4.60.2.tar.gz", hash = "sha256:d29552e6b155ebfc685b0aecf8d429cb76c14ab734c22ef5d3dea6fdf800c92c", size = 3562254, upload-time = "2025-12-09T13:38:11.835Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/80/b7693d37c02417e162cc83cdd0b19a4f58be82c638b5d4ce4de2dae050c4/fonttools-4.60.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a2aed0a7931401b3875265717a24c726f87ecfedbb7b3426c2ca4d2812e281ae", size = 2847809, upload-time = "2025-12-09T13:36:14.884Z" }, + { url = "https://files.pythonhosted.org/packages/f9/9a/9c2c13bf8a6496ac21607d704e74e9cc68ebf23892cf924c9a8b5c7566b9/fonttools-4.60.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dea6868e9d2b816c9076cfea77754686f3c19149873bdbc5acde437631c15df1", size = 2397302, upload-time = "2025-12-09T13:36:17.151Z" }, + { url = "https://files.pythonhosted.org/packages/56/f6/ce38ff6b2d2d58f6fd981d32f3942365bfa30eadf2b47d93b2d48bf6097f/fonttools-4.60.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fa27f34950aa1fe0f0b1abe25eed04770a3b3b34ad94e5ace82cc341589678a", size = 5054418, upload-time = "2025-12-09T13:36:19.062Z" }, + { url = "https://files.pythonhosted.org/packages/88/06/5353bea128ff39e857c31de3dd605725b4add956badae0b31bc9a50d4c8e/fonttools-4.60.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:13a53d479d187b09bfaa4a35ffcbc334fc494ff355f0a587386099cb66674f1e", size = 5031652, upload-time = "2025-12-09T13:36:21.206Z" }, + { url = "https://files.pythonhosted.org/packages/71/05/ebca836437f6ebd57edd6428e7eff584e683ff0556ddb17d62e3b731f46c/fonttools-4.60.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fac5e921d3bd0ca3bb8517dced2784f0742bc8ca28579a68b139f04ea323a779", size = 5030321, upload-time = "2025-12-09T13:36:23.515Z" }, + { url = "https://files.pythonhosted.org/packages/57/f9/eb9d2a2ce30c99f840c1cc3940729a970923cf39d770caf88909d98d516b/fonttools-4.60.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:648f4f9186fd7f1f3cd57dbf00d67a583720d5011feca67a5e88b3a491952cfb", size = 5154255, upload-time = "2025-12-09T13:36:25.879Z" }, + { url = "https://files.pythonhosted.org/packages/08/a2/088b6ceba8272a9abb629d3c08f9c1e35e5ce42db0ccfe0c1f9f03e60d1d/fonttools-4.60.2-cp311-cp311-win32.whl", hash = "sha256:3274e15fad871bead5453d5ce02658f6d0c7bc7e7021e2a5b8b04e2f9e40da1a", size = 2276300, upload-time = "2025-12-09T13:36:27.772Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/8e4c3d908cc5dade7bb1316ce48589f6a24460c1056fd4b8db51f1fa309a/fonttools-4.60.2-cp311-cp311-win_amd64.whl", hash = "sha256:91d058d5a483a1525b367803abb69de0923fbd45e1f82ebd000f5c8aa65bc78e", size = 2327574, upload-time = "2025-12-09T13:36:30.89Z" }, + { url = "https://files.pythonhosted.org/packages/c0/30/530c9eddcd1c39219dc0aaede2b5a4c8ab80e0bb88d1b3ffc12944c4aac3/fonttools-4.60.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e0164b7609d2b5c5dd4e044b8085b7bd7ca7363ef8c269a4ab5b5d4885a426b2", size = 2847196, upload-time = "2025-12-09T13:36:33.262Z" }, + { url = "https://files.pythonhosted.org/packages/19/2f/4077a482836d5bbe3bc9dac1c004d02ee227cf04ed62b0a2dfc41d4f0dfd/fonttools-4.60.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1dd3d9574fc595c1e97faccae0f264dc88784ddf7fbf54c939528378bacc0033", size = 2395842, upload-time = "2025-12-09T13:36:35.47Z" }, + { url = "https://files.pythonhosted.org/packages/dd/05/aae5bb99c5398f8ed4a8b784f023fd9dd3568f0bd5d5b21e35b282550f11/fonttools-4.60.2-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:98d0719f1b11c2817307d2da2e94296a3b2a3503f8d6252a101dca3ee663b917", size = 4949713, upload-time = "2025-12-09T13:36:37.874Z" }, + { url = "https://files.pythonhosted.org/packages/b4/37/49067349fc78ff0efbf09fadefe80ddf41473ca8f8a25400e3770da38328/fonttools-4.60.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d3ea26957dd07209f207b4fff64c702efe5496de153a54d3b91007ec28904dd", size = 4999907, upload-time = "2025-12-09T13:36:39.853Z" }, + { url = "https://files.pythonhosted.org/packages/16/31/d0f11c758bd0db36b664c92a0f9dfdcc2d7313749aa7d6629805c6946f21/fonttools-4.60.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1ee301273b0850f3a515299f212898f37421f42ff9adfc341702582ca5073c13", size = 4939717, upload-time = "2025-12-09T13:36:43.075Z" }, + { url = "https://files.pythonhosted.org/packages/d9/bc/1cff0d69522e561bf1b99bee7c3911c08c25e919584827c3454a64651ce9/fonttools-4.60.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c6eb4694cc3b9c03b7c01d65a9cf35b577f21aa6abdbeeb08d3114b842a58153", size = 5089205, upload-time = "2025-12-09T13:36:45.468Z" }, + { url = "https://files.pythonhosted.org/packages/05/e6/fb174f0069b7122e19828c551298bfd34fdf9480535d2a6ac2ed37afacd3/fonttools-4.60.2-cp312-cp312-win32.whl", hash = "sha256:57f07b616c69c244cc1a5a51072eeef07dddda5ebef9ca5c6e9cf6d59ae65b70", size = 2264674, upload-time = "2025-12-09T13:36:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/75/57/6552ffd6b582d3e6a9f01780c5275e6dfff1e70ca146101733aa1c12a129/fonttools-4.60.2-cp312-cp312-win_amd64.whl", hash = "sha256:310035802392f1fe5a7cf43d76f6ff4a24c919e4c72c0352e7b8176e2584b8a0", size = 2314701, upload-time = "2025-12-09T13:36:51.09Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e4/8381d0ca6b6c6c484660b03517ec5b5b81feeefca3808726dece36c652a9/fonttools-4.60.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2bb5fd231e56ccd7403212636dcccffc96c5ae0d6f9e4721fa0a32cb2e3ca432", size = 2842063, upload-time = "2025-12-09T13:36:53.468Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2c/4367117ee8ff4f4374787a1222da0bd413d80cf3522111f727a7b8f80d1d/fonttools-4.60.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:536b5fab7b6fec78ccf59b5c59489189d9d0a8b0d3a77ed1858be59afb096696", size = 2393792, upload-time = "2025-12-09T13:36:55.742Z" }, + { url = "https://files.pythonhosted.org/packages/49/b7/a76b6dffa193869e54e32ca2f9abb0d0e66784bc8a24e6f86eb093015481/fonttools-4.60.2-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b9288fc38252ac86a9570f19313ecbc9ff678982e0f27c757a85f1f284d3400", size = 4924020, upload-time = "2025-12-09T13:36:58.229Z" }, + { url = "https://files.pythonhosted.org/packages/bd/4e/0078200e2259f0061c86a74075f507d64c43dd2ab38971956a5c0012d344/fonttools-4.60.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93fcb420791d839ef592eada2b69997c445d0ce9c969b5190f2e16828ec10607", size = 4980070, upload-time = "2025-12-09T13:37:00.311Z" }, + { url = "https://files.pythonhosted.org/packages/85/1f/d87c85a11cb84852c975251581862681e4a0c1c3bd456c648792203f311b/fonttools-4.60.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7916a381b094db4052ac284255186aebf74c5440248b78860cb41e300036f598", size = 4921411, upload-time = "2025-12-09T13:37:02.345Z" }, + { url = "https://files.pythonhosted.org/packages/75/c0/7efad650f5ed8e317c2633133ef3c64917e7adf2e4e2940c798f5d57ec6e/fonttools-4.60.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:58c8c393d5e16b15662cfc2d988491940458aa87894c662154f50c7b49440bef", size = 5063465, upload-time = "2025-12-09T13:37:04.836Z" }, + { url = "https://files.pythonhosted.org/packages/18/a8/750518c4f8cdd79393b386bc81226047ade80239e58c6c9f5dbe1fdd8ea1/fonttools-4.60.2-cp313-cp313-win32.whl", hash = "sha256:19c6e0afd8b02008caa0aa08ab896dfce5d0bcb510c49b2c499541d5cb95a963", size = 2263443, upload-time = "2025-12-09T13:37:06.762Z" }, + { url = "https://files.pythonhosted.org/packages/b8/22/026c60376f165981f80a0e90bd98a79ae3334e9d89a3d046c4d2e265c724/fonttools-4.60.2-cp313-cp313-win_amd64.whl", hash = "sha256:6a500dc59e11b2338c2dba1f8cf11a4ae8be35ec24af8b2628b8759a61457b76", size = 2313800, upload-time = "2025-12-09T13:37:08.713Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ab/7cf1f5204e1366ddf9dc5cdc2789b571feb9eebcee0e3463c3f457df5f52/fonttools-4.60.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9387c532acbe323bbf2a920f132bce3c408a609d5f9dcfc6532fbc7e37f8ccbb", size = 2841690, upload-time = "2025-12-09T13:37:10.696Z" }, + { url = "https://files.pythonhosted.org/packages/00/3c/0bf83c6f863cc8b934952567fa2bf737cfcec8fc4ffb59b3f93820095f89/fonttools-4.60.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6f1c824185b5b8fb681297f315f26ae55abb0d560c2579242feea8236b1cfef", size = 2392191, upload-time = "2025-12-09T13:37:12.954Z" }, + { url = "https://files.pythonhosted.org/packages/00/f0/40090d148b8907fbea12e9bdf1ff149f30cdf1769e3b2c3e0dbf5106b88d/fonttools-4.60.2-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:55a3129d1e4030b1a30260f1b32fe76781b585fb2111d04a988e141c09eb6403", size = 4873503, upload-time = "2025-12-09T13:37:15.142Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e0/d8b13f99e58b8c293781288ba62fe634f1f0697c9c4c0ae104d3215f3a10/fonttools-4.60.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b196e63753abc33b3b97a6fd6de4b7c4fef5552c0a5ba5e562be214d1e9668e0", size = 4968493, upload-time = "2025-12-09T13:37:18.272Z" }, + { url = "https://files.pythonhosted.org/packages/46/c5/960764d12c92bc225f02401d3067048cb7b282293d9e48e39fe2b0ec38a9/fonttools-4.60.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:de76c8d740fb55745f3b154f0470c56db92ae3be27af8ad6c2e88f1458260c9a", size = 4920015, upload-time = "2025-12-09T13:37:20.334Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ab/839d8caf253d1eef3653ef4d34427d0326d17a53efaec9eb04056b670fff/fonttools-4.60.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ba6303225c95998c9fda2d410aa792c3d2c1390a09df58d194b03e17583fa25", size = 5031165, upload-time = "2025-12-09T13:37:23.57Z" }, + { url = "https://files.pythonhosted.org/packages/de/bf/3bc862796a6841cbe0725bb5512d272239b809dba631a4b0301df885e62d/fonttools-4.60.2-cp314-cp314-win32.whl", hash = "sha256:0a89728ce10d7c816fedaa5380c06d2793e7a8a634d7ce16810e536c22047384", size = 2267526, upload-time = "2025-12-09T13:37:25.821Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a1/c1909cacf00c76dc37b4743451561fbaaf7db4172c22a6d9394081d114c3/fonttools-4.60.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa8446e6ab8bd778b82cb1077058a2addba86f30de27ab9cc18ed32b34bc8667", size = 2319096, upload-time = "2025-12-09T13:37:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/29/b3/f66e71433f08e3a931b2b31a665aeed17fcc5e6911fc73529c70a232e421/fonttools-4.60.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4063bc81ac5a4137642865cb63dd270e37b3cd1f55a07c0d6e41d072699ccca2", size = 2925167, upload-time = "2025-12-09T13:37:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/2e/13/eeb491ff743594bbd0bee6e49422c03a59fe9c49002d3cc60eeb77414285/fonttools-4.60.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebfdb66fa69732ed604ab8e2a0431e6deff35e933a11d73418cbc7823d03b8e1", size = 2430923, upload-time = "2025-12-09T13:37:32.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/db609f785e460796e53c4dbc3874a5f4948477f27beceb5e2d24b2537666/fonttools-4.60.2-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50b10b3b1a72d1d54c61b0e59239e1a94c0958f4a06a1febf97ce75388dd91a4", size = 4877729, upload-time = "2025-12-09T13:37:35.858Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d6/85e4484dd4bfb03fee7bd370d65888cccbd3dee2681ee48c869dd5ccb23f/fonttools-4.60.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:beae16891a13b4a2ddec9b39b4de76092a3025e4d1c82362e3042b62295d5e4d", size = 5096003, upload-time = "2025-12-09T13:37:37.862Z" }, + { url = "https://files.pythonhosted.org/packages/30/49/1a98e44b71030b83d2046f981373b80571868259d98e6dae7bc20099dac6/fonttools-4.60.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:522f017fdb3766fd5d2d321774ef351cc6ce88ad4e6ac9efe643e4a2b9d528db", size = 4974410, upload-time = "2025-12-09T13:37:40.166Z" }, + { url = "https://files.pythonhosted.org/packages/42/07/d6f775d950ee8a841012472c7303f8819423d8cc3b4530915de7265ebfa2/fonttools-4.60.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:82cceceaf9c09a965a75b84a4b240dd3768e596ffb65ef53852681606fe7c9ba", size = 5002036, upload-time = "2025-12-09T13:37:42.639Z" }, + { url = "https://files.pythonhosted.org/packages/73/f6/ba6458f83ce1a9f8c3b17bd8f7b8a2205a126aac1055796b7e7cfebbd38f/fonttools-4.60.2-cp314-cp314t-win32.whl", hash = "sha256:bbfbc918a75437fe7e6d64d1b1e1f713237df1cf00f3a36dedae910b2ba01cee", size = 2330985, upload-time = "2025-12-09T13:37:45.157Z" }, + { url = "https://files.pythonhosted.org/packages/91/24/fea0ba4d3a32d4ed1103a1098bfd99dc78b5fe3bb97202920744a37b73dc/fonttools-4.60.2-cp314-cp314t-win_amd64.whl", hash = "sha256:0e5cd9b0830f6550d58c84f3ab151a9892b50c4f9d538c5603c0ce6fff2eb3f1", size = 2396226, upload-time = "2025-12-09T13:37:47.355Z" }, + { url = "https://files.pythonhosted.org/packages/79/6c/10280af05b44fafd1dff69422805061fa1af29270bc52dce031ac69540bf/fonttools-4.60.2-py3-none-any.whl", hash = "sha256:73cf92eeda67cf6ff10c8af56fc8f4f07c1647d989a979be9e388a49be26552a", size = 1144610, upload-time = "2025-12-09T13:38:09.5Z" }, ] [[package]] @@ -1886,18 +1733,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e2/de/747ad1aa49e902da9a4699081c282a1ed8ceed3b4d295fd99a6d286e09e4/google_genai-2.8.0-py3-none-any.whl", hash = "sha256:4da0a223a100f4b37f609a68b835e3326ab0fa313314dc0fd9d34e76ee293844", size = 832497, upload-time = "2026-06-03T22:55:36.598Z" }, ] -[[package]] -name = "googleapis-common-protos" -version = "1.72.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" }, -] - [[package]] name = "greenlet" version = "3.2.4" @@ -2022,15 +1857,15 @@ wheels = [ [[package]] name = "h2" -version = "4.3.0" +version = "4.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "hpack" }, { name = "hyperframe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/85/7c366e69d84c17bb778fe41419e1fbcce3033d5b7ce29bbffff0a98b859f/h2-4.4.1.tar.gz", hash = "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516", size = 2157281, upload-time = "2026-08-03T11:45:09.509Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/e85faf23bd72a92d1921e37d674ca56eb298a3c8be31fdecef0ff2b3aaac/h2-4.4.1-py3-none-any.whl", hash = "sha256:0e25f1462b23c9cb82d9eb02e28bc706dac2a68cb457c6a0d74d63c8a2a5d0e6", size = 62636, upload-time = "2026-08-03T11:44:59.164Z" }, ] [[package]] @@ -2064,11 +1899,11 @@ wheels = [ [[package]] name = "hpack" -version = "4.1.0" +version = "4.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, + { url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" }, ] [[package]] @@ -2084,42 +1919,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] -[[package]] -name = "httptools" -version = "0.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, - { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, - { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, - { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" }, - { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" }, - { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" }, - { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" }, - { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, - { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, - { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, - { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, - { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, - { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, - { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, - { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, - { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, - { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, - { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, - { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, - { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, - { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, - { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, - { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, - { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, - { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, - { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, -] - [[package]] name = "httpx" version = "0.28.1" @@ -2168,18 +1967,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/bd/1a875e0d592d447cbc02805fd3fe0f497714d6a2583f59d14fa9ebad96eb/huggingface_hub-0.36.0-py3-none-any.whl", hash = "sha256:7bcc9ad17d5b3f07b57c78e79d527102d08313caa278a641993acddcb894548d", size = 566094, upload-time = "2025-10-23T12:11:59.557Z" }, ] -[[package]] -name = "humanfriendly" -version = "10.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyreadline3", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" }, -] - [[package]] name = "hyperframe" version = "6.1.0" @@ -2221,32 +2008,11 @@ wheels = [ [[package]] name = "idna" -version = "3.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, -] - -[[package]] -name = "importlib-metadata" -version = "8.7.0" +version = "3.20" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "zipp" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, -] - -[[package]] -name = "importlib-resources" -version = "6.5.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cf/8c/f834fbf984f691b4f7ff60f50b514cc3de5cc08abfc3295564dd89c5e2e7/importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c", size = 44693, upload-time = "2025-01-03T18:51:56.698Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461, upload-time = "2025-01-03T18:51:54.306Z" }, + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, ] [[package]] @@ -2532,27 +2298,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/70/a07dcf4f62598c8ad579df241af55ced65bed76e42e45d3c368a6d82dbc1/kombu-5.5.4-py3-none-any.whl", hash = "sha256:a12ed0557c238897d8e518f1d1fdf84bd1516c5e305af2dacd85c2015115feb8", size = 210034, upload-time = "2025-06-01T10:19:20.436Z" }, ] -[[package]] -name = "kubernetes" -version = "34.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "durationpy" }, - { name = "google-auth" }, - { name = "python-dateutil" }, - { name = "pyyaml" }, - { name = "requests" }, - { name = "requests-oauthlib" }, - { name = "six" }, - { name = "urllib3" }, - { name = "websocket-client" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ef/55/3f880ef65f559cbed44a9aa20d3bdbc219a2c3a3bac4a30a513029b03ee9/kubernetes-34.1.0.tar.gz", hash = "sha256:8fe8edb0b5d290a2f3ac06596b23f87c658977d46b5f8df9d0f4ea83d0003912", size = 1083771, upload-time = "2025-09-29T20:23:49.283Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/ec/65f7d563aa4a62dd58777e8f6aa882f15db53b14eb29aba0c28a20f7eb26/kubernetes-34.1.0-py2.py3-none-any.whl", hash = "sha256:bffba2272534e224e6a7a74d582deb0b545b7c9879d2cd9e4aae9481d1f2cc2a", size = 2008380, upload-time = "2025-09-29T20:23:47.684Z" }, -] - [[package]] name = "langchain" version = "1.3.11" @@ -2820,7 +2565,7 @@ wheels = [ [[package]] name = "langsmith" -version = "0.8.14" +version = "0.8.18" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -2834,9 +2579,9 @@ dependencies = [ { name = "xxhash" }, { name = "zstandard" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/08/0c/9e4284d9c279490a2407249bd780e1075c2dd29bf6fa77e2b71e91859227/langsmith-0.8.14.tar.gz", hash = "sha256:a3e8feb178540a2866ed39faf11521a4b9e476bf94ab3acdb1491ee6f804cfda", size = 4499898, upload-time = "2026-06-10T19:55:10.607Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/d9/a6681aa9847bbbc5ec21abe20a5e233b94e5edcfe39624db607ac7e8ccb4/langsmith-0.8.18.tar.gz", hash = "sha256:32dde9c0e67e053e0fb738921fc8ced768af7b8fa83d7a0e3fd63597cf8776dd", size = 4526988, upload-time = "2026-06-19T13:12:17.123Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/06/402c8f09994d8b11600918ad19c3b210c7eb40f883c12276534ad3398225/langsmith-0.8.14-py3-none-any.whl", hash = "sha256:f4e50906449ebede47a1f01f65e035b653a3729942871d51b61396349219be7c", size = 481242, upload-time = "2026-06-10T19:55:08.728Z" }, + { url = "https://files.pythonhosted.org/packages/03/70/0e0cc80a3b064c8d6c8d697c3125ed86e39d5a7393ec6dc8b07cb1cf13c4/langsmith-0.8.18-py3-none-any.whl", hash = "sha256:3940183349993faef48e6c7d08e4822ee9cefd906b362d0e3c2d650314d2f282", size = 508108, upload-time = "2026-06-19T13:12:15.348Z" }, ] [[package]] @@ -3018,116 +2763,116 @@ wheels = [ [[package]] name = "lxml" -version = "6.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/d5/becbe1e2569b474a23f0c672ead8a29ac50b2dc1d5b9de184831bda8d14c/lxml-6.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:13e35cbc684aadf05d8711a5d1b5857c92e5e580efa9a0d2be197199c8def607", size = 8634365, upload-time = "2025-09-22T04:00:45.672Z" }, - { url = "https://files.pythonhosted.org/packages/28/66/1ced58f12e804644426b85d0bb8a4478ca77bc1761455da310505f1a3526/lxml-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b1675e096e17c6fe9c0e8c81434f5736c0739ff9ac6123c87c2d452f48fc938", size = 4650793, upload-time = "2025-09-22T04:00:47.783Z" }, - { url = "https://files.pythonhosted.org/packages/11/84/549098ffea39dfd167e3f174b4ce983d0eed61f9d8d25b7bf2a57c3247fc/lxml-6.0.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac6e5811ae2870953390452e3476694196f98d447573234592d30488147404d", size = 4944362, upload-time = "2025-09-22T04:00:49.845Z" }, - { url = "https://files.pythonhosted.org/packages/ac/bd/f207f16abf9749d2037453d56b643a7471d8fde855a231a12d1e095c4f01/lxml-6.0.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5aa0fc67ae19d7a64c3fe725dc9a1bb11f80e01f78289d05c6f62545affec438", size = 5083152, upload-time = "2025-09-22T04:00:51.709Z" }, - { url = "https://files.pythonhosted.org/packages/15/ae/bd813e87d8941d52ad5b65071b1affb48da01c4ed3c9c99e40abb266fbff/lxml-6.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de496365750cc472b4e7902a485d3f152ecf57bd3ba03ddd5578ed8ceb4c5964", size = 5023539, upload-time = "2025-09-22T04:00:53.593Z" }, - { url = "https://files.pythonhosted.org/packages/02/cd/9bfef16bd1d874fbe0cb51afb00329540f30a3283beb9f0780adbb7eec03/lxml-6.0.2-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:200069a593c5e40b8f6fc0d84d86d970ba43138c3e68619ffa234bc9bb806a4d", size = 5344853, upload-time = "2025-09-22T04:00:55.524Z" }, - { url = "https://files.pythonhosted.org/packages/b8/89/ea8f91594bc5dbb879734d35a6f2b0ad50605d7fb419de2b63d4211765cc/lxml-6.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d2de809c2ee3b888b59f995625385f74629707c9355e0ff856445cdcae682b7", size = 5225133, upload-time = "2025-09-22T04:00:57.269Z" }, - { url = "https://files.pythonhosted.org/packages/b9/37/9c735274f5dbec726b2db99b98a43950395ba3d4a1043083dba2ad814170/lxml-6.0.2-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:b2c3da8d93cf5db60e8858c17684c47d01fee6405e554fb55018dd85fc23b178", size = 4677944, upload-time = "2025-09-22T04:00:59.052Z" }, - { url = "https://files.pythonhosted.org/packages/20/28/7dfe1ba3475d8bfca3878365075abe002e05d40dfaaeb7ec01b4c587d533/lxml-6.0.2-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:442de7530296ef5e188373a1ea5789a46ce90c4847e597856570439621d9c553", size = 5284535, upload-time = "2025-09-22T04:01:01.335Z" }, - { url = "https://files.pythonhosted.org/packages/e7/cf/5f14bc0de763498fc29510e3532bf2b4b3a1c1d5d0dff2e900c16ba021ef/lxml-6.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2593c77efde7bfea7f6389f1ab249b15ed4aa5bc5cb5131faa3b843c429fbedb", size = 5067343, upload-time = "2025-09-22T04:01:03.13Z" }, - { url = "https://files.pythonhosted.org/packages/1c/b0/bb8275ab5472f32b28cfbbcc6db7c9d092482d3439ca279d8d6fa02f7025/lxml-6.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3e3cb08855967a20f553ff32d147e14329b3ae70ced6edc2f282b94afbc74b2a", size = 4725419, upload-time = "2025-09-22T04:01:05.013Z" }, - { url = "https://files.pythonhosted.org/packages/25/4c/7c222753bc72edca3b99dbadba1b064209bc8ed4ad448af990e60dcce462/lxml-6.0.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ed6c667fcbb8c19c6791bbf40b7268ef8ddf5a96940ba9404b9f9a304832f6c", size = 5275008, upload-time = "2025-09-22T04:01:07.327Z" }, - { url = "https://files.pythonhosted.org/packages/6c/8c/478a0dc6b6ed661451379447cdbec77c05741a75736d97e5b2b729687828/lxml-6.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b8f18914faec94132e5b91e69d76a5c1d7b0c73e2489ea8929c4aaa10b76bbf7", size = 5248906, upload-time = "2025-09-22T04:01:09.452Z" }, - { url = "https://files.pythonhosted.org/packages/2d/d9/5be3a6ab2784cdf9accb0703b65e1b64fcdd9311c9f007630c7db0cfcce1/lxml-6.0.2-cp311-cp311-win32.whl", hash = "sha256:6605c604e6daa9e0d7f0a2137bdc47a2e93b59c60a65466353e37f8272f47c46", size = 3610357, upload-time = "2025-09-22T04:01:11.102Z" }, - { url = "https://files.pythonhosted.org/packages/e2/7d/ca6fb13349b473d5732fb0ee3eec8f6c80fc0688e76b7d79c1008481bf1f/lxml-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e5867f2651016a3afd8dd2c8238baa66f1e2802f44bc17e236f547ace6647078", size = 4036583, upload-time = "2025-09-22T04:01:12.766Z" }, - { url = "https://files.pythonhosted.org/packages/ab/a2/51363b5ecd3eab46563645f3a2c3836a2fc67d01a1b87c5017040f39f567/lxml-6.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:4197fb2534ee05fd3e7afaab5d8bfd6c2e186f65ea7f9cd6a82809c887bd1285", size = 3680591, upload-time = "2025-09-22T04:01:14.874Z" }, - { url = "https://files.pythonhosted.org/packages/f3/c8/8ff2bc6b920c84355146cd1ab7d181bc543b89241cfb1ebee824a7c81457/lxml-6.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a59f5448ba2ceccd06995c95ea59a7674a10de0810f2ce90c9006f3cbc044456", size = 8661887, upload-time = "2025-09-22T04:01:17.265Z" }, - { url = "https://files.pythonhosted.org/packages/37/6f/9aae1008083bb501ef63284220ce81638332f9ccbfa53765b2b7502203cf/lxml-6.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8113639f3296706fbac34a30813929e29247718e88173ad849f57ca59754924", size = 4667818, upload-time = "2025-09-22T04:01:19.688Z" }, - { url = "https://files.pythonhosted.org/packages/f1/ca/31fb37f99f37f1536c133476674c10b577e409c0a624384147653e38baf2/lxml-6.0.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a8bef9b9825fa8bc816a6e641bb67219489229ebc648be422af695f6e7a4fa7f", size = 4950807, upload-time = "2025-09-22T04:01:21.487Z" }, - { url = "https://files.pythonhosted.org/packages/da/87/f6cb9442e4bada8aab5ae7e1046264f62fdbeaa6e3f6211b93f4c0dd97f1/lxml-6.0.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:65ea18d710fd14e0186c2f973dc60bb52039a275f82d3c44a0e42b43440ea534", size = 5109179, upload-time = "2025-09-22T04:01:23.32Z" }, - { url = "https://files.pythonhosted.org/packages/c8/20/a7760713e65888db79bbae4f6146a6ae5c04e4a204a3c48896c408cd6ed2/lxml-6.0.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c371aa98126a0d4c739ca93ceffa0fd7a5d732e3ac66a46e74339acd4d334564", size = 5023044, upload-time = "2025-09-22T04:01:25.118Z" }, - { url = "https://files.pythonhosted.org/packages/a2/b0/7e64e0460fcb36471899f75831509098f3fd7cd02a3833ac517433cb4f8f/lxml-6.0.2-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:700efd30c0fa1a3581d80a748157397559396090a51d306ea59a70020223d16f", size = 5359685, upload-time = "2025-09-22T04:01:27.398Z" }, - { url = "https://files.pythonhosted.org/packages/b9/e1/e5df362e9ca4e2f48ed6411bd4b3a0ae737cc842e96877f5bf9428055ab4/lxml-6.0.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c33e66d44fe60e72397b487ee92e01da0d09ba2d66df8eae42d77b6d06e5eba0", size = 5654127, upload-time = "2025-09-22T04:01:29.629Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192", size = 5253958, upload-time = "2025-09-22T04:01:31.535Z" }, - { url = "https://files.pythonhosted.org/packages/35/35/d955a070994725c4f7d80583a96cab9c107c57a125b20bb5f708fe941011/lxml-6.0.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:064fdadaf7a21af3ed1dcaa106b854077fbeada827c18f72aec9346847cd65d0", size = 4711541, upload-time = "2025-09-22T04:01:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/1e/be/667d17363b38a78c4bd63cfd4b4632029fd68d2c2dc81f25ce9eb5224dd5/lxml-6.0.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fbc74f42c3525ac4ffa4b89cbdd00057b6196bcefe8bce794abd42d33a018092", size = 5267426, upload-time = "2025-09-22T04:01:35.639Z" }, - { url = "https://files.pythonhosted.org/packages/ea/47/62c70aa4a1c26569bc958c9ca86af2bb4e1f614e8c04fb2989833874f7ae/lxml-6.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ddff43f702905a4e32bc24f3f2e2edfe0f8fde3277d481bffb709a4cced7a1f", size = 5064917, upload-time = "2025-09-22T04:01:37.448Z" }, - { url = "https://files.pythonhosted.org/packages/bd/55/6ceddaca353ebd0f1908ef712c597f8570cc9c58130dbb89903198e441fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6da5185951d72e6f5352166e3da7b0dc27aa70bd1090b0eb3f7f7212b53f1bb8", size = 4788795, upload-time = "2025-09-22T04:01:39.165Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e8/fd63e15da5e3fd4c2146f8bbb3c14e94ab850589beab88e547b2dbce22e1/lxml-6.0.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:57a86e1ebb4020a38d295c04fc79603c7899e0df71588043eb218722dabc087f", size = 5676759, upload-time = "2025-09-22T04:01:41.506Z" }, - { url = "https://files.pythonhosted.org/packages/76/47/b3ec58dc5c374697f5ba37412cd2728f427d056315d124dd4b61da381877/lxml-6.0.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2047d8234fe735ab77802ce5f2297e410ff40f5238aec569ad7c8e163d7b19a6", size = 5255666, upload-time = "2025-09-22T04:01:43.363Z" }, - { url = "https://files.pythonhosted.org/packages/19/93/03ba725df4c3d72afd9596eef4a37a837ce8e4806010569bedfcd2cb68fd/lxml-6.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f91fd2b2ea15a6800c8e24418c0775a1694eefc011392da73bc6cef2623b322", size = 5277989, upload-time = "2025-09-22T04:01:45.215Z" }, - { url = "https://files.pythonhosted.org/packages/c6/80/c06de80bfce881d0ad738576f243911fccf992687ae09fd80b734712b39c/lxml-6.0.2-cp312-cp312-win32.whl", hash = "sha256:3ae2ce7d6fedfb3414a2b6c5e20b249c4c607f72cb8d2bb7cc9c6ec7c6f4e849", size = 3611456, upload-time = "2025-09-22T04:01:48.243Z" }, - { url = "https://files.pythonhosted.org/packages/f7/d7/0cdfb6c3e30893463fb3d1e52bc5f5f99684a03c29a0b6b605cfae879cd5/lxml-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:72c87e5ee4e58a8354fb9c7c84cbf95a1c8236c127a5d1b7683f04bed8361e1f", size = 4011793, upload-time = "2025-09-22T04:01:50.042Z" }, - { url = "https://files.pythonhosted.org/packages/ea/7b/93c73c67db235931527301ed3785f849c78991e2e34f3fd9a6663ffda4c5/lxml-6.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:61cb10eeb95570153e0c0e554f58df92ecf5109f75eacad4a95baa709e26c3d6", size = 3672836, upload-time = "2025-09-22T04:01:52.145Z" }, - { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, - { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, - { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, - { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, - { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, - { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, - { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, - { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, - { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, - { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, - { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, - { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, - { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, - { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, - { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, - { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, - { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, - { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" }, - { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" }, - { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" }, - { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" }, - { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" }, - { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" }, - { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" }, - { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" }, - { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" }, - { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" }, - { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" }, - { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" }, - { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" }, - { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" }, - { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" }, - { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" }, - { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" }, - { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" }, - { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" }, - { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" }, - { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" }, - { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" }, - { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" }, - { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" }, - { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" }, - { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" }, - { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" }, - { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" }, - { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" }, - { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" }, - { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" }, - { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" }, - { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" }, - { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" }, - { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" }, - { url = "https://files.pythonhosted.org/packages/0b/11/29d08bc103a62c0eba8016e7ed5aeebbf1e4312e83b0b1648dd203b0e87d/lxml-6.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1c06035eafa8404b5cf475bb37a9f6088b0aca288d4ccc9d69389750d5543700", size = 3949829, upload-time = "2025-09-22T04:04:45.608Z" }, - { url = "https://files.pythonhosted.org/packages/12/b3/52ab9a3b31e5ab8238da241baa19eec44d2ab426532441ee607165aebb52/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c7d13103045de1bdd6fe5d61802565f1a3537d70cd3abf596aa0af62761921ee", size = 4226277, upload-time = "2025-09-22T04:04:47.754Z" }, - { url = "https://files.pythonhosted.org/packages/a0/33/1eaf780c1baad88224611df13b1c2a9dfa460b526cacfe769103ff50d845/lxml-6.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a3c150a95fbe5ac91de323aa756219ef9cf7fde5a3f00e2281e30f33fa5fa4f", size = 4330433, upload-time = "2025-09-22T04:04:49.907Z" }, - { url = "https://files.pythonhosted.org/packages/7a/c1/27428a2ff348e994ab4f8777d3a0ad510b6b92d37718e5887d2da99952a2/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60fa43be34f78bebb27812ed90f1925ec99560b0fa1decdb7d12b84d857d31e9", size = 4272119, upload-time = "2025-09-22T04:04:51.801Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d0/3020fa12bcec4ab62f97aab026d57c2f0cfd480a558758d9ca233bb6a79d/lxml-6.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21c73b476d3cfe836be731225ec3421fa2f048d84f6df6a8e70433dff1376d5a", size = 4417314, upload-time = "2025-09-22T04:04:55.024Z" }, - { url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" }, +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/28/30/9abc9e34c657c33834eaf6cd02124c61bdf5944d802aa48e69be8da3585d/lxml-6.1.0.tar.gz", hash = "sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13", size = 4197006, upload-time = "2026-04-18T04:32:51.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/5d/3bccad330292946f97962df9d5f2d3ae129cce6e212732a781e856b91e07/lxml-6.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cec05be8c876f92a5aa07b01d60bbb4d11cfbdd654cad0561c0d7b5c043a61b9", size = 8526232, upload-time = "2026-04-18T04:27:40.389Z" }, + { url = "https://files.pythonhosted.org/packages/a7/51/adc8826570a112f83bb4ddb3a2ab510bbc2ccd62c1b9fe1f34fae2d90b57/lxml-6.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9c03e048b6ce8e77b09c734e931584894ecd58d08296804ca2d0b184c933ce50", size = 4595448, upload-time = "2026-04-18T04:27:44.208Z" }, + { url = "https://files.pythonhosted.org/packages/54/84/5a9ec07cbe1d2334a6465f863b949a520d2699a755738986dcd3b6b89e3f/lxml-6.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:942454ff253da14218f972b23dc72fa4edf6c943f37edd19cd697618b626fac5", size = 4923771, upload-time = "2026-04-18T04:32:17.402Z" }, + { url = "https://files.pythonhosted.org/packages/a7/23/851cfa33b6b38adb628e45ad51fb27105fa34b2b3ba9d1d4aa7a9428dfe0/lxml-6.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d036ee7b99d5148072ac7c9b847193decdfeac633db350363f7bce4fff108f0e", size = 5068101, upload-time = "2026-04-18T04:32:21.437Z" }, + { url = "https://files.pythonhosted.org/packages/b0/38/41bf99c2023c6b79916ba057d83e9db21d642f473cac210201222882d38b/lxml-6.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ae5d8d5427f3cc317e7950f2da7ad276df0cfa37b8de2f5658959e618ea8512", size = 5002573, upload-time = "2026-04-18T04:32:25.373Z" }, + { url = "https://files.pythonhosted.org/packages/c2/20/053aa10bdc39747e1e923ce2d45413075e84f70a136045bb09e5eaca41d3/lxml-6.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:363e47283bde87051b821826e71dde47f107e08614e1aa312ba0c5711e77738c", size = 5202816, upload-time = "2026-04-18T04:32:29.393Z" }, + { url = "https://files.pythonhosted.org/packages/9a/da/bc710fad8bf04b93baee752c192eaa2210cd3a84f969d0be7830fea55802/lxml-6.1.0-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:f504d861d9f2a8f94020130adac88d66de93841707a23a86244263d1e54682f5", size = 5329999, upload-time = "2026-04-18T04:32:34.019Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/bf035dedbdf7fab49411aa52e4236f3445e98d38647d85419e6c0d2806b9/lxml-6.1.0-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:23a5dc68e08ed13331d61815c08f260f46b4a60fdd1640bbeb82cf89a9d90289", size = 4659643, upload-time = "2026-04-18T04:32:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4f/22be31f33727a5e4c7b01b0a874503026e50329b259d3587e0b923cf964b/lxml-6.1.0-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f15401d8d3dbf239e23c818afc10c7207f7b95f9a307e092122b6f86dd43209a", size = 5265963, upload-time = "2026-04-18T04:32:41.881Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2b/d44d0e5c79226017f4ab8c87a802ebe4f89f97e6585a8e4166dffcdd7b6e/lxml-6.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fcf3da95e93349e0647d48d4b36a12783105bcc74cb0c416952f9988410846a3", size = 5045444, upload-time = "2026-04-18T04:32:44.512Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c3/3f034fec1594c331a6dbf9491238fdcc9d66f68cc529e109ec75b97197e1/lxml-6.1.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:0d082495c5fcf426e425a6e28daaba1fcb6d8f854a4ff01effb1f1f381203eb9", size = 4712703, upload-time = "2026-04-18T04:32:47.16Z" }, + { url = "https://files.pythonhosted.org/packages/12/16/0b83fccc158218aca75a7aa33e97441df737950734246b9fffa39301603d/lxml-6.1.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e3c4f84b24a1fcba435157d111c4b755099c6ff00a3daee1ad281817de75ed11", size = 5252745, upload-time = "2026-04-18T04:32:50.427Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ee/12e6c1b39a77666c02eaa77f94a870aaf63c4ac3a497b2d52319448b01c6/lxml-6.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:976a6b39b1b13e8c354ad8d3f261f3a4ac6609518af91bdb5094760a08f132c4", size = 5226822, upload-time = "2026-04-18T04:32:53.437Z" }, + { url = "https://files.pythonhosted.org/packages/34/20/c7852904858b4723af01d2fc14b5d38ff57cb92f01934a127ebd9a9e51aa/lxml-6.1.0-cp311-cp311-win32.whl", hash = "sha256:857efde87d365706590847b916baff69c0bc9252dc5af030e378c9800c0b10e3", size = 3594026, upload-time = "2026-04-18T04:27:31.903Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/d60c732b56da5085175c07c74b2df4e6d181b0c9a61e1691474f06ef4b39/lxml-6.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:183bfb45a493081943be7ea2b5adfc2b611e1cf377cefa8b8a8be404f45ef9a7", size = 4025114, upload-time = "2026-04-18T04:27:34.077Z" }, + { url = "https://files.pythonhosted.org/packages/c2/df/c84dcc175fd690823436d15b41cb920cd5ba5e14cd8bfb00949d5903b320/lxml-6.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:19f4164243fc206d12ed3d866e80e74f5bc3627966520da1a5f97e42c32a3f39", size = 3667742, upload-time = "2026-04-18T04:27:38.45Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d4/9326838b59dc36dfae42eec9656b97520f9997eee1de47b8316aaeed169c/lxml-6.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d2f17a16cd8751e8eb233a7e41aecdf8e511712e00088bf9be455f604cd0d28d", size = 8570663, upload-time = "2026-04-18T04:27:48.253Z" }, + { url = "https://files.pythonhosted.org/packages/d8/a4/053745ce1f8303ccbb788b86c0db3a91b973675cefc42566a188637b7c40/lxml-6.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0cea5b1d3e6e77d71bd2b9972eb2446221a69dc52bb0b9c3c6f6e5700592d93", size = 4624024, upload-time = "2026-04-18T04:27:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/90/97/a517944b20f8fd0932ad2109482bee4e29fe721416387a363306667941f6/lxml-6.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fc46da94826188ed45cb53bd8e3fc076ae22675aea2087843d4735627f867c6d", size = 4930895, upload-time = "2026-04-18T04:32:56.29Z" }, + { url = "https://files.pythonhosted.org/packages/94/7c/e08a970727d556caa040a44773c7b7e3ad0f0d73dedc863543e9a8b931f2/lxml-6.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9147d8e386ec3b82c3b15d88927f734f565b0aaadef7def562b853adca45784a", size = 5093820, upload-time = "2026-04-18T04:32:58.94Z" }, + { url = "https://files.pythonhosted.org/packages/88/ee/2a5c2aa2c32016a226ca25d3e1056a8102ea6e1fe308bf50213586635400/lxml-6.1.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5715e0e28736a070f3f34a7ccc09e2fdcba0e3060abbcf61a1a5718ff6d6b105", size = 5005790, upload-time = "2026-04-18T04:33:01.272Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/a0db9be8f38ad6043ab9429487c128dd1d30f07956ef43040402f8da49e8/lxml-6.1.0-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4937460dc5df0cdd2f06a86c285c28afda06aefa3af949f9477d3e8df430c485", size = 5630827, upload-time = "2026-04-18T04:33:04.036Z" }, + { url = "https://files.pythonhosted.org/packages/31/ba/3c13d3fc24b7cacf675f808a3a1baabf43a30d0cd24c98f94548e9aa58eb/lxml-6.1.0-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc783ee3147e60a25aa0445ea82b3e8aabb83b240f2b95d32cb75587ff781814", size = 5240445, upload-time = "2026-04-18T04:33:06.87Z" }, + { url = "https://files.pythonhosted.org/packages/55/ba/eeef4ccba09b2212fe239f46c1692a98db1878e0872ae320756488878a94/lxml-6.1.0-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:40d9189f80075f2e1f88db21ef815a2b17b28adf8e50aaf5c789bfe737027f32", size = 5350121, upload-time = "2026-04-18T04:33:09.365Z" }, + { url = "https://files.pythonhosted.org/packages/7e/01/1da87c7b587c38d0cbe77a01aae3b9c1c49ed47d76918ef3db8fc151b1ca/lxml-6.1.0-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:05b9b8787e35bec69e68daf4952b2e6dfcfb0db7ecf1a06f8cdfbbac4eb71aad", size = 4694949, upload-time = "2026-04-18T04:33:11.628Z" }, + { url = "https://files.pythonhosted.org/packages/a1/88/7db0fe66d5aaf128443ee1623dec3db1576f3e4c17751ec0ef5866468590/lxml-6.1.0-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0f08beb0182e3e9a86fae124b3c47a7b41b7b69b225e1377db983802404e54", size = 5243901, upload-time = "2026-04-18T04:33:13.95Z" }, + { url = "https://files.pythonhosted.org/packages/00/a8/1346726af7d1f6fca1f11223ba34001462b0a3660416986d37641708d57c/lxml-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73becf6d8c81d4c76b1014dbd3584cb26d904492dcf73ca85dc8bff08dcd6d2d", size = 5048054, upload-time = "2026-04-18T04:33:16.965Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b7/85057012f035d1a0c87e02f8c723ca3c3e6e0728bcf4cb62080b21b1c1e3/lxml-6.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1ae225f66e5938f4fa29d37e009a3bb3b13032ac57eb4eb42afa44f6e4054e69", size = 4777324, upload-time = "2026-04-18T04:33:19.832Z" }, + { url = "https://files.pythonhosted.org/packages/75/6c/ad2f94a91073ef570f33718040e8e160d5fb93331cf1ab3ca1323f939e2d/lxml-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:690022c7fae793b0489aa68a658822cea83e0d5933781811cabbf5ea3bcfe73d", size = 5645702, upload-time = "2026-04-18T04:33:22.436Z" }, + { url = "https://files.pythonhosted.org/packages/3b/89/0bb6c0bd549c19004c60eea9dc554dd78fd647b72314ef25d460e0d208c6/lxml-6.1.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63aeafc26aac0be8aff14af7871249e87ea1319be92090bfd632ec68e03b16a5", size = 5232901, upload-time = "2026-04-18T04:33:26.21Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d9/d609a11fb567da9399f525193e2b49847b5a409cdebe737f06a8b7126bdc/lxml-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:264c605ab9c0e4aa1a679636f4582c4d3313700009fac3ec9c3412ed0d8f3e1d", size = 5261333, upload-time = "2026-04-18T04:33:28.984Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3a/ac3f99ec8ac93089e7dd556f279e0d14c24de0a74a507e143a2e4b496e7c/lxml-6.1.0-cp312-cp312-win32.whl", hash = "sha256:56971379bc5ee8037c5a0f09fa88f66cdb7d37c3e38af3e45cf539f41131ac1f", size = 3596289, upload-time = "2026-04-18T04:27:42.819Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a7/0a915557538593cb1bbeedcd40e13c7a261822c26fecbbdb71dad0c2f540/lxml-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:bba078de0031c219e5dd06cf3e6bf8fb8e6e64a77819b358f53bb132e3e03366", size = 3997059, upload-time = "2026-04-18T04:27:46.764Z" }, + { url = "https://files.pythonhosted.org/packages/92/96/a5dc078cf0126fbfbc35611d77ecd5da80054b5893e28fb213a5613b9e1d/lxml-6.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:c3592631e652afa34999a088f98ba7dfc7d6aff0d535c410bea77a71743f3819", size = 3659552, upload-time = "2026-04-18T04:27:51.133Z" }, + { url = "https://files.pythonhosted.org/packages/08/03/69347590f1cf4a6d5a4944bb6099e6d37f334784f16062234e1f892fdb1d/lxml-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a0092f2b107b69601adf562a57c956fbb596e05e3e6651cabd3054113b007e45", size = 8559689, upload-time = "2026-04-18T04:31:57.785Z" }, + { url = "https://files.pythonhosted.org/packages/3f/58/25e00bb40b185c974cfe156c110474d9a8a8390d5f7c92a4e328189bb60e/lxml-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc7140d7a7386e6b545d41b7358f4d02b656d4053f5fa6859f92f4b9c2572c4d", size = 4617892, upload-time = "2026-04-18T04:32:01.78Z" }, + { url = "https://files.pythonhosted.org/packages/f5/54/92ad98a94ac318dc4f97aaac22ff8d1b94212b2ae8af5b6e9b354bf825f7/lxml-6.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:419c58fc92cc3a2c3fa5f78c63dbf5da70c1fa9c1b25f25727ecee89a96c7de2", size = 4923489, upload-time = "2026-04-18T04:33:31.401Z" }, + { url = "https://files.pythonhosted.org/packages/15/3b/a20aecfab42bdf4f9b390590d345857ad3ffd7c51988d1c89c53a0c73faf/lxml-6.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:37fabd1452852636cf38ecdcc9dd5ca4bba7a35d6c53fa09725deeb894a87491", size = 5082162, upload-time = "2026-04-18T04:33:34.262Z" }, + { url = "https://files.pythonhosted.org/packages/45/26/2cdb3d281ac1bd175603e290cbe4bad6eff127c0f8de90bafd6f8548f0fd/lxml-6.1.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2853c8b2170cc6cd54a6b4d50d2c1a8a7aeca201f23804b4898525c7a152cfc", size = 4993247, upload-time = "2026-04-18T04:33:36.674Z" }, + { url = "https://files.pythonhosted.org/packages/f6/05/d735aef963740022a08185c84821f689fc903acb3d50326e6b1e9886cc22/lxml-6.1.0-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e369cbd690e788c8d15e56222d91a09c6a417f49cbc543040cba0fe2e25a79e", size = 5613042, upload-time = "2026-04-18T04:33:39.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b8/ead7c10efff731738c72e59ed6eb5791854879fbed7ae98781a12006263a/lxml-6.1.0-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e69aa6805905807186eb00e66c6d97a935c928275182eb02ee40ba00da9623b2", size = 5228304, upload-time = "2026-04-18T04:33:41.647Z" }, + { url = "https://files.pythonhosted.org/packages/6b/10/e9842d2ec322ea65f0a7270aa0315a53abed06058b88ef1b027f620e7a5f/lxml-6.1.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:4bd1bdb8a9e0e2dd229de19b5f8aebac80e916921b4b2c6ef8a52bc131d0c1f9", size = 5341578, upload-time = "2026-04-18T04:33:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/89/54/40d9403d7c2775fa7301d3ddd3464689bfe9ba71acc17dfff777071b4fdc/lxml-6.1.0-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:cbd7b79cdcb4986ad78a2662625882747f09db5e4cd7b2ae178a88c9c51b3dfe", size = 4700209, upload-time = "2026-04-18T04:33:47.552Z" }, + { url = "https://files.pythonhosted.org/packages/85/b2/bbdcc2cf45dfc7dfffef4fd97e5c47b15919b6a365247d95d6f684ef5e82/lxml-6.1.0-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:43e4d297f11080ec9d64a4b1ad7ac02b4484c9f0e2179d9c4ef78e886e747b88", size = 5232365, upload-time = "2026-04-18T04:33:50.249Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/b06875665e53aaba7127611a7bed3b7b9658e20b22bc2dd217a0b7ab0091/lxml-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cc16682cc987a3da00aa56a3aa3075b08edb10d9b1e476938cfdbee8f3b67181", size = 5043654, upload-time = "2026-04-18T04:33:52.71Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9c/e71a069d09641c1a7abeb30e693f828c7c90a41cbe3d650b2d734d876f85/lxml-6.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d6d8efe71429635f0559579092bb5e60560d7b9115ee38c4adbea35632e7fa24", size = 4769326, upload-time = "2026-04-18T04:33:55.244Z" }, + { url = "https://files.pythonhosted.org/packages/cc/06/7a9cd84b3d4ed79adf35f874750abb697dec0b4a81a836037b36e47c091a/lxml-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7e39ab3a28af7784e206d8606ec0e4bcad0190f63a492bca95e94e5a4aef7f6e", size = 5635879, upload-time = "2026-04-18T04:33:58.509Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f0/9d57916befc1e54c451712c7ee48e9e74e80ae4d03bdce49914e0aee42cd/lxml-6.1.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9eb667bf50856c4a58145f8ca2d5e5be160191e79eb9e30855a476191b3c3495", size = 5224048, upload-time = "2026-04-18T04:34:00.943Z" }, + { url = "https://files.pythonhosted.org/packages/99/75/90c4eefda0c08c92221fe0753db2d6699a4c628f76ff4465ec20dea84cc1/lxml-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7f4a77d6f7edf9230cee3e1f7f6764722a41604ee5681844f18db9a81ea0ec33", size = 5250241, upload-time = "2026-04-18T04:34:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/5e/73/16596f7e4e38fa33084b9ccbccc22a15f82a290a055126f2c1541236d2ff/lxml-6.1.0-cp313-cp313-win32.whl", hash = "sha256:28902146ffbe5222df411c5d19e5352490122e14447e98cd118907ee3fd6ee62", size = 3596938, upload-time = "2026-04-18T04:31:56.206Z" }, + { url = "https://files.pythonhosted.org/packages/8e/63/981401c5680c1eb30893f00a19641ac80db5d1e7086c62cb4b13ed813038/lxml-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:4a1503c56e4e2b38dc76f2f2da7bae69670c0f1933e27cfa34b2fa5876410b16", size = 3995728, upload-time = "2026-04-18T04:31:58.763Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e8/c358a38ac3e541d16a1b527e4e9cb78c0419b0506a070ace11777e5e8404/lxml-6.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:e0af85773850417d994d019741239b901b22c6680206f46a34766926e466141d", size = 3658372, upload-time = "2026-04-18T04:32:03.629Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/cee4cf203ef0bab5c52afc118da61d6b460c928f2893d40023cfa27e0b80/lxml-6.1.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ab863fd37458fed6456525f297d21239d987800c46e67da5ef04fc6b3dd93ac8", size = 8576713, upload-time = "2026-04-18T04:32:06.831Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a7/eda05babeb7e046839204eaf254cd4d7c9130ce2bbf0d9e90ea41af5654d/lxml-6.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6fd8b1df8254ff4fd93fd31da1fc15770bde23ac045be9bb1f87425702f61cc9", size = 4623874, upload-time = "2026-04-18T04:32:10.755Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e9/db5846de9b436b91890a62f29d80cd849ea17948a49bf532d5278ee69a9e/lxml-6.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:47024feaae386a92a146af0d2aeed65229bf6fff738e6a11dda6b0015fb8fd03", size = 4949535, upload-time = "2026-04-18T04:34:06.657Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ba/0d3593373dcae1d68f40dc3c41a5a92f2544e68115eb2f62319a4c2a6500/lxml-6.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3f00972f84450204cd5d93a5395965e348956aaceaadec693a22ec743f8ae3eb", size = 5086881, upload-time = "2026-04-18T04:34:09.556Z" }, + { url = "https://files.pythonhosted.org/packages/43/76/759a7484539ad1af0d125a9afe9c3fb5f82a8779fd1f5f56319d9e4ea2fd/lxml-6.1.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97faa0860e13b05b15a51fb4986421ef7a30f0b3334061c416e0981e9450ca4c", size = 5031305, upload-time = "2026-04-18T04:34:12.336Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b9/c1f0daf981a11e47636126901fd4ab82429e18c57aeb0fc3ad2940b42d8b/lxml-6.1.0-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:972a6451204798675407beaad97b868d0c733d9a74dafefc63120b81b8c2de28", size = 5647522, upload-time = "2026-04-18T04:34:14.89Z" }, + { url = "https://files.pythonhosted.org/packages/31/e6/1f533dcd205275363d9ba3511bcec52fa2df86abf8abe6a5f2c599f0dc31/lxml-6.1.0-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fe022f20bc4569ec66b63b3fb275a3d628d9d32da6326b2982584104db6d3086", size = 5239310, upload-time = "2026-04-18T04:34:17.652Z" }, + { url = "https://files.pythonhosted.org/packages/c3/8c/4175fb709c78a6e315ed814ed33be3defd8b8721067e70419a6cf6f971da/lxml-6.1.0-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:75c4c7c619a744f972f4451bf5adf6d0fb00992a1ffc9fd78e13b0bc817cc99f", size = 5350799, upload-time = "2026-04-18T04:34:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/6ffdebc5994975f0dde4acb59761902bd9d9bb84422b9a0bd239a7da9ca8/lxml-6.1.0-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3648f20d25102a22b6061c688beb3a805099ea4beb0a01ce62975d926944d292", size = 4697693, upload-time = "2026-04-18T04:34:23.541Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/565f36bd5c73294602d48e04d23f81ff4c8736be6ba5e1d1ec670ac9be80/lxml-6.1.0-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77b9f99b17cbf14026d1e618035077060fc7195dd940d025149f3e2e830fbfcb", size = 5250708, upload-time = "2026-04-18T04:34:26.001Z" }, + { url = "https://files.pythonhosted.org/packages/5a/11/a68ab9dd18c5c499404deb4005f4bc4e0e88e5b72cd755ad96efec81d18d/lxml-6.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32662519149fd7a9db354175aa5e417d83485a8039b8aaa62f873ceee7ea4cad", size = 5084737, upload-time = "2026-04-18T04:34:28.32Z" }, + { url = "https://files.pythonhosted.org/packages/ab/78/e8f41e2c74f4af564e6a0348aea69fb6daaefa64bc071ef469823d22cc18/lxml-6.1.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:73d658216fc173cf2c939e90e07b941c5e12736b0bf6a99e7af95459cfe8eabb", size = 4737817, upload-time = "2026-04-18T04:34:30.784Z" }, + { url = "https://files.pythonhosted.org/packages/06/2d/aa4e117aa2ce2f3b35d9ff246be74a2f8e853baba5d2a92c64744474603a/lxml-6.1.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ac4db068889f8772a4a698c5980ec302771bb545e10c4b095d4c8be26749616f", size = 5670753, upload-time = "2026-04-18T04:34:33.675Z" }, + { url = "https://files.pythonhosted.org/packages/08/f5/dd745d50c0409031dbfcc4881740542a01e54d6f0110bd420fa7782110b8/lxml-6.1.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:45e9dfbd1b661eb64ba0d4dbe762bd210c42d86dd1e5bd2bdf89d634231beb43", size = 5238071, upload-time = "2026-04-18T04:34:36.12Z" }, + { url = "https://files.pythonhosted.org/packages/3e/74/ad424f36d0340a904665867dab310a3f1f4c96ff4039698de83b77f44c1f/lxml-6.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:89e8d73d09ac696a5ba42ec69787913d53284f12092f651506779314f10ba585", size = 5264319, upload-time = "2026-04-18T04:34:39.035Z" }, + { url = "https://files.pythonhosted.org/packages/53/36/a15d8b3514ec889bfd6aa3609107fcb6c9189f8dc347f1c0b81eded8d87c/lxml-6.1.0-cp314-cp314-win32.whl", hash = "sha256:ebe33f4ec1b2de38ceb225a1749a2965855bffeef435ba93cd2d5d540783bf2f", size = 3657139, upload-time = "2026-04-18T04:32:20.006Z" }, + { url = "https://files.pythonhosted.org/packages/1a/a4/263ebb0710851a3c6c937180a9a86df1206fdfe53cc43005aa2237fd7736/lxml-6.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:398443df51c538bd578529aa7e5f7afc6c292644174b47961f3bf87fe5741120", size = 4064195, upload-time = "2026-04-18T04:32:23.876Z" }, + { url = "https://files.pythonhosted.org/packages/80/68/2000f29d323b6c286de077ad20b429fc52272e44eae6d295467043e56012/lxml-6.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:8c8984e1d8c4b3949e419158fda14d921ff703a9ed8a47236c6eb7a2b6cb4946", size = 3741870, upload-time = "2026-04-18T04:32:27.922Z" }, + { url = "https://files.pythonhosted.org/packages/30/e9/21383c7c8d43799f0da90224c0d7c921870d476ec9b3e01e1b2c0b8237c5/lxml-6.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1081dd10bc6fa437db2500e13993abf7cc30716d0a2f40e65abb935f02ec559c", size = 8827548, upload-time = "2026-04-18T04:32:15.094Z" }, + { url = "https://files.pythonhosted.org/packages/a5/01/c6bc11cd587030dd4f719f65c5657960649fe3e19196c844c75bf32cd0d6/lxml-6.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:dabecc48db5f42ba348d1f5d5afdc54c6c4cc758e676926c7cd327045749517d", size = 4735866, upload-time = "2026-04-18T04:32:18.924Z" }, + { url = "https://files.pythonhosted.org/packages/f3/01/757132fff5f4acf25463b5298f1a46099f3a94480b806547b29ce5e385de/lxml-6.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e3dd5fe19c9e0ac818a9c7f132a5e43c1339ec1cbbfecb1a938bd3a47875b7c9", size = 4969476, upload-time = "2026-04-18T04:34:41.889Z" }, + { url = "https://files.pythonhosted.org/packages/fd/fb/1bc8b9d27ed64be7c8903db6c89e74dc8c2cd9ec630a7462e4654316dc5b/lxml-6.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9e7b0a4ca6dcc007a4cef00a761bba2dea959de4bd2df98f926b33c92ca5dfb9", size = 5103719, upload-time = "2026-04-18T04:34:44.797Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e7/5bf82fa28133536a54601aae633b14988e89ed61d4c1eb6b899b023233aa/lxml-6.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d27bbe326c6b539c64b42638b18bc6003a8d88f76213a97ac9ed4f885efeab7", size = 5027890, upload-time = "2026-04-18T04:34:47.634Z" }, + { url = "https://files.pythonhosted.org/packages/2d/20/e048db5d4b4ea0366648aa595f26bb764b2670903fc585b87436d0a5032c/lxml-6.1.0-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4e425db0c5445ef0ad56b0eec54f89b88b2d884656e536a90b2f52aecb4ca86", size = 5596008, upload-time = "2026-04-18T04:34:51.503Z" }, + { url = "https://files.pythonhosted.org/packages/9a/c2/d10807bc8da4824b39e5bd01b5d05c077b6fd01bd91584167edf6b269d22/lxml-6.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b89b098105b8599dc57adac95d1813409ac476d3c948a498775d3d0c6124bfb", size = 5224451, upload-time = "2026-04-18T04:34:54.263Z" }, + { url = "https://files.pythonhosted.org/packages/3c/15/2ebea45bea427e7f0057e9ce7b2d62c5aba20c6b001cca89ed0aadb3ad41/lxml-6.1.0-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:c4a699432846df86cc3de502ee85f445ebad748a1c6021d445f3e514d2cd4b1c", size = 5312135, upload-time = "2026-04-18T04:34:56.818Z" }, + { url = "https://files.pythonhosted.org/packages/31/e2/87eeae151b0be2a308d49a7ec444ff3eb192b14251e62addb29d0bf3778f/lxml-6.1.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:30e7b2ed63b6c8e97cca8af048589a788ab5c9c905f36d9cf1c2bb549f450d2f", size = 4639126, upload-time = "2026-04-18T04:34:59.704Z" }, + { url = "https://files.pythonhosted.org/packages/a3/51/8a3f6a20902ad604dd746ec7b4000311b240d389dac5e9d95adefd349e0c/lxml-6.1.0-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:022981127642fe19866d2907d76241bb07ed21749601f727d5d5dd1ce5d1b773", size = 5232579, upload-time = "2026-04-18T04:35:02.658Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d2/650d619bdbe048d2c3f2c31edb00e35670a5e2d65b4fe3b61bce37b19121/lxml-6.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:23cad0cc86046d4222f7f418910e46b89971c5a45d3c8abfad0f64b7b05e4a9b", size = 5084206, upload-time = "2026-04-18T04:35:05.175Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8a/672ca1a3cbeabd1f511ca275a916c0514b747f4b85bdaae103b8fa92f307/lxml-6.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:21c3302068f50d1e8728c67c87ba92aa87043abee517aa2576cca1855326b405", size = 4758906, upload-time = "2026-04-18T04:35:08.098Z" }, + { url = "https://files.pythonhosted.org/packages/be/f1/ef4b691da85c916cb2feb1eec7414f678162798ac85e042fa164419ac05c/lxml-6.1.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:be10838781cb3be19251e276910cd508fe127e27c3242e50521521a0f3781690", size = 5620553, upload-time = "2026-04-18T04:35:11.23Z" }, + { url = "https://files.pythonhosted.org/packages/59/17/94e81def74107809755ac2782fdad4404420f1c92ca83433d117a6d5acf0/lxml-6.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2173a7bffe97667bbf0767f8a99e587740a8c56fdf3befac4b09cb29a80276fd", size = 5229458, upload-time = "2026-04-18T04:35:14.254Z" }, + { url = "https://files.pythonhosted.org/packages/21/55/c4be91b0f830a871fc1b0d730943d56013b683d4671d5198260e2eae722b/lxml-6.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c6854e9cf99c84beb004eecd7d3a3868ef1109bf2b1df92d7bc11e96a36c2180", size = 5247861, upload-time = "2026-04-18T04:35:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ca/77123e4d77df3cb1e968ade7b1f808f5d3a5c1c96b18a33895397de292c1/lxml-6.1.0-cp314-cp314t-win32.whl", hash = "sha256:00750d63ef0031a05331b9223463b1c7c02b9004cef2346a5b2877f0f9494dd2", size = 3897377, upload-time = "2026-04-18T04:32:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/64/ce/3554833989d074267c063209bae8b09815e5656456a2d332b947806b05ff/lxml-6.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:80410c3a7e3c617af04de17caa9f9f20adaa817093293d69eae7d7d0522836f5", size = 4392701, upload-time = "2026-04-18T04:32:12.113Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a0/9b916c68c0e57752c07f8f64b30138d9d4059dbeb27b90274dedbea128ff/lxml-6.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:26dd9f57ee3bd41e7d35b4c98a2ffd89ed11591649f421f0ec19f67d50ec67ac", size = 3817120, upload-time = "2026-04-18T04:32:15.803Z" }, + { url = "https://files.pythonhosted.org/packages/f2/88/55143966481409b1740a3ac669e611055f49efd68087a5ce41582325db3e/lxml-6.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:546b66c0dd1bb8d9fa89d7123e5fa19a8aff3a1f2141eb22df96112afb17b842", size = 3930134, upload-time = "2026-04-18T04:32:35.008Z" }, + { url = "https://files.pythonhosted.org/packages/b5/97/28b985c2983938d3cb696dd5501423afb90a8c3e869ef5d3c62569282c0f/lxml-6.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5cfa1a34df366d9dc0d5eaf420f4cf2bb1e1bebe1066d1c2fc28c179f8a4004c", size = 4210749, upload-time = "2026-04-18T04:36:03.626Z" }, + { url = "https://files.pythonhosted.org/packages/29/67/dfab2b7d58214921935ccea7ce9b3df9b7d46f305d12f0f532ac7cf6b804/lxml-6.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db88156fcf544cdbf0d95588051515cfdfd4c876fc66444eb98bceb5d6db76de", size = 4318463, upload-time = "2026-04-18T04:36:06.309Z" }, + { url = "https://files.pythonhosted.org/packages/32/a2/4ac7eb32a4d997dd352c32c32399aae27b3f268d440e6f9cfa405b575d2f/lxml-6.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:07f98f5496f96bf724b1e3c933c107f0cbf2745db18c03d2e13a291c3afd2635", size = 4251124, upload-time = "2026-04-18T04:36:09.056Z" }, + { url = "https://files.pythonhosted.org/packages/33/ef/d6abd850bb4822f9b720cfe36b547a558e694881010ff7d012191e8769c6/lxml-6.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4642e04449a1e164b5ff71ffd901ddb772dfabf5c9adf1b7be5dffe1212bc037", size = 4401758, upload-time = "2026-04-18T04:36:11.803Z" }, + { url = "https://files.pythonhosted.org/packages/40/44/3ee09a5b60cb44c4f2fbc1c9015cfd6ff5afc08f991cab295d3024dcbf2d/lxml-6.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:7da13bb6fbadfafb474e0226a30570a3445cfd47c86296f2446dafbd77079ace", size = 3508860, upload-time = "2026-04-18T04:32:48.619Z" }, ] [[package]] name = "mako" -version = "1.3.10" +version = "1.3.12" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, ] [[package]] @@ -3139,18 +2884,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/81/54e3ce63502cd085a0c556652a4e1b919c45a446bd1e5300e10c44c8c521/markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c", size = 107678, upload-time = "2025-11-03T19:51:13.887Z" }, ] -[[package]] -name = "markdown-it-py" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, -] - [[package]] name = "markdownify" version = "1.2.0" @@ -3240,14 +2973,14 @@ wheels = [ [[package]] name = "marshmallow" -version = "3.26.1" +version = "3.26.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ab/5e/5e53d26b42ab75491cda89b871dab9e97c840bf12c63ec58a1919710cd06/marshmallow-3.26.1.tar.gz", hash = "sha256:e6d8affb6cb61d39d26402096dc0aee12d5a26d490a121f118d2e81dc0719dc6", size = 221825, upload-time = "2025-02-03T15:32:25.093Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/79/de6c16cc902f4fc372236926b0ce2ab7845268dcc30fb2fbb7f71b418631/marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57", size = 222095, upload-time = "2025-12-22T06:53:53.309Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/34/75/51952c7b2d3873b44a0028b1bd26a25078c18f92f256608e8d1dc61b39fd/marshmallow-3.26.1-py3-none-any.whl", hash = "sha256:3350409f20a70a7e4e11a27661187b77cdcaeb20abca41c1454fe33636bea09c", size = 50878, upload-time = "2025-02-03T15:32:22.295Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/5108cb3ee4ba6501748c4908b908e55f42a5b66245b4cfe0c99326e1ef6e/marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73", size = 50964, upload-time = "2025-12-22T06:53:51.801Z" }, ] [[package]] @@ -3316,7 +3049,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.27.1" +version = "1.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -3334,18 +3067,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/38/83/d1efe7c2980d8a3afa476f4e3d42d53dd54c0ab94c27bee5d755b45c8b73/mcp-1.27.1.tar.gz", hash = "sha256:0f47e1820f8f8f941466b39749eb1d1839a04caddca2bc60e9d46e8a99914924", size = 608458, upload-time = "2026-05-08T16:50:12.601Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/73/42d9596facebdb533b7f0b86c1b0364ef350d1f8ba78b1052e8a58b48b65/mcp-1.27.1-py3-none-any.whl", hash = "sha256:1af3c4203b329430fde7a87b4fcb6392a041f5cb851fd68fc674016ab4e7c06f", size = 216260, upload-time = "2026-05-08T16:50:10.547Z" }, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/93/0142dc84a666daf8ad51a34268f34c12fd6fda4f3810c4be2504eecc8212/mcp-1.30.0.tar.gz", hash = "sha256:445414625fce5c295faa505bb11bacece661ab6f4028d57c935db57820b7a3e4", size = 680511, upload-time = "2026-09-07T14:34:15.845Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f4/e58bc33317c92a0203664daaf00bf6f41166cc0149e5d6870a03f7cd004a/mcp-1.30.0-py3-none-any.whl", hash = "sha256:666edb5009503e1047c9d60346a756f94b261f05cc2625f23d41c728ffc484d0", size = 234581, upload-time = "2026-09-07T14:34:14.266Z" }, ] [[package]] @@ -3380,102 +3104,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/2e/26ae250a0bee97dc7098a18343c54128e812ae56aeaa940d3f0444a86a99/miniopy_async-1.23.4-py3-none-any.whl", hash = "sha256:7aaf3332d8b82d97ace787cadbb837b454ac9beb7ba2017c3ae132e1443902be", size = 99374, upload-time = "2025-09-17T12:27:48.075Z" }, ] -[[package]] -name = "mmh3" -version = "5.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/af/f28c2c2f51f31abb4725f9a64bc7863d5f491f6539bd26aee2a1d21a649e/mmh3-5.2.0.tar.gz", hash = "sha256:1efc8fec8478e9243a78bb993422cf79f8ff85cb4cf6b79647480a31e0d950a8", size = 33582, upload-time = "2025-07-29T07:43:48.49Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/87/399567b3796e134352e11a8b973cd470c06b2ecfad5468fe580833be442b/mmh3-5.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7901c893e704ee3c65f92d39b951f8f34ccf8e8566768c58103fb10e55afb8c1", size = 56107, upload-time = "2025-07-29T07:41:57.07Z" }, - { url = "https://files.pythonhosted.org/packages/c3/09/830af30adf8678955b247d97d3d9543dd2fd95684f3cd41c0cd9d291da9f/mmh3-5.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4a5f5536b1cbfa72318ab3bfc8a8188b949260baed186b75f0abc75b95d8c051", size = 40635, upload-time = "2025-07-29T07:41:57.903Z" }, - { url = "https://files.pythonhosted.org/packages/07/14/eaba79eef55b40d653321765ac5e8f6c9ac38780b8a7c2a2f8df8ee0fb72/mmh3-5.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cedac4f4054b8f7859e5aed41aaa31ad03fce6851901a7fdc2af0275ac533c10", size = 40078, upload-time = "2025-07-29T07:41:58.772Z" }, - { url = "https://files.pythonhosted.org/packages/bb/26/83a0f852e763f81b2265d446b13ed6d49ee49e1fc0c47b9655977e6f3d81/mmh3-5.2.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eb756caf8975882630ce4e9fbbeb9d3401242a72528230422c9ab3a0d278e60c", size = 97262, upload-time = "2025-07-29T07:41:59.678Z" }, - { url = "https://files.pythonhosted.org/packages/00/7d/b7133b10d12239aeaebf6878d7eaf0bf7d3738c44b4aba3c564588f6d802/mmh3-5.2.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:097e13c8b8a66c5753c6968b7640faefe85d8e38992703c1f666eda6ef4c3762", size = 103118, upload-time = "2025-07-29T07:42:01.197Z" }, - { url = "https://files.pythonhosted.org/packages/7b/3e/62f0b5dce2e22fd5b7d092aba285abd7959ea2b17148641e029f2eab1ffa/mmh3-5.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a7c0c7845566b9686480e6a7e9044db4afb60038d5fabd19227443f0104eeee4", size = 106072, upload-time = "2025-07-29T07:42:02.601Z" }, - { url = "https://files.pythonhosted.org/packages/66/84/ea88bb816edfe65052c757a1c3408d65c4201ddbd769d4a287b0f1a628b2/mmh3-5.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:61ac226af521a572700f863d6ecddc6ece97220ce7174e311948ff8c8919a363", size = 112925, upload-time = "2025-07-29T07:42:03.632Z" }, - { url = "https://files.pythonhosted.org/packages/2e/13/c9b1c022807db575fe4db806f442d5b5784547e2e82cff36133e58ea31c7/mmh3-5.2.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:582f9dbeefe15c32a5fa528b79b088b599a1dfe290a4436351c6090f90ddebb8", size = 120583, upload-time = "2025-07-29T07:42:04.991Z" }, - { url = "https://files.pythonhosted.org/packages/8a/5f/0e2dfe1a38f6a78788b7eb2b23432cee24623aeabbc907fed07fc17d6935/mmh3-5.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2ebfc46b39168ab1cd44670a32ea5489bcbc74a25795c61b6d888c5c2cf654ed", size = 99127, upload-time = "2025-07-29T07:42:05.929Z" }, - { url = "https://files.pythonhosted.org/packages/77/27/aefb7d663b67e6a0c4d61a513c83e39ba2237e8e4557fa7122a742a23de5/mmh3-5.2.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1556e31e4bd0ac0c17eaf220be17a09c171d7396919c3794274cb3415a9d3646", size = 98544, upload-time = "2025-07-29T07:42:06.87Z" }, - { url = "https://files.pythonhosted.org/packages/ab/97/a21cc9b1a7c6e92205a1b5fa030cdf62277d177570c06a239eca7bd6dd32/mmh3-5.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:81df0dae22cd0da87f1c978602750f33d17fb3d21fb0f326c89dc89834fea79b", size = 106262, upload-time = "2025-07-29T07:42:07.804Z" }, - { url = "https://files.pythonhosted.org/packages/43/18/db19ae82ea63c8922a880e1498a75342311f8aa0c581c4dd07711473b5f7/mmh3-5.2.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:eba01ec3bd4a49b9ac5ca2bc6a73ff5f3af53374b8556fcc2966dd2af9eb7779", size = 109824, upload-time = "2025-07-29T07:42:08.735Z" }, - { url = "https://files.pythonhosted.org/packages/9f/f5/41dcf0d1969125fc6f61d8618b107c79130b5af50b18a4651210ea52ab40/mmh3-5.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e9a011469b47b752e7d20de296bb34591cdfcbe76c99c2e863ceaa2aa61113d2", size = 97255, upload-time = "2025-07-29T07:42:09.706Z" }, - { url = "https://files.pythonhosted.org/packages/32/b3/cce9eaa0efac1f0e735bb178ef9d1d2887b4927fe0ec16609d5acd492dda/mmh3-5.2.0-cp311-cp311-win32.whl", hash = "sha256:bc44fc2b886243d7c0d8daeb37864e16f232e5b56aaec27cc781d848264cfd28", size = 40779, upload-time = "2025-07-29T07:42:10.546Z" }, - { url = "https://files.pythonhosted.org/packages/7c/e9/3fa0290122e6d5a7041b50ae500b8a9f4932478a51e48f209a3879fe0b9b/mmh3-5.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:8ebf241072cf2777a492d0e09252f8cc2b3edd07dfdb9404b9757bffeb4f2cee", size = 41549, upload-time = "2025-07-29T07:42:11.399Z" }, - { url = "https://files.pythonhosted.org/packages/3a/54/c277475b4102588e6f06b2e9095ee758dfe31a149312cdbf62d39a9f5c30/mmh3-5.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:b5f317a727bba0e633a12e71228bc6a4acb4f471a98b1c003163b917311ea9a9", size = 39336, upload-time = "2025-07-29T07:42:12.209Z" }, - { url = "https://files.pythonhosted.org/packages/bf/6a/d5aa7edb5c08e0bd24286c7d08341a0446f9a2fbbb97d96a8a6dd81935ee/mmh3-5.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:384eda9361a7bf83a85e09447e1feafe081034af9dd428893701b959230d84be", size = 56141, upload-time = "2025-07-29T07:42:13.456Z" }, - { url = "https://files.pythonhosted.org/packages/08/49/131d0fae6447bc4a7299ebdb1a6fb9d08c9f8dcf97d75ea93e8152ddf7ab/mmh3-5.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c9da0d568569cc87315cb063486d761e38458b8ad513fedd3dc9263e1b81bcd", size = 40681, upload-time = "2025-07-29T07:42:14.306Z" }, - { url = "https://files.pythonhosted.org/packages/8f/6f/9221445a6bcc962b7f5ff3ba18ad55bba624bacdc7aa3fc0a518db7da8ec/mmh3-5.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86d1be5d63232e6eb93c50881aea55ff06eb86d8e08f9b5417c8c9b10db9db96", size = 40062, upload-time = "2025-07-29T07:42:15.08Z" }, - { url = "https://files.pythonhosted.org/packages/1e/d4/6bb2d0fef81401e0bb4c297d1eb568b767de4ce6fc00890bc14d7b51ecc4/mmh3-5.2.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bf7bee43e17e81671c447e9c83499f53d99bf440bc6d9dc26a841e21acfbe094", size = 97333, upload-time = "2025-07-29T07:42:16.436Z" }, - { url = "https://files.pythonhosted.org/packages/44/e0/ccf0daff8134efbb4fbc10a945ab53302e358c4b016ada9bf97a6bdd50c1/mmh3-5.2.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7aa18cdb58983ee660c9c400b46272e14fa253c675ed963d3812487f8ca42037", size = 103310, upload-time = "2025-07-29T07:42:17.796Z" }, - { url = "https://files.pythonhosted.org/packages/02/63/1965cb08a46533faca0e420e06aff8bbaf9690a6f0ac6ae6e5b2e4544687/mmh3-5.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9d032488fcec32d22be6542d1a836f00247f40f320844dbb361393b5b22773", size = 106178, upload-time = "2025-07-29T07:42:19.281Z" }, - { url = "https://files.pythonhosted.org/packages/c2/41/c883ad8e2c234013f27f92061200afc11554ea55edd1bcf5e1accd803a85/mmh3-5.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1861fb6b1d0453ed7293200139c0a9011eeb1376632e048e3766945b13313c5", size = 113035, upload-time = "2025-07-29T07:42:20.356Z" }, - { url = "https://files.pythonhosted.org/packages/df/b5/1ccade8b1fa625d634a18bab7bf08a87457e09d5ec8cf83ca07cbea9d400/mmh3-5.2.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:99bb6a4d809aa4e528ddfe2c85dd5239b78b9dd14be62cca0329db78505e7b50", size = 120784, upload-time = "2025-07-29T07:42:21.377Z" }, - { url = "https://files.pythonhosted.org/packages/77/1c/919d9171fcbdcdab242e06394464ccf546f7d0f3b31e0d1e3a630398782e/mmh3-5.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1f8d8b627799f4e2fcc7c034fed8f5f24dc7724ff52f69838a3d6d15f1ad4765", size = 99137, upload-time = "2025-07-29T07:42:22.344Z" }, - { url = "https://files.pythonhosted.org/packages/66/8a/1eebef5bd6633d36281d9fc83cf2e9ba1ba0e1a77dff92aacab83001cee4/mmh3-5.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b5995088dd7023d2d9f310a0c67de5a2b2e06a570ecfd00f9ff4ab94a67cde43", size = 98664, upload-time = "2025-07-29T07:42:23.269Z" }, - { url = "https://files.pythonhosted.org/packages/13/41/a5d981563e2ee682b21fb65e29cc0f517a6734a02b581359edd67f9d0360/mmh3-5.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1a5f4d2e59d6bba8ef01b013c472741835ad961e7c28f50c82b27c57748744a4", size = 106459, upload-time = "2025-07-29T07:42:24.238Z" }, - { url = "https://files.pythonhosted.org/packages/24/31/342494cd6ab792d81e083680875a2c50fa0c5df475ebf0b67784f13e4647/mmh3-5.2.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fd6e6c3d90660d085f7e73710eab6f5545d4854b81b0135a3526e797009dbda3", size = 110038, upload-time = "2025-07-29T07:42:25.629Z" }, - { url = "https://files.pythonhosted.org/packages/28/44/efda282170a46bb4f19c3e2b90536513b1d821c414c28469a227ca5a1789/mmh3-5.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c4a2f3d83879e3de2eb8cbf562e71563a8ed15ee9b9c2e77ca5d9f73072ac15c", size = 97545, upload-time = "2025-07-29T07:42:27.04Z" }, - { url = "https://files.pythonhosted.org/packages/68/8f/534ae319c6e05d714f437e7206f78c17e66daca88164dff70286b0e8ea0c/mmh3-5.2.0-cp312-cp312-win32.whl", hash = "sha256:2421b9d665a0b1ad724ec7332fb5a98d075f50bc51a6ff854f3a1882bd650d49", size = 40805, upload-time = "2025-07-29T07:42:28.032Z" }, - { url = "https://files.pythonhosted.org/packages/b8/f6/f6abdcfefcedab3c964868048cfe472764ed358c2bf6819a70dd4ed4ed3a/mmh3-5.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d80005b7634a3a2220f81fbeb94775ebd12794623bb2e1451701ea732b4aa3", size = 41597, upload-time = "2025-07-29T07:42:28.894Z" }, - { url = "https://files.pythonhosted.org/packages/15/fd/f7420e8cbce45c259c770cac5718badf907b302d3a99ec587ba5ce030237/mmh3-5.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:3d6bfd9662a20c054bc216f861fa330c2dac7c81e7fb8307b5e32ab5b9b4d2e0", size = 39350, upload-time = "2025-07-29T07:42:29.794Z" }, - { url = "https://files.pythonhosted.org/packages/d8/fa/27f6ab93995ef6ad9f940e96593c5dd24744d61a7389532b0fec03745607/mmh3-5.2.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:e79c00eba78f7258e5b354eccd4d7907d60317ced924ea4a5f2e9d83f5453065", size = 40874, upload-time = "2025-07-29T07:42:30.662Z" }, - { url = "https://files.pythonhosted.org/packages/11/9c/03d13bcb6a03438bc8cac3d2e50f80908d159b31a4367c2e1a7a077ded32/mmh3-5.2.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:956127e663d05edbeec54df38885d943dfa27406594c411139690485128525de", size = 42012, upload-time = "2025-07-29T07:42:31.539Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/0865d9765408a7d504f1789944e678f74e0888b96a766d578cb80b040999/mmh3-5.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c3dca4cb5b946ee91b3d6bb700d137b1cd85c20827f89fdf9c16258253489044", size = 39197, upload-time = "2025-07-29T07:42:32.374Z" }, - { url = "https://files.pythonhosted.org/packages/3e/12/76c3207bd186f98b908b6706c2317abb73756d23a4e68ea2bc94825b9015/mmh3-5.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:e651e17bfde5840e9e4174b01e9e080ce49277b70d424308b36a7969d0d1af73", size = 39840, upload-time = "2025-07-29T07:42:33.227Z" }, - { url = "https://files.pythonhosted.org/packages/5d/0d/574b6cce5555c9f2b31ea189ad44986755eb14e8862db28c8b834b8b64dc/mmh3-5.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:9f64bf06f4bf623325fda3a6d02d36cd69199b9ace99b04bb2d7fd9f89688504", size = 40644, upload-time = "2025-07-29T07:42:34.099Z" }, - { url = "https://files.pythonhosted.org/packages/52/82/3731f8640b79c46707f53ed72034a58baad400be908c87b0088f1f89f986/mmh3-5.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ddc63328889bcaee77b743309e5c7d2d52cee0d7d577837c91b6e7cc9e755e0b", size = 56153, upload-time = "2025-07-29T07:42:35.031Z" }, - { url = "https://files.pythonhosted.org/packages/4f/34/e02dca1d4727fd9fdeaff9e2ad6983e1552804ce1d92cc796e5b052159bb/mmh3-5.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bb0fdc451fb6d86d81ab8f23d881b8d6e37fc373a2deae1c02d27002d2ad7a05", size = 40684, upload-time = "2025-07-29T07:42:35.914Z" }, - { url = "https://files.pythonhosted.org/packages/8f/36/3dee40767356e104967e6ed6d102ba47b0b1ce2a89432239b95a94de1b89/mmh3-5.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b29044e1ffdb84fe164d0a7ea05c7316afea93c00f8ed9449cf357c36fc4f814", size = 40057, upload-time = "2025-07-29T07:42:36.755Z" }, - { url = "https://files.pythonhosted.org/packages/31/58/228c402fccf76eb39a0a01b8fc470fecf21965584e66453b477050ee0e99/mmh3-5.2.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:58981d6ea9646dbbf9e59a30890cbf9f610df0e4a57dbfe09215116fd90b0093", size = 97344, upload-time = "2025-07-29T07:42:37.675Z" }, - { url = "https://files.pythonhosted.org/packages/34/82/fc5ce89006389a6426ef28e326fc065b0fbaaed230373b62d14c889f47ea/mmh3-5.2.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e5634565367b6d98dc4aa2983703526ef556b3688ba3065edb4b9b90ede1c54", size = 103325, upload-time = "2025-07-29T07:42:38.591Z" }, - { url = "https://files.pythonhosted.org/packages/09/8c/261e85777c6aee1ebd53f2f17e210e7481d5b0846cd0b4a5c45f1e3761b8/mmh3-5.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0271ac12415afd3171ab9a3c7cbfc71dee2c68760a7dc9d05bf8ed6ddfa3a7a", size = 106240, upload-time = "2025-07-29T07:42:39.563Z" }, - { url = "https://files.pythonhosted.org/packages/70/73/2f76b3ad8a3d431824e9934403df36c0ddacc7831acf82114bce3c4309c8/mmh3-5.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:45b590e31bc552c6f8e2150ff1ad0c28dd151e9f87589e7eaf508fbdd8e8e908", size = 113060, upload-time = "2025-07-29T07:42:40.585Z" }, - { url = "https://files.pythonhosted.org/packages/9f/b9/7ea61a34e90e50a79a9d87aa1c0b8139a7eaf4125782b34b7d7383472633/mmh3-5.2.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bdde97310d59604f2a9119322f61b31546748499a21b44f6715e8ced9308a6c5", size = 120781, upload-time = "2025-07-29T07:42:41.618Z" }, - { url = "https://files.pythonhosted.org/packages/0f/5b/ae1a717db98c7894a37aeedbd94b3f99e6472a836488f36b6849d003485b/mmh3-5.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fc9c5f280438cf1c1a8f9abb87dc8ce9630a964120cfb5dd50d1e7ce79690c7a", size = 99174, upload-time = "2025-07-29T07:42:42.587Z" }, - { url = "https://files.pythonhosted.org/packages/e3/de/000cce1d799fceebb6d4487ae29175dd8e81b48e314cba7b4da90bcf55d7/mmh3-5.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c903e71fd8debb35ad2a4184c1316b3cb22f64ce517b4e6747f25b0a34e41266", size = 98734, upload-time = "2025-07-29T07:42:43.996Z" }, - { url = "https://files.pythonhosted.org/packages/79/19/0dc364391a792b72fbb22becfdeacc5add85cc043cd16986e82152141883/mmh3-5.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:eed4bba7ff8a0d37106ba931ab03bdd3915fbb025bcf4e1f0aa02bc8114960c5", size = 106493, upload-time = "2025-07-29T07:42:45.07Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b1/bc8c28e4d6e807bbb051fefe78e1156d7f104b89948742ad310612ce240d/mmh3-5.2.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1fdb36b940e9261aff0b5177c5b74a36936b902f473180f6c15bde26143681a9", size = 110089, upload-time = "2025-07-29T07:42:46.122Z" }, - { url = "https://files.pythonhosted.org/packages/3b/a2/d20f3f5c95e9c511806686c70d0a15479cc3941c5f322061697af1c1ff70/mmh3-5.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7303aab41e97adcf010a09efd8f1403e719e59b7705d5e3cfed3dd7571589290", size = 97571, upload-time = "2025-07-29T07:42:47.18Z" }, - { url = "https://files.pythonhosted.org/packages/7b/23/665296fce4f33488deec39a750ffd245cfc07aafb0e3ef37835f91775d14/mmh3-5.2.0-cp313-cp313-win32.whl", hash = "sha256:03e08c6ebaf666ec1e3d6ea657a2d363bb01effd1a9acfe41f9197decaef0051", size = 40806, upload-time = "2025-07-29T07:42:48.166Z" }, - { url = "https://files.pythonhosted.org/packages/59/b0/92e7103f3b20646e255b699e2d0327ce53a3f250e44367a99dc8be0b7c7a/mmh3-5.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:7fddccd4113e7b736706e17a239a696332360cbaddf25ae75b57ba1acce65081", size = 41600, upload-time = "2025-07-29T07:42:49.371Z" }, - { url = "https://files.pythonhosted.org/packages/99/22/0b2bd679a84574647de538c5b07ccaa435dbccc37815067fe15b90fe8dad/mmh3-5.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa0c966ee727aad5406d516375593c5f058c766b21236ab8985693934bb5085b", size = 39349, upload-time = "2025-07-29T07:42:50.268Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ca/a20db059a8a47048aaf550da14a145b56e9c7386fb8280d3ce2962dcebf7/mmh3-5.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e5015f0bb6eb50008bed2d4b1ce0f2a294698a926111e4bb202c0987b4f89078", size = 39209, upload-time = "2025-07-29T07:42:51.559Z" }, - { url = "https://files.pythonhosted.org/packages/98/dd/e5094799d55c7482d814b979a0fd608027d0af1b274bfb4c3ea3e950bfd5/mmh3-5.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:e0f3ed828d709f5b82d8bfe14f8856120718ec4bd44a5b26102c3030a1e12501", size = 39843, upload-time = "2025-07-29T07:42:52.536Z" }, - { url = "https://files.pythonhosted.org/packages/f4/6b/7844d7f832c85400e7cc89a1348e4e1fdd38c5a38415bb5726bbb8fcdb6c/mmh3-5.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:f35727c5118aba95f0397e18a1a5b8405425581bfe53e821f0fb444cbdc2bc9b", size = 40648, upload-time = "2025-07-29T07:42:53.392Z" }, - { url = "https://files.pythonhosted.org/packages/1f/bf/71f791f48a21ff3190ba5225807cbe4f7223360e96862c376e6e3fb7efa7/mmh3-5.2.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bc244802ccab5220008cb712ca1508cb6a12f0eb64ad62997156410579a1770", size = 56164, upload-time = "2025-07-29T07:42:54.267Z" }, - { url = "https://files.pythonhosted.org/packages/70/1f/f87e3d34d83032b4f3f0f528c6d95a98290fcacf019da61343a49dccfd51/mmh3-5.2.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ff3d50dc3fe8a98059f99b445dfb62792b5d006c5e0b8f03c6de2813b8376110", size = 40692, upload-time = "2025-07-29T07:42:55.234Z" }, - { url = "https://files.pythonhosted.org/packages/a6/e2/db849eaed07117086f3452feca8c839d30d38b830ac59fe1ce65af8be5ad/mmh3-5.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:37a358cc881fe796e099c1db6ce07ff757f088827b4e8467ac52b7a7ffdca647", size = 40068, upload-time = "2025-07-29T07:42:56.158Z" }, - { url = "https://files.pythonhosted.org/packages/df/6b/209af927207af77425b044e32f77f49105a0b05d82ff88af6971d8da4e19/mmh3-5.2.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b9a87025121d1c448f24f27ff53a5fe7b6ef980574b4a4f11acaabe702420d63", size = 97367, upload-time = "2025-07-29T07:42:57.037Z" }, - { url = "https://files.pythonhosted.org/packages/ca/e0/78adf4104c425606a9ce33fb351f790c76a6c2314969c4a517d1ffc92196/mmh3-5.2.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ba55d6ca32eeef8b2625e1e4bfc3b3db52bc63014bd7e5df8cc11bf2b036b12", size = 103306, upload-time = "2025-07-29T07:42:58.522Z" }, - { url = "https://files.pythonhosted.org/packages/a3/79/c2b89f91b962658b890104745b1b6c9ce38d50a889f000b469b91eeb1b9e/mmh3-5.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9ff37ba9f15637e424c2ab57a1a590c52897c845b768e4e0a4958084ec87f22", size = 106312, upload-time = "2025-07-29T07:42:59.552Z" }, - { url = "https://files.pythonhosted.org/packages/4b/14/659d4095528b1a209be90934778c5ffe312177d51e365ddcbca2cac2ec7c/mmh3-5.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a094319ec0db52a04af9fdc391b4d39a1bc72bc8424b47c4411afb05413a44b5", size = 113135, upload-time = "2025-07-29T07:43:00.745Z" }, - { url = "https://files.pythonhosted.org/packages/8d/6f/cd7734a779389a8a467b5c89a48ff476d6f2576e78216a37551a97e9e42a/mmh3-5.2.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c5584061fd3da584659b13587f26c6cad25a096246a481636d64375d0c1f6c07", size = 120775, upload-time = "2025-07-29T07:43:02.124Z" }, - { url = "https://files.pythonhosted.org/packages/1d/ca/8256e3b96944408940de3f9291d7e38a283b5761fe9614d4808fcf27bd62/mmh3-5.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecbfc0437ddfdced5e7822d1ce4855c9c64f46819d0fdc4482c53f56c707b935", size = 99178, upload-time = "2025-07-29T07:43:03.182Z" }, - { url = "https://files.pythonhosted.org/packages/8a/32/39e2b3cf06b6e2eb042c984dab8680841ac2a0d3ca6e0bea30db1f27b565/mmh3-5.2.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7b986d506a8e8ea345791897ba5d8ba0d9d8820cd4fc3e52dbe6de19388de2e7", size = 98738, upload-time = "2025-07-29T07:43:04.207Z" }, - { url = "https://files.pythonhosted.org/packages/61/d3/7bbc8e0e8cf65ebbe1b893ffa0467b7ecd1bd07c3bbf6c9db4308ada22ec/mmh3-5.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:38d899a156549da8ef6a9f1d6f7ef231228d29f8f69bce2ee12f5fba6d6fd7c5", size = 106510, upload-time = "2025-07-29T07:43:05.656Z" }, - { url = "https://files.pythonhosted.org/packages/10/99/b97e53724b52374e2f3859046f0eb2425192da356cb19784d64bc17bb1cf/mmh3-5.2.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d86651fa45799530885ba4dab3d21144486ed15285e8784181a0ab37a4552384", size = 110053, upload-time = "2025-07-29T07:43:07.204Z" }, - { url = "https://files.pythonhosted.org/packages/ac/62/3688c7d975ed195155671df68788c83fed6f7909b6ec4951724c6860cb97/mmh3-5.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c463d7c1c4cfc9d751efeaadd936bbba07b5b0ed81a012b3a9f5a12f0872bd6e", size = 97546, upload-time = "2025-07-29T07:43:08.226Z" }, - { url = "https://files.pythonhosted.org/packages/ca/3b/c6153250f03f71a8b7634cded82939546cdfba02e32f124ff51d52c6f991/mmh3-5.2.0-cp314-cp314-win32.whl", hash = "sha256:bb4fe46bdc6104fbc28db7a6bacb115ee6368ff993366bbd8a2a7f0076e6f0c0", size = 41422, upload-time = "2025-07-29T07:43:09.216Z" }, - { url = "https://files.pythonhosted.org/packages/74/01/a27d98bab083a435c4c07e9d1d720d4c8a578bf4c270bae373760b1022be/mmh3-5.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c7f0b342fd06044bedd0b6e72177ddc0076f54fd89ee239447f8b271d919d9b", size = 42135, upload-time = "2025-07-29T07:43:10.183Z" }, - { url = "https://files.pythonhosted.org/packages/cb/c9/dbba5507e95429b8b380e2ba091eff5c20a70a59560934dff0ad8392b8c8/mmh3-5.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:3193752fc05ea72366c2b63ff24b9a190f422e32d75fdeae71087c08fff26115", size = 39879, upload-time = "2025-07-29T07:43:11.106Z" }, - { url = "https://files.pythonhosted.org/packages/b5/d1/c8c0ef839c17258b9de41b84f663574fabcf8ac2007b7416575e0f65ff6e/mmh3-5.2.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:69fc339d7202bea69ef9bd7c39bfdf9fdabc8e6822a01eba62fb43233c1b3932", size = 57696, upload-time = "2025-07-29T07:43:11.989Z" }, - { url = "https://files.pythonhosted.org/packages/2f/55/95e2b9ff201e89f9fe37036037ab61a6c941942b25cdb7b6a9df9b931993/mmh3-5.2.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:12da42c0a55c9d86ab566395324213c319c73ecb0c239fad4726324212b9441c", size = 41421, upload-time = "2025-07-29T07:43:13.269Z" }, - { url = "https://files.pythonhosted.org/packages/77/79/9be23ad0b7001a4b22752e7693be232428ecc0a35068a4ff5c2f14ef8b20/mmh3-5.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f7f9034c7cf05ddfaac8d7a2e63a3c97a840d4615d0a0e65ba8bdf6f8576e3be", size = 40853, upload-time = "2025-07-29T07:43:14.888Z" }, - { url = "https://files.pythonhosted.org/packages/ac/1b/96b32058eda1c1dee8264900c37c359a7325c1f11f5ff14fd2be8e24eff9/mmh3-5.2.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:11730eeb16dfcf9674fdea9bb6b8e6dd9b40813b7eb839bc35113649eef38aeb", size = 109694, upload-time = "2025-07-29T07:43:15.816Z" }, - { url = "https://files.pythonhosted.org/packages/8d/6f/a2ae44cd7dad697b6dea48390cbc977b1e5ca58fda09628cbcb2275af064/mmh3-5.2.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:932a6eec1d2e2c3c9e630d10f7128d80e70e2d47fe6b8c7ea5e1afbd98733e65", size = 117438, upload-time = "2025-07-29T07:43:16.865Z" }, - { url = "https://files.pythonhosted.org/packages/a0/08/bfb75451c83f05224a28afeaf3950c7b793c0b71440d571f8e819cfb149a/mmh3-5.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ca975c51c5028947bbcfc24966517aac06a01d6c921e30f7c5383c195f87991", size = 120409, upload-time = "2025-07-29T07:43:18.207Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ea/8b118b69b2ff8df568f742387d1a159bc654a0f78741b31437dd047ea28e/mmh3-5.2.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5b0b58215befe0f0e120b828f7645e97719bbba9f23b69e268ed0ac7adde8645", size = 125909, upload-time = "2025-07-29T07:43:19.39Z" }, - { url = "https://files.pythonhosted.org/packages/3e/11/168cc0b6a30650032e351a3b89b8a47382da541993a03af91e1ba2501234/mmh3-5.2.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29c2b9ce61886809d0492a274a5a53047742dea0f703f9c4d5d223c3ea6377d3", size = 135331, upload-time = "2025-07-29T07:43:20.435Z" }, - { url = "https://files.pythonhosted.org/packages/31/05/e3a9849b1c18a7934c64e831492c99e67daebe84a8c2f2c39a7096a830e3/mmh3-5.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a367d4741ac0103f8198c82f429bccb9359f543ca542b06a51f4f0332e8de279", size = 110085, upload-time = "2025-07-29T07:43:21.92Z" }, - { url = "https://files.pythonhosted.org/packages/d9/d5/a96bcc306e3404601418b2a9a370baec92af84204528ba659fdfe34c242f/mmh3-5.2.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5a5dba98e514fb26241868f6eb90a7f7ca0e039aed779342965ce24ea32ba513", size = 111195, upload-time = "2025-07-29T07:43:23.066Z" }, - { url = "https://files.pythonhosted.org/packages/af/29/0fd49801fec5bff37198684e0849b58e0dab3a2a68382a357cfffb0fafc3/mmh3-5.2.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:941603bfd75a46023807511c1ac2f1b0f39cccc393c15039969806063b27e6db", size = 116919, upload-time = "2025-07-29T07:43:24.178Z" }, - { url = "https://files.pythonhosted.org/packages/2d/04/4f3c32b0a2ed762edca45d8b46568fc3668e34f00fb1e0a3b5451ec1281c/mmh3-5.2.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:132dd943451a7c7546978863d2f5a64977928410782e1a87d583cb60eb89e667", size = 123160, upload-time = "2025-07-29T07:43:25.26Z" }, - { url = "https://files.pythonhosted.org/packages/91/76/3d29eaa38821730633d6a240d36fa8ad2807e9dfd432c12e1a472ed211eb/mmh3-5.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f698733a8a494466432d611a8f0d1e026f5286dee051beea4b3c3146817e35d5", size = 110206, upload-time = "2025-07-29T07:43:26.699Z" }, - { url = "https://files.pythonhosted.org/packages/44/1c/ccf35892684d3a408202e296e56843743e0b4fb1629e59432ea88cdb3909/mmh3-5.2.0-cp314-cp314t-win32.whl", hash = "sha256:6d541038b3fc360ec538fc116de87462627944765a6750308118f8b509a8eec7", size = 41970, upload-time = "2025-07-29T07:43:27.666Z" }, - { url = "https://files.pythonhosted.org/packages/75/b2/b9e4f1e5adb5e21eb104588fcee2cd1eaa8308255173481427d5ecc4284e/mmh3-5.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e912b19cf2378f2967d0c08e86ff4c6c360129887f678e27e4dde970d21b3f4d", size = 43063, upload-time = "2025-07-29T07:43:28.582Z" }, - { url = "https://files.pythonhosted.org/packages/6a/fc/0e61d9a4e29c8679356795a40e48f647b4aad58d71bfc969f0f8f56fb912/mmh3-5.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:e7884931fe5e788163e7b3c511614130c2c59feffdc21112290a194487efb2e9", size = 40455, upload-time = "2025-07-29T07:43:29.563Z" }, -] - [[package]] name = "mpmath" version = "1.3.0" @@ -3692,15 +3320,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, ] -[[package]] -name = "oauthlib" -version = "3.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, -] - [[package]] name = "ollama" version = "0.6.2" @@ -3714,38 +3333,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/ab/d6722beeb2d10f7a3b9ff49375708904fde18f82b5609a0bc4aeb5996a4d/ollama-0.6.2-py3-none-any.whl", hash = "sha256:3ad7daab28e5a973445c36a73882a3ef698c2ebb00e21e308652741577509f7d", size = 15115, upload-time = "2026-04-29T21:21:13.794Z" }, ] -[[package]] -name = "onnxruntime" -version = "1.23.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "coloredlogs" }, - { name = "flatbuffers" }, - { name = "numpy" }, - { name = "packaging" }, - { name = "protobuf" }, - { name = "sympy" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/44/be/467b00f09061572f022ffd17e49e49e5a7a789056bad95b54dfd3bee73ff/onnxruntime-1.23.2-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:6f91d2c9b0965e86827a5ba01531d5b669770b01775b23199565d6c1f136616c", size = 17196113, upload-time = "2025-10-22T03:47:33.526Z" }, - { url = "https://files.pythonhosted.org/packages/9f/a8/3c23a8f75f93122d2b3410bfb74d06d0f8da4ac663185f91866b03f7da1b/onnxruntime-1.23.2-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:87d8b6eaf0fbeb6835a60a4265fde7a3b60157cf1b2764773ac47237b4d48612", size = 19153857, upload-time = "2025-10-22T03:46:37.578Z" }, - { url = "https://files.pythonhosted.org/packages/3f/d8/506eed9af03d86f8db4880a4c47cd0dffee973ef7e4f4cff9f1d4bcf7d22/onnxruntime-1.23.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbfd2fca76c855317568c1b36a885ddea2272c13cb0e395002c402f2360429a6", size = 15220095, upload-time = "2025-10-22T03:46:24.769Z" }, - { url = "https://files.pythonhosted.org/packages/e9/80/113381ba832d5e777accedc6cb41d10f9eca82321ae31ebb6bcede530cea/onnxruntime-1.23.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da44b99206e77734c5819aa2142c69e64f3b46edc3bd314f6a45a932defc0b3e", size = 17372080, upload-time = "2025-10-22T03:47:00.265Z" }, - { url = "https://files.pythonhosted.org/packages/3a/db/1b4a62e23183a0c3fe441782462c0ede9a2a65c6bbffb9582fab7c7a0d38/onnxruntime-1.23.2-cp311-cp311-win_amd64.whl", hash = "sha256:902c756d8b633ce0dedd889b7c08459433fbcf35e9c38d1c03ddc020f0648c6e", size = 13468349, upload-time = "2025-10-22T03:47:25.783Z" }, - { url = "https://files.pythonhosted.org/packages/1b/9e/f748cd64161213adeef83d0cb16cb8ace1e62fa501033acdd9f9341fff57/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:b8f029a6b98d3cf5be564d52802bb50a8489ab73409fa9db0bf583eabb7c2321", size = 17195929, upload-time = "2025-10-22T03:47:36.24Z" }, - { url = "https://files.pythonhosted.org/packages/91/9d/a81aafd899b900101988ead7fb14974c8a58695338ab6a0f3d6b0100f30b/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:218295a8acae83905f6f1aed8cacb8e3eb3bd7513a13fe4ba3b2664a19fc4a6b", size = 19157705, upload-time = "2025-10-22T03:46:40.415Z" }, - { url = "https://files.pythonhosted.org/packages/3c/35/4e40f2fba272a6698d62be2cd21ddc3675edfc1a4b9ddefcc4648f115315/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76ff670550dc23e58ea9bc53b5149b99a44e63b34b524f7b8547469aaa0dcb8c", size = 15226915, upload-time = "2025-10-22T03:46:27.773Z" }, - { url = "https://files.pythonhosted.org/packages/ef/88/9cc25d2bafe6bc0d4d3c1db3ade98196d5b355c0b273e6a5dc09c5d5d0d5/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f9b4ae77f8e3c9bee50c27bc1beede83f786fe1d52e99ac85aa8d65a01e9b77", size = 17382649, upload-time = "2025-10-22T03:47:02.782Z" }, - { url = "https://files.pythonhosted.org/packages/c0/b4/569d298f9fc4d286c11c45e85d9ffa9e877af12ace98af8cab52396e8f46/onnxruntime-1.23.2-cp312-cp312-win_amd64.whl", hash = "sha256:25de5214923ce941a3523739d34a520aac30f21e631de53bba9174dc9c004435", size = 13470528, upload-time = "2025-10-22T03:47:28.106Z" }, - { url = "https://files.pythonhosted.org/packages/3d/41/fba0cabccecefe4a1b5fc8020c44febb334637f133acefc7ec492029dd2c/onnxruntime-1.23.2-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:2ff531ad8496281b4297f32b83b01cdd719617e2351ffe0dba5684fb283afa1f", size = 17196337, upload-time = "2025-10-22T03:46:35.168Z" }, - { url = "https://files.pythonhosted.org/packages/fe/f9/2d49ca491c6a986acce9f1d1d5fc2099108958cc1710c28e89a032c9cfe9/onnxruntime-1.23.2-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:162f4ca894ec3de1a6fd53589e511e06ecdc3ff646849b62a9da7489dee9ce95", size = 19157691, upload-time = "2025-10-22T03:46:43.518Z" }, - { url = "https://files.pythonhosted.org/packages/1c/a1/428ee29c6eaf09a6f6be56f836213f104618fb35ac6cc586ff0f477263eb/onnxruntime-1.23.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45d127d6e1e9b99d1ebeae9bcd8f98617a812f53f46699eafeb976275744826b", size = 15226898, upload-time = "2025-10-22T03:46:30.039Z" }, - { url = "https://files.pythonhosted.org/packages/f2/2b/b57c8a2466a3126dbe0a792f56ad7290949b02f47b86216cd47d857e4b77/onnxruntime-1.23.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8bace4e0d46480fbeeb7bbe1ffe1f080e6663a42d1086ff95c1551f2d39e7872", size = 17382518, upload-time = "2025-10-22T03:47:05.407Z" }, - { url = "https://files.pythonhosted.org/packages/4a/93/aba75358133b3a941d736816dd392f687e7eab77215a6e429879080b76b6/onnxruntime-1.23.2-cp313-cp313-win_amd64.whl", hash = "sha256:1f9cc0a55349c584f083c1c076e611a7c35d5b867d5d6e6d6c823bf821978088", size = 13470276, upload-time = "2025-10-22T03:47:31.193Z" }, - { url = "https://files.pythonhosted.org/packages/7c/3d/6830fa61c69ca8e905f237001dbfc01689a4e4ab06147020a4518318881f/onnxruntime-1.23.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d2385e774f46ac38f02b3a91a91e30263d41b2f1f4f26ae34805b2a9ddef466", size = 15229610, upload-time = "2025-10-22T03:46:32.239Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ca/862b1e7a639460f0ca25fd5b6135fb42cf9deea86d398a92e44dfda2279d/onnxruntime-1.23.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2b9233c4947907fd1818d0e581c049c41ccc39b2856cc942ff6d26317cee145", size = 17394184, upload-time = "2025-10-22T03:47:08.127Z" }, -] - [[package]] name = "openai" version = "2.41.0" @@ -3805,88 +3392,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, ] -[[package]] -name = "opentelemetry-api" -version = "1.38.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "importlib-metadata" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/08/d8/0f354c375628e048bd0570645b310797299754730079853095bf000fba69/opentelemetry_api-1.38.0.tar.gz", hash = "sha256:f4c193b5e8acb0912b06ac5b16321908dd0843d75049c091487322284a3eea12", size = 65242, upload-time = "2025-10-16T08:35:50.25Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/a2/d86e01c28300bd41bab8f18afd613676e2bd63515417b77636fc1add426f/opentelemetry_api-1.38.0-py3-none-any.whl", hash = "sha256:2891b0197f47124454ab9f0cf58f3be33faca394457ac3e09daba13ff50aa582", size = 65947, upload-time = "2025-10-16T08:35:30.23Z" }, -] - -[[package]] -name = "opentelemetry-exporter-otlp-proto-common" -version = "1.38.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-proto" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/83/dd4660f2956ff88ed071e9e0e36e830df14b8c5dc06722dbde1841accbe8/opentelemetry_exporter_otlp_proto_common-1.38.0.tar.gz", hash = "sha256:e333278afab4695aa8114eeb7bf4e44e65c6607d54968271a249c180b2cb605c", size = 20431, upload-time = "2025-10-16T08:35:53.285Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/9e/55a41c9601191e8cd8eb626b54ee6827b9c9d4a46d736f32abc80d8039fc/opentelemetry_exporter_otlp_proto_common-1.38.0-py3-none-any.whl", hash = "sha256:03cb76ab213300fe4f4c62b7d8f17d97fcfd21b89f0b5ce38ea156327ddda74a", size = 18359, upload-time = "2025-10-16T08:35:34.099Z" }, -] - -[[package]] -name = "opentelemetry-exporter-otlp-proto-grpc" -version = "1.38.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "googleapis-common-protos" }, - { name = "grpcio" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-otlp-proto-common" }, - { name = "opentelemetry-proto" }, - { name = "opentelemetry-sdk" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a2/c0/43222f5b97dc10812bc4f0abc5dc7cd0a2525a91b5151d26c9e2e958f52e/opentelemetry_exporter_otlp_proto_grpc-1.38.0.tar.gz", hash = "sha256:2473935e9eac71f401de6101d37d6f3f0f1831db92b953c7dcc912536158ebd6", size = 24676, upload-time = "2025-10-16T08:35:53.83Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/28/f0/bd831afbdba74ca2ce3982142a2fad707f8c487e8a3b6fef01f1d5945d1b/opentelemetry_exporter_otlp_proto_grpc-1.38.0-py3-none-any.whl", hash = "sha256:7c49fd9b4bd0dbe9ba13d91f764c2d20b0025649a6e4ac35792fb8d84d764bc7", size = 19695, upload-time = "2025-10-16T08:35:35.053Z" }, -] - -[[package]] -name = "opentelemetry-proto" -version = "1.38.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/51/14/f0c4f0f6371b9cb7f9fa9ee8918bfd59ac7040c7791f1e6da32a1839780d/opentelemetry_proto-1.38.0.tar.gz", hash = "sha256:88b161e89d9d372ce723da289b7da74c3a8354a8e5359992be813942969ed468", size = 46152, upload-time = "2025-10-16T08:36:01.612Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/6a/82b68b14efca5150b2632f3692d627afa76b77378c4999f2648979409528/opentelemetry_proto-1.38.0-py3-none-any.whl", hash = "sha256:b6ebe54d3217c42e45462e2a1ae28c3e2bf2ec5a5645236a490f55f45f1a0a18", size = 72535, upload-time = "2025-10-16T08:35:45.749Z" }, -] - -[[package]] -name = "opentelemetry-sdk" -version = "1.38.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "opentelemetry-semantic-conventions" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/85/cb/f0eee1445161faf4c9af3ba7b848cc22a50a3d3e2515051ad8628c35ff80/opentelemetry_sdk-1.38.0.tar.gz", hash = "sha256:93df5d4d871ed09cb4272305be4d996236eedb232253e3ab864c8620f051cebe", size = 171942, upload-time = "2025-10-16T08:36:02.257Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/2e/e93777a95d7d9c40d270a371392b6d6f1ff170c2a3cb32d6176741b5b723/opentelemetry_sdk-1.38.0-py3-none-any.whl", hash = "sha256:1c66af6564ecc1553d72d811a01df063ff097cdc82ce188da9951f93b8d10f6b", size = 132349, upload-time = "2025-10-16T08:35:46.995Z" }, -] - -[[package]] -name = "opentelemetry-semantic-conventions" -version = "0.59b0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-api" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/40/bc/8b9ad3802cd8ac6583a4eb7de7e5d7db004e89cb7efe7008f9c8a537ee75/opentelemetry_semantic_conventions-0.59b0.tar.gz", hash = "sha256:7a6db3f30d70202d5bf9fa4b69bc866ca6a30437287de6c510fb594878aed6b0", size = 129861, upload-time = "2025-10-16T08:36:03.346Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/24/7d/c88d7b15ba8fe5c6b8f93be50fc11795e9fc05386c44afaf6b76fe191f9b/opentelemetry_semantic_conventions-0.59b0-py3-none-any.whl", hash = "sha256:35d3b8833ef97d614136e253c1da9342b4c3c083bbaf29ce31d572a1c3825eed", size = 207954, upload-time = "2025-10-16T08:35:48.054Z" }, -] - [[package]] name = "oracledb" version = "3.4.0" @@ -4034,15 +3539,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/03/f0/9696c6c6cf8ad35170f0be8d0ef3523cc258083535f6c8071cb8235ebb8b/ormsgpack-1.12.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b291d086e524a1062d57d1b7b5a8bcaaf29caebf0212fec12fd86240bd33633", size = 208316, upload-time = "2025-11-04T18:30:08.663Z" }, ] -[[package]] -name = "overrides" -version = "7.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/36/86/b585f53236dec60aba864e050778b25045f857e17f6e5ea0ae95fe80edd2/overrides-7.7.0.tar.gz", hash = "sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a", size = 22812, upload-time = "2024-01-27T21:01:33.423Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/ab/fc8290c6a4c722e5514d80f62b2dc4c4df1a68a41d1364e625c35990fcf3/overrides-7.7.0-py3-none-any.whl", hash = "sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49", size = 17832, upload-time = "2024-01-27T21:01:31.393Z" }, -] - [[package]] name = "packaging" version = "25.0" @@ -4108,89 +3604,87 @@ wheels = [ [[package]] name = "pillow" -version = "12.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/5a/a2f6773b64edb921a756eb0729068acad9fc5208a53f4a349396e9436721/pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc", size = 5289798, upload-time = "2025-10-15T18:21:47.763Z" }, - { url = "https://files.pythonhosted.org/packages/2e/05/069b1f8a2e4b5a37493da6c5868531c3f77b85e716ad7a590ef87d58730d/pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257", size = 4650589, upload-time = "2025-10-15T18:21:49.515Z" }, - { url = "https://files.pythonhosted.org/packages/61/e3/2c820d6e9a36432503ead175ae294f96861b07600a7156154a086ba7111a/pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642", size = 6230472, upload-time = "2025-10-15T18:21:51.052Z" }, - { url = "https://files.pythonhosted.org/packages/4f/89/63427f51c64209c5e23d4d52071c8d0f21024d3a8a487737caaf614a5795/pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3", size = 8033887, upload-time = "2025-10-15T18:21:52.604Z" }, - { url = "https://files.pythonhosted.org/packages/f6/1b/c9711318d4901093c15840f268ad649459cd81984c9ec9887756cca049a5/pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c", size = 6343964, upload-time = "2025-10-15T18:21:54.619Z" }, - { url = "https://files.pythonhosted.org/packages/41/1e/db9470f2d030b4995083044cd8738cdd1bf773106819f6d8ba12597d5352/pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227", size = 7034756, upload-time = "2025-10-15T18:21:56.151Z" }, - { url = "https://files.pythonhosted.org/packages/cc/b0/6177a8bdd5ee4ed87cba2de5a3cc1db55ffbbec6176784ce5bb75aa96798/pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b", size = 6458075, upload-time = "2025-10-15T18:21:57.759Z" }, - { url = "https://files.pythonhosted.org/packages/bc/5e/61537aa6fa977922c6a03253a0e727e6e4a72381a80d63ad8eec350684f2/pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e", size = 7125955, upload-time = "2025-10-15T18:21:59.372Z" }, - { url = "https://files.pythonhosted.org/packages/1f/3d/d5033539344ee3cbd9a4d69e12e63ca3a44a739eb2d4c8da350a3d38edd7/pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739", size = 6298440, upload-time = "2025-10-15T18:22:00.982Z" }, - { url = "https://files.pythonhosted.org/packages/4d/42/aaca386de5cc8bd8a0254516957c1f265e3521c91515b16e286c662854c4/pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e", size = 6999256, upload-time = "2025-10-15T18:22:02.617Z" }, - { url = "https://files.pythonhosted.org/packages/ba/f1/9197c9c2d5708b785f631a6dfbfa8eb3fb9672837cb92ae9af812c13b4ed/pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d", size = 2436025, upload-time = "2025-10-15T18:22:04.598Z" }, - { url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377, upload-time = "2025-10-15T18:22:05.993Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343, upload-time = "2025-10-15T18:22:07.718Z" }, - { url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981, upload-time = "2025-10-15T18:22:09.287Z" }, - { url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399, upload-time = "2025-10-15T18:22:10.872Z" }, - { url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740, upload-time = "2025-10-15T18:22:12.769Z" }, - { url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201, upload-time = "2025-10-15T18:22:14.813Z" }, - { url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334, upload-time = "2025-10-15T18:22:16.375Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162, upload-time = "2025-10-15T18:22:17.996Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769, upload-time = "2025-10-15T18:22:19.923Z" }, - { url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107, upload-time = "2025-10-15T18:22:21.644Z" }, - { url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012, upload-time = "2025-10-15T18:22:23.621Z" }, - { url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" }, - { url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" }, - { url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" }, - { url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" }, - { url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" }, - { url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" }, - { url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" }, - { url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" }, - { url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" }, - { url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" }, - { url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" }, - { url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" }, - { url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" }, - { url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" }, - { url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" }, - { url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" }, - { url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" }, - { url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" }, - { url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" }, - { url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" }, - { url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" }, - { url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" }, - { url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" }, - { url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" }, - { url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" }, - { url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" }, - { url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" }, - { url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" }, - { url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" }, - { url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" }, - { url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" }, - { url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" }, - { url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" }, - { url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" }, - { url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" }, - { url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" }, - { url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" }, - { url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" }, - { url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" }, - { url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" }, - { url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" }, - { url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" }, - { url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" }, - { url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" }, - { url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" }, - { url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" }, - { url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" }, - { url = "https://files.pythonhosted.org/packages/1d/b3/582327e6c9f86d037b63beebe981425d6811104cb443e8193824ef1a2f27/pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8", size = 5215068, upload-time = "2025-10-15T18:23:59.594Z" }, - { url = "https://files.pythonhosted.org/packages/fd/d6/67748211d119f3b6540baf90f92fae73ae51d5217b171b0e8b5f7e5d558f/pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a", size = 4614994, upload-time = "2025-10-15T18:24:01.669Z" }, - { url = "https://files.pythonhosted.org/packages/2d/e1/f8281e5d844c41872b273b9f2c34a4bf64ca08905668c8ae730eedc7c9fa/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197", size = 5246639, upload-time = "2025-10-15T18:24:03.403Z" }, - { url = "https://files.pythonhosted.org/packages/94/5a/0d8ab8ffe8a102ff5df60d0de5af309015163bf710c7bb3e8311dd3b3ad0/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c", size = 6986839, upload-time = "2025-10-15T18:24:05.344Z" }, - { url = "https://files.pythonhosted.org/packages/20/2e/3434380e8110b76cd9eb00a363c484b050f949b4bbe84ba770bb8508a02c/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e", size = 5313505, upload-time = "2025-10-15T18:24:07.137Z" }, - { url = "https://files.pythonhosted.org/packages/57/ca/5a9d38900d9d74785141d6580950fe705de68af735ff6e727cb911b64740/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76", size = 5963654, upload-time = "2025-10-15T18:24:09.579Z" }, - { url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850, upload-time = "2025-10-15T18:24:11.495Z" }, +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, ] [[package]] @@ -4257,22 +3751,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/b2/78d588d5acd1cc195bbbc26e9810a75371fdfd47489a653df4476867f220/postgrest-2.24.0-py3-none-any.whl", hash = "sha256:2127b7ff70c3e917791c17d4adfe36d1b721d5999eeda9d4ad3862d1bb6d15ae", size = 21581, upload-time = "2025-11-07T17:08:09.789Z" }, ] -[[package]] -name = "posthog" -version = "5.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "backoff" }, - { name = "distro" }, - { name = "python-dateutil" }, - { name = "requests" }, - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/48/20/60ae67bb9d82f00427946218d49e2e7e80fb41c15dc5019482289ec9ce8d/posthog-5.4.0.tar.gz", hash = "sha256:701669261b8d07cdde0276e5bc096b87f9e200e3b9589c5ebff14df658c5893c", size = 88076, upload-time = "2025-06-20T23:19:23.485Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/98/e480cab9a08d1c09b1c59a93dade92c1bb7544826684ff2acbfd10fcfbd4/posthog-5.4.0-py3-none-any.whl", hash = "sha256:284dfa302f64353484420b52d4ad81ff5c2c2d1d607c4e2db602ac72761831bd", size = 105364, upload-time = "2025-06-20T23:19:22.001Z" }, -] - [[package]] name = "prompt-toolkit" version = "3.0.52" @@ -4386,17 +3864,17 @@ wheels = [ [[package]] name = "protobuf" -version = "6.33.0" +version = "6.33.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/ff/64a6c8f420818bb873713988ca5492cba3a7946be57e027ac63495157d97/protobuf-6.33.0.tar.gz", hash = "sha256:140303d5c8d2037730c548f8c7b93b20bb1dc301be280c378b82b8894589c954", size = 443463, upload-time = "2025-10-15T20:39:52.159Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/ee/52b3fa8feb6db4a833dfea4943e175ce645144532e8a90f72571ad85df4e/protobuf-6.33.0-cp310-abi3-win32.whl", hash = "sha256:d6101ded078042a8f17959eccd9236fb7a9ca20d3b0098bbcb91533a5680d035", size = 425593, upload-time = "2025-10-15T20:39:40.29Z" }, - { url = "https://files.pythonhosted.org/packages/7b/c6/7a465f1825872c55e0341ff4a80198743f73b69ce5d43ab18043699d1d81/protobuf-6.33.0-cp310-abi3-win_amd64.whl", hash = "sha256:9a031d10f703f03768f2743a1c403af050b6ae1f3480e9c140f39c45f81b13ee", size = 436882, upload-time = "2025-10-15T20:39:42.841Z" }, - { url = "https://files.pythonhosted.org/packages/e1/a9/b6eee662a6951b9c3640e8e452ab3e09f117d99fc10baa32d1581a0d4099/protobuf-6.33.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:905b07a65f1a4b72412314082c7dbfae91a9e8b68a0cc1577515f8df58ecf455", size = 427521, upload-time = "2025-10-15T20:39:43.803Z" }, - { url = "https://files.pythonhosted.org/packages/10/35/16d31e0f92c6d2f0e77c2a3ba93185130ea13053dd16200a57434c882f2b/protobuf-6.33.0-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e0697ece353e6239b90ee43a9231318302ad8353c70e6e45499fa52396debf90", size = 324445, upload-time = "2025-10-15T20:39:44.932Z" }, - { url = "https://files.pythonhosted.org/packages/e6/eb/2a981a13e35cda8b75b5585aaffae2eb904f8f351bdd3870769692acbd8a/protobuf-6.33.0-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:e0a1715e4f27355afd9570f3ea369735afc853a6c3951a6afe1f80d8569ad298", size = 339159, upload-time = "2025-10-15T20:39:46.186Z" }, - { url = "https://files.pythonhosted.org/packages/21/51/0b1cbad62074439b867b4e04cc09b93f6699d78fd191bed2bbb44562e077/protobuf-6.33.0-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:35be49fd3f4fefa4e6e2aacc35e8b837d6703c37a2168a55ac21e9b1bc7559ef", size = 323172, upload-time = "2025-10-15T20:39:47.465Z" }, - { url = "https://files.pythonhosted.org/packages/07/d1/0a28c21707807c6aacd5dc9c3704b2aa1effbf37adebd8caeaf68b17a636/protobuf-6.33.0-py3-none-any.whl", hash = "sha256:25c9e1963c6734448ea2d308cfa610e692b801304ba0908d7bfa564ac5132995", size = 170477, upload-time = "2025-10-15T20:39:51.311Z" }, + { url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" }, + { url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" }, + { url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" }, + { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" }, ] [[package]] @@ -4453,11 +3931,11 @@ wheels = [ [[package]] name = "pyasn1" -version = "0.6.1" +version = "0.6.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" }, ] [[package]] @@ -4472,147 +3950,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, ] -[[package]] -name = "pybase64" -version = "1.4.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/14/43297a7b7f0c1bf0c00b596f754ee3ac946128c64d21047ccf9c9bbc5165/pybase64-1.4.2.tar.gz", hash = "sha256:46cdefd283ed9643315d952fe44de80dc9b9a811ce6e3ec97fd1827af97692d0", size = 137246, upload-time = "2025-07-27T13:08:57.808Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/fb/edaa56bbf04715efc3c36966cc0150e01d7a8336c3da182f850b7fd43d32/pybase64-1.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:26284ef64f142067293347bcc9d501d2b5d44b92eab9d941cb10a085fb01c666", size = 38238, upload-time = "2025-07-27T13:02:44.224Z" }, - { url = "https://files.pythonhosted.org/packages/28/a4/ca1538e9adf08f5016b3543b0060c18aea9a6e805dd20712a197c509d90d/pybase64-1.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:52dd32fe5cbfd8af8f3f034a4a65ee61948c72e5c358bf69d59543fc0dbcf950", size = 31659, upload-time = "2025-07-27T13:02:45.445Z" }, - { url = "https://files.pythonhosted.org/packages/0b/8f/f9b49926a60848ba98350dd648227ec524fb78340b47a450c4dbaf24b1bb/pybase64-1.4.2-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:37f133e8c96427995480bb6d396d9d49e949a3e829591845bb6a5a7f215ca177", size = 68318, upload-time = "2025-07-27T13:02:46.644Z" }, - { url = "https://files.pythonhosted.org/packages/29/9b/6ed2dd2bc8007f33b8316d6366b0901acbdd5665b419c2893b3dd48708de/pybase64-1.4.2-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6ee3874b0abbdd4c903d3989682a3f016fd84188622879f6f95a5dc5718d7e5", size = 71357, upload-time = "2025-07-27T13:02:47.937Z" }, - { url = "https://files.pythonhosted.org/packages/fb/69/be9ac8127da8d8339db7129683bd2975cecb0bf40a82731e1a492577a177/pybase64-1.4.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c69f177b1e404b22b05802127d6979acf4cb57f953c7de9472410f9c3fdece7", size = 59817, upload-time = "2025-07-27T13:02:49.163Z" }, - { url = "https://files.pythonhosted.org/packages/f4/a2/e3e09e000b509609276ee28b71beb0b61462d4a43b3e0db0a44c8652880c/pybase64-1.4.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:80c817e88ef2ca3cc9a285fde267690a1cb821ce0da4848c921c16f0fec56fda", size = 56639, upload-time = "2025-07-27T13:02:50.384Z" }, - { url = "https://files.pythonhosted.org/packages/01/70/ad7eff88aa4f1be06db705812e1f01749606933bf8fe9df553bb04b703e6/pybase64-1.4.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7a4bb6e7e45bfdaea0f2aaf022fc9a013abe6e46ccea31914a77e10f44098688", size = 59368, upload-time = "2025-07-27T13:02:51.883Z" }, - { url = "https://files.pythonhosted.org/packages/9d/82/0cd1b4bcd2a4da7805cfa04587be783bf9583b34ac16cadc29cf119a4fa2/pybase64-1.4.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2710a80d41a2b41293cb0e5b84b5464f54aa3f28f7c43de88784d2d9702b8a1c", size = 59981, upload-time = "2025-07-27T13:02:53.16Z" }, - { url = "https://files.pythonhosted.org/packages/3c/4c/8029a03468307dfaf0f9694d31830487ee43af5f8a73407004907724e8ac/pybase64-1.4.2-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:aa6122c8a81f6597e1c1116511f03ed42cf377c2100fe7debaae7ca62521095a", size = 54908, upload-time = "2025-07-27T13:02:54.363Z" }, - { url = "https://files.pythonhosted.org/packages/a1/8b/70bd0fe659e242efd0f60895a8ce1fe88e3a4084fd1be368974c561138c9/pybase64-1.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b7e22b02505d64db308e9feeb6cb52f1d554ede5983de0befa59ac2d2ffb6a5f", size = 58650, upload-time = "2025-07-27T13:02:55.905Z" }, - { url = "https://files.pythonhosted.org/packages/64/ca/9c1d23cbc4b9beac43386a32ad53903c816063cef3f14c10d7c3d6d49a23/pybase64-1.4.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:edfe4a3c8c4007f09591f49b46a89d287ef5e8cd6630339536fe98ff077263c2", size = 52323, upload-time = "2025-07-27T13:02:57.192Z" }, - { url = "https://files.pythonhosted.org/packages/aa/29/a6292e9047248c8616dc53131a49da6c97a61616f80e1e36c73d7ef895fe/pybase64-1.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b79b4a53dd117ffbd03e96953f2e6bd2827bfe11afeb717ea16d9b0893603077", size = 68979, upload-time = "2025-07-27T13:02:58.594Z" }, - { url = "https://files.pythonhosted.org/packages/c2/e0/cfec7b948e170395d8e88066e01f50e71195db9837151db10c14965d6222/pybase64-1.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fd9afa7a61d89d170607faf22287290045757e782089f0357b8f801d228d52c3", size = 58037, upload-time = "2025-07-27T13:02:59.753Z" }, - { url = "https://files.pythonhosted.org/packages/74/7e/0ac1850198c9c35ef631174009cee576f4d8afff3bf493ce310582976ab4/pybase64-1.4.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5c17b092e4da677a595178d2db17a5d2fafe5c8e418d46c0c4e4cde5adb8cff3", size = 54416, upload-time = "2025-07-27T13:03:00.978Z" }, - { url = "https://files.pythonhosted.org/packages/1b/45/b0b037f27e86c50e62d927f0bc1bde8b798dd55ab39197b116702e508d05/pybase64-1.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:120799274cf55f3f5bb8489eaa85142f26170564baafa7cf3e85541c46b6ab13", size = 56257, upload-time = "2025-07-27T13:03:02.201Z" }, - { url = "https://files.pythonhosted.org/packages/d2/0d/5034598aac56336d88fd5aaf6f34630330643b51d399336b8c788d798fc5/pybase64-1.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:522e4e712686acec2d25de9759dda0b0618cb9f6588523528bc74715c0245c7b", size = 70889, upload-time = "2025-07-27T13:03:03.437Z" }, - { url = "https://files.pythonhosted.org/packages/8a/3b/0645f21bb08ecf45635b624958b5f9e569069d31ecbf125dc7e0e5b83f60/pybase64-1.4.2-cp311-cp311-win32.whl", hash = "sha256:bfd828792982db8d787515535948c1e340f1819407c8832f94384c0ebeaf9d74", size = 33631, upload-time = "2025-07-27T13:03:05.194Z" }, - { url = "https://files.pythonhosted.org/packages/8f/08/24f8103c1f19e78761026cdd9f3b3be73239bc19cf5ab6fef0e8042d0bc6/pybase64-1.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:7a9e89d40dbf833af481d1d5f1a44d173c9c4b56a7c8dba98e39a78ee87cfc52", size = 35781, upload-time = "2025-07-27T13:03:06.779Z" }, - { url = "https://files.pythonhosted.org/packages/66/cd/832fb035a0ea7eb53d776a5cfa961849e22828f6dfdfcdb9eb43ba3c0166/pybase64-1.4.2-cp311-cp311-win_arm64.whl", hash = "sha256:ce5809fa90619b03eab1cd63fec142e6cf1d361731a9b9feacf27df76c833343", size = 30903, upload-time = "2025-07-27T13:03:07.903Z" }, - { url = "https://files.pythonhosted.org/packages/28/6d/11ede991e800797b9f5ebd528013b34eee5652df93de61ffb24503393fa5/pybase64-1.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2c75d1388855b5a1015b65096d7dbcc708e7de3245dcbedeb872ec05a09326", size = 38326, upload-time = "2025-07-27T13:03:09.065Z" }, - { url = "https://files.pythonhosted.org/packages/fe/84/87f1f565f42e2397e2aaa2477c86419f5173c3699881c42325c090982f0a/pybase64-1.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b621a972a01841368fdb9dedc55fd3c6e0c7217d0505ba3b1ebe95e7ef1b493", size = 31661, upload-time = "2025-07-27T13:03:10.295Z" }, - { url = "https://files.pythonhosted.org/packages/cb/2a/a24c810e7a61d2cc6f73fe9ee4872a03030887fa8654150901b15f376f65/pybase64-1.4.2-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f48c32ac6a16cbf57a5a96a073fef6ff7e3526f623cd49faa112b7f9980bafba", size = 68192, upload-time = "2025-07-27T13:03:11.467Z" }, - { url = "https://files.pythonhosted.org/packages/ee/87/d9baf98cbfc37b8657290ad4421f3a3c36aa0eafe4872c5859cfb52f3448/pybase64-1.4.2-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ace8b23093a6bb862477080d9059b784096ab2f97541e8bfc40d42f062875149", size = 71587, upload-time = "2025-07-27T13:03:12.719Z" }, - { url = "https://files.pythonhosted.org/packages/0b/89/3df043cc56ef3b91b7aa0c26ae822a2d7ec8da0b0fd7c309c879b0eb5988/pybase64-1.4.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1772c7532a7fb6301baea3dd3e010148dbf70cd1136a83c2f5f91bdc94822145", size = 59910, upload-time = "2025-07-27T13:03:14.266Z" }, - { url = "https://files.pythonhosted.org/packages/75/4f/6641e9edf37aeb4d4524dc7ba2168eff8d96c90e77f6283c2be3400ab380/pybase64-1.4.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:f86f7faddcba5cbfea475f8ab96567834c28bf09ca6c7c3d66ee445adac80d8f", size = 56701, upload-time = "2025-07-27T13:03:15.6Z" }, - { url = "https://files.pythonhosted.org/packages/2d/7f/20d8ac1046f12420a0954a45a13033e75f98aade36eecd00c64e3549b071/pybase64-1.4.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0b8c8e275b5294089f314814b4a50174ab90af79d6a4850f6ae11261ff6a7372", size = 59288, upload-time = "2025-07-27T13:03:16.823Z" }, - { url = "https://files.pythonhosted.org/packages/17/ea/9c0ca570e3e50b3c6c3442e280c83b321a0464c86a9db1f982a4ff531550/pybase64-1.4.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:864d85a0470c615807ae8b97d724d068b940a2d10ac13a5f1b9e75a3ce441758", size = 60267, upload-time = "2025-07-27T13:03:18.132Z" }, - { url = "https://files.pythonhosted.org/packages/f9/ac/46894929d71ccedebbfb0284173b0fea96bc029cd262654ba8451a7035d6/pybase64-1.4.2-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:47254d97ed2d8351e30ecfdb9e2414547f66ba73f8a09f932c9378ff75cd10c5", size = 54801, upload-time = "2025-07-27T13:03:19.669Z" }, - { url = "https://files.pythonhosted.org/packages/6a/1e/02c95218ea964f0b2469717c2c69b48e63f4ca9f18af01a5b2a29e4c1216/pybase64-1.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:264b65ecc4f0ee73f3298ab83bbd8008f7f9578361b8df5b448f985d8c63e02a", size = 58599, upload-time = "2025-07-27T13:03:20.951Z" }, - { url = "https://files.pythonhosted.org/packages/15/45/ccc21004930789b8fb439d43e3212a6c260ccddb2bf450c39a20db093f33/pybase64-1.4.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:fbcc2b30cd740c16c9699f596f22c7a9e643591311ae72b1e776f2d539e9dd9d", size = 52388, upload-time = "2025-07-27T13:03:23.064Z" }, - { url = "https://files.pythonhosted.org/packages/c4/45/22e46e549710c4c237d77785b6fb1bc4c44c288a5c44237ba9daf5c34b82/pybase64-1.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cda9f79c22d51ee4508f5a43b673565f1d26af4330c99f114e37e3186fdd3607", size = 68802, upload-time = "2025-07-27T13:03:24.673Z" }, - { url = "https://files.pythonhosted.org/packages/55/0c/232c6261b81296e5593549b36e6e7884a5da008776d12665923446322c36/pybase64-1.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0c91c6d2a7232e2a1cd10b3b75a8bb657defacd4295a1e5e80455df2dfc84d4f", size = 57841, upload-time = "2025-07-27T13:03:25.948Z" }, - { url = "https://files.pythonhosted.org/packages/20/8a/b35a615ae6f04550d696bb179c414538b3b477999435fdd4ad75b76139e4/pybase64-1.4.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a370dea7b1cee2a36a4d5445d4e09cc243816c5bc8def61f602db5a6f5438e52", size = 54320, upload-time = "2025-07-27T13:03:27.495Z" }, - { url = "https://files.pythonhosted.org/packages/d3/a9/8bd4f9bcc53689f1b457ecefed1eaa080e4949d65a62c31a38b7253d5226/pybase64-1.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9aa4de83f02e462a6f4e066811c71d6af31b52d7484de635582d0e3ec3d6cc3e", size = 56482, upload-time = "2025-07-27T13:03:28.942Z" }, - { url = "https://files.pythonhosted.org/packages/75/e5/4a7735b54a1191f61c3f5c2952212c85c2d6b06eb5fb3671c7603395f70c/pybase64-1.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83a1c2f9ed00fee8f064d548c8654a480741131f280e5750bb32475b7ec8ee38", size = 70959, upload-time = "2025-07-27T13:03:30.171Z" }, - { url = "https://files.pythonhosted.org/packages/d3/67/e2b6cb32c782e12304d467418e70da0212567f42bd4d3b5eb1fdf64920ad/pybase64-1.4.2-cp312-cp312-win32.whl", hash = "sha256:a6e5688b18d558e8c6b8701cc8560836c4bbeba61d33c836b4dba56b19423716", size = 33683, upload-time = "2025-07-27T13:03:31.775Z" }, - { url = "https://files.pythonhosted.org/packages/4f/bc/d5c277496063a09707486180f17abbdbdebbf2f5c4441b20b11d3cb7dc7c/pybase64-1.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:c995d21b8bd08aa179cd7dd4db0695c185486ecc72da1e8f6c37ec86cadb8182", size = 35817, upload-time = "2025-07-27T13:03:32.99Z" }, - { url = "https://files.pythonhosted.org/packages/e6/69/e4be18ae685acff0ae77f75d4586590f29d2cd187bf603290cf1d635cad4/pybase64-1.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:e254b9258c40509c2ea063a7784f6994988f3f26099d6e08704e3c15dfed9a55", size = 30900, upload-time = "2025-07-27T13:03:34.499Z" }, - { url = "https://files.pythonhosted.org/packages/f4/56/5337f27a8b8d2d6693f46f7b36bae47895e5820bfa259b0072574a4e1057/pybase64-1.4.2-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:0f331aa59549de21f690b6ccc79360ffed1155c3cfbc852eb5c097c0b8565a2b", size = 33888, upload-time = "2025-07-27T13:03:35.698Z" }, - { url = "https://files.pythonhosted.org/packages/4c/09/f3f4b11fc9beda7e8625e29fb0f549958fcbb34fea3914e1c1d95116e344/pybase64-1.4.2-cp313-cp313-android_21_x86_64.whl", hash = "sha256:9dad20bf1f3ed9e6fe566c4c9d07d9a6c04f5a280daebd2082ffb8620b0a880d", size = 40796, upload-time = "2025-07-27T13:03:36.927Z" }, - { url = "https://files.pythonhosted.org/packages/e3/ff/470768f0fe6de0aa302a8cb1bdf2f9f5cffc3f69e60466153be68bc953aa/pybase64-1.4.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:69d3f0445b0faeef7bb7f93bf8c18d850785e2a77f12835f49e524cc54af04e7", size = 30914, upload-time = "2025-07-27T13:03:38.475Z" }, - { url = "https://files.pythonhosted.org/packages/75/6b/d328736662665e0892409dc410353ebef175b1be5eb6bab1dad579efa6df/pybase64-1.4.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2372b257b1f4dd512f317fb27e77d313afd137334de64c87de8374027aacd88a", size = 31380, upload-time = "2025-07-27T13:03:39.7Z" }, - { url = "https://files.pythonhosted.org/packages/ca/96/7ff718f87c67f4147c181b73d0928897cefa17dc75d7abc6e37730d5908f/pybase64-1.4.2-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:fb794502b4b1ec91c4ca5d283ae71aef65e3de7721057bd9e2b3ec79f7a62d7d", size = 38230, upload-time = "2025-07-27T13:03:41.637Z" }, - { url = "https://files.pythonhosted.org/packages/4d/58/a3307b048d799ff596a3c7c574fcba66f9b6b8c899a3c00a698124ca7ad5/pybase64-1.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d5c532b03fd14a5040d6cf6571299a05616f925369c72ddf6fe2fb643eb36fed", size = 38319, upload-time = "2025-07-27T13:03:42.847Z" }, - { url = "https://files.pythonhosted.org/packages/08/a7/0bda06341b0a2c830d348c6e1c4d348caaae86c53dc9a046e943467a05e9/pybase64-1.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f699514dc1d5689ca9cf378139e0214051922732f9adec9404bc680a8bef7c0", size = 31655, upload-time = "2025-07-27T13:03:44.426Z" }, - { url = "https://files.pythonhosted.org/packages/87/df/e1d6e8479e0c5113c2c63c7b44886935ce839c2d99884c7304ca9e86547c/pybase64-1.4.2-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:cd3e8713cbd32c8c6aa935feaf15c7670e2b7e8bfe51c24dc556811ebd293a29", size = 68232, upload-time = "2025-07-27T13:03:45.729Z" }, - { url = "https://files.pythonhosted.org/packages/71/ab/db4dbdfccb9ca874d6ce34a0784761471885d96730de85cee3d300381529/pybase64-1.4.2-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d377d48acf53abf4b926c2a7a24a19deb092f366a04ffd856bf4b3aa330b025d", size = 71608, upload-time = "2025-07-27T13:03:47.01Z" }, - { url = "https://files.pythonhosted.org/packages/11/e9/508df958563951045d728bbfbd3be77465f9231cf805cb7ccaf6951fc9f1/pybase64-1.4.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d83c076e78d619b9e1dd674e2bf5fb9001aeb3e0b494b80a6c8f6d4120e38cd9", size = 59912, upload-time = "2025-07-27T13:03:48.277Z" }, - { url = "https://files.pythonhosted.org/packages/f2/58/7f2cef1ceccc682088958448d56727369de83fa6b29148478f4d2acd107a/pybase64-1.4.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:ab9cdb6a8176a5cb967f53e6ad60e40c83caaa1ae31c5e1b29e5c8f507f17538", size = 56413, upload-time = "2025-07-27T13:03:49.908Z" }, - { url = "https://files.pythonhosted.org/packages/08/7c/7e0af5c5728fa7e2eb082d88eca7c6bd17429be819d58518e74919d42e66/pybase64-1.4.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:adf0c103ad559dbfb9fe69edfd26a15c65d9c991a5ab0a25b04770f9eb0b9484", size = 59311, upload-time = "2025-07-27T13:03:51.238Z" }, - { url = "https://files.pythonhosted.org/packages/03/8b/09825d0f37e45b9a3f546e5f990b6cf2dd838e54ea74122c2464646e0c77/pybase64-1.4.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:0d03ef2f253d97ce0685d3624bf5e552d716b86cacb8a6c971333ba4b827e1fc", size = 60282, upload-time = "2025-07-27T13:03:52.56Z" }, - { url = "https://files.pythonhosted.org/packages/9c/3f/3711d2413f969bfd5b9cc19bc6b24abae361b7673ff37bcb90c43e199316/pybase64-1.4.2-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:e565abf906efee76ae4be1aef5df4aed0fda1639bc0d7732a3dafef76cb6fc35", size = 54845, upload-time = "2025-07-27T13:03:54.167Z" }, - { url = "https://files.pythonhosted.org/packages/c6/3c/4c7ce1ae4d828c2bb56d144322f81bffbaaac8597d35407c3d7cbb0ff98f/pybase64-1.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3c6a5f15fd03f232fc6f295cce3684f7bb08da6c6d5b12cc771f81c9f125cc6", size = 58615, upload-time = "2025-07-27T13:03:55.494Z" }, - { url = "https://files.pythonhosted.org/packages/f5/8f/c2fc03bf4ed038358620065c75968a30184d5d3512d09d3ef9cc3bd48592/pybase64-1.4.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:bad9e3db16f448728138737bbd1af9dc2398efd593a8bdd73748cc02cd33f9c6", size = 52434, upload-time = "2025-07-27T13:03:56.808Z" }, - { url = "https://files.pythonhosted.org/packages/e2/0a/757d6df0a60327c893cfae903e15419914dd792092dc8cc5c9523d40bc9b/pybase64-1.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2683ef271328365c31afee0ed8fa29356fb8fb7c10606794656aa9ffb95e92be", size = 68824, upload-time = "2025-07-27T13:03:58.735Z" }, - { url = "https://files.pythonhosted.org/packages/a0/14/84abe2ed8c29014239be1cfab45dfebe5a5ca779b177b8b6f779bd8b69da/pybase64-1.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:265b20089cd470079114c09bb74b101b3bfc3c94ad6b4231706cf9eff877d570", size = 57898, upload-time = "2025-07-27T13:04:00.379Z" }, - { url = "https://files.pythonhosted.org/packages/7e/c6/d193031f90c864f7b59fa6d1d1b5af41f0f5db35439988a8b9f2d1b32a13/pybase64-1.4.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e53173badead10ef8b839aa5506eecf0067c7b75ad16d9bf39bc7144631f8e67", size = 54319, upload-time = "2025-07-27T13:04:01.742Z" }, - { url = "https://files.pythonhosted.org/packages/cb/37/ec0c7a610ff8f994ee6e0c5d5d66b6b6310388b96ebb347b03ae39870fdf/pybase64-1.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5823b8dcf74da7da0f761ed60c961e8928a6524e520411ad05fe7f9f47d55b40", size = 56472, upload-time = "2025-07-27T13:04:03.089Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5a/e585b74f85cedd261d271e4c2ef333c5cfce7e80750771808f56fee66b98/pybase64-1.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1237f66c54357d325390da60aa5e21c6918fbcd1bf527acb9c1f4188c62cb7d5", size = 70966, upload-time = "2025-07-27T13:04:04.361Z" }, - { url = "https://files.pythonhosted.org/packages/ad/20/1b2fdd98b4ba36008419668c813025758214c543e362c66c49214ecd1127/pybase64-1.4.2-cp313-cp313-win32.whl", hash = "sha256:b0b851eb4f801d16040047f6889cca5e9dfa102b3e33f68934d12511245cef86", size = 33681, upload-time = "2025-07-27T13:04:06.126Z" }, - { url = "https://files.pythonhosted.org/packages/ff/64/3df4067d169c047054889f34b5a946cbe3785bca43404b93c962a5461a41/pybase64-1.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:19541c6e26d17d9522c02680fe242206ae05df659c82a657aabadf209cd4c6c7", size = 35822, upload-time = "2025-07-27T13:04:07.752Z" }, - { url = "https://files.pythonhosted.org/packages/d1/fd/db505188adf812e60ee923f196f9deddd8a1895b2b29b37f5db94afc3b1c/pybase64-1.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:77a191863d576c0a5dd81f8a568a5ca15597cc980ae809dce62c717c8d42d8aa", size = 30899, upload-time = "2025-07-27T13:04:09.062Z" }, - { url = "https://files.pythonhosted.org/packages/d9/27/5f5fecd206ec1e06e1608a380af18dcb76a6ab08ade6597a3251502dcdb2/pybase64-1.4.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2e194bbabe3fdf9e47ba9f3e157394efe0849eb226df76432126239b3f44992c", size = 38677, upload-time = "2025-07-27T13:04:10.334Z" }, - { url = "https://files.pythonhosted.org/packages/bf/0f/abe4b5a28529ef5f74e8348fa6a9ef27d7d75fbd98103d7664cf485b7d8f/pybase64-1.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:39aef1dadf4a004f11dd09e703abaf6528a87c8dbd39c448bb8aebdc0a08c1be", size = 32066, upload-time = "2025-07-27T13:04:11.641Z" }, - { url = "https://files.pythonhosted.org/packages/ac/7e/ea0ce6a7155cada5526017ec588b6d6185adea4bf9331565272f4ef583c2/pybase64-1.4.2-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:91cb920c7143e36ec8217031282c8651da3b2206d70343f068fac0e7f073b7f9", size = 72300, upload-time = "2025-07-27T13:04:12.969Z" }, - { url = "https://files.pythonhosted.org/packages/45/2d/e64c7a056c9ec48dfe130d1295e47a8c2b19c3984488fc08e5eaa1e86c88/pybase64-1.4.2-cp313-cp313t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6958631143fb9e71f9842000da042ec2f6686506b6706e2dfda29e97925f6aa0", size = 75520, upload-time = "2025-07-27T13:04:14.374Z" }, - { url = "https://files.pythonhosted.org/packages/43/e0/e5f93b2e1cb0751a22713c4baa6c6eaf5f307385e369180486c8316ed21e/pybase64-1.4.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:dc35f14141ef3f1ac70d963950a278a2593af66fe5a1c7a208e185ca6278fa25", size = 65384, upload-time = "2025-07-27T13:04:16.204Z" }, - { url = "https://files.pythonhosted.org/packages/ff/23/8c645a1113ad88a1c6a3d0e825e93ef8b74ad3175148767853a0a4d7626e/pybase64-1.4.2-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:5d949d2d677859c3a8507e1b21432a039d2b995e0bd3fe307052b6ded80f207a", size = 60471, upload-time = "2025-07-27T13:04:17.947Z" }, - { url = "https://files.pythonhosted.org/packages/8b/81/edd0f7d8b0526b91730a0dd4ce6b4c8be2136cd69d424afe36235d2d2a06/pybase64-1.4.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:09caacdd3e15fe7253a67781edd10a6a918befab0052a2a3c215fe5d1f150269", size = 63945, upload-time = "2025-07-27T13:04:19.383Z" }, - { url = "https://files.pythonhosted.org/packages/a5/a5/edc224cd821fd65100b7af7c7e16b8f699916f8c0226c9c97bbae5a75e71/pybase64-1.4.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:e44b0e793b23f28ea0f15a9754bd0c960102a2ac4bccb8fafdedbd4cc4d235c0", size = 64858, upload-time = "2025-07-27T13:04:20.807Z" }, - { url = "https://files.pythonhosted.org/packages/11/3b/92853f968f1af7e42b7e54d21bdd319097b367e7dffa2ca20787361df74c/pybase64-1.4.2-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:849f274d0bcb90fc6f642c39274082724d108e41b15f3a17864282bd41fc71d5", size = 58557, upload-time = "2025-07-27T13:04:22.229Z" }, - { url = "https://files.pythonhosted.org/packages/76/09/0ec6bd2b2303b0ea5c6da7535edc9a608092075ef8c0cdd96e3e726cd687/pybase64-1.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:528dba7ef1357bd7ce1aea143084501f47f5dd0fff7937d3906a68565aa59cfe", size = 63624, upload-time = "2025-07-27T13:04:23.952Z" }, - { url = "https://files.pythonhosted.org/packages/73/6e/52cb1ced2a517a3118b2e739e9417432049013ac7afa15d790103059e8e4/pybase64-1.4.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:1da54be743d9a68671700cfe56c3ab8c26e8f2f5cc34eface905c55bc3a9af94", size = 56174, upload-time = "2025-07-27T13:04:25.419Z" }, - { url = "https://files.pythonhosted.org/packages/5b/9d/820fe79347467e48af985fe46180e1dd28e698ade7317bebd66de8a143f5/pybase64-1.4.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:9b07c0406c3eaa7014499b0aacafb21a6d1146cfaa85d56f0aa02e6d542ee8f3", size = 72640, upload-time = "2025-07-27T13:04:26.824Z" }, - { url = "https://files.pythonhosted.org/packages/53/58/e863e10d08361e694935c815b73faad7e1ab03f99ae154d86c4e2f331896/pybase64-1.4.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:312f2aa4cf5d199a97fbcaee75d2e59ebbaafcd091993eb373b43683498cdacb", size = 62453, upload-time = "2025-07-27T13:04:28.562Z" }, - { url = "https://files.pythonhosted.org/packages/95/f0/c392c4ac8ccb7a34b28377c21faa2395313e3c676d76c382642e19a20703/pybase64-1.4.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad59362fc267bf15498a318c9e076686e4beeb0dfe09b457fabbc2b32468b97a", size = 58103, upload-time = "2025-07-27T13:04:29.996Z" }, - { url = "https://files.pythonhosted.org/packages/32/30/00ab21316e7df8f526aa3e3dc06f74de6711d51c65b020575d0105a025b2/pybase64-1.4.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:01593bd064e7dcd6c86d04e94e44acfe364049500c20ac68ca1e708fbb2ca970", size = 60779, upload-time = "2025-07-27T13:04:31.549Z" }, - { url = "https://files.pythonhosted.org/packages/a6/65/114ca81839b1805ce4a2b7d58bc16e95634734a2059991f6382fc71caf3e/pybase64-1.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5b81547ad8ea271c79fdf10da89a1e9313cb15edcba2a17adf8871735e9c02a0", size = 74684, upload-time = "2025-07-27T13:04:32.976Z" }, - { url = "https://files.pythonhosted.org/packages/54/8f/aa9d445b9bb693b8f6bb1456bd6d8576d79b7a63bf6c69af3a539235b15f/pybase64-1.4.2-cp313-cp313t-win32.whl", hash = "sha256:7edbe70b5654545a37e6e6b02de738303b1bbdfcde67f6cfec374cfb5cc4099e", size = 33961, upload-time = "2025-07-27T13:04:34.806Z" }, - { url = "https://files.pythonhosted.org/packages/0e/e5/da37cfb173c646fd4fc7c6aae2bc41d40de2ee49529854af8f4e6f498b45/pybase64-1.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:385690addf87c25d6366fab5d8ff512eed8a7ecb18da9e8152af1c789162f208", size = 36199, upload-time = "2025-07-27T13:04:36.223Z" }, - { url = "https://files.pythonhosted.org/packages/66/3e/1eb68fb7d00f2cec8bd9838e2a30d183d6724ae06e745fd6e65216f170ff/pybase64-1.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c2070d0aa88580f57fe15ca88b09f162e604d19282915a95a3795b5d3c1c05b5", size = 31221, upload-time = "2025-07-27T13:04:37.704Z" }, - { url = "https://files.pythonhosted.org/packages/99/bf/00a87d951473ce96c8c08af22b6983e681bfabdb78dd2dcf7ee58eac0932/pybase64-1.4.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:4157ad277a32cf4f02a975dffc62a3c67d73dfa4609b2c1978ef47e722b18b8e", size = 30924, upload-time = "2025-07-27T13:04:39.189Z" }, - { url = "https://files.pythonhosted.org/packages/ae/43/dee58c9d60e60e6fb32dc6da722d84592e22f13c277297eb4ce6baf99a99/pybase64-1.4.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:e113267dc349cf624eb4f4fbf53fd77835e1aa048ac6877399af426aab435757", size = 31390, upload-time = "2025-07-27T13:04:40.995Z" }, - { url = "https://files.pythonhosted.org/packages/e1/11/b28906fc2e330b8b1ab4bc845a7bef808b8506734e90ed79c6062b095112/pybase64-1.4.2-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:cea5aaf218fd9c5c23afacfe86fd4464dfedc1a0316dd3b5b4075b068cc67df0", size = 38212, upload-time = "2025-07-27T13:04:42.729Z" }, - { url = "https://files.pythonhosted.org/packages/24/9e/868d1e104413d14b19feaf934fc7fad4ef5b18946385f8bb79684af40f24/pybase64-1.4.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:41213497abbd770435c7a9c8123fb02b93709ac4cf60155cd5aefc5f3042b600", size = 38303, upload-time = "2025-07-27T13:04:44.095Z" }, - { url = "https://files.pythonhosted.org/packages/a3/73/f7eac96ca505df0600280d6bfc671a9e2e2f947c2b04b12a70e36412f7eb/pybase64-1.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c8b522df7ee00f2ac1993ccd5e1f6608ae7482de3907668c2ff96a83ef213925", size = 31669, upload-time = "2025-07-27T13:04:45.845Z" }, - { url = "https://files.pythonhosted.org/packages/c6/43/8e18bea4fd455100112d6a73a83702843f067ef9b9272485b6bdfd9ed2f0/pybase64-1.4.2-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:06725022e540c5b098b978a0418ca979773e2cbdbb76f10bd97536f2ad1c5b49", size = 68452, upload-time = "2025-07-27T13:04:47.788Z" }, - { url = "https://files.pythonhosted.org/packages/e4/2e/851eb51284b97354ee5dfa1309624ab90920696e91a33cd85b13d20cc5c1/pybase64-1.4.2-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a3e54dcf0d0305ec88473c9d0009f698cabf86f88a8a10090efeff2879c421bb", size = 71674, upload-time = "2025-07-27T13:04:49.294Z" }, - { url = "https://files.pythonhosted.org/packages/57/0d/5cf1e5dc64aec8db43e8dee4e4046856d639a72bcb0fb3e716be42ced5f1/pybase64-1.4.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:67675cee727a60dc91173d2790206f01aa3c7b3fbccfa84fd5c1e3d883fe6caa", size = 60027, upload-time = "2025-07-27T13:04:50.769Z" }, - { url = "https://files.pythonhosted.org/packages/a4/8e/3479266bc0e65f6cc48b3938d4a83bff045330649869d950a378f2ddece0/pybase64-1.4.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:753da25d4fd20be7bda2746f545935773beea12d5cb5ec56ec2d2960796477b1", size = 56461, upload-time = "2025-07-27T13:04:52.37Z" }, - { url = "https://files.pythonhosted.org/packages/20/b6/f2b6cf59106dd78bae8717302be5b814cec33293504ad409a2eb752ad60c/pybase64-1.4.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:a78c768ce4ca550885246d14babdb8923e0f4a848dfaaeb63c38fc99e7ea4052", size = 59446, upload-time = "2025-07-27T13:04:53.967Z" }, - { url = "https://files.pythonhosted.org/packages/16/70/3417797dfccdfdd0a54e4ad17c15b0624f0fc2d6a362210f229f5c4e8fd0/pybase64-1.4.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:51b17f36d890c92f0618fb1c8db2ccc25e6ed07afa505bab616396fc9b0b0492", size = 60350, upload-time = "2025-07-27T13:04:55.881Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c6/6e4269dd98d150ae95d321b311a345eae0f7fd459d97901b4a586d7513bb/pybase64-1.4.2-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f92218d667049ab4f65d54fa043a88ffdb2f07fff1f868789ef705a5221de7ec", size = 54989, upload-time = "2025-07-27T13:04:57.436Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e8/18c1b0c255f964fafd0412b0d5a163aad588aeccb8f84b9bf9c8611d80f6/pybase64-1.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3547b3d1499919a06491b3f879a19fbe206af2bd1a424ecbb4e601eb2bd11fea", size = 58724, upload-time = "2025-07-27T13:04:59.406Z" }, - { url = "https://files.pythonhosted.org/packages/b1/ad/ddfbd2125fc20b94865fb232b2e9105376fa16eee492e4b7786d42a86cbf/pybase64-1.4.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:958af7b0e09ddeb13e8c2330767c47b556b1ade19c35370f6451d139cde9f2a9", size = 52285, upload-time = "2025-07-27T13:05:01.198Z" }, - { url = "https://files.pythonhosted.org/packages/b6/4c/b9d4ec9224add33c84b925a03d1a53cd4106efb449ea8e0ae7795fed7bf7/pybase64-1.4.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4facc57f6671e2229a385a97a618273e7be36a9ea0a9d1c1b9347f14d19ceba8", size = 69036, upload-time = "2025-07-27T13:05:03.109Z" }, - { url = "https://files.pythonhosted.org/packages/92/38/7b96794da77bed3d9b4fea40f14ae563648fba83a696e7602fabe60c0eb7/pybase64-1.4.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a32fc57d05d73a7c9b0ca95e9e265e21cf734195dc6873829a890058c35f5cfd", size = 57938, upload-time = "2025-07-27T13:05:04.744Z" }, - { url = "https://files.pythonhosted.org/packages/eb/c5/ae8bbce3c322d1b074e79f51f5df95961fe90cb8748df66c6bc97616e974/pybase64-1.4.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3dc853243c81ce89cc7318e6946f860df28ddb7cd2a0648b981652d9ad09ee5a", size = 54474, upload-time = "2025-07-27T13:05:06.662Z" }, - { url = "https://files.pythonhosted.org/packages/15/9a/c09887c4bb1b43c03fc352e2671ef20c6686c6942a99106a45270ee5b840/pybase64-1.4.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0e6d863a86b3e7bc6ac9bd659bebda4501b9da842521111b0b0e54eb51295df5", size = 56533, upload-time = "2025-07-27T13:05:08.368Z" }, - { url = "https://files.pythonhosted.org/packages/4f/0f/d5114d63d35d085639606a880cb06e2322841cd4b213adfc14d545c1186f/pybase64-1.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6579475140ff2067903725d8aca47f5747bcb211597a1edd60b58f6d90ada2bd", size = 71030, upload-time = "2025-07-27T13:05:10.3Z" }, - { url = "https://files.pythonhosted.org/packages/40/0e/fe6f1ed22ea52eb99f490a8441815ba21de288f4351aeef4968d71d20d2d/pybase64-1.4.2-cp314-cp314-win32.whl", hash = "sha256:373897f728d7b4f241a1f803ac732c27b6945d26d86b2741ad9b75c802e4e378", size = 34174, upload-time = "2025-07-27T13:05:12.254Z" }, - { url = "https://files.pythonhosted.org/packages/71/46/0e15bea52ffc63e8ae7935e945accbaf635e0aefa26d3e31fdf9bc9dcd01/pybase64-1.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:1afe3361344617d298c1d08bc657ef56d0f702d6b72cb65d968b2771017935aa", size = 36308, upload-time = "2025-07-27T13:05:13.898Z" }, - { url = "https://files.pythonhosted.org/packages/4f/dc/55849fee2577bda77c1e078da04cc9237e8e474a8c8308deb702a26f2511/pybase64-1.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:f131c9360babe522f3d90f34da3f827cba80318125cf18d66f2ee27e3730e8c4", size = 31341, upload-time = "2025-07-27T13:05:15.553Z" }, - { url = "https://files.pythonhosted.org/packages/39/44/c69d088e28b25e70ac742b6789cde038473815b2a69345c4bae82d5e244d/pybase64-1.4.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2583ac304131c1bd6e3120b0179333610f18816000db77c0a2dd6da1364722a8", size = 38678, upload-time = "2025-07-27T13:05:17.544Z" }, - { url = "https://files.pythonhosted.org/packages/00/93/2860ec067497b9cbb06242f96d44caebbd9eed32174e4eb8c1ffef760f94/pybase64-1.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:75a8116be4ea4cdd30a5c4f1a6f3b038e0d457eb03c8a2685d8ce2aa00ef8f92", size = 32066, upload-time = "2025-07-27T13:05:19.18Z" }, - { url = "https://files.pythonhosted.org/packages/d3/55/1e96249a38759332e8a01b31c370d88c60ceaf44692eb6ba4f0f451ee496/pybase64-1.4.2-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:217ea776a098d7c08668e5526b9764f5048bbfd28cac86834217ddfe76a4e3c4", size = 72465, upload-time = "2025-07-27T13:05:20.866Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ab/0f468605b899f3e35dbb7423fba3ff98aeed1ec16abb02428468494a58f4/pybase64-1.4.2-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4ec14683e343c95b14248cdfdfa78c052582be7a3865fd570aa7cffa5ab5cf37", size = 75693, upload-time = "2025-07-27T13:05:22.896Z" }, - { url = "https://files.pythonhosted.org/packages/91/d1/9980a0159b699e2489baba05b71b7c953b29249118ba06fdbb3e9ea1b9b5/pybase64-1.4.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:480ecf21e1e956c5a10d3cf7b3b7e75bce3f9328cf08c101e4aab1925d879f34", size = 65577, upload-time = "2025-07-27T13:05:25Z" }, - { url = "https://files.pythonhosted.org/packages/16/86/b27e7b95f9863d245c0179a7245582eda3d262669d8f822777364d8fd7d5/pybase64-1.4.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:1fe1ebdc55e9447142e2f6658944aadfb5a4fbf03dbd509be34182585515ecc1", size = 60662, upload-time = "2025-07-27T13:05:27.138Z" }, - { url = "https://files.pythonhosted.org/packages/28/87/a7f0dde0abc26bfbee761f1d3558eb4b139f33ddd9fe1f6825ffa7daa22d/pybase64-1.4.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c793a2b06753accdaf5e1a8bbe5d800aab2406919e5008174f989a1ca0081411", size = 64179, upload-time = "2025-07-27T13:05:28.996Z" }, - { url = "https://files.pythonhosted.org/packages/1e/88/5d6fa1c60e1363b4cac4c396978f39e9df4689e75225d7d9c0a5998e3a14/pybase64-1.4.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6acae6e1d1f7ebe40165f08076c7a73692b2bf9046fefe673f350536e007f556", size = 64968, upload-time = "2025-07-27T13:05:30.818Z" }, - { url = "https://files.pythonhosted.org/packages/20/6e/2ed585af5b2211040445d9849326dd2445320c9316268794f5453cfbaf30/pybase64-1.4.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:88b91cd0949358aadcea75f8de5afbcf3c8c5fb9ec82325bd24285b7119cf56e", size = 58738, upload-time = "2025-07-27T13:05:32.629Z" }, - { url = "https://files.pythonhosted.org/packages/ce/94/e2960b56322eabb3fbf303fc5a72e6444594c1b90035f3975c6fe666db5c/pybase64-1.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:53316587e1b1f47a11a5ff068d3cbd4a3911c291f2aec14882734973684871b2", size = 63802, upload-time = "2025-07-27T13:05:34.687Z" }, - { url = "https://files.pythonhosted.org/packages/95/47/312139d764c223f534f751528ce3802887c279125eac64f71cd3b4e05abc/pybase64-1.4.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:caa7f20f43d00602cf9043b5ba758d54f5c41707d3709b2a5fac17361579c53c", size = 56341, upload-time = "2025-07-27T13:05:36.554Z" }, - { url = "https://files.pythonhosted.org/packages/3f/d7/aec9a6ed53b128dac32f8768b646ca5730c88eef80934054d7fa7d02f3ef/pybase64-1.4.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2d93817e24fdd79c534ed97705df855af6f1d2535ceb8dfa80da9de75482a8d7", size = 72838, upload-time = "2025-07-27T13:05:38.459Z" }, - { url = "https://files.pythonhosted.org/packages/e3/a8/6ccc54c5f1f7c3450ad7c56da10c0f131d85ebe069ea6952b5b42f2e92d9/pybase64-1.4.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:63cd769b51474d8d08f7f2ce73b30380d9b4078ec92ea6b348ea20ed1e1af88a", size = 62633, upload-time = "2025-07-27T13:05:40.624Z" }, - { url = "https://files.pythonhosted.org/packages/34/22/2b9d89f8ff6f2a01d6d6a88664b20a4817049cfc3f2c62caca040706660c/pybase64-1.4.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cd07e6a9993c392ec8eb03912a43c6a6b21b2deb79ee0d606700fe276e9a576f", size = 58282, upload-time = "2025-07-27T13:05:42.565Z" }, - { url = "https://files.pythonhosted.org/packages/b2/14/dbf6266177532a6a11804ac080ebffcee272f491b92820c39886ee20f201/pybase64-1.4.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:6a8944e8194adff4668350504bc6b7dbde2dab9244c88d99c491657d145b5af5", size = 60948, upload-time = "2025-07-27T13:05:44.48Z" }, - { url = "https://files.pythonhosted.org/packages/fd/7a/b2ae9046a66dd5746cd72836a41386517b1680bea5ce02f2b4f1c9ebc688/pybase64-1.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:04ab398ec4b6a212af57f6a21a6336d5a1d754ff4ccb215951366ab9080481b2", size = 74854, upload-time = "2025-07-27T13:05:46.416Z" }, - { url = "https://files.pythonhosted.org/packages/ef/7e/9856f6d6c38a7b730e001123d2d9fa816b8b1a45f0cdee1d509d5947b047/pybase64-1.4.2-cp314-cp314t-win32.whl", hash = "sha256:3b9201ecdcb1c3e23be4caebd6393a4e6615bd0722528f5413b58e22e3792dd3", size = 34490, upload-time = "2025-07-27T13:05:48.304Z" }, - { url = "https://files.pythonhosted.org/packages/c7/38/8523a9dc1ec8704dedbe5ccc95192ae9a7585f7eec85cc62946fe3cacd32/pybase64-1.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:36e9b0cad8197136d73904ef5a71d843381d063fd528c5ab203fc4990264f682", size = 36680, upload-time = "2025-07-27T13:05:50.264Z" }, - { url = "https://files.pythonhosted.org/packages/3c/52/5600104ef7b85f89fb8ec54f73504ead3f6f0294027e08d281f3cafb5c1a/pybase64-1.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:f25140496b02db0e7401567cd869fb13b4c8118bf5c2428592ec339987146d8b", size = 31600, upload-time = "2025-07-27T13:05:52.24Z" }, - { url = "https://files.pythonhosted.org/packages/32/34/b67371f4fcedd5e2def29b1cf92a4311a72f590c04850f370c75297b48ce/pybase64-1.4.2-graalpy311-graalpy242_311_native-macosx_10_9_x86_64.whl", hash = "sha256:b4eed40a5f1627ee65613a6ac834a33f8ba24066656f569c852f98eb16f6ab5d", size = 38667, upload-time = "2025-07-27T13:07:25.315Z" }, - { url = "https://files.pythonhosted.org/packages/aa/3e/e57fe09ed1c7e740d21c37023c5f7c8963b4c36380f41d10261cc76f93b4/pybase64-1.4.2-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:57885fa521e9add235af4db13e9e048d3a2934cd27d7c5efac1925e1b4d6538d", size = 32094, upload-time = "2025-07-27T13:07:28.235Z" }, - { url = "https://files.pythonhosted.org/packages/51/34/f40d3262c3953814b9bcdcf858436bd5bc1133a698be4bcc7ed2a8c0730d/pybase64-1.4.2-graalpy311-graalpy242_311_native-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eef9255d926c64e2fca021d3aee98023bacb98e1518e5986d6aab04102411b04", size = 43212, upload-time = "2025-07-27T13:07:31.327Z" }, - { url = "https://files.pythonhosted.org/packages/8c/2a/5e05d25718cb8ffd68bd46553ddfd2b660893d937feda1716b8a3b21fb38/pybase64-1.4.2-graalpy311-graalpy242_311_native-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:89614ea2d2329b6708746c540e0f14d692125df99fb1203ff0de948d9e68dfc9", size = 35789, upload-time = "2025-07-27T13:07:34.026Z" }, - { url = "https://files.pythonhosted.org/packages/d5/9d/f56c3ee6e94faaae2896ecaf666428330cb24096abf7d2427371bb2b403a/pybase64-1.4.2-graalpy311-graalpy242_311_native-win_amd64.whl", hash = "sha256:e401cecd2d7ddcd558768b2140fd4430746be4d17fb14c99eec9e40789df136d", size = 35861, upload-time = "2025-07-27T13:07:37.099Z" }, - { url = "https://files.pythonhosted.org/packages/0e/bf/5ebaa2d9ddb5fc506633bc8b820fc27e64da964937fb30929c0367c47d00/pybase64-1.4.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0a5393be20b0705870f5a8969749af84d734c077de80dd7e9f5424a247afa85e", size = 38162, upload-time = "2025-07-27T13:07:58.364Z" }, - { url = "https://files.pythonhosted.org/packages/25/41/795c5fd6e5571bb675bf9add8a048166dddf8951c2a903fea8557743886b/pybase64-1.4.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:448f0259a2f1a17eb086f70fe2ad9b556edba1fc5bc4e62ce6966179368ee9f8", size = 31452, upload-time = "2025-07-27T13:08:01.259Z" }, - { url = "https://files.pythonhosted.org/packages/aa/dd/c819003b59b2832256b72ad23cbeadbd95d083ef0318d07149a58b7a88af/pybase64-1.4.2-pp311-pypy311_pp73-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:1159e70cba8e76c3d8f334bd1f8fd52a1bb7384f4c3533831b23ab2df84a6ef3", size = 40668, upload-time = "2025-07-27T13:08:04.176Z" }, - { url = "https://files.pythonhosted.org/packages/0e/c5/38c6aba28678c4a4db49312a6b8171b93a0ffe9f21362cf4c0f325caa850/pybase64-1.4.2-pp311-pypy311_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d943bc5dad8388971494554b97f22ae06a46cc7779ad0de3d4bfdf7d0bbea30", size = 41281, upload-time = "2025-07-27T13:08:07.395Z" }, - { url = "https://files.pythonhosted.org/packages/e5/23/5927bd9e59714e4e8cefd1d21ccd7216048bb1c6c3e7104b1b200afdc63d/pybase64-1.4.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10b99182c561d86422c5de4265fd1f8f172fb38efaed9d72c71fb31e279a7f94", size = 35433, upload-time = "2025-07-27T13:08:10.551Z" }, - { url = "https://files.pythonhosted.org/packages/01/0f/fab7ed5bf4926523c3b39f7621cea3e0da43f539fbc2270e042f1afccb79/pybase64-1.4.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bb082c1114f046e59fcbc4f2be13edc93b36d7b54b58605820605be948f8fdf6", size = 36131, upload-time = "2025-07-27T13:08:13.777Z" }, -] - [[package]] name = "pycparser" version = "2.23" @@ -4792,20 +4129,20 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] name = "pyjwt" -version = "2.10.1" +version = "2.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, ] [package.optional-dependencies] @@ -4935,12 +4272,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/5e/1aa9a93198c6b64513c9d7752de7422c06402de6600a8767da1524f9570b/pyparsing-3.2.5-py3-none-any.whl", hash = "sha256:e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e", size = 113890, upload-time = "2025-09-21T04:11:04.117Z" }, ] -[[package]] -name = "pypika" -version = "0.48.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/2c/94ed7b91db81d61d7096ac8f2d325ec562fc75e35f3baea8749c85b28784/PyPika-0.48.9.tar.gz", hash = "sha256:838836a61747e7c8380cd1b7ff638694b7a7335345d0f559b04b2cd832ad5378", size = 67259, upload-time = "2022-03-15T11:22:57.066Z" } - [[package]] name = "pypinyin" version = "0.55.0" @@ -4950,24 +4281,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b9/7b/4cabc76fcc21c3c7d5c671d8783984d30ac9d3bb387c4ba784fca3cdfa3a/pypinyin-0.55.0-py2.py3-none-any.whl", hash = "sha256:d53b1e8ad2cdb815fb2cb604ed3123372f5a28c6f447571244aca36fc62a286f", size = 840203, upload-time = "2025-07-20T12:01:48.535Z" }, ] -[[package]] -name = "pyproject-hooks" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, -] - -[[package]] -name = "pyreadline3" -version = "3.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7", size = 99839, upload-time = "2024-09-19T02:40:10.062Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" }, -] - [[package]] name = "pytest" version = "9.0.3" @@ -5038,20 +4351,20 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.2.1" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] name = "python-multipart" -version = "0.0.20" +version = "0.0.31" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +sdist = { url = "https://files.pythonhosted.org/packages/64/7e/9b35ad8f3d9ca680f7c87a88f19612fdd8da9796c4d3b46e560ac79dcc4a/python_multipart-0.0.31.tar.gz", hash = "sha256:fc631183bb13e56db3158a4909908dfb2e23565286744e798241e63750e5d680", size = 46689, upload-time = "2026-06-04T08:27:49.014Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, + { url = "https://files.pythonhosted.org/packages/5e/1e/7f7f299527a5a8ad90acd5f2f78dfa6c8495c6301a3205106ea68a84de96/python_multipart-0.0.31-py3-none-any.whl", hash = "sha256:8408153d68a9773291fc1da39a8b85a50044bddbabd2dd72e9229776b7b15e28", size = 29996, upload-time = "2026-06-04T08:27:47.804Z" }, ] [[package]] @@ -5409,19 +4722,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] -[[package]] -name = "requests-oauthlib" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "oauthlib" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, -] - [[package]] name = "requests-toolbelt" version = "1.0.0" @@ -5434,19 +4734,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, ] -[[package]] -name = "rich" -version = "14.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, -] - [[package]] name = "rpds-py" version = "0.28.0" @@ -5759,15 +5046,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/f6/f09272a71976dfc138129b8faf435d064a811ae2f708cb147dccdf7aacdb/shapely-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:0036ac886e0923417932c2e6369b6c52e38e0ff5d9120b90eef5cd9a5fc5cae9", size = 1796682, upload-time = "2025-09-24T13:51:39.233Z" }, ] -[[package]] -name = "shellingham" -version = "1.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, -] - [[package]] name = "simhash" version = "2.1.2" @@ -5873,11 +5151,11 @@ wheels = [ [[package]] name = "soupsieve" -version = "2.8" +version = "2.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" } +sdist = { url = "https://files.pythonhosted.org/packages/80/f1/93422647dd7e461f23d254e6b2bfa687a85b53aeb4903fcdbb74474d4584/soupsieve-2.9.tar.gz", hash = "sha256:acee8417325c5653e1377dc31eccad59eb82cbc65942afe6174c53b3aaad63fc", size = 122122, upload-time = "2026-07-19T01:35:18.425Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d6/3185ab5ad1280319b31986898f3206dd7227cd75e293d4dba2a5e6bf27a0/soupsieve-2.9-py3-none-any.whl", hash = "sha256:a2b2c76d67df2382d245409fd71e321a571717e58463efa32ace87dcadac2c12", size = 37387, upload-time = "2026-07-19T01:35:17.106Z" }, ] [[package]] @@ -6235,21 +5513,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/d3/c16c3b3cf7655a67db1144da94b021c200ac1303f82428f2beef6c2e72bb/transformers-4.57.1-py3-none-any.whl", hash = "sha256:b10d05da8fa67dc41644dbbf9bc45a44cb86ae33da6f9295f5fbf5b7890bd267", size = 11990925, upload-time = "2025-10-14T15:39:23.085Z" }, ] -[[package]] -name = "typer" -version = "0.20.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "rich" }, - { name = "shellingham" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8f/28/7c85c8032b91dbe79725b6f17d2fffc595dff06a35c7a30a37bef73a1ab4/typer-0.20.0.tar.gz", hash = "sha256:1aaf6494031793e4876fb0bacfa6a912b551cf43c1e63c800df8b1a866720c37", size = 106492, upload-time = "2025-10-20T17:03:49.445Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028, upload-time = "2025-10-20T17:03:47.617Z" }, -] - [[package]] name = "typing-extensions" version = "4.15.0" @@ -6295,11 +5558,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.3.0" +version = "2.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268, upload-time = "2024-12-22T07:47:30.032Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369, upload-time = "2024-12-22T07:47:28.074Z" }, + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] [[package]] @@ -6415,55 +5678,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" }, ] -[package.optional-dependencies] -standard = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "httptools" }, - { name = "python-dotenv" }, - { name = "pyyaml" }, - { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, - { name = "watchfiles" }, - { name = "websockets" }, -] - -[[package]] -name = "uvloop" -version = "0.22.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, - { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, - { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, - { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, - { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, - { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, - { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, - { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, - { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, - { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, - { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, - { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, - { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, - { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, - { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, - { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, - { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, - { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, - { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, - { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, - { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, - { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, - { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, - { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, - { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, - { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, -] - [[package]] name = "validators" version = "0.35.0" @@ -6482,93 +5696,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" }, ] -[[package]] -name = "watchfiles" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, - { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, - { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, - { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, - { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, - { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, - { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, - { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, - { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, - { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, - { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, - { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, - { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, - { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, - { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, - { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, - { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, - { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, - { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, - { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, - { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, - { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, - { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, - { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, - { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, - { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, - { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, - { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, - { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, - { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, - { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, - { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, - { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, - { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, - { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, - { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, - { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, - { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, - { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, - { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, - { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, - { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, - { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, - { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, - { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, - { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, - { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, - { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, - { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, - { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, - { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, - { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, - { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, - { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, - { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, - { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, - { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, - { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, - { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, - { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, - { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, - { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, - { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, - { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, - { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, -] - [[package]] name = "wcmatch" version = "10.1" @@ -7004,15 +6131,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a5/b5/cb50cd478426b17503154585be799e09506b70944e2fe2402aa029987fa2/zhipuai-1.0.7-py3-none-any.whl", hash = "sha256:360c01b8c2698f366061452e86d5a36a5ff68a576ea33940da98e4806f232530", size = 7873, upload-time = "2023-06-12T12:15:30.355Z" }, ] -[[package]] -name = "zipp" -version = "3.23.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, -] - [[package]] name = "zstandard" version = "0.25.0" diff --git a/src/bisheng-cli/README.md b/src/bisheng-cli/README.md new file mode 100644 index 0000000000..39b52644f9 --- /dev/null +++ b/src/bisheng-cli/README.md @@ -0,0 +1,259 @@ +# bisheng CLI + +`bisheng` 是毕昇应用工场的开发者命令行工具:拿服务账号密钥登录平台、把本地项目打包发布成托管应用、查看自己名下应用的运行日志。 + +它是一个**独立的 Python 包**,不依赖后端代码、不连任何数据库、不访问公网。安装件由平台自己分发,运行期只访问 `login` 时指定的那一个地址。 + +本版本提供 `login` / `deploy` / `logs` / `skills sync` 四条命令。`dev` 随后续版本提供 —— 它**没有**注册进命令行,`--help` 里看不到,也不会给出占位报错。 + +--- + +## 安装 + +安装件由平台分发,不需要连公网 registry: + +```bash +# -OJ 让 curl 用平台在 Content-Disposition 里给的真实文件名 +# (bisheng_cli-<版本>-py3-none-any.whl)——pip 只认这种五段式 wheel 名。 +# 别用 -o bisheng.whl 或 -sO:前者存成 pip 拒收的短名,后者按 URL 末段存成 download。 +curl -OJ http://<平台地址>/api/v1/dev-toolkit/cli/download +pip install ./bisheng_cli-*.whl + +bisheng --version +``` + +要求 Python ≥ 3.11。运行期依赖只有 `httpx` 与 `PyYAML` 两条 —— 每多一条依赖,就多一次「客户内网 pip 镜像里没有这个包」的失败面。 + +平台上是否提供该端点,取决于是否部署了开放能力层。若 `GET /api/v1/dev-toolkit/versions` 返回 404,说明该环境未部署(或平台版本过老),此时 CLI 无法使用,`login` 会给出可读原因并以确定的退出码结束(见退出码 8 / 9)。 + +--- + +## 命令 + +### 全局参数 + +| 参数 | 说明 | +|---|---| +| `--version` / `--help` | 版本与帮助 | +| `--json` | 机器可读输出:NDJSON 走 **stdout**,人读文本走 **stderr** | +| `--verbose` | 打印请求方法 / 路径 / 状态码 / 耗时(**Authorization 头恒掩码**) | +| `--quiet` | 只输出错误 | +| `--timeout` | 普通请求读超时秒数(默认 60;上传固定 240) | +| `--no-proxy` | 忽略 `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` 环境变量 | + +本版本**没有** `--platform` 参数:命令一律使用 `credentials.json` 里 `current` 指向的平台(由最近一次 `login` 覆盖)。也**没有** `--as` 或任何指定他人身份的参数。 + +### `bisheng login ` + +校验密钥并把凭据写入 `~/.bisheng/credentials.json`(目录 `0700`、文件 `0600`)。 + +| 参数 | 说明 | +|---|---| +| `--api-key` | 直接传密钥(会进 shell 历史,建议用其它方式) | +| `--api-key-stdin` | 从标准输入读密钥(非交互环境首选) | + +密钥来源优先级:`--api-key` > 环境变量 `BISHENG_API_KEY` > `--api-key-stdin` > 终端隐藏输入。四者皆无且没有终端可问 → 退出 2(**不会**默认取空值去试)。 + +```bash +BISHENG_API_KEY=bs-sak-… bisheng login http://<平台地址> +ls -l ~/.bisheng/credentials.json # 必须是 -rw------- +``` + +输出目标平台、服务账号名、资源归属人、密钥掩码、到期时间。其中资源归属人平台只返回用户 ID(`whoami` 的 `resource_owner` 只有 `user_id` 一个字段),对应姓名在服务账号详情页核对。 +**`login` 不校验任何权限位** —— 一把还没勾任何位的有效密钥也能登录成功,这样「密钥不对」和「密钥缺权限位」才是两个可区分的诊断。 +被配置为**委托专用**(`delegate`)的密钥会被拒绝,且**不写入凭据**:这类密钥用于身份传递,本地开发要另外签发一把。 + +> 密钥以**明文**保存 —— 每次请求都要用原值,没有可派生的东西。文件权限位是唯一保护,所以文件用 `os.open(..., 0o600)` 一步创建,不存在「先写再 chmod」的可读窗口。凭据**永不**写进工程目录,也**永不**进上传包。 +> 凭据里**不存 `scopes`**:权限位由服务端每次调用重新判定,客户端一旦缓存就会出现「管理员补勾了但 CLI 还说没权限」。 + +### `bisheng deploy [PATH]` + +打包当前项目并发布。`PATH` 默认为当前目录。 + +| 参数 | 说明 | +|---|---| +| `--app-id` | 显式指定目标应用(覆盖 `.bisheng/app.json`) | +| `--dry-run` | 只做本地校验与打包、打印体量统计,**不上传**,平台上不创建任何东西 | +| `--wait` | 等待审批与上线结果落定(默认不等:审批是人工动作,可跨天) | +| `--wait-timeout` | `--wait` 的最长等待秒数,默认 1800 | +| `--confirm-schema-change` | 确认本次发布包含应用数据表结构变更 | +| `--yes` | 跳过迭代发布时的目标应用确认 | + +```bash +bisheng deploy --dry-run # 先看打包体量与忽略统计 +bisheng deploy +bisheng deploy --wait --json | tee deploy.ndjson; echo "exit=$?" +``` + +**本地只做三项快速失败**:`bisheng-app.yaml` 存在、可解析、含 `name` / `runtime` / `port`。其余字段(`tier` / `capabilities` / `database` / `egress` …)一律不在本地判 —— 声明的权威校验在平台托管预检,本地自造校验或缺省值只会制造「本地过、上传被拒」。 + +**打包规则**(三层,从内到外): + +1. 结构性排除。**硬排除、`!` 取不回**:`.git/` `.hg/` `.svn/` `node_modules/` `.venv/` `venv/` `env/` `__pycache__/` `*.pyc` `.pytest_cache/` `.ruff_cache/` `.mypy_cache/` `*.egg-info/` `.DS_Store` `.bisheng/`。**软排除、可用 `.bishengignore` 的 `!` 取回**:`*.db` `*.sqlite` `*.sqlite3` `dist/` `build/` `attachments/` `uploads/`。 + (`dist/` 刻意不硬排:存量应用把前端构建产物放那里交后端静态托管是常见形态,硬排会把它静默剥掉,而故障要到构建或探活阶段才暴露。) +2. `.gitignore` 语义。是 git 仓库且本机有 git → 直接交给 `git ls-files` 算;否则回落到自研子集解析器,并在输出里明说。 +3. `.bishengignore`(可选,同 `.gitignore` 语法,最后加载、优先级最高)。 + +打包**绝不静默截断**:超限就整包拒绝并列出「已排除多少 + Top 10 最大文件 / 目录」。符号链接、设备文件、FIFO 会被本地跳过**并列出**(服务端解包闸会拒它们)。文件模式归一为 `0644`,但**保留 owner 执行位为 `0755`** —— 丢了它,entrypoint 上线后不可执行,而排查方向会指向「平台构建有问题」。同样内容两次打包产出同样的 sha256。 + +**应用标识**存在项目根的 `.bisheng/app.json`,按平台地址分键。建议**提交进 git**(团队共享同一个应用),它**不会**进上传包 —— 「进 git」与「进包」是两件事。 +标识在 `POST /deploy` 返回 200 的那一刻就写盘,**不等管线成功**:首发预检失败时平台已经建好草稿应用并分配了标识,只在成功时才保存会让你修完 manifest 重跑时**再建一个应用**。 + +**迭代发布有误投护栏**:上传前打印目标应用的名称与标识。指定了归属人不同的应用会被服务端拒绝(安全),但指定了**同一归属人的另一个应用**会真的更新那一个 —— 那是无声的错误,所以非交互环境下这行输出就是唯一的护栏。 + +**默认不等审批**:轮询到审批单生成即返回 0,并给出三条跟踪路径(应用详情页·发布 / MCP 应用状态工具 / `deploy --wait`)。撤回只在应用详情页·发布做,CLI 不提供撤回命令。 + +### `bisheng logs` + +查看自己名下应用的运行日志。 + +| 参数 | 说明 | +|---|---| +| `--app-id` | 显式指定应用(覆盖 `.bisheng/app.json`) | +| `--tail` | 返回最后 N 行,默认 200 | +| `--since` | 起始时间:**epoch 秒**,或 `30m` / `2h` / `7d` 相对窗口 | +| `--keyword` | 只返回含该关键字的行 | +| `--follow` | 持续拉取(短轮询 3 秒;服务端无流式接口) | + +```bash +bisheng logs --tail 200 +bisheng logs --since 30m --keyword ERROR +bisheng logs --follow +``` + +几条容易误判的事实: + +- **带 `--keyword` 时返回行数可能少于 `--tail`** —— 过滤发生在服务端,这是设计不是截断,CLI 不会因此重试或告警。 +- **`--follow` 会去重**:容器日志时间戳是秒级,同一秒的行会在下一轮窗口里重复返回。 +- **取不到日志不等于「什么都没发生」**:可能是应用没有运行实例(草稿 / 待上线 / 已下线),也可能是该时间段的日志已被轮转。平台对日志保留期不做承诺。 +- **归属判定完全在服务端**:owner-only 按密钥的**资源归属人**判,CLI 不做任何预检 —— 它拿不到权威数据,加一处判定只会与服务端分叉。 + +### `bisheng skills sync` + +把平台**当前版本**的开发者技能包同步到 `~/.bisheng/skills/`。技能包是给 AI 编程工具读的指引 —— 让它照着平台的托管运行契约写出可部署的应用。 + +- **登录时自动执行一次**(`login` 成功后即触发),一般无需手动敲;平台升级后重跑即更新到新版本。 +- **单向覆盖**:技能包是平台发布物,sync 以平台版本覆盖本地内容、并列出被覆盖的文件,**不做合并、不保留本地改动、不覆盖前确认**。要定制团队自己的约定,写在项目的 `AGENTS.md` 里、放在技能目录**之外**。 +- 同步完成后会输出「怎么让你的 AI 工具用上它」的指引(Claude Code 自动发现;其它引擎经 `AGENTS.md` 指向同一目录)。 +- 平台未部署开放能力层(端点 404)时以退出码 8 结束并提示。 + +```bash +bisheng skills sync +``` + +--- + +## 退出码 + +退出码是**对外契约**,本地 coding agent 靠它决定下一步。分格判据是**下一步动作是否不同**,不是错误的严重程度。 + +| 码 | 含义 | 下一步 | +|---|---|---| +| 0 | 成功(`--wait` 下 = 通过并上线) | — | +| 1 | CLI 内部未预期异常 | 加 `--verbose` 重跑并报障;可以先重试一次 | +| 2 | 用法 / 参数错误 | 补齐参数 | +| 3 | 未登录 | 先 `bisheng login` | +| 4 | 鉴权失败(`26001` / `26002` / `26027`) | 换 / 重签一把密钥;`26027` 是账号被停用,要找管理员启用 | +| 5 | 权限不足或委托专用密钥(`26003`) | 找平台管理员编辑该密钥的权限位(无需重新签发) | +| 6 | 本地校验失败 / 包不合法(`16101` `16201` `16202` `16203`) | 改本地再传 | +| 7 | 平台不可达 / 服务暂不可用(网络错误、`26030`、`16121`) | **可重试** | +| 8 | 环境未启用(分发端点 404、`16207`) | 找管理员开开关并重启后端 | +| 9 | 平台版本过老,不支持本 CLI | 升级平台,或换用与平台同版的 CLI | +| 10 | 托管预检失败(`16221`–`16224` `16227` `16228` `16230` `16231`) | 改 manifest / 改代码后重发(`16230` / `16231` 是删掉 `capabilities` 声明) | +| 11 | 密钥扫描命中(`16241`) | 移除密钥后重发 | +| 12 | 发布流程冲突 / 归属与操作权限(`16205` `16229` `16251` `16252` `16254`) | 撤回在途单,或换归属人正确的密钥 | +| 13 | 平台未配置发布审批场景(`16225`) | 找平台管理员,**改代码无用** | +| 14 | 运行环境容量不足(`16226`) | 等资源 / 稍后重试 / 让归属人手动上线 | +| 18 | **缺陷类**(`26004` = CLI 缺陷,`26031` = 平台缺陷) | 停下报障;**重试与改参数都无用** | +| 19 | 平台返回未登记的错误码 | 按打印出的 `code` + `message` 处置 | +| 20 | `--wait` 终态:审批被驳回 | 按驳回理由(全文输出,不截断)修改后重发 | +| 21 | `--wait` 终态:审批被撤回 | 撤回发生在应用详情页·发布 | +| 22 | `--wait` 终态:待上线 | 等资源,或由归属人在应用详情页·发布手动上线 | +| 23 | `--wait` 超时 | **这不是失败** —— 继续等或换跟踪方式 | +| 24 | `--wait` 终态:审批单已取消(目标应用被删除) | 这一单不会有结论;重新发布会创建新应用 | +| 25 | `--wait` 终态:审批异常(审批人解析为空) | 找管理员处理审批异常;这一单不会有结论 | + +> `13` 与 `14` **绝不合并**:前者是「平台没配审批场景,改代码没用」,后者是「机器没资源,等一会儿」。 +> `18` 与 `1` / `19` 也**绝不合并**:`1` 值得重试一次,`19` 可以改参数再试,`18` 两者都保证无用。 + +--- + +## `--json` 事件契约 + +加 `--json` 后,**stdout 的每一行都是一个 JSON 对象**(人读文本一律走 stderr,所以管道不会被进度文本污染)。三种事件: + +```jsonc +{"event":"stage","command":"deploy","stage":"precheck_build","status":"running","ts":"…"} +{"event":"progress","command":"deploy","phase":"upload","sent_bytes":123,"total_bytes":456} +{"event":"result","command":"deploy","ok":false,"exit_code":10, + "data":{"deployment_id":"…","app_id":"…","version_id":"…","entry_url":null, + "stage":"…","app_state":"…","pending_reason":null,"approval":{…}}, + "failure":{"stage":"…","code":16227,"message":"…","details":{…},"hints":["…"]}} +``` + +可依赖的不变量: + +- **`result` 恒为最后一行,且每次运行恰好一条** —— 崩溃路径也有。只读最后一行就能判定结果。 +- **`stage` 恒为服务端原值**,不翻译;不认识的阶段原样透传,**不是错误**。阶段的**顺序由服务端决定**,CLI 不做顺序假设(服务端已经调整过一次),所以不要在消费端写死顺序。 +- **`failure` 是服务端五元组的原样透传** —— `details` 与 `hints` 一个字都不吞,那是本地 agent 自动修复的全部输入。 +- 同步段的失败**没有 `stage` 事件**:`POST /deploy` 的同步错误直接落进 `result.failure`。 +- `data.entry_url` 只可能来自 `POST /deploy` 的返回,首发时通常为 `null`;**不要把 `null` 当成上线失败**,看 `exit_code` 与 `data.app_state`。 + +```bash +bisheng deploy --wait --json | jq -c 'select(.event=="stage")' +bisheng deploy --json | tail -1 | jq .exit_code +``` + +--- + +## 排障 + +**1. 先看 body 里的 `status_code` 是 `20001` 还是 `260xx`。** +多租户环境下默认租户被禁用会返回一个与密钥完全无关的 `20001`/403。把它当成「密钥不对」会让人反复重新签发密钥,而真正要做的是去解禁默认租户。 + +**2. 「平台不可达」十有八九是代理。** +`httpx` 默认读 `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY`,而开发机上常年挂着通往公网的代理、平台却是内网地址 —— 请求被送去代理后超时或拿到代理的 502,看起来就像平台挂了。CLI 在连接失败时会把检测到的代理变量名和值打出来。处置:把平台地址加进 `NO_PROXY`,或加 `--no-proxy`。 + +**3. `deployment_id` 是与平台侧对账的唯一凭据。** +「CLI 说失败了」要拿 `deployment_id` 去平台侧的发布记录与 `app.release.*` 审计事件对账,不要只看终端输出 —— CLI 没有服务端日志。 + +**4. 首次 deploy 就被拒且提示包太大?** 先看输出里「已排除多少项」。绝大多数情况是 `.venv/` 或 `node_modules/` 没被排除(本地不是 git 仓库时忽略规则走子集解析,复杂规则要写进 `.bishengignore`),而不是平台上限太小。 + +**5. 输出里永远不会出现密钥。** 包括 `--verbose`:`Authorization` 头恒掩码。扫描命中只打 `文件:行号` 与规则名,CLI 也不会去本地文件里把那一行读出来补打 —— 那等于把服务端刻意不给的东西自己补回来。 + +--- + +## 开发与发版 + +```bash +cd src/bisheng-cli +uv sync --extra dev +uv run pytest # 带 @pytest.mark.network 的用例默认跳过 +uv run ruff check . +uv run ruff format --check . +``` + +**发版契约**:改了 CLI 就必须重跑 + +```bash +bash scripts/pack_cli_wheel.sh +``` + +并把产物(`src/backend/bisheng/dev_toolkit/artifacts/` 下的 wheel 与 `manifest.json`)**一起提交**。后端镜像的构建上下文只有 `./src/backend/`,`src/bisheng-cli/` 不在镜像里 —— 不提交产物,平台分发的就还是上一版 wheel,而且没有任何提示。该脚本自带「装进干净 venv 后 import 生产入口」的冒烟:源码树里跑 pytest 永远看不见「依赖解析到新版本后 import 期就崩」这一类问题,因为它测的是开发环境已经解析好的那组版本。 + +两条依赖(`httpx` / `PyYAML`)**都带上界**。CLI 的分发路径是开发者机器上的 `pip install `,`uv.lock` 在那条路径上完全不参与解析 —— 能约束它的只有 wheel metadata 里的上界。放宽上界是一次显式决定(先跑通打包脚本的冒烟),不是顺手升。 + +## Create a runtime v2 application + +`bisheng init example-app --runtime python3.11` creates a manifest v2 application +bundle. `node20` and `static` are also supported. Its startup argv is explicit; +publish with the ordinary `bisheng deploy` command. The platform must have runtime +v2 and an imported default runtime profile. Existing manifest v1 projects remain +supported without automatic conversion. Private offline material IDs must be +imported by the application owner before being declared in the manifest. + + +F086 的私有离线依赖由当前应用自然人 owner 通过 backend 管理接口导入并绑定。 +之后相同 profile/平台的发布自动消费已授权材料,普通 SAK 发布无需取得补包管理权限。 +显式在清单中选择私有 `offline_artifacts` 仍要求 owner;`enabled` 只改变未来发布。 +基础镜像替换、完全离线交付和故障处理见 [runtime-manager/OPERATIONS.md](../runtime-manager/OPERATIONS.md)。 diff --git a/src/bisheng-cli/bisheng_cli/__init__.py b/src/bisheng-cli/bisheng_cli/__init__.py new file mode 100644 index 0000000000..c54d90cdb4 --- /dev/null +++ b/src/bisheng-cli/bisheng_cli/__init__.py @@ -0,0 +1,9 @@ +"""BiSheng developer CLI. + +Single source of truth for the version: `--version`, the compatibility probe in +`http.py` and the `manifest.json` written by `scripts/pack_cli_wheel.sh` all read +`__version__` from here. Bumping it in two places is how the download endpoint +starts advertising a version that is not the one inside the wheel. +""" + +__version__ = "3.0.0.post2" diff --git a/src/bisheng-cli/bisheng_cli/agent_skills.py b/src/bisheng-cli/bisheng_cli/agent_skills.py new file mode 100644 index 0000000000..5182d1e589 --- /dev/null +++ b/src/bisheng-cli/bisheng_cli/agent_skills.py @@ -0,0 +1,238 @@ +"""Wire synced skill packs into the AI coding tools installed on this machine. + +`skills sync` writes packs under `~/.bisheng/skills/`, which is the platform's own +directory — **no coding agent scans it**. A pack that lands there and stops there +is the worst failure this feature can produce: the agent keeps working, unaware of +the hosting contract, and the developer only finds out at `bisheng deploy`, from an +error that says nothing about a missing skill. Wrong-but-confident beats ignorant +here, and it beats it in the expensive direction. So syncing does not end at "files +written" — it wires the pack into every agent skills dir on the machine and reports +which ones took it. + +Four rules this module pins down. + +* **Install into all of them; never pick one.** Asymmetric cost: an extra link is + a zero-byte inode, a missing one is a silent failure. And "which agent is the + developer using right now" has no answer worth acting on — the same project gets + opened in Codex today and Claude Code tomorrow, often both at once. +* **Link, don't copy.** A symlink means the next `sync` reaches every agent at + once, and it keeps `~/.bisheng/skills/` the single source. Copying is the Windows + fallback only (symlinks there need Developer Mode), and it is redone on every + sync precisely because a copy cannot follow the source. +* **User-level, not project-level.** The pack's version follows the platform, not + the project (see `commands/skills.py`), so it must not be committed into a + business repo where git would freeze it at whatever the contract was that week. + What belongs in the project is a *pointer* — `commands/deploy.py` writes that. +* **Never overwrite something we did not create.** A path already occupied by the + developer's own skill is reported as a conflict and left alone. Managed copies + carry a `.bisheng-managed` marker so a later sync can tell its own work from + someone else's. +""" + +from __future__ import annotations + +import os +import shutil +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +# Coding tools that discover skills from a per-user directory. The home dir is the +# install signal (an agent may not have created `skills/` yet — it only appears +# once the user first has a skill), so detection keys off `~/.claude`, not +# `~/.claude/skills`. +# +# Tools without a skills-directory convention (Cursor, Cline, Windsurf, …) are not +# absent from the design — they read `AGENTS.md`, which is why `deploy` leaves a +# pointer there. Adding one of them *here* would create a directory the tool never +# reads, i.e. exactly the silent success this module exists to prevent. +KNOWN_AGENTS: tuple[tuple[str, str, str], ...] = ( + ("claude-code", "Claude Code", ".claude"), + ("codex", "Codex", ".codex"), +) + +SKILLS_SUBDIR = "skills" + +# Written into a managed *copy* so a later sync knows it may replace it. Symlinks +# need no marker — their target already identifies them as ours. +MANAGED_MARKER = ".bisheng-managed" + + +@dataclass(frozen=True) +class AgentTarget: + key: str + label: str + skills_dir: Path + + +def detect(home: Path | None = None) -> list[AgentTarget]: + """Agent skills dirs on this machine, in `KNOWN_AGENTS` order.""" + root = home if home is not None else Path.home() + found = [] + for key, label, dirname in KNOWN_AGENTS: + agent_home = root / dirname + if agent_home.is_dir(): + found.append(AgentTarget(key=key, label=label, skills_dir=agent_home / SKILLS_SUBDIR)) + return found + + +def install(pack_dir: Path, pack: str, targets: list[AgentTarget]) -> list[dict[str, Any]]: + """Point every target at `pack_dir`. Never raises — a wiring failure is reported. + + One entry per target, with `status` in `linked` / `conflict` / `failed`. The + caller decides how loud to be about each; this function only refuses to be the + place where a failure disappears. + """ + results: list[dict[str, Any]] = [] + for target in targets: + results.append(_install_one(pack_dir, pack, target)) + return results + + +def _install_one(pack_dir: Path, pack: str, target: AgentTarget) -> dict[str, Any]: + entry: dict[str, Any] = {"agent": target.key, "label": target.label, "pack": pack} + link = target.skills_dir / pack + entry["path"] = str(link) + + try: + target.skills_dir.mkdir(parents=True, exist_ok=True) + except OSError as exc: + return {**entry, "status": "failed", "reason": f"无法创建 {target.skills_dir}:{exc.__class__.__name__}"} + + occupied = _classify(link, pack_dir) + if occupied == "foreign": + return { + **entry, + "status": "conflict", + "reason": f"{link} 已存在且不是本 CLI 创建的,未改动", + } + + try: + _clear(link) + except OSError as exc: + return {**entry, "status": "failed", "reason": f"无法清理 {link}:{exc.__class__.__name__}"} + + try: + os.symlink(pack_dir, link, target_is_directory=True) + return {**entry, "status": "linked", "mode": "symlink"} + except (OSError, NotImplementedError, AttributeError): + # Windows without Developer Mode, or a filesystem that has no symlinks. + # A copy cannot follow later syncs, so it is re-made on every sync and the + # mode is reported — the developer needs to know the link is not live. + pass + + try: + shutil.copytree(pack_dir, link) + (link / MANAGED_MARKER).write_text("bisheng skills sync\n", encoding="utf-8") + return {**entry, "status": "linked", "mode": "copy"} + except OSError as exc: + return {**entry, "status": "failed", "reason": f"无法写入 {link}:{exc.__class__.__name__}"} + + +def _classify(link: Path, pack_dir: Path) -> str: + """`absent` / `managed` / `foreign` — who owns the path we want to occupy. + + A broken symlink counts as managed: it is the normal state after the user + deletes `~/.bisheng`, and refusing to fix it would strand every agent. + """ + if link.is_symlink(): + try: + resolved = link.resolve() + except OSError: + return "managed" + # Ours if it points into the bisheng skills tree at all — the profile slug + # in between changes whenever the developer logs into another platform, so + # matching the exact current target would wrongly call it foreign. + bisheng_root = pack_dir.parent.parent + return "managed" if (resolved == pack_dir or bisheng_root in resolved.parents) else "foreign" + if not link.exists(): + return "absent" + if link.is_dir() and (link / MANAGED_MARKER).is_file(): + return "managed" + return "foreign" + + +def _clear(link: Path) -> None: + if link.is_symlink() or link.is_file(): + link.unlink() + elif link.is_dir(): + shutil.rmtree(link) + + +def summarise(results: list[dict[str, Any]]) -> tuple[list[str], list[dict[str, Any]]]: + """`(labels that took the pack, entries that did not)`.""" + linked = [r["label"] for r in results if r.get("status") == "linked"] + problems = [r for r in results if r.get("status") != "linked"] + return linked, problems + + +# ---- project-level pointer ---------------------------------------------- + +POINTER_FILE = "AGENTS.md" +POINTER_MARKER = "" + + +def ensure_project_pointer(root: Path, skills_root: Path, packs: list[str]) -> dict[str, Any]: + """Leave a pointer to the packs in the project's ``AGENTS.md``. + + A *pointer*, never the pack itself. The pack's version follows the platform, so + a copy committed to a business repo is frozen at whatever the contract was that + week and goes on being read long after it stopped being true. A path does not + rot the same way, and it is the only channel that reaches tools with no skills + directory at all (Cursor, Cline, Windsurf). + + Written with ``~`` rather than the absolute home path on purpose: this file is + meant to be committed, and a teammate's home dir is not the developer's. The + profile slug is derived from the platform URL, so it is the same for everyone + deploying to the same platform. + + Idempotent via `POINTER_MARKER`, and strictly additive — an existing AGENTS.md + is appended to, never rewritten. + """ + path = Path(root) / POINTER_FILE + try: + existing = path.read_text(encoding="utf-8") if path.is_file() else None + except OSError as exc: + return {"status": "failed", "path": str(path), "reason": exc.__class__.__name__} + + if existing is not None and POINTER_MARKER in existing: + return {"status": "present", "path": str(path)} + + block = _pointer_block(skills_root, packs) + try: + if existing is None: + path.write_text(block, encoding="utf-8") + return {"status": "created", "path": str(path)} + separator = "" if existing.endswith("\n\n") else ("\n" if existing.endswith("\n") else "\n\n") + path.write_text(existing + separator + block, encoding="utf-8") + return {"status": "appended", "path": str(path)} + except OSError as exc: + return {"status": "failed", "path": str(path), "reason": exc.__class__.__name__} + + +def _display_root(skills_root: Path) -> str: + """`~/.bisheng/skills/` — home-relative so the line survives being committed.""" + try: + return "~/" + str(Path(skills_root).relative_to(Path.home())) + except ValueError: + return str(skills_root) + + +def _pointer_block(skills_root: Path, packs: list[str]) -> str: + root = _display_root(skills_root) + lines = [ + POINTER_MARKER, + "## BiSheng 应用平台", + "", + "本项目通过 `bisheng deploy` 部署到 BiSheng 应用平台。托管契约(应用必须遵守的约定)、", + "`bisheng-app.yaml` 的写法和部署排障都在技能包里,改动本项目前请先读:", + "", + ] + lines += [f"- `{root}/{pack}/SKILL.md`" for pack in packs] + lines += [ + "", + "技能包由 `bisheng skills sync` 获取、版本跟随平台,不要拷贝进本仓库——拷贝会被 git", + "冻结在旧版本,而平台的规矩会继续变。", + "", + ] + return "\n".join(lines) diff --git a/src/bisheng-cli/bisheng_cli/cli.py b/src/bisheng-cli/bisheng_cli/cli.py new file mode 100644 index 0000000000..9c96a3c612 --- /dev/null +++ b/src/bisheng-cli/bisheng_cli/cli.py @@ -0,0 +1,199 @@ +"""Argument surface. Standard-library `argparse`, no click/typer. + +The dependency budget (design D12) buys two runtime dependencies and this is not +one of them: a subcommand parser with auto-generated `--help` is exactly what +argparse already does. What we give up is colour and shell completion, neither of +which any acceptance criterion asks for — AC-04 asks for "usable with no TTY and +machine-readable", which is orthogonal to looking nice. + +A command is either registered or absent — never a stub. Registering a name so it +can answer "not in this version" would make `--help` advertise it, an agent would +call it, and it would get a non-standard failure. `DEFERRED_COMMANDS` is kept as +the (now empty) list of names announced in the epilogue but not registered, so +the test that guards "announced ≠ registered" keeps its shape for the next round +that defers something. +""" + +from __future__ import annotations + +import argparse +from collections.abc import Callable + +from bisheng_cli import __version__ +from bisheng_cli.errors import EXIT_USAGE, CliError + +SUBCOMMANDS = ("init", "login", "deploy", "logs", "skills", "dev", "platforms") +DEFERRED_COMMANDS: tuple[str, ...] = () + +_EPILOG = ( + "命令: login / deploy / logs / skills sync / dev / platforms。\n" + "目标平台: 默认是最近一次 login 的平台;--platform <地址> 指定另一个已登录的平台," + "bisheng platforms use <地址> 切换默认。\n" + "机器可读输出: 加 --json,NDJSON 走 stdout、人读文本走 stderr,最后一行恒为 result 事件。" +) + + +def _add_global_flags(parser: argparse.ArgumentParser, *, mirror: bool) -> None: + """Global flags, defined on the root parser and mirrored onto each subcommand. + + argparse only accepts a root-level option *before* the subcommand, so a bare + definition would make `bisheng deploy --json` fail with "unrecognized + arguments" — and that is the order everyone writes, including this feature's + own verification script. Mirroring the flags onto every subparser makes both + orders work. + + The mirrors must use `default=SUPPRESS`. argparse parses a subcommand into a + fresh namespace and then copies every key of it onto the outer one, so a + mirror carrying an ordinary default would overwrite the value already parsed + from `bisheng --json deploy` with `False`. SUPPRESS leaves the key out of the + sub-namespace entirely when the flag was not given, so whatever the root + parser decided survives. + """ + + def default(value: object) -> object: + return argparse.SUPPRESS if mirror else value + + parser.add_argument( + "--verbose", + action="store_true", + default=default(False), + help="打印请求方法 / 路径 / 状态码 / 耗时(Authorization 头恒掩码)", + ) + parser.add_argument("--quiet", action="store_true", default=default(False), help="只输出错误") + parser.add_argument( + "--json", action="store_true", dest="json_mode", default=default(False), help="机器可读 NDJSON 输出(stdout)" + ) + parser.add_argument("--timeout", type=float, default=default(None), help="普通请求读超时秒数(默认 60)") + parser.add_argument( + "--no-proxy", + action="store_true", + default=default(False), + help="忽略 HTTP_PROXY / HTTPS_PROXY / ALL_PROXY 环境变量", + ) + # AC-12: selects among platforms that were each logged into on their own. + # It never creates a profile — an address with no stored key is exit 3. + parser.add_argument( + "--platform", + metavar="BASE_URL", + default=default(None), + help="作用于这个已登录的平台(默认:最近一次 login 或 platforms use 选定的平台)", + ) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="bisheng", + description="BiSheng 开发者 CLI:登录平台、本地运行、发布托管应用、查看运行日志。", + epilog=_EPILOG, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--version", action="version", version=f"bisheng {__version__}") + _add_global_flags(parser, mirror=False) + + subparsers = parser.add_subparsers(dest="command", metavar="{" + ",".join(SUBCOMMANDS) + "}") + + init = subparsers.add_parser("init", help="Create an application bundle project") + init.add_argument("path", nargs="?", default=".") + init.add_argument("--runtime", choices=("python3.11", "node20", "static"), default="python3.11") + + login = subparsers.add_parser("login", help="校验服务账号密钥并把凭据写入本地用户目录") + login.add_argument("base_url", metavar="BASE_URL", help="目标平台地址,例如 http://bisheng.example.com") + key_source = login.add_mutually_exclusive_group() + key_source.add_argument( + "--api-key", dest="api_key", default=None, help="直接传入密钥(会进入 shell 历史,建议改用其它方式)" + ) + key_source.add_argument("--api-key-stdin", action="store_true", help="从标准输入读取密钥(非交互环境首选)") + + deploy = subparsers.add_parser("deploy", help="打包当前项目并发布到平台") + deploy.add_argument("path", metavar="PATH", nargs="?", default=".", help="项目根,默认当前目录") + deploy.add_argument("--app-id", dest="app_id", default=None, help="显式指定目标应用标识(覆盖 .bisheng/app.json)") + deploy.add_argument("--wait", action="store_true", help="等待审批与上线结果落定") + deploy.add_argument("--wait-timeout", type=int, default=1800, help="--wait 的最长等待秒数,默认 1800") + deploy.add_argument("--confirm-schema-change", action="store_true", help="确认本次发布包含应用数据表结构变更") + from bisheng_cli.release_notes import message + + notes_source = deploy.add_mutually_exclusive_group() + notes_source.add_argument("--release-notes", default=None, help=message("text_help")) + notes_source.add_argument("--release-notes-file", default=None, help=message("file_help")) + deploy.add_argument("--yes", action="store_true", help="跳过目标应用确认(非交互环境必须显式带上)") + deploy.add_argument("--dry-run", action="store_true", help="只打包与本地校验,打印体量统计,不上传") + deploy.add_argument( + "--no-agents-note", + dest="no_agents_note", + action="store_true", + help="不要在项目 AGENTS.md 里写入技能包位置说明", + ) + + logs = subparsers.add_parser("logs", help="查看自己名下应用的运行日志") + logs.add_argument("--app-id", dest="app_id", default=None, help="显式指定应用标识(覆盖 .bisheng/app.json)") + logs.add_argument("--tail", type=int, default=200, help="返回最后 N 行,默认 200") + logs.add_argument("--since", default=None, help="起始时间:epoch 秒,或 30m / 2h / 7d 相对窗口") + logs.add_argument("--keyword", default=None, help="只返回含该关键字的行(服务端过滤,行数可能少于 --tail)") + logs.add_argument("--follow", action="store_true", help="持续拉取(短轮询 3 秒)") + + # `skills` is the first two-token command: a plain subparser whose own + # subparsers carry the verb. Dispatch (main.py) keys off the top-level + # `command`, so `commands/skills.py` owns the `sync`-vs-missing branch. + skills = subparsers.add_parser("skills", help="同步平台的开发者技能包并接入本机 AI 编程工具") + skills_sub = skills.add_subparsers(dest="skills_command", metavar="{sync}") + sync = skills_sub.add_parser("sync", help="拉取平台当前版本的技能包并接入本机 AI 编程工具(幂等,单向覆盖)") + + # `dev` takes no identity argument of any kind (AC-25): the injected identity + # is the logged-in service account, full stop. The two port flags are the + # only knobs — the proxy is the entry the developer opens, the app port is + # what the app process receives as PORT. + dev = subparsers.add_parser("dev", help="本地运行应用,并像托管环境一样注入身份与平台接线环境变量") + dev.add_argument("path", metavar="PATH", nargs="?", default=".", help="项目根,默认当前目录") + dev.add_argument( + "--port", + type=int, + default=None, + help="本地访问入口(迷你代理)监听的端口,默认取 bisheng-app.yaml 的 port", + ) + dev.add_argument( + "--app-port", + dest="app_port", + type=int, + default=None, + help="应用进程实际监听的端口(注入为 PORT),默认自动挑一个空闲端口", + ) + + platforms = subparsers.add_parser("platforms", help="列出已登录的平台,或切换默认平台") + platforms_sub = platforms.add_subparsers(dest="platforms_command", metavar="{list,use}") + platforms_list = platforms_sub.add_parser("list", help="列出本机已登录的平台(密钥恒掩码)") + platforms_use = platforms_sub.add_parser("use", help="把某个已登录的平台设为默认") + platforms_use.add_argument("base_url", metavar="BASE_URL", help="已登录过的平台地址") + + # Every leaf that can receive a flag needs the mirror, or `--json` after it + # is "unrecognized arguments". `sync` is where `bisheng skills sync --json` + # lands; `skills` covers `bisheng skills --json sync`. + for subparser in (init, login, deploy, logs, skills, sync, dev, platforms, platforms_list, platforms_use): + _add_global_flags(subparser, mirror=True) + + return parser + + +def confirm( + prompt: str, + *, + assume_yes: bool, + is_tty: bool, + flag_name: str, + reader: Callable[[str], str] = input, +) -> bool: + """Ask for confirmation, or refuse when there is nobody to ask. + + "No TTY" must never mean "assume yes". The whole point of the guard is that + the destructive case (overwriting somebody else's app, applying a schema + change) is the one that happens unattended. + """ + if assume_yes: + return True + if not is_tty: + raise CliError( + f"非交互环境下无法确认:{prompt}", + exit_code=EXIT_USAGE, + next_step=f"确认无误后带上 {flag_name} 重新执行。", + ) + answer = reader(f"{prompt} [y/N] ").strip().lower() + return answer in ("y", "yes") diff --git a/src/bisheng-cli/bisheng_cli/commands/__init__.py b/src/bisheng-cli/bisheng_cli/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/bisheng-cli/bisheng_cli/commands/deploy.py b/src/bisheng-cli/bisheng_cli/commands/deploy.py new file mode 100644 index 0000000000..75eaecf0a9 --- /dev/null +++ b/src/bisheng-cli/bisheng_cli/commands/deploy.py @@ -0,0 +1,634 @@ +"""`bisheng deploy` — package, upload, then follow the server's own pipeline. + +The single most important fact about this command is that **`POST /apps/deploy` +answers synchronously with most of the verdicts**. Ownership, the size gate, the +unpack gate, manifest validation, local-reference validation and the in-flight / +pending-online gates all run before the deployment row exists (F055 design §4.1 +①). Only after they pass does the server insert `stage=received` and hand back a +`deployment_id`. So the failure a first-time user hits most often — a manifest +missing a field — never appears in the polling payload. Code that waits for a +`stage=precheck_manifest` failure event waits until the timeout. + +Two smaller rules with outsized consequences: + +**The app id is saved the moment HTTP 200 arrives**, not when the pipeline +succeeds. A first deploy whose precheck fails has still created a draft +application on the platform and been assigned an id; saving only on success means +the developer's next attempt creates a *second* application, and the build page +fills with identically named drafts. + +**`details` and `hints` are forwarded untouched.** They are the entire input a +local agent has for repairing the failure by itself; summarising them is the +difference between an agent that fixes the manifest and one that asks a human. +""" + +from __future__ import annotations + +import tempfile +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any, BinaryIO + +from bisheng_cli import agent_skills, credentials, packaging, project, release_notes +from bisheng_cli import ignore as ignore_rules +from bisheng_cli.cli import confirm +from bisheng_cli.commands import skills +from bisheng_cli.errors import ( + EXIT_APPROVAL_EXCEPTION, + EXIT_CANCELLED, + EXIT_OK, + EXIT_PENDING_ONLINE, + EXIT_PLATFORM_TOO_OLD, + EXIT_REJECTED, + EXIT_UNKNOWN_CODE, + EXIT_USAGE, + EXIT_WAIT_TIMEOUT, + EXIT_WITHDRAWN, + CliError, + error_from_platform, +) +from bisheng_cli.http import UPLOAD_READ_TIMEOUT, PlatformClient +from bisheng_cli.output import Emitter, format_scan_hits, format_schema_change + +COMMAND = "deploy" +DEPLOY_PATH = "/api/v2/apps/deploy" +DEPLOYMENT_PATH = "/api/v2/apps/deployments/{deployment_id}" +PACKAGE_FIELD = "package" +CONFIRM_SCHEMA_FLAG = "--confirm-schema-change" + +# "This release drops or modifies a column of the online version and nobody +# said so." Answered on the upload itself (F055 AC-09), which is what makes it +# the one platform refusal this command turns into a question: the package is +# still on disk, so a "yes" is one more POST rather than a whole new run. +SCHEMA_CHANGE_UNCONFIRMED = 16229 + +# 2s, then ×1.5 every five polls, capped at 10s. A long connection is not an +# option here: nginx caps `proxy_read_timeout` at 300s while an approval is a +# human action that can take days. +POLL_INTERVAL_START = 2.0 +POLL_BACKOFF_EVERY = 5 +POLL_BACKOFF_FACTOR = 1.5 +POLL_INTERVAL_MAX = 10.0 + +ONLINE_APP_STATE = "已上线" +PENDING_REASONS = {"capacity", "deploy_failed"} + +# The six values `approval.status` can hold. The last two matter most: an +# application deleted mid-flight cancels its approval, and an approval whose +# approver set resolves to empty is parked as an exception. Neither will ever +# reach a decision, so a `--wait` that does not recognise them polls to the +# timeout and then prints "this is not a failure, keep waiting" about a request +# that is already dead. +APPROVAL_TERMINALS: dict[str, int] = { + "rejected": EXIT_REJECTED, + "withdrawn": EXIT_WITHDRAWN, + "cancelled": EXIT_CANCELLED, + "exception": EXIT_APPROVAL_EXCEPTION, +} +APPROVAL_PASSED = ("executed", "approved") + +DETAIL_PAGE = "应用详情页 · 发布" +TRACKING_PATHS = ( + f"在 {DETAIL_PAGE} 查看审批与上线进度", + "用 MCP 的应用状态工具查询", + "重新执行 bisheng deploy --wait 继续等待", +) + + +def _sleep(seconds: float) -> None: + time.sleep(seconds) + + +def _monotonic() -> float: + return time.monotonic() + + +def next_interval(interval: float, polls_done: int) -> float: + """Backoff step. Split out so the schedule is testable without 30 polls.""" + if polls_done and polls_done % POLL_BACKOFF_EVERY == 0: + return min(interval * POLL_BACKOFF_FACTOR, POLL_INTERVAL_MAX) + return interval + + +@dataclass +class Outcome: + exit_code: int + kind: str + + +# ---- upload ------------------------------------------------------------- + + +class _ProgressReader: + """File wrapper that reports upload progress as httpx streams it. + + A file object rather than `read_bytes()` is what keeps a 50 MB package out of + memory; the counter is bolted on here so that the streaming property is not + quietly traded away for a progress bar. + """ + + def __init__(self, fh: BinaryIO, total: int, emitter: Emitter) -> None: + self._fh = fh + self._total = total + self._emitter = emitter + self._sent = 0 + self._last_percent = -1 + + def read(self, size: int = -1) -> bytes: + chunk = self._fh.read(size) + if chunk: + self._sent += len(chunk) + percent = int(self._sent * 100 / self._total) if self._total else 100 + if percent != self._last_percent: + self._last_percent = percent + self._emitter.progress(COMMAND, "upload", self._sent, self._total) + return chunk + + def seek(self, offset: int, whence: int = 0) -> int: + position = self._fh.seek(offset, whence) + if position == 0: + self._sent = 0 + self._last_percent = -1 + return position + + def tell(self) -> int: + return self._fh.tell() + + def fileno(self) -> int: + return self._fh.fileno() + + +# ---- command ------------------------------------------------------------ + + +def run(args: Any, emitter: Emitter) -> int: + notes = release_notes.load_release_notes( + getattr(args, "release_notes", None), getattr(args, "release_notes_file", None) + ) + profile = credentials.load_selected(args) + base_url = profile.base_url + root = project.find_project_root(args.path) + project.load_manifest(root) + + ignore_result = ignore_rules.collect_files(root) + for note in ignore_result.notes: + emitter.info(note) + + with tempfile.TemporaryDirectory(prefix="bisheng-cli-") as tmp: + package_path = Path(tmp) / "package.tar.gz" + package_stat = packaging.build_package(root, ignore_result, package_path) + + if args.dry_run: + emitter.info(packaging.format_size_report(package_stat, packaging.DEFAULT_LIMITS)) + emitter.info("--dry-run:本地校验与打包已完成,未上传,平台上什么都没有创建。") + emitter.result( + COMMAND, + ok=True, + exit_code=EXIT_OK, + data={ + "dry_run": True, + "has_release_notes": notes is not None, + "release_notes_codepoints": len(notes) if notes else 0, + "entry_count": package_stat.entry_count, + "compressed_bytes": package_stat.compressed_bytes, + "raw_bytes": package_stat.raw_bytes, + "excluded_count": package_stat.excluded_count, + "package_sha256": package_stat.sha256, + }, + ) + return EXIT_OK + + client = PlatformClient( + base_url, + api_key=profile.api_key, + trust_env=not getattr(args, "no_proxy", False), + timeout=getattr(args, "timeout", None), + emitter=emitter, + ) + target_app_id = project.resolve_app_id(root, base_url, args.app_id) + state: dict[str, Any] = { + "deployment_id": None, + "app_id": target_app_id, + "version_id": None, + "entry_url": None, + "stage": None, + "app_state": None, + "pending_reason": None, + "approval": None, + } + try: + with client: + _check_size(client, emitter, package_stat, notes=notes) + _confirm_target(args, emitter, root, base_url, target_app_id) + accepted = _upload(client, emitter, args, package_path, package_stat, target_app_id, notes=notes) + package_path.unlink(missing_ok=True) + _accept(emitter, root, base_url, accepted, state) + _ensure_agents_pointer(args, emitter, root, base_url) + return _follow(client, emitter, args, state) + except CliError as exc: + failure = getattr(exc, "server_failure", None) or exc.as_failure(stage=state["stage"]) + _print_scan_hits(emitter, failure) + emitter.result(COMMAND, ok=False, exit_code=exc.exit_code, data=state, failure=failure) + raise + + +def _ensure_agents_pointer(args: Any, emitter: Emitter, root: Path, base_url: str) -> None: + """Leave a pointer to the skill packs in the project's AGENTS.md (best effort). + + Deploy is the right moment for two reasons: the project certainly exists by + now (login often runs before there is one), and a project that deploys is a + project whose next editor — a teammate, a different AI tool, the same + developer in six weeks — needs to know the hosting contract exists. + + Placed *after* the platform accepts the upload, not before it. "This project + deploys to this platform" is the fact the pointer asserts, and until the + platform has taken the package that fact is still a guess — a failed deploy + must leave the developer's tree exactly as it found it. + + Best effort in the strict sense: this touches the developer's repository, so + it appends and never rewrites, it is skippable with --no-agents-note, and any + failure is a debug line. Nothing here may stand between a valid project and + its deploy. + """ + if getattr(args, "no_agents_note", False) or getattr(args, "dry_run", False): + # --dry-run promises "nothing was created"; that has to include the + # developer's own working tree, not just the platform. + return + try: + outcome = agent_skills.ensure_project_pointer(root, skills.skills_root(base_url), list(skills.DEFAULT_PACKS)) + except Exception as exc: # deliberately broad: a note must never block a deploy + emitter.debug(f"AGENTS.md 指针未写入:{exc.__class__.__name__}") + return + status = outcome.get("status") + if status == "created": + emitter.info(f"已创建 {agent_skills.POINTER_FILE},写明技能包位置(供其它 AI 工具查阅,建议提交到仓库)") + elif status == "appended": + emitter.info(f"已在 {agent_skills.POINTER_FILE} 追加技能包位置说明(建议提交到仓库)") + elif status == "failed": + emitter.debug(f"AGENTS.md 指针未写入:{outcome.get('reason')}") + + +def _check_size( + client: PlatformClient, emitter: Emitter, package_stat: packaging.PackageStat, *, notes: str | None = None +) -> packaging.Limits: + """Self-check against the server's ceilings — advisory when they are unknown. + + `deploy-limits` failing must never be able to block a deploy: the endpoint + may not be deployed yet, or the hop may be flaky, and a soft check that can + kill the main flow is worse than no check. The server's 16201 stays the + authority either way. + """ + limits = packaging.fetch_limits(client, require_release_notes=notes is not None) + if notes is not None and ( + limits.release_notes_version != release_notes.CONTRACT_VERSION + or not limits.release_notes_max_codepoints + or limits.release_notes_max_codepoints < len(notes) + ): + raise CliError( + release_notes.message("unsupported"), + exit_code=EXIT_PLATFORM_TOO_OLD, + next_step=release_notes.message("next_step"), + ) + if limits.degraded: + emitter.warn("未能从平台取到包体上限,已按内置默认值提示并继续上传;是否超限最终由平台判定。") + else: + packaging.check_limits(package_stat, limits) + emitter.info(packaging.format_size_report(package_stat, limits)) + return limits + + +def _confirm_target(args: Any, emitter: Emitter, root: Path, base_url: str, app_id: str | None) -> None: + """Misdirection guard for an iterative deploy. + + Pointing at an application owned by somebody else is refused by the server + (16205). Pointing at *another application of your own* is not — it silently + updates that one. In an unattended run this printed line is the only guard + that exists, which is why it is printed even when nobody can answer it. + """ + if not app_id: + return + entry = project.read_app_ref(root, base_url) or {} + name = entry.get("app_name") or "(名称未知,本次发布后回填)" + emitter.info(f"目标应用:{name}(标识 {app_id})") + emitter.info("若这不是要更新的应用,请中止并用 --app-id 指定正确的目标。") + if emitter.is_tty and not args.yes: + if not confirm("确认更新以上应用?", assume_yes=False, is_tty=True, flag_name="--yes"): + raise CliError( + "已按用户要求取消本次发布", + exit_code=EXIT_USAGE, + next_step="用 --app-id 指定正确的目标应用后重试。", + ) + + +def _upload( + client: PlatformClient, + emitter: Emitter, + args: Any, + package_path: Path, + package_stat: packaging.PackageStat, + app_id: str | None, + *, + notes: str | None = None, +) -> dict[str, Any]: + """POST the package; on 16229, show the structure change and ask once. + + The platform refuses a breaking table change (drop / modify column) with + 16229 unless `--confirm-schema-change` was sent. At a terminal that becomes + a question with the diff in front of the person; a "yes" re-sends the same + package with the flag. Without a terminal the refusal stands — "no TTY" + must never mean "assume yes" for a change that can lose production rows — + and the error's next step already names the flag. + + Sent with the flag and still refused means the platform did not honour + it; that is re-raised as-is rather than asked about, because asking would + loop on an answer the caller already gave. + """ + confirmed = bool(args.confirm_schema_change) + try: + return _post_package( + client, emitter, package_path, package_stat, app_id, confirm_schema_change=confirmed, notes=notes + ) + except CliError as exc: + if exc.code != SCHEMA_CHANGE_UNCONFIRMED or confirmed: + raise + items = schema_change_items(exc.details) + if items: + emitter.error("平台检出应用数据表结构变更(相对当前在线版本):") + emitter.error(format_schema_change(items)) + if not emitter.is_tty: + raise + if not confirm( + "以上改列 / 删列会影响线上已有数据,确认变更并继续发布?", + assume_yes=False, + is_tty=True, + flag_name=CONFIRM_SCHEMA_FLAG, + reader=input, + ): + raise CliError( + "已按用户要求取消本次发布", + exit_code=EXIT_USAGE, + code=SCHEMA_CHANGE_UNCONFIRMED, + next_step=f"确认变更影响后带上 {CONFIRM_SCHEMA_FLAG} 重新发布。", + details=exc.details, + hints=exc.hints, + ) from exc + emitter.info("已确认结构变更,重新上传同一个包…") + return _post_package( + client, emitter, package_path, package_stat, app_id, confirm_schema_change=True, notes=notes + ) + + +def _post_package( + client: PlatformClient, + emitter: Emitter, + package_path: Path, + package_stat: packaging.PackageStat, + app_id: str | None, + *, + confirm_schema_change: bool, + notes: str | None = None, +) -> dict[str, Any]: + fields: dict[str, str] = {"confirm_schema_change": "true" if confirm_schema_change else "false"} + if app_id: + fields["app_id"] = app_id + if notes is not None: + fields["release_notes"] = notes + emitter.info(f"开始上传({package_stat.entry_count} 个条目,sha256 {package_stat.sha256[:12]}…)") + with open(package_path, "rb") as fh: + reader = _ProgressReader(fh, package_stat.compressed_bytes, emitter) + data = client.post_json( + DEPLOY_PATH, + files={PACKAGE_FIELD: (package_path.name, reader, "application/gzip")}, + data=fields, + read_timeout=UPLOAD_READ_TIMEOUT, + ) + return data or {} + + +def schema_change_items(details: Any) -> list[dict[str, Any]]: + """The `items[]` of a 16229 payload, wherever the envelope put them. + + A synchronous refusal arrives as the envelope's `data` + (`{exception, details: {items, breaking, ...}, hints}`); a five-tuple + would carry `details.items` directly. Both are read so the same printer + serves both, and anything else yields an empty list rather than a crash + in the middle of an error path. + """ + if not isinstance(details, dict): + return [] + inner = details.get("details") + source = inner if isinstance(inner, dict) and "items" in inner else details + items = source.get("items") + return [item for item in items if isinstance(item, dict)] if isinstance(items, list) else [] + + +def _accept(emitter: Emitter, root: Path, base_url: str, accepted: dict[str, Any], state: dict[str, Any]) -> None: + state["deployment_id"] = accepted.get("deployment_id") + state["app_id"] = accepted.get("app_id") or state["app_id"] + state["version_id"] = accepted.get("version_id") + state["entry_url"] = accepted.get("entry_url") + + if state["app_id"]: + # Written now, not on success — see the module docstring. + project.save_app_ref( + root, + base_url, + app_id=str(state["app_id"]), + app_name=accepted.get("app_name"), + slug=accepted.get("slug"), + last_deployment_id=state["deployment_id"], + ) + emitter.info(f"平台已接收,应用标识 {state['app_id']},发布单 {state['deployment_id']}") + if state["entry_url"]: + emitter.info(f"入口地址:{state['entry_url']}") + else: + # A first deploy has no application row yet at this instant, so there is + # genuinely no address to print. Printing "None" would read as a broken + # URL; the polling payload carries the address as soon as it exists. + emitter.info(f"入口地址暂不可得,请在 {DETAIL_PAGE} 获取。") + + +def _print_scan_hits(emitter: Emitter, failure: Any) -> None: + if not isinstance(failure, dict): + return + details = failure.get("details") + hits = details.get("hits") if isinstance(details, dict) else None + if isinstance(hits, list) and hits: + emitter.error("扫描命中(平台不回显命中的值,CLI 也不会从本地文件补读):") + emitter.error(format_scan_hits(hits)) + + +# ---- polling ------------------------------------------------------------ + + +def _follow(client: PlatformClient, emitter: Emitter, args: Any, state: dict[str, Any]) -> int: + deployment_id = state["deployment_id"] + if not deployment_id: + # Nothing to poll: the server accepted the package but gave no handle. + emitter.warn("平台未返回发布单标识,无法跟踪进度。") + emitter.result(COMMAND, ok=True, exit_code=EXIT_OK, data=state) + return EXIT_OK + + path = DEPLOYMENT_PATH.format(deployment_id=deployment_id) + wait = bool(args.wait) + deadline = float(args.wait_timeout) + interval = POLL_INTERVAL_START + polls = 0 + started = _monotonic() + last_stage: str | None = None + warned_references: set[str] = set() + + while True: + payload = client.get_json(path) or {} + _absorb(payload, state) + _emit_secret_warnings(payload, emitter, warned_references) + stage = payload.get("stage") + status = payload.get("status") + failure = payload.get("failure") + + if stage and stage != last_stage and status != "failed": + last_stage = stage + emitter.stage(COMMAND, stage, "running") + + # A person's decision is read before the failure tuple. The platform + # also closes a rejected or withdrawn attempt as ``failed`` with no + # code (a person decided against it, no check failed), so checking the + # tuple first reported every rejection as an unexplained failure, exit + # 19, with the reason never printed. + outcome = _terminal(payload, wait=wait) + if outcome is not None and outcome.kind in APPROVAL_TERMINALS: + return _report_outcome(emitter, outcome, state) + + if status == "failed" or failure: + emitter.stage(COMMAND, stage or "unknown", "failed", failure=failure) + raise _failure_error(stage, failure) + + if outcome is not None: + return _report_outcome(emitter, outcome, state) + + if _monotonic() - started >= deadline: + return _report_outcome(emitter, Outcome(EXIT_WAIT_TIMEOUT, "timeout"), state) + + _sleep(interval) + polls += 1 + interval = next_interval(interval, polls) + + +def _emit_secret_warnings(payload: dict[str, Any], emitter: Emitter, seen: set[str]) -> None: + """Only bounded names and presence metadata may reach the terminal.""" + rows = payload.get("secret_references") + if not isinstance(rows, list): + return + for row in rows[:64]: + if not isinstance(row, dict) or row.get("reason") != "missing_value": + continue + name = row.get("name") + if ( + not isinstance(name, str) + or not name.isascii() + or not name.isidentifier() + or len(name) > 128 + or name in seen + ): + continue + seen.add(name) + emitter.warn(f"环境变量 {name} 尚未配置;预检和预览只获得空值,请在应用发布设置中保存。") + + +def _absorb(payload: dict[str, Any], state: dict[str, Any]) -> None: + # ``entry_url`` is absorbed here as of the F055 write-back: it used to exist + # only on the POST response, where a first deploy is still a draft and the + # value was almost always null exactly when it mattered. Now every poll + # carries it, so `--wait` can print a real address on success. + for key in ("stage", "app_state", "pending_reason", "approval", "entry_url", "secret_references"): + if payload.get(key) is not None: + state[key] = payload[key] + if payload.get("app_id"): + state["app_id"] = payload["app_id"] + + +def _terminal(payload: dict[str, Any], *, wait: bool) -> Outcome | None: + approval = payload.get("approval") or {} + approval_status = str(approval.get("status") or "").lower() + + # Checked before anything else and in both modes: these two never reach a + # decision, so continuing to poll can only burn the timeout. + if approval_status in APPROVAL_TERMINALS: + return Outcome(APPROVAL_TERMINALS[approval_status], approval_status) + + status = payload.get("status") + if not wait: + if status == "waiting_approval": + return Outcome(EXIT_OK, "waiting_approval") + if status == "succeeded": + return Outcome(EXIT_OK, "online") + return None + + stage = payload.get("stage") + if stage == "pending_online" or payload.get("pending_reason") in PENDING_REASONS: + return Outcome(EXIT_PENDING_ONLINE, "pending_online") + if stage == "online" or payload.get("app_state") == ONLINE_APP_STATE or status == "succeeded": + return Outcome(EXIT_OK, "online") + return None + + +def _failure_error(stage: str | None, failure: Any) -> CliError: + raw = failure if isinstance(failure, dict) else {} + code = raw.get("code") + message = str(raw.get("message") or "") + if isinstance(code, int): + err = error_from_platform(code, message, details=raw.get("details"), hints=raw.get("hints")) + else: + err = CliError( + f"发布在阶段 {stage or '未知'} 失败:{message}", + exit_code=EXIT_UNKNOWN_CODE, + next_step="按平台给出的信息处置;若无法判断,请带上发布单标识联系平台支持。", + details=raw.get("details"), + hints=raw.get("hints"), + ) + if raw: + # The server's five-tuple, byte for byte. `as_failure()` would rebuild a + # near-identical dict, and "near-identical" is exactly what an agent + # cannot rely on. + err.server_failure = raw # type: ignore[attr-defined] + return err + + +def _report_outcome(emitter: Emitter, outcome: Outcome, state: dict[str, Any]) -> int: + approval = state.get("approval") or {} + instance_id = approval.get("instance_id") or "(平台未返回标识)" + + if outcome.kind == "waiting_approval": + emitter.info(f"审批单已生成:{instance_id}。审批是人工动作,可能跨天。") + emitter.info("跟踪方式:") + for path in TRACKING_PATHS: + emitter.info(f" - {path}") + elif outcome.kind == "online": + emitter.info("发布通过并已上线。") + if state.get("entry_url"): + emitter.info(f"入口地址:{state['entry_url']}") + else: + emitter.info(f"入口地址请在 {DETAIL_PAGE} 获取。") + elif outcome.kind == "rejected": + # Full text, never truncated: the reason is the entire instruction for + # what to change before resubmitting. + emitter.error(f"审批被驳回。驳回理由:{approval.get('reject_reason') or '(平台未返回理由)'}") + elif outcome.kind == "withdrawn": + emitter.error(f"审批单已被撤回(撤回操作在 {DETAIL_PAGE})。") + elif outcome.kind == "pending_online": + emitter.error(f"应用处于待上线状态,成因:{state.get('pending_reason')}。") + emitter.error(f"可由应用归属人在 {DETAIL_PAGE} 手动上线,或等待资源释放后重试。") + elif outcome.kind == "cancelled": + emitter.error("目标应用已被删除,该审批单已取消——这一单永远不会有结论。") + emitter.error("下一步: 重新执行 deploy 创建新应用(先删掉 .bisheng/app.json 里的过期标识)。") + elif outcome.kind == "exception": + emitter.error("平台未能解析出审批人,该审批单已置为异常——这一单永远不会有结论。") + emitter.error("下一步: 联系平台管理员处理审批异常;改代码或重新发布都不会让它继续。") + elif outcome.kind == "timeout": + emitter.error(f"等待超时(--wait-timeout)。这不是失败:审批单 {instance_id} 仍在流转。") + for path in TRACKING_PATHS: + emitter.error(f" - {path}") + + emitter.result(COMMAND, ok=outcome.exit_code == EXIT_OK, exit_code=outcome.exit_code, data=state) + return outcome.exit_code diff --git a/src/bisheng-cli/bisheng_cli/commands/dev.py b/src/bisheng-cli/bisheng_cli/commands/dev.py new file mode 100644 index 0000000000..1a8da1733b --- /dev/null +++ b/src/bisheng-cli/bisheng_cli/commands/dev.py @@ -0,0 +1,255 @@ +"""`bisheng dev` — run the app locally, wired the way the platform would wire it. + +The command orchestrates three pieces and owns none of their logic: +`devdb` (the project-local SQLite plus the same-named environment), `devproxy` +(the mini proxy that injects identity per request) and the app process itself, +started by the same resolution order as the hosted entrypoint. What this file +decides is the **order of the pre-checks** (T044) and **what the developer is +told** (AC-24): + +1. credentials — no profile is exit 3 with zero requests (AC-51 / AC-29); +2. the manifest — missing file or a missing `name` / `runtime` / `port` is + exit 6 naming the field, before anything is started (AC-53); +3. the platform — `probe` (exit 7 / 8 / 9 / 2) then `whoami` (exit 4 on a + revoked key). **No scope is checked** (AC-29, same as `login`): a key with + no permission bit ticked yet can still run the app locally; what it can + *do* against the platform is the platform's call, per call. + +Only then does anything happen on this machine: the database is prepared, the +app process is spawned with the injected environment, the proxy comes up in +front of it, and the identity source is printed — service account name, the +platform it came from, the local URL — so nobody mistakes the injected subject +for a real visitor (AC-24). Ctrl-C tears both down. + +What the developer does not get: `--as` or any identity override (AC-25), and +a proxy for platform-capability calls (决议-3 — the app calls the platform +directly with the same environment it will have hosted). +""" + +from __future__ import annotations + +import signal +import subprocess +import time +from pathlib import Path +from typing import Any + +from bisheng_cli import credentials, devdb, project +from bisheng_cli.devproxy import DevIdentity, DevProxy, HandleMinter +from bisheng_cli.errors import EXIT_LOCAL_INVALID, EXIT_OK, EXIT_USAGE, CliError +from bisheng_cli.http import PlatformClient, probe +from bisheng_cli.output import Emitter + +COMMAND = "dev" +WHOAMI_PATH = "/api/v2/auth/whoami" + +#: How long to give the app after SIGTERM before SIGKILL on shutdown. +_STOP_GRACE_SECONDS = 5.0 + + +def run(args: Any, emitter: Emitter) -> int: + profile = credentials.load_selected(args) + root = project.find_project_root(args.path) + manifest = project.load_manifest(root) + + client = PlatformClient( + profile.base_url, + api_key=profile.api_key, + trust_env=not getattr(args, "no_proxy", False), + timeout=getattr(args, "timeout", None), + emitter=emitter, + ) + with client: + probe(client) + whoami = client.get_json(WHOAMI_PATH) or {} + + app_ref = project.read_app_ref(root, profile.base_url) + app_id = (app_ref or {}).get("app_id") + identity = DevIdentity.from_whoami(whoami, app_id=app_id) + + proxy_port = int(args.port) if getattr(args, "port", None) else int(manifest["port"]) + app_port = int(args.app_port) if getattr(args, "app_port", None) else devdb.pick_free_port(exclude=proxy_port) + if app_port == proxy_port: + raise CliError( + f"本地访问入口与应用进程不能用同一个端口({proxy_port})", + exit_code=EXIT_USAGE, + next_step="去掉 --app-port 让 CLI 自动挑一个,或给两者不同的端口。", + ) + + slug = devdb.dev_slug(manifest, app_ref) + db = devdb.prepare_dev_db(root) + # AC-27 / AC-28: the model face is wired from `whoami` — the address it + # reports (never one composed here) and the key this session logged in with, + # so a model call made locally is judged against exactly the scopes that key + # carries, by the same face the hosted app will call. + model_base_url = str(whoami.get("model_base_url") or "") + env = devdb.build_dev_env( + manifest=manifest, + app_port=app_port, + platform_base_url=profile.base_url, + app_id=app_id, + db=db, + model_base_url=model_base_url, + model_api_key=profile.api_key, + slug=slug, + ) + start = devdb.resolve_start_command(root) + + proxy = DevProxy( + identity=identity, + minter=HandleMinter(identity), + app_port=app_port, + listen_port=proxy_port, + emitter=emitter, + slug=slug, + ) + # Bind the entry port before spawning anything: a busy port (8080 usually + # is) must be a clean refusal, not a child process started and then killed. + try: + proxy.start() + except OSError as exc: + proxy.stop() + raise CliError( + f"本地访问入口无法监听 127.0.0.1:{proxy_port}:{exc.strerror or exc.__class__.__name__}", + exit_code=EXIT_USAGE, + next_step="用 --port 换一个未被占用的端口。", + ) + try: + process = spawn_app(start, root, env) + except CliError: + proxy.stop() + raise + + _report(emitter, profile, whoami, identity, proxy.url, app_port, db, start, model_base_url) + # A long-running command: the machine-readable "it is up, here is where" is + # a `stage` event, so that `result` (with the real exit code) stays the + # last line on stdout when the session ends. + emitter.stage( + COMMAND, + "ready", + "running", + data={ + "base_url": profile.base_url, + "actor_kind": whoami.get("actor_kind"), + "actor_id": whoami.get("actor_id"), + "actor_name": whoami.get("actor_name"), + "subject_kind": identity.subject_kind, + "app_id": app_id, + "local_url": proxy.url, + "base_path": proxy.prefix, + "app_port": app_port, + "db_path": str(db.path), + "start_command": start.display, + "start_source": start.source, + }, + ) + try: + return run_session(process, proxy, emitter) + finally: + proxy.stop() + _terminate(process) + + +# ---- process handling (module-level so tests can replace them) --------------- + + +def spawn_app(start: devdb.StartCommand, root: Path, env: dict[str, str]) -> subprocess.Popen[bytes]: + """Start the app in the project root; its stdout/stderr stay on the terminal.""" + try: + return subprocess.Popen(start.argv, cwd=str(root), env=env) + except OSError as exc: + raise CliError( + f"启动应用失败({start.display}):{exc.strerror or exc.__class__.__name__}", + exit_code=EXIT_LOCAL_INVALID, + next_step="确认启动命令可执行;改用 BISHENG_APP_START 或 Procfile 的 web: 行显式指定。", + ) + + +def run_session(process: subprocess.Popen[bytes], proxy: DevProxy, emitter: Emitter) -> int: + """Block until Ctrl-C or the app exits on its own. + + An app that exits by itself is reported with its code: a non-zero exit is + almost always the app failing to bind `PORT` or crashing at import, and + that is exit 6 here — the same class of "fix the project" failure the + hosted probe would report as 16228. + """ + try: + while True: + code = process.poll() + if code is not None: + if code == 0: + emitter.info("应用进程已退出。") + return EXIT_OK + emitter.error(f"应用进程异常退出(code {code})。托管环境里这会是启动探活失败(16228)。") + return EXIT_LOCAL_INVALID + time.sleep(0.2) + except KeyboardInterrupt: + emitter.info("收到 Ctrl-C,正在停止应用与本地入口……") + return EXIT_OK + + +def _terminate(process: subprocess.Popen[bytes]) -> None: + if process.poll() is not None: + return + try: + process.send_signal(signal.SIGTERM) + process.wait(timeout=_STOP_GRACE_SECONDS) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + except (OSError, ValueError): + # Already gone, or the handle is closed; nothing to stop. + return + + +# ---- output -------------------------------------------------------------------- + + +def _report( + emitter: Emitter, + profile: credentials.Profile, + whoami: dict[str, Any], + identity: DevIdentity, + local_url: str, + app_port: int, + db: devdb.DevDatabase, + start: devdb.StartCommand, + model_base_url: str, +) -> None: + """AC-24: identity source and account, platform, local URL — no key, ever.""" + who = whoami.get("actor_name") or "(未命名)" + if whoami.get("actor_kind") == "natural_person": + emitter.info(f"注入身份: {who}(个人访问令牌持有人,来自 {profile.base_url} 的 login 凭据)") + else: + emitter.info(f"注入身份: 服务账号 {who}(来自 {profile.base_url} 的 login 凭据)") + emitter.info( + f" 应用看到的访问者恒为这个账号(X-BiSheng-Subject-Kind={identity.subject_kind});" + "线上看到的是真实访问用户,本地无法模拟他人身份。" + ) + emitter.info(f"本地访问地址: {local_url}") + emitter.info( + f" 应用进程监听 127.0.0.1:{app_port}(PORT / BISHENG_APP_PORT),启动命令来自 {start.source}:{start.display}" + ) + emitter.info(f" 应用数据库: {db.path}(BISHENG_APP_DB_URL / BISHENG_APP_DB_PATH,跨重启保留,不进上传包)") + prefix = local_url.split("://", 1)[-1].partition("/")[2].rstrip("/") + emitter.info( + f" 应用挂在 /{prefix}/ 下,和平台上一样(BISHENG_APP_BASE_PATH=/{prefix})。" + "以 / 开头、不带这个前缀的请求会得到 404 和说明,托管后它们同样会失败。" + ) + if start.source == "BISHENG_APP_START": + emitter.warn( + "启动命令来自 shell 里的 BISHENG_APP_START。托管环境没有途径设置这个变量," + "上线后会退回 Procfile 或 main.py / app.py。请把命令写进 Procfile 的 web: 行。" + ) + if model_base_url: + emitter.info( + f" 模型调用地址: {model_base_url}(OPENAI_BASE_URL / BISHENG_MODEL_BASE_URL;" + "OPENAI_API_KEY 为当前 login 凭据,能调哪些模型由这把密钥的权限位决定)" + ) + else: + emitter.info( + " 这个平台没有部署模型协议面,OPENAI_BASE_URL / OPENAI_API_KEY / " + "BISHENG_MODEL_BASE_URL 都不注入(shell 里已有的同名值也会被清掉," + "避免本地指向平台以外的服务、上线后却调不通)。" + ) + emitter.info("Ctrl-C 停止。") diff --git a/src/bisheng-cli/bisheng_cli/commands/login.py b/src/bisheng-cli/bisheng_cli/commands/login.py new file mode 100644 index 0000000000..58635c6667 --- /dev/null +++ b/src/bisheng-cli/bisheng_cli/commands/login.py @@ -0,0 +1,223 @@ +"""`bisheng login` — prove the key works, then store it. + +The command validates nothing itself. `GET /api/v2/auth/whoami` is the one +endpoint under `/api/v2` that requires no scope at all, which is exactly what +AC-06 asks for: logging in must succeed for a valid key even when no permission +has been ticked yet, so that "the key is wrong" and "the key lacks a scope" stay +two different diagnoses. + +Order is load-bearing in two places. + +**The probe runs before the key is sent.** If the open-capability layer is not +deployed, the run ends at the probe with exit 8 and the credential never leaves +the machine. `whoami` itself is always registered server-side (`open_api/api/router.py` +mounts the auth router unconditionally), so this is the only place the +"unusable in this environment" verdict can be produced. + +**The delegate check runs before the write.** A delegate-only key is refused, and +refused *without* leaving a credential file behind — a stored key that every +later command rejects is worse than no key at all. + +That check is the second line of defence, not the first. A key carrying +`delegate` does not get as far as reading its own scopes: `whoami` requires no +scope, so the platform's entrance gate waves it through and the delegation +resolver then rejects the call with `26016` (INV-31 / 伴生 PRD §4.2.4). The CLI +translates that code into the same refusal (`errors.py`), so `login` still ends +with "委托专用,另签一把" and no credential file. The scope check below survives +for the case the code path cannot cover: a platform that answers `whoami` +successfully for such a key. +""" + +from __future__ import annotations + +import getpass +import os +import sys +from collections.abc import Callable +from typing import Any, TextIO + +from bisheng_cli import credentials +from bisheng_cli.commands import skills +from bisheng_cli.errors import EXIT_OK, EXIT_USAGE, CliError, delegate_refusal +from bisheng_cli.http import PlatformClient, probe +from bisheng_cli.output import Emitter + +COMMAND = "login" +WHOAMI_PATH = "/api/v2/auth/whoami" +API_KEY_ENV = "BISHENG_API_KEY" +DELEGATE_SCOPE = "delegate" + +_PROMPT = "请输入服务账号密钥(输入不回显): " + + +def resolve_api_key( + args: Any, + *, + stdin: TextIO | None = None, + prompt: Callable[[str], str] | None = None, +) -> str: + """`--api-key` > `BISHENG_API_KEY` > `--api-key-stdin` > hidden TTY input. + + The flag comes first because an explicit argument should always win, and last + because it is the one that lands in shell history — the other three exist so + that it does not have to be used. + + Nothing here invents a key. With no source and nobody to ask, the command + refuses (exit 2); guessing produces an anonymous request whose 26001 sends + the user looking at the platform instead of at their own invocation. + """ + flag = getattr(args, "api_key", None) + if flag and flag.strip(): + return flag.strip() + + env_value = os.environ.get(API_KEY_ENV) + if env_value and env_value.strip(): + return env_value.strip() + + stream = stdin if stdin is not None else sys.stdin + + if getattr(args, "api_key_stdin", False): + value = (stream.read() if stream is not None else "").strip() + if value: + return value + raise CliError( + "--api-key-stdin 没有从标准输入读到任何内容", + exit_code=EXIT_USAGE, + next_step="把密钥通过管道送入,例如 echo $KEY | bisheng login <平台地址> --api-key-stdin。", + ) + + if stream is not None and bool(getattr(stream, "isatty", lambda: False)()): + value = (prompt or getpass.getpass)(_PROMPT).strip() + if value: + return value + + raise CliError( + "没有提供服务账号密钥", + exit_code=EXIT_USAGE, + next_step=f"用 --api-key、环境变量 {API_KEY_ENV} 或 --api-key-stdin 提供密钥(非交互环境下必须用后两者之一)。", + ) + + +def run(args: Any, emitter: Emitter) -> int: + base_url = credentials.normalise_base_url(args.base_url) + # `login` names its platform positionally; `--platform` selects among + # platforms that are *already* logged in. Accepting both and silently + # preferring one would leave the developer unsure which address the key was + # just verified against. + if getattr(args, "platform", None): + raise CliError( + "login 用位置参数指定平台地址,不接受 --platform", + exit_code=EXIT_USAGE, + next_step=f"改为 bisheng login {credentials.normalise_base_url(args.platform)}。", + ) + api_key = resolve_api_key(args) + + client = PlatformClient( + base_url, + api_key=api_key, + trust_env=not getattr(args, "no_proxy", False), + timeout=getattr(args, "timeout", None), + emitter=emitter, + ) + with client: + probe(client) + whoami = client.get_json(WHOAMI_PATH) or {} + + scopes = whoami.get("scopes") or [] + if DELEGATE_SCOPE in scopes: + raise delegate_refusal() + + credentials.save_profile( + base_url, + { + "base_url": base_url, + "api_key": api_key, + "key_mask": whoami.get("key_mask"), + "tenant_id": whoami.get("tenant_id"), + # Stored under the server's own field names. The previous spelling + # (`service_account: {id, name}`) was F049's shape and no longer + # exists on the wire; translating back into it here would put a + # personal token's holder under a key that says "service account". + "actor_kind": whoami.get("actor_kind"), + "actor_id": whoami.get("actor_id"), + "actor_name": whoami.get("actor_name"), + "resource_owner": whoami.get("resource_owner"), + "expires_at": whoami.get("expires_at"), + }, + warn=emitter.warn, + ) + + _report(emitter, base_url, whoami) + + # AC-08: pull the developer skill packs now so a first-time developer never + # has to know `skills sync` exists. This login already succeeded — a sync + # failure downgrades to a warning inside run_after_login and never touches + # the exit code or the result event below. + skills.run_after_login(credentials.Profile(base_url=base_url, api_key=api_key), args, emitter) + + emitter.result( + COMMAND, + ok=True, + exit_code=EXIT_OK, + data={ + "base_url": base_url, + "actor_kind": whoami.get("actor_kind"), + "actor_id": whoami.get("actor_id"), + "actor_name": whoami.get("actor_name"), + "resource_owner": whoami.get("resource_owner"), + "tenant_id": whoami.get("tenant_id"), + "key_mask": whoami.get("key_mask"), + "expires_at": whoami.get("expires_at"), + }, + ) + return EXIT_OK + + +def _report(emitter: Emitter, base_url: str, whoami: dict[str, Any]) -> None: + emitter.info(f"登录成功:{base_url}") + + # `actor_kind` / `actor_name` replace F049's `service_account: {id, name}`. + # The kind is printed rather than assumed: a `bs-pat-` personal token + # authenticates the same way and answers `natural_person`, and labelling its + # holder "服务账号" would name the wrong subject. + if whoami.get("actor_kind") == "natural_person": + emitter.info(f" 登录主体: {whoami.get('actor_name') or '(未命名)'}(个人访问令牌)") + else: + emitter.info(f" 服务账号: {whoami.get('actor_name') or '(未命名)'}") + + owner_user_id = resource_owner_user_id(whoami) + if owner_user_id is not None: + # The server sends `resource_owner: {user_id}` and nothing else — the + # name F049 used to include is gone from the contract, so there is no + # honest way to print one. The id is what the platform actually knows, + # and the pointer says where to turn it into a person. + emitter.info(f" 资源归属人: 用户 #{owner_user_id}(姓名请在服务账号详情页核对)") + else: + # Not a display problem: a key with no resource owner cannot publish at + # all — the first deploy is refused with 16205 rather than creating an + # application owned by nobody. Saying so at login turns that into a + # fixable sentence now instead of a rejection ten minutes later. + emitter.info(" 资源归属人: 平台未返回 —— 这把密钥还不能 deploy,请管理员在服务账号详情页指定归属人") + + # Printed only when the platform sends it. A single-tenant install has one + # (Root) tenant, so the id is a constant the developer can do nothing with, + # and "租户" is a word that deployment shape is supposed to never show. The + # platform decides — the CLI cannot read the multi_tenant switch. + if whoami.get("tenant_id") is not None: + emitter.info(f" 租户: {whoami.get('tenant_id')}") + emitter.info(f" 密钥: {whoami.get('key_mask') or '(平台未返回掩码)'}") + emitter.info(f" 到期时间: {whoami.get('expires_at') or '未设置'}") + emitter.info(f" 凭据已写入 {credentials.credentials_path()}(仅当前用户可读写)") + + +def resource_owner_user_id(whoami: dict[str, Any]) -> int | None: + """`resource_owner.user_id`, or None when the platform sends no owner. + + Kept in one place because the shape moved once already: F049 sent + ``{user_id, user_name}``, beta2's ``WhoamiResourceOwner`` carries ``user_id`` + alone (and the whole object is null for a credential without an owner). + """ + owner = whoami.get("resource_owner") + if isinstance(owner, dict) and owner.get("user_id") is not None: + return owner.get("user_id") + return None diff --git a/src/bisheng-cli/bisheng_cli/commands/logs.py b/src/bisheng-cli/bisheng_cli/commands/logs.py new file mode 100644 index 0000000000..8d588a4c2c --- /dev/null +++ b/src/bisheng-cli/bisheng_cli/commands/logs.py @@ -0,0 +1,162 @@ +"""`bisheng logs` — show the caller their own app's runtime logs. + +**The CLI makes no ownership decision.** owner-only is enforced server-side +against the key's *resource owner* (not its subject), and the CLI cannot see that +field at all. Pre-checking here would add a second judge working from worse data; +it would eventually disagree with the first, and the disagreement would look like +a permission bug. So the request goes out, and 16205 / 16254 come back as +sentences. + +**`--follow` is polling, not a stream.** The whole chain — v2 endpoint → F054's +v1 endpoint → runtime-manager → `docker logs` — is one-shot responses, and nginx +caps a held connection at 300s anyway. Polling brings its own hazard: docker +timestamps have one-second resolution, so the last second's lines come back again +on the next `since` window. Hence the content-hash de-duplication; without it a +followed log looks like an app screaming the same line. + +**Empty is not the same as "never happened".** Nothing came back either because +there is no running instance, or because the window predates the log rotation +budget. The CLI says which of the two it cannot distinguish rather than printing +a blank. +""" + +from __future__ import annotations + +import hashlib +import time +from typing import Any + +from bisheng_cli import credentials, project +from bisheng_cli.errors import EXIT_OK +from bisheng_cli.http import PlatformClient +from bisheng_cli.output import Emitter + +COMMAND = "logs" +LOGS_PATH = "/api/v2/apps/{app_id}/logs" + +# Short polling only; see the module docstring for why a stream is not an option. +FOLLOW_INTERVAL = 3.0 +# How many trailing lines to remember for de-duplication. Larger than any single +# second's realistic output, small enough to stay free. +DEDUPE_WINDOW = 500 + + +def _sleep(seconds: float) -> None: + time.sleep(seconds) + + +def _now_epoch() -> int: + return int(time.time()) + + +def _digest(line: str) -> str: + return hashlib.sha256(line.encode("utf-8", errors="replace")).hexdigest() + + +def run(args: Any, emitter: Emitter) -> int: + profile = credentials.load_selected(args) + base_url = profile.base_url + root = project.find_project_root(".") + app_id = project.require_app_id(root, base_url, args.app_id) + path = LOGS_PATH.format(app_id=app_id) + + client = PlatformClient( + base_url, + api_key=profile.api_key, + trust_env=not getattr(args, "no_proxy", False), + timeout=getattr(args, "timeout", None), + emitter=emitter, + ) + + printed = 0 + last_lines: list[str] = [] + seen: set[str] = set() + # The user's `--since` is forwarded verbatim: the server accepts epoch + # seconds or a `30m` / `2h` / `7d` window, and converting one form into the + # other locally would be a second implementation of the same semantics. + since: Any = args.since + interrupted = False + + # Initialised before the loop: a `--follow` run interrupted on its first + # request must still be able to explain itself. + runtime_state: dict[str, Any] = {"app_state": None, "pending_reason": None} + try: + with client: + while True: + requested_at = _now_epoch() + params: dict[str, Any] = {"tail": args.tail} + if since is not None: + params["since"] = since + if args.keyword: + # Filtered inside runtime-manager, so fewer than `tail` lines + # can come back. That is the design, not a short read — the + # CLI must not retry or warn about it. + params["keyword"] = args.keyword + + data = client.get_json(path, params=params) or {} + # Kept for the empty-output explanation below: the two fields + # ride along with every response so the reason survives a + # `--follow` loop that ended without printing anything. + runtime_state = { + "app_state": data.get("app_state"), + "pending_reason": data.get("pending_reason"), + } + lines = [str(line) for line in (data.get("lines") or [])] + fresh = [line for line in lines if _digest(line) not in seen] + for line in fresh: + emitter.info(line) + printed += len(fresh) + last_lines = lines + seen = {_digest(line) for line in lines[-DEDUPE_WINDOW:]} + + if not args.follow: + break + since = requested_at + _sleep(FOLLOW_INTERVAL) + except KeyboardInterrupt: + interrupted = True + emitter.info("已停止跟踪。") + + if not last_lines and not interrupted: + _explain_empty(emitter, args, runtime_state) + + data_out: dict[str, Any] = {"app_id": app_id, "line_count": printed} + if not args.follow: + data_out["lines"] = last_lines + emitter.result(COMMAND, ok=True, exit_code=EXIT_OK, data=data_out) + return EXIT_OK + + +#: Application states that explain an empty log by themselves — there is no +#: running container to have printed anything. +_NOT_RUNNING_STATES: dict[str, str] = { + "draft": "应用还是草稿,尚未上线,因此没有运行日志。", + "pending_capacity": "应用处于待上线(资源不足),没有在运行的实例,因此没有运行日志。", + "stopped": "应用已下线,没有在运行的实例,因此没有运行日志。", + "deleted": "应用已被删除。", +} + + +def _explain_empty(emitter: Emitter, args: Any, runtime_state: dict[str, Any]) -> None: + if args.since: + emitter.info("该时间段没有日志,或日志已被轮转(平台对保留期不做承诺)——这不代表这段时间什么都没发生。") + return + + # The server now names the state (F055 write-back 2), which is the whole + # difference between "your app printed nothing yet" and "your app is not + # running". Those two need opposite next steps, and the log text alone can + # never tell them apart. + state = str(runtime_state.get("app_state") or "") + explanation = _NOT_RUNNING_STATES.get(state) + if explanation: + reason = runtime_state.get("pending_reason") + if state == "pending_capacity" and reason: + explanation = f"{explanation}(成因:{reason})" + emitter.info(explanation) + return + if state == "online": + emitter.info("应用正在运行,但还没有输出任何日志。") + return + # An older platform that does not send the field, or a state we do not know: + # naming the candidates still beats printing nothing. + emitter.info("未取到日志:应用可能没有运行实例(草稿 / 待上线 / 已下线),请在应用详情页确认应用态。") diff --git a/src/bisheng-cli/bisheng_cli/commands/platforms.py b/src/bisheng-cli/bisheng_cli/commands/platforms.py new file mode 100644 index 0000000000..b8d71d2f48 --- /dev/null +++ b/src/bisheng-cli/bisheng_cli/commands/platforms.py @@ -0,0 +1,62 @@ +"""`bisheng platforms list` / `bisheng platforms use ` — the multi-platform +interaction layer (T047, AC-12). + +The data shape has been multi-profile since T012; this command only reads and +re-points it. Two rules: + +* **The key never leaves the store through this command.** `list` prints the + mask the platform handed back at login (`key_mask`), never the value — a + listing is exactly the kind of output that gets pasted into a chat. +* **`use` cannot invent a profile.** Switching to an address that was never + logged into is refused with exit 3 and the address in the message, because + the fix is `bisheng login `, not editing the store. + +`--platform` (the per-invocation form of the same choice) lives in `cli.py` and +is resolved by `credentials.load_selected`; both forms read the same store. +""" + +from __future__ import annotations + +from typing import Any + +from bisheng_cli import credentials +from bisheng_cli.errors import EXIT_OK, EXIT_USAGE +from bisheng_cli.output import Emitter + +COMMAND = "platforms" + + +def run(args: Any, emitter: Emitter) -> int: + sub = getattr(args, "platforms_command", None) + if sub == "list": + return _list(emitter) + if sub == "use": + return _use(args, emitter) + emitter.error("用法: bisheng platforms list | bisheng platforms use ") + emitter.error("列出本机已登录的平台,或把其中一个设为后续命令的默认平台。") + return EXIT_USAGE + + +def _list(emitter: Emitter) -> int: + rows = credentials.list_profiles() + if not rows: + emitter.info("本机尚未登录任何平台。先执行 bisheng login <平台地址>。") + for row in rows: + marker = "*" if row["current"] else " " + who = row.get("actor_name") or "(未命名)" + kind = "个人访问令牌" if row.get("actor_kind") == "natural_person" else "服务账号" + expires = row.get("expires_at") or "未设置" + emitter.info( + f"{marker} {row['base_url']} {kind}: {who} 密钥: {row.get('key_mask') or '(无掩码)'} 到期: {expires}" + ) + if rows: + emitter.info("(* = 当前默认平台;换默认用 bisheng platforms use ,单次指定用 --platform)") + emitter.result(COMMAND, ok=True, exit_code=EXIT_OK, data={"platforms": rows}) + return EXIT_OK + + +def _use(args: Any, emitter: Emitter) -> int: + key = credentials.set_current(args.base_url, warn=emitter.warn) + emitter.info(f"默认平台已切换为 {key}") + emitter.result(COMMAND, ok=True, exit_code=EXIT_OK, data={"current": key}) + return EXIT_OK diff --git a/src/bisheng-cli/bisheng_cli/commands/skills.py b/src/bisheng-cli/bisheng_cli/commands/skills.py new file mode 100644 index 0000000000..dc2d8bd092 --- /dev/null +++ b/src/bisheng-cli/bisheng_cli/commands/skills.py @@ -0,0 +1,359 @@ +"""`bisheng skills sync` — pull the platform's developer skill packs to this machine. + +A skill pack is guidance an AI coding tool reads so it builds an app that fits the +platform's hosting contract. The platform ships exactly one copy of each pack +(AC-15, 内外同源); ``sync`` fetches that copy from the anonymous distribution +endpoint into ``~/.bisheng/skills/`` so any local agent can reference it. + +Two rules the design pins down (决议-8, AC-21): + +* **One-way overwrite.** The pack is a platform release artifact, not the + developer's file. ``sync`` replaces the local copy wholesale — no three-way + merge, no keep-my-edits, no confirm-before-overwrite — and lists what it + overwrote so a developer who had edited a pack file is told, not surprised. + Local customisation belongs in the project's own ``AGENTS.md``, outside the + pack dir. +* **Version follows the platform.** There is no pack version to pick; whatever + platform you are logged into decides it, and re-running after a platform + upgrade is how you update. + +The pack ships next to the CLI wheel behind the same anonymous, conditionally +registered router, so a 404 here means the same thing it means for the wheel: the +open-capability layer is not on (or the platform predates skill packs). The +command reports that as its own exit code rather than as a mysterious empty sync. + +Two things sync does beyond unpacking, both of them corrections to a version that +merely wrote files and called it success: + +* **Packs are stored per platform.** ``~/.bisheng/skills///``. One + machine logging into a test platform and a production one used to have the + second login silently overwrite the first's contract, leaving the developer's + agent reading the wrong platform's rules with nothing on screen to suggest it. +* **Sync wires the pack into the local agents.** Writing into ``~/.bisheng`` alone + reaches nobody — see ``agent_skills`` for why an unwired pack is the expensive + failure mode and why every detected agent gets one. +""" + +from __future__ import annotations + +import hashlib +import io +import re +import tarfile +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit + +from bisheng_cli import agent_skills, credentials +from bisheng_cli.errors import EXIT_LOCAL_INVALID, EXIT_NOT_ENABLED, EXIT_OK, EXIT_USAGE, CliError +from bisheng_cli.http import PlatformClient +from bisheng_cli.output import Emitter + +COMMAND = "skills" + +#: Packs the platform ships, in sync order.「部署纳管」first: it is the pack whose +#: absence means the whole distribution layer is off, so its 404 is the +#: "environment not enabled" verdict (exit 8). A later pack that 404s on a +#: platform which does serve the first one is a *version* gap (the platform +#: predates that pack) and is reported as skipped, not as a failed sync — see +#: `sync_packs`. +DEFAULT_PACKS: tuple[str, ...] = ("deploy-hosting", "platform-wiring") + +SKILLS_ENDPOINT = "/api/v1/dev-toolkit/skills/{pack}" +# Header the endpoint stamps with the platform version the pack shipped with. +PACK_VERSION_HEADER = "x-bisheng-pack-version" + +# Where packs land: a sibling of credentials.json under ~/.bisheng, so the whole +# developer state lives in one 0700 directory. +SKILLS_DIR_NAME = "skills" + + +def _skills_base() -> Path: + return credentials.credentials_path().parent / SKILLS_DIR_NAME + + +def profile_slug(base_url: str) -> str: + """Directory name for one platform's packs: readable host + disambiguating hash. + + The host alone is not enough (`http://x` and `https://x` are two platforms with + two contracts), and a bare hash is unreadable in the path line the CLI prints. So + both: the netloc for a human, eight hex for uniqueness. Derived from the + normalised URL, i.e. the same key the credential store profiles by — anything + else would let two spellings of one platform own two pack copies. + """ + key = credentials.normalise_base_url(base_url) + digest = hashlib.sha256(key.encode("utf-8")).hexdigest()[:8] + readable = re.sub(r"[^a-z0-9.-]+", "-", (urlsplit(key).netloc or "platform").lower()).strip("-") + return f"{readable or 'platform'}.{digest}" + + +def skills_root(base_url: str) -> Path: + """Where this platform's packs live. Public: `deploy` cites it in AGENTS.md.""" + return _skills_base() / profile_slug(base_url) + + +# Internal call sites kept short; same function, one implementation. +_skills_root = skills_root + + +def _migrate_flat_layout(emitter: Emitter) -> None: + """Drop pre-profile packs sitting directly under ``~/.bisheng/skills/``. + + Only ever removes a directory whose name is a pack we ship — a slug always + carries a ``.`` + hash, so the two namespaces cannot collide. Left in place the + old copy is a decoy: nothing updates it, and a developer who finds it has no + way to tell it from the live one. + """ + base = _skills_base() + for pack in DEFAULT_PACKS: + stale = base / pack + if stale.is_dir() and not stale.is_symlink(): + try: + _rmtree(stale) + emitter.debug(f"已清理旧版落点 {stale}") + except OSError: + emitter.debug(f"旧版落点 {stale} 清理失败,可手动删除") + + +def run(args: Any, emitter: Emitter) -> int: + """Entry point. Only ``sync`` exists today; a bare ``skills`` prints usage.""" + sub = getattr(args, "skills_command", None) + if sub != "sync": + emitter.error("用法: bisheng skills sync") + emitter.error("将平台当前版本的开发者技能包同步到本地用户目录(~/.bisheng/skills/)。") + return EXIT_USAGE + + profile = credentials.load_selected(args) + client = PlatformClient( + profile.base_url, + api_key=profile.api_key, + trust_env=not getattr(args, "no_proxy", False), + timeout=getattr(args, "timeout", None), + emitter=emitter, + ) + with client: + packs = sync_packs(client, profile.base_url, emitter) + + _print_reference_guide(profile.base_url, packs, emitter) + emitter.result( + COMMAND, + ok=True, + exit_code=EXIT_OK, + data={"skills_dir": str(_skills_root(profile.base_url)), "packs": packs}, + ) + return EXIT_OK + + +def run_after_login(profile: credentials.Profile, args: Any, emitter: Emitter) -> None: + """Best-effort sync triggered by a successful ``login`` (AC-08). + + Login has already succeeded and its own ``result`` event is what the command + reports, so this must never raise and must never emit a ``result`` — a + failure here downgrades to a warning that tells the developer to re-run + ``skills sync`` by hand. + + It *does* print where the pack went and which agents took it. The earlier + version stayed silent to keep the login output short, and that silence was the + whole bug this round fixes: "5 个文件已同步" told the developer a sync had + happened while leaving them no way to learn that nothing could read it. + """ + try: + client = PlatformClient( + profile.base_url, + api_key=profile.api_key, + trust_env=not getattr(args, "no_proxy", False), + timeout=getattr(args, "timeout", None), + emitter=emitter, + ) + with client: + packs = sync_packs(client, profile.base_url, emitter) + _print_reference_guide(profile.base_url, packs, emitter) + except CliError as exc: + emitter.warn(f"技能包自动同步未完成:{exc.message}") + emitter.info(" 可稍后手动执行 bisheng skills sync 重试。") + except Exception as exc: + emitter.warn(f"技能包自动同步未完成:{exc.__class__.__name__}") + emitter.info(" 可稍后手动执行 bisheng skills sync 重试。") + + +def sync_packs(client: PlatformClient, base_url: str, emitter: Emitter) -> list[dict[str, Any]]: + """Fetch, unpack and wire each shipped pack. One line per pack; may raise.""" + root = _skills_root(base_url) + targets = agent_skills.detect() + results: list[dict[str, Any]] = [] + for index, pack in enumerate(DEFAULT_PACKS): + try: + result = _sync_one(client, pack, root, emitter) + except CliError as exc: + # The first pack decides whether the layer exists at all. Once one + # pack has been served, a 404 on another can only mean this platform + # version does not ship it yet — a fact to report, not a reason to + # discard the pack that did arrive. Only a 404 says that: a 5xx on + # the second pack is the platform failing, and stays fatal. + if index == 0 or (exc.details or {}).get("http_status") != 404: + raise + emitter.warn(f"平台当前版本未提供技能包 {pack},已跳过(平台升级后重跑 skills sync 即可获得)") + results.append({"pack": pack, "version": None, "files": [], "overwritten": [], "skipped": True}) + continue + # Wiring runs per pack and after its files are on disk, so a pack that + # failed to download never gets linked to a half-written directory. + result["agents"] = agent_skills.install(root / pack, pack, targets) + _report_agents(result, emitter) + results.append(result) + _migrate_flat_layout(emitter) + return results + + +def _report_agents(result: dict[str, Any], emitter: Emitter) -> None: + """Say which agents took the pack — and be loud when none did. + + "No agent found" is not a footnote: it means the sync that just reported + success cannot affect anything the developer's AI does. + """ + linked, problems = agent_skills.summarise(result.get("agents") or []) + if linked: + emitter.info(f" 已接入 {'、'.join(linked)}") + else: + emitter.warn("技能包已下载,但本机未接入任何 AI 编程工具——AI 读不到平台规矩。") + for problem in problems: + emitter.warn(f"{problem.get('label')} 未接入:{problem.get('reason') or problem.get('status')}") + + +def _sync_one(client: PlatformClient, pack: str, root: Path, emitter: Emitter) -> dict[str, Any]: + resp = client.request("GET", SKILLS_ENDPOINT.format(pack=pack)) + if resp.status_code == 404: + # The whole dev-toolkit router is absent when the open layer is off, so a + # missing wheel and a missing pack both surface here as 404. Either way + # the developer's next move is the platform admin, not their own machine. + raise CliError( + f"平台未提供技能包 {pack}", + exit_code=EXIT_NOT_ENABLED, + next_step="确认平台已启用开放能力层(open_platform)并升级到含技能包的版本,再重试。", + details={"pack": pack, "http_status": resp.status_code}, + ) + if resp.status_code >= 400: + raise CliError( + f"拉取技能包 {pack} 失败(HTTP {resp.status_code})", + exit_code=EXIT_NOT_ENABLED, + next_step="稍后重试;持续失败请联系平台管理员。", + details={"pack": pack, "http_status": resp.status_code}, + ) + + version = resp.headers.get(PACK_VERSION_HEADER) + written, overwritten = _extract(resp.content, root, pack) + + label = f"版本 {version}" if version else "平台当前版本" + emitter.info( + f"✓ {pack}({label}):{len(written)} 个文件已同步" + (f",覆盖 {len(overwritten)} 个" if overwritten else "") + ) + for name in overwritten: + emitter.debug(f" 覆盖 {name}") + return {"pack": pack, "version": version, "files": written, "overwritten": overwritten} + + +def _extract(content: bytes, root: Path, pack: str) -> tuple[list[str], list[str]]: + """Unpack ``pack.tar.gz`` into ``root/pack``, overwriting wholesale. + + Returns ``(files_written, files_overwritten)`` — both relative to ``root`` so + they read as ``deploy-hosting/SKILL.md``. Overwritten = files that existed + before this sync, which is the set AC-21 wants surfaced. + """ + pack_dir = root / pack + prior = _existing_files(pack_dir, root) + + try: + with tarfile.open(fileobj=io.BytesIO(content), mode="r:gz") as tar: + members = _safe_members(tar, root, pack) + # One-way overwrite: drop the old tree entirely, then lay down the + # platform version. No merge means no half-updated pack. + _rmtree(pack_dir) + root.mkdir(mode=0o700, parents=True, exist_ok=True) + tar.extractall(path=root, members=members) + written = sorted(m.name for m in members if m.isfile()) + except (tarfile.TarError, OSError) as exc: + raise CliError( + f"技能包 {pack} 解包失败:{exc.__class__.__name__}", + exit_code=EXIT_LOCAL_INVALID, + next_step="删除后重试,或联系平台管理员确认发布件完整。", + ) + + overwritten = sorted(prior & set(written)) + return written, overwritten + + +def _safe_members(tar: tarfile.TarFile, root: Path, pack: str) -> list[tarfile.TarInfo]: + """Members guaranteed to unpack inside ``root/pack`` — nothing else is trusted. + + The tarball comes from the platform the developer logged into, but a path + that climbs out of the pack dir is never legitimate, so it is rejected rather + than sanitised. Every member must live under the ``pack/`` arcname the + endpoint writes. + """ + root_resolved = root.resolve() + pack_prefix = f"{pack}/" + safe: list[tarfile.TarInfo] = [] + for member in tar.getmembers(): + name = member.name + if member.islnk() or member.issym(): + raise CliError( + f"技能包含有链接条目({name}),拒绝解包", + exit_code=EXIT_LOCAL_INVALID, + next_step="联系平台管理员确认发布件未被篡改。", + ) + if name != pack and not name.startswith(pack_prefix): + raise CliError( + f"技能包含有越界条目({name}),拒绝解包", + exit_code=EXIT_LOCAL_INVALID, + next_step="联系平台管理员确认发布件未被篡改。", + ) + target = (root / name).resolve() + if target != root_resolved / pack and root_resolved / pack not in target.parents: + raise CliError( + f"技能包条目路径越界({name}),拒绝解包", + exit_code=EXIT_LOCAL_INVALID, + next_step="联系平台管理员确认发布件未被篡改。", + ) + safe.append(member) + return safe + + +def _existing_files(pack_dir: Path, root: Path) -> set[str]: + if not pack_dir.exists(): + return set() + return {str(p.relative_to(root)) for p in pack_dir.rglob("*") if p.is_file()} + + +def _rmtree(path: Path) -> None: + """Remove a directory tree without importing shutil for one call site.""" + if not path.exists(): + return + for child in sorted(path.rglob("*"), reverse=True): + if child.is_file() or child.is_symlink(): + child.unlink() + elif child.is_dir(): + child.rmdir() + path.rmdir() + + +def _print_reference_guide(base_url: str, packs: list[dict[str, Any]], emitter: Emitter) -> None: + """After sync, say where the packs went and how anything else reaches them (AC-20). + + The previous wording claimed "Claude Code:自动发现 SKILL.md,无需配置" and pointed + at a ``README.md`` the pack does not contain. Both were wrong in the same + direction — they told the developer there was nothing left to do. No agent + scans ``~/.bisheng/skills/``; that is what ``agent_skills`` exists to fix, and + what this guide now reports instead of promising. + """ + root = _skills_root(base_url) + linked = sorted({r["label"] for pack in packs for r in (pack.get("agents") or []) if r.get("status") == "linked"}) + emitter.info("") + emitter.info(f"技能包落点:{root}(按平台分目录,切换平台不会互相覆盖)") + synced = [pack["pack"] for pack in packs if not pack.get("skipped")] + if synced: + emitter.info(f" · 已同步:{'、'.join(synced)}") + if linked: + emitter.info(f" · 已自动接入:{'、'.join(linked)}——新开一个会话即可生效。") + else: + emitter.info(" · 本机未发现 Claude Code / Codex;技能包只是下载了,还没有任何工具能读到。") + emitter.info(" · 其它 AI 编程工具(Cursor、Cline 等):在项目 AGENTS.md 里指向") + emitter.info(f" {root}/<包名>/SKILL.md;bisheng deploy 会自动写好这一行。") diff --git a/src/bisheng-cli/bisheng_cli/credentials.py b/src/bisheng-cli/bisheng_cli/credentials.py new file mode 100644 index 0000000000..286a1d0fe7 --- /dev/null +++ b/src/bisheng-cli/bisheng_cli/credentials.py @@ -0,0 +1,350 @@ +"""Credential storage: `~/.bisheng/credentials.json`, 0600, multi-profile. + +The key is stored **in clear text** and that is not an oversight — every request +has to send the original value, so there is nothing to derive it from. The file +mode is therefore the entire protection, which has one direct consequence for +the code below: the file is created with `os.open(..., 0o600)` in a single step. +"Write it, then chmod it" leaves a window in which the file is world-readable, +and on a shared jump host that window is the whole attack. + +`scopes` are never persisted. The server re-evaluates them per call; a cached +copy can only produce the "the admin ticked the box but the CLI still says no" +failure, which is unfalsifiable from the user's side. + +The multi-profile shape landed one round before `--platform` did, on purpose: +writing the single-platform shape (key at the top level) would have turned adding +a second platform into a migration. The interaction layer (`load_selected`, +`list_profiles`, `set_current`) sits on that shape with zero migration — every +profile is keyed by the normalised base URL and `current` names one of them. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import time +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit, urlunsplit + +from bisheng_cli.errors import EXIT_NOT_LOGGED_IN, CliError + +STORE_VERSION = 1 +DIR_NAME = ".bisheng" +FILE_NAME = "credentials.json" + +# The snapshot is for display only (design D3). Adding a field here means adding +# something the CLI will show the user without re-checking it against the server. +# The identity fields are spelled exactly as `GET /api/v2/auth/whoami` spells +# them (`actor_kind` / `actor_id` / `actor_name` / `resource_owner`), so that a +# server-side contract change shows up as a missing key rather than as a +# translation layer quietly filling in the wrong subject. They replaced the +# earlier `service_account: {id, name}` pair, which was F049's shape; a profile +# written by an older CLI simply loses those two values on the next read +# (`_to_profile` keeps only known fields) and `login` refills them. +SNAPSHOT_FIELDS = ( + "base_url", + "api_key", + "key_mask", + "tenant_id", + "actor_kind", + "actor_id", + "actor_name", + "resource_owner", + "expires_at", + "logged_in_at", +) + + +@dataclass +class Profile: + base_url: str + api_key: str + key_mask: str | None = None + tenant_id: int | None = None + actor_kind: str | None = None + actor_id: int | None = None + actor_name: str | None = None + resource_owner: dict[str, Any] | None = None + expires_at: str | None = None + logged_in_at: str | None = None + + +def normalise_base_url(raw: str) -> str: + """Canonical spelling of a platform address — the ONE implementation. + + Both the credential profile key and `.bisheng/app.json`'s per-platform key go + through this function. Two implementations would drift, and the drift shows + up as "login succeeded but the next command says you are not logged in", or + worse, an iterative deploy that reads no saved app_id and creates a second + draft application on the platform. + + Trailing slash and case are normalised. An explicitly written `:80` / `:443` + is **kept**: dropping it would be a guess about the operator's nginx setup, + and a wrong guess there merges two genuinely different platforms. + """ + text = (raw or "").strip() + if not text: + raise CliError("平台地址为空", exit_code=EXIT_NOT_LOGGED_IN, next_step="请执行 bisheng login <平台地址>。") + if "://" not in text: + text = "http://" + text + parts = urlsplit(text) + scheme = parts.scheme.lower() + netloc = parts.netloc.lower() + path = parts.path.rstrip("/") + return urlunsplit((scheme, netloc, path, "", "")) + + +def credentials_path() -> Path: + return Path.home() / DIR_NAME / FILE_NAME + + +def _is_windows() -> bool: + return os.name == "nt" + + +def _current_user_grantee() -> str | None: + """The icacls principal for the current user — its SID, name as a fallback. + + A bare ``%USERNAME%`` is unreliable as an icacls grantee: on a Microsoft-account + or domain login the name may not map to a SID, and a grant that fails to + resolve — on top of the old ``/inheritance:r`` — left the credentials file with + an empty DACL, i.e. unreadable by *everyone*. That is the field failure this + guards against: login succeeds and writes the file, then the next + ``bisheng deploy`` reads it and gets "Permission denied". The SID from + ``whoami`` resolves unambiguously and independent of locale, so we grant that. + A ``*``-prefixed SID is how icacls names a principal by SID directly. + """ + try: + out = subprocess.run( + ["whoami", "/user", "/fo", "csv", "/nh"], + capture_output=True, + text=True, + check=False, + ) + except OSError: + out = None + if out is not None and out.returncode == 0 and out.stdout.strip(): + # A row like: "HOST\user","S-1-5-21-..." + cells = [c.strip().strip('"') for c in out.stdout.strip().split('","')] + if len(cells) == 2 and cells[1].startswith("S-1-"): + return "*" + cells[1] + user = os.environ.get("USERNAME") or os.environ.get("USER") or "" + return user or None + + +def _can_read(path: Path) -> bool: + try: + with open(path, "rb"): + return True + except OSError: + return False + + +def _restore_inheritance(path: Path) -> None: + """Undo a hardening attempt that left the file unreadable by its owner. + + ``/reset`` replaces the DACL with the parent's inherited one (the profile + folder already excludes other standard users), which the owner can always do + even when the current DACL grants them nothing. + """ + try: + subprocess.run(["icacls", str(path), "/reset"], capture_output=True, check=False) + subprocess.run(["icacls", str(path), "/inheritance:e"], capture_output=True, check=False) + except OSError: + return + + +def _run_icacls(path: Path) -> bool: + """Grant the current user access on `path`, never leaving it unreadable. + + We deliberately do **not** run ``/inheritance:r`` any more. A user's + ``~/.bisheng`` inherits the profile folder's ACL, which already keeps other + standard users out — that is the real protection. Stripping inheritance only + created two ways to brick the file: a grant that did not resolve left an empty + DACL, and a grant that *did* locked the key to exactly the login-time token so + a deploy under a slightly different token (elevation) could not read it. So we + only add the current user, then confirm this process can still read the file; + if it cannot, we restore inherited permissions rather than leave a dead file. + Returns False only when the file is still unreadable after that recovery. + """ + grantee = _current_user_grantee() + if grantee: + try: + subprocess.run( + ["icacls", str(path), "/grant:r", f"{grantee}:F"], + capture_output=True, + check=False, + ) + except OSError: + pass + if _can_read(path): + return True + _restore_inheritance(path) + return _can_read(path) + + +def _harden(path: Path, warn: Callable[[str], None] | None) -> None: + if not _is_windows(): + return + if _run_icacls(path): + return + if warn is not None: + warn( + f"凭据文件 {path} 的权限可能异常:若后续命令报「读取凭据被拒 / Permission denied」," + f'请在 PowerShell 执行 icacls "{path}" /reset 后重试。' + ) + + +def _read_store() -> dict[str, Any]: + path = credentials_path() + if not path.exists(): + return {"version": STORE_VERSION, "current": None, "profiles": {}} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (ValueError, OSError) as exc: + # Never silently reset: the file holds the only copy of a key the admin + # handed over once, and overwriting it turns a typo into a re-issue. + raise CliError( + f"凭据文件无法解析:{exc}", + exit_code=EXIT_NOT_LOGGED_IN, + next_step=f"检查或删除 {path} 后重新执行 bisheng login。", + ) + if not isinstance(data, dict) or "profiles" not in data: + raise CliError( + "凭据文件结构无法识别", + exit_code=EXIT_NOT_LOGGED_IN, + next_step=f"检查或删除 {path} 后重新执行 bisheng login。", + ) + return data + + +def _write_store(store: dict[str, Any], warn: Callable[[str], None] | None) -> None: + path = credentials_path() + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + if not _is_windows(): + os.chmod(path.parent, 0o700) + payload = json.dumps(store, ensure_ascii=False, indent=2) + "\n" + tmp = path.with_name(path.name + f".tmp-{os.getpid()}") + if tmp.exists(): + tmp.unlink() + # O_CREAT|O_EXCL with the mode argument — the file is never world-readable, + # not even for the instant between open() and chmod(). + fd = os.open(tmp, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(payload) + except Exception: + tmp.unlink(missing_ok=True) + raise + os.replace(tmp, path) + _harden(path, warn) + + +def save_profile(base_url: str, profile: dict[str, Any], *, warn: Callable[[str], None] | None = None) -> None: + """Write one platform's profile and make it current.""" + key = normalise_base_url(base_url) + stored = {field: profile.get(field) for field in SNAPSHOT_FIELDS} + stored["base_url"] = key + stored["logged_in_at"] = profile.get("logged_in_at") or time.strftime("%Y-%m-%dT%H:%M:%S%z") + store = _read_store() + store["version"] = STORE_VERSION + store.setdefault("profiles", {})[key] = stored + store["current"] = key + _write_store(store, warn) + + +def load_profile(base_url: str) -> Profile: + key = normalise_base_url(base_url) + store = _read_store() + raw = store.get("profiles", {}).get(key) + if not raw: + raise CliError( + f"本机没有 {key} 的凭据", + exit_code=EXIT_NOT_LOGGED_IN, + next_step=f"先执行 bisheng login {key}。", + ) + return _to_profile(raw) + + +def load_current() -> Profile: + store = _read_store() + current = store.get("current") + raw = store.get("profiles", {}).get(current) if current else None + if not raw: + raise CliError( + "尚未登录任何平台", + exit_code=EXIT_NOT_LOGGED_IN, + next_step="先执行 bisheng login <平台地址>。", + ) + return _to_profile(raw) + + +def load_selected(args: Any) -> Profile: + """The profile a command should act on: `--platform` if given, else `current`. + + One resolver for every command that talks to a platform, so "which platform + did this run against" has exactly one answer and one error shape. A + `--platform` naming an address that was never logged into is exit 3 with the + address in the message — the same failure a bare `load_current` reports + when nothing is logged in at all, because the next step is the same: + `bisheng login `. + """ + selected = getattr(args, "platform", None) + if selected: + return load_profile(selected) + return load_current() + + +def list_profiles() -> list[dict[str, Any]]: + """Every stored platform, key **masked**, with the current one flagged. + + This is the payload `bisheng platforms list` prints and emits as JSON. The + key is replaced by its mask before the dict leaves this module: the output + masker would catch a `bs-sak-` literal anyway, but a listing that carries + the key in a field named `api_key` invites someone to read the field. + """ + store = _read_store() + current = store.get("current") + rows: list[dict[str, Any]] = [] + for key, raw in (store.get("profiles") or {}).items(): + profile = _to_profile(raw or {}) + rows.append( + { + "base_url": key, + "current": key == current, + "actor_kind": profile.actor_kind, + "actor_name": profile.actor_name, + "key_mask": profile.key_mask, + "expires_at": profile.expires_at, + "logged_in_at": profile.logged_in_at, + } + ) + return rows + + +def set_current(base_url: str, *, warn: Callable[[str], None] | None = None) -> str: + """Make a stored platform the default for commands run without `--platform`. + + Refuses (exit 3) rather than creating an empty profile: `current` pointing + at a platform with no key would make every later command fail with a + confusing "not logged in" for an address the store *does* list. + """ + key = normalise_base_url(base_url) + store = _read_store() + if key not in (store.get("profiles") or {}): + raise CliError( + f"本机没有 {key} 的凭据,无法切换", + exit_code=EXIT_NOT_LOGGED_IN, + next_step=f"先执行 bisheng login {key};已登录的平台用 bisheng platforms list 查看。", + ) + store["current"] = key + _write_store(store, warn) + return key + + +def _to_profile(raw: dict[str, Any]) -> Profile: + return Profile(**{field: raw.get(field) for field in SNAPSHOT_FIELDS}) diff --git a/src/bisheng-cli/bisheng_cli/devdb.py b/src/bisheng-cli/bisheng_cli/devdb.py new file mode 100644 index 0000000000..60857f2f90 --- /dev/null +++ b/src/bisheng-cli/bisheng_cli/devdb.py @@ -0,0 +1,294 @@ +"""`bisheng dev`'s local database and the environment it hands the app (T043). + +Everything the hosted runtime injects, the local run injects under the **same +name** — that is the whole contract (F054 `contracts-runtime-manager.md` §5, +INV-32), and the list below is a mirror of `runtime_manager.lifecycle.build_env` +rather than a second definition: `tests/test_platform_contract.py` reads that +function with `ast` and fails if the two sets of names ever differ. What +differs is only the *values*: the database is a SQLite file inside the project, +the base path is empty, the version is `dev`. + +Where the database lives and why (AC-26): `/.bisheng/dev/app.db`. +Inside the project so a `git clone` + `bisheng dev` on another machine starts +from the same place; under `.bisheng/` because that directory is a **hard** +packaging exclude (`ignore.py`) — the file can never ride into an upload, and +`!` in `.bishengignore` cannot bring it back. It is never deleted by the CLI, +so data survives restarts. + +Two things deliberately kept out of the child environment (AC-27): + +* the login key under its own name — `BISHENG_API_KEY` is stripped even if the + developer's shell exports it. The one place the credential does reach the app + is `OPENAI_API_KEY` (see below), which is the platform's name for "the + credential this process calls the model face with" and is what the hosted + runtime fills with the application's own token; +* any per-request visitor credential — that arrives per request in a header + (`devproxy`), never as a process-level variable. + +**The model face's three names** (`contracts-runtime-manager.md` §5, F051 +design D2): `OPENAI_BASE_URL` / `OPENAI_API_KEY` / `BISHENG_MODEL_BASE_URL`. +Hosted, `OPENAI_API_KEY` carries the application's runtime credential +(`BISHENG_APP_TOKEN`'s value); under `dev` it carries the developer's own +service-account key, so the same `OpenAI(...)` line works in both places and +the call is judged against exactly the scopes that key was granted (AC-28). +The base URL is **read from `whoami.model_base_url`**, never composed here: +F051 AC-30 makes that field the single outward spelling of the address. + +These three are platform-owned names like the rest, so they override the shell — +and when the platform answers with no model face (the open-capability layer is +not deployed), they are *removed* rather than inherited. Leaving a developer's +personal `OPENAI_API_KEY` in place would point the app at api.openai.com and +make it work locally in a way it can never work hosted. +""" + +from __future__ import annotations + +import os +import socket +import sqlite3 +import sys +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from bisheng_cli.errors import EXIT_LOCAL_INVALID, CliError +from bisheng_cli.project import STATE_DIR + +DEV_SUBDIR = "dev" +DB_FILE_NAME = "app.db" + +#: Same tuple as `runtime_manager.lifecycle.RESERVED_ENV_PREFIXES`: names the +#: platform owns and the app cannot redefine. +RESERVED_ENV_PREFIXES: tuple[str, ...] = ("BISHENG_APP_", "BISHENG_PLATFORM_", "PORT") + +#: The contract list — `contracts-runtime-manager.md` §5, as `build_env` sets it. +PLATFORM_ENV_NAMES: tuple[str, ...] = ( + "BISHENG_APP_DB_URL", + "BISHENG_APP_DB_PATH", + "BISHENG_APP_ID", + "BISHENG_APP_SLUG", + "BISHENG_APP_VERSION", + "BISHENG_APP_VERSION_ID", + "BISHENG_PLATFORM_API_BASE", + "PORT", + "BISHENG_APP_PORT", + "BISHENG_APP_BASE_PATH", + "BISHENG_APP_HEALTH_PATH", +) + +#: The model protocol face's names, declared in the same contract section as +#: `PLATFORM_ENV_NAMES` but set by a different producer hosted (F055 T056 fills +#: them from the app's runtime credential, not by `lifecycle.build_env`), which +#: is why they are a separate tuple here rather than three more entries above. +#: `tests/test_platform_contract.py` reads the contract document itself so this +#: list cannot drift away from it. +MODEL_FACE_ENV_NAMES: tuple[str, ...] = ( + "OPENAI_BASE_URL", + "OPENAI_API_KEY", + "BISHENG_MODEL_BASE_URL", +) + +#: Framework spellings of the base path, exported by the hosted image's +#: `entrypoint.sh.j2` from `BISHENG_APP_BASE_PATH`. Set here too, from the same +#: `/apps/{slug}` value, so the app behaves identically in both places. +FRAMEWORK_ENV_NAMES: tuple[str, ...] = ( + "UVICORN_ROOT_PATH", + "STREAMLIT_SERVER_BASE_URL_PATH", + "GRADIO_ROOT_PATH", + "FORWARDED_ALLOW_IPS", +) + +#: Never reaches the child process, whatever the shell exports. +LOGIN_KEY_ENV = "BISHENG_API_KEY" + +DEFAULT_HEALTH_PATH = "/" +DEV_VERSION = "dev" + + +@dataclass(frozen=True) +class DevDatabase: + path: Path + url: str + + +def dev_state_dir(root: Path) -> Path: + return Path(root) / STATE_DIR / DEV_SUBDIR + + +def prepare_dev_db(root: Path) -> DevDatabase: + """Create (or reuse) the project-local SQLite file and return its two spellings. + + The file is created here rather than left to the app's first connect, so a + read-only-at-start app finds a database and so the path printed at start-up + is a path that exists. Re-runs are no-ops: existing data is untouched. + """ + path = dev_state_dir(root) / DB_FILE_NAME + try: + path.parent.mkdir(parents=True, exist_ok=True) + sqlite3.connect(path).close() + except (OSError, sqlite3.Error) as exc: + raise CliError( + f"无法在项目里创建本地应用数据库 {path}:{exc.__class__.__name__}", + exit_code=EXIT_LOCAL_INVALID, + next_step="确认项目目录可写;若 .bisheng/dev/ 被占用或损坏,删除后重试。", + ) + # `sqlite:///` + an absolute POSIX path yields the four-slash form the hosted + # runtime uses (`sqlite:////data/app.db`); a Windows path becomes + # `sqlite:///C:/…`, which SQLAlchemy reads the same way. + return DevDatabase(path=path, url=f"sqlite:///{path.resolve().as_posix()}") + + +#: The slug `dev` serves under before the platform has assigned one. Any +#: non-empty prefix exposes a link that forgets the base path; the real slug +#: only matters for code that hard-codes it, which the approval preview +#: (`/apps/preview/{session}`) exposes anyway. +DEV_FALLBACK_SLUG = "dev" + + +def dev_slug(manifest: Mapping[str, Any], app_ref: Mapping[str, Any] | None = None) -> str: + """The slug the app has, or will have, on the platform; a placeholder before the first deploy.""" + for source in (manifest, app_ref or {}): + value = str(source.get("slug") or "").strip() + if value: + return value + return DEV_FALLBACK_SLUG + + +def build_dev_env( + *, + manifest: dict[str, Any], + app_port: int, + platform_base_url: str, + app_id: str | None, + db: DevDatabase, + base_env: dict[str, str] | None = None, + model_base_url: str | None = None, + model_api_key: str | None = None, + slug: str | None = None, +) -> dict[str, str]: + """The child environment: the shell's, with the platform names on top. + + Platform-owned names **override** whatever the shell had (the reserved + prefixes are the platform's, `lifecycle.RESERVED_ENV_PREFIXES`, plus the + model face's three), and the login key is removed from its own name. + Nothing else is filtered: `BISHENG_APP_START` and the developer's own + variables pass through, the same way the hosted entrypoint honours them. + + `model_base_url` is `whoami.model_base_url` verbatim. Both it and + `model_api_key` have to be present for the three model names to be set: + half a wiring (an address with no credential, or a credential pointed + nowhere) is what produces a confusing 401 instead of a clear "this platform + has no model face". + """ + env = dict(os.environ if base_env is None else base_env) + env.pop(LOGIN_KEY_ENV, None) + slug = slug or dev_slug(manifest) + base_path = f"/apps/{slug}" + env.update( + { + "BISHENG_APP_DB_URL": db.url, + "BISHENG_APP_DB_PATH": str(db.path), + "BISHENG_APP_ID": app_id or "", + "BISHENG_APP_SLUG": slug, + "BISHENG_APP_VERSION": DEV_VERSION, + "BISHENG_APP_VERSION_ID": DEV_VERSION, + "BISHENG_PLATFORM_API_BASE": platform_base_url, + "PORT": str(app_port), + "BISHENG_APP_PORT": str(app_port), + # `/apps/{slug}` in both places: `DevProxy` serves the app under + # the same prefix app-proxy does, so a link that forgets it breaks + # here first (INV-32 / D5.2). + "BISHENG_APP_BASE_PATH": base_path, + "BISHENG_APP_HEALTH_PATH": DEFAULT_HEALTH_PATH, + # entrypoint.sh.j2 exports, spelled the same way it spells them. + "UVICORN_ROOT_PATH": base_path, + "STREAMLIT_SERVER_BASE_URL_PATH": base_path.lstrip("/"), + "GRADIO_ROOT_PATH": base_path, + "FORWARDED_ALLOW_IPS": env.get("FORWARDED_ALLOW_IPS") or "*", + } + ) + base_url = (model_base_url or "").strip() + if base_url and model_api_key: + env.update( + { + "OPENAI_BASE_URL": base_url, + "OPENAI_API_KEY": model_api_key, + "BISHENG_MODEL_BASE_URL": base_url, + } + ) + else: + for name in MODEL_FACE_ENV_NAMES: + env.pop(name, None) + return env + + +# ---- start command ------------------------------------------------------------ + + +@dataclass(frozen=True) +class StartCommand: + """What to run and where it came from — `display` is what gets printed.""" + + argv: list[str] + source: str + + @property + def display(self) -> str: + return " ".join(self.argv) + + +def resolve_start_command(root: Path, *, environ: dict[str, str] | None = None) -> StartCommand: + """Same resolution order as the hosted image's `entrypoint.sh.j2`. + + `BISHENG_APP_START` → `Procfile` `web:` line → `main.py` → `app.py`. Anything + else is refused with the same four options the hosted entrypoint prints + (its exit 78), so a project that starts locally is one that starts hosted. + A shell string is run through `/bin/sh -c` exactly as the entrypoint does; + a script is run with the interpreter running this CLI. + """ + env = os.environ if environ is None else environ + explicit = (env.get("BISHENG_APP_START") or "").strip() + if explicit: + return StartCommand(argv=_shell(explicit), source="BISHENG_APP_START") + + procfile = Path(root) / "Procfile" + if procfile.is_file(): + for line in procfile.read_text(encoding="utf-8", errors="replace").splitlines(): + if line.startswith("web:"): + command = line[len("web:") :].strip() + if command: + return StartCommand(argv=_shell(command), source="Procfile web:") + break + + for candidate in ("main.py", "app.py"): + if (Path(root) / candidate).is_file(): + return StartCommand(argv=[sys.executable, candidate], source=candidate) + + raise CliError( + "找不到应用的启动方式", + exit_code=EXIT_LOCAL_INVALID, + next_step="提供以下任意一种:环境变量 BISHENG_APP_START、项目根 Procfile 的 web: 行、main.py 或 app.py" + "(托管环境按同一顺序解析,本地找不到线上也起不来)。", + ) + + +def _shell(command: str) -> list[str]: + if os.name == "nt": + return ["cmd", "/c", command] + return ["/bin/sh", "-c", command] + + +def pick_free_port(*, exclude: int | None = None) -> int: + """An unused loopback port for the app process (the proxy owns the public one).""" + for _ in range(10): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + if port != exclude: + return port + raise CliError( + "无法为应用进程挑选空闲端口", + exit_code=EXIT_LOCAL_INVALID, + next_step="用 --app-port 显式指定一个未被占用的端口。", + ) diff --git a/src/bisheng-cli/bisheng_cli/devproxy.py b/src/bisheng-cli/bisheng_cli/devproxy.py new file mode 100644 index 0000000000..f9a2d39a88 --- /dev/null +++ b/src/bisheng-cli/bisheng_cli/devproxy.py @@ -0,0 +1,623 @@ +"""The `bisheng dev` mini reverse proxy: identity in, forged identity out (T042). + +This is the local twin of F054's app-proxy (`src/app-proxy/app_proxy/headers.py`) +and it exists so that the code an application writes to read its visitor is the +**same code** locally and hosted (AC-23, INV-32). Three things it copies from +app-proxy on purpose, and a contract test (`tests/test_platform_contract.py`) +reads that file with `ast` to make sure they cannot drift: + +* **The strip is a normalised prefix match, never a name list.** Normalise + (`lower()` + `_` → `-`), then drop everything under `x-bisheng-`. This is the + CVE-2025-64484 lesson: oauth2-proxy stripped the exact hyphenated spellings it + knew about, and `X_Forwarded_User` walked straight past it because WSGI-family + frameworks fold underscores and hyphens onto one key (AC-25). +* **The ten canonical injected names, in the same order.** Absent material is + omitted, never emitted empty, so `if request.headers.get("X-BiSheng-Dept-Id")` + means the same thing in both places. A service account has no department, so + the three `Dept-*` headers are simply not there under `dev`. +* **The per-request short-lived credential handle** (`X-BiSheng-Access-Token`). + Hosted, app-proxy injects an OBO token the backend signs (F054 AC-34, 900 s). + Locally there is no backend signer to ask and — as of this round — no + consumer either, so the handle is **minted locally**: an opaque, HMAC-signed, + 15-minute token bound to this `dev` session. What matters for AC-23 is the + shape (a fresh short-lived handle per request, in the same header) and one + invariant this module guards with a test: **the login key never appears in + any header and never enters the application process.** + +What it does *not* do: proxy platform-capability calls (spec 决议-3 — the app +talks to the platform directly, the same way it does hosted), or accept any +`--as` / identity override (AC-25; `cli.py` has no such flag and +`tests/test_cli.py::test_no_as_flag_anywhere` keeps it that way). + +Dependency budget (design D12): the listener is the standard library's +`ThreadingHTTPServer`; the upstream hop reuses `httpx`, which the CLI already +carries. No third entry. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import html +import json +import secrets +import threading +import time +import uuid +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any +from urllib.parse import quote, urlsplit + +import httpx + +from bisheng_cli.output import Emitter + +#: Normalised prefix owned by the platform. Everything under it is ours to write +#: and no one else's to send — the equivalence class app-proxy strips. +PLATFORM_HEADER_PREFIX = "x-bisheng-" + +#: The ten of F054 design §4.2 ③, in the order they go on the wire. Mirrors +#: `app_proxy.headers.INJECTED_HEADER_NAMES` verbatim (contract-tested). +INJECTED_HEADER_NAMES: tuple[str, ...] = ( + "X-BiSheng-User-Id", + "X-BiSheng-User-Name", + "X-BiSheng-Tenant-Id", + "X-BiSheng-Dept-Id", + "X-BiSheng-Dept-Name", + "X-BiSheng-Dept-Path", + "X-BiSheng-Subject-Kind", + "X-BiSheng-App-Id", + "X-BiSheng-Access-Token", + "X-BiSheng-Request-Id", +) + +_CANONICAL_BY_NORMALISED = {name.lower(): name for name in INJECTED_HEADER_NAMES} + +#: Dropped outright before forwarding — same set as app-proxy, same reasons: +#: `x-forwarded-*` / `forwarded` are rewritten by the proxy; hop-by-hop headers +#: are RFC 9110 §7.6.1; `host` / `content-length` are recomputed for the +#: upstream request. +DROPPED_HEADERS = frozenset( + { + "x-forwarded-prefix", + "x-forwarded-proto", + "x-forwarded-host", + "x-forwarded-port", + "forwarded", + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "proxy-connection", + "te", + "trailer", + "trailers", + "transfer-encoding", + "upgrade", + "host", + "content-length", + } +) + +#: The platform session cookie app-proxy removes from `Cookie` (K7): the browser +#: sends it host-only, so under `dev` it only shows up when the platform itself +#: is on localhost — but that is exactly the case where forwarding it would hand +#: the app a credential far stronger than the scoped handle it is meant to get. +PLATFORM_SESSION_COOKIE = "access_token_cookie" + +#: Hop-by-hop response headers not relayed back to the browser. +_RESPONSE_DROPPED = frozenset( + {"connection", "keep-alive", "transfer-encoding", "content-length", "upgrade", "te", "trailer"} +) + +#: Same lifetime as the hosted OBO token (F054 AC-34). +HANDLE_TTL_SECONDS = 900 +HANDLE_PREFIX = "bsdev" + +_CONTROL_CHARS = frozenset(chr(i) for i in range(0x20)) | {chr(0x7F)} + + +# ---- header helpers (mirrors of app_proxy.headers) --------------------------- + + +def normalize_header_name(name: str) -> str: + """``X_BiSheng_User_Id`` and ``X-BiSheng-User-Id`` are the same header.""" + return name.strip().lower().replace("_", "-") + + +def is_platform_header(name: str) -> bool: + return normalize_header_name(name).startswith(PLATFORM_HEADER_PREFIX) + + +def encode_header_value(value: str) -> str: + """Make a value latin-1 safe without ever double-encoding it. + + ASCII in, ASCII out — so a value that is already percent-encoded survives + unchanged. Control characters are encoded even when ASCII: a raw CR/LF in a + header value is a response-splitting primitive. + """ + text = str(value) + if text.isascii() and not (_CONTROL_CHARS & set(text)): + return text + return quote(text, safe="/") + + +def _filter_cookie(value: str, session_cookie_name: str) -> str: + kept = [] + for chunk in value.split(";"): + pair = chunk.strip() + if not pair: + continue + name = pair.split("=", 1)[0].strip() + if name == session_cookie_name: + continue + kept.append(pair) + return "; ".join(kept) + + +def strip_platform_headers( + headers: Iterable[tuple[str, str]], + *, + session_cookie_name: str = PLATFORM_SESSION_COOKIE, + strip_session_cookie: bool = True, +) -> list[tuple[str, str]]: + """Everything the client sent, minus what it must not be able to say (AC-25).""" + kept: list[tuple[str, str]] = [] + for name, value in headers: + normalised = normalize_header_name(name) + if normalised.startswith(PLATFORM_HEADER_PREFIX): + continue + if normalised in DROPPED_HEADERS: + continue + if normalised == "cookie" and strip_session_cookie: + filtered = _filter_cookie(value, session_cookie_name) + if not filtered: + continue + kept.append((name, filtered)) + continue + kept.append((name, value)) + return kept + + +# ---- identity ----------------------------------------------------------------- + + +@dataclass(frozen=True) +class DevIdentity: + """What `dev` injects: the logged-in credential's subject, nothing else. + + Built from the platform's fresh `whoami` answer at start-up rather than the + stored snapshot, so a renamed or re-owned account shows up on the next + `dev`, and so a revoked key is refused before anything is injected (AC-29). + """ + + user_id: str + user_name: str + tenant_id: str | None + subject_kind: str + app_id: str | None = None + + @classmethod + def from_whoami(cls, whoami: Mapping[str, Any], *, app_id: str | None = None) -> DevIdentity: + # The hosted side spells a natural person `human` (entry_authz_service + # `_user_facts`); a `bs-pat-` personal token therefore maps to that, + # and a service-account key to `service_account` — the kind AC-23 names. + kind = "human" if whoami.get("actor_kind") == "natural_person" else "service_account" + tenant = whoami.get("tenant_id") + return cls( + user_id=str(whoami.get("actor_id") if whoami.get("actor_id") is not None else ""), + user_name=str(whoami.get("actor_name") or ""), + tenant_id=str(tenant) if tenant is not None else None, + subject_kind=kind, + app_id=app_id or None, + ) + + def material(self) -> dict[str, str]: + """Canonical header → value, absent values omitted (never emitted empty).""" + values = { + "X-BiSheng-User-Id": self.user_id, + "X-BiSheng-User-Name": encode_header_value(self.user_name), + "X-BiSheng-Tenant-Id": self.tenant_id or "", + "X-BiSheng-Subject-Kind": self.subject_kind, + "X-BiSheng-App-Id": self.app_id or "", + } + return {name: value for name, value in values.items() if value} + + +class HandleMinter: + """Mints the per-request short-lived credential handle (AC-23). + + The secret is generated per `dev` session and never written anywhere, so a + handle is meaningful only to this process — which is the whole point: it + is a *handle* with the hosted token's shape and lifetime, not a credential + that reaches the platform. `verify` exists so a test (or a future local + consumer) can check the signature and expiry rather than trusting bytes. + """ + + def __init__( + self, identity: DevIdentity, *, ttl_seconds: int = HANDLE_TTL_SECONDS, now: Callable[[], float] = time.time + ) -> None: + self._identity = identity + self._ttl = ttl_seconds + self._now = now + self._secret = secrets.token_bytes(32) + + def mint(self, request_id: str) -> str: + issued = int(self._now()) + payload = { + "sub": { + "app_id": self._identity.app_id, + "user_id": self._identity.user_id, + "tenant_id": self._identity.tenant_id, + "subject_kind": self._identity.subject_kind, + }, + "iat": issued, + "exp": issued + self._ttl, + "rid": request_id, + "iss": "bisheng-dev", + } + body = _b64(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")) + return f"{HANDLE_PREFIX}.{body}.{self._sign(body)}" + + def verify(self, handle: str) -> dict[str, Any] | None: + try: + prefix, body, signature = handle.split(".") + except ValueError: + return None + if prefix != HANDLE_PREFIX or not hmac.compare_digest(signature, self._sign(body)): + return None + payload = json.loads(_unb64(body)) + if payload.get("exp", 0) < self._now(): + return None + return payload + + def _sign(self, body: str) -> str: + return _b64(hmac.new(self._secret, body.encode("ascii"), hashlib.sha256).digest()) + + +def _b64(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def _unb64(text: str) -> bytes: + return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) + + +# ---- building the upstream request ------------------------------------------ + + +def build_injected_headers( + identity: DevIdentity, + *, + request_id: str, + handle: str | None, + proto: str = "http", + host: str = "", + prefix: str = "", +) -> list[tuple[str, str]]: + """The ten identity headers plus the three forwarding headers we own. + + `X-Forwarded-Prefix` carries the same `/apps/{slug}` the hosted proxy sends, + and `BISHENG_APP_BASE_PATH` holds the same value (see `DevProxy`). + """ + values: dict[str, str] = {} + for raw_name, raw_value in identity.material().items(): + canonical = _CANONICAL_BY_NORMALISED.get(normalize_header_name(raw_name)) + if canonical is None or raw_value is None or str(raw_value) == "": + continue + values[canonical] = str(raw_value) + if handle: + values["X-BiSheng-Access-Token"] = handle + values["X-BiSheng-Request-Id"] = request_id + + injected = [(name, encode_header_value(values[name])) for name in INJECTED_HEADER_NAMES if name in values] + injected.append(("X-Forwarded-Prefix", prefix)) + if proto: + injected.append(("X-Forwarded-Proto", proto)) + if host: + injected.append(("X-Forwarded-Host", host)) + return injected + + +def build_upstream_headers( + inbound: Iterable[tuple[str, str]], + identity: DevIdentity, + *, + request_id: str, + handle: str | None, + proto: str = "http", + host: str = "", + prefix: str = "", +) -> list[tuple[str, str]]: + """Strip then inject, in that order — the only order that is safe.""" + return strip_platform_headers(inbound) + build_injected_headers( + identity, request_id=request_id, handle=handle, proto=proto, host=host, prefix=prefix + ) + + +# ---- the entry prefix ----------------------------------------------------------- + +#: The platform's entry segment. Hosted, every app lives at `/apps/{slug}`. +ENTRY_PREFIX = "/apps" + + +def entry_prefix_for(slug: str, entry_prefix: str = ENTRY_PREFIX) -> str: + return f"{entry_prefix.rstrip('/')}/{slug}" + + +def restore_entry_prefix(location: str, slug: str, entry_prefix: str = ENTRY_PREFIX, upstream_base: str = "") -> str: + """Put the prefix back on a redirect the app sent — a copy of app-proxy's. + + `tests/test_platform_contract.py` runs both implementations over the same + cases, so the redirect an app sends lands in the same place locally and + hosted. See `app_proxy.routing.restore_entry_prefix` for the rule. + """ + prefix = entry_prefix_for(slug, entry_prefix) + value = location + if upstream_base and "://" in value: + target, origin = urlsplit(value), urlsplit(upstream_base) + if (target.scheme, target.netloc) != (origin.scheme, origin.netloc): + return location + value = target.path or "/" + if target.query: + value += "?" + target.query + if target.fragment: + value += "#" + target.fragment + if not value.startswith("/") or value.startswith("//"): + return location + if value == prefix or value.startswith((prefix + "/", prefix + "?", prefix + "#")): + return value + if value.startswith(entry_prefix.rstrip("/") + "/"): + return value + return prefix + value + + +def outside_prefix_page(path: str, prefix: str) -> str: + """The 404 body for a request that left the app's prefix. + + Hosted, the same request reaches the platform instead of the app, and the + developer sees a platform page or a bare 404 with nothing pointing at the + cause. Here the cause is known, so it is named. + """ + shown = html.escape(path) + root = html.escape(prefix) + return ( + "请求离开了应用的路径前缀" + "" + f"

    这个请求不在应用的路径前缀 {root}/ 下

    " + f"

    请求路径:{shown}

    " + f"

    托管到平台后,应用挂在 {root}/ 下。这个请求会落到平台自己的 " + f"{shown},而不是你的应用,所以在线上同样会失败。

    " + "

    原因通常是代码或构建产物里写了以 / 开头的绝对路径,例如 " + '<img src="/logo.png">、fetch("/api/x"),' + "或者前端构建工具的 base 用了默认的 /。

    " + "

    改法:经环境变量 BISHENG_APP_BASE_PATH 拼接链接,或者改用相对路径" + "(logo.png、api/x);Vite 把 base 设为 './'。

    " + f"

    打开应用 {root}/

    " + "" + ) + + +# ---- the proxy ---------------------------------------------------------------- + + +def _response_body(response: httpx.Response) -> Iterable[bytes]: + """Stream the upstream body; fall back to the buffered content. + + A response whose content httpx already holds in memory (a mock transport, + or a body read during redirect handling) reports its stream as consumed; + the bytes are still there and are relayed as one chunk. + """ + if response.is_stream_consumed: + yield response.content + return + yield from response.iter_raw() + + +@dataclass +class ProxiedResponse: + status: int + headers: list[tuple[str, str]] + body: Iterable[bytes] + close: Callable[[], None] = lambda: None + + +class DevProxy: + """One listener in front of the app process; forwards over a local httpx client. + + `forward` is the whole logic and takes plain values so it can be exercised + with an `httpx.MockTransport`; the `BaseHTTPRequestHandler` shell around it + only reads bytes off the socket and writes them back. + """ + + def __init__( + self, + *, + identity: DevIdentity, + minter: HandleMinter, + app_port: int, + listen_port: int, + listen_host: str = "127.0.0.1", + emitter: Emitter | None = None, + transport: httpx.BaseTransport | None = None, + request_id_factory: Callable[[], str] = lambda: uuid.uuid4().hex, + slug: str = "dev", + ) -> None: + self.identity = identity + # Served under the same `/apps/{slug}` as hosted, prefix stripped on the + # way in and announced in `X-Forwarded-Prefix`. A root-absolute link + # (`/logo.png`) then fails here, with a page that names the cause, + # instead of working locally and 404ing only once deployed. + self.slug = slug + self.prefix = entry_prefix_for(slug) + self._warned_paths: set[str] = set() + self.minter = minter + self.app_port = app_port + self.listen_host = listen_host + self.listen_port = listen_port + self.emitter = emitter + self._request_id_factory = request_id_factory + self._client = httpx.Client( + base_url=f"http://127.0.0.1:{app_port}", + transport=transport, + trust_env=False, + # A dev server must not time out a slow first request or an SSE + # stream; connect stays short so "app not listening" is prompt. + timeout=httpx.Timeout(None, connect=3.0), + ) + self._server: ThreadingHTTPServer | None = None + self._thread: threading.Thread | None = None + + # -- core ------------------------------------------------------------ + + def forward( + self, + method: str, + path: str, + inbound: list[tuple[str, str]], + body: bytes, + *, + host: str = "", + ) -> ProxiedResponse: + raw_path, _, query = path.partition("?") + suffix = f"?{query}" if query else "" + if raw_path == self.prefix and method in ("GET", "HEAD"): + # app-proxy answers the bare prefix the same way: relative links in + # the page only resolve inside the app with the trailing slash. + return self._redirect(308, f"{self.prefix}/{suffix}") + if raw_path == self.prefix: + upstream_path = "/" + suffix + elif raw_path.startswith(self.prefix + "/"): + upstream_path = raw_path[len(self.prefix) :] + suffix + else: + return self._outside_prefix(method, raw_path) + + request_id = self._request_id_factory() + headers = build_upstream_headers( + inbound, + self.identity, + request_id=request_id, + handle=self.minter.mint(request_id), + proto="http", + host=host, + prefix=self.prefix, + ) + started = time.monotonic() + try: + request = self._client.build_request(method, upstream_path, headers=headers, content=body) + response = self._client.send(request, stream=True) + except httpx.ConnectError: + return self._error( + 502, + f"应用尚未在 127.0.0.1:{self.app_port} 监听。进程可能还在启动;" + f"若一直如此,检查应用是否读取了 PORT 环境变量并绑定 0.0.0.0。", + ) + except httpx.HTTPError as exc: + return self._error(502, f"转发到应用失败:{exc.__class__.__name__}") + + if self.emitter is not None: + elapsed = int((time.monotonic() - started) * 1000) + self.emitter.info(f"{method} {path} → {response.status_code} {elapsed}ms") + upstream_base = str(self._client.base_url).rstrip("/") + relayed = [ + (name, restore_entry_prefix(value, self.slug, upstream_base=upstream_base)) + if name.lower() == "location" + else (name, value) + for name, value in response.headers.multi_items() + if name.lower() not in _RESPONSE_DROPPED + ] + return ProxiedResponse( + status=response.status_code, + headers=relayed, + body=_response_body(response), + close=response.close, + ) + + def _redirect(self, status: int, location: str) -> ProxiedResponse: + return ProxiedResponse(status=status, headers=[("Location", location), ("Content-Length", "0")], body=iter([])) + + def _outside_prefix(self, method: str, path: str) -> ProxiedResponse: + # Not redirected, not even `/`: a link to `/` in the app is the same + # defect as any other root-absolute link, and the page below carries a + # link to the app for the habitual first visit to `127.0.0.1:/`. + # The browser asks for /favicon.ico at the origin root by itself; + # hosted it gets the platform's icon, which is not a defect. + if path not in ("/", "/favicon.ico") and path not in self._warned_paths and self.emitter is not None: + self._warned_paths.add(path) + self.emitter.warn( + f"{method} {path} 不在应用前缀 {self.prefix}/ 下,托管后会落到平台而不是应用(返回 404)。" + "检查代码或构建产物里以 / 开头的绝对路径。" + ) + payload = outside_prefix_page(path, self.prefix).encode("utf-8") + return ProxiedResponse( + status=404, + headers=[("Content-Type", "text/html; charset=utf-8"), ("Content-Length", str(len(payload)))], + body=iter([payload]), + ) + + def _error(self, status: int, text: str) -> ProxiedResponse: + if self.emitter is not None: + self.emitter.warn(text) + payload = text.encode("utf-8") + return ProxiedResponse( + status=status, + headers=[("Content-Type", "text/plain; charset=utf-8")], + body=iter([payload]), + ) + + # -- lifecycle ------------------------------------------------------- + + def start(self) -> None: + proxy = self + + class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, format: str, *args: Any) -> None: + return # the proxy prints its own one-liner per request + + def _relay(self) -> None: + length = int(self.headers.get("Content-Length") or 0) + body = self.rfile.read(length) if length else b"" + inbound = list(self.headers.items()) + result = proxy.forward(self.command, self.path, inbound, body, host=self.headers.get("Host") or "") + try: + self.send_response(result.status) + for name, value in result.headers: + self.send_header(name, value) + # Body is delimited by connection close: no re-framing, and + # streaming responses (SSE) flow through chunk by chunk. + self.send_header("Connection", "close") + self.end_headers() + for chunk in result.body: + if chunk: + self.wfile.write(chunk) + self.wfile.flush() + except (BrokenPipeError, ConnectionResetError): + pass # the browser went away mid-response; nothing to report + finally: + result.close() + self.close_connection = True + + do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = do_OPTIONS = do_HEAD = _relay + + self._server = ThreadingHTTPServer((self.listen_host, self.listen_port), Handler) + self._server.daemon_threads = True + self.listen_port = self._server.server_address[1] + self._thread = threading.Thread(target=self._server.serve_forever, name="bisheng-dev-proxy", daemon=True) + self._thread.start() + + @property + def url(self) -> str: + """Where to open the app: the listener plus the app's prefix.""" + return f"http://{self.listen_host}:{self.listen_port}{self.prefix}/" + + def stop(self) -> None: + if self._server is not None: + self._server.shutdown() + self._server.server_close() + self._server = None + self._client.close() diff --git a/src/bisheng-cli/bisheng_cli/errors.py b/src/bisheng-cli/bisheng_cli/errors.py new file mode 100644 index 0000000000..88989bdb0a --- /dev/null +++ b/src/bisheng-cli/bisheng_cli/errors.py @@ -0,0 +1,494 @@ +"""Exit codes and the translation layer on top of the platform's error codes. + +Three ideas hold this file together. + +**One code, one next action.** The exit-code table is partitioned by what the +caller does next, not by severity. `16225` ("the platform never seeded the +approval scene — go find an administrator, editing your code will not help") and +`16226` ("the box is out of capacity — wait, or have the owner publish manually") +are the canonical example: identical severity, opposite responses, so they get +13 and 14 and must never be merged back together. + +**Three parts, always.** Every failure prints the platform's raw `code`, one +sentence of Chinese explaining it, and a concrete next step. Dropping the raw +code makes support unable to place the failure; dropping the sentence leaves a +number; dropping the next step leaves the agent with nothing to act on. + +**Unknown is neither crash nor success.** An unregistered code degrades in two +stages — first by HTTP status class (a 503 is "the platform is briefly down", +whatever the code says), then to exit 19 which prints the code and message +verbatim. It must never land on exit 1, which means "the CLI itself broke". +""" + +from __future__ import annotations + +from typing import Any + +from bisheng_cli.output import mask + +# ---- exit codes --------------------------------------------------------- + +EXIT_OK = 0 +EXIT_INTERNAL = 1 +EXIT_USAGE = 2 +EXIT_NOT_LOGGED_IN = 3 +EXIT_AUTH = 4 +EXIT_FORBIDDEN = 5 +EXIT_LOCAL_INVALID = 6 +EXIT_UNREACHABLE = 7 +EXIT_NOT_ENABLED = 8 +EXIT_PLATFORM_TOO_OLD = 9 +EXIT_PRECHECK_FAILED = 10 +EXIT_SECRET_FOUND = 11 +EXIT_PUBLISH_CONFLICT = 12 +EXIT_SCENE_MISSING = 13 +EXIT_CAPACITY = 14 +# 18 = a defect on either side of the contract. It is separate from 1 and from +# 19 because the *action* differs, and the action is the whole point of this +# table: on 1 (the CLI crashed) retrying once is reasonable; on 19 (a code we +# have no entry for) the caller reads the message and may retry with different +# arguments; on 18 both retrying and changing arguments are guaranteed useless +# — the only correct move is to stop and file the defect. Folding it into 19 +# would also mislabel it: 19 says "unregistered code", and 26004 / 26031 are +# codes we know exactly. +EXIT_DEFECT = 18 +EXIT_UNKNOWN_CODE = 19 +EXIT_REJECTED = 20 +EXIT_WITHDRAWN = 21 +EXIT_PENDING_ONLINE = 22 +EXIT_WAIT_TIMEOUT = 23 +EXIT_CANCELLED = 24 +EXIT_APPROVAL_EXCEPTION = 25 + +# ---- code → (sentence, next step) -------------------------------------- + +# INV-31 has three gates that all mean the same thing to a developer, so they +# say the same thing: the CLI-side scope check in `login`, the platform's +# channel-entrance refusal (`26051`), and `26016`. +# +# `26016` is the platform saying "a delegated key must send X-On-Behalf-Of". +# That reading is right for a general integration and useless here: **the CLI +# never sends identity headers**, on any command, so there is no header to add +# and the only thing 26016 can mean on this wire is "this key carries +# `delegate`". Translating it to "add the header" would be advice no caller can +# act on; leaving it unregistered dropped it to exit 19 ("未登记的错误码 26016"), +# which is how a delegate-only key used to fail `bisheng login`. +_DELEGATE_ONLY_SENTENCE = "这把密钥被配置为委托专用(delegate),不能用于本地开发" +_DELEGATE_ONLY_NEXT_STEP = "请平台管理员另外签发一把不含 delegate 位的服务账号密钥用于 CLI。" + +ERROR_HINTS: dict[int, tuple[str, str]] = { + 16233: ("Release notes exceed the limit", "Shorten the release notes to 2,000 characters and retry."), + # --- F049, 260 segment: credential and scope --- + 26001: ( + "请求没有携带可识别的 Authorization 头", + "检查密钥的传法:--api-key、环境变量 BISHENG_API_KEY 或 --api-key-stdin,值需是完整的 bs-sak- 开头字符串。", + ), + # 26002 covers four causes with one code, not three: the key is unknown / + # revoked / expired, **or the service account behind it is disabled or + # deleted**. That last one used to arrive as its own code (26027); on the + # beta2 server it does not — `credential_validator.resolve_service_account` + # raises `OpenApiCredentialInvalidError` for a missing, disabled or + # cross-tenant account. So "get a new key" can no longer be the only next + # step: for a disabled account a new key is just as dead as the old one, and + # a developer who is not told that will keep asking for re-issues. + 26002: ( + "平台不接受这把密钥(不存在、已撤销、已过期,或密钥所属的服务账号已被停用/删除——平台不区分这几种成因)", + "请平台管理员先确认该服务账号仍是启用状态,再在服务账号详情页重新签发一把密钥;账号本身被停用时,换密钥没有用。", + ), + # Still registered because the code still exists and still means what it + # says — but on beta2 it is raised only on the management face (issuing a + # key for a disabled account, `open_api/api/endpoints/service_account_keys.py`), + # which no CLI command calls. A CLI run that receives it is talking to a + # platform whose runtime path differs from the one measured here. + 26027: ( + "密钥所属的服务账号已被停用或删除", + "请平台管理员启用该服务账号;换一把密钥没有用,问题在账号本身。", + ), + 26003: ( + "这把密钥缺少本次操作需要的权限位", + "请平台管理员在服务账号详情页编辑该密钥、补勾所缺权限位后重试(无需重新签发)。", + ), + # 26004 changed meaning under the beta2 server. It used to be "any + # identity-passing header at all", which for this CLI could only ever be its + # own defect. It is now raised by `identity_service.resolve_request_identity` + # for a request that **did** carry `X-On-Behalf-Of` and whose delegation was + # refused (the key has no `delegate` bit, or the target is outside the key's + # delegate scope). The half that still holds is the diagnosis: the CLI sends + # no identity headers on any command, so the header came from something else + # on the path. Naming both origins is the difference between a bug report + # that goes to the right place and one that goes nowhere. + 26004: ( + "平台按「委托被拒」拒绝了这次调用:请求里带了 X-On-Behalf-Of 身份头,而这把密钥未开启委托、或委托目标不在允许范围内", + "CLI 从不发送这个头,所以它来自链路上的其它环节:先查网关 / 反向代理是否注入了 X-On-Behalf-Of;确认没有再连同 --verbose 输出报 CLI 故障。改密钥或权限位都不解决它。", + ), + # 26016 / 26051 are the same verdict reached at two different gates: 26016 + # by the delegation resolver (login's `whoami` requires no scope, so the + # entrance gate lets it through), 26051 by the entrance gate itself on the + # toolkit endpoints (`deploy` / `logs`). One next step, because there is + # only one: get a key without `delegate`. + 26016: (_DELEGATE_ONLY_SENTENCE, _DELEGATE_ONLY_NEXT_STEP), + 26051: (_DELEGATE_ONLY_SENTENCE, _DELEGATE_ONLY_NEXT_STEP), + # A `bs-pat-` personal token authenticates on the same wire as a service + # account key, so a developer can paste one into `bisheng login` and will: + # the platform then answers with these two codes, and unregistered they + # degraded to the generic "ask your admin about the permission bits" / + # "check whether the key expired" fallbacks — both of which point away from + # the actual cause. Note a personal token can never carry `app:manage` + # (伴生 PRD §4.10.3 whitelist), so it logs in and then fails every deploy. + 26040: ( + "这是一把个人访问令牌,而本平台没有开启个人访问令牌能力", + "改用服务账号密钥(bs-sak- 开头)——CLI 的 deploy 本来就只认服务账号密钥,个人令牌拿不到 app:manage 位。", + ), + 26043: ( + "个人访问令牌的持有人已停用、已删除,或已不在本租户", + "找平台管理员恢复该账号,或改用服务账号密钥(bs-sak- 开头)重新 login。", + ), + 26030: ( + "平台鉴权服务暂时不可用(fail-closed,与密钥无关),可重试", + "稍等片刻后重跑同一条命令;持续不恢复再联系平台管理员。", + ), + 26031: ( + "平台端点缺少权限位标记,这是平台缺陷", + "请报平台故障并附上失败的命令与时间;CLI 侧无可修改项。", + ), + # --- F054, 161 segment: orchestrator --- + 16101: ( + "平台上找不到这个应用", + "确认 --app-id 是否写错,或该应用是否已被删除;必要时删掉 .bisheng/app.json 里的过期标识重新首发。", + ), + 16103: ( + "应用标识(slug)已被占用", + "在 bisheng-app.yaml 里换一个 slug 再部署。已删除的应用同样占用它原来的标识,不能复用。", + ), + 16124: ( + "应用执行失败", + "先核对操作阶段;启动失败时检查启动命令、监听地址和端口,涉及数据维护时先确认操作状态再重试。", + ), + 16121: ( + "编排器不可用(容器守护进程或 runtime-manager 不可达),可重试", + "稍后重试;持续不恢复请联系运维检查 runtime-manager 与 dockerd。注意这不代表应用实例不存在。", + ), + # --- F055, 162 segment: receive gates (synchronous leg of POST /deploy) --- + 16201: ( + "上传包超过平台配置的体量上限", + "先看忽略统计里排除了多少,再按 Top 10 大文件清理;仍不够就请运维调整 app_runtime.max_package_mb。", + ), + 16202: ( + "平台无法解包,或包内含非法路径条目", + "重新执行 deploy 生成包;若持续失败,用 --dry-run 检查是否有符号链接、设备文件或越界路径。", + ), + 16203: ( + "包根里没有 bisheng-app.yaml", + "检查 deploy 的 PATH 参数与包根是否一致——本地校验已经过了,说明上传的包根不是项目根。", + ), + 16205: ( + "这个应用归属于别人,当前密钥的资源归属人无权操作", + "改用资源归属人正确的那把密钥;应用归属不因任何请求头改变。", + ), + 16207: ( + "本环境未启用应用工场运行时层", + "请平台管理员打开 app_runtime 开关并重启后端;开关未开时 deploy / logs 都不可用。", + ), + # --- F055, 162 segment: hosted precheck --- + 16221: ( + "应用声明(bisheng-app.yaml)未通过平台校验", + "按 details 指出的字段补齐或修正 bisheng-app.yaml 后重新 deploy。", + ), + 16222: ( + "声明的 runtime 平台不支持", + "把 bisheng-app.yaml 的 runtime 改成 details/hints 里列出的受支持取值之一。", + ), + 16223: ( + "声明的资源档位不存在或已停用", + "改用平台当前提供的档位;不确定就先删掉 tier 让平台取默认值。", + ), + 16224: ( + "能力声明无法解析", + "修正 bisheng-app.yaml 里 capabilities 段的写法后重发。", + ), + 16227: ( + "交付准备失败", + "检查固定镜像、启动入口和精确版本依赖材料;离线缺包时导入对应材料后重试。", + ), + 16228: ( + "应用启动探活失败", + "确认应用监听声明的端口、绑定 0.0.0.0,且健康路径返回 500 以下状态码。用 bisheng dev 本地复现后再重发。", + ), + 16230: ( + "bisheng-app.yaml 里含密钥或密钥引用", + "从 bisheng-app.yaml 删除密钥值或 vault:// 这类引用后重发;清单里不写任何密钥。", + ), + 16231: ( + "本环境尚未开放能力声明需要的平台能力", + "请管理员开启开放平台能力后重发,或先从 bisheng-app.yaml 移除对应的能力声明。", + ), + 16232: ( + "WebSocket 无需在 bisheng-app.yaml 里声明", + "删掉清单里的 WebSocket 键后重发;托管入口直接转发 WebSocket,前端要对任何 close 自动重连。", + ), + 16241: ( + "安全扫描在包内命中了疑似密钥", + "按输出里的 文件:行号 移除密钥、改用环境变量引用后重发;平台不会回显命中的值。", + ), + # --- F055, 162 segment: publish-flow conflicts --- + 16225: ( + "平台没有配置发布审批场景", + "请平台管理员补齐发布审批场景配置;改代码或改声明都没有用。", + ), + 16226: ( + "运行环境容量不足", + "等资源释放后重试,或让应用归属人在应用详情页·发布手动上线。", + ), + 16229: ( + "本次发布包含未确认的结构变更", + "确认变更影响后带上 --confirm-schema-change 重发。", + ), + 16251: ( + "该应用已有在途的发布审批单", + "先在应用详情页·发布撤回在途单,再重新 deploy;CLI 不提供撤回命令。", + ), + 16252: ( + "该应用处于待上线状态", + "先在应用详情页·发布完成或放弃这次上线,再重新 deploy。", + ), + 16254: ( + "该操作仅限应用归属人执行", + "改用资源归属人是该应用 owner 的那把密钥。", + ), +} +# Runtime v2 keeps the existing runtime code; its capacity remedy is unchanged. +ERROR_HINTS[16125] = ERROR_HINTS[16226] + +ERROR_EXIT_CODES: dict[int, int] = { + 16233: EXIT_LOCAL_INVALID, + 26001: EXIT_AUTH, + 26002: EXIT_AUTH, + 26027: EXIT_AUTH, + 26003: EXIT_FORBIDDEN, + # Both sides of the defect class land on 18 (see its definition above): the + # request reached the platform carrying an identity header this CLI never + # sends, or the platform is missing a scope marking. Neither is anything the + # caller did or can fix by retrying or by changing arguments — 26004 stays + # on 18 under beta2's wider reading of it ("delegation refused") for exactly + # that reason, even though the platform now describes it as a key- + # configuration problem: no CLI key delegates, so no key change is the fix. + 26004: EXIT_DEFECT, + # Not EXIT_DEFECT: nothing is broken, the key is simply the wrong kind — + # and not EXIT_UNKNOWN_CODE either, which would invite a retry with + # different arguments. It is the same refusal `delegate_refusal()` raises + # locally, so it lands on the same exit code. + 26016: EXIT_FORBIDDEN, + 26051: EXIT_FORBIDDEN, + 26040: EXIT_FORBIDDEN, + 26043: EXIT_AUTH, + 26030: EXIT_UNREACHABLE, + 26031: EXIT_DEFECT, + 16101: EXIT_LOCAL_INVALID, + 16121: EXIT_UNREACHABLE, + 16125: EXIT_CAPACITY, + 16201: EXIT_LOCAL_INVALID, + 16202: EXIT_LOCAL_INVALID, + 16203: EXIT_LOCAL_INVALID, + 16103: EXIT_PUBLISH_CONFLICT, + 16205: EXIT_PUBLISH_CONFLICT, + 16207: EXIT_NOT_ENABLED, + 16221: EXIT_PRECHECK_FAILED, + 16222: EXIT_PRECHECK_FAILED, + 16223: EXIT_PRECHECK_FAILED, + 16224: EXIT_PRECHECK_FAILED, + 16124: EXIT_PRECHECK_FAILED, + 16227: EXIT_PRECHECK_FAILED, + 16228: EXIT_PRECHECK_FAILED, + 16230: EXIT_PRECHECK_FAILED, + 16231: EXIT_PRECHECK_FAILED, + 16232: EXIT_PRECHECK_FAILED, + 16241: EXIT_SECRET_FOUND, + 16225: EXIT_SCENE_MISSING, + 16226: EXIT_CAPACITY, + 16229: EXIT_PUBLISH_CONFLICT, + 16251: EXIT_PUBLISH_CONFLICT, + 16252: EXIT_PUBLISH_CONFLICT, + 16254: EXIT_PUBLISH_CONFLICT, +} + +# Unregistered codes degrade by HTTP status class before falling to exit 19. +_HTTP_STATUS_FALLBACK: dict[int, int] = { + 401: EXIT_AUTH, + 403: EXIT_FORBIDDEN, + 502: EXIT_UNREACHABLE, + 503: EXIT_UNREACHABLE, + 504: EXIT_UNREACHABLE, +} + + +class CliError(Exception): + """A failure with a decided exit code and something to say about it.""" + + def __init__( + self, + message: str, + *, + exit_code: int = EXIT_INTERNAL, + code: int | None = None, + next_step: str = "", + platform_message: str = "", + details: Any = None, + hints: list[str] | None = None, + ) -> None: + super().__init__(message) + self.message = message + self.exit_code = exit_code + self.code = code + self.next_step = next_step + self.platform_message = platform_message + self.details = details + self.hints = hints or [] + + def as_failure(self, stage: str | None = None) -> dict[str, Any]: + """The machine-readable five-tuple. + + `details` and `hints` go through untouched: they are the entire input a + local agent has for repairing the failure automatically. + """ + return { + "stage": stage, + "code": self.code, + "message": self.platform_message or self.message, + "details": self.details, + "hints": self.hints, + } + + +def error_from_platform( + code: int, + message: str = "", + *, + http_status: int | None = None, + details: Any = None, + hints: list[str] | None = None, +) -> CliError: + """Translate a platform error code into a CliError.""" + hint = ERROR_HINTS.get(code) + if code == 16233: + from bisheng_cli.release_notes import MAX_CODEPOINTS + from bisheng_cli.release_notes import message as release_notes_message + + hint = (release_notes_message("too_long", limit=MAX_CODEPOINTS), release_notes_message("fix_input")) + if hint is not None: + human, next_step = hint + next_step = _augment_next_step(code, next_step, details) + return CliError( + human, + exit_code=ERROR_EXIT_CODES[code], + code=code, + next_step=next_step, + platform_message=message, + details=details, + hints=hints, + ) + + exit_code = _HTTP_STATUS_FALLBACK.get(http_status or 0, EXIT_UNKNOWN_CODE) + if exit_code == EXIT_UNREACHABLE: + human = f"平台暂时不可用(HTTP {http_status}),可重试" + next_step = "稍后重跑同一条命令;持续不恢复请联系平台管理员。" + elif exit_code == EXIT_AUTH: + human = f"平台拒绝了这次鉴权(HTTP {http_status})" + next_step = "检查密钥是否有效、是否已过期或被撤销。" + elif exit_code == EXIT_FORBIDDEN: + human = f"平台以权限不足拒绝了这次请求(HTTP {http_status})" + next_step = "请平台管理员核对该密钥的权限位。" + else: + human = f"平台返回未登记的错误码 {code}" + next_step = "按平台给出的信息处置;若无法判断,请把该错误码报给平台支持。" + return CliError( + human, + exit_code=exit_code, + code=code, + next_step=next_step, + platform_message=message, + details=details, + hints=hints, + ) + + +def _augment_next_step(code: int, next_step: str, details: Any) -> str: + """Fold code-specific payload fields into the next step. + + Only `26003` needs this today: naming the missing scope verbatim is the + difference between "ask your admin for permissions" and an admin who knows + which checkbox to tick. + """ + if code == 26003 and isinstance(details, dict): + required = details.get("required") + # A single string, not a list (OpenApiScopeMissingError(required: str)). + if isinstance(required, str) and required: + return f"该密钥缺少权限位 {required}。" + next_step + return next_step + + +def delegate_refusal() -> CliError: + """CLI-side refusal of a delegate-only key. + + Shaped to match the server-side refusal on purpose (same sentence, same + next step, same exit code as `26016` / `26051`): the rejections are the same + product rule enforced at different gates, and a caller that special-cases + one of them is a caller that will mishandle the others. + + This path is no longer the one a delegate-only key normally takes — the + platform refuses it during `whoami` (26016), before `login` ever sees a + scope list. It stays because it is the only gate that still works if a + future platform lets such a key read its own scopes. + """ + return CliError( + _DELEGATE_ONLY_SENTENCE, + exit_code=EXIT_FORBIDDEN, + next_step=_DELEGATE_ONLY_NEXT_STEP, + ) + + +#: How many lines of a server-supplied log excerpt to print for a person. +#: The whole tail is 80 lines and reaches ``--json`` intact; a person reading a +#: terminal wants the end of it, which is where the actual error is. +_HUMAN_LOG_LINES = 20 + + +def _log_excerpt(details: Any) -> list[str]: + """The build log tail, when the platform sent one. + + Hints routinely say "look at the log excerpt below", and until this existed + there was no below: ``details`` only ever reached ``--json``, so in the + terminal the advice pointed at nothing. A failure a person cannot diagnose + from the output is a failure they will re-run unchanged. + """ + if not isinstance(details, dict): + return [] + tail = details.get("tail") + if not isinstance(tail, (list, tuple)): + return [] + lines = [str(one).rstrip() for one in tail] + return [one for one in lines if one][-_HUMAN_LOG_LINES:] + + +def render_human(err: CliError) -> str: + """Format a failure for a person: raw code, sentence, next step, hints, log.""" + head = f"错误 {err.code}: {err.message}" if err.code is not None else f"错误: {err.message}" + lines = [head] + if err.platform_message and err.platform_message != err.message: + lines.append(f" 平台信息: {err.platform_message}") + if err.next_step: + lines.append(f" 下一步: {err.next_step}") + for hint in err.hints: + lines.append(f" 提示: {hint}") + if err.code in {16124, 16227, 16228} and isinstance(err.details, dict): + for key, label in (("reason", "原因"), ("runtime_stage", "阶段"), ("operation_id", "操作")): + value = err.details.get(key) + if isinstance(value, str) and value: + lines.append(f" {label}: {value}") + excerpt = _log_excerpt(err.details) + if excerpt: + label = "应用启动输出" if err.code == 16228 else ("交付准备输出" if err.code == 16227 else "运行输出") + lines.append(f" {label}(末尾):") + lines.extend(f" {one}" for one in excerpt) + elif err.code in {16124, 16227, 16228}: + lines.append(" 暂无可用的运行输出;诊断可能未生成或已到期。") + return mask("\n".join(lines)) diff --git a/src/bisheng-cli/bisheng_cli/http.py b/src/bisheng-cli/bisheng_cli/http.py new file mode 100644 index 0000000000..2d434f0ab1 --- /dev/null +++ b/src/bisheng-cli/bisheng_cli/http.py @@ -0,0 +1,257 @@ +"""HTTP access to the platform: one client, two envelope shapes, one probe. + +The single most load-bearing rule in this file is the parse order in +`parse_envelope`: **body first, status line second**. `/api/v1` answers HTTP 200 +and puts the verdict in `status_code`, while `/api/v2` sets a real status *and* +keeps the envelope. Read the status line first and every v1 business error looks +like a success; read only the body and a v2 503 looks like a valid response. + +Nothing here judges permissions and nothing here caches `scopes`. The server +re-evaluates a key's scopes on every call (with a 3-second positive cache of its +own), so a CLI-side cache can only ever produce "the admin ticked the box but +the CLI still says no". +""" + +from __future__ import annotations + +import os +import time +from dataclasses import dataclass +from typing import Any + +import httpx + +from bisheng_cli import __version__ +from bisheng_cli.errors import ( + EXIT_NOT_ENABLED, + EXIT_PLATFORM_TOO_OLD, + EXIT_UNREACHABLE, + EXIT_USAGE, + CliError, + error_from_platform, +) +from bisheng_cli.output import Emitter + +CONNECT_TIMEOUT = 10.0 +READ_TIMEOUT = 60.0 +# Deliberately below nginx's `proxy_read_timeout 300s`: the CLI has to be the one +# that gives up first, otherwise the user gets a 504 from a proxy they cannot see +# and no way to tell an upload problem from a platform problem. +UPLOAD_READ_TIMEOUT = 240.0 +NGINX_PROXY_READ_TIMEOUT = 300.0 + +VERSIONS_PATH = "/api/v1/dev-toolkit/versions" +ENV_PATH = "/api/v1/env" +# Where the platform serves its own CLI wheel when `versions` does not say. +DEFAULT_CLI_DOWNLOAD_PATH = "/api/v1/dev-toolkit/cli/download" + +_PROXY_ENV_NAMES = ("ALL_PROXY", "HTTPS_PROXY", "HTTP_PROXY", "all_proxy", "https_proxy", "http_proxy") + + +@dataclass +class ProbeResult: + """Outcome of the pre-flight `versions` probe.""" + + versions: dict[str, Any] + cli_min_compatible: str | None + warning: str | None + + +def parse_envelope(resp: httpx.Response) -> Any: + """Return `data`, or raise a translated CliError. + + Order matters — see the module docstring. + """ + body: Any = None + try: + body = resp.json() + except ValueError: + body = None + + if isinstance(body, dict) and "status_code" in body: + status_code = body.get("status_code") + if isinstance(status_code, int) and status_code != 200: + raise error_from_platform( + status_code, + str(body.get("status_message") or ""), + http_status=resp.status_code, + details=body.get("data"), + hints=_hints_of(body.get("data")), + ) + return body.get("data") + + if resp.status_code >= 400: + raise error_from_platform( + resp.status_code, + _text_excerpt(resp), + http_status=resp.status_code, + ) + return body + + +def _hints_of(data: Any) -> list[str] | None: + if isinstance(data, dict): + hints = data.get("hints") + if isinstance(hints, list): + return [str(h) for h in hints] + return None + + +def _text_excerpt(resp: httpx.Response) -> str: + try: + return resp.text[:200] + except Exception: # pragma: no cover - defensive + return "" + + +class PlatformClient: + """Thin httpx wrapper: base URL, bearer header, timeout tiers, envelopes.""" + + def __init__( + self, + base_url: str, + api_key: str | None = None, + *, + trust_env: bool = True, + timeout: float | None = None, + transport: httpx.BaseTransport | None = None, + emitter: Emitter | None = None, + ) -> None: + self.base_url = base_url.rstrip("/") + self._api_key = api_key + self.trust_env = trust_env + self._read_timeout = timeout if timeout is not None else READ_TIMEOUT + self.emitter = emitter + self._client = httpx.Client( + base_url=self.base_url, + trust_env=trust_env, + transport=transport, + timeout=httpx.Timeout(self._read_timeout, connect=CONNECT_TIMEOUT), + headers={"User-Agent": f"bisheng-cli/{__version__}"}, + ) + + # ---- lifecycle ------------------------------------------------------ + + def close(self) -> None: + self._client.close() + + def __enter__(self) -> PlatformClient: + return self + + def __exit__(self, *exc: object) -> None: + self.close() + + # ---- requests ------------------------------------------------------- + + def request(self, method: str, path: str, *, read_timeout: float | None = None, **kwargs: Any) -> httpx.Response: + headers = dict(kwargs.pop("headers", {}) or {}) + if self._api_key: + headers["Authorization"] = f"Bearer {self._api_key}" + timeout = httpx.Timeout( + read_timeout if read_timeout is not None else self._read_timeout, connect=CONNECT_TIMEOUT + ) + started = time.monotonic() + try: + resp = self._client.request(method, path, headers=headers, timeout=timeout, **kwargs) + except httpx.HTTPError as exc: + raise self._connection_error(method, path, exc) + if self.emitter is not None: + elapsed = int((time.monotonic() - started) * 1000) + self.emitter.debug(f"{method} {path} {resp.status_code} {elapsed}ms") + return resp + + def get_json(self, path: str, **kwargs: Any) -> Any: + return parse_envelope(self.request("GET", path, **kwargs)) + + def post_json(self, path: str, **kwargs: Any) -> Any: + return parse_envelope(self.request("POST", path, **kwargs)) + + # ---- errors --------------------------------------------------------- + + def _connection_error(self, method: str, path: str, exc: httpx.HTTPError) -> CliError: + """Turn a transport failure into something actionable. + + A developer machine usually has a proxy configured for the public + internet while the platform lives on an intranet address. The symptom is + "platform unreachable" while the platform is perfectly healthy, so the + proxy variable is named in the message rather than left for the user to + remember. + """ + message = f"平台不可达({method} {path}):{exc.__class__.__name__}" + proxies = [f"{name}={os.environ[name]}" for name in _PROXY_ENV_NAMES if os.environ.get(name)] + if proxies and self.trust_env: + next_step = ( + f"检测到代理环境变量 {', '.join(proxies)};内网地址请加入 NO_PROXY,或改用 --no-proxy 直接绕过代理。" + ) + else: + next_step = "确认平台地址与网络连通性(内网地址是否需要 VPN),稍后重试。" + return CliError(message, exit_code=EXIT_UNREACHABLE, next_step=next_step) + + +def probe(client: PlatformClient) -> ProbeResult: + """Pre-flight: is this platform one this CLI can talk to at all? + + Four distinguishable outcomes, each with its own exit code, because the + remedies have nothing in common: turn on a switch and restart (8), upgrade + the platform or downgrade the CLI (9), fix the network (7), download the + platform's own newer CLI (2, AC-02 / T048). + + Once the probe decides the open-capability layer is absent it **returns by + raising** — no credential is sent afterwards. That is how AC-05's "login is + unusable in this environment" is honoured while `whoami` itself stays + permanently registered on the server side. + + The version check has two tiers on purpose (AC-02): below the platform's + `min_compatible` the run is **refused** with the download link, because a + CLI the platform declared incompatible would fail somewhere later with an + error that says nothing about versions; merely behind `cli.version` while + still compatible is a warning, since the run will work and blocking it + would only train people to ignore the message. + """ + resp = client.request("GET", VERSIONS_PATH) + if resp.status_code != 404: + versions = parse_envelope(resp) + cli_info = (versions or {}).get("cli") or {} + min_compatible = cli_info.get("min_compatible") + download_url = f"{client.base_url}{cli_info.get('download_path') or DEFAULT_CLI_DOWNLOAD_PATH}" + warning = None + local = _version_tuple(__version__) + if isinstance(min_compatible, str) and _version_tuple(min_compatible) > local: + raise CliError( + f"本地 CLI 版本 {__version__} 低于平台要求的最低兼容版本 {min_compatible},无法与该平台交互", + exit_code=EXIT_USAGE, + next_step=f"从当前平台重新下载并安装:curl -OJ {download_url} && pip install ./bisheng_cli-*.whl", + ) + latest = cli_info.get("version") + if isinstance(latest, str) and _version_tuple(latest) > local: + warning = f"平台提供的 CLI 版本是 {latest},本地是 {__version__}(仍兼容);可从 {download_url} 更新。" + if client.emitter is not None: + client.emitter.warn(warning) + return ProbeResult(versions=versions or {}, cli_min_compatible=min_compatible, warning=warning) + + env = client.get_json(ENV_PATH) + if isinstance(env, dict) and "open_platform_enabled" in env: + if not env.get("open_platform_enabled"): + raise CliError( + "本环境未部署开放能力层,CLI 在这里不可用", + exit_code=EXIT_NOT_ENABLED, + next_step="请平台管理员打开 open_platform 开关并重启后端后再试。", + ) + raise CliError( + "平台版本过老,不提供 CLI 分发端点", + exit_code=EXIT_PLATFORM_TOO_OLD, + next_step="请升级平台,或改用与该平台同版本的 CLI。", + ) + raise CliError( + "平台版本过老,不支持本 CLI", + exit_code=EXIT_PLATFORM_TOO_OLD, + next_step="请升级平台,或改用与该平台同版本的 CLI。", + ) + + +def _version_tuple(raw: str) -> tuple[int, ...]: + parts: list[int] = [] + for chunk in str(raw).split("."): + digits = "".join(ch for ch in chunk if ch.isdigit()) + parts.append(int(digits) if digits else 0) + return tuple(parts) diff --git a/src/bisheng-cli/bisheng_cli/ignore.py b/src/bisheng-cli/bisheng_cli/ignore.py new file mode 100644 index 0000000000..2f12464c29 --- /dev/null +++ b/src/bisheng-cli/bisheng_cli/ignore.py @@ -0,0 +1,323 @@ +"""Which files go into the package: three layers, from innermost outward. + +1. **Structural excludes**, independent of any ignore file. + *Hard* ones (`.git/`, `node_modules/`, `.venv/`, caches, `.bisheng/`) cannot be + taken back, because their content is wrong or meaningless on the target + machine no matter what the developer intends. + *Soft* ones (`*.sqlite`, `dist/`, `build/`, local attachment dirs) are excluded + by default but a `!` line in `.bishengignore` gets them back. `dist/` is soft on + purpose: existing python3.11 apps commonly serve a front-end bundle from there, + and hard-excluding it strips the app silently — the failure surfaces at probe + time and sends the investigation after "the platform build". +2. **`.gitignore` semantics.** In a git repo with git on PATH we ask git + (`git ls-files -c -o --exclude-standard -z`): zero dependencies and 100% of the + semantics, for free. Otherwise a hand-written subset parser, and the CLI *says + so* in its output so the developer knows complex rules may need restating. +3. **`.bishengignore`** — same syntax, loaded last, highest priority. The escape + hatch for whatever the subset parser cannot express. + +Why not `fnmatch` for layer 2: its `*` crosses `/` and it has no `**` at all (the +comment in `linsight/.../workspace_backend.py` records both). This repo already +lost time to that once, with a `glob("/uploads/**/*.xlsx")` that matched nothing +for months. Why not `pathspec`: one more air-gapped install link bought only for +projects that are not under git — the case where we are least likely to be right +about the rules anyway. + +Known subset limitation: git refuses to re-include a file underneath an ignored +directory, this parser allows it. That is the escape hatch working as intended, +and it is only reachable from `.bishengignore`. +""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +from dataclasses import dataclass, field +from pathlib import Path, PurePosixPath + +# Content here is either wrong on the target machine (a host virtualenv, a +# platform-specific binary) or meaningless (VCS metadata, caches). +HARD_EXCLUDE_DIRS = frozenset( + { + ".git", + ".hg", + ".svn", + "node_modules", + ".venv", + "venv", + "env", + "__pycache__", + ".pytest_cache", + ".ruff_cache", + ".mypy_cache", + ".bisheng", + } +) +HARD_EXCLUDE_PATTERNS = ("*.pyc", "*.egg-info/", ".DS_Store") + +# Excluded by default, recoverable with a `!` line in .bishengignore. +SOFT_EXCLUDE_PATTERNS = ( + "*.db", + "*.sqlite", + "*.sqlite3", + "dist/", + "build/", + "attachments/", + "uploads/", +) + +SUBSET_NOTICE = ( + "本项目不在 git 仓库内(或本机没有 git),忽略规则按子集解析;" + "复杂规则请写进 .bishengignore(同 .gitignore 语法,最后加载、优先级最高)。" +) + +#: git 回了一个空清单,但目录里确实有文件。这不是"没东西可打包",而是"git 认为 +#: 这里的东西全被忽略了"——两者的下一步完全不同,而后续那个"缺 bisheng-app.yaml" +#: 的报错完全指不到这个成因上。 +ALL_IGNORED_NOTICE = ( + "git 认为这个目录下的文件全部被忽略(多半是上层 .gitignore 命中了整个目录)," + "因此打包清单为空。请检查忽略规则,或在 .bishengignore 里用 ! 显式取回需要的文件。" +) + +GITIGNORE_NAME = ".gitignore" +BISHENGIGNORE_NAME = ".bishengignore" + + +@dataclass +class _Rule: + negate: bool + dir_only: bool + exact: re.Pattern[str] + under: re.Pattern[str] + + def matches(self, path: str, is_dir: bool) -> bool: + if self.under.match(path): + return True + if self.exact.match(path): + return is_dir or not self.dir_only + return False + + +@dataclass +class Ruleset: + """An ordered `.gitignore`-style rule list. Last match wins.""" + + rules: list[_Rule] = field(default_factory=list) + + @classmethod + def parse(cls, text: str) -> Ruleset: + rules = [rule for rule in (_compile(line) for line in text.splitlines()) if rule is not None] + return cls(rules) + + @classmethod + def from_file(cls, path: Path) -> Ruleset: + if not path.is_file(): + return cls([]) + return cls.parse(path.read_text(encoding="utf-8", errors="replace")) + + def decide(self, path: str, is_dir: bool) -> bool | None: + """True = ignored, False = explicitly taken back, None = no opinion.""" + verdict: bool | None = None + for rule in self.rules: + if rule.matches(path, is_dir): + verdict = not rule.negate + return verdict + + +def _compile(line: str) -> _Rule | None: + raw = line.rstrip("\n") + if not raw.strip() or raw.lstrip().startswith("#"): + return None + negate = raw.startswith("!") + if negate: + raw = raw[1:] + raw = raw.strip() + if not raw: + return None + dir_only = raw.endswith("/") + raw = raw.rstrip("/") + if not raw: + return None + anchored = raw.startswith("/") or "/" in raw + raw = raw.lstrip("/") + segments = raw.split("/") + if segments and segments[-1] == "**": + # `foo/**` means "everything under foo", which the `under` regex already + # expresses; keeping the segment would demand a trailing slash that no + # real path has. + segments = segments[:-1] + if not segments: + return None + body = _build_body(segments) + if not anchored: + body = "(?:[^/]+/)*" + body + return _Rule( + negate=negate, + dir_only=dir_only, + exact=re.compile("^" + body + "$"), + under=re.compile("^" + body + "/.*$"), + ) + + +def _build_body(segments: list[str]) -> str: + parts: list[str] = [] + for segment in segments: + if segment == "**": + parts.append("(?:[^/]+/)*") + continue + parts.append(_segment_regex(segment) + "/") + body = "".join(parts) + return body[:-1] if body.endswith("/") else body + + +def _segment_regex(segment: str) -> str: + out: list[str] = [] + index = 0 + while index < len(segment): + ch = segment[index] + if ch == "*": + out.append("[^/]*") + elif ch == "?": + out.append("[^/]") + elif ch == "[": + close = segment.find("]", index + 1) + if close == -1: + out.append(re.escape(ch)) + else: + body = segment[index + 1 : close] + body = body.replace("!", "^", 1) if body.startswith("!") else body + out.append("[" + body + "]") + index = close + else: + out.append(re.escape(ch)) + index += 1 + return "".join(out) + + +@dataclass +class IgnoreResult: + files: list[str] + excluded: list[str] + used_git: bool + notes: list[str] + + @property + def excluded_count(self) -> int: + return len(self.excluded) + + +def collect_files(root: Path, *, use_git: bool | None = None) -> IgnoreResult: + """Decide the package contents for `root`.""" + root = Path(root) + hard = Ruleset.parse("\n".join(HARD_EXCLUDE_PATTERNS)) + soft = Ruleset.parse("\n".join(SOFT_EXCLUDE_PATTERNS)) + bisheng = Ruleset.from_file(root / BISHENGIGNORE_NAME) + notes: list[str] = [] + + candidates, pruned = _walk(root) + + git_files = None + if use_git is not False: + git_files = _git_ls_files(root) + if use_git is True and git_files is None: + git_files = None + used_git = git_files is not None + if not used_git: + gitignore = Ruleset.from_file(root / GITIGNORE_NAME) + notes.append(SUBSET_NOTICE) + else: + gitignore = Ruleset([]) + if not git_files and candidates: + notes.append(ALL_IGNORED_NOTICE) + + included: list[str] = [] + excluded: list[str] = list(pruned) + + for rel, is_dir in candidates: + if hard.decide(rel, is_dir) is True: + excluded.append(rel) + continue + if used_git: + keep = rel in git_files # type: ignore[operator] + else: + keep = gitignore.decide(rel, is_dir) is not True + if keep and soft.decide(rel, is_dir) is True: + keep = False + override = bisheng.decide(rel, is_dir) + if override is True: + keep = False + elif override is False: + keep = True + (included if keep else excluded).append(rel) + + return IgnoreResult(files=sorted(included), excluded=sorted(excluded), used_git=used_git, notes=notes) + + +def _walk(root: Path) -> tuple[list[tuple[str, bool]], list[str]]: + """List candidate entries, pruning hard-excluded directories as we go. + + Pruned directories are returned as a single entry each rather than expanded: + walking a 400 MB `node_modules` only to throw every path away is the slowest + possible way to reach the same answer. + """ + candidates: list[tuple[str, bool]] = [] + pruned: list[str] = [] + for dirpath, dirnames, filenames in os.walk(root, followlinks=False): + rel_dir = PurePosixPath(Path(dirpath).relative_to(root).as_posix()) + keep_dirs = [] + for name in sorted(dirnames): + rel = _join(rel_dir, name) + if name in HARD_EXCLUDE_DIRS: + pruned.append(rel + "/") + continue + if Path(dirpath, name).is_symlink(): + # Never descend through a link; record it so packaging can list + # it as skipped instead of silently losing it. + candidates.append((rel, True)) + continue + keep_dirs.append(name) + dirnames[:] = keep_dirs + for name in sorted(filenames): + candidates.append((_join(rel_dir, name), False)) + return candidates, pruned + + +def _join(rel_dir: PurePosixPath, name: str) -> str: + text = str(rel_dir) + return name if text == "." else f"{text}/{name}" + + +def _git_ls_files(root: Path) -> set[str] | None: + """Ask git for the file list; None means "git could not answer". + + Deliberately **not** gated on ``root/".git"`` existing. An application + directory sitting inside a larger repository — a monorepo app, an + ``examples/`` folder, anything that is not itself the repo root — has no + ``.git`` of its own, and that is the common case, not the exotic one. + Requiring one there produced two wrong outcomes at once: the report claimed + "not a git repository" about a directory that plainly is in one, and the + fallback parser then read only ``root/.gitignore`` while the repo's real + ignore rules live at the repo root — so files git ignores (local data, + generated output, an ignored config holding real credentials) got packed + and uploaded. Let git answer instead: it walks up to find the repository, + applies every applicable ignore file, and prints paths relative to ``cwd``, + which is exactly the shape ``_walk`` produces. Outside a repository it + exits non-zero and we fall back as before. + """ + if shutil.which("git") is None: + return None + try: + result = subprocess.run( + ["git", "ls-files", "-c", "-o", "--exclude-standard", "-z"], + cwd=root, + capture_output=True, + check=False, + ) + except OSError: + return None + if result.returncode != 0: + return None + raw = result.stdout.decode("utf-8", errors="replace") + return {entry for entry in raw.split("\0") if entry} diff --git a/src/bisheng-cli/bisheng_cli/main.py b/src/bisheng-cli/bisheng_cli/main.py new file mode 100644 index 0000000000..2dae7c9895 --- /dev/null +++ b/src/bisheng-cli/bisheng_cli/main.py @@ -0,0 +1,94 @@ +"""Process entry point: stream hardening, dispatch, and the exit-code funnel. + +Every path out of this function emits exactly one `result` event — success, +handled failure, and crash alike. An agent that reads only the last stdout line +can then always reach a verdict; without that invariant a crash would leave it +parsing an empty stream and guessing. +""" + +from __future__ import annotations + +import importlib +import sys +import traceback +from collections.abc import Callable +from typing import Any, TextIO + +from bisheng_cli.cli import build_parser +from bisheng_cli.errors import EXIT_INTERNAL, EXIT_OK, EXIT_USAGE, CliError, render_human +from bisheng_cli.output import Emitter, wrap_stream + +Handler = Callable[[Any, Emitter], int] + +# Resolved lazily so that importing `main` never drags in every command module — +# and so a command that is still being written cannot break `--help`. +_HANDLER_PATHS = { + "init": "bisheng_cli.project:run_init", + "login": "bisheng_cli.commands.login:run", + "deploy": "bisheng_cli.commands.deploy:run", + "logs": "bisheng_cli.commands.logs:run", + "skills": "bisheng_cli.commands.skills:run", + "dev": "bisheng_cli.commands.dev:run", + "platforms": "bisheng_cli.commands.platforms:run", +} + + +def resolve_handler(name: str, overrides: dict[str, Handler] | None = None) -> Handler: + if overrides and name in overrides: + return overrides[name] + module_name, _, attr = _HANDLER_PATHS[name].partition(":") + module = importlib.import_module(module_name) + return getattr(module, attr) + + +def run( + argv: list[str] | None = None, + *, + handlers: dict[str, Handler] | None = None, + stdout: TextIO | None = None, + stderr: TextIO | None = None, +) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + emitter = Emitter( + json_mode=args.json_mode, + quiet=args.quiet, + verbose=args.verbose, + stdout=stdout, + stderr=stderr, + ) + command = args.command or "" + + if not command: + emitter.error(parser.format_usage().strip()) + emitter.error("错误: 缺少子命令。可用: login / deploy / logs / skills sync / dev / platforms") + emitter.result(command, ok=False, exit_code=EXIT_USAGE) + return EXIT_USAGE + + try: + handler = resolve_handler(command, handlers) + exit_code = handler(args, emitter) + emitter.result(command, ok=exit_code == EXIT_OK, exit_code=exit_code) + return exit_code + except CliError as exc: + emitter.error(render_human(exc)) + emitter.result(command, ok=False, exit_code=exc.exit_code, failure=exc.as_failure()) + return exc.exit_code + except Exception as exc: + emitter.error(f"CLI 内部异常: {exc.__class__.__name__}: {exc}") + emitter.error("下一步: 加 --verbose 重跑并把输出报给支持。") + if args.verbose: + emitter.error(traceback.format_exc()) + emitter.result(command, ok=False, exit_code=EXIT_INTERNAL) + return EXIT_INTERNAL + + +def main(argv: list[str] | None = None) -> None: + """Console-script entry point (`[project.scripts] bisheng`).""" + # First thing, before any Chinese copy can reach a GBK console: a + # UnicodeEncodeError here would replace the actual error message with a + # traceback about encoding. + sys.stdout = wrap_stream(sys.stdout) + sys.stderr = wrap_stream(sys.stderr) + raise SystemExit(run(argv)) diff --git a/src/bisheng-cli/bisheng_cli/output.py b/src/bisheng-cli/bisheng_cli/output.py new file mode 100644 index 0000000000..b0e86c2720 --- /dev/null +++ b/src/bisheng-cli/bisheng_cli/output.py @@ -0,0 +1,290 @@ +"""Dual-shape output: machine-readable NDJSON on stdout, human text on stderr. + +Why the split (design D2): a local coding agent is the primary caller, and the +only way it can do `bisheng deploy --wait --json | jq -c 'select(.event=="stage")'` +is if *every* line on stdout parses as JSON. Interleaving progress text there +blows up the pipeline on its first line. Human text therefore always goes to +stderr — in `--json` mode and outside it — so a person watching the terminal still +sees the run while the pipe stays clean. + +The masker lives here because every exit from the process funnels through this +module; a masker attached to any single call site is one that some other call +site will forget. +""" + +from __future__ import annotations + +import io +import json +import re +import sys +import time +from typing import Any, TextIO + +# Server-side `stage` values are the machine-readable truth (design D6); this +# table is *display only* and deliberately unordered. Two consequences that are +# easy to get wrong: +# - an unknown stage is printed verbatim, never an error — the server is free +# to add stages without breaking the CLI; +# - the dict order says nothing about execution order. The server reordered +# secret_scan relative to precheck_* once already; a CLI that asserts an +# order turns the next reordering into a client-side bug. +STAGE_LABELS: dict[str, str] = { + "received": "已接收", + "secret_scan": "安全扫描", + "precheck_manifest": "应用声明校验", + "precheck_schema": "结构变更预检", + "precheck_build": "依赖构建", + "precheck_probe": "启动探活", + "version_recorded": "版本已记录", + "approval_created": "审批单已生成", + "approved": "审批通过", + "publishing": "发布中", + "online": "已上线", + "pending_online": "待上线", +} + +_MASK_PATTERNS = ( + # Both credential prefixes the platform authenticates on this wire: + # `bs-sak-` service-account keys and `bs-pat-` personal access tokens + # (server-side `credential_validator._TOKEN_RE` accepts either). Matching + # only the first one left a pasted personal token printed in full — the CLI + # asks for a service-account key but cannot stop anyone typing the other. + # The tail is greedy on purpose: a partially matched key is still a leaked + # key. The prefix is kept so the reader can tell which kind leaked. + (re.compile(r"(bs-(?:sak|pat)-)[A-Za-z0-9_\-]+"), r"\1****"), + (re.compile(r"(?i)(authorization\W{0,4}bearer)\s+\S+"), r"\1 ****"), + (re.compile(r"(?i)\bbearer\s+[A-Za-z0-9_\-\.]{8,}"), "Bearer ****"), +) + +_MILESTONES = (25, 50, 75, 100) + + +def mask(text: str) -> str: + """Redact key material from any string on its way out of the process.""" + for pattern, replacement in _MASK_PATTERNS: + text = pattern.sub(replacement, text) + return text + + +def wrap_stream(stream: TextIO) -> TextIO: + """Make `stream` survive non-UTF-8 consoles. + + A Windows console defaults to GBK. Printing a Chinese hint that happens to + contain one character outside that codepage raises UnicodeEncodeError, and + the user sees a Python traceback instead of "缺 app:manage 位" — the crash + hides the very message it was crashing on. + """ + try: + stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined] + return stream + except (AttributeError, ValueError, io.UnsupportedOperation): + pass + buffer = getattr(stream, "buffer", None) + if buffer is None: + return stream + return io.TextIOWrapper(buffer, encoding="utf-8", errors="replace", line_buffering=True) + + +def stage_label(stage: str) -> str: + """Display phrase for a server stage; unknown stages pass through verbatim.""" + return STAGE_LABELS.get(stage, stage) + + +class Emitter: + """Single exit point for everything the CLI says. + + Invariant relied upon by callers: exactly one `result` event per command run, + and it is the last line on stdout. An agent that reads only the final line + can therefore always reach a verdict, including on the crash path. + """ + + def __init__( + self, + *, + json_mode: bool = False, + quiet: bool = False, + verbose: bool = False, + is_tty: bool | None = None, + stdout: TextIO | None = None, + stderr: TextIO | None = None, + ) -> None: + self.json_mode = json_mode + self.quiet = quiet + self.verbose = verbose + self._stdout = stdout if stdout is not None else sys.stdout + self._stderr = stderr if stderr is not None else sys.stderr + if is_tty is None: + is_tty = bool(getattr(self._stderr, "isatty", lambda: False)()) + self.is_tty = is_tty + self._result_emitted = False + self._last_milestone: dict[str, int] = {} + + # ---- machine events ------------------------------------------------- + + def _emit(self, payload: dict[str, Any]) -> None: + if not self.json_mode: + return + line = json.dumps(payload, ensure_ascii=False, sort_keys=False, default=str) + self._stdout.write(mask(line) + "\n") + self._stdout.flush() + + def stage( + self, + command: str, + stage: str, + status: str, + *, + failure: dict[str, Any] | None = None, + **extra: Any, + ) -> None: + payload: dict[str, Any] = { + "event": "stage", + "command": command, + "stage": stage, + "status": status, + "ts": _now(), + } + if failure is not None: + payload["failure"] = failure + payload.update(extra) + self._emit(payload) + label = stage_label(stage) + self.info(f"[{status}] {label}") + + def progress(self, command: str, phase: str, sent_bytes: int, total_bytes: int) -> None: + percent = int(sent_bytes * 100 / total_bytes) if total_bytes else 100 + if not self._should_report(phase, percent): + return + self._emit( + { + "event": "progress", + "command": command, + "phase": phase, + "sent_bytes": sent_bytes, + "total_bytes": total_bytes, + } + ) + self.info(f"已上传 {_mb(sent_bytes)} / {_mb(total_bytes)} ({percent}%)") + + def result( + self, + command: str, + ok: bool, + exit_code: int, + *, + data: dict[str, Any] | None = None, + failure: dict[str, Any] | None = None, + ) -> None: + if self._result_emitted: + return + self._result_emitted = True + self._emit( + { + "event": "result", + "command": command, + "ok": ok, + "exit_code": exit_code, + "data": data or {}, + "failure": failure, + } + ) + + @property + def result_emitted(self) -> bool: + return self._result_emitted + + # ---- human text ----------------------------------------------------- + + def info(self, text: str) -> None: + if self.quiet: + return + self._write_human(text) + + def warn(self, text: str) -> None: + self._write_human(f"警告: {text}") + + def error(self, text: str) -> None: + self._write_human(text) + + def debug(self, text: str) -> None: + if not self.verbose: + return + self._write_human(f"[debug] {text}") + + def _write_human(self, text: str) -> None: + self._stderr.write(mask(text) + "\n") + self._stderr.flush() + + # ---- helpers -------------------------------------------------------- + + def _should_report(self, phase: str, percent: int) -> bool: + """Throttle progress on non-TTY callers. + + A pipe has no cursor to rewrite, so a per-chunk progress line becomes + thousands of lines in the agent's transcript. Milestones keep the signal + without the flood. + """ + if self.is_tty: + return True + last = self._last_milestone.get(phase, -1) + reached = max((m for m in _MILESTONES if percent >= m), default=None) + if reached is None or reached <= last: + return False + self._last_milestone[phase] = reached + return True + + +def format_scan_hits(hits: list[dict[str, Any]]) -> str: + """Render secret-scan hits as `file:line` plus the rule name — never a value. + + The server withholds even a redacted value on purpose. Reading the offending + line back out of the local file to "complete" the report would hand back + exactly what the platform refused to hand over. + """ + lines = [] + for hit in hits: + location = f"{hit.get('file', '?')}:{hit.get('line', '?')}" + rule = hit.get("rule_id") or hit.get("name_i18n_key") or "unknown-rule" + lines.append(f" {location} 规则 {rule}") + return "\n".join(lines) + + +# `items[].op` of the platform's structure-change summary (F055 design §4.2 ②). +# Display only, like STAGE_LABELS: an unknown op is printed verbatim. +SCHEMA_OP_LABELS: dict[str, str] = { + "add_table": "加表", + "drop_table": "删表", + "add_column": "加列", + "drop_column": "删列", + "modify_column": "改列", +} +SCHEMA_BREAKING_OPS = frozenset({"drop_table", "drop_column", "modify_column"}) + + +def format_schema_change(items: list[dict[str, Any]]) -> str: + """Render the structure-change items the platform refused with 16229. + + One line per item, breaking ones flagged, so the person at the prompt (or + the agent reading the transcript) sees exactly what `--confirm-schema-change` + would be confirming. Additive items are listed too: the confirmation covers + the release, and hiding half the diff is how someone confirms the wrong + thing. + """ + lines = [] + for item in items: + op = str(item.get("op") or "?") + table = item.get("table") or "?" + column = item.get("column") + target = f"表 {table}" + (f" 列 {column}" if column else "") + flag = " ← 破坏性,需确认" if op in SCHEMA_BREAKING_OPS else "" + lines.append(f" {SCHEMA_OP_LABELS.get(op, op)} {target}{flag}") + return "\n".join(lines) + + +def _now() -> str: + return time.strftime("%Y-%m-%dT%H:%M:%S%z") + + +def _mb(size: int) -> str: + return f"{size / (1024 * 1024):.1f} MB" diff --git a/src/bisheng-cli/bisheng_cli/packaging.py b/src/bisheng-cli/bisheng_cli/packaging.py new file mode 100644 index 0000000000..25620de495 --- /dev/null +++ b/src/bisheng-cli/bisheng_cli/packaging.py @@ -0,0 +1,276 @@ +"""Build the version snapshot: a reproducible tar.gz, or a refusal with a report. + +Four rules, each the dual of a real failure. + +**Never truncate silently.** Over the limit means the whole package is refused +and the offenders are listed. A package quietly trimmed to fit means what runs in +production is not what the developer has locally, and nothing anywhere says so. + +**Skip links and special files locally, and say which.** The server's unpack gate +rejects symlinks, hardlinks, device nodes and FIFOs. Not skipping them here buys +a 16202 *after* the upload; skipping them without listing them buys a missing file +nobody can account for. + +**Reproducible bytes.** Members sorted, mtime zeroed, uid/gid/uname/gname cleared, +gzip header stripped of its timestamp. Same content, same sha256 — which is what +makes "the package I built is the package the server received" checkable at all. + +**Keep the owner execute bit.** Normalising every mode to 0644 leaves the +entrypoint script non-executable, and that failure only appears at build or probe +time, pointing the investigation at the platform instead of at the tarball. + +tar.gz, not zip: F055's endpoint parameter is literally `package` and its object +key ends in `code.tar.gz`. Producing a zip would be wrong against the wording, +the object key, and any future chunked-upload scheme at once, in exchange for +nothing. +""" + +from __future__ import annotations + +import gzip +import hashlib +import stat as stat_module +import tarfile +from dataclasses import dataclass, field +from pathlib import Path, PurePosixPath +from typing import Any + +from bisheng_cli.errors import EXIT_LOCAL_INVALID, CliError +from bisheng_cli.ignore import IgnoreResult + +LIMITS_PATH = "/api/v2/apps/deploy-limits" +TOP_N = 10 + + +@dataclass +class Limits: + """Package ceilings. The authoritative values live in platform config.""" + + max_package_mb: int + max_unpacked_mb: int + max_package_entries: int + degraded: bool = False + release_notes_version: int | None = None + release_notes_max_codepoints: int | None = None + + +# Fallback only — used when the endpoint cannot answer. Never a hard constant: +# raising the ceiling is an ops change, not a CLI release. +DEFAULT_LIMITS = Limits(max_package_mb=50, max_unpacked_mb=200, max_package_entries=20000, degraded=True) + + +@dataclass +class SkippedEntry: + path: str + kind: str + + +@dataclass +class PackageStat: + path: Path + entry_count: int + raw_bytes: int + compressed_bytes: int + sha256: str + excluded_count: int + skipped: list[SkippedEntry] = field(default_factory=list) + top_files: list[tuple[str, int]] = field(default_factory=list) + top_dirs: list[tuple[str, int]] = field(default_factory=list) + notes: list[str] = field(default_factory=list) + + +def fetch_limits(client: Any, *, require_release_notes: bool = False) -> Limits: + """Fetch ceilings, with strict capability checks when notes are present. + + Any failure here — endpoint not deployed yet, a flaky hop, the runtime layer + switched off — must not stop a plain deploy. The upload proceeds and the server's + 16201 remains the authority. A soft self-check that can kill the main flow is + worse than no self-check. Release-note submissions propagate query failures + instead: an old server may silently ignore their optional multipart field. + """ + try: + data = client.get_json(LIMITS_PATH) + except Exception: + if require_release_notes: + raise + return Limits(**{**_limits_kwargs(DEFAULT_LIMITS), "degraded": True}) + if not isinstance(data, dict): + return Limits(**{**_limits_kwargs(DEFAULT_LIMITS), "degraded": True}) + features = data.get("features") + notes = features.get("release_notes") if isinstance(features, dict) else None + notes = notes if isinstance(notes, dict) else {} + version = notes.get("version") + max_codepoints = notes.get("max_codepoints") + return Limits( + max_package_mb=int(data.get("max_package_mb", DEFAULT_LIMITS.max_package_mb)), + max_unpacked_mb=int(data.get("max_unpacked_mb", DEFAULT_LIMITS.max_unpacked_mb)), + max_package_entries=int(data.get("max_package_entries", DEFAULT_LIMITS.max_package_entries)), + degraded=False, + release_notes_version=version if type(version) is int else None, + release_notes_max_codepoints=max_codepoints if type(max_codepoints) is int else None, + ) + + +def _limits_kwargs(limits: Limits) -> dict[str, Any]: + return { + "max_package_mb": limits.max_package_mb, + "max_unpacked_mb": limits.max_unpacked_mb, + "max_package_entries": limits.max_package_entries, + } + + +def build_package(root: Path, ignore_result: IgnoreResult, out_path: Path) -> PackageStat: + root = Path(root) + out_path = Path(out_path) + out_path.parent.mkdir(parents=True, exist_ok=True) + + skipped: list[SkippedEntry] = [] + sizes: list[tuple[str, int]] = [] + + with open(out_path, "wb") as raw: + # mtime=0 and an empty filename keep the gzip header free of anything + # that changes between two runs of the same content. + with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as gz: + with tarfile.open(fileobj=gz, mode="w", format=tarfile.PAX_FORMAT) as tar: + for rel in sorted(ignore_result.files): + source = root / rel + kind = _special_kind(source) + if kind is not None: + skipped.append(SkippedEntry(path=rel, kind=kind)) + continue + info = _tar_info(rel, source) + sizes.append((rel, info.size)) + with open(source, "rb") as fh: + tar.addfile(info, fh) + + compressed = out_path.stat().st_size + digest = _sha256(out_path) + sizes.sort(key=lambda item: item[1], reverse=True) + return PackageStat( + path=out_path, + entry_count=len(sizes), + raw_bytes=sum(size for _, size in sizes), + compressed_bytes=compressed, + sha256=digest, + excluded_count=ignore_result.excluded_count, + skipped=skipped, + top_files=sizes[:TOP_N], + top_dirs=_top_dirs(sizes), + notes=list(ignore_result.notes), + ) + + +def _special_kind(source: Path) -> str | None: + """Classify anything the server's unpack gate would reject.""" + try: + st = source.lstat() + except OSError: + return "unreadable" + mode = st.st_mode + if stat_module.S_ISLNK(mode): + return "symlink" + if stat_module.S_ISFIFO(mode): + return "fifo" + if stat_module.S_ISCHR(mode) or stat_module.S_ISBLK(mode): + return "device" + if stat_module.S_ISSOCK(mode): + return "socket" + # A hardlinked regular file is packed like any other file, content and all. + # Hardlink *members* only appear when tarfile picks the type itself, via + # ``TarFile.add``/``gettarinfo`` and its inode table; ``_tar_info`` builds a + # plain REGTYPE header by hand and streams the bytes, so no such member can + # be produced here. Skipping ``st_nlink > 1`` would drop the contents of an + # ordinary file for a reason that does not apply to this writer — and + # ``st_nlink > 1`` is not a property the author chose or can see. + if not stat_module.S_ISREG(mode): + return "special" + return None + + +def _tar_info(rel: str, source: Path) -> tarfile.TarInfo: + arcname = str(PurePosixPath(*Path(rel).parts)) + _assert_safe(arcname) + st = source.stat() + info = tarfile.TarInfo(arcname) + info.size = st.st_size + info.mtime = 0 + info.uid = 0 + info.gid = 0 + info.uname = "" + info.gname = "" + info.type = tarfile.REGTYPE + info.mode = 0o755 if st.st_mode & stat_module.S_IXUSR else 0o644 + return info + + +def _assert_safe(arcname: str) -> None: + if "\\" in arcname or arcname.startswith("/") or arcname.startswith("./") or ".." in arcname.split("/"): + raise CliError( + f"包内路径非法:{arcname}", + exit_code=EXIT_LOCAL_INVALID, + next_step="这通常意味着项目里有异常文件名,请重命名后重试;这是 CLI 的最后一道防线,请一并报障。", + ) + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with open(path, "rb") as fh: + for chunk in iter(lambda: fh.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _top_dirs(sizes: list[tuple[str, int]]) -> list[tuple[str, int]]: + totals: dict[str, int] = {} + for rel, size in sizes: + top = rel.split("/")[0] if "/" in rel else "(项目根)" + totals[top] = totals.get(top, 0) + size + return sorted(totals.items(), key=lambda item: item[1], reverse=True)[:TOP_N] + + +def check_limits(stat: PackageStat, limits: Limits) -> None: + """Refuse the whole package when it does not fit. Never trim.""" + reasons = [] + if stat.compressed_bytes > limits.max_package_mb * 1024 * 1024: + reasons.append(f"压缩后 {_mb(stat.compressed_bytes)} 超过上限 {limits.max_package_mb} MB") + if stat.raw_bytes > limits.max_unpacked_mb * 1024 * 1024: + reasons.append(f"解包后 {_mb(stat.raw_bytes)} 超过上限 {limits.max_unpacked_mb} MB") + if stat.entry_count > limits.max_package_entries: + reasons.append(f"条目数 {stat.entry_count} 超过上限 {limits.max_package_entries}") + if not reasons: + return + raise CliError( + "上传包超过平台上限,整包拒绝:" + ";".join(reasons), + exit_code=EXIT_LOCAL_INVALID, + next_step="按下面的清单清理后重试(或请运维调整 app_runtime 的包体上限)。\n" + + format_size_report(stat, limits), + ) + + +def format_size_report(stat: PackageStat, limits: Limits) -> str: + """The excluded count comes first, on purpose. + + The first reaction to "package too large" is "the platform limit is too + small". Nine times out of ten the real answer is an un-ignored `.venv/`, so + the report opens by saying how much was already dropped before it starts + naming files. + """ + lines = [ + f"已排除 {stat.excluded_count} 项(版本控制元数据 / 依赖目录 / 缓存 / 本地数据文件等)", + f"打包条目 {stat.entry_count} 个,解包后 {_mb(stat.raw_bytes)},压缩后 {_mb(stat.compressed_bytes)}" + f"(上限:{limits.max_package_mb} MB / 解包 {limits.max_unpacked_mb} MB / {limits.max_package_entries} 条目)", + ] + for note in stat.notes: + lines.append(f"说明: {note}") + if stat.skipped: + lines.append(f"已跳过 {len(stat.skipped)} 个不可打包条目(平台解包闸会拒绝它们):") + lines.extend(f" {entry.path} [{entry.kind}]" for entry in stat.skipped) + lines.append("Top 目录:") + lines.extend(f" {name} {_mb(size)}" for name, size in stat.top_dirs) + lines.append("Top 文件:") + lines.extend(f" {name} {_mb(size)}" for name, size in stat.top_files) + return "\n".join(lines) + + +def _mb(size: int) -> str: + return f"{size / (1024 * 1024):.2f} MB" diff --git a/src/bisheng-cli/bisheng_cli/project.py b/src/bisheng-cli/bisheng_cli/project.py new file mode 100644 index 0000000000..dc6232045c --- /dev/null +++ b/src/bisheng-cli/bisheng_cli/project.py @@ -0,0 +1,228 @@ +"""Project layer: locate the root, fast-fail on the manifest, remember the app id. + +Two boundaries this module refuses to cross. + +**It does not validate the manifest.** `bisheng-app.yaml` is owned by F055 and its +model is `extra='forbid'`. Checking "exists / parses / has name, runtime, port" +catches the failure that is worth catching locally (a wasted upload) without +creating a second, drifting copy of the schema. Adding a default value here is +how "it passed locally but the platform rejected it" gets manufactured. + +**It never rewrites an existing manifest.** The explicit init command only +creates a new bundle scaffold; validation remains platform-owned. The app identity lives beside it in +`.bisheng/app.json` instead — a file the CLI owns end to end, which is committed +to git (so a team shares one app) yet structurally excluded from the upload +package (so it never reaches the platform). Those two facts are independent. +""" + +from __future__ import annotations + +import json +import time +from pathlib import Path +from typing import Any + +import yaml + +from bisheng_cli.credentials import normalise_base_url +from bisheng_cli.errors import EXIT_LOCAL_INVALID, EXIT_USAGE, CliError + +MANIFEST_NAME = "bisheng-app.yaml" +STATE_DIR = ".bisheng" +APP_REF_NAME = "app.json" +APP_REF_VERSION = 1 +REQUIRED_FIELDS = ("name", "runtime", "port") + +# Re-exported so callers (and the test that asserts identity) reach the single +# implementation rather than a second one that happens to agree today. +__all__ = [ + "MANIFEST_NAME", + "REQUIRED_FIELDS", + "find_project_root", + "load_manifest", + "normalise_base_url", + "read_app_ref", + "require_app_id", + "resolve_app_id", + "save_app_ref", +] + + +def find_project_root(path: str | Path) -> Path: + root = Path(path).expanduser() + if not root.exists(): + raise CliError( + f"路径不存在:{root}", + exit_code=EXIT_LOCAL_INVALID, + next_step="检查 deploy 的 PATH 参数。", + ) + if not root.is_dir(): + raise CliError( + f"项目根必须是目录:{root}", + exit_code=EXIT_LOCAL_INVALID, + next_step="把 PATH 指向项目根目录,而不是其中的某个文件。", + ) + return root.resolve() + + +def load_manifest(root: Path) -> dict[str, Any]: + """Existence + parseability + three required fields. Nothing else.""" + path = Path(root) / MANIFEST_NAME + if not path.is_file(): + raise CliError( + f"项目根缺少 {MANIFEST_NAME}", + exit_code=EXIT_LOCAL_INVALID, + next_step=f"在 {root} 下创建 {MANIFEST_NAME},至少写明 name / runtime / port。", + ) + try: + # safe_load only. full_load / unsafe_load honour `!!python/object`, which + # makes reading a manifest equivalent to running it. + data = yaml.safe_load(path.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + raise CliError( + f"{MANIFEST_NAME} 无法解析:{exc}", + exit_code=EXIT_LOCAL_INVALID, + next_step="按 YAML 语法修正该文件后重试;错误信息里的 line / column 指向出错位置。", + ) + if not isinstance(data, dict): + raise CliError( + f"{MANIFEST_NAME} 的顶层必须是键值映射", + exit_code=EXIT_LOCAL_INVALID, + next_step="参考「部署纳管」文档的最小示例重写该文件。", + ) + missing = [field for field in REQUIRED_FIELDS if data.get(field) is None] + if missing: + raise CliError( + f"{MANIFEST_NAME} 缺少必填项:{', '.join(missing)}", + exit_code=EXIT_LOCAL_INVALID, + next_step=f"补齐 {', '.join(missing)} 后重试;其余字段的合法性由平台托管预检判定。", + ) + return data + + +# ---- .bisheng/app.json -------------------------------------------------- + + +def _app_ref_path(root: Path) -> Path: + return Path(root) / STATE_DIR / APP_REF_NAME + + +def _read_store(root: Path) -> dict[str, Any]: + path = _app_ref_path(root) + if not path.is_file(): + return {"version": APP_REF_VERSION, "apps": {}} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (ValueError, OSError) as exc: + raise CliError( + f"{path} 无法解析:{exc}", + exit_code=EXIT_LOCAL_INVALID, + next_step=f"删除 {path} 后用 --app-id 显式指定目标应用重发。", + ) + if not isinstance(data, dict) or "apps" not in data: + return {"version": APP_REF_VERSION, "apps": {}} + return data + + +def save_app_ref( + root: Path, + base_url: str, + *, + app_id: str, + app_name: str | None = None, + slug: str | None = None, + last_deployment_id: str | None = None, +) -> None: + key = normalise_base_url(base_url) + store = _read_store(root) + entry = store.setdefault("apps", {}).get(key, {}) + entry.update( + { + "app_id": app_id, + "app_name": app_name if app_name is not None else entry.get("app_name"), + "slug": slug if slug is not None else entry.get("slug"), + "last_deployment_id": ( + last_deployment_id if last_deployment_id is not None else entry.get("last_deployment_id") + ), + "updated_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), + } + ) + store["version"] = APP_REF_VERSION + store["apps"][key] = entry + path = _app_ref_path(root) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(store, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def read_app_ref(root: Path, base_url: str) -> dict[str, Any] | None: + return _read_store(root).get("apps", {}).get(normalise_base_url(base_url)) + + +def resolve_app_id(root: Path, base_url: str, explicit: str | None) -> str | None: + """Explicit flag wins; otherwise the saved id; otherwise None (first deploy).""" + if explicit: + return explicit + entry = read_app_ref(root, base_url) + return entry.get("app_id") if entry else None + + +def require_app_id(root: Path, base_url: str, explicit: str | None) -> str: + """Same, but for commands that cannot invent a target (e.g. `logs`).""" + app_id = resolve_app_id(root, base_url, explicit) + if app_id: + return app_id + raise CliError( + "无法确定目标应用", + exit_code=EXIT_USAGE, + next_step=f"用 --app-id 显式指定,或在曾经 deploy 过的项目目录下执行(标识存放在 {STATE_DIR}/{APP_REF_NAME})。", + ) + + +def initialize_project(path: Path, runtime: str = "python3.11") -> dict[str, Any]: + """Create a new scaffold atomically with respect to pre-existing file names.""" + root = Path(path).expanduser().resolve() + if runtime not in {"python3.11", "node20", "static"}: + raise CliError("Unsupported scaffold runtime", exit_code=EXIT_LOCAL_INVALID) + manifest = {"manifest_version": 2, "name": root.name or "application", "runtime": runtime, "port": 8080} + if runtime == "python3.11": + command = ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"] + files = { + "main.py": 'from fastapi import FastAPI\n\napp = FastAPI()\n\n@app.get("/")\ndef home():\n return {"status": "ok"}\n', + "requirements.txt": "fastapi\nuvicorn\nbisheng-sdk==0.1.1\n", + } + elif runtime == "node20": + command = ["node", "server.js"] + package = {"name": "bisheng-app", "version": "1.0.0", "private": True, "scripts": {"start": "node server.js"}} + lock = { + "name": "bisheng-app", + "version": "1.0.0", + "lockfileVersion": 3, + "packages": {"": {"name": "bisheng-app", "version": "1.0.0"}}, + } + files = { + "package.json": json.dumps(package, indent=2) + "\n", + "package-lock.json": json.dumps(lock, indent=2) + "\n", + "server.js": 'require("http").createServer((req,res)=>{res.end("Application ready");}).listen(Number(process.env.PORT||8080),"0.0.0.0");\n', + } + else: + command = ["nginx", "-g", "daemon off;"] + files = { + "index.html": 'ApplicationApplication ready\n' + } + manifest["delivery"] = {"mode": "bundle", "startup": {"command": command}} + files[MANIFEST_NAME] = yaml.safe_dump(manifest, sort_keys=False, allow_unicode=True) + conflicts = [name for name in files if (root / name).exists()] + if conflicts: + raise CliError("Project files already exist: " + ", ".join(conflicts), exit_code=EXIT_LOCAL_INVALID) + root.mkdir(parents=True, exist_ok=True) + for name, content in files.items(): + with (root / name).open("x", encoding="utf-8") as output: + output.write(content) + return {"path": str(root), "runtime": runtime, "manifest_version": 2, "delivery_mode": "bundle"} + + +def run_init(args, emitter) -> int: + result = initialize_project(Path(args.path), args.runtime) + emitter.info("Created application bundle project: " + result["path"]) + emitter.result("init", ok=True, exit_code=0, data=result) + return 0 diff --git a/src/bisheng-cli/bisheng_cli/release_notes.py b/src/bisheng-cli/bisheng_cli/release_notes.py new file mode 100644 index 0000000000..f639d3a5d7 --- /dev/null +++ b/src/bisheng-cli/bisheng_cli/release_notes.py @@ -0,0 +1,70 @@ +"""Release-note input for a single deployment; independent of the backend package.""" + +import locale +import os +from pathlib import Path + +from bisheng_cli.errors import EXIT_LOCAL_INVALID, CliError + +MAX_CODEPOINTS = 2000 +CONTRACT_VERSION = 1 + +_MESSAGES = { + "en": { + "text_help": "Release notes for this version (up to 2,000 characters)", + "file_help": "Read release notes from a UTF-8 text file", + "too_long": "Release notes must not exceed {limit} Unicode characters", + "file_error": "Cannot read release notes as a regular UTF-8 text file: {path}", + "unsupported": "The platform does not declare support for these release notes; nothing was uploaded", + "next_step": "Upgrade the platform, or explicitly remove the release notes and retry.", + "fix_input": "Shorten the release notes to 2,000 characters and retry.", + }, + "zh": { + "text_help": "本次版本的发布说明(最多 2,000 个字符)", + "file_help": "从 UTF-8 文本文件读取本次发布说明", + "too_long": "发布说明不能超过 {limit} 个 Unicode 字符", + "file_error": "无法将发布说明文件作为普通 UTF-8 文本读取:{path}", + "unsupported": "平台未声明支持本次发布说明,未上传应用包", + "next_step": "请升级平台,或明确移除发布说明后重新提交。", + "fix_input": "请将发布说明缩短至 2,000 个字符后重新提交。", + }, + "ja": { + "text_help": "このバージョンのリリースノート(最大2,000文字)", + "file_help": "UTF-8テキストファイルからリリースノートを読み込む", + "too_long": "リリースノートはUnicode {limit}文字以内で入力してください", + "file_error": "通常のUTF-8テキストファイルとして読み込めません:{path}", + "unsupported": "このリリースノートはプラットフォームでサポートされていません。アップロードしていません", + "next_step": "プラットフォームを更新するか、リリースノートを明示的に削除して再実行してください。", + "fix_input": "リリースノートを2,000文字以内にして再実行してください。", + }, +} + + +def message(key: str, **values: object) -> str: + current = os.environ.get("LC_ALL") or os.environ.get("LC_MESSAGES") or os.environ.get("LANG") + current = current or locale.getlocale()[0] or "en" + language = current.lower().split("_")[0].split("-")[0] + return _MESSAGES.get(language, _MESSAGES["en"])[key].format(**values) + + +def normalize_release_notes(value: str | None) -> str | None: + if value is None: + return None + normalized = value.replace("\r\n", "\n").replace("\r", "\n").strip() + if len(normalized) > MAX_CODEPOINTS: + raise CliError( + message("too_long", limit=MAX_CODEPOINTS), exit_code=EXIT_LOCAL_INVALID, next_step=message("fix_input") + ) + return normalized or None + + +def load_release_notes(text: str | None = None, file_path: str | None = None) -> str | None: + if file_path is not None: + path = Path(file_path) + try: + if not path.is_file(): + raise OSError("not a regular file") + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeError) as exc: + raise CliError(message("file_error", path=path), exit_code=EXIT_LOCAL_INVALID) from exc + return normalize_release_notes(text) diff --git a/src/bisheng-cli/pyproject.toml b/src/bisheng-cli/pyproject.toml new file mode 100644 index 0000000000..91a1b295db --- /dev/null +++ b/src/bisheng-cli/pyproject.toml @@ -0,0 +1,77 @@ +[project] +name = "bisheng-cli" +version = "3.0.0.post2" +description = "BiSheng developer CLI — login / deploy / logs for hosted apps, zero coupling to the backend package" +requires-python = ">=3.11" +license = { text = "Apache-2.0" } +# Dependency budget is two entries (design D12). Adding a third one requires a +# written justification in design D12 first — every extra dependency is one more +# way for `pip install` to fail inside a customer's air-gapped network (CON-2). +# +# Both entries carry an UPPER bound on purpose. app-proxy shipped `fastapi>=0.115` +# with no ceiling: the dev machine resolved 0.121 and the whole suite was green, +# production resolved 0.141 where `add_websocket_route` had been removed and the +# process died at import time. The CLI is even more exposed than app-proxy: its +# distribution path is `pip install ` on a developer's machine, where +# `uv.lock` does not participate in resolution at all — the only thing that can +# constrain it is the metadata below. Widening a ceiling is a deliberate decision +# (re-run the wheel-install smoke first), never a drive-by bump. +dependencies = [ + "httpx>=0.27,<1.0", + "PyYAML>=6.0,<7.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "ruff>=0.9.0", +] + +[project.scripts] +bisheng = "bisheng_cli.main:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +# The directory is `src/bisheng-cli/` (hyphen) while the import package is +# `bisheng_cli` (underscore). hatchling cannot infer that mapping, and without +# this line `uv build` happily produces an EMPTY wheel. +[tool.hatch.build.targets.wheel] +packages = ["bisheng_cli"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +pythonpath = ["."] +markers = [ + "network: needs a real platform — skipped by default, runs only in the 114 manual verification (set BISHENG_CLI_RUN_NETWORK_TESTS=1)", +] + +[tool.ruff] +target-version = "py311" +line-length = 120 +exclude = [".venv", "__pycache__", "*.egg-info"] + +[tool.ruff.lint] +select = ["E", "W", "F", "I", "B", "C4", "UP", "RUF"] +ignore = [ + "E501", # line length — handled by the formatter + "B904", # raise ... from None is too strict for existing style + "UP007", # Optional[X] over X | None in runtime annotations + "RUF012", # mutable class attributes + # Every human-facing string this CLI prints is Chinese product copy with + # full-width punctuation. RUF001/002/003 would "fix" 「」(), into ASCII and + # silently rewrite the copy the product team signed off on. + "RUF001", + "RUF002", + "RUF003", +] + +[tool.ruff.lint.isort] +known-first-party = ["bisheng_cli"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false diff --git a/src/bisheng-cli/tests/__init__.py b/src/bisheng-cli/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/bisheng-cli/tests/conftest.py b/src/bisheng-cli/tests/conftest.py new file mode 100644 index 0000000000..c6d7233047 --- /dev/null +++ b/src/bisheng-cli/tests/conftest.py @@ -0,0 +1,158 @@ +"""Shared fixtures for the bisheng-cli suite. + +Three rules this file enforces for the whole package: + +1. **No test ever touches the network.** `no_network` swaps httpx's real + transport for a sentinel that raises. A forgotten mock therefore fails loudly + at the call site instead of quietly reaching out to whatever host the test + happened to name — which on a developer machine means a proxy timeout that + looks nothing like a missing mock. +2. **No test ever touches the real home directory.** Credentials are written + with `Path.home()`; pointing that at `tmp_path` is what keeps a test run from + overwriting the developer's own `~/.bisheng/credentials.json`. +3. **No long key literal is ever written into a `.py` file.** `scripts/arch-guard.sh` + RULE-7 greps for an assignment of a long string literal to a credential-looking + name. It only emits a WARNING and does not + block, which is precisely the problem: a permanent stream of false warnings is + how a real hardcoded secret gets scrolled past. `FAKE_KEY` is assembled by + concatenation so the pattern never matches. +""" + +from __future__ import annotations + +import os +import shutil +import stat +import subprocess +from pathlib import Path + +import httpx +import pytest + +from tests.helpers.platform_mock import FAKE_KEY + +__all__ = ["FAKE_KEY"] + +_PROXY_ENV = ( + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + "no_proxy", +) + +FIXTURES = Path(__file__).parent / "fixtures" + + +def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: + """Skip `@pytest.mark.network` unless a real platform was opted into.""" + if os.environ.get("BISHENG_CLI_RUN_NETWORK_TESTS") == "1": + return + skip = pytest.mark.skip( + reason="needs a real platform — 114 manual verification only (set BISHENG_CLI_RUN_NETWORK_TESTS=1)" + ) + for item in items: + if "network" in item.keywords: + item.add_marker(skip) + + +@pytest.fixture(autouse=True) +def no_network(monkeypatch: pytest.MonkeyPatch) -> None: + """Make any un-mocked HTTP call fail immediately. + + Also strips proxy env: a `socks5://` ALL_PROXY without `socksio` installed + turns an entire file into collection ERRORs that read like an import + problem, and the proxy-hint logic in `http.py` has to be exercised under a + known-empty environment anyway. + """ + for name in _PROXY_ENV: + monkeypatch.delenv(name, raising=False) + + def _refuse(self, request): + raise AssertionError(f"unmocked network call: {request.method} {request.url}") + + monkeypatch.setattr(httpx.HTTPTransport, "handle_request", _refuse, raising=True) + monkeypatch.setattr(httpx.AsyncHTTPTransport, "handle_async_request", _refuse, raising=True) + + +@pytest.fixture +def home_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Point `Path.home()` (and the env vars behind it) at a throwaway dir.""" + home = tmp_path / "home" + home.mkdir() + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("USERPROFILE", str(home)) + return home + + +def _build_sample_tree(root: Path) -> None: + """Materialise the sample application, noise and all. + + The noise is the point: `.venv/` and `node_modules/` are what actually blows + the 50 MB ceiling in the field, `dist/` is the soft-exclude that a real app + may legitimately need back, and the symlink / 0755 script / sqlite file each + stand for one packaging rule. + """ + shutil.copytree(FIXTURES / "sample_app", root) + (root / ".gitignore").write_text((root / "gitignore.template").read_text(encoding="utf-8"), encoding="utf-8") + (root / "gitignore.template").unlink() + + script = root / "entrypoint.sh" + script.chmod(script.stat().st_mode | stat.S_IXUSR) + + for noisy in (".venv", "node_modules", "__pycache__", ".pytest_cache"): + d = root / noisy + d.mkdir() + (d / "payload.bin").write_bytes(b"\x00" * 32) + + dist = root / "dist" + dist.mkdir() + (dist / "bundle.js").write_text("console.log(1)\n", encoding="utf-8") + + (root / "app.sqlite").write_bytes(b"SQLite format 3\x00") + (root / "debug.log").write_text("noise\n", encoding="utf-8") + (root / "important.log").write_text("kept by the ! rule\n", encoding="utf-8") + build_artifacts = root / "build-artifacts" + build_artifacts.mkdir() + (build_artifacts / "old.tar").write_bytes(b"x") + + try: + (root / "link-to-main.py").symlink_to(root / "main.py") + except (OSError, NotImplementedError): # pragma: no cover - Windows without dev mode + pass + + +@pytest.fixture +def sample_project(tmp_path: Path) -> Path: + root = tmp_path / "sample" + _build_sample_tree(root) + return root + + +def _git_available() -> bool: + return shutil.which("git") is not None + + +@pytest.fixture +def sample_project_git(tmp_path: Path) -> Path: + """Same tree, under real git. + + CI has git; a developer machine is not guaranteed to, so the fixture skips + rather than failing — a missing `git` says nothing about the code. + """ + if not _git_available(): + pytest.skip("git is not available; the git-backed ignore path cannot be exercised") + root = tmp_path / "sample-git" + _build_sample_tree(root) + env = { + **os.environ, + "GIT_CONFIG_GLOBAL": str(tmp_path / "gitconfig"), + "GIT_CONFIG_SYSTEM": str(tmp_path / "gitconfig-system"), + } + subprocess.run(["git", "init", "-q"], cwd=root, check=True, env=env) + subprocess.run(["git", "add", "-A"], cwd=root, check=True, env=env) + return root diff --git a/src/bisheng-cli/tests/fixtures/echo_app/bisheng-app.yaml b/src/bisheng-cli/tests/fixtures/echo_app/bisheng-app.yaml new file mode 100644 index 0000000000..05bf5ed237 --- /dev/null +++ b/src/bisheng-cli/tests/fixtures/echo_app/bisheng-app.yaml @@ -0,0 +1,4 @@ +name: 回声应用 +runtime: python3.11 +port: 8080 +slug: echo diff --git a/src/bisheng-cli/tests/fixtures/echo_app/main.py b/src/bisheng-cli/tests/fixtures/echo_app/main.py new file mode 100644 index 0000000000..e01dff67ed --- /dev/null +++ b/src/bisheng-cli/tests/fixtures/echo_app/main.py @@ -0,0 +1,36 @@ +"""Echo app for the `bisheng dev` end-to-end test: answers with what it received. + +Reads `PORT` (铁律①), binds 0.0.0.0 (铁律②) and reports every header plus the +platform environment it was given, so the test can assert what crossed the +proxy and what entered the process. +""" + +from __future__ import annotations + +import json +import os +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +PORT = int(os.environ.get("PORT") or os.environ.get("BISHENG_APP_PORT") or 8080) + + +class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + payload = { + "path": self.path, + "headers": {k.lower(): v for k, v in self.headers.items()}, + "env": {k: v for k, v in os.environ.items() if k.startswith(("BISHENG_", "PORT"))}, + } + body = json.dumps(payload).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args) -> None: + return + + +if __name__ == "__main__": + ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever() diff --git a/src/bisheng-cli/tests/fixtures/sample_app/bisheng-app.yaml b/src/bisheng-cli/tests/fixtures/sample_app/bisheng-app.yaml new file mode 100644 index 0000000000..9813151cd5 --- /dev/null +++ b/src/bisheng-cli/tests/fixtures/sample_app/bisheng-app.yaml @@ -0,0 +1,4 @@ +name: 表单问卷小应用 +runtime: python3.11 +port: 8080 +description: Wave 1 packaging sample diff --git a/src/bisheng-cli/tests/fixtures/sample_app/entrypoint.sh b/src/bisheng-cli/tests/fixtures/sample_app/entrypoint.sh new file mode 100644 index 0000000000..bf0ea0bd79 --- /dev/null +++ b/src/bisheng-cli/tests/fixtures/sample_app/entrypoint.sh @@ -0,0 +1,2 @@ +#!/bin/sh +exec python main.py diff --git a/src/bisheng-cli/tests/fixtures/sample_app/gitignore.template b/src/bisheng-cli/tests/fixtures/sample_app/gitignore.template new file mode 100644 index 0000000000..0bbd62d655 --- /dev/null +++ b/src/bisheng-cli/tests/fixtures/sample_app/gitignore.template @@ -0,0 +1,4 @@ +# noise the developer already told git to ignore +*.log +build-artifacts/ +!important.log diff --git a/src/bisheng-cli/tests/fixtures/sample_app/main.py b/src/bisheng-cli/tests/fixtures/sample_app/main.py new file mode 100644 index 0000000000..d1ebd13463 --- /dev/null +++ b/src/bisheng-cli/tests/fixtures/sample_app/main.py @@ -0,0 +1,5 @@ +"""Minimal sample app used by the packaging and ignore-rule suites.""" + + +def handler(): + return "ok" diff --git a/src/bisheng-cli/tests/fixtures/sample_app/requirements.txt b/src/bisheng-cli/tests/fixtures/sample_app/requirements.txt new file mode 100644 index 0000000000..5bd19d39d1 --- /dev/null +++ b/src/bisheng-cli/tests/fixtures/sample_app/requirements.txt @@ -0,0 +1 @@ +flask==3.0.0 diff --git a/src/bisheng-cli/tests/fixtures/sample_app/src/util.py b/src/bisheng-cli/tests/fixtures/sample_app/src/util.py new file mode 100644 index 0000000000..b15b1b0797 --- /dev/null +++ b/src/bisheng-cli/tests/fixtures/sample_app/src/util.py @@ -0,0 +1 @@ +VALUE = 1 diff --git a/src/bisheng-cli/tests/helpers/__init__.py b/src/bisheng-cli/tests/helpers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/bisheng-cli/tests/helpers/platform_mock.py b/src/bisheng-cli/tests/helpers/platform_mock.py new file mode 100644 index 0000000000..ba31e6fcca --- /dev/null +++ b/src/bisheng-cli/tests/helpers/platform_mock.py @@ -0,0 +1,435 @@ +"""Executable snapshot of the platform contracts this CLI consumes. + +This module is deliberately dumb. Every payload here is shaped exactly like the +one F053 (open_api, beta2) / F054 / F055 actually return, including the parts +that look wrong: + +* `/api/v1` answers HTTP 200 with the business code inside the envelope, while + `/api/v2` puts a real status on the status line *and* keeps the envelope body. +* the 260-segment error payload is `data = {"exception": , **kwargs}`, so + `26003` carries `data.required` as a **single string** (`"app:manage"`), not a + list. Joining it with `", ".join(...)` would print `a, p, p, :, m…`. + +"Tidying" any of these into a more sensible shape here would mean the CLI is +tested against a server that does not exist. If a shape looks wrong, fix the +server or the design note — not this file. +""" + +from __future__ import annotations + +import io +import tarfile +from collections.abc import Callable +from typing import Any + +import httpx + +from bisheng_cli.commands.skills import DEFAULT_PACKS as _CLI_DEFAULT_PACKS + +# Assembled rather than written out so `scripts/arch-guard.sh` RULE-7 never +# matches a long key literal in this repo's Python (see conftest docstring). +FAKE_KEY = "bs-sak-" + "x" * 24 +#: What `whoami.model_base_url` answers on a deployment with the model face on. +FAKE_MODEL_BASE_URL = "http://platform.test/api/v2/model/v1" +FAKE_KEY_MASK = "bs-sak-" + "*" * 8 + "wxyz" +# beta2 authenticates two credential prefixes on the same wire +# (`credential_validator._TOKEN_RE`): `bs-sak-` service-account keys and +# `bs-pat-` personal tokens. Anything the CLI prints has to redact both. +FAKE_PAT = "bs-pat-" + "y" * 24 + +# Real statuses, cross-checked against the server's own declarations by +# tests/test_platform_contract.py. +OPEN_API_HTTP_STATUS: dict[int, int] = { + 26001: 401, + 26002: 401, + 26027: 401, + 26003: 403, + 26004: 403, + 26016: 400, + # Personal access tokens authenticate on the same wire (`bs-pat-`), so a + # developer who pastes one gets these two rather than 26001 / 26002. + 26040: 403, + 26043: 401, + # 26051 is 403, not the 400 its issue-time sibling 26050 carries: it is a + # channel-entrance refusal, so it sits with 26003 / 26004. + 26051: 403, + 26030: 503, + 26031: 500, +} + + +def v1_envelope(data: Any, *, status_code: int = 200, status_message: str = "SUCCESS") -> httpx.Response: + """`/api/v1` shape: HTTP 200 always, the verdict lives in the body.""" + return httpx.Response(200, json={"status_code": status_code, "status_message": status_message, "data": data}) + + +def v2_ok(data: Any) -> httpx.Response: + return httpx.Response(200, json={"status_code": 200, "status_message": "SUCCESS", "data": data}) + + +def v2_error(code: int, message: str, *, http_status: int | None = None, **data: Any) -> httpx.Response: + """`/api/v2` shape: real status line + envelope body.""" + if http_status is None: + http_status = OPEN_API_HTTP_STATUS.get(code, 400) + payload = {"exception": message, **data} + return httpx.Response(http_status, json={"status_code": code, "status_message": message, "data": payload}) + + +# ---- /api/v1/dev-toolkit ------------------------------------------------ + + +def versions_ok(*, cli_version: str = "3.0.0", min_compatible: str = "3.0.0") -> httpx.Response: + return v1_envelope( + { + "cli": { + "version": cli_version, + "min_compatible": min_compatible, + "filename": f"bisheng_cli-{cli_version}-py3-none-any.whl", + "sha256": "0" * 64, + "download_path": "/api/v1/dev-toolkit/cli/download", + }, + # F057 consumes these positions; null this round (design D11). + "sdk": {"version": None, "min_compatible": None, "download_path": None}, + "platform": {"version": cli_version, "open_platform_enabled": True, "app_runtime_enabled": True}, + } + ) + + +def versions_404() -> httpx.Response: + """The router was never registered — FastAPI's own 404, not an error code.""" + return httpx.Response(404, json={"detail": "Not Found"}) + + +DEFAULT_PACK = "deploy-hosting" +#: The packs the CLI syncs, in its own order — re-exported from the CLI so a +#: pack added there is served by every test that uses `serve_default_packs`. +DEFAULT_PACKS: tuple[str, ...] = tuple(_CLI_DEFAULT_PACKS) + + +def skills_path(pack: str = DEFAULT_PACK) -> str: + return f"/api/v1/dev-toolkit/skills/{pack}" + + +def serve_default_packs(mock: PlatformMock) -> PlatformMock: + """Route every shipped pack the test did not route itself. + + `skills sync` fetches all of `DEFAULT_PACKS`; a test that only cares about + one of them would otherwise trip the mock's "unexpected request" on the + others. Routes the test registered are left alone, so a test can still + make one pack 404 or answer a specific tarball. + """ + for pack in DEFAULT_PACKS: + if not mock.has("GET", skills_path(pack)): + mock.get(skills_path(pack), skill_pack({"SKILL.md": f"# {pack}\n"}, pack=pack)) + return mock + + +def skill_pack( + files: dict[str, str] | None = None, + *, + pack: str = DEFAULT_PACK, + version: str = "3.0.0", +) -> httpx.Response: + """A skill-pack tarball, shaped like ``GET /dev-toolkit/skills/{pack}`` serves it. + + Members live under a ``pack/`` arcname and the platform version rides in a + header (the body is a tarball, not an envelope). Same shape the CLI's + ``skills sync`` unpacks in production. + """ + files = files or {"SKILL.md": "# deploy-hosting\n"} + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode="w:gz") as tar: + for rel, content in files.items(): + data = content.encode("utf-8") + info = tarfile.TarInfo(f"{pack}/{rel}") + info.size = len(data) + tar.addfile(info, io.BytesIO(data)) + return httpx.Response(200, content=buffer.getvalue(), headers={"x-bisheng-pack-version": version}) + + +def env_ok(*, open_platform_enabled: bool = True, app_runtime_enabled: bool = True) -> httpx.Response: + return v1_envelope( + { + "env": "prod", + "version": "2.6.0-fix", # hardcoded upstream; never use it for compatibility (design 坑 21) + "open_platform_enabled": open_platform_enabled, + "app_runtime_enabled": app_runtime_enabled, + } + ) + + +def env_legacy() -> httpx.Response: + """A platform old enough that the flag does not exist at all.""" + return v1_envelope({"env": "prod", "version": "2.4.0"}) + + +def env_unreachable() -> httpx.ConnectError: + return httpx.ConnectError("connection refused") + + +# ---- /api/v2/auth ------------------------------------------------------- + + +#: Every field ``WhoamiResponse`` declares, in declaration order. Kept as data +#: rather than only inside :func:`whoami_ok` so that +#: ``tests/test_platform_contract.py`` can compare it against the server model +#: itself — the whole payload changed shape once already (F049 sent +#: ``subject_kind`` / ``service_account: {id, name}`` / ``resource_owner: +#: {user_id, user_name}``; beta2's F053 sends none of those) and the CLI suite +#: stayed green throughout, asserting against a server that no longer existed. +WHOAMI_FIELDS = ( + "credential_id", + "actor_kind", + "actor_id", + "actor_name", + "tenant_id", + "resource_owner", + "authorization_subject_type", + "authorization_subject_id", + "effective_user_id", + "mode", + "scopes", + "key_mask", + "expires_at", + "model_base_url", +) + + +#: Sentinel for "the caller said nothing", so that an explicit +#: ``resource_owner=None`` can mean what the server means by it: JSON null. +_DEFAULT = object() + + +def whoami_ok( + *, + scopes: list[str] | None = None, + resource_owner: Any = _DEFAULT, + actor_kind: str = "service_account", + actor_id: int = 123, + actor_name: str = "问卷小队开发号", + tenant_id: int = 1, + expires_at: str | None = "2026-12-31T00:00:00", + model_base_url: str = FAKE_MODEL_BASE_URL, +) -> httpx.Response: + """``GET /api/v2/auth/whoami`` exactly as beta2's ``WhoamiResponse`` serves it. + + Two shapes here are easy to "tidy" into something wrong: + + * ``resource_owner`` is ``{"user_id": int}`` **or null** — one key, no name. + A service account always has an owner (the column is NOT NULL), so the + null case means an ownerless subject, not an older platform. + * the subject is flat (``actor_kind`` / ``actor_id`` / ``actor_name``), and + ``actor_kind`` is ``"natural_person"`` for a ``bs-pat-`` personal token. + """ + owner = {"user_id": 7} if resource_owner is _DEFAULT else resource_owner + natural_person = actor_kind == "natural_person" + data: dict[str, Any] = { + "credential_id": 42, + "actor_kind": actor_kind, + "actor_id": actor_id, + "actor_name": actor_name, + "tenant_id": tenant_id, + "resource_owner": owner, + # A personal token authorises as its holder; a service account + # authorises as itself and has no effective user (mode S, no delegation + # — the only shape a CLI key can have, INV-31). + "authorization_subject_type": "user" if natural_person else "service_account", + "authorization_subject_id": actor_id, + "effective_user_id": actor_id if natural_person else None, + "mode": "S", + "scopes": scopes if scopes is not None else ["app:manage"], + "key_mask": FAKE_KEY_MASK, + "expires_at": expires_at, + # F051 AC-30: the one outward spelling of the model face's base URL. + # Empty string where the open-capability layer is not deployed — the + # face does not exist there, and `dev` must not invent an address. + "model_base_url": model_base_url, + } + return v2_ok(data) + + +def whoami_without_resource_owner(**kwargs: Any) -> httpx.Response: + """A credential the platform reports as having no resource owner at all. + + ``resource_owner`` is nullable on the wire (``WhoamiResourceOwner | None``), + and a key in that state cannot publish: `deploy` refuses it with 16205 + rather than creating an application owned by nobody. + """ + return whoami_ok(resource_owner=None, **kwargs) + + +def whoami_err(code: int, message: str = "credential rejected", *, http_status: int | None = None, **data: Any): + return v2_error(code, message, http_status=http_status, **data) + + +# ---- /api/v2/apps ------------------------------------------------------- + + +def deploy_limits(*, max_package_mb: int = 50, max_unpacked_mb: int = 200, max_package_entries: int = 20000): + return v2_ok( + { + "max_package_mb": max_package_mb, + "max_unpacked_mb": max_unpacked_mb, + "max_package_entries": max_package_entries, + } + ) + + +def deploy_accept( + *, + deployment_id: str = "dep-1", + app_id: str = "app-1", + version_id: str = "ver-1", + entry_url: str | None = None, +) -> httpx.Response: + return v2_ok({"deployment_id": deployment_id, "app_id": app_id, "version_id": version_id, "entry_url": entry_url}) + + +def deploy_sync_err(code: int, message: str = "deploy rejected", *, http_status: int | None = None, **data: Any): + """A failure from the *synchronous* leg of POST /apps/deploy. + + Ownership, size, unpack, manifest, local-reference and in-flight gates all + run before the row exists, so these never appear in the polling payload + (design D6 / red line 1). + """ + return v2_error(code, message, http_status=http_status, **data) + + +def deployment( + *, + stage: str = "received", + status: str = "running", + failure: dict[str, Any] | None = None, + app_id: str = "app-1", + version_no: int = 1, + approval: dict[str, Any] | None = None, + app_state: str | None = None, + pending_reason: str | None = None, + entry_url: str | None = None, +) -> httpx.Response: + return v2_ok( + { + "stage": stage, + "status": status, + "failure": failure, + "app_id": app_id, + "version_no": version_no, + "approval": approval, + "app_state": app_state, + "pending_reason": pending_reason, + # ``entry_url`` used to live only on the POST response, where a first + # deploy is still a draft and it was null exactly when it mattered. + # F055's write-back put it on every poll; defaults to None so tests + # that do not care keep exercising the older shape. + "entry_url": entry_url, + } + ) + + +def deployment_seq(responses: list[httpx.Response]) -> list[httpx.Response]: + """Identity helper — reads as a sequence at the call site.""" + return list(responses) + + +def failure_tuple(stage: str, code: int, message: str, **details: Any) -> dict[str, Any]: + """The five-tuple. `details` / `hints` are the agent's entire repair input.""" + return { + "stage": stage, + "code": code, + "message": message, + "details": details or {}, + "hints": [f"hint for {code}"], + } + + +def logs( + lines: list[str] | None = None, + *, + app_state: str | None = None, + pending_reason: str | None = None, +) -> httpx.Response: + """The logs payload. ``app_state`` / ``pending_reason`` ride along with it. + + They default to ``None`` so existing tests keep exercising the older-platform + shape — a platform that predates the write-back simply omits them, and the + CLI must still say something useful. + """ + return v2_ok( + {"lines": lines if lines is not None else [], "app_state": app_state, "pending_reason": pending_reason} + ) + + +# ---- transport ---------------------------------------------------------- + +Route = httpx.Response | Exception | Callable[[httpx.Request], httpx.Response] + + +class PlatformMock: + """Route table for `httpx.MockTransport`. + + Responses registered as a list are consumed in order — that is how a polling + sequence is expressed. Running past the end of the list raises instead of + repeating the last entry: a test that polls one more time than it declared + should say so out loud. + """ + + def __init__(self) -> None: + self._routes: dict[tuple[str, str], list[Route]] = {} + self.calls: list[httpx.Request] = [] + + def add(self, method: str, path: str, response: Route | list[Route]) -> PlatformMock: + key = (method.upper(), path) + items = response if isinstance(response, list) else [response] + self._routes.setdefault(key, []).extend(items) + return self + + def get(self, path: str, response: Route | list[Route]) -> PlatformMock: + return self.add("GET", path, response) + + def post(self, path: str, response: Route | list[Route]) -> PlatformMock: + return self.add("POST", path, response) + + def has(self, method: str, path: str) -> bool: + return (method.upper(), path) in self._routes + + @property + def transport(self) -> httpx.MockTransport: + return httpx.MockTransport(self._handle) + + def _handle(self, request: httpx.Request) -> httpx.Response: + self.calls.append(request) + if len(self.calls) > 500: + # A polling loop that never terminates would otherwise hang the suite + # instead of failing it. + raise AssertionError("mock served 500 requests — the caller is not terminating") + key = (request.method.upper(), request.url.path) + queue = self._routes.get(key) + if not queue: + raise AssertionError(f"unexpected request: {request.method} {request.url.path}") + item = queue.pop(0) if len(queue) > 1 else queue[0] + if isinstance(item, Exception): + raise item + if callable(item): + return item(request) + return item + + def paths_called(self) -> list[str]: + return [r.url.path for r in self.calls] + + +def use_mock_transport(monkeypatch: Any, module: Any, mock: PlatformMock) -> None: + """Point a command module's `PlatformClient` at `mock`. + + Command modules construct their own client, which is the right shape for + production and the wrong shape for a test. Patching the name inside the + module (rather than injecting a client through the signature) keeps the + production code free of a test-only seam while still letting the sentinel in + `conftest.no_network` catch anything the mock does not cover. + """ + real = module.PlatformClient + + def factory(*args: Any, **kwargs: Any) -> Any: + kwargs.setdefault("transport", mock.transport) + return real(*args, **kwargs) + + monkeypatch.setattr(module, "PlatformClient", factory) diff --git a/src/bisheng-cli/tests/test_agent_skills.py b/src/bisheng-cli/tests/test_agent_skills.py new file mode 100644 index 0000000000..718f870fcd --- /dev/null +++ b/src/bisheng-cli/tests/test_agent_skills.py @@ -0,0 +1,247 @@ +"""Wiring synced packs into the AI coding tools on the machine. + +The bug this module answers looked like a success: `login` printed +"5 个文件已同步", the files really were on disk under `~/.bisheng/skills/`, and +nothing on the machine could read them — no agent scans that directory. So the +tests here are mostly about the *reporting* contract as much as the linking one: +a pack that reaches no agent must be loud, and a path the CLI did not create must +never be overwritten to make the summary look better. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from bisheng_cli import agent_skills + + +def _pack(home: Path, *, slug: str = "platform.test.abcd1234", pack: str = "deploy-hosting") -> Path: + """A synced pack in its real shape: ~/.bisheng/skills///.""" + pack_dir = home / ".bisheng" / "skills" / slug / pack + (pack_dir / "example").mkdir(parents=True) + (pack_dir / "SKILL.md").write_text("# deploy-hosting\n", encoding="utf-8") + (pack_dir / "example" / "main.py").write_text("print('hi')\n", encoding="utf-8") + return pack_dir + + +# ---- detection ------------------------------------------------------------ + + +def test_detects_nothing_on_a_bare_machine(home_dir: Path) -> None: + assert agent_skills.detect() == [] + + +def test_detects_both_agents_and_keeps_them_both(home_dir: Path) -> None: + # The whole point: with two tools installed we install into two, never pick. + (home_dir / ".claude").mkdir() + (home_dir / ".codex").mkdir() + assert [t.key for t in agent_skills.detect()] == ["claude-code", "codex"] + + +def test_detects_agent_whose_skills_dir_does_not_exist_yet(home_dir: Path) -> None: + # `~/.claude/skills` only appears once the user has a skill; keying detection + # off it would skip exactly the first-time developer this feature is for. + (home_dir / ".claude").mkdir() + targets = agent_skills.detect() + assert len(targets) == 1 + assert not targets[0].skills_dir.exists() + + +# ---- installing ----------------------------------------------------------- + + +def test_installs_into_every_detected_agent(home_dir: Path) -> None: + (home_dir / ".claude").mkdir() + (home_dir / ".codex").mkdir() + pack_dir = _pack(home_dir) + + results = agent_skills.install(pack_dir, "deploy-hosting", agent_skills.detect()) + + assert [r["status"] for r in results] == ["linked", "linked"] + for agent in (".claude", ".codex"): + link = home_dir / agent / "skills" / "deploy-hosting" + assert link.is_symlink() + assert (link / "SKILL.md").read_text(encoding="utf-8") == "# deploy-hosting\n" + + +def test_install_is_idempotent(home_dir: Path) -> None: + (home_dir / ".claude").mkdir() + pack_dir = _pack(home_dir) + agent_skills.install(pack_dir, "deploy-hosting", agent_skills.detect()) + results = agent_skills.install(pack_dir, "deploy-hosting", agent_skills.detect()) + assert results[0]["status"] == "linked" + assert (home_dir / ".claude" / "skills" / "deploy-hosting" / "SKILL.md").is_file() + + +def test_relinks_when_the_platform_changes(home_dir: Path) -> None: + """Logging into another platform must repoint the link, not report a conflict. + + The link from the previous platform is ours — same tree, different slug — so + it gets replaced. Treating it as foreign would leave every agent reading the + old platform's contract with nothing on screen to say so. + """ + (home_dir / ".claude").mkdir() + first = _pack(home_dir, slug="old.platform.11111111") + agent_skills.install(first, "deploy-hosting", agent_skills.detect()) + + second = _pack(home_dir, slug="new.platform.22222222") + (second / "SKILL.md").write_text("# new platform\n", encoding="utf-8") + results = agent_skills.install(second, "deploy-hosting", agent_skills.detect()) + + assert results[0]["status"] == "linked" + link = home_dir / ".claude" / "skills" / "deploy-hosting" + assert link.resolve() == second.resolve() + assert (link / "SKILL.md").read_text(encoding="utf-8") == "# new platform\n" + + +def test_repairs_a_broken_link(home_dir: Path) -> None: + # Normal state after someone deletes ~/.bisheng. Leaving it dangling would + # strand the agent on a path that resolves to nothing. + (home_dir / ".claude" / "skills").mkdir(parents=True) + link = home_dir / ".claude" / "skills" / "deploy-hosting" + os.symlink(home_dir / ".bisheng" / "skills" / "gone" / "deploy-hosting", link) + pack_dir = _pack(home_dir) + + results = agent_skills.install(pack_dir, "deploy-hosting", agent_skills.detect()) + + assert results[0]["status"] == "linked" + assert (link / "SKILL.md").is_file() + + +def test_never_overwrites_a_skill_the_developer_owns(home_dir: Path) -> None: + """A real directory at the target path is the developer's, and stays untouched. + + Deleting it would make the summary read "已接入" at the cost of destroying + someone's own skill — the one outcome worse than not installing at all. + """ + (home_dir / ".claude" / "skills" / "deploy-hosting").mkdir(parents=True) + mine = home_dir / ".claude" / "skills" / "deploy-hosting" / "SKILL.md" + mine.write_text("我自己写的\n", encoding="utf-8") + pack_dir = _pack(home_dir) + + results = agent_skills.install(pack_dir, "deploy-hosting", agent_skills.detect()) + + assert results[0]["status"] == "conflict" + assert mine.read_text(encoding="utf-8") == "我自己写的\n" + + +def test_foreign_symlink_is_a_conflict_too(home_dir: Path) -> None: + (home_dir / ".claude" / "skills").mkdir(parents=True) + elsewhere = home_dir / "my-skills" / "deploy-hosting" + elsewhere.mkdir(parents=True) + os.symlink(elsewhere, home_dir / ".claude" / "skills" / "deploy-hosting") + pack_dir = _pack(home_dir) + + results = agent_skills.install(pack_dir, "deploy-hosting", agent_skills.detect()) + + assert results[0]["status"] == "conflict" + assert (home_dir / ".claude" / "skills" / "deploy-hosting").resolve() == elsewhere.resolve() + + +def test_falls_back_to_copy_when_symlinks_are_unavailable(home_dir: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # Windows without Developer Mode. A copy cannot follow later syncs, so the + # mode is reported rather than smoothed over. + (home_dir / ".claude").mkdir() + pack_dir = _pack(home_dir) + monkeypatch.setattr(agent_skills.os, "symlink", _raise_oserror) + + results = agent_skills.install(pack_dir, "deploy-hosting", agent_skills.detect()) + + assert results[0]["status"] == "linked" + assert results[0]["mode"] == "copy" + link = home_dir / ".claude" / "skills" / "deploy-hosting" + assert not link.is_symlink() + assert (link / "SKILL.md").is_file() + assert (link / agent_skills.MANAGED_MARKER).is_file() + + +def test_a_managed_copy_is_replaceable_but_a_plain_dir_is_not(home_dir: Path, monkeypatch: pytest.MonkeyPatch) -> None: + (home_dir / ".claude").mkdir() + pack_dir = _pack(home_dir) + monkeypatch.setattr(agent_skills.os, "symlink", _raise_oserror) + agent_skills.install(pack_dir, "deploy-hosting", agent_skills.detect()) + + (pack_dir / "SKILL.md").write_text("# v2\n", encoding="utf-8") + results = agent_skills.install(pack_dir, "deploy-hosting", agent_skills.detect()) + + assert results[0]["status"] == "linked" + link = home_dir / ".claude" / "skills" / "deploy-hosting" + assert (link / "SKILL.md").read_text(encoding="utf-8") == "# v2\n" + + +def _raise_oserror(*_args: object, **_kwargs: object) -> None: + raise OSError("symlink unsupported") + + +# ---- summarise ------------------------------------------------------------ + + +def test_summarise_splits_linked_from_problems() -> None: + linked, problems = agent_skills.summarise( + [ + {"label": "Claude Code", "status": "linked"}, + {"label": "Codex", "status": "conflict", "reason": "占用"}, + ] + ) + assert linked == ["Claude Code"] + assert [p["label"] for p in problems] == ["Codex"] + + +# ---- project pointer ------------------------------------------------------ + + +def test_pointer_created_when_agents_md_absent(home_dir: Path, tmp_path: Path) -> None: + project = tmp_path / "proj" + project.mkdir() + skills_root = home_dir / ".bisheng" / "skills" / "platform.test.abcd1234" + + outcome = agent_skills.ensure_project_pointer(project, skills_root, ["deploy-hosting"]) + + assert outcome["status"] == "created" + text = (project / "AGENTS.md").read_text(encoding="utf-8") + assert agent_skills.POINTER_MARKER in text + assert "deploy-hosting/SKILL.md" in text + # Home-relative, because this file gets committed and a teammate's home differs. + assert str(home_dir) not in text + assert "~/.bisheng/skills/platform.test.abcd1234" in text + + +def test_pointer_appends_and_never_rewrites(home_dir: Path, tmp_path: Path) -> None: + project = tmp_path / "proj" + project.mkdir() + existing = "# 我的项目\n\n本地约定若干。\n" + (project / "AGENTS.md").write_text(existing, encoding="utf-8") + + outcome = agent_skills.ensure_project_pointer(project, home_dir / ".bisheng" / "skills" / "s", ["deploy-hosting"]) + + assert outcome["status"] == "appended" + text = (project / "AGENTS.md").read_text(encoding="utf-8") + assert text.startswith(existing) + assert agent_skills.POINTER_MARKER in text + + +def test_pointer_is_idempotent(home_dir: Path, tmp_path: Path) -> None: + project = tmp_path / "proj" + project.mkdir() + skills_root = home_dir / ".bisheng" / "skills" / "s" + agent_skills.ensure_project_pointer(project, skills_root, ["deploy-hosting"]) + first = (project / "AGENTS.md").read_text(encoding="utf-8") + + outcome = agent_skills.ensure_project_pointer(project, skills_root, ["deploy-hosting"]) + + assert outcome["status"] == "present" + assert (project / "AGENTS.md").read_text(encoding="utf-8") == first + + +def test_pointer_failure_is_reported_not_raised(home_dir: Path, tmp_path: Path) -> None: + # A directory named AGENTS.md: unreadable and unwritable as a file. Deploy + # must survive it, so the failure comes back as data. + project = tmp_path / "proj" + (project / "AGENTS.md").mkdir(parents=True) + + outcome = agent_skills.ensure_project_pointer(project, home_dir / ".bisheng" / "skills" / "s", ["deploy-hosting"]) + + assert outcome["status"] == "failed" diff --git a/src/bisheng-cli/tests/test_cli.py b/src/bisheng-cli/tests/test_cli.py new file mode 100644 index 0000000000..8a6042acb4 --- /dev/null +++ b/src/bisheng-cli/tests/test_cli.py @@ -0,0 +1,239 @@ +"""T009 — the command surface and the exit-code funnel. + +Module-level imports on purpose: this file doubles as the import smoke test for +the production entry point. A dependency that resolved to an incompatible +version breaks here, at collection, instead of on a developer's machine after a +`pip install`. +""" + +from __future__ import annotations + +import io +import json + +import pytest + +from bisheng_cli import __version__ +from bisheng_cli.cli import DEFERRED_COMMANDS, SUBCOMMANDS, build_parser, confirm +from bisheng_cli.errors import EXIT_INTERNAL, EXIT_USAGE, CliError +from bisheng_cli.main import main, run + + +def _subparser_actions(parser): + for action in parser._actions: + if hasattr(action, "choices") and isinstance(action.choices, dict): + yield from action.choices.items() + + +def _all_option_strings(parser) -> set[str]: + strings = {s for a in parser._actions for s in a.option_strings} + for _, sub in _subparser_actions(parser): + strings |= {s for a in sub._actions for s in a.option_strings} + return strings + + +def test_help_lists_registered_commands() -> None: + parser = build_parser() + names = {name for name, _ in _subparser_actions(parser)} + assert names == {"login", "deploy", "logs", "skills", "dev", "platforms", "init"} == set(SUBCOMMANDS) + + +def test_help_footer_declares_deferred_commands() -> None: + # Deferred commands are announced, not hidden: an agent that reads --help + # should learn that a command exists later rather than infer it never will. + # Nothing is deferred any more (`dev` shipped with T042–T044); the footer + # lists every registered command instead. + text = build_parser().format_help() + for name in DEFERRED_COMMANDS: + assert name in text + assert "dev" in text and "platforms" in text + assert "随后续版本提供" not in text + + +def test_deferred_commands_are_not_registered() -> None: + names = {name for name, _ in _subparser_actions(build_parser())} + # A name is either registered or announced-as-deferred, never both — a + # stub that answers "not in this version" is a non-standard failure. + assert set(DEFERRED_COMMANDS).isdisjoint(names) + assert "dev" in names # T044: the fifth command is real now + + +def test_dev_parses_path_and_ports_and_nothing_identity_shaped() -> None: + parser = build_parser() + args = parser.parse_args(["dev", "./app", "--port", "9000", "--app-port", "9001", "--json"]) + assert (args.command, args.path, args.port, args.app_port, args.json_mode) == ("dev", "./app", 9000, 9001, True) + bare = parser.parse_args(["dev"]) + assert bare.path == "." and bare.port is None and bare.app_port is None + dev_options = {s for a in dict(_subparser_actions(parser))["dev"]._actions for s in a.option_strings} + assert dev_options <= { + "-h", + "--help", + "--port", + "--app-port", + "--verbose", + "--quiet", + "--json", + "--timeout", + "--no-proxy", + "--platform", + } + + +def test_platforms_list_and_use_parse() -> None: + parser = build_parser() + assert parser.parse_args(["platforms", "list"]).platforms_command == "list" + use = parser.parse_args(["platforms", "use", "http://p.test", "--json"]) + assert use.platforms_command == "use" and use.base_url == "http://p.test" and use.json_mode is True + assert parser.parse_args(["platforms"]).platforms_command is None + + +def test_skills_sync_registered_and_parses() -> None: + parser = build_parser() + args = parser.parse_args(["skills", "sync"]) + assert args.command == "skills" + assert args.skills_command == "sync" + # A global flag after the two-token command must be accepted (mirror on the + # `sync` leaf), not "unrecognized arguments". + assert parser.parse_args(["skills", "sync", "--json"]).json_mode is True + # A bare `skills` leaves the verb unset for the handler to turn into a usage + # message rather than an argparse error. + assert parser.parse_args(["skills"]).skills_command is None + + +def test_no_as_flag_anywhere() -> None: + options = _all_option_strings(build_parser()) + assert "--as" not in options + assert not [o for o in options if "on-behalf" in o or "end-user" in o or "impersonat" in o] + + +def test_platform_flag_is_global_and_mirrored() -> None: + # T047 (AC-12): `--platform` selects among logged-in platforms and, like the + # other global flags, works before and after the subcommand. + parser = build_parser() + for argv in (["--platform", "http://p.test", "logs"], ["logs", "--platform", "http://p.test"]): + assert parser.parse_args(argv).platform == "http://p.test" + assert parser.parse_args(["logs"]).platform is None + assert parser.parse_args(["skills", "sync", "--platform", "http://p.test"]).platform == "http://p.test" + + +def test_init_subcommand_generates_versioned_bundle_projects() -> None: + args = build_parser().parse_args(["init", "example-app", "--runtime", "python3.11"]) + assert args.command == "init" and args.runtime == "python3.11" + + +def test_version_flag_prints_package_version(capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as excinfo: + build_parser().parse_args(["--version"]) + assert excinfo.value.code == 0 + assert __version__ in capsys.readouterr().out + + +def test_every_interactive_confirm_has_a_flag_equivalent() -> None: + subs = dict(_subparser_actions(build_parser())) + deploy_options = {s for a in subs["deploy"]._actions for s in a.option_strings} + login_options = {s for a in subs["login"]._actions for s in a.option_strings} + assert {"--confirm-schema-change", "--yes"} <= deploy_options + assert "--api-key-stdin" in login_options + + +def test_non_interactive_missing_flag_is_refusal_not_default_yes() -> None: + with pytest.raises(CliError) as excinfo: + confirm("覆盖既有应用?", assume_yes=False, is_tty=False, flag_name="--yes") + assert excinfo.value.exit_code == EXIT_USAGE + assert "--yes" in excinfo.value.next_step + + +def test_confirm_flag_equivalent_short_circuits_without_prompting() -> None: + def _explode(_prompt: str) -> str: # pragma: no cover - must never run + raise AssertionError("prompted despite the flag") + + assert confirm("覆盖?", assume_yes=True, is_tty=True, flag_name="--yes", reader=_explode) is True + + +def test_confirm_interactive_reads_answer() -> None: + assert confirm("覆盖?", assume_yes=False, is_tty=True, flag_name="--yes", reader=lambda _p: "y") is True + assert confirm("覆盖?", assume_yes=False, is_tty=True, flag_name="--yes", reader=lambda _p: "n") is False + + +def _boom(_args, _emitter) -> int: + raise RuntimeError("kaboom") + + +def test_unexpected_exception_exits_1_with_traceback_only_in_verbose() -> None: + err = io.StringIO() + code = run(["login", "http://p.test"], handlers={"login": _boom}, stdout=io.StringIO(), stderr=err) + assert code == EXIT_INTERNAL + assert "Traceback" not in err.getvalue() + + verbose_err = io.StringIO() + run( + ["--verbose", "login", "http://p.test"], + handlers={"login": _boom}, + stdout=io.StringIO(), + stderr=verbose_err, + ) + assert "Traceback" in verbose_err.getvalue() + + +def test_result_event_emitted_even_on_exception() -> None: + out = io.StringIO() + run(["--json", "login", "http://p.test"], handlers={"login": _boom}, stdout=out, stderr=io.StringIO()) + events = [json.loads(line) for line in out.getvalue().splitlines() if line.strip()] + assert events[-1]["event"] == "result" + assert events[-1]["ok"] is False and events[-1]["exit_code"] == EXIT_INTERNAL + + +def test_cli_error_from_handler_is_rendered_with_code_and_next_step() -> None: + def _fail(_args, _emitter) -> int: + raise CliError("坏了", exit_code=6, code=16203, next_step="检查包根") + + err = io.StringIO() + code = run(["login", "http://p.test"], handlers={"login": _fail}, stdout=io.StringIO(), stderr=err) + assert code == 6 + text = err.getvalue() + assert "16203" in text and "检查包根" in text + + +def test_successful_handler_emits_single_result_and_returns_its_code() -> None: + out = io.StringIO() + code = run( + ["--json", "logs"], + handlers={"logs": lambda _a, _e: 0}, + stdout=out, + stderr=io.StringIO(), + ) + assert code == 0 + events = [json.loads(line) for line in out.getvalue().splitlines() if line.strip()] + assert [e["event"] for e in events].count("result") == 1 + + +def test_no_subcommand_is_usage_error() -> None: + assert run([], stdout=io.StringIO(), stderr=io.StringIO()) == EXIT_USAGE + + +def test_main_is_the_console_script_entry_point() -> None: + with pytest.raises(SystemExit) as excinfo: + main([]) + assert excinfo.value.code == EXIT_USAGE + + +def test_global_flags_accepted_before_and_after_the_subcommand() -> None: + """`bisheng deploy --json` must work, not only `bisheng --json deploy`. + + Plain argparse only accepts a root-level option before the subcommand, and + the after-form is what people and agents actually type — this feature's own + 114 verification script writes `bisheng deploy --wait --json`. The mirrored + flags on each subparser use `default=SUPPRESS`, so the before-form must keep + working too: without SUPPRESS the sub-namespace copy would silently reset + `--json` back to False. + """ + parser = build_parser() + for argv in (["--json", "deploy", "--wait"], ["deploy", "--wait", "--json"]): + args = parser.parse_args(argv) + assert args.json_mode is True and args.wait is True + for argv in (["--verbose", "logs"], ["logs", "--verbose"]): + assert parser.parse_args(argv).verbose is True + # The defaults still arrive when nothing is passed anywhere. + bare = parser.parse_args(["logs"]) + assert bare.json_mode is False and bare.verbose is False + assert bare.quiet is False and bare.timeout is None and bare.no_proxy is False diff --git a/src/bisheng-cli/tests/test_command_deploy_sync.py b/src/bisheng-cli/tests/test_command_deploy_sync.py new file mode 100644 index 0000000000..e8ae4ced2f --- /dev/null +++ b/src/bisheng-cli/tests/test_command_deploy_sync.py @@ -0,0 +1,509 @@ +"""T021 — `bisheng deploy`, synchronous leg. + +This file carries red line 1. `POST /api/v2/apps/deploy` answers with the +ownership, size, unpack, manifest, local-reference and in-flight verdicts *in the +response to the upload itself* — the deployment row does not exist yet. None of +those failures can appear in the polling payload, so every one of them must +terminate the command where it is raised. The mock enforces this structurally: +the polling path is simply not registered, so a run that starts polling fails +with "unexpected request". +""" + +from __future__ import annotations + +import io +import json +from pathlib import Path + +import pytest + +from bisheng_cli import credentials +from bisheng_cli.commands import deploy as deploy_mod +from bisheng_cli.errors import ( + EXIT_FORBIDDEN, + EXIT_LOCAL_INVALID, + EXIT_NOT_ENABLED, + EXIT_NOT_LOGGED_IN, + EXIT_OK, + EXIT_PUBLISH_CONFLICT, + EXIT_USAGE, +) +from bisheng_cli.main import run as main_run +from tests.helpers.platform_mock import ( + FAKE_KEY, + FAKE_KEY_MASK, + PlatformMock, + deploy_accept, + deploy_limits, + deploy_sync_err, + deployment, + use_mock_transport, +) + +BASE = "http://platform.test" +LIMITS = "/api/v2/apps/deploy-limits" +DEPLOY = "/api/v2/apps/deploy" + + +class _Tty(io.StringIO): + """A stream that claims to be a terminal, so the interactive path is reachable.""" + + def isatty(self) -> bool: + return True + + +@pytest.fixture +def logged_in(home_dir) -> None: + credentials.save_profile( + BASE, + { + "api_key": FAKE_KEY, + "key_mask": FAKE_KEY_MASK, + "tenant_id": 1, + "service_account": {"id": 123, "name": "问卷小队开发号"}, + }, + ) + + +def _mock(*, limits: bool = True) -> PlatformMock: + mock = PlatformMock() + if limits: + mock.get(LIMITS, deploy_limits()) + return mock + + +def _run( + argv: list[str], + *, + monkeypatch: pytest.MonkeyPatch, + mock: PlatformMock, + tty: bool = False, +) -> tuple[int, str, str]: + use_mock_transport(monkeypatch, deploy_mod, mock) + monkeypatch.setattr(deploy_mod, "_sleep", lambda _s: None) + out: io.StringIO = io.StringIO() + err: io.StringIO = _Tty() if tty else io.StringIO() + code = main_run(argv, stdout=out, stderr=err) + return code, out.getvalue(), err.getvalue() + + +def _waiting_seq() -> list: + return [ + deployment(stage="received", status="running"), + deployment( + stage="approval_created", + status="waiting_approval", + approval={"instance_id": "ap-1", "status": "pending"}, + ), + ] + + +def test_not_logged_in_exits_3_without_any_request( + monkeypatch: pytest.MonkeyPatch, home_dir, sample_project: Path +) -> None: + mock = _mock() + code, _, err = _run(["deploy", str(sample_project)], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_NOT_LOGGED_IN + assert "login" in err + assert mock.paths_called() == [] + + +def test_local_manifest_failure_refuses_before_packaging( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + (sample_project / "bisheng-app.yaml").unlink() + mock = _mock() + code, _, err = _run(["deploy", str(sample_project)], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_LOCAL_INVALID + assert "bisheng-app.yaml" in err + assert mock.paths_called() == [] + + +def test_dry_run_stops_after_packaging_and_prints_stats( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + mock = _mock() + code, _, err = _run(["deploy", str(sample_project), "--dry-run"], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_OK + assert "已排除" in err and "打包条目" in err and "未上传" in err + # Nothing at all is created on the platform by a dry run. + assert mock.paths_called() == [] + + +def test_oversize_refused_locally_with_top10(monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path) -> None: + mock = _mock(limits=False).get(LIMITS, deploy_limits(max_package_mb=0)) + code, _, err = _run(["deploy", str(sample_project)], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_LOCAL_INVALID + assert "整包拒绝" in err + # The excluded count comes before the file list: "the limit is too small" is + # the wrong first conclusion nine times out of ten. + assert err.index("已排除") < err.index("Top 文件") + assert DEPLOY not in mock.paths_called() + + +def test_upload_is_streamed_multipart_not_read_bytes( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + def _forbidden(self, *a, **kw): # pragma: no cover - must never run + raise AssertionError("the package was slurped into memory via read_bytes()") + + monkeypatch.setattr(Path, "read_bytes", _forbidden) + + mock = _mock().post(DEPLOY, deploy_accept()).get("/api/v2/apps/deployments/dep-1", _waiting_seq()) + code, _, _ = _run(["deploy", str(sample_project)], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_OK + upload = next(call for call in mock.calls if call.url.path == DEPLOY) + assert upload.headers["content-type"].startswith("multipart/form-data") + + +def test_app_id_persisted_immediately_on_http_200_even_if_pipeline_later_fails( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + # The platform has already created a draft application and assigned this id. + # Saving it only on success means the next attempt creates a second one. + failed = deployment( + stage="precheck_build", + status="failed", + failure={"stage": "precheck_build", "code": 16227, "message": "pip failed", "details": {}, "hints": []}, + ) + mock = _mock().post(DEPLOY, deploy_accept(app_id="app-42")).get("/api/v2/apps/deployments/dep-1", failed) + code, _, _ = _run(["deploy", str(sample_project)], monkeypatch=monkeypatch, mock=mock) + + assert code != EXIT_OK + saved = json.loads((sample_project / ".bisheng" / "app.json").read_text(encoding="utf-8")) + assert saved["apps"][BASE]["app_id"] == "app-42" + + +def test_iterative_deploy_prints_target_app_name_and_id_before_upload( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + from bisheng_cli import project + + project.save_app_ref(sample_project, BASE, app_id="app-7", app_name="问卷小应用") + mock = _mock().post(DEPLOY, deploy_accept(app_id="app-7")).get("/api/v2/apps/deployments/dep-1", _waiting_seq()) + code, _, err = _run(["deploy", str(sample_project)], monkeypatch=monkeypatch, mock=mock) + + assert code == EXIT_OK + assert err.index("问卷小应用") < err.index("开始上传") + assert "app-7" in err + + +def test_interactive_confirm_skipped_by_yes_flag( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + from bisheng_cli import project + + def _explode(_prompt: str) -> str: # pragma: no cover - must never run + raise AssertionError("prompted despite --yes") + + monkeypatch.setattr("builtins.input", _explode) + project.save_app_ref(sample_project, BASE, app_id="app-7", app_name="问卷小应用") + mock = _mock().post(DEPLOY, deploy_accept(app_id="app-7")).get("/api/v2/apps/deployments/dep-1", _waiting_seq()) + code, _, _ = _run(["deploy", str(sample_project), "--yes"], monkeypatch=monkeypatch, mock=mock, tty=True) + assert code == EXIT_OK + + +def test_first_deploy_prints_app_id_and_entry_url_when_present_else_points_to_detail_page( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + with_url = _mock().post(DEPLOY, deploy_accept(entry_url="http://platform.test/apps/app-1")) + with_url.get("/api/v2/apps/deployments/dep-1", _waiting_seq()) + code, _, err = _run(["deploy", str(sample_project)], monkeypatch=monkeypatch, mock=with_url) + assert code == EXIT_OK and "http://platform.test/apps/app-1" in err + + (sample_project / ".bisheng" / "app.json").unlink() + without = _mock().post(DEPLOY, deploy_accept(entry_url=None)) + without.get("/api/v2/apps/deployments/dep-1", _waiting_seq()) + code, _, err = _run(["deploy", str(sample_project)], monkeypatch=monkeypatch, mock=without) + assert code == EXIT_OK + # Neither an empty string nor the word None: a pointer to where it lives. + assert "应用详情页" in err and "None" not in err + + +def test_26003_missing_app_manage_refused_and_names_the_required_scope( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + mock = _mock().post(DEPLOY, deploy_sync_err(26003, "scope missing", required="app:manage")) + code, _, err = _run(["deploy", str(sample_project)], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_FORBIDDEN + # `data.required` is one string. Joining it as a list prints "a, p, p, :, m…". + assert "app:manage" in err + + +def test_26051_delegate_key_refused_without_sending_anyone_to_an_admin_for_a_scope( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + """INV-31 at the deploy entrance: "another key", never "another checkbox". + + The failure this replaces was a loop. A delegate key got `26003`, whose next + step is "have an administrator tick the missing scope"; the administrator + then could not, because `delegate` + `app:manage` is refused at issue time + (26050). The only exit is a second key, and the message has to say so. + """ + mock = _mock().post(DEPLOY, deploy_sync_err(26051, "credential is delegation-only")) + code, _, err = _run(["deploy", str(sample_project)], monkeypatch=monkeypatch, mock=mock) + + assert code == EXIT_FORBIDDEN + assert "委托" in err and "另外签发" in err + assert "app:manage" not in err and "补勾" not in err + + +def test_16205_other_owner_refused_and_names_the_owner( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + mock = _mock().post(DEPLOY, deploy_sync_err(16205, "该应用归属于 张三", http_status=403, owner_user_name="张三")) + code, out, err = _run(["--json", "deploy", str(sample_project)], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_PUBLISH_CONFLICT + assert "张三" in err + result = json.loads(out.strip().splitlines()[-1]) + assert result["failure"]["details"]["owner_user_name"] == "张三" + + +def test_16251_in_flight_approval_refused_with_withdraw_path( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + mock = _mock().post(DEPLOY, deploy_sync_err(16251, "in flight", http_status=409)) + code, _, err = _run(["deploy", str(sample_project)], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_PUBLISH_CONFLICT + assert "应用详情页" in err and "撤回" in err + + +def test_16252_pending_online_refused(monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path) -> None: + mock = _mock().post(DEPLOY, deploy_sync_err(16252, "pending online", http_status=409)) + code, _, err = _run(["deploy", str(sample_project)], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_PUBLISH_CONFLICT + assert "待上线" in err + + +def test_16229_schema_change_requires_confirm_flag( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + mock = _mock().post(DEPLOY, deploy_sync_err(16229, "schema change unconfirmed", http_status=409)) + code, _, err = _run(["deploy", str(sample_project)], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_PUBLISH_CONFLICT + assert "--confirm-schema-change" in err + # Terminated at the synchronous response: no approval request was created, + # and no polling happened. + assert mock.paths_called().count(DEPLOY) == 1 + + +# ---- 16229: the one refusal that becomes a question (F055 AC-09) ----------- + +SCHEMA_ITEMS = [ + {"table": "orders", "column": "amount", "op": "modify_column"}, + {"table": "orders", "column": "note", "op": "drop_column"}, + {"table": "settings", "column": None, "op": "add_table"}, +] + + +def _schema_refusal() -> object: + """What the platform's `precheck_schema` gate answers on the upload itself.""" + return deploy_sync_err( + 16229, + "本次发布包含未确认的应用数据表结构变更(改列 / 删列)", + http_status=409, + details={ + "reason": "schema_change_unconfirmed", + "has_breaking": True, + "items": SCHEMA_ITEMS, + "breaking": SCHEMA_ITEMS[:2], + }, + hints=["改列 / 删列会影响线上已有数据, 请逐项确认后带上 --confirm-schema-change 重新发布"], + ) + + +def _deploy_bodies(mock: PlatformMock) -> list[bytes]: + return [call.content for call in mock.calls if call.url.path == DEPLOY] + + +def test_16229_without_tty_prints_the_diff_and_refuses_without_asking( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + """No terminal, no question: an unattended run must never confirm a data-losing change by itself.""" + + def _explode(_prompt: str) -> str: # pragma: no cover - must never run + raise AssertionError("prompted without a TTY") + + monkeypatch.setattr("builtins.input", _explode) + mock = _mock().post(DEPLOY, _schema_refusal()) + code, _, err = _run(["deploy", str(sample_project)], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_PUBLISH_CONFLICT + # The whole diff is on screen — breaking items flagged, the additive one listed too. + assert "改列 表 orders 列 amount" in err and "破坏性" in err + assert "删列 表 orders 列 note" in err + assert "加表 表 settings" in err + assert "--confirm-schema-change" in err + assert mock.paths_called().count(DEPLOY) == 1 + + +def test_16229_at_a_tty_yes_resends_the_same_package_with_the_flag( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + prompts: list[str] = [] + + def _answer_yes(prompt: str) -> str: + prompts.append(prompt) + return "y" + + monkeypatch.setattr("builtins.input", _answer_yes) + mock = ( + _mock().post(DEPLOY, [_schema_refusal(), deploy_accept()]).get("/api/v2/apps/deployments/dep-1", _waiting_seq()) + ) + code, _, err = _run(["deploy", str(sample_project)], monkeypatch=monkeypatch, mock=mock, tty=True) + + assert code == EXIT_OK + assert len(prompts) == 1 and "确认变更" in prompts[0] + first, second = _deploy_bodies(mock) + assert b'name="confirm_schema_change"' in first and b"false" in first + assert b'name="confirm_schema_change"' in second and b"true" in second + assert "重新上传" in err + + +def test_16229_at_a_tty_no_cancels_without_a_second_upload( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + monkeypatch.setattr("builtins.input", lambda _prompt: "n") + mock = _mock().post(DEPLOY, _schema_refusal()) + code, _, err = _run(["deploy", str(sample_project)], monkeypatch=monkeypatch, mock=mock, tty=True) + + assert code == EXIT_USAGE + assert "取消" in err and "--confirm-schema-change" in err + assert mock.paths_called().count(DEPLOY) == 1 + + +def test_16229_with_the_flag_already_sent_is_never_asked_again( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + """A platform that refuses a confirmed upload is broken; looping on the question would hide that.""" + + def _explode(_prompt: str) -> str: # pragma: no cover - must never run + raise AssertionError("asked again despite --confirm-schema-change") + + monkeypatch.setattr("builtins.input", _explode) + mock = _mock().post(DEPLOY, _schema_refusal()) + code, _, _ = _run( + ["deploy", str(sample_project), "--confirm-schema-change"], monkeypatch=monkeypatch, mock=mock, tty=True + ) + assert code == EXIT_PUBLISH_CONFLICT + assert mock.paths_called().count(DEPLOY) == 1 + + +def test_schema_change_items_reads_both_envelope_shapes() -> None: + from bisheng_cli.commands.deploy import schema_change_items + + envelope = {"exception": "x", "details": {"items": SCHEMA_ITEMS, "breaking": []}, "hints": []} + assert schema_change_items(envelope) == SCHEMA_ITEMS + assert schema_change_items({"items": SCHEMA_ITEMS}) == SCHEMA_ITEMS + assert schema_change_items({"details": {"items": "nonsense"}}) == [] + assert schema_change_items(None) == [] + + +def test_confirm_schema_change_flag_forwarded_as_form_field( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + mock = _mock().post(DEPLOY, deploy_accept()).get("/api/v2/apps/deployments/dep-1", _waiting_seq()) + _run(["deploy", str(sample_project), "--confirm-schema-change"], monkeypatch=monkeypatch, mock=mock) + body = next(call for call in mock.calls if call.url.path == DEPLOY).content + assert b'name="confirm_schema_change"' in body + assert b"true" in body + + +def test_16207_workshop_runtime_layer_absent_exits_8( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + mock = _mock().post(DEPLOY, deploy_sync_err(16207, "app runtime disabled", http_status=404)) + code, _, err = _run(["deploy", str(sample_project)], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_NOT_ENABLED + assert "应用工场" in err + + +@pytest.mark.parametrize("code", [16201, 16202, 16203]) +def test_16201_16202_16203_map_to_exit_6( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path, code: int +) -> None: + mock = _mock().post(DEPLOY, deploy_sync_err(code, f"gate {code}", http_status=400)) + exit_code, _, err = _run(["deploy", str(sample_project)], monkeypatch=monkeypatch, mock=mock) + assert exit_code == EXIT_LOCAL_INVALID + if code == 16203: + # 16203 after a passing local check can only mean the package root is + # not the project root; "create a manifest" would send the user in a + # circle, since they already have one. + assert "包根" in err + assert "创建" not in err + + +def test_sync_error_never_enters_polling_loop(monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path) -> None: + # The polling route is deliberately not registered: entering the loop hits + # the mock's "unexpected request" assertion instead of quietly succeeding. + mock = _mock().post(DEPLOY, deploy_sync_err(16221, "manifest invalid", http_status=400)) + code, _, _ = _run(["deploy", str(sample_project)], monkeypatch=monkeypatch, mock=mock) + assert code == 10 + assert not [path for path in mock.paths_called() if "deployments" in path] + + +# ---- AGENTS.md pointer ---------------------------------------------------- +# +# The pack itself must never be committed (its version follows the platform, so +# git would freeze it at whatever the contract was that week). A *pointer* is the +# opposite: it survives being committed, it reaches tools that have no skills +# directory at all, and a path does not go stale the way a copied contract does. + + +def _deploy_ok(project: Path, monkeypatch: pytest.MonkeyPatch, *extra: str) -> tuple[int, str, str]: + mock = _mock().post(DEPLOY, deploy_accept()).get("/api/v2/apps/deployments/dep-1", _waiting_seq()) + return _run(["deploy", str(project), "--yes", *extra], monkeypatch=monkeypatch, mock=mock) + + +def test_deploy_leaves_a_pointer_to_the_skill_pack( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + code, _, err = _deploy_ok(sample_project, monkeypatch) + assert code == EXIT_OK + text = (sample_project / "AGENTS.md").read_text(encoding="utf-8") + assert "deploy-hosting/SKILL.md" in text + assert "不要拷贝进本仓库" in text + assert "AGENTS.md" in err + + +def test_pointer_is_written_once(monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path) -> None: + _deploy_ok(sample_project, monkeypatch) + first = (sample_project / "AGENTS.md").read_text(encoding="utf-8") + _deploy_ok(sample_project, monkeypatch) + assert (sample_project / "AGENTS.md").read_text(encoding="utf-8") == first + + +def test_no_agents_note_opts_out(monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path) -> None: + code, _, _ = _deploy_ok(sample_project, monkeypatch, "--no-agents-note") + assert code == EXIT_OK + assert not (sample_project / "AGENTS.md").exists() + + +def test_dry_run_writes_nothing_into_the_working_tree( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + # "未上传,平台上什么都没有创建" has to include the developer's own tree. + code, _, _ = _run(["deploy", str(sample_project), "--dry-run"], monkeypatch=monkeypatch, mock=_mock()) + assert code == EXIT_OK + assert not (sample_project / "AGENTS.md").exists() + + +def test_failed_deploy_leaves_the_tree_untouched( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + # The pointer asserts "this project deploys to this platform"; until the + # platform accepts the package that is still a guess. + mock = _mock().post(DEPLOY, deploy_sync_err(16203, "包根不对", http_status=400)) + code, _, _ = _run(["deploy", str(sample_project), "--yes"], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_LOCAL_INVALID + assert not (sample_project / "AGENTS.md").exists() + + +def test_existing_agents_md_is_appended_never_rewritten( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + original = "# 我的项目\n\n本地约定若干。\n" + (sample_project / "AGENTS.md").write_text(original, encoding="utf-8") + _deploy_ok(sample_project, monkeypatch) + text = (sample_project / "AGENTS.md").read_text(encoding="utf-8") + assert text.startswith(original) + assert "deploy-hosting/SKILL.md" in text diff --git a/src/bisheng-cli/tests/test_command_deploy_wait.py b/src/bisheng-cli/tests/test_command_deploy_wait.py new file mode 100644 index 0000000000..b15fb34737 --- /dev/null +++ b/src/bisheng-cli/tests/test_command_deploy_wait.py @@ -0,0 +1,448 @@ +"""T023 — `bisheng deploy` polling, stage output and the `--wait` terminal states. + +Two invariants this file defends. + +**Stage order is the server's business.** The eleven stage values are a *set*, +not a sequence. The server already reordered `secret_scan` relative to the +precheck stages once; a CLI that asserts an order turns the next reordering into +a client-side bug and drags the README along with it. So the assertions here are +"any order survives" and "an unknown stage prints verbatim", never a fixed list. + +**`cancelled` and `exception` end the wait immediately.** An application deleted +mid-flight and an approval with no resolvable approver both produce requests that +will never reach a decision. Recognising only the four "normal" terminal states +means `--wait` polls until the timeout and then prints "this is not a failure, +keep waiting" — advice that is exactly backwards. +""" + +from __future__ import annotations + +import io +import json +from pathlib import Path + +import pytest + +from bisheng_cli import credentials +from bisheng_cli.commands import deploy as deploy_mod +from bisheng_cli.commands.deploy import ( + POLL_INTERVAL_MAX, + POLL_INTERVAL_START, + next_interval, +) +from bisheng_cli.errors import ( + EXIT_APPROVAL_EXCEPTION, + EXIT_CANCELLED, + EXIT_CAPACITY, + EXIT_OK, + EXIT_PENDING_ONLINE, + EXIT_PRECHECK_FAILED, + EXIT_REJECTED, + EXIT_SECRET_FOUND, + EXIT_UNKNOWN_CODE, + EXIT_WAIT_TIMEOUT, + EXIT_WITHDRAWN, +) +from bisheng_cli.main import run as main_run +from tests.helpers.platform_mock import ( + FAKE_KEY, + FAKE_KEY_MASK, + PlatformMock, + deploy_accept, + deploy_limits, + deployment, + deployment_seq, + failure_tuple, + use_mock_transport, +) + +BASE = "http://platform.test" +LIMITS = "/api/v2/apps/deploy-limits" +DEPLOY = "/api/v2/apps/deploy" +POLL = "/api/v2/apps/deployments/dep-1" + +# The authoritative enumeration (F055 design). Order below is F055's own writing +# order and is used here only as "a set of values", never as a schedule. +SERVER_STAGES = ( + "received", + "secret_scan", + "precheck_manifest", + "precheck_schema", + "precheck_build", + "precheck_probe", + "version_recorded", + "approval_created", + "approved", + "publishing", + "online", + "pending_online", +) + + +@pytest.fixture +def logged_in(home_dir) -> None: + credentials.save_profile( + BASE, + { + "api_key": FAKE_KEY, + "key_mask": FAKE_KEY_MASK, + "tenant_id": 1, + "service_account": {"id": 123, "name": "问卷小队开发号"}, + }, + ) + + +def _mock(polls: list) -> PlatformMock: + return PlatformMock().get(LIMITS, deploy_limits()).post(DEPLOY, deploy_accept()).get(POLL, deployment_seq(polls)) + + +def _run( + argv: list[str], *, monkeypatch: pytest.MonkeyPatch, mock: PlatformMock, sleeps: list[float] | None = None +) -> tuple[int, str, str]: + use_mock_transport(monkeypatch, deploy_mod, mock) + recorder = sleeps if sleeps is not None else [] + monkeypatch.setattr(deploy_mod, "_sleep", lambda seconds: recorder.append(seconds)) + out, err = io.StringIO(), io.StringIO() + code = main_run(argv, stdout=out, stderr=err) + return code, out.getvalue(), err.getvalue() + + +def _events(stdout: str) -> list[dict]: + return [json.loads(line) for line in stdout.splitlines() if line.strip()] + + +def _waiting(**kw): + payload = { + "stage": "approval_created", + "status": "waiting_approval", + "approval": {"instance_id": "ap-1", "status": "pending"}, + } + payload.update(kw) + return deployment(**payload) + + +def test_stage_events_in_server_order_received_first( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + # The very first polling response carries `received`. Leaving it out of the + # label table would drop every deploy's first line into the unknown-stage + # branch. + mock = _mock([deployment(stage="received", status="running"), _waiting()]) + code, out, err = _run(["--json", "deploy", str(sample_project)], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_OK + stages = [event["stage"] for event in _events(out) if event["event"] == "stage"] + assert stages[0] == "received" + assert "received" not in err # translated for humans, not echoed raw + assert "已接收" in err + + +def test_all_eleven_server_stages_translate_or_pass_through( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + polls = [deployment(stage=stage, status="running") for stage in SERVER_STAGES] + polls.append(_waiting()) + mock = _mock(polls) + code, out, _ = _run(["--json", "deploy", str(sample_project)], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_OK + stages = [event["stage"] for event in _events(out) if event["event"] == "stage"] + assert set(SERVER_STAGES) <= set(stages) + # `approval_created` stays the machine value; the Chinese phrase is display + # only, and swapping them makes `--json` disagree with the server. + assert "approval_created" in stages + + +def test_stage_translation_is_a_mapping_not_an_ordered_sequence( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + shuffled = ["precheck_probe", "secret_scan", "received", "precheck_build", "version_recorded"] + polls = [deployment(stage=stage, status="running") for stage in shuffled] + polls.append(_waiting()) + code, out, _ = _run(["--json", "deploy", str(sample_project)], monkeypatch=monkeypatch, mock=_mock(polls)) + assert code == EXIT_OK + stages = [event["stage"] for event in _events(out) if event["event"] == "stage"] + assert stages[: len(shuffled)] == shuffled + + +def test_unknown_stage_printed_verbatim_not_an_error( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + polls = [deployment(stage="quantum_review", status="running"), _waiting()] + code, out, err = _run(["--json", "deploy", str(sample_project)], monkeypatch=monkeypatch, mock=_mock(polls)) + assert code == EXIT_OK + assert "quantum_review" in err + assert "quantum_review" in [e.get("stage") for e in _events(out)] + + +def test_any_failed_stage_stops_polling_and_exits_nonzero( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + failed = deployment( + stage="precheck_build", + status="failed", + failure=failure_tuple("precheck_build", 16227, "pip install failed"), + ) + mock = _mock([deployment(stage="received", status="running"), failed, _waiting()]) + code, _, _ = _run(["deploy", str(sample_project)], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_PRECHECK_FAILED + # Two polls, then stop — the third response was never fetched. + assert mock.paths_called().count(POLL) == 2 + + +@pytest.mark.parametrize("platform_code", [16125, 16226, 19999]) +@pytest.mark.parametrize("json_output", [False, True]) +def test_preapproval_capacity_failure_keeps_raw_code_and_unknown_fallback( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path, platform_code: int, json_output: bool +) -> None: + """F059 T264 — coverage AC-02, AC-08: real deploy polling preserves the failure contract.""" + failure = failure_tuple("precheck_build", platform_code, "Insufficient runtime capacity") + failed = deployment(stage="precheck_build", status="failed", failure=failure) + mock = _mock([failed, _waiting()]) + argv = (["--json"] if json_output else []) + ["deploy", str(sample_project), "--wait"] + code, out, err = _run(argv, monkeypatch=monkeypatch, mock=mock) + expected = EXIT_UNKNOWN_CODE if platform_code == 19999 else EXIT_CAPACITY + assert code == expected + assert mock.paths_called().count(POLL) == 1 + assert FAKE_KEY not in out + err + if json_output: + assert _events(out)[-1]["failure"] == failure + else: + assert str(platform_code) in err + assert ("未登记" if platform_code == 19999 else "容量不足") in err + + +def test_failure_tuple_passed_through_untouched_in_json( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + tuple_ = failure_tuple("precheck_manifest", 16221, "missing field", missing=["port"]) + mock = _mock([deployment(stage="precheck_manifest", status="failed", failure=tuple_)]) + code, out, _ = _run(["--json", "deploy", str(sample_project)], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_PRECHECK_FAILED + result = _events(out)[-1] + assert result["event"] == "result" + # Byte for byte: `details` and `hints` are the agent's whole repair input. + assert result["failure"] == tuple_ + + +def test_precheck_failure_prints_missing_items_and_hints_lines( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + tuple_ = failure_tuple("precheck_manifest", 16221, "manifest 缺少 port", missing=["port"]) + mock = _mock([deployment(stage="precheck_manifest", status="failed", failure=tuple_)]) + code, _, err = _run(["deploy", str(sample_project)], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_PRECHECK_FAILED + assert "manifest 缺少 port" in err + assert "hint for 16221" in err + + +def test_16241_scan_hit_prints_file_line_only_never_the_value( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + secret = "bs-sak-" + "z" * 20 + tuple_ = failure_tuple( + "secret_scan", + 16241, + "扫描命中", + hits=[{"rule_id": "bisheng-service-account-key", "file": "config/settings.py", "line": 12}], + ) + mock = _mock([deployment(stage="secret_scan", status="failed", failure=tuple_)]) + (sample_project / "config").mkdir(exist_ok=True) + (sample_project / "config" / "settings.py").write_text(f'KEY = "{secret}"\n', encoding="utf-8") + + code, out, err = _run(["--json", "deploy", str(sample_project)], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_SECRET_FOUND + assert "config/settings.py:12" in err + # The server withholds even a redacted value on purpose; reading the line + # back out of the local file would hand back what it refused to give. + assert secret not in err and secret not in out + + +def test_default_returns_0_at_waiting_approval_with_three_tracking_paths( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + mock = _mock([deployment(stage="received", status="running"), _waiting()]) + code, _, err = _run(["deploy", str(sample_project)], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_OK + assert "ap-1" in err + assert "应用详情页" in err and "MCP" in err and "deploy --wait" in err + + +def test_wait_online_exits_0_with_entry_url_or_pointer( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + online = deployment( + stage="online", + status="succeeded", + approval={"instance_id": "ap-1", "status": "executed"}, + app_state="已上线", + ) + mock = _mock([deployment(stage="publishing", status="running"), online]) + code, _, err = _run(["deploy", str(sample_project), "--wait"], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_OK + # An older platform omits `entry_url` from the poll; the pointer is then the + # honest answer rather than a printed `None`. + assert "应用详情页" in err and "None" not in err + + +def test_wait_online_prints_the_entry_url_when_the_poll_carries_it( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + """The address is the one thing the developer actually wants at this moment. + + It used to be unreachable here: `entry_url` lived only on the POST response, + where a first deploy is still a draft and the value was null. F055's + write-back puts it on every poll, so `--wait` can end with a real link + instead of sending the developer to a page to look it up. + """ + online = deployment( + stage="online", + status="succeeded", + approval={"instance_id": "ap-1", "status": "executed"}, + app_state="已上线", + entry_url="https://bisheng.example.com/apps/form-survey", + ) + mock = _mock([deployment(stage="publishing", status="running"), online]) + code, _, err = _run(["deploy", str(sample_project), "--wait"], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_OK + assert "https://bisheng.example.com/apps/form-survey" in err + + +def test_wait_rejected_exits_20_with_full_reason( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + reason = "驳回理由" + ",需要补充数据留存说明并说明外呼域名" * 5 + rejected = deployment( + stage="approval_created", + status="waiting_approval", + approval={"instance_id": "ap-1", "status": "rejected", "reject_reason": reason}, + ) + code, _, err = _run(["deploy", str(sample_project), "--wait"], monkeypatch=monkeypatch, mock=_mock([rejected])) + assert code == EXIT_REJECTED + assert reason in err # never truncated: it is the instruction for the retry + + +@pytest.mark.parametrize( + ("approval_status", "reason_kind", "exit_code"), + [("rejected", "approval_rejected", EXIT_REJECTED), ("withdrawn", "approval_withdrawn", EXIT_WITHDRAWN)], +) +def test_a_decided_attempt_reads_as_the_decision_not_as_a_failure( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path, approval_status, reason_kind, exit_code +) -> None: + """The shape the platform really sends once a person decided against a release. + + ``PublishTerminalService`` also closes the attempt as ``failed`` with a + code-less failure tuple. Read in that order, every rejection used to exit + 19 ("unknown code") and the reason was never printed. + """ + reason = "接口缺少鉴权,请补上后重新提交。" if approval_status == "rejected" else None + closed = deployment( + stage="approved", + status="failed", + failure={ + "stage": "approved", + "code": None, + "message": "发布审批被驳回", + "details": {"reason": reason_kind}, + "hints": ["修改后可重新执行 bisheng deploy 提交新的发布"], + }, + approval={"instance_id": 7, "status": approval_status, "reject_reason": reason}, + ) + code, _, err = _run(["deploy", str(sample_project), "--wait"], monkeypatch=monkeypatch, mock=_mock([closed])) + assert code == exit_code + if reason: + assert reason in err + + +def test_wait_withdrawn_exits_21(monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path) -> None: + withdrawn = deployment( + stage="approval_created", + status="waiting_approval", + approval={"instance_id": "ap-1", "status": "withdrawn"}, + ) + code, _, err = _run(["deploy", str(sample_project), "--wait"], monkeypatch=monkeypatch, mock=_mock([withdrawn])) + assert code == EXIT_WITHDRAWN + assert "应用详情页" in err + + +def test_wait_pending_online_exits_22_with_manual_publish_path( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + pending = deployment( + stage="pending_online", + status="succeeded", + approval={"instance_id": "ap-1", "status": "executed"}, + app_state="待上线", + pending_reason="capacity", + ) + code, _, err = _run(["deploy", str(sample_project), "--wait"], monkeypatch=monkeypatch, mock=_mock([pending])) + assert code == EXIT_PENDING_ONLINE + assert "capacity" in err and "手动上线" in err + + +def test_wait_cancelled_exits_24_immediately_not_timeout( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + cancelled = deployment( + stage="approval_created", + status="running", + approval={"instance_id": "ap-1", "status": "cancelled"}, + ) + sleeps: list[float] = [] + code, _, err = _run( + ["deploy", str(sample_project), "--wait"], monkeypatch=monkeypatch, mock=_mock([cancelled]), sleeps=sleeps + ) + assert code == EXIT_CANCELLED + assert sleeps == [] # decided on the first poll, no waiting at all + assert "已被删除" in err and "永远不会有结论" in err + + +def test_wait_exception_exits_25_immediately_not_timeout( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + exception = deployment( + stage="approval_created", + status="running", + approval={"instance_id": "ap-1", "status": "exception"}, + ) + sleeps: list[float] = [] + code, _, err = _run( + ["deploy", str(sample_project), "--wait"], + monkeypatch=monkeypatch, + mock=_mock([exception]), + sleeps=sleeps, + ) + assert code == EXIT_APPROVAL_EXCEPTION + assert sleeps == [] + assert "审批人" in err and "管理员" in err + + +def test_wait_timeout_exits_23_and_says_not_a_failure( + monkeypatch: pytest.MonkeyPatch, logged_in, sample_project: Path +) -> None: + pending = deployment( + stage="approval_created", + status="waiting_approval", + approval={"instance_id": "ap-1", "status": "pending"}, + ) + code, _, err = _run( + ["deploy", str(sample_project), "--wait", "--wait-timeout", "0"], + monkeypatch=monkeypatch, + mock=_mock([pending]), + ) + assert code == EXIT_WAIT_TIMEOUT + # Timeout and rejection lead to opposite actions — keep waiting vs change the + # code — so the copy has to separate them explicitly. + assert "这不是失败" in err + + +def test_backoff_2s_x1_5_capped_10s() -> None: + schedule: list[float] = [] + interval = POLL_INTERVAL_START + for poll in range(1, 31): + schedule.append(interval) + interval = next_interval(interval, poll) + + assert schedule[:5] == [2.0] * 5 + assert schedule[5:10] == [3.0] * 5 + assert schedule[10:15] == [4.5] * 5 + assert max(schedule) == POLL_INTERVAL_MAX == 10.0 + assert schedule[-1] == 10.0 diff --git a/src/bisheng-cli/tests/test_command_dev.py b/src/bisheng-cli/tests/test_command_dev.py new file mode 100644 index 0000000000..616f45e5b7 --- /dev/null +++ b/src/bisheng-cli/tests/test_command_dev.py @@ -0,0 +1,374 @@ +"""T044 — `bisheng dev`: pre-checks in order, then a wired local run. + +The refusal cases are the interesting contract (AC-29 / AC-51 / AC-53): each +one has to stop *before* the next step happens — no request without a +credential, no start without a manifest, no process without a platform that +accepts the key — and say which step failed. The happy path is exercised twice: +with the process and session replaced by fakes (what gets printed, AC-24), and +once for real against a stdlib echo app (what actually reaches the app). +""" + +from __future__ import annotations + +import http.client +import io +import json +import shutil +import time +from pathlib import Path + +import httpx +import pytest + +from bisheng_cli import credentials, devdb, project +from bisheng_cli.commands import dev as dev_mod +from bisheng_cli.errors import ( + EXIT_AUTH, + EXIT_LOCAL_INVALID, + EXIT_NOT_ENABLED, + EXIT_NOT_LOGGED_IN, + EXIT_OK, + EXIT_UNREACHABLE, + EXIT_USAGE, +) +from bisheng_cli.main import run as main_run +from tests.helpers.platform_mock import ( + FAKE_KEY, + FAKE_KEY_MASK, + FAKE_MODEL_BASE_URL, + PlatformMock, + env_ok, + use_mock_transport, + versions_404, + versions_ok, + whoami_err, + whoami_ok, +) + +BASE = "http://platform.test" +WHOAMI = "/api/v2/auth/whoami" +VERSIONS = "/api/v1/dev-toolkit/versions" +FIXTURES = Path(__file__).parent / "fixtures" + +# Captured at import, before conftest's `no_network` replaces it: the one test +# that talks to a real child process on loopback puts it back for the proxy's +# upstream hop. The platform side of that test stays mocked. +_REAL_HANDLE_REQUEST = httpx.HTTPTransport.handle_request + + +@pytest.fixture +def logged_in(home_dir) -> None: + credentials.save_profile( + BASE, + {"api_key": FAKE_KEY, "key_mask": FAKE_KEY_MASK, "actor_kind": "service_account", "actor_name": "旧名字"}, + ) + + +@pytest.fixture +def echo_project(tmp_path: Path) -> Path: + root = tmp_path / "echo" + shutil.copytree(FIXTURES / "echo_app", root) + return root + + +class _FakeProcess: + """A Popen stand-in that never exits on its own.""" + + def __init__(self) -> None: + self.signals: list[int] = [] + self.returncode: int | None = None + + def poll(self) -> int | None: + return self.returncode + + def send_signal(self, sig: int) -> None: + self.signals.append(sig) + self.returncode = 0 + + def wait(self, timeout: float | None = None) -> int: + return self.returncode or 0 + + def kill(self) -> None: + self.returncode = -9 + + +def _fake_launch(monkeypatch: pytest.MonkeyPatch) -> dict: + """Replace the process + the blocking session; capture what `dev` handed them.""" + captured: dict = {} + + def spawn(start, root, env): + captured["start"], captured["root"], captured["env"] = start, root, env + captured["process"] = _FakeProcess() + return captured["process"] + + def session(process, proxy, emitter): + captured["proxy_url"] = proxy.url + return EXIT_OK + + monkeypatch.setattr(dev_mod, "spawn_app", spawn) + monkeypatch.setattr(dev_mod, "run_session", session) + return captured + + +def _run(argv: list[str], *, monkeypatch: pytest.MonkeyPatch, mock: PlatformMock) -> tuple[int, str, str]: + use_mock_transport(monkeypatch, dev_mod, mock) + out, err = io.StringIO(), io.StringIO() + code = main_run(argv, stdout=out, stderr=err) + return code, out.getvalue(), err.getvalue() + + +def _free_port() -> int: + return devdb.pick_free_port() + + +# ---- refusals, in pre-check order --------------------------------------------- + + +def test_not_logged_in_exits_3_with_zero_requests( + monkeypatch: pytest.MonkeyPatch, home_dir, echo_project: Path +) -> None: + captured = _fake_launch(monkeypatch) + mock = PlatformMock() + code, _, err = _run(["dev", str(echo_project)], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_NOT_LOGGED_IN + assert "bisheng login" in err + assert mock.paths_called() == [] and "process" not in captured + + +def test_missing_manifest_field_exits_6_naming_it_before_any_request( + monkeypatch: pytest.MonkeyPatch, logged_in, echo_project: Path +) -> None: + captured = _fake_launch(monkeypatch) + (echo_project / "bisheng-app.yaml").write_text("name: x\nruntime: python3.11\n", encoding="utf-8") + mock = PlatformMock() + code, _, err = _run(["dev", str(echo_project)], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_LOCAL_INVALID + assert "port" in err and "缺少必填项" in err + assert mock.paths_called() == [] and "process" not in captured + + +def test_missing_manifest_file_exits_6(monkeypatch: pytest.MonkeyPatch, logged_in, echo_project: Path) -> None: + _fake_launch(monkeypatch) + (echo_project / "bisheng-app.yaml").unlink() + code, _, err = _run(["dev", str(echo_project)], monkeypatch=monkeypatch, mock=PlatformMock()) + assert code == EXIT_LOCAL_INVALID + assert "bisheng-app.yaml" in err + + +def test_invalid_key_exits_4_and_starts_nothing(monkeypatch: pytest.MonkeyPatch, logged_in, echo_project: Path) -> None: + captured = _fake_launch(monkeypatch) + mock = PlatformMock().get(VERSIONS, versions_ok()).get(WHOAMI, whoami_err(26002)) + code, _, err = _run(["dev", str(echo_project)], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_AUTH + assert "26002" in err + assert "process" not in captured + assert not (echo_project / ".bisheng" / "dev").exists() + + +def test_platform_unreachable_exits_7(monkeypatch: pytest.MonkeyPatch, logged_in, echo_project: Path) -> None: + captured = _fake_launch(monkeypatch) + mock = PlatformMock().get(VERSIONS, httpx.ConnectError("refused")) + code, _, err = _run(["dev", str(echo_project)], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_UNREACHABLE + assert "不可达" in err and "process" not in captured + + +def test_layer_absent_exits_8_before_the_key_is_sent( + monkeypatch: pytest.MonkeyPatch, logged_in, echo_project: Path +) -> None: + _fake_launch(monkeypatch) + mock = PlatformMock().get(VERSIONS, versions_404()).get("/api/v1/env", env_ok(open_platform_enabled=False)) + code, _, _ = _run(["dev", str(echo_project)], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_NOT_ENABLED + assert WHOAMI not in mock.paths_called() + + +def test_no_scope_check_at_all(monkeypatch: pytest.MonkeyPatch, logged_in, echo_project: Path) -> None: + # AC-29: a key with no permission bit ticked still runs the app locally. + _fake_launch(monkeypatch) + mock = PlatformMock().get(VERSIONS, versions_ok()).get(WHOAMI, whoami_ok(scopes=[])) + code, _, _ = _run(["dev", str(echo_project), "--port", str(_free_port())], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_OK + + +def test_busy_entry_port_is_exit_2_and_spawns_nothing( + monkeypatch: pytest.MonkeyPatch, logged_in, echo_project: Path +) -> None: + # The entry port is bound before the app is spawned: a port already in use + # (8080 usually is) must not cost a child process started and then killed. + import socket + + captured = _fake_launch(monkeypatch) + holder = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + holder.bind(("127.0.0.1", 0)) + holder.listen(1) + busy = holder.getsockname()[1] + try: + mock = PlatformMock().get(VERSIONS, versions_ok()).get(WHOAMI, whoami_ok()) + code, _, err = _run(["dev", str(echo_project), "--port", str(busy)], monkeypatch=monkeypatch, mock=mock) + finally: + holder.close() + assert code == EXIT_USAGE + assert str(busy) in err and "--port" in err + assert "process" not in captured + + +def test_same_port_for_proxy_and_app_is_a_usage_error( + monkeypatch: pytest.MonkeyPatch, logged_in, echo_project: Path +) -> None: + captured = _fake_launch(monkeypatch) + mock = PlatformMock().get(VERSIONS, versions_ok()).get(WHOAMI, whoami_ok()) + code, _, _ = _run( + ["dev", str(echo_project), "--port", "9000", "--app-port", "9000"], monkeypatch=monkeypatch, mock=mock + ) + assert code == EXIT_USAGE and "process" not in captured + + +# ---- happy path: what is printed and what is injected -------------------------- + + +def test_a_start_command_only_the_shell_knows_is_called_out( + monkeypatch: pytest.MonkeyPatch, logged_in, echo_project: Path +) -> None: + """``BISHENG_APP_START`` runs locally, but nothing can set it on the platform.""" + _fake_launch(monkeypatch) + monkeypatch.setenv("BISHENG_APP_START", "python main.py") + mock = PlatformMock().get(VERSIONS, versions_ok()).get(WHOAMI, whoami_ok()) + code, _, err = _run(["dev", str(echo_project), "--port", str(_free_port())], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_OK + assert "BISHENG_APP_START" in err and "Procfile" in err + + +def test_output_names_identity_source_platform_and_local_url_never_the_key( + monkeypatch: pytest.MonkeyPatch, logged_in, echo_project: Path +) -> None: + captured = _fake_launch(monkeypatch) + project.save_app_ref(echo_project, BASE, app_id="app-42") + port = _free_port() + mock = PlatformMock().get(VERSIONS, versions_ok()).get(WHOAMI, whoami_ok(actor_name="问卷小队开发号")) + code, out, err = _run( + ["dev", str(echo_project), "--port", str(port), "--json", "--verbose"], monkeypatch=monkeypatch, mock=mock + ) + assert code == EXIT_OK + # AC-24: the *fresh* whoami name, the platform, the local URL. + assert "问卷小队开发号" in err and "旧名字" not in err + assert BASE in err and f"http://127.0.0.1:{port}" in err + assert "服务账号" in err + assert FAKE_KEY not in err and FAKE_KEY not in out + + events = [json.loads(line) for line in out.splitlines() if line.strip()] + ready = next(e for e in events if e["event"] == "stage" and e["stage"] == "ready") + # Opened under the same prefix the app has hosted (manifest slug "echo"). + assert ready["data"]["local_url"] == f"http://127.0.0.1:{port}/apps/echo/" == captured["proxy_url"] + assert ready["data"]["base_path"] == "/apps/echo" + assert ready["data"]["app_id"] == "app-42" and ready["data"]["actor_name"] == "问卷小队开发号" + assert events[-1]["event"] == "result" and events[-1]["ok"] is True + + env = captured["env"] + assert env["BISHENG_APP_ID"] == "app-42" and env["BISHENG_APP_SLUG"] == "echo" + assert env["PORT"] == env["BISHENG_APP_PORT"] == str(ready["data"]["app_port"]) != str(port) + assert env["BISHENG_APP_DB_PATH"] == str(echo_project / ".bisheng" / "dev" / "app.db") + # The key reaches the app under exactly one name — `OPENAI_API_KEY`, the + # platform's name for "the credential this process calls the model face + # with" (AC-27; hosted it carries the app's own token instead). Never under + # `BISHENG_API_KEY`, and never anywhere else. + assert "BISHENG_API_KEY" not in env + assert [name for name, value in env.items() if value == FAKE_KEY] == ["OPENAI_API_KEY"] + assert env["OPENAI_BASE_URL"] == env["BISHENG_MODEL_BASE_URL"] == FAKE_MODEL_BASE_URL + assert captured["start"].source == "main.py" and captured["root"] == echo_project.resolve() + # Teardown stopped the process. + assert captured["process"].signals + + +def test_no_as_or_identity_override_on_dev() -> None: + from bisheng_cli.cli import build_parser + + parser = build_parser() + dev = next(sub for name, sub in _subparsers(parser) if name == "dev") + options = {s for a in dev._actions for s in a.option_strings} + assert "--as" not in options + assert not [o for o in options if "user" in o or "behalf" in o or "impersonat" in o] + + +def _subparsers(parser): + for action in parser._actions: + if hasattr(action, "choices") and isinstance(action.choices, dict): + yield from action.choices.items() + + +def test_platform_flag_selects_the_profile_for_dev( + monkeypatch: pytest.MonkeyPatch, home_dir, echo_project: Path +) -> None: + captured = _fake_launch(monkeypatch) + other = "http://other.test" + credentials.save_profile(BASE, {"api_key": FAKE_KEY}) + credentials.save_profile(other, {"api_key": FAKE_KEY}) # current + mock = PlatformMock().get(VERSIONS, versions_ok()).get(WHOAMI, whoami_ok()) + code, _, err = _run( + ["dev", str(echo_project), "--platform", BASE, "--port", str(_free_port())], monkeypatch=monkeypatch, mock=mock + ) + assert code == EXIT_OK + assert captured["env"]["BISHENG_PLATFORM_API_BASE"] == BASE + assert BASE in err + + +def test_app_that_exits_is_reported_as_exit_6(monkeypatch: pytest.MonkeyPatch, logged_in, echo_project: Path) -> None: + class _Crashing(_FakeProcess): + def poll(self): + return 1 + + monkeypatch.setattr(dev_mod, "spawn_app", lambda start, root, env: _Crashing()) + monkeypatch.setattr(dev_mod.time, "sleep", lambda s: None) + mock = PlatformMock().get(VERSIONS, versions_ok()).get(WHOAMI, whoami_ok()) + code, _, err = _run(["dev", str(echo_project), "--port", str(_free_port())], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_LOCAL_INVALID + assert "16228" in err + + +# ---- the real thing: a stdlib app behind the proxy ----------------------------- + + +def test_end_to_end_headers_and_env_reach_a_real_app( + monkeypatch: pytest.MonkeyPatch, logged_in, echo_project: Path +) -> None: + """Spawn the echo app for real; the session body makes one request through the proxy.""" + # The proxy's upstream hop is a real loopback connection to the child + # process; the platform mock stays on the command module's client. + monkeypatch.setattr(httpx.HTTPTransport, "handle_request", _REAL_HANDLE_REQUEST) + port = _free_port() + seen: dict = {} + + def session(process, proxy, emitter): + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + try: + conn = http.client.HTTPConnection("127.0.0.1", proxy.listen_port, timeout=3) + conn.request("GET", "/apps/echo/echo?q=1", headers={"X_BiSheng_User_Id": "forged", "X-Custom": "kept"}) + response = conn.getresponse() + if response.status == 200: + seen.update(json.loads(response.read())) + conn.close() + return EXIT_OK + conn.close() + except OSError: + pass + time.sleep(0.2) + raise AssertionError("the echo app never answered through the proxy") + + monkeypatch.setattr(dev_mod, "run_session", session) + mock = PlatformMock().get(VERSIONS, versions_ok()).get(WHOAMI, whoami_ok(actor_id=123, tenant_id=7)) + code, _, _ = _run(["dev", str(echo_project), "--port", str(port)], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_OK + assert seen["path"] == "/echo?q=1" + headers = seen["headers"] + assert headers["x-bisheng-user-id"] == "123" and headers["x-bisheng-tenant-id"] == "7" + assert headers["x-bisheng-subject-kind"] == "service_account" + assert headers["x-custom"] == "kept" + assert headers["x-bisheng-access-token"].startswith("bsdev.") + assert "x-bisheng-dept-id" not in headers + env = seen["env"] + assert headers["x-forwarded-prefix"] == "/apps/echo" + assert env["BISHENG_APP_BASE_PATH"] == "/apps/echo" and env["BISHENG_PLATFORM_API_BASE"] == BASE + assert env["PORT"] == env["BISHENG_APP_PORT"] and env["PORT"] != str(port) + assert "BISHENG_API_KEY" not in env and FAKE_KEY not in json.dumps(seen) + assert (echo_project / ".bisheng" / "dev" / "app.db").is_file() diff --git a/src/bisheng-cli/tests/test_command_login.py b/src/bisheng-cli/tests/test_command_login.py new file mode 100644 index 0000000000..5ef01d6775 --- /dev/null +++ b/src/bisheng-cli/tests/test_command_login.py @@ -0,0 +1,336 @@ +"""T019 — `bisheng login`: probe, whoami, delegate refusal, credential write. + +Two things about this file are worth knowing before changing it. + +**The delegate cases can only ever be mocked.** F049 deliberately does not +register the `delegate` scope (`open_api/domain/scopes.py` NOTE: "ships with +F050"), so no key that exists today can carry it. Going to 114 to "verify AC-09" +proves nothing and will be read as "the feature does not work". + +**`26002` covers four causes with one code.** Unknown, revoked and expired keys +are indistinguishable on the wire — and so is a disabled or deleted service +account, which the beta2 server folds into the same code +(`credential_validator.resolve_service_account`) instead of the separate `26027` +F049 used to raise here. The server sends no signal that separates them, so the +CLI must not pretend to: what this file asserts is that `26001` and `26002` read +differently, and that `26002` names the disabled-account case rather than +telling the developer to go get yet another key. +""" + +from __future__ import annotations + +import io +import json +from types import SimpleNamespace + +import httpx +import pytest + +from bisheng_cli.commands import login as login_mod +from bisheng_cli.commands import skills as skills_mod +from bisheng_cli.errors import ( + EXIT_AUTH, + EXIT_FORBIDDEN, + EXIT_NOT_ENABLED, + EXIT_OK, + EXIT_UNREACHABLE, + EXIT_USAGE, + CliError, +) +from bisheng_cli.main import run as main_run +from tests.helpers.platform_mock import ( + DEFAULT_PACKS, + FAKE_KEY, + FAKE_PAT, + PlatformMock, + env_ok, + serve_default_packs, + skills_path, + use_mock_transport, + versions_404, + versions_ok, + whoami_err, + whoami_ok, + whoami_without_resource_owner, +) + +BASE = "http://platform.test" +WHOAMI = "/api/v2/auth/whoami" +VERSIONS = "/api/v1/dev-toolkit/versions" + + +@pytest.fixture(autouse=True) +def _no_ambient_key(monkeypatch: pytest.MonkeyPatch) -> None: + """A developer's own `BISHENG_API_KEY` must not leak into the suite.""" + monkeypatch.delenv("BISHENG_API_KEY", raising=False) + + +def _mock(whoami: httpx.Response | None = None) -> PlatformMock: + mock = PlatformMock().get(VERSIONS, versions_ok()) + if whoami is not None: + mock.get(WHOAMI, whoami) + # A successful login now auto-syncs the skill packs (AC-08). Serve the + # packs so the happy path exercises the real end-to-end shape; failure + # tests never reach these routes because login raises before the sync. + serve_default_packs(mock) + return mock + + +def _run(argv: list[str], *, monkeypatch: pytest.MonkeyPatch, mock: PlatformMock) -> tuple[int, str, str]: + use_mock_transport(monkeypatch, login_mod, mock) + # login's auto-sync builds its client inside the skills module, so that name + # needs the mock transport too. + use_mock_transport(monkeypatch, skills_mod, mock) + out, err = io.StringIO(), io.StringIO() + code = main_run(argv, stdout=out, stderr=err) + return code, out.getvalue(), err.getvalue() + + +def test_success_writes_profile_and_prints_platform_account_owner_mask_expiry( + monkeypatch: pytest.MonkeyPatch, home_dir +) -> None: + mock = _mock(whoami_ok(resource_owner={"user_id": 7})) + code, _, err = _run(["login", BASE, "--api-key", FAKE_KEY], monkeypatch=monkeypatch, mock=mock) + + assert code == EXIT_OK + # AC-06: platform address, the account name and its resource owner. The + # owner reaches the CLI as an id and nothing else (`WhoamiResourceOwner` has + # exactly one field), so an id is what gets printed — inventing a name here + # would be inventing it about the person who ends up owning every app. + for expected in (BASE, "问卷小队开发号", "用户 #7", "2026-12-31"): + assert expected in err + + stored = json.loads((home_dir / ".bisheng" / "credentials.json").read_text(encoding="utf-8")) + profile = stored["profiles"][BASE] + assert stored["current"] == BASE + assert profile["api_key"] == FAKE_KEY + assert profile["resource_owner"] == {"user_id": 7} + assert (profile["actor_kind"], profile["actor_id"], profile["actor_name"]) == ( + "service_account", + 123, + "问卷小队开发号", + ) + # AC-52: a cached scope set can only ever produce "the admin ticked the box + # but the CLI still says no". + assert "scopes" not in profile + + +def test_personal_token_is_not_reported_as_a_service_account(monkeypatch: pytest.MonkeyPatch, home_dir) -> None: + """A `bs-pat-` token authenticates on the same endpoint and answers `natural_person`. + + The CLI asks for a service-account key but cannot stop anyone pasting the + other kind, and beta2 accepts it (`credential_validator._TOKEN_RE`). Printing + its holder under "服务账号" would name the wrong subject entirely. + """ + mock = _mock(whoami_ok(actor_kind="natural_person", actor_id=7, actor_name="李开发", scopes=[])) + code, _, err = _run(["login", BASE, "--api-key", FAKE_PAT], monkeypatch=monkeypatch, mock=mock) + + assert code == EXIT_OK + assert "个人访问令牌" in err and "李开发" in err + assert "服务账号: 李开发" not in err + + +def test_login_says_so_when_the_key_has_no_resource_owner(monkeypatch: pytest.MonkeyPatch, home_dir) -> None: + # `resource_owner` is nullable on the wire, and a credential in that state + # cannot publish anything: `resource_owner_of()` refuses the first deploy + # with 16205 rather than creating an application owned by nobody. Login is + # where that is cheap to say — ten minutes before the deploy that fails. + mock = _mock(whoami_without_resource_owner()) + code, _, err = _run(["login", BASE, "--api-key", FAKE_KEY], monkeypatch=monkeypatch, mock=mock) + + assert code == EXIT_OK + assert "资源归属人" in err and "服务账号详情页" in err + assert "deploy" in err + + +def test_no_scope_check_at_all(monkeypatch: pytest.MonkeyPatch, home_dir) -> None: + # whoami is the one endpoint under /api/v2 that requires no scope at all; + # login must not add a client-side check the server does not make. + mock = _mock(whoami_ok(scopes=[])) + code, _, _ = _run(["login", BASE, "--api-key", FAKE_KEY], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_OK + + +def test_delegate_scope_refused_before_writing_credentials(monkeypatch: pytest.MonkeyPatch, home_dir) -> None: + mock = _mock(whoami_ok(scopes=["delegate", "app:manage"])) + code, _, err = _run(["login", BASE, "--api-key", FAKE_KEY], monkeypatch=monkeypatch, mock=mock) + + assert code == EXIT_FORBIDDEN + assert "委托" in err + assert not (home_dir / ".bisheng" / "credentials.json").exists() + + +def test_delegate_refusal_is_not_a_silent_fallback_to_mode_s(monkeypatch: pytest.MonkeyPatch, home_dir) -> None: + # INV-31: the channel entry point rejects by scope. Degrading to "well, it + # still works as a plain key" is the failure mode the invariant exists for. + mock = _mock(whoami_ok(scopes=["delegate"])) + code, _, err = _run(["login", BASE, "--api-key", FAKE_KEY], monkeypatch=monkeypatch, mock=mock) + assert code != EXIT_OK + assert "登录成功" not in err + + +def test_delegate_refusal_is_not_a_bare_param_error(monkeypatch: pytest.MonkeyPatch, home_dir) -> None: + mock = _mock(whoami_ok(scopes=["delegate"])) + code, _, err = _run(["login", BASE, "--api-key", FAKE_KEY], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_FORBIDDEN and code != EXIT_USAGE + assert "本地开发" in err or "另外签发" in err + + +def test_delegate_key_is_refused_at_whoami_not_dropped_as_an_unknown_code( + monkeypatch: pytest.MonkeyPatch, home_dir +) -> None: + """The path a real delegate key takes, which the scope check never sees. + + `whoami` requires no scope, so the platform's entrance gate lets the call + through and the delegation resolver answers `26016` — `login` never gets a + scope list to check. Unregistered, that code fell through to exit 19 + ("平台返回未登记的错误码 26016"), which is neither the right verdict nor a + diagnosis anyone can act on. + """ + mock = _mock(whoami_err(26016, "X-On-Behalf-Of is required for a delegated credential")) + code, _, err = _run(["login", BASE, "--api-key", FAKE_KEY], monkeypatch=monkeypatch, mock=mock) + + assert code == EXIT_FORBIDDEN + assert "委托" in err and "另外签发" in err + # The platform's own sentence still gets printed verbatim ("三部分,缺一不可"), + # but it must not be what the CLI tells the caller to act on: the CLI sends + # no identity headers, on any command, so adding one is not available. + assert "下一步: 请平台管理员另外签发" in err + assert not (home_dir / ".bisheng" / "credentials.json").exists() + + +def test_malformed_header_and_rejected_key_are_distinguishable(monkeypatch: pytest.MonkeyPatch, home_dir) -> None: + """26001 and 26002 are the two the runtime path can actually produce. + + A disabled service account used to arrive as its own code (26027). On the + beta2 server `resolve_service_account` raises `OpenApiCredentialInvalidError` + for it — 26002 — so that is the code whose copy has to carry the case; 26027 + survives only on the management face, which no CLI command calls. + """ + messages: dict[int, str] = {} + for code in (26001, 26002): + mock = _mock(whoami_err(code, f"server text {code}")) + exit_code, _, err = _run(["login", BASE, "--api-key", FAKE_KEY], monkeypatch=monkeypatch, mock=mock) + assert exit_code == EXIT_AUTH + messages[code] = err + + assert messages[26001] != messages[26002] + assert "Authorization" in messages[26001] + # Re-issuing a key is no longer the whole answer: for a disabled account the + # new key is exactly as dead as the old one. + assert "停用" in messages[26002] and "重新签发" in messages[26002] + + +def test_personal_token_rejections_are_not_generic_permission_errors(monkeypatch: pytest.MonkeyPatch, home_dir) -> None: + # Unregistered, 26040 fell through to "ask your admin about the permission + # bits" (HTTP 403) and 26043 to "check whether the key expired" (401) — + # both point away from the cause, which is the credential *kind*. + for code, expected_exit in ((26040, EXIT_FORBIDDEN), (26043, EXIT_AUTH)): + mock = _mock(whoami_err(code, f"server text {code}")) + exit_code, _, err = _run(["login", BASE, "--api-key", FAKE_PAT], monkeypatch=monkeypatch, mock=mock) + assert exit_code == expected_exit + assert "个人" in err and "bs-sak-" in err + + +def test_platform_unreachable_and_layer_absent_are_distinguishable(monkeypatch: pytest.MonkeyPatch, home_dir) -> None: + absent = PlatformMock().get(VERSIONS, versions_404()).get("/api/v1/env", env_ok(open_platform_enabled=False)) + code, _, _ = _run(["login", BASE, "--api-key", FAKE_KEY], monkeypatch=monkeypatch, mock=absent) + assert code == EXIT_NOT_ENABLED + # The probe decides before any credential leaves the machine. + assert WHOAMI not in absent.paths_called() + + unreachable = PlatformMock().get(VERSIONS, httpx.ConnectError("refused")) + code, _, _ = _run(["login", BASE, "--api-key", FAKE_KEY], monkeypatch=monkeypatch, mock=unreachable) + assert code == EXIT_UNREACHABLE + assert WHOAMI not in unreachable.paths_called() + + +def test_key_from_flag_env_stdin_tty_priority(monkeypatch: pytest.MonkeyPatch) -> None: + stdin_key = FAKE_KEY + "stdin" + env_key = FAKE_KEY + "env" + + def args(**kw): + return SimpleNamespace(**{"api_key": None, "api_key_stdin": False, **kw}) + + monkeypatch.setenv("BISHENG_API_KEY", env_key) + # 1. the flag beats everything + assert login_mod.resolve_api_key(args(api_key=FAKE_KEY, api_key_stdin=True), stdin=io.StringIO(stdin_key)) == ( + FAKE_KEY + ) + # 2. the environment beats stdin + assert login_mod.resolve_api_key(args(api_key_stdin=True), stdin=io.StringIO(stdin_key)) == env_key + monkeypatch.delenv("BISHENG_API_KEY") + # 3. stdin beats the prompt + assert login_mod.resolve_api_key(args(api_key_stdin=True), stdin=io.StringIO(stdin_key)) == stdin_key + + # 4. a TTY with none of the above gets asked + class _Tty(io.StringIO): + def isatty(self) -> bool: + return True + + assert login_mod.resolve_api_key(args(), stdin=_Tty(), prompt=lambda _p: FAKE_KEY) == FAKE_KEY + + # 5. nothing at all, and nobody to ask: a refusal, never a blank key + with pytest.raises(CliError) as excinfo: + login_mod.resolve_api_key(args(), stdin=io.StringIO()) + assert excinfo.value.exit_code == EXIT_USAGE + + +def test_key_never_echoed_in_any_output(monkeypatch: pytest.MonkeyPatch, home_dir) -> None: + mock = _mock(whoami_ok()) + code, out, err = _run( + ["--verbose", "--json", "login", BASE, "--api-key", FAKE_KEY], monkeypatch=monkeypatch, mock=mock + ) + assert code == EXIT_OK + assert FAKE_KEY not in out and FAKE_KEY not in err + assert "Bearer" not in out + + +def test_relogin_overwrites_same_platform_profile(monkeypatch: pytest.MonkeyPatch, home_dir) -> None: + second_key = FAKE_KEY + "2" + _run(["login", BASE, "--api-key", FAKE_KEY], monkeypatch=monkeypatch, mock=_mock(whoami_ok())) + _run(["login", BASE + "/", "--api-key", second_key], monkeypatch=monkeypatch, mock=_mock(whoami_ok())) + + stored = json.loads((home_dir / ".bisheng" / "credentials.json").read_text(encoding="utf-8")) + # A trailing slash must not fork the profile — that is how "logged in but the + # next command says otherwise" happens. + assert list(stored["profiles"]) == [BASE] + assert stored["profiles"][BASE]["api_key"] == second_key + + +def test_login_auto_syncs_skill_packs(monkeypatch: pytest.MonkeyPatch, home_dir) -> None: + # AC-08 (T039): a successful login pulls the skill packs so a first-time + # developer never has to know `skills sync` exists. + mock = _mock(whoami_ok()) + code, _, err = _run(["login", BASE, "--api-key", FAKE_KEY], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_OK + assert [path for path in mock.paths_called() if "skills" in path] == [skills_path(p) for p in DEFAULT_PACKS] + slug = skills_mod.profile_slug(BASE) + for pack in DEFAULT_PACKS: + assert (home_dir / ".bisheng" / "skills" / slug / pack / "SKILL.md").is_file() + # Where it landed must be on screen. Login used to sync in silence, which is + # how "5 个文件已同步" could be true while nothing could read them. + assert "技能包落点" in err + + +def test_login_refuses_platform_flag(monkeypatch: pytest.MonkeyPatch, home_dir) -> None: + # `login` names its platform positionally; accepting `--platform` as well + # would leave two addresses in one command and no way to tell which one the + # key was verified against. + mock = PlatformMock() + code, _, err = _run( + ["--platform", "http://other.test", "login", BASE, "--api-key", FAKE_KEY], monkeypatch=monkeypatch, mock=mock + ) + assert code == EXIT_USAGE + assert "--platform" in err + assert mock.paths_called() == [] + + +def test_login_still_succeeds_when_auto_sync_fails(monkeypatch: pytest.MonkeyPatch, home_dir) -> None: + # The sync is best-effort: if the pack endpoint 404s, login still succeeds + # and points the developer at a manual retry. + mock = PlatformMock().get(VERSIONS, versions_ok()).get(WHOAMI, whoami_ok()) + mock.get(skills_path(), httpx.Response(404, json={"detail": "Not Found"})) + code, _, err = _run(["login", BASE, "--api-key", FAKE_KEY], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_OK + assert "skills sync" in err diff --git a/src/bisheng-cli/tests/test_command_logs.py b/src/bisheng-cli/tests/test_command_logs.py new file mode 100644 index 0000000000..2cbb583313 --- /dev/null +++ b/src/bisheng-cli/tests/test_command_logs.py @@ -0,0 +1,268 @@ +"""T025 — `bisheng logs`. + +The recurring theme: everything authoritative happens on the server. Ownership is +judged there against the key's resource owner, keyword filtering happens there, +and the log retention window is whatever docker's rotation left behind. The CLI's +whole job is to forward the query, print what came back, and translate refusals +into sentences that point somewhere. +""" + +from __future__ import annotations + +import io +from pathlib import Path + +import pytest + +from bisheng_cli import credentials, project +from bisheng_cli.commands import logs as logs_mod +from bisheng_cli.errors import ( + EXIT_FORBIDDEN, + EXIT_NOT_ENABLED, + EXIT_NOT_LOGGED_IN, + EXIT_OK, + EXIT_PUBLISH_CONFLICT, + EXIT_UNREACHABLE, +) +from bisheng_cli.main import run as main_run +from tests.helpers.platform_mock import ( + FAKE_KEY, + FAKE_KEY_MASK, + PlatformMock, + use_mock_transport, + v2_error, +) +from tests.helpers.platform_mock import ( + logs as logs_response, +) + +BASE = "http://platform.test" +APP_ID = "app-1" +LOGS = f"/api/v2/apps/{APP_ID}/logs" + + +class _Stop(KeyboardInterrupt): + """What Ctrl-C looks like to `--follow`.""" + + +@pytest.fixture +def logged_in(home_dir) -> None: + credentials.save_profile( + BASE, + { + "api_key": FAKE_KEY, + "key_mask": FAKE_KEY_MASK, + "tenant_id": 1, + "service_account": {"id": 123, "name": "问卷小队开发号"}, + }, + ) + + +@pytest.fixture +def project_dir(sample_project: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + project.save_app_ref(sample_project, BASE, app_id=APP_ID, app_name="问卷小应用") + monkeypatch.chdir(sample_project) + return sample_project + + +def _run( + argv: list[str], + *, + monkeypatch: pytest.MonkeyPatch, + mock: PlatformMock, + sleeps: list[float] | None = None, + stop_after: int = 1, +) -> tuple[int, str, str]: + use_mock_transport(monkeypatch, logs_mod, mock) + recorder = sleeps if sleeps is not None else [] + + def _fake_sleep(seconds: float) -> None: + recorder.append(seconds) + if len(recorder) >= stop_after: + raise _Stop + + monkeypatch.setattr(logs_mod, "_sleep", _fake_sleep) + out, err = io.StringIO(), io.StringIO() + code = main_run(argv, stdout=out, stderr=err) + return code, out.getvalue(), err.getvalue() + + +def test_not_logged_in_exits_3_without_request(monkeypatch: pytest.MonkeyPatch, home_dir, sample_project: Path) -> None: + monkeypatch.chdir(sample_project) + mock = PlatformMock() + code, _, err = _run(["logs"], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_NOT_LOGGED_IN + assert "login" in err + assert mock.paths_called() == [] + + +def test_tail_since_keyword_forwarded_as_query_params( + monkeypatch: pytest.MonkeyPatch, logged_in, project_dir: Path +) -> None: + mock = PlatformMock().get(LOGS, logs_response(["hello"])) + code, _, _ = _run( + ["logs", "--tail", "50", "--since", "30m", "--keyword", "ERROR"], monkeypatch=monkeypatch, mock=mock + ) + assert code == EXIT_OK + params = mock.calls[0].url.params + assert params["tail"] == "50" and params["since"] == "30m" and params["keyword"] == "ERROR" + + +def test_16254_owner_only_refused_with_readable_reason( + monkeypatch: pytest.MonkeyPatch, logged_in, project_dir: Path +) -> None: + mock = PlatformMock().get(LOGS, v2_error(16254, "owner only", http_status=403)) + code, _, err = _run(["logs"], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_PUBLISH_CONFLICT + assert "归属人" in err + + +def test_16205_other_owner_refused(monkeypatch: pytest.MonkeyPatch, logged_in, project_dir: Path) -> None: + mock = PlatformMock().get(LOGS, v2_error(16205, "belongs to somebody else", http_status=403)) + code, _, err = _run(["logs"], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_PUBLISH_CONFLICT + assert "归属" in err + + +def test_cli_never_checks_ownership_itself(monkeypatch: pytest.MonkeyPatch, logged_in, project_dir: Path) -> None: + # One request, straight to the logs endpoint. A CLI-side pre-check would be a + # second judge working from data the CLI cannot see (the key's resource + # owner), and the two would eventually disagree. + mock = PlatformMock().get(LOGS, logs_response(["hello"])) + _run(["logs"], monkeypatch=monkeypatch, mock=mock) + assert mock.paths_called() == [LOGS] + + +def test_26003_missing_app_manage_exits_5(monkeypatch: pytest.MonkeyPatch, logged_in, project_dir: Path) -> None: + mock = PlatformMock().get(LOGS, v2_error(26003, "scope missing", required="app:manage")) + code, _, err = _run(["logs"], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_FORBIDDEN + assert "app:manage" in err + + +def test_16207_layer_absent_exits_8(monkeypatch: pytest.MonkeyPatch, logged_in, project_dir: Path) -> None: + mock = PlatformMock().get(LOGS, v2_error(16207, "runtime layer off", http_status=404)) + code, _, err = _run(["logs"], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_NOT_ENABLED + assert "应用工场" in err + + +def test_empty_lines_prints_app_state_hint_not_blank( + monkeypatch: pytest.MonkeyPatch, logged_in, project_dir: Path +) -> None: + mock = PlatformMock().get(LOGS, logs_response([])) + code, _, err = _run(["logs"], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_OK + assert "没有运行实例" in err and "应用详情页" in err + + +def test_empty_lines_names_the_state_when_the_server_sends_it( + monkeypatch: pytest.MonkeyPatch, logged_in, project_dir: Path +) -> None: + """"Not running" and "running but quiet" need opposite next steps. + + The log text can never tell them apart, so the server names the state + (F055 write-back 2). Listing all three candidates when the answer is known + would be a worse answer than the one we have. + """ + for state, expected, unexpected in ( + ("draft", "还是草稿", "已下线"), + ("stopped", "已下线", "还是草稿"), + ("online", "还没有输出任何日志", "没有运行实例"), + ): + mock = PlatformMock().get(LOGS, logs_response([], app_state=state)) + code, _, err = _run(["logs"], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_OK + assert expected in err, (state, err) + assert unexpected not in err, (state, err) + + +def test_empty_lines_pending_capacity_includes_the_reason( + monkeypatch: pytest.MonkeyPatch, logged_in, project_dir: Path +) -> None: + mock = PlatformMock().get(LOGS, logs_response([], app_state="pending_capacity", pending_reason="capacity")) + code, _, err = _run(["logs"], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_OK + assert "待上线" in err and "capacity" in err + + +def test_follow_polls_with_since_every_3s(monkeypatch: pytest.MonkeyPatch, logged_in, project_dir: Path) -> None: + mock = PlatformMock().get(LOGS, [logs_response(["a"]), logs_response(["b"])]) + sleeps: list[float] = [] + code, _, _ = _run(["logs", "--follow"], monkeypatch=monkeypatch, mock=mock, sleeps=sleeps, stop_after=2) + assert code == EXIT_OK + assert sleeps == [logs_mod.FOLLOW_INTERVAL, logs_mod.FOLLOW_INTERVAL] == [3.0, 3.0] + # The first round has no `since` (the user gave none); every later round + # carries one so the server does not resend the whole tail. + assert "since" not in mock.calls[0].url.params + assert "since" in mock.calls[1].url.params + + +def test_since_accepts_epoch_seconds_and_relative_window( + monkeypatch: pytest.MonkeyPatch, logged_in, project_dir: Path +) -> None: + for value in ("1700000000", "30m", "2h", "7d"): + mock = PlatformMock().get(LOGS, logs_response(["x"])) + code, _, _ = _run(["logs", "--since", value], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_OK + # Verbatim. Turning `30m` into an epoch locally would be a second + # implementation of a semantics the server already owns. + assert mock.calls[0].url.params["since"] == value + + +def test_keyword_may_return_fewer_lines_than_tail_is_not_a_bug( + monkeypatch: pytest.MonkeyPatch, logged_in, project_dir: Path +) -> None: + mock = PlatformMock().get(LOGS, logs_response(["only one match"])) + code, _, err = _run(["logs", "--tail", "500", "--keyword", "match"], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_OK + assert len(mock.calls) == 1 # no retry + assert "警告" not in err + + +def test_16121_orchestrator_unavailable_says_retry_not_app_missing( + monkeypatch: pytest.MonkeyPatch, logged_in, project_dir: Path +) -> None: + # dockerd or runtime-manager down comes back as 503 → 16121. Reporting it as + # "the app is gone" sends the owner to look for a deletion that never + # happened. + mock = PlatformMock().get(LOGS, v2_error(16121, "backend unavailable", http_status=503)) + code, _, err = _run(["logs"], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_UNREACHABLE + assert "编排器不可用" in err and "可重试" in err + assert "应用已删除" not in err + + +def test_follow_dedupes_same_second_repeats(monkeypatch: pytest.MonkeyPatch, logged_in, project_dir: Path) -> None: + # docker timestamps are second-resolution, so the last second comes back + # again in the next `since` window. + first = logs_response(["line-a", "line-b", "line-c"]) + second = logs_response(["line-b", "line-c", "line-d"]) + mock = PlatformMock().get(LOGS, [first, second]) + code, _, err = _run(["logs", "--follow"], monkeypatch=monkeypatch, mock=mock, stop_after=2) + assert code == EXIT_OK + assert err.count("line-b") == 1 and err.count("line-c") == 1 + assert err.count("line-d") == 1 + + +def test_app_id_resolution_same_as_deploy(monkeypatch: pytest.MonkeyPatch, logged_in, project_dir: Path) -> None: + explicit = "/api/v2/apps/app-99/logs" + mock = PlatformMock().get(explicit, logs_response(["x"])) + code, _, _ = _run(["logs", "--app-id", "app-99"], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_OK + assert mock.paths_called() == [explicit] + + saved = PlatformMock().get(LOGS, logs_response(["x"])) + code, _, _ = _run(["logs"], monkeypatch=monkeypatch, mock=saved) + assert code == EXIT_OK + assert saved.paths_called() == [LOGS] + + +def test_since_empty_says_no_logs_in_range_or_rotated_not_never_happened( + monkeypatch: pytest.MonkeyPatch, logged_in, project_dir: Path +) -> None: + mock = PlatformMock().get(LOGS, logs_response([])) + code, _, err = _run(["logs", "--since", "7d"], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_OK + assert "轮转" in err + assert "什么都没发生" in err # stated as what the CLI cannot claim diff --git a/src/bisheng-cli/tests/test_command_platforms.py b/src/bisheng-cli/tests/test_command_platforms.py new file mode 100644 index 0000000000..abc2dd0b41 --- /dev/null +++ b/src/bisheng-cli/tests/test_command_platforms.py @@ -0,0 +1,83 @@ +"""T047 — `bisheng platforms list` / `bisheng platforms use`. + +The store has been multi-profile since T012; these tests pin the interaction on +top of it: the listing never shows a key, `use` re-points `current` and refuses +to invent a profile, and a bare `platforms` is a usage error with no request. +""" + +from __future__ import annotations + +import io +import json + +from bisheng_cli import credentials +from bisheng_cli.errors import EXIT_NOT_LOGGED_IN, EXIT_OK, EXIT_USAGE +from bisheng_cli.main import run as main_run +from tests.helpers.platform_mock import FAKE_KEY, FAKE_KEY_MASK + +BASE = "http://platform.test" +OTHER = "http://other.test" + + +def _profile(**overrides) -> dict: + payload = { + "api_key": FAKE_KEY, + "key_mask": FAKE_KEY_MASK, + "actor_kind": "service_account", + "actor_id": 123, + "actor_name": "问卷小队开发号", + "expires_at": "2026-12-31T00:00:00", + } + payload.update(overrides) + return payload + + +def _run(argv: list[str]) -> tuple[int, str, str]: + out, err = io.StringIO(), io.StringIO() + code = main_run(argv, stdout=out, stderr=err) + return code, out.getvalue(), err.getvalue() + + +def test_list_marks_current_and_never_prints_the_key(home_dir) -> None: + credentials.save_profile(BASE, _profile()) + credentials.save_profile(OTHER, _profile(actor_name="另一个号")) + code, out, err = _run(["platforms", "list", "--json"]) + assert code == EXIT_OK + assert f"* {OTHER}" in err and f" {BASE}" in err + assert "问卷小队开发号" in err and "另一个号" in err and FAKE_KEY_MASK in err + assert FAKE_KEY not in err and FAKE_KEY not in out + result = json.loads(out.strip().splitlines()[-1]) + assert result["event"] == "result" and result["ok"] is True + rows = result["data"]["platforms"] + assert [(r["base_url"], r["current"]) for r in rows] == [(BASE, False), (OTHER, True)] + assert all("api_key" not in r for r in rows) + + +def test_list_with_nothing_logged_in_is_ok_and_says_so(home_dir) -> None: + code, out, err = _run(["platforms", "list", "--json"]) + assert code == EXIT_OK + assert "bisheng login" in err + assert json.loads(out.strip().splitlines()[-1])["data"]["platforms"] == [] + + +def test_use_switches_current(home_dir) -> None: + credentials.save_profile(BASE, _profile()) + credentials.save_profile(OTHER, _profile()) + code, _, err = _run(["platforms", "use", BASE + "/"]) + assert code == EXIT_OK + assert BASE in err + assert credentials.load_current().base_url == BASE + + +def test_use_unknown_platform_is_exit_3_and_leaves_current_alone(home_dir) -> None: + credentials.save_profile(BASE, _profile()) + code, _, err = _run(["platforms", "use", "http://never.test"]) + assert code == EXIT_NOT_LOGGED_IN + assert "http://never.test" in err and "bisheng login" in err + assert credentials.load_current().base_url == BASE + + +def test_bare_platforms_is_usage_error(home_dir) -> None: + code, _, err = _run(["platforms"]) + assert code == EXIT_USAGE + assert "platforms list" in err and "platforms use" in err diff --git a/src/bisheng-cli/tests/test_command_skills.py b/src/bisheng-cli/tests/test_command_skills.py new file mode 100644 index 0000000000..0741099f0c --- /dev/null +++ b/src/bisheng-cli/tests/test_command_skills.py @@ -0,0 +1,348 @@ +"""T035 — `bisheng skills sync`. + +The pack is a platform release artifact, so the command's contract is narrow and +sharp: fetch the current version from the anonymous endpoint, overwrite the local +copy wholesale (no merge, 决议-8), and say what it overwrote. Everything the CLI +refuses to do — merge, keep local edits, ask before overwriting — is as much part +of the contract as what it does, so those get tests too. +""" + +from __future__ import annotations + +import io +import tarfile +from pathlib import Path +from types import SimpleNamespace + +import httpx +import pytest + +from bisheng_cli import credentials +from bisheng_cli.commands import skills as skills_mod +from bisheng_cli.errors import EXIT_LOCAL_INVALID, EXIT_NOT_ENABLED, EXIT_NOT_LOGGED_IN, EXIT_OK, EXIT_USAGE +from bisheng_cli.main import run as main_run +from bisheng_cli.output import Emitter +from tests.helpers.platform_mock import ( + DEFAULT_PACKS, + FAKE_KEY, + FAKE_KEY_MASK, + PlatformMock, + serve_default_packs, + skills_path, + use_mock_transport, +) + +BASE = "http://platform.test" +PACK = "deploy-hosting" +SECOND_PACK = "platform-wiring" +SKILLS_PATH = f"/api/v1/dev-toolkit/skills/{PACK}" + + +@pytest.fixture +def logged_in(home_dir) -> None: + credentials.save_profile( + BASE, + { + "api_key": FAKE_KEY, + "key_mask": FAKE_KEY_MASK, + "tenant_id": 1, + "service_account": {"id": 123, "name": "问卷小队开发号"}, + }, + ) + + +def _pack_tar(files: dict[str, str], *, pack: str = PACK) -> bytes: + """Build a pack tarball the same shape the endpoint serves: members under ``pack/``.""" + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode="w:gz") as tar: + for rel, content in files.items(): + data = content.encode("utf-8") + info = tarfile.TarInfo(f"{pack}/{rel}") + info.size = len(data) + tar.addfile(info, io.BytesIO(data)) + return buffer.getvalue() + + +def _pack_response(files: dict[str, str], *, version: str = "3.0.0") -> httpx.Response: + return httpx.Response( + 200, + content=_pack_tar(files), + headers={"x-bisheng-pack-version": version, "content-type": "application/gzip"}, + ) + + +SAMPLE = {"SKILL.md": "# deploy-hosting\n铁律……\n", "example/main.py": "print('hi')\n", "selfcheck.py": "print('ok')\n"} + + +def _run(argv: list[str], *, monkeypatch: pytest.MonkeyPatch, mock: PlatformMock) -> tuple[int, str, str]: + # A test that routes the first pack gets the other shipped packs served too; + # a test with an empty mock keeps asserting "no request at all". + if mock.has("GET", SKILLS_PATH): + serve_default_packs(mock) + use_mock_transport(monkeypatch, skills_mod, mock) + out, err = io.StringIO(), io.StringIO() + code = main_run(argv, stdout=out, stderr=err) + return code, out.getvalue(), err.getvalue() + + +def _skills_dir(home: Path, base: str = BASE) -> Path: + """Packs live under a per-platform slug now — see `skills.profile_slug`.""" + return home / ".bisheng" / "skills" / skills_mod.profile_slug(base) + + +# ---- not logged in / usage ------------------------------------------------ + + +def test_not_logged_in_exits_3_without_request(monkeypatch: pytest.MonkeyPatch, home_dir) -> None: + mock = PlatformMock() + code, _, err = _run(["skills", "sync"], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_NOT_LOGGED_IN + assert "login" in err + assert mock.paths_called() == [] + + +def test_bare_skills_prints_usage_exit_2(monkeypatch: pytest.MonkeyPatch, logged_in) -> None: + mock = PlatformMock() + code, _, err = _run(["skills"], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_USAGE + assert "skills sync" in err + assert mock.paths_called() == [] + + +# ---- happy path ----------------------------------------------------------- + + +def test_sync_writes_pack_into_home_skills_dir(monkeypatch: pytest.MonkeyPatch, logged_in, home_dir) -> None: + mock = PlatformMock().get(SKILLS_PATH, _pack_response(SAMPLE)) + code, _, err = _run(["skills", "sync"], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_OK + pack_dir = _skills_dir(home_dir) / PACK + assert (pack_dir / "SKILL.md").read_text(encoding="utf-8") == SAMPLE["SKILL.md"] + assert (pack_dir / "example" / "main.py").read_text(encoding="utf-8") == SAMPLE["example/main.py"] + assert (pack_dir / "selfcheck.py").is_file() + # AC-20: the reference guide points a non-Claude engine at the same dir. + assert "AGENTS.md" in err + assert str(_skills_dir(home_dir)) in err + + +def test_sync_reports_version_and_is_idempotent(monkeypatch: pytest.MonkeyPatch, logged_in, home_dir) -> None: + mock = PlatformMock().get( + SKILLS_PATH, [_pack_response(SAMPLE, version="3.0.1"), _pack_response(SAMPLE, version="3.0.1")] + ) + code1, _, err1 = _run(["skills", "sync"], monkeypatch=monkeypatch, mock=mock) + code2, _, _ = _run(["skills", "sync"], monkeypatch=monkeypatch, mock=mock) + assert code1 == EXIT_OK and code2 == EXIT_OK + assert "3.0.1" in err1 + # Second run is a clean overwrite, not a duplicate/merge: the tree matches. + files = sorted(p.name for p in (_skills_dir(home_dir) / PACK).rglob("*") if p.is_file()) + assert files == ["SKILL.md", "main.py", "selfcheck.py"] + + +def test_sync_overwrites_local_edits_and_lists_them(monkeypatch: pytest.MonkeyPatch, logged_in, home_dir) -> None: + # First sync, then the developer edits a pack file; re-sync must restore the + # platform version (决议-8: one-way overwrite) and name the overwritten file. + mock = PlatformMock().get(SKILLS_PATH, [_pack_response(SAMPLE), _pack_response(SAMPLE)]) + _run(["skills", "sync"], monkeypatch=monkeypatch, mock=mock) + edited = _skills_dir(home_dir) / PACK / "SKILL.md" + edited.write_text("我改过了\n", encoding="utf-8") + code, out, _ = _run(["skills", "sync", "--json"], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_OK + assert edited.read_text(encoding="utf-8") == SAMPLE["SKILL.md"] # restored + # The machine-readable result lists the overwritten file so the edit is not silent. + assert f"{PACK}/SKILL.md" in out + + +# ---- endpoint absent / errors -------------------------------------------- + + +def test_endpoint_404_exits_not_enabled(monkeypatch: pytest.MonkeyPatch, logged_in, home_dir) -> None: + mock = PlatformMock().get(SKILLS_PATH, httpx.Response(404, json={"detail": "Not Found"})) + code, _, err = _run(["skills", "sync"], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_NOT_ENABLED + assert "open_platform" in err or "开放能力层" in err + assert not (_skills_dir(home_dir) / PACK).exists() + + +def test_traversal_member_rejected_nothing_escapes(monkeypatch: pytest.MonkeyPatch, logged_in, home_dir) -> None: + evil = _pack_tar({"../evil.txt": "pwned\n", "SKILL.md": "ok\n"}) + mock = PlatformMock().get(SKILLS_PATH, httpx.Response(200, content=evil)) + code, _, _ = _run(["skills", "sync"], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_LOCAL_INVALID + assert not (home_dir / ".bisheng" / "evil.txt").exists() + assert not (home_dir / "evil.txt").exists() + + +# ---- login auto-sync (AC-08) --------------------------------------------- + + +def test_run_after_login_never_raises_on_failure(monkeypatch: pytest.MonkeyPatch, logged_in, home_dir) -> None: + # A sync failure at login must degrade to a warning, never propagate — login + # already succeeded by the time this runs. + mock = PlatformMock().get(SKILLS_PATH, httpx.Response(404, json={"detail": "Not Found"})) + use_mock_transport(monkeypatch, skills_mod, mock) + err = io.StringIO() + emitter = Emitter(stdout=io.StringIO(), stderr=err) + profile = credentials.Profile(base_url=BASE, api_key=FAKE_KEY) + args = SimpleNamespace(no_proxy=False, timeout=None) + skills_mod.run_after_login(profile, args, emitter) # must not raise + assert "skills sync" in err.getvalue() + + +def test_run_after_login_syncs_on_success(monkeypatch: pytest.MonkeyPatch, logged_in, home_dir) -> None: + mock = PlatformMock().get(SKILLS_PATH, _pack_response(SAMPLE)) + use_mock_transport(monkeypatch, skills_mod, mock) + emitter = Emitter(stdout=io.StringIO(), stderr=io.StringIO()) + profile = credentials.Profile(base_url=BASE, api_key=FAKE_KEY) + args = SimpleNamespace(no_proxy=False, timeout=None) + skills_mod.run_after_login(profile, args, emitter) + assert (_skills_dir(home_dir) / PACK / "SKILL.md").is_file() + + +# ---- per-platform isolation + wiring (this round) ------------------------- + + +OTHER = "http://other-platform.test" + + +def test_two_platforms_do_not_overwrite_each_other(monkeypatch: pytest.MonkeyPatch, home_dir) -> None: + """Logging into a second platform must not silently replace the first's contract. + + Before packs were stored per platform, the second sync overwrote the first in + place: the developer's agent then read platform B's rules while working on a + platform A project, with nothing on screen to suggest it. + """ + first = {"SKILL.md": "# 平台 A 的规矩\n"} + second = {"SKILL.md": "# 平台 B 的规矩\n"} + + credentials.save_profile(BASE, {"api_key": FAKE_KEY}) + _run(["skills", "sync"], monkeypatch=monkeypatch, mock=PlatformMock().get(SKILLS_PATH, _pack_response(first))) + credentials.save_profile(OTHER, {"api_key": FAKE_KEY}) + _run(["skills", "sync"], monkeypatch=monkeypatch, mock=PlatformMock().get(SKILLS_PATH, _pack_response(second))) + + assert (_skills_dir(home_dir, BASE) / PACK / "SKILL.md").read_text(encoding="utf-8") == first["SKILL.md"] + assert (_skills_dir(home_dir, OTHER) / PACK / "SKILL.md").read_text(encoding="utf-8") == second["SKILL.md"] + + +def test_sync_links_the_pack_into_installed_agents(monkeypatch: pytest.MonkeyPatch, logged_in, home_dir) -> None: + # The pack landing on disk is not the deliverable; an agent being able to read + # it is. `~/.bisheng/skills/` is scanned by nobody. + (home_dir / ".claude").mkdir() + (home_dir / ".codex").mkdir() + mock = PlatformMock().get(SKILLS_PATH, _pack_response(SAMPLE)) + + code, _, err = _run(["skills", "sync"], monkeypatch=monkeypatch, mock=mock) + + assert code == EXIT_OK + for agent in (".claude", ".codex"): + assert (home_dir / agent / "skills" / PACK / "SKILL.md").is_file() + assert "Claude Code" in err and "Codex" in err + + +def test_sync_warns_when_no_agent_can_read_the_pack(monkeypatch: pytest.MonkeyPatch, logged_in, home_dir) -> None: + mock = PlatformMock().get(SKILLS_PATH, _pack_response(SAMPLE)) + code, _, err = _run(["skills", "sync"], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_OK # downloading worked; the warning is about reachability + assert "警告" in err + assert "未接入" in err + + +def test_guide_no_longer_claims_claude_code_needs_no_setup( + monkeypatch: pytest.MonkeyPatch, logged_in, home_dir +) -> None: + """Two wrong claims are gone: auto-discovery, and a README the pack lacks. + + Both told the developer there was nothing left to do, which is how a pack + nothing could read still read as success. + """ + mock = PlatformMock().get(SKILLS_PATH, _pack_response(SAMPLE)) + _, _, err = _run(["skills", "sync"], monkeypatch=monkeypatch, mock=mock) + assert "无需配置" not in err + assert "README.md" not in err + + +def test_sync_clears_the_pre_profile_layout(monkeypatch: pytest.MonkeyPatch, logged_in, home_dir) -> None: + # A pack left at the old flat path is a decoy: nothing updates it, and it is + # indistinguishable from the live one. + stale = home_dir / ".bisheng" / "skills" / PACK + stale.mkdir(parents=True) + (stale / "SKILL.md").write_text("# 旧版落点\n", encoding="utf-8") + mock = PlatformMock().get(SKILLS_PATH, _pack_response(SAMPLE)) + + code, _, _ = _run(["skills", "sync"], monkeypatch=monkeypatch, mock=mock) + + assert code == EXIT_OK + assert not stale.exists() + assert (_skills_dir(home_dir) / PACK / "SKILL.md").is_file() + + +def test_two_packs_ship_and_both_are_synced(monkeypatch: pytest.MonkeyPatch, logged_in, home_dir) -> None: + # T038: 「平台能力接线」 rides beside 「部署纳管」. Both land, both are reported. + assert DEFAULT_PACKS == ("deploy-hosting", "platform-wiring") + mock = PlatformMock().get(SKILLS_PATH, _pack_response(SAMPLE)) + code, out, err = _run(["skills", "sync", "--json"], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_OK + assert mock.paths_called() == [skills_path(PACK), skills_path(SECOND_PACK)] + for pack in DEFAULT_PACKS: + assert (_skills_dir(home_dir) / pack / "SKILL.md").is_file() + assert "platform-wiring" in err + assert '"pack": "platform-wiring"' in out + + +def test_second_pack_missing_on_an_older_platform_is_skipped_not_fatal( + monkeypatch: pytest.MonkeyPatch, logged_in, home_dir +) -> None: + """A platform that predates a pack still syncs the packs it has. + + The first pack's 404 means the layer is off (exit 8); a later pack's 404 on a + platform that just served the first one is a version gap, reported as a + warning so the developer knows what to expect after the platform upgrades. + """ + mock = PlatformMock().get(SKILLS_PATH, _pack_response(SAMPLE)) + mock.get(skills_path(SECOND_PACK), httpx.Response(404, json={"detail": "Not Found"})) + code, out, err = _run(["skills", "sync", "--json"], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_OK + assert (_skills_dir(home_dir) / PACK / "SKILL.md").is_file() + assert not (_skills_dir(home_dir) / SECOND_PACK).exists() + assert "警告" in err and SECOND_PACK in err and "跳过" in err + assert '"skipped": true' in out + + +def test_second_pack_server_error_stays_fatal(monkeypatch: pytest.MonkeyPatch, logged_in, home_dir) -> None: + # Only a 404 means "this platform predates the pack". A 5xx on the second + # pack is the platform failing mid-sync and must not be reported as a + # version gap the developer can wait out. + mock = PlatformMock().get(SKILLS_PATH, _pack_response(SAMPLE)) + mock.get(skills_path(SECOND_PACK), httpx.Response(503, text="upstream down")) + code, out, err = _run(["skills", "sync", "--json"], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_NOT_ENABLED + assert "503" in err and "跳过" not in err + assert '"skipped": true' not in out + + +def test_platform_flag_syncs_into_that_platforms_slug(monkeypatch: pytest.MonkeyPatch, home_dir) -> None: + # T047: `--platform` picks a stored profile; the pack lands under *its* slug, + # and `current` is left as it was. + credentials.save_profile(BASE, {"api_key": FAKE_KEY}) + credentials.save_profile(OTHER, {"api_key": FAKE_KEY}) # OTHER is now current + mock = PlatformMock().get(SKILLS_PATH, _pack_response(SAMPLE)) + code, _, _ = _run(["--platform", BASE, "skills", "sync"], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_OK + assert (_skills_dir(home_dir, BASE) / PACK / "SKILL.md").is_file() + assert not (_skills_dir(home_dir, OTHER) / PACK).exists() + assert credentials.load_current().base_url == OTHER + + +def test_platform_flag_for_an_unknown_platform_is_exit_3_without_request( + monkeypatch: pytest.MonkeyPatch, logged_in +) -> None: + mock = PlatformMock() + code, _, err = _run(["skills", "sync", "--platform", "http://never.test"], monkeypatch=monkeypatch, mock=mock) + assert code == EXIT_NOT_LOGGED_IN + assert "http://never.test" in err and "bisheng login" in err + assert mock.paths_called() == [] + + +def test_profile_slug_separates_schemes_and_is_stable() -> None: + assert skills_mod.profile_slug("http://a.test") == skills_mod.profile_slug("http://a.test/") + assert skills_mod.profile_slug("http://a.test") != skills_mod.profile_slug("https://a.test") + assert skills_mod.profile_slug("http://192.168.106.114:4101").startswith("192.168.106.114-4101.") diff --git a/src/bisheng-cli/tests/test_credentials.py b/src/bisheng-cli/tests/test_credentials.py new file mode 100644 index 0000000000..52a216ddea --- /dev/null +++ b/src/bisheng-cli/tests/test_credentials.py @@ -0,0 +1,312 @@ +"""T011 — credential storage. + +The key is stored in clear text because every request needs its original value. +That makes the file mode the *only* protection there is, not a hardening extra — +which is why the creation mode, not a later chmod, is what these tests assert. +""" + +from __future__ import annotations + +import json +import os +import stat +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from bisheng_cli import credentials +from bisheng_cli.errors import EXIT_NOT_LOGGED_IN, CliError +from tests.helpers.platform_mock import FAKE_KEY, FAKE_KEY_MASK + +BASE = "http://114.test:7860" +OTHER = "http://another.test" + +pytestmark = pytest.mark.usefixtures("home_dir") + + +def _profile(base_url: str = BASE, **overrides) -> dict: + payload = { + "base_url": base_url, + "api_key": FAKE_KEY, + "key_mask": FAKE_KEY_MASK, + "tenant_id": 1, + "actor_kind": "service_account", + "actor_id": 123, + "actor_name": "问卷小队开发号", + "resource_owner": {"user_id": 7}, + "expires_at": "2026-12-31T00:00:00", + } + payload.update(overrides) + return payload + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX mode bits do not exist on Windows") +def test_file_created_0600_and_dir_0700_in_one_step() -> None: + credentials.save_profile(BASE, _profile()) + path = credentials.credentials_path() + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + assert stat.S_IMODE(path.parent.stat().st_mode) == 0o700 + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX mode bits do not exist on Windows") +def test_rewrite_keeps_0600_and_leaves_no_world_readable_leftover(tmp_path: Path) -> None: + credentials.save_profile(BASE, _profile()) + credentials.save_profile(BASE, _profile(tenant_id=2)) + directory = credentials.credentials_path().parent + for entry in directory.iterdir(): + assert not stat.S_IMODE(entry.stat().st_mode) & 0o077, f"{entry} is readable by others" + + +def test_never_written_into_project_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + project = tmp_path / "project" + project.mkdir() + monkeypatch.chdir(project) + credentials.save_profile(BASE, _profile()) + assert list(project.rglob("*")) == [] + assert Path.home() in credentials.credentials_path().parents + + +def test_scopes_never_persisted() -> None: + credentials.save_profile(BASE, _profile(scopes=["app:manage", "delegate"])) + raw = json.loads(credentials.credentials_path().read_text(encoding="utf-8")) + assert "scopes" not in json.dumps(raw) + + +def test_multi_profile_isolated_by_base_url() -> None: + credentials.save_profile(BASE, _profile()) + credentials.save_profile(OTHER, _profile(OTHER, tenant_id=9)) + store = json.loads(credentials.credentials_path().read_text(encoding="utf-8")) + assert set(store["profiles"]) == {BASE, OTHER} + assert store["current"] == OTHER + assert credentials.load_profile(BASE).tenant_id == 1 + + +def test_relogin_same_platform_overwrites_profile() -> None: + credentials.save_profile(BASE, _profile()) + credentials.save_profile(BASE, _profile(tenant_id=42)) + store = json.loads(credentials.credentials_path().read_text(encoding="utf-8")) + assert list(store["profiles"]) == [BASE] + assert credentials.load_current().tenant_id == 42 + + +def test_base_url_normalised_before_use_as_key() -> None: + # Two real failures ride on this, not just a duplicate row: + # 1. "please run bisheng login" right after a successful login; + # 2. .bisheng/app.json keyed by the other spelling, so an iterative deploy + # looks like a first deploy and the platform grows a second draft app. + variants = ["http://114.test:7860/", "http://114.test:7860", "HTTP://114.test:7860"] + assert len({credentials.normalise_base_url(v) for v in variants}) == 1 + credentials.save_profile(variants[0], _profile()) + for variant in variants: + assert credentials.load_profile(variant).api_key == FAKE_KEY + + +def test_explicit_default_port_is_not_stripped() -> None: + # Guessing that :80 == no port would be a guess about how the operator wrote + # their nginx config; keeping them distinct is wrong at most in one direction. + assert credentials.normalise_base_url("http://p.test:80") != credentials.normalise_base_url("http://p.test") + + +def test_load_without_credentials_raises_exit_3_not_network_error() -> None: + with pytest.raises(CliError) as excinfo: + credentials.load_current() + assert excinfo.value.exit_code == EXIT_NOT_LOGGED_IN + assert "bisheng login" in excinfo.value.next_step + + +def test_load_profile_for_unknown_platform_raises_exit_3() -> None: + credentials.save_profile(BASE, _profile()) + with pytest.raises(CliError) as excinfo: + credentials.load_profile("http://never-seen.test") + assert excinfo.value.exit_code == EXIT_NOT_LOGGED_IN + + +# ---- T047: multi-platform interaction layer --------------------------------- + + +def test_load_selected_prefers_platform_flag_over_current() -> None: + credentials.save_profile(BASE, _profile()) + credentials.save_profile(OTHER, _profile(OTHER, tenant_id=9)) # current + assert credentials.load_selected(SimpleNamespace(platform=None)).base_url == OTHER + assert credentials.load_selected(SimpleNamespace(platform=BASE + "/")).tenant_id == 1 + assert credentials.load_selected(SimpleNamespace()).base_url == OTHER # no attribute at all + + +def test_load_selected_unknown_platform_is_exit_3_naming_the_address() -> None: + credentials.save_profile(BASE, _profile()) + with pytest.raises(CliError) as excinfo: + credentials.load_selected(SimpleNamespace(platform="http://never.test")) + assert excinfo.value.exit_code == EXIT_NOT_LOGGED_IN + assert "http://never.test" in excinfo.value.message + assert "bisheng login http://never.test" in excinfo.value.next_step + + +def test_list_profiles_masks_the_key_and_flags_current() -> None: + credentials.save_profile(BASE, _profile()) + credentials.save_profile(OTHER, _profile(OTHER, actor_name="另一个号")) + rows = credentials.list_profiles() + assert [r["base_url"] for r in rows] == [BASE, OTHER] + assert [r["current"] for r in rows] == [False, True] + assert rows[1]["actor_name"] == "另一个号" and rows[0]["key_mask"] == FAKE_KEY_MASK + assert FAKE_KEY not in json.dumps(rows) + assert "api_key" not in json.dumps(rows) + + +def test_set_current_switches_and_refuses_unknown() -> None: + credentials.save_profile(BASE, _profile()) + credentials.save_profile(OTHER, _profile(OTHER)) + assert credentials.load_current().base_url == OTHER + assert credentials.set_current(BASE.upper()) == BASE # normalised like every other lookup + assert credentials.load_current().base_url == BASE + with pytest.raises(CliError) as excinfo: + credentials.set_current("http://never.test") + assert excinfo.value.exit_code == EXIT_NOT_LOGGED_IN + assert credentials.load_current().base_url == BASE # untouched by the refusal + + +def test_stored_snapshot_fields() -> None: + credentials.save_profile(BASE, _profile()) + stored = json.loads(credentials.credentials_path().read_text(encoding="utf-8"))["profiles"][BASE] + assert set(stored) == { + "base_url", + "api_key", + "key_mask", + "tenant_id", + "actor_kind", + "actor_id", + "actor_name", + "resource_owner", + "expires_at", + "logged_in_at", + } + assert stored["logged_in_at"] + + +def test_resource_owner_may_be_null() -> None: + # `WhoamiResourceOwner | None`: a credential without a resource owner is a + # shape the server can send, and the snapshot has to survive it. + credentials.save_profile(BASE, _profile(resource_owner=None)) + assert credentials.load_current().resource_owner is None + + +def test_a_profile_written_by_an_older_cli_loads_without_its_dead_fields() -> None: + """`service_account: {id, name}` was F049's shape and is gone from the wire. + + A developer upgrading the CLI has one of those on disk. It must not raise on + read (the key would otherwise reach `Profile(**...)` as an unexpected + argument) — the stale identity simply drops out and the next `login` refills + the current fields. + """ + credentials.save_profile(BASE, _profile()) + path = credentials.credentials_path() + store = json.loads(path.read_text(encoding="utf-8")) + legacy = store["profiles"][BASE] + for field in ("actor_kind", "actor_id", "actor_name"): + legacy.pop(field) + legacy["service_account"] = {"id": 123, "name": "问卷小队开发号"} + path.write_text(json.dumps(store, ensure_ascii=False), encoding="utf-8") + + profile = credentials.load_current() + assert profile.api_key == FAKE_KEY + assert profile.actor_name is None + + +def test_windows_acl_failure_warns_loudly_instead_of_pretending(monkeypatch: pytest.MonkeyPatch) -> None: + # os.chmod on Windows only moves the read-only bit; if the ACL call fails the + # file is readable by every account on the box. Saying nothing would be the + # one outcome worse than failing. + warnings: list[str] = [] + monkeypatch.setattr(credentials, "_is_windows", lambda: True) + monkeypatch.setattr(credentials, "_run_icacls", lambda path: False) + credentials.save_profile(BASE, _profile(), warn=warnings.append) + assert warnings and "权限" in warnings[0] + + +def test_windows_acl_success_is_silent(monkeypatch: pytest.MonkeyPatch) -> None: + warnings: list[str] = [] + monkeypatch.setattr(credentials, "_is_windows", lambda: True) + monkeypatch.setattr(credentials, "_run_icacls", lambda path: True) + credentials.save_profile(BASE, _profile(), warn=warnings.append) + assert warnings == [] + + +def test_corrupt_store_is_reported_not_silently_reset() -> None: + path = credentials.credentials_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("{not json", encoding="utf-8") + with pytest.raises(CliError) as excinfo: + credentials.load_current() + assert str(path) in excinfo.value.next_step + assert os.path.exists(path) + + +# ---- Windows ACL hardening: SID grant + never brick the file (field fix) ---- + + +class _FakeRun: + def __init__(self, returncode: int = 0, stdout: str = "") -> None: + self.returncode = returncode + self.stdout = stdout + + +def test_current_user_grantee_prefers_sid(monkeypatch: pytest.MonkeyPatch) -> None: + # whoami /user gives an unambiguous SID; a bare username may not resolve. + monkeypatch.setattr( + credentials.subprocess, + "run", + lambda *a, **k: _FakeRun(0, '"DESKTOP-ABC\\X1C","S-1-5-21-1-2-3-1001"\n'), + ) + assert credentials._current_user_grantee() == "*S-1-5-21-1-2-3-1001" + + +def test_current_user_grantee_falls_back_to_username(monkeypatch: pytest.MonkeyPatch) -> None: + def boom(*a, **k): + raise OSError + + monkeypatch.setattr(credentials.subprocess, "run", boom) + monkeypatch.setenv("USERNAME", "X1C") + assert credentials._current_user_grantee() == "X1C" + + +def test_run_icacls_never_strips_inheritance(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + # /inheritance:r on a file with only inherited ACEs is what bricked the field + # machine: it must never be issued again. + path = tmp_path / "credentials.json" + path.write_text("{}", encoding="utf-8") + calls: list[list[str]] = [] + monkeypatch.setattr(credentials.subprocess, "run", lambda args, **k: calls.append(args) or _FakeRun()) + monkeypatch.setattr(credentials, "_current_user_grantee", lambda: "*S-1-5-21-x") + monkeypatch.setattr(credentials, "_can_read", lambda p: True) + + assert credentials._run_icacls(path) is True + flat = [a for call in calls for a in call] + assert "/inheritance:r" not in flat + assert "*S-1-5-21-x:F" in flat # granted by SID + assert not any("/reset" in call for call in calls) # readable, so no rollback + + +def test_run_icacls_rolls_back_when_the_grant_bricks_read(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + # If the ACL change somehow leaves the file unreadable by us, restore inherited + # permissions so the next `bisheng deploy` can still read the key. + path = tmp_path / "credentials.json" + path.write_text("{}", encoding="utf-8") + calls: list[list[str]] = [] + monkeypatch.setattr(credentials.subprocess, "run", lambda args, **k: calls.append(args) or _FakeRun()) + monkeypatch.setattr(credentials, "_current_user_grantee", lambda: "*S-1-5-21-x") + reads = iter([False, True]) # unreadable after grant, readable after /reset + monkeypatch.setattr(credentials, "_can_read", lambda p: next(reads)) + + assert credentials._run_icacls(path) is True + assert any("/reset" in call for call in calls) + + +def test_run_icacls_returns_false_only_when_still_unreadable(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + path = tmp_path / "credentials.json" + path.write_text("{}", encoding="utf-8") + monkeypatch.setattr(credentials.subprocess, "run", lambda args, **k: _FakeRun()) + monkeypatch.setattr(credentials, "_current_user_grantee", lambda: "*S-1-5-21-x") + monkeypatch.setattr(credentials, "_can_read", lambda p: False) # even /reset didn't help + + assert credentials._run_icacls(path) is False diff --git a/src/bisheng-cli/tests/test_delivery_v2.py b/src/bisheng-cli/tests/test_delivery_v2.py new file mode 100644 index 0000000000..52d882de94 --- /dev/null +++ b/src/bisheng-cli/tests/test_delivery_v2.py @@ -0,0 +1,35 @@ +"""Scaffold a bundle without changing existing manifests or packing local environments.""" + +import io +import json + +import pytest +import yaml + + +@pytest.mark.parametrize("runtime", ["python3.11", "node20", "static"]) +def test_init_defaults_to_v2_bundle(tmp_path, runtime): + """AC-11, AC-17: new projects use the explicit bundle startup contract.""" + from bisheng_cli.main import run + + out = io.StringIO() + err = io.StringIO() + root = tmp_path / "new-app" + assert run(["init", str(root), "--runtime", runtime, "--json"], stdout=out, stderr=err) == 0 + manifest = yaml.safe_load((root / "bisheng-app.yaml").read_text()) + assert manifest["manifest_version"] == 2 + assert manifest["delivery"]["mode"] == "bundle" + assert manifest["delivery"]["startup"]["command"] + assert json.loads(out.getvalue().splitlines()[-1])["ok"] is True + + +def test_init_preserves_existing_files(tmp_path): + """AC-13, AC-60: initialization never rewrites an existing v1 application.""" + from bisheng_cli.errors import CliError + from bisheng_cli.project import initialize_project + + path = tmp_path / "bisheng-app.yaml" + path.write_text("manifest_version: 1\n") + with pytest.raises(CliError): + initialize_project(tmp_path, "python3.11") + assert path.read_text() == "manifest_version: 1\n" diff --git a/src/bisheng-cli/tests/test_dev_capability_calls.py b/src/bisheng-cli/tests/test_dev_capability_calls.py new file mode 100644 index 0000000000..c158f04883 --- /dev/null +++ b/src/bisheng-cli/tests/test_dev_capability_calls.py @@ -0,0 +1,198 @@ +"""T045 — platform-capability calls under `bisheng dev` (AC-28 / AC-49). + +The decision this pins is 决议-3: the mini proxy injects identity and +environment and **does not proxy** the application's calls to the platform. An +app under `dev` dials `/api/v2` itself, with the credential the developer logged +in with, so the scope check and the visibility filtering are the platform's — +the same code a hosted app meets. The platform-side half of that claim is +`src/backend/test/dev_toolkit/test_dev_capability_parity.py`; this file pins the +CLI half. + +Three ways it could quietly stop being true, one test each: + +* the proxy grows a rule that forwards `/api/v2/**` to the platform — then a + local call would carry whatever the proxy decided to attach, and the two + environments would diverge exactly where nobody looks; +* the proxy attaches the login credential to a forwarded request — the key would + reach the application through a second door, and `dev` would be granting what + the environment is supposed to grant; +* `dev` starts checking scopes before it will run — AC-29 says it must not, and + a CLI-side check is a second, always-stale copy of the platform's answer. +""" + +from __future__ import annotations + +import inspect +import io +from pathlib import Path + +import httpx + +from bisheng_cli import devdb, devproxy +from bisheng_cli.commands import dev as dev_command +from bisheng_cli.devproxy import DevIdentity, DevProxy, HandleMinter +from tests.helpers.platform_mock import FAKE_KEY, FAKE_MODEL_BASE_URL + +PLATFORM = "http://platform.test" + +CAPABILITY_PATHS = ( + "/api/v2/model/v1/chat/completions", + "/api/v2/model/v1/models", + "/api/v2/mcp", + "/api/v2/filelib/retrieve", + "/api/v2/auth/whoami", +) + + +def _identity() -> DevIdentity: + return DevIdentity.from_whoami( + {"actor_kind": "service_account", "actor_id": 123, "actor_name": "问卷小队开发号", "tenant_id": 1}, + app_id="app-1", + ) + + +def _proxy(seen: list[httpx.Request]) -> DevProxy: + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + return httpx.Response(200, content=b"{}", headers={"content-type": "application/json"}) + + identity = _identity() + return DevProxy( + identity=identity, + minter=HandleMinter(identity), + app_port=51234, + listen_port=0, + transport=httpx.MockTransport(handler), + ) + + +def test_a_capability_path_goes_to_the_app_not_to_the_platform() -> None: + """Every request the proxy sees is forwarded to the app, whatever its path. + + The proxy has exactly one upstream. A path that *looks* like a platform API + is just a path the application chose to serve; routing it anywhere else + would mean the app could never own its own `/api/**` namespace either. + """ + seen: list[httpx.Request] = [] + proxy = _proxy(seen) + try: + for path in CAPABILITY_PATHS: + proxy.forward("POST", f"{proxy.prefix}{path}", [("Content-Type", "application/json")], b"{}") + finally: + proxy.stop() + + assert [str(request.url) for request in seen] == [f"http://127.0.0.1:51234{path}" for path in CAPABILITY_PATHS] + assert not [request for request in seen if PLATFORM in str(request.url)] + + +def test_the_proxy_never_attaches_the_login_credential() -> None: + """Not under `Authorization`, not under any other name. + + The application's own `Authorization` header is passed through untouched (it + is the app's request to make), but the proxy adds none of its own: the + credential reaches the process through the environment, which is what the + hosted runtime does too. + """ + seen: list[httpx.Request] = [] + proxy = _proxy(seen) + try: + proxy.forward( + "GET", + f"{proxy.prefix}/api/v2/model/v1/models", + [("Authorization", f"Bearer {FAKE_KEY}")], + b"", + ) + finally: + proxy.stop() + + request = seen[0] + # Passed through exactly once — relayed, not duplicated or replaced. + assert request.headers.get_list("authorization") == [f"Bearer {FAKE_KEY}"] + + # And with no inbound credential, nothing appears. + seen.clear() + proxy = _proxy(seen) + try: + proxy.forward("GET", f"{proxy.prefix}/api/v2/model/v1/models", [], b"") + finally: + proxy.stop() + assert "authorization" not in seen[0].headers + assert FAKE_KEY not in str(seen[0].headers) + + +def test_the_proxy_module_states_that_it_does_not_proxy_capability_calls() -> None: + """决议-3 written where the next editor will read it, not only in the spec.""" + source = inspect.getsource(devproxy) + assert "proxy platform-capability calls" in source + assert "决议-3" in source + + +def test_dev_never_reads_the_scopes_whoami_reported() -> None: + """AC-29 / AC-52: a key with nothing ticked still runs the app locally. + + What that key can *do* is answered per call, by the platform. A CLI-side + scope check would be a second copy of an answer that can change between two + calls (the platform's cache upper bound is three seconds), and `dev` would + start refusing to run code that the platform would happily serve. + """ + source = inspect.getsource(dev_command) + for read in ('whoami.get("scopes")', 'whoami["scopes"]', '"scopes"]'): + assert read not in source, f"`dev` reads {read} — it must not judge permissions" + + +def test_the_model_face_is_wired_from_whoami_not_from_a_composed_url() -> None: + """AC-27: the CLI never spells `/api/v2/model/v1` itself (F051 AC-30). + + A composed address is right until a deployment sits behind a path prefix or + a gateway, and then it is wrong in a way only that customer can see. + """ + for module in (dev_command, devdb): + assert "/api/v2/model/v1" not in inspect.getsource(module) + assert "model_base_url" in inspect.getsource(dev_command) + + +def test_dev_reports_the_model_address_without_ever_printing_the_key(sample_project: Path) -> None: + """AC-24 / AC-04: the developer is told where model calls go, never the key.""" + from bisheng_cli import credentials + from bisheng_cli.output import Emitter + + # Human-readable output is on stderr by design (stdout stays parseable). + out = io.StringIO() + emitter = Emitter(stdout=io.StringIO(), stderr=out) + profile = credentials.Profile(base_url=PLATFORM, api_key=FAKE_KEY, actor_name="问卷小队开发号") + identity = _identity() + db = devdb.prepare_dev_db(sample_project) + start = devdb.StartCommand(argv=["python", "main.py"], source="main.py") + + dev_command._report( + emitter, + profile, + {"actor_kind": "service_account", "actor_name": "问卷小队开发号"}, + identity, + "http://127.0.0.1:8080", + 51234, + db, + start, + FAKE_MODEL_BASE_URL, + ) + printed = out.getvalue() + assert FAKE_MODEL_BASE_URL in printed + assert "OPENAI_BASE_URL" in printed + assert FAKE_KEY not in printed + + out2 = io.StringIO() + dev_command._report( + Emitter(stdout=io.StringIO(), stderr=out2), + profile, + {"actor_kind": "service_account", "actor_name": "问卷小队开发号"}, + identity, + "http://127.0.0.1:8080", + 51234, + db, + start, + "", + ) + # No model face: say so, rather than leaving the developer to discover it + # through a 404 from a client library. + assert "没有部署模型协议面" in out2.getvalue() + assert FAKE_KEY not in out2.getvalue() diff --git a/src/bisheng-cli/tests/test_dev_env.py b/src/bisheng-cli/tests/test_dev_env.py new file mode 100644 index 0000000000..295aacacd0 --- /dev/null +++ b/src/bisheng-cli/tests/test_dev_env.py @@ -0,0 +1,200 @@ +"""T043 — the project-local database and the same-named environment (AC-26 / AC-27). + +The env names are the F054 contract (`contracts-runtime-manager.md` §5); the +cross-check against `runtime_manager.lifecycle.build_env` lives in +`test_platform_contract.py`. What this file pins is the behaviour around them: +platform names win over the shell, the key never enters, the database persists +and can never be packaged, and the start command resolves like the hosted +entrypoint. +""" + +from __future__ import annotations + +import sqlite3 +import sys +import tarfile +from pathlib import Path + +import pytest + +from bisheng_cli import devdb, ignore, packaging +from bisheng_cli.errors import EXIT_LOCAL_INVALID, CliError +from tests.helpers.platform_mock import FAKE_KEY, FAKE_MODEL_BASE_URL + +MANIFEST = {"name": "表单问卷小应用", "runtime": "python3.11", "port": 8080, "slug": "survey"} + + +def _env(project: Path, **overrides) -> dict[str, str]: + db = devdb.prepare_dev_db(project) + kwargs = { + "manifest": MANIFEST, + "app_port": 51234, + "platform_base_url": "http://platform.test", + "app_id": "app-1", + "db": db, + "base_env": {}, + } + kwargs.update(overrides) + return devdb.build_dev_env(**kwargs) + + +def test_env_carries_exactly_the_contract_names_plus_framework_exports(sample_project: Path) -> None: + env = _env(sample_project) + assert set(env) == set(devdb.PLATFORM_ENV_NAMES) | set(devdb.FRAMEWORK_ENV_NAMES) + assert env["PORT"] == env["BISHENG_APP_PORT"] == "51234" + # The hosted value, not an empty one: `DevProxy` serves the app under it. + assert env["BISHENG_APP_BASE_PATH"] == env["UVICORN_ROOT_PATH"] == env["GRADIO_ROOT_PATH"] == "/apps/survey" + assert env["STREAMLIT_SERVER_BASE_URL_PATH"] == "apps/survey" and env["FORWARDED_ALLOW_IPS"] == "*" + assert env["BISHENG_APP_SLUG"] == "survey" and env["BISHENG_APP_ID"] == "app-1" + assert env["BISHENG_APP_VERSION"] == "dev" and env["BISHENG_APP_VERSION_ID"] == "dev" + assert env["BISHENG_PLATFORM_API_BASE"] == "http://platform.test" + assert env["BISHENG_APP_HEALTH_PATH"] == "/" + assert env["BISHENG_APP_DB_URL"].startswith("sqlite:///") and env["BISHENG_APP_DB_PATH"].endswith("app.db") + + +def test_platform_names_override_the_shell_and_the_key_never_enters(sample_project: Path) -> None: + shell = { + "PATH": "/usr/bin", + "PORT": "1", + "BISHENG_APP_DB_URL": "sqlite:///elsewhere.db", + "BISHENG_PLATFORM_API_BASE": "http://wrong.test", + "BISHENG_API_KEY": FAKE_KEY, + "MY_OWN_SETTING": "kept", + "BISHENG_APP_START": "python serve.py", + } + env = _env(sample_project, base_env=shell) + assert env["PATH"] == "/usr/bin" and env["MY_OWN_SETTING"] == "kept" + assert env["PORT"] == "51234" and env["BISHENG_PLATFORM_API_BASE"] == "http://platform.test" + assert env["BISHENG_APP_DB_URL"] != "sqlite:///elsewhere.db" + assert env["BISHENG_APP_START"] == "python serve.py" # not ours to override + assert "BISHENG_API_KEY" not in env + assert FAKE_KEY not in "".join(env.values()) + # The reserved prefixes are the hosted runtime's; every name we set is under one. + assert all(name.startswith(devdb.RESERVED_ENV_PREFIXES) for name in devdb.PLATFORM_ENV_NAMES) + + +# ---- the model face's three names (AC-27 / AC-28) --------------------------- + + +def test_model_face_names_carry_the_address_whoami_reported(sample_project: Path) -> None: + """The base URL is F051's field verbatim — nothing is composed here. + + Both OpenAI spellings and the platform's reserved name get the same value, + so an app can read whichever its engine prefers and behave the same hosted. + """ + env = _env(sample_project, model_base_url=FAKE_MODEL_BASE_URL, model_api_key=FAKE_KEY) + assert env["OPENAI_BASE_URL"] == FAKE_MODEL_BASE_URL + assert env["BISHENG_MODEL_BASE_URL"] == FAKE_MODEL_BASE_URL + assert env["OPENAI_API_KEY"] == FAKE_KEY + assert set(env) == set(devdb.PLATFORM_ENV_NAMES) | set(devdb.FRAMEWORK_ENV_NAMES) | set(devdb.MODEL_FACE_ENV_NAMES) + # The key reaches the app under exactly one name — the platform's name for + # "the credential this process calls the model face with" — and never under + # the name the CLI itself reads it from. + assert "BISHENG_API_KEY" not in env + assert [name for name, value in env.items() if value == FAKE_KEY] == ["OPENAI_API_KEY"] + + +def test_no_model_face_means_the_three_names_are_removed_not_inherited(sample_project: Path) -> None: + """AC-28 fail-closed, one step earlier: no face, no half-wiring. + + A developer's personal `OPENAI_API_KEY` surviving into the app would make + model calls work locally against somebody else's service and fail the moment + the same code is hosted — the exact local-only divergence INV-32 forbids. + """ + shell = { + "OPENAI_BASE_URL": "https://api.openai.com/v1", + "OPENAI_API_KEY": "sk-personal", + "BISHENG_MODEL_BASE_URL": "https://elsewhere.test/v1", + } + env = _env(sample_project, base_env=shell, model_base_url="", model_api_key=FAKE_KEY) + for name in devdb.MODEL_FACE_ENV_NAMES: + assert name not in env + assert "sk-personal" not in "".join(env.values()) + + +def test_an_address_without_a_credential_wires_nothing(sample_project: Path) -> None: + env = _env(sample_project, model_base_url=FAKE_MODEL_BASE_URL, model_api_key=None) + for name in devdb.MODEL_FACE_ENV_NAMES: + assert name not in env + + +def test_db_lives_under_dot_bisheng_dev_and_persists_across_runs(sample_project: Path) -> None: + first = devdb.prepare_dev_db(sample_project) + assert first.path == sample_project / ".bisheng" / "dev" / "app.db" + assert first.path.is_file() + conn = sqlite3.connect(first.path) + conn.execute("CREATE TABLE t (v TEXT)") + conn.execute("INSERT INTO t VALUES ('kept')") + conn.commit() + conn.close() + second = devdb.prepare_dev_db(sample_project) # a restart + assert second == first + assert sqlite3.connect(second.path).execute("SELECT v FROM t").fetchone() == ("kept",) + + +def test_db_url_is_the_four_slash_absolute_form() -> None: + db = devdb.DevDatabase(path=Path("/tmp/x/app.db"), url="sqlite:////tmp/x/app.db") + assert db.url == f"sqlite:///{db.path.as_posix()}" + + +def test_db_never_enters_the_upload_package(sample_project: Path, tmp_path: Path) -> None: + # AC-26 / AC-32: `.bisheng/` is a hard exclude — `!` cannot bring it back. + devdb.prepare_dev_db(sample_project) + (sample_project / ".bishengignore").write_text("!.bisheng/\n!*.db\n", encoding="utf-8") + result = ignore.collect_files(sample_project) + assert not [p for p in result.files if ".bisheng/" in str(p) or str(p).endswith("app.db")] + stat = packaging.build_package(sample_project, result, tmp_path / "pkg.tar.gz") + assert stat.entry_count > 0 + with tarfile.open(tmp_path / "pkg.tar.gz") as tar: + assert not [m.name for m in tar.getmembers() if ".bisheng/" in m.name or m.name.endswith("app.db")] + + +# ---- start command (entrypoint.sh.j2 order) --------------------------------- + + +def test_start_resolution_order_matches_the_hosted_entrypoint(tmp_path: Path) -> None: + root = tmp_path / "proj" + root.mkdir() + with pytest.raises(CliError) as excinfo: + devdb.resolve_start_command(root, environ={}) + assert excinfo.value.exit_code == EXIT_LOCAL_INVALID + for option in ("BISHENG_APP_START", "Procfile", "main.py", "app.py"): + assert option in excinfo.value.next_step + + (root / "app.py").write_text("", encoding="utf-8") + assert devdb.resolve_start_command(root, environ={}).argv == [sys.executable, "app.py"] + + (root / "main.py").write_text("", encoding="utf-8") + assert devdb.resolve_start_command(root, environ={}).source == "main.py" + + (root / "Procfile").write_text("worker: python w.py\nweb: uvicorn app:app --port $PORT\n", encoding="utf-8") + start = devdb.resolve_start_command(root, environ={}) + assert start.source == "Procfile web:" and start.argv[-1] == "uvicorn app:app --port $PORT" + + start = devdb.resolve_start_command(root, environ={"BISHENG_APP_START": "python custom.py"}) + assert start.source == "BISHENG_APP_START" and start.argv[-1] == "python custom.py" + assert start.display.endswith("python custom.py") + + +def test_pick_free_port_avoids_the_proxy_port() -> None: + port = devdb.pick_free_port(exclude=1) + assert 1024 < port < 65536 and port != 1 + + +@pytest.mark.parametrize( + ("manifest", "app_ref", "expected"), + [ + ({"slug": "survey"}, {"slug": "assigned"}, "survey"), + ({}, {"slug": "assigned"}, "assigned"), + ({}, None, devdb.DEV_FALLBACK_SLUG), + ({"slug": " "}, {}, devdb.DEV_FALLBACK_SLUG), + ], +) +def test_dev_slug_prefers_the_declared_then_the_assigned_slug(manifest, app_ref, expected) -> None: + assert devdb.dev_slug(manifest, app_ref) == expected + + +def test_the_base_path_follows_the_slug_it_is_given(sample_project: Path) -> None: + env = _env(sample_project, slug="assigned") + assert env["BISHENG_APP_SLUG"] == "assigned" + assert env["BISHENG_APP_BASE_PATH"] == "/apps/assigned" diff --git a/src/bisheng-cli/tests/test_dev_proxy.py b/src/bisheng-cli/tests/test_dev_proxy.py new file mode 100644 index 0000000000..7d277e32f6 --- /dev/null +++ b/src/bisheng-cli/tests/test_dev_proxy.py @@ -0,0 +1,369 @@ +"""T042 — the `bisheng dev` mini proxy: strip forged identity, inject the real one. + +Mirrors `src/app-proxy/tests/test_headers.py` case for case, because the two +proxies must behave identically for an app to be testable locally (AC-23 / +AC-25). The forged-variant list is the CVE-2025-64484 shape: same header as far +as a WSGI-family framework is concerned, four spellings. +""" + +from __future__ import annotations + +import http.client +import io +import json +from urllib.parse import unquote + +import httpx +import pytest + +from bisheng_cli import devproxy +from bisheng_cli.devproxy import ( + HANDLE_TTL_SECONDS, + INJECTED_HEADER_NAMES, + DevIdentity, + DevProxy, + HandleMinter, + build_injected_headers, + build_upstream_headers, + encode_header_value, + normalize_header_name, + strip_platform_headers, +) +from bisheng_cli.output import Emitter +from tests.helpers.platform_mock import FAKE_KEY + +FORGED_VARIANTS = [ + ("X-BiSheng-User-Id", "1"), + ("X_BiSheng_User_Id", "1"), + ("x-bisheng-user-id", "1"), + ("X-BISHENG-USER-ID", "1"), + ("x_bisheng_USER_name", "root"), + ("X-BiSheng_Tenant-Id", "999"), + ("x-bisheng-subject-kind", "human"), + ("X_BISHENG_ACCESS_TOKEN", "forged.jwt"), + ("x-BiSheng-Dept-Path", "/root"), + ("X-Bisheng-Anything-New-We-Add-Later", "boom"), +] + +WHOAMI = { + "actor_kind": "service_account", + "actor_id": 123, + "actor_name": "问卷小队开发号", + "tenant_id": 1, +} + + +def _identity(**overrides) -> DevIdentity: + return DevIdentity.from_whoami({**WHOAMI, **overrides}, app_id=overrides.pop("app_id", "app-1")) + + +def _names(pairs) -> set[str]: + return {name.lower() for name, _ in pairs} + + +# ---- normalisation + strip (AC-25) ----------------------------------------- + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("X-BiSheng-User-Id", "x-bisheng-user-id"), + ("X_BiSheng_User_Id", "x-bisheng-user-id"), + ("x_bisheng_user_id", "x-bisheng-user-id"), + ("X-BISHENG-USER-ID", "x-bisheng-user-id"), + ], +) +def test_underscore_hyphen_case_collapse_to_one_name(raw: str, expected: str) -> None: + assert normalize_header_name(raw) == expected + + +def test_strip_all_x_bisheng_equivalence_class() -> None: + kept = strip_platform_headers([*FORGED_VARIANTS, ("Accept", "*/*")]) + assert kept == [("Accept", "*/*")] + + +def test_non_platform_headers_survive_untouched() -> None: + inbound = [("Accept", "text/html"), ("X-Custom-Thing", "kept"), ("Authorization", "Bearer app-own-token")] + assert strip_platform_headers(inbound) == inbound + + +def test_forged_header_has_no_effect_on_upstream() -> None: + identity = _identity() + upstream = build_upstream_headers(FORGED_VARIANTS, identity, request_id="rid-1", handle="h") + values = {name.lower(): value for name, value in upstream} + assert values["x-bisheng-user-id"] == "123" + assert unquote(values["x-bisheng-user-name"]) == "问卷小队开发号" + assert values["x-bisheng-tenant-id"] == "1" + assert values["x-bisheng-subject-kind"] == "service_account" + assert values["x-bisheng-access-token"] == "h" + assert "x-bisheng-dept-path" not in values + assert "x-bisheng-anything-new-we-add-later" not in values + # Every platform header on the wire is one of the canonical ten, exactly once. + platform = [name for name, _ in upstream if name.lower().startswith("x-bisheng-")] + assert len(platform) == len(set(platform)) + assert set(platform) <= set(INJECTED_HEADER_NAMES) + + +def test_forwarded_and_hop_by_hop_headers_dropped_and_rewritten() -> None: + inbound = [ + ("X-Forwarded-Prefix", "/apps/evil"), + ("X-Forwarded-Host", "attacker.example"), + ("Forwarded", "for=1.2.3.4"), + ("Transfer-Encoding", "chunked"), + ("Connection", "keep-alive"), + ("Host", "localhost:8080"), + ("Content-Length", "99"), + ] + upstream = build_upstream_headers( + inbound, _identity(), request_id="rid", handle=None, host="localhost:8080", prefix="/apps/survey" + ) + values = dict(upstream) + # Ours, never the client's: the same value app-proxy fills in hosted. + assert values["X-Forwarded-Prefix"] == "/apps/survey" + assert values["X-Forwarded-Proto"] == "http" + assert values["X-Forwarded-Host"] == "localhost:8080" + assert "forwarded" not in _names(upstream) + assert not {"transfer-encoding", "connection", "host", "content-length"} & _names(upstream) + + +def test_platform_session_cookie_removed_but_app_cookies_kept() -> None: + inbound = [("Cookie", "access_token_cookie=platform-jwt; theme=dark")] + assert strip_platform_headers(inbound) == [("Cookie", "theme=dark")] + assert strip_platform_headers([("Cookie", "access_token_cookie=platform-jwt")]) == [] + + +# ---- inject (AC-23) ---------------------------------------------------------- + + +def test_inject_headers_in_canonical_order_omitting_absent_material() -> None: + injected = build_injected_headers(_identity(), request_id="rid-9", handle="handle-9", host="localhost") + platform = [name for name, _ in injected if name.startswith("X-BiSheng-")] + expected = [ + "X-BiSheng-User-Id", + "X-BiSheng-User-Name", + "X-BiSheng-Tenant-Id", + "X-BiSheng-Subject-Kind", + "X-BiSheng-App-Id", + "X-BiSheng-Access-Token", + "X-BiSheng-Request-Id", + ] + assert platform == expected # same relative order as app-proxy's ten; Dept-* absent + assert [name for name in INJECTED_HEADER_NAMES if name in platform] == platform + values = dict(injected) + assert values["X-BiSheng-App-Id"] == "app-1" and values["X-BiSheng-Request-Id"] == "rid-9" + # Absent material is omitted, never emitted empty. + assert "" not in [value for name, value in injected if name.startswith("X-BiSheng-")] + + +def test_no_app_ref_means_no_app_id_header() -> None: + injected = dict(build_injected_headers(_identity(app_id=None), request_id="r", handle="h")) + assert "X-BiSheng-App-Id" not in injected + + +def test_personal_token_holder_is_human_not_service_account() -> None: + identity = _identity(actor_kind="natural_person") + assert identity.subject_kind == "human" + + +def test_chinese_values_percent_encoded_once_ascii_untouched() -> None: + assert encode_header_value("张三") == "%E5%BC%A0%E4%B8%89" + assert encode_header_value("%E5%BC%A0%E4%B8%89") == "%E5%BC%A0%E4%B8%89" + assert encode_header_value("plain-ascii") == "plain-ascii" + assert "\r" not in encode_header_value("a\r\nb") + name = dict(build_injected_headers(_identity(), request_id="r", handle=None))["X-BiSheng-User-Name"] + assert name.isascii() and unquote(name) == "问卷小队开发号" + + +# ---- the short-lived handle --------------------------------------------------- + + +def test_handle_is_fresh_per_request_short_lived_and_never_the_key() -> None: + now = [1_000_000.0] + minter = HandleMinter(_identity(), now=lambda: now[0]) + first, second = minter.mint("rid-1"), minter.mint("rid-2") + assert first != second + assert FAKE_KEY not in first and "bs-sak-" not in first + payload = minter.verify(first) + assert payload and payload["rid"] == "rid-1" + assert payload["exp"] - payload["iat"] == HANDLE_TTL_SECONDS == 900 + assert payload["sub"] == {"app_id": "app-1", "user_id": "123", "tenant_id": "1", "subject_kind": "service_account"} + # Expired or tampered handles do not verify. + now[0] += HANDLE_TTL_SECONDS + 1 + assert minter.verify(first) is None + assert HandleMinter(_identity()).verify(first) is None # another session's secret + assert minter.verify(first[:-2] + "zz") is None + + +# ---- forwarding end to end --------------------------------------------------- + + +def _echo_transport(seen: list[httpx.Request]) -> httpx.MockTransport: + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + body = json.dumps({"path": request.url.raw_path.decode(), "headers": request.headers.multi_items()}) + return httpx.Response(200, content=body, headers={"content-type": "application/json", "x-app": "yes"}) + + return httpx.MockTransport(handler) + + +def test_forward_strips_and_injects_and_relays_response() -> None: + seen: list[httpx.Request] = [] + identity = _identity() + proxy = DevProxy( + identity=identity, + minter=HandleMinter(identity), + app_port=1, + listen_port=0, + transport=_echo_transport(seen), + request_id_factory=lambda: "fixed-rid", + ) + result = proxy.forward("POST", "/apps/dev/submit?x=1", [*FORGED_VARIANTS, ("Content-Type", "text/plain")], b"hello") + assert result.status == 200 + assert dict(result.headers)["x-app"] == "yes" + assert b"".join(result.body) + request = seen[0] + assert request.method == "POST" and request.url.raw_path == b"/submit?x=1" and request.content == b"hello" + assert request.headers["x-bisheng-user-id"] == "123" + assert request.headers["x-bisheng-request-id"] == "fixed-rid" + assert request.headers["x-bisheng-subject-kind"] == "service_account" + assert proxy.minter.verify(request.headers["x-bisheng-access-token"]) + assert FAKE_KEY not in str(request.headers) + proxy.stop() + + +def test_app_not_listening_is_a_readable_502_not_a_crash() -> None: + identity = _identity() + err = io.StringIO() + proxy = DevProxy( + identity=identity, + minter=HandleMinter(identity), + app_port=1, + listen_port=0, + transport=httpx.MockTransport(lambda r: (_ for _ in ()).throw(httpx.ConnectError("refused"))), + emitter=Emitter(stdout=io.StringIO(), stderr=err), + ) + result = proxy.forward("GET", "/apps/dev/", [], b"") + assert result.status == 502 + assert "PORT" in b"".join(result.body).decode("utf-8") + assert "警告" in err.getvalue() + proxy.stop() + + +def test_real_listener_round_trip_over_loopback() -> None: + """The stdlib server shell: bytes in on a socket, upstream over httpx, bytes out.""" + seen: list[httpx.Request] = [] + identity = _identity() + proxy = DevProxy( + identity=identity, minter=HandleMinter(identity), app_port=1, listen_port=0, transport=_echo_transport(seen) + ) + proxy.start() + try: + conn = http.client.HTTPConnection("127.0.0.1", proxy.listen_port, timeout=5) + conn.request("GET", "/apps/dev/hello?a=b", headers={"X_BiSheng_User_Id": "forged", "X-Custom": "kept"}) + response = conn.getresponse() + payload = json.loads(response.read()) + conn.close() + finally: + proxy.stop() + assert response.status == 200 + assert payload["path"] == "/hello?a=b" + upstream = {name.lower(): value for name, value in payload["headers"]} + assert upstream["x-bisheng-user-id"] == "123" and upstream["x-custom"] == "kept" + assert proxy.url.startswith("http://127.0.0.1:") and proxy.url.endswith("/apps/dev/") + + +def test_module_exposes_no_identity_override() -> None: + # AC-25: nothing in the proxy takes "who to be" from anywhere but the identity + # built from `whoami`. + assert not [name for name in dir(devproxy) if "impersonat" in name.lower() or "on_behalf" in name.lower()] + + +# ---- served under the hosted prefix --------------------------------------------- + + +def _prefixed_proxy(seen: list[httpx.Request], *, err: io.StringIO | None = None, location: str = "") -> DevProxy: + identity = _identity() + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + headers = {"content-type": "text/plain"} + if location: + return httpx.Response(302, headers={**headers, "location": location}) + return httpx.Response(200, content=request.url.raw_path, headers=headers) + + return DevProxy( + identity=identity, + minter=HandleMinter(identity), + app_port=51234, + listen_port=0, + transport=httpx.MockTransport(handler), + emitter=Emitter(stdout=io.StringIO(), stderr=err or io.StringIO()), + slug="survey", + ) + + +@pytest.mark.parametrize( + ("path", "upstream_path"), + [("/apps/survey/", "/"), ("/apps/survey/x?y=1", "/x?y=1"), ("/apps/survey/static/app.css", "/static/app.css")], +) +def test_the_prefix_is_stripped_like_app_proxy_does(path: str, upstream_path: str) -> None: + seen: list[httpx.Request] = [] + proxy = _prefixed_proxy(seen) + result = proxy.forward("GET", path, [], b"") + proxy.stop() + assert result.status == 200 + assert seen[0].url.raw_path.decode() == upstream_path + assert seen[0].headers["x-forwarded-prefix"] == "/apps/survey" + + +def test_the_bare_prefix_redirects_to_its_slashed_form() -> None: + seen: list[httpx.Request] = [] + proxy = _prefixed_proxy(seen) + result = proxy.forward("GET", "/apps/survey?a=1", [], b"") + proxy.stop() + assert result.status == 308 + assert dict(result.headers)["Location"] == "/apps/survey/?a=1" + assert seen == [] + + +@pytest.mark.parametrize("path", ["/logo.png", "/api/x", "/apps/other/", "/"]) +def test_a_request_outside_the_prefix_is_a_404_that_names_the_cause(path: str) -> None: + """Hosted, this request reaches the platform instead of the app; locally the cause is named.""" + seen: list[httpx.Request] = [] + err = io.StringIO() + proxy = _prefixed_proxy(seen, err=err) + result = proxy.forward("GET", path, [], b"") + proxy.stop() + page = b"".join(result.body).decode("utf-8") + assert result.status == 404 and seen == [] + assert "BISHENG_APP_BASE_PATH" in page and "/apps/survey/" in page + if path == "/": + # The habitual first visit: no warning, a link to the app. + assert "href='/apps/survey/'" in page and err.getvalue() == "" + else: + assert path in err.getvalue() + + +def test_each_stray_path_is_warned_about_once() -> None: + seen: list[httpx.Request] = [] + err = io.StringIO() + proxy = _prefixed_proxy(seen, err=err) + for _ in range(3): + proxy.forward("GET", "/logo.png", [], b"") + proxy.forward("GET", "/favicon.ico", [], b"") + proxy.stop() + assert err.getvalue().count("/logo.png") == 1 + assert "/favicon.ico" not in err.getvalue() + + +@pytest.mark.parametrize( + ("sent", "seen_by_browser"), + [("/login", "/apps/survey/login"), ("/apps/survey/next", "/apps/survey/next"), ("next", "next")], +) +def test_a_redirect_from_the_app_stays_inside_the_prefix(sent: str, seen_by_browser: str) -> None: + seen: list[httpx.Request] = [] + proxy = _prefixed_proxy(seen, location=sent) + result = proxy.forward("GET", "/apps/survey/admin", [], b"") + proxy.stop() + assert result.status == 302 + assert {name.lower(): value for name, value in result.headers}["location"] == seen_by_browser diff --git a/src/bisheng-cli/tests/test_distribution_contract.py b/src/bisheng-cli/tests/test_distribution_contract.py new file mode 100644 index 0000000000..979aafdb78 --- /dev/null +++ b/src/bisheng-cli/tests/test_distribution_contract.py @@ -0,0 +1,51 @@ +"""F119 覆盖 AC: AC-01, AC-27: test the wheel actually downloaded by platform users.""" + +import hashlib +import json +import subprocess +import sys +import tomllib +import zipfile +from email.parser import Parser +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +ARTIFACTS = ROOT / "src/backend/bisheng/dev_toolkit/artifacts" + + +def test_source_metadata_wheel_and_platform_manifest_agree(): + import bisheng_cli + + distribution = json.loads((ARTIFACTS / "manifest.json").read_text())["cli"] + wheel = ARTIFACTS / distribution["filename"] + assert distribution["version"] == bisheng_cli.__version__ + assert ( + tomllib.loads((ROOT / "src/bisheng-cli/pyproject.toml").read_text())["project"]["version"] + == bisheng_cli.__version__ + ) + assert hashlib.sha256(wheel.read_bytes()).hexdigest() == distribution["sha256"] + with zipfile.ZipFile(wheel) as archive: + metadata = Parser().parsestr( + archive.read(next(n for n in archive.namelist() if n.endswith(".dist-info/METADATA"))).decode() + ) + assert metadata["Version"] == distribution["version"] + assert archive.read("bisheng_cli/errors.py") == (ROOT / "src/bisheng-cli/bisheng_cli/errors.py").read_bytes() + + +def test_downloaded_wheel_has_the_new_error_behavior_in_an_isolated_python(): + distribution = json.loads((ARTIFACTS / "manifest.json").read_text())["cli"] + wheel = ARTIFACTS / distribution["filename"] + script = "import sys; sys.path.insert(0, sys.argv[1]); from bisheng_cli.errors import error_from_platform,render_human; err=error_from_platform(16124,'failed',details={'reason':'startup_exited','tail':[]}); assert err.code==16124 and err.exit_code==10; text=render_human(err); assert 'startup_exited' in text and '暂无可用' in text" + subprocess.run([sys.executable, "-I", "-c", script, str(wheel)], check=True, capture_output=True) + + +def test_cli_packaging_preserves_the_existing_sdk_distribution(): + manifest = json.loads((ARTIFACTS / "manifest.json").read_text()) + previous = json.loads( + subprocess.check_output( + ["git", "show", "HEAD:src/backend/bisheng/dev_toolkit/artifacts/manifest.json"], cwd=ROOT + ) + ) + assert manifest["sdk"] == previous["sdk"] + sdk = ARTIFACTS / manifest["sdk"]["filename"] + assert hashlib.sha256(sdk.read_bytes()).hexdigest() == manifest["sdk"]["sha256"] diff --git a/src/bisheng-cli/tests/test_errors.py b/src/bisheng-cli/tests/test_errors.py new file mode 100644 index 0000000000..a6eb6631dd --- /dev/null +++ b/src/bisheng-cli/tests/test_errors.py @@ -0,0 +1,351 @@ +"""T003 — the exit-code table and the error translation on top of it. + +The table's dividing line is **what the developer (or their agent) does next**, +not how bad the failure is. Two codes whose next action differs must land on +different exit codes; that is the whole reason this file exists. +""" + +from __future__ import annotations + +import pytest + +from bisheng_cli.errors import ( + ERROR_EXIT_CODES, + ERROR_HINTS, + EXIT_AUTH, + EXIT_CAPACITY, + EXIT_DEFECT, + EXIT_FORBIDDEN, + EXIT_INTERNAL, + EXIT_LOCAL_INVALID, + EXIT_NOT_ENABLED, + EXIT_PRECHECK_FAILED, + EXIT_SCENE_MISSING, + EXIT_UNKNOWN_CODE, + EXIT_UNREACHABLE, + CliError, + delegate_refusal, + error_from_platform, + render_human, +) +from tests.helpers.platform_mock import FAKE_KEY + + +def _err(code: int, message: str = "server said so", **kw) -> CliError: + return error_from_platform(code, message, **kw) + + +def test_exit_code_table_is_total() -> None: + assert set(ERROR_HINTS) == set(ERROR_EXIT_CODES) + for code, (human, next_step) in ERROR_HINTS.items(): + assert human.strip(), f"{code} has no human sentence" + assert next_step.strip(), f"{code} has no next step" + + +def test_16225_and_16226_map_to_different_codes_and_different_next_step() -> None: + scene, capacity = _err(16225), _err(16226) + assert scene.exit_code == EXIT_SCENE_MISSING == 13 + assert capacity.exit_code == EXIT_CAPACITY == 14 + assert scene.next_step != capacity.next_step + + +@pytest.mark.parametrize("code", [16125, 16226]) +def test_v2_and_legacy_capacity_failures_have_the_same_next_action(code: int) -> None: + """F059 T264 — coverage AC-02, AC-08: capacity stays actionable and redacted.""" + err = _err(code, f"Insufficient runtime capacity {FAKE_KEY}", details={"key": FAKE_KEY}) + legacy = _err(16226) + assert err.exit_code == EXIT_CAPACITY == 14 + assert err.code == code + assert (err.message, err.next_step) == (legacy.message, legacy.next_step) + assert FAKE_KEY not in render_human(err) + + +def test_16231_and_16230_are_precheck_failures_with_the_fix_in_the_manifest() -> None: + for code in (16230, 16231): + err = _err(code) + assert err.exit_code == EXIT_PRECHECK_FAILED == 10 + assert err.exit_code != EXIT_NOT_ENABLED + assert "bisheng-app.yaml" in err.next_step + # 16231 is raised only when this deployment has not opened the capability + # the declaration needs (manifest_validator._check_capabilities), so the + # administrator is one of the two ways out — the platform copy says so too. + assert "管理员" in _err(16231).next_step + + +def test_16232_says_to_delete_the_key_not_that_websocket_is_unsupported() -> None: + err = _err(16232) + assert "不支持" not in err.message + err.next_step and "4501" not in err.next_step + assert "重连" in err.next_step + + +def test_16203_next_step_points_to_package_root_not_create_manifest() -> None: + err = _err(16203) + assert err.exit_code == EXIT_LOCAL_INVALID + assert "包根" in err.next_step or "PATH" in err.next_step + assert "创建" not in err.next_step + + +def test_26003_prints_required_scope_verbatim() -> None: + # data.required is a single string; ", ".join() on it would print "a, p, p, :…". + err = _err(26003, "scope missing", details={"exception": "scope missing", "required": "app:manage"}) + assert err.exit_code == EXIT_FORBIDDEN + assert "app:manage" in render_human(err) + + +def test_26016_and_26051_both_say_delegate_only_and_never_ask_for_a_header() -> None: + """INV-31: the two gates, one verdict. + + `26016` is where a delegate-only key actually lands during `bisheng login` + (`whoami` requires no scope, so the entrance gate waves it through and the + delegation resolver rejects it), and its server-side sentence — "send + X-On-Behalf-Of" — is advice no CLI caller can take: the CLI never sends + identity headers on any command. `26051` is the entrance gate itself on + `deploy` / `logs`. Both must read as "this key is the wrong kind". + """ + for code in (26016, 26051): + err = _err(code, "X-On-Behalf-Of is required for a delegated credential") + assert err.exit_code == EXIT_FORBIDDEN + assert "委托" in err.message and "本地开发" in err.message + assert "另外签发" in err.next_step + # The platform sentence is still echoed under 平台信息 (三部分,缺一不可); + # what must not happen is the CLI's own advice repeating it. + assert "X-On-Behalf-Of" not in err.message + err.next_step + assert "X-On-Behalf-Of" in render_human(err) + + +def test_delegate_refusal_reads_identically_at_all_three_gates() -> None: + # The CLI-side check in `login`, 26016 and 26051 are one product rule. Three + # wordings for it would read as three different problems. + local = delegate_refusal() + for code in (26016, 26051): + err = _err(code) + assert (err.message, err.next_step) == (local.message, local.next_step) + assert err.exit_code == local.exit_code + + +def test_delegate_only_key_is_not_a_defect_and_not_an_unknown_code() -> None: + # 18 says "stop and file a bug", 19 says "read the message, maybe retry with + # different arguments". Neither fits: the key is simply the wrong kind, and + # the fix is a new key from an administrator. + for code in (26016, 26051): + assert ERROR_EXIT_CODES[code] == EXIT_FORBIDDEN + assert ERROR_EXIT_CODES[code] not in (EXIT_DEFECT, EXIT_UNKNOWN_CODE, EXIT_INTERNAL) + + +def test_26002_covers_the_disabled_account_case_it_actually_receives() -> None: + """The server folds four causes into 26002, and the copy has to say so. + + `credential_validator.resolve_service_account` raises + `OpenApiCredentialInvalidError` — 26002 — when the account behind the key is + missing, disabled or in another tenant. The separate 26027 that F049 raised + on this path survives only on the management face (issuing a key), which no + CLI command calls. A next step reading only "get a new key" therefore sends + the holder of a disabled account round a loop that cannot terminate. + """ + texts = {code: render_human(_err(code)) for code in (26001, 26002, 26027)} + assert len(set(texts.values())) == 3 + for code in texts: + assert ERROR_EXIT_CODES[code] == EXIT_AUTH + assert "停用" in texts[26002] + assert "换一把密钥没有用" in texts[26027] + + +def test_personal_token_codes_are_registered_not_generic_403_or_401() -> None: + # beta2 authenticates `bs-pat-` tokens on the same endpoints, so the CLI can + # receive these. Unregistered they degraded by HTTP status into "ask your + # admin about the permission bits" / "check whether the key expired", which + # is the wrong investigation: the problem is the credential *kind*. + disabled, holder_gone = _err(26040, http_status=403), _err(26043, http_status=401) + assert (disabled.exit_code, holder_gone.exit_code) == (EXIT_FORBIDDEN, EXIT_AUTH) + for err in (disabled, holder_gone): + assert "个人" in err.message + assert "bs-sak-" in err.next_step + + +def test_26030_marked_retryable() -> None: + err = _err(26030, http_status=503) + assert err.exit_code == EXIT_UNREACHABLE + assert "可重试" in render_human(err) + + +def test_16207_maps_to_layer_not_enabled() -> None: + err = _err(16207) + assert err.exit_code == EXIT_NOT_ENABLED + assert "应用工场" in render_human(err) + + +def test_build_failure_prints_the_log_excerpt_it_tells_you_to_read() -> None: + """The hints say "look at the log excerpt"; there has to be one to look at. + + ``details`` used to reach ``--json`` only, so in a terminal that advice + pointed at nothing and the natural next move was to re-run the same deploy + unchanged. + """ + err = _err( + 16227, + "依赖构建失败", + details={ + "reason": "build_network_blocked", + "tail": [ + "Step 5/17 : RUN pip install -r requirements.txt", + "ERROR: No matching distribution found for fastapi", + ], + }, + hints=["构建容器连不上任何软件源, 这是构建环境的网络问题, 与 requirements.txt 无关"], + ) + + text = render_human(err) + + assert "No matching distribution found for fastapi" in text + assert "构建环境的网络问题" in text + + +def test_a_failed_start_prints_the_apps_own_last_output() -> None: + """16228's probe instance is removed right after; its output is all that is left.""" + err = _err( + 16228, + "应用启动探活失败", + details={"reason": "probe_not_ready", "tail": ["ModuleNotFoundError: No module named 'fastapi'"]}, + ) + + text = render_human(err) + + assert "应用启动输出(末尾)" in text and "构建日志" not in text + assert "No module named 'fastapi'" in text + + +def test_a_failure_without_a_log_excerpt_prints_no_empty_section() -> None: + """Most codes carry no ``tail``; they must not grow a dangling header.""" + text = render_human(_err(26003, "scope missing", details={"required": "app:manage"})) + + assert "构建日志" not in text + + +def test_unknown_code_falls_back_to_exit_19_not_exit_1() -> None: + err = _err(16999, "brand new failure") + assert err.exit_code == EXIT_UNKNOWN_CODE == 19 + assert err.exit_code != EXIT_INTERNAL + text = render_human(err) + assert "16999" in text and "brand new failure" in text + + +def test_161_segment_codes_are_registered_not_unknown() -> None: + # CON-8: deploy / logs consume F054's 161 segment too. + assert _err(16121).exit_code == EXIT_UNREACHABLE # orchestrator down != app missing + assert _err(16101).exit_code == EXIT_LOCAL_INVALID + for code in (16121, 16101): + assert _err(code).exit_code != EXIT_UNKNOWN_CODE + + +@pytest.mark.parametrize( + ("http_status", "expected"), + [ + (503, EXIT_UNREACHABLE), + (502, EXIT_UNREACHABLE), + (504, EXIT_UNREACHABLE), + (401, EXIT_AUTH), + (403, EXIT_FORBIDDEN), + ], +) +def test_unknown_code_with_5xx_degrades_to_retryable_not_19(http_status: int, expected: int) -> None: + # FGA / DB faults surface as HTTP 503 + code 19002; keying only on the code + # would send the user off to check their key. + err = _err(19002, "fga unavailable", http_status=http_status) + assert err.exit_code == expected + + +def test_unknown_code_without_usable_http_status_falls_to_19() -> None: + assert _err(19002, "weird", http_status=400).exit_code == EXIT_UNKNOWN_CODE + + +def test_26004_reads_as_delegation_refused_and_still_points_off_the_key() -> None: + """26004's meaning moved; its next step had to move with it. + + Under F049 it meant "any identity-passing header at all". beta2 raises it + from `identity_service.resolve_request_identity` for a request that *did* + carry `X-On-Behalf-Of` and whose delegation was refused — no `delegate` bit, + or a target outside the key's delegate scope. What survives unchanged is the + part the caller acts on: this CLI sends no identity headers on any command, + so the header came from somewhere else on the path, and neither the key nor + its permission bits are the thing to go and change. + """ + text = render_human(_err(26004)) + assert "委托" in text + assert "X-On-Behalf-Of" in text + assert "网关" in text or "代理" in text + assert "CLI 从不发送" in text + # The head sentence now names the key, because that is what the server's + # verdict is about ("this key has no delegate bit"). What must not happen is + # the *next step* sending the reader off to change it: no CLI key delegates, + # so no key change and no permission bit can produce a different outcome. + next_step = _err(26004).next_step + assert "改密钥或权限位都不解决" in next_step + + +def test_26031_is_reported_as_a_platform_defect() -> None: + text = render_human(_err(26031, http_status=500)) + assert "平台" in text and "缺陷" in text + assert "密钥" not in text.split("下一步")[0] + + +def test_defect_class_gets_its_own_exit_code_not_1_or_19() -> None: + # The exit code exists so a caller can pick its next move without reading + # prose. On 1 it may retry (the CLI crashed); on 19 it may retry with other + # arguments (unknown code, read the message). For these two, retrying and + # changing arguments are both useless — that is a third action, so it is a + # third code. 19 would also be a lie: these codes are registered. + assert _err(26004).exit_code == EXIT_DEFECT == 18 + assert _err(26031, http_status=500).exit_code == EXIT_DEFECT + for code in (26004, 26031): + assert _err(code).exit_code not in (EXIT_INTERNAL, EXIT_UNKNOWN_CODE) + + +def test_registered_code_wins_over_5xx_degradation() -> None: + # 26031 arrives with HTTP 500. The 5xx degradation rule only applies to + # codes with no entry; letting it override a registered mapping would turn + # "report this defect" into "retry later", which never succeeds. + assert _err(26031, http_status=500).exit_code != EXIT_UNREACHABLE + + +def test_delegate_rejection_shape_matches_server_side_rejection() -> None: + from bisheng_cli.errors import delegate_refusal + + local = delegate_refusal() + remote = _err(26003, "delegate key", details={"exception": "delegate key", "required": "delegate"}) + assert local.exit_code == remote.exit_code == EXIT_FORBIDDEN + assert "委托" in render_human(local) + + +def test_no_error_text_contains_key_material() -> None: + for code in sorted(ERROR_HINTS): + err = _err(code, f"server echoed {FAKE_KEY}", details={"key": FAKE_KEY}, hints=[f"try {FAKE_KEY}"]) + assert FAKE_KEY not in render_human(err) + unknown = _err(19999, f"leak {FAKE_KEY}") + assert FAKE_KEY not in render_human(unknown) + + +@pytest.mark.parametrize("code", [16124, 16227, 16228]) +def test_runtime_failure_preserves_original_reason_in_json_and_human_output(code): + """F119 覆盖 AC: AC-24, AC-26, AC-27.""" + err = _err(code, "runtime failed", details={"reason": "image_missing", "operation_id": "op", "tail": ["safe log"]}) + assert err.exit_code == EXIT_PRECHECK_FAILED + assert err.as_failure()["code"] == code + assert err.as_failure()["details"]["reason"] == "image_missing" + text = render_human(err) + assert str(code) in text and "image_missing" in text and "safe log" in text + + +@pytest.mark.parametrize("code", [16124, 16227, 16228]) +def test_missing_diagnostics_do_not_claim_an_excerpt_exists(code): + """F119 覆盖 AC: AC-24, AC-26, AC-27.""" + err = _err(code, "no logs", details={"reason": "startup_exited", "tail": [], "diagnostics_available": False}) + text = render_human(err) + assert "下方" not in text and "下面" not in text + assert "暂无可用" in text + + +def test_legacy_runtime_failure_offers_startup_troubleshooting_without_losing_original_code(): + """F119 AC-26: old 16124 receives concrete startup advice.""" + err = _err(16124, "failed") + assert "启动命令" in err.next_step and "监听地址" in err.next_step and "端口" in err.next_step + assert err.as_failure()["code"] == 16124 diff --git a/src/bisheng-cli/tests/test_http.py b/src/bisheng-cli/tests/test_http.py new file mode 100644 index 0000000000..047c31e7ca --- /dev/null +++ b/src/bisheng-cli/tests/test_http.py @@ -0,0 +1,221 @@ +"""T007 — HTTP client, the two envelope shapes, and the pre-flight probe.""" + +from __future__ import annotations + +import io + +import httpx +import pytest + +from bisheng_cli import __version__ +from bisheng_cli.errors import ( + EXIT_AUTH, + EXIT_FORBIDDEN, + EXIT_NOT_ENABLED, + EXIT_PLATFORM_TOO_OLD, + EXIT_UNREACHABLE, + EXIT_USAGE, + CliError, +) +from bisheng_cli.http import ( + CONNECT_TIMEOUT, + NGINX_PROXY_READ_TIMEOUT, + READ_TIMEOUT, + UPLOAD_READ_TIMEOUT, + PlatformClient, + parse_envelope, + probe, +) +from bisheng_cli.output import Emitter +from tests.helpers.platform_mock import ( + FAKE_KEY, + PlatformMock, + env_legacy, + env_ok, + v1_envelope, + v2_error, + versions_404, + versions_ok, +) + +BASE = "http://platform.test" + + +def _client(mock: PlatformMock, **kw) -> PlatformClient: + return PlatformClient(BASE, api_key=FAKE_KEY, transport=mock.transport, **kw) + + +def test_envelope_parsed_body_status_code_first_then_http_status() -> None: + # /api/v1 answers HTTP 200 with the verdict in the body. Reading the status + # line first would classify every v1 business error as a success. + ok_status_bad_body = httpx.Response(200, json={"status_code": 16207, "status_message": "layer off", "data": {}}) + with pytest.raises(CliError) as excinfo: + parse_envelope(ok_status_bad_body) + assert excinfo.value.code == 16207 + assert excinfo.value.exit_code == EXIT_NOT_ENABLED + + # And a real 503 whose body names 26030 must translate as 26030, not as a + # generic "some 5xx". + bad_status_known_body = v2_error(26030, "auth service down") + with pytest.raises(CliError) as excinfo: + parse_envelope(bad_status_known_body) + assert excinfo.value.code == 26030 + + +def test_v1_http200_business_error_is_treated_as_error() -> None: + mock = PlatformMock().get("/api/v1/env", v1_envelope(None, status_code=20001, status_message="tenant disabled")) + with pytest.raises(CliError) as excinfo: + _client(mock).get_json("/api/v1/env") + assert excinfo.value.code == 20001 + + +@pytest.mark.parametrize( + ("code", "http_status", "expected_exit"), + [(26002, 401, EXIT_AUTH), (26003, 403, EXIT_FORBIDDEN), (26030, 503, EXIT_UNREACHABLE)], +) +def test_v2_real_http_401_403_503_parsed_with_envelope_body(code: int, http_status: int, expected_exit: int) -> None: + mock = PlatformMock().get("/api/v2/auth/whoami", v2_error(code, "nope")) + with pytest.raises(CliError) as excinfo: + _client(mock).get_json("/api/v2/auth/whoami") + assert excinfo.value.code == code + assert excinfo.value.exit_code == expected_exit + + +def test_v1_success_returns_data_payload() -> None: + mock = PlatformMock().get("/api/v1/env", env_ok()) + data = _client(mock).get_json("/api/v1/env") + assert data["open_platform_enabled"] is True + + +def test_probe_versions_404_and_env_open_platform_false_exits_8() -> None: + mock = PlatformMock().get("/api/v1/dev-toolkit/versions", versions_404()) + mock.get("/api/v1/env", env_ok(open_platform_enabled=False)) + with pytest.raises(CliError) as excinfo: + probe(_client(mock)) + assert excinfo.value.exit_code == EXIT_NOT_ENABLED + assert "开放能力层" in excinfo.value.message + + +def test_probe_versions_404_and_env_without_open_platform_flag_exits_9() -> None: + mock = PlatformMock().get("/api/v1/dev-toolkit/versions", versions_404()) + mock.get("/api/v1/env", env_legacy()) + with pytest.raises(CliError) as excinfo: + probe(_client(mock)) + assert excinfo.value.exit_code == EXIT_PLATFORM_TOO_OLD + assert "版本" in excinfo.value.message + + +def test_probe_env_unreachable_exits_7() -> None: + mock = PlatformMock().get("/api/v1/dev-toolkit/versions", versions_404()) + mock.get("/api/v1/env", httpx.ConnectError("refused")) + with pytest.raises(CliError) as excinfo: + probe(_client(mock)) + assert excinfo.value.exit_code == EXIT_UNREACHABLE + + +def test_probe_stops_before_whoami_when_layer_absent() -> None: + # AC-05's "login is unusable in this environment" is paid for right here: + # the probe decides before any credential ever leaves the machine. + mock = PlatformMock().get("/api/v1/dev-toolkit/versions", versions_404()) + mock.get("/api/v1/env", env_ok(open_platform_enabled=False)) + with pytest.raises(CliError): + probe(_client(mock)) + assert "/api/v2/auth/whoami" not in mock.paths_called() + + +def test_min_compatible_greater_than_local_blocks_with_download_link() -> None: + """T048 (AC-02): incompatible → refused, with the platform's own download URL. + + Exit 2 per design D11 / T048. The link is the full URL on *this* platform, + because "download a newer CLI" without saying from where sends the developer + to a registry that does not exist on the intranet. + """ + mock = PlatformMock().get("/api/v1/dev-toolkit/versions", versions_ok(cli_version="9.9.9", min_compatible="9.9.9")) + err = io.StringIO() + emitter = Emitter(stdout=io.StringIO(), stderr=err, is_tty=False) + with pytest.raises(CliError) as excinfo: + probe(_client(mock, emitter=emitter)) + assert excinfo.value.exit_code == EXIT_USAGE + assert "9.9.9" in excinfo.value.message and __version__ in excinfo.value.message + assert f"{BASE}/api/v1/dev-toolkit/cli/download" in excinfo.value.next_step + # No credential-bearing call happens after the refusal. + assert mock.paths_called() == ["/api/v1/dev-toolkit/versions"] + + +def test_behind_but_compatible_only_warns() -> None: + # The platform ships a newer CLI but still accepts this one: the run goes + # ahead, and the warning names where the update lives. + mock = PlatformMock().get( + "/api/v1/dev-toolkit/versions", versions_ok(cli_version="9.9.9", min_compatible=__version__) + ) + err = io.StringIO() + emitter = Emitter(stdout=io.StringIO(), stderr=err, is_tty=False) + result = probe(_client(mock, emitter=emitter)) + assert result.warning and "9.9.9" in result.warning and "/api/v1/dev-toolkit/cli/download" in result.warning + assert "警告" in err.getvalue() + + +def test_min_compatible_equal_to_local_does_not_warn() -> None: + mock = PlatformMock().get("/api/v1/dev-toolkit/versions", versions_ok(min_compatible=__version__)) + assert probe(_client(mock)).warning is None + + +def test_no_scopes_cached_between_calls() -> None: + mock = PlatformMock().get("/api/v2/auth/whoami", v1_envelope({"scopes": ["app:manage"]})) + client = _client(mock) + client.get_json("/api/v2/auth/whoami") + client.get_json("/api/v2/auth/whoami") + # Two calls, two round trips: a cached scope set is how "the admin ticked the + # box but the CLI still says no" happens. + assert mock.paths_called().count("/api/v2/auth/whoami") == 2 + assert not [name for name in vars(client) if "scope" in name.lower()] + + +def test_proxy_env_detected_and_named_in_connect_error(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("ALL_PROXY", "socks5://127.0.0.1:1080") + mock = PlatformMock().get("/api/v1/env", httpx.ConnectError("refused")) + with pytest.raises(CliError) as excinfo: + _client(mock).get_json("/api/v1/env") + text = excinfo.value.message + excinfo.value.next_step + assert "ALL_PROXY" in text and "NO_PROXY" in text + assert excinfo.value.exit_code == EXIT_UNREACHABLE + + +def test_no_proxy_flag_disables_env_trust() -> None: + mock = PlatformMock().get("/api/v1/env", env_ok()) + client = _client(mock, trust_env=False) + assert client.trust_env is False + client.get_json("/api/v1/env") + + +def test_upload_read_timeout_is_240s_below_nginx_300s() -> None: + # The CLI must time out first; otherwise the user gets nginx's 504 with no + # idea which side gave up. + assert UPLOAD_READ_TIMEOUT == 240.0 + assert UPLOAD_READ_TIMEOUT < NGINX_PROXY_READ_TIMEOUT == 300.0 + assert CONNECT_TIMEOUT == 10.0 and READ_TIMEOUT == 60.0 + + +def test_bearer_header_masked_in_verbose_log() -> None: + err = io.StringIO() + emitter = Emitter(stdout=io.StringIO(), stderr=err, verbose=True, is_tty=False) + mock = PlatformMock().get("/api/v1/env", env_ok()) + _client(mock, emitter=emitter).get_json("/api/v1/env") + text = err.getvalue() + assert FAKE_KEY not in text + assert "/api/v1/env" in text and "200" in text + + +def test_authorization_header_is_sent_when_key_present() -> None: + mock = PlatformMock().get("/api/v1/env", env_ok()) + _client(mock).get_json("/api/v1/env") + assert mock.calls[0].headers["authorization"] == f"Bearer {FAKE_KEY}" + + +def test_no_identity_delegation_headers_are_ever_sent() -> None: + # 26004 exists precisely because these headers would be a CLI defect. + mock = PlatformMock().get("/api/v1/env", env_ok()) + _client(mock).get_json("/api/v1/env") + sent = {k.lower() for k in mock.calls[0].headers} + assert "x-bisheng-on-behalf-of" not in sent + assert "x-bisheng-end-user" not in sent diff --git a/src/bisheng-cli/tests/test_ignore.py b/src/bisheng-cli/tests/test_ignore.py new file mode 100644 index 0000000000..0a4700be22 --- /dev/null +++ b/src/bisheng-cli/tests/test_ignore.py @@ -0,0 +1,166 @@ +"""T015 — the three ignore layers. + +The load-bearing test here is the first one: git and the hand-written subset +parser must agree on the same tree. When they diverge, nothing fails — the +package silently contains (or omits) different files depending on whether the +developer happened to have git installed. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from bisheng_cli.ignore import ALL_IGNORED_NOTICE, SUBSET_NOTICE, Ruleset, collect_files + + +def _names(root: Path, **kw) -> set[str]: + return set(collect_files(root, **kw).files) + + +def test_git_path_and_subset_parser_agree_on_sample_tree(sample_project_git: Path) -> None: + via_git = collect_files(sample_project_git, use_git=True) + via_subset = collect_files(sample_project_git, use_git=False) + assert via_git.used_git is True and via_subset.used_git is False + assert set(via_git.files) == set(via_subset.files) + + +def test_gitignore_rules_are_honoured(sample_project: Path) -> None: + files = _names(sample_project, use_git=False) + assert "debug.log" not in files + assert "build-artifacts/old.tar" not in files + assert "important.log" in files # taken back by the ! rule + + +def test_double_star_matches_across_directories() -> None: + rules = Ruleset.parse("logs/**/*.tmp\n") + assert rules.decide("logs/a.tmp", False) is True + assert rules.decide("logs/x/y/a.tmp", False) is True + assert rules.decide("other/a.tmp", False) is None + + +def test_single_star_does_not_cross_slash() -> None: + # fnmatch's `*` crosses `/` and it has no `**` at all — using it directly is + # how a rule silently matches nothing (or everything). + rules = Ruleset.parse("a/*.log\n") + assert rules.decide("a/b.log", False) is True + assert rules.decide("a/x/b.log", False) is None + + +def test_leading_bang_negation_takes_back() -> None: + rules = Ruleset.parse("*.log\n!keep.log\n") + assert rules.decide("debug.log", False) is True + assert rules.decide("keep.log", False) is False + + +def test_trailing_slash_matches_directory_only() -> None: + rules = Ruleset.parse("cache/\n") + assert rules.decide("cache", True) is True + assert rules.decide("cache/x.txt", False) is True + assert rules.decide("cache", False) is None # a *file* named cache is untouched + + +def test_comments_and_blank_lines_are_skipped() -> None: + rules = Ruleset.parse("# comment\n\n \n*.log\n") + assert len(rules.rules) == 1 + + +def test_bishengignore_loaded_last_and_wins(sample_project: Path) -> None: + (sample_project / ".bishengignore").write_text("!debug.log\nmain.py\n", encoding="utf-8") + files = _names(sample_project, use_git=False) + assert "debug.log" in files # taken back from .gitignore + assert "main.py" not in files # newly excluded + + +def test_hard_excluded_dirs_cannot_be_taken_back_by_bang(sample_project: Path) -> None: + (sample_project / ".bishengignore").write_text("!.venv/**\n!node_modules/**\n!.bisheng/**\n", encoding="utf-8") + files = _names(sample_project, use_git=False) + assert not [f for f in files if f.startswith((".venv/", "node_modules/", "__pycache__/", ".bisheng/"))] + + +def test_dist_and_build_are_soft_excluded_and_can_be_taken_back(sample_project: Path) -> None: + # dist/ deliberately is NOT a hard exclude: plenty of real apps ship a + # front-end bundle there for the backend to serve statically, and hard-excluding + # it strips the app silently — the failure only shows up at probe time, pointing + # the investigation at "the platform build is broken". + assert "dist/bundle.js" not in _names(sample_project, use_git=False) + (sample_project / ".bishengignore").write_text("!dist/**\n", encoding="utf-8") + assert "dist/bundle.js" in _names(sample_project, use_git=False) + + +def test_local_sqlite_is_soft_excluded(sample_project: Path) -> None: + assert "app.sqlite" not in _names(sample_project, use_git=False) + + +def test_non_git_project_output_says_subset_parsing_used(tmp_path: Path) -> None: + root = tmp_path / "plain" + root.mkdir() + (root / "main.py").write_text("x\n", encoding="utf-8") + result = collect_files(root) + assert result.used_git is False + assert any("子集解析" in note and ".bishengignore" in note for note in result.notes) + + +def test_git_project_does_not_emit_the_subset_notice(sample_project_git: Path) -> None: + result = collect_files(sample_project_git) + assert result.used_git is True + assert not [note for note in result.notes if "子集解析" in note] + + +def test_excluded_entries_are_reported_not_silently_dropped(sample_project: Path) -> None: + result = collect_files(sample_project, use_git=False) + assert result.excluded_count > 0 + assert any(entry.startswith(".venv") for entry in result.excluded) + + +@pytest.mark.parametrize("pattern", ["**", "", " ", "#only a comment"]) +def test_degenerate_patterns_do_not_crash(pattern: str) -> None: + Ruleset.parse(pattern + "\n").decide("a/b.txt", False) + + +def test_app_directory_inside_a_repo_uses_git_not_the_subset_parser(tmp_path: Path) -> None: + """An app folder inside a larger repo is the common case, not the exotic one. + + Gating on ``root/".git"`` made this directory report "not a git repository" + and fall back to reading only its own ``.gitignore`` — while the repo's real + rules sit at the repo root. Anything git ignores from above (local data, + generated output, an ignored config with real credentials in it) would then + be packed and uploaded. + """ + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + (tmp_path / ".gitignore").write_text("secrets.env\n*.local\n") + app = tmp_path / "examples" / "demo" + app.mkdir(parents=True) + (app / "main.py").write_text("print('hi')\n") + (app / "secrets.env").write_text("TOKEN=real\n") + (app / "notes.local").write_text("scratch\n") + + result = collect_files(app) + + assert result.used_git is True, "the parent repo must be found from a subdirectory" + assert "main.py" in result.files + assert "secrets.env" not in result.files, "an ignore rule from the repo root must still apply" + assert "notes.local" not in result.files + assert SUBSET_NOTICE not in result.notes + + +def test_fully_ignored_directory_says_so_instead_of_packing_nothing_silently(tmp_path: Path) -> None: + """"git ignores everything here" and "there is nothing here" need different fixes. + + Without the note, the only symptom is a later "bisheng-app.yaml missing" + error, which points at the manifest rather than at the ignore rule that + swallowed the whole directory. + """ + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + (tmp_path / ".gitignore").write_text("vendor/\n") + app = tmp_path / "vendor" / "app" + app.mkdir(parents=True) + (app / "main.py").write_text("print('hi')\n") + (app / "bisheng-app.yaml").write_text("name: x\n") + + result = collect_files(app) + + assert result.files == [] + assert ALL_IGNORED_NOTICE in result.notes diff --git a/src/bisheng-cli/tests/test_import_smoke.py b/src/bisheng-cli/tests/test_import_smoke.py new file mode 100644 index 0000000000..0c62168d5e --- /dev/null +++ b/src/bisheng-cli/tests/test_import_smoke.py @@ -0,0 +1,62 @@ +"""Module-level import smoke. + +Every import below runs at collection time, deliberately — not inside a fixture +or a function body. A dependency that resolved to a version whose API moved +breaks at import, and if the only imports live inside test bodies that break can +hide behind mocks and still leave a wheel that dies on `bisheng --help`. + +This repo has already paid for that once: `fastapi>=0.115` with no ceiling +resolved to 0.121 in development (69 tests green) and to 0.141 in production, +where the removed `add_websocket_route` killed the process at import. The upper +bounds in pyproject.toml and this file are the two halves of that fix. +""" + +from __future__ import annotations + +import tomllib +from pathlib import Path + +import httpx +import yaml + +import bisheng_cli +import bisheng_cli.cli +import bisheng_cli.commands +import bisheng_cli.credentials +import bisheng_cli.errors +import bisheng_cli.http +import bisheng_cli.ignore +import bisheng_cli.main +import bisheng_cli.output +import bisheng_cli.packaging +import bisheng_cli.project +from bisheng_cli.cli import build_parser +from bisheng_cli.main import main + + +def test_console_script_entry_point_is_importable_and_callable() -> None: + # `[project.scripts] bisheng = "bisheng_cli.main:main"` — this is the exact + # symbol pip wires the shell command to. + assert callable(main) + assert build_parser().prog == "bisheng" + + +def test_declared_runtime_dependencies_expose_the_api_we_use() -> None: + # Not "is it installed" but "does this version still have the entry points + # this CLI calls at module scope". + assert hasattr(httpx, "MockTransport") and hasattr(httpx, "Timeout") + assert hasattr(yaml, "safe_load") + + +def test_version_is_declared_once() -> None: + # Everything that reports a version — --version, the compatibility probe, the + # artifacts manifest — has to read this one attribute. A second literal + # somewhere is how the download endpoint starts advertising a version that + # is not the one inside the wheel. + assert bisheng_cli.cli.__version__ is bisheng_cli.__version__ + assert bisheng_cli.http.__version__ is bisheng_cli.__version__ + # Compare with the declared package version rather than counting dots: + # valid post releases such as 3.0.0.post1 contain more than two. + pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml" + metadata = tomllib.loads(pyproject.read_text(encoding="utf-8")) + assert bisheng_cli.__version__ == metadata["project"]["version"] diff --git a/src/bisheng-cli/tests/test_output.py b/src/bisheng-cli/tests/test_output.py new file mode 100644 index 0000000000..00fea18390 --- /dev/null +++ b/src/bisheng-cli/tests/test_output.py @@ -0,0 +1,161 @@ +"""T005 — the output layer. + +AC-04 in one sentence: usable with no TTY, parseable by a machine, and never +echoing key material. Everything here is one of those three. +""" + +from __future__ import annotations + +import io +import json + +import pytest + +from bisheng_cli.output import STAGE_LABELS, Emitter, format_schema_change, mask, stage_label, wrap_stream +from tests.helpers.platform_mock import FAKE_KEY, FAKE_PAT + + +def _emitter(**kw): + out, err = io.StringIO(), io.StringIO() + kw.setdefault("json_mode", True) + kw.setdefault("is_tty", False) + return Emitter(stdout=out, stderr=err, **kw), out, err + + +def _lines(buf: io.StringIO) -> list[str]: + return [line for line in buf.getvalue().splitlines() if line.strip()] + + +def test_json_mode_machine_events_go_to_stdout_human_text_to_stderr() -> None: + em, out, err = _emitter() + em.stage("deploy", "precheck_build", "running") + em.progress("deploy", "upload", 100, 100) + em.result("deploy", ok=True, exit_code=0) + # Every stdout line must parse; one stray progress sentence there would blow + # up the agent's `jq` pipeline on its first read. + for line in _lines(out): + json.loads(line) + assert _lines(err), "the human watching the terminal must still see the run" + + +def test_result_event_is_always_the_last_line_and_appears_exactly_once() -> None: + em, out, _ = _emitter() + em.stage("deploy", "received", "running") + em.result("deploy", ok=False, exit_code=10) + em.result("deploy", ok=True, exit_code=0) # a second call must not double-emit + events = [json.loads(line) for line in _lines(out)] + assert [e["event"] for e in events].count("result") == 1 + assert events[-1]["event"] == "result" + assert events[-1]["exit_code"] == 10 + + +def test_event_shapes_are_exactly_three() -> None: + em, out, _ = _emitter() + em.stage("deploy", "received", "running") + em.progress("deploy", "upload", 50, 100) + em.result("deploy", ok=True, exit_code=0) + kinds = {json.loads(line)["event"] for line in _lines(out)} + assert kinds == {"stage", "progress", "result"} + + +def test_non_tty_degrades_to_milestones() -> None: + em, out, _ = _emitter(is_tty=False) + for sent in range(0, 101): + em.progress("deploy", "upload", sent, 100) + percents = [int(json.loads(line)["sent_bytes"]) for line in _lines(out) if json.loads(line)["event"] == "progress"] + assert percents == [25, 50, 75, 100] + + +def test_tty_reports_every_progress_call() -> None: + em, out, _ = _emitter(is_tty=True) + for sent in (10, 20, 30): + em.progress("deploy", "upload", sent, 100) + assert len([line for line in _lines(out) if json.loads(line)["event"] == "progress"]) == 3 + + +def test_mask_never_emits_key_material() -> None: + em, out, err = _emitter() + em.stage("deploy", "received", "running", failure={"message": FAKE_KEY, "details": {"header": FAKE_KEY}}) + em.info(f"Authorization: Bearer {FAKE_KEY}") + em.result("deploy", ok=False, exit_code=4, data={"echo": FAKE_KEY}) + blob = out.getvalue() + err.getvalue() + assert FAKE_KEY not in blob + assert "bs-sak-****" in blob + + +def test_mask_covers_personal_tokens_too() -> None: + """Both prefixes the platform authenticates, not just the one we ask for. + + beta2's `credential_validator._TOKEN_RE` accepts `bs-sak-` **and** + `bs-pat-`. The CLI asks for a service-account key and cannot stop anyone + pasting a personal token, and a token that reaches an error message or a + `--verbose` line unmasked is just as leaked as a key would be. + """ + em, out, err = _emitter() + em.info(f"Authorization: Bearer {FAKE_PAT}") + em.result("login", ok=False, exit_code=4, data={"echo": FAKE_PAT}) + blob = out.getvalue() + err.getvalue() + assert FAKE_PAT not in blob + # The prefix survives so the reader can still tell which kind of credential + # was involved — that is the whole diagnostic value of the leftover. + assert "bs-pat-****" in blob + + +def test_verbose_masks_authorization_header() -> None: + em, _, err = _emitter(verbose=True) + em.debug(f"GET /api/v2/auth/whoami 200 12ms Authorization: Bearer {FAKE_KEY}") + text = err.getvalue() + assert FAKE_KEY not in text + assert "/api/v2/auth/whoami" in text and "200" in text + + +def test_debug_is_silent_without_verbose() -> None: + em, _, err = _emitter(verbose=False) + em.debug("should not appear") + assert err.getvalue() == "" + + +def test_scan_hits_print_file_line_without_value() -> None: + from bisheng_cli.output import format_scan_hits + + hits = [{"rule_id": "generic-key", "name_i18n_key": "scan.generic_key", "file": "app/conf.py", "line": 12}] + text = format_scan_hits(hits) + assert "app/conf.py:12" in text + # The server deliberately withholds even a redacted value; the CLI must not + # reopen the local file to "helpfully" put it back. + assert "value" not in text and FAKE_KEY not in text + + +def test_utf8_wrapper_survives_gbk_console() -> None: + raw = io.BytesIO() + console = io.TextIOWrapper(raw, encoding="cp936", errors="strict", write_through=True) + wrapped = wrap_stream(console) + wrapped.write("缺 app:manage 位,请联系管理员 — 甯\n") + wrapped.flush() + assert raw.getvalue() + + +def test_unknown_stage_is_printed_verbatim() -> None: + assert stage_label("received") == STAGE_LABELS["received"] + assert stage_label("brand_new_stage") == "brand_new_stage" + + +def test_format_schema_change_flags_breaking_items_and_keeps_unknown_ops() -> None: + text = format_schema_change( + [ + {"table": "orders", "column": "amount", "op": "modify_column"}, + {"table": "audit", "column": None, "op": "drop_table"}, + {"table": "settings", "column": "key", "op": "add_column"}, + {"table": "x", "column": "y", "op": "rename_column"}, + ] + ) + lines = text.splitlines() + assert lines[0].startswith(" 改列 表 orders 列 amount") and "破坏性" in lines[0] + assert lines[1].startswith(" 删表 表 audit") and "列" not in lines[1].split("表 audit")[1].split("←")[0] + assert lines[2] == " 加列 表 settings 列 key" + assert lines[3].startswith(" rename_column 表 x 列 y") and "破坏性" not in lines[3] + + +@pytest.mark.parametrize("text", ["nothing to mask", ""]) +def test_mask_is_identity_on_clean_text(text: str) -> None: + assert mask(text) == text diff --git a/src/bisheng-cli/tests/test_packaging.py b/src/bisheng-cli/tests/test_packaging.py new file mode 100644 index 0000000000..61a3360d02 --- /dev/null +++ b/src/bisheng-cli/tests/test_packaging.py @@ -0,0 +1,198 @@ +"""T017 — packaging. Each assertion here is the dual of a failure seen in the field.""" + +from __future__ import annotations + +import hashlib +import os +import stat +import sys +import tarfile +from pathlib import Path + +import httpx +import pytest + +from bisheng_cli.errors import EXIT_LOCAL_INVALID, CliError +from bisheng_cli.http import PlatformClient +from bisheng_cli.ignore import collect_files +from bisheng_cli.packaging import DEFAULT_LIMITS, Limits, build_package, check_limits, fetch_limits, format_size_report +from tests.helpers.platform_mock import FAKE_KEY, PlatformMock, deploy_limits + +pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes / links") + + +def _pack(root: Path, tmp_path: Path, name: str = "pkg.tar.gz"): + out = tmp_path / name + return build_package(root, collect_files(root, use_git=False), out), out + + +def _members(path: Path) -> dict[str, tarfile.TarInfo]: + with tarfile.open(path, "r:gz") as tar: + return {m.name: m for m in tar.getmembers()} + + +def test_credentials_file_never_in_package(sample_project: Path, tmp_path: Path, home_dir: Path) -> None: + # Two paths to the same leak: the real user-level file, and a stray copy that + # somebody dropped inside the project. + (home_dir / ".bisheng").mkdir() + (home_dir / ".bisheng" / "credentials.json").write_text("{}", encoding="utf-8") + stray = sample_project / ".bisheng" + stray.mkdir(exist_ok=True) + (stray / "credentials.json").write_text(f'{{"api_key": "{FAKE_KEY}"}}', encoding="utf-8") + + _, out = _pack(sample_project, tmp_path) + names = _members(out) + assert not [n for n in names if "credentials" in n] + assert out.read_bytes().find(FAKE_KEY.encode()) == -1 + + +def test_venv_node_modules_git_pycache_excluded(sample_project: Path, tmp_path: Path) -> None: + names = _members(_pack(sample_project, tmp_path)[1]) + assert not [n for n in names if n.startswith((".venv/", "node_modules/", ".git/", "__pycache__/"))] + + +def test_local_sqlite_and_attachment_dir_excluded(sample_project: Path, tmp_path: Path) -> None: + (sample_project / "attachments").mkdir() + (sample_project / "attachments" / "a.bin").write_bytes(b"x") + names = _members(_pack(sample_project, tmp_path)[1]) + assert "app.sqlite" not in names + assert not [n for n in names if n.startswith("attachments/")] + + +def test_member_paths_are_posix_relative_no_backslash_no_abs_no_dotdot(sample_project: Path, tmp_path: Path) -> None: + # A `\` separator survives as part of the *filename* on Linux: the server + # unpacks a single file called `src\main.py` and the app's directory tree is + # gone — or the unpack gate rejects it as 16202. + for name in _members(_pack(sample_project, tmp_path)[1]): + assert "\\" not in name + assert not name.startswith("/") + assert not name.startswith("./") + assert ".." not in Path(name).parts + + +def test_symlink_device_fifo_skipped_and_listed(sample_project: Path, tmp_path: Path) -> None: + os.mkfifo(sample_project / "a.fifo") + stat_result, out = _pack(sample_project, tmp_path) + names = _members(out) + kinds = {entry.path: entry.kind for entry in stat_result.skipped} + + assert "link-to-main.py" not in names and kinds["link-to-main.py"] == "symlink" + assert "a.fifo" not in names and kinds["a.fifo"] == "fifo" + # Skipped, but never silently: the server's unpack gate rejects these kinds, + # so a silent skip would turn into a 16202 after a wasted upload. + assert all(entry.path in format_size_report(stat_result, DEFAULT_LIMITS) for entry in stat_result.skipped) + + +def test_hardlinked_regular_file_is_packed_with_its_contents(sample_project: Path, tmp_path: Path) -> None: + """``st_nlink > 1`` is not a property the author chose, or can even see. + + ``_tar_info`` hand-builds a REGTYPE header and streams the bytes, so this + writer cannot emit a hardlink member no matter how many links a file has — + that only happens when tarfile picks the type itself via ``add`` and its + inode table. Skipping these would drop the contents of an ordinary file to + avoid a member type that is never produced. + """ + os.link(sample_project / "main.py", sample_project / "hardlinked.py") + stat_result, out = _pack(sample_project, tmp_path) + + assert "hardlinked.py" not in {entry.path for entry in stat_result.skipped} + with tarfile.open(out, "r:gz") as tar: + member = tar.getmember("hardlinked.py") + assert member.isreg(), "a hardlink member would be rejected by the server's unpack gate" + assert tar.extractfile(member).read() == (sample_project / "main.py").read_bytes() + + +def test_owner_exec_bit_preserved_0755_others_normalized_0644(sample_project: Path, tmp_path: Path) -> None: + members = _members(_pack(sample_project, tmp_path)[1]) + # Losing the bit makes the entrypoint unexecutable, and that failure only + # surfaces at build/probe time pointing at "the platform build". + assert members["entrypoint.sh"].mode == 0o755 + assert members["main.py"].mode == 0o644 + + +def test_reproducible_same_sha256_twice(sample_project: Path, tmp_path: Path) -> None: + first, path_a = _pack(sample_project, tmp_path, "a.tar.gz") + second, path_b = _pack(sample_project, tmp_path, "b.tar.gz") + assert first.sha256 == second.sha256 + assert hashlib.sha256(path_a.read_bytes()).hexdigest() == first.sha256 + assert path_a.read_bytes() == path_b.read_bytes() + + +def test_member_metadata_is_normalised(sample_project: Path, tmp_path: Path) -> None: + for member in _members(_pack(sample_project, tmp_path)[1]).values(): + assert member.mtime == 0 + assert member.uid == member.gid == 0 + assert member.uname == member.gname == "" + assert member.type == tarfile.REGTYPE + + +def test_never_silently_truncates_on_limit(sample_project: Path, tmp_path: Path) -> None: + result = collect_files(sample_project, use_git=False) + stat_result, out = _pack(sample_project, tmp_path) + # Nothing was dropped to fit: what got packed is what the ignore rules chose. + packed = set(_members(out)) + expected = set(result.files) - {entry.path for entry in stat_result.skipped} + assert packed == expected + + with pytest.raises(CliError) as excinfo: + check_limits(stat_result, Limits(max_package_mb=0, max_unpacked_mb=0, max_package_entries=1)) + assert excinfo.value.exit_code == EXIT_LOCAL_INVALID + assert "整包拒绝" in excinfo.value.message or "拒绝" in excinfo.value.message + + +def test_oversize_report_lists_excluded_count_then_top10(sample_project: Path, tmp_path: Path) -> None: + stat_result, _ = _pack(sample_project, tmp_path) + report = format_size_report(stat_result, Limits(max_package_mb=0, max_unpacked_mb=0, max_package_entries=1)) + # "Clean up your project" is useless advice on its own. The first reflex is + # "the platform limit is too small"; the excluded count answers that before + # the Top-10 list names what to do next. + assert report.index("已排除") < report.index("Top") + assert "entrypoint.sh" in report or "main.py" in report + + +def test_entry_count_limit_is_enforced(sample_project: Path, tmp_path: Path) -> None: + stat_result, _ = _pack(sample_project, tmp_path) + with pytest.raises(CliError): + check_limits(stat_result, Limits(max_package_mb=999, max_unpacked_mb=999, max_package_entries=1)) + + +def test_within_limits_passes_quietly(sample_project: Path, tmp_path: Path) -> None: + stat_result, _ = _pack(sample_project, tmp_path) + check_limits(stat_result, DEFAULT_LIMITS) + + +def test_limits_endpoint_returns_server_values() -> None: + mock = PlatformMock().get("/api/v2/apps/deploy-limits", deploy_limits(max_package_mb=7)) + limits = fetch_limits(PlatformClient("http://p.test", api_key=FAKE_KEY, transport=mock.transport)) + assert limits.max_package_mb == 7 and limits.degraded is False + + +def test_limits_endpoint_unreachable_falls_back_to_defaults_and_proceeds() -> None: + # A soft self-check must never be able to kill the main flow: if the endpoint + # is missing or flaky we upload anyway and let the server's 16201 decide. + mock = PlatformMock().get("/api/v2/apps/deploy-limits", httpx.ConnectError("refused")) + limits = fetch_limits(PlatformClient("http://p.test", api_key=FAKE_KEY, transport=mock.transport)) + assert limits.max_package_mb == DEFAULT_LIMITS.max_package_mb + assert limits.degraded is True + + +def test_limits_endpoint_error_code_also_falls_back() -> None: + from tests.helpers.platform_mock import v2_error + + mock = PlatformMock().get("/api/v2/apps/deploy-limits", v2_error(16207, "layer off")) + limits = fetch_limits(PlatformClient("http://p.test", api_key=FAKE_KEY, transport=mock.transport)) + assert limits.degraded is True + + +def test_package_is_targz_not_zip(sample_project: Path, tmp_path: Path) -> None: + _, out = _pack(sample_project, tmp_path) + assert out.read_bytes()[:2] == b"\x1f\x8b" + with tarfile.open(out, "r:gz"): + pass + + +def test_directory_mode_of_source_tree_is_not_leaked(sample_project: Path, tmp_path: Path) -> None: + script = sample_project / "entrypoint.sh" + script.chmod(stat.S_IMODE(script.stat().st_mode) | 0o077) + members = _members(_pack(sample_project, tmp_path)[1]) + assert members["entrypoint.sh"].mode == 0o755 diff --git a/src/bisheng-cli/tests/test_platform_contract.py b/src/bisheng-cli/tests/test_platform_contract.py new file mode 100644 index 0000000000..fd5e2c08f5 --- /dev/null +++ b/src/bisheng-cli/tests/test_platform_contract.py @@ -0,0 +1,298 @@ +"""The CLI's test doubles, checked against the server they claim to imitate. + +Why this file exists: when the open-API base was swapped from F049 to beta2's +F053, `GET /api/v2/auth/whoami` stopped sending `subject_kind` / +`service_account: {id, name}` / `resource_owner: {user_id, user_name}` and +started sending `actor_kind` / `actor_id` / `actor_name` / `resource_owner: +{user_id}`. Not one of the 254 tests in this suite noticed, because they all +assert against `tests/helpers/platform_mock.py` — and the mock had not moved. +A green suite against a server that does not exist is worse than a red one. + +**Why read the server's source instead of importing it.** `pyproject.toml` +budgets this package at two runtime dependencies and describes it as having +"zero coupling to the backend package"; the backend needs fastapi, sqlmodel and +the rest, none of which exist in this project's environment, so +`import bisheng.open_api...` would turn the whole file into a collection error +rather than a check. `ast` parses the declaration without importing anything and +without adding a dependency — the field names are what matters, and they are +right there in the source. The cost is that a field added dynamically (there are +none) would be missed. + +If the backend tree is not next to this package, these tests **fail** rather +than skip: a silent skip is the same failure mode this file was written to +close. +""" + +from __future__ import annotations + +import ast +import json +import re +from pathlib import Path +from typing import Any + +import pytest + +from bisheng_cli import devdb, devproxy +from bisheng_cli.commands.login import resource_owner_user_id +from tests.helpers.platform_mock import OPEN_API_HTTP_STATUS, WHOAMI_FIELDS, whoami_ok + +REPO_ROOT = Path(__file__).resolve().parents[3] +WHOAMI_SCHEMA = REPO_ROOT / "src/backend/bisheng/open_api/domain/schemas/credential.py" +OPEN_API_ERRCODES = REPO_ROOT / "src/backend/bisheng/common/errcode/open_api.py" +# `bisheng dev` mirrors these two; INV-32 says the names must be identical. +APP_PROXY_HEADERS = REPO_ROOT / "src/app-proxy/app_proxy/headers.py" +APP_PROXY_CONFIG = REPO_ROOT / "src/app-proxy/app_proxy/config.py" +APP_PROXY_ROUTING = REPO_ROOT / "src/app-proxy/app_proxy/routing.py" +RUNTIME_LIFECYCLE = REPO_ROOT / "src/runtime-manager/runtime_manager/lifecycle.py" +RUNTIME_ENTRYPOINT = REPO_ROOT / "src/runtime-manager/runtime_manager/templates/python3.11/entrypoint.sh.j2" +#: §5 of this document is the one definition of what gets injected into an app. +#: `build_env` implements the part runtime-manager owns; the model face's three +#: names are filled by F055 T056 from the app's runtime credential, so the +#: document is the only place both halves are written down together. +RUNTIME_ENV_CONTRACT = REPO_ROOT / "features/v3.0.0/054-app-domain-runtime/contracts-runtime-manager.md" + + +def _module(path: Path) -> ast.Module: + if not path.is_file(): + pytest.fail( + f"找不到服务端契约源文件 {path}。" + "这个测试要拿服务端的真实模型来核对 CLI 的测试桩,找不到就等于没核对——" + "所以它失败而不是跳过。若后端目录被移动,请改这里的路径。" + ) + return ast.parse(path.read_text(encoding="utf-8")) + + +def _class_fields(path: Path, name: str) -> list[str]: + """Annotated field names declared directly on a pydantic model, in order.""" + for node in _module(path).body: + if isinstance(node, ast.ClassDef) and node.name == name: + return [ + stmt.target.id + for stmt in node.body + if isinstance(stmt, ast.AnnAssign) + and isinstance(stmt.target, ast.Name) + and stmt.target.id != "model_config" + ] + pytest.fail(f"{path.name} 里没有 {name} 了——服务端契约被改名或移走,CLI 侧必须跟着改") + raise AssertionError("unreachable") + + +def _error_codes(path: Path) -> dict[int, int]: + """`{Code: http_status}` for every error class declared in the module.""" + codes: dict[int, int] = {} + for node in _module(path).body: + if not isinstance(node, ast.ClassDef): + continue + values: dict[str, int] = {} + for stmt in node.body: + target = None + if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name): + target = stmt.target.id + elif isinstance(stmt, ast.Assign) and len(stmt.targets) == 1 and isinstance(stmt.targets[0], ast.Name): + target = stmt.targets[0].id + if target in ("Code", "http_status") and isinstance(stmt.value, ast.Constant): + values[target] = stmt.value.value + if "Code" in values: + codes[values["Code"]] = values.get("http_status", 400) + return codes + + +def _module_constant(path: Path, name: str) -> Any: + """The literal value assigned to a module-level `NAME = ` (or annotated).""" + for node in _module(path).body: + target = None + if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name): + target, value = node.target.id, node.value + elif isinstance(node, ast.Assign) and len(node.targets) == 1 and isinstance(node.targets[0], ast.Name): + target, value = node.targets[0].id, node.value + if target == name and value is not None: + return ast.literal_eval(_unwrap_call(value)) + pytest.fail(f"{path.name} 里没有模块级常量 {name}——服务端契约被改名或移走,CLI 侧必须跟着改") + raise AssertionError("unreachable") + + +def _unwrap_call(value: ast.expr) -> ast.expr: + """`frozenset({...})` → the set literal inside, so `literal_eval` can read it.""" + if isinstance(value, ast.Call) and value.args and isinstance(value.func, ast.Name): + return value.args[0] + return value + + +def _build_env_keys(path: Path) -> list[str]: + """The string keys `build_env` sets in its `env.update({...})` call, in order.""" + for node in _module(path).body: + if isinstance(node, ast.FunctionDef) and node.name == "build_env": + for sub in ast.walk(node): + if ( + isinstance(sub, ast.Call) + and isinstance(sub.func, ast.Attribute) + and sub.func.attr == "update" + and sub.args + and isinstance(sub.args[0], ast.Dict) + ): + return [key.value for key in sub.args[0].keys if isinstance(key, ast.Constant)] + pytest.fail(f"{path.name} 里没有 build_env 的 env.update({{...}})——运行期环境契约被改写,CLI 侧必须跟着改") + raise AssertionError("unreachable") + + +def test_whoami_stub_has_exactly_the_fields_the_server_sends() -> None: + """The mock's payload == `WhoamiResponse`'s fields. No extras, none missing. + + An extra key means the CLI may be reading something the server never sends + (that is how `service_account.name` kept "working" in tests while login + printed nothing). A missing key means a field landed that no test covers. + """ + server_fields = _class_fields(WHOAMI_SCHEMA, "WhoamiResponse") + payload = json.loads(whoami_ok().content)["data"] + + assert list(WHOAMI_FIELDS) == server_fields + assert sorted(payload) == sorted(server_fields) + + +def test_resource_owner_carries_the_one_field_the_cli_reads() -> None: + # `WhoamiResourceOwner` lost `user_name` in the F049 → beta2 move, which is + # why `login` prints an id. If a name ever comes back, this goes red and the + # copy can be improved on purpose rather than by accident. + assert _class_fields(WHOAMI_SCHEMA, "WhoamiResourceOwner") == ["user_id"] + + payload = json.loads(whoami_ok().content)["data"] + assert resource_owner_user_id(payload) == payload["resource_owner"]["user_id"] + + +def test_open_api_error_codes_the_stub_declares_still_exist_with_that_http_status() -> None: + """Status drift is invisible from inside this suite otherwise. + + `OPEN_API_HTTP_STATUS` decides what the mock puts on the status line, and + the CLI's fallback ladder keys on exactly that. A code retired server-side, + or moved from 401 to 403, would leave every test here passing against a + status the platform stopped sending. + """ + server = _error_codes(OPEN_API_ERRCODES) + missing = sorted(code for code in OPEN_API_HTTP_STATUS if code not in server) + assert not missing, f"服务端已不再定义这些错误码,CLI 桩仍在用:{missing}" + + drifted = { + code: (declared, server[code]) for code, declared in OPEN_API_HTTP_STATUS.items() if server[code] != declared + } + assert not drifted, f"HTTP 状态与服务端不一致(桩值, 服务端值):{drifted}" + + +# ---- `bisheng dev` mirrors of app-proxy and runtime-manager (INV-32) ---------- + + +def test_dev_proxy_injects_exactly_app_proxys_ten_headers_in_order() -> None: + """`devproxy.INJECTED_HEADER_NAMES` == `app_proxy.headers.INJECTED_HEADER_NAMES`. + + The application reads these names; if the hosted proxy adds an eleventh or + renames one, the local run must follow in the same change, or "works with + `bisheng dev`, broken hosted" becomes possible again (AC-23). + """ + assert tuple(devproxy.INJECTED_HEADER_NAMES) == tuple(_module_constant(APP_PROXY_HEADERS, "INJECTED_HEADER_NAMES")) + assert devproxy.PLATFORM_HEADER_PREFIX == _module_constant(APP_PROXY_HEADERS, "PLATFORM_HEADER_PREFIX") + + +def test_dev_proxy_strips_the_same_equivalence_class_and_hop_by_hop_set() -> None: + assert devproxy.DROPPED_HEADERS == frozenset(_module_constant(APP_PROXY_HEADERS, "DROPPED_HEADERS")) + assert devproxy.PLATFORM_SESSION_COOKIE == _module_constant(APP_PROXY_CONFIG, "ACCESS_TOKEN_COOKIE") + + +def _app_proxy_functions(*names: str) -> dict[str, Any]: + """Compile named top-level functions of app-proxy's ``routing.py`` without importing the package. + + ``routing.py`` imports app-proxy's RPC client, which this environment does + not have. The two path functions only need ``urlsplit``, so they are lifted + out of the parsed module and compiled on their own. + """ + from urllib.parse import urlsplit + + module = _module(APP_PROXY_ROUTING) + nodes = [node for node in module.body if isinstance(node, ast.FunctionDef) and node.name in names] + missing = set(names) - {node.name for node in nodes} + assert not missing, f"app-proxy routing.py 里找不到 {missing}" + namespace: dict[str, Any] = {"urlsplit": urlsplit} + exec(compile(ast.Module(body=nodes, type_ignores=[]), str(APP_PROXY_ROUTING), "exec"), namespace) + return namespace + + +@pytest.mark.parametrize( + ("location", "upstream"), + [ + ("/login", ""), + ("/docs/?a=1#top", ""), + ("/", ""), + ("/apps/survey/login", ""), + ("/apps/survey", ""), + ("/apps/other/", ""), + ("next", ""), + ("//cdn.example.com/x", ""), + ("https://example.com/login", "http://127.0.0.1:51234"), + ("http://127.0.0.1:51234/login?next=%2F", "http://127.0.0.1:51234"), + ], +) +def test_dev_proxy_restores_the_prefix_on_redirects_exactly_like_app_proxy(location: str, upstream: str) -> None: + """A redirect the app sends must land in the same place locally and hosted. + + The local copy exists because this package cannot import app-proxy; this + test is what keeps the copy honest. + """ + hosted = _app_proxy_functions("entry_prefix_for", "restore_entry_prefix")["restore_entry_prefix"] + assert devproxy.restore_entry_prefix(location, "survey", "/apps", upstream) == hosted( + location, "survey", "/apps", upstream + ) + + +def test_dev_proxy_uses_app_proxys_entry_prefix() -> None: + assert devproxy.ENTRY_PREFIX == _module_constant(APP_PROXY_CONFIG, "DEFAULT_ENTRY_PREFIX") + + +def test_dev_env_names_equal_the_runtime_managers_build_env() -> None: + """`devdb.PLATFORM_ENV_NAMES` == the keys `lifecycle.build_env` injects. + + The contract list lives in F054 `contracts-runtime-manager.md` §5 and is + implemented once, in `build_env`; this is the check that the CLI never grows + a second definition of it (design §6.2: "不得在 CLI 侧另抄一份定义"). + """ + assert list(devdb.PLATFORM_ENV_NAMES) == _build_env_keys(RUNTIME_LIFECYCLE) + assert tuple(devdb.RESERVED_ENV_PREFIXES) == tuple(_module_constant(RUNTIME_LIFECYCLE, "RESERVED_ENV_PREFIXES")) + + +def test_model_face_env_names_come_from_the_injection_contract() -> None: + """`devdb.MODEL_FACE_ENV_NAMES` ⊆ what §5 of the contract declares. + + These three are not in `build_env` — hosted, F055 T056 fills them from the + application's own runtime credential — so there is no source file to read + them off. The contract document is what both sides were written against, and + a name renamed there without the CLI following is exactly the drift that + makes "works with `bisheng dev`, broken hosted" possible (AC-27 / AC-49). + """ + if not RUNTIME_ENV_CONTRACT.is_file(): + pytest.fail(f"找不到注入环境变量契约 {RUNTIME_ENV_CONTRACT};若文档被移动,请改这里的路径。") + section = RUNTIME_ENV_CONTRACT.read_text(encoding="utf-8").split("## 5. ", 1) + assert len(section) == 2, "契约文档的第 5 节(注入应用的环境变量)不见了" + body = section[1].split("\n## ", 1)[0] + for name in devdb.MODEL_FACE_ENV_NAMES: + assert f"`{name}`" in body, f"{name} 不在契约 §5 里——CLI 不得自造注入名" + # And the document still says `dev` is the one that injects them locally; if + # that sentence goes, this mirror has lost its reason to exist. + assert "`dev` 期由 F053 同名注入" in body + + +def test_the_three_model_names_are_not_a_second_copy_of_the_build_env_list() -> None: + """The two tuples are disjoint, so neither can quietly absorb the other.""" + assert not set(devdb.MODEL_FACE_ENV_NAMES) & set(devdb.PLATFORM_ENV_NAMES) + assert not set(devdb.MODEL_FACE_ENV_NAMES) & set(_build_env_keys(RUNTIME_LIFECYCLE)) + + +def test_dev_framework_exports_match_the_hosted_entrypoint() -> None: + # The entrypoint script exports the framework spellings of the base path; + # `dev` sets the same names so an app needs no per-environment branch. + if not RUNTIME_ENTRYPOINT.is_file(): + pytest.fail(f"找不到托管入口脚本 {RUNTIME_ENTRYPOINT};若模板被移动,请改这里的路径。") + script = RUNTIME_ENTRYPOINT.read_text(encoding="utf-8") + exported = set(re.findall(r"^export ([A-Z_]+)=", script, re.MULTILINE)) + assert set(devdb.FRAMEWORK_ENV_NAMES) <= exported + # And the same resolution order for the start command. + assert ( + script.index("BISHENG_APP_START") < script.index("Procfile") < script.index("main.py") < script.index("app.py") + ) diff --git a/src/bisheng-cli/tests/test_project.py b/src/bisheng-cli/tests/test_project.py new file mode 100644 index 0000000000..55a3f39f31 --- /dev/null +++ b/src/bisheng-cli/tests/test_project.py @@ -0,0 +1,130 @@ +"""T013 — project layer: manifest fast-fail and the per-project app identity. + +CON-1's consequence in one line: the CLI checks that the manifest **exists, parses, +and names three fields**, and nothing else. The authoritative schema belongs to +F055's hosted precheck, and `extra='forbid'` there means any field the CLI +invents locally becomes a rejected upload. +""" + +from __future__ import annotations + +import json + +import pytest + +from bisheng_cli import credentials, project +from bisheng_cli.errors import EXIT_LOCAL_INVALID, EXIT_USAGE, CliError + +BASE = "http://114.test:7860" + +MINIMAL = "name: demo\nruntime: python3.11\nport: 8080\n" + + +def _write(root, text: str): + root.mkdir(parents=True, exist_ok=True) + (root / project.MANIFEST_NAME).write_text(text, encoding="utf-8") + return root + + +def test_missing_manifest_refused_locally_without_upload(tmp_path) -> None: + # The `no_network` sentinel is what proves "without upload": any request at + # all would raise from the transport instead of reaching this assertion. + (tmp_path / "app").mkdir() + with pytest.raises(CliError) as excinfo: + project.load_manifest(tmp_path / "app") + assert excinfo.value.exit_code == EXIT_LOCAL_INVALID + assert project.MANIFEST_NAME in excinfo.value.message + + +def test_unparsable_yaml_refused_with_line_info(tmp_path) -> None: + root = _write(tmp_path / "app", "name: demo\n bad: [unclosed\n") + with pytest.raises(CliError) as excinfo: + project.load_manifest(root) + assert excinfo.value.exit_code == EXIT_LOCAL_INVALID + assert "line" in excinfo.value.message.lower() or "行" in excinfo.value.message + + +@pytest.mark.parametrize("missing", ["name", "runtime", "port"]) +def test_each_of_name_runtime_port_missing_is_named_in_the_error(tmp_path, missing: str) -> None: + lines = [line for line in MINIMAL.splitlines() if not line.startswith(missing + ":")] + root = _write(tmp_path / missing, "\n".join(lines) + "\n") + with pytest.raises(CliError) as excinfo: + project.load_manifest(root) + assert missing in excinfo.value.message + for other in {"name", "runtime", "port"} - {missing}: + assert other not in excinfo.value.message + + +def test_safe_load_only_rejects_python_object_tag(tmp_path) -> None: + # full_load / unsafe_load turn a manifest into remote code execution; the + # refusal has to come from safe_load itself, not from a blocklist we maintain. + root = _write(tmp_path / "app", "name: !!python/object/apply:os.system ['echo pwned']\n") + with pytest.raises(CliError) as excinfo: + project.load_manifest(root) + assert excinfo.value.exit_code == EXIT_LOCAL_INVALID + + +def test_optional_fields_not_validated_locally(tmp_path) -> None: + root = _write( + tmp_path / "app", + MINIMAL + "tier: nonsense\ncapabilities: [whatever]\ndatabase: 3\negress: []\n", + ) + manifest = project.load_manifest(root) + # Nothing invented, nothing defaulted: a locally-added default is exactly how + # "passes locally, rejected on upload" is manufactured. + assert manifest["tier"] == "nonsense" + assert set(manifest) == {"name", "runtime", "port", "tier", "capabilities", "database", "egress"} + + +def test_app_json_written_and_read_per_base_url(tmp_path) -> None: + root = _write(tmp_path / "app", MINIMAL) + project.save_app_ref(root, BASE, app_id="app-1", app_name="问卷", slug="wenjuan", last_deployment_id="dep-1") + raw = json.loads((root / ".bisheng" / "app.json").read_text(encoding="utf-8")) + assert raw["version"] == 1 + entry = raw["apps"][BASE] + assert entry["app_id"] == "app-1" and entry["slug"] == "wenjuan" and entry["updated_at"] + assert project.read_app_ref(root, BASE)["app_name"] == "问卷" + + +def test_app_json_key_uses_the_same_base_url_normaliser_as_credentials(tmp_path) -> None: + # One function, not two implementations that agree today. + assert project.normalise_base_url is credentials.normalise_base_url + root = _write(tmp_path / "app", MINIMAL) + project.save_app_ref(root, "http://114.test:7860/", app_id="app-1") + assert project.read_app_ref(root, "HTTP://114.test:7860")["app_id"] == "app-1" + + +def test_second_platform_does_not_clobber_the_first(tmp_path) -> None: + root = _write(tmp_path / "app", MINIMAL) + project.save_app_ref(root, BASE, app_id="app-1") + project.save_app_ref(root, "http://other.test", app_id="app-2") + assert project.read_app_ref(root, BASE)["app_id"] == "app-1" + + +def test_explicit_app_id_overrides_saved_one(tmp_path) -> None: + root = _write(tmp_path / "app", MINIMAL) + project.save_app_ref(root, BASE, app_id="app-saved") + assert project.resolve_app_id(root, BASE, explicit="app-forced") == "app-forced" + + +def test_resolve_app_id_returns_none_when_unknown_for_first_deploy(tmp_path) -> None: + root = _write(tmp_path / "app", MINIMAL) + assert project.resolve_app_id(root, BASE, explicit=None) is None + + +def test_missing_app_id_asks_for_explicit_flag(tmp_path) -> None: + # A copied project has no saved identity. Guessing one would silently deploy + # over whatever app happened to match. + root = _write(tmp_path / "app", MINIMAL) + with pytest.raises(CliError) as excinfo: + project.require_app_id(root, BASE, explicit=None) + assert excinfo.value.exit_code == EXIT_USAGE + assert "--app-id" in excinfo.value.next_step + + +def test_find_project_root_rejects_a_path_that_is_not_a_directory(tmp_path) -> None: + target = tmp_path / "not-a-dir" + target.write_text("x", encoding="utf-8") + with pytest.raises(CliError) as excinfo: + project.find_project_root(str(target)) + assert excinfo.value.exit_code == EXIT_LOCAL_INVALID diff --git a/src/bisheng-cli/tests/test_release_notes.py b/src/bisheng-cli/tests/test_release_notes.py new file mode 100644 index 0000000000..f485f5345d --- /dev/null +++ b/src/bisheng-cli/tests/test_release_notes.py @@ -0,0 +1,233 @@ +"""F112: the CLI must not upload when release-note input or compatibility is uncertain.""" + +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +import httpx +import pytest + +from bisheng_cli.errors import CliError +from tests.helpers.platform_mock import ( + PlatformMock, + deploy_accept, + deploy_limits, + deploy_sync_err, + v2_ok, +) +from tests.test_command_deploy_sync import _run, _waiting_seq +from tests.test_command_deploy_sync import logged_in as logged_in + +LIMITS = "/api/v2/apps/deploy-limits" +DEPLOY = "/api/v2/apps/deploy" + + +def _platform(*, capability=None, post=None): + data = deploy_limits().json()["data"] + if capability is not None: + data["features"] = {"release_notes": capability} + return ( + PlatformMock() + .get(LIMITS, v2_ok(data)) + .post(DEPLOY, post or deploy_accept()) + .get("/api/v2/apps/deployments/dep-1", _waiting_seq()) + ) + + +def _note(request): + match = re.search(b'name="release_notes"\r\n\r\n(.*?)\r\n--', request.read(), re.S) + return match.group(1).decode() if match else None + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + (None, None), + ("", None), + (" \r\n\t", None), + (" First\r\n Second\rThird ", "First\n Second\nThird"), + ("新功能😀" * 400, "新功能😀" * 400), + ("x" * 2000, "x" * 2000), + ("😀" * 2000, "😀" * 2000), + ], + ids=["null", "empty", "whitespace", "newlines", "unicode", "limit", "emoji-limit"], +) +def test_same_normalization_as_platform(raw, expected): + from bisheng_cli.release_notes import normalize_release_notes + + assert normalize_release_notes(raw) == expected + + +@pytest.mark.parametrize("raw", ["x" * 2001, "😀" * 2001], ids=["ascii", "emoji"]) +def test_over_limit_uses_codepoints_not_bytes(raw): + from bisheng_cli.release_notes import normalize_release_notes + + with pytest.raises(CliError) as caught: + normalize_release_notes(raw) + assert caught.value.exit_code == 6 + + +def test_sources_are_mutually_exclusive(logged_in, sample_project, monkeypatch): + mock = _platform() + with pytest.raises(SystemExit) as caught: + _run( + ["deploy", str(sample_project), "--release-notes", "hello", "--release-notes-file", "notes.txt"], + monkeypatch=monkeypatch, + mock=mock, + ) + assert caught.value.code == 2 + assert not mock.calls + + +@pytest.mark.parametrize("kind", ["missing", "directory", "invalid-utf8", "too-long", "unreadable"]) +def test_bad_files_never_upload(kind, logged_in, sample_project, tmp_path, monkeypatch): + path = tmp_path / "notes.txt" + if kind == "directory": + path.mkdir() + elif kind == "invalid-utf8": + path.write_bytes(b"\xff") + elif kind == "too-long": + path.write_text("x" * 2001) + elif kind == "unreadable": + path.write_text("hello") + original = Path.open + + def no_read(self, *args, **kwargs): + if self == path: + raise PermissionError("no access") + return original(self, *args, **kwargs) + + monkeypatch.setattr(Path, "open", no_read) + mock = _platform() + code, _, _ = _run( + ["deploy", str(sample_project), "--release-notes-file", str(path)], monkeypatch=monkeypatch, mock=mock + ) + assert code == 6 + assert not mock.calls + + +def test_dry_run_validates_without_network(logged_in, sample_project, monkeypatch): + mock = _platform() + code, out, _ = _run( + ["deploy", str(sample_project), "--release-notes", " New\r\nfix ", "--dry-run", "--json"], + monkeypatch=monkeypatch, + mock=mock, + ) + assert code == 0 + result = json.loads(out.splitlines()[-1]) + assert result["data"]["has_release_notes"] is True + assert result["data"]["release_notes_codepoints"] == 7 + assert not mock.calls + + +@pytest.mark.parametrize( + "capability", + [ + None, + {"version": 2, "max_codepoints": 2000}, + {"version": True, "max_codepoints": 2000}, + {"version": 1, "max_codepoints": "2000"}, + {"version": 1, "max_codepoints": 0}, + {"version": 1, "max_codepoints": 2}, + ], + ids=["old", "future", "bool-version", "string-limit", "zero", "smaller-limit"], +) +def test_unknown_support_never_uploads(capability, logged_in, sample_project, monkeypatch): + mock = _platform(capability=capability) + code, _, _ = _run(["deploy", str(sample_project), "--release-notes", "Feature"], monkeypatch=monkeypatch, mock=mock) + assert code == 9 + assert mock.paths_called() == [LIMITS] + + +def test_network_failure_with_notes_does_not_soft_fallback(logged_in, sample_project, monkeypatch): + def broken(request): + raise httpx.ConnectError("connection failed", request=request) + + mock = PlatformMock().get(LIMITS, broken) + code, _, _ = _run(["deploy", str(sample_project), "--release-notes", "Feature"], monkeypatch=monkeypatch, mock=mock) + assert code == 7 + assert mock.paths_called() == [LIMITS] + + +@pytest.mark.parametrize("notes", [None, " \r\n"]) +def test_empty_notes_keep_legacy_platform_compatible(notes, logged_in, sample_project, monkeypatch): + mock = _platform() + argv = ["deploy", str(sample_project)] + (["--release-notes", notes] if notes is not None else []) + code, _, _ = _run(argv, monkeypatch=monkeypatch, mock=mock) + assert code == 0 + post = next(r for r in mock.calls if r.method == "POST") + assert _note(post) is None + + +def test_file_is_frozen_for_schema_confirmation_retry(logged_in, sample_project, tmp_path, monkeypatch): + path = tmp_path / "notes.txt" + path.write_bytes(" 新增功能\r\n Fix ".encode()) + bodies = [] + + def receive(request): + bodies.append(_note(request)) + path.write_text("Changed during upload") + if len(bodies) == 1: + return deploy_sync_err(16229, details={"items": []}) + return deploy_accept() + + mock = _platform(capability={"version": 1, "max_codepoints": 2000}, post=receive) + monkeypatch.setattr("builtins.input", lambda _prompt: "yes") + code, _, _ = _run( + ["deploy", str(sample_project), "--release-notes-file", str(path)], monkeypatch=monkeypatch, mock=mock, tty=True + ) + assert code == 0 + assert bodies == ["新增功能\n Fix", "新增功能\n Fix"] + assert mock.paths_called().count(LIMITS) == 1 + + +def test_server_rejection_maps_to_fix_input(logged_in, sample_project, monkeypatch): + mock = _platform(capability={"version": 1, "max_codepoints": 2000}, post=deploy_sync_err(16233)) + code, _, _ = _run(["deploy", str(sample_project), "--release-notes", "Feature"], monkeypatch=monkeypatch, mock=mock) + assert code == 6 + + +@pytest.mark.parametrize( + ("language", "word"), + [("zh_CN.UTF-8", "发布说明"), ("en_US.UTF-8", "Release notes"), ("ja_JP.UTF-8", "リリースノート")], +) +def test_new_messages_support_all_three_languages(language, word, monkeypatch): + from bisheng_cli.release_notes import message + + monkeypatch.setenv("LC_ALL", language) + assert word in message("text_help") + + +def test_file_source_is_relative_to_command_directory(tmp_path, monkeypatch): + from bisheng_cli.release_notes import load_release_notes + + monkeypatch.chdir(tmp_path) + (tmp_path / "notes.txt").write_bytes(b" A\r\n B ") + assert load_release_notes(file_path="notes.txt") == "A\n B" + + +@pytest.mark.parametrize("explicit", [False, True]) +def test_packaging_post_release_preserves_minimum_and_sdk(tmp_path, explicit): + script = (Path(__file__).resolve().parents[3] / "scripts/pack_cli_wheel.sh").read_text() + manifest_writer = script.split("python3 - <<'PY'\n", 1)[1].split("\nPY\n", 1)[0] + target = tmp_path / "manifest.json" + sdk = {"version": "0.1.0", "filename": "sdk.whl", "sha256": "sdk-preserved"} + target.write_text(json.dumps({"sdk": sdk})) + env = { + **os.environ, + "MANIFEST": str(target), + "CLI_VERSION": "3.0.0.post1", + "CLI_MIN_COMPATIBLE": "3.0.0" if explicit else "", + "PLATFORM_VERSION": "3.0.0" if explicit else "", + "CLI_FILENAME": "cli.whl", + "CLI_SHA256": "cli-digest", + } + subprocess.run([sys.executable, "-c", manifest_writer], check=True, env=env) + result = json.loads(target.read_text()) + assert result["cli"]["version"] == "3.0.0.post1" + assert result["cli"]["min_compatible"] == "3.0.0" + assert result["platform"]["version"] == "3.0.0" + assert result["sdk"] == sdk diff --git a/src/bisheng-cli/tests/test_secret_warnings.py b/src/bisheng-cli/tests/test_secret_warnings.py new file mode 100644 index 0000000000..5f088198c3 --- /dev/null +++ b/src/bisheng-cli/tests/test_secret_warnings.py @@ -0,0 +1,22 @@ +"""F055 AC-72: missing references warn once and never include values.""" + +import io + +from bisheng_cli.commands import deploy +from bisheng_cli.output import Emitter + + +def test_missing_reference_warnings_are_name_only_and_not_repeated(): + output = io.StringIO() + emitter = Emitter(stderr=output, stdout=io.StringIO()) + payload = { + "secret_references": [ + {"name": "MISSING", "reason": "missing_value", "value": "never-show"}, + {"name": "READY", "reason": "ok"}, + ] + } + seen = set() + deploy._emit_secret_warnings(payload, emitter, seen) + deploy._emit_secret_warnings(payload, emitter, seen) + assert output.getvalue().count("MISSING") == 1 + assert "READY" not in output.getvalue() and "never-show" not in output.getvalue() diff --git a/src/bisheng-cli/uv.lock b/src/bisheng-cli/uv.lock new file mode 100644 index 0000000000..1dfe90ba27 --- /dev/null +++ b/src/bisheng-cli/uv.lock @@ -0,0 +1,245 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "bisheng-cli" +version = "3.0.0.post1" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "pyyaml" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.27,<1.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, + { name = "pyyaml", specifier = ">=6.0,<7.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2", size = 4891904, upload-time = "2026-08-13T15:17:13.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7", size = 10902799, upload-time = "2026-08-13T15:16:27.382Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081", size = 11135539, upload-time = "2026-08-13T15:16:30.87Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9", size = 10475095, upload-time = "2026-08-13T15:16:33.259Z" }, + { url = "https://files.pythonhosted.org/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84", size = 10668771, upload-time = "2026-08-13T15:16:35.65Z" }, + { url = "https://files.pythonhosted.org/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870", size = 10699568, upload-time = "2026-08-13T15:16:38.195Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b", size = 11499365, upload-time = "2026-08-13T15:16:40.623Z" }, + { url = "https://files.pythonhosted.org/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413", size = 12311728, upload-time = "2026-08-13T15:16:43.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82", size = 11699896, upload-time = "2026-08-13T15:16:46.209Z" }, + { url = "https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb", size = 11058736, upload-time = "2026-08-13T15:16:48.823Z" }, + { url = "https://files.pythonhosted.org/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474", size = 11586911, upload-time = "2026-08-13T15:16:51.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da", size = 10954265, upload-time = "2026-08-13T15:16:54.763Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50", size = 10709886, upload-time = "2026-08-13T15:16:57.339Z" }, + { url = "https://files.pythonhosted.org/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506", size = 11210392, upload-time = "2026-08-13T15:17:00.171Z" }, + { url = "https://files.pythonhosted.org/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d", size = 11626910, upload-time = "2026-08-13T15:17:03.299Z" }, + { url = "https://files.pythonhosted.org/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a", size = 10931415, upload-time = "2026-08-13T15:17:05.726Z" }, + { url = "https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948", size = 11445993, upload-time = "2026-08-13T15:17:08.353Z" }, + { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] diff --git a/src/bisheng-sdk/README.md b/src/bisheng-sdk/README.md new file mode 100644 index 0000000000..861b14f284 --- /dev/null +++ b/src/bisheng-sdk/README.md @@ -0,0 +1,58 @@ +# bisheng-sdk + +托管应用用的 Python SDK:三件「平台特有 ∧ 写错了会出安全事故」的事各封成一行到位的 +默认做法,且这三行在 `bisheng dev` 本地与上线后**一字不改**。 + +```python +from bisheng_sdk import auth, retrieve, storage + +user = auth.current_user() # 当前访问者;无注入身份即抛错 +hits = retrieve.search("年假怎么休", knowledge_base_ids=[12]) # 以该访问者的身份检索 +meta = storage.put("报告/2026.pdf", data) # 存进本应用的附件空间 +``` + +## 安装 + +从**你正在用的那个平台**取包(纯内网、匿名可达、无公网依赖): + +```bash +pip install --extra-index-url http://<平台地址>/api/v1/dev-toolkit/simple/ bisheng-sdk +``` + +托管构建期同样从这个索引装——在应用的依赖清单里写一行 `bisheng-sdk` 即可。 + +## 三件套 + +| 模块 | 做什么 | 最容易写错的地方 | +|---|---|---| +| `auth` | 读平台注入的访问者身份 | **唯一「写错了应用照样跑」的静默失败点**:不要自建登录页、不要自己解析凭据;无注入身份时 `current_user()` **抛错**而不是返回 `None` | +| `retrieve` | 以**当前访问者**的身份检索知识 | 执行身份必须是访问者而不是应用自己;后台任务没有访问者,检索一律被拒 | +| `storage` | 本应用附件空间的存取 | 只用应用内相对路径;没有 bucket / 对象键 / 直链,需要给用户下载就由应用自己吐流 | + +**模型调用与应用数据库刻意不在 SDK 里**:模型用官方 OpenAI 兼容客户端 + 平台注入的 +`OPENAI_BASE_URL` / `OPENAI_API_KEY`,数据库用标准库 + 平台注入的 `BISHENG_APP_DB_*`。 + +## 把当前请求交给 SDK + +```python +# FastAPI / Starlette(ASGI) +app.add_middleware(auth.ASGIMiddleware) + +# Flask(WSGI) +app.wsgi_app = auth.WSGIMiddleware(app.wsgi_app) + +# Streamlit / 无中间件钩子的框架:脚本顶部显式绑定 +auth.bind(st.context.headers).__enter__() +``` + +## 完整指南 + +`GET /api/v1/dev-toolkit/sdk-guide.md`(与「平台能力接线」技能包同源一份)。 + +## 本地开发 + +```bash +uv sync --frozen --extra dev +uv run pytest +uv run ruff check . && uv run ruff format --check . +``` diff --git a/src/bisheng-sdk/bisheng_sdk/__init__.py b/src/bisheng-sdk/bisheng_sdk/__init__.py new file mode 100644 index 0000000000..600c3cf089 --- /dev/null +++ b/src/bisheng-sdk/bisheng_sdk/__init__.py @@ -0,0 +1,33 @@ +"""BiSheng application SDK — three things an application must not get wrong. + +The public surface is **exactly** three capability modules plus the exception +module they all raise from: + +* :mod:`bisheng_sdk.auth` — who is visiting *right now*. Read from the headers + the platform injects; never invented, never defaulted, never ``None``. +* :mod:`bisheng_sdk.retrieve` — search knowledge as **that visitor**, through + the platform's one retrieval face. +* :mod:`bisheng_sdk.storage` — the application's own attachment space, with no + bucket, key prefix, endpoint or credential ever reaching the application. +* :mod:`bisheng_sdk.errors` — one exception per next action the caller can take. + +Model calls and the application database are **deliberately not here** (PRD-1 +DEV-07): they are the official OpenAI-compatible client and a standard database +driver, wired through environment variables the platform injects. The admission +bar for a fourth module is "platform specific ∧ getting it wrong is a security +incident", and ``tests/test_public_surface.py`` is where that bar is enforced — +change the set there first, or the new module does not ship. +""" + +from __future__ import annotations + +#: Independent of the platform's version on purpose (design D6 / decision 6): +#: an application's dependency list pins this, and a platform upgrade must not +#: invalidate every deployed application. +__version__ = "0.1.1" + +from bisheng_sdk import auth, retrieve, storage + +#: The capability surface. ``errors`` is importable but not a capability, so it +#: is not listed here (design D2). +__all__ = ("auth", "retrieve", "storage") diff --git a/src/bisheng-sdk/bisheng_sdk/_attachment.py b/src/bisheng-sdk/bisheng_sdk/_attachment.py new file mode 100644 index 0000000000..3bfc5b0ba8 --- /dev/null +++ b/src/bisheng-sdk/bisheng_sdk/_attachment.py @@ -0,0 +1,37 @@ +"""附件元信息 —— 应用被允许知道的全部(design D9)。 + +刻意**没有** bucket、对象键、端点地址、存储凭据、签名:应用只见附件的应用内 +路径与元信息。两个后端(平台存储 / 本地目录)产出同一个形状,于是"本地跑得通" +与"线上跑得通"是同一段代码。 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime + +__all__ = ("AttachmentMeta",) + + +@dataclass(frozen=True) +class AttachmentMeta: + """``path`` 是应用内相对路径;``modified_at`` 无值时为 ``None``。""" + + path: str + size: int + content_type: str | None = None + modified_at: datetime | None = None + #: 平台存储给的对象校验值;本地目录后端为空串。保留它只为让应用能做 + #: "变了没有"的判断,它不是可寻址的实现细节。 + etag: str = "" + + +def parse_modified_at(raw: object) -> datetime | None: + """manager 给的是 ISO 8601 字符串;MinIO 无值时是空串。""" + if not isinstance(raw, str) or not raw: + return None + text = raw.replace("Z", "+00:00") + try: + return datetime.fromisoformat(text) + except ValueError: + return None diff --git a/src/bisheng-sdk/bisheng_sdk/_codes.py b/src/bisheng-sdk/bisheng_sdk/_codes.py new file mode 100644 index 0000000000..09e18ef15b --- /dev/null +++ b/src/bisheng-sdk/bisheng_sdk/_codes.py @@ -0,0 +1,177 @@ +"""服务端错误码 → SDK 异常(design D6)。 + +**两张表,因为线上有两种错误信封**: + +* backend(`/api/v1`、`/api/v2`)用 `{status_code: , status_message, data}`, + 码是整数; +* runtime-manager 的附件 API 用 `{"detail": {"code": "<机器码>", "message", …}}`, + 码是字符串。 + +用一套解析会让 storage 的 401 / 413 / 404 全部落成"平台拒绝"、机器码丢失 +(坑 26)。 + +**未登记的码永不吞掉**:两级降级——先按码查表,查不到按 HTTP 状态类兜底, +再兜不住就是 `PlatformRefusedError`,原样带上 code / message / details。 +""" + +from __future__ import annotations + +from typing import Any + +from bisheng_sdk import errors + +# --- backend 业务码(int) ------------------------------------------------- + +#: 260 段:开放 API 鉴权。26001 缺凭据 / 26002 凭据无效 / 26027 服务账号被停用。 +CREDENTIAL_REJECTED_CODES = frozenset({26001, 26002, 26027}) + +#: 263 段(F052 统一检索门面):26320「无执行身份」。托管期它意味着平台没能从 +#: `X-BiSheng-Access-Token` 确立访问用户——检索无访问用户一律拒绝,没有兜底身份。 +RETRIEVAL_IDENTITY_MISSING_CODE = 26320 + +#: 26321「知识库不可及」——缺失 / 未授予 / 类型不支持 / 白名单外合并成一个码 +#: (存在性不泄露),`data.unreachable_ids` 是不可及的标识。 +TARGET_UNREACHABLE_CODES = frozenset({26321}) + +#: 26322:声明过的知识库没了(可与普通不可及区分,`data.knowledge_id`)。 +KNOWLEDGE_CAPABILITY_REVOKED_CODE = 26322 + +#: 162 段(F055 能力总线):16273 能力已收回 / 16274 能力未声明。 +CAPABILITY_REVOKED_CODE = 16273 +CAPABILITY_NOT_DECLARED_CODE = 16274 + +#: 26003 缺权限位(`data.required` 是**单个字符串**,不是列表)。 +SCOPE_MISSING_CODE = 26003 + +#: 26030 凭据校验 / 权限评估依赖不可用。 +PERMISSION_EVALUATION_CODES = frozenset({26030}) + + +def map_error( + code: int | None, + message: str, + *, + http_status: int, + data: Any = None, +) -> errors.BishengSdkError: + """backend 信封 → 异常。""" + details = data if isinstance(data, dict) else ({"data": data} if data is not None else None) + payload = details or {} + + if code in CREDENTIAL_REJECTED_CODES or code == RETRIEVAL_IDENTITY_MISSING_CODE: + return errors.VisitorCredentialRejectedError(message or None, code=code, details=details) + if code == SCOPE_MISSING_CODE: + required = payload.get("required") + return errors.ScopeMissingError( + required=required if isinstance(required, str) else "", + code=code, + details=details, + ) + if code in TARGET_UNREACHABLE_CODES: + ids = payload.get("unreachable_ids") + return errors.TargetUnreachableError(ids if isinstance(ids, list) else [], code=code, details=details) + if code == KNOWLEDGE_CAPABILITY_REVOKED_CODE: + knowledge_id = payload.get("knowledge_id") + return errors.CapabilityRevokedError( + capability=str(knowledge_id) if knowledge_id is not None else None, + reason="revoked", + code=code, + details=details, + ) + if code == CAPABILITY_REVOKED_CODE: + return errors.CapabilityRevokedError( + capability=_text(payload.get("capability")), + reason=_text(payload.get("reason")) or "revoked", + code=code, + details=details, + ) + if code == CAPABILITY_NOT_DECLARED_CODE: + return errors.CapabilityNotDeclaredError( + capability=_text(payload.get("capability")), code=code, details=details + ) + if code in PERMISSION_EVALUATION_CODES: + return errors.PermissionEvaluationError(message or None, code=code, details=details) + + return _by_http_status(code, message, http_status=http_status, details=details) + + +def _by_http_status( + code: int | None, + message: str, + *, + http_status: int, + details: Any, +) -> errors.BishengSdkError: + if http_status == 401: + return errors.VisitorCredentialRejectedError(message or None, code=code, details=details) + if http_status >= 500: + return errors.PlatformUnreachableError( + message or f"平台返回 {http_status}", + "稍后重试;持续如此请管理员查看平台日志", + code=code, + details=details, + ) + return errors.PlatformRefusedError(message or None, code=code, details=details) + + +# --- runtime-manager 机器码(str) ---------------------------------------- + +STORAGE_UNAUTHORIZED = "unauthorized" +STORAGE_NOT_FOUND = "not_found" +STORAGE_INVALID_KEY = "invalid_object_key" +STORAGE_TOO_LARGE = "payload_too_large" +STORAGE_UNAVAILABLE = "storage_unavailable" + + +def map_storage_error( + code: str | None, + message: str, + *, + http_status: int, + detail: Any = None, + path: str = "", +) -> errors.BishengSdkError: + """manager 信封 → 异常。 + + **没有 403、也没有「应用已下线」专用码**:令牌不属本应用、应用已被销毁、 + 根本没带 Bearer,全都是 401 `unauthorized`(坑 28)。AC-25 只要求这几类 + 彼此可区分,因此归一到 `StorageHandleRejectedError`,原因放 `reason`。 + """ + payload = detail if isinstance(detail, dict) else {} + + if code == STORAGE_UNAUTHORIZED: + return errors.StorageHandleRejectedError(reason=message, details=payload or None) + if code == STORAGE_NOT_FOUND: + return errors.AttachmentNotFoundError(path=path, details=payload or None) + if code == STORAGE_INVALID_KEY: + return errors.InvalidAttachmentPathError(path=path, reason=message, details=payload or None) + if code == STORAGE_TOO_LARGE: + max_file_mb = payload.get("max_file_mb") + limit = ( + int(max_file_mb) * 1024 * 1024 + if isinstance(max_file_mb, (int, float, str)) and _numeric(max_file_mb) + else None + ) + return errors.AttachmentTooLargeError(path=path, limit_bytes=limit, details=payload or None) + if code == STORAGE_UNAVAILABLE: + return errors.StorageUnavailableError(message or None, details=payload or None) + + if http_status == 401: + return errors.StorageHandleRejectedError(reason=message, details=payload or None) + if http_status == 404: + return errors.AttachmentNotFoundError(path=path, details=payload or None) + if http_status >= 500: + return errors.StorageUnavailableError(message or None, details=payload or None) + return errors.PlatformRefusedError(message or None, code=None, details=payload or None) + + +def _numeric(value: Any) -> bool: + try: + int(value) + except (TypeError, ValueError): + return False + return True + + +def _text(value: Any) -> str: + return str(value) if value is not None else "" diff --git a/src/bisheng-sdk/bisheng_sdk/_compat.py b/src/bisheng-sdk/bisheng_sdk/_compat.py new file mode 100644 index 0000000000..29f8e6ed21 --- /dev/null +++ b/src/bisheng-sdk/bisheng_sdk/_compat.py @@ -0,0 +1,93 @@ +"""SDK ↔ 平台的版本兼容校验(design D7)。 + +平台声明它支持的**最低兼容 SDK 版本**(`/api/v1/dev-toolkit/versions` 的 +`sdk.min_compatible`)。低于它就在与该平台的**首次交互**抛错,不静默降级、不 +部分工作;兼容的应用在平台升级后不需要重发。 + +三件容易搞反的事: + +* **auth 永不探测。** 它是纯上下文读取、零 I/O,import 与探活都不该被拖下水。 + 于是不兼容可能要到第一次 retrieve / storage 才暴露——指南因此建议应用启动时 + 跑一次连通自检。 +* **只比下界。** 平台升级后老 SDK 仍在区间内即无需重发(AC-03)。 +* **成功才缓存。** 不可达不缓存,下次再试;否则一次网络抖动会让整个进程 + 一直以为平台不可用。 +""" + +from __future__ import annotations + +import threading + +from bisheng_sdk import __version__, _http +from bisheng_sdk.errors import PlatformTooOldError, SdkIncompatibleError + +VERSIONS_PATH = "/api/v1/dev-toolkit/versions" + +_lock = threading.Lock() +_checked: set[str] = set() + + +def version_tuple(raw: str) -> tuple[int, ...]: + """三段元组比较,与 CLI `bisheng_cli.http._version_tuple` 同算法。 + + 字符串比较会让 `"0.10.0" < "0.9.9"`,这类比较一旦错了没人看得出来。 + 不引 `packaging`:依赖预算只有 httpx 一条(CON-1)。 + """ + parts: list[int] = [] + for chunk in str(raw).split("."): + digits = "".join(ch for ch in chunk if ch.isdigit()) + parts.append(int(digits) if digits else 0) + return tuple(parts) + + +def _decide(payload: object) -> None: + sdk = payload.get("sdk") if isinstance(payload, dict) else None + if not isinstance(sdk, dict) or not sdk.get("version"): + raise PlatformTooOldError() + min_compatible = sdk.get("min_compatible") or sdk.get("version") + if not isinstance(min_compatible, str): + raise PlatformTooOldError() + if version_tuple(min_compatible) > version_tuple(__version__): + platform = payload.get("platform") if isinstance(payload, dict) else None + platform_version = platform.get("version") if isinstance(platform, dict) else None + raise SdkIncompatibleError( + sdk_version=__version__, + min_compatible=min_compatible, + platform_version=platform_version if isinstance(platform_version, str) else None, + ) + + +def _mark(base_url: str) -> None: + with _lock: + _checked.add(base_url) + + +def _already(base_url: str) -> bool: + with _lock: + return base_url in _checked + + +def reset() -> None: + """清空进程内缓存(测试用;生产路径不调用)。""" + with _lock: + _checked.clear() + + +def ensure_compatible(base_url: str) -> None: + if _already(base_url): + return + resp = _http.request("versions", "GET", VERSIONS_PATH, base_url=base_url) + if resp.status_code == 404: + raise PlatformTooOldError() + _decide(_http.parse_envelope(resp)) + _mark(base_url) + + +async def aensure_compatible(base_url: str) -> None: + if _already(base_url): + return + resp = await _http.arequest("versions", "GET", VERSIONS_PATH, base_url=base_url) + if resp.status_code == 404: + raise PlatformTooOldError() + _decide(_http.parse_envelope(resp)) + _mark(base_url) diff --git a/src/bisheng-sdk/bisheng_sdk/_context.py b/src/bisheng-sdk/bisheng_sdk/_context.py new file mode 100644 index 0000000000..27b5defb4c --- /dev/null +++ b/src/bisheng-sdk/bisheng_sdk/_context.py @@ -0,0 +1,57 @@ +"""当前请求的注入头快照,存在一个 `ContextVar` 里(design D3)。 + +为什么是 `ContextVar` 而不是线程局部变量或进程全局:asyncio 下线程局部失效, +进程全局会让并发处理两个用户的请求互相串扰(AC-09)。`ContextVar` 的语义恰好 +就是我们要的——新任务复制父上下文、新线程从空开始,于是"后台线程里没有访问者" +是标准库替我们保证的,不是 SDK 兜出来的。 +""" + +from __future__ import annotations + +from contextvars import ContextVar, Token + +from bisheng_sdk import _headers + +_current: ContextVar[dict[str, str] | None] = ContextVar("bisheng_sdk_request_headers", default=None) + + +def bind(snapshot: dict[str, str]) -> Token: + """把本请求的快照放进上下文;返回的 token 用于 :func:`reset`。""" + return _current.set(dict(snapshot)) + + +def reset(token: Token) -> None: + """请求结束时复位。 + + ⚠️ 必须在 **同一个** 上下文里 reset。Starlette 的 `BaseHTTPMiddleware` 把 + `call_next` 放进独立任务,token 跨任务 reset 会抛 + `ValueError: Token was created in a different Context`——这就是 + `auth.ASGIMiddleware` 必须是纯 ASGI 可调用的原因(design 坑 14)。 + """ + try: + _current.reset(token) + except ValueError: + # 上下文已经不是当初 set 的那个(例如调用方把 reset 放进了别的任务)。 + # 与其让应用在正常请求路径上崩,不如清空——语义上等价于"请求结束了"。 + _current.set(None) + + +def clear() -> None: + """丢掉当前上下文里的快照(测试与显式收尾用)。""" + _current.set(None) + + +def current() -> dict[str, str] | None: + return _current.get() + + +def access_token() -> str | None: + """本请求的访问者凭据(`X-BiSheng-Access-Token` 的值),没有则 ``None``。 + + 它**只**活在上下文里:`Identity` 上没有这个字段,进程级环境变量里也没有 + (spec 决议-2 / AC-04)。 + """ + snapshot = _current.get() + if not snapshot: + return None + return snapshot.get(_headers.HEADER_ACCESS_TOKEN) or None diff --git a/src/bisheng-sdk/bisheng_sdk/_env.py b/src/bisheng-sdk/bisheng_sdk/_env.py new file mode 100644 index 0000000000..466ce6cdaa --- /dev/null +++ b/src/bisheng-sdk/bisheng_sdk/_env.py @@ -0,0 +1,113 @@ +"""平台注入的环境变量名(design §4.2 ②)。 + +名字是契约:托管期由 runtime-manager `lifecycle.build_env` 与能力总线注入, +本地期由 `bisheng dev` 同名注入(契约见 +`features/v3.0.0/054-app-domain-runtime/contracts-runtime-manager.md` §5), +因此应用代码本地线上零差异。SDK 只读这几个名字,**不读任何别的密钥变量**。 + +`BISHENG_APP_TOKEN` 是应用自身的运行期凭据。它回答的是「这次调用来自哪个应用」, +与访问者身份(请求头里的 `X-BiSheng-Access-Token`)是两件事,永远一起出现、 +永远不互相替代:只带应用凭据的检索会被平台以「无访问用户」拒绝,只带访问者凭据 +则根本不认识这个应用。 +""" + +from __future__ import annotations + +import os + +from bisheng_sdk.errors import PlatformUnreachableError + +#: 平台 API 根地址(retrieve 与版本探测都打它)。 +ENV_PLATFORM_API_BASE = "BISHENG_PLATFORM_API_BASE" + +#: 应用自身的运行期凭据(F055 能力总线在创建容器前注入)。 +ENV_APP_TOKEN = "BISHENG_APP_TOKEN" + +#: 托管期附件句柄三件(runtime-manager `storage.STORAGE_ENV_NAMES`)。 +ENV_STORAGE_ENDPOINT = "BISHENG_APP_STORAGE_ENDPOINT" +ENV_STORAGE_TOKEN = "BISHENG_APP_STORAGE_TOKEN" +ENV_STORAGE_MAX_FILE_MB = "BISHENG_APP_STORAGE_MAX_FILE_MB" + +#: 本地期目录句柄(`bisheng dev` 注入;值 = `<项目根>/.bisheng/attachments/`)。 +ENV_STORAGE_DIR = "BISHENG_APP_STORAGE_DIR" + +#: 显式打开 httpx 的代理环境变量读取(本地默认关,design D11)。 +ENV_TRUST_ENV = "BISHENG_SDK_TRUST_ENV" + +#: 平台出站代理的凭据(runtime-manager `egress.ENV_EGRESS_TOKEN`,开了出站白名单才注入)。 +#: SDK **只看它在不在**,不取值:在 = 身处托管容器,唯一出口是注入的 `HTTP(S)_PROXY`。 +ENV_EGRESS_TOKEN = "BISHENG_APP_EGRESS_TOKEN" + +#: 应用标识,只用于错误文案(真正定位应用的是注入的句柄与凭据)。 +ENV_APP_ID = "BISHENG_APP_ID" + + +def platform_api_base() -> str: + """平台地址,空即抛错。 + + `app_runtime.entry_base_url` 允许为空("derive from request"),此时注入的 + 是**空串**。不在这里挡住的话,httpx 会对相对地址报 `UnsupportedProtocol`, + 看不出是平台配置缺失(design 坑 19)。 + """ + base = (os.environ.get(ENV_PLATFORM_API_BASE) or "").strip().rstrip("/") + if not base: + raise PlatformUnreachableError( + f"环境变量 {ENV_PLATFORM_API_BASE} 未注入或为空,不知道平台地址", + "托管运行期请管理员配置 `app_runtime.entry_base_url`;本地开发请经 `bisheng dev` 启动应用", + ) + return base + + +def app_token() -> str | None: + """应用运行期凭据明文,未注入时 ``None``。""" + return (os.environ.get(ENV_APP_TOKEN) or "").strip() or None + + +def storage_endpoint() -> str | None: + return (os.environ.get(ENV_STORAGE_ENDPOINT) or "").strip().rstrip("/") or None + + +def storage_token() -> str | None: + return (os.environ.get(ENV_STORAGE_TOKEN) or "").strip() or None + + +def storage_dir() -> str | None: + return (os.environ.get(ENV_STORAGE_DIR) or "").strip() or None + + +def storage_max_file_bytes() -> int | None: + """单文件上限(字节),未注入或不可解析时 ``None`` = 客户端不预判。 + + 线上恒注入(缺省 20 MB);本地 `bisheng dev` 期可选,不注入即本地不限—— + 于是本地传得进、线上 413,指南把这一条列为本地 / 线上差异之一(坑 23)。 + """ + raw = (os.environ.get(ENV_STORAGE_MAX_FILE_MB) or "").strip() + if not raw: + return None + try: + megabytes = int(float(raw)) + except ValueError: + return None + if megabytes <= 0: + return None + return megabytes * 1024 * 1024 + + +def trust_env() -> bool: + """httpx 是否读代理环境变量(design D11)。 + + 托管容器挂在 `--internal` 网上,除了网关没有任何路由:平台 API 只能经平台注入的 + 出站代理到达(平台地址在代理白名单里恒放行)。所以**有出站凭据就必须读**,否则 + `retrieve` 直连平台只会得到「网络不可达」。附件句柄的地址在注入的 `NO_PROXY` 里, + 读了代理变量也照旧直连。 + + 本地没有这个凭据(`bisheng dev` 不注入),仍默认不读——开发机上的公网代理会劫持 + 内网平台地址;确实需要时用 `BISHENG_SDK_TRUST_ENV=1` 显式打开。 + """ + if (os.environ.get(ENV_TRUST_ENV) or "").strip() == "1": + return True + return bool((os.environ.get(ENV_EGRESS_TOKEN) or "").strip()) + + +def app_id() -> str: + return (os.environ.get(ENV_APP_ID) or "").strip() diff --git a/src/bisheng-sdk/bisheng_sdk/_headers.py b/src/bisheng-sdk/bisheng_sdk/_headers.py new file mode 100644 index 0000000000..db67af3f33 --- /dev/null +++ b/src/bisheng-sdk/bisheng_sdk/_headers.py @@ -0,0 +1,103 @@ +"""平台注入头的名字、归一化与解析(design §4.2 ①)。 + +这张表是 `src/app-proxy/app_proxy/headers.py::INJECTED_HEADER_NAMES` 的第三份 +副本(第二份在 `bisheng dev` 的迷你代理里)。三份必须逐字相同, +`tests/test_contract_alignment.py` 读 app-proxy 的源文件对账——SDK 不读 CLI, +上游是 app-proxy。 + +两个反直觉之处,写错了排查成本很高: + +* **缺失的头是「不发」而不是「发空串」**(app-proxy `build_injected_headers`)。 + 服务账号没有部门,于是部门三头整个不出现——用 ``== ""`` 判断"无部门"永远为假。 +* **非 ASCII 值在注入侧被 ``quote(text, safe="/")``**,ASCII 值原样。因此中文 + 姓名读出来是 ``%E5%BC%A0…``,必须 ``unquote`` 才是人名。 +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from urllib.parse import unquote + +#: 平台拥有的归一化前缀。快照只收这个前缀下的头。 +PLATFORM_HEADER_PREFIX = "x-bisheng-" + +#: 十个注入头,顺序与拼写逐字照 app-proxy。 +INJECTED_HEADER_NAMES: tuple[str, ...] = ( + "X-BiSheng-User-Id", + "X-BiSheng-User-Name", + "X-BiSheng-Tenant-Id", + "X-BiSheng-Dept-Id", + "X-BiSheng-Dept-Name", + "X-BiSheng-Dept-Path", + "X-BiSheng-Subject-Kind", + "X-BiSheng-App-Id", + "X-BiSheng-Access-Token", + "X-BiSheng-Request-Id", +) + +HEADER_USER_ID = "x-bisheng-user-id" +HEADER_USER_NAME = "x-bisheng-user-name" +HEADER_TENANT_ID = "x-bisheng-tenant-id" +HEADER_DEPT_ID = "x-bisheng-dept-id" +HEADER_DEPT_NAME = "x-bisheng-dept-name" +HEADER_DEPT_PATH = "x-bisheng-dept-path" +HEADER_SUBJECT_KIND = "x-bisheng-subject-kind" +HEADER_APP_ID = "x-bisheng-app-id" +HEADER_ACCESS_TOKEN = "x-bisheng-access-token" +HEADER_REQUEST_ID = "x-bisheng-request-id" + +#: 注入侧的缺省值(`entry_authz_service` 不发该头时的含义)。线上恒 ``human``; +#: ``bisheng dev`` 期取决于 ``login`` 用的密钥种类(服务账号密钥 → +#: ``service_account``,个人访问令牌 → ``human``)。SDK 原样透传、不推导。 +DEFAULT_SUBJECT_KIND = "human" + + +def normalize_name(name: str) -> str: + """``X_BiSheng_User_Id``、``HTTP_X_BISHENG_USER_ID`` 与规范拼写是同一个头。 + + WSGI 家族把头折成 ``HTTP_`` 前缀 + 下划线,这里先去前缀再归一化——与 + app-proxy `normalize_header_name` 同规则(那正是 CVE-2025-64484 的教训: + 只认带连字符的拼法,下划线形态会径直走过去)。 + """ + text = str(name).strip() + if text.upper().startswith("HTTP_"): + text = text[5:] + return text.lower().replace("_", "-") + + +def snapshot(headers: Iterable[tuple[str, str]] | Mapping[str, str]) -> dict[str, str]: + """本请求的注入头快照:只收 ``x-bisheng-`` 前缀,键为归一化名。""" + items = headers.items() if isinstance(headers, Mapping) else headers + collected: dict[str, str] = {} + for raw_name, raw_value in items: + name = normalize_name(_as_text(raw_name)) + if not name.startswith(PLATFORM_HEADER_PREFIX): + continue + collected[name] = _as_text(raw_value) + return collected + + +def wsgi_snapshot(environ: Mapping[str, object]) -> dict[str, str]: + """WSGI ``environ`` 里的 ``HTTP_X_BISHENG_*``,归一化成同一份快照。""" + pairs: list[tuple[str, str]] = [] + for key, value in environ.items(): + if not isinstance(key, str) or not key.startswith("HTTP_"): + continue + pairs.append((key, value if isinstance(value, str) else str(value))) + return snapshot(pairs) + + +def decode_value(value: str) -> str: + """还原注入侧的 ``quote(text, safe="/")``。 + + 一个恰好含字面 ``%E5`` 的 ASCII 值会被误解码——病态输入,接受(注入侧对 + ASCII 值原样放行,因此无法区分"本来就有 %"与"被编码过")。 + """ + return unquote(value) + + +def _as_text(value: object) -> str: + """ASGI 的 ``scope["headers"]`` 是 ``bytes``,WSGI / 框架给 ``str``。""" + if isinstance(value, bytes): + return value.decode("latin-1") + return str(value) diff --git a/src/bisheng-sdk/bisheng_sdk/_http.py b/src/bisheng-sdk/bisheng_sdk/_http.py new file mode 100644 index 0000000000..18db093257 --- /dev/null +++ b/src/bisheng-sdk/bisheng_sdk/_http.py @@ -0,0 +1,214 @@ +"""与平台的 HTTP 往来:连接池、分档超时、两种信封、零重试(design D11)。 + +本文件最要命的一条规则是 :func:`parse_envelope` 的解析顺序:**先读 body,再看 +状态行**。`/api/v1` 一律答 HTTP 200 并把结论放在信封的 `status_code` 里, +`/api/v2` 则既有真状态、又保留信封。先看状态行会把 v1 的业务错误当成功;只看 +body 会把 v2 的 401 当没有信封(坑 7)。 + +**零重试**是刻意的:PUT 非幂等,而 AC-16 明令"不本地重试成更小的范围"——重试 +是应用的决定,不是 SDK 的。 + +代理环境变量**只在托管期读**(`_env.trust_env()`):托管容器在 `--internal` 网上, +平台 API 唯一的路是平台注入的出站代理,不读就连不上平台;本地不读,因为开发机上的 +公网代理会劫持内网地址,需要时用 `BISHENG_SDK_TRUST_ENV=1` 显式打开。 +""" + +from __future__ import annotations + +import threading +from typing import Any + +import httpx + +from bisheng_sdk import _codes, _env +from bisheng_sdk.errors import PlatformUnreachableError + +CONNECT_TIMEOUT = 5.0 +#: 检索是每请求同步调用,读超时不能长到把应用自己的请求拖死。 +RETRIEVE_READ_TIMEOUT = 30.0 +#: 附件可能是几十 MB,上传下载给足时间。 +STORAGE_READ_TIMEOUT = 120.0 +VERSIONS_READ_TIMEOUT = 10.0 + +_READ_TIMEOUTS = { + "retrieve": RETRIEVE_READ_TIMEOUT, + "storage": STORAGE_READ_TIMEOUT, + "versions": VERSIONS_READ_TIMEOUT, +} + +_lock = threading.Lock() +_clients: dict[tuple[str, str], httpx.Client] = {} +_aclients: dict[tuple[str, str], httpx.AsyncClient] = {} + + +def timeout_for(kind: str) -> httpx.Timeout: + read = _READ_TIMEOUTS.get(kind, RETRIEVE_READ_TIMEOUT) + return httpx.Timeout(read, connect=CONNECT_TIMEOUT, read=read, write=read, pool=CONNECT_TIMEOUT) + + +def client(base_url: str, kind: str = "retrieve") -> httpx.Client: + """按 (base_url, kind) 复用的同步客户端(连接池活在进程内)。""" + key = (base_url, kind) + with _lock: + existing = _clients.get(key) + if existing is not None: + return existing + created = httpx.Client(base_url=base_url, timeout=timeout_for(kind), trust_env=_env.trust_env()) + _clients[key] = created + return created + + +def aclient(base_url: str, kind: str = "retrieve") -> httpx.AsyncClient: + key = (base_url, kind) + with _lock: + existing = _aclients.get(key) + if existing is not None: + return existing + created = httpx.AsyncClient(base_url=base_url, timeout=timeout_for(kind), trust_env=_env.trust_env()) + _aclients[key] = created + return created + + +def reset_clients() -> None: + """丢弃连接池。测试与"环境变量变了"之后用;生产路径不调用。""" + with _lock: + for pooled in _clients.values(): + try: + pooled.close() + except Exception: # pragma: no cover - 关闭失败不该影响调用方 + pass + _clients.clear() + _aclients.clear() + + +def request( + kind: str, + method: str, + url: str, + *, + base_url: str, + bearer: str | None = None, + headers: dict[str, str] | None = None, + json: Any = None, + content: Any = None, + params: dict[str, Any] | None = None, +) -> httpx.Response: + """发一次请求。连接 / 超时类失败统一成 `PlatformUnreachableError`。""" + try: + return client(base_url, kind).request( + method, + url, + headers=_headers_for(bearer, headers), + json=json, + content=content, + params=params, + ) + except httpx.HTTPError as exc: + raise _unreachable(base_url, exc) + + +async def arequest( + kind: str, + method: str, + url: str, + *, + base_url: str, + bearer: str | None = None, + headers: dict[str, str] | None = None, + json: Any = None, + content: Any = None, + params: dict[str, Any] | None = None, +) -> httpx.Response: + try: + return await aclient(base_url, kind).request( + method, + url, + headers=_headers_for(bearer, headers), + json=json, + content=content, + params=params, + ) + except httpx.HTTPError as exc: + raise _unreachable(base_url, exc) + + +def _headers_for(bearer: str | None, extra: dict[str, str] | None) -> dict[str, str]: + headers: dict[str, str] = {} + if bearer: + headers["Authorization"] = f"Bearer {bearer}" + if extra: + headers.update({name: value for name, value in extra.items() if value is not None}) + return headers + + +def _unreachable(base_url: str, exc: httpx.HTTPError) -> PlatformUnreachableError: + # 异常文本里可能带 URL,不带凭据;`BishengSdkError` 仍会过一遍掩码。 + return PlatformUnreachableError(f"连接平台 {base_url} 失败:{type(exc).__name__}: {exc}") + + +def parse_envelope(resp: httpx.Response) -> Any: + """返回信封的 `data`,或抛出翻译后的异常。顺序见模块 docstring。""" + body = _json_or_none(resp) + + if isinstance(body, dict) and "status_code" in body: + status_code = body.get("status_code") + if isinstance(status_code, int) and status_code != 200: + raise _codes.map_error( + status_code, + str(body.get("status_message") or ""), + http_status=resp.status_code, + data=body.get("data"), + ) + return body.get("data") + + if resp.status_code >= 400: + raise _codes.map_error( + None, + _text_excerpt(resp), + http_status=resp.status_code, + data=None, + ) + return body + + +def parse_manager_envelope(resp: httpx.Response, *, path: str = "") -> Any: + """runtime-manager 的附件 API:成功返 JSON,失败读 `{"detail": {...}}`。""" + if resp.status_code < 400: + return _json_or_none(resp) + + body = _json_or_none(resp) + detail = body.get("detail") if isinstance(body, dict) else None + if isinstance(detail, dict): + raise _codes.map_storage_error( + _as_str(detail.get("code")), + str(detail.get("message") or ""), + http_status=resp.status_code, + detail=detail, + path=path, + ) + # FastAPI 的默认校验错误(`detail` 是列表)与任何非 JSON 响应都落这里。 + raise _codes.map_storage_error( + None, + _text_excerpt(resp), + http_status=resp.status_code, + detail=detail if isinstance(detail, dict) else None, + path=path, + ) + + +def _json_or_none(resp: httpx.Response) -> Any: + try: + return resp.json() + except ValueError: + return None + + +def _as_str(value: Any) -> str | None: + return value if isinstance(value, str) else None + + +def _text_excerpt(resp: httpx.Response) -> str: + try: + return resp.text[:200] + except Exception: # pragma: no cover - defensive + return "" diff --git a/src/bisheng-sdk/bisheng_sdk/_paths.py b/src/bisheng-sdk/bisheng_sdk/_paths.py new file mode 100644 index 0000000000..bcffbd3d36 --- /dev/null +++ b/src/bisheng-sdk/bisheng_sdk/_paths.py @@ -0,0 +1,61 @@ +"""附件路径规则 —— runtime-manager `runtime_manager/storage.py::validate_key` +的逐条镜像(design D9)。 + +镜像而不是"差不多":客户端先判一次是为了零请求就给出明确错误,服务端仍会再判 +一次,两边规则一旦漂移,应用会看到"本地过、线上 400"。 +`tests/test_contract_alignment.py` 读对方源码对账。 + +**拒绝而不是规范化**:``a/../b`` 会被拒,哪怕它规范化后就是 ``b``——写出 +``a/../b`` 的应用几乎肯定想越界,诚实的回答是"不行",而不是"给你 b"。 +""" + +from __future__ import annotations + +import posixpath + +from bisheng_sdk.errors import InvalidAttachmentPathError + +#: S3 自己的上限;manager 先说一次。 +MAX_KEY_BYTES = 1024 + +#: manager 保留的命名空间。哪怕是自己应用的 ``apps/…`` 前缀也拒——接受它等于 +#: 告诉应用"这个命名空间是可寻址的",它不是。 +APPS_NAMESPACE = "apps/" + + +def validate(path: str) -> str: + """合法则原样返回,否则抛 :class:`InvalidAttachmentPathError`。""" + if not isinstance(path, str) or not path: + raise InvalidAttachmentPathError(path=str(path), reason="路径为空") + if len(path.encode("utf-8")) > MAX_KEY_BYTES: + raise InvalidAttachmentPathError(path=path[:64] + "…", reason=f"超过 {MAX_KEY_BYTES} 字节") + if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in path): + raise InvalidAttachmentPathError(path=repr(path), reason="含控制字符") + if "\\" in path: + raise InvalidAttachmentPathError(path=path, reason="含反斜杠") + if path.startswith("/"): + raise InvalidAttachmentPathError(path=path, reason="绝对路径") + if path.endswith("/"): + raise InvalidAttachmentPathError(path=path, reason="以 / 结尾") + if any(segment in {"", ".", ".."} for segment in path.split("/")): + raise InvalidAttachmentPathError(path=path, reason="含空段、`.` 或 `..`") + if posixpath.normpath(path) != path: + raise InvalidAttachmentPathError(path=path, reason="不是规范形") + if path.startswith(APPS_NAMESPACE): + raise InvalidAttachmentPathError(path=path, reason=f"`{APPS_NAMESPACE}` 是保留命名空间") + return path + + +def validate_prefix(prefix: str) -> str: + """列举用的前缀:空串、或一个可选以 ``/`` 结尾的路径。""" + if prefix in ("", None): + return "" + if not isinstance(prefix, str): + raise InvalidAttachmentPathError(path=str(prefix), reason="前缀不是字符串") + if prefix.startswith(APPS_NAMESPACE): + raise InvalidAttachmentPathError(path=prefix, reason=f"`{APPS_NAMESPACE}` 是保留命名空间") + if prefix.endswith("/"): + validate(prefix[:-1]) + return prefix + validate(prefix) + return prefix diff --git a/src/bisheng-sdk/bisheng_sdk/_storage_local.py b/src/bisheng-sdk/bisheng_sdk/_storage_local.py new file mode 100644 index 0000000000..5d8cbce1eb --- /dev/null +++ b/src/bisheng-sdk/bisheng_sdk/_storage_local.py @@ -0,0 +1,184 @@ +"""本地目录后端 —— `bisheng dev` 期的附件空间(design D8)。 + +同一套 API、同一个 `AttachmentMeta`,只是真身落在项目本地的附件目录里而不是平台 +存储。**真实落盘位置对应用逻辑不可见**:元信息与错误文案里只有应用内路径。 + +写入是 `tempfile` + `os.replace` 的原子落地:中途异常不会留下半个文件,另一个 +进程也读不到写了一半的附件。 +""" + +from __future__ import annotations + +import mimetypes +import os +import tempfile +from collections.abc import Iterator +from datetime import UTC, datetime +from pathlib import Path +from typing import IO, Any + +from bisheng_sdk._attachment import AttachmentMeta +from bisheng_sdk.errors import AttachmentNotFoundError, AttachmentTooLargeError, InvalidAttachmentPathError + +_CHUNK = 1024 * 1024 + + +class LocalDirBackend: + """把 `/<应用内路径>` 当作附件空间。""" + + def __init__(self, root: str | os.PathLike[str], max_bytes: int | None = None) -> None: + self.root = Path(root) + self.max_bytes = max_bytes + + # -- helpers --------------------------------------------------------- + + def _resolved(self, path: str) -> Path: + target = self.root / path + # 防御性二次守卫:路径规则已经拒了 `..` 与绝对路径,符号链接仍可能把 + # 解析结果带出根目录。 + root = self.root.resolve() + try: + resolved = target.resolve() + except OSError: # pragma: no cover - defensive + return target + if root not in resolved.parents and resolved != root: + raise _escapes(path) + return target + + def _meta(self, path: str, target: Path) -> AttachmentMeta: + stat = target.stat() + guessed, _ = mimetypes.guess_type(path) + return AttachmentMeta( + path=path, + size=stat.st_size, + content_type=guessed, + modified_at=datetime.fromtimestamp(stat.st_mtime, tz=UTC), + ) + + def _check_size(self, path: str, size: int) -> None: + if self.max_bytes is not None and size > self.max_bytes: + raise AttachmentTooLargeError(path=path, limit_bytes=self.max_bytes) + + # -- operations ------------------------------------------------------ + + def put(self, path: str, data: Any, content_type: str | None = None) -> AttachmentMeta: + target = self._resolved(path) + known = known_length(data) + if known is not None: + # 长度已知就在建临时文件之前拒——零落盘。 + self._check_size(path, known) + target.parent.mkdir(parents=True, exist_ok=True) + handle = tempfile.NamedTemporaryFile(dir=target.parent, delete=False, suffix=".bstmp") + written = 0 + try: + for chunk in _iter_chunks(data): + written += len(chunk) + self._check_size(path, written) + handle.write(chunk) + handle.flush() + os.fsync(handle.fileno()) + handle.close() + os.replace(handle.name, target) + except BaseException: + handle.close() + # 半个文件不留:失败的 put 必须让附件空间回到调用前的样子。 + try: + os.unlink(handle.name) + except OSError: # pragma: no cover - 已被 replace 走或已删 + pass + raise + return self._meta(path, target) + + def get(self, path: str) -> bytes: + target = self._resolved(path) + if not target.is_file(): + raise AttachmentNotFoundError(path=path) + return target.read_bytes() + + def open(self, path: str) -> IO[bytes]: + target = self._resolved(path) + if not target.is_file(): + raise AttachmentNotFoundError(path=path) + return target.open("rb") + + def stat(self, path: str) -> AttachmentMeta: + target = self._resolved(path) + if not target.is_file(): + raise AttachmentNotFoundError(path=path) + return self._meta(path, target) + + def list(self, prefix: str = "", limit: int | None = None) -> list[AttachmentMeta]: + if not self.root.is_dir(): + return [] + rows: list[AttachmentMeta] = [] + for dirpath, _dirnames, filenames in os.walk(self.root): + for name in filenames: + if name.endswith(".bstmp"): + continue + absolute = Path(dirpath) / name + relative = absolute.relative_to(self.root).as_posix() + if prefix and not relative.startswith(prefix): + continue + rows.append(self._meta(relative, absolute)) + rows.sort(key=lambda meta: meta.path) + if limit is not None: + return rows[:limit] + return rows + + def delete(self, path: str) -> None: + target = self._resolved(path) + if not target.is_file(): + # 删一个不存在的附件不是静默成功(AC-25)。 + raise AttachmentNotFoundError(path=path) + target.unlink() + + +def _escapes(path: str) -> InvalidAttachmentPathError: + return InvalidAttachmentPathError(path=path, reason="解析后超出附件空间") + + +def known_length(data: Any) -> int | None: + """发送前能确定的长度,确定不了就 ``None``(边写边计数)。""" + if isinstance(data, (bytes, bytearray, memoryview)): + return len(data) + if isinstance(data, str): + return len(data.encode("utf-8")) + if isinstance(data, os.PathLike): + try: + return os.path.getsize(data) + except OSError: # pragma: no cover - 交给随后的 open 去报真正的错 + return None + try: + current = data.tell() + end = data.seek(0, os.SEEK_END) + data.seek(current, os.SEEK_SET) + except (AttributeError, OSError, ValueError): + return None + return max(int(end) - int(current), 0) + + +def _iter_chunks(data: Any) -> Iterator[bytes]: + """bytes / 文件对象 / 本地文件路径三种入参都走同一条流式写入。""" + if isinstance(data, (bytes, bytearray, memoryview)): + yield bytes(data) + return + if isinstance(data, str): + # 字符串是"写这段文本"而不是"读这个路径"——路径要用 `pathlib.Path`, + # 否则一个恰好存在的同名文件会让语义在两台机器上不一样。 + yield data.encode("utf-8") + return + if isinstance(data, os.PathLike): + with open(data, "rb") as source: + while True: + chunk = source.read(_CHUNK) + if not chunk: + return + yield chunk + read = getattr(data, "read", None) + if read is None: + raise TypeError(f"unsupported attachment payload: {type(data).__name__}") + while True: + chunk = read(_CHUNK) + if not chunk: + return + yield chunk if isinstance(chunk, bytes) else bytes(chunk) diff --git a/src/bisheng-sdk/bisheng_sdk/_storage_remote.py b/src/bisheng-sdk/bisheng_sdk/_storage_remote.py new file mode 100644 index 0000000000..4bb52e4d81 --- /dev/null +++ b/src/bisheng-sdk/bisheng_sdk/_storage_remote.py @@ -0,0 +1,242 @@ +"""平台存储后端 —— 托管期的附件句柄(design D8 / §4.2 ④)。 + +句柄 = 注入的 ``BISHENG_APP_STORAGE_ENDPOINT``(**已经包含** +``/v1/apps/{app_id}/storage``,SDK 不拼 ``app_id``)+ ``BISHENG_APP_STORAGE_TOKEN`` +(每应用一把、绑定该 app,A 的令牌打 B 的 URL 是 401)。 + +四条反 REST 直觉、照着直觉写就会错的地方(对方源码为准, +`tests/test_contract_alignment.py` 对账): + +* 元信息在 ``/meta/{key}``,**不是** ``/stat/``; +* ``DELETE`` 成功答 **200 `{}`**,不是 204; +* 删一个不存在的附件是 **404**,不是静默成功; +* 错误信封是 manager 形状 ``{"detail": {"code", "message", …}}``,不是 backend 的 + ``{status_code, status_message, data}``。 + +上传 body 是**原始字节**(不是 multipart),路径逐段 percent-encode 后拼进 URL。 +永不走 manager 的 HMAC 路径——那是平台后端用的。 + +**连不上附件服务不是"连不上平台"**:`BISHENG_APP_STORAGE_ENDPOINT` 指向的是 +runtime-manager 的应用面地址,与 `BISHENG_PLATFORM_API_BASE` 是两个地址、两套排查 +动作(坑 27:manager 只听 `127.0.0.1` 或没配 `RTM_APP_FACING_BASE_URL` 时应用容器 +根本够不到它)。因此本模块把传输层失败一律翻成 `StorageUnavailableError`——它的 +下一步指向 `runtime/status` 的 `attachment_storage` 自检项;漏翻会让附件故障看起来 +像平台故障,排查从第一步就走错方向(design D6 / AC-25「彼此可区分」)。 +""" + +from __future__ import annotations + +import io +import os +from collections.abc import Iterator +from contextlib import contextmanager +from typing import IO, Any +from urllib.parse import quote + +from bisheng_sdk import _env, _http +from bisheng_sdk._attachment import AttachmentMeta, parse_modified_at +from bisheng_sdk._storage_local import known_length +from bisheng_sdk.errors import AttachmentTooLargeError, PlatformUnreachableError, StorageUnavailableError + +DEFAULT_CONTENT_TYPE = "application/octet-stream" +#: manager 的 `limit` 上限(`MAX_LIST_LIMIT`),单页最多这么多。 +MAX_PAGE = 1000 + + +def _as_storage_outage(exc: PlatformUnreachableError) -> StorageUnavailableError: + """传输层失败 → 附件服务不可用(不是平台不可用)。""" + return StorageUnavailableError(exc.message, details=exc.details) + + +def encode_key(path: str) -> str: + """逐段 ``quote(seg, safe="")``:服务端用 ``{key:path}`` 接,斜杠要保留为分隔符。""" + return "/".join(quote(segment, safe="") for segment in path.split("/")) + + +class RemoteBackend: + """HTTP 句柄后端。``endpoint`` 已含应用前缀,``token`` 是该应用的句柄凭据。""" + + def __init__(self, endpoint: str, token: str, max_bytes: int | None = None) -> None: + self.endpoint = endpoint.rstrip("/") + self.token = token + self.max_bytes = max_bytes + + # -- helpers --------------------------------------------------------- + + def _objects_url(self, path: str) -> str: + return f"{self.endpoint}/objects/{encode_key(path)}" + + def _meta_url(self, path: str) -> str: + return f"{self.endpoint}/meta/{encode_key(path)}" + + def _check_size(self, path: str, data: Any) -> int | None: + size = known_length(data) + if size is not None and self.max_bytes is not None and size > self.max_bytes: + # 平台会再判一次答 413;能在本地判出来就不必把几十 MB 送上网。 + raise AttachmentTooLargeError(path=path, limit_bytes=self.max_bytes) + return size + + def _meta_of(self, payload: Any, fallback_path: str) -> AttachmentMeta: + row = payload if isinstance(payload, dict) else {} + return AttachmentMeta( + path=str(row.get("key") or fallback_path), + size=int(row.get("size") or 0), + content_type=(row.get("content_type") or None), + modified_at=parse_modified_at(row.get("last_modified")), + etag=str(row.get("etag") or ""), + ) + + def _put_headers(self, content_type: str | None, size: int | None) -> dict[str, str]: + headers = {"Content-Type": content_type or DEFAULT_CONTENT_TYPE} + if size is not None: + # 服务端先看 `Content-Length` 再看实收字节;带上它意味着超限在第一个 + # 字节离开本进程之前就被拒。 + headers["Content-Length"] = str(size) + return headers + + def _list_params(self, prefix: str, limit: int | None, collected: int, cursor: str | None) -> dict[str, Any]: + page = min(limit - collected, MAX_PAGE) if limit is not None else MAX_PAGE + params: dict[str, Any] = {"prefix": prefix, "limit": max(page, 1)} + if cursor: + params["cursor"] = cursor + return params + + def _absorb_page(self, payload: Any, rows: list[AttachmentMeta]) -> str | None: + body = payload if isinstance(payload, dict) else {} + rows.extend(self._meta_of(row, "") for row in (body.get("objects") or [])) + return body.get("next_cursor") or None + + def _send(self, method: str, url: str, **kwargs: Any) -> Any: + """一次附件 API 调用。传输层失败在这里就翻成"附件服务不可用"。""" + try: + return _http.request("storage", method, url, base_url=self.endpoint, bearer=self.token, **kwargs) + except PlatformUnreachableError as exc: + raise _as_storage_outage(exc) from exc + + async def _asend(self, method: str, url: str, **kwargs: Any) -> Any: + try: + return await _http.arequest("storage", method, url, base_url=self.endpoint, bearer=self.token, **kwargs) + except PlatformUnreachableError as exc: + raise _as_storage_outage(exc) from exc + + # -- operations ------------------------------------------------------ + + def put(self, path: str, data: Any, content_type: str | None = None) -> AttachmentMeta: + size = self._check_size(path, data) + with _payload(data) as body: + resp = self._send( + "PUT", + self._objects_url(path), + headers=self._put_headers(content_type, size), + content=body, + ) + return self._meta_of(_http.parse_manager_envelope(resp, path=path), path) + + def get(self, path: str) -> bytes: + resp = self._send("GET", self._objects_url(path)) + if resp.status_code >= 400: + _http.parse_manager_envelope(resp, path=path) + return resp.content + + def open(self, path: str) -> IO[bytes]: + return io.BytesIO(self.get(path)) + + def stat(self, path: str) -> AttachmentMeta: + resp = self._send("GET", self._meta_url(path)) + return self._meta_of(_http.parse_manager_envelope(resp, path=path), path) + + def list(self, prefix: str = "", limit: int | None = None) -> list[AttachmentMeta]: + rows: list[AttachmentMeta] = [] + cursor: str | None = None + while True: + resp = self._send( + "GET", + f"{self.endpoint}/objects", + params=self._list_params(prefix, limit, len(rows), cursor), + ) + cursor = self._absorb_page(_http.parse_manager_envelope(resp, path=prefix), rows) + if not cursor or (limit is not None and len(rows) >= limit): + break + return rows[:limit] if limit is not None else rows + + def delete(self, path: str) -> None: + resp = self._send("DELETE", self._objects_url(path)) + # 成功是 200 `{}`;缺失是 404 `not_found`,由信封解析抛出。 + _http.parse_manager_envelope(resp, path=path) + + +class AsyncRemoteBackend(RemoteBackend): + """异步孪生:同一套 URL、同一套错误映射,只是走 `httpx.AsyncClient`。""" + + async def aput(self, path: str, data: Any, content_type: str | None = None) -> AttachmentMeta: + size = self._check_size(path, data) + with _payload(data) as body: + resp = await self._asend( + "PUT", + self._objects_url(path), + headers=self._put_headers(content_type, size), + content=body, + ) + return self._meta_of(_http.parse_manager_envelope(resp, path=path), path) + + async def aget(self, path: str) -> bytes: + resp = await self._asend("GET", self._objects_url(path)) + if resp.status_code >= 400: + _http.parse_manager_envelope(resp, path=path) + return resp.content + + async def aopen(self, path: str) -> IO[bytes]: + return io.BytesIO(await self.aget(path)) + + async def astat(self, path: str) -> AttachmentMeta: + resp = await self._asend("GET", self._meta_url(path)) + return self._meta_of(_http.parse_manager_envelope(resp, path=path), path) + + async def alist(self, prefix: str = "", limit: int | None = None) -> list[AttachmentMeta]: + rows: list[AttachmentMeta] = [] + cursor: str | None = None + while True: + resp = await self._asend( + "GET", + f"{self.endpoint}/objects", + params=self._list_params(prefix, limit, len(rows), cursor), + ) + cursor = self._absorb_page(_http.parse_manager_envelope(resp, path=prefix), rows) + if not cursor or (limit is not None and len(rows) >= limit): + break + return rows[:limit] if limit is not None else rows + + async def adelete(self, path: str) -> None: + resp = await self._asend("DELETE", self._objects_url(path)) + _http.parse_manager_envelope(resp, path=path) + + +def from_env(endpoint: str, token: str) -> RemoteBackend: + return RemoteBackend(endpoint, token, max_bytes=_env.storage_max_file_bytes()) + + +def async_from_env(endpoint: str, token: str) -> AsyncRemoteBackend: + return AsyncRemoteBackend(endpoint, token, max_bytes=_env.storage_max_file_bytes()) + + +@contextmanager +def _payload(data: Any) -> Iterator[Any]: + """httpx 的 `content=` 接受 bytes / 可迭代 / 文件对象;str 先编码成 UTF-8。 + + 本地路径由**这里**打开也由这里关闭:httpx 不会关掉调用方给的文件对象, + 交给 GC 的话,一个循环上传几千个附件的应用会先撞上文件描述符上限。 + """ + if isinstance(data, str): + yield data.encode("utf-8") + return + if isinstance(data, (bytearray, memoryview)): + yield bytes(data) + return + if isinstance(data, os.PathLike): + with open(data, "rb") as handle: + yield handle + return + yield data + + +__all__ = ("AsyncRemoteBackend", "RemoteBackend", "async_from_env", "encode_key", "from_env") diff --git a/src/bisheng-sdk/bisheng_sdk/auth.py b/src/bisheng-sdk/bisheng_sdk/auth.py new file mode 100644 index 0000000000..b55e3ddf4f --- /dev/null +++ b/src/bisheng-sdk/bisheng_sdk/auth.py @@ -0,0 +1,193 @@ +"""读平台注入的访问者身份——**唯一「写错了应用照样跑」的静默失败点**。 + +一行到位: + + from bisheng_sdk import auth + + user = auth.current_user() # 无注入身份即抛错,绝不返回 None / 匿名 + +这里没有登录页、没有签发或校验凭据的 API、没有 ``as_user`` 之类覆盖身份的参数。 +伪造注入头无效是**入口拓扑**保证的(线上应用容器仅经 app-proxy 可达且 app-proxy +剥掉伪造头,本地迷你代理同理),SDK 不验签、也不持有任何验签密钥材料——验签会把 +密钥分发进应用容器,新增一个泄漏面,而验签失败与"没有注入"在应用侧毫无区别。 + +把当前请求交给 SDK 有三种接法,指南与技能包只教这三种: + +1. **ASGI**(FastAPI / Starlette):``app.add_middleware(auth.ASGIMiddleware)`` +2. **WSGI**(Flask 等):``app.wsgi_app = auth.WSGIMiddleware(app.wsgi_app)`` +3. **显式绑定**(Streamlit、没有中间件钩子的框架、测试): + ``with auth.bind(headers): ...`` + +中间件**不拒绝**没有注入头的请求:健康探活照常进入应用,只有真的调用 +:func:`current_user` 的那一刻才抛错(AC-07 的落点在读取处)。 +""" + +from __future__ import annotations + +from collections.abc import Iterable, Iterator, Mapping +from contextlib import contextmanager +from dataclasses import dataclass + +from bisheng_sdk import _context, _headers +from bisheng_sdk.errors import PlatformIdentityMissingError + +__all__ = ("ASGIMiddleware", "Identity", "WSGIMiddleware", "bind", "current_user", "from_headers") + + +@dataclass(frozen=True) +class Identity: + """当前访问者。字段即平台注入的那一组,原样透传、不推导、不补全。 + + 两处容易踩: + + * **所有标识都是 ``str``。** ``bisheng dev`` 期的 ``user_id`` 是**服务账号 + id**,与平台 ``user`` 表不是一张表——当成 ``user.id`` 去 join 会得到错的人。 + * **``subject_kind`` 线上的字面值是 ``"human"``**(不是 ``natural_person``); + ``dev`` 期取决于 ``login`` 用的密钥:服务账号密钥 → ``"service_account"``、 + 个人访问令牌 → ``"human"``。 + + 没有 ``access_token`` 字段:访问者凭据只活在请求上下文里,供 retrieve 读取, + 不上对象、不进日志(AC-04)。 + """ + + user_id: str + user_name: str = "" + tenant_id: str = "" + dept_id: str | None = None + dept_name: str | None = None + dept_path: str | None = None + subject_kind: str = _headers.DEFAULT_SUBJECT_KIND + app_id: str = "" + request_id: str | None = None + + @property + def is_service_account(self) -> bool: + """本地 `bisheng dev` 期常见;页面角标「本地开发 · 服务账号」用它。""" + return self.subject_kind == "service_account" + + +def _parse_identity(snapshot: Mapping[str, str]) -> Identity: + user_id = (snapshot.get(_headers.HEADER_USER_ID) or "").strip() + if not user_id: + raise PlatformIdentityMissingError() + + def text(name: str) -> str: + return _headers.decode_value(snapshot.get(name) or "") + + def optional(name: str) -> str | None: + # 缺失的头是「不发」而不是「发空串」——服务账号没有部门(design 坑 2)。 + raw = snapshot.get(name) + if raw is None or raw == "": + return None + return _headers.decode_value(raw) + + return Identity( + user_id=user_id, + user_name=text(_headers.HEADER_USER_NAME), + tenant_id=(snapshot.get(_headers.HEADER_TENANT_ID) or ""), + dept_id=optional(_headers.HEADER_DEPT_ID), + dept_name=optional(_headers.HEADER_DEPT_NAME), + dept_path=optional(_headers.HEADER_DEPT_PATH), + subject_kind=(snapshot.get(_headers.HEADER_SUBJECT_KIND) or _headers.DEFAULT_SUBJECT_KIND), + app_id=(snapshot.get(_headers.HEADER_APP_ID) or ""), + request_id=(snapshot.get(_headers.HEADER_REQUEST_ID) or None), + ) + + +def current_user() -> Identity: + """当前请求的访问者。没有注入身份就抛 :class:`PlatformIdentityMissingError`。 + + 健康探活端点、后台任务、单元测试裸调用会遇到这条错误——这是**刻意的**: + 健康端点不该问"谁在访问",后台任务没有访问者。不提供"宽松模式"开关,否则 + 静默失败点就又回来了。 + """ + snapshot = _context.current() + if not snapshot: + raise PlatformIdentityMissingError() + return _parse_identity(snapshot) + + +def from_headers(headers: Iterable[tuple[str, str]] | Mapping[str, str]) -> Identity: + """纯函数版:直接从一组头解析身份,不碰上下文。 + + 用于握手回调等一次性场景。它和 :func:`current_user` 用同一套解析,所以 + "自己读头"与"用 SDK"的结果不会有两套口径。 + """ + return _parse_identity(_headers.snapshot(headers)) + + +@contextmanager +def bind(headers: Iterable[tuple[str, str]] | Mapping[str, str]) -> Iterator[None]: + """在一段代码内把这组头当作当前请求的注入头。 + + Streamlit 每次 rerun 是**新线程、空上下文**,请求头只能从 + ``st.context.headers`` 拿,因此要在脚本顶部绑定(design 坑 13)。 + """ + token = _context.bind(_headers.snapshot(headers)) + try: + yield + finally: + _context.reset(token) + + +class ASGIMiddleware: + """纯 ASGI 中间件:把本请求的注入头放进上下文,请求结束即复位。 + + ⚠️ **不要**改成继承 Starlette 的 ``BaseHTTPMiddleware``:它在独立任务里跑 + ``call_next``,ContextVar 在任务边界只复制不回传,第二个请求起 ``reset`` + 会抛 ``ValueError: Token was created in a different Context``(坑 14)。 + + WebSocket 取握手 scope 的头(AC-09);连接的授权有效期与到期断开由平台入口 + 负责,SDK 不续期。 + """ + + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope.get("type") not in ("http", "websocket"): + await self.app(scope, receive, send) + return + token = _context.bind(_headers.snapshot(scope.get("headers") or [])) + try: + await self.app(scope, receive, send) + finally: + _context.reset(token) + + +class _ResetOnClose: + """WSGI 响应体的包装:迭代完(或服务器 close 时)才复位上下文。""" + + def __init__(self, body, token) -> None: + self._body = body + self._token = token + + def __iter__(self): + return iter(self._body) + + def close(self) -> None: + try: + closer = getattr(self._body, "close", None) + if closer is not None: + closer() + finally: + _context.reset(self._token) + + +class WSGIMiddleware: + """WSGI 版:从 ``environ`` 的 ``HTTP_X_BISHENG_*`` 还原同一份快照。""" + + def __init__(self, app): + self.app = app + + def __call__(self, environ, start_response): + token = _context.bind(_headers.wsgi_snapshot(environ)) + try: + result = self.app(environ, start_response) + except BaseException: + _context.reset(token) + raise + # 上下文要活到响应**迭代完**为止,不是活到视图函数返回为止:一个流式 + # 响应(`yield` 出来的生成器)会在返回之后才真正跑业务代码,那时再调 + # `current_user()` 必须仍拿得到身份。 + return _ResetOnClose(result, token) diff --git a/src/bisheng-sdk/bisheng_sdk/errors.py b/src/bisheng-sdk/bisheng_sdk/errors.py new file mode 100644 index 0000000000..34a8d01268 --- /dev/null +++ b/src/bisheng-sdk/bisheng_sdk/errors.py @@ -0,0 +1,373 @@ +"""异常层次:一个基类 + 按「应用的下一步动作」分格的子类(design D6)。 + +三条规则撑起本文件: + +**一个异常,一个下一步。** 分格依据不是严重程度,而是应用(或读日志的人)接下来 +该做什么。`VisitorCredentialMissingError`(这条路径根本不该调)与 +`VisitorCredentialRejectedError`(让用户重新进入应用)严重程度相同、处置相反, +因此永远是两个类。 + +**未登记的错误绝不吞掉。** 平台答了一个本 SDK 不认识的业务码时, +`PlatformRefusedError` 原样带上 `code` / `message` / `details`——降级成一句 +"平台拒绝了请求"会让排查从"查这个码"变成"猜"。 + +**凭据永不出现在任何字符串里。** `__str__` / `__repr__` 一律过 :func:`redact`, +`details` 里的令牌也一并掩掉(AC-04)。 +""" + +from __future__ import annotations + +import re +from typing import Any + +__all__ = ( + "AppCredentialMissingError", + "AttachmentNotFoundError", + "AttachmentTooLargeError", + "BishengSdkError", + "CapabilityNotDeclaredError", + "CapabilityRevokedError", + "InvalidAttachmentPathError", + "PermissionEvaluationError", + "PlatformIdentityMissingError", + "PlatformRefusedError", + "PlatformTooOldError", + "PlatformUnreachableError", + "ScopeMissingError", + "SdkIncompatibleError", + "StorageHandleMissingError", + "StorageHandleRejectedError", + "StorageUnavailableError", + "TargetUnreachableError", + "VisitorCredentialMissingError", + "VisitorCredentialRejectedError", + "redact", +) + +# 掩码规则与 CLI `bisheng_cli.output._MASK_PATTERNS` 同源:两个平台凭据前缀 +# (`bs-sak-` 服务账号密钥 / `bs-pat-` 个人访问令牌)、`Bearer <值>`、以及裸的 +# JWT(app-proxy 注入的 OBO 令牌就是 `eyJ...` 开头的 JWT)。尾部贪婪是刻意的: +# 半截密钥仍是泄漏的密钥。 +_MASK_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = ( + (re.compile(r"(bs-(?:sak|pat)-)[A-Za-z0-9_\-]+"), r"\1****"), + (re.compile(r"(?i)\bbearer\s+[A-Za-z0-9_\-.]{8,}"), "Bearer ****"), + (re.compile(r"\beyJ[A-Za-z0-9_-]{6,}(?:\.[A-Za-z0-9_-]+){0,2}"), "****"), + # `bisheng dev` 的本地访问凭据句柄(`bsdev..`,F053 devproxy)。 + (re.compile(r"\bbsdev\.[A-Za-z0-9_\-.=]+"), "bsdev.****"), +) + + +def redact(text: str) -> str: + """把任何凭据形态的子串换成掩码;用于一切离开本进程的字符串。""" + result = str(text) + for pattern, replacement in _MASK_PATTERNS: + result = pattern.sub(replacement, result) + return result + + +def _redact_value(value: Any) -> Any: + """递归掩码:`details` 常常是服务端原样回传的字典,里面可能带令牌回显。""" + if isinstance(value, str): + return redact(value) + if isinstance(value, dict): + return {key: _redact_value(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return type(value)(_redact_value(item) for item in value) + return value + + +class BishengSdkError(Exception): + """所有 SDK 异常的基类。 + + `next_step` 不是可选的修辞——DEV-04 验收 7 的判据是「缺什么、怎么改」, + 一个只有 message 的异常把这件事留给了读堆栈的人。 + """ + + def __init__( + self, + message: str, + next_step: str, + *, + code: int | None = None, + details: Any = None, + ) -> None: + self.message = redact(str(message)) + self.next_step = redact(str(next_step)) + self.code = code + self.details = _redact_value(details) + super().__init__(self.message) + + def __str__(self) -> str: + return redact(f"{self.message}(下一步:{self.next_step})") + + def __repr__(self) -> str: + return f"{type(self).__name__}({self.__str__()!r})" + + +# --------------------------------------------------------------------------- +# auth / retrieve —— 身份与访问者凭据 +# --------------------------------------------------------------------------- + + +class PlatformIdentityMissingError(BishengSdkError): + """上下文里没有平台注入的身份(AC-07)。 + + 刻意不返回 None / 匿名身份:那会让应用写出 `if user is None: 匿名处理`, + 把静默失败点从「自建登录页」挪到「匿名兜底」,性质不变。 + """ + + def __init__(self, message: str | None = None, next_step: str | None = None) -> None: + super().__init__( + message or "未取得平台注入的访问者身份", + next_step + or ( + "这条路径不应调用 auth(健康探活、后台任务、单元测试)," + "或请求未经平台入口转发;本地开发请经 `bisheng dev` 启动应用" + ), + ) + + +class VisitorCredentialMissingError(BishengSdkError): + """retrieve 时上下文里没有访问者凭据(含根本没有请求上下文,AC-15)。""" + + def __init__(self, message: str | None = None, next_step: str | None = None) -> None: + super().__init__( + message or "当前请求上下文中没有平台注入的访问者凭据,检索不会以任何其它身份发起", + next_step + or ( + "经平台入口访问应用;后台任务没有访问者、按设计不能检索;" + "线上另请管理员确认 `app_runtime.obo_secret` 已配置" + ), + ) + + +class AppCredentialMissingError(BishengSdkError): + """应用自身的运行期凭据(`BISHENG_APP_TOKEN`)未注入。 + + 托管期由平台在创建容器前注入(F055 能力总线);`bisheng dev` 本地期今天 + 不注入它,因此本地 retrieve 得到的是这条明确错误,而不是一次注定被拒的请求。 + """ + + def __init__(self, message: str | None = None, next_step: str | None = None) -> None: + super().__init__( + message or "应用运行期凭据未注入,无法向平台证明「这次调用来自哪个应用」", + next_step + or ( + "托管运行期由平台注入,请确认实例是经平台发布上线的;" + "本地 `bisheng dev` 期平台尚未注入该凭据,检索请在发布后用真实账号验证" + ), + ) + + +class VisitorCredentialRejectedError(BishengSdkError): + """平台拒绝了访问者凭据:过期、失效、或无法确立访问用户。""" + + def __init__(self, message: str | None = None, next_step: str | None = None, **kwargs: Any) -> None: + super().__init__( + message or "平台拒绝了本次调用的访问者凭据", + next_step + or ( + "让用户刷新页面重新进入应用;若应用刚下线或凭据已过期,重新上线后再试;仍被拒请确认平台已受理访问者凭据" + ), + **kwargs, + ) + + +class ScopeMissingError(BishengSdkError): + """凭据缺某个权限位(服务端 26003,`data.required` 是**单个字符串**)。""" + + def __init__(self, required: str = "", **kwargs: Any) -> None: + self.required = required + super().__init__( + f"调用所用的凭据缺少权限位「{required}」" if required else "调用所用的凭据缺少必需的权限位", + f"请管理员给该密钥勾上「{required}」后重试" if required else "请管理员给该密钥补上所缺的权限位后重试", + **kwargs, + ) + + +class TargetUnreachableError(BishengSdkError): + """显式指定的知识库中有不可及的(缺失 / 未授予 / 类型不支持 / 白名单外)。 + + 整个请求被拒,而不是静默剔除后返回缩水结果——存在性不泄露,缩水结果也不该 + 被当成"查过了"。 + """ + + def __init__(self, ids: Any = None, **kwargs: Any) -> None: + self.ids = list(ids or []) + shown = "、".join(str(one) for one in self.ids) + super().__init__( + f"目标知识库不可及:{shown}" if shown else "指定的目标知识库不可及", + "去掉不可及的知识库 id 后重试;托管运行期请确认它在应用的能力声明白名单内", + **kwargs, + ) + + +class CapabilityRevokedError(BishengSdkError): + """应用声明过的能力已被平台收回(服务端 16273 / 26322)。""" + + def __init__(self, capability: str | None = None, reason: str = "revoked", **kwargs: Any) -> None: + self.capability = capability + self.reason = reason + super().__init__( + f"能力「{capability}」已被收回(原因:{reason})" + if capability + else f"应用声明的能力已被收回(原因:{reason})", + "请应用负责人重新声明该能力并发布,或联系管理员确认它为何消失", + **kwargs, + ) + + +class CapabilityNotDeclaredError(BishengSdkError): + """应用请求了它从未声明的能力(服务端 16274)。""" + + def __init__(self, capability: str | None = None, **kwargs: Any) -> None: + self.capability = capability + super().__init__( + f"能力「{capability}」不在应用的能力声明中" if capability else "该能力不在应用的能力声明中", + "在 `bisheng-app.yaml` 中声明后重新发布;应用已下线时同样会得到这条错误", + **kwargs, + ) + + +class PermissionEvaluationError(BishengSdkError): + """权限引擎不可用 / 评估失败(服务端 26030 等)。 + + **不得**改小范围重试:那会把"判不了"变成"判成更少",正是 GOV-05 fail-closed + 要挡住的事。 + """ + + def __init__(self, message: str | None = None, next_step: str | None = None, **kwargs: Any) -> None: + super().__init__( + message or "平台的权限评估暂时不可用,本次检索未执行", + next_step or "稍后重试;不要改小检索范围重试——那会得到一个不完整但看起来成功的结果", + **kwargs, + ) + + +class PlatformRefusedError(BishengSdkError): + """平台以一个本 SDK 未登记的业务码拒绝了请求——原样呈现,永不吞掉。""" + + def __init__(self, message: str | None = None, *, code: int | None = None, details: Any = None) -> None: + super().__init__( + message or "平台拒绝了本次请求", + "按平台返回的说明处置;若这是一个应当被 SDK 识别的错误,请反馈该错误码", + code=code, + details=details, + ) + + +class PlatformUnreachableError(BishengSdkError): + """连不上平台:网络、超时、无信封的 5xx,或平台地址根本没注入。""" + + def __init__(self, message: str | None = None, next_step: str | None = None, **kwargs: Any) -> None: + super().__init__( + message or "无法连接到平台", + next_step or "检查平台地址与网络连通性后重试", + **kwargs, + ) + + +class SdkIncompatibleError(BishengSdkError): + """本 SDK 版本低于该平台声明的最低兼容版本(AC-03)。""" + + def __init__(self, sdk_version: str, min_compatible: str, platform_version: str | None = None) -> None: + self.sdk_version = sdk_version + self.min_compatible = min_compatible + self.platform_version = platform_version + platform = f"(平台版本 {platform_version})" if platform_version else "" + super().__init__( + f"本地 bisheng-sdk 版本 {sdk_version} 低于该平台要求的最低兼容版本 {min_compatible}{platform}", + "从当前平台重新获取 SDK(`/api/v1/dev-toolkit/simple/` 索引)并重新发布应用", + ) + + +class PlatformTooOldError(BishengSdkError): + """平台没有 SDK 版本信息:版本过老,或开放能力层未部署。""" + + def __init__(self, message: str | None = None, next_step: str | None = None) -> None: + super().__init__( + message or "该平台未提供 SDK 版本信息", + next_step or "升级平台,或确认平台已部署开放能力层(`open_platform` 开关)", + ) + + +# --------------------------------------------------------------------------- +# storage —— 附件空间 +# --------------------------------------------------------------------------- + + +class StorageHandleMissingError(BishengSdkError): + """取不到应用的附件存储句柄:未注入、不完整、或两种形态同时存在。""" + + def __init__(self, message: str | None = None, next_step: str | None = None) -> None: + super().__init__( + message or "未取得应用的附件存储句柄", + next_step or ("托管运行期由平台注入,请确认实例是经平台发布上线的;本地开发请经 `bisheng dev` 启动应用"), + ) + + +class StorageHandleRejectedError(BishengSdkError): + """附件服务拒绝了句柄(令牌不属本应用 / 应用已删除 / 未带凭据,恒 401)。""" + + def __init__(self, reason: str = "", **kwargs: Any) -> None: + self.reason = redact(str(reason)) + super().__init__( + f"附件存储拒绝了本应用的句柄:{self.reason}" if reason else "附件存储拒绝了本应用的句柄", + "重新上线应用以取得新的句柄;仍被拒请联系平台管理员", + **kwargs, + ) + + +class StorageUnavailableError(BishengSdkError): + """附件服务不可用(对象存储未配置 / 不可达 / 连接失败)。""" + + def __init__(self, message: str | None = None, next_step: str | None = None, **kwargs: Any) -> None: + super().__init__( + message or "附件存储暂时不可用", + next_step + or ( + "稍后重试;请管理员查看运行环境状态中的 `attachment_storage` 自检项" + "(`GET /v1/runtime/status` 的 preflight)" + ), + **kwargs, + ) + + +class AttachmentNotFoundError(BishengSdkError): + """附件不存在。删除一个不存在的附件同样是这条错误,不是静默成功。""" + + def __init__(self, path: str = "", **kwargs: Any) -> None: + self.path = path + super().__init__( + f"附件「{path}」不存在" if path else "附件不存在", + "确认路径与大小写;用 `storage.list()` 查看当前应用附件空间内的文件", + **kwargs, + ) + + +class AttachmentTooLargeError(BishengSdkError): + """超过部署配置的单文件上限——拒绝,绝不截断写入。""" + + def __init__(self, path: str = "", limit_bytes: int | None = None, **kwargs: Any) -> None: + self.path = path + self.limit_bytes = limit_bytes + limit = f"(单文件上限 {limit_bytes // (1024 * 1024)} MB)" if limit_bytes else "" + super().__init__( + f"附件「{path}」超过单文件上限{limit}" if path else f"附件超过单文件上限{limit}", + "缩小文件或分成多个附件上传;上限由平台部署配置决定", + **kwargs, + ) + + +class InvalidAttachmentPathError(BishengSdkError): + """路径不是合法的应用内相对路径——拒绝,而不是规范化后放行。""" + + def __init__(self, path: str = "", reason: str = "", **kwargs: Any) -> None: + self.path = path + self.reason = reason + detail = f"({reason})" if reason else "" + super().__init__( + f"附件路径「{path}」不合法{detail}", + "用应用内相对路径,如 `报告/2026.pdf`;不要用绝对路径、`..`、反斜杠或 `apps/` 前缀", + **kwargs, + ) diff --git a/src/bisheng-sdk/bisheng_sdk/retrieve.py b/src/bisheng-sdk/bisheng_sdk/retrieve.py new file mode 100644 index 0000000000..5d2e0bd73d --- /dev/null +++ b/src/bisheng-sdk/bisheng_sdk/retrieve.py @@ -0,0 +1,184 @@ +"""以**当前访问者**的身份检索知识——选错身份就是越权,这里把选择固定下来。 + +一行到位: + + from bisheng_sdk import retrieve + + result = retrieve.search("年假怎么休", knowledge_base_ids=[12]) + +**两把凭据,缺一不可**(F055 已落地的托管运行契约): + +* ``Authorization: Bearer `` —— 应用自身的运行期凭据, + 回答"这次调用来自哪个应用",平台据此取出该应用**当前生效的能力声明白名单**; +* ``X-BiSheng-Access-Token: <本请求注入的访问者凭据>`` —— 回答"这次调用是为谁做 + 的",平台据此确立访问用户。 + +两者都在托管容器里,但角色不能互换:只带应用凭据的检索会被平台以「无访问用户」 +拒绝(**没有 owner 可以兜底**),只带访问者凭据则不认识这个应用。访问者凭据只从 +**请求上下文**取,绝不从进程级环境变量取——否则后台任务会"顺手"拿到一把凭据, +越权路径就在一个默认参数之外。 + +没有 ``as_user``、没有任何指定他人身份的参数:那是 `--as` 的代码翻版。 + +可及范围由平台决定,SDK 不参与:托管期 = 应用当前生效声明的白名单 ∩ 访问用户 +可见范围(文件级);本地 `bisheng dev` 期 = 服务账号被显式授予的范围(无白名单)。 +于是开发期只会"本地看得少",不会"本地能跑、线上越权"。 +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass, field +from typing import Any + +from bisheng_sdk import _compat, _context, _env, _http +from bisheng_sdk.errors import AppCredentialMissingError, VisitorCredentialMissingError + +__all__ = ("Chunk", "KnowledgeBaseFilter", "RetrieveResult", "asearch", "search") + +#: F052 统一检索门面在开放面上的唯一路径(AC-11 / F052 AC-26)。 +RETRIEVE_PATH = "/api/v2/filelib/retrieve" + +#: 访问者凭据以头的形式随调用一起送出,头名与 app-proxy 注入给应用的那一个相同 +#: (`open_api/domain/services/model_range_policy.py::ACCESS_TOKEN_HEADER`)。 +ACCESS_TOKEN_HEADER = "X-BiSheng-Access-Token" + + +@dataclass(frozen=True) +class Chunk: + """一个可引用溯源的片段。字段与门面出参一一对应,SDK 不增删。""" + + content: str + knowledge_id: int + document_id: int + document_name: str + chunk_index: int + document_update_time: str = "" + + +@dataclass(frozen=True) +class RetrieveResult: + chunks: list[Chunk] = field(default_factory=list) + total: int = 0 + + +@dataclass(frozen=True) +class KnowledgeBaseFilter: + """按知识库的标签过滤。 + + ``tag_match_mode`` 的合法值是 ``"ANY"``(服务端 `RetrieveFilters` 的字面量, + 大写);``"ALL"`` 服务端尚未实现、会答 400——SDK 不替它翻译成 ``ANY``, + 那会返回**比你要的更宽**的结果集而无从察觉。 + """ + + knowledge_base_id: int + tags: Sequence[str] = () + tag_match_mode: str = "ANY" + + +def _credentials() -> tuple[str, str]: + """(应用运行期凭据, 访问者凭据)。任一缺失都是明确、可区分的错误。""" + visitor = _context.access_token() + if not visitor: + raise VisitorCredentialMissingError() + app = _env.app_token() + if not app: + raise AppCredentialMissingError() + return app, visitor + + +def _body( + query: str, + knowledge_base_ids: Sequence[int] | None, + top_k: int, + max_content: int, + filters: Sequence[KnowledgeBaseFilter] | None, +) -> dict[str, Any]: + body: dict[str, Any] = {"query": query, "top_k": top_k, "max_content": max_content} + if knowledge_base_ids is not None: + # 服务端 schema 是 `extra="forbid"`,送 `null` 会 422;不指定就**省略该键**。 + body["knowledge_base_ids"] = [int(one) for one in knowledge_base_ids] + if filters: + body["filters"] = { + "knowledge_base_filters": [ + { + "knowledge_base_id": int(one.knowledge_base_id), + "tags": list(one.tags), + "tag_match_mode": one.tag_match_mode, + } + for one in filters + ] + } + return body + + +def _result(data: Any) -> RetrieveResult: + payload = data if isinstance(data, dict) else {} + rows = payload.get("chunks") or [] + chunks = [ + Chunk( + content=str(row.get("content") or ""), + knowledge_id=int(row.get("knowledge_id") or 0), + document_id=int(row.get("document_id") or 0), + document_name=str(row.get("document_name") or ""), + chunk_index=int(row.get("chunk_index") or 0), + document_update_time=str(row.get("document_update_time") or ""), + ) + for row in rows + if isinstance(row, dict) + ] + total = payload.get("total") + return RetrieveResult(chunks=chunks, total=int(total) if isinstance(total, int) else len(chunks)) + + +def search( + query: str, + *, + knowledge_base_ids: Sequence[int] | None = None, + top_k: int = 10, + max_content: int = 15000, + filters: Sequence[KnowledgeBaseFilter] | None = None, +) -> RetrieveResult: + """检索,以当前访问者的身份。 + + 失败一律抛异常、彼此可区分(没有访问者凭据 / 凭据被拒 / 缺权限位 / 目标不可及 + / 能力已收回 / 权限评估失败 / 版本不兼容 / 平台不可达),**绝不**返回空集 + 当作成功,也绝不按白名单全量放行。 + """ + app_token, visitor_token = _credentials() + base = _env.platform_api_base() + _compat.ensure_compatible(base) + resp = _http.request( + "retrieve", + "POST", + RETRIEVE_PATH, + base_url=base, + bearer=app_token, + headers={ACCESS_TOKEN_HEADER: visitor_token}, + json=_body(query, knowledge_base_ids, top_k, max_content, filters), + ) + return _result(_http.parse_envelope(resp)) + + +async def asearch( + query: str, + *, + knowledge_base_ids: Sequence[int] | None = None, + top_k: int = 10, + max_content: int = 15000, + filters: Sequence[KnowledgeBaseFilter] | None = None, +) -> RetrieveResult: + """:func:`search` 的异步孪生:同一套入参、同一套错误、同一条码路。""" + app_token, visitor_token = _credentials() + base = _env.platform_api_base() + await _compat.aensure_compatible(base) + resp = await _http.arequest( + "retrieve", + "POST", + RETRIEVE_PATH, + base_url=base, + bearer=app_token, + headers={ACCESS_TOKEN_HEADER: visitor_token}, + json=_body(query, knowledge_base_ids, top_k, max_content, filters), + ) + return _result(_http.parse_envelope(resp)) diff --git a/src/bisheng-sdk/bisheng_sdk/storage.py b/src/bisheng-sdk/bisheng_sdk/storage.py new file mode 100644 index 0000000000..9377353801 --- /dev/null +++ b/src/bisheng-sdk/bisheng_sdk/storage.py @@ -0,0 +1,166 @@ +"""当前应用的附件空间 —— 上传 / 下载 / 列举 / 删除,没有 bucket,没有对象键。 + +一行到位: + + from bisheng_sdk import storage + + meta = storage.put("报告/2026.pdf", data) + blob = storage.get("报告/2026.pdf") + +应用只见**应用内相对路径**与元信息。底层是哪个对象存储、桶叫什么、键怎么拼、 +凭据是什么,一律不出现在这个模块的任何返回值与异常里(AC-21)。跨应用、路径穿越、 +附件空间之外的写法一律明确拒绝,而不是静默映射到别处。 + +**同一套 API 两个后端**,按注入的句柄分辨(每次调用重算,`bisheng dev` 重启即 +换): + +* ``BISHENG_APP_STORAGE_ENDPOINT``(+ ``_TOKEN``)→ 平台存储(托管期) +* ``BISHENG_APP_STORAGE_DIR`` → 项目本地附件目录(`bisheng dev` 期) +* **两个都有** → 报"句柄不唯一"。环境配错时宁可停下,也不猜一个后端——猜错的 + 代价是附件默默落在另一个地方。 + +没有 ``clear()`` / ``delete_prefix()``:清空整个附件空间没有业务场景,误用即毁 +数据。没有任何返回 URL 的函数:附件不进公共可读存储、也不提供绕过应用的直链, +要让用户下载就由应用自己吐流。 +""" + +from __future__ import annotations + +import asyncio +from typing import IO, Any + +from bisheng_sdk import _env, _paths, _storage_local, _storage_remote +from bisheng_sdk._attachment import AttachmentMeta +from bisheng_sdk.errors import StorageHandleMissingError + +__all__ = ( + "AttachmentMeta", + "adelete", + "aget", + "alist", + "aopen", + "aput", + "astat", + "delete", + "get", + "list", + "open", + "put", + "stat", +) + +_BUILTIN_LIST = list + + +def _backend(*, is_async: bool = False): + """解析句柄。每次调用重算、不缓存。""" + endpoint = _env.storage_endpoint() + directory = _env.storage_dir() + + if endpoint and directory: + raise StorageHandleMissingError( + "同时注入了平台存储句柄与本地附件目录句柄,无法判断附件该落在哪里", + f"只保留其中一个:托管运行期是 {_env.ENV_STORAGE_ENDPOINT},本地开发期是 {_env.ENV_STORAGE_DIR}", + ) + if endpoint: + token = _env.storage_token() + if not token: + raise StorageHandleMissingError( + f"附件存储句柄不完整:有 {_env.ENV_STORAGE_ENDPOINT} 但没有 {_env.ENV_STORAGE_TOKEN}", + "重新上线应用以取得完整句柄;不要手工改这两个平台保留环境变量", + ) + if is_async: + return _storage_remote.async_from_env(endpoint, token) + return _storage_remote.from_env(endpoint, token) + if directory: + return _storage_local.LocalDirBackend(directory, max_bytes=_env.storage_max_file_bytes()) + raise StorageHandleMissingError() + + +# --- 同步面 --------------------------------------------------------------- + + +def put(path: str, data: Any, *, content_type: str | None = None) -> AttachmentMeta: + """写入一个附件。超过单文件上限 → 明确错误,绝不截断写入。""" + validated = _paths.validate(path) + return _backend().put(validated, data, content_type) + + +def get(path: str) -> bytes: + """整读一个附件。""" + return _backend().get(_paths.validate(path)) + + +def open(path: str) -> IO[bytes]: + """流式读取一个附件(大文件用)。""" + return _backend().open(_paths.validate(path)) + + +def stat(path: str) -> AttachmentMeta: + """只读元信息。""" + return _backend().stat(_paths.validate(path)) + + +def list(prefix: str = "", *, limit: int | None = None) -> _BUILTIN_LIST[AttachmentMeta]: + """列举附件(按路径升序)。``limit`` 省略时取完所有页。""" + return _backend().list(_paths.validate_prefix(prefix), limit) + + +def delete(path: str) -> None: + """删除一个附件。附件不存在是错误,不是静默成功。""" + _backend().delete(_paths.validate(path)) + + +# --- 异步孪生 ------------------------------------------------------------- +# +# 远端后端有真正的异步实现;本地目录后端用 `asyncio.to_thread` 包一层——磁盘 I/O +# 在事件循环里阻塞的时间虽短,但"本地能跑、线上卡住"是最难查的一类差异。 + + +async def aput(path: str, data: Any, *, content_type: str | None = None) -> AttachmentMeta: + validated = _paths.validate(path) + backend = _backend(is_async=True) + if isinstance(backend, _storage_remote.AsyncRemoteBackend): + return await backend.aput(validated, data, content_type) + return await asyncio.to_thread(backend.put, validated, data, content_type) + + +async def aget(path: str) -> bytes: + validated = _paths.validate(path) + backend = _backend(is_async=True) + if isinstance(backend, _storage_remote.AsyncRemoteBackend): + return await backend.aget(validated) + return await asyncio.to_thread(backend.get, validated) + + +async def aopen(path: str) -> IO[bytes]: + validated = _paths.validate(path) + backend = _backend(is_async=True) + if isinstance(backend, _storage_remote.AsyncRemoteBackend): + return await backend.aopen(validated) + return await asyncio.to_thread(backend.open, validated) + + +async def astat(path: str) -> AttachmentMeta: + validated = _paths.validate(path) + backend = _backend(is_async=True) + if isinstance(backend, _storage_remote.AsyncRemoteBackend): + return await backend.astat(validated) + return await asyncio.to_thread(backend.stat, validated) + + +async def alist(prefix: str = "", *, limit: int | None = None) -> _BUILTIN_LIST[AttachmentMeta]: + validated = _paths.validate_prefix(prefix) + backend = _backend(is_async=True) + if isinstance(backend, _storage_remote.AsyncRemoteBackend): + return await backend.alist(validated, limit) + return await asyncio.to_thread(backend.list, validated, limit) + + +async def adelete(path: str) -> None: + validated = _paths.validate(path) + backend = _backend(is_async=True) + if isinstance(backend, _storage_remote.AsyncRemoteBackend): + await backend.adelete(validated) + return + await asyncio.to_thread(backend.delete, validated) diff --git a/src/bisheng-sdk/pyproject.toml b/src/bisheng-sdk/pyproject.toml new file mode 100644 index 0000000000..36a6df0922 --- /dev/null +++ b/src/bisheng-sdk/pyproject.toml @@ -0,0 +1,76 @@ +[project] +name = "bisheng-sdk" +version = "0.1.1" +description = "BiSheng application SDK — auth / retrieve / storage for hosted apps, zero coupling to the backend package" +requires-python = ">=3.11" +license = { text = "Apache-2.0" } +# Dependency budget is ONE entry (design D1 / CON-1). This package is installed +# into every hosted application's container by `pip install` inside a customer's +# air-gapped network; every extra dependency is one more way that install fails. +# +# The entry carries an UPPER bound on purpose. On the wheel distribution path +# `uv.lock` does not participate in resolution at all — the metadata below is +# the only thing that can constrain what pip picks. Widening the ceiling is a +# deliberate decision (re-run the wheel-install smoke first), never a drive-by +# bump. +dependencies = [ + "httpx>=0.27,<1.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.23", + "ruff>=0.9.0", +] + +# No console script: this is a library an application imports, not a tool a +# developer runs (the CLI is `src/bisheng-cli/`). + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +# The directory is `src/bisheng-sdk/` (hyphen) while the import package is +# `bisheng_sdk` (underscore). hatchling cannot infer that mapping, and without +# this line `uv build` happily produces an EMPTY wheel that installs fine and +# fails at `import bisheng_sdk` (design pit 12). +[tool.hatch.build.targets.wheel] +packages = ["bisheng_sdk"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +pythonpath = ["."] +asyncio_mode = "auto" +markers = [ + "network: needs a real platform — skipped by default, runs only in the 114 manual verification (set BISHENG_SDK_RUN_NETWORK_TESTS=1)", +] + +[tool.ruff] +target-version = "py311" +line-length = 120 +exclude = [".venv", "__pycache__", "*.egg-info"] + +[tool.ruff.lint] +select = ["E", "W", "F", "I", "B", "C4", "UP", "RUF"] +ignore = [ + "E501", # line length — handled by the formatter + "B904", # raise ... from None is too strict for existing style + "UP007", # Optional[X] over X | None in runtime annotations + "RUF012", # mutable class attributes + # Every human-facing string this SDK raises is Chinese product copy with + # full-width punctuation. RUF001/002/003 would "fix" 「」(), into ASCII and + # silently rewrite the copy the product team signed off on. + "RUF001", + "RUF002", + "RUF003", +] + +[tool.ruff.lint.isort] +known-first-party = ["bisheng_sdk"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false diff --git a/src/bisheng-sdk/tests/conftest.py b/src/bisheng-sdk/tests/conftest.py new file mode 100644 index 0000000000..b24d7c9467 --- /dev/null +++ b/src/bisheng-sdk/tests/conftest.py @@ -0,0 +1,119 @@ +"""Shared fixtures. Four rules this file enforces for the whole package. + +1. **No test ever touches the network.** `no_network` swaps httpx's real + transport for a sentinel that raises, so a forgotten mock fails at the call + site instead of quietly reaching out to whatever host the test named. +2. **No test ever sees the developer's own environment.** Every `BISHENG_*` + variable is cleared: a developer with `BISHENG_APP_STORAGE_DIR` exported + would otherwise get a different verdict from the same test than CI does. +3. **No test leaks request context into the next one.** The identity lives in a + `ContextVar`; one test that forgets to reset would hand its user to the next. +4. **No long credential literal is ever written into a `.py` file** — + `scripts/arch-guard.sh` RULE-7 greps for exactly that, and a permanent stream + of false warnings is how a real leak gets scrolled past. Fakes are assembled + by concatenation in `tests/helpers/platform_mock.py`. +""" + +from __future__ import annotations + +import os + +import httpx +import pytest + +from bisheng_sdk import _compat, _context, _http + +_PROXY_ENV = ( + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + "no_proxy", +) + + +def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: + """Skip `@pytest.mark.network` unless a real platform was opted into.""" + if os.environ.get("BISHENG_SDK_RUN_NETWORK_TESTS") == "1": + return + skip = pytest.mark.skip( + reason="needs a real platform — 114 manual verification only (set BISHENG_SDK_RUN_NETWORK_TESTS=1)" + ) + for item in items: + if "network" in item.keywords: + item.add_marker(skip) + + +@pytest.fixture(autouse=True) +def no_network(monkeypatch: pytest.MonkeyPatch) -> None: + for name in _PROXY_ENV: + monkeypatch.delenv(name, raising=False) + + def _refuse(self, request): + raise AssertionError(f"unmocked network call: {request.method} {request.url}") + + monkeypatch.setattr(httpx.HTTPTransport, "handle_request", _refuse, raising=True) + monkeypatch.setattr(httpx.AsyncHTTPTransport, "handle_async_request", _refuse, raising=True) + + +@pytest.fixture(autouse=True) +def clean_env(monkeypatch: pytest.MonkeyPatch) -> None: + """A developer machine's own `BISHENG_*` must not decide a test's verdict.""" + for name in list(os.environ): + if name.startswith("BISHENG_"): + monkeypatch.delenv(name, raising=False) + + +@pytest.fixture(autouse=True) +def reset_state() -> None: + """Request context, client pool and the version probe cache are per-test.""" + _http.reset_clients() + _compat.reset() + _context.clear() + yield + _http.reset_clients() + _compat.reset() + _context.clear() + + +@pytest.fixture +def platform_env(monkeypatch: pytest.MonkeyPatch) -> str: + from tests.helpers.platform_mock import PLATFORM_BASE + + monkeypatch.setenv("BISHENG_PLATFORM_API_BASE", PLATFORM_BASE) + return PLATFORM_BASE + + +@pytest.fixture +def app_token_env(monkeypatch: pytest.MonkeyPatch) -> str: + """The application's own runtime credential, as F055 injects it.""" + from tests.helpers.platform_mock import FAKE_APP_TOKEN + + monkeypatch.setenv("BISHENG_APP_TOKEN", FAKE_APP_TOKEN) + return FAKE_APP_TOKEN + + +@pytest.fixture +def mock_transport(monkeypatch: pytest.MonkeyPatch): + """Install a `httpx.MockTransport` behind the SDK's client pool. + + Patching the pool factories rather than passing a transport parameter keeps + the production code free of a test-only hook — and still exercises the real + `request` / `parse_envelope` path. + """ + + def install(transport): + def _client(base_url: str, kind: str = "retrieve") -> httpx.Client: + return httpx.Client(base_url=base_url, transport=transport.sync, timeout=_http.timeout_for(kind)) + + def _aclient(base_url: str, kind: str = "retrieve") -> httpx.AsyncClient: + return httpx.AsyncClient(base_url=base_url, transport=transport.asgi, timeout=_http.timeout_for(kind)) + + monkeypatch.setattr(_http, "client", _client) + monkeypatch.setattr(_http, "aclient", _aclient) + return transport + + return install diff --git a/src/bisheng-sdk/tests/helpers/__init__.py b/src/bisheng-sdk/tests/helpers/__init__.py new file mode 100644 index 0000000000..5f68c874b1 --- /dev/null +++ b/src/bisheng-sdk/tests/helpers/__init__.py @@ -0,0 +1 @@ +"""Test helpers for the bisheng-sdk suite.""" diff --git a/src/bisheng-sdk/tests/helpers/platform_mock.py b/src/bisheng-sdk/tests/helpers/platform_mock.py new file mode 100644 index 0000000000..c12a829e6f --- /dev/null +++ b/src/bisheng-sdk/tests/helpers/platform_mock.py @@ -0,0 +1,188 @@ +"""Executable snapshot of the contracts this SDK consumes. + +This module is deliberately dumb. Every payload is shaped exactly like the one +the platform actually returns, **including the parts that look wrong**: + +* ``/api/v1`` answers HTTP 200 with the business code inside the envelope, while + ``/api/v2`` puts a real status on the status line *and* keeps the envelope. +* the 260-segment error payload is ``data = {"exception": , **kwargs}``, so + ``26003`` carries ``data.required`` as a **single string**, never a list. +* runtime-manager's attachment API uses a completely different envelope — + ``{"detail": {"code": "", "message": …}}`` — and answers + ``200 {}`` to a successful DELETE. + +"Tidying" any of these into a more sensible shape would mean the SDK is tested +against a server that does not exist. If a shape looks wrong, fix the server or +the design note, not this file. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any +from urllib.parse import quote + +import httpx + +# Assembled rather than written out so `scripts/arch-guard.sh` RULE-7 never +# matches a long credential literal in this repo's Python (design pit 22). +FAKE_APP_TOKEN = "bs-sak-" + "a" * 24 +FAKE_OBO = "eyJ" + "b" * 40 +FAKE_DEV_HANDLE = "bsdev." + "c" * 24 + "." + "d" * 16 +FAKE_STORAGE_TOKEN = "st-" + "e" * 30 + +PLATFORM_BASE = "http://platform.test" +STORAGE_ENDPOINT = "http://runtime-manager.test:8091/v1/apps/app-1/storage" + +VERSIONS_PATH = "/api/v1/dev-toolkit/versions" +RETRIEVE_PATH = "/api/v2/filelib/retrieve" + + +def encode_header_value(value: str) -> str: + """app-proxy's `encode_header_value`: ASCII verbatim, anything else quoted.""" + if value.isascii(): + return value + return quote(value, safe="/") + + +def hosted_headers( + *, + user_id: str = "42", + user_name: str = "张三", + tenant_id: str = "1", + dept: bool = True, + subject_kind: str = "human", + app_id: str = "app-1", + token: str | None = FAKE_OBO, + request_id: str = "req-1", +) -> list[tuple[str, str]]: + """The ten headers app-proxy injects, in wire order. + + ``dept=False`` **omits** the three department headers rather than sending + them empty — that is what the injector does for a subject with no + department, and an app that cannot tell the two apart is the bug this + fixture exists to catch. + """ + headers: list[tuple[str, str]] = [ + ("X-BiSheng-User-Id", user_id), + ("X-BiSheng-User-Name", encode_header_value(user_name)), + ("X-BiSheng-Tenant-Id", tenant_id), + ] + if dept: + headers += [ + ("X-BiSheng-Dept-Id", "7"), + ("X-BiSheng-Dept-Name", encode_header_value("财务部")), + ("X-BiSheng-Dept-Path", encode_header_value("集团/财务部")), + ] + headers += [ + ("X-BiSheng-Subject-Kind", subject_kind), + ("X-BiSheng-App-Id", app_id), + ] + if token: + headers.append(("X-BiSheng-Access-Token", token)) + headers.append(("X-BiSheng-Request-Id", request_id)) + return headers + + +def dev_headers(*, token: str | None = FAKE_DEV_HANDLE) -> list[tuple[str, str]]: + """What `bisheng dev`'s mini-proxy injects for a service-account login.""" + return hosted_headers( + user_id="9001", + user_name="dev-service-account", + dept=False, + subject_kind="service_account", + token=token, + ) + + +# --- envelopes ------------------------------------------------------------ + + +def v1_envelope(data: Any) -> dict[str, Any]: + return {"status_code": 200, "status_message": "SUCCESS", "data": data} + + +def v2_error_body(code: int, message: str, data: Any = None) -> dict[str, Any]: + return { + "status_code": code, + "status_message": message, + "data": data if data is not None else {"exception": message}, + } + + +def versions_payload(version: str | None = "0.1.0", min_compatible: str | None = "0.1.0") -> dict[str, Any]: + sdk = None if version is None else {"version": version, "min_compatible": min_compatible} + return { + "platform": {"version": "3.0.0"}, + "cli": {"version": "3.0.0", "min_compatible": "3.0.0"}, + "sdk": sdk, + } + + +def retrieve_payload(chunks: list[dict[str, Any]] | None = None) -> dict[str, Any]: + rows = chunks if chunks is not None else [_chunk()] + return {"chunks": rows, "total": len(rows)} + + +def _chunk(content: str = "年假 5 天", knowledge_id: int = 12, document_id: int = 7) -> dict[str, Any]: + return { + "content": content, + "knowledge_id": knowledge_id, + "document_id": document_id, + "document_name": "员工手册.pdf", + "chunk_index": 3, + "document_update_time": "2026-09-01 10:00:00", + } + + +def storage_meta(key: str = "报告/2026.pdf", size: int = 11) -> dict[str, Any]: + return { + "key": key, + "size": size, + "content_type": "application/pdf", + "etag": "abc123", + "last_modified": "2026-09-16T10:00:00+00:00", + } + + +def manager_error_body(code: str, message: str, **extra: Any) -> dict[str, Any]: + body = {"code": code, "message": message} + body.update(extra) + return {"detail": body} + + +# --- transports ----------------------------------------------------------- + + +class RecordingTransport: + """A `httpx.MockTransport` that keeps every request it answered.""" + + def __init__(self, handler: Callable[[httpx.Request], httpx.Response]) -> None: + self.requests: list[httpx.Request] = [] + self._handler = handler + self.sync = httpx.MockTransport(self._record) + self.asgi = httpx.MockTransport(self._record) + + def _record(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + return self._handler(request) + + @property + def last(self) -> httpx.Request: + return self.requests[-1] + + +def routes(handlers: dict[str, Callable[[httpx.Request], httpx.Response]]) -> RecordingTransport: + """Route by URL path; an unrouted path fails loudly rather than 404-ing.""" + + def handler(request: httpx.Request) -> httpx.Response: + for path, respond in handlers.items(): + if request.url.path == path or request.url.path.startswith(path): + return respond(request) + raise AssertionError(f"unrouted request: {request.method} {request.url}") + + return RecordingTransport(handler) + + +def json_response(status: int, body: Any) -> Callable[[httpx.Request], httpx.Response]: + return lambda request: httpx.Response(status, json=body) diff --git a/src/bisheng-sdk/tests/test_auth.py b/src/bisheng-sdk/tests/test_auth.py new file mode 100644 index 0000000000..ffa339a1de --- /dev/null +++ b/src/bisheng-sdk/tests/test_auth.py @@ -0,0 +1,201 @@ +"""auth:读注入身份、三种接法、无注入即抛错(AC-04 / 06 / 07 / 08 / 09 / 10 / 31)。""" + +from __future__ import annotations + +import pytest + +from bisheng_sdk import auth +from bisheng_sdk.errors import PlatformIdentityMissingError +from tests.helpers.platform_mock import FAKE_OBO, dev_headers, hosted_headers + + +def test_current_user_from_hosted_headers(): + with auth.bind(hosted_headers()): + user = auth.current_user() + assert user.user_id == "42" + assert user.user_name == "张三" # 注入侧 quote 过,SDK 要解回来 + assert user.tenant_id == "1" + assert user.dept_path == "集团/财务部" # Path 里的 / 必须保留 + assert user.subject_kind == "human" # 线上字面值是 human,不是 natural_person + assert user.app_id == "app-1" + assert user.request_id == "req-1" + + +def test_missing_dept_headers_become_none_not_empty_string(): + with auth.bind(hosted_headers(dept=False)): + user = auth.current_user() + assert (user.dept_id, user.dept_name, user.dept_path) == (None, None, None) + + +def test_dev_service_account_identity_has_same_shape(): + with auth.bind(dev_headers()): + user = auth.current_user() + assert user.subject_kind == "service_account" + assert user.is_service_account + assert user.dept_id is None + assert isinstance(user, auth.Identity) + + +def test_dev_personal_token_login_is_human_and_may_have_dept(): + """PAT `login` 时 `bisheng dev` 注入的是自然人身份——SDK 原样透传、不推导。""" + with auth.bind(hosted_headers(user_id="9002", subject_kind="human", dept=True)): + user = auth.current_user() + assert user.subject_kind == "human" + assert user.dept_id == "7" + + +@pytest.mark.parametrize( + "headers", + [ + None, + [], + [("X-BiSheng-Tenant-Id", "1"), ("X-BiSheng-App-Id", "app-1")], + ], +) +def test_no_identity_raises_and_never_returns_none(headers): + if headers is None: + with pytest.raises(PlatformIdentityMissingError): + auth.current_user() + return + with auth.bind(headers): + with pytest.raises(PlatformIdentityMissingError): + auth.current_user() + + +def test_identity_has_no_token_field_and_repr_hides_it(): + with auth.bind(hosted_headers()): + user = auth.current_user() + assert not hasattr(user, "access_token") + assert "access_token" not in set(user.__dataclass_fields__) + assert FAKE_OBO not in repr(user) + + +def test_no_as_user_or_login_or_verify_api(): + forbidden = {"as_user", "login", "logout", "verify", "verify_token", "impersonate", "sign", "issue"} + assert forbidden.isdisjoint(set(dir(auth))) + + +@pytest.mark.parametrize( + "name", + ["X-BiSheng-User-Id", "x-bisheng-user-id", "X_BiSheng_User_Id", "HTTP_X_BISHENG_USER_ID"], +) +def test_header_name_normalisation(name: str): + with auth.bind([(name, "42")]): + assert auth.current_user().user_id == "42" + + +def test_ids_are_strings_not_ints(): + with auth.bind(hosted_headers()): + user = auth.current_user() + # dev 期的 user_id 是服务账号 id,与平台 user 表不是一张表——当 int 用会 join 到错的人。 + assert isinstance(user.user_id, str) + assert isinstance(user.tenant_id, str) + + +def test_from_headers_is_pure_and_does_not_touch_context(): + user = auth.from_headers(hosted_headers()) + assert user.user_id == "42" + with pytest.raises(PlatformIdentityMissingError): + auth.current_user() + + +def test_bind_is_scoped_to_the_with_block(): + with auth.bind(hosted_headers()): + assert auth.current_user().user_id == "42" + with pytest.raises(PlatformIdentityMissingError): + auth.current_user() + + +# --- ASGI / WSGI 接法 ----------------------------------------------------- + + +def _asgi_scope(headers: list[tuple[str, str]], scope_type: str = "http") -> dict: + return { + "type": scope_type, + "headers": [(name.lower().encode("latin-1"), value.encode("latin-1")) for name, value in headers], + } + + +async def test_asgi_middleware_sets_and_resets_per_request(): + seen: list[str] = [] + + async def app(scope, receive, send): + seen.append(auth.current_user().user_id) + + middleware = auth.ASGIMiddleware(app) + await middleware(_asgi_scope(hosted_headers(user_id="1")), None, None) + await middleware(_asgi_scope(hosted_headers(user_id="2")), None, None) + assert seen == ["1", "2"] + with pytest.raises(PlatformIdentityMissingError): + auth.current_user() + + +async def test_asgi_middleware_passes_headerless_request_through(): + """探活请求照常进入应用;只有真的调 `current_user()` 才抛(AC-07 的落点)。""" + reached = [] + + async def app(scope, receive, send): + reached.append(True) + + await auth.ASGIMiddleware(app)({"type": "http", "headers": []}, None, None) + assert reached == [True] + + +async def test_asgi_middleware_handles_websocket_handshake_scope(): + seen: list[str] = [] + + async def app(scope, receive, send): + seen.append(auth.current_user().user_id) + + await auth.ASGIMiddleware(app)(_asgi_scope(hosted_headers(), "websocket"), None, None) + assert seen == ["42"] + + +async def test_asgi_middleware_passes_lifespan_through_untouched(): + seen = [] + + async def app(scope, receive, send): + seen.append(scope["type"]) + + await auth.ASGIMiddleware(app)({"type": "lifespan"}, None, None) + assert seen == ["lifespan"] + + +def test_middleware_is_not_a_starlette_basehttpmiddleware(): + """纯 ASGI 可调用:`BaseHTTPMiddleware` 会让 ContextVar 的 reset 跨任务抛错(坑 14)。""" + bases = {base.__name__ for base in auth.ASGIMiddleware.__mro__} + assert "BaseHTTPMiddleware" not in bases + assert {"scope", "receive", "send"}.issubset(auth.ASGIMiddleware.__call__.__code__.co_varnames) + + +def test_wsgi_middleware_reads_http_x_bisheng_environ(): + seen: list[str] = [] + + def app(environ, start_response): + seen.append(auth.current_user().user_name) + return [b"ok"] + + environ = {"HTTP_X_BISHENG_USER_ID": "42", "HTTP_X_BISHENG_USER_NAME": "%E5%BC%A0%E4%B8%89", "PATH_INFO": "/"} + body = auth.WSGIMiddleware(app)(environ, lambda *args: None) + assert seen == ["张三"] + assert list(body) == [b"ok"] + body.close() + with pytest.raises(PlatformIdentityMissingError): + auth.current_user() + + +def test_wsgi_middleware_keeps_the_identity_until_the_body_is_consumed(): + """流式响应的业务代码在视图返回之后才跑——那时 `current_user()` 仍要拿得到身份。""" + seen: list[str] = [] + + def app(environ, start_response): + def stream(): + seen.append(auth.current_user().user_id) + yield b"chunk" + + return stream() + + body = auth.WSGIMiddleware(app)({"HTTP_X_BISHENG_USER_ID": "42"}, lambda *args: None) + assert list(body) == [b"chunk"] + assert seen == ["42"] + body.close() diff --git a/src/bisheng-sdk/tests/test_compat.py b/src/bisheng-sdk/tests/test_compat.py new file mode 100644 index 0000000000..8eb3db9715 --- /dev/null +++ b/src/bisheng-sdk/tests/test_compat.py @@ -0,0 +1,93 @@ +"""版本兼容:懒探测、只比下界、成功才缓存(AC-03 / AC-05)。""" + +from __future__ import annotations + +import httpx +import pytest + +import bisheng_sdk +from bisheng_sdk import _compat, auth +from bisheng_sdk.errors import PlatformTooOldError, PlatformUnreachableError, SdkIncompatibleError +from tests.helpers import platform_mock as pm + + +def _versions_transport(payload, status: int = 200): + return pm.routes( + {pm.VERSIONS_PATH: pm.json_response(status, {"status_code": 200, "status_message": "", "data": payload})} + ) + + +@pytest.mark.parametrize("local_min", ["0.1.0", "0.0.9"]) +def test_compatible_when_min_is_not_above_local(mock_transport, platform_env: str, local_min: str): + transport = mock_transport(_versions_transport(pm.versions_payload(min_compatible=local_min))) + _compat.ensure_compatible(platform_env) + assert len(transport.requests) == 1 + + +def test_incompatible_names_both_versions_and_the_remedy(mock_transport, platform_env: str): + mock_transport(_versions_transport(pm.versions_payload(min_compatible="0.3.0"))) + with pytest.raises(SdkIncompatibleError) as caught: + _compat.ensure_compatible(platform_env) + rendered = str(caught.value) + assert "0.3.0" in rendered and bisheng_sdk.__version__ in rendered and "3.0.0" in rendered + assert "重新获取" in rendered + + +def test_sdk_block_null_is_platform_too_old(mock_transport, platform_env: str): + mock_transport(_versions_transport(pm.versions_payload(version=None))) + with pytest.raises(PlatformTooOldError) as caught: + _compat.ensure_compatible(platform_env) + assert "开放能力层" in str(caught.value) + + +def test_versions_404_is_platform_too_old(mock_transport, platform_env: str): + mock_transport(pm.routes({pm.VERSIONS_PATH: pm.json_response(404, {"detail": "Not Found"})})) + with pytest.raises(PlatformTooOldError): + _compat.ensure_compatible(platform_env) + + +def test_probe_runs_once_per_process_per_base(mock_transport, platform_env: str): + transport = mock_transport(_versions_transport(pm.versions_payload())) + _compat.ensure_compatible(platform_env) + _compat.ensure_compatible(platform_env) + assert len(transport.requests) == 1 + + +def test_unreachable_is_not_cached(monkeypatch: pytest.MonkeyPatch, platform_env: str): + from bisheng_sdk import _http + + calls: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls.append(request) + raise httpx.ConnectError("refused", request=request) + + transport = httpx.MockTransport(handler) + monkeypatch.setattr( + _http, "client", lambda base_url, kind="retrieve": httpx.Client(base_url=base_url, transport=transport) + ) + for _ in range(2): + with pytest.raises(PlatformUnreachableError): + _compat.ensure_compatible(platform_env) + assert len(calls) == 2 + + +def test_auth_never_probes(platform_env: str): + """auth 是纯上下文读取、零 I/O;`no_network` 哨兵会让任何请求当场炸。""" + with auth.bind(pm.hosted_headers()): + assert auth.current_user().user_id == "42" + + +@pytest.mark.parametrize( + ("left", "right"), + [("0.10.0", "0.9.9"), ("1.0.0", "0.99.99"), ("0.1.1", "0.1.0")], +) +def test_version_tuple_is_numeric_not_lexicographic(left: str, right: str): + assert _compat.version_tuple(left) > _compat.version_tuple(right) + + +async def test_async_probe_shares_the_same_cache(mock_transport, platform_env: str): + transport = mock_transport(_versions_transport(pm.versions_payload())) + await _compat.aensure_compatible(platform_env) + _compat.ensure_compatible(platform_env) + assert len(transport.requests) == 1 diff --git a/src/bisheng-sdk/tests/test_context_isolation.py b/src/bisheng-sdk/tests/test_context_isolation.py new file mode 100644 index 0000000000..1c9266be5d --- /dev/null +++ b/src/bisheng-sdk/tests/test_context_isolation.py @@ -0,0 +1,60 @@ +"""请求作用域与并发隔离(AC-07 / AC-09)。""" + +from __future__ import annotations + +import asyncio +from concurrent.futures import ThreadPoolExecutor + +import pytest + +from bisheng_sdk import auth +from bisheng_sdk.errors import PlatformIdentityMissingError +from tests.helpers.platform_mock import hosted_headers + + +async def test_two_concurrent_requests_do_not_leak_into_each_other(): + async def handle(user_id: str) -> str: + with auth.bind(hosted_headers(user_id=user_id)): + await asyncio.sleep(0) # 交出控制权:串扰就在这一刻发生 + return auth.current_user().user_id + + assert await asyncio.gather(handle("1"), handle("2")) == ["1", "2"] + + +def test_thread_pool_worker_has_no_identity(): + """后台线程没有访问者——这是 ContextVar 的语义,也正是我们要的行为。""" + + def work() -> str: + try: + auth.current_user() + except PlatformIdentityMissingError: + return "refused" + return "leaked" + + with auth.bind(hosted_headers()): + with ThreadPoolExecutor(max_workers=1) as pool: + assert pool.submit(work).result() == "refused" + + +async def test_created_task_inherits_a_copy_of_the_request_context(): + """`asyncio.create_task` 复制父上下文——记录这条语义,不是 SDK 的兜底。 + + 于是请求里 `create_task` 出去的后台任务**能**拿到身份并检索。指南因此写明 + 「后台任务不要假设有访问者」,SDK 不额外清空副本(清空会破坏 FastAPI 依赖 + 注入的正常任务树)。 + """ + + async def child() -> str: + return auth.current_user().user_id + + with auth.bind(hosted_headers(user_id="7")): + assert await asyncio.create_task(child()) == "7" + + +def test_nested_bind_restores_the_outer_identity(): + with auth.bind(hosted_headers(user_id="1")): + with auth.bind(hosted_headers(user_id="2")): + assert auth.current_user().user_id == "2" + assert auth.current_user().user_id == "1" + with pytest.raises(PlatformIdentityMissingError): + auth.current_user() diff --git a/src/bisheng-sdk/tests/test_contract_alignment.py b/src/bisheng-sdk/tests/test_contract_alignment.py new file mode 100644 index 0000000000..94d405b61f --- /dev/null +++ b/src/bisheng-sdk/tests/test_contract_alignment.py @@ -0,0 +1,145 @@ +"""对账:SDK 复制的三张表必须与源头逐字一致(AC-11 / AC-20 / AC-21 / AC-31)。 + +SDK 是可发布的独立包,装进应用容器时看不到这个仓库——所以这些常量只能是副本。 +副本的代价是漂移,对账测试就是收这个代价:**读源文件的文本**,而不是 import 它们 +(SDK 不依赖 backend / runtime-manager / app-proxy 任何一个包)。 + +仓外运行(发布件里跑)或源文件不存在时 `skip` 并打印原因——那不是失败,只是 +在那个环境里对不了账。 +""" + +from __future__ import annotations + +import ast +import re +from pathlib import Path + +import pytest + +from bisheng_sdk import _env, _headers, _paths, _storage_remote, retrieve + +REPO_ROOT = Path(__file__).resolve().parents[3] +APP_PROXY_HEADERS = REPO_ROOT / "src" / "app-proxy" / "app_proxy" / "headers.py" +MANAGER_STORAGE = REPO_ROOT / "src" / "runtime-manager" / "runtime_manager" / "storage.py" +MANAGER_STORAGE_API = REPO_ROOT / "src" / "runtime-manager" / "runtime_manager" / "api" / "storage.py" +MANAGER_EGRESS = REPO_ROOT / "src" / "runtime-manager" / "runtime_manager" / "egress.py" +BACKEND_FILELIB = REPO_ROOT / "src" / "backend" / "bisheng" / "open_endpoints" / "api" / "endpoints" / "filelib.py" +BACKEND_MODEL_RANGE = ( + REPO_ROOT / "src" / "backend" / "bisheng" / "open_api" / "domain" / "services" / "model_range_policy.py" +) +BACKEND_APP_CREDENTIAL = ( + REPO_ROOT / "src" / "backend" / "bisheng" / "app_publish" / "domain" / "services" / "app_credential_service.py" +) +RTM_CONTRACT = REPO_ROOT / "features" / "v3.0.0" / "054-app-domain-runtime" / "contracts-runtime-manager.md" + + +def _source(path: Path) -> str: + if not path.is_file(): + pytest.skip(f"契约源文件不在本检出里(仓外运行或前置分支未合入):{path}") + return path.read_text(encoding="utf-8") + + +def _tuple_literal(text: str, name: str) -> list[str]: + """从源码里取一个模块级字符串元组常量的值。""" + tree = ast.parse(text) + for node in ast.walk(tree): + targets = [] + if isinstance(node, ast.Assign): + targets = node.targets + elif isinstance(node, ast.AnnAssign): + targets = [node.target] + else: + continue + for target in targets: + if isinstance(target, ast.Name) and target.id == name and node.value is not None: + return [element.value for element in node.value.elts] # type: ignore[attr-defined] + raise AssertionError(f"没在源文件里找到常量 {name}") + + +def test_header_names_match_app_proxy_verbatim(): + upstream = _tuple_literal(_source(APP_PROXY_HEADERS), "INJECTED_HEADER_NAMES") + assert list(_headers.INJECTED_HEADER_NAMES) == upstream + + +def test_header_normalisation_matches_app_proxy(): + text = _source(APP_PROXY_HEADERS) + assert 'strip().lower().replace("_", "-")' in text + assert _headers.normalize_name("X_BiSheng_User_Id") == "x-bisheng-user-id" + + +def test_storage_env_names_match_the_manager(): + text = _source(MANAGER_STORAGE) + names = { + "ENV_STORAGE_ENDPOINT": _env.ENV_STORAGE_ENDPOINT, + "ENV_STORAGE_TOKEN": _env.ENV_STORAGE_TOKEN, + "ENV_STORAGE_MAX_FILE_MB": _env.ENV_STORAGE_MAX_FILE_MB, + } + for constant, ours in names.items(): + match = re.search(rf'^{constant} = "([^"]+)"', text, re.MULTILINE) + assert match, f"manager 里没有 {constant}" + assert match.group(1) == ours + + +def test_egress_token_env_name_matches_the_manager(): + """SDK 凭它在不在判断要不要走出站代理;manager 改名而这里不跟,托管期平台调用就全断。""" + text = _source(MANAGER_EGRESS) + match = re.search(r'^ENV_EGRESS_TOKEN = "([^"]+)"', text, re.MULTILINE) + assert match, "runtime-manager egress.py 里没有 ENV_EGRESS_TOKEN" + assert match.group(1) == _env.ENV_EGRESS_TOKEN + + +def test_platform_env_names_are_in_the_runtime_contract(): + text = _source(RTM_CONTRACT) + for name in (_env.ENV_PLATFORM_API_BASE, _env.ENV_STORAGE_ENDPOINT, _env.ENV_STORAGE_TOKEN): + assert name in text, f"契约文档 §5 里没有 {name}" + + +def test_app_token_env_name_matches_the_capability_bus(): + text = _source(BACKEND_APP_CREDENTIAL) + match = re.search(r'^HOSTED_APP_TOKEN_ENV = "([^"]+)"', text, re.MULTILINE) + assert match, "app_credential_service 里没有 HOSTED_APP_TOKEN_ENV" + assert match.group(1) == _env.ENV_APP_TOKEN + + +def test_access_token_header_name_matches_the_verifier(): + text = _source(BACKEND_MODEL_RANGE) + match = re.search(r'^ACCESS_TOKEN_HEADER = "([^"]+)"', text, re.MULTILINE) + assert match, "model_range_policy 里没有 ACCESS_TOKEN_HEADER" + assert match.group(1) == retrieve.ACCESS_TOKEN_HEADER + assert match.group(1) in _headers.INJECTED_HEADER_NAMES + + +def test_retrieve_path_and_hosted_branch_still_exist(): + text = _source(BACKEND_FILELIB) + assert '@router.post("/retrieve")' in text + assert retrieve.RETRIEVE_PATH.endswith("/filelib/retrieve") + # 托管应用走能力总线(白名单 ∩ 访问用户),不是门面直连——这是 SDK 必须同时 + # 送两把凭据的原因。任何一侧改了这条分支,这里先红。 + assert "HOSTED_APP_ACTOR_KIND" in text + assert "hosted_app_access_user" in text + + +def test_storage_routes_match_the_manager_router(): + text = _source(MANAGER_STORAGE_API) + assert 'prefix="/v1/apps/{app_id}/storage"' in text + for route in ('@router.get("/objects")', '@router.get("/meta/{key:path}")', '@router.put("/objects/{key:path}")'): + assert route in text, f"manager 的附件路由变了:{route}" + assert '@router.delete("/objects/{key:path}")' in text + # SDK 拼的 URL 必须落在同样的形状上。 + backend = _storage_remote.RemoteBackend("http://m.test/v1/apps/app-1/storage", "t") + assert backend._objects_url("a/b.txt").endswith("/storage/objects/a/b.txt") + assert backend._meta_url("a.txt").endswith("/storage/meta/a.txt") + + +def test_path_rules_mirror_manager_validate_key(): + text = _source(MANAGER_STORAGE) + assert f"MAX_KEY_BYTES = {_paths.MAX_KEY_BYTES}" in text + assert f'APPS_NAMESPACE = "{_paths.APPS_NAMESPACE}"' in text + for rule in ( + 'raise _reject(key, "absolute path")', + 'raise _reject(key, "trailing slash")', + 'raise _reject(key, "backslashes")', + 'raise _reject(key, "control characters")', + "if posixpath.normpath(key) != key:", + ): + assert rule in text, f"manager 的路径规则变了:{rule}" diff --git a/src/bisheng-sdk/tests/test_errors.py b/src/bisheng-sdk/tests/test_errors.py new file mode 100644 index 0000000000..d0f5d360a6 --- /dev/null +++ b/src/bisheng-sdk/tests/test_errors.py @@ -0,0 +1,89 @@ +"""异常层次与脱敏(AC-04 / AC-07 / AC-15 / AC-19 / AC-25)。""" + +from __future__ import annotations + +import inspect + +import pytest + +from bisheng_sdk import errors +from tests.helpers.platform_mock import FAKE_APP_TOKEN, FAKE_DEV_HANDLE, FAKE_OBO + +ALL_ERROR_CLASSES = [ + value + for _name, value in vars(errors).items() + if inspect.isclass(value) and issubclass(value, errors.BishengSdkError) and value is not errors.BishengSdkError +] + + +def _instance(cls: type[errors.BishengSdkError]) -> errors.BishengSdkError: + if cls is errors.SdkIncompatibleError: + return cls(sdk_version="0.1.0", min_compatible="0.3.0", platform_version="3.0.0") + return cls() + + +def test_every_error_carries_message_and_next_step(): + assert ALL_ERROR_CLASSES, "异常类应当被 vars() 枚举到,层次改名了就该来改这个测试" + for cls in ALL_ERROR_CLASSES: + error = _instance(cls) + assert error.message, f"{cls.__name__} 没有 message" + assert error.next_step, f"{cls.__name__} 没有 next_step" + assert "下一步" in str(error) + + +def test_single_base_and_auth_vs_retrieve_are_not_subclasses(): + for cls in ALL_ERROR_CLASSES: + assert issubclass(cls, errors.BishengSdkError) + # auth 缺身份与 retrieve 缺访问者凭据是两件事:前者说"这条路径不该调 auth", + # 后者说"这次调用没有访问者"。谁是谁的子类,应用的 except 就会吃掉另一个。 + assert not issubclass(errors.PlatformIdentityMissingError, errors.VisitorCredentialMissingError) + assert not issubclass(errors.VisitorCredentialMissingError, errors.PlatformIdentityMissingError) + + +@pytest.mark.parametrize( + "secret", + [FAKE_APP_TOKEN, "bs-pat-" + "y" * 24, f"Bearer {FAKE_OBO}", FAKE_OBO, FAKE_DEV_HANDLE], +) +def test_redact_masks_every_credential_shape(secret: str): + error = errors.PlatformRefusedError( + f"平台拒绝:{secret}", + code=12345, + details={"echo": secret, "nested": {"again": [secret]}}, + ) + rendered = f"{error}{error!r}{error.details}" + assert secret not in rendered + assert errors.redact(secret) != secret + + +def test_platform_refused_keeps_code_and_details_verbatim(): + error = errors.PlatformRefusedError("未登记的业务码", code=26399, details={"hint": "去看文档", "n": 3}) + assert error.code == 26399 + assert error.details == {"hint": "去看文档", "n": 3} + assert "未登记的业务码" in str(error) + + +def test_structured_attributes_survive(): + assert errors.ScopeMissingError(required="knowledge:read").required == "knowledge:read" + assert errors.TargetUnreachableError(ids=[1, 2]).ids == [1, 2] + revoked = errors.CapabilityRevokedError(capability="财务档案", reason="revoked") + assert (revoked.capability, revoked.reason) == ("财务档案", "revoked") + assert errors.AttachmentTooLargeError(path="a.bin", limit_bytes=20 * 1024 * 1024).limit_bytes == 20 * 1024 * 1024 + incompatible = errors.SdkIncompatibleError(sdk_version="0.1.0", min_compatible="0.3.0", platform_version="3.0.0") + assert "0.3.0" in str(incompatible) and "0.1.0" in str(incompatible) + + +def test_storage_errors_are_six_distinguishable_classes(): + classes = ( + errors.StorageHandleMissingError, + errors.StorageHandleRejectedError, + errors.StorageUnavailableError, + errors.AttachmentNotFoundError, + errors.AttachmentTooLargeError, + errors.InvalidAttachmentPathError, + ) + for cls in classes: + for other in classes: + if cls is not other: + assert not issubclass(cls, other), f"{cls.__name__} 不该是 {other.__name__} 的子类" + messages = {str(_instance(cls)) for cls in classes} + assert len(messages) == len(classes), "六类 storage 错误的文案必须互不相同" diff --git a/src/bisheng-sdk/tests/test_http.py b/src/bisheng-sdk/tests/test_http.py new file mode 100644 index 0000000000..29f361a619 --- /dev/null +++ b/src/bisheng-sdk/tests/test_http.py @@ -0,0 +1,155 @@ +"""HTTP 层:信封顺序、码映射两级降级、零重试、超时分档(AC-04 / 14 / 16 / 17 / 18 / 19)。""" + +from __future__ import annotations + +import httpx +import pytest + +from bisheng_sdk import _codes, _env, _http, errors +from tests.helpers.platform_mock import FAKE_OBO + + +def _resp(status: int, body) -> httpx.Response: + return httpx.Response(status, json=body, request=httpx.Request("POST", "http://platform.test/x")) + + +def test_envelope_body_is_read_before_the_status_line(): + """/api/v1 的业务错误是 HTTP 200 + 信封;先看状态行就会把它当成功(坑 7)。""" + with pytest.raises(errors.BishengSdkError): + _http.parse_envelope(_resp(200, {"status_code": 10500, "status_message": "boom", "data": None})) + + with pytest.raises(errors.VisitorCredentialRejectedError): + _http.parse_envelope(_resp(401, {"status_code": 26001, "status_message": "缺凭据", "data": {}})) + + +def test_success_envelope_returns_data(): + assert _http.parse_envelope(_resp(200, {"status_code": 200, "status_message": "SUCCESS", "data": {"a": 1}})) == { + "a": 1 + } + + +@pytest.mark.parametrize("code", [26001, 26002, 26027, 26320]) +def test_credential_and_identity_codes_map_to_rejected(code: int): + error = _codes.map_error(code, "拒绝", http_status=401, data={}) + assert isinstance(error, errors.VisitorCredentialRejectedError) + assert error.code == code + + +def test_scope_missing_keeps_required_as_a_single_string(): + error = _codes.map_error(26003, "缺位", http_status=403, data={"required": "knowledge:read"}) + assert isinstance(error, errors.ScopeMissingError) + assert error.required == "knowledge:read" + assert "knowledge:read" in str(error) + + +def test_unreachable_targets_carry_the_ids(): + error = _codes.map_error(26321, "不可及", http_status=404, data={"unreachable_ids": [3, 9]}) + assert isinstance(error, errors.TargetUnreachableError) + assert error.ids == [3, 9] + + +def test_capability_codes_map_to_their_own_classes(): + revoked = _codes.map_error(16273, "已收回", http_status=400, data={"capability": "财务档案", "reason": "revoked"}) + assert isinstance(revoked, errors.CapabilityRevokedError) + assert (revoked.capability, revoked.reason) == ("财务档案", "revoked") + + facade_revoked = _codes.map_error(26322, "已收回", http_status=409, data={"knowledge_id": 12}) + assert isinstance(facade_revoked, errors.CapabilityRevokedError) + assert facade_revoked.capability == "12" + + undeclared = _codes.map_error(16274, "未声明", http_status=400, data={"capability": "knowledge"}) + assert isinstance(undeclared, errors.CapabilityNotDeclaredError) + assert undeclared.capability == "knowledge" + + +def test_permission_evaluation_outage_is_its_own_class(): + error = _codes.map_error(26030, "鉴权依赖不可用", http_status=503, data={}) + assert isinstance(error, errors.PermissionEvaluationError) + assert "不要改小检索范围" in error.next_step + + +def test_unregistered_code_degrades_by_http_class_then_to_refused(): + assert isinstance(_codes.map_error(None, "", http_status=401, data=None), errors.VisitorCredentialRejectedError) + assert isinstance(_codes.map_error(None, "", http_status=502, data=None), errors.PlatformUnreachableError) + + refused = _codes.map_error(29999, "没见过的码", http_status=403, data={"x": 1}) + assert isinstance(refused, errors.PlatformRefusedError) + assert refused.code == 29999 + assert refused.details == {"x": 1} + assert "没见过的码" in str(refused) + + +def test_connect_error_is_unreachable_and_sent_once(monkeypatch: pytest.MonkeyPatch, platform_env: str): + calls: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls.append(request) + raise httpx.ConnectError("connection refused", request=request) + + transport = httpx.MockTransport(handler) + monkeypatch.setattr( + _http, "client", lambda base_url, kind="retrieve": httpx.Client(base_url=base_url, transport=transport) + ) + + with pytest.raises(errors.PlatformUnreachableError): + _http.request("retrieve", "GET", "/x", base_url=platform_env) + assert len(calls) == 1, "零重试:重试是应用的决定,不是 SDK 的" + + +def test_timeouts_are_tiered(): + assert _http.timeout_for("retrieve").read == _http.RETRIEVE_READ_TIMEOUT + assert _http.timeout_for("storage").read == _http.STORAGE_READ_TIMEOUT + assert _http.timeout_for("retrieve").connect == _http.CONNECT_TIMEOUT + + +def test_proxy_variables_are_ignored_locally_by_default(): + """开发机上的公网代理会劫持内网平台地址,本地不读。""" + assert _env.trust_env() is False + assert _http.client("http://platform.test", "retrieve").trust_env is False + + +def test_proxy_variables_can_be_enabled_explicitly(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("BISHENG_SDK_TRUST_ENV", "1") + assert _http.client("http://platform.test", "retrieve").trust_env is True + + +def test_a_hosted_container_with_an_egress_credential_goes_through_the_proxy(monkeypatch: pytest.MonkeyPatch): + """托管容器在 `--internal` 网上,平台 API 只能经注入的出站代理到达。 + + 曾经默认不读代理变量:出站白名单一开、应用网改成 internal,`retrieve` 直连平台 + 只得到「网络不可达」。 + """ + monkeypatch.setenv("BISHENG_APP_EGRESS_TOKEN", "t" * 43) + assert _env.trust_env() is True + assert _http.client("http://platform.test", "retrieve").trust_env is True + assert _http.client("http://runtime-manager:8091", "storage").trust_env is True + + +def test_clients_are_pooled_per_base_url_and_kind(): + first = _http.client("http://a.test", "retrieve") + assert _http.client("http://a.test", "retrieve") is first + assert _http.client("http://b.test", "retrieve") is not first + assert _http.client("http://a.test", "storage") is not first + + +def test_bearer_never_reaches_an_exception(): + error = _codes.map_error(26002, f"token {FAKE_OBO} invalid", http_status=401, data={"echo": FAKE_OBO}) + assert FAKE_OBO not in f"{error}{error.details}" + + +def test_manager_envelope_is_parsed_with_its_own_shape(): + """附件 API 的信封是 `{"detail": {...}}`,不是 backend 的 status_code(坑 26)。""" + resp = httpx.Response( + 413, + json={"detail": {"code": "payload_too_large", "message": "too big", "max_file_mb": 20}}, + request=httpx.Request("PUT", "http://manager.test/x"), + ) + with pytest.raises(errors.AttachmentTooLargeError) as caught: + _http.parse_manager_envelope(resp, path="a.bin") + assert caught.value.limit_bytes == 20 * 1024 * 1024 + + +def test_manager_envelope_without_detail_falls_back_by_status(): + resp = httpx.Response(500, text="boom", request=httpx.Request("GET", "http://manager.test/x")) + with pytest.raises(errors.StorageUnavailableError): + _http.parse_manager_envelope(resp, path="a.bin") diff --git a/src/bisheng-sdk/tests/test_import_smoke.py b/src/bisheng-sdk/tests/test_import_smoke.py new file mode 100644 index 0000000000..adaf974861 --- /dev/null +++ b/src/bisheng-sdk/tests/test_import_smoke.py @@ -0,0 +1,48 @@ +"""每个模块都能单独 import —— 一个循环 import 在应用容器里就是启动失败。""" + +from __future__ import annotations + +import importlib + +import pytest + +MODULES = [ + "bisheng_sdk", + "bisheng_sdk.auth", + "bisheng_sdk.retrieve", + "bisheng_sdk.storage", + "bisheng_sdk.errors", + "bisheng_sdk._attachment", + "bisheng_sdk._codes", + "bisheng_sdk._compat", + "bisheng_sdk._context", + "bisheng_sdk._env", + "bisheng_sdk._headers", + "bisheng_sdk._http", + "bisheng_sdk._paths", + "bisheng_sdk._storage_local", + "bisheng_sdk._storage_remote", +] + + +@pytest.mark.parametrize("name", MODULES) +def test_module_imports(name: str): + assert importlib.import_module(name) is not None + + +def test_import_does_no_io(): + """`import bisheng_sdk` 不打网络、不读盘——否则单测与探活都被拖下水。 + + `no_network` 哨兵已经保证了前半句;这里断言导入后没有任何客户端被建出来。 + """ + from bisheng_sdk import _http + + importlib.reload(importlib.import_module("bisheng_sdk")) + assert _http._clients == {} + assert _http._aclients == {} + + +def test_httpx_is_available_with_both_faces(): + import httpx + + assert hasattr(httpx, "Client") and hasattr(httpx, "AsyncClient") and hasattr(httpx, "MockTransport") diff --git a/src/bisheng-sdk/tests/test_paths.py b/src/bisheng-sdk/tests/test_paths.py new file mode 100644 index 0000000000..cfc27a4f5e --- /dev/null +++ b/src/bisheng-sdk/tests/test_paths.py @@ -0,0 +1,66 @@ +"""路径规则 —— manager `validate_key` / `validate_prefix` 的逐条镜像(AC-20 / AC-21)。""" + +from __future__ import annotations + +import pytest + +from bisheng_sdk import _paths +from bisheng_sdk.errors import InvalidAttachmentPathError + + +@pytest.mark.parametrize( + "path", + [ + "", + "../a", + "a/../b", + "/abs", + "a//b", + "a/./b", + "a\\b", + ".", + "..", + "a/", + "a\x00b", + "a\nb", + "a\x7fb", + "x" * 1025, + "apps/app-1/attachments/a.txt", + "apps/", + ], +) +def test_rejected_paths(path: str): + with pytest.raises(InvalidAttachmentPathError): + _paths.validate(path) + + +def test_rejection_is_not_normalisation(): + """`a/../b` 是拒绝,不是"给你 b"——写出它的应用几乎肯定想越界。""" + with pytest.raises(InvalidAttachmentPathError) as caught: + _paths.validate("a/../b") + # 错误里回显的是**原样**的路径:换成规范化后的 `b` 就等于告诉调用方 + # "我懂你的意思",下一步就会有人把拒绝改成放行。 + assert caught.value.path == "a/../b" + + +@pytest.mark.parametrize("path", ["a.txt", "报告/2026 年度.pdf", "a/b/c.bin", "x" * 1024]) +def test_accepted_paths(path: str): + assert _paths.validate(path) == path + + +@pytest.mark.parametrize("prefix", ["", "报告/", "报告", "a/b"]) +def test_prefix_allows_empty_and_trailing_slash(prefix: str): + assert _paths.validate_prefix(prefix) == prefix + + +@pytest.mark.parametrize("prefix", ["apps/", "/abs/", "../x/"]) +def test_prefix_follows_the_same_rules(prefix: str): + with pytest.raises(InvalidAttachmentPathError): + _paths.validate_prefix(prefix) + + +def test_max_key_bytes_is_counted_in_utf8(): + # 1024 字节,不是 1024 个字符:三字节的汉字 342 个就到顶了。 + assert _paths.validate("中" * 341) + with pytest.raises(InvalidAttachmentPathError): + _paths.validate("中" * 342) diff --git a/src/bisheng-sdk/tests/test_public_surface.py b/src/bisheng-sdk/tests/test_public_surface.py new file mode 100644 index 0000000000..bd07b01b2d --- /dev/null +++ b/src/bisheng-sdk/tests/test_public_surface.py @@ -0,0 +1,119 @@ +"""公开面守卫 —— 准入门槛做成一条会红的测试(AC-01 / 03 / 12 / 30 / 33)。 + +新增第四个能力模块必须先证明它同时满足「平台特有」∧「写错了会出安全事故」, +**并先改这里的集合再写代码**。把门槛写在备忘录里没人看,写成断言就绕不过去。 +chat / appdb 被否决的理由见 PRD-1 DEV-07 两表,不在这里重抄。 +""" + +from __future__ import annotations + +import ast +import pkgutil +from pathlib import Path + +import pytest + +import bisheng_sdk + +PACKAGE_DIR = Path(bisheng_sdk.__file__).parent +SOURCES = sorted(PACKAGE_DIR.glob("*.py")) + + +def test_public_modules_are_exactly_auth_retrieve_storage_errors(): + public = {name for _finder, name, _pkg in pkgutil.iter_modules([str(PACKAGE_DIR)]) if not name.startswith("_")} + assert public == {"auth", "retrieve", "storage", "errors"} + + +def test_dunder_all_is_the_three_capability_modules(): + assert bisheng_sdk.__all__ == ("auth", "retrieve", "storage") + + +@pytest.mark.parametrize("name", ["chat", "appdb", "llm", "db", "client", "models"]) +def test_the_modules_that_must_not_exist(name: str): + with pytest.raises(ModuleNotFoundError): + __import__(f"bisheng_sdk.{name}") + + +def _code_strings(source: Path) -> set[str]: + """源文件里**代码**用到的字符串字面量与符号名(docstring 与注释不算)。 + + 区分二者是必须的:指南性质的 docstring 会提到 `OpenAI 兼容客户端` 与 + `BISHENG_APP_TOKEN` 这些名字,而"提到"与"用上"是两回事——用文本 grep 判定 + 会逼着实现把解释删掉,那正是下一个人需要的解释。 + """ + tree = ast.parse(source.read_text(encoding="utf-8")) + docstrings = {ast.get_docstring(node) for node in ast.walk(tree) if isinstance(node, _DOCSTRING_OWNERS)} + names: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Constant) and isinstance(node.value, str): + if node.value not in docstrings: + names.add(node.value) + elif isinstance(node, ast.Name): + names.add(node.id) + elif isinstance(node, ast.Attribute): + names.add(node.attr) + elif isinstance(node, (ast.Import, ast.ImportFrom)): + module = getattr(node, "module", None) or "" + names.add(module) + names.update(alias.name for alias in node.names) + return names + + +_DOCSTRING_OWNERS = (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef) + + +@pytest.mark.parametrize("needle", ["openai", "OpenAI", "create_engine", "sqlalchemy", "psycopg", "sqlite3"]) +def test_no_model_or_database_convenience_wrapper(needle: str): + """两条标准库接法刻意不进 SDK:顺手包一层就是把它们请进来了。""" + for source in SOURCES: + assert needle not in _code_strings(source), f"{source.name} 的代码里用到了 {needle}" + + +@pytest.mark.parametrize("name", ["as_user", "on_behalf_of", "impersonate", "verify_token"]) +def test_no_identity_override_symbol_anywhere(name: str): + for source in SOURCES: + assert f"def {name}" not in source.read_text(encoding="utf-8") + + +def test_only_env_module_reads_os_environ(): + """凭据与句柄的读取集中在 `_env.py` 一处,别处不得自己伸手去拿。""" + offenders = [ + source.name + for source in SOURCES + if source.name not in {"_env.py"} and "os.environ" in source.read_text(encoding="utf-8") + ] + assert offenders == [] + + +def test_app_token_is_read_only_through_the_env_module(): + """应用运行期凭据只能经 `_env.app_token()` 取,且只用于证明「哪个应用」。 + + F055 已落地的托管契约要求 retrieve 同时带应用凭据与访问者凭据,因此这个名字 + 在 SDK 里是**允许出现的**——但只允许出现在两处:`_env.py` 的常量,和 + `retrieve.py` 里对它的调用。散落到别处就意味着某条路径在拿应用身份当访问者用。 + """ + offenders = [ + source.name for source in SOURCES if source.name != "_env.py" and "BISHENG_APP_TOKEN" in _code_strings(source) + ] + assert offenders == [] + + +def test_storage_never_exposes_a_url_or_bucket(): + from bisheng_sdk import storage + + names = set(dir(storage)) + assert not {name for name in names if name.endswith(("url", "link", "presign", "share"))} + assert {"clear", "delete_prefix", "delete_many", "bucket", "endpoint"}.isdisjoint(names) + + +def test_version_declared_once(): + assert bisheng_sdk.__version__ == "0.1.1" + hits = [source.name for source in SOURCES if '__version__ = "' in source.read_text(encoding="utf-8")] + assert hits == ["__init__.py"] + + +def test_only_dependency_is_httpx(): + text = (PACKAGE_DIR.parent / "pyproject.toml").read_text(encoding="utf-8") + block = text.split("dependencies = [", 1)[1].split("]", 1)[0] + entries = [line.strip().strip(",").strip('"') for line in block.splitlines() if line.strip().startswith('"')] + assert entries == ["httpx>=0.27,<1.0"] diff --git a/src/bisheng-sdk/tests/test_retrieve.py b/src/bisheng-sdk/tests/test_retrieve.py new file mode 100644 index 0000000000..fbd43b919c --- /dev/null +++ b/src/bisheng-sdk/tests/test_retrieve.py @@ -0,0 +1,249 @@ +"""retrieve:两把凭据、门面入参出参、fail-closed(AC-03 / 08 / 11 / 12 / 14 / 15 / 16 / 17 / 18 / 19)。 + +**这里断言的是 F055 已落地的托管运行契约**(`open_endpoints/api/endpoints/filelib.py` +的 `HOSTED_APP_ACTOR_KIND` 分支):一次检索带两把凭据——`Authorization: Bearer` +是**应用**的运行期凭据(平台据此取白名单),`X-BiSheng-Access-Token` 是**访问者** +的凭据(平台据此确立访问用户)。没有访问者头就没有访问用户,检索被直接拒绝, +**没有 owner 可以兜底**。 +""" + +from __future__ import annotations + +import inspect + +import httpx +import pytest + +from bisheng_sdk import auth, retrieve +from bisheng_sdk.errors import ( + AppCredentialMissingError, + CapabilityNotDeclaredError, + CapabilityRevokedError, + PermissionEvaluationError, + PlatformRefusedError, + PlatformTooOldError, + ScopeMissingError, + TargetUnreachableError, + VisitorCredentialMissingError, + VisitorCredentialRejectedError, +) +from tests.helpers import platform_mock as pm + + +@pytest.fixture +def wired(mock_transport, platform_env, app_token_env): + """平台就绪:版本探测通过 + 检索按给定响应作答。""" + + def install(retrieve_response=None): + retrieve_response = retrieve_response or pm.json_response( + 200, {"status_code": 200, "status_message": "SUCCESS", "data": pm.retrieve_payload()} + ) + return mock_transport( + pm.routes( + { + pm.VERSIONS_PATH: pm.json_response( + 200, {"status_code": 200, "status_message": "", "data": pm.versions_payload()} + ), + pm.RETRIEVE_PATH: retrieve_response, + } + ) + ) + + return install + + +def _retrieve_request(transport) -> httpx.Request: + return next(request for request in transport.requests if request.url.path == pm.RETRIEVE_PATH) + + +def test_search_sends_both_credentials(wired): + transport = wired() + with auth.bind(pm.hosted_headers()): + retrieve.search("年假", knowledge_base_ids=[12]) + request = _retrieve_request(transport) + assert request.method == "POST" + assert str(request.url) == f"{pm.PLATFORM_BASE}{pm.RETRIEVE_PATH}" + # 应用凭据证明「哪个应用」,访问者凭据证明「为谁」——两把都要,且不可对调。 + assert request.headers["Authorization"] == f"Bearer {pm.FAKE_APP_TOKEN}" + assert request.headers["X-BiSheng-Access-Token"] == pm.FAKE_OBO + + +def test_body_is_field_for_field_and_none_ids_are_omitted(wired): + transport = wired() + with auth.bind(pm.hosted_headers()): + retrieve.search("年假", knowledge_base_ids=[12, 13], top_k=3, max_content=999) + body = _json(_retrieve_request(transport)) + assert body == {"query": "年假", "top_k": 3, "max_content": 999, "knowledge_base_ids": [12, 13]} + + transport = wired() + with auth.bind(pm.hosted_headers()): + retrieve.search("年假") + # 服务端 schema 是 extra="forbid";送 `null` 会 422,所以不指定就省略该键。 + assert "knowledge_base_ids" not in _json(_retrieve_request(transport)) + + +def test_filters_use_the_servers_own_shape(wired): + transport = wired() + with auth.bind(pm.hosted_headers()): + retrieve.search( + "年假", + knowledge_base_ids=[12], + filters=[retrieve.KnowledgeBaseFilter(knowledge_base_id=12, tags=["hr"])], + ) + body = _json(_retrieve_request(transport)) + assert body["filters"] == { + "knowledge_base_filters": [{"knowledge_base_id": 12, "tags": ["hr"], "tag_match_mode": "ANY"}] + } + + +def test_result_mirrors_the_facade_response(wired): + wired() + with auth.bind(pm.hosted_headers()): + result = retrieve.search("年假", knowledge_base_ids=[12]) + assert result.total == 1 + chunk = result.chunks[0] + assert chunk.content == "年假 5 天" + assert (chunk.knowledge_id, chunk.document_id, chunk.chunk_index) == (12, 7, 3) + assert chunk.document_name == "员工手册.pdf" + assert chunk.document_update_time == "2026-09-01 10:00:00" + + +def test_no_visitor_credential_sends_nothing(mock_transport, platform_env, app_token_env): + transport = mock_transport(pm.routes({})) + with auth.bind(pm.hosted_headers(token=None)): + with pytest.raises(VisitorCredentialMissingError): + retrieve.search("年假", knowledge_base_ids=[12]) + with pytest.raises(VisitorCredentialMissingError): + retrieve.search("年假", knowledge_base_ids=[12]) + assert transport.requests == [] + + +def test_app_token_alone_is_never_enough(mock_transport, platform_env, app_token_env): + """进程级环境里有应用凭据,但没有访问者——仍然拒绝,且一个请求都不发。""" + transport = mock_transport(pm.routes({})) + with pytest.raises(VisitorCredentialMissingError): + retrieve.search("年假", knowledge_base_ids=[12]) + assert transport.requests == [] + + +def test_visitor_credential_alone_is_refused_locally(mock_transport, platform_env): + """应用凭据未注入(本地 `bisheng dev` 期就是这样)→ 明确、可区分的错误。""" + transport = mock_transport(pm.routes({})) + with auth.bind(pm.dev_headers()): + with pytest.raises(AppCredentialMissingError): + retrieve.search("年假", knowledge_base_ids=[12]) + assert transport.requests == [] + + +def test_no_as_user_parameter(): + signature = inspect.signature(retrieve.search) + assert {"as_user", "user_id", "on_behalf_of", "subject"}.isdisjoint(signature.parameters) + assert inspect.signature(retrieve.asearch).parameters.keys() == signature.parameters.keys() + + +@pytest.mark.parametrize( + ("http_status", "code", "data", "expected"), + [ + (401, 26001, {}, VisitorCredentialRejectedError), + (403, 26320, {}, VisitorCredentialRejectedError), + (403, 26003, {"required": "knowledge:read"}, ScopeMissingError), + (404, 26321, {"unreachable_ids": [9]}, TargetUnreachableError), + (409, 26322, {"knowledge_id": 12}, CapabilityRevokedError), + (400, 16273, {"capability": "财务档案", "reason": "revoked"}, CapabilityRevokedError), + (400, 16274, {"capability": "knowledge"}, CapabilityNotDeclaredError), + (503, 26030, {}, PermissionEvaluationError), + (422, None, None, PlatformRefusedError), + ], +) +def test_each_failure_kind_is_distinguishable(wired, http_status, code, data, expected): + body = pm.v2_error_body(code, "拒绝", data) if code else {"detail": "validation error"} + wired(pm.json_response(http_status, body)) + with auth.bind(pm.hosted_headers()): + with pytest.raises(expected): + retrieve.search("年假", knowledge_base_ids=[12]) + + +def test_permission_outage_is_an_exception_not_an_empty_result(wired): + wired(pm.json_response(503, pm.v2_error_body(26030, "鉴权依赖不可用"))) + with auth.bind(pm.hosted_headers()): + with pytest.raises(PermissionEvaluationError): + retrieve.search("年假", knowledge_base_ids=[12]) + + +def test_incompatibility_surfaces_before_any_retrieval(mock_transport, platform_env, app_token_env): + transport = mock_transport( + pm.routes( + { + pm.VERSIONS_PATH: pm.json_response( + 200, {"status_code": 200, "status_message": "", "data": pm.versions_payload(version=None)} + ) + } + ) + ) + with auth.bind(pm.hosted_headers()): + with pytest.raises(PlatformTooOldError): + retrieve.search("年假", knowledge_base_ids=[12]) + assert all(request.url.path != pm.RETRIEVE_PATH for request in transport.requests) + + +def test_two_visitors_get_their_own_results_and_nothing_is_cached(mock_transport, platform_env, app_token_env): + def respond(request: httpx.Request) -> httpx.Response: + visitor = request.headers["X-BiSheng-Access-Token"] + content = "甲的结果" if visitor.endswith("1") else "乙的结果" + return httpx.Response( + 200, + json={ + "status_code": 200, + "status_message": "", + "data": pm.retrieve_payload([_chunk_with(content)]), + }, + ) + + transport = mock_transport( + pm.routes( + { + pm.VERSIONS_PATH: pm.json_response( + 200, {"status_code": 200, "status_message": "", "data": pm.versions_payload()} + ), + pm.RETRIEVE_PATH: respond, + } + ) + ) + + with auth.bind(pm.hosted_headers(user_id="1", token="tok-1")): + first = retrieve.search("同一个问题", knowledge_base_ids=[12]) + with auth.bind(pm.hosted_headers(user_id="2", token="tok-2")): + second = retrieve.search("同一个问题", knowledge_base_ids=[12]) + + assert first.chunks[0].content == "甲的结果" + assert second.chunks[0].content == "乙的结果" + assert len([r for r in transport.requests if r.url.path == pm.RETRIEVE_PATH]) == 2 + + +async def test_async_twin_has_the_same_wire_shape(wired): + transport = wired() + with auth.bind(pm.hosted_headers()): + result = await retrieve.asearch("年假", knowledge_base_ids=[12]) + request = _retrieve_request(transport) + assert result.total == 1 + assert request.headers["Authorization"] == f"Bearer {pm.FAKE_APP_TOKEN}" + assert request.headers["X-BiSheng-Access-Token"] == pm.FAKE_OBO + + +async def test_async_twin_refuses_without_a_visitor(mock_transport, platform_env, app_token_env): + transport = mock_transport(pm.routes({})) + with pytest.raises(VisitorCredentialMissingError): + await retrieve.asearch("年假", knowledge_base_ids=[12]) + assert transport.requests == [] + + +def _chunk_with(content: str) -> dict: + row = pm.retrieve_payload()["chunks"][0].copy() + row["content"] = content + return row + + +def _json(request: httpx.Request) -> dict: + import json + + return json.loads(request.content.decode("utf-8")) diff --git a/src/bisheng-sdk/tests/test_storage_facade.py b/src/bisheng-sdk/tests/test_storage_facade.py new file mode 100644 index 0000000000..eff0951076 --- /dev/null +++ b/src/bisheng-sdk/tests/test_storage_facade.py @@ -0,0 +1,111 @@ +"""storage 门面:同一套 API 两个后端、句柄每次重算(AC-20 / 21 / 22 / 23 / 25)。""" + +from __future__ import annotations + +import inspect +from pathlib import Path + +import httpx +import pytest + +from bisheng_sdk import storage +from bisheng_sdk.errors import AttachmentNotFoundError, InvalidAttachmentPathError +from tests.helpers import platform_mock as pm + + +def _fake_remote_store(mock_transport): + """一个记性很好的假平台存储:足以跑完整条 put → stat → list → get → delete。""" + objects: dict[str, bytes] = {} + + def handler(request: httpx.Request) -> httpx.Response: + key = request.url.path.split("/storage/", 1)[1] + kind, _, raw = key.partition("/") + from urllib.parse import unquote + + path = unquote(raw) + if request.method == "PUT": + objects[path] = request.content + return httpx.Response(200, json=pm.storage_meta(path, len(request.content))) + if request.method == "DELETE": + if path not in objects: + return httpx.Response(404, json=pm.manager_error_body("not_found", "missing")) + objects.pop(path) + return httpx.Response(200, json={}) + if kind == "meta": + if path not in objects: + return httpx.Response(404, json=pm.manager_error_body("not_found", "missing")) + return httpx.Response(200, json=pm.storage_meta(path, len(objects[path]))) + if kind == "objects" and not path: + prefix = request.url.params.get("prefix") or "" + rows = [ + pm.storage_meta(name, len(blob)) for name, blob in sorted(objects.items()) if name.startswith(prefix) + ] + return httpx.Response(200, json={"objects": rows, "next_cursor": None}) + if path not in objects: + return httpx.Response(404, json=pm.manager_error_body("not_found", "missing")) + return httpx.Response(200, content=objects[path]) + + return mock_transport(pm.RecordingTransport(handler)) + + +@pytest.fixture(params=["local", "remote"]) +def backend(request, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mock_transport) -> str: + if request.param == "local": + monkeypatch.setenv("BISHENG_APP_STORAGE_DIR", str(tmp_path / "attachments")) + else: + monkeypatch.setenv("BISHENG_APP_STORAGE_ENDPOINT", pm.STORAGE_ENDPOINT) + monkeypatch.setenv("BISHENG_APP_STORAGE_TOKEN", pm.FAKE_STORAGE_TOKEN) + _fake_remote_store(mock_transport) + return request.param + + +def test_the_same_script_runs_on_both_backends(backend: str): + meta = storage.put("报告/2026.pdf", b"hello world") + assert (meta.path, meta.size) == ("报告/2026.pdf", 11) + assert storage.stat("报告/2026.pdf").size == 11 + assert [row.path for row in storage.list("报告/")] == ["报告/2026.pdf"] + assert storage.get("报告/2026.pdf") == b"hello world" + storage.delete("报告/2026.pdf") + with pytest.raises(AttachmentNotFoundError): + storage.get("报告/2026.pdf") + + +def test_path_is_validated_before_any_io(backend: str): + with pytest.raises(InvalidAttachmentPathError): + storage.put("../escape", b"x") + with pytest.raises(InvalidAttachmentPathError): + storage.list("apps/") + + +def test_six_functions_and_six_async_twins_exist(): + """每个同步函数都要有异步孪生——少一个,用 FastAPI 的应用就只能在事件循环里阻塞。""" + for name in ("put", "get", "open", "stat", "list", "delete"): + assert callable(getattr(storage, name)) + twin = f"a{name}" + assert inspect.iscoroutinefunction(getattr(storage, twin, None)), f"缺异步孪生 {twin}" + + +def test_handle_is_resolved_per_call_not_cached(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + first = tmp_path / "one" + monkeypatch.setenv("BISHENG_APP_STORAGE_DIR", str(first)) + storage.put("a.txt", b"1") + + second = tmp_path / "two" + monkeypatch.setenv("BISHENG_APP_STORAGE_DIR", str(second)) + storage.put("b.txt", b"2") + + assert (first / "a.txt").is_file() + assert (second / "b.txt").is_file() + assert [row.path for row in storage.list()] == ["b.txt"] + + +async def test_async_roundtrip_on_the_local_backend(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("BISHENG_APP_STORAGE_DIR", str(tmp_path / "attachments")) + await storage.aput("a.txt", b"hi") + assert await storage.aget("a.txt") == b"hi" + with await storage.aopen("a.txt") as handle: + assert handle.read() == b"hi" + assert [row.path for row in await storage.alist()] == ["a.txt"] + await storage.adelete("a.txt") + with pytest.raises(AttachmentNotFoundError): + await storage.astat("a.txt") diff --git a/src/bisheng-sdk/tests/test_storage_local.py b/src/bisheng-sdk/tests/test_storage_local.py new file mode 100644 index 0000000000..69fea53a8f --- /dev/null +++ b/src/bisheng-sdk/tests/test_storage_local.py @@ -0,0 +1,130 @@ +"""本地目录后端 —— `bisheng dev` 期的附件空间(AC-20 / 21 / 22 / 23 / 24 / 25)。""" + +from __future__ import annotations + +import io +from datetime import datetime +from pathlib import Path + +import pytest + +from bisheng_sdk import storage +from bisheng_sdk.errors import ( + AttachmentNotFoundError, + AttachmentTooLargeError, + InvalidAttachmentPathError, + StorageHandleMissingError, +) + + +@pytest.fixture +def local_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """`bisheng dev` 今天还没注入 `BISHENG_APP_STORAGE_DIR`,用例自己造句柄。""" + root = tmp_path / "attachments" + monkeypatch.setenv("BISHENG_APP_STORAGE_DIR", str(root)) + return root + + +def test_no_handle_at_all_is_an_error_not_a_temp_dir(): + with pytest.raises(StorageHandleMissingError): + storage.put("a.txt", b"x") + + +def test_both_handles_is_refused_rather_than_guessed(local_dir: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("BISHENG_APP_STORAGE_ENDPOINT", "http://manager.test/v1/apps/app-1/storage") + monkeypatch.setenv("BISHENG_APP_STORAGE_TOKEN", "t") + with pytest.raises(StorageHandleMissingError) as caught: + storage.put("a.txt", b"x") + assert "无法判断" in str(caught.value) + + +def test_roundtrip_put_stat_list_get_delete(local_dir: Path): + meta = storage.put("报告/2026.pdf", b"hello world") + assert meta.path == "报告/2026.pdf" + assert meta.size == 11 + assert meta.content_type == "application/pdf" + assert isinstance(meta.modified_at, datetime) + + assert storage.stat("报告/2026.pdf").size == 11 + assert storage.get("报告/2026.pdf") == b"hello world" + with storage.open("报告/2026.pdf") as handle: + assert handle.read() == b"hello world" + + storage.put("其它/x.txt", b"x") + rows = storage.list("报告/") + assert [row.path for row in rows] == ["报告/2026.pdf"] + assert [row.path for row in storage.list()] == ["其它/x.txt", "报告/2026.pdf"] + + storage.delete("报告/2026.pdf") + with pytest.raises(AttachmentNotFoundError): + storage.get("报告/2026.pdf") + + +@pytest.mark.parametrize("payload", [b"hello world", io.BytesIO(b"hello world"), "hello world"]) +def test_put_accepts_bytes_file_objects_and_text(local_dir: Path, payload): + assert storage.put("a.bin", payload).size == 11 + + +def test_put_accepts_a_pathlike_source(local_dir: Path, tmp_path: Path): + source = tmp_path / "src.bin" + source.write_bytes(b"hello world") + assert storage.put("a.bin", source).size == 11 + + +def test_delete_of_a_missing_attachment_is_an_error(local_dir: Path): + with pytest.raises(AttachmentNotFoundError): + storage.delete("nope.txt") + + +def test_put_is_atomic_and_leaves_no_temp_file(local_dir: Path): + class Exploding: + def read(self, size: int = -1) -> bytes: + raise OSError("disk gone") + + with pytest.raises(OSError): + storage.put("a.bin", Exploding()) + assert list(local_dir.rglob("*")) == [] or all(not p.name.endswith(".bstmp") for p in local_dir.rglob("*")) + with pytest.raises(AttachmentNotFoundError): + storage.stat("a.bin") + + +def test_real_disk_location_never_reaches_the_app(local_dir: Path): + meta = storage.put("报告/2026.pdf", b"x") + assert str(local_dir) not in f"{meta}" + with pytest.raises(AttachmentNotFoundError) as caught: + storage.get("报告/missing.pdf") + assert str(local_dir) not in str(caught.value) + + +def test_single_file_cap_applies_locally_when_injected(local_dir: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("BISHENG_APP_STORAGE_MAX_FILE_MB", "1") + with pytest.raises(AttachmentTooLargeError) as caught: + storage.put("big.bin", b"x" * (1024 * 1024 + 1)) + assert caught.value.limit_bytes == 1024 * 1024 + # 拒绝而不是截断:什么都不该落盘。 + with pytest.raises(AttachmentNotFoundError): + storage.stat("big.bin") + + +def test_without_the_cap_variable_local_is_unlimited(local_dir: Path): + assert storage.put("big.bin", b"x" * (2 * 1024 * 1024)).size == 2 * 1024 * 1024 + + +def test_invalid_path_never_touches_the_disk(local_dir: Path): + with pytest.raises(InvalidAttachmentPathError): + storage.put("../escape.txt", b"x") + assert not local_dir.exists() or list(local_dir.rglob("*")) == [] + + +def test_directory_is_created_lazily_on_first_put(local_dir: Path): + assert not local_dir.exists() + storage.put("a.txt", b"x") + assert local_dir.is_dir() + + +def test_listing_an_empty_space_is_an_empty_list(local_dir: Path): + assert storage.list() == [] + + +def test_no_clear_or_prefix_delete_api(): + assert {"clear", "delete_prefix", "delete_many", "purge", "url", "presign", "share"}.isdisjoint(set(dir(storage))) diff --git a/src/bisheng-sdk/tests/test_storage_remote.py b/src/bisheng-sdk/tests/test_storage_remote.py new file mode 100644 index 0000000000..dc743a31f2 --- /dev/null +++ b/src/bisheng-sdk/tests/test_storage_remote.py @@ -0,0 +1,230 @@ +"""平台存储后端 —— 附件 API 契约的可执行快照(AC-20 / 21 / 22 / 25 / 31)。 + +每个形状都照 `runtime_manager/api/storage.py` 逐字构造,包括看着像 bug 的那几处: +`/meta/{key}` 不是 `/stat/`、DELETE 成功答 `200 {}`、删缺失的附件是 404、错误信封 +是 `{"detail": {...}}`。 +""" + +from __future__ import annotations + +import io + +import httpx +import pytest + +from bisheng_sdk import storage +from bisheng_sdk.errors import ( + AttachmentNotFoundError, + AttachmentTooLargeError, + InvalidAttachmentPathError, + StorageHandleMissingError, + StorageHandleRejectedError, + StorageUnavailableError, +) +from tests.helpers import platform_mock as pm + +ENCODED = "%E6%8A%A5%E5%91%8A/2026.pdf" + + +@pytest.fixture +def remote_env(monkeypatch: pytest.MonkeyPatch) -> str: + monkeypatch.setenv("BISHENG_APP_STORAGE_ENDPOINT", pm.STORAGE_ENDPOINT) + monkeypatch.setenv("BISHENG_APP_STORAGE_TOKEN", pm.FAKE_STORAGE_TOKEN) + monkeypatch.setenv("BISHENG_APP_STORAGE_MAX_FILE_MB", "20") + return pm.STORAGE_ENDPOINT + + +@pytest.fixture +def remote(mock_transport, remote_env): + def install(handler): + return mock_transport(pm.RecordingTransport(handler)) + + return install + + +def test_endpoint_without_token_is_an_incomplete_handle(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("BISHENG_APP_STORAGE_ENDPOINT", pm.STORAGE_ENDPOINT) + with pytest.raises(StorageHandleMissingError) as caught: + storage.stat("a.txt") + assert "不完整" in str(caught.value) + + +def test_put_sends_raw_bytes_with_bearer_and_content_length(remote): + transport = remote(lambda request: httpx.Response(200, json=pm.storage_meta())) + meta = storage.put("报告/2026.pdf", b"hello world", content_type="application/pdf") + + request = transport.last + assert request.method == "PUT" + assert request.url.raw_path.decode().endswith(f"/storage/objects/{ENCODED}") + assert request.headers["Authorization"] == f"Bearer {pm.FAKE_STORAGE_TOKEN}" + assert request.headers["Content-Type"] == "application/pdf" + assert request.headers["Content-Length"] == "11" + assert request.content == b"hello world" # 原始字节,不是 multipart + assert meta.path == "报告/2026.pdf" + assert meta.etag == "abc123" + assert meta.modified_at is not None + + +def test_put_streams_a_file_object(remote): + transport = remote(lambda request: httpx.Response(200, json=pm.storage_meta(size=5))) + storage.put("a.bin", io.BytesIO(b"hello")) + assert transport.last.headers["Content-Length"] == "5" + + +def test_get_stat_list_delete_wire_shapes(remote): + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if request.method == "GET" and "/meta/" in path: + return httpx.Response(200, json=pm.storage_meta()) + if request.method == "GET" and path.endswith("/objects"): + cursor = request.url.params.get("cursor") + if not cursor: + return httpx.Response(200, json={"objects": [pm.storage_meta("a.txt")], "next_cursor": "a.txt"}) + return httpx.Response(200, json={"objects": [pm.storage_meta("b.txt")], "next_cursor": None}) + if request.method == "GET": + return httpx.Response(200, content=b"hello world") + if request.method == "DELETE": + return httpx.Response(200, json={}) # 不是 204 + raise AssertionError(request.method) + + transport = remote(handler) + + assert storage.get("报告/2026.pdf") == b"hello world" + assert storage.stat("报告/2026.pdf").size == 11 + assert [row.path for row in storage.list()] == ["a.txt", "b.txt"] # 跟着 next_cursor 翻页 + storage.delete("报告/2026.pdf") + + meta_request = next(r for r in transport.requests if "/meta/" in r.url.path) + assert meta_request.url.raw_path.decode().endswith(f"/storage/meta/{ENCODED}") + + +def test_list_query_parameters_follow_the_manager_contract(remote): + transport = remote(lambda request: httpx.Response(200, json={"objects": [], "next_cursor": None})) + storage.list("报告/", limit=5) + params = transport.last.url.params + assert params["prefix"] == "报告/" + assert params["limit"] == "5" + + +@pytest.mark.parametrize( + ("status", "body", "expected"), + [ + ( + 401, + pm.manager_error_body("unauthorized", "invalid storage token for this application"), + StorageHandleRejectedError, + ), + (404, pm.manager_error_body("not_found", "attachment does not exist"), AttachmentNotFoundError), + (400, pm.manager_error_body("invalid_object_key", "reserved namespace"), InvalidAttachmentPathError), + (413, pm.manager_error_body("payload_too_large", "too big", max_file_mb=20), AttachmentTooLargeError), + (503, pm.manager_error_body("storage_unavailable", "minio not configured"), StorageUnavailableError), + ], +) +def test_manager_machine_codes_map_one_to_one(remote, status, body, expected): + remote(lambda request: httpx.Response(status, json=body)) + with pytest.raises(expected): + storage.stat("a.txt") + + +def test_delete_of_a_missing_attachment_is_404_not_silent_success(remote): + remote(lambda request: httpx.Response(404, json=pm.manager_error_body("not_found", "missing"))) + with pytest.raises(AttachmentNotFoundError): + storage.delete("a.txt") + + +def test_list_during_an_outage_raises_instead_of_returning_an_empty_list(remote): + remote(lambda request: httpx.Response(503, json=pm.manager_error_body("storage_unavailable", "minio down"))) + with pytest.raises(StorageUnavailableError) as caught: + storage.list() + assert "attachment_storage" in caught.value.next_step + + +@pytest.fixture +def unreachable_manager(monkeypatch: pytest.MonkeyPatch, remote_env): + """附件端点连不上——坑 27 最常见的生产故障(manager 只听 127.0.0.1)。""" + from bisheng_sdk import _http + + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("no route to host", request=request) + + transport = httpx.MockTransport(handler) + monkeypatch.setattr( + _http, "client", lambda base_url, kind="retrieve": httpx.Client(base_url=base_url, transport=transport) + ) + monkeypatch.setattr( + _http, + "aclient", + lambda base_url, kind="retrieve": httpx.AsyncClient(base_url=base_url, transport=transport), + ) + + +def test_connect_failure_is_a_storage_outage_not_a_platform_outage(unreachable_manager): + """附件端点与平台 API 是两个地址、两套排查动作(坑 27)。 + + 翻成 `PlatformUnreachableError` 会把人支去查 `BISHENG_PLATFORM_API_BASE`,而真正 + 要看的是 `runtime/status` 的 `attachment_storage` 自检项与 `RTM_APP_FACING_BASE_URL`。 + AC-25 要求这几类错误彼此可区分,这一条就是分界线。 + """ + from bisheng_sdk.errors import PlatformUnreachableError + + for call in (lambda: storage.list(), lambda: storage.stat("a.txt"), lambda: storage.put("a.txt", b"x")): + with pytest.raises(StorageUnavailableError) as caught: + call() + assert not isinstance(caught.value, PlatformUnreachableError) + assert "attachment_storage" in caught.value.next_step + + +async def test_async_connect_failure_is_also_a_storage_outage(unreachable_manager): + with pytest.raises(StorageUnavailableError): + await storage.alist() + + +def test_client_side_cap_refuses_before_sending(remote): + transport = remote(lambda request: httpx.Response(200, json=pm.storage_meta())) + with pytest.raises(AttachmentTooLargeError) as caught: + storage.put("big.bin", b"x" * (20 * 1024 * 1024 + 1)) + assert caught.value.limit_bytes == 20 * 1024 * 1024 + assert transport.requests == [], "超限的附件一个字节都不该离开本进程" + + +def test_meta_carries_no_bucket_key_endpoint_or_token(remote): + remote(lambda request: httpx.Response(200, json=pm.storage_meta())) + meta = storage.stat("报告/2026.pdf") + fields = set(meta.__dataclass_fields__) + assert fields == {"path", "size", "content_type", "modified_at", "etag"} + rendered = repr(meta) + assert pm.FAKE_STORAGE_TOKEN not in rendered + assert "bisheng-apps" not in rendered + assert "apps/" not in rendered + + +def test_app_id_is_never_sent_by_the_client(remote): + transport = remote(lambda request: httpx.Response(200, json=pm.storage_meta())) + storage.put("a.txt", b"x") + request = transport.last + # app_id 只在平台注入的 ENDPOINT 里;SDK 不拼、不改、不发。 + assert "app_id" not in request.url.params + assert b"app_id" not in request.content + assert all("app" not in name.lower() for name in request.headers if name.lower() != "authorization") + + +async def test_async_twins_have_the_same_wire_shape(remote): + transport = remote(lambda request: httpx.Response(200, json=pm.storage_meta())) + meta = await storage.aput("报告/2026.pdf", b"hello world") + assert meta.path == "报告/2026.pdf" + assert transport.last.headers["Authorization"] == f"Bearer {pm.FAKE_STORAGE_TOKEN}" + + +async def test_async_stat_open_and_delete(remote): + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "DELETE": + return httpx.Response(200, json={}) + if "/meta/" in request.url.path: + return httpx.Response(200, json=pm.storage_meta()) + return httpx.Response(200, content=b"hello world") + + remote(handler) + assert (await storage.astat("a.txt")).size == 11 + with await storage.aopen("a.txt") as handle: + assert handle.read() == b"hello world" + await storage.adelete("a.txt") diff --git a/src/bisheng-sdk/uv.lock b/src/bisheng-sdk/uv.lock new file mode 100644 index 0000000000..88d0c49096 --- /dev/null +++ b/src/bisheng-sdk/uv.lock @@ -0,0 +1,203 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "bisheng-sdk" +version = "0.1.1" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.27,<1.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" }, + { url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" }, + { url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" }, + { url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" }, + { url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" }, + { url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" }, + { url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] diff --git a/src/frontend/AGENTS.md b/src/frontend/AGENTS.md new file mode 100644 index 0000000000..2b98af4459 --- /dev/null +++ b/src/frontend/AGENTS.md @@ -0,0 +1,28 @@ +# Frontend Rules — Shared (Platform + Client) + +Auto-loaded when editing any file under `src/frontend/`. Root `AGENTS.md §4` points here; per-app detail lives in `platform/AGENTS.md` and `client/AGENTS.md`. + +Two React apps that **must not be mixed**: +- `src/frontend/platform/AGENTS.md` — Admin/builder UI (Zustand, @tanstack/react-query v4, bs-ui, `@/`) +- `src/frontend/client/AGENTS.md` — End-user chat UI (Recoil, react-query v4, shadcn, `~/`) + +**Design specs & component library — mandatory entry for ANY UI work (new pages AND edits to existing ones):** +- **Read the design spec docs first.** They are the SSOT for "when to use what" (typography, color, radius/shadow, multi-device, per-component usage rules). They live at `src/frontend/packages/ui/docs/` (git-tracked; entry `index.md`, specs in `基础-*` / `组件-*` files; browsable as a site via `pnpm dev:ui` at `src/frontend/`). The specs are actively maintained and keep evolving — read the current version each time; never code from memory of an old read. +- **Component selection**: when a UI needs a component, use a **landed** one from the component overview (`src/frontend/packages/ui/docs/components/index.mdx` — i.e. pages marked `status: done`, shipped in `@bisheng/ui`). If the component you need is not finalized or not built yet, do NOT invent a new pattern — find the closest existing implementation in the current client code and follow it. +- **Ownership**: design spec content and component visual styles belong to the designer. Non-designers must not edit spec decisions, change component styles, or add new components on their own. Frontend engineers MAY improve existing component internals (refactor, perf, a11y), but any style/visual change requires designer sign-off. + +**Hard rules (both apps — single source of truth here — this file; per-app files add only app-specific detail):** +- TypeScript only (`.ts` / `.tsx`); functional components only; no class components. +- Single file ≤ 600 lines. Extract sub-components or hooks when exceeded. +- `interface` for Props; `type` for internal types. `handleXxx` internal handlers / `onXxx` props. PascalCase components, camelCase utilities/hooks. +- Named exports for components (`export function`); no default exports. Minimize `any` — if unavoidable, `// eslint-disable-next-line` + a one-line reason. +- **Never** `import axios` directly — use the wrapped request module. (store must not call HTTP = constitution **C7**) +- **Never** introduce new UI or state-management libraries. +- **Shared list infrastructure**: list sorting uses `@hello-pangea/dnd` and file-preview virtualization uses `react-window`, both pinned in the workspace catalog. Do not reintroduce `react-beautiful-dnd` or `react-virtualized`. Client's existing `react-dnd` remains for its distinct drag/drop use cases; it is not replaced by the list-sorting library. Keep file-upload drop handling on its existing single owner. +- All code comments in English. +- 403 handled automatically by response interceptors — never add 403 branches in business code. +- **A Radix `` must never receive an empty-string `value`** — it throws and takes the whole page down with it. Any selector rendering a backend list has to filter out rows whose id is `null` or `''` before mapping them to items. Do not rely on the backend to sanitize: older backend versions do send such rows, and the crash surfaces to the user as a blank page rather than an empty dropdown. +- **UI copy is written in plain language and does not borrow internal object names.** Where the spec says `PermissionModel`, the interface needs a separate business word: check what comparable products call it, then confirm the term isn't already taken by another concept on the platform. Don't coin abbreviations — read the sentence aloud, and if it sounds wrong it is wrong. Buttons take short verbs, fields take noun phrases, and explanatory sentences belong in a hint rather than crammed into a control's label. When copy refuses to come out clear, what's usually missing is a mechanism the page never explains — fix that first instead of renaming. +- **No frosted glass by default** — no `backdrop-filter` / `backdrop-blur-*`, including arbitrary values (`backdrop-blur-[4px]`), variant prefixes (`hover:backdrop-blur-sm`) and the arbitrary-property form `[backdrop-filter:blur(…)]`. Every such element gets its own compositing layer and re-snapshots + re-blurs its backdrop each frame; without GPU acceleration — the norm on 信创 machines — that runs on the CPU. A customer on 信保安全浏览器 (Chromium 108) had scrolling *and mouse movement* stall in daily chat; forcing `backdrop-filter: none` fixed it outright. Cost is **per element, not per radius**: a 4px blur on a 24px button costs the same order as a full-screen one, and the per-message action buttons multiplied it by conversation length. Use a translucent background instead (`bg-white/80`, `bg-black/40`) — past ~70% opacity the blur was invisible anyway. **Only exception**, and it must be justified: a full-screen overlay of which at most one exists at a time, verified on a 信创 browser. Never on anything that scales with content (list rows, message bubbles, cards, notification items). Rationale + alternatives: `packages/ui/docs/基础-阴影与圆角规范.mdx` §3. +- **i18n**: no hardcoded Chinese in source (lint-enforced; legacy frozen). New keys ship all three languages (zh-Hans/en/ja) in the same PR. Error-code copy lives ONLY in `src/frontend/packages/locales` (`api_errors` domain — platform addresses `api_errors:`, client `api_errors.`); its generated artifacts (`platform/public/locales/*/api_errors.json`, `client/src/locales/*/api_errors.gen.json`) are never edited by hand (CI-checked). CI also runs `pnpm check-i18n` — key parity across languages + backend error-code coverage; legacy drift is frozen in `scripts/i18n-baseline.json` (shrink-only, `--update-baseline` after healing). Legacy hardcoded Chinese is paid down by whoever touches the file: when editing a file with frozen violations, extract its Chinese strings to i18n (`/i18n-localizer`) in the same change. See `packages/locales/README.md`. +- **Quality gate (CI-enforced, `frontend-quality.yml`)**: `pnpm lint` + `pnpm typecheck` (run from `src/frontend/`) must pass. Legacy violations are frozen — ESLint in each app's `eslint-suppressions.json`, TS strict via `// @ts-strict-ignore` file headers — and may only shrink: never hand-edit the suppressions file, never add `@ts-strict-ignore` to a new file. After fixing violations in a file, run `pnpm lint:prune` (per app) and delete its `@ts-strict-ignore` header if it now passes strict. diff --git a/src/frontend/CLAUDE.md b/src/frontend/CLAUDE.md new file mode 120000 index 0000000000..47dc3e3d86 --- /dev/null +++ b/src/frontend/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/src/frontend/client/AGENTS.md b/src/frontend/client/AGENTS.md index 2353c445f4..92b027a300 100644 --- a/src/frontend/client/AGENTS.md +++ b/src/frontend/client/AGENTS.md @@ -1,7 +1,7 @@ # Frontend Rules — Client (End-user Chat UI) Auto-loaded when editing files in `src/frontend/client/`. Base path: `/workspace`. -Cross-app boundary + hard rules common to both apps: root `AGENTS.md §4` (single source — not repeated here). The store-must-not-call-HTTP law: `docs/constitution.md` C7 (arch-guard RULE-6). +Cross-app boundary + hard rules common to both apps: `src/frontend/AGENTS.md` (single source — not repeated here). The store-must-not-call-HTTP law: `docs/constitution.md` C7 (arch-guard RULE-6). ## Commands (cwd: `src/frontend/client/`) @@ -10,14 +10,16 @@ pnpm install # run at src/frontend/ (pnpm workspace root; npm is disabled) pnpm dev # dev server on :4001 (or `pnpm dev:client` from the workspace root) ``` +Both apps extend `../config/` for TypeScript, ESLint, PostCSS and Vite proxy helpers. Shared dependency versions are managed by the workspace catalog. See `../config/README.md`. + ## Tech Stack Vite 6 + React 18 + TypeScript + TailwindCSS 3 + Radix UI (shadcn/ui) + **Recoil** + **react-query v4 (@tanstack)** + react-i18next + react-router-dom v6 + **lucide-react** + axios (wrapped in `~/api/request.ts`) ## Mandatory Rules (client-specific — common hard rules in root §4) - **Path Aliases**: `~/` (or `@/`) → `src/`. - **HTTP Requests**: wrapper is `~/api/request.ts`. -- **State Management**: **Recoil is FROZEN** (archived by Meta; ledger #5) — no new atoms/selectors/`recoil` imports (lint-enforced via `no-restricted-imports`; existing usage is suppressed). Server state → react-query v4; local state → `useState`/props. If a feature genuinely needs new cross-page client state, raise it with shanghang (Jotai migration decision pending) — do not work around with Context or new libraries. -- **UI Components**: shared library `@bisheng/ui` (`src/frontend/packages/ui/`) first — components migrated there (Button, …) keep re-export shims at `~/components/ui/` so both import paths work; everything else still lives in `~/components/ui/` (shadcn / Radix-based). +- **State Management**: **Recoil is FROZEN** (archived by Meta; ledger #5) — no new atoms/selectors/`recoil` imports (lint-enforced via `no-restricted-imports`; existing usage is suppressed). Keep existing Recoil state in place; no blanket migration. Server state → react-query v4; local state → `useState`/props. New cross-page client state may use **Zustand**, preferred to align with platform. This permits incremental Zustand adoption, not migration of existing Recoil state. Keep each state under one owner; do not mirror it between Recoil and Zustand or bypass these boundaries with Context or another state library. +- **UI Components**: shared library `@bisheng/ui` (`src/frontend/packages/ui/`) first — selection controls, Textarea, Tag/Badge, Tooltip and Tabs import directly from the shared library, without legacy implementations or compatibility exports. Older Button/Input entry points still delegate to the shared library. Rich hover information uses the existing HoverCard; other families remain in `~/components/ui/` (shadcn / Radix-based). - **Icons**: prefer `bisheng-icons` — `import { Outlined } from 'bisheng-icons'` → `` (variants `Outlined` / `Filled` / `Colored`). Use `lucide-react` ONLY as a fallback when `bisheng-icons` has no matching-semantic icon. - **⚠️ After upgrading `bisheng-icons`**, clear the Vite pre-bundle cache or new icons crash the page (`Element type is invalid`): `pnpm dev -- --force` (or `rm -rf node_modules/.vite && pnpm dev`). Its git-source `exports` field defeats Vite's dep-change detection, so the stale pre-bundled snapshot is served unless forced. - **File preview**: reuse what exists, never hand-roll a viewer. `~/pages/knowledge/FilePreview/` is the full set (`viewers/` covers pdf / docx / xlsx·csv / md / html / image / text, with the PDF worker already wired) and takes a `compactMode` prop for embedding in a panel; the Excel and image internals are further shared with platform via `@bisheng/file-viewers`. A new preview surface should pass file name / type / url into the existing component. @@ -38,6 +40,8 @@ Vite 6 + React 18 + TypeScript + TailwindCSS 3 + Radix UI (shadcn/ui) + **Recoil - Neutral colors (new code): semantic tokens `text-text-1…4` / `bg-fill-1…4` / `border-border-base|-deep` / `success|warning|danger` — never `text-gray-*` or hex (基础-色彩规范.md). - Hover/touch: plain `hover:` classes ONLY (`hoverOnlyWhenSupported` disables them on touch app-wide) — **never invent hover variant prefixes**; touch press via `coarse-pointer:active:`; hover/active shade stays within the base color's own ramp (no cross-palette graying). +- **Frosted glass exception switch**: the root §4 ban stands; the one sanctioned exception (a full-screen overlay, at most one at a time) goes through `~/utils/frostedGlass` — `cn(FROSTED_GLASS_ENABLED && FROSTED_GLASS_CLASS)`. `backdrop-filter` is written ONLY in `style.css` under `.bs-frosted-glass`; never add it to a component. `VITE_FROSTED_GLASS=false` turns every instance off for 信创 deployments. Currently used by: knowledge-space drag-upload overlay (`pages/knowledge/index.tsx`), chat drag-drop overlay (`components/Chat/Input/Files/DragDropOverlay.tsx`). + ## Known Pitfalls - **The MinIO dev-proxy target host must match the backend's `object_storage.minio.sharepoint` verbatim.** Presigned URLs sign `host` (`X-Amz-SignedHeaders=host`), so `localhost:9000` and `127.0.0.1:9000` are different signatures and the mismatch returns `403 SignatureDoesNotMatch`, not a connection error. Set `VITE_DEV_MINIO_TARGET` to whatever the backend actually advertises; `client/vite.config.ts` already warns on a detected mismatch. The MinIO proxy and the API proxy are separate targets on separate ports — pointing the MinIO one at the backend port is a regression that whole-file reformatting commits have introduced before. - **A `srcDoc` iframe with `sandbox="allow-scripts"` is an opaque origin, and reading `localStorage` there throws.** The throw is fatal rather than local: generated pages typically read storage at the top of their main script, so the exception aborts the rest of it and no keyboard/wheel/touch handlers ever get bound — the fingerprint is "renders fine, some buttons work, all keyboard interaction dead", while the same file opened from disk behaves normally. `~/utils/sandboxedHtml.ts` already solves this with an in-memory storage shim; use `buildSandboxedSrcDoc` rather than re-deriving a fix. **Never add `allow-same-origin`** — `srcDoc` would then inherit this app's origin, turning every externally generated page into an XSS vector (guarded by `sandboxedHtml.test.ts`). diff --git a/src/frontend/client/eslint-suppressions.json b/src/frontend/client/eslint-suppressions.json index d083a066dd..5adef7dec2 100644 --- a/src/frontend/client/eslint-suppressions.json +++ b/src/frontend/client/eslint-suppressions.json @@ -1,7 +1,7 @@ { "postcss.config.cjs": { "@typescript-eslint/no-require-imports": { - "count": 2 + "count": 1 } }, "src/@types/chat.ts": { @@ -16,7 +16,7 @@ }, "src/api/apps.ts": { "@typescript-eslint/no-explicit-any": { - "count": 15 + "count": 14 }, "@typescript-eslint/no-unused-expressions": { "count": 2 @@ -65,7 +65,7 @@ }, "src/api/chatApi.ts": { "@typescript-eslint/no-explicit-any": { - "count": 15 + "count": 10 } }, "src/api/index.ts": { @@ -75,23 +75,15 @@ }, "src/api/knowledge.ts": { "@typescript-eslint/no-explicit-any": { - "count": 64 + "count": 62 }, "@typescript-eslint/no-unused-vars": { "count": 1 - }, - "no-restricted-syntax": { - "count": 3 - } - }, - "src/api/knowledgeParseFailureMessage.ts": { - "no-restricted-syntax": { - "count": 7 } }, "src/api/linsight.ts": { "@typescript-eslint/no-explicit-any": { - "count": 14 + "count": 13 }, "no-console": { "count": 1 @@ -120,17 +112,6 @@ "count": 1 } }, - "src/api/request.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - }, - "no-console": { - "count": 1 - }, - "no-restricted-syntax": { - "count": 1 - } - }, "src/api/user.ts": { "@typescript-eslint/no-explicit-any": { "count": 10 @@ -243,14 +224,8 @@ "@typescript-eslint/no-explicit-any": { "count": 11 }, - "@typescript-eslint/no-unused-vars": { - "count": 2 - }, "no-restricted-imports": { "count": 1 - }, - "no-restricted-syntax": { - "count": 1 } }, "src/components/Chat/AiChatMessages.tsx": { @@ -268,34 +243,20 @@ "@typescript-eslint/no-explicit-any": { "count": 3 }, - "@typescript-eslint/no-unused-vars": { - "count": 3 - }, "no-misleading-character-class": { "count": 1 } }, - "src/components/Chat/AiModelSelect.tsx": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - }, - "react-hooks/exhaustive-deps": { - "count": 1 - } - }, "src/components/Chat/ChatView.tsx": { "@typescript-eslint/no-explicit-any": { - "count": 26 + "count": 12 }, "@typescript-eslint/no-unused-vars": { - "count": 2 + "count": 1 }, "no-restricted-imports": { "count": 1 }, - "no-restricted-syntax": { - "count": 2 - }, "react-hooks/exhaustive-deps": { "count": 2 } @@ -321,19 +282,13 @@ } }, "src/components/Chat/Input/AgentToolSelector.tsx": { - "@typescript-eslint/no-unused-vars": { - "count": 2 - }, "no-restricted-imports": { "count": 1 } }, "src/components/Chat/Input/AttachmentBar.tsx": { "@typescript-eslint/no-explicit-any": { - "count": 13 - }, - "react-hooks/rules-of-hooks": { - "count": 1 + "count": 5 } }, "src/components/Chat/Input/AudioRecorder.tsx": { @@ -341,23 +296,9 @@ "count": 1 } }, - "src/components/Chat/Input/ChatFormTools.tsx": { - "@typescript-eslint/no-unused-vars": { - "count": 3 - }, - "no-restricted-imports": { - "count": 1 - }, - "react-hooks/exhaustive-deps": { - "count": 1 - } - }, "src/components/Chat/Input/ChatKnowledge.tsx": { "@typescript-eslint/no-explicit-any": { - "count": 11 - }, - "@typescript-eslint/no-unused-vars": { - "count": 4 + "count": 10 } }, "src/components/Chat/Input/Files/FileFormWrapper.tsx": { @@ -452,24 +393,11 @@ "count": 2 } }, - "src/components/Chat/Input/Mention.tsx": { - "no-restricted-imports": { - "count": 2 - } - }, "src/components/Chat/Input/PopoverButtons.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/components/Chat/Input/PromptsCommand.tsx": { - "jsx-a11y/no-autofocus": { - "count": 1 - }, - "no-restricted-imports": { - "count": 2 - } - }, "src/components/Chat/Input/StreamAudio.tsx": { "jsx-a11y/media-has-caption": { "count": 1 @@ -496,10 +424,7 @@ }, "src/components/Chat/Landing.tsx": { "@typescript-eslint/no-unused-vars": { - "count": 3 - }, - "no-console": { - "count": 1 + "count": 2 } }, "src/components/Chat/Menus/BookmarkMenu.tsx": { @@ -533,54 +458,24 @@ "count": 1 } }, - "src/components/Chat/MessageFeedbackButtons.tsx": { - "no-restricted-syntax": { - "count": 4 - } - }, - "src/components/Chat/MessageSelection/ExportFormatSheet.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - }, - "no-restricted-syntax": { - "count": 1 - } - }, "src/components/Chat/MessageSelection/ExportSelectionButton.tsx": { "no-restricted-syntax": { "count": 4 } }, - "src/components/Chat/MessageSelection/MessageSelectionToolbar.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - }, - "no-restricted-syntax": { - "count": 2 - } - }, "src/components/Chat/Messages/Content/ActionIcon.tsx": { "react/no-unknown-property": { "count": 2 } }, "src/components/Chat/Messages/Content/CitationDocumentPreviewDrawer.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - }, "no-restricted-imports": { "count": 1 } }, "src/components/Chat/Messages/Content/CitationReferencesDrawer.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - }, "no-restricted-imports": { "count": 1 - }, - "react-hooks/exhaustive-deps": { - "count": 1 } }, "src/components/Chat/Messages/Content/CodeAnalyze.tsx": { @@ -624,12 +519,9 @@ "count": 2 }, "@typescript-eslint/no-explicit-any": { - "count": 3 - }, - "no-restricted-imports": { "count": 1 }, - "no-useless-escape": { + "no-restricted-imports": { "count": 1 } }, @@ -698,11 +590,6 @@ "count": 1 } }, - "src/components/Chat/Messages/Content/citationUtils.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 4 - } - }, "src/components/Chat/Messages/Content/useCitationReferencePanel.tsx": { "react-hooks/exhaustive-deps": { "count": 1 @@ -745,7 +632,7 @@ }, "src/components/Chat/Messages/MessagesView.tsx": { "@typescript-eslint/no-unused-vars": { - "count": 7 + "count": 5 }, "no-restricted-imports": { "count": 1 @@ -780,9 +667,6 @@ "src/components/Chat/Messages/ToolCallDisplay.tsx": { "@typescript-eslint/no-explicit-any": { "count": 8 - }, - "no-restricted-syntax": { - "count": 13 } }, "src/components/Chat/Messages/ui/MessageRender.tsx": { @@ -801,11 +685,6 @@ "count": 1 } }, - "src/components/Chat/attachments/MediaAttachmentChip.tsx": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - } - }, "src/components/Conversations/Convo.tsx": { "@typescript-eslint/no-unused-vars": { "count": 2 @@ -816,7 +695,7 @@ }, "src/components/Conversations/ConvoOptions/ConvoOptions.tsx": { "@typescript-eslint/no-unused-vars": { - "count": 3 + "count": 1 } }, "src/components/Conversations/Fork.tsx": { @@ -1011,11 +890,6 @@ "count": 1 } }, - "src/components/Linsight/Execution/ToolRowLite.tsx": { - "no-restricted-syntax": { - "count": 7 - } - }, "src/components/Linsight/Execution/clarify.ts": { "@typescript-eslint/no-explicit-any": { "count": 2 @@ -1069,7 +943,7 @@ }, "src/components/Linsight/Input/ModelSelector.tsx": { "@typescript-eslint/no-explicit-any": { - "count": 6 + "count": 4 } }, "src/components/Linsight/Input/TaskModeChatInput.tsx": { @@ -1082,7 +956,7 @@ }, "src/components/Linsight/Input/TaskModeInput.tsx": { "@typescript-eslint/no-explicit-any": { - "count": 22 + "count": 21 }, "no-restricted-imports": { "count": 1 @@ -1125,9 +999,6 @@ }, "no-restricted-imports": { "count": 1 - }, - "no-restricted-syntax": { - "count": 2 } }, "src/components/Nav/MobileNav.tsx": { @@ -1140,19 +1011,10 @@ }, "src/components/Nav/Nav.tsx": { "@typescript-eslint/no-unused-vars": { - "count": 4 - }, - "react-hooks/exhaustive-deps": { - "count": 1 + "count": 3 } }, "src/components/Nav/NewChat.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - }, - "@typescript-eslint/no-unused-vars": { - "count": 2 - }, "no-restricted-imports": { "count": 1 } @@ -1395,47 +1257,6 @@ "count": 1 } }, - "src/components/PreviewFile/DocxFileViewer.tsx": { - "no-useless-escape": { - "count": 1 - } - }, - "src/components/PreviewFile/FileView.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - }, - "@typescript-eslint/no-unused-expressions": { - "count": 3 - }, - "@typescript-eslint/no-unused-vars": { - "count": 5 - }, - "no-console": { - "count": 2 - }, - "no-restricted-syntax": { - "count": 1 - }, - "no-useless-escape": { - "count": 1 - }, - "react-hooks/exhaustive-deps": { - "count": 4 - } - }, - "src/components/PreviewFile/TxtFileViewer.tsx": { - "no-useless-escape": { - "count": 1 - } - }, - "src/components/PreviewFile/index.tsx": { - "no-console": { - "count": 1 - }, - "no-useless-escape": { - "count": 1 - } - }, "src/components/Prompts/AdvancedSwitch.tsx": { "no-restricted-imports": { "count": 1 @@ -1590,11 +1411,6 @@ "count": 1 } }, - "src/components/SidebarListMoreMenu.tsx": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - } - }, "src/components/Sop/Header.tsx": { "@typescript-eslint/no-explicit-any": { "count": 1 @@ -1618,17 +1434,8 @@ } }, "src/components/Voice/SpeechToText.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - }, - "@typescript-eslint/no-unused-vars": { - "count": 1 - }, "no-restricted-imports": { "count": 1 - }, - "react-hooks/exhaustive-deps": { - "count": 4 } }, "src/components/Voice/textToSpeechStore.tsx": { @@ -1669,14 +1476,6 @@ "count": 1 } }, - "src/components/ui/MultiSelect.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - }, - "react-hooks/exhaustive-deps": { - "count": 3 - } - }, "src/components/ui/MultiSelectDropDown.tsx": { "@typescript-eslint/ban-ts-comment": { "count": 1 @@ -2063,47 +1862,6 @@ "count": 2 } }, - "src/hooks/SSE/useAttachmentHandler.ts": { - "no-restricted-imports": { - "count": 1 - } - }, - "src/hooks/SSE/useEventHandlers.ts": { - "no-console": { - "count": 4 - }, - "no-restricted-imports": { - "count": 2 - }, - "react-hooks/exhaustive-deps": { - "count": 1 - } - }, - "src/hooks/SSE/useSSE.ts": { - "@typescript-eslint/ban-ts-comment": { - "count": 3 - }, - "@typescript-eslint/no-unused-expressions": { - "count": 2 - }, - "@typescript-eslint/no-unused-vars": { - "count": 1 - }, - "no-console": { - "count": 6 - }, - "no-restricted-imports": { - "count": 1 - } - }, - "src/hooks/SSE/useStepHandler.ts": { - "no-restricted-imports": { - "count": 1 - }, - "no-restricted-syntax": { - "count": 2 - } - }, "src/hooks/ThemeContext.tsx": { "no-restricted-imports": { "count": 1 @@ -2120,7 +1878,7 @@ "count": 2 }, "no-console": { - "count": 3 + "count": 2 }, "no-empty": { "count": 1 @@ -2180,7 +1938,7 @@ "count": 14 }, "@typescript-eslint/no-unused-vars": { - "count": 5 + "count": 4 }, "no-console": { "count": 4 @@ -2309,19 +2067,16 @@ }, "src/layouts/MainLayout.tsx": { "@typescript-eslint/no-explicit-any": { - "count": 3 + "count": 1 }, "@typescript-eslint/no-unused-vars": { - "count": 5 + "count": 2 }, "no-restricted-imports": { "count": 1 }, - "no-restricted-syntax": { - "count": 2 - }, "react-hooks/exhaustive-deps": { - "count": 2 + "count": 1 } }, "src/locales/i18n.ts": { @@ -2367,7 +2122,7 @@ }, "src/pages/MenuUnavailablePage.tsx": { "@typescript-eslint/no-explicit-any": { - "count": 2 + "count": 1 }, "react-hooks/exhaustive-deps": { "count": 1 @@ -2386,14 +2141,6 @@ "count": 1 } }, - "src/pages/Subscription/AiChat/ArticleAiDock.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - }, - "@typescript-eslint/no-unused-vars": { - "count": 1 - } - }, "src/pages/Subscription/AiChat/FileAiDock.tsx": { "@typescript-eslint/no-explicit-any": { "count": 2 @@ -2441,22 +2188,11 @@ "src/pages/Subscription/ArticleList/ArticleList.tsx": { "no-restricted-imports": { "count": 1 - }, - "react-hooks/exhaustive-deps": { - "count": 3 - } - }, - "src/pages/Subscription/ArticleList/ChannelSwitcher.tsx": { - "@typescript-eslint/no-unused-vars": { - "count": 1 } }, "src/pages/Subscription/ChannelDiscoveryHome.tsx": { "@typescript-eslint/no-explicit-any": { "count": 6 - }, - "no-restricted-syntax": { - "count": 3 } }, "src/pages/Subscription/ChannelLayout.tsx": { @@ -2469,7 +2205,7 @@ }, "src/pages/Subscription/ChannelPreviewDrawer.tsx": { "@typescript-eslint/no-explicit-any": { - "count": 9 + "count": 8 }, "@typescript-eslint/no-unused-vars": { "count": 3 @@ -2507,11 +2243,6 @@ "count": 1 } }, - "src/pages/Subscription/CreateChannel/KnowledgeSyncSection.tsx": { - "no-restricted-syntax": { - "count": 7 - } - }, "src/pages/Subscription/CreateChannel/SyncSpaceItem.tsx": { "@typescript-eslint/no-unused-vars": { "count": 1 @@ -2520,11 +2251,6 @@ "count": 1 } }, - "src/pages/Subscription/Sidebar/ChannelSidebar.tsx": { - "@typescript-eslint/no-unused-vars": { - "count": 2 - } - }, "src/pages/Subscription/Sidebar/SectionHeader.tsx": { "@typescript-eslint/no-unused-vars": { "count": 1 @@ -2537,7 +2263,7 @@ }, "src/pages/Subscription/hooks/useChannelActions.ts": { "@typescript-eslint/no-unused-vars": { - "count": 3 + "count": 2 } }, "src/pages/Subscription/hooks/useCrawlQueue.ts": { @@ -2559,9 +2285,6 @@ "@typescript-eslint/no-explicit-any": { "count": 3 }, - "no-empty": { - "count": 2 - }, "no-unsafe-finally": { "count": 1 }, @@ -2587,7 +2310,7 @@ }, "src/pages/appChat/ChatInput.tsx": { "@typescript-eslint/no-unused-expressions": { - "count": 3 + "count": 1 }, "no-restricted-imports": { "count": 1 @@ -2609,7 +2332,7 @@ }, "src/pages/appChat/ChatView.tsx": { "@typescript-eslint/no-explicit-any": { - "count": 3 + "count": 2 }, "no-restricted-imports": { "count": 1 @@ -2624,9 +2347,6 @@ }, "no-restricted-imports": { "count": 1 - }, - "no-restricted-syntax": { - "count": 2 } }, "src/pages/appChat/appUtils/skillMethod.ts": { @@ -2658,11 +2378,6 @@ "count": 1 } }, - "src/pages/appChat/components/ChatFile.tsx": { - "no-useless-escape": { - "count": 1 - } - }, "src/pages/appChat/components/ConversationList.tsx": { "@typescript-eslint/no-unused-vars": { "count": 2 @@ -2673,7 +2388,7 @@ "count": 2 }, "@typescript-eslint/no-unused-vars": { - "count": 6 + "count": 5 }, "no-console": { "count": 2 @@ -2700,12 +2415,6 @@ } }, "src/pages/appChat/components/InputForm.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - }, - "@typescript-eslint/no-unused-vars": { - "count": 1 - }, "no-restricted-imports": { "count": 1 } @@ -2727,103 +2436,26 @@ "count": 1 } }, - "src/pages/appChat/components/MessageBs.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - }, - "@typescript-eslint/no-unused-vars": { - "count": 2 - }, - "no-restricted-syntax": { - "count": 1 - } - }, - "src/pages/appChat/components/MessageBsChoose.tsx": { - "@typescript-eslint/no-wrapper-object-types": { - "count": 1 - } - }, "src/pages/appChat/components/MessageButtons.tsx": { "@typescript-eslint/no-unused-vars": { "count": 1 }, "no-restricted-imports": { "count": 1 - }, - "no-restricted-syntax": { - "count": 3 - } - }, - "src/pages/appChat/components/MessageFile.tsx": { - "no-restricted-syntax": { - "count": 1 - } - }, - "src/pages/appChat/components/MessageNodeRun.tsx": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - } - }, - "src/pages/appChat/components/MessageRunlog.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 8 - }, - "no-restricted-imports": { - "count": 1 - }, - "react-hooks/exhaustive-deps": { - "count": 1 - } - }, - "src/pages/appChat/components/MessageUser.tsx": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - }, - "no-restricted-syntax": { - "count": 1 - }, - "react-hooks/exhaustive-deps": { - "count": 1 - } - }, - "src/pages/appChat/components/ResouceModal.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - }, - "@typescript-eslint/no-unused-expressions": { - "count": 2 - }, - "@typescript-eslint/no-unused-vars": { - "count": 1 - }, - "no-useless-escape": { - "count": 1 - }, - "react-hooks/exhaustive-deps": { - "count": 5 } }, "src/pages/appChat/hooks/useAppSidebar.ts": { "no-restricted-imports": { "count": 1 - }, - "no-restricted-syntax": { - "count": 4 } }, "src/pages/appChat/index.tsx": { "@typescript-eslint/no-explicit-any": { - "count": 5 + "count": 4 }, "@typescript-eslint/no-unused-expressions": { "count": 1 }, - "@typescript-eslint/no-unused-vars": { - "count": 5 - }, "no-case-declarations": { "count": 2 }, @@ -2838,9 +2470,6 @@ }, "react-hooks/exhaustive-deps": { "count": 1 - }, - "react-hooks/rules-of-hooks": { - "count": 17 } }, "src/pages/appChat/store/appSidebarAtoms.ts": { @@ -2892,34 +2521,13 @@ "@typescript-eslint/no-unused-vars": { "count": 2 }, - "no-console": { - "count": 1 - }, "no-restricted-imports": { "count": 1 - }, - "no-restricted-syntax": { - "count": 1 } }, "src/pages/appChat/useMessages.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - }, - "@typescript-eslint/no-unused-vars": { - "count": 2 - }, - "no-console": { - "count": 1 - }, "no-restricted-imports": { "count": 1 - }, - "no-restricted-syntax": { - "count": 1 - }, - "react-hooks/exhaustive-deps": { - "count": 3 } }, "src/pages/appChat/useWebsocket.ts": { @@ -2932,9 +2540,6 @@ "no-restricted-imports": { "count": 1 }, - "no-restricted-syntax": { - "count": 1 - }, "react-hooks/exhaustive-deps": { "count": 2 } @@ -2942,9 +2547,6 @@ "src/pages/apps/components/AgentCard.tsx": { "@typescript-eslint/no-explicit-any": { "count": 1 - }, - "no-restricted-syntax": { - "count": 1 } }, "src/pages/apps/components/AgentGrid.tsx": { @@ -2964,11 +2566,6 @@ "count": 3 } }, - "src/pages/apps/components/MarkLabel.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/pages/apps/components/SearchOverlay.tsx": { "@typescript-eslint/no-explicit-any": { "count": 4 @@ -3006,51 +2603,6 @@ "count": 1 } }, - "src/pages/knowledge/FilePreview/RichKnowledgePreview.tsx": { - "no-restricted-syntax": { - "count": 2 - }, - "react-hooks/exhaustive-deps": { - "count": 1 - } - }, - "src/pages/knowledge/FilePreview/index.tsx": { - "react-hooks/exhaustive-deps": { - "count": 1 - } - }, - "src/pages/knowledge/FilePreview/viewers/DocxViewer.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - }, - "react-hooks/exhaustive-deps": { - "count": 1 - } - }, - "src/pages/knowledge/FilePreview/viewers/HtmlViewer.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - }, - "react-hooks/exhaustive-deps": { - "count": 1 - } - }, - "src/pages/knowledge/FilePreview/viewers/MarkdownViewer.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - }, - "react-hooks/exhaustive-deps": { - "count": 1 - } - }, - "src/pages/knowledge/FilePreview/viewers/TextViewer.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - }, - "react-hooks/exhaustive-deps": { - "count": 1 - } - }, "src/pages/knowledge/KnowledgeSpacePreviewDrawer.tsx": { "@typescript-eslint/no-explicit-any": { "count": 3 @@ -3062,7 +2614,7 @@ "count": 2 }, "react-hooks/exhaustive-deps": { - "count": 2 + "count": 1 } }, "src/pages/knowledge/KnowledgeSquare.tsx": { @@ -3086,9 +2638,6 @@ "src/pages/knowledge/SpaceDetail/AiChat/KnowledgeAiInput.tsx": { "@typescript-eslint/no-explicit-any": { "count": 1 - }, - "react-hooks/exhaustive-deps": { - "count": 1 } }, "src/pages/knowledge/SpaceDetail/CompoundSearchInput.tsx": { @@ -3112,9 +2661,6 @@ } }, "src/pages/knowledge/SpaceDetail/KnowledgeSpaceHeader.tsx": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - }, "no-restricted-imports": { "count": 1 } @@ -3124,11 +2670,6 @@ "count": 2 } }, - "src/pages/knowledge/SpaceDetail/RelateDocumentPanel.tsx": { - "no-restricted-syntax": { - "count": 5 - } - }, "src/pages/knowledge/SpaceDetail/SelectionPathBreadcrumb.tsx": { "@typescript-eslint/no-unused-vars": { "count": 2 @@ -3137,9 +2678,6 @@ "src/pages/knowledge/SpaceDetail/SimilarDocumentDialog.tsx": { "@typescript-eslint/no-unused-vars": { "count": 2 - }, - "no-restricted-syntax": { - "count": 5 } }, "src/pages/knowledge/SpaceDetail/VersionManagementDialog.tsx": { @@ -3151,9 +2689,6 @@ "@typescript-eslint/no-explicit-any": { "count": 1 }, - "@typescript-eslint/no-unused-vars": { - "count": 7 - }, "no-restricted-imports": { "count": 1 }, @@ -3173,7 +2708,7 @@ }, "src/pages/knowledge/hooks/useFileUpload.test.ts": { "no-restricted-syntax": { - "count": 8 + "count": 7 } }, "src/pages/knowledge/hooks/useFileUpload.ts": { @@ -3198,9 +2733,6 @@ } }, "src/pages/knowledge/index.tsx": { - "@typescript-eslint/no-unused-vars": { - "count": 3 - }, "no-console": { "count": 3 }, @@ -3219,9 +2751,6 @@ "src/pages/knowledge/sidebar/KnowledgeSpaceCardItem.tsx": { "@typescript-eslint/no-unused-expressions": { "count": 1 - }, - "@typescript-eslint/no-unused-vars": { - "count": 1 } }, "src/pages/knowledge/sidebar/KnowledgeSpaceItem.tsx": { @@ -3231,7 +2760,7 @@ }, "src/pages/knowledge/sidebar/KnowledgeSpaceSidebar.tsx": { "@typescript-eslint/no-unused-vars": { - "count": 5 + "count": 3 }, "react-hooks/exhaustive-deps": { "count": 2 @@ -3243,9 +2772,6 @@ } }, "src/pages/share.tsx": { - "no-console": { - "count": 1 - }, "react-hooks/exhaustive-deps": { "count": 1 } @@ -3294,14 +2820,6 @@ "count": 1 } }, - "src/routes/Layouts/DashBreadcrumb.tsx": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - } - }, "src/routes/Layouts/Login.tsx": { "no-restricted-imports": { "count": 1 @@ -3540,9 +3058,6 @@ "test/setupTests.js": { "@typescript-eslint/no-require-imports": { "count": 1 - }, - "no-undef": { - "count": 6 } } } \ No newline at end of file diff --git a/src/frontend/client/eslint.config.mjs b/src/frontend/client/eslint.config.mjs index 3f33c779ca..5e1ca80236 100644 --- a/src/frontend/client/eslint.config.mjs +++ b/src/frontend/client/eslint.config.mjs @@ -1,74 +1,58 @@ -// ESLint 9 flat config — quality gate for the client app. -// Policy: legacy violations are frozen in eslint-suppressions.json (may only shrink); -// new code must be clean. Run `npm run lint` locally, CI enforces on every PR. -import js from '@eslint/js' -import tseslint from 'typescript-eslint' -import reactHooks from 'eslint-plugin-react-hooks' -import globals from 'globals' +import { frontendConfig } from '../config/eslint.mjs'; -export default tseslint.config( - { - ignores: [ - 'dist/**', - 'build/**', - 'node_modules/**', - 'public/**', - 'coverage/**', - 'doc_build/**', - // Docs-site widgets: rspress content, not app code. The docs site has no - // i18n (rspress.config.ts) so its copy is Chinese by design. - 'docs-site/**', - 'scripts/**', - '*.config.{js,ts,mjs,mts}', - 'tailwind.config.js', - 'postcss.config.js', +export default frontendConfig({ + ignores: [ + 'dist/**', + 'build/**', + 'node_modules/**', + 'public/**', + 'coverage/**', + 'doc_build/**', + // Docs-site widgets: rspress content, not app code. The docs site has no + // i18n (rspress.config.ts) so its copy is Chinese by design. + 'docs-site/**', + 'scripts/**', + '*.config.{js,ts,mjs,mts}', + 'tailwind.config.js', + 'postcss.config.js', + ], + restrictedImports: { + paths: [ + { + name: 'axios', + message: + 'Use ~/api/request (wrapped module) instead of raw axios. See constitution C7.', + }, + // Deprecated libs frozen at current usage (ledger #5, #8): + // existing violations live in eslint-suppressions.json, new imports are blocked. + { + name: 'recoil', + message: + 'Recoil is archived by Meta and frozen — no new atoms/selectors/imports (ledger #5). Server state goes to @tanstack/react-query; new cross-page client state may use Zustand to align with platform. Keep existing Recoil state in place.', + }, + { + name: 'react-beautiful-dnd', + message: + 'react-beautiful-dnd was removed (ledger #8). Use @hello-pangea/dnd from the workspace catalog for list sorting.', + }, + { + name: 'react-virtualized', + message: + 'react-virtualized was removed with unreachable input components (ledger #8). Use react-window from the workspace catalog for existing file preview lists.', + }, + ], + patterns: [ + { + group: ['react-beautiful-dnd/*', 'react-virtualized/*'], + message: 'Removed legacy dependencies (ledger #8). Use catalog list sorting and file preview libraries.', + }, + { + group: ['recoil/*'], + message: + 'Recoil is archived by Meta and frozen (ledger #5). Do not add new usage.', + }, ], }, - js.configs.recommended, - ...tseslint.configs.recommended, - { - languageOptions: { - globals: { ...globals.browser, ...globals.es2021, ...globals.node }, - parserOptions: { ecmaFeatures: { jsx: true } }, - }, - plugins: { 'react-hooks': reactHooks }, - rules: { - ...reactHooks.configs.recommended.rules, - // All gate rules are "error" so they are frozen via suppressions and block new code. - 'react-hooks/exhaustive-deps': 'error', - '@typescript-eslint/no-explicit-any': 'error', - '@typescript-eslint/ban-ts-comment': ['error', { 'ts-expect-error': 'allow-with-description' }], - 'no-console': ['error', { allow: ['warn', 'error'] }], - // C7: HTTP must go through the wrapped request module. - 'no-restricted-imports': [ - 'error', - { - paths: [ - { name: 'axios', message: 'Use ~/api/request (wrapped module) instead of raw axios. See constitution C7.' }, - // Deprecated libs frozen at current usage (ledger #5, #8): - // existing violations live in eslint-suppressions.json, new imports are blocked. - { name: 'recoil', message: 'Recoil is archived by Meta and frozen — no new atoms/selectors/imports (ledger #5). Server state goes to @tanstack/react-query; new client-global state awaits the Jotai migration decision.' }, - { name: 'react-beautiful-dnd', message: 'react-beautiful-dnd is deprecated upstream (no React 18 StrictMode support) and frozen; use @hello-pangea/dnd when migrating (ledger #8).' }, - { name: 'react-virtualized', message: 'react-virtualized is frozen — the app standardizes on react-window / @tanstack/react-virtual (ledger #8). Do not add new usage.' }, - ], - patterns: [ - { group: ['recoil/*'], message: 'Recoil is archived by Meta and frozen (ledger #5). Do not add new usage.' }, - ], - }, - ], - // Ledger #28: user-facing copy must go through i18n. Hardcoded Chinese in - // source is frozen in eslint-suppressions.json; new code uses localize(). - 'no-restricted-syntax': [ - 'error', - { selector: 'Literal[value=/[\\u4e00-\\u9fff]/]', message: 'Hardcoded Chinese string — route copy through i18n (useLocalize → localize()). Ledger #28.' }, - { selector: 'TemplateElement[value.raw=/[\\u4e00-\\u9fff]/]', message: 'Hardcoded Chinese in template string — route copy through i18n (useLocalize → localize()). Ledger #28.' }, - { selector: 'JSXText[value=/[\\u4e00-\\u9fff]/]', message: 'Hardcoded Chinese JSX text — route copy through i18n (useLocalize → localize()). Ledger #28.' }, - ], - }, - }, - { - // The wrapper itself legitimately imports axios. - files: ['src/api/request.ts'], - rules: { 'no-restricted-imports': 'off' }, - }, -) + requestFiles: ['src/api/request.ts'], + localizeHint: 'useLocalize → localize()', +}); diff --git a/src/frontend/client/index.html b/src/frontend/client/index.html index 371f94121f..2cf217a428 100644 --- a/src/frontend/client/index.html +++ b/src/frontend/client/index.html @@ -18,6 +18,25 @@ }; } + + \n **New feature**"; + const detail = buildDetail(); + detail.detail_snapshot = { + ...detail.detail_snapshot, + release_notes: notes, + }; + detail.payload_snapshot = { release_notes: "Never render this fallback" }; + const { container } = render( + , + ); + expect(screen.getByText(/evil/).textContent).toBe(notes); + expect(container.querySelector("script")).toBeNull(); + expect( + screen.queryByText("Never render this fallback"), + ).not.toBeInTheDocument(); + }, + ); + + it("shows the release-note empty state for an old snapshot", () => { + render( + , + ); + expect( + screen.getByText("approval.app_release_notes.empty"), + ).toBeInTheDocument(); + }); + + it("recognises only the app publish scenario code", () => { + expect(isAppPublishScenario("app_publish_request")).toBe(true); + expect(isAppPublishScenario("APP_PUBLISH_REQUEST")).toBe(true); + expect(isAppPublishScenario("menu_access_request")).toBe(false); + expect(isAppPublishScenario(undefined)).toBe(false); + }); + + it("renders the four regions and unpacks the tier instead of stringifying it", () => { + const { container } = render( + , + ); + + // Region 1 — basic information, with the release kind localized from the code + // rather than trusting the backend's pre-rendered text. + expect( + screen.getByText("com_approval_section_basic_info"), + ).toBeInTheDocument(); + expect( + screen.getByText("com_approval_app_publish_kind_initial"), + ).toBeInTheDocument(); + expect( + screen.getByText("com_approval_app_publish_source_cli"), + ).toBeInTheDocument(); + expect(screen.getByText("v3")).toBeInTheDocument(); + expect(container.textContent).not.toContain("raw-backend-text"); + + // Regions 2 and 3 — empty this round, so they say so instead of rendering nothing. + expect( + screen.getByText("com_approval_app_publish_capabilities_empty"), + ).toBeInTheDocument(); + expect( + screen.getByText("approval.current_visibility.unavailable"), + ).toBeInTheDocument(); + + // Region 4 — the tier dict expanded into name / cpu / memory. + expect(screen.getByText("Light")).toBeInTheDocument(); + expect( + screen.getByText("com_approval_app_publish_cpu_cores:0.5"), + ).toBeInTheDocument(); + expect(screen.getByText("512 MB")).toBeInTheDocument(); + expect(container.textContent).not.toContain("[object Object]"); + }); + + it("never spills payload_snapshot onto the card", () => { + const { container } = render( + , + ); + expect(container.textContent).not.toContain("99"); + expect(container.textContent).not.toContain("1234"); + expect(container.textContent).not.toContain("tenant"); + }); + + it("explains a skipped department node without naming an administrator role", () => { + render( + , + ); + expect( + screen.getByText("com_approval_app_publish_note_no_department_admin"), + ).toBeInTheDocument(); + }); + + // AC-69, AC-72, AC-84: names and submission-time state only. + it("shows environment reference names and submission-time missing state", () => { + const detail = buildDetail(); + detail.detail_snapshot = { + ...detail.detail_snapshot, + capabilities: [ + { + kind: "secret", + name: "COLOR", + revoked: false, + reason: "missing_value", + }, + ], + }; + render( + , + ); + expect(screen.getByText("COLOR")).toBeInTheDocument(); + expect( + screen.getByText( + "approval.app_environment.type · approval.app_environment.missing", + ), + ).toBeInTheDocument(); + }); + + it("lists frozen capabilities and current visibility instead of the old snapshot", () => { + const detail = buildDetail(); + detail.detail_snapshot = { + ...detail.detail_snapshot, + capabilities: [ + { type: "model", name: "qwen-max", description: "chat model" }, + ], + visibility_snapshot: [{ type: "department", name: "R&D" }], + }; + detail.current_visibility = { state: 'available', owner_only: false, subjects: [{ type: 'department', id: '12', name: 'Current department', userset_relation: 'member' }] }; + const { container } = render( + , + ); + expect(screen.getByText("qwen-max")).toBeInTheDocument(); + expect(screen.getByText("model · chat model")).toBeInTheDocument(); + expect(screen.getByText(/Current department/)).toBeInTheDocument(); + expect(screen.queryByText('R&D')).not.toBeInTheDocument(); + expect(container.textContent).not.toContain("[object Object]"); + }); + + it("adds the structure-change row only when the release changes the declared tables", () => { + // Default fixture: schema_change is null — the row must not exist at all. + const { container, unmount } = render( + , + ); + expect( + container.querySelector('[data-testid="app-publish-schema-change"]'), + ).toBeNull(); + expect(container.textContent).not.toContain( + "com_approval_app_publish_field_schema_change", + ); + unmount(); + + const detail = buildDetail(); + detail.detail_snapshot = { + ...detail.detail_snapshot, + schema_change: { + has_breaking: true, + items: [ + { table: "orders", column: "amount", op: "modify_column" }, + { table: "orders", column: "note", op: "drop_column" }, + { table: "settings", column: null, op: "add_table" }, + ], + }, + }; + render( + , + ); + + expect(screen.getByTestId("app-publish-schema-change")).toBeInTheDocument(); + expect( + screen.getByText("com_approval_app_publish_field_schema_change"), + ).toBeInTheDocument(); + expect( + screen.getByText("com_approval_app_publish_schema_breaking"), + ).toBeInTheDocument(); + expect(screen.getByText("orders.amount")).toBeInTheDocument(); + expect(screen.getByText("orders.note")).toBeInTheDocument(); + expect(screen.getByText("settings")).toBeInTheDocument(); + expect( + screen.getByText("com_approval_app_publish_schema_op_modify_column"), + ).toBeInTheDocument(); + expect( + screen.getByText("com_approval_app_publish_schema_op_add_table"), + ).toBeInTheDocument(); + // Two breaking rows, one additive: the tag appears exactly twice. + expect( + screen.getAllByText("com_approval_app_publish_schema_breaking_tag"), + ).toHaveLength(2); + expect( + screen.getByTestId("app-publish-schema-change").textContent, + ).not.toContain("[object Object]"); + }); + + it("offers the review-view entry only when there is a release to open and a handler to open it", () => { + const onOpenReview = jest.fn(); + + // No handler wired: the review view is not reachable, so no button. + const { unmount } = render( + , + ); + expect( + screen.queryByTestId("app-publish-open-review"), + ).not.toBeInTheDocument(); + unmount(); + + // Handler wired but the snapshot names no version: a dead button is worse + // than none, so it stays away. + const noVersion = buildDetail(); + noVersion.detail_snapshot = { + ...noVersion.detail_snapshot, + version_id: "", + }; + const second = render( + , + ); + expect( + screen.queryByTestId("app-publish-open-review"), + ).not.toBeInTheDocument(); + second.unmount(); + + render( + , + ); + fireEvent.click(screen.getByTestId("app-publish-open-review")); + + // The card is the one place `detail_snapshot` is parsed; the review view is + // handed the result rather than parsing it a second time. + expect(onOpenReview).toHaveBeenCalledWith({ + appId: "17", + versionId: "v-1", + appName: "form-survey", + versionNo: 3, + releaseKindText: "com_approval_app_publish_kind_initial", + capabilities: [], + }); + }); + + it("falls back to placeholders when the tier is missing", () => { + const detail = buildDetail(); + detail.detail_snapshot = { + ...detail.detail_snapshot, + tier: {}, + tier_name: "", + }; + const { container } = render( + , + ); + expect(container.textContent).not.toContain("NaN"); + expect(container.textContent).not.toContain("[object Object]"); + }); +}); diff --git a/src/frontend/client/src/components/approval/AppPublishDetailPanel.tsx b/src/frontend/client/src/components/approval/AppPublishDetailPanel.tsx new file mode 100644 index 0000000000..3b6f37d2c5 --- /dev/null +++ b/src/frontend/client/src/components/approval/AppPublishDetailPanel.tsx @@ -0,0 +1,398 @@ +import type { + ApprovalInstanceDetail, + ApprovalTaskDetail, +} from "~/api/approval"; +import { cn } from "~/utils"; +import type { AppReviewTarget } from "./AppReviewView"; +import { CurrentAppVisibility } from './CurrentAppVisibility'; +import { + DetailHeader, + formatSerialNo, + formatTime, + InfoGrid, + type LocalizeFn, +} from "./approvalPresentation"; + +/** Backend scenario code (app_publish_scenario_handler.SCENARIO_CODE). */ +export const APP_PUBLISH_SCENARIO_CODE = "app_publish_request"; + +export function isAppPublishScenario(scenarioCode?: string | null): boolean { + return String(scenarioCode ?? "").toLowerCase() === APP_PUBLISH_SCENARIO_CODE; +} + +/** One declared platform capability, normalized from the (deferred) backend shape. */ +type CapabilityEntry = { + type: string; + name: string; + description: string; +}; + +/** One row of the structure change (`schema_change.items[]`, F055 AC-09). */ +type SchemaChangeEntry = { + op: string; + target: string; + breaking: boolean; +}; + +/** Ops the backend gate required confirmation for (schema_evolution_service.BREAKING_OPS). */ +const BREAKING_SCHEMA_OPS = new Set([ + "drop_table", + "drop_column", + "modify_column", +]); + +const SCHEMA_OP_I18N: Record = { + add_table: "com_approval_app_publish_schema_op_add_table", + drop_table: "com_approval_app_publish_schema_op_drop_table", + add_column: "com_approval_app_publish_schema_op_add_column", + drop_column: "com_approval_app_publish_schema_op_drop_column", + modify_column: "com_approval_app_publish_schema_op_modify_column", +}; + +export interface AppPublishDetailPanelProps { + detail: ApprovalTaskDetail | ApprovalInstanceDetail; + scope: "task" | "instance"; + localize: LocalizeFn; + onBack?: () => void; + onRefresh?: () => void; + /** + * Opens the review view on the version waiting to go live (AC-24's third + * operation, AC-25). Omitted — or called with a release whose snapshot the + * backend will not hand over — the button is simply not drawn: a dead + * 「查看待上线版本」 is worse than none. + */ + onOpenReview?: (target: AppReviewTarget) => void; +} + +function asText(value: unknown): string { + if (value === null || value === undefined || value === "") return ""; + if (typeof value === "object") return ""; + return String(value); +} + +function asRecord(value: unknown): Record { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function toCapabilityEntries( + value: unknown, + localize: LocalizeFn, +): CapabilityEntry[] { + if (!Array.isArray(value)) return []; + return value.map((item) => { + if (typeof item === "string") + return { type: "", name: item, description: "" }; + const record = asRecord(item); + if (record.kind === "secret") { + return { + type: localize("approval.app_environment.type"), + name: asText(record.name), + description: localize( + record.reason === "missing_value" + ? "approval.app_environment.missing" + : "approval.app_environment.present", + ), + }; + } + return { + type: asText(record.type), + name: asText(record.name) || asText(record.code) || asText(record.id), + description: asText(record.description), + }; + }); +} + +function toSchemaChangeEntries(value: unknown): SchemaChangeEntry[] { + const items = asRecord(value).items; + if (!Array.isArray(items)) return []; + return items.flatMap((item) => { + const record = asRecord(item); + const table = asText(record.table); + if (!table) return []; + const column = asText(record.column); + const op = asText(record.op); + return [ + { + op, + target: column ? `${table}.${column}` : table, + breaking: BREAKING_SCHEMA_OPS.has(op), + }, + ]; + }); +} + +function SectionTitle({ children }: { children: string }) { + return ( +
    + {children} +
    + ); +} + +function SectionEmpty({ children }: { children: string }) { + return ( +
    + {children} +
    + ); +} + +/** + * The app-publish approval card (F055 AC-24): four regions instead of the generic + * two-column grid, because `build_detail` ships structured sub-trees that the + * generic grid would print as `[object Object]` under raw English key names. + * + * Only `detail_snapshot` is read — never `payload_snapshot`, which the same API + * response also carries and which holds internal fields (tenant_id, deployment_id…) + * that must not reach an approver's screen. + */ +export function AppPublishDetailPanel({ + detail, + scope, + localize, + onBack, + onRefresh, + onOpenReview, +}: AppPublishDetailPanelProps) { + const snapshot: Record = detail.detail_snapshot ?? {}; + const instanceId = + scope === "task" + ? (detail.instance_id ?? null) + : (detail.instance_id ?? detail.id ?? null); + const serialNo = instanceId + ? formatSerialNo(instanceId, detail.create_time) + : "--"; + const instanceStatus = + "instance_status" in detail ? detail.instance_status : undefined; + + const appName = asText(snapshot.app_name) || detail.business_name || "--"; + + const sourceCode = asText(snapshot.source).toLowerCase(); + const sourceText = + sourceCode === "cli" + ? localize("com_approval_app_publish_source_cli") + : sourceCode === "platform" || sourceCode === "builder" + ? localize("com_approval_app_publish_source_platform") + : asText(snapshot.source) || "--"; + + const releaseKind = asText(snapshot.release_kind).toLowerCase(); + const releaseKindText = + releaseKind === "initial" + ? localize("com_approval_app_publish_kind_initial") + : releaseKind === "iteration" + ? localize("com_approval_app_publish_kind_iteration") + : asText(snapshot.release_kind_text) || "--"; + + const versionNo = asText(snapshot.version_no); + const reviewAppId = asText(snapshot.app_id); + const reviewVersionId = asText(snapshot.version_id); + + const basicRows: [string, string][] = [ + [localize("com_approval_field_serial_no"), serialNo], + [localize("com_approval_app_publish_field_app_name"), appName], + [ + localize("com_approval_app_publish_field_owner"), + asText(snapshot.owner_user_name) || detail.applicant_user_name || "--", + ], + [localize("com_approval_app_publish_field_source"), sourceText], + [localize("com_approval_app_publish_field_release_kind"), releaseKindText], + [ + localize("com_approval_app_publish_field_version"), + versionNo ? `v${versionNo}` : "--", + ], + [ + localize("com_approval_app_publish_field_submitted_at"), + formatTime(asText(snapshot.submitted_at) || detail.create_time), + ], + ]; + + // AC-16: says why the request skipped the department approval node, so an + // approver is not left wondering. Deliberately names no administrator role — + // who the fallback approver is differs between single- and multi-tenant setups. + const approverNote = asText(snapshot.approver_note); + const approverNoteText = + approverNote === "no_department_admin_source" + ? localize("com_approval_app_publish_note_no_department_admin") + : approverNote; + + const capabilities = toCapabilityEntries(snapshot.capabilities, localize); + // AC-09 / AC-24: the "结构变更" row appears only when the release changes the + // declared tables. Breaking rows were confirmed by the publisher at submit + // time — the approver sees what was confirmed, they are not asked again. + const schemaChange = toSchemaChangeEntries(snapshot.schema_change); + const schemaChangeBreaking = + asRecord(snapshot.schema_change).has_breaking === true; + + const tier = asRecord(snapshot.tier); + const cpuMillicores = Number(tier.cpu_millicores); + const memoryMb = Number(tier.memory_mb); + const tierRows: [string, string][] = [ + [ + localize("com_approval_app_publish_field_tier_name"), + asText(tier.name) || + asText(tier.code) || + asText(snapshot.tier_name) || + "--", + ], + [ + localize("com_approval_app_publish_field_cpu"), + Number.isFinite(cpuMillicores) && cpuMillicores > 0 + ? localize("com_approval_app_publish_cpu_cores", { + value: String(cpuMillicores / 1000), + }) + : "--", + ], + [ + localize("com_approval_app_publish_field_memory"), + Number.isFinite(memoryMb) && memoryMb > 0 ? `${memoryMb} MB` : "--", + ], + ]; + + return ( +
    + + + {/* ① Basic information */} +
    + + {localize("com_approval_section_basic_info")} + + + {/* AC-24's third operation. It sits with the release's identity rather + than beside 通过 / 驳回: reading the code is what you do *before* + deciding, and a read-only view among two decisions invites a + misclick. Needs a version id — without one there is nothing to + open, so the button does not appear at all. */} + {onOpenReview && reviewAppId && reviewVersionId && ( + + )} + {approverNoteText && ( +
    + {approverNoteText} +
    + )} +
    + +
    + + {localize("approval.app_release_notes.title")} + +

    + {typeof snapshot.release_notes === "string" && snapshot.release_notes + ? snapshot.release_notes + : localize("approval.app_release_notes.empty")} +

    +
    + + {/* ② Declared platform capabilities */} +
    + + {localize("com_approval_app_publish_section_capabilities")} + + {capabilities.length === 0 ? ( + + {localize("com_approval_app_publish_capabilities_empty")} + + ) : ( +
    + {capabilities.map((capability, index) => ( +
    0 && "border-t border-[#f2f3f5]", + )} + > +
    + {capability.name || "--"} +
    + {(capability.type || capability.description) && ( +
    + {[capability.type, capability.description] + .filter(Boolean) + .join(" · ")} +
    + )} +
    + ))} +
    + )} +
    + + + + {/* ④ Resource tier (+ the structure-change row when there is one) */} +
    + + {localize("com_approval_app_publish_section_tier")} + + + {schemaChange.length > 0 && ( +
    +
    + {localize("com_approval_app_publish_field_schema_change")} +
    +
    + {localize( + schemaChangeBreaking + ? "com_approval_app_publish_schema_breaking" + : "com_approval_app_publish_schema_additive", + )} +
    +
      + {schemaChange.map((entry, index) => ( +
    • + + {SCHEMA_OP_I18N[entry.op] + ? localize(SCHEMA_OP_I18N[entry.op]) + : entry.op || "--"} + + {entry.target} + {entry.breaking && ( + + {localize("com_approval_app_publish_schema_breaking_tag")} + + )} +
    • + ))} +
    +
    + )} +
    +
    + ); +} diff --git a/src/frontend/client/src/components/approval/AppReviewView.test.tsx b/src/frontend/client/src/components/approval/AppReviewView.test.tsx new file mode 100644 index 0000000000..de266cb591 --- /dev/null +++ b/src/frontend/client/src/components/approval/AppReviewView.test.tsx @@ -0,0 +1,233 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { AppReviewView, type AppReviewTarget } from "./AppReviewView"; +import type { LocalizeFn } from "./approvalPresentation"; + +const mockGetSnapshotTreeApi = jest.fn(); +const mockGetReviewContextApi = jest.fn(); +const mockGetSnapshotFileApi = jest.fn(); +const mockGetVersionDiffApi = jest.fn(); + +jest.mock("~/api/hostedAppReview", () => ({ + getSnapshotTreeApi: (...args: unknown[]) => mockGetSnapshotTreeApi(...args), + getReviewContextApi: (...args: unknown[]) => mockGetReviewContextApi(...args), + getSnapshotFileApi: (...args: unknown[]) => mockGetSnapshotFileApi(...args), + getVersionDiffApi: (...args: unknown[]) => mockGetVersionDiffApi(...args), +})); + +/** + * The preview panel has its own suite; here it is stubbed so this file asserts + * *where* it sits rather than what it does — and so the review view's tests do + * not fire a real XHR at the preview endpoint. + */ +jest.mock("./AppPreviewPanel", () => ({ + AppPreviewPanel: ({ appId, versionId }: Record) => ( +
    {`${String(appId)}/${String(versionId)}`}
    + ), +})); + +/** + * The diff presentation is the shared `@bisheng/file-viewers` component, tested + * on its own in the platform suite. Here it is a stub, so this file asserts + * what the review view *feeds* it — which two versions, and whether it asks + * for a diff at all. + */ +jest.mock("@bisheng/file-viewers", () => ({ + VersionDiffView: ({ files, caption, emptyMessage }: Record) => ( +
    + {String(caption ?? "")} + {String(emptyMessage ?? "")} + {(files as unknown[]).length} +
    + ), +})); + +// jsdom ships no ResizeObserver, and `@bisheng/ui`'s Tabs measures its ink bar +// with one. Without the stub every render here throws before any assertion. +beforeAll(() => { + (globalThis as { ResizeObserver?: unknown }).ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; +}); + +/** + * The design-system Tabs renders each label twice (a hidden copy it measures + * the ink bar against), so a plain getByText is ambiguous; the visible one is + * first. + */ +function clickTab(label: string) { + // Radix Tabs run on `activationMode="automatic"`: the trigger switches on + // mousedown/focus, not on a synthetic click. + const trigger = screen.getAllByRole("tab").find((node) => node.textContent?.includes(label)); + if (!trigger) throw new Error(`no tab labelled ${label}`); + fireEvent.mouseDown(trigger); + fireEvent.focus(trigger); +} + +/** Echoes the key back so assertions read as the copy contract. */ +const localize: LocalizeFn = (key) => String(key); + +const TARGET: AppReviewTarget = { + appId: "app-1", + versionId: "ver-2", + appName: "form-survey", + versionNo: 2, + releaseKindText: "iteration", + capabilities: [{ type: "model", name: "qwen", description: "chat model" }], +}; + +const TREE = { + version: { version_id: "ver-2", version_no: 2, kind: "iteration", terminal_state: null, submitted_at: null }, + role: "approver", + entries: [ + { path: "bisheng-app.yaml", name: "bisheng-app.yaml", type: "file", size: 20, previewable: true, reason: null }, + { path: "logo.png", name: "logo.png", type: "file", size: 9000, previewable: false, reason: "binary" }, + ], + total_files: 2, + truncated: false, +}; + +const CONTEXT = { + version: TREE.version, + role: "approver", + current_version_id: "ver-1", + pending_version_id: "ver-2", + versions: [ + { version_id: "ver-2", version_no: 2, kind: "iteration", terminal_state: null, submitted_at: null, is_current: false, is_pending: true }, + { version_id: "ver-1", version_no: 1, kind: "initial", terminal_state: "online", submitted_at: null, is_current: true, is_pending: false }, + ], +}; + +function renderView(overrides: Partial = {}) { + return render( + , + ); +} + +describe("AppReviewView", () => { + beforeEach(() => { + mockGetSnapshotTreeApi.mockResolvedValue(TREE); + mockGetReviewContextApi.mockResolvedValue(CONTEXT); + mockGetSnapshotFileApi.mockResolvedValue({ + version: TREE.version, + role: "approver", + path: "bisheng-app.yaml", + size: 20, + previewable: true, + reason: null, + content: "name: form-survey\nruntime: python3.11\n", + masked_secrets: 0, + line_count: 2, + }); + mockGetVersionDiffApi.mockResolvedValue({ + base: CONTEXT.versions[1], + target: CONTEXT.versions[0], + role: "approver", + summary: { files_changed: 1, additions: 1, deletions: 0, truncated: false }, + files: [{ path: "main.py", change: "modified", additions: 1, deletions: 0, comparable: true, reason: null }], + patches: [], + }); + }); + + it("opens on the source tab with the manifest already loaded", async () => { + renderView(); + + await screen.findByTestId("review-source-pane"); + expect(mockGetSnapshotTreeApi).toHaveBeenCalledWith("app-1", "ver-2"); + await waitFor(() => expect(mockGetSnapshotFileApi).toHaveBeenCalledWith("app-1", "ver-2", "bisheng-app.yaml")); + expect(await screen.findByText(/runtime: python3.11/)).toBeInTheDocument(); + }); + + it("does not fetch a file the tree already said cannot be shown", async () => { + renderView(); + await screen.findByTestId("review-source-pane"); + await waitFor(() => expect(mockGetSnapshotFileApi).toHaveBeenCalledTimes(1)); + + fireEvent.click(screen.getByTitle("logo.png")); + + expect(await screen.findByText("com_approval_review_not_previewable_binary")).toBeInTheDocument(); + expect(mockGetSnapshotFileApi).toHaveBeenCalledTimes(1); + }); + + it("shows an empty file as empty, not as one it refuses to display", async () => { + // `content: ""` is a real, readable file. Branching on the text rather than + // on `previewable` would tell the approver the platform will not show it. + mockGetSnapshotFileApi.mockResolvedValue({ + version: TREE.version, + role: "approver", + path: "bisheng-app.yaml", + size: 0, + previewable: true, + reason: null, + content: "", + masked_secrets: 0, + line_count: 0, + }); + + renderView(); + + await screen.findByTestId("review-source-pane"); + await waitFor(() => expect(mockGetSnapshotFileApi).toHaveBeenCalledTimes(1)); + expect( + screen.queryByText("com_approval_review_not_previewable_generic"), + ).not.toBeInTheDocument(); + }); + + it("lists the version history an approver has no other way to read", async () => { + renderView(); + await screen.findByTestId("review-source-pane"); + + clickTab("com_approval_review_tab_versions"); + + expect(await screen.findByText("v1")).toBeInTheDocument(); + expect(screen.getByText("com_approval_review_version_current")).toBeInTheDocument(); + expect(screen.getByText("com_approval_review_version_pending")).toBeInTheDocument(); + expect(screen.getByText("com_approval_review_terminal_online")).toBeInTheDocument(); + }); + + it("diffs the pending version against the published one, and only when that tab is opened", async () => { + renderView(); + await screen.findByTestId("review-source-pane"); + expect(mockGetVersionDiffApi).not.toHaveBeenCalled(); + + clickTab("com_approval_review_tab_diff"); + + // base = the running version, target = the one under approval (AC-41). + await waitFor(() => expect(mockGetVersionDiffApi).toHaveBeenCalledWith("app-1", "ver-1", "ver-2")); + expect(await screen.findByTestId("diff-caption")).toHaveTextContent("v1 → v2"); + }); + + it("says a first release has nothing to compare against instead of calling the endpoint", async () => { + mockGetReviewContextApi.mockResolvedValue({ ...CONTEXT, current_version_id: null }); + renderView(); + await screen.findByTestId("review-source-pane"); + + clickTab("com_approval_review_tab_diff"); + + expect(await screen.findByTestId("diff-empty")).toHaveTextContent("com_approval_review_no_baseline"); + expect(mockGetVersionDiffApi).not.toHaveBeenCalled(); + }); + + it("shows the refusal message rather than an empty frame when the reads are denied", async () => { + const refusal = "not allowed to read this version"; + mockGetSnapshotTreeApi.mockRejectedValue(new Error(refusal)); + mockGetReviewContextApi.mockRejectedValue(new Error(refusal)); + + renderView(); + + expect(await screen.findByText(refusal)).toBeInTheDocument(); + expect(screen.queryByTestId("review-source-pane")).not.toBeInTheDocument(); + }); +}); + +test("the try-it-out panel is pinned above the tabs, not hidden inside one", async () => { + render(); + + const panel = await screen.findByTestId("preview-panel-stub"); + expect(panel).toHaveTextContent("app-1/ver-2"); + // Pinned means "before the tab strip in document order", which is what an + // approver actually experiences as the panel being at the top. + const tablist = screen.getByRole("tablist"); + expect(panel.compareDocumentPosition(tablist) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); +}); diff --git a/src/frontend/client/src/components/approval/AppReviewView.tsx b/src/frontend/client/src/components/approval/AppReviewView.tsx new file mode 100644 index 0000000000..9958b7e10a --- /dev/null +++ b/src/frontend/client/src/components/approval/AppReviewView.tsx @@ -0,0 +1,287 @@ +import { Tabs, Tag } from "@bisheng/ui"; +import { VersionDiffView } from "@bisheng/file-viewers"; +import { Outlined } from "bisheng-icons"; +import { useEffect, useState } from "react"; +import { + getReviewContextApi, + getSnapshotTreeApi, + getVersionDiffApi, + type ReviewContext, + type SnapshotTree, + type VersionDiffResponse, +} from "~/api/hostedAppReview"; +import { extractApiErrorMessage } from "~/utils/apiStatusError"; +import { AppPreviewPanel } from "./AppPreviewPanel"; +import type { LocalizeFn } from "./approvalPresentation"; +import { formatTime } from "./approvalPresentation"; +import { ReviewSourcePane } from "./ReviewSourcePane"; + +/** + * The review view (F055 AC-25): what an approver reads before deciding. + * + * Reached from 「查看待上线版本」 on the app-publish approval card, and it takes + * over the whole settings content panel rather than opening a second surface — + * design D14's 案 A, restated for the page the approval centre became: the + * decision stays where the approver already is, and 「返回」 puts the two-column + * list back. + * + * Four tabs, and the tab is what decides which read runs: source browsing, the + * capability declaration in full, the version history, and the diff against + * the last published version. Nothing here can change anything — approve and + * reject stay on the card behind 「返回」, because a decision taken while + * reading code is a decision taken without the request in front of you. + */ + +type ReviewTab = "source" | "capabilities" | "versions" | "diff"; + +/** One declared capability, in the shape `build_detail` ships it. */ +export interface ReviewCapability { + type: string; + name: string; + description: string; +} + +/** + * Everything the view needs that the approval card has already parsed out of + * `detail_snapshot`. Passing it through keeps one parser for that payload — + * the card — instead of two that can disagree about the same release. + */ +export interface AppReviewTarget { + appId: string; + versionId: string; + appName: string; + versionNo: number | null; + releaseKindText: string; + capabilities: ReviewCapability[]; +} + +export interface AppReviewViewProps { + target: AppReviewTarget; + localize: LocalizeFn; + onBack: () => void; +} + +/** + * Failure is a box, not a string: an empty message would be falsy and fall + * through to the normal pane, showing an empty file tree as though the package + * had no files in it. `localize` is left out of it because it is a new function + * identity on every render — putting it in an effect's dependencies re-fires + * the fetch forever. + */ +type Failure = { message: string }; + +function toFailure(error: unknown): Failure { + return { message: extractApiErrorMessage(error) }; +} + +export function AppReviewView({ target, localize, onBack }: AppReviewViewProps) { + const { appId, versionId, appName, versionNo, releaseKindText, capabilities } = target; + const [tab, setTab] = useState("source"); + const [context, setContext] = useState(null); + const [tree, setTree] = useState(null); + const [diff, setDiff] = useState(null); + const [loading, setLoading] = useState(true); + const [failure, setFailure] = useState(null); + const [diffLoading, setDiffLoading] = useState(false); + const [diffFailure, setDiffFailure] = useState(null); + + // The tree and the context are what the header and three of the four tabs + // need, so both are read once on open rather than per tab switch. + useEffect(() => { + let cancelled = false; + setLoading(true); + setFailure(null); + // A diff belongs to the release it was read for; dropping it here keeps a + // previous target's comparison off the screen if this view is ever handed a + // second release without being unmounted first. + setDiff(null); + setDiffFailure(null); + Promise.all([getSnapshotTreeApi(appId, versionId), getReviewContextApi(appId, versionId)]) + .then(([treeData, contextData]) => { + if (cancelled) return; + setTree(treeData); + setContext(contextData); + }) + .catch((error: unknown) => { + if (cancelled) return; + setTree(null); + setContext(null); + setFailure(toFailure(error)); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [appId, versionId]); + + // AC-41's comparison, and only that one: the version waiting to go live + // against the one currently published. There is no version picker here — + // an approver is deciding on one release, not browsing history. + const baseVersionId = context?.current_version_id ?? null; + const hasBaseline = !!baseVersionId && baseVersionId !== versionId; + + useEffect(() => { + if (tab !== "diff" || !hasBaseline || !baseVersionId || diff) return; + let cancelled = false; + setDiffLoading(true); + setDiffFailure(null); + getVersionDiffApi(appId, baseVersionId, versionId) + .then((data) => { + if (!cancelled) setDiff(data); + }) + .catch((error: unknown) => { + if (cancelled) return; + setDiff(null); + setDiffFailure(toFailure(error)); + }) + .finally(() => { + if (!cancelled) setDiffLoading(false); + }); + return () => { + cancelled = true; + }; + }, [tab, hasBaseline, baseVersionId, appId, versionId, diff]); + + const tabItems = [ + { key: "source", label: localize("com_approval_review_tab_source") }, + { key: "capabilities", label: localize("com_approval_review_tab_capabilities") }, + { key: "versions", label: localize("com_approval_review_tab_versions") }, + { key: "diff", label: localize("com_approval_review_tab_diff") }, + ]; + + return ( +
    +
    + + +

    {appName}

    + {versionNo ? ( + {`v${versionNo}`} + ) : null} + {releaseKindText ? ( + {releaseKindText} + ) : null} +
    + + {/* 「预览试用」 sits above the tabs, not inside one (AC-25 「置顶」): + running the release is the first thing offered an approver, not a + fifth thing to find behind a tab. It renders in every tab state and + keeps its own status, so switching tabs never interrupts a trial. */} +
    + +
    + +
    + setTab(key as ReviewTab)} + /> +
    + +
    + {loading ? ( +

    {localize("com_approval_review_loading")}

    + ) : failure ? ( +

    + {failure.message || localize("com_approval_review_load_failed")} +

    + ) : tab === "source" ? ( + + ) : tab === "capabilities" ? ( + capabilities.length === 0 ? ( +

    + {localize("com_approval_app_publish_capabilities_empty")} +

    + ) : ( +
      + {capabilities.map((capability, index) => ( +
    • +
      + {capability.name || "--"} +
      + {(capability.type || capability.description) && ( +
      + {[capability.type, capability.description].filter(Boolean).join(" · ")} +
      + )} +
    • + ))} +
    + ) + ) : tab === "versions" ? ( +
      + {(context?.versions ?? []).map((row) => ( +
    • + {`v${row.version_no}`} + + {localize( + row.kind === "initial" + ? "com_approval_app_publish_kind_initial" + : "com_approval_app_publish_kind_iteration", + )} + + {formatTime(row.submitted_at)} + {row.terminal_state && ( + + {localize(`com_approval_review_terminal_${row.terminal_state}`)} + + )} + {row.is_current && ( + + {localize("com_approval_review_version_current")} + + )} + {row.is_pending && ( + + {localize("com_approval_review_version_pending")} + + )} +
    • + ))} +
    + ) : ( + + )} +
    +
    + ); +} diff --git a/src/frontend/client/src/components/approval/ApprovalDetailPanels.tsx b/src/frontend/client/src/components/approval/ApprovalDetailPanels.tsx index 3dce4f8845..be685d1a3e 100644 --- a/src/frontend/client/src/components/approval/ApprovalDetailPanels.tsx +++ b/src/frontend/client/src/components/approval/ApprovalDetailPanels.tsx @@ -1,13 +1,14 @@ import { Tag } from "@bisheng/ui"; -import { Outlined } from "bisheng-icons"; import type { ApprovalInstanceDetail, ApprovalTaskDetail } from "~/api/approval"; import { useLocalize } from "~/hooks"; import { type TranslationKeys } from "~/hooks/useLocalize"; import { cn } from "~/utils"; +import { AppPublishDetailPanel, isAppPublishScenario } from "./AppPublishDetailPanel"; +import type { AppReviewTarget } from "./AppReviewView"; import { DETAIL_INTERNAL_KEYS, + DetailHeader, InfoGrid, - StatusBadge, type ApprovalTone, TimelineStep, formatSerialNo, @@ -28,43 +29,7 @@ type TimelineNode = { status?: string | null; }; -export function DetailHeader({ title, status, instanceStatus, scope, serialNo, scenarioName, createTime, localize, onBack }: { - title?: string; status?: string; instanceStatus?: string; scope: "task" | "instance"; serialNo: string; scenarioName?: string; createTime?: string | null; localize: ReturnType; onBack?: () => void; -}) { - return ( - // Pinned to the top of the scrolling detail pane so the title/status/serial stay visible while the body scrolls. -
    -
    - {/* Compact-only back control — sits to the left of the detail title, split by a short vertical divider. - h-8 matches the title line so the arrow centers against it under items-start. */} - {onBack && ( -
    - - -
    - )} - {/* Title + serial share one column so the serial line aligns with the title, not the back arrow. */} -
    -
    - {/* Same line box as the list column's section heading (text-base/leading-8 at pt-4), - so both titles sit on one line across the two columns. */} -

    {title || "--"}

    - {/* §4 — one object's status, meant to be seen from across the page, - so the medium rung; the dot comes from StatusBadge itself. */} - -
    -

    - {serialNo} · {scenarioName || "--"} · {formatTime(createTime)} -

    -
    -
    -
    - ); -} - -export function TaskDetailPanel({ detail, localize, onBack }: { detail: ApprovalTaskDetail; localize: ReturnType; onBack?: () => void }) { +export function TaskDetailPanel({ detail, localize, onBack, onOpenReview, onRefresh }: { detail: ApprovalTaskDetail; localize: ReturnType; onBack?: () => void; onOpenReview?: (target: AppReviewTarget) => void; onRefresh?: () => void }) { const instanceId = detail.instance_id; const serialNo = instanceId ? formatSerialNo(instanceId, detail.create_time) : "--"; @@ -83,36 +48,45 @@ export function TaskDetailPanel({ detail, localize, onBack }: { detail: Approval ); const showContent = detailEntries.length > 0; + // Early dispatch, not a scenario registry — one scenario does not earn one. + const isAppPublish = isAppPublishScenario(detail.scenario_code); + return (
    - + {isAppPublish ? ( + + ) : ( + <> + -
    -
    {localize("com_approval_section_basic_info")}
    - -
    +
    +
    {localize("com_approval_section_basic_info")}
    + +
    - {showContent && ( -
    -
    {localize("com_approval_section_business_content")}
    -
    - {detailEntries.map(([k, v]) => ( -
    -
    {localizeFieldKey(k, localize)}
    -
    {Array.isArray(v) ? v.join(", ") : String(v)}
    + {showContent && ( +
    +
    {localize("com_approval_section_business_content")}
    +
    + {detailEntries.map(([k, v]) => ( +
    +
    {localizeFieldKey(k, localize)}
    +
    {Array.isArray(v) ? v.join(", ") : String(v)}
    +
    + ))} + {detailEntries.length % 2 === 1 &&
    }
    - ))} - {detailEntries.length % 2 === 1 &&
    } -
    -
    - )} +
    + )} - {detail.reason && ( -
    -
    {localize("com_approval_section_apply_reason")}
    -
    {detail.reason}
    -
    + {detail.reason && ( +
    +
    {localize("com_approval_section_apply_reason")}
    +
    {detail.reason}
    +
    + )} + )} {((detail.action_logs && detail.action_logs.length > 0) || (detail.tasks && detail.tasks.length > 0) || detail.current_node_name) ? ( @@ -235,7 +209,7 @@ export function TaskDetailPanel({ detail, localize, onBack }: { detail: Approval ); } -export function RequestDetailPanel({ detail, localize, onBack }: { detail: ApprovalInstanceDetail; localize: ReturnType; onBack?: () => void }) { +export function RequestDetailPanel({ detail, localize, onBack, onOpenReview, onRefresh }: { detail: ApprovalInstanceDetail; localize: ReturnType; onBack?: () => void; onOpenReview?: (target: AppReviewTarget) => void; onRefresh?: () => void }) { const id = detail.instance_id ?? detail.id; const serialNo = id ? formatSerialNo(Number(id), detail.create_time) : "--"; @@ -255,36 +229,45 @@ export function RequestDetailPanel({ detail, localize, onBack }: { detail: Appro ([k, v]) => !DETAIL_INTERNAL_KEYS.has(k) && k !== "reason" && v !== undefined && v !== null && v !== "", ); + // Early dispatch, not a scenario registry — one scenario does not earn one. + const isAppPublish = isAppPublishScenario(detail.scenario_code); + return (
    - + {isAppPublish ? ( + + ) : ( + <> + -
    -
    {localize("com_approval_section_basic_info")}
    - -
    +
    +
    {localize("com_approval_section_basic_info")}
    + +
    - {detailEntries.length > 0 && ( -
    -
    {localize("com_approval_section_business_content")}
    -
    - {detailEntries.map(([k, v]) => ( -
    -
    {localizeFieldKey(k, localize)}
    -
    {Array.isArray(v) ? v.join(", ") : String(v)}
    + {detailEntries.length > 0 && ( +
    +
    {localize("com_approval_section_business_content")}
    +
    + {detailEntries.map(([k, v]) => ( +
    +
    {localizeFieldKey(k, localize)}
    +
    {Array.isArray(v) ? v.join(", ") : String(v)}
    +
    + ))} + {detailEntries.length % 2 === 1 &&
    }
    - ))} - {detailEntries.length % 2 === 1 &&
    } -
    -
    - )} +
    + )} - {detail.reason && ( -
    -
    {localize("com_approval_section_apply_reason")}
    -
    {detail.reason}
    -
    + {detail.reason && ( +
    +
    {localize("com_approval_section_apply_reason")}
    +
    {detail.reason}
    +
    + )} + )} {((detail.action_logs && detail.action_logs.length > 0) || (detail.tasks && detail.tasks.length > 0)) && ( diff --git a/src/frontend/client/src/components/approval/ApprovalPane.tsx b/src/frontend/client/src/components/approval/ApprovalPane.tsx index 1ade875eaf..8512083cfa 100644 --- a/src/frontend/client/src/components/approval/ApprovalPane.tsx +++ b/src/frontend/client/src/components/approval/ApprovalPane.tsx @@ -1,5 +1,6 @@ -import { Tabs, Tag } from "@bisheng/ui"; -import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { showApiError } from "~/api/request"; +import { Button, Tabs, Tag } from "@bisheng/ui"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { decideApprovalTaskApi, getApprovalInstanceDetailApi, @@ -21,6 +22,7 @@ import { cn } from "~/utils"; import { Dialog, DialogContent } from "../ui/Dialog"; import { ExpandableSearchField } from "../ui/ExpandableSearchField"; import { RequestDetailPanel, TaskDetailPanel } from "./ApprovalDetailPanels"; +import { AppReviewView, type AppReviewTarget } from "./AppReviewView"; import { IN_PROGRESS_STATUSES, StatusBadge, @@ -70,6 +72,10 @@ export function ApprovalPane({ const [selectedInstanceId, setSelectedInstanceId] = useState(null); const [taskDetail, setTaskDetail] = useState(null); const [requestDetail, setRequestDetail] = useState(null); + const [listError, setListError] = useState(null); + const [detailError, setDetailError] = useState(null); + const detailRequest = useRef(0); + const listRequest = useRef(0); const [loadingList, setLoadingList] = useState(false); const [loadingDetail, setLoadingDetail] = useState(false); const [actionLoading, setActionLoading] = useState(false); @@ -79,6 +85,14 @@ export function ApprovalPane({ const [withdrawReason, setWithdrawReason] = useState(""); const [revokeDialogOpen, setRevokeDialogOpen] = useState(false); const [revokeReason, setRevokeReason] = useState(""); + /** + * The review view (AC-25) is a second mode of this same surface, not a second + * surface — design D14 option A, read on the approval center page: it + * takes over both columns so the file tree and the code get the full content + * panel, and Back restores the list with the selection intact. A route of + * its own would need a permission guard and a return state for nothing. + */ + const [reviewTarget, setReviewTarget] = useState(null); const filteredTaskItems = useMemo(() => { const byStatus = taskFilter === "pending_me" @@ -114,10 +128,33 @@ export function ApprovalPane({ severity: ok ? NotificationSeverity.SUCCESS : NotificationSeverity.INFO, }); + const loadDetail = async (id: number | null, kind: "task" | "request") => { + const revision = ++detailRequest.current; + setTaskDetail(null); setRequestDetail(null); setDetailError(null); + setLoadingDetail(Boolean(id)); + if (!id) return; + try { + const detail = kind === "task" ? await getMyApprovalTaskDetailApi(id) : await getApprovalInstanceDetailApi(id); + if (revision !== detailRequest.current) return; + if (kind === "task") setTaskDetail(detail); + else setRequestDetail(detail); + } catch (error) { + if (revision !== detailRequest.current) return; + setDetailError(error); + showApiError(error); + } finally { + if (revision === detailRequest.current) setLoadingDetail(false); + } + }; + const loadTasks = async (preferredId?: number | null, preferredInstanceId?: number | null) => { - setLoadingList(true); + const revision = ++listRequest.current; + ++detailRequest.current; + setLoadingDetail(false); + setLoadingList(true); setListError(null); try { const resp = await listMyApprovalTasksApi(); + if (revision !== listRequest.current) return; setTaskItems(resp.data); // Prefer the explicit task id; otherwise resolve the task from the notification's // instance id. Channel/space subscribe approval notifications only carry instance_id, @@ -137,17 +174,25 @@ export function ApprovalPane({ const visibleItems = targetFilter === "pending_me" ? resp.data.filter((t) => t.status === "pending") : resp.data.filter((t) => t.status !== "pending"); - const nextId = (resolvedTask ? getId(resolvedTask, "task") : null) ?? getId(visibleItems[0], "task"); + const nextId = resolvedTask ? getId(resolvedTask, "task") + : preferredId || preferredInstanceId ? null : getId(visibleItems[0], "task"); setSelectedTaskId(nextId); - if (nextId) { setLoadingDetail(true); setTaskDetail(await getMyApprovalTaskDetailApi(nextId)); } - else setTaskDetail(null); - } finally { setLoadingList(false); setLoadingDetail(false); } + await loadDetail(nextId, "task"); + } catch (error) { + if (revision !== listRequest.current) return; + setListError(error); setTaskDetail(null); setRequestDetail(null); + showApiError(error); + } finally { if (revision === listRequest.current) setLoadingList(false); } }; const loadRequests = async (preferredId?: number | null) => { - setLoadingList(true); + const revision = ++listRequest.current; + ++detailRequest.current; + setLoadingDetail(false); + setLoadingList(true); setListError(null); try { const resp = await listMyApprovalRequestsApi(); + if (revision !== listRequest.current) return; setRequestItems(resp.data); // If there's a preferred instance, derive the correct filter tab from its status so // the item is actually visible in the left panel (not hidden by the current filter). @@ -164,11 +209,14 @@ export function ApprovalPane({ ? resp.data.filter((i) => IN_PROGRESS_STATUSES.has(i.status ?? "")) : resp.data.filter((i) => !IN_PROGRESS_STATUSES.has(i.status ?? "")); const validPreferred = preferredId && resp.data.some((i) => getId(i, "instance") === preferredId) ? preferredId : null; - const nextId = validPreferred ?? getId(visibleItems[0], "instance"); + const nextId = validPreferred ?? preferredId ?? getId(visibleItems[0], "instance"); setSelectedInstanceId(nextId); - if (nextId) { setLoadingDetail(true); setRequestDetail(await getApprovalInstanceDetailApi(nextId)); } - else setRequestDetail(null); - } finally { setLoadingList(false); setLoadingDetail(false); } + await loadDetail(nextId, "request"); + } catch (error) { + if (revision !== listRequest.current) return; + setListError(error); setTaskDetail(null); setRequestDetail(null); + showApiError(error); + } finally { if (revision === listRequest.current) setLoadingList(false); } }; useEffect(() => { @@ -176,15 +224,19 @@ export function ApprovalPane({ setSelectedTaskId(target?.taskId ?? null); setSelectedInstanceId(target?.instanceId ?? null); setSearchQuery(""); + setReviewTarget(null); // Deep-links (from a notification) open straight to the detail; otherwise land on the list. setCompactView(target?.taskId || target?.instanceId ? "detail" : "list"); // setCompactView is owned by the shell and stable; excluding it keeps this to a mount/deep-link reset. // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, target?.instanceId, target?.taskId]); - // Switching the nav section clears the search box so the new list starts unfiltered. + // Switching the nav section clears the search box so the new list starts + // unfiltered, and closes the review view — its release belongs to the + // request that was open, not to whatever the other list shows. useEffect(() => { setSearchQuery(""); + setReviewTarget(null); }, [activeTab]); useEffect(() => { @@ -194,52 +246,46 @@ export function ApprovalPane({ // loadTasks/loadRequests are re-created every render; listing them would refetch on each // keystroke. The load is intentionally keyed on "which list is being shown". // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, activeTab]); + }, [open, activeTab, target?.taskId, target?.instanceId]); // Auto-select first visible item when sub-filter changes const autoSelectTask = (items: ApprovalTaskItem[]) => { const first = getId(items[0], "task"); setSelectedTaskId(first); - if (first) { setLoadingDetail(true); getMyApprovalTaskDetailApi(first).then(setTaskDetail).finally(() => setLoadingDetail(false)); } - else setTaskDetail(null); + void loadDetail(first, "task"); }; const autoSelectRequest = (items: ApprovalInstanceItem[]) => { const first = getId(items[0], "instance"); setSelectedInstanceId(first); - if (first) { setLoadingDetail(true); getApprovalInstanceDetailApi(first).then(setRequestDetail).finally(() => setLoadingDetail(false)); } - else setRequestDetail(null); + void loadDetail(first, "request"); }; - // Re-select only when the sub-filter itself changes — following the item lists would fight the - // user's own selection on every list refresh. - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => { if (activeTab === "my_tasks") autoSelectTask(filteredTaskItems); }, [taskFilter]); - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => { if (activeTab === "my_requests") autoSelectRequest(filteredRequestItems); }, [requestsFilter]); - const openTask = async (id: number) => { setSelectedTaskId(id); setLoadingDetail(true); setDecisionComment(""); setCompactView("detail"); - try { setTaskDetail(await getMyApprovalTaskDetailApi(id)); } finally { setLoadingDetail(false); } + await loadDetail(id, "task"); }; const openRequest = async (id: number) => { setSelectedInstanceId(id); setLoadingDetail(true); setCompactView("detail"); - try { setRequestDetail(await getApprovalInstanceDetailApi(id)); } finally { setLoadingDetail(false); } + await loadDetail(id, "request"); }; const runTaskDecision = async (action: "approve" | "reject") => { - if (!selectedTaskId) return; + if (!selectedTaskId || !taskDetail || getId(taskDetail, "task") !== selectedTaskId || loadingDetail || detailError) return; + const comment = decisionComment.trim(); + // A rejection must say why; approving without a comment stays allowed. + if (action === "reject" && !comment) { + showToast({ message: localize("com_approval_reject_reason_required"), severity: NotificationSeverity.WARNING }); + return; + } setActionLoading(true); - const comment = - decisionComment.trim() || - localize(action === "approve" ? "com_approval_action_approve" : "com_approval_action_reject"); try { - await decideApprovalTaskApi(selectedTaskId, { action, comment }); + await decideApprovalTaskApi(selectedTaskId, { action, comment: comment || undefined }); setDecisionComment(""); await loadTasks(selectedTaskId); onPendingCountMaybeChanged?.(); toast(true); } - catch { toast(false); } finally { setActionLoading(false); } + catch (error) { showApiError(error); } finally { setActionLoading(false); } }; const runWithdraw = () => { setWithdrawReason(""); @@ -247,18 +293,14 @@ export function ApprovalPane({ }; const confirmWithdraw = async () => { if (!selectedInstanceId) return; - setWithdrawDialogOpen(false); setActionLoading(true); try { await withdrawApprovalInstanceApi(selectedInstanceId, { reason: withdrawReason.trim() || undefined }); + setWithdrawDialogOpen(false); toast(true); - const resp = await listMyApprovalRequestsApi(); - setRequestItems(resp.data); - setRequestsFilter("completed"); - setLoadingDetail(true); - setRequestDetail(await getApprovalInstanceDetailApi(selectedInstanceId)); + await loadRequests(selectedInstanceId); onPendingCountMaybeChanged?.(); - } catch { toast(false); } finally { setActionLoading(false); setLoadingDetail(false); } + } catch (error) { showApiError(error); } finally { setActionLoading(false); } }; const runRevokeGrant = () => { if (!taskDetail?.instance_id) return; @@ -276,19 +318,19 @@ export function ApprovalPane({ }); return; } - setRevokeDialogOpen(false); setActionLoading(true); try { await revokeMenuAccessGrantApi(instanceId, { reason }); + setRevokeDialogOpen(false); await loadTasks(selectedTaskId); onPendingCountMaybeChanged?.(); toast(true); } - catch { toast(false); } finally { setActionLoading(false); } + catch (error) { showApiError(error); } finally { setActionLoading(false); } }; - const isTaskPending = activeTab === "my_tasks" && taskDetail?.status === "pending"; - const isInstancePending = activeTab === "my_requests" && requestDetail?.status === "pending"; + const isTaskPending = !loadingDetail && !loadingList && !detailError && !listError && activeTab === "my_tasks" && taskDetail?.status === "pending"; + const isInstancePending = !loadingDetail && !loadingList && !detailError && !listError && activeTab === "my_requests" && requestDetail?.status === "pending"; // Only the approver (my_tasks) can revoke a granted menu permission, and only if not already revoked const canRevoke = activeTab === "my_tasks" && @@ -296,12 +338,24 @@ export function ApprovalPane({ ["approved", "executed"].includes(String(taskDetail?.instance_status ?? "").toLowerCase()) && !taskDetail?.grant_revoked; + if (reviewTarget) { + return ( +
    + setReviewTarget(null)} + /> +
    + ); + } + return ( <> {/* Left list — hidden in compact detail view */}
    {listHeader} - {/* 待我处理/已处理 · 审批中/已完成 — design-system Tabs (line type, ink + {/* Pending/processed tasks and active/completed requests use shared Tabs (line type, ink variant: the surrounding column already draws its own edges, so no divider, and an accent here would fight the status badges in the list). */}
    @@ -322,8 +376,13 @@ export function ApprovalPane({ } activeKey={activeTab === "my_tasks" ? taskFilter : requestsFilter} onChange={(key) => { - if (activeTab === "my_tasks") setTaskFilter(key as TaskFilter); - else setRequestsFilter(key as RequestsFilter); + if (activeTab === "my_tasks") { + setTaskFilter(key as TaskFilter); + autoSelectTask(taskItems.filter((item) => key === "pending_me" ? item.status === "pending" : item.status !== "pending")); + } else { + setRequestsFilter(key as RequestsFilter); + autoSelectRequest(requestItems.filter((item) => key === "in_progress" ? IN_PROGRESS_STATUSES.has(item.status ?? "") : !IN_PROGRESS_STATUSES.has(item.status ?? ""))); + } }} />
    @@ -342,6 +401,11 @@ export function ApprovalPane({ {loadingList ? (
    {localize("com_approval_loading")}
    + ) : listError ? ( +
    + {listError instanceof Error ? listError.message : localize('com_ui_error')} + +
    ) : (
    {(activeTab === "my_tasks" ? filteredTaskItems : filteredRequestItems).length === 0 ? ( @@ -408,10 +472,15 @@ export function ApprovalPane({
    {loadingDetail ? (
    {localize("com_approval_loading")}
    + ) : detailError ? ( +
    + {detailError instanceof Error ? detailError.message : localize('com_ui_error')} + +
    ) : activeTab === "my_tasks" && taskDetail ? ( - setCompactView("list")} /> + setCompactView("list")} onOpenReview={setReviewTarget} onRefresh={() => void loadDetail(selectedTaskId, 'task')} /> ) : activeTab === "my_requests" && requestDetail ? ( - setCompactView("list")} /> + setCompactView("list")} onOpenReview={setReviewTarget} onRefresh={() => void loadDetail(selectedInstanceId, 'request')} /> ) : (
    {localize("com_approval_empty_detail")}
    )} @@ -432,7 +501,7 @@ export function ApprovalPane({
    {isTaskPending && ( <> - } +
    +

    {localize('approval.current_visibility.currentHint')}

    + {!available ?

    {localize(value?.state === 'deleted' ? 'approval.current_visibility.deleted' : 'approval.current_visibility.unavailable')}

    : <> + {value.owner_only === true &&

    {localize('approval.current_visibility.ownerOnly')}

    } + {value.subjects.length === 0 &&

    {localize('approval.current_visibility.noOrdinary')}

    } +
      {value.subjects.map((entry, index) =>
    • + {localize(`approval.current_visibility.${entry.type}`)} · {entry.name} ({entry.id}) + {entry.userset_relation && ` · ${localize(`approval.current_visibility.${entry.userset_relation === 'subtree_member' ? 'member' : entry.userset_relation}`)}`} + {entry.include_children && ` · ${localize('approval.current_visibility.children')}`} +
    • )}
    + } + {value?.admin_implicit_access &&

    {localize('approval.current_visibility.adminHint')}

    } + {value?.checked_at &&

    {localize('approval.current_visibility.checkedAt')} {formatTime(value.checked_at)}

    } + ; +} diff --git a/src/frontend/client/src/components/approval/ReviewSourcePane.tsx b/src/frontend/client/src/components/approval/ReviewSourcePane.tsx new file mode 100644 index 0000000000..6ce883f3ac --- /dev/null +++ b/src/frontend/client/src/components/approval/ReviewSourcePane.tsx @@ -0,0 +1,230 @@ +import { Outlined } from "bisheng-icons"; +import { useEffect, useMemo, useState } from "react"; +import { + getSnapshotFileApi, + type SnapshotEntry, + type SnapshotFile, +} from "~/api/hostedAppReview"; +import { extractApiErrorMessage } from "~/utils/apiStatusError"; +import { cn } from "~/utils"; +import type { LocalizeFn } from "./approvalPresentation"; +import { ancestorPaths, buildReviewTree, pickInitialFile, type ReviewTreeNode } from "./reviewTree"; + +/** + * Left file tree, right read-only code — the body of the review view (AC-25). + * + * Read-only is structural, not a `readOnly` attribute: the source is rendered + * as text in a `
    `, there is no editor and no download. What comes back is
    + * already masked by the backend, and a file the scanner would not read
    + * (binary, over 1 MiB) is reported as such instead of being served.
    + */
    +
    +interface ReviewSourcePaneProps {
    +  appId: string;
    +  versionId: string;
    +  entries: SnapshotEntry[];
    +  /** The package holds more entries than the platform will list. */
    +  truncated: boolean;
    +  localize: LocalizeFn;
    +}
    +
    +const REASON_KEY: Record = {
    +  binary: "com_approval_review_not_previewable_binary",
    +  too_large: "com_approval_review_not_previewable_too_large",
    +};
    +
    +function TreeRow({
    +  node,
    +  depth,
    +  selected,
    +  openDirs,
    +  onToggle,
    +  onSelect,
    +}: {
    +  node: ReviewTreeNode;
    +  depth: number;
    +  selected: string | null;
    +  openDirs: Set;
    +  onToggle: (path: string) => void;
    +  onSelect: (path: string) => void;
    +}) {
    +  const isDir = node.type === "dir";
    +  const isOpen = isDir && openDirs.has(node.path);
    +  return (
    +    <>
    +      
    +      {isOpen &&
    +        node.children.map((child) => (
    +          
    +        ))}
    +    
    +  );
    +}
    +
    +export function ReviewSourcePane({ appId, versionId, entries, truncated, localize }: ReviewSourcePaneProps) {
    +  const tree = useMemo(() => buildReviewTree(entries), [entries]);
    +  const [selected, setSelected] = useState(null);
    +  const [openDirs, setOpenDirs] = useState>(new Set());
    +  const [file, setFile] = useState(null);
    +  const [loading, setLoading] = useState(false);
    +  // A box rather than a string for the same reason as in the view above: an
    +  // empty message must still read as "this failed", not as "nothing to show".
    +  const [failure, setFailure] = useState<{ message: string } | null>(null);
    +
    +  // Land on the manifest (or the first readable file) and open the folders
    +  // that hold it, so the pane never opens on an empty right-hand side.
    +  useEffect(() => {
    +    const initial = pickInitialFile(entries);
    +    setSelected(initial);
    +    setOpenDirs(new Set(initial ? ancestorPaths(initial) : []));
    +  }, [entries]);
    +
    +  useEffect(() => {
    +    if (!selected) {
    +      setFile(null);
    +      return;
    +    }
    +    const entry = entries.find((one) => one.path === selected);
    +    if (entry && entry.previewable === false) {
    +      // The tree already knows this one will not be served; asking anyway
    +      // would spend a round trip to be told the same thing.
    +      setFile(null);
    +      setFailure(null);
    +      return;
    +    }
    +    let cancelled = false;
    +    setLoading(true);
    +    setFailure(null);
    +    getSnapshotFileApi(appId, versionId, selected)
    +      .then((data) => {
    +        if (!cancelled) setFile(data);
    +      })
    +      .catch((error: unknown) => {
    +        if (cancelled) return;
    +        setFile(null);
    +        setFailure({ message: extractApiErrorMessage(error) });
    +      })
    +      .finally(() => {
    +        if (!cancelled) setLoading(false);
    +      });
    +    return () => {
    +      cancelled = true;
    +    };
    +  }, [appId, versionId, selected, entries]);
    +
    +  const handleToggle = (path: string) =>
    +    setOpenDirs((current) => {
    +      const next = new Set(current);
    +      if (next.has(path)) next.delete(path);
    +      else next.add(path);
    +      return next;
    +    });
    +
    +  const selectedEntry = selected ? (entries.find((one) => one.path === selected) ?? null) : null;
    +  const notPreviewableKey =
    +    selectedEntry && selectedEntry.type === "file" && selectedEntry.previewable === false
    +      ? (REASON_KEY[selectedEntry.reason ?? ""] ?? "com_approval_review_not_previewable_generic")
    +      : null;
    +
    +  if (entries.length === 0) {
    +    return 

    {localize("com_approval_review_tree_empty")}

    ; + } + + return ( +
    +
    + {tree.map((node) => ( + + ))} + {truncated && ( +

    + {localize("com_approval_review_tree_truncated")} +

    + )} +
    + +
    + {selected === null ? ( +

    {localize("com_approval_review_select_file")}

    + ) : ( + <> +
    + + {selected} + + {file && file.masked_secrets > 0 && ( + + {localize("com_approval_review_masked_secrets", { count: file.masked_secrets })} + + )} +
    + {notPreviewableKey ? ( +

    {localize(notPreviewableKey)}

    + ) : failure ? ( +

    + {failure.message || localize("com_approval_review_load_failed")} +

    + ) : loading || !file ? ( + // `!file` as well as `loading`: the fetch starts in an effect, so + // between picking a file and that effect running there is a frame + // with neither a file nor a pending request — reading it as "not + // previewable" would flash a wrong explanation on every click. +

    {localize("com_approval_review_loading")}

    + ) : file.previewable ? ( + // `content` on a previewable file can legitimately be the empty + // string — an empty file in the package. Branching on the verdict + // rather than on the text keeps an empty file from being reported + // as one the platform refuses to show. +
    +                {file.content ?? ""}
    +              
    + ) : ( +

    + {localize( + REASON_KEY[file.reason ?? ""] ?? "com_approval_review_not_previewable_generic", + )} +

    + )} + + )} +
    +
    + ); +} diff --git a/src/frontend/client/src/components/approval/approvalPresentation.tsx b/src/frontend/client/src/components/approval/approvalPresentation.tsx index 2eec406a17..86bc8bc8cd 100644 --- a/src/frontend/client/src/components/approval/approvalPresentation.tsx +++ b/src/frontend/client/src/components/approval/approvalPresentation.tsx @@ -1,8 +1,12 @@ import { Tag } from "@bisheng/ui"; +import { Outlined } from "bisheng-icons"; import { useLocalize } from "~/hooks"; import { type TranslationKeys } from "~/hooks/useLocalize"; import { cn } from "~/utils"; +/** `localize` is handed down as a prop so these stay pure presentational helpers. */ +export type LocalizeFn = ReturnType; + export type TaskFilter = "pending_me" | "processed"; export type RequestsFilter = "in_progress" | "completed"; export const IN_PROGRESS_STATUSES = new Set(["pending", "exception", "execute_failed"]); @@ -131,7 +135,15 @@ export function formatTitle( return businessName; } -export const DETAIL_INTERNAL_KEYS = new Set(["menu_key", "space_id", "channel_id", "applicant_user_id", "applicant_user_name"]); +// Keys the generic two-column grid must never print: internal ids, plus the +// structured sub-trees the app-publish scenario ships (objects/arrays would +// render as "[object Object]" under their raw English key name). The dedicated +// AppPublishDetailPanel renders those; anything not dispatched just hides them. +export const DETAIL_INTERNAL_KEYS = new Set([ + "menu_key", "space_id", "channel_id", "applicant_user_id", "applicant_user_name", + "scenario_code", "app_id", "owner_user_id", "version_id", "deployment_id", "tenant_id", + "capabilities", "visibility_snapshot", "tier", "schema_change", "approver_note", +]); export function localizeFieldKey(key: string, localize: ReturnType): string { const map: Record = { @@ -162,3 +174,44 @@ export function InfoGrid({ rows }: { rows: [string, string][] }) {
    ); } + +/** + * Sticky title/status/serial strip shared by every detail panel. Lives here, + * not in `ApprovalDetailPanels`, so a per-scenario panel (`AppPublishDetailPanel`) + * can reuse it without importing the panels module that dispatches to it. + */ +export function DetailHeader({ title, status, instanceStatus, scope, serialNo, scenarioName, createTime, localize, onBack }: { + title?: string; status?: string; instanceStatus?: string; scope: "task" | "instance"; serialNo: string; scenarioName?: string; createTime?: string | null; localize: ReturnType; onBack?: () => void; +}) { + return ( + // Pinned to the top of the scrolling detail pane so the title/status/serial stay visible while the body scrolls. +
    +
    + {/* Compact-only back control — sits to the left of the detail title, split by a short vertical divider. + h-8 matches the title line so the arrow centers against it under items-start. */} + {onBack && ( +
    + + +
    + )} + {/* Title + serial share one column so the serial line aligns with the title, not the back arrow. */} +
    +
    + {/* Same line box as the list column's section heading (text-base/leading-8 at pt-4), + so both titles sit on one line across the two columns. */} +

    {title || "--"}

    + {/* §4 — one object's status, meant to be seen from across the page, + so the medium rung; the dot comes from StatusBadge itself. */} + +
    +

    + {serialNo} · {scenarioName || "--"} · {formatTime(createTime)} +

    +
    +
    +
    + ); +} diff --git a/src/frontend/client/src/components/approval/reviewTree.test.ts b/src/frontend/client/src/components/approval/reviewTree.test.ts new file mode 100644 index 0000000000..c8bdc5654f --- /dev/null +++ b/src/frontend/client/src/components/approval/reviewTree.test.ts @@ -0,0 +1,79 @@ +import type { SnapshotEntry } from "~/api/hostedAppReview"; +import { ancestorPaths, buildReviewTree, pickInitialFile } from "./reviewTree"; + +/** What `GET …/snapshot/tree` sends: flat, path-sorted, directories derived. */ +function entries(): SnapshotEntry[] { + return [ + { path: "README.md", name: "README.md", type: "file", size: 12, previewable: true, reason: null }, + { path: "bisheng-app.yaml", name: "bisheng-app.yaml", type: "file", size: 40, previewable: true, reason: null }, + { path: "src", name: "src", type: "dir", size: null }, + { path: "src/app", name: "app", type: "dir", size: null }, + { path: "src/app/main.py", name: "main.py", type: "file", size: 90, previewable: true, reason: null }, + { path: "src/logo.png", name: "logo.png", type: "file", size: 9000, previewable: false, reason: "binary" }, + ]; +} + +describe("buildReviewTree", () => { + it("nests every entry under its directory", () => { + const tree = buildReviewTree(entries()); + + // Directories first, then files by `localeCompare` — which is + // case-insensitive, so `bisheng-app.yaml` sorts above `README.md`. + expect(tree.map((node) => node.path)).toEqual(["src", "bisheng-app.yaml", "README.md"]); + const src = tree[0]; + expect(src.children.map((node) => node.path)).toEqual(["src/app", "src/logo.png"]); + expect(src.children[0].children.map((node) => node.path)).toEqual(["src/app/main.py"]); + }); + + it("puts directories before files and sorts each group by name", () => { + const tree = buildReviewTree([ + { path: "z.py", name: "z.py", type: "file", size: 1, previewable: true, reason: null }, + { path: "a.py", name: "a.py", type: "file", size: 1, previewable: true, reason: null }, + { path: "lib", name: "lib", type: "dir", size: null }, + ]); + expect(tree.map((node) => node.name)).toEqual(["lib", "a.py", "z.py"]); + }); + + it("carries the previewable verdict through so the tree can grey a file out", () => { + const tree = buildReviewTree(entries()); + const logo = tree[0].children.find((node) => node.name === "logo.png"); + expect(logo).toMatchObject({ previewable: false, reason: "binary" }); + }); + + it("keeps a file whose directory was never listed at the root rather than dropping it", () => { + // An archive that omits directory members is the normal case; the backend + // derives them, but the fold must not depend on that having happened. + const tree = buildReviewTree([ + { path: "deep/nested/file.py", name: "file.py", type: "file", size: 1, previewable: true, reason: null }, + ]); + expect(tree).toHaveLength(1); + expect(tree[0].path).toBe("deep/nested/file.py"); + }); +}); + +describe("ancestorPaths", () => { + it("names every directory that has to be open for a path to be visible", () => { + expect(ancestorPaths("src/app/main.py")).toEqual(["src", "src/app"]); + expect(ancestorPaths("main.py")).toEqual([]); + }); +}); + +describe("pickInitialFile", () => { + it("opens on the manifest, which is the file every reviewer reads first", () => { + expect(pickInitialFile(entries())).toBe("bisheng-app.yaml"); + }); + + it("falls back to the first readable file and skips the ones that cannot be shown", () => { + const withoutManifest = entries().filter((entry) => entry.path !== "bisheng-app.yaml"); + expect(pickInitialFile(withoutManifest)).toBe("README.md"); + expect( + pickInitialFile([ + { path: "logo.png", name: "logo.png", type: "file", size: 9, previewable: false, reason: "binary" }, + ]), + ).toBeNull(); + }); + + it("returns null for a package with no files at all", () => { + expect(pickInitialFile([])).toBeNull(); + }); +}); diff --git a/src/frontend/client/src/components/approval/reviewTree.ts b/src/frontend/client/src/components/approval/reviewTree.ts new file mode 100644 index 0000000000..b33e9d1952 --- /dev/null +++ b/src/frontend/client/src/components/approval/reviewTree.ts @@ -0,0 +1,88 @@ +import type { SnapshotEntry } from "~/api/hostedAppReview"; + +/** + * Folding the backend's flat entry list into the tree the review view draws. + * + * The listing is flat and path-sorted by design (an archive that omits + * directory members still yields a complete tree, because the backend derives + * directories from file paths). Turning it back into a tree is pure string + * work, so it lives apart from the component and is tested on its own. + */ + +export interface ReviewTreeNode { + path: string; + name: string; + type: "file" | "dir"; + size: number | null; + previewable: boolean; + reason: string | null; + children: ReviewTreeNode[]; +} + +function parentPath(path: string): string { + const cut = path.lastIndexOf("/"); + return cut === -1 ? "" : path.slice(0, cut); +} + +/** + * Directories first, then files, each alphabetically — the order every file + * browser uses, and the one that keeps a deep `src/` from being buried under + * loose root files. + */ +function compareNodes(a: ReviewTreeNode, b: ReviewTreeNode): number { + if (a.type !== b.type) return a.type === "dir" ? -1 : 1; + return a.name.localeCompare(b.name); +} + +export function buildReviewTree(entries: SnapshotEntry[]): ReviewTreeNode[] { + const nodes = new Map(); + const roots: ReviewTreeNode[] = []; + + // Shallowest first so a parent always exists by the time its child arrives; + // the backend sorts by path, which is not the same thing ("a/b" precedes + // "a.txt" but "a" precedes both). + const ordered = [...entries].sort( + (a, b) => a.path.split("/").length - b.path.split("/").length || a.path.localeCompare(b.path), + ); + + for (const entry of ordered) { + if (!entry.path || nodes.has(entry.path)) continue; + const node: ReviewTreeNode = { + path: entry.path, + name: entry.name || entry.path.split("/").pop() || entry.path, + type: entry.type, + size: entry.size ?? null, + previewable: entry.type === "file" ? entry.previewable !== false : false, + reason: entry.reason ?? null, + children: [], + }; + nodes.set(node.path, node); + const parent = nodes.get(parentPath(entry.path)); + if (parent && parent.type === "dir") parent.children.push(node); + else roots.push(node); + } + + const sortDeep = (list: ReviewTreeNode[]) => { + list.sort(compareNodes); + for (const node of list) sortDeep(node.children); + }; + sortDeep(roots); + return roots; +} + +/** Every directory on the way to `path` — what has to be open for it to be visible. */ +export function ancestorPaths(path: string): string[] { + const parts = path.split("/").filter(Boolean); + return parts.slice(0, -1).map((_part, index) => parts.slice(0, index + 1).join("/")); +} + +/** + * Which file to open when the view mounts: the manifest if the package has + * one (it is the one file every reviewer reads first), else the first + * previewable file in tree order, else nothing. + */ +export function pickInitialFile(entries: SnapshotEntry[]): string | null { + const files = entries.filter((entry) => entry.type === "file" && entry.previewable !== false); + const manifest = files.find((entry) => entry.path === "bisheng-app.yaml"); + return manifest?.path ?? files[0]?.path ?? null; +} diff --git a/src/frontend/client/src/components/dsh/DshDesktopDialog.tsx b/src/frontend/client/src/components/dsh/DshDesktopDialog.tsx index 638bcf935a..f86345e765 100644 --- a/src/frontend/client/src/components/dsh/DshDesktopDialog.tsx +++ b/src/frontend/client/src/components/dsh/DshDesktopDialog.tsx @@ -1,11 +1,6 @@ -import { Button } from '@bisheng/ui'; -import { useQuery } from '@tanstack/react-query'; -import { useEffect, useState } from 'react'; -import { dshLaunchUrl } from '~/utils/dshLaunch'; -import { getDshUsageSummary } from '~/api/dsh'; import { Dialog, DialogContent, DialogTitle } from '~/components/ui/Dialog'; -import { useAuthContext, useLocalize } from '~/hooks'; -import { DshUsageCalendar } from './DshUsageCalendar'; +import { useLocalize } from '~/hooks'; +import { DshDesktopPanel } from './DshDesktopPanel'; interface DshDesktopDialogProps { open: boolean; @@ -16,30 +11,10 @@ interface DshDesktopDialogProps { export function DshDesktopDialog({ open, onOpenChange, downloadUrl, launchUrl }: DshDesktopDialogProps) { const t = useLocalize(); - const { user } = useAuthContext(); - const [launchAttempted, setLaunchAttempted] = useState(false); - useEffect(() => { if (!open) setLaunchAttempted(false); }, [open]); - const handleOpenDesktop = () => { - setLaunchAttempted(true); - window.location.assign(dshLaunchUrl(launchUrl)); - }; - const usage = useQuery({ - queryKey: ['dsh-self-usage-summary', user?.id], enabled: open && Boolean(user?.id), retry: false, staleTime: 0, - queryFn: ({ signal }) => getDshUsageSummary(signal), - }); return {t('dsh_title')} -
    - - {downloadUrl && } -
    - {launchAttempted &&

    - {t(downloadUrl ? 'dsh.launchHelp' : 'dsh.launchHelpNoDownload')} -

    } - {usage.isLoading ?

    {t('dsh_loading')}

    : usage.isError ? -

    {t('dsh_load_error')}

    : - usage.data && } +
    ; } diff --git a/src/frontend/client/src/components/dsh/DshDesktopPanel.tsx b/src/frontend/client/src/components/dsh/DshDesktopPanel.tsx new file mode 100644 index 0000000000..ba1968028b --- /dev/null +++ b/src/frontend/client/src/components/dsh/DshDesktopPanel.tsx @@ -0,0 +1,47 @@ +import { Button } from '@bisheng/ui'; +import { useQuery } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; +import { dshLaunchUrl } from '~/utils/dshLaunch'; +import { getDshUsageSummary } from '~/api/dsh'; +import { useAuthContext, useLocalize } from '~/hooks'; +import { DshUsageCalendar } from './DshUsageCalendar'; + +interface DshDesktopPanelProps { + /** Usage loads only when the panel is on screen; the dialog passes its open state. */ + active: boolean; + downloadUrl: string | null; + launchUrl: string; +} + +/** + * Launch/download actions plus the usage calendar. Shared so the dialog and the + * settings section show the same thing rather than drifting apart. + */ +export function DshDesktopPanel({ active, downloadUrl, launchUrl }: DshDesktopPanelProps) { + const t = useLocalize(); + const { user } = useAuthContext(); + // The launch is a protocol handover the page cannot observe, so the guidance + // stays up until the panel is closed or left. + const [launchAttempted, setLaunchAttempted] = useState(false); + useEffect(() => { if (!active) setLaunchAttempted(false); }, [active]); + const handleOpenDesktop = () => { + setLaunchAttempted(true); + window.location.assign(dshLaunchUrl(launchUrl)); + }; + const usage = useQuery({ + queryKey: ['dsh-self-usage-summary', user?.id], enabled: active && Boolean(user?.id), retry: false, staleTime: 0, + queryFn: ({ signal }) => getDshUsageSummary(signal), + }); + return <> +
    + + {downloadUrl && } +
    + {launchAttempted &&

    + {t(downloadUrl ? 'dsh.launchHelp' : 'dsh.launchHelpNoDownload')} +

    } + {usage.isLoading ?

    {t('dsh_loading')}

    : usage.isError ? +

    {t('dsh_load_error')}

    : + usage.data && } + ; +} diff --git a/src/frontend/client/src/components/messageApproval/NotificationRow.tsx b/src/frontend/client/src/components/messageApproval/NotificationRow.tsx index f4832375c9..5fc0203f98 100644 --- a/src/frontend/client/src/components/messageApproval/NotificationRow.tsx +++ b/src/frontend/client/src/components/messageApproval/NotificationRow.tsx @@ -1,8 +1,9 @@ +import { HoverCard, HoverCardTrigger, HoverCardContent, HoverCardPortal } from '~/components/ui/HoverCard'; import { Badge } from "@bisheng/ui"; import { useState } from "react"; import type { MessageItem } from "~/api/message"; import { Avatar, AvatarImage, AvatarName } from "~/components/ui/Avatar"; -import { TooltipAnchor } from "~/components/ui/Tooltip"; + import useLocalize, { type TranslationKeys } from "~/hooks/useLocalize"; import { cn } from "~/utils"; import { @@ -188,26 +189,16 @@ export function NotificationRow({ > {/* Avatar, text+time column and the unread dot are all top-aligned. */}
    - +
    + {userAvatar ? : } +
    {
    {userName}
    {userGroup ? (
    {userGroup}
    ) : null} -
    - } - > - - {userAvatar ? : } - -
    +
    }
    ]> = [ + ["zh-Hans", zhHans as Record], + ["en", en as Record], + ["ja", ja as Record], +]; + +/** What `NotificationRow` resolves an action code to, including its fallback. */ +function copyKeyFor(code: string): string { + return NOTIFICATION_ACTION_TEXT_KEYS[code] || `com_notifications_action_${code}`; +} + +describe("§3.0.3 — every hosted-application notification is readable", () => { + it.each(ALL_CODES)("%s has copy in zh-Hans, en and ja", (code) => { + const key = copyKeyFor(code); + for (const [language, bundle] of BUNDLES) { + const copy = bundle[key]; + expect(`${language}:${typeof copy}`).toBe(`${language}:string`); + expect(`${language}:${copy as string}`.length).toBeGreaterThan(language.length + 1); + } + }); + + it.each(ALL_CODES)("%s names the application it is about", (code) => { + // Without `{{target}}` the owner gets "your application was stopped" + // and has to guess which one — they may own several. + const key = copyKeyFor(code); + for (const [, bundle] of BUNDLES) { + expect(bundle[key]).toEqual(expect.stringContaining("{{target}}")); + } + }); +}); + +describe("§3.0.3 — statements stay statements", () => { + it.each(HOSTED_APP_STATEMENT_CODES)("%s carries no approval action", (code) => { + // AC-39: these are notifications; the buttons live on the publish face + // and in the approval centre. + expect(APPROVAL_CENTER_ACTION_CODES.has(code)).toBe(false); + expect(isApprovalMessageType("notify", code)).toBe(false); + }); + + it("keeps the approval-centre codes in the approval centre", () => { + // The other direction: an approver's task has to remain actionable, so + // the same split that silences the publish notices must not silence it. + expect(APPROVAL_CENTER_ACTION_CODES.has("approval_task_pending")).toBe(true); + }); + + it("treats a cancelled-by-delete notice as a statement", () => { + // AC-41: the request is already gone — there is nothing left to decide, + // and a button here would fail on press. + expect(isApprovalMessageType("notify", "approval_instance_cancelled")).toBe(false); + }); +}); + +describe("§3.0.3 — the four silent rows have no copy to render", () => { + // AC-44. Copy is the last place a decision like this survives: a leftover + // "your key was revoked" string is an invitation to wire up the sender. + const FORBIDDEN_FRAGMENTS = [ + "app_key_issued", + "app_key_revoked", + "app_capability_revoked", + "app_resource_released", + "app_listed_in_square", + "app_visibility_granted", + ]; + + it.each(FORBIDDEN_FRAGMENTS)("no action code or copy key exists for %s", (fragment) => { + expect(NOTIFICATION_ACTION_TEXT_KEYS[fragment]).toBeUndefined(); + for (const [, bundle] of BUNDLES) { + expect(bundle[`com_notifications_action_${fragment}`]).toBeUndefined(); + } + }); +}); diff --git a/src/frontend/client/src/components/messageApproval/notificationContent.test.ts b/src/frontend/client/src/components/messageApproval/notificationContent.test.ts new file mode 100644 index 0000000000..6824450657 --- /dev/null +++ b/src/frontend/client/src/components/messageApproval/notificationContent.test.ts @@ -0,0 +1,49 @@ +import en from "~/locales/en/translation.json"; +import ja from "~/locales/ja/translation.json"; +import zhHans from "~/locales/zh-Hans/translation.json"; +import { + APPROVAL_CENTER_ACTION_CODES, + NOTIFICATION_ACTION_TEXT_KEYS, + isApprovalMessageType, + resolveApprovalCenterTarget, +} from "./notificationContent"; + +// F056 AC-43: an administrator stopping / resuming someone else's hosted app +// sends the owner a statement. These codes must render copy in every language +// and must never grow an approval button. +const ADMIN_STATE_CHANGE_CODES = ["app_stopped_by_admin", "app_resumed_by_admin"] as const; + +describe("hosted app admin stop / resume notifications", () => { + it.each(ADMIN_STATE_CHANGE_CODES)("maps %s to a copy key shipped in all three languages", (code) => { + const key = NOTIFICATION_ACTION_TEXT_KEYS[code]; + expect(key).toBe(`com_notifications_action_${code}`); + for (const bundle of [zhHans, en, ja]) { + const copy = (bundle as Record)[key]; + expect(typeof copy).toBe("string"); + expect(copy).toEqual(expect.stringContaining("{{target}}")); + } + }); + + it.each(ADMIN_STATE_CHANGE_CODES)("%s is a statement, not an approval-centre item", (code) => { + expect(APPROVAL_CENTER_ACTION_CODES.has(code)).toBe(false); + expect(isApprovalMessageType("notify", code)).toBe(false); + }); +}); + + +describe("approval notification destinations", () => { + it.each(["approval_instance_withdrawn", "approval_instance_cancelled"])("opens %s in the recipient approver's processed tasks", (actionCode) => { + const target = resolveApprovalCenterTarget({ + action_code: actionCode, message_type: "notify", + content: [{ type: "system_text", content: actionCode }, { type: "business_url", content: "demo", metadata: { business_type: "approval_instance_id", data: { approval_instance_id: "42" } } }], + } as Parameters[0]); + expect(target).toEqual({ tab: "my_tasks", taskId: null, instanceId: 42 }); + }); + it("keeps explicit task and instance identities distinct", () => { + const target = resolveApprovalCenterTarget({ + action_code: "approval_task_pending", message_type: "notify", + content: [{ type: "system_text", content: "approval_task_pending" }, { type: "business_url", content: "demo", metadata: { business_type: "approval_instance_id", data: { approval_instance_id: "42", approval_task_id: "87" } } }], + } as Parameters[0]); + expect(target).toEqual({ tab: "my_tasks", taskId: 87, instanceId: 42 }); + }); +}); diff --git a/src/frontend/client/src/components/messageApproval/notificationContent.ts b/src/frontend/client/src/components/messageApproval/notificationContent.ts index 959d5cdc8d..0409e84992 100644 --- a/src/frontend/client/src/components/messageApproval/notificationContent.ts +++ b/src/frontend/client/src/components/messageApproval/notificationContent.ts @@ -9,7 +9,7 @@ type MessageWithLegacyBusinessId = MessageItem & { business_id?: string | number /** * Pure helpers for reading an inbox message's payload: action code, business target, * approval-center deep link. Extracted from the old NotificationsDialog so the merged - * 消息与审批 dialog can share them without dragging the whole component along. + * notifications and approvals can share them without importing the whole component. */ export const NOTIFICATION_ACTION_TEXT_KEYS: Record = { @@ -35,6 +35,19 @@ export const NOTIFICATION_ACTION_TEXT_KEYS: Record = { approval_exception_route_missing: "com_notifications_action_approval_exception_route_missing", approval_exception_approver_empty: "com_notifications_action_approval_exception_approver_empty", approval_execute_failed: "com_notifications_action_approval_execute_failed", + // The engine has emitted this one since the approval centre shipped, but it + // was never mapped — it only became reachable for users when deleting a + // hosted application started cancelling its pending publish request. + approval_instance_cancelled: "com_notifications_action_approval_instance_cancelled", + // hosted application publishing (F055): approval passed, the app did not go + // live. Statements only — the manual-publish button lives on the detail page. + app_publish_pending_capacity: "com_notifications_action_app_publish_pending_capacity", + app_publish_deploy_failed: "com_notifications_action_app_publish_deploy_failed", + // hosted application governance (F056 AC-43): an administrator stopped or + // resumed the owner's app. Statements only — deliberately NOT in + // APPROVAL_CENTER_ACTION_CODES, there is nothing to approve. + app_stopped_by_admin: "com_notifications_action_app_stopped_by_admin", + app_resumed_by_admin: "com_notifications_action_app_resumed_by_admin", menu_grant_revoked: "com_notifications_action_menu_grant_revoked", revoked_channel_admin: "com_notifications_action_revoked_channel_admin", revoked_knowledge_space_admin: "com_notifications_action_revoked_knowledge_space_admin", @@ -52,6 +65,7 @@ export const APPROVAL_CENTER_ACTION_CODES = new Set([ "approval_task_rejected", "approval_instance_approved", "approval_instance_withdrawn", + "approval_instance_cancelled", "approval_exception_cancelled", "approval_exception_route_missing", "approval_exception_approver_empty", @@ -69,6 +83,7 @@ export const APPROVAL_TASK_SCENARIO_TEXT_KEYS: Record = { menu_access_request: "com_notifications_action_request_menu_access", channel_subscribe_request: "com_notifications_action_request_channel", knowledge_space_subscribe_request: "com_notifications_action_request_knowledge_space", + app_publish_request: "com_notifications_action_request_app_publish", }; export const isKnowledgeSpaceApprovalActionCode = (actionCode?: string) => @@ -148,6 +163,10 @@ export const resolveApprovalCenterTarget = (notification: MessageItem) => { for (const part of parts) { const metadata: PartMetadata = part?.metadata ?? {}; const data = metadata?.data ?? {}; + const explicitTask = Number(data.approval_task_id ?? data.task_id); + const explicitInstance = Number(data.approval_instance_id ?? data.instance_id); + if (Number.isSafeInteger(explicitTask) && explicitTask > 0) taskId = explicitTask; + if (Number.isSafeInteger(explicitInstance) && explicitInstance > 0) instanceId = explicitInstance; const candidates = [ [metadata?.business_type, data], [metadata?.type, data], @@ -162,7 +181,7 @@ export const resolveApprovalCenterTarget = (notification: MessageItem) => { metadata?.business_id ?? payload?.business_id; const parsedTaskId = Number(rawTaskId); - if (Number.isFinite(parsedTaskId)) taskId = parsedTaskId; + if (Number.isSafeInteger(parsedTaskId) && parsedTaskId > 0) taskId = parsedTaskId; } if (!instanceId && /approval_instance_id|instance_id|approval_request_id/i.test(normalizedType)) { const rawInstanceId = @@ -172,7 +191,7 @@ export const resolveApprovalCenterTarget = (notification: MessageItem) => { metadata?.business_id ?? payload?.business_id; const parsedInstanceId = Number(rawInstanceId); - if (Number.isFinite(parsedInstanceId)) instanceId = parsedInstanceId; + if (Number.isSafeInteger(parsedInstanceId) && parsedInstanceId > 0) instanceId = parsedInstanceId; } } } @@ -181,13 +200,17 @@ export const resolveApprovalCenterTarget = (notification: MessageItem) => { const tab = isPendingApprovalStatus(notification.status) || actionCode === "approval_task_pending" || + actionCode === "approval_instance_withdrawn" || + actionCode === "approval_instance_cancelled" || actionCode === "request_menu_access" ? "my_tasks" : "my_requests"; + const resolvedInstanceId = instanceId ?? getApprovalRequestId(notification); return { tab, taskId, - instanceId: instanceId ?? getApprovalRequestId(notification), + instanceId: resolvedInstanceId != null && Number.isSafeInteger(resolvedInstanceId) && resolvedInstanceId > 0 + ? resolvedInstanceId : null, } as const; }; diff --git a/src/frontend/client/src/components/permission/ModeHeader.tsx b/src/frontend/client/src/components/permission/ModeHeader.tsx index 04cbb15e73..2d11321a8f 100644 --- a/src/frontend/client/src/components/permission/ModeHeader.tsx +++ b/src/frontend/client/src/components/permission/ModeHeader.tsx @@ -1,4 +1,7 @@ -import { AlertTriangle, Loader2, ShieldCheck } from "lucide-react"; +import { Outlined } from "bisheng-icons"; +// `ShieldCheck` stays on lucide: bisheng-icons has no shield / verified glyph +// (PeopleSafe and SafeRetrieval carry other meanings). +import { ShieldCheck } from "lucide-react"; import { useState } from "react"; import { applyResourcePermissionModeDraft, @@ -143,7 +146,7 @@ export function ModeHeader({ onClick={() => void handleCreateDraft(mode)} > {creating && !active ? ( -
    )} @@ -161,7 +151,7 @@ export function PermissionDialog({ className="mx-5 flex min-h-32 items-center justify-center gap-2 rounded-lg border border-red-200 bg-red-50 p-5 text-sm text-red-700 max-[768px]:mx-4" role="alert" > -
    )} @@ -175,27 +165,16 @@ export function PermissionDialog({ onApplied={handleModeApplied} /> - - setSubjectType(value as SubjectType) - } - className="flex min-h-0 flex-1 flex-col px-5 pb-5 pt-4 max-[768px]:px-4" - > -
    - - {SUBJECT_TABS.map((tab) => ( - - {localize(tab.labelKey)} - - ))} - - - {canAddPermission && ( +
    + setSubjectType(value as SubjectType)} + items={SUBJECT_TABS.map((tab) => ({ + key: tab.value, + label: localize(tab.labelKey), + }))} + extra={canAddPermission && (
    - - + /> +
    - - +
    +
    )} @@ -267,7 +242,7 @@ export function PermissionDialog({ {grantSubjectType === "department" && (
    @@ -79,9 +80,11 @@ export default function index() {
    {el.user_name || '--'}
    - { window.libname = el.name; }}> - - + { window.libname = el.name; }}> +
    + + +
    ))} @@ -89,10 +92,9 @@ export default function index() {
    -

    {t('dataset.collection')}

    -
    - + setPage(newPage)} diff --git a/src/frontend/platform/src/pages/DepartmentPage/components/AddMemberDialog.test.tsx b/src/frontend/platform/src/pages/DepartmentPage/components/AddMemberDialog.test.tsx new file mode 100644 index 0000000000..e09229c7f9 --- /dev/null +++ b/src/frontend/platform/src/pages/DepartmentPage/components/AddMemberDialog.test.tsx @@ -0,0 +1,20 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, expect, it, vi } from 'vitest' +import { addDepartmentMembersApi } from '@/controllers/API/department' +import { AddMemberDialog } from './AddMemberDialog' + +vi.mock('@/components/bs-comp/selectComponent/useSubjectSearch', () => ({ useSubjectSearch: () => ({ rows: [{ user_id: 7, user_name: 'Tester' }], keyword: 'Tester', setKeyword: vi.fn(), status: 'ready', loading: false, hasMore: false }) })) +vi.mock('@/controllers/API/department', () => ({ addDepartmentMembersApi: vi.fn(async () => ({})) })) +vi.mock('@/controllers/request', async (importOriginal) => ({ ...await importOriginal() })) +afterEach(() => { cleanup(); vi.clearAllMocks() }) + +it.each([1, 0])('keeps numeric affiliation %s in the member payload', async (affiliation) => { + const onAdded = vi.fn() + render() + await userEvent.click(screen.getByText('Tester')) + if (affiliation === 0) await userEvent.click(screen.getByRole('radio', { name: 'bs:department.secondary' })) + await userEvent.click(screen.getByRole('button', { name: 'confirmButton' })) + await waitFor(() => expect(addDepartmentMembersApi).toHaveBeenCalledWith('dept-1', { user_ids: [7], is_primary: affiliation })) + expect(onAdded).toHaveBeenCalledOnce() +}) diff --git a/src/frontend/platform/src/pages/DepartmentPage/components/AddMemberDialog.tsx b/src/frontend/platform/src/pages/DepartmentPage/components/AddMemberDialog.tsx index 7e8f753268..3b6a350fba 100644 --- a/src/frontend/platform/src/pages/DepartmentPage/components/AddMemberDialog.tsx +++ b/src/frontend/platform/src/pages/DepartmentPage/components/AddMemberDialog.tsx @@ -1,21 +1,25 @@ // @ts-strict-ignore -import { Button } from "@/components/bs-ui/button" +import { Button, SearchInput, notification } from '@bisheng/ui'; +import { Radio,RadioGroup } from "@bisheng/ui"; +import { SubjectBusinessId,SubjectSearchStatus } from "@/components/bs-comp/selectComponent/SubjectSearchStatus"; +import { useSubjectSearch } from "@/components/bs-comp/selectComponent/useSubjectSearch"; + import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/bs-ui/dialog" -import { Label } from "@/components/bs-ui/label" -import { SearchInput } from "@/components/bs-ui/input" -import { toast } from "@/components/bs-ui/toast/use-toast" -import { addDepartmentMembersApi } from "@/controllers/API/department" -import { getUsersApi } from "@/controllers/API/user" -import { captureAndAlertRequestErrorHoc } from "@/controllers/request" -import { Checkbox } from "@/components/bs-ui/checkBox" -import { useCallback, useEffect, useState } from "react" -import { useTranslation } from "react-i18next" +Dialog, +DialogContent, +DialogFooter, +DialogHeader, +DialogTitle, +} from "@/components/bs-ui/dialog"; + +import { Label } from "@/components/bs-ui/label"; + +import { addDepartmentMembersApi } from "@/controllers/API/department"; +import { getUsersApi } from "@/controllers/API/user"; +import { showApiError } from "@/controllers/request"; +import { Checkbox } from "@bisheng/ui"; +import { useCallback,useState } from "react"; +import { useTranslation } from "react-i18next"; interface AddMemberDialogProps { deptId: string @@ -26,38 +30,21 @@ interface AddMemberDialogProps { interface UserItem { user_id: number user_name: string + external_id?: string | null } export function AddMemberDialog({ deptId, onAdded, onClose }: AddMemberDialogProps) { const { t } = useTranslation() - const [keyword, setKeyword] = useState("") - const [users, setUsers] = useState([]) const [selectedIds, setSelectedIds] = useState>(new Set()) const [isPrimary, setIsPrimary] = useState(1) const [loading, setLoading] = useState(false) - // Search users - useEffect(() => { - if (!keyword) { - setUsers([]) - return - } - const timer = setTimeout(() => { - captureAndAlertRequestErrorHoc( - getUsersApi({ page: 1, pageSize: 10, name: keyword }) - ).then((res: any) => { - if (res?.data) { - setUsers( - res.data.map((u: any) => ({ - user_id: u.user_id, - user_name: u.user_name, - })) - ) - } - }) - }, 300) - return () => clearTimeout(timer) - }, [keyword]) + const search = useSubjectSearch({ + emptyByDefault: true, + fetchPage: (keyword, page, signal) => getUsersApi({ page, pageSize: 10, name: keyword }, { signal }), + }) + const users = search.rows + const keyword = search.keyword const toggleUser = useCallback((userId: number) => { setSelectedIds((prev) => { @@ -70,22 +57,17 @@ export function AddMemberDialog({ deptId, onAdded, onClose }: AddMemberDialogPro const handleSubmit = useCallback(() => { if (selectedIds.size === 0) { - toast({ title: t("bs:department.selectMembers"), variant: "error" }) + notification.open({ type: "error", title: String(t("bs:department.selectMembers") ?? ''), description: "" }) return } setLoading(true) - captureAndAlertRequestErrorHoc( - addDepartmentMembersApi(deptId, { + addDepartmentMembersApi(deptId, { user_ids: Array.from(selectedIds), is_primary: isPrimary, - }) - ).then((res) => { - setLoading(false) - if (res !== null) { - toast({ title: t("bs:department.addMember"), variant: "success" }) + }).then(() => { + notification.open({ type: "success", title: String(t("bs:department.addMember") ?? ''), description: "" }) onAdded() - } - }) + }).catch(showApiError).finally(() => setLoading(false)) }, [deptId, selectedIds, isPrimary, onAdded, t]) return ( @@ -98,17 +80,14 @@ export function AddMemberDialog({ deptId, onAdded, onClose }: AddMemberDialogPro {/* Search */}
    - setKeyword(e.target.value)} - /> +
    {/* User list */} -
    - {users.length === 0 ? ( +
    { const el = event.currentTarget; if (el.scrollTop + el.clientHeight >= el.scrollHeight - 40) search.loadMore() }}> + {search.tooLong || search.failed || search.loading ? : users.length === 0 ? (
    - {keyword ? t("bs:department.noMembers") : t("bs:department.searchMember")} + {keyword ? t("bs:subjectSearch.emptyUsers") : t("bs:department.searchMember")}
    ) : ( users.map((u) => ( @@ -118,12 +97,16 @@ export function AddMemberDialog({ deptId, onAdded, onClose }: AddMemberDialogPro onClick={() => toggleUser(u.user_id)} > - {u.user_name} + {u.user_name}
    )) )}
    + {!search.loading && !search.failed && !search.tooLong && users.length < search.total && !!search.normalized && ( + + )} + {/* Selected count */} {selectedIds.size > 0 && (

    @@ -134,30 +117,14 @@ export function AddMemberDialog({ deptId, onAdded, onClose }: AddMemberDialogPro {/* Primary/Secondary radio */}

    -
    - - -
    + setIsPrimary(Number(value))} className="flex gap-4"> + {t("bs:department.primary")} + {t("bs:department.secondary")} +
    -
-
) : ( - ({ label: r.role_name, value: String(r.id) }))} + aria-label={t("bs:department.assignRoles")} + options={roles + .filter((r) => r.id != null) + .map((r) => ({ label: r.role_name, value: String(r.id) }))} value={Array.from(selected).map(String)} - onChange={(vals) => - setSelected(new Set((vals as string[]).map((id) => Number(id)))) - } + onChange={(vals) => setSelected(new Set(vals.map((id) => Number(id))))} placeholder={t("bs:department.multiSelectRolesPlaceholder")} - searchPlaceholder={t("system.searchRoles")} /> )} - - {!isGuestDepartmentDeptId(dept.dept_id) ? ( - - ) : null} - - - )} - - {/* 全局保存 + 删除部门 */} - {!isArchived && ( -
-
-
- {canEditPermissions && ( - - )} -
-
- {onMarkAsTenant && !isRootDept && !dept.is_tenant_root && ( - - )} - {onMarkAsTenant && !isRootDept && dept.is_tenant_root && ( - - )} - {!isSynced && - dept.parent_id !== null && - !isGuestDepartmentDeptId(dept.dept_id) && ( - - )} -
-
- )} -
- ) + return { + FORM_CONTROL_WIDTH, + handleRestore, + isArchived, + t, + loading, + isSynced, + name, + setName, + isRootDept, + canEditParent, + parentIdValue, + setParentIdValue, + dept, + parentDisplayName, + canEditPermissions, + adminSelectValue, + setAdminSelectValue, + adminSelectValueRef, + defaultRoleIds, + assignableRoles, + setDefaultRoleIds, + applyDefaultRolesToExisting, + setApplyDefaultRolesToExisting, + handlePurge, + handleGlobalSave, + saving, + onMarkAsTenant, + onChanged, + handleDelete, + }; +} +export function DepartmentSettings(props: DepartmentSettingsProps) { + return ; } diff --git a/src/frontend/platform/src/pages/DepartmentPage/components/DepartmentSettingsView.tsx b/src/frontend/platform/src/pages/DepartmentPage/components/DepartmentSettingsView.tsx new file mode 100644 index 0000000000..b2a9d6a56c --- /dev/null +++ b/src/frontend/platform/src/pages/DepartmentPage/components/DepartmentSettingsView.tsx @@ -0,0 +1,248 @@ +import { Alert, Button, Input, Select, notification } from '@bisheng/ui'; +import { bsConfirm } from "@/components/bs-ui/alertDialog/useConfirm"; + +import { Checkbox, Tooltip as SharedTooltip } from "@bisheng/ui"; +import { Outlined } from "bisheng-icons"; + +import { TreeDepartmentSelect } from "@/components/bs-comp/department/TreeDepartmentSelect"; +import { useSelectLabels } from "@/components/bs-comp/formFields/useSelectLabels"; +import DepartmentUsersSelect, { + DepartmentUserOption, +} from "@/components/bs-comp/selectComponent/DepartmentUsersSelect"; + +import { Label } from "@/components/bs-ui/label"; +import { Separator } from "@/components/bs-ui/separator"; + +import { unmountTenantApi } from "@/controllers/API/department"; +import { handleUiRequest } from "@/controllers/request"; +import { isGuestDepartmentDeptId } from "@/pages/DepartmentPage/constants/systemDepartments"; + +import type { useDepartmentSettingsController } from "./DepartmentSettings"; +interface DepartmentSettingsViewProps { + controller: ReturnType; +} +export function DepartmentSettingsView({ controller }: DepartmentSettingsViewProps) { + const { + FORM_CONTROL_WIDTH, + handleRestore, + isArchived, + t, + loading, + isSynced, + name, + setName, + isRootDept, + canEditParent, + parentIdValue, + setParentIdValue, + dept, + parentDisplayName, + canEditPermissions, + adminSelectValue, + setAdminSelectValue, + adminSelectValueRef, + defaultRoleIds, + assignableRoles, + setDefaultRoleIds, + applyDefaultRolesToExisting, + setApplyDefaultRolesToExisting, + handlePurge, + handleGlobalSave, + saving, + onMarkAsTenant, + onChanged, + handleDelete, + } = controller; + const selectLabels = useSelectLabels(); + return ( +
+ {isArchived && ( + + )} + + {loading &&

{t("loading", { ns: "bs" })}

} + + +
+
+

+ {t("bs:department.sectionBasic")} +

+ +
+
+ + {isSynced || isArchived ? ( + + ) : ( + setName(e.target.value)} maxLength={50} className={FORM_CONTROL_WIDTH} showCount /> + )} +
+ {!isRootDept && ( +
+ + {canEditParent ? ( + setParentIdValue(id)} + excludeSubtreePath={dept.path} + className={FORM_CONTROL_WIDTH} + placeholder={t("bs:department.selectDept")} + searchPlaceholder={t("bs:department.parentDept")} + modal={false} + /> + ) : ( + + )} +
+ )} +
+ + + {!isArchived && ( +
+
+

+ {t("bs:department.sectionPermissions")} +

+ +
+
+ + { + const v = (vals as DepartmentUserOption[]) || []; + setAdminSelectValue(v); + adminSelectValueRef.current = v; + }} + placeholder={t("bs:department.adminSelectPlaceholder")} + searchPlaceholder={t("bs:department.searchUsersPlaceholder")} + className={FORM_CONTROL_WIDTH} + // Tenant-root depts: admin assignment writes the FGA + // ``admin tenant:X`` tuple, so we constrain the picker to the + // dept's own subtree. Plain depts keep the full org tree. + rootDeptId={dept.is_tenant_root ? dept.id : undefined} + emptyMessage={dept.is_tenant_root ? t("bs:tenant.initialAdminEmptySubtree") : undefined} + /> + {dept.is_tenant_root ? ( +

+ {t("bs:tenant.initialAdminSubtreeHint")} +

+ ) : null} +
+
+ + - - - - - {t("bs:department.all")} - {t("bs:department.primary")} - {t("bs:department.secondary")} - - - )} -
- {memberScope === "dept" && ( - - )} -
- {memberScope === "org" && ( -

- {t("bs:department.globalMemberSearchHint")} -

- )} - - {memberScope === "dept" && (directAdmins.length > 0 || inheritedAdmins.length > 0) && ( -
- {directAdmins.length > 0 && ( -
- - {t("bs:department.directAdmins")} - - {directAdmins.map((admin) => ( - - {admin.user_name} - - ))} -
- )} - {inheritedAdmins.length > 0 && ( -
- - {t("bs:department.inheritedAdmins")} - - {inheritedAdmins.map((admin) => ( - - - - - {admin.user_name} - - - - {t("bs:department.inheritedAdminSource", { - department: admin.sourceDeptName, - })} - - - - ))} -
- )} -
- )} + [], + ); + const mrc = useResizableColumns(memberCols); + const memberLastCol = memberCols.length - 1; - {memberScope === "org" && ( -
- {globalLoading && globalRows.length === 0 ? ( -
- - {t("bs:department.memberEditLoading")} -
- ) : !keyword.trim() ? ( -
- {t("bs:department.globalMemberEmptyKeyword")} -
- ) : globalRows.length === 0 ? ( -
- {t("bs:department.globalMemberNoResults")} -
- ) : ( - <> - - - - {t("system.username")} - {t("bs:department.globalMemberPrimaryPath")} - - - - {globalRows.map((r) => ( - { - setMemberScope("dept") - onRequestLocateMember?.({ - primaryDeptDeptId: r.primary_department_dept_id, - userId: r.user_id, - }) - }} - > - - - {r.user_name} - {r.enabled === false && ( - - {t("bs:department.disabled")} - - )} - - - - {r.primary_department_path || "—"} - - - ))} - -
-
- -
- - )} -
- )} - - {memberScope === "dept" && ( - - - - - - {t("system.username")} - - - - {t("bs:department.memberType", "所属关系")} - - - - {t("bs:department.roles")} - - - - {t("bs:department.userGroups")} - - - - {t("bs:department.updateTime")} - - - - {t("bs:department.enabled")} - - - - {t("operations")} - - - - - {members.length === 0 ? ( - - - {t("bs:department.noMembers")} - - - ) : ( - members.map((m) => ( - - - {memberDisplayNames.get(m.user_id) ?? m.user_name} - - - - {m.is_primary === 1 - ? t("bs:department.primary") - : t("bs:department.secondary")} - - - - {(() => { - const roleNames: string[] = [] - if (m.is_department_admin) { - roleNames.push(t("bs:department.deptAdminRoleName")) - } - roleNames.push(...m.roles.map((r) => r.role_name)) - const text = roleNames.join(", ") || "-" - return ( - - -
{text}
-
- - {text} - -
- ) - })()} -
- - {(() => { - const text = - m.user_groups.map((g) => g.group_name).join(", ") || "-" - return ( - - -
{text}
-
- - {text} - -
- ) - })()} -
- - {fmtTime(m.update_time ?? m.create_time)} - - - - handleToggleEnabled(m, Boolean(checked)) - } - /> - - - {(() => { - const isSuperAdmin = m.roles.some((role) => role.id === 1) - const canResetPwd = user.role === "admin" - return ( -
- {isSuperAdmin ? ( - - ) : ( - - )} - {canResetPwd && ( - - )} -
- ) - })()} -
-
- )) - )} -
-
-
- )} - - {memberScope === "dept" && ( -
- -
- )} - - {addOpen && !isArchived && ( - setAddOpen(false)} - /> - )} - - - -
- ) + return { + memberLastCol, + memberScope, + setMemberScope, + t, + keyword, + setKeyword, + isPrimaryFilter, + setIsPrimaryFilter, + isArchived, + setAddOpen, + directAdmins, + inheritedAdmins, + globalLoading, + globalRows: displayGlobalRows, + onRequestLocateMember, + globalPage, + globalTotal, + handleGlobalPageChange, + mrc, + members, + highlightUserId, + memberDisplayNames, + fmtTime, + user, + handleToggleEnabled, + handleEditUser, + userPwdModalRef, + page, + total, + handlePageChange, + addOpen, + deptId, + deptName, + handleAdded, + editMember, + handleEditClose, + handleEditSaved, + }; +} +export function MemberTable(props: MemberTableProps) { + return ; } diff --git a/src/frontend/platform/src/pages/DepartmentPage/components/MemberTableView.tsx b/src/frontend/platform/src/pages/DepartmentPage/components/MemberTableView.tsx new file mode 100644 index 0000000000..14bb560875 --- /dev/null +++ b/src/frontend/platform/src/pages/DepartmentPage/components/MemberTableView.tsx @@ -0,0 +1,354 @@ +import { Pagination, TooltipProvider as SharedTooltipProvider } from '@bisheng/ui'; +import { Button, SearchInput } from '@bisheng/ui'; +import { Segmented, Select, Tooltip as SharedTooltip, Switch, Tag } from "@bisheng/ui"; +import { Outlined } from "bisheng-icons"; + + + +import { usePaginationLocale } from "@/hooks/usePaginationLocale"; +import { useSelectLabels } from "@/components/bs-comp/formFields/useSelectLabels"; + +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/bs-ui/table"; +import { ColumnResizeHandle } from "@/components/bs-ui/table/useResizableColumns"; + +import { cname } from "@/components/bs-ui/utils"; +import UserPwdModal from "@/pages/LoginPage/UserPwdModal"; + +import { CreateLocalMemberDialog } from "./CreateLocalMemberDialog"; +import type { useMemberTableController } from "./MemberTable"; +import { OrganizationMemberEditDialog } from "./OrganizationMemberEditDialog"; +interface MemberTableViewProps { + controller: ReturnType; +} +export function MemberTableView({ controller }: MemberTableViewProps) { + const { + memberLastCol, + memberScope, + setMemberScope, + t, + keyword, + setKeyword, + isPrimaryFilter, + setIsPrimaryFilter, + isArchived, + setAddOpen, + directAdmins, + inheritedAdmins, + globalLoading, + globalRows, + onRequestLocateMember, + globalPage, + globalTotal, + handleGlobalPageChange, + mrc, + members, + highlightUserId, + memberDisplayNames, + fmtTime, + user, + handleToggleEnabled, + handleEditUser, + userPwdModalRef, + page, + total, + handlePageChange, + addOpen, + deptId, + deptName, + handleAdded, + editMember, + handleEditClose, + handleEditSaved, + } = controller; + const paginationLocale = usePaginationLocale(); + const selectLabels = useSelectLabels(); + return ( +
+
+ setMemberScope(value as "dept" | "org")} + options={[ + { value: "dept", label: t("bs:department.memberSearchScopeCurrent") }, + { value: "org", label: t("bs:department.memberSearchScopeOrg") }, + ]} + /> + setKeyword(e.target.value)} className={"w-[200px]"} /> + {memberScope === "dept" && ( + setTenantName(e.target.value)} - maxLength={128} - className="mt-1" - /> + setTenantName(e.target.value)} maxLength={128} className={"mt-1"} showCount />
@@ -167,10 +158,10 @@ export function MountTenantDialog({
- -
) : ( -
+
- setUserName(e.target.value)} - /> + } onChange={(e) => setUserName(e.target.value)} className={"mt-1"} />
- +
{form.edit_mode !== "affiliate" && ( @@ -382,20 +346,30 @@ export function OrganizationMemberEditDialog({ {t("bs:department.memberEditUserGroupsEmpty")}

) : ( - ({ - label: g.group_name, - value: String(g.id), - }))} - lockedValues={(form.locked_group_ids ?? []).map(String)} + showSearch + className="mt-1 min-w-0 max-w-full" + aria-label={t("bs:department.userGroups")} + options={form.manageable_groups + .filter((g) => g.id != null) + .map((g) => ({ + label: g.group_name, + value: String(g.id), + // Locked groups cannot be toggled from the panel + disabled: lockedGroupIds.has(g.id), + }))} value={Array.from(groupSel).map(String)} - onChange={(vals) => - setGroupSel(new Set((vals as string[]).map((id) => Number(id)))) - } + onChange={(vals) => { + // Locked groups stay selected even if removed via a tag or clear + const next = new Set(vals.map((id) => Number(id))) + groupSel.forEach((id) => { + if (lockedGroupIds.has(id)) next.add(id) + }) + setGroupSel(next) + }} placeholder={t("bs:department.multiSelectUserGroupsPlaceholder")} - searchPlaceholder={t("system.searchUserGroups")} /> )}
@@ -404,34 +378,25 @@ export function OrganizationMemberEditDialog({ {form.can_change_primary && primaryDeptInternalId != null ? ( void handlePrimaryDeptChange(id, node)} + onChange={handleUiRequest(handlePrimaryDeptChange)} placeholder={t("bs:department.memberEditPrimaryPlaceholder")} /> ) : ( <> - - - -

+ {departmentDisplayName(form.primary_department, t("bs:department.defaultOrganization"))} +

} side="bottom" >
- {form.primary_department.name} + {departmentDisplayName(form.primary_department, t("bs:department.defaultOrganization"))} -
- - -

- {form.primary_department.name} -

-
- - +

{t("bs:department.memberEditPrimaryReadonly")}

@@ -442,7 +407,12 @@ export function OrganizationMemberEditDialog({ {form.primary_department && (
- {renderRoleMultiSelect(primaryRoleOptions, primaryRoles, setPrimaryRoles)} + {renderRoleMultiSelect( + primaryRoleOptions, + primaryRoles, + setPrimaryRoles, + t("bs:department.roles") + )}
)} {form.affiliate_rows.length > 0 && ( @@ -455,7 +425,8 @@ export function OrganizationMemberEditDialog({ form.assignable_roles_catalog[row.dept_id] ?? [], affRoleByDept[row.dept_id] ?? new Set(), (next) => - setAffRoleByDept((prev) => ({ ...prev, [row.dept_id]: next })) + setAffRoleByDept((prev) => ({ ...prev, [row.dept_id]: next })), + `${row.name} ${t("bs:department.roles")}` )}
))} @@ -470,7 +441,8 @@ export function OrganizationMemberEditDialog({ {renderRoleMultiSelect( form.assignable_roles_catalog[form.context.dept_id] ?? [], ctxRoles, - setCtxRoles + setCtxRoles, + t("bs:department.roles") )}
)} @@ -479,18 +451,13 @@ export function OrganizationMemberEditDialog({
{form?.edit_mode === "local_primary" && ( - )}
- + )} ), - [handleCreateClick, t] - ) + [handleCreateClick, t], + ); return (
@@ -154,55 +153,68 @@ export default function DepartmentPage() { onSelect={selectNode} renderRowSuffix={renderRowSuffix} rowRef={(deptId, el) => { - if (el) rowRefs.current.set(deptId, el) - else rowRefs.current.delete(deptId) + if (el) rowRefs.current.set(deptId, el); + else rowRefs.current.delete(deptId); }} /> - +
{/* Right panel */}
{selectedDept ? ( - -
-
-

- {selectedDept.name} - {selectedDept.status === "archived" ? ` ${t("bs:department.archivedTag")}` : ""} -

-
- - {t("bs:department.members")} - {t("bs:department.settings")} - -
- - - - - - -
+
+

+ {selectedDept.name} + {selectedDept.status === "archived" ? ` ${t("bs:department.archivedTag")}` : ""} +

+ {t("bs:department.members")} , + children: ( + <> + {" "} +
+ +
+ + ), + }, + { + key: "settings", + label: <> {t("bs:department.settings")} , + children: ( + <> + {" "} +
+ +
+ + ), + }, + ]} + /> +
) : (
{t("bs:department.selectDept")} @@ -229,5 +241,5 @@ export default function DepartmentPage() { /> )}
- ) + ); } diff --git a/src/frontend/platform/src/pages/DshLogin/index.tsx b/src/frontend/platform/src/pages/DshLogin/index.tsx index e0b3da1c7c..12a02c85cf 100644 --- a/src/frontend/platform/src/pages/DshLogin/index.tsx +++ b/src/frontend/platform/src/pages/DshLogin/index.tsx @@ -1,173 +1,152 @@ -import { CircleCheck, ArrowUpRight } from 'lucide-react' -import { dshLaunchUrl } from '@/utils/dshLaunch' -import { Button } from '@/components/bs-ui/button' -import { Input } from '@/components/bs-ui/input' -import { userContext } from '@/contexts/userContext' -import { useDshProfile } from '@/hooks/useDshProfile' -import { useDshBrowserConfig } from '@/hooks/useDshBrowserConfig' -import { rememberDesktopLoginReturnTo } from '@/utils/loginReturnTo' -import { useContext, useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { copyText } from '@/utils' -import { - callbackUrl, - readAuthId, - useDshAuthorization, -} from './useDshAuthorization' +import { Button, Input } from '@bisheng/ui'; +import { Outlined } from "bisheng-icons"; + + + +import { userContext } from "@/contexts/userContext"; +import { useDshBrowserConfig } from "@/hooks/useDshBrowserConfig"; +import { useDshProfile } from "@/hooks/useDshProfile"; +import { copyText } from "@/utils"; +import { dshLaunchUrl } from "@/utils/dshLaunch"; +import { rememberDesktopLoginReturnTo } from "@/utils/loginReturnTo"; +import { useContext, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { callbackUrl, readAuthId, useDshAuthorization } from "./useDshAuthorization"; export function DshLogin() { - const { t } = useTranslation() - const { user } = useContext(userContext) - const { config, failed } = useDshBrowserConfig() - const homeUrl = import.meta.env.BASE_URL - const [copyStatus, setCopyStatus] = useState<'idle' | 'copied' | 'failed'>('idle') + const { t } = useTranslation(); + const { user } = useContext(userContext); + const { config, failed } = useDshBrowserConfig(); + const homeUrl = import.meta.env.BASE_URL; + const [copyStatus, setCopyStatus] = useState<"idle" | "copied" | "failed">("idle"); useEffect(() => { - const previous = document.title - document.title = t('dsh.title') + const previous = document.title; + document.title = t("dsh.title"); return () => { - document.title = previous - } - }, [t]) - let authId: string | null = null - let invalid = false + document.title = previous; + }; + }, [t]); + let authId: string | null = null; + let invalid = false; try { - authId = readAuthId(location.search) + authId = readAuthId(location.search); } catch { - invalid = true + invalid = true; } - const flow = useDshAuthorization(authId) + const flow = useDshAuthorization(authId); useEffect(() => { - const meta = document.createElement('meta') - meta.name = 'referrer' - meta.content = 'no-referrer' - document.head.appendChild(meta) + const meta = document.createElement("meta"); + meta.name = "referrer"; + meta.content = "no-referrer"; + document.head.appendChild(meta); return () => { - meta.remove() - } - }, []) + meta.remove(); + }; + }, []); function handleLogin() { - rememberDesktopLoginReturnTo() - location.assign(homeUrl) + rememberDesktopLoginReturnTo(); + location.assign(homeUrl); } async function handleCopy(value: string) { - setCopyStatus('idle') + setCopyStatus("idle"); try { - await copyText(value) - setCopyStatus('copied') + await copyText(value); + setCopyStatus("copied"); } catch { - setCopyStatus('failed') + setCopyStatus("failed"); } } - const profile = useDshProfile(user?.user_id, !!config?.enabled) - const departmentName = profile?.department_name?.trim() - const base = location.origin - const download = config?.download_url - const completed = !invalid && !failed && config?.enabled && !!user?.user_id && flow.status === 'issued' + const profile = useDshProfile(user?.user_id, !!config?.enabled); + const departmentName = profile?.department_name?.trim(); + const base = location.origin; + const download = config?.download_url; + const completed = !invalid && !failed && config?.enabled && !!user?.user_id && flow.status === "issued"; return (
{completed ? (
-

{t('dsh.title')}

+

{t("dsh.title")}

- +
-

{t('dsh.authorizationComplete')}

-

{t('dsh.authorizationCompleteHelp')}

-

{user.user_name}{departmentName && ` · ${departmentName}`}

+

{t("dsh.authorizationComplete")}

+

{t("dsh.authorizationCompleteHelp")}

+

+ {user.user_name} + {departmentName && ` · ${departmentName}`} +

) : ( <> -

{t('dsh.title')}

+

{t("dsh.title")}

{base}

)} {invalid || failed ? ( -

{t('dsh.unavailable')}

+

{t("dsh.unavailable")}

) : !config ? ( -

{t('dsh.loading')}

+

{t("dsh.loading")}

) : !config.enabled ? ( -

{t('dsh.disabled')}

+

{t("dsh.disabled")}

) : ( <> {!authId ? ( <> -

{t('dsh.entryHelp')}

- -

- {t('dsh.installHelp')} -

+

{t("dsh.installHelp")}

{download && ( - )} - - ) : !user?.user_id ? ( - + ) : ( <> - {!completed &&

- {t(departmentName ? 'dsh.authorizeWithDepartment' : 'dsh.authorize_without_tenant', { - name: user.user_name, - department: departmentName, - })} -

} - {flow.status === 'idle' && ( + {!completed && ( +

+ {t( + departmentName + ? "dsh.authorizeWithDepartment" + : "dsh.authorize_without_tenant", + { + name: user.user_name, + department: departmentName, + }, + )} +

+ )} + {flow.status === "idle" && (
- - +
)} - {!completed &&

- {flow.status !== 'idle' && t(`dsh.auth_${flow.status}`)} -

} - {flow.status === 'issued' && ( - )} {flow.denialUrl && ( */} -
-
-
-
- - - {t('login.slogen')} -
-
-
- +
+
+ logo_picture + logo_picture + {/* */} +
+
+
+
+ -
- { - !showLogin &&
- -
- } -
- e.key === 'Enter' && showLogin && handleLogin()} + + + {t("login.slogen")} +
- { - !showLogin &&
- +
+
+
- } - { - showCaptcha && (
- - captcha + {!showLogin && ( +
+ +
+ )} +
+ e.key === "Enter" && showLogin && handleLogin()} />
- ) - } - { - showLogin ? <> - {enableDualLogin && ( - - )} -
- {!shouldUseLdap && appConfig.register && setShowLogin(false)}>{t('login.noAccountRegister')}} + {!showLogin && ( +
+ +
+ )} + {showCaptcha && ( +
+ + captcha
- - : + )} + {showLogin ? ( <> + {enableDualLogin && ( + + )} - + - } - {appConfig.isPro && } -
-
- v{json.version} - {!appConfig.noFace && } + ) : ( + <> + + + + )} + {appConfig.isPro && } +
+
+ v{json.version} + {!appConfig.noFace && ( + + )} +
-
+ ); }; - - - export const useLoginError = () => { const location = useLocation(); - const { toast } = useToast(); + const { t } = useTranslation(); useEffect(() => { const queryParams = new URLSearchParams(location.search); - const code = queryParams.get('status_code') + const code = queryParams.get("status_code"); if (code) { - toast({ - variant: 'error', - description: t('errors.' + code) - }) + notification.open({ type: "error", description: String(t("errors." + code) ?? '') }); } - }, [location]) -} + }, [location]); +}; diff --git a/src/frontend/platform/src/pages/LoginPage/loginBridge.tsx b/src/frontend/platform/src/pages/LoginPage/loginBridge.tsx index 86d370dcb2..7e3dcab64f 100644 --- a/src/frontend/platform/src/pages/LoginPage/loginBridge.tsx +++ b/src/frontend/platform/src/pages/LoginPage/loginBridge.tsx @@ -1,9 +1,10 @@ +import { Button } from '@bisheng/ui'; import Separator from "@/components/bs-comp/chatComponent/Separator"; -import { Button } from "@/components/bs-ui/button"; + import { useEffect } from "react"; -//@ts-ignore -import Wxpro from "./icons/wxpro.svg?react"; import { useTranslation } from "react-i18next"; +// @ts-ignore -- The existing SVG loader type is not declared by the app. +import Wxpro from "./icons/wxpro.svg?react"; interface LoginBridgeProps { oauthData: any; @@ -11,23 +12,32 @@ interface LoginBridgeProps { } export default function LoginBridge({ oauthData, onHasLdap }: LoginBridgeProps) { + const { t } = useTranslation(); - const { t } = useTranslation() - - const ssoUrl = oauthData?.sso || '' - const wxUrl = oauthData?.wx || '' + const ssoUrl = oauthData?.sso || ""; + const wxUrl = oauthData?.wx || ""; useEffect(() => { - if (oauthData?.ldap) onHasLdap(true) - }, [oauthData]) + if (oauthData?.ldap) onHasLdap(true); + }, [oauthData]); - if (!ssoUrl && !wxUrl) return null + if (!ssoUrl && !wxUrl) return null; - return
- -
- {ssoUrl && } - {wxUrl && } + return ( +
+ +
+ {ssoUrl && ( + + )} + {wxUrl && ( + + )} +
-
-}; + ); +} diff --git a/src/frontend/platform/src/pages/LoginPage/resetPwd.tsx b/src/frontend/platform/src/pages/LoginPage/resetPwd.tsx index 3c0d700cd4..f89c26d9d0 100644 --- a/src/frontend/platform/src/pages/LoginPage/resetPwd.tsx +++ b/src/frontend/platform/src/pages/LoginPage/resetPwd.tsx @@ -1,29 +1,28 @@ // @ts-strict-ignore -import { useToast } from "@/components/bs-ui/toast/use-toast"; -import { ArrowLeft } from "lucide-react"; +import { Button, PasswordInput, toast } from '@bisheng/ui'; + +import { Outlined } from "bisheng-icons"; + +import { getBrandAssetUrl } from "@/utils/brand"; import { useEffect, useRef } from "react"; -import { useTranslation } from 'react-i18next'; -import { useLocation, useNavigate } from 'react-router-dom'; -import { Button } from "../../components/bs-ui/button"; -import { PasswordInput } from "../../components/bs-ui/input"; +import { useTranslation } from "react-i18next"; +import { useLocation, useNavigate } from "react-router-dom"; + + import { changePasswordApi, loggedChangePasswordApi } from "../../controllers/API/user"; -import { captureAndAlertRequestErrorHoc } from "../../controllers/request"; -import { getBrandAssetUrl } from "@/utils/brand"; -import { PWD_RULE, handleEncrypt } from './utils'; +import { handleUiRequest } from "../../controllers/request"; +import { PWD_RULE, handleEncrypt } from "./utils"; export const ResetPwdPage = () => { const { t } = useTranslation(); - const { message } = useToast(); - const { state } = useLocation() - const navigate = useNavigate() + + const { state } = useLocation(); + const navigate = useNavigate(); useEffect(() => { - state?.noback && message({ - title: `${t('prompt')}`, - variant: 'warning', - description: '您的密码已过期,请及时修改' - }) - }, []) + state?.noback && + toast.show({ type: "warning", message: [String(`${t("prompt")}`), String(t("publicPages.passwordExpired") ?? '')].filter(Boolean).join('\n') }); + }, []); const currentPwdRef = useRef(null); const newPwdRef = useRef(null); @@ -34,40 +33,35 @@ export const ResetPwdPage = () => { const [currentPwd, newPwd, confirmPwd] = [ currentPwdRef.current?.value, newPwdRef.current?.value, - confirmPwdRef.current?.value + confirmPwdRef.current?.value, ]; - if (!currentPwd) errors.push(t('resetPassword.pleaseEnterCurrentPassword')); - if (!newPwd) errors.push(t('resetPassword.pleaseEnterNewPassword')); - if (!confirmPwd) errors.push(t('resetPassword.pleaseEnterConfirmPassword')); - if (!/.{8,}/.test(newPwd)) errors.push(t('resetPassword.newPasswordTooShort')); - if (!PWD_RULE.test(newPwd)) errors.push(t('login.passwordError')) - if (newPwd !== confirmPwd) errors.push(t('resetPassword.passwordMismatch')); + if (!currentPwd) errors.push(t("resetPassword.pleaseEnterCurrentPassword")); + if (!newPwd) errors.push(t("resetPassword.pleaseEnterNewPassword")); + if (!confirmPwd) errors.push(t("resetPassword.pleaseEnterConfirmPassword")); + if (!/.{8,}/.test(newPwd)) errors.push(t("resetPassword.newPasswordTooShort")); + if (!PWD_RULE.test(newPwd)) errors.push(t("login.passwordError")); + if (newPwd !== confirmPwd) errors.push(t("resetPassword.passwordMismatch")); if (errors.length) { - return message({ - title: `${t('prompt')}`, - variant: 'warning', - description: errors - }); + return toast.show({ type: "warning", message: [String(`${t("prompt")}`), errors.filter(Boolean).join('\n')].filter(Boolean).join('\n') }); } - const account = localStorage.getItem('account'); + const account = localStorage.getItem("account"); const encryptCurrentPwd = await handleEncrypt(currentPwd); const encryptNewPwd = await handleEncrypt(newPwd); - console.log(state) - const res = await captureAndAlertRequestErrorHoc(state ? changePasswordApi(account, encryptCurrentPwd, encryptNewPwd) : loggedChangePasswordApi(encryptCurrentPwd, encryptNewPwd)) + console.log(state); + const res = await (state + ? changePasswordApi(account, encryptCurrentPwd, encryptNewPwd) + : loggedChangePasswordApi(encryptCurrentPwd, encryptNewPwd) + ); if (res === null) { - message({ - title: `${t('prompt')}`, - variant: 'success', - description: [t('resetPassword.passwordResetSuccess')] - }); + toast.show({ type: "success", message: [String(`${t("prompt")}`), ([t("resetPassword.passwordResetSuccess")]).filter(Boolean).join('\n')].filter(Boolean).join('\n') }); // Clear input fields - if (currentPwdRef.current) currentPwdRef.current.value = ''; - if (newPwdRef.current) newPwdRef.current.value = ''; - if (confirmPwdRef.current) confirmPwdRef.current.value = ''; + if (currentPwdRef.current) currentPwdRef.current.value = ""; + if (newPwdRef.current) newPwdRef.current.value = ""; + if (confirmPwdRef.current) confirmPwdRef.current.value = ""; // if (!state?.noback) { navigate(-1); // } @@ -76,49 +70,36 @@ export const ResetPwdPage = () => { }; return ( -
-
- {!state?.noback && } -
+
+
+ {!state?.noback && ( + + )} +
- small_logo - {t('resetPassword.slogen')} + small_logo + + {t("resetPassword.slogen")} +
- +
- +
- +
-
diff --git a/src/frontend/platform/src/pages/MenuPermissionPlaceholder.tsx b/src/frontend/platform/src/pages/MenuPermissionPlaceholder.tsx index 8f52df978e..07538b7e73 100644 --- a/src/frontend/platform/src/pages/MenuPermissionPlaceholder.tsx +++ b/src/frontend/platform/src/pages/MenuPermissionPlaceholder.tsx @@ -1,119 +1,106 @@ +import { Button, Textarea, notification } from '@bisheng/ui'; + +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/bs-ui/dialog"; + + +import { userContext } from "@/contexts/userContext"; +import { applyMenuAccessApi } from "@/controllers/API/approval"; +import { showApiError } from "@/controllers/request"; import { useContext, useState } from "react"; -import { useLocation } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import { applyMenuAccessApi } from "@/controllers/API/approval"; -import { captureAndAlertRequestErrorHoc } from "@/controllers/request"; -import { useToast } from "@/components/bs-ui/toast/use-toast"; -import { userContext } from "@/contexts/userContext"; -import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/bs-ui/dialog"; +import { useLocation } from "react-router-dom"; /** Menu key → i18n label mapping, aligned with MainLayout sidebar */ const MENU_LABEL_KEYS: Record = { - board: "menu.board", - build: "menu.skills", - knowledge: "menu.knowledge", - model: "menu.models", - evaluation: "menu.evaluation", - mark_task: "menu.annotation", - log: "menu.log", + board: "menu.board", + build: "menu.skills", + knowledge: "menu.knowledge", + model: "menu.models", + evaluation: "menu.evaluation", + mark_task: "menu.annotation", + log: "menu.log", }; -/** 菜单无权限占位页:approval 模式展示申请按钮+理由弹窗,非 approval 模式展示无权限提示 */ export default function MenuPermissionPlaceholder() { - const { t } = useTranslation("bs"); - const location = useLocation(); - const { toast } = useToast(); - const { user } = useContext(userContext); + const { t } = useTranslation("bs"); + const location = useLocation(); - const menuKey = new URLSearchParams(location.search).get("menu") ?? ""; - const menuLabelKey = menuKey ? MENU_LABEL_KEYS[menuKey] : ""; - const menuName = menuLabelKey ? t(menuLabelKey, { defaultValue: menuKey }) : ""; - // Admin-area approval scope (legacy global flag as fallback). - const approvalEnabled = Boolean(user?.menu_approval_mode_admin ?? user?.menu_approval_mode); + const { user } = useContext(userContext); - const [showDialog, setShowDialog] = useState(false); - const [reason, setReason] = useState(""); - const [submitting, setSubmitting] = useState(false); - const [applied, setApplied] = useState(false); + const menuKey = new URLSearchParams(location.search).get("menu") ?? ""; + const menuLabelKey = menuKey ? MENU_LABEL_KEYS[menuKey] : ""; + const menuName = menuLabelKey ? t(menuLabelKey, { defaultValue: menuKey }) : ""; + // Admin-area approval scope (legacy global flag as fallback). + const approvalEnabled = Boolean(user?.menu_approval_mode_admin ?? user?.menu_approval_mode); - const handleSubmit = async () => { - if (!menuKey || submitting) return; - setSubmitting(true); - await captureAndAlertRequestErrorHoc( - applyMenuAccessApi({ menu_key: menuKey, menu_name: menuName, reason: reason.trim() || undefined }) - .then(() => { - setApplied(true); - setShowDialog(false); - setReason(""); - toast({ variant: "success", description: t("approvalPage.genericOperateSuccess") }); - }) - ); - setSubmitting(false); - }; + const [showDialog, setShowDialog] = useState(false); + const [reason, setReason] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [applied, setApplied] = useState(false); - return ( -
-
-

- {menuName - ? `「${menuName}」${t(approvalEnabled ? "approvalPage.menuNeedsApproval" : "approvalPage.menuNoPermission", { defaultValue: approvalEnabled ? "菜单需审批开通" : "暂无该菜单权限" })}` - : t(approvalEnabled ? "approvalPage.menuNeedsApproval" : "approvalPage.menuNoPermission", { defaultValue: approvalEnabled ? "当前菜单需审批开通" : "暂无该菜单权限" })} -

-

- {approvalEnabled - ? t("approvalPage.menuApprovalDesc", { defaultValue: "你的角色当前未包含该菜单权限,点击下方按钮发起申请,审批通过后即可访问。" }) - : t("approvalPage.menuNoPermissionDesc", { defaultValue: "你的角色当前未包含该菜单权限,请联系管理员开通。" })} -

- {approvalEnabled && menuKey && ( - - )} -
+ const handleSubmit = async () => { + if (!menuKey || submitting) return; + setSubmitting(true); + await applyMenuAccessApi({ menu_key: menuKey, menu_name: menuName, reason: reason.trim() || undefined }).then( + () => { + setApplied(true); + setShowDialog(false); + setReason(""); + notification.open({ type: "success", description: String(t("approvalPage.genericOperateSuccess") ?? '') }); + }, + ).catch(showApiError).finally(() => setSubmitting(false)); + }; - { if (!v) { setShowDialog(false); setReason(""); } }}> - - - {t("approvalPage.menuApply", { defaultValue: "申请菜单权限" })} - -
-